diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..2734572 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,12 @@ +{ + "permissions": { + "allow": [ + "WebFetch(domain:packagist.org)", + "Bash(composer dump-autoload *)", + "Bash(composer test *)", + "Bash(composer stan *)", + "Bash(vendor/bin/phpstan analyse *)", + "Bash(composer cs-check *)" + ] + } +} diff --git a/.gitignore b/.gitignore index d1b0b44..cf10401 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,11 @@ data/*.data # Ignore the content of the log folter logs/*.log + +# Tooling caches +.php-cs-fixer.cache +.phpunit.result.cache +.phpunit.cache + +# Local PR draft (not part of the codebase) +pr-body.md diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..a8564d1 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,21 @@ +in([__DIR__ . '/src', __DIR__ . '/cli', __DIR__ . '/includes', __DIR__ . '/tests']) + ->exclude(['vendor']); + +return (new PhpCsFixer\Config()) + ->setRiskyAllowed(true) + ->setIndent("\t") + ->setLineEnding("\n") + ->setRules([ + '@PSR12' => true, + 'declare_strict_types' => true, + 'no_unused_imports' => true, + 'ordered_imports' => ['sort_algorithm' => 'alpha'], + 'single_quote' => true, + 'trailing_comma_in_multiline' => ['elements' => ['arrays']], + ]) + ->setFinder($finder); diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2d56fa8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,46 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Setup & Run + +- PHP 8.5+ required +- Install dependencies: `composer install` +- Configure: `cp includes/constants.dist.php includes/constants.php` and fill in values +- Run the bot: `php cli/jgerman-github-bot.php` (intended to be triggered by a daily cron) + +## Tooling + +- `composer test` — PHPUnit suite (`tests/Unit/`) +- `composer stan` — PHPStan at `level: max` +- `composer cs-check` / `composer cs-fix` — PHP-CS-Fixer (PSR-12 + tab indent) + +## Architecture + +This is a single-purpose cron-driven PHP CLI bot. It mirrors merged language-changing PRs from one GitHub repo into translation-request issues on another, then notifies chat channels. + +**Entry point flow** (`cli/jgerman-github-bot.php`): +1. Loads `includes/constants.php` (defines `ROOT_PATH` and all `GITHUB_*` / `NOTIFYER_*` config) and Composer autoload. +2. Instantiates `joomlagerman\Helper\Bootstrap`, which wires the configured constants into a typed Registry and constructs the three helpers (`github`, `log`, `notifier`). +3. Reads `data/lastrun.data`. If today's date matches, exits early — **the script self-enforces "run once per day"**, so multiple cron firings on the same day are a no-op. +4. Pulls closed issues since `lastrun` from the source repo with the watch label, filters to those actually merged, creates a translation-request issue in the translation repo for each, fires a notification per created issue, then writes today's date back to `lastrun.data`. + +**Helpers** (`src/`, PSR-4 namespace `joomlagerman\Helper\`): +- `Bootstrap` — composes the three helpers from `includes/constants.php` so the CLI script stays thin and the wiring is testable. +- `GithubApiHelper` — wraps `Joomla\Github\Github`. Owns the lastrun + lastrelease state files in `data/`. Key non-obvious behaviors: + - `getClosedAndMergedTranslationIssuesList()` filters by `closed_at == $since` (date-only) to avoid re-processing issues that just had a comment update. + - `createNewTranslationRequestIssueFromMergedTranslationIssue()` fetches the raw diff via a hand-rolled HTTP call (the Joomla GitHub package can't request the `application/vnd.github.v3.diff` Accept header) and embeds it inside `
`. If issue creation throws (typically because the diff body is too large), it retries once **without** the diff. The auth token is pulled from the options Registry (`github.authtoken`), not a global constant. + - Branch-label mapping is delegated to `joomlagerman\Enum\JoomlaBranch::labelFor()`. Joomla 5+ caps minors at `.4-dev` before bumping the major (`5.1-dev` … `5.4-dev`, then `6.0-dev`), but Joomla 3 and 4 had multi-digit minors (`3.10-dev`, `4.10-dev`) — the parser handles both forms. + - PRs authored by `joomla-translation-bot` are skipped to avoid loops. + - `pickLatestTag()` is a pure static helper (extracted for testability) that selects the highest `version_compare` tag matching a given `major.minor` branch. +- `NotifyerHelper` — fan-out to Slack, Mattermost, and Telegram via `joomlagerman\Enum\NotificationChannel`. Each channel case carries its own `isEnabled()`/`endpoint()`/`payload()` logic. Accepts an injected `Joomla\Http\Http` for testing; defaults to `(new HttpFactory())->getHttp()`. +- `LogHelper` — appends to `logs/YYYYMM_jgerman.log`. + +**Persistent state** lives in `data/*.data` (gitignored): `lastrun.data` for the daily guard, `lastrelease.data` for release tracking helpers. Deleting `lastrun.data` is the way to force a same-day re-run. + +## Conventions + +- PHP 8.5+ platform target (`composer.json`); use modern idioms (`readonly`, constructor property promotion, `final`, `match`, enums, strict types). +- All PHP files start with `declare(strict_types=1);`. +- Tab indentation, LF line endings, final newline (`.editorconfig` + `.php-cs-fixer.dist.php`). +- `includes/constants.php` is gitignored — never commit it. Update `includes/constants.dist.php` (and `tools/phpstan-stubs/constants.stub.php` so static analysis sees the constant) whenever a new constant is introduced. diff --git a/README.md b/README.md index 066223f..8717583 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,16 @@ J!German Bot This repo holds the code powering automation for the JGerman Team. As of today it has the following feature: Autocreate issues to the [joomlagerman/joomla](https://github.com/joomlagerman/joomla) repo when an language changing PR at the [joomla/joomla-cms](https://github.com/joomla/joomla-cms) repo got merged (using [@jgerman-bot](https://github.com/jgerman-bot)) +## Requirements + +- PHP 8.5 or higher +- Composer + ## Initial Setup - `cd /repo/path/jgerman-bot` - `git clone git@github.com:joomlagerman/jgerman-bot.git .` +- `composer install --no-dev` - `cp includes/constants.dist.php includes/constants.php` - `nano includes/constants.php` (Add the access data) - Setup an daily cronjob on this script: `php cli/jgerman-github-bot.php` @@ -17,6 +23,23 @@ This repo holds the code powering automation for the JGerman Team. As of today i - `cd /repo/path/jgerman-bot` - `git reset --hard HEAD && git pull origin master` +- `composer install --no-dev` + +## Upgrade Notes + +The `NOTIFYER_*_ENABED` constants were renamed to `NOTIFYER_*_ENABLED` (typo fix). +After pulling the new version, update your existing `includes/constants.php`: + +- `NOTIFYER_SLACK_ENABED` → `NOTIFYER_SLACK_ENABLED` +- `NOTIFYER_MATTERMOST_ENABED` → `NOTIFYER_MATTERMOST_ENABLED` +- `NOTIFYER_TELEGRAM_ENABED` → `NOTIFYER_TELEGRAM_ENABLED` + +## Development + +- Run tests: `composer test` +- Run static analysis: `composer stan` +- Check code style: `composer cs-check` +- Auto-fix code style: `composer cs-fix` ## Configuration @@ -69,7 +92,7 @@ Holds a template of the body to be posted by the bot. This string allows the fol - `[sourcePullDiff]` => The full diff of the original PR via an `
` tab ### Notifyer Config -#### NOTIFYER_SLACK_ENABED +#### NOTIFYER_SLACK_ENABLED True or False whether the Slack notification should be anabled @@ -81,7 +104,7 @@ The webhook URL pointing to the slack channel where the message shoud be send to THe Slack username the message should be send as -#### NOTIFYER_MATTERMOST_ENABED +#### NOTIFYER_MATTERMOST_ENABLED True or False whether the Slack notification should be anabled @@ -89,7 +112,7 @@ True or False whether the Slack notification should be anabled The webhook URL pointing to the mattermost channel where the message should be send to -#### NOTIFYER_TELEGRAM_ENABED +#### NOTIFYER_TELEGRAM_ENABLED True or False whether the Slack notification should be anabled diff --git a/cli/jgerman-github-bot.php b/cli/jgerman-github-bot.php index 3824c6d..5d1ef46 100644 --- a/cli/jgerman-github-bot.php +++ b/cli/jgerman-github-bot.php @@ -1,4 +1,7 @@ log->writeLogMessage('Start JGerman GitHub Bot'); -$logHelper->writeLogMessage('Start JGerman GitHub Bot'); -$notifierHelper->sendLogNotification('Start JGerman GitHub Bot'); +$currentRunDateTime = new DateTimeImmutable('now'); +$lastRunDate = $services->github->getLatestRunDateTime(); -$currentRunDateTime = new DateTime('now'); -$lastRunDate = $githubApiHelper->getLatestRunDateTime(); +// Self-enforced once-per-day guard. +if ($currentRunDateTime->format('Y-m-d') === $lastRunDate->format('Y-m-d')) { + $services->log->writeLogMessage('We only run once a day so exiting here.'); + $services->log->writeLogMessage('End JGerman GitHub Bot'); -// Make sure we only run once a day -if ($currentRunDateTime->format('Y-m-d') === $lastRunDate->format('Y-m-d')) -{ - $logHelper->writeLogMessage('We only run once a day so exiting here.'); - $notifierHelper->sendLogNotification('We only run once a day so exiting here.'); - $logHelper->writeLogMessage('End JGerman GitHub Bot'); - $notifierHelper->sendLogNotification('End JGerman GitHub Bot'); + $summary->setStatus(RunStatus::Noop); + $summary->addLine('Already ran today — skipped.'); + $services->notifier->sendRunSummary($summary); exit; } -$closedTranslationIssues = $githubApiHelper->getClosedAndMergedTranslationIssuesList($lastRunDate); +$closedTranslationIssues = $services->github->getClosedAndMergedTranslationIssuesList($lastRunDate); +$closedCount = count($closedTranslationIssues); -$logHelper->writeLogMessage('We have ' . count($closedTranslationIssues) . ' closed translation issues since the last run.'); -$notifierHelper->sendLogNotification('We have ' . count($closedTranslationIssues) . ' closed translation issues since the last run.'); +$services->log->writeLogMessage('We have ' . $closedCount . ' closed translation issues since the last run.'); +$summary->addLine('Closed translation issues since last run: ' . $closedCount); -if (!empty($closedTranslationIssues) || !is_array($closedTranslationIssues)) -{ - $createdTranslationRequestIssues = 0; +$createdTranslationRequestIssues = 0; - // We have issues to check - foreach ($closedTranslationIssues as $translationIssue) - { - $createdIssue = $githubApiHelper->createNewTranslationRequestIssueFromMergedTranslationIssue($translationIssue); +foreach ($closedTranslationIssues as $translationIssue) { + $createdIssue = $services->github->createNewTranslationRequestIssueFromMergedTranslationIssue($translationIssue); - if (!$createdIssue) - { - continue; - } - - $notifierHelper->sendMessageTemplateNotification([ - 'title' => $createdIssue->title, - 'issueUrl' => $createdIssue->html_url, - ] - ); - $createdTranslationRequestIssues++; + if ($createdIssue === null) { + continue; } - $logHelper->writeLogMessage('We have ' . $createdTranslationRequestIssues . ' translation request issues created.'); - $notifierHelper->sendLogNotification('We have ' . $createdTranslationRequestIssues . ' translation request issues created.'); + /** @var object{title: string, html_url: string} $createdIssue */ + $summary->addCreatedIssue($createdIssue->title, $createdIssue->html_url); + $createdTranslationRequestIssues++; +} + +if ($closedCount > 0) { + $services->log->writeLogMessage('We have ' . $createdTranslationRequestIssues . ' translation request issues created.'); + $summary->addLine('Created translation requests: ' . $createdTranslationRequestIssues); } -$logHelper->writeLogMessage('Set the new latest run date to: ' . $currentRunDateTime->format('Y-m-d')); -$notifierHelper->sendLogNotification('Set the new latest run date to: ' . $currentRunDateTime->format('Y-m-d')); -$githubApiHelper->setLatestRunDateTime($currentRunDateTime); -$logHelper->writeLogMessage('End JGerman GitHub Bot'); -$notifierHelper->sendLogNotification('End JGerman GitHub Bot'); +$services->log->writeLogMessage('Set the new latest run date to: ' . $currentRunDateTime->format('Y-m-d')); +$summary->addLine('Last run date set to: ' . $currentRunDateTime->format('Y-m-d')); +$services->github->setLatestRunDateTime($currentRunDateTime); + +$services->log->writeLogMessage('End JGerman GitHub Bot'); +$services->notifier->sendRunSummary($summary); diff --git a/composer.json b/composer.json index f836e12..08397dd 100644 --- a/composer.json +++ b/composer.json @@ -6,16 +6,37 @@ "config": { "optimize-autoloader": true, "platform": { - "php": "7.3.0" + "php": "8.5.0" } }, "require": { - "joomla/github": "^1.7", - "joomla/http": "~1.3" + "php": "^8.5", + "joomla/github": "^4.0", + "joomla/http": "^4.0", + "joomla/registry": "^4.0", + "joomla/uri": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "phpstan/phpstan": "^2.0", + "friendsofphp/php-cs-fixer": "^3.60" }, "autoload": { "psr-4": { - "joomlagerman\\Helper\\": "src/" + "joomlagerman\\Helper\\": "src/", + "joomlagerman\\Enum\\": "src/Enum/", + "joomlagerman\\Notification\\": "src/Notification/" } + }, + "autoload-dev": { + "psr-4": { + "joomlagerman\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit", + "stan": "phpstan analyse", + "cs-check": "php-cs-fixer fix --dry-run --diff", + "cs-fix": "php-cs-fixer fix" } } diff --git a/includes/constants.dist.php b/includes/constants.dist.php index 0f28ce3..9b8d047 100644 --- a/includes/constants.dist.php +++ b/includes/constants.dist.php @@ -1,4 +1,7 @@ set('api.username', GITHUB_USERNAME); -$githubOptions->set('headers', ['Authorization' => 'token ' . GITHUB_AUTHTOKEN]); - -$options = new Registry; -$options->set('source.owner', GITHUB_SOURCE_OWNER); -$options->set('source.repo', GITHUB_SOURCE_REPO); -$options->set('source.watchlabel', GITHUB_SOURCE_WATCHLABEL); -$options->set('translation.owner', GITHUB_TRANSLATION_OWNER); -$options->set('translation.repo', GITHUB_TRANSLATION_REPO); -$options->set('translation.label', GITHUB_TRANSLATION_LABEL); -$options->set('translation.assigments', GITHUB_TRANSLATION_ASSIGMENTS); -$options->set('translation.templagebody', GITHUB_TRANSLATION_TEMPLATE_BODY); - -$githubApiHelper = new GithubApiHelper($githubOptions, $options); - -// LogHelper Setup -$logHelper = new LogHelper(['logName' => 'jgerman']); - -// Notifyer Setup -$notifyerOptions = new Registry; -$notifyerOptions->set('slack.enabled', NOTIFYER_SLACK_ENABED); -$notifyerOptions->set('slack.webhookurl', NOTIFYER_SLACK_WEBHOOKURL); -$notifyerOptions->set('slack.username', NOTIFYER_SLACK_USERNAME); -$notifyerOptions->set('mattermost.enabled', NOTIFYER_MATTERMOST_ENABED); -$notifyerOptions->set('mattermost.webhookurl', NOTIFYER_MATTERMOST_WEBHOOKURL); -$notifyerOptions->set('telegram.enabled', NOTIFYER_TELEGRAM_ENABED); -$notifyerOptions->set('telegram.botToken', NOTIFYER_TELEGRAM_BOTTOKEN); -$notifyerOptions->set('telegram.chatId', NOTIFYER_TELEGRAM_CHATID); -$notifyerOptions->set('notifyer.messageTemplate', NOTIFYER_GITHUB_ISSUE_MESSAGE_TEMPLATE); - -$notifierHelper = new NotifyerHelper($notifyerOptions); diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..71cb1fc --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,8 @@ +parameters: + level: max + paths: + - src + - cli + bootstrapFiles: + - tests/bootstrap.php + - tools/phpstan-stubs/constants.stub.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..b35407f --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,19 @@ + + + + + tests/Unit + + + + + src + + + diff --git a/src/Bootstrap.php b/src/Bootstrap.php new file mode 100644 index 0000000..4927e1a --- /dev/null +++ b/src/Bootstrap.php @@ -0,0 +1,53 @@ +set('api.username', GITHUB_USERNAME); + $githubOptions->set('headers', ['Authorization' => 'token ' . GITHUB_AUTHTOKEN]); + + $options = new Registry(); + $options->set('source.owner', GITHUB_SOURCE_OWNER); + $options->set('source.repo', GITHUB_SOURCE_REPO); + $options->set('source.watchlabel', GITHUB_SOURCE_WATCHLABEL); + $options->set('translation.owner', GITHUB_TRANSLATION_OWNER); + $options->set('translation.repo', GITHUB_TRANSLATION_REPO); + $options->set('translation.label', GITHUB_TRANSLATION_LABEL); + $options->set('translation.assigments', GITHUB_TRANSLATION_ASSIGMENTS); + $options->set('translation.templagebody', GITHUB_TRANSLATION_TEMPLATE_BODY); + $options->set('github.authtoken', GITHUB_AUTHTOKEN); + + $notifyerOptions = new Registry(); + $notifyerOptions->set('slack.enabled', NOTIFYER_SLACK_ENABLED); + $notifyerOptions->set('slack.webhookurl', NOTIFYER_SLACK_WEBHOOKURL); + $notifyerOptions->set('slack.username', NOTIFYER_SLACK_USERNAME); + $notifyerOptions->set('mattermost.enabled', NOTIFYER_MATTERMOST_ENABLED); + $notifyerOptions->set('mattermost.webhookurl', NOTIFYER_MATTERMOST_WEBHOOKURL); + $notifyerOptions->set('telegram.enabled', NOTIFYER_TELEGRAM_ENABLED); + $notifyerOptions->set('telegram.botToken', NOTIFYER_TELEGRAM_BOTTOKEN); + $notifyerOptions->set('telegram.chatId', NOTIFYER_TELEGRAM_CHATID); + + $this->github = new GithubApiHelper($githubOptions, $options); + $this->log = new LogHelper('jgerman'); + $this->notifier = new NotifyerHelper($notifyerOptions); + } +} diff --git a/src/Enum/JoomlaBranch.php b/src/Enum/JoomlaBranch.php new file mode 100644 index 0000000..86e2fda --- /dev/null +++ b/src/Enum/JoomlaBranch.php @@ -0,0 +1,28 @@ +get($this->value . '.enabled') === true; + } + + public function endpoint(Registry $options): string + { + return match ($this) { + self::Slack, self::Mattermost => self::stringOption($options, $this->value . '.webhookurl'), + self::Telegram => 'https://api.telegram.org/bot' + . self::stringOption($options, 'telegram.botToken') . '/sendMessage', + }; + } + + /** + * @return array + */ + public function summaryPayload(RunSummary $summary, Registry $options): array + { + return match ($this) { + self::Slack => [ + 'payload' => (string) json_encode(self::slackBody( + $summary, + self::stringOption($options, 'slack.username') + )), + ], + self::Mattermost => [ + 'payload' => (string) json_encode(self::slackBody($summary, '')), + ], + self::Telegram => [ + 'chat_id' => self::stringOption($options, 'telegram.chatId'), + 'parse_mode' => 'HTML', + 'disable_web_page_preview' => 'true', + 'text' => self::telegramBody($summary), + ], + }; + } + + /** + * Slack/Mattermost share the legacy `attachments` schema with `color`, `title`, `text`. + * + * @return array + */ + private static function slackBody(RunSummary $summary, string $username): array + { + $lines = $summary->lines(); + + foreach ($summary->createdIssues() as $issue) { + $lines[] = '• <' . $issue['url'] . '|' . $issue['title'] . '>'; + } + + $attachment = [ + 'color' => $summary->status()->color(), + 'title' => 'JGerman GitHub Bot', + 'text' => implode("\n", $lines), + 'fallback' => 'JGerman GitHub Bot run', + ]; + + $body = ['attachments' => [$attachment]]; + + if ($username !== '') { + $body['username'] = $username; + } + + return $body; + } + + private static function telegramBody(RunSummary $summary): string + { + $lines = ['JGerman GitHub Bot']; + + foreach ($summary->lines() as $line) { + $lines[] = htmlspecialchars($line, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } + + foreach ($summary->createdIssues() as $issue) { + $lines[] = '• ' + . htmlspecialchars($issue['title'], ENT_QUOTES | ENT_HTML5, 'UTF-8') + . ''; + } + + return implode("\n", $lines); + } + + private static function stringOption(Registry $options, string $key): string + { + $value = $options->get($key); + + return is_string($value) ? $value : ''; + } +} diff --git a/src/GithubApiHelper.php b/src/GithubApiHelper.php index a7a69de..61f49a1 100644 --- a/src/GithubApiHelper.php +++ b/src/GithubApiHelper.php @@ -1,4 +1,7 @@ options = $options ?: new Registry; + $this->options = $options; - // Setup the default user agent if not already set. - if (!$this->getOption('userAgent')) - { - $this->setOption('userAgent', 'JGerman-Bot/1.0'); + if ($this->options->get('userAgent') === null) { + $this->options->set('userAgent', 'JGerman-Bot/1.0'); } - $this->github = new Github($githubOptions); + $this->github = new Github($githubOptions); $this->dataRootPath = ROOT_PATH . '/data/'; } - /** - * Returns the latest run date for the item - * - * @return DateTime A DateTime Object with the latest run date - * - * @since 1.0 - */ - public function getLatestRunDateTime(): \DateTime + public function getOption(string $key): mixed + { + return $this->options->get($key); + } + + public function getLatestRunDateTime(): DateTimeImmutable { $dataFileName = $this->getDateFileName('lastrun.data'); - // When there is no file create one with an empty date so it is now. - if (!is_file($dataFileName)) - { - $now = new \DateTime('now'); + if (!is_file($dataFileName)) { + $now = new DateTimeImmutable('now'); file_put_contents($dataFileName, $now->format('Y-m-d')); } - return new \DateTime(file_get_contents($dataFileName)); + $contents = file_get_contents($dataFileName); + + return new DateTimeImmutable($contents !== false ? $contents : 'now'); } - /** - * Sets the latest run date to the given value - * - * @param DateTime $lastRunDateTime The last run DateTime - * - * @return void - * - * @since 1.0 - */ - public function setLatestRunDateTime($lastRunDateTime): void + public function setLatestRunDateTime(DateTimeInterface $lastRunDateTime): void { $dataFileName = $this->getDateFileName('lastrun.data'); - if (is_file($dataFileName)) - { + if (is_file($dataFileName)) { unlink($dataFileName); } @@ -101,77 +68,30 @@ public function setLatestRunDateTime($lastRunDateTime): void } /** - * Returns the file name to save the latest run DataTime - * - * @param string $fileName The data filename to use - * - * @return string The dataFile path - * - * @since 1.0 - */ - private function getDateFileName($fileName): string - { - return $this->dataRootPath . $fileName; - } - - /** - * Get an option from the instance. - * - * @param string $key The name of the option to get. - * - * @return mixed The option value. - * - * @since 1.0 - */ - public function getOption($key) - { - return isset($this->options[$key]) ? $this->options[$key] : null; - } - - /** - * Set an option for the instance. - * - * @param string $key The name of the option to set. - * @param mixed $value The option value to set. + * Get all closed and merged PRs with the translation label since a given timestamp. * - * @return GithubApiHelper This object for method chaining. - * - * @since 1.0 + * @return list */ - public function setOption($key, $value) + public function getClosedAndMergedTranslationIssuesList(DateTimeInterface $since): array { - $this->options[$key] = $value; - - return $this; - } + $closedAndMerged = []; - /** - * Get all closed and merged PRs with the translation label since a given timestamp - * - * @param DateTime $since The timestamp since we want new data - * - * @return array An array of github Issue objects - * - * @since 1.0 - */ - public function getClosedAndMergedTranslationIssuesList($since): array - { - // Get all closed issues with the translation label + /** @var iterable $closedIssues */ $closedIssues = $this->getClosedTranslationIssuesList($since); - $closedAndMerged = []; - foreach ($closedIssues as $issue) - { - $closedAt = new \DateTime($issue->closed_at); + foreach ($closedIssues as $issue) { + $closedAt = new DateTimeImmutable($issue->closed_at); - // Make sure only the closedAt date is checked and we ignore any additional comments or other updates to the issue - if ($closedAt->format('Y-m-d') !== $since->format('Y-m-d')) - { + // Only the closedAt date — ignore comment/update bumps. + if ($closedAt->format('Y-m-d') !== $since->format('Y-m-d')) { continue; } - if ($this->github->pulls->isMerged($this->getOption('source.owner'), $this->getOption('source.repo'), $issue->number)) - { + if ($this->github->pulls->isMerged( + $this->optString('source.owner'), + $this->optString('source.repo'), + $issue->number + )) { $closedAndMerged[] = $issue; } } @@ -179,295 +99,271 @@ public function getClosedAndMergedTranslationIssuesList($since): array return $closedAndMerged; } - /** - * Get all closed issues with the translation label since a given timestamp - * - * @param DateTime $since The timestamp since we want new data - * - * @return array an array of github Issue objects - * - * @since 1.0 - */ - private function getClosedTranslationIssuesList($since) + public function createNewTranslationRequestIssueFromMergedTranslationIssue(object $sourceTranslationIssue): ?object { - // List all closed issues with the watchlabel - $state = 'closed'; - $labels = urlencode($this->getOption('source.watchlabel')); + /** @var object{number: int, title: string} $sourceTranslationIssue */ + $labels = [$this->optString('translation.label')]; + $sourcePull = $this->getSourcePull($sourceTranslationIssue->number); - return $this->github->issues->getListByRepository( - $this->getOption('source.owner'), $this->getOption('source.repo'), NULL, $state, NULL, NULL, $labels, NULL, NULL, $since - ); + /** @var object{base: object{ref: string}, user: object{login: string}, _links: object{html: object{href: string}}} $sourcePull */ + $labels[] = JoomlaBranch::labelFor($sourcePull->base->ref); + $assignments = $this->optAssignments(); + + // Add label per top-level directory of changed translation files. + foreach ($this->getChangedTranslationFilesByPR($sourceTranslationIssue->number) as $filename) { + $path = explode('/', $filename); + + if (in_array($path[0], ['administrator', 'api', 'installation'], true)) { + $labels[] = $path[0]; + } + + if ($path[0] === 'language') { + $labels[] = 'site'; + } + } + + $labels = array_values(array_unique($labels)); + + // Skip echoes from the joomla-translation-bot to avoid loops. + if ($sourcePull->user->login === 'joomla-translation-bot') { + return null; + } + + $body = $this->buildIssueBody($sourcePull, $this->getSourcePullDiff($sourceTranslationIssue->number)); + + try { + return $this->github->issues->create( + $this->optString('translation.owner'), + $this->optString('translation.repo'), + $sourceTranslationIssue->title, + $body, + null, + null, + $labels, + $assignments + ); + } catch (\Exception) { + // Retry without the diff — large diffs can blow the 65k issue body limit. + return $this->github->issues->create( + $this->optString('translation.owner'), + $this->optString('translation.repo'), + $sourceTranslationIssue->title, + $this->buildIssueBody($sourcePull, ''), + null, + null, + $labels, + $assignments + ); + } } - /** - * Get an pull from the source github owner/repo - * - * @param string $pullrequestId The pullrequest id - * - * @return object - * - * @since 1.0 - */ - private function getSourcePull($pullrequestId) + public function getLatestGithubRelease(): object { - return $this->github->pulls->get($this->getOption('source.owner'), $this->getOption('source.repo'), $pullrequestId); + return $this->github->repositories->releases->getLatest( + $this->optString('translation.owner'), + $this->optString('translation.repo') + ); } - /** - * Get the diff for the pullrequest - * - * @param string $pullrequestId The pullrequest id - * - * @return object - * - * @link https://developer.github.com/v3/pulls/#get-a-single-pull-request - * @link https://developer.github.com/v3/media/#commits-commit-comparison-and-pull-requests - * - * @since 1.0 - */ - private function getSourcePullDiff($pullrequestId) + public function getLatestGithubReleaseByBranch(string $branch): ?object { - $uri = new Uri( - 'https://api.github.com/repos/' - . $this->getOption('source.owner') - . '/' - . $this->getOption('source.repo') - . '/pulls/' - . (int) $pullrequestId + /** @var iterable $last5releases */ + $last5releases = $this->github->repositories->releases->getList( + $this->optString('translation.owner'), + $this->optString('translation.repo'), + 0, + 5 ); - return HttpFactory::getHttp()->get($uri->toString(), ['Accept' => 'application/vnd.github.v3.diff', 'User-Agent' => $this->getOption('userAgent'), 'Authorization' => 'token ' . GITHUB_AUTHTOKEN])->body; + $tagNames = []; + + foreach ($last5releases as $tagName => $_release) { + $tagNames[] = (string) $tagName; + } + + $latestTag = self::pickLatestTag($tagNames, $branch); + + if ($latestTag === null) { + return null; + } + + return $this->github->repositories->releases->getByTag( + $this->optString('translation.owner'), + $this->optString('translation.repo'), + $latestTag + ); } /** - * Get changed files by PR - * - * @param string $pullrequestId The pullrequest id + * Pure helper for selecting the highest-version tag matching a "major.minor" branch. * - * @return object - * - * @link https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#list-pull-requests-files - * - * @since 1.0 + * @param list $tagNames */ - private function getChangedTranslationFilesByPR($pullrequestId) + public static function pickLatestTag(array $tagNames, string $branch): ?string { - $changedFilesByPR = $this->github->pulls->getFiles($this->getOption('source.owner'), $this->getOption('source.repo'), $pullrequestId); + $candidates = array_values(array_filter( + $tagNames, + static function (string $tag) use ($branch): bool { + $parts = explode('.', $tag); - $translationFiles = []; + if (count($parts) < 2) { + return false; + } - foreach ($changedFilesByPR as $key => $value) - { - if (strpos($value->filename, 'language/') !== false) - { - $translationFiles[] = $value->filename; + return $parts[0] . '.' . $parts[1] === $branch; } + )); + + if ($candidates === []) { + return null; } - return $translationFiles; + usort($candidates, static fn (string $a, string $b): int => version_compare($b, $a)); + + return $candidates[0]; } - /** - * Creates an translation request issue - * - * @param object $sourceTranslationIssue The sourceTranslationIssue Object - * - * @return boolean True on success false on failiure - * - * @since 1.0 - */ - public function createNewTranslationRequestIssueFromMergedTranslationIssue($sourceTranslationIssue) + public function getLatestPublishedRelease(string $branch): string { - // Labels - $labels[] = $this->getOption('translation.label'); + $dataFileName = $this->getDateFileName('lastrelease' . $branch . '.data'); - $sourcePull = $this->getSourcePull($sourceTranslationIssue->number); - $labels[] = $this->getTranslationTargetBranchLabel($sourcePull->base->ref); - $assigments = $this->getOption('translation.assigments') ?: []; + if (!is_file($dataFileName)) { + file_put_contents($dataFileName, $branch . '.0.0'); + } - // Add label within which client(s) the files are changed - $changedTranslationFiles = $this->getChangedTranslationFilesByPR($sourceTranslationIssue->number); + $contents = file_get_contents($dataFileName); - foreach ($changedTranslationFiles as $key => $filename) - { - $path = explode('/', $filename); + return $contents !== false ? trim($contents) : $branch . '.0.0'; + } - if (\in_array($path[0], ['administrator', 'api', 'installation'])) - { - $labels[] = (string) $path[0]; - } + public function setLatestPublishedRelease(string $branch, string $lastPublishedRelease): void + { + $dataFileName = $this->getDateFileName('lastrelease' . $branch . '.data'); - if (\in_array($path[0], ['language'])) - { - $labels[] = 'site'; - } + if (is_file($dataFileName)) { + unlink($dataFileName); } - // Make sure the lables are unique - $labels = array_values(array_unique($labels)); + file_put_contents($dataFileName, $lastPublishedRelease); + } - $sourcePullDiff = $this->getSourcePullDiff($sourceTranslationIssue->number); - $sourcePullDiffText = PHP_EOL . '
' . PHP_EOL . 'Click to expand the diff!' . PHP_EOL . PHP_EOL . - '```diff' . PHP_EOL . $sourcePullDiff . PHP_EOL . '```' . PHP_EOL . '
' . PHP_EOL; + private function buildIssueBody(object $sourcePull, string $sourcePullDiff): string + { + /** @var object{_links: object{html: object{href: string}}} $sourcePull */ + $diffSection = $sourcePullDiff !== '' + ? PHP_EOL . '
' . PHP_EOL . 'Click to expand the diff!' . PHP_EOL . PHP_EOL + . '```diff' . PHP_EOL . $sourcePullDiff . PHP_EOL . '```' . PHP_EOL . '
' . PHP_EOL + : ''; - $body = $this->getOption('translation.templagebody'); + $body = $this->optString('translation.templagebody'); $body = str_replace('[sourcePullRequestUrl]', $sourcePull->_links->html->href, $body); - $body = str_replace('[sourcePullDiff]', $sourcePullDiffText, $body); - - // Create the issue in the translation owner/repo but skip PRs from the joomla-translation-bot - if ($sourcePull->user->login !== 'joomla-translation-bot') - { - try - { - return $this->github->issues->create( - $this->getOption('translation.owner'), - $this->getOption('translation.repo'), - $sourceTranslationIssue->title, - $body, - NULL, - NULL, - $labels, - $assigments - ); - } - catch (\Exception $e) - { - // Try to catch the error by sending the request without the source diff that could couse issues - $body = $this->getOption('translation.templagebody'); - $body = str_replace('[sourcePullRequestUrl]', $sourcePull->_links->html->href, $body); - $body = str_replace('[sourcePullDiff]', '', $body); - - return $this->github->issues->create( - $this->getOption('translation.owner'), - $this->getOption('translation.repo'), - $sourceTranslationIssue->title, - $body, - NULL, - NULL, - $labels, - $assigments - ); - } - } + return str_replace('[sourcePullDiff]', $diffSection, $body); } - /** - * Returns the correct target label for a given target branch - * - * @param string $targetBranch The branch target of the source repo - * - * @return string The label - * - * @since 1.0 - */ - private function getTranslationTargetBranchLabel($targetBranch) + private function getClosedTranslationIssuesList(DateTimeInterface $since): mixed { - if ($targetBranch === '3.10-dev') - { - return 'Joomla! 3.10'; - } - return 'Joomla! ' . substr($targetBranch, 0, 3); + $labels = urlencode($this->optString('source.watchlabel')); + + return $this->github->issues->getListByRepository( + $this->optString('source.owner'), + $this->optString('source.repo'), + null, + 'closed', + null, + null, + $labels, + null, + null, + $since + ); + } + + private function getSourcePull(int $pullrequestId): object + { + return $this->github->pulls->get( + $this->optString('source.owner'), + $this->optString('source.repo'), + $pullrequestId + ); } /** - * Returns the latest release information - * - * @param string $targetBranch The branch target of the source repo + * Fetch the raw diff for a PR — the Joomla GitHub package can't request the diff Accept header, + * so we issue a manual HTTP call. The token is read from the options Registry, not a global. * - * @return string The label - * - * @since 1.0 + * @link https://developer.github.com/v3/pulls/#get-a-single-pull-request + * @link https://developer.github.com/v3/media/#commits-commit-comparison-and-pull-requests */ - public function getLatestGithubRelease() + private function getSourcePullDiff(int $pullrequestId): string { - return $this->github->repositories->releases->getLatest($this->getOption('translation.owner'), $this->getOption('translation.repo')); + $uri = new Uri( + 'https://api.github.com/repos/' + . $this->optString('source.owner') + . '/' + . $this->optString('source.repo') + . '/pulls/' + . $pullrequestId + ); + + $headers = [ + 'Accept' => 'application/vnd.github.v3.diff', + 'User-Agent' => $this->optString('userAgent'), + 'Authorization' => 'token ' . $this->optString('github.authtoken'), + ]; + + $response = (new HttpFactory())->getHttp()->get($uri->toString(), $headers); + + return (string) $response->getBody(); } /** - * Returns the latest release information by branch - * - * @param string $targetBranch The branch target of the source repo - * - * @return string The label - * - * @since 1.0 + * @link https://docs.github.com/en/rest/pulls/pulls#list-pull-requests-files + * @return list */ - public function getLatestGithubReleaseByBranch($branch) + private function getChangedTranslationFilesByPR(int $pullrequestId): array { - // Get the last 5 releases - $last5releases = $this->github->repositories->releases->getList($this->getOption('translation.owner'), $this->getOption('translation.repo'), $page = 0, $limit = 5); - - foreach ($last5releases as $tagName => $release) - { - // Check the branch from the tag name - $branchName = explode('.', $tagName); - $coreReleaseBranchName = $branchName[0] . '.' . $branchName[1]; - - // The tag name does not match, continue here - if ($coreReleaseBranchName !== $branch) - { - continue; - } + /** @var iterable $changedFilesByPR */ + $changedFilesByPR = $this->github->pulls->getFiles( + $this->optString('source.owner'), + $this->optString('source.repo'), + $pullrequestId + ); - // Inital value - if (!isset($latestTag)) - { - $latestTag = $tagName; - } + $translationFiles = []; - // When we have found a newer version use it - if (!version_compare($tagName, $latestTag, '>')) - { - $latestTag = $tagName; + foreach ($changedFilesByPR as $value) { + if (str_contains($value->filename, 'language/')) { + $translationFiles[] = $value->filename; } } - // Return the latest Tage we found - return $this->github->repositories->releases->getByTag($this->getOption('translation.owner'), $this->getOption('translation.repo'), $latestTag); + return $translationFiles; } - /** - * Returns the latest run date for the item - * - * @param string $branch The core release branch - * - * @return string The last processed release - * - * @since 1.0 - */ - public function getLatestPublishedRelease($branch): string + private function getDateFileName(string $fileName): string { - $dataFileName = $this->getDateFileName('lastrelease' . $branch . '.data'); + return $this->dataRootPath . $fileName; + } - // When there is no file create one with an empty date so it is now. - if (!is_file($dataFileName)) - { - file_put_contents($dataFileName, $branch . '.0v0'); - } + private function optString(string $key): string + { + $value = $this->options->get($key); - return trim(file_get_contents($dataFileName)); + return is_string($value) ? $value : ''; } /** - * Sets the latest run date to the given value - * - * @param string $branch The core release branch - * @param string $lastPublishedRelease The last processed release - * - * @return void - * - * @since 1.0 + * @return list */ - public function setLatestPublishedRelease($branch, $lastPublishedRelease): void + private function optAssignments(): array { - $dataFileName = $this->getDateFileName('lastrelease' . $branch . '.data'); + $value = $this->options->get('translation.assigments'); - if (is_file($dataFileName)) - { - unlink($dataFileName); + if (!is_array($value)) { + return []; } - file_put_contents($dataFileName, $lastPublishedRelease); + return array_values(array_filter($value, 'is_string')); } } diff --git a/src/LogHelper.php b/src/LogHelper.php index c8466ee..172664c 100644 --- a/src/LogHelper.php +++ b/src/LogHelper.php @@ -1,4 +1,7 @@ logfile = ROOT_PATH . '/logs/' . date('Ym') . '_' . $logName . '.log'; + } + + public function writeLogMessage(string $message, ?string $messageType = null): void { - $this->logfile = ROOT_PATH . '/logs/' . date('Ym') . '_' . $options['logName'] . '.log'; + file_put_contents($this->logfile, $this->getLogMessage($message, $messageType), FILE_APPEND | LOCK_EX); } - /** - * Get the log message with date and time. - * - * @param string $message The log messages - * @param string $messageType The log messagetype - * - * @return string The log message including metadata like dates - * - * @since 1.0 - */ - private function getLogMessage($message, $messageType = false): string + private function getLogMessage(string $message, ?string $messageType): string { - if (is_string($messageType)) - { + if ($messageType !== null) { return '[' . date('d/m/Y H:i:s') . '] - [' . $messageType . '] - ' . $message . PHP_EOL; } return '[' . date('d/m/Y H:i:s') . '] - ' . $message . PHP_EOL; } - - /** - * Write the log message to the log file - * - * @param string $message The log messages - * @param string $messageType The log messagetype - * - * @return void - * - * @since 1.0 - */ - public function writeLogMessage($message, $messageType = false): void - { - file_put_contents($this->logfile, $this->getLogMessage($message, $messageType), FILE_APPEND | LOCK_EX); - } } diff --git a/src/Notification/RunStatus.php b/src/Notification/RunStatus.php new file mode 100644 index 0000000..38344e4 --- /dev/null +++ b/src/Notification/RunStatus.php @@ -0,0 +1,28 @@ + 'good', + self::Noop => 'warning', + self::Error => 'danger', + }; + } +} diff --git a/src/Notification/RunSummary.php b/src/Notification/RunSummary.php new file mode 100644 index 0000000..3e4f15f --- /dev/null +++ b/src/Notification/RunSummary.php @@ -0,0 +1,55 @@ + */ + private array $lines = []; + + /** @var list */ + private array $createdIssues = []; + + public function setStatus(RunStatus $status): void + { + $this->status = $status; + } + + public function addLine(string $line): void + { + $this->lines[] = $line; + } + + public function addCreatedIssue(string $title, string $url): void + { + $this->createdIssues[] = ['title' => $title, 'url' => $url]; + } + + public function status(): RunStatus + { + return $this->status; + } + + /** @return list */ + public function lines(): array + { + return $this->lines; + } + + /** @return list */ + public function createdIssues(): array + { + return $this->createdIssues; + } +} diff --git a/src/NotifyerHelper.php b/src/NotifyerHelper.php index af3f2cf..39925ff 100644 --- a/src/NotifyerHelper.php +++ b/src/NotifyerHelper.php @@ -1,4 +1,7 @@ options = $options ?: new Registry; - - $this->http = HttpFactory::getHttp(); - } - - /** - * Get an option from the instance. - * - * @param string $key The name of the option to get. - * - * @return mixed The option value. - * - * @since 1.0 - */ - public function getOption($key) - { - return isset($this->options[$key]) ? $this->options[$key] : null; - } - - /** - * Set an option for the instance. - * - * @param string $key The name of the option to set. - * @param mixed $value The option value to set. - * - * @return GithubApiHelper This object for method chaining. - * - * @since 1.0 - */ - public function setOption($key, $value) - { - $this->options[$key] = $value; - - return $this; - } - - /** - * Get the Notification message with date and time. - * - * @param array $messageData The messagedata - * @param string $messageType The log messagetype - * - * @return string The log message including metadata like dates - * - * @since 1.0 - */ - private function getMessageTemplateNotificationMessage($messageData, $messageType = false): string - { - $message = $this->getOption('notifyer.messageTemplate'); - - foreach ($messageData as $key => $value) - { - $message = \str_replace('{' . $key . '}', $value, $message); - } - - if (is_string($messageType)) - { - return '[jgerman-bot] - [' . $messageType . '] - ' . $message . PHP_EOL; - } + private readonly Registry $options; + private readonly Http $http; - return '[jgerman-bot] - ' . $message . PHP_EOL; - } - - /** - * Send the Notificaton for the given message tempalte - * - * @param array $messageData The messagedata - * @param string $messageType The log messagetype - * - * @return void - * - * @since 1.0 - */ - public function sendMessageTemplateNotification($messageData, $messageType = false): void + public function __construct(Registry $options, ?Http $http = null) { - $this->sendNotificationMessage( - $this->getMessageTemplateNotificationMessage( - $messageData, - $messageType - ) - ); + $this->options = $options; + $this->http = $http ?? (new HttpFactory())->getHttp(); } - /** - * Send the Log Notifications - * - * @param array $message The messagt to be sended out - * - * @return void - * - * @since 1.0 - */ - public function sendLogNotification($message): void + public function sendRunSummary(RunSummary $summary): void { - $this->sendNotificationMessage($message); - } - - /** - * Send the Notifications to the configured endpoints - * - * @param array $message The messagt to be sended out - * - * @return void - * - * @since 1.0 - */ - private function sendNotificationMessage($message): void - { - if ($this->getOption('slack.enabled') === true) - { - $data = [ - 'payload' => json_encode( - [ - 'username' => $this->getOption('slack.username'), - 'text' => $message, - ] - ) - ]; - - $this->http->post($this->getOption('slack.webhookurl'), $data); - } - - if ($this->getOption('mattermost.enabled') === true) - { - $data = [ - 'payload' => json_encode( - [ - 'text' => $message, - ] - ) - ]; - - $this->http->post($this->getOption('mattermost.webhookurl'), $data); - } - - if ($this->getOption('telegram.enabled') === true) - { - $data = [ - 'chat_id' => $this->getOption('telegram.chatId'), - 'parse_mode' => 'HTML', - 'disable_web_page_preview' => 'true', - 'text' => $message, - ]; - - $this->http->post('https://api.telegram.org/bot' . $this->getOption('telegram.botToken') . '/sendMessage', $data); + foreach (NotificationChannel::cases() as $channel) { + if (!$channel->isEnabled($this->options)) { + continue; + } + + $this->http->post( + $channel->endpoint($this->options), + $channel->summaryPayload($summary, $this->options) + ); } } } diff --git a/tests/Unit/GithubApiHelperTest.php b/tests/Unit/GithubApiHelperTest.php new file mode 100644 index 0000000..1d72de0 --- /dev/null +++ b/tests/Unit/GithubApiHelperTest.php @@ -0,0 +1,39 @@ + + */ + public static function branchLabelCases(): iterable + { + // Historical multi-digit minors (Joomla 3 + 4 had 10+ minor releases). + yield 'historical 3.10' => ['3.10-dev', 'Joomla! 3.10']; + yield 'historical 4.10' => ['4.10-dev', 'Joomla! 4.10']; + // Joomla 5+ caps minors at .4 before bumping the major. + yield 'five-four dev' => ['5.4-dev', 'Joomla! 5.4']; + yield 'six-one dev' => ['6.1-dev', 'Joomla! 6.1']; + yield 'six-four dev' => ['6.4-dev', 'Joomla! 6.4']; + yield 'seven-zero dev' => ['7.0-dev', 'Joomla! 7.0']; + yield 'plain' => ['6.1', 'Joomla! 6.1']; + } +} diff --git a/tests/Unit/NotifyerHelperTest.php b/tests/Unit/NotifyerHelperTest.php new file mode 100644 index 0000000..156b575 --- /dev/null +++ b/tests/Unit/NotifyerHelperTest.php @@ -0,0 +1,172 @@ +set('slack.enabled', true); + $registry->set('slack.webhookurl', 'https://example.invalid/slack'); + $registry->set('slack.username', 'jgerman-bot'); + $registry->set('mattermost.enabled', false); + $registry->set('telegram.enabled', false); + + $http = $this->createMock(Http::class); + $http->expects(self::once()) + ->method('post') + ->with( + self::equalTo('https://example.invalid/slack'), + self::callback(static function (array $payload): bool { + $body = json_decode((string) $payload['payload'], true); + + return is_array($body) + && ($body['username'] ?? null) === 'jgerman-bot' + && isset($body['attachments'][0]['color']); + }) + ) + ->willReturn(new Response()); + + $summary = new RunSummary(); + $summary->addLine('hello'); + + (new NotifyerHelper($registry, $http))->sendRunSummary($summary); + } + + public function testFansOutToAllThreeWhenAllEnabled(): void + { + $registry = new Registry(); + $registry->set('slack.enabled', true); + $registry->set('slack.webhookurl', 'https://example.invalid/slack'); + $registry->set('slack.username', 'jgerman-bot'); + $registry->set('mattermost.enabled', true); + $registry->set('mattermost.webhookurl', 'https://example.invalid/mm'); + $registry->set('telegram.enabled', true); + $registry->set('telegram.botToken', 'TOKEN'); + $registry->set('telegram.chatId', '42'); + + $http = $this->createMock(Http::class); + $http->expects(self::exactly(3)) + ->method('post') + ->willReturn(new Response()); + + (new NotifyerHelper($registry, $http))->sendRunSummary(new RunSummary()); + } + + public function testSlackPayloadCarriesColorAndCreatedIssueLinks(): void + { + $registry = new Registry(); + $registry->set('slack.enabled', true); + $registry->set('slack.webhookurl', 'https://example.invalid/slack'); + $registry->set('slack.username', 'jgerman-bot'); + $registry->set('mattermost.enabled', false); + $registry->set('telegram.enabled', false); + + $captured = ''; + + $http = $this->createMock(Http::class); + $http->expects(self::once()) + ->method('post') + ->willReturnCallback(function ($url, array $payload) use (&$captured): Response { + $captured = (string) $payload['payload']; + + return new Response(); + }); + + $summary = new RunSummary(); + $summary->setStatus(RunStatus::Success); + $summary->addLine('Closed translation issues since last run: 1'); + $summary->addCreatedIssue('Big PR', 'https://example.invalid/issues/1'); + + (new NotifyerHelper($registry, $http))->sendRunSummary($summary); + + $body = json_decode($captured, true); + + self::assertIsArray($body); + self::assertSame('jgerman-bot', $body['username']); + self::assertSame('good', $body['attachments'][0]['color']); + self::assertSame('JGerman GitHub Bot', $body['attachments'][0]['title']); + self::assertStringContainsString( + '', + $body['attachments'][0]['text'] + ); + } + + public function testNoopStatusYieldsWarningColor(): void + { + $registry = new Registry(); + $registry->set('slack.enabled', false); + $registry->set('mattermost.enabled', true); + $registry->set('mattermost.webhookurl', 'https://example.invalid/mm'); + $registry->set('telegram.enabled', false); + + $captured = ''; + + $http = $this->createMock(Http::class); + $http->expects(self::once()) + ->method('post') + ->willReturnCallback(function ($url, array $payload) use (&$captured): Response { + $captured = (string) $payload['payload']; + + return new Response(); + }); + + $summary = new RunSummary(); + $summary->setStatus(RunStatus::Noop); + $summary->addLine('Already ran today — skipped.'); + + (new NotifyerHelper($registry, $http))->sendRunSummary($summary); + + $body = json_decode($captured, true); + + self::assertIsArray($body); + self::assertArrayNotHasKey('username', $body); + self::assertSame('warning', $body['attachments'][0]['color']); + } + + public function testTelegramReceivesPlainTextWithLinks(): void + { + $registry = new Registry(); + $registry->set('slack.enabled', false); + $registry->set('mattermost.enabled', false); + $registry->set('telegram.enabled', true); + $registry->set('telegram.botToken', 'TOKEN'); + $registry->set('telegram.chatId', '42'); + + $captured = []; + + $http = $this->createMock(Http::class); + $http->expects(self::once()) + ->method('post') + ->willReturnCallback(function ($url, array $payload) use (&$captured): Response { + $captured = $payload; + + return new Response(); + }); + + $summary = new RunSummary(); + $summary->addLine('Closed translation issues since last run: 1'); + $summary->addCreatedIssue('Big PR', 'https://example.invalid/issues/1'); + + (new NotifyerHelper($registry, $http))->sendRunSummary($summary); + + self::assertSame('42', $captured['chat_id']); + self::assertSame('HTML', $captured['parse_mode']); + self::assertStringContainsString('JGerman GitHub Bot', $captured['text']); + self::assertStringContainsString( + 'Big PR', + $captured['text'] + ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..83b8641 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,9 @@ + */ +const GITHUB_TRANSLATION_ASSIGMENTS = []; + +const GITHUB_TRANSLATION_TEMPLATE_BODY = ''; + +const NOTIFYER_SLACK_ENABLED = false; +const NOTIFYER_SLACK_WEBHOOKURL = ''; +const NOTIFYER_SLACK_USERNAME = ''; +const NOTIFYER_MATTERMOST_ENABLED = false; +const NOTIFYER_MATTERMOST_WEBHOOKURL = ''; +const NOTIFYER_TELEGRAM_ENABLED = false; +const NOTIFYER_TELEGRAM_BOTTOKEN = ''; +const NOTIFYER_TELEGRAM_CHATID = '';