Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

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

syntaqxcommitted Parents620d4ab048a058Browse files
21 files+1051−2230/21 viewed
+79−35
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.
58 #
69 # rust every crate's tests, natively, in three jobs at once
710 # typescript type checks and tests of the apps and TS services, the
912 # build the site, sudo and the docs build as they deploy
1013 # desktop the desktop app's checks and tests, and the release scripts'
1114 #
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.
1519 #
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).
2028 # What each cache holds, its key and which job saves it: CONTRIBUTING.md,
2129 # "How g1t's own CI caches". The Caches workflow (caches.yml) keeps the
2230 # ones only releases build.
2533 on:
2634 pull_request:
2735 branches: [main]
36+ merge_group:
2837 push:
2938 branches: [main]
3039 workflow_dispatch:
3140
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).
3343 permissions:
3444 contents: read
45+ actions: read
3546
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.
3650 concurrency:
3751 group: ci-${{ github.ref }}
3852 cancel-in-progress: true
4862 # the libraries, but a test binary is linked and never cached), and the
4963 # suites then ran one after another. The parts are about even in Rust;
5064 # 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.
5469 rust:
5570 name: Rust (${{ matrix.part }})
5671 runs-on: ubuntu-latest
7994 steps:
8095 # Two commits deep: a pull request's run builds its test merge commit,
8196 # 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.
8399 - uses: actions/checkout@v5
84100 with:
85101 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.
89107 - name: What this part tests
90108 id: affected
109+ env:
110+ GITHUB_TOKEN: ${{ github.token }}
91111 run: node scripts/deploy/ci-affected.mjs ${{ matrix.crates }}
92112 # rust: the toolchain; cargo: Cargo.lock's crates.io and git packages,
93113 # not the workspace's own versions (scripts/deploy/ci-cache.mjs).
136156 if: ${{ steps.affected.outputs.crates }}
137157 run: bash scripts/sccache.sh install
138158 - name: Tests
139− if: ${{ steps.affected.outputs.crates }}
159+ if: ${{ steps.affected.outputs.crates && steps.affected.outputs.tested != 'true' }}
140160 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
141166 # A restored target keeps the old versions of whatever changed beside
142167 # 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
144169 # that tested some built only those, and saved under the key a whole
145170 # build is found by, its entry would be a hit for the runs after it,
146171 # 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.
149175 - 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' }}
151177 run: node scripts/deploy/ci-cache.mjs prune target/debug -- test ${{ matrix.crates }} --locked --no-run
152178 - 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' }}
154180 uses: actions/cache/save@v4
155181 with:
156182 path: |
167193 timeout-minutes: 30
168194 steps:
169195 - 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
170203 # npm's downloads, kept between runs: an install reads them first
171204 # and asks the registry only for what changed, so a dropped
172205 # connection to it rarely stops the job.
211244 packages/*/*.tsbuildinfo
212245 key: tsc-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-${{ github.sha }}
213246 - name: Tests
247+ if: ${{ steps.queue.outputs.tested != 'true' }}
214248 run: npm test --workspaces --if-present
215249 - name: The deploy tool's tests
250+ if: ${{ steps.queue.outputs.tested != 'true' }}
216251 run: npm run test:deploy
217252 - name: The conformance report's tests
253+ if: ${{ steps.queue.outputs.tested != 'true' }}
218254 run: npm run test:conformance
219255 - name: The ops scripts' tests
256+ if: ${{ steps.queue.outputs.tested != 'true' }}
220257 run: npm run test:ops
221258 - name: The manifest matches every wrangler.jsonc
259+ if: ${{ steps.queue.outputs.tested != 'true' }}
222260 run: node scripts/deploy.mjs manifest --check
223261 - name: The Claude Code plugin's tests
262+ if: ${{ steps.queue.outputs.tested != 'true' }}
224263 run: node --test plugins/claude-code/test/hooks.test.mjs
225264 # What plugins-release.yml publishes to flagon-io/g1t-plugins: the
226265 # manifests agree, and the tree is only the plugin.
227266 - name: The plugin's published marketplace
267+ if: ${{ steps.queue.outputs.tested != 'true' }}
228268 run: |
229269 node scripts/plugins-release.mjs check
230270 node --test scripts/plugins-release.test.mjs
235275 runs-on: ubuntu-latest
236276 timeout-minutes: 30
237277 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).
241281 - uses: actions/checkout@v5
242282 with:
243283 fetch-depth: 2
244− - name: Whether this pull request needs it
284+ - name: Whether this change needs it
245285 id: affected
246286 run: node scripts/deploy/ci-affected.mjs --job build
247287 # npm's downloads, kept between runs: an install reads them first
268308 runs-on: ubuntu-latest
269309 timeout-minutes: 30
270310 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.
274315 - uses: actions/checkout@v5
275316 with:
276317 fetch-depth: 2
277− - name: Whether this pull request needs it
318+ - name: Whether this change needs it
278319 id: affected
320+ env:
321+ GITHUB_TOKEN: ${{ github.token }}
279322 run: node scripts/deploy/ci-affected.mjs --job desktop
280323 - name: Cache keys
281324 id: keys
290333 if: ${{ steps.affected.outputs.run == 'true' }}
291334 run: bash scripts/apt-install.sh tauri --check
292335 # 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.
295339 - name: Cache apt's downloads
296340 if: ${{ steps.affected.outputs.run == 'true' && steps.tauri-apt.outputs.installed != 'true' }}
297341 uses: actions/cache@v4
+23−12
184184 100 GiB (10 GiB in a workspace on the Free plan), past which the entries
185185 restored longest ago go. So:
186186
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.
191200 - **Rust caches are keyed by the toolchain and the dependencies**, from
192201 `node scripts/deploy/ci-cache.mjs keys`: `rustc -V`, and a hash of the
193202 packages `Cargo.lock` takes from crates.io and git. A release bumping a
197206 saved** (`ci-cache.mjs prune`), to what this build used.
198207 - **A partial install never saves a whole-install key.** npm's downloads
199208 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.
205216 - **One set of paths per entry.** An entry's version is the hash of its
206217 paths, so every step that names it, in every workflow, names the same
207218 ones. `crates/actions/tests/repository_workflows.rs` holds all of this.
232243 | -------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------- | --------------------------- |
233244 | npm's downloads | `npm-full-<os>-<package-lock.json>` | Caches (`npm`) | every npm install |
234245 | 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 |
236247 | tsc's build info | `tsc-<os>-<package-lock.json>-<commit>` (every `*.tsbuildinfo`) | CI TypeScript on `main` | CI TypeScript |
237248 | Workers' builds | `cargo-workers-<os>-<rust>-<group>-<deps>` | Deploy | Deploy |
238249 | The runner's image build | `runner-musl-<os>-<rust>-<deps>` | Deploy | Deploy |
239250 | worker-build and its tools | `worker-build-…`, `worker-build-tools-…` | Deploy | Deploy |
240251 | 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 |
243254 | The runner release | `runner-release-<os>-<target>-<rust>-<deps>`, zig | Caches (`runner`) | the runner release |
244255 | The desktop release | `desktop-release-linux-…`, `desktop-release-windows-…`, `xwin-…`, `apt-tauri-windows-…` | Caches (`desktop-linux`, `desktop-windows`) | the desktop release |
245256 | The CLI release | `cli-release-<os>-<zigbuild>-<deps>` (`target/zig`, every platform) | Caches (`cli`), built whole | the CLI release |
+8−9
33 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.
44 ---
55
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.
109 It reads top to bottom, from what you can do now to what already happened:
1110
1211 1. **What are we building today?**: a box that [asks g1t](#ask-from-home)
2019 the last two weeks day by day (what agents and people landed), and what
2120 is [worth a look](#worth-a-look), each finding with its evidence and
2221 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
2723 did, what changed in plain English when you ask for it, and what landed,
2824 each change with whether it is live yet. Beside it, kept in view as you
2925 scroll: **[Spent](#spent)**, what the span cost and the month so far
3026 (owners and billing managers), and
3127 **[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.
3231 6. **Members**, everyone in the workspace, and, for owners and billing
3332 managers, the month's **Usage** against the plan.
3433
370369
371370 ## The workspace
372371
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
374373 [ground truth](/guides/workspaces/#the-workspace-overview), what is moving
375374 and what is waiting team by team (each repository, without teams), with
376375 the pull requests waiting on a first review, and the quiet changes in who
+7−2
6262 | Monitoring & incidents | [Sentry, Datadog, and any system that sends a webhook](#alerts) | |
6363
6464 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.
6772
6873 Secrets are sealed when they are saved and never shown again, to anyone.
6974 A setup page shows the last four characters of a key, so you can tell keys
+13−12
775775
776776 Each page has three parts:
777777
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. |
783783
784784 ### The app bar
785785
787787 each a tab with what is unread on it (named from 1280px wide, an icon with
788788 its name on hover below that):
789789
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. |
797798
798799 After them come the apps you pinned, then:
799800
+82−29
2727 );
2828 }
2929
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+ }
3263 return (
3364 <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">
3465 <p className="font-mono text-[0.6875rem] tracking-[0.16em] text-accent uppercase">{eyebrow}</p>
77108 * week). `money` shows the spend threaded to the work, which is in dollars:
78109 * only for those who look after the workspace's billing.
79110 */
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);
81126 return (
82127 <div className="space-y-6">
83128 {pulse && (
110155 )}
111156 <Section
112157 id="rhythm"
158+ compact={compact}
113159 eyebrow="What's moving and what's waiting"
114160 title={ground.rhythm?.by === "project" ? "Each repository's last two weeks" : "Each team's last two weeks"}
115161 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."
150196 </Section>
151197 <Section
152198 id="quiet"
199+ compact={compact}
153200 eyebrow="Quiet changes"
154201 title="Who works where, and what is spreading"
155202 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."
156203 >
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+ )}
183236 </Section>
184237 </div>
185238 );
+3−1
151151 pagePath,
152152 repositoriesPath,
153153 workspaceSettingsPage,
154+ isIntegrationsPath,
154155 } from "../lib/workspace-nav";
155156 import { useSidebarLocation } from "../lib/use-sidebar-location";
156157 import { setupPath } from "../lib/onboarding";
23802381 username: user.username,
23812382 });
23822383 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);
23842386 const { sidebar: chatSidebar } = useChatSidebar();
23852387 // Notify: the feed socket's live counts (lib/notify-client.ts) once it has them; the page's until then.
23862388 const live = useLiveBadges(ws?.slug);
+4−4
3939 );
4040 });
4141
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"]);
4444 // 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"]);
4646 });
4747
4848 test("a phone's bar is Home, Work, Chat and Notifications, with Code first of More's apps", () => {
8686 apps.map((app) => app.key),
8787 ["int-github", "int-sentry"],
8888 );
89− assert.equal(apps[0]!.path("acme"), "/new/github?workspace=acme");
89+ assert.equal(apps[0]!.path("acme"), "/acme/-/settings/integrations/github");
9090 assert.deepEqual(appData(apps[1]!, "acme").href, "/acme/-/settings/integrations/alerts");
9191 assert.deepEqual(installedApps({}), []);
9292 assert.deepEqual(
+5−5
5555 ];
5656
5757 /**
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
6262 * workspaces) follow them as icons; People and Workspace are in the
6363 * workspace's menu.
6464 */
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[];
6666
6767 export type BarApp = (typeof BAR_APPS)[number];
6868
+5−4
129129 assert.equal(modeHome("apps", "acme"), "/acme/-/apps");
130130 assert.equal(modeHome("code", "acme"), "/acme/-/overview");
131131 assert.equal(modeHome("workspace", "acme"), "/acme/-/settings/profile");
132− assert.equal(homePath("acme"), "/acme/-/chat");
132+ assert.equal(homePath("acme"), "/acme/-/home");
133133 assert.equal(homePath("acme", "?tab=landed&_routes=x"), "/acme/-/overview?tab=landed");
134134 });
135135
160160 assert.ok(!keys.has(page.key), `${page.key} twice`);
161161 keys.add(page.key);
162162 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);
164165 }
165166 }
166167 });
168169 test("a member without Code access is sent around Code's pages", async () => {
169170 const { codeGate } = await import("./workspace-nav.ts");
170171 const none = ["acme"];
171− assert.equal(codeGate("/", "", none, "acme"), "/acme/-/chat");
172+ assert.equal(codeGate("/", "", none, "acme"), "/acme/-/home");
172173 assert.equal(codeGate("/", "", none, "other"), null);
173− assert.equal(codeGate("/acme", "", none, "acme"), "/acme/-/chat");
174+ assert.equal(codeGate("/acme", "", none, "acme"), "/acme/-/home");
174175 assert.equal(codeGate("/acme/web/pull/3", "?x=1", none, "acme"), "/acme/-/code-access?from=%2Facme%2Fweb%2Fpull%2F3%3Fx%3D1");
175176 assert.equal(codeGate("/acme/-/projects", "", none, "acme"), "/acme/-/code-access?from=%2Facme%2F-%2Fprojects");
176177 assert.equal(codeGate("/acme/-/code/repositories", "", none, "acme"), "/acme/-/code-access?from=%2Facme%2F-%2Fcode%2Frepositories");
+13−7
348348 if (SITE_PAGES.has(parts[0] ?? "")) return "site";
349349 if (slug && parts[0]?.toLowerCase() === slug.toLowerCase()) {
350350 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+ }
352356 }
353357 // Another workspace's chat, where the viewer is a guest: Chat all the same.
354358 if (parts[1] === "-" && parts[2] === "chat") return "chat";
355359 return "code";
356360 }
357361
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+
358368 /** Where each mode's button in the rail goes, in the workspace `slug`. */
359369 export function modeHome(mode: ModeKey, slug: string): string {
360370 switch (mode) {
490500 return browsingOf(pathname, viewer) ? "browse" : modeOf(pathname, viewer.slug);
491501 }
492502
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. */
498504 export function homePagePath(slug: string): string {
499− return `/${slug}/-/chat`;
505+ return `/${slug}/-/home`;
500506 }
501507
502508 /**
+1−0
176176 // The integrations directory, and the setup pages for alerts and
177177 // trackers (lib/integration-sections).
178178 route("-/settings/integrations", "routes/workspace/integrations-directory.tsx"),
179+ route("-/settings/integrations/github", "routes/workspace/integration-github.tsx"),
179180 route("-/settings/integrations/:section", "routes/workspace/integrations.tsx"),
180181 // Ask g1t: what it answers with (Chat's New with no repository), and
181182 // starting a session it proposed (components/chat/agent-composer).
+11−24
33 import { usePageNavigation } from "../../../lib/use-page-navigation";
44
55 import type { Route } from "./+types/index";
6−import type { Route as HomeRoute } from "../+types/home";
7−import { HomePage, loader as homeLoader } from "../home";
86 import { ChatUnavailable, NoChannels } from "../../../components/chat/empty";
97 import { ChatSidebar } from "../../../components/chat/sidebar";
108 import { NewChatButton, useChatData } from "../../../components/chat/actions";
1311 import { LAST_CHAT_COOKIE, sidebarOrNull } from "../../../lib/chat.server";
1412 import { readCookie } from "../../../lib/mission";
1513 import { page } from "../../../lib/meta";
16−import { requireUser, roleIn } from "../../../lib/session.server";
14+import { requireUser } from "../../../lib/session.server";
1715
1816 export function meta({ params, ...args }: Route.MetaArgs) {
1917 return page(args, { title: `Chat · ${params.owner} · g1t` });
2018 }
2119
2220 /**
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).
2924 */
30−export async function loader(args: Route.LoaderArgs) {
31− const { params, context, request } = args;
25+export async function loader({ params, context, request }: Route.LoaderArgs) {
3226 const viewer = requireUser(context, request);
3327 const slug = params.owner.toLowerCase();
3428 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− }
3929 const last = readCookie(request.headers.get("cookie"), LAST_CHAT_COOKIE);
4030 const remembered = last && last.startsWith(`/${slug}/-/chat/`) && !last.includes("..") ? last : null;
4131 if (remembered && !phone) throw redirect(remembered);
4232 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 };
4434 const entries = sidebar.entries;
4535 const first =
4636 entries.find((entry) => entry.channel.kind === "channel" && entry.channel.name === "general") ??
4838 entries[0];
4939 const to = remembered ?? (first ? channelPath(slug, first.channel) : null);
5040 if (to && !phone) throw redirect(to);
51− return { home: null, unavailable: false, phone, to };
41+ return { unavailable: false, phone, to };
5242 }
5343
5444 export default function ChatIndex({ loaderData, params }: Route.ComponentProps) {
5545 const { navigate } = usePageNavigation();
56− const { to, home } = loaderData;
46+ const { to } = loaderData;
5747 const slug = params.owner.toLowerCase();
5848 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.
6050 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]);
6653 return (
6754 <>
6855 {/* A phone: the list of conversations, full screen. */}
+22−31
11 import { Suspense, useEffect, useState } from "react";
2−import { Await, data, redirect } from "react-router";
2+import { Await, data } from "react-router";
33
44 import { type WorkspaceSession, hasCodeAccess } from "@g1t/contracts";
55
8484 import { billing, identity, inbox, work } from "../../lib/services.server";
8585 import { managesBilling, requireUser, roleIn } from "../../lib/session.server";
8686 import { canItem } from "../../lib/work-access";
87−import { isPhone } from "../../lib/phone";
8887 import { Boundary } from "../../components/boundary";
8988
9089 export function meta({ params, ...args }: Route.MetaArgs) {
134133 const now = Date.now();
135134 const tz = readCookie(request.headers.get("cookie"), "g1t_tz");
136135 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− }
143136 const windowKey = parseWindow(url.searchParams.get("window"));
144137 // Getting started, for an owner: streamed, and opened by itself until dismissed (`?setup` opens it anyway).
145138 const setupAsked = url.searchParams.has("setup");
414407 }, [slug, loadedAt]);
415408 }
416409
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. */
418411 export default function Home({ loaderData }: Route.ComponentProps) {
419− return <HomePage data={loaderData} />;
412+ return <HomePage key={loaderData.slug} data={loaderData} />;
420413 }
421414
422415 /**
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.
427418 */
428419 export function HomePage({ data: loaderData }: { data: Route.ComponentProps["loaderData"] }) {
429420 const { slug, now, tz, span, digest, happened, attention: waiting, away, live, head, spend, signal } = loaderData;
496487 </HeldSuspense>
497488 )}
498489
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−
514490 <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. */}
516492 <section aria-labelledby="since" className="rounded-2xl border border-line bg-surface">
517493 <header className="flex flex-wrap items-center gap-x-3 gap-y-2 px-5 pt-4">
518494 <div className="flex min-w-0 flex-wrap items-baseline gap-x-3 gap-y-1">
571547 </HeldSuspense>
572548 </section>
573549
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. */}
575551 <aside aria-label="Cost and settled" className="space-y-5 lg:sticky lg:top-4">
576552 {spend && (
577553 <HeldSuspense
610586 </aside>
611587 </div>
612588
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+
613604 {/* 7. The workspace at a glance: who is in it, and the month's usage against its plan (for billing). */}
614605 <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"}`}>
615606 {loaderData.usage && (
+117−0
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&apos;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+}
+4−0
4545 }
4646
4747 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,
4852 define: { "import.meta.env.G1T_BUILD_ID": JSON.stringify(randomUUID()) },
4953 plugins: [cloudflare({ viteEnvironment: { name: "ssr" } }), tailwindcss(), reactRouter(), editorChunk()],
5054 resolve: {
+131−3
11301130 // builds and tsc's build info; the site's build keeps nothing.
11311131 assert!(ci.trigger("push").unwrap().branches.allows("main"));
11321132 assert!(ci.trigger("pull_request").is_some());
1133+ assert!(ci.trigger("merge_group").is_some());
11331134 for (id, on_push) in [("rust", true), ("typescript", true), ("build", false)] {
11341135 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+ ] {
11361141 let mut contexts = Map::new();
11371142 contexts.insert(
11381143 "github".into(),
14331438 // A part with nothing to test does nothing more, and passes.
14341439 assert!(!runs_with(step, &none, Status::Success), "{name}");
14351440 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+ }
14361445 assert!(runs_with(step, &all, Status::Success), "{name}");
14371446 // Only a build of every crate of the part is pruned and saved.
14381447 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-"));
14441453 for path in ["\"Cargo.lock\"", "\".g1t/workflows/ci.yml\"", "\"scripts/sccache.sh\"", "\"scripts/deploy/ci-affected.mjs\""] {
14451454 assert!(script.contains(path), "{path}");
14461455 }
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+ );
14491577 }
+379−22
11 //! Telling g1t how a job is going: its steps, its log in batches, its
22 //! annotations, and how it ended. Every report carries the job's token.
33
4+use std::collections::VecDeque;
5+use std::sync::Arc;
46 use std::sync::atomic::{AtomicBool, Ordering};
7+use std::sync::mpsc;
58 use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
69
710 use anyhow::Result;
811 use g1t_actions::mask;
912 use serde_json::{Value, json};
1013
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.
1317 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.
1519 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);
1631 /// The most one step may add to the job's summary, as on GitHub.
1732 pub(crate) const MAX_SUMMARY_BYTES: usize = 1024 * 1024;
1833 /// How long the runner goes without a report before it asks whether the
261276 }
262277 }
263278
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+
264391 /// The job's log, masked, sent in batches. `masks` holds every form of
265392 /// every secret (`g1t_actions::mask`), longest first.
266393 pub(crate) struct Log {
273400 sent: Instant,
274401 /// The problem matchers steps added, which last the whole job.
275402 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>,
280408 /// Everything sent so far, so a test reads the whole log however its
281409 /// batches fell (`buffered`).
282410 #[cfg(test)]
287415 pub(crate) fn new(api: Api, mut masks: Vec<String>) -> Log {
288416 masks.retain(|mask| !mask.is_empty());
289417 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+ });
290436 Log {
291437 api,
292438 masks,
295441 last: Instant::now(),
296442 sent: Instant::now(),
297443 matchers: super::matchers::Matchers::default(),
298− chunk: 0,
444+ queue: Some(queue),
445+ uploaded: Some(uploaded),
446+ hurry,
299447 #[cfg(test)]
300448 flushed: String::new(),
301449 }
302450 }
303451
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+
304482 /// The log so far, what was sent and what waits to be, without the
305483 /// time each line is stamped with.
306484 #[cfg(test)]
358536 self.flush();
359537 } else if self.sent.elapsed() >= PING_EVERY {
360538 self.sent = Instant::now();
361− self.api.report(json!({ "kind": "ping" }));
539+ self.send(Outgoing::Report(json!({ "kind": "ping" })));
362540 }
363541 }
364542
543+ /// Hands what waits to the uploader, which sends it as soon as it can.
365544 pub(crate) fn flush(&mut self) {
366545 self.last = Instant::now();
367546 if self.buffer.is_empty() {
371550 let text = std::mem::take(&mut self.buffer);
372551 #[cfg(test)]
373552 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));
382554 }
383555
384556 /// Does `work` with the job's API on a thread of its own, writing
417589 }
418590
419591 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 })));
421593 }
422594
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.
423597 pub(crate) fn step_state(&mut self, number: u32, name: &str, status: &str, conclusion: Option<&str>) {
424598 self.flush();
425599 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 })));
427601 }
428602
429603 /// What a step wrote to `$GITHUB_STEP_SUMMARY`, masked, for the run's
442616 }
443617 let markdown = self.mask(markdown);
444618 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 })));
446620 }
447621
448622 /// An annotation, from `::error::` and its kin or a problem matcher:
451625 /// and `end_column`.
452626 pub(crate) fn annotation(&mut self, level: &str, message: &str, properties: &serde_json::Map<String, Value>) {
453627 let report = self.annotation_report(level, message, properties);
454− self.api.report(report);
628+ self.send(Outgoing::Report(report));
455629 }
456630
457631 fn annotation_report(&self, level: &str, message: &str, properties: &serde_json::Map<String, Value>) -> Value {
471645 })
472646 }
473647
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`].
474650 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+ ) {
475661 let outputs = self.withhold_secrets(outputs);
476662 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);
478665 }
479666
480667 /// The job's outputs without any that hold a secret, as on GitHub: an
497684 }
498685 }
499686
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+
500695 /// A time as GitHub's logs stamp each line: RFC 3339 in UTC, to a tenth
501696 /// of a microsecond (`2026-10-12T09:41:07.1234567Z`).
502697 pub(crate) fn stamp(at: SystemTime) -> String {
695890 let mut log = Log::new(Api { base, job: "job_1".into(), token: "t".into() }, Vec::new());
696891 log.line("one");
697892 log.flush();
893+ // Sent before the next is handed over, or the two would go together.
894+ log.settle();
698895 log.line("two");
699896 log.flush();
700897 log.flush(); // Nothing waits: no batch, and no number used.
709906 assert_eq!(bodies[0], bodies[1], "a batch sent again is the same batch");
710907 }
711908
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+
7121069 #[test]
7131070 fn a_long_wait_says_so_in_the_log_as_it_goes() {
7141071 let mut log = log(&[]);
+1−1
130130 description: "Import repositories, with their branches, tags and issues, through g1t's GitHub App.",
131131 scopes: BOTH,
132132 status: "available",
133− href: { workspace: "/new/github?workspace=:workspace", personal: "/settings/github" },
133+ href: { workspace: "/:workspace/-/settings/integrations/github", personal: "/settings/github" },
134134 capabilities: ["Imports repositories"],
135135 personal: {
136136 description: "Sign in with your GitHub account, and reach the repositories you can see there to import them.",
+124−21
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.
67 //
78 // node scripts/deploy/ci-affected.mjs -p <crate>...
89 // node scripts/deploy/ci-affected.mjs --job desktop|build
10+// node scripts/deploy/ci-affected.mjs --tested
911 //
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).
1316 //
1417 // It takes the part's `-p` arguments and writes to the step's outputs
1518 // (GITHUB_OUTPUT):
1619 //
1720 // crates those of them to test, in the same order: all, some or none
1821 // 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
1924 //
2025 // What a pull request changes is its test merge commit against its first
2126 // 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.
2540 //
2641 // A file outside every crate's folder tests the crates that read it
2742 // (READS), or every crate when the build itself reads it (EVERYTHING), and
3045 // crate's source names outside its own folder is in one or the other.
3146
3247 import { spawnSync } from "node:child_process";
33−import { appendFileSync } from "node:fs";
48+import { appendFileSync, readFileSync } from "node:fs";
3449 import { basename, dirname, relative, sep } from "node:path";
3550 import { fileURLToPath } from "node:url";
3651
192207 return out.stdout;
193208 }
194209
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. */
196236 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 };
200239 // 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);
202241 return { files };
203242 }
204243
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+
205295 /** `--job <name>`: writes `run`, whether this run needs the job. */
206−function mainJob(job) {
296+async function mainJob(job) {
207297 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+ }
208304 let run = true;
209305 try {
210306 const { files, why } = changedFiles();
221317 if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `run=${run}\n`);
222318 }
223319
224−function main(args) {
320+async function main(args) {
225321 if (args[0] === "--job") return mainJob(args[1]);
322+ if (args[0] === "--tested") return mainTested();
226323 const part = partCrates(args);
227324 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);
228329 try {
229330 const { files, why } = changedFiles();
230331 if (files) {
243344 test = part;
244345 }
245346 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+ );
247350 if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `crates=${outputs.crates}\nwhole=${outputs.whole}\n`);
248351 }
249352
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));
+19−1
33 import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
44 import { dirname, join, relative, resolve, sep } from "node:path";
55 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";
77
88 const ROOT = fileURLToPath(new URL("../..", import.meta.url));
99
8181 assert.doesNotMatch(readFileSync(join(ROOT, "apps/desktop/src-tauri/Cargo.toml"), "utf8"), /\bpath\s*=/);
8282 });
8383
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+
84102 test("a part's crates are read from its -p arguments", () => {
85103 assert.deepEqual(partCrates(["-p", "g1t-kit", "-p", "g1t"]), ["g1t-kit", "g1t"]);
86104 assert.throws(() => partCrates(["-p", "g1t-kit", "g1t"]));

Comments

Sign in to comment.