diff --git a/src/Api/Action/Maintainer/GetMaintainerReleaseNotesAction.php b/src/Api/Action/Maintainer/GetMaintainerReleaseNotesAction.php index 95e4068..51d2246 100644 --- a/src/Api/Action/Maintainer/GetMaintainerReleaseNotesAction.php +++ b/src/Api/Action/Maintainer/GetMaintainerReleaseNotesAction.php @@ -7,6 +7,7 @@ use mglaman\DrupalOrg\Client; use mglaman\DrupalOrg\CommitParser; use mglaman\DrupalOrg\DrupalOrg; +use mglaman\DrupalOrg\GitLab\Client as GitLabClient; use mglaman\DrupalOrg\Result\Maintainer\MaintainerReleaseNotesResult; use Symfony\Component\Process\Process; @@ -24,8 +25,10 @@ class GetMaintainerReleaseNotesAction implements ActionInterface 5 => 'Plan', ]; - public function __construct(private readonly Client $client) - { + public function __construct( + private readonly Client $client, + private readonly ?GitLabClient $gitLabClient = null, + ) { } public function __invoke( @@ -96,23 +99,72 @@ public function __invoke( // Fetch data from Drupal.org concurrently. $drupalOrg = new DrupalOrg($this->client->getGuzzleClient()); $nidList = array_values($nids); - $contributorsFromApi = $drupalOrg->getContributorsFromJsonApi($nidList); - $issueDetails = $drupalOrg->getIssueDetails($nidList); - $projectId = $drupalOrg->getProjectId($project); + + // Projects migrated to GitLab work items report field_project_has_issue_queue + // as false. Work item iids are unique only within a project, so they must + // not be treated as global Drupal.org node IDs. + $projectEntity = $drupalOrg->getProject($project); + $projectId = $projectEntity?->nid; + $isGitLab = $projectEntity !== null && !$projectEntity->hasIssueQueue; + // The resolved machine name scopes GitLab work item URLs; fall back to the + // git-derived name when the project could not be looked up. + $machineName = ($projectEntity !== null && $projectEntity->machineName !== '') + ? $projectEntity->machineName + : $project; + + // Contribution records are filtered by the issue's source link: a node URL + // for legacy issues, a work item URL for GitLab projects. + $sourceLinks = []; + foreach ($nidList as $nid) { + $sourceLinks[$nid] = $isGitLab + ? sprintf('https://git.drupalcode.org/project/%s/-/work_items/%s', $machineName, $nid) + : sprintf('https://www.drupal.org/node/%s', $nid); + } + $contributorsFromApi = $drupalOrg->getContributorsFromJsonApi($sourceLinks); + + $issueDetails = $isGitLab ? [] : $drupalOrg->getIssueDetails($nidList); + $gitLabIssues = []; + if ($isGitLab) { + $gitLabClient = $this->gitLabClient ?? new GitLabClient(); + $gitLabIssues = $gitLabClient->getIssuesByIid('project/' . $machineName, array_map('intval', $nidList)); + } // Track all contributors across commits. $users = []; + // Map each issue ID to its resolved canonical URL. + $issueLinks = []; + // Process commits into categorized changes. $categorizedChanges = []; foreach ($commits as $commit) { $nid = CommitParser::getNid($commit->title); - // Determine issue category. + // Determine issue category and link. $issueCategoryLabel = 'Misc'; - if ($nid !== null && isset($issueDetails[$nid])) { - $issueCategory = $issueDetails[$nid]->fieldIssueCategory; - $issueCategoryLabel = self::CATEGORY_MAP[$issueCategory] ?? 'Misc'; + if ($nid !== null) { + if ($isGitLab) { + $issue = $gitLabIssues[(int) $nid] ?? null; + $link = sprintf('https://www.drupal.org/project/%s/issues/%s', $machineName, $nid); + if ($issue !== null) { + $issueCategoryLabel = self::categoryFromGitLabLabels($issue->labels) + ?? CommitParser::categoryFromConventionalCommit($commit->title) + ?? 'Misc'; + if ($issue->webUrl !== '') { + $link = $issue->webUrl; + } + } else { + $issueCategoryLabel = CommitParser::categoryFromConventionalCommit($commit->title) ?? 'Misc'; + } + } elseif (isset($issueDetails[$nid])) { + $issueCategory = $issueDetails[$nid]->fieldIssueCategory; + $issueCategoryLabel = self::CATEGORY_MAP[$issueCategory] ?? 'Misc'; + $link = sprintf('https://www.drupal.org/node/%s', $nid); + } else { + $issueCategoryLabel = CommitParser::categoryFromConventionalCommit($commit->title) ?? 'Misc'; + $link = sprintf('https://www.drupal.org/node/%s', $nid); + } + $issueLinks[$nid] = $link; } // Gather contributors: JSON:API first, then commit parsing, then email fallback. @@ -147,21 +199,46 @@ public function __invoke( // Fetch change records if we have a project ID. $changeRecords = []; - if ($projectId !== null) { + if ($projectId !== null && $projectId !== '') { $changeRecords = $drupalOrg->getChangeRecords($projectId, $ref2); } return new MaintainerReleaseNotesResult( ref1: $ref1, ref2: $ref2, - project: $project, + project: $machineName, categorizedChanges: $categorizedChanges, contributors: $users, nidList: $nidList, changeRecords: $changeRecords, + issueLinks: $issueLinks, + isGitLab: $isGitLab, ); } + /** + * Resolve an issue category from a GitLab `category::*` label. + * + * @param string[] $labels + */ + private static function categoryFromGitLabLabels(array $labels): ?string + { + foreach ($labels as $label) { + $matches = []; + if (preg_match('/^category::(.+)$/i', $label, $matches) === 1) { + return match (strtolower(trim($matches[1]))) { + 'bug' => self::CATEGORY_MAP[1], + 'task' => self::CATEGORY_MAP[2], + 'feature' => self::CATEGORY_MAP[3], + 'support' => self::CATEGORY_MAP[4], + 'plan' => self::CATEGORY_MAP[5], + default => null, + }; + } + } + return null; + } + private function cleanCommitTitle(string $title): string { // Strip common prefixes. @@ -180,7 +257,18 @@ private function getProjectName(string $cwd): string return ''; } - $remoteUrl = $process->getOutput(); + $remoteUrl = trim($process->getOutput()); + + // GitLab-hosted projects (including those on work items) push to + // git.drupalcode.org/project/{name} or, for issue forks, + // git.drupalcode.org/issue/{name}-{nid}. Machine names use no hyphens, + // so the capture stops before the issue fork's "-{nid}" suffix. + if (str_contains($remoteUrl, 'git.drupalcode.org')) { + $matches = []; + if (preg_match('#git\.drupalcode\.org[:/](?:project|issue)/([a-z0-9_]+)#', $remoteUrl, $matches) === 1) { + return $matches[1]; + } + } // Not a drupal.org project — use the directory name. if (strpos($remoteUrl, 'drupal.org') === false) { diff --git a/src/Api/CommitParser.php b/src/Api/CommitParser.php index fe493c3..4aaad1e 100644 --- a/src/Api/CommitParser.php +++ b/src/Api/CommitParser.php @@ -54,4 +54,24 @@ public static function getNid(string $title): ?string } return null; } + + /** + * Derive an issue category from a conventional-commit prefix. + * + * The optional scope matches any character except ")" so titles like + * "feat(CLI Tool):" and "chore(Project management):" resolve correctly. + */ + public static function categoryFromConventionalCommit(string $title): ?string + { + $matches = []; + if (preg_match('/^(fix|feat|chore|docs|style|refactor|perf|test|build|ci)(?:\([^)]+\))?!?:\s/i', $title, $matches) !== 1) { + return null; + } + return match (strtolower($matches[1])) { + 'fix' => 'Bug', + 'feat' => 'Feature', + 'chore' => 'Task', + default => null, + }; + } } diff --git a/src/Api/DrupalOrg.php b/src/Api/DrupalOrg.php index fc895e3..ed245a0 100644 --- a/src/Api/DrupalOrg.php +++ b/src/Api/DrupalOrg.php @@ -8,6 +8,7 @@ use GuzzleHttp\Promise\Utils; use mglaman\DrupalOrg\Entity\ChangeRecord; use mglaman\DrupalOrg\Entity\IssueNode; +use mglaman\DrupalOrg\Entity\Project; final class DrupalOrg { @@ -42,15 +43,42 @@ public function getProjectId(string $machineName): ?string } } + /** + * Get the project entity (nid + issue queue type) from a machine name. + * + * Reads field_project_has_issue_queue so callers can tell whether issues + * live on the legacy Drupal.org queue or on GitLab work items. + */ + public function getProject(string $machineName): ?Project + { + try { + $url = sprintf( + 'https://www.drupal.org/api-d7/node.json?field_project_machine_name=%s', + urlencode($machineName) + ); + $response = $this->client->request('GET', $url); + $data = \json_decode((string) $response->getBody()); + if ($data === null || !isset($data->list) || count($data->list) === 0) { + return null; + } + return Project::fromStdClass($data->list[0]); + } catch (RequestException) { + return null; + } catch (\JsonException) { + return null; + } + } + /** * Fetch contributors for multiple issues concurrently using promises. * - * @param list $nids Array of issue node IDs - * @return array> Associative array mapping nid => array of contributor display names + * @param array $sourceLinks Map of id => contribution source URI + * (a Drupal.org node URL for legacy issues, or a GitLab work item URL). + * @return array> Map of id => contributor display names */ - public function getContributorsFromJsonApi(array $nids): array + public function getContributorsFromJsonApi(array $sourceLinks): array { - if ($nids === []) { + if ($sourceLinks === []) { return []; } @@ -58,26 +86,26 @@ public function getContributorsFromJsonApi(array $nids): array try { $promises = []; - foreach ($nids as $nid) { + foreach ($sourceLinks as $id => $sourceLink) { $url = sprintf( - 'https://www.drupal.org/jsonapi/node/contribution_record?filter[field_source_link.uri]=https://www.drupal.org/node/%s&filter[field_contributors.field_credit_this_contributor]=1&include=field_contributors.field_contributor_user&fields[node--contribution_record]=field_contributors&fields[paragraph--contributor]=field_contributor_user,field_credit_this_contributor&fields[user--user]=display_name', - urlencode($nid) + 'https://www.drupal.org/jsonapi/node/contribution_record?filter[field_source_link.uri]=%s&filter[field_contributors.field_credit_this_contributor]=1&include=field_contributors.field_contributor_user&fields[node--contribution_record]=field_contributors&fields[paragraph--contributor]=field_contributor_user,field_credit_this_contributor&fields[user--user]=display_name', + urlencode($sourceLink) ); - $promises[$nid] = $this->client->requestAsync('GET', $url); + $promises[$id] = $this->client->requestAsync('GET', $url); } $results = Utils::settle($promises)->wait(); - foreach ($results as $nid => $result) { + foreach ($results as $id => $result) { if ($result['state'] === PromiseInterface::FULFILLED) { try { $data = \json_decode((string) $result['value']->getBody(), false, 512, JSON_THROW_ON_ERROR); - $contributors[$nid] = $this->extractContributorsFromJsonApiResponse($data); + $contributors[$id] = $this->extractContributorsFromJsonApiResponse($data); } catch (\JsonException) { - $contributors[$nid] = []; + $contributors[$id] = []; } } else { - $contributors[$nid] = []; + $contributors[$id] = []; } } } catch (\Throwable) { diff --git a/src/Api/GitLab/Client.php b/src/Api/GitLab/Client.php index e440c11..77ac8be 100644 --- a/src/Api/GitLab/Client.php +++ b/src/Api/GitLab/Client.php @@ -3,7 +3,10 @@ namespace mglaman\DrupalOrg\GitLab; use GuzzleHttp\HandlerStack; +use GuzzleHttp\Promise\PromiseInterface; +use GuzzleHttp\Promise\Utils; use GuzzleRetry\GuzzleRetryMiddleware; +use mglaman\DrupalOrg\GitLab\Entity\GitLabIssue; use Symfony\Component\Process\Process; class Client @@ -223,6 +226,45 @@ public function getIssues(string $projectPath, array $params = []): array return is_array($result) ? $result : []; } + /** + * Fetch multiple work items in one project concurrently, keyed by iid. + * + * Work item iids are unique only within a project, so this fetches each iid + * from the same project path. Failed or non-decodable responses map to null. + * + * @param int[] $iids + * @return array + */ + public function getIssuesByIid(string $projectPath, array $iids): array + { + if ($iids === []) { + return []; + } + + $path = 'projects/' . urlencode($projectPath) . '/issues/'; + $issues = []; + $promises = []; + foreach ($iids as $iid) { + $promises[$iid] = $this->client->requestAsync('GET', $path . $iid); + } + + $results = Utils::settle($promises)->wait(); + foreach ($results as $iid => $result) { + if ($result['state'] !== PromiseInterface::FULFILLED) { + $issues[$iid] = null; + continue; + } + try { + $data = \json_decode((string) $result['value']->getBody(), false, 512, JSON_THROW_ON_ERROR); + $issues[$iid] = GitLabIssue::fromStdClass($data); + } catch (\JsonException) { + $issues[$iid] = null; + } + } + + return $issues; + } + /** * GET /projects/{id}/jobs/{job_id}/trace * diff --git a/src/Api/Result/Maintainer/MaintainerReleaseNotesResult.php b/src/Api/Result/Maintainer/MaintainerReleaseNotesResult.php index f832c94..9a7b0f3 100644 --- a/src/Api/Result/Maintainer/MaintainerReleaseNotesResult.php +++ b/src/Api/Result/Maintainer/MaintainerReleaseNotesResult.php @@ -12,6 +12,7 @@ class MaintainerReleaseNotesResult implements ResultInterface * @param array $contributors username => commit count * @param list $nidList * @param ChangeRecord[] $changeRecords + * @param array $issueLinks nid => resolved issue URL */ public function __construct( public readonly string $ref1, @@ -21,6 +22,8 @@ public function __construct( public readonly array $contributors, public readonly array $nidList, public readonly array $changeRecords, + public readonly array $issueLinks = [], + public readonly bool $isGitLab = false, ) { } @@ -37,6 +40,8 @@ public function jsonSerialize(): mixed static fn(ChangeRecord $r) => ['title' => $r->title, 'url' => $r->url], $this->changeRecords ), + 'issue_links' => $this->issueLinks, + 'is_gitlab' => $this->isGitLab, ]; } } diff --git a/src/Cli/Command/Maintainer/ReleaseNotes.php b/src/Cli/Command/Maintainer/ReleaseNotes.php index 1795bf3..87f5c31 100644 --- a/src/Cli/Command/Maintainer/ReleaseNotes.php +++ b/src/Cli/Command/Maintainer/ReleaseNotes.php @@ -103,7 +103,7 @@ protected function execute( switch ($format) { case 'json': $this->stdOut->writeln( - json_encode($result->categorizedChanges, JSON_PRETTY_PRINT) + json_encode($result, JSON_PRETTY_PRINT) ); break; @@ -143,8 +143,9 @@ function ($username) use ($format): string { foreach ($result->categorizedChanges as $changeCategory => $changeCategoryItems) { $this->stdOut->writeln(sprintf('#### %s', $changeCategory)); $this->stdOut->writeln(''); - foreach ($changeCategoryItems as $change) { - $this->stdOut->writeln(sprintf('* %s', $this->formatLine($change, $format))); + foreach ($changeCategoryItems as $nid => $change) { + $link = $result->isGitLab ? ($result->issueLinks[$nid] ?? null) : null; + $this->stdOut->writeln(sprintf('* %s', $this->formatLine($change, $format, $link))); } $this->stdOut->writeln(''); } @@ -202,9 +203,10 @@ function ($username) use ($format): string { sprintf('

%s

', $changeCategory) ); $this->stdOut->writeln('
    '); - foreach ($changeCategoryItems as $change) { + foreach ($changeCategoryItems as $nid => $change) { + $link = $result->isGitLab ? ($result->issueLinks[$nid] ?? null) : null; $this->stdOut->writeln( - sprintf('
  • %s
  • ', $this->formatLine($change, $format)) + sprintf('
  • %s
  • ', $this->formatLine($change, $format, $link)) ); } $this->stdOut->writeln('
'); @@ -242,18 +244,24 @@ protected function formatUsername(string $user, string $format): string return sprintf($replacement, $userAlias, $user); } - protected function formatLine(string $value, string $format): string + protected function formatLine(string $value, string $format, ?string $link = null): string { - $baseUrl = 'https://www.drupal.org/node/$1'; - - if ($format === 'html') { - $replacement = sprintf('#$1', $baseUrl); - } elseif ($format === 'markdown' || $format === 'md') { - $replacement = sprintf('[#$1](%s)', $baseUrl); - } else { - $replacement = '#$1'; - } - - return preg_replace('/#(\d+)/S', $replacement, $value); + // A resolved $link (GitLab work item or canonical issue URL) overrides the + // default; otherwise each "#nid" links to its own global node. The URL is + // emitted via a callback, never interpolated into the regex replacement. + return preg_replace_callback( + '/#(\d+)/S', + static function (array $matches) use ($format, $link): string { + $url = $link ?? sprintf('https://www.drupal.org/node/%s', $matches[1]); + if ($format === 'html') { + return sprintf('#%s', htmlspecialchars($url, ENT_QUOTES), $matches[1]); + } + if ($format === 'markdown' || $format === 'md') { + return sprintf('[#%s](%s)', $matches[1], $url); + } + return '#' . $matches[1]; + }, + $value + ) ?? $value; } } diff --git a/tests/src/Action/Maintainer/GetMaintainerReleaseNotesActionTest.php b/tests/src/Action/Maintainer/GetMaintainerReleaseNotesActionTest.php index 9cf871e..5a231b8 100644 --- a/tests/src/Action/Maintainer/GetMaintainerReleaseNotesActionTest.php +++ b/tests/src/Action/Maintainer/GetMaintainerReleaseNotesActionTest.php @@ -9,8 +9,11 @@ use GuzzleHttp\Psr7\Response; use mglaman\DrupalOrg\Action\Maintainer\GetMaintainerReleaseNotesAction; use mglaman\DrupalOrg\Client; +use mglaman\DrupalOrg\GitLab\Client as GitLabClient; +use mglaman\DrupalOrg\GitLab\Entity\GitLabIssue; use mglaman\DrupalOrg\Result\Maintainer\MaintainerReleaseNotesResult; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\Process\Process; @@ -102,11 +105,11 @@ public function testInvokeThrowsOnInvalidRef2(): void public function testInvokeRef2HeadAllowed(): void { - // MockHandler responses: contributors for NID 1234567, issue details for NID 1234567, projectId query + // Request order: project lookup, contributors, issue details. $client = $this->makeClientWithMockResponses([ + new Response(200, [], json_encode(['list' => []])), // project lookup (null → legacy) new Response(200, [], json_encode(['data' => []])), // contributors (empty) new Response(200, [], json_encode(['nid' => '1234567', 'title' => 'Fix the thing', 'field_issue_category' => 1])), // issue details - new Response(200, [], json_encode(['list' => []])), // projectId (null) ]); $action = new GetMaintainerReleaseNotesAction($client); @@ -120,11 +123,11 @@ public function testInvokeRef2HeadAllowed(): void public function testInvoke(): void { - // MockHandler responses: contributors, issue details, projectId + // Request order: project lookup (null → legacy, no change records), contributors, issue details. $client = $this->makeClientWithMockResponses([ + new Response(200, [], json_encode(['list' => []])), // project lookup (null → legacy) new Response(200, [], json_encode(['data' => []])), // contributors (empty) new Response(200, [], json_encode(['nid' => '1234567', 'title' => 'Fix the thing', 'field_issue_category' => 1])), // issue details - new Response(200, [], json_encode(['list' => []])), // projectId (null → skip change records) ]); $action = new GetMaintainerReleaseNotesAction($client); @@ -145,12 +148,75 @@ public function testInvoke(): void self::assertSame([], $result->changeRecords); } + public function testInvokeGitLabWorkItem(): void + { + // Request order: project lookup (GitLab), contributors, change records. + // GitLab work item data comes from the injected GitLab client. + $client = $this->makeClientWithMockResponses([ + new Response(200, [], json_encode(['list' => [[ + 'nid' => '2431121', + 'field_project_machine_name' => 'canvas', + 'field_project_has_issue_queue' => false, + ]]])), // project lookup (GitLab) + new Response(200, [], json_encode(['data' => []])), // contributors (empty) + new Response(200, [], json_encode(['list' => []])), // change records (none) + ]); + + $gitLabClient = $this->createMock(GitLabClient::class); + $gitLabClient->method('getIssuesByIid')->willReturn([ + 1234567 => GitLabIssue::fromStdClass((object) [ + 'iid' => 1234567, + 'title' => 'Fix the thing', + 'labels' => ['category::bug'], + 'web_url' => 'https://git.drupalcode.org/project/canvas/-/work_items/1234567', + ]), + ]); + + $action = new GetMaintainerReleaseNotesAction($client, $gitLabClient); + $result = $action(self::$gitRepo, self::$tmpDir, '1.0.0', '1.1.0'); + + self::assertTrue($result->isGitLab); + // Category resolved from the category::bug GitLab label. + self::assertArrayHasKey('Bug', $result->categorizedChanges); + // Link uses the work item web_url, not a global node URL. + self::assertSame( + 'https://git.drupalcode.org/project/canvas/-/work_items/1234567', + $result->issueLinks[1234567] + ); + } + + public function testInvokeGitLabWorkItemFallsBackToCanonicalLink(): void + { + $client = $this->makeClientWithMockResponses([ + new Response(200, [], json_encode(['list' => [[ + 'nid' => '2431121', + 'field_project_machine_name' => 'canvas', + 'field_project_has_issue_queue' => false, + ]]])), + new Response(200, [], json_encode(['data' => []])), + new Response(200, [], json_encode(['list' => []])), + ]); + + // GitLab fetch fails for the iid (null), so the canonical link is used. + $gitLabClient = $this->createMock(GitLabClient::class); + $gitLabClient->method('getIssuesByIid')->willReturn([1234567 => null]); + + $action = new GetMaintainerReleaseNotesAction($client, $gitLabClient); + $result = $action(self::$gitRepo, self::$tmpDir, '1.0.0', '1.1.0'); + + self::assertTrue($result->isGitLab); + self::assertSame( + 'https://www.drupal.org/project/canvas/issues/1234567', + $result->issueLinks[1234567] + ); + } + public function testJsonSerialize(): void { $client = $this->makeClientWithMockResponses([ + new Response(200, [], json_encode(['list' => []])), new Response(200, [], json_encode(['data' => []])), new Response(200, [], json_encode(['nid' => '1234567', 'title' => 'Fix the thing', 'field_issue_category' => 0])), - new Response(200, [], json_encode(['list' => []])), ]); $action = new GetMaintainerReleaseNotesAction($client); @@ -166,5 +232,40 @@ public function testJsonSerialize(): void self::assertIsArray($decoded['contributors']); self::assertIsArray($decoded['nid_list']); self::assertIsArray($decoded['change_records']); + self::assertIsArray($decoded['issue_links']); + self::assertFalse($decoded['is_gitlab']); + } + + /** + * @return array + */ + public static function gitLabRemoteProvider(): array + { + return [ + 'https project' => ['https://git.drupalcode.org/project/canvas.git', 'canvas'], + 'ssh project' => ['git@git.drupalcode.org:project/canvas.git', 'canvas'], + 'issue fork strips nid' => ['https://git.drupalcode.org/issue/canvas-3001234.git', 'canvas'], + 'machine name with underscore' => ['https://git.drupalcode.org/project/some_module.git', 'some_module'], + ]; + } + + #[DataProvider('gitLabRemoteProvider')] + public function testGetProjectNameFromGitLabRemote(string $remoteUrl, string $expected): void + { + $dir = sys_get_temp_dir() . '/drupalorg-cli-remote-' . uniqid(); + mkdir($dir); + $run = static function (array $cmd) use ($dir): void { + (new Process($cmd, $dir))->mustRun(); + }; + $run(['git', 'init']); + $run(['git', 'remote', 'add', 'origin', $remoteUrl]); + + $action = new GetMaintainerReleaseNotesAction($this->createMock(Client::class)); + $method = new \ReflectionMethod($action, 'getProjectName'); + $projectName = $method->invoke($action, $dir); + + (new Process(['rm', '-rf', $dir]))->run(); + + self::assertSame($expected, $projectName); } } diff --git a/tests/src/CommitParserTest.php b/tests/src/CommitParserTest.php new file mode 100644 index 0000000..38fa923 --- /dev/null +++ b/tests/src/CommitParserTest.php @@ -0,0 +1,34 @@ + + */ + public static function conventionalCommitProvider(): array + { + return [ + 'fix maps to Bug' => ['fix: #123 correct the thing', 'Bug'], + 'feat maps to Feature' => ['feat: add a new thing', 'Feature'], + 'chore maps to Task' => ['chore: tidy up', 'Task'], + 'scope with spaces' => ['feat(CLI Tool): add a flag', 'Feature'], + 'scope with spaces and bang' => ['chore(Project management)!: drop support', 'Task'], + 'unmapped type' => ['docs: update readme', null], + 'not conventional' => ['Issue #123: fix the thing by user:', null], + ]; + } + + #[DataProvider('conventionalCommitProvider')] + public function testCategoryFromConventionalCommit(string $title, ?string $expected): void + { + self::assertSame($expected, CommitParser::categoryFromConventionalCommit($title)); + } +}