fix: 显示 GitHub 仓库写与合并能力
This commit is contained in:
@@ -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<string, boolean> | 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<typeof probeRepositoryWriteCapability>): Record<string, unknown> {
|
||||
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<GitHubCommandResult> {
|
||||
const ghPath = ghBinaryPath();
|
||||
@@ -48,7 +92,7 @@ export async function authStatus(repo: string): Promise<GitHubCommandResult> {
|
||||
});
|
||||
}
|
||||
|
||||
const repoProbe = await githubRequest<{ full_name?: string; private?: boolean }>(token, "GET", `/repos/${owner}/${name}`);
|
||||
const repoProbe = await githubRequest<GitHubRepository>(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<GitHubCommandResult> {
|
||||
});
|
||||
}
|
||||
|
||||
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<GitHubCommandResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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, unknown>): 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, unknown>): 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);
|
||||
|
||||
@@ -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/<number> cat`; use the same route with `rg <pattern>` for targeted lookup.");
|
||||
notes.push("`--json body`, `--full`, and `--raw` are explicit structured machine disclosure; recent comment progress uses bounded `gh issue comments <number>`.");
|
||||
} 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/<number> cat`; use the same route with `rg <pattern>` 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`.");
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user