| 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 | */ |
| 27 | import type { ServiceBinding } from "./clients"; |
| 28 | import type { User } from "./identity"; |
| 29 | import type { Result } from "./result"; |
| 30 | |
| 31 | /** Where a mirror stands with the remote it follows. */ |
| 32 | export type MirrorState = "standby" | "ci" | "takeover" | "handing_back"; |
| 33 | |
| 34 | /** A repository's tie to the remote it mirrors, on `Repo.mirror`. */ |
| 35 | export 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. */ |
| 49 | export 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 | */ |
| 59 | export 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. */ |
| 72 | export 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 | */ |
| 82 | export 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. */ |
| 90 | export 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 | |
| 94 | export type RemoteProvider = "github" | "g1t" | "git"; |
| 95 | export type RemoteRole = "leader" | "follower"; |
| 96 | export type RemoteState = MirrorState | "following" | "stuck"; |
| 97 | |
| 98 | export 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 | /** For followers: pushes made on the remote itself. */ |
| 109 | remotePushes: "adopt" | "overwrite"; |
| 110 | }; |
| 111 | |
| 112 | export const DEFAULT_MIRROR_SETTINGS: MirrorSettings = { |
| 113 | notify: "banner", |
| 114 | takeOverAfter: null, |
| 115 | handBack: "when_clean", |
| 116 | keepCiWarm: false, |
| 117 | githubWorkflows: true, |
| 118 | holdDeploys: true, |
| 119 | remotePushes: "adopt", |
| 120 | }; |
| 121 | |
| 122 | /** The shortest and longest wait before an automatic takeover, in minutes. */ |
| 123 | export const TAKE_OVER_AFTER_MINUTES = [5, 24 * 60] as const; |
| 124 | |
| 125 | export type Remote = { |
| 126 | id: string; |
| 127 | repoId: string; |
| 128 | repo: string; |
| 129 | provider: RemoteProvider; |
| 130 | role: RemoteRole; |
| 131 | /** For people: `github.com/acme/web`. */ |
| 132 | name: string; |
| 133 | url: string; |
| 134 | state: RemoteState; |
| 135 | stateSince: string; |
| 136 | /** A username, or `g1t` when it happened on its own. */ |
| 137 | stateBy: string | null; |
| 138 | reachable: boolean; |
| 139 | unreachableSince: string | null; |
| 140 | syncedAt: string | null; |
| 141 | lastError: string | null; |
| 142 | settings: MirrorSettings; |
| 143 | createdAt: string; |
| 144 | }; |
| 145 | |
| 146 | export type RefAction = "same" | "push" | "fetch" | "pull_request" | "diverged"; |
| 147 | export type RefDecision = "keep_ours" | "keep_theirs" | "pull_request"; |
| 148 | |
| 149 | export type RefPlan = { |
| 150 | ref: string; |
| 151 | base: string | null; |
| 152 | ours: string | null; |
| 153 | theirs: string | null; |
| 154 | action: RefAction; |
| 155 | decision: RefDecision | null; |
| 156 | }; |
| 157 | |
| 158 | export type HandbackPlan = { |
| 159 | refs: RefPlan[]; |
| 160 | reachable: boolean; |
| 161 | ready: boolean; |
| 162 | }; |
| 163 | |
| 164 | export type MirrorView = { |
| 165 | remotes: Remote[]; |
| 166 | plan: HandbackPlan | null; |
| 167 | canManage: boolean; |
| 168 | /** What the last action did that people should know: pull requests it opened, and so on. */ |
| 169 | notes?: string[]; |
| 170 | }; |
| 171 | |
| 172 | export type RemoteBrief = { |
| 173 | repoId: string; |
| 174 | role: RemoteRole; |
| 175 | name: string; |
| 176 | state: RemoteState; |
| 177 | reachable: boolean; |
| 178 | /** When g1t last copied from it or pushed to it. */ |
| 179 | syncedAt?: string | null; |
| 180 | }; |
| 181 | |
| 182 | export type MirrorAddInput = { |
| 183 | provider: Exclude<RemoteProvider, "github">; |
| 184 | role: RemoteRole; |
| 185 | url: string; |
| 186 | username?: string | null; |
| 187 | token?: string | null; |
| 188 | }; |
| 189 | |
| 190 | async function rpc<T>(service: ServiceBinding, method: string, args: object): Promise<T> { |
| 191 | const response = await service.fetch(`https://service/rpc/${method}`, { |
| 192 | method: "POST", |
| 193 | headers: { "content-type": "application/json" }, |
| 194 | body: JSON.stringify(args), |
| 195 | }); |
| 196 | if (!response.ok) throw new Error(`${method} failed with status ${response.status}`); |
| 197 | return (await response.json()) as T; |
| 198 | } |
| 199 | |
| 200 | /** Mirroring: methods of the integrations service. */ |
| 201 | export function mirrorsClient(integrations: ServiceBinding) { |
| 202 | const call = <T>(method: string, args: object) => rpc<T>(integrations, method, args); |
| 203 | return { |
| 204 | view: (viewer: User | null, repoId: string) => call<Result<MirrorView>>("mirror_view", { viewer, repoId }), |
| 205 | briefs: (repoIds: string[]) => call<RemoteBrief[]>("mirror_briefs", { repoIds }), |
| 206 | takeOver: (actor: User, repoId: string) => call<Result<MirrorView>>("mirror_take_over", { actor, repoId }), |
| 207 | ci: (actor: User, repoId: string, on: boolean) => call<Result<MirrorView>>("mirror_ci", { actor, repoId, on }), |
| 208 | plan: (actor: User, repoId: string) => call<Result<MirrorView>>("mirror_hand_back_plan", { actor, repoId }), |
| 209 | handBack: (actor: User, repoId: string, decisions: Record<string, RefDecision>) => |
| 210 | call<Result<MirrorView>>("mirror_hand_back", { actor, repoId, decisions }), |
| 211 | moveIn: (actor: User, repoId: string, keepRemoteUpdated: boolean) => |
| 212 | call<Result<MirrorView>>("mirror_move_in", { actor, repoId, keepRemoteUpdated }), |
| 213 | sync: (actor: User, repoId: string) => call<Result<MirrorView>>("mirror_sync", { actor, repoId }), |
| 214 | settings: (actor: User, remoteId: string, settings: MirrorSettings) => |
| 215 | call<Result<Remote>>("mirror_settings", { actor, remoteId, settings }), |
| 216 | add: (actor: User, repoId: string, input: MirrorAddInput) => call<Result<Remote>>("mirror_add", { actor, repoId, ...input }), |
| 217 | remove: (actor: User, remoteId: string) => call<Result<boolean>>("mirror_remove", { actor, remoteId }), |
| 218 | }; |
| 219 | } |