From 5bc96d6408b4d9587e126ffb4d7f24a04bdfd5db Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 11 Jun 2026 19:55:22 +0000 Subject: [PATCH] fix(tool): tolerated missing issue state reason Older GitHub CLI releases reject the optional issue stateReason JSON field. Retried single issue reads without it and stopped requesting it for issue listings.\n\nFixes #2333 --- packages/coding-agent/CHANGELOG.md | 4 ++ .../src/internal-urls/issue-pr-protocol.ts | 14 +++++-- packages/coding-agent/src/tools/gh.ts | 40 ++++++++++++++++++- .../internal-urls/issue-pr-protocol.test.ts | 19 +++++++++ 4 files changed, 72 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ffc6c36da..aad31f24d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `issue://` reads failing on older GitHub CLI releases that reject the optional `stateReason` issue JSON field; single issue reads now retry without it and issue listings no longer request it ([#2333](https://github.com/can1357/oh-my-pi/issues/2333)). + ## [15.11.3] - 2026-06-11 ### Fixed diff --git a/packages/coding-agent/src/internal-urls/issue-pr-protocol.ts b/packages/coding-agent/src/internal-urls/issue-pr-protocol.ts index 7277d19e7..a65e8a797 100644 --- a/packages/coding-agent/src/internal-urls/issue-pr-protocol.ts +++ b/packages/coding-agent/src/internal-urls/issue-pr-protocol.ts @@ -23,6 +23,7 @@ import { getOrFetchIssue, getOrFetchPr, getOrFetchPrDiff, + githubIssueJsonWithStateReasonFallback, type PrDiffFile, parsePositiveDecimalInt, resolveDefaultRepoMemoized, @@ -294,7 +295,7 @@ async function fetchAndRenderList( const cwd = resolveCwd(context); const fields = scheme === "issue" - ? ["number", "title", "state", "stateReason", "author", "labels", "createdAt", "updatedAt", "url"] + ? ["number", "title", "state", "author", "labels", "createdAt", "updatedAt", "url"] : [ "number", "title", @@ -323,9 +324,14 @@ async function fetchAndRenderList( if (options.author) args.push("--author", options.author); if (options.label) args.push("--label", options.label); - const items = await git.github.json>(cwd, args, context?.signal, { - repoProvided: true, - }); + const items = + scheme === "issue" + ? await githubIssueJsonWithStateReasonFallback>(cwd, args, context?.signal, { + repoProvided: true, + }) + : await git.github.json>(cwd, args, context?.signal, { + repoProvided: true, + }); const header = scheme === "issue" ? `# Issues in ${repo} (${options.state}, up to ${options.limit})` diff --git a/packages/coding-agent/src/tools/gh.ts b/packages/coding-agent/src/tools/gh.ts index 9225fbb1a..6999892a8 100644 --- a/packages/coding-agent/src/tools/gh.ts +++ b/packages/coding-agent/src/tools/gh.ts @@ -63,6 +63,44 @@ const GH_ISSUE_FIELDS_NO_COMMENTS = [ "updatedAt", "url", ]; + +const GH_ISSUE_STATE_REASON_FIELD = "stateReason"; + +function ghJsonErrorNamesField(err: unknown, field: string): boolean { + if (!(err instanceof Error) || !err.message.includes("Unknown JSON field")) return false; + return err.message.includes(`"${field}"`) || err.message.includes(`'${field}'`) || err.message.includes(field); +} + +function dropJsonField(args: readonly string[], field: string): string[] | undefined { + const next = [...args]; + const jsonIndex = next.indexOf("--json"); + if (jsonIndex < 0) return undefined; + const fields = next[jsonIndex + 1]; + if (!fields) return undefined; + const splitFields = fields.split(","); + const kept = splitFields.filter(candidate => candidate !== field); + if (kept.length === splitFields.length) return undefined; + next[jsonIndex + 1] = kept.join(","); + return next; +} + +/** Runs `gh --json` for issue data, retrying without optional stateReason on older gh releases. */ +export async function githubIssueJsonWithStateReasonFallback( + cwd: string, + args: readonly string[], + signal: AbortSignal | undefined, + options?: git.GhCommandOptions, +): Promise { + try { + return await git.github.json(cwd, [...args], signal, options); + } catch (err) { + if (!ghJsonErrorNamesField(err, GH_ISSUE_STATE_REASON_FIELD)) throw err; + const retryArgs = dropJsonField(args, GH_ISSUE_STATE_REASON_FIELD); + if (!retryArgs) throw err; + return await git.github.json(cwd, retryArgs, signal, options); + } +} + const GH_PR_FIELDS = [ "author", "baseRefName", @@ -2549,7 +2587,7 @@ async function fetchIssueViewFresh( const args = ["issue", "view", identifier]; appendRepoFlag(args, repo, identifier); args.push("--json", (includeComments ? GH_ISSUE_FIELDS : GH_ISSUE_FIELDS_NO_COMMENTS).join(",")); - const data = await git.github.json(cwd, args, signal, { + const data = await githubIssueJsonWithStateReasonFallback(cwd, args, signal, { repoProvided: Boolean(repo), }); const rendered = formatIssueView(data, { issue: identifier, repo, comments: includeComments }); diff --git a/packages/coding-agent/test/internal-urls/issue-pr-protocol.test.ts b/packages/coding-agent/test/internal-urls/issue-pr-protocol.test.ts index 65be6e5cd..d4c36a5f3 100644 --- a/packages/coding-agent/test/internal-urls/issue-pr-protocol.test.ts +++ b/packages/coding-agent/test/internal-urls/issue-pr-protocol.test.ts @@ -173,6 +173,24 @@ describe("issue:// protocol handler", () => { expect(spy).toHaveBeenCalledTimes(1); }); + it("retries issue://owner/repo/ without stateReason when gh does not support it", async () => { + const spy = vi.spyOn(git.github, "json").mockImplementation(async (_cwd, args) => { + if (requestedJsonFields(args).has("stateReason")) { + throw new Error('Unknown JSON field: "stateReason"'); + } + return issuePayload(42, "issue body") as never; + }); + + const router = InternalUrlRouter.instance(); + const resource = await router.resolve("issue://owner/example/42"); + + expect(resource.content).toContain("# Issue #42: Issue #42"); + expect(resource.content).not.toContain("State reason"); + expect(spy).toHaveBeenCalledTimes(2); + expect(requestedJsonFields(spy.mock.calls[0]?.[1] as string[]).has("stateReason")).toBe(true); + expect(requestedJsonFields(spy.mock.calls[1]?.[1] as string[]).has("stateReason")).toBe(false); + }); + it("?comments=0 selects a separate cache row with comments suppressed", async () => { const spy = vi .spyOn(git.github, "json") @@ -400,6 +418,7 @@ describe("issue:// / pr:// listing", () => { expect(args[1]).toBe("list"); expect(args).toEqual(expect.arrayContaining(["--repo", "owner/example"])); expect(args).toEqual(expect.arrayContaining(["--state", "open"])); + expect(requestedJsonFields(args).has("stateReason")).toBe(false); }); it("pr://owner/repo passes state and limit query params through to gh", async () => {