Skip to the content

Capsid menu

src/github/actions.ts

404 lines and 22 functions in DrDustinEdwards/capsid, at commit daa875b on 2026-10-24. This file on GitHub.

Functions

The functions of actions.ts
FunctionLinesComplexityCoveredCRAPMutation
ciStatus46 to 1122494%WatchCRAP 24.1No data: mutation testing has not run on this repo yet
stepLog149 to 1919100%WatchCRAP 9No data: mutation testing has not run on this repo yet
<anonymous:161>161 to 1664100%OkCRAP 4No data: mutation testing has not run on this repo yet
errorBody195 to 1952100%OkCRAP 2No data: mutation testing has not run on this repo yet
ciStatusFromRuns197 to 28111100%WatchCRAP 11No data: mutation testing has not run on this repo yet
<anonymous:206>206 to 2172100%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:231>231 to 2311100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:251>251 to 2511100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:252>252 to 2552100%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:254>254 to 2541100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:254>254 to 2541100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:256>256 to 2561100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:266>266 to 2661100%OkCRAP 1No data: mutation testing has not run on this repo yet
stepView307 to 3143100%OkCRAP 3No data: mutation testing has not run on this repo yet
jobView316 to 3255100%OkCRAP 5No data: mutation testing has not run on this repo yet
pickOne330 to 3508100%WatchCRAP 8No data: mutation testing has not run on this repo yet
<anonymous:338>338 to 3381100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:341>341 to 3411100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:348>348 to 3481100%OkCRAP 1No data: mutation testing has not run on this repo yet
ciRunJobs355 to 4291595%WatchCRAP 15No data: mutation testing has not run on this repo yet
<anonymous:408>408 to 4081100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:412>412 to 4121100%OkCRAP 1No data: mutation testing has not run on this repo yet

Imports

It imports src/env.ts, src/github/client.ts, src/redact.ts. Imported by src/github.ts.

Source

import type { RepoEnv as Env } from "../env";
import { redact, redactText, type Redacted } from "../redact";
import {
  assertRepoArg,
  cachedGet,
  ghFetch,
  resolveRepo,
} from "./client";

// Recent CI workflow runs for a namespace's repo, via the GitHub App. Read-only.
// For the most recent failed run it also returns the failing jobs and steps and a
// bounded log tail. Needs the App's Actions: Read permission; a 403 is surfaced as
// a named error.
//
// The log tail is withheld from read-only keys. Run metadata (name, sha, conclusion)
// is inert; a build log carries whatever the workflow echoed: resolved binding ids,
// account ids, wrangler output, and any variable a step printed by accident. The runs
// list stays open to read-only keys. Every log text returned, to anyone, is redacted
// first (src/redact.ts). With run_id, jobs/job/step drill into any run the same way
// (ciRunJobs below), behind the same log gate.

// A ref matching this shape is filtered as a head sha, anything else as a branch.
// GitHub has two query parameters for these and none that accepts either, so the
// tool takes one `ref` and decides here.
const SHA_SHAPE = /^[0-9a-f]{7,40}$/i;

interface CiRunsOpts {
  limit?: number;
  logTail?: boolean;
  ref?: string;
  runId?: number;
}
interface CiDrillOpts extends CiRunsOpts {
  jobs?: boolean;
  job?: string;
  step?: string;
}
type CiRunsResult = Awaited<ReturnType<typeof ciStatusFromRuns>>;
type CiJobsResult = Awaited<ReturnType<typeof ciRunJobs>>;

