Merge branch 'fast-push' into mirroring
# Conflicts: # apps/api/src/operations.rs # apps/web/app/components/shell.tsx # apps/web/app/lib/access.ts # apps/web/app/routes/repo/issue-new.tsx # apps/web/app/routes/repo/pull-new.tsx
| 7 | 7 | # typescript type checks and tests of the apps and TS services, the | |
| 8 | 8 | # deploy and ops scripts, and the deploy manifest | |
| 9 | 9 | # build the site, sudo and the docs build as they deploy | |
| 10 | + | # | |
| 11 | + | # On a push to main only `rust` runs, to keep main's caches current: a pull | |
| 12 | + | # request's run restores from main's cache, never from another pull | |
| 13 | + | # request's, so without it each pull request would start from nothing. | |
| 10 | 14 | name: CI | |
| 11 | 15 | ||
| 12 | 16 | on: | |
| 13 | 17 | pull_request: | |
| 14 | 18 | branches: [main] | |
| 19 | + | push: | |
| 20 | + | branches: [main] | |
| 15 | 21 | workflow_dispatch: | |
| 16 | 22 | ||
| 17 | 23 | # Its token only reads: it checks the code out and nothing more. | |
| ⋯ | |||
| 47 | 53 | !target/debug/incremental | |
| 48 | 54 | key: cargo-test-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'services/runner/base.json') }} | |
| 49 | 55 | restore-keys: cargo-test-${{ runner.os }}- | |
| 56 | + | # The workspace's library crates, which Cargo compiles again on every | |
| 57 | + | # checkout, come back from the repository's Actions cache when their | |
| 58 | + | # inputs did not change (scripts/sccache.sh). Test harnesses and | |
| 59 | + | # Workers' own crates are linked, and still compiled. | |
| 60 | + | - name: sccache | |
| 61 | + | run: bash scripts/sccache.sh install | |
| 50 | 62 | - name: Tests | |
| 51 | 63 | run: cargo test --workspace --locked --quiet | |
| 64 | + | - name: sccache's hits and misses | |
| 65 | + | if: ${{ always() }} | |
| 66 | + | run: bash scripts/sccache.sh stats | |
| 52 | 67 | ||
| 53 | 68 | typescript: | |
| 54 | 69 | name: TypeScript | |
| 70 | + | if: ${{ github.event_name != 'push' }} | |
| 55 | 71 | runs-on: ubuntu-latest | |
| 56 | 72 | timeout-minutes: 30 | |
| 57 | 73 | steps: | |
| ⋯ | |||
| 71 | 87 | ||
| 72 | 88 | build: | |
| 73 | 89 | name: Build | |
| 90 | + | if: ${{ github.event_name != 'push' }} | |
| 74 | 91 | runs-on: ubuntu-latest | |
| 75 | 92 | timeout-minutes: 30 | |
| 76 | 93 | steps: | |
| 4 | 4 | # | |
| 5 | 5 | # check the deploy manifest is consistent, and the tool's tests pass | |
| 6 | 6 | # plan what changed since each Worker's live commit, and pending migrations | |
| 7 | − | # migrate pending D1 migrations, before any code | |
| 7 | + | # (beside check, not after it: neither waits for the other) | |
| 8 | + | # migrate pending D1 migrations, before any code, once check and plan pass | |
| 8 | 9 | # core, edge, front the units of each stage, in jobs that share a build; | |
| 9 | 10 | # a stage starts only when the one before it succeeded | |
| 10 | 11 | # smoke sign-in, sign-up and the waitlist still work on g1t.sh | |
| ⋯ | |||
| 76 | 77 | - name: The deploy tool's tests | |
| 77 | 78 | run: npm run test:deploy | |
| 78 | 79 | ||
| 80 | + | # Runs beside check: nothing deploys until both have succeeded. | |
| 79 | 81 | plan: | |
| 80 | 82 | name: Plan | |
| 81 | − | needs: check | |
| 82 | 83 | runs-on: ubuntu-latest | |
| 83 | 84 | # Production's secrets, without a deployment: planning deploys nothing. | |
| 84 | 85 | environment: | |
| ⋯ | |||
| 99 | 100 | with: | |
| 100 | 101 | # Each Worker's live commit is compared with this one. | |
| 101 | 102 | fetch-depth: 0 | |
| 102 | − | - name: Install Wrangler | |
| 103 | − | run: npm ci --workspaces=false --no-audit --no-fund | |
| 103 | + | # No npm ci: with CLOUDFLARE_API_TOKEN the plan reads Cloudflare's API | |
| 104 | + | # itself (scripts/deploy/cloudflare.mjs), and needs no Wrangler. | |
| 104 | 105 | - name: Plan | |
| 105 | 106 | id: plan | |
| 106 | 107 | env: | |
| ⋯ | |||
| 115 | 116 | ||
| 116 | 117 | migrate: | |
| 117 | 118 | name: Migrations | |
| 118 | − | needs: plan | |
| 119 | + | needs: [check, plan] | |
| 119 | 120 | if: ${{ needs.plan.outputs.migrate == 'true' && inputs.dry_run != true }} | |
| 120 | 121 | runs-on: ubuntu-latest | |
| 121 | 122 | environment: | |
| ⋯ | |||
| 133 | 134 | ||
| 134 | 135 | core: | |
| 135 | 136 | name: core (${{ matrix.group }}) | |
| 136 | − | needs: [plan, migrate] | |
| 137 | + | needs: [check, plan, migrate] | |
| 137 | 138 | # Runs when nothing before it failed: a migrate job skipped for having | |
| 138 | 139 | # nothing to apply is not a failure. | |
| 139 | 140 | if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_core == 'true' && inputs.dry_run != true }} | |
| ⋯ | |||
| 179 | 180 | key: cargo-crates-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} | |
| 180 | 181 | restore-keys: cargo-crates-${{ runner.os }}- | |
| 181 | 182 | # The compiled dependencies of this job's units, for wasm32 and the | |
| 182 | − | # build scripts and proc macros they run. The workspace's own crates | |
| 183 | − | # are compiled again whatever is cached (a checkout's sources are | |
| 184 | − | # newer), so an entry is saved only when the dependencies change: a | |
| 185 | − | # new Cargo.lock, or a new base image (base.json names its Rust). | |
| 186 | − | # Otherwise the nearest earlier entry, of any group, is a start. | |
| 183 | + | # build scripts and proc macros they run. Cargo calls rustc again for | |
| 184 | + | # the workspace's own crates whatever is cached (a checkout's sources | |
| 185 | + | # are newer); sccache, below, answers those calls. So an entry is | |
| 186 | + | # saved only when the dependencies change: a new Cargo.lock, or a new | |
| 187 | + | # base image (base.json names its Rust). Otherwise the nearest earlier | |
| 188 | + | # entry, of any group, is a start. | |
| 187 | 189 | - name: Cache the Cargo target | |
| 188 | 190 | if: ${{ matrix.rust }} | |
| 189 | 191 | uses: actions/cache@v4 | |
| ⋯ | |||
| 214 | 216 | !target/**/incremental | |
| 215 | 217 | key: runner-musl-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'services/runner/base.json') }} | |
| 216 | 218 | restore-keys: runner-musl-${{ runner.os }}- | |
| 219 | + | # Every rustc call that makes a library (the workspace's crates, | |
| 220 | + | # which Cargo compiles again on every checkout, and any dependency | |
| 221 | + | # not restored above) is looked up by its inputs in the repository's | |
| 222 | + | # Actions cache: unchanged crates come back from it instead of being | |
| 223 | + | # compiled. A Worker's own crate (a cdylib) and the runner's binary | |
| 224 | + | # are still compiled. worker-build runs Cargo, so its wasm32 builds | |
| 225 | + | # go through it too; wasm-bindgen and wasm-opt are not rustc. | |
| 226 | + | # Pinned by version and sha256 in scripts/sccache.sh; without the | |
| 227 | + | # cache, the job builds as before. | |
| 228 | + | - name: sccache | |
| 229 | + | if: ${{ matrix.rust || matrix.image }} | |
| 230 | + | run: bash scripts/sccache.sh install | |
| 217 | 231 | - name: Install | |
| 218 | 232 | run: node scripts/deploy.mjs install --only "${{ matrix.units }}" | |
| 219 | 233 | - name: Deploy ${{ matrix.units }} | |
| ⋯ | |||
| 223 | 237 | # are not drafted as incidents. Optional: without it, nothing is sent. | |
| 224 | 238 | STATUS_DEPLOY_TOKEN: ${{ secrets.STATUS_DEPLOY_TOKEN }} | |
| 225 | 239 | run: node scripts/deploy.mjs deploy --only "${{ matrix.units }}" --force --no-migrations --concurrency 2 | |
| 240 | + | # Hits and misses, on the log and in the run's summary. | |
| 241 | + | - name: sccache's hits and misses | |
| 242 | + | if: ${{ always() && (matrix.rust || matrix.image) }} | |
| 243 | + | run: bash scripts/sccache.sh stats | |
| 226 | 244 | ||
| 227 | 245 | edge: | |
| 228 | 246 | name: edge (${{ matrix.group }}) | |
| 229 | − | needs: [plan, migrate, core] | |
| 247 | + | needs: [check, plan, migrate, core] | |
| 230 | 248 | if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_edge == 'true' && inputs.dry_run != true }} | |
| 231 | 249 | runs-on: ${{ (matrix.rust || matrix.image) && 'g1t-4core' || 'ubuntu-latest' }} | |
| 232 | 250 | environment: | |
| ⋯ | |||
| 241 | 259 | ||
| 242 | 260 | front: | |
| 243 | 261 | name: front (${{ matrix.group }}) | |
| 244 | − | needs: [plan, migrate, core, edge] | |
| 262 | + | needs: [check, plan, migrate, core, edge] | |
| 245 | 263 | if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_front == 'true' && inputs.dry_run != true }} | |
| 246 | 264 | runs-on: ${{ (matrix.rust || matrix.image) && 'g1t-4core' || 'ubuntu-latest' }} | |
| 247 | 265 | environment: | |
| ⋯ | |||
| 260 | 278 | # real waitlist is never touched. scripts/ops/smoke.mjs. | |
| 261 | 279 | smoke: | |
| 262 | 280 | name: Smoke | |
| 263 | − | needs: [plan, core, edge, front] | |
| 281 | + | needs: [check, plan, core, edge, front] | |
| 264 | 282 | if: ${{ !failure() && !cancelled() && inputs.dry_run != true && (needs.plan.outputs.has_core == 'true' || needs.plan.outputs.has_edge == 'true' || needs.plan.outputs.has_front == 'true') }} | |
| 265 | 283 | runs-on: ubuntu-latest | |
| 266 | 284 | timeout-minutes: 5 | |
| 1105 | 1105 | "g1t-actions", | |
| 1106 | 1106 | "g1t-scan", | |
| 1107 | 1107 | "hex", | |
| 1108 | + | "libc", | |
| 1108 | 1109 | "serde", | |
| 1109 | 1110 | "serde_json", | |
| 1110 | 1111 | "serde_yaml", |
| 1 | 1 | # g1t | |
| 2 | 2 | ||
| 3 | − | The open-source git platform where people and agents ship software | |
| 4 | − | together, from the first issue to production on the edge. It runs on | |
| 5 | − | Cloudflare Workers and Artifacts. | |
| 3 | + | One open-source workspace where a team and its agents talk, work, write | |
| 4 | + | things down and ship. No separate chat app, wiki or forge to stitch | |
| 5 | + | together. It runs on Cloudflare Workers and Artifacts. | |
| 6 | 6 | ||
| 7 | − | - **Collaborate.** Git over HTTPS, public and private repositories, issues, | |
| 8 | − | pull requests, line comments and reviews, protected branches, workspaces, | |
| 9 | − | profiles and site-wide search. | |
| 10 | − | - **Agents as teammates.** Assign an issue to g1t or mention `@g1t`, or | |
| 11 | − | connect Claude Code, Codex, OpenCode or Cursor over MCP. Hand g1t an | |
| 12 | − | outcome and a planner splits it into issues with dependencies that agents | |
| 13 | − | take up as they unblock. Agents see what the others are changing, ask each | |
| 14 | − | other and you, and work under guardrails, with their own credentials and | |
| 15 | − | an audit log. | |
| 16 | − | - **Ship safely.** Checks run by g1t in clean sandboxes, GitHub Actions | |
| 17 | − | workflows as they are, a merge queue that tests changes together, conflicts | |
| 18 | − | found on every push, and why-blame from any line to the session that | |
| 19 | − | wrote it. | |
| 20 | − | - **Run it.** A preview of every pull request and production on merge, on | |
| 21 | − | `g1t.page`, with custom domains. Apps nobody visits cost nothing. | |
| 22 | − | - **Secure and healthy.** Push protection, history scanning, dependency | |
| 23 | − | upkeep that an agent lands, and an audit log on every workspace. | |
| 7 | + | - **Chat.** Channels, direct messages and threads, live. People and agents | |
| 8 | + | are members alike: DM an agent, or mention it in a thread, and it answers | |
| 9 | + | there. Chat is included on every plan, with no seats and no history | |
| 10 | + | cutoff. | |
| 11 | + | - **Agents.** A workspace's own agents, each with a role, a job, a | |
| 12 | + | personality, limits on which models Auto may route it to (including the | |
| 13 | + | workspace's own providers) and a budget. Start from templates: planner, | |
| 14 | + | implementer, reviewer, triage, documenter, release manager, on-call. | |
| 15 | + | - **Docs** (coming soon). Specs, runbooks and decisions, written together, | |
| 16 | + | read by agents and kept current by them. | |
| 17 | + | - **Code.** Git over HTTPS, issues, pull requests and reviews. Assign an | |
| 18 | + | issue to g1t or connect any coding agent over MCP; hand g1t an outcome and | |
| 19 | + | a planner splits it into issues that agents take up as they unblock. | |
| 20 | + | Checks run by g1t in clean sandboxes, workflows from `.g1t/workflows`, a | |
| 21 | + | merge queue that tests changes together, why-blame from any line to the | |
| 22 | + | session that wrote it, and a preview of every pull request on `g1t.page`. | |
| 23 | + | - **Rails.** Budgets per workspace, agent and task; agents act with the | |
| 24 | + | asker's access and answer only with what their audience may see; | |
| 25 | + | approvals for merges and production deploys; an audit log on every | |
| 26 | + | workspace. | |
| 24 | 27 | - **Open and fair.** MIT licensed and self-hostable (an early Docker Compose | |
| 25 | − | version of the core forge, in `deploy/self-host`). The forge is free; compute is what it costs | |
| 26 | − | plus 20%, never per seat. | |
| 28 | + | version of the core forge, in `deploy/self-host`). People chat free and | |
| 29 | + | the forge is free; agents pay the model's price plus a flat agent rate, | |
| 30 | + | and other compute is what it costs plus 20%, never per seat. | |
| 31 | + | ||
| 32 | + | The plan for the workspace is [docs/WORKSPACE.md](docs/WORKSPACE.md). | |
| 27 | 33 | ||
| 28 | 34 | g1t is made by Flagon, Inc. It is also an entry in Cloudflare's **Build the | |
| 29 | 35 | Next-Gen Git Platform** competition, which asks what a git platform looks | |
| ⋯ | |||
| 95 | 101 | and Message, and memory at two levels (project and workspace) that | |
| 96 | 102 | agents write and read. | |
| 97 | 103 | - Projects with deployments on g1t.page: a preview for every pull request, | |
| 98 | − | production on merge, dependencies between projects, custom domains. | |
| 104 | + | production on merge, custom domains. | |
| 99 | 105 | - GitHub Actions workflows from `.g1t/workflows`, secrets and variables, | |
| 100 | 106 | webhooks and integrations (Sentry, Datadog, Jira, Linear). | |
| 101 | 107 | - Profiles, workspaces with display names, icons and renameable slugs. | |
| ⋯ | |||
| 142 | 148 | | `services/integrations` | Model providers, alerts, trackers and the GitHub App. | Rust | | |
| 143 | 149 | | `services/webhooks` | Webhook deliveries. | Rust | | |
| 144 | 150 | | `services/runner` | Starts sandboxes: for g1t agents, workflow jobs and the merge queue. | TypeScript | | |
| 145 | − | | `services/projects` | Projects and the dependencies between them. | TypeScript | | |
| 151 | + | | `services/projects` | Projects: what a workspace builds and runs, and where its code lives. | TypeScript | | |
| 146 | 152 | | `services/deployments` | Builds, previews and production on `g1t.page`. | TypeScript | | |
| 147 | 153 | | `services/pages` | Serves every app deployed on `g1t.page`, and custom domains. | TypeScript | | |
| 148 | 154 | | `services/models` | The model proxy at `models.g1t.sh`. | TypeScript | | |
| 167 | 167 | None => Decision::allow("anonymous"), | |
| 168 | 168 | }); | |
| 169 | 169 | record(op, services, viewer, input, &decision, Some(&outcome)).await; | |
| 170 | − | Ok(outcome) | |
| 170 | + | // Every person in the answer with the case they chose (people.rs). | |
| 171 | + | Ok(match outcome { | |
| 172 | + | Outcome::Ok(value) => Outcome::Ok(crate::people::name_people(services, value).await), | |
| 173 | + | failed => failed, | |
| 174 | + | }) | |
| 171 | 175 | } | |
| 172 | 176 | ||
| 173 | 177 | /// Appends the entry, if this is something the log keeps. A failure to |
| 22 | 22 | mod oauth; | |
| 23 | 23 | mod oidc; | |
| 24 | 24 | mod packages; | |
| 25 | + | mod people; | |
| 25 | 26 | mod openapi; | |
| 26 | 27 | mod pins; | |
| 27 | 28 | mod projects; | |
| ⋯ | |||
| 277 | 278 | DeviceClaim::Approved { token, user } => json!({ | |
| 278 | 279 | "status": "approved", | |
| 279 | 280 | "token": token, | |
| 281 | + | "display_username": user.display_username.clone().filter(|display| display.eq_ignore_ascii_case(&user.username)).unwrap_or_else(|| user.username.clone()), | |
| 280 | 282 | "username": user.username, | |
| 281 | 283 | "verified": user.verified, | |
| 282 | 284 | }), | |
| 66 | 66 | ), | |
| 67 | 67 | ( | |
| 68 | 68 | "Personal access tokens", | |
| 69 | − | "A workspace's rules for its members' personal access tokens: whether classic and fine-grained tokens reach it, whether fine-grained tokens wait for an owner's approval, and how long a token may last; the tokens that reach it, approving or denying the ones that wait, and revoking one there. Owners only, as people.", | |
| 69 | + | "A workspace's rules for its members' personal access tokens: whether tokens made for all of a member's workspaces reach it, whether tokens may be made for it alone and wait for an owner's approval, and how long a token may last; the tokens that reach it, approving or denying the ones that wait, and revoking one there. Owners only, as people.", | |
| 70 | 70 | &[ | |
| 71 | 71 | Op::Tokens(TokenOp::GetTokenPolicy), | |
| 72 | 72 | Op::Tokens(TokenOp::SetTokenPolicy), | |
| ⋯ | |||
| 83 | 83 | ), | |
| 84 | 84 | ( | |
| 85 | 85 | "Invites", | |
| 86 | − | "While g1t is invite-only, every new account needs an invite. Your invites, and inviting people into a workspace by email.", | |
| 86 | + | "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.", | |
| 87 | 87 | &[ | |
| 88 | 88 | Op::ListInvites, | |
| 89 | 89 | Op::CreateInvite, | |
| ⋯ | |||
| 91 | 91 | Op::ListWorkspaceInvites, | |
| 92 | 92 | Op::InviteMember, | |
| 93 | 93 | Op::RevokeWorkspaceInvite, | |
| 94 | + | Op::ListInvitations, | |
| 95 | + | Op::AcceptInvitation, | |
| 96 | + | Op::DeclineInvitation, | |
| 94 | 97 | ], | |
| 95 | 98 | ), | |
| 96 | 99 | ( | |
| ⋯ | |||
| 566 | 569 | Op::RevokeInvite => "Revoke an invite", | |
| 567 | 570 | Op::ListWorkspaceInvites => "List a workspace's invites", | |
| 568 | 571 | Op::InviteMember => "Invite someone to a workspace", | |
| 572 | + | Op::ListInvitations => "List your workspace invitations", | |
| 573 | + | Op::AcceptInvitation => "Accept a workspace invitation", | |
| 574 | + | Op::DeclineInvitation => "Decline a workspace invitation", | |
| 569 | 575 | Op::RevokeWorkspaceInvite => "Revoke a workspace's invite", | |
| 570 | 576 | Op::TransferRepo => "Transfer a repository", | |
| 571 | 577 | Op::RenameRepo => "Rename a repository", | |
| ⋯ | |||
| 1060 | 1066 | "properties": { | |
| 1061 | 1067 | "status": { "type": "string", "enum": ["pending", "approved", "denied", "expired"] }, | |
| 1062 | 1068 | "token": { "type": "string", "description": "Present when approved." }, | |
| 1063 | − | "username": { "type": "string" }, | |
| 1069 | + | "username": { "type": "string", "description": "Lowercased: what the account is found and linked by." }, | |
| 1070 | + | "display_username": { "type": "string", "description": "The username as its owner wrote it; the same as username when they chose no case." }, | |
| 1064 | 1071 | "verified": { | |
| 1065 | 1072 | "type": "boolean", | |
| 1066 | 1073 | "description": "Whether the account's email is confirmed.", | |
| 124 | 124 | ListWorkspaceInvites, | |
| 125 | 125 | InviteMember, | |
| 126 | 126 | RevokeWorkspaceInvite, | |
| 127 | + | ListInvitations, | |
| 128 | + | AcceptInvitation, | |
| 129 | + | DeclineInvitation, | |
| 127 | 130 | ListRepos, | |
| 128 | 131 | GetRepo, | |
| 129 | 132 | CreateRepo, | |
| ⋯ | |||
| 687 | 690 | } | |
| 688 | 691 | ||
| 689 | 692 | impl Op { | |
| 690 | − | pub const ALL: [Op; 325] = [ | |
| 693 | + | pub const ALL: [Op; 328] = [ | |
| 691 | 694 | Op::Whoami, | |
| 692 | 695 | Op::GetWorkspace, | |
| 693 | 696 | Op::CreateWorkspace, | |
| ⋯ | |||
| 709 | 712 | Op::ListWorkspaceInvites, | |
| 710 | 713 | Op::InviteMember, | |
| 711 | 714 | Op::RevokeWorkspaceInvite, | |
| 715 | + | Op::ListInvitations, | |
| 716 | + | Op::AcceptInvitation, | |
| 717 | + | Op::DeclineInvitation, | |
| 712 | 718 | Op::ListRepos, | |
| 713 | 719 | Op::GetRepo, | |
| 714 | 720 | Op::CreateRepo, | |
| ⋯ | |||
| 1043 | 1049 | Op::ListWorkspaceInvites => "list_workspace_invites", | |
| 1044 | 1050 | Op::InviteMember => "invite_member", | |
| 1045 | 1051 | Op::RevokeWorkspaceInvite => "revoke_workspace_invite", | |
| 1052 | + | Op::ListInvitations => "list_invitations", | |
| 1053 | + | Op::AcceptInvitation => "accept_invitation", | |
| 1054 | + | Op::DeclineInvitation => "decline_invitation", | |
| 1046 | 1055 | Op::ListRepos => "list_repos", | |
| 1047 | 1056 | Op::GetRepo => "get_repo", | |
| 1048 | 1057 | Op::CreateRepo => "create_repo", | |
| ⋯ | |||
| 1240 | 1249 | "Add an email address to your account. g1t emails it a link to confirm it; until then it cannot be primary and does not sign you in. Adding an address you added before and have not confirmed sends the link again. An address another account has confirmed cannot be added. An account has at most 10. Needs your account `password`; your confirmed addresses are told. People only." | |
| 1241 | 1250 | } | |
| 1242 | 1251 | Op::ConfirmEmail => { | |
| 1243 | − | "Confirm an email address with the six-digit `code` from the confirmation email g1t sent it. The same email has a link that does the same; either one works, once, for 60 minutes, and asking for a new email ends both. A new account must confirm its address before it can do anything else: until then this, `GET /user` and `GET /user/emails` are the only calls its token can make, and everything else, MCP included, is refused with `403`. Confirming a new account's address also joins the workspace its invite named, when the invite still applies: the answer's `joined` names it, or `invite_lapsed` says why not. Ten wrong codes in an hour pause checking for the account. People only." | |
| 1252 | + | "Confirm an email address with the six-digit `code` from the confirmation email g1t sent it. The same email has a link that does the same; either one works, once, for 60 minutes, and asking for a new email ends both. A new account must confirm its address before it can do anything else: until then this, `GET /user` and `GET /user/emails` are the only calls its token can make, and everything else, MCP included, is refused with `403`. Confirming a new account's address also invites it to the workspace its invite named, when the invite still applies: the answer's `invited_to` names it, and the invitation waits for you to accept or decline it (accept_invitation), or `invite_lapsed` says why not. Ten wrong codes in an hour pause checking for the account. People only." | |
| 1244 | 1253 | } | |
| 1245 | 1254 | Op::RemoveEmail => { | |
| 1246 | 1255 | "Remove an email address from your account. Never your primary address (make another primary first) and never your last confirmed one. Needs your account `password`; every confirmed address, the removed one included, is told. People only." | |
| ⋯ | |||
| 1249 | 1258 | "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." | |
| 1250 | 1259 | } | |
| 1251 | 1260 | Op::ListInvites => { | |
| 1252 | − | "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, redeemed, expired or revoked." | |
| 1261 | + | "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`." | |
| 1253 | 1262 | } | |
| 1254 | 1263 | Op::CreateInvite => { | |
| 1255 | − | "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. It uses one of your invites, or with `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." | |
| 1264 | + | "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." | |
| 1256 | 1265 | } | |
| 1257 | 1266 | Op::RevokeInvite => { | |
| 1258 | 1267 | "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." | |
| ⋯ | |||
| 1261 | 1270 | "The invites made for a workspace, newest first, with each pending one's `code`. Owners only." | |
| 1262 | 1271 | } | |
| 1263 | 1272 | Op::InviteMember => { | |
| 1264 | − | "Invite an email address into a workspace. 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, accepting makes the account and joins the workspace in one step, and uses one of the workspace's granted invites, or else one of yours. With one, it costs nothing, and they join when they accept. To add someone by username at once, use the workspace's People page. 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." | |
| 1273 | + | "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." | |
| 1265 | 1274 | } | |
| 1266 | 1275 | Op::RevokeWorkspaceInvite => "Revoke a workspace's pending invite. Owners only.", | |
| 1276 | + | Op::ListInvitations => { | |
| 1277 | + | "The invitations to workspaces waiting for your answer, newest first: each one's `id`, the `workspace` (`slug`, `name`, `avatar`), the `role` accepting gives (`member` or `owner`), who sent it (`invited_by`, null when g1t staff did), and when it was made and when it expires. Expired, revoked and answered ones are left out. Accept or decline each by its `id`. People only; an agent's or a workspace's token gets an empty list." | |
| 1278 | + | } | |
| 1279 | + | Op::AcceptInvitation => { | |
| 1280 | + | "Accept an invitation to a workspace sent to you. You join it at once with the role it names. Returns the workspace's slug in `workspace`. Refused with `404` when you have no open invitation with that id (it may have been answered, revoked or expired), with `403` until you confirm your email address or when your account does not meet what the workspace asks of its members, such as two-factor authentication, and with `payment_required` (402) while the workspace is free: it can add no one until it starts the g1t plan, and the invitation stays open until then. People only." | |
| 1281 | + | } | |
| 1282 | + | Op::DeclineInvitation => { | |
| 1283 | + | "Decline an invitation to a workspace sent to you. Whoever sent it is told in their inbox, and the workspace's owners can invite you again. People only." | |
| 1284 | + | } | |
| 1267 | 1285 | Op::DeleteWorkspace => { | |
| 1268 | 1286 | "Delete a workspace and everything in it. Owners only, signed in as a person, and confirm must be the workspace's slug. Billing must be able to settle it: no unpaid invoice, no prepaid credit left, and no usage this month still being metered; what it owes is charged to its card at once and its plan ends. Its repositories, projects and apps go with it at once, nobody can reach it, and its access tokens stop working. It is kept for 30 days, when g1t's support can restore it as it was; then it is purged, with its webhooks, integrations and workspace secrets. Its statements, invoices and audit log are kept. The slug is never given to another workspace; the person whose username it is may create it again once it is purged. Some workspaces, such as Flagon's, can never be deleted." | |
| 1269 | 1287 | } | |
| ⋯ | |||
| 1274 | 1292 | "Change a workspace's display name and description, what every member gets on each of its repositories (base_permission: none, read, write or admin), who may create its teams (team_creation: members or owners), its member privileges, and whether it requires two-factor authentication. The member privileges are: members_can_create_public_repositories and members_can_create_private_repositories (who may create each kind; owners always can), members_can_change_repo_visibility (members with the Admin role on a repository may make it public or private), members_can_delete_repositories (they may delete or transfer it) and members_can_invite_outside_collaborators (they may give a role to someone outside the workspace). two_factor_requirement_enabled true holds every member and outside collaborator without two-factor authentication out of the workspace until they turn it on; you need it on yourself first. Only the fields given are changed; give at least one. An empty name falls back to the slug, which this never changes (that is a rename, on Settings); an empty description clears it. Owners only, signed in as a person. Returns the workspace as it is now." | |
| 1275 | 1293 | } | |
| 1276 | 1294 | Op::ListMembers => { | |
| 1277 | − | "A workspace's members, owners first, then by username. Each has their `username`, `name`, `avatar`, `role` (`owner` or `member`), the roles they hold besides it (`org_roles`: `billing_manager`, `security_manager`), and, when an owner asks, whether they have two-factor authentication on (`two_factor`; null for anyone else). Members only." | |
| 1295 | + | "A workspace's members, owners first, then by username. Each has their `username`, `display_username` (the username as they wrote it), `name`, `avatar`, `role` (`owner` or `member`), the roles they hold besides it (`org_roles`: `billing_manager`, `security_manager`), and, when an owner asks, whether they have two-factor authentication on (`two_factor`; null for anyone else). Members only." | |
| 1278 | 1296 | } | |
| 1279 | 1297 | Op::UpdateMember => { | |
| 1280 | 1298 | "Change a member's role in a workspace: `role` (`owner` or `member`) and the roles they hold besides it (`org_roles`, a list of `billing_manager` and `security_manager`, which replaces the one they have). Only the fields given are changed. A billing manager manages the workspace's billing as an owner does, and gets nothing on repositories from it; a security manager reads every repository and sees and manages its security alerts and security settings. Refused with `409` when it would leave the workspace without an owner. Owners only, signed in as a person. Returns the member." | |
| ⋯ | |||
| 1366 | 1384 | "Issues on a repository, newest first. An issue is something that should change: a bug, a feature, a question. Pull requests are made against it. Filter by state, by a label's name, or by a milestone's number." | |
| 1367 | 1385 | } | |
| 1368 | 1386 | Op::GetIssue => { | |
| 1369 | − | "An issue: its description (which may say what done means, under \"Definition of done\"), labels, its comments, and every pull request made against it with its status. If the issue is closed, resolved_by is the number of the pull request that was merged for it. Read this before opening a pull request, to see what others have already tried." | |
| 1387 | + | "An issue: its description (which may say what done means, under \"Definition of done\"), labels, its comments, and every pull request made against it with its status. If the issue is closed, resolved_by is the number of the pull request that was merged for it. Read this before opening a pull request, to see what others have already tried. A comment one of the workspace's agents wrote as itself has `agent` (its `id`, `handle`, `display_name` and `avatar_seed`) and `acting_for` (the person it acted for, whose access capped it); its `author` is the agent, of kind `agent`." | |
| 1370 | 1388 | } | |
| 1371 | 1389 | Op::CreateIssue => { | |
| 1372 | 1390 | "Open an issue on a repository. Say what done means in the body if it helps, for instance under a \"Definition of done\" heading; what must pass before a pull request for it merges is the default branch's required checks, the same for every pull request. labels are the repository's labels by name; a name it does not have yet is created when you have the Triage role or higher, and refused otherwise. milestone, a milestone's number, needs the Triage role." | |
| ⋯ | |||
| 1449 | 1467 | "Pull requests on a repository, newest first. State open covers drafts and those ready for review; closed covers merged and closed. Filter by a label's name, a milestone's number, or base, the branch they merge into." | |
| 1450 | 1468 | } | |
| 1451 | 1469 | Op::GetPullRequest => { | |
| 1452 | − | "A pull request's status, base (the branch it merges into), head commit, labels, milestone, comments and reviews, the issue it is for, its checks (statuses: what each workflow run reported on its head, with a link to the run; get_workflow_run and get_job_logs say why one failed), required_checks (each check the rules of the branch it merges into require, as success, failure, pending or expected when nothing has reported it yet), rules (each rule of that branch it does not meet yet, with the ruleset it comes from, what is wrong and how to meet it, in `unmet`; those you may bypass in `bypassable`; those of rulesets in evaluate that would refuse it in `evaluate`; and whether merging joins the merge queue), whether it is behind the branch it would merge into, and overlaps: other pull requests in progress that change the same files. An overlap with a pull request for a different issue means the two will conflict; say so, or keep clear of those files. `pull.reviewers` lists the people asked to review it and `pull.team_reviewers` the teams, as `workspace/team`. `code_owners` is there when the branch it merges into has a CODEOWNERS file: its `path`, whether code owners' approval is `required`, `reviews` (one per section and rule that owns a changed file, with its `section`, `line`, `pattern`, `owners`, `files`, whether it is `optional`, the approvals `required`, who it was `approved_by` and `changes_requested_by`, and whether it is `satisfied`), what is still `missing`, and how many `errors` the file has (get_codeowners_errors lists them)." | |
| 1470 | + | "A pull request's status, base (the branch it merges into), head commit, labels, milestone, comments and reviews, the issue it is for, its checks (statuses: what each workflow run reported on its head, with a link to the run; get_workflow_run and get_job_logs say why one failed), required_checks (each check the rules of the branch it merges into require, as success, failure, pending or expected when nothing has reported it yet), rules (each rule of that branch it does not meet yet, with the ruleset it comes from, what is wrong and how to meet it, in `unmet`; those you may bypass in `bypassable`; those of rulesets in evaluate that would refuse it in `evaluate`; and whether merging joins the merge queue), whether it is behind the branch it would merge into, and overlaps: other pull requests in progress that change the same files. An overlap with a pull request for a different issue means the two will conflict; say so, or keep clear of those files. `pull.reviewers` lists the people asked to review it and `pull.team_reviewers` the teams, as `workspace/team`. `code_owners` is there when the branch it merges into has a CODEOWNERS file: its `path`, whether code owners' approval is `required`, `reviews` (one per section and rule that owns a changed file, with its `section`, `line`, `pattern`, `owners`, `files`, whether it is `optional`, the approvals `required`, who it was `approved_by` and `changes_requested_by`, and whether it is `satisfied`), what is still `missing`, and how many `errors` the file has (get_codeowners_errors lists them). A comment one of the workspace's agents wrote as itself has `agent` (its `id`, `handle`, `display_name` and `avatar_seed`) and `acting_for` (the person it acted for, whose access capped it); its `author` is the agent, of kind `agent`. An agent's review also has `advisory: true`: its `verdict` (none, for a review that only comments) is shown but never counts toward required approvals or code owners, and never blocks a merge." | |
| 1453 | 1471 | } | |
| 1454 | 1472 | Op::CreatePullRequest => { | |
| 1455 | 1473 | "Start a change. Opens a draft pull request with its own fork of the repository and returns the fork's git remote. Clone it, commit your work there, push, record your session as you go, then call mark_pull_request_ready. Give the issue it is for whenever there is one. If the change is already on a branch pushed to the repository, give that branch instead: no fork is made and the pull request is ready for review at once. It merges into the default branch unless base names another existing branch; leave base out unless you were asked for another." | |
| ⋯ | |||
| 1848 | 1866 | }, | |
| 1849 | 1867 | "workspace": { | |
| 1850 | 1868 | "type": "string", | |
| 1869 | + | "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.", | |
| 1870 | + | }, | |
| 1871 | + | "charge_workspace": { | |
| 1872 | + | "type": "string", | |
| 1851 | 1873 | "description": "Use one of the invites g1t granted this workspace instead of yours, by slug. Owners only.", | |
| 1852 | 1874 | }, | |
| 1853 | 1875 | }), | |
| ⋯ | |||
| 1861 | 1883 | Op::InviteMember => object( | |
| 1862 | 1884 | json!({ | |
| 1863 | 1885 | "workspace": workspace_schema(), | |
| 1864 | − | "email": { "type": "string", "description": "The address to invite." }, | |
| 1886 | + | "username": { | |
| 1887 | + | "type": "string", | |
| 1888 | + | "description": "A g1t username to invite. Give this or email.", | |
| 1889 | + | }, | |
| 1890 | + | "email": { "type": "string", "description": "An address to invite. Give this or username." }, | |
| 1891 | + | "role": { | |
| 1892 | + | "type": "string", | |
| 1893 | + | "enum": ["member", "owner"], | |
| 1894 | + | "description": "The role they join with when they accept. member when left out.", | |
| 1895 | + | }, | |
| 1896 | + | }), | |
| 1897 | + | &["workspace"], | |
| 1898 | + | ), | |
| 1899 | + | Op::ListInvitations => object(json!({}), &[]), | |
| 1900 | + | Op::AcceptInvitation | Op::DeclineInvitation => object( | |
| 1901 | + | json!({ | |
| 1902 | + | "id": { "type": "string", "description": "The invitation's id, from list_invitations." }, | |
| 1865 | 1903 | }), | |
| 1866 | − | &["workspace", "email"], | |
| 1904 | + | &["id"], | |
| 1867 | 1905 | ), | |
| 1868 | 1906 | Op::RevokeWorkspaceInvite => object( | |
| 1869 | 1907 | json!({ | |
| ⋯ | |||
| 3344 | 3382 | | Op::ListWorkspaceInvites | |
| 3345 | 3383 | | Op::InviteMember | |
| 3346 | 3384 | | Op::RevokeWorkspaceInvite | |
| 3385 | + | | Op::ListInvitations | |
| 3386 | + | | Op::AcceptInvitation | |
| 3387 | + | | Op::DeclineInvitation | |
| 3347 | 3388 | | Op::ListDeletedRepos | |
| 3348 | 3389 | | Op::SearchContext | |
| 3349 | 3390 | | Op::GetEntity | |
| ⋯ | |||
| 3659 | 3700 | &json!({ | |
| 3660 | 3701 | "user": actor(), | |
| 3661 | 3702 | "email": optional_text(input, "email"), | |
| 3662 | − | "workspace": optional_text(input, "workspace"), | |
| 3703 | + | "workspace": optional_text(input, "charge_workspace"), | |
| 3704 | + | "join": optional_text(input, "workspace"), | |
| 3663 | 3705 | "surface": services.audit.surface, | |
| 3664 | 3706 | }), | |
| 3665 | 3707 | ) | |
| ⋯ | |||
| 3672 | 3714 | pass(identity, "workspace_invites", &json!({ "slug": workspace(), "viewer": viewer })).await | |
| 3673 | 3715 | } | |
| 3674 | 3716 | Op::InviteMember => { | |
| 3717 | + | let role = optional_text(input, "role"); | |
| 3718 | + | if role.as_deref().is_some_and(|role| role != "member" && role != "owner") { | |
| 3719 | + | return failed(FailureCode::Invalid, "role is member or owner."); | |
| 3720 | + | } | |
| 3675 | 3721 | pass( | |
| 3676 | 3722 | identity, | |
| 3677 | 3723 | "invite_member", | |
| 3678 | 3724 | &json!({ | |
| 3679 | 3725 | "actor": actor(), | |
| 3680 | 3726 | "slug": workspace(), | |
| 3681 | − | "email": text(input, "email"), | |
| 3727 | + | "email": optional_text(input, "email").unwrap_or_default(), | |
| 3728 | + | "username": optional_text(input, "username"), | |
| 3729 | + | "role": role, | |
| 3682 | 3730 | "surface": services.audit.surface, | |
| 3683 | 3731 | }), | |
| 3684 | 3732 | ) | |
| 3685 | 3733 | .await | |
| 3686 | 3734 | } | |
| 3735 | + | Op::ListInvitations => { | |
| 3736 | + | let waiting: Vec<g1t_contracts::identity::WorkspaceInvitation> = | |
| 3737 | + | g1t_kit::call(identity, "list_invitations", &json!({ "user": actor() })).await?; | |
| 3738 | + | ok(&waiting) | |
| 3739 | + | } | |
| 3740 | + | Op::AcceptInvitation => { | |
| 3741 | + | let joined: Outcome<String> = call( | |
| 3742 | + | identity, | |
| 3743 | + | "accept_invitation", | |
| 3744 | + | &json!({ "user": actor(), "id": text(input, "id"), "surface": services.audit.surface }), | |
| 3745 | + | ) | |
| 3746 | + | .await?; | |
| 3747 | + | match joined { | |
| 3748 | + | Outcome::Ok(slug) => ok(&json!({ "workspace": slug })), | |
| 3749 | + | Outcome::Fail(failure) => Ok(Outcome::Fail(failure)), | |
| 3750 | + | } | |
| 3751 | + | } | |
| 3752 | + | Op::DeclineInvitation => { | |
| 3753 | + | pass( | |
| 3754 | + | identity, | |
| 3755 | + | "decline_invitation", | |
| 3756 | + | &json!({ "user": actor(), "id": text(input, "id"), "surface": services.audit.surface }), | |
| 3757 | + | ) | |
| 3758 | + | .await | |
| 3759 | + | } | |
| 3687 | 3760 | Op::RevokeWorkspaceInvite => { | |
| 3688 | 3761 | pass( | |
| 3689 | 3762 | identity, | |
| ⋯ | |||
| 4637 | 4710 | .unwrap_or_else(|| if g1t_contracts::rules::is_agent(&user) { "agent".into() } else { user.username.clone() }), | |
| 4638 | 4711 | runtime: Runtime::External, | |
| 4639 | 4712 | base: optional_text(input, "base"), | |
| 4713 | + | draft: input.get("draft").and_then(|value| value.as_bool()).unwrap_or(false), | |
| 4640 | 4714 | }, | |
| 4641 | 4715 | ) | |
| 4642 | 4716 | .await?; | |
| 1 | + | //! Usernames as their owners wrote them. Every person in an answer, the | |
| 2 | + | //! REST API's and MCP's alike (an issue's `author`, a member, a | |
| 3 | + | //! collaborator, whoever `whoami` is), carries `display_username` beside | |
| 4 | + | //! `username`: `username` stays the lowercased key, stable for anything | |
| 5 | + | //! that matches on it, and `display_username` is the case its owner chose | |
| 6 | + | //! (`Ana`), the same as `username` when they chose none. | |
| 7 | + | //! | |
| 8 | + | //! The services keep the lowercased name; this fills the chosen case in on | |
| 9 | + | //! the way out, with one call to identity per answer. | |
| 10 | + | ||
| 11 | + | use std::collections::{BTreeSet, HashMap}; | |
| 12 | + | ||
| 13 | + | use g1t_contracts::identity::DisplayUsernamesArgs; | |
| 14 | + | use serde_json::Value; | |
| 15 | + | ||
| 16 | + | use crate::operations::Services; | |
| 17 | + | ||
| 18 | + | /// The most people one answer looks up; the rest show `username` as it is. | |
| 19 | + | const MOST: usize = 200; | |
| 20 | + | ||
| 21 | + | /// Whether `object` is a person (or another principal) named by `username`, | |
| 22 | + | /// rather than a set of credentials that happens to carry one. | |
| 23 | + | fn names_someone(object: &serde_json::Map<String, Value>) -> bool { | |
| 24 | + | matches!(object.get("username"), Some(Value::String(name)) if !name.is_empty()) | |
| 25 | + | && !object.contains_key("password") | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | /// The lowercased usernames in `value`, each once, at most [`MOST`]. | |
| 29 | + | pub fn usernames_in(value: &Value) -> Vec<String> { | |
| 30 | + | fn walk(value: &Value, out: &mut BTreeSet<String>) { | |
| 31 | + | match value { | |
| 32 | + | Value::Object(object) => { | |
| 33 | + | if names_someone(object) | |
| 34 | + | && let Some(Value::String(name)) = object.get("username") | |
| 35 | + | { | |
| 36 | + | out.insert(name.to_lowercase()); | |
| 37 | + | } | |
| 38 | + | object.values().for_each(|value| walk(value, out)); | |
| 39 | + | } | |
| 40 | + | Value::Array(items) => items.iter().for_each(|value| walk(value, out)), | |
| 41 | + | _ => {} | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | let mut out = BTreeSet::new(); | |
| 45 | + | walk(value, &mut out); | |
| 46 | + | out.into_iter().take(MOST).collect() | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | /// Gives every person in `value` a `display_username`: the case from | |
| 50 | + | /// `chosen` (by lowercased username), one they already carry when it is | |
| 51 | + | /// the same name, or else `username` itself. | |
| 52 | + | pub fn fill(value: &mut Value, chosen: &HashMap<String, String>) { | |
| 53 | + | match value { | |
| 54 | + | Value::Object(object) => { | |
| 55 | + | if names_someone(object) { | |
| 56 | + | let username = object["username"].as_str().unwrap_or_default().to_owned(); | |
| 57 | + | // A service's own `displayUsername` (a profile's) becomes this. | |
| 58 | + | let theirs = object.remove("displayUsername"); | |
| 59 | + | let carried = object | |
| 60 | + | .get("display_username") | |
| 61 | + | .or(theirs.as_ref()) | |
| 62 | + | .and_then(Value::as_str) | |
| 63 | + | .filter(|display| display.eq_ignore_ascii_case(&username)) | |
| 64 | + | .map(str::to_owned); | |
| 65 | + | let shown = chosen | |
| 66 | + | .get(&username.to_lowercase()) | |
| 67 | + | .cloned() | |
| 68 | + | .or(carried) | |
| 69 | + | .unwrap_or(username); | |
| 70 | + | object.insert("display_username".to_owned(), Value::String(shown)); | |
| 71 | + | } | |
| 72 | + | object.values_mut().for_each(|value| fill(value, chosen)); | |
| 73 | + | } | |
| 74 | + | Value::Array(items) => items.iter_mut().for_each(|value| fill(value, chosen)), | |
| 75 | + | _ => {} | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | /// `value` with each person's chosen case filled in. When identity does | |
| 80 | + | /// not answer, the people are still given `display_username`, as their | |
| 81 | + | /// `username`: an answer is never held up for it. | |
| 82 | + | pub async fn name_people(services: &Services, mut value: Value) -> Value { | |
| 83 | + | let usernames = usernames_in(&value); | |
| 84 | + | if usernames.is_empty() { | |
| 85 | + | return value; | |
| 86 | + | } | |
| 87 | + | let chosen: HashMap<String, String> = | |
| 88 | + | g1t_kit::call(&services.identity, "display_usernames", &DisplayUsernamesArgs { usernames }) | |
| 89 | + | .await | |
| 90 | + | .unwrap_or_default(); | |
| 91 | + | fill(&mut value, &chosen); | |
| 92 | + | value | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | #[cfg(test)] | |
| 96 | + | mod tests { | |
| 97 | + | use super::*; | |
| 98 | + | use serde_json::json; | |
| 99 | + | ||
| 100 | + | #[test] | |
| 101 | + | fn every_person_in_an_answer_is_found_once() { | |
| 102 | + | let answer = json!({ | |
| 103 | + | "issue": { "author": { "id": "usr_1", "username": "ana" }, "assignees": [{ "username": "bo" }, { "username": "ana" }] }, | |
| 104 | + | "git": { "username": "ana", "password": "your g1t access token" }, | |
| 105 | + | "count": 3, | |
| 106 | + | }); | |
| 107 | + | assert_eq!(usernames_in(&answer), vec!["ana".to_owned(), "bo".to_owned()]); | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | #[test] | |
| 111 | + | fn people_get_the_case_they_chose_or_their_username() { | |
| 112 | + | let mut answer = json!({ | |
| 113 | + | "author": { "id": "usr_1", "username": "ana" }, | |
| 114 | + | "members": [{ "username": "bo" }, { "username": "cy", "display_username": "Cy" }], | |
| 115 | + | "git": { "username": "ana", "password": "your g1t access token" }, | |
| 116 | + | }); | |
| 117 | + | let chosen = HashMap::from([("ana".to_owned(), "Ana".to_owned())]); | |
| 118 | + | fill(&mut answer, &chosen); | |
| 119 | + | assert_eq!(answer["author"]["username"], "ana"); | |
| 120 | + | assert_eq!(answer["author"]["display_username"], "Ana"); | |
| 121 | + | assert_eq!(answer["members"][0]["display_username"], "bo"); | |
| 122 | + | assert_eq!(answer["members"][1]["display_username"], "Cy"); | |
| 123 | + | assert!(answer["git"].get("display_username").is_none()); | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | #[test] | |
| 127 | + | fn a_carried_case_of_another_name_is_not_kept() { | |
| 128 | + | let mut answer = json!({ "username": "ana", "display_username": "Bob" }); | |
| 129 | + | fill(&mut answer, &HashMap::new()); | |
| 130 | + | assert_eq!(answer["display_username"], "ana"); | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | #[test] | |
| 134 | + | fn a_profiles_own_spelling_is_folded_in() { | |
| 135 | + | let mut answer = json!({ "username": "ana", "displayUsername": "Ana" }); | |
| 136 | + | fill(&mut answer, &HashMap::new()); | |
| 137 | + | assert_eq!(answer, json!({ "username": "ana", "display_username": "Ana" })); | |
| 138 | + | } | |
| 139 | + | } |
| 20 | 20 | "status": "approved", | |
| 21 | 21 | "token": "g1t_…", | |
| 22 | 22 | "username": "syntaqx", | |
| 23 | + | "display_username": "syntaqx", | |
| 23 | 24 | "verified": true | |
| 24 | 25 | } | |
| 25 | 26 | }, | |
| ⋯ | |||
| 27 | 28 | "response": { | |
| 28 | 29 | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 29 | 30 | "username": "syntaqx", | |
| 31 | + | "display_username": "syntaqx", | |
| 30 | 32 | "kind": "user", | |
| 31 | 33 | "verified": true, | |
| 32 | 34 | "workspaces": [ | |
| ⋯ | |||
| 134 | 136 | "response": [ | |
| 135 | 137 | { | |
| 136 | 138 | "username": "ada", | |
| 139 | + | "display_username": "Ada", | |
| 137 | 140 | "role": "owner", | |
| 138 | 141 | "org_roles": [], | |
| 139 | 142 | "two_factor": true, | |
| ⋯ | |||
| 142 | 145 | }, | |
| 143 | 146 | { | |
| 144 | 147 | "username": "grace", | |
| 148 | + | "display_username": "grace", | |
| 145 | 149 | "role": "member", | |
| 146 | 150 | "org_roles": [ | |
| 147 | 151 | "security_manager" | |
| ⋯ | |||
| 166 | 170 | }, | |
| 167 | 171 | "response": { | |
| 168 | 172 | "username": "grace", | |
| 173 | + | "display_username": "grace", | |
| 169 | 174 | "role": "member", | |
| 170 | 175 | "org_roles": [ | |
| 171 | 176 | "security_manager" | |
| ⋯ | |||
| 290 | 295 | }, | |
| 291 | 296 | "response": { | |
| 292 | 297 | "username": "ada", | |
| 298 | + | "display_username": "Ada", | |
| 293 | 299 | "email": "ada@example.com", | |
| 294 | 300 | "verified": true, | |
| 295 | − | "joined": "acme", | |
| 301 | + | "joined": null, | |
| 302 | + | "invited_to": "acme", | |
| 296 | 303 | "invite_lapsed": null | |
| 297 | 304 | }, | |
| 298 | − | "notes": "The code confirms the address it was sent to, and ends the link sent with it. A wrong, used or expired code answers `422` with one message for all three; after ten wrong codes in an hour the account answers `409` for a while, and the link in the email still works. `verified` says whether the account is confirmed: whether its primary address is. `joined` is set when confirming a new account's address joined the workspace its invite named; when the invite was revoked or expired, or the workspace deleted, while the account waited, the address is confirmed all the same and `invite_lapsed` says so. See [Confirming your email address](/guides/authentication/#confirming-your-email-address)." | |
| 305 | + | "notes": "The code confirms the address it was sent to, and ends the link sent with it. A wrong, used or expired code answers `422` with one message for all three; after ten wrong codes in an hour the account answers `409` for a while, and the link in the email still works. `verified` says whether the account is confirmed: whether its primary address is. `invited_to` is set when the invite the account signed up with brings it into a workspace: confirming the address sends the account an invitation to that workspace, which it accepts with `POST /user/invitations/{id}/accept` or declines; nobody joins a workspace without saying yes. `joined` is kept for older clients and is null. When the invite was revoked or expired, or the workspace deleted, while the account waited, the address is confirmed all the same and `invite_lapsed` says so. See [Confirming your email address](/guides/authentication/#confirming-your-email-address)." | |
| 299 | 306 | }, | |
| 300 | 307 | "remove_email": { | |
| 301 | 308 | "request": { | |
| ⋯ | |||
| 409 | 416 | "created_at": "2026-10-05T17:00:00.000Z", | |
| 410 | 417 | "expires_at": "2026-11-04T17:00:00.000Z", | |
| 411 | 418 | "redeemed_at": null, | |
| 412 | − | "revoked_at": null | |
| 419 | + | "revoked_at": null, | |
| 420 | + | "invitee": null, | |
| 421 | + | "role": null | |
| 413 | 422 | }, | |
| 414 | 423 | { | |
| 415 | 424 | "id": "inv_01kp1v3c4d5e6f7g8h9j0k1m2n", | |
| ⋯ | |||
| 425 | 434 | "created_at": "2026-10-02T09:12:40.000Z", | |
| 426 | 435 | "expires_at": "2026-11-01T09:12:40.000Z", | |
| 427 | 436 | "redeemed_at": "2026-10-02T11:30:05.000Z", | |
| 428 | − | "revoked_at": null | |
| 437 | + | "revoked_at": null, | |
| 438 | + | "invitee": "grace", | |
| 439 | + | "role": null | |
| 429 | 440 | } | |
| 430 | 441 | ] | |
| 431 | 442 | }, | |
| 432 | − | "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. Ask for more at hey@flagon.io with the subject `[g1t Invites]`. See [Invites](/guides/authentication/#invites)." | |
| 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)." | |
| 433 | 444 | }, | |
| 434 | 445 | "create_invite": { | |
| 435 | 446 | "request": { | |
| 436 | − | "email": "ada@example.com" | |
| 447 | + | "email": "ada@example.com", | |
| 448 | + | "workspace": "acme-labs" | |
| 437 | 449 | }, | |
| 438 | 450 | "response": { | |
| 439 | 451 | "id": "inv_01kp2x7m8n9q0r1s2t3v4w5x6y", | |
| ⋯ | |||
| 441 | 453 | "hint": "g1t-k7m2", | |
| 442 | 454 | "email": "ada@example.com", | |
| 443 | 455 | "kind": "account", | |
| 444 | − | "workspace": null, | |
| 456 | + | "workspace": "acme-labs", | |
| 445 | 457 | "status": "pending", | |
| 446 | 458 | "charged_to": "user", | |
| 447 | 459 | "invited_by": "syntaqx", | |
| ⋯ | |||
| 449 | 461 | "created_at": "2026-10-05T17:00:00.000Z", | |
| 450 | 462 | "expires_at": "2026-11-04T17:00:00.000Z", | |
| 451 | 463 | "redeemed_at": null, | |
| 452 | − | "revoked_at": null | |
| 464 | + | "revoked_at": null, | |
| 465 | + | "invitee": null, | |
| 466 | + | "role": "member" | |
| 453 | 467 | }, | |
| 454 | − | "notes": "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>`." | |
| 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>`." | |
| 455 | 469 | }, | |
| 456 | 470 | "revoke_invite": { | |
| 457 | 471 | "response": { | |
| ⋯ | |||
| 468 | 482 | "created_at": "2026-10-05T17:00:00.000Z", | |
| 469 | 483 | "expires_at": "2026-11-04T17:00:00.000Z", | |
| 470 | 484 | "redeemed_at": null, | |
| 471 | − | "revoked_at": "2026-10-06T08:00:00.000Z" | |
| 485 | + | "revoked_at": "2026-10-06T08:00:00.000Z", | |
| 486 | + | "invitee": null, | |
| 487 | + | "role": null | |
| 472 | 488 | } | |
| 473 | 489 | }, | |
| 474 | 490 | "list_workspace_invites": { | |
| ⋯ | |||
| 487 | 503 | "created_at": "2026-10-05T17:00:00.000Z", | |
| 488 | 504 | "expires_at": "2026-11-04T17:00:00.000Z", | |
| 489 | 505 | "redeemed_at": null, | |
| 490 | − | "revoked_at": null | |
| 506 | + | "revoked_at": null, | |
| 507 | + | "invitee": null, | |
| 508 | + | "role": "member" | |
| 491 | 509 | }, | |
| 492 | 510 | { | |
| 493 | 511 | "id": "inv_01kp1z9y8x7w6v5t4s3r2q1p0n", | |
| 494 | 512 | "code": "g1t-k7m2-q9xd-4hpw-…", | |
| 495 | 513 | "hint": "g1t-w2vb", | |
| 496 | − | "email": "linus@example.com", | |
| 514 | + | "email": null, | |
| 497 | 515 | "kind": "workspace", | |
| 498 | 516 | "workspace": "acme-labs", | |
| 499 | 517 | "status": "pending", | |
| ⋯ | |||
| 503 | 521 | "created_at": "2026-10-05T17:00:00.000Z", | |
| 504 | 522 | "expires_at": "2026-11-04T17:00:00.000Z", | |
| 505 | 523 | "redeemed_at": null, | |
| 506 | − | "revoked_at": null | |
| 524 | + | "revoked_at": null, | |
| 525 | + | "invitee": "linus", | |
| 526 | + | "role": "owner" | |
| 507 | 527 | } | |
| 508 | 528 | ] | |
| 509 | 529 | }, | |
| 510 | 530 | "invite_member": { | |
| 511 | 531 | "request": { | |
| 512 | − | "email": "ada@example.com" | |
| 532 | + | "username": "ada", | |
| 533 | + | "role": "member" | |
| 513 | 534 | }, | |
| 514 | 535 | "response": { | |
| 515 | 536 | "id": "inv_01kp2x7m8n9q0r1s2t3v4w5x6y", | |
| 516 | 537 | "code": "g1t-k7m2-q9xd-4hpw-…", | |
| 517 | 538 | "hint": "g1t-k7m2", | |
| 518 | − | "email": "ada@example.com", | |
| 519 | − | "kind": "account", | |
| 539 | + | "email": null, | |
| 540 | + | "kind": "workspace", | |
| 520 | 541 | "workspace": "acme-labs", | |
| 521 | 542 | "status": "pending", | |
| 522 | − | "charged_to": "workspace", | |
| 543 | + | "charged_to": "none", | |
| 523 | 544 | "invited_by": "syntaqx", | |
| 524 | 545 | "redeemed_by": null, | |
| 525 | 546 | "created_at": "2026-10-05T17:00:00.000Z", | |
| 526 | 547 | "expires_at": "2026-11-04T17:00:00.000Z", | |
| 527 | 548 | "redeemed_at": null, | |
| 528 | − | "revoked_at": null | |
| 549 | + | "revoked_at": null, | |
| 550 | + | "invitee": "ada", | |
| 551 | + | "role": "member" | |
| 529 | 552 | }, | |
| 530 | − | "notes": "`kind` is `workspace` when the address already has a g1t account (`charged_to` is then `none`), and `account` when accepting makes one. Both answers look alike to the caller on purpose: the invite is emailed either way. A free workspace cannot invite anyone: `402` with `payment_required`, until it starts the g1t plan." | |
| 553 | + | "notes": "Give `username` or `email`. Nobody is added without saying yes: the person gets an invitation to accept or decline, and joins with `role` when they accept. By username, `invitee` names the account and it costs nothing; `404` when there is no account with that username. By email, `kind` is `workspace` when the address already has a g1t account (`charged_to` is then `none`), and `account` when the link makes one; both answers look alike to the caller on purpose, and `invitee` stays null until an account is known. `409` when the person is already a member or already has a pending invitation to the workspace. A free workspace cannot invite anyone: `402` with `payment_required`, until it starts the g1t plan. See [Workspace invitations](/reference/api/invites/list-invitations/) for the other side." | |
| 531 | 554 | }, | |
| 532 | 555 | "revoke_workspace_invite": { | |
| 533 | 556 | "response": { | |
| ⋯ | |||
| 544 | 567 | "created_at": "2026-10-05T17:00:00.000Z", | |
| 545 | 568 | "expires_at": "2026-11-04T17:00:00.000Z", | |
| 546 | 569 | "redeemed_at": null, | |
| 547 | − | "revoked_at": "2026-10-06T08:00:00.000Z" | |
| 570 | + | "revoked_at": "2026-10-06T08:00:00.000Z", | |
| 571 | + | "invitee": null, | |
| 572 | + | "role": "member" | |
| 548 | 573 | } | |
| 549 | 574 | }, | |
| 575 | + | "list_invitations": { | |
| 576 | + | "response": [ | |
| 577 | + | { | |
| 578 | + | "id": "inv_01kp2x7m8n9q0r1s2t3v4w5x6y", | |
| 579 | + | "workspace": { | |
| 580 | + | "slug": "acme-labs", | |
| 581 | + | "name": "Acme Labs", | |
| 582 | + | "avatar": null | |
| 583 | + | }, | |
| 584 | + | "role": "member", | |
| 585 | + | "invited_by": { | |
| 586 | + | "username": "syntaqx", | |
| 587 | + | "display_username": "syntaqx", | |
| 588 | + | "name": "Chase", | |
| 589 | + | "avatar": null | |
| 590 | + | }, | |
| 591 | + | "created_at": "2026-10-05T17:00:00.000Z", | |
| 592 | + | "expires_at": "2026-11-04T17:00:00.000Z" | |
| 593 | + | } | |
| 594 | + | ], | |
| 595 | + | "notes": "Accept one with `POST /user/invitations/{id}/accept`, or decline it with `POST /user/invitations/{id}/decline`. An agent's or a workspace's token gets an empty list." | |
| 596 | + | }, | |
| 597 | + | "accept_invitation": { | |
| 598 | + | "params": { | |
| 599 | + | "id": "inv_01kp2x7m8n9q0r1s2t3v4w5x6y" | |
| 600 | + | }, | |
| 601 | + | "response": { | |
| 602 | + | "workspace": "acme-labs" | |
| 603 | + | }, | |
| 604 | + | "notes": "You join at once with the invitation's `role`. `404` when you have no open invitation with that id (it may have been answered, revoked or expired). `403` until you confirm your email address, and when your account does not meet what the workspace asks of its members, such as two-factor authentication, with a message that says what to turn on. `402` with `payment_required` while the workspace is on the free plan; the invitation stays open until it starts the g1t plan. Recorded as `member.added`." | |
| 605 | + | }, | |
| 606 | + | "decline_invitation": { | |
| 607 | + | "params": { | |
| 608 | + | "id": "inv_01kp2x7m8n9q0r1s2t3v4w5x6y" | |
| 609 | + | }, | |
| 610 | + | "response": true, | |
| 611 | + | "notes": "Whoever sent it is told in their inbox. The workspace's owners can invite you again. `404` when you have no open invitation with that id." | |
| 612 | + | }, | |
| 550 | 613 | "list_repos": { | |
| 551 | 614 | "query": { | |
| 552 | 615 | "q": "hello" | |
| ⋯ | |||
| 997 | 1060 | "author": { | |
| 998 | 1061 | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 999 | 1062 | "username": "syntaqx", | |
| 1063 | + | "display_username": "syntaqx", | |
| 1000 | 1064 | "kind": "user", | |
| 1001 | 1065 | "verified": false, | |
| 1002 | 1066 | "workspaces": [] | |
| ⋯ | |||
| 1039 | 1103 | "author": { | |
| 1040 | 1104 | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 1041 | 1105 | "username": "syntaqx", | |
| 1106 | + | "display_username": "syntaqx", | |
| 1042 | 1107 | "kind": "user", | |
| 1043 | 1108 | "verified": false, | |
| 1044 | 1109 | "workspaces": [] | |
| ⋯ | |||
| 1074 | 1139 | "author": { | |
| 1075 | 1140 | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 1076 | 1141 | "username": "syntaqx", | |
| 1142 | + | "display_username": "syntaqx", | |
| 1077 | 1143 | "kind": "user", | |
| 1078 | 1144 | "verified": false, | |
| 1079 | 1145 | "workspaces": [] | |
| ⋯ | |||
| 1122 | 1188 | "author": { | |
| 1123 | 1189 | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 1124 | 1190 | "username": "syntaqx", | |
| 1191 | + | "display_username": "syntaqx", | |
| 1125 | 1192 | "kind": "user", | |
| 1126 | 1193 | "verified": false, | |
| 1127 | 1194 | "workspaces": [] | |
| ⋯ | |||
| 1169 | 1236 | "author": { | |
| 1170 | 1237 | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 1171 | 1238 | "username": "syntaqx", | |
| 1239 | + | "display_username": "syntaqx", | |
| 1172 | 1240 | "kind": "user", | |
| 1173 | 1241 | "verified": false, | |
| 1174 | 1242 | "workspaces": [] | |
| ⋯ | |||
| 1185 | 1253 | "author": { | |
| 1186 | 1254 | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 1187 | 1255 | "username": "syntaqx", | |
| 1256 | + | "display_username": "syntaqx", | |
| 1188 | 1257 | "kind": "user", | |
| 1189 | 1258 | "verified": false, | |
| 1190 | 1259 | "workspaces": [] | |
| ⋯ | |||
| 1226 | 1295 | "author": { | |
| 1227 | 1296 | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 1228 | 1297 | "username": "syntaqx", | |
| 1298 | + | "display_username": "syntaqx", | |
| 1229 | 1299 | "kind": "user", | |
| 1230 | 1300 | "verified": false, | |
| 1231 | 1301 | "workspaces": [] | |
| ⋯ | |||
| 1267 | 1337 | "author": { | |
| 1268 | 1338 | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 1269 | 1339 | "username": "syntaqx", | |
| 1340 | + | "display_username": "syntaqx", | |
| 1270 | 1341 | "kind": "user", | |
| 1271 | 1342 | "verified": false, | |
| 1272 | 1343 | "workspaces": [] | |
| ⋯ | |||
| 1300 | 1371 | "author": { | |
| 1301 | 1372 | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 1302 | 1373 | "username": "syntaqx", | |
| 1374 | + | "display_username": "syntaqx", | |
| 1303 | 1375 | "kind": "user", | |
| 1304 | 1376 | "verified": false, | |
| 1305 | 1377 | "workspaces": [] | |
| ⋯ | |||
| 1352 | 1424 | "author": { | |
| 1353 | 1425 | "id": "usr_g1t_agent", | |
| 1354 | 1426 | "username": "g1t", | |
| 1427 | + | "display_username": "g1t", | |
| 1355 | 1428 | "kind": "agent", | |
| 1356 | 1429 | "verified": false, | |
| 1357 | 1430 | "workspaces": [] | |
| ⋯ | |||
| 1359 | 1432 | "requested_by": { | |
| 1360 | 1433 | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 1361 | 1434 | "username": "syntaqx", | |
| 1435 | + | "display_username": "syntaqx", | |
| 1362 | 1436 | "kind": "user", | |
| 1363 | 1437 | "verified": false, | |
| 1364 | 1438 | "workspaces": [] | |
| ⋯ | |||
| 1388 | 1462 | "author": { | |
| 1389 | 1463 | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 1390 | 1464 | "username": "syntaqx", | |
| 1465 | + | "display_username": "syntaqx", | |
| 1391 | 1466 | "kind": "user", | |
| 1392 | 1467 | "verified": false, | |
| 1393 | 1468 | "workspaces": [] | |
| ⋯ | |||
| 1435 | 1510 | "author": { | |
| 1436 | 1511 | "id": "usr_g1t_agent", | |
| 1437 | 1512 | "username": "g1t", | |
| 1513 | + | "display_username": "g1t", | |
| 1438 | 1514 | "kind": "agent", | |
| 1439 | 1515 | "verified": false, | |
| 1440 | 1516 | "workspaces": [] | |
| ⋯ | |||
| 1442 | 1518 | "requested_by": { | |
| 1443 | 1519 | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 1444 | 1520 | "username": "syntaqx", | |
| 1521 | + | "display_username": "syntaqx", | |
| 1445 | 1522 | "kind": "user", | |
| 1446 | 1523 | "verified": false, | |
| 1447 | 1524 | "workspaces": [] | |
| ⋯ | |||
| 1469 | 1546 | "author": { | |
| 1470 | 1547 | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 1471 | 1548 | "username": "syntaqx", | |
| 1549 | + | "display_username": "syntaqx", | |
| 1472 | 1550 | "kind": "user", | |
| 1473 | 1551 | "verified": true, | |
| 1474 | 1552 | "workspaces": [ | |
| ⋯ | |||
| 1498 | 1576 | "author": { | |
| 1499 | 1577 | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 1500 | 1578 | "username": "syntaqx", | |
| 1579 | + | "display_username": "syntaqx", | |
| 1501 | 1580 | "kind": "user", | |
| 1502 | 1581 | "verified": true, | |
| 1503 | 1582 | "workspaces": [ | |
| ⋯ | |||
| 1604 | 1683 | "author": { | |
| 1605 | 1684 | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 1606 | 1685 | "username": "syntaqx", | |
| 1686 | + | "display_username": "syntaqx", | |
| 1607 | 1687 | "kind": "user", | |
| 1608 | 1688 | "verified": false, | |
| 1609 | 1689 | "workspaces": [] | |
| ⋯ | |||
| 1666 | 1746 | "author": { | |
| 1667 | 1747 | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 1668 | 1748 | "username": "syntaqx", | |
| 1749 | + | "display_username": "syntaqx", | |
| 1669 | 1750 | "kind": "user", | |
| 1670 | 1751 | "verified": false, | |
| 1671 | 1752 | "workspaces": [] | |
| ⋯ | |||
| 1719 | 1800 | "author": { | |
| 1720 | 1801 | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 1721 | 1802 | "username": "syntaqx", | |
| 1803 | + | "display_username": "syntaqx", | |
| 1722 | 1804 | "kind": "user", | |
| 1723 | 1805 | "verified": false, | |
| 1724 | 1806 | "workspaces": [] | |
| ⋯ | |||
| 1770 | 1852 | "author": { | |
| 1771 | 1853 | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 1772 | 1854 | "username": "syntaqx", | |
| 1855 | + | "display_username": "syntaqx", | |
| 1773 | 1856 | "kind": "user", | |
| 1774 | 1857 | "verified": false, | |
| 1775 | 1858 | "workspaces": [] | |
| ⋯ | |||
| 1827 | 1910 | "author": { | |
| 1828 | 1911 | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 1829 | 1912 | "username": "syntaqx", | |
| 1913 | + | "display_username": "syntaqx", | |
| 1830 | 1914 | "kind": "user", | |
| 1831 | 1915 | "verified": false, | |
| 1832 | 1916 | "workspaces": [] | |
| ⋯ | |||
| 1854 | 1938 | "author": { | |
| 1855 | 1939 | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 1856 | 1940 | "username": "syntaqx", | |
| 1941 | + | "display_username": "syntaqx", | |
| 1857 | 1942 | "kind": "user", | |
| 1858 | 1943 | "verified": false, | |
| 1859 | 1944 | "workspaces": [] | |
| ⋯ | |||
| 1877 | 1962 | "author": { | |
| 1878 | 1963 | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 1879 | 1964 | "username": "syntaqx", | |
| 1965 | + | "display_username": "syntaqx", | |
| 1880 | 1966 | "kind": "user", | |
| 1881 | 1967 | "verified": false, | |
| 1882 | 1968 | "workspaces": [] | |
| ⋯ | |||
| 1886 | 1972 | "line": null, | |
| 1887 | 1973 | "verdict": null, | |
| 1888 | 1974 | "created_at": "2026-10-01T18:33:10.420Z" | |
| 1975 | + | }, | |
| 1976 | + | { | |
| 1977 | + | "id": "cmt_01m43sw2a6c0e4h8k2n6r0v4z8", | |
| 1978 | + | "kind": "comment", | |
| 1979 | + | "author": { | |
| 1980 | + | "id": "agt_01m43q8d2f6h0k4n8r2v6z0c4g", | |
| 1981 | + | "username": "margo", | |
| 1982 | + | "kind": "agent", | |
| 1983 | + | "verified": false, | |
| 1984 | + | "workspaces": [] | |
| 1985 | + | }, | |
| 1986 | + | "body": "The empty-name case is handled and tested. One nit: trim the name before using it.", | |
| 1987 | + | "path": null, | |
| 1988 | + | "line": null, | |
| 1989 | + | "verdict": "approve", | |
| 1990 | + | "created_at": "2026-10-01T18:36:41.207Z", | |
| 1991 | + | "agent": { | |
| 1992 | + | "id": "agt_01m43q8d2f6h0k4n8r2v6z0c4g", | |
| 1993 | + | "handle": "margo", | |
| 1994 | + | "display_name": "Margo", | |
| 1995 | + | "avatar_seed": "margo" | |
| 1996 | + | }, | |
| 1997 | + | "acting_for": { | |
| 1998 | + | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 1999 | + | "username": "syntaqx", | |
| 2000 | + | "kind": "user", | |
| 2001 | + | "verified": false, | |
| 2002 | + | "workspaces": [] | |
| 2003 | + | }, | |
| 2004 | + | "advisory": true | |
| 1889 | 2005 | } | |
| 1890 | 2006 | ], | |
| 1891 | 2007 | "checks": null, | |
| ⋯ | |||
| 2041 | 2157 | "author": { | |
| 2042 | 2158 | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 2043 | 2159 | "username": "syntaqx", | |
| 2160 | + | "display_username": "syntaqx", | |
| 2044 | 2161 | "kind": "user", | |
| 2045 | 2162 | "verified": false, | |
| 2046 | 2163 | "workspaces": [] | |
| ⋯ | |||
| 2061 | 2178 | "author": { | |
| 2062 | 2179 | "id": "usr_01kz9d3f7h1k5n9r3v7z1c5g9b", | |
| 2063 | 2180 | "username": "ana", | |
| 2181 | + | "display_username": "Ana", | |
| 2064 | 2182 | "kind": "user", | |
| 2065 | 2183 | "verified": true, | |
| 2066 | 2184 | "workspaces": [ | |
| ⋯ | |||
| 2131 | 2249 | "author": { | |
| 2132 | 2250 | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 2133 | 2251 | "username": "syntaqx", | |
| 2252 | + | "display_username": "syntaqx", | |
| 2134 | 2253 | "kind": "user", | |
| 2135 | 2254 | "verified": false, | |
| 2136 | 2255 | "workspaces": [] | |
| ⋯ | |||
| 2192 | 2311 | "author": { | |
| 2193 | 2312 | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 2194 | 2313 | "username": "syntaqx", | |
| 2314 | + | "display_username": "syntaqx", | |
| 2195 | 2315 | "kind": "user", | |
| 2196 | 2316 | "verified": false, | |
| 2197 | 2317 | "workspaces": [] | |
| ⋯ | |||
| 2245 | 2365 | "author": { | |
| 2246 | 2366 | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 2247 | 2367 | "username": "syntaqx", | |
| 2368 | + | "display_username": "syntaqx", | |
| 2248 | 2369 | "kind": "user", | |
| 2249 | 2370 | "verified": false, | |
| 2250 | 2371 | "workspaces": [] | |
| ⋯ | |||
| 2293 | 2414 | "author": { | |
| 2294 | 2415 | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 2295 | 2416 | "username": "syntaqx", | |
| 2417 | + | "display_username": "syntaqx", | |
| 2296 | 2418 | "kind": "user", | |
| 2297 | 2419 | "verified": false, | |
| 2298 | 2420 | "workspaces": [] | |
| ⋯ | |||
| 2340 | 2462 | "author": { | |
| 2341 | 2463 | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 2342 | 2464 | "username": "syntaqx", | |
| 2465 | + | "display_username": "syntaqx", | |
| 2343 | 2466 | "kind": "user", | |
| 2344 | 2467 | "verified": false, | |
| 2345 | 2468 | "workspaces": [] | |
| ⋯ | |||
| 2388 | 2511 | "author": { | |
| 2389 | 2512 | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 2390 | 2513 | "username": "syntaqx", | |
| 2514 | + | "display_username": "syntaqx", | |
| 2391 | 2515 | "kind": "user", | |
| 2392 | 2516 | "verified": false, | |
| 2393 | 2517 | "workspaces": [] | |
| ⋯ | |||
| 4354 | 4478 | "kind": "project", | |
| 4355 | 4479 | "id": "ent_9b1d6f0a2c3e4b5d6e7f8a9b", | |
| 4356 | 4480 | "title": "web", | |
| 4357 | − | "snippet": "The storefront. Written in TypeScript. Packages: @acme/web. Uses api. Owned by ana.", | |
| 4481 | + | "snippet": "The storefront. Written in TypeScript. Packages: @acme/web. Owned by ana.", | |
| 4358 | 4482 | "project": "web", | |
| 4359 | 4483 | "url": "/acme/web", | |
| 4360 | 4484 | "score": 0.71, | |
| ⋯ | |||
| 4379 | 4503 | "kind": "project", | |
| 4380 | 4504 | "key": "web", | |
| 4381 | 4505 | "name": "web", | |
| 4382 | − | "summary": "The storefront. Written in TypeScript. Packages: @acme/web. Uses api. Owned by ana.", | |
| 4506 | + | "summary": "The storefront. Written in TypeScript. Packages: @acme/web. Owned by ana.", | |
| 4383 | 4507 | "project": "web", | |
| 4384 | 4508 | "private": true, | |
| 4385 | 4509 | "data": { | |
| ⋯ | |||
| 4404 | 4528 | }, | |
| 4405 | 4529 | "relations": [ | |
| 4406 | 4530 | { | |
| 4407 | − | "kind": "depends_on", | |
| 4531 | + | "kind": "exposes", | |
| 4408 | 4532 | "direction": "out", | |
| 4409 | 4533 | "entity": { | |
| 4410 | 4534 | "id": "ent_0a7c3e5b9d1f2a4c6e8b0d2f", | |
| 4411 | 4535 | "workspace": "acme", | |
| 4412 | − | "kind": "project", | |
| 4413 | − | "key": "api", | |
| 4414 | − | "name": "api", | |
| 4415 | − | "summary": "The public API. Written in Rust.", | |
| 4416 | − | "project": "api", | |
| 4536 | + | "kind": "package", | |
| 4537 | + | "key": "npm:@acme/web", | |
| 4538 | + | "name": "@acme/web", | |
| 4539 | + | "summary": null, | |
| 4540 | + | "project": "web", | |
| 4417 | 4541 | "private": true, | |
| 4418 | 4542 | "data": {}, | |
| 4419 | 4543 | "source": "scan", | |
| 4420 | − | "ref": "/acme/api", | |
| 4421 | − | "updated_at": "2026-10-04T20:57:12.000Z" | |
| 4544 | + | "ref": "/acme/web/blob/main/package.json", | |
| 4545 | + | "updated_at": "2026-10-04T20:58:41.000Z" | |
| 4422 | 4546 | } | |
| 4423 | 4547 | }, | |
| 4424 | 4548 | { | |
| ⋯ | |||
| 4450 | 4574 | "people": [ | |
| 4451 | 4575 | { | |
| 4452 | 4576 | "username": "syntaqx", | |
| 4577 | + | "display_username": "syntaqx", | |
| 4453 | 4578 | "name": "Chase Pierce", | |
| 4454 | 4579 | "avatar": null, | |
| 4455 | 4580 | "role": "admin", | |
| ⋯ | |||
| 4459 | 4584 | }, | |
| 4460 | 4585 | { | |
| 4461 | 4586 | "username": "linus", | |
| 4587 | + | "display_username": "linus", | |
| 4462 | 4588 | "name": null, | |
| 4463 | 4589 | "avatar": null, | |
| 4464 | 4590 | "role": "maintain", | |
| ⋯ | |||
| 4468 | 4594 | }, | |
| 4469 | 4595 | { | |
| 4470 | 4596 | "username": "grace", | |
| 4597 | + | "display_username": "grace", | |
| 4471 | 4598 | "name": "Grace Hopper", | |
| 4472 | 4599 | "avatar": null, | |
| 4473 | 4600 | "role": "write", | |
| ⋯ | |||
| 4477 | 4604 | }, | |
| 4478 | 4605 | { | |
| 4479 | 4606 | "username": "ada", | |
| 4607 | + | "display_username": "Ada", | |
| 4480 | 4608 | "name": "Ada Lovelace", | |
| 4481 | 4609 | "avatar": null, | |
| 4482 | 4610 | "role": "triage", | |
| ⋯ | |||
| 4539 | 4667 | "expires_at": "2026-10-12T17:00:00.000Z" | |
| 4540 | 4668 | } | |
| 4541 | 4669 | }, | |
| 4542 | − | "notes": "`invitee` is a username or an email address. A member of the workspace answers `{\"result\": \"granted\", \"collaborator\": {…}}` with the role already given, shown as list_collaborators shows a person. Anyone else answers `{\"result\": \"invited\", \"invitation\": {…}}`: the invitation is emailed and waits 7 days, and the role is theirs once they accept it. An email address without an account gets an invitation with `email` set and `invitee` null, and an invite that makes the account and accepts in one step. Refused with `404` when no account has that username, `409` when they already have a role of their own or a pending invitation (change it with update_collaborator), and `403` without the Admin role, from an agent's or a workspace's token, or when the person does not meet what the workspace asks of everyone with access. collaborator_added` webhook event is sent once they have the role. See [Access and roles](/guides/access-and-roles/). On a free workspace, inviting anyone who is not a member answers `402` with `payment_required` until it starts the g1t plan." | |
| 4670 | + | "notes": "`invitee` is a username or an email address. A member of the workspace answers `{\"result\": \"granted\", \"collaborator\": {…}}` with the role already given, shown as list_collaborators shows a person. Anyone else answers `{\"result\": \"invited\", \"invitation\": {…}}`: the invitation is emailed and waits 7 days, and the role is theirs once they accept it. An email address without an account gets an invitation with `email` set and `invitee` null, and an invite that makes the account and accepts in one step. Refused with `404` when no account has that username, `409` when they already have a role of their own or a pending invitation (change it with update_collaborator), and `403` without the Admin role, from an agent's or a workspace's token, or when the person does not meet what the workspace asks of everyone with access. A `collaborator_added` webhook event is sent once they have the role. See [Access and roles](/guides/access-and-roles/). On a free workspace, inviting anyone who is not a member answers `402` with `payment_required` until it starts the g1t plan." | |
| 4543 | 4671 | }, | |
| 4544 | 4672 | "update_collaborator": { | |
| 4545 | 4673 | "params": { | |
| ⋯ | |||
| 4552 | 4680 | }, | |
| 4553 | 4681 | "response": { | |
| 4554 | 4682 | "username": "ada", | |
| 4683 | + | "display_username": "Ada", | |
| 4555 | 4684 | "name": "Ada Lovelace", | |
| 4556 | 4685 | "avatar": null, | |
| 4557 | 4686 | "role": "write", | |
| ⋯ | |||
| 4578 | 4707 | }, | |
| 4579 | 4708 | "response": { | |
| 4580 | 4709 | "username": "ada", | |
| 4710 | + | "display_username": "Ada", | |
| 4581 | 4711 | "role": "triage", | |
| 4582 | 4712 | "source": "direct", | |
| 4583 | 4713 | "capabilities": [ | |
| ⋯ | |||
| 4716 | 4846 | "response": [ | |
| 4717 | 4847 | { | |
| 4718 | 4848 | "username": "ada", | |
| 4849 | + | "display_username": "Ada", | |
| 4719 | 4850 | "name": "Ada Lovelace", | |
| 4720 | 4851 | "avatar": null, | |
| 4721 | 4852 | "repos": [ | |
| ⋯ | |||
| 4991 | 5122 | "response": [ | |
| 4992 | 5123 | { | |
| 4993 | 5124 | "username": "syntaqx", | |
| 5125 | + | "display_username": "syntaqx", | |
| 4994 | 5126 | "name": "Chase Pierce", | |
| 4995 | 5127 | "avatar": null, | |
| 4996 | 5128 | "role": "maintainer", | |
| ⋯ | |||
| 4998 | 5130 | }, | |
| 4999 | 5131 | { | |
| 5000 | 5132 | "username": "ana", | |
| 5133 | + | "display_username": "Ana", | |
| 5001 | 5134 | "name": "Ana Lima", | |
| 5002 | 5135 | "avatar": null, | |
| 5003 | 5136 | "role": "member", | |
| ⋯ | |||
| 5005 | 5138 | }, | |
| 5006 | 5139 | { | |
| 5007 | 5140 | "username": "bo", | |
| 5141 | + | "display_username": "bo", | |
| 5008 | 5142 | "name": null, | |
| 5009 | 5143 | "avatar": null, | |
| 5010 | 5144 | "role": "member", | |
| ⋯ | |||
| 5024 | 5158 | }, | |
| 5025 | 5159 | "response": { | |
| 5026 | 5160 | "username": "ana", | |
| 5161 | + | "display_username": "Ana", | |
| 5027 | 5162 | "name": "Ana Lima", | |
| 5028 | 5163 | "avatar": null, | |
| 5029 | 5164 | "role": "maintainer", | |
| ⋯ | |||
| 8592 | 8727 | "author": { | |
| 8593 | 8728 | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 8594 | 8729 | "username": "syntaqx", | |
| 8730 | + | "display_username": "syntaqx", | |
| 8595 | 8731 | "kind": "user", | |
| 8596 | 8732 | "verified": false, | |
| 8597 | 8733 | "workspaces": [] | |
| ⋯ | |||
| 8657 | 8793 | "author": { | |
| 8658 | 8794 | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 8659 | 8795 | "username": "syntaqx", | |
| 8796 | + | "display_username": "syntaqx", | |
| 8660 | 8797 | "kind": "user", | |
| 8661 | 8798 | "verified": false, | |
| 8662 | 8799 | "workspaces": [] | |
| ⋯ | |||
| 8717 | 8854 | "author": { | |
| 8718 | 8855 | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 8719 | 8856 | "username": "syntaqx", | |
| 8857 | + | "display_username": "syntaqx", | |
| 8720 | 8858 | "kind": "user", | |
| 8721 | 8859 | "verified": false, | |
| 8722 | 8860 | "workspaces": [] | |
| ⋯ | |||
| 10609 | 10747 | "workspace": "flagon-io" | |
| 10610 | 10748 | }, | |
| 10611 | 10749 | "response": { | |
| 10612 | − | "allow_classic": true, | |
| 10613 | − | "allow_fine_grained": true, | |
| 10750 | + | "allow_tokens_for_all_workspaces": true, | |
| 10751 | + | "allow_tokens_for_this_workspace": true, | |
| 10614 | 10752 | "require_approval": true, | |
| 10615 | 10753 | "max_lifetime_days": null, | |
| 10616 | 10754 | "forbid_no_expiry": false, | |
| ⋯ | |||
| 10623 | 10761 | "workspace": "flagon-io" | |
| 10624 | 10762 | }, | |
| 10625 | 10763 | "request": { | |
| 10626 | − | "allow_classic": false, | |
| 10764 | + | "allow_tokens_for_all_workspaces": false, | |
| 10627 | 10765 | "max_lifetime_days": 90 | |
| 10628 | 10766 | }, | |
| 10629 | 10767 | "response": { | |
| 10630 | − | "allow_classic": false, | |
| 10631 | − | "allow_fine_grained": true, | |
| 10768 | + | "allow_tokens_for_all_workspaces": false, | |
| 10769 | + | "allow_tokens_for_this_workspace": true, | |
| 10632 | 10770 | "require_approval": true, | |
| 10633 | 10771 | "max_lifetime_days": 90, | |
| 10634 | 10772 | "forbid_no_expiry": false, | |
| 10635 | 10773 | "updated_by": "ada", | |
| 10636 | 10774 | "updated_at": "2026-10-08T09:30:00.000Z" | |
| 10637 | 10775 | }, | |
| 10638 | − | "notes": "From each token's next request, classic tokens no longer reach the workspace, and neither does a token that lasts longer than 90 days or never expires. They keep working everywhere else." | |
| 10776 | + | "notes": "From each token's next request, tokens made for all of a member's workspaces no longer reach this one (tokens made for it alone still do), and neither does a token that lasts longer than 90 days or never expires. They keep working everywhere else." | |
| 10639 | 10777 | }, | |
| 10640 | 10778 | "list_member_tokens": { | |
| 10641 | 10779 | "params": { | |
| ⋯ | |||
| 10646 | 10784 | "id": "tok_01HZX3K2M9V7Q4N8B6D5C3A2E1", | |
| 10647 | 10785 | "name": "release-bot", | |
| 10648 | 10786 | "owner": "ada", | |
| 10649 | − | "kind": "fine_grained", | |
| 10650 | − | "description": "Publishes releases from CI", | |
| 10787 | + | "description": "Publishes releases from CI", | |
| 10651 | 10788 | "created_at": "2026-10-08T09:00:00.000Z", | |
| 10652 | 10789 | "created_by": null, | |
| 10653 | 10790 | "last_used_at": null, | |
| 10654 | 10791 | "expires_at": "2026-11-07T09:00:00.000Z", | |
| 10655 | − | "scopes": ["repo:read", "repo:write", "code:read", "code:write"], | |
| 10656 | − | "resource_owner": "flagon-io", | |
| 10792 | + | "scopes": ["repo:read", "code:write"], | |
| 10793 | + | "workspace": "flagon-io", | |
| 10657 | 10794 | "repository_selection": "selected", | |
| 10658 | 10795 | "repositories": ["flagon-io/hello"], | |
| 10659 | − | "permissions": { "contents": "write", "metadata": "read" }, | |
| 10796 | + | "permissions": { "code": "write", "repo": "read" }, | |
| 10660 | 10797 | "status": "pending", | |
| 10661 | 10798 | "review_reason": null, | |
| 10662 | 10799 | "reaches": false, | |
| ⋯ | |||
| 10673 | 10810 | "id": "tok_01HZX3K2M9V7Q4N8B6D5C3A2E1", | |
| 10674 | 10811 | "name": "release-bot", | |
| 10675 | 10812 | "owner": "ada", | |
| 10676 | − | "kind": "fine_grained", | |
| 10677 | − | "description": "Publishes releases from CI", | |
| 10813 | + | "description": "Publishes releases from CI", | |
| 10678 | 10814 | "created_at": "2026-10-08T09:00:00.000Z", | |
| 10679 | 10815 | "created_by": null, | |
| 10680 | 10816 | "last_used_at": null, | |
| 10681 | 10817 | "expires_at": "2026-11-07T09:00:00.000Z", | |
| 10682 | − | "scopes": ["repo:read", "repo:write", "code:read", "code:write"], | |
| 10683 | − | "resource_owner": "flagon-io", | |
| 10818 | + | "scopes": ["repo:read", "code:write"], | |
| 10819 | + | "workspace": "flagon-io", | |
| 10684 | 10820 | "repository_selection": "selected", | |
| 10685 | 10821 | "repositories": ["flagon-io/hello"], | |
| 10686 | − | "permissions": { "contents": "write", "metadata": "read" }, | |
| 10822 | + | "permissions": { "code": "write", "repo": "read" }, | |
| 10687 | 10823 | "status": "pending", | |
| 10688 | 10824 | "review_reason": null, | |
| 10689 | 10825 | "reaches": false, | |
| ⋯ | |||
| 10703 | 10839 | "id": "tok_01HZX3K2M9V7Q4N8B6D5C3A2E1", | |
| 10704 | 10840 | "name": "release-bot", | |
| 10705 | 10841 | "owner": "ada", | |
| 10706 | − | "kind": "fine_grained", | |
| 10707 | − | "description": "Publishes releases from CI", | |
| 10842 | + | "description": "Publishes releases from CI", | |
| 10708 | 10843 | "created_at": "2026-10-08T09:00:00.000Z", | |
| 10709 | 10844 | "created_by": null, | |
| 10710 | 10845 | "last_used_at": null, | |
| 10711 | 10846 | "expires_at": "2026-11-07T09:00:00.000Z", | |
| 10712 | − | "scopes": ["repo:read", "repo:write", "code:read", "code:write"], | |
| 10713 | − | "resource_owner": "flagon-io", | |
| 10847 | + | "scopes": ["repo:read", "code:write"], | |
| 10848 | + | "workspace": "flagon-io", | |
| 10714 | 10849 | "repository_selection": "selected", | |
| 10715 | 10850 | "repositories": ["flagon-io/hello"], | |
| 10716 | − | "permissions": { "contents": "write", "metadata": "read" }, | |
| 10851 | + | "permissions": { "code": "write", "repo": "read" }, | |
| 10717 | 10852 | "status": "active", | |
| 10718 | 10853 | "review_reason": null, | |
| 10719 | 10854 | "reaches": true, | |
| ⋯ | |||
| 10796 | 10931 | "kind": "user", | |
| 10797 | 10932 | "name": "ada", | |
| 10798 | 10933 | "username": "ada", | |
| 10934 | + | "display_username": "Ada", | |
| 10799 | 10935 | "avatar": "5f2b8c1d9e7a3f6b4c0d2e8a1b9c7d5e3f1a0b2c4d6e8f0a1b3c5d7e9f0a2b4c", | |
| 10800 | 10936 | "commits": 52, | |
| 10801 | 10937 | "first_at": "2026-09-28T14:11:52.000Z", | |
| ⋯ | |||
| 10854 | 10990 | "response": [ | |
| 10855 | 10991 | { | |
| 10856 | 10992 | "username": "ada", | |
| 10993 | + | "display_username": "Ada", | |
| 10857 | 10994 | "avatar": null, | |
| 10858 | 10995 | "starred_at": "2026-10-06T18:30:00.000Z" | |
| 10859 | 10996 | }, | |
| 10860 | 10997 | { | |
| 10861 | 10998 | "username": "sam", | |
| 10999 | + | "display_username": "sam", | |
| 10862 | 11000 | "avatar": null, | |
| 10863 | 11001 | "starred_at": "2026-10-02T09:12:00.000Z" | |
| 10864 | 11002 | } | |
| 210 | 210 | through::<g1t_contracts::identity::Invite>(op, sent) | |
| 211 | 211 | } | |
| 212 | 212 | Op::ListWorkspaceInvites => through::<Vec<g1t_contracts::identity::Invite>>(op, sent), | |
| 213 | + | Op::ListInvitations => through::<Vec<g1t_contracts::identity::WorkspaceInvitation>>(op, sent), | |
| 213 | 214 | Op::ListNotifications => through::<g1t_contracts::inbox::InboxPage>(op, sent), | |
| 214 | 215 | Op::GetNotificationThread | Op::MarkThreadRead | Op::MarkThreadDone | Op::SaveThread | Op::SnoozeThread => { | |
| 215 | 216 | through::<g1t_contracts::inbox::InboxThread>(op, sent) | |
| ⋯ | |||
| 267 | 268 | assert!(wire::camel_case_keys(example).is_empty(), "{method} {path}"); | |
| 268 | 269 | continue; | |
| 269 | 270 | }; | |
| 270 | − | let sample = sample(op, example); | |
| 271 | + | let mut sample = sample(op, example); | |
| 272 | + | // Each person gets their chosen case on the way out (people.rs). | |
| 273 | + | crate::people::fill(&mut sample, &std::collections::HashMap::new()); | |
| 271 | 274 | converted += wire::camel_case_keys(&sample).len(); | |
| 272 | 275 | let sent = wire::snake_case(sample); | |
| 273 | 276 | let leaked = wire::camel_case_keys(&sent); | |
| 51 | 51 | route("GET", "/user/invites", Op::ListInvites, &[]), | |
| 52 | 52 | route("POST", "/user/invites", Op::CreateInvite, &[]), | |
| 53 | 53 | route("DELETE", "/user/invites/:id", Op::RevokeInvite, &[]), | |
| 54 | + | // Invitations to workspaces waiting for your answer. | |
| 55 | + | route("GET", "/user/invitations", Op::ListInvitations, &[]), | |
| 56 | + | route("POST", "/user/invitations/:id/accept", Op::AcceptInvitation, &[]), | |
| 57 | + | route("POST", "/user/invitations/:id/decline", Op::DeclineInvitation, &[]), | |
| 54 | 58 | route("GET", "/workspaces/:workspace/invitations", Op::ListWorkspaceInvites, &[]), | |
| 55 | 59 | // A workspace's rules for personal access tokens, and its members' tokens. | |
| 56 | 60 | route("GET", "/workspaces/:workspace/personal-access-token-policy", Op::Tokens(TokenOp::GetTokenPolicy), &[]), | |
| 57 | 61 | route("PATCH", "/workspaces/:workspace/personal-access-token-policy", Op::Tokens(TokenOp::SetTokenPolicy), &[]), | |
| 58 | − | route("GET", "/workspaces/:workspace/personal-access-tokens", Op::Tokens(TokenOp::ListMemberTokens), &[("kind", "kind")]), | |
| 62 | + | route("GET", "/workspaces/:workspace/personal-access-tokens", Op::Tokens(TokenOp::ListMemberTokens), &[]), | |
| 59 | 63 | route("POST", "/workspaces/:workspace/personal-access-tokens/:id", Op::Tokens(TokenOp::RevokeMemberToken), &[]), | |
| 60 | 64 | route("GET", "/workspaces/:workspace/personal-access-token-requests", Op::Tokens(TokenOp::ListTokenRequests), &[]), | |
| 61 | 65 | route("POST", "/workspaces/:workspace/personal-access-token-requests/:id", Op::Tokens(TokenOp::ReviewTokenRequest), &[]), |
| 1 | 1 | //! A workspace's rules for personal access tokens, over REST and MCP: the | |
| 2 | − | //! policy (which kinds reach it, approval, lifetime), the members' tokens | |
| 3 | − | //! that reach it, approving or denying fine-grained tokens that wait for | |
| 2 | + | //! policy (which tokens reach it, approval, lifetime), the members' tokens | |
| 3 | + | //! that reach it, approving or denying tokens made for it that wait for | |
| 4 | 4 | //! approval, and revoking a token there. Identity decides and keeps all of | |
| 5 | 5 | //! it (services/identity/src/token_reach.rs); owners only, as people. | |
| 6 | 6 | ||
| ⋯ | |||
| 50 | 50 | TokenOp::GetTokenPolicy => "Get a workspace's personal access token policy", | |
| 51 | 51 | TokenOp::SetTokenPolicy => "Set a workspace's personal access token policy", | |
| 52 | 52 | TokenOp::ListMemberTokens => "List the personal access tokens that reach a workspace", | |
| 53 | − | TokenOp::ListTokenRequests => "List fine-grained tokens waiting for approval", | |
| 54 | − | TokenOp::ReviewTokenRequest => "Approve or deny a fine-grained token", | |
| 53 | + | TokenOp::ListTokenRequests => "List personal access tokens waiting for approval", | |
| 54 | + | TokenOp::ReviewTokenRequest => "Approve or deny a personal access token", | |
| 55 | 55 | TokenOp::RevokeMemberToken => "Revoke a member's token in a workspace", | |
| 56 | 56 | } | |
| 57 | 57 | } | |
| 58 | 58 | ||
| 59 | 59 | pub fn description(self) -> &'static str { | |
| 60 | 60 | match self { | |
| 61 | − | TokenOp::GetTokenPolicy => "A workspace's rules for its members' personal access tokens: allow_classic (classic tokens reach it), allow_fine_grained (fine-grained tokens may name it as their resource owner), require_approval (a fine-grained token naming it waits for an owner's approval; true unless an owner says, and never for an owner's own token), max_lifetime_days (the longest a token reaching it may last; null for no limit, and a fine-grained token lasts at most 366 days anyway) and forbid_no_expiry (a token that never expires does not reach it). A token outside the rules keeps working elsewhere and reaches the workspace's public repositories only. Members only.", | |
| 61 | + | TokenOp::GetTokenPolicy => "A workspace's rules for its members' personal access tokens: allow_tokens_for_all_workspaces (a token made for every workspace of its owner reaches this one), allow_tokens_for_this_workspace (a token may be made for this workspace alone), require_approval (a token made for this workspace waits for an owner's approval; true unless an owner says, and never for an owner's own token), max_lifetime_days (the longest a token reaching it may last; null for no limit, and a token with an expiry lasts at most 366 days anyway) and forbid_no_expiry (a token that never expires does not reach it). A token outside the rules keeps working elsewhere and reaches the workspace's public repositories only. Members only.", | |
| 62 | 62 | TokenOp::SetTokenPolicy => "Change a workspace's rules for personal access tokens; fields left out stay as they are. max_lifetime_days of 0 removes the limit. The rules apply from each token's next request, to tokens made before them too. Owners only, as people.", | |
| 63 | − | TokenOp::ListMemberTokens => "The personal access tokens of the workspace's members and outside collaborators that can reach it: every fine-grained token naming it as its resource owner, whatever its status, and every classic token that has not expired. Each with its owner, kind, name, scopes, a fine-grained token's permissions, repository_selection, repositories and status (active, pending, denied or revoked), when it was made, last used and expires, and whether it reaches the workspace now (reaches, and blocked_by when not: pending approval, denied, revoked, classic tokens not allowed, lasts too long, never expires). Never the token itself. kind narrows it to classic or fine_grained. Owners only, as people.", | |
| 64 | − | TokenOp::ListTokenRequests => "The fine-grained tokens naming the workspace that wait for an owner's approval, as list_member_tokens shows them. Until approved, a token reaches public repositories only. Owners only, as people.", | |
| 65 | − | TokenOp::ReviewTokenRequest => "Approve or deny a fine-grained token waiting for approval: decision is approve or deny, and reason, if given, is shown to the token's owner, who hears of it in their inbox. An approved token reaches the workspace from its next request; a denied one reaches public repositories only. Recorded in the audit log as token.approved or token.denied. Owners only, as people.", | |
| 66 | − | TokenOp::RevokeMemberToken => "Take a member's token out of the workspace, with an optional reason its owner is shown. A fine-grained token naming the workspace stops reaching it for good; a classic token keeps working everywhere else but never reaches this workspace again. Recorded in the audit log as token.revoked. Owners only, as people.", | |
| 63 | + | TokenOp::ListMemberTokens => "The personal access tokens of the workspace's members and outside collaborators that can reach it and have not expired: every token made for this workspace, whatever its status, and every token made for all of its owner's workspaces. Each with its owner, name, description, permissions (each resource at its level, such as {\"issues\": \"write\"}), scopes, workspace (the one it is made for; null for all of its owner's), repository_selection (all, selected or public), repositories, status (active, pending, denied or revoked), when it was made, last used and expires, and whether it reaches the workspace now (reaches, and blocked_by when not: pending approval, denied, revoked, tokens for all workspaces not allowed, tokens made for this workspace not allowed, lasts too long, never expires). Never the token itself. Owners only, as people.", | |
| 64 | + | TokenOp::ListTokenRequests => "The tokens made for the workspace that wait for an owner's approval, as list_member_tokens shows them. Until approved, a token reaches public repositories only. Owners only, as people.", | |
| 65 | + | TokenOp::ReviewTokenRequest => "Approve or deny a token waiting for approval: decision is approve or deny, and reason, if given, is shown to the token's owner, who hears of it in their inbox. An approved token reaches the workspace from its next request; a denied one reaches public repositories only. Recorded in the audit log as token.approved or token.denied. Owners only, as people.", | |
| 66 | + | TokenOp::RevokeMemberToken => "Take a member's token out of the workspace, with an optional reason its owner is shown. A token made for this workspace stops reaching it for good; a token made for all of its owner's workspaces keeps working everywhere else but never reaches this one again. Recorded in the audit log as token.revoked. Owners only, as people.", | |
| 67 | 67 | } | |
| 68 | 68 | } | |
| 69 | 69 | ||
| ⋯ | |||
| 78 | 78 | let id = json!({ "type": "string", "description": "The token's id, tok_…." }); | |
| 79 | 79 | let reason = json!({ "type": "string", "description": "Why, shown to the token's owner." }); | |
| 80 | 80 | let (properties, required): (Value, &[&str]) = match self { | |
| 81 | − | TokenOp::GetTokenPolicy | TokenOp::ListTokenRequests => (json!({ "workspace": workspace }), &["workspace"]), | |
| 81 | + | TokenOp::GetTokenPolicy | TokenOp::ListTokenRequests | TokenOp::ListMemberTokens => (json!({ "workspace": workspace }), &["workspace"]), | |
| 82 | 82 | TokenOp::SetTokenPolicy => ( | |
| 83 | 83 | json!({ | |
| 84 | 84 | "workspace": workspace, | |
| 85 | − | "allow_classic": { "type": "boolean", "description": "Classic tokens reach the workspace." }, | |
| 86 | − | "allow_fine_grained": { "type": "boolean", "description": "Fine-grained tokens may name the workspace as their resource owner." }, | |
| 87 | − | "require_approval": { "type": "boolean", "description": "A fine-grained token naming the workspace waits for an owner's approval." }, | |
| 85 | + | "allow_tokens_for_all_workspaces": { "type": "boolean", "description": "A token made for every workspace of its owner reaches this one." }, | |
| 86 | + | "allow_tokens_for_this_workspace": { "type": "boolean", "description": "A token may be made for this workspace alone." }, | |
| 87 | + | "require_approval": { "type": "boolean", "description": "A token made for this workspace waits for an owner's approval." }, | |
| 88 | 88 | "max_lifetime_days": { "type": "integer", "description": "The longest a token reaching it may last, in days, 1 to 3650; 0 for no limit." }, | |
| 89 | 89 | "forbid_no_expiry": { "type": "boolean", "description": "A token that never expires does not reach the workspace." }, | |
| 90 | 90 | }), | |
| 91 | 91 | &["workspace"], | |
| 92 | 92 | ), | |
| 93 | − | TokenOp::ListMemberTokens => ( | |
| 94 | − | json!({ | |
| 95 | − | "workspace": workspace, | |
| 96 | − | "kind": { "type": "string", "enum": ["classic", "fine_grained"], "description": "Only tokens of this kind." }, | |
| 97 | − | }), | |
| 98 | − | &["workspace"], | |
| 99 | − | ), | |
| 100 | 93 | TokenOp::ReviewTokenRequest => ( | |
| 101 | 94 | json!({ | |
| 102 | 95 | "workspace": workspace, | |
| ⋯ | |||
| 138 | 131 | } | |
| 139 | 132 | } | |
| 140 | 133 | ||
| 141 | − | /// A member's token in one flat shape: the token's fields, a fine-grained | |
| 142 | − | /// token's beside them, and its owner and whether it reaches the workspace. | |
| 143 | − | /// Keys stay as identity sends them (`camelCase`); the API's converter | |
| 144 | − | /// writes them out in `snake_case`. | |
| 134 | + | /// A member's token in one flat shape: the token's fields, and its owner | |
| 135 | + | /// and whether it reaches the workspace. Keys stay as identity sends them | |
| 136 | + | /// (`camelCase`); the API's converter writes them out in `snake_case`. | |
| 145 | 137 | pub(crate) fn member_view(member: &Value) -> Value { | |
| 146 | 138 | let mut out = Map::new(); | |
| 147 | 139 | if let Some(token) = member["token"].as_object() { | |
| 148 | 140 | for (key, value) in token { | |
| 149 | − | if key != "fineGrained" && key != "legacy" { | |
| 141 | + | if key != "legacy" && key != "workspaceOwned" && key != "admin" { | |
| 150 | 142 | out.insert(key.clone(), value.clone()); | |
| 151 | − | } | |
| 152 | − | } | |
| 153 | − | if let Some(details) = token.get("fineGrained").and_then(Value::as_object) { | |
| 154 | − | for (key, value) in details { | |
| 155 | − | let key = if key == "workspace" { "resourceOwner".to_owned() } else { key.clone() }; | |
| 156 | − | out.insert(key, value.clone()); | |
| 157 | 143 | } | |
| 158 | 144 | } | |
| 159 | 145 | } | |
| ⋯ | |||
| 196 | 182 | &json!({ | |
| 197 | 183 | "actor": actor, | |
| 198 | 184 | "slug": workspace, | |
| 199 | − | "allow_classic": flag(input, "allow_classic"), | |
| 200 | − | "allow_fine_grained": flag(input, "allow_fine_grained"), | |
| 185 | + | "allow_tokens_for_all_workspaces": flag(input, "allow_tokens_for_all_workspaces"), | |
| 186 | + | "allow_tokens_for_this_workspace": flag(input, "allow_tokens_for_this_workspace"), | |
| 201 | 187 | "require_approval": flag(input, "require_approval"), | |
| 202 | 188 | "max_lifetime_days": days, | |
| 203 | 189 | "forbid_no_expiry": flag(input, "forbid_no_expiry"), | |
| ⋯ | |||
| 207 | 193 | .await? | |
| 208 | 194 | } | |
| 209 | 195 | TokenOp::ListMemberTokens | TokenOp::ListTokenRequests => { | |
| 210 | − | let kind = text(input, "kind"); | |
| 211 | − | if kind.as_deref().is_some_and(|kind| kind != "classic" && kind != "fine_grained") { | |
| 212 | − | return Ok(Outcome::fail(FailureCode::Invalid, "kind is classic or fine_grained.")); | |
| 213 | − | } | |
| 214 | 196 | let status = (op == TokenOp::ListTokenRequests).then_some("pending"); | |
| 215 | − | let kind = if op == TokenOp::ListTokenRequests { Some("fine_grained".to_owned()) } else { kind }; | |
| 216 | − | let members: Outcome<Value> = | |
| 217 | − | g1t_kit::call(identity, "list_member_tokens", &json!({ "actor": actor, "slug": workspace, "status": status, "kind": kind })).await?; | |
| 197 | + | let members: Outcome<Value> = g1t_kit::call(identity, "list_member_tokens", &json!({ "actor": actor, "slug": workspace, "status": status })).await?; | |
| 218 | 198 | map(members, list) | |
| 219 | 199 | } | |
| 220 | 200 | TokenOp::ReviewTokenRequest => { | |
| ⋯ | |||
| 275 | 255 | "token": { | |
| 276 | 256 | "id": "tok_1", "name": "ci", "createdAt": "2026-10-08T00:00:00.000Z", "lastUsedAt": null, | |
| 277 | 257 | "createdBy": null, "scopes": ["repo:read", "code:read"], "legacy": false, "expiresAt": "2026-11-07T00:00:00.000Z", | |
| 278 | − | "kind": "fine_grained", | |
| 279 | − | "fineGrained": { "workspace": "acme", "repositorySelection": "selected", "repositories": ["acme/web"], "permissions": { "contents": "read", "metadata": "read" }, "status": "pending" }, | |
| 258 | + | "permissions": { "code": "read", "repo": "read" }, | |
| 259 | + | "workspace": "acme", "repositorySelection": "selected", "repositories": ["acme/web"], "status": "pending", | |
| 280 | 260 | }, | |
| 281 | 261 | })); | |
| 282 | 262 | assert_eq!(view["owner"], "ana"); | |
| 283 | − | assert_eq!(view["resourceOwner"], "acme"); | |
| 263 | + | assert_eq!(view["workspace"], "acme"); | |
| 284 | 264 | assert_eq!(view["repositorySelection"], "selected"); | |
| 285 | − | assert_eq!(view["permissions"]["contents"], "read"); | |
| 265 | + | assert_eq!(view["permissions"]["code"], "read"); | |
| 286 | 266 | assert_eq!(view["status"], "pending"); | |
| 287 | 267 | assert_eq!(view["blockedBy"], "pending approval"); | |
| 288 | − | assert!(view.get("fineGrained").is_none() && view.get("legacy").is_none()); | |
| 268 | + | assert!(view.get("legacy").is_none()); | |
| 289 | 269 | } | |
| 290 | 270 | ||
| 291 | 271 | #[test] | |
| 10 | 10 | //! `FinalizeCacheEntryUpload`, `GetCacheEntryDownloadURL`) and the | |
| 11 | 11 | //! artifacts' (`CreateArtifact`, `FinalizeArtifact`, `ListArtifacts`, | |
| 12 | 12 | //! `GetSignedArtifactURL`, `DeleteArtifact`). JSON, the toolkit's field | |
| 13 | − | //! names. | |
| 13 | + | //! names; the cache's methods also in protobuf (`application/protobuf`), | |
| 14 | + | //! which other clients of the protocol send (sccache, through OpenDAL). | |
| 14 | 15 | //! - The cache's older protocol, at `{ACTIONS_CACHE_URL}_apis/artifactcache/…`, | |
| 15 | 16 | //! which the toolkit's client uses whenever the server it runs against is | |
| 16 | − | //! not github.com: on g1t, that is the one it uses. | |
| 17 | + | //! not github.com: on g1t, that is the one it uses, and sccache's too. | |
| 18 | + | //! Its entries are sent in 32 MB chunks, or in one chunk of any size. | |
| 17 | 19 | //! - Blobs, at `/actions/toolkit/blobs/{token}`: the signed links those | |
| 18 | − | //! hand out. Downloads are a plain GET. Uploads speak the part of Azure | |
| 19 | − | //! Blob Storage's protocol the toolkit's client uses (Put Blob, Put | |
| 20 | − | //! Block, Put Block List), mapped onto an R2 multipart upload: a block's | |
| 21 | − | //! id ends in its index, which is its part's number. | |
| 20 | + | //! hand out. Downloads are a GET, of the whole blob or of one byte range | |
| 21 | + | //! (`Range`), as the toolkit's client fetches large entries in segments. | |
| 22 | + | //! Uploads speak the part of Azure Blob Storage's protocol the toolkit's | |
| 23 | + | //! client uses (Put Blob, Put Block, Put Block List), mapped onto an R2 | |
| 24 | + | //! multipart upload: a block's id ends in its index, which is its part's | |
| 25 | + | //! number. An upload link carries a query, as an Azure SAS link does, | |
| 26 | + | //! which clients that sign their requests with it need. | |
| 22 | 27 | //! | |
| 23 | 28 | //! Every call carries the job's runtime token; the actions service checks | |
| 24 | 29 | //! it and keeps the entries (cache.rs, artifacts.rs, runtime.rs there). | |
| ⋯ | |||
| 85 | 90 | ||
| 86 | 91 | // ── Twirp ─────────────────────────────────────────────────────────────────── | |
| 87 | 92 | ||
| 93 | + | /// Twirp's binary encoding. | |
| 94 | + | const PROTOBUF: &str = "application/protobuf"; | |
| 95 | + | ||
| 96 | + | /// Whether a request's `Content-Type` is Twirp's protobuf encoding. | |
| 97 | + | fn is_protobuf(content_type: &str) -> bool { | |
| 98 | + | let kind = content_type.split(';').next().unwrap_or_default().trim().to_ascii_lowercase(); | |
| 99 | + | kind == PROTOBUF || kind == "application/x-protobuf" | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | /// The cache service's messages in protobuf, read into and written from the | |
| 103 | + | /// JSON the handlers use (`results/api/v1/cache.proto`, field numbers as | |
| 104 | + | /// there). Only what the cache's three methods carry: strings, a repeated | |
| 105 | + | /// string, an int64 and a bool. `metadata` (field 1 of each request) is | |
| 106 | + | /// skipped: the runtime token says whose cache it is. | |
| 107 | + | mod proto { | |
| 108 | + | use serde_json::{Map, Value}; | |
| 109 | + | ||
| 110 | + | #[derive(Clone, Copy)] | |
| 111 | + | enum Kind { | |
| 112 | + | Text, | |
| 113 | + | Texts, | |
| 114 | + | Int, | |
| 115 | + | Bool, | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | /// A message's fields: number, JSON name, kind. | |
| 119 | + | type Fields = &'static [(u64, &'static str, Kind)]; | |
| 120 | + | ||
| 121 | + | fn request_fields(method: &str) -> Option<Fields> { | |
| 122 | + | Some(match method { | |
| 123 | + | "CreateCacheEntry" => &[(2, "key", Kind::Text), (3, "version", Kind::Text)], | |
| 124 | + | "FinalizeCacheEntryUpload" => &[(2, "key", Kind::Text), (3, "size_bytes", Kind::Int), (4, "version", Kind::Text)], | |
| 125 | + | "GetCacheEntryDownloadURL" => &[(2, "key", Kind::Text), (3, "restore_keys", Kind::Texts), (4, "version", Kind::Text)], | |
| 126 | + | _ => return None, | |
| 127 | + | }) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | fn response_fields(method: &str) -> Fields { | |
| 131 | + | match method { | |
| 132 | + | "CreateCacheEntry" => &[(1, "ok", Kind::Bool), (2, "signed_upload_url", Kind::Text), (3, "message", Kind::Text)], | |
| 133 | + | "FinalizeCacheEntryUpload" => &[(1, "ok", Kind::Bool), (2, "entry_id", Kind::Int), (3, "message", Kind::Text)], | |
| 134 | + | "GetCacheEntryDownloadURL" => &[(1, "ok", Kind::Bool), (2, "signed_download_url", Kind::Text), (3, "matched_key", Kind::Text)], | |
| 135 | + | _ => &[], | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | fn varint(bytes: &[u8], at: &mut usize) -> Option<u64> { | |
| 140 | + | let mut value = 0u64; | |
| 141 | + | for shift in (0..64).step_by(7) { | |
| 142 | + | let byte = *bytes.get(*at)?; | |
| 143 | + | *at += 1; | |
| 144 | + | value |= u64::from(byte & 0x7f) << shift; | |
| 145 | + | if byte & 0x80 == 0 { | |
| 146 | + | return Some(value); | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | None | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | fn put_varint(out: &mut Vec<u8>, mut value: u64) { | |
| 153 | + | while value >= 0x80 { | |
| 154 | + | out.push((value as u8 & 0x7f) | 0x80); | |
| 155 | + | value >>= 7; | |
| 156 | + | } | |
| 157 | + | out.push(value as u8); | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | /// A request of `method` as JSON, or None when it is not one. | |
| 161 | + | pub fn request(method: &str, bytes: &[u8]) -> Option<Value> { | |
| 162 | + | let fields = request_fields(method)?; | |
| 163 | + | let mut out = Map::new(); | |
| 164 | + | let mut at = 0; | |
| 165 | + | while at < bytes.len() { | |
| 166 | + | let tag = varint(bytes, &mut at)?; | |
| 167 | + | let (number, wire) = (tag >> 3, tag & 7); | |
| 168 | + | let known = fields.iter().find(|(n, _, _)| *n == number); | |
| 169 | + | match wire { | |
| 170 | + | 0 => { | |
| 171 | + | let value = varint(bytes, &mut at)?; | |
| 172 | + | if let Some((_, name, Kind::Int)) = known { | |
| 173 | + | // An int64 is sent as its two's complement. | |
| 174 | + | out.insert((*name).to_owned(), Value::String((value as i64).to_string())); | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | 2 => { | |
| 178 | + | let length = usize::try_from(varint(bytes, &mut at)?).ok()?; | |
| 179 | + | let end = at.checked_add(length).filter(|end| *end <= bytes.len())?; | |
| 180 | + | let raw = &bytes[at..end]; | |
| 181 | + | at = end; | |
| 182 | + | match known { | |
| 183 | + | Some((_, name, Kind::Text)) => { | |
| 184 | + | out.insert((*name).to_owned(), Value::String(String::from_utf8(raw.to_vec()).ok()?)); | |
| 185 | + | } | |
| 186 | + | Some((_, name, Kind::Texts)) => { | |
| 187 | + | let text = Value::String(String::from_utf8(raw.to_vec()).ok()?); | |
| 188 | + | match out.entry((*name).to_owned()).or_insert_with(|| Value::Array(Vec::new())) { | |
| 189 | + | Value::Array(list) => list.push(text), | |
| 190 | + | _ => return None, | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | _ => {} | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | 1 => at = at.checked_add(8).filter(|end| *end <= bytes.len())?, | |
| 197 | + | 5 => at = at.checked_add(4).filter(|end| *end <= bytes.len())?, | |
| 198 | + | _ => return None, | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | Some(Value::Object(out)) | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | /// A response of `method` from its JSON. Defaults are left out, as | |
| 205 | + | /// proto3 does. | |
| 206 | + | pub fn response(method: &str, value: &Value) -> Vec<u8> { | |
| 207 | + | let mut out = Vec::new(); | |
| 208 | + | for (number, name, kind) in response_fields(method) { | |
| 209 | + | let field = &value[*name]; | |
| 210 | + | match kind { | |
| 211 | + | Kind::Bool if field.as_bool() == Some(true) => { | |
| 212 | + | put_varint(&mut out, number << 3); | |
| 213 | + | put_varint(&mut out, 1); | |
| 214 | + | } | |
| 215 | + | Kind::Int => { | |
| 216 | + | let n = field.as_i64().or_else(|| field.as_str().and_then(|s| s.parse().ok())).unwrap_or(0); | |
| 217 | + | if n != 0 { | |
| 218 | + | put_varint(&mut out, number << 3); | |
| 219 | + | put_varint(&mut out, n as u64); | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | Kind::Text => { | |
| 223 | + | let text = field.as_str().unwrap_or_default(); | |
| 224 | + | if !text.is_empty() { | |
| 225 | + | put_varint(&mut out, (number << 3) | 2); | |
| 226 | + | put_varint(&mut out, text.len() as u64); | |
| 227 | + | out.extend_from_slice(text.as_bytes()); | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | _ => {} | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | out | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | ||
| 88 | 237 | /// A Twirp error: its code and message, at the status Twirp gives it. | |
| 89 | 238 | fn twirp_error(code: &str, message: &str) -> Result<Response> { | |
| 90 | 239 | let status = match code { | |
| ⋯ | |||
| 92 | 241 | "permission_denied" => 403, | |
| 93 | 242 | "not_found" => 404, | |
| 94 | 243 | "already_exists" => 409, | |
| 95 | − | "invalid_argument" => 400, | |
| 244 | + | "invalid_argument" | "malformed" => 400, | |
| 245 | + | "bad_route" => 404, | |
| 96 | 246 | "failed_precondition" => 412, | |
| 97 | 247 | "resource_exhausted" => 429, | |
| 98 | 248 | _ => 500, | |
| ⋯ | |||
| 155 | 305 | }) | |
| 156 | 306 | } | |
| 157 | 307 | ||
| 308 | + | /// The Azure Storage version g1t's blob links answer as. | |
| 309 | + | const AZURE_VERSION: &str = "2024-11-04"; | |
| 310 | + | ||
| 311 | + | /// An upload link: the blob's, with a query as an Azure SAS link has one. | |
| 312 | + | /// A client that treats it as a container, a blob and a SAS token (OpenDAL, | |
| 313 | + | /// which sccache uses) refuses a link without one; the token in the path | |
| 314 | + | /// is what g1t checks. | |
| 315 | + | pub fn upload_url(api: &str, blob: &str) -> String { | |
| 316 | + | format!("{}?sv={AZURE_VERSION}", blob_url(api, blob)) | |
| 317 | + | } | |
| 318 | + | ||
| 158 | 319 | /// Starts an R2 upload for an entry the service reserved, and the signed | |
| 159 | 320 | /// link the toolkit sends it to. | |
| 160 | 321 | async fn start_upload(bucket: &Bucket, services: &Services, job: &str, token: &str, kind: &str, id: &str, object: &str) -> Result<Outcome<String>> { | |
| ⋯ | |||
| 167 | 328 | ) | |
| 168 | 329 | .await?; | |
| 169 | 330 | Ok(match signed { | |
| 170 | − | Outcome::Ok(blob) => Outcome::Ok(blob_url(&services.addresses.api, &blob)), | |
| 331 | + | Outcome::Ok(blob) => Outcome::Ok(upload_url(&services.addresses.api, &blob)), | |
| 171 | 332 | Outcome::Fail(refused) => Outcome::Fail(refused), | |
| 172 | 333 | }) | |
| 173 | 334 | } | |
| 174 | 335 | ||
| 175 | − | /// `POST /twirp/{service}/{method}`. | |
| 176 | − | pub async fn twirp(mut request: Request, env: &Env, services: &Services, service: &str, method: &str) -> Result<Response> { | |
| 336 | + | /// `POST /twirp/{service}/{method}`. A failure inside is logged and | |
| 337 | + | /// answered as Twirp's `internal`, with its cause. | |
| 338 | + | pub async fn twirp(request: Request, env: &Env, services: &Services, service: &str, method: &str) -> Result<Response> { | |
| 339 | + | match twirp_inner(request, env, services, service, method).await { | |
| 340 | + | Ok(response) => Ok(response), | |
| 341 | + | Err(error) => twirp_error("internal", &failed(&format!("POST /twirp/{service}/{method}"), &error)), | |
| 342 | + | } | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | async fn twirp_inner(mut request: Request, env: &Env, services: &Services, service: &str, method: &str) -> Result<Response> { | |
| 177 | 346 | let token = bearer(&request); | |
| 178 | 347 | let Some(job) = runtime_job(&token) else { | |
| 179 | 348 | return twirp_error("unauthenticated", "Send the job's ACTIONS_RUNTIME_TOKEN as a bearer token."); | |
| 180 | 349 | }; | |
| 181 | − | let body: Value = request.json().await.unwrap_or(Value::Null); | |
| 350 | + | // Twirp clients send JSON or protobuf, and are answered in kind. | |
| 351 | + | let binary = is_protobuf(&request.headers().get("content-type")?.unwrap_or_default()); | |
| 352 | + | let body: Value = if binary { | |
| 353 | + | match proto::request(method, &request.bytes().await.unwrap_or_default()) { | |
| 354 | + | Some(body) => body, | |
| 355 | + | None => return twirp_error("malformed", "That is not a protobuf message this method takes."), | |
| 356 | + | } | |
| 357 | + | } else { | |
| 358 | + | request.json().await.unwrap_or(Value::Null) | |
| 359 | + | }; | |
| 360 | + | let answer = |value: Value| -> Result<Response> { | |
| 361 | + | if binary { | |
| 362 | + | let mut response = Response::from_bytes(proto::response(method, &value))?; | |
| 363 | + | response.headers_mut().set("content-type", PROTOBUF)?; | |
| 364 | + | Ok(response) | |
| 365 | + | } else { | |
| 366 | + | Response::from_json(&value) | |
| 367 | + | } | |
| 368 | + | }; | |
| 182 | 369 | let bucket = env.bucket("ACTIONS_CACHE")?; | |
| 183 | 370 | let actions = &services.actions; | |
| 184 | 371 | let (run, own_job) = backend_ids(&token); | |
| ⋯ | |||
| 201 | 388 | let found: Outcome<Option<CacheHit>> = g1t_kit::call(actions, "cache_lookup", &args).await?; | |
| 202 | 389 | match found { | |
| 203 | 390 | Outcome::Ok(Some(CacheHit { key, blob: Some(blob), .. })) => { | |
| 204 | − | Response::from_json(&json!({ "ok": true, "signed_download_url": blob_url(&services.addresses.api, &blob), "matched_key": key })) | |
| 391 | + | answer(json!({ "ok": true, "signed_download_url": blob_url(&services.addresses.api, &blob), "matched_key": key })) | |
| 205 | 392 | } | |
| 206 | − | Outcome::Ok(_) => Response::from_json(&json!({ "ok": false, "signed_download_url": "", "matched_key": "" })), | |
| 393 | + | Outcome::Ok(_) => answer(json!({ "ok": false, "signed_download_url": "", "matched_key": "" })), | |
| 207 | 394 | Outcome::Fail(refused) => twirp_failure(&refused), | |
| 208 | 395 | } | |
| 209 | 396 | } | |
| ⋯ | |||
| 212 | 399 | let reserved: Outcome<CacheReservation> = g1t_kit::call(actions, "cache_reserve", &args).await?; | |
| 213 | 400 | let reserved = match reserved { | |
| 214 | 401 | Outcome::Ok(reserved) => reserved, | |
| 215 | − | // The client warns with this and goes on, as for a key | |
| 216 | − | // another job is saving. | |
| 217 | − | Outcome::Fail(refused) => return Response::from_json(&json!({ "ok": false, "signed_upload_url": "", "message": refused.message })), | |
| 402 | + | // A key already saved, or being saved by another job, to a | |
| 403 | + | // protobuf client (OpenDAL's) is Twirp's `already_exists` | |
| 404 | + | // (409), which it takes as "someone else has it": sccache | |
| 405 | + | // then still writes. An `ok: false` would read as a broken | |
| 406 | + | // cache, and sccache would only read from it. | |
| 407 | + | Outcome::Fail(refused) if binary && refused.code == FailureCode::Conflict => return twirp_failure(&refused), | |
| 408 | + | // The toolkit's client logs this ("another job may be | |
| 409 | + | // creating this cache") and goes on. | |
| 410 | + | Outcome::Fail(refused) => return answer(json!({ "ok": false, "signed_upload_url": "", "message": refused.message })), | |
| 218 | 411 | }; | |
| 219 | 412 | match start_upload(&bucket, services, &job, &token, "cache", &reserved.id, &reserved.object).await? { | |
| 220 | − | Outcome::Ok(url) => Response::from_json(&json!({ "ok": true, "signed_upload_url": url })), | |
| 221 | − | Outcome::Fail(refused) => Response::from_json(&json!({ "ok": false, "signed_upload_url": "", "message": refused.message })), | |
| 413 | + | Outcome::Ok(url) => answer(json!({ "ok": true, "signed_upload_url": url })), | |
| 414 | + | Outcome::Fail(refused) => answer(json!({ "ok": false, "signed_upload_url": "", "message": refused.message })), | |
| 222 | 415 | } | |
| 223 | 416 | } | |
| 224 | 417 | (CACHE_SERVICE, "FinalizeCacheEntryUpload") => { | |
| ⋯ | |||
| 226 | 419 | let pending: Outcome<CacheReservation> = g1t_kit::call(actions, "cache_upload", &args).await?; | |
| 227 | 420 | let pending = match pending { | |
| 228 | 421 | Outcome::Ok(pending) => pending, | |
| 229 | − | Outcome::Fail(refused) => return Response::from_json(&json!({ "ok": false, "entry_id": "0", "message": refused.message })), | |
| 422 | + | Outcome::Fail(refused) => return answer(json!({ "ok": false, "entry_id": "0", "message": refused.message })), | |
| 230 | 423 | }; | |
| 231 | 424 | let Some(object) = bucket.head(&pending.object).await? else { | |
| 232 | − | return Response::from_json(&json!({ "ok": false, "entry_id": "0", "message": "Nothing was uploaded for that entry." })); | |
| 425 | + | return answer(json!({ "ok": false, "entry_id": "0", "message": "Nothing was uploaded for that entry." })); | |
| 233 | 426 | }; | |
| 234 | 427 | match commit_cache(&bucket, services, &job, &token, &pending.id, object.size()).await? { | |
| 235 | − | Outcome::Ok(()) => Response::from_json(&json!({ "ok": true, "entry_id": pending.number.to_string() })), | |
| 236 | − | Outcome::Fail(refused) => Response::from_json(&json!({ "ok": false, "entry_id": "0", "message": refused.message })), | |
| 428 | + | Outcome::Ok(()) => answer(json!({ "ok": true, "entry_id": pending.number.to_string() })), | |
| 429 | + | Outcome::Fail(refused) => answer(json!({ "ok": false, "entry_id": "0", "message": refused.message })), | |
| 237 | 430 | } | |
| 238 | 431 | } | |
| 239 | 432 | (ARTIFACT_SERVICE, "CreateArtifact") => { | |
| ⋯ | |||
| 317 | 510 | } | |
| 318 | 511 | ||
| 319 | 512 | fn query(request: &Request, name: &str) -> Option<String> { | |
| 320 | − | request.url().ok()?.query_pairs().find(|(k, _)| k == name).map(|(_, v)| v.into_owned()) | |
| 513 | + | query_in(&request.url().ok()?, name) | |
| 514 | + | } | |
| 515 | + | ||
| 516 | + | fn query_in(url: &worker::Url, name: &str) -> Option<String> { | |
| 517 | + | url.query_pairs().find(|(k, _)| k == name).map(|(_, v)| v.into_owned()) | |
| 321 | 518 | } | |
| 322 | 519 | ||
| 323 | 520 | /// The part a chunk of the older protocol is, from its `Content-Range`: | |
| 324 | − | /// chunks are `CACHE_PART_BYTES` apart, as the toolkit sends them. | |
| 521 | + | /// chunks are `CACHE_PART_BYTES` apart, as the toolkit sends them. A first | |
| 522 | + | /// chunk may be larger, up to `MAX_BLOCK_BYTES`: a client that sends an | |
| 523 | + | /// entry in one request (sccache does) sends a single chunk from 0. | |
| 325 | 524 | pub fn chunk_part(range: &str) -> Option<(u16, u64)> { | |
| 326 | 525 | let range = range.trim().strip_prefix("bytes ")?; | |
| 327 | 526 | let (span, _) = range.split_once('/')?; | |
| 328 | 527 | let (start, end) = span.split_once('-')?; | |
| 329 | 528 | let (start, end): (u64, u64) = (start.trim().parse().ok()?, end.trim().parse().ok()?); | |
| 330 | − | if end < start || start % CACHE_PART_BYTES != 0 || end - start + 1 > CACHE_PART_BYTES { | |
| 529 | + | if end < start { | |
| 530 | + | return None; | |
| 531 | + | } | |
| 532 | + | let length = end - start + 1; | |
| 533 | + | if start == 0 && length <= MAX_BLOCK_BYTES { | |
| 534 | + | return Some((1, length)); | |
| 535 | + | } | |
| 536 | + | if start % CACHE_PART_BYTES != 0 || length > CACHE_PART_BYTES { | |
| 537 | + | return None; | |
| 538 | + | } | |
| 539 | + | Some(((start / CACHE_PART_BYTES + 1) as u16, length)) | |
| 540 | + | } | |
| 541 | + | ||
| 542 | + | /// The bytes a download's `Range` header asks for, out of `size`: first and | |
| 543 | + | /// last, inclusive. None to send the whole blob (no header, or one this | |
| 544 | + | /// does not read, such as several ranges); `Some(None)` when the range is | |
| 545 | + | /// past the end (416). | |
| 546 | + | pub fn byte_range(header: &str, size: u64) -> Option<Option<(u64, u64)>> { | |
| 547 | + | let spec = header.trim().strip_prefix("bytes=")?.trim(); | |
| 548 | + | if spec.contains(',') { | |
| 331 | 549 | return None; | |
| 332 | 550 | } | |
| 333 | − | Some(((start / CACHE_PART_BYTES + 1) as u16, end - start + 1)) | |
| 551 | + | let (first, last) = spec.split_once('-')?; | |
| 552 | + | let (first, last) = (first.trim(), last.trim()); | |
| 553 | + | let range = if first.is_empty() { | |
| 554 | + | // The last `n` bytes. | |
| 555 | + | let n: u64 = last.parse().ok()?; | |
| 556 | + | if n == 0 || size == 0 { | |
| 557 | + | return Some(None); | |
| 558 | + | } | |
| 559 | + | (size.saturating_sub(n), size - 1) | |
| 560 | + | } else { | |
| 561 | + | let first: u64 = first.parse().ok()?; | |
| 562 | + | let last: u64 = if last.is_empty() { u64::MAX } else { last.parse().ok()? }; | |
| 563 | + | if last < first { | |
| 564 | + | return None; | |
| 565 | + | } | |
| 566 | + | if first >= size { | |
| 567 | + | return Some(None); | |
| 568 | + | } | |
| 569 | + | (first, last.min(size - 1)) | |
| 570 | + | }; | |
| 571 | + | Some(Some(range)) | |
| 572 | + | } | |
| 573 | + | ||
| 574 | + | /// What a lookup of the older protocol answers: 200 with the entry, 204 | |
| 575 | + | /// for a miss (which the toolkit's client and sccache read as "not | |
| 576 | + | /// cached"), or the refusal's status. | |
| 577 | + | pub fn lookup_answer(found: Outcome<Option<CacheHit>>, version: &str, api: &str) -> (u16, Option<Value>) { | |
| 578 | + | match found { | |
| 579 | + | Outcome::Ok(Some(CacheHit { key, blob: Some(blob), created_at, .. })) => ( | |
| 580 | + | 200, | |
| 581 | + | Some(json!({ | |
| 582 | + | "cacheKey": key, | |
| 583 | + | "cacheVersion": version, | |
| 584 | + | "scope": "", | |
| 585 | + | "creationTime": created_at, | |
| 586 | + | "archiveLocation": blob_url(api, &blob), | |
| 587 | + | })), | |
| 588 | + | ), | |
| 589 | + | // No entry, or one without a download link (no ACTIONS_KEY): a miss. | |
| 590 | + | Outcome::Ok(_) => (204, None), | |
| 591 | + | Outcome::Fail(refused) => (refused.code.http_status(), Some(json!({ "message": refused.message, "error": { "message": refused.message } }))), | |
| 592 | + | } | |
| 593 | + | } | |
| 594 | + | ||
| 595 | + | /// Logs a toolkit request that failed inside g1t, and says what to tell | |
| 596 | + | /// its client: the cause, so a job's log shows more than a bare 500. | |
| 597 | + | fn failed(route: &str, error: &worker::Error) -> String { | |
| 598 | + | worker::console_error!("toolkit: {route} failed: {error}"); | |
| 599 | + | format!("g1t could not answer this: {error}") | |
| 334 | 600 | } | |
| 335 | 601 | ||
| 336 | 602 | /// `{ACTIONS_CACHE_URL}_apis/artifactcache/…`. `rest` is the path after it. | |
| 337 | − | pub async fn cache_v1(mut request: Request, env: &Env, services: &Services, method: &str, rest: &str) -> Result<Response> { | |
| 603 | + | /// A failure inside is logged and answered as a 500 with its cause. | |
| 604 | + | pub async fn cache_v1(request: Request, env: &Env, services: &Services, method: &str, rest: &str) -> Result<Response> { | |
| 605 | + | match cache_v1_inner(request, env, services, method, rest).await { | |
| 606 | + | Ok(response) => Ok(response), | |
| 607 | + | Err(error) => plain_error(500, &failed(&format!("{method} {CACHE_PATH}_apis/artifactcache/{rest}"), &error)), | |
| 608 | + | } | |
| 609 | + | } | |
| 610 | + | ||
| 611 | + | async fn cache_v1_inner(mut request: Request, env: &Env, services: &Services, method: &str, rest: &str) -> Result<Response> { | |
| 338 | 612 | let token = bearer(&request); | |
| 339 | 613 | let Some(job) = runtime_job(&token) else { | |
| 340 | 614 | return plain_error(401, "Send the job's ACTIONS_RUNTIME_TOKEN as a bearer token."); | |
| ⋯ | |||
| 351 | 625 | let version = query(&request, "version").unwrap_or_default(); | |
| 352 | 626 | let args = CacheLookupArgs { job, token, key: key.clone(), restore: restore.to_vec(), version: Some(version.clone()) }; | |
| 353 | 627 | let found: Outcome<Option<CacheHit>> = g1t_kit::call(actions, "cache_lookup", &args).await?; | |
| 354 | − | match found { | |
| 355 | − | Outcome::Ok(Some(CacheHit { key, blob: Some(blob), created_at, .. })) => Response::from_json(&json!({ | |
| 356 | − | "cacheKey": key, | |
| 357 | − | "cacheVersion": version, | |
| 358 | − | "scope": "", | |
| 359 | − | "creationTime": created_at, | |
| 360 | − | "archiveLocation": blob_url(&services.addresses.api, &blob), | |
| 361 | − | })), | |
| 362 | − | Outcome::Ok(_) => Ok(Response::empty()?.with_status(204)), | |
| 363 | − | Outcome::Fail(refused) => plain_error(refused.code.http_status(), &refused.message), | |
| 628 | + | match lookup_answer(found, &version, &services.addresses.api) { | |
| 629 | + | (status, Some(body)) => Ok(Response::from_json(&body)?.with_status(status)), | |
| 630 | + | (status, None) => Ok(Response::empty()?.with_status(status)), | |
| 364 | 631 | } | |
| 365 | 632 | } | |
| 366 | 633 | ("POST", ["caches"]) => { | |
| ⋯ | |||
| 510 | 777 | } | |
| 511 | 778 | ||
| 512 | 779 | /// `/actions/toolkit/blobs/{token}`: GET or HEAD a download, PUT an upload. | |
| 513 | − | pub async fn blob(mut request: Request, env: &Env, services: &Services, method: &str, token: &str) -> Result<Response> { | |
| 780 | + | /// A failure inside is logged and answered as Azure's `InternalError`. | |
| 781 | + | pub async fn blob(request: Request, env: &Env, services: &Services, method: &str, token: &str) -> Result<Response> { | |
| 782 | + | match blob_inner(request, env, services, method, token).await { | |
| 783 | + | Ok(response) => Ok(response), | |
| 784 | + | // The token is a credential: the route is logged without it. | |
| 785 | + | Err(error) => azure_error(500, "InternalError", &failed(&format!("{method} /actions/toolkit/blobs/…"), &error)), | |
| 786 | + | } | |
| 787 | + | } | |
| 788 | + | ||
| 789 | + | async fn blob_inner(mut request: Request, env: &Env, services: &Services, method: &str, token: &str) -> Result<Response> { | |
| 514 | 790 | let opened: Outcome<BlobGrant> = g1t_kit::call(&services.actions, "blob_open", &BlobArgs { blob: token.to_owned(), ..BlobArgs::default() }).await?; | |
| 515 | 791 | let grant = match opened { | |
| 516 | 792 | Outcome::Ok(grant) => grant, | |
| ⋯ | |||
| 527 | 803 | headers.set("content-length", &size.to_string())?; | |
| 528 | 804 | headers.set("content-type", grant.content_type.as_deref().unwrap_or("application/octet-stream"))?; | |
| 529 | 805 | headers.set("x-ms-blob-type", "BlockBlob")?; | |
| 806 | + | headers.set("accept-ranges", "bytes")?; | |
| 530 | 807 | if let Some(name) = &grant.filename { | |
| 531 | 808 | headers.set("content-disposition", &format!("attachment; filename=\"{}\"", name.replace('"', "")))?; | |
| 532 | 809 | } | |
| ⋯ | |||
| 538 | 815 | headers(&mut response, object.size())?; | |
| 539 | 816 | return Ok(response); | |
| 540 | 817 | } | |
| 818 | + | // One byte range (`Range`, or Azure's `x-ms-range`): the | |
| 819 | + | // toolkit's client fetches a large entry in segments, side by | |
| 820 | + | // side, and writes each where its range says. | |
| 821 | + | let asked = match request.headers().get("x-ms-range")? { | |
| 822 | + | Some(range) => Some(range), | |
| 823 | + | None => request.headers().get("range")?, | |
| 824 | + | }; | |
| 825 | + | if let Some(asked) = asked.filter(|r| !r.trim().is_empty()) { | |
| 826 | + | let Some(object) = bucket.head(&grant.object).await? else { return azure_error(404, "BlobNotFound", "It is gone.") }; | |
| 827 | + | let size = object.size(); | |
| 828 | + | match byte_range(&asked, size) { | |
| 829 | + | Some(Some((first, last))) => { | |
| 830 | + | let length = last - first + 1; | |
| 831 | + | let Some(object) = bucket.get(&grant.object).range(worker::Range::OffsetWithLength { offset: first, length }).execute().await? else { | |
| 832 | + | return azure_error(404, "BlobNotFound", "It is gone."); | |
| 833 | + | }; | |
| 834 | + | let Some(body) = object.body() else { return azure_error(404, "BlobNotFound", "It is gone.") }; | |
| 835 | + | let mut response = Response::from_body(body.response_body()?)?.with_status(206); | |
| 836 | + | headers(&mut response, length)?; | |
| 837 | + | response.headers_mut().set("content-range", &format!("bytes {first}-{last}/{size}"))?; | |
| 838 | + | return Ok(response); | |
| 839 | + | } | |
| 840 | + | Some(None) => { | |
| 841 | + | let mut response = azure_error(416, "InvalidRange", "The range is past the end of the blob.")?; | |
| 842 | + | response.headers_mut().set("content-range", &format!("bytes */{size}"))?; | |
| 843 | + | return Ok(response); | |
| 844 | + | } | |
| 845 | + | // Not a range this reads: the whole blob. | |
| 846 | + | None => {} | |
| 847 | + | } | |
| 848 | + | } | |
| 541 | 849 | let Some(object) = bucket.get(&grant.object).execute().await? else { return azure_error(404, "BlobNotFound", "It is gone.") }; | |
| 542 | 850 | let size = object.size(); | |
| 543 | 851 | let Some(body) = object.body() else { return azure_error(404, "BlobNotFound", "It is gone.") }; | |
| ⋯ | |||
| 674 | 982 | let mb32 = CACHE_PART_BYTES; | |
| 675 | 983 | assert_eq!(chunk_part(&format!("bytes 0-{}/*", mb32 - 1)), Some((1, mb32))); | |
| 676 | 984 | assert_eq!(chunk_part(&format!("bytes {}-{}/*", mb32 * 2, mb32 * 2 + 99)), Some((3, 100))); | |
| 677 | − | // Not on a chunk's boundary, too long, or not a range. | |
| 985 | + | // A whole entry in one chunk, as sccache (OpenDAL) sends it: its | |
| 986 | + | // check file is 13 bytes, a compiled crate can be well over 32 MB. | |
| 987 | + | assert_eq!(chunk_part("bytes 0-12/*"), Some((1, 13))); | |
| 988 | + | assert_eq!(chunk_part(&format!("bytes 0-{}/*", mb32)), Some((1, mb32 + 1))); | |
| 989 | + | assert_eq!(chunk_part(&format!("bytes 0-{}/*", MAX_BLOCK_BYTES - 1)), Some((1, MAX_BLOCK_BYTES))); | |
| 990 | + | // Not on a chunk's boundary, too long, backwards, or not a range. | |
| 678 | 991 | assert_eq!(chunk_part("bytes 5-10/*"), None); | |
| 679 | − | assert_eq!(chunk_part(&format!("bytes 0-{}/*", mb32)), None); | |
| 992 | + | assert_eq!(chunk_part(&format!("bytes {mb32}-{}/*", mb32 * 2)), None); | |
| 993 | + | assert_eq!(chunk_part(&format!("bytes 0-{}/*", MAX_BLOCK_BYTES)), None); | |
| 994 | + | assert_eq!(chunk_part("bytes 10-5/*"), None); | |
| 680 | 995 | assert_eq!(chunk_part("0-10"), None); | |
| 681 | 996 | } | |
| 682 | 997 | ||
| 998 | + | #[test] | |
| 999 | + | fn downloads_read_one_byte_range() { | |
| 1000 | + | // OpenDAL's stat: the first byte. | |
| 1001 | + | assert_eq!(byte_range("bytes=0-0", 100), Some(Some((0, 0)))); | |
| 1002 | + | // The toolkit's segments, the last one cut at the end. | |
| 1003 | + | assert_eq!(byte_range("bytes=0-49", 100), Some(Some((0, 49)))); | |
| 1004 | + | assert_eq!(byte_range("bytes=50-999", 100), Some(Some((50, 99)))); | |
| 1005 | + | assert_eq!(byte_range("bytes=90-", 100), Some(Some((90, 99)))); | |
| 1006 | + | assert_eq!(byte_range("bytes=-10", 100), Some(Some((90, 99)))); | |
| 1007 | + | assert_eq!(byte_range("bytes=-1000", 100), Some(Some((0, 99)))); | |
| 1008 | + | // Past the end: 416. | |
| 1009 | + | assert_eq!(byte_range("bytes=100-200", 100), Some(None)); | |
| 1010 | + | assert_eq!(byte_range("bytes=0-0", 0), Some(None)); | |
| 1011 | + | assert_eq!(byte_range("bytes=-0", 100), Some(None)); | |
| 1012 | + | // Not read: the whole blob. | |
| 1013 | + | assert_eq!(byte_range("bytes=0-1,5-6", 100), None); | |
| 1014 | + | assert_eq!(byte_range("bytes=9-3", 100), None); | |
| 1015 | + | assert_eq!(byte_range("items=0-1", 100), None); | |
| 1016 | + | assert_eq!(byte_range("bytes=a-b", 100), None); | |
| 1017 | + | } | |
| 1018 | + | ||
| 1019 | + | #[test] | |
| 1020 | + | fn upload_links_carry_a_query_as_sas_links_do() { | |
| 1021 | + | let url = upload_url("https://api.g1t.sh", "tok.sig"); | |
| 1022 | + | assert_eq!(url, "https://api.g1t.sh/actions/toolkit/blobs/tok.sig?sv=2024-11-04"); | |
| 1023 | + | // How OpenDAL reads a signed upload link: a container, a blob in | |
| 1024 | + | // it, and a SAS query, all of which must be there. | |
| 1025 | + | let rest = url.strip_prefix("https://api.g1t.sh/").unwrap(); | |
| 1026 | + | let (path, query) = rest.split_once('?').unwrap(); | |
| 1027 | + | let (container, blob) = path.split_once('/').unwrap(); | |
| 1028 | + | assert_eq!((container, blob, query), ("actions", "toolkit/blobs/tok.sig", "sv=2024-11-04")); | |
| 1029 | + | } | |
| 1030 | + | ||
| 1031 | + | /// A protobuf length-delimited field, as prost writes it. | |
| 1032 | + | fn pb_text(number: u8, text: &str) -> Vec<u8> { | |
| 1033 | + | let mut out = vec![(number << 3) | 2, text.len() as u8]; | |
| 1034 | + | out.extend_from_slice(text.as_bytes()); | |
| 1035 | + | out | |
| 1036 | + | } | |
| 1037 | + | ||
| 1038 | + | /// The requests sccache 0.18 sends (OpenDAL 0.58's `ghac` service, with | |
| 1039 | + | /// prost): fields in number order, defaults left out, no metadata. | |
| 1040 | + | #[test] | |
| 1041 | + | fn twirp_reads_sccaches_protobuf_requests() { | |
| 1042 | + | assert!(is_protobuf("application/protobuf")); | |
| 1043 | + | assert!(is_protobuf("Application/Protobuf; charset=utf-8")); | |
| 1044 | + | assert!(!is_protobuf("application/json")); | |
| 1045 | + | assert!(!is_protobuf("")); | |
| 1046 | + | ||
| 1047 | + | let key = "sccache/f/c/b/fcb0a1d2e3"; | |
| 1048 | + | let version = "sccache-v0.18.0"; | |
| 1049 | + | let create = [pb_text(2, key), pb_text(3, version)].concat(); | |
| 1050 | + | let read = proto::request("CreateCacheEntry", &create).unwrap(); | |
| 1051 | + | assert_eq!((text(&read, "key"), text(&read, "version")), (key.to_owned(), version.to_owned())); | |
| 1052 | + | ||
| 1053 | + | // size_bytes is field 3, a varint: 300 is 0xac 0x02. | |
| 1054 | + | let finalize = [pb_text(2, key), vec![0x18, 0xac, 0x02], pb_text(4, version)].concat(); | |
| 1055 | + | let read = proto::request("FinalizeCacheEntryUpload", &finalize).unwrap(); | |
| 1056 | + | assert_eq!(number(&read, "size_bytes"), Some(300)); | |
| 1057 | + | assert_eq!(text(&read, "version"), version); | |
| 1058 | + | ||
| 1059 | + | let lookup = [pb_text(2, key), pb_text(4, version)].concat(); | |
| 1060 | + | let read = proto::request("GetCacheEntryDownloadURL", &lookup).unwrap(); | |
| 1061 | + | assert_eq!(text(&read, "key"), key); | |
| 1062 | + | assert!(field(&read, "restore_keys").is_null()); | |
| 1063 | + | // The toolkit's own lookup, with metadata (skipped) and restore keys. | |
| 1064 | + | let metadata = vec![0x0a, 0x02, 0x08, 0x07]; | |
| 1065 | + | let with_restore = [metadata, pb_text(2, "k"), pb_text(3, "k-"), pb_text(3, "x-"), pb_text(4, "v")].concat(); | |
| 1066 | + | let read = proto::request("GetCacheEntryDownloadURL", &with_restore).unwrap(); | |
| 1067 | + | assert_eq!(field(&read, "restore_keys"), &json!(["k-", "x-"])); | |
| 1068 | + | assert_eq!(text(&read, "version"), "v"); | |
| 1069 | + | ||
| 1070 | + | // The same three, as prost 0.14 encodes them with OpenDAL's | |
| 1071 | + | // generated types (the `ghac` crate, 0.3.0), byte for byte. | |
| 1072 | + | let recorded = |hex: &str| -> Vec<u8> { (0..hex.len()).step_by(2).map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap()).collect() }; | |
| 1073 | + | let prefix = "1218736363616368652f662f632f622f66636230613164326533"; | |
| 1074 | + | let suffix = "0f736363616368652d76302e31382e30"; | |
| 1075 | + | assert_eq!(recorded(&format!("{prefix}1a{suffix}")), create); | |
| 1076 | + | assert_eq!(recorded(&format!("{prefix}18ac0222{suffix}")), finalize); | |
| 1077 | + | assert_eq!(recorded(&format!("{prefix}22{suffix}")), lookup); | |
| 1078 | + | ||
| 1079 | + | // Cut short, or not a cache method. | |
| 1080 | + | assert!(proto::request("CreateCacheEntry", &create[..create.len() - 1]).is_none()); | |
| 1081 | + | assert!(proto::request("CreateCacheEntry", &[0x12, 0xff]).is_none()); | |
| 1082 | + | assert!(proto::request("CreateArtifact", &create).is_none()); | |
| 1083 | + | assert_eq!(proto::request("CreateCacheEntry", &[]), Some(json!({}))); | |
| 1084 | + | } | |
| 1085 | + | ||
| 1086 | + | #[test] | |
| 1087 | + | fn twirp_answers_in_protobuf_as_prost_reads_it() { | |
| 1088 | + | let url = "https://api.g1t.sh/actions/toolkit/blobs/t?sv=2024-11-04"; | |
| 1089 | + | let created = proto::response("CreateCacheEntry", &json!({ "ok": true, "signed_upload_url": url })); | |
| 1090 | + | assert_eq!(created, [vec![0x08, 0x01], pb_text(2, url)].concat()); | |
| 1091 | + | // Refused: ok false is the default, so only the message is sent. | |
| 1092 | + | let refused = proto::response("CreateCacheEntry", &json!({ "ok": false, "signed_upload_url": "", "message": "no" })); | |
| 1093 | + | assert_eq!(refused, pb_text(3, "no")); | |
| 1094 | + | // entry_id is an int64, given as a string in JSON. | |
| 1095 | + | let finalized = proto::response("FinalizeCacheEntryUpload", &json!({ "ok": true, "entry_id": "300" })); | |
| 1096 | + | assert_eq!(finalized, vec![0x08, 0x01, 0x10, 0xac, 0x02]); | |
| 1097 | + | let found = proto::response("GetCacheEntryDownloadURL", &json!({ "ok": true, "signed_download_url": "u", "matched_key": "k" })); | |
| 1098 | + | assert_eq!(found, [vec![0x08, 0x01], pb_text(2, "u"), pb_text(3, "k")].concat()); | |
| 1099 | + | // A miss is an empty message: ok false. | |
| 1100 | + | assert!(proto::response("GetCacheEntryDownloadURL", &json!({ "ok": false, "signed_download_url": "", "matched_key": "" })).is_empty()); | |
| 1101 | + | } | |
| 1102 | + | ||
| 683 | 1103 | /// The toolkit's requests, as `@actions/cache` 4 and `@actions/artifact` | |
| 684 | 1104 | /// 2 send them (protobuf-ts, proto field names, no defaults). | |
| 685 | 1105 | #[test] | |
| ⋯ | |||
| 716 | 1136 | assert_eq!(runtime_job("deadbeef"), None); | |
| 717 | 1137 | } | |
| 718 | 1138 | ||
| 1139 | + | /// sccache 0.18's storage check, at server start: a lookup of | |
| 1140 | + | /// `sccache/.sccache_check`. The actions service answers a miss with | |
| 1141 | + | /// `Ok(None)`, `{"ok":true,"value":null}`, which was read back as a | |
| 1142 | + | /// malformed outcome, and every lookup that missed was a 500 | |
| 1143 | + | /// ("Server startup failed: cache storage failed to read"). | |
| 1144 | + | #[test] | |
| 1145 | + | fn sccaches_first_lookup_misses_with_a_204() { | |
| 1146 | + | let url = worker::Url::parse( | |
| 1147 | + | "https://api.g1t.sh/actions/toolkit/_apis/artifactcache/cache?keys=sccache/.sccache_check&version=sccache-v0.18.0", | |
| 1148 | + | ) | |
| 1149 | + | .unwrap(); | |
| 1150 | + | assert_eq!(query_in(&url, "keys").as_deref(), Some("sccache/.sccache_check")); | |
| 1151 | + | assert_eq!(query_in(&url, "version").as_deref(), Some("sccache-v0.18.0")); | |
| 1152 | + | ||
| 1153 | + | // As the actions service replies (`g1t_kit::reply`), and the API | |
| 1154 | + | // reads it (`g1t_kit::call`). | |
| 1155 | + | let wire = serde_json::to_string(&Outcome::<Option<CacheHit>>::Ok(None)).unwrap(); | |
| 1156 | + | assert_eq!(wire, r#"{"ok":true,"value":null}"#); | |
| 1157 | + | let found: Outcome<Option<CacheHit>> = g1t_kit::read_answer("cache_lookup", &wire).unwrap(); | |
| 1158 | + | assert_eq!(lookup_answer(found, "sccache-v0.18.0", "https://api.g1t.sh"), (204, None)); | |
| 1159 | + | ||
| 1160 | + | // Once saved, the same lookup is a hit with its download link. | |
| 1161 | + | let hit = CacheHit { | |
| 1162 | + | key: "sccache/.sccache_check".into(), | |
| 1163 | + | object: "c/repo_1/cache_1".into(), | |
| 1164 | + | size: 13, | |
| 1165 | + | created_at: "2026-10-08T12:00:00.000Z".into(), | |
| 1166 | + | blob: Some("tok.sig".into()), | |
| 1167 | + | }; | |
| 1168 | + | let wire = serde_json::to_string(&Outcome::Ok(Some(hit))).unwrap(); | |
| 1169 | + | let found: Outcome<Option<CacheHit>> = g1t_kit::read_answer("cache_lookup", &wire).unwrap(); | |
| 1170 | + | let (status, body) = lookup_answer(found, "sccache-v0.18.0", "https://api.g1t.sh"); | |
| 1171 | + | let body = body.unwrap(); | |
| 1172 | + | assert_eq!(status, 200); | |
| 1173 | + | assert_eq!(body["cacheKey"], "sccache/.sccache_check"); | |
| 1174 | + | assert_eq!(body["cacheVersion"], "sccache-v0.18.0"); | |
| 1175 | + | assert_eq!(body["archiveLocation"], "https://api.g1t.sh/actions/toolkit/blobs/tok.sig"); | |
| 1176 | + | ||
| 1177 | + | // A refusal keeps its status and says why. | |
| 1178 | + | let refused = Outcome::<Option<CacheHit>>::fail(FailureCode::Unauthenticated, "That job is not running."); | |
| 1179 | + | let (status, body) = lookup_answer(refused, "v", "https://api.g1t.sh"); | |
| 1180 | + | assert_eq!((status, body.unwrap()["message"].as_str()), (401, Some("That job is not running."))); | |
| 1181 | + | ||
| 1182 | + | // An answer that does not read names its method and the cause. | |
| 1183 | + | let unread = g1t_kit::read_answer::<Outcome<CacheHit>>("cache_lookup", r#"{"ok":true,"value":null}"#).unwrap_err(); | |
| 1184 | + | assert!(unread.to_string().contains("cache_lookup answered with what could not be read"), "{unread}"); | |
| 1185 | + | } | |
| 1186 | + | ||
| 719 | 1187 | #[test] | |
| 720 | 1188 | fn a_job_is_told_where_the_toolkit_s_services_are() { | |
| 721 | 1189 | let vars = runtime_variables("https://api.g1t.sh", "tok", false); | |
| 395 | 395 | a("transfer_ownership", Op::TransferOwnership, "Hand it to another member: they become an owner, you a member"), | |
| 396 | 396 | a("leave", Op::LeaveWorkspace, "Leave it yourself"), | |
| 397 | 397 | a("list_invites", Op::ListWorkspaceInvites, "Its invites"), | |
| 398 | − | a("invite_member", Op::InviteMember, "Invite an email address"), | |
| 398 | + | a("invite_member", Op::InviteMember, "Invite someone by username or email address"), | |
| 399 | 399 | a("revoke_invite", Op::RevokeWorkspaceInvite, "Revoke a pending invite"), | |
| 400 | 400 | a("list_integrations", Op::ListIntegrations, "Model providers, alert sources, trackers"), | |
| 401 | 401 | a("connect_integration", Op::ConnectIntegration, "Connect one"), | |
| ⋯ | |||
| 418 | 418 | a("delete_ruleset", Op::Rules(RulesOp::DeleteWorkspaceRuleset), "Delete one of its rulesets"), | |
| 419 | 419 | a("rule_evaluations", Op::Rules(RulesOp::ListWorkspaceRuleEvaluations), "How rules judged changes across its repositories"), | |
| 420 | 420 | a("get_token_policy", Op::Tokens(TokenOp::GetTokenPolicy), "Its rules for personal access tokens"), | |
| 421 | − | a("set_token_policy", Op::Tokens(TokenOp::SetTokenPolicy), "Change them: kinds allowed, approval, lifetime"), | |
| 421 | + | a("set_token_policy", Op::Tokens(TokenOp::SetTokenPolicy), "Change them: which tokens reach it, approval, lifetime"), | |
| 422 | 422 | a("list_member_tokens", Op::Tokens(TokenOp::ListMemberTokens), "Members' personal access tokens that reach it"), | |
| 423 | − | a("list_token_requests", Op::Tokens(TokenOp::ListTokenRequests), "Fine-grained tokens waiting for approval"), | |
| 423 | + | a("list_token_requests", Op::Tokens(TokenOp::ListTokenRequests), "Tokens waiting for approval"), | |
| 424 | 424 | a("review_token_request", Op::Tokens(TokenOp::ReviewTokenRequest), "Approve or deny one"), | |
| 425 | 425 | a("revoke_member_token", Op::Tokens(TokenOp::RevokeMemberToken), "Revoke a member's token in it"), | |
| 426 | 426 | ], | |
| ⋯ | |||
| 505 | 505 | Tool { | |
| 506 | 506 | name: "account", | |
| 507 | 507 | title: "Your account", | |
| 508 | − | description: "Who this token acts as and its workspaces (`whoami`), your email addresses, your invites, and invitations to repositories waiting for you.", | |
| 508 | + | description: "Who this token acts as and its workspaces (`whoami`), your email addresses, your invites, and invitations to workspaces and repositories waiting for you.", | |
| 509 | 509 | default_action: Some("whoami"), | |
| 510 | 510 | actions: &[ | |
| 511 | 511 | a("whoami", Op::Whoami, "Who the token acts as, and its workspaces"), | |
| ⋯ | |||
| 517 | 517 | a("list_invites", Op::ListInvites, "Your invites to g1t"), | |
| 518 | 518 | a("create_invite", Op::CreateInvite, "Make an invite"), | |
| 519 | 519 | a("revoke_invite", Op::RevokeInvite, "Revoke one"), | |
| 520 | + | a("list_workspace_invitations", Op::ListInvitations, "Invitations to workspaces for you"), | |
| 521 | + | a("accept_workspace_invitation", Op::AcceptInvitation, "Accept one and join"), | |
| 522 | + | a("decline_workspace_invitation", Op::DeclineInvitation, "Decline one"), | |
| 520 | 523 | a("list_repository_invitations", Op::ListMyRepoInvitations, "Invitations to repositories for you"), | |
| 521 | 524 | a("accept_repository_invitation", Op::AcceptRepoInvitation, "Accept one"), | |
| 522 | 525 | a("decline_repository_invitation", Op::DeclineRepoInvitation, "Decline one"), | |
| 16 | 16 | integrations: [ | |
| 17 | 17 | starlight({ | |
| 18 | 18 | title: 'g1t docs', | |
| 19 | − | description: 'Guides and reference for g1t, where people and agents ship software together.', | |
| 19 | + | description: 'Guides and reference for g1t, the workspace where a team and its agents talk, work and ship.', | |
| 20 | 20 | components: { | |
| 21 | 21 | Footer: './src/components/Footer.astro', | |
| 22 | 22 | Head: './src/components/Head.astro', | |
| ⋯ | |||
| 56 | 56 | head: [ | |
| 57 | 57 | { tag: 'link', attrs: { rel: 'icon', href: '/favicon.ico', sizes: '32x32' } }, | |
| 58 | 58 | { tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '192x192', href: '/icon-192.png' } }, | |
| 59 | − | { tag: 'link', attrs: { rel: 'apple-touch-icon', href: '/apple-touch-icon.png' } }, | |
| 59 | + | { tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '512x512', href: '/icon-512.png' } }, | |
| 60 | + | { tag: 'link', attrs: { rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png' } }, | |
| 61 | + | { tag: 'link', attrs: { rel: 'manifest', href: '/site.webmanifest' } }, | |
| 62 | + | { tag: 'meta', attrs: { name: 'theme-color', content: '#0f0f11' } }, | |
| 60 | 63 | ], | |
| 61 | 64 | sidebar: [ | |
| 62 | 65 | { | |
| ⋯ | |||
| 70 | 73 | ], | |
| 71 | 74 | }, | |
| 72 | 75 | { | |
| 76 | + | label: 'Chat and agents', | |
| 77 | + | items: [ | |
| 78 | + | { label: 'Chat', slug: 'guides/chat' }, | |
| 79 | + | { label: 'Agents', slug: 'guides/agents' }, | |
| 80 | + | { label: 'Sessions', slug: 'guides/agent-sessions' }, | |
| 81 | + | { label: 'Agent memory', slug: 'guides/agent-memory' }, | |
| 82 | + | { label: 'Routines', slug: 'guides/agent-routines' }, | |
| 83 | + | { label: 'Agent budgets and spend', slug: 'guides/agent-budgets' }, | |
| 84 | + | { label: 'What agents can do for whom', slug: 'guides/agent-access' }, | |
| 85 | + | { label: 'Docs', slug: 'guides/docs' }, | |
| 86 | + | { label: 'Agents in your chat app', slug: 'guides/chat-app', badge: { text: 'Soon', variant: 'default' } }, | |
| 87 | + | ], | |
| 88 | + | }, | |
| 89 | + | { | |
| 73 | 90 | label: 'Projects', | |
| 74 | 91 | items: [ | |
| 75 | 92 | { label: 'Projects', slug: 'guides/projects' }, | |
| ⋯ | |||
| 100 | 117 | ], | |
| 101 | 118 | }, | |
| 102 | 119 | { | |
| 103 | − | label: 'Agents', | |
| 120 | + | label: 'Agents on code', | |
| 104 | 121 | items: [ | |
| 105 | 122 | { label: "g1t's agent", slug: 'guides/working-with-g1t' }, | |
| 106 | 123 | { label: 'Guardrails', slug: 'guides/guardrails' }, | |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.