Skip to content
245 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge branch 'mirroring' into artifacts-mode1/**
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
A desktop release goes out with what can be built. Linux is built on g1t's machines in every release; Windows and macOS each wait on a repository variable: a machine registered for them, or for Windows a cross build on Linux once aka.ms and download.visualstudio.microsoft.com are workflow-only domains, which is where the first release stopped. A platform with no machine is skipped and the download page says it is coming, with a word for the visitor whose platform this release lacks. The guide and CONTRIBUTING say so.18/** As `RepoArea` in the contracts: the parts of a repository a mirror may or may not offer. */
19export type RepoArea = "code" | "issues" | "pulls" | "agents" | "workflows" | "deployments" | "security" | "insights" | "settings" | "updates";
20
21/**
22 * As `mirrorOffers` in the contracts. A repository that leads, or a
23 * mirror g1t has taken over, offers everything; one standing by or handing
24 * back is a read-only copy: code, what security scanning found, insights
25 * and settings. Workflows run in CI failover, and while standing by only
26 * when the link keeps CI warm.
27 */
28export function offers(mirror: RepoMirror | null | undefined, area: RepoArea): boolean {
29 if (writable(mirror)) return true;
30 if (area === "code" || area === "security" || area === "insights" || area === "settings") return true;
31 if (area === "workflows") return mirror!.state === "ci" || (mirror!.state === "standby" && mirror!.warm === true);
32 return false;
33}
34
35/**
36 * As `mirrorUpdatesWait` in the contracts: why g1t opens no security or
37 * dependency updates on the repository now. Null when it does.
38 */
39export function updatesWaitReason(repo: MirroredRepo | null | undefined): string | null {
40 const mirror = repo?.mirror;
41 if (!repo || !mirror || offers(mirror, "updates")) return null;
42 return `Security updates wait until ${repo.namespace}/${repo.name} is taken over or moved to g1t. It is a mirror of ${mirror.remote}, so nothing opens a branch or a pull request here on its own.`;
43}
44
Merge branch 'mirroring' into artifacts-mode45/** As `TAKE_OVER_AFTER_MINUTES` in the contracts: 5 minutes to a day. */
46export const TAKE_OVER_MINUTES = [5, 24 * 60] as const;
47
48/** What a repository needs to say anything about its mirroring. */
49export type MirroredRepo = { namespace: string; name: string; mirror?: RepoMirror | null };
50
51/**
52 * A remote in brief, as the repository layout loads it for every page.
53 * `syncedAt` when the service says when it last copied, for the line that
54 * says how fresh g1t's copy is.
55 */
56export type MirrorBrief = RemoteBrief & { syncedAt?: string | null };
57
58/** Where the mirroring settings are, and the guide. */
59export const MIRRORING_DOCS = "https://docs.g1t.sh/guides/mirroring/";
60export const mirroringSettings = (base: string) => `${base}/settings/mirroring`;
61
62/**
63 * Why pushing, merging, opening issues and pull requests, and assigning
64 * agents are off: the repository follows a remote that leads. Null when
65 * they are not off for that reason (it leads, or g1t has taken over).
66 */
67export function mirrorReason(repo: MirroredRepo | null | undefined): string | null {
68 const mirror = repo?.mirror;
69 if (!repo || writable(mirror)) return null;
70 const full = `${repo.namespace}/${repo.name}`;
71 if (mirror!.state === "handing_back") {
72 return `${full} is handing back to ${mirror!.remote}; it takes changes again when that is done.`;
73 }
74 return `${full} is a mirror of ${mirror!.remote}. Work happens there until someone takes over in Settings → Mirroring.`;
75}
76
77/**
78 * Why running a workflow or running one again is off. In CI failover g1t
79 * runs the remote's workflows, so they may be run here; a mirror standing
80 * by, or handing back, runs nothing.
81 */
82export function workflowReason(repo: MirroredRepo | null | undefined): string | null {
83 const state = repo?.mirror?.state;
84 if (state === "ci" || state === "takeover") return null;
85 return mirrorReason(repo);
86}
87
88/** The leader a mirror follows, from the briefs. */
89export function leaderOf(briefs: readonly MirrorBrief[] | null | undefined): MirrorBrief | null {
90 return briefs?.find((brief) => brief.role === "leader") ?? null;
91}
92
93/** The remotes that follow this repository, from the briefs. */
94export function followersOf(briefs: readonly MirrorBrief[] | null | undefined): MirrorBrief[] {
95 return (briefs ?? []).filter((brief) => brief.role === "follower");
96}
97
98export type MirrorTone = "neutral" | "info" | "warn" | "accent";
99
100/** The badge beside the repository's name: what it is to its remotes, in a few words. */
101export function mirrorBadge(
102 mirror: RepoMirror | null | undefined,
103 briefs: readonly MirrorBrief[] | null | undefined,
104): { label: string; tone: MirrorTone; more: number } | null {
105 if (mirror) {
106 switch (mirror.state) {
107 case "standby":
108 return { label: `Mirror of ${mirror.remote}`, tone: "neutral", more: 0 };
109 case "ci":
110 return { label: "Mirror · running CI", tone: "info", more: 0 };
111 case "takeover":
112 return { label: `Taken over from ${mirror.remote}`, tone: "warn", more: 0 };
113 case "handing_back":
114 return { label: `Handing back to ${mirror.remote}`, tone: "warn", more: 0 };
115 }
116 }
117 const followers = followersOf(briefs);
118 if (followers.length === 0) return null;
119 return { label: `Mirrored to ${followers[0]!.name}`, tone: "accent", more: followers.length - 1 };
120}
121
122/**
123 * Which line goes across the repository's pages, if any. A mirror standing
124 * by whose remote answers needs none: the badge says it.
125 */
126export type MirrorBannerKind = "unreachable" | "ci" | "takeover" | "handing_back";
127
128export function mirrorBanner(
129 mirror: RepoMirror | null | undefined,
130 briefs: readonly MirrorBrief[] | null | undefined,
131): MirrorBannerKind | null {
132 if (!mirror) return null;
133 const answering = leaderOf(briefs)?.reachable !== false;
134 if ((mirror.state === "standby" || mirror.state === "ci") && !answering) return "unreachable";
135 if (mirror.state === "standby") return null;
136 return mirror.state;
137}
138
139/** What happens to one branch when the takeover goes back. */
140export function refActionWords(action: RefAction, remote: string): string {
141 switch (action) {
142 case "same":
143 return "Already the same";
144 case "push":
145 return `Pushed to ${remote}`;
146 case "fetch":
147 return `Taken from ${remote}`;
148 case "pull_request":
149 return "Sent as a pull request (protected there)";
150 case "diverged":
151 return "Both moved: choose";
152 }
153}
154
155/** The choices for a branch both sides moved, in order. */
156export function decisionChoices(remote: string): { value: RefDecision; label: string }[] {
157 return [
158 { value: "keep_ours", label: "Keep g1t's" },
159 { value: "keep_theirs", label: `Keep ${remote}'s` },
160 { value: "pull_request", label: "Send g1t's as a pull request" },
161 ];
162}
163
164/** A commit's short name, or a dash when the branch is not there. */
165export function shortSha(sha: string | null | undefined): string {
166 return sha ? sha.slice(0, 7) : "—";
167}
168
169/** A branch name as a form field, for its decision. */
170export const decisionField = (ref: string) => `decision:${ref}`;
171
172/** The decisions posted with a hand-back: one per branch both sides moved. */
173export function decisionsFrom(entries: Iterable<[string, FormDataEntryValue]>): Record<string, RefDecision> {
174 const decisions: Record<string, RefDecision> = {};
175 for (const [name, value] of entries) {
176 if (!name.startsWith("decision:")) continue;
177 const decision = String(value);
178 if (decision === "keep_ours" || decision === "keep_theirs" || decision === "pull_request") {
179 decisions[name.slice("decision:".length)] = decision;
180 }
181 }
182 return decisions;
183}
184
185/**
186 * Whether the hand-back can go: the remote answers, and every branch both
187 * sides moved has a decision, the one chosen on the page or the plan's.
188 */
189export function handBackReady(plan: HandbackPlan, chosen: Record<string, RefDecision | undefined>): boolean {
190 if (!plan.reachable) return false;
191 return plan.refs.every((ref) => ref.action !== "diverged" || (chosen[ref.ref] ?? ref.decision) != null);
192}
193
194/** Minutes before an automatic takeover, held to what the service takes. */
195export function clampMinutes(value: number): number {
196 const [least, most] = TAKE_OVER_MINUTES;
197 if (!Number.isFinite(value)) return least;
198 return Math.min(most, Math.max(least, Math.round(value)));
199}
200
201type FormLike = { get(name: string): FormDataEntryValue | null };
202
203/**
204 * A remote's settings from its form. The leader's form (a mirror's remote)
205 * and a follower's carry different fields: what a form does not carry
206 * keeps its value from `current`.
207 */
208export function mirrorSettingsFrom(form: FormLike, current: MirrorSettings): MirrorSettings {
209 if (form.get("kind") === "follower") {
210 const pushes = form.get("remotePushes");
211 return { ...current, remotePushes: pushes === "overwrite" ? "overwrite" : pushes === "adopt" ? "adopt" : current.remotePushes };
212 }
213 const on = (name: string) => form.get(name) === "on";
214 return {
215 ...current,
216 notify: form.get("notify") === "inbox" ? "inbox" : "banner",
217 takeOverAfter: on("autoTakeOver") ? clampMinutes(Number(form.get("takeOverAfter") ?? "")) : null,
218 handBack: on("handBackWhenClean") ? "when_clean" : "ask",
219 keepCiWarm: on("keepCiWarm"),
220 githubWorkflows: on("githubWorkflows"),
221 holdDeploys: on("holdDeploys"),
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.222 // Only a GitHub mirror's form carries it.
223 reportChecks: form.get("provider") === "github" ? on("reportChecks") : current.reportChecks,
Merge branch 'mirroring' into artifacts-mode224 };
225}
226
227/** Words for a remote's state, in its row. */
228export function remoteStateWords(state: string): string {
229 switch (state) {
230 case "standby":
231 return "Standing by";
232 case "ci":
233 return "CI failover";
234 case "takeover":
235 return "Taken over";
236 case "handing_back":
237 return "Handing back";
238 case "following":
239 return "Following";
240 case "stuck":
241 return "Stuck";
242 default:
243 return state;
244 }
245}

This file's history is long; its oldest lines are credited to the oldest commit read.