feat(linking): idempotent relink engine preserving manual links
This commit is contained in:
parent
9c27d34c3e
commit
25b504c901
4 changed files with 116 additions and 17 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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})`);
|
||||
|
|
|
|||
|
|
@ -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
113
src/lib/linking/engine.ts
Normal 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" });
|
||||
}
|
||||
Loading…
Reference in a new issue