├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── extension.neon └── src ├── TodoByDateRule.php ├── TodoByIssueUrlRule.php ├── TodoByPackageVersionRule.php ├── TodoBySymfonyDeprecationRule.php ├── TodoByTicketCollector.php ├── TodoByTicketRule.php ├── TodoByVersionRule.php └── utils ├── CommentMatcher.php ├── CredentialsHelper.php ├── ExpiredCommentErrorBuilder.php ├── GitTagFetcher.php ├── HttpClient.php ├── LatestTagNotFoundException.php ├── ReferenceVersionFinder.php ├── TagFetcher.php └── ticket ├── GitHubTicketStatusFetcher.php ├── JiraTicketStatusFetcher.php ├── TicketRuleConfiguration.php ├── TicketRuleConfigurationFactory.php ├── TicketStatusFetcher.php └── YouTrackTicketStatusFetcher.php /.gitattributes: -------------------------------------------------------------------------------- 1 | /.github export-ignore 2 | /tests export-ignore 3 | /tests-e2e export-ignore 4 | .editorconfig export-ignore 5 | .php-cs-fixer.php export-ignore 6 | phpstan.neon export-ignore 7 | baseline-7.4.neon export-ignore 8 | ignore-by-php-version.neon.php export-ignore 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /composer.lock 3 | /test.php 4 | .php-cs-fixer.cache 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-present Markus Staab 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # phpstan-todo-by: comments with expiration 2 | 3 | PHPStan extension to check for `TODO/FIXME/XXX` comments with expiration. 4 | Inspired by [parker-codes/todo-by](https://github.com/parker-codes/todo_by). 5 | 6 | 7 | ## Examples 8 | 9 | The main idea is, that comments within the source code will be turned into PHPStan errors when a condition is satisfied, e.g. a date reached, a version met, a issue tracker ticket is closed. 10 | 11 | ```php 12 | 123.4: Must fix this or bump the version 78 | 79 | // TODO: phpunit/phpunit:<5 This has to be fixed before updating to phpunit 5.x 80 | // TODO@markus: phpunit/phpunit:5.3 This has to be fixed when updating phpunit to 5.3.x or higher 81 | 82 | // TODO: APP-123 fix it when this Jira ticket is closed 83 | // TODO: #123 fix it when this GitHub issue is closed 84 | // TODO: GH-123 fix it when this GitHub issue is closed 85 | // TODO: some-organization/some-repo#123 change me if this GitHub pull request is closed 86 | ``` 87 | 88 | ## Configuration 89 | 90 | 91 | ### Non-ignorable errors 92 | 93 | Errors emitted by the extension are non-ignorable by default, so they cannot accidentally be put into the baseline. 94 | You can change this behaviour with a configuration option within your `phpstan.neon`: 95 | 96 | ```neon 97 | parameters: 98 | todo_by: 99 | nonIgnorable: false # default is true 100 | ``` 101 | 102 | 103 | ### Reference time 104 | 105 | By default date-todo-comments are checked against todays date. 106 | 107 | You might be interested, which comments will expire e.g. within the next 7 days, which can be configured with the `referenceTime` option. 108 | You need to configure a date parsable by `strtotime`. 109 | 110 | ```neon 111 | parameters: 112 | todo_by: 113 | # any strtotime() compatible string 114 | referenceTime: "now+7days" 115 | ``` 116 | 117 | It can be especially handy to use a env variable for it, so you can pass the reference date e.g. via the CLI: 118 | 119 | ```neon 120 | parameters: 121 | todo_by: 122 | referenceTime: %env.TODOBY_REF_TIME% 123 | ``` 124 | 125 | `TODOBY_REF_TIME="now+7days" vendor/bin/phpstan analyze` 126 | 127 | 128 | ### Reference version 129 | 130 | By default version-todo-comments are checked against `"nextMajor"` version. 131 | It is determined by fetching the latest local available git tag and incrementing the major version number. 132 | 133 | The behaviour can be configured with the `referenceVersion` option. 134 | Possible values are `"nextMajor"`, `"nextMinor"`, `"nextPatch"` - which will be computed based on the latest local git tag - or any other version string like `"1.2.3"`. 135 | 136 | ```neon 137 | parameters: 138 | todo_by: 139 | # "nextMajor", "nextMinor", "nextPatch" or a version string like "1.2.3" 140 | referenceVersion: "nextMinor" 141 | ``` 142 | 143 | As shown in the "Reference time"-paragraph above, you might even use a env variable instead. 144 | 145 | > [!NOTE] 146 | > The reference version is not applied to package-version-todo-comments which are matched against `composer.lock` instead. 147 | 148 | #### Prerequisite 149 | 150 | Make sure tags are available within your git clone, e.g. by running `git fetch --tags origin` - otherwise you are likely running into a 'Could not determine latest git tag' error. 151 | 152 | In a GitHub Action this can be done like this: 153 | 154 | ```yaml 155 | - name: Checkout 156 | uses: actions/checkout@v4 157 | 158 | - name: Get tags 159 | run: git fetch --tags origin 160 | ``` 161 | 162 | 163 | #### Multiple GIT repository support 164 | 165 | By default the latest git tag to calculate the reference version is fetched once for all files beeing analyzed. 166 | 167 | This behaviour can be configured with the `singleGitRepo` option. 168 | 169 | In case you are using git submodules, or the analyzed codebase consists of multiple git repositories, 170 | set the `singleGitRepo` option to `false` which resolves the reference version for each directory beeing analyzed. 171 | 172 | 173 | #### Virtual packages 174 | 175 | Within the PHPStan config file you can define additional packages, to match against package-version-todo-comments. 176 | 177 | ```neon 178 | parameters: 179 | todo_by: 180 | virtualPackages: 181 | 'staabm/mypackage': '2.1.0' 182 | 'staabm/my-api': '3.1.0' 183 | ``` 184 | 185 | Reference these virtual packages like any other package in your todo-comments: 186 | 187 | `// TODO staabm/mypackage:2.2.0 remove the following function once staabm/mypackage is updated to 2.2.0` 188 | 189 | 190 | ### Issue tracker key support 191 | 192 | Optionally you can configure this extension to analyze your comments with issue tracker ticket keys. 193 | The extension fetches issue tracker API for issue status. If the remote issue is resolved, the comment will be reported. 194 | 195 | Currently, Jira, GitHub and YouTrack are supported. 196 | 197 | This feature is disabled by default. To enable it, you must set `ticket.enabled` parameter to `true`. 198 | You also need to set these parameters: 199 | 200 | ```yaml 201 | parameters: 202 | todo_by: 203 | ticket: 204 | enabled: true 205 | 206 | # one of: jira, github (case-sensitive) 207 | tracker: jira 208 | 209 | # a case-sensitive list of status names. 210 | # only tickets having any of these statuses are considered resolved. 211 | # supported trackers: jira. Other trackers ignore this parameter. 212 | resolvedStatuses: 213 | - Done 214 | - Resolved 215 | - Declined 216 | 217 | # if your ticket key is FOO-12345, then this value should be ["FOO"]. 218 | # multiple key prefixes are allowed, e.g. ["FOO", "APP"]. 219 | # only comments with keys containing this prefix will be analyzed. 220 | # supported trackers: jira, youtrack. Other trackers ignore this parameter. 221 | keyPrefixes: 222 | - FOO 223 | 224 | jira: 225 | # e.g. https://your-company.atlassian.net 226 | server: https://acme.atlassian.net 227 | 228 | # see below for possible formats. 229 | # if this value is empty, credentials file will be used instead. 230 | credentials: %env.JIRA_TOKEN% 231 | 232 | # path to a file containing Jira credentials. 233 | # see below for possible formats. 234 | # if credentials parameter is not empty, it will be used instead of this file. 235 | # this file must not be committed into the repository! 236 | credentialsFilePath: .secrets/jira-credentials.txt 237 | 238 | github: 239 | # The account owner of referenced repositories. 240 | defaultOwner: your-name 241 | 242 | # The name of the repository without the .git extension. 243 | defaultRepo: your-repository 244 | 245 | # GitHub Access Token 246 | # if this value is empty, credentials file will be used instead. 247 | credentials: null 248 | 249 | # path to a file containing GitHub Access Token. 250 | # if credentials parameter is not empty, it will be used instead of this file. 251 | # this file must not be committed into the repository! 252 | credentialsFilePath: null 253 | 254 | youtrack: 255 | # e.g. https://your-company.youtrack.cloud 256 | server: https://acme.youtrack.cloud 257 | 258 | # YouTrack permanent token 259 | # if this value is empty, credentials file will be used instead. 260 | credentials: %env.YOUTRACK_TOKEN% 261 | 262 | # path to a file containing a YouTrack permanent token 263 | # if credentials parameter is not empty, it will be used instead of this file. 264 | # this file must not be committed into the repository! 265 | credentialsFilePath: .secrets/youtrack-credentials.txt 266 | ``` 267 | 268 | #### Jira Credentials 269 | 270 | This extension uses Jira's REST API to fetch ticket's status. If your board is not public, you need to configure valid credentials. 271 | These authentication methods are supported: 272 | - [OAuth 2.0 Access Tokens](https://confluence.atlassian.com/adminjiraserver/jira-oauth-2-0-provider-api-1115659070.html) 273 | - [Personal Access Tokens](https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html) 274 | - [Basic Authentication](https://developer.atlassian.com/server/jira/platform/basic-authentication/) 275 | 276 | We recommend you use OAuth over basic authentication, especially if you use phpstan in CI. 277 | There are multiple ways to pass your credentials to this extension. 278 | You should choose one of them - if you define both parameters, only `credentials` parameter is considered and the file is ignored. 279 | 280 | ##### Pass credentials in environment variable 281 | 282 | Configure `credentials` parameter to [read value from environment variable](https://phpstan.org/config-reference#environment-variables): 283 | ```yaml 284 | parameters: 285 | todo_by: 286 | ticket: 287 | jira: 288 | credentials: %env.JIRA_TOKEN% 289 | ``` 290 | 291 | Depending on chosen authentication method its content should be: 292 | * Access Token for token based authentication, e.g. `JIRA_TOKEN=ATATT3xFfGF0Gv_pLFSsunmigz8wq5Y0wkogoTMgxDFHyR...` 293 | * `:` for basic authentication, e.g. `JIRA_TOKEN=john.doe@example.com:p@ssword` 294 | 295 | ##### Pass credentials in text file 296 | 297 | Create text file in your project's directory (or anywhere else on your computer) and put its path into configuration: 298 | 299 | ```yaml 300 | parameters: 301 | todo_by: 302 | ticket: 303 | jira: 304 | credentialsFilePath: path/to/file 305 | ``` 306 | 307 | **Remember not to commit this file to repository!** 308 | Depending on chosen authentication method its value should be: 309 | * Access Token for token based authentication, e.g. `JATATT3xFfGF0Gv_pLFSsunmigz8wq5Y0wkogoTMgxDFHyR...` 310 | * `:` for basic authentication, e.g. `john.doe@example.com:p@ssword` 311 | 312 | #### GitHub 313 | Both issues and pull requests are supported. The comment will be reported if the referenced issue/PR is closed. 314 | There are multiple ways to reference GitHub issue/PR: 315 | 316 | ##### Only number 317 | ```php 318 | // TODO: #123 - fix me 319 | // TODO: GH-123 - fix me 320 | ``` 321 | If the `defaultOwner` is set to `acme` and `defaultRepo` is set to `hello-world`, the referenced issue is resolved to `acme/hello-world#123`. 322 | 323 | ##### Owner + repository name + number 324 | ```php 325 | // TODO: acme/hello-world#123 - fix me 326 | ``` 327 | 328 | ## Installation 329 | 330 | To use this extension, require it in [Composer](https://getcomposer.org/): 331 | 332 | ``` 333 | composer require --dev staabm/phpstan-todo-by 334 | ``` 335 | 336 | If you also install [phpstan/extension-installer](https://github.com/phpstan/extension-installer) then you're all set! 337 | 338 |
339 | Manual installation 340 | 341 | If you don't want to use `phpstan/extension-installer`, include extension.neon in your project's PHPStan config: 342 | 343 | ``` 344 | includes: 345 | - vendor/staabm/phpstan-todo-by/extension.neon 346 | ``` 347 | 348 |
349 | 350 | ## FAQ 351 | 352 | ### Unexpected '"php" version requirement ">=XXX" satisfied' error 353 | 354 | If you get this errors too early, it might be caused by wrong version constraints in your `composer.json` file. 355 | A `php` version constraint of e.g. `^7.4` means `>=7.4.0 && <= 7.999999.99999`, 356 | which means comments like `// TODO >7.5` will emit an error. 357 | 358 | For the `php` declaration, it is recommended to use a version constraint with a fixed upper bound, e.g. `7.4.*` or `^7 || <8.3`. 359 | 360 | ### 'Could not determine latest git tag' error 361 | 362 | This error is thrown, when no git tags are available within your git clone. 363 | Fetch git tags, as described in the ["Reference version"-chapter](https://github.com/staabm/phpstan-todo-by#reference-version) above. 364 | 365 | ## 💌 Give back some love 366 | 367 | [Consider supporting the project](https://github.com/sponsors/staabm), so we can make this tool even better even faster for everyone. 368 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "staabm/phpstan-todo-by", 3 | "license": "MIT", 4 | "type": "phpstan-extension", 5 | "keywords": [ 6 | "dev", 7 | "phpstan", 8 | "phpstan-extension", 9 | "static analysis", 10 | "comments", 11 | "expiration" 12 | ], 13 | "require": { 14 | "php": "^7.4 || ^8.0", 15 | "ext-curl": "*", 16 | "ext-json": "*", 17 | "composer-runtime-api": "^2", 18 | "composer/semver": "^3.4", 19 | "nikolaposa/version": "^4.1", 20 | "phpstan/phpstan": "^2.0" 21 | }, 22 | "require-dev": { 23 | "friendsofphp/php-cs-fixer": "^3.75", 24 | "nikic/php-parser": "^5.3", 25 | "phpstan/extension-installer": "^1.4", 26 | "phpstan/phpstan-deprecation-rules": "^2", 27 | "phpunit/phpunit": "^9 || ^10.5", 28 | "redaxo/php-cs-fixer-config": "^1.0" 29 | }, 30 | "autoload": { 31 | "psr-4": { 32 | "staabm\\PHPStanTodoBy\\": "src/" 33 | } 34 | }, 35 | "autoload-dev": { 36 | "classmap": [ 37 | "tests/" 38 | ] 39 | }, 40 | "config": { 41 | "allow-plugins": { 42 | "phpstan/extension-installer": true 43 | }, 44 | "sort-packages": true 45 | }, 46 | "extra": { 47 | "phpstan": { 48 | "includes": [ 49 | "extension.neon" 50 | ] 51 | } 52 | }, 53 | "scripts": { 54 | "cs": "vendor/bin/php-cs-fixer fix --ansi", 55 | "phpstan": "vendor/bin/phpstan --ansi", 56 | "test": "vendor/bin/phpunit tests/" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /extension.neon: -------------------------------------------------------------------------------- 1 | parametersSchema: 2 | todo_by: structure([ 3 | nonIgnorable: bool() 4 | referenceTime: string() 5 | referenceVersion: string() 6 | singleGitRepo: bool() 7 | virtualPackages: arrayOf(string(), string()) 8 | ticket: structure([ 9 | enabled: bool() 10 | tracker: anyOf('jira', 'github', 'youtrack') 11 | keyPrefixes: listOf(string()) 12 | resolvedStatuses: listOf(string()) 13 | jira: structure([ 14 | server: string() 15 | credentials: schema(string(), nullable()) 16 | credentialsFilePath: schema(string(), nullable()) 17 | ]) 18 | github: structure([ 19 | defaultOwner: string() 20 | defaultRepo: string() 21 | credentials: schema(string(), nullable()) 22 | credentialsFilePath: schema(string(), nullable()) 23 | ]) 24 | youtrack: structure([ 25 | server: string() 26 | credentials: schema(string(), nullable()) 27 | credentialsFilePath: schema(string(), nullable()) 28 | ]) 29 | ]) 30 | ]) 31 | 32 | # default parameters 33 | parameters: 34 | todo_by: 35 | nonIgnorable: true 36 | 37 | # any strtotime() compatible string 38 | referenceTime: "now" 39 | 40 | # "nextMajor", "nextMinor", "nextPatch" or a version string like "1.2.3" 41 | referenceVersion: "nextMajor" 42 | 43 | # whether all files beeing analyzed are contained in the same git repository. 44 | # If set to false, the git tags are fetched for each directory individually (slower) 45 | singleGitRepo: true 46 | 47 | # a map of additional packages to match package-comments against 48 | virtualPackages: [] 49 | 50 | ticket: 51 | # whether to analyze comments by issue tracker ticket key 52 | enabled: false 53 | 54 | # one of: jira, github, youtrack (case-sensitive) 55 | tracker: jira 56 | 57 | # a case-sensitive list of status names. 58 | # only tickets having any of these statuses are considered resolved. 59 | # supported trackers: jira. Other trackers ignore this parameter. 60 | resolvedStatuses: [] 61 | 62 | # if your ticket key is FOO-12345, then this value should be ["FOO"]. 63 | # multiple key prefixes are allowed, e.g. ["FOO", "APP"]. 64 | # only comments with keys containing this prefix will be analyzed. 65 | # supported trackers: jira, youtrack. Other trackers ignore this parameter. 66 | keyPrefixes: [] 67 | 68 | jira: 69 | # e.g. https://your-company.atlassian.net 70 | server: https://jira.atlassian.com 71 | 72 | # see README for possible formats. 73 | # if this value is empty, credentials file will be used instead. 74 | credentials: null 75 | 76 | # path to a file containing Jira credentials. 77 | # see README for possible formats. 78 | # if credentials parameter is not empty, it will be used instead of this file. 79 | # this file must not be commited into the repository! 80 | credentialsFilePath: null 81 | 82 | github: 83 | # The account owner of referenced repositories. 84 | defaultOwner: ~ 85 | 86 | # The name of the repository without the .git extension. 87 | defaultRepo: ~ 88 | 89 | # GitHub Access Token 90 | # if this value is empty, credentials file will be used instead. 91 | credentials: null 92 | 93 | # path to a file containing GitHub Access Token. 94 | # if credentials parameter is not empty, it will be used instead of this file. 95 | # this file must not be committed into the repository! 96 | credentialsFilePath: null 97 | 98 | youtrack: 99 | # e.g. https://your-company.youtrack.cloud 100 | server: https://youtrack.jetbrains.com 101 | 102 | # see README for possible formats. 103 | # if this value is empty, credentials file will be used instead. 104 | credentials: null 105 | 106 | # path to a file containing YouTrack credentials. 107 | # see README for possible formats. 108 | # if credentials parameter is not empty, it will be used instead of this file. 109 | # this file must not be commited into the repository! 110 | credentialsFilePath: null 111 | 112 | conditionalTags: 113 | staabm\PHPStanTodoBy\TodoByTicketRule: 114 | phpstan.rules.rule: %todo_by.ticket.enabled% 115 | 116 | services: 117 | - 118 | class: staabm\PHPStanTodoBy\TodoByDateRule 119 | tags: [phpstan.rules.rule] 120 | arguments: 121 | - %todo_by.referenceTime% 122 | 123 | - 124 | class: staabm\PHPStanTodoBy\TodoByTicketRule 125 | 126 | - 127 | class: staabm\PHPStanTodoBy\TodoByVersionRule 128 | tags: [phpstan.rules.rule] 129 | arguments: 130 | - %todo_by.singleGitRepo% 131 | 132 | - 133 | class: staabm\PHPStanTodoBy\TodoBySymfonyDeprecationRule 134 | tags: [phpstan.rules.rule] 135 | arguments: 136 | workingDirectory: %currentWorkingDirectory% 137 | 138 | - 139 | class: staabm\PHPStanTodoBy\TodoByPackageVersionRule 140 | tags: [phpstan.rules.rule] 141 | arguments: 142 | workingDirectory: %currentWorkingDirectory% 143 | virtualPackages: %todo_by.virtualPackages% 144 | 145 | - 146 | class: staabm\PHPStanTodoBy\TodoByIssueUrlRule 147 | tags: [phpstan.rules.rule] 148 | 149 | - 150 | class: staabm\PHPStanTodoBy\utils\GitTagFetcher 151 | 152 | - 153 | class: staabm\PHPStanTodoBy\utils\ReferenceVersionFinder 154 | arguments: 155 | - %todo_by.referenceVersion% 156 | 157 | - 158 | class: staabm\PHPStanTodoBy\utils\ExpiredCommentErrorBuilder 159 | arguments: 160 | - %todo_by.nonIgnorable% 161 | 162 | - 163 | class: staabm\PHPStanTodoBy\utils\ticket\TicketRuleConfigurationFactory 164 | 165 | - 166 | class: staabm\PHPStanTodoBy\utils\ticket\TicketRuleConfiguration 167 | factory: @staabm\PHPStanTodoBy\utils\ticket\TicketRuleConfigurationFactory::create 168 | 169 | - 170 | class: staabm\PHPStanTodoBy\TodoByTicketCollector 171 | tags: 172 | - phpstan.collector 173 | 174 | - 175 | class: staabm\PHPStanTodoBy\utils\ticket\GitHubTicketStatusFetcher 176 | arguments: 177 | - %todo_by.ticket.github.defaultOwner% 178 | - %todo_by.ticket.github.defaultRepo% 179 | - %todo_by.ticket.github.credentials% 180 | - %todo_by.ticket.github.credentialsFilePath% 181 | 182 | - 183 | class: staabm\PHPStanTodoBy\utils\ticket\JiraTicketStatusFetcher 184 | arguments: 185 | - %todo_by.ticket.jira.server% 186 | - %todo_by.ticket.jira.credentials% 187 | - %todo_by.ticket.jira.credentialsFilePath% 188 | 189 | - 190 | class: staabm\PHPStanTodoBy\utils\ticket\YouTrackTicketStatusFetcher 191 | arguments: 192 | - %todo_by.ticket.youtrack.server% 193 | - %todo_by.ticket.youtrack.credentials% 194 | - %todo_by.ticket.youtrack.credentialsFilePath% 195 | 196 | - 197 | class: staabm\PHPStanTodoBy\utils\HttpClient 198 | -------------------------------------------------------------------------------- /src/TodoByDateRule.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | final class TodoByDateRule implements Rule 19 | { 20 | private const ERROR_IDENTIFIER = 'date'; 21 | 22 | private const PATTERN = <<<'REGEXP' 23 | { 24 | @?(?:TODO|FIXME|XXX) # possible @ prefix 25 | @?[a-zA-Z0-9_-]* # optional username 26 | \s*[:-]?\s* # optional colon or hyphen 27 | \s+ # keyword/date separator 28 | (?P\d{4}-\d\d?-\d\d?) # date consisting of YYYY-MM-DD format 29 | \s*[:-]?\s* # optional colon or hyphen 30 | (?P(?:(?!\*+/).)*) # rest of line as comment text, excluding block end 31 | }ix 32 | REGEXP; 33 | 34 | private int $now; 35 | private ExpiredCommentErrorBuilder $errorBuilder; 36 | 37 | public function __construct( 38 | string $referenceTime, 39 | ExpiredCommentErrorBuilder $errorBuilder 40 | ) { 41 | $time = strtotime($referenceTime); 42 | 43 | if (false === $time) { 44 | throw new RuntimeException('Unable to parse reference time "' . $referenceTime . '"'); 45 | } 46 | 47 | $this->now = $time; 48 | $this->errorBuilder = $errorBuilder; 49 | } 50 | 51 | public function getNodeType(): string 52 | { 53 | return Node::class; 54 | } 55 | 56 | public function processNode(Node $node, Scope $scope): array 57 | { 58 | $it = CommentMatcher::matchComments($node, self::PATTERN); 59 | 60 | $errors = []; 61 | foreach ($it as $comment => $matches) { 62 | /** @var array> $matches */ 63 | foreach ($matches as $match) { 64 | $date = $match['date'][0]; 65 | $todoText = trim($match['comment'][0]); 66 | 67 | sscanf($date, '%4d-%2d-%2d', $year, $month, $day); 68 | 69 | if (!checkdate((int) $month, (int) $day, (int) $year)) { 70 | $errors[] = $this->errorBuilder->buildError( 71 | $comment->getText(), 72 | $comment->getStartLine(), 73 | 'Invalid date "'. $date .'". Expected format "YYYY-MM-DD".', 74 | self::ERROR_IDENTIFIER, 75 | null, 76 | $match[0][1] 77 | ); 78 | 79 | continue; 80 | } 81 | 82 | /** 83 | * strtotime() will parse date-only values with time set to 00:00:00. 84 | * This is fine, because this will count any expiration matching 85 | * the current date as expired, except when ran exactly at 00:00:00. 86 | */ 87 | if (strtotime($date) > $this->now) { 88 | continue; 89 | } 90 | 91 | // Have always present date at the start of the message. 92 | // If there is further text, append it. 93 | if ('' !== $todoText) { 94 | $errorMessage = "Expired on {$date}: ". rtrim($todoText, '.') .'.'; 95 | } else { 96 | $errorMessage = "Comment expired on {$date}."; 97 | } 98 | 99 | $errors[] = $this->errorBuilder->buildError( 100 | $comment->getText(), 101 | $comment->getStartLine(), 102 | $errorMessage, 103 | self::ERROR_IDENTIFIER, 104 | null, 105 | $match[0][1] 106 | ); 107 | } 108 | } 109 | 110 | return $errors; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/TodoByIssueUrlRule.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | final class TodoByIssueUrlRule implements Rule 20 | { 21 | private const ERROR_IDENTIFIER = 'url'; 22 | 23 | private const PATTERN = <<<'REGEXP' 24 | { 25 | @?(?:TODO|FIXME|XXX) # possible @ prefix 26 | @?[a-zA-Z0-9_-]* # optional username 27 | \s*[:-]?\s* # optional colon or hyphen 28 | \s+ # keyword/version separator 29 | (?Phttps://github.com/(?P[\S]{2,})/(?P[\S]+)/issues/(?P\d+)) # url 30 | \s*[:-]?\s* # optional colon or hyphen 31 | (?P(?:(?!\*+/).)*) # rest of line as comment text, excluding block end 32 | }ix 33 | REGEXP; 34 | 35 | private ExpiredCommentErrorBuilder $errorBuilder; 36 | private GitHubTicketStatusFetcher $fetcher; 37 | 38 | public function __construct( 39 | ExpiredCommentErrorBuilder $errorBuilder, 40 | GitHubTicketStatusFetcher $fetcher 41 | ) { 42 | $this->errorBuilder = $errorBuilder; 43 | $this->fetcher = $fetcher; 44 | } 45 | 46 | public function getNodeType(): string 47 | { 48 | return Node::class; 49 | } 50 | 51 | public function processNode(Node $node, Scope $scope): array 52 | { 53 | $it = CommentMatcher::matchComments($node, self::PATTERN); 54 | 55 | $errors = []; 56 | foreach ($it as $comment => $matches) { 57 | /** @var array> $matches */ 58 | foreach ($matches as $match) { 59 | $url = $match['url'][0]; 60 | $owner = $match['owner'][0]; 61 | $repo = $match['repo'][0]; 62 | $issueNumber = $match['issueNumber'][0]; 63 | $todoText = trim($match['comment'][0]); 64 | $wholeMatchStartOffset = $match[0][1]; 65 | 66 | $apiUrl = $this->fetcher->buildUrl($owner, $repo, $issueNumber); 67 | $fetchedStatuses = $this->fetcher->fetchTicketStatusByUrls([$apiUrl => $apiUrl]); 68 | 69 | if (!array_key_exists($apiUrl, $fetchedStatuses) || null === $fetchedStatuses[$apiUrl]) { 70 | $errors[] = $this->errorBuilder->buildError( 71 | $comment->getText(), 72 | $comment->getStartLine(), 73 | "Ticket $url doesn't exist or provided credentials do not allow for viewing it.", 74 | self::ERROR_IDENTIFIER, 75 | null, 76 | $wholeMatchStartOffset 77 | ); 78 | 79 | continue; 80 | } 81 | 82 | $ticketStatus = $fetchedStatuses[$apiUrl]; 83 | if (!in_array($ticketStatus, GitHubTicketStatusFetcher::RESOLVED_STATUSES, true)) { 84 | continue; 85 | } 86 | 87 | if ('' !== $todoText) { 88 | $errorMessage = "Should have been resolved in {$url}: ". rtrim($todoText, '.') .'.'; 89 | } else { 90 | $errorMessage = "Comment should have been resolved with {$url}."; 91 | } 92 | 93 | $errors[] = $this->errorBuilder->buildError( 94 | $comment->getText(), 95 | $comment->getStartLine(), 96 | $errorMessage, 97 | self::ERROR_IDENTIFIER, 98 | null, 99 | $wholeMatchStartOffset 100 | ); 101 | } 102 | } 103 | 104 | return $errors; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/TodoByPackageVersionRule.php: -------------------------------------------------------------------------------- 1 | 28 | */ 29 | final class TodoByPackageVersionRule implements Rule 30 | { 31 | private const ERROR_IDENTIFIER = 'packageVersion'; 32 | 33 | private const COMPARATORS = ['<', '>', '=']; 34 | 35 | // composer package-name pattern from https://getcomposer.org/doc/04-schema.md#name 36 | // adjusted because of backtrack limit issues https://github.com/staabm/phpstan-todo-by/issues/44 37 | private const PATTERN = <<<'REGEXP' 38 | { 39 | @?(?:TODO|FIXME|XXX) # possible @ prefix 40 | @?[a-zA-Z0-9_-]* # optional username 41 | \s*[:-]?\s* # optional colon or hyphen 42 | \s+ # keyword/version separator 43 | (?:(?P(php|[a-z0-9]([_.-]?[a-z0-9]++)*+/[a-z0-9](([_.]|-{1,2})?[a-z0-9]++)*+)):) # "php" or a composer package name, followed by ":" 44 | (?P[<>=]?[^\s:\-]+) # version 45 | \s*[:-]?\s* # optional colon or hyphen 46 | (?P(?:(?!\*+/).)*) # rest of line as comment text, excluding block end 47 | }ix 48 | REGEXP; 49 | 50 | private ExpiredCommentErrorBuilder $errorBuilder; 51 | 52 | private string $workingDirectory; 53 | 54 | /** 55 | * @var null|string|IdentifierRuleError 56 | */ 57 | private $phpPlatformVersion; 58 | 59 | /** 60 | * @var array 61 | */ 62 | private array $virtualPackages; 63 | 64 | /** 65 | * @param array $virtualPackages 66 | */ 67 | public function __construct( 68 | ExpiredCommentErrorBuilder $errorBuilder, 69 | string $workingDirectory, 70 | array $virtualPackages 71 | ) { 72 | $this->workingDirectory = $workingDirectory; 73 | $this->virtualPackages = $virtualPackages; 74 | $this->errorBuilder = $errorBuilder; 75 | 76 | // require the top level installed versions, so we don't mix it up with the one in phpstan.phar 77 | $installedVersions = $this->workingDirectory . '/vendor/composer/InstalledVersions.php'; 78 | if (!class_exists(InstalledVersions::class, false) && is_readable($installedVersions)) { 79 | require_once $installedVersions; 80 | } 81 | } 82 | 83 | public function getNodeType(): string 84 | { 85 | return Node::class; 86 | } 87 | 88 | public function processNode(Node $node, Scope $scope): array 89 | { 90 | $it = CommentMatcher::matchComments($node, self::PATTERN); 91 | 92 | $errors = []; 93 | foreach ($it as $comment => $matches) { 94 | /** @var array> $matches */ 95 | foreach ($matches as $match) { 96 | $package = $match['package'][0]; 97 | $version = $match['version'][0]; 98 | $todoText = trim($match['comment'][0]); 99 | 100 | // assume a min version constraint, when the comment does not specify a comparator 101 | if (null === $this->getVersionComparator($version)) { 102 | $version = '>='. $version; 103 | } 104 | 105 | if ('php' === $package) { 106 | $satisfiesOrError = $this->satisfiesPhpPlatformPackage($package, $version, $comment, $match[0][1]); 107 | } elseif (array_key_exists($package, $this->virtualPackages)) { 108 | $satisfiesOrError = $this->satisfiesVirtualPackage($package, $version, $comment, $match[0][1]); 109 | } else { 110 | $satisfiesOrError = $this->satisfiesInstalledPackage($package, $version, $comment, $match[0][1]); 111 | } 112 | 113 | if ($satisfiesOrError instanceof RuleError) { 114 | $errors[] = $satisfiesOrError; 115 | continue; 116 | } 117 | if (false === $satisfiesOrError) { 118 | continue; 119 | } 120 | 121 | // If there is further text, append it. 122 | if ('' !== $todoText) { 123 | $errorMessage = '"'. $package .'" version requirement "'. $version .'" satisfied: '. rtrim($todoText, '.') .'.'; 124 | } else { 125 | $errorMessage = '"'. $package .'" version requirement "'. $version .'" satisfied.'; 126 | } 127 | 128 | $errors[] = $this->errorBuilder->buildError( 129 | $comment->getText(), 130 | $comment->getStartLine(), 131 | $errorMessage, 132 | self::ERROR_IDENTIFIER, 133 | null, 134 | $match[0][1] 135 | ); 136 | } 137 | } 138 | 139 | return $errors; 140 | } 141 | 142 | /** 143 | * @return bool|IdentifierRuleError 144 | */ 145 | private function satisfiesPhpPlatformPackage(string $package, string $version, Comment $comment, int $wholeMatchStartOffset) 146 | { 147 | $phpPlatformVersion = $this->readPhpPlatformVersion($comment, $wholeMatchStartOffset); 148 | if ($phpPlatformVersion instanceof RuleError) { 149 | return $phpPlatformVersion; 150 | } 151 | 152 | $versionParser = new VersionParser(); 153 | $provided = $versionParser->parseConstraints($phpPlatformVersion); 154 | 155 | try { 156 | $constraint = $versionParser->parseConstraints($version); 157 | } catch (UnexpectedValueException $e) { 158 | return $this->errorBuilder->buildError( 159 | $comment->getText(), 160 | $comment->getStartLine(), 161 | 'Invalid version constraint "' . $version . '" for package "' . $package . '".', 162 | self::ERROR_IDENTIFIER, 163 | null, 164 | $wholeMatchStartOffset 165 | ); 166 | } 167 | 168 | return $provided->matches($constraint); 169 | } 170 | 171 | /** 172 | * @return bool|IdentifierRuleError 173 | */ 174 | private function satisfiesVirtualPackage(string $package, string $version, Comment $comment, int $wholeMatchStartOffset) 175 | { 176 | $versionParser = new VersionParser(); 177 | try { 178 | $provided = $versionParser->parseConstraints( 179 | $this->virtualPackages[$package] 180 | ); 181 | } catch (UnexpectedValueException $e) { 182 | return $this->errorBuilder->buildError( 183 | $comment->getText(), 184 | $comment->getStartLine(), 185 | 'Invalid virtual-package "' . $package . '": "' . $this->virtualPackages[$package] . '" provided via PHPStan config file.', 186 | self::ERROR_IDENTIFIER, 187 | null, 188 | $wholeMatchStartOffset 189 | ); 190 | } 191 | 192 | try { 193 | $constraint = $versionParser->parseConstraints($version); 194 | } catch (UnexpectedValueException $e) { 195 | return $this->errorBuilder->buildError( 196 | $comment->getText(), 197 | $comment->getStartLine(), 198 | 'Invalid version constraint "' . $version . '" for virtual-package "' . $package . '".', 199 | self::ERROR_IDENTIFIER, 200 | null, 201 | $wholeMatchStartOffset 202 | ); 203 | } 204 | 205 | return $provided->matches($constraint); 206 | } 207 | 208 | /** 209 | * @return IdentifierRuleError|string 210 | */ 211 | private function readPhpPlatformVersion(Comment $comment, int $wholeMatchStartOffset) 212 | { 213 | if (null !== $this->phpPlatformVersion) { 214 | return $this->phpPlatformVersion; 215 | } 216 | 217 | /** @phpstan-ignore-next-line missing bc promise */ 218 | $config = ComposerHelper::getComposerConfig($this->workingDirectory); 219 | 220 | // fallback to current working directory 221 | if (null === $config) { 222 | /** @phpstan-ignore-next-line missing bc promise */ 223 | $config = ComposerHelper::getComposerConfig(getcwd()); 224 | } 225 | 226 | if (null === $config) { 227 | return $this->phpPlatformVersion = $this->errorBuilder->buildError( 228 | $comment->getText(), 229 | $comment->getStartLine(), 230 | 'Unable to find composer.json in '. $this->workingDirectory, 231 | self::ERROR_IDENTIFIER, 232 | null, 233 | $wholeMatchStartOffset 234 | ); 235 | } 236 | 237 | if ( 238 | !isset($config['require']) 239 | || !is_array($config['require']) 240 | || !isset($config['require']['php']) 241 | || !is_string($config['require']['php']) 242 | ) { 243 | return $this->phpPlatformVersion = $this->errorBuilder->buildError( 244 | $comment->getText(), 245 | $comment->getStartLine(), 246 | 'Missing php platform requirement in '. $this->workingDirectory .'/composer.json', 247 | self::ERROR_IDENTIFIER, 248 | null, 249 | $wholeMatchStartOffset 250 | ); 251 | } 252 | 253 | return $this->phpPlatformVersion = $config['require']['php']; 254 | } 255 | 256 | /** 257 | * @return bool|IdentifierRuleError 258 | */ 259 | private function satisfiesInstalledPackage(string $package, string $version, Comment $comment, int $wholeMatchStartOffset) 260 | { 261 | $versionParser = new VersionParser(); 262 | 263 | // see https://getcomposer.org/doc/07-runtime.md#installed-versions 264 | if (!InstalledVersions::isInstalled($package)) { 265 | return $this->errorBuilder->buildError( 266 | $comment->getText(), 267 | $comment->getStartLine(), 268 | 'Unknown package "' . $package . '". It is neither installed via composer.json nor declared as virtual package via PHPStan config.', 269 | self::ERROR_IDENTIFIER, 270 | null, 271 | $wholeMatchStartOffset 272 | ); 273 | } 274 | 275 | try { 276 | return InstalledVersions::satisfies($versionParser, $package, $version); 277 | } catch (UnexpectedValueException $e) { 278 | return $this->errorBuilder->buildError( 279 | $comment->getText(), 280 | $comment->getStartLine(), 281 | 'Invalid version constraint "' . $version . '" for package "' . $package . '".', 282 | self::ERROR_IDENTIFIER, 283 | null, 284 | $wholeMatchStartOffset 285 | ); 286 | } 287 | } 288 | 289 | private function getVersionComparator(string $version): ?string 290 | { 291 | $comparator = null; 292 | for ($i = 0; $i < strlen($version); ++$i) { 293 | if (!in_array($version[$i], self::COMPARATORS)) { 294 | break; 295 | } 296 | $comparator .= $version[$i]; 297 | } 298 | 299 | return $comparator; 300 | } 301 | } 302 | -------------------------------------------------------------------------------- /src/TodoBySymfonyDeprecationRule.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | final class TodoBySymfonyDeprecationRule implements Rule 23 | { 24 | private const ERROR_IDENTIFIER = 'sfDeprecation'; 25 | 26 | private string $workingDirectory; 27 | 28 | public function __construct( 29 | string $workingDirectory 30 | ) { 31 | $this->workingDirectory = $workingDirectory; 32 | 33 | // require the top level installed versions, so we don't mix it up with the one in phpstan.phar 34 | $installedVersions = $this->workingDirectory . '/vendor/composer/InstalledVersions.php'; 35 | if (!class_exists(InstalledVersions::class, false) && is_readable($installedVersions)) { 36 | require_once $installedVersions; 37 | } 38 | } 39 | 40 | public function getNodeType(): string 41 | { 42 | return Node\Expr\FuncCall::class; 43 | } 44 | 45 | public function processNode(Node $node, Scope $scope): array 46 | { 47 | if (!($node->name instanceof Node\Name)) { 48 | return []; 49 | } 50 | 51 | $args = $node->getArgs(); 52 | if (count($args) < 3) { 53 | return []; 54 | } 55 | 56 | $functionName = strtolower($node->name->toString()); 57 | if ('trigger_deprecation' !== $functionName) { 58 | return []; 59 | } 60 | 61 | $packageArgType = $scope->getType($args[0]->value); 62 | $versionArgType = $scope->getType($args[1]->value); 63 | $messageArgType = $scope->getType($args[2]->value); 64 | 65 | $messages = $messageArgType->getConstantStrings(); 66 | if (1 !== count($messages)) { 67 | return []; 68 | } 69 | $message = $messages[0]->getValue(); 70 | 71 | $errors = []; 72 | foreach ($packageArgType->getConstantStrings() as $package) { 73 | foreach ($versionArgType->getConstantStrings() as $version) { 74 | $satisfiesOrError = $this->satisfiesInstalledPackage($package->getValue(), $version->getValue()); 75 | if ($satisfiesOrError instanceof RuleError) { 76 | $errors[] = $satisfiesOrError; 77 | continue; 78 | } 79 | if (true !== $satisfiesOrError) { 80 | continue; 81 | } 82 | 83 | $errorMessage = 'Since %s %s: %s.'; 84 | $errors[] = RuleErrorBuilder::message( 85 | sprintf($errorMessage, $package->getValue(), $version->getValue(), $message) 86 | )->identifier(ExpiredCommentErrorBuilder::ERROR_IDENTIFIER_PREFIX.self::ERROR_IDENTIFIER)->build(); 87 | } 88 | } 89 | 90 | return $errors; 91 | } 92 | 93 | /** 94 | * @return bool|IdentifierRuleError 95 | */ 96 | private function satisfiesInstalledPackage(string $package, string $version) 97 | { 98 | $versionParser = new VersionParser(); 99 | 100 | // see https://getcomposer.org/doc/07-runtime.md#installed-versions 101 | if (!InstalledVersions::isInstalled($package)) { 102 | return false; 103 | } 104 | 105 | try { 106 | return InstalledVersions::satisfies($versionParser, $package, '>='.$version); 107 | } catch (UnexpectedValueException $e) { 108 | return RuleErrorBuilder::message('Invalid version constraint "' . $version . '" for package "' . $package . '".') 109 | ->identifier(ExpiredCommentErrorBuilder::ERROR_IDENTIFIER_PREFIX.self::ERROR_IDENTIFIER) 110 | ->build(); 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/TodoByTicketCollector.php: -------------------------------------------------------------------------------- 1 | > 15 | */ 16 | final class TodoByTicketCollector implements Collector 17 | { 18 | private TicketRuleConfiguration $configuration; 19 | 20 | public function __construct(TicketRuleConfiguration $configuration) 21 | { 22 | $this->configuration = $configuration; 23 | } 24 | 25 | public function getNodeType(): string 26 | { 27 | return Node::class; 28 | } 29 | 30 | public function processNode(Node $node, Scope $scope) 31 | { 32 | $it = CommentMatcher::matchComments($node, $this->createPattern()); 33 | 34 | $tickets = []; 35 | foreach ($it as $comment => $matches) { 36 | $text = $comment->getText(); 37 | $startLine = $comment->getStartLine(); 38 | 39 | /** @var array> $matches */ 40 | foreach ($matches as $match) { 41 | $ticketKey = $match['ticketKey'][0]; 42 | $todoText = trim($match['comment'][0]); 43 | 44 | $tickets[] = [ 45 | $text, 46 | $startLine, 47 | $ticketKey, 48 | $todoText, 49 | $match[0][1], // wholeMatchStartOffset, 50 | $startLine, 51 | ]; 52 | } 53 | } 54 | 55 | // don't return empty array so we don't pollute the result cache 56 | // see https://github.com/phpstan/phpstan/discussions/11701#discussioncomment-10660711 57 | if ([] !== $tickets) { 58 | return $tickets; 59 | } 60 | return null; 61 | } 62 | 63 | private function createPattern(): string 64 | { 65 | $keyRegex = $this->configuration->getKeyPattern(); 66 | 67 | return <<<"REGEXP" 68 | { 69 | @?(?:TODO|FIXME|XXX) # possible @ prefix 70 | @?[a-zA-Z0-9_-]* # optional username 71 | \s*[:-]?\s* # optional colon or hyphen 72 | \s+ # keyword/ticket separator 73 | (?P$keyRegex) # ticket key 74 | \s*[:-]?\s* # optional colon or hyphen 75 | (?P(?:(?!\*+/).)*) # rest of line as comment text, excluding block end 76 | }ix 77 | REGEXP; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/TodoByTicketRule.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | final class TodoByTicketRule implements Rule 21 | { 22 | public const ERROR_IDENTIFIER = 'ticket'; 23 | 24 | private TicketRuleConfiguration $configuration; 25 | private ExpiredCommentErrorBuilder $errorBuilder; 26 | 27 | public function __construct(TicketRuleConfiguration $configuration, ExpiredCommentErrorBuilder $errorBuilder) 28 | { 29 | $this->configuration = $configuration; 30 | $this->errorBuilder = $errorBuilder; 31 | } 32 | 33 | public function getNodeType(): string 34 | { 35 | return CollectedDataNode::class; 36 | } 37 | 38 | public function processNode(Node $node, Scope $scope): array 39 | { 40 | $collectorData = $node->get(TodoByTicketCollector::class); 41 | 42 | $ticketKeys = []; 43 | foreach ($collectorData as $collected) { 44 | foreach ($collected as $tickets) { 45 | foreach ($tickets as [$comment, $startLine, $ticketKey, $todoText, $wholeMatchStartOffset, $line]) { 46 | if ([] !== $this->configuration->getKeyPrefixes() && !$this->hasPrefix($ticketKey)) { 47 | continue; 48 | } 49 | if ('' === $ticketKey) { 50 | continue; 51 | } 52 | // de-duplicate keys 53 | $ticketKeys[$ticketKey] = true; 54 | } 55 | } 56 | } 57 | 58 | if ([] === $ticketKeys) { 59 | return []; 60 | } 61 | 62 | $keyToTicketStatus = $this->configuration->getFetcher()->fetchTicketStatus( 63 | array_keys($ticketKeys) 64 | ); 65 | 66 | $errors = []; 67 | foreach ($collectorData as $file => $collected) { 68 | foreach ($collected as $tickets) { 69 | foreach ($tickets as [$comment, $startLine, $ticketKey, $todoText, $wholeMatchStartOffset, $line]) { 70 | if ([] !== $this->configuration->getKeyPrefixes() && !$this->hasPrefix($ticketKey)) { 71 | continue; 72 | } 73 | 74 | if (!array_key_exists($ticketKey, $keyToTicketStatus)) { 75 | throw new RuntimeException("Missing ticket-status for key $ticketKey"); 76 | } 77 | $ticketStatus = $keyToTicketStatus[$ticketKey]; 78 | 79 | if (null === $ticketStatus) { 80 | $errors[] = $this->errorBuilder->buildFileError( 81 | $comment, 82 | $startLine, 83 | "Ticket $ticketKey doesn't exist or provided credentials do not allow for viewing it.", 84 | self::ERROR_IDENTIFIER, 85 | null, 86 | $wholeMatchStartOffset, 87 | $file, 88 | $line 89 | ); 90 | 91 | continue; 92 | } 93 | 94 | if (!in_array($ticketStatus, $this->configuration->getResolvedStatuses(), true)) { 95 | continue; 96 | } 97 | 98 | if ('' !== $todoText) { 99 | $errorMessage = "Should have been resolved in {$ticketKey}: ". rtrim($todoText, '.') .'.'; 100 | } else { 101 | $errorMessage = "Comment should have been resolved in {$ticketKey}."; 102 | } 103 | 104 | $errors[] = $this->errorBuilder->buildFileError( 105 | $comment, 106 | $startLine, 107 | $errorMessage, 108 | self::ERROR_IDENTIFIER, 109 | "See {$this->configuration->getFetcher()->resolveTicketUrl($ticketKey)}", 110 | $wholeMatchStartOffset, 111 | $file, 112 | $line 113 | ); 114 | } 115 | } 116 | } 117 | 118 | return $errors; 119 | } 120 | 121 | private function hasPrefix(string $ticketKey): bool 122 | { 123 | foreach ($this->configuration->getKeyPrefixes() as $prefix) { 124 | if (substr($ticketKey, 0, strlen($prefix)) === $prefix) { 125 | return true; 126 | } 127 | } 128 | 129 | return false; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/TodoByVersionRule.php: -------------------------------------------------------------------------------- 1 | 24 | */ 25 | final class TodoByVersionRule implements Rule 26 | { 27 | private const ERROR_IDENTIFIER = 'version'; 28 | 29 | private const COMPARATORS = ['<', '>', '=']; 30 | 31 | private const PATTERN = <<<'REGEXP' 32 | { 33 | @?(?:TODO|FIXME|XXX) # possible @ prefix 34 | @?[a-zA-Z0-9_-]* # optional username 35 | \s*[:-]?\s* # optional colon or hyphen 36 | \s+ # keyword/version separator 37 | (?P[<>=]?[0-9]+\.[0-9]+(\.[0-9]+)?) # version 38 | \s*[:-]?\s* # optional colon or hyphen 39 | (?P(?:(?!\*+/).)*) # rest of line as comment text, excluding block end 40 | }ix 41 | REGEXP; 42 | 43 | private ReferenceVersionFinder $referenceVersionFinder; 44 | 45 | private bool $singleGitRepo; 46 | 47 | /** 48 | * @var array 49 | */ 50 | private array $referenceVersions = []; 51 | 52 | private ExpiredCommentErrorBuilder $errorBuilder; 53 | 54 | public function __construct( 55 | bool $singleGitRepo, 56 | ReferenceVersionFinder $refVersionFinder, 57 | ExpiredCommentErrorBuilder $errorBuilder 58 | ) { 59 | $this->referenceVersionFinder = $refVersionFinder; 60 | $this->errorBuilder = $errorBuilder; 61 | $this->singleGitRepo = $singleGitRepo; 62 | } 63 | 64 | public function getNodeType(): string 65 | { 66 | return Node::class; 67 | } 68 | 69 | public function processNode(Node $node, Scope $scope): array 70 | { 71 | $it = CommentMatcher::matchComments($node, self::PATTERN); 72 | 73 | $errors = []; 74 | $versionParser = new VersionParser(); 75 | foreach ($it as $comment => $matches) { 76 | try { 77 | $referenceVersion = $this->getReferenceVersion($scope); 78 | } catch (LatestTagNotFoundException $e) { 79 | return [ 80 | RuleErrorBuilder::message($e->getMessage()) 81 | ->tip('See https://github.com/staabm/phpstan-todo-by#could-not-determine-latest-git-tag-error') 82 | ->identifier(ExpiredCommentErrorBuilder::ERROR_IDENTIFIER_PREFIX.self::ERROR_IDENTIFIER) 83 | ->build(), 84 | ]; 85 | } 86 | $provided = $versionParser->parseConstraints( 87 | $referenceVersion 88 | ); 89 | 90 | /** @var array> $matches */ 91 | foreach ($matches as $match) { 92 | $version = $match['version'][0]; 93 | $todoText = trim($match['comment'][0]); 94 | 95 | // assume a min version constraint, when the comment does not specify a comparator 96 | if (null === $this->getVersionComparator($version)) { 97 | $version = '>='. $version; 98 | } 99 | 100 | try { 101 | $constraint = $versionParser->parseConstraints($version); 102 | } catch (UnexpectedValueException $e) { 103 | $errors[] = $this->errorBuilder->buildError( 104 | $comment->getText(), 105 | $comment->getStartLine(), 106 | 'Invalid version constraint "' . $version . '".', 107 | self::ERROR_IDENTIFIER, 108 | null, 109 | $match[0][1] 110 | ); 111 | 112 | continue; 113 | } 114 | 115 | if (!$provided->matches($constraint)) { 116 | continue; 117 | } 118 | 119 | // If there is further text, append it. 120 | if ('' !== $todoText) { 121 | $errorMessage = "Version requirement {$version} satisfied: ". rtrim($todoText, '.') .'.'; 122 | } else { 123 | $errorMessage = "Version requirement {$version} satisfied."; 124 | } 125 | 126 | $errors[] = $this->errorBuilder->buildError( 127 | $comment->getText(), 128 | $comment->getStartLine(), 129 | $errorMessage, 130 | self::ERROR_IDENTIFIER, 131 | "Calculated reference version is '". $referenceVersion ."'.\n\n See also:\n https://github.com/staabm/phpstan-todo-by#reference-version", 132 | $match[0][1] 133 | ); 134 | } 135 | } 136 | 137 | return $errors; 138 | } 139 | 140 | private function getReferenceVersion(Scope $scope): string 141 | { 142 | if ($this->singleGitRepo) { 143 | // same reference shared by all files 144 | $cacheKey = '__todoby__global__'; 145 | $workingDirectory = null; 146 | } else { 147 | // reference only shared between files in the same directory 148 | // slower but adds support for analyzing codebases with several git clones 149 | $cacheKey = $workingDirectory = dirname($scope->getFile()); 150 | } 151 | 152 | if (!array_key_exists($cacheKey, $this->referenceVersions)) { 153 | $versionParser = new VersionParser(); 154 | // lazy get the version, as it might incur subprocess creation 155 | $this->referenceVersions[$cacheKey] = $versionParser->normalize( 156 | $this->referenceVersionFinder->find($workingDirectory) 157 | ); 158 | } 159 | 160 | return $this->referenceVersions[$cacheKey]; 161 | } 162 | 163 | private function getVersionComparator(string $version): ?string 164 | { 165 | $comparator = null; 166 | for ($i = 0; $i < strlen($version); ++$i) { 167 | if (!in_array($version[$i], self::COMPARATORS)) { 168 | break; 169 | } 170 | $comparator .= $version[$i]; 171 | } 172 | 173 | return $comparator; 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/utils/CommentMatcher.php: -------------------------------------------------------------------------------- 1 | > 16 | */ 17 | public static function matchComments(Node $node, string $pattern): iterable 18 | { 19 | if ( 20 | $node instanceof Node\Stmt\InlineHTML 21 | || $node instanceof Node\Name 22 | || $node instanceof Node\Identifier 23 | ) { 24 | // prevent unnecessary work / reduce memory consumption 25 | return []; 26 | } 27 | 28 | if ( 29 | $node instanceof VirtualNode 30 | || $node instanceof Node\Expr 31 | ) { 32 | // prevent duplicate errors 33 | return []; 34 | } 35 | 36 | foreach ($node->getComments() as $comment) { 37 | $text = $comment->getText(); 38 | 39 | /** 40 | * PHP doc comments have the entire multi-line comment as the text. 41 | * Since this could potentially contain multiple "todo" comments, we need to check all lines. 42 | * This works for single line comments as well. 43 | * 44 | * PREG_OFFSET_CAPTURE: Track where each "todo" comment starts within the whole comment text. 45 | * PREG_SET_ORDER: Make each value of $matches be structured the same as if from preg_match(). 46 | */ 47 | if ( 48 | false === preg_match_all($pattern, $text, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER) 49 | || 0 === count($matches) 50 | ) { 51 | if (PREG_NO_ERROR !== preg_last_error()) { 52 | throw new RuntimeException('Error in PCRE: '. preg_last_error_msg()); 53 | } 54 | 55 | continue; 56 | } 57 | 58 | yield $comment => $matches; 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/utils/CredentialsHelper.php: -------------------------------------------------------------------------------- 1 | nonIgnorable = $nonIgnorable; 17 | } 18 | 19 | public function buildError( 20 | string $comment, 21 | int $startLine, 22 | string $errorMessage, 23 | string $errorIdentifier, 24 | ?string $tip, 25 | int $wholeMatchStartOffset 26 | ): \PHPStan\Rules\IdentifierRuleError { 27 | return $this->build( 28 | $comment, 29 | $startLine, 30 | $errorMessage, 31 | $errorIdentifier, 32 | $tip, 33 | $wholeMatchStartOffset, 34 | null, 35 | null 36 | ); 37 | } 38 | 39 | public function buildFileError( 40 | string $comment, 41 | int $startLine, 42 | string $errorMessage, 43 | string $errorIdentifier, 44 | ?string $tip, 45 | int $wholeMatchStartOffset, 46 | string $file, 47 | int $line 48 | ): \PHPStan\Rules\IdentifierRuleError { 49 | return $this->build( 50 | $comment, 51 | $startLine, 52 | $errorMessage, 53 | $errorIdentifier, 54 | $tip, 55 | $wholeMatchStartOffset, 56 | $file, 57 | $line 58 | ); 59 | } 60 | 61 | private function build( 62 | string $comment, 63 | int $startLine, 64 | string $errorMessage, 65 | string $errorIdentifier, 66 | ?string $tip, 67 | int $wholeMatchStartOffset, 68 | ?string $file, 69 | ?int $line 70 | ): \PHPStan\Rules\IdentifierRuleError { 71 | // Count the number of newlines between the start of the whole comment, and the start of the match. 72 | $newLines = substr_count($comment, "\n", 0, $wholeMatchStartOffset); 73 | 74 | // Set the message line to match the line the comment actually starts on. 75 | $messageLine = $startLine + $newLines; 76 | 77 | $errBuilder = RuleErrorBuilder::message($errorMessage) 78 | ->line($messageLine) 79 | ->identifier(self::ERROR_IDENTIFIER_PREFIX.$errorIdentifier); 80 | 81 | if (null !== $file) { 82 | $errBuilder->file($file); 83 | } 84 | if (null !== $line) { 85 | $errBuilder->line($line); 86 | } 87 | if ($this->nonIgnorable) { 88 | $errBuilder->nonIgnorable(); 89 | } 90 | if (null !== $tip) { 91 | $errBuilder->tip($tip); 92 | } 93 | return $errBuilder->build(); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/utils/GitTagFetcher.php: -------------------------------------------------------------------------------- 1 | $urls 13 | * @param list $headers 14 | * 15 | * @return non-empty-array 16 | */ 17 | public function getMulti(array $urls, array $headers): array 18 | { 19 | $mh = curl_multi_init(); 20 | 21 | $handles = []; 22 | 23 | foreach ($urls as $url) { 24 | $curl = curl_init($url); 25 | if (!$curl) { 26 | throw new RuntimeException('Could not initialize cURL connection'); 27 | } 28 | 29 | // see https://stackoverflow.com/a/27776164/1597388 30 | curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 3); 31 | curl_setopt($curl, CURLOPT_TIMEOUT, 10); 32 | 33 | curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true); 34 | curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); 35 | 36 | if ([] !== $headers) { 37 | curl_setopt($curl, CURLOPT_HTTPHEADER, $headers); 38 | } 39 | 40 | $handles[$url] = $curl; 41 | } 42 | 43 | foreach ($handles as $handle) { 44 | curl_multi_add_handle($mh, $handle); 45 | } 46 | 47 | do { 48 | $status = curl_multi_exec($mh, $active); 49 | if ($active) { 50 | // Wait a short time for more activity 51 | curl_multi_select($mh); 52 | } 53 | } while ($active && CURLM_OK == $status); 54 | 55 | $result = []; 56 | foreach ($handles as $url => $handle) { 57 | $response = curl_multi_getcontent($handle); 58 | $errno = curl_multi_errno($mh); 59 | 60 | if ($errno || !is_string($response)) { 61 | $message = curl_multi_strerror($errno); 62 | if (null === $message) { 63 | $message = "Could not fetch url $url"; 64 | } 65 | throw new RuntimeException($message); 66 | } 67 | 68 | $responseCode = curl_getinfo($handle, CURLINFO_RESPONSE_CODE); 69 | $result[$url] = [$responseCode, $response]; 70 | 71 | curl_multi_remove_handle($mh, $handle); 72 | curl_close($handle); 73 | } 74 | curl_multi_close($mh); 75 | 76 | return $result; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/utils/LatestTagNotFoundException.php: -------------------------------------------------------------------------------- 1 | referenceVersion = $referenceVersion; 22 | $this->fetcher = $fetcher; 23 | } 24 | 25 | public function find(?string $workingDirectory): string 26 | { 27 | if (in_array($this->referenceVersion, ['nextMajor', 'nextMinor', 'nextPatch'], true)) { 28 | $latestTagVersion = $this->fetcher->fetchLatestTagVersion($workingDirectory); 29 | 30 | if (null !== $latestTagVersion) { 31 | return $this->nextVersion($latestTagVersion); 32 | } 33 | } 34 | 35 | // a version string like "1.2.3" 36 | return $this->referenceVersion; 37 | } 38 | 39 | // adopted from https://github.com/WyriHaximus/github-action-next-semvers/blob/master/src/Next.php 40 | private function nextVersion(string $versionString): string 41 | { 42 | try { 43 | $version = Version::fromString($versionString); 44 | } catch (InvalidVersionString $invalidVersionException) { 45 | if (3 === count(explode('.', $versionString))) { 46 | throw $invalidVersionException; 47 | } 48 | 49 | // split versionString by '-' (in case it is a pre-release) 50 | if (str_contains($versionString, '-')) { 51 | [$versionString, $preRelease] = explode('-', $versionString, self::PRE_RELEASE_CHUNK_COUNT); 52 | $versionString .= '.0-' . $preRelease; 53 | } else { 54 | $versionString .= '.0'; 55 | } 56 | 57 | return self::nextVersion($versionString); 58 | } 59 | 60 | $wasPreRelease = false; 61 | 62 | // if current version is a pre-release 63 | if ($version->isPreRelease()) { 64 | // get current version by removing anything else (e.g., pre-release, build-id, ...) 65 | $version = Version::from($version->getMajor(), $version->getMinor(), $version->getPatch()); 66 | $wasPreRelease = true; 67 | } 68 | 69 | if ('nextMajor' === $this->referenceVersion) { 70 | return $version->incrementMajor()->toString(); 71 | } 72 | if ('nextMinor' === $this->referenceVersion) { 73 | return $version->incrementMinor()->toString(); 74 | } 75 | if ('nextPatch' === $this->referenceVersion) { 76 | // check if current version is a pre-release 77 | if ($wasPreRelease) { 78 | // use current version (without pre-release) 79 | return $version->__toString(); 80 | } 81 | return $version->incrementPatch()->toString(); 82 | } 83 | 84 | throw new RuntimeException('Invalid reference version: ' . $this->referenceVersion); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/utils/TagFetcher.php: -------------------------------------------------------------------------------- 1 | [\w\-\.]+)\/)? # optional owner with slash separator 21 | (?P[\w\-\.]+)? # optional repo 22 | (\#|GH-)(?P\d+) # ticket number 23 | '; 24 | 25 | private string $defaultOwner; 26 | private string $defaultRepo; 27 | private ?string $accessToken; 28 | 29 | private HttpClient $httpClient; 30 | 31 | public function __construct(string $defaultOwner, string $defaultRepo, ?string $credentials, ?string $credentialsFilePath, HttpClient $httpClient) 32 | { 33 | $this->defaultOwner = $defaultOwner; 34 | $this->defaultRepo = $defaultRepo; 35 | $this->accessToken = CredentialsHelper::getCredentials($credentials, $credentialsFilePath); 36 | 37 | $this->httpClient = $httpClient; 38 | } 39 | 40 | public function fetchTicketStatus(array $ticketKeys): array 41 | { 42 | $ticketUrls = []; 43 | 44 | foreach ($ticketKeys as $ticketKey) { 45 | [$owner, $repo, $number] = $this->processKey($ticketKey); 46 | 47 | $ticketUrls[$ticketKey] = $this->buildUrl($owner, $repo, $number); 48 | } 49 | 50 | return $this->fetchTicketStatusByUrls($ticketUrls); 51 | } 52 | 53 | /** @return non-empty-string */ 54 | public function buildUrl(string $owner, string $repo, string $number): string 55 | { 56 | return "https://api.github.com/repos/$owner/$repo/issues/$number"; 57 | } 58 | 59 | /** 60 | * @param non-empty-array $ticketUrls 61 | * 62 | * @return non-empty-array 63 | */ 64 | public function fetchTicketStatusByUrls(array $ticketUrls): array 65 | { 66 | $apiVersion = self::API_VERSION; 67 | 68 | $headers = [ 69 | 'User-agent: phpstan-todo-by', 70 | 'Accept: application/vnd.github+json', 71 | "X-GitHub-Api-Version: $apiVersion", 72 | ]; 73 | 74 | if ($this->accessToken) { 75 | $headers[] = "Authorization: Bearer $this->accessToken"; 76 | } 77 | 78 | $responses = $this->httpClient->getMulti($ticketUrls, $headers); 79 | 80 | $results = []; 81 | 82 | $urlsToKeys = []; 83 | foreach ($ticketUrls as $key => $url) { 84 | $urlsToKeys[$url][] = $key; 85 | } 86 | 87 | foreach ($responses as $url => [$responseCode, $response]) { 88 | if (404 === $responseCode) { 89 | $results[$url] = null; 90 | continue; 91 | } 92 | 93 | if (200 !== $responseCode) { 94 | throw new RuntimeException("Could not fetch ticket's status from GitHub with $url"); 95 | } 96 | 97 | $data = json_decode($response, true); 98 | if (!is_array($data) || !array_key_exists('state', $data) || !is_string($data['state'])) { 99 | throw new RuntimeException("GitHub returned invalid response body with $url"); 100 | } 101 | 102 | foreach ($urlsToKeys[$url] as $ticketKey) { 103 | $results[$ticketKey] = $data['state']; 104 | } 105 | } 106 | 107 | return $results; 108 | } 109 | 110 | public static function getKeyPattern(): string 111 | { 112 | return self::KEY_REGEX; 113 | } 114 | 115 | public function resolveTicketUrl(string $ticketKey): string 116 | { 117 | [$owner, $repo, $number] = $this->processKey($ticketKey); 118 | 119 | return "https://github.com/$owner/$repo/issues/$number"; 120 | } 121 | 122 | /** @return array{string,string,string} */ 123 | private function processKey(string $ticketKey): array 124 | { 125 | $keyRegex = self::KEY_REGEX; 126 | preg_match_all("/$keyRegex/ix", $ticketKey, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER); 127 | 128 | $owner = $matches[0]['githubOwner'][0] ?: $this->defaultOwner; 129 | $repo = $matches[0]['githubRepo'][0] ?: $this->defaultRepo; 130 | $number = $matches[0]['githubNumber'][0]; 131 | 132 | return [$owner, $repo, $number]; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/utils/ticket/JiraTicketStatusFetcher.php: -------------------------------------------------------------------------------- 1 | host = $host; 27 | $this->authorizationHeader = $credentials ? self::createAuthorizationHeader($credentials) : null; 28 | 29 | $this->httpClient = $httpClient; 30 | } 31 | 32 | public function fetchTicketStatus(array $ticketKeys): array 33 | { 34 | $ticketUrls = []; 35 | 36 | $apiVersion = self::API_VERSION; 37 | foreach ($ticketKeys as $ticketKey) { 38 | $ticketUrls[$ticketKey] = "{$this->host}/rest/api/$apiVersion/issue/$ticketKey?expand=status"; 39 | } 40 | 41 | $headers = []; 42 | if (null !== $this->authorizationHeader) { 43 | $headers = [ 44 | "Authorization: $this->authorizationHeader", 45 | ]; 46 | } 47 | 48 | $responses = $this->httpClient->getMulti($ticketUrls, $headers); 49 | 50 | $results = []; 51 | $urlsToKeys = array_flip($ticketUrls); 52 | foreach ($responses as $url => [$responseCode, $response]) { 53 | if (404 === $responseCode) { 54 | $results[$url] = null; 55 | continue; 56 | } 57 | 58 | if (200 !== $responseCode) { 59 | throw new RuntimeException("Could not fetch ticket's status from Jira with url $url"); 60 | } 61 | 62 | $data = self::decodeAndValidateResponse($response); 63 | 64 | $ticketKey = $urlsToKeys[$url]; 65 | $results[$ticketKey] = $data['fields']['status']['name']; 66 | } 67 | 68 | return $results; 69 | } 70 | 71 | public static function getKeyPattern(): string 72 | { 73 | return '[A-Z0-9]+-\d+'; 74 | } 75 | 76 | public function resolveTicketUrl(string $ticketKey): string 77 | { 78 | return "$this->host/browse/$ticketKey"; 79 | } 80 | 81 | /** @return array{fields: array{status: array{name: string}}} */ 82 | private static function decodeAndValidateResponse(string $body): array 83 | { 84 | $data = json_decode($body, true); 85 | 86 | if (!is_array($data) || !array_key_exists('fields', $data)) { 87 | self::throwInvalidResponse(); 88 | } 89 | 90 | $fields = $data['fields']; 91 | 92 | if (!is_array($fields) || !array_key_exists('status', $fields)) { 93 | self::throwInvalidResponse(); 94 | } 95 | 96 | $status = $fields['status']; 97 | if (!is_array($status) || !array_key_exists('name', $status)) { 98 | self::throwInvalidResponse(); 99 | } 100 | 101 | $name = $status['name']; 102 | 103 | if (!is_string($name) || '' === trim($name)) { 104 | self::throwInvalidResponse(); 105 | } 106 | 107 | return $data; 108 | } 109 | 110 | private static function createAuthorizationHeader(string $credentials): string 111 | { 112 | if (str_contains($credentials, ':')) { 113 | return 'Basic ' . base64_encode($credentials); 114 | } 115 | 116 | return "Bearer $credentials"; 117 | } 118 | 119 | /** @return never */ 120 | private static function throwInvalidResponse(): void 121 | { 122 | throw new RuntimeException('Jira returned invalid response body'); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/utils/ticket/TicketRuleConfiguration.php: -------------------------------------------------------------------------------- 1 | */ 10 | private array $resolvedStatuses; 11 | /** @var list */ 12 | private array $keyPrefixes; 13 | private TicketStatusFetcher $fetcher; 14 | 15 | /** 16 | * @param non-empty-string $keyPattern 17 | * @param list $resolvedStatuses 18 | * @param list $keyPrefixes 19 | */ 20 | public function __construct(string $keyPattern, array $resolvedStatuses, array $keyPrefixes, TicketStatusFetcher $fetcher) 21 | { 22 | $this->keyPattern = $keyPattern; 23 | $this->resolvedStatuses = $resolvedStatuses; 24 | $this->keyPrefixes = $keyPrefixes; 25 | $this->fetcher = $fetcher; 26 | } 27 | 28 | /** @return non-empty-string */ 29 | public function getKeyPattern(): string 30 | { 31 | return $this->keyPattern; 32 | } 33 | 34 | /** @return list */ 35 | public function getResolvedStatuses(): array 36 | { 37 | return $this->resolvedStatuses; 38 | } 39 | 40 | /** @return list */ 41 | public function getKeyPrefixes(): array 42 | { 43 | return $this->keyPrefixes; 44 | } 45 | 46 | public function getFetcher(): TicketStatusFetcher 47 | { 48 | return $this->fetcher; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/utils/ticket/TicketRuleConfigurationFactory.php: -------------------------------------------------------------------------------- 1 | container = $container; 15 | } 16 | 17 | public function create(): TicketRuleConfiguration 18 | { 19 | $extensionParameters = $this->container->getParameter('todo_by'); 20 | 21 | $parameters = $extensionParameters['ticket']; 22 | $resolvedStatuses = $parameters['resolvedStatuses']; 23 | $keyPrefixes = $parameters['keyPrefixes']; 24 | $tracker = $parameters['tracker']; 25 | 26 | if ('jira' === $tracker) { 27 | $fetcher = $this->container->getByType(JiraTicketStatusFetcher::class); 28 | 29 | return new TicketRuleConfiguration( 30 | $fetcher::getKeyPattern(), 31 | $resolvedStatuses, 32 | $keyPrefixes, 33 | $fetcher, 34 | ); 35 | } 36 | 37 | if ('github' === $tracker) { 38 | $fetcher = $this->container->getByType(GitHubTicketStatusFetcher::class); 39 | 40 | return new TicketRuleConfiguration( 41 | $fetcher::getKeyPattern(), 42 | ['closed'], 43 | [], 44 | $fetcher, 45 | ); 46 | } 47 | 48 | if ('youtrack' === $tracker) { 49 | $fetcher = $this->container->getByType(YouTrackTicketStatusFetcher::class); 50 | 51 | return new TicketRuleConfiguration( 52 | $fetcher::getKeyPattern(), 53 | ['resolved'], 54 | $keyPrefixes, 55 | $fetcher, 56 | ); 57 | } 58 | 59 | throw new RuntimeException("Unsupported tracker type: $tracker"); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/utils/ticket/TicketStatusFetcher.php: -------------------------------------------------------------------------------- 1 | $ticketKeys 10 | * 11 | * @return array Map using the ticket-key as key and Status name or null if ticket doesn't exist as value 12 | */ 13 | public function fetchTicketStatus(array $ticketKeys): array; 14 | 15 | /** @return non-empty-string */ 16 | public static function getKeyPattern(): string; 17 | 18 | /** 19 | * @param non-empty-string $ticketKey 20 | * 21 | * @return non-empty-string 22 | */ 23 | public function resolveTicketUrl(string $ticketKey): string; 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/ticket/YouTrackTicketStatusFetcher.php: -------------------------------------------------------------------------------- 1 | host = $host; 24 | $this->authorizationHeader = $credentials ? self::createAuthorizationHeader($credentials) : null; 25 | 26 | $this->httpClient = $httpClient; 27 | } 28 | 29 | public function fetchTicketStatus(array $ticketKeys): array 30 | { 31 | $ticketUrls = []; 32 | 33 | foreach ($ticketKeys as $ticketKey) { 34 | $ticketUrls[$ticketKey] = "{$this->host}/api/issues/$ticketKey?fields=resolved"; 35 | } 36 | 37 | $headers = []; 38 | if (null !== $this->authorizationHeader) { 39 | $headers = [ 40 | "Authorization: $this->authorizationHeader", 41 | ]; 42 | } 43 | 44 | $responses = $this->httpClient->getMulti($ticketUrls, $headers); 45 | 46 | $results = []; 47 | $urlsToKeys = array_flip($ticketUrls); 48 | foreach ($responses as $url => [$responseCode, $response]) { 49 | if (200 !== $responseCode) { 50 | throw new RuntimeException("Could not fetch ticket's status from YouTrack with url $url"); 51 | } 52 | 53 | $data = self::decodeAndValidateResponse($response); 54 | 55 | $ticketKey = $urlsToKeys[$url]; 56 | $results[$ticketKey] = null === $data['resolved'] ? 'open' : 'resolved'; 57 | } 58 | 59 | return $results; 60 | } 61 | 62 | public static function getKeyPattern(): string 63 | { 64 | return '[A-Z0-9]+-\d+'; 65 | } 66 | 67 | public function resolveTicketUrl(string $ticketKey): string 68 | { 69 | return "https://youtrack.jetbrains.com/issue/$ticketKey"; 70 | } 71 | 72 | private static function createAuthorizationHeader(string $credentials): string 73 | { 74 | return "Bearer $credentials"; 75 | } 76 | 77 | /** @return array{resolved: ?int} */ 78 | private static function decodeAndValidateResponse(string $body): array 79 | { 80 | $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR); 81 | 82 | if (!is_array($data) || !array_key_exists('resolved', $data)) { 83 | self::throwInvalidResponse(); 84 | } 85 | 86 | return $data; 87 | } 88 | 89 | /** @return never */ 90 | private static function throwInvalidResponse(): void 91 | { 92 | throw new RuntimeException('YouTrack returned invalid response body'); 93 | } 94 | } 95 | --------------------------------------------------------------------------------