Skip to content
Merged
73 changes: 55 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ Drupal.org CLI
--------------
[![Latest Stable Version](https://poser.pugx.org/mglaman/drupalorg-cli/v/stable)](https://packagist.org/packages/mglaman/drupalorg-cli) [![Total Downloads](https://poser.pugx.org/mglaman/drupalorg-cli/downloads)](https://packagist.org/packages/mglaman/drupalorg-cli) [![Latest Unstable Version](https://poser.pugx.org/mglaman/drupalorg-cli/v/unstable)](https://packagist.org/packages/mglaman/drupalorg-cli) [![License](https://poser.pugx.org/mglaman/drupalorg-cli/license)](https://packagist.org/packages/mglaman/drupalorg-cli)

A command line tool for interfacing with Drupal.org. Uses the Drupal.org REST API.
A command line tool for interfacing with Drupal.org and GitLab (git.drupalcode.org). Uses the Drupal.org REST API and GitLab REST API.

## Requirements

Expand Down Expand Up @@ -92,29 +92,66 @@ drupalorg list

````
Available commands:
help Displays help for a command
list Lists commands
cache
cache:clear (cc) Clears caches
help Display help for a command
list List commands
issue
issue:apply Applies the latest patch from an issue.
issue:branch Creates a branch for the issue.
issue:interdiff Generate an interdiff for the issue from local changes.
issue:link Opens an issue
issue:patch Generate a patch for the issue from committed local changes.
issue:apply Applies the latest patch from an issue.
issue:branch Creates a branch for the issue.
issue:checkout Check out a branch from the GitLab issue fork.
issue:get-fork Show the GitLab issue fork URLs and branches.
issue:interdiff Generate an interdiff for the issue from committed local changes.
issue:link Opens an issue
issue:patch Generate a patch for the issue from committed local changes.
issue:search [is] Searches issues for a project by title keyword.
issue:setup-remote Add the GitLab issue fork as a git remote and fetch it.
issue:show Show a given issue information.
maintainer
maintainer:issues (mi) Lists issues for a user, based on maintainer.
maintainer:release-notes (rn, mrn) Generate release notes.
maintainer:issues [mi] Lists issues for a user, based on maintainer.
maintainer:release-notes [rn|mrn] Generate release notes.
mcp
mcp:config Output the Claude Desktop MCP configuration snippet.
mcp:serve Start a Model Context Protocol server over stdio.
mr
mr:diff Show the unified diff for a merge request.
mr:files List changed files in a merge request.
mr:list [mrl] List merge requests for a Drupal.org issue fork or project.
mr:logs Show failed job traces from the latest pipeline for a merge request.
mr:status Show the pipeline status for a merge request.
project
project:issues (pi) Lists issues for a project.
project:kanban Opens project kanban
project:link Opens project page
project:release-notes (prn) View release notes for a release
project:releases Lists available releases
project:issues [pi] Lists issues for a project.
project:kanban Opens project kanban
project:link Opens project page
project:release-notes [prn] View release notes for a release
project:releases Lists available releases
skill
skill:install Installs all drupalorg-cli agent skills into .claude/skills/ in the current directory.
skill:install Installs all drupalorg-cli agent skills into .claude/skills/ in the current directory.
````

## GitLab work items

Some Drupal.org projects have migrated their issue queues to GitLab work items at `git.drupalcode.org`. These projects are detected automatically via `field_project_has_issue_queue` on the project node.

**`project:issues`** fetches from the GitLab API instead of Drupal.org for these projects.

The following commands accept a work item reference in place of a Drupal.org issue NID:

```bash
# Full URL
drupalorg issue:show https://git.drupalcode.org/project/ai_context/-/work_items/3586157

# Explicit path
drupalorg issue:show project/ai_context#3586157

# Shorthand (project/ prefix assumed)
drupalorg issue:show ai_context#3586157
```

The same formats work for `issue:get-fork` and `mr:list`. MR URLs also work directly:

```bash
drupalorg mr:list https://git.drupalcode.org/project/ai_context/-/merge_requests/131
```

## Getting Started

### Working with project issues
Expand Down
35 changes: 29 additions & 6 deletions skills/drupalorg-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,34 @@ name: drupalorg-cli
description: >
CLI for Drupal.org issue lifecycle management. Use when fetching issue details,
generating patches/interdiffs, listing project or maintainer issues, looking up
releases, or working with GitLab merge requests on issue forks. Pass --format=llm
to every read command for structured XML output optimised for agent consumption.
releases, or working with GitLab merge requests on issue forks. Also supports
projects that have migrated to GitLab work items. Pass --format=llm to every
read command for structured XML output optimised for agent consumption.
---

## Overview

`drupalorg-cli` (invoked as `drupalorg`) wraps Drupal.org's REST and JSON:API
endpoints. It covers the full contribution lifecycle: browsing issues, creating
`drupalorg-cli` (invoked as `drupalorg`) wraps Drupal.org's REST and GitLab REST
APIs. It covers the full contribution lifecycle: browsing issues, creating
branches, generating patches and interdiffs, applying patches, working with GitLab
issue forks and merge requests, and browsing releases.

Some Drupal.org projects have migrated their issue queues to GitLab work items
at `git.drupalcode.org`. These projects are detected automatically — `project:issues`
fetches from the GitLab API instead of Drupal.org for them.

### Work item references

`issue:show`, `issue:get-fork`, and `mr:list` all accept a **WorkItemRef** in
place of a plain Drupal.org NID:

| Format | Example |
|--------|---------|
| D.o NID | `3586157` |
| Shorthand | `ai_context#3586157` |
| Explicit path | `project/ai_context#3586157` |
| Full URL | `https://git.drupalcode.org/project/ai_context/-/work_items/3586157` |

```bash
drupalorg <command> [arguments]
```
Expand All @@ -36,11 +53,14 @@ with clearly labelled fields, contributor lists, and change records.

### Issue commands

`<nid>` accepts a D.o NID, shorthand (`ai_context#3586157`), or full GitLab work item URL.

```bash
# Fetch full details for an issue
# Fetch full details for an issue (D.o or GitLab work item)
drupalorg issue:show <nid> --format=llm

# Fetch issue details including all comments (skips system-generated messages)
# Note: --with-comments only applies to D.o issues
drupalorg issue:show <nid> --with-comments --format=llm

# Show the GitLab issue fork URLs and branches
Expand Down Expand Up @@ -115,8 +135,10 @@ drupalorg mr:logs 'project/drupal!708'

```bash
# List open issues for a project
# For projects using GitLab work items, fetches from GitLab API automatically
# type: all (default), rtbc, or review; --core defaults to 8.x; --limit defaults to 10
# --category filters by issue type: bug, task, feature, support, plan (omit for all categories)
# Note: type/core/category filters only apply to D.o issue queue projects
drupalorg project:issues [project] [type] [--category=bug|task|feature|support|plan] --format=llm

# Search issues for a project by title keyword
Expand Down Expand Up @@ -174,7 +196,8 @@ drupalorg mr:list [nid] --format=llm --no-cache

| Error | Cause | Recovery |
|-------|-------|----------|
| `Node not found` | Invalid or private issue NID | Verify the NID on drupal.org |
| `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` |
| `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 |
| `Remote … does not exist` | `issue:checkout` run before `issue:setup-remote` | Run `issue:setup-remote <nid>` first |
Expand Down
13 changes: 12 additions & 1 deletion skills/drupalorg-issue-search/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,23 @@ description: >
- `--status`: issue status filter (default: `all`)
- `--skip`: comma-separated list of channels to skip. Valid values: `api_search`, `drupalorg_scrape`, `web_search`. For example `--skip=web_search` skips the web search, `--skip=api_search,web_search` runs only the Drupal.org scrape.

2. **Detect project**: If `--project` is not provided, try to infer the project machine name from the current git remote:
2. **Detect project and issue queue type**: If `--project` is not provided, try to infer the project machine name from the current git remote:
```bash
git config --get remote.origin.url
```
Extract the project name from the URL (pattern: `*/project-name.git`). If detection fails, proceed without a project filter.

Once the project name is known, check whether it uses GitLab work items:
```bash
drupalorg project:issues <project> --limit=1 --format=json
```
If the output contains a `"gitlab_issues"` key or the command prints
`"Project uses GitLab work items"` to stderr, the project has migrated.
In that case:
- Skip the `api_search` and `drupalorg_scrape` channels (they search the D.o issue queue, which is empty for this project).
- For `web_search`, target `site:git.drupalcode.org/project/<project>/-/issues` instead.
- Note to the user: "This project uses GitLab work items. Search results are from GitLab."

3. **Run enabled searches in parallel** (skip any channel listed in `--skip`):

a. **API search** (channel: `api_search`) — run the CLI command:
Expand Down
11 changes: 11 additions & 0 deletions skills/drupalorg-issue-summary-update/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,17 @@ the latest discussion in the comments.

## Instructions

### Step 0: Detect issue type

If `<nid>` looks like a GitLab work item reference — a URL containing
`git.drupalcode.org`, or a shorthand like `ai_context#3586157` — stop immediately
and tell the user:

> "GitLab work items don't use the Drupal.org issue summary format (Problem/Motivation,
> Proposed resolution, etc.). This skill only applies to Drupal.org issues."

Do not proceed further.

### Step 1: Fetch issue with comments

```bash
Expand Down
11 changes: 9 additions & 2 deletions skills/drupalorg-work-on-issue/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@ description: >

**Purpose:** Agentic workflow for contributing to a Drupal.org issue via GitLab MR.

**Usage:** `/drupalorg-work-on-issue <nid>`
**Usage:** `/drupalorg-work-on-issue <nid-or-ref>`

The argument can be:
- A Drupal.org issue NID: `3586157`
- A shorthand work item ref: `ai_context#3586157`
- A full GitLab work item URL: `https://git.drupalcode.org/project/ai_context/-/work_items/3586157`

All three formats are accepted by `issue:show`, `issue:get-fork`, and `mr:list`.

---

Expand All @@ -24,7 +31,7 @@ before proceeding.

### Step 1: Fetch issue and fork details

Run both commands to gather context:
Run both commands to gather context (substitute `<nid>` with whatever ref was provided):

```bash
drupalorg issue:show <nid> --format=llm
Expand Down
23 changes: 23 additions & 0 deletions src/Api/Action/GitLab/GetGitLabIssueAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace mglaman\DrupalOrg\Action\GitLab;

use mglaman\DrupalOrg\GitLab\Client as GitLabClient;
use mglaman\DrupalOrg\GitLab\Entity\GitLabIssue;
use mglaman\DrupalOrg\GitLab\WorkItemRef;
use mglaman\DrupalOrg\Result\GitLab\GitLabIssueResult;

class GetGitLabIssueAction
{
public function __construct(private readonly GitLabClient $gitLabClient)
{
}

public function __invoke(WorkItemRef $ref): GitLabIssueResult
{
$data = $this->gitLabClient->getIssue($ref->projectPath, $ref->issueId);
return new GitLabIssueResult(GitLabIssue::fromStdClass($data));
}
}
37 changes: 37 additions & 0 deletions src/Api/Action/GitLab/ListGitLabIssuesAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace mglaman\DrupalOrg\Action\GitLab;

use mglaman\DrupalOrg\GitLab\Client as GitLabClient;
use mglaman\DrupalOrg\GitLab\Entity\GitLabIssue;
use mglaman\DrupalOrg\Result\GitLab\GitLabIssuesResult;

class ListGitLabIssuesAction
{
public function __construct(private readonly GitLabClient $gitLabClient)
{
}

public function __invoke(string $projectMachineName, string $state = 'opened', int $limit = 25): GitLabIssuesResult
{
$params = [
'state' => $state,
'per_page' => $limit,
'order_by' => 'created_at',
'sort' => 'desc',
];

$data = $this->gitLabClient->getIssues('project/' . $projectMachineName, $params);
$issues = array_map(
static fn(\stdClass $item) => GitLabIssue::fromStdClass($item),
$data
);

return new GitLabIssuesResult(
projectMachineName: $projectMachineName,
issues: $issues,
);
}
}
8 changes: 5 additions & 3 deletions src/Api/Action/Issue/GetIssueForkAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ public function __construct(
) {
}

public function __invoke(string $nid): IssueForkResult
public function __invoke(string $nid, ?string $projectMachineName = null): IssueForkResult
{
$issue = $this->client->getNode($nid);
$projectMachineName = $issue->fieldProjectMachineName;
if ($projectMachineName === null) {
$issue = $this->client->getNode($nid);
$projectMachineName = $issue->fieldProjectMachineName;
}
$remoteName = $projectMachineName . '-' . $nid;
$gitLabProjectPath = 'issue/' . $remoteName;

Expand Down
2 changes: 2 additions & 0 deletions src/Api/Entity/Project.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ public function __construct(
public readonly string $nid,
public readonly string $title,
public readonly string $machineName,
public readonly bool $hasIssueQueue = true,
) {
}

Expand All @@ -17,6 +18,7 @@ public static function fromStdClass(\stdClass $data): self
nid: (string) ($data->nid ?? ''),
title: (string) ($data->title ?? ''),
machineName: (string) ($data->field_project_machine_name ?? ''),
hasIssueQueue: (bool) ($data->field_project_has_issue_queue ?? true),
);
}
}
25 changes: 25 additions & 0 deletions src/Api/GitLab/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,31 @@ public function getPipelineJobs(string|int $projectId, int $pipelineId): array
return is_array($result) ? $result : [];
}

/**
* GET /projects/{path}/issues/{iid}
*
* @throws \Exception
*/
public function getIssue(string $projectPath, int $iid): \stdClass
{
/** @var \stdClass $result */
$result = $this->get('projects/' . urlencode($projectPath) . '/issues/' . $iid);
return $result;
}

/**
* GET /projects/{path}/issues
*
* @param array<string, mixed> $params
* @return \stdClass[]
* @throws \Exception
*/
public function getIssues(string $projectPath, array $params = []): array
{
$result = $this->get('projects/' . urlencode($projectPath) . '/issues', $params);
return is_array($result) ? $result : [];
}

/**
* GET /projects/{id}/jobs/{job_id}/trace
*
Expand Down
Loading