From f601be7f422178357beaf93ce322a8a30d31196c Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 14 Jul 2026 20:30:50 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=98=BE=E7=A4=BA=20GitHub=20=E4=BB=93?= =?UTF-8?q?=E5=BA=93=E5=86=99=E4=B8=8E=E5=90=88=E5=B9=B6=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/unidesk-gh/SKILL.md | 1 + .../R2_Task_Report.md | 32 ++++++++ docs/MDTODO/github-pr-draft-ready-command.md | 2 +- scripts/src/gh/auth-pr-read.ts | 60 +++++++++++++- scripts/src/gh/default-render.ts | 27 +++++++ scripts/src/gh/help.ts | 5 +- scripts/src/gh/repository-capability.ts | 80 +++++++++++++++++++ 7 files changed, 203 insertions(+), 4 deletions(-) create mode 100644 docs/MDTODO/details/github-pr-draft-ready-command/R2_Task_Report.md create mode 100644 scripts/src/gh/repository-capability.ts diff --git a/.agents/skills/unidesk-gh/SKILL.md b/.agents/skills/unidesk-gh/SKILL.md index b611f056..2cae539a 100644 --- a/.agents/skills/unidesk-gh/SKILL.md +++ b/.agents/skills/unidesk-gh/SKILL.md @@ -20,6 +20,7 @@ GitHub issue/PR 正式读写必须走 `bun scripts/cli.ts gh ...` 或 `trans gh: - 规划型、多阶段、架构/API/平台运维类 issue 第一阶段必须 `P0 SPEC 先行`;细则见 [references/issues.md](references/issues.md)。 - `P0 SPEC 先行` 段不得写入硬编码阈值、采样周期、重试次数、并发数等可调参数;必须写明这些参数由指定 YAML/source-of-truth 控制,issue 只列配置路径、字段族和验收读取方式。 - `gh` 默认输出是 k8s 风格 text/table/summary/Next/Disclosure;脚本消费或全量排障必须显式用 `--json`、`--full` 或 `--raw`。 +- `gh auth status --repo owner/name` 分别披露仓库 read、基于 `git push --dry-run` 的非变更 write 能力和 PR merge eligibility,保持 `writesRemote=false`、`mutation=false` 与 `valuesPrinted=false`;merge eligibility 只覆盖 token 与仓权限,分支保护、检查和 PR 状态仍由 guarded merge 判定。 - `gh issue view` / `gh pr view` 的 `--json body,...`、`--full` 和 `--raw` 只用于显式机器结构化披露;请求正文时只在 `.data.issue.body` / `.data.pullRequest.body` 出现一次。 - 多行正文使用 quoted heredoc: - 正文使用 `--body-stdin <<'EOF'`;issue close/reopen 生命周期评论只用 `--comment-stdin <<'EOF'`。 diff --git a/docs/MDTODO/details/github-pr-draft-ready-command/R2_Task_Report.md b/docs/MDTODO/details/github-pr-draft-ready-command/R2_Task_Report.md new file mode 100644 index 00000000..1fdbf343 --- /dev/null +++ b/docs/MDTODO/details/github-pr-draft-ready-command/R2_Task_Report.md @@ -0,0 +1,32 @@ +# R2 任务报告 + +## 结论 + +- pikainc/pikaoa 继续使用 config/unidesk-cli.yaml#github.auth.repositoryOverrides 中既有的 pikainc-selfmedia-gh-token.txt;未修改 Secret sourceRef、运行面 Secret 或 PikaOA 业务。 +- 先前“该 token 只能读、不能 merge”的根因假设已被真实原入口证伪:主代理使用同一受控入口首次成功合并 pikainc/pikaoa#9,merge commit 为 0142d2ff47ccfa4eb7543dcee11dd05e0bae2c6c,远端 head branch 删除成功。 +- gh auth status --repo 现在分别披露 repo read、基于 HTTPS git push --dry-run 的 write capability 和 PR merge eligibility。write 探针使用临时 askpass、随机 ref 与 --dry-run,固定报告 writesRemote=false、mutation=false、valuesPrinted=false。 +- PR merge eligibility 只覆盖 token 与仓库写权限;分支保护、检查和 PR 状态仍由 guarded gh pr merge 判定。 + +## 非阻塞判定 + +- write capability 不可用或未知时,auth status 仍保持可读认证成功,返回 warning=true、blocking=false,不成为业务发布门禁。 +- 使用公开只读仓验证了 repo-read=ok、repo-write/pr-merge=unavailable 且命令退出码为 0。 +- 未新增权限阈值、版本门禁或 YAML 数值策略;探针复用既有 GitHub 请求超时预算。 + +## 验证 + +- bun --check scripts/src/gh/repository-capability.ts +- bun --check scripts/src/gh/auth-pr-read.ts +- bun --check scripts/src/gh/default-render.ts +- bun --check scripts/src/gh/help.ts +- bun test scripts/src/gh-token-repository-override.test.ts:6 pass,0 fail。 +- bun scripts/cli.ts gh auth status --repo pikainc/pikaoa:read/write/pr-merge 均 available,warnings=0,valuesPrinted=false。 +- 从 /tmp 调用同一 auth status:成功,证明探针不依赖调用方 cwd。 +- bun scripts/cli.ts gh auth status --repo octocat/Hello-World:read available,write/pr-merge unavailable,非阻塞返回。 +- bun scripts/cli.ts gh pr view 9 --repo pikainc/pikaoa --json title,state,stateDetail,merged,mergedAt,mergeCommit,head,base:stateDetail=merged,merge commit 与主代理证据一致。 +- git diff --check:通过。 + +## 风险 + +- git push --dry-run 证明 Git contents 写能力,不替代最终 PR merge 的分支保护与检查判断;CLI 已在帮助和输出中明确这一边界。 +- 本任务没有再次执行真实 merge,也没有修改或生成任何 Secret。 diff --git a/docs/MDTODO/github-pr-draft-ready-command.md b/docs/MDTODO/github-pr-draft-ready-command.md index 2dd3fc2f..27cc8971 100644 --- a/docs/MDTODO/github-pr-draft-ready-command.md +++ b/docs/MDTODO/github-pr-draft-ready-command.md @@ -24,6 +24,6 @@ ### 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)。 -## R2 [in_progress] +## R2 [completed] 修复 `pikainc/pikaoa` 受控 GitHub token repository override 的写权限路由,并让 `gh auth status` 区分 repo 可读与 PR merge capability,执行记录见 [pikasTech/unidesk#2063](https://github.com/pikasTech/unidesk/issues/2063),完成任务后将详细报告写入[任务报告](./details/github-pr-draft-ready-command/R2_Task_Report.md)。 diff --git a/scripts/src/gh/auth-pr-read.ts b/scripts/src/gh/auth-pr-read.ts index 99b0b1ac..d75342ec 100644 --- a/scripts/src/gh/auth-pr-read.ts +++ b/scripts/src/gh/auth-pr-read.ts @@ -6,8 +6,52 @@ import { ghBinaryPath, repoParts, resolveToken } from "./auth-and-safety"; import { commandError, errorPayload, githubRequest, isGitHubError, prBodyReadCommands, runnerDisposition } from "./client"; import { issueRead } from "./issue-read"; import { needsPrGraphqlMetadata, numberOrNull, prCloseoutMetadata, prCloseoutMetadataError, prCompactSummary, prFileSummary, prGraphqlMetadata, prMetadataSummary, prSummary, selectedPrJson, sumPrFileStats } from "./pr-summary"; +import { probeRepositoryWriteCapability } from "./repository-capability"; import { MAX_PR_FILES_LIMIT, PR_LIST_JSON_FIELDS } from "./types"; -import type { GitHubCommandResult, GitHubDegradedReason, GitHubIssue, GitHubPullRequest, GitHubPullRequestFile, PrListJsonField, PrListState, PrReadJsonField } from "./types"; +import type { GitHubCommandResult, GitHubDegradedReason, GitHubIssue, GitHubPullRequest, GitHubPullRequestFile, GitHubRepository, PrListJsonField, PrListState, PrReadJsonField } from "./types"; + +type RepositoryCapabilityStatus = "available" | "unavailable" | "unknown"; + +function capabilityStatus(available: boolean | null): RepositoryCapabilityStatus { + if (available === true) return "available"; + if (available === false) return "unavailable"; + return "unknown"; +} + +function repositoryPermissionLevel(permissions: Record | undefined): "admin" | "maintain" | "write" | "read" | "unknown" { + if (permissions === undefined) return "unknown"; + if (permissions.admin === true) return "admin"; + if (permissions.maintain === true) return "maintain"; + if (permissions.push === true) return "write"; + if (permissions.pull === true || permissions.triage === true) return "read"; + return "unknown"; +} + +function repositoryCapabilities(repo: GitHubRepository, issueReadable: boolean, repositoryWrite: ReturnType): Record { + const permissionLevel = repositoryPermissionLevel(repo.permissions); + return { + repositoryRead: { + status: capabilityStatus(issueReadable), + available: issueReadable, + evidence: "repository-and-issue-read", + }, + repositoryWrite: { + ...repositoryWrite, + permissionLevel, + }, + pullRequestMerge: { + status: repositoryWrite.status, + available: repositoryWrite.available, + permissionLevel, + inferredFrom: "repository-write-git-push-dry-run", + requiredPermission: "contents:write", + writesRemote: false, + mutation: false, + caveat: "guarded merge still checks branch protection, checks, and PR state", + }, + valuesPrinted: false, + }; +} export async function authStatus(repo: string): Promise { const ghPath = ghBinaryPath(); @@ -48,7 +92,7 @@ export async function authStatus(repo: string): Promise { }); } - const repoProbe = await githubRequest<{ full_name?: string; private?: boolean }>(token, "GET", `/repos/${owner}/${name}`); + const repoProbe = await githubRequest(token, "GET", `/repos/${owner}/${name}`); if (isGitHubError(repoProbe)) { return commandError("auth status", repo, repoProbe, { degraded: [...degraded, repoProbe.degradedReason], @@ -68,6 +112,9 @@ export async function authStatus(repo: string): Promise { }); } + const repositoryWrite = probeRepositoryWriteCapability(repo, token); + const capabilities = repositoryCapabilities(repoProbe, true, repositoryWrite); + return { ok: true, command: "auth status", @@ -80,7 +127,16 @@ export async function authStatus(repo: string): Promise { repo: { ok: true, fullName: repoProbe.full_name ?? repo, private: repoProbe.private ?? null }, issueRead: { ok: true, readable: true, sampleCount: issueProbe.length }, }, + capabilities, + warnings: (capabilities.repositoryWrite as { available: boolean | null }).available === true ? [] : [{ + warning: true, + blocking: false, + code: "github-repository-write-capability-unavailable", + capability: "pull-request-merge", + status: (capabilities.pullRequestMerge as { status: RepositoryCapabilityStatus }).status, + }], restFallback: true, + valuesPrinted: false, }; } diff --git a/scripts/src/gh/default-render.ts b/scripts/src/gh/default-render.ts index 4560513b..57325de5 100644 --- a/scripts/src/gh/default-render.ts +++ b/scripts/src/gh/default-render.ts @@ -381,6 +381,11 @@ function renderAuthStatus(result: GitHubCommandResult): string { const token = record(result.token); const gh = record(result.gh); const probes = record(result.probes); + const capabilities = record(result.capabilities); + const repositoryRead = record(capabilities.repositoryRead); + const repositoryWrite = record(capabilities.repositoryWrite); + const pullRequestMerge = record(capabilities.pullRequestMerge); + const warnings = Array.isArray(result.warnings) ? result.warnings : []; const tokenDetail = [ `source=${ghText(token.source)}`, `scope=${ghText(token.scope)}`, @@ -399,6 +404,9 @@ function renderAuthStatus(result: GitHubCommandResult): string { ["rest-api", probeStatus(probes.restApi), ghText(probes.restApi)], ["repo", probeStatus(probes.repo), probeDetail(probes.repo)], ["issue-read", probeStatus(probes.issueRead), probeDetail(probes.issueRead)], + ["repo-read", capabilityProbeStatus(repositoryRead), capabilityProbeDetail(repositoryRead)], + ["repo-write", capabilityProbeStatus(repositoryWrite), capabilityProbeDetail(repositoryWrite)], + ["pr-merge", capabilityProbeStatus(pullRequestMerge), capabilityProbeDetail(pullRequestMerge)], ]; return [ `gh auth status (${result.ok ? "ok" : "failed"})`, @@ -407,12 +415,31 @@ function renderAuthStatus(result: GitHubCommandResult): string { "", "Summary:", ` repo=${ghText(result.repo)} restFallback=${ghText(result.restFallback)} valuesPrinted=false`, + ` warnings=${warnings.length} blocking=false`, "", "Disclosure:", " token values are never printed; use --raw only where supported for structured diagnostics.", ].join("\n"); } +function capabilityProbeStatus(value: Record): string { + if (value.status === "available") return "ok"; + if (value.status === "unavailable") return "unavailable"; + if (value.status === "unknown") return "unknown"; + return "skipped"; +} + +function capabilityProbeDetail(value: Record): string { + if (Object.keys(value).length === 0) return "not-probed"; + return [ + `status=${ghText(value.status)}`, + `permission=${ghText(value.permissionLevel)}`, + `evidence=${ghText(value.evidence ?? value.inferredFrom)}`, + `reason=${ghText(value.reason)}`, + `mutation=${ghText(value.mutation)}`, + ].filter((item) => !item.endsWith("=-") && !item.endsWith("=undefined")).join(" "); +} + function renderRepoResult(result: GitHubCommandResult): string { const repo = record(result.repository); const planned = record(result.planned); diff --git a/scripts/src/gh/help.ts b/scripts/src/gh/help.ts index daa53fe3..6049673e 100644 --- a/scripts/src/gh/help.ts +++ b/scripts/src/gh/help.ts @@ -69,7 +69,7 @@ export function ghHelp(): unknown { notes: [ "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.", "repo view/create use GitHub REST through the same token path. repo create defaults to private repositories, preflights existing repos, supports --dry-run, and refuses duplicate creation.", - "Token values are never printed; auth status reports only token source and presence.", + "Token values are never printed; auth status reports token source, presence, repository read capability, non-mutating git push dry-run write capability and PR merge eligibility.", "Default gh output is k8s-style text/table/summary with Next drill-down commands. Machine-readable or full-disclosure output must be requested explicitly with --json, --full, or --raw where supported.", "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. --title-prefix filters the bounded listed issues locally by exact title startsWith, useful for [FEEDBACK] dedupe, and reports titleFilter input/output counts. Supported --json fields are number,title,state,closed,closedAt,url,updatedAt,createdAt,author,labels and unknown fields fail structurally.", @@ -160,6 +160,9 @@ export function ghScopedHelpNotes(tokens: string[]): string[] { if (key === "issue view" || key === "issue read") { notes.push("Human full-body reading uses `trans gh:/owner/repo/issue/ cat`; use the same route with `rg ` for targeted lookup."); notes.push("`--json body`, `--full`, and `--raw` are explicit structured machine disclosure; recent comment progress uses bounded `gh issue comments `."); + } else if (key === "auth status") { + notes.push("Auth status reports repository read capability plus a non-mutating git push dry-run write probe and PR merge eligibility without printing token values."); + notes.push("PR merge eligibility covers token and repository permission only; guarded merge still checks branch protection, checks, and PR state."); } else if (key === "pr view" || key === "pr read") { notes.push("Human full-body reading uses `trans gh:/owner/repo/pr/ cat`; use the same route with `rg ` for targeted lookup."); notes.push("`--json body`, `--full`, and `--raw` are explicit structured machine disclosure; changed-file review uses bounded `gh pr files` or `gh pr review-plan`."); diff --git a/scripts/src/gh/repository-capability.ts b/scripts/src/gh/repository-capability.ts new file mode 100644 index 00000000..686877a9 --- /dev/null +++ b/scripts/src/gh/repository-capability.ts @@ -0,0 +1,80 @@ +import { spawnSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { rootPath } from "../config"; +import { REQUEST_TIMEOUT_MS } from "./types"; + +export interface RepositoryWriteCapability { + status: "available" | "unavailable" | "unknown"; + available: boolean | null; + evidence: "git-push-dry-run"; + reason: string; + exitCode: number | null; + writesRemote: false; + mutation: false; + valuesPrinted: false; +} + +export function probeRepositoryWriteCapability(repo: string, token: string): RepositoryWriteCapability { + const root = mkdtempSync(join(tmpdir(), "unidesk-gh-auth-")); + const askpass = join(root, "askpass.sh"); + const probeRef = `refs/heads/unidesk-auth-capability-${randomBytes(6).toString("hex")}`; + try { + writeFileSync(askpass, [ + "#!/bin/sh", + "case \"$1\" in", + " *Username*) printf '%s\\n' 'x-access-token' ;;", + " *Password*) printf '%s\\n' \"$UNIDESK_GH_AUTH_TOKEN\" ;;", + " *) printf '\\n' ;;", + "esac", + "", + ].join("\n"), { encoding: "utf8", mode: 0o700 }); + const result = spawnSync("git", [ + "-c", "credential.helper=", + "-c", "http.extraHeader=", + "push", "--dry-run", "--porcelain", + `https://github.com/${repo}.git`, + `HEAD:${probeRef}`, + ], { + encoding: "utf8", + cwd: rootPath(), + env: { + ...process.env, + GH_TOKEN: undefined, + GITHUB_TOKEN: undefined, + GIT_ASKPASS: askpass, + GIT_ASKPASS_REQUIRE: "force", + GIT_TERMINAL_PROMPT: "0", + UNIDESK_GH_AUTH_TOKEN: token, + }, + stdio: ["ignore", "pipe", "pipe"], + timeout: REQUEST_TIMEOUT_MS, + maxBuffer: 64 * 1024, + }); + if (result.status === 0) return capability(true, "git-push-dry-run-succeeded", result.status); + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; + if (/write access .* not granted|permission denied|authentication failed|requested url returned error: 403|repository not found|remote rejected/iu.test(output)) { + return capability(false, "git-push-dry-run-permission-denied", result.status); + } + if (result.error?.name === "ETIMEDOUT") return capability(null, "git-push-dry-run-timeout", result.status); + if ((result.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") return capability(null, "git-binary-missing", result.status); + return capability(null, "git-push-dry-run-inconclusive", result.status); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +function capability(available: boolean | null, reason: string, exitCode: number | null): RepositoryWriteCapability { + return { + status: available === true ? "available" : available === false ? "unavailable" : "unknown", + available, + evidence: "git-push-dry-run", + reason, + exitCode, + writesRemote: false, + mutation: false, + valuesPrinted: false, + }; +}