From 6001a483a18c54cf02d12952660a4ef78b149e34 Mon Sep 17 00:00:00 2001 From: Matt Glaman Date: Fri, 27 Feb 2026 13:56:10 -0600 Subject: [PATCH 1/3] Add issue:show --with-comments and drupalorg-issue-summary-update skill Implements #290. Fetches all comments on an issue concurrently via async Guzzle promises, filters out system-generated bot comments (author ID 180064), and surfaces them in text/json/md/llm output. - Add IssueComment entity (comment_body.value, author, created) - GetIssueAction: accept $withComments=false; fetch+settle promises - IssueResult: add $comments array; include in jsonSerialize() - issue:show: --with-comments option; text format appends comment block - LlmFormatter: XML block inside - MarkdownFormatter: ## Comments section with per-comment headings - ToolRegistry: issueShow gains bool $withComments=false parameter - Add /drupalorg-issue-summary-update agentic skill - Full test coverage: entity, action (incl. system-msg filter), formatters Co-Authored-By: Claude Sonnet 4.6 --- skills/drupalorg-cli/SKILL.md | 5 +- .../drupalorg-issue-summary-update/SKILL.md | 82 ++++++++++++++++ src/Api/Action/Issue/GetIssueAction.php | 75 +++++++++++++- src/Api/Entity/IssueComment.php | 37 +++++++ src/Api/Mcp/ToolRegistry.php | 8 +- src/Api/Result/Issue/IssueResult.php | 12 ++- src/Cli/Command/Issue/Show.php | 17 +++- src/Cli/Formatter/LlmFormatter.php | 26 ++++- src/Cli/Formatter/MarkdownFormatter.php | 15 +++ tests/fixtures/comment_node.json | 9 ++ tests/src/Action/Issue/GetIssueActionTest.php | 97 +++++++++++++++++++ tests/src/Entity/IssueCommentTest.php | 61 ++++++++++++ tests/src/Formatter/LlmFormatterTest.php | 48 +++++++++ tests/src/Formatter/MarkdownFormatterTest.php | 43 ++++++++ 14 files changed, 526 insertions(+), 9 deletions(-) create mode 100644 skills/drupalorg-issue-summary-update/SKILL.md create mode 100644 src/Api/Entity/IssueComment.php create mode 100644 tests/fixtures/comment_node.json create mode 100644 tests/src/Entity/IssueCommentTest.php diff --git a/skills/drupalorg-cli/SKILL.md b/skills/drupalorg-cli/SKILL.md index e191837..8e1d629 100644 --- a/skills/drupalorg-cli/SKILL.md +++ b/skills/drupalorg-cli/SKILL.md @@ -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. @@ -40,6 +40,9 @@ with clearly labelled fields, contributor lists, and change records. # Fetch full details for an issue drupalorg issue:show --format=llm +# Fetch issue details including all comments (skips system-generated messages) +drupalorg issue:show --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 diff --git a/skills/drupalorg-issue-summary-update/SKILL.md b/skills/drupalorg-issue-summary-update/SKILL.md new file mode 100644 index 0000000..ab8efd3 --- /dev/null +++ b/skills/drupalorg-issue-summary-update/SKILL.md @@ -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 ` + +--- + +## Instructions + +### Step 1: Fetch issue with comments + +```bash +drupalorg issue:show --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 ` +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). diff --git a/src/Api/Action/Issue/GetIssueAction.php b/src/Api/Action/Issue/GetIssueAction.php index 4c24320..50c0500 100644 --- a/src/Api/Action/Issue/GetIssueAction.php +++ b/src/Api/Action/Issue/GetIssueAction.php @@ -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.json', 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 (\JsonException) { + // skip unparseable responses + } + }, + '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); } } diff --git a/src/Api/Entity/IssueComment.php b/src/Api/Entity/IssueComment.php new file mode 100644 index 0000000..f0dda8e --- /dev/null +++ b/src/Api/Entity/IssueComment.php @@ -0,0 +1,37 @@ +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, + ]; + } +} diff --git a/src/Api/Mcp/ToolRegistry.php b/src/Api/Mcp/ToolRegistry.php index 84894f5..623000d 100644 --- a/src/Api/Mcp/ToolRegistry.php +++ b/src/Api/Mcp/ToolRegistry.php @@ -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.')] diff --git a/src/Api/Result/Issue/IssueResult.php b/src/Api/Result/Issue/IssueResult.php index f13c0a5..d942f88 100644 --- a/src/Api/Result/Issue/IssueResult.php +++ b/src/Api/Result/Issue/IssueResult.php @@ -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, @@ -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, @@ -38,6 +46,7 @@ public static function fromIssueNode(IssueNode $issue): self fieldProjectMachineName: $issue->fieldProjectMachineName, authorId: $issue->authorId, bodyValue: $issue->bodyValue, + comments: $comments, ); } @@ -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), ]; } } diff --git a/src/Cli/Command/Issue/Show.php b/src/Cli/Command/Issue/Show.php index aee5f89..f56ed15 100644 --- a/src/Cli/Command/Issue/Show.php +++ b/src/Cli/Command/Issue/Show.php @@ -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)) { @@ -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; } } diff --git a/src/Cli/Formatter/LlmFormatter.php b/src/Cli/Formatter/LlmFormatter.php index 22ee2dd..7f14846 100644 --- a/src/Cli/Formatter/LlmFormatter.php +++ b/src/Cli/Formatter/LlmFormatter.php @@ -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 "; + 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 "; + $commentsXml .= "\n {$number}"; + $commentsXml .= "\n {$author}"; + $commentsXml .= "\n {$commentCreated}"; + $commentsXml .= "\n {$body}"; + $commentsXml .= "\n "; + } + $commentsXml .= "\n "; + } + return << {$nid} @@ -41,7 +59,7 @@ protected function formatIssue(IssueResult $result): string {$component} {$created} {$updated} - {$description} + {$description}{$commentsXml} XML; } @@ -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 '', ']]]]>', $value) . ']]>'; + } } diff --git a/src/Cli/Formatter/MarkdownFormatter.php b/src/Cli/Formatter/MarkdownFormatter.php index 06db3a7..07bc17b 100644 --- a/src/Cli/Formatter/MarkdownFormatter.php +++ b/src/Cli/Formatter/MarkdownFormatter.php @@ -33,6 +33,21 @@ protected function formatIssue(IssueResult $result): string $lines[] = '## Summary'; $lines[] = ''; $lines[] = strip_tags($result->bodyValue ?? ''); + if ($result->comments !== []) { + $lines[] = ''; + $lines[] = '## Comments'; + foreach ($result->comments as $index => $comment) { + $lines[] = ''; + $lines[] = sprintf( + '### Comment #%d — %s (%s)', + $index + 1, + $comment->authorName, + date('c', $comment->created) + ); + $lines[] = ''; + $lines[] = strip_tags($comment->bodyValue ?? ''); + } + } return implode("\n", $lines); } diff --git a/tests/fixtures/comment_node.json b/tests/fixtures/comment_node.json new file mode 100644 index 0000000..1a9554b --- /dev/null +++ b/tests/fixtures/comment_node.json @@ -0,0 +1,9 @@ +{ + "cid": "15671234", + "subject": "Example comment subject", + "comment_body": { "value": "

