feat(linking): commit-to-card resolution with date bounding

This commit is contained in:
Dave 2026-09-16 13:21:26 +02:00
parent 7970edf7c2
commit 9c27d34c3e
2 changed files with 288 additions and 0 deletions

View file

@ -0,0 +1,186 @@
import { describe, it, expect } from "vitest";
import { resolveCommitCard, type LinkCandidate, type LinkableCommit } from "@/lib/linking/resolve";
function card(overrides: Partial<LinkCandidate>): LinkCandidate {
return {
id: "card_a",
number: 1,
scopeRule: "",
startDate: "",
created: "2026-01-01 00:00:00.000Z",
archived: false,
...overrides,
};
}
function commit(overrides: Partial<LinkableCommit>): LinkableCommit {
return {
message: "feat(stock): a thing",
scope: "stock",
authoredAt: "2026-06-15 10:00:00.000Z",
isMerge: false,
...overrides,
};
}
describe("resolveCommitCard — explicit references win", () => {
it("links by number regardless of scope", () => {
const cards = [
card({ id: "by_scope", number: 1, scopeRule: "stock" }),
card({ id: "by_number", number: 42, scopeRule: "quotes" }),
];
const result = resolveCommitCard(
commit({ message: "feat(stock): thing [#42]" }),
cards
);
expect(result).toEqual({ cardId: "by_number", source: "explicit" });
});
it("links by number even before the card's bound", () => {
// An explicit reference is a human statement of intent. Date bounding
// exists to stop SCOPE rules over-reaching, not to overrule a person.
const cards = [card({ id: "late", number: 42, created: "2026-12-01 00:00:00.000Z" })];
const result = resolveCommitCard(commit({ message: "fix: thing [#42]" }), cards);
expect(result).toEqual({ cardId: "late", source: "explicit" });
});
it("falls through to scope when the number matches no card", () => {
const cards = [card({ id: "scoped", number: 1, scopeRule: "stock" })];
const result = resolveCommitCard(
commit({ message: "feat(stock): thing [#999]" }),
cards
);
expect(result).toEqual({ cardId: "scoped", source: "scope" });
});
});
describe("resolveCommitCard — scope matching", () => {
it("links a commit to the card owning its scope", () => {
const cards = [card({ id: "stock", scopeRule: "stock" })];
expect(resolveCommitCard(commit({}), cards)).toEqual({
cardId: "stock",
source: "scope",
});
});
it("does not link when the commit has no scope", () => {
const cards = [card({ id: "stock", scopeRule: "stock" })];
expect(resolveCommitCard(commit({ scope: "" }), cards)).toEqual({
cardId: null,
source: "none",
});
});
it("matches case-insensitively", () => {
const cards = [card({ id: "stock", scopeRule: "Stock" })];
expect(resolveCommitCard(commit({ scope: "stock" }), cards).cardId).toBe("stock");
});
it("ignores cards with no scope rule", () => {
const cards = [card({ id: "plain", scopeRule: "" })];
expect(resolveCommitCard(commit({}), cards).cardId).toBeNull();
});
it("ignores archived cards", () => {
const cards = [card({ id: "old", scopeRule: "stock", archived: true })];
expect(resolveCommitCard(commit({}), cards).cardId).toBeNull();
});
});
describe("resolveCommitCard — date bounding", () => {
it("does not let a card claim commits from before it existed", () => {
// The rot this prevents: a new "Stock pricing v2" card swallowing a year
// of old feat(stock) commits and claiming work it did not do.
const cards = [
card({ id: "new", scopeRule: "stock", created: "2026-08-01 00:00:00.000Z" }),
];
expect(
resolveCommitCard(commit({ authoredAt: "2026-06-15 10:00:00.000Z" }), cards)
.cardId
).toBeNull();
});
it("uses start_date when set", () => {
const cards = [
card({
id: "started",
scopeRule: "stock",
created: "2026-01-01 00:00:00.000Z",
startDate: "2026-08-01 00:00:00.000Z",
}),
];
expect(
resolveCommitCard(commit({ authoredAt: "2026-06-15 10:00:00.000Z" }), cards)
.cardId
).toBeNull();
});
it("falls back to created when start_date is empty", () => {
// Owner-created cards never get a start_date — only the accept flow sets
// one — so a literal start_date-only rule would make them match nothing.
const cards = [
card({ id: "owner", scopeRule: "stock", startDate: "", created: "2026-01-01 00:00:00.000Z" }),
];
expect(resolveCommitCard(commit({}), cards).cardId).toBe("owner");
});
it("includes a commit exactly on the bound", () => {
const cards = [
card({ id: "exact", scopeRule: "stock", created: "2026-06-15 10:00:00.000Z" }),
];
expect(resolveCommitCard(commit({}), cards).cardId).toBe("exact");
});
});
describe("resolveCommitCard — one commit, one card", () => {
it("gives the commit to the eligible card with the latest bound", () => {
const cards = [
card({ id: "v1", number: 1, scopeRule: "stock", created: "2026-01-01 00:00:00.000Z" }),
card({ id: "v2", number: 2, scopeRule: "stock", created: "2026-05-01 00:00:00.000Z" }),
];
expect(resolveCommitCard(commit({}), cards).cardId).toBe("v2");
});
it("leaves an earlier commit with the earlier card", () => {
const cards = [
card({ id: "v1", number: 1, scopeRule: "stock", created: "2026-01-01 00:00:00.000Z" }),
card({ id: "v2", number: 2, scopeRule: "stock", created: "2026-05-01 00:00:00.000Z" }),
];
expect(
resolveCommitCard(commit({ authoredAt: "2026-03-01 10:00:00.000Z" }), cards)
.cardId
).toBe("v1");
});
it("breaks an exact tie by the higher card number, deterministically", () => {
const cards = [
card({ id: "a", number: 1, scopeRule: "stock", created: "2026-01-01 00:00:00.000Z" }),
card({ id: "b", number: 2, scopeRule: "stock", created: "2026-01-01 00:00:00.000Z" }),
];
expect(resolveCommitCard(commit({}), cards).cardId).toBe("b");
// Order of the input must not change the answer.
expect(resolveCommitCard(commit({}), [...cards].reverse()).cardId).toBe("b");
});
});
describe("resolveCommitCard — merges", () => {
it("never links a merge commit", () => {
// A merge is not a unit of work; attaching it to a card would inflate that
// card's commit count and drag its session boundaries around.
const cards = [card({ id: "stock", scopeRule: "stock" })];
expect(resolveCommitCard(commit({ isMerge: true }), cards)).toEqual({
cardId: null,
source: "none",
});
});
it("does not link a merge even with an explicit reference", () => {
const cards = [card({ id: "stock", number: 42 })];
expect(
resolveCommitCard(
commit({ isMerge: true, message: "Merge branch 'x' [#42]" }),
cards
).cardId
).toBeNull();
});
});

