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
105 changes: 105 additions & 0 deletions src/cli/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env bun
import { registerEventsCommands } from "@hasna/events/commander";
import { execSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { program } from "commander";
import { getCliVersion } from "./version.js";
import { parseIntOption } from "./args.js";
Expand All @@ -17,6 +18,12 @@ import {
getRepoStats,
AmbiguousRepoNameError,
} from "../db/repos.js";
import {
BranchAdjudicationError,
adjudicateBranches,
type BranchAdjudicationRequest,
type BranchAdjudicationRowSpec,
} from "../db/branch-adjudication.js";
import {
PrimaryRelocationError,
relocatePrimaryRepo,
Expand Down Expand Up @@ -481,6 +488,104 @@ registry
}
});

function branchAdjudicationSpecFromFile(path: string): Omit<BranchAdjudicationRequest, "actor" | "idempotencyKey" | "apply" | "expectedPlanHash" | "databasePath"> & {
actor?: string;
idempotency_key?: string;
idempotencyKey?: string;
} {
const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new BranchAdjudicationError("INVALID_REQUEST", "spec file must contain a JSON object");
}
const object = parsed as Record<string, unknown>;
if (!Array.isArray(object["rows"])) {
throw new BranchAdjudicationError("INVALID_REQUEST", "spec file must contain rows[]");
}
const rows = object["rows"].map((value, index): BranchAdjudicationRowSpec => {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new BranchAdjudicationError("INVALID_REQUEST", `rows[${index}] must be an object`);
}
const row = value as Record<string, unknown>;
const expectedIsRemote = row["expected_is_remote"] ?? row["expectedIsRemote"];
return {
id: Number(row["id"]),
repoId: Number(row["repo_id"] ?? row["repoId"]),
name: String(row["name"] ?? ""),
action: "reclassify-local",
expectedIsRemote: expectedIsRemote === undefined
? undefined
: typeof expectedIsRemote === "boolean" || typeof expectedIsRemote === "number"
? expectedIsRemote
: Number.NaN,
expectedLastCommitSha: String(row["expected_last_commit_sha"] ?? row["expectedLastCommitSha"] ?? ""),
expectedRepoRevision: typeof (row["expected_repo_revision"] ?? row["expectedRepoRevision"]) === "string"
? String(row["expected_repo_revision"] ?? row["expectedRepoRevision"])
: undefined,
evidenceRepoPath: String(row["evidence_repo_path"] ?? row["evidenceRepoPath"] ?? ""),
evidenceRef: typeof (row["evidence_ref"] ?? row["evidenceRef"]) === "string"
? String(row["evidence_ref"] ?? row["evidenceRef"])
: undefined,
};
});
return {
rows,
actor: typeof object["actor"] === "string" ? object["actor"] : undefined,
idempotency_key: typeof object["idempotency_key"] === "string" ? object["idempotency_key"] : undefined,
idempotencyKey: typeof object["idempotencyKey"] === "string" ? object["idempotencyKey"] : undefined,
};
}

registry
.command("adjudicate-branches")
.description("Dry-run or apply exact audited branch-row adjudications")
.requiredOption("--spec <path>", "JSON spec containing exact guarded branch row adjudications")
.option("--actor <actor>", "Auditable operator or workflow identity; overrides spec actor")
.option("--idempotency-key <key>", "Stable unique key for this logical adjudication; overrides spec key")
.option("--expected-plan-hash <sha256>", "Exact plan hash emitted by reviewed dry run; required with --apply")
.option("--dry-run", "Plan exact row adjudication without writing (default)")
.option("--apply", "Atomically apply exact row adjudication using the reviewed plan hash")
.option("--json", "Output the versioned JSON result")
.action((opts) => {
const json = Boolean(opts.json);
try {
if (opts.apply && opts.dryRun) {
throw new BranchAdjudicationError("INVALID_REQUEST", "--apply and --dry-run are mutually exclusive");
}
const spec = branchAdjudicationSpecFromFile(opts.spec);
const actor = opts.actor ?? spec.actor;
const idempotencyKey = opts.idempotencyKey ?? spec.idempotencyKey ?? spec.idempotency_key;
const result = adjudicateBranches({
rows: spec.rows,
actor,
idempotencyKey,
apply: Boolean(opts.apply),
expectedPlanHash: opts.expectedPlanHash,
databasePath: getDbPath(),
});
if (json) {
console.log(JSON.stringify(result, null, 2));
} else if (result.applied) {
const replayed = result.replayed ? "replayed " : "";
console.log(chalk.green(`✓ ${replayed}branch adjudication applied for ${result.plan.rows.length} row(s)`));
console.log(chalk.dim(` Receipt: ${result.receipt!.id}`));
} else {
console.log(chalk.yellow(`Dry run: ${result.plan.rows.length} branch row(s) can be adjudicated`));
console.log(chalk.dim(` Plan: ${result.plan.plan_hash}`));
console.log(chalk.dim(" Re-run with --apply --expected-plan-hash <plan> after review."));
}
} catch (error) {
const code = error instanceof BranchAdjudicationError ? error.code : "UNEXPECTED_ERROR";
const message = error instanceof Error ? error.message : "unknown branch adjudication error";
if (json) {
const details = error instanceof BranchAdjudicationError ? error.details : undefined;
console.log(JSON.stringify({ schema: "open-repos.branch-adjudication.v1", ok: false, error: { code, message, details } }, null, 2));
} else {
console.error(chalk.red(`${code}: ${message}`));
}
process.exitCode = 1;
}
});

