From a376cf8205041f84961c025027f44d146bbd521d Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 13 May 2026 02:55:30 +0200 Subject: [PATCH] fix(tools): corrected github tool search parsing for /search/issues responses - Switched issue and PR search handlers to `gh api /search/issues` with `is:issue`/`is:pr` queries. - Added REST search response models and mapped issue/code/commit/repo payloads to normalized results. - Updated code, commit, and repo search parsing to read `{items}` envelopes and convert snake_case fields. - Fixed merged-PR output state by deriving it from `pull_request.merged_at` in test fixtures. --- docs/tools/github.md | 10 +- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/tools/gh.ts | 254 ++++++++++++++------ packages/coding-agent/test/tools/gh.test.ts | 196 ++++++++------- 4 files changed, 301 insertions(+), 160 deletions(-) diff --git a/docs/tools/github.md b/docs/tools/github.md index 31ebfb301..525d8a818 100644 --- a/docs/tools/github.md +++ b/docs/tools/github.md @@ -183,7 +183,7 @@ Push target resolution reads the `branch..ompPrHeadRef`, `pushRemote`/`rem | --- | --- | | Required fields | `op`, `query` | | Optional fields | `repo`, `limit` | -| `gh` command | `gh search issues --limit --json [--repo ] -- ` | +| `gh` command | `gh api -X GET /search/issues -f q=" [repo:] is:issue" -F per_page=` | | Batching | None | | Output | `# GitHub issues search`, echoed query, optional repo, result count, then one bullet per issue with repo/state/author/labels/timestamps/URL. | @@ -193,7 +193,7 @@ Push target resolution reads the `branch..ompPrHeadRef`, `pushRemote`/`rem | --- | --- | | Required fields | `op`, `query` | | Optional fields | `repo`, `limit` | -| `gh` command | `gh search prs --limit --json [--repo ] -- ` | +| `gh` command | `gh api -X GET /search/issues -f q=" [repo:] is:pr" -F per_page=` | | Batching | None | | Output | Same shape as `search_issues`, labeled as pull requests. | @@ -203,7 +203,7 @@ Push target resolution reads the `branch..ompPrHeadRef`, `pushRemote`/`rem | --- | --- | | Required fields | `op`, `query` | | Optional fields | `repo`, `limit` | -| `gh` command | `gh search code --limit --json [--repo ] -- ` | +| `gh` command | `gh api -X GET /search/code -f q=" [repo:]" -F per_page= -H "Accept: application/vnd.github.text-match+json"` | | Batching | None | | Output | `# GitHub code search`, result count, then one bullet per match with path, repo, short commit SHA, URL, and first normalized text-match fragment line when present. | @@ -213,7 +213,7 @@ Push target resolution reads the `branch..ompPrHeadRef`, `pushRemote`/`rem | --- | --- | | Required fields | `op`, `query` | | Optional fields | `repo`, `limit` | -| `gh` command | `gh search commits --limit --json [--repo ] -- ` | +| `gh` command | `gh api -X GET /search/commits -f q=" [repo:]" -F per_page=` | | Batching | None | | Output | `# GitHub commits search`, result count, then one bullet per commit: short SHA + first commit-message line, repo, author, date, URL. | @@ -223,7 +223,7 @@ Push target resolution reads the `branch..ompPrHeadRef`, `pushRemote`/`rem | --- | --- | | Required fields | `op`, `query` | | Optional fields | `limit` | -| `gh` command | `gh search repos --limit --json -- ` | +| `gh` command | `gh api -X GET /search/repositories -f q="" -F per_page=` | | Batching | None | | Output | `# GitHub repositories search`, result count, then one bullet per repo with first description line, language, stars, forks, open issues, visibility, archive/fork flags, updated time, URL. | diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b642ee64e..0147209f0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,6 +12,7 @@ - Fixed `createAgentSession()` subscribing the `credential_disabled` bridge to a freshly discovered `AuthStorage` orphan when an embedder supplied only `options.modelRegistry` (no `options.authStorage`). Refresh failures emitted by `modelRegistry.getApiKey()` flow through `modelRegistry.authStorage`, so a divergent local instance silently swallowed every disable event and also leaked into the `mcpManager` and session result. The SDK now reconciles `authStorage` to `modelRegistry.authStorage` up front and rejects mismatched `options.authStorage`/`options.modelRegistry.authStorage` pairs at session construction. - Fixed `runSubagent` (subagent task executor) carrying the same latent `AuthStorage`/`ModelRegistry` divergence as `createAgentSession()`: when only `options.modelRegistry` was supplied, the executor previously fell through to a fresh `discoverAuthStorage()` and handed that orphan into `createAgentSession()` alongside a registry whose `.authStorage` was a different instance. The executor now reconciles to `modelRegistry.authStorage` before any further work and rejects mismatched `options.authStorage`/`options.modelRegistry.authStorage` pairs the same way the SDK does, so subagents can no longer silently observe a different storage view than their parent. +- Fixed `github` tool's `search_issues`/`search_prs`/`search_code`/`search_commits`/`search_repos` ops always returning 0 results when the query contained more than one qualifier (e.g. `is:merged is:pr`, `is:open author:foo`). `gh search …` since the `advanced_search=true` rollout in gh 2.92 silently wraps multi-token positional queries in parentheses and quotes everything after the first qualifier as that qualifier's value (`is:"merged is:pr"`), which GitHub then matches as a literal state filter that no PR can satisfy. The tool now calls `gh api -X GET /search/ -f q=… -F per_page=…` directly so the qualifiers reach GitHub's search API verbatim. `is:issue`/`is:pr` and `repo:/` are appended internally to preserve the previous CLI-flag behavior; the user-facing query string in the formatted output is unchanged. `state` for merged PRs is derived from `pull_request.merged_at` so the rendered `State:` line stays `merged`/`closed`/`open` as before. ## [14.9.9] - 2026-05-12 diff --git a/packages/coding-agent/src/tools/gh.ts b/packages/coding-agent/src/tools/gh.ts index e86895d2f..b71dc1c6c 100644 --- a/packages/coding-agent/src/tools/gh.ts +++ b/packages/coding-agent/src/tools/gh.ts @@ -103,35 +103,81 @@ const GH_PR_CHECKOUT_FIELDS = [ "title", "url", ]; -const GH_SEARCH_FIELDS = [ - "author", - "createdAt", - "labels", - "number", - "repository", - "state", - "title", - "updatedAt", - "url", -]; -const GH_SEARCH_CODE_FIELDS = ["path", "repository", "sha", "textMatches", "url"]; -const GH_SEARCH_COMMITS_FIELDS = ["author", "commit", "committer", "id", "repository", "sha", "url"]; -const GH_SEARCH_REPOS_FIELDS = [ - "createdAt", - "description", - "forksCount", - "fullName", - "isArchived", - "isFork", - "isPrivate", - "language", - "openIssuesCount", - "owner", - "stargazersCount", - "updatedAt", - "url", - "visibility", -]; +// /search/ API response shapes (subset). Used when projecting raw +// REST results into the normalized `GhSearch*Result` shapes the formatters +// consume. We talk to the API directly because `gh search prs`/`issues` +// quotes multi-token positional queries (`is:"merged is:pr"`) and returns 0 +// hits — see https://github.com/cli/cli for the upstream regression. +interface GhApiSearchResponse { + total_count?: number; + incomplete_results?: boolean; + items?: T[]; +} +interface GhApiUser { + login?: string; + name?: string | null; +} +interface GhApiLabel { + name?: string; +} +interface GhApiPullRequestRef { + merged_at?: string | null; +} +interface GhApiSearchIssueItem { + number?: number; + title?: string; + state?: string; + state_reason?: string | null; + user?: GhApiUser | null; + labels?: GhApiLabel[]; + created_at?: string; + updated_at?: string; + html_url?: string; + repository_url?: string; + pull_request?: GhApiPullRequestRef | null; +} +interface GhApiSearchCodeItem { + name?: string; + path?: string; + sha?: string; + html_url?: string; + repository?: { full_name?: string } | null; + text_matches?: Array<{ fragment?: string; property?: string }>; +} +interface GhApiSearchCommitGitActor { + name?: string; + email?: string; + date?: string; +} +interface GhApiSearchCommitItem { + sha?: string; + node_id?: string; + html_url?: string; + author?: GhApiUser | null; + committer?: GhApiUser | null; + commit?: { + author?: GhApiSearchCommitGitActor | null; + committer?: GhApiSearchCommitGitActor | null; + message?: string; + } | null; + repository?: { full_name?: string } | null; +} +interface GhApiSearchRepoItem { + full_name?: string; + description?: string | null; + language?: string | null; + stargazers_count?: number; + forks_count?: number; + open_issues_count?: number; + archived?: boolean; + fork?: boolean; + private?: boolean; + visibility?: string | null; + updated_at?: string; + created_at?: string; + html_url?: string; + owner?: GhApiUser | null; +} const SEARCH_LIMIT_DEFAULT = 10; const SEARCH_LIMIT_MAX = 50; const FILE_PREVIEW_LIMIT = 50; @@ -699,13 +745,7 @@ function appendRepoFlag(args: string[], repo: string | undefined, identifier?: s args.push("--repo", repo); } -const SEARCH_FIELDS_BY_COMMAND: Record<"issues" | "prs" | "code" | "commits" | "repos", readonly string[]> = { - issues: GH_SEARCH_FIELDS, - prs: GH_SEARCH_FIELDS, - code: GH_SEARCH_CODE_FIELDS, - commits: GH_SEARCH_COMMITS_FIELDS, - repos: GH_SEARCH_REPOS_FIELDS, -}; +const REPO_API_URL_PREFIX = "https://api.github.com/repos/"; const RELATIVE_DURATION_PATTERN = /^(\d+)\s*(m|h|d|w|mo|y)$/i; const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; @@ -811,21 +851,98 @@ function composeSearchQuery(parts: ReadonlyArray): string { return cleaned.join(" "); } -function buildGhSearchArgs( - command: "issues" | "prs" | "code" | "commits" | "repos", +function buildGhApiSearchArgs( + endpoint: "issues" | "code" | "commits" | "repositories", query: string, limit: number, - repo: string | undefined, + extraHeaders?: ReadonlyArray, ): string[] { - const fields = SEARCH_FIELDS_BY_COMMAND[command]; - const args = ["search", command, "--limit", String(limit), "--json", fields.join(",")]; - if (command !== "repos") { - appendRepoFlag(args, repo); + const args = ["api", "-X", "GET", `/search/${endpoint}`, "-f", `q=${query}`, "-F", `per_page=${limit}`]; + for (const header of extraHeaders ?? []) { + args.push("-H", header); } - args.push("--", query); return args; } +function repoFromRepositoryUrl(value: string | undefined): string | undefined { + if (!value?.startsWith(REPO_API_URL_PREFIX)) return undefined; + return value.slice(REPO_API_URL_PREFIX.length); +} + +function apiUserToGhUser(user: GhApiUser | null | undefined): GhUser | undefined { + if (!user) return undefined; + const login = user.login ?? undefined; + const name = user.name ?? undefined; + if (login === undefined && name === undefined) return undefined; + return { login, name }; +} + +function apiLabelsToGhLabels(labels: GhApiLabel[] | undefined): GhLabel[] { + return labels?.map(label => ({ name: label.name })) ?? []; +} + +function apiIssueToSearchResult(item: GhApiSearchIssueItem): GhSearchResult { + const merged = Boolean(item.pull_request?.merged_at); + return { + author: apiUserToGhUser(item.user) ?? null, + createdAt: item.created_at, + labels: apiLabelsToGhLabels(item.labels), + number: item.number, + repository: { nameWithOwner: repoFromRepositoryUrl(item.repository_url) }, + state: merged ? "merged" : item.state, + title: item.title, + updatedAt: item.updated_at, + url: item.html_url, + }; +} + +function apiCodeToSearchResult(item: GhApiSearchCodeItem): GhSearchCodeResult { + return { + path: item.path, + repository: { nameWithOwner: item.repository?.full_name }, + sha: item.sha, + textMatches: item.text_matches?.map(match => ({ fragment: match.fragment, property: match.property })), + url: item.html_url, + }; +} + +function apiCommitToSearchResult(item: GhApiSearchCommitItem): GhSearchCommitResult { + return { + author: apiUserToGhUser(item.author) ?? null, + commit: item.commit + ? { + author: item.commit.author ?? null, + committer: item.commit.committer ?? null, + message: item.commit.message, + } + : null, + committer: apiUserToGhUser(item.committer) ?? null, + id: item.node_id, + repository: { nameWithOwner: item.repository?.full_name }, + sha: item.sha, + url: item.html_url, + }; +} + +function apiRepoToSearchResult(item: GhApiSearchRepoItem): GhSearchRepoResult { + return { + createdAt: item.created_at, + description: item.description, + forksCount: item.forks_count, + fullName: item.full_name, + isArchived: item.archived, + isFork: item.fork, + isPrivate: item.private, + language: item.language, + openIssuesCount: item.open_issues_count, + owner: apiUserToGhUser(item.owner) ?? null, + stargazersCount: item.stargazers_count, + updatedAt: item.updated_at, + url: item.html_url, + visibility: item.visibility ?? null, + }; +} + function sanitizeRemoteName(value: string): string { const sanitized = value .toLowerCase() @@ -2765,13 +2882,13 @@ async function executeSearchIssues( const limit = resolveSearchLimit(params.limit); const dateField = resolveSearchDateField("issues", params.dateField); const dateQualifier = buildSearchDateQualifier(dateField, params.since, params.until); - const query = composeSearchQuery([params.query, dateQualifier]); - const args = buildGhSearchArgs("issues", query, limit, repo); + const displayQuery = composeSearchQuery([params.query, dateQualifier]); + const apiQuery = composeSearchQuery([displayQuery, repo ? `repo:${repo}` : undefined, "is:issue"]); + const args = buildGhApiSearchArgs("issues", apiQuery, limit); - const items = await git.github.json(session.cwd, args, signal, { - repoProvided: Boolean(repo), - }); - return buildTextResult(formatSearchResults("issues", query, repo, items)); + const response = await git.github.json>(session.cwd, args, signal); + const items = (response.items ?? []).map(apiIssueToSearchResult); + return buildTextResult(formatSearchResults("issues", displayQuery, repo, items)); } async function executeSearchPrs( @@ -2783,13 +2900,13 @@ async function executeSearchPrs( const limit = resolveSearchLimit(params.limit); const dateField = resolveSearchDateField("prs", params.dateField); const dateQualifier = buildSearchDateQualifier(dateField, params.since, params.until); - const query = composeSearchQuery([params.query, dateQualifier]); - const args = buildGhSearchArgs("prs", query, limit, repo); + const displayQuery = composeSearchQuery([params.query, dateQualifier]); + const apiQuery = composeSearchQuery([displayQuery, repo ? `repo:${repo}` : undefined, "is:pr"]); + const args = buildGhApiSearchArgs("issues", apiQuery, limit); - const items = await git.github.json(session.cwd, args, signal, { - repoProvided: Boolean(repo), - }); - return buildTextResult(formatSearchResults("pull requests", query, repo, items)); + const response = await git.github.json>(session.cwd, args, signal); + const items = (response.items ?? []).map(apiIssueToSearchResult); + return buildTextResult(formatSearchResults("pull requests", displayQuery, repo, items)); } async function executeSearchCode( @@ -2803,11 +2920,11 @@ async function executeSearchCode( } const repo = normalizeOptionalString(params.repo); const limit = resolveSearchLimit(params.limit); - const args = buildGhSearchArgs("code", query, limit, repo); + const apiQuery = composeSearchQuery([query, repo ? `repo:${repo}` : undefined]); + const args = buildGhApiSearchArgs("code", apiQuery, limit, ["Accept: application/vnd.github.text-match+json"]); - const items = await git.github.json(session.cwd, args, signal, { - repoProvided: Boolean(repo), - }); + const response = await git.github.json>(session.cwd, args, signal); + const items = (response.items ?? []).map(apiCodeToSearchResult); return buildTextResult(formatSearchCodeResults(query, repo, items)); } @@ -2820,13 +2937,13 @@ async function executeSearchCommits( const limit = resolveSearchLimit(params.limit); const dateField = resolveSearchDateField("commits", params.dateField); const dateQualifier = buildSearchDateQualifier(dateField, params.since, params.until); - const query = composeSearchQuery([params.query, dateQualifier]); - const args = buildGhSearchArgs("commits", query, limit, repo); + const displayQuery = composeSearchQuery([params.query, dateQualifier]); + const apiQuery = composeSearchQuery([displayQuery, repo ? `repo:${repo}` : undefined]); + const args = buildGhApiSearchArgs("commits", apiQuery, limit); - const items = await git.github.json(session.cwd, args, signal, { - repoProvided: Boolean(repo), - }); - return buildTextResult(formatSearchCommitsResults(query, repo, items)); + const response = await git.github.json>(session.cwd, args, signal); + const items = (response.items ?? []).map(apiCommitToSearchResult); + return buildTextResult(formatSearchCommitsResults(displayQuery, repo, items)); } async function executeSearchRepos( @@ -2838,9 +2955,10 @@ async function executeSearchRepos( const dateField = resolveSearchDateField("repos", params.dateField); const dateQualifier = buildSearchDateQualifier(dateField, params.since, params.until); const query = composeSearchQuery([params.query, dateQualifier]); - const args = buildGhSearchArgs("repos", query, limit, undefined); + const args = buildGhApiSearchArgs("repositories", query, limit); - const items = await git.github.json(session.cwd, args, signal); + const response = await git.github.json>(session.cwd, args, signal); + const items = (response.items ?? []).map(apiRepoToSearchResult); return buildTextResult(formatSearchReposResults(query, items)); } diff --git a/packages/coding-agent/test/tools/gh.test.ts b/packages/coding-agent/test/tools/gh.test.ts index bb2c896bd..2653fd432 100644 --- a/packages/coding-agent/test/tools/gh.test.ts +++ b/packages/coding-agent/test/tools/gh.test.ts @@ -386,30 +386,34 @@ describe("github tool", () => { }); it("formats pull request search results", async () => { - vi.spyOn(git.github, "json").mockResolvedValue([ - { - number: 101, - title: "Add feature", - state: "OPEN", - author: { login: "dev1" }, - repository: { nameWithOwner: "owner/repo" }, - labels: [{ name: "feature" }], - createdAt: "2026-04-01T08:00:00Z", - updatedAt: "2026-04-01T09:00:00Z", - url: "https://github.com/owner/repo/pull/101", - }, - { - number: 102, - title: "Fix regression", - state: "CLOSED", - author: { login: "dev2" }, - repository: { nameWithOwner: "owner/repo" }, - labels: [], - createdAt: "2026-03-31T08:00:00Z", - updatedAt: "2026-03-31T09:00:00Z", - url: "https://github.com/owner/repo/pull/102", - }, - ]); + vi.spyOn(git.github, "json").mockResolvedValue({ + items: [ + { + number: 101, + title: "Add feature", + state: "open", + user: { login: "dev1" }, + repository_url: "https://api.github.com/repos/owner/repo", + labels: [{ name: "feature" }], + created_at: "2026-04-01T08:00:00Z", + updated_at: "2026-04-01T09:00:00Z", + html_url: "https://github.com/owner/repo/pull/101", + pull_request: { merged_at: null }, + }, + { + number: 102, + title: "Fix regression", + state: "closed", + user: { login: "dev2" }, + repository_url: "https://api.github.com/repos/owner/repo", + labels: [], + created_at: "2026-03-31T08:00:00Z", + updated_at: "2026-03-31T09:00:00Z", + html_url: "https://github.com/owner/repo/pull/102", + pull_request: { merged_at: "2026-03-31T10:00:00Z" }, + }, + ], + }); const tool = new GithubTool(createSession()); const result = await tool.execute("search-prs", { @@ -424,12 +428,15 @@ describe("github tool", () => { expect(text).toContain("Query: feature"); expect(text).toContain("Repository: owner/repo"); expect(text).toContain("- #101 Add feature"); + expect(text).toContain(" State: open"); expect(text).toContain(" Labels: feature"); expect(text).toContain("- #102 Fix regression"); + // merged_at present → state surfaces as "merged" even though the API says "closed". + expect(text).toContain(" State: merged"); }); - it("passes leading-dash search queries after -- so gh does not parse them as flags", async () => { - const runGhJsonSpy = vi.spyOn(git.github, "json").mockResolvedValue([]); + it("calls /search/issues via gh api with the full query verbatim (including leading-dash terms)", async () => { + const runGhJsonSpy = vi.spyOn(git.github, "json").mockResolvedValue({ items: [] }); const tool = new GithubTool(createSession()); await tool.execute("search-issues", { @@ -448,14 +455,14 @@ describe("github tool", () => { const issueArgs = runGhJsonSpy.mock.calls[0]?.[1]; const prArgs = runGhJsonSpy.mock.calls[1]?.[1]; - expect(issueArgs?.slice(0, 2)).toEqual(["search", "issues"]); - expect(issueArgs?.at(2)).toBe("--limit"); - expect(issueArgs?.at(-2)).toBe("--"); - expect(issueArgs?.at(-1)).toBe("-label:bug"); - expect(prArgs?.slice(0, 2)).toEqual(["search", "prs"]); - expect(prArgs?.at(2)).toBe("--limit"); - expect(prArgs?.at(-2)).toBe("--"); - expect(prArgs?.at(-1)).toBe("-label:bug"); + // `gh api` carries each form field separately, so leading dashes inside `q=` are not + // parsed as flags — replaces the historical `--` positional workaround. + expect(issueArgs?.slice(0, 4)).toEqual(["api", "-X", "GET", "/search/issues"]); + expect(issueArgs).toContain("q=-label:bug repo:owner/repo is:issue"); + expect(issueArgs).toContain("per_page=1"); + expect(prArgs?.slice(0, 4)).toEqual(["api", "-X", "GET", "/search/issues"]); + expect(prArgs).toContain("q=-label:bug repo:owner/repo is:pr"); + expect(prArgs).toContain("per_page=1"); }); it("parseSearchDateBound: relative duration walks back from `now` and returns YYYY-MM-DD", () => { @@ -485,8 +492,8 @@ describe("github tool", () => { expect(buildSearchDateQualifier("created", undefined, undefined)).toBeUndefined(); }); - it("search_issues: appends a created:>= qualifier built from `since`", async () => { - const spy = vi.spyOn(git.github, "json").mockResolvedValue([]); + it("search_issues: appends a created:>= qualifier built from `since` and tags `is:issue`", async () => { + const spy = vi.spyOn(git.github, "json").mockResolvedValue({ items: [] }); const tool = new GithubTool(createSession()); await tool.execute("search-issues", { op: "search_issues", @@ -497,11 +504,11 @@ describe("github tool", () => { }); const args = spy.mock.calls[0]?.[1]; - expect(args?.at(-1)).toBe("is:open created:>=2026-05-01"); + expect(args).toContain("q=is:open created:>=2026-05-01 repo:owner/repo is:issue"); }); - it("search_prs: builds a qualifier-only query when `query` is omitted", async () => { - const spy = vi.spyOn(git.github, "json").mockResolvedValue([]); + it("search_prs: builds a qualifier-only query when `query` is omitted and tags `is:pr`", async () => { + const spy = vi.spyOn(git.github, "json").mockResolvedValue({ items: [] }); const tool = new GithubTool(createSession()); await tool.execute("search-prs", { op: "search_prs", @@ -513,11 +520,11 @@ describe("github tool", () => { }); const args = spy.mock.calls[0]?.[1]; - expect(args?.at(-1)).toBe("updated:2026-05-01..2026-05-09"); + expect(args).toContain("q=updated:2026-05-01..2026-05-09 repo:owner/repo is:pr"); }); it("search_prs: errors when neither `query` nor a date bound is provided", async () => { - vi.spyOn(git.github, "json").mockResolvedValue([]); + vi.spyOn(git.github, "json").mockResolvedValue({ items: [] }); const tool = new GithubTool(createSession()); await expect(tool.execute("search-prs", { op: "search_prs", repo: "owner/repo" })).rejects.toThrow( /query is required/, @@ -525,7 +532,7 @@ describe("github tool", () => { }); it("search_commits: forces `committer-date` regardless of `dateField`", async () => { - const spy = vi.spyOn(git.github, "json").mockResolvedValue([]); + const spy = vi.spyOn(git.github, "json").mockResolvedValue({ items: [] }); const tool = new GithubTool(createSession()); await tool.execute("search-commits", { op: "search_commits", @@ -537,11 +544,12 @@ describe("github tool", () => { }); const args = spy.mock.calls[0]?.[1]; - expect(args?.at(-1)).toBe("refactor committer-date:>=2026-05-01"); + expect(args?.slice(0, 4)).toEqual(["api", "-X", "GET", "/search/commits"]); + expect(args).toContain("q=refactor committer-date:>=2026-05-01 repo:owner/repo"); }); it("search_repos: maps dateField=updated to the `pushed:` qualifier", async () => { - const spy = vi.spyOn(git.github, "json").mockResolvedValue([]); + const spy = vi.spyOn(git.github, "json").mockResolvedValue({ items: [] }); const tool = new GithubTool(createSession()); await tool.execute("search-repos", { op: "search_repos", @@ -552,11 +560,12 @@ describe("github tool", () => { }); const args = spy.mock.calls[0]?.[1]; - expect(args?.at(-1)).toBe("language:rust pushed:>=2026-05-01"); + expect(args?.slice(0, 4)).toEqual(["api", "-X", "GET", "/search/repositories"]); + expect(args).toContain("q=language:rust pushed:>=2026-05-01"); }); it("search_code: rejects since/until since GitHub code search has no date qualifier", async () => { - const spy = vi.spyOn(git.github, "json").mockResolvedValue([]); + const spy = vi.spyOn(git.github, "json").mockResolvedValue({ items: [] }); const tool = new GithubTool(createSession()); await expect(tool.execute("search-code", { op: "search_code", query: "foo", since: "3d" })).rejects.toThrow( /search_code does not support since\/until/, @@ -565,15 +574,17 @@ describe("github tool", () => { }); it("formats code search results with paths, repo, sha, and match fragment", async () => { - vi.spyOn(git.github, "json").mockResolvedValue([ - { - path: "src/lib.ts", - repository: { nameWithOwner: "owner/repo" }, - sha: "abcdef1234567890", - url: "https://github.com/owner/repo/blob/abcdef1234567890/src/lib.ts", - textMatches: [{ fragment: "function findThing(): void {\n ...\n}", property: "content" }], - }, - ]); + const spy = vi.spyOn(git.github, "json").mockResolvedValue({ + items: [ + { + path: "src/lib.ts", + repository: { full_name: "owner/repo" }, + sha: "abcdef1234567890", + html_url: "https://github.com/owner/repo/blob/abcdef1234567890/src/lib.ts", + text_matches: [{ fragment: "function findThing(): void {\n ...\n}", property: "content" }], + }, + ], + }); const tool = new GithubTool(createSession()); const result = await tool.execute("search-code", { @@ -591,21 +602,30 @@ describe("github tool", () => { expect(text).toContain(" Repo: owner/repo"); expect(text).toContain(" Commit: abcdef123456"); expect(text).toContain(" Match: function findThing(): void {"); + + // Code search needs the text-match accept header to populate `text_matches`. + const args = spy.mock.calls[0]?.[1] ?? []; + const acceptIndex = args.indexOf("Accept: application/vnd.github.text-match+json"); + expect(acceptIndex).toBeGreaterThan(-1); + expect(args[acceptIndex - 1]).toBe("-H"); + expect(args).toContain("q=findThing repo:owner/repo"); }); it("formats commit search results with short sha and message subject", async () => { - vi.spyOn(git.github, "json").mockResolvedValue([ - { - sha: "0123456789abcdef", - author: { login: "octocat" }, - commit: { - message: "Fix flaky test\n\nMore detail in the body.", - author: { name: "Mona Lisa", email: "mona@example.com", date: "2026-04-01T12:00:00Z" }, + vi.spyOn(git.github, "json").mockResolvedValue({ + items: [ + { + sha: "0123456789abcdef", + author: { login: "octocat" }, + commit: { + message: "Fix flaky test\n\nMore detail in the body.", + author: { name: "Mona Lisa", email: "mona@example.com", date: "2026-04-01T12:00:00Z" }, + }, + repository: { full_name: "owner/repo" }, + html_url: "https://github.com/owner/repo/commit/0123456789abcdef", }, - repository: { nameWithOwner: "owner/repo" }, - url: "https://github.com/owner/repo/commit/0123456789abcdef", - }, - ]); + ], + }); const tool = new GithubTool(createSession()); const result = await tool.execute("search-commits", { @@ -623,22 +643,24 @@ describe("github tool", () => { expect(text).toContain(" Date: 2026-04-01T12:00:00Z"); }); - it("formats repository search results without forwarding --repo", async () => { - const runGhJsonSpy = vi.spyOn(git.github, "json").mockResolvedValue([ - { - fullName: "octocat/hello-world", - description: "First line.\nSecond line should not surface.", - language: "TypeScript", - stargazersCount: 42, - forksCount: 7, - openIssuesCount: 3, - visibility: "public", - isArchived: false, - isFork: false, - updatedAt: "2026-04-01T09:00:00Z", - url: "https://github.com/octocat/hello-world", - }, - ]); + it("formats repository search results and never injects the repo qualifier", async () => { + const runGhJsonSpy = vi.spyOn(git.github, "json").mockResolvedValue({ + items: [ + { + full_name: "octocat/hello-world", + description: "First line.\nSecond line should not surface.", + language: "TypeScript", + stargazers_count: 42, + forks_count: 7, + open_issues_count: 3, + visibility: "public", + archived: false, + fork: false, + updated_at: "2026-04-01T09:00:00Z", + html_url: "https://github.com/octocat/hello-world", + }, + ], + }); const tool = new GithubTool(createSession()); const result = await tool.execute("search-repos", { @@ -656,11 +678,11 @@ describe("github tool", () => { expect(text).toContain(" Language: TypeScript"); expect(text).toContain(" Stars: 42"); - const reposArgs = runGhJsonSpy.mock.calls[0]?.[1]; - expect(reposArgs?.slice(0, 2)).toEqual(["search", "repos"]); - expect(reposArgs).not.toContain("--repo"); - expect(reposArgs?.at(-2)).toBe("--"); - expect(reposArgs?.at(-1)).toBe("language:typescript stars:>100"); + const reposArgs = runGhJsonSpy.mock.calls[0]?.[1] ?? []; + expect(reposArgs.slice(0, 4)).toEqual(["api", "-X", "GET", "/search/repositories"]); + // `repo:` is ignored for repository searches even when supplied — query is forwarded as-is. + expect(reposArgs).toContain("q=language:typescript stars:>100"); + expect(reposArgs.some(arg => typeof arg === "string" && arg.includes("repo:ignored/value"))).toBe(false); }); it("returns diff output under a stable heading without rewriting patch content", async () => {