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
11 changes: 11 additions & 0 deletions skill-data/drupalorg-cli/references/gitlab-mr-contribution.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ drupalorg issue:setup-remote <nid>
This is idempotent: if the remote already exists it skips the `git remote add`
step and always runs `git fetch` to update remote refs.

The project that owns the fork is resolved in this order:

1. An explicit qualifier (`project#<nid>` or a work-item URL). No Drupal.org
request is made.
2. The `project/<name>` remote of the current repository, checked against the
Drupal.org node when one exists. A mismatch is an error.
3. The Drupal.org node lookup.

GitLab work-item ids on migrated projects can collide with unrelated Drupal.org
node ids, so pass `project#<nid>` for work items whenever you know the project.

### 3. Check out an issue branch

```bash
Expand Down
11 changes: 11 additions & 0 deletions skills/drupalorg-cli/references/gitlab-mr-contribution.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ drupalorg issue:setup-remote <nid>
This is idempotent: if the remote already exists it skips the `git remote add`
step and always runs `git fetch` to update remote refs.

The project that owns the fork is resolved in this order:

1. An explicit qualifier (`project#<nid>` or a work-item URL). No Drupal.org
request is made.
2. The `project/<name>` remote of the current repository, checked against the
Drupal.org node when one exists. A mismatch is an error.
3. The Drupal.org node lookup.

GitLab work-item ids on migrated projects can collide with unrelated Drupal.org
node ids, so pass `project#<nid>` for work items whenever you know the project.

### 3. Check out an issue branch

