diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml new file mode 100644 index 0000000..f0a3ef1 --- /dev/null +++ b/.github/workflows/phpstan.yml @@ -0,0 +1,9 @@ +name: PHPStan +on: + pull_request: + push: + branches: + - dev/2.x +jobs: + phpstan: + uses: artemeon/.shared/.github/workflows/phpstan-php84-upwards.yml@main diff --git a/.github/workflows/pint.yml b/.github/workflows/pint.yml new file mode 100644 index 0000000..1c6e7f2 --- /dev/null +++ b/.github/workflows/pint.yml @@ -0,0 +1,23 @@ +name: Pint + +on: + pull_request: + +jobs: + pint: + name: Pint (PHP-CS-Fixer) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.4 + coverage: none + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Composer install + run: composer install --no-interaction --no-ansi --no-progress + - name: Run Pint + run: composer run pint diff --git a/README.md b/README.md index 27392fa..8d760f5 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # Mantis 2 GitHub Connector -[![Packagist Version](https://img.shields.io/packagist/v/artemeon/mantis2github)](https://packagist.org/packages/artemeon/mantis2github) -[![Packagist Downloads](https://img.shields.io/packagist/dt/artemeon/mantis2github)](https://packagist.org/packages/artemeon/mantis2github) -[![GitHub](https://img.shields.io/github/license/artemeon/mantis2github)](https://packagist.org/packages/artemeon/mantis2github) +[![Packagist Version](https://img.shields.io/packagist/v/artemeon/mantis2github?style=for-the-badge)](https://packagist.org/packages/artemeon/mantis2github) +![PHPStan](https://img.shields.io/badge/PHPStan-level%2010-brightgreen.svg?style=for-the-badge) +[![Packagist Downloads](https://img.shields.io/packagist/dt/artemeon/mantis2github?style=for-the-badge)](https://packagist.org/packages/artemeon/mantis2github) +[![License](https://img.shields.io/github/license/artemeon/mantis2github?style=for-the-badge)](https://packagist.org/packages/artemeon/mantis2github) A small CLI tool to create a GitHub issue out of a Mantis issue. Creates cross-references, so links the GitHub issue to mantis and vice versa. @@ -39,11 +40,12 @@ mantis2github [command] ### Available Commands -| Command | Description | -|------------------------------|-------------------------------------------| -| [`sync`](#sync) | Create a GitHub issue from a Mantis issue | -| [`read:github`](#readgithub) | Read details of a GitHub issue | -| [`read:mantis`](#readmantis) | Read details of a Mantis issue | +| Command | Description | +|------------------------------|-------------------------------------------------------------------| +| [`sync`](#sync) | Create a GitHub issue from a Mantis issue | +| [`read:github`](#readgithub) | Read details of a GitHub issue | +| [`read:mantis`](#readmantis) | Read details of a Mantis issue | +| [`issues:list`](#issueslist) | Get a list of Mantis Tickets with their associated GitHub Issues. | #### `sync` @@ -101,6 +103,20 @@ mantis2github read:mantis |----------|----------|-----------------| | `id` | `true` | Mantis issue id | +#### `issues:list` + +Get a list of Mantis Tickets with their associated GitHub Issues. + +```shell +mantis2github issues:list [--output=html] +``` + +##### Options + +| Option | Possible values | Description | +|----------|-----------------|---------------| +| `output` | `html` | Output Format | + ## License This project is open-sourced software licensed under the [MIT license](LICENSE). diff --git a/composer.json b/composer.json index 98e50ff..68485df 100644 --- a/composer.json +++ b/composer.json @@ -5,6 +5,11 @@ "bin": [ "bin/mantis2github" ], + "scripts": { + "phpstan": "php ./vendor/bin/phpstan analyse --memory-limit=4G", + "pint": "php ./vendor/bin/pint --test -v", + "pint:fix": "php ./vendor/bin/pint -v" + }, "authors": [ { "name": "Stefan Idler", @@ -21,9 +26,9 @@ } }, "require": { - "php": ">=8.0", + "php": ">=8.2", "ext-json": "*", - "artemeon/console": "^0.4.1", + "artemeon/console": "^0.5.0", "guzzlehttp/guzzle": "^7.3.0", "symfony/console": "^5.0|^6.0|^7.0", "symfony/yaml": "^5.0|^6.0|^7.0", @@ -32,6 +37,10 @@ "ahinkle/packagist-latest-version": "^2.0" }, "require-dev": { - "roave/security-advisories": "dev-latest" + "jetbrains/phpstorm-attributes": "^1.0", + "roave/security-advisories": "dev-latest", + "phpstan/phpstan": "^2.1.2", + "laravel/pint": "^1.14", + "rector/rector": "^2.0.7" } } diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..d20294c --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,10 @@ +includes: + - phar://phpstan.phar/conf/bleedingEdge.neon + +parameters: + level: 10 + checkUninitializedProperties: true + checkImplicitMixed: true + rememberPossiblyImpureFunctionValues: false + paths: + - src diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..252be94 --- /dev/null +++ b/pint.json @@ -0,0 +1,108 @@ +{ + "preset": "psr12", + "rules": { + "align_multiline_comment": true, + "array_indentation": true, + "array_push": true, + "array_syntax": { + "syntax": "short" + }, + "assign_null_coalescing_to_coalesce_equal": true, + "binary_operator_spaces": true, + "blank_line_before_statement": true, + "cast_spaces": true, + "clean_namespace": true, + "combine_consecutive_issets": true, + "combine_consecutive_unsets": true, + "compact_nullable_typehint": true, + "concat_space": { + "spacing": "one" + }, + "declare_strict_types": true, + "fully_qualified_strict_types": true, + "function_to_constant": true, + "get_class_to_class_keyword": true, + "is_null": true, + "lambda_not_used_import": true, + "logical_operators": true, + "method_chaining_indentation": true, + "modernize_types_casting": true, + "multiline_whitespace_before_semicolons": true, + "no_empty_comment": true, + "no_empty_phpdoc": true, + "no_empty_statement": true, + "no_extra_blank_lines": { + "tokens": ["attribute", "break", "case", "continue", "curly_brace_block", "default", "extra", "parenthesis_brace_block", "return", "square_brace_block", "switch", "throw", "use", "use_trait"] + }, + "no_multiline_whitespace_around_double_arrow": true, + "no_short_bool_cast": true, + "no_singleline_whitespace_before_semicolons": true, + "no_superfluous_elseif": false, + "no_superfluous_phpdoc_tags": true, + "no_trailing_comma_in_singleline": true, + "no_unneeded_control_parentheses": true, + "no_useless_concat_operator": true, + "no_useless_else": true, + "no_useless_nullsafe_operator": true, + "no_useless_return": true, + "no_whitespace_before_comma_in_array": true, + "nullable_type_declaration": true, + "object_operator_without_whitespace": true, + "ordered_imports": { + "imports_order": [ + "class", + "function", + "const" + ], + "sort_algorithm": "alpha" + }, + "ordered_interfaces": true, + "ordered_types": { + "null_adjustment": "always_last" + }, + "phpdoc_align": { + "align": "left" + }, + "phpdoc_indent": true, + "phpdoc_no_useless_inheritdoc": true, + "phpdoc_order": true, + "phpdoc_scalar": true, + "phpdoc_single_line_var_spacing": true, + "phpdoc_summary": true, + "phpdoc_tag_casing": true, + "phpdoc_trim": true, + "phpdoc_trim_consecutive_blank_line_separation": true, + "phpdoc_var_without_name": true, + "php_unit_construct": true, + "php_unit_dedicate_assert": true, + "php_unit_dedicate_assert_internal_type": true, + "php_unit_internal_class": true, + "php_unit_method_casing": true, + "return_assignment": true, + "return_type_declaration": true, + "short_scalar_cast": true, + "single_line_comment_spacing": true, + "single_line_comment_style": true, + "single_quote": true, + "single_space_around_construct": true, + "ternary_to_null_coalescing": true, + "trailing_comma_in_multiline": { + "elements": [ + "arguments", + "arrays", + "match", + "parameters" + ] + }, + "trim_array_spaces": true, + "type_declaration_spaces": true, + "types_spaces": { + "space": "single" + }, + "use_arrow_functions": true, + "void_return": true, + "whitespace_after_comma_in_array": { + "ensure_single_space": true + } + } +} diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..a91d269 --- /dev/null +++ b/rector.php @@ -0,0 +1,35 @@ +withPhpSets() + ->withAttributesSets(phpunit: true) + ->withRules([ + Rector\CodeQuality\Rector\Ternary\ArrayKeyExistsTernaryThenValueToCoalescingRector::class, + Rector\CodeQuality\Rector\NullsafeMethodCall\CleanupUnneededNullsafeOperatorRector::class, + Rector\CodeQuality\Rector\ClassMethod\InlineArrayReturnAssignRector::class, + Rector\CodeQuality\Rector\Ternary\UnnecessaryTernaryExpressionRector::class, + Rector\DeadCode\Rector\Foreach_\RemoveUnusedForeachKeyRector::class, + Rector\TypeDeclaration\Rector\ClassMethod\ReturnTypeFromStrictFluentReturnRector::class, + Rector\Php80\Rector\Class_\StringableForToStringRector::class, + Rector\CodingStyle\Rector\ArrowFunction\StaticArrowFunctionRector::class, + Rector\CodingStyle\Rector\Closure\StaticClosureRector::class, + Rector\DeadCode\Rector\Node\RemoveNonExistingVarAnnotationRector::class, + Rector\DeadCode\Rector\ClassMethod\RemoveUnusedPrivateMethodParameterRector::class, + Rector\TypeDeclaration\Rector\ClassMethod\BoolReturnTypeFromBooleanStrictReturnsRector::class, + Rector\TypeDeclaration\Rector\ClassMethod\ReturnTypeFromReturnNewRector::class, + Rector\TypeDeclaration\Rector\ClassMethod\ParamTypeByMethodCallTypeRector::class, + Rector\TypeDeclaration\Rector\ClassMethod\NumericReturnTypeFromStrictScalarReturnsRector::class, + Rector\TypeDeclaration\Rector\ClassMethod\AddMethodCallBasedStrictParamTypeRector::class, + Rector\CodeQuality\Rector\If_\ExplicitBoolCompareRector::class, + Rector\CodeQuality\Rector\Foreach_\ForeachItemsAssignToEmptyArrayToAssignRector::class, + Rector\CodeQuality\Rector\Foreach_\ForeachToInArrayRector::class, + Rector\CodeQuality\Rector\BooleanAnd\RemoveUselessIsObjectCheckRector::class, + ]) + ->withPaths([ + __DIR__ . '/src', + ]) + ->withTypeCoverageLevel(0); diff --git a/src/Command/CheckUpdateCommand.php b/src/Command/CheckUpdateCommand.php index 00797bf..b979a38 100644 --- a/src/Command/CheckUpdateCommand.php +++ b/src/Command/CheckUpdateCommand.php @@ -5,7 +5,6 @@ namespace Artemeon\M2G\Command; use Artemeon\M2G\Helper\VersionHelper; - use Exception; use JsonException; diff --git a/src/Command/Command.php b/src/Command/Command.php index 1100b22..6c54573 100644 --- a/src/Command/Command.php +++ b/src/Command/Command.php @@ -6,6 +6,7 @@ use Artemeon\Console\Command as BaseCommand; use Artemeon\M2G\Config\ConfigReader; +use Artemeon\M2G\Config\ConfigValues; class Command extends BaseCommand { @@ -19,21 +20,22 @@ final public function header(): void | | | || (_| || | | || |_ | |\__ \ / __/ | |_| || || |_ | _ || |_| || |_) | |_| |_| \__,_||_| |_| \__||_||___/ |_____| \____||_| \__||_| |_| \__,_||_.__/ -' +', ); } final public function checkConfig(): void { $config = (new ConfigReader())->read(); + if ($config instanceof ConfigValues) { + return; + } - if (!$config) { - $this->newLine(); - $this->warn('You have not configured mantis2github yet'); - $this->warn('Please run "mantis2github configure" to get started'); - $this->newLine(); + $this->newLine(); + $this->warn('You have not configured mantis2github yet'); + $this->warn('Please run "mantis2github configure" to get started'); + $this->newLine(); - exit(self::INVALID); - } + exit(self::INVALID); } } diff --git a/src/Command/ConfigurationCommand.php b/src/Command/ConfigurationCommand.php index 64344c5..8bbd439 100644 --- a/src/Command/ConfigurationCommand.php +++ b/src/Command/ConfigurationCommand.php @@ -15,7 +15,21 @@ class ConfigurationCommand extends Command protected ?string $description = 'Configure the tool'; protected string $configPath = __DIR__ . '/../../../config.yaml'; - protected array $config = []; + + /** + * @var array{ + * mantisUrl: string, + * mantisToken: string, + * githubToken: string, + * githubRepository: string, + * } + */ + protected array $config = [ + 'mantisUrl' => '', + 'mantisToken' => '', + 'githubToken' => '', + 'githubRepository' => '', + ]; public function __invoke(): int { @@ -57,7 +71,7 @@ private function askForMantisUrl(): void label: 'The URL of your Mantis installation', placeholder: 'E.g. https://tickets.company.tld/', required: true, - validate: function ($value) { + validate: static function (string $value) { $parsedUrl = parse_url($value); if ($parsedUrl === false) { @@ -85,10 +99,12 @@ private function askForMantisUrl(): void ); $parsedUrl = parse_url($mantisUrl); - $port = isset($parsedUrl['port']) ? ':' . $parsedUrl['port'] : ''; - $mantisUrl = "{$parsedUrl['scheme']}://{$parsedUrl['host']}$port/"; + if ($parsedUrl) { + $port = isset($parsedUrl['port']) ? ':' . $parsedUrl['port'] : ''; + $mantisUrl = "{$parsedUrl['scheme']}://{$parsedUrl['host']}$port/"; - $this->config['mantisUrl'] = $mantisUrl; + $this->config['mantisUrl'] = $mantisUrl; + } } private function askForMantisToken(): void @@ -108,7 +124,7 @@ private function askForGitHubToken(): void $this->config['githubToken'] = $this->password( label: 'GitHub Personal Access Token', required: true, - validate: fn ($value) => !str_starts_with($value, 'ghp_') && str_starts_with($value, 'github_pat_') + validate: static fn (string $value) => !str_starts_with($value, 'ghp_') && str_starts_with($value, 'github_pat_') ? 'The provided value is not a valid GitHub PAT.' : null, ); @@ -120,7 +136,7 @@ private function askForGitHubRepository(): void label: 'GitHub Repository(e.g. user/repository)', placeholder: 'E.g. user/repository', required: true, - validate: fn ($value) => count(explode('/', $value)) !== 2 + validate: static fn (string $value) => count(explode('/', $value)) !== 2 ? 'Invalid repository name.' : null, ); @@ -129,10 +145,11 @@ private function askForGitHubRepository(): void private function saveConfig(): void { $stub = file_get_contents(__DIR__ . '/../../stubs/config.yaml.stub'); + if (!$stub) { + return; + } - $configContent = preg_replace_callback('/{{([a-z0-9_]+)}}/i', function ($matches) { - return $this->config[$matches[1]] ?? ''; - }, $stub); + $configContent = preg_replace_callback('/{{([a-z0-9_]+)}}/i', fn ($matches) => $this->config[$matches[1]] ?? '', $stub); file_put_contents($this->configPath, $configContent); @@ -149,19 +166,29 @@ private function saveConfig(): void private function readExistingConfigFromPath(): void { - if (!$this->argument('file')) { + $file = $this->argument('file'); + + if (!$file) { return; } - if (!file_exists($this->argument('file'))) { + if (!is_string($file) || !file_exists($file)) { $this->error('The given config file does not exist.'); exit(1); } - $config = Yaml::parseFile($this->argument('file')); - - if (!$config['MANTIS_URL'] || !$config['MANTIS_TOKEN'] || !$config['GITHUB_TOKEN'] || !$config['GITHUB_REPOSITORY']) { + /** + * @var array{ + * MANTIS_URL?: string, + * MANTIS_TOKEN?: string, + * GITHUB_TOKEN?: string, + * GITHUB_REPOSITORY?: string, + * } $config + */ + $config = Yaml::parseFile($file); + + if (!isset($config['MANTIS_URL'], $config['MANTIS_TOKEN'], $config['GITHUB_TOKEN'], $config['GITHUB_REPOSITORY'])) { $this->error('The given config file is incomplete.'); $this->info('Please configure the tool without the file parameter.'); diff --git a/src/Command/CreateGithubIssueFromMantisIssue.php b/src/Command/CreateGithubIssueFromMantisIssue.php index d962d87..7826407 100644 --- a/src/Command/CreateGithubIssueFromMantisIssue.php +++ b/src/Command/CreateGithubIssueFromMantisIssue.php @@ -8,7 +8,6 @@ use Artemeon\M2G\Service\GithubConnector; use Artemeon\M2G\Service\MantisConnector; use JsonException; -use Symfony\Component\Console\Helper\ProgressBar; use Symfony\Component\Console\Helper\Table; class CreateGithubIssueFromMantisIssue extends Command @@ -17,14 +16,9 @@ class CreateGithubIssueFromMantisIssue extends Command protected ?string $description = 'Synchronize a list of Mantis issues to GitHub'; - private GithubConnector $githubConnector; - private MantisConnector $mantisConnector; - - public function __construct(MantisConnector $mantisConnector, GithubConnector $githubConnector) + public function __construct(private MantisConnector $mantisConnector, private GithubConnector $githubConnector) { parent::__construct(); - $this->mantisConnector = $mantisConnector; - $this->githubConnector = $githubConnector; } /** @@ -35,19 +29,25 @@ public function __invoke(): int $this->checkConfig(); $this->title('Mantis 2 GitHub Sync'); + /** @var string[]|bool|string|null $idsArgument */ + $idsArgument = $this->argument('ids'); + + if (!is_array($idsArgument)) { + return self::INVALID; + } - $ids = array_unique($this->argument('ids')); + $ids = array_unique($idsArgument); $message = count($ids) !== 1 ? 'Creating issues ...' : 'Creating issue ...'; $this->newLine(); $issues = []; - $this->spin(function () use ($ids, &$issues) { - $labels = array_map(static fn ($label) => $label['name'], $this->githubConnector->getLabels()); + $this->spin(function () use ($ids, &$issues): void { + $labels = array_map(static fn (array $label) => $label['name'], $this->githubConnector->getLabels()); foreach ($ids as $id) { - $mantisIssue = $this->mantisConnector->readIssue((int)$id); + $mantisIssue = $this->mantisConnector->readIssue((int) $id); if ($mantisIssue === null) { $issues[] = [ @@ -56,13 +56,21 @@ public function __invoke(): int 'message' => 'Mantis issue not found.', 'issue' => '', ]; + continue; } $newGithubIssue = GithubIssue::fromMantisIssue($mantisIssue); + /** + * @var array{ + * id: int, + * name: string, + * color: string, + * }[] $filteredLabels + */ $filteredLabels = array_values( - array_filter($labels, static fn ($label) => strtolower($label) === strtolower($mantisIssue->getProject())), + array_filter($labels, static fn (string $label) => strtolower($label) === strtolower($mantisIssue->getProject())), ); $newGithubIssue->setLabels($filteredLabels); @@ -75,11 +83,12 @@ public function __invoke(): int 'message' => 'GitHub issue could not be created.', 'issue' => '', ]; + continue; } $mantisIssue->setUpstreamTicket( - trim($mantisIssue->getUpstreamTicket() . ' ' . $newGithubIssue->getIssueUrl()) + trim($mantisIssue->getUpstreamTicket() . ' ' . $newGithubIssue->getIssueUrl()), ); $patched = $this->mantisConnector->patchUpstreamField($mantisIssue); @@ -90,6 +99,7 @@ public function __invoke(): int 'message' => 'Upstream ticket URL could not be updated.', 'issue' => '', ]; + continue; } diff --git a/src/Command/IssuesListCommand.php b/src/Command/IssuesListCommand.php index 8aeab59..2bdb9b1 100644 --- a/src/Command/IssuesListCommand.php +++ b/src/Command/IssuesListCommand.php @@ -16,23 +16,20 @@ class IssuesListCommand extends Command protected string $signature = 'issues:list {--output= : Output Format}'; protected ?string $description = 'Get a list of Mantis Tickets with their associated GitHub Issues.'; - private MantisConnector $mantisConnector; - private GithubConnector $githubConnector; - private ?ConfigValues $config; - - public function __construct(MantisConnector $mantisConnector, GithubConnector $githubConnector, ?ConfigValues $config) + public function __construct(private MantisConnector $mantisConnector, private GithubConnector $githubConnector, private ?ConfigValues $config) { parent::__construct(); - - $this->mantisConnector = $mantisConnector; - $this->githubConnector = $githubConnector; - $this->config = $config; } public function __invoke(): int { + if ($this->config === null) { + return self::INVALID; + } + $mantisIssues = $this->mantisConnector->fetchIssues(410); + /** @var array $githubIssueIds */ $githubIssueIds = []; foreach ($mantisIssues as $issue) { $parsedIssues = array_map(static fn (array $data) => $data['id'], UpstreamIssueParser::parse($issue->getUpstreamTicket())); @@ -40,6 +37,7 @@ public function __invoke(): int } $parts = []; + /** @var string $id */ foreach (array_unique($githubIssueIds) as $id) { $parts[] = <<githubConnector->graphql($query)['data']['repository']; - - switch ($this->option('output')) { - case 'html': - HtmlTableConverter::convert($this, $mantisIssues, $githubResult); + /** @var array{ repository: array } $result */ + $result = $this->githubConnector->graphql($query)['data']; + $githubResult = $result['repository']; - break; - default: - CliTableConverter::convert($this, $mantisIssues, $githubResult); - } + match ($this->option('output')) { + 'html' => HtmlTableConverter::convert($this, $mantisIssues, $githubResult), + default => CliTableConverter::convert($this, $mantisIssues, $githubResult), + }; return self::SUCCESS; } diff --git a/src/Command/ReadGithubIssueCommand.php b/src/Command/ReadGithubIssueCommand.php index 867b475..8aade13 100644 --- a/src/Command/ReadGithubIssueCommand.php +++ b/src/Command/ReadGithubIssueCommand.php @@ -14,15 +14,9 @@ class ReadGithubIssueCommand extends Command protected string $signature = 'read:github {id : GitHub issue ID}'; protected ?string $description = 'Read details of a GitHub issue'; - private GithubConnector $githubConnector; - - /** - * @param GithubConnector $mantisConnector - */ - public function __construct(GithubConnector $mantisConnector) + public function __construct(private GithubConnector $githubConnector) { parent::__construct(); - $this->githubConnector = $mantisConnector; } public function __invoke(): int @@ -69,9 +63,10 @@ public function __invoke(): int ); $assignees = array_map( - static fn($assignee + static fn ( + $assignee, ) => "{$assignee['login']}", - $issue->getAssignees() + $issue->getAssignees(), ); if (count($assignees)) { @@ -98,6 +93,7 @@ public function __invoke(): int if (count($labels)) { $labels = array_map(static function ($label) { style("label-{$label['id']}")->color('#' . $label['color']); + return "{$label['name']}"; }, $labels); @@ -136,9 +132,9 @@ private function fetchIssueDetails(): GithubIssue $this->info('Fetching issue details...'); - $issue = $this->githubConnector->readIssue((int)$id); + $issue = $this->githubConnector->readIssue((int) $id); - if (!$issue) { + if ($issue === null) { $this->error('Issue not found.'); if (empty($this->argument('id'))) { diff --git a/src/Command/ReadMantisIssueCommand.php b/src/Command/ReadMantisIssueCommand.php index f3408a1..b6deed4 100644 --- a/src/Command/ReadMantisIssueCommand.php +++ b/src/Command/ReadMantisIssueCommand.php @@ -14,12 +14,9 @@ class ReadMantisIssueCommand extends Command protected string $signature = 'read:mantis {id : The issue ID}'; protected ?string $description = 'Read details of a Mantis issue'; - private MantisConnector $mantisConnector; - - public function __construct(MantisConnector $mantisConnector) + public function __construct(private MantisConnector $mantisConnector) { parent::__construct(); - $this->mantisConnector = $mantisConnector; } public function __invoke(): int @@ -114,9 +111,9 @@ private function fetchIssueDetails(): MantisIssue $this->info('Fetching issue details...'); - $issue = $this->mantisConnector->readIssue((int)$id); + $issue = $this->mantisConnector->readIssue((int) $id); - if (!$issue) { + if ($issue === null) { $this->error('Issue not found.'); exit(1); diff --git a/src/Config/ConfigReader.php b/src/Config/ConfigReader.php index d15a8e1..73f4ffa 100644 --- a/src/Config/ConfigReader.php +++ b/src/Config/ConfigReader.php @@ -4,6 +4,7 @@ namespace Artemeon\M2G\Config; +use RuntimeException; use Symfony\Component\Yaml\Yaml; class ConfigReader @@ -16,9 +17,22 @@ final public function read(): ?ConfigValues return null; } - $config = Yaml::parse(file_get_contents($configFile)); + $content = file_get_contents($configFile); + if (!$content) { + throw new RuntimeException('Invalid config file provided.'); + } - if (!$config['MANTIS_URL'] || !$config['MANTIS_TOKEN'] || !$config['GITHUB_TOKEN'] || !$config['GITHUB_REPOSITORY']) { + /** + * @var array{ + * MANTIS_URL?: string, + * MANTIS_TOKEN?: string, + * GITHUB_TOKEN?: string, + * GITHUB_REPOSITORY?: string, + * } $config + */ + $config = Yaml::parse($content); + + if (!isset($config['MANTIS_URL'], $config['MANTIS_TOKEN'], $config['GITHUB_TOKEN'], $config['GITHUB_REPOSITORY'])) { return null; } diff --git a/src/Config/ConfigValues.php b/src/Config/ConfigValues.php index 106f487..860c074 100644 --- a/src/Config/ConfigValues.php +++ b/src/Config/ConfigValues.php @@ -6,24 +6,8 @@ class ConfigValues { - private string $mantisUrl; - private string $mantisToken; - - private string $githubToken; - private string $githubRepo; - - /** - * @param string $mantisUrl - * @param string $mantisToken - * @param string $githubToken - * @param string $githubRepo - */ - public function __construct(string $mantisUrl, string $mantisToken, string $githubToken, string $githubRepo) + public function __construct(private string $mantisUrl, private string $mantisToken, private string $githubToken, private string $githubRepo) { - $this->mantisUrl = $mantisUrl; - $this->mantisToken = $mantisToken; - $this->githubToken = $githubToken; - $this->githubRepo = $githubRepo; } final public function getMantisUrl(): string diff --git a/src/Dto/GithubIssue.php b/src/Dto/GithubIssue.php index 0ae8fb2..727b202 100644 --- a/src/Dto/GithubIssue.php +++ b/src/Dto/GithubIssue.php @@ -6,6 +6,17 @@ class GithubIssue { + /** + * @param array{ + * html_url: string, + * login: string, + * }[] $assignees + * @param array{ + * id: int, + * name: string, + * color: string, + * }[] $labels + */ public function __construct( private ?int $id = null, private ?int $number = null, @@ -58,11 +69,24 @@ final public function getState(): string return $this->state; } + /** + * @return array{ + * html_url: string, + * login: string, + * }[] + */ final public function getAssignees(): array { return $this->assignees; } + /** + * @param array{ + * id: int, + * name: string, + * color: string, + * }[] $labels + */ final public function setLabels(array $labels = []): self { $this->labels = $labels; @@ -70,8 +94,15 @@ final public function setLabels(array $labels = []): self return $this; } + /** + * @return array{ + * id: int, + * name: string, + * color: string, + * }[] + */ final public function getLabels(): array { - return $this->labels ?? []; + return $this->labels; } } diff --git a/src/Helper/CliTableConverter.php b/src/Helper/CliTableConverter.php index 505801a..5a09039 100644 --- a/src/Helper/CliTableConverter.php +++ b/src/Helper/CliTableConverter.php @@ -5,10 +5,16 @@ namespace Artemeon\M2G\Helper; use Artemeon\M2G\Command\IssuesListCommand; -use Artemeon\M2G\Dto\MantisIssue; class CliTableConverter implements ConverterInterface { + /** + * @param array $githubResult + */ public static function convert(IssuesListCommand $command, array $mantisIssues, array $githubResult): void { $rows = []; diff --git a/src/Helper/ConverterInterface.php b/src/Helper/ConverterInterface.php index c9acd76..d410c1d 100644 --- a/src/Helper/ConverterInterface.php +++ b/src/Helper/ConverterInterface.php @@ -11,6 +11,7 @@ interface ConverterInterface { /** * @param MantisIssue[] $mantisIssues + * @param array $githubResult */ public static function convert(IssuesListCommand $command, array $mantisIssues, array $githubResult): void; } diff --git a/src/Helper/HtmlTableConverter.php b/src/Helper/HtmlTableConverter.php index 31df7c8..8797230 100644 --- a/src/Helper/HtmlTableConverter.php +++ b/src/Helper/HtmlTableConverter.php @@ -8,6 +8,13 @@ class HtmlTableConverter implements ConverterInterface { + /** + * @param array $githubResult + */ public static function convert(IssuesListCommand $command, array $mantisIssues, array $githubResult): void { $rows = []; @@ -15,6 +22,9 @@ public static function convert(IssuesListCommand $command, array $mantisIssues, foreach ($mantisIssues as $issue) { $githubIssues = array_map(static function (array $data) use ($githubResult) { $githubIssue = $githubResult['issue' . $data['id']] ?? null; + if (!$githubIssue) { + return ''; + } $url = $githubIssue['url']; $title = $githubIssue['title']; diff --git a/src/Helper/UpstreamIssueParser.php b/src/Helper/UpstreamIssueParser.php index 8e3f92c..409fb01 100644 --- a/src/Helper/UpstreamIssueParser.php +++ b/src/Helper/UpstreamIssueParser.php @@ -6,7 +6,13 @@ class UpstreamIssueParser { - public static function parse(string $input): array + /** + * @return array{ + * url: string, + * id: int, + * }[] + */ + public static function parse(?string $input): array { if (!$input) { return []; @@ -27,7 +33,7 @@ public static function parse(string $input): array continue; } - $issues[] = ['url' => $part, 'id' => $matches[1]]; + $issues[] = ['url' => $part, 'id' => (int) $matches[1]]; } return $issues; diff --git a/src/Helper/VersionHelper.php b/src/Helper/VersionHelper.php index 49ea108..542e38d 100644 --- a/src/Helper/VersionHelper.php +++ b/src/Helper/VersionHelper.php @@ -16,14 +16,29 @@ class VersionHelper */ public static function getPackageName(): ?string { - $packageJson = json_decode(file_get_contents(__DIR__ . '/../../composer.json'), true, 512, JSON_THROW_ON_ERROR); + $path = __DIR__ . '/../../composer.json'; + if (!file_exists($path)) { + return null; + } + + $content = file_get_contents($path); + if (!$content) { + return null; + } + + /** + * @var array{ + * name?: string, + * } $packageJson + */ + $packageJson = json_decode($content, true, 512, JSON_THROW_ON_ERROR); return $packageJson['name'] ?? null; } public static function fetchVersion(): string { - return InstalledVersions::getPrettyVersion('artemeon/mantis2github'); + return InstalledVersions::getPrettyVersion('artemeon/mantis2github') ?? ''; } /** @@ -32,8 +47,22 @@ public static function fetchVersion(): string public static function latestVersion(): ?string { $packagist = new PackagistLatestVersion(); + $packageName = self::getPackageName(); + if (!$packageName) { + return null; + } + + $latestRelease = $packagist->getLatestRelease($packageName); + if (!is_array($latestRelease) || !array_key_exists('version', $latestRelease)) { + return null; + } + + $version = $latestRelease['version'] ?? null; + if (!is_string($version)) { + return null; + } - return $packagist->getLatestRelease(self::getPackageName())['version'] ?? null; + return $version; } /** @@ -44,6 +73,10 @@ public static function checkForUpdates(): bool $currentVersion = self::fetchVersion(); $latestVersion = self::latestVersion(); + if (!$currentVersion || !$latestVersion) { + return false; + } + if (!preg_match("/^\d+\.\d+\.\d+$/", $currentVersion)) { return false; } diff --git a/src/Service/GithubConnector.php b/src/Service/GithubConnector.php index 9018400..d284fce 100644 --- a/src/Service/GithubConnector.php +++ b/src/Service/GithubConnector.php @@ -16,18 +16,15 @@ class GithubConnector { private Client $client; - public function __construct(private ?ConfigValues $config) + public function __construct(?ConfigValues $config) { - if (!$config) { - return; - } $this->client = new Client([ 'headers' => [ 'Accept' => 'application/vnd.github.v3+json', - 'Authorization' => 'token ' . $this->config->getGithubToken(), + 'Authorization' => 'token ' . $config?->getGithubToken(), ], 'verify' => false, - 'base_uri' => 'https://api.github.com/repos/' . $config->getGithubRepo() . '/', + 'base_uri' => 'https://api.github.com/repos/' . $config?->getGithubRepo() . '/', ]); } @@ -37,8 +34,27 @@ final public function readIssue(int $number): ?GithubIssue $response = $this->client->get( 'issues/' . $number, ); - $result = json_decode((string)$response->getBody(), true, 512, JSON_THROW_ON_ERROR); - } catch (GuzzleException | Exception) { + /** + * @var array{ + * id: int, + * number: int, + * title: string, + * body: ?string, + * html_url: string, + * state: string, + * assignees: array{ + * html_url: string, + * login: string, + * }[], + * labels: array{ + * id: int, + * name: string, + * color: string, + * }[], + * } $result + */ + $result = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); + } catch (Exception | GuzzleException) { return null; } @@ -70,17 +86,36 @@ final public function createIssue(GithubIssue $issue): ?GithubIssue ], JSON_THROW_ON_ERROR), ], ); - } catch (GuzzleException | Exception) { + } catch (Exception | GuzzleException) { return null; } - $result = json_decode((string)$response->getBody(), true, 512, JSON_THROW_ON_ERROR); + /** + * @var array{ + * id: int, + * number: int, + * title: string, + * body: ?string, + * html_url: string, + * state: string, + * assignees: array{ + * html_url: string, + * login: string, + * }[], + * labels: array{ + * id: int, + * name: string, + * color: string, + * }[], + * } $result + */ + $result = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); return new GithubIssue( id: $result['id'], number: $result['number'], title: $result['title'], - description: $result['body'], + description: $result['body'] ?? '', issueUrl: $result['html_url'], state: $result['state'], assignees: $result['assignees'], @@ -88,18 +123,31 @@ final public function createIssue(GithubIssue $issue): ?GithubIssue ); } + /** + * @return array{ + * name: string, + * }[] + */ final public function getLabels(): array { try { $response = $this->client->get('labels'); - $result = json_decode((string)$response->getBody(), true, 512, JSON_THROW_ON_ERROR); - } catch (GuzzleException | Exception) { + /** + * @var array{ + * name: string, + * }[] $result + */ + $result = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); + } catch (Exception | GuzzleException) { return []; } return $result ?: []; } + /** + * @return array{ data: mixed } + */ final public function graphql(string $query): array { try { @@ -109,11 +157,14 @@ final public function graphql(string $query): array ], ]); + /** + * @var ?array{ data: mixed } $result + */ $result = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); - } catch (GuzzleException | Exception) { - return []; + } catch (Exception | GuzzleException) { + return ['data' => null]; } - return $result ?: []; + return $result ?? ['data' => null]; } } diff --git a/src/Service/MantisConnector.php b/src/Service/MantisConnector.php index 0d41c50..38713aa 100644 --- a/src/Service/MantisConnector.php +++ b/src/Service/MantisConnector.php @@ -9,7 +9,9 @@ use Exception; use GuzzleHttp\Client; use GuzzleHttp\Exception\GuzzleException; +use JetBrains\PhpStorm\ExpectedValues; use JsonException; +use RuntimeException; class MantisConnector { @@ -17,23 +19,20 @@ class MantisConnector public function __construct(private ?ConfigValues $config) { - if (!$config) { - return; - } $this->client = new Client([ 'headers' => [ - 'Authorization' => $this->config->getMantisToken(), + 'Authorization' => $this->config?->getMantisToken(), 'Content-Type' => 'application/json', ], 'verify' => false, - 'base_uri' => rtrim($this->config->getMantisUrl(), '/') . '/api/rest/issues/', + 'base_uri' => rtrim($this->config?->getMantisUrl() ?? '', '/') . '/api/rest/issues/', ]); } /** * @return MantisIssue[] */ - final public function fetchIssues(int $filterId = null): array + final public function fetchIssues(?int $filterId = null): array { try { $query = http_build_query(array_filter([ @@ -41,9 +40,39 @@ final public function fetchIssues(int $filterId = null): array 'page_size' => 400, ], static fn (mixed $value) => $value !== null)); - $response = $this->client->get($query ? '?' . $query : ''); + $response = $this->client->get($query !== '' && $query !== '0' ? '?' . $query : ''); + /** + * @var array{ + * issues: array{ + * id: int, + * summary: string, + * description: string, + * project: array{ + * name: string, + * }, + * status: array{ + * name: string, + * label: string, + * }, + * resolution: array{ + * name: string, + * }, + * handler: array{ + * real_name: ?string, + * name: ?string, + * }, + * custom_fields: array{ + * field: array{ + * name: string, + * id: ?int + * }, + * value: string + * }[], + * }[] + * } $result + */ $result = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); - } catch (GuzzleException | Exception) { + } catch (Exception | GuzzleException) { return []; } @@ -59,8 +88,38 @@ final public function readIssue(int $number): ?MantisIssue { try { $response = $this->client->get((string) $number); - $result = json_decode((string)$response->getBody(), true, 512, JSON_THROW_ON_ERROR); - } catch (GuzzleException | Exception) { + /** + * @var array{ + * issues: array{ + * id: int, + * summary: string, + * description: string, + * project: array{ + * name: string, + * }, + * status: array{ + * name: string, + * label: string, + * }, + * resolution: array{ + * name: string, + * }, + * handler: array{ + * real_name: ?string, + * name: ?string, + * }, + * custom_fields: array{ + * field: array{ + * name: string, + * id: ?int + * }, + * value: string + * }[], + * }[] + * } $result + */ + $result = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); + } catch (Exception | GuzzleException) { return null; } @@ -91,14 +150,47 @@ final public function patchUpstreamField(MantisIssue $issue): bool 'body' => $body, ], ); + return true; - } catch (GuzzleException | Exception) { + } catch (Exception | GuzzleException) { return false; } } - private function mapIssue(array $data, string $status = 'name'): MantisIssue + /** + * @param array{ + * id: int, + * summary: string, + * description: string, + * project: array{ + * name: string, + * }, + * status: array{ + * name: string, + * label: string, + * }, + * resolution: array{ + * name: string, + * }, + * handler: array{ + * real_name: ?string, + * name: ?string, + * }, + * custom_fields: array{ + * field: array{ + * name: string, + * id: ?int + * }, + * value: string + * }[], + * } $data + */ + private function mapIssue(array $data, #[ExpectedValues(['name', 'label'])] string $status = 'name'): MantisIssue { + if ($this->config === null) { + throw new RuntimeException('Config is missing.'); + } + $mantisBaseUrl = $this->config->getMantisUrl(); if (!str_ends_with($mantisBaseUrl, '/')) { $mantisBaseUrl .= '/'; @@ -119,6 +211,17 @@ private function mapIssue(array $data, string $status = 'name'): MantisIssue return $issue; } + /** + * @param array{ + * custom_fields: array{ + * field: array{ + * name: string, + * id: ?int + * }, + * value: string + * }[], + * } $issue + */ private function updateUpstreamFieldsIssue(array $issue, MantisIssue $mantisIssue): void { foreach ($issue['custom_fields'] as $field) {