fix: bound github issue comment views (#915)
Co-authored-by: Codex <codex@noreply.local>
This commit is contained in:
@@ -363,6 +363,7 @@ export function issueBodyReadCommands(repo: string, issueNumber: number): Record
|
||||
export function issueCommentReadCommands(repo: string, issueNumber: number): Record<string, string> {
|
||||
return {
|
||||
comments: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --json comments`,
|
||||
comment: `bun scripts/cli.ts gh issue comment view <commentId> --repo ${repo} --full`,
|
||||
full: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --full`,
|
||||
raw: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --raw`,
|
||||
};
|
||||
|
||||
@@ -256,6 +256,34 @@ export async function commentUpdate(repo: string, token: string, ownerKind: "iss
|
||||
};
|
||||
}
|
||||
|
||||
export async function commentView(repo: string, token: string, ownerKind: "issue" | "pr", commentId: number, includeBody: boolean, commandName?: string): Promise<GitHubCommandResult> {
|
||||
const command = commandName ?? `${ownerKind} comment view`;
|
||||
const comment = await getIssueComment(token, repo, commentId);
|
||||
if (isGitHubError(comment)) return commandError(command, repo, comment, { commentId });
|
||||
const body = comment.body ?? "";
|
||||
return {
|
||||
ok: true,
|
||||
command,
|
||||
repo,
|
||||
commentId,
|
||||
comment: includeBody ? commentSummary(comment) : compactCommentSummary(comment),
|
||||
bodyChars: body.length,
|
||||
bodySha: bodySha(body),
|
||||
bodyOmitted: !includeBody,
|
||||
fullBodyIncluded: includeBody,
|
||||
readCommands: {
|
||||
compact: `bun scripts/cli.ts gh ${ownerKind} comment view ${commentId} --repo ${repo}`,
|
||||
full: `bun scripts/cli.ts gh ${ownerKind} comment view ${commentId} --repo ${repo} --full`,
|
||||
raw: `bun scripts/cli.ts gh ${ownerKind} comment view ${commentId} --repo ${repo} --raw`,
|
||||
},
|
||||
request: {
|
||||
method: "GET",
|
||||
path: `/repos/{owner}/{repo}/issues/comments/${commentId}`,
|
||||
},
|
||||
rest: true,
|
||||
};
|
||||
}
|
||||
|
||||
export async function commentDelete(repo: string, token: string, ownerKind: "issue" | "pr", commentId: number, dryRun: boolean): Promise<GitHubCommandResult> {
|
||||
const command = `${ownerKind} comment delete`;
|
||||
const { owner, name } = repoParts(repo);
|
||||
|
||||
@@ -23,6 +23,7 @@ export function ghHelp(): unknown {
|
||||
"bun scripts/cli.ts gh issue edit <number> (--body-stdin|--body-file <file|->) [--repo owner/name] [--number N compat] [--full|--raw] [compat alias for issue update --mode replace]",
|
||||
"bun scripts/cli.ts gh issue edit 24 --body-stdin --notify-claudeqq-brief-diff [--dry-run]",
|
||||
"bun scripts/cli.ts gh issue comment create <number> (--body-stdin|--body-file <file|->|--body <short-text>) [--repo owner/name] [--number N compat] [--dry-run]",
|
||||
"bun scripts/cli.ts gh issue comment view|read <commentId> [--repo owner/name] [--number N compat] [--full|--raw]",
|
||||
"bun scripts/cli.ts gh issue comment update <commentId> (--body-stdin|--body-file <file|->|--body <short-text>) [--repo owner/name] [--number N compat] [--dry-run]",
|
||||
"bun scripts/cli.ts gh issue comment patch <commentId> --body-patch-stdin [--repo owner/name] [--number N compat] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256]",
|
||||
"bun scripts/cli.ts gh issue comment edit <commentId> (--body-stdin|--body-file <file|->|--body <short-text>) [--repo owner/name] [--number N compat] [--dry-run] [compatibility alias for issue comment update]",
|
||||
@@ -52,6 +53,7 @@ export function ghHelp(): unknown {
|
||||
"bun scripts/cli.ts gh pr edit <number> [--title title] [--body-stdin|--body-file <file|->|--body <text>] [--repo owner/name] [--number N compat] [--dry-run]",
|
||||
"bun scripts/cli.ts gh pr update <number> --mode replace|append [--body-stdin|--body-file <file|->|--body <text>] [--title title] [--repo owner/name] [--number N compat] [--dry-run]",
|
||||
"bun scripts/cli.ts gh pr comment create <number> (--body-stdin|--body-file <file|->|--body <text>) [--repo owner/name] [--number N compat] [--dry-run]",
|
||||
"bun scripts/cli.ts gh pr comment view|read <commentId> [--repo owner/name] [--number N compat] [--full|--raw]",
|
||||
"bun scripts/cli.ts gh pr comment update <commentId> (--body-stdin|--body-file <file|->|--body <text>) [--repo owner/name] [--number N compat] [--dry-run]",
|
||||
"bun scripts/cli.ts gh pr comment edit <commentId> (--body-stdin|--body-file <file|->|--body <text>) [--repo owner/name] [--number N compat] [--dry-run] [compatibility alias for pr comment update]",
|
||||
"bun scripts/cli.ts gh pr comment delete <commentId> [--repo owner/name] [--number N compat] [--dry-run]",
|
||||
@@ -67,9 +69,9 @@ export function ghHelp(): unknown {
|
||||
"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.",
|
||||
"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 delete treats --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 body is included only when requested with --json body or when --json is omitted and --full/--raw requests all fields. Unsupported fields fail structurally.",
|
||||
"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 and gh pr list/read/view. For read/view commands, an explicit --json field list narrows the disclosure and prevents accidental full body/comment expansion. 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 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.",
|
||||
"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.",
|
||||
@@ -78,7 +80,7 @@ export function ghHelp(): unknown {
|
||||
"issue update accepts --body-stdin or --body-file <file|->, refuses literal null, blank, and too-short bodies by default. Use --allow-short-body only for intentional short writes; #20 requires its board heading, warns when HWLAB product/user issue routing appears in favor of pikasTech/HWLAB, and still rejects commander brief update sections; commander-brief requires its stable heading on legacy #24 plus daily rolling brief issues titled YYYY-MM-DD 指挥简报(北京时间).",
|
||||
"issue update dry-run reports bounded bodyPreview/bodyPreviewLines, old/new body length slots, body SHA, required heading checks, literal \\n detection, shell-pollution signals, guard/concurrency summary, wouldPatch, and readCommands without printing an unbounded full body. Non-dry-run automatically reads current issue metadata before PATCH and returns oldBodySha/updatedAt; --expect-updated-at or --expect-body-sha remain available for explicit stale-cache protection.",
|
||||
"issue patch reads the current GitHub issue body, applies a Codex apply_patch envelope against virtual file issue.md from --body-patch-stdin or --body-patch-file <file|->, then runs the same issue body guard before PATCH. It returns old/new bodySha, updatedAt, patch summary, and bounded previews; context mismatch fails with redacted diagnostics and no GitHub write.",
|
||||
"issue comment create/update/edit accept --body-stdin or --body-file <file|-> for Markdown/generated content and --body only for short single-line text. Blank, multiline, shell-polluted, secret-like, and overlong inline bodies fail structurally. Use comment update/edit to correct existing wording in place; delete remains for intentional removal.",
|
||||
"issue comment view/read reads one comment by commentId. Default output is compact metadata plus bodyChars/bodySha/preview; --full includes that one full comment body. issue comment create/update/edit accept --body-stdin or --body-file <file|-> for Markdown/generated content and --body only for short single-line text. Blank, multiline, shell-polluted, secret-like, and overlong inline bodies fail structurally. Use comment update/edit to correct existing wording in place; delete remains for intentional removal.",
|
||||
"issue comment patch reads the current issue comment by commentId, applies a Codex apply_patch envelope against virtual file comment.md, then PATCHes only that comment. It returns comment id, old/new bodySha, updatedAt, patch summary, and redacted mismatch diagnostics without echoing the full comment body.",
|
||||
"issue close/reopen default success output is compact and omits full issue.body. Optional --comment <short-text>, --comment-stdin, or --comment-file <file|-> posts a bounded lifecycle comment before the state change and aborts the state change if the comment POST fails. --comment-stdin is the first-class heredoc path for generated Markdown closeout evidence; --comment remains the short inline form. Use gh issue view <number> --json body or --full/--raw on view when full text is needed.",
|
||||
"issue stale-close is the reusable lifecycle cleanup path for policies such as closing open issues inactive for more than 48 hours. It selects open issues by GitHub updatedAt older than observedAt - --inactive-hours, treats comments and state changes as activity, filters pull requests, supports --dry-run, and returns bounded candidate/closed/failure summaries without echoing full bodies.",
|
||||
@@ -150,12 +152,14 @@ export function ghScopedHelpNotes(tokens: string[]): string[] {
|
||||
];
|
||||
if (key === "issue comment" || key.startsWith("issue comment ")) {
|
||||
notes.push("Issue comments use `--body-stdin` or `--body-file <file|->` for Markdown bodies; inline `--body` is only for short single-line comments.");
|
||||
notes.push("Use `issue comment view <commentId> --full` to read one full comment body; issue-level `--json comments --full` keeps the comment list bounded.");
|
||||
notes.push("Use `issue comment update/edit` for wording fixes, `issue comment patch` for apply_patch-style local edits, and `issue comment delete` only for intentional removal.");
|
||||
} else if (key === "issue close" || key === "issue reopen") {
|
||||
notes.push("Issue close/reopen can post a lifecycle comment with `--comment`, `--comment-stdin`, or `--comment-file <file|->` before changing state.");
|
||||
notes.push("For long closeout evidence, prefer `--comment-stdin` with a quoted heredoc.");
|
||||
} 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 <commentId> --full` to read one full comment body by id.");
|
||||
} 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.");
|
||||
|
||||
+28
-5
@@ -7,7 +7,7 @@ import { authStatus, prFiles, prList, prRead, prView } from "./auth-pr-read";
|
||||
import { issueBoardAudit } from "./board-audit";
|
||||
import { issueBoardRowAdd, issueBoardRowDelete, issueBoardRowGet, issueBoardRowList, issueBoardRowMove, issueBoardRowUpdate, issueBoardRowUpsert } from "./board-commands";
|
||||
import { authRequired, unsupportedCommand, validationError } from "./client";
|
||||
import { commentDelete, commentPatch, commentUpdate, issueComment, issueState } from "./comments-and-state";
|
||||
import { commentDelete, commentPatch, commentUpdate, commentView, issueComment, issueState } from "./comments-and-state";
|
||||
import { issueScanEscape } from "./escape-scan";
|
||||
import { ghHelp, ghScopedHelp } from "./help";
|
||||
import { issueList } from "./issue-list";
|
||||
@@ -53,13 +53,16 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
|
||||
return validationError(command, options.repo, "--json field selection is only supported by gh issue read/view/list and gh pr read/view/list");
|
||||
}
|
||||
}
|
||||
if ((optionWasProvided(args, "--raw") || optionWasProvided(args, "--full")) && !((top === "issue" && (isIssueReadCommand(sub) || sub === "list" || sub === "update" || sub === "edit" || sub === "patch")) || top === "preflight" || (top === "pr" && (isPrReadCommand(sub) || sub === "list" || sub === "preflight" || sub === "closeout")))) {
|
||||
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 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, gh pr list/read/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, and gh pr preflight/closeout.", {
|
||||
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",
|
||||
"bun scripts/cli.ts gh issue view <number> --repo owner/name --json body,title,state,comments",
|
||||
"bun scripts/cli.ts gh issue comment view <commentId> --repo owner/name --full",
|
||||
"bun scripts/cli.ts gh issue update <number> --repo owner/name --body-stdin --full <<'EOF'\n<reviewed body>\nEOF",
|
||||
"bun scripts/cli.ts gh issue patch <number> --repo owner/name --body-patch-stdin --full <<'PATCH'\n*** Begin Patch\n*** Update File: issue.md\n@@\n-old\n+new\n*** End Patch\nPATCH",
|
||||
"bun scripts/cli.ts gh pr list --repo owner/name --limit 100 --full",
|
||||
@@ -226,6 +229,17 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
|
||||
if (action === "list") return withNumberOptionHint(issueAttachmentList(resolved.repo, token, resolved.number), resolved);
|
||||
return withNumberOptionHint(issueAttachmentDownload(resolved.repo, token, resolved.number, { ...options, repo: resolved.repo }), resolved);
|
||||
}
|
||||
if (sub === "comment" && (third === "view" || third === "read")) {
|
||||
const commandName = `issue comment ${third}`;
|
||||
const resolved = resolvePositionalNumberReference("issue", args, 3, commandName, options);
|
||||
if (isGitHubCommandResult(resolved)) return resolved;
|
||||
const commentId = resolved.number;
|
||||
if (typeof commentId !== "number") return commentId;
|
||||
const { token, probe } = resolveToken(true);
|
||||
const missing = authRequired(resolved.repo, commandName, probe);
|
||||
if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, commandName, { present: false, source: null, ghFallbackAttempted: true });
|
||||
return withNumberOptionHint(commentView(resolved.repo, token, "issue", commentId, options.raw || options.full, commandName), resolved);
|
||||
}
|
||||
if (sub === "comment" && third === "delete") {
|
||||
const resolved = resolvePositionalNumberReference("issue", args, 3, "issue comment delete", options);
|
||||
if (isGitHubCommandResult(resolved)) return resolved;
|
||||
@@ -354,8 +368,8 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
|
||||
const missing = authRequired(resolved.repo, `issue ${sub}`, probe);
|
||||
if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, `issue ${sub}`, { present: false, source: null, ghFallbackAttempted: true });
|
||||
const disclosure = readDisclosureOptions(options, resolved.shorthand);
|
||||
if (sub === "read") return withNumberOptionHint(issueRead(resolved.repo, token, resolved.number, issueReadJsonFields(options), "issue read", disclosure, { includeFullCommentBodies: options.raw || options.full }), resolved);
|
||||
return withNumberOptionHint(issueView(resolved.repo, token, resolved.number, issueReadJsonFields(options), disclosure, { includeFullCommentBodies: options.raw || options.full }), resolved);
|
||||
if (sub === "read") return withNumberOptionHint(issueRead(resolved.repo, token, resolved.number, issueReadJsonFields(options), "issue read", disclosure, { includeFullCommentBodies: options.raw }), resolved);
|
||||
return withNumberOptionHint(issueView(resolved.repo, token, resolved.number, issueReadJsonFields(options), disclosure, { includeFullCommentBodies: options.raw }), resolved);
|
||||
}
|
||||
const { token, probe } = resolveToken(true);
|
||||
const missing = authRequired(options.repo, `issue ${sub ?? ""}`.trim(), probe);
|
||||
@@ -432,6 +446,15 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
|
||||
if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, "pr comment delete", { present: false, source: null, ghFallbackAttempted: true });
|
||||
return withNumberOptionHint(commentDelete(resolved.repo, token, "pr", resolved.number, false), resolved);
|
||||
}
|
||||
if (sub === "comment" && (third === "view" || third === "read")) {
|
||||
const commandName = `pr comment ${third}`;
|
||||
const resolved = resolvePositionalNumberReference("pr", args, 3, commandName, options);
|
||||
if (isGitHubCommandResult(resolved)) return resolved;
|
||||
const { token, probe } = resolveToken(true);
|
||||
const missing = authRequired(resolved.repo, commandName, probe);
|
||||
if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, commandName, { present: false, source: null, ghFallbackAttempted: true });
|
||||
return withNumberOptionHint(commentView(resolved.repo, token, "pr", resolved.number, options.raw || options.full, commandName), resolved);
|
||||
}
|
||||
if (sub === "comment" && (third === "update" || third === "edit")) {
|
||||
const commandName = `pr comment ${third}`;
|
||||
const resolved = resolvePositionalNumberReference("pr", args, 3, commandName, options);
|
||||
|
||||
@@ -5,13 +5,18 @@ import { bodySha, preview, previewLines } from "./auth-and-safety";
|
||||
import type { GitHubComment } from "./types";
|
||||
|
||||
export function commentSummary(comment: GitHubComment): Record<string, unknown> {
|
||||
const body = comment.body ?? "";
|
||||
return {
|
||||
id: comment.id,
|
||||
body: comment.body ?? "",
|
||||
body,
|
||||
url: comment.html_url,
|
||||
author: comment.user?.login ?? null,
|
||||
createdAt: comment.created_at ?? null,
|
||||
updatedAt: comment.updated_at ?? null,
|
||||
bodyChars: body.length,
|
||||
bodySha: bodySha(body),
|
||||
bodyOmitted: false,
|
||||
fullBodyIncluded: true,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user