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