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-mode | 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. | |
| 11 | import type { HandbackPlan, MirrorSettings, RefAction, RefDecision, RemoteBrief, RepoMirror } from "@g1t/contracts"; | |
| 12 | ||
| 13 | /** As `mirrorWritable` in the contracts: it leads, or g1t has taken over. */ | |
| 14 | export 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. */ |
| 19 | export 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 | */ | |
| 28 | export 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 | */ | |
| 39 | export 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-mode | 45 | /** As `TAKE_OVER_AFTER_MINUTES` in the contracts: 5 minutes to a day. */ |
| 46 | export const TAKE_OVER_MINUTES = [5, 24 * 60] as const; | |
| 47 | ||
| 48 | /** What a repository needs to say anything about its mirroring. */ | |
| 49 | export 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 | */ | |
| 56 | export type MirrorBrief = RemoteBrief & { syncedAt?: string | null }; | |
| 57 | ||
| 58 | /** Where the mirroring settings are, and the guide. */ | |
| 59 | export const MIRRORING_DOCS = "https://docs.g1t.sh/guides/mirroring/"; | |
| 60 | export 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 | */ | |
| 67 | export 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 | */ | |
| 82 | export 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. */ | |
| 89 | export 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. */ | |
| 94 | export function followersOf(briefs: readonly MirrorBrief[] | null | undefined): MirrorBrief[] { | |
| 95 | return (briefs ?? []).filter((brief) => brief.role === "follower"); | |
| 96 | } | |
| 97 | ||
| 98 | export 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. */ | |
| 101 | export 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 | */ | |
| 126 | export type MirrorBannerKind = "unreachable" | "ci" | "takeover" | "handing_back"; | |
| 127 | ||
| 128 | export 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. */ | |
| 140 | export 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. */ | |
| 156 | export 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. */ | |
| 165 | export 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. */ | |
| 170 | export const decisionField = (ref: string) => `decision:${ref}`; | |
| 171 | ||
| 172 | /** The decisions posted with a hand-back: one per branch both sides moved. */ | |
| 173 | export 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 | */ | |
| 189 | export 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. */ | |
| 195 | export 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 | ||
| 201 | type 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 | */ | |
| 208 | export 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-mode | 224 | }; |
| 225 | } | |
| 226 | ||
| 227 | /** Words for a remote's state, in its row. */ | |
| 228 | export 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.