/*
Unit tests for link extractor covering extractLinks; issue references; PR references.
- Not a contract source of truth — the module under test owns the authoritative behavior; fixtures are minimal scaffolding only.
- RFC-1311: Compass header sweep — declares the link suite's contract for test-file scaffold coverage.
*/
import { describe, it, expect } from "vitest";
import {
extractLinks,
formatLinksMarkdown,
buildCommitLinksMap,
} from "../changelog/utils/link-extractor.ts";
import type { RawCommit } from "../changelog/types.ts";
const REPO = "https://github.com/org/repo";
// ─── extractLinks ────────────────────────────────────────────────────────────
describe("extractLinks", () => {
describe("issue references", () => {
it("parses 'closes #321'", () => {
const { issues } = extractLinks("fix: auth error closes #321", REPO);
expect(issues).toHaveLength(1);
expect(issues[0]!.number).toBe("321");
expect(issues[0]!.url).toBe(`${REPO}/issues/321`);
});
it("parses 'fixes #42' (without trailing s)", () => {
const { issues } = extractLinks("fix stuff fixes #42", REPO);
expect(issues[0]!.number).toBe("42");
});
it("parses 'resolves #7'", () => {
const { issues } = extractLinks("resolves #7", REPO);
expect(issues[0]!.number).toBe("7");
});
it("is case-insensitive (Closes, FIXES)", () => {
expect(extractLinks("Closes #1", REPO).issues).toHaveLength(1);
expect(extractLinks("FIXES #2", REPO).issues).toHaveLength(1);
});
it("deduplicates repeated issue refs", () => {
const { issues } = extractLinks("closes #10 and also closes #10", REPO);
expect(issues).toHaveLength(1);
});
it("extracts multiple distinct issues", () => {
const { issues } = extractLinks("fixes #1 and closes #2", REPO);
expect(issues).toHaveLength(2);
expect(issues.map((i) => i.number)).toEqual(["1", "2"]);
});
});
describe("PR references", () => {
it("parses squash-merge style '(#328)'", () => {
const { prs } = extractLinks("feat: add OAuth (#328)", REPO);
expect(prs).toHaveLength(1);
expect(prs[0]!.number).toBe("328");
expect(prs[0]!.url).toBe(`${REPO}/pull/328`);
});
it("deduplicates repeated PR refs", () => {
const { prs } = extractLinks("feat (#5) and (#5) again", REPO);
expect(prs).toHaveLength(1);
});
it("extracts multiple distinct PRs", () => {
const { prs } = extractLinks("feat (#10) (#20)", REPO);
expect(prs).toHaveLength(2);
});
});
describe("without repoUrl", () => {
it("uses bare '#N' as URL when repoUrl is absent", () => {
const { issues } = extractLinks("closes #99");
expect(issues[0]!.url).toBe("#99");
});
it("uses bare '#N' for PRs too", () => {
const { prs } = extractLinks("feat (#12)");
expect(prs[0]!.url).toBe("#12");
});
});
describe("no references", () => {
it("returns empty arrays for plain commit messages", () => {
const { issues, prs } = extractLinks("feat: add dark mode", REPO);
expect(issues).toHaveLength(0);
expect(prs).toHaveLength(0);
});
it("returns empty arrays for empty string", () => {
const result = extractLinks("", REPO);
expect(result.issues).toHaveLength(0);
expect(result.prs).toHaveLength(0);
});
});
});
// ─── formatLinksMarkdown ─────────────────────────────────────────────────────
describe("formatLinksMarkdown", () => {
it("returns empty string when no links", () => {
expect(formatLinksMarkdown({ issues: [], prs: [] })).toBe("");
});
it("formats a single issue", () => {
const md = formatLinksMarkdown({
issues: [{ number: "321", url: `${REPO}/issues/321` }],
prs: [],
});
expect(md).toBe(` (closes [#321](${REPO}/issues/321))`);
});
it("formats a single PR", () => {
const md = formatLinksMarkdown({
issues: [],
prs: [{ number: "328", url: `${REPO}/pull/328` }],
});
expect(md).toBe(` ([PR #328](${REPO}/pull/328))`);
});
it("formats issue and PR together", () => {
const md = formatLinksMarkdown({
issues: [{ number: "321", url: `${REPO}/issues/321` }],
prs: [{ number: "328", url: `${REPO}/pull/328` }],
});
expect(md).toBe(` (closes [#321](${REPO}/issues/321), [PR #328](${REPO}/pull/328))`);
});
it("formats multiple issues", () => {
const md = formatLinksMarkdown({
issues: [
{ number: "1", url: `${REPO}/issues/1` },
{ number: "2", url: `${REPO}/issues/2` },
],
prs: [],
});
expect(md).toBe(` (closes [#1](${REPO}/issues/1), closes [#2](${REPO}/issues/2))`);
});
});
// ─── buildCommitLinksMap ─────────────────────────────────────────────────────
function makeCommit(hash: string, message: string, body?: string): RawCommit {
return {
hash,
treeHash: "tree",
author: "test",
date: new Date("2026-01-01"),
message,
body,
files: [],
diffSummary: "",
isConventional: true,
};
}
describe("buildCommitLinksMap", () => {
it("returns empty map when no commits have refs", () => {
const commits = [makeCommit("aaa", "feat: add dark mode")];
const map = buildCommitLinksMap(commits, REPO);
expect(map.size).toBe(0);
});
it("maps hash to links extracted from message", () => {
const commits = [makeCommit("bbb", "fix: auth error closes #321 (#99)")];
const map = buildCommitLinksMap(commits, REPO);
expect(map.has("bbb")).toBe(true);
expect(map.get("bbb")!.issues[0]!.number).toBe("321");
expect(map.get("bbb")!.prs[0]!.number).toBe("99");
});
it("extracts refs from body as well as message", () => {
const commits = [makeCommit("ccc", "feat: add OAuth", "This closes #42")];
const map = buildCommitLinksMap(commits, REPO);
expect(map.get("ccc")!.issues[0]!.number).toBe("42");
});
it("only includes commits that have at least one ref", () => {
const commits = [
makeCommit("plain", "refactor: clean up"),
makeCommit("linked", "fix: crash closes #5"),
];
const map = buildCommitLinksMap(commits, REPO);
expect(map.has("plain")).toBe(false);
expect(map.has("linked")).toBe(true);
});
it("handles multiple commits independently", () => {
const commits = [makeCommit("h1", "fix: a closes #1"), makeCommit("h2", "fix: b closes #2")];
const map = buildCommitLinksMap(commits, REPO);
expect(map.get("h1")!.issues[0]!.number).toBe("1");
expect(map.get("h2")!.issues[0]!.number).toBe("2");
});
});