Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions skill-data/drupalorg-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,13 @@ drupalorg issue:show <ref> --with-comments --include-bot-comments --format=llm

# Show the GitLab issue fork URLs and branches
# nid is optional; auto-detected from the branch name if omitted
# <exists>false</exists> means nobody has clicked "Create issue fork" yet;
# an empty <branches> list with <exists>true</exists> means the fork has no pushes
drupalorg issue:get-fork [nid] --format=llm

# Add the GitLab issue fork as a git remote and fetch it
# nid is optional; auto-detected from the branch name if omitted
# Fails without adding a remote when the fork does not exist yet
drupalorg issue:setup-remote [nid]

# Check out a branch from the GitLab issue fork
Expand Down Expand Up @@ -270,6 +273,7 @@ drupalorg mr:list [nid] --format=llm --no-cache
| `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 |
| `Remote … does not exist` | `issue:checkout` run before `issue:setup-remote` | Run `issue:setup-remote <nid>` first |
| `No issue fork for … yet` | `issue:setup-remote` or `issue:checkout` run before the fork was created on GitLab | Create the fork from the issue page (or `issue:fork <ref>` on work item projects), then retry |
| `429 / 503` | Drupal.org rate limit or maintenance | The client retries automatically; wait and retry if it persists |

## References
Expand Down
8 changes: 5 additions & 3 deletions skill-data/drupalorg-work-on-issue/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,11 @@ asking the user if `CLAUDE.md` provides no guidance.
- **No matches** → note that no branches exist yet and ask the user how to proceed
(e.g. create a new branch from the upstream project default branch).

**No fork at all (GitLab work item projects):** If `issue:get-fork` reports no fork
exists AND the project uses GitLab work items (the ref is a `project_name#nid` or
work item URL, not a classic Drupal.org NID), offer to create one:
**No fork at all:** `issue:get-fork` prints `<exists>false</exists>` when nobody has
created the fork yet. `issue:setup-remote` and `issue:checkout` refuse to run in that
state. For a classic Drupal.org issue, ask the user to click "Create issue fork" on
the issue page. If the project uses GitLab work items (the ref is a `project_name#nid`
or work item URL, not a classic Drupal.org NID), offer to create one:

