From 8b68a45fed7f05890b5387f994eb5ea313d0b6e0 Mon Sep 17 00:00:00 2001 From: Matt Glaman Date: Thu, 4 Jun 2026 11:03:13 -0500 Subject: [PATCH 1/2] fix: resolve release notes issue links for GitLab work item projects Release notes treated every numeric ID in a commit title as a global Drupal.org node ID. For projects migrated to GitLab work items, that ID is a work item iid that is unique only within its project, so links pointed at unrelated nodes and categories came back wrong. Tested against canvas 1.5.0 from 1.4.1, where every issue link was broken. Detect GitLab projects via field_project_has_issue_queue. For those projects: - Link to the work item web_url, falling back to the canonical drupal.org/project/{project}/issues/{iid} URL when the GitLab fetch fails. - Resolve the category from the category::* GitLab label, then a conventional-commit prefix, then Misc. - Filter contribution records by the work item URL. Parse git.drupalcode.org remotes so the project machine name resolves correctly for GitLab-hosted projects, and apply the conventional-commit category fallback to legacy projects too. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../GetMaintainerReleaseNotesAction.php | 108 ++++++++++++++++-- src/Api/CommitParser.php | 20 ++++ src/Api/DrupalOrg.php | 52 +++++++-- src/Api/GitLab/Client.php | 42 +++++++ .../MaintainerReleaseNotesResult.php | 5 + src/Cli/Command/Maintainer/ReleaseNotes.php | 16 ++- .../GetMaintainerReleaseNotesActionTest.php | 75 +++++++++++- tests/src/CommitParserTest.php | 34 ++++++ 8 files changed, 319 insertions(+), 33 deletions(-) create mode 100644 tests/src/CommitParserTest.php diff --git a/src/Api/Action/Maintainer/GetMaintainerReleaseNotesAction.php b/src/Api/Action/Maintainer/GetMaintainerReleaseNotesAction.php index 95e4068..2d73d08 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. @@ -159,9 +211,34 @@ public function __invoke( 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..7751386 100644 --- a/src/Cli/Command/Maintainer/ReleaseNotes.php +++ b/src/Cli/Command/Maintainer/ReleaseNotes.php @@ -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->issueLinks[$nid] ?? 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->issueLinks[$nid] ?? null; $this->stdOut->writeln( - sprintf('
  • %s
  • ', $this->formatLine($change, $format)) + sprintf('
  • %s
  • ', $this->formatLine($change, $format, $link)) ); } $this->stdOut->writeln('
'); @@ -242,9 +244,11 @@ 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'; + // Legacy issues fall back to the global node URL; GitLab work items and + // resolved issues pass an explicit project-scoped link. + $baseUrl = $link ?? 'https://www.drupal.org/node/$1'; if ($format === 'html') { $replacement = sprintf('#$1', $baseUrl); diff --git a/tests/src/Action/Maintainer/GetMaintainerReleaseNotesActionTest.php b/tests/src/Action/Maintainer/GetMaintainerReleaseNotesActionTest.php index 9cf871e..39026da 100644 --- a/tests/src/Action/Maintainer/GetMaintainerReleaseNotesActionTest.php +++ b/tests/src/Action/Maintainer/GetMaintainerReleaseNotesActionTest.php @@ -9,6 +9,8 @@ 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\TestCase; @@ -102,11 +104,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 +122,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 +147,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); 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)); + } +} From 4af646d11715e409b1546297e55c7c9d16dfd242 Mon Sep 17 00:00:00 2001 From: Matt Glaman Date: Thu, 4 Jun 2026 11:13:42 -0500 Subject: [PATCH 2/2] fix: harden release notes link rendering and json output Address review findings: - formatLine builds each link via a callback instead of interpolating the URL into the preg_replace replacement, which avoids backreference mangling and escapes the href for HTML output. - Only override the issue link for GitLab projects, so legacy titles that reference several issues keep linking each "#nid" to its own node. - Emit the full result DTO for --format=json so consumers receive issue_links and is_gitlab, not just the categorized changes. - Report the resolved machine name as the project so it matches the links. - Skip change record lookup when the project nid resolves to an empty string. Add tests for the git.drupalcode.org remote parsing and the new json keys. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../GetMaintainerReleaseNotesAction.php | 4 +-- src/Cli/Command/Maintainer/ReleaseNotes.php | 36 ++++++++++--------- .../GetMaintainerReleaseNotesActionTest.php | 36 +++++++++++++++++++ 3 files changed, 58 insertions(+), 18 deletions(-) diff --git a/src/Api/Action/Maintainer/GetMaintainerReleaseNotesAction.php b/src/Api/Action/Maintainer/GetMaintainerReleaseNotesAction.php index 2d73d08..51d2246 100644 --- a/src/Api/Action/Maintainer/GetMaintainerReleaseNotesAction.php +++ b/src/Api/Action/Maintainer/GetMaintainerReleaseNotesAction.php @@ -199,14 +199,14 @@ 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, diff --git a/src/Cli/Command/Maintainer/ReleaseNotes.php b/src/Cli/Command/Maintainer/ReleaseNotes.php index 7751386..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; @@ -144,7 +144,7 @@ function ($username) use ($format): string { $this->stdOut->writeln(sprintf('#### %s', $changeCategory)); $this->stdOut->writeln(''); foreach ($changeCategoryItems as $nid => $change) { - $link = $result->issueLinks[$nid] ?? null; + $link = $result->isGitLab ? ($result->issueLinks[$nid] ?? null) : null; $this->stdOut->writeln(sprintf('* %s', $this->formatLine($change, $format, $link))); } $this->stdOut->writeln(''); @@ -204,7 +204,7 @@ function ($username) use ($format): string { ); $this->stdOut->writeln('
    '); foreach ($changeCategoryItems as $nid => $change) { - $link = $result->issueLinks[$nid] ?? null; + $link = $result->isGitLab ? ($result->issueLinks[$nid] ?? null) : null; $this->stdOut->writeln( sprintf('
  • %s
  • ', $this->formatLine($change, $format, $link)) ); @@ -246,18 +246,22 @@ protected function formatUsername(string $user, string $format): string protected function formatLine(string $value, string $format, ?string $link = null): string { - // Legacy issues fall back to the global node URL; GitLab work items and - // resolved issues pass an explicit project-scoped link. - $baseUrl = $link ?? '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 39026da..5a231b8 100644 --- a/tests/src/Action/Maintainer/GetMaintainerReleaseNotesActionTest.php +++ b/tests/src/Action/Maintainer/GetMaintainerReleaseNotesActionTest.php @@ -13,6 +13,7 @@ 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; @@ -231,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); } }