diff --git a/scripts/backfill.ts b/scripts/backfill.ts index d7f92d3..1d7f6b7 100644 --- a/scripts/backfill.ts +++ b/scripts/backfill.ts @@ -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; diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts index eeaf298..5625041 100644 --- a/scripts/seed-demo.ts +++ b/scripts/seed-demo.ts @@ -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})`); diff --git a/src/app/api/client/cards/route.ts b/src/app/api/client/cards/route.ts index f04e64b..3f90877 100644 --- a/src/app/api/client/cards/route.ts +++ b/src/app/api/client/cards/route.ts @@ -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"; diff --git a/src/lib/linking/engine.ts b/src/lib/linking/engine.ts new file mode 100644 index 0000000..9fe2a6a --- /dev/null +++ b/src/lib/linking/engine.ts @@ -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 { + const pb = await getAdminPb(); + + 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", + }); + + 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 { + 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 { + const pb = await getAdminPb(); + await pb.collection("commits").update(commitId, { card: "", link_source: "none" }); +} \ No newline at end of file