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
13 changes: 13 additions & 0 deletions skills/drupalorg-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,26 +76,39 @@ drupalorg issue:link <nid>

### Merge Request commands

The first argument can be a Drupal.org issue NID, a `project-path!iid` ref
(e.g. `project/drupal!708`), or a full GitLab MR URL. When using a ref that
includes the MR IID (`!iid`), the second `<mr-iid>` argument is not needed.

> **zsh users:** Escape `!` or quote the argument to prevent history expansion:
> `project/drupal\!708` or `'project/drupal!708'`

```bash
# List merge requests for a Drupal.org issue fork
# --state: opened (default), closed, merged, all
# nid is optional; auto-detected from the branch name if omitted
drupalorg mr:list [nid] [--state=opened] --format=llm
# List MRs by project path (no issue NID needed)
drupalorg mr:list project/drupal --format=llm

# Show the unified diff for a merge request
# Supports --format=text (default), json
drupalorg mr:diff <nid> <mr-iid>
drupalorg mr:diff 'project/drupal!708'

# List changed files in a merge request
# Supports --format=text (default), json
drupalorg mr:files <nid> <mr-iid>
drupalorg mr:files 'project/drupal!708'

# Show the pipeline status for a merge request
drupalorg mr:status <nid> <mr-iid> --format=llm
drupalorg mr:status 'project/drupal!708' --format=llm

# Show failed job traces from the latest pipeline for a merge request
# Supports --format=text (default), json
drupalorg mr:logs <nid> <mr-iid>
drupalorg mr:logs 'project/drupal!708'
```

### Project commands
Expand Down
10 changes: 10 additions & 0 deletions src/Api/Action/MergeRequest/AbstractMergeRequestAction.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\GitLab\MergeRequestRef;

abstract class AbstractMergeRequestAction implements ActionInterface
{
Expand All @@ -24,4 +25,13 @@ protected function resolveGitLabProject(string $nid): array
$project = $this->gitLabClient->getProject($projectPath);
return [(int) $project->id, $projectPath];
}

/**
* @return array{0: int, 1: string}
*/
protected function resolveFromRef(MergeRequestRef $ref): array
{
$project = $this->gitLabClient->getProject($ref->projectPath);
return [(int) $project->id, $ref->projectPath];
}
}
5 changes: 3 additions & 2 deletions src/Api/Action/MergeRequest/GetMergeRequestDiffAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@

namespace mglaman\DrupalOrg\Action\MergeRequest;

use mglaman\DrupalOrg\GitLab\MergeRequestRef;
use mglaman\DrupalOrg\Result\MergeRequest\MergeRequestDiffResult;

