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
5 changes: 4 additions & 1 deletion skills/drupalorg-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Commands that fetch data accept `--format` / `-f`:
| `text` | Human-readable plain text (default) | All commands |
| `json` | Machine-readable JSON | Most commands |
| `md` | Markdown suitable for display or copy-paste | `issue:show`, `issue:get-fork`, `mr:list`, `mr:status`, `project:issues`, `project:releases`, `project:release-notes`, `maintainer:issues` |
| `llm` | Structured XML optimised for agent consumption | `issue:show`, `issue:get-fork`, `mr:list`, `mr:status`, `project:issues`, `project:releases`, `project:release-notes`, `maintainer:issues` |
| `llm` | Structured XML optimised for agent consumption | `issue:show` (add `--with-comments` to include comment thread), `issue:get-fork`, `mr:list`, `mr:status`, `project:issues`, `project:releases`, `project:release-notes`, `maintainer:issues` |

**Agents should always pass `--format=llm`** to get rich, structured output
with clearly labelled fields, contributor lists, and change records.
Expand All @@ -40,6 +40,9 @@ with clearly labelled fields, contributor lists, and change records.
# Fetch full details for an issue
drupalorg issue:show <nid> --format=llm

# Fetch issue details including all comments (skips system-generated messages)
drupalorg issue:show <nid> --with-comments --format=llm

# Show the GitLab issue fork URLs and branches
# nid is optional; auto-detected from the branch name if omitted
drupalorg issue:get-fork [nid] --format=llm
Expand Down
82 changes: 82 additions & 0 deletions skills/drupalorg-issue-summary-update/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
---
name: drupalorg-issue-summary-update
description: >
Fetches a Drupal.org issue with all its comments and analyses whether the
"Proposed resolution" in the issue summary matches the current discussion
consensus. Drafts an updated summary for the user to paste back.
---

# /drupalorg-issue-summary-update

**Purpose:** Ensure a Drupal.org issue summary's "Proposed resolution" reflects
the latest discussion in the comments.

**Usage:** `/drupalorg-issue-summary-update <nid>`

---

## Instructions

### Step 1: Fetch issue with comments

```bash
drupalorg issue:show <nid> --with-comments --format=llm
```

Report to the user:
- Issue title, status, project
- The current "Proposed resolution" section (extracted from the body)
- A concise summary of what the comments discuss, highlighting the latest direction

> **Note:** Comments from the automated "System Message" user (bot posts about
> MRs being opened/closed) are automatically excluded from the output.

**[PAUSE]** Present your analysis:
- What the proposed resolution currently says
- Where comments agree, diverge, or add new direction
- Which parts of the summary may be out of date

Ask: "Would you like me to draft an updated issue summary?"

---

### Step 2: Draft updated summary

If the user agrees, draft an updated issue summary that:
- Preserves the standard Drupal.org section headings:
- Problem/Motivation
- Proposed resolution
- Remaining tasks
- User interface changes (if applicable)
- API changes (if applicable)
- Data model changes (if applicable)
- Updates "Proposed resolution" to reflect the discussion consensus
- Updates "Remaining tasks" to match what is still outstanding
- Keeps "Problem/Motivation" unchanged unless comments clarify the problem itself

Present the full updated summary text to the user.

**[PAUSE]** Ask: "Does this look correct? Should I adjust anything before you
paste it into the issue?"

---

### Step 3: Guide the user to apply the update

Once the summary is approved, instruct the user:

1. Open the issue: `drupalorg issue:link <nid>`
2. Click "Edit" on the issue node
3. Replace the "Summary" (body) field with the updated text
4. Save the issue

Note: drupalorg-cli is read-only and cannot write to Drupal.org directly.

---

## Notes

- Always use `--with-comments` on `issue:show` to capture the full context.
- Focus on the *latest* comments — earlier comments may reflect resolved debates.
- If `comment_count` is high (>30), note this and ask the user which comment
range is most relevant before fetching (to avoid noise).
75 changes: 73 additions & 2 deletions src/Api/Action/Issue/GetIssueAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,90 @@

namespace mglaman\DrupalOrg\Action\Issue;

use GuzzleHttp\Pool;
use GuzzleHttp\Psr7\Request as GuzzleRequest;
use mglaman\DrupalOrg\Action\ActionInterface;
use mglaman\DrupalOrg\Client;
use mglaman\DrupalOrg\Entity\IssueComment;
use mglaman\DrupalOrg\Result\Issue\IssueResult;
use Psr\Http\Message\ResponseInterface;

