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);
|
const result = await fetchCommitPage(project.repo_owner, project.repo_name, page);
|
||||||
if (result.commits.length === 0) break;
|
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({
|
const existing = await pb.collection("commits").getFullList({
|
||||||
filter: `project = "${projectId}" && (${shas})`,
|
filter: `project = "${projectId}" && (${shas})`,
|
||||||
fields: "sha",
|
fields: "sha",
|
||||||
});
|
});
|
||||||
const known = new Set(existing.map((r) => r.sha as string));
|
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)) {
|
if (known.has(commit.sha)) {
|
||||||
skipped += 1;
|
skipped += 1;
|
||||||
continue;
|
continue;
|
||||||
|
|
|
||||||
|
|
@ -155,21 +155,6 @@ async function main() {
|
||||||
);
|
);
|
||||||
position += SPACING;
|
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("Seeded:");
|
||||||
console.log(` client ${client.id} (${DEMO.clientSlug})`);
|
console.log(` client ${client.id} (${DEMO.clientSlug})`);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
canWriteClientFields,
|
canWriteClientFields,
|
||||||
} from "@/lib/auth/access";
|
} from "@/lib/auth/access";
|
||||||
import { pickClientCardCreate } from "@/lib/cards/client-patch";
|
import { pickClientCardCreate } from "@/lib/cards/client-patch";
|
||||||
|
import { nextCardNumber } from "@/lib/cards/number";
|
||||||
import { positionBetween } from "@/lib/ordering/fractional";
|
import { positionBetween } from "@/lib/ordering/fractional";
|
||||||
import { jsonError, jsonOk } from "@/lib/http/json";
|
import { jsonError, jsonOk } from "@/lib/http/json";
|
||||||
import type { CardRecord, ColumnRecord, ProjectRecord } from "@/lib/pb/types";
|
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