diff --git a/CHANGELOG.md b/CHANGELOG.md index 29fb677..82d5c38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,29 @@ All notable changes to **basou** are recorded here. The project follows empty match the declaration could not see. The keys of the value are basou's words, so `basou board record`'s check for the names of other workspaces looks at what they hold, not at them. +- **A board can observe a check run by its name (`github_check`, from + `board_version: 3`, experimental).** A `github_check` observation names a + GitHub repository, the name of a check run (`check`: one a GitHub App + reports, such as a deploy, as well as a job of a workflow) and a `branch` + (`main` when it names none). Its value is the conclusion of the newest + check run of that name on the head commit of the branch, or its status + while it has not finished; `github_ci`, which takes the file name of a + workflow, looks at the latest run of the workflow on the branch instead. + basou does not make it and sends nothing: `basou board guide` gives a + `gh api` command that asks for the check runs of `heads/` and + takes the one with the highest id (the API lists one for each check suite + that ran the check, in an order it does not document), and prints a second + line when there are more than the 100 it reads, which the guide says to + put in a footnote. No check run of that name on the head commit, as when + a path filter, `[skip ci]` or the app's own rules kept the check from + starting, is a null with that as the error; a job a workflow skipped with + `if:` is a check run concluded `skipped`. A check name that is empty, + longer than 255 characters, holds a control character or a `'`, or starts + with `-` is refused. +- **Every observation can say what to mind when making it (`notes`, from + `board_version: 3`, experimental),** in the shape of a stage's or a lane's + `notes`. `basou board guide` prints them under the observation's commands, + and a record keeps them with the declaration it holds. ### Changed @@ -44,6 +67,10 @@ All notable changes to **basou** are recorded here. The project follows version reads is told so on a line of its own (`observe[0].kind: 'http' is read from board_version 3 on; this file is board_version 2`) for a kind or a value as for a key: until now only a key was told so. +- **A branch a `github_ci` or `github_check` observation names may not have + a part that starts with `.`** (in a board of any version). git takes no + such branch, and GitHub reads `a/./b` in the path of a request as `a/b`, + another branch. ## 0.70.1 — 2026-10-17 diff --git a/packages/cli/src/commands/board.test.ts b/packages/cli/src/commands/board.test.ts index 6ca6b5a..b7b6f81 100644 --- a/packages/cli/src/commands/board.test.ts +++ b/packages/cli/src/commands/board.test.ts @@ -1021,6 +1021,51 @@ describe("basou board record", () => { }); }); + const check = { + key: "deploy", + kind: "github_check", + repo: "owner/name", + check: "Workers Builds: app", + notes: ["a commit that changes only docs starts no build"], + }; + + it("tells a version 2 board that observes a check run, or has notes, which version reads it", async () => { + const board = JSON.parse(boardYaml([MD])); + board.board_version = 2; + board.observe = [check]; + const repo = await workspace([{ path: ".", visibility: "private" }]); + await placeBoard(repo, JSON.stringify(board)); + const { err } = capture(); + await runBoardMeasure({}, ctx(repo)); + expect(err.join("\n")).toContain( + "observe[0].kind: 'github_check' is read from board_version 3 on; this file is board_version 2", + ); + expect(process.exitCode).toBe(1); + }); + + it("records a version 3 board that observes a check run, keeping its notes", async () => { + const board = JSON.parse(boardYaml([MD])); + board.board_version = 3; + board.observe = [check]; + const { repo, input } = await judged(undefined, JSON.stringify(board)); + const observed = { + deploy: { + value: "success", + observed_at: "2026-10-17T00:00:00Z", + source: + "gh api -X GET repos/owner/name/commits/heads/main/check-runs -f check_name=Workers Builds: app", + }, + }; + const result = await doRunBoardRecord({}, fed(repo, { ...input, observed })); + const written = JSON.parse(await readFile(join(repo, result.record ?? ""), "utf8")); + expect(written).toMatchObject({ + record_version: 3, + measure: { board_version: 3 }, + observed, + declaration: { observe: [{ ...check, branch: "main" }] }, + }); + }); + it("checks and measures but writes nothing with --dry-run, and reports as JSON", async () => { const { repo, input } = await judged(); const { out } = capture(); diff --git a/packages/core/src/board/declaration.test.ts b/packages/core/src/board/declaration.test.ts index 1b3334e..5188cd6 100644 --- a/packages/core/src/board/declaration.test.ts +++ b/packages/core/src/board/declaration.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from "vitest"; import { stringify } from "yaml"; -import { type BoardDeclarationResult, parseBoardDeclaration } from "./declaration.js"; +import { + BOARD_OBSERVE_KINDS, + type BoardDeclarationResult, + parseBoardDeclaration, +} from "./declaration.js"; const REPOS = [".", "../basou"]; @@ -1391,6 +1395,9 @@ describe("parseBoardDeclaration: board_version 2", () => { { key: "j", kind: "github_ci", repo: "o/n", workflow: "q.yml", branch: "v1." }, { key: "k", kind: "github_ci", repo: "o/n", workflow: "Q.YML" }, { key: "l", kind: "github_ci", repo: "o/n", workflow: `${"w".repeat(252)}.yml` }, + { key: "m", kind: "github_ci", repo: "o/n", workflow: "q.yml", branch: "a/./b" }, + { key: "n", kind: "github_ci", repo: "o/n", workflow: "q.yml", branch: "release/.x" }, + { key: "o", kind: "github_ci", repo: "o/n", workflow: "q.yml", branch: "a.b/c.d" }, ]; const workflow = "must be the file name of a workflow, such as quality.yml"; const branch = @@ -1407,6 +1414,8 @@ describe("parseBoardDeclaration: board_version 2", () => { `observe[9].branch: ${branch}`, `observe[10].workflow: ${workflow}`, `observe[11].workflow: ${workflow}`, + `observe[12].branch: ${branch}`, + `observe[13].branch: ${branch}`, ]); }); @@ -1660,3 +1669,141 @@ describe("parseBoardDeclaration: board_version 3 and http", () => { ]); }); }); + +// A version 3 board whose only observation is a github_check with `over`. +function withCheck(over: Record): Record { + return { + ...boardV2(), + board_version: 3, + observe: [{ key: "deploy", kind: "github_check", repo: "owner/name", ...over }], + }; +} + +describe("parseBoardDeclaration: board_version 3, github_check and an observation's notes", () => { + it("accepts a check run by its name, filling the default branch, and a branch of its own", () => { + const names = [ + "Workers Builds: my-app", + "Quality checks (typecheck / build / test / lint)", + 'a`b $(c) "d" ; e', + "\u30c7\u30d7\u30ed\u30a4", + "c".repeat(255), + // Characters, not UTF-16 code units: each of these is two. + "\u{1F680}".repeat(255), + ]; + for (const check of names) { + const result = parse(withCheck({ check })); + if (!result.ok) throw new Error(result.errors.join("\n")); + expect(result.declaration.observe).toEqual([ + { key: "deploy", kind: "github_check", repo: "owner/name", check, branch: "main" }, + ]); + } + const result = parse(withCheck({ check: "deploy", branch: "release/1.x" })); + if (!result.ok) throw new Error(result.errors.join("\n")); + expect(result.declaration.observe[0]).toMatchObject({ branch: "release/1.x" }); + }); + + it("refuses a check name a command could misread, too long, or missing", () => { + const at = (check: unknown) => errorsOf(parse(withCheck({ check }))); + expect(at("")).toEqual(["observe[0].check: must be a non-empty string"]); + expect(at("-deploy")).toEqual(["observe[0].check: must not start with '-'"]); + expect(at("it's")).toEqual([`observe[0].check: must not contain "'"`]); + expect(at("a\tb")).toEqual(["observe[0].check: must not contain control characters"]); + expect(at("a\u009bb")).toEqual(["observe[0].check: must not contain control characters"]); + expect(at("c".repeat(256))).toEqual(["observe[0].check: must be at most 255 characters"]); + expect(at("\u{1F680}".repeat(256))).toEqual([ + "observe[0].check: must be at most 255 characters", + ]); + expect(at(1)).toEqual(["observe[0].check: Invalid input: expected string, received number"]); + expect(errorsOf(parse(withCheck({})))).toEqual([ + "observe[0].check: Invalid input: expected string, received undefined", + ]); + }); + + it("holds the repo and the branch to github_ci's shapes, and takes no workflow", () => { + expect(errorsOf(parse(withCheck({ check: "x", repo: "name" })))).toEqual([ + "observe[0].repo: must be a GitHub repository written as owner/name", + ]); + for (const branch of ["-x", "a/./b", "release/.x"]) { + expect(errorsOf(parse(withCheck({ check: "x", branch })))).toEqual([ + "observe[0].branch: must be a branch name such as main, using only A-Z, a-z, 0-9, '.', '_', '-' and '/'", + ]); + } + expect(parse(withCheck({ check: "x", branch: "a.b/c.d" })).ok).toBe(true); + expect(errorsOf(parse(withCheck({ check: "x", workflow: "q.yml" })))).toEqual([ + "observe[0]: unknown key 'workflow'", + ]); + const ci = withCheck({}); + ci.observe = [{ key: "ci", kind: "github_ci", repo: "o/n", workflow: "q.yml", check: "x" }]; + expect(errorsOf(parse(ci))).toEqual(["observe[0]: unknown key 'check'"]); + }); + + it("takes notes on every kind of observation, kept as written", () => { + const notes = ["404 is right while it is sold out", "`ask` first"]; + const doc: Record = { + ...boardV2(), + board_version: 3, + observe: [ + ...(boardV2().observe as Record[]), + { key: "deploy", kind: "github_check", repo: "owner/name", check: "deploy" }, + { key: "shop", kind: "http", url: "https://example.com/" }, + ].map((o) => ({ ...o, notes })), + }; + const result = parse(doc); + if (!result.ok) throw new Error(result.errors.join("\n")); + expect(result.declaration.observe.map((o) => o.kind).sort()).toEqual( + [...BOARD_OBSERVE_KINDS].sort(), + ); + for (const o of result.declaration.observe) expect(o.notes).toEqual(notes); + // An observation without notes has none. + const bare = parse(withCheck({ check: "x" })); + if (!bare.ok) throw new Error(bare.errors.join("\n")); + expect(bare.declaration.observe[0]).not.toHaveProperty("notes"); + }); + + it("refuses notes that are not a list of non-empty strings", () => { + expect(errorsOf(parse(withCheck({ check: "x", notes: "mind" })))).toEqual([ + "observe[0].notes: Invalid input: expected array, received string", + ]); + expect(errorsOf(parse(withCheck({ check: "x", notes: ["", " ", 1] })))).toEqual([ + "observe[0].notes[0]: must be a non-empty string", + "observe[0].notes[1]: must be a non-empty string", + "observe[0].notes[2]: Invalid input: expected string, received number", + ]); + }); + + it("names github_check among the kinds a version 3 board reads", () => { + expect(errorsOf(parse(withCheck({ kind: "rss_feed" })))).toEqual([ + "observe[0].kind: Invalid discriminator value. Expected 'npm_version' | 'github_release' | 'github_open_issues' | 'github_open_prs' | 'github_ci' | 'github_check' | 'page_version' | 'http' | 'manual'", + ]); + }); + + it("says that version 3 reads github_check and notes when version 2 or 1 has them", () => { + const doc = boardV2(); + doc.observe = [ + { key: "a", kind: "github_check", repo: "owner/name", check: "deploy" }, + { key: "b", kind: "npm_version", package: "x", notes: ["mind"] }, + { key: "c", kind: "manual", how: "ask", notes: [""] }, + { key: "d", kind: "github_check", repo: "owner/name", check: "-x" }, + { key: "e", kind: "manual", how: "ask", notes: ["mind"], colour: "red" }, + ]; + expect(errorsOf(parse(doc))).toEqual([ + "observe[0].kind: 'github_check' is read from board_version 3 on; this file is board_version 2", + "observe[1]: 'notes' is read from board_version 3 on; this file is board_version 2", + "observe[2]: 'notes' is read from board_version 3 on; this file is board_version 2", + "observe[3].kind: 'github_check' is read from board_version 3 on; this file is board_version 2", + "observe[4]: unknown key 'colour'", + "observe[4]: 'notes' is read from board_version 3 on; this file is board_version 2", + ]); + const v1 = board(); + for (const observe of [ + [{ key: "a", kind: "github_check", repo: "owner/name", check: "deploy" }], + [{ key: "a", kind: "manual", how: "ask", notes: ["mind"] }], + [{ key: "a", kind: "manual", how: "ask", notes: [""] }], + ]) { + v1.observe = observe; + expect(errorsOf(parse(v1))).toEqual([ + "(top level): 'observe' is read from board_version 3 on; this file is board_version 1", + ]); + } + }); +}); diff --git a/packages/core/src/board/declaration.ts b/packages/core/src/board/declaration.ts index cfb8416..061cb61 100644 --- a/packages/core/src/board/declaration.ts +++ b/packages/core/src/board/declaration.ts @@ -17,7 +17,8 @@ import { TaskStatusSchema } from "../schemas/task.schema.js"; * Every version ever written is read: 1; 2, which adds what to observe * outside basou (`observe`) and what to look at when judging a stage * (`stages..look` and `.notes`); and 3, which adds the response to an - * HTTP GET as an observation (`http`). + * HTTP GET (`http`) and a check run by its name (`github_check`) as + * observations, and what to mind when making any observation (`notes`). */ export const BOARD_VERSION = 3; @@ -266,14 +267,18 @@ export const BOARD_OBSERVE_KINDS = [ "github_open_issues", "github_open_prs", "github_ci", + "github_check", "page_version", "http", "manual", ] as const; -/** The branch a `github_ci` observation looks at when it names none. */ +/** The branch a `github_ci` or `github_check` observation looks at when it names none. */ export const BOARD_DEFAULT_CI_BRANCH = "main"; +/** How many characters a `github_check` observation's check name may have. */ +export const BOARD_CHECK_NAME_MAX = 255; + /** How many redirects an `http` observation follows; one that needs more is not made. */ export const BOARD_HTTP_MAX_REDIRECTS = 10; @@ -313,7 +318,9 @@ const workflowFile = z error: "must be the file name of a workflow, such as quality.yml", }); -// A branch name without what a shell or git would read as something else. +// A branch name without what a shell or git would read as something else. No +// part of it starts with '.': git takes no such branch, and GitHub reads +// `a/./b` in a path as `a/b`, another branch. const branchName = z .string() .refine( @@ -321,6 +328,7 @@ const branchName = z /^[A-Za-z0-9_][A-Za-z0-9._/-]*$/.test(s) && !s.includes("..") && !s.includes("//") && + !s.includes("/.") && !/[/.]$/.test(s), { error: "must be a branch name such as main, using only A-Z, a-z, 0-9, '.', '_', '-' and '/'", @@ -404,23 +412,60 @@ const capturePattern = httpPattern(BOARD_HTTP_CAPTURE_FLAGS, (s) => : undefined, ); -const observeKinds = [ - z.strictObject({ key: idText, kind: z.literal("npm_version"), package: npmPackage }), - z.strictObject({ key: idText, kind: z.literal("github_release"), repo: githubRepo }), - z.strictObject({ key: idText, kind: z.literal("github_open_issues"), repo: githubRepo }), - z.strictObject({ key: idText, kind: z.literal("github_open_prs"), repo: githubRepo }), - z.strictObject({ - key: idText, - kind: z.literal("github_ci"), - repo: githubRepo, - workflow: workflowFile, - branch: branchName.default(BOARD_DEFAULT_CI_BRANCH), - }), - z.strictObject({ key: idText, kind: z.literal("page_version"), url: httpsUrl }), -] as const; +// The name of a check run, as the API takes it: it may hold spaces and ':'. +const checkName = z.string().superRefine((s, ctx) => { + const problem = + argumentProblem(s) ?? + ([...s].length > BOARD_CHECK_NAME_MAX + ? `must be at most ${BOARD_CHECK_NAME_MAX} characters` + : undefined); + if (problem !== undefined) ctx.addIssue({ code: "custom", message: problem }); +}); + +const npmVersionObserve = z.strictObject({ + key: idText, + kind: z.literal("npm_version"), + package: npmPackage, +}); +const githubReleaseObserve = z.strictObject({ + key: idText, + kind: z.literal("github_release"), + repo: githubRepo, +}); +const githubOpenIssuesObserve = z.strictObject({ + key: idText, + kind: z.literal("github_open_issues"), + repo: githubRepo, +}); +const githubOpenPrsObserve = z.strictObject({ + key: idText, + kind: z.literal("github_open_prs"), + repo: githubRepo, +}); +const githubCiObserve = z.strictObject({ + key: idText, + kind: z.literal("github_ci"), + repo: githubRepo, + workflow: workflowFile, + branch: branchName.default(BOARD_DEFAULT_CI_BRANCH), +}); +const pageVersionObserve = z.strictObject({ + key: idText, + kind: z.literal("page_version"), + url: httpsUrl, +}); const manualObserve = z.strictObject({ key: idText, kind: z.literal("manual"), how: nonEmptyText }); +// The latest check run of that name on the head commit of the branch. +const githubCheckObserve = z.strictObject({ + key: idText, + kind: z.literal("github_check"), + repo: githubRepo, + check: checkName, + branch: branchName.default(BOARD_DEFAULT_CI_BRANCH), +}); + // GET only: a HEAD is answered differently by many servers, and is another command. const httpObserve = z.strictObject({ key: idText, @@ -431,9 +476,31 @@ const httpObserve = z.strictObject({ capture_pattern: capturePattern.optional(), }); -const observeSchemaV2 = z.discriminatedUnion("kind", [...observeKinds, manualObserve]); +const observeSchemaV2 = z.discriminatedUnion("kind", [ + npmVersionObserve, + githubReleaseObserve, + githubOpenIssuesObserve, + githubOpenPrsObserve, + githubCiObserve, + pageVersionObserve, + manualObserve, +]); -const observeSchemaV3 = z.discriminatedUnion("kind", [...observeKinds, httpObserve, manualObserve]); +// From version 3, every observation may say what to mind when making it, in +// the shape of a stage's or a lane's `notes`. +const observeNotes = { notes: z.array(nonEmptyText).optional() }; + +const observeSchemaV3 = z.discriminatedUnion("kind", [ + npmVersionObserve.extend(observeNotes), + githubReleaseObserve.extend(observeNotes), + githubOpenIssuesObserve.extend(observeNotes), + githubOpenPrsObserve.extend(observeNotes), + githubCiObserve.extend(observeNotes), + githubCheckObserve.extend(observeNotes), + pageVersionObserve.extend(observeNotes), + httpObserve.extend(observeNotes), + manualObserve.extend(observeNotes), +]); const stageSchemaV1 = z.strictObject({ meaning: nonEmptyText }); @@ -534,8 +601,8 @@ const boardSchemaV3 = z.strictObject({ /** * A declaration as read, whatever its version: one of version 1 has no - * observations and no stage has `look` or `notes`, and one of version 2 - * observes nothing over `http`. + * observations and no stage has `look` or `notes`, and one of version 2 has + * no `http` or `github_check` observation and no observation has `notes`. */ export type BoardDeclaration = z.output; export type BoardMeasure = BoardDeclaration["measures"][number]; diff --git a/packages/core/src/board/guide.test.ts b/packages/core/src/board/guide.test.ts index e7a32bc..b93be87 100644 --- a/packages/core/src/board/guide.test.ts +++ b/packages/core/src/board/guide.test.ts @@ -345,7 +345,9 @@ describe("boardGuide: a board declared", () => { }), ); expect(guide).toContain("This board declares nothing to observe outside basou."); - expect(guide).toContain("declare it under `observe` (board_version 2 on, and 3 for `http`)"); + expect(guide).toContain( + "declare it under `observe` (board_version 2 on, and 3 for `http`, `github_check` and an observation's `notes`)", + ); expect(templateIn(guide).observed).toEqual({}); }); @@ -1066,3 +1068,157 @@ describe("boardGuide: an http observation, run", () => { } }); }); + +const ODD_CHECK = 'Workers Builds: a`b $(touch x) "c"; \u30c7\u30d7\u30ed\u30a4'; +const CHECK_STATE = + '(.check_runs | max_by(.id) | if .status == "completed" then .conclusion else .status end), (if .total_count > 100 then "more than 100 runs: \\(.total_count)" else empty end)'; + +describe("boardGuide: a github_check observation and an observation's notes", () => { + it("asks for the newest check run of that name on the head of the branch, quoted, with its value and source", () => { + const guide = boardGuide( + guideInput({ + board: declared( + httpDeclaration([ + { + key: "deploy", + kind: "github_check", + repo: "owner/name", + check: "Workers Builds: app", + }, + { + key: "odd", + kind: "github_check", + repo: "owner/name.js", + check: ODD_CHECK, + branch: "release/1.x", + }, + ]), + ), + }), + ); + const blocks = blocksIn(guide); + expect(blocks).toContain( + `gh api -X GET 'repos/owner/name/commits/heads/main/check-runs' -f check_name='Workers Builds: app' -f per_page=100 -q '${CHECK_STATE}'; echo "exit=$?"`, + ); + expect(blocks).toContain( + `gh api -X GET 'repos/owner/name.js/commits/heads/release/1.x/check-runs' -f check_name='${ODD_CHECK}' -f per_page=100 -q '${CHECK_STATE}'; echo "exit=$?"`, + ); + const deploy = guide.slice( + guide.indexOf("#### `deploy` (github_check)"), + guide.indexOf("#### `odd`"), + ); + expect(deploy).toContain( + "How the latest check run named `Workers Builds: app` on the head commit of `main` of `owner/name` ended", + ); + expect(deploy).toContain( + "Value: the first line it prints, as a string (`success`, `failure`, `cancelled`, `skipped`, or the status of a run not finished, such as `queued` or `in_progress`). A second line, `more than 100 runs: `, says the newest run may be on a page not read: keep the first line as the value, and say so in a footnote.", + ); + expect(deploy).toContain( + "An empty line is no check run of that name on the head commit: a null, with the error saying that the head commit of `main` has no check run named `Workers Builds: app`.", + ); + expect(deploy).toContain( + "A head commit that never started the check (a path filter, `[skip ci]` in its commit message, or the app's own rules) gives this too, even when what the check looks after is fine; a job a workflow skipped with `if:` is a check run, concluded `skipped`.", + ); + expect(deploy).toContain("A branch that does not exist exits 1 (HTTP 422)"); + expect(deploy).toContain( + "Source: `gh api -X GET repos/owner/name/commits/heads/main/check-runs -f check_name=Workers Builds: app`.", + ); + // A name with a backquote is still Markdown code as it is, in the prose as in the source. + const odd = guide.slice(guide.indexOf("#### `odd` (github_check)"), guide.indexOf("### 4.")); + expect(odd).toContain( + `How the latest check run named \`\`${ODD_CHECK}\`\` on the head commit of \`release/1.x\` of \`owner/name.js\` ended`, + ); + expect(odd).toContain( + `the head commit of \`release/1.x\` has no check run named \`\`${ODD_CHECK}\`\`.`, + ); + expect(odd).toContain( + `Source: \`\`gh api -X GET repos/owner/name.js/commits/heads/release/1.x/check-runs -f check_name=${ODD_CHECK}\`\`.`, + ); + const observed = templateIn(guide).observed as Record; + expect(observed.deploy?.source).toBe( + "gh api -X GET repos/owner/name/commits/heads/main/check-runs -f check_name=Workers Builds: app", + ); + expect(observed.odd?.source).toBe( + `gh api -X GET repos/owner/name.js/commits/heads/release/1.x/check-runs -f check_name=${ODD_CHECK}`, + ); + }); + + it("gives the check name to gh as one argument, whatever it holds", async () => { + const dir = await mkdtemp(join(tmpdir(), "basou-board-check-")); + try { + const bin = join(dir, "bin"); + await mkdir(bin); + // A gh that prints each argument it is given on a line of its own. + await writeFile( + join(bin, "gh"), + '#!/bin/sh\nfor a in "$@"; do printf \'[%s]\\n\' "$a"; done\n', + { + mode: 0o755, + }, + ); + const guide = boardGuide( + guideInput({ + anchor: dir, + board: declared( + httpDeclaration([ + { key: "odd", kind: "github_check", repo: "o/n", check: ODD_CHECK, branch: "a/b" }, + ]), + ), + }), + ); + const ask = blocksIn(guide).find((b) => b.startsWith("gh api ")) as string; + const out = execFileSync("sh", ["-c", ask], { + cwd: dir, + env: { ...process.env, PATH: `${bin}:${process.env.PATH ?? ""}` }, + encoding: "utf8", + }); + expect(out).toBe( + [ + "[api]", + "[-X]", + "[GET]", + "[repos/o/n/commits/heads/a/b/check-runs]", + "[-f]", + `[check_name=${ODD_CHECK}]`, + "[-f]", + "[per_page=100]", + "[-q]", + `[${CHECK_STATE}]`, + "exit=0", + "", + ].join("\n"), + ); + await expect(readdir(dir)).resolves.toEqual(["bin"]); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("prints each observation's notes under how it is made, and nothing for one without", () => { + const guide = boardGuide( + guideInput({ + board: declared( + httpDeclaration([ + { + key: "shop", + kind: "http", + url: SHOP, + notes: ["404 is right while it is sold out", `a${String.fromCodePoint(0x202e)}b`], + }, + { key: "users", kind: "manual", how: "ask", notes: ["ask on Mondays"] }, + { key: "deploy", kind: "github_check", repo: "o/n", check: "deploy" }, + ]), + ), + }), + ); + const shop = guide.slice(guide.indexOf("#### `shop` (http)"), guide.indexOf("#### `users`")); + expect(shop).toMatch( + /Source: `https:\/\/example\.com\/shop\/item-1`\.\n\n- mind: 404 is right while it is sold out\n- mind: a\\u202eb\n\n$/, + ); + const users = guide.slice(guide.indexOf("#### `users`"), guide.indexOf("#### `deploy`")); + expect(users).toMatch(/as the operator asked`\.\n\n- mind: ask on Mondays\n\n$/); + const deploy = guide.slice(guide.indexOf("#### `deploy`"), guide.indexOf("### 4.")); + expect(deploy).not.toContain("mind:"); + expect([...guide].some((c) => c.codePointAt(0) === 0x202e)).toBe(false); + }); +}); diff --git a/packages/core/src/board/guide.ts b/packages/core/src/board/guide.ts index 52b7922..f1ef9a7 100644 --- a/packages/core/src/board/guide.ts +++ b/packages/core/src/board/guide.ts @@ -448,7 +448,7 @@ function observeSteps( ): void { if (observe.length === 0) { line( - "This board declares nothing to observe outside basou. If a lane reaches its users through something outside the repos (a package registry, a release, a site, a production database), declare it under `observe` (board_version 2 on, and 3 for `http`) through an axis review (step 2b).", + "This board declares nothing to observe outside basou. If a lane reaches its users through something outside the repos (a package registry, a release, a site, a production database), declare it under `observe` (board_version 2 on, and 3 for `http`, `github_check` and an observation's `notes`) through an axis review (step 2b).", "", ); return; @@ -501,6 +501,9 @@ function observeSteps( "", ); break; + case "github_check": + githubCheckSteps(o, line, block); + break; case "page_version": line( `The version ${o.url} shows: the first \`v..\` (with what follows a \`-\`) not right after a letter, digit, underscore or dot, once the page's generator meta tags are taken out, however they are quoted or cased (they name the site builder's version, not the product's).`, @@ -526,6 +529,8 @@ function observeSteps( ); break; } + for (const note of o.notes ?? []) line(`- mind: ${displayPath(note)}`); + line(""); } } @@ -590,6 +595,8 @@ function sourceOf(o: BoardObserve): string { return `gh pr list --repo ${o.repo} --state open`; case "github_ci": return `gh run list --repo ${o.repo} --workflow ${o.workflow} --branch ${o.branch}`; + case "github_check": + return `gh api -X GET ${githubCheckPath(o)} -f check_name=${o.check}`; case "page_version": case "http": return o.url; @@ -627,6 +634,41 @@ function codeSpan(text: string): string { return `${fence}${pad}${shown}${pad}${fence}`; } +// The API path of the check runs on the head commit of a github_check +// observation's branch. `heads/` names the branch, as the API documents it, +// so a tag or a commit of the same name is never read instead. +function githubCheckPath(o: Extract): string { + return `repos/${o.repo}/commits/heads/${o.branch}/check-runs`; +} + +// How many check runs a github_check observation reads, as one page. +const CHECK_RUNS_PER_PAGE = 100; + +// What prints the state of the newest check run listed, as its conclusion +// once it is completed and its status before. The API lists one run for each +// check suite that ran the check, in an order it does not document; with no +// run it prints an empty line. When there are more runs of the name than a +// page holds, a second line says so: the newest may be on a page not read. +const CHECK_RUN_STATE = `(.check_runs | max_by(.id) | if .status == "completed" then .conclusion else .status end), (if .total_count > ${CHECK_RUNS_PER_PAGE} then "more than ${CHECK_RUNS_PER_PAGE} runs: \\(.total_count)" else empty end)`; + +// How a github_check observation is made. +function githubCheckSteps( + o: Extract, + line: (...lines: string[]) => void, + block: (...lines: string[]) => void, +): void { + line( + `How the latest check run named ${codeSpan(o.check)} on the head commit of ${codeSpan(o.branch)} of ${codeSpan(o.repo)} ended (a check run of a GitHub App, such as a deploy, as well as a job of a workflow):`, + ); + block( + `gh api -X GET ${shellWord(githubCheckPath(o))} -f check_name=${shellWord(o.check)} -f per_page=${CHECK_RUNS_PER_PAGE} -q ${shellWord(CHECK_RUN_STATE)}; echo "exit=$?"`, + ); + line( + `Value: the first line it prints, as a string (\`success\`, \`failure\`, \`cancelled\`, \`skipped\`, or the status of a run not finished, such as \`queued\` or \`in_progress\`). A second line, \`more than ${CHECK_RUNS_PER_PAGE} runs: \`, says the newest run may be on a page not read: keep the first line as the value, and say so in a footnote. An empty line is no check run of that name on the head commit: a null, with the error saying that the head commit of ${codeSpan(o.branch)} has no check run named ${codeSpan(o.check)}. A head commit that never started the check (a path filter, \`[skip ci]\` in its commit message, or the app's own rules) gives this too, even when what the check looks after is fine; a job a workflow skipped with \`if:\` is a check run, concluded \`skipped\`. A branch that does not exist exits 1 (HTTP 422): a null with that as the error. Source: ${codeSpan(sourceOf(o))}.`, + "", + ); +} + // How an http observation is made: a GET, then its body read when the // observation declares what to read off it. function httpSteps( diff --git a/packages/core/src/board/record.test.ts b/packages/core/src/board/record.test.ts index 0f0276a..5648af6 100644 --- a/packages/core/src/board/record.test.ts +++ b/packages/core/src/board/record.test.ts @@ -454,6 +454,55 @@ describe("buildRecord: a board of version 3 that observes over http", () => { }); }); +describe("buildRecord: a board of version 3 that observes a check run, with notes", () => { + it("keeps the check run and each observation's notes as declared, and the value as given", () => { + const observe = [ + { + key: "deploy", + kind: "github_check", + repo: "owner/name", + check: "Workers Builds: app", + branch: "main", + notes: ["a commit that changes only docs starts no build"], + }, + { key: "users", kind: "manual", how: "ask", notes: ["ask on Mondays"] }, + ]; + const result = parseBoardDeclaration( + stringify({ + board_version: 3, + title: "Test board", + stages: Object.fromEntries(STAGES.map((id) => [id, { meaning: `stage ${id}` }])), + lanes: [ + { id: "core", name: "Core" }, + { id: "docs", name: "Docs" }, + ], + observe, + axis: { version: 1, review_due_days: 60 }, + effort: { start: "2026-04-28", time_zone: "UTC" }, + }), + { manifestRepoPaths: [] }, + ); + if (!result.ok) throw new Error(result.errors.join("\n")); + const at = "2026-10-17T00:00:00Z"; + const observed = { + deploy: { value: "success", observed_at: at, source: "gh api" }, + users: { value: 12, observed_at: at, source: "the operator" }, + }; + const parsed = parseRecordInput(input({ observed }), result.declaration); + if (!parsed.ok) throw new Error(parsed.errors.join("\n")); + const r = buildRecord({ + declaration: result.declaration, + measurement: { digest: DIGEST } as BoardMeasurement, + recordInput: parsed.input, + recordedAt: new Date("2026-10-17T00:00:00.000Z"), + recordedWith: { basou: "0.0.0-test", build: null }, + }); + expect(r.record_version).toBe(3); + expect(r.declaration.observe).toEqual(observe); + expect(r.observed).toEqual(observed); + }); +}); + describe("orderAnomalies", () => { it("finds a stage not started, blocked or shelved before one done or begun, not counting unverified", () => { const parsed = parseRecordInput( diff --git a/packages/core/src/board/record.ts b/packages/core/src/board/record.ts index fcb8d96..f89109c 100644 --- a/packages/core/src/board/record.ts +++ b/packages/core/src/board/record.ts @@ -24,9 +24,10 @@ function isRecord(value: unknown): value is Record { * included. Raised whenever that shape changes: 2 added the observations the * declaration names (`declaration.observe`) and what each stage is judged by * (`look` and `notes` of `declaration.stages`); 3 may hold a declaration of - * board_version 3, which may observe an HTTP response (`http`). Every record - * is written in the newest version, whatever the version of the board it - * records, and every version is read. + * board_version 3, which may observe an HTTP response (`http`) or a check run + * (`github_check`) and say what to mind when making an observation (`notes`). + * Every record is written in the newest version, whatever the version of the + * board it records, and every version is read. */ export const BOARD_RECORD_VERSION = 3; diff --git a/packages/core/src/lib/view-strings.ts b/packages/core/src/lib/view-strings.ts index 155abaa..cab66b5 100644 --- a/packages/core/src/lib/view-strings.ts +++ b/packages/core/src/lib/view-strings.ts @@ -1450,7 +1450,7 @@ const BOARD_INIT_EN: BoardInitStrings = { measures: "What to count in the repos (file_count, dir_count, line_count, match_count, regex_capture, json_length, trail_count). One with a lane is drawn in that lane's section of the board page; one without is kept only in the records and their differences (and in a ratio that uses it).", observe: - "What to observe outside basou (npm_version, github_release, github_open_issues, github_open_prs, github_ci, page_version, http, manual). basou never makes these; the guide says how.", + "What to observe outside basou (npm_version, github_release, github_open_issues, github_open_prs, github_ci, github_check, page_version, http, manual), each with notes on what to mind when making it if need be. basou never makes these; the guide says how.", components: "Left empty: the first measure names every component it finds, and the axis review registers each with its lanes or '-' and a note.", axis: "Raise version when a lane is added or removed or a stage's meaning changes.", @@ -1487,7 +1487,7 @@ const BOARD_INIT_JA: BoardInitStrings = { measures: "repo の中で数えるもの(file_count・dir_count・line_count・match_count・regex_capture・json_length・trail_count)。lane を付けたものは盤のページのそのレーンの節に描かれ、付けないものは記録と差分(と、それを使う ratio)にだけ残る。", observe: - "basou の外で観測するもの(npm_version・github_release・github_open_issues・github_open_prs・github_ci・page_version・http・manual)。basou は取りに行かない。取り方は guide が出す。", + "basou の外で観測するもの(npm_version・github_release・github_open_issues・github_open_prs・github_ci・github_check・page_version・http・manual)。取るときの注意があれば notes に書く。basou は取りに行かない。取り方は guide が出す。", components: "空のままでよい。最初の measure が見つけた構成要素を全部名指しし、軸の見直しで各要素をレーンに(数えないなら '-' と note で)登録する。", axis: "レーンの増減や段の意味を変えたら version を上げる。",