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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<branch>` 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

Expand All @@ -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

Expand Down
45 changes: 45 additions & 0 deletions packages/cli/src/commands/board.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
149 changes: 148 additions & 1 deletion packages/core/src/board/declaration.test.ts
Original file line number Diff line number Diff line change
@@ -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"];

Expand Down Expand Up @@ -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 =
Expand All @@ -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}`,
]);
});

Expand Down Expand Up @@ -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<string, unknown>): Record<string, unknown> {
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<string, unknown> = {
...boardV2(),
board_version: 3,
observe: [
...(boardV2().observe as Record<string, unknown>[]),
{ 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",
]);
}
});
});
Loading
Loading