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 + +The GitHub card in the overview: the year's contributions, the last seven days, the latest activity + +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. + +The GitHub panel on its Activity tab: the contribution graph and the latest events + +| 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 | + +A project's sheet: its build, a pull request, its deployment +A pull request whose checks are running, one mini Mochi per job +A review thread shown on the line of the diff it is about + +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 + +The island opened on a merged pull request, with Open and OK + +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. + +The Activity tab with two pull requests waiting for your review above the recent activity + +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: GitHub switched on, its token, the permissions to grant and the connection test + +**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