Comment body.

", "format": "1" }, + "created": "1700000000", + "name": "testuser", + "author": { "uri": "https://www.drupal.org/api-d7/user/99999", "id": "99999", "resource": "user" }, + "node": { "uri": "https://www.drupal.org/api-d7/node/3383637", "id": "3383637", "resource": "node" } +} diff --git a/tests/src/Action/Issue/GetIssueActionTest.php b/tests/src/Action/Issue/GetIssueActionTest.php index fa0913a..4874478 100644 --- a/tests/src/Action/Issue/GetIssueActionTest.php +++ b/tests/src/Action/Issue/GetIssueActionTest.php @@ -2,8 +2,12 @@ namespace mglaman\DrupalOrg\Tests\Action\Issue; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Psr7\Response; use mglaman\DrupalOrg\Action\Issue\GetIssueAction; use mglaman\DrupalOrg\Client; +use mglaman\DrupalOrg\Entity\IssueComment; use mglaman\DrupalOrg\Entity\IssueNode; use mglaman\DrupalOrg\Result\Issue\IssueResult; use PHPUnit\Framework\Attributes\CoversClass; @@ -23,6 +27,11 @@ private static function fixture(): \stdClass ); } + private static function commentFixture(): string + { + return file_get_contents(__DIR__ . '/../../../fixtures/comment_node.json'); + } + public function testInvoke(): void { $issueNode = IssueNode::fromStdClass(self::fixture()); @@ -45,6 +54,93 @@ public function testInvoke(): void self::assertSame('3643629', $result->authorId); self::assertSame(1693195104, $result->created); self::assertSame(1727653295, $result->changed); + self::assertSame([], $result->comments); + } + + public function testInvokeWithoutCommentsFlag(): void + { + $issueData = self::fixture(); + $commentRef = new \stdClass(); + $commentRef->uri = 'https://www.drupal.org/api-d7/comment/15671234'; + $commentRef->id = '15671234'; + $commentRef->resource = 'comment'; + $issueData->comments = [$commentRef]; + + $issueNode = IssueNode::fromStdClass($issueData); + + $client = $this->createMock(Client::class); + $client->method('getNode')->willReturn($issueNode); + + $action = new GetIssueAction($client); + $result = $action('3383637', false); + + self::assertSame([], $result->comments); + } + + public function testInvokeWithComments(): void + { + $issueData = self::fixture(); + $commentRef = new \stdClass(); + $commentRef->uri = 'https://www.drupal.org/api-d7/comment/15671234'; + $commentRef->id = '15671234'; + $commentRef->resource = 'comment'; + $issueData->comments = [$commentRef]; + + $issueNode = IssueNode::fromStdClass($issueData); + + $mock = new MockHandler([ + new Response(200, [], self::commentFixture()), + ]); + $handlerStack = HandlerStack::create($mock); + $guzzleClient = new \GuzzleHttp\Client(['handler' => $handlerStack]); + + $client = $this->createMock(Client::class); + $client->method('getNode')->willReturn($issueNode); + $client->method('getGuzzleClient')->willReturn($guzzleClient); + + $action = new GetIssueAction($client); + $result = $action('3383637', true); + + self::assertCount(1, $result->comments); + self::assertInstanceOf(IssueComment::class, $result->comments[0]); + self::assertSame('15671234', $result->comments[0]->cid); + self::assertSame('testuser', $result->comments[0]->authorName); + } + + public function testInvokeWithCommentsFiltersSystemMessages(): void + { + $issueData = self::fixture(); + $commentRef = new \stdClass(); + $commentRef->uri = 'https://www.drupal.org/api-d7/comment/99887766'; + $commentRef->id = '99887766'; + $commentRef->resource = 'comment'; + $issueData->comments = [$commentRef]; + + $issueNode = IssueNode::fromStdClass($issueData); + + $systemCommentData = [ + 'cid' => '99887766', + 'subject' => 'Status: Needs work', + 'comment_body' => ['value' => '