102
src/lib/linking/resolve.ts Normal file
View file

@ -0,0 +1,102 @@
/**
* Decide which card a commit belongs to.
*
* Pure and database-free, so the precedence chain is verifiable rather than
* asserted. Every rule here exists to stop a specific kind of quiet wrongness —
* see the tests, which name each one.
*/
import { extractCardReference } from "@/lib/linking/reference";
export interface LinkCandidate {
id: string;
number: number;
/** The conventional-commit scope this card claims, or "". */
scopeRule: string;
/** PocketBase date string, or "" when never started. */
startDate: string;
created: string;
archived: boolean;
}
export interface LinkableCommit {
message: string;
/** Parsed conventional-commit scope, or "". */
scope: string;
authoredAt: string;
isMerge: boolean;
}
export type LinkSource = "explicit" | "scope" | "none";
export interface LinkResolution {
cardId: string | null;
source: LinkSource;
}
const UNLINKED: LinkResolution = { cardId: null, source: "none" };
/**
* The earliest commit a card may claim by scope.
*
* `start_date` when the card was accepted through the Inbox flow, otherwise
* `created`. A literal start_date-only rule would exclude every card the owner
* created directly, which is most of them.
*/
function bound(card: LinkCandidate): string {
return card.startDate || card.created;
}
/** PocketBase dates compare correctly as strings once normalised to ISO. */
function comparable(value: string): string {
return (value ?? "").replace(" ", "T");
}
export function resolveCommitCard(
commit: LinkableCommit,
candidates: LinkCandidate[]
): LinkResolution {
// A merge is real history but not a unit of work. Linking one would inflate
// a card's commit count and distort its effort estimate.
if (commit.isMerge) return UNLINKED;
const live = candidates.filter((c) => !c.archived);
// 1. Explicit reference. Deliberately not date-bounded: bounding exists to
// stop scope rules over-reaching, not to overrule a person who typed a
// card number on purpose.
const referenced = extractCardReference(commit.message);
if (referenced !== null) {
const match = live.find((c) => c.number === referenced);
if (match) return { cardId: match.id, source: "explicit" };
// Fall through: a stale or mistyped number should still let scope work.
}
// 2. Scope rule, bounded by the card's start.
if (!commit.scope) return UNLINKED;
const authored = comparable(commit.authoredAt);
const eligible = live.filter(
(c) =>
c.scopeRule &&
c.scopeRule.toLowerCase() === commit.scope.toLowerCase() &&
comparable(bound(c)) <= authored
);
if (eligible.length === 0) return UNLINKED;
// One commit, one card. The winner is the eligible card that started most
// recently — so "Stock pricing v2" takes July's commits while the January
// card keeps January's. Ties break on the higher number so the answer never
// depends on the order the candidates arrived in.
const winner = eligible.reduce((best, current) => {
const a = comparable(bound(best));
const b = comparable(bound(current));
if (b > a) return current;
if (b < a) return best;
return current.number > best.number ? current : best;
});
return { cardId: winner.id, source: "scope" };
}