Fork Horse repository, listed by its owner. Publisher snapshot.

packages/forkhorse-dashboard/src/needs-you-model.ts

Snapshot at 34e3175

Containing directory

7,605 bytes · typescriptView raw
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