Fork Horse repository, listed by its owner. Publisher snapshot.
packages/forkhorse-dashboard/src/needs-you-model.ts
Snapshot at 34e3175
1import { adviceForReview } from "./judge.ts";
2import type { Brand, ProjectState } from "./contracts.ts";
3import { isArchived, blockedLabel, blockerPacket, reviewGate, reviewState } from "./model.ts";
4import { duration } from "./receipts.tsx";
5import { relativeTime } from "./time.ts";
6
7export type NeedKind =
8 | "promotionPending"
9 | "reconciler"
10 | "blocker"
11 | "failed"
12 | "promote"
13 | "approve";
14export type CopyPart = string | { code: string; full?: string };
15export interface OwnerNeed {
16 id: string;
17 kind: NeedKind;
18 title: CopyPart[];
19 why: CopyPart[];
20 outcome: CopyPart[];
21 target: { review: string } | { packet: string; collision?: string } | { repo: string } | { promotion: true };
22}
23export const copyText = (parts: CopyPart[]) =>
24 parts.map((part) => (typeof part === "string" ? part : part.code)).join("");
25const sha = (value?: string): CopyPart =>
26 value ? { code: value.slice(0, 7), full: value } : "an unavailable head";
27const mono = (code: string): CopyPart => ({ code });
28const timestamp = (value?: string) => Date.parse(value ?? "") || 0;
29// Core persists receive-sequence IDs for alerts and collisions; current-head has no sequence.
30const sequence = (id?: string) =>
31 /^\d+(?:-|$)/.test(id ?? "") ? Number(id!.split("-")[0]) : Infinity;
32
33/** Public state only. Use the review panel's gate, never a second approval policy. */
34export function ownerNeeds(state: ProjectState, brand: Brand, now = Date.now()): OwnerNeed[] {
35 const items: OwnerNeed[] = [];
36 const pending = state.promotionPending;
37 if (pending) {
38 const review = state.reviews.find((r) => r.id === pending.review);
39 items.push({
40 id: `promotion:${pending.id}`,
41 kind: "promotionPending",
42 title: [`Confirm a promotion of ${review?.packet ?? pending.review}`],
43 why: [
44 `${brand.name} started moving `,
45 mono("main"),
46 " ",
47 sha(pending.before),
48 " → ",
49 sha(pending.after),
50 " but couldn't confirm the result.",
51 ],
52 outcome: ["Until it's confirmed, the ledger may not match ", mono("main"), "."],
53 target: pending.review ? { review: pending.review } : { promotion: true },
54 });
55 }
56 for (const alert of state.reconcilerAlerts.toSorted((a, b) => sequence(a.id) - sequence(b.id))) {
57 items.push({
58 id: `alert:${alert.id ?? `${alert.sha}:${alert.message}`}`,
59 kind: "reconciler",
60 title: [mono(alert.ref ?? "main"), ` moved outside ${brand.name}`],
61 why:
62 alert.before && alert.after
63 ? [
64 mono(alert.repoName ?? state.project.repoName),
65 " went ",
66 sha(alert.before),
67 " → ",
68 sha(alert.after),
69 " without a promotion.",
70 ]
71 : [alert.message, " ", sha(alert.sha)],
72 outcome: alert.before
73 ? ["Open reviews based on ", sha(alert.before), " may now be stale."]
74 : ["Open reviews may now be stale."],
75 target: { repo: alert.repoName ?? state.project.repoName },
76 });
77 }
78 for (const blocker of state.blockers
79 .filter((b) => !b.resolved && !isArchived(state, b.packet) &&
80 !(b.pushingPacket && isArchived(state, b.pushingPacket)))
81 .toSorted((a, b) => sequence(a.id) - sequence(b.id))) {
82 items.push({
83 id: `blocker:${blocker.id}`,
84 kind: "blocker",
85 title: [`Sort out a claim collision on ${blocker.packet}`],
86 why:
87 blocker.pushingAgent && blocker.claimingAgent && blocker.sha && blocker.paths.length
88 ? [
89 `${blocker.pushingAgent} pushed `,
90 sha(blocker.sha),
91 " to ",
92 mono(blocker.paths[0]),
93 ...(blocker.paths.length > 1 ? [` +${blocker.paths.length - 1}`] : []),
94 `, which ${blocker.claimingAgent} has claimed.`,
95 ]
96 : [blocker.reason],
97 outcome: [`${blockedLabel(blocker)} stays blocked until the collision is resolved.`],
98 target: { packet: blockerPacket(blocker), collision: blocker.id },
99 });
100 }
101 const required = state.project.settings?.requireReceipt === true;
102 const reviews = state.reviews
103 .filter(
104 (review) =>
105 !isArchived(state, review.packet) &&
106 reviewState(review, state).kind === "current" &&
107 review.status !== "stale" &&
108 state.forks.find((f) => f.repoName === review.repoName)?.head === review.sha,
109 )
110 .toSorted(
111 (a, b) => timestamp(a.requestedAt) - timestamp(b.requestedAt) || a.id.localeCompare(b.id),
112 );
113 for (const review of reviews) {
114 const receipt = review.receipt;
115 if (
116 !required ||
117 !receipt ||
118 !["failed", "timed-out", "infrastructure-error"].includes(receipt.status)
119 )
120 continue;
121 const checks = receipt.checks.filter((check) => check.outcome === receipt.status);
122 const count = receipt.checks.length;
123 const why: CopyPart[] =
124 receipt.status === "failed"
125 ? [`${checks.length} of ${count} ${count === 1 ? "check" : "checks"} failed`]
126 : receipt.status === "timed-out"
127 ? ["Checks timed out"]
128 : ["The checks didn't start"];
129 for (const check of checks) {
130 why.push(" · ", mono(check.name));
131 if (duration(check.durationMs)) why.push(` (${duration(check.durationMs)})`);
132 }
133 items.push({
134 id: `failed:${review.id}`,
135 kind: "failed",
136 title: [`${review.packet} didn't pass its checks`],
137 why,
138 outcome:
139 receipt.status === "failed"
140 ? [
141 `It can't be approved until ${review.agent} pushes a fix. Return it to say what to change.`,
142 ]
143 : ["Promote or a new push runs the checks again."],
144 target: { review: review.id },
145 });
146 }
147 for (const kind of ["promote", "approve"] as const) {
148 for (const review of reviews) {
149 // A failed-required row routes to the evidence once, even when an interrupted approved run can retry.
150 if (
151 required &&
152 review.receipt &&
153 ["failed", "timed-out", "infrastructure-error"].includes(review.receipt.status)
154 )
155 continue;
156 const gate = reviewGate(review, state);
157 if (!gate[kind]) continue;
158 items.push({
159 id: `${kind}:${review.id}`,
160 kind,
161 title:
162 kind === "promote"
163 ? [`Promote ${review.packet} to `, mono("main")]
164 : [`Review ${review.packet} from ${review.agent}`],
165 why:
166 kind === "promote"
167 ? [
168 "Approved at ",
169 sha(review.sha),
170 ...(review.receipt?.status === "passed" ? [" · Checks passed"] : []),
171 ]
172 : [
173 `Requested ${relativeTime(review.requestedAt ?? "", now) || "at an unavailable time"}`,
174 ...(required ? [" · Checks passed"] : [" · Checks advisory"]),
175 ],
176 outcome:
177 kind === "promote"
178 ? [
179 mono("main"),
180 " moves ",
181 sha(review.base),
182 " → ",
183 sha(review.sha),
184 ".",
185 ...(required ? [" Checks run again first."] : []),
186 ...(state.packets.find((packet) => packet.id === review.packet)?.mode === "race"
187 ? [" First promote wins the race."]
188 : []),
189 ...(adviceForReview(state, review)?.index === 0 ? [" The judge picked this entrant."] : []),
190 ]
191 : ["Approving lets you promote it to ", mono("main"), "."],
192 target: { review: review.id },
193 });
194 }
195 }
196 return items;
197}
198