diff --git a/.agents/skills/unidesk-gh/SKILL.md b/.agents/skills/unidesk-gh/SKILL.md index 77e814ae..ed1fb7e1 100644 --- a/.agents/skills/unidesk-gh/SKILL.md +++ b/.agents/skills/unidesk-gh/SKILL.md @@ -54,6 +54,9 @@ bun scripts/cli.ts gh issue comments --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 --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 --repo owner/name +bun scripts/cli.ts gh run logs --job --repo owner/name trans gh:/pikasTech/unidesk/pr/ cat trans gh:/pikasTech/unidesk/pr/ rg bun scripts/cli.ts gh pr review-plan --repo pikasTech/unidesk diff --git a/scripts/src/gh-actions-runs.test.ts b/scripts/src/gh-actions-runs.test.ts new file mode 100644 index 00000000..b8469096 --- /dev/null +++ b/scripts/src/gh-actions-runs.test.ts @@ -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(""); + 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"); + }); +}); diff --git a/scripts/src/gh/actions-runs.ts b/scripts/src/gh/actions-runs.ts new file mode 100644 index 00000000..a52d7d05 --- /dev/null +++ b/scripts/src/gh/actions-runs.ts @@ -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 { + 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 { + 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@") + .replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]+/giu, "$1") + .replace(/((?:PASSWORD|SECRET|TOKEN|API[_-]?KEY)\s*[=:]\s*)\S+/giu, "$1"); + return redacted.split(/\r?\n/u).slice(-lineLimit).join("\n").slice(-charLimit); +} + +export async function actionRunList(repo: string, token: string, limit: number): Promise> { + 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(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> { + 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(token, "GET", `/repos/${owner}/${name}/actions/runs/${runId}`); + if (isGitHubError(run)) return commandError("run view", repo, run, { runId, mutation: false }); + const jobs = await githubRequest(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> { + 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> { + 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, + }; +} diff --git a/scripts/src/gh/client.ts b/scripts/src/gh/client.ts index 7fa34c57..3a6d7583 100644 --- a/scripts/src/gh/client.ts +++ b/scripts/src/gh/client.ts @@ -269,6 +269,44 @@ export async function githubRequest( return parsed as T; } +export async function githubTextRequest( + token: string, + path: string, +): Promise { + 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( token: string, query: string, diff --git a/scripts/src/gh/default-render.ts b/scripts/src/gh/default-render.ts index 57325de5..92f07e3d 100644 --- a/scripts/src/gh/default-render.ts +++ b/scripts/src/gh/default-render.ts @@ -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 --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 : "", + "", + "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 { return isRecord(value) ? value : {}; } diff --git a/scripts/src/gh/help.ts b/scripts/src/gh/help.ts index 64bd4333..3acf106f 100644 --- a/scripts/src/gh/help.ts +++ b/scripts/src/gh/help.ts @@ -44,6 +44,9 @@ export function ghHelp(): unknown { "bun scripts/cli.ts gh issue board-row delete [--repo owner/name] [--number N compat] --board-issue 20 [--dry-run] [--expect-body-sha sha256]", "bun scripts/cli.ts gh preflight [--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 --repo owner/name", + "bun scripts/cli.ts gh run logs --job --repo owner/name", "bun scripts/cli.ts gh pr files [--repo owner/name] [--number N compat] [--limit N] [number may appear before or after options]", "bun scripts/cli.ts gh pr diff --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 [--repo owner/name] [--number N compat] [--limit N] [bounded changed-file index plus per-file patch drill-down commands]", diff --git a/scripts/src/gh/index.ts b/scripts/src/gh/index.ts index 48028f89..9c141d73 100644 --- a/scripts/src/gh/index.ts +++ b/scripts/src/gh/index.ts @@ -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 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 --job "); + } 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"); + 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.", { diff --git a/scripts/src/gh/types.ts b/scripts/src/gh/types.ts index de62ad84..78d909ba 100644 --- a/scripts/src/gh/types.ts +++ b/scripts/src/gh/types.ts @@ -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"]);