// Without the drill-in arguments the answer is always the run list, and the callers
// that read `runs` (job-outcomes.ts, watcher.ts) keep that type. Only a call that
// passes jobs, job or step can get the jobs view back.
export async function ciStatus(env: Env, namespace: string, repoSelector?: string, opts?: CiRunsOpts): Promise<CiRunsResult>;
export async function ciStatus(env: Env, namespace: string, repoSelector: string | undefined, opts: CiDrillOpts): Promise<CiRunsResult | CiJobsResult>;
export async function ciStatus(
  env: Env,
  namespace: string,
  repoSelector?: string,
  opts: CiDrillOpts = {}
) {
  // The drill-in arguments narrow one run, so each needs the one above it. Refused
  // before any GitHub call rather than ignored, so a caller never reads a run list
  // believing it is the step it asked for.
  if (opts.step !== undefined && opts.job === undefined) {
    throw new Error("ci_status: step needs job, the job (name or id) the step ran in, and run_id");
  }
  if (opts.job !== undefined && opts.runId === undefined) {
    throw new Error("ci_status: job needs run_id, the run the job belongs to. capsid_inspect_repo action ci without it lists runs and their ids");
  }
  if (opts.jobs === true && opts.runId === undefined) {
    throw new Error("ci_status: jobs needs run_id, the run whose jobs to list. capsid_inspect_repo action ci without it lists runs and their ids");
  }
  if (opts.jobs === true && opts.job !== undefined) {
    throw new Error("ci_status: pass jobs: true to list every job in the run, or job to read one, not both");
  }
  if (opts.ref) assertRepoArg("ref", opts.ref);
  const { owner, repo, full } = await resolveRepo(env, namespace, repoSelector);
  const limit = opts.limit && opts.limit > 0 ? Math.min(opts.limit, 20) : 10;
  let query = `/repos/${owner}/${repo}/actions/runs?per_page=${limit}`;
  if (opts.runId) {
    // One run, by id. Wrapped back into the list's shape so the failed-run drill-in
    // has one code path.
    const one = await ghFetch(env, owner, repo, `/repos/${owner}/${repo}/actions/runs/${opts.runId}`);
    if (one.status === 404) throw new Error(`ci_status: run ${opts.runId} does not exist on ${full}`);
    if (!one.ok) throw new Error(`ci_status run lookup failed (${one.status}): ${await errorBody(one)}`);
    const run = await one.json();
    if (opts.jobs === true || opts.job !== undefined) {
      return ciRunJobs(env, { owner, repo, full }, run as CiRun, { job: opts.job, step: opts.step, logTail: opts.logTail === true });
    }
    return ciStatusFromRuns(env, owner, repo, full, [run as CiRun], opts.logTail === true, { run_id: opts.runId });
  }
  if (opts.ref) {
    if (SHA_SHAPE.test(opts.ref)) {
      // An abbreviated sha is expanded first. GitHub matches head_sha exactly and
      // documents nothing, so a short sha returns an empty run list rather than an
      // error. One extra GET, only when the ref is short.
      let full40 = opts.ref;
      if (opts.ref.length < 40) {
        const commit = await cachedGet(env, owner, repo, `/repos/${owner}/${repo}/commits/${encodeURIComponent(opts.ref)}`);
        if (!commit.ok) {
          throw new Error(
            `ci_status: ${opts.ref} does not resolve to a commit on ${full} (${commit.status}), so runs cannot be filtered by it`
          );
        }
        full40 = ((await commit.json()) as { sha: string }).sha;
      }
      query += `&head_sha=${encodeURIComponent(full40)}`;
    } else {
      query += `&branch=${encodeURIComponent(opts.ref)}`;
    }
  }
  const resp = await ghFetch(env, owner, repo, query);
  if (resp.status === 403) {
    throw new Error(
      "ci_status: the capsid-repo-access GitHub App lacks Actions: Read. Add that permission in the App settings and accept the installation prompt, then retry."
    );
  }
  if (!resp.ok) throw new Error(`ci_status failed (${resp.status}): ${redactText(await resp.text())}`);
  const data = (await resp.json()) as { workflow_runs: CiRun[] };
  return ciStatusFromRuns(env, owner, repo, full, data.workflow_runs, opts.logTail === true, opts.ref ? { ref: opts.ref } : {});
}

interface CiRun {
  id: number;
  name: string;
  head_sha: string;
  status: string;
  conclusion: string | null;
  event: string;
  created_at: string;
  updated_at?: string;
  html_url: string;
}

