feat: add safe gh pr create/comment

This commit is contained in:
Codex
2026-05-20 09:56:21 +00:00
parent 4fdf31a67e
commit 2bd1b0a1d6
7 changed files with 619 additions and 84 deletions
+499 -77
View File
@@ -5,23 +5,33 @@ const DEFAULT_REPO = "pikasTech/unidesk";
const GITHUB_API = "https://api.github.com";
const USER_AGENT = "unidesk-cli-gh";
const PREVIEW_CHARS = 240;
const REQUEST_TIMEOUT_MS = 20_000;
type GitHubDegradedReason =
| "missing-binary"
| "missing-token"
| "auth-failed"
| "egress-failed"
| "network-proxy-failed"
| "permission-denied"
| "repo-not-found"
| "repo-forbidden"
| "issue-not-found"
| "pr-not-found"
| "scope-insufficient"
| "validation-failed"
| "invalid-response"
| "unsupported-command";
type RunnerDisposition = "infra-blocked" | "business-failed";
interface GitHubCommandResult {
ok: boolean;
repo: string;
command: string;
degradedReason?: GitHubDegradedReason;
degraded?: GitHubDegradedReason[];
runnerDisposition?: RunnerDisposition;
[key: string]: unknown;
}
@@ -29,22 +39,37 @@ interface GitHubTokenProbe {
present: boolean;
source: "GH_TOKEN" | "GITHUB_TOKEN" | "gh-auth-token" | null;
ghFallbackAttempted: boolean;
ghBinaryFound?: boolean;
ghAuthTokenAvailable?: boolean | null;
}
interface GitHubOptions {
repo: string;
dryRun: boolean;
limit: number;
draft: boolean;
title?: string;
body?: string;
bodyFile?: string;
base?: string;
head?: string;
}
interface GitHubErrorPayload {
ok: false;
degradedReason: GitHubDegradedReason;
runnerDisposition: RunnerDisposition;
status?: number;
message: string;
details?: unknown;
scopes?: {
accepted: string | null;
token: string | null;
};
request?: {
method: string;
path: string;
};
}
interface GitHubIssue {
@@ -84,6 +109,29 @@ interface GitHubPullRequest {
updated_at?: string;
}
interface GitHubRepository {
id?: number;
full_name?: string;
private?: boolean;
default_branch?: string;
permissions?: Record<string, boolean>;
}
interface GitHubBranch {
name?: string;
commit?: { sha?: string };
}
interface GitHubCompare {
status?: string;
ahead_by?: number;
behind_by?: number;
total_commits?: number;
html_url?: string;
base_commit?: { sha?: string };
merge_base_commit?: { sha?: string };
}
function optionValue(args: string[], name: string): string | undefined {
const index = args.indexOf(name);
if (index === -1) return undefined;
@@ -104,13 +152,33 @@ function positiveIntegerOption(args: string[], name: string, defaultValue: numbe
return Math.min(value, maxValue);
}
function validateKnownOptions(args: string[]): void {
const valueOptions = new Set(["--repo", "--limit", "--title", "--body-file", "--body", "--base", "--head"]);
const flagOptions = new Set(["--dry-run", "--draft"]);
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (!arg.startsWith("--")) continue;
if (flagOptions.has(arg)) continue;
if (valueOptions.has(arg)) {
index += 1;
continue;
}
throw new Error(`unknown gh option: ${arg}`);
}
}
function parseOptions(args: string[]): GitHubOptions {
validateKnownOptions(args);
return {
repo: optionValue(args, "--repo") ?? DEFAULT_REPO,
dryRun: hasFlag(args, "--dry-run"),
limit: positiveIntegerOption(args, "--limit", 30, 100),
draft: hasFlag(args, "--draft"),
title: optionValue(args, "--title"),
body: optionValue(args, "--body"),
bodyFile: optionValue(args, "--body-file"),
base: optionValue(args, "--base"),
head: optionValue(args, "--head"),
};
}
@@ -121,12 +189,38 @@ function parseNumber(raw: string | undefined, label: string): number {
return value;
}
function parseNumberForCommand(repo: string, raw: string | undefined, label: string): number | GitHubCommandResult {
try {
return parseNumber(raw, label);
} catch (error) {
return validationError(label, repo, error instanceof Error ? error.message : String(error));
}
}
function readBodyFile(path: string | undefined, command: string): string {
if (path === undefined) throw new Error(`${command} requires --body-file <file>`);
if (!existsSync(path)) throw new Error(`body file not found: ${path}`);
return readFileSync(path, "utf8");
}
function readMarkdownBody(options: GitHubOptions, command: string): { body: string; bodySource: Record<string, unknown> } {
if (options.bodyFile !== undefined && options.body !== undefined) throw new Error(`${command} accepts only one body source: --body-file or --body`);
if (options.bodyFile !== undefined) {
const body = readBodyFile(options.bodyFile, command);
return { body, bodySource: { kind: "body-file", path: options.bodyFile } };
}
if (options.body !== undefined) {
return {
body: options.body,
bodySource: {
kind: "inline",
warning: options.body.includes("\n") ? "inline body contains real newlines; --body-file is safer for generated Markdown" : "inline body is intended only for short single-line text",
},
};
}
throw new Error(`${command} requires --body-file <file> or --body <text>`);
}
function tokenFromEnvironment(): GitHubTokenProbe {
if (process.env.GH_TOKEN && process.env.GH_TOKEN.length > 0) {
return { present: true, source: "GH_TOKEN", ghFallbackAttempted: false };
@@ -162,9 +256,11 @@ function resolveToken(allowGhFallback: boolean): { token: string | null; probe:
return { token, probe: envProbe };
}
if (!allowGhFallback) return { token: null, probe: envProbe };
const ghPath = ghBinaryPath();
if (ghPath === null) return { token: null, probe: { present: false, source: null, ghFallbackAttempted: true, ghBinaryFound: false, ghAuthTokenAvailable: null } };
const token = ghAuthToken();
if (token !== null) return { token, probe: { present: true, source: "gh-auth-token", ghFallbackAttempted: true } };
return { token: null, probe: { present: false, source: null, ghFallbackAttempted: true } };
if (token !== null) return { token, probe: { present: true, source: "gh-auth-token", ghFallbackAttempted: true, ghBinaryFound: true, ghAuthTokenAvailable: true } };
return { token: null, probe: { present: false, source: null, ghFallbackAttempted: true, ghBinaryFound: true, ghAuthTokenAvailable: false } };
}
function repoParts(repo: string): { owner: string; name: string } {
@@ -191,6 +287,55 @@ function dryRunBody(repo: string, title: string | undefined, body: string): Reco
};
}
function runnerDisposition(reason: GitHubDegradedReason): RunnerDisposition {
if (reason === "unsupported-command" || reason === "validation-failed" || reason === "issue-not-found" || reason === "pr-not-found") return "business-failed";
return "infra-blocked";
}
function sanitizedErrorDetails(parsed: unknown): unknown {
if (typeof parsed !== "object" || parsed === null) return parsed;
const source = parsed as Record<string, unknown>;
const details: Record<string, unknown> = {};
if ("message" in source) details.message = source.message;
if ("documentation_url" in source) details.documentationUrl = source.documentation_url;
if ("status" in source) details.status = source.status;
if (Array.isArray(source.errors)) details.errors = source.errors.slice(0, 5);
if ("raw" in source) details.raw = source.raw;
return details;
}
function errorPayload(reason: GitHubDegradedReason, message: string, extra: Omit<GitHubErrorPayload, "ok" | "degradedReason" | "runnerDisposition" | "message"> = {}): GitHubErrorPayload {
return {
ok: false,
degradedReason: reason,
runnerDisposition: runnerDisposition(reason),
message,
...extra,
};
}
function commandError(command: string, repo: string, error: GitHubErrorPayload, extra: Record<string, unknown> = {}): GitHubCommandResult {
return {
ok: false,
command,
repo,
degradedReason: error.degradedReason,
runnerDisposition: error.runnerDisposition,
details: error,
...extra,
};
}
function validationError(command: string, repo: string, message: string, extra: Record<string, unknown> = {}): GitHubCommandResult {
const error = errorPayload("validation-failed", message, { details: extra });
return commandError(command, repo, error, extra);
}
function unsupportedCommand(command: string, repo: string, message: string, extra: Record<string, unknown> = {}): GitHubCommandResult {
const error = errorPayload("unsupported-command", message, { details: extra });
return commandError(command, repo, error, { message, ...extra });
}
async function parseGitHubResponse(response: Response): Promise<unknown> {
const text = await response.text();
if (text.length === 0) return null;
@@ -201,9 +346,22 @@ async function parseGitHubResponse(response: Response): Promise<unknown> {
}
}
function classifyHttpStatus(status: number, message: string, path: string): GitHubDegradedReason {
if (status === 401 || status === 403) return "permission-denied";
if (status === 404) return path.includes("/issues/") || message.toLowerCase().includes("issue") ? "issue-not-found" : "repo-not-found";
function classifyHttpStatus(status: number, message: string, path: string, response: Response): GitHubDegradedReason {
const lower = message.toLowerCase();
const acceptedScopes = response.headers.get("x-accepted-oauth-scopes");
if (status === 401) return "auth-failed";
if (status === 403) {
if (lower.includes("resource not accessible") || lower.includes("insufficient") || lower.includes("scope") || (acceptedScopes !== null && acceptedScopes.length > 0)) return "scope-insufficient";
if (path.startsWith("/repos/")) return "repo-forbidden";
return "permission-denied";
}
if (status === 404) {
if (/\/pulls\/\d+/u.test(path)) return "pr-not-found";
if (/\/issues\/\d+/u.test(path)) return "issue-not-found";
if (path.includes("/branches/") || path.includes("/compare/")) return "validation-failed";
return "repo-not-found";
}
if (status === 422) return "validation-failed";
return "invalid-response";
}
@@ -214,9 +372,12 @@ async function githubRequest<T>(
body?: Record<string, unknown>,
): Promise<T | GitHubErrorPayload> {
let response: Response;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
response = await fetch(`${GITHUB_API}${path}`, {
method,
signal: controller.signal,
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
@@ -227,35 +388,38 @@ async function githubRequest<T>(
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch (error) {
return {
ok: false,
degradedReason: "egress-failed",
message: error instanceof Error ? error.message : String(error),
};
return errorPayload("network-proxy-failed", error instanceof Error ? error.message : String(error), { request: { method, path } });
} finally {
clearTimeout(timeout);
}
const parsed = await parseGitHubResponse(response);
if (!response.ok) {
const message = typeof parsed === "object" && parsed !== null && "message" in parsed ? String((parsed as { message?: unknown }).message) : response.statusText;
return {
ok: false,
degradedReason: classifyHttpStatus(response.status, message, path),
return errorPayload(classifyHttpStatus(response.status, message, path, response), message, {
status: response.status,
message,
details: parsed,
};
details: sanitizedErrorDetails(parsed),
scopes: {
accepted: response.headers.get("x-accepted-oauth-scopes"),
token: response.headers.get("x-oauth-scopes"),
},
request: { method, path },
});
}
return parsed as T;
}
function authRequired(repo: string, command: string, tokenProbe: GitHubTokenProbe): GitHubCommandResult | null {
if (!tokenProbe.present) {
return {
ok: false,
command,
repo,
degradedReason: "missing-token",
if (tokenProbe.ghFallbackAttempted && tokenProbe.ghBinaryFound === false) {
return commandError(command, repo, errorPayload("missing-binary", "gh binary is missing and no GH_TOKEN/GITHUB_TOKEN is available", { details: tokenProbe }));
}
if (tokenProbe.ghFallbackAttempted && tokenProbe.ghBinaryFound === true && tokenProbe.ghAuthTokenAvailable === false) {
return commandError(command, repo, errorPayload("auth-failed", "gh auth token failed and no GH_TOKEN/GITHUB_TOKEN is available", { details: tokenProbe }));
}
return commandError(command, repo, errorPayload("missing-token", "GH_TOKEN or GITHUB_TOKEN is required", { details: tokenProbe }), {
degraded: ["missing-token"],
token: tokenProbe,
};
});
}
return null;
}
@@ -306,6 +470,238 @@ function prSummary(pr: GitHubPullRequest): Record<string, unknown> {
};
}
function repoSummary(repo: GitHubRepository): Record<string, unknown> {
return {
id: repo.id ?? null,
fullName: repo.full_name ?? null,
private: repo.private ?? null,
defaultBranch: repo.default_branch ?? null,
permissions: repo.permissions ?? null,
};
}
function branchSummary(branch: GitHubBranch): Record<string, unknown> {
return {
name: branch.name ?? null,
sha: branch.commit?.sha ?? null,
};
}
function compareSummary(compare: GitHubCompare): Record<string, unknown> {
return {
status: compare.status ?? null,
aheadBy: compare.ahead_by ?? null,
behindBy: compare.behind_by ?? null,
totalCommits: compare.total_commits ?? null,
htmlUrl: compare.html_url ?? null,
baseSha: compare.base_commit?.sha ?? null,
mergeBaseSha: compare.merge_base_commit?.sha ?? null,
};
}
function encodePathPart(value: string): string {
return encodeURIComponent(value);
}
async function repoInfo(token: string, repo: string): Promise<GitHubRepository | GitHubErrorPayload> {
const { owner, name } = repoParts(repo);
return githubRequest<GitHubRepository>(token, "GET", `/repos/${owner}/${name}`);
}
async function branchInfo(token: string, repo: string, branch: string): Promise<GitHubBranch | GitHubErrorPayload> {
const { owner, name } = repoParts(repo);
return githubRequest<GitHubBranch>(token, "GET", `/repos/${owner}/${name}/branches/${encodePathPart(branch)}`);
}
async function compareBranches(token: string, repo: string, base: string, head: string): Promise<GitHubCompare | GitHubErrorPayload> {
const { owner, name } = repoParts(repo);
return githubRequest<GitHubCompare>(token, "GET", `/repos/${owner}/${name}/compare/${encodePathPart(base)}...${encodePathPart(head)}`);
}
function prCreatePlannedOperation(repo: string, options: GitHubOptions, body: string, bodySource: Record<string, unknown>): Record<string, unknown> {
return {
repo,
title: options.title,
base: options.base,
head: options.head,
draft: options.draft,
bodyChars: body.length,
bodyPreview: preview(body),
bodyPreviewLines: body.split(/\r?\n/).slice(0, 12),
bodySource,
preservesRawNewlines: body.includes("\n"),
containsLiteralBackslashN: body.includes("\\n"),
containsBackticks: body.includes("`"),
request: {
method: "POST",
path: "/repos/{owner}/{repo}/pulls",
body: {
title: options.title,
base: options.base,
head: options.head,
draft: options.draft,
bodyChars: body.length,
},
},
validation: {
repo: "required",
base: "required",
head: "required",
title: "required",
body: body.length > 0 ? "optional" : "empty",
},
};
}
function prCommentPlannedOperation(repo: string, issueNumber: number, body: string, bodySource: Record<string, unknown>): Record<string, unknown> {
return {
repo,
issueNumber,
bodyChars: body.length,
bodyPreview: preview(body),
bodyPreviewLines: body.split(/\r?\n/).slice(0, 12),
bodySource,
preservesRawNewlines: body.includes("\n"),
containsLiteralBackslashN: body.includes("\\n"),
containsBackticks: body.includes("`"),
request: {
method: "POST",
path: `/repos/{owner}/{repo}/issues/${issueNumber}/comments`,
body: { bodyChars: body.length },
},
validation: {
repo: "required",
issueNumber: "required",
body: "required",
},
};
}
async function prCreate(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> {
if (options.title === undefined) return validationError("pr create", repo, "pr create requires --title <title>");
if (options.base === undefined) return validationError("pr create", repo, "pr create requires --base <branch>");
if (options.head === undefined) return validationError("pr create", repo, "pr create requires --head <branch>");
let bodySource: { body: string; bodySource: Record<string, unknown> };
try {
bodySource = readMarkdownBody(options, "pr create");
} catch (error) {
return validationError("pr create", repo, error instanceof Error ? error.message : String(error));
}
const body = bodySource.body;
const planned = prCreatePlannedOperation(repo, options, body, bodySource.bodySource);
if (options.dryRun) {
return {
ok: true,
command: "pr create",
repo,
dryRun: true,
planned: true,
draft: options.draft,
...planned,
};
}
const repoResult = await repoInfo(token, repo);
if (isGitHubError(repoResult)) return commandError("pr create", repo, repoResult, { planned });
const baseBranch = await branchInfo(token, repo, options.base);
if (isGitHubError(baseBranch)) return commandError("pr create", repo, baseBranch, { base: options.base, planned });
const headBranch = await branchInfo(token, repo, options.head);
if (isGitHubError(headBranch)) return commandError("pr create", repo, headBranch, { head: options.head, planned });
const compare = await compareBranches(token, repo, options.base, options.head);
if (isGitHubError(compare)) return commandError("pr create", repo, compare, { base: options.base, head: options.head, planned });
const aheadBy = typeof compare.ahead_by === "number" ? compare.ahead_by : null;
if (aheadBy === null || aheadBy <= 0) {
return validationError("pr create", repo, "head branch must be ahead of base branch before creating a PR", {
base: options.base,
head: options.head,
compare: compareSummary(compare),
});
}
const { owner, name } = repoParts(repo);
const payload: Record<string, unknown> = {
title: options.title,
base: options.base,
head: options.head,
draft: options.draft,
body,
};
const pr = await githubRequest<GitHubPullRequest>(token, "POST", `/repos/${owner}/${name}/pulls`, payload);
if (isGitHubError(pr)) return commandError("pr create", repo, pr, { base: options.base, head: options.head, planned });
return {
ok: true,
command: "pr create",
repo,
pr: prSummary(pr),
validation: {
repo: repoSummary(repoResult),
base: branchSummary(baseBranch),
head: branchSummary(headBranch),
compare: compareSummary(compare),
bodySource: bodySource.bodySource,
},
request: {
method: "POST",
path: `/repos/${owner}/${name}/pulls`,
title: options.title,
base: options.base,
head: options.head,
draft: options.draft,
bodyChars: body.length,
},
rest: true,
};
}
async function prComment(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
let bodySource: { body: string; bodySource: Record<string, unknown> };
try {
bodySource = readMarkdownBody(options, "pr comment");
} catch (error) {
return validationError("pr comment", repo, error instanceof Error ? error.message : String(error), { issueNumber });
}
const body = bodySource.body;
const planned = prCommentPlannedOperation(repo, issueNumber, body, bodySource.bodySource);
if (options.dryRun) {
return {
ok: true,
command: "pr comment",
repo,
dryRun: true,
planned: true,
issueNumber,
...planned,
};
}
const repoResult = await repoInfo(token, repo);
if (isGitHubError(repoResult)) return commandError("pr comment", repo, repoResult, { issueNumber, planned });
const { owner, name } = repoParts(repo);
const prResult = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${issueNumber}`);
if (isGitHubError(prResult)) return commandError("pr comment", repo, prResult, { issueNumber, planned });
const comment = await githubRequest<GitHubComment>(token, "POST", `/repos/${owner}/${name}/issues/${issueNumber}/comments`, { body });
if (isGitHubError(comment)) return commandError("pr comment", repo, comment, { issueNumber, planned });
return {
ok: true,
command: "pr comment",
repo,
issueNumber,
pr: prSummary(prResult),
comment: commentSummary(comment),
request: {
method: "POST",
path: `/repos/${owner}/${name}/issues/${issueNumber}/comments`,
bodyChars: body.length,
},
validation: {
repo: repoSummary(repoResult),
pr: prSummary(prResult),
bodySource: bodySource.bodySource,
},
rest: true,
};
}
async function listIssueComments(token: string, repo: string, issueNumber: number): Promise<GitHubComment[] | GitHubErrorPayload> {
const { owner, name } = repoParts(repo);
return githubRequest<GitHubComment[]>(token, "GET", `/repos/${owner}/${name}/issues/${issueNumber}/comments?per_page=100`);
@@ -314,9 +710,9 @@ async function listIssueComments(token: string, repo: string, issueNumber: numbe
async function issueView(repo: string, token: string, issueNumber: number): Promise<GitHubCommandResult> {
const { owner, name } = repoParts(repo);
const issue = await githubRequest<GitHubIssue>(token, "GET", `/repos/${owner}/${name}/issues/${issueNumber}`);
if (isGitHubError(issue)) return { ok: false, command: "issue view", repo, degradedReason: issue.degradedReason, details: issue };
if (isGitHubError(issue)) return commandError("issue view", repo, issue, { issueNumber });
const comments = await listIssueComments(token, repo, issueNumber);
if (isGitHubError(comments)) return { ok: false, command: "issue view", repo, degradedReason: comments.degradedReason, issue: issueSummary(issue), details: comments };
if (isGitHubError(comments)) return commandError("issue view", repo, comments, { issueNumber, issue: issueSummary(issue) });
return {
ok: true,
command: "issue view",
@@ -332,7 +728,7 @@ async function issueCreate(repo: string, token: string, options: GitHubOptions):
if (options.dryRun) return { ok: true, command: "issue create", repo, dryRun: true, ...dryRunBody(repo, options.title, body) };
const { owner, name } = repoParts(repo);
const issue = await githubRequest<GitHubIssue>(token, "POST", `/repos/${owner}/${name}/issues`, { title: options.title, body });
if (isGitHubError(issue)) return { ok: false, command: "issue create", repo, degradedReason: issue.degradedReason, details: issue };
if (isGitHubError(issue)) return commandError("issue create", repo, issue);
return { ok: true, command: "issue create", repo, issue: issueSummary(issue), rest: true };
}
@@ -353,7 +749,7 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio
const payload: Record<string, unknown> = { body };
if (options.title !== undefined) payload.title = options.title;
const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, payload);
if (isGitHubError(issue)) return { ok: false, command: "issue edit", repo, degradedReason: issue.degradedReason, details: issue };
if (isGitHubError(issue)) return commandError("issue edit", repo, issue, { issueNumber });
return { ok: true, command: "issue edit", repo, issue: issueSummary(issue), rest: true };
}
@@ -362,7 +758,7 @@ async function issueComment(repo: string, token: string, issueNumber: number, op
if (options.dryRun) return { ok: true, command: "issue comment", repo, dryRun: true, issueNumber, ...dryRunBody(repo, undefined, body) };
const { owner, name } = repoParts(repo);
const comment = await githubRequest<GitHubComment>(token, "POST", `/repos/${owner}/${name}/issues/${issueNumber}/comments`, { body });
if (isGitHubError(comment)) return { ok: false, command: "issue comment", repo, degradedReason: comment.degradedReason, details: comment };
if (isGitHubError(comment)) return commandError("issue comment", repo, comment, { issueNumber });
return { ok: true, command: "issue comment", repo, comment: commentSummary(comment), rest: true };
}
@@ -370,7 +766,7 @@ async function issueState(repo: string, token: string, issueNumber: number, stat
if (dryRun) return { ok: true, command: state === "closed" ? "issue close" : "issue reopen", dryRun: true, repo, issueNumber, wouldPatch: { state } };
const { owner, name } = repoParts(repo);
const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, { state });
if (isGitHubError(issue)) return { ok: false, command: state === "closed" ? "issue close" : "issue reopen", repo, degradedReason: issue.degradedReason, details: issue };
if (isGitHubError(issue)) return commandError(state === "closed" ? "issue close" : "issue reopen", repo, issue, { issueNumber });
return { ok: true, command: state === "closed" ? "issue close" : "issue reopen", repo, issue: issueSummary(issue), rest: true };
}
@@ -397,7 +793,7 @@ function scanText(text: string, patterns: Array<{ kind: string; pattern: RegExp
async function issueScanEscape(repo: string, token: string, limit: number): Promise<GitHubCommandResult> {
const { owner, name } = repoParts(repo);
const issues = await githubRequest<GitHubIssue[]>(token, "GET", `/repos/${owner}/${name}/issues?state=all&per_page=${limit}`);
if (isGitHubError(issues)) return { ok: false, command: "issue scan-escape", repo, degradedReason: issues.degradedReason, details: issues };
if (isGitHubError(issues)) return commandError("issue scan-escape", repo, issues);
const patterns = [
{ kind: "literal-backslash-n", pattern: /\\n/g },
@@ -413,7 +809,14 @@ async function issueScanEscape(repo: string, token: string, limit: number): Prom
}
const comments = await listIssueComments(token, repo, issue.number);
if (isGitHubError(comments)) {
findings.push({ type: "comment-scan-error", issueNumber: issue.number, url: issue.html_url, degradedReason: comments.degradedReason, details: comments });
findings.push({
type: "comment-scan-error",
issueNumber: issue.number,
url: issue.html_url,
degradedReason: comments.degradedReason,
runnerDisposition: comments.runnerDisposition ?? runnerDisposition(comments.degradedReason),
details: comments,
});
continue;
}
for (const comment of comments) {
@@ -438,60 +841,57 @@ async function authStatus(repo: string): Promise<GitHubCommandResult> {
const degraded: GitHubDegradedReason[] = [];
if (ghPath === null) degraded.push("missing-binary");
if (!probe.present || token === null) {
if (ghPath === null) {
return {
ok: false,
command: "auth status",
repo,
degradedReason: "missing-binary",
degraded,
runnerDisposition: runnerDisposition("missing-binary"),
gh: { binaryFound: false, path: null },
token: probe,
probes: { restApi: "skipped", repo: "skipped", issueRead: "skipped" },
};
}
degraded.push("missing-token");
return {
ok: false,
command: "auth status",
repo,
degradedReason: "missing-token",
return commandError("auth status", repo, errorPayload("missing-token", "GH_TOKEN or GITHUB_TOKEN is required", { details: probe }), {
degraded,
gh: { binaryFound: ghPath !== null, path: ghPath },
token: probe,
probes: { restApi: "skipped", repo: "skipped", issueRead: "skipped" },
};
});
}
const { owner, name } = repoParts(repo);
const api = await githubRequest<Record<string, unknown>>(token, "GET", "/rate_limit");
if (isGitHubError(api)) {
return {
ok: false,
command: "auth status",
repo,
degradedReason: api.degradedReason,
return commandError("auth status", repo, api, {
degraded: [...degraded, api.degradedReason],
gh: { binaryFound: ghPath !== null, path: ghPath },
token: probe,
probes: { restApi: api, repo: "skipped", issueRead: "skipped" },
};
});
}
const repoProbe = await githubRequest<{ full_name?: string; private?: boolean }>(token, "GET", `/repos/${owner}/${name}`);
if (isGitHubError(repoProbe)) {
return {
ok: false,
command: "auth status",
repo,
degradedReason: repoProbe.degradedReason,
return commandError("auth status", repo, repoProbe, {
degraded: [...degraded, repoProbe.degradedReason],
gh: { binaryFound: ghPath !== null, path: ghPath },
token: probe,
probes: { restApi: "ok", repo: repoProbe, issueRead: "skipped" },
};
});
}
const issueProbe = await githubRequest<GitHubIssue[]>(token, "GET", `/repos/${owner}/${name}/issues?per_page=1&state=all`);
if (isGitHubError(issueProbe)) {
return {
ok: false,
command: "auth status",
repo,
degradedReason: issueProbe.degradedReason,
return commandError("auth status", repo, issueProbe, {
degraded: [...degraded, issueProbe.degradedReason],
gh: { binaryFound: ghPath !== null, path: ghPath },
token: probe,
probes: { restApi: "ok", repo: { ok: true, fullName: repoProbe.full_name ?? repo, private: repoProbe.private ?? null }, issueRead: issueProbe },
};
});
}
return {
@@ -513,12 +913,12 @@ async function authStatus(repo: string): Promise<GitHubCommandResult> {
async function prList(repo: string, token: string, limit: number): Promise<GitHubCommandResult> {
const { owner, name } = repoParts(repo);
const prs = await githubRequest<GitHubPullRequest[]>(token, "GET", `/repos/${owner}/${name}/pulls?state=all&per_page=${limit}`);
if (isGitHubError(prs)) return { ok: false, command: "pr list", repo, degradedReason: prs.degradedReason, details: prs };
if (isGitHubError(prs)) return commandError("pr list", repo, prs);
return {
ok: true,
command: "pr list",
repo,
plannedScope: "first-stage read-only REST support",
plannedScope: "read-only REST support",
pullRequests: prs.map(prSummary),
};
}
@@ -526,12 +926,12 @@ async function prList(repo: string, token: string, limit: number): Promise<GitHu
async function prView(repo: string, token: string, number: number): Promise<GitHubCommandResult> {
const { owner, name } = repoParts(repo);
const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`);
if (isGitHubError(pr)) return { ok: false, command: "pr view", repo, degradedReason: pr.degradedReason, details: pr };
if (isGitHubError(pr)) return commandError("pr view", repo, pr, { number });
return {
ok: true,
command: "pr view",
repo,
plannedScope: "first-stage read-only REST support",
plannedScope: "read-only REST support",
pullRequest: prSummary(pr),
};
}
@@ -550,14 +950,16 @@ export function ghHelp(): unknown {
"bun scripts/cli.ts gh issue scan-escape [--repo owner/name] [--limit N]",
"bun scripts/cli.ts gh pr list [--repo owner/name] [--limit N]",
"bun scripts/cli.ts gh pr view <number> [--repo owner/name]",
"bun scripts/cli.ts gh pr create --title <title> --body-file <file>|--body <text> --base <branch> --head <branch> [--repo owner/name] [--draft] [--dry-run]",
"bun scripts/cli.ts gh pr comment <number> --body-file <file>|--body <text> [--repo owner/name] [--dry-run]",
],
defaults: { repo: DEFAULT_REPO },
notes: [
"Issue create/edit/comment/close/reopen use GitHub REST and do not require the gh binary when GH_TOKEN or GITHUB_TOKEN is present.",
"Token values are never printed; auth status reports only token source and presence.",
"--body-file is required for mutating Markdown bodies so real newlines, backticks, and tables are read as file bytes instead of shell arguments.",
"Issue body stdin is intentionally unsupported in the first phase; write generated Markdown to a file and pass --body-file.",
"PR support is first-stage read-only list/view; create/edit/merge are planned and intentionally unsupported.",
"--body-file is the recommended source for Markdown bodies so real newlines, backticks, and tables are read as file bytes instead of shell arguments.",
"Issue body stdin is intentionally unsupported in this CLI; write generated Markdown to a file and pass --body-file.",
"PR create/comment are safe-write operations with dry-run planning; merge is intentionally unsupported in this phase.",
],
};
}
@@ -565,7 +967,17 @@ export function ghHelp(): unknown {
export async function runGhCommand(args: string[]): Promise<GitHubCommandResult | unknown> {
const [top, sub, third] = args;
if (top === undefined || top === "help" || top === "--help" || top === "-h") return ghHelp();
const options = parseOptions(args);
let options: GitHubOptions;
try {
options = parseOptions(args);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
const repo = optionValue(args, "--repo") ?? DEFAULT_REPO;
return message.startsWith("unknown gh option:")
? unsupportedCommand(command, repo, message)
: validationError(command, repo, message);
}
if (top === "auth" && sub === "status") return authStatus(options.repo);
@@ -591,15 +1003,31 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
}
if (top === "pr") {
if (sub === "create") {
if (options.dryRun) return prCreate(options.repo, "", options);
const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, "pr create", probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr create", { present: false, source: null, ghFallbackAttempted: true });
return prCreate(options.repo, token, options);
}
if (sub === "comment") {
if (options.dryRun) {
const number = parseNumberForCommand(options.repo, third, "pr comment");
if (typeof number !== "number") return number;
return prComment(options.repo, "", number, options);
}
const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, "pr comment", probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr comment", { present: false, source: null, ghFallbackAttempted: true });
const number = parseNumberForCommand(options.repo, third, "pr comment");
if (typeof number !== "number") return number;
return prComment(options.repo, token, number, options);
}
if (sub === "merge") {
return unsupportedCommand("pr merge", options.repo, "PR merge is intentionally unsupported in this phase; use create/comment/read only.");
}
if (sub !== "list" && sub !== "view") {
return {
ok: false,
command: `pr ${sub ?? ""}`.trim(),
repo: options.repo,
degradedReason: "unsupported-command",
planned: true,
message: "PR create/edit/comment/merge are planned for a later phase; first-stage support is read-only pr list/view.",
};
return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, view, create, comment, and unsupported merge.");
}
const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, `pr ${sub}`, probe);
@@ -608,11 +1036,5 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
return prView(options.repo, token, parseNumber(third, "pr view"));
}
return {
ok: false,
command: args.join(" ") || "gh",
repo: options.repo,
degradedReason: "unsupported-command",
help: ghHelp(),
};
return unsupportedCommand(args.join(" ") || "gh", options.repo, "Unsupported gh command", { help: ghHelp() });
}
+1 -1
View File
@@ -43,7 +43,7 @@ export function rootHelp(): unknown {
{ command: "deploy check|plan|apply [--file deploy.json|--env dev|prod] [--service id] [--commit full-sha] [--dry-run] [--force]", description: "Reconcile services from a repo+commit manifest; --env reads origin/master:deploy.json environments and applies supported dev target-side rollouts or reviewed D601 registry artifact consumers. code-queue artifact consumption is dev-only." },
{ command: "dev-env validate|prewarm-images", description: "Validate D601 unidesk-dev guardrails or prewarm dev foundation images into native k3s containerd through a bounded async job." },
{ command: "artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service", description: "Manage the D601 host-managed CNCF Distribution registry and run pull-only artifact CD for supported services, including D601 direct, k3s-managed, and code-queue dev-only consumers." },
{ command: "gh auth|issue|pr", description: "Run safe GitHub issue operations through REST with body-file support, token diagnostics, escape scanning, and first-stage read-only PR list/view." },
{ command: "gh auth|issue|pr", description: "Run safe GitHub issue and PR list/view/create/comment operations through REST with body-file support, token diagnostics, escape scanning, and merge blocked." },
{ command: "code-agent-sandbox", description: "Independent Code Agent Sandbox service skeleton for adapter, mode, and credential-boundary diagnostics." },
{ command: "schedule list|get|runs|run|delete", description: "Manage backend-core scheduled tasks and run history; schedule run <id> supports --wait-ms N." },
{ command: "schedule upsert-pgdata-backup [--time HH:MM] [--remote-base /SERVER_DATA/UNIDESK_PG_DATA]", description: "Create or update the daily PGDATA physical backup task that uploads monthly rotated archives to Baidu Netdisk." },