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:
@@ -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})`
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user