From 7970edf7c2089b1fca0c1bf60c71fef7efa3916f Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 16 Sep 2026 13:20:56 +0200 Subject: [PATCH] feat(linking): explicit [#42] card references --- src/lib/linking/reference.test.ts | 50 +++++++++++++++++++++++++++++++ src/lib/linking/reference.ts | 27 +++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 src/lib/linking/reference.test.ts create mode 100644 src/lib/linking/reference.ts diff --git a/src/lib/linking/reference.test.ts b/src/lib/linking/reference.test.ts new file mode 100644 index 0000000..33ec757 --- /dev/null +++ b/src/lib/linking/reference.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { extractCardReference } from "@/lib/linking/reference"; + +describe("extractCardReference", () => { + it("finds a bracketed reference at the end of the subject", () => { + expect(extractCardReference("feat(stock): inline qty editing [#42]")).toBe(42); + }); + + it("finds one anywhere in the message, including the body", () => { + expect(extractCardReference("[#7] fix: thing")).toBe(7); + expect(extractCardReference("fix: thing\n\nRefs [#7]\n")).toBe(7); + }); + + it("returns null when there is none", () => { + expect(extractCardReference("feat(stock): inline qty editing")).toBeNull(); + expect(extractCardReference("")).toBeNull(); + }); + + it("ignores a bare #42 without brackets", () => { + // Bare hashes appear constantly in prose — colours, issue numbers from + // other systems, ordinals. Requiring brackets makes the reference a + // deliberate act rather than a guess. + expect(extractCardReference("fix: matches #42 in the old tracker")).toBeNull(); + expect(extractCardReference("style: use #ff8a5c")).toBeNull(); + }); + + it("takes the first reference when several appear", () => { + // One commit links to at most one card, so a second reference cannot be + // honoured. First wins, deterministically. + expect(extractCardReference("fix: thing [#3] and also [#9]")).toBe(3); + }); + + it("rejects zero and negative numbers", () => { + expect(extractCardReference("fix: thing [#0]")).toBeNull(); + expect(extractCardReference("fix: thing [#-4]")).toBeNull(); + }); + + it("rejects a non-numeric reference", () => { + expect(extractCardReference("fix: thing [#abc]")).toBeNull(); + expect(extractCardReference("fix: thing [#]")).toBeNull(); + }); + + it("tolerates whitespace inside the brackets", () => { + expect(extractCardReference("fix: thing [# 42 ]")).toBe(42); + }); + + it("rejects an absurdly large number rather than overflowing", () => { + expect(extractCardReference(`fix: thing [#${"9".repeat(20)}]`)).toBeNull(); + }); +}); \ No newline at end of file diff --git a/src/lib/linking/reference.ts b/src/lib/linking/reference.ts new file mode 100644 index 0000000..4ffeefe --- /dev/null +++ b/src/lib/linking/reference.ts @@ -0,0 +1,27 @@ +/** + * Explicit card references in commit messages: `[#42]`. + * + * Brackets are required. A bare `#42` appears far too often in ordinary prose — + * hex colours, issue numbers from other trackers, ordinals — and a linker that + * guesses at those attaches work to the wrong card, which is worse than not + * linking it at all. + * + * This is the override, not the daily habit: scope matching carries most + * commits. It exists for the times auto-linking gets it wrong. + */ + +/** Above this, the value is not a card number; it is a typo or an overflow. */ +const MAX_CARD_NUMBER = 1_000_000; + +const REFERENCE = /\[#\s*(\d+)\s*\]/; + +export function extractCardReference(message: string): number | null { + const match = REFERENCE.exec(message ?? ""); + if (!match) return null; + + const value = Number(match[1]); + if (!Number.isInteger(value)) return null; + if (value < 1 || value > MAX_CARD_NUMBER) return null; + + return value; +} \ No newline at end of file