```bash
drupalorg issue:fork <ref>
Expand Down
22 changes: 15 additions & 7 deletions src/Api/Action/Issue/GetIssueForkAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,23 +37,31 @@ public function __invoke(
$sshUrl = 'git@git.drupal.org:' . $gitLabProjectPath . '.git';
$httpsUrl = 'https://git.drupalcode.org/' . $gitLabProjectPath . '.git';

$branches = [];
try {
$project = $this->gitLabClient->getProject($gitLabProjectPath);
$branchObjects = $this->gitLabClient->getBranches((int) $project->id);
$branches = array_map(
static fn(\stdClass $b) => (string) $b->name,
$branchObjects
);
} catch (\Exception $e) {
// Fork may not exist yet; return URL info without branches.
// GitLab answers 404 until someone clicks "Create issue fork".
$project = null;
}

$branches = [];
if ($project !== null) {
try {
$branches = array_map(
static fn(\stdClass $b) => (string) $b->name,
$this->gitLabClient->getBranches((int) $project->id)
);
} catch (\Exception $e) {
// The fork exists; a failed branch listing must not report it missing.
}
}

return new IssueForkResult(
remoteName: $remoteName,
sshUrl: $sshUrl,
httpsUrl: $httpsUrl,
gitLabProjectPath: $gitLabProjectPath,
exists: $project !== null,
branches: $branches,
);
}
Expand Down
6 changes: 6 additions & 0 deletions src/Api/Action/Issue/SetupIssueRemoteAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ public function __invoke(
): SetupIssueRemoteResult {
$getFork = new GetIssueForkAction($this->client, $this->gitLabClient);
$fork = $getFork($nid, $projectMachineName, $repositoryProject);
if (!$fork->exists) {
throw new \RuntimeException(sprintf(
'No issue fork for %s yet. Create it on the issue page and click "Get push access", then run this again.',
$nid
));
}

$remoteName = $fork->remoteName;
$sshUrl = $fork->sshUrl;
Expand Down
5 changes: 5 additions & 0 deletions src/Api/Result/Issue/IssueForkResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@
class IssueForkResult implements ResultInterface
{
/**
* @param bool $exists
* Whether the fork project exists on GitLab. An empty branch list alone
* cannot tell a missing fork from a fork nobody has pushed to.
* @param string[] $branches
*/
public function __construct(
public readonly string $remoteName,
public readonly string $sshUrl,
public readonly string $httpsUrl,
public readonly string $gitLabProjectPath,
public readonly bool $exists,
public readonly array $branches,
) {
}
Expand All @@ -25,6 +29,7 @@ public function jsonSerialize(): mixed
'ssh_url' => $this->sshUrl,
'https_url' => $this->httpsUrl,
'gitlab_project_path' => $this->gitLabProjectPath,
'exists' => $this->exists,
'branches' => $this->branches,
];
}
Expand Down
7 changes: 7 additions & 0 deletions src/Cli/Command/Issue/Checkout.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$repositoryProject = ProjectRemote::detect()?->machineName;
$action = new GetIssueForkAction($this->client, $gitLabClient);
$fork = $action($this->nid, $explicitProject, $repositoryProject);
if (!$fork->exists) {
$this->stdErr->writeln(sprintf(
'<error>No issue fork for %s yet. Create it on the issue page and click "Get push access".</error>',
$this->nid
));
return 1;
}

// Verify the remote exists locally; offer to set it up if missing.
$checkRemote = new Process(['git', 'remote', 'get-url', $fork->remoteName]);
Expand Down
8 changes: 7 additions & 1 deletion src/Cli/Command/Issue/GetFork.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$this->stdOut->writeln(sprintf('HTTPS URL: %s', $result->httpsUrl));
$this->stdOut->writeln(sprintf('GitLab path: %s', $result->gitLabProjectPath));

if (!$result->exists) {
$this->stdOut->writeln('');
$this->stdOut->writeln('Fork does not exist yet. Create it on the issue page and click "Get push access".');
return 0;
}

if ($result->branches !== []) {
$this->stdOut->writeln('');
$this->stdOut->writeln('Branches:');
foreach ($result->branches as $branch) {
$this->stdOut->writeln(' ' . $branch);
}
} else {
$this->stdOut->writeln('No branches found (fork may not exist yet).');
$this->stdOut->writeln('Fork exists but has no branches yet.');
}

return 0;
Expand Down
2 changes: 2 additions & 0 deletions src/Cli/Formatter/LlmFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ protected function formatIssueFork(IssueForkResult $result): string
$sshUrl = $this->xmlEscape($result->sshUrl);
$httpsUrl = $this->xmlEscape($result->httpsUrl);
$gitLabPath = $this->xmlEscape($result->gitLabProjectPath);
$exists = $result->exists ? 'true' : 'false';

$branchItems = '';
foreach ($result->branches as $branch) {
Expand All @@ -159,6 +160,7 @@ protected function formatIssueFork(IssueForkResult $result): string
<ssh_url>{$sshUrl}</ssh_url>
<https_url>{$httpsUrl}</https_url>
<gitlab_project_path>{$gitLabPath}</gitlab_project_path>
<exists>{$exists}</exists>
<branches>
{$branchItems} </branches>
</drupal_context>
Expand Down
1 change: 1 addition & 0 deletions src/Cli/Formatter/MarkdownFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ protected function formatIssueFork(IssueForkResult $result): string
$lines[] = "- **SSH URL:** {$result->sshUrl}";
$lines[] = "- **HTTPS URL:** {$result->httpsUrl}";
$lines[] = "- **GitLab path:** {$result->gitLabProjectPath}";
$lines[] = '- **Exists:** ' . ($result->exists ? 'yes' : 'no');
if ($result->branches !== []) {
$lines[] = '';
$lines[] = '## Branches';
Expand Down
38 changes: 38 additions & 0 deletions tests/src/Action/Issue/GetIssueForkActionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,46 @@ public function testForkWithBranches(): void
self::assertSame('git@git.drupal.org:issue/drupal-3383637.git', $result->sshUrl);
self::assertSame('https://git.drupalcode.org/issue/drupal-3383637.git', $result->httpsUrl);
self::assertSame('issue/drupal-3383637', $result->gitLabProjectPath);
self::assertTrue($result->exists);
self::assertSame(['3383637-test-issue', 'main'], $result->branches);
}

public function testForkWithoutBranchesStillExists(): void
{
$project = new \stdClass();
$project->id = 12345;

$client = $this->createMock(Client::class);
$client->method('getNode')->willReturn(self::makeIssueNode());

$gitLabClient = $this->createMock(GitLabClient::class);
$gitLabClient->method('getProject')->willReturn($project);
$gitLabClient->method('getBranches')->willReturn([]);

$result = (new GetIssueForkAction($client, $gitLabClient))('3383637');

self::assertTrue($result->exists);
self::assertSame([], $result->branches);
}

public function testBranchListingFailureDoesNotReportMissingFork(): void
{
$project = new \stdClass();
$project->id = 12345;

$client = $this->createMock(Client::class);
$client->method('getNode')->willReturn(self::makeIssueNode());

$gitLabClient = $this->createMock(GitLabClient::class);
$gitLabClient->method('getProject')->willReturn($project);
$gitLabClient->method('getBranches')->willThrowException(new \Exception('Service Unavailable', 503));

$result = (new GetIssueForkAction($client, $gitLabClient))('3383637');

self::assertTrue($result->exists);
self::assertSame([], $result->branches);
}

public function testExplicitProjectSkipsNodeLookup(): void
{
$client = $this->createMock(Client::class);
Expand Down Expand Up @@ -111,6 +148,7 @@ public function testForkNotYetCreated(): void
self::assertSame('drupal-3383637', $result->remoteName);
self::assertSame('git@git.drupal.org:issue/drupal-3383637.git', $result->sshUrl);
self::assertSame('issue/drupal-3383637', $result->gitLabProjectPath);
self::assertFalse($result->exists);
self::assertSame([], $result->branches);
}
}
28 changes: 28 additions & 0 deletions tests/src/Action/Issue/SetupIssueRemoteActionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

namespace mglaman\DrupalOrg\Tests\Action\Issue;

use mglaman\DrupalOrg\Action\Issue\SetupIssueRemoteAction;
use mglaman\DrupalOrg\Client;
use mglaman\DrupalOrg\GitLab\Client as GitLabClient;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;

#[CoversClass(SetupIssueRemoteAction::class)]
class SetupIssueRemoteActionTest extends TestCase
{
public function testMissingForkStopsBeforeTouchingGit(): void
{
$client = $this->createMock(Client::class);
$client->expects(self::never())->method('getNode');

$gitLabClient = $this->createMock(GitLabClient::class);
$gitLabClient->method('getProject')->willThrowException(new \Exception('Not Found', 404));

$action = new SetupIssueRemoteAction($client, $gitLabClient);

$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('No issue fork for 3007808 yet.');
$action('3007808', 'poll');
}
}
2 changes: 2 additions & 0 deletions tests/src/Formatter/LlmFormatterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ public function testIssueForkResult(): void
sshUrl: 'git@git.drupal.org:issue/drupal-3383637.git',
httpsUrl: 'https://git.drupalcode.org/issue/drupal-3383637.git',
gitLabProjectPath: 'issue/drupal-3383637',
exists: true,
branches: ['3383637-fix-the-thing', 'main'],
);

Expand All @@ -259,6 +260,7 @@ public function testIssueForkResult(): void
self::assertStringContainsString('<ssh_url>git@git.drupal.org:issue/drupal-3383637.git</ssh_url>', $output);
self::assertStringContainsString('<https_url>https://git.drupalcode.org/issue/drupal-3383637.git</https_url>', $output);
self::assertStringContainsString('<gitlab_project_path>issue/drupal-3383637</gitlab_project_path>', $output);
self::assertStringContainsString('<exists>true</exists>', $output);
self::assertStringContainsString('<branch>3383637-fix-the-thing</branch>', $output);
self::assertStringContainsString('<branch>main</branch>', $output);
}
Expand Down
2 changes: 2 additions & 0 deletions tests/src/Formatter/MarkdownFormatterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ public function testIssueForkResult(): void
sshUrl: 'git@git.drupal.org:issue/drupal-3383637.git',
httpsUrl: 'https://git.drupalcode.org/issue/drupal-3383637.git',
gitLabProjectPath: 'issue/drupal-3383637',
exists: true,
branches: ['3383637-fix-the-thing', 'main'],
);

Expand All @@ -223,6 +224,7 @@ public function testIssueForkResult(): void
self::assertStringContainsString('**SSH URL:** git@git.drupal.org:issue/drupal-3383637.git', $output);
self::assertStringContainsString('**HTTPS URL:** https://git.drupalcode.org/issue/drupal-3383637.git', $output);
self::assertStringContainsString('**GitLab path:** issue/drupal-3383637', $output);
self::assertStringContainsString('**Exists:** yes', $output);
self::assertStringContainsString('## Branches', $output);
self::assertStringContainsString('- 3383637-fix-the-thing', $output);
self::assertStringContainsString('- main', $output);
Expand Down