// ── Commits ──
program
.command("commits")
Expand Down
190 changes: 190 additions & 0 deletions src/db/branch-adjudication.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { describe, it, expect, beforeEach, afterAll } from "bun:test";
import { execFileSync } from "node:child_process";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { closeDb, getDb } from "./database";
import {
BranchAdjudicationError,
adjudicateBranches,
type BranchAdjudicationRequest,
} from "./branch-adjudication";

let root = "";
let repoPath = "";
let head = "";
let legacyHead = "";

function git(...args: string[]): string {
return execFileSync("git", ["-C", repoPath, ...args], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
}

function seedGitRepo() {
repoPath = join(root, "repo");
execFileSync("git", ["init", "-b", "main", repoPath], { stdio: ["ignore", "pipe", "pipe"] });
git("config", "user.email", "repos-test@invalid.example");
git("config", "user.name", "Repos Test");
writeFileSync(join(repoPath, "README.md"), "main\n");
git("add", "README.md");
git("commit", "-m", "main");
head = git("rev-parse", "HEAD");
git("checkout", "-b", "legacy");
writeFileSync(join(repoPath, "legacy.txt"), "legacy\n");
git("add", "legacy.txt");
git("commit", "-m", "legacy");
legacyHead = git("rev-parse", "HEAD");
git("update-ref", "refs/heads/legacy-preserved/build/test", legacyHead);
git("checkout", "main");
git("update-ref", "refs/heads/build/test", head);
}

function seedDb() {
const db = getDb(":memory:");
db.query(`INSERT INTO repos (
id, path, name, org, remote_url, default_branch, description,
last_scanned, commit_count, branch_count, tag_count, updated_at
) VALUES
(1, ?, 'legacy', 'hasna', 'github.com/hasna/legacy', 'main', 'fixture', '2026-07-16', 0, 0, 0, 'legacy-rev'),
(2, ?, 'target', 'hasna', 'github.com/hasna/target', 'main', 'fixture', '2026-07-16', 0, 0, 0, 'target-rev')`)
.run(join(root, "missing-legacy"), repoPath);
db.query("INSERT INTO branches (id, repo_id, name, is_remote, last_commit_sha, ahead, behind) VALUES (101, 1, 'build/test', 1, ?, 3, 4)")
.run(legacyHead.slice(0, 9));
db.query("INSERT INTO branches (id, repo_id, name, is_remote, last_commit_sha, ahead, behind) VALUES (102, 2, 'build/test', 1, ?, 5, 6)")
.run(head.slice(0, 9));
}

function request(changes: Partial<BranchAdjudicationRequest> = {}): BranchAdjudicationRequest {
return {
actor: "test:branch-adjudication",
idempotencyKey: "test-branch-adjudication",
rows: [
{
id: 101,
repoId: 1,
name: "build/test",
action: "reclassify-local",
expectedIsRemote: 1,
expectedLastCommitSha: legacyHead.slice(0, 9),
expectedRepoRevision: "legacy-rev",
evidenceRepoPath: repoPath,
evidenceRef: "refs/heads/legacy-preserved/build/test",
},
{
id: 102,
repoId: 2,
name: "build/test",
action: "reclassify-local",
expectedIsRemote: 1,
expectedLastCommitSha: head.slice(0, 9),
expectedRepoRevision: "target-rev",
evidenceRepoPath: repoPath,
evidenceRef: "refs/heads/build/test",
},
],
...changes,
};
}

function expectCode(action: () => unknown, code: string) {
try {
action();
throw new Error(`expected ${code}`);
} catch (error) {
expect(error).toBeInstanceOf(BranchAdjudicationError);
expect((error as BranchAdjudicationError).code).toBe(code);
}
}

beforeEach(() => {
closeDb();
process.env["HASNA_REPOS_DB_PATH"] = ":memory:";
root = mkdtempSync(join(tmpdir(), "repos-branch-adjudication-"));
seedGitRepo();
seedDb();
});

afterAll(() => {
closeDb();
delete process.env["HASNA_REPOS_DB_PATH"];
});

describe("branch adjudication", () => {
it("plans exact branch row reclassification without writing", () => {
const dry = adjudicateBranches(request());
expect(dry.applied).toBe(false);
expect(dry.plan.can_apply).toBe(true);
expect(dry.plan.rows).toHaveLength(2);
expect(dry.plan.rows[0]!.before.is_remote).toBe(1);
expect(dry.plan.rows[0]!.after.is_remote).toBe(0);
const rows = getDb(":memory:").query("SELECT id, is_remote, ahead, behind FROM branches ORDER BY id").all();
expect(rows).toEqual([
{ id: 101, is_remote: 1, ahead: 3, behind: 4 },
{ id: 102, is_remote: 1, ahead: 5, behind: 6 },
]);
});

it("fails dry-run when a duplicate local branch row already exists", () => {
getDb(":memory:").query("INSERT INTO branches (repo_id, name, is_remote, last_commit_sha) VALUES (1, 'build/test', 0, ?)")
.run(legacyHead.slice(0, 9));
expectCode(() => adjudicateBranches(request()), "DUPLICATE_LOCAL_ROW");
});

it("fails dry-run when evidence ref does not match stored sha", () => {
const bad = request({ rows: [{ ...request().rows[0]!, evidenceRef: "refs/heads/build/test" }] });
expectCode(() => adjudicateBranches(bad), "EVIDENCE_REF_MISMATCH");
});

it("rejects revision expressions instead of treating them as refs", () => {
const bad = request({ rows: [{ ...request().rows[0]!, evidenceRef: "refs/heads/legacy-preserved/build/test~0" }] });
expectCode(() => adjudicateBranches(bad), "EVIDENCE_REF_MISMATCH");
});

it("rejects stored commit prefixes that do not disambiguate to the evidence ref", () => {
const bad = request({ rows: [{ ...request().rows[0]!, expectedLastCommitSha: "dead" }] });
expectCode(() => adjudicateBranches(bad), "ROW_MISMATCH");
});

it("requires the reviewed plan hash for apply", () => {
expectCode(() => adjudicateBranches(request({ apply: true })), "PLAN_HASH_REQUIRED");
});

it("applies only exact guarded rows and writes an audit receipt", () => {
const dry = adjudicateBranches(request());
const applied = adjudicateBranches(request({ apply: true, expectedPlanHash: dry.plan.plan_hash }));
expect(applied.applied).toBe(true);
expect(applied.replayed).toBe(false);
expect(applied.receipt?.row_count).toBe(2);
const rows = getDb(":memory:").query("SELECT id, is_remote, ahead, behind FROM branches ORDER BY id").all();
expect(rows).toEqual([
{ id: 101, is_remote: 0, ahead: 0, behind: 0 },
{ id: 102, is_remote: 0, ahead: 0, behind: 0 },
]);
const audits = getDb(":memory:").query("SELECT idempotency_key, plan_hash, row_count FROM branch_adjudication_audit").all();
expect(audits).toEqual([{ idempotency_key: "test-branch-adjudication", plan_hash: dry.plan.plan_hash, row_count: 2 }]);
});

it("replays an idempotent apply and rejects conflicting reuse", () => {
const dry = adjudicateBranches(request());
adjudicateBranches(request({ apply: true, expectedPlanHash: dry.plan.plan_hash }));
const replay = adjudicateBranches(request({ apply: true, expectedPlanHash: dry.plan.plan_hash }));
expect(replay.replayed).toBe(true);
expectCode(
() => adjudicateBranches(request({
idempotencyKey: "test-branch-adjudication",
rows: [{ ...request().rows[0]! }],
apply: true,
expectedPlanHash: dry.plan.plan_hash,
})),
"IDEMPOTENCY_CONFLICT",
);
});

it("fails apply when the reviewed plan hash is stale", () => {
const dry = adjudicateBranches(request());
getDb(":memory:").query("UPDATE branches SET ahead = 99 WHERE id = 101").run();
expectCode(() => adjudicateBranches(request({ apply: true, expectedPlanHash: dry.plan.plan_hash })), "PLAN_HASH_MISMATCH");
});
});
Loading
Loading