feat: add bounded GitHub Actions diagnostics

This commit is contained in:
pikastech
2026-07-17 19:18:31 +02:00
parent 0f477d07e5
commit c0f8d459f4
8 changed files with 343 additions and 1 deletions
+3
View File
@@ -54,6 +54,9 @@ bun scripts/cli.ts gh issue comments <number> --repo pikasTech/unidesk --limit 8
bun scripts/cli.ts gh issue create --repo pikasTech/unidesk --title "标题" --body-stdin
bun scripts/cli.ts gh issue comment create <number> --repo pikasTech/unidesk --body-stdin
bun scripts/cli.ts gh pr list --repo pikasTech/unidesk --state all --limit 10
bun scripts/cli.ts gh run list --repo owner/name --limit 10
bun scripts/cli.ts gh run view <run-id> --repo owner/name
bun scripts/cli.ts gh run logs <run-id> --job <job-id> --repo owner/name
trans gh:/pikasTech/unidesk/pr/<number> cat
trans gh:/pikasTech/unidesk/pr/<number> rg <pattern>
bun scripts/cli.ts gh pr review-plan <number> --repo pikasTech/unidesk
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, test } from "bun:test";
import { redactAndTailActionLog } from "./gh/actions-runs";
import { withGhDefaultRendered } from "./gh/default-render";
describe("GitHub Actions run diagnostics", () => {
test("redacts credentials and keeps a bounded tail", () => {
const lines = Array.from({ length: 200 }, (_, index) => `line-${index}`);
lines.push("TOKEN=secret-value");
lines.push("Authorization: Bearer abc.def.ghi");
lines.push("clone https://user:password@example.com/repo.git failed");
const tail = redactAndTailActionLog(lines.join("\n"), 5, 1000);
expect(tail).not.toContain("secret-value");
expect(tail).not.toContain("abc.def.ghi");
expect(tail).not.toContain("user:password");
expect(tail).toContain("<redacted>");
expect(tail.split("\n")).toHaveLength(5);
});
test("applies the character bound after line selection", () => {
expect(redactAndTailActionLog("a".repeat(100), 10, 12)).toBe("a".repeat(12));
});
test("renders startup failures and bounded logs without hiding evidence", () => {
const startup = withGhDefaultRendered(["run", "view", "42"], {
ok: true,
command: "run view",
repo: "owner/repo",
run: { id: 42, workflow: "code scan", status: "completed", conclusion: "failure", commit: "abcdef", title: "push" },
jobs: [],
startupFailure: true,
diagnosis: "workflow failed before any job was created",
next: [],
}) as { renderedText: string };
expect(startup.renderedText).toContain("startupFailure=true");
expect(startup.renderedText).toContain("workflow failed before any job was created");
const logs = withGhDefaultRendered(["run", "logs", "42"], {
ok: true,
command: "run logs",
repo: "owner/repo",
runId: 42,
job: { id: 7, name: "test", status: "completed", conclusion: "failure" },
logTail: "compile failed at source.c:10",
lineLimit: 80,
charLimit: 7000,
truncated: true,
}) as { renderedText: string };
expect(logs.renderedText).toContain("compile failed at source.c:10");
expect(logs.renderedText).toContain("valuesPrinted=false");
});
});
+155
View File
@@ -0,0 +1,155 @@
import { commandError, githubRequest, githubTextRequest, isGitHubError, validationError } from "./client";
interface WorkflowRun {
id: number;
name: string;
display_title: string;
event: string;
status: string;
conclusion: string | null;
head_branch: string;
head_sha: string;
run_number: number;
created_at: string;
updated_at: string;
html_url: string;
}
interface WorkflowJob {
id: number;
name: string;
status: string;
conclusion: string | null;
started_at: string | null;
completed_at: string | null;
html_url: string;
steps?: Array<{ name: string; status: string; conclusion: string | null; number: number }>;
}
interface WorkflowRunsResponse { total_count: number; workflow_runs: WorkflowRun[] }
interface WorkflowJobsResponse { total_count: number; jobs: WorkflowJob[] }
function splitRepo(repo: string): [string, string] | null {
const [owner, name, extra] = repo.split("/");
return owner && name && extra === undefined ? [owner, name] : null;
}
function compactRun(run: WorkflowRun): Record<string, unknown> {
return {
id: run.id,
workflow: run.name,
title: run.display_title,
event: run.event,
status: run.status,
conclusion: run.conclusion,
branch: run.head_branch,
commit: run.head_sha,
number: run.run_number,
createdAt: run.created_at,
updatedAt: run.updated_at,
url: run.html_url,
};
}
function compactJob(job: WorkflowJob): Record<string, unknown> {
return {
id: job.id,
name: job.name,
status: job.status,
conclusion: job.conclusion,
startedAt: job.started_at,
completedAt: job.completed_at,
url: job.html_url,
steps: (job.steps ?? []).map((step) => ({
number: step.number,
name: step.name,
status: step.status,
conclusion: step.conclusion,
})),
};
}
export function redactAndTailActionLog(text: string, lineLimit = 80, charLimit = 7000): string {
const redacted = text
.replace(/(https?:\/\/)[^/@\s]+@/giu, "$1<redacted>@")
.replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]+/giu, "$1<redacted>")
.replace(/((?:PASSWORD|SECRET|TOKEN|API[_-]?KEY)\s*[=:]\s*)\S+/giu, "$1<redacted>");
return redacted.split(/\r?\n/u).slice(-lineLimit).join("\n").slice(-charLimit);
}
export async function actionRunList(repo: string, token: string, limit: number): Promise<Record<string, unknown>> {
const parts = splitRepo(repo);
if (parts === null) return validationError("run list", repo, "--repo must use owner/name format");
const [owner, name] = parts;
const result = await githubRequest<WorkflowRunsResponse>(token, "GET", `/repos/${owner}/${name}/actions/runs?per_page=${Math.min(limit, 100)}`);
if (isGitHubError(result)) return commandError("run list", repo, result, { mutation: false });
const runs = result.workflow_runs.map(compactRun);
return {
ok: true,
command: "run list",
repo,
mutation: false,
runs,
count: runs.length,
total: result.total_count,
next: runs.length > 0 ? { view: `bun scripts/cli.ts gh run view ${String(result.workflow_runs[0]?.id)} --repo ${repo}` } : null,
};
}
async function loadRunAndJobs(repo: string, token: string, runId: number): Promise<{ run: WorkflowRun; jobs: WorkflowJob[] } | Record<string, unknown>> {
const parts = splitRepo(repo);
if (parts === null) return validationError("run view", repo, "--repo must use owner/name format");
const [owner, name] = parts;
const run = await githubRequest<WorkflowRun>(token, "GET", `/repos/${owner}/${name}/actions/runs/${runId}`);
if (isGitHubError(run)) return commandError("run view", repo, run, { runId, mutation: false });
const jobs = await githubRequest<WorkflowJobsResponse>(token, "GET", `/repos/${owner}/${name}/actions/runs/${runId}/jobs?per_page=100`);
if (isGitHubError(jobs)) return commandError("run view", repo, jobs, { runId, mutation: false });
return { run, jobs: jobs.jobs };
}
export async function actionRunView(repo: string, token: string, runId: number): Promise<Record<string, unknown>> {
const loaded = await loadRunAndJobs(repo, token, runId);
if (!("run" in loaded) || !("jobs" in loaded)) return loaded;
const failedJobs = loaded.jobs.filter((job) => job.conclusion === "failure");
const startupFailure = loaded.run.conclusion === "failure" && loaded.jobs.length === 0;
return {
ok: true,
command: "run view",
repo,
mutation: false,
run: compactRun(loaded.run),
jobs: loaded.jobs.map(compactJob),
failedJobIds: failedJobs.map((job) => job.id),
startupFailure,
diagnosis: startupFailure
? "workflow failed before any job was created; inspect workflow syntax, triggers, permissions, and repository Actions settings"
: null,
next: failedJobs.map((job) => `bun scripts/cli.ts gh run logs ${runId} --job ${job.id} --repo ${repo}`),
};
}
export async function actionRunLogs(repo: string, token: string, runId: number, jobId: number): Promise<Record<string, unknown>> {
const loaded = await loadRunAndJobs(repo, token, runId);
if (!("run" in loaded) || !("jobs" in loaded)) return loaded;
const job = loaded.jobs.find((candidate) => candidate.id === jobId);
if (job === undefined) return validationError("run logs", repo, `job ${jobId} does not belong to run ${runId}`, { runId, jobId });
const parts = splitRepo(repo);
if (parts === null) return validationError("run logs", repo, "--repo must use owner/name format");
const [owner, name] = parts;
const log = await githubTextRequest(token, `/repos/${owner}/${name}/actions/jobs/${jobId}/logs`);
if (isGitHubError(log)) return commandError("run logs", repo, log, { runId, jobId, mutation: false });
const logTail = redactAndTailActionLog(log);
return {
ok: true,
command: "run logs",
repo,
mutation: false,
runId,
job: compactJob(job),
logTail,
lineLimit: 80,
charLimit: 7000,
truncated: logTail.length < log.length,
valuesPrinted: false,
};
}
+38
View File
@@ -269,6 +269,44 @@ export async function githubRequest<T>(
return parsed as T;
}
export async function githubTextRequest(
token: string,
path: string,
): Promise<string | GitHubErrorPayload> {
let response: Response;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
response = await fetch(`${GITHUB_API}${path}`, {
signal: controller.signal,
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"User-Agent": USER_AGENT,
"X-GitHub-Api-Version": "2022-11-28",
},
});
} catch (error) {
const classified = classifyGitHubFetchFailure(error);
return errorPayload(classified.reason, classified.message, {
request: { method: "GET", path },
details: classified.details,
retryable: classified.retryable,
commanderAction: classified.commanderAction,
});
} finally {
clearTimeout(timeout);
}
const text = await response.text();
if (!response.ok) {
return errorPayload(classifyHttpStatus(response.status, response.statusText, path, response), response.statusText, {
status: response.status,
request: { method: "GET", path },
});
}
return text;
}
export async function githubGraphqlRequest<T>(
token: string,
query: string,
+68
View File
@@ -50,6 +50,9 @@ function renderGhDefaultText(result: GitHubCommandResult): string {
if (command === "issue comments") return renderIssueComments(result);
if (isPrReadResult(result)) return renderPrView(result);
if (command === "pr list") return renderPrList(result);
if (command === "run list") return renderRunList(result);
if (command === "run view") return renderRunView(result);
if (command === "run logs") return renderRunLogs(result);
if (command === "pr files" || command === "pr diff --stat") return renderPrFiles(result);
if (command === "pr review-plan") return renderPrReviewPlan(result);
if (command === "pr diff" && isRecord(result.file)) return renderPrDiffFile(result);
@@ -683,6 +686,71 @@ function renderGenericResult(result: GitHubCommandResult): string {
].join("\n");
}
function renderRunList(result: GitHubCommandResult): string {
const runs = arrayOfRecords(result.runs);
const rows = runs.map((run) => [
ghText(run.id),
ghShort(ghText(run.workflow), 28),
ghText(run.status),
ghText(run.conclusion),
ghShort(ghText(run.branch), 20),
ghShort(ghText(run.commit), 10),
ghShort(ghText(run.title), 54),
]);
return [
"gh run list (observed)",
"",
ghTable(["RUN", "WORKFLOW", "STATUS", "CONCLUSION", "BRANCH", "COMMIT", "TITLE"], rows),
"",
"Summary:",
` repo=${ghText(result.repo)} count=${ghText(result.count)} total=${ghText(result.total)} mutation=false`,
"",
"Next:",
` bun scripts/cli.ts gh run view <run-id> --repo ${ghText(result.repo)}`,
].join("\n");
}
function renderRunView(result: GitHubCommandResult): string {
const run = record(result.run);
const jobs = arrayOfRecords(result.jobs);
const jobRows = jobs.map((job) => [ghText(job.id), ghShort(ghText(job.name), 36), ghText(job.status), ghText(job.conclusion)]);
const stepRows = jobs.flatMap((job) => arrayOfRecords(job.steps)
.filter((step) => step.conclusion === "failure" || step.status === "in_progress")
.map((step) => [ghShort(ghText(job.name), 24), ghText(step.number), ghShort(ghText(step.name), 54), ghText(step.status), ghText(step.conclusion)]));
const lines = [
"gh run view (observed)",
"",
ghTable(["RUN", "WORKFLOW", "STATUS", "CONCLUSION", "COMMIT", "TITLE"], [[
ghText(run.id), ghShort(ghText(run.workflow), 28), ghText(run.status), ghText(run.conclusion), ghShort(ghText(run.commit), 10), ghShort(ghText(run.title), 54),
]]),
"",
ghTable(["JOB", "NAME", "STATUS", "CONCLUSION"], jobRows),
];
if (stepRows.length > 0) lines.push("", ghTable(["JOB", "STEP", "NAME", "STATUS", "CONCLUSION"], stepRows));
if (result.startupFailure === true) lines.push("", "Diagnosis:", ` ${ghText(result.diagnosis)}`);
const next = Array.isArray(result.next) ? result.next.filter((value): value is string => typeof value === "string") : [];
if (next.length > 0) lines.push("", "Next:", ...next.map((command) => ` ${command}`));
lines.push("", "Summary:", ` repo=${ghText(result.repo)} jobs=${jobs.length} startupFailure=${ghText(result.startupFailure)} mutation=false`);
return lines.join("\n");
}
function renderRunLogs(result: GitHubCommandResult): string {
const job = record(result.job);
return [
"gh run logs (observed)",
"",
`Run ${ghText(result.runId)} / job ${ghText(job.id)} (${ghText(job.name)})`,
"",
typeof result.logTail === "string" && result.logTail.length > 0 ? result.logTail : "<no log lines returned>",
"",
"Summary:",
` repo=${ghText(result.repo)} status=${ghText(job.status)} conclusion=${ghText(job.conclusion)} truncated=${ghText(result.truncated)} mutation=false`,
"",
"Disclosure:",
` bounded redacted tail: lines<=${ghText(result.lineLimit)} chars<=${ghText(result.charLimit)} valuesPrinted=false`,
].join("\n");
}
function record(value: unknown): Record<string, unknown> {
return isRecord(value) ? value : {};
}
+3
View File
@@ -44,6 +44,9 @@ export function ghHelp(): unknown {
"bun scripts/cli.ts gh issue board-row delete <issueNumber> [--repo owner/name] [--number N compat] --board-issue 20 [--dry-run] [--expect-body-sha sha256]",
"bun scripts/cli.ts gh preflight <prNumber|owner/repo#number> [--repo owner/name] [--number N compat] [--full|--raw] [compatibility alias for gh pr preflight]",
"bun scripts/cli.ts gh pr list [owner/repo] [--repo owner/name] [--state open|closed|all] [--limit N] [--json number,title,state,url,updatedAt,createdAt,author,head,base,draft]",
"bun scripts/cli.ts gh run list --repo owner/name [--limit N]",
"bun scripts/cli.ts gh run view <run-id> --repo owner/name",
"bun scripts/cli.ts gh run logs <run-id> --job <job-id> --repo owner/name",
"bun scripts/cli.ts gh pr files <number> [--repo owner/name] [--number N compat] [--limit N] [number may appear before or after options]",
"bun scripts/cli.ts gh pr diff <number> --stat [--repo owner/name] [--number N compat] [--limit N] [number may appear before or after options; compatibility alias for pr files; no raw diff]",
"bun scripts/cli.ts gh pr review-plan <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--limit N] [bounded changed-file index plus per-file patch drill-down commands]",
+22 -1
View File
@@ -2,6 +2,7 @@
// Moved mechanically from scripts/src/gh.ts:8348-8845.
import { issueAttachmentDownload, issueAttachmentList } from "./attachments";
import { actionRunList, actionRunLogs, actionRunView } from "./actions-runs";
import { resolveToken } from "./auth-and-safety";
import { authStatus, prFiles, prList, prRead, prView } from "./auth-pr-read";
import { issueBoardAudit } from "./board-audit";
@@ -58,7 +59,7 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
const isIssueCommentReadCommand = top === "issue" && sub === "comment" && (third === "view" || third === "read");
const isPrCommentReadCommand = top === "pr" && sub === "comment" && (third === "view" || third === "read");
const isPrDiffFileCommand = top === "pr" && sub === "diff" && optionWasProvided(args, "--file");
if ((optionWasProvided(args, "--raw") || optionWasProvided(args, "--full")) && !((top === "issue" && (isIssueReadCommand(sub) || sub === "comments" || sub === "list" || sub === "update" || sub === "edit" || sub === "patch")) || isIssueCommentReadCommand || top === "preflight" || (top === "pr" && (isPrReadCommand(sub) || sub === "list" || sub === "preflight" || sub === "closeout")) || isPrCommentReadCommand || isPrDiffFileCommand)) {
if ((optionWasProvided(args, "--raw") || optionWasProvided(args, "--full")) && !((top === "issue" && (isIssueReadCommand(sub) || sub === "comments" || sub === "list" || sub === "update" || sub === "edit" || sub === "patch")) || isIssueCommentReadCommand || top === "preflight" || top === "run" || (top === "pr" && (isPrReadCommand(sub) || sub === "list" || sub === "preflight" || sub === "closeout")) || isPrCommentReadCommand || isPrDiffFileCommand)) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
return validationError(command, options.repo, "--raw and --full are explicit full-disclosure aliases only for gh issue list/read/view/comments/update/edit/patch/comment view, gh pr list/read/view/comment view, gh pr preflight/closeout, and gh pr diff --file.", {
supportedCommands: [
@@ -111,6 +112,9 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
],
});
}
if (optionWasProvided(args, "--job") && !(top === "run" && sub === "logs")) {
return validationError([top, sub].filter(Boolean).join(" "), options.repo, "--job is only supported by gh run logs <run-id> --job <job-id>");
}
if (optionWasProvided(args, "--number") && !allowsNumberTargetAlias(top, sub, third)) {
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
const standardViewCommand = top === "issue" || top === "pr" ? `gh ${top} view` : "gh issue/pr view";
@@ -220,6 +224,23 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
if (top === "auth" && sub === "status") return authStatus(options.repo);
if (top === "run") {
const commandName = `run ${sub ?? ""}`.trim();
if (sub !== "list" && sub !== "view" && sub !== "logs") {
return unsupportedCommand(commandName, options.repo, "run supported commands are list, view, and logs.");
}
const { token, probe } = resolveToken(options.repo, true);
const missing = authRequired(options.repo, commandName, probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, commandName, { present: false, source: null, ghFallbackAttempted: true });
if (sub === "list") return actionRunList(options.repo, token, options.limit);
const runId = Number(third);
if (!Number.isSafeInteger(runId) || runId <= 0) return validationError(commandName, options.repo, `${commandName} requires a positive positional run id`);
if (sub === "view") return actionRunView(options.repo, token, runId);
const jobId = Number(optionValue(args, "--job"));
if (!Number.isSafeInteger(jobId) || jobId <= 0) return validationError(commandName, options.repo, "run logs requires --job <positive-job-id>");
return actionRunLogs(options.repo, token, runId, jobId);
}
if (top === "repo") {
if (sub !== "view" && sub !== "create") {
return unsupportedCommand(`repo ${sub ?? ""}`.trim(), options.repo, "repo supported commands are view and create.", {
+1
View File
@@ -97,6 +97,7 @@ export const GH_VALUE_OPTIONS = new Set([
"--tasks", "--summary", "--focus", "--validation", "--progress", "--number", "--pr",
"--search", "--title-prefix", "--inactive-hours", "--comment", "--comment-file", "--description",
"--attachment", "--output", "--file", "--hunk", "--sync-node",
"--job",
]);
export const GH_FLAG_OPTIONS = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body", "--body-stdin", "--body-patch-stdin", "--comment-stdin", "--raw", "--full", "--stat", "--merge", "--squash", "--rebase", "--delete-branch", "--keep-branch", "--skip-local-closeout", "--private", "--public", "--auto-init"]);