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
3 changes: 2 additions & 1 deletion skill-data/drupalorg-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,9 @@ includes the MR IID (`!iid`), the second `<mr-iid>` argument is not needed.
# 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
# Only MRs opened from the issue fork are returned; empty means no fork or no MRs
drupalorg mr:list [nid] [--state=opened] --format=llm
# List MRs by project path (no issue NID needed)
# List every MR on a project (not scoped to an issue)
drupalorg mr:list project/drupal --format=llm

# Show the unified diff for a merge request
Expand Down
6 changes: 6 additions & 0 deletions skill-data/drupalorg-cli/references/gitlab-mr-contribution.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ drupalorg mr:list <nid> --state=merged --format=llm
drupalorg mr:list <nid> --state=all --format=llm
```

`mr:list <nid>` only returns MRs opened from that issue's fork
(`issue/{project}-{nid}`). The `issue_fork` field names the fork the list was
scoped to. An empty list means the issue has no fork or no MRs in the requested
state, never that MRs live elsewhere. To list every MR on a project, pass the
project path instead: `mr:list project/drupal`.

`--format=llm` output includes IID, title, source branch, state, mergeability,
author, and last-updated timestamp for each MR.

Expand Down
6 changes: 6 additions & 0 deletions skills/drupalorg-cli/references/gitlab-mr-contribution.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ drupalorg mr:list <nid> --state=merged --format=llm
drupalorg mr:list <nid> --state=all --format=llm
```

`mr:list <nid>` only returns MRs opened from that issue's fork
(`issue/{project}-{nid}`). The `issue_fork` field names the fork the list was
scoped to. An empty list means the issue has no fork or no MRs in the requested
state, never that MRs live elsewhere. To list every MR on a project, pass the
project path instead: `mr:list project/drupal`.

`--format=llm` output includes IID, title, source branch, state, mergeability,
author, and last-updated timestamp for each MR.

Expand Down
81 changes: 70 additions & 11 deletions src/Api/Action/MergeRequest/ListMergeRequestsAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,90 @@

namespace mglaman\DrupalOrg\Action\MergeRequest;

use GuzzleHttp\Exception\ClientException;
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, ?MergeRequestRef $ref = null): MergeRequestListResult
{
[$projectId, $gitLabProjectPath] = $ref !== null ? $this->resolveFromRef($ref) : $this->resolveGitLabProject($nid);

/**
* Lists merge requests for an issue fork, or for a whole project.
*
* Merge requests belong to the target project on GitLab, so listing them
* on the fork returns nothing. Instead this lists the parent project and
* filters by the fork's project ID. A missing fork yields an empty list.
*
* @param string|null $projectMachineName
* Skips the Drupal.org node lookup when the caller already knows the
* project, such as from a WorkItemRef.
*/
public function __invoke(
string $nid,
MergeRequestState $state = MergeRequestState::Opened,
?MergeRequestRef $ref = null,
?string $projectMachineName = null,
): MergeRequestListResult {
$params = ['per_page' => 100];
if ($state !== MergeRequestState::All) {
$params['state'] = $state->value;
}

$mrObjects = $this->gitLabClient->getMergeRequests($projectId, $params);
$mergeRequests = array_map(
static fn(\stdClass $mr) => MergeRequestItem::fromStdClass($mr),
$mrObjects
);
if ($ref !== null) {
[$projectId, $projectPath] = $this->resolveFromRef($ref);
return new MergeRequestListResult(
projectPath: $projectPath,
mergeRequests: $this->fetch($projectId, $params),
);
}

if ($projectMachineName === null) {
$projectMachineName = $this->client->getNode($nid)->fieldProjectMachineName;
}
$projectPath = 'project/' . $projectMachineName;
$issueForkPath = 'issue/' . $projectMachineName . '-' . $nid;

$forkId = $this->findProjectId($issueForkPath);
if ($forkId === null) {
return new MergeRequestListResult(
projectPath: $projectPath,
mergeRequests: [],
issueFork: $issueForkPath,
);
}

$project = $this->gitLabClient->getProject($projectPath);
$params['source_project_id'] = $forkId;

return new MergeRequestListResult(
projectPath: $gitLabProjectPath,
mergeRequests: $mergeRequests,
projectPath: $projectPath,
mergeRequests: $this->fetch((int) $project->id, $params),
issueFork: $issueForkPath,
);
}

/**
* @param array<string, mixed> $params
* @return MergeRequestItem[]
*/
private function fetch(int $projectId, array $params): array
{
return array_map(
static fn(\stdClass $mr) => MergeRequestItem::fromStdClass($mr),
$this->gitLabClient->getMergeRequests($projectId, $params)
);
}

private function findProjectId(string $path): ?int
{
try {
return (int) $this->gitLabClient->getProject($path)->id;
} catch (ClientException $e) {
if ($e->getResponse()->getStatusCode() === 404) {
return null;
}
throw $e;
}
}
}
2 changes: 1 addition & 1 deletion src/Api/Mcp/ToolRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ public function maintainerGetIssues(
return (new GetMaintainerIssuesAction())($user, MaintainerIssueType::from($type))->jsonSerialize();
}

