Skip to content
224 linesCodeBlameRaw
1/**
2 * Mirroring: a repository kept in step with copies of it on other hosts.
3 * Mirrors `g1t_contracts::mirrors`.
4 *
5 * Every linked repository has exactly one leader, where work happens. A
6 * **mirror** follows a remote that leads; while it stands by it is an exact,
7 * read-only copy that runs nothing. Someone can **take over** (g1t leads for
8 * a while, then **hands back**), or **move it to g1t** for good, after which
9 * g1t no longer tracks the remote. A repository g1t leads can be **mirrored
10 * to** any number of followers.
11 *
12 * What a mirror offers on g1t is one rule, `mirrorOffers`, by state:
13 *
14 * | Area | Standing by | CI failover | Taken over | Handing back |
15 * | --- | --- | --- | --- | --- |
16 * | `code`: files, commits, branches, tags, compare, releases (read) | ✓ | ✓ | ✓ | ✓ |
17 * | `security`: what scanning found, read-only | ✓ | ✓ | ✓ | ✓ |
18 * | `insights`, `settings` (mirroring settings included) | ✓ | ✓ | ✓ | ✓ |
19 * | `workflows`: runs on g1t | with Keep CI warm | ✓ | ✓ | – |
20 * | `issues`, `pulls`, `agents`, `deployments` | – | – | ✓ | – |
21 * | `updates`: security and dependency updates, upkeep, anything that opens a branch or a pull request on its own | – | – | ✓ | – |
22 *
23 * The sidebar lists only what is offered, a page not offered says so in
24 * place of its list or form, every service refuses the writes, and g1t's
25 * own automation checks the same rule before it opens anything.
26 */
27import type { ServiceBinding } from "./clients";
28import type { User } from "./identity";
29import type { Result } from "./result";
30
31/** Where a mirror stands with the remote it follows. */
32export type MirrorState = "standby" | "ci" | "takeover" | "handing_back";
33
34/** A repository's tie to the remote it mirrors, on `Repo.mirror`. */
35export type RepoMirror = {
36 state: MirrorState;
37 /** For people: `github.com/acme/web`. */
38 remote: string;
39 /** Its web address. */
40 url: string;
41 /** RFC 3339: when it entered this state. */
42 since: string;
43 warm?: boolean;
44 githubWorkflows?: boolean;
45 holdDeploys?: boolean;
46};
47
48/** Whether the repository takes pushes, merges, issues and agents now. */
49export function mirrorWritable(mirror: RepoMirror | null | undefined): boolean {
50 return !mirror || mirror.state === "takeover";
51}
52
53/**
54 * The parts of a repository a mirror may or may not offer: the sidebar's
55 * pages, and `updates` for what g1t does on its own (security and
56 * dependency updates, upkeep, anything that opens a branch or a pull
57 * request without being asked).
58 */
59export type RepoArea =
60 | "code"
61 | "issues"
62 | "pulls"
63 | "agents"
64 | "workflows"
65 | "deployments"
66 | "security"
67 | "insights"
68 | "settings"
69 | "updates";
70
71/** What every mirror offers, whatever its state: an exact copy, read. */
72export const MIRROR_READ_AREAS: readonly RepoArea[] = ["code", "security", "insights", "settings"];
73
74/**
75 * Whether a repository with `mirror` offers `area` now (the table in this
76 * module's comment). A repository that leads offers everything, as does a
77 * mirror g1t has taken over. One standing by or handing back is a
78 * read-only copy: code, what security scanning found, insights and
79 * settings. Workflows run in CI failover, and while standing by only when
80 * the link keeps CI warm.
81 */
82export function mirrorOffers(mirror: RepoMirror | null | undefined, area: RepoArea): boolean {
83 if (mirrorWritable(mirror)) return true;
84 if (MIRROR_READ_AREAS.includes(area)) return true;
85 if (area === "workflows") return mirror!.state === "ci" || (mirror!.state === "standby" && mirror!.warm === true);
86 return false;
87}
88
89/** Why g1t's own updates wait on a mirror: the sentence the security pages and the service share. */
90export function mirrorUpdatesWait(namespace: string, name: string, mirror: RepoMirror): string {
91 return `Security updates wait until ${namespace}/${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.`;
92}
93
94export type RemoteProvider = "github" | "g1t" | "git";
95export type RemoteRole = "leader" | "follower";
96export type RemoteState = MirrorState | "following" | "stuck";
97
98export type MirrorSettings = {
99 /** Who hears that the remote stopped answering: the banner only, or the inbox too. */
100 notify: "banner" | "inbox";
101 /** Take over on its own after this many minutes unreachable; null for only when someone does. */
102 takeOverAfter: number | null;
103 /** When a takeover goes back once the remote answers: when someone says, or by itself when clean. */
104 handBack: "ask" | "when_clean";
105 keepCiWarm: boolean;
106 githubWorkflows: boolean;
107 holdDeploys: boolean;
108 /** A GitHub mirror: report g1t's statuses to the commit on GitHub while g1t runs its workflows. */
109 reportChecks: boolean;
110 /** For followers: pushes made on the remote itself. */
111 remotePushes: "adopt" | "overwrite";
112};
113
114export const DEFAULT_MIRROR_SETTINGS: MirrorSettings = {
115 notify: "banner",
116 takeOverAfter: null,
117 handBack: "when_clean",
118 keepCiWarm: false,
119 githubWorkflows: true,
120 holdDeploys: true,
121 reportChecks: true,
122 remotePushes: "adopt",
123};
124
125/** The shortest and longest wait before an automatic takeover, in minutes. */
126export const TAKE_OVER_AFTER_MINUTES = [5, 24 * 60] as const;
127
128export type Remote = {
129 id: string;
130 repoId: string;
131 repo: string;
132 provider: RemoteProvider;
133 role: RemoteRole;
134 /** For people: `github.com/acme/web`. */
135 name: string;
136 url: string;
137 state: RemoteState;
138 stateSince: string;
139 /** A username, or `g1t` when it happened on its own. */
140 stateBy: string | null;
141 reachable: boolean;
142 unreachableSince: string | null;
143 syncedAt: string | null;
144 lastError: string | null;
145 /** Why g1t could not report checks to the remote, until it next can. */
146 checksProblem?: string | null;
147 settings: MirrorSettings;
148 createdAt: string;
149};
150
151export type RefAction = "same" | "push" | "fetch" | "pull_request" | "diverged";
152export type RefDecision = "keep_ours" | "keep_theirs" | "pull_request";
153
154export type RefPlan = {
155 ref: string;
156 base: string | null;
157 ours: string | null;
158 theirs: string | null;
159 action: RefAction;
160 decision: RefDecision | null;
161};
162
163export type HandbackPlan = {
164 refs: RefPlan[];
165 reachable: boolean;
166 ready: boolean;
167};
168
169export type MirrorView = {
170 remotes: Remote[];
171 plan: HandbackPlan | null;
172 canManage: boolean;
173 /** What the last action did that people should know: pull requests it opened, and so on. */
174 notes?: string[];
175};
176
177export type RemoteBrief = {
178 repoId: string;
179 role: RemoteRole;
180 name: string;
181 state: RemoteState;
182 reachable: boolean;
183 /** When g1t last copied from it or pushed to it. */
184 syncedAt?: string | null;
185};
186
187export type MirrorAddInput = {
188 provider: Exclude<RemoteProvider, "github">;
189 role: RemoteRole;
190 url: string;
191 username?: string | null;
192 token?: string | null;
193};
194
195async function rpc<T>(service: ServiceBinding, method: string, args: object): Promise<T> {
196 const response = await service.fetch(`https://service/rpc/${method}`, {
197 method: "POST",
198 headers: { "content-type": "application/json" },
199 body: JSON.stringify(args),
200 });
201 if (!response.ok) throw new Error(`${method} failed with status ${response.status}`);
202 return (await response.json()) as T;
203}
204
205/** Mirroring: methods of the integrations service. */
206export function mirrorsClient(integrations: ServiceBinding) {
207 const call = <T>(method: string, args: object) => rpc<T>(integrations, method, args);
208 return {
209 view: (viewer: User | null, repoId: string) => call<Result<MirrorView>>("mirror_view", { viewer, repoId }),
210 briefs: (repoIds: string[]) => call<RemoteBrief[]>("mirror_briefs", { repoIds }),
211 takeOver: (actor: User, repoId: string) => call<Result<MirrorView>>("mirror_take_over", { actor, repoId }),
212 ci: (actor: User, repoId: string, on: boolean) => call<Result<MirrorView>>("mirror_ci", { actor, repoId, on }),
213 plan: (actor: User, repoId: string) => call<Result<MirrorView>>("mirror_hand_back_plan", { actor, repoId }),
214 handBack: (actor: User, repoId: string, decisions: Record<string, RefDecision>) =>
215 call<Result<MirrorView>>("mirror_hand_back", { actor, repoId, decisions }),
216 moveIn: (actor: User, repoId: string, keepRemoteUpdated: boolean) =>
217 call<Result<MirrorView>>("mirror_move_in", { actor, repoId, keepRemoteUpdated }),
218 sync: (actor: User, repoId: string) => call<Result<MirrorView>>("mirror_sync", { actor, repoId }),
219 settings: (actor: User, remoteId: string, settings: MirrorSettings) =>
220 call<Result<Remote>>("mirror_settings", { actor, remoteId, settings }),
221 add: (actor: User, repoId: string, input: MirrorAddInput) => call<Result<Remote>>("mirror_add", { actor, repoId, ...input }),
222 remove: (actor: User, remoteId: string) => call<Result<boolean>>("mirror_remove", { actor, remoteId }),
223 };
224}