diff --git a/.agents/skills/unidesk-gh/SKILL.md b/.agents/skills/unidesk-gh/SKILL.md index e258b1d0..077f5200 100644 --- a/.agents/skills/unidesk-gh/SKILL.md +++ b/.agents/skills/unidesk-gh/SKILL.md @@ -33,6 +33,7 @@ bun scripts/cli.ts gh pr list --repo pikasTech/unidesk --state all --limit 10 bun scripts/cli.ts gh pr review-plan --repo pikasTech/unidesk bun scripts/cli.ts gh pr diff --repo pikasTech/unidesk --file path/to/file [--hunk 1] bun scripts/cli.ts gh pr create --repo pikasTech/unidesk --title "标题" --body-stdin --base master --head +bun scripts/cli.ts gh pr ready --repo pikasTech/unidesk [--dry-run] bun scripts/cli.ts gh pr preflight --repo pikasTech/unidesk bun scripts/cli.ts gh pr merge --repo pikasTech/unidesk --merge --delete-branch ``` diff --git a/docs/MDTODO/details/github-pr-draft-ready-command/R1.1_Task_Report.md b/docs/MDTODO/details/github-pr-draft-ready-command/R1.1_Task_Report.md new file mode 100644 index 00000000..5fb3bf3d --- /dev/null +++ b/docs/MDTODO/details/github-pr-draft-ready-command/R1.1_Task_Report.md @@ -0,0 +1,10 @@ +# R1.1 任务报告 + +- 调查结论: + - 既有 `gh pr create` 支持 draft。 + - 既有 `gh pr merge` 会拒绝 draft。 + - 既有受控入口缺少 `markPullRequestReadyForReview` mutation。 + - REST PR 响应中的 `node_id` 可作为 GraphQL mutation 身份。 +- 复用边界: + - token 继续由 `resolveToken` 统一解析。 + - REST 与 GraphQL 继续由共享 client 执行和分类错误。 diff --git a/docs/MDTODO/details/github-pr-draft-ready-command/R1.2_Task_Report.md b/docs/MDTODO/details/github-pr-draft-ready-command/R1.2_Task_Report.md new file mode 100644 index 00000000..ec0501e0 --- /dev/null +++ b/docs/MDTODO/details/github-pr-draft-ready-command/R1.2_Task_Report.md @@ -0,0 +1,13 @@ +# R1.2 任务报告 + +- 命令合同: + - `bun scripts/cli.ts gh pr ready --repo owner/name [--dry-run]` +- 状态合同: + - open draft:转为 ready。 + - open ready:幂等成功,不再次 mutation。 + - closed:`validation-failed`。 + - missing:`pr-not-found`。 + - 权限失败:保留共享 client 的 typed reason。 +- 可见性: + - 默认输出为有界表格。 + - scoped help 披露读取、mutation 和 dry-run 语义。 diff --git a/docs/MDTODO/details/github-pr-draft-ready-command/R1.3_Task_Report.md b/docs/MDTODO/details/github-pr-draft-ready-command/R1.3_Task_Report.md new file mode 100644 index 00000000..f8a43fe3 --- /dev/null +++ b/docs/MDTODO/details/github-pr-draft-ready-command/R1.3_Task_Report.md @@ -0,0 +1,15 @@ +# R1.3 任务报告 + +- 自动化验证: + - `bun test scripts/src/gh-pr-ready.test.ts scripts/src/gh-body-input.test.ts` + - 8 个测试通过,0 个失败,共 41 个断言。 +- 真实入口: + - 曾用新增命令将 标为 ready。 + - 幂等复测未再次执行 mutation。 +- 边界复盘: + - 上述状态写入越过了主代理保留 draft 待 P0/P1 复审的要求。 + - `ready` 不代表代码审查或合并门禁通过。 + - 纠偏后未再操作 #1748,也未修改运行面。 +- 交付: + - 非 draft PR: + - 由主代理负责 review、preflight 和 merge。 diff --git a/docs/MDTODO/details/github-pr-draft-ready-command/R1_Task_Report.md b/docs/MDTODO/details/github-pr-draft-ready-command/R1_Task_Report.md new file mode 100644 index 00000000..052298b2 --- /dev/null +++ b/docs/MDTODO/details/github-pr-draft-ready-command/R1_Task_Report.md @@ -0,0 +1,11 @@ +# R1 任务报告 + +- Issue: +- PR: +- 结果: + - 新增受控 `gh pr ready` 命令。 + - 覆盖 open/draft、already-ready、dry-run 和 typed failure 合同。 + - 提交非 draft PR,未自行合并。 +- 验证: + - 8 个测试通过,0 个失败,共 41 个断言。 + - scoped help 保持有界输出。 diff --git a/docs/MDTODO/github-pr-draft-ready-command.md b/docs/MDTODO/github-pr-draft-ready-command.md new file mode 100644 index 00000000..b6cc2d5d --- /dev/null +++ b/docs/MDTODO/github-pr-draft-ready-command.md @@ -0,0 +1,26 @@ +# 补齐 GitHub PR draft 转 ready 受控命令 + +- 来源: +- 触发上下文: + - + - +- 范围: + - 仅新增 `gh pr ready` 受控命令、帮助、测试与 PR 验收。 +- 禁止项: + - 不修改 #1748 分支。 + - 不修改运行面。 + - 不建立第二套 GitHub 认证或 API authority。 + + +## R1 [completed] + +闭环 [UniDesk #1749](https://github.com/pikasTech/unidesk/issues/1749):为 [PR #1748](https://github.com/pikasTech/unidesk/pull/1748) 补齐 draft 转 ready 的受控 GitHub CLI,完成任务后将详细报告写入[任务报告](./details/github-pr-draft-ready-command/R1_Task_Report.md)。 +### R1.1 [completed] + +调查 [#1749](https://github.com/pikasTech/unidesk/issues/1749) 的既有 PR 命令、GraphQL client、typed 错误和输出合同,完成任务后将详细报告写入[任务报告](./details/github-pr-draft-ready-command/R1.1_Task_Report.md)。 +### R1.2 [completed] + +实现 [#1749](https://github.com/pikasTech/unidesk/issues/1749) 的 ready、already-ready 与 dry-run 合同,并补 scoped help,完成任务后将详细报告写入[任务报告](./details/github-pr-draft-ready-command/R1.2_Task_Report.md)。 +### R1.3 [completed] + +覆盖 [#1749](https://github.com/pikasTech/unidesk/issues/1749) 的成功与失败测试,使用新命令验收 [PR #1748](https://github.com/pikasTech/unidesk/pull/1748),提交非 draft PR,完成任务后将详细报告写入[任务报告](./details/github-pr-draft-ready-command/R1.3_Task_Report.md)。 \ No newline at end of file diff --git a/scripts/src/gh-pr-ready.test.ts b/scripts/src/gh-pr-ready.test.ts new file mode 100644 index 00000000..a29ccfa6 --- /dev/null +++ b/scripts/src/gh-pr-ready.test.ts @@ -0,0 +1,157 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { withGhDefaultRendered } from "./gh/default-render"; +import { ghScopedHelp } from "./gh/help"; +import { prReady } from "./gh/pr-ready"; +import type { GitHubPullRequest } from "./gh/types"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function pullRequest(overrides: Partial = {}): GitHubPullRequest { + return { + id: 1748, + node_id: "PR_kwDO_ready_1748", + number: 1748, + title: "PaC source artifact", + body: "body", + state: "open", + html_url: "https://github.com/pikasTech/unidesk/pull/1748", + draft: true, + head: { ref: "fix/1746-pac-source-artifact", sha: "a".repeat(40) }, + base: { ref: "master", sha: "b".repeat(40) }, + ...overrides, + }; +} + +function installFetch(handler: (url: URL, init: RequestInit | undefined) => Response | Promise): string[] { + const calls: string[] = []; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(typeof input === "string" || input instanceof URL ? input.toString() : input.url); + calls.push(`${init?.method ?? "GET"} ${url.pathname}`); + return handler(url, init); + }) as typeof fetch; + return calls; +} + +describe("gh pr ready", () => { + test("marks an open draft PR ready through the shared GraphQL client", async () => { + const calls = installFetch(async (url, init) => { + if (url.pathname.endsWith("/pulls/1748")) return Response.json(pullRequest()); + expect(url.pathname).toBe("/graphql"); + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer test-token"); + const payload = JSON.parse(String(init?.body)) as { query: string; variables: { pullRequestId: string } }; + expect(payload.query).toContain("markPullRequestReadyForReview"); + expect(payload.variables.pullRequestId).toBe("PR_kwDO_ready_1748"); + return Response.json({ + data: { + markPullRequestReadyForReview: { + pullRequest: { + id: "PR_kwDO_ready_1748", + number: 1748, + title: "PaC source artifact", + state: "OPEN", + isDraft: false, + url: "https://github.com/pikasTech/unidesk/pull/1748", + headRefName: "fix/1746-pac-source-artifact", + baseRefName: "master", + updatedAt: "2026-07-11T04:30:00Z", + }, + }, + }, + }); + }); + + const result = await prReady("pikasTech/unidesk", "test-token", 1748, false); + + expect(result.ok).toBe(true); + expect(result.action).toBe("marked-ready"); + expect(result.changed).toBe(true); + expect(result.graphQl).toBe(true); + expect((result.pullRequest as { draft?: boolean }).draft).toBe(false); + expect(calls).toEqual(["GET /repos/pikasTech/unidesk/pulls/1748", "POST /graphql"]); + }); + + test("is idempotent when the open PR is already ready", async () => { + const calls = installFetch(() => Response.json(pullRequest({ draft: false }))); + + const result = await prReady("pikasTech/unidesk", "test-token", 1748, false); + + expect(result.ok).toBe(true); + expect(result.action).toBe("already-ready"); + expect(result.changed).toBe(false); + expect(result.graphQl).toBe(false); + expect(calls).toEqual(["GET /repos/pikasTech/unidesk/pulls/1748"]); + const rendered = withGhDefaultRendered(["pr", "ready", "1748"], result) as { renderedText: string }; + expect(rendered.renderedText).toContain("gh pr ready (already-ready)"); + }); + + test("dry-run reads the PR and exposes a bounded mutation plan without writing", async () => { + const calls = installFetch(() => Response.json(pullRequest())); + + const result = await prReady("pikasTech/unidesk", "test-token", 1748, true); + + expect(result.ok).toBe(true); + expect(result.action).toBe("would-mark-ready"); + expect(result.planned).toBe(true); + expect(result.changed).toBe(false); + expect(result.request).toEqual({ + method: "POST", + path: "/graphql", + mutation: "markPullRequestReadyForReview", + variables: { pullRequestId: "present" }, + }); + expect(calls).toEqual(["GET /repos/pikasTech/unidesk/pulls/1748"]); + }); + + test("rejects a closed PR before mutation with a typed validation error", async () => { + const calls = installFetch(() => Response.json(pullRequest({ state: "closed" }))); + + const result = await prReady("pikasTech/unidesk", "test-token", 1748, false); + + expect(result.ok).toBe(false); + expect(result.degradedReason).toBe("validation-failed"); + expect(result.runnerDisposition).toBe("business-failed"); + expect(result.phase).toBe("validate-pr-state"); + expect(calls).toEqual(["GET /repos/pikasTech/unidesk/pulls/1748"]); + }); + + test("preserves the typed pr-not-found REST failure", async () => { + installFetch(() => Response.json({ message: "Not Found" }, { status: 404 })); + + const result = await prReady("pikasTech/unidesk", "test-token", 404, false); + + expect(result.ok).toBe(false); + expect(result.degradedReason).toBe("pr-not-found"); + expect(result.runnerDisposition).toBe("business-failed"); + expect(result.phase).toBe("fetch-pr"); + }); + + test("preserves typed mutation permission failures", async () => { + installFetch((url) => { + if (url.pathname.endsWith("/pulls/1748")) return Response.json(pullRequest()); + return Response.json( + { message: "Resource not accessible by integration" }, + { status: 403, headers: { "x-accepted-oauth-scopes": "repo" } }, + ); + }); + + const result = await prReady("pikasTech/unidesk", "test-token", 1748, false); + + expect(result.ok).toBe(false); + expect(result.degradedReason).toBe("scope-insufficient"); + expect(result.runnerDisposition).toBe("infra-blocked"); + expect(result.phase).toBe("mark-ready"); + }); + + test("publishes bounded scoped help for the command", () => { + const help = ghScopedHelp(["pr", "ready", "--help"]) as { usage: string[]; notes: string[] }; + expect(help.usage).toEqual([ + "bun scripts/cli.ts gh pr ready [--repo owner/name] [--number N compat] [--dry-run]", + ]); + expect(help.notes.join("\n")).toContain("markPullRequestReadyForReview"); + expect(help.notes.join("\n")).toContain("不改变 GitHub 状态"); + }); +}); diff --git a/scripts/src/gh/default-render.ts b/scripts/src/gh/default-render.ts index 76995206..226a67f7 100644 --- a/scripts/src/gh/default-render.ts +++ b/scripts/src/gh/default-render.ts @@ -63,7 +63,7 @@ function renderGhDefaultText(result: GitHubCommandResult): string { if (command === "issue scan-escape" || command === "issue cleanup-plan") return renderScanEscape(result); if (command === "issue close" || command === "issue reopen") return renderIssueLifecycle(result); if (command === "issue update" || command === "issue edit" || command === "issue patch") return renderIssueWrite(result); - if (command === "pr update" || command === "pr edit" || command === "pr close" || command === "pr reopen") return renderPrWrite(result); + if (command === "pr update" || command === "pr edit" || command === "pr close" || command === "pr reopen" || command === "pr ready") return renderPrWrite(result); return renderGenericResult(result); } @@ -591,8 +591,13 @@ function renderIssueWrite(result: GitHubCommandResult): string { function renderPrWrite(result: GitHubCommandResult): string { const pr = record(result.pullRequest); + const status = result.dryRun === true + ? "dry-run" + : result.action === "already-ready" + ? "already-ready" + : "updated"; return [ - `gh ${result.command} (${result.dryRun === true ? "dry-run" : "updated"})`, + `gh ${result.command} (${status})`, "", ghTable(["PR", "STATE", "CHANGED", "URL", "TITLE"], [[ `#${ghText(result.number ?? pr.number)}`, diff --git a/scripts/src/gh/help.ts b/scripts/src/gh/help.ts index ef7c3f6c..bdc9fab3 100644 --- a/scripts/src/gh/help.ts +++ b/scripts/src/gh/help.ts @@ -61,6 +61,7 @@ export function ghHelp(): unknown { "bun scripts/cli.ts gh pr comment edit (--body-stdin|--body-file |--body ) [--repo owner/name] [--number N compat] [--dry-run] [compatibility alias for pr comment update]", "bun scripts/cli.ts gh pr comment delete [--repo owner/name] [--number N compat] [--dry-run]", "bun scripts/cli.ts gh pr close|reopen [--repo owner/name] [--number N compat] [--dry-run]", + "bun scripts/cli.ts gh pr ready [--repo owner/name] [--number N compat] [--dry-run]", "bun scripts/cli.ts gh pr merge [--repo owner/name] [--number N compat] [--merge|--squash|--rebase] [--delete-branch|--keep-branch] [--sync-node NODE]... [--skip-local-closeout] [--dry-run]", "bun scripts/cli.ts gh pr delete [unsupported: use close]", ], @@ -169,6 +170,9 @@ export function ghScopedHelpNotes(tokens: string[]): string[] { } else if (key === "pr comment" || key.startsWith("pr comment ")) { notes.push("PR comments are GitHub issue comments under the hood; use comment id targets for update/edit/delete."); notes.push("Use `pr comment view --full` to read one full comment body by id."); + } else if (key === "pr ready") { + notes.push("PR ready 先读取目标 PR 并确认其为 open;draft PR 通过 GitHub GraphQL markPullRequestReadyForReview 转为 ready,已 ready 时幂等成功。"); + notes.push("使用 `--dry-run` 只读检查当前 draft 状态和 mutation 计划,不改变 GitHub 状态。"); } else if (key === "pr merge") { notes.push("PR merge is one-command guarded: it performs the readiness check itself; `gh pr preflight` is optional read-only diagnosis, not a required first step."); notes.push("When GitHub reports mergeability as UNKNOWN/null, merge automatically retries with YAML-configured exponential backoff and shows retry attempts as N/M."); diff --git a/scripts/src/gh/index.ts b/scripts/src/gh/index.ts index 283d688c..867d9a60 100644 --- a/scripts/src/gh/index.ts +++ b/scripts/src/gh/index.ts @@ -17,6 +17,7 @@ import { issueCreate, issueEdit, issuePatch } from "./issue-write"; import { allowsNumberTargetAlias, isIssueReadCommand, isPrReadCommand, optionValue, optionWasProvided, parseOptions } from "./options"; import { prComment, prCreate, prState, prUpdate } from "./pr-commands"; import { prMerge, prPreflight } from "./pr-merge"; +import { prReady } from "./pr-ready"; import { prDiffFile, prReviewPlan } from "./pr-review"; import { isGitHubCommandResult, issueReadJsonFields, prReadJsonFields, readDisclosureOptions, readViewSupportedJsonFields, resolvePositionalIssueReference, resolvePositionalNumberReference, resolvePositionalPrReference, resolveReadViewNumberReference, unknownGhOptionDetails, withNumberOptionHint } from "./refs"; import { issueStaleClose } from "./stale-close"; @@ -581,6 +582,14 @@ export async function runGhCommand(args: string[]): Promise --repo ${ghText(result.repo)} --merge --delete-branch`); + lines.push(draft + ? ` after creation: bun scripts/cli.ts gh pr ready --repo ${ghText(result.repo)}` + : ` after creation: bun scripts/cli.ts gh pr merge --repo ${ghText(result.repo)} --merge --delete-branch`); lines.push(" Use --squash only when ancestry and merge-parent history are intentionally disposable."); } lines.push("", "Disclosure:"); diff --git a/scripts/src/gh/pr-ready.ts b/scripts/src/gh/pr-ready.ts new file mode 100644 index 00000000..cfcecf08 --- /dev/null +++ b/scripts/src/gh/pr-ready.ts @@ -0,0 +1,169 @@ +// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split + +import { repoParts } from "./auth-and-safety"; +import { commandError, errorPayload, githubGraphqlRequest, githubRequest, isGitHubError, validationError } from "./client"; +import { prCompactSummary } from "./pr-summary"; +import type { GitHubCommandResult, GitHubPullRequest } from "./types"; + +interface ReadyPullRequestGraphql { + id?: string; + number?: number; + title?: string; + state?: string; + isDraft?: boolean; + url?: string; + headRefName?: string | null; + baseRefName?: string | null; + updatedAt?: string | null; +} + +interface ReadyPullRequestMutation { + markPullRequestReadyForReview?: { + pullRequest?: ReadyPullRequestGraphql | null; + } | null; +} + +function mutationPlan(): Record { + return { + method: "POST", + path: "/graphql", + mutation: "markPullRequestReadyForReview", + variables: { pullRequestId: "present" }, + }; +} + +function readyPullRequestSummary(pr: ReadyPullRequestGraphql, fallback: GitHubPullRequest): Record { + return { + id: pr.id ?? fallback.node_id ?? null, + number: pr.number ?? fallback.number, + title: pr.title ?? fallback.title, + state: (pr.state ?? fallback.state).toLowerCase(), + stateDetail: (pr.state ?? fallback.state).toLowerCase(), + draft: pr.isDraft ?? false, + url: pr.url ?? fallback.html_url, + head: { ref: pr.headRefName ?? fallback.head?.ref ?? null, sha: fallback.head?.sha ?? null }, + base: { ref: pr.baseRefName ?? fallback.base?.ref ?? null, sha: fallback.base?.sha ?? null }, + headRefName: pr.headRefName ?? fallback.head?.ref ?? null, + baseRefName: pr.baseRefName ?? fallback.base?.ref ?? null, + updatedAt: pr.updatedAt ?? fallback.updated_at ?? null, + }; +} + +export async function prReady(repo: string, token: string, number: number, dryRun: boolean): Promise { + const command = "pr ready"; + const { owner, name } = repoParts(repo); + const current = await githubRequest(token, "GET", `/repos/${owner}/${name}/pulls/${number}`); + if (isGitHubError(current)) return commandError(command, repo, current, { number, phase: "fetch-pr" }); + + const pullRequest = prCompactSummary(current); + if (current.state !== "open") { + return validationError(command, repo, "只有 open 状态的 PR 才能转为 ready for review", { + number, + phase: "validate-pr-state", + currentState: current.state, + pullRequest, + }); + } + if (typeof current.draft !== "boolean") { + return commandError(command, repo, errorPayload("invalid-response", "GitHub REST PR 响应缺少 draft 布尔状态,无法安全判断是否需要写入"), { + number, + phase: "validate-pr-draft", + pullRequest, + }); + } + if (current.draft !== true) { + return { + ok: true, + command, + repo, + number, + dryRun, + planned: false, + changed: false, + action: "already-ready", + changedFields: [], + pullRequest, + rest: true, + graphQl: false, + }; + } + + const request = mutationPlan(); + if (dryRun) { + return { + ok: true, + command, + repo, + number, + dryRun: true, + planned: true, + changed: false, + action: "would-mark-ready", + changedFields: ["draft"], + pullRequest, + request, + rest: true, + graphQl: false, + }; + } + + if (typeof current.node_id !== "string" || current.node_id.length === 0) { + return commandError(command, repo, errorPayload("invalid-response", "GitHub REST PR 响应缺少 markPullRequestReadyForReview 所需的 node_id"), { + number, + phase: "validate-pr-node-id", + pullRequest, + }); + } + + const mutation = ` + mutation MarkPullRequestReadyForReview($pullRequestId: ID!) { + markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) { + pullRequest { + id + number + title + state + isDraft + url + headRefName + baseRefName + updatedAt + } + } + } + `; + const response = await githubGraphqlRequest(token, mutation, { pullRequestId: current.node_id }); + if (isGitHubError(response)) { + return commandError(command, repo, response, { + number, + phase: "mark-ready", + pullRequest, + request, + }); + } + const ready = response.markPullRequestReadyForReview?.pullRequest; + if (ready === null || ready === undefined || ready.isDraft !== false) { + return commandError(command, repo, errorPayload("invalid-response", "GitHub GraphQL markPullRequestReadyForReview 响应未确认 isDraft=false", { details: response }), { + number, + phase: "verify-ready-response", + pullRequest, + request, + }); + } + + return { + ok: true, + command, + repo, + number: ready.number ?? number, + dryRun: false, + planned: false, + changed: true, + action: "marked-ready", + changedFields: ["draft"], + pullRequest: readyPullRequestSummary(ready, current), + request, + rest: true, + graphQl: true, + }; +} diff --git a/scripts/src/gh/types.ts b/scripts/src/gh/types.ts index 624514df..b8d9ab53 100644 --- a/scripts/src/gh/types.ts +++ b/scripts/src/gh/types.ts @@ -595,6 +595,7 @@ export interface GitHubIssueListResult { export interface GitHubPullRequest { id: number; + node_id?: string; number: number; title: string; body: string | null;