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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ This CLI reads from Drupal.org and automates local merge request workflows. Its
| `maintainer:issues` | `mi` | Lists issues for a user |
| `maintainer:release-notes` | `rn`, `mrn` | Generates release notes from git log |
| `project:issues` | `pi` | Lists issues for a project |
| `issue:search` | `is` | Searches issues by title keyword (optional project scope) |
| `project:releases` | | Lists available releases |
| `project:release-notes` | `prn` | Displays release notes for a release |
| `project:link` | | Opens project page in browser |
Expand Down Expand Up @@ -67,5 +68,6 @@ composer box-install && composer box-build # Build phar

## Skills

- `/drupalorg-issue-search` — Search for Drupal.org issues by keyword, combining API search, Drupal.org issue queue scraping, and web search
- `/phpstan-fix` — Run PHPStan and fix all reported errors in `src/`
- `/pr-check` — Run the full local CI suite before opening a PR
5 changes: 5 additions & 0 deletions skills/drupalorg-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,11 @@ drupalorg mr:logs 'project/drupal!708'
# type: all (default) or rtbc; --core defaults to 8.x; --limit defaults to 10
drupalorg project:issues [project] [type] --format=llm

# Search issues for a project by title keyword
# project is optional; auto-detected from git remote if omitted
# --status: all (default), open, closed, rtbc, review; --limit defaults to 20
drupalorg project:search [project] <query> [--status=all] [--limit=20] --format=llm

# List available releases for a project
drupalorg project:releases <project> --format=llm

Expand Down
69 changes: 69 additions & 0 deletions skills/drupalorg-issue-search/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
name: drupalorg-issue-search
description: >
Search for Drupal.org issues by keyword. Combines CLI API search, Drupal.org
issue queue scraping, and web search, then deduplicates and presents a unified
summary.
---

## Usage

```
/drupalorg-issue-search <query> [--project=<project>] [--status=all] [--skip=web_search,api_search,drupalorg_scrape]
```

## Instructions

1. **Parse inputs**: Extract the search `query` and optional flags:
- `--project`: project machine name
- `--status`: issue status filter (default: `all`)
- `--skip`: comma-separated list of channels to skip. Valid values: `api_search`, `drupalorg_scrape`, `web_search`. For example `--skip=web_search` skips the web search, `--skip=api_search,web_search` runs only the Drupal.org scrape.

2. **Detect project**: If `--project` is not provided, try to infer the project machine name from the current git remote:
```bash
git config --get remote.origin.url
```
Extract the project name from the URL (pattern: `*/project-name.git`). If detection fails, proceed without a project filter.

3. **Run enabled searches in parallel** (skip any channel listed in `--skip`):

a. **API search** (channel: `api_search`) — run the CLI command:
```bash
php drupalorg issue:search <query> --status=<status> --format=json
```
If a project is known, include it as the first argument:
```bash
php drupalorg issue:search <project> <query> --status=<status> --format=json
```

b. **Drupal.org issue queue scrape** (channel: `drupalorg_scrape`) — if a project is known, fetch the project's issue search page directly using `WebFetch`:
```
URL: https://www.drupal.org/project/issues/<project>?text=<query words joined by +>&status=All
Prompt: Extract all issue NIDs (numeric IDs from URLs like /node/XXXX or /issues/XXXX), titles, and statuses from this page. Return as a compact list.
```
Replace spaces in the query with `+` for the URL parameter. This channel searches issue titles and bodies server-side, so it can find older and closed issues that the API search misses.
If no project is known, skip this channel.

c. **Web search** (channel: `web_search`) — search the web:
- If project is known: `<query> site:https://www.drupal.org/project/issues/<project>`
- If project is unknown: `<query> site:https://www.drupal.org/project/issues/`

4. **Extract NIDs**: Parse NIDs from all active sources:
- API search: from the JSON response
- Drupal.org scrape: from the extracted issue list
- Web search: from URLs matching patterns `/issues/{nid}` or `/node/{nid}` where `{nid}` is a numeric ID

5. **Deduplicate**: Collect all unique NIDs from all sources.

6. **Enrich results without details**: For any NIDs found via web search (but NOT in the API or scrape results which already have titles), fetch details:
```bash
drupalorg issue:show <nid> --format=llm
```

7. **Present results**: Output a combined summary table with columns:
- NID
- Title
- Status
- Link (`https://www.drupal.org/node/{nid}`)

Group results by source if helpful (API results first, then scrape results, then web-only results).
55 changes: 55 additions & 0 deletions src/Api/Action/Issue/SearchIssuesAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

declare(strict_types=1);

namespace mglaman\DrupalOrg\Action\Issue;

