feat(linking): commit-to-card resolution with date bounding
This commit is contained in:
parent
7970edf7c2
commit
9c27d34c3e
2 changed files with 288 additions and 0 deletions
186
src/lib/linking/resolve.test.ts
Normal file
186
src/lib/linking/resolve.test.ts
Normal 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
102
src/lib/linking/resolve.ts
Normal 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" };
|
||||
}
|
||||
Loading…
Reference in a new issue