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
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions docs/superpowers/specs/2026-08-20-rt-statedb.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ CREATE TABLE IF NOT EXISTS kv (
);
```

**V6 addendum (RT board codeowner tabs; v5 is reserved by another lane):** adds `project_mr_sections` (repo, iid, sections JSON string[], PK (repo, iid)) and a nullable `project_mr_demands.sections` column (JSON string[]), for CODEOWNERS section tags on project-mrs rows.

**branch_cache keeps the bare-branch primary key** (review r1 findings 3+4): the bare branch IS the cache's semantic key today (`DiskCache.entries: Record<branch, CacheEntry>`); `enrichBranches`' `fetchAndCache` path has no repoName, and a `(repo, branch)` PK would mint permanent duplicate rows plus an undefined collapse tie-break that flaps `checkAndNotify`. The cross-repo branch-name collision is today's documented quirk, carried forward unchanged; `repo` stays what it is today — an optional attribute (used by `worktree:list`'s guard exactly as before).

`kv` holds whole-blob states where per-key granularity buys nothing: notifier state (`ns='notifier', k='state'`, read+written once per cycle as a unit) and event cursors (`ns='events-cursor', k=<repoName>`). Payload shapes stay opaque JSON — this ticket changes persistence, never payload schemas.
Expand Down
53 changes: 53 additions & 0 deletions lib/daemon/__tests__/freshness-mapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,59 @@ describe("applyInvalidationBatch", () => {
expect(store.read("repo-x")!.mrs[42]).toBeDefined();
});

// ─── tagged strangers + approval heal (Task 8) ────────────────────────────

test("upsertProject keeps a tagged stranger despite the author scope filter", async () => {
const store = pmrsStore();
store.fullSync("repo-x", "g/p", [fakePR(9, { sourceBranch: "branch-9", author: { id: "gid://gitlab/User/2", username: "stranger", name: "Stranger", avatarUrl: null } })], Date.now() - 1000);
store.setSectionTags("repo-x", { 9: ["Acme"] });
store.setScope("repo-x", { authors: ["ada"], windowDays: 30 });
const { env, broadcasts } = makeEnv({});
const target: RepoTarget = {
repoName: "repo-x", projectPath: "g/p",
provider: {
fetchSingleMR: async (_pp: string, iid: number) =>
fakePR(iid, { sourceBranch: "branch-9", title: "v2", author: { id: "gid://gitlab/User/2", username: "stranger", name: "Stranger", avatarUrl: null } }),
fetchPullRequestByBranch: async () => { throw new Error("unexpected"); },
fetchPullRequestsByBranches: async () => { throw new Error("unexpected"); },
} as any,
};
await applyInvalidationBatch(env, target, makeRunner(), [key("mr", "9")], {
...noNotify, grantsFor: projectGrants, projectStore: store,
});
expect(store.read("repo-x")!.mrs[9]!.pr.title).toBe("v2"); // upserted, not dropped
expect(store.read("repo-x")!.mrs[9]!.codeownerSections).toEqual(["Acme"]); // tag preserved through upsert
expect(broadcasts.some((b) => b.type === "project-mrs" && b.data.iids.includes(9))).toBe(true);
});

test("an approved invalidation re-checks rules and untags", async () => {
const store = pmrsStore();
store.fullSync("repo-x", "g/p", [fakePR(9, { sourceBranch: "branch-9" })], Date.now() - 1000);
store.setSectionTags("repo-x", { 9: ["Acme"] });
store.setScope("repo-x", { authors: ["ada"], sections: ["Acme"], windowDays: 30 });
const { env, broadcasts } = makeEnv({});
const rulesCalls: number[][] = [];
const target: RepoTarget = {
repoName: "repo-x", projectPath: "g/p",
provider: {
fetchSingleMR: async (_pp: string, iid: number) => fakePR(iid, { sourceBranch: "branch-9" }),
fetchPullRequestByBranch: async () => { throw new Error("unexpected"); },
fetchPullRequestsByBranches: async () => { throw new Error("unexpected"); },
} as any,
};
await applyInvalidationBatch(env, target, makeRunner(), [{ kind: "mr", ref: "9", cause: "approved" }], {
...noNotify, grantsFor: projectGrants, projectStore: store,
fetchRulesByIid: async (iids: number[]) => {
rulesCalls.push(iids);
return [{ iid: 9, rules: [{ type: "CODE_OWNER", approved: true, section: "Acme" }] }];
},
});
expect(rulesCalls).toEqual([[9]]); // rules re-checked for exactly this iid
expect(store.read("repo-x")!.mrs[9]!.codeownerSections).toBeUndefined(); // healed: rule is now approved
const projectBroadcasts = broadcasts.filter((b) => b.type === "project-mrs");
expect(projectBroadcasts.some((b) => b.data.iids.includes(9))).toBe(true);
});

test("notes: no discussions grant → no refresh; grant without cached discussions → no refresh; both → refresh", async () => {
const entries: Record<string, any> = {
"feat-a": { mr: { iid: 42 }, fetchedAt: 1, repoName: "repo-x" },
Expand Down
65 changes: 65 additions & 0 deletions lib/daemon/__tests__/project-mrs-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,18 @@ describe("upsert", () => {
expect(s.read("repo")!.mrs[2]!.pr.state).toBe("merged");
expect(s.read("repo")!.source).toBe("events");
});

// upsert replaces the entry wholesale, same as applyDelta -- an
// events-path upsert of a tagged MR must carry the tag forward or it
// desyncs from the SQL project_mr_sections row.
test("preserves an existing codeownerSections tag on the replacement entry", () => {
const s = tmpStore();
s.fullSync("repo", "g/p", [pr(1)], Date.now() - 10_000);
s.setSectionTags("repo", { 1: ["Acme"] });
s.upsert("repo", null, pr(1, { title: "updated" } as any), "events");
expect(s.read("repo")!.mrs[1]!.pr.title).toBe("updated");
expect(s.read("repo")!.mrs[1]!.codeownerSections).toEqual(["Acme"]);
});
});

describe("fullSync reconcile", () => {
Expand Down Expand Up @@ -251,6 +263,18 @@ describe("applyDelta guards (review fixes)", () => {
expect(s.read("repo")!.mrs[1]!.pr.state).toBe("merged");
expect(changed).toEqual([]);
});

// applyDelta replaces entries wholesale, so a tag on the entry must be
// carried forward the same way divergedCommitsCount already is -- else its
// SQL project_mr_sections row would outlive the in-memory tag.
test("delta preserves an existing codeownerSections tag on the replacement entry", () => {
const s = tmpStore();
s.fullSync("repo", "g/p", [pr(1)], Date.now() - 10_000);
s.setSectionTags("repo", { 1: ["Acme"] });
s.applyDelta("repo", "g/p", [pr(1, { title: "updated" } as any)], Date.now());
expect(s.read("repo")!.mrs[1]!.pr.title).toBe("updated");
expect(s.read("repo")!.mrs[1]!.codeownerSections).toEqual(["Acme"]);
});
});

describe("demand registry", () => {
Expand Down Expand Up @@ -323,6 +347,47 @@ describe("scope", () => {
});
});

describe("section tags", () => {
test("setSectionTags tags, replaces, and clears per iid", () => {
const store = tmpStore();
store.fullSync("r", "g/p", [pr(1), pr(2)], 1000);
store.setSectionTags("r", { 1: ["Acme"] });
expect(store.read("r")!.mrs[1]!.codeownerSections).toEqual(["Acme"]);
expect(store.read("r")!.mrs[2]!.codeownerSections).toBeUndefined();
store.setSectionTags("r", { 1: [] });
expect(store.read("r")!.mrs[1]!.codeownerSections).toBeUndefined();
});

test("setSectionTags replaceAll clears tags the map does not mention", () => {
const store = tmpStore();
store.fullSync("r", "g/p", [pr(1), pr(2)], 1000);
store.setSectionTags("r", { 1: ["Acme"], 2: ["Beta"] });
store.setSectionTags("r", { 2: ["Beta"] }, { replaceAll: true });
expect(store.read("r")!.mrs[1]!.codeownerSections).toBeUndefined();
expect(store.read("r")!.mrs[2]!.codeownerSections).toEqual(["Beta"]);
});

test("fullSync prune drops the pruned row's tag row too", () => {
const db = tmpDb();
const store = createProjectMRs(db);
store.fullSync("r", "g/p", [pr(1)], 1000);
store.setSectionTags("r", { 1: ["Acme"] });
store.fullSync("r", "g/p", [], 2000);
expect(store.read("r")!.mrs[1]).toBeUndefined();
const rows = db.query("SELECT * FROM project_mr_sections WHERE repo = 'r';").all();
expect(rows).toHaveLength(0);
});

test("registerDemand stores sections and scope round-trips them", () => {
const store = tmpStore();
store.registerDemand("r", "board:1", ["ada"], 5, ["Acme"]);
expect(store.read("r")!.demands!["board:1"]!.sections).toEqual(["Acme"]);
store.fullSync("r", "g/p", [], 1000);
store.setScope("r", { authors: ["ada"], sections: ["Acme"], windowDays: 30 });
expect(store.read("r")!.scope).toEqual({ authors: ["ada"], sections: ["Acme"], windowDays: 30 });
});
});

describe("persistence — rows mirror the in-memory model", () => {
test("fullSync writes are visible as project_mrs / project_mrs_meta rows", () => {
const dir = mkdtempSync(join(tmpdir(), "rt-pmrs-"));
Expand Down
Loading
Loading