#!/usr/bin/env -S deno run --allow-net --allow-env --allow-read --allow-write --allow-run=git,unzip,deno /** * Tells a pull request what a later run on the default branch found. * * Selection means some regressions land and the run on the default * branch is what catches them. This follows every such run, works out * what it found that the pull request behind its commit could not have * found for itself, and comments once on that pull request. It runs in * the base-repository context with a write token, which is what lets it * comment at all. * * The repository squash-merges with the pull request number in the * subject, so the pull request behind a commit is unambiguous. A commit * pushed directly, with no pull request behind it, is the ordinary case * for finding none: the commit is logged and nothing is posted. * * Three runs' records are what the notes rest on, and they come from two * places for one reason each. The two runs on the default branch are * read from their own `test-records-*` artifacts: those are readable the * moment a run ends, where the store holds a run's records only once the * relay has shipped them, and two merges landing close together are * exactly when the earlier relay is still running and exactly the case * this exists for. * * The pull request's own run is read from the store instead, because the * relay is where the trust decision about it was made: it ships a fork * run only for a team member, and the store is what that decision * produced. Reading the run's own artifacts would go around the gate and * put a fork's claims about which tests passed into a comment. * * What the run under report did not fail for is read from its own * records, where its lanes wrote each identity they excused. The * pull request's manifest says why its run did not run a test, and the * run under report's supplies the flake counts behind a note about a * test it excused. Each is resolved at the moment the lanes of its run * resolved it: when the commit that run tested was made. * * Environment: * GITHUB_TOKEN - Required. * GITHUB_EVENT_PATH - The workflow_run payload naming the run. * GITHUB_REPOSITORY - Optional, defaults to "commonfabric/labs". * MAIN_REPORT_RUN_ID - Optional, the run to report on in place of the * payload's, for running this by hand. * * `--dry-run` prints what it would say and posts nothing: * * GITHUB_TOKEN=$(gh auth token) MAIN_REPORT_RUN_ID= \ * deno run --allow-net --allow-env --allow-read --allow-write \ * --allow-run=git,unzip,deno tasks/post-main-report.ts --dry-run */ import { join } from "@std/path"; import { coverageArtifactAttempt, type CoverageFigures, coverageFiguresOf, datePartition, type FlakeEvidence, listObjects, parseReportGroups, readObject, RECORD_SCHEMA_VERSION, testIdentityKey, type TestRecord, } from "@commonfabric/test-support/records"; import { ciSubmissionsPrefix, storeBucket } from "./test-records-config.ts"; import { type Artifact, coverageGroupsForChangedFiles, downloadAndExtractArtifact, fetchArtifactsForRun, fetchIssueComments, githubGet, githubPatch, githubPost, isNotFound, type IssueComment, REPO, TOKEN, WORKFLOW_FILE, type WorkflowRun, } from "./ci-check-lib.ts"; import { claimsFor, loadTopology } from "./test-topology.ts"; import type { LocatableRecord, Suite } from "./test-topology/suite.ts"; import { gainedSince, planOver, requestedRun } from "./ci-lane.ts"; import { unknownIdentity } from "./test-selection/census.ts"; import { isLaneMeasurement } from "./lane-measurement.ts"; import { coverageGateIdentity, measuredSetName, reachedSets, } from "./test-topology/coverage-gates.ts"; import { placedOnlyMandatory } from "./test-selection/plan.ts"; import { fetchManifest, type ManifestFetch } from "./test-selection/store.ts"; import type { Manifest, WithheldReason } from "./test-selection/manifest.ts"; import { buildReport, excusedFailures, excusedIn, MAIN_REPORT_MARKER, outcomesOf, type PullRequestView, renderReport, renderWithdrawal, type ReportInput, reportIsEmpty, type RunOutcomes, unknownPullRequest, } from "./test-selection/report.ts"; /** The branch whose runs this reports on. */ export const DEFAULT_BRANCH = "main"; /** * The pull request a squash-merged commit's subject names. Undefined for * a subject naming none, which is what a direct push looks like. * * The number is the last one in the subject rather than the first: a * subject may mention another pull request or an issue, and the merge * number is the one the merge appended. */ export function pullRequestOf(subject: string): number | undefined { const matches = [...subject.matchAll(/\(#(\d+)\)/g)]; const last = matches[matches.length - 1]; return last === undefined ? undefined : Number(last[1]); } /** * The conclusions a run this reads may have, which is every one that * leaves records. * * A run killed at its bound is the wedge case whose surviving records * matter most, and it is the shape a hanging test takes, so it counts * the way the relay counts it. What makes a partial run safe to read is * that every note needs evidence rather than the absence of it: a first * failure needs a failure here and a pass there, a flaky new test needs * a disagreement and an identity the store has never seen, and a rename * needs the departing test's unit to have recorded something. */ const REPORTABLE: ReadonlySet = new Set([ "success", "failure", "cancelled", "timed_out", ]); /** * Whether a run is one this reports on: a push to the default branch, * finished, and of the workflow that runs the tests. */ export function isReportable(run: WorkflowRun): boolean { return run.event === "push" && run.head_branch === DEFAULT_BRANCH && REPORTABLE.has(run.conclusion); } /** * The finished run of the test workflow at one commit, on the branch and * from the event given. * * A commit is asked for by name rather than a listing being walked back * from the run under report. Pushes to the default branch are not * cancelled by their successors, so two of them overlap whenever two * merges land close together, and the run before this one in a listing * of finished runs can be the run two commits back. Comparing against * that one attributes whatever the commit in between broke to this * change, which is the mistake the whole comparison exists to prevent. * * A run still going is not one of these, and neither is a re-run's * earlier attempt: the listing is finished runs, newest first. */ export async function runAt( commit: string, event: "push" | "pull_request", ): Promise { const params = new URLSearchParams({ event, status: "completed", head_sha: commit, per_page: "10", }); if (event === "push") params.set("branch", DEFAULT_BRANCH); const data = await githubGet<{ workflow_runs: WorkflowRun[] }>( `/repos/${REPO}/actions/workflows/${WORKFLOW_FILE}/runs?${params}`, ); return (data.workflow_runs ?? []).find((run) => REPORTABLE.has(run.conclusion) ); } /** * Every record in one extracted `test-records-*` artifact. * * The gather step writes `records.ndjson` last, so an artifact without it * is one whose gathering stopped part way, and it contributes no records. * That is safe where an artifact that could not be downloaded is not. An * artifact cannot change once uploaded, so every report on the run reads * this one the same way, and a run's records are read across all of its * attempts together. The records it lacks can therefore leave a note * unwritten, but cannot withdraw one an earlier report made. */ export async function recordsInDirectory( directory: string, ): Promise { let text: string; try { text = await Deno.readTextFile(join(directory, "records.ndjson")); } catch { return []; } return parseReportGroups(text).flatMap((group) => group.records); } /** * The day partitions one run's objects can be under. * * An object is named for the day its run's attempt started, and a * re-running attempt starts whenever it is asked to, so a run created * one day and re-run the next has its attempts under two days. Every day * from the run's creation to its latest attempt is listed, which is one * small prefixed request each. */ export function runPartitions(run: WorkflowRun): string[] { const days: string[] = []; const from = Date.parse(run.created_at); const to = Date.parse(run.run_started_at ?? run.created_at); if (Number.isNaN(from)) return days; const last = Number.isNaN(to) ? from : Math.max(from, to); for (let at = from; at <= last + 86_400_000; at += 86_400_000) { const day = datePartition(new Date(at).toISOString()); if (!days.includes(day)) days.push(day); if (day === datePartition(new Date(last).toISOString())) break; } return days; } /** * What every test in one run did, from the store, and the commit it did * it at. Undefined where the store holds nothing for it, because a run * whose records never arrived is a run nothing is known about, and * reading it as a run that skipped everything is what makes a test it ran * look unrun. */ export async function outcomesFromStore( run: WorkflowRun, ): Promise { const bucket = storeBucket(); const records: TestRecord[] = []; const commits = new Set(); let objects = 0; for (const day of runPartitions(run)) { const prefix = `${ciSubmissionsPrefix()}/v${RECORD_SCHEMA_VERSION}/` + `${day}/run-${run.id}-`; for (const objectName of await listObjects({ bucket, prefix })) { objects++; const object = await readObject({ bucket, objectName }); for (const record of object.records) records.push(record); for (const report of object.reports) { if (report.context !== undefined) commits.add(report.context.commit); } } } if (objects === 0) return undefined; const commit = commits.size === 1 ? [...commits][0] : undefined; return { outcomes: outcomesOf(records), laned: records.some((record) => isLaneMeasurement(record.test)), ...(commit === undefined ? {} : { commit }), }; } /** What one run recorded, as the store holds it. */ export interface StoredRun { outcomes: RunOutcomes; /** * Whether the run's tests ran in lanes, which is whether a manifest * decided what it ran: a lane measures itself, and nothing else writes * those records. */ laned: boolean; /** * The commit every report of the run names as the one its tests ran * against. For a pull request's run that is the merge the * continuous-integration provider built, not the branch tip. Absent * where the reports name several, since none of them is then the commit * the run tested. */ commit?: string; } /** * What every test in one run did, from the run's own artifacts. * * Every `test-records-*` artifact counts, including a re-run attempt's, * so an identity that passed in one attempt and failed in another folds * into the test disagreeing with itself rather than into whichever * attempt was uploaded last. The coverage artifact counts too, since it * holds the record of each coverage gate the run judged; the coverage * figures beside them are measurements, which `outcomesOf` passes over and * `coverageOfRun` reads. Undefined where one of them could not be read, * because a run read in part reads as a run that ran less. */ export async function outcomesFromArtifacts( runId: number, listed?: readonly Artifact[], ): Promise { const reports = await reportsFromArtifacts(runId, listed); return reports === undefined ? undefined : outcomesOf(reports.flat()); } /** * The records in each of one run's `test-records-*` artifacts, as * `outcomesFromArtifacts` reads them, or nothing where one of them could * not be read. Each artifact is one job's attempt, and so one report. */ export async function reportsFromArtifacts( runId: number, listed?: readonly Artifact[], ): Promise { const artifacts = (listed ?? await fetchArtifactsForRun(runId)) .filter((artifact) => artifact.name.startsWith("test-records-") && !artifact.expired ); const reports: TestRecord[][] = []; for (let at = 0; at < artifacts.length; at += ARTIFACTS_AT_ONCE) { const batch = artifacts.slice(at, at + ARTIFACTS_AT_ONCE); for (const read of await Promise.all(batch.map(readArtifact))) { // One artifact this could not read is a share of the run missing // from what would otherwise read as the whole of it. Every test in // it would look unrun, and a report built on that would withdraw // one an earlier attempt correctly made. if (read === undefined) { console.log( `Run ${runId}: an artifact could not be read, so what it ran is ` + "not known.", ); return undefined; } reports.push(read); } } return reports; } /** * How many artifacts are downloaded at once. A run carries one per test * job, and reading two runs one artifact at a time is most of what this * spends its time on. */ const ARTIFACTS_AT_ONCE = 8; /** One artifact's records, or nothing where it could not be read. */ async function readArtifact( artifact: Artifact, ): Promise { const directory = await downloadAndExtractArtifact( artifact.id, "test-records-", ); if (directory === null) return undefined; try { return await recordsInDirectory(directory); } finally { await Deno.remove(directory, { recursive: true }).catch(() => {}); } } /** * Every coverage figure one run measured, from the newest of its coverage * artifacts, since a re-run attempt measures the run again. Artifact ids * grow with upload. */ export async function coverageOfRun( runId: number, listed?: readonly Artifact[], ): Promise { const artifact = (listed ?? await fetchArtifactsForRun(runId)) .filter((candidate) => coverageArtifactAttempt(candidate.name) !== undefined && !candidate.expired ) .sort((a, b) => b.id - a.id)[0]; const records = artifact === undefined ? [] : await readArtifact(artifact); return coverageFiguresOf(records ?? []); } /** * The coverage figures a push run at `commit` measured, from the first * upload of the earliest such run to make one, and when that run was * created; nothing where no run has, or where what one uploaded cannot be * read. * * A run is read whatever its status. Its coverage artifact is uploaded * whole at the end of its `Status` job, so it is there, and complete, from * then on, while the run goes on attesting and deploying. The earliest * upload rather than the newest, so that every reader asking once one * exists reads the same figures however often the commit is pushed or its * run attempted again. An earlier run is passed over only once it has * finished without uploading, since one still going may yet upload, and * a reader taking a later run's figures meanwhile would read different * figures from one asking after it did. */ export async function firstCoverageAt( commit: string, ): Promise<{ figures: CoverageFigures; startedAt: string } | undefined> { const listed: WorkflowRun[] = []; const perPage = 100; for (let page = 1;; page++) { const params = new URLSearchParams({ event: "push", branch: DEFAULT_BRANCH, head_sha: commit, per_page: `${perPage}`, page: `${page}`, }); const data = await githubGet<{ workflow_runs: WorkflowRun[] }>( `/repos/${REPO}/actions/workflows/${WORKFLOW_FILE}/runs?${params}`, ); const found = data.workflow_runs ?? []; for (const run of found) listed.push(run); if (found.length < perPage) break; } const runs = listed.toSorted((a, b) => a.id - b.id); for (const run of runs) { const artifact = (await fetchArtifactsForRun(run.id)) .filter((candidate) => coverageArtifactAttempt(candidate.name) !== undefined && !candidate.expired ) .sort((a, b) => a.id - b.id)[0]; if (artifact === undefined) { if (run.status === "completed") continue; return undefined; } const records = await readArtifact(artifact); if (records === undefined) return undefined; return { figures: coverageFiguresOf(records), startedAt: run.created_at, }; } return undefined; } /** * What the reporter reads from outside itself. * * A caller that supplies one of these is saying what is there — what the * checkout holds, what the tree declares, what the store has — which is * how everything the reporter does with those answers is exercised * without a checkout, a tree, or a store to read. */ export interface ReportDeps { /** Runs git in the checkout. */ git?: (...args: string[]) => Promise; /** The suites the working tree declares. */ topology?: () => Promise; /** The manifest the store holds at a moment. */ manifest?: (options: { at: string }) => Promise; } /** Runs git in the checkout and returns what it printed. */ export async function runGit(...args: string[]): Promise { const { code, stdout, stderr } = await new Deno.Command("git", { args, stdout: "piped", stderr: "piped", }).output(); if (code !== 0) { throw new Error( `git ${args.join(" ")} failed: ${new TextDecoder().decode(stderr)}`, ); } return new TextDecoder().decode(stdout); } /** What the report reads of one pull request. */ export interface PullRequestFacts { /** The commit at the head of its branch, which its runs are listed under. */ head: string; /** * The labels it carries, which say how much its runs ran. They are the * labels its last run read unless somebody changed them since. */ labels: string[]; } /** * The head of one pull request's branch, and its labels. * * Undefined when the pull request cannot be read. The report is worth * more with a note saying its own run could not be read than it is worth * not being written, so this is one place the reporter carries on * without an answer. */ export async function pullRequestFacts( pullRequest: number, ): Promise { try { const info = await githubGet<{ head: { sha: string }; labels?: { name: string }[]; }>(`/repos/${REPO}/pulls/${pullRequest}`); return { head: info.head.sha, labels: (info.labels ?? []).map((label) => label.name), }; } catch (error) { // A number in a commit subject may name an issue rather than a pull // request, and an issue takes comments just as a pull request does. // Nothing is posted to one. if (isNotFound(error)) return "absent"; console.warn(` Warning: could not read PR #${pullRequest}: ${error}`); return undefined; } } /** * The moment a commit was made, as its committer date in UTC, which is * the moment the lanes testing that commit resolve their manifest at. * Undefined where the commit or its date cannot be read. */ export async function committedAt(commit: string): Promise { try { const found = await githubGet<{ commit: { committer: { date: string } } }>( `/repos/${REPO}/commits/${commit}`, ); const at = Date.parse(found.commit.committer.date); return Number.isNaN(at) ? undefined : new Date(at).toISOString(); } catch (error) { console.warn(` Warning: could not read commit ${commit}: ${error}`); return undefined; } } /** * The manifest a pull request's own run resolved, or why there is none * to read. * * Its lanes resolved it at the moment the commit they tested was made, * and the commit a pull request's run tests is the merge its records * name, which is made later than the branch tip. A manifest published * between the two is one those lanes read and the tip's moment misses, so * the moment is that merge's. A run that names no one commit, or whose * commit's date cannot be read, has no moment to stand behind, and no * manifest is resolved for it: one resolved at any other moment would * explain the run's selection from a manifest it may never have read. * Nor is one resolved for a run whose tests did not run in lanes, since * no manifest decided what that run ran. */ async function manifestOfTheirRun( ran: StoredRun | undefined, resolve: (options: { at: string }) => Promise, ): Promise { if (ran === undefined) { return { absent: "the store holds no records of the pull request's run" }; } if (!ran.laned) { return { absent: "the pull request's own run did not run in lanes, so no " + "manifest decided what it ran", }; } if (ran.commit === undefined) { return { absent: "the pull request's own run names no commit it tested" }; } const at = await committedAt(ran.commit); if (at === undefined) { return { absent: `the date of ${ran.commit} could not be read` }; } return await resolve({ at }); } /** * What the packing over one manifest reaches, worked out the way a lane * works it out: this tree read against that manifest, packed. * * Reading the tree is what makes this the answer a lane would give * rather than a summary of the manifest, because the capabilities a * suite opens are most of what a lane's budget goes on and the manifest * holds neither the suites nor which units still exist. `run` is what the * pull request's labels asked its run to be: the full one, or a budgeted * one at a scale. * * `recorded` is what the run under report recorded. The tree places each * of its records the manifest has no identity for in a unit, since such * a test ran on the pull request wherever its unit did. */ export function manifestView( manifest: Manifest, suites: readonly Suite[], changed: ReadonlySet, landed: ReadonlySet, recorded: Iterable, run: { full: boolean; scale: number }, ): Omit { // A pull request's run, packed into as many lanes as it would take. const packed = planOver({ suites, manifest, changed, landed, ...run }).laid; const selected = new Set(); const placed = new Set(); for (const lane of packed.lanes) { for (const selection of lane.selections) { selected.add(testIdentityKey(selection.entry.test)); placed.add(`${selection.entry.suite}\t${selection.entry.unit}`); } } // A gate is in no lane, and the packing chose it all the same. for (const gate of packed.gates) { selected.add(testIdentityKey(gate.entry.test)); } const withheld = new Map(); for (const entry of packed.withheld) { withheld.set(testIdentityKey(entry.test), entry.reason); } const unschedulable = new Set( packed.unschedulable.map((entry) => testIdentityKey(entry.test)), ); const heldWithUnit = new Set(packed.heldWithUnit.map(testIdentityKey)); const flakes = new Map(); const newRealTestFailures = new Map(); const units = new Map(); for (const entry of manifest.entries) { const key = testIdentityKey(entry.test); flakes.set(key, entry.flakeEvidence); newRealTestFailures.set(key, entry.inputs.catches); units.set(key, `${entry.suite}\t${entry.unit}`); } const heldUnits = new Set( [...heldWithUnit].flatMap((key) => units.get(key) ?? []), ); for (const record of recorded) { const key = testIdentityKey(record.test); if (units.has(key)) continue; const [claim, ...others] = claimsFor(suites, record); if (claim?.unit === undefined || others.length > 0) continue; const unit = `${claim.suite.id}\t${claim.unit}`; units.set(key, unit); // A unit that runs whole runs this test as well, so a unit held back // for a flaky test in it held this one back too. if (heldUnits.has(unit)) heldWithUnit.add(key); // A unit nothing has measured is packed as one stand-in, so no lane // being able to hold the stand-in holds of every test in the unit. const standing = testIdentityKey(unknownIdentity(claim.suite, claim.unit)); if (unschedulable.has(standing)) unschedulable.add(key); } return { manifest: true, selected, placed, withheld, heldWithUnit, unschedulable, crowded: placedOnlyMandatory(packed), flakes, newRealTestFailures, units, }; } /** * The flake counts the manifest a run on the default branch resolved * carries for each of the identities given. * * The run's lanes resolved it at the moment the commit they tested was * made, which the checkout holds. The counts only label a note, so * failing to read either the moment or the manifest leaves the note * without them rather than stopping the report. */ async function flakeCountsAt( commit: string, keys: Iterable, git: (...args: string[]) => Promise, resolve: (options: { at: string }) => Promise, ): Promise> { const counts = new Map( [...keys].map((key) => [key, undefined]), ); let at: number; try { at = Date.parse((await git("log", "-1", "--format=%cI", commit)).trim()); } catch (error) { console.warn(` Warning: could not read when ${commit} was made: ${error}`); return counts; } if (Number.isNaN(at)) { console.warn(` Warning: ${commit} carries no usable date.`); return counts; } const fetched = await resolve({ at: new Date(at).toISOString() }); if (fetched.manifest === undefined) { console.log(`No manifest for the run at ${commit}: ${fetched.absent}`); return counts; } for (const entry of fetched.manifest.entries) { const key = testIdentityKey(entry.test); if (counts.has(key)) counts.set(key, entry.flakeEvidence); } return counts; } /** * The report this run left on a pull request, when it left one. * * The marker is what finds it, and the author is what keeps this from * editing somebody else's comment that happens to quote the marker: the * token this runs under may edit any comment on the pull request, and * every review app on it writes as a bot. */ function ownReport( comments: readonly IssueComment[], ): IssueComment | undefined { return comments.find((comment) => comment.body.includes(MAIN_REPORT_MARKER) && comment.author === WORKFLOW_AUTHOR ); } /** The login every comment a workflow token posts is written under. */ const WORKFLOW_AUTHOR = "github-actions[bot]"; /** Posts the comment, or edits the one already there. */ export async function postReport( pullRequest: number, body: string, ): Promise { const marked = ownReport(await fetchIssueComments(pullRequest)); if (marked === undefined) { await githubPost(`/repos/${REPO}/issues/${pullRequest}/comments`, { body }); console.log(`Posted the run report to PR #${pullRequest}.`); return; } if (marked.body === body) { console.log(`The run report on PR #${pullRequest} already says this.`); return; } await githubPatch(`/repos/${REPO}/issues/comments/${marked.id}`, { body }); console.log(`Updated the run report on PR #${pullRequest}.`); } /** Withdraws a report an earlier attempt left, when there is one. */ export async function withdrawReport( pullRequest: number, body: string, ): Promise { const marked = ownReport(await fetchIssueComments(pullRequest)); if (marked === undefined || marked.body === body) return; await githubPatch(`/repos/${REPO}/issues/comments/${marked.id}`, { body }); console.log(`Withdrew the run report on PR #${pullRequest}.`); } /** The run this was asked to report on. */ async function runUnderReport(): Promise { const override = Deno.env.get("MAIN_REPORT_RUN_ID"); if (override !== undefined && override.length > 0) { if (!/^\d+$/.test(override)) { throw new Error(`MAIN_REPORT_RUN_ID is not a run id: ${override}`); } return await githubGet( `/repos/${REPO}/actions/runs/${override}`, ); } const path = Deno.env.get("GITHUB_EVENT_PATH"); if (path === undefined) return undefined; const event = JSON.parse(await Deno.readTextFile(path)) as { workflow_run?: WorkflowRun; }; return event.workflow_run; } export async function main( dryRun = false, deps: ReportDeps = {}, ): Promise { const git = deps.git ?? runGit; const run = await runUnderReport(); if (run === undefined || !isReportable(run)) { console.log( `Nothing to report on: ${ run === undefined ? "no run in the event" : `run ${run.id} is a ` + `${run.conclusion} ${run.event} on ${run.head_branch}` }.`, ); return; } const commit = run.head_sha; const subject = (await git("log", "-1", "--format=%s", commit)).trim(); const pullRequest = pullRequestOf(subject); if (pullRequest === undefined) { console.log( `${commit} has no pull request behind it: ${subject}. Nothing to post.`, ); return; } const parent = (await git("rev-parse", `${commit}^`)).trim(); const previousRun = await runAt(parent, "push"); if (previousRun === undefined) { console.log( `${parent} has no finished run on ${DEFAULT_BRANCH}, so nothing at ` + `${commit} can be attributed to it.`, ); return; } const listedThere = await fetchArtifactsForRun(previousRun.id); const previous = await outcomesFromArtifacts(previousRun.id, listedThere); if (previous === undefined || previous.size === 0) { console.log( `Nothing readable from run ${previousRun.id}, so there is nothing to ` + "compare against.", ); return; } const listedHere = await fetchArtifactsForRun(run.id); const reportsHere = await reportsFromArtifacts(run.id, listedHere); const current = outcomesOf(reportsHere?.flat() ?? []); if (reportsHere === undefined || current.size === 0) { console.log( `Nothing readable from run ${run.id}, so there is nothing to say.`, ); return; } const changed = new Set( (await git("diff", "--name-only", parent, commit)) .split("\n").map((line) => line.trim()).filter((line) => line.length > 0), ); const manifestAt = deps.manifest ?? fetchManifest; const facts = await pullRequestFacts(pullRequest); if (facts === "absent") { console.log( `${commit}'s subject names #${pullRequest}, which is not a pull ` + "request. Nothing to post.", ); return; } const suites = await (deps.topology ?? loadTopology)(); let view: PullRequestView = unknownPullRequest(); if (facts !== undefined) { const theirRun = await runAt(facts.head, "pull_request"); const ran = theirRun === undefined ? undefined : await outcomesFromStore(theirRun); const fetched = await manifestOfTheirRun(ran, manifestAt); if (fetched.manifest === undefined) { console.log(`No manifest for PR #${pullRequest}: ${fetched.absent}`); } // Labels that ask for no run this can read leave no plan to explain // what the run left out by. const requested = requestedRun(facts.labels); if (!requested.ok) { console.log(`No plan for PR #${pullRequest}: ${requested.problem}`); } view = { ...(fetched.manifest === undefined || !requested.ok ? unknownPullRequest() : manifestView( fetched.manifest, suites, changed, await gainedSince(git, fetched.manifest.commit, parent), reportsHere.flat(), requested, )), ...(ran === undefined ? {} : { ran: ran.outcomes }), }; } // The same declarations the gate reads, and the same packing its plan // chose gates by, so the note about a rise and the gate that was // supposed to catch it cannot disagree about which sets were measured. const reached = reachedSets(suites, changed); const uncounted: ReportInput = { current, previous, pullRequest: view, nonGating: new Map( [...excusedIn(reportsHere)].map((key) => [key, undefined]), ), coverage: await coverageOfRun(run.id, listedHere), coverageBefore: await coverageOfRun(previousRun.id, listedThere), touched: coverageGroupsForChangedFiles(changed), coverageGate: { reached: reached.map(measuredSetName), // A gate the pull request's run recorded ran there, whatever packing // chose it; a run of every test chooses every gate, which the plan // this packs for a pull request does not. ...(view.manifest ? { chosen: reached .filter((ref) => { const key = testIdentityKey(coverageGateIdentity(ref)); return view.selected.has(key) || view.ran?.has(key) === true; }) .map(measuredSetName), } : {}), }, day: new Date().toISOString().slice(0, 10), }; // The store's counts label only the note about a test the run excused, // so the manifest they come from is read only where there is one. const input: ReportInput = excusedFailures(uncounted).length === 0 ? uncounted : { ...uncounted, nonGating: await flakeCountsAt( commit, uncounted.nonGating.keys(), git, manifestAt, ), }; console.log( `Run ${run.id} judged ${input.current.size} identities, run ` + `${previousRun.id} at ${parent.slice(0, 12)} judged ` + `${input.previous.size}, and PR #${pullRequest}'s own run judged ` + `${view.ran?.size ?? 0}.`, ); const context = { commit, runUrl: run.html_url }; const report = buildReport(input); const body = reportIsEmpty(report) ? undefined : renderReport(report, context); if (dryRun) { console.log( body === undefined ? `Would withdraw any report on PR #${pullRequest}.` : `Would comment on PR #${pullRequest}:\n\n${body}`, ); return; } if (body === undefined) { await withdrawReport(pullRequest, renderWithdrawal(context)); return; } await postReport(pullRequest, body); } if (import.meta.main) { if (!TOKEN) { console.error("GITHUB_TOKEN is required."); Deno.exit(1); } // Best-effort, exactly as the coverage comment is: a comment nobody // gates on must never turn a run red, and least of all a run on the // default branch that has already passed. try { await main(Deno.args.includes("--dry-run")); } catch (error) { // An annotation rather than a line in the log, so a reporter that // stopped is visible on the run rather than only to whoever opens it. console.log(`::warning title=Run report::${error}`); } }