#[McpTool(annotations: new ToolAnnotations(readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true), name: 'mr_list', description: 'List merge requests for an issue fork.')]
#[McpTool(annotations: new ToolAnnotations(readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true), name: 'mr_list', description: 'List merge requests opened from a Drupal.org issue fork. Returns an empty list when the issue has no fork.')]
public function mrList(
#[Schema(description: 'The Drupal.org issue node ID.', pattern: self::NID_PATTERN)]
string $nid,
Expand Down
5 changes: 5 additions & 0 deletions src/Api/Result/MergeRequest/MergeRequestListResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,22 @@ class MergeRequestListResult implements ResultInterface
{
/**
* @param MergeRequestItem[] $mergeRequests
* @param string|null $issueFork
* The issue fork path the list is scoped to, or null for a
* project-wide list.
*/
public function __construct(
public readonly string $projectPath,
public readonly array $mergeRequests,
public readonly ?string $issueFork = null,
) {
}

public function jsonSerialize(): mixed
{
return [
'project_path' => $this->projectPath,
'issue_fork' => $this->issueFork,
'merge_requests' => array_map(
static fn(MergeRequestItem $mr) => $mr->toArray(),
$this->mergeRequests
Expand Down
15 changes: 13 additions & 2 deletions src/Cli/Command/MergeRequest/ListMergeRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,20 +58,31 @@ protected function initialize(InputInterface $input, OutputInterface $output): v
parent::initialize($input, $output);
}

private function projectMachineName(): ?string
{
if ($this->workItemRef === null) {
return null;
}
return substr($this->workItemRef->projectPath, strlen('project/'));
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$state = MergeRequestState::from((string) ($this->stdIn->getOption('state') ?? 'opened'));
$format = (string) ($this->stdIn->getOption('format') ?? 'text');

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

if ($this->writeFormatted($result, $format)) {
return 0;
}

if ($result->mergeRequests === []) {
$this->stdOut->writeln(sprintf('No %s merge requests found.', $state->value));
$scope = $result->issueFork !== null
? sprintf('for issue fork %s', $result->issueFork)
: sprintf('in %s', $result->projectPath);
$this->stdOut->writeln(sprintf('No %s merge requests found %s.', $state->value, $scope));
return 0;
}

Expand Down
7 changes: 5 additions & 2 deletions src/Cli/Command/MergeRequest/MrCommandBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,12 @@ protected function initialize(InputInterface $input, OutputInterface $output): v
return;
}

// mr-iid not provided — auto-select from open merge requests.
// mr-iid not provided — auto-select from the issue fork's open merge requests.
$projectMachineName = $this->workItemRef !== null
? substr($this->workItemRef->projectPath, strlen('project/'))
: null;
$listAction = new ListMergeRequestsAction($this->client, new GitLabClient());
$listResult = $listAction($this->nid, MergeRequestState::Opened);
$listResult = $listAction($this->nid, MergeRequestState::Opened, null, $projectMachineName);
$mergeRequests = $listResult->mergeRequests;

if ($mergeRequests === []) {
Expand Down
5 changes: 4 additions & 1 deletion src/Cli/Formatter/LlmFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,9 @@ protected function formatIssueFork(IssueForkResult $result): string
protected function formatMergeRequestList(MergeRequestListResult $result): string
{
$projectPath = $this->xmlEscape($result->projectPath);
$issueFork = $result->issueFork !== null
? " <issue_fork>" . $this->xmlEscape($result->issueFork) . "</issue_fork>\n"
: '';
$items = '';
foreach ($result->mergeRequests as $mr) {
$title = $this->xmlEscape($mr->title);
Expand All @@ -188,7 +191,7 @@ protected function formatMergeRequestList(MergeRequestListResult $result): strin
$items .= " <updated_at>{$updatedAt}</updated_at>\n";
$items .= " </merge_request>\n";
}
return "<drupal_context>\n <project_path>{$projectPath}</project_path>\n <merge_requests>\n{$items} </merge_requests>\n</drupal_context>";
return "<drupal_context>\n <project_path>{$projectPath}</project_path>\n{$issueFork} <merge_requests>\n{$items} </merge_requests>\n</drupal_context>";
}

protected function formatMergeRequestStatus(MergeRequestStatusResult $result): string
Expand Down
4 changes: 4 additions & 0 deletions src/Cli/Formatter/MarkdownFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@ protected function formatMergeRequestList(MergeRequestListResult $result): strin
$lines = [];
$lines[] = "# Merge Requests: {$result->projectPath}";
$lines[] = '';
if ($result->issueFork !== null) {
$lines[] = "Scoped to issue fork `{$result->issueFork}`.";
$lines[] = '';
}
foreach ($result->mergeRequests as $mr) {
$mergeable = $mr->isMergeable ? ' ✓' : '';
$lines[] = "- **!{$mr->iid}** [{$mr->state}{$mergeable}] [{$mr->title}]({$mr->webUrl})";
Expand Down
Loading