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
This commit is contained in:
roboomp
2026-06-11 19:55:22 +00:00
parent 37faeb6a6d
commit 5bc96d6408
4 changed files with 72 additions and 5 deletions
+4
View File
@@ -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
@@ -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<Array<IssueListItem | PrListItem>>(cwd, args, context?.signal, {
repoProvided: true,
});
const items =
scheme === "issue"
? await githubIssueJsonWithStateReasonFallback<Array<IssueListItem>>(cwd, args, context?.signal, {
repoProvided: true,
})
: await git.github.json<Array<PrListItem>>(cwd, args, context?.signal, {
repoProvided: true,
});
const header =
scheme === "issue"
? `# Issues in ${repo} (${options.state}, up to ${options.limit})`
+39 -1
View File
@@ -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<T>(
cwd: string,
args: readonly string[],
signal: AbortSignal | undefined,
options?: git.GhCommandOptions,
): Promise<T> {
try {
return await git.github.json<T>(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<T>(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<GhIssueViewData>(cwd, args, signal, {
const data = await githubIssueJsonWithStateReasonFallback<GhIssueViewData>(cwd, args, signal, {
repoProvided: Boolean(repo),
});
const rendered = formatIssueView(data, { issue: identifier, repo, comments: includeComments });
@@ -173,6 +173,24 @@ describe("issue:// protocol handler", () => {
expect(spy).toHaveBeenCalledTimes(1);
});
it("retries issue://owner/repo/<n> 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 () => {