Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 75 additions & 4 deletions scripts/pipboy/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
NodeHttpClient,
Expand All @@ -11,13 +12,16 @@ import {
FileSystem,
Layer,
Option,
Redacted,
Schema,
} from "effect";
import { Command, Flag } from "effect/cli";
import { HttpClient, HttpClientRequest } from "effect/http";
import { compareFootprint } from "./compare/footprint.ts";
import { compareHygiene } from "./compare/hygiene.ts";
import { compareNative } from "./compare/native.ts";
import { CARGO_WORKSPACE, REPO_ROOT } from "./config.ts";
import { run } from "./exec.ts";
import { inspectEntrypoints } from "./footprint/inspect.ts";
import { pack, readPackManifest } from "./footprint/pack.ts";
import * as Registry from "./footprint/registry.ts";
Expand All @@ -32,9 +36,11 @@ import { renderFootprint } from "./render/footprint.ts";
import { renderHygiene } from "./render/hygiene.ts";
import { renderNative } from "./render/native.ts";
import { JobResults, renderCheckSection } from "./render/status.ts";
import { renderTraceHelp } from "./render/trace.ts";
import { FootprintReport, type FootprintSide } from "./schema/footprint.ts";
import { HygieneReport } from "./schema/hygiene.ts";
import { NativeReport } from "./schema/native.ts";
import { commitTrace } from "./trace/commit.ts";

