diff --git a/windows/README.md b/windows/README.md
index 60374024cf..6e77704b50 100644
--- a/windows/README.md
+++ b/windows/README.md
@@ -77,6 +77,90 @@ only ask whether a key exists. Same for every integration key.
No telemetry. The only network requests Coucou makes are to the services you
configure yourself.
+## GitHub
+
+
+
+With GitHub as the focused pill, the overview shows your year at a glance and
+your latest activity. Click the figure — or the arrow — and the island opens the
+**GitHub panel**, where everything is read without leaving for the browser.
+
+
+
+| Where | What you see |
+|---|---|
+| **Activity** | The contribution graph, the pull requests waiting for your review, and your latest events. Click a day of the graph for what was done that day; click a line for its sheet |
+| **Projects** | Your repositories, most recently pushed first, each with its latest build |
+| A project | Description, languages, its last builds, its latest pull request, its latest deployment |
+| A pull request | State, branches, labels, review, checks, the files with their diff |
+| An issue, a push, a release | The same, each as its own sheet: the discussion, the commits and their files, the notes and assets |
+| A run | Its jobs as mini Mochis, each with its steps and how long they took — followed live while it runs |
+| Comments | A pull request's conversation: the description, the reviews, and each thread on the lines it is about |
+| A file | Its diff, syntax-coloured. A thread opens the file on the line it was written on |
+
+
+
+
+
+The trail on the left is the way back: every step you went through stays there,
+one click away. The arrow at the top right opens the same thing on github.com.
+
+### News
+
+
+
+Five things make Mochi speak up, with a sound and a card: **a build of yours
+breaks**, **the checks of a pull request of yours end**, red or green,
+**somebody asks for your review**, **a pull request of yours is merged**, and
+**somebody opens a pull request on one of your projects** — the ones the
+Projects tab lists. **Open**
+goes straight to the run or the pull request. Each project of that tab has a
+bell: click it to mute a project you do not want to hear from, and again to give
+it its voice back. **OK** folds the island and leaves the news on the pill
+for five minutes, so it isn't lost: click Mochi, or the card, to go to it.
+
+
+
+A pull request your review is asked on stays at the top of the Activity tab,
+under **Waiting for your review**, for as long as it waits: the request is told
+once, when it comes in.
+
+### The token
+
+
+
+**Settings… → Integrations → GitHub**: switching it on opens its setup under its
+line. Create a [fine-grained token](https://github.com/settings/personal-access-tokens/new)
+with **Repository access: All repositories** and these permissions, all
+**Read-only**:
+
+| Permission | What it is for |
+|---|---|
+| Actions | Builds, runs, jobs and steps |
+| Contents | Commits, diffs and releases |
+| Deployments | A project's latest deployment |
+| Issues | Issues and their comments |
+| Pull requests | Pull requests, reviews and threads |
+| Commit statuses (optional) | Whether the checks of your pull requests passed |
+| Events (account, optional) | Your activity in private repositories |
+
+Metadata is added by GitHub on its own. **Test connection** checks each of them
+and names the one that is missing; in the panel, a part the token cannot read
+says which permission it needs instead of showing up empty.
+
+Coucou only reads, and nothing else is ever asked of GitHub. The token is stored
+in the Windows Credential Manager like every other key: it never touches the
+disk or the log, and the interface never sees it — every request is made by the
+Rust side.
+
+### How often it asks
+
+- Every **2 minutes** for the activity, the projects and their builds. Answers
+ GitHub says are unchanged (`304`) cost nothing against the rate limit.
+- Every **20 seconds**, builds only, and only while one of your builds is running.
+- A sheet is fetched **when you open it**, and kept for a minute.
+- Nothing while Coucou is paused or GitHub is switched off.
+
## Build it yourself
You need [Rust](https://rustup.rs), [Node 20+](https://nodejs.org), and the
@@ -91,9 +175,14 @@ npm run pack # builds the installer and drops it in windows/release/
```
`npm run dev` alone serves the front end in an ordinary browser, which is enough
-to work on the island's looks. It also serves `dev/upload-preview.html`, which
-replays the whole file-drop choreography on a loop — the one part of the UI that
-otherwise needs a real drag from Explorer to see. Neither page ships in the app.
+to work on the island's looks. It also serves two pages that never ship in the
+app:
+
+- `dev/upload-preview.html` replays the whole file-drop choreography on a loop —
+ the one part of the UI that otherwise needs a real drag from Explorer to see.
+- `dev/github-preview.html` shows the GitHub card and panel on made-up data, so
+ they can be worked on without a token. The links at the bottom of the page go
+ to each screen: a project, a run in progress, a failing build, the comments.
`npm run pack` leaves two files in `windows/release/`, the same names the release
workflow publishes:
@@ -146,6 +235,8 @@ problems. It stays on your machine.
attach it as context, and jumping to a specific terminal window — "Open
terminal" opens the working folder in VS Code when `code` is on your `PATH`.
- Cal.com shows the next bookings as a list rather than the Mac's calendar.
+- GitHub goes further than the Mac's card: the [panel](#github), the news of a
+ broken build or a merged pull request, and comments are Windows-only for now.
## Linux
diff --git a/windows/dev/github-preview.html b/windows/dev/github-preview.html
new file mode 100644
index 0000000000..46031306cd
--- /dev/null
+++ b/windows/dev/github-preview.html
@@ -0,0 +1,31 @@
+
+
+
+
+ Coucou — GitHub panel preview
+
+
+
+
+
+
+
+
diff --git a/windows/dev/github-preview.ts b/windows/dev/github-preview.ts
new file mode 100644
index 0000000000..72813168d3
--- /dev/null
+++ b/windows/dev/github-preview.ts
@@ -0,0 +1,436 @@
+// Dev harness: the real island with made-up GitHub data, in a plain browser, so
+// the card and the panel can be looked at without a token or the Rust side.
+// Not part of the app bundle. `npm run dev`, then /dev/github-preview.html.
+
+import "../src/style.css";
+import {
+ Bridge, type GithubActivity, type GithubBuild, type GithubCommentsDetail, type GithubData, type GithubDay,
+ type GithubDetail, type GithubFile, type GithubJob, type GithubProject, type GithubPullDetail, type GithubRemark,
+ type GithubRepo, type GithubRunDetail, type GithubStep, type GithubTarget,
+} from "../src/core/bridge";
+import { State } from "../src/core/state";
+import { Island } from "../src/island/island";
+
+const params = new URLSearchParams(location.search);
+const minutesAgo = (m: number) => new Date(Date.now() - m * 60_000).toISOString();
+
+const pullTarget = (repo: string, number: number): GithubTarget => ({ kind: "pull", repo, number });
+const pushTarget = (repo: string, count: number, branch: string): GithubTarget => ({
+ kind: "commits", repo, head: "a1b2c3d4e5f6", count, branch, author: null, from: null, to: null,
+});
+
+const activity: GithubActivity[] = [
+ { kind: "pr_merged", repo: "mochi/coucou", title: "GitHub panel for the Windows island", detail: "#12", url: "https://github.com", at: minutesAgo(4), target: pullTarget("mochi/coucou", 12) },
+ { kind: "push", repo: "mochi/coucou", title: "Keep the last snapshot through an error", detail: "3 commits · windows-github-panel", url: "https://github.com", at: minutesAgo(38), target: pushTarget("mochi/coucou", 3, "windows-github-panel") },
+ { kind: "pr_opened", repo: "mochi/coucou", title: "Windows: GitHub panel", detail: "#12", url: "https://github.com", at: minutesAgo(95), target: pullTarget("mochi/coucou", 12) },
+ { kind: "issue_opened", repo: "louis-cfm/coucou", title: "Defender flags the installer", detail: "#9", url: "https://github.com", at: minutesAgo(60 * 5), target: { kind: "issue", repo: "louis-cfm/coucou", number: 9 } },
+ { kind: "release", repo: "mochi/tour-convention-geneve", title: "Sprint 3", detail: "v0.3.0", url: "https://github.com", at: minutesAgo(60 * 26), target: { kind: "release", repo: "mochi/tour-convention-geneve", tag: "v0.3.0" } },
+ { kind: "issue_closed", repo: "mochi/tour-convention-geneve", title: "Map tiles flicker on zoom", detail: "#41", url: "https://github.com", at: minutesAgo(60 * 50), target: { kind: "issue", repo: "mochi/tour-convention-geneve", number: 41 } },
+ { kind: "pr_closed", repo: "mochi/dotfiles", title: "Try another prompt theme", detail: "#3", url: "https://github.com", at: minutesAgo(60 * 72), target: pullTarget("mochi/dotfiles", 3) },
+ { kind: "create", repo: "mochi/sandbox", title: "Created the repository", detail: null, url: "https://github.com", at: minutesAgo(60 * 24 * 6), target: { kind: "project", repo: "mochi/sandbox" } },
+ { kind: "push", repo: "mochi/sandbox", title: "Pushed", detail: "main", url: "https://github.com", at: minutesAgo(60 * 24 * 6), target: pushTarget("mochi/sandbox", 1, "main") },
+];
+
+// The sheets behind those lines, one per kind, with a real-looking diff.
+const PATCH = [
+ "@@ -12,9 +12,14 @@ export function enterGithubPanel() {",
+ " sweepNext = true;",
+ "- if (sheet) closeSheet();",
+ "- else touchSheet();",
+ "+ if (day) stopSearching(day);",
+ "+ day = null;",
+ "+ clearStack();",
+ "+ touch();",
+ " const d = githubData();",
+ " if (!d || Date.now() - d.fetchedAt > STALE_MS) void Bridge.refreshIntegration(ID);",
+ " }",
+ "@@ -40,6 +45,8 @@ function eventRow(",
+ " const style = ACTIVITY_STYLE[item.kind];",
+ "+ // A line of activity opens its sheet; GitHub is the last resort.",
+ "+ const open = () => openTarget(item.target, item.title, item.url);",
+ " return h(",
+ "\\ No newline at end of file",
+].join("\n");
+const files: GithubFile[] = [
+ { path: "windows/src/views/github.ts", status: "modified", additions: 612, deletions: 58, patch: PATCH, truncated: true },
+ { path: "windows/src-tauri/src/github_detail.rs", status: "added", additions: 540, deletions: 0, patch: PATCH, truncated: false },
+ { path: "windows/src/style.css", status: "modified", additions: 180, deletions: 6, patch: PATCH, truncated: false },
+ { path: "windows/screenshots/panel.png", status: "added", additions: 0, deletions: 0, patch: null, truncated: false },
+];
+const ci = (state: "success" | "failure" | "running" | "neutral"): GithubBuild => ({
+ id: 1, state, workflow: "CI", branch: "windows-github-panel", url: "https://github.com", at: minutesAgo(6),
+});
+// A run with its jobs side by side; `running` catches it halfway.
+const live = params.has("running");
+const runStart = Date.now() - (live ? 100 : 7 * 60) * 1000;
+const t = (s: number) => new Date(runStart + s * 1000).toISOString();
+const step = (name: string, from: number, to: number | null, outcome = "passed"): GithubStep => ({
+ name, outcome,
+ state: outcome === "failed" ? "failure" : outcome === "skipped" ? "neutral" : to == null ? "running" : "success",
+ startedAt: outcome === "skipped" ? null : t(from), endedAt: to == null ? null : t(to),
+});
+const jobs: GithubJob[] = [
+ {
+ id: 1, name: "lint", state: "success", outcome: "passed", url: "https://github.com", runner: "ubuntu-latest",
+ startedAt: t(4), endedAt: t(46),
+ steps: [step("Set up job", 4, 6), step("Checkout", 6, 8), step("npm ci", 8, 31), step("Type-check", 31, 45), step("Complete job", 45, 46)],
+ },
+ {
+ id: 2, name: "build (windows)", state: live ? "running" : "success", outcome: live ? "running" : "passed",
+ url: "https://github.com", runner: "windows-latest", startedAt: t(5), endedAt: live ? null : t(212),
+ steps: [
+ step("Set up job", 5, 9), step("Checkout", 9, 12), step("Set up Rust", 12, 41), step("Restore cache", 41, 52),
+ live ? step("cargo build --release", 52, null) : step("cargo build --release", 52, 198),
+ ...(live ? [] : [step("Save cache", 198, 210), step("Complete job", 210, 212)]),
+ ],
+ },
+ {
+ id: 3, name: "test", state: live ? "running" : "failure", outcome: live ? "running" : "failed",
+ url: "https://github.com", runner: "ubuntu-latest", startedAt: t(5), endedAt: live ? null : t(141),
+ steps: [
+ step("Set up job", 5, 7), step("Checkout", 7, 9), step("Set up Rust", 9, 35),
+ live ? step("cargo test", 35, null) : step("cargo test", 35, 139, "failed"),
+ ...(live ? [] : [step("Upload report", 139, 139, "skipped"), step("Complete job", 139, 141)]),
+ ],
+ },
+ {
+ id: 4, name: "deploy", state: live ? "running" : "neutral", outcome: live ? "queued" : "skipped",
+ url: "https://github.com", runner: null, startedAt: null, endedAt: null, steps: [],
+ },
+];
+const runDetail: GithubRunDetail = {
+ kind: "run", repo: "mochi/coucou", id: 1, workflow: "CI", title: "Keep the last snapshot through an error",
+ branch: "windows-github-panel", event: "push", actor: "mochi", attempt: 1, url: "https://github.com",
+ state: live ? "running" : "failure", outcome: live ? "running" : "failed",
+ startedAt: t(0), endedAt: live ? null : t(212), jobs, moreJobs: 0,
+};
+
+// `running=play`: a run that goes on while you watch, over some fifty seconds
+// from the moment the page opened — lint passes, then test (or breaks, with
+// `fail`), then build, then deploy wakes up and ships. Every answer is the
+// run as it would stand at that moment, so the panel's own refresh sees it
+// move: what its jobs' Mochis and the big one do can be watched to the end.
+const played = params.get("running") === "play";
+const opened = Date.now();
+
+function playedRun(): GithubRunDetail {
+ const elapsed = (Date.now() - opened) / 1000;
+ const at = (s: number) => new Date(opened + s * 1000).toISOString();
+ const breaks = params.has("fail");
+ // from / to: seconds from the opening; a step's number is where in its job it ends.
+ const plans: { id: number; name: string; runner: string; from: number; to: number; ends: "passed" | "failed" | "skipped"; steps: [string, number][] }[] = [
+ { id: 1, name: "lint", runner: "ubuntu-latest", from: -16, to: 14, ends: "passed", steps: [["Set up job", 0.1], ["Checkout", 0.2], ["npm ci", 0.7], ["Type-check", 1]] },
+ { id: 2, name: "build (windows)", runner: "windows-latest", from: -15, to: 34, ends: "passed", steps: [["Set up job", 0.1], ["Set up Rust", 0.3], ["cargo build --release", 0.95], ["Save cache", 1]] },
+ { id: 3, name: "test", runner: "ubuntu-latest", from: -15, to: 24, ends: breaks ? "failed" : "passed", steps: [["Set up job", 0.1], ["Set up Rust", 0.4], ["cargo test", 1]] },
+ { id: 4, name: "deploy", runner: "ubuntu-latest", from: 34, to: 46, ends: breaks ? "skipped" : "passed", steps: [["Set up job", 0.2], ["Upload", 1]] },
+ ];
+ const jobs: GithubJob[] = plans.map((p) => {
+ const waiting = elapsed < p.from;
+ const skipped = p.ends === "skipped" && !waiting;
+ const done = elapsed >= p.to;
+ const length = p.to - p.from;
+ let before = 0;
+ const steps: GithubStep[] = waiting || skipped ? [] : p.steps.map(([name, end], i) => {
+ const from = p.from + before * length;
+ const to = p.from + end * length;
+ before = end;
+ const broke = p.ends === "failed" && done && i === p.steps.length - 1;
+ const over = elapsed >= to;
+ return {
+ name,
+ state: broke ? "failure" : over ? "success" : "running",
+ outcome: broke ? "failed" : over ? "passed" : elapsed >= from ? "running" : "queued",
+ startedAt: elapsed >= from ? at(from) : null, endedAt: over ? at(to) : null,
+ };
+ });
+ return {
+ id: p.id, name: p.name, url: "https://github.com", runner: p.runner, steps,
+ state: skipped ? "neutral" : !done ? "running" : p.ends === "failed" ? "failure" : "success",
+ outcome: skipped ? "skipped" : waiting ? "queued" : !done ? "running" : p.ends,
+ startedAt: waiting || skipped ? null : at(p.from), endedAt: done && !skipped ? at(p.to) : null,
+ };
+ });
+ const over = jobs.every((j) => j.state !== "running");
+ const broke = jobs.some((j) => j.state === "failure");
+ return {
+ ...runDetail, jobs,
+ state: !over ? "running" : broke ? "failure" : "success",
+ outcome: !over ? "running" : broke ? "failed" : "passed",
+ startedAt: at(-16), endedAt: over ? at(breaks ? 34 : 46) : null,
+ };
+}
+
+// What was said on the pull request: a description, a thread still open on a
+// line of the first file's diff, a review asking for changes, a thread settled,
+// one on code that has changed since, an answer, an approval.
+const said = (author: string | null, minutes: number, body: string, cut = false): GithubRemark => ({
+ author, body, cut, at: minutesAgo(minutes), url: "https://github.com",
+});
+const comments: GithubCommentsDetail = {
+ kind: "comments", repo: "mochi/coucou", number: 12, title: "GitHub panel for the Windows island", url: "https://github.com",
+ earlier: params.has("earlier"),
+ entries: params.has("quiet") ? [] : [
+ { kind: "description", ...said("mochi", 60 * 50, "Turns the GitHub pill into a panel: the year's activity, each project's sheet, and the sheet behind every line.\n\nEverything is fetched on the click, by the Rust side, with a read-only token.") },
+ {
+ kind: "thread", path: "windows/src/views/github.ts", line: 16, side: "right", resolved: false, outdated: false, more: 0,
+ code: [
+ { number: 13, sign: "+", text: " if (day) stopSearching(day);" },
+ { number: 14, sign: "+", text: " day = null;" },
+ { number: 15, sign: "+", text: " clearStack();" },
+ { number: 16, sign: "+", text: " touch();" },
+ ],
+ remarks: [
+ said("louis", 60 * 30, "Is this touch() needed? clearStack() already drops the sheets."),
+ said("mochi", 60 * 29, "It is: the view only redraws when its key changes, and a sheet dropped without a new stamp stayed on screen."),
+ ],
+ },
+ { kind: "review", state: "changes requested", ...said("louis", 60 * 28, "Nearly there. Two things on the code, and the panel should not poll while the island is hidden.") },
+ {
+ kind: "thread", path: "windows/src/style.css", line: 46, side: "right", resolved: true, outdated: false, more: 2,
+ code: [{ number: 46, sign: "+", text: " // A line of activity opens its sheet; GitHub is the last resort." }],
+ remarks: [said("kirzen", 60 * 27, "Typo."), said("mochi", 60 * 26, "Fixed.")],
+ },
+ {
+ kind: "thread", path: "windows/src-tauri/src/github_detail.rs", line: null, side: "right", resolved: false, outdated: true, more: 0,
+ code: [
+ { number: 286, sign: "", text: " if !force {" },
+ { number: 287, sign: "-", text: " if let Some(cached) = CACHE.get(&key) {" },
+ { number: 287, sign: "+", text: " if let Some((at, cached)) = CACHE.lock().unwrap().get(&key) {" },
+ ],
+ remarks: [said("louis", 60 * 26, "A minute of cache is fine here, but say so where the constant is.")],
+ },
+ { kind: "comment", ...said("mochi", 60 * 20, "Both fixed, and nothing is fetched on a timer any more: the sheets load on the click only.\n\nThe run under a pull request is the one exception, and only while the panel is open.") },
+ { kind: "comment", ...said(null, 60 * 12, "A very long comment, cut short by Coucou so the panel stays a panel…", true) },
+ { kind: "review", state: "approved", ...said("louis", 9, "") },
+ ],
+};
+
+const details: Record = {
+ run: runDetail,
+ comments,
+ pull: {
+ kind: "pull", repo: "mochi/coucou", number: 12, title: "GitHub panel for the Windows island", url: "https://github.com",
+ state: "merged", author: "mochi", base: "main", head: "windows-github-panel",
+ additions: 1332, deletions: 64, changedFiles: 14, commits: 18,
+ comments: params.has("quiet") ? 0 : 3, threads: params.has("quiet") ? 0 : 3, review: "approved",
+ reviewers: [{ login: "louis", state: "approved" }, { login: "kirzen", state: "commented" }],
+ labels: [{ name: "windows", color: "#0e8a16" }, { name: "enhancement", color: "#a2eeef" }],
+ files, createdAt: minutesAgo(60 * 50), mergedAt: minutesAgo(4), mergedBy: "louis", closedAt: minutesAgo(4),
+ ci: ci(params.has("running") ? "running" : "success"), missing: [],
+ },
+ issue: {
+ kind: "issue", repo: "louis-cfm/coucou", number: 9, title: "Defender flags the installer", url: "https://github.com",
+ state: "open", author: "mochi", comments: 4, assignees: ["louis"],
+ body: "Windows Defender reports Trojan:Win32/Wacatac.H!ml on the unsigned installer. It's a machine-learning false positive; a report is under review.",
+ labels: [{ name: "windows", color: "#0e8a16" }, { name: "bug", color: "#d73a4a" }],
+ createdAt: minutesAgo(60 * 5), closedAt: null,
+ },
+ commits: {
+ kind: "commits", repo: "mochi/coucou", branch: "windows-github-panel", total: 3,
+ commits: [
+ { sha: "a1b2c3d", id: "a1b2c3d4e5f6", message: "Keep the last snapshot through an error", author: "mochi", at: minutesAgo(38), url: "https://github.com" },
+ { sha: "9f8e7d6", id: "9f8e7d6c5b4a", message: "Fade the list only while there is more", author: "mochi", at: minutesAgo(52), url: "https://github.com" },
+ { sha: "5a4b3c2", id: "5a4b3c2d1e0f", message: "Close the sheet when the panel reopens", author: "mochi", at: minutesAgo(70), url: "https://github.com" },
+ ],
+ additions: 22, deletions: 4, files: files.slice(0, 2), ci: ci("failure"), url: "https://github.com", missing: [],
+ },
+ release: {
+ kind: "release", repo: "mochi/tour-convention-geneve", tag: "v0.3.0", name: "Sprint 3", url: "https://github.com",
+ body: "What's new: the interactive map, the torch relay journal, and the partner pages.", author: "mochi",
+ publishedAt: minutesAgo(60 * 26), prerelease: false, downloads: 1284,
+ assets: [{ name: "site-build.zip", downloads: 1200, size: 18_400_000 }, { name: "checksums.txt", downloads: 84, size: 512 }],
+ },
+ project: { kind: "locked", permission: "Contents" },
+};
+Bridge.githubDetail = async (target) => {
+ await new Promise((r) => setTimeout(r, 700));
+ if (params.has("locked")) return { kind: "locked", permission: "Contents" };
+ if (played && target.kind === "run") return playedRun();
+ if (played && target.kind === "pull") {
+ return { ...(details.pull as GithubPullDetail), ci: ci(playedRun().state) };
+ }
+ return details[target.kind];
+};
+
+const repo = (
+ name: string, language: [string, string] | null, stars: number, openPrs: number,
+ build: GithubRepo["build"], pushedMinutesAgo: number, priv = false,
+): GithubRepo => ({
+ fullName: `mochi/${name}`, url: "https://github.com", private: priv,
+ language: language?.[0] ?? null, languageColor: language?.[1] ?? null,
+ stars, openPrs, pushedAt: minutesAgo(pushedMinutesAgo), build,
+});
+const run = (state: "success" | "failure" | "running" | "neutral", m: number) => ({
+ id: m, state, workflow: "CI", branch: "main", url: "https://github.com", at: minutesAgo(m),
+});
+
+const repos: GithubRepo[] = [
+ // A project you contribute to without owning it: shown with its owner.
+ { ...repo("coucou", ["Swift", "#F05138"], 1204, 3, run("success", 20), 2), fullName: "louis-cfm/coucou" },
+ repo("coucou", ["Rust", "#dea584"], 4, 2, run("running", 1), 4),
+ repo("tour-convention-geneve", ["TypeScript", "#3178c6"], 38, 5, run("failure", 50), 50),
+ repo("dotfiles", ["Shell", "#89e051"], 12, 0, run("success", 60 * 26), 60 * 26),
+ repo("notes", null, 0, 0, null, 60 * 30, true),
+ repo("sandbox", ["Python", "#3572A5"], 3, 1, run("neutral", 60 * 24 * 6), 60 * 24 * 6),
+ repo("portfolio", ["Astro", "#ff5a03"], 27, 0, run("success", 60 * 24 * 9), 60 * 24 * 9),
+];
+
+// A plausible year: quiet weekends, a few busy stretches, a streak up to today.
+// Seeded, so every reload draws the same graph.
+const today = new Date();
+const todayUtc = Date.UTC(today.getFullYear(), today.getMonth(), today.getDate());
+const firstSunday = todayUtc - (new Date(todayUtc).getUTCDay() + 52 * 7) * 86_400_000;
+const days = Math.round((todayUtc - firstSunday) / 86_400_000) + 1;
+let seed = 7;
+const rand = () => ((seed = (seed * 16807) % 2147483647) / 2147483647);
+const counts = Array.from({ length: days }, (_, i) => {
+ const weekday = new Date(firstSunday + i * 86_400_000).getUTCDay();
+ const busy = Math.sin(i / 23) > 0.3 ? 2.2 : 1;
+ const base = weekday === 0 || weekday === 6 ? 0.25 : 1;
+ const n = Math.floor(rand() * 6 * busy * base - (rand() < 0.35 ? 3 : 0));
+ return i >= days - 6 ? Math.max(1, n) : Math.max(0, n);
+});
+const level = (n: number) => (n === 0 ? 0 : n <= 2 ? 1 : n <= 5 ? 2 : n <= 8 ? 3 : 4);
+
+const data: GithubData = {
+ login: "mochi",
+ profileUrl: "https://github.com",
+ totalStars: 1284,
+ // `pushed`: pushes GitHub gave no message for come first, as they often do.
+ activity: params.has("empty") ? [] : params.has("pushed") ? [{ ...activity[8], at: minutesAgo(14) }, { ...activity[8], at: minutesAgo(31) }, ...activity] : activity,
+ repos: params.has("empty") ? [] : repos,
+ // `review`: two pull requests waiting for your review, above the activity.
+ toReview: params.has("review") || params.get("news") === "review"
+ ? [
+ { repo: "louis/coucou", number: 57, title: "Windows: the GitHub panel", url: "https://github.com", author: "edu", draft: false, at: minutesAgo(6), target: pullTarget("louis/coucou", 57) },
+ { repo: "mochi/notch", number: 31, title: "Fold the island when the mouse leaves", url: "https://github.com", author: "ada", draft: true, at: minutesAgo(190), target: pullTarget("mochi/notch", 31) },
+ ]
+ : [],
+ contributions: {
+ total: counts.reduce((a, b) => a + b, 0),
+ start: new Date(firstSunday).toISOString().slice(0, 10),
+ counts,
+ levels: counts.map(level),
+ },
+ fetchedAt: Date.now() - (params.has("error") ? 12 * 60_000 : 0),
+};
+
+// A project's sheet normally comes from Rust; here every project gets this one.
+const runState = ["success", "success", "failure", "success", "success", "neutral", "success", "success"] as const;
+const sheet: GithubProject = {
+ fullName: "mochi/coucou",
+ url: "https://github.com",
+ description: "A tiny friend that lives at the top of your screen and keeps an eye on your Claude Code sessions.",
+ homepage: "https://coucou.example.com",
+ private: false,
+ createdAt: "2024-03-12T09:00:00Z",
+ stars: 4,
+ forks: 1,
+ languages: [
+ { name: "Rust", color: "#dea584", share: 0.46 },
+ { name: "TypeScript", color: "#3178c6", share: 0.38 },
+ { name: "CSS", color: "#663399", share: 0.12 },
+ { name: "Other", color: null, share: 0.04 },
+ ],
+ runs: runState.map((state, i) => ({
+ id: i, state, workflow: "CI", branch: "main",
+ title: i === 0 ? "Keep the last snapshot through an error" : "Earlier work",
+ actor: "mochi", url: "https://github.com",
+ startedAt: minutesAgo(62 + i * 90), updatedAt: minutesAgo(60 + i * 90),
+ })),
+ pull: {
+ number: 12, title: "GitHub panel for the Windows island", url: "https://github.com",
+ state: "merged", author: "mochi", additions: 1320, deletions: 94, changedFiles: 14,
+ review: "approved", comments: 3, at: minutesAgo(4),
+ },
+ deploy: {
+ environment: "Production", state: "success", url: "https://coucou.example.com",
+ creator: "vercel", sha: "a1b2c3d", at: minutesAgo(58),
+ },
+ missing: params.has("locked") ? ["Deployments"] : [],
+};
+// `slow` stands in for a real network, long enough to watch Mochi search.
+Bridge.githubProject = async () => {
+ const ms = Number(params.get("slow"));
+ if (params.has("slow")) await new Promise((r) => setTimeout(r, ms > 100 ? ms : 2500));
+ return sheet;
+};
+
+// Any clicked day gets this one, after a short wait so the loader shows.
+const aDay: GithubDay = {
+ items: [
+ { kind: "push", repo: "mochi/coucou", title: "4 commits", detail: null, url: "https://github.com", target: pushTarget("mochi/coucou", 4, "main") },
+ { kind: "push", repo: "mochi/tour-convention-geneve", title: "1 commit", detail: null, url: "https://github.com", target: pushTarget("mochi/tour-convention-geneve", 1, "main") },
+ { kind: "pr_merged", repo: "mochi/coucou", title: "GitHub panel for the Windows island", detail: "#12", url: "https://github.com", target: pullTarget("mochi/coucou", 12) },
+ { kind: "review", repo: "louis-cfm/coucou", title: "Fix the hook timeout", detail: "#31", url: "https://github.com", target: pullTarget("louis-cfm/coucou", 31) },
+ ],
+ privateCount: 2,
+};
+Bridge.githubDay = async () => {
+ await new Promise((r) => setTimeout(r, 900));
+ return aDay;
+};
+
+State.loadIntegrationTasks();
+State.integrations.integration_github = {
+ data: data as unknown as Record,
+ error: params.has("error") ? "No connection" : null,
+ loaded: true,
+ configured: true,
+};
+// `news=fail`, `news=review` or `news=merge`: what the panel shows when the pill's news comes
+// in while it is open.
+if (params.has("news")) {
+ State.integrations.integration_github.news = params.get("news") === "merge"
+ ? { success: true, label: "#12 merged", detail: "GitHub panel for the Windows island", open: { target: pullTarget("mochi/coucou", 12), label: "#12", url: "https://github.com", title: "#12 GitHub panel for the Windows island", facts: [{ kind: "repo", text: "coucou" }, { kind: "by", verb: "merged by", text: "louis" }, { kind: "diff", additions: 1332, deletions: 64 }, { kind: "files", text: "14 files" }] } }
+ : params.get("news") === "review"
+ ? { success: true, label: "Review requested on coucou", detail: "Windows: the GitHub panel", open: { target: pullTarget("louis/coucou", 57), label: "#57", url: "https://github.com", title: "#57 Windows: the GitHub panel", says: "review requested", facts: [{ kind: "repo", text: "coucou" }, { kind: "by", verb: "opened by", text: "edu" }, { kind: "diff", additions: 1332, deletions: 64 }, { kind: "files", text: "14 files" }] } }
+ : { success: false, label: "CI failed on coucou", detail: "main", open: { target: { kind: "run", repo: "mochi/coucou", id: 1 }, label: "CI", url: "https://github.com", title: "CI failed on coucou", facts: [{ kind: "step", text: "test \u203a cargo test" }, { kind: "branch", text: "main" }, { kind: "commit", text: "Keep the last snapshot through an error" }, { kind: "by", verb: "by", text: "mochi" }] } };
+}
+// With the news, Mochi's state as the island's handler sets it. `view=pill`
+// shows it the way it arrives with the island away: GitHub's Mochi at the
+// front, the island unfolded on the finished or the error card.
+const news = State.integrations.integration_github.news;
+const pill = params.get("view") === "pill";
+if (news) {
+ const task = State.tasks.find((t) => t.id === "integration_github");
+ if (task) {
+ task.state = news.success ? "finished" : "error";
+ task.steps = news.detail ? [news.label, news.detail] : [news.label];
+ task.stepIndex = task.steps.length - 1;
+ }
+}
+State.setFocus("integration_github");
+// Pinned, so the island doesn't fold away while it's being looked at.
+State.isPinned = true;
+
+const island = new Island(document.getElementById("root")!);
+if (pill && news) island.alert(news.success ? "finished" : "error");
+else island.alert(params.get("view") === "overview" ? "overview" : "github");
+
+// The tab is the panel's own state; get there the way a person would.
+if (params.get("tab") === "projects") {
+ requestAnimationFrame(() => {
+ document.querySelector(".gh-trail .gh-step:nth-child(2)")?.click();
+ if (params.has("sheet")) {
+ requestAnimationFrame(() => document.querySelector(".gh-list .gh-row:nth-child(2)")?.click());
+ }
+ });
+}
+
+// `pr`: straight to the project's pull request, once its sheet is up. With
+// `talk`, on to what was said on it; `talk=code`, on to its first thread's line.
+if (params.has("pr")) {
+ const steps = [
+ () => document.querySelectorAll(".gh-sheet .gh-block")[1],
+ ...(params.has("talk") ? [() => [...document.querySelectorAll(".gh-sheet button.gh-block")].find((b) => /in the conversation|No comments yet/.test(b.textContent ?? ""))] : []),
+ ...(params.get("talk") === "code" ? [() => document.querySelector(".gh-thread-head")] : []),
+ ];
+ const walk = window.setInterval(() => {
+ const next = steps[0]?.();
+ if (!next) return;
+ steps.shift();
+ next.click();
+ if (steps.length === 0) window.clearInterval(walk);
+ }, 200);
+}
diff --git a/windows/screenshots/github-activity.png b/windows/screenshots/github-activity.png
new file mode 100644
index 0000000000..c89ec404f1
Binary files /dev/null and b/windows/screenshots/github-activity.png differ
diff --git a/windows/screenshots/github-card.png b/windows/screenshots/github-card.png
new file mode 100644
index 0000000000..7de441f80a
Binary files /dev/null and b/windows/screenshots/github-card.png differ
diff --git a/windows/screenshots/github-news-merge.png b/windows/screenshots/github-news-merge.png
new file mode 100644
index 0000000000..44bcb52006
Binary files /dev/null and b/windows/screenshots/github-news-merge.png differ
diff --git a/windows/screenshots/github-project.png b/windows/screenshots/github-project.png
new file mode 100644
index 0000000000..a909b871b2
Binary files /dev/null and b/windows/screenshots/github-project.png differ
diff --git a/windows/screenshots/github-review.png b/windows/screenshots/github-review.png
new file mode 100644
index 0000000000..0cc1c131f1
Binary files /dev/null and b/windows/screenshots/github-review.png differ
diff --git a/windows/screenshots/github-run.png b/windows/screenshots/github-run.png
new file mode 100644
index 0000000000..6f4d603740
Binary files /dev/null and b/windows/screenshots/github-run.png differ
diff --git a/windows/screenshots/github-settings.png b/windows/screenshots/github-settings.png
new file mode 100644
index 0000000000..d9d29db481
Binary files /dev/null and b/windows/screenshots/github-settings.png differ
diff --git a/windows/screenshots/github-thread.png b/windows/screenshots/github-thread.png
new file mode 100644
index 0000000000..7c47e3d379
Binary files /dev/null and b/windows/screenshots/github-thread.png differ
diff --git a/windows/src-tauri/src/github.rs b/windows/src-tauri/src/github.rs
new file mode 100644
index 0000000000..bf323ebf79
--- /dev/null
+++ b/windows/src-tauri/src/github.rs
@@ -0,0 +1,2554 @@
+// GitHub — everything behind the GitHub pill: the panel's data (profile, recent
+// activity, projects and their last Actions run), the client, the rate-limit
+// bookkeeping and the connection test in the settings window.
+//
+// The token is a fine-grained, read-only personal access token kept in the
+// Credential Manager under `github-token`. It goes into the Authorization header
+// and nowhere else: never into a URL, never into the log, never back to the
+// front end.
+//
+// When things happen: integrations.rs ticks every two minutes, island shown or
+// not — the tick is what tells the pill a build broke — and `watch_live` every
+// twenty seconds while a run is going. The Refresh button and opening the
+// panel refresh too. Sheets are fetched on the click only.
+
+use std::collections::HashMap;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::{LazyLock, Mutex};
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
+
+use reqwest::header::HeaderMap;
+use reqwest::{Method, RequestBuilder};
+use serde::Serialize;
+use serde_json::{json, Value};
+use tauri::{AppHandle, Manager};
+
+use crate::github_detail::Target;
+use crate::integrations::{emit, IntegrationEvent, IntegrationUpdate};
+use crate::log;
+use crate::secrets;
+
+const ID: &str = "integration_github";
+
+const API: &str = "https://api.github.com";
+/// GitHub's own site: a page's address is built on it when GitHub gave no link.
+pub(crate) const WEB: &str = "https://github.com";
+/// Pinned so a change of default on GitHub's side can't reshape the answers.
+const API_VERSION: &str = "2022-11-28";
+const TIMEOUT: Duration = Duration::from_secs(10);
+pub(crate) const TOKEN_KEY: &str = "github-token";
+/// The wait GitHub's documentation asks for when it gives no length: a minute.
+const MINUTE: u64 = 60;
+/// How much of GitHub's own words about a failed query the log keeps.
+const LOG_CHARS: usize = 160;
+const ETAG: &str = "etag";
+
+/// What stands in for a name GitHub left out.
+pub(crate) const UNTITLED: &str = "Untitled";
+pub(crate) const WORKFLOW: &str = "Workflow";
+const OTHER: &str = "Other";
+const CREATED: &str = "Created the repository";
+
+/// A commit as people write it: the first seven characters of its SHA.
+pub(crate) const SHORT_SHA: usize = 7;
+
+pub(crate) fn short_sha(sha: &str) -> String {
+ sha.chars().take(SHORT_SHA).collect()
+}
+
+/// The first line of a message: a commit's title, a run's.
+pub(crate) fn first_line(message: String) -> Option {
+ message.lines().next().map(str::to_string)
+}
+
+/// A count GitHub gave, or zero: `pointer` is a JSON pointer into `node`.
+pub(crate) fn int(node: &Value, pointer: &str) -> i64 {
+ node.pointer(pointer).and_then(Value::as_i64).unwrap_or(0)
+}
+
+/// A yes or no GitHub gave, or no.
+pub(crate) fn flag(node: &Value, key: &str) -> bool {
+ node.get(key).and_then(Value::as_bool).unwrap_or(false)
+}
+
+/// The entries of a list in an answer: `pointer` leads to the array.
+pub(crate) fn nodes<'a>(value: &'a Value, pointer: &str) -> &'a [Value] {
+ value.pointer(pointer).and_then(Value::as_array).map_or(&[], Vec::as_slice)
+}
+
+/// The token's permissions, by the names GitHub's settings page gives them:
+/// what a sheet says it is missing, and what the connection test checks.
+pub(crate) mod permission {
+ pub const ACTIONS: &str = "Actions";
+ pub const CONTENTS: &str = "Contents";
+ pub const DEPLOYMENTS: &str = "Deployments";
+ pub const ISSUES: &str = "Issues";
+ pub const PULL_REQUESTS: &str = "Pull requests";
+}
+
+/// Where a pull request stands — open, draft, merged or closed.
+pub(crate) fn pull_state(node: &Value) -> &'static str {
+ match node.get("state").and_then(Value::as_str) {
+ Some("MERGED") => "merged",
+ Some("CLOSED") => "closed",
+ _ if flag(node, "isDraft") => "draft",
+ _ => "open",
+ }
+}
+
+/// What its reviews add up to — approved, changes requested or review
+/// required; None when no review was asked for.
+pub(crate) fn review_decision(node: &Value) -> Option<&'static str> {
+ match node.get("reviewDecision").and_then(Value::as_str) {
+ Some("APPROVED") => Some("approved"),
+ Some("CHANGES_REQUESTED") => Some("changes requested"),
+ Some("REVIEW_REQUIRED") => Some("review required"),
+ _ => None,
+ }
+}
+
+/// Answers kept for a while under a key: asked for again soon enough, the
+/// kept one is given back. What has outlived every use is dropped as new
+/// answers come in, so the map holds what was looked at lately, not
+/// everything ever opened.
+pub(crate) struct Kept(Mutex>);
+
+/// Past this no kept answer is still good: the longest any of them is trusted.
+const KEPT_AT_MOST: u64 = PAST_DAY_TTL;
+
+impl Kept {
+ pub(crate) fn new() -> Self {
+ Self(Mutex::new(HashMap::new()))
+ }
+
+ /// The answer under `key`, if it is younger than `ttl` says it may be.
+ pub(crate) fn fresh(&self, key: &str, now: u64, ttl: impl FnOnce(&T) -> u64) -> Option {
+ let kept = self.0.lock().unwrap();
+ let (at, value) = kept.get(key)?;
+ (now.saturating_sub(*at) < ttl(value)).then(|| value.clone())
+ }
+
+ pub(crate) fn keep(&self, key: String, now: u64, value: T) {
+ let mut kept = self.0.lock().unwrap();
+ kept.retain(|_, (at, _)| now.saturating_sub(*at) < KEPT_AT_MOST);
+ kept.insert(key, (now, value));
+ }
+
+ pub(crate) fn clear(&self) {
+ self.0.lock().unwrap().clear();
+ }
+}
+
+/// "owner/name" → "name".
+pub(crate) fn short_name(full_name: &str) -> &str {
+ full_name.rsplit('/').next().unwrap_or(full_name)
+}
+
+/// Why a GitHub call gave nothing usable. `message()` is what the island and the
+/// settings window show, so it has to make sense to someone who never saw an
+/// HTTP status code.
+#[derive(Debug, PartialEq)]
+pub enum GhError {
+ NoToken,
+ /// 401 — wrong, revoked or expired token.
+ Unauthorized,
+ /// 403 that is not a rate limit: the token can't see this.
+ Forbidden,
+ NotFound,
+ /// Nothing goes out before this Unix time (seconds).
+ RateLimited(u64),
+ Offline,
+ Status(u16),
+ BadResponse,
+}
+
+impl GhError {
+ pub fn message(&self) -> String {
+ match self {
+ GhError::NoToken => "No token saved — Settings → GitHub".into(),
+ GhError::Unauthorized => "Token refused (401): invalid or expired".into(),
+ GhError::Forbidden => "Missing permission (403)".into(),
+ GhError::NotFound => "Not found (404)".into(),
+ GhError::RateLimited(until) => {
+ format!("GitHub rate limit — {}", wait_text(*until, unix_now()))
+ }
+ GhError::Offline => "No connection".into(),
+ GhError::Status(code) => format!("GitHub error {code}"),
+ GhError::BadResponse => "Unexpected answer from GitHub".into(),
+ }
+ }
+}
+
+impl From for String {
+ fn from(e: GhError) -> String {
+ e.message()
+ }
+}
+
+pub(crate) fn unix_now() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_secs())
+ .unwrap_or(0)
+}
+
+/// "back in 12 min" — relative, so no time zone is involved.
+fn wait_text(until: u64, now: u64) -> String {
+ let minutes = until.saturating_sub(now).div_ceil(60);
+ if minutes <= 1 {
+ "back in a minute".into()
+ } else {
+ format!("back in {minutes} min")
+ }
+}
+
+// ── Rate limit ────────────────────────────────────────────────────────────────
+
+/// Set from GitHub's own headers. Until then no GitHub request leaves at all:
+/// pushing on after a rate-limit answer is what gets a token blocked for longer.
+static BLOCKED_UNTIL: Mutex = Mutex::new(0);
+
+fn blocked(now: u64) -> Option {
+ let until = *BLOCKED_UNTIL.lock().unwrap();
+ (until > now).then_some(until)
+}
+
+fn block_until(until: u64) {
+ let mut current = BLOCKED_UNTIL.lock().unwrap();
+ *current = (*current).max(until);
+}
+
+/// Until when GitHub asked us to stay quiet, read from one answer.
+///
+/// Primary limit: `x-ratelimit-remaining` reaches 0 and `x-ratelimit-reset` says
+/// when the window reopens — even on a 200, since the next call would fail.
+/// Secondary limit: a 403/429 with `retry-after`, or with no hint at all, in
+/// which case GitHub's documentation says to wait at least a minute.
+fn rate_block(
+ status: u16,
+ remaining: Option,
+ reset: Option,
+ retry_after: Option,
+ says_rate_limit: bool,
+ now: u64,
+) -> Option {
+ let refused = status == 403 || status == 429;
+ if refused {
+ if let Some(secs) = retry_after {
+ return Some(now + secs.max(1));
+ }
+ }
+ if remaining == Some(0) {
+ return Some(reset.filter(|r| *r > now).unwrap_or(now + MINUTE));
+ }
+ if status == 429 || (refused && says_rate_limit) {
+ return Some(now + MINUTE);
+ }
+ None
+}
+
+fn header_u64(headers: &HeaderMap, name: &str) -> Option {
+ headers.get(name)?.to_str().ok()?.trim().parse().ok()
+}
+
+fn header_str(headers: &HeaderMap, name: &str) -> Option {
+ headers
+ .get(name)?
+ .to_str()
+ .ok()
+ .map(|s| s.trim().to_string())
+ .filter(|s| !s.is_empty())
+}
+
+// ── Client ────────────────────────────────────────────────────────────────────
+
+pub struct Reply {
+ pub json: Value,
+ pub headers: HeaderMap,
+}
+
+pub struct Gh {
+ token: String,
+}
+
+/// One client for every call, so a connection to GitHub opened for one is
+/// there for the next: the token is what changes, and it rides in a header.
+static HTTP: LazyLock =
+ LazyLock::new(|| reqwest::Client::builder().timeout(TIMEOUT).build().unwrap_or_default());
+
+impl Gh {
+ /// Reads the token from the Credential Manager, each time something is
+ /// asked: a token changed in the settings window is the one used next.
+ pub fn from_store() -> Result {
+ Ok(Self { token: secrets::get(TOKEN_KEY).ok_or(GhError::NoToken)? })
+ }
+
+ fn request(&self, method: Method, url: &str) -> RequestBuilder {
+ HTTP.request(method, url)
+ .header("Authorization", format!("Bearer {}", self.token))
+ .header("Accept", "application/vnd.github+json")
+ .header("X-GitHub-Api-Version", API_VERSION)
+ .header("User-Agent", "Coucou")
+ }
+
+ /// REST GET. `path` starts with `/`.
+ pub async fn get(&self, path: &str) -> Result {
+ let request = self.request(Method::GET, &format!("{API}{path}"));
+ self.send(request, path).await?.ok_or(GhError::BadResponse)
+ }
+
+ /// REST GET that sends back the ETag of the previous answer. `None` means
+ /// "unchanged since then" — a 304, which GitHub does not count against the
+ /// rate limit, and the reason polling stays free when nothing happens.
+ pub async fn get_if_changed(&self, path: &str, etag: Option<&str>) -> Result, GhError> {
+ let mut request = self.request(Method::GET, &format!("{API}{path}"));
+ if let Some(etag) = etag {
+ request = request.header("If-None-Match", etag);
+ }
+ self.send(request, path).await
+ }
+
+ /// GraphQL query → its `data`. A partial answer (some fields refused) still
+ /// comes back as data, with those fields null; only a query that produced no
+ /// data at all is an error.
+ pub async fn graphql(&self, query: &str) -> Result {
+ self.graphql_with(query, json!({})).await.map(|(data, _)| data)
+ }
+
+ /// Same, with variables — so a repository name is never pasted into the
+ /// query text — and the errors of a partial answer, which say which fields
+ /// the token was refused.
+ pub async fn graphql_with(&self, query: &str, variables: Value) -> Result<(Value, Vec), GhError> {
+ let request = self
+ .request(Method::POST, &format!("{API}/graphql"))
+ .json(&json!({ "query": query, "variables": variables }));
+ let mut reply = self.send(request, "/graphql").await?.ok_or(GhError::BadResponse)?;
+ let errors = match reply.json.get_mut("errors").map(Value::take) {
+ Some(Value::Array(errors)) => errors,
+ _ => Vec::new(),
+ };
+ match reply.json.get_mut("data").map(Value::take) {
+ Some(data) if !data.is_null() => Ok((data, errors)),
+ _ => {
+ // An HTTP 200 that failed: `send` logged nothing. Only GitHub's
+ // own words about the query — no data, no token.
+ let message: String = errors
+ .first()
+ .and_then(|e| e.get("message"))
+ .and_then(Value::as_str)
+ .unwrap_or("")
+ .chars()
+ .take(LOG_CHARS)
+ .collect();
+ log::line(format!("github /graphql → {message}"));
+ Err(graphql_error(&errors))
+ }
+ }
+ }
+
+ /// `Ok(None)` is a 304 Not Modified.
+ async fn send(&self, request: RequestBuilder, what: &str) -> Result, GhError> {
+ let now = unix_now();
+ if let Some(until) = blocked(now) {
+ return Err(GhError::RateLimited(until));
+ }
+
+ let response = request.send().await.map_err(|_| GhError::Offline)?;
+ let status = response.status().as_u16();
+ let headers = response.headers().clone();
+ let body = response.text().await.map_err(|_| GhError::Offline)?;
+
+ let says_rate_limit = matches!(status, 403 | 429) && body.to_lowercase().contains("rate limit");
+ if let Some(until) = rate_block(
+ status,
+ header_u64(&headers, "x-ratelimit-remaining"),
+ header_u64(&headers, "x-ratelimit-reset"),
+ header_u64(&headers, "retry-after"),
+ says_rate_limit,
+ now,
+ ) {
+ block_until(until);
+ if status >= 400 {
+ log::line(format!("github {what} → {status}, rate limited for {}s", until - now));
+ return Err(GhError::RateLimited(until));
+ }
+ }
+
+ match status {
+ 200..=299 => {
+ let json = serde_json::from_str(&body).map_err(|_| GhError::BadResponse)?;
+ Ok(Some(Reply { json, headers }))
+ }
+ 304 => Ok(None),
+ _ => {
+ // The path and the status only: the body can quote the request.
+ log::line(format!("github {what} → {status}"));
+ Err(match status {
+ 401 => GhError::Unauthorized,
+ 403 => GhError::Forbidden,
+ 404 => GhError::NotFound,
+ other => GhError::Status(other),
+ })
+ }
+ }
+ }
+}
+
+/// A GraphQL answer comes back as HTTP 200 even when it failed; the reason is
+/// in `errors[].type`.
+fn graphql_error(errors: &[Value]) -> GhError {
+ let kind = errors.first().and_then(|e| e.get("type")).and_then(Value::as_str).unwrap_or("");
+ match kind {
+ "RATE_LIMITED" => {
+ let until = unix_now() + MINUTE;
+ block_until(until);
+ GhError::RateLimited(until)
+ }
+ "FORBIDDEN" | "INSUFFICIENT_SCOPES" => GhError::Forbidden,
+ "NOT_FOUND" => GhError::NotFound,
+ _ => GhError::BadResponse,
+ }
+}
+
+// ── What the island receives ──────────────────────────────────────────────────
+
+/// The `data` of the `integration_github` update.
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Snapshot {
+ pub login: String,
+ pub profile_url: String,
+ pub total_stars: i64,
+ /// Newest first.
+ pub activity: Vec,
+ /// Most recently pushed first.
+ pub repos: Vec,
+ /// The year of contributions behind the graph; None if GitHub gave none.
+ pub contributions: Option,
+ /// The open pull requests waiting for your review, most recently updated first.
+ pub to_review: Vec,
+ /// Unix milliseconds of the last complete refresh, so the panel can say how
+ /// old what it shows is when GitHub can't be reached.
+ pub fetched_at: u64,
+}
+
+/// The contribution calendar, day by day from `start`. Two flat lists rather
+/// than 371 objects: this rides along with every update of the pill.
+#[derive(Serialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct Contributions {
+ pub total: i64,
+ /// First day, "YYYY-MM-DD"; the lists run one entry per day from there.
+ pub start: String,
+ pub counts: Vec,
+ /// GitHub's own quartiles, 0 (none) to 4 (busiest), so the graph shades
+ /// days the way the profile page does.
+ pub levels: Vec,
+}
+
+#[derive(Serialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct Repo {
+ /// "owner/name".
+ pub full_name: String,
+ pub url: String,
+ pub private: bool,
+ pub language: Option,
+ /// GitHub's colour for the language, e.g. "#dea584" for Rust.
+ pub language_color: Option,
+ pub stars: i64,
+ pub open_prs: i64,
+ pub pushed_at: Option,
+ /// The newest Actions run on any branch. None: no workflow, or the token
+ /// can't read Actions for this repository.
+ pub build: Option,
+}
+
+#[derive(Serialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct Build {
+ pub id: u64,
+ /// success, failure, running or neutral (cancelled, skipped…).
+ pub state: &'static str,
+ /// The workflow's name, e.g. "CI".
+ pub workflow: String,
+ pub branch: Option,
+ pub url: String,
+ pub at: String,
+}
+
+#[derive(Serialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct Activity {
+ /// push, pr_opened, pr_merged, pr_closed, issue_opened, issue_closed,
+ /// release or create.
+ pub kind: &'static str,
+ /// "owner/name".
+ pub repo: String,
+ pub title: String,
+ /// "#12", a branch, a tag — whatever tells two similar lines apart.
+ pub detail: Option,
+ pub url: String,
+ /// ISO 8601, as GitHub sends it.
+ pub at: String,
+ /// The sheet a click on the line opens in the panel; None: straight to GitHub.
+ pub target: Option,
+}
+
+/// An open pull request somebody asked you to review.
+#[derive(Serialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct ReviewRequest {
+ /// "owner/name".
+ pub repo: String,
+ pub number: u64,
+ pub title: String,
+ pub url: String,
+ /// None for an account that is gone.
+ pub author: Option,
+ pub draft: bool,
+ /// When it last changed, ISO 8601.
+ pub at: String,
+ /// Its sheet in the panel.
+ pub target: Target,
+}
+
+/// Enough for the panel, with room left for the kinds we skip.
+const MAX_ACTIVITY: usize = 20;
+/// The events feed goes back 30 days and 300 events; 50 is plenty once
+/// comments, stars and branch creations are filtered out.
+const EVENTS_PAGE: usize = 50;
+/// GitHub's own floor when it doesn't send X-Poll-Interval.
+const DEFAULT_POLL_INTERVAL: u64 = 60;
+/// Projects in the panel, each costing one Actions request per refresh — a free
+/// one (304) as long as nothing ran.
+const MAX_REPOS: usize = 8;
+
+#[derive(Default)]
+struct Cache {
+ snapshot: Option,
+ /// URL and ETag of the last events answer.
+ events_etag: Option<(String, String)>,
+ /// X-Poll-Interval: the events feed is not asked again before this.
+ events_not_before: u64,
+ /// Per Actions URL: the ETag of the last answer and what it said.
+ runs: HashMap)>,
+ /// Per Actions URL the token was refused: not asked again before this.
+ no_runs: HashMap,
+ /// When the latest merge known happened, so only a later one is news.
+ /// None until the first refresh, which announces nothing.
+ merged: Option,
+ /// The same for the latest pull request somebody else opened.
+ opened: Option,
+ /// The pull requests that were waiting for your review at the last look,
+ /// so only one that joins them is news. None until the first refresh.
+ to_review: Option>,
+ /// Your open pull requests and how their checks stood at the last look,
+ /// so only a change is news. None until the first refresh.
+ checked: Option>,
+}
+
+static CACHE: LazyLock> = LazyLock::new(|| Mutex::new(Cache::default()));
+
+/// One refresh at a time: the tick and a Refresh click can land
+/// together, and two refreshes racing would each spend the rate limit.
+static BUSY: AtomicBool = AtomicBool::new(false);
+
+struct BusyGuard;
+
+impl BusyGuard {
+ /// None while another refresh is under way.
+ fn take() -> Option {
+ (!BUSY.swap(true, Ordering::SeqCst)).then_some(BusyGuard)
+ }
+}
+
+impl Drop for BusyGuard {
+ fn drop(&mut self) {
+ BUSY.store(false, Ordering::SeqCst);
+ }
+}
+
+/// The tick: every two minutes rather than the other pills' five, since it is
+/// also what tells the pill that a build broke, and "a while ago" is not news.
+pub(crate) const TICK_SECS: u64 = 120;
+/// How often GitHub is asked while one of the projects has a run going.
+const LIVE_SECS: u64 = 20;
+
+/// The two-minute tick is fine for news, not for watching a run: a build that
+/// takes three minutes would be seen starting and never seen ending. While
+/// any project's newest run is going, the runs are asked about every twenty
+/// seconds instead, island open or not, until none is. It asks nothing on its
+/// own while everything is at rest, and nothing at all while Coucou is paused
+/// or GitHub is switched off.
+pub fn watch_live(app: AppHandle) {
+ tauri::async_runtime::spawn(async move {
+ loop {
+ tokio::time::sleep(Duration::from_secs(LIVE_SECS)).await;
+ let going = CACHE
+ .lock()
+ .unwrap()
+ .snapshot
+ .as_ref()
+ .is_some_and(|s| s.repos.iter().any(|r| r.build.as_ref().is_some_and(|b| b.state == "running")));
+ if !going || crate::integrations::PAUSED.load(Ordering::Relaxed) || !crate::integrations::enabled(&app, ID) {
+ continue;
+ }
+ refresh_builds(app.clone()).await;
+ }
+ });
+}
+
+/// A pull request of yours that went in.
+struct Merged {
+ repo: String,
+ number: u64,
+ title: String,
+ url: String,
+ /// ISO 8601 in UTC, as GitHub sends it: these sort as text.
+ at: String,
+}
+
+fn parse_merged(viewer: &Value) -> Vec {
+ nodes(viewer, "/merged/nodes")
+ .iter()
+ .filter_map(|n| {
+ let number = n.get("number")?.as_u64()?;
+ let repo = text(n.pointer("/repository/nameWithOwner"))?;
+ Some(Merged {
+ number,
+ title: text(n.get("title")).unwrap_or_default(),
+ url: text(n.get("url")).unwrap_or_else(|| format!("{WEB}/{repo}/pull/{number}")),
+ at: text(n.get("mergedAt"))?,
+ repo,
+ })
+ })
+ .collect()
+}
+
+/// A pull request open on one of your projects.
+struct Opened {
+ repo: String,
+ number: u64,
+ title: String,
+ url: String,
+ /// None for an account that is gone.
+ author: Option,
+ /// ISO 8601 in UTC, as GitHub sends it: these sort as text.
+ at: String,
+}
+
+/// The newest open pull requests of every project the panel lists. A project
+/// you own that you also contributed to comes back in both lists: once is enough.
+fn parse_opened(viewer: &Value) -> Vec {
+ let mut seen = std::collections::HashSet::new();
+ nodes(viewer, "/repositories/nodes")
+ .iter()
+ .chain(nodes(viewer, "/repositoriesContributedTo/nodes"))
+ .filter(|project| !flag(project, "isArchived"))
+ .filter_map(|project| Some((text(project.get("nameWithOwner"))?, nodes(project, "/pullRequests/nodes"))))
+ .flat_map(|(repo, pulls)| {
+ pulls.iter().filter_map(move |n| {
+ let number = n.get("number")?.as_u64()?;
+ Some(Opened {
+ number,
+ title: text(n.get("title")).unwrap_or_default(),
+ url: text(n.get("url")).unwrap_or_else(|| format!("{WEB}/{repo}/pull/{number}")),
+ author: text(n.pointer("/author/login")),
+ at: text(n.get("createdAt"))?,
+ repo: repo.clone(),
+ })
+ })
+ })
+ .filter(|pull| seen.insert((pull.repo.clone(), pull.number)))
+ .collect()
+}
+
+/// The open pull requests your review is asked on. A search rather than a
+/// field of the viewer: GitHub has no other list of them.
+fn parse_to_review(data: &Value) -> Vec {
+ let mut seen = std::collections::HashSet::new();
+ nodes(data, "/toReview/nodes")
+ .iter()
+ .filter_map(|n| {
+ let number = n.get("number")?.as_u64()?;
+ let repo = text(n.pointer("/repository/nameWithOwner"))?;
+ Some(ReviewRequest {
+ number,
+ title: text(n.get("title")).unwrap_or_default(),
+ url: text(n.get("url")).unwrap_or_else(|| format!("{WEB}/{repo}/pull/{number}")),
+ author: text(n.pointer("/author/login")),
+ draft: flag(n, "isDraft"),
+ at: text(n.get("updatedAt"))?,
+ target: Target::Pull { repo: repo.clone(), number },
+ repo,
+ })
+ })
+ .filter(|pull| seen.insert((pull.repo.clone(), pull.number)))
+ .collect()
+}
+
+/// One of your open pull requests, and how the checks of its last commit stand.
+#[derive(Clone, Debug, PartialEq)]
+struct Checked {
+ repo: String,
+ number: u64,
+ title: String,
+ url: String,
+ /// The commit the checks ran for.
+ sha: Option,
+ /// success, failure or pending; None when it has no checks, or the token
+ /// may not read them.
+ state: Option<&'static str>,
+}
+
+/// GitHub's roll-up of a commit's checks, in three words.
+fn rollup_state(state: Option<&str>) -> Option<&'static str> {
+ match state? {
+ "SUCCESS" => Some("success"),
+ "FAILURE" | "ERROR" => Some("failure"),
+ "PENDING" | "EXPECTED" => Some("pending"),
+ _ => None,
+ }
+}
+
+fn parse_checked(viewer: &Value) -> Vec {
+ nodes(viewer, "/mine/nodes")
+ .iter()
+ .filter_map(|n| {
+ let number = n.get("number")?.as_u64()?;
+ let repo = text(n.pointer("/repository/nameWithOwner"))?;
+ let commit = n.pointer("/commits/nodes/0/commit");
+ Some(Checked {
+ number,
+ title: text(n.get("title")).unwrap_or_default(),
+ url: text(n.get("url")).unwrap_or_else(|| format!("{WEB}/{repo}/pull/{number}")),
+ sha: text(commit.and_then(|c| c.get("oid"))),
+ state: rollup_state(commit.and_then(|c| c.pointer("/statusCheckRollup/state")).and_then(Value::as_str)),
+ repo,
+ })
+ })
+ .collect()
+}
+
+/// A piece of news, and what to ask GitHub about to say more of it on the card.
+type News = (IntegrationEvent, Target);
+
+/// The projects the user asked not to hear about (Settings.github_muted).
+fn muted(app: &AppHandle) -> Vec {
+ app.try_state::()
+ .map(|shared| shared.settings.lock().unwrap().github_muted.clone())
+ .unwrap_or_default()
+}
+
+/// A build that broke since the last look. Nothing for a failure already told,
+/// nor for a project that just entered the list: it has no "before" — nor for
+/// a project that is muted.
+fn broke(before: &[Repo], now: &[Repo], muted: &[String]) -> Option {
+ now.iter().filter(|repo| !muted.contains(&repo.full_name)).find_map(|repo| {
+ let build = repo.build.as_ref().filter(|b| b.state == "failure")?;
+ let was = before.iter().find(|r| r.full_name == repo.full_name)?;
+ let told = was.build.as_ref().is_some_and(|b| b.id == build.id && b.state == "failure");
+ if told {
+ return None;
+ }
+ let target = Target::Run { repo: repo.full_name.clone(), id: build.id };
+ let event = IntegrationEvent {
+ success: false,
+ label: format!("{} failed on {}", build.workflow, short_name(&repo.full_name)),
+ detail: build.branch.clone(),
+ open: Some(json!({ "target": target, "label": build.workflow, "url": build.url, "says": "a build broke" })),
+ };
+ Some((event, target))
+ })
+}
+
+/// A pull request merged since the last look: later than the latest merge
+/// known (`seen`), which is None on the first refresh — it only learns how
+/// things stand. An old pull request that comes back up the list because
+/// somebody commented on it was merged when it was merged: not news.
+fn went_in(seen: Option<&str>, merged: &[Merged]) -> Option {
+ let seen = seen?;
+ let pull = merged.iter().filter(|m| m.at.as_str() > seen).max_by(|a, b| a.at.cmp(&b.at))?;
+ let target = Target::Pull { repo: pull.repo.clone(), number: pull.number };
+ let event = IntegrationEvent {
+ success: true,
+ label: format!("#{} merged", pull.number),
+ detail: Some(pull.title.clone()).filter(|t| !t.is_empty()),
+ open: Some(json!({
+ "target": target,
+ "label": format!("#{}", pull.number),
+ "url": pull.url,
+ "title": format!("#{} {}", pull.number, pull.title),
+ "says": "pull request merged",
+ })),
+ };
+ Some((event, target))
+}
+
+/// A pull request somebody else opened on one of your projects since the last
+/// look: later than the latest one known (`seen`), which is None on the first
+/// refresh — it only learns how things stand. One of your own is not news to you.
+fn came_in(seen: Option<&str>, opened: &[Opened], login: &str) -> Option {
+ let seen = seen?;
+ let pull = opened
+ .iter()
+ .filter(|p| p.at.as_str() > seen && p.author.as_deref() != Some(login))
+ .max_by(|a, b| a.at.cmp(&b.at))?;
+ let target = Target::Pull { repo: pull.repo.clone(), number: pull.number };
+ let event = IntegrationEvent {
+ success: true,
+ label: format!("#{} opened on {}", pull.number, short_name(&pull.repo)),
+ detail: Some(pull.title.clone()).filter(|t| !t.is_empty()),
+ open: Some(json!({
+ "target": target,
+ "label": format!("#{}", pull.number),
+ "url": pull.url,
+ "title": format!("#{} {}", pull.number, pull.title),
+ "says": "pull request opened",
+ })),
+ };
+ Some((event, target))
+}
+
+/// A pull request your review was asked on since the last look: one that was
+/// not among those waiting (`seen`), which is None on the first refresh — it
+/// only learns how things stand. GitHub does not say when the request was
+/// made, so being new to the list is what makes it news.
+fn asked(seen: Option<&[(String, u64)]>, to_review: &[ReviewRequest], muted: &[String]) -> Option {
+ let seen = seen?;
+ let pull = to_review
+ .iter()
+ .find(|p| !muted.contains(&p.repo) && !seen.iter().any(|(repo, number)| *repo == p.repo && *number == p.number))?;
+ let event = IntegrationEvent {
+ success: true,
+ label: format!("Review requested on {}", short_name(&pull.repo)),
+ detail: Some(pull.title.clone()).filter(|t| !t.is_empty()),
+ open: Some(json!({
+ "target": pull.target,
+ "label": format!("#{}", pull.number),
+ "url": pull.url,
+ "title": format!("#{} {}", pull.number, pull.title),
+ "says": "review requested",
+ })),
+ };
+ Some((event, pull.target.clone()))
+}
+
+/// The checks of one of your pull requests that ended since the last look, red
+/// (`passed` false) or green. On the commit already seen, red is news once it
+/// was not red, green once it was pending. On a new commit the checks may have
+/// started and ended between two looks: how they ended is news as it is. A
+/// pull request that was not there at the last look has no "before": nothing.
+fn checks_ended(seen: Option<&[Checked]>, now: &[Checked], muted: &[String], passed: bool) -> Option {
+ let seen = seen?;
+ let want = if passed { "success" } else { "failure" };
+ let pull = now.iter().filter(|p| p.state == Some(want) && !muted.contains(&p.repo)).find(|p| {
+ let Some(was) = seen.iter().find(|w| w.repo == p.repo && w.number == p.number) else { return false };
+ if was.sha != p.sha {
+ return true;
+ }
+ if passed { was.state == Some("pending") } else { was.state != Some("failure") }
+ })?;
+ let target = Target::Pull { repo: pull.repo.clone(), number: pull.number };
+ let event = IntegrationEvent {
+ success: passed,
+ label: format!("Checks {} on #{}", if passed { "passed" } else { "failed" }, pull.number),
+ detail: Some(pull.title.clone()).filter(|t| !t.is_empty()),
+ open: Some(json!({
+ "target": target,
+ "label": format!("#{}", pull.number),
+ "url": pull.url,
+ "title": format!("#{} {}", pull.number, pull.title),
+ "says": if passed { "checks passed" } else { "checks failed" },
+ })),
+ };
+ Some((event, target))
+}
+
+/// What the card says under a pull request somebody opened: where, by whom, how big.
+fn opened_facts(pull: &crate::github_detail::PullDetail) -> Vec {
+ let mut facts = vec![json!({ "kind": "repo", "text": short_name(&pull.repo) })];
+ if let Some(who) = &pull.author {
+ facts.push(json!({ "kind": "by", "verb": "opened by", "text": who }));
+ }
+ facts.push(json!({ "kind": "diff", "additions": pull.additions, "deletions": pull.deletions }));
+ let files = pull.changed_files;
+ facts.push(json!({ "kind": "files", "text": if files == 1 { "1 file".to_string() } else { format!("{files} files") } }));
+ facts
+}
+
+/// What the card says under a merged pull request: where, by whom, how big.
+///
+/// Each fact says what it is (`kind`), so the island can draw it as what it
+/// is — a name, a size in green and red, a branch — rather than as one grey
+/// sentence.
+fn merge_facts(pull: &crate::github_detail::PullDetail) -> Vec {
+ let mut facts = vec![json!({ "kind": "repo", "text": short_name(&pull.repo) })];
+ if let Some(who) = &pull.merged_by {
+ facts.push(json!({ "kind": "by", "verb": "merged by", "text": who }));
+ }
+ facts.push(json!({ "kind": "diff", "additions": pull.additions, "deletions": pull.deletions }));
+ let files = pull.changed_files;
+ facts.push(json!({ "kind": "files", "text": if files == 1 { "1 file".to_string() } else { format!("{files} files") } }));
+ facts
+}
+
+/// What the card says under a build that broke: the job and the step it broke
+/// at, the branch, the commit it ran for, who started it.
+fn failure_facts(run: &crate::github_detail::RunDetail) -> Vec {
+ let mut facts = Vec::new();
+ if let Some(job) = run.jobs.iter().find(|j| j.state == "failure") {
+ let at = match job.steps.iter().find(|s| s.state == "failure") {
+ Some(step) => format!("{} \u{203a} {}", job.name, step.name),
+ None => job.name.clone(),
+ };
+ facts.push(json!({ "kind": "step", "text": at }));
+ }
+ if let Some(branch) = &run.branch {
+ facts.push(json!({ "kind": "branch", "text": branch }));
+ }
+ if let Some(title) = &run.title {
+ facts.push(json!({ "kind": "commit", "text": title }));
+ }
+ if let Some(actor) = &run.actor {
+ facts.push(json!({ "kind": "by", "verb": "by", "text": actor }));
+ }
+ facts
+}
+
+/// News is worth one more question to GitHub — which job and which step
+/// broke, or who merged and how much — so the card can say more than "it
+/// failed" or "it went in". Asked only then, and the answer stays in the
+/// sheets' cache for the click that opens it. If it can't be had, the news
+/// goes out as it is.
+async fn tell(event: &mut IntegrationEvent, target: Target) {
+ use crate::github_detail::{detail, Detail};
+ let Some(open) = event.open.as_mut() else { return };
+ match detail(target, false).await {
+ Ok(Detail::Run(run)) => {
+ open["title"] = json!(event.label);
+ open["facts"] = json!(failure_facts(&run));
+ }
+ Ok(Detail::Pull(pull)) if pull.state == "merged" => open["facts"] = json!(merge_facts(&pull)),
+ Ok(Detail::Pull(pull)) => open["facts"] = json!(opened_facts(&pull)),
+ _ => {}
+ }
+}
+
+/// The panel gets its data at once; the news follows on its own, once GitHub
+/// has said the little more the card tells about it.
+async fn publish(app: &AppHandle, data: Value, news: Option) {
+ emit(app, IntegrationUpdate { id: ID, data, error: None, event: None });
+ let Some((mut event, target)) = news else { return };
+ log::line(format!("github news: {} ({})", event.label, if event.success { "good" } else { "bad" }));
+ tell(&mut event, target).await;
+ // No data: the island keeps what it shows and only takes the news.
+ emit(app, IntegrationUpdate { id: ID, data: json!({}), error: None, event: Some(event) });
+}
+
+/// The token changed or went: nothing kept under the old one may show under
+/// the new — not its projects, not its days, not its merges as fresh news.
+pub fn forget() {
+ *CACHE.lock().unwrap() = Cache::default();
+ PROJECTS.clear();
+ DAYS.clear();
+ crate::github_detail::forget();
+}
+
+/// Everything the panel shows. The tick, the Refresh button, opening the panel
+/// and saving a token all land here.
+///
+/// The tick runs with the island hidden too: a build that just broke or a
+/// pull request that just went in is news for the pill, and the pill is what
+/// shows while the island is away. It stays cheap there — one GraphQL point,
+/// and the Actions and events calls answer 304, which GitHub doesn't count,
+/// as long as nothing happened.
+pub async fn refresh(app: AppHandle) {
+ let Some(_busy) = BusyGuard::take() else { return };
+
+ match fetch().await {
+ Ok((snapshot, mut merged, mut opened, checked)) => {
+ log::line(format!(
+ "github refresh: {} events, {} projects, {} with a build, {} contribution days, {} to review, {} of {} open pulls with checks",
+ snapshot.activity.len(),
+ snapshot.repos.len(),
+ snapshot.repos.iter().filter(|r| r.build.is_some()).count(),
+ snapshot.contributions.as_ref().map_or(0, |c| c.counts.len()),
+ snapshot.to_review.len(),
+ checked.iter().filter(|p| p.state.is_some()).count(),
+ checked.len(),
+ ));
+ let data = serde_json::to_value(&snapshot).unwrap_or_else(|_| json!({}));
+ let muted = muted(&app);
+ let news = {
+ let mut cache = CACHE.lock().unwrap();
+ // How things stand is learnt from every project, muted or not:
+ // one that is unmuted later does not tell what happened meanwhile.
+ let newest_merge = merged.iter().map(|m| m.at.clone()).max().unwrap_or_default();
+ let newest_pull = opened.iter().map(|p| p.at.clone()).max().unwrap_or_default();
+ merged.retain(|m| !muted.contains(&m.repo));
+ opened.retain(|p| !muted.contains(&p.repo));
+ let news = cache
+ .snapshot
+ .as_ref()
+ .and_then(|before| broke(&before.repos, &snapshot.repos, &muted))
+ // A build that broke is told as the build, with its step;
+ // red checks elsewhere — a project the panel does not
+ // list — are told as the pull request they are on.
+ .or_else(|| checks_ended(cache.checked.as_deref(), &checked, &muted, false))
+ // A pull request opened on your project with your review
+ // asked is told once, as the review it asks for.
+ .or_else(|| asked(cache.to_review.as_deref(), &snapshot.to_review, &muted))
+ .or_else(|| checks_ended(cache.checked.as_deref(), &checked, &muted, true))
+ .or_else(|| went_in(cache.merged.as_deref(), &merged))
+ .or_else(|| came_in(cache.opened.as_deref(), &opened, &snapshot.login));
+ cache.to_review = Some(snapshot.to_review.iter().map(|p| (p.repo.clone(), p.number)).collect());
+ cache.checked = Some(checked);
+ let seen = cache.merged.take().filter(|seen| *seen >= newest_merge);
+ cache.merged = Some(seen.unwrap_or(newest_merge));
+ let seen = cache.opened.take().filter(|seen| *seen >= newest_pull);
+ cache.opened = Some(seen.unwrap_or(newest_pull));
+ cache.snapshot = Some(snapshot);
+ news
+ };
+ publish(&app, data, news).await;
+ }
+ // No token: the pill says "Key not configured" on its own. Forget what a
+ // previous token showed, so a removed token doesn't leave its data up.
+ Err(GhError::NoToken) => forget(),
+ // Keep what we had: the panel shows it with the reason it's not fresh.
+ Err(e) => {
+ let data = CACHE
+ .lock()
+ .unwrap()
+ .snapshot
+ .as_ref()
+ .and_then(|s| serde_json::to_value(s).ok())
+ .unwrap_or_else(|| json!({}));
+ emit(&app, IntegrationUpdate { id: ID, data, error: Some(e.message()), event: None });
+ }
+ }
+}
+
+/// While a run is going, only the runs can have changed: they alone are asked
+/// about again — a request per project, most of them answered 304 — not the
+/// whole profile with its year of contributions. Whatever goes wrong here is
+/// left for the tick to say.
+async fn refresh_builds(app: AppHandle) {
+ let Some(_busy) = BusyGuard::take() else { return };
+ let Some(mut repos) = CACHE.lock().unwrap().snapshot.as_ref().map(|s| s.repos.clone()) else { return };
+ let Ok(gh) = Gh::from_store() else { return };
+ for repo in &mut repos {
+ let Ok(build) = latest_build(&gh, &repo.full_name).await else { return };
+ repo.build = build;
+ }
+ let muted = muted(&app);
+ let (data, news) = {
+ let mut cache = CACHE.lock().unwrap();
+ let Some(snapshot) = cache.snapshot.as_mut() else { return };
+ let news = broke(&snapshot.repos, &repos, &muted);
+ snapshot.repos = repos;
+ snapshot.fetched_at = unix_now() * 1000;
+ (serde_json::to_value(&*snapshot).unwrap_or_else(|_| json!({})), news)
+ };
+ publish(&app, data, news).await;
+}
+
+/// Repositories the star count is added up over: the hundred most recently
+/// pushed, like the macOS poller.
+const STAR_REPOS: usize = 100;
+/// Repositories contributed to that are asked for: twice what is shown, since
+/// the archived ones among them are only dropped afterwards.
+const CONTRIBUTED_REPOS: usize = MAX_REPOS * 2;
+/// Merged pull requests looked at for news.
+const MERGED_PULLS: usize = 5;
+/// Open pull requests looked at for news, the newest of each project.
+const OPENED_PULLS: usize = 3;
+/// Open pull requests of yours whose checks are watched.
+const CHECKED_PULLS: usize = 10;
+/// Pull requests waiting for your review that are asked for.
+const REVIEW_PULLS: usize = 10;
+/// How long a project whose runs the token may not read is left alone.
+const REFUSED_FOR: u64 = 3600;
+
+/// Profile, star count and projects in one request, both lists sorted by last
+/// push. The star count stays about what you own, as on macOS, and needs only
+/// that one number from each repository; the Projects tab also takes what you
+/// contributed to elsewhere — as far as the token can see, which for a
+/// fine-grained token means your own repositories, organisations it was made
+/// for, and public ones — and asks for no more of them than it shows.
+const PROFILE_QUERY: &str = "query($stars: Int!, $owned: Int!, $contributed: Int!, $merged: Int!, $opened: Int!, $review: Int!, $mine: Int!) { \
+ toReview: search(query: \"is:pr is:open archived:false review-requested:@me sort:updated-desc\", type: ISSUE, first: $review) { \
+ nodes { ... on PullRequest { number title url isDraft updatedAt author { login } repository { nameWithOwner } } } } \
+ viewer { login url \
+ contributionsCollection { contributionCalendar { totalContributions \
+ weeks { firstDay contributionDays { contributionCount contributionLevel } } } } \
+ starred: repositories(ownerAffiliations: OWNER, first: $stars, orderBy: {field: PUSHED_AT, direction: DESC}) { \
+ nodes { stargazerCount } } \
+ repositories(ownerAffiliations: OWNER, isArchived: false, first: $owned, \
+ orderBy: {field: PUSHED_AT, direction: DESC}) { nodes { ...Project } } \
+ repositoriesContributedTo(first: $contributed, includeUserRepositories: true, \
+ orderBy: {field: PUSHED_AT, direction: DESC}) { nodes { ...Project } } \
+ merged: pullRequests(states: MERGED, first: $merged, orderBy: {field: UPDATED_AT, direction: DESC}) { \
+ nodes { number title url mergedAt repository { nameWithOwner } } } \
+ mine: pullRequests(states: OPEN, first: $mine, orderBy: {field: UPDATED_AT, direction: DESC}) { \
+ nodes { number title url repository { nameWithOwner } \
+ commits(last: 1) { nodes { commit { oid statusCheckRollup { state } } } } } } } } \
+ fragment Project on Repository { nameWithOwner url isPrivate isArchived pushedAt \
+ stargazerCount primaryLanguage { name color } \
+ pullRequests(states: OPEN, first: $opened, orderBy: {field: CREATED_AT, direction: DESC}) { \
+ totalCount nodes { number title url createdAt author { login } } } }";
+
+async fn fetch() -> Result<(Snapshot, Vec, Vec, Vec), GhError> {
+ let gh = Gh::from_store()?;
+ let sizes = json!({
+ "stars": STAR_REPOS, "owned": MAX_REPOS, "contributed": CONTRIBUTED_REPOS,
+ "merged": MERGED_PULLS, "opened": OPENED_PULLS, "review": REVIEW_PULLS, "mine": CHECKED_PULLS,
+ });
+ let (data, _) = gh.graphql_with(PROFILE_QUERY, sizes).await?;
+ let viewer = data.get("viewer").ok_or(GhError::BadResponse)?;
+
+ let login = text(viewer.get("login")).ok_or(GhError::BadResponse)?;
+ let total_stars = nodes(viewer, "/starred/nodes").iter().map(|n| int(n, "/stargazerCount")).sum();
+
+ let activity = fetch_activity(&gh, &login).await?;
+
+ let mut repos = parse_repos(nodes(viewer, "/repositories/nodes"), nodes(viewer, "/repositoriesContributedTo/nodes"));
+ // One after the other, as GitHub asks of its API: not all at once.
+ for repo in &mut repos {
+ repo.build = latest_build(&gh, &repo.full_name).await?;
+ }
+
+ let merged = parse_merged(viewer);
+ let opened = parse_opened(viewer);
+ let checked = parse_checked(viewer);
+ Ok((Snapshot {
+ profile_url: text(viewer.get("url")).unwrap_or_else(|| format!("{WEB}/{login}")),
+ contributions: parse_contributions(viewer.pointer("/contributionsCollection/contributionCalendar")),
+ login,
+ total_stars,
+ activity,
+ repos,
+ to_review: parse_to_review(&data),
+ fetched_at: unix_now() * 1000,
+ }, merged, opened, checked))
+}
+
+fn parse_contributions(calendar: Option<&Value>) -> Option {
+ let calendar = calendar?;
+ let weeks = calendar.get("weeks")?.as_array()?;
+ // The first week says where the year starts; the days need no date each.
+ let start = text(weeks.first()?.get("firstDay"))?;
+ let days: Vec<&Value> = weeks
+ .iter()
+ .filter_map(|w| w.get("contributionDays")?.as_array())
+ .flatten()
+ .collect();
+ let counts = days
+ .iter()
+ .map(|d| d.get("contributionCount").and_then(Value::as_u64).unwrap_or(0) as u32)
+ .collect();
+ let levels = days
+ .iter()
+ .map(|d| match d.get("contributionLevel").and_then(Value::as_str) {
+ Some("FIRST_QUARTILE") => 1,
+ Some("SECOND_QUARTILE") => 2,
+ Some("THIRD_QUARTILE") => 3,
+ Some("FOURTH_QUARTILE") => 4,
+ _ => 0,
+ })
+ .collect();
+ Some(Contributions {
+ total: int(calendar, "/totalContributions"),
+ start,
+ counts,
+ levels,
+ })
+}
+
+/// The projects tab: what you own and what you contributed to, merged, most
+/// recently pushed first. Archived repositories are left out since nothing
+/// happens there any more.
+fn parse_repos(owned: &[Value], contributed: &[Value]) -> Vec {
+ let mut seen = std::collections::HashSet::new();
+ let mut repos: Vec = owned
+ .iter()
+ .chain(contributed)
+ .filter(|n| !flag(n, "isArchived"))
+ .filter_map(|n| {
+ let full_name = text(n.get("nameWithOwner"))?;
+ let language = n.get("primaryLanguage");
+ Some(Repo {
+ url: text(n.get("url")).unwrap_or_else(|| format!("{WEB}/{full_name}")),
+ full_name,
+ private: flag(n, "isPrivate"),
+ language: text(language.and_then(|l| l.get("name"))),
+ language_color: text(language.and_then(|l| l.get("color"))),
+ stars: int(n, "/stargazerCount"),
+ // Null when the token may not read pull requests: shown as none.
+ open_prs: int(n, "/pullRequests/totalCount"),
+ pushed_at: text(n.get("pushedAt")),
+ build: None,
+ })
+ })
+ // Your own repositories you contributed to come back in both lists.
+ .filter(|r| seen.insert(r.full_name.clone()))
+ .collect();
+ // ISO 8601 sorts as text; a repository never pushed to goes last.
+ repos.sort_by(|a, b| b.pushed_at.cmp(&a.pushed_at));
+ repos.truncate(MAX_REPOS);
+ repos
+}
+
+/// The newest Actions run of a repository, whatever its branch, asked with the
+/// ETag of the last answer.
+///
+/// A repository the token can't read Actions for, or that has no workflow, has
+/// no build — the connection test is where a missing permission gets named —
+/// and is not asked about again for an hour.
+/// Anything that concerns every request (rate limit, bad token, no network)
+/// stops the refresh instead.
+async fn latest_build(gh: &Gh, repo: &str) -> Result, GhError> {
+ let path = format!("/repos/{repo}/actions/runs?per_page=1");
+ let now = unix_now();
+ let cached = {
+ let cache = CACHE.lock().unwrap();
+ if cache.no_runs.get(&path).is_some_and(|until| now < *until) {
+ return Ok(None);
+ }
+ cache.runs.get(&path).cloned()
+ };
+ let etag = cached.as_ref().map(|(tag, _)| tag.as_str());
+
+ match gh.get_if_changed(&path, etag).await {
+ Ok(None) => Ok(cached.and_then(|(_, build)| build)),
+ Ok(Some(reply)) => {
+ let build = parse_run(&reply.json);
+ if let Some(tag) = header_str(&reply.headers, ETAG) {
+ CACHE.lock().unwrap().runs.insert(path, (tag, build.clone()));
+ }
+ Ok(build)
+ }
+ // Refused, or no Actions there: left alone for a while rather than asked
+ // — and counted, and logged — again at every refresh.
+ Err(GhError::Forbidden) | Err(GhError::NotFound) => {
+ CACHE.lock().unwrap().no_runs.insert(path, now + REFUSED_FOR);
+ Ok(None)
+ }
+ Err(e) => Err(e),
+ }
+}
+
+/// Four states are all the island needs: it passed, it broke, it's going, or
+/// it ended without saying either (cancelled, skipped, waiting for a click).
+pub(crate) fn build_state(status: &str, conclusion: Option<&str>) -> &'static str {
+ if status != "completed" {
+ return "running";
+ }
+ match conclusion {
+ Some("success") => "success",
+ Some("failure") | Some("timed_out") | Some("startup_failure") => "failure",
+ _ => "neutral",
+ }
+}
+
+pub(crate) fn parse_run(json: &Value) -> Option {
+ let run = json.get("workflow_runs")?.as_array()?.first()?;
+ let status = run.get("status").and_then(Value::as_str).unwrap_or("completed");
+ let conclusion = run.get("conclusion").and_then(Value::as_str);
+ Some(Build {
+ id: run.get("id")?.as_u64()?,
+ state: build_state(status, conclusion),
+ workflow: text(run.get("name")).unwrap_or_else(|| WORKFLOW.into()),
+ branch: text(run.get("head_branch")),
+ url: text(run.get("html_url"))?,
+ at: text(run.get("updated_at")).or_else(|| text(run.get("created_at")))?,
+ })
+}
+
+/// The events feed, asked with the ETag of the last answer and never more
+/// often than GitHub's X-Poll-Interval allows.
+async fn fetch_activity(gh: &Gh, login: &str) -> Result, GhError> {
+ let path = format!("/users/{login}/events?per_page={EVENTS_PAGE}");
+ let now = unix_now();
+
+ let (cached, etag) = {
+ let cache = CACHE.lock().unwrap();
+ // Only a feed for this same account counts: the token may have changed.
+ let cached = cache
+ .snapshot
+ .as_ref()
+ .filter(|s| s.login == login)
+ .map(|s| s.activity.clone());
+ if let Some(activity) = &cached {
+ if now < cache.events_not_before {
+ return Ok(activity.clone());
+ }
+ }
+ let etag = cache
+ .events_etag
+ .as_ref()
+ .filter(|(url, _)| *url == path)
+ .map(|(_, tag)| tag.clone());
+ (cached, etag)
+ };
+
+ // Without a cached list, a 304 would leave the panel empty: ask afresh.
+ let etag = if cached.is_some() { etag } else { None };
+ let Some(reply) = gh.get_if_changed(&path, etag.as_deref()).await? else {
+ return Ok(cached.unwrap_or_default());
+ };
+
+ let interval = header_u64(&reply.headers, "x-poll-interval").unwrap_or(DEFAULT_POLL_INTERVAL);
+ {
+ let mut cache = CACHE.lock().unwrap();
+ cache.events_not_before = now + interval;
+ cache.events_etag = header_str(&reply.headers, ETAG).map(|tag| (path, tag));
+ }
+ Ok(parse_events(&reply.json))
+}
+
+fn parse_events(json: &Value) -> Vec {
+ json.as_array()
+ .map(|events| events.iter().filter_map(parse_event).take(MAX_ACTIVITY).collect())
+ .unwrap_or_default()
+}
+
+pub(crate) fn text(value: Option<&Value>) -> Option {
+ value
+ .and_then(Value::as_str)
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ .map(str::to_string)
+}
+
+/// One GitHub event → one line of the panel, or None for the kinds the panel
+/// doesn't show (comments, stars, forks, branch creations…).
+///
+/// Every field of `payload` is optional here: GitHub has been trimming what the
+/// events feed carries, and a missing title must cost a line its title, not
+/// the line itself.
+fn parse_event(event: &Value) -> Option {
+ let repo = text(event.get("repo").and_then(|r| r.get("name")))?;
+ let at = text(event.get("created_at"))?;
+ let payload = event.get("payload")?;
+ let repo_url = format!("{WEB}/{repo}");
+ let action = payload.get("action").and_then(Value::as_str).unwrap_or("");
+
+ let (kind, title, detail, url, target) = match event.get("type")?.as_str()? {
+ "PushEvent" => {
+ let branch = text(payload.get("ref")).map(|r| r.trim_start_matches("refs/heads/").to_string());
+ let commits = payload.get("commits").and_then(Value::as_array);
+ let count = payload
+ .get("size")
+ .and_then(Value::as_u64)
+ .or_else(|| commits.map(|c| c.len() as u64));
+ // The last commit of the push is the newest one.
+ let message = commits
+ .and_then(|c| c.last())
+ .and_then(|c| text(c.get("message")))
+ .and_then(first_line);
+ let title = message.unwrap_or_else(|| match count {
+ Some(n) if n > 1 => format!("Pushed {n} commits"),
+ _ => "Pushed".to_string(),
+ });
+ let detail = match (count, &branch) {
+ (Some(n), Some(b)) if n > 1 => Some(format!("{n} commits · {b}")),
+ (_, Some(b)) => Some(b.clone()),
+ _ => None,
+ };
+ let head = text(payload.get("head"));
+ let url = match (&head, &branch) {
+ (Some(sha), _) => format!("{repo_url}/commit/{sha}"),
+ (None, Some(b)) => format!("{repo_url}/commits/{b}"),
+ _ => repo_url.clone(),
+ };
+ let target = head.map(|head| Target::Commits {
+ repo: repo.clone(),
+ head: Some(head),
+ count,
+ branch: branch.clone(),
+ author: None,
+ from: None,
+ to: None,
+ });
+ ("push", title, detail, url, target)
+ }
+ "PullRequestEvent" => {
+ let pr = payload.get("pull_request");
+ let number = payload
+ .get("number")
+ .or_else(|| pr.and_then(|p| p.get("number")))
+ .and_then(Value::as_u64);
+ let merged = pr
+ .map(|p| {
+ p.get("merged").and_then(Value::as_bool).unwrap_or(false)
+ || p.get("merged_at").is_some_and(|m| !m.is_null())
+ })
+ .unwrap_or(false);
+ let kind = match action {
+ "opened" | "reopened" => "pr_opened",
+ "closed" if merged => "pr_merged",
+ "closed" => "pr_closed",
+ _ => return None,
+ };
+ let title = text(pr.and_then(|p| p.get("title")))
+ .unwrap_or_else(|| number.map_or("Pull request".into(), |n| format!("Pull request #{n}")));
+ let url = text(pr.and_then(|p| p.get("html_url")))
+ .or_else(|| number.map(|n| format!("{repo_url}/pull/{n}")))
+ .unwrap_or_else(|| repo_url.clone());
+ let target = number.map(|number| Target::Pull { repo: repo.clone(), number });
+ (kind, title, number.map(|n| format!("#{n}")), url, target)
+ }
+ "IssuesEvent" => {
+ let issue = payload.get("issue");
+ let number = issue.and_then(|i| i.get("number")).and_then(Value::as_u64);
+ let kind = match action {
+ "opened" | "reopened" => "issue_opened",
+ "closed" => "issue_closed",
+ _ => return None,
+ };
+ let title = text(issue.and_then(|i| i.get("title")))
+ .unwrap_or_else(|| number.map_or("Issue".into(), |n| format!("Issue #{n}")));
+ let url = text(issue.and_then(|i| i.get("html_url")))
+ .or_else(|| number.map(|n| format!("{repo_url}/issues/{n}")))
+ .unwrap_or_else(|| repo_url.clone());
+ let target = number.map(|number| Target::Issue { repo: repo.clone(), number });
+ (kind, title, number.map(|n| format!("#{n}")), url, target)
+ }
+ "ReleaseEvent" => {
+ if !matches!(action, "published" | "released" | "created") {
+ return None;
+ }
+ let release = payload.get("release");
+ let tag = text(release.and_then(|r| r.get("tag_name")));
+ let title = text(release.and_then(|r| r.get("name")))
+ .or_else(|| tag.clone())
+ .unwrap_or_else(|| "Release".into());
+ let url = text(release.and_then(|r| r.get("html_url")))
+ .or_else(|| tag.as_ref().map(|t| format!("{repo_url}/releases/tag/{t}")))
+ .unwrap_or_else(|| format!("{repo_url}/releases"));
+ let target = tag.clone().map(|tag| Target::Release { repo: repo.clone(), tag });
+ ("release", title, tag, url, target)
+ }
+ "CreateEvent" => match payload.get("ref_type").and_then(Value::as_str)? {
+ "repository" => (
+ "create",
+ CREATED.to_string(),
+ None,
+ repo_url.clone(),
+ Some(Target::Project { repo: repo.clone() }),
+ ),
+ "tag" => {
+ let tag = text(payload.get("ref"))?;
+ let url = format!("{repo_url}/releases/tag/{tag}");
+ ("create", format!("Tagged {tag}"), None, url, None)
+ }
+ _ => return None,
+ },
+ _ => return None,
+ };
+
+ Some(Activity { kind, repo, title, detail, url, at, target })
+}
+
+// ── Project sheet (a click on a project) ──────────────────────────────────────
+//
+// Fetched on demand only — never by the tick — and kept a minute, so going
+// back and forth between projects costs nothing.
+
+#[derive(Serialize, Clone)]
+#[serde(rename_all = "camelCase")]
+pub struct Project {
+ pub full_name: String,
+ pub url: String,
+ pub description: Option,
+ pub homepage: Option,
+ pub private: bool,
+ pub created_at: Option,
+ pub stars: i64,
+ pub forks: i64,
+ /// Largest first, the long tail folded into "Other".
+ pub languages: Vec,
+ /// Newest first.
+ pub runs: Vec,
+ /// The pull request touched most recently, whoever opened it.
+ pub pull: Option,
+ pub deploy: Option,
+ /// Sections the token may not read for this repository, by the name of
+ /// the permission that would open them. The sheet says so rather than
+ /// looking empty.
+ pub missing: Vec<&'static str>,
+}
+
+#[derive(Serialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct LanguageShare {
+ pub name: String,
+ pub color: Option,
+ /// 0…1 of the repository's code.
+ pub share: f64,
+}
+
+#[derive(Serialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct Run {
+ pub id: u64,
+ pub state: &'static str,
+ pub workflow: String,
+ pub branch: Option,
+ /// The commit or pull request title the run is about.
+ pub title: Option,
+ pub actor: Option,
+ pub url: String,
+ pub started_at: Option,
+ pub updated_at: String,
+}
+
+#[derive(Serialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct Pull {
+ pub number: u64,
+ pub title: String,
+ pub url: String,
+ /// open, draft, merged or closed.
+ pub state: &'static str,
+ pub author: Option,
+ pub additions: i64,
+ pub deletions: i64,
+ pub changed_files: i64,
+ /// approved, changes requested or review required; None when not asked.
+ pub review: Option<&'static str>,
+ pub comments: i64,
+ /// Merged at for a merged one, last update otherwise.
+ pub at: String,
+}
+
+#[derive(Serialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct Deploy {
+ pub environment: String,
+ /// success, failure, running or inactive (replaced by a newer one).
+ pub state: &'static str,
+ /// Where it went live, when the deployment says.
+ pub url: Option,
+ pub creator: Option,
+ pub sha: Option,
+ pub at: String,
+}
+
+const PROJECT_QUERY: &str = "query($owner: String!, $name: String!, $languages: Int!) { \
+ repository(owner: $owner, name: $name) { \
+ nameWithOwner url description homepageUrl isPrivate createdAt stargazerCount forkCount \
+ languages(first: $languages, orderBy: {field: SIZE, direction: DESC}) { totalSize edges { size node { name color } } } \
+ pullRequests(first: 1, orderBy: {field: UPDATED_AT, direction: DESC}) { nodes { \
+ number title url state isDraft mergedAt updatedAt additions deletions changedFiles \
+ reviewDecision author { login } comments { totalCount } } } \
+ deployments(first: 1, orderBy: {field: CREATED_AT, direction: DESC}) { nodes { \
+ environment createdAt commitOid creator { login } latestStatus { state environmentUrl createdAt } } } } }";
+
+/// Runs shown as the CI streak.
+const STREAK: usize = 8;
+const PROJECT_TTL: u64 = 60;
+/// Languages named in the bar before the rest becomes "Other".
+const MAX_LANGUAGES: usize = 4;
+/// Shares that add up to this are the whole: under half a percent is rounding.
+const WHOLE: f64 = 0.995;
+
+static PROJECTS: LazyLock> = LazyLock::new(Kept::new);
+
+/// "owner/name" with nothing else in it: the name ends up in a URL path.
+pub(crate) fn split_full_name(full_name: &str) -> Option<(&str, &str)> {
+ let (owner, name) = full_name.split_once('/')?;
+ let valid = |s: &str| {
+ !s.is_empty()
+ && s != "."
+ && s != ".."
+ && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
+ };
+ (valid(owner) && valid(name)).then_some((owner, name))
+}
+
+/// True when a partial GraphQL answer refused the field `field`.
+fn refused(errors: &[Value], field: &str) -> bool {
+ errors.iter().any(|e| {
+ e.get("path")
+ .and_then(Value::as_array)
+ .is_some_and(|path| path.iter().any(|p| p.as_str() == Some(field)))
+ })
+}
+
+pub async fn project(full_name: &str, force: bool) -> Result {
+ let (owner, name) = split_full_name(full_name).ok_or("Unknown repository")?;
+ let now = unix_now();
+ if !force {
+ if let Some(kept) = PROJECTS.fresh(full_name, now, |_| PROJECT_TTL) {
+ return Ok(kept);
+ }
+ }
+
+ let gh = Gh::from_store()?;
+ let (data, errors) = gh
+ .graphql_with(PROJECT_QUERY, json!({ "owner": owner, "name": name, "languages": MAX_LANGUAGES }))
+ .await?;
+ let repo = data.get("repository").filter(|r| !r.is_null()).ok_or(GhError::NotFound)?;
+
+ let mut missing = Vec::new();
+ if refused(&errors, "pullRequests") {
+ missing.push(permission::PULL_REQUESTS);
+ }
+ if refused(&errors, "deployments") {
+ missing.push(permission::DEPLOYMENTS);
+ }
+ let runs = match gh.get(&format!("/repos/{owner}/{name}/actions/runs?per_page={STREAK}")).await {
+ Ok(reply) => parse_runs(&reply.json),
+ Err(GhError::Forbidden) => {
+ missing.push(permission::ACTIONS);
+ Vec::new()
+ }
+ // Actions switched off for this repository.
+ Err(GhError::NotFound) => Vec::new(),
+ Err(e) => return Err(e.into()),
+ };
+
+ let project = parse_project(repo, runs, missing).ok_or(GhError::BadResponse)?;
+ PROJECTS.keep(full_name.to_string(), now, project.clone());
+ Ok(project)
+}
+
+fn parse_runs(json: &Value) -> Vec {
+ let Some(runs) = json.get("workflow_runs").and_then(Value::as_array) else {
+ return Vec::new();
+ };
+ runs.iter()
+ .filter_map(|run| {
+ let status = run.get("status").and_then(Value::as_str).unwrap_or("completed");
+ let conclusion = run.get("conclusion").and_then(Value::as_str);
+ Some(Run {
+ id: run.get("id")?.as_u64()?,
+ state: build_state(status, conclusion),
+ workflow: text(run.get("name")).unwrap_or_else(|| WORKFLOW.into()),
+ branch: text(run.get("head_branch")),
+ title: text(run.get("display_title"))
+ .or_else(|| text(run.pointer("/head_commit/message")))
+ .and_then(first_line),
+ actor: text(run.pointer("/triggering_actor/login"))
+ .or_else(|| text(run.pointer("/actor/login"))),
+ url: text(run.get("html_url"))?,
+ started_at: text(run.get("run_started_at")),
+ updated_at: text(run.get("updated_at")).or_else(|| text(run.get("created_at")))?,
+ })
+ })
+ .collect()
+}
+
+fn parse_languages(languages: Option<&Value>) -> Vec {
+ let Some(languages) = languages else { return Vec::new() };
+ let total = languages.get("totalSize").and_then(Value::as_f64).unwrap_or(0.0);
+ let Some(edges) = languages.get("edges").and_then(Value::as_array) else {
+ return Vec::new();
+ };
+ if total <= 0.0 {
+ return Vec::new();
+ }
+ // As many as the bar names: the query asks for no more.
+ let mut shares: Vec = edges
+ .iter()
+ .filter_map(|edge| {
+ Some(LanguageShare {
+ name: text(edge.pointer("/node/name"))?,
+ color: text(edge.pointer("/node/color")),
+ share: edge.get("size")?.as_f64()? / total,
+ })
+ })
+ .collect();
+ // What they don't cover is "Other" — once it is more than a rounding's worth.
+ let covered: f64 = shares.iter().map(|l| l.share).sum();
+ if covered < WHOLE {
+ shares.push(LanguageShare { name: OTHER.into(), color: None, share: 1.0 - covered });
+ }
+ shares
+}
+
+fn parse_pull(node: &Value) -> Option {
+ let state = pull_state(node);
+ let merged_at = if state == "merged" { text(node.get("mergedAt")) } else { None };
+ let at = merged_at.or_else(|| text(node.get("updatedAt")))?;
+ Some(Pull {
+ number: node.get("number")?.as_u64()?,
+ title: text(node.get("title")).unwrap_or_else(|| UNTITLED.into()),
+ url: text(node.get("url"))?,
+ state,
+ author: text(node.pointer("/author/login")),
+ additions: int(node, "/additions"),
+ deletions: int(node, "/deletions"),
+ changed_files: int(node, "/changedFiles"),
+ review: review_decision(node),
+ comments: int(node, "/comments/totalCount"),
+ at,
+ })
+}
+
+fn parse_deploy(node: &Value) -> Option {
+ let status = node.get("latestStatus").filter(|s| !s.is_null());
+ let state = match status.and_then(|s| s.get("state")).and_then(Value::as_str) {
+ Some("SUCCESS") => "success",
+ Some("FAILURE") | Some("ERROR") => "failure",
+ Some("INACTIVE") => "inactive",
+ _ => "running",
+ };
+ Some(Deploy {
+ environment: text(node.get("environment")).unwrap_or_else(|| "production".into()),
+ state,
+ url: text(status.and_then(|s| s.get("environmentUrl"))),
+ creator: text(node.pointer("/creator/login")),
+ sha: text(node.get("commitOid")).map(|sha| short_sha(&sha)),
+ at: text(status.and_then(|s| s.get("createdAt"))).or_else(|| text(node.get("createdAt")))?,
+ })
+}
+
+fn parse_project(repo: &Value, runs: Vec, missing: Vec<&'static str>) -> Option {
+ let full_name = text(repo.get("nameWithOwner"))?;
+ Some(Project {
+ url: text(repo.get("url")).unwrap_or_else(|| format!("{WEB}/{full_name}")),
+ full_name,
+ description: text(repo.get("description")),
+ homepage: text(repo.get("homepageUrl")),
+ private: flag(repo, "isPrivate"),
+ created_at: text(repo.get("createdAt")),
+ stars: int(repo, "/stargazerCount"),
+ forks: int(repo, "/forkCount"),
+ languages: parse_languages(repo.get("languages")),
+ runs,
+ pull: repo.pointer("/pullRequests/nodes/0").and_then(parse_pull),
+ deploy: repo.pointer("/deployments/nodes/0").and_then(parse_deploy),
+ missing,
+ })
+}
+
+// ── One day of the graph (a click on a day) ───────────────────────────────────
+//
+// What GitHub's profile page lists under the graph when a day is clicked:
+// contributionsCollection narrowed to that day. The events feed can't do it —
+// it only goes back thirty days.
+
+#[derive(Serialize, Clone)]
+#[serde(rename_all = "camelCase")]
+pub struct Day {
+ pub items: Vec,
+ /// Contributions that day in repositories the token can't see into.
+ pub private_count: i64,
+}
+
+#[derive(Serialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct DayItem {
+ /// push (commits), pr_opened, pr_merged, review, issue_opened or create.
+ pub kind: &'static str,
+ /// "owner/name".
+ pub repo: String,
+ pub title: String,
+ pub detail: Option,
+ pub url: String,
+ /// The sheet a click on the line opens in the panel.
+ pub target: Option,
+}
+
+const DAY_QUERY: &str = "query($from: DateTime!, $to: DateTime!, $each: Int!) { viewer { login \
+ contributionsCollection(from: $from, to: $to) { restrictedContributionsCount \
+ commitContributionsByRepository(maxRepositories: $each) { repository { nameWithOwner url } contributions { totalCount } } \
+ pullRequestContributions(first: $each) { nodes { pullRequest { number title url merged repository { nameWithOwner } } } } \
+ pullRequestReviewContributions(first: $each) { nodes { pullRequest { number title url repository { nameWithOwner } } } } \
+ issueContributions(first: $each) { nodes { issue { number title url repository { nameWithOwner } } } } \
+ repositoryContributions(first: $each) { nodes { repository { nameWithOwner url } } } } } }";
+
+/// Lines of each kind a day lists: its commits by repository, its pull
+/// requests, its reviews, its issues, its new repositories.
+const DAY_EACH: usize = 10;
+
+/// A day that's over doesn't change; today still can.
+const PAST_DAY_TTL: u64 = 3600;
+const TODAY_TTL: u64 = 60;
+
+static DAYS: LazyLock> = LazyLock::new(Kept::new);
+
+/// An ISO 8601 timestamp and nothing else: it goes to GitHub as a variable.
+pub(crate) fn is_timestamp(s: &str) -> bool {
+ (10..=40).contains(&s.len()) && s.chars().all(|c| c.is_ascii_digit() || "-:T.Z+".contains(c))
+}
+
+/// `from` and `to` bound the day in the user's own time zone, worked out by
+/// the island, so the list matches the day they clicked on.
+pub async fn day(from: &str, to: &str, today: bool) -> Result {
+ if !is_timestamp(from) || !is_timestamp(to) {
+ return Err("Unknown day".into());
+ }
+ let key = format!("{from}/{to}");
+ let now = unix_now();
+ let ttl = if today { TODAY_TTL } else { PAST_DAY_TTL };
+ if let Some(kept) = DAYS.fresh(&key, now, |_| ttl) {
+ return Ok(kept);
+ }
+
+ let gh = Gh::from_store()?;
+ let (data, _) = gh.graphql_with(DAY_QUERY, json!({ "from": from, "to": to, "each": DAY_EACH })).await?;
+ let collection = data.pointer("/viewer/contributionsCollection").ok_or(GhError::BadResponse)?;
+ let login = text(data.pointer("/viewer/login"));
+ let day = parse_day(collection, login.as_deref(), from, to);
+ DAYS.keep(key, now, day.clone());
+ Ok(day)
+}
+
+/// `login`, `from` and `to` go into the commits' target, so their sheet lists
+/// exactly the commits counted here.
+fn parse_day(collection: &Value, login: Option<&str>, from: &str, to: &str) -> Day {
+ let mut items = Vec::new();
+
+ // Commits come grouped by repository, as on the profile page.
+ for group in nodes(collection, "/commitContributionsByRepository") {
+ let (Some(repo), Some(count)) = (
+ text(group.pointer("/repository/nameWithOwner")),
+ group.pointer("/contributions/totalCount").and_then(Value::as_i64),
+ ) else {
+ continue;
+ };
+ let url = text(group.pointer("/repository/url")).unwrap_or_else(|| format!("{WEB}/{repo}"));
+ let title = if count == 1 { "1 commit".to_string() } else { format!("{count} commits") };
+ let target = Target::Commits {
+ repo: repo.clone(),
+ head: None,
+ count: u64::try_from(count).ok(),
+ branch: None,
+ author: login.map(str::to_string),
+ from: Some(from.to_string()),
+ to: Some(to.to_string()),
+ };
+ items.push(DayItem { kind: "push", repo, title, detail: None, url: format!("{url}/commits"), target: Some(target) });
+ }
+
+ let pull = |node: &Value, kind_for: &dyn Fn(bool) -> &'static str| -> Option {
+ let pr = node.get("pullRequest")?;
+ let merged = flag(pr, "merged");
+ let number = pr.get("number")?.as_u64()?;
+ let repo = text(pr.pointer("/repository/nameWithOwner"))?;
+ Some(DayItem {
+ kind: kind_for(merged),
+ target: Some(Target::Pull { repo: repo.clone(), number }),
+ repo,
+ title: text(pr.get("title")).unwrap_or_else(|| format!("Pull request #{number}")),
+ detail: Some(format!("#{number}")),
+ url: text(pr.get("url"))?,
+ })
+ };
+ items.extend(nodes(collection, "/pullRequestContributions/nodes").iter().filter_map(|n| {
+ pull(n, &|merged| if merged { "pr_merged" } else { "pr_opened" })
+ }));
+ items.extend(nodes(collection, "/pullRequestReviewContributions/nodes").iter().filter_map(|n| pull(n, &|_| "review")));
+
+ items.extend(nodes(collection, "/issueContributions/nodes").iter().filter_map(|n| {
+ let issue = n.get("issue")?;
+ let number = issue.get("number")?.as_u64()?;
+ let repo = text(issue.pointer("/repository/nameWithOwner"))?;
+ Some(DayItem {
+ kind: "issue_opened",
+ target: Some(Target::Issue { repo: repo.clone(), number }),
+ repo,
+ title: text(issue.get("title")).unwrap_or_else(|| format!("Issue #{number}")),
+ detail: Some(format!("#{number}")),
+ url: text(issue.get("url"))?,
+ })
+ }));
+
+ items.extend(nodes(collection, "/repositoryContributions/nodes").iter().filter_map(|n| {
+ let repo = text(n.pointer("/repository/nameWithOwner"))?;
+ Some(DayItem {
+ kind: "create",
+ url: text(n.pointer("/repository/url")).unwrap_or_else(|| format!("{WEB}/{repo}")),
+ target: Some(Target::Project { repo: repo.clone() }),
+ repo,
+ title: CREATED.into(),
+ detail: None,
+ })
+ }));
+
+ Day {
+ items,
+ private_count: int(collection, "/restrictedContributionsCount"),
+ }
+}
+
+// ── Connection test (settings window) ─────────────────────────────────────────
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Account {
+ pub login: String,
+ pub name: Option,
+ /// As GitHub sends it, e.g. "2026-12-12 10:00:00 +0100". None: no expiry.
+ pub expires_at: Option,
+ pub checks: Vec,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Check {
+ pub label: &'static str,
+ pub ok: bool,
+ pub note: Option,
+}
+
+fn check(label: &'static str, result: Result<(), GhError>) -> Check {
+ match result {
+ Ok(()) => Check { label, ok: true, note: None },
+ Err(GhError::Forbidden) | Err(GhError::NotFound) => Check {
+ label,
+ ok: false,
+ note: Some("not granted to this token".into()),
+ },
+ Err(e) => Check { label, ok: false, note: Some(e.message()) },
+ }
+}
+
+/// Settings → GitHub → Test connection. One call per thing the panel needs, so
+/// a missing permission is named here rather than discovered as an empty card.
+pub async fn test() -> Result {
+ let gh = Gh::from_store()?;
+ let me = gh.get("/user").await?;
+
+ let login = text(me.json.get("login")).ok_or(GhError::BadResponse)?;
+ let name = text(me.json.get("name"));
+ let expires_at = header_str(&me.headers, "github-authentication-token-expiration");
+
+ let mut checks = vec![Check { label: "Account", ok: true, note: None }];
+
+ // The contribution graph and the project list both come from GraphQL.
+ let graphql = gh.graphql("query { viewer { login } }").await.map(|_| ());
+ checks.push(check("Contributions", graphql));
+
+ // The most recently pushed repository stands in for all of them.
+ let repos = gh.get("/user/repos?per_page=1&affiliation=owner&sort=pushed").await;
+ let sample = repos
+ .as_ref()
+ .ok()
+ .and_then(|r| r.json.as_array()?.first()?.get("full_name")?.as_str().map(str::to_string));
+ match (repos, &sample) {
+ (Err(e), _) => checks.push(check("Repositories", Err(e))),
+ (Ok(_), None) => checks.push(Check {
+ label: "Repositories",
+ ok: false,
+ note: Some("none visible — set Repository access to All repositories".into()),
+ }),
+ (Ok(_), Some(_)) => checks.push(check("Repositories", Ok(()))),
+ }
+ if let Some(repo) = &sample {
+ let pulls = gh.get(&format!("/repos/{repo}/pulls?per_page=1")).await.map(|_| ());
+ checks.push(check(permission::PULL_REQUESTS, pulls));
+ let runs = gh.get(&format!("/repos/{repo}/actions/runs?per_page=1")).await.map(|_| ());
+ checks.push(check(permission::ACTIONS, runs));
+ let deployments = gh.get(&format!("/repos/{repo}/deployments?per_page=1")).await.map(|_| ());
+ checks.push(check(permission::DEPLOYMENTS, deployments));
+ // Commits, their diffs and releases.
+ let contents = gh.get(&format!("/repos/{repo}/commits?per_page=1")).await.map(|_| ());
+ checks.push(check(permission::CONTENTS, contents));
+ let issues = gh.get(&format!("/repos/{repo}/issues?per_page=1")).await.map(|_| ());
+ checks.push(check(permission::ISSUES, issues));
+ }
+
+ let events = gh.get(&format!("/users/{login}/events?per_page=1")).await.map(|_| ());
+ checks.push(check("Activity", events));
+
+ let failed = checks.iter().filter(|c| !c.ok).count();
+ log::line(format!("github test: {} checks, {failed} failed", checks.len()));
+
+ Ok(Account { login, name, expires_at, checks })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn snapshot_with(builds: &[(&str, Option<(u64, &'static str)>)]) -> Snapshot {
+ Snapshot {
+ login: "edu".into(),
+ profile_url: String::new(),
+ total_stars: 0,
+ activity: Vec::new(),
+ to_review: Vec::new(),
+ repos: builds
+ .iter()
+ .map(|(name, build)| Repo {
+ full_name: format!("edu/{name}"),
+ url: String::new(),
+ private: false,
+ language: None,
+ language_color: None,
+ stars: 0,
+ open_prs: 0,
+ pushed_at: None,
+ build: build.map(|(id, state)| Build {
+ id,
+ state,
+ workflow: "CI".into(),
+ branch: Some("main".into()),
+ url: String::new(),
+ at: String::new(),
+ }),
+ })
+ .collect(),
+ contributions: None,
+ fetched_at: 0,
+ }
+ }
+
+ #[test]
+ fn the_pill_hears_of_a_build_once_it_breaks_and_only_once() {
+ let green = snapshot_with(&[("coucou", Some((1, "success")))]).repos;
+ let running = snapshot_with(&[("coucou", Some((2, "running")))]).repos;
+ let red = snapshot_with(&[("coucou", Some((2, "failure")))]).repos;
+
+ // A new run that failed, and a run that was going and ended badly.
+ let (event, target) = broke(&green, &red, &[]).unwrap();
+ assert_eq!((event.success, event.label.as_str(), event.detail.as_deref()), (false, "CI failed on coucou", Some("main")));
+ assert_eq!(target, Target::Run { repo: "edu/coucou".into(), id: 2 });
+ assert!(broke(&running, &red, &[]).is_some());
+ // Still the same failure on the next refresh: already told.
+ assert!(broke(&red, &red, &[]).is_none());
+ // A project that just entered the list has nothing to compare with.
+ assert!(broke(&[], &red, &[]).is_none());
+ // A project that is muted keeps its broken build to itself.
+ assert!(broke(&green, &red, &[red[0].full_name.clone()]).is_none());
+ }
+
+ #[test]
+ fn the_pill_hears_of_a_pull_request_merged_since_the_last_look() {
+ let merged = parse_merged(&json!({ "merged": { "nodes": [
+ // Merged long ago, back up the list because somebody commented on it.
+ { "number": 3, "title": "Older", "mergedAt": "2026-06-01T10:00:00Z", "repository": { "nameWithOwner": "edu/notes" } },
+ { "number": 12, "title": "Panel", "mergedAt": "2026-09-30T18:00:00Z", "repository": { "nameWithOwner": "edu/coucou" } },
+ { "number": 9, "title": "No date", "repository": { "nameWithOwner": "edu/coucou" } },
+ ]}}));
+ assert_eq!(merged.len(), 2);
+
+ // The first refresh only learns how things stand.
+ assert!(went_in(None, &merged).is_none());
+ // Merged after the latest one known: news, and it opens that pull request.
+ let (event, target) = went_in(Some("2026-09-30T17:00:00Z"), &merged).unwrap();
+ assert_eq!((event.success, event.label.as_str(), event.detail.as_deref()), (true, "#12 merged", Some("Panel")));
+ assert_eq!(target, Target::Pull { repo: "edu/coucou".into(), number: 12 });
+ // Nothing merged since: the old one coming back up is not news.
+ assert!(went_in(Some("2026-09-30T18:00:00Z"), &merged).is_none());
+ // An account with no merge yet hears of its first.
+ assert!(went_in(Some(""), &merged).is_some());
+ }
+
+ #[test]
+ fn the_pill_hears_of_a_pull_request_somebody_else_opened() {
+ let pull = |number: u64, who: &str, at: &str| json!({ "number": number, "title": format!("Pull {number}"), "createdAt": at, "author": { "login": who } });
+ let project = |name: &str, pulls: Vec| json!({ "nameWithOwner": name, "pullRequests": { "totalCount": pulls.len(), "nodes": pulls } });
+ let opened = parse_opened(&json!({
+ "repositories": { "nodes": [project("edu/coucou", vec![
+ pull(8, "edu", "2026-10-01T16:00:00Z"),
+ pull(7, "kirzen", "2026-10-01T15:00:00Z"),
+ ])] },
+ // The same project again, as one contributed to, and one that is archived.
+ "repositoriesContributedTo": { "nodes": [
+ project("edu/coucou", vec![pull(7, "kirzen", "2026-10-01T15:00:00Z")]),
+ json!({ "nameWithOwner": "old/thing", "isArchived": true, "pullRequests": { "nodes": [pull(1, "kirzen", "2026-10-01T17:00:00Z")] } }),
+ ] },
+ }));
+ assert_eq!(opened.len(), 2);
+
+ // The first refresh only learns how things stand.
+ assert!(came_in(None, &opened, "edu").is_none());
+ // Opened since by somebody else: news, and it opens that pull request. Your own is not.
+ let (event, target) = came_in(Some("2026-10-01T14:00:00Z"), &opened, "edu").unwrap();
+ assert_eq!((event.success, event.label.as_str(), event.detail.as_deref()), (true, "#7 opened on coucou", Some("Pull 7")));
+ assert_eq!(target, Target::Pull { repo: "edu/coucou".into(), number: 7 });
+ // Nothing but your own since.
+ assert!(came_in(Some("2026-10-01T15:00:00Z"), &opened, "edu").is_none());
+ }
+
+ #[test]
+ fn checks_that_end_are_news() {
+ let viewer = json!({ "mine": { "nodes": [
+ { "number": 57, "title": "The panel", "url": "https://github.com/louis/coucou/pull/57",
+ "repository": { "nameWithOwner": "louis/coucou" },
+ "commits": { "nodes": [{ "commit": { "oid": "abc", "statusCheckRollup": { "state": "SUCCESS" } } }] } },
+ { "number": 3, "title": "No checks", "repository": { "nameWithOwner": "edu/tools" },
+ "commits": { "nodes": [{ "commit": { "oid": "def", "statusCheckRollup": null } }] } },
+ {},
+ ] } });
+ let now = parse_checked(&viewer);
+ assert_eq!(now.len(), 2);
+ assert_eq!((now[0].sha.as_deref(), now[0].state), (Some("abc"), Some("success")));
+ assert_eq!((now[1].url.as_str(), now[1].state), ("https://github.com/edu/tools/pull/3", None));
+
+ let was = |sha: &str, state: Option<&'static str>| vec![Checked { sha: Some(sha.into()), state, ..now[0].clone() }];
+ // The first look only learns how things stand; so does a pull request's first.
+ assert!(checks_ended(None, &now, &[], true).is_none());
+ assert!(checks_ended(Some(&[]), &now, &[], true).is_none());
+ // Same commit: green is news after pending, not after green or nothing.
+ let (event, target) = checks_ended(Some(&was("abc", Some("pending"))), &now, &[], true).unwrap();
+ assert_eq!(event.label, "Checks passed on #57");
+ assert!(event.success);
+ assert_eq!(target, Target::Pull { repo: "louis/coucou".into(), number: 57 });
+ assert!(checks_ended(Some(&was("abc", Some("success"))), &now, &[], true).is_none());
+ assert!(checks_ended(Some(&was("abc", None)), &now, &[], true).is_none());
+ // A new commit whose checks already ended: news as it is.
+ assert!(checks_ended(Some(&was("old", Some("success"))), &now, &[], true).is_some());
+ // Green is not red, and a muted project stays quiet.
+ assert!(checks_ended(Some(&was("abc", Some("pending"))), &now, &[], false).is_none());
+ assert!(checks_ended(Some(&was("abc", Some("pending"))), &now, &["louis/coucou".to_string()], true).is_none());
+
+ let red = vec![Checked { state: Some("failure"), ..now[0].clone() }];
+ let (event, _) = checks_ended(Some(&was("abc", Some("pending"))), &red, &[], false).unwrap();
+ assert_eq!(event.label, "Checks failed on #57");
+ assert!(!event.success);
+ assert!(checks_ended(Some(&red), &red, &[], false).is_none());
+ }
+
+ #[test]
+ fn a_review_asked_is_news_once() {
+ let data = json!({ "toReview": { "nodes": [
+ { "number": 7, "title": "Fix the hook", "url": "https://github.com/ada/tools/pull/7", "isDraft": false,
+ "updatedAt": "2026-10-03T09:00:00Z", "author": { "login": "ada" }, "repository": { "nameWithOwner": "ada/tools" } },
+ { "number": 7, "title": "Fix the hook", "updatedAt": "2026-10-03T09:00:00Z", "repository": { "nameWithOwner": "ada/tools" } },
+ { "number": 3, "title": "Old one", "isDraft": true, "updatedAt": "2026-09-30T09:00:00Z",
+ "author": null, "repository": { "nameWithOwner": "edu/coucou" } },
+ {},
+ ] } });
+ let to_review = parse_to_review(&data);
+ assert_eq!(to_review.len(), 2);
+ assert_eq!(to_review[0].author.as_deref(), Some("ada"));
+ assert_eq!(to_review[1].url, "https://github.com/edu/coucou/pull/3");
+ assert!(to_review[1].draft && to_review[1].author.is_none());
+
+ // The first look only learns how things stand.
+ assert!(asked(None, &to_review, &[]).is_none());
+ let known = vec![("edu/coucou".to_string(), 3)];
+ let (event, target) = asked(Some(&known), &to_review, &[]).unwrap();
+ assert_eq!(event.label, "Review requested on tools");
+ assert_eq!(event.detail.as_deref(), Some("Fix the hook"));
+ assert_eq!(target, Target::Pull { repo: "ada/tools".into(), number: 7 });
+ // Not for a muted project, nor once it is among those waiting.
+ assert!(asked(Some(&known), &to_review, &["ada/tools".to_string()]).is_none());
+ let known = vec![("edu/coucou".to_string(), 3), ("ada/tools".to_string(), 7)];
+ assert!(asked(Some(&known), &to_review, &[]).is_none());
+ // Nothing waiting before, one now: news too.
+ assert!(asked(Some(&[]), &to_review, &[]).is_some());
+ }
+
+ const NOW: u64 = 1_000_000;
+
+ #[test]
+ fn a_normal_answer_blocks_nothing() {
+ assert_eq!(rate_block(200, Some(4999), Some(NOW + 3600), None, false, NOW), None);
+ assert_eq!(rate_block(404, Some(4999), Some(NOW + 3600), None, false, NOW), None);
+ }
+
+ #[test]
+ fn an_empty_budget_blocks_until_the_reset_even_on_a_200() {
+ assert_eq!(rate_block(200, Some(0), Some(NOW + 900), None, false, NOW), Some(NOW + 900));
+ assert_eq!(rate_block(403, Some(0), Some(NOW + 900), None, true, NOW), Some(NOW + 900));
+ }
+
+ #[test]
+ fn a_reset_in_the_past_still_waits_a_minute() {
+ assert_eq!(rate_block(403, Some(0), Some(NOW - 5), None, true, NOW), Some(NOW + 60));
+ assert_eq!(rate_block(403, Some(0), None, None, true, NOW), Some(NOW + 60));
+ }
+
+ #[test]
+ fn retry_after_wins_on_a_refusal() {
+ assert_eq!(rate_block(403, Some(12), None, Some(30), true, NOW), Some(NOW + 30));
+ assert_eq!(rate_block(429, None, None, Some(90), false, NOW), Some(NOW + 90));
+ // A retry-after on a success means nothing to us.
+ assert_eq!(rate_block(200, Some(12), None, Some(30), false, NOW), None);
+ }
+
+ #[test]
+ fn a_secondary_limit_without_headers_waits_a_minute() {
+ assert_eq!(rate_block(403, Some(40), None, None, true, NOW), Some(NOW + 60));
+ assert_eq!(rate_block(429, None, None, None, false, NOW), Some(NOW + 60));
+ }
+
+ #[test]
+ fn a_plain_403_is_a_permission_problem_not_a_rate_limit() {
+ assert_eq!(rate_block(403, Some(4000), Some(NOW + 3600), None, false, NOW), None);
+ }
+
+ #[test]
+ fn the_wait_is_said_in_minutes_rounded_up() {
+ assert_eq!(wait_text(NOW + 30, NOW), "back in a minute");
+ assert_eq!(wait_text(NOW + 61, NOW), "back in 2 min");
+ assert_eq!(wait_text(NOW + 720, NOW), "back in 12 min");
+ assert_eq!(wait_text(NOW - 10, NOW), "back in a minute");
+ }
+
+ fn event(kind: &str, payload: Value) -> Value {
+ json!({
+ "id": "42",
+ "type": kind,
+ "repo": { "name": "edu/coucou" },
+ "created_at": "2026-09-30T18:00:00Z",
+ "payload": payload,
+ })
+ }
+
+ #[test]
+ fn a_push_shows_its_newest_commit() {
+ let push = event("PushEvent", json!({
+ "ref": "refs/heads/main",
+ "head": "abc123",
+ "size": 3,
+ "commits": [
+ { "message": "First" },
+ { "message": "Fix the hook timeout\n\nLonger body" },
+ ],
+ }));
+ let a = parse_event(&push).unwrap();
+ assert_eq!(a.kind, "push");
+ assert_eq!(a.title, "Fix the hook timeout");
+ assert_eq!(a.detail.as_deref(), Some("3 commits · main"));
+ assert_eq!(a.url, "https://github.com/edu/coucou/commit/abc123");
+ }
+
+ #[test]
+ fn a_push_without_commits_still_makes_a_line() {
+ let push = event("PushEvent", json!({ "ref": "refs/heads/dev" }));
+ let a = parse_event(&push).unwrap();
+ assert_eq!(a.title, "Pushed");
+ assert_eq!(a.detail.as_deref(), Some("dev"));
+ assert_eq!(a.url, "https://github.com/edu/coucou/commits/dev");
+ }
+
+ #[test]
+ fn a_closed_pull_request_is_merged_only_when_github_says_so() {
+ let merged = event("PullRequestEvent", json!({
+ "action": "closed",
+ "number": 12,
+ "pull_request": { "title": "Panel", "merged": true, "html_url": "https://github.com/edu/coucou/pull/12" },
+ }));
+ let a = parse_event(&merged).unwrap();
+ assert_eq!((a.kind, a.detail.as_deref()), ("pr_merged", Some("#12")));
+
+ let closed = event("PullRequestEvent", json!({
+ "action": "closed",
+ "number": 13,
+ "pull_request": { "title": "Nope", "merged": false, "merged_at": null },
+ }));
+ let a = parse_event(&closed).unwrap();
+ assert_eq!(a.kind, "pr_closed");
+ assert_eq!(a.url, "https://github.com/edu/coucou/pull/13");
+ }
+
+ #[test]
+ fn a_pull_request_without_its_object_keeps_its_number() {
+ let opened = event("PullRequestEvent", json!({ "action": "opened", "number": 7 }));
+ let a = parse_event(&opened).unwrap();
+ assert_eq!((a.kind, a.title.as_str()), ("pr_opened", "Pull request #7"));
+ }
+
+ #[test]
+ fn issues_and_releases_map_to_their_kinds() {
+ let issue = event("IssuesEvent", json!({
+ "action": "closed",
+ "issue": { "number": 4, "title": "Crash on drop" },
+ }));
+ let a = parse_event(&issue).unwrap();
+ assert_eq!((a.kind, a.title.as_str()), ("issue_closed", "Crash on drop"));
+ assert_eq!(a.url, "https://github.com/edu/coucou/issues/4");
+
+ let release = event("ReleaseEvent", json!({
+ "action": "published",
+ "release": { "tag_name": "v0.2.0", "name": "", "html_url": "https://github.com/edu/coucou/releases/tag/v0.2.0" },
+ }));
+ let a = parse_event(&release).unwrap();
+ assert_eq!((a.kind, a.title.as_str(), a.detail.as_deref()), ("release", "v0.2.0", Some("v0.2.0")));
+ }
+
+ #[test]
+ fn noise_is_left_out() {
+ for skipped in [
+ event("WatchEvent", json!({ "action": "started" })),
+ event("IssueCommentEvent", json!({ "action": "created" })),
+ event("PullRequestEvent", json!({ "action": "synchronize", "number": 3 })),
+ event("CreateEvent", json!({ "ref_type": "branch", "ref": "wip" })),
+ json!({ "type": "PushEvent", "payload": {} }),
+ ] {
+ assert_eq!(parse_event(&skipped), None);
+ }
+ }
+
+ #[test]
+ fn the_feed_keeps_its_order_and_is_capped() {
+ let many: Vec = (0..30)
+ .map(|i| event("PushEvent", json!({ "ref": format!("refs/heads/b{i}") })))
+ .collect();
+ let list = parse_events(&Value::Array(many));
+ assert_eq!(list.len(), MAX_ACTIVITY);
+ assert_eq!(list[0].detail.as_deref(), Some("b0"));
+ }
+
+ #[test]
+ fn projects_skip_the_archived_and_keep_the_push_order() {
+ let nodes = vec![
+ json!({
+ "name": "coucou", "nameWithOwner": "edu/coucou", "url": "https://github.com/edu/coucou",
+ "isPrivate": false, "isArchived": false, "pushedAt": "2026-09-30T18:00:00Z",
+ "stargazerCount": 12, "primaryLanguage": { "name": "Rust", "color": "#dea584" },
+ "pullRequests": { "totalCount": 2 },
+ }),
+ json!({ "name": "old", "nameWithOwner": "edu/old", "isArchived": true }),
+ json!({
+ "name": "notes", "nameWithOwner": "edu/notes", "isPrivate": true,
+ "primaryLanguage": null, "pullRequests": null,
+ }),
+ ];
+ let repos = parse_repos(&nodes, &[]);
+ assert_eq!(repos.len(), 2);
+ assert_eq!(repos[0].full_name, "edu/coucou");
+ assert_eq!(repos[0].language.as_deref(), Some("Rust"));
+ assert_eq!(repos[0].language_color.as_deref(), Some("#dea584"));
+ assert_eq!((repos[0].stars, repos[0].open_prs), (12, 2));
+ assert_eq!(repos[1].full_name, "edu/notes");
+ assert!(repos[1].private);
+ assert_eq!((repos[1].language.as_deref(), repos[1].open_prs), (None, 0));
+ assert_eq!(repos[1].url, "https://github.com/edu/notes");
+ }
+
+ #[test]
+ fn projects_are_capped() {
+ let nodes: Vec = (0..20)
+ .map(|i| json!({ "name": format!("r{i}"), "nameWithOwner": format!("edu/r{i}") }))
+ .collect();
+ assert_eq!(parse_repos(&nodes, &[]).len(), MAX_REPOS);
+ }
+
+ #[test]
+ fn contributions_elsewhere_join_the_projects_once_in_push_order() {
+ let repo = |full: &str, pushed: &str| {
+ let name = full.split('/').nth(1).unwrap();
+ json!({ "name": name, "nameWithOwner": full, "pushedAt": pushed })
+ };
+ let owned = vec![
+ repo("edu/coucou", "2026-09-30T10:00:00Z"),
+ repo("edu/notes", "2026-08-01T10:00:00Z"),
+ ];
+ let contributed = vec![
+ repo("louis/coucou", "2026-09-30T12:00:00Z"),
+ repo("edu/coucou", "2026-09-30T10:00:00Z"),
+ json!({ "name": "old", "nameWithOwner": "org/old", "isArchived": true, "pushedAt": "2026-09-30T23:00:00Z" }),
+ ];
+ let names: Vec = parse_repos(&owned, &contributed).into_iter().map(|r| r.full_name).collect();
+ assert_eq!(names, ["louis/coucou", "edu/coucou", "edu/notes"]);
+ }
+
+ #[test]
+ fn a_run_is_one_of_four_states() {
+ assert_eq!(build_state("in_progress", None), "running");
+ assert_eq!(build_state("queued", None), "running");
+ assert_eq!(build_state("waiting", None), "running");
+ assert_eq!(build_state("completed", Some("success")), "success");
+ assert_eq!(build_state("completed", Some("failure")), "failure");
+ assert_eq!(build_state("completed", Some("timed_out")), "failure");
+ assert_eq!(build_state("completed", Some("startup_failure")), "failure");
+ assert_eq!(build_state("completed", Some("cancelled")), "neutral");
+ assert_eq!(build_state("completed", Some("skipped")), "neutral");
+ assert_eq!(build_state("completed", None), "neutral");
+ }
+
+ #[test]
+ fn the_newest_run_becomes_the_build() {
+ let runs = json!({ "total_count": 40, "workflow_runs": [{
+ "id": 99, "name": "CI", "head_branch": "main", "status": "completed",
+ "conclusion": "failure", "html_url": "https://github.com/edu/coucou/actions/runs/99",
+ "created_at": "2026-09-30T17:00:00Z", "updated_at": "2026-09-30T17:04:00Z",
+ }]});
+ let build = parse_run(&runs).unwrap();
+ assert_eq!((build.id, build.state, build.workflow.as_str()), (99, "failure", "CI"));
+ assert_eq!(build.branch.as_deref(), Some("main"));
+ assert_eq!(build.at, "2026-09-30T17:04:00Z");
+ }
+
+ #[test]
+ fn a_repository_without_workflows_has_no_build() {
+ assert_eq!(parse_run(&json!({ "total_count": 0, "workflow_runs": [] })), None);
+ assert_eq!(parse_run(&json!({})), None);
+ }
+
+ #[test]
+ fn the_calendar_flattens_into_days_from_its_first_date() {
+ let day = |count: u64, level: &str| {
+ json!({ "contributionCount": count, "contributionLevel": level })
+ };
+ let calendar = json!({
+ "totalContributions": 9,
+ "weeks": [
+ // A first week that starts mid-week, as GitHub's does.
+ { "firstDay": "2025-10-01", "contributionDays": [day(0, "NONE"), day(1, "FIRST_QUARTILE")] },
+ { "firstDay": "2025-10-03", "contributionDays": [day(8, "FOURTH_QUARTILE"), day(0, "NONE")] },
+ ],
+ });
+ let c = parse_contributions(Some(&calendar)).unwrap();
+ assert_eq!((c.total, c.start.as_str()), (9, "2025-10-01"));
+ assert_eq!(c.counts, [0, 1, 8, 0]);
+ assert_eq!(c.levels, [0, 1, 4, 0]);
+ }
+
+ #[test]
+ fn no_calendar_means_no_graph() {
+ assert_eq!(parse_contributions(None), None);
+ assert_eq!(parse_contributions(Some(&json!({ "weeks": [] }))), None);
+ }
+
+ #[test]
+ fn a_day_lists_commits_by_repository_then_pull_requests_reviews_issues_and_repos() {
+ let collection = json!({
+ "restrictedContributionsCount": 3,
+ "commitContributionsByRepository": [
+ { "repository": { "nameWithOwner": "edu/coucou", "url": "https://github.com/edu/coucou" }, "contributions": { "totalCount": 4 } },
+ { "repository": { "nameWithOwner": "edu/notes", "url": "https://github.com/edu/notes" }, "contributions": { "totalCount": 1 } },
+ ],
+ "pullRequestContributions": { "nodes": [
+ { "pullRequest": { "number": 12, "title": "Panel", "url": "https://github.com/edu/coucou/pull/12", "merged": true, "repository": { "nameWithOwner": "edu/coucou" } } },
+ ]},
+ "pullRequestReviewContributions": { "nodes": [
+ { "pullRequest": { "number": 3, "title": "Typo", "url": "https://github.com/louis/coucou/pull/3", "repository": { "nameWithOwner": "louis/coucou" } } },
+ ]},
+ "issueContributions": { "nodes": [
+ { "issue": { "number": 4, "title": "Crash", "url": "https://github.com/edu/coucou/issues/4", "repository": { "nameWithOwner": "edu/coucou" } } },
+ ]},
+ "repositoryContributions": { "nodes": [
+ { "repository": { "nameWithOwner": "edu/sandbox", "url": "https://github.com/edu/sandbox" } },
+ ]},
+ });
+ let day = parse_day(&collection, Some("edu"), "2026-09-29T22:00:00.000Z", "2026-09-30T21:59:59.000Z");
+ let summary: Vec<(&str, &str)> = day.items.iter().map(|i| (i.kind, i.title.as_str())).collect();
+ assert_eq!(summary, [
+ ("push", "4 commits"),
+ ("push", "1 commit"),
+ ("pr_merged", "Panel"),
+ ("review", "Typo"),
+ ("issue_opened", "Crash"),
+ ("create", "Created the repository"),
+ ]);
+ assert_eq!(day.items[0].url, "https://github.com/edu/coucou/commits");
+ assert_eq!(day.items[3].repo, "louis/coucou");
+ assert_eq!(day.private_count, 3);
+ }
+
+ #[test]
+ fn an_empty_day_is_empty() {
+ let day = parse_day(&json!({ "restrictedContributionsCount": 0 }), None, "a", "b");
+ assert!(day.items.is_empty());
+ assert_eq!(day.private_count, 0);
+ }
+
+ #[test]
+ fn only_timestamps_go_to_github() {
+ assert!(is_timestamp("2026-09-29T22:00:00.000Z"));
+ assert!(is_timestamp("2026-09-30T00:00:00+02:00"));
+ for bad in ["", "yesterday", "2026-09-30\") { x }", "2026-09-30T00:00:00Z; drop"] {
+ assert!(!is_timestamp(bad), "{bad}");
+ }
+ }
+
+ #[test]
+ fn only_a_plain_owner_slash_name_reaches_a_url() {
+ assert_eq!(split_full_name("edu/coucou"), Some(("edu", "coucou")));
+ assert_eq!(split_full_name("louis-cfm/my_repo.rs"), Some(("louis-cfm", "my_repo.rs")));
+ for bad in ["coucou", "edu/", "/coucou", "edu/../x", "edu/co?x=1", "a/b/c", "../etc", "edu/.."] {
+ assert_eq!(split_full_name(bad), None, "{bad}");
+ }
+ }
+
+ #[test]
+ fn a_refused_field_is_found_in_the_error_path() {
+ let errors = vec![json!({ "type": "FORBIDDEN", "path": ["repository", "deployments"] })];
+ assert!(refused(&errors, "deployments"));
+ assert!(!refused(&errors, "pullRequests"));
+ assert!(!refused(&[], "deployments"));
+ }
+
+ #[test]
+ fn languages_asked_for_are_named_and_the_rest_is_other() {
+ // Four are asked for; the repository's total counts them all.
+ let langs = json!({ "totalSize": 1000, "edges": [
+ { "size": 500, "node": { "name": "Rust", "color": "#dea584" } },
+ { "size": 200, "node": { "name": "TypeScript", "color": "#3178c6" } },
+ { "size": 100, "node": { "name": "CSS", "color": "#663399" } },
+ { "size": 80, "node": { "name": "HTML", "color": null } },
+ ]});
+ let shares = parse_languages(Some(&langs));
+ let names: Vec<&str> = shares.iter().map(|l| l.name.as_str()).collect();
+ assert_eq!(names, ["Rust", "TypeScript", "CSS", "HTML", "Other"]);
+ // The 12 % the four largest don't cover.
+ assert!((shares[4].share - 0.12).abs() < 1e-9);
+ assert!((shares.iter().map(|l| l.share).sum::() - 1.0).abs() < 1e-9);
+ }
+
+ #[test]
+ fn an_empty_repository_has_no_language_bar() {
+ assert!(parse_languages(Some(&json!({ "totalSize": 0, "edges": [] }))).is_empty());
+ assert!(parse_languages(None).is_empty());
+ }
+
+ #[test]
+ fn a_pull_request_says_where_it_stands() {
+ let pr = |merged: bool, state: &str, draft: bool| json!({
+ "number": 12, "title": "Panel", "url": "https://github.com/edu/coucou/pull/12",
+ "state": state, "isDraft": draft, "merged": merged,
+ "mergedAt": if merged { json!("2026-09-30T18:00:00Z") } else { Value::Null },
+ "updatedAt": "2026-09-30T19:00:00Z", "additions": 320, "deletions": 40, "changedFiles": 9,
+ "reviewDecision": "APPROVED", "author": { "login": "edu" }, "comments": { "totalCount": 3 },
+ });
+ let merged = parse_pull(&pr(true, "MERGED", false)).unwrap();
+ assert_eq!((merged.state, merged.at.as_str()), ("merged", "2026-09-30T18:00:00Z"));
+ assert_eq!((merged.additions, merged.deletions, merged.comments), (320, 40, 3));
+ assert_eq!(merged.review, Some("approved"));
+ assert_eq!(parse_pull(&pr(false, "OPEN", true)).unwrap().state, "draft");
+ assert_eq!(parse_pull(&pr(false, "OPEN", false)).unwrap().state, "open");
+ let closed = parse_pull(&pr(false, "CLOSED", false)).unwrap();
+ assert_eq!((closed.state, closed.at.as_str()), ("closed", "2026-09-30T19:00:00Z"));
+ }
+
+ #[test]
+ fn a_deployment_reads_its_latest_status() {
+ let live = json!({
+ "environment": "Production", "createdAt": "2026-09-30T10:00:00Z",
+ "commitOid": "a1b2c3d4e5f6", "creator": { "login": "vercel" },
+ "latestStatus": { "state": "SUCCESS", "environmentUrl": "https://coucou.vercel.app", "createdAt": "2026-09-30T10:02:00Z" },
+ });
+ let d = parse_deploy(&live).unwrap();
+ assert_eq!((d.state, d.environment.as_str(), d.sha.as_deref()), ("success", "Production", Some("a1b2c3d")));
+ assert_eq!((d.url.as_deref(), d.at.as_str()), (Some("https://coucou.vercel.app"), "2026-09-30T10:02:00Z"));
+
+ let pending = json!({ "environment": "Preview", "createdAt": "2026-09-30T11:00:00Z", "latestStatus": null });
+ let d = parse_deploy(&pending).unwrap();
+ assert_eq!((d.state, d.at.as_str(), d.url), ("running", "2026-09-30T11:00:00Z", None));
+ }
+
+ #[test]
+ fn runs_carry_what_the_ci_line_says() {
+ let runs = json!({ "workflow_runs": [{
+ "id": 7, "name": "CI", "head_branch": "main", "status": "completed", "conclusion": "success",
+ "display_title": "Fix the hook timeout\nmore", "triggering_actor": { "login": "edu" },
+ "html_url": "https://github.com/edu/coucou/actions/runs/7",
+ "run_started_at": "2026-09-30T10:00:00Z", "updated_at": "2026-09-30T10:02:14Z",
+ }, { "id": 6 }]});
+ let list = parse_runs(&runs);
+ assert_eq!(list.len(), 1);
+ assert_eq!((list[0].state, list[0].title.as_deref(), list[0].actor.as_deref()), ("success", Some("Fix the hook timeout"), Some("edu")));
+ assert_eq!(list[0].started_at.as_deref(), Some("2026-09-30T10:00:00Z"));
+ }
+
+ #[test]
+ fn graphql_errors_map_to_something_a_person_can_act_on() {
+ assert_eq!(graphql_error(&[json!({ "type": "FORBIDDEN" })]), GhError::Forbidden);
+ assert_eq!(graphql_error(&[json!({ "type": "INSUFFICIENT_SCOPES" })]), GhError::Forbidden);
+ assert_eq!(graphql_error(&[json!({ "message": "boom" })]), GhError::BadResponse);
+ assert_eq!(graphql_error(&[]), GhError::BadResponse);
+ }
+}
diff --git a/windows/src-tauri/src/github_detail.rs b/windows/src-tauri/src/github_detail.rs
new file mode 100644
index 0000000000..c2574a7d85
--- /dev/null
+++ b/windows/src-tauri/src/github_detail.rs
@@ -0,0 +1,1405 @@
+// GitHub, one level deeper: the sheet behind a line of activity — a pull
+// request and what was said on it, an issue, a push's commits, a release, a
+// run of Actions — so that GitHub's own site is the last place to go, not the
+// first.
+//
+// Fetched on the click only, never by the tick, and kept a minute. What to
+// fetch comes as a Target that github.rs built from GitHub's answers; it
+// travels through the island and back, so it is checked again here before any
+// of it reaches a URL or a query.
+
+use std::sync::LazyLock;
+
+use serde::{Deserialize, Serialize};
+use serde_json::{json, Value};
+
+use crate::github::{
+ build_state, first_line, flag, int, is_timestamp, nodes, parse_run, permission, pull_state, review_decision,
+ short_sha, split_full_name, text, unix_now, Build, Gh, GhError, Kept, SHORT_SHA, UNTITLED, WEB, WORKFLOW,
+};
+
+/// What a line of activity leads to.
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
+pub enum Target {
+ Pull { repo: String, number: u64 },
+ Issue { repo: String, number: u64 },
+ /// A push (its `head` and how many commits), or a day's commits in one
+ /// repository (`author` between `from` and `to`).
+ Commits {
+ repo: String,
+ head: Option,
+ count: Option,
+ branch: Option,
+ author: Option,
+ from: Option,
+ to: Option,
+ },
+ Release { repo: String, tag: String },
+ /// A repository: the island opens its project sheet; nothing to fetch here.
+ Project { repo: String },
+ /// A run of Actions, from a CI line of a project, a pull request or a commit.
+ Run { repo: String, id: u64 },
+ /// What was said on a pull request, from its sheet.
+ Comments { repo: String, number: u64 },
+}
+
+/// A line's sheet. `Locked` is a sheet the token may not read, with the
+/// permission that would open it.
+#[derive(Serialize, Clone)]
+#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
+pub enum Detail {
+ /// Boxed: a pull request's sheet is twice the size of any other.
+ Pull(Box),
+ Issue(IssueDetail),
+ Commits(CommitsDetail),
+ Release(ReleaseDetail),
+ Run(RunDetail),
+ Comments(CommentsDetail),
+ Locked { permission: &'static str },
+}
+
+#[derive(Serialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct Label {
+ pub name: String,
+ /// "#rrggbb".
+ pub color: String,
+}
+
+#[derive(Serialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct FileChange {
+ pub path: String,
+ /// added, modified, removed, renamed…, as GitHub says.
+ pub status: Option,
+ pub additions: i64,
+ pub deletions: i64,
+ /// The unified diff of the file, as GitHub gives it; None for a binary
+ /// file or one too large for GitHub to diff.
+ pub patch: Option,
+ /// The patch was cut to MAX_PATCH; the whole of it is on GitHub.
+ pub truncated: bool,
+}
+
+#[derive(Serialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct Review {
+ pub login: String,
+ /// approved, changes requested, commented or dismissed.
+ pub state: &'static str,
+}
+
+#[derive(Serialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct PullDetail {
+ pub repo: String,
+ pub number: u64,
+ pub title: String,
+ pub url: String,
+ /// open, draft, merged or closed.
+ pub state: &'static str,
+ pub author: Option,
+ pub base: Option,
+ pub head: Option,
+ pub additions: i64,
+ pub deletions: i64,
+ pub changed_files: i64,
+ pub commits: i64,
+ pub comments: i64,
+ /// Threads of comments on the lines of code.
+ pub threads: i64,
+ /// approved, changes requested or review required.
+ pub review: Option<&'static str>,
+ pub reviewers: Vec,
+ pub labels: Vec,
+ /// The files touched, each with its diff.
+ pub files: Vec,
+ pub created_at: Option,
+ pub merged_at: Option,
+ pub merged_by: Option,
+ pub closed_at: Option,
+ /// The newest Actions run on the pull request's last commit.
+ pub ci: Option,
+ /// The permission the token lacks to read the runs, when it does.
+ pub missing: Vec<&'static str>,
+}
+
+#[derive(Serialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct IssueDetail {
+ pub repo: String,
+ pub number: u64,
+ pub title: String,
+ pub url: String,
+ /// open, completed, not planned or closed.
+ pub state: &'static str,
+ pub author: Option,
+ pub body: Option,
+ pub labels: Vec,
+ pub assignees: Vec,
+ pub comments: i64,
+ pub created_at: Option,
+ pub closed_at: Option,
+}
+
+#[derive(Serialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct CommitLine {
+ /// Seven characters, to show.
+ pub sha: String,
+ /// The whole SHA, to open the commit's own sheet.
+ pub id: String,
+ /// The first line of the message.
+ pub message: String,
+ pub author: Option,
+ pub at: Option,
+ pub url: String,
+}
+
+#[derive(Serialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct CommitsDetail {
+ pub repo: String,
+ pub branch: Option,
+ /// How many commits the push or the day counted; `commits` may show fewer.
+ pub total: Option,
+ /// Newest first.
+ pub commits: Vec,
+ /// What the newest commit changed, file by file with its diff.
+ pub additions: Option,
+ pub deletions: Option,
+ pub files: Vec,
+ /// The newest Actions run on the newest commit.
+ pub ci: Option,
+ /// Where GitHub shows the same commits.
+ pub url: String,
+ pub missing: Vec<&'static str>,
+}
+
+#[derive(Serialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct Asset {
+ pub name: String,
+ pub downloads: i64,
+ /// Bytes.
+ pub size: i64,
+}
+
+#[derive(Serialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct ReleaseDetail {
+ pub repo: String,
+ pub tag: String,
+ pub name: String,
+ pub url: String,
+ /// The release notes as plain text, cut short.
+ pub body: Option,
+ pub author: Option,
+ pub published_at: Option,
+ pub prerelease: bool,
+ pub assets: Vec,
+ pub downloads: i64,
+}
+
+/// A run, its jobs, and each job's steps, with when each one started and
+/// ended: how long everything took is worked out from those, by the island.
+#[derive(Serialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct RunDetail {
+ pub repo: String,
+ pub id: u64,
+ pub workflow: String,
+ /// The commit or pull request title the run is about.
+ pub title: Option,
+ pub branch: Option,
+ /// What started it: push, pull_request, schedule, workflow_dispatch…
+ pub event: Option,
+ pub actor: Option,
+ /// success, failure, running or neutral — the colour.
+ pub state: &'static str,
+ /// passed, failed, running, queued, cancelled, skipped… — the word.
+ pub outcome: &'static str,
+ /// 2 and up for a re-run.
+ pub attempt: u64,
+ pub url: String,
+ pub started_at: Option,
+ /// None while it runs.
+ pub ended_at: Option,
+ pub jobs: Vec,
+ /// Jobs of the run beyond the ones carried.
+ pub more_jobs: u64,
+}
+
+#[derive(Serialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct Job {
+ pub id: u64,
+ pub name: String,
+ pub state: &'static str,
+ pub outcome: &'static str,
+ /// The job's page on GitHub, with its logs.
+ pub url: String,
+ /// The machine it asked for, e.g. "ubuntu-latest".
+ pub runner: Option,
+ /// None while it waits for a runner.
+ pub started_at: Option,
+ pub ended_at: Option,
+ pub steps: Vec,
+}
+
+#[derive(Serialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct Step {
+ pub name: String,
+ pub state: &'static str,
+ pub outcome: &'static str,
+ pub started_at: Option,
+ pub ended_at: Option,
+}
+
+/// What was said on a pull request — its description, the comments, the
+/// reviews, the threads on its lines of code — oldest first, the way a
+/// conversation reads. To read only: the token cannot answer, and Coucou never
+/// asks for one that could.
+#[derive(Serialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct CommentsDetail {
+ pub repo: String,
+ pub number: u64,
+ pub title: String,
+ pub url: String,
+ pub entries: Vec,
+ /// Older comments, reviews or threads than the ones carried are on GitHub.
+ pub earlier: bool,
+}
+
+#[derive(Serialize, Clone, Debug, PartialEq)]
+#[serde(tag = "kind", rename_all = "camelCase")]
+pub enum Entry {
+ /// What the pull request says of itself.
+ Description(Remark),
+ Comment(Remark),
+ Review(Verdict),
+ Thread(Thread),
+}
+
+impl Entry {
+ /// When it was said; a thread, when it started.
+ fn at(&self) -> Option<&str> {
+ match self {
+ Entry::Description(r) | Entry::Comment(r) => r.at.as_deref(),
+ Entry::Review(v) => v.remark.at.as_deref(),
+ Entry::Thread(t) => t.remarks.first().and_then(|r| r.at.as_deref()),
+ }
+ }
+}
+
+/// Something somebody wrote.
+#[derive(Serialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct Remark {
+ /// None for an account that is gone.
+ pub author: Option,
+ /// Plain text, its lines kept; empty for a review that only gave a verdict.
+ pub body: String,
+ /// The text was cut to MAX_REMARK; the whole of it is at `url`.
+ pub cut: bool,
+ pub at: Option,
+ pub url: String,
+}
+
+/// A review: its verdict, and what the reviewer wrote under it.
+#[derive(Serialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct Verdict {
+ /// approved, changes requested, commented or dismissed.
+ pub state: &'static str,
+ #[serde(flatten)]
+ pub remark: Remark,
+}
+
+/// Comments on one place in the code, and the replies under them.
+#[derive(Serialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct Thread {
+ pub path: String,
+ /// The line it sits on in the file as it is now; None on a whole file, or
+ /// once the code under it has changed.
+ pub line: Option,
+ /// "left" on a line that was removed, "right" otherwise.
+ pub side: &'static str,
+ pub resolved: bool,
+ /// The code has changed since: the thread is about lines that are gone.
+ pub outdated: bool,
+ /// The lines it is about: the end of the hunk it was written on.
+ pub code: Vec,
+ pub remarks: Vec,
+ /// Replies beyond the ones carried.
+ pub more: u64,
+}
+
+#[derive(Serialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct CodeLine {
+ /// Its number in the file: the old one for a removed line.
+ pub number: Option,
+ /// "+", "-", or "" for a line that did not change.
+ pub sign: &'static str,
+ pub text: String,
+}
+
+const TTL: u64 = 60;
+/// A run still going is worth asking again sooner: its jobs finish one by one.
+const LIVE_TTL: u64 = 10;
+/// Jobs carried per run; a matrix can have hundreds.
+const MAX_JOBS: usize = 30;
+/// Descriptions and notes are a taste, not the whole text: GitHub has that.
+const EXCERPT: usize = 320;
+/// Files carried per sheet, and the most of one file's diff: enough to read on
+/// a small panel, without shipping a whole refactor through the island.
+const MAX_FILES: usize = 20;
+const MAX_PATCH: usize = 12_000;
+const MAX_COMMITS: u64 = 10;
+/// Files of a release listed; its downloads are counted over all of them.
+const MAX_ASSETS: usize = 5;
+/// The grey a label gets when GitHub gives it no colour.
+const LABEL_GREY: &str = "6b7079";
+/// A comment is there to be read, so it gets more room than an excerpt; past
+/// this it is an essay, and GitHub has it.
+const MAX_REMARK: usize = 1600;
+/// Lines of code shown over a thread, and the most of one line.
+const CODE_LINES: usize = 4;
+const MAX_CODE: usize = 240;
+
+/// Reviewers and labels a pull request's sheet names; labels and assignees an issue's.
+const MAX_REVIEWERS: usize = 6;
+const MAX_LABELS: usize = 6;
+const MAX_ASSIGNEES: usize = 4;
+/// The newest comments, reviews and threads carried, of each kind, and the
+/// replies carried in a thread.
+const MAX_ENTRIES: usize = 30;
+const MAX_REPLIES: usize = 10;
+
+static CACHE: LazyLock> = LazyLock::new(Kept::new);
+
+/// The token changed: what was read with the old one is not the new one's.
+pub(crate) fn forget() {
+ CACHE.clear();
+}
+
+const PULL_QUERY: &str = "query($owner: String!, $name: String!, $number: Int!, $reviewers: Int!, $labels: Int!) { \
+ repository(owner: $owner, name: $name) { pullRequest(number: $number) { \
+ number title url state isDraft mergedAt createdAt closedAt \
+ additions deletions changedFiles baseRefName headRefName headRefOid reviewDecision \
+ author { login } mergedBy { login } commits { totalCount } comments { totalCount } \
+ reviewThreads { totalCount } \
+ latestReviews(first: $reviewers) { nodes { state author { login } } } \
+ labels(first: $labels) { nodes { name color } } } } }";
+
+/// `hunk` is the code a thread was written on, which only its first comment
+/// needs to say.
+const COMMENTS_QUERY: &str = "query($owner: String!, $name: String!, $number: Int!, $entries: Int!, $replies: Int!) { \
+ repository(owner: $owner, name: $name) { pullRequest(number: $number) { \
+ number title url bodyText createdAt author { login } \
+ comments(last: $entries) { totalCount nodes { url bodyText createdAt author { login } } } \
+ reviews(last: $entries) { totalCount nodes { url state bodyText submittedAt author { login } } } \
+ reviewThreads(last: $entries) { totalCount nodes { isResolved isOutdated path line diffSide \
+ hunk: comments(first: 1) { nodes { diffHunk } } \
+ comments(first: $replies) { totalCount nodes { url bodyText createdAt author { login } } } } } } } }";
+
+const ISSUE_QUERY: &str = "query($owner: String!, $name: String!, $number: Int!, $labels: Int!, $assignees: Int!) { \
+ repository(owner: $owner, name: $name) { issue(number: $number) { \
+ number title url state stateReason createdAt closedAt bodyText author { login } \
+ comments { totalCount } labels(first: $labels) { nodes { name color } } \
+ assignees(first: $assignees) { nodes { login } } } } }";
+
+pub async fn detail(target: Target, force: bool) -> Result {
+ let key = serde_json::to_string(&target).map_err(|e| e.to_string())?;
+ let now = unix_now();
+ if !force {
+ if let Some(kept) = CACHE.fresh(&key, now, ttl) {
+ return Ok(kept);
+ }
+ }
+
+ let gh = Gh::from_store()?;
+ let fetched = match &target {
+ Target::Pull { repo, number } => pull(&gh, repo, *number).await,
+ Target::Issue { repo, number } => issue(&gh, repo, *number).await,
+ Target::Commits { repo, head, count, branch, author, from, to } => {
+ commits(&gh, repo, head.as_deref(), *count, branch.clone(), author.as_deref(), from.as_deref(), to.as_deref()).await
+ }
+ Target::Release { repo, tag } => release(&gh, repo, tag).await,
+ Target::Run { repo, id } => run(&gh, repo, *id).await,
+ Target::Comments { repo, number } => comments(&gh, repo, *number).await,
+ Target::Project { .. } => Err(GhError::BadResponse),
+ };
+ let detail = fetched?;
+ CACHE.keep(key, now, detail.clone());
+ Ok(detail)
+}
+
+fn ttl(detail: &Detail) -> u64 {
+ match detail {
+ Detail::Run(run) if run.state == "running" => LIVE_TTL,
+ _ => TTL,
+ }
+}
+
+fn owner_name(repo: &str) -> Result<(&str, &str), GhError> {
+ split_full_name(repo).ok_or(GhError::NotFound)
+}
+
+/// The newest Actions run on a commit. A token that may not read Actions
+/// leaves the sheet without CI rather than without everything.
+async fn ci_for(gh: &Gh, repo: &str, sha: &str, missing: &mut Vec<&'static str>) -> Result, GhError> {
+ match gh.get(&format!("/repos/{repo}/actions/runs?head_sha={sha}&per_page=1")).await {
+ Ok(reply) => Ok(parse_run(&reply.json)),
+ Err(GhError::Forbidden) => {
+ missing.push(permission::ACTIONS);
+ Ok(None)
+ }
+ Err(GhError::NotFound) => Ok(None),
+ Err(e) => Err(e),
+ }
+}
+
+/// A pull request or an issue (`field`) of a repository, by its number: its
+/// node in the answer, or None when the token may not read it. `sizes` are the
+/// query's own variables, on top of the three every such query takes.
+async fn numbered(gh: &Gh, query: &str, repo: &str, number: u64, field: &str, mut sizes: Value) -> Result , GhError> {
+ let (owner, name) = owner_name(repo)?;
+ sizes["owner"] = json!(owner);
+ sizes["name"] = json!(name);
+ sizes["number"] = json!(number);
+ let (mut data, _) = gh.graphql_with(query, sizes).await.or_else(locked_on_refusal)?;
+ Ok(data.pointer_mut(&format!("/repository/{field}")).map(Value::take).filter(|n| !n.is_null()))
+}
+
+// ── Pull request ──────────────────────────────────────────────────────────────
+
+async fn pull(gh: &Gh, repo: &str, number: u64) -> Result {
+ let sizes = json!({ "reviewers": MAX_REVIEWERS, "labels": MAX_LABELS });
+ let Some(node) = numbered(gh, PULL_QUERY, repo, number, "pullRequest", sizes).await? else {
+ return Ok(Detail::Locked { permission: permission::PULL_REQUESTS });
+ };
+ let node = &node;
+ let mut detail = parse_pull(repo, node).ok_or(GhError::BadResponse)?;
+ // The files with their diffs: GraphQL has no patch, the REST list does.
+ match gh.get(&format!("/repos/{repo}/pulls/{number}/files?per_page={MAX_FILES}")).await {
+ Ok(reply) => detail.files = files(Some(&reply.json)),
+ Err(GhError::Forbidden) | Err(GhError::NotFound) => {}
+ Err(e) => return Err(e),
+ }
+ if let Some(sha) = text(node.get("headRefOid")) {
+ detail.ci = ci_for(gh, repo, &sha, &mut detail.missing).await?;
+ }
+ Ok(Detail::Pull(Box::new(detail)))
+}
+
+/// A GraphQL query refused as a whole reads as "no data", which the callers
+/// turn into a locked sheet.
+fn locked_on_refusal(e: GhError) -> Result<(Value, Vec), GhError> {
+ match e {
+ GhError::Forbidden | GhError::NotFound => Ok((Value::Null, Vec::new())),
+ other => Err(other),
+ }
+}
+
+fn labels(node: &Value) -> Vec {
+ nodes(node, "/labels/nodes")
+ .iter()
+ .filter_map(|l| {
+ Some(Label {
+ name: text(l.get("name"))?,
+ color: format!("#{}", text(l.get("color")).unwrap_or_else(|| LABEL_GREY.into())),
+ })
+ })
+ .collect()
+}
+
+/// The `files` of a REST commit or pull request answer.
+fn files(list: Option<&Value>) -> Vec {
+ list.and_then(Value::as_array)
+ .map(|files| {
+ files
+ .iter()
+ .filter_map(|f| {
+ let (patch, truncated) = match text(f.get("patch")) {
+ Some(p) => cut_patch(p),
+ None => (None, false),
+ };
+ Some(FileChange {
+ path: text(f.get("filename"))?,
+ status: text(f.get("status")),
+ additions: int(f, "/additions"),
+ deletions: int(f, "/deletions"),
+ patch,
+ truncated,
+ })
+ })
+ .take(MAX_FILES)
+ .collect()
+ })
+ .unwrap_or_default()
+}
+
+/// A patch cut at a line boundary before MAX_PATCH, and whether it was cut.
+fn cut_patch(patch: String) -> (Option, bool) {
+ if patch.len() <= MAX_PATCH {
+ return (Some(patch), false);
+ }
+ let mut end = MAX_PATCH;
+ while !patch.is_char_boundary(end) {
+ end -= 1;
+ }
+ let cut = patch[..end].rfind('\n').map_or(&patch[..end], |i| &patch[..i]);
+ (Some(cut.to_string()), true)
+}
+
+/// What a review said — approved, changes requested, commented or dismissed.
+/// None for one not sent yet (PENDING): only its author can see it.
+fn review_state(review: &Value) -> Option<&'static str> {
+ match review.get("state").and_then(Value::as_str)? {
+ "APPROVED" => Some("approved"),
+ "CHANGES_REQUESTED" => Some("changes requested"),
+ "COMMENTED" => Some("commented"),
+ "DISMISSED" => Some("dismissed"),
+ _ => None,
+ }
+}
+
+fn parse_pull(repo: &str, node: &Value) -> Option {
+ let reviewers = nodes(node, "/latestReviews/nodes")
+ .iter()
+ .filter_map(|r| Some(Review { login: text(r.pointer("/author/login"))?, state: review_state(r)? }))
+ .collect();
+ let count = |path: &str| int(node, path);
+ Some(PullDetail {
+ repo: repo.to_string(),
+ number: node.get("number")?.as_u64()?,
+ title: text(node.get("title")).unwrap_or_else(|| UNTITLED.into()),
+ url: text(node.get("url"))?,
+ state: pull_state(node),
+ author: text(node.pointer("/author/login")),
+ base: text(node.get("baseRefName")),
+ head: text(node.get("headRefName")),
+ additions: count("/additions"),
+ deletions: count("/deletions"),
+ changed_files: count("/changedFiles"),
+ commits: count("/commits/totalCount"),
+ comments: count("/comments/totalCount"),
+ threads: count("/reviewThreads/totalCount"),
+ review: review_decision(node),
+ reviewers,
+ labels: labels(node),
+ files: Vec::new(),
+ created_at: text(node.get("createdAt")),
+ merged_at: text(node.get("mergedAt")),
+ merged_by: text(node.pointer("/mergedBy/login")),
+ closed_at: text(node.get("closedAt")),
+ ci: None,
+ missing: Vec::new(),
+ })
+}
+
+// ── What was said on a pull request ───────────────────────────────────────────
+
+async fn comments(gh: &Gh, repo: &str, number: u64) -> Result {
+ let sizes = json!({ "entries": MAX_ENTRIES, "replies": MAX_REPLIES });
+ let Some(node) = numbered(gh, COMMENTS_QUERY, repo, number, "pullRequest", sizes).await? else {
+ return Ok(Detail::Locked { permission: permission::PULL_REQUESTS });
+ };
+ parse_comments(repo, &node).map(Detail::Comments).ok_or(GhError::BadResponse)
+}
+
+/// The nodes of one of a pull request's lists, and whether GitHub has more.
+fn listed<'a>(node: &'a Value, list: &str) -> (&'a [Value], bool) {
+ let carried = nodes(node, &format!("/{list}/nodes"));
+ let total = node.pointer(&format!("/{list}/totalCount")).and_then(Value::as_u64).unwrap_or(0);
+ (carried, total > carried.len() as u64)
+}
+
+/// What a node says, by whom and when; `when` is the name of its date.
+fn remark(node: &Value, when: &str) -> Option {
+ let (body, cut) = passage(node.get("bodyText").and_then(Value::as_str).unwrap_or(""));
+ Some(Remark {
+ author: text(node.pointer("/author/login")),
+ body,
+ cut,
+ at: text(node.get(when)),
+ url: text(node.get("url"))?,
+ })
+}
+
+fn parse_comments(repo: &str, node: &Value) -> Option {
+ let mut entries = Vec::new();
+
+ let (comments, more_comments) = listed(node, "comments");
+ entries.extend(
+ comments
+ .iter()
+ .filter_map(|c| remark(c, "createdAt"))
+ .filter(|r| !r.body.is_empty())
+ .map(Entry::Comment),
+ );
+
+ let (reviews, more_reviews) = listed(node, "reviews");
+ entries.extend(reviews.iter().filter_map(|r| {
+ let state = review_state(r)?;
+ let remark = remark(r, "submittedAt")?;
+ // A review that only commented, without a word of its own, is the
+ // envelope of its comments on the code: the threads say it all.
+ if state == "commented" && remark.body.is_empty() {
+ return None;
+ }
+ Some(Entry::Review(Verdict { state, remark }))
+ }));
+
+ let (threads, more_threads) = listed(node, "reviewThreads");
+ entries.extend(threads.iter().filter_map(parse_thread).map(Entry::Thread));
+
+ // GitHub's timestamps are all UTC in one format: they sort as text.
+ entries.sort_by(|a, b| a.at().cmp(&b.at()));
+
+ // The description opens the conversation, whenever it was last edited.
+ if let Some(description) = remark(node, "createdAt").filter(|r| !r.body.is_empty()) {
+ entries.insert(0, Entry::Description(description));
+ }
+
+ Some(CommentsDetail {
+ repo: repo.to_string(),
+ number: node.get("number")?.as_u64()?,
+ title: text(node.get("title")).unwrap_or_else(|| UNTITLED.into()),
+ url: text(node.get("url"))?,
+ entries,
+ earlier: more_comments || more_reviews || more_threads,
+ })
+}
+
+fn parse_thread(thread: &Value) -> Option {
+ let (replies, _) = listed(thread, "comments");
+ let remarks: Vec = replies
+ .iter()
+ .filter_map(|c| remark(c, "createdAt"))
+ .filter(|r| !r.body.is_empty())
+ .collect();
+ if remarks.is_empty() {
+ return None;
+ }
+ let total = thread.pointer("/comments/totalCount").and_then(Value::as_u64).unwrap_or(0);
+ Some(Thread {
+ path: text(thread.get("path"))?,
+ line: thread.get("line").and_then(Value::as_u64),
+ side: match thread.get("diffSide").and_then(Value::as_str) {
+ Some("LEFT") => "left",
+ _ => "right",
+ },
+ resolved: flag(thread, "isResolved"),
+ outdated: flag(thread, "isOutdated"),
+ code: thread
+ .pointer("/hunk/nodes/0/diffHunk")
+ .and_then(Value::as_str)
+ .map(hunk_tail)
+ .unwrap_or_default(),
+ more: total.saturating_sub(replies.len() as u64),
+ remarks,
+ })
+}
+
+/// "@@ -12,9 +14,11 @@ fn x()" → (12, 14): where a hunk starts, old and new.
+fn hunk_starts(header: &str) -> Option<(u64, u64)> {
+ let mut parts = header.strip_prefix("@@ -")?.split(' ');
+ let old = parts.next()?.split(',').next()?.parse().ok()?;
+ let new = parts.next()?.strip_prefix('+')?.split(',').next()?.parse().ok()?;
+ Some((old, new))
+}
+
+/// The last lines of the hunk a thread was written on — the code it is about
+/// — each with its number in the file.
+fn hunk_tail(hunk: &str) -> Vec {
+ let mut starts: Option<(u64, u64)> = None;
+ let mut lines = Vec::new();
+ for raw in hunk.lines() {
+ if let Some(found) = hunk_starts(raw) {
+ starts = Some(found);
+ continue;
+ }
+ let mut chars = raw.chars();
+ let sign = match chars.next() {
+ Some('+') => "+",
+ Some('-') => "-",
+ // "\ No newline at end of file", or nothing at all.
+ Some('\\') | None => continue,
+ _ => "",
+ };
+ let number = starts.as_mut().map(|(old, new)| {
+ let number = if sign == "-" { *old } else { *new };
+ if sign != "+" {
+ *old += 1;
+ }
+ if sign != "-" {
+ *new += 1;
+ }
+ number
+ });
+ lines.push(CodeLine { number, sign, text: chars.as_str().chars().take(MAX_CODE).collect() });
+ }
+ lines.split_off(lines.len().saturating_sub(CODE_LINES))
+}
+
+// ── Issue ─────────────────────────────────────────────────────────────────────
+
+async fn issue(gh: &Gh, repo: &str, number: u64) -> Result {
+ let sizes = json!({ "labels": MAX_LABELS, "assignees": MAX_ASSIGNEES });
+ let Some(node) = numbered(gh, ISSUE_QUERY, repo, number, "issue", sizes).await? else {
+ return Ok(Detail::Locked { permission: permission::ISSUES });
+ };
+ parse_issue(repo, &node).map(Detail::Issue).ok_or(GhError::BadResponse)
+}
+
+fn parse_issue(repo: &str, node: &Value) -> Option {
+ let state = match (node.get("state").and_then(Value::as_str), node.get("stateReason").and_then(Value::as_str)) {
+ (Some("OPEN"), _) => "open",
+ (_, Some("COMPLETED")) => "completed",
+ (_, Some("NOT_PLANNED")) => "not planned",
+ _ => "closed",
+ };
+ Some(IssueDetail {
+ repo: repo.to_string(),
+ number: node.get("number")?.as_u64()?,
+ title: text(node.get("title")).unwrap_or_else(|| UNTITLED.into()),
+ url: text(node.get("url"))?,
+ state,
+ author: text(node.pointer("/author/login")),
+ body: text(node.get("bodyText")).map(|b| excerpt(&b)),
+ labels: labels(node),
+ assignees: nodes(node, "/assignees/nodes").iter().filter_map(|n| text(n.get("login"))).collect(),
+ comments: int(node, "/comments/totalCount"),
+ created_at: text(node.get("createdAt")),
+ closed_at: text(node.get("closedAt")),
+ })
+}
+
+// ── Commits ───────────────────────────────────────────────────────────────────
+
+/// A SHA written out whole.
+const FULL_SHA: usize = 40;
+
+fn is_sha(s: &str) -> bool {
+ (SHORT_SHA..=FULL_SHA).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit())
+}
+
+fn is_login(s: &str) -> bool {
+ !s.is_empty() && s.len() <= 39 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
+}
+
+/// A timestamp for a query string: `+` would read as a space.
+fn query_time(s: &str) -> String {
+ s.replace('+', "%2B")
+}
+
+#[allow(clippy::too_many_arguments)]
+async fn commits(
+ gh: &Gh,
+ repo: &str,
+ head: Option<&str>,
+ count: Option,
+ branch: Option,
+ author: Option<&str>,
+ from: Option<&str>,
+ to: Option<&str>,
+) -> Result {
+ owner_name(repo)?;
+ let (list_path, url) = match (head, author, from, to) {
+ (Some(sha), _, _, _) if is_sha(sha) => {
+ let n = count.unwrap_or(1).clamp(1, MAX_COMMITS);
+ (format!("/repos/{repo}/commits?sha={sha}&per_page={n}"), format!("{WEB}/{repo}/commits/{sha}"))
+ }
+ (None, Some(login), Some(from), Some(to)) if is_login(login) && is_timestamp(from) && is_timestamp(to) => {
+ let window = format!("author={login}&since={}&until={}", query_time(from), query_time(to));
+ (
+ format!("/repos/{repo}/commits?{window}&per_page={MAX_COMMITS}"),
+ format!("{WEB}/{repo}/commits?{window}"),
+ )
+ }
+ _ => return Err(GhError::NotFound),
+ };
+
+ let list = match gh.get(&list_path).await {
+ Ok(reply) => reply.json,
+ // A private repository's commits need Contents; GitHub answers 404 as
+ // often as 403 for what a token may not see.
+ Err(GhError::Forbidden) | Err(GhError::NotFound) => {
+ return Ok(Detail::Locked { permission: permission::CONTENTS })
+ }
+ Err(e) => return Err(e),
+ };
+ let lines = parse_commit_lines(&list);
+
+ let mut detail = CommitsDetail {
+ repo: repo.to_string(),
+ branch,
+ total: count,
+ commits: lines,
+ additions: None,
+ deletions: None,
+ files: Vec::new(),
+ ci: None,
+ url,
+ missing: Vec::new(),
+ };
+
+ // What the newest one changed, and whether it built.
+ if let Some(newest) = list.pointer("/0/sha").and_then(Value::as_str).map(str::to_string) {
+ if let Ok(reply) = gh.get(&format!("/repos/{repo}/commits/{newest}?per_page={MAX_FILES}")).await {
+ detail.additions = reply.json.pointer("/stats/additions").and_then(Value::as_i64);
+ detail.deletions = reply.json.pointer("/stats/deletions").and_then(Value::as_i64);
+ detail.files = files(reply.json.get("files"));
+ }
+ detail.ci = ci_for(gh, repo, &newest, &mut detail.missing).await?;
+ }
+ Ok(Detail::Commits(detail))
+}
+
+fn parse_commit_lines(list: &Value) -> Vec {
+ list.as_array()
+ .map(|commits| {
+ commits
+ .iter()
+ .filter_map(|c| {
+ let sha = text(c.get("sha"))?;
+ Some(CommitLine {
+ message: text(c.pointer("/commit/message")).and_then(first_line).unwrap_or_default(),
+ author: text(c.pointer("/author/login")).or_else(|| text(c.pointer("/commit/author/name"))),
+ at: text(c.pointer("/commit/author/date")),
+ url: text(c.get("html_url")).unwrap_or_default(),
+ sha: short_sha(&sha),
+ id: sha,
+ })
+ })
+ .collect()
+ })
+ .unwrap_or_default()
+}
+
+// ── Release ───────────────────────────────────────────────────────────────────
+
+fn is_tag(s: &str) -> bool {
+ !s.is_empty() && s.len() <= 100 && s.chars().all(|c| c.is_ascii_alphanumeric() || "._-+/".contains(c))
+}
+
+async fn release(gh: &Gh, repo: &str, tag: &str) -> Result {
+ owner_name(repo)?;
+ if !is_tag(tag) {
+ return Err(GhError::NotFound);
+ }
+ let path_tag = tag.replace('/', "%2F").replace('+', "%2B");
+ match gh.get(&format!("/repos/{repo}/releases/tags/{path_tag}")).await {
+ Ok(reply) => parse_release(repo, tag, &reply.json).map(Detail::Release).ok_or(GhError::BadResponse),
+ Err(GhError::Forbidden) | Err(GhError::NotFound) => Ok(Detail::Locked { permission: permission::CONTENTS }),
+ Err(e) => Err(e),
+ }
+}
+
+fn parse_release(repo: &str, tag: &str, json: &Value) -> Option {
+ let assets: Vec = json
+ .get("assets")
+ .and_then(Value::as_array)
+ .map(|assets| {
+ assets
+ .iter()
+ .filter_map(|a| {
+ Some(Asset {
+ name: text(a.get("name"))?,
+ downloads: int(a, "/download_count"),
+ size: int(a, "/size"),
+ })
+ })
+ .collect()
+ })
+ .unwrap_or_default();
+ Some(ReleaseDetail {
+ repo: repo.to_string(),
+ tag: tag.to_string(),
+ name: text(json.get("name")).unwrap_or_else(|| tag.to_string()),
+ url: text(json.get("html_url"))?,
+ body: text(json.get("body")).map(|b| excerpt(&plain(&b))),
+ author: text(json.pointer("/author/login")),
+ published_at: text(json.get("published_at")),
+ prerelease: flag(json, "prerelease"),
+ downloads: assets.iter().map(|a| a.downloads).sum(),
+ assets: assets.into_iter().take(MAX_ASSETS).collect(),
+ })
+}
+
+// ── Run of Actions ────────────────────────────────────────────────────────────
+
+async fn run(gh: &Gh, repo: &str, id: u64) -> Result {
+ owner_name(repo)?;
+ // The run says what it was about; its jobs, with their steps, say how long
+ // each part took.
+ let run = match gh.get(&format!("/repos/{repo}/actions/runs/{id}")).await {
+ Ok(reply) => reply.json,
+ // A private repository's runs need Actions; GitHub answers 404 as often
+ // as 403 for what a token may not see.
+ Err(GhError::Forbidden) | Err(GhError::NotFound) => {
+ return Ok(Detail::Locked { permission: permission::ACTIONS })
+ }
+ Err(e) => return Err(e),
+ };
+ let jobs = match gh.get(&format!("/repos/{repo}/actions/runs/{id}/jobs?per_page={MAX_JOBS}")).await {
+ Ok(reply) => reply.json,
+ Err(GhError::Forbidden) | Err(GhError::NotFound) => Value::Null,
+ Err(e) => return Err(e),
+ };
+ parse_run_detail(repo, &run, &jobs).map(Detail::Run).ok_or(GhError::BadResponse)
+}
+
+/// The word for a run, a job or a step, finer than its colour: a cancelled run
+/// and a skipped step are both grey, but not the same news.
+fn outcome(status: &str, conclusion: Option<&str>) -> &'static str {
+ match status {
+ "completed" => match conclusion {
+ Some("success") => "passed",
+ Some("failure") => "failed",
+ Some("timed_out") => "timed out",
+ Some("startup_failure") => "failed to start",
+ Some("cancelled") => "cancelled",
+ Some("skipped") => "skipped",
+ Some("action_required") => "waiting for approval",
+ _ => "stopped",
+ },
+ "queued" | "requested" | "pending" => "queued",
+ "waiting" => "waiting",
+ _ => "running",
+ }
+}
+
+/// (state, outcome) of a run, a job or a step: the colour and the word.
+fn states(node: &Value) -> (&'static str, &'static str) {
+ let status = node.get("status").and_then(Value::as_str).unwrap_or("completed");
+ let conclusion = node.get("conclusion").and_then(Value::as_str);
+ (build_state(status, conclusion), outcome(status, conclusion))
+}
+
+/// The start of something still waiting for its turn. GitHub gives a queued
+/// job a start time already; drawn, it would look like it was running.
+fn start(node: &Value, outcome: &str) -> Option {
+ if matches!(outcome, "queued" | "waiting") {
+ return None;
+ }
+ text(node.get("started_at"))
+}
+
+fn parse_run_detail(repo: &str, run: &Value, jobs: &Value) -> Option {
+ let (state, outcome) = states(run);
+ let list: Vec = jobs
+ .get("jobs")
+ .and_then(Value::as_array)
+ .map(|jobs| jobs.iter().filter_map(parse_job).collect())
+ .unwrap_or_default();
+ let total = jobs.get("total_count").and_then(Value::as_u64).unwrap_or(list.len() as u64);
+ // A finished run ends with its last job. `updated_at` moves on afterwards
+ // (logs expiring, a re-run's bookkeeping), so it is only the fallback.
+ let ended_at = if state == "running" {
+ None
+ } else {
+ list.iter()
+ .filter_map(|j| j.ended_at.clone())
+ .max()
+ .or_else(|| text(run.get("updated_at")))
+ };
+ Some(RunDetail {
+ repo: repo.to_string(),
+ id: run.get("id")?.as_u64()?,
+ workflow: text(run.get("name")).unwrap_or_else(|| WORKFLOW.into()),
+ title: text(run.get("display_title"))
+ .or_else(|| text(run.pointer("/head_commit/message")))
+ .and_then(first_line),
+ branch: text(run.get("head_branch")),
+ event: text(run.get("event")),
+ actor: text(run.pointer("/triggering_actor/login")).or_else(|| text(run.pointer("/actor/login"))),
+ state,
+ outcome,
+ attempt: run.get("run_attempt").and_then(Value::as_u64).unwrap_or(1),
+ url: text(run.get("html_url"))?,
+ started_at: text(run.get("run_started_at")),
+ ended_at,
+ more_jobs: total.saturating_sub(list.len() as u64),
+ jobs: list,
+ })
+}
+
+fn parse_job(job: &Value) -> Option {
+ let (state, outcome) = states(job);
+ let steps = job
+ .get("steps")
+ .and_then(Value::as_array)
+ .map(|steps| steps.iter().filter_map(parse_step).collect())
+ .unwrap_or_default();
+ Some(Job {
+ id: job.get("id")?.as_u64()?,
+ name: text(job.get("name")).unwrap_or_else(|| "Job".into()),
+ state,
+ outcome,
+ url: text(job.get("html_url"))?,
+ runner: job.pointer("/labels/0").and_then(|l| text(Some(l))),
+ started_at: start(job, outcome),
+ ended_at: text(job.get("completed_at")),
+ steps,
+ })
+}
+
+fn parse_step(step: &Value) -> Option {
+ let (state, outcome) = states(step);
+ Some(Step {
+ name: text(step.get("name"))?,
+ state,
+ outcome,
+ started_at: start(step, outcome),
+ ended_at: text(step.get("completed_at")),
+ })
+}
+
+// ── Text ──────────────────────────────────────────────────────────────────────
+
+/// Markdown release notes read as plain text: headings, emphasis, code
+/// marks, quotes and list bullets dropped, a link kept as its words.
+fn plain(markdown: &str) -> String {
+ let mut out = String::with_capacity(markdown.len());
+ for line in markdown.lines() {
+ let line = line
+ .trim_start()
+ .trim_start_matches('#')
+ .trim_start_matches('>')
+ .trim_start();
+ let line = line
+ .strip_prefix("- ")
+ .or_else(|| line.strip_prefix("* "))
+ .unwrap_or(line);
+ let mut rest = line;
+ while let Some(open) = rest.find('[') {
+ out.push_str(&rest[..open]);
+ let after = &rest[open + 1..];
+ match (after.find("]("), after.find(')')) {
+ (Some(close), Some(end)) if close < end => {
+ out.push_str(&after[..close]);
+ rest = &after[end + 1..];
+ }
+ _ => {
+ out.push('[');
+ rest = after;
+ }
+ }
+ }
+ out.push_str(rest);
+ out.push('\n');
+ }
+ out.replace(['*', '`', '_'], "")
+}
+
+/// What somebody wrote, as they wrote it — its lines kept, unlike an excerpt —
+/// with runs of blank lines closed up, cut at a word past MAX_REMARK
+/// characters. Says whether it was cut.
+fn passage(text: &str) -> (String, bool) {
+ let mut out = String::with_capacity(text.len().min(MAX_REMARK * 4));
+ let mut blank = false;
+ for line in text.trim().lines() {
+ let line = line.trim_end();
+ // One blank line parts two paragraphs; more is only air.
+ if line.is_empty() && blank {
+ continue;
+ }
+ blank = line.is_empty();
+ if !out.is_empty() {
+ out.push('\n');
+ }
+ out.push_str(line);
+ }
+ if out.chars().count() <= MAX_REMARK {
+ return (out, false);
+ }
+ let cut: String = out.chars().take(MAX_REMARK).collect();
+ let at_word = cut.rfind(char::is_whitespace).map_or(cut.as_str(), |i| &cut[..i]);
+ (format!("{}…", at_word.trim_end()), true)
+}
+
+/// One paragraph, whitespace collapsed, cut at a word near EXCERPT characters.
+fn excerpt(text: &str) -> String {
+ let flat = text.split_whitespace().collect::>().join(" ");
+ if flat.chars().count() <= EXCERPT {
+ return flat;
+ }
+ let cut: String = flat.chars().take(EXCERPT).collect();
+ let at_word = cut.rfind(' ').map_or(cut.as_str(), |i| &cut[..i]);
+ format!("{at_word}…")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn a_target_travels_as_the_island_sends_it_back() {
+ let push = json!({ "kind": "commits", "repo": "edu/coucou", "head": "abc1234", "count": 3, "branch": "main", "author": null, "from": null, "to": null });
+ let target: Target = serde_json::from_value(push).unwrap();
+ assert_eq!(target, Target::Commits {
+ repo: "edu/coucou".into(), head: Some("abc1234".into()), count: Some(3), branch: Some("main".into()),
+ author: None, from: None, to: None,
+ });
+ let pull = serde_json::to_value(Target::Pull { repo: "edu/coucou".into(), number: 12 }).unwrap();
+ assert_eq!(pull, json!({ "kind": "pull", "repo": "edu/coucou", "number": 12 }));
+ }
+
+ #[test]
+ fn a_pull_request_sheet_reads_every_part() {
+ let node = json!({
+ "number": 12, "title": "Panel", "url": "https://github.com/edu/coucou/pull/12",
+ "state": "MERGED", "isDraft": false, "merged": true, "mergedAt": "2026-09-30T18:00:00Z",
+ "createdAt": "2026-09-28T10:00:00Z", "closedAt": "2026-09-30T18:00:00Z",
+ "additions": 320, "deletions": 40, "changedFiles": 9,
+ "baseRefName": "main", "headRefName": "windows-github-panel", "reviewDecision": "APPROVED",
+ "author": { "login": "edu" }, "mergedBy": { "login": "louis" },
+ "commits": { "totalCount": 5 }, "comments": { "totalCount": 3 }, "reviewThreads": { "totalCount": 2 },
+ "latestReviews": { "nodes": [
+ { "state": "APPROVED", "author": { "login": "louis" } },
+ { "state": "PENDING", "author": { "login": "ghost" } },
+ ]},
+ "labels": { "nodes": [{ "name": "windows", "color": "0e8a16" }] },
+ });
+ let pr = parse_pull("edu/coucou", &node).unwrap();
+ assert_eq!((pr.state, pr.review, pr.merged_by.as_deref()), ("merged", Some("approved"), Some("louis")));
+ assert_eq!((pr.base.as_deref(), pr.head.as_deref()), (Some("main"), Some("windows-github-panel")));
+ assert_eq!((pr.commits, pr.comments, pr.threads, pr.changed_files), (5, 3, 2, 9));
+ assert_eq!(pr.reviewers, [Review { login: "louis".into(), state: "approved" }]);
+ assert_eq!(pr.labels, [Label { name: "windows".into(), color: "#0e8a16".into() }]);
+ }
+
+ #[test]
+ fn files_carry_their_diff_and_a_huge_one_is_cut_on_a_line() {
+ let list = json!([
+ { "filename": "a.rs", "status": "modified", "additions": 1, "deletions": 1, "patch": "@@ -1 +1 @@\n-old\n+new" },
+ { "filename": "logo.png", "status": "added", "additions": 0, "deletions": 0 },
+ ]);
+ let f = files(Some(&list));
+ assert_eq!((f[0].path.as_str(), f[0].status.as_deref(), f[0].truncated), ("a.rs", Some("modified"), false));
+ assert_eq!(f[0].patch.as_deref(), Some("@@ -1 +1 @@\n-old\n+new"));
+ assert_eq!(f[1].patch, None);
+
+ let huge = "+line\n".repeat(MAX_PATCH);
+ let (cut, truncated) = cut_patch(huge);
+ let cut = cut.unwrap();
+ assert!(truncated && cut.len() <= MAX_PATCH && cut.ends_with("+line"));
+ }
+
+ #[test]
+ fn an_issue_says_how_it_was_closed() {
+ let issue = |state: &str, reason: Value| json!({
+ "number": 4, "title": "Crash", "url": "https://github.com/edu/coucou/issues/4",
+ "state": state, "stateReason": reason,
+ });
+ assert_eq!(parse_issue("r/r", &issue("OPEN", Value::Null)).unwrap().state, "open");
+ assert_eq!(parse_issue("r/r", &issue("CLOSED", json!("COMPLETED"))).unwrap().state, "completed");
+ assert_eq!(parse_issue("r/r", &issue("CLOSED", json!("NOT_PLANNED"))).unwrap().state, "not planned");
+ assert_eq!(parse_issue("r/r", &issue("CLOSED", Value::Null)).unwrap().state, "closed");
+ }
+
+ #[test]
+ fn commit_lines_keep_the_first_line_and_a_short_sha() {
+ let list = json!([{
+ "sha": "abcdef1234567890", "html_url": "https://github.com/edu/coucou/commit/abcdef1",
+ "author": { "login": "edu" },
+ "commit": { "message": "Fix the hook\n\nBecause.", "author": { "name": "Edu", "date": "2026-09-30T10:00:00Z" } },
+ }, {
+ "sha": "1234567aaaa", "author": null,
+ "commit": { "message": "Anonymous", "author": { "name": "Someone", "date": "2026-09-30T09:00:00Z" } },
+ }]);
+ let lines = parse_commit_lines(&list);
+ assert_eq!((lines[0].sha.as_str(), lines[0].message.as_str(), lines[0].author.as_deref()), ("abcdef1", "Fix the hook", Some("edu")));
+ assert_eq!(lines[0].id, "abcdef1234567890");
+ assert_eq!(lines[1].author.as_deref(), Some("Someone"));
+ }
+
+ #[test]
+ fn a_release_adds_its_downloads() {
+ let json = json!({
+ "name": "", "html_url": "https://github.com/edu/coucou/releases/tag/v1", "prerelease": true,
+ "body": "## What's new\n- **Faster** [panel](https://x.y)\n",
+ "author": { "login": "edu" }, "published_at": "2026-09-30T10:00:00Z",
+ "assets": [{ "name": "a.exe", "download_count": 40, "size": 1000 }, { "name": "b.zip", "download_count": 2, "size": 10 }],
+ });
+ let r = parse_release("edu/coucou", "v1", &json).unwrap();
+ assert_eq!((r.name.as_str(), r.downloads, r.prerelease), ("v1", 42, true));
+ assert_eq!(r.body.as_deref(), Some("What's new Faster panel"));
+ }
+
+ #[test]
+ fn only_plain_values_reach_a_url() {
+ assert!(is_sha("abc1234") && is_sha("0123456789abcdef0123456789abcdef01234567"));
+ assert!(!is_sha("abc") && !is_sha("abc123z") && !is_sha("abc1234&x=1"));
+ assert!(is_login("louis-cfm") && !is_login("a b") && !is_login("x&y"));
+ assert!(is_tag("v0.1.1") && is_tag("windows/v1") && !is_tag("v1?x") && !is_tag("../x y"));
+ assert_eq!(query_time("2026-09-30T00:00:00+02:00"), "2026-09-30T00:00:00%2B02:00");
+ }
+
+ #[test]
+ fn a_run_carries_each_job_and_step_with_its_times() {
+ let run = json!({
+ "id": 77, "name": "CI", "display_title": "Fix the hook\n\nBody", "head_branch": "main",
+ "event": "push", "status": "completed", "conclusion": "failure", "run_attempt": 2,
+ "html_url": "https://github.com/edu/coucou/actions/runs/77",
+ "run_started_at": "2026-09-30T10:00:00Z", "updated_at": "2026-09-30T11:30:00Z",
+ "actor": { "login": "edu" },
+ });
+ let jobs = json!({ "total_count": 3, "jobs": [
+ {
+ "id": 1, "name": "build", "status": "completed", "conclusion": "success",
+ "html_url": "https://github.com/edu/coucou/actions/runs/77/job/1", "labels": ["ubuntu-latest"],
+ "started_at": "2026-09-30T10:00:05Z", "completed_at": "2026-09-30T10:02:00Z",
+ "steps": [
+ { "name": "Set up job", "status": "completed", "conclusion": "success",
+ "started_at": "2026-09-30T10:00:05Z", "completed_at": "2026-09-30T10:00:08Z" },
+ { "name": "Lint", "status": "completed", "conclusion": "skipped",
+ "started_at": null, "completed_at": null },
+ ],
+ },
+ {
+ "id": 2, "name": "test", "status": "completed", "conclusion": "failure",
+ "html_url": "https://github.com/edu/coucou/actions/runs/77/job/2",
+ "started_at": "2026-09-30T10:02:03Z", "completed_at": "2026-09-30T10:04:40Z", "steps": [],
+ },
+ ]});
+ let r = parse_run_detail("edu/coucou", &run, &jobs).unwrap();
+ assert_eq!((r.state, r.outcome, r.attempt, r.more_jobs), ("failure", "failed", 2, 1));
+ assert_eq!((r.title.as_deref(), r.actor.as_deref()), (Some("Fix the hook"), Some("edu")));
+ // The run ends with its last job, not when GitHub last touched it.
+ assert_eq!(r.ended_at.as_deref(), Some("2026-09-30T10:04:40Z"));
+ assert_eq!(r.jobs[0].runner.as_deref(), Some("ubuntu-latest"));
+ assert_eq!((r.jobs[0].steps[1].state, r.jobs[0].steps[1].outcome), ("neutral", "skipped"));
+ assert_eq!((r.jobs[1].state, r.jobs[1].runner.as_deref()), ("failure", None));
+ }
+
+ #[test]
+ fn a_run_still_going_has_no_end_and_a_queued_job_no_start() {
+ let run = json!({
+ "id": 78, "name": "CI", "status": "in_progress", "conclusion": null,
+ "html_url": "https://github.com/edu/coucou/actions/runs/78",
+ "run_started_at": "2026-09-30T10:00:00Z", "updated_at": "2026-09-30T10:01:00Z",
+ });
+ let jobs = json!({ "total_count": 1, "jobs": [{
+ "id": 3, "name": "deploy", "status": "queued", "conclusion": null,
+ "html_url": "https://github.com/edu/coucou/actions/runs/78/job/3",
+ "started_at": "2026-09-30T10:00:30Z", "completed_at": null,
+ }]});
+ let r = parse_run_detail("edu/coucou", &run, &jobs).unwrap();
+ assert_eq!((r.state, r.outcome, r.ended_at.as_deref()), ("running", "running", None));
+ assert_eq!((r.jobs[0].outcome, r.jobs[0].started_at.as_deref()), ("queued", None));
+ assert_eq!(ttl(&Detail::Run(r)), LIVE_TTL);
+
+ // The jobs refused: the run still says what it was.
+ let bare = parse_run_detail("edu/coucou", &run, &Value::Null).unwrap();
+ assert!(bare.jobs.is_empty() && bare.more_jobs == 0);
+ }
+
+ #[test]
+ fn a_run_target_travels_by_its_id() {
+ let target: Target = serde_json::from_value(json!({ "kind": "run", "repo": "edu/coucou", "id": 77 })).unwrap();
+ assert_eq!(target, Target::Run { repo: "edu/coucou".into(), id: 77 });
+ }
+
+ /// A pull request as COMMENTS_QUERY gets it back.
+ fn talked_over() -> Value {
+ let said = |who: &str, at: &str, body: &str| json!({
+ "url": format!("https://github.com/edu/coucou/pull/12#{at}"), "bodyText": body,
+ "createdAt": at, "submittedAt": at, "author": { "login": who },
+ });
+ let mut approval = said("louis", "2026-09-30T12:00:00Z", "");
+ approval["state"] = json!("APPROVED");
+ let mut envelope = said("louis", "2026-09-30T10:30:00Z", "");
+ envelope["state"] = json!("COMMENTED");
+ let mut pending = said("edu", "2026-09-30T13:00:00Z", "Not sent yet");
+ pending["state"] = json!("PENDING");
+ json!({
+ "number": 12, "title": "Panel", "url": "https://github.com/edu/coucou/pull/12",
+ "bodyText": "Adds the panel.\n\n\n\nCloses #4. ", "createdAt": "2026-09-30T09:00:00Z",
+ "author": { "login": "edu" },
+ "comments": { "totalCount": 31, "nodes": [
+ said("edu", "2026-09-30T11:00:00Z", "Fixed in the last commit."),
+ { "url": "https://github.com/edu/coucou/pull/12#ghost", "bodyText": "Nice.", "createdAt": "2026-09-30T08:00:00Z", "author": null },
+ ]},
+ "reviews": { "totalCount": 3, "nodes": [approval, envelope, pending] },
+ "reviewThreads": { "totalCount": 1, "nodes": [{
+ "isResolved": true, "isOutdated": false, "path": "windows/src/views/github.ts", "line": 14, "diffSide": "RIGHT",
+ "hunk": { "nodes": [{ "diffHunk": "@@ -10,4 +10,5 @@ function x() {\n a\n-b\n+c\n+d\n e" }] },
+ "comments": { "totalCount": 12, "nodes": [
+ said("louis", "2026-09-30T10:30:00Z", "Why twice?"),
+ said("edu", "2026-09-30T10:45:00Z", "Good catch."),
+ ]},
+ }]},
+ })
+ }
+
+ #[test]
+ fn a_conversation_reads_oldest_first_after_the_description() {
+ let c = parse_comments("edu/coucou", &talked_over()).unwrap();
+ let kinds: Vec<&str> = c.entries.iter().map(|e| match e {
+ Entry::Description(_) => "description",
+ Entry::Comment(_) => "comment",
+ Entry::Review(_) => "review",
+ Entry::Thread(_) => "thread",
+ }).collect();
+ // The empty "commented" review and the pending one are not said to anyone.
+ assert_eq!(kinds, ["description", "comment", "thread", "comment", "review"]);
+ assert!(c.earlier);
+ let Entry::Description(d) = &c.entries[0] else { panic!() };
+ assert_eq!((d.author.as_deref(), d.body.as_str(), d.cut), (Some("edu"), "Adds the panel.\n\nCloses #4.", false));
+ // An account that is gone still said what it said.
+ let Entry::Comment(ghost) = &c.entries[1] else { panic!() };
+ assert_eq!((ghost.author.as_deref(), ghost.body.as_str()), (None, "Nice."));
+ let Entry::Review(v) = &c.entries[4] else { panic!() };
+ assert_eq!((v.state, v.remark.body.as_str()), ("approved", ""));
+ }
+
+ #[test]
+ fn a_thread_carries_its_place_its_code_and_its_replies() {
+ let c = parse_comments("edu/coucou", &talked_over()).unwrap();
+ let Entry::Thread(t) = &c.entries[2] else { panic!() };
+ assert_eq!((t.path.as_str(), t.line, t.side, t.resolved, t.outdated), ("windows/src/views/github.ts", Some(14), "right", true, false));
+ assert_eq!((t.remarks.len(), t.more), (2, 10));
+ // The end of the hunk, numbered as in the file: old numbers for what went.
+ let code: Vec<(Option, &str, &str)> = t.code.iter().map(|l| (l.number, l.sign, l.text.as_str())).collect();
+ assert_eq!(code, [(Some(11), "-", "b"), (Some(11), "+", "c"), (Some(12), "+", "d"), (Some(13), "", "e")]);
+ }
+
+ #[test]
+ fn an_entry_travels_flat_under_its_kind() {
+ let c = parse_comments("edu/coucou", &talked_over()).unwrap();
+ let review = serde_json::to_value(&c.entries[4]).unwrap();
+ assert_eq!((review["kind"].as_str(), review["state"].as_str(), review["author"].as_str()), (Some("review"), Some("approved"), Some("louis")));
+ let thread = serde_json::to_value(&c.entries[2]).unwrap();
+ assert_eq!((thread["kind"].as_str(), thread["code"][0]["sign"].as_str()), (Some("thread"), Some("-")));
+ let target: Target = serde_json::from_value(json!({ "kind": "comments", "repo": "edu/coucou", "number": 12 })).unwrap();
+ assert_eq!(target, Target::Comments { repo: "edu/coucou".into(), number: 12 });
+ }
+
+ #[test]
+ fn a_long_comment_keeps_its_lines_and_is_cut_at_a_word() {
+ let (short, cut) = passage(" One.\r\n\r\n\r\nTwo. \n");
+ assert_eq!((short.as_str(), cut), ("One.\n\nTwo.", false));
+ let (long, cut) = passage(&"word ".repeat(1000));
+ assert!(cut && long.ends_with("word…") && long.chars().count() <= MAX_REMARK + 1);
+ // A hunk whose header is missing still shows its lines, unnumbered.
+ assert_eq!(hunk_tail("+x\n\\ No newline at end of file"), [CodeLine { number: None, sign: "+", text: "x".into() }]);
+ }
+
+ #[test]
+ fn long_text_is_cut_at_a_word() {
+ let long = "word ".repeat(200);
+ let cut = excerpt(&long);
+ assert!(cut.ends_with('…'));
+ assert!(cut.chars().count() <= EXCERPT + 1);
+ assert!(!cut.contains(" "));
+ }
+}
diff --git a/windows/src-tauri/src/integrations.rs b/windows/src-tauri/src/integrations.rs
index d666b99779..7b3118086c 100644
--- a/windows/src-tauri/src/integrations.rs
+++ b/windows/src-tauri/src/integrations.rs
@@ -5,6 +5,9 @@
// one emits an `integration` event; the island owns the badge, the sound and the
// 60 s auto-clear, exactly as the Swift handlers do.
//
+// GitHub outgrew this file: its panel lives in github.rs, scheduled from here
+// like the others.
+//
// Nothing is polled until its key exists in the Credential Manager, and no
// request goes anywhere the user has not configured.
@@ -39,9 +42,13 @@ pub struct IntegrationEvent {
pub success: bool,
pub label: String,
pub detail: Option,
+ /// What the island can open for it, when the integration has a screen of
+ /// its own for the thing the event is about (GitHub: a run, a pull request).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub open: Option,
}
-fn emit(app: &AppHandle, update: IntegrationUpdate) {
+pub(crate) fn emit(app: &AppHandle, update: IntegrationUpdate) {
let _ = app.emit_to(WINDOW_LABEL, "integration", update);
}
@@ -66,13 +73,14 @@ pub fn start(app: AppHandle) {
spawn(app.clone(), "integration_vercel", 5, 30, poll_vercel);
spawn(app.clone(), "integration_stripe", 6, 30, poll_stripe);
spawn(app.clone(), "integration_resend", 6, 60, poll_resend);
- spawn(app.clone(), "integration_github", 7, 300, poll_github);
+ spawn(app.clone(), "integration_github", 7, crate::github::TICK_SECS, crate::github::refresh);
+ crate::github::watch_live(app.clone());
spawn(app.clone(), "integration_calcom", 8, 300, poll_calcom);
spawn(app, "integration_notion", 9, 300, poll_notion);
}
/// True when the user has this integration switched on in settings.
-fn enabled(app: &AppHandle, id: &str) -> bool {
+pub(crate) fn enabled(app: &AppHandle, id: &str) -> bool {
app.try_state::()
.map(|shared| {
let settings = shared.settings.lock().unwrap();
@@ -107,7 +115,7 @@ where
pub async fn poll_once(app: AppHandle, id: &str) {
match id {
"integration_stripe" => poll_stripe(app).await,
- "integration_github" => poll_github(app).await,
+ "integration_github" => crate::github::refresh(app).await,
"integration_vercel" => poll_vercel(app).await,
"integration_n8n" => poll_n8n(app).await,
"integration_resend" => poll_resend(app).await,
@@ -249,7 +257,7 @@ async fn poll_stripe(app: AppHandle) {
let cents = payments[0].get("amount").and_then(Value::as_i64).unwrap_or(0);
format!("{:.2}", cents as f64 / 100.0)
});
- Some(IntegrationEvent { success: true, label, detail: None })
+ Some(IntegrationEvent { success: true, label, detail: None, open: None })
} else {
None
};
@@ -262,67 +270,6 @@ async fn poll_stripe(app: AppHandle) {
});
}
-// ── GitHub ────────────────────────────────────────────────────────────────────
-
-async fn poll_github(app: AppHandle) {
- let Some(token) = secrets::get("github-token") else { return };
- let http = client();
-
- let user = http
- .get("https://api.github.com/user")
- .header("Authorization", format!("Bearer {token}"))
- .header("Accept", "application/vnd.github+json")
- .header("User-Agent", "Coucou")
- .send()
- .await;
- let Ok(response) = user else { return };
- if !response.status().is_success() {
- emit(&app, IntegrationUpdate {
- id: "integration_github",
- data: json!({}),
- error: Some(status_error(response.status().as_u16(), "Token lacks the needed scope")),
- event: None,
- });
- return;
- }
- let json: Value = response.json().await.unwrap_or(json!({}));
- let public = json.get("public_repos").and_then(Value::as_i64).unwrap_or(0);
- let private = json
- .get("owned_private_repos")
- .or_else(|| json.get("total_private_repos"))
- .and_then(Value::as_i64)
- .unwrap_or(0);
-
- let repos = http
- .get("https://api.github.com/user/repos?per_page=100&affiliation=owner&sort=pushed")
- .header("Authorization", format!("Bearer {token}"))
- .header("Accept", "application/vnd.github+json")
- .header("User-Agent", "Coucou")
- .send()
- .await;
- let stars: i64 = match repos {
- Ok(r) if r.status().is_success() => r
- .json::()
- .await
- .ok()
- .and_then(|v| v.as_array().cloned())
- .map(|list| {
- list.iter()
- .filter_map(|r| r.get("stargazers_count").and_then(Value::as_i64))
- .sum()
- })
- .unwrap_or(0),
- _ => 0,
- };
-
- emit(&app, IntegrationUpdate {
- id: "integration_github",
- data: json!({ "totalRepos": public + private, "totalStars": stars }),
- error: None,
- event: None,
- });
-}
-
// ── Vercel ────────────────────────────────────────────────────────────────────
async fn poll_vercel(app: AppHandle) {
@@ -384,6 +331,7 @@ async fn poll_vercel(app: AppHandle) {
success,
label: latest.get("projectName")?.as_str()?.to_string(),
detail: None,
+ open: None,
})
});
@@ -691,7 +639,7 @@ async fn poll_n8n(app: AppHandle) {
id: "integration_n8n",
data: json!({ "workflow": name, "status": status }),
error: None,
- event: Some(IntegrationEvent { success, label: name, detail }),
+ event: Some(IntegrationEvent { success, label: name, detail, open: None }),
});
}
diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs
index 714103e59e..b2caaf2eaa 100644
--- a/windows/src-tauri/src/lib.rs
+++ b/windows/src-tauri/src/lib.rs
@@ -2,6 +2,8 @@
mod claude;
mod files;
+mod github;
+mod github_detail;
mod hooks;
mod integrations;
mod island;
@@ -263,13 +265,56 @@ fn secret_present(key: String) -> bool {
}
#[tauri::command]
-fn secret_set(key: String, value: String) -> Result<(), String> {
- secrets::set(&key, &value)
+fn secret_set(app: AppHandle, key: String, value: String) -> Result<(), String> {
+ secrets::set(&key, &value)?;
+ secrets_changed(&app, &key);
+ Ok(())
}
#[tauri::command]
-fn secret_clear(key: String) -> Result<(), String> {
- secrets::clear(&key)
+fn secret_clear(app: AppHandle, key: String) -> Result<(), String> {
+ secrets::clear(&key)?;
+ secrets_changed(&app, &key);
+ Ok(())
+}
+
+/// The island only learns which keys exist by asking, and used to ask once at
+/// launch: a key saved in the settings window left its pill saying "Key not
+/// configured" until a restart. This tells it to ask again. A new GitHub token
+/// may be another account's: what the old one fetched is forgotten first.
+fn secrets_changed(app: &AppHandle, key: &str) {
+ if key == github::TOKEN_KEY {
+ github::forget();
+ }
+ let _ = app.emit_to(island::WINDOW_LABEL, "secrets-changed", ());
+}
+
+/// Settings → GitHub → Test connection. Runs on the stored token and brings back
+/// the account and what the token can reach — never the token itself.
+#[tauri::command]
+async fn github_test() -> Result {
+ github::test().await
+}
+
+/// A click on a project in the GitHub panel: its CI, last pull request and last
+/// deployment. On demand only, cached a minute; `force` is the ↻ button.
+#[tauri::command]
+async fn github_project(full_name: String, force: bool) -> Result {
+ github::project(&full_name, force).await
+}
+
+/// A click on a day of the contribution graph: what was done that day, between
+/// the island's local midnights. On demand only, cached.
+#[tauri::command]
+async fn github_day(from: String, to: String, today: bool) -> Result {
+ github::day(&from, &to, today).await
+}
+
+/// A click on a line of GitHub activity: the pull request, issue, commits or
+/// release behind it, with the files' diffs. On demand only, cached a minute.
+#[tauri::command]
+async fn github_detail(target: github_detail::Target, force: bool) -> Result {
+ github_detail::detail(target, force).await
}
/// Opens the configured n8n instance — the URL lives in the Credential Manager.
@@ -397,6 +442,10 @@ pub fn run() {
secret_present,
secret_set,
secret_clear,
+ github_test,
+ github_project,
+ github_day,
+ github_detail,
refresh_integration,
open_n8n,
open_settings_window,
diff --git a/windows/src-tauri/src/settings.rs b/windows/src-tauri/src/settings.rs
index 2a8d7d359c..ae1dc19db2 100644
--- a/windows/src-tauri/src/settings.rs
+++ b/windows/src-tauri/src/settings.rs
@@ -20,6 +20,10 @@ pub struct Settings {
/// Defaulted explicitly so a settings.json written by an older build still loads.
#[serde(default = "default_model")]
pub model: String,
+ /// GitHub projects ("owner/name") whose news the pill keeps to itself: a
+ /// project not listed here speaks up, so a new one does by default.
+ #[serde(default)]
+ pub github_muted: Vec,
}
fn default_model() -> String {
@@ -43,6 +47,7 @@ impl Default for Settings {
autostart: false,
hooks_installed: false,
model: default_model(),
+ github_muted: Vec::new(),
}
}
}
diff --git a/windows/src/core/anim.ts b/windows/src/core/anim.ts
index 74e4b6c0d0..ec52a9cde1 100644
--- a/windows/src/core/anim.ts
+++ b/windows/src/core/anim.ts
@@ -21,6 +21,46 @@ export const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
export const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
export const seg = (t: number, a: number, b: number) => clamp((t - a) / (b - a), 0, 1);
+// ── Colour blending ───────────────────────────────────────────────────────────
+// Mixing two colours channel by channel in sRGB dips through a dark, muddy
+// middle — GitHub red to green passes through brown. OKLab is built so equal
+// steps look equal, which keeps a colour change bright and even throughout.
+
+type Triple = readonly [number, number, number];
+
+const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
+const toGamma = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
+
+function rgbToOklab([r, g, b]: Triple): Triple {
+ const [lr, lg, lb] = [toLinear(r), toLinear(g), toLinear(b)];
+ const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
+ const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
+ const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
+ return [
+ 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
+ 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
+ 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
+ ];
+}
+
+function oklabToRgb([L, a, b]: Triple): [number, number, number] {
+ const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
+ const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
+ const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
+ return [
+ clamp(toGamma(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), 0, 1),
+ clamp(toGamma(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s), 0, 1),
+ clamp(toGamma(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s), 0, 1),
+ ];
+}
+
+/** a → b by t, blended in OKLab. Components 0…1, like the engine's RGB. */
+export function mixColor(a: Triple, b: Triple, t: number): [number, number, number] {
+ const [la, aa, ba] = rgbToOklab(a);
+ const [lb, ab, bb] = rgbToOklab(b);
+ return oklabToRgb([lerp(la, lb, t), lerp(aa, ab, t), lerp(ba, bb, t)]);
+}
+
/** cubic-bezier(x1,y1,x2,y2) — used for the 340 ms close curve (.45,0,.2,1). */
export function cubicBezier(x1: number, y1: number, x2: number, y2: number): EaseFn {
const cx = (t: number) => ((1 - t) ** 2 * 3 * t * x1) + (3 * (1 - t) * t * t * x2) + t ** 3;
diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts
index 24aaf55ff9..c92ee5dace 100644
--- a/windows/src/core/bridge.ts
+++ b/windows/src/core/bridge.ts
@@ -99,13 +99,429 @@ export const Bridge = {
/** Tray → Pause. Stops the integration pollers, not just the island. */
setPaused: (paused: boolean) => call("set_paused", { paused }),
+
+ // ── GitHub ────────────────────────────────────────────────────────────────
+ /** Settings → Test connection. Runs on the stored token; the token never comes back. */
+ githubTest: () => callOrThrow("github_test"),
+ /** A project's sheet, fetched on the click. `force` skips the minute of cache. */
+ githubProject: (fullName: string, force: boolean) =>
+ callOrThrow("github_project", { fullName, force }),
+ /** What was done on one day of the graph, between two local midnights (ISO). */
+ githubDay: (from: string, to: string, today: boolean) =>
+ callOrThrow("github_day", { from, to, today }),
+ /** The sheet behind a line of activity. `target` goes back exactly as Rust sent it. */
+ githubDetail: (target: GithubTarget, force: boolean) =>
+ callOrThrow("github_detail", { target, force }),
};
+/** What a line of activity leads to — the Target enum in github_detail.rs. */
+export type GithubTarget =
+ | { kind: "pull"; repo: string; number: number }
+ | { kind: "issue"; repo: string; number: number }
+ | {
+ kind: "commits"; repo: string; head: string | null; count: number | null;
+ branch: string | null; author: string | null; from: string | null; to: string | null;
+ }
+ | { kind: "release"; repo: string; tag: string }
+ | { kind: "project"; repo: string }
+ | { kind: "run"; repo: string; id: number }
+ | { kind: "comments"; repo: string; number: number };
+
+export interface GithubFile {
+ path: string;
+ /** added, modified, removed, renamed… */
+ status: string | null;
+ additions: number;
+ deletions: number;
+ /** The unified diff; null for a binary file or one GitHub won't diff. */
+ patch: string | null;
+ /** The patch was cut short; the rest is on GitHub. */
+ truncated: boolean;
+}
+
+export interface GithubLabel {
+ name: string;
+ /** "#rrggbb". */
+ color: string;
+}
+
+export interface GithubPullDetail {
+ kind: "pull";
+ repo: string;
+ number: number;
+ title: string;
+ url: string;
+ state: "open" | "draft" | "merged" | "closed";
+ author: string | null;
+ base: string | null;
+ head: string | null;
+ additions: number;
+ deletions: number;
+ changedFiles: number;
+ commits: number;
+ comments: number;
+ /** Threads of comments on the lines of code. */
+ threads: number;
+ review: "approved" | "changes requested" | "review required" | null;
+ reviewers: { login: string; state: "approved" | "changes requested" | "commented" | "dismissed" }[];
+ labels: GithubLabel[];
+ files: GithubFile[];
+ createdAt: string | null;
+ mergedAt: string | null;
+ mergedBy: string | null;
+ closedAt: string | null;
+ ci: GithubBuild | null;
+ missing: string[];
+}
+
+export interface GithubIssueDetail {
+ kind: "issue";
+ repo: string;
+ number: number;
+ title: string;
+ url: string;
+ state: "open" | "completed" | "not planned" | "closed";
+ author: string | null;
+ body: string | null;
+ labels: GithubLabel[];
+ assignees: string[];
+ comments: number;
+ createdAt: string | null;
+ closedAt: string | null;
+}
+
+export interface GithubCommitsDetail {
+ kind: "commits";
+ repo: string;
+ branch: string | null;
+ /** How many the push or the day counted; `commits` may hold fewer. */
+ total: number | null;
+ /** Newest first. */
+ commits: { sha: string; id: string; message: string; author: string | null; at: string | null; url: string }[];
+ /** What the newest commit changed. */
+ additions: number | null;
+ deletions: number | null;
+ files: GithubFile[];
+ ci: GithubBuild | null;
+ url: string;
+ missing: string[];
+}
+
+export interface GithubReleaseDetail {
+ kind: "release";
+ repo: string;
+ tag: string;
+ name: string;
+ url: string;
+ body: string | null;
+ author: string | null;
+ publishedAt: string | null;
+ prerelease: boolean;
+ assets: { name: string; downloads: number; size: number }[];
+ downloads: number;
+}
+
+/**
+ * A step of a job, a job of a run, a run: its colour (`state`), its word
+ * (`outcome`: passed, failed, running, queued, cancelled, skipped…), and when
+ * it started and ended — how long it took is worked out from those.
+ */
+export interface GithubTimed {
+ state: GithubBuild["state"];
+ outcome: string;
+ /** Null while it waits for its turn. */
+ startedAt: string | null;
+ /** Null while it runs. */
+ endedAt: string | null;
+}
+
+export interface GithubStep extends GithubTimed {
+ name: string;
+}
+
+export interface GithubJob extends GithubTimed {
+ id: number;
+ name: string;
+ /** The job's page on GitHub, with its logs. */
+ url: string;
+ /** The machine it asked for, e.g. "ubuntu-latest". */
+ runner: string | null;
+ steps: GithubStep[];
+}
+
+export interface GithubRunDetail extends GithubTimed {
+ kind: "run";
+ repo: string;
+ id: number;
+ workflow: string;
+ title: string | null;
+ branch: string | null;
+ /** push, pull_request, schedule, workflow_dispatch… */
+ event: string | null;
+ actor: string | null;
+ /** 2 and up for a re-run. */
+ attempt: number;
+ url: string;
+ jobs: GithubJob[];
+ /** Jobs of the run beyond the ones carried. */
+ moreJobs: number;
+}
+
+/** Something somebody wrote on a pull request. */
+export interface GithubRemark {
+ /** Null for an account that is gone. */
+ author: string | null;
+ /** Plain text, its lines kept; empty for a review that only gave a verdict. */
+ body: string;
+ /** The text was cut short; the whole of it is at `url`. */
+ cut: boolean;
+ at: string | null;
+ url: string;
+}
+
+/** Comments on one place in the code, and the replies under them. */
+export interface GithubThread {
+ path: string;
+ /** Null on a whole file, or once the code under it has changed. */
+ line: number | null;
+ /** "left" on a line that was removed. */
+ side: "left" | "right";
+ resolved: boolean;
+ outdated: boolean;
+ /** The lines it is about, numbered as in the file. */
+ code: { number: number | null; sign: "+" | "-" | ""; text: string }[];
+ remarks: GithubRemark[];
+ /** Replies beyond the ones carried. */
+ more: number;
+}
+
+export type GithubEntry =
+ | ({ kind: "description" | "comment" } & GithubRemark)
+ | ({ kind: "review"; state: "approved" | "changes requested" | "commented" | "dismissed" } & GithubRemark)
+ | ({ kind: "thread" } & GithubThread);
+
+/** What was said on a pull request, oldest first. To read only. */
+export interface GithubCommentsDetail {
+ kind: "comments";
+ repo: string;
+ number: number;
+ title: string;
+ url: string;
+ entries: GithubEntry[];
+ /** Older ones than these are on GitHub. */
+ earlier: boolean;
+}
+
+/** A line's sheet, or the permission the token lacks to read it. */
+export type GithubDetail =
+ | GithubPullDetail
+ | GithubIssueDetail
+ | GithubCommitsDetail
+ | GithubReleaseDetail
+ | GithubRunDetail
+ | GithubCommentsDetail
+ | { kind: "locked"; permission: string };
+
+/** A clicked day of the contribution graph — the Day struct in github.rs. */
+export interface GithubDay {
+ items: {
+ kind: GithubActivityKind | "review";
+ /** "owner/name". */
+ repo: string;
+ title: string;
+ detail: string | null;
+ url: string;
+ target: GithubTarget | null;
+ }[];
+ /** Contributions that day in repositories the token can't see into. */
+ privateCount: number;
+}
+
+/** The sheet behind a click on a project — the Project struct in github.rs. */
+export interface GithubProject {
+ fullName: string;
+ url: string;
+ description: string | null;
+ homepage: string | null;
+ private: boolean;
+ createdAt: string | null;
+ stars: number;
+ forks: number;
+ /** Largest first, the tail folded into "Other" (color null). */
+ languages: { name: string; color: string | null; share: number }[];
+ /** Newest first, up to eight — the CI streak. */
+ runs: GithubRun[];
+ pull: GithubPull | null;
+ deploy: GithubDeploy | null;
+ /** The permissions the token lacks to read some of this, as GitHub names them. */
+ missing: string[];
+}
+
+export interface GithubRun {
+ id: number;
+ state: GithubBuild["state"];
+ workflow: string;
+ branch: string | null;
+ /** The commit or pull request the run is about. */
+ title: string | null;
+ actor: string | null;
+ url: string;
+ startedAt: string | null;
+ updatedAt: string;
+}
+
+export interface GithubPull {
+ number: number;
+ title: string;
+ url: string;
+ state: "open" | "draft" | "merged" | "closed";
+ author: string | null;
+ additions: number;
+ deletions: number;
+ changedFiles: number;
+ review: "approved" | "changes requested" | "review required" | null;
+ comments: number;
+ /** Merged at, or last update. */
+ at: string;
+}
+
+export interface GithubDeploy {
+ environment: string;
+ state: "success" | "failure" | "running" | "inactive";
+ url: string | null;
+ creator: string | null;
+ sha: string | null;
+ at: string;
+}
+
+/** `State.integrations.integration_github.data` — the Snapshot in github.rs. */
+export interface GithubData {
+ login: string;
+ profileUrl: string;
+ totalStars: number;
+ /** Newest first. */
+ activity: GithubActivity[];
+ /** Most recently pushed first. */
+ repos: GithubRepo[];
+ /** The year behind the contribution graph. */
+ contributions: GithubContributions | null;
+ /** The open pull requests waiting for your review, most recently updated first. */
+ toReview: GithubReviewRequest[];
+ /** Unix ms of the last complete refresh. */
+ fetchedAt: number;
+}
+
+export interface GithubReviewRequest {
+ /** "owner/name". */
+ repo: string;
+ number: number;
+ title: string;
+ url: string;
+ author: string | null;
+ draft: boolean;
+ /** When it last changed, ISO 8601. */
+ at: string;
+ target: GithubTarget;
+}
+
+export interface GithubContributions {
+ total: number;
+ /** First day, "YYYY-MM-DD"; one entry per day from there, today last. */
+ start: string;
+ counts: number[];
+ /** GitHub's quartiles: 0 (none) to 4 (busiest). */
+ levels: number[];
+}
+
+export interface GithubRepo {
+ /** "owner/name". */
+ fullName: string;
+ url: string;
+ private: boolean;
+ language: string | null;
+ /** GitHub's colour for the language. */
+ languageColor: string | null;
+ stars: number;
+ openPrs: number;
+ /** ISO 8601. */
+ pushedAt: string | null;
+ /** The newest Actions run on any branch; null when there is none to read. */
+ build: GithubBuild | null;
+}
+
+export interface GithubBuild {
+ id: number;
+ state: "success" | "failure" | "running" | "neutral";
+ /** The workflow's name, e.g. "CI". */
+ workflow: string;
+ branch: string | null;
+ url: string;
+ /** ISO 8601. */
+ at: string;
+}
+
+export type GithubActivityKind =
+ | "push" | "pr_opened" | "pr_merged" | "pr_closed"
+ | "issue_opened" | "issue_closed" | "release" | "create";
+
+export interface GithubActivity {
+ kind: GithubActivityKind;
+ /** "owner/name". */
+ repo: string;
+ title: string;
+ /** "#12", a branch, a tag. */
+ detail: string | null;
+ url: string;
+ /** ISO 8601. */
+ at: string;
+ /** The sheet a click opens in the panel; null: straight to GitHub. */
+ target: GithubTarget | null;
+}
+
+export interface GithubAccount {
+ login: string;
+ name: string | null;
+ /** GitHub's `github-authentication-token-expiration` header, as sent. Null: no expiry. */
+ expiresAt: string | null;
+ /** One line per thing the panel needs, so a missing permission is named. */
+ checks: { label: string; ok: boolean; note: string | null }[];
+}
+
+/**
+ * One thing a news card says under its title, with what it is, so it is drawn
+ * as that: a project, who did it (`verb`: "merged by"), a size in lines, a
+ * count of files, a branch, the step a build broke at, a commit's title.
+ */
+export interface NewsFact {
+ kind: "repo" | "by" | "diff" | "files" | "branch" | "step" | "commit";
+ text?: string;
+ verb?: string;
+ additions?: number;
+ deletions?: number;
+}
+
+/** Something that just happened in an integration: the pill's badge and sound. */
+export interface IntegrationNews {
+ success: boolean;
+ label: string;
+ detail: string | null;
+ /**
+ * What the island can say and open for it (GitHub): a title and a line of
+ * facts for the card — the step that broke, who merged — and the run or
+ * the pull request to go to.
+ */
+ open?: {
+ target?: GithubTarget; label?: string; url?: string; title?: string; facts?: NewsFact[];
+ /** What kind of news this is, as the card says it: "pull request opened". */
+ says?: string;
+ };
+}
+
export interface IntegrationUpdate {
id: string;
data: Record;
error: string | null;
- event: { success: boolean; label: string; detail: string | null } | null;
+ event: IntegrationNews | null;
}
export type ChatContext =
diff --git a/windows/src/core/layout.ts b/windows/src/core/layout.ts
index 6f70eea4c5..9e5cf7db0c 100644
--- a/windows/src/core/layout.ts
+++ b/windows/src/core/layout.ts
@@ -21,7 +21,8 @@ export type IslandViewName =
| "result"
| "note"
| "settings"
- | "greeting";
+ | "greeting"
+ | "github";
export type BotStateName =
| "idle"
@@ -86,6 +87,11 @@ export const VIEW_LAYOUTS: Record = {
note: { height: 160, botX: 60, botY: null, botDiameter: 50, agentMode: "column" },
settings: { height: 160, botX: 54, botY: null, botDiameter: 46, agentMode: "none" },
greeting: { height: 150, botX: 320, botY: 90, botDiameter: 0, agentMode: "none" },
+ // Windows only, no macOS counterpart yet: the GitHub panel, laid out like a
+ // Claude Code session's view — the biggest Mochi of any view, centred in the
+ // upper part of his own column (its middle is x 67), the screen in a panel
+ // on the right. As tall as the window allows (PANEL_H), like that view.
+ github: { height: PANEL_H, botX: 67, botY: 108, botDiameter: 71, agentMode: "none" },
};
// The upload views above are only the fallback geometry. Once a file is actually
@@ -97,10 +103,19 @@ export function chatPromptHeight(messageCount: number): number {
return Math.min(300, 240 + messageCount * 40);
}
+/**
+ * A result card that tells an integration's news has one line more than a
+ * session's — the facts under the title, with air on both sides of it. The
+ * island grows by that much, so the card keeps the same room above and below
+ * its words.
+ */
+export const NEWS_LINE = 28;
+
export function islandSize(
mode: IslandMode,
view: IslandViewName,
chatCount = 0,
+ news = false,
): { w: number; h: number } {
switch (mode) {
case "hidden":
@@ -111,7 +126,8 @@ export function islandSize(
return { w: COMPACT_W, h: NOTCH_H };
case "expanded": {
const h = view === "prompt" ? chatPromptHeight(chatCount) : VIEW_LAYOUTS[view].height;
- return { w: EXPANDED_W, h };
+ const grown = news && (view === "finished" || view === "error") ? NEWS_LINE : 0;
+ return { w: EXPANDED_W, h: h + grown };
}
}
}
diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts
index 01236b80a9..309f6d28f4 100644
--- a/windows/src/core/state.ts
+++ b/windows/src/core/state.ts
@@ -2,6 +2,7 @@
import type { BotEmoteName, BotStateName, IslandMode, IslandViewName } from "./layout";
import type { EyeShape } from "../mochi/engine";
+import type { IntegrationNews } from "./bridge";
export type AgentSource = "claudeCode" | "n8n" | "agent";
export type PillBadge = "approval" | "finished" | "error";
@@ -79,6 +80,8 @@ export interface IntegrationInfo {
error: string | null;
loaded: boolean;
configured: boolean;
+ /** What just happened, for as long as the pill says it. */
+ news?: IntegrationNews | null;
}
export interface Settings {
@@ -92,6 +95,8 @@ export interface Settings {
hooksInstalled: boolean;
/** Claude model used by the chat. */
model: string;
+ /** GitHub projects ("owner/name") whose news the pill keeps to itself. */
+ githubMuted: string[];
}
export const DEFAULT_SETTINGS: Settings = {
@@ -106,6 +111,7 @@ export const DEFAULT_SETTINGS: Settings = {
autostart: false,
hooksInstalled: false,
model: "claude-opus-5",
+ githubMuted: [],
};
type Listener = () => void;
diff --git a/windows/src/island/integrations.ts b/windows/src/island/integrations.ts
index 862ffb14b0..c533203d32 100644
--- a/windows/src/island/integrations.ts
+++ b/windows/src/island/integrations.ts
@@ -2,9 +2,10 @@
// pollers: a genuinely new item flips the pill to finished/error, badges it when
// the pill isn't focused, plays a sound, and clears itself after 60 s.
-import { onEvent, Bridge, type IntegrationUpdate } from "../core/bridge";
+import { onEvent, Bridge, type GithubData, type IntegrationUpdate } from "../core/bridge";
import { Sound } from "../core/sound";
import { State } from "../core/state";
+import { enterGithubPanel, sawRunEnd } from "../views/github";
import type { Island } from "./island";
/** Which Credential Manager key backs each pill. */
@@ -19,6 +20,82 @@ const KEY_FOR: Record = {
};
const clearTimers = new Map();
+/** As in the Swift pollers: a pill's finished or error look clears itself after this. */
+const SETTLE_MS = 60_000;
+/**
+ * News that speaks up stays longer: it is what tells of a build that broke,
+ * and someone away from the screen for a minute must still find it there.
+ * Opening it ends it sooner.
+ */
+const NEWS_MS = 5 * 60_000;
+
+const GITHUB = "integration_github";
+
+/**
+ * Integrations whose news opens the island, the way a Claude Code session's
+ * result does: their Mochi steps to the front and the island unfolds on the
+ * finished or the error card, which says the news in words. Among four mini
+ * Mochis a badge on one is easy to miss — and the folded pill has no room
+ * for words: the island hides in a MacBook's notch, its middle is the notch.
+ */
+const SPEAKS_UP = new Set([GITHUB]);
+/** The pill each of them took the front from, to hand it back. */
+const borrowedFrom = new Map();
+/** States that are waiting for the user: nothing takes the front from those. */
+const WAITING = new Set(["approval", "question"]);
+
+/** Hands the front of the pill back, unless the user moved it since. */
+function giveBack(id: string) {
+ const previous = borrowedFrom.get(id);
+ borrowedFrom.delete(id);
+ if (previous && State.focusId === id) State.setFocus(previous);
+}
+
+/**
+ * A pill's news is over — it got old, or it was opened, and news that was
+ * opened is not news any more: the pill goes back to rest. Unless the user is
+ * being taken to what it was about (`stay`), the front goes back to whoever
+ * had it.
+ */
+function settle(id: string, stay = false) {
+ const timer = clearTimers.get(id);
+ if (timer != null) window.clearTimeout(timer);
+ clearTimers.delete(id);
+ const info = State.integrations[id];
+ if (info) info.news = null;
+ if (stay) borrowedFrom.delete(id);
+ else giveBack(id);
+ const task = State.tasks.find((t) => t.id === id);
+ if (task && (task.state === "finished" || task.state === "error")) {
+ task.state = "idle";
+ task.steps = [];
+ task.stepIndex = 0;
+ task.pillBadge = null;
+ }
+ State.notify();
+}
+
+/** The pill whose news the user is looking at: the one at the front, else GitHub's. */
+function newsId(): string | null {
+ const telling = (id: string | null) => id != null && SPEAKS_UP.has(id) && State.integrations[id]?.news != null;
+ return telling(State.focusId) ? State.focusId : telling(GITHUB) ? GITHUB : null;
+}
+
+/**
+ * Straight to what the news is about — from its card, from the panel's line,
+ * or from a click on Mochi while he has some to tell. False when there was
+ * none to follow.
+ */
+export function followNews(island: Island): boolean {
+ const id = newsId();
+ const news = id ? State.integrations[id]?.news : null;
+ if (!id || !news) return false;
+ Sound.play("blip");
+ enterGithubPanel(news.open);
+ settle(id, true);
+ island.setView("github");
+ return true;
+}
export function registerIntegrationHandlers(island: Island) {
void onEvent("integration", (update) => handle(island, update));
@@ -44,13 +121,28 @@ function handle(island: Island, update: IntegrationUpdate) {
if (State.paused) return;
const previous = State.integrations[update.id];
+ // A failed poll normally carries no data and keeps what was there. GitHub's
+ // carries its last good snapshot, so the panel can go on showing it next to
+ // the reason it isn't fresh.
+ const hasData = Object.keys(update.data).length > 0;
State.integrations[update.id] = {
- data: update.error ? (previous?.data ?? {}) : update.data,
+ data: hasData ? update.data : (previous?.data ?? {}),
error: update.error,
- loaded: update.error ? (previous?.loaded ?? false) : true,
+ loaded: hasData || (previous?.loaded ?? false),
configured: previous?.configured ?? true,
+ news: update.event ?? previous?.news ?? null,
};
+ // A run going on one of the projects: GitHub's Mochi is at work, like a
+ // session's, until it ends — in news, or quietly.
+ if (update.id === GITHUB && hasData) {
+ const repos = (update.data as Partial).repos ?? [];
+ const going = repos.some((r) => r.build?.state === "running");
+ const task = State.tasks.find((t) => t.id === update.id);
+ if (task && going && task.state === "idle") task.state = "working";
+ else if (task && !going && task.state === "working") task.state = "idle";
+ }
+
const event = update.event;
if (event) {
const task = State.tasks.find((t) => t.id === update.id);
@@ -58,28 +150,36 @@ function handle(island: Island, update: IntegrationUpdate) {
task.state = event.success ? "finished" : "error";
task.steps = event.detail ? [event.label, event.detail] : [event.label];
task.stepIndex = task.steps.length - 1;
+ // Only while the island is away or folded: open, it is showing something
+ // the user is reading, and the news has its own place there.
+ const front = State.focusTask;
+ let opened = false;
+ if (
+ SPEAKS_UP.has(update.id) && State.mode !== "expanded" && State.focusId !== update.id &&
+ State.focusId && !(front && WAITING.has(front.state))
+ ) {
+ if (!borrowedFrom.has(update.id)) borrowedFrom.set(update.id, State.focusId);
+ State.setFocus(update.id);
+ opened = true;
+ }
if (State.focusId !== update.id) {
task.pillBadge = event.success ? "finished" : "error";
}
- Sound.play(event.success ? "finish" : "error");
+ // A run the panel was showing when it ended has been played there already.
+ const about = event.open?.target;
+ const heard = about?.kind === "run" && sawRunEnd(about.id);
+ if (!heard) Sound.play(event.success ? "finish" : "error");
// Same as the Swift pollers: show the compact island so the badge is seen,
- // but never steal the screen for a successful deploy.
- island.reveal();
+ // but never steal the screen for a successful deploy. News that speaks up
+ // unfolds the island on its card instead; it folds back on its own.
+ if (opened) island.alert(event.success ? "finished" : "error");
+ else island.reveal();
const existing = clearTimers.get(update.id);
if (existing != null) window.clearTimeout(existing);
clearTimers.set(
update.id,
- window.setTimeout(() => {
- clearTimers.delete(update.id);
- const t = State.tasks.find((x) => x.id === update.id);
- if (!t || (t.state !== "finished" && t.state !== "error")) return;
- t.state = "idle";
- t.steps = [];
- t.stepIndex = 0;
- t.pillBadge = null;
- State.notify();
- }, 60_000),
+ window.setTimeout(() => settle(update.id), SPEAKS_UP.has(update.id) ? NEWS_MS : SETTLE_MS),
);
}
}
diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts
index 7fdb618027..e577b03048 100644
--- a/windows/src/island/island.ts
+++ b/windows/src/island/island.ts
@@ -1,22 +1,24 @@
// The island: DOM shell, sizing animation, Mochi placement, mouse handling.
// Mirrors IslandRootView.swift + IslandWindowController.swift.
-import { Tracked, Spring, clamp } from "../core/anim";
+import { Tracked, Spring, clamp, mixColor } from "../core/anim";
import { Bridge, IS_TAURI, onDragDrop } from "../core/bridge";
import {
EXPANDED_CORNER, EXPANDED_W, NOTCH_W, PANEL_H, PANEL_W,
ROUNDED_CORNER, VIEW_LAYOUTS, botGlowColor, botGlowOpacity, botPosition, chatPromptHeight,
islandSize,
- type IslandMode, type IslandViewName,
+ type BotStateName, type IslandMode, type IslandViewName,
} from "../core/layout";
import { Sound } from "../core/sound";
import { State } from "../core/state";
-import { BotEngine, hexToRGB } from "../mochi/engine";
+import { BotEngine, hexToRGB, type RGB } from "../mochi/engine";
import { Greeting } from "../mochi/greeting";
import { createMiniBot, pruneMiniBots, syncMiniBotStates, tickMiniBots } from "../mochi/minibots";
import { UploadCanvas } from "../upload/canvas";
import { USC, UploadSeq } from "../upload/sequence";
import { buildHeader, buildViews, type ViewActions, type ViewHost } from "../views/views";
+import { githubData } from "../views/integrations";
+import { followNews } from "./integrations";
import { h } from "../views/dom";
import { IslandStateMachine } from "./fsm";
@@ -32,6 +34,11 @@ const PRE_PROGRESS = USC.T_PROG_START - USC.T_DROP;
const modeOrder = (m: IslandMode) => (m === "hidden" ? 0 : m === "compact" ? 1 : 2);
+/** How fast Mochi's body goes to a new colour, per second: about 90 % of the way in 0.4 s. */
+const TINT_RATE = 5.5;
+/** Closer than this on every channel (0…1), the body has its colour: a unit of 8-bit colour. */
+const TINT_SETTLED = 0.004;
+
export class Island {
readonly fsm = new IslandStateMachine();
@@ -61,6 +68,16 @@ export class Island {
private engine = new BotEngine();
private greeting = new Greeting();
+ /**
+ * A view asking Mochi to take a colour for a moment (a day of the GitHub
+ * graph). His body only: the glow stays his own.
+ */
+ private tintRequest: RGB | null = null;
+ /** A state a view asked Mochi to wear (see ViewActions.look). */
+ private viewState: BotStateName | null = null;
+ /** The colour Mochi's body is drawn in, eased towards what it should be. */
+ private bodyRGB: RGB | null = null;
+
private running = false;
private lastFrame = 0;
private dirty = true;
@@ -129,6 +146,7 @@ export class Island {
};
if (task.id === "integration_claude") void Bridge.openInVSCode(task.sessionCwd ?? null);
else if (task.id === "integration_n8n") void Bridge.openN8n();
+ else if (task.id === "integration_github" && githubData()) void Bridge.openUrl(githubData()!.profileUrl);
else if (urls[task.id]) void Bridge.openUrl(urls[task.id]);
},
openUrl: (url) => {
@@ -167,6 +185,17 @@ export class Island {
},
openSettingsWindow: () => void Bridge.openSettingsWindow(),
blip: () => Sound.play("blip"),
+ emote: (e) => this.engine.triggerEmote(e),
+ tintMochi: (color) => {
+ this.tintRequest = color ? hexToRGB(color) : null;
+ this.ensureRunning();
+ },
+ look: (state) => {
+ if (this.viewState === state) return;
+ this.viewState = state;
+ State.notify();
+ },
+ followNews: () => followNews(this),
};
this.wakeStrip = h("div", { id: "wake-strip" });
@@ -260,6 +289,9 @@ export class Island {
const prev = State.mode;
if (mode === prev) return;
State.mode = mode;
+ // Whatever asked for a tint is no longer under the mouse.
+ this.tintRequest = null;
+ this.viewState = null;
if (mode === "expanded") Sound.play("open");
if (prev === "expanded") {
Sound.play("close");
@@ -300,6 +332,8 @@ export class Island {
setView(view: IslandViewName) {
this.stopSequenceIfLeaving(view);
+ this.tintRequest = null;
+ this.viewState = null;
if (State.mode !== "expanded") {
this.fsm.forceHome();
State.view = view;
@@ -450,7 +484,8 @@ export class Island {
// ── Geometry ────────────────────────────────────────────────────────────────
private targetSize(): { w: number; h: number; r: number } {
- const { w, h } = islandSize(State.mode, State.view, State.chatHistory.length);
+ const news = State.focusId != null && State.integrations[State.focusId]?.news != null;
+ const { w, h } = islandSize(State.mode, State.view, State.chatHistory.length, news);
const r = State.mode === "expanded" ? EXPANDED_CORNER : ROUNDED_CORNER;
return { w, h, r };
}
@@ -476,7 +511,13 @@ export class Island {
this.islandEl.style.width = `${w}px`;
this.islandEl.style.height = `${hh}px`;
this.islandEl.style.borderRadius = `0 0 ${r}px ${r}px`;
- this.islandEl.style.transform = `translateX(-50%)`;
+ // Centred on a whole pixel of the screen. `translateX(-50%)` put the island
+ // on a fraction of one for as long as its width was animating, and whatever
+ // was painted then in a layer of its own — a scrolling list, say — kept
+ // that fraction once the island had settled: its text stayed smeared until
+ // it was drawn again.
+ const dpr = window.devicePixelRatio || 1;
+ this.islandEl.style.transform = `translateX(${-Math.round((w / 2) * dpr) / dpr}px)`;
// These follow the island as it resizes, so they belong here rather than in
// the state-driven DOM sync.
this.miniGrid.style.left = `${w - 40 - 14.5}px`;
@@ -540,7 +581,8 @@ export class Island {
}
if (this.isBotHit(e.clientX, e.clientY)) {
this.cancelBotHover();
- this.engine.slap();
+ // A Mochi with news to tell takes you to it; any other gets his slap.
+ if (!followNews(this)) this.engine.slap();
}
});
@@ -733,7 +775,10 @@ export class Island {
? settling
: settling ||
!this.botCx.settled || !this.botCy.settled || !this.botSize.settled ||
- greetingActive || this.engine.busy || UploadSeq.isActive;
+ greetingActive || this.engine.busy || UploadSeq.isActive || this.tintSettling ||
+ // A view showing something live keeps its Mochis moving: a run's crew
+ // would otherwise freeze the moment the big one came to rest.
+ this.viewState != null;
if (busy) {
requestAnimationFrame(this.frame);
@@ -787,8 +832,7 @@ export class Island {
const ctx = this.botCanvas.getContext("2d");
if (!ctx) return;
- const focus = State.focusTask;
- this.engine.bodyColor = focus?.isIntegration ? hexToRGB(focus.color) : null;
+ this.engine.bodyColor = this.easeBodyColor(dt);
this.engine.particleOverhang = BOT_OVERHANG;
this.engine.lookX = this.lookX();
this.engine.lookY = this.lookY();
@@ -807,6 +851,37 @@ export class Island {
this.engine.draw(ctx, w, hCss);
}
+ /** The colour Mochi should be: a view's request, else his pill's colour. */
+ private targetBodyColor(): RGB | null {
+ if (this.tintRequest) return this.tintRequest;
+ const focus = State.focusTask;
+ return focus?.isIntegration ? hexToRGB(focus.color) : null;
+ }
+
+ /**
+ * Eases the body towards its target rather than jumping, so sliding the mouse
+ * across the graph reads as Mochi slowly changing colour, not flickering:
+ * about 90 % of the way in 0.4 s, blended in OKLab so red to green stays
+ * bright instead of dipping through brown. Null — his own cream gradient —
+ * can't be blended into, and is simply taken.
+ */
+ private easeBodyColor(dt: number): RGB | null {
+ const target = this.targetBodyColor();
+ if (!target || !this.bodyRGB) {
+ this.bodyRGB = target;
+ return target;
+ }
+ this.bodyRGB = mixColor(this.bodyRGB, target, 1 - Math.exp(-dt * TINT_RATE));
+ return this.bodyRGB;
+ }
+
+ /** True while the body colour is still on its way — the frame loop keeps going. */
+ private get tintSettling(): boolean {
+ const target = this.targetBodyColor();
+ if (!target || !this.bodyRGB) return false;
+ return this.bodyRGB.some((v, i) => Math.abs(v - target[i]) > TINT_SETTLED);
+ }
+
/** BotCanvasView.lookX / lookY — tanh of the distance to the bot. */
private lookX(): number {
const rect = this.islandRect();
@@ -837,6 +912,10 @@ export class Island {
const greetingActive = expanded && State.view === "greeting";
this.contentEl.style.opacity = expanded && !greetingActive ? "1" : "0";
+ // Folded or hidden, the views are out of sight but still in the page: what
+ // moves in them on its own stops (see #content.away), and picks up when the
+ // island unfolds.
+ this.contentEl.classList.toggle("away", !expanded);
this.contentEl.style.pointerEvents = expanded && !greetingActive ? "auto" : "none";
this.greetingCanvas.style.display = greetingActive ? "block" : "none";
@@ -877,7 +956,11 @@ export class Island {
}
syncMiniBotStates(State.tasks);
- this.engine.setState(State.effectiveState);
+ // A view's look fills in for a Mochi with nothing of his own to say: idle,
+ // or only "working" — which the view, closer to what it shows, knows better.
+ const state = State.effectiveState;
+ const quiet = state === "idle" || state === "working";
+ this.engine.setState(this.viewState && quiet ? this.viewState : state);
}
/** Applies settings coming from Rust at boot. */
diff --git a/windows/src/main.ts b/windows/src/main.ts
index f65d5e7577..106ef5a44f 100644
--- a/windows/src/main.ts
+++ b/windows/src/main.ts
@@ -61,6 +61,9 @@ async function main() {
void refreshConfigured();
});
+ // A key saved or removed in the settings window.
+ await onEvent("secrets-changed", () => void refreshConfigured());
+
registerHookHandlers(island);
registerIntegrationHandlers(island);
diff --git a/windows/src/mochi/minibots.ts b/windows/src/mochi/minibots.ts
index fbf2e03b07..8429c9e101 100644
--- a/windows/src/mochi/minibots.ts
+++ b/windows/src/mochi/minibots.ts
@@ -1,7 +1,8 @@
// Mini Mochis (pills + compact grid) — port of MiniBotCanvasView.
// Each canvas owns a BotEngine; the island's frame loop ticks every live one.
-import { BotEngine, hexToRGB } from "./engine";
+import { BOT_STATES, BotEngine, hexToRGB } from "./engine";
+import type { BotStateName } from "../core/layout";
import type { AgentTask } from "../core/state";
interface MiniBot {
@@ -23,6 +24,20 @@ const live = new Map();
* whole drawing to 60 %, which is what used to happen.
*/
export function createMiniBot(task: AgentTask, bodySize: number): HTMLElement {
+ const { slot, engine } = mount(bodySize, task.id);
+ engine.bodyColor = hexToRGB(task.color);
+ engine.setState(task.state, true);
+ if (task.emote) engine.setPermanentEmote(task.emote);
+ if (task.miniEye) {
+ engine.permanentEye = task.miniEye;
+ engine.eyeOverride = task.miniEye;
+ engine.eyeOverrideUntil = Number.POSITIVE_INFINITY;
+ }
+ return slot;
+}
+
+/** The slot, its canvas and its engine, ticked from now on by the island's loop. */
+function mount(bodySize: number, taskId: string): { slot: HTMLElement; engine: BotEngine } {
const slot = document.createElement("span");
slot.className = "mini";
slot.style.width = `${bodySize}px`;
@@ -39,17 +54,39 @@ export function createMiniBot(task: AgentTask, bodySize: number): HTMLElement {
const engine = new BotEngine();
engine.isMini = true;
- engine.bodyColor = hexToRGB(task.color);
- engine.setState(task.state, true);
- if (task.emote) engine.setPermanentEmote(task.emote);
- if (task.miniEye) {
- engine.permanentEye = task.miniEye;
- engine.eyeOverride = task.miniEye;
- engine.eyeOverrideUntil = Number.POSITIVE_INFINITY;
- }
+ live.set(canvas, { canvas, engine, cssSize: engineSize, taskId });
+ return { slot, engine };
+}
- live.set(canvas, { canvas, engine, cssSize: engineSize, taskId: task.id });
- return slot;
+/**
+ * A mini Mochi that stands for something other than a task — a job of a CI
+ * run. Nothing in State drives it: it comes with its engine, and whoever made
+ * it changes its state.
+ *
+ * It appears already in `state`, without that state's entrance (the finished
+ * roll, the error shake): arriving on a run that passed an hour ago is not the
+ * moment it passed. `engine.setState` plays the entrance later, when the state
+ * really changes, as for any other Mochi.
+ *
+ * `eyes` scales his eyes. A mini's are drawn large so they still read at the
+ * grid's 13 px; drawn bigger than that, the same eyes fill the face and their
+ * shapes — happy, flat, closed — run into each other.
+ */
+export function createFreeBot(
+ color: string, state: BotStateName, bodySize: number, eyes = 1,
+): { el: HTMLElement; engine: BotEngine } {
+ const { slot, engine } = mount(bodySize, "");
+ const cfg = BOT_STATES[state];
+ engine.bodyColor = hexToRGB(color);
+ engine.state = state;
+ engine.cfg = cfg;
+ engine.col = cfg.color;
+ engine.colT = cfg.color;
+ engine.tint = cfg.tint;
+ engine.es = eyes;
+ engine.tgEs = eyes;
+ engine.setBadge(cfg.badge);
+ return { el: slot, engine };
}
export function releaseMiniBot(canvas: HTMLCanvasElement) {
diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts
index 3ab9ab94a7..7b9a9c0a99 100644
--- a/windows/src/settings/main.ts
+++ b/windows/src/settings/main.ts
@@ -3,7 +3,7 @@
// integrations land here too in a later stage.
import "./settings.css";
-import { Bridge, onEvent, type HookStatus } from "../core/bridge";
+import { Bridge, onEvent, type GithubAccount, type HookStatus } from "../core/bridge";
import { DEFAULT_SETTINGS, type Settings } from "../core/state";
import { h, clear } from "../views/dom";
@@ -28,8 +28,11 @@ function toggle(on: boolean, onChange: (v: boolean) => void): HTMLElement {
return el;
}
+/** Green for what is in place, red for what is missing. */
+const statusColor = (ok: boolean) => (ok ? "var(--green)" : "var(--red)");
+
function statusDot(ok: boolean): HTMLElement {
- return h("i", { class: "dot", style: `background:${ok ? "#22c55e" : "#f4505e"}` });
+ return h("i", { class: "dot", style: `background:${statusColor(ok)}` });
}
function renderDiff(text: string): HTMLElement {
@@ -197,7 +200,7 @@ function apiSection(hasKey: boolean): HTMLElement {
async function refresh() {
const present = (await Bridge.secretPresent("anthropic-api-key")) ?? false;
- dot.style.background = present ? "#22c55e" : "#f4505e";
+ dot.style.background = statusColor(present);
state.textContent = present
? "Key saved in the Windows Credential Manager."
: "No key yet — the chat needs one.";
@@ -254,6 +257,161 @@ function apiSection(hasKey: boolean): HTMLElement {
);
}
+// ── GitHub setup ──────────────────────────────────────────────────────────────
+
+const GITHUB_ID = "integration_github";
+const GITHUB_KEY = "github-token";
+const GITHUB_NEW_TOKEN_URL = "https://github.com/settings/personal-access-tokens/new";
+const DAY_MS = 86_400_000;
+/** A token this close to its end gets a word about it. */
+const EXPIRES_SOON_DAYS = 7;
+
+/** GitHub's line in the integrations, and what opens under it once it is on. */
+function githubSetup(present: Record): IntegrationSetup {
+ const hasToken = present[GITHUB_KEY] ?? false;
+ const dot = statusDot(hasToken);
+ const state = h("span", { class: "hint" });
+
+ const field = h("input", {
+ type: "password",
+ style: "flex:1 1 auto;min-width:0",
+ autocomplete: "off",
+ spellcheck: "false",
+ }) as HTMLInputElement;
+
+ const saveBtn = h("button", { class: "primary", text: "Save token" });
+ const clearBtn = h("button", { class: "danger", text: "Remove" });
+ const testBtn = h("button", { text: "Test connection" });
+ const feedback = h("div", {});
+
+ function show(present: boolean) {
+ dot.style.background = statusColor(present);
+ state.textContent = present
+ ? "Token saved in the Windows Credential Manager."
+ : "No token yet — the GitHub pill needs one.";
+ field.placeholder = present ? "•••••••••••• (stored)" : "github_pat_…";
+ clearBtn.style.display = present ? "" : "none";
+ }
+
+ async function refresh() {
+ show((await Bridge.secretPresent(GITHUB_KEY)) ?? false);
+ }
+
+ /** Saves whatever is in the field. True when there was nothing to save, too. */
+ async function store(): Promise {
+ const value = field.value.trim();
+ if (!value) return true;
+ try {
+ await Bridge.secretSet(GITHUB_KEY, value);
+ field.value = "";
+ await refresh();
+ // Fill the pill now rather than at the next poll.
+ void Bridge.refreshIntegration(GITHUB_ID);
+ return true;
+ } catch (err) {
+ feedback.append(h("div", { class: "notice err", text: `Could not save: ${String(err)}` }));
+ return false;
+ }
+ }
+
+ saveBtn.addEventListener("click", async () => {
+ if (!field.value.trim()) return;
+ clear(feedback);
+ if (await store()) {
+ feedback.append(h("div", { class: "notice ok", text: "Saved. It never touches disk — test it below." }));
+ }
+ });
+
+ clearBtn.addEventListener("click", async () => {
+ clear(feedback);
+ try {
+ await Bridge.secretClear(GITHUB_KEY);
+ feedback.append(h("div", { class: "notice ok", text: "Token removed." }));
+ await refresh();
+ } catch (err) {
+ feedback.append(h("div", { class: "notice err", text: `Could not remove: ${String(err)}` }));
+ }
+ });
+
+ // A token pasted but not saved yet is saved first: the test only ever runs on
+ // the stored token, so the value never has to travel anywhere else.
+ testBtn.addEventListener("click", async () => {
+ clear(feedback);
+ if (!(await store())) return;
+ testBtn.disabled = true;
+ testBtn.textContent = "Testing…";
+ try {
+ feedback.append(githubResult(await Bridge.githubTest()));
+ } catch (err) {
+ feedback.append(h("div", { class: "notice err", text: String(err).replace(/^Error:\s*/, "") }));
+ } finally {
+ testBtn.disabled = false;
+ testBtn.textContent = "Test connection";
+ }
+ });
+
+ const newToken = h("button", {
+ class: "link",
+ text: "fine-grained token",
+ onclick: () => void Bridge.openUrl(GITHUB_NEW_TOKEN_URL),
+ });
+
+ show(hasToken);
+
+ const status = h("div", { class: "row", style: "gap:8px;padding-top:5px" }, dot, state);
+ const panel = h(
+ "div",
+ { class: "setup" },
+ h("div", { class: "row" }, h("label", { text: "Token" }), field, saveBtn, clearBtn),
+ h(
+ "div",
+ { class: "hint" },
+ "Create a ", newToken, " with ",
+ h("b", { text: "Repository access: All repositories" }), ", then under ",
+ h("b", { text: "Repository permissions" }), " set ",
+ h("b", { text: "Actions" }), ", ", h("b", { text: "Contents" }), ", ",
+ h("b", { text: "Deployments" }), ", ", h("b", { text: "Issues" }), " and ",
+ h("b", { text: "Pull requests" }),
+ " to Read-only (Metadata is added on its own). Under ",
+ h("b", { text: "Account permissions" }), ", ", h("b", { text: "Events" }),
+ " Read-only is optional and adds your private activity. Nothing else — Coucou only reads.",
+ ),
+ h("div", { class: "row" }, testBtn),
+ feedback,
+ );
+ return { status, panel };
+}
+
+function githubResult(account: GithubAccount): HTMLElement {
+ const missing = account.checks.some((c) => !c.ok);
+ const who = account.name ? `@${account.login} (${account.name})` : `@${account.login}`;
+ const list = h("ul", { class: "checks" });
+ for (const c of account.checks) {
+ list.append(
+ h("li", {}, statusDot(c.ok), h("span", { text: c.label }), c.note ? h("span", { class: "check-note", text: c.note }) : null),
+ );
+ }
+ return h(
+ "div",
+ { class: missing ? "notice warn" : "notice ok" },
+ h("div", { text: `Connected as ${who}.` }),
+ h("div", { text: tokenExpiry(account.expiresAt) }),
+ list,
+ );
+}
+
+/** "2026-12-12 10:00:00 +0100" → a date, with a nudge when it is close. */
+function tokenExpiry(raw: string | null): string {
+ if (!raw) return "This token has no expiry date.";
+ const date = new Date(raw.slice(0, 10));
+ if (Number.isNaN(date.getTime())) return `Token expires ${raw}.`;
+ const when = date.toLocaleDateString(undefined, { day: "numeric", month: "long", year: "numeric" });
+ const days = Math.ceil((date.getTime() - Date.now()) / DAY_MS);
+ if (days < 0) return `This token expired on ${when}.`;
+ if (days <= EXPIRES_SOON_DAYS) return `Token expires on ${when} — in ${days} day${days === 1 ? "" : "s"}. Make a new one soon.`;
+ return `Token expires on ${when}.`;
+}
+
// ── Integrations section ──────────────────────────────────────────────────────
interface IntegrationDef {
@@ -262,13 +420,22 @@ interface IntegrationDef {
color: string;
/** Credential Manager keys, in the order they are shown. */
fields: { key: string; label: string; placeholder: string; secret: boolean }[];
+ /** For an integration that needs more than its fields. */
+ setup?: (present: Record) => IntegrationSetup;
+}
+
+interface IntegrationSetup {
+ /** Where it stands, on the integration's own line. */
+ status: HTMLElement;
+ /** Opens under the line while the integration is on. */
+ panel: HTMLElement;
}
const INTEGRATIONS: IntegrationDef[] = [
{ id: "integration_stripe", name: "Stripe", color: "#0570DE",
fields: [{ key: "stripe-api-key", label: "Secret key", placeholder: "sk_live_…", secret: true }] },
{ id: "integration_github", name: "GitHub", color: "#F4505E",
- fields: [{ key: "github-token", label: "Token", placeholder: "ghp_…", secret: true }] },
+ fields: [], setup: githubSetup },
{ id: "integration_vercel", name: "Vercel", color: "#7C5CFF",
fields: [{ key: "vercel-token", label: "Token", placeholder: "…", secret: true }] },
{ id: "integration_n8n", name: "n8n", color: "#F29B38",
@@ -298,6 +465,14 @@ function integrationsSection(present: Record): HTMLElement {
for (const def of INTEGRATIONS) {
const active = settings.activeIntegrations.includes(def.id);
const sw = h("button", { class: active ? "switch on" : "switch" });
+ const setup = def.setup?.(present);
+ const drawer = setup ? h("div", { class: "drawer" }, h("div", {}, setup.panel)) : null;
+ const open = (on: boolean) => {
+ drawer?.classList.toggle("open", on);
+ // Folded away, its fields are out of reach of the keyboard too.
+ if (setup) setup.panel.inert = !on;
+ };
+ open(active);
sw.addEventListener("click", () => {
const on = settings.activeIntegrations.includes(def.id);
if (on) {
@@ -307,11 +482,13 @@ function integrationsSection(present: Record): HTMLElement {
settings.activeIntegrations = [...settings.activeIntegrations, def.id];
}
sw.classList.toggle("on", !on);
+ open(!on);
updateNote();
void save();
});
const rows = h("div", { style: "display:flex;flex-direction:column;gap:6px;flex:1 1 auto;min-width:0" });
+ if (setup) rows.append(setup.status);
for (const field of def.fields) {
const input = h("input", {
type: field.secret ? "password" : "text",
@@ -342,16 +519,15 @@ function integrationsSection(present: Record): HTMLElement {
);
}
- list.append(
- h("div", { style: "display:flex;gap:12px;align-items:flex-start" },
- h("div", { style: "display:flex;align-items:center;gap:8px;min-width:132px;padding-top:4px" },
- sw,
- h("i", { class: "dot", style: `background:${def.color}` }),
- h("span", { style: "font-size:12.5px", text: def.name }),
- ),
- rows,
+ const line = h("div", { style: "display:flex;gap:12px;align-items:flex-start" },
+ h("div", { style: "display:flex;align-items:center;gap:8px;min-width:132px;padding-top:4px" },
+ sw,
+ h("i", { class: "dot", style: `background:${def.color}` }),
+ h("span", { style: "font-size:12.5px", text: def.name }),
),
+ rows,
);
+ list.append(drawer ? h("div", {}, line, drawer) : line);
}
updateNote();
diff --git a/windows/src/settings/settings.css b/windows/src/settings/settings.css
index 605b82025d..76e444fe75 100644
--- a/windows/src/settings/settings.css
+++ b/windows/src/settings/settings.css
@@ -137,6 +137,27 @@ button:disabled {
cursor: default;
}
+/* A link inside running text, e.g. the GitHub token page. */
+button.link {
+ font: inherit;
+ padding: 0;
+ border-radius: 0;
+ background: transparent;
+ color: var(--ink);
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+
+button.link:hover:not(:disabled) {
+ background: transparent;
+ color: #fff;
+}
+
+.hint b {
+ font-weight: 500;
+ color: #c5c8cd;
+}
+
code,
.path {
font: 400 11.5px var(--mono);
@@ -265,3 +286,64 @@ select option {
background: rgba(245, 165, 36, 0.13);
color: #fbc06a;
}
+
+/* GitHub connection test: one line per permission the panel relies on. */
+.checks {
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ margin-top: 8px;
+}
+
+.checks li {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: var(--ink);
+}
+
+.checks .dot {
+ width: 6px;
+ height: 6px;
+}
+
+.check-note {
+ color: var(--dim);
+}
+
+/* What an integration needs beyond its fields opens under its line once it is
+ switched on, and folds back when it is switched off. */
+.drawer {
+ display: grid;
+ grid-template-rows: 0fr;
+ transition: grid-template-rows 0.22s ease;
+}
+
+.drawer.open {
+ grid-template-rows: 1fr;
+}
+
+.drawer > div {
+ min-height: 0;
+ overflow: hidden;
+}
+
+.setup {
+ margin-top: 12px;
+ padding: 14px;
+ border: 1px solid var(--hairline);
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.025);
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.setup .row > label {
+ min-width: 0;
+}
+
+.setup > :empty {
+ display: none;
+}
diff --git a/windows/src/style.css b/windows/src/style.css
index b2cf6756c8..bd8bdf3aa2 100644
--- a/windows/src/style.css
+++ b/windows/src/style.css
@@ -192,6 +192,14 @@ body {
transition: opacity 0.2s ease-in-out;
}
+/* Keep it light: 0 % CPU when the island is hidden. Folded or hidden, the
+ views are invisible but still in the page, and an animation with no end — a
+ shimmering line, a pulsing dot, a turning ring — would go on being computed
+ for nobody. They all stop, and pick up when the island unfolds. */
+#content.away * {
+ animation-play-state: paused !important;
+}
+
#header {
height: 34px;
flex: 0 0 34px;
@@ -1197,54 +1205,113 @@ input[type="range"]::-webkit-slider-thumb {
height: 28px;
}
-.int-stats {
+.int-balance {
display: flex;
- flex-direction: column;
- gap: 5px;
- padding-top: 8px;
- padding-right: 12px;
+ align-items: baseline;
+ gap: 3px;
+ padding-top: 4px;
}
-.int-stat {
- display: flex;
- align-items: center;
- gap: 7px;
+.int-balance span {
+ font: 700 20px var(--mono);
+ color: var(--ink);
}
-.int-stat-icon {
- width: 14px;
+.int-balance i {
+ font: 600 9px var(--font);
+ font-style: normal;
+ color: var(--dim-3);
+}
+
+/* GitHub's figure is a way in: it opens the panel, and lights up under the
+ mouse like the pills. The last seven days ride at its right, as GitHub
+ draws a day. */
+.int-figure {
+ align-self: stretch;
+ margin: 2px -6px 0;
+ padding: 2px 6px 1px;
+ border: 0;
+ border-radius: 7px;
+ background: transparent;
+ text-align: left;
+ cursor: default;
+ transition: background 0.12s ease-out;
+}
+
+.int-figure:hover {
+ background: var(--gh-hover);
+}
+
+/* Its right edge on the times of the lines under it. */
+.int-week {
+ display: inline-flex;
+ gap: 2px;
+ margin: 0 4px 0 auto;
+ align-self: center;
+}
+
+.int-week i {
+ width: 6px;
+ height: 6px;
+ border-radius: 34%;
+}
+
+.int-star {
display: grid;
place-items: center;
+ color: var(--amber);
}
-.int-stat-label {
- font: 400 11px var(--font);
- color: var(--dim-3);
+/* A line of the card that opens its sheet in the panel. Its dot sits just
+ inside the account's dot over it, as on the macOS cards, not a row's full
+ padding in. */
+.int-go {
+ width: 100%;
+ padding-left: 4px;
+ padding-right: 4px;
+ border: 0;
+ background: transparent;
+ font: inherit;
+ text-align: left;
+ cursor: default;
+ transition: background 0.12s ease-out;
}
-.int-stat-value {
- margin-left: auto;
- font: 600 12px var(--font);
- color: #c5c8cd;
- font-variant-numeric: tabular-nums;
+.int-go:hover {
+ background: var(--gh-hover);
}
-.int-balance {
- display: flex;
- align-items: baseline;
- gap: 3px;
- padding-top: 4px;
+.int-go:hover .int-name {
+ color: var(--ink);
}
-.int-balance span {
- font: 700 20px var(--mono);
- color: var(--ink);
+/* Read from the left, like a line of the panel: the title, what goes with it
+ right after, and only the time at the far end. A fact — "#12" — keeps its
+ room and the title is cut short; a project's name shares the cut with it. */
+.int-go .int-name {
+ flex: 0 1 auto;
}
-.int-balance i {
- font: 600 9px var(--font);
- font-style: normal;
+.int-go .int-amount {
+ font: 500 10.5px var(--mono);
+ margin-left: 2px;
+ white-space: nowrap;
+}
+
+.int-where {
+ flex: 0 1 auto;
+ min-width: 0;
+ margin-left: 2px;
+ font: 400 10.5px var(--font);
color: var(--dim-3);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.int-go .int-ago {
+ margin-left: auto;
+ padding-left: 6px;
}
.int-page {
@@ -1365,3 +1432,1760 @@ input[type="range"]::-webkit-slider-thumb {
.int-card.detail {
padding-top: 8px;
}
+
+/* ── GitHub panel (github view) ─────────────────────────────────────────── */
+
+/* The panel's own tones, on top of the island's palette: each written once.
+ White laid over the dark ground comes in a few strengths, named for what
+ they are used for; the last block is an editor's colours, for a file's
+ code. */
+:root {
+ --gh-text: #c5c8cd; /* a line's words: between the ink and the dims */
+ --gh-edge: rgba(255, 255, 255, 0.05); /* the panel's border */
+ --gh-hover: rgba(255, 255, 255, 0.05); /* a line under the mouse */
+ --gh-track: rgba(255, 255, 255, 0.06); /* the empty length of a bar */
+ --gh-plate: rgba(255, 255, 255, 0.07); /* a round button, the step on show, a tag */
+ --gh-tile: color-mix(in srgb, var(--card), #fff 3.5%); /* a block of a sheet */
+ --gh-tile-hover: color-mix(in srgb, var(--card), #fff 7%);
+
+ --code: #d6d9df;
+ --code-keyword: #c792ea;
+ --code-string: #c3e88d;
+ --code-call: #82aaff; /* a call, a property */
+ --code-constant: #f78c6c; /* a number, a constant */
+ --code-type: #ffcb6b;
+ --code-comment: #5c6370;
+ --code-added: #6ee7b7; /* the number and the sign of a line added */
+ --code-gone: #b86f78; /* the words of a line removed */
+}
+
+/* The panel stays in the page while another view is up, and it holds
+ animations with no end: a running build's ring, a live job's bar, today's
+ square. They stop while it is not the view on show, and pick up when it is
+ back. (Folded or hidden, #content.away stops them with everything else.) */
+.gh-view:not(.on) * {
+ animation-play-state: paused !important;
+}
+
+/* Laid out like a Claude Code session's view: Mochi's column on the left —
+ Mochi at full size, whose GitHub this is, and the way down to the screen on
+ show — and the screen itself in a darker panel on the right, headed like an
+ editor's tab. */
+.gh-card {
+ display: flex;
+}
+
+/* Mochi is drawn by the island at the top of this column (VIEW_LAYOUTS.github):
+ the column starts under him. */
+.gh-side {
+ position: relative;
+ z-index: 1;
+ flex: 0 0 113px;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ padding: 119px 8px 12px 13px;
+}
+
+.gh-side-who {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ min-width: 0;
+}
+
+.gh-side-who b {
+ font: 600 14px var(--font);
+ color: var(--ink);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.gh-side-who span {
+ font: 400 12px var(--font);
+ color: var(--dim-2);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* The tabs at the top, and deeper, the way down: every level a way back. */
+.gh-trail {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5px;
+ margin-top: 11px;
+ min-width: 0;
+}
+
+.gh-step {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ height: 21px;
+ min-width: 0;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: var(--dim-3);
+ font: 500 12.5px var(--font);
+ text-align: left;
+ cursor: default;
+ transition: color 0.12s ease-out;
+}
+
+button.gh-step:hover {
+ color: var(--dim);
+}
+
+.gh-step.on {
+ color: var(--ink);
+ font-weight: 600;
+}
+
+/* The marks hang a little left of the words above them, as in the session view. */
+.gh-step > i {
+ width: 14px;
+ margin-left: -2px;
+ display: grid;
+ place-items: center;
+ flex: 0 0 auto;
+}
+
+.gh-step.on > i {
+ color: var(--c, var(--ink));
+}
+
+.gh-step > span {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+}
+
+/* Deeper, the column is no longer a choice between two tabs but the way down
+ to the screen on show, and has to read as one: the levels above are a notch
+ brighter than an unlit tab (each is a way back), a rail ties every mark to
+ the one above it, and the screen on show sits on a plate of its own. */
+.gh-trail.deep .gh-step {
+ color: var(--dim);
+}
+
+.gh-trail.deep button.gh-step:hover {
+ color: var(--ink);
+}
+
+.gh-trail.deep .gh-step > i {
+ position: relative;
+ height: 12px;
+}
+
+.gh-trail.deep .gh-step > i svg {
+ width: 12px;
+ height: 12px;
+}
+
+.gh-trail.deep .gh-step + .gh-step > i::before {
+ content: "";
+ position: absolute;
+ left: 50%;
+ bottom: calc(100% + 2px);
+ width: 1px;
+ height: 6.5px;
+ margin-left: -0.5px;
+ background: rgba(255, 255, 255, 0.16);
+}
+
+/* The plate reaches into the column's margins, so the words stay lined up
+ with the ones above. */
+.gh-trail.deep .gh-step.on {
+ color: var(--ink);
+ margin: 0 -4px 0 -7px;
+ padding: 0 4px 0 7px;
+ border-radius: 7px;
+ background: var(--gh-plate);
+}
+
+/* The panel wears the colour of the screen on it — on its tab, as an editor
+ marks the tab that is open: a rule under it, a breath of the colour in it,
+ and the head's line taking the colour along. Sharp marks on a sharp panel;
+ a glow behind the head said the same thing, but as a smudge. Registering
+ --accent as a colour lets it glide from one screen's colour to the next
+ instead of switching; unregistered, a custom property can't be
+ interpolated. Transparent on the lists, where nothing is open. */
+@property --accent {
+ syntax: "";
+ inherits: true;
+ initial-value: rgba(0, 0, 0, 0);
+}
+
+.gh-main {
+ position: relative;
+ flex: 1 1 auto;
+ min-width: 0;
+ margin: 7px 8px 7px 0;
+ display: flex;
+ flex-direction: column;
+ border-radius: 14px;
+ background: var(--card-flat);
+ border: 1px solid var(--gh-edge);
+ overflow: hidden;
+ transition: --accent 0.35s ease-out;
+}
+
+.gh-head {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+ flex: 0 0 auto;
+ height: 25px;
+ padding: 0 4px 0 7px;
+ border-bottom: 1px solid color-mix(in srgb, var(--accent) 38%, var(--gh-edge));
+}
+
+/* What is open, as an editor names its file: a tab standing on the head's
+ line, with a mark and a name. */
+.gh-tab {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+ flex: 0 1 auto;
+ align-self: flex-end;
+ height: 22px;
+ padding: 0 10px 0 6px;
+ border-radius: 7px 7px 0 0;
+ background:
+ linear-gradient(color-mix(in srgb, var(--accent) 13%, transparent) 0 0),
+ rgba(255, 255, 255, 0.06);
+ box-shadow: inset 0 -1px 0 var(--accent);
+ /* The rule takes the tab's last pixel: its mark and its name are centred in
+ what is left above it, not pressed against it. */
+ padding-bottom: 1px;
+}
+
+/* A file's name, in the face its code is set in — as an editor's tab has it. */
+.gh-tab b.file {
+ font: 400 11px var(--mono);
+}
+
+.gh-head .gh-icon {
+ width: 19px;
+ height: 19px;
+}
+
+.gh-tab b {
+ font: 600 12px var(--font);
+ color: var(--ink);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+}
+
+.gh-head-badge {
+ display: grid;
+ place-items: center;
+ min-width: 16px;
+ flex: 0 0 auto;
+}
+
+/* Beside the tab: what happened to a file, and its lines added and removed. */
+.gh-head-aside {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex: 0 0 auto;
+}
+
+.gh-head-aside:empty {
+ display: none;
+}
+
+.gh-head-badge .gh-block-icon {
+ margin-top: 0;
+ width: 16px;
+ height: 16px;
+}
+
+/* Where it sits, on the right, like the file's path. */
+.gh-sub {
+ font: 400 11px var(--font);
+ color: var(--dim-3);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+ flex: 0 1 auto;
+ padding-right: 2px;
+}
+
+/* A file's path, as an editor writes it. */
+.gh-sub.path {
+ font: 400 10.5px var(--mono);
+}
+
+.gh-icon {
+ width: 22px;
+ height: 22px;
+ border: 0;
+ border-radius: 50%;
+ background: var(--gh-plate);
+ color: var(--dim-2);
+ display: grid;
+ place-items: center;
+ flex: 0 0 auto;
+ cursor: default;
+ transition: background 0.12s ease-out, color 0.12s ease-out;
+}
+
+.gh-icon:hover {
+ background: rgba(255, 255, 255, 0.14);
+ color: var(--ink);
+}
+
+.gh-icon.spin svg {
+ animation: gh-spin 0.8s linear infinite;
+}
+
+@keyframes gh-spin {
+ to { transform: rotate(360deg); }
+}
+
+.gh-status {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font: 400 11px var(--font);
+ color: var(--dim-3);
+ min-width: 0;
+ padding: 7px 12px 0;
+}
+
+/* The panel and, over it, the news that came in while it was open. */
+.gh-col {
+ flex: 1 1 auto;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+/* News that came in while the panel was open — a build that broke, a pull
+ request that went in: one line in the card, over the panel and as wide as
+ it, on the card's own ground. Apart from what is being read, and clear of
+ the island's bar, whose middle is the notch on a MacBook. It opens what it
+ is about, and leaves with the pill's badge. */
+.gh-notice-slot {
+ flex: 0 0 auto;
+ margin: 6px 8px 0 0;
+}
+
+.gh-notice-slot:empty {
+ display: none;
+}
+
+.gh-notice-slot:not(:empty) + .gh-main {
+ margin-top: 5px;
+}
+
+.gh-notice {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ width: 100%;
+ min-width: 0;
+ height: 24px;
+ padding: 0 9px 0 8px;
+ border: 0;
+ border-radius: 9px;
+ background: color-mix(in srgb, var(--c) 10%, transparent);
+ color: var(--ink);
+ font: 500 11px var(--font);
+ text-align: left;
+ cursor: default;
+ animation: gh-rise 0.3s cubic-bezier(0.3, 1.2, 0.4, 1) both;
+ transition: background 0.12s ease-out;
+}
+
+.gh-notice:hover {
+ background: color-mix(in srgb, var(--c) 17%, transparent);
+}
+
+.gh-notice > i {
+ display: grid;
+ place-items: center;
+ flex: 0 0 auto;
+ color: var(--c);
+}
+
+.gh-notice > b {
+ flex: 0 1 auto;
+ min-width: 0;
+ font-weight: 600;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.gh-notice > span {
+ flex: 1 1 0;
+ min-width: 0;
+ font-weight: 400;
+}
+
+.gh-notice > em {
+ flex: 0 0 auto;
+ color: var(--c);
+ font-style: normal;
+ font-weight: 600;
+}
+
+/* The facts of a piece of news, each one a thing of its own: a mark, then
+ what it is. Grey for what frames it, clear for what is named. */
+.nfs {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ min-width: 0;
+ overflow: hidden;
+}
+
+.nf {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ flex: 0 0 auto;
+ min-width: 0;
+ font: 400 12px var(--font);
+ color: var(--dim);
+ white-space: nowrap;
+}
+
+.nf > svg {
+ flex: 0 0 auto;
+ color: var(--dim-3);
+}
+
+.nf b,
+.nf.strong {
+ font-weight: 600;
+ color: var(--ink-2);
+}
+
+.nf .gh-pm {
+ font-size: 11.5px;
+}
+
+.nf-branch {
+ padding: 1px 6px;
+ border-radius: 5px;
+ background: var(--gh-plate);
+ color: var(--ink-2);
+ font-size: 10.5px;
+}
+
+.nf.bad,
+.nf.bad > svg {
+ color: var(--red-text);
+ font-weight: 500;
+}
+
+/* The longest of them — a commit's title — is the one that gives way. */
+.nf.quote {
+ flex: 0 1 auto;
+ overflow: hidden;
+}
+
+.nf.quote {
+ text-overflow: ellipsis;
+ display: inline-block;
+}
+
+.nf.quote > svg {
+ vertical-align: -1px;
+ margin-right: 5px;
+}
+
+/* On a result card the facts belong to the title: they sit right under it, on
+ the stack's own gap, so the two read as one piece. The room goes between
+ them and the buttons, which are taller than a line and would otherwise
+ read as stuck to the facts. */
+.news-facts {
+ margin-bottom: 5px;
+}
+
+/* In the panel's notice they ride on one line beside the title, a size down. */
+.gh-notice .nfs {
+ gap: 10px;
+}
+
+.gh-notice .nf {
+ font-size: 11px;
+}
+
+.gh-status:empty {
+ display: none;
+}
+
+.gh-status span {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.gh-list {
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ padding: 6px 6px 8px;
+ scrollbar-width: thin;
+ scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
+}
+
+/* Like the overview ticker, a row cut by the bottom edge fades out — but only
+ while there is more below. At the end of the list nothing is hidden. */
+.gh-list.more {
+ -webkit-mask-image: linear-gradient(to bottom, #000 82%, transparent 100%);
+ mask-image: linear-gradient(to bottom, #000 82%, transparent 100%);
+}
+
+.gh-row {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ flex: 0 0 auto;
+ min-width: 0;
+ padding: 4px 8px;
+ border: 0;
+ border-radius: 6px;
+ background: transparent;
+ text-align: left;
+ cursor: default;
+}
+
+.gh-row:hover {
+ background: var(--gh-hover);
+}
+
+.gh-row-icon {
+ width: 14px;
+ display: grid;
+ place-items: center;
+ flex: 0 0 auto;
+}
+
+/* The title keeps its room; the repository gives way first. */
+.gh-row-title {
+ font: 500 11.5px var(--font);
+ color: var(--gh-text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+ flex: 0 1 auto;
+}
+
+.gh-row-where {
+ font: 400 10.5px var(--font);
+ color: var(--dim-3);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+ flex: 0 100 auto;
+}
+
+.gh-row .int-ago {
+ margin-left: auto;
+ padding-left: 6px;
+ min-width: 30px;
+ text-align: right;
+}
+
+/* Projects: the right-hand columns line up from row to row. */
+.gh-right {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-left: auto;
+ flex: 0 0 auto;
+}
+
+/* When it was, in a column wide enough for the longest it says ("just now"):
+ a row pushed a minute ago would otherwise push its marks out of line. */
+.gh-right .int-ago {
+ margin-left: 0;
+ min-width: 50px;
+}
+
+.gh-lock {
+ display: grid;
+ place-items: center;
+ flex: 0 0 auto;
+ color: var(--dim-3);
+}
+
+.gh-build {
+ width: 16px;
+ height: 16px;
+ padding: 0;
+ border: 0;
+ border-radius: 50%;
+ background: var(--tint);
+ color: var(--c);
+ display: grid;
+ place-items: center;
+ flex: 0 0 auto;
+ cursor: default;
+ transition: box-shadow 0.12s ease-out;
+}
+
+.gh-build:hover {
+ box-shadow: 0 0 0 3px var(--tint);
+}
+
+.gh-build.none {
+ background: transparent;
+}
+
+/* "Running": a ring turning in the badge's colour. */
+.gh-ring {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ border: 1.6px solid currentColor;
+ border-top-color: transparent;
+ animation: gh-spin 0.9s linear infinite;
+}
+
+.gh-meta {
+ display: inline-flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 3px;
+ width: 36px;
+ flex: 0 0 auto;
+ font: 500 10.5px var(--font);
+ color: var(--dim-2);
+ font-variant-numeric: tabular-nums;
+}
+
+.gh-meta.link:hover {
+ color: var(--ink);
+}
+
+/* ── GitHub panel: contribution graph ───────────────────────────────────── */
+
+.gh-graph {
+ flex: 0 0 auto;
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ padding: 2px 8px 6px;
+}
+
+/* --cell, --gap and --columns are set from the graph's width in whole device
+ pixels (see contributionGraph): every day is drawn the same size, and each
+ week's column carries the gap after it, so the weeks fill the width to the
+ pixel. The fallbacks only hold until that first fit. */
+.gh-months,
+.gh-grid {
+ display: grid;
+ grid-template-columns: var(--columns, repeat(var(--weeks, 53), calc(var(--cell, 7px) + var(--gap, 2px))));
+}
+
+.gh-months {
+ height: 11px;
+ font: 400 9.5px var(--font);
+ color: var(--dim-3);
+}
+
+.gh-months span {
+ white-space: nowrap;
+ overflow: visible;
+}
+
+.gh-grid {
+ grid-template-rows: repeat(7, var(--cell, 7px));
+ grid-auto-flow: column;
+ row-gap: var(--gap, 2px);
+}
+
+/* Each day is a tiny Mochi-shaped squircle in GitHub's colour for its level.
+ Hover grows it with the `scale` property, which composes with `transform`
+ instead of fighting the entrance and breathing animations over it. */
+.gh-grid i {
+ width: var(--cell, 7px);
+ border-radius: 34%;
+ background: var(--c);
+ cursor: default;
+ transition: scale 0.14s ease-out, opacity 0.2s ease-out;
+}
+
+/* The day under the mouse, and the picked day, just grow a little; no glow. */
+.gh-grid i:not(.pad):hover,
+.gh-grid i.on {
+ scale: 1.45;
+}
+
+/* A picked day: the rest of the year steps back, as on GitHub. */
+.gh-grid.picked i:not(.pad):not(.on) {
+ opacity: 0.3;
+}
+
+.gh-grid.picked i:not(.pad):not(.on):hover {
+ opacity: 0.7;
+}
+
+.gh-grid i.pad {
+ background: transparent;
+}
+
+/* Entering: the days light up left to right, after the view's own fade-in.
+ `backwards`, not `both`: once in, the cell's own transform takes over. */
+.gh-grid.sweep i:not(.pad) {
+ animation: gh-dot-in 0.34s cubic-bezier(0.3, 1.2, 0.4, 1) backwards;
+ animation-delay: calc(160ms + var(--col, 0) * 6ms);
+}
+
+@keyframes gh-dot-in {
+ from {
+ transform: scale(0);
+ }
+ to {
+ transform: scale(1);
+ }
+}
+
+/* Today breathes a little, the way Mochi does at rest. */
+.gh-grid i.today {
+ animation: gh-breathe 3.2s ease-in-out infinite;
+}
+
+.gh-grid.sweep i.today {
+ animation:
+ gh-dot-in 0.34s cubic-bezier(0.3, 1.2, 0.4, 1) backwards,
+ gh-breathe 3.2s ease-in-out 1.2s infinite;
+ animation-delay: calc(160ms + var(--col, 0) * 6ms), 1.2s;
+}
+
+@keyframes gh-breathe {
+ 0%,
+ 100% {
+ transform: scale(1);
+ }
+ 50% {
+ transform: scale(0.8);
+ }
+}
+
+.gh-graph-foot {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding-top: 3px;
+}
+
+.gh-caption {
+ flex: 1 1 auto;
+ min-width: 0;
+ font: 400 10.5px var(--font);
+ color: var(--dim-2);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-variant-numeric: tabular-nums;
+}
+
+/* GitHub's "Less ▪▪▪▪▪ More". */
+.gh-legend {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ flex: 0 0 auto;
+ font: 400 9.5px var(--font);
+ color: var(--dim-3);
+}
+
+.gh-legend i {
+ width: 7px;
+ height: 7px;
+ border-radius: 34%;
+ background: var(--c);
+}
+
+.gh-legend i:first-of-type {
+ margin-left: 2px;
+}
+
+.gh-legend i:last-of-type {
+ margin-right: 2px;
+}
+
+.gh-caption.day {
+ color: var(--ink-2);
+}
+
+/* ── GitHub panel: moving between screens ───────────────────────────────── */
+/* The island's own entrance curve and rhythm (.view.on): a deeper screen
+ comes from the right, going back from the left, a day's activity rises. */
+
+.gh-list.gh-from-right {
+ animation: gh-from-right 0.3s cubic-bezier(0.3, 1.2, 0.4, 1) both;
+}
+
+.gh-list.gh-from-left {
+ animation: gh-from-left 0.3s cubic-bezier(0.3, 1.2, 0.4, 1) both;
+}
+
+@keyframes gh-from-right {
+ from {
+ opacity: 0;
+ transform: translateX(16px);
+ }
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
+
+@keyframes gh-from-left {
+ from {
+ opacity: 0;
+ transform: translateX(-16px);
+ }
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
+
+.gh-head.gh-swap b,
+.gh-head.gh-swap .gh-sub {
+ animation: gh-swap 0.26s ease-out both;
+}
+
+@keyframes gh-swap {
+ from {
+ opacity: 0;
+ transform: translateY(3px);
+ }
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
+
+.gh-below {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ flex: 0 0 auto;
+}
+
+.gh-below.gh-rise {
+ animation: gh-rise 0.3s cubic-bezier(0.3, 1.2, 0.4, 1) both;
+}
+
+/* Picking a day: the rest of the year steps back and the day steps forward,
+ instead of snapping. No fill mode — once over, the .picked rules hold. */
+.gh-grid.just-picked i:not(.pad):not(.on) {
+ animation: gh-dim 0.26s ease-out;
+}
+
+.gh-grid.just-picked i.on {
+ animation: gh-grow 0.3s cubic-bezier(0.3, 1.2, 0.4, 1);
+}
+
+.gh-grid.just-unpicked i:not(.pad) {
+ animation: gh-undim 0.26s ease-out;
+}
+
+@keyframes gh-dim {
+ from {
+ opacity: 1;
+ }
+ to {
+ opacity: 0.3;
+ }
+}
+
+@keyframes gh-undim {
+ from {
+ opacity: 0.3;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes gh-grow {
+ from {
+ scale: 1;
+ }
+ to {
+ scale: 1.45;
+ }
+}
+
+/* A picked day, under the graph: a head like a sheet's, then the day's rows. */
+.gh-day {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ flex: 0 0 auto;
+ padding-bottom: 8px;
+}
+
+.gh-day-head {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 0 0 2px 8px;
+ font: 500 11px var(--font);
+ color: var(--ink-2);
+}
+
+.gh-day-head span {
+ flex: 1 1 auto;
+}
+
+.gh-day-close {
+ width: 22px;
+ height: 22px;
+}
+
+.gh-day .gh-loader-text {
+ padding-top: 2px;
+}
+
+.gh-row.muted {
+ color: var(--dim-3);
+}
+
+/* ── GitHub panel: activity sheets (pull request, issue, commits, release) ─ */
+
+.gh-title {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ padding: 0 8px;
+}
+
+.gh-title > span:first-child {
+ flex: 1 1 auto;
+ min-width: 0;
+ font: 600 12.5px/1.35 var(--font);
+ color: var(--ink);
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.gh-title .gh-chip {
+ flex: 0 0 auto;
+ margin-top: 1px;
+}
+
+.gh-desc.long {
+ -webkit-line-clamp: 5;
+ color: var(--dim);
+}
+
+.gh-labels {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ padding: 0 8px;
+}
+
+/* GitHub's label colours, kept readable on the dark card. */
+.gh-label {
+ font: 500 10px var(--font);
+ padding: 1px 7px;
+ border-radius: 999px;
+ color: var(--ink-2);
+ background: color-mix(in srgb, var(--c) 28%, transparent);
+ border: 1px solid color-mix(in srgb, var(--c) 55%, transparent);
+}
+
+.gh-heading {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 8px 0;
+ font: 500 10.5px var(--font);
+ color: var(--dim-3);
+}
+
+.gh-heading > span:first-child {
+ flex: 1 1 auto;
+}
+
+.gh-pm {
+ flex: 0 0 auto;
+ font: 500 10.5px var(--mono);
+ white-space: nowrap;
+}
+
+
+/* ── GitHub panel: a file's code ────────────────────────────────────────── */
+
+/* A file's kind as an editor's tab shows it: its letters on its language's
+ colour. */
+.gh-ext {
+ display: inline-grid;
+ place-items: center;
+ flex: 0 0 auto;
+ min-width: 17px;
+ height: 13px;
+ padding: 0 3px;
+ border-radius: 3.5px;
+ background: var(--c);
+ color: #fff;
+ font: 700 7.5px/1 var(--font);
+ font-style: normal;
+ white-space: nowrap;
+ /* Centred on the line's box, a badge reads as standing on the name's
+ baseline: a name's weight is in its lower-case letters, which sit below
+ the middle of the line. A pixel down puts both on one axis. */
+ translate: 0 1px;
+}
+
+.gh-ext.dark {
+ color: var(--card);
+}
+
+
+/* In the column, a mark among marks: a size down. */
+.gh-step .gh-ext {
+ min-width: 14px;
+ height: 11px;
+ padding: 0 2px;
+ border-radius: 3px;
+ font-size: 6.5px;
+}
+
+/* Badges are wider than the other rows' marks: the names still line up. */
+.gh-file .gh-row-icon {
+ width: 24px;
+ justify-items: start;
+}
+
+/* What happened to the file, in a word: grey for "modified", its colour for
+ the rest (see statusWord). */
+.gh-file-status {
+ flex: 0 0 auto;
+ font: 500 10px var(--font);
+ color: var(--dim-3);
+ white-space: nowrap;
+}
+
+/* That word, then the lines added and removed, sit at the row's right edge,
+ each in a column of its own: down the list they read like a table, instead
+ of trailing wherever each path happens to end. */
+.gh-file .gh-file-status {
+ margin-left: auto;
+ padding-left: 8px;
+ min-width: 52px;
+ text-align: right;
+}
+
+.gh-file .gh-pm {
+ padding-left: 4px;
+}
+
+.gh-file .gh-pm > span {
+ display: inline-block;
+ min-width: 4ch;
+ text-align: right;
+}
+
+.gh-file .gh-pm > .gh-add {
+ min-width: 5ch;
+}
+
+/* The panel is the editor: the code runs from one edge to the other. */
+.gh-list.gh-edge {
+ padding: 0;
+}
+
+.gh-code {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 9px 0 10px;
+}
+
+.gh-code > .gh-host {
+ align-self: flex-start;
+ margin-left: 12px;
+}
+
+.gh-code-empty {
+ padding: 10px 12px;
+}
+
+.gh-diff {
+ font: 400 10.5px/16px var(--mono);
+ color: var(--code);
+ user-select: text;
+}
+
+/* One gutter of numbers, the sign, the code. The left border is the bar a
+ changed line wears. */
+.gh-diff-line {
+ display: grid;
+ grid-template-columns: 34px 16px 1fr;
+ border-left: 2px solid transparent;
+}
+
+.gh-diff-line .n {
+ text-align: right;
+ color: var(--dim-5);
+ user-select: none;
+}
+
+.gh-diff-line .s {
+ text-align: center;
+ user-select: none;
+}
+
+.gh-diff-line .t {
+ min-width: 0;
+ padding: 0 10px 0 4px;
+ white-space: pre-wrap;
+ word-break: break-all;
+}
+
+.gh-diff-line.add {
+ background: color-mix(in srgb, var(--green-2) 10%, transparent);
+ border-left-color: var(--green-2);
+}
+
+.gh-diff-line.add .n,
+.gh-diff-line.add .s {
+ color: var(--code-added);
+}
+
+/* A line that is gone: struck through, its colours drained to the red. */
+.gh-diff-line.del {
+ background: color-mix(in srgb, var(--red) 13%, transparent);
+ border-left-color: var(--red);
+}
+
+.gh-diff-line.del .n,
+.gh-diff-line.del .s {
+ color: var(--red-text);
+}
+
+.gh-diff-line.del .t {
+ color: var(--code-gone);
+}
+
+.gh-diff-line.del .gone {
+ text-decoration: line-through;
+ text-decoration-color: color-mix(in srgb, var(--red-text) 50%, transparent);
+}
+
+/* Lines skipped: a quiet break, with where the next ones sit. */
+.gh-diff-line.hunk {
+ margin: 4px 0;
+ color: var(--dim-4);
+}
+
+.gh-diff-line.hunk .t {
+ font-style: italic;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* An editor's colours (see code.ts): keyword, string, key, number or
+ constant, call, type, comment. */
+.gh-diff .hl-k { color: var(--code-keyword); }
+.gh-diff .hl-s { color: var(--code-string); }
+.gh-diff .hl-p { color: var(--code-call); }
+.gh-diff .hl-c { color: var(--code-constant); }
+.gh-diff .hl-f { color: var(--code-call); }
+.gh-diff .hl-y { color: var(--code-type); }
+.gh-diff .hl-m { color: var(--code-comment); font-style: italic; }
+
+/* ── GitHub panel: what was said on a pull request ──────────────────────── */
+
+/* Who, what they did, when: one line over what they wrote. */
+.gh-say-head {
+ display: flex;
+ align-items: baseline;
+ gap: 5px;
+ min-width: 0;
+ font: 400 10.5px var(--font);
+ color: var(--dim-2);
+}
+
+.gh-say-head b {
+ font: 600 11.5px var(--font);
+ color: var(--ink-2);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+}
+
+.gh-say-did {
+ flex: 0 0 auto;
+ white-space: nowrap;
+}
+
+.gh-say-head .int-ago {
+ margin-left: auto;
+ padding-left: 6px;
+}
+
+/* The words as they were written: their lines kept, and free to be copied. */
+.gh-say-body {
+ font: 400 11.5px/1.45 var(--font);
+ color: var(--gh-text);
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ user-select: text;
+}
+
+.gh-say .gh-host,
+.gh-thread-talk .gh-host {
+ align-self: flex-start;
+}
+
+/* A thread on the code: the block's tile, with the lines it is about let into
+ it on the panel's own ground, like a window onto the file. */
+.gh-thread {
+ display: flex;
+ flex-direction: column;
+ flex: 0 0 auto;
+ border-radius: 10px;
+ background: var(--gh-tile);
+ overflow: hidden;
+}
+
+.gh-thread-head {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ min-width: 0;
+ padding: 7px 9px 6px 8px;
+ border: 0;
+ background: transparent;
+ text-align: left;
+ cursor: default;
+ transition: background 0.12s ease-out;
+}
+
+.gh-thread-head:hover {
+ background: var(--gh-hover);
+}
+
+/* The badge in a column of its own, as in a list of files. */
+.gh-thread-head .gh-row-icon {
+ width: 24px;
+ justify-items: start;
+}
+
+.gh-thread-head .gh-row-title {
+ font: 400 11px var(--mono);
+}
+
+.gh-thread-head .gh-file-status {
+ margin-left: auto;
+ padding-left: 8px;
+}
+
+/* The mark of a way out to GitHub, as on the panel's own button. */
+.gh-thread-out {
+ display: grid;
+ place-items: center;
+ flex: 0 0 auto;
+ color: var(--dim-3);
+}
+
+.gh-thread-head .gh-row-where + .gh-thread-out {
+ margin-left: auto;
+}
+
+.gh-thread > .gh-diff {
+ padding: 4px 0;
+ background: var(--card-flat);
+}
+
+.gh-thread-talk {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 8px 10px 9px;
+}
+
+.gh-thread-say {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ min-width: 0;
+}
+
+/* Settled: its head, who took part, and nothing more. */
+.gh-thread.resolved {
+ background: transparent;
+}
+
+.gh-thread.resolved .gh-row-title {
+ color: var(--dim-2);
+}
+
+.gh-thread-sum {
+ padding: 0 9px 6px 39px;
+ margin-top: -3px;
+ font: 400 10.5px var(--font);
+ color: var(--dim-3);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* In a file's diff: the line a thread is about wears the comments' cyan, and
+ the thread sits under it, in from the gutter. */
+.gh-diff-line.noted {
+ border-left-color: var(--cyan);
+ box-shadow: inset 0 -1px color-mix(in srgb, var(--cyan) 35%, transparent);
+}
+
+.gh-diff-note {
+ margin: 0 10px 6px 36px;
+ border-radius: 0 0 9px 9px;
+ background: var(--gh-tile);
+ user-select: none;
+}
+
+/* A thread whose line is not in the diff: over the code, not in it. */
+.gh-diff-note.apart {
+ margin: 0 10px 8px 12px;
+ border-radius: 9px;
+}
+
+.gh-diff-note-why {
+ padding: 8px 10px 0;
+ font: 400 10.5px var(--font);
+ color: var(--dim-3);
+}
+
+.gh-diff-note-why + .gh-thread-talk {
+ padding-top: 6px;
+}
+
+/* ── GitHub panel: a project's sheet ────────────────────────────────────── */
+
+.gh-sheet {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 0 0 10px;
+}
+
+/* Arriving after a wait: the parts come in one after another, on the island's
+ own entrance curve (.view.on). */
+.gh-sheet.enter > *,
+.gh-day.enter > * {
+ animation: gh-rise 0.3s cubic-bezier(0.3, 1.2, 0.4, 1) both;
+ animation-delay: calc(var(--i, 0) * 45ms);
+}
+
+@keyframes gh-rise {
+ from {
+ opacity: 0;
+ transform: translateY(6px);
+ }
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
+
+/* While Mochi looks: the shimmering line of the searching view, over ghosts
+ of the blocks to come. */
+.gh-loader {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding-top: 2px;
+}
+
+.gh-loader-text {
+ font: 500 11.5px var(--font);
+ padding: 0 8px 4px;
+}
+
+.gh-ghost {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ height: 40px;
+ padding: 0 8px;
+ border-radius: 10px;
+ background: linear-gradient(
+ 90deg,
+ rgba(255, 255, 255, 0.03) 0%,
+ rgba(255, 255, 255, 0.075) 50%,
+ rgba(255, 255, 255, 0.03) 100%
+ );
+ background-size: 220% 100%;
+ animation: gh-ghost 1.6s ease-in-out infinite;
+ animation-delay: calc(var(--i, 0) * 0.14s);
+}
+
+.gh-ghost > i {
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ flex: 0 0 auto;
+ background: color-mix(in srgb, var(--indigo) 22%, transparent);
+}
+
+.gh-ghost > div {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ flex: 1 1 auto;
+}
+
+.gh-ghost b,
+.gh-ghost span {
+ display: block;
+ height: 6px;
+ border-radius: 3px;
+ background: var(--gh-plate);
+}
+
+.gh-ghost b {
+ width: 46%;
+}
+
+.gh-ghost span {
+ width: 72%;
+}
+
+@keyframes gh-ghost {
+ from {
+ background-position: 110% 0;
+ }
+ to {
+ background-position: -110% 0;
+ }
+}
+
+.gh-desc {
+ font: 400 11.5px/1.4 var(--font);
+ color: var(--gh-text);
+ padding: 0 8px;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.gh-facts {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 3px 12px;
+ padding: 0 8px 2px;
+ font: 400 10.5px var(--font);
+ color: var(--dim-2);
+}
+
+.gh-facts span {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.gh-host {
+ border: 0;
+ padding: 0;
+ background: transparent;
+ font: 400 10.5px var(--font);
+ color: var(--dim-2);
+ text-decoration: underline dotted;
+ text-underline-offset: 2px;
+ cursor: default;
+}
+
+button.gh-host:hover {
+ color: var(--ink);
+}
+
+/* A way out to GitHub at the foot of a sheet, aligned with the rest. */
+.gh-sheet > .gh-host {
+ align-self: flex-start;
+ margin: 2px 8px 0;
+}
+
+.gh-block {
+ display: flex;
+ align-items: flex-start;
+ gap: 9px;
+ flex: 0 0 auto;
+ width: 100%;
+ padding: 7px 8px;
+ border: 0;
+ border-radius: 10px;
+ /* The same grey as a white wash over the card, but solid: a translucent tile
+ let the card's glow show through and read as lying on top of it. */
+ background: var(--gh-tile);
+ font: inherit;
+ color: inherit;
+ text-align: left;
+ cursor: default;
+ transition: background 0.12s ease-out;
+}
+
+button.gh-block:hover {
+ background: var(--gh-tile-hover);
+}
+
+.gh-block.muted {
+ background: transparent;
+ padding-top: 4px;
+ padding-bottom: 4px;
+}
+
+.gh-block-icon {
+ width: 18px;
+ height: 18px;
+ margin-top: 1px;
+ border-radius: 50%;
+ display: grid;
+ place-items: center;
+ flex: 0 0 auto;
+ background: var(--tint);
+ color: var(--c);
+}
+
+.gh-block-text {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+.gh-block-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+
+.gh-block-title > span:first-child {
+ font: 500 11.5px var(--font);
+ color: var(--ink-2);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+ flex: 1 1 auto;
+}
+
+.gh-block-right {
+ font: 400 10.5px var(--font);
+ color: var(--dim-3);
+ flex: 0 0 auto;
+}
+
+.gh-block-sub {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+ font: 400 10.5px var(--font);
+ color: var(--dim-2);
+}
+
+.gh-block-sub .txt {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+ flex: 1 1 auto;
+}
+
+.gh-chip {
+ display: inline-block;
+ font: 500 10px var(--font);
+ padding: 1px 7px;
+ border-radius: 999px;
+ color: var(--c);
+ background: var(--tint);
+}
+
+.gh-add {
+ color: var(--green-2);
+}
+
+.gh-del {
+ color: var(--red-text);
+}
+
+.gh-sha {
+ font: 400 10px var(--mono);
+}
+
+/* The last runs as a row of dots, oldest first. */
+.gh-streak {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ flex: 0 0 auto;
+}
+
+.gh-streak i {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--c);
+}
+
+.gh-streak span {
+ margin-left: 3px;
+ font: 500 10px var(--font);
+ color: var(--dim-3);
+ font-variant-numeric: tabular-nums;
+}
+
+/* ── GitHub panel: a run's timeline ─────────────────────────────────────── */
+
+/* A job or a step: its line, then its bar under the words, where it ran. */
+.gh-timed {
+ flex-wrap: wrap;
+ row-gap: 4px;
+ padding-bottom: 6px;
+}
+
+.gh-timed .gh-took {
+ color: var(--ink-2);
+ font-variant-numeric: tabular-nums;
+}
+
+/* A job's row opens with its Mochi instead of a mark: a little wider, so the
+ name and the bar under it move over by as much. */
+.gh-crew .gh-row-icon {
+ width: 20px;
+ height: 17px;
+ /* He stands beside the whole row — the name and the bar under it — not
+ beside the name alone: centred on the name he reads as riding high, all
+ the more as his badge and his hops are above him. Four pixels down is the
+ middle of the two. */
+ translate: 0 4px;
+}
+
+.gh-crew .gh-bar {
+ width: calc(100% - 27px);
+ margin-left: 27px;
+}
+
+/* Under the words, not under the mark: the bars line up with the names. */
+.gh-bar {
+ position: relative;
+ flex: 0 0 auto;
+ width: calc(100% - 21px);
+ margin-left: 21px;
+ height: 4px;
+ border-radius: 2px;
+ background: var(--gh-track);
+}
+
+.gh-bar i {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ min-width: 3px;
+ border-radius: 2px;
+ background: var(--c);
+}
+
+/* Still going: the bar breathes, like Mochi does. */
+.gh-bar i.live {
+ animation: gh-live 1.6s ease-in-out infinite;
+}
+
+@keyframes gh-live {
+ 50% { opacity: 0.5; }
+}
+
+/* Waiting for its turn: an empty ring. */
+.gh-wait {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ border: 1.6px solid currentColor;
+ opacity: 0.7;
+}
+
+.gh-langs {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 4px 8px 0;
+}
+
+.gh-lang-bar {
+ display: flex;
+ gap: 2px;
+ height: 5px;
+}
+
+.gh-lang-bar i {
+ min-width: 3px;
+ border-radius: 3px;
+}
+
+.gh-lang-legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 3px 12px;
+ font: 400 10px var(--font);
+ color: var(--dim-2);
+}
+
+.gh-lang-legend span {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+}
+
+/* On a project's row: whether the pill speaks up for it. Struck through and
+ dim when it is muted. */
+.gh-bell {
+ flex: 0 0 auto;
+ width: 18px;
+ height: 18px;
+ display: grid;
+ place-items: center;
+ border: 0;
+ border-radius: 50%;
+ background: transparent;
+ color: var(--dim-2);
+ cursor: default;
+ transition: color 0.2s ease-out, background 0.2s ease-out;
+}
+
+.gh-bell:hover {
+ background: var(--gh-plate);
+ color: var(--ink);
+}
+
+.gh-bell.off {
+ color: var(--dim-5);
+}
diff --git a/windows/src/views/code.ts b/windows/src/views/code.ts
new file mode 100644
index 0000000000..cda0c2e9d6
--- /dev/null
+++ b/windows/src/views/code.ts
@@ -0,0 +1,189 @@
+// A file as the GitHub panel shows it: its extension's badge, and its lines
+// coloured the way an editor would — keywords, strings, numbers, calls, types.
+//
+// No highlighting library: the island reads a few dozen lines of a diff, one
+// line at a time, so a small tokenizer per comment style is all it takes. It
+// knows nothing of what came on the line before (a string or a comment left
+// open), which a diff's fragments wouldn't tell it anyway.
+
+import { h } from "./dom";
+import { COLOR, isLight } from "./palette";
+
+type Comment = "slash" | "hash" | "dash";
+
+export interface FileKind {
+ /** Two or three letters for the badge. */
+ label: string;
+ /** GitHub's colour for the language. */
+ color: string;
+ comment?: Comment;
+ /** A programming language: keywords, calls and types are coloured too. */
+ words?: boolean;
+ /** `'` opens a string. Not in Rust, where it also starts a lifetime. */
+ apostrophe?: boolean;
+}
+
+const code = (label: string, color: string, more: Partial = {}): FileKind => ({
+ label, color, comment: "slash", words: true, apostrophe: true, ...more,
+});
+const script = (label: string, color: string, more: Partial = {}): FileKind => ({
+ label, color, comment: "hash", words: true, apostrophe: true, ...more,
+});
+const data = (label: string, color: string, more: Partial = {}): FileKind => ({
+ label, color, apostrophe: true, ...more,
+});
+
+const TS = code("TS", "#3178C6");
+const JS = code("JS", "#F1E05A");
+const CPP = code("C++", "#F34B7D");
+const SHELL = script("SH", "#89E051");
+const YAML = data("YML", "#CB171E", { comment: "hash" });
+const IMAGE: FileKind = { label: "IMG", color: "#A074C4" };
+
+const BY_EXTENSION: Record = {
+ ts: TS, mts: TS, cts: TS, tsx: code("TSX", "#3178C6"),
+ js: JS, mjs: JS, cjs: JS, jsx: code("JSX", "#F1E05A"),
+ rs: code("RS", "#DEA584", { apostrophe: false }),
+ go: code("GO", "#00ADD8"),
+ swift: code("SW", "#F05138"),
+ kt: code("KT", "#A97BFF"), kts: code("KT", "#A97BFF"),
+ java: code("JV", "#B07219"),
+ c: code("C", "#555555"), h: code("H", "#555555"),
+ cpp: CPP, cc: CPP, cxx: CPP, hpp: CPP,
+ cs: code("C#", "#178600"),
+ php: code("PHP", "#4F5D95"),
+ dart: code("DT", "#00B4AB"),
+ vue: code("VUE", "#41B883"),
+ svelte: code("SV", "#FF3E00"),
+ astro: code("AST", "#FF5A03"),
+ py: script("PY", "#3572A5"),
+ rb: script("RB", "#701516"),
+ sh: SHELL, bash: SHELL, zsh: SHELL,
+ ps1: script("PS", "#012456"),
+ sql: { label: "SQL", color: "#E38C00", comment: "dash", words: true, apostrophe: true },
+ lua: { label: "LUA", color: "#000080", comment: "dash", words: true, apostrophe: true },
+ css: data("CSS", "#663399", { comment: "slash" }),
+ scss: data("SCS", "#C6538C", { comment: "slash" }),
+ less: data("LES", "#1D365D", { comment: "slash" }),
+ json: data("{ }", "#CBCB41"), jsonc: data("{ }", "#CBCB41", { comment: "slash" }),
+ yml: YAML, yaml: YAML,
+ toml: data("TML", "#9C4221", { comment: "hash" }),
+ lock: data("LCK", COLOR.grey, { comment: "hash" }),
+ env: data("ENV", COLOR.grey, { comment: "hash" }),
+ ini: data("INI", COLOR.grey, { comment: "hash" }),
+ md: { label: "MD", color: "#083FA1" }, mdx: { label: "MDX", color: "#083FA1" },
+ html: { label: "HTM", color: "#E34C26" }, htm: { label: "HTM", color: "#E34C26" },
+ xml: { label: "XML", color: "#0060AC" },
+ svg: { label: "SVG", color: "#FFB13B" },
+ txt: { label: "TXT", color: COLOR.grey },
+ png: IMAGE, jpg: IMAGE, jpeg: IMAGE, gif: IMAGE, webp: IMAGE, ico: IMAGE, icns: IMAGE,
+};
+
+/** Files known by their whole name. */
+const BY_NAME: Record = {
+ dockerfile: script("DKR", "#384D54", { words: false }),
+ makefile: script("MK", "#427819", { words: false }),
+};
+
+export function fileKind(path: string): FileKind {
+ const name = path.slice(path.lastIndexOf("/") + 1).toLowerCase();
+ const named = BY_NAME[name];
+ if (named) return named;
+ const dot = name.lastIndexOf(".");
+ const extension = dot > 0 ? name.slice(dot + 1) : "";
+ // An extension nobody listed still gets its own letters, on grey.
+ return BY_EXTENSION[extension] ?? { label: extension ? extension.slice(0, 3).toUpperCase() : "·", color: COLOR.blank };
+}
+
+/** The file's kind as an editor's tab shows it: its letters on its colour. */
+export function extBadge(path: string): HTMLElement {
+ const kind = fileKind(path);
+ // On a light colour the letters go dark.
+ const el = h("span", { class: isLight(kind.color) ? "gh-ext dark" : "gh-ext", text: kind.label });
+ el.style.setProperty("--c", kind.color);
+ return el;
+}
+
+// One list for every language: a word that is a keyword in one and a name in
+// another is rare enough, and a diff line gives too little to tell which.
+const KEYWORDS = new Set(
+ (
+ "abstract and as async await break case catch class const continue crate def default defer del delete do dyn elif else " +
+ "enum except export extends extern final finally fn for from func function global go if impl implements import in " +
+ "instanceof interface is lambda let loop match mod move mut namespace new nonlocal not of or override package pass " +
+ "private protected pub public raise readonly ref return select self static struct super switch template this throw " +
+ "trait try type typedef typeof union unsafe use using var virtual void where while with yield"
+ ).split(" "),
+);
+
+/** Values spelled as words — coloured in data files too. */
+const LITERALS = new Set(["true", "false", "null", "undefined", "nil", "None", "True", "False", "NaN"]);
+
+const tokenizers = new Map();
+
+/** comment | string | number | word, in that order of precedence. */
+function tokenizer(kind: FileKind): RegExp {
+ const key = `${kind.comment ?? ""}|${kind.apostrophe === false ? "" : "'"}`;
+ let re = tokenizers.get(key);
+ if (!re) {
+ const comment =
+ kind.comment === "slash"
+ ? String.raw`\/\/.*$|\/\*.*?(?:\*\/|$)`
+ : kind.comment === "hash"
+ ? // Only at the start of a line or after a space: a # inside a URL is not a comment.
+ String.raw`(?:^|(?<=\s))#.*$`
+ : kind.comment === "dash"
+ ? String.raw`--.*$`
+ : "(?!)";
+ const quoted = [String.raw`"(?:[^"\\]|\\.)*"`, "`(?:[^`\\\\]|\\\\.)*`"];
+ if (kind.apostrophe !== false) quoted.push(String.raw`'(?:[^'\\]|\\.)*'`);
+ const number = String.raw`\b(?:0[xX][0-9a-fA-F_]+|\d[\d_]*(?:\.\d+)?(?:[eE][+-]?\d+)?)\b`;
+ re = new RegExp(`(${comment})|(${quoted.join("|")})|(${number})|([A-Za-z_$][\\w$]*)`, "g");
+ tokenizers.set(key, re);
+ }
+ re.lastIndex = 0;
+ return re;
+}
+
+/** What colour a word takes, if any. */
+function wordClass(word: string, kind: FileKind, next: string): string | null {
+ if (LITERALS.has(word)) return "hl-k";
+ if (!kind.words) return null;
+ if (KEYWORDS.has(word)) return "hl-k";
+ // CONSTANTS read as values; Capitalised names as types; a name before "(" as a call.
+ if (/^[A-Z][A-Z0-9_]+$/.test(word)) return "hl-c";
+ if (/^[A-Z]/.test(word)) return "hl-y";
+ if (next === "(") return "hl-f";
+ return null;
+}
+
+/** One line of code as coloured pieces. A file of no known kind stays plain. */
+export function highlight(line: string, kind: FileKind): Node[] {
+ if (kind.comment == null && !kind.words && kind.apostrophe == null) return [document.createTextNode(line)];
+ const out: Node[] = [];
+ const re = tokenizer(kind);
+ let at = 0;
+ const plain = (to: number) => {
+ if (to > at) out.push(document.createTextNode(line.slice(at, to)));
+ };
+ for (let m = re.exec(line); m; m = re.exec(line)) {
+ const [text, comment, string, number, word] = m;
+ let cls: string | null = null;
+ if (comment != null) cls = "hl-m";
+ else if (string != null) {
+ // A string before a colon is a key: JSON, YAML, an object literal.
+ cls = /^\s*:/.test(line.slice(m.index + text.length)) ? "hl-p" : "hl-s";
+ } else if (number != null) cls = "hl-c";
+ else if (word != null) cls = wordClass(word, kind, line.charAt(m.index + text.length));
+ if (cls) {
+ plain(m.index);
+ out.push(h("span", { class: cls, text }));
+ at = m.index + text.length;
+ }
+ // An empty match (the hash comment's lookbehind can't produce one, but a
+ // pattern that could would loop forever here).
+ if (text.length === 0) re.lastIndex += 1;
+ }
+ plain(line.length);
+ return out;
+}
diff --git a/windows/src/views/github.ts b/windows/src/views/github.ts
new file mode 100644
index 0000000000..479148c5e6
--- /dev/null
+++ b/windows/src/views/github.ts
@@ -0,0 +1,2698 @@
+// The GitHub panel — the view behind the `…` of the GitHub card.
+//
+// Windows only for now, with no macOS view to port from, so it is built from the
+// island's own pieces — the card, the rows, the dots, Mochi's state colours —
+// rather than from GitHub's look.
+
+import { h, svg, clear, dot } from "./dom";
+import { extBadge, fileKind, highlight, type FileKind } from "./code";
+import { ICONS } from "./icons";
+import { COLOR, wear } from "./palette";
+import { ACTIVITY_STYLE, GITHUB_LEVELS, compact, githubData, repoName, timeAgo, type GithubOpening } from "./integrations";
+import {
+ Bridge,
+ type GithubActivity, type GithubBuild, type GithubCommentsDetail, type GithubCommitsDetail, type GithubContributions,
+ type GithubData, type GithubDay, type GithubDeploy, type GithubDetail, type GithubEntry, type GithubFile,
+ type GithubIssueDetail, type GithubJob, type GithubLabel, type GithubProject, type GithubPull, type GithubPullDetail,
+ type GithubReleaseDetail, type GithubRemark, type GithubRepo, type GithubReviewRequest, type GithubRunDetail, type GithubTarget,
+ type GithubThread, type GithubTimed, type IntegrationNews,
+} from "../core/bridge";
+import type { BotEmoteName, BotStateName } from "../core/layout";
+import { hexToRGB, type BotEngine } from "../mochi/engine";
+import { createFreeBot, pruneMiniBots } from "../mochi/minibots";
+import { Sound } from "../core/sound";
+import { State } from "../core/state";
+import type { ViewActions, ViewHost } from "./views";
+
+const ID = "integration_github";
+/** Where a screen leads when it has no page of its own to give. */
+const GITHUB_HOME = "https://github.com";
+/** Opening the panel refetches first when what it holds is older than this. */
+const STALE_MS = 60_000;
+/** A refresh that comes back instantly still turns the arrow once. */
+const MIN_SPIN_MS = 500;
+
+type Tab = "activity" | "projects";
+/** Kept across openings: the panel comes back on the tab it was left on. */
+let tab: Tab = "activity";
+
+/** Same colours as the pill badges: green check, red cross, amber for "going". */
+const BUILD_STYLE: Record = {
+ success: { color: COLOR.pass, label: "passed" },
+ failure: { color: COLOR.red, label: "failed" },
+ running: { color: COLOR.amber, label: "running" },
+ neutral: { color: COLOR.grey, label: "stopped" },
+};
+
+/** "comment" or "comments", by how many there are. */
+const plural = (n: number, one: string, many = `${one}s`) => (n === 1 ? one : many);
+
+/** "1 comment", "3 comments". */
+const counted = (n: number, one: string, many?: string) => `${n} ${plural(n, one, many)}`;
+
+/** Numbers an element's parts (--i), so each takes its turn when they come in one by one. */
+function cascade(el: HTMLElement) {
+ Array.from(el.children).forEach((child, i) => (child as HTMLElement).style.setProperty("--i", String(i)));
+}
+
+/**
+ * Something the panel fetches on a click — a project's sheet, a day of the
+ * graph — and how that fetch is going.
+ */
+interface Pending {
+ data: T | null;
+ error: string | null;
+ loading: boolean;
+ /** The loader is up — only for a fetch slow enough to notice. */
+ waiting: boolean;
+ /** Arrived after a visible wait: the next draw makes an entrance of it. */
+ react: boolean;
+}
+
+function pending(): Pending {
+ return { data: null, error: null, loading: false, waiting: false, react: false };
+}
+
+/** A project's sheet. */
+interface ProjectScreen extends Pending