class GetIssueAction implements ActionInterface
{
private const COMMENT_CONCURRENCY = 5;

public function __construct(private readonly Client $client)
{
}

public function __invoke(string $nid): IssueResult
public function __invoke(string $nid, bool $withComments = false): IssueResult
{
$issue = $this->client->getNode($nid);
return IssueResult::fromIssueNode($issue);

if (!$withComments || $issue->comments === []) {
return IssueResult::fromIssueNode($issue);
}

// Build an ordered list of [cid, uri] pairs from the comment references.
// We use a plain list (not a cid-keyed map) because PHP coerces numeric
// string array keys to int, which would break typed Pool callbacks.
$commentRefs = [];
foreach ($issue->comments as $ref) {
$cid = (string) ($ref->id ?? '');
if ($cid === '') {
continue;
}
$uri = (string) ($ref->uri ?? '');
if ($uri === '') {
$uri = sprintf('%scomment/%s', Client::API_URL, $cid);
}
$commentRefs[] = ['cid' => $cid, 'uri' => $uri];
}

if ($commentRefs === []) {
return IssueResult::fromIssueNode($issue);
}

$requests = static function () use ($commentRefs): \Generator {
foreach ($commentRefs as $index => $ref) {
yield $index => new GuzzleRequest('GET', $ref['uri']);
}
};

$commentsByIndex = [];
$pool = new Pool($this->client->getGuzzleClient(), $requests(), [
'concurrency' => self::COMMENT_CONCURRENCY,
'fulfilled' => static function (ResponseInterface $response, int $index) use (&$commentsByIndex): void {
try {
$data = json_decode(
(string) $response->getBody(),
false,
512,
JSON_THROW_ON_ERROR
);
$comment = IssueComment::fromStdClass($data);
if ($comment->authorId === '180064') {
return;
}
$commentsByIndex[$index] = $comment;
} catch (\Throwable) {
// skip unparseable responses (JSON errors or unexpected comment payloads)
}
},
'rejected' => static function (\Throwable $reason, int $index): void {
// skip failed comment fetches
},
]);
$pool->promise()->wait();

// Restore original ordering from the issue comment refs.
$comments = [];
foreach (array_keys($commentRefs) as $index) {
if (isset($commentsByIndex[$index])) {
$comments[] = $commentsByIndex[$index];
}
}

return IssueResult::fromIssueNode($issue, $comments);
}
}
37 changes: 37 additions & 0 deletions src/Api/Entity/IssueComment.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

namespace mglaman\DrupalOrg\Entity;

class IssueComment implements \JsonSerializable
{
public function __construct(
public readonly string $cid,
public readonly ?string $bodyValue,
public readonly int $created,
public readonly ?string $authorId,
public readonly string $authorName,
) {
}

public static function fromStdClass(\stdClass $data): self
{
return new self(
cid: (string) ($data->cid ?? ''),
bodyValue: isset($data->comment_body->value) ? (string) $data->comment_body->value : null,
created: (int) ($data->created ?? 0),
authorId: isset($data->author->id) ? (string) $data->author->id : null,
authorName: (string) ($data->name ?? ''),
);
}

public function jsonSerialize(): mixed
{
return [
'cid' => $this->cid,
'body_value' => $this->bodyValue,
'created' => $this->created,
'author_id' => $this->authorId,
'author_name' => $this->authorName,
];
}
}
8 changes: 5 additions & 3 deletions src/Api/Mcp/ToolRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,14 @@ public function __construct(private readonly Client $client)
{
}

#[McpTool(annotations: new ToolAnnotations(readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true), name: 'issue_show', description: 'Get details of a Drupal.org issue.')]
#[McpTool(annotations: new ToolAnnotations(readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true), name: 'issue_show', description: 'Get details of a Drupal.org issue, optionally including comments.')]
public function issueShow(
#[Schema(description: 'The Drupal.org issue node ID.', pattern: self::NID_PATTERN)]
string $nid
string $nid,
#[Schema(description: 'Whether to include issue comments.')]
bool $withComments = false
): mixed {
return (new GetIssueAction($this->client))($nid)->jsonSerialize();
return (new GetIssueAction($this->client))($nid, $withComments)->jsonSerialize();
}

#[McpTool(annotations: new ToolAnnotations(readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true), name: 'issue_get_link', description: 'Get the URL for a Drupal.org issue.')]
Expand Down
12 changes: 11 additions & 1 deletion src/Api/Result/Issue/IssueResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@

namespace mglaman\DrupalOrg\Result\Issue;