class GetMergeRequestDiffAction extends AbstractMergeRequestAction
{
public function __invoke(string $nid, int $mrIid): MergeRequestDiffResult
public function __invoke(string $nid, int $mrIid, ?MergeRequestRef $ref = null): MergeRequestDiffResult
{
[$projectId] = $this->resolveGitLabProject($nid);
[$projectId] = $ref !== null ? $this->resolveFromRef($ref) : $this->resolveGitLabProject($nid);

$mr = $this->gitLabClient->getMergeRequest($projectId, $mrIid);
$diffs = $this->gitLabClient->getMergeRequestDiffs($projectId, $mrIid);
Expand Down
5 changes: 3 additions & 2 deletions src/Api/Action/MergeRequest/GetMergeRequestFilesAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@

namespace mglaman\DrupalOrg\Action\MergeRequest;

use mglaman\DrupalOrg\GitLab\MergeRequestRef;
use mglaman\DrupalOrg\Result\MergeRequest\MergeRequestFilesResult;

class GetMergeRequestFilesAction extends AbstractMergeRequestAction
{
public function __invoke(string $nid, int $mrIid): MergeRequestFilesResult
public function __invoke(string $nid, int $mrIid, ?MergeRequestRef $ref = null): MergeRequestFilesResult
{
[$projectId] = $this->resolveGitLabProject($nid);
[$projectId] = $ref !== null ? $this->resolveFromRef($ref) : $this->resolveGitLabProject($nid);

$diffs = $this->gitLabClient->getMergeRequestDiffs($projectId, $mrIid);

Expand Down
5 changes: 3 additions & 2 deletions src/Api/Action/MergeRequest/GetMergeRequestLogsAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@

namespace mglaman\DrupalOrg\Action\MergeRequest;

use mglaman\DrupalOrg\GitLab\MergeRequestRef;
use mglaman\DrupalOrg\Result\MergeRequest\MergeRequestLogsResult;

class GetMergeRequestLogsAction extends AbstractMergeRequestAction
{
private const TRACE_EXCERPT_LINES = 100;

public function __invoke(string $nid, int $mrIid): MergeRequestLogsResult
public function __invoke(string $nid, int $mrIid, ?MergeRequestRef $ref = null): MergeRequestLogsResult
{
[$projectId] = $this->resolveGitLabProject($nid);
[$projectId] = $ref !== null ? $this->resolveFromRef($ref) : $this->resolveGitLabProject($nid);

$pipelines = $this->gitLabClient->getMergeRequestPipelines($projectId, $mrIid);

Expand Down
5 changes: 3 additions & 2 deletions src/Api/Action/MergeRequest/GetMergeRequestStatusAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@

namespace mglaman\DrupalOrg\Action\MergeRequest;

use mglaman\DrupalOrg\GitLab\MergeRequestRef;
use mglaman\DrupalOrg\Result\MergeRequest\MergeRequestStatusResult;

class GetMergeRequestStatusAction extends AbstractMergeRequestAction
{
public function __invoke(string $nid, int $mrIid): MergeRequestStatusResult
public function __invoke(string $nid, int $mrIid, ?MergeRequestRef $ref = null): MergeRequestStatusResult
{
[$projectId] = $this->resolveGitLabProject($nid);
[$projectId] = $ref !== null ? $this->resolveFromRef($ref) : $this->resolveGitLabProject($nid);

$pipelines = $this->gitLabClient->getMergeRequestPipelines($projectId, $mrIid);

Expand Down
5 changes: 3 additions & 2 deletions src/Api/Action/MergeRequest/ListMergeRequestsAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@
namespace mglaman\DrupalOrg\Action\MergeRequest;

use mglaman\DrupalOrg\Enum\MergeRequestState;
use mglaman\DrupalOrg\GitLab\MergeRequestRef;
use mglaman\DrupalOrg\Result\MergeRequest\MergeRequestItem;
use mglaman\DrupalOrg\Result\MergeRequest\MergeRequestListResult;

class ListMergeRequestsAction extends AbstractMergeRequestAction
{
public function __invoke(string $nid, MergeRequestState $state = MergeRequestState::Opened): MergeRequestListResult
public function __invoke(string $nid, MergeRequestState $state = MergeRequestState::Opened, ?MergeRequestRef $ref = null): MergeRequestListResult
{
[$projectId, $gitLabProjectPath] = $this->resolveGitLabProject($nid);
[$projectId, $gitLabProjectPath] = $ref !== null ? $this->resolveFromRef($ref) : $this->resolveGitLabProject($nid);

$params = ['per_page' => 100];
if ($state !== MergeRequestState::All) {
Expand Down
77 changes: 77 additions & 0 deletions src/Api/GitLab/MergeRequestRef.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

declare(strict_types=1);

namespace mglaman\DrupalOrg\GitLab;

/**
* Value object representing a direct merge request reference.
*
* Supports formats like "project/canvas!708", "project/canvas",
* or full GitLab URLs.
*/
final class MergeRequestRef
{
public function __construct(
public readonly string $projectPath,
public readonly ?int $mrIid = null,
) {
}

/**
* Try to parse a string into a MergeRequestRef.
*
* Returns null if the input is a pure numeric NID or unrecognized format.
*/
public static function tryParse(string $input): ?self
{
$input = trim($input);

if ($input === '') {
return null;
}

// Pure numeric string → NID, fall through.
if (ctype_digit($input)) {
return null;
}

// Full GitLab URL.
if (str_starts_with($input, 'https://git.drupalcode.org/')) {
return self::parseUrl($input);
}

// project/name!iid format.
if (preg_match('#^([a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+)!(\d+)$#', $input, $matches)) {
return new self($matches[1], (int) $matches[2]);
}

// project/name format (no IID).
if (preg_match('#^[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$#', $input)) {
return new self($input);
}

return null;
}

private static function parseUrl(string $url): ?self
{
$path = parse_url($url, PHP_URL_PATH);
if ($path === null || $path === false) {
return null;
}
$path = ltrim($path, '/');

// Match: project/name/-/merge_requests/123
if (preg_match('#^([a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+)/-/merge_requests/(\d+)#', $path, $matches)) {
return new self($matches[1], (int) $matches[2]);
}

// Match: project/name (with possible trailing segments but no merge_requests)
if (preg_match('#^([a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+)(?:/.*)?$#', $path, $matches)) {
return new self($matches[1]);
}

return null;
}
}
2 changes: 1 addition & 1 deletion src/Cli/Command/MergeRequest/GetDiff.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$format = (string) ($this->stdIn->getOption('format') ?? 'text');

$action = new GetMergeRequestDiffAction($this->client, new GitLabClient());
$result = $action($this->nid, $this->mrIid);
$result = $action($this->nid ?? '', $this->mrIid, $this->mrRef);

if ($format === 'json') {
$this->stdOut->writeln((string) json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
Expand Down
2 changes: 1 addition & 1 deletion src/Cli/Command/MergeRequest/GetFiles.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$format = (string) ($this->stdIn->getOption('format') ?? 'text');

$action = new GetMergeRequestFilesAction($this->client, new GitLabClient());
$result = $action($this->nid, $this->mrIid);
$result = $action($this->nid ?? '', $this->mrIid, $this->mrRef);

if ($format === 'json') {
$this->stdOut->writeln((string) json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
Expand Down
2 changes: 1 addition & 1 deletion src/Cli/Command/MergeRequest/GetLogs.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$format = (string) ($this->stdIn->getOption('format') ?? 'text');

$action = new GetMergeRequestLogsAction($this->client, new GitLabClient());
$result = $action($this->nid, $this->mrIid);
$result = $action($this->nid ?? '', $this->mrIid, $this->mrRef);

if ($format === 'json') {
$this->stdOut->writeln((string) json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
Expand Down
2 changes: 1 addition & 1 deletion src/Cli/Command/MergeRequest/GetStatus.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$format = (string) ($this->stdIn->getOption('format') ?? 'text');

$action = new GetMergeRequestStatusAction($this->client, new GitLabClient());
$result = $action($this->nid, $this->mrIid);
$result = $action($this->nid ?? '', $this->mrIid, $this->mrRef);

if ($this->writeFormatted($result, $format)) {
return 0;
Expand Down
25 changes: 22 additions & 3 deletions src/Cli/Command/MergeRequest/ListMergeRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
use mglaman\DrupalOrg\Action\MergeRequest\ListMergeRequestsAction;
use mglaman\DrupalOrg\Enum\MergeRequestState;
use mglaman\DrupalOrg\GitLab\Client as GitLabClient;
use mglaman\DrupalOrg\GitLab\MergeRequestRef;
use mglaman\DrupalOrgCli\Command\Command;
use mglaman\DrupalOrgCli\Command\Issue\IssueCommandBase;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputArgument;
Expand All @@ -16,12 +18,14 @@ class ListMergeRequests extends IssueCommandBase
{
protected bool $requiresRepository = false;

protected ?MergeRequestRef $mrRef = null;

protected function configure(): void
{
$this
->setName('mr:list')
->setAliases(['mrl'])
->addArgument('nid', InputArgument::OPTIONAL, 'The issue node ID')
->addArgument('nid', InputArgument::OPTIONAL, 'The issue NID, project-path (or project-path!iid; quote in zsh), or GitLab URL')
->addOption(
'state',
null,
Expand All @@ -36,7 +40,22 @@ protected function configure(): void
'Output format: text, json, md, llm. Defaults to text.',
'text'
)
->setDescription('List merge requests for a Drupal.org issue fork.');
->setDescription('List merge requests for a Drupal.org issue fork or project.');
}

protected function initialize(InputInterface $input, OutputInterface $output): void
{
$nidArg = (string) $input->getArgument('nid');
$ref = $nidArg !== '' ? MergeRequestRef::tryParse($nidArg) : null;

if ($ref !== null) {
Command::initialize($input, $output);
$this->mrRef = $ref;
$this->nid = '';
return;
}

parent::initialize($input, $output);
}

protected function execute(InputInterface $input, OutputInterface $output): int
Expand All @@ -45,7 +64,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$format = (string) ($this->stdIn->getOption('format') ?? 'text');

$action = new ListMergeRequestsAction($this->client, new GitLabClient());
$result = $action($this->nid, $state);
$result = $action($this->nid ?? '', $state, $this->mrRef);

if ($this->writeFormatted($result, $format)) {
return 0;
Expand Down
27 changes: 26 additions & 1 deletion src/Cli/Command/MergeRequest/MrCommandBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace mglaman\DrupalOrgCli\Command\MergeRequest;

use mglaman\DrupalOrg\GitLab\MergeRequestRef;
use mglaman\DrupalOrgCli\Command\Command;
use mglaman\DrupalOrgCli\Command\Issue\IssueCommandBase;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
Expand All @@ -13,15 +15,38 @@ abstract class MrCommandBase extends IssueCommandBase

protected int $mrIid;

protected ?MergeRequestRef $mrRef = null;

protected function configureNidAndMrIid(): void
{
$this
->addArgument('nid', InputArgument::OPTIONAL, 'The issue node ID')
->addArgument('nid', InputArgument::OPTIONAL, 'The issue NID, project-path!iid (quote in zsh), or GitLab MR URL')
->addArgument('mr-iid', InputArgument::OPTIONAL, 'The merge request IID');
}

protected function initialize(InputInterface $input, OutputInterface $output): void
{
$nidArg = (string) $input->getArgument('nid');
$ref = $nidArg !== '' ? MergeRequestRef::tryParse($nidArg) : null;

if ($ref !== null) {
// Skip IssueCommandBase NID resolution — go straight to Command::initialize.
Command::initialize($input, $output);
$this->mrRef = $ref;
$this->nid = '';

if ($ref->mrIid !== null) {
$this->mrIid = $ref->mrIid;
} else {
$mrIidArg = $input->getArgument('mr-iid');
if ($mrIidArg === null || $mrIidArg === '') {
throw new \RuntimeException('Argument mr-iid is required when project-path has no !iid.');
}
$this->mrIid = (int) $mrIidArg;
}
return;
}

parent::initialize($input, $output);

$mrIid = $this->stdIn->getArgument('mr-iid');
Expand Down
Loading