// The failing step's log, 64KB from its end, for write-grant keys. A job log ends
// with post-run cleanup, so the last few thousand characters of a failed run are
// `git config --unset-all` lines and the failure is far earlier. The region is found
// by the failing step and then tailed. The read-only tier is unchanged.
export const CI_LOG_BUDGET = 64 * 1024;

// Actions labels groups by command, not step name, so a step's name appears nowhere
// in its own log and a name search matches an incidental later occurrence. The
// failing step is found by its started_at..completed_at window instead: every log
// line carries an ISO timestamp.
//
// The jobs API truncates those to the second while log lines carry sub-second
// stamps, so the upper bound is extended to the end of that second, or the last
// second of output (where the error is) is dropped. Truncating down only widens the
// lower bound, so `from` needs no adjustment.
const STEP_STAMP_PRECISION_MS = 1000;

// Every log text ci_status returns is redacted (src/redact.ts), and redacted BEFORE the
// budget cut: a cut first could split a secret so its remaining half no longer matches
// any pattern, or keep a private key's body without its BEGIN line.
//
// `label` is how the region is named: "failing step" for the failed-run drill-in, whose
// wording callers already read, and "step" for a step the caller asked for by name.
function stepLog(
  log: string,
  step: { name?: string; started_at?: string | null; completed_at?: string | null } | undefined,
  label: "failing step" | "step" = "failing step"
): { text: string; how: string; redactions: Redacted["redactions"] } {
  const name = step?.name;
  const from = step?.started_at ? Date.parse(step.started_at) : NaN;
  const to = step?.completed_at ? Date.parse(step.completed_at) : NaN;

  if (Number.isFinite(from) && Number.isFinite(to)) {
    const windowed = log
      .split("\n")
      .filter((line) => {
        const stamp = /^(\S+Z)/.exec(line);
        if (!stamp) return false;
        const at = Date.parse(stamp[1]);
        return Number.isFinite(at) && at >= from && at < to + STEP_STAMP_PRECISION_MS;
      })
      .join("\n");
    if (windowed.length > 0) {
      const clean = redact(windowed);
      return {
        text: clean.text.length > CI_LOG_BUDGET ? clean.text.slice(-CI_LOG_BUDGET) : clean.text,
        how:
          clean.text.length > CI_LOG_BUDGET
            ? `${label} "${name}" by timestamp window, last ${CI_LOG_BUDGET} bytes of it`
            : `${label} "${name}" by timestamp window, whole (${clean.text.length} bytes)`,
        redactions: clean.redactions,
      };
    }
  }

  // NAMED FALLBACK, not a silent one. "The end of the job log" and "the failing
  // step" are different claims and the caller is told which one this is.
  const clean = redact(log);
  return {
    text: clean.text.slice(-CI_LOG_BUDGET),
    how: name
      ? `step "${name}" had no usable timestamp window, so this is the last ${CI_LOG_BUDGET} bytes of the whole job, cleanup included`
      : `no failing step was named, so this is the last ${CI_LOG_BUDGET} bytes of the whole job, cleanup included`,
    redactions: clean.redactions,
  };
}

// A GitHub error body is echoed into a result too, so it is redacted as well, before
// its 200-character cut for the same reason as above.
const errorBody = async (resp: Response): Promise<string> => redactText(await resp.text()).slice(0, 200) || "no response body";

