Skip to content
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions skill-data/drupalorg-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,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 <nid>

# Generate a patch from committed (but not yet pushed) changes
Expand Down Expand Up @@ -269,6 +271,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 <nid>` first |
Expand Down
33 changes: 28 additions & 5 deletions src/Api/Action/Issue/GetIssueBranchNameAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
8 changes: 8 additions & 0 deletions src/Api/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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') {
Expand Down
14 changes: 5 additions & 9 deletions src/Api/Entity/IssueNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace mglaman\DrupalOrg\Entity;

use mglaman\DrupalOrg\IssueBranchNaming;

class IssueNode
{
/**
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
49 changes: 49 additions & 0 deletions src/Api/IssueBranchNaming.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

namespace mglaman\DrupalOrg;

/**
* Branch naming rules shared by Drupal.org issues and GitLab work items.
*/
final class IssueBranchNaming
{
/**
* Lowercase, underscore-separated, at most 20 characters of the title.
*/
public static function slug(string $title): string
{
$slug = (string) preg_replace('/[^a-zA-Z0-9]+/', '_', $title);
$slug = strtolower(substr($slug, 0, 20));
return (string) preg_replace('/(^_|_$)/', '', $slug);
}

/**
* The development branch for an issue version such as "2.0.x-dev",
* "2.0.0-beta2" or "8.x-1.x-dev".
*/
public static function versionBranch(string $version): string
{
if (preg_match('/^(\d+\.\d+)\./', $version, $matches)) {
return $matches[1] . '.x';
}
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;
}
}
33 changes: 29 additions & 4 deletions src/Api/IssueProjectResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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)),
Expand All @@ -60,9 +61,21 @@ private function resolveAgainstRepository(string $nid, string $repositoryProject
{
try {
$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 (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;
}

Expand All @@ -79,6 +92,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);
Expand Down
2 changes: 1 addition & 1 deletion src/Api/Mcp/ToolRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.')]
Expand Down
30 changes: 30 additions & 0 deletions src/Api/MigratedIssueException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace mglaman\DrupalOrg;

use mglaman\DrupalOrg\GitLab\WorkItemRef;

/**
* The Drupal.org node moved to a GitLab work item.
*
* Drupal.org answers such node IDs with a stub whose only field is new_url.
* Callers that support work items catch this and continue with the ref.
*/
final class MigratedIssueException extends \RuntimeException
{
public function __construct(
public readonly string $nid,
public readonly WorkItemRef $ref,
string $newUrl,
) {
parent::__construct(sprintf(
'Issue %s moved to a GitLab work item at %s. Pass %s#%s.',
$nid,
$newUrl,
$ref->projectMachineName(),
$nid
));
}
}
10 changes: 10 additions & 0 deletions src/Api/Result/Issue/IssueBranchResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 [
Expand Down
7 changes: 4 additions & 3 deletions src/Cli/Command/Issue/Branch.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(
Expand All @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions src/Cli/Command/Issue/IssueCommandBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ protected function initialize(
if ($ref !== null) {
$this->workItemRef = $ref;
$this->nid = (string) $ref->issueId;
if ($this->requiresRepository) {
$this->initRepo();
}
return;
}

Expand Down
Loading