chore(relink): CLI helper for off-server relink runs

This commit is contained in:
Dave 2026-09-16 13:36:42 +02:00
parent 7fc87042f6
commit a1ef2ff55c
2 changed files with 88 additions and 6 deletions

82
scripts/relink.ts Normal file
View file

@ -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 <projectId>
*/
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 <projectId>");
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);
});

View file

@ -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 } },
},
},
],