async function ciStatusFromRuns(
  env: Env,
  owner: string,
  repo: string,
  full: string,
  workflowRuns: CiRun[],
  logTail: boolean,
  filter: { ref?: string; run_id?: number }
) {
  const runs = workflowRuns.map((r) => ({
    name: r.name,
    head_sha: r.head_sha?.slice(0, 7),
    status: r.status,
    conclusion: r.conclusion,
    event: r.event,
    created_at: r.created_at,
    // When a completed run finished: the watcher's deploy-drift check waits a settle
    // window after master's own CI run ends (src/watcher-detect.ts, deployVerdict).
    updated_at: r.updated_at ?? null,
    url: r.html_url,
  }));

  const result: {
    repo: string;
    filter?: { ref?: string; run_id?: number };
    runs: typeof runs;
    failed_run?: unknown;
  } = { repo: full, runs };
  if (filter.ref || filter.run_id) result.filter = filter;

  // Drill into the most recent failed run so the caller sees why, not just that.
  // Every degraded path is named: an errored jobs fetch or log fetch is reported
  // rather than dropped, so a write-grant caller can tell "no log" from "the log was
  // refused for you".
  const failed = workflowRuns.find((r) => r.conclusion === "failure");
  if (failed) {
    const failedRun: Record<string, unknown> = {
      name: failed.name,
      head_sha: failed.head_sha?.slice(0, 7),
      url: failed.html_url,
    };
    const jobsResp = await ghFetch(env, owner, repo, `/repos/${owner}/${repo}/actions/runs/${failed.id}/jobs`);
    if (!jobsResp.ok) {
      failedRun.jobs_unavailable = `${jobsResp.status}: ${await errorBody(jobsResp)}`;
    } else {
      const jobsData = (await jobsResp.json()) as {
        jobs: Array<{
          id: number;
          name: string;
          conclusion: string | null;
          steps?: Array<{ name: string; conclusion: string | null; started_at?: string | null; completed_at?: string | null }>;
        }>;
      };
      failedRun.jobs = jobsData.jobs
        .filter((j) => j.conclusion === "failure")
        .map((j) => ({
          name: j.name,
          failed_steps: (j.steps ?? []).filter((s) => s.conclusion === "failure").map((s) => s.name),
        }));
      const firstFailedJob = jobsData.jobs.find((j) => j.conclusion === "failure");
      if (!logTail) {
        // Unchanged: this is the read-only tier's boundary, not a degraded path.
        failedRun.log_tail_withheld =
          "read-only key: run metadata only. A write-grant key returns the failing step's log.";
      } else if (!firstFailedJob) {
        failedRun.log_tail_unavailable = "the run is marked failed but no job in it is, so there is no job log to read";
      } else {
        const logResp = await ghFetch(env, owner, repo, `/repos/${owner}/${repo}/actions/jobs/${firstFailedJob.id}/logs`);
        if (logResp.ok) {
          const failedStep = (firstFailedJob.steps ?? []).find((s) => s.conclusion === "failure");
          const picked = stepLog(await logResp.text(), failedStep);
          failedRun.failing_job = firstFailedJob.name;
          failedRun.failing_step = failedStep?.name ?? null;
          failedRun.log_region = picked.how;
          failedRun.log = picked.text;
          if (Object.keys(picked.redactions).length > 0) failedRun.log_redactions = picked.redactions;
        } else {
          failedRun.log_tail_unavailable = `${logResp.status}: ${await errorBody(logResp)}`;
        }
      }
    }
    result.failed_run = failedRun;
  }
  return result;
}

// One page of a run's jobs, the latest attempt only. GitHub's page limit, and the cap:
// a run with more jobs than this says so rather than being paged through.
export const CI_JOBS_MAX = 100;

interface CiJob {
  id: number;
  name: string;
  status: string;
  conclusion: string | null;
  started_at?: string | null;
  completed_at?: string | null;
  html_url?: string;
  steps?: CiStep[];
}

interface CiStep {
  number: number;
  name: string;
  status: string;
  conclusion: string | null;
  started_at?: string | null;
  completed_at?: string | null;
}

const stepView = (s: CiStep) => ({
  number: s.number,
  name: s.name,
  status: s.status,
  conclusion: s.conclusion,
  started_at: s.started_at ?? null,
  completed_at: s.completed_at ?? null,
});

const jobView = (j: CiJob) => ({
  id: j.id,
  name: j.name,
  status: j.status,
  conclusion: j.conclusion,
  started_at: j.started_at ?? null,
  completed_at: j.completed_at ?? null,
  url: j.html_url ?? null,
  steps: (j.steps ?? []).map(stepView),
});

