Merge pull request #362 from flagon-io/361-home-tab
Home is its own tab and one hub; jobs never wait on their logs; CI tests in the merge queue, not again on main
21 files+1051−2230/21 viewed
| 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. | |
| 1 | + | # Tests every pull request into main, and then, in the merge queue, each | |
| 2 | + | # one together with those ahead of it, so main can always be deployed. | |
| 3 | + | # The "Main stays deployable" ruleset requires this workflow ("CI") to pass | |
| 4 | + | # before a pull request merges, and the merge queue lands it only once CI | |
| 5 | + | # passed on its queue state (a merge_group run; "CI / merge_group" meets the | |
| 6 | + | # required "CI" too). People may still push to main directly, agents may | |
| 7 | + | # not. Deploy (deploy.yml) ships what lands on main. | |
| 5 | 8 | # | |
| 6 | 9 | # rust every crate's tests, natively, in three jobs at once | |
| 7 | 10 | # typescript type checks and tests of the apps and TS services, the | |
| ⋯ | |||
| 9 | 12 | # build the site, sudo and the docs build as they deploy | |
| 10 | 13 | # desktop the desktop app's checks and tests, and the release scripts' | |
| 11 | 14 | # | |
| 12 | − | # On a pull request, `rust`, `build` and `desktop` run only for what it | |
| 13 | − | # changes (scripts/deploy/ci-affected.mjs) and pass without running | |
| 14 | − | # otherwise; `typescript` always runs. | |
| 15 | + | # On a pull request, and on a merge queue state, `rust`, `build` and | |
| 16 | + | # `desktop` run only for what it changes (scripts/deploy/ci-affected.mjs: | |
| 17 | + | # a pull request against its base, a queue state against the main it was | |
| 18 | + | # built on) and pass without running otherwise; `typescript` always runs. | |
| 15 | 19 | # | |
| 16 | − | # On a push to main `rust`, `typescript` and `desktop` run, keeping main's | |
| 17 | − | # caches current: | |
| 18 | − | # a pull request's run restores from main's cache, never from another pull | |
| 19 | − | # request's, so without them each pull request would start from nothing. | |
| 20 | + | # A push to main the merge queue landed is the very commit its merge_group | |
| 21 | + | # run passed on, so it is not tested again: `rust` only compiles its | |
| 22 | + | # tests and `typescript` only type checks, keeping main's caches current, | |
| 23 | + | # and `desktop` passes without running. A pull request's run restores | |
| 24 | + | # from main's cache, never from another pull request's, so without them | |
| 25 | + | # each pull request would start from nothing. Any other push to main (one | |
| 26 | + | # made directly, or one the API could not vouch for) runs `rust`, | |
| 27 | + | # `typescript` and `desktop` in full (ci-affected.mjs says which it is). | |
| 20 | 28 | # What each cache holds, its key and which job saves it: CONTRIBUTING.md, | |
| 21 | 29 | # "How g1t's own CI caches". The Caches workflow (caches.yml) keeps the | |
| 22 | 30 | # ones only releases build. | |
| ⋯ | |||
| 25 | 33 | on: | |
| 26 | 34 | pull_request: | |
| 27 | 35 | branches: [main] | |
| 36 | + | merge_group: | |
| 28 | 37 | push: | |
| 29 | 38 | branches: [main] | |
| 30 | 39 | workflow_dispatch: | |
| 31 | 40 | ||
| 32 | − | # Its token only reads: it checks the code out and nothing more. | |
| 41 | + | # Its token only reads: it checks the code out, and asks whether a push's | |
| 42 | + | # commit passed in the merge queue (ci-affected.mjs). | |
| 33 | 43 | permissions: | |
| 34 | 44 | contents: read | |
| 45 | + | actions: read | |
| 35 | 46 | ||
| 47 | + | # Each merge queue state is a branch of its own (g1t-queue/<entry>-<attempt>), | |
| 48 | + | # so a run on one never cancels another's; the queue starts a state again | |
| 49 | + | # when it needs to. | |
| 36 | 50 | concurrency: | |
| 37 | 51 | group: ci-${{ github.ref }} | |
| 38 | 52 | cancel-in-progress: true | |
| ⋯ | |||
| 48 | 62 | # the libraries, but a test binary is linked and never cached), and the | |
| 49 | 63 | # suites then ran one after another. The parts are about even in Rust; | |
| 50 | 64 | # repository_workflows.rs checks every workspace crate is in exactly one. | |
| 51 | − | # On a pull request a part tests only its crates the change reaches | |
| 52 | − | # (scripts/deploy/ci-affected.mjs), and a part it does not reach passes | |
| 53 | − | # without compiling; on main every part tests all of its crates. | |
| 65 | + | # On a pull request or a queue state a part tests only its crates the | |
| 66 | + | # change reaches (scripts/deploy/ci-affected.mjs), and a part it does not | |
| 67 | + | # reach passes without compiling; on main every part compiles all of its | |
| 68 | + | # crates' tests, and runs them unless the merge queue already did. | |
| 54 | 69 | rust: | |
| 55 | 70 | name: Rust (${{ matrix.part }}) | |
| 56 | 71 | runs-on: ubuntu-latest | |
| ⋯ | |||
| 79 | 94 | steps: | |
| 80 | 95 | # Two commits deep: a pull request's run builds its test merge commit, | |
| 81 | 96 | # and what it changes is that commit against its first parent, the | |
| 82 | − | # base branch's tip. | |
| 97 | + | # base branch's tip. A queue state is compared with the main it was | |
| 98 | + | # built on, which the script fetches when it is deeper. | |
| 83 | 99 | - uses: actions/checkout@v5 | |
| 84 | 100 | with: | |
| 85 | 101 | fetch-depth: 2 | |
| 86 | − | # The crates this part tests: on a pull request, those whose files it | |
| 87 | − | # changes and those that depend on them, else every one. With none, | |
| 88 | − | # every step after this one is skipped and the part passes. | |
| 102 | + | # The crates this part tests: on a pull request or a queue state, | |
| 103 | + | # those whose files it changes and those that depend on them, else | |
| 104 | + | # every one. With none, every step after this one is skipped and the | |
| 105 | + | # part passes. `tested`: a push the merge queue landed, whose tests | |
| 106 | + | # are compiled and not run. | |
| 89 | 107 | - name: What this part tests | |
| 90 | 108 | id: affected | |
| 109 | + | env: | |
| 110 | + | GITHUB_TOKEN: ${{ github.token }} | |
| 91 | 111 | run: node scripts/deploy/ci-affected.mjs ${{ matrix.crates }} | |
| 92 | 112 | # rust: the toolchain; cargo: Cargo.lock's crates.io and git packages, | |
| 93 | 113 | # not the workspace's own versions (scripts/deploy/ci-cache.mjs). | |
| ⋯ | |||
| 136 | 156 | if: ${{ steps.affected.outputs.crates }} | |
| 137 | 157 | run: bash scripts/sccache.sh install | |
| 138 | 158 | - name: Tests | |
| 139 | − | if: ${{ steps.affected.outputs.crates }} | |
| 159 | + | if: ${{ steps.affected.outputs.crates && steps.affected.outputs.tested != 'true' }} | |
| 140 | 160 | run: cargo test ${{ steps.affected.outputs.crates }} --locked --quiet | |
| 161 | + | # The merge queue ran these tests on this very commit: built, not run, | |
| 162 | + | # so the test build below and sccache's entries are main's again. | |
| 163 | + | - name: Tests, compiled only | |
| 164 | + | if: ${{ steps.affected.outputs.crates && steps.affected.outputs.tested == 'true' }} | |
| 165 | + | run: cargo test ${{ steps.affected.outputs.crates }} --locked --quiet --no-run | |
| 141 | 166 | # A restored target keeps the old versions of whatever changed beside | |
| 142 | 167 | # the new ones; only what this build used goes into the new entry. | |
| 143 | − | # And only a run that tested all of the part's crates saves one: a run | |
| 168 | + | # And only a run that built all of the part's crates saves one: a run | |
| 144 | 169 | # that tested some built only those, and saved under the key a whole | |
| 145 | 170 | # build is found by, its entry would be a hit for the runs after it, | |
| 146 | 171 | # which would compile the rest every time and never save a whole one. | |
| 147 | − | # main's runs test everything, and keep the entries pull requests | |
| 148 | − | # start from. | |
| 172 | + | # main's runs build everything, and keep the entries pull requests | |
| 173 | + | # start from. A queue state's branch is gone once it lands, and no | |
| 174 | + | # run would read what it saved. | |
| 149 | 175 | - name: Only this build's files are saved | |
| 150 | − | if: ${{ steps.test-build.outputs.cache-hit != 'true' && steps.affected.outputs.whole == 'true' }} | |
| 176 | + | if: ${{ steps.test-build.outputs.cache-hit != 'true' && steps.affected.outputs.whole == 'true' && github.event_name != 'merge_group' }} | |
| 151 | 177 | run: node scripts/deploy/ci-cache.mjs prune target/debug -- test ${{ matrix.crates }} --locked --no-run | |
| 152 | 178 | - name: Save the test build | |
| 153 | − | if: ${{ steps.test-build.outputs.cache-hit != 'true' && steps.affected.outputs.whole == 'true' }} | |
| 179 | + | if: ${{ steps.test-build.outputs.cache-hit != 'true' && steps.affected.outputs.whole == 'true' && github.event_name != 'merge_group' }} | |
| 154 | 180 | uses: actions/cache/save@v4 | |
| 155 | 181 | with: | |
| 156 | 182 | path: | | |
| ⋯ | |||
| 167 | 193 | timeout-minutes: 30 | |
| 168 | 194 | steps: | |
| 169 | 195 | - uses: actions/checkout@v5 | |
| 196 | + | # A push the merge queue landed only type checks, for tsc's cache: | |
| 197 | + | # its tests passed in the queue on this very commit. | |
| 198 | + | - name: Whether the merge queue tested this commit | |
| 199 | + | id: queue | |
| 200 | + | env: | |
| 201 | + | GITHUB_TOKEN: ${{ github.token }} | |
| 202 | + | run: node scripts/deploy/ci-affected.mjs --tested | |
| 170 | 203 | # npm's downloads, kept between runs: an install reads them first | |
| 171 | 204 | # and asks the registry only for what changed, so a dropped | |
| 172 | 205 | # connection to it rarely stops the job. | |
| ⋯ | |||
| 211 | 244 | packages/*/*.tsbuildinfo | |
| 212 | 245 | key: tsc-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-${{ github.sha }} | |
| 213 | 246 | - name: Tests | |
| 247 | + | if: ${{ steps.queue.outputs.tested != 'true' }} | |
| 214 | 248 | run: npm test --workspaces --if-present | |
| 215 | 249 | - name: The deploy tool's tests | |
| 250 | + | if: ${{ steps.queue.outputs.tested != 'true' }} | |
| 216 | 251 | run: npm run test:deploy | |
| 217 | 252 | - name: The conformance report's tests | |
| 253 | + | if: ${{ steps.queue.outputs.tested != 'true' }} | |
| 218 | 254 | run: npm run test:conformance | |
| 219 | 255 | - name: The ops scripts' tests | |
| 256 | + | if: ${{ steps.queue.outputs.tested != 'true' }} | |
| 220 | 257 | run: npm run test:ops | |
| 221 | 258 | - name: The manifest matches every wrangler.jsonc | |
| 259 | + | if: ${{ steps.queue.outputs.tested != 'true' }} | |
| 222 | 260 | run: node scripts/deploy.mjs manifest --check | |
| 223 | 261 | - name: The Claude Code plugin's tests | |
| 262 | + | if: ${{ steps.queue.outputs.tested != 'true' }} | |
| 224 | 263 | run: node --test plugins/claude-code/test/hooks.test.mjs | |
| 225 | 264 | # What plugins-release.yml publishes to flagon-io/g1t-plugins: the | |
| 226 | 265 | # manifests agree, and the tree is only the plugin. | |
| 227 | 266 | - name: The plugin's published marketplace | |
| 267 | + | if: ${{ steps.queue.outputs.tested != 'true' }} | |
| 228 | 268 | run: | | |
| 229 | 269 | node scripts/plugins-release.mjs check | |
| 230 | 270 | node --test scripts/plugins-release.test.mjs | |
| ⋯ | |||
| 235 | 275 | runs-on: ubuntu-latest | |
| 236 | 276 | timeout-minutes: 30 | |
| 237 | 277 | steps: | |
| 238 | − | # Two commits deep, as for the Rust parts: what the pull request | |
| 239 | − | # changes. A change of only Rust source, which none of the three | |
| 240 | − | # reads, passes without building (ci-affected.mjs JOBS). | |
| 278 | + | # Two commits deep, as for the Rust parts: what the pull request or | |
| 279 | + | # the queue state changes. A change of only Rust source, which none of | |
| 280 | + | # the three reads, passes without building (ci-affected.mjs JOBS). | |
| 241 | 281 | - uses: actions/checkout@v5 | |
| 242 | 282 | with: | |
| 243 | 283 | fetch-depth: 2 | |
| 244 | − | - name: Whether this pull request needs it | |
| 284 | + | - name: Whether this change needs it | |
| 245 | 285 | id: affected | |
| 246 | 286 | run: node scripts/deploy/ci-affected.mjs --job build | |
| 247 | 287 | # npm's downloads, kept between runs: an install reads them first | |
| ⋯ | |||
| 268 | 308 | runs-on: ubuntu-latest | |
| 269 | 309 | timeout-minutes: 30 | |
| 270 | 310 | steps: | |
| 271 | − | # A pull request that changes none of the app's files, its release | |
| 272 | − | # scripts or the build's own (ci-affected.mjs DESKTOP) passes without | |
| 273 | − | # building; main's runs build it, keeping its cache. | |
| 311 | + | # A pull request or queue state that changes none of the app's files, | |
| 312 | + | # its release scripts or the build's own (ci-affected.mjs DESKTOP) | |
| 313 | + | # passes without building, as does a push the merge queue landed; | |
| 314 | + | # a push made directly builds it, keeping its cache. | |
| 274 | 315 | - uses: actions/checkout@v5 | |
| 275 | 316 | with: | |
| 276 | 317 | fetch-depth: 2 | |
| 277 | − | - name: Whether this pull request needs it | |
| 318 | + | - name: Whether this change needs it | |
| 278 | 319 | id: affected | |
| 320 | + | env: | |
| 321 | + | GITHUB_TOKEN: ${{ github.token }} | |
| 279 | 322 | run: node scripts/deploy/ci-affected.mjs --job desktop | |
| 280 | 323 | - name: Cache keys | |
| 281 | 324 | id: keys | |
| ⋯ | |||
| 290 | 333 | if: ${{ steps.affected.outputs.run == 'true' }} | |
| 291 | 334 | run: bash scripts/apt-install.sh tauri --check | |
| 292 | 335 | # apt's lists and downloads (scripts/apt-install.sh): 110 s of every | |
| 293 | − | # run before, mostly fetching 335 MB. Saved here, on main; the desktop | |
| 294 | − | # release's Linux build restores the same entry. | |
| 336 | + | # run before, mostly fetching 335 MB. Saved here, on a push to main | |
| 337 | + | # that builds the app; the desktop release's Linux build restores the | |
| 338 | + | # same entry. | |
| 295 | 339 | - name: Cache apt's downloads | |
| 296 | 340 | if: ${{ steps.affected.outputs.run == 'true' && steps.tauri-apt.outputs.installed != 'true' }} | |
| 297 | 341 | uses: actions/cache@v4 | |
| 184 | 184 | 100 GiB (10 GiB in a workspace on the Free plan), past which the entries | |
| 185 | 185 | restored longest ago go. So: | |
| 186 | 186 | ||
| 187 | − | - **`main` saves; pull requests and tags restore.** CI's Rust, TypeScript | |
| 188 | − | and desktop jobs keep their caches on each push to `main`, and the | |
| 189 | − | Caches workflow (`.g1t/workflows/caches.yml`) builds and saves what only | |
| 190 | − | releases use, when its key is missing. Release workflows only restore. | |
| 187 | + | - **`main` saves; pull requests, queue states and tags restore.** CI's | |
| 188 | + | Rust and TypeScript jobs keep their caches on each push to `main`, and | |
| 189 | + | the Caches workflow (`.g1t/workflows/caches.yml`) builds and saves what | |
| 190 | + | only releases use, when its key is missing. Release workflows only | |
| 191 | + | restore. | |
| 192 | + | - **A push the merge queue landed only warms the caches.** It is the very | |
| 193 | + | commit CI's `merge_group` run passed on, so it is not tested again: the | |
| 194 | + | Rust parts compile their tests (`cargo test --no-run`) and save the test | |
| 195 | + | build, TypeScript type checks and saves tsc's build info, and the | |
| 196 | + | desktop job passes without building. `scripts/deploy/ci-affected.mjs` | |
| 197 | + | tells it from a push made directly, which asks the API for a passing | |
| 198 | + | `merge_group` run of `ci.yml` on that commit; a direct push, or one the | |
| 199 | + | API cannot answer for, runs everything, desktop included. | |
| 191 | 200 | - **Rust caches are keyed by the toolchain and the dependencies**, from | |
| 192 | 201 | `node scripts/deploy/ci-cache.mjs keys`: `rustc -V`, and a hash of the | |
| 193 | 202 | packages `Cargo.lock` takes from crates.io and git. A release bumping a | |
| ⋯ | |||
| 197 | 206 | saved** (`ci-cache.mjs prune`), to what this build used. | |
| 198 | 207 | - **A partial install never saves a whole-install key.** npm's downloads | |
| 199 | 208 | and the crates are saved only by jobs that fetch all of them. | |
| 200 | − | - **A partial test build is never saved.** On a pull request, CI's Rust | |
| 201 | − | parts test only the crates it changes and those that depend on them | |
| 202 | − | (`scripts/deploy/ci-affected.mjs`); a part that tested some of its | |
| 203 | − | crates saves no entry, and one with none to test does nothing. On | |
| 204 | − | `main` every part tests, and saves, all of its crates. | |
| 209 | + | - **A partial test build is never saved.** On a pull request or a merge | |
| 210 | + | queue state, CI's Rust parts test only the crates it changes and those | |
| 211 | + | that depend on them (`scripts/deploy/ci-affected.mjs`); a part that | |
| 212 | + | tested some of its crates saves no entry, and one with none to test does | |
| 213 | + | nothing. A queue state saves no test build at all: its branch is gone | |
| 214 | + | once it lands. On `main` every part builds, and saves, all of its | |
| 215 | + | crates. | |
| 205 | 216 | - **One set of paths per entry.** An entry's version is the hash of its | |
| 206 | 217 | paths, so every step that names it, in every workflow, names the same | |
| 207 | 218 | ones. `crates/actions/tests/repository_workflows.rs` holds all of this. | |
| ⋯ | |||
| 232 | 243 | | -------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------- | --------------------------- | | |
| 233 | 244 | | npm's downloads | `npm-full-<os>-<package-lock.json>` | Caches (`npm`) | every npm install | | |
| 234 | 245 | | Crates and their index | `cargo-crates-<os>-<deps>` | CI Rust (`cargo fetch` of the whole lockfile) | Deploy, the runner release | | |
| 235 | − | | The test builds | `cargo-test-<os>-<rust>-<part>-<deps>`, one for each of CI Rust's three parts | CI Rust, when it tests a whole part | CI Rust on pull requests | | |
| 246 | + | | The test builds | `cargo-test-<os>-<rust>-<part>-<deps>`, one for each of CI Rust's three parts | CI Rust, when it builds a whole part | CI Rust on pull requests | | |
| 236 | 247 | | tsc's build info | `tsc-<os>-<package-lock.json>-<commit>` (every `*.tsbuildinfo`) | CI TypeScript on `main` | CI TypeScript | | |
| 237 | 248 | | Workers' builds | `cargo-workers-<os>-<rust>-<group>-<deps>` | Deploy | Deploy | | |
| 238 | 249 | | The runner's image build | `runner-musl-<os>-<rust>-<deps>` | Deploy | Deploy | | |
| 239 | 250 | | worker-build and its tools | `worker-build-…`, `worker-build-tools-…` | Deploy | Deploy | | |
| 240 | 251 | | Compiled crates | sccache's, one per crate | every Rust job | every Rust job | | |
| 241 | − | | The desktop app's check | `desktop-check-<os>-<rust>-<desktop deps>` | CI desktop | CI desktop on pull requests | | |
| 242 | − | | apt for Tauri | `apt-tauri-<os>-<scripts/apt-install.sh>`, only where the image lacks Tauri's packages | CI desktop | the desktop release | | |
| 252 | + | | The desktop app's check | `desktop-check-<os>-<rust>-<desktop deps>` | CI desktop, on a push not from the queue | CI desktop on pull requests | | |
| 253 | + | | apt for Tauri | `apt-tauri-<os>-<scripts/apt-install.sh>`, only where the image lacks Tauri's packages | CI desktop, on a push not from the queue | the desktop release | | |
| 243 | 254 | | The runner release | `runner-release-<os>-<target>-<rust>-<deps>`, zig | Caches (`runner`) | the runner release | | |
| 244 | 255 | | The desktop release | `desktop-release-linux-…`, `desktop-release-windows-…`, `xwin-…`, `apt-tauri-windows-…` | Caches (`desktop-linux`, `desktop-windows`) | the desktop release | | |
| 245 | 256 | | The CLI release | `cli-release-<os>-<zigbuild>-<deps>` (`target/zig`, every platform) | Caches (`cli`), built whole | the CLI release | | |
| 3 | 3 | description: Your workspace's front page. A box that asks g1t, your move (everything waiting on you, and the one item to start with), the agents, runs and builds going now, this week's work day by day with what is worth a look, and what happened since you were last here in plain English. How every number is worked out. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | − | **Home** is the first page of your workspace. On a computer it is where | |
| 7 | − | **Chat** opens, at `/<workspace>/-/chat`, with your conversations and | |
| 8 | − | sessions beside it; on a phone it is the **Home** tab, at | |
| 9 | − | `/<workspace>/-/home`. | |
| 6 | + | **Home** is the first page of your workspace and the first tab in the app | |
| 7 | + | bar, at `/<workspace>/-/home`, on a computer and on a phone alike. g1t and | |
| 8 | + | your workspace's own address open there. | |
| 10 | 9 | It reads top to bottom, from what you can do now to what already happened: | |
| 11 | 10 | ||
| 12 | 11 | 1. **What are we building today?**: a box that [asks g1t](#ask-from-home) | |
| ⋯ | |||
| 20 | 19 | the last two weeks day by day (what agents and people landed), and what | |
| 21 | 20 | is [worth a look](#worth-a-look), each finding with its evidence and | |
| 22 | 21 | pinned to the day it happened where it happened on one. | |
| 23 | − | 4. **[The workspace](#the-workspace)**: what is moving and what is | |
| 24 | − | waiting team by team, and the quiet changes in who works where; for | |
| 25 | − | owners and billing managers, the quarter's spend threaded to the work. | |
| 26 | − | 5. **Since you were last here**: one card for the span. It shows what people | |
| 22 | + | 4. **Since you were last here**: one card for the span. It shows what people | |
| 27 | 23 | did, what changed in plain English when you ask for it, and what landed, | |
| 28 | 24 | each change with whether it is live yet. Beside it, kept in view as you | |
| 29 | 25 | scroll: **[Spent](#spent)**, what the span cost and the month so far | |
| 30 | 26 | (owners and billing managers), and | |
| 31 | 27 | **[Settled for you](#settled-for-you)**, what finished by itself. | |
| 28 | + | 5. **[The workspace](#the-workspace)**: what is moving and what is | |
| 29 | + | waiting team by team, and the quiet changes in who works where; for | |
| 30 | + | owners and billing managers, the quarter's spend threaded to the work. | |
| 32 | 31 | 6. **Members**, everyone in the workspace, and, for owners and billing | |
| 33 | 32 | managers, the month's **Usage** against the plan. | |
| 34 | 33 | ||
| ⋯ | |||
| 370 | 369 | ||
| 371 | 370 | ## The workspace | |
| 372 | 371 | ||
| 373 | − | Under This week, for members who can see Code: the workspace's | |
| 372 | + | Under Since you were last here, for members who can see Code: the workspace's | |
| 374 | 373 | [ground truth](/guides/workspaces/#the-workspace-overview), what is moving | |
| 375 | 374 | and what is waiting team by team (each repository, without teams), with | |
| 376 | 375 | the pull requests waiting on a first review, and the quiet changes in who | |
| 62 | 62 | | Monitoring & incidents | [Sentry, Datadog, and any system that sends a webhook](#alerts) | | | |
| 63 | 63 | ||
| 64 | 64 | Alerts and trackers each have a setup page under the directory: | |
| 65 | − | `-/integrations/alerts` and `-/integrations/trackers`. GitHub and webhooks | |
| 66 | − | keep their own pages; the directory links to them. | |
| 65 | + | `-/settings/integrations/alerts` and `-/settings/integrations/trackers`. | |
| 66 | + | GitHub has one too, `-/settings/integrations/github`: the accounts g1t's | |
| 67 | + | GitHub App is installed on for the workspace, each with **Import from it** | |
| 68 | + | and **Manage on GitHub**, and **Import repositories**, which goes on to | |
| 69 | + | the import. Webhooks keep their own page; the directory links to it. On | |
| 70 | + | every integrations page, **Apps** is lit in the app bar, with the | |
| 71 | + | workspace's settings beside it. | |
| 67 | 72 | ||
| 68 | 73 | Secrets are sealed when they are saved and never shown again, to anyone. | |
| 69 | 74 | A setup page shows the last four characters of a key, so you can tell keys |
| 775 | 775 | ||
| 776 | 776 | Each page has three parts: | |
| 777 | 777 | ||
| 778 | − | | Part | Where | What it holds | | |
| 779 | − | | --------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | |
| 780 | − | | **The app bar** | Across the top of the window | g1t's mark, which goes to **Chat** and its [Home](/guides/home/); the workspace, with **People** and **Workspace settings** in its menu; the built-in apps, **Chat**, **Work**, **Code**, **Library** and **Routines**, named from 1280px wide and icons alone below; the apps you pinned, **Apps** and **Explore**; then search, **Ask**, the bell, **Create new** and your account. | | |
| 781 | − | | **The sidebar** | Under the app bar, from the window's left edge | The lists of the app you are in, in named groups; Ctrl B (⌘B on a Mac) folds it away. Home has none. | | |
| 782 | − | | **The page** | The rest of the window, flat against the sidebar | Its header: the button that shows or hides the sidebar, where you are, **Search or jump to** (<kbd>/</kbd>) when the sidebar is hidden or has a search of its own, the [notifications](/guides/notifications/) bell and **Create new**. Then the page itself. | | |
| 778 | + | | Part | Where | What it holds | | |
| 779 | + | | --------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | |
| 780 | + | | **The app bar** | Across the top of the window | g1t's mark, which goes to [Home](/guides/home/); the workspace, with **People** and **Workspace settings** in its menu; the built-in apps, **Home**, **Chat**, **Work**, **Code**, **Library** and **Routines**, named from 1280px wide and icons alone below; the apps you pinned, **Apps** and **Explore**; then search, **Ask**, the bell, **Create new** and your account. | | |
| 781 | + | | **The sidebar** | Under the app bar, from the window's left edge | The lists of the app you are in, in named groups; Ctrl B (⌘B on a Mac) folds it away. Home has none. | | |
| 782 | + | | **The page** | The rest of the window, flat against the sidebar | Its header: the button that shows or hides the sidebar, where you are, **Search or jump to** (<kbd>/</kbd>) when the sidebar is hidden or has a search of its own, the [notifications](/guides/notifications/) bell and **Create new**. Then the page itself. | | |
| 783 | 783 | ||
| 784 | 784 | ### The app bar | |
| 785 | 785 | ||
| ⋯ | |||
| 787 | 787 | each a tab with what is unread on it (named from 1280px wide, an icon with | |
| 788 | 788 | its name on hover below that): | |
| 789 | 789 | ||
| 790 | − | | App | Opens | | |
| 791 | − | | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | |
| 792 | − | | **Chat** | Where the workspace opens. On a computer it opens on [Home](/guides/home/): where you're needed across the workspace, and what happened while you were away. Beside it, **Needs attention**, the workspace's [sessions](/work/sessions/) (what agents are doing, at `g1t.sh/<workspace>/-/chat/sessions`), your channels, your direct messages, and your recent sessions. See [Chat](/guides/chat/). | | |
| 793 | − | | **Work** | The workspace's work items, their [Board](/work/board/), and its labels and milestones, at `g1t.sh/<workspace>/-/work`. | | |
| 794 | − | | **Code** | Repositories, pull requests and checks. Not shown to a member without Code access. | | |
| 795 | − | | **Library** | Your artifacts: documents, decks and pages, at `g1t.sh/<workspace>/-/library/artifacts`. See [artifacts](/guides/artifacts/). | | |
| 796 | − | | **Routines** | Standing work g1t will run by itself, on a schedule or when something happens. Not available yet: the page shows what it will hold. | | |
| 790 | + | | App | Opens | | |
| 791 | + | | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | |
| 792 | + | | **Home** | Where the workspace opens: [Home](/guides/home/), where you're needed across the workspace, what happened while you were away, and how the workspace is doing. | | |
| 793 | + | | **Chat** | The conversation you were last in, else #general. Beside it, **Needs attention**, the workspace's [sessions](/work/sessions/) (what agents are doing, at `g1t.sh/<workspace>/-/chat/sessions`), your channels, your direct messages, and your recent sessions. See [Chat](/guides/chat/). | | |
| 794 | + | | **Work** | The workspace's work items, their [Board](/work/board/), and its labels and milestones, at `g1t.sh/<workspace>/-/work`. | | |
| 795 | + | | **Code** | Repositories, pull requests and checks. Not shown to a member without Code access. | | |
| 796 | + | | **Library** | Your artifacts: documents, decks and pages, at `g1t.sh/<workspace>/-/library/artifacts`. See [artifacts](/guides/artifacts/). | | |
| 797 | + | | **Routines** | Standing work g1t will run by itself, on a schedule or when something happens. Not available yet: the page shows what it will hold. | | |
| 797 | 798 | ||
| 798 | 799 | After them come the apps you pinned, then: | |
| 799 | 800 | ||
| 27 | 27 | ); | |
| 28 | 28 | } | |
| 29 | 29 | ||
| 30 | − | /** A part of the ground truth: a mono eyebrow, a sentence for a heading, and what it shows. */ | |
| 31 | − | function Section({ id, eyebrow, title, hint, children }: { id: string; eyebrow: string; title: string; hint?: ReactNode; children: ReactNode }) { | |
| 30 | + | /** | |
| 31 | + | * A part of the ground truth: a mono eyebrow, a sentence for a heading, and | |
| 32 | + | * what it shows. `compact` draws it as Home's cards are: the eyebrow as | |
| 33 | + | * the card's title and the sentence under it. | |
| 34 | + | */ | |
| 35 | + | function Section({ | |
| 36 | + | id, | |
| 37 | + | eyebrow, | |
| 38 | + | title, | |
| 39 | + | hint, | |
| 40 | + | compact = false, | |
| 41 | + | children, | |
| 42 | + | }: { | |
| 43 | + | id: string; | |
| 44 | + | eyebrow: string; | |
| 45 | + | title: string; | |
| 46 | + | hint?: ReactNode; | |
| 47 | + | compact?: boolean; | |
| 48 | + | children: ReactNode; | |
| 49 | + | }) { | |
| 50 | + | if (compact) { | |
| 51 | + | return ( | |
| 52 | + | <section id={id} aria-labelledby={`${id}-title`} className="scroll-mt-6 rounded-2xl border border-line bg-surface"> | |
| 53 | + | <header className="px-5 pt-4 pb-3"> | |
| 54 | + | <h2 id={`${id}-title`} className="text-sm font-semibold text-fg"> | |
| 55 | + | {eyebrow} | |
| 56 | + | </h2> | |
| 57 | + | <p className="mt-0.5 text-xs text-muted">{title}</p> | |
| 58 | + | </header> | |
| 59 | + | <div className="px-5 pb-5">{children}</div> | |
| 60 | + | </section> | |
| 61 | + | ); | |
| 62 | + | } | |
| 32 | 63 | return ( | |
| 33 | 64 | <section id={id} aria-labelledby={`${id}-title`} className="scroll-mt-6 rounded-2xl border border-line bg-surface px-5 py-6 md:px-8 md:py-7"> | |
| 34 | 65 | <p className="font-mono text-[0.6875rem] tracking-[0.16em] text-accent uppercase">{eyebrow}</p> | |
| ⋯ | |||
| 77 | 108 | * week). `money` shows the spend threaded to the work, which is in dollars: | |
| 78 | 109 | * only for those who look after the workspace's billing. | |
| 79 | 110 | */ | |
| 80 | − | export function GroundTruth({ slug, ground, pulse = true, money = true }: { slug: string; ground: Ground; pulse?: boolean; money?: boolean }) { | |
| 111 | + | export function GroundTruth({ | |
| 112 | + | slug, | |
| 113 | + | ground, | |
| 114 | + | pulse = true, | |
| 115 | + | money = true, | |
| 116 | + | compact = false, | |
| 117 | + | }: { | |
| 118 | + | slug: string; | |
| 119 | + | ground: Ground; | |
| 120 | + | pulse?: boolean; | |
| 121 | + | money?: boolean; | |
| 122 | + | /** Drawn among Home's cards, as one of them. */ | |
| 123 | + | compact?: boolean; | |
| 124 | + | }) { | |
| 125 | + | const quiet = [ground.silos, ground.newGround, ground.spreading, ground.habits].every((rows) => rows.length === 0); | |
| 81 | 126 | return ( | |
| 82 | 127 | <div className="space-y-6"> | |
| 83 | 128 | {pulse && ( | |
| ⋯ | |||
| 110 | 155 | )} | |
| 111 | 156 | <Section | |
| 112 | 157 | id="rhythm" | |
| 158 | + | compact={compact} | |
| 113 | 159 | eyebrow="What's moving and what's waiting" | |
| 114 | 160 | title={ground.rhythm?.by === "project" ? "Each repository's last two weeks" : "Each team's last two weeks"} | |
| 115 | 161 | hint="Pull requests and agent sessions above the line; reviews, comments and requests for changes below it. Each row is drawn to its own scale: point at a day for its numbers." | |
| ⋯ | |||
| 150 | 196 | </Section> | |
| 151 | 197 | <Section | |
| 152 | 198 | id="quiet" | |
| 199 | + | compact={compact} | |
| 153 | 200 | eyebrow="Quiet changes" | |
| 154 | 201 | title="Who works where, and what is spreading" | |
| 155 | 202 | hint="What no roll-up shows: knowledge resting with one person, people moving into new ground, and new ways of working taking hold. Each is a place to look, not a verdict." | |
| 156 | 203 | > | |
| 157 | − | <div className="grid gap-8 md:grid-cols-2 xl:grid-cols-4"> | |
| 158 | − | <QuietList | |
| 159 | − | title="Knowledge silos" | |
| 160 | − | hint="Repositories where one person has done most of the last 90 days' work" | |
| 161 | − | rows={ground.silos} | |
| 162 | − | empty="No repository rests on one person right now." | |
| 163 | − | /> | |
| 164 | − | <QuietList | |
| 165 | − | title="New ground" | |
| 166 | − | hint="People who merged to a repository for the first time in the last two weeks" | |
| 167 | − | rows={ground.newGround} | |
| 168 | − | empty="No one has moved into a new repository in the last two weeks." | |
| 169 | − | /> | |
| 170 | − | <QuietList | |
| 171 | − | title="Spreading" | |
| 172 | − | hint="A harness more people took up this month, after someone started with it" | |
| 173 | − | rows={ground.spreading} | |
| 174 | − | empty="No harness has caught on in a team this month." | |
| 175 | − | /> | |
| 176 | − | <QuietList | |
| 177 | − | title="New habits" | |
| 178 | − | hint="People whose first agent-made change merged in the last two weeks" | |
| 179 | − | rows={ground.habits} | |
| 180 | − | empty="No one new started merging agents' work in the last two weeks." | |
| 181 | − | /> | |
| 182 | − | </div> | |
| 204 | + | {quiet ? ( | |
| 205 | + | <p className="text-sm text-faint"> | |
| 206 | + | Nothing has shifted lately: no repository rests on one person, no one has moved into new ground, and no new harness or habit has caught on. | |
| 207 | + | </p> | |
| 208 | + | ) : ( | |
| 209 | + | <div className="grid gap-8 md:grid-cols-2 xl:grid-cols-4"> | |
| 210 | + | <QuietList | |
| 211 | + | title="Knowledge silos" | |
| 212 | + | hint="Repositories where one person has done most of the last 90 days' work" | |
| 213 | + | rows={ground.silos} | |
| 214 | + | empty="No repository rests on one person right now." | |
| 215 | + | /> | |
| 216 | + | <QuietList | |
| 217 | + | title="New ground" | |
| 218 | + | hint="People who merged to a repository for the first time in the last two weeks" | |
| 219 | + | rows={ground.newGround} | |
| 220 | + | empty="No one has moved into a new repository in the last two weeks." | |
| 221 | + | /> | |
| 222 | + | <QuietList | |
| 223 | + | title="Spreading" | |
| 224 | + | hint="A harness more people took up this month, after someone started with it" | |
| 225 | + | rows={ground.spreading} | |
| 226 | + | empty="No harness has caught on in a team this month." | |
| 227 | + | /> | |
| 228 | + | <QuietList | |
| 229 | + | title="New habits" | |
| 230 | + | hint="People whose first agent-made change merged in the last two weeks" | |
| 231 | + | rows={ground.habits} | |
| 232 | + | empty="No one new started merging agents' work in the last two weeks." | |
| 233 | + | /> | |
| 234 | + | </div> | |
| 235 | + | )} | |
| 183 | 236 | </Section> | |
| 184 | 237 | </div> | |
| 185 | 238 | ); | |
| 151 | 151 | pagePath, | |
| 152 | 152 | repositoriesPath, | |
| 153 | 153 | workspaceSettingsPage, | |
| 154 | + | isIntegrationsPath, | |
| 154 | 155 | } from "../lib/workspace-nav"; | |
| 155 | 156 | import { useSidebarLocation } from "../lib/use-sidebar-location"; | |
| 156 | 157 | import { setupPath } from "../lib/onboarding"; | |
| ⋯ | |||
| 2380 | 2381 | username: user.username, | |
| 2381 | 2382 | }); | |
| 2382 | 2383 | const mode: ModeKey = browsing ? "browse" : modeOf(target, ws?.slug ?? null); | |
| 2383 | − | const panel = sidebarFor(mode, ws != null); | |
| 2384 | + | // The workspace's integrations are Apps's (lit in the app bar), kept beside the settings they are part of. | |
| 2385 | + | const panel = ws && isIntegrationsPath(target, ws.slug) ? "workspace" : sidebarFor(mode, ws != null); | |
| 2384 | 2386 | const { sidebar: chatSidebar } = useChatSidebar(); | |
| 2385 | 2387 | // Notify: the feed socket's live counts (lib/notify-client.ts) once it has them; the page's until then. | |
| 2386 | 2388 | const live = useLiveBadges(ws?.slug); | |
| 39 | 39 | ); | |
| 40 | 40 | }); | |
| 41 | 41 | ||
| 42 | − | test("the app bar's tabs are Chat (which opens on Home), Work, Code, the Library and Routines", () => { | |
| 43 | − | assert.deepEqual(barApps(true), ["chat", "work", "code", "artifacts", "routines"]); | |
| 42 | + | test("the app bar's tabs are Home, Chat, Work, Code, the Library and Routines", () => { | |
| 43 | + | assert.deepEqual(barApps(true), ["home", "chat", "work", "code", "artifacts", "routines"]); | |
| 44 | 44 | // A member without Code access has no Code, and the rest in the same order. | |
| 45 | − | assert.deepEqual(barApps(false), ["chat", "work", "artifacts", "routines"]); | |
| 45 | + | assert.deepEqual(barApps(false), ["home", "chat", "work", "artifacts", "routines"]); | |
| 46 | 46 | }); | |
| 47 | 47 | ||
| 48 | 48 | test("a phone's bar is Home, Work, Chat and Notifications, with Code first of More's apps", () => { | |
| ⋯ | |||
| 86 | 86 | apps.map((app) => app.key), | |
| 87 | 87 | ["int-github", "int-sentry"], | |
| 88 | 88 | ); | |
| 89 | − | assert.equal(apps[0]!.path("acme"), "/new/github?workspace=acme"); | |
| 89 | + | assert.equal(apps[0]!.path("acme"), "/acme/-/settings/integrations/github"); | |
| 90 | 90 | assert.deepEqual(appData(apps[1]!, "acme").href, "/acme/-/settings/integrations/alerts"); | |
| 91 | 91 | assert.deepEqual(installedApps({}), []); | |
| 92 | 92 | assert.deepEqual( | |
| 55 | 55 | ]; | |
| 56 | 56 | ||
| 57 | 57 | /** | |
| 58 | − | * The app bar's tabs, in order (components/app-bar.tsx): Chat first, which | |
| 59 | − | * opens on Home (where you're needed, what happened) and holds the | |
| 60 | − | * sessions, then Work, Code for a member with Code access, the Library and | |
| 61 | − | * Routines. Pinned apps, the Apps launcher and Explore (g1t beyond your | |
| 58 | + | * The app bar's tabs, in order (components/app-bar.tsx): Home first, the | |
| 59 | + | * workspace's focal point (where you're needed, what happened, how the | |
| 60 | + | * workspace is doing), then Chat, which holds the sessions, Work, Code for | |
| 61 | + | * a member with Code access, the Library and Routines. Pinned apps, the Apps launcher and Explore (g1t beyond your | |
| 62 | 62 | * workspaces) follow them as icons; People and Workspace are in the | |
| 63 | 63 | * workspace's menu. | |
| 64 | 64 | */ | |
| 65 | − | export const BAR_APPS = ["chat", "work", "code", "artifacts", "routines"] as const satisfies readonly BuiltinApp[]; | |
| 65 | + | export const BAR_APPS = ["home", "chat", "work", "code", "artifacts", "routines"] as const satisfies readonly BuiltinApp[]; | |
| 66 | 66 | ||
| 67 | 67 | export type BarApp = (typeof BAR_APPS)[number]; | |
| 68 | 68 |
| 129 | 129 | assert.equal(modeHome("apps", "acme"), "/acme/-/apps"); | |
| 130 | 130 | assert.equal(modeHome("code", "acme"), "/acme/-/overview"); | |
| 131 | 131 | assert.equal(modeHome("workspace", "acme"), "/acme/-/settings/profile"); | |
| 132 | − | assert.equal(homePath("acme"), "/acme/-/chat"); | |
| 132 | + | assert.equal(homePath("acme"), "/acme/-/home"); | |
| 133 | 133 | assert.equal(homePath("acme", "?tab=landed&_routes=x"), "/acme/-/overview?tab=landed"); | |
| 134 | 134 | }); | |
| 135 | 135 | ||
| ⋯ | |||
| 160 | 160 | assert.ok(!keys.has(page.key), `${page.key} twice`); | |
| 161 | 161 | keys.add(page.key); | |
| 162 | 162 | assert.ok(page.path.startsWith("settings/"), page.path); | |
| 163 | − | assert.equal(modeOf(`/acme/-/${page.path}`, "acme"), "workspace", page.path); | |
| 163 | + | // Integrations are Apps's, beside the settings' sidebar. | |
| 164 | + | assert.equal(modeOf(`/acme/-/${page.path}`, "acme"), page.key === "integrations" ? "apps" : "workspace", page.path); | |
| 164 | 165 | } | |
| 165 | 166 | } | |
| 166 | 167 | }); | |
| ⋯ | |||
| 168 | 169 | test("a member without Code access is sent around Code's pages", async () => { | |
| 169 | 170 | const { codeGate } = await import("./workspace-nav.ts"); | |
| 170 | 171 | const none = ["acme"]; | |
| 171 | − | assert.equal(codeGate("/", "", none, "acme"), "/acme/-/chat"); | |
| 172 | + | assert.equal(codeGate("/", "", none, "acme"), "/acme/-/home"); | |
| 172 | 173 | assert.equal(codeGate("/", "", none, "other"), null); | |
| 173 | − | assert.equal(codeGate("/acme", "", none, "acme"), "/acme/-/chat"); | |
| 174 | + | assert.equal(codeGate("/acme", "", none, "acme"), "/acme/-/home"); | |
| 174 | 175 | assert.equal(codeGate("/acme/web/pull/3", "?x=1", none, "acme"), "/acme/-/code-access?from=%2Facme%2Fweb%2Fpull%2F3%3Fx%3D1"); | |
| 175 | 176 | assert.equal(codeGate("/acme/-/projects", "", none, "acme"), "/acme/-/code-access?from=%2Facme%2F-%2Fprojects"); | |
| 176 | 177 | assert.equal(codeGate("/acme/-/code/repositories", "", none, "acme"), "/acme/-/code-access?from=%2Facme%2F-%2Fcode%2Frepositories"); | |
| 348 | 348 | if (SITE_PAGES.has(parts[0] ?? "")) return "site"; | |
| 349 | 349 | if (slug && parts[0]?.toLowerCase() === slug.toLowerCase()) { | |
| 350 | 350 | if (parts.length === 1) return "home"; | |
| 351 | − | if (parts[1] === "-") return PAGE_MODES[parts[2] ?? ""] ?? "code"; | |
| 351 | + | if (parts[1] === "-") { | |
| 352 | + | // The workspace's integrations are its apps: Apps is lit on them, beside the settings' sidebar. | |
| 353 | + | if (parts[2] === "settings" && parts[3] === "integrations") return "apps"; | |
| 354 | + | return PAGE_MODES[parts[2] ?? ""] ?? "code"; | |
| 355 | + | } | |
| 352 | 356 | } | |
| 353 | 357 | // Another workspace's chat, where the viewer is a guest: Chat all the same. | |
| 354 | 358 | if (parts[1] === "-" && parts[2] === "chat") return "chat"; | |
| 355 | 359 | return "code"; | |
| 356 | 360 | } | |
| 357 | 361 | ||
| 362 | + | /** Whether `pathname` is one of the workspace `slug`'s integrations pages: Apps's mode, with the settings' sidebar. */ | |
| 363 | + | export function isIntegrationsPath(pathname: string, slug: string): boolean { | |
| 364 | + | const parts = pagePath(pathname).split("/").filter(Boolean); | |
| 365 | + | return parts[0]?.toLowerCase() === slug.toLowerCase() && parts[1] === "-" && parts[2] === "settings" && parts[3] === "integrations"; | |
| 366 | + | } | |
| 367 | + | ||
| 358 | 368 | /** Where each mode's button in the rail goes, in the workspace `slug`. */ | |
| 359 | 369 | export function modeHome(mode: ModeKey, slug: string): string { | |
| 360 | 370 | switch (mode) { | |
| ⋯ | |||
| 490 | 500 | return browsingOf(pathname, viewer) ? "browse" : modeOf(pathname, viewer.slug); | |
| 491 | 501 | } | |
| 492 | 502 | ||
| 493 | − | /** | |
| 494 | − | * A workspace's front page: Chat, which opens on Home on a computer | |
| 495 | − | * (routes/workspace/chat/index.tsx). A phone's Home tab is Home's own | |
| 496 | − | * page (`modeHome("home")`). | |
| 497 | − | */ | |
| 503 | + | /** A workspace's front page: Home, the first tab, on every device. */ | |
| 498 | 504 | export function homePagePath(slug: string): string { | |
| 499 | − | return `/${slug}/-/chat`; | |
| 505 | + | return `/${slug}/-/home`; | |
| 500 | 506 | } | |
| 501 | 507 | ||
| 502 | 508 | /** | |
| 176 | 176 | // The integrations directory, and the setup pages for alerts and | |
| 177 | 177 | // trackers (lib/integration-sections). | |
| 178 | 178 | route("-/settings/integrations", "routes/workspace/integrations-directory.tsx"), | |
| 179 | + | route("-/settings/integrations/github", "routes/workspace/integration-github.tsx"), | |
| 179 | 180 | route("-/settings/integrations/:section", "routes/workspace/integrations.tsx"), | |
| 180 | 181 | // Ask g1t: what it answers with (Chat's New with no repository), and | |
| 181 | 182 | // starting a session it proposed (components/chat/agent-composer). |
| 3 | 3 | import { usePageNavigation } from "../../../lib/use-page-navigation"; | |
| 4 | 4 | ||
| 5 | 5 | import type { Route } from "./+types/index"; | |
| 6 | − | import type { Route as HomeRoute } from "../+types/home"; | |
| 7 | − | import { HomePage, loader as homeLoader } from "../home"; | |
| 8 | 6 | import { ChatUnavailable, NoChannels } from "../../../components/chat/empty"; | |
| 9 | 7 | import { ChatSidebar } from "../../../components/chat/sidebar"; | |
| 10 | 8 | import { NewChatButton, useChatData } from "../../../components/chat/actions"; | |
| ⋯ | |||
| 13 | 11 | import { LAST_CHAT_COOKIE, sidebarOrNull } from "../../../lib/chat.server"; | |
| 14 | 12 | import { readCookie } from "../../../lib/mission"; | |
| 15 | 13 | import { page } from "../../../lib/meta"; | |
| 16 | − | import { requireUser, roleIn } from "../../../lib/session.server"; | |
| 14 | + | import { requireUser } from "../../../lib/session.server"; | |
| 17 | 15 | ||
| 18 | 16 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 19 | 17 | return page(args, { title: `Chat · ${params.owner} · g1t` }); | |
| 20 | 18 | } | |
| 21 | 19 | ||
| 22 | 20 | /** | |
| 23 | − | * Chat is the app a workspace opens on, and on a computer it opens on Home: | |
| 24 | − | * where you're needed and what happened while you were away (routes/ | |
| 25 | − | * workspace/home.tsx), with your conversations and sessions beside it. A | |
| 26 | − | * phone opens on the list of conversations, and keeps Home as a tab of its | |
| 27 | − | * own. Someone in another workspace's chat as a guest has no Home there: | |
| 28 | − | * Chat opens where they left it, else #general, else the first there is. | |
| 21 | + | * Chat opens on the conversation you were in, else #general, else the | |
| 22 | + | * first there is; a phone opens on the list of conversations. Home, the | |
| 23 | + | * workspace's front page, is a tab of its own (routes/workspace/home.tsx). | |
| 29 | 24 | */ | |
| 30 | − | export async function loader(args: Route.LoaderArgs) { | |
| 31 | − | const { params, context, request } = args; | |
| 25 | + | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 32 | 26 | const viewer = requireUser(context, request); | |
| 33 | 27 | const slug = params.owner.toLowerCase(); | |
| 34 | 28 | const phone = isPhone(request.headers); | |
| 35 | − | if (!phone && roleIn(viewer, slug)) { | |
| 36 | − | const home = await homeLoader(args as unknown as HomeRoute.LoaderArgs); | |
| 37 | − | return { home, unavailable: false, phone, to: null }; | |
| 38 | − | } | |
| 39 | 29 | const last = readCookie(request.headers.get("cookie"), LAST_CHAT_COOKIE); | |
| 40 | 30 | const remembered = last && last.startsWith(`/${slug}/-/chat/`) && !last.includes("..") ? last : null; | |
| 41 | 31 | if (remembered && !phone) throw redirect(remembered); | |
| 42 | 32 | const sidebar = await sidebarOrNull(slug, viewer); | |
| 43 | − | if (!sidebar) return { home: null, unavailable: true, phone, to: null }; | |
| 33 | + | if (!sidebar) return { unavailable: true, phone, to: null }; | |
| 44 | 34 | const entries = sidebar.entries; | |
| 45 | 35 | const first = | |
| 46 | 36 | entries.find((entry) => entry.channel.kind === "channel" && entry.channel.name === "general") ?? | |
| ⋯ | |||
| 48 | 38 | entries[0]; | |
| 49 | 39 | const to = remembered ?? (first ? channelPath(slug, first.channel) : null); | |
| 50 | 40 | if (to && !phone) throw redirect(to); | |
| 51 | − | return { home: null, unavailable: false, phone, to }; | |
| 41 | + | return { unavailable: false, phone, to }; | |
| 52 | 42 | } | |
| 53 | 43 | ||
| 54 | 44 | export default function ChatIndex({ loaderData, params }: Route.ComponentProps) { | |
| 55 | 45 | const { navigate } = usePageNavigation(); | |
| 56 | − | const { to, home } = loaderData; | |
| 46 | + | const { to } = loaderData; | |
| 57 | 47 | const slug = params.owner.toLowerCase(); | |
| 58 | 48 | const data = useChatData(); | |
| 59 | − | // Told it was a phone, but wider: on to Home, or the conversation, after all. | |
| 49 | + | // Told it was a phone, but wider: on to the conversation after all. | |
| 60 | 50 | useEffect(() => { | |
| 61 | − | if (!home && window.matchMedia("(min-width: 768px)").matches) navigate(to ?? `/${slug}/-/home`, { replace: true }); | |
| 62 | − | }, [home, to, slug, navigate]); | |
| 63 | − | // Home drawn here takes its data as a prop: its route's own export would read this route's data instead. | |
| 64 | − | // A page of its own per workspace, so nothing of the last one is held over after switching. | |
| 65 | − | if (home) return <HomePage key={slug} data={home} />; | |
| 51 | + | if (to && window.matchMedia("(min-width: 768px)").matches) navigate(to, { replace: true }); | |
| 52 | + | }, [to, navigate]); | |
| 66 | 53 | return ( | |
| 67 | 54 | <> | |
| 68 | 55 | {/* A phone: the list of conversations, full screen. */} | |
| 1 | 1 | import { Suspense, useEffect, useState } from "react"; | |
| 2 | − | import { Await, data, redirect } from "react-router"; | |
| 2 | + | import { Await, data } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import { type WorkspaceSession, hasCodeAccess } from "@g1t/contracts"; | |
| 5 | 5 | ||
| ⋯ | |||
| 84 | 84 | import { billing, identity, inbox, work } from "../../lib/services.server"; | |
| 85 | 85 | import { managesBilling, requireUser, roleIn } from "../../lib/session.server"; | |
| 86 | 86 | import { canItem } from "../../lib/work-access"; | |
| 87 | − | import { isPhone } from "../../lib/phone"; | |
| 88 | 87 | import { Boundary } from "../../components/boundary"; | |
| 89 | 88 | ||
| 90 | 89 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| ⋯ | |||
| 134 | 133 | const now = Date.now(); | |
| 135 | 134 | const tz = readCookie(request.headers.get("cookie"), "g1t_tz"); | |
| 136 | 135 | const url = new URL(request.url); | |
| 137 | − | // On a computer Home is Chat's landing (routes/workspace/chat/index.tsx): its own address leads there. | |
| 138 | − | if (url.pathname.toLowerCase().endsWith(`/${slug}/-/home`) && !isPhone(request.headers)) { | |
| 139 | − | url.searchParams.delete("_routes"); | |
| 140 | − | const query = url.searchParams.toString(); | |
| 141 | − | throw redirect(`/${slug}/-/chat${query ? `?${query}` : ""}`); | |
| 142 | − | } | |
| 143 | 136 | const windowKey = parseWindow(url.searchParams.get("window")); | |
| 144 | 137 | // Getting started, for an owner: streamed, and opened by itself until dismissed (`?setup` opens it anyway). | |
| 145 | 138 | const setupAsked = url.searchParams.has("setup"); | |
| ⋯ | |||
| 414 | 407 | }, [slug, loadedAt]); | |
| 415 | 408 | } | |
| 416 | 409 | ||
| 417 | − | /** Home's page, from its loader's data: its own route draws it, and so does Chat's first page (routes/workspace/chat/index.tsx). */ | |
| 410 | + | /** Home's page, from its loader's data. */ | |
| 418 | 411 | export default function Home({ loaderData }: Route.ComponentProps) { | |
| 419 | − | return <HomePage data={loaderData} />; | |
| 412 | + | return <HomePage key={loaderData.slug} data={loaderData} />; | |
| 420 | 413 | } | |
| 421 | 414 | ||
| 422 | 415 | /** | |
| 423 | − | * Home itself, taking its data as a prop. The route's default export is | |
| 424 | − | * wrapped by the router, which hands it the data of the route it is drawn | |
| 425 | − | * in: drawn inside another route (Chat's first page), that would be the | |
| 426 | − | * other route's data, so that route draws this instead. | |
| 416 | + | * Home itself, taking its data as a prop. Keyed by the workspace above, so | |
| 417 | + | * nothing of the last one is held over after switching. | |
| 427 | 418 | */ | |
| 428 | 419 | export function HomePage({ data: loaderData }: { data: Route.ComponentProps["loaderData"] }) { | |
| 429 | 420 | const { slug, now, tz, span, digest, happened, attention: waiting, away, live, head, spend, signal } = loaderData; | |
| ⋯ | |||
| 496 | 487 | </HeldSuspense> | |
| 497 | 488 | )} | |
| 498 | 489 | ||
| 499 | − | {/* 4. The workspace's ground truth: what is moving and waiting team by team, and the quiet changes; spend for billing. */} | |
| 500 | − | {loaderData.ground && ( | |
| 501 | − | <HeldSuspense id={`home:${slug}:ground`} fallback={<GroundSkeleton />}> | |
| 502 | − | <Await resolve={loaderData.ground}> | |
| 503 | − | {(value) => | |
| 504 | − | value && ( | |
| 505 | − | <Boundary what="What the work says"> | |
| 506 | − | <GroundTruth slug={slug} ground={value} pulse={false} money={loaderData.money} /> | |
| 507 | − | </Boundary> | |
| 508 | − | ) | |
| 509 | − | } | |
| 510 | − | </Await> | |
| 511 | − | </HeldSuspense> | |
| 512 | − | )} | |
| 513 | − | ||
| 514 | 490 | <div className="grid grid-cols-[minmax(0,1fr)] items-start gap-5 lg:grid-cols-[minmax(0,1fr)_24rem]"> | |
| 515 | − | {/* 5. What happened in the span: in a sentence, then in plain English, then who did what, then what landed. */} | |
| 491 | + | {/* 4. What happened in the span: in a sentence, then in plain English, then who did what, then what landed. */} | |
| 516 | 492 | <section aria-labelledby="since" className="rounded-2xl border border-line bg-surface"> | |
| 517 | 493 | <header className="flex flex-wrap items-center gap-x-3 gap-y-2 px-5 pt-4"> | |
| 518 | 494 | <div className="flex min-w-0 flex-wrap items-baseline gap-x-3 gap-y-1"> | |
| ⋯ | |||
| 571 | 547 | </HeldSuspense> | |
| 572 | 548 | </section> | |
| 573 | 549 | ||
| 574 | − | {/* 6. What the span cost (for billing), and what settled by itself: kept in view beside it. */} | |
| 550 | + | {/* 5. What the span cost (for billing), and what settled by itself: kept in view beside it. */} | |
| 575 | 551 | <aside aria-label="Cost and settled" className="space-y-5 lg:sticky lg:top-4"> | |
| 576 | 552 | {spend && ( | |
| 577 | 553 | <HeldSuspense | |
| ⋯ | |||
| 610 | 586 | </aside> | |
| 611 | 587 | </div> | |
| 612 | 588 | ||
| 589 | + | {/* 6. The workspace: what is moving and waiting team by team, and the quiet changes; spend for billing. Drawn as Home's cards. */} | |
| 590 | + | {loaderData.ground && ( | |
| 591 | + | <HeldSuspense id={`home:${slug}:ground`} fallback={<GroundSkeleton />}> | |
| 592 | + | <Await resolve={loaderData.ground}> | |
| 593 | + | {(value) => | |
| 594 | + | value && ( | |
| 595 | + | <Boundary what="What the work says"> | |
| 596 | + | <GroundTruth slug={slug} ground={value} pulse={false} money={loaderData.money} compact /> | |
| 597 | + | </Boundary> | |
| 598 | + | ) | |
| 599 | + | } | |
| 600 | + | </Await> | |
| 601 | + | </HeldSuspense> | |
| 602 | + | )} | |
| 603 | + | ||
| 613 | 604 | {/* 7. The workspace at a glance: who is in it, and the month's usage against its plan (for billing). */} | |
| 614 | 605 | <div className={`grid grid-cols-[minmax(0,1fr)] items-start gap-5 ${loaderData.usage ? "lg:grid-cols-[minmax(0,1fr)_24rem]" : "lg:grid-cols-2"}`}> | |
| 615 | 606 | {loaderData.usage && ( | |
| 1 | + | import { ArrowUpRight, Download } from "lucide-react"; | |
| 2 | + | import { Link } from "react-router"; | |
| 3 | + | ||
| 4 | + | import type { Route } from "./+types/integration-github"; | |
| 5 | + | import { Unavailable } from "../../components/boundary"; | |
| 6 | + | import { ConnectorMark } from "../../components/connectors"; | |
| 7 | + | import { ButtonLink } from "../../components/ui"; | |
| 8 | + | import { Badge } from "../../components/ui/badge"; | |
| 9 | + | import { Card } from "../../components/ui/card"; | |
| 10 | + | import { githubApp } from "../../lib/github.server"; | |
| 11 | + | import { page } from "../../lib/meta"; | |
| 12 | + | import { isFailed, part } from "../../lib/partial"; | |
| 13 | + | import { requireUser, roleIn } from "../../lib/session.server"; | |
| 14 | + | import { settingsPath } from "../../lib/workspace-nav"; | |
| 15 | + | ||
| 16 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 17 | + | return page(args, { title: `GitHub · Integrations · ${params.owner} · g1t` }); | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | /** | |
| 21 | + | * GitHub, as one of the workspace's integrations: the accounts g1t's | |
| 22 | + | * GitHub App is installed on for it, and the way to import their | |
| 23 | + | * repositories. Opened from Apps and the integrations directory, it stays | |
| 24 | + | * among the integrations; importing is the step that goes on to Code. | |
| 25 | + | */ | |
| 26 | + | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 27 | + | const viewer = requireUser(context, request); | |
| 28 | + | const role = roleIn(viewer, params.owner); | |
| 29 | + | if (!role) throw new Response(null, { status: 404 }); | |
| 30 | + | const slug = params.owner.toLowerCase(); | |
| 31 | + | const status = await part(githubApp.status(viewer, slug), "The GitHub App"); | |
| 32 | + | return { | |
| 33 | + | slug, | |
| 34 | + | owner: role === "owner", | |
| 35 | + | status: isFailed(status) ? status : status.ok ? status.value : null, | |
| 36 | + | }; | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | export default function GithubIntegration({ loaderData }: Route.ComponentProps) { | |
| 40 | + | const { slug, owner, status } = loaderData; | |
| 41 | + | const importPath = `/new/github?workspace=${slug}`; | |
| 42 | + | return ( | |
| 43 | + | <div className="max-w-3xl space-y-8"> | |
| 44 | + | <header className="flex items-start gap-4"> | |
| 45 | + | <ConnectorMark view={{ id: "github", name: "GitHub", provider: null }} size={44} /> | |
| 46 | + | <div className="min-w-0 grow"> | |
| 47 | + | <h1 className="text-2xl font-semibold tracking-tight">GitHub</h1> | |
| 48 | + | <p className="mt-1 text-sm text-muted"> | |
| 49 | + | Import repositories, with their branches, tags and issues, through g1t's GitHub App. Each person can also connect their own GitHub account, | |
| 50 | + | under{" "} | |
| 51 | + | <Link to="/settings/github" className="text-fg underline underline-offset-4"> | |
| 52 | + | their settings | |
| 53 | + | </Link> | |
| 54 | + | . | |
| 55 | + | </p> | |
| 56 | + | </div> | |
| 57 | + | <ButtonLink to={importPath}> | |
| 58 | + | <Download size={14} /> | |
| 59 | + | Import repositories | |
| 60 | + | </ButtonLink> | |
| 61 | + | </header> | |
| 62 | + | ||
| 63 | + | {isFailed(status) ? ( | |
| 64 | + | <Unavailable what={status.what} /> | |
| 65 | + | ) : !status ? ( | |
| 66 | + | <Unavailable what="The GitHub App" /> | |
| 67 | + | ) : !status.configured ? ( | |
| 68 | + | <Card className="p-5 text-sm text-muted">This g1t has no GitHub App set up. Whoever runs it adds one first.</Card> | |
| 69 | + | ) : ( | |
| 70 | + | <Card asChild className="overflow-hidden"> | |
| 71 | + | <section> | |
| 72 | + | <header className="flex items-center gap-2 border-b border-line px-5 py-3"> | |
| 73 | + | <h2 className="text-sm font-semibold">Installed on</h2> | |
| 74 | + | {owner && status.installUrl && ( | |
| 75 | + | <a href={status.installUrl} className="ml-auto inline-flex items-center gap-1 text-xs text-muted hover:text-fg"> | |
| 76 | + | Install on another account <ArrowUpRight size={12} /> | |
| 77 | + | </a> | |
| 78 | + | )} | |
| 79 | + | </header> | |
| 80 | + | {status.installations.length === 0 ? ( | |
| 81 | + | <p className="px-5 py-4 text-sm text-muted"> | |
| 82 | + | Not installed for {slug} yet. {owner ? "Install it on a GitHub account or organization to import its repositories." : "An owner installs it."} | |
| 83 | + | </p> | |
| 84 | + | ) : ( | |
| 85 | + | <ul className="divide-y divide-line"> | |
| 86 | + | {status.installations.map((installation) => ( | |
| 87 | + | <li key={installation.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 px-5 py-3 text-sm"> | |
| 88 | + | <span className="font-medium text-fg">{installation.account}</span> | |
| 89 | + | <span className="text-xs text-faint"> | |
| 90 | + | {installation.accountType === "Organization" ? "Organization" : "Account"} ·{" "} | |
| 91 | + | {installation.repositorySelection === "all" ? "every repository" : "chosen repositories"} | |
| 92 | + | </span> | |
| 93 | + | {installation.suspended && <Badge size="md">suspended on GitHub</Badge>} | |
| 94 | + | <span className="ml-auto flex items-center gap-3 text-xs"> | |
| 95 | + | <Link to={`${importPath}&installation=${installation.id}`} className="text-muted hover:text-fg"> | |
| 96 | + | Import from it | |
| 97 | + | </Link> | |
| 98 | + | <a href={installation.settingsUrl} className="inline-flex items-center gap-1 text-muted hover:text-fg"> | |
| 99 | + | Manage on GitHub <ArrowUpRight size={12} /> | |
| 100 | + | </a> | |
| 101 | + | </span> | |
| 102 | + | </li> | |
| 103 | + | ))} | |
| 104 | + | </ul> | |
| 105 | + | )} | |
| 106 | + | </section> | |
| 107 | + | </Card> | |
| 108 | + | )} | |
| 109 | + | ||
| 110 | + | <p className="text-xs text-faint"> | |
| 111 | + | <Link to={settingsPath(slug, "integrations")} className="hover:text-fg"> | |
| 112 | + | All integrations | |
| 113 | + | </Link> | |
| 114 | + | </p> | |
| 115 | + | </div> | |
| 116 | + | ); | |
| 117 | + | } |
| 45 | 45 | } | |
| 46 | 46 | ||
| 47 | 47 | export default defineConfig({ | |
| 48 | + | // In CI, warnings and errors only: the list of every chunk the build | |
| 49 | + | // writes runs to hundreds of lines, and a job's log is uploaded line by | |
| 50 | + | // line before the job is done. | |
| 51 | + | logLevel: process.env.CI ? "warn" : undefined, | |
| 48 | 52 | define: { "import.meta.env.G1T_BUILD_ID": JSON.stringify(randomUUID()) }, | |
| 49 | 53 | plugins: [cloudflare({ viteEnvironment: { name: "ssr" } }), tailwindcss(), reactRouter(), editorChunk()], | |
| 50 | 54 | resolve: { |
| 1130 | 1130 | // builds and tsc's build info; the site's build keeps nothing. | |
| 1131 | 1131 | assert!(ci.trigger("push").unwrap().branches.allows("main")); | |
| 1132 | 1132 | assert!(ci.trigger("pull_request").is_some()); | |
| 1133 | + | assert!(ci.trigger("merge_group").is_some()); | |
| 1133 | 1134 | for (id, on_push) in [("rust", true), ("typescript", true), ("build", false)] { | |
| 1134 | 1135 | let job = ci.jobs.iter().find(|j| j.id == id).unwrap(); | |
| 1135 | − | for (event, expected) in [("push", on_push), ("pull_request", true)] { | |
| 1136 | + | for (event, expected) in [ | |
| 1137 | + | ("push", on_push), | |
| 1138 | + | ("pull_request", true), | |
| 1139 | + | ("merge_group", true), | |
| 1140 | + | ] { | |
| 1136 | 1141 | let mut contexts = Map::new(); | |
| 1137 | 1142 | contexts.insert( | |
| 1138 | 1143 | "github".into(), | |
| ⋯ | |||
| 1433 | 1438 | // A part with nothing to test does nothing more, and passes. | |
| 1434 | 1439 | assert!(!runs_with(step, &none, Status::Success), "{name}"); | |
| 1435 | 1440 | assert!(!runs_with(step, &none, Status::Failure), "{name}"); | |
| 1441 | + | // Built only for a push the merge queue tested (below). | |
| 1442 | + | if name == "Tests, compiled only" { | |
| 1443 | + | continue; | |
| 1444 | + | } | |
| 1436 | 1445 | assert!(runs_with(step, &all, Status::Success), "{name}"); | |
| 1437 | 1446 | // Only a build of every crate of the part is pruned and saved. | |
| 1438 | 1447 | let keeps = name == "Only this build's files are saved" || saves(step) && step.id.as_deref() != Some("test-build") && step.raw["with"]["key"].as_str().is_some_and(|k| k.starts_with("cargo-test-")); | |
| ⋯ | |||
| 1444 | 1453 | for path in ["\"Cargo.lock\"", "\".g1t/workflows/ci.yml\"", "\"scripts/sccache.sh\"", "\"scripts/deploy/ci-affected.mjs\""] { | |
| 1445 | 1454 | assert!(script.contains(path), "{path}"); | |
| 1446 | 1455 | } | |
| 1447 | − | // Anything but a pull request tests every crate. | |
| 1448 | − | assert!(script.contains("process.env.GITHUB_EVENT_NAME !== \"pull_request\"")); | |
| 1456 | + | // Anything but a pull request or a merge queue state tests every crate. | |
| 1457 | + | assert!(script.contains("if (event === \"pull_request\") {")); | |
| 1458 | + | assert!(script.contains("if (event === \"merge_group\") {")); | |
| 1459 | + | assert!(script.contains("return { why: `a ${event ?? \"local\"} run tests every crate` };")); | |
| 1460 | + | } | |
| 1461 | + | ||
| 1462 | + | #[test] | |
| 1463 | + | fn a_push_the_merge_queue_tested_keeps_mains_caches_and_runs_no_tests_again() { | |
| 1464 | + | let ci = read("ci.yml"); | |
| 1465 | + | // Its token may read runs: ci-affected.mjs asks whether the push's | |
| 1466 | + | // commit passed a merge_group run of CI. | |
| 1467 | + | assert_eq!(ci.raw["permissions"]["actions"], json!("read")); | |
| 1468 | + | let rust = ci.jobs.iter().find(|j| j.id == "rust").unwrap(); | |
| 1469 | + | let affected = rust | |
| 1470 | + | .steps | |
| 1471 | + | .iter() | |
| 1472 | + | .find(|s| s.id.as_deref() == Some("affected")) | |
| 1473 | + | .unwrap(); | |
| 1474 | + | assert_eq!( | |
| 1475 | + | affected.raw["env"]["GITHUB_TOKEN"], | |
| 1476 | + | json!("${{ github.token }}") | |
| 1477 | + | ); | |
| 1478 | + | let outputs = |tested: bool| { | |
| 1479 | + | json!({ "steps": { | |
| 1480 | + | "affected": { "outputs": { "crates": "-p g1t-kit -p g1t", "whole": "true", "tested": tested.to_string() } }, | |
| 1481 | + | "test-build": { "outputs": { "cache-hit": "false" } }, | |
| 1482 | + | } }) | |
| 1483 | + | }; | |
| 1484 | + | let named = |name: &str| { | |
| 1485 | + | rust.steps | |
| 1486 | + | .iter() | |
| 1487 | + | .find(|s| s.name.as_deref() == Some(name)) | |
| 1488 | + | .unwrap_or_else(|| panic!("no step {name}")) | |
| 1489 | + | }; | |
| 1490 | + | let (tests, compiled) = (named("Tests"), named("Tests, compiled only")); | |
| 1491 | + | assert_eq!( | |
| 1492 | + | tests.run.as_deref(), | |
| 1493 | + | Some("cargo test ${{ steps.affected.outputs.crates }} --locked --quiet") | |
| 1494 | + | ); | |
| 1495 | + | assert_eq!( | |
| 1496 | + | compiled.run.as_deref(), | |
| 1497 | + | Some("cargo test ${{ steps.affected.outputs.crates }} --locked --quiet --no-run") | |
| 1498 | + | ); | |
| 1499 | + | for (tested, test, compile) in [(false, true, false), (true, false, true)] { | |
| 1500 | + | assert_eq!( | |
| 1501 | + | runs_with(tests, &outputs(tested), Status::Success), | |
| 1502 | + | test, | |
| 1503 | + | "tested={tested}" | |
| 1504 | + | ); | |
| 1505 | + | assert_eq!( | |
| 1506 | + | runs_with(compiled, &outputs(tested), Status::Success), | |
| 1507 | + | compile, | |
| 1508 | + | "tested={tested}" | |
| 1509 | + | ); | |
| 1510 | + | // Either way the whole build is pruned and saved for pull requests. | |
| 1511 | + | assert!(runs_with( | |
| 1512 | + | named("Only this build's files are saved"), | |
| 1513 | + | &outputs(tested), | |
| 1514 | + | Status::Success | |
| 1515 | + | )); | |
| 1516 | + | assert!(runs_with( | |
| 1517 | + | named("Save the test build"), | |
| 1518 | + | &outputs(tested), | |
| 1519 | + | Status::Success | |
| 1520 | + | )); | |
| 1521 | + | } | |
| 1522 | + | // A queue state's branch is gone once it lands: it saves no test build. | |
| 1523 | + | let mut queued = outputs(false).as_object().unwrap().clone(); | |
| 1524 | + | queued.insert("github".into(), json!({ "event_name": "merge_group" })); | |
| 1525 | + | assert!(!runs_with( | |
| 1526 | + | named("Save the test build"), | |
| 1527 | + | &Value::Object(queued), | |
| 1528 | + | Status::Success | |
| 1529 | + | )); | |
| 1530 | + | // TypeScript type checks, saving tsc's build info, and runs no tests. | |
| 1531 | + | let typescript = ci.jobs.iter().find(|j| j.id == "typescript").unwrap(); | |
| 1532 | + | let queue = typescript | |
| 1533 | + | .steps | |
| 1534 | + | .iter() | |
| 1535 | + | .position(|s| s.id.as_deref() == Some("queue")) | |
| 1536 | + | .unwrap(); | |
| 1537 | + | assert_eq!( | |
| 1538 | + | typescript.steps[queue].run.as_deref(), | |
| 1539 | + | Some("node scripts/deploy/ci-affected.mjs --tested") | |
| 1540 | + | ); | |
| 1541 | + | assert_eq!( | |
| 1542 | + | typescript.steps[queue].raw["env"]["GITHUB_TOKEN"], | |
| 1543 | + | json!("${{ github.token }}") | |
| 1544 | + | ); | |
| 1545 | + | let check = typescript | |
| 1546 | + | .steps | |
| 1547 | + | .iter() | |
| 1548 | + | .position(|s| s.run.as_deref() == Some("npm run typecheck")) | |
| 1549 | + | .unwrap(); | |
| 1550 | + | assert!(queue < check); | |
| 1551 | + | let tested = |yes: bool| json!({ "steps": { "queue": { "outputs": { "tested": yes.to_string() } } }, "github": { "event_name": "push" } }); | |
| 1552 | + | for step in &typescript.steps[check..] { | |
| 1553 | + | let name = step.name.clone().unwrap_or_default(); | |
| 1554 | + | assert!(runs_with(step, &tested(false), Status::Success), "{name}"); | |
| 1555 | + | let kept = step.run.as_deref() == Some("npm run typecheck") || saves(step); | |
| 1556 | + | assert_eq!( | |
| 1557 | + | runs_with(step, &tested(true), Status::Success), | |
| 1558 | + | kept, | |
| 1559 | + | "{name}" | |
| 1560 | + | ); | |
| 1561 | + | } | |
| 1562 | + | // The desktop job asks too, and passes without running (ci-affected.mjs). | |
| 1563 | + | let desktop = ci.jobs.iter().find(|j| j.id == "desktop").unwrap(); | |
| 1564 | + | let asks = desktop | |
| 1565 | + | .steps | |
| 1566 | + | .iter() | |
| 1567 | + | .find(|s| s.id.as_deref() == Some("affected")) | |
| 1568 | + | .unwrap(); | |
| 1569 | + | assert_eq!( | |
| 1570 | + | asks.run.as_deref(), | |
| 1571 | + | Some("node scripts/deploy/ci-affected.mjs --job desktop") | |
| 1572 | + | ); | |
| 1573 | + | assert_eq!( | |
| 1574 | + | asks.raw["env"]["GITHUB_TOKEN"], | |
| 1575 | + | json!("${{ github.token }}") | |
| 1576 | + | ); | |
| 1449 | 1577 | } | |
| 1 | 1 | //! Telling g1t how a job is going: its steps, its log in batches, its | |
| 2 | 2 | //! annotations, and how it ended. Every report carries the job's token. | |
| 3 | 3 | ||
| 4 | + | use std::collections::VecDeque; | |
| 5 | + | use std::sync::Arc; | |
| 4 | 6 | use std::sync::atomic::{AtomicBool, Ordering}; | |
| 7 | + | use std::sync::mpsc; | |
| 5 | 8 | use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; | |
| 6 | 9 | ||
| 7 | 10 | use anyhow::Result; | |
| 8 | 11 | use g1t_actions::mask; | |
| 9 | 12 | use serde_json::{Value, json}; | |
| 10 | 13 | ||
| 11 | − | /// How long log lines wait before they are sent: about a second, so a | |
| 12 | − | /// running step's page follows it closely, at one write to g1t a second. | |
| 14 | + | /// How long log lines wait before they are handed to the uploader: about a | |
| 15 | + | /// second, so a running step's page follows it closely, at one write to | |
| 16 | + | /// g1t a second. | |
| 13 | 17 | const FLUSH_EVERY: Duration = Duration::from_millis(1000); | |
| 14 | − | /// How much log is sent at once. | |
| 18 | + | /// How much log is handed to the uploader at once. | |
| 15 | 19 | const FLUSH_BYTES: usize = 64 * 1024; | |
| 20 | + | /// The most log the uploader sends in one report, joining the batches that | |
| 21 | + | /// waited while it was sending the last: under the 256 KiB g1t keeps of | |
| 22 | + | /// one (services/actions, `MAX_CHUNK_BYTES`). | |
| 23 | + | const SEND_BYTES: usize = 192 * 1024; | |
| 24 | + | /// How long the job's end waits for its log to reach g1t before the rest | |
| 25 | + | /// is let go and the job's conclusion sent: a slow log never holds a job, | |
| 26 | + | /// or the deploy waiting on it, for longer. | |
| 27 | + | const DRAIN_WAIT: Duration = Duration::from_secs(30); | |
| 28 | + | /// How long, past [`DRAIN_WAIT`], the job's end waits for the report in | |
| 29 | + | /// flight and its conclusion: one report's three tries at most. | |
| 30 | + | const HURRY_WAIT: Duration = Duration::from_secs(100); | |
| 16 | 31 | /// The most one step may add to the job's summary, as on GitHub. | |
| 17 | 32 | pub(crate) const MAX_SUMMARY_BYTES: usize = 1024 * 1024; | |
| 18 | 33 | /// How long the runner goes without a report before it asks whether the | |
| ⋯ | |||
| 261 | 276 | } | |
| 262 | 277 | } | |
| 263 | 278 | ||
| 279 | + | /// What the log hands its uploader. | |
| 280 | + | enum Outgoing { | |
| 281 | + | /// A batch of step `.0`'s log. | |
| 282 | + | Log(u32, String), | |
| 283 | + | /// A report about the job (its steps, an annotation, a summary, a | |
| 284 | + | /// ping), sent ahead of any log still waiting. | |
| 285 | + | Report(Value), | |
| 286 | + | /// The job's conclusion, sent once everything before it has been. | |
| 287 | + | Done(Value), | |
| 288 | + | /// Answered once everything handed over before it has been sent. | |
| 289 | + | #[cfg(test)] | |
| 290 | + | Settled(mpsc::Sender<()>), | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | /// Sends the job's reports to g1t on a thread of its own, so a step never | |
| 294 | + | /// waits on one. Each report used to be sent where it was made, and a step | |
| 295 | + | /// that printed quickly to a g1t that took a second to answer waited that | |
| 296 | + | /// second for every line (the batch's second had passed again by the time | |
| 297 | + | /// the last one was answered): Deploy #511's and #531's builds, done in a | |
| 298 | + | /// minute or two, sent their output a line a second for twenty minutes, | |
| 299 | + | /// and held their deploys. Here what waits while one report is sent goes | |
| 300 | + | /// in the next together. | |
| 301 | + | struct Uploader { | |
| 302 | + | api: Api, | |
| 303 | + | /// Log batches waiting, in order. | |
| 304 | + | logs: VecDeque<(u32, String)>, | |
| 305 | + | /// Other reports waiting, in order. | |
| 306 | + | others: VecDeque<Value>, | |
| 307 | + | done: Option<Value>, | |
| 308 | + | /// Whether the log can still hand anything over. | |
| 309 | + | open: bool, | |
| 310 | + | /// Set when the job's end has waited long enough for the log: what | |
| 311 | + | /// waits of it is let go, and the conclusion sent. | |
| 312 | + | hurry: Arc<AtomicBool>, | |
| 313 | + | /// The number of the last batch sent, 1 for the job's first: each | |
| 314 | + | /// batch carries its own, and keeps it when it is sent again, so g1t | |
| 315 | + | /// keeps a batch it already has once. | |
| 316 | + | chunk: u64, | |
| 317 | + | #[cfg(test)] | |
| 318 | + | settled: Vec<mpsc::Sender<()>>, | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | impl Uploader { | |
| 322 | + | fn take(&mut self, item: Outgoing) { | |
| 323 | + | match item { | |
| 324 | + | Outgoing::Log(step, text) => self.logs.push_back((step, text)), | |
| 325 | + | Outgoing::Report(report) => self.others.push_back(report), | |
| 326 | + | Outgoing::Done(report) => self.done = Some(report), | |
| 327 | + | #[cfg(test)] | |
| 328 | + | Outgoing::Settled(answer) => self.settled.push(answer), | |
| 329 | + | } | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | /// Sends everything handed over, until the log is closed and nothing | |
| 333 | + | /// waits; the conclusion last. | |
| 334 | + | fn run(mut self, queue: mpsc::Receiver<Outgoing>) { | |
| 335 | + | loop { | |
| 336 | + | if self.logs.is_empty() && self.others.is_empty() { | |
| 337 | + | #[cfg(test)] | |
| 338 | + | for answer in self.settled.drain(..) { | |
| 339 | + | let _ = answer.send(()); | |
| 340 | + | } | |
| 341 | + | if !self.open { | |
| 342 | + | if let Some(done) = self.done.take() { | |
| 343 | + | self.api.report(done); | |
| 344 | + | } | |
| 345 | + | return; | |
| 346 | + | } | |
| 347 | + | match queue.recv() { | |
| 348 | + | Ok(item) => self.take(item), | |
| 349 | + | Err(_) => self.open = false, | |
| 350 | + | } | |
| 351 | + | continue; | |
| 352 | + | } | |
| 353 | + | // Everything that waits, so the batches go together. | |
| 354 | + | while self.open { | |
| 355 | + | match queue.try_recv() { | |
| 356 | + | Ok(item) => self.take(item), | |
| 357 | + | Err(mpsc::TryRecvError::Empty) => break, | |
| 358 | + | Err(mpsc::TryRecvError::Disconnected) => self.open = false, | |
| 359 | + | } | |
| 360 | + | } | |
| 361 | + | if self.hurry.load(Ordering::Relaxed) && !self.logs.is_empty() { | |
| 362 | + | let left: usize = self.logs.drain(..).map(|(_, text)| text.len()).sum(); | |
| 363 | + | eprintln!( | |
| 364 | + | "g1t-runner: {left} bytes of the job's log did not reach g1t in time and were let go" | |
| 365 | + | ); | |
| 366 | + | } | |
| 367 | + | if let Some(report) = self.others.pop_front() { | |
| 368 | + | self.api.report(report); | |
| 369 | + | } else if let Some(report) = self.next_batch() { | |
| 370 | + | self.api.report(report); | |
| 371 | + | } | |
| 372 | + | } | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | /// The batches that wait for the same step, joined up to | |
| 376 | + | /// [`SEND_BYTES`], numbered. | |
| 377 | + | fn next_batch(&mut self) -> Option<Value> { | |
| 378 | + | let (step, mut text) = self.logs.pop_front()?; | |
| 379 | + | while let Some((next, more)) = self.logs.front() | |
| 380 | + | && *next == step | |
| 381 | + | && text.len() + more.len() <= SEND_BYTES | |
| 382 | + | { | |
| 383 | + | text.push_str(more); | |
| 384 | + | self.logs.pop_front(); | |
| 385 | + | } | |
| 386 | + | self.chunk += 1; | |
| 387 | + | Some(json!({ "kind": "log", "step": step, "chunk": self.chunk, "text": text })) | |
| 388 | + | } | |
| 389 | + | } | |
| 390 | + | ||
| 264 | 391 | /// The job's log, masked, sent in batches. `masks` holds every form of | |
| 265 | 392 | /// every secret (`g1t_actions::mask`), longest first. | |
| 266 | 393 | pub(crate) struct Log { | |
| ⋯ | |||
| 273 | 400 | sent: Instant, | |
| 274 | 401 | /// The problem matchers steps added, which last the whole job. | |
| 275 | 402 | pub(crate) matchers: super::matchers::Matchers, | |
| 276 | − | /// The number of the last batch sent, 1 for the job's first: each | |
| 277 | − | /// batch carries its own, and keeps it when it is sent again, so g1t | |
| 278 | − | /// keeps a batch it already has once. | |
| 279 | − | chunk: u64, | |
| 403 | + | /// Where reports go to be sent (`Uploader`), until the job's end. | |
| 404 | + | queue: Option<mpsc::Sender<Outgoing>>, | |
| 405 | + | /// Hangs up when the uploader has finished. | |
| 406 | + | uploaded: Option<mpsc::Receiver<()>>, | |
| 407 | + | hurry: Arc<AtomicBool>, | |
| 280 | 408 | /// Everything sent so far, so a test reads the whole log however its | |
| 281 | 409 | /// batches fell (`buffered`). | |
| 282 | 410 | #[cfg(test)] | |
| ⋯ | |||
| 287 | 415 | pub(crate) fn new(api: Api, mut masks: Vec<String>) -> Log { | |
| 288 | 416 | masks.retain(|mask| !mask.is_empty()); | |
| 289 | 417 | masks.sort_by_key(|mask| std::cmp::Reverse(mask.len())); | |
| 418 | + | let (queue, handed) = mpsc::channel(); | |
| 419 | + | let (finished, uploaded) = mpsc::channel::<()>(); | |
| 420 | + | let hurry = Arc::new(AtomicBool::new(false)); | |
| 421 | + | let uploader = Uploader { | |
| 422 | + | api: api.clone(), | |
| 423 | + | logs: VecDeque::new(), | |
| 424 | + | others: VecDeque::new(), | |
| 425 | + | done: None, | |
| 426 | + | open: true, | |
| 427 | + | hurry: hurry.clone(), | |
| 428 | + | chunk: 0, | |
| 429 | + | #[cfg(test)] | |
| 430 | + | settled: Vec::new(), | |
| 431 | + | }; | |
| 432 | + | std::thread::spawn(move || { | |
| 433 | + | uploader.run(handed); | |
| 434 | + | drop(finished); | |
| 435 | + | }); | |
| 290 | 436 | Log { | |
| 291 | 437 | api, | |
| 292 | 438 | masks, | |
| ⋯ | |||
| 295 | 441 | last: Instant::now(), | |
| 296 | 442 | sent: Instant::now(), | |
| 297 | 443 | matchers: super::matchers::Matchers::default(), | |
| 298 | − | chunk: 0, | |
| 444 | + | queue: Some(queue), | |
| 445 | + | uploaded: Some(uploaded), | |
| 446 | + | hurry, | |
| 299 | 447 | #[cfg(test)] | |
| 300 | 448 | flushed: String::new(), | |
| 301 | 449 | } | |
| 302 | 450 | } | |
| 303 | 451 | ||
| 452 | + | /// Hands `item` to the uploader; nothing, once the job has ended. | |
| 453 | + | fn send(&self, item: Outgoing) { | |
| 454 | + | if let Some(queue) = &self.queue { | |
| 455 | + | let _ = queue.send(item); | |
| 456 | + | } | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | /// Waits for everything handed over to be sent: up to `within`, then | |
| 460 | + | /// lets the log that still waits go, and waits for the report in flight | |
| 461 | + | /// and the conclusion. | |
| 462 | + | fn finish(&mut self, within: Duration) { | |
| 463 | + | self.flush(); | |
| 464 | + | self.queue = None; | |
| 465 | + | let Some(uploaded) = self.uploaded.take() else { | |
| 466 | + | return; | |
| 467 | + | }; | |
| 468 | + | if let Err(mpsc::RecvTimeoutError::Timeout) = uploaded.recv_timeout(within) { | |
| 469 | + | self.hurry.store(true, Ordering::Relaxed); | |
| 470 | + | let _ = uploaded.recv_timeout(HURRY_WAIT); | |
| 471 | + | } | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | /// Waits until everything handed over so far has been sent. | |
| 475 | + | #[cfg(test)] | |
| 476 | + | fn settle(&self) { | |
| 477 | + | let (answer, settled) = mpsc::channel(); | |
| 478 | + | self.send(Outgoing::Settled(answer)); | |
| 479 | + | let _ = settled.recv_timeout(Duration::from_secs(60)); | |
| 480 | + | } | |
| 481 | + | ||
| 304 | 482 | /// The log so far, what was sent and what waits to be, without the | |
| 305 | 483 | /// time each line is stamped with. | |
| 306 | 484 | #[cfg(test)] | |
| ⋯ | |||
| 358 | 536 | self.flush(); | |
| 359 | 537 | } else if self.sent.elapsed() >= PING_EVERY { | |
| 360 | 538 | self.sent = Instant::now(); | |
| 361 | − | self.api.report(json!({ "kind": "ping" })); | |
| 539 | + | self.send(Outgoing::Report(json!({ "kind": "ping" }))); | |
| 362 | 540 | } | |
| 363 | 541 | } | |
| 364 | 542 | ||
| 543 | + | /// Hands what waits to the uploader, which sends it as soon as it can. | |
| 365 | 544 | pub(crate) fn flush(&mut self) { | |
| 366 | 545 | self.last = Instant::now(); | |
| 367 | 546 | if self.buffer.is_empty() { | |
| ⋯ | |||
| 371 | 550 | let text = std::mem::take(&mut self.buffer); | |
| 372 | 551 | #[cfg(test)] | |
| 373 | 552 | self.flushed.push_str(&text); | |
| 374 | − | let report = self.log_report(text); | |
| 375 | − | self.api.report(report); | |
| 376 | − | } | |
| 377 | − | ||
| 378 | − | /// The next batch of the log, numbered. | |
| 379 | − | fn log_report(&mut self, text: String) -> Value { | |
| 380 | − | self.chunk += 1; | |
| 381 | − | json!({ "kind": "log", "step": self.step, "chunk": self.chunk, "text": text }) | |
| 553 | + | self.send(Outgoing::Log(self.step, text)); | |
| 382 | 554 | } | |
| 383 | 555 | ||
| 384 | 556 | /// Does `work` with the job's API on a thread of its own, writing | |
| ⋯ | |||
| 417 | 589 | } | |
| 418 | 590 | ||
| 419 | 591 | pub(crate) fn steps(&self, names: &[String]) { | |
| 420 | − | self.api.report(json!({ "kind": "steps", "steps": names })); | |
| 592 | + | self.send(Outgoing::Report(json!({ "kind": "steps", "steps": names }))); | |
| 421 | 593 | } | |
| 422 | 594 | ||
| 595 | + | /// A step's state, sent ahead of any of its log still waiting: a step | |
| 596 | + | /// ends when its process does, and its log catches up after. | |
| 423 | 597 | pub(crate) fn step_state(&mut self, number: u32, name: &str, status: &str, conclusion: Option<&str>) { | |
| 424 | 598 | self.flush(); | |
| 425 | 599 | let name = self.mask(name); | |
| 426 | − | self.api.report(json!({ "kind": "step", "number": number, "name": name, "status": status, "conclusion": conclusion })); | |
| 600 | + | self.send(Outgoing::Report(json!({ "kind": "step", "number": number, "name": name, "status": status, "conclusion": conclusion }))); | |
| 427 | 601 | } | |
| 428 | 602 | ||
| 429 | 603 | /// What a step wrote to `$GITHUB_STEP_SUMMARY`, masked, for the run's | |
| ⋯ | |||
| 442 | 616 | } | |
| 443 | 617 | let markdown = self.mask(markdown); | |
| 444 | 618 | self.flush(); | |
| 445 | − | self.api.report(json!({ "kind": "summary", "step": self.step, "markdown": markdown })); | |
| 619 | + | self.send(Outgoing::Report(json!({ "kind": "summary", "step": self.step, "markdown": markdown }))); | |
| 446 | 620 | } | |
| 447 | 621 | ||
| 448 | 622 | /// An annotation, from `::error::` and its kin or a problem matcher: | |
| ⋯ | |||
| 451 | 625 | /// and `end_column`. | |
| 452 | 626 | pub(crate) fn annotation(&mut self, level: &str, message: &str, properties: &serde_json::Map<String, Value>) { | |
| 453 | 627 | let report = self.annotation_report(level, message, properties); | |
| 454 | − | self.api.report(report); | |
| 628 | + | self.send(Outgoing::Report(report)); | |
| 455 | 629 | } | |
| 456 | 630 | ||
| 457 | 631 | fn annotation_report(&self, level: &str, message: &str, properties: &serde_json::Map<String, Value>) -> Value { | |
| ⋯ | |||
| 471 | 645 | }) | |
| 472 | 646 | } | |
| 473 | 647 | ||
| 648 | + | /// The job's conclusion, sent after its log (g1t takes no more once the | |
| 649 | + | /// job is done), waiting for that up to [`DRAIN_WAIT`]. | |
| 474 | 650 | pub(crate) fn done(&mut self, conclusion: &str, outputs: &serde_json::Map<String, Value>, reason: Option<&str>) { | |
| 651 | + | self.done_within(conclusion, outputs, reason, DRAIN_WAIT); | |
| 652 | + | } | |
| 653 | + | ||
| 654 | + | fn done_within( | |
| 655 | + | &mut self, | |
| 656 | + | conclusion: &str, | |
| 657 | + | outputs: &serde_json::Map<String, Value>, | |
| 658 | + | reason: Option<&str>, | |
| 659 | + | within: Duration, | |
| 660 | + | ) { | |
| 475 | 661 | let outputs = self.withhold_secrets(outputs); | |
| 476 | 662 | self.flush(); | |
| 477 | − | self.api.report(json!({ "kind": "done", "conclusion": conclusion, "outputs": outputs, "reason": reason })); | |
| 663 | + | self.send(Outgoing::Done(json!({ "kind": "done", "conclusion": conclusion, "outputs": outputs, "reason": reason }))); | |
| 664 | + | self.finish(within); | |
| 478 | 665 | } | |
| 479 | 666 | ||
| 480 | 667 | /// The job's outputs without any that hold a secret, as on GitHub: an | |
| ⋯ | |||
| 497 | 684 | } | |
| 498 | 685 | } | |
| 499 | 686 | ||
| 687 | + | /// A log dropped before the job's end (its setup failed) still sends what | |
| 688 | + | /// it was handed. | |
| 689 | + | impl Drop for Log { | |
| 690 | + | fn drop(&mut self) { | |
| 691 | + | self.finish(DRAIN_WAIT); | |
| 692 | + | } | |
| 693 | + | } | |
| 694 | + | ||
| 500 | 695 | /// A time as GitHub's logs stamp each line: RFC 3339 in UTC, to a tenth | |
| 501 | 696 | /// of a microsecond (`2026-10-12T09:41:07.1234567Z`). | |
| 502 | 697 | pub(crate) fn stamp(at: SystemTime) -> String { | |
| ⋯ | |||
| 695 | 890 | let mut log = Log::new(Api { base, job: "job_1".into(), token: "t".into() }, Vec::new()); | |
| 696 | 891 | log.line("one"); | |
| 697 | 892 | log.flush(); | |
| 893 | + | // Sent before the next is handed over, or the two would go together. | |
| 894 | + | log.settle(); | |
| 698 | 895 | log.line("two"); | |
| 699 | 896 | log.flush(); | |
| 700 | 897 | log.flush(); // Nothing waits: no batch, and no number used. | |
| ⋯ | |||
| 709 | 906 | assert_eq!(bodies[0], bodies[1], "a batch sent again is the same batch"); | |
| 710 | 907 | } | |
| 711 | 908 | ||
| 909 | + | /// A g1t that answers each report `{}` after `slow(report)`, until the | |
| 910 | + | /// job's `done`, and hands back each report as it came. | |
| 911 | + | fn slow_server( | |
| 912 | + | slow: impl Fn(&Value) -> Duration + Send + 'static, | |
| 913 | + | ) -> (String, std::thread::JoinHandle<Vec<Value>>) { | |
| 914 | + | use std::io::{BufRead, BufReader, Read, Write}; | |
| 915 | + | let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); | |
| 916 | + | let base = format!("http://{}", listener.local_addr().unwrap()); | |
| 917 | + | let serving = std::thread::spawn(move || { | |
| 918 | + | let mut reports = Vec::new(); | |
| 919 | + | loop { | |
| 920 | + | let (stream, _) = listener.accept().unwrap(); | |
| 921 | + | let mut reader = BufReader::new(stream); | |
| 922 | + | let mut length = 0; | |
| 923 | + | loop { | |
| 924 | + | let mut header = String::new(); | |
| 925 | + | reader.read_line(&mut header).unwrap(); | |
| 926 | + | if header.trim().is_empty() { | |
| 927 | + | break; | |
| 928 | + | } | |
| 929 | + | if let Some((name, value)) = header.split_once(':') | |
| 930 | + | && name.eq_ignore_ascii_case("content-length") | |
| 931 | + | { | |
| 932 | + | length = value.trim().parse().unwrap(); | |
| 933 | + | } | |
| 934 | + | } | |
| 935 | + | let mut body = vec![0; length]; | |
| 936 | + | reader.read_exact(&mut body).unwrap(); | |
| 937 | + | let report = serde_json::from_slice::<Value>(&body).unwrap()["report"].clone(); | |
| 938 | + | std::thread::sleep(slow(&report)); | |
| 939 | + | write!( | |
| 940 | + | reader.get_mut(), | |
| 941 | + | "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{{}}" | |
| 942 | + | ) | |
| 943 | + | .unwrap(); | |
| 944 | + | let done = report["kind"] == "done"; | |
| 945 | + | reports.push(report); | |
| 946 | + | if done { | |
| 947 | + | return reports; | |
| 948 | + | } | |
| 949 | + | } | |
| 950 | + | }); | |
| 951 | + | (base, serving) | |
| 952 | + | } | |
| 953 | + | ||
| 954 | + | #[test] | |
| 955 | + | fn a_slow_g1t_never_holds_a_step_and_its_log_catches_up_in_few_reports() { | |
| 956 | + | // Each report takes 200 ms to answer, as g1t's took a second in | |
| 957 | + | // Deploy #531, where a 300-line table went a line a second. | |
| 958 | + | let (base, serving) = slow_server(|_| Duration::from_millis(200)); | |
| 959 | + | let mut log = Log::new( | |
| 960 | + | Api { | |
| 961 | + | base, | |
| 962 | + | job: "job_1".into(), | |
| 963 | + | token: "t".into(), | |
| 964 | + | }, | |
| 965 | + | Vec::new(), | |
| 966 | + | ); | |
| 967 | + | log.steps(&["Build".into()]); | |
| 968 | + | log.step(1); | |
| 969 | + | log.step_state(1, "Build", "in_progress", None); | |
| 970 | + | let started = Instant::now(); | |
| 971 | + | for n in 0..300 { | |
| 972 | + | log.line(&format!("line {n}")); | |
| 973 | + | // The step's output comes over two seconds or so: lines still | |
| 974 | + | // come while a batch is being sent. | |
| 975 | + | if n % 30 == 0 { | |
| 976 | + | log.flush(); | |
| 977 | + | std::thread::sleep(Duration::from_millis(5)); | |
| 978 | + | } | |
| 979 | + | } | |
| 980 | + | log.step_state(1, "Build", "completed", Some("success")); | |
| 981 | + | assert!( | |
| 982 | + | started.elapsed() < Duration::from_millis(200), | |
| 983 | + | "the step waited on g1t: {:?}", | |
| 984 | + | started.elapsed() | |
| 985 | + | ); | |
| 986 | + | log.done("success", &serde_json::Map::new(), None); | |
| 987 | + | let reports = serving.join().unwrap(); | |
| 988 | + | let logs: Vec<&Value> = reports | |
| 989 | + | .iter() | |
| 990 | + | .filter(|report| report["kind"] == "log") | |
| 991 | + | .collect(); | |
| 992 | + | // Far fewer reports than lines, numbered in order, with every line. | |
| 993 | + | assert!(logs.len() < 30, "{} log reports for 300 lines", logs.len()); | |
| 994 | + | let numbers: Vec<u64> = logs | |
| 995 | + | .iter() | |
| 996 | + | .map(|report| report["chunk"].as_u64().unwrap()) | |
| 997 | + | .collect(); | |
| 998 | + | assert_eq!(numbers, (1..=logs.len() as u64).collect::<Vec<_>>()); | |
| 999 | + | let text: String = logs | |
| 1000 | + | .iter() | |
| 1001 | + | .map(|report| unstamped(report["text"].as_str().unwrap())) | |
| 1002 | + | .collect(); | |
| 1003 | + | let expected: String = (0..300).map(|n| format!("line {n}\n")).collect(); | |
| 1004 | + | assert_eq!(text, expected); | |
| 1005 | + | // The step's end went no later than the last of its log, and the | |
| 1006 | + | // job's conclusion after all of it. | |
| 1007 | + | let at = |find: &dyn Fn(&Value) -> bool| reports.iter().position(find).unwrap(); | |
| 1008 | + | let completed = at(&|report| report["kind"] == "step" && report["status"] == "completed"); | |
| 1009 | + | let last_log = reports | |
| 1010 | + | .iter() | |
| 1011 | + | .rposition(|report| report["kind"] == "log") | |
| 1012 | + | .unwrap(); | |
| 1013 | + | assert!(completed <= last_log + 1, "{reports:?}"); | |
| 1014 | + | assert_eq!(reports.last().unwrap()["kind"], "done"); | |
| 1015 | + | } | |
| 1016 | + | ||
| 1017 | + | #[test] | |
| 1018 | + | fn a_log_that_cannot_keep_up_is_let_go_and_the_job_still_ends() { | |
| 1019 | + | // Step 1's log takes a second and a half to be answered; the job's | |
| 1020 | + | // end waits a tenth of a second for it, then lets step 2's go. | |
| 1021 | + | let (arrived, sending) = mpsc::channel(); | |
| 1022 | + | let (base, serving) = slow_server(move |report| { | |
| 1023 | + | if report["kind"] == "log" { | |
| 1024 | + | let _ = arrived.send(()); | |
| 1025 | + | Duration::from_millis(1500) | |
| 1026 | + | } else { | |
| 1027 | + | Duration::ZERO | |
| 1028 | + | } | |
| 1029 | + | }); | |
| 1030 | + | let mut log = Log::new( | |
| 1031 | + | Api { | |
| 1032 | + | base, | |
| 1033 | + | job: "job_1".into(), | |
| 1034 | + | token: "t".into(), | |
| 1035 | + | }, | |
| 1036 | + | Vec::new(), | |
| 1037 | + | ); | |
| 1038 | + | log.step(1); | |
| 1039 | + | log.line("one"); | |
| 1040 | + | log.flush(); | |
| 1041 | + | sending.recv_timeout(Duration::from_secs(10)).unwrap(); | |
| 1042 | + | log.step(2); | |
| 1043 | + | log.line("two"); | |
| 1044 | + | let started = Instant::now(); | |
| 1045 | + | log.done_within( | |
| 1046 | + | "success", | |
| 1047 | + | &serde_json::Map::new(), | |
| 1048 | + | None, | |
| 1049 | + | Duration::from_millis(100), | |
| 1050 | + | ); | |
| 1051 | + | assert!( | |
| 1052 | + | started.elapsed() < Duration::from_secs(10), | |
| 1053 | + | "{:?}", | |
| 1054 | + | started.elapsed() | |
| 1055 | + | ); | |
| 1056 | + | let reports = serving.join().unwrap(); | |
| 1057 | + | let kinds: Vec<(&str, u64)> = reports | |
| 1058 | + | .iter() | |
| 1059 | + | .map(|report| { | |
| 1060 | + | ( | |
| 1061 | + | report["kind"].as_str().unwrap(), | |
| 1062 | + | report["step"].as_u64().unwrap_or(0), | |
| 1063 | + | ) | |
| 1064 | + | }) | |
| 1065 | + | .collect(); | |
| 1066 | + | assert_eq!(kinds, [("log", 1), ("done", 0)]); | |
| 1067 | + | } | |
| 1068 | + | ||
| 712 | 1069 | #[test] | |
| 713 | 1070 | fn a_long_wait_says_so_in_the_log_as_it_goes() { | |
| 714 | 1071 | let mut log = log(&[]); | |
| 130 | 130 | description: "Import repositories, with their branches, tags and issues, through g1t's GitHub App.", | |
| 131 | 131 | scopes: BOTH, | |
| 132 | 132 | status: "available", | |
| 133 | − | href: { workspace: "/new/github?workspace=:workspace", personal: "/settings/github" }, | |
| 133 | + | href: { workspace: "/:workspace/-/settings/integrations/github", personal: "/settings/github" }, | |
| 134 | 134 | capabilities: ["Imports repositories"], | |
| 135 | 135 | personal: { | |
| 136 | 136 | description: "Sign in with your GitHub account, and reach the repositories you can see there to import them.", |
| 1 | − | // Which of a CI Rust part's crates a run tests. On a pull request, the | |
| 2 | − | // workspace crates whose files it changes and every workspace crate that | |
| 3 | − | // depends on them (from `cargo metadata`, through dependencies of every | |
| 4 | − | // kind); anywhere else, every one. Most pull requests touch a few crates, | |
| 5 | − | // and a part compiled and tested all of its own whatever changed. | |
| 1 | + | // Which of a CI Rust part's crates a run tests. On a pull request or a | |
| 2 | + | // merge queue state, the workspace crates whose files it changes and every | |
| 3 | + | // workspace crate that depends on them (from `cargo metadata`, through | |
| 4 | + | // dependencies of every kind); anywhere else, every one. Most pull | |
| 5 | + | // requests touch a few crates, and a part compiled and tested all of its | |
| 6 | + | // own whatever changed. | |
| 6 | 7 | // | |
| 7 | 8 | // node scripts/deploy/ci-affected.mjs -p <crate>... | |
| 8 | 9 | // node scripts/deploy/ci-affected.mjs --job desktop|build | |
| 10 | + | // node scripts/deploy/ci-affected.mjs --tested | |
| 9 | 11 | // | |
| 10 | − | // With --job it says whether a pull request needs one of CI's other jobs | |
| 11 | − | // (JOBS), as the output `run` (true or false), on the same terms: not a | |
| 12 | − | // pull request, or anything failing, runs it. | |
| 12 | + | // With --job it says whether a run needs one of CI's other jobs (JOBS), as | |
| 13 | + | // the output `run` (true or false), on the same terms: anything but a pull | |
| 14 | + | // request or a queue state, or anything failing, runs it, except a push of | |
| 15 | + | // a commit the merge queue tested (below). | |
| 13 | 16 | // | |
| 14 | 17 | // It takes the part's `-p` arguments and writes to the step's outputs | |
| 15 | 18 | // (GITHUB_OUTPUT): | |
| 16 | 19 | // | |
| 17 | 20 | // crates those of them to test, in the same order: all, some or none | |
| 18 | 21 | // whole true when that is all of them | |
| 22 | + | // tested true when the merge queue tested this commit (below): the part | |
| 23 | + | // compiles its tests, for main's caches, and runs none | |
| 19 | 24 | // | |
| 20 | 25 | // What a pull request changes is its test merge commit against its first | |
| 21 | 26 | // parent, the base branch's tip it was merged into (services/repos | |
| 22 | − | // test_merge.rs), so the checkout fetches two commits deep. When the commit | |
| 23 | − | // is not a merge (g1t could not make one and the run builds the head), or | |
| 24 | − | // anything here fails, every crate is tested. | |
| 27 | + | // test_merge.rs), so the checkout fetches two commits deep. What a merge | |
| 28 | + | // queue state changes is its commit against the default branch's tip it | |
| 29 | + | // was built on (the event's `merge_group.base_sha`), fetched when the | |
| 30 | + | // entries ahead put it deeper than that. When the commit is not a merge | |
| 31 | + | // (g1t could not make one and the run builds the head), or anything here | |
| 32 | + | // fails, every crate is tested. | |
| 33 | + | // | |
| 34 | + | // A push to main of a commit a `merge_group` run of CI passed on is one | |
| 35 | + | // the merge queue landed: it moves main to exactly the commit it tested | |
| 36 | + | // (services/work queue.rs lands by fast-forward to the tested state). Such | |
| 37 | + | // a push is `tested` (--tested writes only that), asked of the API with | |
| 38 | + | // the job's token (GITHUB_TOKEN, which needs `actions: read`). Any other | |
| 39 | + | // push, or a question that fails, is not, and CI tests everything. | |
| 25 | 40 | // | |
| 26 | 41 | // A file outside every crate's folder tests the crates that read it | |
| 27 | 42 | // (READS), or every crate when the build itself reads it (EVERYTHING), and | |
| ⋯ | |||
| 30 | 45 | // crate's source names outside its own folder is in one or the other. | |
| 31 | 46 | ||
| 32 | 47 | import { spawnSync } from "node:child_process"; | |
| 33 | − | import { appendFileSync } from "node:fs"; | |
| 48 | + | import { appendFileSync, readFileSync } from "node:fs"; | |
| 34 | 49 | import { basename, dirname, relative, sep } from "node:path"; | |
| 35 | 50 | import { fileURLToPath } from "node:url"; | |
| 36 | 51 | ||
| ⋯ | |||
| 192 | 207 | return out.stdout; | |
| 193 | 208 | } | |
| 194 | 209 | ||
| 195 | − | /** The files a pull request's test merge commit changes, or why they are not known. */ | |
| 210 | + | /** The run's event payload (GITHUB_EVENT_PATH), or an empty one. */ | |
| 211 | + | function eventPayload() { | |
| 212 | + | const path = process.env.GITHUB_EVENT_PATH; | |
| 213 | + | return path ? JSON.parse(readFileSync(path, "utf8")) : {}; | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | /** The commit a run's changes are against: a pull request's base tip, or a queue state's. */ | |
| 217 | + | function base() { | |
| 218 | + | const event = process.env.GITHUB_EVENT_NAME; | |
| 219 | + | if (event === "pull_request") { | |
| 220 | + | const parents = git("rev-list", "--parents", "-n", "1", "HEAD").trim().split(" ").slice(1); | |
| 221 | + | return parents.length === 2 ? { base: parents[0] } : { why: "HEAD is not the pull request's test merge commit" }; | |
| 222 | + | } | |
| 223 | + | if (event === "merge_group") { | |
| 224 | + | const sha = eventPayload().merge_group?.base_sha; | |
| 225 | + | if (!sha) return { why: "the merge group names no base commit" }; | |
| 226 | + | // Behind the entries ahead of this one, past the checkout's depth. | |
| 227 | + | if (spawnSync("git", ["cat-file", "-e", `${sha}^{commit}`], { cwd: ROOT }).status !== 0) { | |
| 228 | + | git("fetch", "--no-tags", "--quiet", "--depth=1", "origin", sha); | |
| 229 | + | } | |
| 230 | + | return { base: sha }; | |
| 231 | + | } | |
| 232 | + | return { why: `a ${event ?? "local"} run tests every crate` }; | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | /** The files a pull request's test merge commit, or a merge queue state, changes, or why they are not known. */ | |
| 196 | 236 | function changedFiles() { | |
| 197 | − | if (process.env.GITHUB_EVENT_NAME !== "pull_request") return { why: `a ${process.env.GITHUB_EVENT_NAME ?? "local"} run tests every crate` }; | |
| 198 | − | const parents = git("rev-list", "--parents", "-n", "1", "HEAD").trim().split(" ").slice(1); | |
| 199 | − | if (parents.length !== 2) return { why: "HEAD is not the pull request's test merge commit" }; | |
| 237 | + | const { base: from, why } = base(); | |
| 238 | + | if (!from) return { why }; | |
| 200 | 239 | // Both sides of a rename: the crate it left changed too. | |
| 201 | − | const files = git("diff", "--name-only", "--no-renames", "-z", parents[0], "HEAD").split("\0").filter(Boolean); | |
| 240 | + | const files = git("diff", "--name-only", "--no-renames", "-z", from, "HEAD").split("\0").filter(Boolean); | |
| 202 | 241 | return { files }; | |
| 203 | 242 | } | |
| 204 | 243 | ||
| 244 | + | /** The workflow whose `merge_group` runs vouch for a push. */ | |
| 245 | + | export const WORKFLOW = "ci.yml"; | |
| 246 | + | ||
| 247 | + | /** | |
| 248 | + | * Whether `runs` (the API's answer for CI's runs on `sha`) hold a | |
| 249 | + | * `merge_group` run that passed on that very commit. Each is checked, not | |
| 250 | + | * only filtered by the query: a filter the API ignored never vouches. | |
| 251 | + | */ | |
| 252 | + | export function queueTested(runs, sha) { | |
| 253 | + | const list = Array.isArray(runs) ? runs : (runs?.workflow_runs ?? []); | |
| 254 | + | return list.some( | |
| 255 | + | (run) => | |
| 256 | + | run.event === "merge_group" && (run.sha ?? run.head_sha) === sha && run.conclusion === "success" && (!run.path || run.path.split("/").pop() === WORKFLOW), | |
| 257 | + | ); | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | /** | |
| 261 | + | * Whether this run is a push of a commit the merge queue tested (see the | |
| 262 | + | * top of this file), and why. Never throws: a failure says no. | |
| 263 | + | */ | |
| 264 | + | async function tested() { | |
| 265 | + | const { GITHUB_EVENT_NAME: event, GITHUB_SHA: sha, GITHUB_API_URL: api, GITHUB_REPOSITORY: repo, GITHUB_TOKEN: token } = process.env; | |
| 266 | + | if (event !== "push") return { tested: false, why: `a ${event ?? "local"} run is not one the merge queue landed` }; | |
| 267 | + | try { | |
| 268 | + | if (!sha || !api || !repo) throw new Error("GITHUB_SHA, GITHUB_API_URL and GITHUB_REPOSITORY are needed"); | |
| 269 | + | const query = new URLSearchParams({ event: "merge_group", head_sha: sha, status: "success", per_page: "10" }); | |
| 270 | + | const response = await fetch(`${api}/repos/${repo}/actions/workflows/${WORKFLOW}/runs?${query}`, { | |
| 271 | + | headers: { accept: "application/vnd.github+json", ...(token ? { authorization: `Bearer ${token}` } : {}) }, | |
| 272 | + | signal: AbortSignal.timeout(30_000), | |
| 273 | + | }); | |
| 274 | + | if (!response.ok) throw new Error(`${WORKFLOW}'s runs: ${response.status} ${(await response.text()).slice(0, 200)}`); | |
| 275 | + | const yes = queueTested(await response.json(), sha); | |
| 276 | + | return { | |
| 277 | + | tested: yes, | |
| 278 | + | why: yes | |
| 279 | + | ? `the merge queue tested ${sha.slice(0, 12)}, so its tests are not run again` | |
| 280 | + | : `no merge_group run passed on ${sha.slice(0, 12)}: a push that did not come through the merge queue tests everything`, | |
| 281 | + | }; | |
| 282 | + | } catch (error) { | |
| 283 | + | console.log(`::warning title=Merge queue::${error.message.split("\n")[0]}; this push tests everything`); | |
| 284 | + | return { tested: false, why: "whether the merge queue tested it is not known" }; | |
| 285 | + | } | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | /** `--tested`: writes `tested`, whether the merge queue tested this push's commit. */ | |
| 289 | + | async function mainTested() { | |
| 290 | + | const { tested: yes, why } = await tested(); | |
| 291 | + | console.log(why); | |
| 292 | + | if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `tested=${yes}\n`); | |
| 293 | + | } | |
| 294 | + | ||
| 205 | 295 | /** `--job <name>`: writes `run`, whether this run needs the job. */ | |
| 206 | − | function mainJob(job) { | |
| 296 | + | async function mainJob(job) { | |
| 207 | 297 | if (!JOBS[job]) throw new Error(`no job ${job}: one of ${Object.keys(JOBS).join(", ")}`); | |
| 298 | + | const queue = await tested(); | |
| 299 | + | if (queue.tested) { | |
| 300 | + | console.log(`${queue.why}; the ${job} job passes without running`); | |
| 301 | + | if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, "run=false\n"); | |
| 302 | + | return; | |
| 303 | + | } | |
| 208 | 304 | let run = true; | |
| 209 | 305 | try { | |
| 210 | 306 | const { files, why } = changedFiles(); | |
| ⋯ | |||
| 221 | 317 | if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `run=${run}\n`); | |
| 222 | 318 | } | |
| 223 | 319 | ||
| 224 | − | function main(args) { | |
| 320 | + | async function main(args) { | |
| 225 | 321 | if (args[0] === "--job") return mainJob(args[1]); | |
| 322 | + | if (args[0] === "--tested") return mainTested(); | |
| 226 | 323 | const part = partCrates(args); | |
| 227 | 324 | let test = part; | |
| 325 | + | // A push: every crate, compiled, and tested unless the queue did. | |
| 326 | + | const queue = await tested(); | |
| 327 | + | if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `tested=${queue.tested}\n`); | |
| 328 | + | if (queue.tested) console.log(queue.why); | |
| 228 | 329 | try { | |
| 229 | 330 | const { files, why } = changedFiles(); | |
| 230 | 331 | if (files) { | |
| ⋯ | |||
| 243 | 344 | test = part; | |
| 244 | 345 | } | |
| 245 | 346 | const outputs = { crates: test.map((name) => `-p ${name}`).join(" "), whole: String(test.length === part.length) }; | |
| 246 | − | console.log(`This part tests ${test.length} of its ${part.length} crates${test.length ? `: ${test.join(", ")}` : ""}`); | |
| 347 | + | console.log( | |
| 348 | + | `This part ${queue.tested ? "compiles the tests of" : "tests"} ${test.length} of its ${part.length} crates${test.length ? `: ${test.join(", ")}` : ""}`, | |
| 349 | + | ); | |
| 247 | 350 | if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `crates=${outputs.crates}\nwhole=${outputs.whole}\n`); | |
| 248 | 351 | } | |
| 249 | 352 | ||
| 250 | − | if (process.argv[1] && basename(process.argv[1]) === "ci-affected.mjs") main(process.argv.slice(2)); | |
| 353 | + | if (process.argv[1] && basename(process.argv[1]) === "ci-affected.mjs") await main(process.argv.slice(2)); | |
| 3 | 3 | import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; | |
| 4 | 4 | import { dirname, join, relative, resolve, sep } from "node:path"; | |
| 5 | 5 | import { fileURLToPath } from "node:url"; | |
| 6 | − | import { DESKTOP, EVERYTHING, READS, affected, needs, partCrates, workspace } from "./ci-affected.mjs"; | |
| 6 | + | import { DESKTOP, EVERYTHING, READS, WORKFLOW, affected, needs, partCrates, queueTested, workspace } from "./ci-affected.mjs"; | |
| 7 | 7 | ||
| 8 | 8 | const ROOT = fileURLToPath(new URL("../..", import.meta.url)); | |
| 9 | 9 | ||
| ⋯ | |||
| 81 | 81 | assert.doesNotMatch(readFileSync(join(ROOT, "apps/desktop/src-tauri/Cargo.toml"), "utf8"), /\bpath\s*=/); | |
| 82 | 82 | }); | |
| 83 | 83 | ||
| 84 | + | test("a push is the merge queue's only when CI's merge_group run passed on that very commit", () => { | |
| 85 | + | const sha = "a".repeat(40); | |
| 86 | + | const run = { event: "merge_group", sha, conclusion: "success", path: `.g1t/workflows/${WORKFLOW}` }; | |
| 87 | + | // g1t's answer is a list; GitHub's has workflow_runs, with head_sha. | |
| 88 | + | assert.equal(queueTested([run], sha), true); | |
| 89 | + | assert.equal(queueTested({ workflow_runs: [{ event: "merge_group", head_sha: sha, conclusion: "success" }] }, sha), true); | |
| 90 | + | // Any other run never vouches for it, whatever the query asked for. | |
| 91 | + | assert.equal(queueTested([], sha), false); | |
| 92 | + | assert.equal(queueTested([{ ...run, event: "push" }], sha), false); | |
| 93 | + | assert.equal(queueTested([{ ...run, event: "pull_request" }], sha), false); | |
| 94 | + | assert.equal(queueTested([{ ...run, sha: "b".repeat(40) }], sha), false); | |
| 95 | + | assert.equal(queueTested([{ ...run, conclusion: "failure" }], sha), false); | |
| 96 | + | assert.equal(queueTested([{ ...run, conclusion: null }], sha), false); | |
| 97 | + | assert.equal(queueTested([{ ...run, path: ".g1t/workflows/deploy.yml" }], sha), false); | |
| 98 | + | assert.equal(queueTested(null, sha), false); | |
| 99 | + | assert.equal(queueTested([{ ...run, event: "push" }, run], sha), true); | |
| 100 | + | }); | |
| 101 | + | ||
| 84 | 102 | test("a part's crates are read from its -p arguments", () => { | |
| 85 | 103 | assert.deepEqual(partCrates(["-p", "g1t-kit", "-p", "g1t"]), ["g1t-kit", "g1t"]); | |
| 86 | 104 | assert.throws(() => partCrates(["-p", "g1t-kit", "g1t"])); | |
Comments
Sign in to comment.