use mglaman\DrupalOrg\Entity\IssueComment;
use mglaman\DrupalOrg\Entity\IssueNode;
use mglaman\DrupalOrg\Result\ResultInterface;

class IssueResult implements ResultInterface
{
/**
* @param IssueComment[] $comments
*/
public function __construct(
public readonly string $nid,
public readonly string $title,
Expand All @@ -20,10 +24,14 @@ public function __construct(
public readonly string $fieldProjectMachineName,
public readonly ?string $authorId,
public readonly ?string $bodyValue,
public readonly array $comments = [],
) {
}

public static function fromIssueNode(IssueNode $issue): self
/**
* @param IssueComment[] $comments
*/
public static function fromIssueNode(IssueNode $issue, array $comments = []): self
{
return new self(
nid: $issue->nid,
Expand All @@ -38,6 +46,7 @@ public static function fromIssueNode(IssueNode $issue): self
fieldProjectMachineName: $issue->fieldProjectMachineName,
authorId: $issue->authorId,
bodyValue: $issue->bodyValue,
comments: $comments,
);
}

Expand All @@ -56,6 +65,7 @@ public function jsonSerialize(): mixed
'field_project_machine_name' => $this->fieldProjectMachineName,
'author_id' => $this->authorId,
'body_value' => $this->bodyValue,
'comments' => array_map(static fn(IssueComment $c) => $c->jsonSerialize(), $this->comments),
];
}
}
17 changes: 16 additions & 1 deletion src/Cli/Command/Issue/Show.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,15 @@ protected function configure(): void
'Output options: text, json, md, llm. Defaults to text.',
'text'
)
->addOption('with-comments', null, InputOption::VALUE_NONE, 'Also fetch issue comments.')
->setDescription('Show a given issue information.');
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$nid = $this->stdIn->getArgument('nid');
$result = (new GetIssueAction($this->client))($nid);
$withComments = (bool) $this->stdIn->getOption('with-comments');
$result = (new GetIssueAction($this->client))($nid, $withComments);
$format = $this->stdIn->getOption('format');

if ($this->writeFormatted($result, (string) $format)) {
Expand All @@ -49,6 +51,19 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$this->stdOut->writeln(sprintf('Created: %s', date('r', $result->created)));
$this->stdOut->writeln(sprintf('Updated: %s', date('r', $result->changed)));
$this->stdOut->writeln(sprintf("\nIssue summary:\n%s", strip_tags($result->bodyValue ?? '')));
if ($result->comments !== []) {
$this->stdOut->writeln('');
foreach ($result->comments as $index => $comment) {
$this->stdOut->writeln(sprintf(
"Comment #%d by %s (%s)",
$index + 1,
$comment->authorName,
date('r', $comment->created)
));
$this->stdOut->writeln(strip_tags($comment->bodyValue ?? ''));
$this->stdOut->writeln('');
}
}
return 0;
}
}
26 changes: 25 additions & 1 deletion src/Cli/Formatter/LlmFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,24 @@ protected function formatIssue(IssueResult $result): string
$updated = $this->toIso8601($result->changed);
$description = $this->stripAndEscape($result->bodyValue ?? '');

$commentsXml = '';
if ($result->comments !== []) {
$commentsXml = "\n <comments>";
foreach ($result->comments as $index => $comment) {
$number = $index + 1;
$author = $this->xmlEscape($comment->authorName);
$commentCreated = $this->toIso8601($comment->created);
$body = $this->cdataWrap($comment->bodyValue ?? '');
$commentsXml .= "\n <comment>";
$commentsXml .= "\n <number>{$number}</number>";
$commentsXml .= "\n <author>{$author}</author>";
$commentsXml .= "\n <created>{$commentCreated}</created>";
$commentsXml .= "\n <body>{$body}</body>";
$commentsXml .= "\n </comment>";
}
$commentsXml .= "\n </comments>";
}

return <<<XML
<drupal_context>
<issue_id>{$nid}</issue_id>
Expand All @@ -41,7 +59,7 @@ protected function formatIssue(IssueResult $result): string
<component>{$component}</component>
<created>{$created}</created>
<updated>{$updated}</updated>
<description>{$description}</description>
<description>{$description}</description>{$commentsXml}
</drupal_context>
XML;
}
Expand Down Expand Up @@ -178,4 +196,10 @@ private function stripAndEscape(string $value): string
{
return $this->xmlEscape(strip_tags($value));
}

private function cdataWrap(string $value): string
{
// CDATA sections cannot contain ']]>', so split any occurrence.
return '<![CDATA[' . str_replace(']]>', ']]]]><![CDATA[>', $value) . ']]>';
}
}
Loading