Compare changes
Choose two branches to see what one has that the other does not, then open a pull request for it.
4 commits
- Merge checks: statuses and check runs on every commitChase Pierce1268854
- Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97Chase Piercea96bdc7
- AI Gateway: OpenAI's format, open models, and your own providersChase Pierce9603c76
- Site: the AI Gateway page's breadcrumb reads "AI Gateway"Chase Pierce502b69b
| 1 | + | # Tests every pull request into main, so main can always be deployed. The | |
| 2 | + | # "Default branch" ruleset requires this workflow ("CI") to pass before a | |
| 3 | + | # pull request merges; people may still push to main directly, agents | |
| 4 | + | # may not. Deploy (deploy.yml) ships what lands on main. | |
| 5 | + | # | |
| 6 | + | # rust every crate's tests, natively | |
| 7 | + | # typescript type checks and tests of the apps and TS services, the | |
| 8 | + | # deploy and ops scripts, and the deploy manifest | |
| 9 | + | # build the site, sudo and the docs build as they deploy | |
| 10 | + | name: CI | |
| 11 | + | ||
| 12 | + | on: | |
| 13 | + | pull_request: | |
| 14 | + | branches: [main] | |
| 15 | + | workflow_dispatch: | |
| 16 | + | ||
| 17 | + | concurrency: | |
| 18 | + | group: ci-${{ github.ref }} | |
| 19 | + | cancel-in-progress: true | |
| 20 | + | ||
| 21 | + | env: | |
| 22 | + | CARGO_TERM_COLOR: never | |
| 23 | + | WRANGLER_SEND_METRICS: "false" | |
| 24 | + | ||
| 25 | + | jobs: | |
| 26 | + | rust: | |
| 27 | + | name: Rust | |
| 28 | + | runs-on: g1t-4core | |
| 29 | + | timeout-minutes: 45 | |
| 30 | + | steps: | |
| 31 | + | - uses: actions/checkout@v5 | |
| 32 | + | - name: Cache crates | |
| 33 | + | uses: actions/cache@v4 | |
| 34 | + | with: | |
| 35 | + | path: ~/.cargo/registry/cache | |
| 36 | + | key: cargo-crates-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} | |
| 37 | + | restore-keys: cargo-crates-${{ runner.os }}- | |
| 38 | + | - name: Cache the test build | |
| 39 | + | uses: actions/cache@v4 | |
| 40 | + | with: | |
| 41 | + | path: | | |
| 42 | + | target/debug | |
| 43 | + | !target/debug/incremental | |
| 44 | + | key: cargo-test-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'services/runner/base.json') }} | |
| 45 | + | restore-keys: cargo-test-${{ runner.os }}- | |
| 46 | + | - name: Tests | |
| 47 | + | run: cargo test --workspace --locked --quiet | |
| 48 | + | ||
| 49 | + | typescript: | |
| 50 | + | name: TypeScript | |
| 51 | + | runs-on: ubuntu-latest | |
| 52 | + | timeout-minutes: 30 | |
| 53 | + | steps: | |
| 54 | + | - uses: actions/checkout@v5 | |
| 55 | + | - name: Install | |
| 56 | + | run: npm ci --no-audit --no-fund | |
| 57 | + | - name: Type checks | |
| 58 | + | run: npm run typecheck | |
| 59 | + | - name: Tests | |
| 60 | + | run: npm test --workspaces --if-present | |
| 61 | + | - name: The deploy tool's tests | |
| 62 | + | run: npm run test:deploy | |
| 63 | + | - name: The ops scripts' tests | |
| 64 | + | run: npm run test:ops | |
| 65 | + | - name: The manifest matches every wrangler.jsonc | |
| 66 | + | run: node scripts/deploy.mjs manifest --check | |
| 67 | + | ||
| 68 | + | build: | |
| 69 | + | name: Build | |
| 70 | + | runs-on: ubuntu-latest | |
| 71 | + | timeout-minutes: 30 | |
| 72 | + | steps: | |
| 73 | + | - uses: actions/checkout@v5 | |
| 74 | + | - name: Install | |
| 75 | + | run: npm ci --no-audit --no-fund | |
| 76 | + | - name: The site, sudo and the docs | |
| 77 | + | run: node scripts/deploy.mjs build --only web,sudo,docs |
| 8 | 8 | # core, edge, front the units of each stage, in jobs that share a build; | |
| 9 | 9 | # a stage starts only when the one before it succeeded | |
| 10 | 10 | # | |
| 11 | + | # Each run that deploys is one production deployment of g1t.sh, made by the | |
| 12 | + | # jobs that name `environment: production` (one per run, however many jobs): | |
| 13 | + | # in progress when the first starts, then a success or a failure when the | |
| 14 | + | # run ends. It shows on the project's Deployments page and as the commit's | |
| 15 | + | # `deploy / production` check. The plan job reads production's secrets | |
| 16 | + | # with `deployment: false`, so a dry run or a change that deploys nothing | |
| 17 | + | # makes no deployment. | |
| 18 | + | # | |
| 11 | 19 | # Needs the repository secret CLOUDFLARE_API_TOKEN (a Production row), the | |
| 12 | 20 | # variable CLOUDFLARE_ACCOUNT_ID, and api.cloudflare.com among the project's | |
| 13 | 21 | # workflow-only domains for deploy.yml in production (Settings, Guardrails), | |
| 61 | 69 | name: Plan | |
| 62 | 70 | needs: check | |
| 63 | 71 | runs-on: ubuntu-latest | |
| 64 | − | environment: production | |
| 72 | + | # Production's secrets, without a deployment: planning deploys nothing. | |
| 73 | + | environment: | |
| 74 | + | name: production | |
| 75 | + | deployment: false | |
| 65 | 76 | timeout-minutes: 15 | |
| 66 | 77 | outputs: | |
| 67 | 78 | migrate: ${{ steps.plan.outputs.migrate }} | |
| 96 | 107 | needs: plan | |
| 97 | 108 | if: ${{ needs.plan.outputs.migrate == 'true' && inputs.dry_run != true }} | |
| 98 | 109 | runs-on: ubuntu-latest | |
| 99 | − | environment: production | |
| 110 | + | environment: | |
| 111 | + | name: production | |
| 112 | + | url: https://g1t.sh | |
| 100 | 113 | timeout-minutes: 20 | |
| 101 | 114 | steps: | |
| 102 | 115 | - uses: actions/checkout@v5 | |
| 115 | 128 | if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_core == 'true' && inputs.dry_run != true }} | |
| 116 | 129 | # Rust builds get 4 vCPUs; everything else the standard machine. | |
| 117 | 130 | runs-on: ${{ matrix.rust && 'g1t-4core' || 'ubuntu-latest' }} | |
| 118 | − | environment: production | |
| 131 | + | environment: | |
| 132 | + | name: production | |
| 133 | + | url: https://g1t.sh | |
| 119 | 134 | timeout-minutes: 60 | |
| 120 | 135 | strategy: | |
| 121 | 136 | # A deploy cut off halfway is worse than one that finishes: the other | |
| 182 | 197 | needs: [plan, migrate, core] | |
| 183 | 198 | if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_edge == 'true' && inputs.dry_run != true }} | |
| 184 | 199 | runs-on: ${{ matrix.rust && 'g1t-4core' || 'ubuntu-latest' }} | |
| 185 | − | environment: production | |
| 200 | + | environment: | |
| 201 | + | name: production | |
| 202 | + | url: https://g1t.sh | |
| 186 | 203 | timeout-minutes: 60 | |
| 187 | 204 | strategy: | |
| 188 | 205 | fail-fast: false | |
| 195 | 212 | needs: [plan, migrate, core, edge] | |
| 196 | 213 | if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_front == 'true' && inputs.dry_run != true }} | |
| 197 | 214 | runs-on: ${{ matrix.rust && 'g1t-4core' || 'ubuntu-latest' }} | |
| 198 | − | environment: production | |
| 215 | + | environment: | |
| 216 | + | name: production | |
| 217 | + | url: https://g1t.sh | |
| 199 | 218 | timeout-minutes: 60 | |
| 200 | 219 | strategy: | |
| 201 | 220 | fail-fast: false |
| 1 | + | //! A repository's About over REST and MCP: its languages, contributors and | |
| 2 | + | //! license (read from the default branch in the background and kept by | |
| 3 | + | //! commit), stars, and releases. The repos service decides who may see | |
| 4 | + | //! and change each (`g1t_contracts::about`). | |
| 5 | + | ||
| 6 | + | use g1t_contracts::about::*; | |
| 7 | + | use g1t_contracts::repos::RepoPath; | |
| 8 | + | use g1t_contracts::{FailureCode, Outcome, Viewer}; | |
| 9 | + | use serde::Serialize; | |
| 10 | + | use serde::de::DeserializeOwned; | |
| 11 | + | use serde_json::{Value, json}; | |
| 12 | + | use worker::Result; | |
| 13 | + | ||
| 14 | + | use crate::operations::Services; | |
| 15 | + | ||
| 16 | + | /// One operation on a repository's About, stars or releases. | |
| 17 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 18 | + | pub enum AboutOp { | |
| 19 | + | GetLanguages, | |
| 20 | + | ListContributors, | |
| 21 | + | GetLicense, | |
| 22 | + | ListStargazers, | |
| 23 | + | ListStarred, | |
| 24 | + | CheckStarred, | |
| 25 | + | Star, | |
| 26 | + | Unstar, | |
| 27 | + | ListReleases, | |
| 28 | + | GetLatestRelease, | |
| 29 | + | GetReleaseByTag, | |
| 30 | + | GetRelease, | |
| 31 | + | CreateRelease, | |
| 32 | + | UpdateRelease, | |
| 33 | + | DeleteRelease, | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | impl AboutOp { | |
| 37 | + | /// Every one: `Op::ALL` lists each as `Op::About(…)`, which a test checks. | |
| 38 | + | #[cfg(test)] | |
| 39 | + | pub const ALL: [AboutOp; 15] = [ | |
| 40 | + | AboutOp::GetLanguages, | |
| 41 | + | AboutOp::ListContributors, | |
| 42 | + | AboutOp::GetLicense, | |
| 43 | + | AboutOp::ListStargazers, | |
| 44 | + | AboutOp::ListStarred, | |
| 45 | + | AboutOp::CheckStarred, | |
| 46 | + | AboutOp::Star, | |
| 47 | + | AboutOp::Unstar, | |
| 48 | + | AboutOp::ListReleases, | |
| 49 | + | AboutOp::GetLatestRelease, | |
| 50 | + | AboutOp::GetReleaseByTag, | |
| 51 | + | AboutOp::GetRelease, | |
| 52 | + | AboutOp::CreateRelease, | |
| 53 | + | AboutOp::UpdateRelease, | |
| 54 | + | AboutOp::DeleteRelease, | |
| 55 | + | ]; | |
| 56 | + | ||
| 57 | + | pub fn name(self) -> &'static str { | |
| 58 | + | match self { | |
| 59 | + | AboutOp::GetLanguages => "get_languages", | |
| 60 | + | AboutOp::ListContributors => "list_contributors", | |
| 61 | + | AboutOp::GetLicense => "get_license", | |
| 62 | + | AboutOp::ListStargazers => "list_stargazers", | |
| 63 | + | AboutOp::ListStarred => "list_starred", | |
| 64 | + | AboutOp::CheckStarred => "check_starred", | |
| 65 | + | AboutOp::Star => "star_repo", | |
| 66 | + | AboutOp::Unstar => "unstar_repo", | |
| 67 | + | AboutOp::ListReleases => "list_releases", | |
| 68 | + | AboutOp::GetLatestRelease => "get_latest_release", | |
| 69 | + | AboutOp::GetReleaseByTag => "get_release_by_tag", | |
| 70 | + | AboutOp::GetRelease => "get_release", | |
| 71 | + | AboutOp::CreateRelease => "create_release", | |
| 72 | + | AboutOp::UpdateRelease => "update_release", | |
| 73 | + | AboutOp::DeleteRelease => "delete_release", | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | /// For the API reference: "List a repository's languages". | |
| 78 | + | pub fn title(self) -> &'static str { | |
| 79 | + | match self { | |
| 80 | + | AboutOp::GetLanguages => "Get a repository's languages", | |
| 81 | + | AboutOp::ListContributors => "List a repository's contributors", | |
| 82 | + | AboutOp::GetLicense => "Get a repository's license", | |
| 83 | + | AboutOp::ListStargazers => "List who starred a repository", | |
| 84 | + | AboutOp::ListStarred => "List repositories you starred", | |
| 85 | + | AboutOp::CheckStarred => "Check whether you starred a repository", | |
| 86 | + | AboutOp::Star => "Star a repository", | |
| 87 | + | AboutOp::Unstar => "Unstar a repository", | |
| 88 | + | AboutOp::ListReleases => "List releases", | |
| 89 | + | AboutOp::GetLatestRelease => "Get the latest release", | |
| 90 | + | AboutOp::GetReleaseByTag => "Get a release by its tag", | |
| 91 | + | AboutOp::GetRelease => "Get a release", | |
| 92 | + | AboutOp::CreateRelease => "Create a release", | |
| 93 | + | AboutOp::UpdateRelease => "Update a release", | |
| 94 | + | AboutOp::DeleteRelease => "Delete a release", | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | pub fn description(self) -> &'static str { | |
| 99 | + | match self { | |
| 100 | + | AboutOp::GetLanguages => "The languages a repository's default branch is written in, by bytes, largest first: each with its name, color, bytes and percent. Programming and markup languages count; data (JSON, YAML) and prose (Markdown) do not, nor do vendored, generated and documentation files, unless the repository's .gitattributes says otherwise (linguist-vendored, linguist-generated, linguist-documentation, linguist-language, linguist-detectable). Worked out in the background for the default branch's head and kept by commit: commit says which commit the answer is for, and pending is true while the first is worked out (ask again in a few seconds). partial is true when the repository was too large to read in full.", | |
| 101 | + | AboutOp::ListContributors => "Everyone whose commits are on a repository's default branch, most commits first: each with kind (user, matched to an account by an address they confirmed or their noreply address; g1t, g1t itself; author, anyone else, by the name on their commits), name, username and avatar for a user, commits, first_at, last_at, and weeks (commits per week, for the 100 most active). total is how many there are (at most 500 are listed), commits how many were read (the newest 3,000), and weeks the repository's commits by week, oldest first. Worked out in the background and kept by commit, as for get_languages.", | |
| 102 | + | AboutOp::GetLicense => "The license in a repository's LICENSE file (or LICENCE, COPYING, UNLICENSE, with or without an extension) on its default branch: its spdx_id (null when the text is not one g1t recognizes), name (\"Other\" then) and path. Not found when it has none, or before the default branch has been read the first time.", | |
| 103 | + | AboutOp::ListStargazers => "Who starred a repository, newest first: each username, avatar and starred_at. 100 a page; page from 1.", | |
| 104 | + | AboutOp::ListStarred => "The repositories you starred that you can still see, newest first, at most 100: each repository, when you starred it (starred_at) and how many stars it has.", | |
| 105 | + | AboutOp::CheckStarred => "Whether you starred a repository (starred), and how many people have (stars).", | |
| 106 | + | AboutOp::Star => "Star a repository you can see. Starring one you starred already changes nothing. Returns starred and the count of stars. People only: a workspace's or an agent's token cannot.", | |
| 107 | + | AboutOp::Unstar => "Take back your star from a repository. Returns starred (false) and the count of stars.", | |
| 108 | + | AboutOp::ListReleases => "A repository's releases, newest first, at most 100: each with its id (rel_…), tag_name, target (the commit the tag named), name, body (Markdown notes), draft, prerelease, author, created_at, published_at and latest (the newest published release that is neither a draft nor a prerelease). Drafts are listed only to those with the Write role.", | |
| 109 | + | AboutOp::GetLatestRelease => "The latest release: the newest published release that is neither a draft nor a prerelease. Not found when there is none.", | |
| 110 | + | AboutOp::GetReleaseByTag => "The release of one tag. A tag with slashes is URL-encoded in the path.", | |
| 111 | + | AboutOp::GetRelease => "One release by its id (rel_…), with its tag, target commit, title, notes and whether it is a draft, a prerelease or the latest. A draft is found only by those with the Write role.", | |
| 112 | + | AboutOp::CreateRelease => "Publish a release of a tag, with a title (release_name; name in the answer) and notes (body, Markdown). A tag that does not exist yet is made at target (a branch or commit; the default branch when left out), as a lightweight tag, under the repository's tag rulesets. draft keeps it from everyone without the Write role until it is published; prerelease marks it not ready for everyone, so it is never the latest. One release per tag. Needs the Write role.", | |
| 113 | + | AboutOp::UpdateRelease => "Change a release's name, body, draft or prerelease; fields left out stay as they are, and an empty release_name clears it. Setting draft to false publishes it (published_at is set the first time). Needs the Write role.", | |
| 114 | + | AboutOp::DeleteRelease => "Delete a release. Its tag stays: delete that with git (git push origin :refs/tags/<tag>). Needs the Write role.", | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | /// Whether the operation is about one repository named by `repo`. | |
| 119 | + | pub fn needs_repo(self) -> bool { | |
| 120 | + | self != AboutOp::ListStarred | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | /// Whether an anonymous caller may use it, on a public repository. | |
| 124 | + | pub fn anonymous(self) -> bool { | |
| 125 | + | matches!( | |
| 126 | + | self, | |
| 127 | + | AboutOp::GetLanguages | |
| 128 | + | | AboutOp::ListContributors | |
| 129 | + | | AboutOp::GetLicense | |
| 130 | + | | AboutOp::ListStargazers | |
| 131 | + | | AboutOp::ListReleases | |
| 132 | + | | AboutOp::GetLatestRelease | |
| 133 | + | | AboutOp::GetReleaseByTag | |
| 134 | + | | AboutOp::GetRelease | |
| 135 | + | ) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | /// Whether it is about the caller's own stars: nobody else's business, | |
| 139 | + | /// so not audited. | |
| 140 | + | pub fn personal(self) -> bool { | |
| 141 | + | matches!(self, AboutOp::ListStarred | AboutOp::CheckStarred | AboutOp::Star | AboutOp::Unstar) | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | pub fn input(self) -> Value { | |
| 145 | + | let repo = || json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." }); | |
| 146 | + | let id = || json!({ "type": "string", "description": "The release's id: rel_…" }); | |
| 147 | + | let release_fields = |mut properties: Value| { | |
| 148 | + | properties["release_name"] = json!({ "type": "string", "description": "Its title (name in the answer), at most 200 characters; under a repository's address `name` is the repository's. The tag's name is shown when it has none." }); | |
| 149 | + | properties["body"] = json!({ "type": "string", "description": "Its notes, Markdown, at most 125,000 characters." }); | |
| 150 | + | properties["draft"] = json!({ "type": "boolean", "description": "Seen only by those with the Write role until published." }); | |
| 151 | + | properties["prerelease"] = json!({ "type": "boolean", "description": "Not ready for everyone: never the latest release." }); | |
| 152 | + | properties | |
| 153 | + | }; | |
| 154 | + | let (properties, required): (Value, &[&str]) = match self { | |
| 155 | + | AboutOp::GetLanguages | |
| 156 | + | | AboutOp::ListContributors | |
| 157 | + | | AboutOp::GetLicense | |
| 158 | + | | AboutOp::CheckStarred | |
| 159 | + | | AboutOp::Star | |
| 160 | + | | AboutOp::Unstar | |
| 161 | + | | AboutOp::ListReleases | |
| 162 | + | | AboutOp::GetLatestRelease => (json!({ "repo": repo() }), &["repo"]), | |
| 163 | + | AboutOp::ListStargazers => ( | |
| 164 | + | json!({ "repo": repo(), "page": { "type": "integer", "description": "The page, from 1; 100 a page." } }), | |
| 165 | + | &["repo"], | |
| 166 | + | ), | |
| 167 | + | AboutOp::ListStarred => (json!({}), &[]), | |
| 168 | + | AboutOp::GetReleaseByTag => ( | |
| 169 | + | json!({ "repo": repo(), "tag": { "type": "string", "description": "The tag's name, such as v1.2.0." } }), | |
| 170 | + | &["repo", "tag"], | |
| 171 | + | ), | |
| 172 | + | AboutOp::GetRelease | AboutOp::DeleteRelease => (json!({ "repo": repo(), "id": id() }), &["repo", "id"]), | |
| 173 | + | AboutOp::CreateRelease => ( | |
| 174 | + | release_fields(json!({ | |
| 175 | + | "repo": repo(), | |
| 176 | + | "tag_name": { "type": "string", "description": "The tag to release, such as v1.2.0. Made at target when it does not exist yet." }, | |
| 177 | + | "target": { "type": "string", "description": "A branch or commit to make a new tag at. The default branch when left out; ignored for a tag that exists." }, | |
| 178 | + | })), | |
| 179 | + | &["repo", "tag_name"], | |
| 180 | + | ), | |
| 181 | + | AboutOp::UpdateRelease => (release_fields(json!({ "repo": repo(), "id": id() })), &["repo", "id"]), | |
| 182 | + | }; | |
| 183 | + | let mut schema = json!({ "type": "object", "properties": properties }); | |
| 184 | + | if !required.is_empty() { | |
| 185 | + | schema["required"] = json!(required); | |
| 186 | + | } | |
| 187 | + | schema | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | fn text(input: &Value, key: &str) -> Option<String> { | |
| 192 | + | input[key].as_str().map(str::trim).filter(|value| !value.is_empty()).map(str::to_owned) | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | /// A string that may be given empty, to clear it. | |
| 196 | + | fn given(input: &Value, key: &str) -> Option<String> { | |
| 197 | + | input[key].as_str().map(str::to_owned) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | fn flag(input: &Value, key: &str) -> Option<bool> { | |
| 201 | + | match &input[key] { | |
| 202 | + | Value::Bool(value) => Some(*value), | |
| 203 | + | Value::String(text) => match text.trim() { | |
| 204 | + | "true" | "1" => Some(true), | |
| 205 | + | "false" | "0" => Some(false), | |
| 206 | + | _ => None, | |
| 207 | + | }, | |
| 208 | + | _ => None, | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | fn number(input: &Value, key: &str) -> Option<u32> { | |
| 213 | + | match &input[key] { | |
| 214 | + | Value::Number(number) => number.as_u64().and_then(|n| u32::try_from(n).ok()), | |
| 215 | + | Value::String(digits) => digits.trim().parse().ok(), | |
| 216 | + | _ => None, | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | async fn call<A: Serialize, T: DeserializeOwned>(services: &Services, method: &str, args: &A) -> Result<Outcome<T>> { | |
| 221 | + | g1t_kit::call(&services.repos, method, args).await | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | fn ok<T: Serialize>(value: &T) -> Result<Outcome<Value>> { | |
| 225 | + | Ok(Outcome::Ok(serde_json::to_value(value)?)) | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | fn out<T: Serialize>(outcome: Outcome<T>) -> Result<Outcome<Value>> { | |
| 229 | + | match outcome { | |
| 230 | + | Outcome::Ok(value) => ok(&value), | |
| 231 | + | Outcome::Fail(failure) => Ok(Outcome::Fail(failure)), | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | pub async fn run(op: AboutOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { | |
| 236 | + | let actor = || viewer.clone().unwrap_or_default(); | |
| 237 | + | if op == AboutOp::ListStarred { | |
| 238 | + | let username = actor().username; | |
| 239 | + | let starred: Vec<StarredRepo> = g1t_kit::call(&services.repos, "starred", &StarredArgs { username, viewer: viewer.clone() }).await?; | |
| 240 | + | return ok(&starred); | |
| 241 | + | } | |
| 242 | + | let Some(path) = crate::operations::repo_path(input) else { | |
| 243 | + | return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\".")); | |
| 244 | + | }; | |
| 245 | + | let view = |path: RepoPath| RepoViewArgs { path, viewer: viewer.clone() }; | |
| 246 | + | let release = |path: RepoPath, id: Option<String>, tag: Option<String>, latest: bool| ReleaseArgs { path, viewer: viewer.clone(), id, tag, latest }; | |
| 247 | + | match op { | |
| 248 | + | AboutOp::GetLanguages => out(call::<_, Languages>(services, "languages", &view(path)).await?), | |
| 249 | + | AboutOp::ListContributors => out(call::<_, Contributors>(services, "contributors", &view(path)).await?), | |
| 250 | + | AboutOp::GetLicense => match call::<_, Option<License>>(services, "license", &view(path)).await? { | |
| 251 | + | Outcome::Ok(Some(license)) => ok(&license), | |
| 252 | + | Outcome::Ok(None) => Ok(Outcome::fail( | |
| 253 | + | FailureCode::NotFound, | |
| 254 | + | "No license file was found on the default branch. A repository just pushed to is read in the background: try again in a moment.", | |
| 255 | + | )), | |
| 256 | + | Outcome::Fail(failure) => Ok(Outcome::Fail(failure)), | |
| 257 | + | }, | |
| 258 | + | AboutOp::ListStargazers => { | |
| 259 | + | out(call::<_, Vec<Stargazer>>(services, "stargazers", &StargazersArgs { path, viewer: viewer.clone(), page: number(input, "page") }).await?) | |
| 260 | + | } | |
| 261 | + | AboutOp::CheckStarred => out(call::<_, Stars>(services, "stars", &view(path)).await?), | |
| 262 | + | AboutOp::Star | AboutOp::Unstar => { | |
| 263 | + | out(call::<_, Stars>(services, "star", &StarArgs { path, actor: actor(), starred: op == AboutOp::Star }).await?) | |
| 264 | + | } | |
| 265 | + | AboutOp::ListReleases => out(call::<_, Vec<Release>>(services, "releases", &view(path)).await?), | |
| 266 | + | AboutOp::GetLatestRelease => out(call::<_, Release>(services, "release", &release(path, None, None, true)).await?), | |
| 267 | + | AboutOp::GetReleaseByTag => { | |
| 268 | + | let Some(tag) = text(input, "tag") else { | |
| 269 | + | return Ok(Outcome::fail(FailureCode::Invalid, "Name the tag.")); | |
| 270 | + | }; | |
| 271 | + | out(call::<_, Release>(services, "release", &release(path, None, Some(tag), false)).await?) | |
| 272 | + | } | |
| 273 | + | AboutOp::GetRelease => { | |
| 274 | + | let Some(id) = text(input, "id") else { | |
| 275 | + | return Ok(Outcome::fail(FailureCode::Invalid, "Give the release's id.")); | |
| 276 | + | }; | |
| 277 | + | out(call::<_, Release>(services, "release", &release(path, Some(id), None, false)).await?) | |
| 278 | + | } | |
| 279 | + | AboutOp::CreateRelease => { | |
| 280 | + | let Some(tag_name) = text(input, "tag_name") else { | |
| 281 | + | return Ok(Outcome::fail(FailureCode::Invalid, "Name the tag to release: tag_name.")); | |
| 282 | + | }; | |
| 283 | + | let args = CreateReleaseArgs { | |
| 284 | + | path, | |
| 285 | + | actor: actor(), | |
| 286 | + | tag_name, | |
| 287 | + | target: text(input, "target"), | |
| 288 | + | name: text(input, "release_name"), | |
| 289 | + | body: given(input, "body"), | |
| 290 | + | draft: flag(input, "draft").unwrap_or(false), | |
| 291 | + | prerelease: flag(input, "prerelease").unwrap_or(false), | |
| 292 | + | }; | |
| 293 | + | out(call::<_, Release>(services, "create_release", &args).await?) | |
| 294 | + | } | |
| 295 | + | AboutOp::UpdateRelease => { | |
| 296 | + | let Some(id) = text(input, "id") else { | |
| 297 | + | return Ok(Outcome::fail(FailureCode::Invalid, "Give the release's id.")); | |
| 298 | + | }; | |
| 299 | + | let args = UpdateReleaseArgs { | |
| 300 | + | path, | |
| 301 | + | actor: actor(), | |
| 302 | + | id, | |
| 303 | + | name: given(input, "release_name"), | |
| 304 | + | body: given(input, "body"), | |
| 305 | + | draft: flag(input, "draft"), | |
| 306 | + | prerelease: flag(input, "prerelease"), | |
| 307 | + | }; | |
| 308 | + | out(call::<_, Release>(services, "update_release", &args).await?) | |
| 309 | + | } | |
| 310 | + | AboutOp::DeleteRelease => { | |
| 311 | + | let Some(id) = text(input, "id") else { | |
| 312 | + | return Ok(Outcome::fail(FailureCode::Invalid, "Give the release's id.")); | |
| 313 | + | }; | |
| 314 | + | match call::<_, bool>(services, "delete_release", &DeleteReleaseArgs { path, actor: actor(), id }).await? { | |
| 315 | + | Outcome::Ok(deleted) => ok(&json!({ "deleted": deleted })), | |
| 316 | + | Outcome::Fail(failure) => Ok(Outcome::Fail(failure)), | |
| 317 | + | } | |
| 318 | + | } | |
| 319 | + | AboutOp::ListStarred => unreachable!("answered above"), | |
| 320 | + | } | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | #[cfg(test)] | |
| 324 | + | mod tests { | |
| 325 | + | use super::*; | |
| 326 | + | ||
| 327 | + | #[test] | |
| 328 | + | fn each_operation_is_described_with_a_schema() { | |
| 329 | + | for op in AboutOp::ALL { | |
| 330 | + | assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name()); | |
| 331 | + | let schema = op.input(); | |
| 332 | + | assert_eq!(schema["type"], "object"); | |
| 333 | + | if op.needs_repo() { | |
| 334 | + | assert!(schema["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name()); | |
| 335 | + | } | |
| 336 | + | } | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | #[test] | |
| 340 | + | fn every_operation_is_one_of_the_api_s() { | |
| 341 | + | for op in AboutOp::ALL { | |
| 342 | + | assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::About(op)), "{}", op.name()); | |
| 343 | + | } | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | #[test] | |
| 347 | + | fn inputs_are_read_loosely() { | |
| 348 | + | let input = json!({ "draft": "true", "prerelease": false, "page": "2", "name": "" }); | |
| 349 | + | assert_eq!(flag(&input, "draft"), Some(true)); | |
| 350 | + | assert_eq!(flag(&input, "prerelease"), Some(false)); | |
| 351 | + | assert_eq!(flag(&input, "missing"), None); | |
| 352 | + | assert_eq!(number(&input, "page"), Some(2)); | |
| 353 | + | assert_eq!(given(&input, "name").as_deref(), Some(""), "an empty release_name clears it"); | |
| 354 | + | assert_eq!(text(&input, "name"), None); | |
| 355 | + | } | |
| 356 | + | } |
| 1 | + | //! Checks over REST and MCP: statuses on commits, and check runs and | |
| 2 | + | //! check suites, in GitHub's shapes so that existing integrations and | |
| 3 | + | //! actions report to g1t unchanged. | |
| 4 | + | //! | |
| 5 | + | //! The work service keeps them and decides who may read and report them | |
| 6 | + | //! (`g1t_contracts::checks`). A g1t Actions job is a check run here too, | |
| 7 | + | //! and its workflow run the suite. | |
| 8 | + | ||
| 9 | + | use g1t_contracts::checks::*; | |
| 10 | + | use g1t_contracts::{FailureCode, Outcome, Viewer}; | |
| 11 | + | use serde::Serialize; | |
| 12 | + | use serde::de::DeserializeOwned; | |
| 13 | + | use serde_json::{Map, Value, json}; | |
| 14 | + | use worker::Result; | |
| 15 | + | ||
| 16 | + | use crate::operations::Services; | |
| 17 | + | ||
| 18 | + | /// One operation on checks. | |
| 19 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 20 | + | pub enum ChecksOp { | |
| 21 | + | CreateCommitStatus, | |
| 22 | + | ListCommitStatuses, | |
| 23 | + | GetCombinedStatus, | |
| 24 | + | CreateCheckRun, | |
| 25 | + | UpdateCheckRun, | |
| 26 | + | GetCheckRun, | |
| 27 | + | ListCheckRunAnnotations, | |
| 28 | + | RerequestCheckRun, | |
| 29 | + | ListCheckRunsForRef, | |
| 30 | + | ListCheckSuitesForRef, | |
| 31 | + | GetCheckSuite, | |
| 32 | + | RerequestCheckSuite, | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | impl ChecksOp { | |
| 36 | + | /// Every one: `Op::ALL` lists each as `Op::Checks(…)`, which a test | |
| 37 | + | /// checks against this. | |
| 38 | + | #[cfg(test)] | |
| 39 | + | pub const ALL: [ChecksOp; 12] = [ | |
| 40 | + | ChecksOp::CreateCommitStatus, | |
| 41 | + | ChecksOp::ListCommitStatuses, | |
| 42 | + | ChecksOp::GetCombinedStatus, | |
| 43 | + | ChecksOp::CreateCheckRun, | |
| 44 | + | ChecksOp::UpdateCheckRun, | |
| 45 | + | ChecksOp::GetCheckRun, | |
| 46 | + | ChecksOp::ListCheckRunAnnotations, | |
| 47 | + | ChecksOp::RerequestCheckRun, | |
| 48 | + | ChecksOp::ListCheckRunsForRef, | |
| 49 | + | ChecksOp::ListCheckSuitesForRef, | |
| 50 | + | ChecksOp::GetCheckSuite, | |
| 51 | + | ChecksOp::RerequestCheckSuite, | |
| 52 | + | ]; | |
| 53 | + | ||
| 54 | + | pub fn name(self) -> &'static str { | |
| 55 | + | match self { | |
| 56 | + | ChecksOp::CreateCommitStatus => "create_commit_status", | |
| 57 | + | ChecksOp::ListCommitStatuses => "list_commit_statuses", | |
| 58 | + | ChecksOp::GetCombinedStatus => "get_combined_status", | |
| 59 | + | ChecksOp::CreateCheckRun => "create_check_run", | |
| 60 | + | ChecksOp::UpdateCheckRun => "update_check_run", | |
| 61 | + | ChecksOp::GetCheckRun => "get_check_run", | |
| 62 | + | ChecksOp::ListCheckRunAnnotations => "list_check_run_annotations", | |
| 63 | + | ChecksOp::RerequestCheckRun => "rerequest_check_run", | |
| 64 | + | ChecksOp::ListCheckRunsForRef => "list_check_runs_for_ref", | |
| 65 | + | ChecksOp::ListCheckSuitesForRef => "list_check_suites_for_ref", | |
| 66 | + | ChecksOp::GetCheckSuite => "get_check_suite", | |
| 67 | + | ChecksOp::RerequestCheckSuite => "rerequest_check_suite", | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | /// For the API reference. | |
| 72 | + | pub fn title(self) -> &'static str { | |
| 73 | + | match self { | |
| 74 | + | ChecksOp::CreateCommitStatus => "Create a commit status", | |
| 75 | + | ChecksOp::ListCommitStatuses => "List a commit's statuses", | |
| 76 | + | ChecksOp::GetCombinedStatus => "Get a commit's combined status", | |
| 77 | + | ChecksOp::CreateCheckRun => "Create a check run", | |
| 78 | + | ChecksOp::UpdateCheckRun => "Update a check run", | |
| 79 | + | ChecksOp::GetCheckRun => "Get a check run", | |
| 80 | + | ChecksOp::ListCheckRunAnnotations => "List a check run's annotations", | |
| 81 | + | ChecksOp::RerequestCheckRun => "Rerequest a check run", | |
| 82 | + | ChecksOp::ListCheckRunsForRef => "List a commit's check runs", | |
| 83 | + | ChecksOp::ListCheckSuitesForRef => "List a commit's check suites", | |
| 84 | + | ChecksOp::GetCheckSuite => "Get a check suite", | |
| 85 | + | ChecksOp::RerequestCheckSuite => "Rerequest a check suite", | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | pub fn description(self) -> &'static str { | |
| 90 | + | match self { | |
| 91 | + | ChecksOp::CreateCommitStatus => "Set a status on a commit: state (pending, success, failure or error), context (what reports it, such as ci/build; default by default), description (at most 140 characters) and target_url (where to see more). A context has one status per commit: setting it again replaces it. A status is a check: a required check or a ruleset's required status check of its context is met by it. Needs the Write role; publishes status.created.", | |
| 92 | + | ChecksOp::ListCommitStatuses => "List the statuses on a commit, named by its SHA, a branch or a tag: one per context, newest first. Check runs are listed by list_check_runs_for_ref instead.", | |
| 93 | + | ChecksOp::GetCombinedStatus => "A commit's statuses, one per context, and the state they add up to: failure if any failed or errored, pending if any is pending or there are none, success otherwise. Named by its SHA, a branch or a tag.", | |
| 94 | + | ChecksOp::CreateCheckRun => "Report a check run on a commit: name and head_sha are required; status (queued, in_progress or completed; queued by default), conclusion (success, failure, neutral, cancelled, skipped, timed_out or action_required; it makes the run completed), started_at and completed_at (RFC 3339; filled in when left out), details_url (your page for it), external_id (your id for it), output (title, summary and text in Markdown, and up to 50 annotations: path, start_line, end_line, start_column, end_column, annotation_level notice, warning or failure, message, title, raw_details) and actions (up to 3 buttons: label, description, identifier). app names who reports it, by default your token's name. Runs are grouped per reporter and commit into a check suite. A check run is a check: a required check of its name is met by it, a cancelled one failing. Needs the Write role; publishes check_run.created, and check_run.completed when it is created completed.", | |
| 95 | + | ChecksOp::UpdateCheckRun => "Change a check run reported through the API, by id (cr_…). Fields left out stay as they are; output annotations are added to the ones it has (at most 1000 in all); actions, when given, replace its buttons. Giving a conclusion completes it. Publishes check_run.completed when it completes. A g1t Actions job's check run is its workflow's and cannot be changed.", | |
| 96 | + | ChecksOp::GetCheckRun => "Get a check run by id: cr_… for one reported through the API, or a g1t Actions job's id (job_…), whose workflow run is its suite and whose workflow says its name, run and event in workflow.", | |
| 97 | + | ChecksOp::ListCheckRunAnnotations => "List a check run's annotations in the order they were reported: path, start_line, end_line, start_column, end_column, annotation_level (notice, warning or failure), message, title and raw_details.", | |
| 98 | + | ChecksOp::RerequestCheckRun => "Ask for a check run to run again. For one reported through the API, its reporter is sent check_run.rerequested; for a g1t Actions job, its workflow run runs again (which also needs workflows:write). Needs the Write role.", | |
| 99 | + | ChecksOp::ListCheckRunsForRef => "List a commit's check runs, named by its SHA, a branch or a tag: those reported through the API and each job of its g1t Actions workflow runs. filter latest (the default) gives each name's latest run and each workflow's latest run per event; all gives every one. Narrow with check_name, status and app (a reporter's slug; actions for g1t Actions).", | |
| 100 | + | ChecksOp::ListCheckSuitesForRef => "List a commit's check suites, named by its SHA, a branch or a tag: one per reporter that reported check runs on it through the API, and one per g1t Actions workflow run, with its status and conclusion worked out from its latest check runs. Narrow with app and check_name.", | |
| 101 | + | ChecksOp::GetCheckSuite => "Get a check suite by id: cs_… for a reporter's, or a g1t Actions workflow run's id (run_…).", | |
| 102 | + | ChecksOp::RerequestCheckSuite => "Ask for a check suite to run again: its reporter is sent check_suite.rerequested, or a g1t Actions workflow run runs again (which also needs workflows:write). Needs the Write role.", | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | /// Whether it only reads, which anyone who can see the repository may. | |
| 107 | + | pub fn reads(self) -> bool { | |
| 108 | + | !matches!( | |
| 109 | + | self, | |
| 110 | + | ChecksOp::CreateCommitStatus | |
| 111 | + | | ChecksOp::CreateCheckRun | |
| 112 | + | | ChecksOp::UpdateCheckRun | |
| 113 | + | | ChecksOp::RerequestCheckRun | |
| 114 | + | | ChecksOp::RerequestCheckSuite | |
| 115 | + | ) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | pub fn input(self) -> Value { | |
| 119 | + | let repo = || json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." }); | |
| 120 | + | let git_ref = || json!({ "type": "string", "description": "The commit: its SHA, a branch or a tag." }); | |
| 121 | + | let run_id = || json!({ "type": "string", "description": "The check run's id: cr_…, or a g1t Actions job's job_…" }); | |
| 122 | + | let suite_id = || json!({ "type": "string", "description": "The check suite's id: cs_…, or a g1t Actions run's run_…" }); | |
| 123 | + | let run_fields = |mut properties: Value, creating: bool| { | |
| 124 | + | properties["name"] = json!({ "type": "string", "description": "The check's name, at most 100 characters, such as lint or coverage." }); | |
| 125 | + | if creating { | |
| 126 | + | properties["head_sha"] = json!({ "type": "string", "description": "The commit's full SHA (or a branch or tag, read as the commit it points to now)." }); | |
| 127 | + | properties["app"] = json!({ "type": "string", "description": "Who reports it, shown with it and grouping its check suite: by default your token's name." }); | |
| 128 | + | } | |
| 129 | + | properties["status"] = json!({ "type": "string", "enum": STATUSES, "description": "Where it is: queued, in_progress or completed." }); | |
| 130 | + | properties["conclusion"] = json!({ "type": "string", "enum": CONCLUSIONS, "description": "How it came out; giving one completes it." }); | |
| 131 | + | properties["started_at"] = json!({ "type": "string", "description": "When it started, RFC 3339." }); | |
| 132 | + | properties["completed_at"] = json!({ "type": "string", "description": "When it completed, RFC 3339." }); | |
| 133 | + | properties["details_url"] = json!({ "type": "string", "description": "Your page for it, http or https." }); | |
| 134 | + | properties["external_id"] = json!({ "type": "string", "description": "Your id for it." }); | |
| 135 | + | properties["output"] = json!({ | |
| 136 | + | "type": "object", | |
| 137 | + | "description": "Its report: a title, a Markdown summary and text, and annotations on lines of files (at most 50 a request).", | |
| 138 | + | "properties": { | |
| 139 | + | "title": { "type": "string" }, | |
| 140 | + | "summary": { "type": "string" }, | |
| 141 | + | "text": { "type": "string" }, | |
| 142 | + | "annotations": { | |
| 143 | + | "type": "array", | |
| 144 | + | "items": { | |
| 145 | + | "type": "object", | |
| 146 | + | "properties": { | |
| 147 | + | "path": { "type": "string" }, | |
| 148 | + | "start_line": { "type": "integer" }, | |
| 149 | + | "end_line": { "type": "integer" }, | |
| 150 | + | "start_column": { "type": "integer" }, | |
| 151 | + | "end_column": { "type": "integer" }, | |
| 152 | + | "annotation_level": { "type": "string", "enum": ANNOTATION_LEVELS }, | |
| 153 | + | "message": { "type": "string" }, | |
| 154 | + | "title": { "type": "string" }, | |
| 155 | + | "raw_details": { "type": "string" }, | |
| 156 | + | }, | |
| 157 | + | "required": ["path", "start_line", "end_line", "annotation_level", "message"], | |
| 158 | + | }, | |
| 159 | + | }, | |
| 160 | + | }, | |
| 161 | + | }); | |
| 162 | + | properties["actions"] = json!({ | |
| 163 | + | "type": "array", | |
| 164 | + | "description": "Up to 3 buttons on its page. Pressing one sends you check_run.requested_action with its identifier.", | |
| 165 | + | "items": { | |
| 166 | + | "type": "object", | |
| 167 | + | "properties": { | |
| 168 | + | "label": { "type": "string", "description": "At most 20 characters." }, | |
| 169 | + | "description": { "type": "string", "description": "At most 40 characters." }, | |
| 170 | + | "identifier": { "type": "string", "description": "At most 20 characters." }, | |
| 171 | + | }, | |
| 172 | + | "required": ["label", "description", "identifier"], | |
| 173 | + | }, | |
| 174 | + | }); | |
| 175 | + | properties | |
| 176 | + | }; | |
| 177 | + | let (properties, required): (Value, &[&str]) = match self { | |
| 178 | + | ChecksOp::CreateCommitStatus => ( | |
| 179 | + | json!({ | |
| 180 | + | "repo": repo(), | |
| 181 | + | "sha": { "type": "string", "description": "The commit's full SHA." }, | |
| 182 | + | "state": { "type": "string", "enum": STATUS_STATES, "description": "pending, success, failure or error." }, | |
| 183 | + | "context": { "type": "string", "description": "What reports it, such as ci/build; default when left out." }, | |
| 184 | + | "description": { "type": "string", "description": "A short word on it, at most 140 characters." }, | |
| 185 | + | "target_url": { "type": "string", "description": "Where to see more, http or https." }, | |
| 186 | + | }), | |
| 187 | + | &["repo", "sha", "state"], | |
| 188 | + | ), | |
| 189 | + | ChecksOp::ListCommitStatuses | ChecksOp::GetCombinedStatus => (json!({ "repo": repo(), "ref": git_ref() }), &["repo", "ref"]), | |
| 190 | + | ChecksOp::CreateCheckRun => (run_fields(json!({ "repo": repo() }), true), &["repo", "name", "head_sha"]), | |
| 191 | + | ChecksOp::UpdateCheckRun => (run_fields(json!({ "repo": repo(), "id": run_id() }), false), &["repo", "id"]), | |
| 192 | + | ChecksOp::GetCheckRun | ChecksOp::ListCheckRunAnnotations | ChecksOp::RerequestCheckRun => { | |
| 193 | + | (json!({ "repo": repo(), "id": run_id() }), &["repo", "id"]) | |
| 194 | + | } | |
| 195 | + | ChecksOp::ListCheckRunsForRef => ( | |
| 196 | + | json!({ | |
| 197 | + | "repo": repo(), | |
| 198 | + | "ref": git_ref(), | |
| 199 | + | "check_name": { "type": "string", "description": "Only runs of this name." }, | |
| 200 | + | "status": { "type": "string", "enum": STATUSES, "description": "Only runs in this status." }, | |
| 201 | + | "app": { "type": "string", "description": "Only this reporter's runs, by slug: actions for g1t Actions." }, | |
| 202 | + | "filter": { "type": "string", "enum": ["latest", "all"], "description": "latest (the default) or all." }, | |
| 203 | + | }), | |
| 204 | + | &["repo", "ref"], | |
| 205 | + | ), | |
| 206 | + | ChecksOp::ListCheckSuitesForRef => ( | |
| 207 | + | json!({ | |
| 208 | + | "repo": repo(), | |
| 209 | + | "ref": git_ref(), | |
| 210 | + | "app": { "type": "string", "description": "Only this reporter's suites, by slug." }, | |
| 211 | + | "check_name": { "type": "string", "description": "Only suites with a run of this name." }, | |
| 212 | + | }), | |
| 213 | + | &["repo", "ref"], | |
| 214 | + | ), | |
| 215 | + | ChecksOp::GetCheckSuite | ChecksOp::RerequestCheckSuite => (json!({ "repo": repo(), "id": suite_id() }), &["repo", "id"]), | |
| 216 | + | }; | |
| 217 | + | json!({ "type": "object", "properties": properties, "required": required }) | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | fn text(input: &Value, key: &str) -> Option<String> { | |
| 222 | + | match &input[key] { | |
| 223 | + | Value::String(text) => Some(text.trim().to_owned()).filter(|text| !text.is_empty()), | |
| 224 | + | _ => None, | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | /// A key in `camelCase`, as the contracts read it: `start_line` is `startLine`. | |
| 229 | + | fn camel_key(key: &str) -> String { | |
| 230 | + | let mut out = String::with_capacity(key.len()); | |
| 231 | + | let mut upper = false; | |
| 232 | + | for c in key.chars() { | |
| 233 | + | if c == '_' { | |
| 234 | + | upper = true; | |
| 235 | + | } else if upper { | |
| 236 | + | out.extend(c.to_uppercase()); | |
| 237 | + | upper = false; | |
| 238 | + | } else { | |
| 239 | + | out.push(c); | |
| 240 | + | } | |
| 241 | + | } | |
| 242 | + | out | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | fn camel(value: &Value) -> Value { | |
| 246 | + | match value { | |
| 247 | + | Value::Object(fields) => Value::Object(fields.iter().map(|(key, value)| (camel_key(key), camel(value))).collect::<Map<_, _>>()), | |
| 248 | + | Value::Array(items) => Value::Array(items.iter().map(camel).collect()), | |
| 249 | + | other => other.clone(), | |
| 250 | + | } | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | /// A check run's fields from a request body. | |
| 254 | + | pub(crate) fn run_input(input: &Value) -> std::result::Result<CheckRunInput, String> { | |
| 255 | + | const FIELDS: [&str; 10] = | |
| 256 | + | ["name", "head_sha", "status", "conclusion", "started_at", "completed_at", "details_url", "external_id", "output", "actions"]; | |
| 257 | + | let mut fields = Map::new(); | |
| 258 | + | for key in FIELDS { | |
| 259 | + | if let Some(value) = input.get(key).filter(|value| !value.is_null()) { | |
| 260 | + | fields.insert(camel_key(key), camel(value)); | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | serde_json::from_value(Value::Object(fields)).map_err(|error| format!("The check run could not be read: {error}")) | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | /// Pages on the site, which the work service names by path, as full | |
| 267 | + | /// addresses. | |
| 268 | + | fn absolute(value: Value, site: &str) -> Value { | |
| 269 | + | match value { | |
| 270 | + | Value::Object(fields) => Value::Object( | |
| 271 | + | fields | |
| 272 | + | .into_iter() | |
| 273 | + | .map(|(key, value)| { | |
| 274 | + | let value = match value { | |
| 275 | + | Value::String(path) if matches!(key.as_str(), "htmlUrl" | "detailsUrl" | "targetUrl") && path.starts_with('/') => { | |
| 276 | + | Value::String(format!("{site}{path}")) | |
| 277 | + | } | |
| 278 | + | other => absolute(other, site), | |
| 279 | + | }; | |
| 280 | + | (key, value) | |
| 281 | + | }) | |
| 282 | + | .collect(), | |
| 283 | + | ), | |
| 284 | + | Value::Array(items) => Value::Array(items.into_iter().map(|item| absolute(item, site)).collect()), | |
| 285 | + | other => other, | |
| 286 | + | } | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | async fn call<A: Serialize, T: DeserializeOwned + Serialize>(services: &Services, method: &str, args: &A) -> Result<Outcome<Value>> { | |
| 290 | + | let found: Outcome<T> = g1t_kit::call(&services.work, method, args).await?; | |
| 291 | + | Ok(match found { | |
| 292 | + | Outcome::Ok(value) => Outcome::Ok(absolute(serde_json::to_value(value)?, &services.addresses.site)), | |
| 293 | + | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 294 | + | }) | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | pub async fn run(op: ChecksOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { | |
| 298 | + | let Some(repo) = crate::operations::repo_path(input) else { | |
| 299 | + | return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\".")); | |
| 300 | + | }; | |
| 301 | + | let actor = || viewer.clone().unwrap_or_default(); | |
| 302 | + | let id = || text(input, "id").unwrap_or_default(); | |
| 303 | + | let git_ref = || text(input, "ref").unwrap_or_default(); | |
| 304 | + | match op { | |
| 305 | + | ChecksOp::CreateCommitStatus => { | |
| 306 | + | let args = CreateStatusArgs { | |
| 307 | + | actor: actor(), | |
| 308 | + | repo, | |
| 309 | + | sha: text(input, "sha").unwrap_or_default(), | |
| 310 | + | state: text(input, "state").unwrap_or_default(), | |
| 311 | + | context: text(input, "context"), | |
| 312 | + | description: text(input, "description"), | |
| 313 | + | target_url: text(input, "target_url"), | |
| 314 | + | }; | |
| 315 | + | call::<_, g1t_contracts::work::CommitStatus>(services, "create_commit_status", &args).await | |
| 316 | + | } | |
| 317 | + | ChecksOp::ListCommitStatuses => { | |
| 318 | + | call::<_, Vec<g1t_contracts::work::CommitStatus>>(services, "commit_statuses", &RefArgs { viewer: viewer.clone(), repo, git_ref: git_ref() }).await | |
| 319 | + | } | |
| 320 | + | ChecksOp::GetCombinedStatus => { | |
| 321 | + | call::<_, CombinedStatus>(services, "combined_status", &RefArgs { viewer: viewer.clone(), repo, git_ref: git_ref() }).await | |
| 322 | + | } | |
| 323 | + | ChecksOp::CreateCheckRun | ChecksOp::UpdateCheckRun => { | |
| 324 | + | let run = match run_input(input) { | |
| 325 | + | Ok(run) => run, | |
| 326 | + | Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)), | |
| 327 | + | }; | |
| 328 | + | if op == ChecksOp::CreateCheckRun { | |
| 329 | + | let args = CreateCheckRunArgs { actor: actor(), repo, app: text(input, "app"), run }; | |
| 330 | + | call::<_, CommitCheckRun>(services, "create_check_run", &args).await | |
| 331 | + | } else { | |
| 332 | + | call::<_, CommitCheckRun>(services, "update_check_run", &UpdateCheckRunArgs { actor: actor(), repo, id: id(), run }).await | |
| 333 | + | } | |
| 334 | + | } | |
| 335 | + | ChecksOp::GetCheckRun => call::<_, CommitCheckRun>(services, "get_check_run", &CheckIdArgs { viewer: viewer.clone(), repo, id: id() }).await, | |
| 336 | + | ChecksOp::ListCheckRunAnnotations => { | |
| 337 | + | call::<_, Vec<CheckAnnotation>>(services, "check_run_annotations", &CheckIdArgs { viewer: viewer.clone(), repo, id: id() }).await | |
| 338 | + | } | |
| 339 | + | ChecksOp::GetCheckSuite => call::<_, CommitCheckSuite>(services, "get_check_suite", &CheckIdArgs { viewer: viewer.clone(), repo, id: id() }).await, | |
| 340 | + | ChecksOp::RerequestCheckRun | ChecksOp::RerequestCheckSuite => { | |
| 341 | + | let method = if op == ChecksOp::RerequestCheckRun { "rerequest_check_run" } else { "rerequest_check_suite" }; | |
| 342 | + | let done: Outcome<bool> = g1t_kit::call(&services.work, method, &RerequestArgs { actor: actor(), repo, id: id() }).await?; | |
| 343 | + | Ok(match done { | |
| 344 | + | Outcome::Ok(_) => Outcome::Ok(json!({ "rerequested": true })), | |
| 345 | + | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 346 | + | }) | |
| 347 | + | } | |
| 348 | + | ChecksOp::ListCheckRunsForRef => { | |
| 349 | + | let args = RefCheckRunsArgs { | |
| 350 | + | viewer: viewer.clone(), | |
| 351 | + | repo, | |
| 352 | + | git_ref: git_ref(), | |
| 353 | + | check_name: text(input, "check_name"), | |
| 354 | + | status: text(input, "status"), | |
| 355 | + | app: text(input, "app"), | |
| 356 | + | filter: text(input, "filter"), | |
| 357 | + | }; | |
| 358 | + | call::<_, CheckRunList>(services, "ref_check_runs", &args).await | |
| 359 | + | } | |
| 360 | + | ChecksOp::ListCheckSuitesForRef => { | |
| 361 | + | let args = RefCheckSuitesArgs { viewer: viewer.clone(), repo, git_ref: git_ref(), app: text(input, "app"), check_name: text(input, "check_name") }; | |
| 362 | + | call::<_, CheckSuiteList>(services, "ref_check_suites", &args).await | |
| 363 | + | } | |
| 364 | + | } | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | #[cfg(test)] | |
| 368 | + | mod tests { | |
| 369 | + | use super::*; | |
| 370 | + | ||
| 371 | + | #[test] | |
| 372 | + | fn a_body_in_snake_case_is_a_check_run() { | |
| 373 | + | let body = json!({ | |
| 374 | + | "repo": "acme/web", | |
| 375 | + | "name": "lint", | |
| 376 | + | "head_sha": "a".repeat(40), | |
| 377 | + | "status": "completed", | |
| 378 | + | "conclusion": "failure", | |
| 379 | + | "output": { | |
| 380 | + | "title": "2 problems", | |
| 381 | + | "summary": "**2** problems", | |
| 382 | + | "annotations": [{ "path": "src/a.rs", "start_line": 3, "end_line": 3, "annotation_level": "warning", "message": "unused" }] | |
| 383 | + | }, | |
| 384 | + | "actions": [{ "label": "Fix", "description": "Fix it", "identifier": "fix" }] | |
| 385 | + | }); | |
| 386 | + | let run = run_input(&body).unwrap(); | |
| 387 | + | assert_eq!(run.head_sha.as_deref(), Some("a".repeat(40).as_str())); | |
| 388 | + | let output = run.output.unwrap(); | |
| 389 | + | assert_eq!(output.annotations[0].start_line, 3); | |
| 390 | + | assert_eq!(output.annotations[0].annotation_level, "warning"); | |
| 391 | + | assert_eq!(run.actions.unwrap()[0].identifier, "fix"); | |
| 392 | + | assert!(run_input(&json!({ "output": { "annotations": "no" } })).is_err()); | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | #[test] | |
| 396 | + | fn pages_on_the_site_become_full_addresses() { | |
| 397 | + | let value = json!({ "checkRuns": [{ "htmlUrl": "/acme/web/checks/cr_1", "detailsUrl": "https://ci.example.com/1", "name": "/x" }] }); | |
| 398 | + | let out = absolute(value, "https://g1t.sh"); | |
| 399 | + | assert_eq!(out["checkRuns"][0]["htmlUrl"], "https://g1t.sh/acme/web/checks/cr_1"); | |
| 400 | + | assert_eq!(out["checkRuns"][0]["detailsUrl"], "https://ci.example.com/1"); | |
| 401 | + | assert_eq!(out["checkRuns"][0]["name"], "/x"); | |
| 402 | + | } | |
| 403 | + | ||
| 404 | + | #[test] | |
| 405 | + | fn each_operation_is_described_with_a_schema() { | |
| 406 | + | for op in ChecksOp::ALL { | |
| 407 | + | assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::Checks(op)), "{}", op.name()); | |
| 408 | + | assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name()); | |
| 409 | + | assert!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name()); | |
| 410 | + | assert_eq!(op.reads(), g1t_contracts::scopes::scope_for(op.name()).unwrap().level() == g1t_contracts::scopes::Level::Read, "{}", op.name()); | |
| 411 | + | } | |
| 412 | + | } | |
| 413 | + | } |
| 1 | + | //! Deployments over REST and MCP: a repository's deployments wherever they | |
| 2 | + | //! run, their statuses, and its environments. | |
| 3 | + | //! | |
| 4 | + | //! Any CI reports a deployment and its statuses here; a g1t Actions job | |
| 5 | + | //! with an `environment:` makes them itself, and g1t.page builds are read | |
| 6 | + | //! in alongside. The deployments service decides who may see and report | |
| 7 | + | //! them (Read to see, Write to report) and keeps them; deployments travel | |
| 8 | + | //! in `snake_case` between services too, so a request's fields reach it | |
| 9 | + | //! as they are, and a deployment's `payload` comes back as it was given. | |
| 10 | + | ||
| 11 | + | use g1t_contracts::{FailureCode, Outcome, Viewer}; | |
| 12 | + | use serde_json::{Map, Value, json}; | |
| 13 | + | use worker::Result; | |
| 14 | + | ||
| 15 | + | use crate::operations::{Services, repo_path}; | |
| 16 | + | ||
| 17 | + | /// One operation on deployments. | |
| 18 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 19 | + | pub enum DeploymentsOp { | |
| 20 | + | ListDeployments, | |
| 21 | + | GetDeployment, | |
| 22 | + | CreateDeployment, | |
| 23 | + | ListDeploymentStatuses, | |
| 24 | + | CreateDeploymentStatus, | |
| 25 | + | ListEnvironments, | |
| 26 | + | GetEnvironment, | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /// The states a deployment's status can have. | |
| 30 | + | const STATES: [&str; 6] = ["queued", "in_progress", "success", "failure", "error", "inactive"]; | |
| 31 | + | ||
| 32 | + | impl DeploymentsOp { | |
| 33 | + | /// Every one: `Op::ALL` lists each as `Op::Deployments(…)`, which a | |
| 34 | + | /// test checks against this. | |
| 35 | + | #[cfg(test)] | |
| 36 | + | pub const ALL: [DeploymentsOp; 7] = [ | |
| 37 | + | DeploymentsOp::ListDeployments, | |
| 38 | + | DeploymentsOp::GetDeployment, | |
| 39 | + | DeploymentsOp::CreateDeployment, | |
| 40 | + | DeploymentsOp::ListDeploymentStatuses, | |
| 41 | + | DeploymentsOp::CreateDeploymentStatus, | |
| 42 | + | DeploymentsOp::ListEnvironments, | |
| 43 | + | DeploymentsOp::GetEnvironment, | |
| 44 | + | ]; | |
| 45 | + | ||
| 46 | + | pub fn name(self) -> &'static str { | |
| 47 | + | match self { | |
| 48 | + | DeploymentsOp::ListDeployments => "list_deployments", | |
| 49 | + | DeploymentsOp::GetDeployment => "get_deployment", | |
| 50 | + | DeploymentsOp::CreateDeployment => "create_deployment", | |
| 51 | + | DeploymentsOp::ListDeploymentStatuses => "list_deployment_statuses", | |
| 52 | + | DeploymentsOp::CreateDeploymentStatus => "create_deployment_status", | |
| 53 | + | DeploymentsOp::ListEnvironments => "list_environments", | |
| 54 | + | DeploymentsOp::GetEnvironment => "get_environment", | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /// For the API reference: "List deployments". | |
| 59 | + | pub fn title(self) -> &'static str { | |
| 60 | + | match self { | |
| 61 | + | DeploymentsOp::ListDeployments => "List deployments", | |
| 62 | + | DeploymentsOp::GetDeployment => "Get a deployment", | |
| 63 | + | DeploymentsOp::CreateDeployment => "Create a deployment", | |
| 64 | + | DeploymentsOp::ListDeploymentStatuses => "List deployment statuses", | |
| 65 | + | DeploymentsOp::CreateDeploymentStatus => "Create a deployment status", | |
| 66 | + | DeploymentsOp::ListEnvironments => "List environments", | |
| 67 | + | DeploymentsOp::GetEnvironment => "Get an environment", | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | pub fn description(self) -> &'static str { | |
| 72 | + | match self { | |
| 73 | + | DeploymentsOp::ListDeployments => "List a repository's deployments wherever they run, newest first: those reported through this API, those g1t Actions made for jobs with an `environment:`, and g1t.page builds (production and previews). Each has its environment, ref and sha, task, description, payload, transient_environment and production_environment, its latest state (queued, in_progress, success, failure, error or inactive), environment_url and log_url, creator, and source (api, actions or g1t_page), with run_id and run_url for g1t Actions and project and number for g1t.page. Filter by environment, ref, sha (or a prefix), task, state, source and creator; page with page and per_page (30 by default, at most 100). total_count counts every match. Needs the Read role; a public repository's are open to anyone.", | |
| 74 | + | DeploymentsOp::GetDeployment => "Get one deployment by id (dep_… for a reported one, dpl_… for a g1t.page build), with every status it has had, oldest first. Needs the Read role.", | |
| 75 | + | DeploymentsOp::CreateDeployment => "Report a deployment of a commit to an environment, from any CI or script. ref is the branch, tag or commit deployed; sha is resolved from it unless you give the whole commit id. environment is production unless you say (any name up to 255 characters, such as staging or review/feature-x; names are matched without regard to case, and the first spelling is kept). task is deploy unless you say; payload is any JSON object, returned as given. production_environment is true for an environment named production unless you say; transient_environment marks one that goes away, such as a review app. Its first status is state (queued unless you say), with environment_url and log_url. Each status also shows on the commit as the check `deploy / <environment>`, which a ruleset's required_deployments rule can require. Needs the Write role. Returns the deployment with its statuses.", | |
| 76 | + | DeploymentsOp::ListDeploymentStatuses => "List a deployment's statuses, newest first: each with its state, description, environment_url, log_url, creator and created_at. A g1t.page build's are read from the build itself. Needs the Read role.", | |
| 77 | + | DeploymentsOp::CreateDeploymentStatus => "Add a status to a reported deployment: state (queued, in_progress, success, failure, error or inactive), description, environment_url (where it is served) and log_url (where its output can be read). The deployment takes its state, and any address it gives. A success with auto_inactive (true unless you say) makes the environment's older successful deployments inactive. The commit's `deploy / <environment>` check follows: pending while queued or in progress, then success, failure or error. A g1t.page build's statuses come from the build and cannot be added to. Needs the Write role.", | |
| 78 | + | DeploymentsOp::ListEnvironments => "List the environments a repository's deployments went to, those people use directly first (production by name before others), then the most recently deployed. Each has its name, url (where its current deployment is served), production_environment, transient_environment, deployments_count, latest (its newest deployment, whatever its state), current (its newest successful deployment that is still active) and updated_at. total_count counts deployments across every environment. Needs the Read role.", | |
| 79 | + | DeploymentsOp::GetEnvironment => "Get one environment by name, matched without regard to case, with its current and latest deployments. A name with slashes is URL-encoded in the path. Needs the Read role.", | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | /// Whether it changes anything. | |
| 84 | + | pub fn writes(self) -> bool { | |
| 85 | + | matches!(self, DeploymentsOp::CreateDeployment | DeploymentsOp::CreateDeploymentStatus) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | pub fn input(self) -> Value { | |
| 89 | + | let repo = json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." }); | |
| 90 | + | let id = json!({ "type": "string", "description": "The deployment's id: dep_… for a reported one, dpl_… for a g1t.page build." }); | |
| 91 | + | let state = |what: &str| json!({ "type": "string", "enum": STATES, "description": what }); | |
| 92 | + | let address = |what: &str| json!({ "type": "string", "description": what }); | |
| 93 | + | let (properties, required): (Value, &[&str]) = match self { | |
| 94 | + | DeploymentsOp::ListDeployments => ( | |
| 95 | + | json!({ | |
| 96 | + | "repo": repo, | |
| 97 | + | "environment": { "type": "string", "description": "Only this environment's, matched without regard to case." }, | |
| 98 | + | "ref": { "type": "string", "description": "Only deployments of this branch, tag or commit as it was given." }, | |
| 99 | + | "sha": { "type": "string", "description": "Only deployments of this commit, or of commits starting with it." }, | |
| 100 | + | "task": { "type": "string", "description": "Only this task's, such as deploy." }, | |
| 101 | + | "state": state("Only deployments whose latest status has this state."), | |
| 102 | + | "source": { "type": "string", "enum": ["api", "actions", "g1t_page"], "description": "Only those reported through the API, made by g1t Actions, or built on g1t.page." }, | |
| 103 | + | "creator": { "type": "string", "description": "Only those this username (or g1t) made." }, | |
| 104 | + | "page": { "type": "integer", "description": "Which page, from 1." }, | |
| 105 | + | "per_page": { "type": "integer", "description": "How many a page holds, 1 to 100; 30 by default." }, | |
| 106 | + | }), | |
| 107 | + | &["repo"], | |
| 108 | + | ), | |
| 109 | + | DeploymentsOp::GetDeployment | DeploymentsOp::ListDeploymentStatuses => (json!({ "repo": repo, "id": id }), &["repo", "id"]), | |
| 110 | + | DeploymentsOp::CreateDeployment => ( | |
| 111 | + | json!({ | |
| 112 | + | "repo": repo, | |
| 113 | + | "ref": { "type": "string", "description": "The branch, tag or commit deployed, such as main or v1.4.0." }, | |
| 114 | + | "sha": { "type": "string", "description": "The commit deployed; resolved from ref when left out." }, | |
| 115 | + | "environment": { "type": "string", "description": "Where it went, such as production, staging or review/feature-x; production unless you say." }, | |
| 116 | + | "task": { "type": "string", "description": "What kind of deployment, such as deploy or deploy:migrations; deploy unless you say." }, | |
| 117 | + | "description": { "type": "string", "description": "A short note, at most 1,000 characters." }, | |
| 118 | + | "payload": { "type": "object", "description": "Anything else to keep with it, as a JSON object (a JSON string of one is read too), at most 64 KB. Returned as given." }, | |
| 119 | + | "production_environment": { "type": "boolean", "description": "Whether people use this environment directly. True for production unless you say." }, | |
| 120 | + | "transient_environment": { "type": "boolean", "description": "Whether the environment goes away, such as a review app. False unless you say." }, | |
| 121 | + | "state": state("Its first status: queued unless you say. Report in_progress, then success or failure, as it goes."), | |
| 122 | + | "environment_url": address("Where it is served, an http(s) address."), | |
| 123 | + | "log_url": address("Where its output can be read, an http(s) address."), | |
| 124 | + | }), | |
| 125 | + | &["repo", "ref"], | |
| 126 | + | ), | |
| 127 | + | DeploymentsOp::CreateDeploymentStatus => ( | |
| 128 | + | json!({ | |
| 129 | + | "repo": repo, | |
| 130 | + | "id": id, | |
| 131 | + | "state": state("Where it is now."), | |
| 132 | + | "description": { "type": "string", "description": "A short note, at most 1,000 characters." }, | |
| 133 | + | "environment_url": address("Where it is served, an http(s) address."), | |
| 134 | + | "log_url": address("Where its output can be read, an http(s) address."), | |
| 135 | + | "auto_inactive": { "type": "boolean", "description": "On a success, make the environment's older successful deployments inactive. True unless you say." }, | |
| 136 | + | }), | |
| 137 | + | &["repo", "id", "state"], | |
| 138 | + | ), | |
| 139 | + | DeploymentsOp::ListEnvironments => (json!({ "repo": repo }), &["repo"]), | |
| 140 | + | DeploymentsOp::GetEnvironment => ( | |
| 141 | + | json!({ | |
| 142 | + | "repo": repo, | |
| 143 | + | "environment": { "type": "string", "description": "The environment's name, such as production." }, | |
| 144 | + | }), | |
| 145 | + | &["repo", "environment"], | |
| 146 | + | ), | |
| 147 | + | }; | |
| 148 | + | json!({ "type": "object", "properties": properties, "required": required }) | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | /// The fields of `input` named in `keys` that were given, as they were. | |
| 153 | + | fn given(input: &Value, keys: &[&str]) -> Map<String, Value> { | |
| 154 | + | keys.iter() | |
| 155 | + | .filter_map(|key| input.get(*key).filter(|value| !value.is_null()).map(|value| ((*key).to_owned(), value.clone()))) | |
| 156 | + | .collect() | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | /// A query parameter's number, given as a number or as text. | |
| 160 | + | fn number(input: &Value, key: &str) -> Option<u64> { | |
| 161 | + | match &input[key] { | |
| 162 | + | Value::Number(number) => number.as_u64(), | |
| 163 | + | Value::String(digits) => digits.trim().parse().ok(), | |
| 164 | + | _ => None, | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | /// A flag given as a boolean or as text. | |
| 169 | + | fn flag(input: &Value, key: &str) -> Option<bool> { | |
| 170 | + | match &input[key] { | |
| 171 | + | Value::Bool(value) => Some(*value), | |
| 172 | + | Value::String(text) => match text.trim() { | |
| 173 | + | "true" | "1" => Some(true), | |
| 174 | + | "false" | "0" => Some(false), | |
| 175 | + | _ => None, | |
| 176 | + | }, | |
| 177 | + | _ => None, | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | /// The arguments the deployments service takes for `op`, from the | |
| 182 | + | /// operation's input; `Err` says what is missing or wrong. | |
| 183 | + | pub(crate) fn args(op: DeploymentsOp, input: &Value) -> std::result::Result<(&'static str, Map<String, Value>), String> { | |
| 184 | + | let mut out = Map::new(); | |
| 185 | + | let repo = repo_path(input).ok_or("Give the repository as \"owner/name\".")?; | |
| 186 | + | out.insert("repo".into(), json!({ "namespace": repo.namespace, "name": repo.name })); | |
| 187 | + | let id = || input["id"].as_str().map(str::trim).filter(|id| !id.is_empty()).map(str::to_owned).ok_or("Give the deployment's id."); | |
| 188 | + | if let Some(state) = input["state"].as_str() | |
| 189 | + | && !STATES.contains(&state) | |
| 190 | + | { | |
| 191 | + | return Err(format!("{state} is not a state: use queued, in_progress, success, failure, error or inactive.")); | |
| 192 | + | } | |
| 193 | + | let method = match op { | |
| 194 | + | DeploymentsOp::ListDeployments => { | |
| 195 | + | out.extend(given(input, &["environment", "ref", "sha", "task", "state", "source", "creator"])); | |
| 196 | + | if let Some(page) = number(input, "page") { | |
| 197 | + | out.insert("page".into(), page.into()); | |
| 198 | + | } | |
| 199 | + | if let Some(per_page) = number(input, "per_page") { | |
| 200 | + | out.insert("per_page".into(), per_page.into()); | |
| 201 | + | } | |
| 202 | + | "list_deployments" | |
| 203 | + | } | |
| 204 | + | DeploymentsOp::GetDeployment => { | |
| 205 | + | out.insert("id".into(), id()?.into()); | |
| 206 | + | "get_deployment" | |
| 207 | + | } | |
| 208 | + | DeploymentsOp::ListDeploymentStatuses => { | |
| 209 | + | out.insert("id".into(), id()?.into()); | |
| 210 | + | "list_deployment_statuses" | |
| 211 | + | } | |
| 212 | + | DeploymentsOp::CreateDeployment => { | |
| 213 | + | if input["ref"].as_str().is_none_or(|text| text.trim().is_empty()) && input["sha"].as_str().is_none() { | |
| 214 | + | return Err("Give the ref deployed: a branch, a tag or a commit.".to_owned()); | |
| 215 | + | } | |
| 216 | + | out.extend(given( | |
| 217 | + | input, | |
| 218 | + | &["ref", "sha", "environment", "task", "description", "payload", "state", "environment_url", "log_url"], | |
| 219 | + | )); | |
| 220 | + | for key in ["production_environment", "transient_environment"] { | |
| 221 | + | if let Some(value) = flag(input, key) { | |
| 222 | + | out.insert(key.into(), value.into()); | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | "create_deployment" | |
| 226 | + | } | |
| 227 | + | DeploymentsOp::CreateDeploymentStatus => { | |
| 228 | + | out.insert("id".into(), id()?.into()); | |
| 229 | + | if input["state"].as_str().is_none() { | |
| 230 | + | return Err("Give the status's state: queued, in_progress, success, failure, error or inactive.".to_owned()); | |
| 231 | + | } | |
| 232 | + | out.extend(given(input, &["state", "description", "environment_url", "log_url"])); | |
| 233 | + | if let Some(value) = flag(input, "auto_inactive") { | |
| 234 | + | out.insert("auto_inactive".into(), value.into()); | |
| 235 | + | } | |
| 236 | + | "create_deployment_status" | |
| 237 | + | } | |
| 238 | + | DeploymentsOp::ListEnvironments => "list_environments", | |
| 239 | + | DeploymentsOp::GetEnvironment => { | |
| 240 | + | let name = input["environment"].as_str().map(str::trim).filter(|name| !name.is_empty()).ok_or("Name the environment.")?; | |
| 241 | + | out.insert("name".into(), name.into()); | |
| 242 | + | "get_environment" | |
| 243 | + | } | |
| 244 | + | }; | |
| 245 | + | Ok((method, out)) | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | pub async fn run(op: DeploymentsOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { | |
| 249 | + | let (method, mut args) = match args(op, input) { | |
| 250 | + | Ok(found) => found, | |
| 251 | + | Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)), | |
| 252 | + | }; | |
| 253 | + | if op.writes() { | |
| 254 | + | let Some(actor) = viewer else { | |
| 255 | + | return Ok(Outcome::fail(FailureCode::Unauthenticated, "Reporting a deployment needs a g1t access token.")); | |
| 256 | + | }; | |
| 257 | + | args.insert("actor".into(), serde_json::to_value(actor)?); | |
| 258 | + | } else { | |
| 259 | + | args.insert("viewer".into(), serde_json::to_value(viewer)?); | |
| 260 | + | } | |
| 261 | + | g1t_kit::call(&services.deployments, method, &Value::Object(args)).await | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | #[cfg(test)] | |
| 265 | + | mod tests { | |
| 266 | + | use super::*; | |
| 267 | + | ||
| 268 | + | #[test] | |
| 269 | + | fn a_request_becomes_the_services_arguments() { | |
| 270 | + | let (method, args) = super::args( | |
| 271 | + | DeploymentsOp::CreateDeployment, | |
| 272 | + | &json!({ "repo": "acme/web", "ref": "main", "environment": "staging", "payload": { "buildId": 7 }, "production_environment": "false", "ignored": 1 }), | |
| 273 | + | ) | |
| 274 | + | .unwrap(); | |
| 275 | + | assert_eq!(method, "create_deployment"); | |
| 276 | + | assert_eq!(args["repo"], json!({ "namespace": "acme", "name": "web" })); | |
| 277 | + | assert_eq!(args["payload"], json!({ "buildId": 7 }), "a payload passes through as given"); | |
| 278 | + | assert_eq!(args["production_environment"], json!(false)); | |
| 279 | + | assert!(!args.contains_key("ignored")); | |
| 280 | + | let (method, args) = super::args(DeploymentsOp::ListDeployments, &json!({ "repo": "acme/web", "page": "2", "state": "failure" })).unwrap(); | |
| 281 | + | assert_eq!(method, "list_deployments"); | |
| 282 | + | assert_eq!(args["page"], json!(2)); | |
| 283 | + | assert!(super::args(DeploymentsOp::ListDeployments, &json!({ "repo": "acme/web", "state": "done" })).is_err()); | |
| 284 | + | assert!(super::args(DeploymentsOp::CreateDeployment, &json!({ "repo": "acme/web" })).is_err()); | |
| 285 | + | assert!(super::args(DeploymentsOp::CreateDeploymentStatus, &json!({ "repo": "acme/web", "id": "dep_1" })).is_err()); | |
| 286 | + | let (method, args) = super::args(DeploymentsOp::GetEnvironment, &json!({ "repo": "acme/web", "environment": "review/x" })).unwrap(); | |
| 287 | + | assert_eq!((method, args["name"].clone()), ("get_environment", json!("review/x"))); | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | #[test] | |
| 291 | + | fn each_operation_is_described_with_a_schema() { | |
| 292 | + | for op in DeploymentsOp::ALL { | |
| 293 | + | assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name()); | |
| 294 | + | assert!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name()); | |
| 295 | + | } | |
| 296 | + | } | |
| 297 | + | } |
| 4 | 4 | //! operations (see [`operations::Op`]), which call the services that own | |
| 5 | 5 | //! the data. This Worker holds none. | |
| 6 | 6 | ||
| 7 | + | mod about; | |
| 7 | 8 | mod addresses; | |
| 8 | 9 | mod alerts; | |
| 9 | 10 | mod audit; | |
| 10 | 11 | mod billing; | |
| 11 | 12 | mod blobs; | |
| 13 | + | mod checks; | |
| 14 | + | mod deployments; | |
| 12 | 15 | mod mcp; | |
| 13 | 16 | mod notifications; | |
| 14 | 17 | mod oauth; | |
| 15 | 18 | mod openapi; | |
| 16 | 19 | mod pins; | |
| 20 | + | mod projects; | |
| 17 | 21 | mod operations; | |
| 18 | 22 | mod renamed; | |
| 19 | 23 | #[cfg(test)] |
| 7 | 7 | use g1t_contracts::scopes::scope_for; | |
| 8 | 8 | use serde_json::{Map, Value, json}; | |
| 9 | 9 | ||
| 10 | + | use crate::about::AboutOp; | |
| 11 | + | use crate::deployments::DeploymentsOp; | |
| 10 | 12 | use crate::operations::Op; | |
| 13 | + | use crate::checks::ChecksOp; | |
| 11 | 14 | use crate::rules::RulesOp; | |
| 12 | 15 | use crate::security::SecurityOp; | |
| 13 | 16 | use crate::rest::{ROUTES, Route}; | |
| 46 | 49 | &[Op::ListPinnedProjects, Op::PinProject, Op::UnpinProject, Op::ReorderPinnedProjects], | |
| 47 | 50 | ), | |
| 48 | 51 | ( | |
| 52 | + | "Projects", | |
| 53 | + | "A project is what a workspace builds and runs, from a repository or a root directory in one. Each says what it is, where it runs and where to find it: its homepage, docs and other links.", | |
| 54 | + | &[Op::ListProjects, Op::GetProject, Op::UpdateProject], | |
| 55 | + | ), | |
| 56 | + | ( | |
| 49 | 57 | "Workspaces", | |
| 50 | 58 | "A workspace owns repositories and is the first part of their address. People and agents work in workspaces.", | |
| 51 | 59 | &[Op::GetWorkspace, Op::CreateWorkspace, Op::UpdateWorkspace, Op::DeleteWorkspace], | |
| 102 | 110 | ], | |
| 103 | 111 | ), | |
| 104 | 112 | ( | |
| 113 | + | "Repository insights", | |
| 114 | + | "What a repository's default branch says about it, read in the background and kept by commit: the languages it is written in, who made it, and its license.", | |
| 115 | + | &[Op::About(AboutOp::GetLanguages), Op::About(AboutOp::ListContributors), Op::About(AboutOp::GetLicense)], | |
| 116 | + | ), | |
| 117 | + | ( | |
| 118 | + | "Stars", | |
| 119 | + | "Starring a repository, to keep it and to say you like it: who starred one, and what you starred.", | |
| 120 | + | &[ | |
| 121 | + | Op::About(AboutOp::ListStargazers), | |
| 122 | + | Op::About(AboutOp::ListStarred), | |
| 123 | + | Op::About(AboutOp::CheckStarred), | |
| 124 | + | Op::About(AboutOp::Star), | |
| 125 | + | Op::About(AboutOp::Unstar), | |
| 126 | + | ], | |
| 127 | + | ), | |
| 128 | + | ( | |
| 129 | + | "Releases", | |
| 130 | + | "A release is a tag published with a title and notes. The latest is the newest published one that is neither a draft nor a prerelease.", | |
| 131 | + | &[ | |
| 132 | + | Op::About(AboutOp::ListReleases), | |
| 133 | + | Op::About(AboutOp::CreateRelease), | |
| 134 | + | Op::About(AboutOp::GetLatestRelease), | |
| 135 | + | Op::About(AboutOp::GetReleaseByTag), | |
| 136 | + | Op::About(AboutOp::GetRelease), | |
| 137 | + | Op::About(AboutOp::UpdateRelease), | |
| 138 | + | Op::About(AboutOp::DeleteRelease), | |
| 139 | + | ], | |
| 140 | + | ), | |
| 141 | + | ( | |
| 105 | 142 | "Access", | |
| 106 | 143 | "Who can do what in a repository: repository roles, people given a role on one repository (outside collaborators when they are not members), invitations, and a workspace's base permission.", | |
| 107 | 144 | &[ | |
| 219 | 256 | ], | |
| 220 | 257 | ), | |
| 221 | 258 | ( | |
| 259 | + | "Checks", | |
| 260 | + | "What CI, integrations and g1t Actions say about a commit, in the shapes CI tools already send: statuses (a state per context) and check runs (a lifecycle, a conclusion, a Markdown report, annotations on lines and buttons), grouped per reporter into check suites. g1t Actions jobs are check runs too. Required checks are met by either.", | |
| 261 | + | &[ | |
| 262 | + | Op::Checks(ChecksOp::CreateCommitStatus), | |
| 263 | + | Op::Checks(ChecksOp::ListCommitStatuses), | |
| 264 | + | Op::Checks(ChecksOp::GetCombinedStatus), | |
| 265 | + | Op::Checks(ChecksOp::CreateCheckRun), | |
| 266 | + | Op::Checks(ChecksOp::UpdateCheckRun), | |
| 267 | + | Op::Checks(ChecksOp::GetCheckRun), | |
| 268 | + | Op::Checks(ChecksOp::ListCheckRunAnnotations), | |
| 269 | + | Op::Checks(ChecksOp::RerequestCheckRun), | |
| 270 | + | Op::Checks(ChecksOp::ListCheckRunsForRef), | |
| 271 | + | Op::Checks(ChecksOp::ListCheckSuitesForRef), | |
| 272 | + | Op::Checks(ChecksOp::GetCheckSuite), | |
| 273 | + | Op::Checks(ChecksOp::RerequestCheckSuite), | |
| 274 | + | ], | |
| 275 | + | ), | |
| 276 | + | ( | |
| 222 | 277 | "Issues", | |
| 223 | 278 | "What should change in a repository, with labels and comments. Issues and pull requests share one sequence of numbers.", | |
| 224 | 279 | &[ | |
| 314 | 369 | ], | |
| 315 | 370 | ), | |
| 316 | 371 | ( | |
| 372 | + | "Deployments", | |
| 373 | + | "A repository's deployments wherever they run: reported from any CI with these routes, made by g1t Actions jobs with an `environment:`, or built on g1t.page. Each has statuses, shows on its commit as the check `deploy / <environment>`, and belongs to an environment.", | |
| 374 | + | &[ | |
| 375 | + | Op::Deployments(DeploymentsOp::ListDeployments), | |
| 376 | + | Op::Deployments(DeploymentsOp::CreateDeployment), | |
| 377 | + | Op::Deployments(DeploymentsOp::GetDeployment), | |
| 378 | + | Op::Deployments(DeploymentsOp::ListDeploymentStatuses), | |
| 379 | + | Op::Deployments(DeploymentsOp::CreateDeploymentStatus), | |
| 380 | + | Op::Deployments(DeploymentsOp::ListEnvironments), | |
| 381 | + | Op::Deployments(DeploymentsOp::GetEnvironment), | |
| 382 | + | ], | |
| 383 | + | ), | |
| 384 | + | ( | |
| 317 | 385 | "Secrets and variables", | |
| 318 | 386 | "Values that workflows and deployments read, per repository or for a whole workspace, with a row per environment.", | |
| 319 | 387 | &[ | |
| 359 | 427 | &[ | |
| 360 | 428 | Op::ListIntegrations, | |
| 361 | 429 | Op::ConnectIntegration, | |
| 430 | + | Op::UpdateIntegration, | |
| 362 | 431 | Op::DisconnectIntegration, | |
| 363 | 432 | Op::TestIntegration, | |
| 364 | 433 | Op::GetModelRoutes, | |
| 461 | 530 | Op::ListEvents => "List repository events", | |
| 462 | 531 | Op::ListIntegrations => "List integrations", | |
| 463 | 532 | Op::ConnectIntegration => "Connect an integration", | |
| 533 | + | Op::UpdateIntegration => "Update an integration", | |
| 464 | 534 | Op::DisconnectIntegration => "Disconnect an integration", | |
| 465 | 535 | Op::TestIntegration => "Test an integration", | |
| 466 | 536 | Op::GetContext => "Look up a ticket", | |
| 538 | 608 | Op::PinProject => "Pin a project", | |
| 539 | 609 | Op::UnpinProject => "Unpin a project", | |
| 540 | 610 | Op::ReorderPinnedProjects => "Reorder your pinned projects", | |
| 611 | + | Op::ListProjects => "List a workspace's projects", | |
| 612 | + | Op::GetProject => "Get a project", | |
| 613 | + | Op::UpdateProject => "Update a project", | |
| 541 | 614 | Op::ListTeams => "List teams", | |
| 542 | 615 | Op::GetTeam => "Get a team", | |
| 543 | 616 | Op::CreateTeam => "Create a team", | |
| 557 | 630 | Op::GetCodeownersErrors => "List CODEOWNERS errors", | |
| 558 | 631 | Op::Security(op) => op.title(), | |
| 559 | 632 | Op::Rules(op) => op.title(), | |
| 633 | + | Op::Checks(op) => op.title(), | |
| 634 | + | Op::About(op) => op.title(), | |
| 635 | + | Op::Deployments(op) => op.title(), | |
| 560 | 636 | } | |
| 561 | 637 | } | |
| 562 | 638 | ||
| 692 | 768 | fn operation(route: &Route) -> Value { | |
| 693 | 769 | let op = route.op; | |
| 694 | 770 | let path_params: Vec<&str> = route.params().collect(); | |
| 695 | − | // `owner` and `name` in the path stand for the operation's `repo` input. | |
| 696 | − | let covered = |name: &str| name == "repo" || path_params.contains(&name); | |
| 771 | + | // `owner` and `name` in the path stand for the operation's `repo` input, | |
| 772 | + | // so a `name` in the body, such as a check run's, is the body's own. | |
| 773 | + | let stands_for_repo = | |
| 774 | + | |name: &str| matches!(name, "owner" | "name") && path_params.contains(&"owner") && path_params.contains(&"name"); | |
| 775 | + | let covered = |name: &str| name == "repo" || (path_params.contains(&name) && !stands_for_repo(name)); | |
| 697 | 776 | let all_properties = op.properties(); | |
| 698 | 777 | let mut properties = all_properties.clone(); | |
| 699 | 778 | properties.retain(|name, _| !covered(name)); | |
| 705 | 784 | ||
| 706 | 785 | let mut parameters: Vec<Value> = path_params | |
| 707 | 786 | .iter() | |
| 708 | − | .map(|name| parameter(name, "path", true, all_properties.get(*name))) | |
| 787 | + | .map(|name| parameter(name, "path", true, if stands_for_repo(name) { None } else { all_properties.get(*name) })) | |
| 709 | 788 | .collect(); | |
| 710 | 789 | let mut body = Value::Null; | |
| 711 | 790 | if route.method == "GET" { |
| 26 | 26 | }; | |
| 27 | 27 | ||
| 28 | 28 | use crate::alerts::{AlertKind, SecurityAlert}; | |
| 29 | + | use crate::checks::ChecksOp; | |
| 30 | + | use crate::about::AboutOp; | |
| 31 | + | use crate::deployments::DeploymentsOp; | |
| 29 | 32 | use crate::rules::RulesOp; | |
| 30 | 33 | use crate::security::SecurityOp; | |
| 31 | 34 | use g1t_contracts::inbox::{Reason, Severity, WATCH_EVENTS, WatchLevel}; | |
| 55 | 58 | pub security: Fetcher, | |
| 56 | 59 | /// Projects: a person's pinned ones. | |
| 57 | 60 | pub projects: Fetcher, | |
| 61 | + | /// Deployments wherever they run, and environments. | |
| 62 | + | pub deployments: Fetcher, | |
| 58 | 63 | /// Where the request came in, for its audit entries. | |
| 59 | 64 | pub audit: crate::audit::AuditContext, | |
| 60 | 65 | /// Set for a request made with an agent's token: all it may do. | |
| 79 | 84 | search: env.service("SEARCH")?, | |
| 80 | 85 | security: env.service("SECURITY")?, | |
| 81 | 86 | projects: env.service("PROJECTS")?, | |
| 87 | + | deployments: env.service("DEPLOYMENTS")?, | |
| 82 | 88 | scope: None, | |
| 83 | 89 | audit: crate::audit::AuditContext::default(), | |
| 84 | 90 | addresses: crate::addresses::Addresses::from_env(env), | |
| 169 | 175 | ListEvents, | |
| 170 | 176 | ListIntegrations, | |
| 171 | 177 | ConnectIntegration, | |
| 178 | + | UpdateIntegration, | |
| 172 | 179 | DisconnectIntegration, | |
| 173 | 180 | TestIntegration, | |
| 174 | 181 | GetContext, | |
| 238 | 245 | PinProject, | |
| 239 | 246 | UnpinProject, | |
| 240 | 247 | ReorderPinnedProjects, | |
| 248 | + | ListProjects, | |
| 249 | + | GetProject, | |
| 250 | + | UpdateProject, | |
| 241 | 251 | ListTeams, | |
| 242 | 252 | GetTeam, | |
| 243 | 253 | CreateTeam, | |
| 267 | 277 | Security(SecurityOp), | |
| 268 | 278 | /// Rulesets: rules.rs. | |
| 269 | 279 | Rules(RulesOp), | |
| 280 | + | /// Statuses, check runs and check suites on commits: checks.rs. | |
| 281 | + | Checks(ChecksOp), | |
| 282 | + | /// A repository's languages, contributors, license, stars and releases: about.rs. | |
| 283 | + | About(AboutOp), | |
| 284 | + | /// Deployments wherever they run, and environments: deployments.rs. | |
| 285 | + | Deployments(DeploymentsOp), | |
| 270 | 286 | } | |
| 271 | 287 | ||
| 272 | 288 | fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> { | |
| 629 | 645 | } | |
| 630 | 646 | ||
| 631 | 647 | impl Op { | |
| 632 | − | pub const ALL: [Op; 219] = [ | |
| 648 | + | pub const ALL: [Op; 257] = [ | |
| 633 | 649 | Op::Whoami, | |
| 634 | 650 | Op::GetWorkspace, | |
| 635 | 651 | Op::CreateWorkspace, | |
| 711 | 727 | Op::ListEvents, | |
| 712 | 728 | Op::ListIntegrations, | |
| 713 | 729 | Op::ConnectIntegration, | |
| 730 | + | Op::UpdateIntegration, | |
| 714 | 731 | Op::DisconnectIntegration, | |
| 715 | 732 | Op::TestIntegration, | |
| 716 | 733 | Op::GetContext, | |
| 780 | 797 | Op::PinProject, | |
| 781 | 798 | Op::UnpinProject, | |
| 782 | 799 | Op::ReorderPinnedProjects, | |
| 800 | + | Op::ListProjects, | |
| 801 | + | Op::GetProject, | |
| 802 | + | Op::UpdateProject, | |
| 783 | 803 | Op::ListTeams, | |
| 784 | 804 | Op::GetTeam, | |
| 785 | 805 | Op::CreateTeam, | |
| 849 | 869 | Op::Rules(RulesOp::UpdateWorkspaceRuleset), | |
| 850 | 870 | Op::Rules(RulesOp::DeleteWorkspaceRuleset), | |
| 851 | 871 | Op::Rules(RulesOp::ListWorkspaceRuleEvaluations), | |
| 872 | + | Op::Checks(ChecksOp::CreateCommitStatus), | |
| 873 | + | Op::Checks(ChecksOp::ListCommitStatuses), | |
| 874 | + | Op::Checks(ChecksOp::GetCombinedStatus), | |
| 875 | + | Op::Checks(ChecksOp::CreateCheckRun), | |
| 876 | + | Op::Checks(ChecksOp::UpdateCheckRun), | |
| 877 | + | Op::Checks(ChecksOp::GetCheckRun), | |
| 878 | + | Op::Checks(ChecksOp::ListCheckRunAnnotations), | |
| 879 | + | Op::Checks(ChecksOp::RerequestCheckRun), | |
| 880 | + | Op::Checks(ChecksOp::ListCheckRunsForRef), | |
| 881 | + | Op::Checks(ChecksOp::ListCheckSuitesForRef), | |
| 882 | + | Op::Checks(ChecksOp::GetCheckSuite), | |
| 883 | + | Op::Checks(ChecksOp::RerequestCheckSuite), | |
| 884 | + | Op::About(AboutOp::GetLanguages), | |
| 885 | + | Op::About(AboutOp::ListContributors), | |
| 886 | + | Op::About(AboutOp::GetLicense), | |
| 887 | + | Op::About(AboutOp::ListStargazers), | |
| 888 | + | Op::About(AboutOp::ListStarred), | |
| 889 | + | Op::About(AboutOp::CheckStarred), | |
| 890 | + | Op::About(AboutOp::Star), | |
| 891 | + | Op::About(AboutOp::Unstar), | |
| 892 | + | Op::About(AboutOp::ListReleases), | |
| 893 | + | Op::About(AboutOp::GetLatestRelease), | |
| 894 | + | Op::About(AboutOp::GetReleaseByTag), | |
| 895 | + | Op::About(AboutOp::GetRelease), | |
| 896 | + | Op::About(AboutOp::CreateRelease), | |
| 897 | + | Op::About(AboutOp::UpdateRelease), | |
| 898 | + | Op::About(AboutOp::DeleteRelease), | |
| 899 | + | Op::Deployments(DeploymentsOp::ListDeployments), | |
| 900 | + | Op::Deployments(DeploymentsOp::CreateDeployment), | |
| 901 | + | Op::Deployments(DeploymentsOp::GetDeployment), | |
| 902 | + | Op::Deployments(DeploymentsOp::ListDeploymentStatuses), | |
| 903 | + | Op::Deployments(DeploymentsOp::CreateDeploymentStatus), | |
| 904 | + | Op::Deployments(DeploymentsOp::ListEnvironments), | |
| 905 | + | Op::Deployments(DeploymentsOp::GetEnvironment), | |
| 852 | 906 | ]; | |
| 853 | 907 | ||
| 854 | 908 | pub fn by_name(name: &str) -> Option<Op> { | |
| 939 | 993 | Op::ListEvents => "list_events", | |
| 940 | 994 | Op::ListIntegrations => "list_integrations", | |
| 941 | 995 | Op::ConnectIntegration => "connect_integration", | |
| 996 | + | Op::UpdateIntegration => "update_integration", | |
| 942 | 997 | Op::DisconnectIntegration => "disconnect_integration", | |
| 943 | 998 | Op::TestIntegration => "test_integration", | |
| 944 | 999 | Op::GetContext => "get_context", | |
| 1008 | 1063 | Op::PinProject => "pin_project", | |
| 1009 | 1064 | Op::UnpinProject => "unpin_project", | |
| 1010 | 1065 | Op::ReorderPinnedProjects => "reorder_pinned_projects", | |
| 1066 | + | Op::ListProjects => "list_projects", | |
| 1067 | + | Op::GetProject => "get_project", | |
| 1068 | + | Op::UpdateProject => "update_project", | |
| 1011 | 1069 | Op::ListTeams => "list_teams", | |
| 1012 | 1070 | Op::GetTeam => "get_team", | |
| 1013 | 1071 | Op::CreateTeam => "create_team", | |
| 1035 | 1093 | Op::GetCodeownersErrors => "get_codeowners_errors", | |
| 1036 | 1094 | Op::Security(op) => op.name(), | |
| 1037 | 1095 | Op::Rules(op) => op.name(), | |
| 1096 | + | Op::Checks(op) => op.name(), | |
| 1097 | + | Op::About(op) => op.name(), | |
| 1098 | + | Op::Deployments(op) => op.name(), | |
| 1038 | 1099 | } | |
| 1039 | 1100 | } | |
| 1040 | 1101 | ||
| 1267 | 1328 | "A workspace's integrations: its own model provider, the alert sources that open issues (Sentry, Datadog, webhooks), and the trackers whose tickets agents can read (Jira, Linear). Secrets are never returned. Members only." | |
| 1268 | 1329 | } | |
| 1269 | 1330 | Op::ConnectIntegration => { | |
| 1270 | − | "Connect a workspace to an outside system. provider is a model provider (anthropic, openai, gemini, xai, mistral, deepseek, azure_openai, openrouter, groq, together, fireworks, cerebras, anthropic_endpoint or openai_endpoint: your own key, billed by that provider, and free on g1t while it is being built out; a workspace can connect several and route each kind of work with set_model_routes), or sentry, datadog, webhook, jira or linear. config holds the settings each needs; secret is the API key or token. For datadog and webhook, g1t makes the signing secret and returns it once. Owners only." | |
| 1331 | + | "Connect a workspace to an outside system. provider is a model provider (anthropic, openai, gemini, xai, mistral, deepseek, azure_openai, openrouter, groq, together, fireworks, cerebras, anthropic_endpoint or openai_endpoint: your own key, billed by that provider, and free on g1t while it is being built out; a workspace can connect several and route each kind of work with set_model_routes), or sentry, datadog, webhook, jira or linear. config holds the settings each needs; secret is the API key or token, kept encrypted and never returned (secret_hint shows its last four characters). For a model provider, config.gateway_models chooses which AI Gateway requests go to it by the model they name: ids such as gpt-5.5, or prefixes ending in * such as gpt-* or ollama/* (a /* prefix is taken off before sending); absent, an Anthropic key or Anthropic-compatible endpoint takes claude-* and the others take nothing. Requests on the workspace's own provider are counted and never charged. For datadog and webhook, g1t makes the signing secret and returns it once. Owners only." | |
| 1332 | + | } | |
| 1333 | + | Op::UpdateIntegration => { | |
| 1334 | + | "Change an integration: its name, its config (replaced whole when given) or its secret (a new key replaces the old one, write-only). Use it to rotate a model provider's key or to choose its config.gateway_models, the AI Gateway models it takes. Fields left out are kept. Secrets are never returned. Owners only." | |
| 1271 | 1335 | } | |
| 1272 | 1336 | Op::DisconnectIntegration => { | |
| 1273 | 1337 | "Remove an integration and its secrets. Agents already running on a model provider being removed stop reaching it. Owners only." | |
| 1456 | 1520 | Op::ReorderPinnedProjects => { | |
| 1457 | 1521 | "Put your pins in a workspace in a new order: `projects` names every pinned project's slug, once, in the order you want them. Returns your pins, in order." | |
| 1458 | 1522 | } | |
| 1523 | + | Op::ListProjects => { | |
| 1524 | + | "A workspace's projects that you can see, by name. A project is what a workspace builds and runs, from a repository or a root directory in one; every repository has a project of its own name. Each has what it is (`kind`: app, library, tool, docs or other) and why (`kind_reason`), where it runs (`runs`: `g1t` when g1t deploys it, `elsewhere` when it is deployed by other means, at `production_url`), and its `links`." | |
| 1525 | + | } | |
| 1526 | + | Op::GetProject => { | |
| 1527 | + | "A project: what it is (`kind`, and `kind_reason` saying why), where it runs (`runs` and `production_url`), what you set and what detection decides (`setting` and `detected`), its repository and `root_dir`, and its homepage, docs and other `links`. A private repository's project is found only by those who can see the repository." | |
| 1528 | + | } | |
| 1529 | + | Op::UpdateProject => { | |
| 1530 | + | "Change a project: its name, description, root directory, what it is, where it runs and its links. Only what you give changes. kind auto and runs auto leave each to detection. Setting runs makes it an app unless it is docs; making it a library, tool or other while Deployments are on is refused, so turn Deployments off first. Give description or homepage as null or \"\" to follow the repository's again, and production_url or docs_url as null or \"\" to clear it. links replaces its other links: at most 10, each a label of up to 40 characters and an http or https address (https:// is added when you leave the scheme out). Needs the Maintain role or higher on its repository." | |
| 1531 | + | } | |
| 1459 | 1532 | Op::ListTeams => { | |
| 1460 | 1533 | "A workspace's teams that you can see, yours first, then by name. A team is a group of the workspace's members, given roles on repositories together, mentioned as @workspace/team and asked to review together. A secret team is seen only by its own people and the workspace's owners. Each team has its `slug`, `name`, `description`, `visibility` (`visible` or `secret`), `parent`, whether its people are notified when it is mentioned (`notify`), its `review_assignment`, how many people, repositories and child teams it has (`members_count`, `repos_count`, `child_teams_count`), your own `viewer_role` in it, and whether you may change it (`can_manage`). `query` narrows them by name or slug. Members of the workspace only." | |
| 1461 | 1534 | } | |
| 1517 | 1590 | "Who a workspace's invoices are made out to: the billing `email`, `name`, `address`, tax ID (`tax_id_type`, `tax_id`), `po_number` and the invoices' `language`, with the default `payment_method` as far as it is safe to show (its kind, brand, last four digits and expiry). `customer` is false until the workspace has been set up to pay. Tax is worked out from the address: `tax_location` says whether it is enough for that (a country, and in the US a ZIP code), `tax_address_needed_at` is set while g1t is holding a charge for want of one, `tax_id_status` is Stripe's check of the tax ID (`pending`, `verified`, `unverified` or `unavailable`), and `tax_exempt` is `none`, `exempt` or `reverse`. Members of the workspace only." | |
| 1518 | 1591 | } | |
| 1519 | 1592 | Op::ListGatewayRequests => { | |
| 1520 | − | "A workspace's recent AI Gateway requests, newest first: each with its `id`, `created_at`, `model`, the access token that sent it (`token_id`, `token_name`), its tokens by kind (`input`, `output`, `cache_read`, `cache_write`), what they cost at the model's price (`cost_micros`) and what the workspace was charged (`charged_micros`, before included usage and AI credit paid for it; 0 on the workspace's own provider key, `own_key`), the HTTP `status` it was answered with, whether it was `streamed`, `duration_ms`, and `error` for one that was refused or failed. Prompts and answers are never kept. `limit` is how many, 50 unless given and 200 at most; pass `next` from one page as `before` for the next. Requests are kept `retention_days` (30). Members of the workspace only." | |
| 1593 | + | "A workspace's recent AI Gateway requests, newest first: each with its `id`, `created_at`, `model`, the access token that sent it (`token_id`, `token_name`), its tokens by kind (`input`, `output`, `cache_read`, `cache_write`, and of those writes `cache_write_hour` to the hour-long cache), the `format` it was sent in (`anthropic` or `openai`), who served it (`provider`: `anthropic` or `workers-ai` on g1t's account, the connection's provider on the workspace's own, and `connection`, that connection's name), what they cost at the model's price (`cost_micros`) and what the workspace was charged (`charged_micros`, before included usage and AI credit paid for it; 0 on the workspace's own provider key, `own_key`), the HTTP `status` it was answered with, whether it was `streamed`, `duration_ms`, and `error` for one that was refused or failed. Prompts and answers are never kept. `limit` is how many, 50 unless given and 200 at most; pass `next` from one page as `before` for the next. Requests are kept `retention_days` (30). Members of the workspace only." | |
| 1521 | 1594 | } | |
| 1522 | 1595 | Op::ListUserTeams => { | |
| 1523 | 1596 | "The teams someone is in within a workspace, as list_teams describes them, leaving out secret teams you cannot see. Members of the workspace only." | |
| 1533 | 1606 | } | |
| 1534 | 1607 | Op::Security(op) => op.description(), | |
| 1535 | 1608 | Op::Rules(op) => op.description(), | |
| 1609 | + | Op::Checks(op) => op.description(), | |
| 1610 | + | Op::About(op) => op.description(), | |
| 1611 | + | Op::Deployments(op) => op.description(), | |
| 1536 | 1612 | } | |
| 1537 | 1613 | } | |
| 1538 | 1614 | ||
| 2260 | 2336 | "name": { "type": "string", "description": "What to call it. The provider's name if left out." }, | |
| 2261 | 2337 | "config": { | |
| 2262 | 2338 | "type": "object", | |
| 2263 | − | "description": "Settings. repo (owner/name) is where alerts open issues; assign puts an agent on each; label names the label (bug). organization is the Sentry org's slug. site is Jira's address; email the account its token belongs to; keys the project or team keys it answers for. base_url and auth_header (x-api-key or authorization) are for your own endpoint; model overrides the model for every kind of work. write_back (default true) tells the outside system when the work lands.", | |
| 2339 | + | "description": "Settings. repo (owner/name) is where alerts open issues; assign puts an agent on each; label names the label (bug). organization is the Sentry org's slug. site is Jira's address; email the account its token belongs to; keys the project or team keys it answers for. base_url and auth_header (x-api-key or authorization) are for your own endpoint; model overrides the model for every kind of work; gateway_models (model ids, or prefixes ending in * such as gpt-* or ollama/*) chooses which AI Gateway requests go to a model provider. write_back (default true) tells the outside system when the work lands.", | |
| 2264 | 2340 | }, | |
| 2265 | − | "secret": { "type": "string", "description": "The API key or token g1t uses to call it." }, | |
| 2341 | + | "secret": { "type": "string", "description": "The API key or token g1t uses to call it. Write-only: kept encrypted, never returned." }, | |
| 2266 | 2342 | "signing_secret": { "type": "string", "description": "For sentry: the integration's client secret." }, | |
| 2267 | 2343 | }), | |
| 2268 | 2344 | &["workspace", "provider"], | |
| 2269 | 2345 | ), | |
| 2346 | + | Op::UpdateIntegration => object( | |
| 2347 | + | json!({ | |
| 2348 | + | "workspace": workspace_schema(), | |
| 2349 | + | "id": { "type": "string", "description": "The integration's id." }, | |
| 2350 | + | "name": { "type": "string", "description": "A new name." }, | |
| 2351 | + | "config": { | |
| 2352 | + | "type": "object", | |
| 2353 | + | "description": "Its settings, replaced whole: the same fields as connect_integration's config. For a model provider, gateway_models chooses the AI Gateway models it takes.", | |
| 2354 | + | }, | |
| 2355 | + | "secret": { "type": "string", "description": "A new API key or token, replacing the old one. Write-only: kept encrypted, never returned." }, | |
| 2356 | + | "signing_secret": { "type": "string", "description": "For sentry: a new client secret." }, | |
| 2357 | + | }), | |
| 2358 | + | &["workspace", "id"], | |
| 2359 | + | ), | |
| 2270 | 2360 | Op::GetModelRoutes => object(json!({ "workspace": workspace_schema() }), &["workspace"]), | |
| 2271 | 2361 | Op::ListWebhooks => object(hook_owner(json!({})), &[]), | |
| 2272 | 2362 | Op::ListWorkflows => repo_only(), | |
| 2677 | 2767 | }), | |
| 2678 | 2768 | &["workspace", "projects"], | |
| 2679 | 2769 | ), | |
| 2770 | + | Op::ListProjects => object(json!({ "workspace": workspace_schema() }), &["workspace"]), | |
| 2771 | + | Op::GetProject => object( | |
| 2772 | + | json!({ | |
| 2773 | + | "workspace": workspace_schema(), | |
| 2774 | + | "project": { "type": "string", "description": "The project's slug, as in g1t.sh/{workspace}/{project}." }, | |
| 2775 | + | }), | |
| 2776 | + | &["workspace", "project"], | |
| 2777 | + | ), | |
| 2778 | + | Op::UpdateProject => object( | |
| 2779 | + | json!({ | |
| 2780 | + | "workspace": workspace_schema(), | |
| 2781 | + | "project": { "type": "string", "description": "The project's slug, as in g1t.sh/{workspace}/{project}." }, | |
| 2782 | + | "name": { "type": "string", "description": "Its name." }, | |
| 2783 | + | "description": { "type": ["string", "null"], "description": "Its own description. null or \"\" follows its repository's again." }, | |
| 2784 | + | "root_dir": { "type": "string", "description": "Where in the repository it lives, such as apps/web; \"\" for the whole repository." }, | |
| 2785 | + | "kind": { | |
| 2786 | + | "type": "string", | |
| 2787 | + | "enum": ["auto", "app", "library", "tool", "docs", "other"], | |
| 2788 | + | "description": "What it is. auto leaves it to detection. A library, tool or other runs nowhere.", | |
| 2789 | + | }, | |
| 2790 | + | "runs": { | |
| 2791 | + | "type": "string", | |
| 2792 | + | "enum": ["auto", "g1t", "elsewhere"], | |
| 2793 | + | "description": "Where it runs: g1t when g1t deploys it, elsewhere when it is deployed by other means. auto leaves it to Deployments.", | |
| 2794 | + | }, | |
| 2795 | + | "production_url": { "type": ["string", "null"], "description": "Production's address when it runs elsewhere. null or \"\" clears it." }, | |
| 2796 | + | "homepage": { "type": ["string", "null"], "description": "Its homepage. null or \"\" follows its repository's website again." }, | |
| 2797 | + | "docs_url": { "type": ["string", "null"], "description": "Where its documentation is read. null or \"\" clears it." }, | |
| 2798 | + | "links": { | |
| 2799 | + | "type": "array", | |
| 2800 | + | "maxItems": 10, | |
| 2801 | + | "items": { | |
| 2802 | + | "type": "object", | |
| 2803 | + | "properties": { | |
| 2804 | + | "label": { "type": "string", "maxLength": 40 }, | |
| 2805 | + | "url": { "type": "string", "description": "An http or https address; https:// is added when you leave the scheme out." }, | |
| 2806 | + | }, | |
| 2807 | + | "required": ["label", "url"], | |
| 2808 | + | }, | |
| 2809 | + | "description": "Its other links, replacing the ones it has. [] removes them all.", | |
| 2810 | + | }, | |
| 2811 | + | }), | |
| 2812 | + | &["workspace", "project"], | |
| 2813 | + | ), | |
| 2680 | 2814 | Op::ListTeams => object( | |
| 2681 | 2815 | json!({ | |
| 2682 | 2816 | "workspace": workspace_schema(), | |
| 2843 | 2977 | ), | |
| 2844 | 2978 | Op::Security(op) => op.input(), | |
| 2845 | 2979 | Op::Rules(op) => op.input(), | |
| 2980 | + | Op::Checks(op) => op.input(), | |
| 2981 | + | Op::About(op) => op.input(), | |
| 2982 | + | Op::Deployments(op) => op.input(), | |
| 2846 | 2983 | } | |
| 2847 | 2984 | } | |
| 2848 | 2985 | ||
| 2849 | 2986 | /// Whether the operation refuses an anonymous caller outright. | |
| 2850 | 2987 | pub(crate) fn needs_user(self) -> bool { | |
| 2988 | + | // A public repository's checks are anyone's to read. | |
| 2989 | + | if let Op::Checks(op) = self { | |
| 2990 | + | return !op.reads(); | |
| 2991 | + | } | |
| 2992 | + | if let Op::About(op) = self { | |
| 2993 | + | return !op.anonymous(); | |
| 2994 | + | } | |
| 2851 | 2995 | !matches!( | |
| 2852 | 2996 | self, | |
| 2853 | 2997 | Op::ListRepos | |
| 2868 | 3012 | | Op::ListCheckNames | |
| 2869 | 3013 | | Op::GetMergeQueue | |
| 2870 | 3014 | | Op::GetCodeownersErrors | |
| 3015 | + | | Op::ListProjects | |
| 3016 | + | | Op::GetProject | |
| 2871 | 3017 | | Op::Rules(RulesOp::ListRepoRulesets | RulesOp::GetRepoRuleset | RulesOp::GetBranchRules) | |
| 3018 | + | | Op::Deployments( | |
| 3019 | + | DeploymentsOp::ListDeployments | |
| 3020 | + | | DeploymentsOp::GetDeployment | |
| 3021 | + | | DeploymentsOp::ListDeploymentStatuses | |
| 3022 | + | | DeploymentsOp::ListEnvironments | |
| 3023 | + | | DeploymentsOp::GetEnvironment | |
| 3024 | + | ) | |
| 2872 | 3025 | ) | |
| 2873 | 3026 | } | |
| 2874 | 3027 | ||
| 2882 | 3035 | if let Op::Rules(op) = self { | |
| 2883 | 3036 | return op.needs_repo(); | |
| 2884 | 3037 | } | |
| 3038 | + | if let Op::About(op) = self { | |
| 3039 | + | return op.needs_repo(); | |
| 3040 | + | } | |
| 2885 | 3041 | if let Op::Security(op) = self { | |
| 2886 | 3042 | return op.needs_repo(); | |
| 2887 | 3043 | } | |
| 2910 | 3066 | | Op::CreateRepo | |
| 2911 | 3067 | | Op::ListIntegrations | |
| 2912 | 3068 | | Op::ConnectIntegration | |
| 3069 | + | | Op::UpdateIntegration | |
| 2913 | 3070 | | Op::DisconnectIntegration | |
| 2914 | 3071 | | Op::TestIntegration | |
| 2915 | 3072 | | Op::GetModelRoutes | |
| 2956 | 3113 | | Op::PinProject | |
| 2957 | 3114 | | Op::UnpinProject | |
| 2958 | 3115 | | Op::ReorderPinnedProjects | |
| 3116 | + | | Op::ListProjects | |
| 3117 | + | | Op::GetProject | |
| 3118 | + | | Op::UpdateProject | |
| 2959 | 3119 | | Op::ListTeams | |
| 2960 | 3120 | | Op::GetTeam | |
| 2961 | 3121 | | Op::CreateTeam | |
| 2985 | 3145 | /// subscriptions and watching) or their pins. Nobody else's business, | |
| 2986 | 3146 | /// so not audited. | |
| 2987 | 3147 | pub(crate) fn personal(self) -> bool { | |
| 3148 | + | if let Op::About(op) = self { | |
| 3149 | + | return op.personal(); | |
| 3150 | + | } | |
| 2988 | 3151 | matches!( | |
| 2989 | 3152 | self, | |
| 2990 | 3153 | Op::ListNotifications | |
| 4094 | 4257 | ) | |
| 4095 | 4258 | .await | |
| 4096 | 4259 | } | |
| 4260 | + | Op::UpdateIntegration => { | |
| 4261 | + | let config = match &input["config"] { | |
| 4262 | + | Value::Null => Value::Null, | |
| 4263 | + | config => camel_keys(config), | |
| 4264 | + | }; | |
| 4265 | + | pass( | |
| 4266 | + | integrations, | |
| 4267 | + | "update", | |
| 4268 | + | &json!({ | |
| 4269 | + | "actor": actor(), | |
| 4270 | + | "workspace": workspace(), | |
| 4271 | + | "id": text(input, "id"), | |
| 4272 | + | "name": optional_text(input, "name"), | |
| 4273 | + | "config": config, | |
| 4274 | + | "secret": optional_text(input, "secret"), | |
| 4275 | + | "signingSecret": optional_text(input, "signing_secret"), | |
| 4276 | + | }), | |
| 4277 | + | ) | |
| 4278 | + | .await | |
| 4279 | + | } | |
| 4097 | 4280 | Op::DisconnectIntegration | Op::TestIntegration => { | |
| 4098 | 4281 | pass( | |
| 4099 | 4282 | integrations, | |
| 4844 | 5027 | Op::ListPinnedProjects | Op::PinProject | Op::UnpinProject | Op::ReorderPinnedProjects => { | |
| 4845 | 5028 | crate::pins::run(self, services, viewer, input).await | |
| 4846 | 5029 | } | |
| 5030 | + | // What a project is, where it runs and its links: the projects | |
| 5031 | + | // service keeps them and decides who may change them. | |
| 5032 | + | Op::ListProjects | Op::GetProject | Op::UpdateProject => { | |
| 5033 | + | crate::projects::run(self, services, viewer, input).await | |
| 5034 | + | } | |
| 4847 | 5035 | // The security suite: the security service decides, this gives | |
| 4848 | 5036 | // each answer its public shape. | |
| 4849 | 5037 | Op::Security(op) => crate::security::run(op, services, viewer, input).await, | |
| 4850 | 5038 | Op::Rules(op) => crate::rules::run(op, services, viewer, input).await, | |
| 5039 | + | Op::Checks(op) => crate::checks::run(op, services, viewer, input).await, | |
| 5040 | + | Op::About(op) => crate::about::run(op, services, viewer, input).await, | |
| 5041 | + | Op::Deployments(op) => crate::deployments::run(op, services, viewer, input).await, | |
| 4851 | 5042 | Op::ReopenSecurityAlert => { | |
| 4852 | 5043 | let changed: Outcome<AlertChange> = call( | |
| 4853 | 5044 | &services.security, |
| 1 | + | //! Projects: what a workspace builds and runs, where each runs, and its | |
| 2 | + | //! links. The projects service keeps them and decides who may see or change | |
| 3 | + | //! them; this is their public shape, in snake_case. | |
| 4 | + | ||
| 5 | + | use g1t_contracts::{FailureCode, Outcome, Viewer}; | |
| 6 | + | use serde_json::{Map, Value, json}; | |
| 7 | + | use worker::Result; | |
| 8 | + | ||
| 9 | + | use crate::operations::{Op, Services}; | |
| 10 | + | ||
| 11 | + | fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> { | |
| 12 | + | Ok(Outcome::fail(code, message)) | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | fn slug(input: &Value, key: &str) -> Option<String> { | |
| 16 | + | input[key].as_str().map(str::trim).filter(|value| !value.is_empty()).map(str::to_lowercase) | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | /// A reason a kind was decided, as the API shows it. | |
| 20 | + | fn reason_json(reason: &Value) -> Value { | |
| 21 | + | json!({ "by": reason["by"], "detail": reason["detail"] }) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | /// A project as the projects service answers (camelCase), as the API shows it. | |
| 25 | + | pub(crate) fn project_json(project: &Value, site: &str) -> Value { | |
| 26 | + | let workspace = project["workspace"].as_str().unwrap_or_default(); | |
| 27 | + | let slug = project["slug"].as_str().unwrap_or_default(); | |
| 28 | + | let source = &project["source"]; | |
| 29 | + | let repository = match (source["repo"]["namespace"].as_str(), source["repo"]["name"].as_str()) { | |
| 30 | + | (Some(namespace), Some(name)) => json!(format!("{namespace}/{name}")), | |
| 31 | + | _ => Value::Null, | |
| 32 | + | }; | |
| 33 | + | let links = &project["links"]; | |
| 34 | + | let custom: Vec<Value> = links["custom"] | |
| 35 | + | .as_array() | |
| 36 | + | .map(|items| items.iter().map(|link| json!({ "label": link["label"], "url": link["url"] })).collect()) | |
| 37 | + | .unwrap_or_default(); | |
| 38 | + | json!({ | |
| 39 | + | "id": project["id"], | |
| 40 | + | "workspace": workspace, | |
| 41 | + | "slug": slug, | |
| 42 | + | "name": project["name"], | |
| 43 | + | "description": project["description"], | |
| 44 | + | "description_inherited": project["descriptionInherited"].as_bool().unwrap_or(false), | |
| 45 | + | "url": format!("{}/{workspace}/{slug}", site.trim_end_matches('/')), | |
| 46 | + | "repository": repository, | |
| 47 | + | "root_dir": source["rootDir"].as_str().unwrap_or_default(), | |
| 48 | + | "default_branch": source["defaultBranch"], | |
| 49 | + | "private": project["private"], | |
| 50 | + | "archived": project["archived"], | |
| 51 | + | "primary": project["primary"], | |
| 52 | + | "kind": project["kind"], | |
| 53 | + | "kind_reason": reason_json(&project["kindReason"]), | |
| 54 | + | "runs": project["runs"], | |
| 55 | + | "production_url": project["productionUrl"], | |
| 56 | + | "setting": { "kind": project["setting"]["kind"], "runs": project["setting"]["runs"] }, | |
| 57 | + | "detected": { | |
| 58 | + | "kind": project["detected"]["kind"], | |
| 59 | + | "reason": reason_json(&project["detected"]["reason"]), | |
| 60 | + | }, | |
| 61 | + | "ecosystem": project["ecosystem"], | |
| 62 | + | "links": { | |
| 63 | + | "homepage": links["homepage"], | |
| 64 | + | "homepage_inherited": links["homepageInherited"].as_bool().unwrap_or(false), | |
| 65 | + | "docs": links["docs"], | |
| 66 | + | "custom": custom, | |
| 67 | + | }, | |
| 68 | + | "created_at": project["createdAt"], | |
| 69 | + | "updated_at": project["updatedAt"], | |
| 70 | + | "pushed_at": project["pushedAt"], | |
| 71 | + | }) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | /// The fields a change may set: (input, service, whether null clears it). | |
| 75 | + | const CHANGES: &[(&str, &str, bool)] = &[ | |
| 76 | + | ("name", "name", false), | |
| 77 | + | ("description", "description", true), | |
| 78 | + | ("root_dir", "rootDir", false), | |
| 79 | + | ("kind", "kind", false), | |
| 80 | + | ("runs", "runs", false), | |
| 81 | + | ("production_url", "productionUrl", true), | |
| 82 | + | ("homepage", "homepage", true), | |
| 83 | + | ("docs_url", "docsUrl", true), | |
| 84 | + | ]; | |
| 85 | + | ||
| 86 | + | /// The change `input` asks for, in the service's spelling: only what it | |
| 87 | + | /// gives, with null passed on for what null clears. | |
| 88 | + | pub(crate) fn changes(input: &Value) -> std::result::Result<Value, String> { | |
| 89 | + | let mut out = Map::new(); | |
| 90 | + | for (key, field, clearable) in CHANGES { | |
| 91 | + | match input.get(*key) { | |
| 92 | + | None => {} | |
| 93 | + | Some(Value::Null) if *clearable => { | |
| 94 | + | out.insert((*field).to_owned(), Value::Null); | |
| 95 | + | } | |
| 96 | + | Some(Value::Null) => {} | |
| 97 | + | Some(Value::String(text)) => { | |
| 98 | + | out.insert((*field).to_owned(), json!(text)); | |
| 99 | + | } | |
| 100 | + | Some(_) => { | |
| 101 | + | let kind = if *clearable { "a string or null" } else { "a string" }; | |
| 102 | + | return Err(format!("{key} must be {kind}.")); | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | match input.get("links") { | |
| 107 | + | None | Some(Value::Null) => {} | |
| 108 | + | Some(Value::Array(items)) => { | |
| 109 | + | let mut links = Vec::with_capacity(items.len()); | |
| 110 | + | for item in items { | |
| 111 | + | match (item["label"].as_str(), item["url"].as_str()) { | |
| 112 | + | (Some(label), Some(url)) => links.push(json!({ "label": label, "url": url })), | |
| 113 | + | _ => return Err("Each of links needs a label and a url.".to_owned()), | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | out.insert("links".to_owned(), Value::Array(links)); | |
| 117 | + | } | |
| 118 | + | Some(_) => return Err("links must be a list of { label, url }.".to_owned()), | |
| 119 | + | } | |
| 120 | + | Ok(Value::Object(out)) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | /// Runs one of the project operations. | |
| 124 | + | pub async fn run(op: Op, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { | |
| 125 | + | let Some(workspace) = slug(input, "workspace") else { | |
| 126 | + | return failed(FailureCode::Invalid, "Give the workspace's slug."); | |
| 127 | + | }; | |
| 128 | + | let site = services.addresses.site.as_str(); | |
| 129 | + | let one = |outcome: Outcome<Value>| -> Outcome<Value> { | |
| 130 | + | match outcome { | |
| 131 | + | Outcome::Ok(project) => Outcome::Ok(project_json(&project, site)), | |
| 132 | + | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 133 | + | } | |
| 134 | + | }; | |
| 135 | + | if op == Op::ListProjects { | |
| 136 | + | let listed: Outcome<Vec<Value>> = | |
| 137 | + | g1t_kit::call(&services.projects, "list", &json!({ "workspace": workspace, "viewer": viewer })).await?; | |
| 138 | + | return Ok(match listed { | |
| 139 | + | Outcome::Ok(projects) => { | |
| 140 | + | Outcome::Ok(Value::Array(projects.iter().map(|project| project_json(project, site)).collect())) | |
| 141 | + | } | |
| 142 | + | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 143 | + | }); | |
| 144 | + | } | |
| 145 | + | let Some(project) = slug(input, "project") else { | |
| 146 | + | return failed(FailureCode::Invalid, "Give the project's slug."); | |
| 147 | + | }; | |
| 148 | + | match op { | |
| 149 | + | Op::GetProject => Ok(one( | |
| 150 | + | g1t_kit::call( | |
| 151 | + | &services.projects, | |
| 152 | + | "get", | |
| 153 | + | &json!({ "workspace": workspace, "slug": project, "viewer": viewer }), | |
| 154 | + | ) | |
| 155 | + | .await?, | |
| 156 | + | )), | |
| 157 | + | Op::UpdateProject => { | |
| 158 | + | let Some(actor) = viewer else { | |
| 159 | + | return failed(FailureCode::Unauthenticated, "This needs a g1t access token."); | |
| 160 | + | }; | |
| 161 | + | let changes = match changes(input) { | |
| 162 | + | Ok(changes) => changes, | |
| 163 | + | Err(message) => return failed(FailureCode::Invalid, &message), | |
| 164 | + | }; | |
| 165 | + | Ok(one( | |
| 166 | + | g1t_kit::call( | |
| 167 | + | &services.projects, | |
| 168 | + | "update", | |
| 169 | + | &json!({ "actor": actor, "workspace": workspace, "slug": project, "changes": changes }), | |
| 170 | + | ) | |
| 171 | + | .await?, | |
| 172 | + | )) | |
| 173 | + | } | |
| 174 | + | _ => failed(FailureCode::Invalid, "Not a project operation."), | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | #[cfg(test)] | |
| 179 | + | mod tests { | |
| 180 | + | use super::*; | |
| 181 | + | use g1t_kit::wire; | |
| 182 | + | ||
| 183 | + | fn project() -> Value { | |
| 184 | + | json!({ | |
| 185 | + | "id": "prj_1", "workspace": "flagon-io", "slug": "g1t", "name": "g1t", | |
| 186 | + | "description": "Git hosting for people and agents.", "descriptionInherited": true, | |
| 187 | + | "source": { | |
| 188 | + | "kind": "hosted", "repoId": "repo_1", "repo": { "namespace": "flagon-io", "name": "g1t" }, | |
| 189 | + | "rootDir": "", "defaultBranch": "main", | |
| 190 | + | }, | |
| 191 | + | "private": false, "archived": false, "primary": true, | |
| 192 | + | "setting": { "kind": "app", "runs": "elsewhere" }, | |
| 193 | + | "kind": "app", "kindReason": { "by": "set", "detail": "Set to an app." }, | |
| 194 | + | "runs": "elsewhere", "productionUrl": "https://g1t.sh", | |
| 195 | + | "detected": { "kind": "app", "reason": { "by": "files", "detail": "It has a wrangler.jsonc." } }, | |
| 196 | + | "ecosystem": null, | |
| 197 | + | "links": { | |
| 198 | + | "homepage": "https://g1t.sh", "homepageInherited": true, "docs": "https://docs.g1t.sh", | |
| 199 | + | "custom": [{ "label": "Status", "url": "https://status.g1t.sh" }], | |
| 200 | + | }, | |
| 201 | + | "createdBy": "usr_1", "createdAt": "2026-09-01T10:00:00.000Z", "updatedAt": "2026-10-07T10:00:00.000Z", | |
| 202 | + | "pushedAt": "2026-10-07T09:00:00.000Z", "activity": 12.5, | |
| 203 | + | }) | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | #[test] | |
| 207 | + | fn a_project_is_snake_case_with_its_address() { | |
| 208 | + | let shown = project_json(&project(), "https://g1t.sh/"); | |
| 209 | + | assert!(wire::camel_case_keys(&shown).is_empty(), "{:?}", wire::camel_case_keys(&shown)); | |
| 210 | + | assert_eq!(shown["url"], "https://g1t.sh/flagon-io/g1t"); | |
| 211 | + | assert_eq!(shown["repository"], "flagon-io/g1t"); | |
| 212 | + | assert_eq!(shown["root_dir"], ""); | |
| 213 | + | assert_eq!(shown["default_branch"], "main"); | |
| 214 | + | assert_eq!(shown["description_inherited"], true); | |
| 215 | + | assert_eq!(shown["kind_reason"]["by"], "set"); | |
| 216 | + | assert_eq!(shown["production_url"], "https://g1t.sh"); | |
| 217 | + | assert_eq!(shown["setting"]["runs"], "elsewhere"); | |
| 218 | + | assert_eq!(shown["detected"]["reason"]["by"], "files"); | |
| 219 | + | assert_eq!(shown["links"]["homepage_inherited"], true); | |
| 220 | + | assert_eq!(shown["links"]["custom"][0]["label"], "Status"); | |
| 221 | + | assert_eq!(shown["pushed_at"], "2026-10-07T09:00:00.000Z"); | |
| 222 | + | // What is the service's own business stays there. | |
| 223 | + | assert!(shown.get("source").is_none()); | |
| 224 | + | assert!(shown.get("activity").is_none()); | |
| 225 | + | assert!(shown.get("created_by").is_none()); | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | #[test] | |
| 229 | + | fn a_change_is_only_what_is_given_in_the_service_s_spelling() { | |
| 230 | + | let asked = json!({ | |
| 231 | + | "workspace": "flagon-io", "project": "g1t", | |
| 232 | + | "kind": "app", "runs": "elsewhere", "production_url": "https://g1t.sh", | |
| 233 | + | "root_dir": "apps/web", "docs_url": "docs.g1t.sh", | |
| 234 | + | "links": [{ "label": "Status", "url": "https://status.g1t.sh", "extra": 1 }], | |
| 235 | + | }); | |
| 236 | + | let sent = changes(&asked).unwrap(); | |
| 237 | + | assert_eq!( | |
| 238 | + | sent, | |
| 239 | + | json!({ | |
| 240 | + | "kind": "app", "runs": "elsewhere", "productionUrl": "https://g1t.sh", "rootDir": "apps/web", | |
| 241 | + | "docsUrl": "docs.g1t.sh", "links": [{ "label": "Status", "url": "https://status.g1t.sh" }], | |
| 242 | + | }) | |
| 243 | + | ); | |
| 244 | + | assert!(sent.get("workspace").is_none()); | |
| 245 | + | assert!(sent.get("name").is_none()); | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | #[test] | |
| 249 | + | fn null_clears_what_it_can_and_absent_changes_nothing() { | |
| 250 | + | let sent = changes(&json!({ "description": null, "homepage": null, "production_url": null, "docs_url": null, "name": null, "kind": null })).unwrap(); | |
| 251 | + | assert_eq!(sent, json!({ "description": null, "homepage": null, "productionUrl": null, "docsUrl": null })); | |
| 252 | + | assert_eq!(changes(&json!({})).unwrap(), json!({})); | |
| 253 | + | assert_eq!(changes(&json!({ "links": [] })).unwrap(), json!({ "links": [] })); | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | #[test] | |
| 257 | + | fn a_change_of_the_wrong_type_is_refused() { | |
| 258 | + | assert!(changes(&json!({ "kind": 3 })).is_err()); | |
| 259 | + | assert!(changes(&json!({ "links": "https://g1t.sh" })).is_err()); | |
| 260 | + | assert!(changes(&json!({ "links": [{ "url": "https://g1t.sh" }] })).is_err()); | |
| 261 | + | } | |
| 262 | + | } |
| 3713 | 3713 | }, | |
| 3714 | 3714 | "notes": "`signing_secret` is set only when g1t made it, for `datadog` and `webhook`, and is shown only this once. A model provider is tested as it is connected. See [integrations](/guides/integrations/) and [model providers](/guides/models/)." | |
| 3715 | 3715 | }, | |
| 3716 | + | "update_integration": { | |
| 3717 | + | "params": { | |
| 3718 | + | "workspace": "flagon-io", | |
| 3719 | + | "id": "con_01kpx5c2d8e4f6g0h2j4k6m8n0" | |
| 3720 | + | }, | |
| 3721 | + | "request": { | |
| 3722 | + | "config": { | |
| 3723 | + | "base_url": "https://gpu.flagon.dev/v1", | |
| 3724 | + | "gateway_models": ["ollama/*"] | |
| 3725 | + | }, | |
| 3726 | + | "secret": "sk-office-gpu-key" | |
| 3727 | + | }, | |
| 3728 | + | "response": { | |
| 3729 | + | "id": "con_01kpx5c2d8e4f6g0h2j4k6m8n0", | |
| 3730 | + | "workspace": "flagon-io", | |
| 3731 | + | "provider": "openai_endpoint", | |
| 3732 | + | "kind": "models", | |
| 3733 | + | "name": "Office GPU", | |
| 3734 | + | "config": { | |
| 3735 | + | "write_back": true, | |
| 3736 | + | "base_url": "https://gpu.flagon.dev/v1", | |
| 3737 | + | "gateway_models": ["ollama/*"] | |
| 3738 | + | }, | |
| 3739 | + | "secret_hint": "…-key", | |
| 3740 | + | "webhook_url": null, | |
| 3741 | + | "created_by": "syntaqx", | |
| 3742 | + | "created_at": "2026-10-07T12:10:44.512Z", | |
| 3743 | + | "last_used_at": "2026-10-07T14:00:02.000Z", | |
| 3744 | + | "last_error": null, | |
| 3745 | + | "models": ["llama3.3:70b", "qwen3-coder:30b"] | |
| 3746 | + | }, | |
| 3747 | + | "notes": "The secret is write-only: it is kept encrypted and only its last four characters come back, as `secret_hint`. With `gateway_models` set to `ollama/*`, an AI Gateway request for `ollama/qwen3-coder:30b` reaches this endpoint as `qwen3-coder:30b`, on the workspace's own account. See [the AI Gateway guide](/guides/ai-gateway/#your-own-providers)." | |
| 3748 | + | }, | |
| 3716 | 3749 | "disconnect_integration": { | |
| 3717 | 3750 | "params": { | |
| 3718 | 3751 | "workspace": "flagon-io", | |
| 5966 | 5999 | "workspace": "flagon-io" | |
| 5967 | 6000 | }, | |
| 5968 | 6001 | "query": { | |
| 5969 | − | "limit": 2 | |
| 6002 | + | "limit": 3 | |
| 5970 | 6003 | }, | |
| 5971 | 6004 | "response": { | |
| 5972 | 6005 | "requests": [ | |
| 5980 | 6013 | "output": 512, | |
| 5981 | 6014 | "cache_read": 12000, | |
| 5982 | 6015 | "cache_write": 0, | |
| 5983 | − | "cost_micros": 11200, | |
| 5984 | − | "charged_micros": 11200, | |
| 6016 | + | "cache_write_hour": 0, | |
| 6017 | + | "cost_micros": 10000, | |
| 6018 | + | "charged_micros": 10000, | |
| 5985 | 6019 | "status": 200, | |
| 5986 | 6020 | "own_key": false, | |
| 6021 | + | "format": "anthropic", | |
| 6022 | + | "provider": "anthropic", | |
| 6023 | + | "connection": null, | |
| 5987 | 6024 | "streamed": true, | |
| 5988 | 6025 | "duration_ms": 4210, | |
| 5989 | 6026 | "error": null | |
| 5990 | 6027 | }, | |
| 5991 | 6028 | { | |
| 6029 | + | "id": "gw_9b3d1f7a5c2e4b6d8f0a1c33", | |
| 6030 | + | "created_at": "2026-10-07T14:00:02.000Z", | |
| 6031 | + | "model": "qwen3-coder:30b", | |
| 6032 | + | "token_id": "tok_01kkr5a2b8c4d6e0f3g5h7j9k1", | |
| 6033 | + | "token_name": "triage-bot", | |
| 6034 | + | "input": 2210, | |
| 6035 | + | "output": 96, | |
| 6036 | + | "cache_read": 0, | |
| 6037 | + | "cache_write": 0, | |
| 6038 | + | "cache_write_hour": 0, | |
| 6039 | + | "cost_micros": 0, | |
| 6040 | + | "charged_micros": 0, | |
| 6041 | + | "status": 200, | |
| 6042 | + | "own_key": true, | |
| 6043 | + | "format": "openai", | |
| 6044 | + | "provider": "openai_endpoint", | |
| 6045 | + | "connection": "Office GPU", | |
| 6046 | + | "streamed": false, | |
| 6047 | + | "duration_ms": 1830, | |
| 6048 | + | "error": null | |
| 6049 | + | }, | |
| 6050 | + | { | |
| 5992 | 6051 | "id": "gw_1a7e3c5b9d2f4e6a8c0b2d41", | |
| 5993 | 6052 | "created_at": "2026-10-07T13:58:40.000Z", | |
| 5994 | 6053 | "model": "claude-opus-5-5", | |
| 5998 | 6057 | "output": 0, | |
| 5999 | 6058 | "cache_read": 0, | |
| 6000 | 6059 | "cache_write": 0, | |
| 6060 | + | "cache_write_hour": 0, | |
| 6001 | 6061 | "cost_micros": 0, | |
| 6002 | 6062 | "charged_micros": 0, | |
| 6003 | 6063 | "status": 402, | |
| 6004 | 6064 | "own_key": false, | |
| 6065 | + | "format": "openai", | |
| 6066 | + | "provider": "", | |
| 6067 | + | "connection": null, | |
| 6005 | 6068 | "streamed": false, | |
| 6006 | 6069 | "duration_ms": 38, | |
| 6007 | 6070 | "error": "The flagon-io workspace is out of AI credit and has used this month's included usage, so the AI Gateway refuses requests to g1t's models. An owner can buy AI credit or turn on auto-reload at /flagon-io/-/billing#ai-credit." | |
| 6010 | 6073 | "next": "gw_1a7e3c5b9d2f4e6a8c0b2d41", | |
| 6011 | 6074 | "retention_days": 30 | |
| 6012 | 6075 | }, | |
| 6013 | − | "notes": "To send requests, see the AI Gateway guide: POST https://models.g1t.sh/anthropic/v1/messages with a workspace access token that has `models:write`. `charged_micros` is what the request was charged before included usage and AI credit paid for it; the payment itself is on the statement as AI Gateway. Pass `next` as `before` for the next page; it is null on the last." | |
| 6076 | + | "notes": "To send requests, see the AI Gateway guide: POST https://models.g1t.sh/anthropic/v1/messages (Anthropic's format) or https://models.g1t.sh/openai/v1/chat/completions (OpenAI's) with a workspace access token that has `models:write`. `format` is the format a request was sent in; `provider` who served it (`anthropic` or `workers-ai` on g1t's account, the connection's provider on the workspace's own, empty when it was refused before reaching one), and `connection` the workspace's own connection by name. `cache_write_hour` is the part of `cache_write` written to the hour-long cache. `charged_micros` is what the request was charged before included usage and AI credit paid for it; the payment itself is on the statement as AI Gateway. Pass `next` as `before` for the next page; it is null on the last." | |
| 6014 | 6077 | }, | |
| 6015 | 6078 | "list_invoices": { | |
| 6016 | 6079 | "params": { | |
| 6317 | 6380 | ], | |
| 6318 | 6381 | "notes": "Name every pinned project once; anything else is refused with `422 invalid`." | |
| 6319 | 6382 | }, | |
| 6383 | + | "list_projects": { | |
| 6384 | + | "params": { | |
| 6385 | + | "workspace": "flagon-io" | |
| 6386 | + | }, | |
| 6387 | + | "response": [ | |
| 6388 | + | { | |
| 6389 | + | "id": "prj_01kkp3m8w2f6t9qh4c7d1r5n0x", | |
| 6390 | + | "workspace": "flagon-io", | |
| 6391 | + | "slug": "g1t", | |
| 6392 | + | "name": "g1t", | |
| 6393 | + | "description": "Git hosting where people and agents work together.", | |
| 6394 | + | "description_inherited": true, | |
| 6395 | + | "url": "https://g1t.sh/flagon-io/g1t", | |
| 6396 | + | "repository": "flagon-io/g1t", | |
| 6397 | + | "root_dir": "", | |
| 6398 | + | "default_branch": "main", | |
| 6399 | + | "private": false, | |
| 6400 | + | "archived": false, | |
| 6401 | + | "primary": true, | |
| 6402 | + | "kind": "app", | |
| 6403 | + | "kind_reason": { | |
| 6404 | + | "by": "set", | |
| 6405 | + | "detail": "Set to an app that runs elsewhere." | |
| 6406 | + | }, | |
| 6407 | + | "runs": "elsewhere", | |
| 6408 | + | "production_url": "https://g1t.sh", | |
| 6409 | + | "setting": { | |
| 6410 | + | "kind": "app", | |
| 6411 | + | "runs": "elsewhere" | |
| 6412 | + | }, | |
| 6413 | + | "detected": { | |
| 6414 | + | "kind": "app", | |
| 6415 | + | "reason": { | |
| 6416 | + | "by": "files", | |
| 6417 | + | "detail": "wrangler.jsonc at its root makes it an app." | |
| 6418 | + | } | |
| 6419 | + | }, | |
| 6420 | + | "ecosystem": null, | |
| 6421 | + | "links": { | |
| 6422 | + | "homepage": "https://g1t.sh", | |
| 6423 | + | "homepage_inherited": true, | |
| 6424 | + | "docs": "https://docs.g1t.sh", | |
| 6425 | + | "custom": [ | |
| 6426 | + | { | |
| 6427 | + | "label": "Status", | |
| 6428 | + | "url": "https://status.g1t.sh" | |
| 6429 | + | } | |
| 6430 | + | ] | |
| 6431 | + | }, | |
| 6432 | + | "created_at": "2026-09-01T10:00:00.000Z", | |
| 6433 | + | "updated_at": "2026-10-07T11:20:00.000Z", | |
| 6434 | + | "pushed_at": "2026-10-07T10:00:00.000Z" | |
| 6435 | + | }, | |
| 6436 | + | { | |
| 6437 | + | "id": "prj_01kkp3n2a7e5v8sk3b6g9j4m1y", | |
| 6438 | + | "workspace": "flagon-io", | |
| 6439 | + | "slug": "hello", | |
| 6440 | + | "name": "hello", | |
| 6441 | + | "description": "Greets people from the command line.", | |
| 6442 | + | "description_inherited": false, | |
| 6443 | + | "url": "https://g1t.sh/flagon-io/hello", | |
| 6444 | + | "repository": "flagon-io/hello", | |
| 6445 | + | "root_dir": "", | |
| 6446 | + | "default_branch": "main", | |
| 6447 | + | "private": false, | |
| 6448 | + | "archived": false, | |
| 6449 | + | "primary": true, | |
| 6450 | + | "kind": "library", | |
| 6451 | + | "kind_reason": { | |
| 6452 | + | "by": "files", | |
| 6453 | + | "detail": "composer.json says it is a library." | |
| 6454 | + | }, | |
| 6455 | + | "runs": null, | |
| 6456 | + | "production_url": null, | |
| 6457 | + | "setting": { | |
| 6458 | + | "kind": null, | |
| 6459 | + | "runs": null | |
| 6460 | + | }, | |
| 6461 | + | "detected": { | |
| 6462 | + | "kind": "library", | |
| 6463 | + | "reason": { | |
| 6464 | + | "by": "files", | |
| 6465 | + | "detail": "composer.json says it is a library." | |
| 6466 | + | } | |
| 6467 | + | }, | |
| 6468 | + | "ecosystem": "composer", | |
| 6469 | + | "links": { | |
| 6470 | + | "homepage": null, | |
| 6471 | + | "homepage_inherited": false, | |
| 6472 | + | "docs": null, | |
| 6473 | + | "custom": [] | |
| 6474 | + | }, | |
| 6475 | + | "created_at": "2026-09-12T14:30:00.000Z", | |
| 6476 | + | "updated_at": "2026-10-01T09:12:00.000Z", | |
| 6477 | + | "pushed_at": null | |
| 6478 | + | } | |
| 6479 | + | ], | |
| 6480 | + | "notes": "By name. Projects whose repository you cannot see are left out; without a token you see public ones only. `kind` is what the project is, from `setting` where a person set it and from detection otherwise (`detected`, shown beside it); `kind_reason` says why. `runs` is null for what is not deployed. `homepage_inherited` is true while the homepage is its repository's website. `pushed_at` is null until its repository is pushed to. See [Projects](/guides/projects/)." | |
| 6481 | + | }, | |
| 6482 | + | "get_project": { | |
| 6483 | + | "params": { | |
| 6484 | + | "workspace": "flagon-io", | |
| 6485 | + | "project": "g1t" | |
| 6486 | + | }, | |
| 6487 | + | "response": { | |
| 6488 | + | "id": "prj_01kkp3m8w2f6t9qh4c7d1r5n0x", | |
| 6489 | + | "workspace": "flagon-io", | |
| 6490 | + | "slug": "g1t", | |
| 6491 | + | "name": "g1t", | |
| 6492 | + | "description": "Git hosting where people and agents work together.", | |
| 6493 | + | "description_inherited": true, | |
| 6494 | + | "url": "https://g1t.sh/flagon-io/g1t", | |
| 6495 | + | "repository": "flagon-io/g1t", | |
| 6496 | + | "root_dir": "", | |
| 6497 | + | "default_branch": "main", | |
| 6498 | + | "private": false, | |
| 6499 | + | "archived": false, | |
| 6500 | + | "primary": true, | |
| 6501 | + | "kind": "app", | |
| 6502 | + | "kind_reason": { | |
| 6503 | + | "by": "set", | |
| 6504 | + | "detail": "Set to an app that runs elsewhere." | |
| 6505 | + | }, | |
| 6506 | + | "runs": "elsewhere", | |
| 6507 | + | "production_url": "https://g1t.sh", | |
| 6508 | + | "setting": { | |
| 6509 | + | "kind": "app", | |
| 6510 | + | "runs": "elsewhere" | |
| 6511 | + | }, | |
| 6512 | + | "detected": { | |
| 6513 | + | "kind": "app", | |
| 6514 | + | "reason": { | |
| 6515 | + | "by": "files", | |
| 6516 | + | "detail": "wrangler.jsonc at its root makes it an app." | |
| 6517 | + | } | |
| 6518 | + | }, | |
| 6519 | + | "ecosystem": null, | |
| 6520 | + | "links": { | |
| 6521 | + | "homepage": "https://g1t.sh", | |
| 6522 | + | "homepage_inherited": true, | |
| 6523 | + | "docs": "https://docs.g1t.sh", | |
| 6524 | + | "custom": [ | |
| 6525 | + | { | |
| 6526 | + | "label": "Status", | |
| 6527 | + | "url": "https://status.g1t.sh" | |
| 6528 | + | } | |
| 6529 | + | ] | |
| 6530 | + | }, | |
| 6531 | + | "created_at": "2026-09-01T10:00:00.000Z", | |
| 6532 | + | "updated_at": "2026-10-07T11:20:00.000Z", | |
| 6533 | + | "pushed_at": "2026-10-07T10:00:00.000Z" | |
| 6534 | + | }, | |
| 6535 | + | "notes": "`404` for a project that does not exist or whose repository you cannot see. `setting` holds what a person set, each part null while it is left to detection. `ecosystem` is where a library's files say it is published (`composer`, `npm`, `cargo`, `go` or `python`), or null. See [What a project is](/guides/projects/#what-a-project-is)." | |
| 6536 | + | }, | |
| 6537 | + | "update_project": { | |
| 6538 | + | "params": { | |
| 6539 | + | "workspace": "flagon-io", | |
| 6540 | + | "project": "g1t" | |
| 6541 | + | }, | |
| 6542 | + | "request": { | |
| 6543 | + | "kind": "app", | |
| 6544 | + | "runs": "elsewhere", | |
| 6545 | + | "production_url": "https://g1t.sh", | |
| 6546 | + | "docs_url": "https://docs.g1t.sh", | |
| 6547 | + | "links": [ | |
| 6548 | + | { | |
| 6549 | + | "label": "Status", | |
| 6550 | + | "url": "https://status.g1t.sh" | |
| 6551 | + | } | |
| 6552 | + | ] | |
| 6553 | + | }, | |
| 6554 | + | "response": { | |
| 6555 | + | "id": "prj_01kkp3m8w2f6t9qh4c7d1r5n0x", | |
| 6556 | + | "workspace": "flagon-io", | |
| 6557 | + | "slug": "g1t", | |
| 6558 | + | "name": "g1t", | |
| 6559 | + | "description": "Git hosting where people and agents work together.", | |
| 6560 | + | "description_inherited": true, | |
| 6561 | + | "url": "https://g1t.sh/flagon-io/g1t", | |
| 6562 | + | "repository": "flagon-io/g1t", | |
| 6563 | + | "root_dir": "", | |
| 6564 | + | "default_branch": "main", | |
| 6565 | + | "private": false, | |
| 6566 | + | "archived": false, | |
| 6567 | + | "primary": true, | |
| 6568 | + | "kind": "app", | |
| 6569 | + | "kind_reason": { | |
| 6570 | + | "by": "set", | |
| 6571 | + | "detail": "Set to an app that runs elsewhere." | |
| 6572 | + | }, | |
| 6573 | + | "runs": "elsewhere", | |
| 6574 | + | "production_url": "https://g1t.sh", | |
| 6575 | + | "setting": { | |
| 6576 | + | "kind": "app", | |
| 6577 | + | "runs": "elsewhere" | |
| 6578 | + | }, | |
| 6579 | + | "detected": { | |
| 6580 | + | "kind": "app", | |
| 6581 | + | "reason": { | |
| 6582 | + | "by": "files", | |
| 6583 | + | "detail": "wrangler.jsonc at its root makes it an app." | |
| 6584 | + | } | |
| 6585 | + | }, | |
| 6586 | + | "ecosystem": null, | |
| 6587 | + | "links": { | |
| 6588 | + | "homepage": "https://g1t.sh", | |
| 6589 | + | "homepage_inherited": true, | |
| 6590 | + | "docs": "https://docs.g1t.sh", | |
| 6591 | + | "custom": [ | |
| 6592 | + | { | |
| 6593 | + | "label": "Status", | |
| 6594 | + | "url": "https://status.g1t.sh" | |
| 6595 | + | } | |
| 6596 | + | ] | |
| 6597 | + | }, | |
| 6598 | + | "created_at": "2026-09-01T10:00:00.000Z", | |
| 6599 | + | "updated_at": "2026-10-07T11:20:00.000Z", | |
| 6600 | + | "pushed_at": "2026-10-07T10:00:00.000Z" | |
| 6601 | + | }, | |
| 6602 | + | "notes": "Only the fields given change. `kind` and `runs` take `auto` to go back to detection. Setting `runs` makes the project an app unless it is docs. Making it a `library`, `tool` or `other` while [Deployments](/guides/deployments/) are on is refused with `409 conflict`: turn them off first. `description` and `homepage` given as null or `\"\"` follow the repository's again; `production_url` and `docs_url` given as null or `\"\"` are cleared. `links` replaces the project's other links: at most 10, each with a `label` of up to 40 characters and an http or https `url` (`https://` is added when you leave the scheme out); anything else is refused with `422 invalid`. Needs the Maintain role or higher on the project's repository. Recorded in the [audit log](/guides/audit-log/). See [Settings](/guides/projects/#settings)." | |
| 6603 | + | }, | |
| 6320 | 6604 | "list_secret_scanning_alerts": { | |
| 6321 | 6605 | "params": { | |
| 6322 | 6606 | "owner": "flagon-io", | |
| 9278 | 9562 | ] | |
| 9279 | 9563 | } | |
| 9280 | 9564 | } | |
| 9565 | + | }, | |
| 9566 | + | "list_deployments": { | |
| 9567 | + | "params": { | |
| 9568 | + | "owner": "flagon-io", | |
| 9569 | + | "name": "g1t" | |
| 9570 | + | }, | |
| 9571 | + | "query": { | |
| 9572 | + | "environment": "production", | |
| 9573 | + | "per_page": "2" | |
| 9574 | + | }, | |
| 9575 | + | "response": { | |
| 9576 | + | "deployments": [ | |
| 9577 | + | { | |
| 9578 | + | "id": "dep_01kq8m3t5v7x9z1b3d5f7h9k2m", | |
| 9579 | + | "environment": "production", | |
| 9580 | + | "ref": "main", | |
| 9581 | + | "sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7", | |
| 9582 | + | "task": "deploy", | |
| 9583 | + | "description": "Deploy", | |
| 9584 | + | "payload": {}, | |
| 9585 | + | "transient_environment": false, | |
| 9586 | + | "production_environment": true, | |
| 9587 | + | "state": "success", | |
| 9588 | + | "environment_url": "https://g1t.sh", | |
| 9589 | + | "log_url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_01kq8m2r4t6v8x0z2b4d6f8h0k", | |
| 9590 | + | "creator": "syntaqx", | |
| 9591 | + | "source": "actions", | |
| 9592 | + | "run_id": "run_01kq8m2r4t6v8x0z2b4d6f8h0k", | |
| 9593 | + | "run_url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_01kq8m2r4t6v8x0z2b4d6f8h0k", | |
| 9594 | + | "project": null, | |
| 9595 | + | "number": null, | |
| 9596 | + | "created_at": "2026-10-07T18:02:11.204Z", | |
| 9597 | + | "updated_at": "2026-10-07T18:19:47.881Z" | |
| 9598 | + | } | |
| 9599 | + | ], | |
| 9600 | + | "total_count": 304, | |
| 9601 | + | "page": 1, | |
| 9602 | + | "per_page": 2 | |
| 9603 | + | }, | |
| 9604 | + | "notes": "Every deployment of the repository in one list, wherever it ran: `source` is `api` for those reported with [`POST /repos/{owner}/{name}/deployments`](/reference/api/deployments/create-deployment/), `actions` for those a g1t Actions job with an `environment:` made, and `g1t_page` for [g1t.page](/guides/deployments/) builds, whose ids start `dpl_`. `state` is the latest status's. See [Deployments API](/guides/deployments-api/)." | |
| 9605 | + | }, | |
| 9606 | + | "create_deployment": { | |
| 9607 | + | "params": { | |
| 9608 | + | "owner": "flagon-io", | |
| 9609 | + | "name": "g1t" | |
| 9610 | + | }, | |
| 9611 | + | "request": { | |
| 9612 | + | "ref": "main", | |
| 9613 | + | "environment": "staging", | |
| 9614 | + | "description": "Deployed by the release pipeline", | |
| 9615 | + | "payload": { | |
| 9616 | + | "pipeline": 4182, | |
| 9617 | + | "region": "us-east" | |
| 9618 | + | }, | |
| 9619 | + | "state": "in_progress", | |
| 9620 | + | "log_url": "https://ci.example.com/pipelines/4182" | |
| 9621 | + | }, | |
| 9622 | + | "response": { | |
| 9623 | + | "id": "dep_01kq7z9a1c3e5g7j9m1p3r5t7v", | |
| 9624 | + | "environment": "staging", | |
| 9625 | + | "ref": "main", | |
| 9626 | + | "sha": "4b8d0f2a6c1e3579bd02468ace13579bdf02468a", | |
| 9627 | + | "task": "deploy", | |
| 9628 | + | "description": "Deployed by the release pipeline", | |
| 9629 | + | "payload": { | |
| 9630 | + | "pipeline": 4182, | |
| 9631 | + | "region": "us-east" | |
| 9632 | + | }, | |
| 9633 | + | "transient_environment": false, | |
| 9634 | + | "production_environment": false, | |
| 9635 | + | "state": "in_progress", | |
| 9636 | + | "environment_url": null, | |
| 9637 | + | "log_url": "https://ci.example.com/pipelines/4182", | |
| 9638 | + | "creator": "flagon-io", | |
| 9639 | + | "source": "api", | |
| 9640 | + | "run_id": null, | |
| 9641 | + | "run_url": null, | |
| 9642 | + | "project": null, | |
| 9643 | + | "number": null, | |
| 9644 | + | "created_at": "2026-10-06T21:40:03.512Z", | |
| 9645 | + | "updated_at": "2026-10-06T21:40:03.512Z", | |
| 9646 | + | "statuses": [ | |
| 9647 | + | { | |
| 9648 | + | "id": "dst_01kq7z9a1d4f6h8k0m2p4r6t8v", | |
| 9649 | + | "deployment_id": "dep_01kq7z9a1c3e5g7j9m1p3r5t7v", | |
| 9650 | + | "state": "in_progress", | |
| 9651 | + | "description": "Deployed by the release pipeline", | |
| 9652 | + | "environment_url": null, | |
| 9653 | + | "log_url": "https://ci.example.com/pipelines/4182", | |
| 9654 | + | "creator": "flagon-io", | |
| 9655 | + | "created_at": "2026-10-06T21:40:03.512Z" | |
| 9656 | + | } | |
| 9657 | + | ] | |
| 9658 | + | }, | |
| 9659 | + | "notes": "Report from any CI with an access token that has `deployments:write` (the CI preset has it) and the Write role. Then report each step with [`POST …/deployments/{id}/statuses`](/reference/api/deployments/create-deployment-status/). Each status sets the check `deploy / <environment>` on the commit. A g1t Actions job with an `environment:` does all of this itself. See [Deployments API](/guides/deployments-api/)." | |
| 9660 | + | }, | |
| 9661 | + | "get_deployment": { | |
| 9662 | + | "params": { | |
| 9663 | + | "owner": "flagon-io", | |
| 9664 | + | "name": "g1t", | |
| 9665 | + | "id": "dep_01kq8m3t5v7x9z1b3d5f7h9k2m" | |
| 9666 | + | }, | |
| 9667 | + | "response": { | |
| 9668 | + | "id": "dep_01kq8m3t5v7x9z1b3d5f7h9k2m", | |
| 9669 | + | "environment": "production", | |
| 9670 | + | "ref": "main", | |
| 9671 | + | "sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7", | |
| 9672 | + | "task": "deploy", | |
| 9673 | + | "description": "Deploy", | |
| 9674 | + | "payload": {}, | |
| 9675 | + | "transient_environment": false, | |
| 9676 | + | "production_environment": true, | |
| 9677 | + | "state": "success", | |
| 9678 | + | "environment_url": "https://g1t.sh", | |
| 9679 | + | "log_url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_01kq8m2r4t6v8x0z2b4d6f8h0k", | |
| 9680 | + | "creator": "syntaqx", | |
| 9681 | + | "source": "actions", | |
| 9682 | + | "run_id": "run_01kq8m2r4t6v8x0z2b4d6f8h0k", | |
| 9683 | + | "run_url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_01kq8m2r4t6v8x0z2b4d6f8h0k", | |
| 9684 | + | "project": null, | |
| 9685 | + | "number": null, | |
| 9686 | + | "created_at": "2026-10-07T18:02:11.204Z", | |
| 9687 | + | "updated_at": "2026-10-07T18:19:47.881Z", | |
| 9688 | + | "statuses": [ | |
| 9689 | + | { | |
| 9690 | + | "id": "dst_01kq8m3t5w0a2c4e6g8j0m2p4r", | |
| 9691 | + | "deployment_id": "dep_01kq8m3t5v7x9z1b3d5f7h9k2m", | |
| 9692 | + | "state": "in_progress", | |
| 9693 | + | "description": "Deploy is deploying", | |
| 9694 | + | "environment_url": "https://g1t.sh", | |
| 9695 | + | "log_url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_01kq8m2r4t6v8x0z2b4d6f8h0k", | |
| 9696 | + | "creator": "syntaqx", | |
| 9697 | + | "created_at": "2026-10-07T18:02:11.204Z" | |
| 9698 | + | }, | |
| 9699 | + | { | |
| 9700 | + | "id": "dst_01kq8n4v6x8z0b2d4f6h8k0m2p", | |
| 9701 | + | "deployment_id": "dep_01kq8m3t5v7x9z1b3d5f7h9k2m", | |
| 9702 | + | "state": "success", | |
| 9703 | + | "description": "Deploy deployed", | |
| 9704 | + | "environment_url": "https://g1t.sh", | |
| 9705 | + | "log_url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_01kq8m2r4t6v8x0z2b4d6f8h0k", | |
| 9706 | + | "creator": "syntaqx", | |
| 9707 | + | "created_at": "2026-10-07T18:19:47.881Z" | |
| 9708 | + | } | |
| 9709 | + | ] | |
| 9710 | + | }, | |
| 9711 | + | "notes": "`statuses` are oldest first. A g1t.page build's (`dpl_…`) are read from the build: queued, building, how it ended, and `inactive` once a newer build replaced it or it was taken down." | |
| 9712 | + | }, | |
| 9713 | + | "list_deployment_statuses": { | |
| 9714 | + | "params": { | |
| 9715 | + | "owner": "flagon-io", | |
| 9716 | + | "name": "g1t", | |
| 9717 | + | "id": "dep_01kq8m3t5v7x9z1b3d5f7h9k2m" | |
| 9718 | + | }, | |
| 9719 | + | "response": [ | |
| 9720 | + | { | |
| 9721 | + | "id": "dst_01kq8n4v6x8z0b2d4f6h8k0m2p", | |
| 9722 | + | "deployment_id": "dep_01kq8m3t5v7x9z1b3d5f7h9k2m", | |
| 9723 | + | "state": "success", | |
| 9724 | + | "description": "Deploy deployed", | |
| 9725 | + | "environment_url": "https://g1t.sh", | |
| 9726 | + | "log_url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_01kq8m2r4t6v8x0z2b4d6f8h0k", | |
| 9727 | + | "creator": "syntaqx", | |
| 9728 | + | "created_at": "2026-10-07T18:19:47.881Z" | |
| 9729 | + | }, | |
| 9730 | + | { | |
| 9731 | + | "id": "dst_01kq8m3t5w0a2c4e6g8j0m2p4r", | |
| 9732 | + | "deployment_id": "dep_01kq8m3t5v7x9z1b3d5f7h9k2m", | |
| 9733 | + | "state": "in_progress", | |
| 9734 | + | "description": "Deploy is deploying", | |
| 9735 | + | "environment_url": "https://g1t.sh", | |
| 9736 | + | "log_url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_01kq8m2r4t6v8x0z2b4d6f8h0k", | |
| 9737 | + | "creator": "syntaqx", | |
| 9738 | + | "created_at": "2026-10-07T18:02:11.204Z" | |
| 9739 | + | } | |
| 9740 | + | ] | |
| 9741 | + | }, | |
| 9742 | + | "create_deployment_status": { | |
| 9743 | + | "params": { | |
| 9744 | + | "owner": "flagon-io", | |
| 9745 | + | "name": "g1t", | |
| 9746 | + | "id": "dep_01kq7z9a1c3e5g7j9m1p3r5t7v" | |
| 9747 | + | }, | |
| 9748 | + | "request": { | |
| 9749 | + | "state": "failure", | |
| 9750 | + | "description": "Smoke tests failed", | |
| 9751 | + | "log_url": "https://ci.example.com/pipelines/4182" | |
| 9752 | + | }, | |
| 9753 | + | "response": { | |
| 9754 | + | "id": "dst_01kq7zc2e4g6j8m0p2r4t6v8x0", | |
| 9755 | + | "deployment_id": "dep_01kq7z9a1c3e5g7j9m1p3r5t7v", | |
| 9756 | + | "state": "failure", | |
| 9757 | + | "description": "Smoke tests failed", | |
| 9758 | + | "environment_url": null, | |
| 9759 | + | "log_url": "https://ci.example.com/pipelines/4182", | |
| 9760 | + | "creator": "flagon-io", | |
| 9761 | + | "created_at": "2026-10-06T21:44:58.020Z" | |
| 9762 | + | }, | |
| 9763 | + | "notes": "`queued` and `in_progress` set the commit's `deploy / <environment>` check pending; `success`, `failure` and `error` settle it; `inactive` leaves it. A success makes the environment's older successful deployments `inactive` unless `auto_inactive` is false. A g1t.page build (`dpl_…`) answers `409`: its statuses come from the build." | |
| 9764 | + | }, | |
| 9765 | + | "list_environments": { | |
| 9766 | + | "params": { | |
| 9767 | + | "owner": "flagon-io", | |
| 9768 | + | "name": "g1t" | |
| 9769 | + | }, | |
| 9770 | + | "response": { | |
| 9771 | + | "total_count": 304, | |
| 9772 | + | "environments": [ | |
| 9773 | + | { | |
| 9774 | + | "name": "production", | |
| 9775 | + | "url": "https://g1t.sh", | |
| 9776 | + | "production_environment": true, | |
| 9777 | + | "transient_environment": false, | |
| 9778 | + | "deployments_count": 281, | |
| 9779 | + | "latest": { | |
| 9780 | + | "id": "dep_01kq8m3t5v7x9z1b3d5f7h9k2m", | |
| 9781 | + | "environment": "production", | |
| 9782 | + | "ref": "main", | |
| 9783 | + | "sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7", | |
| 9784 | + | "task": "deploy", | |
| 9785 | + | "description": "Deploy", | |
| 9786 | + | "payload": {}, | |
| 9787 | + | "transient_environment": false, | |
| 9788 | + | "production_environment": true, | |
| 9789 | + | "state": "success", | |
| 9790 | + | "environment_url": "https://g1t.sh", | |
| 9791 | + | "log_url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_01kq8m2r4t6v8x0z2b4d6f8h0k", | |
| 9792 | + | "creator": "syntaqx", | |
| 9793 | + | "source": "actions", | |
| 9794 | + | "run_id": "run_01kq8m2r4t6v8x0z2b4d6f8h0k", | |
| 9795 | + | "run_url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_01kq8m2r4t6v8x0z2b4d6f8h0k", | |
| 9796 | + | "project": null, | |
| 9797 | + | "number": null, | |
| 9798 | + | "created_at": "2026-10-07T18:02:11.204Z", | |
| 9799 | + | "updated_at": "2026-10-07T18:19:47.881Z" | |
| 9800 | + | }, | |
| 9801 | + | "current": { | |
| 9802 | + | "id": "dep_01kq8m3t5v7x9z1b3d5f7h9k2m", | |
| 9803 | + | "environment": "production", | |
| 9804 | + | "ref": "main", | |
| 9805 | + | "sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7", | |
| 9806 | + | "task": "deploy", | |
| 9807 | + | "description": "Deploy", | |
| 9808 | + | "payload": {}, | |
| 9809 | + | "transient_environment": false, | |
| 9810 | + | "production_environment": true, | |
| 9811 | + | "state": "success", | |
| 9812 | + | "environment_url": "https://g1t.sh", | |
| 9813 | + | "log_url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_01kq8m2r4t6v8x0z2b4d6f8h0k", | |
| 9814 | + | "creator": "syntaqx", | |
| 9815 | + | "source": "actions", | |
| 9816 | + | "run_id": "run_01kq8m2r4t6v8x0z2b4d6f8h0k", | |
| 9817 | + | "run_url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_01kq8m2r4t6v8x0z2b4d6f8h0k", | |
| 9818 | + | "project": null, | |
| 9819 | + | "number": null, | |
| 9820 | + | "created_at": "2026-10-07T18:02:11.204Z", | |
| 9821 | + | "updated_at": "2026-10-07T18:19:47.881Z" | |
| 9822 | + | }, | |
| 9823 | + | "updated_at": "2026-10-07T18:19:47.881Z" | |
| 9824 | + | }, | |
| 9825 | + | { | |
| 9826 | + | "name": "staging", | |
| 9827 | + | "url": null, | |
| 9828 | + | "production_environment": false, | |
| 9829 | + | "transient_environment": false, | |
| 9830 | + | "deployments_count": 19, | |
| 9831 | + | "latest": { | |
| 9832 | + | "id": "dep_01kq7z9a1c3e5g7j9m1p3r5t7v", | |
| 9833 | + | "environment": "staging", | |
| 9834 | + | "ref": "main", | |
| 9835 | + | "sha": "4b8d0f2a6c1e3579bd02468ace13579bdf02468a", | |
| 9836 | + | "task": "deploy", | |
| 9837 | + | "description": "Deployed by the release pipeline", | |
| 9838 | + | "payload": { | |
| 9839 | + | "pipeline": 4182, | |
| 9840 | + | "region": "us-east" | |
| 9841 | + | }, | |
| 9842 | + | "transient_environment": false, | |
| 9843 | + | "production_environment": false, | |
| 9844 | + | "state": "failure", | |
| 9845 | + | "environment_url": null, | |
| 9846 | + | "log_url": "https://ci.example.com/pipelines/4182", | |
| 9847 | + | "creator": "flagon-io", | |
| 9848 | + | "source": "api", | |
| 9849 | + | "run_id": null, | |
| 9850 | + | "run_url": null, | |
| 9851 | + | "project": null, | |
| 9852 | + | "number": null, | |
| 9853 | + | "created_at": "2026-10-06T21:40:03.512Z", | |
| 9854 | + | "updated_at": "2026-10-06T21:44:58.020Z" | |
| 9855 | + | }, | |
| 9856 | + | "current": null, | |
| 9857 | + | "updated_at": "2026-10-06T21:44:58.020Z" | |
| 9858 | + | } | |
| 9859 | + | ] | |
| 9860 | + | }, | |
| 9861 | + | "notes": "Production comes first, then the most recently deployed. `latest` is the newest deployment whatever its state; `current` is the newest that succeeded and is still active, and `url` is where it is served." | |
| 9862 | + | }, | |
| 9863 | + | "get_environment": { | |
| 9864 | + | "params": { | |
| 9865 | + | "owner": "flagon-io", | |
| 9866 | + | "name": "docs", | |
| 9867 | + | "environment": "preview" | |
| 9868 | + | }, | |
| 9869 | + | "response": { | |
| 9870 | + | "name": "preview", | |
| 9871 | + | "url": "https://docs-git-docs-deployments-flagon-io.g1t.page", | |
| 9872 | + | "production_environment": false, | |
| 9873 | + | "transient_environment": true, | |
| 9874 | + | "deployments_count": 87, | |
| 9875 | + | "latest": { | |
| 9876 | + | "id": "dpl_01kq8p2b4d6f8h0k2m4p6r8t0v", | |
| 9877 | + | "environment": "preview", | |
| 9878 | + | "ref": "docs-deployments", | |
| 9879 | + | "sha": "e2f4a6c8b0d1e3f5a7c9b1d3e5f7a9c1b3d5e7f9", | |
| 9880 | + | "task": "deploy", | |
| 9881 | + | "description": "Preview of docs-deployments on g1t.page", | |
| 9882 | + | "payload": {}, | |
| 9883 | + | "transient_environment": true, | |
| 9884 | + | "production_environment": false, | |
| 9885 | + | "state": "success", | |
| 9886 | + | "environment_url": "https://docs-git-docs-deployments-flagon-io.g1t.page", | |
| 9887 | + | "log_url": "https://g1t.sh/flagon-io/docs/deployments/dpl_01kq8p2b4d6f8h0k2m4p6r8t0v", | |
| 9888 | + | "creator": "syntaqx", | |
| 9889 | + | "source": "g1t_page", | |
| 9890 | + | "run_id": null, | |
| 9891 | + | "run_url": null, | |
| 9892 | + | "project": "docs", | |
| 9893 | + | "number": 412, | |
| 9894 | + | "created_at": "2026-10-08T01:12:44.630Z", | |
| 9895 | + | "updated_at": "2026-10-08T01:13:52.101Z" | |
| 9896 | + | }, | |
| 9897 | + | "current": { | |
| 9898 | + | "id": "dpl_01kq8p2b4d6f8h0k2m4p6r8t0v", | |
| 9899 | + | "environment": "preview", | |
| 9900 | + | "ref": "docs-deployments", | |
| 9901 | + | "sha": "e2f4a6c8b0d1e3f5a7c9b1d3e5f7a9c1b3d5e7f9", | |
| 9902 | + | "task": "deploy", | |
| 9903 | + | "description": "Preview of docs-deployments on g1t.page", | |
| 9904 | + | "payload": {}, | |
| 9905 | + | "transient_environment": true, | |
| 9906 | + | "production_environment": false, | |
| 9907 | + | "state": "success", | |
| 9908 | + | "environment_url": "https://docs-git-docs-deployments-flagon-io.g1t.page", | |
| 9909 | + | "log_url": "https://g1t.sh/flagon-io/docs/deployments/dpl_01kq8p2b4d6f8h0k2m4p6r8t0v", | |
| 9910 | + | "creator": "syntaqx", | |
| 9911 | + | "source": "g1t_page", | |
| 9912 | + | "run_id": null, | |
| 9913 | + | "run_url": null, | |
| 9914 | + | "project": "docs", | |
| 9915 | + | "number": 412, | |
| 9916 | + | "created_at": "2026-10-08T01:12:44.630Z", | |
| 9917 | + | "updated_at": "2026-10-08T01:13:52.101Z" | |
| 9918 | + | }, | |
| 9919 | + | "updated_at": "2026-10-08T01:13:52.101Z" | |
| 9920 | + | } | |
| 9921 | + | }, | |
| 9922 | + | "get_languages": { | |
| 9923 | + | "response": { | |
| 9924 | + | "head": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f", | |
| 9925 | + | "commit": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f", | |
| 9926 | + | "computed_at": "2026-10-07T09:14:03.000Z", | |
| 9927 | + | "pending": false, | |
| 9928 | + | "partial": false, | |
| 9929 | + | "languages": [ | |
| 9930 | + | { | |
| 9931 | + | "name": "Rust", | |
| 9932 | + | "color": "#dea584", | |
| 9933 | + | "bytes": 184213, | |
| 9934 | + | "percent": 71.4 | |
| 9935 | + | }, | |
| 9936 | + | { | |
| 9937 | + | "name": "TypeScript", | |
| 9938 | + | "color": "#3178c6", | |
| 9939 | + | "bytes": 61022, | |
| 9940 | + | "percent": 23.7 | |
| 9941 | + | }, | |
| 9942 | + | { | |
| 9943 | + | "name": "Shell", | |
| 9944 | + | "color": "#89e051", | |
| 9945 | + | "bytes": 12650, | |
| 9946 | + | "percent": 4.9 | |
| 9947 | + | } | |
| 9948 | + | ] | |
| 9949 | + | }, | |
| 9950 | + | "notes": "While `pending` is true, `languages` is empty: the default branch is being read for the first time. When `commit` is not `head`, the answer is for an older commit while the newer one is read." | |
| 9951 | + | }, | |
| 9952 | + | "list_contributors": { | |
| 9953 | + | "response": { | |
| 9954 | + | "head": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f", | |
| 9955 | + | "commit": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f", | |
| 9956 | + | "computed_at": "2026-10-07T09:14:03.000Z", | |
| 9957 | + | "pending": false, | |
| 9958 | + | "partial": false, | |
| 9959 | + | "total": 3, | |
| 9960 | + | "commits": 126, | |
| 9961 | + | "contributors": [ | |
| 9962 | + | { | |
| 9963 | + | "kind": "g1t", | |
| 9964 | + | "name": "g1t", | |
| 9965 | + | "username": null, | |
| 9966 | + | "avatar": null, | |
| 9967 | + | "commits": 71, | |
| 9968 | + | "first_at": "2026-09-29T08:00:00.000Z", | |
| 9969 | + | "last_at": "2026-10-07T08:41:00.000Z", | |
| 9970 | + | "weeks": [ | |
| 9971 | + | { | |
| 9972 | + | "week": "2026-09-28", | |
| 9973 | + | "commits": 40 | |
| 9974 | + | }, | |
| 9975 | + | { | |
| 9976 | + | "week": "2026-10-05", | |
| 9977 | + | "commits": 31 | |
| 9978 | + | } | |
| 9979 | + | ] | |
| 9980 | + | }, | |
| 9981 | + | { | |
| 9982 | + | "kind": "user", | |
| 9983 | + | "name": "ada", | |
| 9984 | + | "username": "ada", | |
| 9985 | + | "avatar": "5f2b8c1d9e7a3f6b4c0d2e8a1b9c7d5e3f1a0b2c4d6e8f0a1b3c5d7e9f0a2b4c", | |
| 9986 | + | "commits": 52, | |
| 9987 | + | "first_at": "2026-09-28T14:11:52.000Z", | |
| 9988 | + | "last_at": "2026-10-06T16:02:11.000Z", | |
| 9989 | + | "weeks": [ | |
| 9990 | + | { | |
| 9991 | + | "week": "2026-09-28", | |
| 9992 | + | "commits": 30 | |
| 9993 | + | }, | |
| 9994 | + | { | |
| 9995 | + | "week": "2026-10-05", | |
| 9996 | + | "commits": 22 | |
| 9997 | + | } | |
| 9998 | + | ] | |
| 9999 | + | }, | |
| 10000 | + | { | |
| 10001 | + | "kind": "author", | |
| 10002 | + | "name": "Sam Okafor", | |
| 10003 | + | "username": null, | |
| 10004 | + | "avatar": null, | |
| 10005 | + | "commits": 3, | |
| 10006 | + | "first_at": "2026-10-02T11:20:00.000Z", | |
| 10007 | + | "last_at": "2026-10-03T09:05:00.000Z", | |
| 10008 | + | "weeks": [ | |
| 10009 | + | { | |
| 10010 | + | "week": "2026-09-28", | |
| 10011 | + | "commits": 3 | |
| 10012 | + | } | |
| 10013 | + | ] | |
| 10014 | + | } | |
| 10015 | + | ], | |
| 10016 | + | "weeks": [ | |
| 10017 | + | { | |
| 10018 | + | "week": "2026-09-28", | |
| 10019 | + | "commits": 73 | |
| 10020 | + | }, | |
| 10021 | + | { | |
| 10022 | + | "week": "2026-10-05", | |
| 10023 | + | "commits": 53 | |
| 10024 | + | } | |
| 10025 | + | ] | |
| 10026 | + | }, | |
| 10027 | + | "notes": "Read from the newest 3,000 commits of the default branch. A person's addresses count as one when they confirmed them on their account; their noreply address counts too." | |
| 10028 | + | }, | |
| 10029 | + | "get_license": { | |
| 10030 | + | "response": { | |
| 10031 | + | "spdx_id": "MIT", | |
| 10032 | + | "name": "MIT License", | |
| 10033 | + | "path": "LICENSE" | |
| 10034 | + | } | |
| 10035 | + | }, | |
| 10036 | + | "list_stargazers": { | |
| 10037 | + | "query": { | |
| 10038 | + | "page": 1 | |
| 10039 | + | }, | |
| 10040 | + | "response": [ | |
| 10041 | + | { | |
| 10042 | + | "username": "ada", | |
| 10043 | + | "avatar": null, | |
| 10044 | + | "starred_at": "2026-10-06T18:30:00.000Z" | |
| 10045 | + | }, | |
| 10046 | + | { | |
| 10047 | + | "username": "sam", | |
| 10048 | + | "avatar": null, | |
| 10049 | + | "starred_at": "2026-10-02T09:12:00.000Z" | |
| 10050 | + | } | |
| 10051 | + | ] | |
| 10052 | + | }, | |
| 10053 | + | "list_starred": { | |
| 10054 | + | "response": [ | |
| 10055 | + | { | |
| 10056 | + | "repo": { | |
| 10057 | + | "id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr", | |
| 10058 | + | "namespace": "flagon-io", | |
| 10059 | + | "name": "hello", | |
| 10060 | + | "description": "A tiny service that says hello.", | |
| 10061 | + | "is_private": false, | |
| 10062 | + | "owner_id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 10063 | + | "default_branch": "main", | |
| 10064 | + | "fork_of": null, | |
| 10065 | + | "protected": true, | |
| 10066 | + | "created_at": "2026-09-28T14:11:52.640Z", | |
| 10067 | + | "topics": [ | |
| 10068 | + | "cli" | |
| 10069 | + | ], | |
| 10070 | + | "website": "https://hello.g1t.page", | |
| 10071 | + | "archived_at": null | |
| 10072 | + | }, | |
| 10073 | + | "starred_at": "2026-10-06T18:30:00.000Z", | |
| 10074 | + | "stars": 12 | |
| 10075 | + | } | |
| 10076 | + | ] | |
| 10077 | + | }, | |
| 10078 | + | "check_starred": { | |
| 10079 | + | "response": { | |
| 10080 | + | "starred": true, | |
| 10081 | + | "stars": 12 | |
| 10082 | + | } | |
| 10083 | + | }, | |
| 10084 | + | "star_repo": { | |
| 10085 | + | "response": { | |
| 10086 | + | "starred": true, | |
| 10087 | + | "stars": 13 | |
| 10088 | + | } | |
| 10089 | + | }, | |
| 10090 | + | "unstar_repo": { | |
| 10091 | + | "response": { | |
| 10092 | + | "starred": false, | |
| 10093 | + | "stars": 12 | |
| 10094 | + | } | |
| 10095 | + | }, | |
| 10096 | + | "list_releases": { | |
| 10097 | + | "response": [ | |
| 10098 | + | { | |
| 10099 | + | "id": "rel_01m4a2b7c9d3e5f7g9h1j3k5m7", | |
| 10100 | + | "tag_name": "v1.2.0", | |
| 10101 | + | "target": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f", | |
| 10102 | + | "name": "Greetings by name", | |
| 10103 | + | "body": "## What changed\n\n- `hello` greets the caller by name (#12)\n- Faster start-up (#15)", | |
| 10104 | + | "draft": false, | |
| 10105 | + | "prerelease": false, | |
| 10106 | + | "author": "ada", | |
| 10107 | + | "created_at": "2026-10-06T16:02:11.000Z", | |
| 10108 | + | "published_at": "2026-10-06T16:02:11.000Z", | |
| 10109 | + | "latest": true | |
| 10110 | + | }, | |
| 10111 | + | { | |
| 10112 | + | "id": "rel_01m3x8q2w4e6r8t0y2u4i6o8p0", | |
| 10113 | + | "tag_name": "v1.1.0", | |
| 10114 | + | "target": "3b2a19f8e7d6c5b4a392817069f3c2a1b7e5d4c3", | |
| 10115 | + | "name": "First release", | |
| 10116 | + | "body": "The first release.", | |
| 10117 | + | "draft": false, | |
| 10118 | + | "prerelease": false, | |
| 10119 | + | "author": "ada", | |
| 10120 | + | "created_at": "2026-09-30T10:00:00.000Z", | |
| 10121 | + | "published_at": "2026-09-30T10:00:00.000Z", | |
| 10122 | + | "latest": false | |
| 10123 | + | } | |
| 10124 | + | ], | |
| 10125 | + | "notes": "Drafts are listed only to those with the Write role; their `published_at` is null." | |
| 10126 | + | }, | |
| 10127 | + | "get_latest_release": { | |
| 10128 | + | "response": { | |
| 10129 | + | "id": "rel_01m4a2b7c9d3e5f7g9h1j3k5m7", | |
| 10130 | + | "tag_name": "v1.2.0", | |
| 10131 | + | "target": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f", | |
| 10132 | + | "name": "Greetings by name", | |
| 10133 | + | "body": "## What changed\n\n- `hello` greets the caller by name (#12)\n- Faster start-up (#15)", | |
| 10134 | + | "draft": false, | |
| 10135 | + | "prerelease": false, | |
| 10136 | + | "author": "ada", | |
| 10137 | + | "created_at": "2026-10-06T16:02:11.000Z", | |
| 10138 | + | "published_at": "2026-10-06T16:02:11.000Z", | |
| 10139 | + | "latest": true | |
| 10140 | + | } | |
| 10141 | + | }, | |
| 10142 | + | "get_release_by_tag": { | |
| 10143 | + | "params": { | |
| 10144 | + | "tag": "v1.2.0" | |
| 10145 | + | }, | |
| 10146 | + | "response": { | |
| 10147 | + | "id": "rel_01m4a2b7c9d3e5f7g9h1j3k5m7", | |
| 10148 | + | "tag_name": "v1.2.0", | |
| 10149 | + | "target": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f", | |
| 10150 | + | "name": "Greetings by name", | |
| 10151 | + | "body": "## What changed\n\n- `hello` greets the caller by name (#12)\n- Faster start-up (#15)", | |
| 10152 | + | "draft": false, | |
| 10153 | + | "prerelease": false, | |
| 10154 | + | "author": "ada", | |
| 10155 | + | "created_at": "2026-10-06T16:02:11.000Z", | |
| 10156 | + | "published_at": "2026-10-06T16:02:11.000Z", | |
| 10157 | + | "latest": true | |
| 10158 | + | } | |
| 10159 | + | }, | |
| 10160 | + | "get_release": { | |
| 10161 | + | "params": { | |
| 10162 | + | "id": "rel_01m4a2b7c9d3e5f7g9h1j3k5m7" | |
| 10163 | + | }, | |
| 10164 | + | "response": { | |
| 10165 | + | "id": "rel_01m4a2b7c9d3e5f7g9h1j3k5m7", | |
| 10166 | + | "tag_name": "v1.2.0", | |
| 10167 | + | "target": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f", | |
| 10168 | + | "name": "Greetings by name", | |
| 10169 | + | "body": "## What changed\n\n- `hello` greets the caller by name (#12)\n- Faster start-up (#15)", | |
| 10170 | + | "draft": false, | |
| 10171 | + | "prerelease": false, | |
| 10172 | + | "author": "ada", | |
| 10173 | + | "created_at": "2026-10-06T16:02:11.000Z", | |
| 10174 | + | "published_at": "2026-10-06T16:02:11.000Z", | |
| 10175 | + | "latest": true | |
| 10176 | + | } | |
| 10177 | + | }, | |
| 10178 | + | "create_release": { | |
| 10179 | + | "request": { | |
| 10180 | + | "tag_name": "v1.2.0", | |
| 10181 | + | "target": "main", | |
| 10182 | + | "release_name": "Greetings by name", | |
| 10183 | + | "body": "## What changed\n\n- `hello` greets the caller by name (#12)\n- Faster start-up (#15)", | |
| 10184 | + | "draft": false, | |
| 10185 | + | "prerelease": false | |
| 10186 | + | }, | |
| 10187 | + | "response": { | |
| 10188 | + | "id": "rel_01m4a2b7c9d3e5f7g9h1j3k5m7", | |
| 10189 | + | "tag_name": "v1.2.0", | |
| 10190 | + | "target": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f", | |
| 10191 | + | "name": "Greetings by name", | |
| 10192 | + | "body": "## What changed\n\n- `hello` greets the caller by name (#12)\n- Faster start-up (#15)", | |
| 10193 | + | "draft": false, | |
| 10194 | + | "prerelease": false, | |
| 10195 | + | "author": "ada", | |
| 10196 | + | "created_at": "2026-10-06T16:02:11.000Z", | |
| 10197 | + | "published_at": "2026-10-06T16:02:11.000Z", | |
| 10198 | + | "latest": true | |
| 10199 | + | }, | |
| 10200 | + | "notes": "A tag that already exists is released as it is, and `target` is ignored. One release per tag: releasing a tag again is refused with `conflict`." | |
| 10201 | + | }, | |
| 10202 | + | "update_release": { | |
| 10203 | + | "params": { | |
| 10204 | + | "id": "rel_01m4a2b7c9d3e5f7g9h1j3k5m7" | |
| 10205 | + | }, | |
| 10206 | + | "request": { | |
| 10207 | + | "body": "## What changed\n\n- `hello` greets the caller by name (#12)\n- Faster start-up (#15)\n- Docs for `--name`" | |
| 10208 | + | }, | |
| 10209 | + | "response": { | |
| 10210 | + | "id": "rel_01m4a2b7c9d3e5f7g9h1j3k5m7", | |
| 10211 | + | "tag_name": "v1.2.0", | |
| 10212 | + | "target": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f", | |
| 10213 | + | "name": "Greetings by name", | |
| 10214 | + | "body": "## What changed\n\n- `hello` greets the caller by name (#12)\n- Faster start-up (#15)\n- Docs for `--name`", | |
| 10215 | + | "draft": false, | |
| 10216 | + | "prerelease": false, | |
| 10217 | + | "author": "ada", | |
| 10218 | + | "created_at": "2026-10-06T16:02:11.000Z", | |
| 10219 | + | "published_at": "2026-10-06T16:02:11.000Z", | |
| 10220 | + | "latest": true | |
| 10221 | + | } | |
| 10222 | + | }, | |
| 10223 | + | "delete_release": { | |
| 10224 | + | "params": { | |
| 10225 | + | "id": "rel_01m4a2b7c9d3e5f7g9h1j3k5m7" | |
| 10226 | + | }, | |
| 10227 | + | "response": { | |
| 10228 | + | "deleted": true | |
| 10229 | + | } | |
| 10230 | + | }, | |
| 10231 | + | "create_commit_status": { | |
| 10232 | + | "params": { | |
| 10233 | + | "owner": "flagon-io", | |
| 10234 | + | "name": "hello", | |
| 10235 | + | "sha": "9f3c2a1b7e6d5c4b3a2918f7e6d5c4b3a2918f7e" | |
| 10236 | + | }, | |
| 10237 | + | "request": { | |
| 10238 | + | "state": "success", | |
| 10239 | + | "context": "ci/build", | |
| 10240 | + | "description": "Build #4821 passed", | |
| 10241 | + | "target_url": "https://ci.example.com/builds/4821" | |
| 10242 | + | }, | |
| 10243 | + | "response": { | |
| 10244 | + | "context": "ci/build", | |
| 10245 | + | "state": "success", | |
| 10246 | + | "description": "Build #4821 passed", | |
| 10247 | + | "target_url": "https://ci.example.com/builds/4821", | |
| 10248 | + | "updated_at": "2026-10-07T14:03:02.118Z", | |
| 10249 | + | "source": "api" | |
| 10250 | + | }, | |
| 10251 | + | "notes": "Setting a context again replaces its status on that commit. A status counts as a check: a required check named `ci/build`, in branch protection or a ruleset's `required_status_checks`, is met by it, and a ruleset check pinned to the `api` integration only by statuses and check runs reported through the API. See the [Checks guide](/guides/checks/)." | |
| 10252 | + | }, | |
| 10253 | + | "list_commit_statuses": { | |
| 10254 | + | "params": { | |
| 10255 | + | "owner": "flagon-io", | |
| 10256 | + | "name": "hello", | |
| 10257 | + | "ref": "main" | |
| 10258 | + | }, | |
| 10259 | + | "response": [ | |
| 10260 | + | { | |
| 10261 | + | "context": "ci/build", | |
| 10262 | + | "state": "success", | |
| 10263 | + | "description": "Build #4821 passed", | |
| 10264 | + | "target_url": "https://ci.example.com/builds/4821", | |
| 10265 | + | "updated_at": "2026-10-07T14:03:02.118Z", | |
| 10266 | + | "source": "api" | |
| 10267 | + | } | |
| 10268 | + | ] | |
| 10269 | + | }, | |
| 10270 | + | "get_combined_status": { | |
| 10271 | + | "params": { | |
| 10272 | + | "owner": "flagon-io", | |
| 10273 | + | "name": "hello", | |
| 10274 | + | "ref": "main" | |
| 10275 | + | }, | |
| 10276 | + | "response": { | |
| 10277 | + | "state": "success", | |
| 10278 | + | "sha": "9f3c2a1b7e6d5c4b3a2918f7e6d5c4b3a2918f7e", | |
| 10279 | + | "total_count": 1, | |
| 10280 | + | "statuses": [ | |
| 10281 | + | { | |
| 10282 | + | "context": "ci/build", | |
| 10283 | + | "state": "success", | |
| 10284 | + | "description": "Build #4821 passed", | |
| 10285 | + | "target_url": "https://ci.example.com/builds/4821", | |
| 10286 | + | "updated_at": "2026-10-07T14:03:02.118Z", | |
| 10287 | + | "source": "api" | |
| 10288 | + | } | |
| 10289 | + | ] | |
| 10290 | + | } | |
| 10291 | + | }, | |
| 10292 | + | "create_check_run": { | |
| 10293 | + | "params": { | |
| 10294 | + | "owner": "flagon-io", | |
| 10295 | + | "name": "hello" | |
| 10296 | + | }, | |
| 10297 | + | "request": { | |
| 10298 | + | "name": "lint", | |
| 10299 | + | "head_sha": "9f3c2a1b7e6d5c4b3a2918f7e6d5c4b3a2918f7e", | |
| 10300 | + | "status": "completed", | |
| 10301 | + | "conclusion": "failure", | |
| 10302 | + | "details_url": "https://ci.example.com/builds/4821", | |
| 10303 | + | "external_id": "4821", | |
| 10304 | + | "output": { | |
| 10305 | + | "title": "2 problems", | |
| 10306 | + | "summary": "**2** problems in 1 file.", | |
| 10307 | + | "annotations": [ | |
| 10308 | + | { | |
| 10309 | + | "path": "src/parse.rs", | |
| 10310 | + | "start_line": 42, | |
| 10311 | + | "end_line": 42, | |
| 10312 | + | "annotation_level": "warning", | |
| 10313 | + | "message": "unused variable: `depth`" | |
| 10314 | + | } | |
| 10315 | + | ] | |
| 10316 | + | }, | |
| 10317 | + | "actions": [ | |
| 10318 | + | { | |
| 10319 | + | "label": "Fix this", | |
| 10320 | + | "description": "Apply the suggested fixes", | |
| 10321 | + | "identifier": "fix" | |
| 10322 | + | } | |
| 10323 | + | ] | |
| 10324 | + | }, | |
| 10325 | + | "response": { | |
| 10326 | + | "id": "cr_01kq4b7c8d9e0f1g2h3j4k5m6n", | |
| 10327 | + | "name": "lint", | |
| 10328 | + | "head_sha": "9f3c2a1b7e6d5c4b3a2918f7e6d5c4b3a2918f7e", | |
| 10329 | + | "status": "completed", | |
| 10330 | + | "conclusion": "failure", | |
| 10331 | + | "started_at": "2026-10-07T14:02:11.000Z", | |
| 10332 | + | "completed_at": "2026-10-07T14:02:36.000Z", | |
| 10333 | + | "details_url": "https://ci.example.com/builds/4821", | |
| 10334 | + | "external_id": "4821", | |
| 10335 | + | "html_url": "https://g1t.sh/flagon-io/hello/checks/cr_01kq4b7c8d9e0f1g2h3j4k5m6n", | |
| 10336 | + | "output": { | |
| 10337 | + | "title": "2 problems", | |
| 10338 | + | "summary": "**2** problems in 1 file.", | |
| 10339 | + | "text": null, | |
| 10340 | + | "annotations_count": 2 | |
| 10341 | + | }, | |
| 10342 | + | "actions": [ | |
| 10343 | + | { | |
| 10344 | + | "label": "Fix this", | |
| 10345 | + | "description": "Apply the suggested fixes", | |
| 10346 | + | "identifier": "fix" | |
| 10347 | + | } | |
| 10348 | + | ], | |
| 10349 | + | "check_suite": { | |
| 10350 | + | "id": "cs_01kq4b7c8d9e0f1g2h3j4k5m6p" | |
| 10351 | + | }, | |
| 10352 | + | "app": { | |
| 10353 | + | "slug": "buildkite", | |
| 10354 | + | "name": "Buildkite" | |
| 10355 | + | }, | |
| 10356 | + | "created_at": "2026-10-07T14:02:11.318Z" | |
| 10357 | + | }, | |
| 10358 | + | "notes": "Start a run as `queued` or `in_progress` and complete it later with [`update_check_run`](/reference/api/checks/update-check-run/), or create it completed. `app` names who reports it; by default it is your token's name, and a g1t Actions job's `G1T_TOKEN` reports as g1t Actions. Each reporter's runs on a commit form one check suite. The run also stands as a status of its name, so a required check `lint` is met by it: `success`, `neutral` and `skipped` pass, any other conclusion fails. See the [Checks guide](/guides/checks/)." | |
| 10359 | + | }, | |
| 10360 | + | "update_check_run": { | |
| 10361 | + | "params": { | |
| 10362 | + | "owner": "flagon-io", | |
| 10363 | + | "name": "hello", | |
| 10364 | + | "id": "cr_01kq4b7c8d9e0f1g2h3j4k5m6n" | |
| 10365 | + | }, | |
| 10366 | + | "request": { | |
| 10367 | + | "conclusion": "success", | |
| 10368 | + | "output": { | |
| 10369 | + | "title": "No problems", | |
| 10370 | + | "summary": "All clear." | |
| 10371 | + | } | |
| 10372 | + | }, | |
| 10373 | + | "response": { | |
| 10374 | + | "id": "cr_01kq4b7c8d9e0f1g2h3j4k5m6n", | |
| 10375 | + | "name": "lint", | |
| 10376 | + | "head_sha": "9f3c2a1b7e6d5c4b3a2918f7e6d5c4b3a2918f7e", | |
| 10377 | + | "status": "completed", | |
| 10378 | + | "conclusion": "success", | |
| 10379 | + | "started_at": "2026-10-07T14:02:11.000Z", | |
| 10380 | + | "completed_at": "2026-10-07T14:02:36.000Z", | |
| 10381 | + | "details_url": "https://ci.example.com/builds/4821", | |
| 10382 | + | "external_id": "4821", | |
| 10383 | + | "html_url": "https://g1t.sh/flagon-io/hello/checks/cr_01kq4b7c8d9e0f1g2h3j4k5m6n", | |
| 10384 | + | "output": { | |
| 10385 | + | "title": "No problems", | |
| 10386 | + | "summary": "All clear.", | |
| 10387 | + | "text": null, | |
| 10388 | + | "annotations_count": 2 | |
| 10389 | + | }, | |
| 10390 | + | "actions": [ | |
| 10391 | + | { | |
| 10392 | + | "label": "Fix this", | |
| 10393 | + | "description": "Apply the suggested fixes", | |
| 10394 | + | "identifier": "fix" | |
| 10395 | + | } | |
| 10396 | + | ], | |
| 10397 | + | "check_suite": { | |
| 10398 | + | "id": "cs_01kq4b7c8d9e0f1g2h3j4k5m6p" | |
| 10399 | + | }, | |
| 10400 | + | "app": { | |
| 10401 | + | "slug": "buildkite", | |
| 10402 | + | "name": "Buildkite" | |
| 10403 | + | }, | |
| 10404 | + | "created_at": "2026-10-07T14:02:11.318Z" | |
| 10405 | + | }, | |
| 10406 | + | "notes": "Annotations given here are added to the ones the run has, at most 50 a request and 1000 in all." | |
| 10407 | + | }, | |
| 10408 | + | "get_check_run": { | |
| 10409 | + | "params": { | |
| 10410 | + | "owner": "flagon-io", | |
| 10411 | + | "name": "hello", | |
| 10412 | + | "id": "job_01kq4b6a7b8c9d0e1f2g3h4j5k" | |
| 10413 | + | }, | |
| 10414 | + | "response": { | |
| 10415 | + | "id": "job_01kq4b6a7b8c9d0e1f2g3h4j5k", | |
| 10416 | + | "name": "Test", | |
| 10417 | + | "head_sha": "9f3c2a1b7e6d5c4b3a2918f7e6d5c4b3a2918f7e", | |
| 10418 | + | "status": "completed", | |
| 10419 | + | "conclusion": "success", | |
| 10420 | + | "started_at": "2026-10-07T14:01:02.000Z", | |
| 10421 | + | "completed_at": "2026-10-07T14:01:27.000Z", | |
| 10422 | + | "details_url": "https://g1t.sh/flagon-io/hello/actions/runs/run_01kq4b6a7b8c9d0e1f2g3h4j5m?job=job_01kq4b6a7b8c9d0e1f2g3h4j5k", | |
| 10423 | + | "external_id": null, | |
| 10424 | + | "html_url": "https://g1t.sh/flagon-io/hello/actions/runs/run_01kq4b6a7b8c9d0e1f2g3h4j5m?job=job_01kq4b6a7b8c9d0e1f2g3h4j5k", | |
| 10425 | + | "output": { | |
| 10426 | + | "title": null, | |
| 10427 | + | "summary": null, | |
| 10428 | + | "text": null, | |
| 10429 | + | "annotations_count": 0 | |
| 10430 | + | }, | |
| 10431 | + | "actions": [], | |
| 10432 | + | "check_suite": { | |
| 10433 | + | "id": "run_01kq4b6a7b8c9d0e1f2g3h4j5m" | |
| 10434 | + | }, | |
| 10435 | + | "app": { | |
| 10436 | + | "slug": "actions", | |
| 10437 | + | "name": "g1t Actions" | |
| 10438 | + | }, | |
| 10439 | + | "workflow": { | |
| 10440 | + | "run_id": "run_01kq4b6a7b8c9d0e1f2g3h4j5m", | |
| 10441 | + | "name": "CI", | |
| 10442 | + | "event": "push" | |
| 10443 | + | }, | |
| 10444 | + | "created_at": "2026-10-07T14:01:00.512Z" | |
| 10445 | + | } | |
| 10446 | + | }, | |
| 10447 | + | "list_check_run_annotations": { | |
| 10448 | + | "params": { | |
| 10449 | + | "owner": "flagon-io", | |
| 10450 | + | "name": "hello", | |
| 10451 | + | "id": "cr_01kq4b7c8d9e0f1g2h3j4k5m6n" | |
| 10452 | + | }, | |
| 10453 | + | "response": [ | |
| 10454 | + | { | |
| 10455 | + | "path": "src/parse.rs", | |
| 10456 | + | "start_line": 42, | |
| 10457 | + | "end_line": 42, | |
| 10458 | + | "start_column": 9, | |
| 10459 | + | "end_column": 14, | |
| 10460 | + | "annotation_level": "warning", | |
| 10461 | + | "message": "unused variable: `depth`", | |
| 10462 | + | "title": "unused_variables", | |
| 10463 | + | "raw_details": "#[warn(unused_variables)] on by default" | |
| 10464 | + | } | |
| 10465 | + | ] | |
| 10466 | + | }, | |
| 10467 | + | "rerequest_check_run": { | |
| 10468 | + | "params": { | |
| 10469 | + | "owner": "flagon-io", | |
| 10470 | + | "name": "hello", | |
| 10471 | + | "id": "cr_01kq4b7c8d9e0f1g2h3j4k5m6n" | |
| 10472 | + | }, | |
| 10473 | + | "response": { | |
| 10474 | + | "rerequested": true | |
| 10475 | + | } | |
| 10476 | + | }, | |
| 10477 | + | "list_check_runs_for_ref": { | |
| 10478 | + | "params": { | |
| 10479 | + | "owner": "flagon-io", | |
| 10480 | + | "name": "hello", | |
| 10481 | + | "ref": "main" | |
| 10482 | + | }, | |
| 10483 | + | "query": { | |
| 10484 | + | "filter": "latest" | |
| 10485 | + | }, | |
| 10486 | + | "response": { | |
| 10487 | + | "total_count": 2, | |
| 10488 | + | "check_runs": [ | |
| 10489 | + | { | |
| 10490 | + | "id": "cr_01kq4b7c8d9e0f1g2h3j4k5m6n", | |
| 10491 | + | "name": "lint", | |
| 10492 | + | "head_sha": "9f3c2a1b7e6d5c4b3a2918f7e6d5c4b3a2918f7e", | |
| 10493 | + | "status": "completed", | |
| 10494 | + | "conclusion": "failure", | |
| 10495 | + | "started_at": "2026-10-07T14:02:11.000Z", | |
| 10496 | + | "completed_at": "2026-10-07T14:02:36.000Z", | |
| 10497 | + | "details_url": "https://ci.example.com/builds/4821", | |
| 10498 | + | "external_id": "4821", | |
| 10499 | + | "html_url": "https://g1t.sh/flagon-io/hello/checks/cr_01kq4b7c8d9e0f1g2h3j4k5m6n", | |
| 10500 | + | "output": { | |
| 10501 | + | "title": "2 problems", | |
| 10502 | + | "summary": "**2** problems in 1 file.", | |
| 10503 | + | "text": null, | |
| 10504 | + | "annotations_count": 2 | |
| 10505 | + | }, | |
| 10506 | + | "actions": [ | |
| 10507 | + | { | |
| 10508 | + | "label": "Fix this", | |
| 10509 | + | "description": "Apply the suggested fixes", | |
| 10510 | + | "identifier": "fix" | |
| 10511 | + | } | |
| 10512 | + | ], | |
| 10513 | + | "check_suite": { | |
| 10514 | + | "id": "cs_01kq4b7c8d9e0f1g2h3j4k5m6p" | |
| 10515 | + | }, | |
| 10516 | + | "app": { | |
| 10517 | + | "slug": "buildkite", | |
| 10518 | + | "name": "Buildkite" | |
| 10519 | + | }, | |
| 10520 | + | "created_at": "2026-10-07T14:02:11.318Z" | |
| 10521 | + | }, | |
| 10522 | + | { | |
| 10523 | + | "id": "job_01kq4b6a7b8c9d0e1f2g3h4j5k", | |
| 10524 | + | "name": "Test", | |
| 10525 | + | "head_sha": "9f3c2a1b7e6d5c4b3a2918f7e6d5c4b3a2918f7e", | |
| 10526 | + | "status": "completed", | |
| 10527 | + | "conclusion": "success", | |
| 10528 | + | "started_at": "2026-10-07T14:01:02.000Z", | |
| 10529 | + | "completed_at": "2026-10-07T14:01:27.000Z", | |
| 10530 | + | "details_url": "https://g1t.sh/flagon-io/hello/actions/runs/run_01kq4b6a7b8c9d0e1f2g3h4j5m?job=job_01kq4b6a7b8c9d0e1f2g3h4j5k", | |
| 10531 | + | "external_id": null, | |
| 10532 | + | "html_url": "https://g1t.sh/flagon-io/hello/actions/runs/run_01kq4b6a7b8c9d0e1f2g3h4j5m?job=job_01kq4b6a7b8c9d0e1f2g3h4j5k", | |
| 10533 | + | "output": { | |
| 10534 | + | "title": null, | |
| 10535 | + | "summary": null, | |
| 10536 | + | "text": null, | |
| 10537 | + | "annotations_count": 0 | |
| 10538 | + | }, | |
| 10539 | + | "actions": [], | |
| 10540 | + | "check_suite": { | |
| 10541 | + | "id": "run_01kq4b6a7b8c9d0e1f2g3h4j5m" | |
| 10542 | + | }, | |
| 10543 | + | "app": { | |
| 10544 | + | "slug": "actions", | |
| 10545 | + | "name": "g1t Actions" | |
| 10546 | + | }, | |
| 10547 | + | "workflow": { | |
| 10548 | + | "run_id": "run_01kq4b6a7b8c9d0e1f2g3h4j5m", | |
| 10549 | + | "name": "CI", | |
| 10550 | + | "event": "push" | |
| 10551 | + | }, | |
| 10552 | + | "created_at": "2026-10-07T14:01:00.512Z" | |
| 10553 | + | } | |
| 10554 | + | ] | |
| 10555 | + | } | |
| 10556 | + | }, | |
| 10557 | + | "list_check_suites_for_ref": { | |
| 10558 | + | "params": { | |
| 10559 | + | "owner": "flagon-io", | |
| 10560 | + | "name": "hello", | |
| 10561 | + | "ref": "main" | |
| 10562 | + | }, | |
| 10563 | + | "response": { | |
| 10564 | + | "total_count": 2, | |
| 10565 | + | "check_suites": [ | |
| 10566 | + | { | |
| 10567 | + | "id": "cs_01kq4b7c8d9e0f1g2h3j4k5m6p", | |
| 10568 | + | "head_sha": "9f3c2a1b7e6d5c4b3a2918f7e6d5c4b3a2918f7e", | |
| 10569 | + | "head_branch": "main", | |
| 10570 | + | "status": "completed", | |
| 10571 | + | "conclusion": "failure", | |
| 10572 | + | "app": { | |
| 10573 | + | "slug": "buildkite", | |
| 10574 | + | "name": "Buildkite" | |
| 10575 | + | }, | |
| 10576 | + | "latest_check_runs_count": 1, | |
| 10577 | + | "created_at": "2026-10-07T14:02:11.318Z", | |
| 10578 | + | "updated_at": "2026-10-07T14:02:36.502Z" | |
| 10579 | + | }, | |
| 10580 | + | { | |
| 10581 | + | "id": "run_01kq4b6a7b8c9d0e1f2g3h4j5m", | |
| 10582 | + | "head_sha": "9f3c2a1b7e6d5c4b3a2918f7e6d5c4b3a2918f7e", | |
| 10583 | + | "head_branch": "main", | |
| 10584 | + | "status": "completed", | |
| 10585 | + | "conclusion": "success", | |
| 10586 | + | "app": { | |
| 10587 | + | "slug": "actions", | |
| 10588 | + | "name": "g1t Actions" | |
| 10589 | + | }, | |
| 10590 | + | "name": "CI", | |
| 10591 | + | "latest_check_runs_count": 1, | |
| 10592 | + | "created_at": "2026-10-07T14:01:00.512Z", | |
| 10593 | + | "updated_at": "2026-10-07T14:01:28.040Z" | |
| 10594 | + | } | |
| 10595 | + | ] | |
| 10596 | + | } | |
| 10597 | + | }, | |
| 10598 | + | "get_check_suite": { | |
| 10599 | + | "params": { | |
| 10600 | + | "owner": "flagon-io", | |
| 10601 | + | "name": "hello", | |
| 10602 | + | "id": "cs_01kq4b7c8d9e0f1g2h3j4k5m6p" | |
| 10603 | + | }, | |
| 10604 | + | "response": { | |
| 10605 | + | "id": "cs_01kq4b7c8d9e0f1g2h3j4k5m6p", | |
| 10606 | + | "head_sha": "9f3c2a1b7e6d5c4b3a2918f7e6d5c4b3a2918f7e", | |
| 10607 | + | "head_branch": "main", | |
| 10608 | + | "status": "completed", | |
| 10609 | + | "conclusion": "failure", | |
| 10610 | + | "app": { | |
| 10611 | + | "slug": "buildkite", | |
| 10612 | + | "name": "Buildkite" | |
| 10613 | + | }, | |
| 10614 | + | "latest_check_runs_count": 1, | |
| 10615 | + | "created_at": "2026-10-07T14:02:11.318Z", | |
| 10616 | + | "updated_at": "2026-10-07T14:02:36.502Z" | |
| 10617 | + | } | |
| 10618 | + | }, | |
| 10619 | + | "rerequest_check_suite": { | |
| 10620 | + | "params": { | |
| 10621 | + | "owner": "flagon-io", | |
| 10622 | + | "name": "hello", | |
| 10623 | + | "id": "cs_01kq4b7c8d9e0f1g2h3j4k5m6p" | |
| 10624 | + | }, | |
| 10625 | + | "response": { | |
| 10626 | + | "rerequested": true | |
| 10627 | + | } | |
| 9281 | 10628 | } | |
| 9282 | 10629 | } |
| 7 | 7 | //! encoded again, so that every field the type has is sent, not only the | |
| 8 | 8 | //! ones an example shows. | |
| 9 | 9 | ||
| 10 | − | use g1t_contracts::{access, actions, codeowners, integrations, repos, rules, search, teams, webhooks, work}; | |
| 10 | + | use g1t_contracts::{access, actions, checks, codeowners, integrations, repos, rules, search, teams, webhooks, work}; | |
| 11 | 11 | use g1t_kit::wire::{self, USER_KEYED}; | |
| 12 | 12 | use serde::Serialize; | |
| 13 | 13 | use serde::de::DeserializeOwned; | |
| 15 | 15 | ||
| 16 | 16 | use crate::openapi::document; | |
| 17 | 17 | use crate::operations::Op; | |
| 18 | + | use crate::checks::ChecksOp; | |
| 18 | 19 | use crate::rules::RulesOp; | |
| 19 | 20 | ||
| 20 | 21 | /// A key as `#[serde(rename_all = "camelCase")]` writes it. | |
| 109 | 110 | } | |
| 110 | 111 | // Built by the API itself. | |
| 111 | 112 | Op::Rules(RulesOp::DeleteRepoRuleset | RulesOp::DeleteWorkspaceRuleset) => return as_is, | |
| 113 | + | // Deployments travel in `snake_case` between services too. | |
| 114 | + | Op::Deployments(_) => return as_is, | |
| 112 | 115 | // Built by the API itself, in `snake_case`. | |
| 113 | 116 | Op::ListSecurityAlerts => return through::<Vec<crate::alerts::SecurityAlert>>(op, as_is), | |
| 114 | 117 | Op::DismissSecurityAlert | Op::ReopenSecurityAlert => { | |
| 196 | 199 | Op::GetThreadSubscription | Op::SetThreadSubscription | Op::DeleteThreadSubscription => { | |
| 197 | 200 | through::<g1t_contracts::inbox::ThreadSubscription>(op, sent) | |
| 198 | 201 | } | |
| 202 | + | Op::Checks(ChecksOp::CreateCommitStatus) => through::<work::CommitStatus>(op, sent), | |
| 203 | + | Op::Checks(ChecksOp::ListCommitStatuses) => through::<Vec<work::CommitStatus>>(op, sent), | |
| 204 | + | Op::Checks(ChecksOp::GetCombinedStatus) => through::<checks::CombinedStatus>(op, sent), | |
| 205 | + | Op::Checks(ChecksOp::CreateCheckRun | ChecksOp::UpdateCheckRun | ChecksOp::GetCheckRun) => { | |
| 206 | + | through::<checks::CommitCheckRun>(op, sent) | |
| 207 | + | } | |
| 208 | + | Op::Checks(ChecksOp::ListCheckRunAnnotations) => through::<Vec<checks::CheckAnnotation>>(op, sent), | |
| 209 | + | Op::Checks(ChecksOp::ListCheckRunsForRef) => through::<checks::CheckRunList>(op, sent), | |
| 210 | + | Op::Checks(ChecksOp::ListCheckSuitesForRef) => through::<checks::CheckSuiteList>(op, sent), | |
| 211 | + | Op::Checks(ChecksOp::GetCheckSuite) => through::<checks::CommitCheckSuite>(op, sent), | |
| 199 | 212 | _ => sent, | |
| 200 | 213 | } | |
| 201 | 214 | } |
| 2 | 2 | ||
| 3 | 3 | use serde_json::{Map, Value}; | |
| 4 | 4 | ||
| 5 | + | use crate::about::AboutOp; | |
| 6 | + | use crate::deployments::DeploymentsOp; | |
| 5 | 7 | use crate::operations::Op; | |
| 8 | + | use crate::checks::ChecksOp; | |
| 6 | 9 | use crate::rules::RulesOp; | |
| 7 | 10 | use crate::security::SecurityOp; | |
| 8 | 11 | ||
| 82 | 85 | route("PUT", "/repos/:owner/:name/issues/:number/subscription", Op::SetThreadSubscription, &[]), | |
| 83 | 86 | route("DELETE", "/repos/:owner/:name/issues/:number/subscription", Op::DeleteThreadSubscription, &[]), | |
| 84 | 87 | route("GET", "/user/subscriptions", Op::ListWatchedRepos, &[]), | |
| 88 | + | // Stars: yours, and who starred a repository. | |
| 89 | + | route("GET", "/user/starred", Op::About(AboutOp::ListStarred), &[]), | |
| 90 | + | route("GET", "/user/starred/:owner/:name", Op::About(AboutOp::CheckStarred), &[]), | |
| 91 | + | route("PUT", "/user/starred/:owner/:name", Op::About(AboutOp::Star), &[]), | |
| 92 | + | route("DELETE", "/user/starred/:owner/:name", Op::About(AboutOp::Unstar), &[]), | |
| 93 | + | route("GET", "/repos/:owner/:name/stargazers", Op::About(AboutOp::ListStargazers), &[("page", "page")]), | |
| 94 | + | // What the default branch says about a repository, kept by commit. | |
| 95 | + | route("GET", "/repos/:owner/:name/languages", Op::About(AboutOp::GetLanguages), &[]), | |
| 96 | + | route("GET", "/repos/:owner/:name/contributors", Op::About(AboutOp::ListContributors), &[]), | |
| 97 | + | route("GET", "/repos/:owner/:name/license", Op::About(AboutOp::GetLicense), &[]), | |
| 98 | + | // Releases: `latest` and `tags/…` before an id. | |
| 99 | + | route("GET", "/repos/:owner/:name/releases", Op::About(AboutOp::ListReleases), &[]), | |
| 100 | + | route("POST", "/repos/:owner/:name/releases", Op::About(AboutOp::CreateRelease), &[]), | |
| 101 | + | route("GET", "/repos/:owner/:name/releases/latest", Op::About(AboutOp::GetLatestRelease), &[]), | |
| 102 | + | route("GET", "/repos/:owner/:name/releases/tags/:tag", Op::About(AboutOp::GetReleaseByTag), &[]), | |
| 103 | + | route("GET", "/repos/:owner/:name/releases/:id", Op::About(AboutOp::GetRelease), &[]), | |
| 104 | + | route("PATCH", "/repos/:owner/:name/releases/:id", Op::About(AboutOp::UpdateRelease), &[]), | |
| 105 | + | route("DELETE", "/repos/:owner/:name/releases/:id", Op::About(AboutOp::DeleteRelease), &[]), | |
| 85 | 106 | // Your pinned projects in a workspace, in your order. | |
| 86 | 107 | route("GET", "/user/pinned_projects/:workspace", Op::ListPinnedProjects, &[]), | |
| 87 | 108 | route("PUT", "/user/pinned_projects/:workspace", Op::ReorderPinnedProjects, &[]), | |
| 90 | 111 | route("PATCH", "/user/repository_invitations/:id", Op::AcceptRepoInvitation, &[]), | |
| 91 | 112 | route("DELETE", "/user/repository_invitations/:id", Op::DeclineRepoInvitation, &[]), | |
| 92 | 113 | route("PATCH", "/workspaces/:workspace", Op::UpdateWorkspace, &[]), | |
| 114 | + | // A workspace's projects: what each is, where it runs, its links. | |
| 115 | + | route("GET", "/workspaces/:workspace/projects", Op::ListProjects, &[]), | |
| 116 | + | route("GET", "/workspaces/:workspace/projects/:project", Op::GetProject, &[]), | |
| 117 | + | route("PATCH", "/workspaces/:workspace/projects/:project", Op::UpdateProject, &[]), | |
| 93 | 118 | route("PUT", "/workspaces/:workspace/base_permission", Op::SetBasePermission, &[]), | |
| 94 | 119 | route( | |
| 95 | 120 | "GET", | |
| 237 | 262 | &[], | |
| 238 | 263 | ), | |
| 239 | 264 | route("GET", "/repos/:owner/:name/check-names", Op::ListCheckNames, &[]), | |
| 265 | + | // Checks: GitHub's addresses for statuses, check runs and check suites. | |
| 266 | + | route("POST", "/repos/:owner/:name/statuses/:sha", Op::Checks(ChecksOp::CreateCommitStatus), &[]), | |
| 267 | + | route("GET", "/repos/:owner/:name/commits/:ref/statuses", Op::Checks(ChecksOp::ListCommitStatuses), &[]), | |
| 268 | + | route("GET", "/repos/:owner/:name/commits/:ref/status", Op::Checks(ChecksOp::GetCombinedStatus), &[]), | |
| 269 | + | route( | |
| 270 | + | "GET", | |
| 271 | + | "/repos/:owner/:name/commits/:ref/check-runs", | |
| 272 | + | Op::Checks(ChecksOp::ListCheckRunsForRef), | |
| 273 | + | &[("check_name", "check_name"), ("status", "status"), ("app", "app"), ("filter", "filter")], | |
| 274 | + | ), | |
| 275 | + | route( | |
| 276 | + | "GET", | |
| 277 | + | "/repos/:owner/:name/commits/:ref/check-suites", | |
| 278 | + | Op::Checks(ChecksOp::ListCheckSuitesForRef), | |
| 279 | + | &[("app", "app"), ("check_name", "check_name")], | |
| 280 | + | ), | |
| 281 | + | route("POST", "/repos/:owner/:name/check-runs", Op::Checks(ChecksOp::CreateCheckRun), &[]), | |
| 282 | + | route("GET", "/repos/:owner/:name/check-runs/:id", Op::Checks(ChecksOp::GetCheckRun), &[]), | |
| 283 | + | route("PATCH", "/repos/:owner/:name/check-runs/:id", Op::Checks(ChecksOp::UpdateCheckRun), &[]), | |
| 284 | + | route("GET", "/repos/:owner/:name/check-runs/:id/annotations", Op::Checks(ChecksOp::ListCheckRunAnnotations), &[]), | |
| 285 | + | route("POST", "/repos/:owner/:name/check-runs/:id/rerequest", Op::Checks(ChecksOp::RerequestCheckRun), &[]), | |
| 286 | + | route("GET", "/repos/:owner/:name/check-suites/:id", Op::Checks(ChecksOp::GetCheckSuite), &[]), | |
| 287 | + | route("POST", "/repos/:owner/:name/check-suites/:id/rerequest", Op::Checks(ChecksOp::RerequestCheckSuite), &[]), | |
| 240 | 288 | // Rulesets: a repository's, a workspace's, the rules of one branch, | |
| 241 | 289 | // and how they judged pushes and merges. | |
| 242 | 290 | route("GET", "/repos/:owner/:name/rulesets", Op::Rules(RulesOp::ListRepoRulesets), &[("include_parents", "include_parents")]), | |
| 262 | 310 | Op::Rules(RulesOp::ListWorkspaceRuleEvaluations), | |
| 263 | 311 | &[("ruleset_id", "ruleset_id"), ("verdict", "verdict"), ("problems_only", "problems_only"), ("before", "before"), ("limit", "limit")], | |
| 264 | 312 | ), | |
| 313 | + | // Deployments wherever they run, their statuses, and environments. | |
| 314 | + | route( | |
| 315 | + | "GET", | |
| 316 | + | "/repos/:owner/:name/deployments", | |
| 317 | + | Op::Deployments(DeploymentsOp::ListDeployments), | |
| 318 | + | &[("environment", "environment"), ("ref", "ref"), ("sha", "sha"), ("task", "task"), ("state", "state"), ("source", "source"), ("creator", "creator"), ("page", "page"), ("per_page", "per_page")], | |
| 319 | + | ), | |
| 320 | + | route("POST", "/repos/:owner/:name/deployments", Op::Deployments(DeploymentsOp::CreateDeployment), &[]), | |
| 321 | + | route("GET", "/repos/:owner/:name/deployments/:id", Op::Deployments(DeploymentsOp::GetDeployment), &[]), | |
| 322 | + | route("GET", "/repos/:owner/:name/deployments/:id/statuses", Op::Deployments(DeploymentsOp::ListDeploymentStatuses), &[]), | |
| 323 | + | route("POST", "/repos/:owner/:name/deployments/:id/statuses", Op::Deployments(DeploymentsOp::CreateDeploymentStatus), &[]), | |
| 324 | + | route("GET", "/repos/:owner/:name/environments", Op::Deployments(DeploymentsOp::ListEnvironments), &[]), | |
| 325 | + | route("GET", "/repos/:owner/:name/environments/:environment", Op::Deployments(DeploymentsOp::GetEnvironment), &[]), | |
| 265 | 326 | route("GET", "/repos/:owner/:name/queue", Op::GetMergeQueue, &[]), | |
| 266 | 327 | route( | |
| 267 | 328 | "POST", | |
| 485 | 546 | &[], | |
| 486 | 547 | ), | |
| 487 | 548 | route( | |
| 549 | + | "PATCH", | |
| 550 | + | "/workspaces/:workspace/integrations/:id", | |
| 551 | + | Op::UpdateIntegration, | |
| 552 | + | &[], | |
| 553 | + | ), | |
| 554 | + | route( | |
| 488 | 555 | "DELETE", | |
| 489 | 556 | "/workspaces/:workspace/integrations/:id", | |
| 490 | 557 | Op::DisconnectIntegration, | |
| 847 | 914 | if let (Some(owner), Some(name)) = (param("owner"), param("name")) { | |
| 848 | 915 | input.insert("repo".to_owned(), Value::String(format!("{owner}/{name}"))); | |
| 849 | 916 | } | |
| 850 | − | for key in ["plan", "id", "workspace", "delivery", "workflow", "job", "setting", "username", "team", "basehead"] { | |
| 851 | − | if let Some(value) = param(key) { | |
| 852 | − | input.insert(key.to_owned(), Value::String(value.to_owned())); | |
| 917 | + | // Every other name the path gives, under that name; the ones below that | |
| 918 | + | // need more (a repository, a number, an encoded name) are set after. | |
| 919 | + | for (key, value) in ¶ms { | |
| 920 | + | if !matches!(*key, "owner" | "name") { | |
| 921 | + | input.insert((*key).to_owned(), Value::String(percent_decoded(value))); | |
| 853 | 922 | } | |
| 854 | 923 | } | |
| 855 | 924 | // A repository of a team's workspace, named by itself. | |
| 861 | 930 | if let Some(branch) = param("branch") { | |
| 862 | 931 | input.insert("branch".to_owned(), Value::String(percent_decoded(branch))); | |
| 863 | 932 | } | |
| 933 | + | // An environment's name may hold slashes and spaces, URL-encoded. | |
| 934 | + | if let Some(environment) = param("environment") { | |
| 935 | + | input.insert("environment".to_owned(), Value::String(percent_decoded(environment))); | |
| 936 | + | } | |
| 864 | 937 | if let Some(label) = param("label") { | |
| 865 | 938 | input.insert("label".to_owned(), Value::String(percent_decoded(label))); | |
| 866 | 939 | } | |
| 898 | 971 | ||
| 899 | 972 | use super::*; | |
| 900 | 973 | ||
| 974 | + | /// Every name a route's path gives reaches the operation: a name left | |
| 975 | + | /// off the lists above is dropped, and the operation answers that it | |
| 976 | + | /// was not given (the project routes were, until this test). | |
| 977 | + | #[test] | |
| 978 | + | fn every_path_parameter_reaches_the_input() { | |
| 979 | + | for route in ROUTES.iter() { | |
| 980 | + | let names: Vec<&str> = route.path.split('/').filter_map(|part| part.strip_prefix(':')).collect(); | |
| 981 | + | if names.is_empty() { | |
| 982 | + | continue; | |
| 983 | + | } | |
| 984 | + | let path: String = route | |
| 985 | + | .path | |
| 986 | + | .split('/') | |
| 987 | + | .map(|part| match part.strip_prefix(':') { | |
| 988 | + | Some("number" | "milestone") => "7".to_owned(), | |
| 989 | + | Some(name) => format!("{name}-x"), | |
| 990 | + | None => part.to_owned(), | |
| 991 | + | }) | |
| 992 | + | .collect::<Vec<_>>() | |
| 993 | + | .join("/"); | |
| 994 | + | let (found, input) = resolve(route.method, &path, &[], json!({})).unwrap(); | |
| 995 | + | // A path two routes could take is checked under the first. | |
| 996 | + | if found.path != route.path { | |
| 997 | + | continue; | |
| 998 | + | } | |
| 999 | + | for name in names { | |
| 1000 | + | let key = match name { | |
| 1001 | + | "owner" | "name" => "repo", | |
| 1002 | + | "repo" if names_has_workspace(route.path) => "repo", | |
| 1003 | + | other => other, | |
| 1004 | + | }; | |
| 1005 | + | assert!( | |
| 1006 | + | input.get(key).is_some_and(|value| !value.is_null()), | |
| 1007 | + | "{} {}: :{name} does not reach the input", | |
| 1008 | + | route.method, | |
| 1009 | + | route.path | |
| 1010 | + | ); | |
| 1011 | + | } | |
| 1012 | + | } | |
| 1013 | + | } | |
| 1014 | + | ||
| 1015 | + | fn names_has_workspace(path: &str) -> bool { | |
| 1016 | + | path.split('/').any(|part| part == ":workspace") | |
| 1017 | + | } | |
| 1018 | + | ||
| 901 | 1019 | #[test] | |
| 902 | 1020 | fn a_path_resolves_to_its_operation_and_input() { | |
| 903 | 1021 | let (route, input) = resolve( | |
| 1063 | 1181 | } | |
| 1064 | 1182 | ||
| 1065 | 1183 | #[test] | |
| 1184 | + | fn checks_are_at_githubs_addresses() { | |
| 1185 | + | let sha = "a".repeat(40); | |
| 1186 | + | let body = json!({ "state": "success", "context": "ci/build" }); | |
| 1187 | + | let (route, input) = resolve("POST", &format!("/repos/acme/web/statuses/{sha}"), &[], body).unwrap(); | |
| 1188 | + | assert_eq!(route.op, Op::Checks(ChecksOp::CreateCommitStatus)); | |
| 1189 | + | assert_eq!(input, json!({ "state": "success", "context": "ci/build", "sha": sha, "repo": "acme/web" })); | |
| 1190 | + | let (route, input) = resolve("GET", "/repos/acme/web/commits/release%2F1.x/status", &[], Value::Null).unwrap(); | |
| 1191 | + | assert_eq!(route.op, Op::Checks(ChecksOp::GetCombinedStatus)); | |
| 1192 | + | assert_eq!(input, json!({ "ref": "release/1.x", "repo": "acme/web" })); | |
| 1193 | + | let query = [("check_name".to_owned(), "lint".to_owned())]; | |
| 1194 | + | let (route, input) = resolve("GET", "/repos/acme/web/commits/main/check-runs", &query, Value::Null).unwrap(); | |
| 1195 | + | assert_eq!(route.op, Op::Checks(ChecksOp::ListCheckRunsForRef)); | |
| 1196 | + | assert_eq!(input, json!({ "check_name": "lint", "ref": "main", "repo": "acme/web" })); | |
| 1197 | + | let (route, input) = resolve("PATCH", "/repos/acme/web/check-runs/cr_1", &[], json!({ "conclusion": "success" })).unwrap(); | |
| 1198 | + | assert_eq!(route.op, Op::Checks(ChecksOp::UpdateCheckRun)); | |
| 1199 | + | assert_eq!(input, json!({ "conclusion": "success", "id": "cr_1", "repo": "acme/web" })); | |
| 1200 | + | let op = |method: &str, path: &str| resolve(method, path, &[], Value::Null).unwrap().0.op; | |
| 1201 | + | assert_eq!(op("POST", "/repos/acme/web/check-runs"), Op::Checks(ChecksOp::CreateCheckRun)); | |
| 1202 | + | assert_eq!(op("GET", "/repos/acme/web/check-runs/cr_1/annotations"), Op::Checks(ChecksOp::ListCheckRunAnnotations)); | |
| 1203 | + | assert_eq!(op("POST", "/repos/acme/web/check-suites/cs_1/rerequest"), Op::Checks(ChecksOp::RerequestCheckSuite)); | |
| 1204 | + | assert_eq!(op("GET", "/repos/acme/web/commits/main/check-suites"), Op::Checks(ChecksOp::ListCheckSuitesForRef)); | |
| 1205 | + | } | |
| 1206 | + | ||
| 1207 | + | #[test] | |
| 1066 | 1208 | fn query_parameters_are_renamed() { | |
| 1067 | 1209 | let query = [ | |
| 1068 | 1210 | ("q".to_owned(), "parser".to_owned()), |
| 18 | 18 | use g1t_contracts::scopes::{Level, NO_SCOPE, TokenAccess, scope_for}; | |
| 19 | 19 | use serde_json::{Map, Value, json}; | |
| 20 | 20 | ||
| 21 | + | use crate::about::AboutOp; | |
| 22 | + | use crate::deployments::DeploymentsOp; | |
| 21 | 23 | use crate::operations::Op; | |
| 24 | + | use crate::checks::ChecksOp; | |
| 22 | 25 | use crate::rules::RulesOp; | |
| 23 | 26 | use crate::security::SecurityOp; | |
| 24 | 27 | ||
| 59 | 62 | Tool { | |
| 60 | 63 | name: "repository", | |
| 61 | 64 | title: "Repositories", | |
| 62 | − | description: "Repositories: find, read and create them, change their settings and rulesets (what may happen to branches and tags, and what a pull request needs to merge), check their CODEOWNERS file, manage their labels and milestones, and see and dismiss their security alerts (secrets and vulnerable dependencies). Name one as \"owner/name\". Deleting, transferring and changing visibility need `confirm`.", | |
| 65 | + | description: "Repositories: find, read and create them, change their settings and rulesets (what may happen to branches and tags, and what a pull request needs to merge), check their CODEOWNERS file, manage their labels and milestones, see and dismiss their security alerts (secrets and vulnerable dependencies), read what their default branch says (languages, contributors, license), star them, and publish releases. Name one as \"owner/name\". Deleting, transferring and changing visibility need `confirm`.", | |
| 63 | 66 | default_action: None, | |
| 64 | 67 | actions: &[ | |
| 65 | 68 | a("list", Op::ListRepos, "Repositories you can see"), | |
| 88 | 91 | a("update_milestone", Op::UpdateMilestone, "Change a milestone's title, description, due date or state"), | |
| 89 | 92 | a("delete_milestone", Op::DeleteMilestone, "Delete a milestone"), | |
| 90 | 93 | a("list_events", Op::ListEvents, "Timeline: pushes, issues, pull requests, comments"), | |
| 94 | + | a("languages", Op::About(AboutOp::GetLanguages), "Its languages by bytes, with colors and percentages"), | |
| 95 | + | a("contributors", Op::About(AboutOp::ListContributors), "Who made it: commits per person, agent and author, by week"), | |
| 96 | + | a("license", Op::About(AboutOp::GetLicense), "The license its LICENSE file holds"), | |
| 97 | + | a("stargazers", Op::About(AboutOp::ListStargazers), "Who starred it"), | |
| 98 | + | a("starred", Op::About(AboutOp::CheckStarred), "Whether you starred it, and how many have"), | |
| 99 | + | a("star", Op::About(AboutOp::Star), "Star it"), | |
| 100 | + | a("unstar", Op::About(AboutOp::Unstar), "Take your star back"), | |
| 101 | + | a("list_starred", Op::About(AboutOp::ListStarred), "Repositories you starred"), | |
| 102 | + | a("list_releases", Op::About(AboutOp::ListReleases), "Releases, newest first"), | |
| 103 | + | a("latest_release", Op::About(AboutOp::GetLatestRelease), "The latest release"), | |
| 104 | + | a("get_release", Op::About(AboutOp::GetRelease), "One release by id"), | |
| 105 | + | a("get_release_by_tag", Op::About(AboutOp::GetReleaseByTag), "The release of a tag"), | |
| 106 | + | a("create_release", Op::About(AboutOp::CreateRelease), "Publish a release of a tag, making the tag if needed"), | |
| 107 | + | a("update_release", Op::About(AboutOp::UpdateRelease), "Change a release's title, notes, draft or prerelease"), | |
| 108 | + | a("delete_release", Op::About(AboutOp::DeleteRelease), "Delete a release; its tag stays"), | |
| 91 | 109 | a("rename_branch", Op::RenameBranch, "Rename a branch"), | |
| 92 | 110 | a("rename", Op::RenameRepo, "Rename it; old addresses redirect"), | |
| 93 | 111 | a("transfer", Op::TransferRepo, "Move it to another workspace you own"), | |
| 182 | 200 | Tool { | |
| 183 | 201 | name: "workflow", | |
| 184 | 202 | title: "Workflows", | |
| 185 | − | description: "GitHub Actions workflows from .g1t/workflows: their runs, jobs and logs, and running, cancelling or rerunning them. Also the self-hosted runners they run on: a workspace's (`workspace`) or a repository's own (`repo`), their groups, and where agent work runs.", | |
| 203 | + | description: "GitHub Actions workflows from .g1t/workflows: their runs, jobs and logs, and running, cancelling or rerunning them. Deployments wherever they run (reported from any CI, made by jobs with an `environment:`, or built on g1t.page), their statuses and environments, and reporting your own. Checks on commits: statuses, check runs (a g1t Actions job is one) and check suites, to read where a commit stands or report on it from CI or an integration. Also the self-hosted runners they run on: a workspace's (`workspace`) or a repository's own (`repo`), their groups, and where agent work runs.", | |
| 186 | 204 | default_action: None, | |
| 187 | 205 | actions: &[ | |
| 188 | 206 | a("list", Op::ListWorkflows, "Workflows on the default branch"), | |
| 193 | 211 | a("cancel", Op::CancelWorkflowRun, "Cancel a run"), | |
| 194 | 212 | a("rerun", Op::RerunWorkflowRun, "Run a finished run again"), | |
| 195 | 213 | a("update", Op::UpdateWorkflow, "Turn a workflow on or off"), | |
| 214 | + | a("combined_status", Op::Checks(ChecksOp::GetCombinedStatus), "A commit's statuses and the state they add up to"), | |
| 215 | + | a("list_statuses", Op::Checks(ChecksOp::ListCommitStatuses), "A commit's statuses, newest first"), | |
| 216 | + | a("set_status", Op::Checks(ChecksOp::CreateCommitStatus), "Set a status on a commit"), | |
| 217 | + | a("list_check_runs", Op::Checks(ChecksOp::ListCheckRunsForRef), "A commit's check runs, g1t Actions jobs included"), | |
| 218 | + | a("get_check_run", Op::Checks(ChecksOp::GetCheckRun), "One check run with its report"), | |
| 219 | + | a("check_run_annotations", Op::Checks(ChecksOp::ListCheckRunAnnotations), "What a check run says about lines of files"), | |
| 220 | + | a("create_check_run", Op::Checks(ChecksOp::CreateCheckRun), "Report a check run on a commit"), | |
| 221 | + | a("update_check_run", Op::Checks(ChecksOp::UpdateCheckRun), "Move a check run on, complete it, add annotations"), | |
| 222 | + | a("rerequest_check_run", Op::Checks(ChecksOp::RerequestCheckRun), "Ask for a check run to run again"), | |
| 223 | + | a("list_check_suites", Op::Checks(ChecksOp::ListCheckSuitesForRef), "A commit's check suites, one per reporter or workflow run"), | |
| 224 | + | a("get_check_suite", Op::Checks(ChecksOp::GetCheckSuite), "One check suite"), | |
| 225 | + | a("rerequest_check_suite", Op::Checks(ChecksOp::RerequestCheckSuite), "Ask for a check suite to run again"), | |
| 226 | + | a("list_deployments", Op::Deployments(DeploymentsOp::ListDeployments), "Deployments wherever they run, newest first, filtered"), | |
| 227 | + | a("get_deployment", Op::Deployments(DeploymentsOp::GetDeployment), "One deployment with every status it has had"), | |
| 228 | + | a("create_deployment", Op::Deployments(DeploymentsOp::CreateDeployment), "Report a deployment of a ref to an environment"), | |
| 229 | + | a("deployment_statuses", Op::Deployments(DeploymentsOp::ListDeploymentStatuses), "A deployment's statuses, newest first"), | |
| 230 | + | a("create_deployment_status", Op::Deployments(DeploymentsOp::CreateDeploymentStatus), "Report where a deployment is: in_progress, success, failure"), | |
| 231 | + | a("list_environments", Op::Deployments(DeploymentsOp::ListEnvironments), "Environments with their current and latest deployments"), | |
| 232 | + | a("get_environment", Op::Deployments(DeploymentsOp::GetEnvironment), "One environment by name"), | |
| 196 | 233 | a("list_runners", Op::ListRunners, "Self-hosted runners, with status, labels and what each is doing"), | |
| 197 | 234 | a("create_runner_token", Op::CreateRunnerRegistrationToken, "A one-hour token for g1t-runner register"), | |
| 198 | 235 | a("remove_runner", Op::RemoveRunner, "Remove a self-hosted runner"), | |
| 275 | 312 | Tool { | |
| 276 | 313 | name: "workspace", | |
| 277 | 314 | title: "Workspaces", | |
| 278 | − | description: "Workspaces own repositories (g1t.sh/{workspace}/{repo}): create, update or delete one, invite members, connect integrations and model providers, set rulesets that hold across its repositories, and keep your own pinned projects at the top of its sidebar.", | |
| 315 | + | description: "Workspaces own repositories (g1t.sh/{workspace}/{repo}): create, update or delete one, invite members, connect integrations and model providers, set rulesets that hold across its repositories, read and change its projects (what each is, where it runs, its links), and keep your own pinned projects at the top of its sidebar.", | |
| 279 | 316 | default_action: None, | |
| 280 | 317 | actions: &[ | |
| 281 | 318 | a("get", Op::GetWorkspace, "A workspace's details and settings"), | |
| 287 | 324 | a("revoke_invite", Op::RevokeWorkspaceInvite, "Revoke a pending invite"), | |
| 288 | 325 | a("list_integrations", Op::ListIntegrations, "Model providers, alert sources, trackers"), | |
| 289 | 326 | a("connect_integration", Op::ConnectIntegration, "Connect one"), | |
| 327 | + | a("update_integration", Op::UpdateIntegration, "Change one: rotate its key, choose its AI Gateway models"), | |
| 290 | 328 | a("disconnect_integration", Op::DisconnectIntegration, "Remove one"), | |
| 291 | 329 | a("test_integration", Op::TestIntegration, "Check its credentials"), | |
| 292 | 330 | a("get_model_routes", Op::GetModelRoutes, "Where each kind of work's model requests go"), | |
| 293 | 331 | a("set_model_routes", Op::SetModelRoutes, "Replace them"), | |
| 332 | + | a("list_projects", Op::ListProjects, "Its projects you can see: what each is, where it runs, its links"), | |
| 333 | + | a("get_project", Op::GetProject, "One project"), | |
| 334 | + | a("update_project", Op::UpdateProject, "Change a project's name, description, kind, where it runs or its links"), | |
| 294 | 335 | a("list_pinned_projects", Op::ListPinnedProjects, "Your pinned projects in it, in your order"), | |
| 295 | 336 | a("pin_project", Op::PinProject, "Pin a project, at a position or the end"), | |
| 296 | 337 | a("unpin_project", Op::UnpinProject, "Unpin a project"), | |
| 417 | 458 | | Op::RemoveEmail | |
| 418 | 459 | | Op::RemoveCollaborator | |
| 419 | 460 | | Op::DisconnectIntegration | |
| 461 | + | | Op::UpdateIntegration | |
| 420 | 462 | | Op::DeleteWebhook | |
| 421 | 463 | | Op::DeleteActionsSecret | |
| 422 | 464 | | Op::DeleteActionsVariable |
| 74 | 74 | items: [ | |
| 75 | 75 | { label: 'Projects', slug: 'guides/projects' }, | |
| 76 | 76 | { label: 'Deployments', slug: 'guides/deployments' }, | |
| 77 | + | { label: 'Deployments API', slug: 'guides/deployments-api' }, | |
| 77 | 78 | { label: 'Packages', slug: 'guides/packages' }, | |
| 78 | 79 | { label: 'Container images', slug: 'guides/containers' }, | |
| 79 | 80 | { label: 'npm', slug: 'guides/npm' }, | |
| 125 | 126 | label: 'Landing changes', | |
| 126 | 127 | items: [ | |
| 127 | 128 | { label: 'Pull requests and checks', slug: 'guides/pull-requests' }, | |
| 129 | + | { label: 'Checks', slug: 'guides/checks' }, | |
| 128 | 130 | { label: 'Pull requests into other branches', slug: 'guides/base-branches' }, | |
| 129 | 131 | { label: 'Labels', slug: 'guides/labels' }, | |
| 130 | 132 | { label: 'Milestones', slug: 'guides/milestones' }, | |
| 144 | 146 | { label: 'Access and roles', slug: 'guides/access-and-roles' }, | |
| 145 | 147 | { label: 'Teams', slug: 'guides/teams' }, | |
| 146 | 148 | { label: 'Managing a repository', slug: 'guides/managing-repositories' }, | |
| 149 | + | { label: 'Releases', slug: 'guides/releases' }, | |
| 147 | 150 | { label: 'Transferring a repository', slug: 'guides/transferring-repositories' }, | |
| 148 | 151 | { label: 'Audit log', slug: 'guides/audit-log' }, | |
| 149 | 152 | { label: 'Usage and billing', slug: 'guides/usage-and-billing' }, |
| 42 | 42 | | `GITHUB_OUTPUT`, `GITHUB_ENV`, `GITHUB_PATH`, `GITHUB_STATE`, `GITHUB_STEP_SUMMARY` | The same. | | |
| 43 | 43 | | `::error::`, `::warning::`, `::notice::`, `::group::`, `::add-mask::` | The same: errors and warnings become annotations on the run. | | |
| 44 | 44 | | `secrets.*`, `vars.*`, `secrets.GITHUB_TOKEN` | The same. `secrets.G1T_TOKEN` is the workspace's own token for the run; `GITHUB_TOKEN` is its alias. | | |
| 45 | − | | `environment:` on a job | The job reads each key's row for that environment, as GitHub's environment secrets work. | | |
| 45 | + | | `environment:` on a job | The job reads each key's row for that environment, as GitHub's environment secrets work, and the run records a [deployment](/guides/deployments-api/#deployments-from-g1t-actions) to it. `url` gives the deployment its address; `deployment: false` reads the environment's values without making one. | | |
| 46 | 46 | | `actions/upload-artifact`, `actions/download-artifact` | Kept with the run for 14 days, passed between its jobs, and downloadable from the run's page. Up to 60 MB each. | | |
| 47 | 47 | | `actions/cache`, `actions/cache/restore`, `actions/cache/save` | Kept per repository, found by `key` or the newest under a `restore-keys` prefix. `path` takes globs and `!` exclusions. Up to 2 GiB each; see [the cache](#the-cache). | | |
| 48 | 48 | ||
| 66 | 66 | - **Environments' protection rules** (required reviewers, wait timers, | |
| 67 | 67 | branch limits). A job with `environment:` gets that environment's | |
| 68 | 68 | [values](/guides/secrets-and-variables/#a-value-per-environment), and runs | |
| 69 | − | without waiting. | |
| 69 | + | without waiting. It still records a | |
| 70 | + | [deployment](/guides/deployments-api/#deployments-from-g1t-actions) | |
| 71 | + | unless it says `deployment: false`. | |
| 70 | 72 | ||
| 71 | 73 | Why each of these is missing, and what to use instead, is on | |
| 72 | 74 | [What g1t can't do yet](/about/limitations/#actions-and-runners). | |
| 207 | 209 | `pull_request` runs on every pull request's head, whoever opened it, a | |
| 208 | 210 | person or an agent, and its runs report a check named after the workflow: | |
| 209 | 211 | a workflow with `name: CI` reports `CI`, with the status context | |
| 210 | − | `CI / pull_request` (the workflow's name and the event). | |
| 212 | + | `CI / pull_request` (the workflow's name and the event). Each of its jobs | |
| 213 | + | is a [check run](/guides/checks/) on the commit, shown as | |
| 214 | + | `CI / test (pull_request)` beside it wherever it appears. | |
| 211 | 215 | ||
| 212 | 216 | - **Which checks a merge needs** is up to the [rules](/guides/rules/) of the branch it merges into, | |
| 213 | 217 | their [required status checks](/guides/pull-requests/#required-status-checks), | |
| 264 | 268 | Secrets are read as `${{ secrets.KEY }}` and config as `${{ vars.KEY }}`, | |
| 265 | 269 | from the rows under **Settings → Secrets and variables** that are | |
| 266 | 270 | available to Workflows. A job with `environment: production` reads each | |
| 267 | − | key's Production row; other jobs read the rows for all environments. See | |
| 271 | + | key's Production row; other jobs read the rows for all environments. A | |
| 272 | + | job with an `environment:` also makes a deployment to it; see | |
| 273 | + | [deployments from g1t Actions](/guides/deployments-api/#deployments-from-g1t-actions). See | |
| 268 | 274 | [Secrets and variables](/guides/secrets-and-variables/) for how rows, | |
| 269 | 275 | environments and the workspace's rows work. | |
| 270 | 276 |
| 1 | 1 | --- | |
| 2 | 2 | title: AI Gateway | |
| 3 | − | description: Send your own code's model requests through g1t with a workspace access token, paid from AI credit at the model's price, with a log of every request. | |
| 3 | + | description: Send your own code's model requests through g1t in Anthropic's or OpenAI's format, with a workspace access token, to Claude, open models or your own providers, with a log of every request. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | − | The AI Gateway takes model requests from your own code, in Anthropic's | |
| 7 | − | Messages format, and sends them to the model. You point an Anthropic SDK, | |
| 8 | − | Claude Code or anything else that speaks that format at one base URL and | |
| 9 | − | give it a workspace access token as its API key: | |
| 6 | + | The AI Gateway takes model requests from your own code and sends them to | |
| 7 | + | the model. It speaks two formats, and any model works in either: | |
| 8 | + | ||
| 9 | + | | Format | Base URL | For | | |
| 10 | + | | --- | --- | --- | | |
| 11 | + | | Anthropic's Messages API | `https://models.g1t.sh/anthropic` | Anthropic's SDKs, Claude Code, and anything else that speaks that format | | |
| 12 | + | | OpenAI's Chat Completions API | `https://models.g1t.sh/openai/v1` | OpenAI's SDKs, and any tool that lets you set an OpenAI-compatible base URL | | |
| 10 | 13 | ||
| 11 | − | | | | | |
| 12 | − | | --- | --- | | |
| 13 | − | | Base URL | `https://models.g1t.sh/anthropic` | | |
| 14 | − | | API key | A workspace access token (`g1t_…`) with the `models:write` scope | | |
| 14 | + | In both, the API key is a workspace access token (`g1t_…`) with the | |
| 15 | + | `models:write` scope. | |
| 15 | 16 | ||
| 16 | − | Each request is charged to the workspace at the model's price and paid from | |
| 17 | − | the plan's included usage and [AI credit](/guides/usage-and-billing/#ai-credit). | |
| 18 | − | While the gateway is in beta there is no markup. If the workspace has | |
| 19 | − | connected its own Anthropic key, requests go there instead and cost | |
| 20 | − | nothing on g1t. Every request is logged with its model, tokens, cost and | |
| 21 | − | status. Prompts and answers are never kept. | |
| 17 | + | The model a request names decides where it goes: | |
| 18 | + | ||
| 19 | + | - **g1t's models**: Claude on Anthropic, and open models on Workers AI. | |
| 20 | + | Each request is charged to the workspace at the model's price and paid | |
| 21 | + | from the plan's included usage and | |
| 22 | + | [AI credit](/guides/usage-and-billing/#ai-credit). While the gateway is in | |
| 23 | + | beta there is no markup. | |
| 24 | + | - **Your own providers**: an Anthropic key, an OpenAI key, or any endpoint | |
| 25 | + | that speaks either API, connected under | |
| 26 | + | [Integrations](/guides/models/#connect-a-provider). You choose which models | |
| 27 | + | go to each. Those requests are counted and never charged on g1t. | |
| 28 | + | ||
| 29 | + | Every request is logged with its format, who served it, its model, tokens, | |
| 30 | + | cost and status. Prompts and answers are never kept. | |
| 22 | 31 | ||
| 23 | 32 | ## Before you start | |
| 24 | 33 | ||
| 26 | 35 | ||
| 27 | 36 | - **The workspace on the g1t plan**, with AI credit or this month's | |
| 28 | 37 | included usage left. See [AI credit](/guides/usage-and-billing/#ai-credit). | |
| 29 | − | - **The workspace's own Anthropic key**, connected under | |
| 30 | − | [Integrations](/guides/models/#connect-a-provider). Then the plan is not | |
| 31 | − | needed and nothing is charged. | |
| 38 | + | - **One of the workspace's own model providers**, connected under | |
| 39 | + | [Integrations](#your-own-providers). Requests for the models it takes need | |
| 40 | + | no plan and cost nothing on g1t. | |
| 32 | 41 | ||
| 33 | 42 | ## Make a token | |
| 34 | 43 | ||
| 51 | 60 | ||
| 52 | 61 | ## Send a request | |
| 53 | 62 | ||
| 54 | − | The gateway answers the same routes as Anthropic's API, below the base URL: | |
| 63 | + | The token goes in `x-api-key` or in `Authorization: Bearer`, in either | |
| 64 | + | format. The gateway answers these routes: | |
| 55 | 65 | ||
| 56 | 66 | | Route | What it does | | |
| 57 | 67 | | --- | --- | | |
| 58 | 68 | | `POST /anthropic/v1/messages` | A message, streamed (`"stream": true`) or whole. Logged and charged. | | |
| 59 | − | | `POST /anthropic/v1/messages/count_tokens` | Counts a request's input tokens. Not logged, and costs nothing. | | |
| 69 | + | | `POST /anthropic/v1/messages/count_tokens` | Counts a request's input tokens. Not logged, and costs nothing. For a model that does not speak Anthropic's API, an estimate. | | |
| 70 | + | | `POST /openai/v1/chat/completions` | A chat completion, streamed or whole. Logged and charged. | | |
| 71 | + | | `POST /openai/v1/embeddings` | Embeddings, from an embeddings model. Logged and charged by their input tokens. | | |
| 72 | + | | `GET /openai/v1/models` | The models this workspace can use, with g1t's prices. | | |
| 60 | 73 | ||
| 61 | − | The token goes in `x-api-key`, or in `Authorization: Bearer`. Request and | |
| 62 | − | answer bodies are Anthropic's, unchanged, and so are streamed events. | |
| 74 | + | ### Anthropic's format | |
| 63 | 75 | ||
| 64 | 76 | With curl: | |
| 65 | 77 | ||
| 69 | 81 | -H "anthropic-version: 2023-06-01" \ | |
| 70 | 82 | -H "content-type: application/json" \ | |
| 71 | 83 | -d '{ | |
| 72 | − | "model": "claude-sonnet-5-5", | |
| 84 | + | "model": "claude-haiku-5-5", | |
| 73 | 85 | "max_tokens": 1024, | |
| 74 | 86 | "messages": [{ "role": "user", "content": "Write a commit message for: fix the login redirect" }] | |
| 75 | 87 | }' | |
| 86 | 98 | }); | |
| 87 | 99 | ||
| 88 | 100 | const message = await client.messages.create({ | |
| 89 | − | model: "claude-sonnet-5-5", | |
| 101 | + | model: "claude-haiku-5-5", | |
| 90 | 102 | max_tokens: 1024, | |
| 91 | 103 | messages: [{ role: "user", content: "Write a commit message for: fix the login redirect" }], | |
| 92 | 104 | }); | |
| 105 | 117 | ) | |
| 106 | 118 | ||
| 107 | 119 | message = client.messages.create( | |
| 108 | − | model="claude-sonnet-5-5", | |
| 120 | + | model="claude-haiku-5-5", | |
| 109 | 121 | max_tokens=1024, | |
| 110 | 122 | messages=[{"role": "user", "content": "Write a commit message for: fix the login redirect"}], | |
| 111 | 123 | ) | |
| 112 | 124 | ``` | |
| 113 | 125 | ||
| 114 | − | ### Claude Code | |
| 126 | + | Request and answer bodies are Anthropic's, and so are streamed events. To | |
| 127 | + | an open model, such as `workers-ai/@cf/openai/gpt-oss-120b`, the request is | |
| 128 | + | translated: messages, system prompt, images, tools and tool results, | |
| 129 | + | `tool_choice`, stop sequences, `output_config.effort` (as | |
| 130 | + | `reasoning_effort`, `xhigh` and `max` as `high`) and `output_config.format` | |
| 131 | + | (as a JSON schema). The answer comes back as an Anthropic message, tool | |
| 132 | + | calls included. Server tools have no counterpart there and are refused on | |
| 133 | + | g1t's models. | |
| 134 | + | ||
| 135 | + | ### OpenAI's format | |
| 136 | + | ||
| 137 | + | With curl: | |
| 138 | + | ||
| 139 | + | ```sh | |
| 140 | + | curl https://models.g1t.sh/openai/v1/chat/completions \ | |
| 141 | + | -H "Authorization: Bearer $G1T_TOKEN" \ | |
| 142 | + | -H "content-type: application/json" \ | |
| 143 | + | -d '{ | |
| 144 | + | "model": "anthropic/claude-haiku-5-5", | |
| 145 | + | "messages": [{ "role": "user", "content": "Write a commit message for: fix the login redirect" }] | |
| 146 | + | }' | |
| 147 | + | ``` | |
| 148 | + | ||
| 149 | + | With OpenAI's TypeScript SDK: | |
| 150 | + | ||
| 151 | + | ```ts | |
| 152 | + | import OpenAI from "openai"; | |
| 153 | + | ||
| 154 | + | const client = new OpenAI({ | |
| 155 | + | baseURL: "https://models.g1t.sh/openai/v1", | |
| 156 | + | apiKey: process.env.G1T_TOKEN, | |
| 157 | + | }); | |
| 158 | + | ||
| 159 | + | const completion = await client.chat.completions.create({ | |
| 160 | + | model: "workers-ai/@cf/openai/gpt-oss-120b", | |
| 161 | + | messages: [{ role: "user", content: "Label this issue: the login page is blank on Safari" }], | |
| 162 | + | }); | |
| 163 | + | ``` | |
| 164 | + | ||
| 165 | + | With OpenAI's Python SDK: | |
| 166 | + | ||
| 167 | + | ```python | |
| 168 | + | import os | |
| 169 | + | ||
| 170 | + | from openai import OpenAI | |
| 171 | + | ||
| 172 | + | client = OpenAI( | |
| 173 | + | base_url="https://models.g1t.sh/openai/v1", | |
| 174 | + | api_key=os.environ["G1T_TOKEN"], | |
| 175 | + | ) | |
| 176 | + | ||
| 177 | + | completion = client.chat.completions.create( | |
| 178 | + | model="anthropic/claude-sonnet-5-5", | |
| 179 | + | messages=[{"role": "user", "content": "Summarize this diff in one sentence."}], | |
| 180 | + | stream=True, | |
| 181 | + | stream_options={"include_usage": True}, | |
| 182 | + | ) | |
| 183 | + | for chunk in completion: | |
| 184 | + | print(chunk.choices[0].delta.content or "" if chunk.choices else "", end="") | |
| 185 | + | ``` | |
| 186 | + | ||
| 187 | + | Embeddings: | |
| 188 | + | ||
| 189 | + | ```sh | |
| 190 | + | curl https://models.g1t.sh/openai/v1/embeddings \ | |
| 191 | + | -H "Authorization: Bearer $G1T_TOKEN" \ | |
| 192 | + | -H "content-type: application/json" \ | |
| 193 | + | -d '{ "model": "workers-ai/@cf/baai/bge-m3", "input": ["fix the login redirect"] }' | |
| 194 | + | ``` | |
| 195 | + | ||
| 196 | + | What OpenAI's format supports, to any model: | |
| 197 | + | ||
| 198 | + | | In the request | | | |
| 199 | + | | --- | --- | | |
| 200 | + | | `messages` | `system`, `developer`, `user`, `assistant` and `tool` messages. User content can be text, `image_url` (a URL or a `data:` URL) and, to Claude, `file` with `file_data` (a PDF as a `data:` URL). | | |
| 201 | + | | `tools`, `tool_choice`, `parallel_tool_calls` | Function tools. To Claude, `required` is `any` and a named function is that tool. | | |
| 202 | + | | `stream`, `stream_options.include_usage` | Server-sent chunks, ending with `data: [DONE]`. With `include_usage`, a last chunk carries the usage. | | |
| 203 | + | | `max_tokens`, `max_completion_tokens` | To Claude, 8,192 when neither is given. | | |
| 204 | + | | `temperature`, `top_p`, `stop`, `user` | As given. Some models refuse sampling settings. | | |
| 205 | + | | `reasoning_effort` | To Claude, `output_config.effort`; `minimal` and `none` are `low`. | | |
| 206 | + | | `response_format` | `json_schema` is a JSON schema the answer follows. `json_object` asks Claude for one JSON object. | | |
| 207 | + | | `thinking` | To Claude, passed as it is, for a caller that sets Anthropic's thinking. | | |
| 208 | + | ||
| 209 | + | To Claude, the answer's `usage` counts cached tokens in `prompt_tokens`, | |
| 210 | + | with `prompt_tokens_details.cached_tokens`; Claude's thinking comes back as | |
| 211 | + | `reasoning_content`. Claude's thinking blocks must go back with the tool | |
| 212 | + | calls they led to, so the gateway carries them in the first tool call's | |
| 213 | + | `id`: send the `id` back unchanged in the assistant message and the `tool` | |
| 214 | + | message, as OpenAI's SDKs do. `n` above 1 is refused for Claude. | |
| 215 | + | ||
| 216 | + | ### Claude Code and other tools | |
| 115 | 217 | ||
| 116 | − | Set two environment variables before you start it: | |
| 218 | + | Claude Code speaks Anthropic's format. Set two environment variables | |
| 219 | + | before you start it: | |
| 117 | 220 | ||
| 118 | 221 | ```sh | |
| 119 | 222 | export ANTHROPIC_BASE_URL=https://models.g1t.sh/anthropic | |
| 123 | 226 | ||
| 124 | 227 | Claude Code's own small requests go to Claude Haiku 4.5, which the gateway | |
| 125 | 228 | offers. To choose the main model, also set `ANTHROPIC_MODEL`, such as | |
| 126 | − | `claude-opus-5-5`. | |
| 229 | + | `claude-opus-5-5`, or `ANTHROPIC_SMALL_FAST_MODEL`, such as | |
| 230 | + | `claude-haiku-5-5`. | |
| 127 | 231 | ||
| 232 | + | Any other tool that lets you set an OpenAI-compatible base URL and key | |
| 233 | + | works the same way: give it `https://models.g1t.sh/openai/v1` and the | |
| 234 | + | token, and a model id from [the models list](#models). | |
| 235 | + | ||
| 128 | 236 | ## Models | |
| 129 | 237 | ||
| 130 | − | On g1t's models the gateway offers these. Prices are per million tokens, | |
| 131 | − | the provider's list price; cache writes are five-minute ones. | |
| 238 | + | ### Model ids | |
| 132 | 239 | ||
| 133 | − | | Model | `model` | Input | Output | Cache reads | Cache writes | | |
| 134 | − | | --- | --- | --- | --- | --- | --- | | |
| 135 | − | | Claude Opus 5.5 | `claude-opus-5-5` | $4.00 | $20.00 | $0.20 | $5.00 | | |
| 136 | − | | Claude Sonnet 5.5 | `claude-sonnet-5-5` | $2.00 | $10.00 | $0.20 | $2.50 | | |
| 137 | − | | Claude Haiku 4.5 | `claude-haiku-4-5`, `claude-haiku-4-5-20251001` | $1.00 | $5.00 | $0.10 | $1.25 | | |
| 240 | + | A request names a model: | |
| 138 | 241 | ||
| 139 | − | A request for any other model is refused with `400` before it reaches the | |
| 140 | − | provider, and the error names the models offered. On the workspace's own | |
| 141 | − | key, a request can name any model that key can use. | |
| 242 | + | | Id | Goes to | | |
| 243 | + | | --- | --- | | |
| 244 | + | | `anthropic/claude-sonnet-5-5` | Claude on g1t's account, in either format | | |
| 245 | + | | `claude-sonnet-5-5` | The same, as Anthropic's API names it | | |
| 246 | + | | `workers-ai/@cf/openai/gpt-oss-120b` | An open model on g1t's account, in either format | | |
| 247 | + | | `@cf/openai/gpt-oss-120b` | The same | | |
| 248 | + | | Any id one of your own providers takes, such as `gpt-5.5` or `ollama/llama3.3` | That provider, with its key. See [your own providers](#your-own-providers). | | |
| 142 | 249 | ||
| 143 | − | On g1t's models a request is charged only by its tokens, so what the | |
| 144 | − | provider bills some other way is refused with `400` for now: | |
| 250 | + | Your own providers come first: when one of them takes a model, the request | |
| 251 | + | goes there, even one that names a model g1t offers. An Anthropic key takes | |
| 252 | + | `claude-*` unless you choose otherwise, so with one connected, Claude goes to | |
| 253 | + | your key. | |
| 145 | 254 | ||
| 255 | + | `GET /openai/v1/models` lists what the workspace can use: its own | |
| 256 | + | providers' models first, then g1t's, cheapest Claude first. Each has | |
| 257 | + | `billed_to` (`workspace` or `g1t`), `connection` (your provider's name) and, | |
| 258 | + | on g1t's models, `pricing` in dollars per million tokens: | |
| 259 | + | ||
| 260 | + | ```json | |
| 261 | + | { | |
| 262 | + | "object": "list", | |
| 263 | + | "data": [ | |
| 264 | + | { | |
| 265 | + | "id": "anthropic/claude-haiku-5-5", | |
| 266 | + | "object": "model", | |
| 267 | + | "created": 0, | |
| 268 | + | "owned_by": "anthropic", | |
| 269 | + | "name": "Claude Haiku 5.5", | |
| 270 | + | "kind": "chat", | |
| 271 | + | "billed_to": "g1t", | |
| 272 | + | "connection": null, | |
| 273 | + | "pricing": { | |
| 274 | + | "currency": "usd", | |
| 275 | + | "input": 0.1, | |
| 276 | + | "output": 0.5, | |
| 277 | + | "cache_read": 0.01, | |
| 278 | + | "cache_write": 0.125, | |
| 279 | + | "cache_write_1h": 0.2, | |
| 280 | + | "long_prompt": { "above_tokens": 100000, "input": 0.5, "output": 2.5, "cache_read": 0.05, "cache_write": 0.625, "cache_write_1h": 1 } | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | ] | |
| 284 | + | } | |
| 285 | + | ``` | |
| 286 | + | ||
| 287 | + | ### Claude, on Anthropic | |
| 288 | + | ||
| 289 | + | Prices are per million tokens, Anthropic's list price. Cache writes are | |
| 290 | + | five-minute ones; one-hour cache writes (`"ttl": "1h"`) cost twice the | |
| 291 | + | input price. | |
| 292 | + | ||
| 293 | + | | Model | `model` | Input | Output | Cache reads | Cache writes | One-hour cache writes | | |
| 294 | + | | --- | --- | --- | --- | --- | --- | --- | | |
| 295 | + | | Claude Haiku 5.5 | `claude-haiku-5-5` | $0.10 | $0.50 | $0.01 | $0.125 | $0.20 | | |
| 296 | + | | Claude Haiku 5.5, prompts over 100,000 tokens | `claude-haiku-5-5` | $0.50 | $2.50 | $0.05 | $0.625 | $1.00 | | |
| 297 | + | | Claude Sonnet 5.5 | `claude-sonnet-5-5` | $2.00 | $10.00 | $0.10 | $2.50 | $4.00 | | |
| 298 | + | | Claude Opus 5.5 | `claude-opus-5-5` | $4.00 | $20.00 | $0.20 | $5.00 | $8.00 | | |
| 299 | + | | Claude Haiku 4.5 | `claude-haiku-4-5`, `claude-haiku-4-5-20251001` | $1.00 | $5.00 | $0.10 | $1.25 | $2.00 | | |
| 300 | + | ||
| 301 | + | Claude Haiku 5.5 is the cheapest Claude and the one to start with. It is | |
| 302 | + | priced by the prompt's length: a request whose prompt (its input, cache | |
| 303 | + | read and cache write tokens) is longer than 100,000 tokens is charged | |
| 304 | + | entirely at the higher prices. It takes effort, like Opus: set | |
| 305 | + | `output_config.effort` in Anthropic's format, or `reasoning_effort` in | |
| 306 | + | OpenAI's. | |
| 307 | + | ||
| 308 | + | ### Open models, on Workers AI | |
| 309 | + | ||
| 310 | + | Prices are per million tokens, Cloudflare's list price. Workers AI has no | |
| 311 | + | prompt-cache price: cached tokens, where a model reports them, cost what | |
| 312 | + | input does. | |
| 313 | + | ||
| 314 | + | | Model | `model` | Input | Output | | |
| 315 | + | | --- | --- | --- | --- | | |
| 316 | + | | GLM-5.3 Flash | `workers-ai/@cf/zai-org/glm-5.3-flash` | $0.15 | $0.50 | | |
| 317 | + | | gpt-oss-20b | `workers-ai/@cf/openai/gpt-oss-20b` | $0.20 | $0.30 | | |
| 318 | + | | Llama 4 Scout | `workers-ai/@cf/meta/llama-4-scout-17b-16e-instruct` | $0.27 | $0.85 | | |
| 319 | + | | gpt-oss-120b | `workers-ai/@cf/openai/gpt-oss-120b` | $0.35 | $0.75 | | |
| 320 | + | | Mistral Small 3.1 | `workers-ai/@cf/mistralai/mistral-small-3.1-24b-instruct` | $0.351 | $0.555 | | |
| 321 | + | | DeepSeek V4 Flash | `workers-ai/@cf/deepseek-ai/deepseek-v4-flash-0731` | $0.44 | $1.32 | | |
| 322 | + | | Nemotron 3 120B | `workers-ai/@cf/nvidia/nemotron-3-120b-a12b` | $0.50 | $1.50 | | |
| 323 | + | | Kimi K2.6 | `workers-ai/@cf/moonshotai/kimi-k2.6` | $0.95 | $4.00 | | |
| 324 | + | | DeepSeek V4 Pro | `workers-ai/@cf/deepseek-ai/deepseek-v4-pro-0813` | $1.32 | $3.96 | | |
| 325 | + | | GLM-5.3 | `workers-ai/@cf/zai-org/glm-5.3` | $1.40 | $4.40 | | |
| 326 | + | ||
| 327 | + | Embeddings, through `POST /openai/v1/embeddings` only: | |
| 328 | + | ||
| 329 | + | | Model | `model` | Input | | |
| 330 | + | | --- | --- | --- | | |
| 331 | + | | BGE M3 | `workers-ai/@cf/baai/bge-m3` | $0.012 | | |
| 332 | + | | BGE Base (English) | `workers-ai/@cf/baai/bge-base-en-v1.5` | $0.067 | | |
| 333 | + | ||
| 334 | + | Open models cost much less per call than Claude, and suit one-shot work: | |
| 335 | + | titles, summaries, labels, triage, embeddings. In a long loop that sends | |
| 336 | + | the same context every turn, Claude's cache reads close most of that gap. | |
| 337 | + | ||
| 338 | + | ### What is refused on g1t's models | |
| 339 | + | ||
| 340 | + | A request for a model nobody offers is refused with `404` before it | |
| 341 | + | reaches a provider, and the error names the models offered. On g1t's | |
| 342 | + | models a request is charged only by its tokens, so what a provider bills | |
| 343 | + | some other way is refused with `400` for now: | |
| 344 | + | ||
| 146 | 345 | | Not offered on g1t's models yet | In the request | | |
| 147 | 346 | | --- | --- | | |
| 148 | 347 | | Fast mode | `speed` other than `standard` | | |
| 149 | 348 | | Inference in one region | `inference_geo` other than `global` | | |
| 150 | 349 | | Server-side fallbacks | `fallbacks` | | |
| 151 | − | | Server tools, such as web search, web fetch and code execution | A tool whose `type` is not your own (`custom` or none) or a client tool (`bash_…`, `text_editor_…`, `computer_…`, `memory_…`) | | |
| 350 | + | | Server tools, such as web search, web fetch and code execution | In Anthropic's format, a tool whose `type` is not your own (`custom` or none) or a client tool (`bash_…`, `text_editor_…`, `computer_…`, `memory_…`). In OpenAI's, a tool that is not a `function`, or `web_search_options`. | | |
| 152 | 351 | | Containers and skills | `container` | | |
| 153 | 352 | ||
| 154 | − | All of them work on the workspace's own key, which the provider bills. In | |
| 155 | − | Claude Code on g1t's models, its web search fails for this reason; the | |
| 156 | − | rest of Claude Code works. | |
| 353 | + | All of them work on your own provider, which bills them. In Claude Code on | |
| 354 | + | g1t's models, its web search fails for this reason; the rest of Claude Code | |
| 355 | + | works. | |
| 157 | 356 | ||
| 158 | − | Anthropic's format is the one served today. OpenAI's format and open | |
| 159 | − | models are coming later. | |
| 357 | + | ## Your own providers | |
| 358 | + | ||
| 359 | + | Connect a model provider under **Integrations**, and choose which models | |
| 360 | + | your own code's gateway requests send to it. Requests there use its key, | |
| 361 | + | are counted in the log, and are never charged on g1t. Any provider works: | |
| 160 | 362 | ||
| 363 | + | | Provider | Takes, unless you choose | | |
| 364 | + | | --- | --- | | |
| 365 | + | | An Anthropic key, or an Anthropic-compatible endpoint | `claude-*` | | |
| 366 | + | | An OpenAI key, or any other provider | Nothing until you choose | | |
| 367 | + | | An OpenAI-compatible endpoint: a self-hosted vLLM or Ollama, LiteLLM, another provider | Nothing until you choose | | |
| 368 | + | ||
| 369 | + | 1. Open the workspace's **Integrations** and choose a provider under | |
| 370 | + | **Model providers**. For your own server, choose **OpenAI-compatible | |
| 371 | + | endpoint** or **Anthropic-compatible endpoint** and give its base URL. | |
| 372 | + | 2. Paste its key. It is sealed when saved and never shown again: the page, | |
| 373 | + | the API and MCP show only its last four characters. | |
| 374 | + | 3. Under **AI Gateway models**, list the models to send there, separated by | |
| 375 | + | spaces: | |
| 376 | + | ||
| 377 | + | | Write | Takes | | |
| 378 | + | | --- | --- | | |
| 379 | + | | `gpt-5.5` | That model only | | |
| 380 | + | | `gpt-*` | Every model whose id starts with `gpt-` | | |
| 381 | + | | `ollama/*` | Every model named `ollama/…`, sent without the prefix: `ollama/llama3.3` arrives as `llama3.3` | | |
| 382 | + | | `*` | Every model | | |
| 383 | + | | Nothing | No gateway requests | | |
| 384 | + | ||
| 385 | + | 4. Select **Connect**. To change the list or replace the key later, open | |
| 386 | + | **Change its AI Gateway models or key** under the provider. | |
| 387 | + | ||
| 388 | + | The first provider, in the order they were connected, that takes a model | |
| 389 | + | gets its requests. Either format reaches either kind of provider: a Claude | |
| 390 | + | key answers OpenAI-format requests, and an OpenAI-compatible endpoint | |
| 391 | + | answers Claude Code. Agent runs choose their models under | |
| 392 | + | [routing](/guides/models/), apart from this list. | |
| 393 | + | ||
| 394 | + | From code, connect one with | |
| 395 | + | [`POST /workspaces/{workspace}/integrations`](/reference/api/integrations/connect-integration/) | |
| 396 | + | and change it with | |
| 397 | + | [`PATCH /workspaces/{workspace}/integrations/{id}`](/reference/api/integrations/update-integration/), | |
| 398 | + | with `config.gateway_models`, or the `workspace` MCP tool's | |
| 399 | + | `connect_integration` and `update_integration` actions. Both need | |
| 400 | + | `workspace:admin` and act for an owner. The key is write-only: neither | |
| 401 | + | returns it. | |
| 402 | + | ||
| 403 | + | ```sh | |
| 404 | + | curl -X PATCH https://api.g1t.sh/workspaces/acme/integrations/con_01kpx5c2d8e4f6g0h2j4k6m8n0 \ | |
| 405 | + | -H "Authorization: Bearer $G1T_ADMIN_TOKEN" \ | |
| 406 | + | -H "content-type: application/json" \ | |
| 407 | + | -d '{ "config": { "base_url": "https://gpu.acme.dev/v1", "gateway_models": ["ollama/*"] }, "secret": "…" }' | |
| 408 | + | ``` | |
| 409 | + | ||
| 410 | + | `config` replaces the provider's settings whole, so send the ones it has | |
| 411 | + | with the change. | |
| 412 | + | ||
| 161 | 413 | ## What it costs | |
| 162 | 414 | ||
| 163 | 415 | | Where it goes | You pay | | |
| 164 | 416 | | --- | --- | | |
| 165 | 417 | | g1t's models | Its tokens at the model's price above, with no markup while the gateway is in beta | | |
| 166 | − | | The workspace's own Anthropic key | Nothing on g1t. The provider bills you for the model. | | |
| 418 | + | | Your own providers | Nothing on g1t. The provider bills you for the model. | | |
| 167 | 419 | ||
| 168 | 420 | On g1t's models: | |
| 169 | 421 | ||
| 170 | 422 | - Each request that used tokens is one line on the statement, under | |
| 171 | 423 | **AI Gateway**, such as *AI Gateway: Claude Sonnet 5.5, 14,352 tokens, | |
| 172 | − | token release-notes*. | |
| 424 | + | token release-notes*. A Claude Haiku 5.5 request over 100,000 prompt | |
| 425 | + | tokens says *long-prompt price*. | |
| 173 | 426 | - The plan's included usage pays first, then AI credit. Trial credit and | |
| 174 | 427 | g1t's open-source pool never pay for gateway requests. | |
| 175 | 428 | - It is not an agent run, so the [agent rate](/guides/usage-and-billing/#the-agent-rate) | |
| 178 | 431 | like any other usage, and shows on **Usage** under the AI Gateway product. | |
| 179 | 432 | - A workspace with a 100% discount gets it free through the discount; an | |
| 180 | 433 | enterprise is invoiced for it after use. | |
| 181 | − | ||
| 182 | − | ### Your own key | |
| 183 | − | ||
| 184 | − | When the workspace has an Anthropic or Anthropic-compatible model provider | |
| 185 | − | under [Integrations](/guides/models/), the gateway sends every request to | |
| 186 | − | the first one connected, with its key. Those requests are logged with their | |
| 187 | − | tokens and marked **Own key**, and g1t charges nothing for them. Remove the | |
| 188 | − | provider and requests go to g1t's models again within a few seconds. | |
| 189 | 434 | ||
| 190 | 435 | ## Limits and errors | |
| 191 | 436 | ||
| 196 | 441 | usage left. If auto-reload is on, g1t tries it first. | |
| 197 | 442 | - It is not on the g1t plan. | |
| 198 | 443 | ||
| 199 | − | Errors are Anthropic's shape, so SDKs raise their usual errors: | |
| 444 | + | Errors are in the format of the route, so SDKs raise their usual errors. | |
| 445 | + | Anthropic's: | |
| 200 | 446 | ||
| 201 | 447 | ```json | |
| 202 | 448 | { "type": "error", "error": { "type": "billing_error", "message": "The acme workspace is out of AI credit …" } } | |
| 203 | 449 | ``` | |
| 204 | 450 | ||
| 205 | − | | Status | `error.type` | Why | | |
| 206 | − | | --- | --- | --- | | |
| 207 | − | | `400` | `invalid_request_error` | The body is not JSON, the model is not offered, or the request asks for something [not offered on g1t's models yet](#models). | | |
| 208 | − | | `401` | `authentication_error` | The token is unknown, expired or deleted. | | |
| 209 | − | | `402` | `billing_error` | Out of AI credit, over the spend limit, or not on the plan. The message says what an owner can do. | | |
| 210 | − | | `403` | `permission_error` | Not a workspace's token, or it lacks `models:write`. | | |
| 211 | − | | `404` | `not_found_error` | A route the gateway does not answer. | | |
| 451 | + | OpenAI's: | |
| 212 | 452 | ||
| 213 | − | An error from the model provider, such as `429` or `529`, comes back as | |
| 214 | − | the provider sent it. Refused and failed requests are logged with their | |
| 215 | − | status and why, and cost nothing. | |
| 453 | + | ```json | |
| 454 | + | { "error": { "message": "The acme workspace is out of AI credit …", "type": "insufficient_quota", "param": null, "code": "insufficient_quota" } } | |
| 455 | + | ``` | |
| 456 | + | ||
| 457 | + | | Status | Anthropic's `error.type` | OpenAI's `error.type` (`code`) | Why | | |
| 458 | + | | --- | --- | --- | --- | | |
| 459 | + | | `400` | `invalid_request_error` | `invalid_request_error` | The body is not JSON, the model is of the wrong kind, the request asks for something [not offered on g1t's models yet](#what-is-refused-on-g1ts-models), or it cannot be said to the model (such as `n` above 1 to Claude). | | |
| 460 | + | | `401` | `authentication_error` | `authentication_error` (`invalid_api_key`) | The token is unknown, expired or deleted, or your provider refused its key. | | |
| 461 | + | | `402` | `billing_error` | `insufficient_quota` (`insufficient_quota`) | Out of AI credit, over the spend limit, or not on the plan. The message says what an owner can do. | | |
| 462 | + | | `403` | `permission_error` | `permission_error` | Not a workspace's token, or it lacks `models:write`. | | |
| 463 | + | | `404` | `not_found_error` | `invalid_request_error` (`model_not_found`) | No provider offers the model, or a route the gateway does not answer. | | |
| 464 | + | ||
| 465 | + | An error from the model provider, such as `429` or `529`, comes back with | |
| 466 | + | its status and message, in the route's format. A provider's key never | |
| 467 | + | appears in an error or the log, even when the provider quotes it. Refused | |
| 468 | + | and failed requests are logged with their status and why, and cost | |
| 469 | + | nothing. Every answer carries `x-g1t-request-id`, the request's id in the | |
| 470 | + | log. | |
| 216 | 471 | ||
| 217 | − | A deleted token, a provider added under Integrations, or AI credit just | |
| 218 | − | bought takes effect within about ten seconds. | |
| 472 | + | A deleted token, a provider added or changed under Integrations, or AI | |
| 473 | + | credit just bought takes effect within about ten seconds. | |
| 219 | 474 | ||
| 220 | 475 | ## See every request | |
| 221 | 476 | ||
| 226 | 481 | | Column | | | |
| 227 | 482 | | --- | --- | | |
| 228 | 483 | | Time | When it was sent. Hover for the exact time, how long it took and whether it streamed. | | |
| 229 | − | | Model | The model it named. On the workspace's own key, the one that answered. | | |
| 230 | − | | Input, Output, Cache read, Cache write | Its tokens by kind. | | |
| 231 | − | | Cost | What it was charged, before included usage and AI credit paid for it, or **Own key**. | | |
| 484 | + | | Model | The model it named on g1t's models, or the one that answered on your own provider; below it, the format it was sent in. | | |
| 485 | + | | Served by | g1t's account and the provider (*g1t · Anthropic*, *g1t · Workers AI*), or your provider by name. *None* when it was refused first. | | |
| 486 | + | | Input, Output | Its tokens by kind. Hover Input for all of them. | | |
| 487 | + | | Cache | Cache reads, then cache writes. Hover for how many writes were to the one-hour cache. | | |
| 488 | + | | Cost | What it was charged, before included usage and AI credit paid for it, or **Not charged** on your own provider. | | |
| 232 | 489 | | Status | The status it was answered with. Hover a refusal or failure for why. | | |
| 233 | 490 | | Token | The name of the token that sent it. | | |
| 234 | 491 | ||
| 237 | 494 | From code, list them with | |
| 238 | 495 | [`GET /workspaces/{workspace}/gateway/requests`](/reference/api/billing/list-gateway-requests/), | |
| 239 | 496 | or the `billing` MCP tool's | |
| 240 | − | [`gateway_requests`](/reference/mcp/#billing) action. Both need | |
| 241 | − | `models:read`, which the Read only and Agent presets include. | |
| 497 | + | [`gateway_requests`](/reference/mcp/#billing) action. Each request has | |
| 498 | + | `format`, `provider`, `connection`, `model` and its tokens, with | |
| 499 | + | `cache_write_hour` for one-hour cache writes. Both need `models:read`, | |
| 500 | + | which the Read only and Agent presets include. |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large 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.