feat: add safe gh pr create/comment
This commit is contained in:
+499
-77
@@ -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
@@ -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." },
|
||||
|
||||
Reference in New Issue
Block a user