System message.

', 'format' => '1'], + 'created' => '1700000000', + 'name' => 'System Message', + 'author' => ['uri' => 'https://www.drupal.org/api-d7/user/180064', 'id' => '180064', 'resource' => 'user'], + ]; + + $mock = new MockHandler([ + new Response(200, [], json_encode($systemCommentData)), + ]); + $handlerStack = HandlerStack::create($mock); + $guzzleClient = new \GuzzleHttp\Client(['handler' => $handlerStack]); + + $client = $this->createMock(Client::class); + $client->method('getNode')->willReturn($issueNode); + $client->method('getGuzzleClient')->willReturn($guzzleClient); + + $action = new GetIssueAction($client); + $result = $action('3383637', true); + + self::assertSame([], $result->comments); } public function testJsonSerialize(): void @@ -63,5 +159,6 @@ public function testJsonSerialize(): void self::assertSame('3383637', $decoded['nid']); self::assertSame(6, $decoded['field_issue_status']); self::assertSame('drupal', $decoded['field_project_machine_name']); + self::assertSame([], $decoded['comments']); } } diff --git a/tests/src/Entity/IssueCommentTest.php b/tests/src/Entity/IssueCommentTest.php new file mode 100644 index 0000000..151806e --- /dev/null +++ b/tests/src/Entity/IssueCommentTest.php @@ -0,0 +1,61 @@ +cid); + self::assertSame('

