From d797a0afb58d30b825facf070c1bec31b976329d Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Sat, 17 Oct 2026 09:32:14 +0900 Subject: [PATCH 1/2] basou: Observe a check run by its name on a board, and let every observation carry notes - board.yaml: board_version 3 adds the observation kind github_check: a GitHub repository, the name of a check run (check) 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. A check name that is empty, longer than 255 characters, holds a control character or a quote, or starts with '-' is refused. - board.yaml: from board_version 3, every observation may have notes, in the shape of a stage's or a lane's. A board of version 1 or 2 that has them, or a github_check, is told that version 3 reads them. - board guide: a gh api command that asks for the check runs of heads/ by check_name, 100 to a page, 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 heads/ keeps a branch named like a commit from being read as one. An empty line is no run of that name on the head commit. Each observation's notes are printed under its commands as "mind:". - board init's comment on observe names github_check and notes. --- CHANGELOG.md | 20 +++ packages/cli/src/commands/board.test.ts | 44 ++++++ packages/core/src/board/declaration.test.ts | 141 ++++++++++++++++++- packages/core/src/board/declaration.ts | 104 +++++++++++--- packages/core/src/board/guide.test.ts | 146 +++++++++++++++++++- packages/core/src/board/guide.ts | 41 +++++- packages/core/src/board/record.test.ts | 49 +++++++ packages/core/src/board/record.ts | 7 +- packages/core/src/lib/view-strings.ts | 4 +- 9 files changed, 528 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29fb677..98fa66c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,26 @@ 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). No check run of + that name on the head commit, as when a path filter or a skip did not + start it, is a null with that as the error. 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 diff --git a/packages/cli/src/commands/board.test.ts b/packages/cli/src/commands/board.test.ts index 6ca6b5a..15a74e6 100644 --- a/packages/cli/src/commands/board.test.ts +++ b/packages/cli/src/commands/board.test.ts @@ -1021,6 +1021,50 @@ 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 repos/owner/name/commits/heads/main/check-runs", + }, + }; + 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..8ecfff1 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"]; @@ -1660,3 +1664,138 @@ 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", + ]); + expect(errorsOf(parse(withCheck({ check: "x", branch: "-x" })))).toEqual([ + "observe[0].branch: must be a branch name such as main, using only A-Z, a-z, 0-9, '.', '_', '-' and '/'", + ]); + 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..9a51b13 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; @@ -404,23 +409,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 +473,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 +598,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..2d54220 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,145 @@ 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'; + +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( + "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("even when what the check looks after is fine"); + expect(deploy).toContain("A branch that does not exist exits 1 (HTTP 422)"); + expect(deploy).toContain( + "Source: `gh api 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. + expect(guide).toContain( + `Source: \`\`gh api 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 repos/owner/name/commits/heads/main/check-runs -f check_name=Workers Builds: app", + ); + expect(observed.odd?.source).toBe( + `gh api 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..5ba2392 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 ${githubCheckPath(o)} -f check_name=${o.check}`; case "page_version": case "http": return o.url; @@ -627,6 +634,38 @@ 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`; +} + +// 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. +const CHECK_RUN_STATE = + '.check_runs | max_by(.id) | if .status == "completed" then .conclusion else .status 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 ${o.branch} of ${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=100 -q ${shellWord(CHECK_RUN_STATE)}; echo "exit=$?"`, + ); + line( + `Value: what it prints, as a string (\`success\`, \`failure\`, \`cancelled\`, \`skipped\`, or the status of a run not finished, such as \`queued\` or \`in_progress\`). 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 ${o.branch} has no check run named ${codeSpan(o.check)}. A head commit that did not start the check (a path filter, a skip) gives this too, even when what the check looks after is fine. 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 を上げる。", From 30f2ac637d1c0851c8c1f75e41e48f35e33bc360 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Sat, 17 Oct 2026 15:06:35 +0900 Subject: [PATCH 2/2] basou: Refuse a branch part that starts with '.', and say more of a github_check in the guide - board.yaml: 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. - board guide: the source of a github_check is written as the GET it is (gh api -X GET ...); with -f fields and no -X GET, gh sends a POST. - board guide: when a check has more runs than the 100 a page holds, the command prints a second line saying so, and the guide says to keep the first line as the value and say in a footnote that it may not be the newest. The page and the line share one constant. - board guide: an empty line is a head commit that never started the check (a path filter, a skip marker in its commit message, or the app's own rules); a job a workflow skipped with if: is a check run concluded skipped. - board guide: the branch and the repository of a github_check are Markdown code in its prose, as the check name is, so '_' is not read as emphasis. Tests hold the check name's code spans there. --- CHANGELOG.md | 13 +++++++-- packages/cli/src/commands/board.test.ts | 3 +- packages/core/src/board/declaration.test.ts | 14 +++++++-- packages/core/src/board/declaration.ts | 5 +++- packages/core/src/board/guide.test.ts | 32 ++++++++++++++------- packages/core/src/board/guide.ts | 17 ++++++----- 6 files changed, 59 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98fa66c..82d5c38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,9 +43,12 @@ All notable changes to **basou** are recorded here. The project follows 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). No check run of - that name on the head commit, as when a path filter or a skip did not - start it, is a null with that as the error. A check name that is empty, + 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 @@ -64,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 15a74e6..b7b6f81 100644 --- a/packages/cli/src/commands/board.test.ts +++ b/packages/cli/src/commands/board.test.ts @@ -1052,7 +1052,8 @@ describe("basou board record", () => { deploy: { value: "success", observed_at: "2026-10-17T00:00:00Z", - source: "gh api repos/owner/name/commits/heads/main/check-runs", + 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 })); diff --git a/packages/core/src/board/declaration.test.ts b/packages/core/src/board/declaration.test.ts index 8ecfff1..5188cd6 100644 --- a/packages/core/src/board/declaration.test.ts +++ b/packages/core/src/board/declaration.test.ts @@ -1395,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 = @@ -1411,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}`, ]); }); @@ -1718,9 +1723,12 @@ describe("parseBoardDeclaration: board_version 3, github_check and an observatio expect(errorsOf(parse(withCheck({ check: "x", repo: "name" })))).toEqual([ "observe[0].repo: must be a GitHub repository written as owner/name", ]); - expect(errorsOf(parse(withCheck({ check: "x", branch: "-x" })))).toEqual([ - "observe[0].branch: must be a branch name such as main, using only A-Z, a-z, 0-9, '.', '_', '-' and '/'", - ]); + 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'", ]); diff --git a/packages/core/src/board/declaration.ts b/packages/core/src/board/declaration.ts index 9a51b13..061cb61 100644 --- a/packages/core/src/board/declaration.ts +++ b/packages/core/src/board/declaration.ts @@ -318,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( @@ -326,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 '/'", diff --git a/packages/core/src/board/guide.test.ts b/packages/core/src/board/guide.test.ts index 2d54220..b93be87 100644 --- a/packages/core/src/board/guide.test.ts +++ b/packages/core/src/board/guide.test.ts @@ -1071,7 +1071,7 @@ 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'; + '(.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", () => { @@ -1108,26 +1108,38 @@ describe("boardGuide: a github_check observation and an observation's notes", () 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", + "How the latest check run named `Workers Builds: app` on the head commit of `main` of `owner/name` ended", ); 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`.", + "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("even when what the check looks after is fine"); expect(deploy).toContain("A branch that does not exist exits 1 (HTTP 422)"); expect(deploy).toContain( - "Source: `gh api repos/owner/name/commits/heads/main/check-runs -f check_name=Workers Builds: app`.", + "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. - expect(guide).toContain( - `Source: \`\`gh api repos/owner/name.js/commits/heads/release/1.x/check-runs -f check_name=${ODD_CHECK}\`\`.`, + // 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 repos/owner/name/commits/heads/main/check-runs -f check_name=Workers Builds: app", + "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 repos/owner/name.js/commits/heads/release/1.x/check-runs -f check_name=${ODD_CHECK}`, + `gh api -X GET repos/owner/name.js/commits/heads/release/1.x/check-runs -f check_name=${ODD_CHECK}`, ); }); diff --git a/packages/core/src/board/guide.ts b/packages/core/src/board/guide.ts index 5ba2392..f1ef9a7 100644 --- a/packages/core/src/board/guide.ts +++ b/packages/core/src/board/guide.ts @@ -596,7 +596,7 @@ function sourceOf(o: BoardObserve): string { case "github_ci": return `gh run list --repo ${o.repo} --workflow ${o.workflow} --branch ${o.branch}`; case "github_check": - return `gh api ${githubCheckPath(o)} -f check_name=${o.check}`; + return `gh api -X GET ${githubCheckPath(o)} -f check_name=${o.check}`; case "page_version": case "http": return o.url; @@ -641,12 +641,15 @@ function githubCheckPath(o: Extract): st 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. -const CHECK_RUN_STATE = - '.check_runs | max_by(.id) | if .status == "completed" then .conclusion else .status end'; +// 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( @@ -655,13 +658,13 @@ function githubCheckSteps( block: (...lines: string[]) => void, ): void { line( - `How the latest check run named ${codeSpan(o.check)} on the head commit of ${o.branch} of ${o.repo} ended (a check run of a GitHub App, such as a deploy, as well as a job of a workflow):`, + `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=100 -q ${shellWord(CHECK_RUN_STATE)}; echo "exit=$?"`, + `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: what it prints, as a string (\`success\`, \`failure\`, \`cancelled\`, \`skipped\`, or the status of a run not finished, such as \`queued\` or \`in_progress\`). 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 ${o.branch} has no check run named ${codeSpan(o.check)}. A head commit that did not start the check (a path filter, a skip) gives this too, even when what the check looks after is fine. A branch that does not exist exits 1 (HTTP 422): a null with that as the error. Source: ${codeSpan(sourceOf(o))}.`, + `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))}.`, "", ); }