export class ChecksFailed extends Schema.TaggedError<ChecksFailed>()(
"ChecksFailed",
Expand Down Expand Up @@ -243,7 +249,7 @@ const reportCommand = Command.make(
Effect.fn("Cli.report")(
function* ({ reports, baseHistory }) {
const fs = yield* FileSystem.FileSystem;
const github = yield* GitHubApi.GitHub;
const currentRun = yield* GitHubApi.CurrentRun;
const env = yield* Config.all({
// Empty for pushes and fork PRs, which get the run summary instead of a comment.
prNumber: Config.String("PR_NUMBER").pipe(Config.withDefault("")),
Expand All @@ -263,7 +269,7 @@ const reportCommand = Command.make(
renderCheckSection(
{ heading, job, skipReason, rendered },
results,
github.runUrl,
currentRun.url,
);

const sections: Array<string> = [
Expand Down Expand Up @@ -347,7 +353,7 @@ const reportCommand = Command.make(
);

const timing = yield* readTiming();
sections.push(renderTiming(timing));
sections.push(renderTiming(timing), renderTraceHelp(env.headSha));
const body = sections.join("\n\n");

if (Option.isSome(env.summary)) {
Expand All @@ -359,7 +365,7 @@ const reportCommand = Command.make(
const result = yield* upsertComment(
Number(env.prNumber),
env.headSha,
fitComment(body, github.runUrl),
fitComment(body, currentRun.url),
);
yield* Console.log(`comment ${result}`);
}
Expand All @@ -373,6 +379,70 @@ const reportCommand = Command.make(
),
);

export class InvalidSha extends Schema.TaggedError<InvalidSha>()("InvalidSha", {
sha: Schema.String,
}) {
override get message() {
return `--sha must be a full 40-character commit SHA, got \`${this.sha}\``;
}
}

const traceCommand = Command.make(
"trace",
{
sha: Flag.String("sha").pipe(
Flag.withDescription("Commit to trace (full SHA)"),
),
repo: Flag.String("repo").pipe(Flag.withDefault("rivet-dev/agents")),
out: Flag.String("out").pipe(
Flag.optional,
Flag.withDescription("Write the OTLP/JSON trace to this file"),
),
otlp: Flag.String("otlp").pipe(
Flag.optional,
Flag.withDescription(
"Send the trace to this OTLP/HTTP endpoint, such as motel at http://127.0.0.1:27686",
),
),
},
Effect.fn("Cli.trace")(function* ({ sha, repo, out, otlp }) {
if (!/^[0-9a-f]{40}$/.test(sha)) return yield* new InvalidSha({ sha });
const fs = yield* FileSystem.FileSystem;
// Locally, fall back to the GitHub CLI's login so no token needs exporting.
const token =
Option.getOrUndefined(
yield* Config.option(Config.Redacted("GITHUB_TOKEN")),
) ?? Redacted.make((yield* run("gh", ["auth", "token"])).trim());
const { runs, document } = yield* Effect.scoped(commitTrace(sha)).pipe(
Effect.provide(GitHubApi.layerFor(repo, token)),
);
const spans = document.resourceSpans[0]?.scopeSpans[0]?.spans.length ?? 0;

// The default stays out of the checkout, where version control would pick it up.
const path = Option.getOrElse(out, () =>
join(tmpdir(), `pipboy-trace-${sha.slice(0, 7)}.json`),
);
yield* fs.writeFileString(path, JSON.stringify(document));
yield* Console.log(`${runs} workflow runs, ${spans} spans → ${path}`);
if (Option.isSome(otlp)) {
const http = (yield* HttpClient.HttpClient).pipe(
HttpClient.filterStatusOk,
);
yield* HttpClientRequest.post(
`${otlp.value.replace(/\/$/, "")}/v1/traces`,
).pipe(
HttpClientRequest.bodyJson(document),
Effect.flatMap(http.execute),
);
yield* Console.log(`sent to ${otlp.value}`);
}
}),
).pipe(
Command.withDescription(
"Build one trace of the CI that ran on a commit: CI, Pip-Boy, and releases",
),
);

export class ReportTooLarge extends Schema.TaggedError<ReportTooLarge>()(
"ReportTooLarge",
{
Expand Down Expand Up @@ -424,6 +494,7 @@ const cli = Command.make("pipboy").pipe(
nativeCommand,
baselineCommand,
reportCommand,
traceCommand,
]),
);

Expand Down
113 changes: 62 additions & 51 deletions scripts/pipboy/src/github/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,8 @@ import {
HttpClientResponse,
} from "effect/http";

/** GitHub Actions context, read from the standard runner environment. */
export const ActionsEnv = Config.all({
token: Config.Redacted("GITHUB_TOKEN"),
repository: Config.String("GITHUB_REPOSITORY"),
runId: Config.String("GITHUB_RUN_ID"),
runAttempt: Config.String("GITHUB_RUN_ATTEMPT").pipe(Config.withDefault("1")),
apiUrl: Config.String("GITHUB_API_URL").pipe(
Config.withDefault("https://api.github.com"),
),
serverUrl: Config.String("GITHUB_SERVER_URL").pipe(
Config.withDefault("https://github.com"),
),
});

export interface Interface {
readonly repository: string;
readonly runId: string;
readonly runAttempt: string;
readonly runUrl: string;
readonly get: <S extends Schema.Constraint>(
path: string,
schema: S,
Expand All @@ -48,39 +31,67 @@ export class GitHub extends Context.Service<GitHub, Interface>()(
"pipboy/GitHub",
) {}

export const layer = Layer.effect(
GitHub,
Effect.gen(function* () {
const env = yield* ActionsEnv;
const http = (yield* HttpClient.HttpClient).pipe(
HttpClient.mapRequest((request) =>
request.pipe(
HttpClientRequest.prependUrl(env.apiUrl),
HttpClientRequest.bearerToken(Redacted.value(env.token)),
HttpClientRequest.setHeader("accept", "application/vnd.github+json"),
HttpClientRequest.setHeader("x-github-api-version", "2022-11-28"),
// GitHub rejects API requests without a User-Agent.
HttpClientRequest.setHeader("user-agent", "rivet-pipboy"),
),
),
HttpClient.filterStatusOk,
HttpClient.retryTransient({ times: 4 }),
);
return GitHub.of({
repository: env.repository,
runId: env.runId,
runAttempt: env.runAttempt,
runUrl: `${env.serverUrl}/${env.repository}/actions/runs/${env.runId}`,
get: (path, schema) =>
http
.get(path)
.pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(schema))),
send: (method, path, body) =>
HttpClientRequest.make(method)(path).pipe(
HttpClientRequest.bodyJson(body),
Effect.flatMap(http.execute),
Effect.asVoid,
/** The current GitHub Actions run, read from the standard runner environment. */
export const CurrentRun = Config.all({
repository: Config.String("GITHUB_REPOSITORY"),
runId: Config.String("GITHUB_RUN_ID"),
runAttempt: Config.String("GITHUB_RUN_ATTEMPT").pipe(Config.withDefault("1")),
serverUrl: Config.String("GITHUB_SERVER_URL").pipe(
Config.withDefault("https://github.com"),
),
}).pipe(
Config.map((run) => ({
...run,
url: `${run.serverUrl}/${run.repository}/actions/runs/${run.runId}`,
})),
);

/** A GitHub API client for `repository`, authenticated with `token` or `GITHUB_TOKEN`. */
export const layerFor = (
repository: string,
explicitToken?: Redacted.Redacted<string>,
) =>
Layer.effect(
GitHub,
Effect.gen(function* () {
const token = explicitToken ?? (yield* Config.Redacted("GITHUB_TOKEN"));
const apiUrl = yield* Config.String("GITHUB_API_URL").pipe(
Config.withDefault("https://api.github.com"),
);
const http = (yield* HttpClient.HttpClient).pipe(
HttpClient.mapRequest((request) =>
request.pipe(
HttpClientRequest.prependUrl(apiUrl),
HttpClientRequest.bearerToken(Redacted.value(token)),
HttpClientRequest.setHeader(
"accept",
"application/vnd.github+json",
),
HttpClientRequest.setHeader("x-github-api-version", "2022-11-28"),
// GitHub rejects API requests without a User-Agent.
HttpClientRequest.setHeader("user-agent", "rivet-pipboy"),
),
),
});
}),
HttpClient.filterStatusOk,
HttpClient.retryTransient({ times: 4 }),
);
return GitHub.of({
repository,
get: (path, schema) =>
http
.get(path)
.pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(schema))),
send: (method, path, body) =>
HttpClientRequest.make(method)(path).pipe(
HttpClientRequest.bodyJson(body),
Effect.flatMap(http.execute),
Effect.asVoid,
),
});
}),
);

/** A client for the repository of the current Actions run. */
export const layer = Layer.unwrap(
Config.String("GITHUB_REPOSITORY").pipe(Effect.map(layerFor)),
);
54 changes: 28 additions & 26 deletions scripts/pipboy/src/github/timing.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Effect, Schema } from "effect";
import { code } from "../render/format.ts";
import { GitHub } from "./api.ts";
import { stepLabel, toMillis } from "../trace/spans.ts";
import { CurrentRun, GitHub } from "./api.ts";

const Step = Schema.Struct({
name: Schema.String,
Expand All @@ -14,6 +15,7 @@ const Job = Schema.Struct({
id: Schema.Number,
name: Schema.String,
conclusion: Schema.NullOr(Schema.String),
created_at: Schema.optionalKey(Schema.String),
started_at: Schema.NullOr(Schema.String),
completed_at: Schema.NullOr(Schema.String),
html_url: Schema.optionalKey(Schema.String),
Expand All @@ -29,35 +31,33 @@ export interface Timing {
readonly now: number;
}

/** Job and step timings of the current run, including still-running jobs. */
export const readTiming = Effect.fn("Timing.read")(function* () {
/** Every job of one run attempt, including still-running jobs. */
export const readJobs = Effect.fn("Timing.readJobs")(function* (
runId: number | string,
attempt: number | string,
) {
const github = yield* GitHub;
const { jobs } = yield* github.get(
`/repos/${github.repository}/actions/runs/${github.runId}/attempts/${github.runAttempt}/jobs?per_page=100`,
Jobs,
);
const jobs: Array<Job> = [];
for (let page = 1; ; page++) {
const batch = yield* github.get(
`/repos/${github.repository}/actions/runs/${runId}/attempts/${attempt}/jobs?per_page=100&page=${page}`,
Jobs,
);
jobs.push(...batch.jobs);
if (batch.jobs.length < 100) return jobs;
}
});

/** Job and step timings of the current run. */
export const readTiming = Effect.fn("Timing.read")(function* () {
const run = yield* CurrentRun;
const jobs = yield* readJobs(run.runId, run.runAttempt);
return {
jobs: jobs.filter((job) => job.started_at !== null),
now: Date.now(),
} satisfies Timing;
});

/**
* Short step names: `Run actions/checkout@<sha>` becomes `checkout`. Rendered
* as code, so GitHub never turns `owner/repo@sha` into a commit link or `@name`
* into a mention.
*/
export function stepLabel(name: string): string {
const action = name.match(
/^(Post )?Run [\w.-]+\/([\w.-]+)(?:\/[\w./-]+)?@\S+$/,
);
if (action) return `${action[1] ? "post " : ""}${action[2]}`;
return name.split("\n")[0] ?? name;
}

const ms = (iso: string | null, fallback: number) =>
iso === null ? fallback : Date.parse(iso);

function duration(totalMs: number): string {
const s = Math.max(0, Math.round(totalMs / 1000));
return s >= 60
Expand All @@ -74,11 +74,13 @@ export function renderTiming(timing: Timing): string {
"|---|---:|---|",
];
for (const job of timing.jobs) {
const start = ms(job.started_at, timing.now);
const start = toMillis(job.started_at, timing.now);
const slowest = [...(job.steps ?? [])]
.map((s) => ({
name: s.name,
ms: ms(s.completed_at, timing.now) - ms(s.started_at, timing.now),
ms:
toMillis(s.completed_at, timing.now) -
toMillis(s.started_at, timing.now),
}))
.sort((a, b) => b.ms - a.ms)[0];
const name =
Expand All @@ -96,7 +98,7 @@ export function renderTiming(timing: Timing): string {
? "⊘ "
: "";
lines.push(
`| ${icon}${name} | ${duration(ms(job.completed_at, timing.now) - start)} | ${slowest ? `${code(stepLabel(slowest.name))} · ${duration(slowest.ms)}` : "—"} |`,
`| ${icon}${name} | ${duration(toMillis(job.completed_at, timing.now) - start)} | ${slowest ? `${code(stepLabel(slowest.name))} · ${duration(slowest.ms)}` : "—"} |`,
);
}
return lines.join("\n");
Expand Down
19 changes: 19 additions & 0 deletions scripts/pipboy/src/render/trace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/** Collapsed comment section: how a maintainer or agent pulls the full CI trace for `sha`. */
export function renderTraceHelp(sha: string): string {
return [
"<details><summary>🔭 Full CI trace for this commit</summary>",
"",
`One OpenTelemetry trace of CI and Pip-Boy for \`${sha.slice(0, 7)}\`: every job and step, with the sizes above attached. Run after CI finishes.`,
"",
"```bash",
"pnpm install --frozen-lockfile --filter 'pipboy...'",
`pnpm --filter pipboy pipboy trace --sha ${sha}`,
"```",
"",
"Add `--otlp <url>` to send it to any OTLP/HTTP collector.",
"",
"**Agent instruction:** From a `rivet-dev/agents` checkout, run the commands above, then read the trace file the last command prints. List the slowest jobs and steps and explain where CI time went.",
"",
"</details>",
].join("\n");
}
Loading
Loading