Skip to content

Commit

Permalink
Add TodoByTicketRule (issue tracker support) (#26)
Browse files Browse the repository at this point in the history
Co-authored-by: Markus Staab <maggus.staab@googlemail.com>
Co-authored-by: Emil Masiakowski <emil.masiakowski@amsterdamstandard.com>
Co-authored-by: Markus Staab <markus.staab@redaxo.de>
  • Loading branch information
4 people authored Dec 27, 2023
1 parent 829088b commit 98d37d9
Show file tree
Hide file tree
Showing 12 changed files with 534 additions and 0 deletions.
95 changes: 95 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ function doFooBar() {

// TODO: php:8 drop this polyfill when php 8.x is required

// TODO: APP-2137 This has to be fixed
function doBaz() {

}

```


Expand Down Expand Up @@ -71,6 +76,8 @@ see examples of different comment variants which are supported:

// TODO: phpunit/phpunit:<5 This has to be fixed before updating to phpunit 5.x
// TODO@markus: phpunit/phpunit:5.3 This has to be fixed when updating phpunit to 5.3.x or higher

// TODO: APP-123 fix it
```

## Configuration
Expand Down Expand Up @@ -158,6 +165,94 @@ In case you are using git submodules, or the analyzed codebase consists of multi
set the `singleGitRepo` option to `false` which resolves the reference version for each directory beeing analyzed.


### Issue tracker key support

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 only Jira is 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:

```yaml
parameters:
todo_by:
ticket:
enabled: true

# a case-sensitive list of status names.
# only tickets having any of these statuses are considered resolved.
resolvedStatuses:
- Done
- Resolved
- Declined

# if your ticket key is FOO-12345, then this value should be ["FOO"].
# multiple key prefixes are allowed, e.g. ["FOO", "APP"].
# only comments with keys containing this prefix will be analyzed.
keyPrefixes:
- FOO

jira:
# e.g. https://your-company.atlassian.net
server: https://acme.atlassian.net

# see below for possible formats.
# if this value is empty, credentials file will be used instead.
credentials: %env.JIRA_TOKEN%

# 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!
credentialsFilePath: .secrets/jira-credentials.txt
```
#### Jira Credentials
This extension uses Jira's REST API to fetch ticket's status. In order for it to work, you need to configure valid credentials.
These authentication methods are supported:
- [OAuth 2.0 Access Tokens](https://confluence.atlassian.com/adminjiraserver/jira-oauth-2-0-provider-api-1115659070.html)
- [Personal Access Tokens](https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html)
- [Basic Authentication](https://developer.atlassian.com/server/jira/platform/basic-authentication/)
We recommend you use OAuth over basic authentication, especially if you use phpstan in CI.
There are multiple ways to pass your credentials to this extension.
You should choose one of them - if you define both parameters, only `credentials` parameter is considered and the file is ignored.

##### Pass credentials in environment variable

Configure `credentials` parameter to [read value from environment variable](https://phpstan.org/config-reference#environment-variables):
```yaml
parameters:
todo_by:
ticket:
jira:
credentials: %env.JIRA_TOKEN%
```

Depending on chosen authentication method its content should be:
* Access Token for token based authentication, e.g. `JIRA_TOKEN=ATATT3xFfGF0Gv_pLFSsunmigz8wq5Y0wkogoTMgxDFHyR...`
* `<username>:<passwordOrApiKey>` for basic authentication, e.g. `JIRA_TOKEN=john.doe@example.com:p@ssword`

##### Pass credentials in text file

Create text file in your project's directory (or anywhere else on your computer) and put its path into configuration:

```yaml
parameters:
todo_by:
ticket:
jira:
credentialsFilePath: path/to/file
```

**Remember not to commit this file to repository!**
Depending on chosen authentication method its value should be:
* Access Token for token based authentication, e.g. `JATATT3xFfGF0Gv_pLFSsunmigz8wq5Y0wkogoTMgxDFHyR...`
* `<username>:<passwordOrApiKey>` for basic authentication, e.g. `john.doe@example.com:p@ssword`


## Installation

Expand Down
2 changes: 2 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
],
"require": {
"php": "^7.4 || ^8.0",
"ext-curl": "*",
"ext-json": "*",
"composer-runtime-api": "^2",
"composer/semver": "^3.4",
"nikolaposa/version": "^4.1"
Expand Down
53 changes: 53 additions & 0 deletions extension.neon
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ parametersSchema:
referenceTime: string()
referenceVersion: string()
singleGitRepo: bool()
ticket: structure([
enabled: bool()
keyPrefixes: listOf(string())
resolvedStatuses: listOf(string())
jira: structure([
server: string()
credentials: schema(string(), nullable())
credentialsFilePath: schema(string(), nullable())
])
])
])

# default parameters
Expand All @@ -21,12 +31,48 @@ parameters:
# If set to false, the git tags are fetched for each directory individually (slower)
singleGitRepo: true

ticket:
# whether to analyze comments by issue tracker ticket key
enabled: false

# a case-sensitive list of status names.
# only tickets having any of these statuses are considered resolved.
resolvedStatuses: []

# if your ticket key is FOO-12345, then this value should be ["FOO"].
# multiple key prefixes are allowed, e.g. ["FOO", "APP"].
# only comments with keys containing this prefix will be analyzed.
keyPrefixes: []

jira:
# e.g. https://your-company.atlassian.net
server: https://jira.atlassian.com

# see README for possible formats.
# if this value is empty, credentials file will be used instead.
credentials: null

# path to a file containing Jira 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%

services:
-
class: staabm\PHPStanTodoBy\TodoByDateRule
tags: [phpstan.rules.rule]
arguments:
- %todo_by.referenceTime%
-
class: staabm\PHPStanTodoBy\TodoByTicketRule
arguments:
- %todo_by.ticket.resolvedStatuses%
- %todo_by.ticket.keyPrefixes%

-
class: staabm\PHPStanTodoBy\TodoByVersionRule
Expand All @@ -52,3 +98,10 @@ services:
class: staabm\PHPStanTodoBy\utils\ExpiredCommentErrorBuilder
arguments:
- %todo_by.nonIgnorable%

-
class: staabm\PHPStanTodoBy\utils\jira\JiraTicketStatusFetcher
arguments:
- %todo_by.ticket.jira.server%
- %todo_by.ticket.jira.credentials%
- %todo_by.ticket.jira.credentialsFilePath%
106 changes: 106 additions & 0 deletions src/TodoByTicketRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php

namespace staabm\PHPStanTodoBy;

use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use staabm\PHPStanTodoBy\utils\CommentMatcher;
use staabm\PHPStanTodoBy\utils\ExpiredCommentErrorBuilder;
use staabm\PHPStanTodoBy\utils\TicketStatusFetcher;

use function in_array;
use function strlen;
use function trim;

/**
* @implements Rule<Node>
*/
final class TodoByTicketRule implements Rule
{
private const PATTERN = <<<'REGEXP'
{
@?TODO # possible @ prefix
@?[a-zA-Z0-9_-]* # optional username
\s*[:-]?\s* # optional colon or hyphen
\s+ # keyword/ticket separator
(?P<ticketKey>[A-Z0-9]+-\d+) # ticket key consisting of ABC-123 or F01-12345 format
\s*[:-]?\s* # optional colon or hyphen
(?P<comment>.*) # rest of line as comment text
}ix
REGEXP;

/** @var list<non-empty-string> */
private array $resolvedStatuses;
/** @var list<non-empty-string> */
private array $keyPrefixes;
private TicketStatusFetcher $fetcher;
private ExpiredCommentErrorBuilder $errorBuilder;

/**
* @param list<non-empty-string> $resolvedStatuses
* @param list<non-empty-string> $keyPrefixes
*/
public function __construct(array $resolvedStatuses, array $keyPrefixes, TicketStatusFetcher $fetcher, ExpiredCommentErrorBuilder $errorBuilder)
{
$this->resolvedStatuses = $resolvedStatuses;
$this->keyPrefixes = $keyPrefixes;
$this->fetcher = $fetcher;
$this->errorBuilder = $errorBuilder;
}

public function getNodeType(): string
{
return Node::class;
}

public function processNode(Node $node, Scope $scope): array
{
$it = CommentMatcher::matchComments($node, self::PATTERN);

$errors = [];
foreach ($it as $comment => $matches) {
/** @var array<int, array<array{0: string, 1: int}>> $matches */
foreach ($matches as $match) {
$ticketKey = $match['ticketKey'][0];
$todoText = trim($match['comment'][0]);

if (!$this->hasPrefix($ticketKey)) {
continue;
}

$ticketStatus = $this->fetcher->fetchTicketStatus($ticketKey);

if (null === $ticketStatus || !in_array($ticketStatus, $this->resolvedStatuses, true)) {
continue;
}

if ('' !== $todoText) {
$errorMessage = "Should have been resolved in {$ticketKey}: ". rtrim($todoText, '.') .'.';
} else {
$errorMessage = "Comment should have been resolved in {$ticketKey}.";
}

$errors[] = $this->errorBuilder->buildError(
$comment,
$errorMessage,
null,
$match[0][1]
);
}
}

return $errors;
}

private function hasPrefix(string $ticketKey): bool
{
foreach ($this->keyPrefixes as $prefix) {
if (substr($ticketKey, 0, strlen($prefix)) === $prefix) {
return true;
}
}

return false;
}
}
9 changes: 9 additions & 0 deletions src/utils/TicketStatusFetcher.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

namespace staabm\PHPStanTodoBy\utils;

interface TicketStatusFetcher
{
/** @return string|null Status name or null if ticket doesn't exist */
public function fetchTicketStatus(string $ticketKey): ?string;
}
36 changes: 36 additions & 0 deletions src/utils/jira/JiraAuthorization.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

namespace staabm\PHPStanTodoBy\utils\jira;

use RuntimeException;

final class JiraAuthorization
{
public static function getCredentials(?string $credentials, ?string $credentialsFilePath): string
{
if (null !== $credentials) {
return trim($credentials);
}

if (null === $credentialsFilePath) {
throw new RuntimeException('Either credentials or credentialsFilePath parameter must be configured');
}

$credentials = file_get_contents($credentialsFilePath);

if (false === $credentials) {
throw new RuntimeException("Cannot read $credentialsFilePath file");
}

return trim($credentials);
}

public static function createAuthorizationHeader(string $credentials): string
{
if (str_contains($credentials, ':')) {
return 'Basic ' . base64_encode($credentials);
}

return "Bearer $credentials";
}
}
Loading

0 comments on commit 98d37d9

Please sign in to comment.