use mglaman\DrupalOrg\Action\ActionInterface;
use mglaman\DrupalOrg\Client;
use mglaman\DrupalOrg\Entity\IssueNode;
use mglaman\DrupalOrg\Entity\Project;
use mglaman\DrupalOrg\Request;
use mglaman\DrupalOrg\Result\Issue\IssueSearchResult;

class SearchIssuesAction implements ActionInterface
{
public function __construct(private readonly Client $client)
{
}

/**
* @param int[] $statuses
*/
public function __invoke(Project $project, string $query, array $statuses, int $limit): IssueSearchResult
{
$params = [
'type' => 'project_issue',
'field_project' => $project->nid,
'sort' => 'changed',
'direction' => 'DESC',
// Fetch more than $limit so the in-memory title filter has enough
// candidates; capped at 100 to avoid excessively large responses.
'limit' => min($limit * 3, 100),
];
if ($statuses !== []) {
$params['field_issue_status[value]'] = $statuses;
}
$rawIssues = $this->client->requestRaw(new Request('node.json', $params));
$issueList = (array) ($rawIssues->list ?? []);
$issues = array_map(
static fn(\stdClass $issue) => IssueNode::fromStdClass($issue),
$issueList
);

$issues = array_filter(
$issues,
static fn(IssueNode $issue) => stripos($issue->title, $query) !== false
);
$issues = array_slice(array_values($issues), 0, $limit);

return new IssueSearchResult(
projectTitle: $project->title,
issues: $issues,
);
}
}
17 changes: 17 additions & 0 deletions src/Api/Mcp/ToolRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use mglaman\DrupalOrg\Action\Project\GetProjectIssuesAction;
use mglaman\DrupalOrg\Action\Project\GetProjectReleaseNotesAction;
use mglaman\DrupalOrg\Action\Project\GetProjectReleasesAction;
use mglaman\DrupalOrg\Action\Issue\SearchIssuesAction;
use mglaman\DrupalOrg\Client;
use mglaman\DrupalOrg\Enum\MaintainerIssueType;
use mglaman\DrupalOrg\Enum\MergeRequestState;
Expand Down Expand Up @@ -93,6 +94,22 @@ public function projectGetIssues(
return (new GetProjectIssuesAction($this->client))($project, ProjectIssueType::from($type), $core, $limit)->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.')]
public function issueSearch(
#[Schema(description: "The project machine name (e.g. 'drupal', 'token', 'pathauto').")]
string $machineName,
#[Schema(description: 'The search text to filter issue titles.')]
string $query,
#[Schema(description: 'Maximum number of issues to return.', minimum: 1, maximum: 100)]
int $limit = 20
): mixed {
$project = $this->client->getProject($machineName);
if ($project === null) {
throw new \RuntimeException("Project '$machineName' not found.");
}
return (new SearchIssuesAction($this->client))($project, $query, [], $limit)->jsonSerialize();
}

#[McpTool(annotations: new ToolAnnotations(readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true), name: 'project_get_releases', description: 'List releases for a Drupal.org project.')]
public function projectGetReleases(
#[Schema(description: "The project machine name (e.g. 'drupal', 'token', 'pathauto').")]
Expand Down
35 changes: 35 additions & 0 deletions src/Api/Result/Issue/IssueSearchResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

namespace mglaman\DrupalOrg\Result\Issue;

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

class IssueSearchResult implements ResultInterface
{
/**
* @param IssueNode[] $issues
*/
public function __construct(
public readonly ?string $projectTitle,
public readonly array $issues,
) {
}

public function jsonSerialize(): mixed
{
return [
'project_title' => $this->projectTitle,
'issues' => array_map(
static fn(IssueNode $issue) => [
'nid' => $issue->nid,
'title' => $issue->title,
'field_issue_status' => $issue->fieldIssueStatus,
],
$this->issues
),
];
}
}
1 change: 1 addition & 0 deletions src/Cli/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ public function getCommands(): array
$commands[] = new Command\Project\Link();
$commands[] = new Command\Project\Kanban();
$commands[] = new Command\Project\ProjectIssues();
$commands[] = new Command\Issue\Search();
$commands[] = new Command\Project\Releases();
$commands[] = new Command\Project\ReleaseNotes();
$commands[] = new Command\Maintainer\Issues();
Expand Down
131 changes: 131 additions & 0 deletions src/Cli/Command/Issue/Search.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<?php

declare(strict_types=1);

namespace mglaman\DrupalOrgCli\Command\Issue;

use mglaman\DrupalOrg\Action\Issue\SearchIssuesAction;
use mglaman\DrupalOrgCli\Command\Project\ProjectCommandBase;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Helper\TableSeparator;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

class Search extends ProjectCommandBase
{
private const STATUS_MAP = [
'all' => [],
'open' => [1, 8, 13, 14, 16],
'closed' => [2, 3, 4, 5, 6, 7],
'rtbc' => [14],
'review' => [8],
];

protected function configure(): void
{
$this
->setName('issue:search')
->setAliases(['is'])
->addArgument('project', InputArgument::OPTIONAL, 'Project machine name (auto-detected from git remote if omitted)')
->addArgument('query', InputArgument::OPTIONAL, 'Search text to filter issue titles')
->addOption(
'status',
's',
InputOption::VALUE_OPTIONAL,
'Issue status filter: all, open, closed, rtbc, review',
'all'
)
->addOption(
'limit',
null,
InputOption::VALUE_OPTIONAL,
'Maximum number of results',
'20'
)
->addOption(
'format',
'f',
InputOption::VALUE_OPTIONAL,
'Output options: text, json, md, llm. Defaults to text.',
'text'
)
->setDescription('Searches issues for a project by title keyword.');
}

protected function initialize(InputInterface $input, OutputInterface $output): void
{
// If only one positional argument is provided, Symfony assigns it to
// the first argument ("project"). Detect this case: when "query" is
// empty, treat the "project" value as the query and auto-detect the
// project from the git remote.
$projectArg = $input->getArgument('project');
$queryArg = $input->getArgument('query');

if ($projectArg !== null && $queryArg === null) {
$input->setArgument('query', $projectArg);
$input->setArgument('project', null);
}

parent::initialize($input, $output);
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$query = (string) $this->stdIn->getArgument('query');
if ($query === '') {
$this->stdErr->writeln('<error>The query argument is required.</error>');
return 1;
}

$status = (string) $this->stdIn->getOption('status');
$statuses = self::STATUS_MAP[$status] ?? self::STATUS_MAP['open'];

$action = new SearchIssuesAction($this->client);
$result = $action(
$this->projectData,
$query,
$statuses,
(int) $this->stdIn->getOption('limit')
);

if ($this->writeFormatted($result, (string) $this->stdIn->getOption('format'))) {
return 0;
}

$output->writeln("<info>{$result->projectTitle}</info> — search: <comment>{$query}</comment>");
$table = new Table($this->stdOut);
$table->setHeaders(['ID', 'Status', 'Title']);

$issueList = $result->issues;
$count = count($issueList);
for ($i = 0; $i < $count; $i++) {
$item = $issueList[$i];
$table->addRow([
$item->nid,
$this->getIssueStatus($item->fieldIssueStatus),
$item->title . PHP_EOL . '<comment>https://www.drupal.org/node/' . $item->nid . '</comment>',
]);
if ($i < $count - 1) {
$table->addRow(new TableSeparator());
}
}

$table->render();
return 0;
}

protected function getIssueStatus(int $value): string
{
return match ($value) {
1 => '<comment>Active</comment>',
2 => '<info>Fixed</info>',
13 => '<error>Needs Work</error>',
8 => '<question>Needs Review</question>',
16 => '<comment>Postponed [NMI]</comment>',
14 => '<info>RTBC</info>',
default => (string) $value,
};
}
}
3 changes: 3 additions & 0 deletions src/Cli/Formatter/AbstractFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use mglaman\DrupalOrg\Result\MergeRequest\MergeRequestFilesResult;
use mglaman\DrupalOrg\Result\MergeRequest\MergeRequestListResult;
use mglaman\DrupalOrg\Result\MergeRequest\MergeRequestStatusResult;
use mglaman\DrupalOrg\Result\Issue\IssueSearchResult;
use mglaman\DrupalOrg\Result\Project\ProjectIssuesResult;
use mglaman\DrupalOrg\Result\Project\ProjectReleasesResult;
use mglaman\DrupalOrg\Result\ResultInterface;
Expand All @@ -20,6 +21,7 @@ final public function format(ResultInterface $result): string
return match (true) {
$result instanceof IssueResult => $this->formatIssue($result),
$result instanceof IssueForkResult => $this->formatIssueFork($result),
$result instanceof IssueSearchResult => $this->formatIssueSearch($result),
$result instanceof ProjectIssuesResult => $this->formatProjectIssues($result),
$result instanceof MaintainerIssuesResult => $this->formatMaintainerIssues($result),
$result instanceof ProjectReleasesResult => $this->formatProjectReleases($result),
Expand All @@ -35,6 +37,7 @@ final public function format(ResultInterface $result): string

abstract protected function formatIssue(IssueResult $result): string;
abstract protected function formatIssueFork(IssueForkResult $result): string;
abstract protected function formatIssueSearch(IssueSearchResult $result): string;
abstract protected function formatProjectIssues(ProjectIssuesResult $result): string;
abstract protected function formatMaintainerIssues(MaintainerIssuesResult $result): string;
abstract protected function formatProjectReleases(ProjectReleasesResult $result): string;
Expand Down
Loading
Loading