Merge pull request #1448 from pikasTech/feat/gh-pr-review-drilldown
feat: add bounded PR review drilldown
This commit is contained in:
@@ -27,16 +27,19 @@ bun scripts/cli.ts gh issue view <number> --repo pikasTech/unidesk
|
||||
bun scripts/cli.ts gh issue view <number> --repo pikasTech/unidesk --json body,title,state
|
||||
bun scripts/cli.ts gh issue create --repo pikasTech/unidesk --title "标题" --body-stdin
|
||||
bun scripts/cli.ts gh pr list --repo pikasTech/unidesk --state all --limit 10
|
||||
bun scripts/cli.ts gh pr review-plan <number> --repo pikasTech/unidesk
|
||||
bun scripts/cli.ts gh pr diff <number> --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 <branch>
|
||||
bun scripts/cli.ts gh pr preflight <number> --repo pikasTech/unidesk
|
||||
bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk --merge --delete-branch
|
||||
```
|
||||
|
||||
完整 issue/PR CRUD、comment、patch、stale-close、scan-escape、PR files/preflight/merge、看板命令和 `trans gh:` 虚拟文件系统见 [references/full.md](references/full.md)。
|
||||
完整 issue/PR CRUD、comment、patch、stale-close、scan-escape、PR files/review-plan/diff/preflight/merge、看板命令和 `trans gh:` 虚拟文件系统见 [references/full.md](references/full.md)。
|
||||
|
||||
## 何时读取 reference
|
||||
|
||||
- 需要具体 issue/PR/comment 命令参数、`--json` 字段或 body guard:读 [references/full.md](references/full.md) 的 Issue/PR 命令段。
|
||||
- 需要 PR bounded patch/index/drill-down:读 [references/full.md](references/full.md) 的 PR 文件变更与 review-plan 段。
|
||||
- 需要局部修补正文或评论:读 `trans gh:` 和 apply-patch 段。
|
||||
- 需要维护总看板 [#20](https://github.com/pikasTech/unidesk/issues/20):读看板命令段。
|
||||
- 需要 closeout、preflight、merge 或 ancestry/squash 判断:读 PR 命令和关键约定段。
|
||||
|
||||
@@ -262,10 +262,16 @@ bun scripts/cli.ts gh pr view <number|url|owner/repo#number> \
|
||||
|
||||
```bash
|
||||
bun scripts/cli.ts gh pr files <number> [--repo owner/name] [--limit N]
|
||||
bun scripts/cli.ts gh pr review-plan <number|url|owner/repo#number> [--repo owner/name] [--limit N]
|
||||
bun scripts/cli.ts gh pr diff <number|url|owner/repo#number> --file <path> [--hunk N] [--repo owner/name] [--limit N] [--full|--raw]
|
||||
```
|
||||
|
||||
默认 stdout 是 changed files 统计表格,不输出 raw diff。`gh pr diff <number> --stat` 是兼容别名。
|
||||
|
||||
`review-plan` 是主代理 review PR 的 bounded patch index:默认输出 changed files、add/del、hunk 数、patch line 数、默认 patch drill-down 是否会截断,以及每个返回文件的 `gh pr diff --file` 命令。它不创建本地 review worktree,也不输出 patch body。
|
||||
|
||||
`gh pr diff --file <path>` 读取单个 changed file 的 GitHub REST patch,默认只显示 bounded patch excerpt;`--hunk N` drill-down 到单个 hunk,`--limit N` 调整默认显示行数。只有显式 `--full`/`--raw` 才在结构化输出里包含完整 file patch。
|
||||
|
||||
### 收口预检
|
||||
|
||||
```bash
|
||||
|
||||
@@ -50,6 +50,8 @@ function renderGhDefaultText(result: GitHubCommandResult): string {
|
||||
if (isPrReadResult(result)) return renderPrView(result);
|
||||
if (command === "pr list") return renderPrList(result);
|
||||
if (command === "pr files" || command === "pr diff --stat") return renderPrFiles(result);
|
||||
if (command === "pr review-plan") return renderPrReviewPlan(result);
|
||||
if (command === "pr diff" && isRecord(result.file)) return renderPrDiffFile(result);
|
||||
if (command.includes("comment")) return renderCommentResult(result);
|
||||
if (command === "auth status") return renderAuthStatus(result);
|
||||
if (command.startsWith("repo ")) return renderRepoResult(result);
|
||||
@@ -235,6 +237,83 @@ function renderPrFiles(result: GitHubCommandResult): string {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function renderPrReviewPlan(result: GitHubCommandResult): string {
|
||||
const files = arrayOfRecords(result.files);
|
||||
const rows = files.slice(0, 40).map((file) => {
|
||||
const patch = record(file.patch);
|
||||
return [
|
||||
ghText(file.index),
|
||||
ghShort(ghText(file.filename), 72),
|
||||
ghText(file.status),
|
||||
ghText(file.additions),
|
||||
ghText(file.deletions),
|
||||
ghText(patch.hunks),
|
||||
ghText(patch.lines),
|
||||
ghText(patch.defaultTruncated),
|
||||
];
|
||||
});
|
||||
const summary = record(result.summary);
|
||||
const truncation = record(result.truncation);
|
||||
const drillDown = files
|
||||
.map((file) => record(file.drillDown).patch)
|
||||
.filter((command): command is string => typeof command === "string")
|
||||
.slice(0, 40);
|
||||
const next = record(result.next);
|
||||
const lines = [
|
||||
"gh pr review-plan (observed)",
|
||||
"",
|
||||
ghTable(["#", "FILE", "STATUS", "ADD", "DEL", "HUNKS", "PATCH_LINES", "PATCH_TRUNC"], rows),
|
||||
"",
|
||||
"Summary:",
|
||||
` repo=${ghText(result.repo)} pr=#${ghText(record(result.pullRequest).number)} files=${ghText(summary.files)} returned=${ghText(result.filesReturned)} truncated=${ghText(truncation.truncated)}`,
|
||||
` additions=${ghText(summary.additions)} deletions=${ghText(summary.deletions)} changes=${ghText(summary.changes)}`,
|
||||
"",
|
||||
"Next:",
|
||||
];
|
||||
if (drillDown.length > 0) lines.push(...drillDown.map((command, index) => ` [${index + 1}] ${command}`));
|
||||
else lines.push(` ${ghText(next.metadata)}`);
|
||||
if (typeof next.files === "string") lines.push(` ${next.files}`);
|
||||
lines.push("", "Disclosure:", " default review-plan is a bounded changed-file index; it omits patch bodies and prints per-file drill-down commands.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function renderPrDiffFile(result: GitHubCommandResult): string {
|
||||
const file = record(result.file);
|
||||
const patch = record(result.patch);
|
||||
const selectedHunk = record(patch.hunk);
|
||||
const patchLines = Array.isArray(patch.lines) ? patch.lines.filter((line): line is string => typeof line === "string") : [];
|
||||
const next = record(result.next);
|
||||
const lines = [
|
||||
"gh pr diff (observed)",
|
||||
"",
|
||||
ghTable(["FILE", "STATUS", "ADD", "DEL", "HUNKS", "SELECTED", "LINES", "SHOWN", "TRUNC"], [[
|
||||
ghShort(ghText(file.filename), 72),
|
||||
ghText(file.status),
|
||||
ghText(file.additions),
|
||||
ghText(file.deletions),
|
||||
ghText(file.hunks),
|
||||
ghText(selectedHunk.index ?? "file"),
|
||||
ghText(patch.linesTotal),
|
||||
ghText(patch.linesShown),
|
||||
ghText(patch.truncated),
|
||||
]]),
|
||||
"",
|
||||
"Patch:",
|
||||
...patchLines.map((line) => ` ${line}`),
|
||||
"",
|
||||
"Summary:",
|
||||
` repo=${ghText(result.repo)} pr=#${ghText(record(result.pullRequest).number)} file=${ghText(file.filename)} redactions=${ghText(patch.redactions)} rawDiffIncluded=${ghText(result.rawDiffIncluded)}`,
|
||||
"",
|
||||
"Next:",
|
||||
` ${ghText(next.reviewPlan)}`,
|
||||
` ${ghText(next.nextHunk ?? next.fullFilePatch)}`,
|
||||
"",
|
||||
"Disclosure:",
|
||||
" default diff output is a bounded patch excerpt; use --hunk N for one hunk or --full/--raw for explicit structured full-patch disclosure.",
|
||||
];
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function renderCommentResult(result: GitHubCommandResult): string {
|
||||
const comment = record(result.comment);
|
||||
const status = result.dryRun === true ? "dry-run" : result.deleted === true ? "deleted" : result.ok ? "ok" : "failed";
|
||||
|
||||
@@ -45,6 +45,8 @@ export function ghHelp(): unknown {
|
||||
"bun scripts/cli.ts gh pr list [owner/repo] [--repo owner/name] [--state open|closed|all] [--limit N] [--json number,title,state,url,updatedAt,createdAt,author,head,base,draft]",
|
||||
"bun scripts/cli.ts gh pr files <number> [--repo owner/name] [--number N compat] [--limit N] [number may appear before or after options]",
|
||||
"bun scripts/cli.ts gh pr diff <number> --stat [--repo owner/name] [--number N compat] [--limit N] [number may appear before or after options; compatibility alias for pr files; no raw diff]",
|
||||
"bun scripts/cli.ts gh pr review-plan <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--limit N] [bounded changed-file index plus per-file patch drill-down commands]",
|
||||
"bun scripts/cli.ts gh pr diff <number|url|owner/repo#number> --file <path> [--hunk N] [--repo owner/name] [--number N compat] [--limit N] [--full|--raw] [bounded file/hunk patch drill-down]",
|
||||
"bun scripts/cli.ts gh pr view <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--json body,title,state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,head,base,draft,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup] [--raw|--full]",
|
||||
"bun scripts/cli.ts gh pr read <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--raw|--full] [compatibility alias for pr view]",
|
||||
"bun scripts/cli.ts gh pr preflight <number|owner/repo#number> [--repo owner/name] [--number N compat] [--full|--raw] [number may appear before or after options]",
|
||||
@@ -72,7 +74,7 @@ export function ghHelp(): unknown {
|
||||
"PR list defaults to --state all for compatibility with earlier UniDesk CLI behavior; supported states are open, closed, and all.",
|
||||
"issue view is the canonical GitHub CLI-compatible read path; read remains a UniDesk compatibility alias. View/read accept positional numbers, GitHub issue URLs, and owner/repo#number shorthand, deriving --repo unless an explicit conflicting --repo is supplied. --number is accepted on single issue/comment numeric target commands for low-friction compatibility and returns a standard syntax hint; list/create/scan-escape/cleanup-plan/board-audit/board-row list do not accept it. Comment view/read/update/edit/delete treat --number as commentId, not an issue number. View supports lifecycle fields closed/closedAt plus legacy --json field selection; explicit --json fields limit output even with --raw/--full; full issue body is included only when requested with --json body or when --json is omitted and --full/--raw requests all fields. Comment lists always stay bounded except --raw; use gh issue comment view <commentId> --full for a single full comment body. Unsupported fields fail structurally.",
|
||||
"issue attachment list/download scan issue body and comments for GitHub user attachment URLs (`https://github.com/user-attachments/assets/...`). list is read-only and returns bounded attachment metadata. download writes the selected attachment to --output or /tmp/unidesk-gh-attachments, returns bytes/SHA-256/content-type/path, redacts redirected signed URL query parameters, and never prints binary bytes.",
|
||||
"--raw and --full are explicit full-disclosure aliases for gh issue list/read/view/update/edit/patch/comment view and gh pr list/read/view/comment view. For issue read/view commands, --full expands issue fields but keeps comment lists bounded; --raw is the explicit all-comment-body escape hatch. Use issue comment view <commentId> --full for a single full comment body. 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.",
|
||||
"--raw and --full are explicit full-disclosure aliases for gh issue list/read/view/update/edit/patch/comment view, gh pr list/read/view/comment view, and gh pr diff --file. For issue read/view commands, --full expands issue fields but keeps comment lists bounded; --raw is the explicit all-comment-body escape hatch. Use issue comment view <commentId> --full for a single full comment body. 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.",
|
||||
"CLI output larger than config/unidesk-cli.yaml output.maxStdoutBytes is automatically written to /tmp/unidesk-cli-output; stdout stays bounded with outputTruncated=true, warning text, dump file metadata, and drill-down read commands.",
|
||||
"issue create accepts --body-stdin or --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.",
|
||||
"--body-stdin is the first-class heredoc/stdin source for Markdown bodies. Use quoted heredoc syntax such as bun scripts/cli.ts gh issue comment create 1 --body-stdin <<'EOF' so real newlines, backticks, and tables are read as stdin bytes instead of shell arguments.",
|
||||
@@ -96,6 +98,8 @@ export function ghHelp(): unknown {
|
||||
"Commander brief ClaudeQQ defaults to private target 645275593 through backend-core /api/microservices/claudeqq/proxy; UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_* env vars can override target, base URL, timeout, and enabled state.",
|
||||
"comment update/edit PATCHes /repos/{owner}/{repo}/issues/comments/{comment_id} and preserves the comment id/timeline; comment delete is supported because GitHub supports deleting issue comments, but routine wording fixes should use update/edit. issue/pr hard delete is unsupported and close is the lifecycle alternative.",
|
||||
"PR files is the canonical compact changed-file/stat summary. It uses GitHub REST, returns bounded file rows, additions/deletions/changes when available, truncation metadata, and a next command for full details. Raw diff patches are not emitted by default; gh pr diff <number> --stat is a compatibility alias for the same JSON summary.",
|
||||
"PR review-plan is the review-first bounded patch index. It uses GitHub REST PR files, prints changed files with additions/deletions/hunk counts/patch-line counts/default truncation flags, and emits one per-file gh pr diff --file drill-down command without creating a local worktree.",
|
||||
"PR diff --file reads one changed file patch from GitHub REST and prints only a bounded excerpt by default. Add --hunk N to inspect one hunk, --limit N to change displayed patch lines, or --full/--raw for explicit structured full-patch disclosure. --stat remains the no-patch file/stat compatibility path.",
|
||||
"PR edit/update PATCHes /repos/{owner}/{repo}/pulls/{number} through REST only, never GitHub Projects Classic GraphQL/projectCards, and returns low-noise JSON with repo, PR number, changedFields, url, and body size/SHA metadata instead of echoing the full body.",
|
||||
"PR view is the canonical GitHub CLI-compatible read path; read remains a UniDesk compatibility alias. PR view/read accept positional numbers, GitHub PR URLs, and owner/repo#number shorthand, deriving --repo unless an explicit conflicting --repo is supplied. --number is accepted on single PR/comment numeric target commands for low-friction compatibility and returns a standard syntax hint; list/create do not accept it. PR comment update/edit/delete treat --number as commentId, not a PR number. PR view/read supports REST closeout fields stateDetail, closed, closedAt, merged, mergedAt, mergeCommit, headRefName, and baseRefName; explicit --json fields limit output even with --raw/--full. Mergeable, mergeStateStatus, and statusCheckRollup are fetched through GitHub GraphQL only when requested or when --json is omitted and --raw/--full requests full disclosure, and closeoutMetadata makes GraphQL errors plus UNKNOWN/null metadata explicit.",
|
||||
"PR preflight/closeout accept the same owner/repo#number shorthand as PR view/read so merge readiness checks do not require repeating --repo after a PR URL has already been normalized.",
|
||||
@@ -164,6 +168,9 @@ export function ghScopedHelpNotes(tokens: string[]): string[] {
|
||||
} 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.");
|
||||
} else if (key === "pr review-plan" || key === "pr diff") {
|
||||
notes.push("Use `pr review-plan` first for a bounded changed-file index with per-file drill-down commands.");
|
||||
notes.push("Use `pr diff <number> --file <path> [--hunk N]` for bounded patch review; full patch disclosure requires explicit --full or --raw.");
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
+47
-3
@@ -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 { prDiffFile, prReviewPlan } from "./pr-review";
|
||||
import { isGitHubCommandResult, issueReadJsonFields, prReadJsonFields, readDisclosureOptions, readViewSupportedJsonFields, resolvePositionalIssueReference, resolvePositionalNumberReference, resolvePositionalPrReference, resolveReadViewNumberReference, unknownGhOptionDetails, withNumberOptionHint } from "./refs";
|
||||
import { issueStaleClose } from "./stale-close";
|
||||
import { DEFAULT_REPO } from "./types";
|
||||
@@ -55,9 +56,10 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
|
||||
}
|
||||
const isIssueCommentReadCommand = top === "issue" && sub === "comment" && (third === "view" || third === "read");
|
||||
const isPrCommentReadCommand = top === "pr" && sub === "comment" && (third === "view" || third === "read");
|
||||
if ((optionWasProvided(args, "--raw") || optionWasProvided(args, "--full")) && !((top === "issue" && (isIssueReadCommand(sub) || sub === "list" || sub === "update" || sub === "edit" || sub === "patch")) || isIssueCommentReadCommand || top === "preflight" || (top === "pr" && (isPrReadCommand(sub) || sub === "list" || sub === "preflight" || sub === "closeout")) || isPrCommentReadCommand)) {
|
||||
const isPrDiffFileCommand = top === "pr" && sub === "diff" && optionWasProvided(args, "--file");
|
||||
if ((optionWasProvided(args, "--raw") || optionWasProvided(args, "--full")) && !((top === "issue" && (isIssueReadCommand(sub) || sub === "list" || sub === "update" || sub === "edit" || sub === "patch")) || isIssueCommentReadCommand || top === "preflight" || (top === "pr" && (isPrReadCommand(sub) || sub === "list" || sub === "preflight" || sub === "closeout")) || isPrCommentReadCommand || isPrDiffFileCommand)) {
|
||||
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||||
return validationError(command, options.repo, "--raw and --full are explicit full-disclosure aliases only for gh issue list/read/view/update/edit/patch/comment view, gh pr list/read/view/comment view, and gh pr preflight/closeout.", {
|
||||
return validationError(command, options.repo, "--raw and --full are explicit full-disclosure aliases only for gh issue list/read/view/update/edit/patch/comment view, gh pr list/read/view/comment view, gh pr preflight/closeout, and gh pr diff --file.", {
|
||||
supportedCommands: [
|
||||
"bun scripts/cli.ts gh issue list --repo owner/name --limit 200 --full",
|
||||
"bun scripts/cli.ts gh issue view owner/name#<number> --raw",
|
||||
@@ -69,6 +71,7 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
|
||||
"bun scripts/cli.ts gh pr view owner/name#<number> --raw",
|
||||
`bun scripts/cli.ts gh pr view <number> --repo owner/name --json ${readViewSupportedJsonFields("pr")}`,
|
||||
"bun scripts/cli.ts gh pr preflight <number> --repo owner/name --full",
|
||||
"bun scripts/cli.ts gh pr diff <number> --repo owner/name --file path/to/file --full",
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -89,6 +92,23 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
|
||||
],
|
||||
});
|
||||
}
|
||||
if (optionWasProvided(args, "--file") && !(top === "pr" && sub === "diff")) {
|
||||
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||||
return validationError(command, options.repo, "--file is only supported by gh pr diff <number> --file <path>", {
|
||||
supportedCommands: [
|
||||
"bun scripts/cli.ts gh pr review-plan <number> --repo owner/name",
|
||||
"bun scripts/cli.ts gh pr diff <number> --repo owner/name --file path/to/file",
|
||||
],
|
||||
});
|
||||
}
|
||||
if (optionWasProvided(args, "--hunk") && !(top === "pr" && sub === "diff" && optionWasProvided(args, "--file"))) {
|
||||
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||||
return validationError(command, options.repo, "--hunk is only supported by gh pr diff <number> --file <path> --hunk <n>", {
|
||||
supportedCommands: [
|
||||
"bun scripts/cli.ts gh pr diff <number> --repo owner/name --file path/to/file --hunk 1",
|
||||
],
|
||||
});
|
||||
}
|
||||
if (optionWasProvided(args, "--number") && !allowsNumberTargetAlias(top, sub, third)) {
|
||||
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||||
const standardViewCommand = top === "issue" || top === "pr" ? `gh ${top} view` : "gh issue/pr view";
|
||||
@@ -409,10 +429,26 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
|
||||
if (sub === "diff") {
|
||||
const resolved = resolvePositionalPrReference(args, 2, "pr diff", options);
|
||||
if (isGitHubCommandResult(resolved)) return resolved;
|
||||
if (optionWasProvided(args, "--stat") && optionWasProvided(args, "--file")) {
|
||||
return validationError("pr diff", resolved.repo, "choose either --stat for file/stat summary or --file for bounded patch drill-down, not both", {
|
||||
supportedCommands: [
|
||||
`bun scripts/cli.ts gh pr diff ${resolved.number} --stat --repo ${resolved.repo} --limit 30`,
|
||||
`bun scripts/cli.ts gh pr diff ${resolved.number} --repo ${resolved.repo} --file ${options.filePath ?? "path/to/file"}`,
|
||||
],
|
||||
});
|
||||
}
|
||||
if (optionWasProvided(args, "--file")) {
|
||||
const { token, probe } = resolveToken(true);
|
||||
const missing = authRequired(resolved.repo, "pr diff", probe);
|
||||
if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, "pr diff", { present: false, source: null, ghFallbackAttempted: true });
|
||||
return withNumberOptionHint(prDiffFile(resolved.repo, token, resolved.number, { ...options, repo: resolved.repo }), resolved);
|
||||
}
|
||||
if (!optionWasProvided(args, "--stat")) {
|
||||
return unsupportedCommand("pr diff", options.repo, "Raw PR diff output is intentionally unsupported by UniDesk CLI; use gh pr diff <number> --stat or gh pr files for a bounded REST file/stat summary.", {
|
||||
return unsupportedCommand("pr diff", options.repo, "Raw PR diff output is intentionally unsupported by default; use gh pr review-plan for an index, gh pr diff <number> --file <path> for bounded patch drill-down, or gh pr diff <number> --stat for a file/stat summary.", {
|
||||
rawDiffIncluded: false,
|
||||
supportedCommands: [
|
||||
`bun scripts/cli.ts gh pr review-plan ${resolved.number} --repo ${resolved.repo}`,
|
||||
`bun scripts/cli.ts gh pr diff ${resolved.number} --repo ${resolved.repo} --file path/to/file`,
|
||||
`bun scripts/cli.ts gh pr files ${resolved.number} --repo ${resolved.repo} --limit 30`,
|
||||
`bun scripts/cli.ts gh pr diff ${resolved.number} --stat --repo ${resolved.repo} --limit 30`,
|
||||
],
|
||||
@@ -423,6 +459,14 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
|
||||
if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, "pr diff --stat", { present: false, source: null, ghFallbackAttempted: true });
|
||||
return withNumberOptionHint(prFiles(resolved.repo, token, resolved.number, options.limit, "pr diff --stat"), resolved);
|
||||
}
|
||||
if (sub === "review-plan") {
|
||||
const resolved = resolvePositionalPrReference(args, 2, "pr review-plan", options);
|
||||
if (isGitHubCommandResult(resolved)) return resolved;
|
||||
const { token, probe } = resolveToken(true);
|
||||
const missing = authRequired(resolved.repo, "pr review-plan", probe);
|
||||
if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, "pr review-plan", { present: false, source: null, ghFallbackAttempted: true });
|
||||
return withNumberOptionHint(prReviewPlan(resolved.repo, token, resolved.number, options.limit), resolved);
|
||||
}
|
||||
if (sub === "files") {
|
||||
const resolved = resolvePositionalPrReference(args, 2, "pr files", options);
|
||||
if (isGitHubCommandResult(resolved)) return resolved;
|
||||
|
||||
@@ -89,6 +89,14 @@ export function positiveIntegerOption(args: string[], name: string, defaultValue
|
||||
return Math.min(value, maxValue);
|
||||
}
|
||||
|
||||
export function optionalPositiveIntegerOption(args: string[], name: string): number | undefined {
|
||||
const raw = optionValue(args, name);
|
||||
if (raw === undefined) return undefined;
|
||||
const value = Number(raw);
|
||||
if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function positiveNumberOption(args: string[], name: string, defaultValue: number, maxValue: number): number {
|
||||
const raw = optionValue(args, name);
|
||||
if (raw === undefined) return defaultValue;
|
||||
@@ -157,7 +165,7 @@ export function allowsNumberTargetAlias(top: string | undefined, sub: string | u
|
||||
return false;
|
||||
}
|
||||
if (top === "pr") {
|
||||
if (sub === "read" || sub === "view" || sub === "files" || sub === "diff" || sub === "preflight" || sub === "closeout" || sub === "edit" || sub === "update" || sub === "close" || sub === "reopen" || sub === "merge" || sub === "delete") return true;
|
||||
if (sub === "read" || sub === "view" || sub === "files" || sub === "diff" || sub === "review-plan" || sub === "preflight" || sub === "closeout" || sub === "edit" || sub === "update" || sub === "close" || sub === "reopen" || sub === "merge" || sub === "delete") return true;
|
||||
if (sub === "comment") return true;
|
||||
}
|
||||
return false;
|
||||
@@ -367,5 +375,7 @@ export function parseOptions(args: string[]): GitHubOptions {
|
||||
deleteBranch: hasFlag(args, "--delete-branch"),
|
||||
attachmentSelector: optionValue(args, "--attachment"),
|
||||
outputPath: optionValue(args, "--output"),
|
||||
filePath: optionValue(args, "--file"),
|
||||
hunk: optionalPositiveIntegerOption(args, "--hunk"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import { commandError, githubRequest, isGitHubError, validationError } from "./client";
|
||||
import { prCompactSummary, prFileSummary, sumPrFileStats, numberOrNull } from "./pr-summary";
|
||||
import { MAX_PR_FILES_LIMIT } from "./types";
|
||||
import type { GitHubCommandResult, GitHubOptions, GitHubPullRequest, GitHubPullRequestFile } from "./types";
|
||||
import { repoParts } from "./auth-and-safety";
|
||||
|
||||
const DEFAULT_PR_PATCH_LINE_LIMIT = 30;
|
||||
|
||||
interface PatchHunk {
|
||||
index: number;
|
||||
header: string;
|
||||
lines: string[];
|
||||
additions: number;
|
||||
deletions: number;
|
||||
}
|
||||
|
||||
interface PatchLineSelection {
|
||||
hunk: PatchHunk | null;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
interface RedactedPatchLines {
|
||||
lines: string[];
|
||||
redactions: number;
|
||||
}
|
||||
|
||||
interface PrFilesFetch {
|
||||
pr: GitHubPullRequest;
|
||||
files: GitHubPullRequestFile[];
|
||||
totalFiles: number | null;
|
||||
truncated: boolean;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export async function prReviewPlan(repo: string, token: string, number: number, limit: number): Promise<GitHubCommandResult> {
|
||||
const fetched = await fetchPrFiles(repo, token, number, Math.min(limit, MAX_PR_FILES_LIMIT), "pr review-plan");
|
||||
if (isGitHubCommandResult(fetched)) return fetched;
|
||||
const listedStats = sumPrFileStats(fetched.files);
|
||||
const totalAdditions = numberOrNull(fetched.pr.additions);
|
||||
const totalDeletions = numberOrNull(fetched.pr.deletions);
|
||||
const files = fetched.files.map((file, index) => reviewPlanFile(file, index + 1, repo, number));
|
||||
const firstFile = files[0];
|
||||
return {
|
||||
ok: true,
|
||||
command: "pr review-plan",
|
||||
repo,
|
||||
readOnly: true,
|
||||
rawDiffIncluded: false,
|
||||
pullRequest: prCompactSummary(fetched.pr),
|
||||
summary: {
|
||||
files: fetched.totalFiles ?? fetched.files.length,
|
||||
additions: totalAdditions ?? listedStats.additions,
|
||||
deletions: totalDeletions ?? listedStats.deletions,
|
||||
changes: (totalAdditions !== null && totalDeletions !== null) ? totalAdditions + totalDeletions : listedStats.changes,
|
||||
commits: numberOrNull(fetched.pr.commits),
|
||||
},
|
||||
files,
|
||||
filesReturned: fetched.files.length,
|
||||
limit: fetched.limit,
|
||||
truncation: {
|
||||
truncated: fetched.truncated,
|
||||
returned: fetched.files.length,
|
||||
totalFiles: fetched.totalFiles,
|
||||
maxLimit: MAX_PR_FILES_LIMIT,
|
||||
},
|
||||
next: {
|
||||
firstFilePatch: drillDownCommand(repo, number, typeof firstFile?.filename === "string" ? firstFile.filename : "<path>"),
|
||||
files: `bun scripts/cli.ts gh pr files ${number} --repo ${repo} --limit ${Math.min(fetched.totalFiles ?? MAX_PR_FILES_LIMIT, MAX_PR_FILES_LIMIT)}`,
|
||||
metadata: `bun scripts/cli.ts gh pr view ${number} --repo ${repo}`,
|
||||
},
|
||||
request: {
|
||||
method: "GET",
|
||||
paths: githubPrFilePaths(repo, number),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function prDiffFile(repo: string, token: string, number: number, options: GitHubOptions): Promise<GitHubCommandResult> {
|
||||
const filePath = options.filePath;
|
||||
if (filePath === undefined || filePath.trim().length === 0) {
|
||||
return validationError("pr diff", repo, "gh pr diff --file requires a non-empty path", {
|
||||
supportedCommands: [`bun scripts/cli.ts gh pr review-plan ${number} --repo ${repo}`],
|
||||
});
|
||||
}
|
||||
const fetched = await fetchPrFiles(repo, token, number, MAX_PR_FILES_LIMIT, "pr diff");
|
||||
if (isGitHubCommandResult(fetched)) return fetched;
|
||||
const file = fetched.files.find((candidate) => candidate.filename === filePath);
|
||||
if (file === undefined) {
|
||||
return validationError("pr diff", repo, `PR #${number} does not include file: ${filePath}`, {
|
||||
filesScanned: fetched.files.length,
|
||||
truncated: fetched.truncated,
|
||||
sampleFiles: fetched.files.slice(0, 20).map((candidate) => candidate.filename),
|
||||
supportedCommands: [`bun scripts/cli.ts gh pr review-plan ${number} --repo ${repo} --limit 100`],
|
||||
});
|
||||
}
|
||||
|
||||
const patch = file.patch ?? "";
|
||||
const patchLines = splitPatchLines(patch);
|
||||
const hunks = parsePatchHunks(patch);
|
||||
const selected = selectPatchLines(repo, hunks, patchLines, options.hunk);
|
||||
if (isGitHubCommandResult(selected)) return selected;
|
||||
const includeFullPatch = options.full || options.raw;
|
||||
const appliedLimit = includeFullPatch ? selected.lines.length : Math.min(options.limit, selected.lines.length);
|
||||
const excerpt = selected.lines.slice(0, appliedLimit);
|
||||
const redacted = redactPatchLines(excerpt);
|
||||
const nextHunk = options.hunk === undefined && hunks.length > 0
|
||||
? `bun scripts/cli.ts gh pr diff ${number} --repo ${repo} --file ${shellQuote(file.filename)} --hunk 1`
|
||||
: options.hunk !== undefined && options.hunk < hunks.length
|
||||
? `bun scripts/cli.ts gh pr diff ${number} --repo ${repo} --file ${shellQuote(file.filename)} --hunk ${options.hunk + 1}`
|
||||
: null;
|
||||
return {
|
||||
ok: true,
|
||||
command: "pr diff",
|
||||
repo,
|
||||
readOnly: true,
|
||||
rawDiffIncluded: includeFullPatch,
|
||||
pullRequest: prCompactSummary(fetched.pr),
|
||||
file: {
|
||||
...prFileSummary(file),
|
||||
patchAvailable: file.patch !== undefined,
|
||||
patchLines: patchLines.length,
|
||||
hunks: hunks.length,
|
||||
drillDown: reviewPlanFile(file, 1, repo, number).drillDown,
|
||||
},
|
||||
patch: {
|
||||
file: file.filename,
|
||||
hunk: selected.hunk === null ? null : {
|
||||
index: selected.hunk.index,
|
||||
header: selected.hunk.header,
|
||||
additions: selected.hunk.additions,
|
||||
deletions: selected.hunk.deletions,
|
||||
lines: selected.hunk.lines.length,
|
||||
},
|
||||
lines: redacted.lines,
|
||||
linesTotal: selected.lines.length,
|
||||
linesShown: redacted.lines.length,
|
||||
truncated: redacted.lines.length < selected.lines.length,
|
||||
limit: includeFullPatch ? null : options.limit,
|
||||
redactions: redacted.redactions,
|
||||
fullPatchIncluded: includeFullPatch,
|
||||
...(includeFullPatch ? { fullPatch: patch } : {}),
|
||||
},
|
||||
next: {
|
||||
reviewPlan: `bun scripts/cli.ts gh pr review-plan ${number} --repo ${repo}`,
|
||||
fullFilePatch: `bun scripts/cli.ts gh pr diff ${number} --repo ${repo} --file ${shellQuote(file.filename)} --full`,
|
||||
nextHunk,
|
||||
},
|
||||
request: {
|
||||
method: "GET",
|
||||
paths: githubPrFilePaths(repo, number),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function reviewPlanFile(file: GitHubPullRequestFile, index: number, repo: string, number: number): Record<string, unknown> {
|
||||
const patch = file.patch ?? "";
|
||||
const patchLines = splitPatchLines(patch);
|
||||
const hunks = parsePatchHunks(patch);
|
||||
const firstHunk = hunks[0];
|
||||
return {
|
||||
index,
|
||||
...prFileSummary(file),
|
||||
patch: {
|
||||
available: file.patch !== undefined,
|
||||
lines: patchLines.length,
|
||||
hunks: hunks.length,
|
||||
defaultLineLimit: DEFAULT_PR_PATCH_LINE_LIMIT,
|
||||
defaultTruncated: patchLines.length > DEFAULT_PR_PATCH_LINE_LIMIT,
|
||||
omittedReason: file.patch === undefined ? "github-rest-patch-unavailable-binary-or-large-file" : null,
|
||||
},
|
||||
drillDown: {
|
||||
patch: drillDownCommand(repo, number, file.filename),
|
||||
firstHunk: firstHunk === undefined ? null : `${drillDownCommand(repo, number, file.filename)} --hunk ${firstHunk.index}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchPrFiles(repo: string, token: string, number: number, limit: number, commandName: "pr review-plan" | "pr diff"): Promise<PrFilesFetch | GitHubCommandResult> {
|
||||
const { owner, name } = repoParts(repo);
|
||||
const boundedLimit = Math.max(1, Math.min(limit, MAX_PR_FILES_LIMIT));
|
||||
const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`);
|
||||
if (isGitHubError(pr)) return commandError(commandName, repo, pr, { number });
|
||||
const files: GitHubPullRequestFile[] = [];
|
||||
let page = 1;
|
||||
while (files.length < boundedLimit) {
|
||||
const remaining = boundedLimit - files.length;
|
||||
const pageSize = Math.min(100, remaining);
|
||||
const pageFiles = await githubRequest<GitHubPullRequestFile[]>(token, "GET", `/repos/${owner}/${name}/pulls/${number}/files?per_page=${pageSize}&page=${page}`);
|
||||
if (isGitHubError(pageFiles)) return commandError(commandName, repo, pageFiles, { number, phase: "fetch-pr-files", filesReturned: files.length });
|
||||
files.push(...pageFiles);
|
||||
if (pageFiles.length < pageSize || pageFiles.length === 0) break;
|
||||
page += 1;
|
||||
}
|
||||
const totalFiles = numberOrNull(pr.changed_files);
|
||||
return {
|
||||
pr,
|
||||
files,
|
||||
totalFiles,
|
||||
truncated: totalFiles !== null ? files.length < totalFiles : files.length >= boundedLimit,
|
||||
limit: boundedLimit,
|
||||
};
|
||||
}
|
||||
|
||||
function selectPatchLines(repo: string, hunks: PatchHunk[], patchLines: string[], hunkIndex: number | undefined): PatchLineSelection | GitHubCommandResult {
|
||||
if (hunkIndex === undefined) return { hunk: null, lines: patchLines };
|
||||
const hunk = hunks[hunkIndex - 1];
|
||||
if (hunk === undefined) {
|
||||
return validationError("pr diff", repo, `--hunk ${hunkIndex} is outside the available hunk range`, {
|
||||
requestedHunk: hunkIndex,
|
||||
availableHunks: hunks.length,
|
||||
});
|
||||
}
|
||||
return { hunk, lines: hunk.lines };
|
||||
}
|
||||
|
||||
function parsePatchHunks(patch: string): PatchHunk[] {
|
||||
const lines = splitPatchLines(patch);
|
||||
if (lines.length === 0) return [];
|
||||
const hunks: PatchHunk[] = [];
|
||||
let current: PatchHunk | null = null;
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("@@")) {
|
||||
if (current !== null) hunks.push(current);
|
||||
current = { index: hunks.length + 1, header: line, lines: [line], additions: 0, deletions: 0 };
|
||||
continue;
|
||||
}
|
||||
if (current === null) current = { index: 1, header: "(patch)", lines: [], additions: 0, deletions: 0 };
|
||||
current.lines.push(line);
|
||||
if (line.startsWith("+") && !line.startsWith("+++")) current.additions += 1;
|
||||
if (line.startsWith("-") && !line.startsWith("---")) current.deletions += 1;
|
||||
}
|
||||
if (current !== null) hunks.push(current);
|
||||
return hunks;
|
||||
}
|
||||
|
||||
function splitPatchLines(patch: string): string[] {
|
||||
if (patch.length === 0) return [];
|
||||
return patch.split(/\r?\n/u);
|
||||
}
|
||||
|
||||
function redactPatchLines(lines: string[]): RedactedPatchLines {
|
||||
let redactions = 0;
|
||||
const redacted = lines.map((line) => {
|
||||
if (!looksSecretLike(line)) return line;
|
||||
redactions += 1;
|
||||
const prefix = line.startsWith("+") || line.startsWith("-") || line.startsWith(" ") ? line[0] : "";
|
||||
return `${prefix}<redacted secret-like diff line>`;
|
||||
});
|
||||
return { lines: redacted, redactions };
|
||||
}
|
||||
|
||||
function looksSecretLike(line: string): boolean {
|
||||
if (/ghp_[A-Za-z0-9_]+|github_pat_[A-Za-z0-9_]+|sk-[A-Za-z0-9_-]+|xox[baprs]-[A-Za-z0-9-]+/u.test(line)) return true;
|
||||
if (!/(token|secret|api[_-]?key|password|passwd|authorization|bearer|database_url|private[_-]?key)/iu.test(line)) return false;
|
||||
return /[:=]|Bearer\s+\S+/iu.test(line);
|
||||
}
|
||||
|
||||
function drillDownCommand(repo: string, number: number, filePath: string): string {
|
||||
return `bun scripts/cli.ts gh pr diff ${number} --repo ${repo} --file ${shellQuote(filePath)}`;
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
if (/^[A-Za-z0-9_./:@%+=,-]+$/u.test(value)) return value;
|
||||
return "'" + value.replace(/'/gu, "'\\''") + "'";
|
||||
}
|
||||
|
||||
function githubPrFilePaths(repo: string, number: number): string[] {
|
||||
const { owner, name } = repoParts(repo);
|
||||
return [
|
||||
`/repos/${owner}/${name}/pulls/${number}`,
|
||||
`/repos/${owner}/${name}/pulls/${number}/files`,
|
||||
];
|
||||
}
|
||||
|
||||
function isGitHubCommandResult(value: unknown): value is GitHubCommandResult {
|
||||
return typeof value === "object" && value !== null && "ok" in value && "command" in value;
|
||||
}
|
||||
@@ -94,7 +94,7 @@ export const GH_VALUE_OPTIONS = new Set([
|
||||
"--value", "--section", "--to", "--status", "--row-file", "--category", "--branch",
|
||||
"--tasks", "--summary", "--focus", "--validation", "--progress", "--number", "--pr",
|
||||
"--search", "--title-prefix", "--inactive-hours", "--comment", "--comment-file", "--description",
|
||||
"--attachment", "--output",
|
||||
"--attachment", "--output", "--file", "--hunk",
|
||||
]);
|
||||
|
||||
export const GH_FLAG_OPTIONS = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body", "--body-stdin", "--body-patch-stdin", "--comment-stdin", "--raw", "--full", "--stat", "--merge", "--squash", "--rebase", "--delete-branch", "--private", "--public", "--auto-init"]);
|
||||
@@ -462,6 +462,8 @@ export interface GitHubOptions {
|
||||
deleteBranch: boolean;
|
||||
attachmentSelector?: string;
|
||||
outputPath?: string;
|
||||
filePath?: string;
|
||||
hunk?: number;
|
||||
}
|
||||
|
||||
export interface GitHubShorthandReference {
|
||||
|
||||
Reference in New Issue
Block a user