Comment body.

', $comment->bodyValue); + self::assertSame(1700000000, $comment->created); + self::assertSame('99999', $comment->authorId); + self::assertSame('testuser', $comment->authorName); + } + + public function testJsonSerialize(): void + { + $comment = IssueComment::fromStdClass(self::fixture()); + + $json = json_encode($comment); + self::assertIsString($json); + $decoded = json_decode($json, true); + self::assertSame('15671234', $decoded['cid']); + self::assertArrayNotHasKey('subject', $decoded); + self::assertSame('

Comment body.

', $decoded['body_value']); + self::assertSame(1700000000, $decoded['created']); + self::assertSame('99999', $decoded['author_id']); + self::assertSame('testuser', $decoded['author_name']); + } + + public function testFromStdClassWithMissingBody(): void + { + $data = new \stdClass(); + $data->cid = '1'; + $data->created = '1700000000'; + $data->name = 'user1'; + $data->author = new \stdClass(); + $data->author->id = '12345'; + + $comment = IssueComment::fromStdClass($data); + + self::assertNull($comment->bodyValue); + } +} diff --git a/tests/src/Formatter/LlmFormatterTest.php b/tests/src/Formatter/LlmFormatterTest.php index c1a4bf7..252928f 100644 --- a/tests/src/Formatter/LlmFormatterTest.php +++ b/tests/src/Formatter/LlmFormatterTest.php @@ -2,6 +2,7 @@ namespace mglaman\DrupalOrg\Tests\Formatter; +use mglaman\DrupalOrg\Entity\IssueComment; use mglaman\DrupalOrg\Entity\IssueNode; use mglaman\DrupalOrg\Entity\Release; use mglaman\DrupalOrg\Result\Issue\IssueForkResult; @@ -94,6 +95,53 @@ public function testIssueResult(): void self::assertStringContainsString('Issue body content.', $output); } + public function testIssueResultWithComments(): void + { + $comment = new IssueComment( + cid: '15671234', + bodyValue: '

LGTM with nits.

', + created: 1700000000, + authorId: '99999', + authorName: 'reviewer', + ); + $result = new IssueResult( + nid: '3383637', + title: 'Schedule transition button size', + created: 1693195104, + changed: 1727653295, + fieldIssueStatus: 1, + fieldIssueCategory: 1, + fieldIssuePriority: 200, + fieldIssueVersion: '11.x-dev', + fieldIssueComponent: 'Claro theme', + fieldProjectMachineName: 'drupal', + authorId: '3643629', + bodyValue: '

Issue body.

', + comments: [$comment], + ); + + $formatter = new LlmFormatter(); + $output = $formatter->format($result); + + self::assertStringContainsString('', $output); + self::assertStringContainsString('', $output); + self::assertStringContainsString('1', $output); + self::assertStringContainsString('reviewer', $output); + self::assertStringContainsString('', $output); + self::assertStringContainsString('LGTM', $output); + self::assertStringContainsString('', $output); + self::assertStringContainsString('', $output); + } + + public function testIssueResultWithoutCommentsHasNoCommentsBlock(): void + { + $formatter = new LlmFormatter(); + $output = $formatter->format(self::makeIssueResult()); + + self::assertStringNotContainsString('', $output); + } + public function testProjectIssuesResult(): void { $result = new ProjectIssuesResult( diff --git a/tests/src/Formatter/MarkdownFormatterTest.php b/tests/src/Formatter/MarkdownFormatterTest.php index eaa3302..0731dec 100644 --- a/tests/src/Formatter/MarkdownFormatterTest.php +++ b/tests/src/Formatter/MarkdownFormatterTest.php @@ -2,6 +2,7 @@ namespace mglaman\DrupalOrg\Tests\Formatter; +use mglaman\DrupalOrg\Entity\IssueComment; use mglaman\DrupalOrg\Entity\IssueNode; use mglaman\DrupalOrg\Entity\Release; use mglaman\DrupalOrg\Result\Issue\IssueForkResult; @@ -90,6 +91,48 @@ public function testIssueResult(): void self::assertStringNotContainsString('

