From 8aa2f63510bbeb54757eaefb2f0f91921e67f857 Mon Sep 17 00:00:00 2001 From: Matt Glaman Date: Wed, 16 Sep 2026 09:58:46 -0500 Subject: [PATCH 1/7] fix: report migrated issues by their GitLab work item instead of "not found" Drupal.org answers a migrated node ID with a stub whose only field is new_url. getNode() read that as a missing node. It now throws MigratedIssueException carrying the parsed work item ref, and the message names the qualified ref to pass. IssueProjectResolver takes the project from that ref, so a bare NID resolves for issue:get-fork, issue:setup-remote, and issue:checkout without a qualifier. Refs #380 Co-Authored-By: Claude Fable 5.1 --- src/Api/Client.php | 8 +++++ src/Api/IssueProjectResolver.php | 23 +++++++++++--- src/Api/MigratedIssueException.php | 30 ++++++++++++++++++ tests/src/ClientTest.php | 23 ++++++++++++++ tests/src/IssueProjectResolverTest.php | 43 ++++++++++++++++++++++++++ 5 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 src/Api/MigratedIssueException.php diff --git a/src/Api/Client.php b/src/Api/Client.php index 85276bc..f18052d 100644 --- a/src/Api/Client.php +++ b/src/Api/Client.php @@ -8,6 +8,7 @@ use mglaman\DrupalOrg\Entity\IssueNode; use mglaman\DrupalOrg\Entity\Project; use mglaman\DrupalOrg\Entity\Release; +use mglaman\DrupalOrg\GitLab\WorkItemRef; class Client { @@ -84,6 +85,8 @@ public function requestRaw(Request $request): \stdClass * serves every node type from the same endpoint, so both cases are checked * here rather than surfacing as empty issue fields downstream. * + * @throws MigratedIssueException + * When the issue moved to a GitLab work item. * @throws \RuntimeException * When the node does not exist or is not an issue. */ @@ -92,6 +95,11 @@ public function getNode(string $nid): IssueNode $data = $this->request(new Request('node/' . $nid)); $type = $data->type ?? null; if (!is_string($type)) { + $newUrl = $data->new_url ?? null; + $ref = is_string($newUrl) ? WorkItemRef::tryParse($newUrl) : null; + if ($newUrl !== null && $ref !== null) { + throw new MigratedIssueException($nid, $ref, $newUrl); + } throw new \RuntimeException(sprintf('Node %s was not found on Drupal.org.', $nid)); } if ($type !== 'project_issue') { diff --git a/src/Api/IssueProjectResolver.php b/src/Api/IssueProjectResolver.php index 35142fe..ac312db 100644 --- a/src/Api/IssueProjectResolver.php +++ b/src/Api/IssueProjectResolver.php @@ -14,7 +14,8 @@ * 1. An explicit project qualifier (project#id, work-item URL). * 2. The project of the git repository the command runs in, checked * against Drupal.org when the node exists. - * 3. The Drupal.org node lookup. + * 3. The Drupal.org node lookup. A node that moved to a GitLab work item + * names its project in the redirect, so that counts as a lookup too. */ final class IssueProjectResolver { @@ -38,7 +39,7 @@ public function resolve(string $nid, ?string $explicitProject = null, ?string $r } try { - $nodeProject = $this->client->getNode($nid)->fieldProjectMachineName; + $nodeProject = $this->nodeProject($nid); } catch (\RuntimeException $e) { throw new \RuntimeException( sprintf('%s %s', $e->getMessage(), self::qualifierHint($nid)), @@ -59,10 +60,10 @@ public function resolve(string $nid, ?string $explicitProject = null, ?string $r private function resolveAgainstRepository(string $nid, string $repositoryProject): string { try { - $nodeProject = $this->client->getNode($nid)->fieldProjectMachineName; + $nodeProject = $this->nodeProject($nid); } catch (\RuntimeException) { - // Not a Drupal.org issue node (for example a migrated work item), - // so the repository is the only source for the project. + // Not a Drupal.org issue node, so the repository is the only + // source for the project. return $repositoryProject; } @@ -79,6 +80,18 @@ private function resolveAgainstRepository(string $nid, string $repositoryProject )); } + /** + * @throws \RuntimeException + */ + private function nodeProject(string $nid): string + { + try { + return $this->client->getNode($nid)->fieldProjectMachineName; + } catch (MigratedIssueException $e) { + return $e->ref->projectMachineName(); + } + } + private static function qualifierHint(string $nid): string { return sprintf('Pass the project explicitly as project#%s.', $nid); diff --git a/src/Api/MigratedIssueException.php b/src/Api/MigratedIssueException.php new file mode 100644 index 0000000..92e36d6 --- /dev/null +++ b/src/Api/MigratedIssueException.php @@ -0,0 +1,30 @@ +projectMachineName(), + $nid + )); + } +} diff --git a/tests/src/ClientTest.php b/tests/src/ClientTest.php index e1acecb..b5b0a9e 100644 --- a/tests/src/ClientTest.php +++ b/tests/src/ClientTest.php @@ -6,6 +6,7 @@ use GuzzleHttp\HandlerStack; use GuzzleHttp\Psr7\Response; use mglaman\DrupalOrg\Client; +use mglaman\DrupalOrg\MigratedIssueException; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -57,6 +58,28 @@ public function testGetNodeRejectsNonIssueNode(): void $client->getNode('3000001'); } + public function testGetNodeReportsMigratedIssue(): void + { + $client = self::clientResponding([ + 'new_url' => 'https://git.drupalcode.org/project/restrict_route_by_ip/-/work_items/3617735', + ]); + + try { + $client->getNode('3617735'); + self::fail('Expected MigratedIssueException.'); + } catch (MigratedIssueException $e) { + self::assertSame('3617735', $e->nid); + self::assertSame('project/restrict_route_by_ip', $e->ref->projectPath); + self::assertSame(3617735, $e->ref->issueId); + self::assertSame( + 'Issue 3617735 moved to a GitLab work item at ' + . 'https://git.drupalcode.org/project/restrict_route_by_ip/-/work_items/3617735. ' + . 'Pass restrict_route_by_ip#3617735.', + $e->getMessage() + ); + } + } + public function testGetNodeRejectsMissingNode(): void { $client = self::clientResponding(['comments' => [], 'body' => []]); diff --git a/tests/src/IssueProjectResolverTest.php b/tests/src/IssueProjectResolverTest.php index 35a5e8b..c76d231 100644 --- a/tests/src/IssueProjectResolverTest.php +++ b/tests/src/IssueProjectResolverTest.php @@ -6,7 +6,9 @@ use mglaman\DrupalOrg\Client; use mglaman\DrupalOrg\Entity\IssueNode; +use mglaman\DrupalOrg\GitLab\WorkItemRef; use mglaman\DrupalOrg\IssueProjectResolver; +use mglaman\DrupalOrg\MigratedIssueException; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -35,6 +37,15 @@ private static function makeIssueNode(string $nid, string $project): IssueNode ); } + private static function migrated(string $nid, string $project): MigratedIssueException + { + return new MigratedIssueException( + $nid, + new WorkItemRef('project/' . $project, (int) $nid), + sprintf('https://git.drupalcode.org/project/%s/-/work_items/%s', $project, $nid) + ); + } + public function testExplicitProjectSkipsNodeLookup(): void { $client = $this->createMock(Client::class); @@ -102,6 +113,38 @@ public function testBareNidUsesNodeProject(): void self::assertSame('drupal', $resolver->resolve('3383637')); } + public function testBareNidForMigratedIssueUsesRedirectProject(): void + { + $client = $this->createMock(Client::class); + $client->method('getNode')->willThrowException(self::migrated('3617735', 'restrict_route_by_ip')); + + $resolver = new IssueProjectResolver($client); + + self::assertSame('restrict_route_by_ip', $resolver->resolve('3617735')); + } + + public function testMigratedIssueConfirmedByRepository(): void + { + $client = $this->createMock(Client::class); + $client->method('getNode')->willThrowException(self::migrated('3617735', 'restrict_route_by_ip')); + + $resolver = new IssueProjectResolver($client); + + self::assertSame('restrict_route_by_ip', $resolver->resolve('3617735', null, 'restrict_route_by_ip')); + } + + public function testMigratedIssueInAnotherRepositoryFails(): void + { + $client = $this->createMock(Client::class); + $client->method('getNode')->willThrowException(self::migrated('3617735', 'restrict_route_by_ip')); + + $resolver = new IssueProjectResolver($client); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Issue 3617735 belongs to project "restrict_route_by_ip" on Drupal.org'); + $resolver->resolve('3617735', null, 'campaign'); + } + public function testBareNidWithoutProjectFails(): void { $client = $this->createMock(Client::class); From a12397f4744ce93108056c4f3e9fd7b79d287ba5 Mon Sep 17 00:00:00 2001 From: Matt Glaman Date: Wed, 16 Sep 2026 09:59:18 -0500 Subject: [PATCH 2/7] refactor: extract issue branch naming rules from IssueNode No behavior change. The slug and version-branch rules move to IssueBranchNaming so a GitLab work item can name its branch the same way a Drupal.org issue does. Co-Authored-By: Claude Fable 5.1 --- src/Api/Entity/IssueNode.php | 14 +++++------- src/Api/IssueBranchNaming.php | 33 ++++++++++++++++++++++++++++ tests/src/IssueBranchNamingTest.php | 34 +++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 9 deletions(-) create mode 100644 src/Api/IssueBranchNaming.php create mode 100644 tests/src/IssueBranchNamingTest.php diff --git a/src/Api/Entity/IssueNode.php b/src/Api/Entity/IssueNode.php index 3efe61b..2adcab6 100644 --- a/src/Api/Entity/IssueNode.php +++ b/src/Api/Entity/IssueNode.php @@ -2,6 +2,8 @@ namespace mglaman\DrupalOrg\Entity; +use mglaman\DrupalOrg\IssueBranchNaming; + class IssueNode { /** @@ -30,9 +32,7 @@ public function __construct( public function buildCleanTitle(): string { - $cleanTitle = preg_replace('/[^a-zA-Z0-9]+/', '_', $this->title); - $cleanTitle = strtolower(substr((string) $cleanTitle, 0, 20)); - return (string) preg_replace('/(^_|_$)/', '', $cleanTitle); + return IssueBranchNaming::slug($this->title); } public function buildBranchName(): string @@ -42,14 +42,10 @@ public function buildBranchName(): string public function buildIssueVersionBranch(): string { - $issueVersionBranch = $this->fieldIssueVersion; if ($this->fieldProjectId === '3060') { - return substr($issueVersionBranch, 0, 5); - } - if (preg_match('/^(\d+\.\d+)\./', $issueVersionBranch, $matches)) { - return $matches[1] . '.x'; + return substr($this->fieldIssueVersion, 0, 5); } - return substr($issueVersionBranch, 0, 6) . 'x'; + return IssueBranchNaming::versionBranch($this->fieldIssueVersion); } public static function fromStdClass(\stdClass $data): self diff --git a/src/Api/IssueBranchNaming.php b/src/Api/IssueBranchNaming.php new file mode 100644 index 0000000..e48433d --- /dev/null +++ b/src/Api/IssueBranchNaming.php @@ -0,0 +1,33 @@ + + */ + public static function versions(): iterable + { + yield 'semver dev' => ['2.0.x-dev', '2.0.x']; + yield 'semver release' => ['2.0.0-beta2', '2.0.x']; + yield 'legacy dev' => ['8.x-1.x-dev', '8.x-1.x']; + } + + #[DataProvider('versions')] + public function testVersionBranch(string $version, string $expected): void + { + self::assertSame($expected, IssueBranchNaming::versionBranch($version)); + } +} From 9a79f23e40e6b00844973a24b22a459da962e7b3 Mon Sep 17 00:00:00 2001 From: Matt Glaman Date: Wed, 16 Sep 2026 10:00:09 -0500 Subject: [PATCH 3/7] feat: issue:branch supports GitLab work items The command parsed project#nid and work item URLs but then discarded the ref and read the Drupal.org node, which fails for migrated issues. The action now takes the ref, or follows a migrated node to GitLab, and names the branch from the work item title and its version label (for example v2.0.x-dev). Work items without a version label fall back to the project's default branch. Fixes #380 Co-Authored-By: Claude Fable 5.1 --- .../Action/Issue/GetIssueBranchNameAction.php | 33 ++++++-- src/Api/IssueBranchNaming.php | 16 ++++ src/Api/Mcp/ToolRegistry.php | 2 +- src/Api/Result/Issue/IssueBranchResult.php | 10 +++ src/Cli/Command/Issue/Branch.php | 7 +- .../Issue/GetIssueBranchNameActionTest.php | 77 ++++++++++++++++++- tests/src/IssueBranchNamingTest.php | 8 ++ 7 files changed, 142 insertions(+), 11 deletions(-) diff --git a/src/Api/Action/Issue/GetIssueBranchNameAction.php b/src/Api/Action/Issue/GetIssueBranchNameAction.php index 22a35f8..64d1a5e 100644 --- a/src/Api/Action/Issue/GetIssueBranchNameAction.php +++ b/src/Api/Action/Issue/GetIssueBranchNameAction.php @@ -4,17 +4,40 @@ use mglaman\DrupalOrg\Action\ActionInterface; use mglaman\DrupalOrg\Client; +use mglaman\DrupalOrg\GitLab\Client as GitLabClient; +use mglaman\DrupalOrg\GitLab\Entity\GitLabIssue; +use mglaman\DrupalOrg\GitLab\WorkItemRef; +use mglaman\DrupalOrg\IssueBranchNaming; +use mglaman\DrupalOrg\MigratedIssueException; use mglaman\DrupalOrg\Result\Issue\IssueBranchResult; class GetIssueBranchNameAction implements ActionInterface { - public function __construct(private readonly Client $client) - { + public function __construct( + private readonly Client $client, + private readonly GitLabClient $gitLabClient, + ) { } - public function __invoke(string $nid): IssueBranchResult + /** + * @param WorkItemRef|null $ref + * Names the GitLab work item directly. Without it the Drupal.org node + * is read, and a node that moved to GitLab is followed there. + */ + public function __invoke(string $nid, ?WorkItemRef $ref = null): IssueBranchResult { - $issue = $this->client->getNode($nid); - return IssueBranchResult::fromIssueNode($issue); + if ($ref === null) { + try { + return IssueBranchResult::fromIssueNode($this->client->getNode($nid)); + } catch (MigratedIssueException $e) { + $ref = $e->ref; + } + } + + $issue = GitLabIssue::fromStdClass($this->gitLabClient->getIssue($ref->projectPath, $ref->issueId)); + $versionBranch = IssueBranchNaming::versionBranchFromLabels($issue->labels) + ?? (string) $this->gitLabClient->getProject($ref->projectPath)->default_branch; + + return IssueBranchResult::fromGitLabIssue($issue, $versionBranch); } } diff --git a/src/Api/IssueBranchNaming.php b/src/Api/IssueBranchNaming.php index e48433d..56a489b 100644 --- a/src/Api/IssueBranchNaming.php +++ b/src/Api/IssueBranchNaming.php @@ -30,4 +30,20 @@ public static function versionBranch(string $version): string } return substr($version, 0, 6) . 'x'; } + + /** + * Migrated work items carry the issue version as a label such as + * "v2.0.x-dev". Returns null when no label looks like a version. + * + * @param string[] $labels + */ + public static function versionBranchFromLabels(array $labels): ?string + { + foreach ($labels as $label) { + if (preg_match('/^v(\d.*)$/', $label, $matches) === 1) { + return self::versionBranch($matches[1]); + } + } + return null; + } } diff --git a/src/Api/Mcp/ToolRegistry.php b/src/Api/Mcp/ToolRegistry.php index 88d3952..cdd5695 100644 --- a/src/Api/Mcp/ToolRegistry.php +++ b/src/Api/Mcp/ToolRegistry.php @@ -61,7 +61,7 @@ public function issueGetBranch( #[Schema(description: 'The Drupal.org issue node ID.', pattern: self::NID_PATTERN)] string $nid ): mixed { - return (new GetIssueBranchNameAction($this->client))($nid)->jsonSerialize(); + return (new GetIssueBranchNameAction($this->client, new GitLabClient()))($nid)->jsonSerialize(); } #[McpTool(annotations: new ToolAnnotations(readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true), name: 'issue_get_patch_url', description: 'Get the latest patch URL for an issue.')] diff --git a/src/Api/Result/Issue/IssueBranchResult.php b/src/Api/Result/Issue/IssueBranchResult.php index 6019398..595dd52 100644 --- a/src/Api/Result/Issue/IssueBranchResult.php +++ b/src/Api/Result/Issue/IssueBranchResult.php @@ -3,6 +3,8 @@ namespace mglaman\DrupalOrg\Result\Issue; use mglaman\DrupalOrg\Entity\IssueNode; +use mglaman\DrupalOrg\GitLab\Entity\GitLabIssue; +use mglaman\DrupalOrg\IssueBranchNaming; use mglaman\DrupalOrg\Result\ResultInterface; class IssueBranchResult implements ResultInterface @@ -25,6 +27,14 @@ public static function fromIssueNode(IssueNode $issue): self ); } + public static function fromGitLabIssue(GitLabIssue $issue, string $issueVersionBranch): self + { + return new self( + branchName: sprintf('%d-%s', $issue->iid, IssueBranchNaming::slug($issue->title)), + issueVersionBranch: $issueVersionBranch, + ); + } + public function jsonSerialize(): mixed { return [ diff --git a/src/Cli/Command/Issue/Branch.php b/src/Cli/Command/Issue/Branch.php index 0cf8ae5..f7ecf36 100644 --- a/src/Cli/Command/Issue/Branch.php +++ b/src/Cli/Command/Issue/Branch.php @@ -3,6 +3,7 @@ namespace mglaman\DrupalOrgCli\Command\Issue; use mglaman\DrupalOrg\Action\Issue\GetIssueBranchNameAction; +use mglaman\DrupalOrg\GitLab\Client as GitLabClient; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -14,7 +15,7 @@ protected function configure(): void { $this ->setName('issue:branch') - ->addArgument('nid', InputArgument::REQUIRED, 'The issue node ID') + ->addArgument('nid', InputArgument::REQUIRED, 'The issue node ID, project#nid, or GitLab work item URL') ->setDescription('Creates a branch for the issue.') ->setHelp( implode( @@ -36,8 +37,8 @@ protected function execute( InputInterface $input, OutputInterface $output ): int { - $action = new GetIssueBranchNameAction($this->client); - $result = $action($this->nid); + $action = new GetIssueBranchNameAction($this->client, new GitLabClient()); + $result = $action($this->nid, $this->workItemRef); if (!in_array($result->issueVersionBranch, $this->repository->getBranches(), true)) { $this->stdOut->writeln( diff --git a/tests/src/Action/Issue/GetIssueBranchNameActionTest.php b/tests/src/Action/Issue/GetIssueBranchNameActionTest.php index 458e93d..4814b46 100644 --- a/tests/src/Action/Issue/GetIssueBranchNameActionTest.php +++ b/tests/src/Action/Issue/GetIssueBranchNameActionTest.php @@ -5,6 +5,9 @@ use mglaman\DrupalOrg\Action\Issue\GetIssueBranchNameAction; use mglaman\DrupalOrg\Client; use mglaman\DrupalOrg\Entity\IssueNode; +use mglaman\DrupalOrg\GitLab\Client as GitLabClient; +use mglaman\DrupalOrg\GitLab\WorkItemRef; +use mglaman\DrupalOrg\MigratedIssueException; use mglaman\DrupalOrg\Result\Issue\IssueBranchResult; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -30,7 +33,10 @@ public function testInvoke(): void $client = $this->createMock(Client::class); $client->method('getNode')->with('3383637')->willReturn($issueNode); - $action = new GetIssueBranchNameAction($client); + $gitLabClient = $this->createMock(GitLabClient::class); + $gitLabClient->expects(self::never())->method('getIssue'); + + $action = new GetIssueBranchNameAction($client, $gitLabClient); $result = $action('3383637'); self::assertInstanceOf(IssueBranchResult::class, $result); @@ -46,7 +52,7 @@ public function testJsonSerialize(): void $client = $this->createMock(Client::class); $client->method('getNode')->willReturn($issueNode); - $action = new GetIssueBranchNameAction($client); + $action = new GetIssueBranchNameAction($client, $this->createMock(GitLabClient::class)); $result = $action('3383637'); $json = json_encode($result); @@ -55,4 +61,71 @@ public function testJsonSerialize(): void self::assertSame('3383637-schedule_transition', $decoded['branch_name']); self::assertSame('11.x-', $decoded['issue_version_branch']); } + + /** + * @param string[] $labels + */ + private static function makeWorkItem(array $labels): \stdClass + { + $issue = new \stdClass(); + $issue->iid = 3617735; + $issue->title = 'Fix JS on add form and remove jQuery dependency'; + $issue->labels = $labels; + return $issue; + } + + public function testWorkItemRefUsesVersionLabel(): void + { + $client = $this->createMock(Client::class); + $client->expects(self::never())->method('getNode'); + + $gitLabClient = $this->createMock(GitLabClient::class); + $gitLabClient->method('getIssue') + ->with('project/restrict_route_by_ip', 3617735) + ->willReturn(self::makeWorkItem(['state::fixed', 'v2.0.x-dev'])); + $gitLabClient->expects(self::never())->method('getProject'); + + $action = new GetIssueBranchNameAction($client, $gitLabClient); + $result = $action('3617735', new WorkItemRef('project/restrict_route_by_ip', 3617735)); + + self::assertSame('3617735-fix_js_on_add_form_a', $result->branchName); + self::assertSame('2.0.x', $result->issueVersionBranch); + } + + public function testWorkItemWithoutVersionLabelUsesDefaultBranch(): void + { + $project = new \stdClass(); + $project->default_branch = '1.0.x'; + + $gitLabClient = $this->createMock(GitLabClient::class); + $gitLabClient->method('getIssue')->willReturn(self::makeWorkItem(['state::needsReview'])); + $gitLabClient->method('getProject')->with('project/ai_context')->willReturn($project); + + $action = new GetIssueBranchNameAction($this->createMock(Client::class), $gitLabClient); + $result = $action('3617735', new WorkItemRef('project/ai_context', 3617735)); + + self::assertSame('1.0.x', $result->issueVersionBranch); + } + + public function testBareNidFollowsMigratedIssueToGitLab(): void + { + $ref = new WorkItemRef('project/restrict_route_by_ip', 3617735); + $client = $this->createMock(Client::class); + $client->method('getNode')->with('3617735')->willThrowException(new MigratedIssueException( + '3617735', + $ref, + 'https://git.drupalcode.org/project/restrict_route_by_ip/-/work_items/3617735' + )); + + $gitLabClient = $this->createMock(GitLabClient::class); + $gitLabClient->method('getIssue') + ->with('project/restrict_route_by_ip', 3617735) + ->willReturn(self::makeWorkItem(['v2.0.x-dev'])); + + $action = new GetIssueBranchNameAction($client, $gitLabClient); + $result = $action('3617735'); + + self::assertSame('3617735-fix_js_on_add_form_a', $result->branchName); + self::assertSame('2.0.x', $result->issueVersionBranch); + } } diff --git a/tests/src/IssueBranchNamingTest.php b/tests/src/IssueBranchNamingTest.php index dd16322..c8b8eef 100644 --- a/tests/src/IssueBranchNamingTest.php +++ b/tests/src/IssueBranchNamingTest.php @@ -31,4 +31,12 @@ public function testVersionBranch(string $version, string $expected): void { self::assertSame($expected, IssueBranchNaming::versionBranch($version)); } + + public function testVersionBranchFromLabels(): void + { + self::assertSame('2.0.x', IssueBranchNaming::versionBranchFromLabels(['state::fixed', 'v2.0.x-dev'])); + self::assertSame('2.0.x', IssueBranchNaming::versionBranchFromLabels(['v2.0.0-beta2'])); + self::assertNull(IssueBranchNaming::versionBranchFromLabels(['state::needsReview', 'verified'])); + self::assertNull(IssueBranchNaming::versionBranchFromLabels([])); + } } From 449b1d12622483fe8111e3ff6cbb6a0893630577 Mon Sep 17 00:00:00 2001 From: Matt Glaman Date: Wed, 16 Sep 2026 10:00:21 -0500 Subject: [PATCH 4/7] docs: describe work item support in issue:branch and the migrated issue error Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- skill-data/drupalorg-cli/SKILL.md | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3f0a856..6ae71b8 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ drupalorg issue:show project/ai_context#3586157 drupalorg issue:show ai_context#3586157 ``` -The same formats work for `issue:get-fork` and `mr:list`. MR URLs also work directly: +The same formats work for `issue:branch`, `issue:get-fork`, `issue:setup-remote`, `issue:checkout`, and `mr:list`. A bare NID also works for these commands when the issue was migrated: Drupal.org answers with the work item URL and the CLI follows it. MR URLs also work directly: ```bash drupalorg mr:list https://git.drupalcode.org/project/ai_context/-/merge_requests/131 diff --git a/skill-data/drupalorg-cli/SKILL.md b/skill-data/drupalorg-cli/SKILL.md index dea6e16..c75ee81 100644 --- a/skill-data/drupalorg-cli/SKILL.md +++ b/skill-data/drupalorg-cli/SKILL.md @@ -106,6 +106,8 @@ drupalorg issue:setup-remote [nid] drupalorg issue:checkout [nid] [branch] # Create a local git branch named after the issue +# Accepts a D.o NID, shorthand ref, or work item URL. For work items the base +# branch comes from the version label (v2.0.x-dev) or the project default branch drupalorg issue:branch # Generate a patch from committed (but not yet pushed) changes @@ -266,6 +268,7 @@ drupalorg mr:list [nid] --format=llm --no-cache | Error | Cause | Recovery | |-------|-------|----------| | `Node not found` | Invalid or private issue NID, or a GitLab work item NID passed to a D.o-only command | Use a WorkItemRef instead: `ai_context#3586157` | +| `Issue … moved to a GitLab work item` | The D.o issue migrated to GitLab and the command has no work item support | Pass the ref the message names, e.g. `restrict_route_by_ip#3617735`, to a command that supports work items | | `404 Project Not Found` (GitLab) | D.o issue NID used with a GitLab work item project — D.o node has no `field_project` | Pass the full work item URL or shorthand ref | | `No patch found on issue` | Issue has no file attachments | Check `issue:show` to confirm files exist | | `No branch configured` | `issue:patch` run outside a git repo or without a tracking branch | Run `issue:branch ` first | From e032068c429c94b55c7bb2746a4eca9e37b5c4d5 Mon Sep 17 00:00:00 2001 From: Matt Glaman Date: Wed, 16 Sep 2026 10:01:27 -0500 Subject: [PATCH 5/7] fix: initialize the git repository when the issue argument is a work item ref IssueCommandBase returned early after parsing project#nid or a work item URL and never opened the repository, so issue:branch crashed with an uninitialized property for those forms. Co-Authored-By: Claude Fable 5.1 --- src/Cli/Command/Issue/IssueCommandBase.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Cli/Command/Issue/IssueCommandBase.php b/src/Cli/Command/Issue/IssueCommandBase.php index 4206753..8483435 100644 --- a/src/Cli/Command/Issue/IssueCommandBase.php +++ b/src/Cli/Command/Issue/IssueCommandBase.php @@ -48,6 +48,9 @@ protected function initialize( if ($ref !== null) { $this->workItemRef = $ref; $this->nid = (string) $ref->issueId; + if ($this->requiresRepository) { + $this->initRepo(); + } return; } From f158b83a993797284e194a7b74b4adbc232425d3 Mon Sep 17 00:00:00 2001 From: Matt Glaman Date: Wed, 16 Sep 2026 10:15:01 -0500 Subject: [PATCH 6/7] feat: issue:show follows a migrated issue to its GitLab work item A bare NID for a migrated issue now renders the work item instead of stopping with the migration error. The command already had the GitLab path; it only needed to catch MigratedIssueException and reuse it. Co-Authored-By: Claude Fable 5.1 --- src/Cli/Command/Issue/Show.php | 85 ++++++++++++++++++++-------------- 1 file changed, 49 insertions(+), 36 deletions(-) diff --git a/src/Cli/Command/Issue/Show.php b/src/Cli/Command/Issue/Show.php index 033d51b..7f1d41d 100644 --- a/src/Cli/Command/Issue/Show.php +++ b/src/Cli/Command/Issue/Show.php @@ -7,6 +7,8 @@ use mglaman\DrupalOrg\GitLab\Client as GitLabClient; use mglaman\DrupalOrg\GitLab\WorkItemRef; use mglaman\DrupalOrg\IssueTrait; +use mglaman\DrupalOrg\MigratedIssueException; +use mglaman\DrupalOrg\Result\Issue\IssueResult; use mglaman\DrupalOrgCli\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -21,7 +23,7 @@ protected function configure(): void { $this ->setName('issue:show') - ->addArgument('nid', InputArgument::REQUIRED, 'The issue node ID or a GitLab work item URL') + ->addArgument('nid', InputArgument::REQUIRED, 'The issue node ID, project#nid, or GitLab work item URL. A migrated issue is followed to its work item.') ->addOption( 'format', 'f', @@ -36,46 +38,57 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { - $nid = $this->stdIn->getArgument('nid'); - $format = $this->stdIn->getOption('format'); - + $nid = (string) $this->stdIn->getArgument('nid'); + $format = (string) $this->stdIn->getOption('format'); $withComments = (bool) $this->stdIn->getOption('with-comments'); - $ref = WorkItemRef::tryParse((string) $nid); - if ($ref !== null) { - $includeBotComments = (bool) $this->stdIn->getOption('include-bot-comments'); - $result = (new GetGitLabIssueAction(new GitLabClient()))($ref, $withComments, $includeBotComments); - if ($this->writeFormatted($result, (string) $format)) { - return 0; - } - $issue = $result->issue; - $this->stdOut->writeln(sprintf('Title: %s', $issue->title)); - $this->stdOut->writeln(sprintf('State: %s', $issue->state)); - $this->stdOut->writeln(sprintf('Author: %s', $issue->author)); - if ($issue->assignees !== []) { - $this->stdOut->writeln(sprintf('Assignees: %s', implode(', ', $issue->assignees))); - } - if ($issue->labels !== []) { - $this->stdOut->writeln(sprintf('Labels: %s', implode(', ', $issue->labels))); - } - $this->stdOut->writeln(sprintf('Created: %s', $issue->createdAt)); - $this->stdOut->writeln(sprintf('Updated: %s', $issue->updatedAt)); - $this->stdOut->writeln(sprintf('URL: %s', $issue->webUrl)); - $this->stdOut->writeln(sprintf("\nDescription:\n%s", $issue->description)); - foreach ($result->comments as $index => $comment) { - $this->stdOut->writeln(sprintf( - "\nComment #%d by %s (%s):\n%s", - $index + 1, - $comment->author, - $comment->createdAt, - $comment->body - )); + + $ref = WorkItemRef::tryParse($nid); + if ($ref === null) { + try { + return $this->showIssue((new GetIssueAction($this->client))($nid, $withComments), $format); + } catch (MigratedIssueException $e) { + $ref = $e->ref; } - return 0; } + return $this->showWorkItem($ref, $withComments, $format); + } - $result = (new GetIssueAction($this->client))($nid, $withComments); + private function showWorkItem(WorkItemRef $ref, bool $withComments, string $format): int + { + $includeBotComments = (bool) $this->stdIn->getOption('include-bot-comments'); + $result = (new GetGitLabIssueAction(new GitLabClient()))($ref, $withComments, $includeBotComments); + if ($this->writeFormatted($result, $format)) { + return 0; + } + $issue = $result->issue; + $this->stdOut->writeln(sprintf('Title: %s', $issue->title)); + $this->stdOut->writeln(sprintf('State: %s', $issue->state)); + $this->stdOut->writeln(sprintf('Author: %s', $issue->author)); + if ($issue->assignees !== []) { + $this->stdOut->writeln(sprintf('Assignees: %s', implode(', ', $issue->assignees))); + } + if ($issue->labels !== []) { + $this->stdOut->writeln(sprintf('Labels: %s', implode(', ', $issue->labels))); + } + $this->stdOut->writeln(sprintf('Created: %s', $issue->createdAt)); + $this->stdOut->writeln(sprintf('Updated: %s', $issue->updatedAt)); + $this->stdOut->writeln(sprintf('URL: %s', $issue->webUrl)); + $this->stdOut->writeln(sprintf("\nDescription:\n%s", $issue->description)); + foreach ($result->comments as $index => $comment) { + $this->stdOut->writeln(sprintf( + "\nComment #%d by %s (%s):\n%s", + $index + 1, + $comment->author, + $comment->createdAt, + $comment->body + )); + } + return 0; + } - if ($this->writeFormatted($result, (string) $format)) { + private function showIssue(IssueResult $result, string $format): int + { + if ($this->writeFormatted($result, $format)) { return 0; } $this->stdOut->writeln(sprintf('Title: %s', $result->title)); From 890ae4b01039acc1242dd83fdc679aac351505fb Mon Sep 17 00:00:00 2001 From: Matt Glaman Date: Wed, 16 Sep 2026 10:15:01 -0500 Subject: [PATCH 7/7] fix: name the work item project when a migrated issue collides with the repository The generic collision message offered a Drupal.org issue that no longer exists. A migrated issue in another project now says where the work item lives and asks for a clone of that project. Co-Authored-By: Claude Fable 5.1 --- src/Api/IssueProjectResolver.php | 14 +++++++++++++- tests/src/IssueProjectResolverTest.php | 5 ++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/Api/IssueProjectResolver.php b/src/Api/IssueProjectResolver.php index ac312db..d3facf7 100644 --- a/src/Api/IssueProjectResolver.php +++ b/src/Api/IssueProjectResolver.php @@ -60,7 +60,19 @@ public function resolve(string $nid, ?string $explicitProject = null, ?string $r private function resolveAgainstRepository(string $nid, string $repositoryProject): string { try { - $nodeProject = $this->nodeProject($nid); + $nodeProject = $this->client->getNode($nid)->fieldProjectMachineName; + } catch (MigratedIssueException $e) { + $workItemProject = $e->ref->projectMachineName(); + if ($workItemProject === $repositoryProject) { + return $repositoryProject; + } + throw new \RuntimeException(sprintf( + 'Issue %1$s is a GitLab work item in project "%2$s", but this repository is project "%3$s". ' + . 'Run this in a clone of %2$s.', + $nid, + $workItemProject, + $repositoryProject + ), 0, $e); } catch (\RuntimeException) { // Not a Drupal.org issue node, so the repository is the only // source for the project. diff --git a/tests/src/IssueProjectResolverTest.php b/tests/src/IssueProjectResolverTest.php index c76d231..dae146b 100644 --- a/tests/src/IssueProjectResolverTest.php +++ b/tests/src/IssueProjectResolverTest.php @@ -141,7 +141,10 @@ public function testMigratedIssueInAnotherRepositoryFails(): void $resolver = new IssueProjectResolver($client); $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Issue 3617735 belongs to project "restrict_route_by_ip" on Drupal.org'); + $this->expectExceptionMessage( + 'Issue 3617735 is a GitLab work item in project "restrict_route_by_ip", but this repository is project "campaign". ' + . 'Run this in a clone of restrict_route_by_ip.' + ); $resolver->resolve('3617735', null, 'campaign'); }