feat(linking): idempotent relink engine preserving manual links

This commit is contained in:
Dave 2026-09-16 13:23:33 +02:00
parent 9c27d34c3e
commit 25b504c901
4 changed files with 116 additions and 17 deletions

View file

@ -69,14 +69,14 @@ async function main() {
const result = await fetchCommitPage(project.repo_owner, project.repo_name, page);
if (result.commits.length === 0) break;
const shas = result.commits.map((c) => `sha = "${c.sha}"`).join(" || ");
const shas = result.commits.map((c: { sha: string }) => `sha = "${c.sha}"`).join(" || ");
const existing = await pb.collection("commits").getFullList({
filter: `project = "${projectId}" && (${shas})`,
fields: "sha",
});
const known = new Set(existing.map((r) => r.sha as string));
for (const commit of result.commits) {
for (const commit of result.commits as Array<{ sha: string; commit: { message: string; author: { name: string; email: string; date: string } }; parents?: Array<{ sha: string }>; stats?: { additions: number; deletions: number }; html_url: string }>) {
if (known.has(commit.sha)) {
skipped += 1;
continue;

View file

@ -155,21 +155,6 @@ async function main() {
);
position += SPACING;
}
`project = "${project.id}" && title = "${card.title}"`,
{
project: project.id,
column: columns[card.column],
title: card.title,
description: "",
position,
priority: card.priority,
created_by_role: "owner",
accepted: true,
archived: false,
}
);
position += SPACING;
}
console.log("Seeded:");
console.log(` client ${client.id} (${DEMO.clientSlug})`);

View file

@ -6,6 +6,7 @@ import {
canWriteClientFields,
} from "@/lib/auth/access";
import { pickClientCardCreate } from "@/lib/cards/client-patch";
import { nextCardNumber } from "@/lib/cards/number";
import { positionBetween } from "@/lib/ordering/fractional";
import { jsonError, jsonOk } from "@/lib/http/json";
import type { CardRecord, ColumnRecord, ProjectRecord } from "@/lib/pb/types";

113
src/lib/linking/engine.ts Normal file
View file

@ -0,0 +1,113 @@
import "server-only";
import { getAdminPb } from "@/lib/pb/admin";
import { resolveCommitCard, type LinkCandidate } from "@/lib/linking/resolve";
import type { CardRecord, CommitRecord } from "@/lib/pb/types";
export interface RelinkResult {
examined: number;
linked: number;
unlinked: number;
preserved: number;
}
/**
* Recompute every derived link on a project.
*
* Idempotent and safe to run repeatedly: it reads the current state, decides
* again from the rules, and writes only where the answer changed.
*
* `manual` links are PRESERVED, never recomputed. A link the owner made by hand
* from the Unassigned tray is a human decision that outranks every rule in
* resolve.ts — overwriting it on the next push would make the tray pointless
* and would look, from the owner's side, like the app undoing their work.
*/
export async function relinkProject(projectId: string): Promise<RelinkResult> {
const pb = await getAdminPb();
const cards = await pb.collection("cards").getFullList<CardRecord>({
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<CommitRecord>({
filter: `project = "${projectId}"`,
fields: "id,message,scope,authored_at,is_merge,card,link_source",
});
const result: RelinkResult = {
examined: 0,
linked: 0,
unlinked: 0,
preserved: 0,
};
for (const commit of commits) {
if (commit.link_source === "manual") {
result.preserved += 1;
continue;
}
result.examined += 1;
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;
// Write only on change. A no-op update would bump `updated` on every
// commit row on every push, which makes the collection useless for
// spotting what actually moved.
if (commit.card === nextCard && commit.link_source === nextSource) continue;
await pb.collection("commits").update(commit.id, {
card: nextCard,
link_source: nextSource,
});
if (nextCard) result.linked += 1;
else result.unlinked += 1;
}
return result;
}
/** Attach a commit to a card by hand. Outranks and survives re-linking. */
export async function linkCommitManually(
commitId: string,
cardId: string
): Promise<void> {
const pb = await getAdminPb();
await pb
.collection("commits")
.update(commitId, { card: cardId, link_source: "manual" });
}
/**
* Detach a commit.
*
* Reset to `none` rather than to `manual`, so the next re-link is free to
* reconsider it — "this is not right" should not also mean "never look at this
* again".
*/
export async function unlinkCommit(commitId: string): Promise<void> {
const pb = await getAdminPb();
await pb.collection("commits").update(commitId, { card: "", link_source: "none" });
}