', $output); } + public function testIssueResultWithComments(): void + { + $comment = new IssueComment( + cid: '15671234', + bodyValue: '

LGTM with nits.

', + created: 1700000000, + authorId: '99999', + authorName: 'reviewer', + ); + $result = new IssueResult( + nid: '3383637', + title: 'Schedule transition button size', + created: 1693195104, + changed: 1727653295, + fieldIssueStatus: 1, + fieldIssueCategory: 1, + fieldIssuePriority: 200, + fieldIssueVersion: '11.x-dev', + fieldIssueComponent: 'Claro theme', + fieldProjectMachineName: 'drupal', + authorId: '3643629', + bodyValue: '

Issue body.

', + comments: [$comment], + ); + + $formatter = new MarkdownFormatter(); + $output = $formatter->format($result); + + self::assertStringContainsString('## Comments', $output); + self::assertStringContainsString('### Comment #1 — reviewer', $output); + self::assertStringContainsString('LGTM', $output); + self::assertStringNotContainsString('', $output); + } + + public function testIssueResultWithoutCommentsHasNoCommentsSection(): void + { + $formatter = new MarkdownFormatter(); + $output = $formatter->format(self::makeIssueResult()); + + self::assertStringNotContainsString('## Comments', $output); + } + public function testProjectIssuesResult(): void { $result = new ProjectIssuesResult( From a9ac3138c7258853ea42eb789dadec2a0b3a33bd Mon Sep 17 00:00:00 2001 From: Matt Glaman Date: Fri, 27 Feb 2026 15:12:34 -0600 Subject: [PATCH 2/3] Update src/Api/Action/Issue/GetIssueAction.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Api/Action/Issue/GetIssueAction.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Api/Action/Issue/GetIssueAction.php b/src/Api/Action/Issue/GetIssueAction.php index 50c0500..da387a7 100644 --- a/src/Api/Action/Issue/GetIssueAction.php +++ b/src/Api/Action/Issue/GetIssueAction.php @@ -37,7 +37,7 @@ public function __invoke(string $nid, bool $withComments = false): IssueResult } $uri = (string) ($ref->uri ?? ''); if ($uri === '') { - $uri = sprintf('%scomment/%s.json', Client::API_URL, $cid); + $uri = sprintf('%scomment/%s', Client::API_URL, $cid); } $commentRefs[] = ['cid' => $cid, 'uri' => $uri]; } From c877a726db72c65ba951e1af45383a364e263eb3 Mon Sep 17 00:00:00 2001 From: Matt Glaman Date: Fri, 27 Feb 2026 15:12:45 -0600 Subject: [PATCH 3/3] Update src/Api/Action/Issue/GetIssueAction.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Api/Action/Issue/GetIssueAction.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Api/Action/Issue/GetIssueAction.php b/src/Api/Action/Issue/GetIssueAction.php index da387a7..de3c50c 100644 --- a/src/Api/Action/Issue/GetIssueAction.php +++ b/src/Api/Action/Issue/GetIssueAction.php @@ -68,8 +68,8 @@ public function __invoke(string $nid, bool $withComments = false): IssueResult return; } $commentsByIndex[$index] = $comment; - } catch (\JsonException) { - // skip unparseable responses + } catch (\Throwable) { + // skip unparseable responses (JSON errors or unexpected comment payloads) } }, 'rejected' => static function (\Throwable $reason, int $index): void {