```bash
Expand Down
22 changes: 16 additions & 6 deletions src/Api/Action/Issue/GetIssueForkAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use mglaman\DrupalOrg\Action\ActionInterface;
use mglaman\DrupalOrg\Client;
use mglaman\DrupalOrg\GitLab\Client as GitLabClient;
use mglaman\DrupalOrg\IssueProjectResolver;
use mglaman\DrupalOrg\Result\Issue\IssueForkResult;

class GetIssueForkAction implements ActionInterface
Expand All @@ -15,12 +16,21 @@ public function __construct(
) {
}

public function __invoke(string $nid, ?string $projectMachineName = null): IssueForkResult
{
if ($projectMachineName === null) {
$issue = $this->client->getNode($nid);
$projectMachineName = $issue->fieldProjectMachineName;
}
/**
* @param string|null $projectMachineName
* Project from an explicit qualifier; skips the Drupal.org lookup.
* @param string|null $repositoryProject
* Project of the git repository the command runs in.
*
* @see IssueProjectResolver for the resolution order.
*/
public function __invoke(
string $nid,
?string $projectMachineName = null,
?string $repositoryProject = null,
): IssueForkResult {
$projectMachineName = (new IssueProjectResolver($this->client))
->resolve($nid, $projectMachineName, $repositoryProject);
$remoteName = $projectMachineName . '-' . $nid;
$gitLabProjectPath = 'issue/' . $remoteName;

Expand Down
15 changes: 12 additions & 3 deletions src/Api/Action/Issue/SetupIssueRemoteAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,19 @@ public function __construct(
) {
}

public function __invoke(string $nid): SetupIssueRemoteResult
{
/**
* @param string|null $projectMachineName
* Project from an explicit qualifier; skips the Drupal.org lookup.
* @param string|null $repositoryProject
* Project of the git repository the remote is added to.
*/
public function __invoke(
string $nid,
?string $projectMachineName = null,
?string $repositoryProject = null,
): SetupIssueRemoteResult {
$getFork = new GetIssueForkAction($this->client, $this->gitLabClient);
$fork = $getFork($nid);
$fork = $getFork($nid, $projectMachineName, $repositoryProject);

$remoteName = $fork->remoteName;
$sshUrl = $fork->sshUrl;
Expand Down
11 changes: 11 additions & 0 deletions src/Api/GitLab/WorkItemRef.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@ public function __construct(
) {
}

/**
* The Drupal.org project machine name, e.g. "campaign" for "project/campaign".
*/
public function projectMachineName(): string
{
if (str_starts_with($this->projectPath, 'project/')) {
return substr($this->projectPath, strlen('project/'));
}
return basename($this->projectPath);
}

public static function tryParse(string $input): ?self
{
$input = trim($input);
Expand Down
86 changes: 86 additions & 0 deletions src/Api/IssueProjectResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

declare(strict_types=1);

namespace mglaman\DrupalOrg;

/**
* Resolves the project machine name that owns an issue id.
*
* GitLab work-item ids on migrated projects share the number space with
* Drupal.org node ids, so a bare id can resolve to an unrelated node. The
* caller's own knowledge therefore wins over the Drupal.org lookup:
*
* 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.
*/
final class IssueProjectResolver
{
public function __construct(private readonly Client $client)
{
}

/**
* @throws \RuntimeException
* When the project cannot be resolved, or when Drupal.org and the
* repository disagree about which project owns the id.
*/
public function resolve(string $nid, ?string $explicitProject = null, ?string $repositoryProject = null): string
{
if ($explicitProject !== null && $explicitProject !== '') {
return $explicitProject;
}

if ($repositoryProject !== null && $repositoryProject !== '') {
return $this->resolveAgainstRepository($nid, $repositoryProject);
}

try {
$nodeProject = $this->client->getNode($nid)->fieldProjectMachineName;
} catch (\RuntimeException $e) {
throw new \RuntimeException(
sprintf('%s %s', $e->getMessage(), self::qualifierHint($nid)),
0,
$e
);
}
if ($nodeProject === '') {
throw new \RuntimeException(sprintf(
'Could not resolve a project for issue %s. %s',
$nid,
self::qualifierHint($nid)
));
}
return $nodeProject;
}

private function resolveAgainstRepository(string $nid, string $repositoryProject): string
{
try {
$nodeProject = $this->client->getNode($nid)->fieldProjectMachineName;
} catch (\RuntimeException) {
// Not a Drupal.org issue node (for example a migrated work item),
// so the repository is the only source for the project.
return $repositoryProject;
}

if ($nodeProject === '' || $nodeProject === $repositoryProject) {
return $repositoryProject;
}

throw new \RuntimeException(sprintf(
'Issue %1$s belongs to project "%2$s" on Drupal.org, but this repository is project "%3$s". '
. 'Pass %3$s#%1$s for the GitLab work item or %2$s#%1$s for the Drupal.org issue.',
$nid,
$nodeProject,
$repositoryProject
));
}

private static function qualifierHint(string $nid): string
{
return sprintf('Pass the project explicitly as project#%s.', $nid);
}
}
54 changes: 54 additions & 0 deletions src/Api/ProjectRemote.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

namespace mglaman\DrupalOrg;

use Symfony\Component\Process\Process;

/**
* The Drupal.org project a git remote URL points at.
*/
Expand All @@ -27,4 +29,56 @@ public static function tryParse(string $remoteUrl): ?self
}
return new self($matches['name']);
}

/**
* Picks the project remote from a repository's remotes. "origin" wins
* when it matches; issue forks and personal forks never match.
*
* @param array<string, string> $remoteUrls
* Fetch URLs keyed by remote name.
*/
public static function fromRemotes(array $remoteUrls): ?self
{
if (isset($remoteUrls['origin'])) {
$fromOrigin = self::tryParse($remoteUrls['origin']);
if ($fromOrigin !== null) {
return $fromOrigin;
}
}
foreach ($remoteUrls as $url) {
$remote = self::tryParse($url);
if ($remote !== null) {
return $remote;
}
}
return null;
}

/**
* Detects the project from the remotes of the repository at $cwd.
* Returns null outside a git repository or when no remote matches.
*/
public static function detect(?string $cwd = null): ?self
{
$process = new Process(['git', 'remote', '-v'], $cwd);
$process->run();
if (!$process->isSuccessful()) {
return null;
}
return self::fromRemotes(self::parseRemoteList($process->getOutput()));
}

/**
* @return array<string, string>
*/
private static function parseRemoteList(string $output): array
{
$remoteUrls = [];
foreach (explode("\n", $output) as $line) {
if (preg_match('/^(\S+)\s+(\S+)\s+\(fetch\)$/', trim($line), $matches) === 1) {
$remoteUrls[$matches[1]] = $matches[2];
}
}
return $remoteUrls;
}
}
9 changes: 6 additions & 3 deletions src/Cli/Command/Issue/Checkout.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use mglaman\DrupalOrg\Action\Issue\GetIssueForkAction;
use mglaman\DrupalOrg\Action\Issue\SetupIssueRemoteAction;
use mglaman\DrupalOrg\ProjectRemote;
use mglaman\DrupalOrg\GitLab\Client as GitLabClient;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
Expand All @@ -25,8 +26,10 @@ protected function configure(): void
protected function execute(InputInterface $input, OutputInterface $output): int
{
$gitLabClient = new GitLabClient();
$explicitProject = $this->explicitProjectMachineName();
$repositoryProject = ProjectRemote::detect()?->machineName;
$action = new GetIssueForkAction($this->client, $gitLabClient);
$fork = $action($this->nid);
$fork = $action($this->nid, $explicitProject, $repositoryProject);

// Verify the remote exists locally; offer to set it up if missing.
$checkRemote = new Process(['git', 'remote', 'get-url', $fork->remoteName]);
Expand Down Expand Up @@ -58,7 +61,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
}
try {
$setupAction = new SetupIssueRemoteAction($this->client, $gitLabClient);
$setupResult = $setupAction($this->nid);
$setupResult = $setupAction($this->nid, $explicitProject, $repositoryProject);
} catch (\RuntimeException $e) {
$this->stdErr->writeln(
sprintf('<error>Failed to set up remote: %s</error>', $e->getMessage())
Expand All @@ -69,7 +72,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
sprintf('<info>Remote %s added and fetched.</info>', $setupResult->remoteName)
);
// Refresh fork data after setup so branches are populated.
$fork = $action($this->nid);
$fork = $action($this->nid, $explicitProject, $repositoryProject);
} else {
// Remote already exists; fetch to ensure tracking refs are up-to-date.
$fetchProcess = new Process(['git', 'fetch', $fork->remoteName]);
Expand Down
5 changes: 1 addition & 4 deletions src/Cli/Command/Issue/GetFork.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,7 @@ protected function configure(): void
protected function execute(InputInterface $input, OutputInterface $output): int
{
$action = new GetIssueForkAction($this->client, new GitLabClient());
$machineName = $this->workItemRef !== null
? substr($this->workItemRef->projectPath, strlen('project/'))
: null;
$result = $action($this->nid, $machineName);
$result = $action($this->nid, $this->explicitProjectMachineName());
$format = (string) $this->stdIn->getOption('format');

if ($this->writeFormatted($result, $format)) {
Expand Down
8 changes: 8 additions & 0 deletions src/Cli/Command/Issue/IssueCommandBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,14 @@ protected function initialize(
}
}

/**
* The project named by the nid argument's qualifier, if one was given.
*/
protected function explicitProjectMachineName(): ?string
{
return $this->workItemRef?->projectMachineName();
}

/**
* Initializes repository for current directory.
*/
Expand Down
3 changes: 2 additions & 1 deletion src/Cli/Command/Issue/SetupRemote.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\SetupIssueRemoteAction;
use mglaman\DrupalOrg\ProjectRemote;
use mglaman\DrupalOrg\GitLab\Client as GitLabClient;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
Expand All @@ -21,7 +22,7 @@ protected function configure(): void
protected function execute(InputInterface $input, OutputInterface $output): int
{
$action = new SetupIssueRemoteAction($this->client, new GitLabClient());
$result = $action($this->nid);
$result = $action($this->nid, $this->explicitProjectMachineName(), ProjectRemote::detect()?->machineName);

if ($result->alreadyExists) {
$this->stdOut->writeln(
Expand Down
32 changes: 32 additions & 0 deletions tests/src/Action/Issue/GetIssueForkActionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,38 @@ public function testForkWithBranches(): void
self::assertSame(['3383637-test-issue', 'main'], $result->branches);
}

public function testExplicitProjectSkipsNodeLookup(): 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 GetIssueForkAction($client, $gitLabClient);
$result = $action('3615648', 'campaign');

self::assertSame('campaign-3615648', $result->remoteName);
self::assertSame('git@git.drupal.org:issue/campaign-3615648.git', $result->sshUrl);
self::assertSame('issue/campaign-3615648', $result->gitLabProjectPath);
}

public function testRepositoryProjectForWorkItem(): void
{
$client = $this->createMock(Client::class);
$client->method('getNode')
->willThrowException(new \RuntimeException('Node 3615635 was not found on Drupal.org.'));

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

$action = new GetIssueForkAction($client, $gitLabClient);
$result = $action('3615635', null, 'campaign');

self::assertSame('campaign-3615635', $result->remoteName);
self::assertSame('issue/campaign-3615635', $result->gitLabProjectPath);
}

public function testForkNotYetCreated(): void
{
$client = $this->createMock(Client::class);
Expand Down
Loading