diff --git a/skill-data/drupalorg-cli/SKILL.md b/skill-data/drupalorg-cli/SKILL.md index 91a89a1..dea6e16 100644 --- a/skill-data/drupalorg-cli/SKILL.md +++ b/skill-data/drupalorg-cli/SKILL.md @@ -85,9 +85,13 @@ with clearly labelled fields, contributor lists, and change records. drupalorg issue:show --format=llm # Fetch issue details including all comments (skips system-generated messages) -# Note: --with-comments only applies to D.o issues +# Works for both D.o issues and GitLab work items drupalorg issue:show --with-comments --format=llm +# GitLab work items only: also keep drupalbot replies to slash commands. +# Off by default because the work item's labels and assignees already reflect them. +drupalorg issue:show --with-comments --include-bot-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/skill-data/drupalorg-cli/references/ai-contribution-policy.md b/skill-data/drupalorg-cli/references/ai-contribution-policy.md index d2cb4a6..90ee15d 100644 --- a/skill-data/drupalorg-cli/references/ai-contribution-policy.md +++ b/skill-data/drupalorg-cli/references/ai-contribution-policy.md @@ -21,13 +21,6 @@ drupalorg issue:show --with-comments --format=llm drupalorg mr:list --state=all --format=llm ``` -`--with-comments` only returns comments for classic Drupal.org issues. For GitLab -work items, read the discussion with `glab` or ask the user to summarize it: - -```bash -GITLAB_HOST=git.drupalcode.org glab issue view --comments --repo project/ -``` - From that output, report to the user before proposing a change: - Previous attempts (patches, MRs, closed MRs) and why they stalled. diff --git a/skill-data/drupalorg-work-on-issue/SKILL.md b/skill-data/drupalorg-work-on-issue/SKILL.md index eb10a1e..875259a 100644 --- a/skill-data/drupalorg-work-on-issue/SKILL.md +++ b/skill-data/drupalorg-work-on-issue/SKILL.md @@ -55,13 +55,6 @@ into an issue that ignores the discussion or reopens settled decisions is a poli violation. If your reading of the code disagrees with the thread's direction, say so to the user and let them raise it in the issue. Do not act on it unilaterally. -`--with-comments` is ignored for GitLab work items. For those, read the discussion -with `glab` or ask the user to summarize it: - -```bash -GITLAB_HOST=git.drupalcode.org glab issue view --comments --repo project/ -``` - **Directory detection:** Before prompting the user, read `CLAUDE.md` in the current directory. If it documents the path to the `` module or repository, `cd` there automatically and skip the directory prompt. Only fall back to running `git remote get-url origin` and diff --git a/src/Api/Action/GitLab/GetGitLabIssueAction.php b/src/Api/Action/GitLab/GetGitLabIssueAction.php index f198690..3c8f73f 100644 --- a/src/Api/Action/GitLab/GetGitLabIssueAction.php +++ b/src/Api/Action/GitLab/GetGitLabIssueAction.php @@ -6,18 +6,40 @@ use mglaman\DrupalOrg\GitLab\Client as GitLabClient; use mglaman\DrupalOrg\GitLab\Entity\GitLabIssue; +use mglaman\DrupalOrg\GitLab\Entity\GitLabNote; use mglaman\DrupalOrg\GitLab\WorkItemRef; use mglaman\DrupalOrg\Result\GitLab\GitLabIssueResult; class GetGitLabIssueAction { + /** + * The Drupal.org bot that answers slash commands on work items. + */ + public const BOT_USERNAME = 'drupalbot'; + public function __construct(private readonly GitLabClient $gitLabClient) { } - public function __invoke(WorkItemRef $ref): GitLabIssueResult + public function __invoke(WorkItemRef $ref, bool $withComments = false, bool $includeBotComments = false): GitLabIssueResult { $data = $this->gitLabClient->getIssue($ref->projectPath, $ref->issueId); - return new GitLabIssueResult(GitLabIssue::fromStdClass($data)); + $issue = GitLabIssue::fromStdClass($data); + if (!$withComments) { + return new GitLabIssueResult($issue); + } + + $comments = []; + foreach ($this->gitLabClient->getIssueNotes($ref->projectPath, $ref->issueId) as $noteData) { + $note = GitLabNote::fromStdClass($noteData); + if ($note->system) { + continue; + } + if (!$includeBotComments && $note->author === self::BOT_USERNAME) { + continue; + } + $comments[] = $note; + } + return new GitLabIssueResult($issue, $comments); } } diff --git a/src/Api/GitLab/Client.php b/src/Api/GitLab/Client.php index 77ac8be..67e72a0 100644 --- a/src/Api/GitLab/Client.php +++ b/src/Api/GitLab/Client.php @@ -116,6 +116,29 @@ public function postIssueNote(string $projectPath, int $iid, string $body): \std return $result; } + /** + * GET /projects/{path}/issues/{iid}/notes + * + * Returns every note on a GitLab issue or work item in chronological order, + * following pagination. Callers decide whether to keep system notes. + * + * @return \stdClass[] + * @throws \Exception + */ + public function getIssueNotes(string $projectPath, int $iid): array + { + $path = 'projects/' . urlencode($projectPath) . '/issues/' . $iid . '/notes'; + $notes = []; + $page = 1; + do { + $result = $this->get($path, ['per_page' => 100, 'page' => $page, 'sort' => 'asc', 'order_by' => 'created_at']); + $batch = is_array($result) ? $result : []; + $notes = [...$notes, ...$batch]; + $page++; + } while (count($batch) === 100); + return $notes; + } + /** * GET /projects/{path} * diff --git a/src/Api/GitLab/Entity/GitLabNote.php b/src/Api/GitLab/Entity/GitLabNote.php new file mode 100644 index 0000000..63c6bf7 --- /dev/null +++ b/src/Api/GitLab/Entity/GitLabNote.php @@ -0,0 +1,38 @@ +id ?? 0), + body: (string) ($data->body ?? ''), + author: (string) ($data->author->username ?? $data->author->name ?? ''), + createdAt: (string) ($data->created_at ?? ''), + system: (bool) ($data->system ?? false), + ); + } + + public function jsonSerialize(): mixed + { + return [ + 'id' => $this->id, + 'body' => $this->body, + 'author' => $this->author, + 'created_at' => $this->createdAt, + ]; + } +} diff --git a/src/Api/Result/GitLab/GitLabIssueResult.php b/src/Api/Result/GitLab/GitLabIssueResult.php index 8a8ea41..eb999e5 100644 --- a/src/Api/Result/GitLab/GitLabIssueResult.php +++ b/src/Api/Result/GitLab/GitLabIssueResult.php @@ -5,12 +5,17 @@ namespace mglaman\DrupalOrg\Result\GitLab; use mglaman\DrupalOrg\GitLab\Entity\GitLabIssue; +use mglaman\DrupalOrg\GitLab\Entity\GitLabNote; use mglaman\DrupalOrg\Result\ResultInterface; class GitLabIssueResult implements ResultInterface { + /** + * @param GitLabNote[] $comments + */ public function __construct( public readonly GitLabIssue $issue, + public readonly array $comments = [], ) { } @@ -27,6 +32,7 @@ public function jsonSerialize(): mixed 'web_url' => $this->issue->webUrl, 'author' => $this->issue->author, 'assignees' => $this->issue->assignees, + 'comments' => array_map(static fn(GitLabNote $note) => $note->jsonSerialize(), $this->comments), ]; } } diff --git a/src/Cli/Command/Command.php b/src/Cli/Command/Command.php index 3807c50..55e5091 100644 --- a/src/Cli/Command/Command.php +++ b/src/Cli/Command/Command.php @@ -67,7 +67,7 @@ protected function writeFormatted(ResultInterface $result, string $format): bool 'llm' => new LlmFormatter(), default => throw new \InvalidArgumentException("Unknown format: $format"), }; - $this->stdOut->writeln($formatter->format($result)); + $this->stdOut->writeln($formatter->format($result), OutputInterface::OUTPUT_RAW); return true; } diff --git a/src/Cli/Command/Issue/Show.php b/src/Cli/Command/Issue/Show.php index 6a69147..033d51b 100644 --- a/src/Cli/Command/Issue/Show.php +++ b/src/Cli/Command/Issue/Show.php @@ -29,7 +29,8 @@ 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.') + ->addOption('with-comments', null, InputOption::VALUE_NONE, 'Also fetch issue comments. System-generated messages are skipped.') + ->addOption('include-bot-comments', null, InputOption::VALUE_NONE, 'Keep drupalbot replies when fetching GitLab work item comments. Off by default because the work item fields already reflect label and assignee changes.') ->setDescription('Show a given issue information.'); } @@ -38,9 +39,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int $nid = $this->stdIn->getArgument('nid'); $format = $this->stdIn->getOption('format'); + $withComments = (bool) $this->stdIn->getOption('with-comments'); $ref = WorkItemRef::tryParse((string) $nid); if ($ref !== null) { - $result = (new GetGitLabIssueAction(new GitLabClient()))($ref); + $includeBotComments = (bool) $this->stdIn->getOption('include-bot-comments'); + $result = (new GetGitLabIssueAction(new GitLabClient()))($ref, $withComments, $includeBotComments); if ($this->writeFormatted($result, (string) $format)) { return 0; } @@ -58,10 +61,18 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->stdOut->writeln(sprintf('Updated: %s', $issue->updatedAt)); $this->stdOut->writeln(sprintf('URL: %s', $issue->webUrl)); $this->stdOut->writeln(sprintf("\nDescription:\n%s", $issue->description)); + foreach ($result->comments as $index => $comment) { + $this->stdOut->writeln(sprintf( + "\nComment #%d by %s (%s):\n%s", + $index + 1, + $comment->author, + $comment->createdAt, + $comment->body + )); + } return 0; } - $withComments = (bool) $this->stdIn->getOption('with-comments'); $result = (new GetIssueAction($this->client))($nid, $withComments); if ($this->writeFormatted($result, (string) $format)) { diff --git a/src/Cli/Formatter/LlmFormatter.php b/src/Cli/Formatter/LlmFormatter.php index f7dd360..96b7448 100644 --- a/src/Cli/Formatter/LlmFormatter.php +++ b/src/Cli/Formatter/LlmFormatter.php @@ -274,6 +274,24 @@ protected function formatGitLabIssue(GitLabIssueResult $result): string $assigneesXml .= ' ' . $this->xmlEscape($assignee) . "\n"; } + $commentsXml = ''; + if ($result->comments !== []) { + $commentsXml = "\n "; + foreach ($result->comments as $index => $comment) { + $number = $index + 1; + $commentAuthor = $this->xmlEscape($comment->author); + $commentCreated = $this->xmlEscape($comment->createdAt); + $body = $this->cdataWrap($comment->body); + $commentsXml .= "\n "; + $commentsXml .= "\n {$number}"; + $commentsXml .= "\n {$commentAuthor}"; + $commentsXml .= "\n {$commentCreated}"; + $commentsXml .= "\n {$body}"; + $commentsXml .= "\n "; + } + $commentsXml .= "\n "; + } + return << {$issue->iid} @@ -287,7 +305,7 @@ protected function formatGitLabIssue(GitLabIssueResult $result): string {$labelsXml} {$assigneesXml} - {$description} + {$description}{$commentsXml} XML; } diff --git a/src/Cli/Formatter/MarkdownFormatter.php b/src/Cli/Formatter/MarkdownFormatter.php index 2f2ba95..1d06148 100644 --- a/src/Cli/Formatter/MarkdownFormatter.php +++ b/src/Cli/Formatter/MarkdownFormatter.php @@ -227,6 +227,16 @@ protected function formatGitLabIssue(GitLabIssueResult $result): string $lines[] = '## Description'; $lines[] = ''; $lines[] = $issue->description; + if ($result->comments !== []) { + $lines[] = ''; + $lines[] = '## Comments'; + foreach ($result->comments as $index => $comment) { + $lines[] = ''; + $lines[] = sprintf('### Comment #%d — %s (%s)', $index + 1, $comment->author, $comment->createdAt); + $lines[] = ''; + $lines[] = $comment->body; + } + } return implode("\n", $lines); } diff --git a/tests/src/Action/GitLab/GetGitLabIssueActionTest.php b/tests/src/Action/GitLab/GetGitLabIssueActionTest.php new file mode 100644 index 0000000..e5fa456 --- /dev/null +++ b/tests/src/Action/GitLab/GetGitLabIssueActionTest.php @@ -0,0 +1,134 @@ + 3586157, + 'title' => 'Example AI context issue', + 'description' => 'Body', + 'state' => 'opened', + 'labels' => ['state::needsReview'], + 'created_at' => '2025-01-01T00:00:00Z', + 'updated_at' => '2025-01-02T00:00:00Z', + 'web_url' => 'https://git.drupalcode.org/project/ai_context/-/work_items/3586157', + 'author' => (object) ['username' => 'reporter'], + 'assignees' => [], + ]; + } + + /** + * @return \stdClass[] + */ + private static function makeNotes(): array + { + return [ + (object) [ + 'id' => 1, + 'body' => 'added label state::needsReview', + 'system' => true, + 'author' => (object) ['username' => 'drupalorg-bot'], + 'created_at' => '2025-01-01T01:00:00Z', + ], + (object) [ + 'id' => 4, + 'body' => 'Fork created: issue/ai_context-3586157', + 'system' => false, + 'author' => (object) ['username' => 'drupalbot'], + 'created_at' => '2025-01-01T01:30:00Z', + ], + (object) [ + 'id' => 2, + 'body' => 'Reviewed the approach, looks good.', + 'system' => false, + 'author' => (object) ['username' => 'reviewer'], + 'created_at' => '2025-01-01T02:00:00Z', + ], + (object) [ + 'id' => 3, + 'body' => 'Addressed feedback.', + 'author' => (object) ['name' => 'Contributor Name'], + 'created_at' => '2025-01-01T03:00:00Z', + ], + ]; + } + + private static function makeRef(): WorkItemRef + { + $ref = WorkItemRef::tryParse('ai_context#3586157'); + self::assertNotNull($ref); + return $ref; + } + + public function testWithoutCommentsSkipsNotesRequest(): void + { + $gitLabClient = $this->createMock(GitLabClient::class); + $gitLabClient->method('getIssue')->willReturn(self::makeIssue()); + $gitLabClient->expects(self::never())->method('getIssueNotes'); + + $result = (new GetGitLabIssueAction($gitLabClient))(self::makeRef()); + + self::assertSame(3586157, $result->issue->iid); + self::assertSame([], $result->comments); + self::assertSame([], $result->jsonSerialize()['comments']); + } + + public function testWithCommentsDropsSystemAndBotNotes(): void + { + $gitLabClient = $this->createMock(GitLabClient::class); + $gitLabClient->method('getIssue')->willReturn(self::makeIssue()); + $gitLabClient->expects(self::once()) + ->method('getIssueNotes') + ->with('project/ai_context', 3586157) + ->willReturn(self::makeNotes()); + + $result = (new GetGitLabIssueAction($gitLabClient))(self::makeRef(), true); + + self::assertCount(2, $result->comments); + self::assertSame('reviewer', $result->comments[0]->author); + self::assertSame(['reviewer', 'Contributor Name'], array_map(static fn(GitLabNote $n) => $n->author, $result->comments)); + self::assertSame('Reviewed the approach, looks good.', $result->comments[0]->body); + self::assertSame('Contributor Name', $result->comments[1]->author); + + $json = $result->jsonSerialize(); + self::assertSame( + [ + 'id' => 2, + 'body' => 'Reviewed the approach, looks good.', + 'author' => 'reviewer', + 'created_at' => '2025-01-01T02:00:00Z', + ], + $json['comments'][0] + ); + } + + public function testIncludeBotCommentsKeepsBotNotesButNotSystemNotes(): void + { + $gitLabClient = $this->createMock(GitLabClient::class); + $gitLabClient->method('getIssue')->willReturn(self::makeIssue()); + $gitLabClient->method('getIssueNotes')->willReturn(self::makeNotes()); + + $result = (new GetGitLabIssueAction($gitLabClient))(self::makeRef(), true, true); + + self::assertSame( + ['drupalbot', 'reviewer', 'Contributor Name'], + array_map(static fn(GitLabNote $n) => $n->author, $result->comments) + ); + } +} diff --git a/tests/src/Formatter/LlmFormatterTest.php b/tests/src/Formatter/LlmFormatterTest.php index 56457ff..5254d5c 100644 --- a/tests/src/Formatter/LlmFormatterTest.php +++ b/tests/src/Formatter/LlmFormatterTest.php @@ -17,6 +17,9 @@ use mglaman\DrupalOrg\Result\Skill\SkillListResult; use mglaman\DrupalOrgCli\Formatter\LlmFormatter; use PHPUnit\Framework\Attributes\CoversClass; +use mglaman\DrupalOrg\GitLab\Entity\GitLabIssue; +use mglaman\DrupalOrg\GitLab\Entity\GitLabNote; +use mglaman\DrupalOrg\Result\GitLab\GitLabIssueResult; use PHPUnit\Framework\TestCase; #[CoversClass(LlmFormatter::class)] @@ -329,4 +332,49 @@ public function testXmlEscapingInTitle(): void self::assertStringContainsString('<script>', $output); self::assertStringContainsString('&', $output); } + + private static function makeGitLabIssue(): GitLabIssue + { + return new GitLabIssue( + iid: 3586157, + title: 'Example AI context issue', + description: 'Body', + state: 'opened', + labels: [], + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-02T00:00:00Z', + webUrl: 'https://git.drupalcode.org/project/ai_context/-/work_items/3586157', + author: 'reporter', + assignees: [], + ); + } + + private static function makeGitLabIssueResult(): GitLabIssueResult + { + return new GitLabIssueResult(self::makeGitLabIssue(), [ + new GitLabNote(id: 2, body: 'Reviewed the approach.', author: 'reviewer', createdAt: '2025-01-01T02:00:00Z', system: false), + ]); + } + + public function testGitLabIssueWithComments(): void + { + $formatter = new LlmFormatter(); + $output = $formatter->format(self::makeGitLabIssueResult()); + + self::assertStringContainsString('', $output); + self::assertStringContainsString('', $output); + self::assertStringContainsString('1', $output); + self::assertStringContainsString('reviewer', $output); + self::assertStringContainsString('2025-01-01T02:00:00Z', $output); + self::assertStringContainsString('the approach.]]>', $output); + self::assertStringContainsString('', $output); + } + + public function testGitLabIssueWithoutCommentsOmitsCommentsElement(): void + { + $formatter = new LlmFormatter(); + $output = $formatter->format(new GitLabIssueResult(self::makeGitLabIssue())); + + self::assertStringNotContainsString('', $output); + } } diff --git a/tests/src/Formatter/MarkdownFormatterTest.php b/tests/src/Formatter/MarkdownFormatterTest.php index 0b3f43b..e9d0bb1 100644 --- a/tests/src/Formatter/MarkdownFormatterTest.php +++ b/tests/src/Formatter/MarkdownFormatterTest.php @@ -18,6 +18,9 @@ use mglaman\DrupalOrg\Result\Skill\SkillListResult; use mglaman\DrupalOrgCli\Formatter\MarkdownFormatter; use PHPUnit\Framework\Attributes\CoversClass; +use mglaman\DrupalOrg\GitLab\Entity\GitLabIssue; +use mglaman\DrupalOrg\GitLab\Entity\GitLabNote; +use mglaman\DrupalOrg\Result\GitLab\GitLabIssueResult; use PHPUnit\Framework\TestCase; #[CoversClass(MarkdownFormatter::class)] @@ -313,4 +316,45 @@ public function jsonSerialize(): mixed $this->expectException(\InvalidArgumentException::class); $formatter->format($result); } + + private static function makeGitLabIssue(): GitLabIssue + { + return new GitLabIssue( + iid: 3586157, + title: 'Example AI context issue', + description: 'Body', + state: 'opened', + labels: [], + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-02T00:00:00Z', + webUrl: 'https://git.drupalcode.org/project/ai_context/-/work_items/3586157', + author: 'reporter', + assignees: [], + ); + } + + private static function makeGitLabIssueResult(): GitLabIssueResult + { + return new GitLabIssueResult(self::makeGitLabIssue(), [ + new GitLabNote(id: 2, body: 'Reviewed the approach.', author: 'reviewer', createdAt: '2025-01-01T02:00:00Z', system: false), + ]); + } + + public function testGitLabIssueWithComments(): void + { + $formatter = new MarkdownFormatter(); + $output = $formatter->format(self::makeGitLabIssueResult()); + + self::assertStringContainsString('## Comments', $output); + self::assertStringContainsString('### Comment #1 — reviewer (2025-01-01T02:00:00Z)', $output); + self::assertStringContainsString('Reviewed the approach.', $output); + } + + public function testGitLabIssueWithoutCommentsOmitsCommentsHeading(): void + { + $formatter = new MarkdownFormatter(); + $output = $formatter->format(new GitLabIssueResult(self::makeGitLabIssue())); + + self::assertStringNotContainsString('## Comments', $output); + } }