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
33 changes: 33 additions & 0 deletions skill-data/drupalorg-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,39 @@ drupalorg mr:logs <nid> <mr-iid>
drupalorg mr:logs 'project/drupal!708'
```

### Slash commands (GitLab work items only)

These commands post Drupal.org bot quick-actions as comments on a GitLab work item.
They only work for projects whose issue queue lives at `git.drupalcode.org` (the bot
is not present on classic Drupal.org issue queues).

The `<ref>` argument is a WorkItemRef — bare NID, `project_name#nid`, or full URL.

Each command also accepts `--format=text|json|md|llm`. Posting is asynchronous: the
bot processes the comment after it lands, so re-fetch with `--no-cache` to confirm.

```bash
# Create a fork (and a default-branch issue branch) for an issue without a fork
drupalorg issue:fork <ref>

# Grant the current user push access to the existing fork
drupalorg issue:get-access <ref>

# Assign one or more users (default: me)
drupalorg issue:assign <ref> [user...]
drupalorg issue:unassign <ref> [user...]
drupalorg issue:reassign <ref> <user> [user...]

# Manage labels (e.g. state::needsReview, state::rtbc, state::needsWork)
drupalorg issue:label <ref> <label> [label...]
drupalorg issue:unlabel <ref> <label> [label...]
drupalorg issue:relabel <ref> <label> [label...]
```

Authentication requires a GitLab token. The CLI reads `DRUPALORG_GITLAB_TOKEN` and
falls back to `glab config get token --host git.drupalcode.org` when that env var is
unset.

### Project commands

```bash
Expand Down
29 changes: 29 additions & 0 deletions skill-data/drupalorg-work-on-issue/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,20 @@ 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:

```bash
drupalorg issue:fork <ref>
```

The Drupal.org bot processes the comment asynchronously. Wait a few seconds, then
re-run `drupalorg issue:get-fork <ref> --format=llm --no-cache` to confirm the fork
exists before proceeding. To pick up an existing fork you don't yet have push access
to (Example #2 in the upstream docs), run `drupalorg issue:get-access <ref>` before
`issue:setup-remote`.

**[PAUSE]** Only pause here if the working directory could not be determined automatically
OR if multiple branches exist. Present your findings and wait for confirmation before
proceeding.
Expand All @@ -72,6 +86,13 @@ drupalorg issue:setup-remote <nid>
drupalorg issue:checkout <nid> <branch>
```

**Optional (GitLab work items):** Self-assign so others can see you are working on
this issue:

```bash
drupalorg issue:assign <ref>
```

**SSH remote URL check:** `issue:setup-remote` sets the remote URL to HTTPS
(`https://git.drupal.org/issue/<project>-<nid>.git`). Contributors using SSH authentication
must switch to the SSH equivalent before pushing. After the remote is set:
Expand Down Expand Up @@ -177,6 +198,14 @@ Iterate until the pipeline is green or the user asks to stop:
**[PAUSE]** After each push, report the pipeline outcome and ask whether to continue
or stop.

**Hand off for review (GitLab work items only):** When the pipeline is green and the
user confirms the work is ready for review, flip the state label and unassign:

```bash
drupalorg issue:label <ref> state::needsReview
drupalorg issue:unassign <ref>
```

---

## Notes
Expand Down
93 changes: 93 additions & 0 deletions src/Api/Action/GitLab/PostWorkItemSlashCommandAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?php

declare(strict_types=1);

namespace mglaman\DrupalOrg\Action\GitLab;

use mglaman\DrupalOrg\Client;
use mglaman\DrupalOrg\GitLab\Client as GitLabClient;
use mglaman\DrupalOrg\GitLab\WorkItemRef;
use mglaman\DrupalOrg\Result\Issue\SlashCommandResult;

/**
* Posts a Drupal.org bot slash command (e.g. `/do:fork`, `/do:assign me`,
* `/do:label ~state::needsReview`) as a comment on a GitLab work item.
*
* Only meaningful for projects whose issue queue has been migrated to GitLab
* work items at git.drupalcode.org. For classic Drupal.org issue queues the
* bot is not present and the call will 404.
*/
class PostWorkItemSlashCommandAction
{
public function __construct(
private readonly Client $client,
private readonly GitLabClient $gitLabClient,
) {
}

public function __invoke(string $refOrNid, string $command): SlashCommandResult
{
$ref = $this->resolveRef($refOrNid);

try {
$response = $this->gitLabClient->postIssueNote(
$ref->projectPath,
$ref->issueId,
$command,
);
} catch (\Exception $e) {
throw new \RuntimeException(sprintf(
'Failed to post "%s" to %s#%d: %s. If this project still uses a '
. 'Drupal.org issue queue, slash commands are not supported.',
$command,
$ref->projectPath,
$ref->issueId,
$e->getMessage(),
), 0, $e);
}

if (!isset($response->id) || !is_numeric($response->id)) {
throw new \RuntimeException(sprintf(
'GitLab note response for %s#%d did not contain an id. The '
. 'note may not have been posted.',
$ref->projectPath,
$ref->issueId,
));
}

return new SlashCommandResult(
projectPath: $ref->projectPath,
issueIid: $ref->issueId,
command: $command,
noteId: (int) $response->id,
);
}

private function resolveRef(string $refOrNid): WorkItemRef
{
$refOrNid = trim($refOrNid);
$ref = WorkItemRef::tryParse($refOrNid);
if ($ref !== null) {
return $ref;
}
if ($refOrNid === '' || !ctype_digit($refOrNid)) {
throw new \InvalidArgumentException(sprintf(
'Unrecognised work item reference "%s". Expected a NID, '
. 'shorthand (project_name#nid), or full work item URL.',
$refOrNid,
));
}
$node = $this->client->getNode($refOrNid);
$machineName = $node->fieldProjectMachineName;
if ($machineName === '') {
throw new \RuntimeException(sprintf(
'Could not resolve project for NID %s.',
$refOrNid,
));
}
return new WorkItemRef(
projectPath: 'project/' . $machineName,
issueId: (int) $refOrNid,
);
}
}
63 changes: 61 additions & 2 deletions src/Api/GitLab/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use GuzzleHttp\HandlerStack;
use GuzzleRetry\GuzzleRetryMiddleware;
use Symfony\Component\Process\Process;