// A job or step named by the caller: a number is tried as the id (a job) or the number
// (a step) first, then as a name. A name two entries share is refused with what tells
// them apart, never resolved to the first.
function pickOne<T extends { name: string }>(
  items: T[],
  wanted: string,
  byNumber: (item: T) => number,
  what: string,
  where: string
): T {
  if (/^\d+$/.test(wanted)) {
    const numbered = items.find((item) => byNumber(item) === Number(wanted));
    if (numbered) return numbered;
  }
  const named = items.filter((item) => item.name === wanted);
  if (named.length === 1) return named[0];
  if (named.length > 1) {
    throw new Error(
      `ci_status: ${named.length} ${what}s in ${where} are named "${wanted}". Pass one by its ${what === "job" ? "id" : "number"}: ${named.map(byNumber).join(", ")}`
    );
  }
  const known = items.slice(0, 30).map((item) => `"${item.name}" (${byNumber(item)})`);
  throw new Error(`ci_status: no ${what} "${wanted}" in ${where}. It has: ${known.join(", ") || "none"}${items.length > 30 ? ", and more" : ""}`);
}

/** Every job of one run with its steps (jobs: true), or one job, or one step of it with
 *  that step's log. For any run, not only a failed one. The log is withheld from a
 *  read-only caller exactly as the failed-run tail is, and redacted for everyone else. */
async function ciRunJobs(
  env: Env,
  target: { owner: string; repo: string; full: string },
  run: CiRun,
  opts: { job?: string; step?: string; logTail: boolean }
) {
  const { owner, repo, full } = target;
  const resp = await ghFetch(
    env,
    owner,
    repo,
    `/repos/${owner}/${repo}/actions/runs/${run.id}/jobs?filter=latest&per_page=${CI_JOBS_MAX}`
  );
  if (resp.status === 403) {
    throw new Error(
      "ci_status: the capsid-repo-access GitHub App lacks Actions: Read. Add that permission in the App settings and accept the installation prompt, then retry."
    );
  }
  if (!resp.ok) throw new Error(`ci_status jobs lookup for run ${run.id} failed (${resp.status}): ${await errorBody(resp)}`);
  const data = (await resp.json()) as { total_count?: number; jobs: CiJob[] };
  const jobs = data.jobs.slice(0, CI_JOBS_MAX);
  const total = typeof data.total_count === "number" ? data.total_count : jobs.length;

  const result: Record<string, unknown> = {
    repo: full,
    filter: {
      run_id: run.id,
      ...(opts.job === undefined ? { jobs: true } : { job: opts.job }),
      ...(opts.step === undefined ? {} : { step: opts.step }),
    },
    run: {
      name: run.name,
      head_sha: run.head_sha?.slice(0, 7),
      status: run.status,
      conclusion: run.conclusion,
      event: run.event,
      created_at: run.created_at,
      url: run.html_url,
    },
  };
  const truncated =
    total > jobs.length
      ? `the run has ${total} jobs in its latest attempt and only the first ${jobs.length} were read (at most ${CI_JOBS_MAX})`
      : null;

  if (opts.job === undefined) {
    result.jobs = jobs.map(jobView);
    result.jobs_total = total;
    if (truncated) result.jobs_truncated = truncated;
    return result;
  }

  const where = `run ${run.id}${truncated ? ` (${truncated})` : ""}`;
  const job = pickOne(jobs, opts.job, (j) => j.id, "job", where);
  result.job = jobView(job);
  if (opts.step === undefined) return result;

  const step = pickOne(job.steps ?? [], opts.step, (s) => s.number, "step", `job "${job.name}"`);
  result.step = stepView(step);
  if (!opts.logTail) {
    // The read-only tier's boundary, worded as the failed-run tail's is.
    result.log_withheld = "read-only key: step metadata only. A write-grant key returns the step's log.";
    return result;
  }
  const logResp = await ghFetch(env, owner, repo, `/repos/${owner}/${repo}/actions/jobs/${job.id}/logs`);
  if (!logResp.ok) {
    result.log_unavailable = `${logResp.status}: ${await errorBody(logResp)}`;
    return result;
  }
  const picked = stepLog(await logResp.text(), step, "step");
  result.log_region = picked.how;
  result.log = picked.text;
  if (Object.keys(picked.redactions).length > 0) result.log_redactions = picked.redactions;
  return result;
}