diff --git a/windows/README.md b/windows/README.md index 60374024c..6235df50c 100644 --- a/windows/README.md +++ b/windows/README.md @@ -66,7 +66,95 @@ exits cleanly if the app is closed, slow or crashed — **a Claude Code session never blocked or slowed down by Coucou.** If nobody answers a permission request in time, Coucou stays quiet and Claude Code asks in the terminal as usual. -It works from any terminal — Windows Terminal, PowerShell, VS Code, Git Bash. +It works wherever Claude Code runs — the Claude desktop app, VS Code, Windows +Terminal, PowerShell, Git Bash. The Claude pill takes the name of the app the +session is in, and its ↗ brings that app forward. + +### Watching a session + +A session's card: the step under way with its icon and its file, and the lines it read + +A session shows on its card, as the prototype draws it: the conversation's +title, the step under way — its icon, its name, the file or the command it is at +— and under it a look at what the step did: the lines of the file it read, the +diff of its edit, the command it ran with the end of what that printed. Once the +turn is over the card says **Done** and shows the first lines of what Claude +replied. + +The session panel: the session's steps on the left, its journal on the right, an edit being typed + +Click the card for the **session panel**. On the left, Mochi's column: the +session's name and its last steps, each going, done or failed. On the right, the +session's **journal**, read like a conversation, in the order things happened: + +| A line of the journal | What it shows | +|---|---| +| What you asked | Your words, as you sent them | +| A file read | Its first lines, numbered | +| An edit | Its diff, the old line struck and the new ones typed under it | +| A command | The command, then the end of what it printed | +| A search | What it found | +| A question from Claude | Its options, and the ones that were picked — on the island or in Claude Code | +| A tool that had to ask first | **needs permission**, then **allowed** or **denied** | +| The end of the turn | What Claude said, as it wrote it — also behind **Read reply** on the finished card | + +The journal follows the session while you are at its end, and stays where you +scrolled otherwise. **N files**, at the top right, lists every file the session +changed, each with its whole diff. + +The journal further up: a question with the option picked, a command that was allowed + +An edit is shown the moment Claude Code reports it, so the typing is a replay of +it, about a second behind. Nothing changes at a stroke: a new line rises into +place, a state's colour fades to the next. The island only watches: to write to +Claude, there is Claude Code. + +### Several sessions at once + +The list of sessions: the one on show, one at work in a terminal, one that has finished + +Two conversations in the Claude app, one more in a terminal: each is followed on +its own, and one of them is in front — the one the card, the panel and Mochi +show. With more than one, the session's card and the panel's head say how many +there are: that chip opens the list of them — each with its name, its project +and where it is at: at work, waiting for you, finished — and a click puts one in +front. The chip takes a colour when a session behind the one on show wants +looking at. + +The island stays on the session it shows for as long as that one is at work or +being looked at. Another one takes its place when the first has nothing going +on, or to ask for something. When two ask at once, the second waits its turn: +the card says how many are waiting, and the next comes forward once the first is +answered. Up to four sessions are followed; past that, the one at rest and heard +from longest ago gives its place. Sessions started by a program rather than a +person — a review run by a plugin, a script using the SDK — are left alone. + +### Answering from the island + +A question from Claude, each option with what it means +A permission request for an edit, with the diff it would make + +- **A question** Claude asks with its question tool opens on the island with + its options, each with what it means. Pick one — or several, then **Send** — + type your own with **Other…**, **Skip** it, or hand it back with **Answer in + Claude**. The session goes on exactly as if you had answered in Claude Code. +- **A permission request for an edit** shows the diff the edit would make before + you click **Allow**. + +### What it reads + +Everything comes from the hooks Claude Code already sends — nothing is asked of +Anthropic, and nothing leaves your machine. Of what a tool gives back, the relay +forwards a few lines and no more: the diff of an edit, the first lines of a file +that was read, the last lines a command printed, the names a search found, the +answers to a question. A session's journal is kept in memory and empties as it +fills — its last 80 lines, the oldest going for each new one — and is gone with +the session. Two +things are read from disk by the relay, and only then: the file an edit asks permission for (to show its diff — +it is never written), and the end of the session's transcript at the start and +end of a turn (for the conversation's title and Claude's last message). That +transcript's format is Claude Code's own: if it changes, the title and the last +message simply stop showing. None of it is written to the log. ## Chat and keys @@ -77,6 +165,128 @@ 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 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 + +Three things make Mochi speak up, with a sound and a card: **a build of yours +breaks**, **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 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 | +| 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. + +## Spotify + +Spotify's card: the cover, the song, who plays it, the album, and the player's keys + +A pill that needs no key and no account. Switch it on in **Settings… → +Integrations**, like the others — four pills at most show next to Mochi. + +Its card shows the cover of what plays, the song, who plays it and the album it +is from, with previous, play/pause and next. A title too long for its line runs +across it. The cover stands where Mochi does on the other cards: he steps out +of this one. + +The folded island with the cover in Mochi's place + +With Spotify's pill in front, the folded island shows the cover too, in Mochi's +place, and unfolding carries it to the card. + +A new song being announced: a green glow behind the cover + +When the song changes, the island unfolds on that card for a few seconds and +folds back, under a light that says why it opened: a green glow behind the +cover, and a sheen across it. No sound — there is music playing. It never opens +over something that waits for you, nor while the island is already open, and a +mouse on the island keeps it there. **Settings… → Integrations → Spotify** has a +switch to keep it folded. + +### Where it comes from + +Windows already knows what every player plays — it is what the volume flyout +shows — and says when it changes. The pill asks Windows: the song, the artist, +the album, the cover, whether it plays or is paused. Nothing is asked of +Spotify and nothing touches the network; the keys go to Spotify's own session, +never to another player. + +That is also where it stops: + +- Only the Spotify desktop app is seen. Spotify in a browser tab is the + browser's. +- Until something has been played since the app was opened, Windows has nothing + to say about it, and neither has the card. +- Windows is told one artist and the album. Featured artists show only when + the song's title names them, and the playlist a song is played from is not + something a player tells the system. + +The idea of a keyless Spotify pill comes from corefusiion's pull request on the +main repository (Louis-CFM/coucou#80). + ## Build it yourself You need [Rust](https://rustup.rs), [Node 20+](https://nodejs.org), and the @@ -91,9 +301,17 @@ 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. +- `dev/claude-preview.html` plays a made-up Claude Code session through the + island's own hook handler: a file being written, a question, a permission + request with its diff, the end of a turn, several sessions at once. `npm run pack` leaves two files in `windows/release/`, the same names the release workflow publishes: @@ -142,10 +360,15 @@ problems. It stays on your machine. the top edge instead of hiding in a notch. - Permission approval works from **any** terminal; the Mac build only listens to VS Code sessions. +- The [session panel](#watching-a-session), answering Claude's questions and the + diff on a permission request are Windows-only for now. - Not in this version: sending a file by email, dragging Mochi onto a window to 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. +- The [Spotify](#spotify) pill is Windows-only for now. +- 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/claude-preview.html b/windows/dev/claude-preview.html new file mode 100644 index 000000000..c0b99b351 --- /dev/null +++ b/windows/dev/claude-preview.html @@ -0,0 +1,32 @@ + + + + + Coucou — Claude Code session preview + + + +
+ + + + diff --git a/windows/dev/claude-preview.ts b/windows/dev/claude-preview.ts new file mode 100644 index 000000000..ba9b1eb6e --- /dev/null +++ b/windows/dev/claude-preview.ts @@ -0,0 +1,265 @@ +// Dev harness: the real island following a made-up Claude Code session, in a +// plain browser — the hook events a session sends, played through the island's +// own handler. Not part of the app bundle. `npm run dev`, then +// /dev/claude-preview.html. + +import "../src/style.css"; +import { CLAUDE_ID, State } from "../src/core/state"; +import { handleHook, type HookPayload } from "../src/island/hooks"; +import { handleIntegration } from "../src/island/integrations"; +import type { IntegrationUpdate } from "../src/core/bridge"; +import { Island } from "../src/island/island"; + +const params = new URLSearchParams(location.search); +const view = params.get("view") ?? "overview"; + +const SESSION = "3f2a9c1e-preview"; +const CWD = "C:\\Users\\mochi\\code\\coucou"; +const base: HookPayload = { + session_id: SESSION, cwd: CWD, entrypoint: "claude-desktop", session_title: "Answer questions from the island", +}; + +State.loadIntegrationTasks(); +State.setFocus(CLAUDE_ID); +// Pinned, so the island doesn't fold away while it's being looked at. +State.isPinned = true; + +const island = new Island(document.getElementById("root")!); +const hook = (payload: HookPayload) => handleHook(island, { ...base, ...payload }); +// From the console: `hook({ hook_event_name: "Stop" })` plays any event by hand, +// `island.launch()` the greeting, `integration({ id, data, error: null, event: null })` +// an integration's update, and `State` is the island's own. +const integration = (update: IntegrationUpdate) => handleIntegration(island, update); +Object.assign(window, { hook, island, State, integration }); + +const edit = (file: string, patch: string, created = false) => { + const lines = patch.split("\n"); + const tool_name = created ? "Write" : "Edit"; + const tool_input = { file_path: `${CWD}\\${file}` }; + hook({ hook_event_name: "PreToolUse", tool_name, tool_input }); + hook({ + hook_event_name: "PostToolUse", tool_name, tool_input, + change: { + patch: `${patch}\n`, created, truncated: false, + additions: lines.filter((l) => l.startsWith("+")).length, + deletions: lines.filter((l) => l.startsWith("-")).length, + }, + }); +}; + +hook({ hook_event_name: "SessionStart" }); +hook({ hook_event_name: "UserPromptSubmit", prompt: "Answer Claude's questions from the island" }); +edit("windows\\src\\island\\hooks.ts", [ + "@@ -228,9 +228,14 @@", + ' const tool = payload.tool_name ?? "Tool";', + " const input = payload.tool_input ?? {};", + "- State.pendingApproval = {", + "- requestId,", + '- sessionId: payload.session_id ?? "",', + "- };", + '+ const sessionId = payload.session_id ?? "";', + "+ // Claude's question tool asks for permission like any other.", + "+ const questions = tool === QUESTION_TOOL ? questionsOf(input) : null;", + "+ if (questions) State.pendingQuestion = { requestId, sessionId, questions };", + "+ else State.pendingApproval = { requestId, sessionId, tool, command: approvalTarget(tool, input) };", + " if (requestId) void Bridge.approvalAck(requestId);", +].join("\n")); +edit("windows\\src\\views\\session.ts", [ + "@@ -0,0 +1,6 @@", + "+// The session panel — what a Claude Code session did, read from the island.", + "+", + '+import { h, svg, clear, dot } from "./dom";', + "+", + "+/** The file whose diff is on screen; null is the list. */", + "+let open: string | null = null;", +].join("\n"), true); +edit("windows\\hook\\src\\main.rs", [ + "@@ -66,8 +66,10 @@ fn main() {", + '- let waits_for_answer = event == "PermissionRequest";', + "+ // Only a permission request waits for a human.", + '+ let waits_for_answer = matches!(event.as_str(), "PermissionRequest" | "Stop");', + ' let budget = if event == "PermissionRequest" { DECISION_BUDGET } else { FIRE_AND_FORGET_BUDGET };', +].join("\n")); +edit("windows\\src\\island\\hooks.ts", [ + "@@ -176,6 +181,8 @@", + ' case "PostToolUse":', + "+ if (answeredElsewhere(payload)) dropPending(island);", + "+ recordChange(payload);", + ' State.updateTask(CLAUDE_ID, "working");', + " break;", +].join("\n")); +edit("windows\\README.md", ["@@ -40,3 +40,4 @@", " ## Claude Code", "+Answer Claude's questions from the island.", " "].join("\n")); +const TESTS = [ + "running 12 tests", + "test tests::an_edit_becomes_a_diff_with_its_line_numbers ... ok", + "test tests::a_command_shows_the_end_of_what_it_printed ... ok", + "test tests::a_file_read_shows_its_first_lines_with_their_numbers ... ok", + "", + "test result: ok. 12 passed; 0 failed; finished in 0.01s", +].join("\n"); +const run = (command: string, printed: string | null) => { + const tool_input = { command }; + hook({ hook_event_name: "PreToolUse", tool_name: "Bash", tool_input }); + if (printed != null) { + hook({ hook_event_name: "PostToolUse", tool_name: "Bash", tool_input, result: { text: printed, start: null, truncated: false, tail: true } }); + } +}; +const read = (file: string, start: number, text: string) => { + const tool_input = { file_path: `${CWD}\\${file}` }; + hook({ hook_event_name: "PreToolUse", tool_name: "Read", tool_input }); + hook({ hook_event_name: "PostToolUse", tool_name: "Read", tool_input, result: { text, start, truncated: true, tail: false } }); +}; +// `step`: where the session is at — reading, running its tests, or (the default) done running them. +const at = params.get("step"); +if (at === "read") { + read("windows\\src\\island\\hooks.ts", 96, [ + "/** The session an event comes from, told what the event says of it. */", + "function sessionOf(island: Island, payload: HookPayload): ClaudeSession {", + " const id = payload.session_id || ANONYMOUS;", + " let session = State.sessions.find((s) => s.id === id);", + " if (!session) {", + " session = newSession(id);", + ].join("\n")); +} else { + run("cargo test -p coucou-hook", at === "run" ? null : TESTS); +} + +// `sessions`: two more conversations going on behind the first — one at work +// in a terminal, one that has just finished in the Claude app. +const second: HookPayload = { + session_id: "8b1d04e7-preview", cwd: "C:\\Users\\mochi\\code\\atlas", entrypoint: "cli", session_title: "Course search", +}; +const third: HookPayload = { + session_id: "c47e9a02-preview", cwd: CWD, entrypoint: "claude-desktop", session_title: "Fix the blurry island", +}; +if (params.has("sessions")) { + hook({ ...second, hook_event_name: "UserPromptSubmit", prompt: "Add a search to the course list" }); + hook({ ...second, hook_event_name: "PreToolUse", tool_name: "Grep", tool_input: { pattern: "courses" } }); + hook({ ...third, hook_event_name: "UserPromptSubmit", prompt: "The island is blurry while it resizes" }); + hook({ ...third, hook_event_name: "Stop", last_message: "The island now stays on a whole pixel while it resizes." }); +} + +const option = (label: string, description: string) => ({ label, description }); +const engine = { + question: "Which engine for the course search?", + header: "Search", + multiSelect: params.has("multi"), + options: [ + option("Postgres full-text", "Already in the stack: no new service, good enough for a few thousand courses."), + option("Meilisearch", "Typo-tolerant and fast, one more container to run."), + option("Algolia", "Hosted, the best relevance out of the box, paid past the free tier."), + ], +}; +const more = [ + { + question: "Index the course descriptions too, or only the titles?", header: "Scope", multiSelect: false, + options: [option("Titles only", "Smaller index, exact matches."), option("Titles and descriptions", "Finds more, ranks titles first.")], + }, + { + question: "Ship it behind a flag?", header: "Rollout", multiSelect: false, + options: [option("Yes", "Off by default, switched on per account."), option("No", "On for everyone at the next release.")], + }, +]; + +// `asked`: earlier in the turn, a question that was answered and a command that had to ask first. +if (params.has("asked")) { + const ask = { tool_name: "AskUserQuestion", tool_input: { questions: [more[0]] } }; + hook({ hook_event_name: "PreToolUse", ...ask }); + hook({ hook_event_name: "PostToolUse", ...ask, answers: { [more[0].question]: "Titles and descriptions" } }); + const push = { tool_name: "Bash", tool_input: { command: "git push origin windows-claude-desktop" } }; + hook({ hook_event_name: "PreToolUse", ...push }); + hook({ hook_event_name: "PermissionRequest", request_id: "preview-0", ...push }); + hook({ hook_event_name: "PostToolUse", ...push, result: { text: ["To github.com:mochi/coucou.git", " 11bd082..848ca62 windows-claude-desktop -> windows-claude-desktop"].join("\n"), start: null, truncated: false, tail: true } }); + hook({ hook_event_name: "SubagentStart" }); +} + +if (view === "question") { + hook({ hook_event_name: "PreToolUse", tool_name: "AskUserQuestion", tool_input: { questions: params.has("many") ? [engine, ...more] : [engine] } }); + hook({ + hook_event_name: "PermissionRequest", request_id: "preview-1", tool_name: "AskUserQuestion", + tool_input: { questions: params.has("many") ? [engine, ...more] : [engine] }, + }); + // A second session asks while the first one's question is on the card: it waits its turn. + if (params.has("sessions")) { + hook({ ...second, hook_event_name: "PermissionRequest", request_id: "preview-3", tool_name: "AskUserQuestion", tool_input: { questions: [more[0]] } }); + } +} else if (view === "approval") { + hook(params.has("diff") + ? { + hook_event_name: "PermissionRequest", request_id: "preview-2", tool_name: "Edit", + tool_input: { file_path: `${CWD}\\windows\\src\\core\\layout.ts` }, + proposal: { + created: false, truncated: false, additions: 5, deletions: 1, + patch: [ + "@@ -118,9 +118,13 @@", + " export const NEWS_LINE = 28;", + " ", + " export function islandSize(", + " mode: IslandMode,", + " view: IslandViewName,", + " chatCount = 0,", + "- news = false,", + "+ news = false,", + "+ proposal = false,", + " ): { w: number; h: number } {", + " switch (mode) {", + ' case "hidden":', + "+ // No notch to hide inside on a PC.", + "+ return { w: NOTCH_W, h: 0 };", + '+ case "compact":', + ].join("\n"), + }, + } + : { hook_event_name: "PermissionRequest", request_id: "preview-2", tool_name: "Bash", tool_input: { command: "cargo test -p coucou-hook" } }); +} else if (view === "finished" || params.has("answered")) { + // The turn ends on what Claude said. + hook({ + hook_event_name: "Stop", + last_message: [ + "The question card now answers for real: the answer goes back on the permission request, in the tool's own input.", + "## What changed", + "- **Question card**: answers go back in the tool's own `answers`\n- Skip tells Claude the question was *skipped*\n and it carries on without", + "| Function | State |\n|---|---|\n| One choice | Checked |\n| Several choices | Checked, with `Send` |", + "```rust\nfn reply_json(answer: &str) -> Option {\n decision_json(answer.trim())\n}\n```", + "Do you want the README updated too?", + ].join("\n\n"), + }); + if (view === "session") island.alert("session"); +} else if (view === "session") { + if (params.has("idle")) { + State.session.state = "idle"; + State.present(); + } + island.alert("session"); + if (params.has("list")) { + requestAnimationFrame(() => { + document.querySelector(".session-view .sess-files")?.click(); + if (params.has("file")) requestAnimationFrame(() => document.querySelector(".session-view .gh-row")?.click()); + }); + } + // `live`: an edit lands while the panel is open, again every few seconds. + if (params.has("live")) { + const write = () => { + hook({ hook_event_name: "PreToolUse", tool_name: "Read", tool_input: { file_path: `${CWD}\\src\\invoice.ts` } }); + hook({ hook_event_name: "PostToolUse", tool_name: "Read", tool_input: { file_path: `${CWD}\\src\\invoice.ts` } }); + edit("src\\invoice.ts", [ + "@@ -10,8 +10,9 @@", + " import { Item } from './types'", + " ", + "-const TVA = 0.196", + "+// The rate changed in 2014.", + "+const TVA = 0.2", + " ", + " export function total(items: Item[]) {", + " const sum = items.reduce((s, i) => s + i.price, 0)", + " return sum * (1 + TVA)", + " }", + ].join("\n")); + hook({ hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: { command: "npm test" } }); + }; + window.setTimeout(write, 1200); + window.setInterval(write, 6000); + } +} else { + island.alert("overview"); +} diff --git a/windows/dev/github-preview.html b/windows/dev/github-preview.html new file mode 100644 index 000000000..46031306c --- /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 000000000..d19e2cbe3 --- /dev/null +++ b/windows/dev/github-preview.ts @@ -0,0 +1,427 @@ +// 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, + 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` 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" }] } } + : { 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/hook/src/main.rs b/windows/hook/src/main.rs index 5bb363781..75f56d5f3 100644 --- a/windows/hook/src/main.rs +++ b/windows/hook/src/main.rs @@ -10,9 +10,10 @@ //! * Every step runs under a deadline enforced by the main thread, so a pipe that //! accepts the connection and then stops reading cannot wedge the session //! either: we abandon the worker and exit. -//! * Only `PermissionRequest` waits for an answer, because approving from the -//! island is the whole point. No answer means empty stdout, and Claude Code -//! asks in the terminal exactly as if Coucou were not installed. +//! * Only `PermissionRequest` waits for a human, because approving from the +//! island — or answering Claude's question there — is the whole point. No +//! answer means empty stdout, and Claude Code asks in its own window exactly +//! as if Coucou were not installed. //! //! Usage: `coucou-hook ` (the name is also read from the JSON). @@ -28,8 +29,36 @@ const FIRE_AND_FORGET_BUDGET: Duration = Duration::from_secs(2); const DECISION_BUDGET: Duration = Duration::from_secs(110); /// Fields that are pointless to forward and can be enormous (a whole file read, -/// a full command output). The island never shows them. +/// a full command output). The island shows a few lines of them at most, and +/// those are taken out first. const DROPPED_FIELDS: &[&str] = &["tool_response", "transcript_path"]; +/// The tools that leave a file changed. What they did to it is the one part of +/// a tool's response the island shows. +const EDIT_TOOLS: &[&str] = &["Edit", "Write", "MultiEdit", "NotebookEdit"]; +/// The tools that run a command: what it printed is what they have to show. +const COMMAND_TOOLS: &[&str] = &["Bash", "PowerShell"]; +/// Lines of a tool's result forwarded — the end of what a command printed, the +/// start of a file that was read — and the longest of them, in characters. +const MAX_RESULT_LINES: usize = 40; +const MAX_RESULT_LINE: usize = 240; +/// The tool Claude asks its questions with. Its input goes back whole, with the +/// answers added, so it is kept as it came. +const QUESTION_TOOL: &str = "AskUserQuestion"; +/// Longest diff forwarded for one change, and longest line in it. +const MAX_PATCH: usize = 12_000; +const MAX_PATCH_LINE: usize = 400; +/// Largest file read to show what an edit would do to it. +const MAX_PROPOSAL_FILE: u64 = 2_000_000; +/// Unchanged lines kept on each side of a proposed change. +const CONTEXT_LINES: usize = 3; +/// How much of a transcript's end is read — for the conversation's title, and +/// for what Claude said last — and the events worth reading it on: a turn's +/// ends, not each of its tools. +const TRANSCRIPT_TAIL: u64 = 256 * 1024; +const TRANSCRIPT_EVENTS: &[&str] = &["SessionStart", "UserPromptSubmit", "Stop", "PermissionRequest"]; +const MAX_TITLE: usize = 80; +/// Longest piece of Claude's last message forwarded with a Stop, in characters. +const MAX_LAST_MESSAGE: usize = 6_000; /// Longest string forwarded for any single field; the island truncates to far /// less than this anyway. const MAX_FIELD_LEN: usize = 2_000; @@ -45,8 +74,9 @@ mod unix; use unix::connect; fn main() { - let Some((payload, event)) = read_event() else { std::process::exit(0) }; + let Some(Event { line: payload, name: event, tool_input }) = read_event() else { std::process::exit(0) }; + // Only a permission request waits for a human, so only it gets the long budget. let waits_for_answer = event == "PermissionRequest"; let budget = if waits_for_answer { DECISION_BUDGET } else { FIRE_AND_FORGET_BUDGET }; @@ -59,8 +89,8 @@ fn main() { let _ = tx.send(talk(&payload, waits_for_answer)); }); - if let Ok(Some(decision)) = rx.recv_timeout(budget) { - if let Some(json) = decision_json(&decision) { + if let Ok(Some(answer)) = rx.recv_timeout(budget) { + if let Some(json) = reply_json(&answer, tool_input.as_ref()) { let mut out = std::io::stdout(); let _ = writeln!(out, "{json}"); let _ = out.flush(); @@ -79,6 +109,9 @@ fn decision_json(decision: &str) -> Option { // business, not Claude Code's. "allow" | "always" => r#"{"behavior":"allow"}"#.to_string(), "deny" => r#"{"behavior":"deny","message":"Denied from Coucou"}"#.to_string(), + // A question the user chose not to answer: Claude is told so, and decides + // what to do without the answer. + "skip" => r#"{"behavior":"deny","message":"The user skipped this question from Coucou, without answering it."}"#.to_string(), _ => return None, }; Some(format!( @@ -86,8 +119,320 @@ fn decision_json(decision: &str) -> Option { )) } +/// What Coucou said about a permission request, as the JSON Claude Code +/// expects. Besides the bare `allow` and `deny`, one line carries something: +/// `answer {…}`, for Claude's question tool — the answers picked on the island, +/// keyed by question. +fn reply_json(answer: &str, tool_input: Option<&serde_json::Value>) -> Option { + let answer = answer.trim(); + match answer.strip_prefix("answer ") { + Some(answers) => answered_json(answers, tool_input?), + None => decision_json(answer), + } +} + +/// Allows the question tool with its input as Claude sent it plus `answers`: +/// the tool then returns them as if they had been picked in Claude Code. +fn answered_json(answers: &str, tool_input: &serde_json::Value) -> Option { + let answers = serde_json::from_str::(answers).ok()?; + if !answers.is_object() { + return None; + } + let mut input = tool_input.as_object()?.clone(); + input.insert("answers".into(), answers); + let input = serde_json::Value::Object(input); + Some(format!( + r#"{{"hookSpecificOutput":{{"hookEventName":"PermissionRequest","decision":{{"behavior":"allow","updatedInput":{input}}}}}}}"# + )) +} + +/// A unified diff being written: capped in length, its lines counted whole. +#[derive(Default)] +struct Patch { + text: String, + additions: u64, + deletions: u64, + truncated: bool, +} + +impl Patch { + fn hunk(&mut self, old_start: usize, old_lines: usize, new_start: usize, new_lines: usize) { + self.text.push_str(&format!("@@ -{old_start},{old_lines} +{new_start},{new_lines} @@\n")); + } + + /// One line with its sign. Past the cap it is still counted, no longer kept. + fn push(&mut self, sign: char, line: &str) { + match sign { + '+' => self.additions += 1, + '-' => self.deletions += 1, + _ => {} + } + if self.truncated || self.text.len() + line.len() > MAX_PATCH { + self.truncated = true; + return; + } + let mut end = line.len().min(MAX_PATCH_LINE); + while !line.is_char_boundary(end) { + end -= 1; + } + self.text.push(sign); + self.text.push_str(&line[..end]); + self.text.push('\n'); + } + + fn json(self, created: bool) -> serde_json::Value { + serde_json::json!({ + "patch": self.text, "additions": self.additions, "deletions": self.deletions, + "truncated": self.truncated, "created": created, + }) + } +} + +/// The unified diff of what an edit tool did, from the hunks Claude Code puts in +/// its response — with the lines' real numbers, which the tool's input has not. +/// A file written new has no hunk: its content is the diff. +fn change_of(response: &serde_json::Value) -> Option { + let mut patch = Patch::default(); + let hunks = response.get("structuredPatch").and_then(|v| v.as_array()); + let created = response.get("type").and_then(|v| v.as_str()) == Some("create"); + match hunks { + Some(hunks) if !hunks.is_empty() => { + for hunk in hunks { + let n = |key: &str| hunk.get(key).and_then(|v| v.as_u64()).unwrap_or(0) as usize; + patch.hunk(n("oldStart"), n("oldLines"), n("newStart"), n("newLines")); + for line in hunk.get("lines").and_then(|v| v.as_array()).into_iter().flatten() { + let line = line.as_str().unwrap_or_default(); + let mut chars = line.chars(); + patch.push(chars.next().unwrap_or(' '), chars.as_str()); + } + } + } + _ if created => { + let content = response.get("content").and_then(|v| v.as_str())?; + patch.hunk(0, 0, 1, content.lines().count()); + for line in content.lines() { + patch.push('+', line); + } + } + _ => return None, + } + Some(patch.json(created)) +} + +/// A few lines of what a tool gave back, for the island to show under the +/// step: the end of what a command printed, the start of a file that was read, +/// what a search found. Nothing else of a tool's response leaves the relay. +fn result_of(tool: &str, response: &serde_json::Value) -> Option { + let text = |key: &str| response.get(key).and_then(|v| v.as_str()).unwrap_or_default(); + let names = || { + let files = response.get("filenames").and_then(|v| v.as_array()); + files.into_iter().flatten().filter_map(|v| v.as_str()).collect::>().join("\n") + }; + match tool { + _ if COMMAND_TOOLS.contains(&tool) => { + let printed = [text("stdout"), text("stderr")].iter().filter(|s| !s.trim().is_empty()).cloned().collect::>().join("\n"); + excerpt(&plain(&printed), None, true) + } + "Read" => { + let file = response.get("file")?; + let start = file.get("startLine").and_then(|v| v.as_u64()).unwrap_or(1); + excerpt(file.get("content")?.as_str()?, Some(start), false) + } + "Grep" if !text("content").trim().is_empty() => excerpt(text("content"), None, false), + "Grep" | "Glob" => excerpt(&names(), None, false), + _ => None, + } +} + +/// The first lines of a text, or its last when `tail` — what a command ends +/// on is what it has to say. `start` is the number of the first line, for a file. +fn excerpt(text: &str, start: Option, tail: bool) -> Option { + let lines: Vec<&str> = text.trim_end().lines().collect(); + if lines.iter().all(|line| line.trim().is_empty()) { + return None; + } + let truncated = lines.len() > MAX_RESULT_LINES; + let kept = if tail { &lines[lines.len().saturating_sub(MAX_RESULT_LINES)..] } else { &lines[..lines.len().min(MAX_RESULT_LINES)] }; + let kept: Vec = kept.iter().map(|line| clip(line.trim_end(), MAX_RESULT_LINE)).collect(); + Some(serde_json::json!({ "text": kept.join("\n"), "start": start, "truncated": truncated, "tail": tail })) +} + +/// What a command printed without the escape sequences that colour it in a terminal. +fn plain(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + if c != '\u{1b}' { + out.push(c); + continue; + } + // ESC [ … up to the letter that ends the sequence; a lone ESC just goes. + if chars.peek() == Some(&'[') { + chars.next(); + for c in chars.by_ref() { + if ('@'..='~').contains(&c) { + break; + } + } + } + } + out +} + +/// The file as Claude Code reads it: text, with Unix line ends. None when it +/// is not there, or too large to be worth showing a diff of. +fn read_text(path: &str) -> Option { + let size = std::fs::metadata(path).ok()?.len(); + if size > MAX_PROPOSAL_FILE { + return None; + } + let bytes = std::fs::read(path).ok()?; + Some(String::from_utf8_lossy(&bytes).replace("\r\n", "\n")) +} + +/// One edit applied to a text, the way the Edit tool applies it. +fn apply(text: &str, edit: &serde_json::Value) -> Option { + let old = edit.get("old_string")?.as_str()?; + let new = edit.get("new_string")?.as_str()?; + if old.is_empty() || !text.contains(old) { + return None; + } + let all = edit.get("replace_all").and_then(|v| v.as_bool()).unwrap_or(false); + Some(if all { text.replace(old, new) } else { text.replacen(old, new, 1) }) +} + +/// What changes between two texts, as one hunk: the lines they share at both +/// ends are left out but for a few of context, and everything between is old +/// then new. Not the shortest diff there is — the honest one a relay can afford. +fn diff_of(before: &str, after: &str, created: bool) -> Option { + let a: Vec<&str> = before.lines().collect(); + let b: Vec<&str> = after.lines().collect(); + let mut head = 0; + while head < a.len() && head < b.len() && a[head] == b[head] { + head += 1; + } + let mut tail = 0; + while tail < a.len() - head && tail < b.len() - head && a[a.len() - 1 - tail] == b[b.len() - 1 - tail] { + tail += 1; + } + let (a_end, b_end) = (a.len() - tail, b.len() - tail); + if head == a_end && head == b_end { + return None; + } + let from = head.saturating_sub(CONTEXT_LINES); + let after_lines = tail.min(CONTEXT_LINES); + let (old_lines, new_lines) = (a_end - from + after_lines, b_end - from + after_lines); + let mut patch = Patch::default(); + // A side with no line starts at 0, as `diff` writes it. + patch.hunk(if old_lines == 0 { 0 } else { from + 1 }, old_lines, if new_lines == 0 { 0 } else { from + 1 }, new_lines); + a[from..head].iter().for_each(|l| patch.push(' ', l)); + a[head..a_end].iter().for_each(|l| patch.push('-', l)); + b[head..b_end].iter().for_each(|l| patch.push('+', l)); + a[a_end..a_end + after_lines].iter().for_each(|l| patch.push(' ', l)); + Some(patch.json(created)) +} + +/// What an edit asking for permission would do to its file: the file as it is, +/// the edit applied to a copy, and the diff between the two. Nothing is written. +fn proposal_of(tool: &str, input: &serde_json::Value) -> Option { + let path = input.get("file_path")?.as_str()?; + let before = read_text(path); + let exists = std::path::Path::new(path).exists(); + let after = match tool { + "Write" if before.is_some() || !exists => input.get("content")?.as_str()?.replace("\r\n", "\n"), + "Edit" => apply(before.as_deref()?, input)?, + "MultiEdit" => input + .get("edits")? + .as_array()? + .iter() + .try_fold(before.clone()?, |text, edit| apply(&text, edit))?, + _ => return None, + }; + diff_of(before.as_deref().unwrap_or_default(), &after, !exists) +} + +/// The conversation's title, as Claude Code last wrote it in the session's +/// transcript. That file's format is Claude Code's own and may change: whatever +/// goes wrong here, there is simply no title. Only the file's end is read — a +/// long session's transcript runs to hundreds of megabytes. +fn title_of(tail: &str) -> Option { + tail.lines().rev().find_map(title_in) +} + +/// The end of a session's transcript, as text. +fn tail_of(transcript: &str) -> Option { + use std::io::{Seek, SeekFrom}; + let mut file = std::fs::File::open(transcript).ok()?; + let size = file.metadata().ok()?.len(); + file.seek(SeekFrom::Start(size.saturating_sub(TRANSCRIPT_TAIL))).ok()?; + let mut tail = Vec::new(); + file.read_to_end(&mut tail).ok()?; + Some(String::from_utf8_lossy(&tail).into_owned()) +} + +/// What Claude said last, from the transcript's end: the text of its last +/// message that has any. Read the same forgiving way as the title. +fn last_message_of(tail: &str) -> Option { + tail.lines().rev().find_map(said_in) +} + +/// The text of an assistant's line of the transcript, if it says anything. +fn said_in(line: &str) -> Option { + if !line.contains("\"assistant\"") { + return None; + } + let entry = serde_json::from_str::(line).ok()?; + if entry.get("type")?.as_str()? != "assistant" { + return None; + } + let text = entry + .pointer("/message/content")? + .as_array()? + .iter() + .filter(|block| block.get("type").and_then(|v| v.as_str()) == Some("text")) + .filter_map(|block| block.get("text")?.as_str()) + .collect::>() + .join("\n\n"); + let text = text.trim(); + (!text.is_empty()).then(|| clip(text, MAX_LAST_MESSAGE)) +} + +/// The first `max` characters, with a mark when some were left out. +fn clip(text: &str, max: usize) -> String { + let mut out: String = text.chars().take(max).collect(); + if text.chars().nth(max).is_some() { + out.push('…'); + } + out +} + +/// The title a transcript line carries, if it is one that names the conversation. +fn title_in(line: &str) -> Option { + if !line.contains("-title\"") && !line.contains("\"summary\"") { + return None; + } + let entry = serde_json::from_str::(line).ok()?; + let title = match entry.get("type")?.as_str()? { + "custom-title" => entry.get("customTitle"), + "ai-title" => entry.get("aiTitle"), + "summary" => entry.get("summary"), + _ => None, + }? + .as_str()? + .trim(); + (!title.is_empty()).then(|| title.chars().take(MAX_TITLE).collect()) +} + +/// One hook event, ready to go: the line for Coucou, its name, and — for +/// Claude's question tool only — its input exactly as it came. +struct Event { + line: String, + name: String, + tool_input: Option, +} + /// Reads stdin and returns the payload to forward plus the event name. -fn read_event() -> Option<(String, String)> { +fn read_event() -> Option { let mut raw = Vec::new(); if std::io::stdin().read_to_end(&mut raw).is_err() || raw.is_empty() { return None; @@ -128,6 +473,47 @@ fn read_event() -> Option<(String, String)> { .unwrap_or(arg_event); map.insert("hook_event_name".into(), serde_json::Value::String(event.clone())); + let tool = map.get("tool_name").and_then(|v| v.as_str()).unwrap_or_default().to_string(); + // Before anything is cut or dropped: what an edit did, and a question whole. + let change = (event == "PostToolUse" && EDIT_TOOLS.contains(&tool.as_str())) + .then(|| map.get("tool_response").and_then(change_of)) + .flatten(); + // A few lines of what the tool gave back, to show under its step. + let result = (event == "PostToolUse") + .then(|| map.get("tool_response").and_then(|response| result_of(&tool, response))) + .flatten(); + let tool_input = (tool == QUESTION_TOOL).then(|| map.get("tool_input").cloned()).flatten(); + // A question that got its answers, wherever they were picked: the island's + // journal shows them under the question. + let answers = (event == "PostToolUse" && tool == QUESTION_TOOL) + .then(|| map.get("tool_response").and_then(|response| response.get("answers")).filter(|v| v.is_object()).cloned()) + .flatten(); + if let Some(answers) = answers { + map.insert("answers".into(), answers); + } + // An edit asking for permission: what it would do, to look at before allowing. + let proposal = (event == "PermissionRequest" && EDIT_TOOLS.contains(&tool.as_str())) + .then(|| map.get("tool_input").and_then(|input| proposal_of(&tool, input))) + .flatten(); + + let tail = TRANSCRIPT_EVENTS + .contains(&event.as_str()) + .then(|| map.get("transcript_path").and_then(|v| v.as_str()).and_then(tail_of)) + .flatten(); + let title = tail.as_deref().and_then(title_of); + // A turn ends: what Claude said to end it. Claude Code hands it over when + // it can; the transcript has it otherwise. + let last_message = (event == "Stop") + .then(|| { + map.get("last_assistant_message") + .and_then(|v| v.as_str()) + .map(|text| clip(text.trim(), MAX_LAST_MESSAGE)) + .filter(|text| !text.is_empty()) + .or_else(|| tail.as_deref().and_then(last_message_of)) + }) + .flatten(); + map.remove("last_assistant_message"); + for field in DROPPED_FIELDS { map.remove(*field); } @@ -154,6 +540,8 @@ fn read_event() -> Option<(String, String)> { ("term_session_id", "TERM_SESSION_ID"), ("vscode_pid", "VSCODE_PID"), ("session_pid", "CLAUDE_CODE_SSE_PORT"), + // Which Claude Code this is: the desktop app, VS Code, the command line. + ("entrypoint", "CLAUDE_CODE_ENTRYPOINT"), ] { if !map.contains_key(key) { let value = std::env::var(var).unwrap_or_default(); @@ -162,10 +550,26 @@ fn read_event() -> Option<(String, String)> { } truncate_strings(&mut payload); + // After the cut: a diff is already capped, and far longer than a field. + if let Some(change) = change { + payload["change"] = change; + } + if let Some(proposal) = proposal { + payload["proposal"] = proposal; + } + if let Some(result) = result { + payload["result"] = result; + } + if let Some(title) = title { + payload["session_title"] = serde_json::Value::String(title); + } + if let Some(text) = last_message { + payload["last_message"] = serde_json::Value::String(text); + } let mut line = payload.to_string(); line.push('\n'); - Some((line, event)) + Some(Event { line, name: event, tool_input }) } /// Caps every string in the payload. A single Write can carry a whole file. @@ -235,6 +639,9 @@ mod tests { ); // "always" is an island concept; Claude Code just gets an allow. assert!(decision_json("always").unwrap().contains(r#""behavior":"allow""#)); + // A skipped question is a denial that says what it is. + let skipped = decision_json("skip").unwrap(); + assert!(skipped.contains(r#""behavior":"deny""#) && skipped.contains("skipped this question")); } #[test] @@ -245,6 +652,170 @@ mod tests { assert!(decision_json(r#"{"permissionDecision":"allow"}"#).is_none()); } + #[test] + fn an_answer_goes_back_with_the_question_it_answers() { + let input = serde_json::json!({ "questions": [{ "question": "Which engine?" }] }); + let out = reply_json(r#"answer {"Which engine?":"Postgres"}"#, Some(&input)).unwrap(); + let v: serde_json::Value = serde_json::from_str(&out).unwrap(); + let decision = &v["hookSpecificOutput"]["decision"]; + assert_eq!(v["hookSpecificOutput"]["hookEventName"], "PermissionRequest"); + assert_eq!(decision["behavior"], "allow"); + assert_eq!(decision["updatedInput"]["questions"][0]["question"], "Which engine?"); + assert_eq!(decision["updatedInput"]["answers"]["Which engine?"], "Postgres"); + // Without the question there is nothing to answer; a plain word still works. + assert!(reply_json(r#"answer {"a":"b"}"#, None).is_none()); + assert!(reply_json("answer nonsense", Some(&input)).is_none()); + assert_eq!(reply_json("deny", None), decision_json("deny")); + } + + #[test] + fn an_edit_becomes_a_diff_with_its_line_numbers() { + let response = serde_json::json!({ + "filePath": "a.rs", + "structuredPatch": [{ + "oldStart": 2, "oldLines": 3, "newStart": 2, "newLines": 4, + "lines": [" mod claude;", "-mod files;", "+mod files;", "+mod github;", " mod hooks;"], + }], + }); + let change = change_of(&response).unwrap(); + assert_eq!( + change["patch"], + "@@ -2,3 +2,4 @@\n mod claude;\n-mod files;\n+mod files;\n+mod github;\n mod hooks;\n" + ); + assert_eq!(change["additions"], 2); + assert_eq!(change["deletions"], 1); + assert_eq!(change["created"], false); + assert_eq!(change["truncated"], false); + } + + #[test] + fn a_new_file_is_all_additions_and_a_long_one_is_cut() { + let created = serde_json::json!({ "type": "create", "content": "one\ntwo\n", "structuredPatch": [] }); + let change = change_of(&created).unwrap(); + assert_eq!(change["patch"], "@@ -0,0 +1,2 @@\n+one\n+two\n"); + assert_eq!(change["created"], true); + + let long = serde_json::json!({ "type": "create", "content": "line of text\n".repeat(5000), "structuredPatch": [] }); + let change = change_of(&long).unwrap(); + assert!(change["patch"].as_str().unwrap().len() <= MAX_PATCH + 32); + assert_eq!(change["truncated"], true); + // Counted whole, even where the diff itself was cut. + assert_eq!(change["additions"], 5000); + + // A response with nothing to show — a read, a failed edit — is no change. + assert!(change_of(&serde_json::json!({ "structuredPatch": [] })).is_none()); + assert!(change_of(&serde_json::json!("done")).is_none()); + } + + #[test] + fn a_proposed_edit_is_shown_where_it_lands() { + let before = "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\n"; + let edit = serde_json::json!({ "old_string": "five", "new_string": "5\nfive and a half" }); + let after = apply(before, &edit).unwrap(); + let diff = diff_of(before, &after, false).unwrap(); + assert_eq!( + diff["patch"], + "@@ -2,7 +2,8 @@\n two\n three\n four\n-five\n+5\n+five and a half\n six\n seven\n eight\n" + ); + assert_eq!(diff["additions"], 2); + assert_eq!(diff["deletions"], 1); + // Text that is not in the file cannot be replaced: no proposal, the card stays plain. + assert!(apply(before, &serde_json::json!({ "old_string": "nine", "new_string": "9" })).is_none()); + // Nothing changed is nothing to show. + assert!(diff_of(before, before, false).is_none()); + } + + #[test] + fn a_proposed_edit_is_read_from_the_file_and_never_written() { + let path = std::env::temp_dir().join(format!("coucou-hook-test-{}.txt", std::process::id())); + std::fs::write(&path, "alpha\r\nbeta\r\ngamma\r\n").unwrap(); + let file = path.to_string_lossy().to_string(); + + let edit = serde_json::json!({ "file_path": file, "old_string": "beta", "new_string": "BETA" }); + let proposal = proposal_of("Edit", &edit).unwrap(); + assert_eq!(proposal["patch"], "@@ -1,3 +1,3 @@\n alpha\n-beta\n+BETA\n gamma\n"); + assert_eq!(proposal["created"], false); + + let write = serde_json::json!({ "file_path": file, "content": "alpha\nbeta\ngamma\ndelta\n" }); + assert_eq!(proposal_of("Write", &write).unwrap()["patch"], "@@ -1,3 +1,4 @@\n alpha\n beta\n gamma\n+delta\n"); + + assert_eq!(std::fs::read_to_string(&path).unwrap(), "alpha\r\nbeta\r\ngamma\r\n"); + std::fs::remove_file(&path).unwrap(); + + // The file is gone now: writing it is creating it. + let created = proposal_of("Write", &write).unwrap(); + assert_eq!(created["patch"], "@@ -0,0 +1,4 @@\n+alpha\n+beta\n+gamma\n+delta\n"); + assert_eq!(created["created"], true); + assert!(proposal_of("Edit", &edit).is_none()); + } + + #[test] + fn the_title_is_the_last_one_the_transcript_names() { + assert_eq!( + title_in(r#"{"type":"custom-title","customTitle":" Panneau GitHub ","sessionId":"x"}"#).as_deref(), + Some("Panneau GitHub") + ); + assert_eq!(title_in(r#"{"type":"summary","summary":"Fix the hook","leafUuid":"y"}"#).as_deref(), Some("Fix the hook")); + // A message that merely talks about titles is not one. + assert!(title_in(r#"{"type":"user","message":{"content":"the custom-title\" line"}}"#).is_none()); + assert!(title_in("not json -title\"").is_none()); + + let path = std::env::temp_dir().join(format!("coucou-hook-title-{}.jsonl", std::process::id())); + std::fs::write( + &path, + "{\"type\":\"custom-title\",\"customTitle\":\"First\"}\n{\"type\":\"user\"}\n{\"type\":\"custom-title\",\"customTitle\":\"Second\"}\n{\"type\":\"assistant\"}\n", + ) + .unwrap(); + let tail = tail_of(&path.to_string_lossy()).unwrap(); + assert_eq!(title_of(&tail).as_deref(), Some("Second")); + std::fs::remove_file(&path).unwrap(); + assert!(tail_of(&path.to_string_lossy()).is_none()); + } + + #[test] + fn what_claude_said_last_is_its_last_message_with_words() { + let tail = [ + r#"{"type":"assistant","message":{"content":[{"type":"text","text":"First answer."}]}}"#, + r#"{"type":"user","message":{"content":"and the \"assistant\" said?"}}"#, + r#"{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"hm"},{"type":"text","text":"Done."},{"type":"text","text":"Two files changed."}]}}"#, + r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{}}]}}"#, + r#"{"type":"custom-title","customTitle":"A title"}"#, + ] + .join("\n"); + assert_eq!(last_message_of(&tail).as_deref(), Some("Done.\n\nTwo files changed.")); + assert!(last_message_of(r#"{"type":"user","message":{"content":"hello"}}"#).is_none()); + assert_eq!(clip("héllo", 3), "hél…"); + assert_eq!(clip("hey", 3), "hey"); + } + + #[test] + fn a_command_shows_the_end_of_what_it_printed_without_its_colours() { + let printed = (1..=60).map(|i| format!("line {i}")).collect::>().join("\n"); + let response = serde_json::json!({ "stdout": format!("{printed}\n\u{1b}[32mPASS\u{1b}[0m tests\n"), "stderr": "" }); + let result = result_of("Bash", &response).unwrap(); + let text = result["text"].as_str().unwrap(); + assert_eq!(text.lines().count(), MAX_RESULT_LINES); + assert!(text.ends_with("PASS tests")); + assert!(!text.contains("line 21\n")); + assert_eq!(result["truncated"], true); + assert_eq!(result["tail"], true); + // A command that printed nothing has nothing to show. + assert!(result_of("PowerShell", &serde_json::json!({ "stdout": "\n", "stderr": "" })).is_none()); + } + + #[test] + fn a_file_read_shows_its_first_lines_with_their_numbers() { + let response = serde_json::json!({ "type": "text", "file": { "content": "fn a() {}\nfn b() {}\n", "startLine": 12 } }); + let result = result_of("Read", &response).unwrap(); + assert_eq!(result["text"], "fn a() {}\nfn b() {}"); + assert_eq!(result["start"], 12); + assert_eq!(result["tail"], false); + // A search shows what it found; a tool with nothing to show, nothing. + let found = result_of("Glob", &serde_json::json!({ "filenames": ["a.rs", "b.rs"] })).unwrap(); + assert_eq!(found["text"], "a.rs\nb.rs"); + assert!(result_of("WebFetch", &serde_json::json!({ "result": "…" })).is_none()); + } + #[test] fn long_strings_are_cut_on_a_char_boundary() { let mut v = serde_json::json!({ "tool_input": { "content": "é".repeat(4000) } }); diff --git a/windows/screenshots/claude-approval.png b/windows/screenshots/claude-approval.png new file mode 100644 index 000000000..9c8097ee2 Binary files /dev/null and b/windows/screenshots/claude-approval.png differ diff --git a/windows/screenshots/claude-card.png b/windows/screenshots/claude-card.png new file mode 100644 index 000000000..6f9dc5f27 Binary files /dev/null and b/windows/screenshots/claude-card.png differ diff --git a/windows/screenshots/claude-journal.png b/windows/screenshots/claude-journal.png new file mode 100644 index 000000000..1a423f81e Binary files /dev/null and b/windows/screenshots/claude-journal.png differ diff --git a/windows/screenshots/claude-question.png b/windows/screenshots/claude-question.png new file mode 100644 index 000000000..310f930f0 Binary files /dev/null and b/windows/screenshots/claude-question.png differ diff --git a/windows/screenshots/claude-session.png b/windows/screenshots/claude-session.png new file mode 100644 index 000000000..3c7833631 Binary files /dev/null and b/windows/screenshots/claude-session.png differ diff --git a/windows/screenshots/claude-sessions.png b/windows/screenshots/claude-sessions.png new file mode 100644 index 000000000..a1d38e5bd Binary files /dev/null and b/windows/screenshots/claude-sessions.png differ diff --git a/windows/screenshots/github-activity.png b/windows/screenshots/github-activity.png new file mode 100644 index 000000000..c89ec404f 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 000000000..7de441f80 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 000000000..44bcb5200 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 000000000..a909b871b Binary files /dev/null and b/windows/screenshots/github-project.png differ diff --git a/windows/screenshots/github-run.png b/windows/screenshots/github-run.png new file mode 100644 index 000000000..6f4d60374 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 000000000..d9d29db48 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 000000000..7c47e3d37 Binary files /dev/null and b/windows/screenshots/github-thread.png differ diff --git a/windows/screenshots/spotify-announce.png b/windows/screenshots/spotify-announce.png new file mode 100644 index 000000000..4bf252e29 Binary files /dev/null and b/windows/screenshots/spotify-announce.png differ diff --git a/windows/screenshots/spotify-card.png b/windows/screenshots/spotify-card.png new file mode 100644 index 000000000..23a7561b9 Binary files /dev/null and b/windows/screenshots/spotify-card.png differ diff --git a/windows/screenshots/spotify-folded.png b/windows/screenshots/spotify-folded.png new file mode 100644 index 000000000..9dfad2fa2 Binary files /dev/null and b/windows/screenshots/spotify-folded.png differ diff --git a/windows/src-tauri/Cargo.toml b/windows/src-tauri/Cargo.toml index f581867d9..0458680ff 100644 --- a/windows/src-tauri/Cargo.toml +++ b/windows/src-tauri/Cargo.toml @@ -25,6 +25,10 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus [target.'cfg(windows)'.dependencies] keyring = { version = "3", features = ["windows-native"] } windows = { version = "0.61", features = [ + "Foundation", + "Foundation_Collections", + "Media_Control", + "Storage_Streams", "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", diff --git a/windows/src-tauri/src/github.rs b/windows/src-tauri/src/github.rs new file mode 100644 index 000000000..e84b8b839 --- /dev/null +++ b/windows/src-tauri/src/github.rs @@ -0,0 +1,2310 @@ +// 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, + /// 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, +} + +/// 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, +} + +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() +} + +/// 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)) +} + +/// 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)) => { + log::line(format!( + "github refresh: {} events, {} projects, {} with a build, {} contribution days", + 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()), + )); + 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)) + .or_else(|| went_in(cache.merged.as_deref(), &merged)) + .or_else(|| came_in(cache.opened.as_deref(), &opened, &snapshot.login)); + 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; +/// 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!) { 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 } } } } } \ + 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), GhError> { + let gh = Gh::from_store()?; + let sizes = json!({ + "stars": STAR_REPOS, "owned": MAX_REPOS, "contributed": CONTRIBUTED_REPOS, + "merged": MERGED_PULLS, "opened": OPENED_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); + 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, + fetched_at: unix_now() * 1000, + }, merged, opened)) +} + +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(), + 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()); + } + + 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 000000000..c2574a7d8 --- /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