diff --git a/scripts/cli.ts b/scripts/cli.ts index 578d4eb9..a14dccbb 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -123,7 +123,7 @@ function displayCommandName(parts: string[]): string { for (let index = 0; index < parts.length; index += 1) { const part = parts[index] ?? ""; shown.push(part); - if (part === "--body") { + if (part === "--body" || part === "--comment") { shown.push(""); index += 1; } diff --git a/scripts/gh-cli-issue-guard-contract-test.ts b/scripts/gh-cli-issue-guard-contract-test.ts index 26c5f0e5..ea21b7d9 100644 --- a/scripts/gh-cli-issue-guard-contract-test.ts +++ b/scripts/gh-cli-issue-guard-contract-test.ts @@ -79,6 +79,10 @@ function sendJson(res: ServerResponse, status: number, payload: unknown): void { res.end(JSON.stringify(payload)); } +function mockUrl(rawUrl: string | undefined): URL { + return new URL(rawUrl ?? "/", "http://mock.local"); +} + async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockRequest[]; close: () => Promise }> { const requests: MockRequest[] = []; let shorthandIssueBody = "HWLAB-style shorthand body fixture\n\nThis is generic CLI coverage, not product data."; @@ -470,9 +474,14 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque ], 53: [], }; + let unideskAllIssuesRequestCount = 0; const server = createServer(async (req, res) => { const body = await collectBody(req); requests.push({ method: req.method ?? "", url: req.url ?? "", body }); + const url = mockUrl(req.url); + const state = url.searchParams.get("state") ?? ""; + const perPage = url.searchParams.get("per_page") ?? ""; + const page = url.searchParams.get("page") ?? "1"; if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/20") { sendJson(res, 200, { ...issue, body: boardIssueBody, updated_at: boardIssueUpdatedAt }); return; @@ -521,43 +530,24 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque sendJson(res, 200, []); return; } - if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues?state=open&per_page=2") { - sendJson(res, 200, issueList.slice(0, 2)); + if (req.method === "GET" && url.pathname === "/repos/pikasTech/unidesk/issues" && state === "open" && perPage === "100" && page === "1") { + sendJson(res, 200, issueList); return; } - if (req.method === "GET" && req.url === "/search/issues?q=AgentRun+final+response+repo%3ApikasTech%2Funidesk+type%3Aissue&per_page=4") { + if (req.method === "GET" && url.pathname === "/search/issues" && url.searchParams.get("q") === "AgentRun final response repo:pikasTech/unidesk type:issue" && perPage === "100" && page === "1") { sendJson(res, 200, { total_count: 1, incomplete_results: false, items: issueList.slice(0, 1) }); return; } - if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues?state=open&per_page=5") { - sendJson(res, 200, issueList); - return; - } - if (req.method === "GET" && req.url === "/repos/pikasTech/HWLAB/issues?state=open&per_page=2") { + if (req.method === "GET" && url.pathname === "/repos/pikasTech/HWLAB/issues" && state === "open" && perPage === "100" && page === "1") { sendJson(res, 200, hwlabIssueList); return; } - if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues?state=all&per_page=3") { - sendJson(res, 200, issueList); + if (req.method === "GET" && url.pathname === "/repos/pikasTech/unidesk/issues" && state === "all" && perPage === "100" && page === "1") { + unideskAllIssuesRequestCount += 1; + sendJson(res, 200, unideskAllIssuesRequestCount === 1 ? issueList : scanIssues); return; } - if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues?state=all&per_page=4") { - sendJson(res, 200, scanIssues); - return; - } - if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues?state=open&per_page=100") { - sendJson(res, 200, boardOpenIssues()); - return; - } - if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues?state=open&per_page=60") { - sendJson(res, 200, legacyBoardOpenIssues); - return; - } - if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues?state=closed&per_page=60") { - sendJson(res, 200, boardClosedIssues); - return; - } - if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues?state=closed&per_page=100") { + if (req.method === "GET" && url.pathname === "/repos/pikasTech/unidesk/issues" && state === "closed" && perPage === "100" && page === "1") { sendJson(res, 200, boardClosedIssues); return; } @@ -570,8 +560,9 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque if (req.method === "PATCH" && req.url === "/repos/pikasTech/unidesk/issues/20") { const parsed = JSON.parse(body) as JsonRecord; boardIssueBody = String(parsed.body ?? boardIssueBody); + const patchedState = typeof parsed.state === "string" ? parsed.state : issue.state; boardIssueUpdatedAt = "2026-05-20T01:05:00Z"; - sendJson(res, 200, { ...issue, body: boardIssueBody, updated_at: boardIssueUpdatedAt }); + sendJson(res, 200, { ...issue, body: boardIssueBody, state: patchedState, updated_at: boardIssueUpdatedAt }); return; } if (req.method === "PATCH" && req.url === "/repos/pikasTech/unidesk/issues/62") { @@ -699,13 +690,19 @@ export async function runGhCliIssueGuardContract(): Promise { assertCondition(listDefaultState.status === 0, "issue list default state should still succeed", listDefaultState.json ?? { stdout: listDefaultState.stdout }); const listDefaultStateData = dataOf(listDefaultState.json ?? {}); assertCondition(listDefaultStateData.state === "open", "issue list should keep default state=open", listDefaultStateData); - assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/unidesk/issues?state=open&per_page=2"), "issue list default should query state=open", mock.requests); + assertCondition(mock.requests.some((request) => { + const requestedUrl = mockUrl(request.url); + return request.method === "GET" && requestedUrl.pathname === "/repos/pikasTech/unidesk/issues" && requestedUrl.searchParams.get("state") === "open"; + }), "issue list default should query state=open", mock.requests); const searchList = await runCli(["gh", "issue", "list", "--repo", "pikasTech/unidesk", "--state", "all", "--limit", "4", "--search", "AgentRun final response", "--json", "number,title,state,url"], env); assertCondition(searchList.status === 0, "issue list should support search query", searchList.json ?? { stdout: searchList.stdout }); const searchListData = dataOf(searchList.json ?? {}); assertCondition(searchListData.search === "AgentRun final response", "issue list should expose search query", searchListData); - assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/search/issues?q=AgentRun+final+response+repo%3ApikasTech%2Funidesk+type%3Aissue&per_page=4"), "issue list search should use GitHub Search Issues API with repo/type qualifiers", mock.requests); + assertCondition(mock.requests.some((request) => { + const requestedUrl = mockUrl(request.url); + return request.method === "GET" && requestedUrl.pathname === "/search/issues" && requestedUrl.searchParams.get("q") === "AgentRun final response repo:pikasTech/unidesk type:issue"; + }), "issue list search should use GitHub Search Issues API with repo/type qualifiers", mock.requests); const positionalRepoList = await runCli(["gh", "issue", "list", "pikasTech/HWLAB", "--state", "open", "--limit", "2", "--json", "number,title,state,url"], env); assertCondition(positionalRepoList.status === 0, "issue list positional owner/repo should succeed", positionalRepoList.json ?? { stdout: positionalRepoList.stdout }); @@ -713,7 +710,10 @@ export async function runGhCliIssueGuardContract(): Promise { assertCondition(positionalRepoListData.repo === "pikasTech/HWLAB", "issue list positional repo should become the actual request repo", positionalRepoListData); const positionalRepoIssues = positionalRepoListData.issues as JsonRecord[]; assertCondition(Array.isArray(positionalRepoIssues) && positionalRepoIssues[0]?.number === 7, "issue list positional repo should return HWLAB fixture issue", positionalRepoListData); - assertCondition(mock.requests.some((request) => request.method === "GET" && request.url === "/repos/pikasTech/HWLAB/issues?state=open&per_page=2"), "issue list positional repo should query derived repo REST path", mock.requests); + assertCondition(mock.requests.some((request) => { + const requestedUrl = mockUrl(request.url); + return request.method === "GET" && requestedUrl.pathname === "/repos/pikasTech/HWLAB/issues" && requestedUrl.searchParams.get("state") === "open"; + }), "issue list positional repo should query derived repo REST path", mock.requests); const positionalRepoConflict = await runCli(["gh", "issue", "list", "pikasTech/HWLAB", "--repo", "pikasTech/unidesk", "--state", "open"], env); assertCondition(positionalRepoConflict.status !== 0, "issue list conflicting positional repo and --repo should fail", positionalRepoConflict.json ?? { stdout: positionalRepoConflict.stdout }); @@ -1258,12 +1258,10 @@ export async function runGhCliIssueGuardContract(): Promise { 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); - const rawUnsupported = await runCli(["gh", "issue", "list", "--raw"], env); - assertCondition(rawUnsupported.status !== 0, "--raw outside read/view should fail structurally", rawUnsupported.json ?? { stdout: rawUnsupported.stdout }); - const rawUnsupportedData = failedDataOf(rawUnsupported.json ?? {}); - assertCondition(rawUnsupportedData.degradedReason === "validation-failed", "unsupported --raw scope should be validation-failed", rawUnsupportedData); - const rawUnsupportedCommands = rawUnsupportedData.supportedCommands as string[]; - assertCondition(Array.isArray(rawUnsupportedCommands) && rawUnsupportedCommands.some((command) => command.includes("gh issue read owner/name# --raw")), "unsupported raw should suggest read/view raw command shape", rawUnsupportedData); + 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); assertCondition(readFields.status === 0, "common --json field selection should succeed", readFields.json ?? { stdout: readFields.stdout }); @@ -1649,6 +1647,39 @@ export async function runGhCliIssueGuardContract(): Promise { const inlinePayload = JSON.parse(inlinePost?.body ?? "{}") as JsonRecord; assertCondition(inlinePayload.body === inlineBody, "inline issue comment REST payload should preserve short text", inlinePayload); + const closeComment = "收口:CLI close --comment contract"; + const closeDryRunRequestCountBefore = mock.requests.length; + const closeDryRun = await runCli(["gh", "issue", "close", "20", "--repo", "pikasTech/unidesk", "--comment", closeComment, "--dry-run"], env); + assertCondition(closeDryRun.status === 0, "issue close --comment dry-run should succeed", closeDryRun.json ?? { stdout: closeDryRun.stdout }); + assertCondition(closeDryRun.json?.command === "gh issue close 20 --repo pikasTech/unidesk --comment --dry-run", "outer gh command should redact lifecycle comment", closeDryRun.json ?? {}); + const closeDryRunData = dataOf(closeDryRun.json ?? {}); + assertCondition(closeDryRunData.command === "issue close" && closeDryRunData.dryRun === true, "issue close dry-run should report lifecycle command", closeDryRunData); + const closeDryRunComment = closeDryRunData.comment as JsonRecord; + assertCondition(closeDryRunComment.planned === true && closeDryRunComment.source === "inline", "issue close dry-run should plan inline lifecycle comment", closeDryRunComment); + assertCondition(String(closeDryRunComment.bodyPreview ?? "") === closeComment && typeof closeDryRunComment.bodySha === "string", "issue close dry-run should expose bounded comment metadata", closeDryRunComment); + const closeDryRunWriteCount = mock.requests.slice(closeDryRunRequestCountBefore).filter((request) => request.method === "POST" || request.method === "PATCH").length; + assertCondition(closeDryRunWriteCount === 0, "issue close --comment dry-run must not POST or PATCH", { requests: mock.requests.slice(closeDryRunRequestCountBefore) }); + + const closeWriteRequestCountBefore = mock.requests.length; + const closeWrite = await runCli(["gh", "issue", "close", "20", "--repo", "pikasTech/unidesk", "--comment", closeComment], env); + assertCondition(closeWrite.status === 0, "issue close --comment should succeed", closeWrite.json ?? { stdout: closeWrite.stdout }); + const closeWriteData = dataOf(closeWrite.json ?? {}); + assertCondition(closeWriteData.command === "issue close", "issue close write should report lifecycle command", closeWriteData); + const closeWriteComment = closeWriteData.comment as JsonRecord; + assertCondition(closeWriteComment.bodyOmitted === true && closeWriteComment.bodyPreview === closeComment, "issue close should summarize lifecycle comment without full body", closeWriteComment); + const closeWriteIssue = closeWriteData.issue as JsonRecord; + assertCondition(closeWriteIssue.state === "closed", "issue close should PATCH state=closed", closeWriteIssue); + const closeWriteRequests = mock.requests.slice(closeWriteRequestCountBefore).filter((request) => request.url === "/repos/pikasTech/unidesk/issues/20/comments" || request.url === "/repos/pikasTech/unidesk/issues/20"); + assertCondition(closeWriteRequests.length === 2 && closeWriteRequests[0]?.method === "POST" && closeWriteRequests[1]?.method === "PATCH", "issue close --comment should POST comment before PATCH state", closeWriteRequests); + const closeCommentPayload = JSON.parse(closeWriteRequests[0]?.body ?? "{}") as JsonRecord; + const closePatchPayload = JSON.parse(closeWriteRequests[1]?.body ?? "{}") as JsonRecord; + assertCondition(closeCommentPayload.body === closeComment && closePatchPayload.state === "closed", "issue close --comment payloads should preserve comment and state", { closeCommentPayload, closePatchPayload }); + + const closeCommentWrongCommand = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--comment", closeComment, "--dry-run"], env); + assertCondition(closeCommentWrongCommand.status !== 0, "--comment outside issue close/reopen should fail structurally", closeCommentWrongCommand.json ?? { stdout: closeCommentWrongCommand.stdout }); + const closeCommentWrongData = failedDataOf(closeCommentWrongCommand.json ?? {}); + assertCondition(failureMessageOf(closeCommentWrongData).includes("only supported by gh issue close/reopen"), "wrong --comment usage should point to close/reopen", closeCommentWrongData); + const missingCommentBody = await runCli(["gh", "issue", "comment", "create", "36", "--repo", "pikasTech/unidesk", "--dry-run"], env); assertCondition(missingCommentBody.status !== 0, "issue comment create without body source should fail", missingCommentBody.json ?? { stdout: missingCommentBody.stdout }); const missingCommentBodyData = failedDataOf(missingCommentBody.json ?? {}); diff --git a/scripts/src/gh.ts b/scripts/src/gh.ts index d5a4001e..bd9d0844 100644 --- a/scripts/src/gh.ts +++ b/scripts/src/gh.ts @@ -40,7 +40,7 @@ const GH_VALUE_OPTIONS = new Set([ "--expect-updated-at", "--expect-body-sha", "--body-profile", "--label", "--field", "--value", "--section", "--to", "--status", "--row-file", "--category", "--branch", "--tasks", "--summary", "--focus", "--validation", "--progress", "--number", "--pr", - "--search", "--inactive-hours", + "--search", "--inactive-hours", "--comment", ]); const GH_FLAG_OPTIONS = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body", "--raw", "--full", "--stat", "--merge", "--squash", "--rebase", "--delete-branch"]); const MIN_SAFE_BODY_SCAN_CHARS = MIN_SAFE_ISSUE_BODY_CHARS; @@ -335,6 +335,7 @@ interface GitHubOptions { title?: string; body?: string; bodyFile?: string; + comment?: string; base?: string; head?: string; jsonFields?: IssueViewJsonField[]; @@ -814,6 +815,7 @@ function parseOptions(args: string[]): GitHubOptions { title: optionValue(args, "--title"), body: optionValue(args, "--body"), bodyFile: optionValue(args, "--body-file"), + comment: optionValue(args, "--comment"), base: optionValue(args, "--base"), head: optionValue(args, "--head"), jsonFields: top === "issue" && isIssueReadCommand(sub) ? parseIssueViewJsonFields(requestedJsonFields) : undefined, @@ -1123,6 +1125,14 @@ function readIssueCommentBody(options: GitHubOptions): { body: string; bodySourc }; } +function readIssueLifecycleCommentBody(options: GitHubOptions, command: string): { body: string; bodySource: Record } | null { + if (options.comment === undefined) return null; + if (options.body !== undefined || options.bodyFile !== undefined) { + throw new Error(`${command} --comment cannot be combined with --body or --body-file`); + } + return readIssueCommentBody({ ...options, body: options.comment, bodyFile: undefined }); +} + function tokenFromEnvironment(): GitHubTokenProbe { if (process.env.GH_TOKEN && process.env.GH_TOKEN.length > 0) { return { present: true, source: "GH_TOKEN", ghFallbackAttempted: false }; @@ -5578,8 +5588,14 @@ async function commentDelete(repo: string, token: string, ownerKind: "issue" | " }; } -async function issueState(repo: string, token: string, issueNumber: number, state: "open" | "closed", dryRun: boolean): Promise { +async function issueState(repo: string, token: string, issueNumber: number, state: "open" | "closed", dryRun: boolean, options?: GitHubOptions): Promise { const command = state === "closed" ? "issue close" : "issue reopen"; + let lifecycleComment: { body: string; bodySource: Record } | null = null; + try { + lifecycleComment = options === undefined ? null : readIssueLifecycleCommentBody(options, command); + } catch (error) { + return validationError(command, repo, error instanceof Error ? error.message : String(error), { issueNumber }); + } if (dryRun) { return { ok: true, @@ -5589,16 +5605,37 @@ async function issueState(repo: string, token: string, issueNumber: number, stat issueNumber, disclosure: issueLifecycleDisclosure(repo, issueNumber, true), readCommands: issueBodyReadCommands(repo, issueNumber), + comment: lifecycleComment === null + ? null + : { + planned: true, + ...writeBodyPlan("issue comment create", repo, lifecycleComment.body, lifecycleComment.bodySource, { issueNumber }), + }, wouldPatch: { state }, }; } const { owner, name } = repoParts(repo); + let commentSummary: Record | null = null; + if (lifecycleComment !== null) { + const comment = await githubRequest(token, "POST", `/repos/${owner}/${name}/issues/${issueNumber}/comments`, { body: lifecycleComment.body }); + if (isGitHubError(comment)) { + return commandError(command, repo, comment, { + issueNumber, + phase: "comment", + commentBodySource: lifecycleComment.bodySource, + commentBodyChars: lifecycleComment.body.length, + commentBodySha: bodySha(lifecycleComment.body), + }); + } + commentSummary = compactCommentSummary(comment); + } const issue = await githubRequest(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, { state }); - if (isGitHubError(issue)) return commandError(command, repo, issue, { issueNumber }); + if (isGitHubError(issue)) return commandError(command, repo, issue, { issueNumber, phase: "state", comment: commentSummary }); return { ok: true, command, repo, + comment: commentSummary, issue: issueLifecycleSummary(issue), disclosure: issueLifecycleDisclosure(repo, issueNumber, false), readCommands: issueBodyReadCommands(repo, issueNumber), @@ -6296,7 +6333,7 @@ export function ghHelp(): unknown { "bun scripts/cli.ts gh issue edit 24 --body-file --notify-claudeqq-brief-diff [--dry-run]", "bun scripts/cli.ts gh issue comment create --body-file |--body [--repo owner/name] [--dry-run]", "bun scripts/cli.ts gh issue comment delete [--repo owner/name] [--dry-run]", - "bun scripts/cli.ts gh issue close|reopen [--repo owner/name] [--dry-run]", + "bun scripts/cli.ts gh issue close|reopen [--repo owner/name] [--comment ] [--dry-run]", "bun scripts/cli.ts gh issue stale-close [--repo owner/name] [--inactive-hours N] [--limit N] [--label label[,label...]]... [--dry-run]", "bun scripts/cli.ts gh issue delete [unsupported: use close]", "bun scripts/cli.ts gh issue scan-escape [--repo owner/name] [--limit N] [--dry-run]", @@ -6343,7 +6380,7 @@ export function ghHelp(): unknown { "issue update --body-file accepts files or - for stdin, refuses literal null, blank, and too-short bodies by default. Use --allow-short-body only for intentional short writes; #20 requires its board heading, warns when HWLAB product/user issue routing appears in favor of pikasTech/HWLAB, and still rejects commander brief update sections; commander-brief requires its stable heading on legacy #24 plus daily rolling brief issues titled YYYY-MM-DD 指挥简报(北京时间).", "issue update dry-run reports bounded bodyPreview/bodyPreviewLines, old/new body length slots, body SHA, required heading checks, literal \\n detection, shell-pollution signals, guard/concurrency summary, wouldPatch, and readCommands without printing an unbounded full body. Non-dry-run automatically reads current issue metadata before PATCH and returns oldBodySha/updatedAt; --expect-updated-at or --expect-body-sha remain available for explicit stale-cache protection.", "issue comment create accepts --body-file for Markdown/generated content and --body only for short single-line text. Blank, multiline, shell-polluted, secret-like, and overlong inline bodies fail structurally.", - "issue close/reopen default success output is compact and omits full issue.body. Use gh issue read --json body or --full/--raw on read when full text is needed.", + "issue close/reopen default success output is compact and omits full issue.body. Optional --comment posts a bounded lifecycle comment before the state change and aborts the state change if the comment POST fails. Use gh issue read --json body or --full/--raw on read when full text is needed.", "issue stale-close is the reusable lifecycle cleanup path for policies such as closing open issues inactive for more than 48 hours. It selects open issues by GitHub updatedAt older than observedAt - --inactive-hours, treats comments and state changes as activity, filters pull requests, supports --dry-run, and returns bounded candidate/closed/failure summaries without echoing full bodies.", "For one-shot issue writes, pipe reviewed Markdown through stdin: cat body.md | bun scripts/cli.ts gh issue update --repo owner/name --body-file - or gh issue comment create --body-file -. When staging a body file from a shell, use a quoted heredoc such as cat <<'EOF' > /tmp/body.md so backticks and backslashes are not expanded before --body-file reads the file.", "For JSON request bodies in other CLI namespaces, prefer --body-file or --body-stdin over long inline shell arguments. GitHub issue/PR Markdown writes use --body-file for long or multiline content.", @@ -6480,6 +6517,10 @@ export async function runGhCommand(args: string[]): Promise value !== undefined).join(" ") || "gh"; return validationError(command, options.repo, "--inactive-hours is only supported by gh issue stale-close"); } + if (optionWasProvided(args, "--comment") && !(top === "issue" && (sub === "close" || sub === "reopen"))) { + const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; + return validationError(command, options.repo, "--comment is only supported by gh issue close/reopen; use gh issue comment create for standalone comments"); + } if (top === "auth" && sub === "status") return authStatus(options.repo); @@ -6554,8 +6595,8 @@ export async function runGhCommand(args: string[]): Promise