Skip to content
168 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 */
12import type { ServiceBinding } from "./clients";
13import type { User } from "./identity";
14import type { Result } from "./result";
15
16/** Where a mirror stands with the remote it follows. */
17export type MirrorState = "standby" | "ci" | "takeover" | "handing_back";
18
19/** A repository's tie to the remote it mirrors, on `Repo.mirror`. */
20export type RepoMirror = {
21 state: MirrorState;
22 /** For people: `github.com/acme/web`. */
23 remote: string;
24 /** Its web address. */
25 url: string;
26 /** RFC 3339: when it entered this state. */
27 since: string;
28 warm?: boolean;
29 githubWorkflows?: boolean;
30 holdDeploys?: boolean;
31};
32
33/** Whether the repository takes pushes, merges, issues and agents now. */
34export function mirrorWritable(mirror: RepoMirror | null | undefined): boolean {
35 return !mirror || mirror.state === "takeover";
36}
37
38export type RemoteProvider = "github" | "g1t" | "git";
39export type RemoteRole = "leader" | "follower";
40export type RemoteState = MirrorState | "following" | "stuck";
41
42export type MirrorSettings = {
43 /** Who hears that the remote stopped answering: the banner only, or the inbox too. */
44 notify: "banner" | "inbox";
45 /** Take over on its own after this many minutes unreachable; null for only when someone does. */
46 takeOverAfter: number | null;
47 /** When a takeover goes back once the remote answers: when someone says, or by itself when clean. */
48 handBack: "ask" | "when_clean";
49 keepCiWarm: boolean;
50 githubWorkflows: boolean;
51 holdDeploys: boolean;
52 /** A GitHub mirror: report g1t's statuses to the commit on GitHub while g1t runs its workflows. */
53 reportChecks: boolean;
54 /** For followers: pushes made on the remote itself. */
55 remotePushes: "adopt" | "overwrite";
56};
57
58export const DEFAULT_MIRROR_SETTINGS: MirrorSettings = {
59 notify: "banner",
60 takeOverAfter: null,
61 handBack: "when_clean",
62 keepCiWarm: false,
63 githubWorkflows: true,
64 holdDeploys: true,
65 reportChecks: true,
66 remotePushes: "adopt",
67};
68
69/** The shortest and longest wait before an automatic takeover, in minutes. */
70export const TAKE_OVER_AFTER_MINUTES = [5, 24 * 60] as const;
71
72export type Remote = {
73 id: string;
74 repoId: string;
75 repo: string;
76 provider: RemoteProvider;
77 role: RemoteRole;
78 /** For people: `github.com/acme/web`. */
79 name: string;
80 url: string;
81 state: RemoteState;
82 stateSince: string;
83 /** A username, or `g1t` when it happened on its own. */
84 stateBy: string | null;
85 reachable: boolean;
86 unreachableSince: string | null;
87 syncedAt: string | null;
88 lastError: string | null;
89 /** Why g1t could not report checks to the remote, until it next can. */
90 checksProblem?: string | null;
91 settings: MirrorSettings;
92 createdAt: string;
93};
94
95export type RefAction = "same" | "push" | "fetch" | "pull_request" | "diverged";
96export type RefDecision = "keep_ours" | "keep_theirs" | "pull_request";
97
98export type RefPlan = {
99 ref: string;
100 base: string | null;
101 ours: string | null;
102 theirs: string | null;
103 action: RefAction;
104 decision: RefDecision | null;
105};
106
107export type HandbackPlan = {
108 refs: RefPlan[];
109 reachable: boolean;
110 ready: boolean;
111};
112
113export type MirrorView = {
114 remotes: Remote[];
115 plan: HandbackPlan | null;
116 canManage: boolean;
117 /** What the last action did that people should know: pull requests it opened, and so on. */
118 notes?: string[];
119};
120
121export type RemoteBrief = {
122 repoId: string;
123 role: RemoteRole;
124 name: string;
125 state: RemoteState;
126 reachable: boolean;
127 /** When g1t last copied from it or pushed to it. */
128 syncedAt?: string | null;
129};
130
131export type MirrorAddInput = {
132 provider: Exclude<RemoteProvider, "github">;
133 role: RemoteRole;
134 url: string;
135 username?: string | null;
136 token?: string | null;
137};
138
139async function rpc<T>(service: ServiceBinding, method: string, args: object): Promise<T> {
140 const response = await service.fetch(`https://service/rpc/${method}`, {
141 method: "POST",
142 headers: { "content-type": "application/json" },
143 body: JSON.stringify(args),
144 });
145 if (!response.ok) throw new Error(`${method} failed with status ${response.status}`);
146 return (await response.json()) as T;
147}
148
149/** Mirroring: methods of the integrations service. */
150export function mirrorsClient(integrations: ServiceBinding) {
151 const call = <T>(method: string, args: object) => rpc<T>(integrations, method, args);
152 return {
153 view: (viewer: User | null, repoId: string) => call<Result<MirrorView>>("mirror_view", { viewer, repoId }),
154 briefs: (repoIds: string[]) => call<RemoteBrief[]>("mirror_briefs", { repoIds }),
155 takeOver: (actor: User, repoId: string) => call<Result<MirrorView>>("mirror_take_over", { actor, repoId }),
156 ci: (actor: User, repoId: string, on: boolean) => call<Result<MirrorView>>("mirror_ci", { actor, repoId, on }),
157 plan: (actor: User, repoId: string) => call<Result<MirrorView>>("mirror_hand_back_plan", { actor, repoId }),
158 handBack: (actor: User, repoId: string, decisions: Record<string, RefDecision>) =>
159 call<Result<MirrorView>>("mirror_hand_back", { actor, repoId, decisions }),
160 moveIn: (actor: User, repoId: string, keepRemoteUpdated: boolean) =>
161 call<Result<MirrorView>>("mirror_move_in", { actor, repoId, keepRemoteUpdated }),
162 sync: (actor: User, repoId: string) => call<Result<MirrorView>>("mirror_sync", { actor, repoId }),
163 settings: (actor: User, remoteId: string, settings: MirrorSettings) =>
164 call<Result<Remote>>("mirror_settings", { actor, remoteId, settings }),
165 add: (actor: User, repoId: string, input: MirrorAddInput) => call<Result<Remote>>("mirror_add", { actor, repoId, ...input }),
166 remove: (actor: User, remoteId: string) => call<Result<boolean>>("mirror_remove", { actor, remoteId }),
167 };
168}