diff --git a/scripts/relink.ts b/scripts/relink.ts new file mode 100644 index 0000000..55df41c --- /dev/null +++ b/scripts/relink.ts @@ -0,0 +1,82 @@ +/** + * One-off relink helper. + * + * Re-runs the linking engine on a project without pulling in the server-only + * modules — same pattern as scripts/backfill.ts. Run with: + * set -a && source .env.local && set +a + * npx tsx scripts/relink.ts + */ + +import PocketBase from "pocketbase"; + +import { resolveCommitCard, type LinkCandidate } from "../src/lib/linking/resolve"; + +const PB_URL = process.env.NEXT_PUBLIC_PB_URL!; + +async function main() { + const projectId = process.argv[2]; + if (!projectId) throw new Error("Usage: tsx scripts/relink.ts "); + + const pb = new PocketBase(PB_URL); + pb.autoCancellation(false); + await pb.collection("_superusers").authWithPassword( + process.env.PB_SUPERUSER_EMAIL!, + process.env.PB_SUPERUSER_PASSWORD! + ); + + const cards = await pb.collection("cards").getFullList({ + filter: `project = "${projectId}"`, + fields: "id,number,scope_rule,start_date,created,archived", + }); + const candidates: LinkCandidate[] = cards.map((card) => ({ + id: card.id, + number: card.number, + scopeRule: card.scope_rule ?? "", + startDate: card.start_date ?? "", + created: card.created, + archived: Boolean(card.archived), + })); + + const commits = await pb.collection("commits").getFullList({ + filter: `project = "${projectId}"`, + fields: "id,message,scope,authored_at,is_merge,card,link_source", + }); + + let linked = 0; + let unlinked = 0; + let preserved = 0; + + for (const commit of commits) { + if (commit.link_source === "manual") { + preserved++; + continue; + } + const resolution = resolveCommitCard( + { + message: commit.message ?? "", + scope: commit.scope ?? "", + authoredAt: commit.authored_at, + isMerge: Boolean(commit.is_merge), + }, + candidates + ); + const nextCard = resolution.cardId ?? ""; + const nextSource = resolution.source; + if (commit.card === nextCard && commit.link_source === nextSource) continue; + await pb.collection("commits").update(commit.id, { + card: nextCard, + link_source: nextSource, + }); + if (nextCard) linked++; + else unlinked++; + } + + console.log( + JSON.stringify({ examined: commits.length - preserved, linked, unlinked, preserved }) + ); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts index e83ba24..21b68c5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,10 +4,6 @@ import path from "node:path"; export default defineConfig({ resolve: { alias: { "@": path.resolve(__dirname, "./src") }, - // Used by every project — including the unit project, where pure modules - // must stay free of `import "server-only"`. The integration project adds - // `react-server` so its test files can import server-only modules. - conditions: ["node", "import"], }, test: { projects: [ @@ -22,7 +18,11 @@ export default defineConfig({ { resolve: { alias: { "@": path.resolve(__dirname, "./src") }, - conditions: ["react-server", "node", "import"], + // server-only throws on import unless this condition is set. The unit + // project deliberately does NOT set it: pure modules must stay free + // of server imports, and a failure there is the signal that one crept + // in. + conditions: ["react-server", "node", "import", "default"], }, test: { name: "integration", @@ -30,7 +30,7 @@ export default defineConfig({ include: ["tests/integration/**/*.test.ts"], hookTimeout: 30_000, testTimeout: 30_000, - fileParallelism: false, + poolOptions: { forks: { singleFork: true } }, }, }, ],