Skip to content

Commit

Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.

syntaqxcommitted Parentsc2dcbb86155a80Browse files
300 files+1194−1052284 files not counted0/300 viewed
+1−1
145145 - name: Check and test
146146 run: cargo test --locked --manifest-path apps/desktop/src-tauri/Cargo.toml --quiet
147147 - name: The release scripts' tests
148− run: node --test scripts/desktop-release.test.mjs scripts/mobile-release.test.mjs
148+ run: node --test scripts/desktop-release.test.mjs scripts/mobile-release.test.mjs scripts/cli-release.test.mjs
+87−0
1+# Releases the g1t CLI (crates/g1t): builds it for Linux and macOS on x64
2+# and arm64 and for Windows on x64, signs the release, and publishes it to
3+# g1t.sh/downloads/cli/ (the g1t-downloads R2 bucket), where g1t.sh/install
4+# and g1t.sh/install.ps1 find it.
5+#
6+# A release is a tag `cli-v<version>`, where the version is the one in
7+# crates/g1t/Cargo.toml; or run it by hand. scripts/cli-release.mjs does the
8+# work, and its header says what a release holds. It is signed with the
9+# runner's release key (RUNNER_RELEASE_KEY, RUNNER_RELEASE_PUBLIC_KEY).
10+#
11+# Every platform builds in one job, in the rust-cross/cargo-zigbuild image:
12+# the macOS builds link Apple's Security framework (the keychain), and that
13+# image carries its SDK. It runs on g1t's own machines (g1t-4core), whose
14+# jobs have a Docker Engine of their own.
15+name: CLI release
16+
17+on:
18+ push:
19+ tags: ["cli-v*"]
20+ workflow_dispatch:
21+
22+permissions:
23+ contents: read
24+
25+concurrency:
26+ group: cli-release
27+ cancel-in-progress: false
28+
29+env:
30+ CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
31+ WRANGLER_SEND_METRICS: "false"
32+
33+jobs:
34+ build:
35+ name: Build every platform
36+ runs-on: g1t-4core
37+ timeout-minutes: 60
38+ steps:
39+ - uses: actions/checkout@v5
40+ - name: The tag names this version
41+ if: startsWith(github.ref, 'refs/tags/cli-v')
42+ run: |
43+ version="$(sed -n 's/^version = "\(.*\)"/\1/p' crates/g1t/Cargo.toml | head -1)"
44+ [ "cli-v$version" = "${GITHUB_REF_NAME}" ] || { echo "::error::The tag is ${GITHUB_REF_NAME}, but crates/g1t is $version"; exit 1; }
45+ - name: Build
46+ run: node scripts/cli-release.mjs build
47+ - uses: actions/upload-artifact@v4
48+ with:
49+ name: cli
50+ path: target/cli-release/
51+ overwrite: true
52+
53+ publish:
54+ name: Sign and publish
55+ needs: build
56+ runs-on: ubuntu-latest
57+ # Production's secrets, without a deployment: a release is not a deploy
58+ # of g1t.sh.
59+ environment:
60+ name: production
61+ deployment: false
62+ timeout-minutes: 30
63+ steps:
64+ - uses: actions/checkout@v5
65+ - uses: actions/download-artifact@v4
66+ with:
67+ name: cli
68+ path: target/cli-release
69+ - name: Sign
70+ env:
71+ RUNNER_RELEASE_KEY: ${{ secrets.RUNNER_RELEASE_KEY }}
72+ G1T_RUNNER_RELEASE_KEY: ${{ vars.RUNNER_RELEASE_PUBLIC_KEY }}
73+ run: |
74+ node scripts/cli-release.mjs sign
75+ node scripts/cli-release.mjs verify
76+ - name: Cache npm's downloads
77+ uses: actions/cache@v4
78+ with:
79+ path: ~/.npm
80+ key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
81+ restore-keys: npm-${{ runner.os }}-
82+ - name: Install Wrangler
83+ run: npm ci --prefer-offline --workspaces=false --no-audit --no-fund
84+ - name: Publish to g1t.sh/downloads/cli
85+ env:
86+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
87+ run: node scripts/cli-release.mjs publish
+5−5
88 # migrate pending D1 migrations, before any code, once check and plan pass
99 # core, edge, front the units of each stage, in jobs that share a build;
1010 # a stage starts only when the one before it succeeded
11−# smoke sign-in, sign-up and the waitlist still work on g1t.sh
11+# smoke sign-in and sign-up still work on g1t.sh
1212 #
1313 # Each run that deploys is one production deployment of g1t.sh, made by the
1414 # jobs that name `environment: production` (one per run, however many jobs):
301301 steps: *deploy
302302
303303 # Once everything has deployed: the ways in for someone new still work.
304− # The pages a visitor lands on load, and the waitlist form reaches
305− # identity, sent an address it refuses before keeping anything, so the
306− # real waitlist is never touched. scripts/ops/smoke.mjs.
304+ # The pages a visitor lands on load, and the sign-up form reaches
305+ # identity, sent an address it refuses before keeping or counting
306+ # anything, so no account is made. scripts/ops/smoke.mjs.
307307 smoke:
308308 name: Smoke
309309 needs: [check, plan, core, edge, front]
312312 timeout-minutes: 5
313313 steps:
314314 - uses: actions/checkout@v5
315− - name: Sign-in, sign-up and the waitlist work
315+ - name: Sign-in and sign-up work
316316 run: node scripts/ops/smoke.mjs
+11−0
128128 `.g1t/workflows/runner-release.yml` builds, signs and publishes it, and
129129 runners update themselves to it. `scripts/runner-release.mjs` does the
130130 same by hand.
131+- **The g1t CLI** is released the same way: bump `version` in
132+ `crates/g1t/Cargo.toml`, merge, and push a `cli-v<version>` tag.
133+ `.g1t/workflows/cli-release.yml` builds it for every platform, signs
134+ the release with the runner's release key and publishes it to
135+ g1t.sh/downloads/cli/, where g1t.sh/install and g1t.sh/install.ps1
136+ find it. `scripts/cli-release.mjs` does the same by hand. The command
137+ reference in the docs is generated from the CLI
138+ (`G1T_WRITE_DOCS=1 cargo test -p g1t cli_reference`), and its table of
139+ API operations from the OpenAPI document
140+ (`G1T_WRITE_OPS=1 cargo test -p g1t ops_table`); tests fail when either
141+ falls behind. The guide is [The g1t CLI](https://docs.g1t.sh/guides/cli/).
131142 - **The desktop app** is released the same way: bump `version` in
132143 `apps/desktop/src-tauri/Cargo.toml`, merge, and push a
133144 `desktop-v<version>` tag. `.g1t/workflows/desktop-release.yml` builds
+1034−16
A large diff (1,474 lines) is not shown until you ask for it.
+1−1
7777 | Packages: npm, Cargo, Composer, Maven, NuGet, RubyGems, Go modules, container images | Live |
7878 | Secret push protection, history scanning, dependency upgrade pull requests | Live |
7979 | A preview of every pull request on `g1t.page`, production on merge, custom domains | Live |
80−| GitHub import, mirroring and pushing back | Live |
80+| GitHub import: every branch and tag, and issues | Live |
8181 | A copy of the data for every preview, instant rollback, container apps kept warm | Coming |
8282 | Git over SSH (waits on inbound TCP on Workers; use HTTPS) | Coming |
8383
+7−3
1818 mod deploy_keys;
1919 mod limits;
2020 mod logs;
21−mod mirrors;
2221 mod mcp;
2322 mod notifications;
2423 mod oauth;
665664 _ => (rest.to_owned(), "job_report"),
666665 };
667666 let body = json_body(&mut request).await;
667+ // `rotate`: a harness that takes a new token with the spec and
668+ // lets go of the one it asked with (plan.rs `job_spec`).
669+ let rotate = method == "job_spec" && body["rotate"].as_bool() == Some(true);
668670 let answered: Outcome<Value> = g1t_kit::call(
669671 &services.actions,
670672 method,
671− &json!({ "job": job, "token": body["token"], "report": body["report"] }),
673+ &json!({ "job": job, "token": body["token"], "report": body["report"], "rotate": rotate }),
672674 )
673675 .await?;
674676 return match answered {
774776 #[test]
775777 fn a_job_spec_gets_the_toolkits_variables() {
776778 // As the actions service sends it, converted as the API does.
777− let sent = json!({ "variables": { "GITHUB_SHA": "abc" }, "runtime": { "token": "h.p.s", "idToken": true } });
779+ let sent = json!({ "variables": { "GITHUB_SHA": "abc" }, "runtime": { "token": "h.p.s", "idToken": true }, "nextToken": "n" });
778780 let mut spec = g1t_kit::wire::snake_case_keeping(sent.clone(), super::JOB_SPEC_AS_GIVEN);
779781 super::with_runtime(&mut spec, "https://api.g1t.sh", true);
780782 assert!(spec.get("runtime").is_none(), "the runner never sees it");
783+ // The token that replaces the one the harness asked with.
784+ assert_eq!(spec["next_token"], "n");
781785 let vars = &spec["variables"];
782786 assert_eq!(vars["GITHUB_SHA"], "abc");
783787 assert_eq!(vars["ACTIONS_RUNTIME_TOKEN"], "h.p.s");
+0−376
1−//! Mirroring over REST and MCP: a repository's links to copies of it on
2−//! other hosts (see `g1t_contracts::mirrors`), at
3−//! `/repos/{owner}/{name}/mirror…`, and as the MCP `mirror` tool.
4−//!
5−//! The integrations service keeps the links and decides who may change
6−//! them: the Admin role on the repository; syncing needs push. Agents never
7−//! move a repository to g1t or handle a remote's token.
8−
9−use g1t_contracts::mirrors::{
10− MirrorActArgs, MirrorAddArgs, MirrorCiArgs, MirrorHandBackArgs, MirrorMoveInArgs, MirrorRemoveArgs, MirrorSettings,
11− MirrorSettingsArgs, MirrorViewArgs, RefDecision, RemoteProvider, RemoteRole,
12−};
13−use g1t_contracts::repos::{GetArgs, Repo};
14−use g1t_contracts::{FailureCode, Outcome, Viewer};
15−use serde_json::{Value, json};
16−use std::collections::BTreeMap;
17−use worker::Result;
18−
19−use crate::operations::{Services, repo_path};
20−
21−/// One operation on a repository's mirroring.
22−#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23−pub enum MirrorsOp {
24− GetMirror,
25− GetHandBackPlan,
26− TakeOver,
27− SetCiFailover,
28− HandBack,
29− MoveToG1t,
30− SyncMirror,
31− AddRemote,
32− UpdateRemote,
33− RemoveRemote,
34−}
35−
36−impl MirrorsOp {
37− /// Every one: `Op::ALL` lists each as `Op::Mirrors(…)`, which a test
38− /// checks against this.
39− #[cfg(test)]
40− pub const ALL: [MirrorsOp; 10] = [
41− MirrorsOp::GetMirror,
42− MirrorsOp::GetHandBackPlan,
43− MirrorsOp::TakeOver,
44− MirrorsOp::SetCiFailover,
45− MirrorsOp::HandBack,
46− MirrorsOp::MoveToG1t,
47− MirrorsOp::SyncMirror,
48− MirrorsOp::AddRemote,
49− MirrorsOp::UpdateRemote,
50− MirrorsOp::RemoveRemote,
51− ];
52−
53− pub fn name(self) -> &'static str {
54− match self {
55− MirrorsOp::GetMirror => "get_mirror",
56− MirrorsOp::GetHandBackPlan => "get_hand_back_plan",
57− MirrorsOp::TakeOver => "take_over_mirror",
58− MirrorsOp::SetCiFailover => "set_ci_failover",
59− MirrorsOp::HandBack => "hand_back_mirror",
60− MirrorsOp::MoveToG1t => "move_mirror_to_g1t",
61− MirrorsOp::SyncMirror => "sync_mirror",
62− MirrorsOp::AddRemote => "add_mirror_remote",
63− MirrorsOp::UpdateRemote => "update_mirror_remote",
64− MirrorsOp::RemoveRemote => "remove_mirror_remote",
65− }
66− }
67−
68− /// For the API reference.
69− pub fn title(self) -> &'static str {
70− match self {
71− MirrorsOp::GetMirror => "Get a repository's mirroring",
72− MirrorsOp::GetHandBackPlan => "Get the hand-back plan",
73− MirrorsOp::TakeOver => "Take over a mirror",
74− MirrorsOp::SetCiFailover => "Start or end CI failover",
75− MirrorsOp::HandBack => "Hand a takeover back",
76− MirrorsOp::MoveToG1t => "Move a mirror to g1t",
77− MirrorsOp::SyncMirror => "Sync a repository's remotes",
78− MirrorsOp::AddRemote => "Add a remote",
79− MirrorsOp::UpdateRemote => "Update a remote's settings",
80− MirrorsOp::RemoveRemote => "Remove a remote",
81− }
82− }
83−
84− pub fn description(self) -> &'static str {
85− match self {
86− MirrorsOp::GetMirror => "A repository's links to other hosts. `remotes` lists each with its `role` (`leader`: the remote leads and this repository is its mirror; `follower`: g1t leads and the remote is kept in step), `provider` (`github`, `g1t` or `git`), `name` (`github.com/acme/web`), `state` (a mirror is `standby`, `ci`, `takeover` or `handing_back`; a follower is `following` or `stuck`), `reachable` and `unreachable_since`, `synced_at`, `last_error`, `checks_problem` (why g1t could not report checks to the remote, such as the GitHub App lacking the Commit statuses permission; null once a report goes through) and its `settings`. `can_manage` says whether you may change them. A mirror standing by is read-only on g1t and runs nothing. Needs read access.",
87− MirrorsOp::GetHandBackPlan => "What handing a takeover back would do, ref by ref, in `plan.refs`: each with `base` (where it was when the takeover began), `ours`, `theirs` and `action`: `push` (only g1t moved), `fetch` (only the remote moved), `pull_request` (the remote protects the branch: g1t's commits go to `g1t/handback/<branch>` there as a pull request, and g1t follows the remote's branch) or `diverged` (both moved: it needs a decision). `plan.reachable` is false while the remote does not answer; `plan.ready` once it answers and every diverged ref has a decision. Asks the remote, so it takes a moment. Needs the Admin role.",
88− MirrorsOp::TakeOver => "Take over a mirror: g1t leads it for now, so pushes, pull requests, issues, agents and workflows work on g1t, whether or not the remote is answering. Each ref's commit is recorded as where the takeover began. Workflows that deploy wait for approval, unless the link's settings say otherwise. End it with hand_back_mirror, or keep it with move_mirror_to_g1t. Needs the Admin role.",
89− MirrorsOp::SetCiFailover => "Start (`on: true`) or end (`on: false`) CI failover on a mirror standing by: the remote keeps the code, and g1t runs its workflows, `.github/workflows` as well as `.g1t/workflows`, on each push copied in. Workflows that deploy wait for approval unless the link's settings say otherwise. Needs the Admin role.",
90− MirrorsOp::HandBack => "Hand a takeover back to the remote, as get_hand_back_plan describes, with `decisions` for diverged refs: an object from ref (`refs/heads/docs`) to `keep_ours` (g1t's commit is pushed over the remote's), `keep_theirs` (the remote's is taken; g1t's is kept under `refs/g1t/replaced/`) or `pull_request`. Refused while the remote does not answer or a diverged ref has no decision. The repository is read-only while it goes back; if anything is refused, g1t keeps the lead and says why. On success the mirror stands by again, and `notes` lists the pull requests opened. Needs the Admin role.",
91− MirrorsOp::MoveToG1t => "Move a mirror to g1t for good: it stops being a mirror and g1t no longer tracks the remote, so pushes made there no longer come here. Allowed while it stands by, in CI failover, or during a takeover, whose work stays as it is. With `keep_remote_updated: true` the remote becomes a follower instead of being unlinked, and g1t pushes to it from then on. Needs the Admin role; agents' tokens are refused.",
92− MirrorsOp::SyncMirror => "Bring a repository's remotes in step now: a mirror standing by or in CI failover copies the remote's branches and tags in; each follower is pushed g1t's (which also clears a follower that was stuck by pushing over what changed there). Needs push access.",
93− MirrorsOp::AddRemote => "Link a repository to a remote on another g1t or any git host over HTTPS (GitHub repositories are linked by importing them through g1t's GitHub App). `role` `follower`: g1t leads and pushes to it. `role` `leader`: this repository becomes its mirror; only an empty repository can, and it is filled from the remote. `url` is its https clone address, `token` a token that can read and push (kept sealed, never returned), `username` the user it is sent as. Needs the Admin role; agents' tokens are refused.",
94− MirrorsOp::UpdateRemote => "Change a remote's `settings`, all optional: `notify` (`banner`, or `inbox` to also tell the workspace's owners), `take_over_after` (minutes, 5 to 1440, after which g1t takes over on its own while the remote does not answer; null for never), `hand_back` (`when_clean`: an automatic takeover goes back on its own once every ref goes back without a decision; `ask`), `keep_ci_warm` (run `.g1t/workflows` on pushes copied in while standing by), `github_workflows` (run `.github/workflows` in CI failover and takeovers), `hold_deploys`, `report_checks` (a GitHub mirror: report the statuses g1t sets on a commit, such as workflow runs and deploys, to the commit on GitHub as `g1t / <context>` while g1t runs its workflows; on by default), and for a follower `remote_pushes` (`adopt` fast-forwards made there, or `overwrite` them). Needs the Admin role; agents' tokens are refused.",
95− MirrorsOp::RemoveRemote => "Unlink a remote. A mirror becomes an ordinary repository with what it has; a follower is no longer pushed to. Refused during a takeover: hand it back or move it to g1t first. Needs the Admin role; agents' tokens are refused.",
96− }
97− }
98−
99− pub fn input(self) -> Value {
100− let repo = json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." });
101− let id = json!({ "type": "string", "description": "The remote's id (rmt_…), from get_mirror." });
102− let (properties, required): (Value, &[&str]) = match self {
103− MirrorsOp::GetMirror | MirrorsOp::GetHandBackPlan | MirrorsOp::TakeOver | MirrorsOp::SyncMirror => {
104− (json!({ "repo": repo }), &["repo"])
105− }
106− MirrorsOp::SetCiFailover => (
107− json!({ "repo": repo, "on": { "type": "boolean", "description": "True to start CI failover, false to end it." } }),
108− &["repo", "on"],
109− ),
110− MirrorsOp::HandBack => (
111− json!({
112− "repo": repo,
113− "decisions": {
114− "type": "object",
115− "description": "For each diverged ref, by its full name: keep_ours, keep_theirs or pull_request.",
116− "additionalProperties": { "type": "string", "enum": ["keep_ours", "keep_theirs", "pull_request"] },
117− },
118− }),
119− &["repo"],
120− ),
121− MirrorsOp::MoveToG1t => (
122− json!({
123− "repo": repo,
124− "keep_remote_updated": { "type": "boolean", "description": "True to keep pushing to the remote from g1t; false (the default) to stop tracking it." },
125− }),
126− &["repo"],
127− ),
128− MirrorsOp::AddRemote => (
129− json!({
130− "repo": repo,
131− "provider": { "type": "string", "enum": ["g1t", "git"], "description": "Another g1t (g1t.sh or your own), or any git host." },
132− "role": { "type": "string", "enum": ["follower", "leader"], "description": "follower: g1t leads and pushes to it. leader: this empty repository becomes its mirror." },
133− "url": { "type": "string", "description": "Its https clone address, such as https://g1t.sh/acme/web.git." },
134− "username": { "type": "string", "description": "The user the token is sent as. x-access-token when left out." },
135− "token": { "type": "string", "description": "A token that can read and push. Kept sealed; never returned." },
136− }),
137− &["repo", "provider", "role", "url", "token"],
138− ),
139− MirrorsOp::UpdateRemote => (
140− json!({
141− "repo": repo,
142− "id": id,
143− "notify": { "type": "string", "enum": ["banner", "inbox"] },
144− "take_over_after": { "type": ["integer", "null"], "minimum": 5, "maximum": 1440 },
145− "hand_back": { "type": "string", "enum": ["when_clean", "ask"] },
146− "keep_ci_warm": { "type": "boolean" },
147− "github_workflows": { "type": "boolean" },
148− "hold_deploys": { "type": "boolean" },
149− "report_checks": { "type": "boolean" },
150− "remote_pushes": { "type": "string", "enum": ["adopt", "overwrite"] },
151− }),
152− &["repo", "id"],
153− ),
154− MirrorsOp::RemoveRemote => (json!({ "repo": repo, "id": id }), &["repo", "id"]),
155− };
156− json!({ "type": "object", "properties": properties, "required": required })
157− }
158−}
159−
160−fn text(input: &Value, key: &str) -> String {
161− input[key].as_str().map(str::trim).unwrap_or_default().to_owned()
162−}
163−
164−/// A boolean as sent: JSON, or a word from a form.
165−fn flag(value: &Value) -> Option<bool> {
166− match value {
167− Value::Bool(flag) => Some(*flag),
168− Value::String(word) => match word.trim().to_ascii_lowercase().as_str() {
169− "true" | "1" => Some(true),
170− "false" | "0" => Some(false),
171− _ => None,
172− },
173− _ => None,
174− }
175−}
176−
177−/// `settings` with what `input` changes, in `snake_case` as sent.
178−pub fn settings_from(mut settings: MirrorSettings, input: &Value) -> std::result::Result<MirrorSettings, String> {
179− let word = |key: &str| input.get(key).filter(|value| !value.is_null()).map(|value| value.as_str().unwrap_or_default());
180− if let Some(notify) = word("notify") {
181− settings.notify = serde_json::from_value(json!(notify)).map_err(|_| "notify is banner or inbox.")?;
182− }
183− if let Some(value) = input.get("take_over_after") {
184− settings.take_over_after = match value {
185− Value::Null => None,
186− value => Some(value.as_u64().map(|minutes| minutes as u32).ok_or("take_over_after is a number of minutes, or null.")?),
187− };
188− }
189− if let Some(hand_back) = word("hand_back") {
190− settings.hand_back = serde_json::from_value(json!(hand_back)).map_err(|_| "hand_back is when_clean or ask.")?;
191− }
192− if let Some(remote_pushes) = word("remote_pushes") {
193− settings.remote_pushes = serde_json::from_value(json!(remote_pushes)).map_err(|_| "remote_pushes is adopt or overwrite.")?;
194− }
195− for (key, field) in [
196− ("keep_ci_warm", &mut settings.keep_ci_warm),
197− ("github_workflows", &mut settings.github_workflows),
198− ("hold_deploys", &mut settings.hold_deploys),
199− ("report_checks", &mut settings.report_checks),
200− ] {
201− if let Some(value) = input.get(key).filter(|value| !value.is_null()) {
202− *field = flag(value).ok_or_else(|| format!("{key} is true or false."))?;
203− }
204− }
205− Ok(settings)
206−}
207−
208−/// `decisions` as sent: ref to `keep_ours`, `keep_theirs` or `pull_request`.
209−pub fn decisions_from(input: &Value) -> std::result::Result<BTreeMap<String, RefDecision>, String> {
210− let Some(decisions) = input.get("decisions").filter(|value| !value.is_null()) else {
211− return Ok(BTreeMap::new());
212− };
213− let Some(decisions) = decisions.as_object() else {
214− return Err("decisions is an object from ref to keep_ours, keep_theirs or pull_request.".to_owned());
215− };
216− decisions
217− .iter()
218− .map(|(git_ref, decision)| {
219− let decision = serde_json::from_value(decision.clone())
220− .map_err(|_| format!("{git_ref}: say keep_ours, keep_theirs or pull_request."))?;
221− let git_ref = if git_ref.starts_with("refs/") { git_ref.clone() } else { format!("refs/heads/{git_ref}") };
222− Ok((git_ref, decision))
223− })
224− .collect()
225−}
226−
227−pub async fn run(op: MirrorsOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
228− let Some(path) = repo_path(input) else {
229− return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\"."));
230− };
231− let found: Outcome<Repo> = g1t_kit::call(&services.repos, "get", &GetArgs { path, viewer: viewer.clone() }).await?;
232− let repo = match found {
233− Outcome::Ok(repo) => repo,
234− Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
235− };
236− let integrations = &services.integrations;
237− if op == MirrorsOp::GetMirror {
238− return g1t_kit::call(integrations, "mirror_view", &MirrorViewArgs { viewer: viewer.clone(), repo_id: repo.id }).await;
239− }
240− let Some(actor) = viewer.clone() else {
241− return Ok(Outcome::fail(FailureCode::Unauthenticated, "Sign in to change a repository's mirroring."));
242− };
243− let act = || MirrorActArgs { actor: actor.clone(), repo_id: repo.id.clone() };
244− let id = text(input, "id");
245− if matches!(op, MirrorsOp::UpdateRemote | MirrorsOp::RemoveRemote) && id.is_empty() {
246− return Ok(Outcome::fail(FailureCode::Invalid, "Name the remote by its id (rmt_…), from get_mirror."));
247− }
248− match op {
249− MirrorsOp::GetMirror => unreachable!("answered above"),
250− MirrorsOp::GetHandBackPlan => g1t_kit::call(integrations, "mirror_hand_back_plan", &act()).await,
251− MirrorsOp::TakeOver => g1t_kit::call(integrations, "mirror_take_over", &act()).await,
252− MirrorsOp::SyncMirror => g1t_kit::call(integrations, "mirror_sync", &act()).await,
253− MirrorsOp::SetCiFailover => {
254− let Some(on) = flag(&input["on"]) else {
255− return Ok(Outcome::fail(FailureCode::Invalid, "Say on: true to start CI failover, or false to end it."));
256− };
257− g1t_kit::call(integrations, "mirror_ci", &MirrorCiArgs { actor, repo_id: repo.id, on }).await
258− }
259− MirrorsOp::HandBack => {
260− let decisions = match decisions_from(input) {
261− Ok(decisions) => decisions,
262− Err(problem) => return Ok(Outcome::fail(FailureCode::Invalid, problem)),
263− };
264− g1t_kit::call(integrations, "mirror_hand_back", &MirrorHandBackArgs { actor, repo_id: repo.id, decisions }).await
265− }
266− MirrorsOp::MoveToG1t => {
267− let keep_remote_updated = match &input["keep_remote_updated"] {
268− Value::Null => false,
269− value => match flag(value) {
270− Some(keep) => keep,
271− None => return Ok(Outcome::fail(FailureCode::Invalid, "keep_remote_updated is true or false.")),
272− },
273− };
274− g1t_kit::call(integrations, "mirror_move_in", &MirrorMoveInArgs { actor, repo_id: repo.id, keep_remote_updated }).await
275− }
276− MirrorsOp::AddRemote => {
277− let provider = match text(input, "provider").as_str() {
278− "g1t" => RemoteProvider::G1t,
279− "git" => RemoteProvider::Git,
280− "github" => {
281− return Ok(Outcome::fail(
282− FailureCode::Invalid,
283− "GitHub repositories are linked by importing them through g1t's GitHub App.",
284− ));
285− }
286− _ => return Ok(Outcome::fail(FailureCode::Invalid, "provider is g1t or git.")),
287− };
288− let role = match text(input, "role").as_str() {
289− "leader" => RemoteRole::Leader,
290− "follower" => RemoteRole::Follower,
291− _ => return Ok(Outcome::fail(FailureCode::Invalid, "role is follower (g1t leads) or leader (the remote leads).")),
292− };
293− let username = Some(text(input, "username")).filter(|name| !name.is_empty());
294− let token = Some(text(input, "token")).filter(|token| !token.is_empty());
295− let args = MirrorAddArgs { actor, repo_id: repo.id, provider, role, url: text(input, "url"), username, token };
296− g1t_kit::call(integrations, "mirror_add", &args).await
297− }
298− MirrorsOp::UpdateRemote => {
299− let view: Outcome<g1t_contracts::mirrors::MirrorView> =
300− g1t_kit::call(integrations, "mirror_view", &MirrorViewArgs { viewer: viewer.clone(), repo_id: repo.id }).await?;
301− let view = match view {
302− Outcome::Ok(view) => view,
303− Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
304− };
305− let Some(remote) = view.remotes.into_iter().find(|remote| remote.id == id) else {
306− return Ok(Outcome::fail(FailureCode::NotFound, "That remote is not one of this repository's."));
307− };
308− let settings = match settings_from(remote.settings, input) {
309− Ok(settings) => settings,
310− Err(problem) => return Ok(Outcome::fail(FailureCode::Invalid, problem)),
311− };
312− g1t_kit::call(integrations, "mirror_settings", &MirrorSettingsArgs { actor, remote_id: id, settings }).await
313− }
314− MirrorsOp::RemoveRemote => {
315− let view: Outcome<g1t_contracts::mirrors::MirrorView> =
316− g1t_kit::call(integrations, "mirror_view", &MirrorViewArgs { viewer: viewer.clone(), repo_id: repo.id }).await?;
317− if !matches!(&view, Outcome::Ok(view) if view.remotes.iter().any(|remote| remote.id == id)) {
318− return Ok(Outcome::fail(FailureCode::NotFound, "That remote is not one of this repository's."));
319− }
320− g1t_kit::call(integrations, "mirror_remove", &MirrorRemoveArgs { actor, remote_id: id }).await
321− }
322− }
323−}
324−
325−#[cfg(test)]
326−mod tests {
327− use super::*;
328− use g1t_contracts::credentials::NEVER;
329− use g1t_contracts::mirrors::{HandBack, Notify, RemotePushes};
330− use g1t_contracts::scopes::{Level, scope_for};
331−
332− #[test]
333− fn each_operation_is_described_and_scoped() {
334− for op in MirrorsOp::ALL {
335− assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::Mirrors(op)), "{}", op.name());
336− assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name());
337− assert!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name());
338− let level = scope_for(op.name()).unwrap_or_else(|| panic!("{} has no scope", op.name())).level();
339− let expected = match op {
340− MirrorsOp::GetMirror => Level::Read,
341− MirrorsOp::SyncMirror => Level::Write,
342− _ => Level::Admin,
343− };
344− assert_eq!(level, expected, "{}", op.name());
345− }
346− for op in [MirrorsOp::MoveToG1t, MirrorsOp::AddRemote, MirrorsOp::UpdateRemote, MirrorsOp::RemoveRemote] {
347− assert!(NEVER.contains(&op.name()), "agents never {}", op.name());
348− }
349− }
350−
351− #[test]
352− fn settings_change_only_what_is_sent() {
353− let base = MirrorSettings::default();
354− let changed = settings_from(base.clone(), &json!({ "notify": "inbox", "take_over_after": 30, "keep_ci_warm": "true" })).unwrap();
355− assert_eq!(changed.notify, Notify::Inbox);
356− assert_eq!(changed.take_over_after, Some(30));
357− assert!(changed.keep_ci_warm);
358− assert_eq!(changed.hand_back, HandBack::WhenClean);
359− assert_eq!(changed.remote_pushes, RemotePushes::Adopt);
360− assert!(changed.report_checks, "checks go to GitHub unless turned off");
361− assert!(!settings_from(base.clone(), &json!({ "report_checks": false })).unwrap().report_checks);
362− let cleared = settings_from(changed, &json!({ "take_over_after": null })).unwrap();
363− assert_eq!(cleared.take_over_after, None);
364− assert!(settings_from(base.clone(), &json!({ "notify": "pager" })).is_err());
365− assert!(settings_from(base, &json!({ "hold_deploys": "maybe" })).is_err());
366− }
367−
368− #[test]
369− fn decisions_name_refs_or_branches() {
370− let decisions = decisions_from(&json!({ "decisions": { "docs": "keep_theirs", "refs/tags/v1": "keep_ours" } })).unwrap();
371− assert_eq!(decisions.get("refs/heads/docs"), Some(&RefDecision::KeepTheirs));
372− assert_eq!(decisions.get("refs/tags/v1"), Some(&RefDecision::KeepOurs));
373− assert!(decisions_from(&json!({ "decisions": { "docs": "merge" } })).is_err());
374− assert!(decisions_from(&json!({})).unwrap().is_empty());
375− }
376−}
+1−25
1111 use crate::run_artifacts::ArtifactsOp;
1212 use crate::archives::ArchiveOp;
1313 use crate::deploy_keys::DeployKeysOp;
14−use crate::mirrors::MirrorsOp;
1514 use crate::deployments::DeploymentsOp;
1615 use crate::telemetry::TelemetryOp;
1716 use crate::packages::PackagesOp;
8685 ),
8786 (
8887 "Invites",
89− "While g1t is invite-only, every new account needs an invite. Your invites, inviting people into a workspace by username or email, and answering the invitations to workspaces sent to you.",
88+ "Inviting people into a workspace by username or email, and answering the invitations to workspaces sent to you.",
9089 &[
91− Op::ListInvites,
92− Op::CreateInvite,
93− Op::RevokeInvite,
9490 Op::ListWorkspaceInvites,
9591 Op::InviteMember,
9692 Op::RevokeWorkspaceInvite,
188184 Op::DeployKeys(DeployKeysOp::GetDeployKey),
189185 Op::DeployKeys(DeployKeysOp::CreateDeployKey),
190186 Op::DeployKeys(DeployKeysOp::DeleteDeployKey),
191− ],
192− ),
193− (
194− "Mirroring",
195− "A repository's links to copies of it on other hosts: a mirror stands by as a read-only copy of a remote that leads, until someone takes over on g1t and later hands back, or moves it to g1t for good. A repository g1t leads can be mirrored to other hosts.",
196− &[
197− Op::Mirrors(MirrorsOp::GetMirror),
198− Op::Mirrors(MirrorsOp::GetHandBackPlan),
199− Op::Mirrors(MirrorsOp::TakeOver),
200− Op::Mirrors(MirrorsOp::SetCiFailover),
201− Op::Mirrors(MirrorsOp::HandBack),
202− Op::Mirrors(MirrorsOp::MoveToG1t),
203− Op::Mirrors(MirrorsOp::SyncMirror),
204− Op::Mirrors(MirrorsOp::AddRemote),
205− Op::Mirrors(MirrorsOp::UpdateRemote),
206− Op::Mirrors(MirrorsOp::RemoveRemote),
207187 ],
208188 ),
209189 (
618598 Op::ConfirmEmail => "Confirm an email address",
619599 Op::RemoveEmail => "Remove an email address",
620600 Op::UpdateEmailSettings => "Change your email settings",
621− Op::ListInvites => "List your invites",
622− Op::CreateInvite => "Create an invite",
623− Op::RevokeInvite => "Revoke an invite",
624601 Op::ListWorkspaceInvites => "List a workspace's invites",
625602 Op::InviteMember => "Invite someone to a workspace",
626603 Op::ListInvitations => "List your workspace invitations",
793770 Op::Artifacts(op) => op.title(),
794771 Op::Archives(op) => op.title(),
795772 Op::DeployKeys(op) => op.title(),
796− Op::Mirrors(op) => op.title(),
797773 Op::Packages(op) => op.title(),
798774 Op::Folios(op) => op.title(),
799775 }
+3−87
3131 use crate::run_artifacts::ArtifactsOp;
3232 use crate::archives::ArchiveOp;
3333 use crate::deploy_keys::DeployKeysOp;
34−use crate::mirrors::MirrorsOp;
3534 use crate::deployments::DeploymentsOp;
3635 use crate::telemetry::TelemetryOp;
3736 use crate::packages::PackagesOp;
124123 ConfirmEmail,
125124 RemoveEmail,
126125 UpdateEmailSettings,
127− ListInvites,
128− CreateInvite,
129− RevokeInvite,
130126 ListWorkspaceInvites,
131127 InviteMember,
132128 RevokeWorkspaceInvite,
313309 Archives(ArchiveOp),
314310 /// A repository's deploy keys: deploy_keys.rs.
315311 DeployKeys(DeployKeysOp),
316− /// A repository's mirroring: its remotes, takeovers and hand-backs:
317− /// mirrors.rs.
318− Mirrors(MirrorsOp),
319312 /// A workspace's packages, their versions, deleting and restoring
320313 /// them, and who may use them: packages.rs.
321314 Packages(PackagesOp),
691684 }
692685
693686 impl Op {
694− pub const ALL: [Op; 366] = [
687+ pub const ALL: [Op; 353] = [
695688 Op::Whoami,
696689 Op::GetWorkspace,
697690 Op::CreateWorkspace,
707700 Op::ConfirmEmail,
708701 Op::RemoveEmail,
709702 Op::UpdateEmailSettings,
710− Op::ListInvites,
711− Op::CreateInvite,
712− Op::RevokeInvite,
713703 Op::ListWorkspaceInvites,
714704 Op::InviteMember,
715705 Op::RevokeWorkspaceInvite,
990980 Op::DeployKeys(DeployKeysOp::GetDeployKey),
991981 Op::DeployKeys(DeployKeysOp::CreateDeployKey),
992982 Op::DeployKeys(DeployKeysOp::DeleteDeployKey),
993− Op::Mirrors(MirrorsOp::GetMirror),
994− Op::Mirrors(MirrorsOp::GetHandBackPlan),
995− Op::Mirrors(MirrorsOp::TakeOver),
996− Op::Mirrors(MirrorsOp::SetCiFailover),
997− Op::Mirrors(MirrorsOp::HandBack),
998− Op::Mirrors(MirrorsOp::MoveToG1t),
999− Op::Mirrors(MirrorsOp::SyncMirror),
1000− Op::Mirrors(MirrorsOp::AddRemote),
1001− Op::Mirrors(MirrorsOp::UpdateRemote),
1002− Op::Mirrors(MirrorsOp::RemoveRemote),
1003983 Op::Protection(ProtectionOp::UpdateEnvironment),
1004984 Op::Protection(ProtectionOp::DeleteEnvironment),
1005985 Op::Protection(ProtectionOp::GetPendingDeployments),
10821062 Op::ConfirmEmail => "confirm_email",
10831063 Op::RemoveEmail => "remove_email",
10841064 Op::UpdateEmailSettings => "update_email_settings",
1085− Op::ListInvites => "list_invites",
1086− Op::CreateInvite => "create_invite",
1087− Op::RevokeInvite => "revoke_invite",
10881065 Op::ListWorkspaceInvites => "list_workspace_invites",
10891066 Op::InviteMember => "invite_member",
10901067 Op::RevokeWorkspaceInvite => "revoke_workspace_invite",
12571234 Op::Artifacts(op) => op.name(),
12581235 Op::Archives(op) => op.name(),
12591236 Op::DeployKeys(op) => op.name(),
1260− Op::Mirrors(op) => op.name(),
12611237 Op::Packages(op) => op.name(),
12621238 Op::Folios(op) => op.name(),
12631239 }
12851261 }
12861262 Op::UpdateEmailSettings => {
12871263 "Change what your addresses do; only the fields given change. `primary` is a confirmed address to make primary: account mail and password resets go there. `backup` is a confirmed address that also gets security notices, or an empty string for the primary only. Changing either needs your account `password`, and every confirmed address is told. `private_email` keeps your address off commits g1t makes for you (merges and changes made on the web, and agents' commits for you), which use your noreply address instead; `block_private_pushes` refuses pushes whose commits carry one of your addresses while it is private. People only."
1288− }
1289− Op::ListInvites => {
1290− "Your invites, newest first, and how many you have left. While g1t is invite-only, every new account needs an invite code. You may have 5 invites out at once: pending and used ones count, and one revoked or expired before it was used comes back. `allowance.limit` is null when you have no limit. `workspaces` lists the workspaces you own that were granted invites to share. A pending invite's `code` is shown to you. `status` is `pending`; `awaiting_confirmation` (used to make an account that has not confirmed its address yet); `awaiting_answer` (used to make an account that has yet to accept or decline the workspace it was invited to); `redeemed`; `declined` (its person declined the workspace); `expired`; or `revoked`. An invite that brings someone into a workspace names it in `workspace`, with the `role` it joins with and, once known, the account it is for in `invitee`."
1291− }
1292− Op::CreateInvite => {
1293− "Make an invite. With `email`, it is sent there and only that address can use it; without, anyone with the code can, once. It works for 30 days. With `workspace`, the new account is brought into that workspace: once it confirms its address it gets an invitation to join as a member, which it accepts or declines, and no workspace of its own is made for it. That must be a workspace you own on the g1t plan; a free workspace is refused with `payment_required` (402). Without `workspace`, the new account gets a free workspace of its own. It uses one of your invites, or with `charge_workspace`, one of the invites g1t granted that workspace (its owners only). Returns the invite with its `code`; the link is https://g1t.sh/invite/<code>. People only: an agent's token or a workspace's token cannot make invites."
1294− }
1295− Op::RevokeInvite => {
1296− "Revoke a pending invite you made, or one made for a workspace you own. It stops working at once, and the invite comes back to whoever it was charged to."
12971264 }
12981265 Op::ListWorkspaceInvites => {
12991266 "The invites made for a workspace, newest first, with each pending one's `code`. Owners only."
13001267 }
13011268 Op::InviteMember => {
1302− "Invite someone into a workspace, by `username` or by `email`. Nobody joins without saying yes: they get an invitation to accept or decline, and join with `role` (`member` unless you give `owner`) when they accept. By `username`, the account gets the invitation in its inbox and by email, and it costs nothing. By `email`, it always makes an invite bound to that address and emails it the link, so the answer never says whether the address has a g1t account. Without one, the link makes the account, which is invited once it confirms its address; while g1t is invite-only that uses one of the workspace's granted invites, or else one of yours, and once anyone can sign up it costs nothing. With one, it costs nothing. Refused with `409` when the person is already a member or already has a pending invitation to the workspace. Owners only. A free workspace cannot invite anyone: this is refused with `payment_required` (402) until it starts the g1t plan, and an invite sent before cannot be accepted until then."
1269+ "Invite someone into a workspace, by `username` or by `email`. Nobody joins without saying yes: they get an invitation to accept or decline, and join with `role` (`member` unless you give `owner`) when they accept. By `username`, the account gets the invitation in its inbox and by email, and it costs nothing. By `email`, it always makes an invite bound to that address and emails it the link, so the answer never says whether the address has a g1t account. Without one, the link makes the account, which is invited once it confirms its address. Refused with `409` when the person is already a member or already has a pending invitation to the workspace. Owners only. A free workspace cannot invite anyone: this is refused with `payment_required` (402) until it starts the g1t plan, and an invite sent before cannot be accepted until then."
13031270 }
13041271 Op::RevokeWorkspaceInvite => "Revoke a workspace's pending invite. Owners only.",
13051272 Op::ListInvitations => {
17771744 Op::Artifacts(op) => op.description(),
17781745 Op::Archives(op) => op.description(),
17791746 Op::DeployKeys(op) => op.description(),
1780− Op::Mirrors(op) => op.description(),
17811747 Op::Packages(op) => op.description(),
17821748 Op::Folios(op) => op.description(),
17831749 }
18461812 "password": {
18471813 "type": "string",
18481814 "description": "Your account password, to confirm it is you. An account that signs in only with GitHub changes its addresses on g1t.sh.",
1849− },
1850− }),
1851− &[],
1852− ),
1853− Op::ListInvites => object(json!({}), &[]),
1854− Op::CreateInvite => object(
1855− json!({
1856− "email": {
1857− "type": "string",
1858− "description": "Only this address can use it, and it is emailed there. Left out, anyone with the code can.",
1859− },
1860− "workspace": {
1861− "type": "string",
1862− "description": "The workspace the new account is invited to, by slug. Once it confirms its address it gets an invitation to join as a member, and no workspace of its own. One you own, on the g1t plan.",
1863− },
1864− "charge_workspace": {
1865− "type": "string",
1866− "description": "Use one of the invites g1t granted this workspace instead of yours, by slug. Owners only.",
18671815 },
18681816 }),
18691817 &[],
18701818 ),
1871− Op::RevokeInvite => object(
1872− json!({ "id": { "type": "string", "description": "The invite's id, such as inv_01k…" } }),
1873− &["id"],
1874− ),
18751819 Op::ListWorkspaceInvites => object(json!({ "workspace": workspace_schema() }), &["workspace"]),
18761820 Op::InviteMember => object(
18771821 json!({
23152259 "assignees": {
23162260 "type": "array",
23172261 "items": { "type": "string" },
2318− "description": "Usernames of the people it is assigned to. Replaces the whole set; an empty list unassigns everyone. To assign it to g1t, use assign_issue.",
2262+ "description": "Usernames of the people it is assigned to. Replaces the whole set; an empty list unassigns everyone.",
23192263 },
23202264 })),
23212265 &["repo", "number"],
31263070 Op::Artifacts(op) => op.input(),
31273071 Op::Archives(op) => op.input(),
31283072 Op::DeployKeys(op) => op.input(),
3129− Op::Mirrors(op) => op.input(),
31303073 Op::Packages(op) => op.input(),
31313074 Op::Folios(op) => op.input(),
31323075 }
32603203 | Op::ConfirmEmail
32613204 | Op::RemoveEmail
32623205 | Op::UpdateEmailSettings
3263− | Op::ListInvites
3264− | Op::CreateInvite
3265− | Op::RevokeInvite
32663206 | Op::ListWorkspaceInvites
32673207 | Op::InviteMember
32683208 | Op::RevokeWorkspaceInvite
35643504 )
35653505 .await
35663506 }
3567− Op::ListInvites => {
3568− let overview: g1t_contracts::identity::InvitesOverview =
3569− g1t_kit::call(identity, "list_invites", &json!({ "user": actor() })).await?;
3570− ok(&overview)
3571− }
3572− Op::CreateInvite => {
3573− pass(
3574− identity,
3575− "create_invite",
3576− &json!({
3577− "user": actor(),
3578− "email": optional_text(input, "email"),
3579− "workspace": optional_text(input, "charge_workspace"),
3580− "join": optional_text(input, "workspace"),
3581− "surface": services.audit.surface,
3582− }),
3583− )
3584− .await
3585− }
3586− Op::RevokeInvite => {
3587− pass(identity, "revoke_invite", &json!({ "user": actor(), "id": text(input, "id") })).await
3588− }
35893507 Op::ListWorkspaceInvites => {
35903508 pass(identity, "workspace_invites", &json!({ "slug": workspace(), "viewer": viewer })).await
35913509 }
41924110 is_private: input["private"].as_bool() == Some(true),
41934111 import_url: optional_text(input, "import_url"),
41944112 import_token: None,
4195− mirror: None,
41964113 },
41974114 )
41984115 .await
53415258 Op::Artifacts(op) => crate::run_artifacts::run(op, services, viewer, input).await,
53425259 Op::Archives(op) => crate::archives::run(op, services, viewer, input).await,
53435260 Op::DeployKeys(op) => crate::deploy_keys::run(op, services, viewer, input).await,
5344− Op::Mirrors(op) => crate::mirrors::run(op, services, viewer, input).await,
53455261 Op::Packages(op) => crate::packages::run(op, services, viewer, input).await,
53465262 Op::Folios(op) => crate::folios::run(op, services, viewer, input).await,
53475263 Op::ReopenSecurityAlert => {
+0−496
383383 },
384384 "notes": "Only the fields given change. `primary` and `backup` need `password`; `private_email` and `block_private_pushes` do not."
385385 },
386− "list_invites": {
387− "response": {
388− "mode": "invite",
389− "allowance": {
390− "limit": 5,
391− "used": 2,
392− "remaining": 3
393− },
394− "workspaces": [
395− {
396− "slug": "acme-labs",
397− "allowance": {
398− "limit": 20,
399− "used": 4,
400− "remaining": 16
401− }
402− }
403− ],
404− "invites": [
405− {
406− "id": "inv_01kp2x7m8n9q0r1s2t3v4w5x6y",
407− "code": "g1t-k7m2-q9xd-4hpw-…",
408− "hint": "g1t-k7m2",
409− "email": "ada@example.com",
410− "kind": "account",
411− "workspace": null,
412− "status": "pending",
413− "charged_to": "user",
414− "invited_by": "syntaqx",
415− "redeemed_by": null,
416− "created_at": "2026-10-05T17:00:00.000Z",
417− "expires_at": "2026-11-04T17:00:00.000Z",
418− "redeemed_at": null,
419− "revoked_at": null,
420− "invitee": null,
421− "role": null
422− },
423− {
424− "id": "inv_01kp1v3c4d5e6f7g8h9j0k1m2n",
425− "code": null,
426− "hint": "g1t-p3rt",
427− "email": null,
428− "kind": "account",
429− "workspace": null,
430− "status": "redeemed",
431− "charged_to": "user",
432− "invited_by": "syntaqx",
433− "redeemed_by": "grace",
434− "created_at": "2026-10-02T09:12:40.000Z",
435− "expires_at": "2026-11-01T09:12:40.000Z",
436− "redeemed_at": "2026-10-02T11:30:05.000Z",
437− "revoked_at": null,
438− "invitee": "grace",
439− "role": null
440− }
441− ]
442− },
443− "notes": "`allowance.limit` and `allowance.remaining` are null when you have no limit. A revoked or expired invite that was never used is not counted. `status` is `pending`, `awaiting_confirmation`, `awaiting_answer` (the account it made has yet to accept or decline the workspace in `workspace`), `redeemed`, `declined`, `expired` or `revoked`. Ask for more at hey@flagon.io with the subject `[g1t Invites]`. See [Invites](/guides/authentication/#invites)."
444− },
445− "create_invite": {
446− "request": {
447− "email": "ada@example.com",
448− "workspace": "acme-labs"
449− },
450− "response": {
451− "id": "inv_01kp2x7m8n9q0r1s2t3v4w5x6y",
452− "code": "g1t-k7m2-q9xd-4hpw-…",
453− "hint": "g1t-k7m2",
454− "email": "ada@example.com",
455− "kind": "account",
456− "workspace": "acme-labs",
457− "status": "pending",
458− "charged_to": "user",
459− "invited_by": "syntaqx",
460− "redeemed_by": null,
461− "created_at": "2026-10-05T17:00:00.000Z",
462− "expires_at": "2026-11-04T17:00:00.000Z",
463− "redeemed_at": null,
464− "revoked_at": null,
465− "invitee": null,
466− "role": "member"
467− },
468− "notes": "`workspace` brings the new account into a workspace you own: once it confirms its address it gets an invitation to join as a member, which it accepts or declines, and no workspace of its own is made for it. Leave `workspace` out and the new account gets a free workspace of its own. A workspace on the free plan cannot bring anyone in: `402` with `payment_required`, until it starts the g1t plan. `charge_workspace` is separate: it uses one of the invites g1t granted that workspace instead of one of yours, and works with or without `workspace`. Refused with `402` (`limit`) when you have no invites left, with `409` when you already have a pending invite for that address or it already has a g1t account, and with `403` for an agent's or a workspace's token. The code is returned in full; send people the link `https://g1t.sh/invite/<code>`."
469− },
470− "revoke_invite": {
471− "response": {
472− "id": "inv_01kp2x7m8n9q0r1s2t3v4w5x6y",
473− "code": null,
474− "hint": "g1t-k7m2",
475− "email": "ada@example.com",
476− "kind": "account",
477− "workspace": null,
478− "status": "revoked",
479− "charged_to": "user",
480− "invited_by": "syntaqx",
481− "redeemed_by": null,
482− "created_at": "2026-10-05T17:00:00.000Z",
483− "expires_at": "2026-11-04T17:00:00.000Z",
484− "redeemed_at": null,
485− "revoked_at": "2026-10-06T08:00:00.000Z",
486− "invitee": null,
487− "role": null
488− }
489− },
490386 "list_workspace_invites": {
491387 "response": [
492388 {
497393 "kind": "account",
498394 "workspace": "acme-labs",
499395 "status": "pending",
500− "charged_to": "workspace",
501396 "invited_by": "syntaqx",
502397 "redeemed_by": null,
503398 "created_at": "2026-10-05T17:00:00.000Z",
515410 "kind": "workspace",
516411 "workspace": "acme-labs",
517412 "status": "pending",
518− "charged_to": "none",
519413 "invited_by": "syntaqx",
520414 "redeemed_by": null,
521415 "created_at": "2026-10-05T17:00:00.000Z",
540434 "kind": "workspace",
541435 "workspace": "acme-labs",
542436 "status": "pending",
543− "charged_to": "none",
544437 "invited_by": "syntaqx",
545438 "redeemed_by": null,
546439 "created_at": "2026-10-05T17:00:00.000Z",
561454 "kind": "account",
562455 "workspace": "acme-labs",
563456 "status": "revoked",
564− "charged_to": "workspace",
565457 "invited_by": "syntaqx",
566458 "redeemed_by": null,
567459 "created_at": "2026-10-05T17:00:00.000Z",
1201811910 "role": "write",
1201911911 "linked": true,
1202011912 "created_at": null
12021− }
12022− ]
12023− }
12024− },
12025− "get_mirror": {
12026− "params": {
12027− "owner": "acme",
12028− "name": "web"
12029− },
12030− "response": {
12031− "remotes": [
12032− {
12033− "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m",
12034− "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn",
12035− "repo": "acme/web",
12036− "provider": "github",
12037− "role": "leader",
12038− "name": "github.com/acme/web",
12039− "url": "https://github.com/acme/web",
12040− "state": "standby",
12041− "state_since": "2026-10-08T13:58:00.000Z",
12042− "state_by": "syntaqx",
12043− "reachable": true,
12044− "unreachable_since": null,
12045− "synced_at": "2026-10-08T13:57:42.000Z",
12046− "last_error": null,
12047− "checks_problem": null,
12048− "settings": {
12049− "notify": "banner",
12050− "take_over_after": null,
12051− "hand_back": "when_clean",
12052− "keep_ci_warm": false,
12053− "github_workflows": true,
12054− "hold_deploys": true,
12055− "report_checks": true,
12056− "remote_pushes": "adopt"
12057− },
12058− "created_at": "2026-10-05T10:00:00.000Z"
12059− }
12060− ],
12061− "plan": null,
12062− "can_manage": true
12063− },
12064− "notes": "A repository that leads lists its followers instead, each `role: follower` with `state` `following` or `stuck`. A mirror standing by is read-only on g1t: pushes, merges, issues, pull requests and agents are refused, and nothing runs. `reachable` is false once the remote's host has failed three checks over two minutes; by default that is only shown, and nothing happens on its own."
12065− },
12066− "get_hand_back_plan": {
12067− "params": {
12068− "owner": "acme",
12069− "name": "web"
12070− },
12071− "response": {
12072− "remotes": [
12073− {
12074− "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m",
12075− "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn",
12076− "repo": "acme/web",
12077− "provider": "github",
12078− "role": "leader",
12079− "name": "github.com/acme/web",
12080− "url": "https://github.com/acme/web",
12081− "state": "takeover",
12082− "state_since": "2026-10-08T13:58:00.000Z",
12083− "state_by": "syntaqx",
12084− "reachable": true,
12085− "unreachable_since": null,
12086− "synced_at": "2026-10-08T13:57:42.000Z",
12087− "last_error": null,
12088− "checks_problem": null,
12089− "settings": {
12090− "notify": "banner",
12091− "take_over_after": null,
12092− "hand_back": "when_clean",
12093− "keep_ci_warm": false,
12094− "github_workflows": true,
12095− "hold_deploys": true,
12096− "report_checks": true,
12097− "remote_pushes": "adopt"
12098− },
12099− "created_at": "2026-10-05T10:00:00.000Z"
12100− }
12101− ],
12102− "plan": {
12103− "refs": [
12104− {
12105− "ref": "refs/heads/fix/login-timeout",
12106− "base": "9d0e1aa4c8f2b7e6d5a4c3b2a1f0e9d8c7b6a5f4",
12107− "ours": "c47f2b8e1d0c9b8a7f6e5d4c3b2a1f0e9d8c7b6a",
12108− "theirs": "9d0e1aa4c8f2b7e6d5a4c3b2a1f0e9d8c7b6a5f4",
12109− "action": "push",
12110− "decision": null
12111− },
12112− {
12113− "ref": "refs/heads/main",
12114− "base": "41c9e02b7a6f5e4d3c2b1a0f9e8d7c6b5a4f3e2d",
12115− "ours": "e01d9a37f6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1",
12116− "theirs": "41c9e02b7a6f5e4d3c2b1a0f9e8d7c6b5a4f3e2d",
12117− "action": "pull_request",
12118− "decision": null
12119− },
12120− {
12121− "ref": "refs/heads/docs/readme",
12122− "base": "7f3a1c2d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b",
12123− "ours": "5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c",
12124− "theirs": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
12125− "action": "diverged",
12126− "decision": null
12127− }
12128− ],
12129− "reachable": true,
12130− "ready": false
12131− },
12132− "can_manage": true
12133− },
12134− "notes": "Refs that did not move on either side are left out. `ready` stays false until `docs/readme` has a decision."
12135− },
12136− "take_over_mirror": {
12137− "params": {
12138− "owner": "acme",
12139− "name": "web"
12140− },
12141− "response": {
12142− "remotes": [
12143− {
12144− "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m",
12145− "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn",
12146− "repo": "acme/web",
12147− "provider": "github",
12148− "role": "leader",
12149− "name": "github.com/acme/web",
12150− "url": "https://github.com/acme/web",
12151− "state": "takeover",
12152− "state_since": "2026-10-08T13:58:00.000Z",
12153− "state_by": "syntaqx",
12154− "reachable": false,
12155− "unreachable_since": "2026-10-08T14:04:00.000Z",
12156− "synced_at": "2026-10-08T13:57:42.000Z",
12157− "last_error": null,
12158− "checks_problem": null,
12159− "settings": {
12160− "notify": "banner",
12161− "take_over_after": null,
12162− "hand_back": "when_clean",
12163− "keep_ci_warm": false,
12164− "github_workflows": true,
12165− "hold_deploys": true,
12166− "report_checks": true,
12167− "remote_pushes": "adopt"
12168− },
12169− "created_at": "2026-10-05T10:00:00.000Z"
12170− }
12171− ],
12172− "plan": null,
12173− "can_manage": true
12174− }
12175− },
12176− "set_ci_failover": {
12177− "params": {
12178− "owner": "acme",
12179− "name": "web"
12180− },
12181− "request": {
12182− "on": true
12183− },
12184− "response": {
12185− "remotes": [
12186− {
12187− "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m",
12188− "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn",
12189− "repo": "acme/web",
12190− "provider": "github",
12191− "role": "leader",
12192− "name": "github.com/acme/web",
12193− "url": "https://github.com/acme/web",
12194− "state": "ci",
12195− "state_since": "2026-10-08T13:58:00.000Z",
12196− "state_by": "syntaqx",
12197− "reachable": true,
12198− "unreachable_since": null,
12199− "synced_at": "2026-10-08T13:57:42.000Z",
12200− "last_error": null,
12201− "checks_problem": null,
12202− "settings": {
12203− "notify": "banner",
12204− "take_over_after": null,
12205− "hand_back": "when_clean",
12206− "keep_ci_warm": false,
12207− "github_workflows": true,
12208− "hold_deploys": true,
12209− "report_checks": true,
12210− "remote_pushes": "adopt"
12211− },
12212− "created_at": "2026-10-05T10:00:00.000Z"
12213− }
12214− ],
12215− "plan": null,
12216− "can_manage": true
12217− }
12218− },
12219− "hand_back_mirror": {
12220− "params": {
12221− "owner": "acme",
12222− "name": "web"
12223− },
12224− "request": {
12225− "decisions": {
12226− "refs/heads/docs/readme": "pull_request"
12227− }
12228− },
12229− "response": {
12230− "remotes": [
12231− {
12232− "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m",
12233− "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn",
12234− "repo": "acme/web",
12235− "provider": "github",
12236− "role": "leader",
12237− "name": "github.com/acme/web",
12238− "url": "https://github.com/acme/web",
12239− "state": "standby",
12240− "state_since": "2026-10-08T13:58:00.000Z",
12241− "state_by": "syntaqx",
12242− "reachable": true,
12243− "unreachable_since": null,
12244− "synced_at": "2026-10-08T13:57:42.000Z",
12245− "last_error": null,
12246− "checks_problem": null,
12247− "settings": {
12248− "notify": "banner",
12249− "take_over_after": null,
12250− "hand_back": "when_clean",
12251− "keep_ci_warm": false,
12252− "github_workflows": true,
12253− "hold_deploys": true,
12254− "report_checks": true,
12255− "remote_pushes": "adopt"
12256− },
12257− "created_at": "2026-10-05T10:00:00.000Z"
1225811913 }
12259− ],
12260− "plan": null,
12261− "can_manage": true,
12262− "notes": [
12263− "main: opened https://github.com/acme/web/pull/1290",
12264− "docs/readme: opened https://github.com/acme/web/pull/1291"
1226511914 ]
12266− },
12267− "notes": "Branch names work as keys too: `{ \"docs/readme\": \"keep_theirs\" }`."
12268− },
12269− "move_mirror_to_g1t": {
12270− "params": {
12271− "owner": "acme",
12272− "name": "web"
12273− },
12274− "request": {
12275− "keep_remote_updated": false
12276− },
12277− "response": {
12278− "remotes": [],
12279− "plan": null,
12280− "can_manage": true
12281− },
12282− "notes": "With `keep_remote_updated: true` the remote stays in `remotes` as a follower, and g1t pushes to it from then on."
12283− },
12284− "sync_mirror": {
12285− "params": {
12286− "owner": "acme",
12287− "name": "web"
12288− },
12289− "response": {
12290− "remotes": [
12291− {
12292− "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m",
12293− "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn",
12294− "repo": "acme/web",
12295− "provider": "github",
12296− "role": "leader",
12297− "name": "github.com/acme/web",
12298− "url": "https://github.com/acme/web",
12299− "state": "standby",
12300− "state_since": "2026-10-08T13:58:00.000Z",
12301− "state_by": "syntaqx",
12302− "reachable": true,
12303− "unreachable_since": null,
12304− "synced_at": "2026-10-08T13:57:42.000Z",
12305− "last_error": null,
12306− "checks_problem": null,
12307− "settings": {
12308− "notify": "banner",
12309− "take_over_after": null,
12310− "hand_back": "when_clean",
12311− "keep_ci_warm": false,
12312− "github_workflows": true,
12313− "hold_deploys": true,
12314− "report_checks": true,
12315− "remote_pushes": "adopt"
12316− },
12317− "created_at": "2026-10-05T10:00:00.000Z"
12318− }
12319− ],
12320− "plan": null,
12321− "can_manage": true
12322− }
12323− },
12324− "add_mirror_remote": {
12325− "params": {
12326− "owner": "acme",
12327− "name": "web"
12328− },
12329− "request": {
12330− "provider": "g1t",
12331− "role": "follower",
12332− "url": "https://git.acme.internal/acme/web.git",
12333− "username": "deploy",
12334− "token": "g1t_…"
12335− },
12336− "response": {
12337− "id": "rmt_01kp4b3c4d5e6f7g8h9j0k1m2n",
12338− "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn",
12339− "repo": "acme/web",
12340− "provider": "g1t",
12341− "role": "follower",
12342− "name": "git.acme.internal/acme/web",
12343− "url": "https://git.acme.internal/acme/web",
12344− "state": "following",
12345− "state_since": "2026-10-08T13:58:00.000Z",
12346− "state_by": "syntaqx",
12347− "reachable": true,
12348− "unreachable_since": null,
12349− "synced_at": "2026-10-08T13:57:42.000Z",
12350− "last_error": null,
12351− "checks_problem": null,
12352− "settings": {
12353− "notify": "banner",
12354− "take_over_after": null,
12355− "hand_back": "when_clean",
12356− "keep_ci_warm": false,
12357− "github_workflows": true,
12358− "hold_deploys": true,
12359− "report_checks": true,
12360− "remote_pushes": "adopt"
12361− },
12362− "created_at": "2026-10-05T10:00:00.000Z"
12363− },
12364− "notes": "The token is never returned. A remote that refused it, or could not be reached, is still added, with `last_error` saying why."
12365− },
12366− "update_mirror_remote": {
12367− "params": {
12368− "owner": "acme",
12369− "name": "web",
12370− "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m"
12371− },
12372− "request": {
12373− "notify": "inbox",
12374− "take_over_after": 30
12375− },
12376− "response": {
12377− "id": "rmt_01kp4a2b3c4d5e6f7g8h9j0k1m",
12378− "repo_id": "rep_01kp49z8y7x6w5v4t3s2r1q0pn",
12379− "repo": "acme/web",
12380− "provider": "github",
12381− "role": "leader",
12382− "name": "github.com/acme/web",
12383− "url": "https://github.com/acme/web",
12384− "state": "standby",
12385− "state_since": "2026-10-08T13:58:00.000Z",
12386− "state_by": "syntaqx",
12387− "reachable": true,
12388− "unreachable_since": null,
12389− "synced_at": "2026-10-08T13:57:42.000Z",
12390− "last_error": null,
12391− "checks_problem": null,
12392− "settings": {
12393− "notify": "inbox",
12394− "take_over_after": 30,
12395− "hand_back": "when_clean",
12396− "keep_ci_warm": false,
12397− "github_workflows": true,
12398− "hold_deploys": true,
12399− "report_checks": true,
12400− "remote_pushes": "adopt"
12401− },
12402− "created_at": "2026-10-05T10:00:00.000Z"
1240311915 }
12404− },
12405− "remove_mirror_remote": {
12406− "params": {
12407− "owner": "acme",
12408− "name": "web",
12409− "id": "rmt_01kp4b3c4d5e6f7g8h9j0k1m2n"
12410− },
12411− "response": true
1241211916 },
1241311917 "list_workspace_artifacts": {
1241411918 "params": {
+1−2
209209 through::<g1t_contracts::accounts::AccountEmails>(op, sent)
210210 }
211211 Op::ConfirmEmail => through::<g1t_contracts::accounts::EmailConfirmed>(op, sent),
212− Op::ListInvites => through::<g1t_contracts::identity::InvitesOverview>(op, sent),
213− Op::CreateInvite | Op::RevokeInvite | Op::InviteMember | Op::RevokeWorkspaceInvite => {
212+ Op::InviteMember | Op::RevokeWorkspaceInvite => {
214213 through::<g1t_contracts::identity::Invite>(op, sent)
215214 }
216215 Op::ListWorkspaceInvites => through::<Vec<g1t_contracts::identity::Invite>>(op, sent),
+0−15
66 use crate::run_artifacts::ArtifactsOp;
77 use crate::archives::ArchiveOp;
88 use crate::deploy_keys::DeployKeysOp;
9−use crate::mirrors::MirrorsOp;
109 use crate::deployments::DeploymentsOp;
1110 use crate::telemetry::TelemetryOp;
1211 use crate::packages::PackagesOp;
5150 route("POST", "/user/emails/confirm", Op::ConfirmEmail, &[]),
5251 route("DELETE", "/user/emails/:email", Op::RemoveEmail, &[]),
5352 route("PATCH", "/user/email-settings", Op::UpdateEmailSettings, &[]),
54− route("GET", "/user/invites", Op::ListInvites, &[]),
55− route("POST", "/user/invites", Op::CreateInvite, &[]),
56− route("DELETE", "/user/invites/:id", Op::RevokeInvite, &[]),
5753 // Invitations to workspaces waiting for your answer.
5854 route("GET", "/user/invitations", Op::ListInvitations, &[]),
5955 route("POST", "/user/invitations/:id/accept", Op::AcceptInvitation, &[]),
8884 route("POST", "/repos/:owner/:name/keys", Op::DeployKeys(DeployKeysOp::CreateDeployKey), &[]),
8985 route("GET", "/repos/:owner/:name/keys/:id", Op::DeployKeys(DeployKeysOp::GetDeployKey), &[]),
9086 route("DELETE", "/repos/:owner/:name/keys/:id", Op::DeployKeys(DeployKeysOp::DeleteDeployKey), &[]),
91− // Mirroring: a repository's remotes, takeovers and hand-backs.
92− route("GET", "/repos/:owner/:name/mirror", Op::Mirrors(MirrorsOp::GetMirror), &[]),
93− route("GET", "/repos/:owner/:name/mirror/hand-back", Op::Mirrors(MirrorsOp::GetHandBackPlan), &[]),
94− route("POST", "/repos/:owner/:name/mirror/take-over", Op::Mirrors(MirrorsOp::TakeOver), &[]),
95− route("POST", "/repos/:owner/:name/mirror/ci", Op::Mirrors(MirrorsOp::SetCiFailover), &[]),
96− route("POST", "/repos/:owner/:name/mirror/hand-back", Op::Mirrors(MirrorsOp::HandBack), &[]),
97− route("POST", "/repos/:owner/:name/mirror/move-to-g1t", Op::Mirrors(MirrorsOp::MoveToG1t), &[]),
98− route("POST", "/repos/:owner/:name/mirror/sync", Op::Mirrors(MirrorsOp::SyncMirror), &[]),
99− route("POST", "/repos/:owner/:name/mirror/remotes", Op::Mirrors(MirrorsOp::AddRemote), &[]),
100− route("PATCH", "/repos/:owner/:name/mirror/remotes/:id", Op::Mirrors(MirrorsOp::UpdateRemote), &[]),
101− route("DELETE", "/repos/:owner/:name/mirror/remotes/:id", Op::Mirrors(MirrorsOp::RemoveRemote), &[]),
10287 // Your notifications: threads, marking them, and what you subscribe
10388 // to and watch. GitHub's addresses, with g1t's saved and snoozed.
10489 route("GET", "/notifications", Op::ListNotifications, &[("all", "all"), ("participating", "participating"), ("view", "view"), ("reason", "reason"), ("severity", "severity"), ("since", "since"), ("before", "before"), ("cursor", "cursor"), ("per_page", "per_page")]),
+2−2
625625 }
626626
627627 /// The repository a project's permission comes from, as the deployments
628−/// service reads it: a mirrored project has none, and its workspace's base
629−/// permission decides.
628+/// service reads it: a project without a hosted source has none, and its
629+/// workspace's base permission decides.
630630 fn repo_of(project: &Value) -> (String, String, bool) {
631631 let source = &project["source"];
632632 let private = project["private"].as_bool().unwrap_or(true);
+2−16
2222 use crate::run_artifacts::ArtifactsOp;
2323 use crate::archives::ArchiveOp;
2424 use crate::deploy_keys::DeployKeysOp;
25−use crate::mirrors::MirrorsOp;
2625 use crate::deployments::DeploymentsOp;
2726 use crate::telemetry::TelemetryOp;
2827 use crate::packages::PackagesOp;
6968 Tool {
7069 name: "repository",
7170 title: "Repositories",
72− description: "Repositories: find, read and create them, change their settings and rulesets (what may happen to branches and tags, and what a pull request needs to merge), check their CODEOWNERS file, manage their labels and milestones, see and dismiss their security alerts (secrets and vulnerable dependencies), read what their default branch says (languages, contributors, license), star them, publish releases, and look after their mirroring (take a mirror over, hand it back, or move it to g1t for good). Name one as \"owner/name\". Deleting, transferring and changing visibility need `confirm`.",
71+ description: "Repositories: find, read and create them, change their settings and rulesets (what may happen to branches and tags, and what a pull request needs to merge), check their CODEOWNERS file, manage their labels and milestones, see and dismiss their security alerts (secrets and vulnerable dependencies), read what their default branch says (languages, contributors, license), star them and publish releases. Name one as \"owner/name\". Deleting, transferring and changing visibility need `confirm`.",
7372 default_action: None,
7473 actions: &[
7574 a("list", Op::ListRepos, "Repositories you can see"),
120119 a("delete_branch", Op::DeleteBranch, "Delete a branch; make it again at its commit to restore it"),
121120 a("rename", Op::RenameRepo, "Rename it; old addresses redirect"),
122121 a("transfer", Op::TransferRepo, "Move it to another workspace you own"),
123− a("mirror", Op::Mirrors(MirrorsOp::GetMirror), "Its remotes: what it mirrors or is mirrored to"),
124− a("mirror_hand_back_plan", Op::Mirrors(MirrorsOp::GetHandBackPlan), "What handing a takeover back would do, ref by ref"),
125− a("mirror_take_over", Op::Mirrors(MirrorsOp::TakeOver), "Make g1t lead a mirror for now"),
126− a("mirror_ci", Op::Mirrors(MirrorsOp::SetCiFailover), "Run a mirror's workflows on g1t (on), or stop (off)"),
127− a("mirror_hand_back", Op::Mirrors(MirrorsOp::HandBack), "Send a takeover back, deciding diverged refs"),
128− a("mirror_move_to_g1t", Op::Mirrors(MirrorsOp::MoveToG1t), "Stop tracking the remote; g1t leads for good"),
129− a("mirror_sync", Op::Mirrors(MirrorsOp::SyncMirror), "Bring its remotes in step now"),
130− a("mirror_add_remote", Op::Mirrors(MirrorsOp::AddRemote), "Link another g1t or git host"),
131− a("mirror_update_remote", Op::Mirrors(MirrorsOp::UpdateRemote), "Change a remote's settings"),
132− a("mirror_remove_remote", Op::Mirrors(MirrorsOp::RemoveRemote), "Unlink a remote"),
133122 a("archive", Op::ArchiveRepo, "Make it read-only"),
134123 a("unarchive", Op::UnarchiveRepo, "Make it writable again"),
135124 a("set_visibility", Op::SetRepoVisibility, "Make it public or private"),
511500 Tool {
512501 name: "account",
513502 title: "Your account",
514− description: "Who this token acts as and its workspaces (`whoami`), your email addresses, your invites, and invitations to workspaces and repositories waiting for you.",
503+ description: "Who this token acts as and its workspaces (`whoami`), your email addresses, and invitations to workspaces and repositories waiting for you.",
515504 default_action: Some("whoami"),
516505 actions: &[
517506 a("whoami", Op::Whoami, "Who the token acts as, and its workspaces"),
520509 a("confirm_email", Op::ConfirmEmail, "Confirm an address with the code from its email"),
521510 a("remove_email", Op::RemoveEmail, "Remove an address"),
522511 a("update_email_settings", Op::UpdateEmailSettings, "Primary, backup and privacy"),
523− a("list_invites", Op::ListInvites, "Your invites to g1t"),
524− a("create_invite", Op::CreateInvite, "Make an invite"),
525− a("revoke_invite", Op::RevokeInvite, "Revoke one"),
526512 a("list_workspace_invitations", Op::ListInvitations, "Invitations to workspaces for you"),
527513 a("accept_workspace_invitation", Op::AcceptInvitation, "Accept one and join"),
528514 a("decline_workspace_invitation", Op::DeclineInvitation, "Decline one"),
+39−7
5353 button.addEventListener("click", function () {
5454 if (action.id === "choose_workspace") return byId("runnerWorkspace").focus();
5555 if (action.id === "choose_backend") return byId("runnerBackend").focus();
56− invoke("runner_control", { action: action.id }).then(function () {
57− if (action.id.indexOf("copy_") !== 0) return;
58− button.textContent = "Copied";
59− setTimeout(function () {
60− button.textContent = action.label;
61− }, 2000);
62− }, fail);
56+ if (action.id === "remove_sandbox" && !window.confirm(REMOVE_SANDBOX)) return;
57+ var copying = action.id.indexOf("copy_") === 0;
58+ // Busy until the status that follows draws the buttons again, so a
59+ // press is seen to do something; given back if nothing comes.
60+ if (!copying) working(button);
61+ invoke("runner_control", { action: action.id }).then(
62+ function () {
63+ if (!copying) return;
64+ button.textContent = "Copied";
65+ setTimeout(function () {
66+ button.textContent = action.label;
67+ }, 2000);
68+ },
69+ function (error) {
70+ done(button, action.label);
71+ fail(error);
72+ },
73+ );
6374 });
6475 return button;
6576 }
6677
78+ var REMOVE_SANDBOX =
79+ "Remove this computer's sandbox? Its VM and registration go, and g1t makes its own in their place, registered with your workspace. Its old runners stay listed on g1t until you remove them there.";
80+
81+ // A button at work: a spinner and its label, pressed no more until it is done.
82+ function working(button) {
83+ button.disabled = true;
84+ button.classList.add("busy");
85+ button.setAttribute("aria-busy", "true");
86+ var label = button.textContent;
87+ setTimeout(function () {
88+ if (button.isConnected) done(button, label);
89+ }, 20000);
90+ }
91+
92+ function done(button, label) {
93+ button.disabled = false;
94+ button.classList.remove("busy");
95+ button.removeAttribute("aria-busy");
96+ button.textContent = label;
97+ }
98+
6799 function element(tag, className, text) {
68100 var item = document.createElement(tag);
69101 if (className) item.className = className;
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…

More files changed than one page lists: these are the first 300.