fix: expose issue lifecycle json fields

This commit is contained in:
Codex
2026-06-04 10:54:49 +00:00
parent 17e0be8580
commit 2c67aa5a36
3 changed files with 45 additions and 11 deletions
File diff suppressed because one or more lines are too long
+30 -3
View File
@@ -98,6 +98,7 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
user: { login: "tester" },
created_at: "2026-05-20T00:00:00Z",
updated_at: "2026-05-20T01:00:00Z",
closed_at: null,
};
const shorthandIssue = {
id: 7000,
@@ -110,6 +111,7 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
user: { login: "tester" },
created_at: "2026-05-20T02:00:00Z",
updated_at: shorthandIssueUpdatedAt,
closed_at: null,
};
const boardIssueBodyInitial = [
"# Code Queue",
@@ -254,6 +256,7 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
labels: [{ name: "cli", color: "1d76db", description: "CLI work" }],
created_at: "2026-05-20T02:00:00Z",
updated_at: "2026-05-20T03:00:00Z",
closed_at: null,
},
{
id: 2002,
@@ -267,6 +270,7 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
labels: [],
created_at: "2026-05-20T02:05:00Z",
updated_at: "2026-05-20T03:05:00Z",
closed_at: null,
},
{
id: 3001,
@@ -296,6 +300,7 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
labels: [],
created_at: "2026-05-20T02:30:00Z",
updated_at: "2026-05-20T03:30:00Z",
closed_at: null,
},
];
const scanIssues = [
@@ -413,6 +418,7 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
labels: [],
created_at: "2026-05-18T02:00:00Z",
updated_at: "2026-05-20T03:00:00Z",
closed_at: "2026-05-20T03:15:00Z",
},
{
id: 2040,
@@ -426,6 +432,7 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
labels: [],
created_at: "2026-05-19T02:00:00Z",
updated_at: "2026-05-20T03:00:00Z",
closed_at: "2026-05-20T03:20:00Z",
},
{
id: 2041,
@@ -439,6 +446,7 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
labels: [],
created_at: "2026-05-19T02:05:00Z",
updated_at: "2026-05-20T03:05:00Z",
closed_at: "2026-05-20T03:25:00Z",
},
];
const comments = [
@@ -663,6 +671,11 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
const mock = await startMockGitHub();
const tmp = mkdtempSync(join(tmpdir(), "unidesk-gh-issue-guard-"));
const startedAt = Date.now();
const heartbeat = setInterval(() => {
const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000);
process.stderr.write(`[gh-issue-contract] elapsed=${elapsedSeconds}s requests=${mock.requests.length}\n`);
}, 10_000);
const env = {
GH_TOKEN: "contract-token-should-not-print",
UNIDESK_GITHUB_API_URL: mock.baseUrl,
@@ -680,6 +693,18 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
assertCondition(listOpenIssues[0]?.title === "master:补齐 UniDesk CLI gh issue list 与 PR 驱动最小闭环前置能力", "issue list should expose title field", listOpenData);
assertCondition(!("labels" in listOpenIssues[0]), "issue list --json should select only requested fields", listOpenIssues[0]);
const listOpenLifecycle = await runCli(["gh", "issue", "list", "--repo", "pikasTech/unidesk", "--state", "open", "--limit", "2", "--json", "number,state,closed,closedAt"], env);
assertCondition(listOpenLifecycle.status === 0, "issue list lifecycle fields should succeed", listOpenLifecycle.json ?? { stdout: listOpenLifecycle.stdout });
const listOpenLifecycleData = dataOf(listOpenLifecycle.json ?? {});
const listOpenLifecycleIssues = listOpenLifecycleData.issues as JsonRecord[];
assertCondition(listOpenLifecycleIssues[0]?.closed === false && listOpenLifecycleIssues[0]?.closedAt === null, "open issue list rows should expose closed=false and closedAt=null", listOpenLifecycleData);
const listClosedLifecycle = await runCli(["gh", "issue", "list", "--repo", "pikasTech/unidesk", "--state", "closed", "--limit", "2", "--json", "number,state,closed,closedAt"], env);
assertCondition(listClosedLifecycle.status === 0, "closed issue list lifecycle fields should succeed", listClosedLifecycle.json ?? { stdout: listClosedLifecycle.stdout });
const listClosedLifecycleData = dataOf(listClosedLifecycle.json ?? {});
const listClosedLifecycleIssues = listClosedLifecycleData.issues as JsonRecord[];
assertCondition(listClosedLifecycleIssues[0]?.state === "closed" && listClosedLifecycleIssues[0]?.closed === true && listClosedLifecycleIssues[0]?.closedAt === "2026-05-20T03:15:00Z", "closed issue list rows should expose closed=true and closedAt", listClosedLifecycleData);
const acceptanceList = await runCli(["gh", "issue", "list", "--repo", "pikasTech/unidesk", "--state", "open", "--limit", "5", "--json", "number,title,state,url"], env);
assertCondition(acceptanceList.status === 0, "acceptance issue list command should succeed under mock GitHub", acceptanceList.json ?? { stdout: acceptanceList.stdout });
const acceptanceListData = dataOf(acceptanceList.json ?? {});
@@ -1256,18 +1281,19 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
assertCondition(shorthandConflictData.degradedReason === "validation-failed", "conflicting --repo should be validation-failed", shorthandConflictData);
assertCondition(String(shorthandConflictData.message ?? "").includes("resolves to repo pikasTech/HWLAB"), "conflict message should name the derived repo", shorthandConflictData);
const issueConflictCommands = shorthandConflictData.supportedCommands as string[];
assertCondition(Array.isArray(issueConflictCommands) && issueConflictCommands.some((command) => command === "bun scripts/cli.ts gh issue read 7 --repo pikasTech/HWLAB --json body,title,state,comments"), "conflict should include the exact supported issue read command", shorthandConflictData);
assertCondition(Array.isArray(issueConflictCommands) && issueConflictCommands.some((command) => command === "bun scripts/cli.ts gh issue read 7 --repo pikasTech/HWLAB --json body,title,state,closed,closedAt,comments,number,url,author,createdAt,updatedAt"), "conflict should include the exact supported issue read command", shorthandConflictData);
const rawIssueList = await runCli(["gh", "issue", "list", "--raw"], env);
assertCondition(rawIssueList.status === 0, "issue list --raw should be a supported explicit list disclosure path", rawIssueList.json ?? { stdout: rawIssueList.stdout });
const rawIssueListData = dataOf(rawIssueList.json ?? {});
assertCondition(rawIssueListData.command === "issue list" && rawIssueListData.rawCount === 3, "issue list --raw should keep compact list semantics with raw pagination metadata", rawIssueListData);
const readFields = await runCli(["gh", "issue", "read", "20", "--repo", "pikasTech/unidesk", "--json", "body,title,state,comments"], env);
const readFields = await runCli(["gh", "issue", "read", "20", "--repo", "pikasTech/unidesk", "--json", "body,title,state,closed,closedAt,comments"], env);
assertCondition(readFields.status === 0, "common --json field selection should succeed", readFields.json ?? { stdout: readFields.stdout });
const readFieldsData = dataOf(readFields.json ?? {});
const fieldsJson = readFieldsData.json as JsonRecord;
assertCondition(fieldsJson.title === "长期总看板", "selected json title should be exposed", fieldsJson);
assertCondition(fieldsJson.closed === false && fieldsJson.closedAt === null, "open issue read should expose lifecycle fields", fieldsJson);
assertCondition(Array.isArray(fieldsJson.comments) && fieldsJson.comments.length === 1, "selected json comments should be exposed", fieldsJson);
const unsupported = await runCli(["gh", "issue", "read", "20", "--repo", "pikasTech/unidesk", "--json", "body,unknown"], env);
@@ -1760,7 +1786,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
"issue create dry-run parses repeated/comma labels, supports stdin, rejects inline --body, and exposes request plan",
"issue create sends labels through REST and preserves GitHub validation errors for missing labels",
"issue list unsupported fields and states fail structurally",
"issue read supports body,title,state,comments selection",
"issue read supports body,title,state,closed,closedAt,comments selection",
"unknown/full disclosure option guidance remains actionable",
"unsupported --json fields fail structurally",
"issue edit --body-file rejects literal null",
@@ -1783,6 +1809,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> {
],
};
} finally {
clearInterval(heartbeat);
rmSync(tmp, { recursive: true, force: true });
await mock.close();
}
+14 -7
View File
@@ -24,8 +24,8 @@ const DEFAULT_BOARD_KNOWN_META_ISSUES = [CODE_QUEUE_BOARD_TARGET_ISSUE, COMMANDE
const BOARD_AUDIT_REQUIRED_COLUMNS = ["branch", "acceptance", "relatedTask", "progress"] as const;
const BOARD_ROW_FIELDS = ["progress", "status", "validation", "branch", "tasks", "focus"] as const;
const BOARD_ROW_UPSERT_TEXT_FIELDS = ["category", "branch", "tasks", "summary", "focus", "validation", "progress"] as const;
const ISSUE_VIEW_JSON_FIELDS = ["body", "title", "state", "comments", "number", "url", "author", "createdAt", "updatedAt"] as const;
const ISSUE_LIST_JSON_FIELDS = ["number", "title", "state", "url", "updatedAt", "createdAt", "author", "labels"] as const;
const ISSUE_VIEW_JSON_FIELDS = ["body", "title", "state", "closed", "closedAt", "comments", "number", "url", "author", "createdAt", "updatedAt"] as const;
const ISSUE_LIST_JSON_FIELDS = ["number", "title", "state", "closed", "closedAt", "url", "updatedAt", "createdAt", "author", "labels"] as const;
const PR_LIST_JSON_FIELDS = [
"body",
"title",
@@ -428,6 +428,7 @@ interface GitHubIssue {
pull_request?: unknown;
created_at?: string;
updated_at?: string;
closed_at?: string | null;
}
interface GitHubComment {
@@ -972,7 +973,7 @@ function readViewSupportedCommands(kind: "issue" | "pr", repo: string, number: n
function readViewSupportedJsonFields(kind: "issue" | "pr"): string {
return kind === "issue"
? "body,title,state,comments"
? ISSUE_VIEW_JSON_FIELDS.join(",")
: "body,title,state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup";
}
@@ -2072,6 +2073,8 @@ function issueSummary(issue: GitHubIssue, options: { includeBody?: boolean; prev
number: issue.number,
title: issue.title,
state: issue.state,
closed: issue.state === "closed",
closedAt: issue.closed_at ?? null,
url: issue.html_url,
author: issue.user?.login ?? null,
createdAt: issue.created_at ?? null,
@@ -2097,6 +2100,8 @@ function issueLifecycleSummary(issue: GitHubIssue): Record<string, unknown> {
number: issue.number,
title: issue.title,
state: issue.state,
closed: issue.state === "closed",
closedAt: issue.closed_at ?? null,
url: issue.html_url,
author: issue.user?.login ?? null,
createdAt: issue.created_at ?? null,
@@ -2151,6 +2156,8 @@ function issueListSummary(issue: GitHubIssue, fields: IssueListJsonField[]): Rec
number: issue.number,
title: issue.title,
state: issue.state,
closed: issue.state === "closed",
closedAt: issue.closed_at ?? null,
url: issue.html_url,
updatedAt: issue.updated_at ?? null,
createdAt: issue.created_at ?? null,
@@ -6376,8 +6383,8 @@ 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] [--search text] [--label label[,label...]]... [--repo owner/name] [--json number,title,state,url,updatedAt,createdAt,author,labels] [--raw|--full]",
"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 list [owner/repo] [--state open|closed|all] [--limit N] [--search text] [--label label[,label...]]... [--repo owner/name] [--json number,title,state,closed,closedAt,url,updatedAt,createdAt,author,labels] [--raw|--full]",
"bun scripts/cli.ts gh issue read <number|owner/repo#number> [--repo owner/name] [--json body,title,state,closed,closedAt,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]",
"bun scripts/cli.ts gh issue update <number> --mode replace|append --body-file <file|-> [--title title] [--repo owner/name] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256] [--body-profile auto|code-queue-board|commander-brief] [--allow-short-body] [--full|--raw]",
@@ -6420,9 +6427,9 @@ 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; it paginates GitHub REST/Search pages internally when --limit exceeds GitHub's per-page cap and discloses pagination/rawCount/hasMore so operators do not mistake a single page for the full repository. --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.",
"issue list defaults to --state open and bounded --limit 30; it paginates GitHub REST/Search pages internally when --limit exceeds GitHub's per-page cap and discloses pagination/rawCount/hasMore so operators do not mistake a single page for the full repository. --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,closed,closedAt,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.",
"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 lifecycle fields closed/closedAt plus 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 list/read/view/update/edit and gh pr list/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 only on commands that explicitly support full disclosure.",
"GitHub CLI output larger than 20 KiB is automatically written to /tmp/unidesk-cli-output/*.json; stdout stays bounded JSON with outputTruncated=true, the dump path, total bytes/lines, and head/tail previews.",
"issue create accepts --body-file <file|-> plus repeatable --label values and comma-separated labels; inline --body is intentionally unsupported for issue creation. Dry-run prints the parsed labels and non-dry-run sends them in the GitHub REST create-issue payload.",