class Client
{
Expand All @@ -25,8 +26,8 @@ public function __construct()
'Accept' => 'application/json',
];

$token = getenv('DRUPALORG_GITLAB_TOKEN');
if ($token !== false && $token !== '') {
$token = self::resolveToken();
if ($token !== null) {
$headers['PRIVATE-TOKEN'] = $token;
}

Expand All @@ -37,6 +38,30 @@ public function __construct()
]);
}

/**
* Resolve a GitLab token from env or, as a fallback, the glab CLI.
*/
private static function resolveToken(): ?string
{
$token = getenv('DRUPALORG_GITLAB_TOKEN');
if ($token !== false && $token !== '') {
return $token;
}
try {
$process = new Process(['glab', 'config', 'get', 'token', '--host', 'git.drupalcode.org']);
$process->run();
if ($process->isSuccessful()) {
$output = trim($process->getOutput());
if ($output !== '') {
return $output;
}
}
} catch (\Throwable) {
// glab not installed or failed; treat as unauthenticated.
}
return null;
}

/**
* @param array<string, mixed> $query
* @throws \Exception
Expand All @@ -54,6 +79,40 @@ private function get(string $path, array $query = []): mixed
throw new \Exception('GitLab API error', $res->getStatusCode());
}

/**
* @param array<string, mixed> $body
* @throws \Exception
*/
private function post(string $path, array $body): mixed
{
$res = $this->client->request('POST', $path, ['json' => $body]);
$status = $res->getStatusCode();
if ($status === 200 || $status === 201) {
return \json_decode($res->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR);
}
throw new \Exception('GitLab API error', $status);
}

/**
* POST /projects/{path}/issues/{iid}/notes
*
* Posts a note (comment) to a GitLab issue or work item. Used to send
* Drupal.org bot slash commands such as `/do:fork`, `/do:assign me`, and
* `/do:label ~state::needsReview` on projects whose issue queue lives on
* GitLab work items.
*
* @throws \Exception
*/
public function postIssueNote(string $projectPath, int $iid, string $body): \stdClass
{
/** @var \stdClass $result */
$result = $this->post(
'projects/' . urlencode($projectPath) . '/issues/' . $iid . '/notes',
['body' => $body],
);
return $result;
}

/**
* GET /projects/{path}
*
Expand Down
111 changes: 111 additions & 0 deletions src/Api/GitLab/SlashCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php

declare(strict_types=1);

namespace mglaman\DrupalOrg\GitLab;

/**
* Builds the comment bodies the Drupal.org bot recognises on GitLab work items.
*
* See https://new.drupal.org/drupalorg/gitlab-custom-commands.
*/
final class SlashCommand
{
public static function fork(): string
{
return '/do:fork';
}

public static function access(): string
{
return '/do:access';
}

/**
* @param array<int, string> $users
*/
public static function assign(array $users): string
{
return '/do:assign ' . self::formatUsers($users);
}

/**
* @param array<int, string> $users
*/
public static function unassign(array $users): string
{
return '/do:unassign ' . self::formatUsers($users);
}

/**
* @param array<int, string> $users
*/
public static function reassign(array $users): string
{
return '/do:reassign ' . self::formatUsers($users);
}

/**
* @param array<int, string> $labels
*/
public static function label(array $labels): string
{
return '/do:label ' . self::formatLabels($labels);
}

/**
* @param array<int, string> $labels
*/
public static function unlabel(array $labels): string
{
return '/do:unlabel ' . self::formatLabels($labels);
}

/**
* @param array<int, string> $labels
*/
public static function relabel(array $labels): string
{
return '/do:relabel ' . self::formatLabels($labels);
}

/**
* @param array<int, string> $users
*/
private static function formatUsers(array $users): string
{
if ($users === []) {
throw new \InvalidArgumentException('At least one user is required.');
}
return implode(' ', array_map(self::formatUser(...), $users));
}

private static function formatUser(string $user): string
{
$user = ltrim(trim($user), '@');
if ($user === '') {
throw new \InvalidArgumentException('User name cannot be empty.');
}
return $user === 'me' ? 'me' : '@' . $user;
}

/**
* @param array<int, string> $labels
*/
private static function formatLabels(array $labels): string
{
if ($labels === []) {
throw new \InvalidArgumentException('At least one label is required.');
}
return implode(' ', array_map(self::formatLabel(...), $labels));
}

private static function formatLabel(string $label): string
{
$label = ltrim(trim($label), '~');
if ($label === '') {
throw new \InvalidArgumentException('Label cannot be empty.');
}
return '~' . $label;
}
}
Loading