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: 3 additions & 2 deletions skills/drupalorg-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,9 @@ drupalorg mr:logs 'project/drupal!708'

```bash
# List open issues for a project
# type: all (default) or rtbc; --core defaults to 8.x; --limit defaults to 10
drupalorg project:issues [project] [type] --format=llm
# 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)
drupalorg project:issues [project] [type] [--category=bug|task|feature|support|plan] --format=llm

# Search issues for a project by title keyword
# project is optional; auto-detected from git remote if omitted
Expand Down
7 changes: 6 additions & 1 deletion src/Api/Action/Project/GetProjectIssuesAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use mglaman\DrupalOrg\Client;
use mglaman\DrupalOrg\Entity\IssueNode;
use mglaman\DrupalOrg\Entity\Project;
use mglaman\DrupalOrg\Enum\ProjectIssueCategory;
use mglaman\DrupalOrg\Enum\ProjectIssueType;
use mglaman\DrupalOrg\Request;
use mglaman\DrupalOrg\Result\Project\ProjectIssuesResult;
Expand All @@ -16,7 +17,7 @@ public function __construct(private readonly Client $client)
{
}

public function __invoke(Project $project, ProjectIssueType $type, string $core, int $limit): ProjectIssuesResult
public function __invoke(Project $project, ProjectIssueType $type, string $core, int $limit, ?ProjectIssueCategory $category = null): ProjectIssuesResult
{
$rawReleases = $this->client->requestRaw(new Request('node.json', [
'field_release_project' => $project->nid,
Expand Down Expand Up @@ -49,6 +50,10 @@ public function __invoke(Project $project, ProjectIssueType $type, string $core,
}
}

if ($category !== null) {
$apiParams['field_issue_category'] = $category->categoryId();
}

$rawIssues = $this->client->requestRaw(new Request('node.json', $apiParams));
$issueList = (array) ($rawIssues->list ?? []);
$issues = array_map(
Expand Down
23 changes: 23 additions & 0 deletions src/Api/Enum/ProjectIssueCategory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

namespace mglaman\DrupalOrg\Enum;

enum ProjectIssueCategory: string
{
case Bug = 'bug';
case Task = 'task';
case Feature = 'feature';
case Support = 'support';
case Plan = 'plan';

public function categoryId(): int
{
return match ($this) {
ProjectIssueCategory::Bug => 1,
ProjectIssueCategory::Task => 2,
ProjectIssueCategory::Feature => 3,
ProjectIssueCategory::Support => 4,
ProjectIssueCategory::Plan => 5,
};
}
}
8 changes: 6 additions & 2 deletions src/Api/Mcp/ToolRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
use mglaman\DrupalOrg\Client;
use mglaman\DrupalOrg\Enum\MaintainerIssueType;
use mglaman\DrupalOrg\Enum\MergeRequestState;
use mglaman\DrupalOrg\Enum\ProjectIssueCategory;
use mglaman\DrupalOrg\Enum\ProjectIssueType;
use mglaman\DrupalOrg\GitLab\Client as GitLabClient;

Expand Down Expand Up @@ -85,13 +86,16 @@ public function projectGetIssues(
#[Schema(description: "Core compatibility branch to filter by (e.g. '10.x', '11.x').")]
string $core = '10.x',
#[Schema(description: 'Maximum number of issues to return.', minimum: 1, maximum: 100)]
int $limit = 50
int $limit = 50,
#[Schema(description: 'Filter issues by category.', enum: ['bug', 'task', 'feature', 'support', 'plan'])]
?string $category = null
): mixed {
$project = $this->client->getProject($machineName);
if ($project === null) {
throw new \RuntimeException("Project '$machineName' not found.");
}
return (new GetProjectIssuesAction($this->client))($project, ProjectIssueType::from($type), $core, $limit)->jsonSerialize();
$issueCategory = $category !== null ? ProjectIssueCategory::from($category) : null;
return (new GetProjectIssuesAction($this->client))($project, ProjectIssueType::from($type), $core, $limit, $issueCategory)->jsonSerialize();
}

#[McpTool(annotations: new ToolAnnotations(readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true), name: 'issue_search', description: 'Search issues for a Drupal.org project by title keyword.')]
Expand Down
13 changes: 12 additions & 1 deletion src/Cli/Command/Project/ProjectIssues.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace mglaman\DrupalOrgCli\Command\Project;

use mglaman\DrupalOrg\Action\Project\GetProjectIssuesAction;
use mglaman\DrupalOrg\Enum\ProjectIssueCategory;
use mglaman\DrupalOrg\Enum\ProjectIssueType;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Helper\TableSeparator;
Expand Down Expand Up @@ -40,6 +41,13 @@ protected function configure(): void
'Limit',
'10'
)
->addOption(
'category',
null,
InputOption::VALUE_OPTIONAL,
'Issue category: bug, task, feature, support, plan',
null
)
->addOption(
'format',
'f',
Expand All @@ -59,11 +67,14 @@ protected function execute(
OutputInterface $output
): int {
$action = new GetProjectIssuesAction($this->client);
$categoryOption = $this->stdIn->getOption('category');
$category = $categoryOption !== null ? ProjectIssueCategory::from((string) $categoryOption) : null;
$result = $action(
$this->projectData,
ProjectIssueType::from((string) $this->stdIn->getArgument('type')),
(string) $this->stdIn->getOption('core'),
(int) $this->stdIn->getOption('limit')
(int) $this->stdIn->getOption('limit'),
$category
);

if ($this->writeFormatted($result, (string) $this->stdIn->getOption('format'))) {
Expand Down
42 changes: 42 additions & 0 deletions tests/src/Action/Project/GetProjectIssuesActionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
use mglaman\DrupalOrg\Client;
use mglaman\DrupalOrg\Entity\IssueNode;
use mglaman\DrupalOrg\Entity\Project;
use mglaman\DrupalOrg\Enum\ProjectIssueCategory;
use mglaman\DrupalOrg\Enum\ProjectIssueType;
use mglaman\DrupalOrg\Result\Project\ProjectIssuesResult;
use mglaman\DrupalOrg\Request;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;

Expand Down Expand Up @@ -44,6 +46,46 @@ private static function makeRawIssues(): \stdClass
];
}

public function testInvokeWithCategory(): void
{
$project = Project::fromStdClass(self::projectFixture());

$client = $this->createMock(Client::class);
$capturedParams = [];
$client->method('requestRaw')->willReturnCallback(
function (Request $request) use (&$capturedParams) {
$capturedParams[] = $request->getOptions();
return (object) ['list' => []];
}
);

$action = new GetProjectIssuesAction($client);
$action($project, ProjectIssueType::All, '8.x', 10, ProjectIssueCategory::Bug);

// Second call is the issues request — it should include field_issue_category = 1 (Bug)
self::assertArrayHasKey('field_issue_category', $capturedParams[1]);
self::assertSame(1, $capturedParams[1]['field_issue_category']);
}

public function testInvokeWithoutCategoryDoesNotAddParam(): void
{
$project = Project::fromStdClass(self::projectFixture());

$client = $this->createMock(Client::class);
$capturedParams = [];
$client->method('requestRaw')->willReturnCallback(
function (Request $request) use (&$capturedParams) {
$capturedParams[] = $request->getOptions();
return (object) ['list' => []];
}
);

$action = new GetProjectIssuesAction($client);
$action($project, ProjectIssueType::All, '8.x', 10);

self::assertArrayNotHasKey('field_issue_category', $capturedParams[1]);
}

public function testInvoke(): void
{
$project = Project::fromStdClass(self::projectFixture());
Expand Down
Loading