karajan-code 4.61.0 → 4.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/register-meta.js +9 -0
- package/src/commands/hu.js +67 -3
- package/src/hu/board-config.js +80 -0
- package/src/hu/github-board.js +119 -0
- package/src/review/clean-merge.js +23 -0
- package/src/review/gate-gitignore.js +2 -0
- package/src/review/publish-pr.js +3 -0
- package/src/review/verify-pr.js +11 -4
package/package.json
CHANGED
package/src/cli/register-meta.js
CHANGED
|
@@ -353,6 +353,15 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
353
353
|
await huCommand({ config, action: "list", flags });
|
|
354
354
|
});
|
|
355
355
|
});
|
|
356
|
+
// KJC-TSK-1163 (ADR 0025): the team board, turned on once per repo.
|
|
357
|
+
hu.command("backend <backend>")
|
|
358
|
+
.description("Turn the repo's HU Board on: github (shared, in GitHub Issues) or local; writes .karajan/board.yml for a PR")
|
|
359
|
+
.option("--json", "Machine-readable output")
|
|
360
|
+
.action(async (backend, flags) => {
|
|
361
|
+
await withConfig(pkgVersion, "hu", flags, async ({ config }) => {
|
|
362
|
+
await huCommand({ config, action: "backend", args: [backend], flags });
|
|
363
|
+
});
|
|
364
|
+
});
|
|
356
365
|
|
|
357
366
|
const adr = program.command("adr").description("Architecture decision records in .karajan/adrs/ (git-tracked)");
|
|
358
367
|
adr.command("add <title>")
|
package/src/commands/hu.js
CHANGED
|
@@ -12,6 +12,8 @@ import { detectHostAgent } from "../utils/agent-detect.js";
|
|
|
12
12
|
import { readIdentity } from "../identity/store.js";
|
|
13
13
|
import { effectiveGitEmail } from "../identity/detect.js";
|
|
14
14
|
import { roleSkills } from "../skills/role-skills.js";
|
|
15
|
+
import { readBoardConfig, setBoardBackend } from "../hu/board-config.js";
|
|
16
|
+
import { createHu, listHus, claim, setStatus, assertNotHeld } from "../hu/github-board.js";
|
|
15
17
|
|
|
16
18
|
// KJC-TSK-0661 (Jorge's friction): every HU records WHO created it — the
|
|
17
19
|
// board must never show work nobody can trace. Host agent env → that agent;
|
|
@@ -46,6 +48,45 @@ export function findTitleMatches(title, allHus) {
|
|
|
46
48
|
return { identical, similar };
|
|
47
49
|
}
|
|
48
50
|
|
|
51
|
+
/** A repeatable flag as lines: one value or many, each split per line, blanks dropped. */
|
|
52
|
+
function linesOf(v) {
|
|
53
|
+
const values = Array.isArray(v) ? v : [v];
|
|
54
|
+
return values.filter((s) => s != null && s !== "").flatMap((s) => String(s).split("\n")).map((s) => s.trim()).filter(Boolean);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** KJC-TSK-1163: kj hu add on the shared board; the same no-duplicates rule, checked against the issues. */
|
|
58
|
+
async function addToTeamBoard({ repo, title, flags, deps, emit }) {
|
|
59
|
+
if (flags.id) throw new Error(`--id is not used with the shared board: GitHub gives each HU its id (the issue number)`);
|
|
60
|
+
const { identical, similar } = findTitleMatches(title, await (deps.listTeamHus ?? listHus)({ repo }));
|
|
61
|
+
if (identical.length > 0) {
|
|
62
|
+
throw new Error(`An HU with this title already exists: ${identical[0].short_id} (status: ${identical[0].status}). Work that card or move it with: kj hu move ${identical[0].short_id} <status>`);
|
|
63
|
+
}
|
|
64
|
+
if (similar.length > 0) {
|
|
65
|
+
const candidates = similar.map((h) => ` · ${h.short_id} (${h.status}) "${h.title}"`).join("\n");
|
|
66
|
+
console.warn("⚠ possible duplicate — check before working it:\n" + candidates);
|
|
67
|
+
}
|
|
68
|
+
const hu = await (deps.createTeamHu ?? createHu)({
|
|
69
|
+
repo, title, acceptanceCriteria: [...linesOf(flags.ac), ...linesOf(flags.criteria)], tests: linesOf(flags.tests), ref: flags.ref ? String(flags.ref).trim() : null,
|
|
70
|
+
});
|
|
71
|
+
return emit({ ...hu, board: "github", repo }, `✓ HU created: ${hu.short_id} (pending) in the shared board, issue #${hu.number} of ${repo}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** KJC-TSK-1164: kj hu move on the shared board; starting it claims it (the assignee) first. */
|
|
75
|
+
async function moveOnTeamBoard({ repo, args, flags, deps, emit, who }) {
|
|
76
|
+
const [ref, status] = args;
|
|
77
|
+
if (!ref || !status) throw new Error("usage: kj hu move <id> <status>");
|
|
78
|
+
if (!HU_STATUSES.includes(status)) throw new Error(`invalid status "${status}" — valid: ${HU_STATUSES.join(", ")}`);
|
|
79
|
+
const number = Number(/^(?:HU-|#)(\d+)$/i.exec(ref)?.[1]);
|
|
80
|
+
if (!Number.isInteger(number)) throw new Error(`"${ref}" is not a shared-board HU: use HU-<issue number>`);
|
|
81
|
+
// GitHub assigns by login: kj identity must name the GitHub user of this clone.
|
|
82
|
+
if (!who || who.includes("@")) throw new Error("the shared board reserves by GitHub login: declare it with kj identity set");
|
|
83
|
+
// Starting it, or releasing it, claims it; any other move of a HU someone else runs is refused.
|
|
84
|
+
if (status === "running" || flags.release) await (deps.claimTeamHu ?? claim)({ repo, number, who, release: Boolean(flags.release) });
|
|
85
|
+
else await (deps.assertNotHeld ?? assertNotHeld)({ repo, number, who });
|
|
86
|
+
await (deps.setTeamStatus ?? setStatus)({ repo, number, status });
|
|
87
|
+
return emit({ id: `HU-${number}`, status, claimed_by: status === "running" ? who : null }, `✓ HU-${number} → ${status}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
49
90
|
// Exported for the Steward's proposed-work sync (KJC-TSK-0792): broken
|
|
50
91
|
// invariants land in the same backlog the brain already consumes.
|
|
51
92
|
export async function backlogPlan(projectDir) {
|
|
@@ -72,6 +113,28 @@ export async function huCommand({ config = null, action, args = [], flags = {},
|
|
|
72
113
|
// tracker coexists with it: each HU points at its replica there (--ref).
|
|
73
114
|
const externalBoard = config?.state_backend === "external" ? (config?.board?.name || "the external board") : null;
|
|
74
115
|
|
|
116
|
+
// KJC-TSK-1163/1164 (ADR 0025): the repo's board; with the shared one on, add/list/move go to GitHub.
|
|
117
|
+
// The repo turns its board on once; the file travels in a PR. Before reading it: it may be what is fixed.
|
|
118
|
+
if (action === "backend") {
|
|
119
|
+
if (!args[0]) throw new Error("usage: kj hu backend <github | local>");
|
|
120
|
+
const r = await (deps.setBackend ?? setBoardBackend)({ projectDir, backend: args[0] });
|
|
121
|
+
const where = r.backend === "github" ? `GitHub Issues of ${r.repo}` : "the local board";
|
|
122
|
+
const files = r.gitignoreChanged ? `${r.file} and .gitignore` : r.file;
|
|
123
|
+
return emit(r, `✓ HU Board: ${where} — commit ${files} in a PR so the whole team inherits it`);
|
|
124
|
+
}
|
|
125
|
+
const board = await (deps.boardConfig ?? readBoardConfig)(projectDir);
|
|
126
|
+
if (board.backend === "github" && action === "list") {
|
|
127
|
+
const rows = (await (deps.listTeamHus ?? listHus)({ repo: board.repo })).map((h) => ({ ...h, id: h.short_id, plan: "github" }));
|
|
128
|
+
if (flags.json) { console.log(JSON.stringify(rows)); return rows; }
|
|
129
|
+
for (const r of rows) {
|
|
130
|
+
const held = r.claimed_by ? " · held by " + r.claimed_by : "";
|
|
131
|
+
console.log(`${r.short_id.padEnd(28)} ${r.status.padEnd(8)} ${r.title} [${board.repo}${held}]`);
|
|
132
|
+
}
|
|
133
|
+
if (rows.length === 0) console.log("no HUs yet — create one with: kj hu add \"<story>\"");
|
|
134
|
+
return rows;
|
|
135
|
+
}
|
|
136
|
+
if (board.backend === "github" && action === "move") return moveOnTeamBoard({ repo: board.repo, args, flags, deps, emit, who: deps.who === undefined ? workerOf(projectDir) : deps.who });
|
|
137
|
+
|
|
75
138
|
if (action === "list") {
|
|
76
139
|
const plans = await listPlans(projectDir);
|
|
77
140
|
const rows = [];
|
|
@@ -104,6 +167,8 @@ export async function huCommand({ config = null, action, args = [], flags = {},
|
|
|
104
167
|
if (action === "add") {
|
|
105
168
|
const title = args[0];
|
|
106
169
|
if (!title || !title.trim()) throw new Error("kj hu add requires a title: kj hu add \"<story>\"");
|
|
170
|
+
// KJC-TSK-1163 (ADR 0025): with the shared board on, the HU is an issue and GitHub gives its id.
|
|
171
|
+
if (board.backend === "github") return addToTeamBoard({ repo: board.repo, title, flags, deps, emit });
|
|
107
172
|
// Cross-plan title dedup (KJC-TSK-0675) — check BEFORE creating anything.
|
|
108
173
|
const allHus = [];
|
|
109
174
|
for (const meta of await listPlans(projectDir)) {
|
|
@@ -152,8 +217,7 @@ export async function huCommand({ config = null, action, args = [], flags = {},
|
|
|
152
217
|
if (flags.taskType && !TASK_TYPES.includes(flags.taskType)) {
|
|
153
218
|
throw new Error(`invalid --task-type "${flags.taskType}" — valid: ${TASK_TYPES.join(", ")}`);
|
|
154
219
|
}
|
|
155
|
-
const splitLines =
|
|
156
|
-
.flatMap((s) => String(s).split("\n")).map((s) => s.trim()).filter(Boolean);
|
|
220
|
+
const splitLines = linesOf;
|
|
157
221
|
const hu = addHu(plan, {
|
|
158
222
|
title,
|
|
159
223
|
// KJC-BUG-0189: the card-first gate demands a branch that references a
|
|
@@ -249,5 +313,5 @@ export async function huCommand({ config = null, action, args = [], flags = {},
|
|
|
249
313
|
return emit({ id: hu.id, status, claimed_by: hu.claimed_by ?? null }, `✓ ${label} → ${status}`);
|
|
250
314
|
}
|
|
251
315
|
|
|
252
|
-
throw new Error(`unknown action "${action}" — use: add | move | list`);
|
|
316
|
+
throw new Error(`unknown action "${action}" — use: add | move | list | backend`);
|
|
253
317
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-1163 (TEAM-GH-B, ADR 0025): which HU Board a repo uses. The shared
|
|
3
|
+
* board on GitHub Issues is turned on once per repo (kj hu backend github),
|
|
4
|
+
* in a versioned file the whole team inherits with a pull; kj never turns it
|
|
5
|
+
* on because the remote happens to be GitHub. No file means the local board.
|
|
6
|
+
*/
|
|
7
|
+
import fs from "node:fs/promises";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { parse as parseYaml, stringify as toYaml } from "yaml";
|
|
10
|
+
import { runCommand } from "../utils/process.js";
|
|
11
|
+
import { ensureGateTrackable } from "../review/gate-gitignore.js";
|
|
12
|
+
|
|
13
|
+
export const BOARD_FILE = ".karajan/board.yml";
|
|
14
|
+
const BACKENDS = ["github", "local"];
|
|
15
|
+
const REPO_RE = /^[\w.-]+\/[\w.-]+$/;
|
|
16
|
+
|
|
17
|
+
/** owner/repo of a GitHub remote (https, ssh, or an ssh host alias like github.com-work), or null. */
|
|
18
|
+
export function repoFromRemote(url) {
|
|
19
|
+
const u = String(url).trim();
|
|
20
|
+
// The host is github.com exactly, or an ssh alias of it written github.com-<name>; never a lookalike.
|
|
21
|
+
const shapes = [
|
|
22
|
+
/^https:\/\/(?:[^@/]+@)?github\.com\/([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/,
|
|
23
|
+
/^ssh:\/\/(?:[\w.-]+@)?github\.com(?:-\w+)?(?::\d+)?\/([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/,
|
|
24
|
+
/^(?:[\w.-]+@)?github\.com(?:-\w+)?:([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/,
|
|
25
|
+
];
|
|
26
|
+
const m = shapes.map((re) => re.exec(u)).find(Boolean);
|
|
27
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The repo's board: {backend: "local"} with no file, or the validated content of the file. */
|
|
31
|
+
export async function readBoardConfig(projectDir) {
|
|
32
|
+
let text;
|
|
33
|
+
try {
|
|
34
|
+
text = await fs.readFile(path.join(projectDir, BOARD_FILE), "utf8");
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if (err.code === "ENOENT") return { backend: "local" };
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
const raw = parseYaml(text) || {};
|
|
40
|
+
if (!BACKENDS.includes(raw.backend)) throw new Error(`${BOARD_FILE}: backend must be one of ${BACKENDS.join(", ")}`);
|
|
41
|
+
if (raw.backend === "local") return { backend: "local" };
|
|
42
|
+
if (!REPO_RE.test(String(raw.repo ?? ""))) throw new Error(`${BOARD_FILE}: repo must be owner/repo, got "${raw.repo}"`);
|
|
43
|
+
return { backend: "github", repo: raw.repo };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Turn a board on for the repo, writing the versioned file and making sure git tracks it. */
|
|
47
|
+
export async function setBoardBackend({ projectDir, backend, run = runCommand, trackable = ensureGateTrackable }) {
|
|
48
|
+
if (!BACKENDS.includes(backend)) throw new Error(`unknown board backend "${backend}": use ${BACKENDS.join(", ")}`);
|
|
49
|
+
const git = (...args) => run("git", ["-C", projectDir, ...args]);
|
|
50
|
+
const doc = { backend };
|
|
51
|
+
if (backend === "github") {
|
|
52
|
+
const remote = await git("remote", "get-url", "origin");
|
|
53
|
+
const repo = remote.exitCode === 0 ? repoFromRemote(remote.stdout) : null;
|
|
54
|
+
if (!repo) throw new Error(`origin is not a GitHub remote (${(remote.stdout || remote.stderr || "").trim() || "none"}): the shared board lives in GitHub Issues`);
|
|
55
|
+
doc.repo = repo;
|
|
56
|
+
}
|
|
57
|
+
// Trackable BEFORE it is written (check-ignore needs no file): a board the team cannot
|
|
58
|
+
// inherit is never left behind. Exit 0 means git ignores it.
|
|
59
|
+
const isIgnored = async () => (await git("check-ignore", "-q", BOARD_FILE)).exitCode === 0;
|
|
60
|
+
const ignored = await isIgnored();
|
|
61
|
+
if (ignored) {
|
|
62
|
+
// A .karajan root exclude becomes the canonical block, which re-includes the board file.
|
|
63
|
+
await trackable(projectDir);
|
|
64
|
+
if (await isIgnored()) await appendReInclude(projectDir);
|
|
65
|
+
if (await isIgnored()) throw new Error(`git still ignores ${BOARD_FILE}: re-include it in .gitignore so the team inherits the board`);
|
|
66
|
+
}
|
|
67
|
+
await fs.mkdir(path.join(projectDir, ".karajan"), { recursive: true });
|
|
68
|
+
await fs.writeFile(path.join(projectDir, BOARD_FILE), toYaml(doc), "utf8");
|
|
69
|
+
return { ...doc, file: BOARD_FILE, gitignoreChanged: ignored };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function appendReInclude(projectDir) {
|
|
73
|
+
const file = path.join(projectDir, ".gitignore");
|
|
74
|
+
// Ignored by a global exclude with no .gitignore: the repo's own file outranks it.
|
|
75
|
+
const text = await fs.readFile(file, "utf8").catch((err) => {
|
|
76
|
+
if (err.code === "ENOENT") { return ""; }
|
|
77
|
+
throw err;
|
|
78
|
+
});
|
|
79
|
+
await fs.writeFile(file, `${text}${text === "" || text.endsWith("\n") ? "" : "\n"}!${BOARD_FILE}\n`, "utf8");
|
|
80
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-1162 (TEAM-GH-A, ADR 0025): the shared HU Board on GitHub Issues.
|
|
3
|
+
* Each HU is an issue of the repo: its number is the id (HU-<n>, unique by
|
|
4
|
+
* construction, so two people never mint the same one), its state a
|
|
5
|
+
* kj:<status> label, and who holds it the assignee. gh authenticates each
|
|
6
|
+
* person; every call names the repo explicitly.
|
|
7
|
+
*/
|
|
8
|
+
import { runCommand } from "../utils/process.js";
|
|
9
|
+
|
|
10
|
+
export const HU_LABEL = "kj:hu";
|
|
11
|
+
export const STATUSES = ["pending", "running", "done", "failed", "skipped"];
|
|
12
|
+
const statusLabel = (s) => `kj:${s}`;
|
|
13
|
+
|
|
14
|
+
/** gh could not reach GitHub: the caller may queue the change, never invent data. */
|
|
15
|
+
export class BoardOfflineError extends Error {}
|
|
16
|
+
/** GitHub answered and said no (permissions, missing repo...). */
|
|
17
|
+
export class BoardRejectedError extends Error {}
|
|
18
|
+
/** The HU is running and someone else holds it: taking it over is explicit (release). */
|
|
19
|
+
export class HuClaimedError extends Error {}
|
|
20
|
+
|
|
21
|
+
// gh is not localized: these are the network failures it prints.
|
|
22
|
+
const OFFLINE = /dial tcp|no such host|could not resolve host|connection refused|i\/o timeout|network is unreachable|error connecting to/i;
|
|
23
|
+
|
|
24
|
+
async function gh(run, args) {
|
|
25
|
+
const res = await run("gh", args);
|
|
26
|
+
if (res.exitCode === 0) return res.stdout ?? "";
|
|
27
|
+
const why = (res.stderr || res.stdout || "").trim();
|
|
28
|
+
const Kind = OFFLINE.test(why) ? BoardOfflineError : BoardRejectedError;
|
|
29
|
+
throw new Kind(`gh ${args.slice(0, 2).join(" ")} failed: ${why}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function ensureLabels(repo, run) {
|
|
33
|
+
const existing = new Set(JSON.parse(await gh(run, ["label", "list", "-R", repo, "--search", "kj:", "--json", "name", "--limit", "100"])).map((l) => l.name));
|
|
34
|
+
for (const name of [HU_LABEL, ...STATUSES.map(statusLabel)].filter((n) => !existing.has(n))) {
|
|
35
|
+
await gh(run, ["label", "create", name, "-R", repo, "--color", "1f6feb", "--description", "Karajan HU Board"]);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const section = (title, items) => (items.length ? [`## ${title}`, ...items.map((i) => `- ${i}`), ""] : []);
|
|
40
|
+
|
|
41
|
+
/** Create the HU as an issue; returns its id, never one made up when GitHub did not answer. */
|
|
42
|
+
export async function createHu({ repo, title, acceptanceCriteria = [], tests = [], ref = null, run = runCommand }) {
|
|
43
|
+
await ensureLabels(repo, run);
|
|
44
|
+
const body = [...section("Acceptance criteria", acceptanceCriteria), ...section("Tests", tests), ...(ref ? [`Ref: ${ref}`, ""] : []), "<!-- kj:hu -->"].join("\n");
|
|
45
|
+
const url = (await gh(run, ["issue", "create", "-R", repo, "--title", title, "--body", body, "--label", HU_LABEL, "--label", statusLabel("pending")])).trim();
|
|
46
|
+
const number = Number(/\/issues\/(\d+)$/.exec(url)?.[1]);
|
|
47
|
+
if (!Number.isInteger(number)) throw new BoardRejectedError(`gh issue create returned no issue URL: ${url}`);
|
|
48
|
+
return { short_id: `HU-${number}`, number, status: "pending", title };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Every HU of the repo, with its status and who holds it. */
|
|
52
|
+
export async function listHus({ repo, run = runCommand }) {
|
|
53
|
+
const issues = JSON.parse(await gh(run, ["issue", "list", "-R", repo, "--label", HU_LABEL, "--state", "all", "--limit", "1000", "--json", "number,title,state,labels,assignees"]));
|
|
54
|
+
return issues.map((i) => ({ short_id: `HU-${i.number}`, number: i.number, title: i.title, status: statusOf(i), claimed_by: i.assignees[0]?.login ?? null }));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The one kj:<status> of an issue; none or several is a board someone broke by hand. */
|
|
58
|
+
function statusOf(issue) {
|
|
59
|
+
const found = issue.labels.map((l) => l.name).filter((n) => STATUSES.some((s) => statusLabel(s) === n));
|
|
60
|
+
if (found.length !== 1) throw new BoardRejectedError(`HU-${issue.number} has ${found.length} status labels (${found.join(", ") || "none"}): fix it to exactly one kj:<status>`);
|
|
61
|
+
return found[0].slice(3);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function view(repo, number, run) {
|
|
65
|
+
const issue = JSON.parse(await gh(run, ["issue", "view", String(number), "-R", repo, "--json", "state,labels,assignees"]));
|
|
66
|
+
return { ...issue, number, status: statusOf({ ...issue, number }), holders: issue.assignees.map((a) => a.login) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Move the HU to `status`; done and skipped close the issue, anything else keeps it open. */
|
|
70
|
+
export async function setStatus({ repo, number, status, run = runCommand }) {
|
|
71
|
+
if (!STATUSES.includes(status)) throw new Error(`invalid status "${status}": use ${STATUSES.join(", ")}`);
|
|
72
|
+
const issue = await view(repo, number, run);
|
|
73
|
+
// Every other status label goes, not only the one read: a concurrent move must not leave two.
|
|
74
|
+
const others = STATUSES.filter((s) => s !== status).flatMap((s) => ["--remove-label", statusLabel(s)]);
|
|
75
|
+
await gh(run, ["issue", "edit", String(number), "-R", repo, "--add-label", statusLabel(status), ...others]);
|
|
76
|
+
const after = await view(repo, number, run);
|
|
77
|
+
if (after.status !== status) throw new BoardRejectedError(`HU-${number} was moved to ${after.status} at the same time: check it before moving it again`);
|
|
78
|
+
const closes = status === "done" || status === "skipped";
|
|
79
|
+
if (closes && issue.state === "OPEN") await gh(run, ["issue", "close", String(number), "-R", repo]);
|
|
80
|
+
if (!closes && issue.state === "CLOSED") await gh(run, ["issue", "reopen", String(number), "-R", repo]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A running HU held by someone else is theirs: any move of it by `who` needs release. */
|
|
84
|
+
export async function assertNotHeld({ repo, number, who, run = runCommand }) {
|
|
85
|
+
const issue = await view(repo, number, run);
|
|
86
|
+
const others = issue.holders.filter((h) => h !== who);
|
|
87
|
+
if (issue.status === "running" && others.length > 0) {
|
|
88
|
+
throw new HuClaimedError(`HU-${number} is held by ${others.join(", ")}: taking it over is explicit (release)`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Reserve the HU for `who` (the assignee is the reservation); a running HU held by another needs release. */
|
|
93
|
+
export async function claim({ repo, number, who, release = false, run = runCommand }) {
|
|
94
|
+
const issue = await view(repo, number, run);
|
|
95
|
+
const others = issue.holders.filter((h) => h !== who);
|
|
96
|
+
if (others.length > 0 && issue.status === "running" && !release) {
|
|
97
|
+
throw new HuClaimedError(`HU-${number} is held by ${others.join(", ")}: taking it over is explicit (release)`);
|
|
98
|
+
}
|
|
99
|
+
const drop = others.flatMap((h) => ["--remove-assignee", h]);
|
|
100
|
+
await gh(run, ["issue", "edit", String(number), "-R", repo, "--add-assignee", who, ...drop]);
|
|
101
|
+
// Two people may claim at once and both see it free: read it back, and the same rule
|
|
102
|
+
// on both sides (the first login in order keeps it; a release always keeps it) leaves one.
|
|
103
|
+
const after = (await view(repo, number, run)).holders;
|
|
104
|
+
const rivals = after.filter((h) => h !== who);
|
|
105
|
+
if (rivals.length > 0) {
|
|
106
|
+
if (!release && [who, ...rivals].sort()[0] !== who) {
|
|
107
|
+
await gh(run, ["issue", "edit", String(number), "-R", repo, "--remove-assignee", who]);
|
|
108
|
+
throw new HuClaimedError(`HU-${number} was claimed at the same time by ${rivals.join(", ")}, who keeps it`);
|
|
109
|
+
}
|
|
110
|
+
// The one who keeps it does not rely on the others backing off: it removes them.
|
|
111
|
+
await gh(run, ["issue", "edit", String(number), "-R", repo, ...rivals.flatMap((h) => ["--remove-assignee", h])]);
|
|
112
|
+
}
|
|
113
|
+
// Every claim, release or not, ends with who as the only holder, or says it did not.
|
|
114
|
+
const final = rivals.length > 0 ? (await view(repo, number, run)).holders : after;
|
|
115
|
+
if (final.length !== 1 || final[0] !== who) {
|
|
116
|
+
throw new HuClaimedError(`HU-${number} is held by ${final.join(", ") || "nobody"} after a concurrent claim: check it before working it`);
|
|
117
|
+
}
|
|
118
|
+
return { claimed_by: who };
|
|
119
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-BUG-0391: "Update branch" puts a merge of the base on top of a PR. A
|
|
3
|
+
* clean merge brings no work of the PR: its tree is exactly what git merges
|
|
4
|
+
* from its parents, so it needs no cross-AI verdict. A merge whose tree differs
|
|
5
|
+
* (conflicts resolved by hand, or anything slipped in) is work and still needs
|
|
6
|
+
* one. Exit codes only, never git's messages, which follow the user's language.
|
|
7
|
+
*/
|
|
8
|
+
import { runCommand } from "../utils/process.js";
|
|
9
|
+
|
|
10
|
+
/** True when `sha` is a merge whose tree is the clean merge of its two parents. */
|
|
11
|
+
export async function isCleanMerge(projectDir, sha, run = runCommand) {
|
|
12
|
+
const git = (...args) => run("git", ["-C", projectDir, ...args]);
|
|
13
|
+
const parents = await git("rev-list", "--parents", "-n", "1", sha);
|
|
14
|
+
if (parents.exitCode !== 0) throw new Error(`git rev-list ${sha.slice(0, 7)} failed: ${(parents.stderr || "").trim()}`);
|
|
15
|
+
const ids = parents.stdout.trim().split(/\s+/).slice(1);
|
|
16
|
+
if (ids.length !== 2) return false;
|
|
17
|
+
// Exit 1 means conflicts: the merge commit then holds a resolution, which is work.
|
|
18
|
+
const merged = await git("merge-tree", "--write-tree", ...ids);
|
|
19
|
+
if (merged.exitCode !== 0) return false;
|
|
20
|
+
const tree = await git("rev-parse", `${sha}^{tree}`);
|
|
21
|
+
if (tree.exitCode !== 0) throw new Error(`git rev-parse ${sha.slice(0, 7)} failed: ${(tree.stderr || "").trim()}`);
|
|
22
|
+
return merged.stdout.trim().split("\n")[0] === tree.stdout.trim();
|
|
23
|
+
}
|
|
@@ -44,6 +44,8 @@ export const CONTRACT_BLOCK = [
|
|
|
44
44
|
"!.karajan/rules.yml",
|
|
45
45
|
// KJC-TSK-0995 (HUM-D, ADR 0018): the security level, signed when lowered, is the team's.
|
|
46
46
|
"!.karajan/security-level.json",
|
|
47
|
+
// KJC-TSK-1163 (ADR 0025): which HU Board the repo uses; the team shares it.
|
|
48
|
+
"!.karajan/board.yml",
|
|
47
49
|
];
|
|
48
50
|
|
|
49
51
|
/**
|
package/src/review/publish-pr.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { runCommand } from "../utils/process.js";
|
|
8
8
|
import { diffHash, loadVerdict } from "./verdict-store.js";
|
|
9
|
+
import { isCleanMerge } from "./clean-merge.js";
|
|
9
10
|
|
|
10
11
|
export const MARKER = (head) => `<!-- kj-review head=${head} -->`;
|
|
11
12
|
|
|
@@ -30,6 +31,8 @@ export async function publishPrReview({ projectDir, pr, run = runCommand }) {
|
|
|
30
31
|
const rows = [];
|
|
31
32
|
const uncovered = [];
|
|
32
33
|
for (const c of info.commits ?? []) {
|
|
34
|
+
// KJC-BUG-0391: a clean merge of the base brings no work of the PR and holds no verdict.
|
|
35
|
+
if (await isCleanMerge(projectDir, c.oid, run)) continue;
|
|
33
36
|
const v = await verdictOf(projectDir, c.oid, run);
|
|
34
37
|
if (v?.verdict === "approved") rows.push({ ...c, v });
|
|
35
38
|
else uncovered.push(`${c.oid.slice(0, 7)} (${c.messageHeadline})`);
|
package/src/review/verify-pr.js
CHANGED
|
@@ -8,8 +8,11 @@
|
|
|
8
8
|
import { runCommand } from "../utils/process.js";
|
|
9
9
|
import { diffHash } from "./verdict-store.js";
|
|
10
10
|
import { MARKER } from "./publish-pr.js";
|
|
11
|
+
import { isCleanMerge } from "./clean-merge.js";
|
|
11
12
|
|
|
12
13
|
const TRUSTED = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
|
|
14
|
+
// The marker of a kj review for any head: what MARKER writes up to the head itself.
|
|
15
|
+
const MARKER_PREFIX = MARKER("\0").split("\0")[0];
|
|
13
16
|
// One line per commit, as publish-pr writes it: - `<sha7>` headline: approved by X (diff <hash12>)...
|
|
14
17
|
const LISTED = /^- `([0-9a-f]{7})` .*?\(diff ([0-9a-f]{12})\)/gm;
|
|
15
18
|
|
|
@@ -45,13 +48,17 @@ export async function verifyPrReview({ projectDir, pr, run = runCommand }) {
|
|
|
45
48
|
const res = await run("gh", ["api", "--paginate", `repos/{owner}/{repo}/pulls/${pr}/reviews`, "--jq", ".[] | {body, author_association}"], { cwd: projectDir });
|
|
46
49
|
if (res.exitCode !== 0) throw new Error(`gh api reviews failed: ${(res.stderr || res.stdout || "").trim()}`);
|
|
47
50
|
const reviews = (res.stdout || "").split("\n").filter(Boolean).map((line) => JSON.parse(line));
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
// KJC-BUG-0391: any kj review a member published on the PR counts, not only the one for
|
|
52
|
+
// today's head: the diff hash of each commit is what proves it, and a commit pushed after
|
|
53
|
+
// the review is simply not listed. "Update branch" no longer leaves the check red.
|
|
54
|
+
const kjReviews = reviews.filter((r) => TRUSTED.has(r.author_association) && String(r.body ?? "").includes(MARKER_PREFIX));
|
|
55
|
+
if (kjReviews.length === 0) {
|
|
56
|
+
return { ok: false, commits: commits.length, problems: [`no kj review published by a member: run kj review --publish-pr ${pr}`] };
|
|
51
57
|
}
|
|
52
|
-
const listed = new Map([...
|
|
58
|
+
const listed = new Map(kjReviews.flatMap((r) => [...r.body.matchAll(LISTED)].map((m) => [m[1], m[2]])));
|
|
53
59
|
const problems = [];
|
|
54
60
|
for (const c of commits) {
|
|
61
|
+
if (await isCleanMerge(projectDir, c.oid, run)) continue;
|
|
55
62
|
const hash = listed.get(c.oid.slice(0, 7));
|
|
56
63
|
if (!hash || !(await hashesOf(projectDir, c.oid, run)).has(hash)) problems.push(`${c.oid.slice(0, 7)} (${c.messageHeadline}): no approved verdict for the diff it brings`);
|
|
57
64
|
}
|