feat: support gh issue search

This commit is contained in:
Codex
2026-06-01 13:40:56 +00:00
parent 48f3f728e7
commit 2346c620b7
3 changed files with 55 additions and 15 deletions
+43 -14
View File
@@ -35,6 +35,7 @@ const GH_VALUE_OPTIONS = new Set([
"--expect-updated-at", "--expect-body-sha", "--body-profile", "--label", "--field",
"--value", "--section", "--to", "--status", "--row-file", "--category", "--branch",
"--tasks", "--summary", "--focus", "--validation", "--progress", "--number", "--pr",
"--search",
]);
const GH_FLAG_OPTIONS = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body", "--raw", "--full", "--stat", "--merge", "--squash", "--rebase", "--delete-branch"]);
const MIN_SAFE_BODY_SCAN_CHARS = MIN_SAFE_ISSUE_BODY_CHARS;
@@ -324,6 +325,7 @@ interface GitHubOptions {
notifyClaudeQqBriefDiff: boolean;
allowShortBody: boolean;
labels: string[];
search?: string;
title?: string;
body?: string;
bodyFile?: string;
@@ -411,6 +413,12 @@ interface GitHubComment {
updated_at?: string;
}
interface GitHubIssueSearchResponse {
total_count?: number;
incomplete_results?: boolean;
items?: GitHubIssue[];
}
interface GitHubPullRequest {
id: number;
number: number;
@@ -765,6 +773,7 @@ function parseOptions(args: string[]): GitHubOptions {
notifyClaudeQqBriefDiff: hasFlag(args, "--notify-claudeqq-brief-diff"),
allowShortBody: hasFlag(args, "--allow-short-body"),
labels: labelsOption(args),
search: optionValue(args, "--search"),
title: optionValue(args, "--title"),
body: optionValue(args, "--body"),
bodyFile: optionValue(args, "--body-file"),
@@ -4906,9 +4915,17 @@ async function listIssueComments(token: string, repo: string, issueNumber: numbe
return githubRequest<GitHubComment[]>(token, "GET", `/repos/${owner}/${name}/issues/${issueNumber}/comments?per_page=100`);
}
async function listIssues(token: string, repo: string, state: IssueListState, limit: number): Promise<GitHubIssue[] | GitHubErrorPayload> {
async function listIssues(token: string, repo: string, state: IssueListState, limit: number, search?: string): Promise<GitHubIssue[] | GitHubErrorPayload | GitHubIssueSearchResponse> {
const { owner, name } = repoParts(repo);
return githubRequest<GitHubIssue[]>(token, "GET", `/repos/${owner}/${name}/issues?state=${state}&per_page=${limit}`);
const normalizedSearch = String(search ?? "").trim();
if (normalizedSearch) {
const qualifiers = [`repo:${owner}/${name}`, "type:issue"];
if (state !== "all") qualifiers.push(`state:${state}`);
const params = new URLSearchParams({ q: `${normalizedSearch} ${qualifiers.join(" ")}`, per_page: String(limit) });
return githubRequest<GitHubIssueSearchResponse>(token, "GET", `/search/issues?${params.toString()}`);
}
const params = new URLSearchParams({ state, per_page: String(limit) });
return githubRequest<GitHubIssue[]>(token, "GET", `/repos/${owner}/${name}/issues?${params.toString()}`);
}
async function getIssue(token: string, repo: string, issueNumber: number): Promise<GitHubIssue | GitHubErrorPayload> {
@@ -4958,10 +4975,13 @@ async function issueView(repo: string, token: string, issueNumber: number, jsonF
return issueRead(repo, token, issueNumber, jsonFields, "issue view", disclosure);
}
async function issueList(repo: string, token: string, state: IssueListState, limit: number, jsonFields: IssueListJsonField[] | undefined): Promise<GitHubCommandResult> {
const rawIssues = await listIssues(token, repo, state, limit);
if (isGitHubError(rawIssues)) return commandError("issue list", repo, rawIssues, { state, limit });
const issues = rawIssues.filter((issue) => issue.pull_request === undefined).slice(0, limit);
async function issueList(repo: string, token: string, state: IssueListState, limit: number, jsonFields: IssueListJsonField[] | undefined, search?: string): Promise<GitHubCommandResult> {
const normalizedSearch = String(search ?? "").trim();
const rawIssues = await listIssues(token, repo, state, limit, normalizedSearch);
if (isGitHubError(rawIssues)) return commandError("issue list", repo, rawIssues, { state, limit, search: normalizedSearch || null });
const searchResponse = Array.isArray(rawIssues) ? null : rawIssues;
const rawIssueItems = Array.isArray(rawIssues) ? rawIssues : Array.isArray(rawIssues.items) ? rawIssues.items : [];
const issues = rawIssueItems.filter((issue) => issue.pull_request === undefined).slice(0, limit);
const fields = jsonFields ?? ISSUE_LIST_JSON_FIELDS.slice();
return {
ok: true,
@@ -4969,14 +4989,19 @@ async function issueList(repo: string, token: string, state: IssueListState, lim
repo,
state,
limit,
search: normalizedSearch || null,
count: issues.length,
rawCount: rawIssues.length,
rawCount: rawIssueItems.length,
searchTotalCount: searchResponse?.total_count,
searchIncomplete: searchResponse?.incomplete_results,
jsonFields: fields,
issues: issues.map((issue) => issueListSummary(issue, fields)),
request: {
method: "GET",
path: "/repos/{owner}/{repo}/issues",
query: { state, per_page: limit },
path: normalizedSearch ? "/search/issues" : "/repos/{owner}/{repo}/issues",
query: normalizedSearch
? { q: `${normalizedSearch} repo:${repo} type:issue${state === "all" ? "" : ` state:${state}`}`, per_page: limit }
: { state, per_page: limit },
},
note: "GitHub's issues endpoint may include pull requests; this command filters pull requests from .issues.",
};
@@ -5934,7 +5959,7 @@ export function ghHelp(): unknown {
output: "json",
usage: [
"bun scripts/cli.ts gh auth status [--repo owner/name]",
"bun scripts/cli.ts gh issue list [owner/repo] [--state open|closed|all] [--limit N] [--repo owner/name] [--json number,title,state,url,updatedAt,createdAt,author,labels]",
"bun scripts/cli.ts gh issue list [owner/repo] [--state open|closed|all] [--limit N] [--search text] [--repo owner/name] [--json number,title,state,url,updatedAt,createdAt,author,labels]",
"bun scripts/cli.ts gh issue read <number|owner/repo#number> [--repo owner/name] [--json body,title,state,comments] [--raw|--full]",
"bun scripts/cli.ts gh issue view <number|owner/repo#number> [--repo owner/name] [--raw|--full] [compatibility alias for issue read]",
"bun scripts/cli.ts gh issue create --title <title> --body-file <file|-> [--label label[,label...]]... [--repo owner/name] [--dry-run]",
@@ -5977,7 +6002,7 @@ export function ghHelp(): unknown {
"Issue and PR create/read/update/comment/close/reopen use GitHub REST and do not require the gh binary when GH_TOKEN or GITHUB_TOKEN is present.",
"Token values are never printed; auth status reports only token source and presence.",
"issue list and pr list accept a single positional owner/repo as a compatibility alias for --repo owner/name. The positional repo and --repo must match if both are supplied; non-repo positionals fail structurally instead of falling back to the default repo.",
"issue list defaults to --state open and bounded --limit 30; supported --json fields are number,title,state,url,updatedAt,createdAt,author,labels and unknown fields fail structurally.",
"issue list defaults to --state open and bounded --limit 30; --search uses GitHub Search Issues API with repo/type/state qualifiers for low-friction dedupe lookup before creating a new issue. Supported --json fields are number,title,state,url,updatedAt,createdAt,author,labels and unknown fields fail structurally.",
"PR list defaults to --state all for compatibility with earlier UniDesk CLI behavior; supported states are open, closed, and all.",
"issue read is the canonical read path; view remains a compatibility alias. Read/view accept owner/repo#number shorthand and derive --repo unless an explicit conflicting --repo is supplied, which fails structurally with suggested commands. Read supports legacy --json field selection such as --json body and still exposes .data.issue.body for compatibility; unsupported fields fail structurally.",
"--raw and --full are explicit full-disclosure aliases for gh issue read/view/update/edit and gh pr read/view. For issue writes, default success output omits full issue.body and returns bodyChars/bodySha/bodyPreview plus readCommands; --full|--raw includes the full returned issue body.",
@@ -6110,9 +6135,13 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--category, --branch, --tasks, --summary, --focus, --validation, and --progress are only supported by gh issue board-row upsert");
}
if (optionWasProvided(args, "--label") && !(top === "issue" && sub === "create")) {
if (optionWasProvided(args, "--label") && !(top === "issue" && (sub === "create" || sub === "list"))) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--label is only supported by gh issue create");
return validationError(command, options.repo, "--label is only supported by gh issue create and gh issue list");
}
if (optionWasProvided(args, "--search") && !(top === "issue" && sub === "list")) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--search is only supported by gh issue list");
}
if (top === "auth" && sub === "status") return authStatus(options.repo);
@@ -6205,7 +6234,7 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
const missing = authRequired(options.repo, `issue ${sub ?? ""}`.trim(), probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, `issue ${sub ?? ""}`.trim(), { present: false, source: null, ghFallbackAttempted: true });
if (sub === "list") return issueList(options.repo, token, options.listState, options.limit, options.issueListJsonFields);
if (sub === "list") return issueList(options.repo, token, options.listState, options.limit, options.issueListJsonFields, options.search);
if (sub === "create") return issueCreate(options.repo, token, options);
if (sub === "edit") return issueEdit(options.repo, token, parseNumber(third, "issue edit"), options);
if (sub === "update") return issueEdit(options.repo, token, parseNumber(third, "issue update"), options, "issue update");