diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 112c09f..e2490f9 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -29,6 +29,11 @@ jobs: test-dir: tests-e2e/jira/ script: | diff <(vendor/bin/phpstan analyse --error-format=raw --no-progress | sed "s|$(pwd)/||") expected-errors.txt + - os: ubuntu-latest + php-version: '8.2' + test-dir: tests-e2e/youtrack/ + script: | + diff <(vendor/bin/phpstan analyse --error-format=raw --no-progress | sed "s|$(pwd)/||") expected-errors.txt steps: - name: Checkout diff --git a/README.md b/README.md index d228ad2..cdaf66a 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,7 @@ Reference these virtual packages like any other package in your todo-comments: Optionally you can configure this extension to analyze your comments with issue tracker ticket keys. The extension fetches issue tracker API for issue status. If the remote issue is resolved, the comment will be reported. -Currently, Jira and GitHub are supported. +Currently, Jira, GitHub and YouTrack are supported. This feature is disabled by default. To enable it, you must set `ticket.enabled` parameter to `true`. You also need to set these parameters: @@ -222,7 +222,7 @@ parameters: # path to a file containing Jira credentials. # see below for possible formats. # if credentials parameter is not empty, it will be used instead of this file. - # this file must not be commited into the repository! + # this file must not be committed into the repository! credentialsFilePath: .secrets/jira-credentials.txt github: @@ -240,6 +240,19 @@ parameters: # if credentials parameter is not empty, it will be used instead of this file. # this file must not be committed into the repository! credentialsFilePath: null + + youtrack: + # e.g. https://your-company.youtrack.cloud + server: https://acme.youtrack.cloud + + # YouTrack permanent token + # if this value is empty, credentials file will be used instead. + credentials: %env.YOUTRACK_TOKEN% + + # path to a file containing a YouTrack permanent token + # if credentials parameter is not empty, it will be used instead of this file. + # this file must not be committed into the repository! + credentialsFilePath: .secrets/youtrack-credentials.txt ``` #### Jira Credentials diff --git a/extension.neon b/extension.neon index 63a4891..7f44ebd 100644 --- a/extension.neon +++ b/extension.neon @@ -7,7 +7,7 @@ parametersSchema: virtualPackages: arrayOf(string(), string()) ticket: structure([ enabled: bool() - tracker: anyOf('jira', 'github') + tracker: anyOf('jira', 'github', 'youtrack') keyPrefixes: listOf(string()) resolvedStatuses: listOf(string()) jira: structure([ @@ -21,6 +21,11 @@ parametersSchema: credentials: schema(string(), nullable()) credentialsFilePath: schema(string(), nullable()) ]) + youtrack: structure([ + server: string() + credentials: schema(string(), nullable()) + credentialsFilePath: schema(string(), nullable()) + ]) ]) ]) @@ -90,6 +95,20 @@ parameters: # this file must not be committed into the repository! credentialsFilePath: null + youtrack: + # e.g. https://your-company.youtrack.cloud + server: https://youtrack.jetbrains.com + + # see README for possible formats. + # if this value is empty, credentials file will be used instead. + credentials: null + + # path to a file containing YouTrack credentials. + # see README for possible formats. + # if credentials parameter is not empty, it will be used instead of this file. + # this file must not be commited into the repository! + credentialsFilePath: null + conditionalTags: staabm\PHPStanTodoBy\TodoByTicketRule: phpstan.rules.rule: %todo_by.ticket.enabled% @@ -151,5 +170,12 @@ services: - %todo_by.ticket.jira.credentials% - %todo_by.ticket.jira.credentialsFilePath% + - + class: staabm\PHPStanTodoBy\utils\ticket\YouTrackTicketStatusFetcher + arguments: + - %todo_by.ticket.youtrack.server% + - %todo_by.ticket.youtrack.credentials% + - %todo_by.ticket.youtrack.credentialsFilePath% + - class: staabm\PHPStanTodoBy\utils\HttpClient diff --git a/src/utils/ticket/TicketRuleConfigurationFactory.php b/src/utils/ticket/TicketRuleConfigurationFactory.php index 58c3c20..28ce792 100644 --- a/src/utils/ticket/TicketRuleConfigurationFactory.php +++ b/src/utils/ticket/TicketRuleConfigurationFactory.php @@ -45,6 +45,17 @@ public function create(): TicketRuleConfiguration ); } + if ('youtrack' === $tracker) { + $fetcher = $this->container->getByType(YouTrackTicketStatusFetcher::class); + + return new TicketRuleConfiguration( + $fetcher::getKeyPattern(), + ['resolved'], + $keyPrefixes, + $fetcher, + ); + } + throw new RuntimeException("Unsupported tracker type: $tracker"); } } diff --git a/src/utils/ticket/YouTrackTicketStatusFetcher.php b/src/utils/ticket/YouTrackTicketStatusFetcher.php new file mode 100644 index 0000000..2797c7e --- /dev/null +++ b/src/utils/ticket/YouTrackTicketStatusFetcher.php @@ -0,0 +1,87 @@ + + */ + private array $cache; + + public function __construct(string $host, ?string $credentials, ?string $credentialsFilePath, HttpClient $httpClient) + { + $credentials = CredentialsHelper::getCredentials($credentials, $credentialsFilePath); + + $this->host = $host; + $this->authorizationHeader = $credentials ? self::createAuthorizationHeader($credentials) : null; + + $this->cache = []; + $this->httpClient = $httpClient; + } + + public function fetchTicketStatus(string $ticketKey): ?string + { + if (array_key_exists($ticketKey, $this->cache)) { + return $this->cache[$ticketKey]; + } + + $url = "{$this->host}/api/issues/$ticketKey?fields=resolved"; + $headers = []; + if (null !== $this->authorizationHeader) { + $headers = [ + "Authorization: $this->authorizationHeader", + ]; + } + + [$responseCode, $response] = $this->httpClient->get($url, $headers); + + if (200 !== $responseCode) { + throw new RuntimeException("Could not fetch ticket's status from YouTrack with url $url"); + } + + $data = self::decodeAndValidateResponse($response); + + return $this->cache[$ticketKey] = null === $data['resolved'] ? 'open' : 'resolved'; + } + + public static function getKeyPattern(): string + { + return '[A-Z0-9]+-\d+'; + } + + private static function createAuthorizationHeader(string $credentials): string + { + return "Bearer $credentials"; + } + + /** @return array{resolved: ?int} */ + private static function decodeAndValidateResponse(string $body): array + { + $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + + if (!is_array($data) || !array_key_exists('resolved', $data)) { + self::throwInvalidResponse(); + } + + return $data; + } + + /** @return never */ + private static function throwInvalidResponse(): void + { + throw new RuntimeException('YouTrack returned invalid response body'); + } +} diff --git a/tests-e2e/youtrack/.gitignore b/tests-e2e/youtrack/.gitignore new file mode 100644 index 0000000..57872d0 --- /dev/null +++ b/tests-e2e/youtrack/.gitignore @@ -0,0 +1 @@ +/vendor/ diff --git a/tests-e2e/youtrack/composer.json b/tests-e2e/youtrack/composer.json new file mode 100644 index 0000000..4bed15f --- /dev/null +++ b/tests-e2e/youtrack/composer.json @@ -0,0 +1,11 @@ +{ + "config": { + "allow-plugins": { + "phpstan/extension-installer": true + } + }, + "require-dev": { + "phpstan/phpstan": "^1.10", + "phpstan/extension-installer": "^1.3" + } +} diff --git a/tests-e2e/youtrack/composer.lock b/tests-e2e/youtrack/composer.lock new file mode 100644 index 0000000..a9df981 --- /dev/null +++ b/tests-e2e/youtrack/composer.lock @@ -0,0 +1,125 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "70d13e8324066e0b24a0b737022ce64b", + "packages": [], + "packages-dev": [ + { + "name": "phpstan/extension-installer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/phpstan/extension-installer.git", + "reference": "f45734bfb9984c6c56c4486b71230355f066a58a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/extension-installer/zipball/f45734bfb9984c6c56c4486b71230355f066a58a", + "reference": "f45734bfb9984c6c56c4486b71230355f066a58a", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.0", + "php": "^7.2 || ^8.0", + "phpstan/phpstan": "^1.9.0" + }, + "require-dev": { + "composer/composer": "^2.0", + "php-parallel-lint/php-parallel-lint": "^1.2.0", + "phpstan/phpstan-strict-rules": "^0.11 || ^0.12 || ^1.0" + }, + "type": "composer-plugin", + "extra": { + "class": "PHPStan\\ExtensionInstaller\\Plugin" + }, + "autoload": { + "psr-4": { + "PHPStan\\ExtensionInstaller\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Composer plugin for automatic installation of PHPStan extensions", + "support": { + "issues": "https://github.com/phpstan/extension-installer/issues", + "source": "https://github.com/phpstan/extension-installer/tree/1.3.1" + }, + "time": "2023-05-24T08:59:17+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "1.10.54", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan.git", + "reference": "3e25f279dada0adc14ffd7bad09af2e2fc3523bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/3e25f279dada0adc14ffd7bad09af2e2fc3523bb", + "reference": "3e25f279dada0adc14ffd7bad09af2e2fc3523bb", + "shasum": "" + }, + "require": { + "php": "^7.2|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpstan/phpstan", + "type": "tidelift" + } + ], + "time": "2024-01-05T15:50:47+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "plugin-api-version": "2.6.0" +} diff --git a/tests-e2e/youtrack/expected-errors.txt b/tests-e2e/youtrack/expected-errors.txt new file mode 100644 index 0000000..e63a414 --- /dev/null +++ b/tests-e2e/youtrack/expected-errors.txt @@ -0,0 +1 @@ +src/tickets.php:3:Should have been resolved in WI-1: fix me. diff --git a/tests-e2e/youtrack/phpstan.neon b/tests-e2e/youtrack/phpstan.neon new file mode 100644 index 0000000..85959c0 --- /dev/null +++ b/tests-e2e/youtrack/phpstan.neon @@ -0,0 +1,12 @@ +parameters: + level: max + paths: + - src/ + todo_by: + ticket: + enabled: true + tracker: youtrack + keyPrefixes: + - WI + jira: + server: https://youtrack.jetbrains.com/ diff --git a/tests-e2e/youtrack/src/tickets.php b/tests-e2e/youtrack/src/tickets.php new file mode 100644 index 0000000..eedd919 --- /dev/null +++ b/tests-e2e/youtrack/src/tickets.php @@ -0,0 +1,3 @@ +