Skip to content
216 linesCodeBlameRaw
1/**
2 * Mirroring, as the site says it: the badge beside a repository's name,
3 * the line across its pages, why a button is off on a read-only mirror,
4 * and the settings and hand-back forms read back from what was posted.
5 * The rules themselves (what a mirror refuses) are the integrations
6 * service's; these only put them into words.
7 */
8
9// Types only: the contracts' index does not load under `node --test`.
10// mirror.test.ts holds the copies below to the contracts' own values.
11import type { HandbackPlan, MirrorSettings, RefAction, RefDecision, RemoteBrief, RepoMirror } from "@g1t/contracts";
12
13/** As `mirrorWritable` in the contracts: it leads, or g1t has taken over. */
14export function writable(mirror: RepoMirror | null | undefined): boolean {
15 return !mirror || mirror.state === "takeover";
16}
17
18/** As `TAKE_OVER_AFTER_MINUTES` in the contracts: 5 minutes to a day. */
19export const TAKE_OVER_MINUTES = [5, 24 * 60] as const;
20
21/** What a repository needs to say anything about its mirroring. */
22export type MirroredRepo = { namespace: string; name: string; mirror?: RepoMirror | null };
23
24/**
25 * A remote in brief, as the repository layout loads it for every page.
26 * `syncedAt` when the service says when it last copied, for the line that
27 * says how fresh g1t's copy is.
28 */
29export type MirrorBrief = RemoteBrief & { syncedAt?: string | null };
30
31/** Where the mirroring settings are, and the guide. */
32export const MIRRORING_DOCS = "https://docs.g1t.sh/guides/mirroring/";
33export const mirroringSettings = (base: string) => `${base}/settings/mirroring`;
34
35/**
36 * Why pushing, merging, opening issues and pull requests, and assigning
37 * agents are off: the repository follows a remote that leads. Null when
38 * they are not off for that reason (it leads, or g1t has taken over).
39 */
40export function mirrorReason(repo: MirroredRepo | null | undefined): string | null {
41 const mirror = repo?.mirror;
42 if (!repo || writable(mirror)) return null;
43 const full = `${repo.namespace}/${repo.name}`;
44 if (mirror!.state === "handing_back") {
45 return `${full} is handing back to ${mirror!.remote}; it takes changes again when that is done.`;
46 }
47 return `${full} is a mirror of ${mirror!.remote}. Work happens there until someone takes over in Settings → Mirroring.`;
48}
49
50/**
51 * Why running a workflow or running one again is off. In CI failover g1t
52 * runs the remote's workflows, so they may be run here; a mirror standing
53 * by, or handing back, runs nothing.
54 */
55export function workflowReason(repo: MirroredRepo | null | undefined): string | null {
56 const state = repo?.mirror?.state;
57 if (state === "ci" || state === "takeover") return null;
58 return mirrorReason(repo);
59}
60
61/** The leader a mirror follows, from the briefs. */
62export function leaderOf(briefs: readonly MirrorBrief[] | null | undefined): MirrorBrief | null {
63 return briefs?.find((brief) => brief.role === "leader") ?? null;
64}
65
66/** The remotes that follow this repository, from the briefs. */
67export function followersOf(briefs: readonly MirrorBrief[] | null | undefined): MirrorBrief[] {
68 return (briefs ?? []).filter((brief) => brief.role === "follower");
69}
70
71export type MirrorTone = "neutral" | "info" | "warn" | "accent";
72
73/** The badge beside the repository's name: what it is to its remotes, in a few words. */
74export function mirrorBadge(
75 mirror: RepoMirror | null | undefined,
76 briefs: readonly MirrorBrief[] | null | undefined,
77): { label: string; tone: MirrorTone; more: number } | null {
78 if (mirror) {
79 switch (mirror.state) {
80 case "standby":
81 return { label: `Mirror of ${mirror.remote}`, tone: "neutral", more: 0 };
82 case "ci":
83 return { label: "Mirror · running CI", tone: "info", more: 0 };
84 case "takeover":
85 return { label: `Taken over from ${mirror.remote}`, tone: "warn", more: 0 };
86 case "handing_back":
87 return { label: `Handing back to ${mirror.remote}`, tone: "warn", more: 0 };
88 }
89 }
90 const followers = followersOf(briefs);
91 if (followers.length === 0) return null;
92 return { label: `Mirrored to ${followers[0]!.name}`, tone: "accent", more: followers.length - 1 };
93}
94
95/**
96 * Which line goes across the repository's pages, if any. A mirror standing
97 * by whose remote answers needs none: the badge says it.
98 */
99export type MirrorBannerKind = "unreachable" | "ci" | "takeover" | "handing_back";
100
101export function mirrorBanner(
102 mirror: RepoMirror | null | undefined,
103 briefs: readonly MirrorBrief[] | null | undefined,
104): MirrorBannerKind | null {
105 if (!mirror) return null;
106 const answering = leaderOf(briefs)?.reachable !== false;
107 if ((mirror.state === "standby" || mirror.state === "ci") && !answering) return "unreachable";
108 if (mirror.state === "standby") return null;
109 return mirror.state;
110}
111
112/** What happens to one branch when the takeover goes back. */
113export function refActionWords(action: RefAction, remote: string): string {
114 switch (action) {
115 case "same":
116 return "Already the same";
117 case "push":
118 return `Pushed to ${remote}`;
119 case "fetch":
120 return `Taken from ${remote}`;
121 case "pull_request":
122 return "Sent as a pull request (protected there)";
123 case "diverged":
124 return "Both moved: choose";
125 }
126}
127
128/** The choices for a branch both sides moved, in order. */
129export function decisionChoices(remote: string): { value: RefDecision; label: string }[] {
130 return [
131 { value: "keep_ours", label: "Keep g1t's" },
132 { value: "keep_theirs", label: `Keep ${remote}'s` },
133 { value: "pull_request", label: "Send g1t's as a pull request" },
134 ];
135}
136
137/** A commit's short name, or a dash when the branch is not there. */
138export function shortSha(sha: string | null | undefined): string {
139 return sha ? sha.slice(0, 7) : "—";
140}
141
142/** A branch name as a form field, for its decision. */
143export const decisionField = (ref: string) => `decision:${ref}`;
144
145/** The decisions posted with a hand-back: one per branch both sides moved. */
146export function decisionsFrom(entries: Iterable<[string, FormDataEntryValue]>): Record<string, RefDecision> {
147 const decisions: Record<string, RefDecision> = {};
148 for (const [name, value] of entries) {
149 if (!name.startsWith("decision:")) continue;
150 const decision = String(value);
151 if (decision === "keep_ours" || decision === "keep_theirs" || decision === "pull_request") {
152 decisions[name.slice("decision:".length)] = decision;
153 }
154 }
155 return decisions;
156}
157
158/**
159 * Whether the hand-back can go: the remote answers, and every branch both
160 * sides moved has a decision, the one chosen on the page or the plan's.
161 */
162export function handBackReady(plan: HandbackPlan, chosen: Record<string, RefDecision | undefined>): boolean {
163 if (!plan.reachable) return false;
164 return plan.refs.every((ref) => ref.action !== "diverged" || (chosen[ref.ref] ?? ref.decision) != null);
165}
166
167/** Minutes before an automatic takeover, held to what the service takes. */
168export function clampMinutes(value: number): number {
169 const [least, most] = TAKE_OVER_MINUTES;
170 if (!Number.isFinite(value)) return least;
171 return Math.min(most, Math.max(least, Math.round(value)));
172}
173
174type FormLike = { get(name: string): FormDataEntryValue | null };
175
176/**
177 * A remote's settings from its form. The leader's form (a mirror's remote)
178 * and a follower's carry different fields: what a form does not carry
179 * keeps its value from `current`.
180 */
181export function mirrorSettingsFrom(form: FormLike, current: MirrorSettings): MirrorSettings {
182 if (form.get("kind") === "follower") {
183 const pushes = form.get("remotePushes");
184 return { ...current, remotePushes: pushes === "overwrite" ? "overwrite" : pushes === "adopt" ? "adopt" : current.remotePushes };
185 }
186 const on = (name: string) => form.get(name) === "on";
187 return {
188 ...current,
189 notify: form.get("notify") === "inbox" ? "inbox" : "banner",
190 takeOverAfter: on("autoTakeOver") ? clampMinutes(Number(form.get("takeOverAfter") ?? "")) : null,
191 handBack: on("handBackWhenClean") ? "when_clean" : "ask",
192 keepCiWarm: on("keepCiWarm"),
193 githubWorkflows: on("githubWorkflows"),
194 holdDeploys: on("holdDeploys"),
195 };
196}
197
198/** Words for a remote's state, in its row. */
199export function remoteStateWords(state: string): string {
200 switch (state) {
201 case "standby":
202 return "Standing by";
203 case "ci":
204 return "CI failover";
205 case "takeover":
206 return "Taken over";
207 case "handing_back":
208 return "Handing back";
209 case "following":
210 return "Following";
211 case "stuck":
212 return "Stuck";
213 default:
214 return state;
215 }
216}