From b95de61fd3f2c24c522bc1ffca0d437940e27eef Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 09:48:17 +0000 Subject: [PATCH 1/4] feat(reports): add plugin and agent reports beside the diff A Reports view (gr) shows documents attached to the snapshot: generators diffle runs when a changed file matches their globs, with copies of both sides and the review on stdin, and reports pushed over PUT /api/reports by an agent or `diffle report put`. Blocks are sandboxed markdown or rendered page pairs shown with the image comparison; an anchored block opens the diff at its line and takes a comment there. Pushed reports persist under the git dir and go stale when the diff moves on. Built-in rows for pixi.lock and Typst resolve from PATH; `diffle plugins` lists them, and the config's `plugins` adds or turns one off. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XY9UWfvKQAyZeDnYaZnihy --- README.md | 41 ++ crates/diffle/protocol.generated.json | 447 +++++++++++++++++- crates/diffle/src/cli/args.rs | 7 + crates/diffle/src/cli/mod.rs | 4 + crates/diffle/src/cli/plugins.rs | 45 ++ crates/diffle/src/cli/report.rs | 262 ++++++++++ crates/diffle/src/cli/serve.rs | 12 +- crates/diffle/src/error.rs | 3 + crates/diffle/src/lib.rs | 1 + crates/diffle/src/reports/catalog.rs | 244 ++++++++++ crates/diffle/src/reports/exec.rs | 121 +++++ crates/diffle/src/reports/generated.rs | 129 +++++ crates/diffle/src/reports/input.rs | 260 ++++++++++ crates/diffle/src/reports/mod.rs | 221 +++++++++ crates/diffle/src/reports/pushed.rs | 84 ++++ crates/diffle/src/reports/runner.rs | 217 +++++++++ crates/diffle/src/reports/sides.rs | 71 +++ crates/diffle/src/reports/store.rs | 170 +++++++ crates/diffle/src/server/mod.rs | 8 +- crates/diffle/src/server/routes/mod.rs | 10 +- crates/diffle/src/server/routes/reports.rs | 74 +++ crates/diffle/src/user_config.rs | 20 +- crates/diffle/tests/cli.rs | 40 ++ crates/diffle/tests/common/server.rs | 39 +- crates/diffle/tests/reports.rs | 247 ++++++++++ crates/diffle/tests/server.rs | 1 + crates/diffle/tests/snapshots/cli__help.snap | 2 + example/plugins/diffle-typst | 77 +++ src/client/App.tsx | 5 + src/client/api.ts | 9 + src/client/keyboard/HelpOverlay.tsx | 1 + src/client/keyboard/useKeymap.ts | 1 + src/client/reports/Blocks.tsx | 117 +++++ src/client/reports/ReportsPanel.tsx | 58 +++ src/client/reports/ReportsView.tsx | 105 ++++ src/client/reports/help.ts | 35 ++ src/client/reports/keys.ts | 40 ++ src/client/reports/state.ts | 147 ++++++ src/client/reports/view.ts | 24 + src/client/review/ImageDiff.tsx | 14 +- src/client/views/registry.ts | 3 +- src/client/views/state.ts | 2 +- src/shared/protocol.ts | 169 +++++++ test/client/reports.test.ts | 103 ++++ test/client/store.test.ts | 4 + .../reports.spec.ts/generator.png | Bin 0 -> 146648 bytes .../reports.spec.ts/list.aria.yml | 4 + .../reports.spec.ts/pushed.aria.yml | 10 + .../__snapshots__/reports.spec.ts/pushed.png | Bin 0 -> 96078 bytes test/e2e/fixtures.ts | 7 +- test/e2e/reports.spec.ts | 82 ++++ test/e2e/server.ts | 9 +- test/reports/fake-generator.mjs | 56 +++ 53 files changed, 3842 insertions(+), 20 deletions(-) create mode 100644 crates/diffle/src/cli/plugins.rs create mode 100644 crates/diffle/src/cli/report.rs create mode 100644 crates/diffle/src/reports/catalog.rs create mode 100644 crates/diffle/src/reports/exec.rs create mode 100644 crates/diffle/src/reports/generated.rs create mode 100644 crates/diffle/src/reports/input.rs create mode 100644 crates/diffle/src/reports/mod.rs create mode 100644 crates/diffle/src/reports/pushed.rs create mode 100644 crates/diffle/src/reports/runner.rs create mode 100644 crates/diffle/src/reports/sides.rs create mode 100644 crates/diffle/src/reports/store.rs create mode 100644 crates/diffle/src/server/routes/reports.rs create mode 100644 crates/diffle/tests/reports.rs create mode 100755 example/plugins/diffle-typst create mode 100644 src/client/reports/Blocks.tsx create mode 100644 src/client/reports/ReportsPanel.tsx create mode 100644 src/client/reports/ReportsView.tsx create mode 100644 src/client/reports/help.ts create mode 100644 src/client/reports/keys.ts create mode 100644 src/client/reports/state.ts create mode 100644 src/client/reports/view.ts create mode 100644 test/client/reports.test.ts create mode 100644 test/e2e/__snapshots__/reports.spec.ts/generator.png create mode 100644 test/e2e/__snapshots__/reports.spec.ts/list.aria.yml create mode 100644 test/e2e/__snapshots__/reports.spec.ts/pushed.aria.yml create mode 100644 test/e2e/__snapshots__/reports.spec.ts/pushed.png create mode 100644 test/e2e/reports.spec.ts create mode 100644 test/reports/fake-generator.mjs diff --git a/README.md b/README.md index 80d266ca..a136687d 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,47 @@ returns to the graph as you left it. The graph needs the language servers, the same ones [Jump to definitions](#jump-to-definitions) uses. +## Add reports from plugins and agents + +Press `gr` for the **Reports** view: documents attached to the diff, beside it rather than in it. +They come from two places. + +A **plugin** is a program diffle runs whenever a changed file matches its globs, with copies of +both sides to read and the review as JSON on stdin. Two are built in and start on their own when +their programs are on `PATH`: `pixi` runs [`pixi-diff`](https://github.com/pavelzw/pixi-diff) and +[`pixi-diff-to-markdown`](https://github.com/pavelzw/pixi-diff-to-markdown) on a changed +`pixi.lock`, and `typst` runs a `diffle-typst` on changed `.typ` files to compare the rendered +pages. `diffle plugins` lists what a review would run and what is missing. Add your own, or +replace a built-in, in the config file (`diffle config` prints its path): + +```json +{ + "plugins": { + "lockfile": { "command": "my-lock-report $DIFFLE_OLD/$DIFFLE_FILE $DIFFLE_NEW/$DIFFLE_FILE", "when": ["*.lock"] }, + "typst": { "command": "" } + } +} +``` + +The command runs without a shell: a `|` pipes one program into the next, and `$DIFFLE_OLD`, +`$DIFFLE_NEW`, `$DIFFLE_OUT`, `$DIFFLE_ROOT` and `$DIFFLE_FILE` are substituted. A program that +writes `report.json` into `$DIFFLE_OUT` makes the whole report, rendered pages included; one that +only prints gets its stdout shown as markdown. `diffle report put --help` shows the JSON. +Plugins come from your config alone, never from the reviewed tree, so a pull request cannot +bring its own. + +An **agent**, or you, pushes a report while diffle runs: + +```bash +diffle report put notes.md --anchor src/app.py:42 # one markdown block, pointing at a line +diffle report put review.json # a whole report, pages and all +``` + +Over HTTP it is `PUT api/reports/` with the same JSON; `/diffle/server.json` +names the url while diffle runs. Reports persist with the review and go stale, not away, when +the diff moves on. A block that points at a line opens the diff there with `Enter`, and `c` +comments on it, so a report's findings become ordinary comments, in the prompt and on GitHub. + ## Open from lazygit [lazygit](https://github.com/jesseduffield/lazygit) custom commands open diffle on what is diff --git a/crates/diffle/protocol.generated.json b/crates/diffle/protocol.generated.json index 6f49db93..24ad5da4 100644 --- a/crates/diffle/protocol.generated.json +++ b/crates/diffle/protocol.generated.json @@ -1681,6 +1681,34 @@ ], "description": "Lines of one side of a snapshot to link to on the forge origin points at." }, + "PluginConfig": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The program and its arguments, split as a shell would, without a shell: a `|` between words\npipes one program's stdout into the next, and `$DIFFLE_OLD`, `$DIFFLE_NEW`, `$DIFFLE_OUT`,\n`$DIFFLE_ROOT` and `$DIFFLE_FILE` are substituted in arguments. Empty turns a built-in plugin off." + }, + "when": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Globs (picomatch syntax) of changed files that start it; a built-in plugin keeps its own when absent." + }, + "scope": { + "type": "string", + "enum": [ + "changed", + "tree" + ], + "description": "What the program gets copies of: the matched changed files, or every file the snapshot shows. Default changed." + } + }, + "required": [ + "command" + ], + "description": "A program that makes a report from a snapshot. It runs whenever a changed file matches `when`,\nwith copies of the files in DIFFLE_OLD and DIFFLE_NEW, a JSON description of the review on\nstdin, and DIFFLE_ROOT, DIFFLE_OUT in its environment. A `report.json` it writes into DIFFLE_OUT\nis the report, its files named relative to that directory; otherwise its stdout is one markdown\nblock. A command naming $DIFFLE_FILE runs once per matched file, with that path in the variable." + }, "PullRef": { "type": "object", "properties": { @@ -2014,6 +2042,400 @@ "body" ] }, + "Report": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Lowercase letters, digits and dashes; a generator's is its plugin name." + }, + "source": { + "$ref": "#/$defs/ReportSource" + }, + "title": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "target": { + "$ref": "#/$defs/ReportTarget" + }, + "status": { + "$ref": "#/$defs/ReportStatus" + }, + "error": { + "type": [ + "string", + "null" + ], + "description": "The failure in one line: a program not on PATH, a nonzero exit, a report that did not validate." + }, + "stale": { + "type": "boolean", + "description": "The snapshot moved away from `target`: the blocks describe an older diff." + }, + "updatedAt": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "description": "Milliseconds since the epoch." + }, + "blocks": { + "type": "array", + "items": { + "$ref": "#/$defs/ReportBlock" + } + } + }, + "required": [ + "id", + "source", + "title", + "summary", + "target", + "status", + "error", + "stale", + "updatedAt", + "blocks" + ] + }, + "ReportAnchor": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "side": { + "$ref": "#/$defs/Side", + "description": "Default 'new'." + }, + "line": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "description": "1-based." + }, + "endLine": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "description": "Default `line`." + } + }, + "required": [ + "path", + "line" + ], + "description": "A place in the diff a report block points at: `c` on the block comments there. The new side\nunless `side` says old. A path the snapshot does not show is dropped when the report is served." + }, + "ReportBlock": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "markdown" + }, + "text": { + "type": "string" + }, + "anchor": { + "$ref": "#/$defs/ReportAnchor" + } + }, + "required": [ + "kind", + "text" + ], + "description": "GitHub-flavored markdown, rendered as a comment body is: raw HTML stays literal and images are dropped." + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "pages" + }, + "label": { + "type": "string" + }, + "pages": { + "type": "array", + "items": { + "$ref": "#/$defs/ReportPage" + } + } + }, + "required": [ + "kind", + "pages" + ] + } + ], + "description": "A report's blocks as the client gets them: every file resolved to a key." + }, + "ReportBlockInput": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "markdown" + }, + "text": { + "type": "string" + }, + "anchor": { + "$ref": "#/$defs/ReportAnchor" + } + }, + "required": [ + "kind", + "text" + ], + "description": "GitHub-flavored markdown, rendered as a comment body is: raw HTML stays literal and images are dropped." + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "pages" + }, + "label": { + "type": "string", + "description": "What the pages are, e.g. \"Rendered, removals struck through\"." + }, + "pages": { + "type": "array", + "items": { + "$ref": "#/$defs/ReportPageInput" + } + } + }, + "required": [ + "kind", + "pages" + ] + } + ], + "description": "A report's blocks as a generator or an agent writes them." + }, + "ReportFile": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string", + "description": "The media type its bytes were sniffed as; only raster images are ever served." + } + }, + "required": [ + "key", + "type" + ], + "description": "A file a report refers to, as the client fetches it: `GET /api/reports/{id}/file?key=`.\nThe key names fixed bytes, so what it serves may be cached for good." + }, + "ReportFileQuery": { + "type": "object", + "properties": { + "key": { + "type": "string" + } + }, + "required": [ + "key" + ] + }, + "ReportInput": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "summary": { + "type": "string", + "description": "One line for the list; the first line of the first markdown block otherwise." + }, + "target": { + "$ref": "#/$defs/ReportTarget" + }, + "blocks": { + "type": "array", + "items": { + "$ref": "#/$defs/ReportBlockInput" + } + } + }, + "required": [ + "title", + "blocks" + ], + "description": "`PUT /api/reports/{id}`: a report as authored. Files it names must be uploaded first, with\n`PUT /api/reports/{id}/files/`. A `target` other than the current snapshot's is refused\nwith 409 and the current target, so an agent that fell behind can refetch; absent means current." + }, + "ReportLogResponse": { + "type": "object", + "properties": { + "lines": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "lines" + ], + "description": "`GET /api/reports/{id}/log`: what the generator wrote to stderr, most recent run only." + }, + "ReportMarkdownBlock": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "markdown" + }, + "text": { + "type": "string" + }, + "anchor": { + "$ref": "#/$defs/ReportAnchor" + } + }, + "required": [ + "kind", + "text" + ], + "description": "GitHub-flavored markdown, rendered as a comment body is: raw HTML stays literal and images are dropped." + }, + "ReportPage": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "old": { + "anyOf": [ + { + "$ref": "#/$defs/ReportFile" + }, + { + "type": "null" + } + ] + }, + "new": { + "anyOf": [ + { + "$ref": "#/$defs/ReportFile" + }, + { + "type": "null" + } + ] + }, + "anchor": { + "$ref": "#/$defs/ReportAnchor" + } + }, + "required": [ + "label", + "old", + "new" + ], + "description": "One rendered page or figure, as served: a side with a file is shown; both are compared." + }, + "ReportPageInput": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "old": { + "type": [ + "string", + "null" + ] + }, + "new": { + "type": [ + "string", + "null" + ] + }, + "anchor": { + "$ref": "#/$defs/ReportAnchor" + } + }, + "required": [ + "label", + "old", + "new" + ], + "description": "One rendered page or figure, as authored: each side names a file next to the report, relative, or none." + }, + "ReportSource": { + "type": "string", + "enum": [ + "generator", + "pushed" + ], + "description": "A generator diffle ran for the snapshot, or something outside pushed it over the API." + }, + "ReportsResponse": { + "type": "object", + "properties": { + "current": { + "$ref": "#/$defs/ReportTarget" + }, + "reports": { + "type": "array", + "items": { + "$ref": "#/$defs/Report" + } + } + }, + "required": [ + "current", + "reports" + ], + "description": "`GET /api/reports`: every report, generated ones first, and the target a pushed one should name." + }, + "ReportStatus": { + "type": "string", + "enum": [ + "running", + "ready", + "failed" + ], + "description": "`running` keeps the previous blocks until the new ones land, as a snapshot refresh does." + }, + "ReportTarget": { + "type": "object", + "properties": { + "oldSha": { + "type": "string" + }, + "newSha": { + "type": "string" + }, + "digest": { + "type": "string" + } + }, + "required": [ + "oldSha", + "newSha", + "digest" + ], + "description": "The snapshot a report was made for. Two shas do not pin a worktree side, so `digest` hashes the\nchanged files' blobs too; `GET /api/reports` says the current one." + }, "ResolvedRequest": { "type": "object", "properties": { @@ -2309,6 +2731,18 @@ "type", "payload" ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "reports" + } + }, + "required": [ + "type" + ] } ] }, @@ -2525,6 +2959,16 @@ "type": "string" }, "description": "GitHub Enterprise hosts: the host in remote urls → the host serving the web UI and API, the\nsame one for most. github.com needs no entry. Read-only over HTTP; set by editing the\nconfig file." + }, + "plugins": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/PluginConfig" + }, + "description": "Report generators by name: a built-in plugin (`typst`, `pixi`) is replaced or turned off by\nits name, any other name adds one. Read-only over HTTP; set by editing the config file." } }, "required": [ @@ -2533,7 +2977,8 @@ "followRefs", "lineWrap", "lspCommands", - "githubHosts" + "githubHosts", + "plugins" ] }, "ViewedBulkRequest": { diff --git a/crates/diffle/src/cli/args.rs b/crates/diffle/src/cli/args.rs index a145ba2b..c28d217e 100644 --- a/crates/diffle/src/cli/args.rs +++ b/crates/diffle/src/cli/args.rs @@ -169,6 +169,13 @@ pub enum Command { Config, /// show the language server each language would get, and what is missing from PATH Lsp, + /// show the report generators a review would run, and what is missing from PATH + Plugins, + /// push a report into the diffle running for this repository, or remove one + Report { + #[command(subcommand)] + action: super::report::ReportAction, + }, /// print a shell completion script #[command(after_help = COMPLETION_HELP)] Completion { diff --git a/crates/diffle/src/cli/mod.rs b/crates/diffle/src/cli/mod.rs index d7704984..8430e76e 100644 --- a/crates/diffle/src/cli/mod.rs +++ b/crates/diffle/src/cli/mod.rs @@ -7,6 +7,8 @@ mod lsp; mod open; mod paint; mod parse; +mod plugins; +mod report; pub mod repository; mod serve; mod timing; @@ -47,6 +49,8 @@ async fn dispatch(cli: Cli) -> anyhow::Result<()> { let (req, later) = match cli.command { Some(Command::Config) => return config::run().await, Some(Command::Lsp) => return lsp::list().await, + Some(Command::Plugins) => return plugins::list().await, + Some(Command::Report { action }) => return report::run(action).await, Some(Command::Completion { shell }) => { completion::print(shell); return Ok(()); diff --git a/crates/diffle/src/cli/plugins.rs b/crates/diffle/src/cli/plugins.rs new file mode 100644 index 00000000..53b06f91 --- /dev/null +++ b/crates/diffle/src/cli/plugins.rs @@ -0,0 +1,45 @@ +//! The run's report generators: resolved from the catalog and the config, reported on stderr, and +//! listed by `diffle plugins`. +use super::paint::{dim, yellow}; +use crate::lsp::registry::default_lookup; +use crate::reports::catalog::{Plugin, Resolution, builtins, resolve}; +use crate::user_config::UserConfigStore; + +/// Which generators a run starts, from the user config and PATH. +pub fn resolved(config: &UserConfigStore) -> Resolution { + resolve(&config.get().plugins, &default_lookup()) +} + +/// The startup line: what will report on the diff, if anything. +pub fn report(plugins: &[Plugin]) { + if plugins.is_empty() { + return; + } + let names: Vec = + plugins.iter().map(|p| format!("{} {}", p.name, dim(&format!("({})", p.when.join(", "))))).collect(); + crate::status!("📄 {} {}", dim("reports"), names.join(", ")); +} + +/// `diffle plugins`: each generator a review would run and its command, then the built-in ones +/// whose programs are not on PATH, said the way the resolver decides it. +pub async fn list() -> anyhow::Result<()> { + let store = UserConfigStore::open(UserConfigStore::default_path()).await; + let config = store.get().plugins; + let resolution = resolve(&config, &default_lookup()); + for p in &resolution.plugins { + let from = if p.configured { dim(" (config)") } else { String::new() }; + println!("{} {} {}{from}", p.name, dim(&format!("[{}]", p.when.join(", "))), p.command); + } + for m in &resolution.missing { + println!("{} {} {}", m.name, dim(&format!("not on PATH (tried {})", m.tried.join(", "))), dim(&m.command)); + } + for b in builtins() { + if config.get(&b.name).is_some_and(|c| c.command.trim().is_empty()) { + println!("{} {}", b.name, dim("off in config")); + } + } + if resolution.plugins.is_empty() && resolution.missing.is_empty() { + crate::status!("{} no report generators", yellow("!")); + } + Ok(()) +} diff --git a/crates/diffle/src/cli/report.rs b/crates/diffle/src/cli/report.rs new file mode 100644 index 00000000..2c114f42 --- /dev/null +++ b/crates/diffle/src/cli/report.rs @@ -0,0 +1,262 @@ +//! `diffle report`: pushing a report into the diffle running for this repository, which announces +//! itself in `/diffle/server.json`. +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde_json::{Value, json}; + +use super::paint::{cyan, dim}; +use crate::error::{Error, Result}; +use crate::git::GitRepo; +use crate::persist::write_file_atomic; +use crate::protocol::{ReportAnchor, ReportBlockInput, ReportInput}; + +/// What `diffle report` does. +#[derive(clap::Subcommand, Debug)] +pub enum ReportAction { + /// push a report: a markdown file becomes one block, a .json file is a whole report + #[command(after_help = PUT_HELP)] + Put { + /// the markdown or JSON file to push + #[arg(value_name = "file", value_hint = clap::ValueHint::FilePath)] + file: PathBuf, + /// the report's id (default: the file's name); putting an id again replaces it + #[arg(long, value_name = "id")] + id: Option, + /// the report's title (default: the file's first heading, else its name) + #[arg(long, value_name = "title")] + title: Option, + /// where a markdown report points: : or :- + #[arg(long, value_name = "path:line")] + anchor: Option, + /// run as if started in (any directory inside the reviewed repository) + #[arg(short = 'C', value_name = "path", value_hint = clap::ValueHint::DirPath)] + dir: Option, + }, + /// remove a pushed report + Delete { + /// the report's id + #[arg(value_name = "id")] + id: String, + /// run as if started in (any directory inside the reviewed repository) + #[arg(short = 'C', value_name = "path", value_hint = clap::ValueHint::DirPath)] + dir: Option, + }, +} + +const PUT_HELP: &str = "\ +A .json file is a report as the API takes it: + { \"title\": \"Dependency review\", + \"blocks\": [ + { \"kind\": \"markdown\", \"text\": \"...\", \"anchor\": { \"path\": \"pixi.lock\", \"line\": 1 } }, + { \"kind\": \"pages\", \"label\": \"Rendered\", + \"pages\": [{ \"label\": \"p. 1\", \"old\": \"old/1.png\", \"new\": \"new/1.png\" }] } ] } +The files a pages block names are read relative to the .json file and uploaded first; only +raster images are accepted. An anchor is dropped when the review does not show its path. + +Any other file is one markdown block; --anchor says what it is about. + +The report shows in the Reports view (gr) and goes stale when the diff moves on. Agents can +push one over HTTP instead: PUT api/reports/ with the same JSON; the url is in +/diffle/server.json while diffle runs."; + +/// How long the running diffle may take to answer. +const TIMEOUT: Duration = Duration::from_secs(10); + +fn announcement(git_dir: &Path) -> PathBuf { + git_dir.join("diffle").join("server.json") +} + +/// Records where this run listens, for `diffle report` and agents. A failure is not the run's. +pub async fn announce(git_dir: &Path, url: &str) { + let text = json!({ "url": url, "pid": std::process::id() }).to_string() + "\n"; + if let Err(e) = write_file_atomic(&announcement(git_dir), &text).await { + crate::status!("[diffle] announcing the server: {e:#}"); + } +} + +/// Withdraws this run's announcement, unless another run has since made its own. +pub async fn withdraw(git_dir: &Path) { + let file = announcement(git_dir); + if let Ok(text) = tokio::fs::read_to_string(&file).await + && serde_json::from_str::(&text).ok().and_then(|v| v["pid"].as_u64()) + == Some(u64::from(std::process::id())) + { + let _ = tokio::fs::remove_file(&file).await; + } +} + +pub async fn run(action: ReportAction) -> anyhow::Result<()> { + match action { + ReportAction::Put { file, id, title, anchor, dir } => put(&file, id, title, anchor, dir).await?, + ReportAction::Delete { id, dir } => { + crate::reports::input::check_id(&id)?; + let (url, client) = connect(dir).await?; + let res = client.delete(format!("{url}api/reports/{id}")).send().await.map_err(sent)?; + answered(res).await?; + crate::status!("{} report {id} removed", dim("✓")); + } + } + Ok(()) +} + +/// The running server's base url (with its trailing slash) and a client for it. +async fn connect(dir: Option) -> Result<(String, reqwest::Client)> { + let cwd = match dir { + Some(dir) => dir, + None => std::env::current_dir()?, + }; + let repo = GitRepo::open(&cwd).await?; + let not_running = + || Error::Conflict("no diffle is running for this repository; start one with `diffle working`".into()); + let text = tokio::fs::read_to_string(announcement(&repo.git_dir)).await.map_err(|_| not_running())?; + let url = serde_json::from_str::(&text) + .ok() + .and_then(|v| v["url"].as_str().map(str::to_owned)) + .ok_or_else(not_running)?; + let client = reqwest::Client::builder().timeout(TIMEOUT).build().map_err(anyhow::Error::from)?; + // The announcement outlives a crashed run: only an answer proves someone is listening. + if client.get(format!("{url}api/config")).send().await.is_err() { + return Err(not_running()); + } + Ok((url, client)) +} + +async fn put( + file: &Path, + id: Option, + title: Option, + anchor: Option, + dir: Option, +) -> Result<()> { + let text = tokio::fs::read_to_string(file) + .await + .map_err(|e| Error::Invalid(format!("reading {}: {e}", file.display())))?; + let stem = file.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); + let id = id.unwrap_or_else(|| id_of(&stem)); + crate::reports::input::check_id(&id)?; + let (mut report, base) = if file.extension().is_some_and(|e| e.eq_ignore_ascii_case("json")) { + let report: ReportInput = + serde_json::from_str(&text).map_err(|e| Error::Invalid(format!("{}: {e}", file.display())))?; + (report, file.parent().map(Path::to_path_buf)) + } else { + let anchor = anchor.as_deref().map(parse_anchor).transpose()?; + let heading = text.lines().map(str::trim).find(|l| !l.is_empty()).map(|l| l.trim_start_matches('#').trim()); + let title = heading.filter(|h| !h.is_empty()).map_or_else(|| stem.clone(), str::to_owned); + ( + ReportInput { + title, + summary: None, + target: None, + blocks: vec![ReportBlockInput::Markdown { text, anchor }], + }, + None, + ) + }; + if let Some(title) = title { + report.title = title; + } + let (url, client) = connect(dir).await?; + for name in named_files(&report) { + let path = base.clone().unwrap_or_default().join(&name); + let bytes = + tokio::fs::read(&path).await.map_err(|e| Error::Invalid(format!("reading {}: {e}", path.display())))?; + let res = client.put(format!("{url}api/reports/{id}/files/{name}")).body(bytes).send().await.map_err(sent)?; + answered(res).await?; + } + let res = client.put(format!("{url}api/reports/{id}")).json(&report).send().await.map_err(sent)?; + let created = res.status() == reqwest::StatusCode::CREATED; + let report = answered(res).await?; + crate::status!( + "{} report {} {} {}", + dim("✓"), + report["title"].as_str().unwrap_or(&id), + if created { "added" } else { "replaced" }, + dim(&format!("({id}, {url})")) + ); + crate::status!(" {}", cyan(&format!("{url}#reports"))); + Ok(()) +} + +/// A request that got no answer: the server went away mid-way, which the user can fix. +fn sent(e: reqwest::Error) -> Error { + Error::Conflict(format!("the running diffle did not answer: {e}")) +} + +/// The response's JSON, or its `error` as a user error. +async fn answered(res: reqwest::Response) -> Result { + let status = res.status(); + let text = res.text().await.map_err(anyhow::Error::from)?; + let value: Value = serde_json::from_str(&text).unwrap_or(Value::Null); + if status.is_success() { + return Ok(value); + } + let message = value["error"].as_str().map_or_else(|| format!("{status}: {text}"), str::to_owned); + Err(if status == reqwest::StatusCode::CONFLICT { Error::Conflict(message) } else { Error::Invalid(message) }) +} + +/// A file name as a report id: lowercased, anything else a dash, at most 64 long. +fn id_of(stem: &str) -> String { + let mut id = String::new(); + for c in stem.to_lowercase().chars() { + let keep = c.is_ascii_lowercase() || c.is_ascii_digit(); + if keep { + id.push(c); + } else if !id.ends_with('-') && !id.is_empty() { + id.push('-'); + } + } + let id = id.trim_end_matches('-'); + id.chars().take(64).collect::().trim_end_matches('-').to_owned() +} + +/// `path:line` or `path:start-end`. +fn parse_anchor(raw: &str) -> Result { + let invalid = || Error::Invalid(format!("--anchor: expected : or :-, got \"{raw}\"")); + let (path, lines) = raw.rsplit_once(':').ok_or_else(invalid)?; + let (start, end) = match lines.split_once('-') { + Some((a, b)) => (a, Some(b)), + None => (lines, None), + }; + let line = start.parse::().ok().filter(|n| *n > 0).ok_or_else(invalid)?; + let end_line = end.map(|e| e.parse::().ok().filter(|n| *n >= line).ok_or_else(invalid)).transpose()?; + if path.is_empty() { + return Err(invalid()); + } + Ok(ReportAnchor { path: path.to_owned(), side: None, line, end_line }) +} + +fn named_files(report: &ReportInput) -> Vec { + let mut names: Vec = report + .blocks + .iter() + .flat_map(|b| match b { + ReportBlockInput::Pages { pages, .. } => { + pages.iter().flat_map(|p| p.old.iter().chain(p.new.iter()).cloned()).collect() + } + ReportBlockInput::Markdown { .. } => Vec::new(), + }) + .collect(); + names.sort(); + names.dedup(); + names +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn derives_ids_and_parses_anchors() { + assert_eq!(id_of("Review Notes (v2)"), "review-notes-v2"); + assert_eq!(id_of("--"), ""); + assert_eq!(id_of(&"a".repeat(70)).len(), 64); + let a = parse_anchor("src/a.rs:12-14").unwrap(); + assert_eq!((a.path.as_str(), a.line, a.end_line), ("src/a.rs", 12, Some(14))); + let a = parse_anchor("C:/x/y.txt:3").unwrap(); + assert_eq!((a.path.as_str(), a.line, a.end_line), ("C:/x/y.txt", 3, None)); + for bad in ["a.rs", "a.rs:0", "a.rs:5-3", ":3", "a.rs:x"] { + assert!(parse_anchor(bad).is_err(), "{bad}"); + } + } +} diff --git a/crates/diffle/src/cli/serve.rs b/crates/diffle/src/cli/serve.rs index 4a18b66f..f7862dae 100644 --- a/crates/diffle/src/cli/serve.rs +++ b/crates/diffle/src/cli/serve.rs @@ -17,6 +17,7 @@ use crate::github::client::token_variables; use crate::github::hosts::Github; use crate::lsp::pool::LspPool; use crate::protocol::{EnterRequest, Live, Snapshot, ThreadQuery, ThreadState, comparison_label}; +use crate::reports::Reports; use crate::server::hub::Hub; use crate::server::{ApiDeps, DEFAULT_PORT, Server, ServerOptions}; use crate::session::{Session, SessionOptions}; @@ -56,6 +57,7 @@ pub async fn run(req: EnterRequest, opts: GlobalOpts) -> anyhow::Result<()> { overrides.extend(opts.lsp.iter().map(|o| (o.language, o.command.clone()))); super::lsp::start(&session, &hub, overrides, opts.lsp.iter().map(|o| o.language).collect()) }); + let reports = Reports::new(session.clone(), hub.clone(), &repo.git_dir, super::plugins::resolved(&config).plugins); let server: Arc>> = Arc::default(); let stop = Arc::new(Notify::new()); @@ -81,6 +83,7 @@ pub async fn run(req: EnterRequest, opts: GlobalOpts) -> anyhow::Result<()> { &server, &repo, lsp.clone(), + &reports, (&github, token_source.as_deref()), &mut timing, ); @@ -90,15 +93,18 @@ pub async fn run(req: EnterRequest, opts: GlobalOpts) -> anyhow::Result<()> { }; match started { Some(Err(e)) => { + reports.close(); dispose(&session, &server, &repo, lsp.as_deref()).await; Err(e) } Some(Ok(())) => { stop.notified().await; + reports.close(); shutdown(&session, &server, &repo, lsp.as_deref()).await; Ok(()) } None => { + reports.close(); shutdown(&session, &server, &repo, lsp.as_deref()).await; Ok(()) } @@ -115,6 +121,7 @@ async fn start( server: &Mutex>, repo: &GitRepo, lsp: Option>, + reports: &Arc, (github, token_source): (&Github, Option<&str>), timing: &mut Timing, ) -> anyhow::Result<()> { @@ -127,6 +134,7 @@ async fn start( extra_auto_viewed: opts.auto_viewed.clone(), hub: hub.clone(), lsp: lsp.clone(), + reports: reports.clone(), }; let server_opts = ServerOptions { port: opts.port.unwrap_or(DEFAULT_PORT), @@ -136,6 +144,7 @@ async fn start( }; let (bound, url) = Server::listen(deps, server_opts).await?; *server.lock().unwrap() = Some(bound); + super::report::announce(&repo.git_dir, &url).await; timing.mark("listen"); if !opts.no_open { let browser = if opts.host() == "0.0.0.0" || opts.host() == "::" { localhost(&url) } else { url.clone() }; @@ -162,6 +171,7 @@ async fn start( if let Some(lsp) = &lsp { super::lsp::report(lsp, &snap); } + super::plugins::report(reports.plugins()); timing.report(); Ok(()) } @@ -231,7 +241,7 @@ async fn dispose(session: &Arc, server: &Mutex>, repo: & }; let _ = tokio::time::timeout(CLOSE_BOUND, async { tokio::join!(sockets, servers) }).await; }; - tokio::join!(session.close(), close_server); + tokio::join!(session.close(), close_server, super::report::withdraw(&repo.git_dir)); if let Err(e) = repo.clean_review_refs().await { crate::status!("{} {e}", red("✖")); } diff --git a/crates/diffle/src/error.rs b/crates/diffle/src/error.rs index ba10263c..0c794b85 100644 --- a/crates/diffle/src/error.rs +++ b/crates/diffle/src/error.rs @@ -75,6 +75,9 @@ pub enum Error { /// A request body that is not valid JSON or not the shape the route takes. #[error("{0}")] Invalid(String), + /// A request that the current state refuses, such as replacing a generator's report: the API answers 409. + #[error("{0}")] + Conflict(String), #[error(transparent)] Other(Arc), } diff --git a/crates/diffle/src/lib.rs b/crates/diffle/src/lib.rs index a0ba973b..0a261e1d 100644 --- a/crates/diffle/src/lib.rs +++ b/crates/diffle/src/lib.rs @@ -23,6 +23,7 @@ pub mod lsp; pub mod mode; pub mod persist; pub mod protocol; +pub mod reports; pub mod server; pub mod session; pub mod snapshotter; diff --git a/crates/diffle/src/reports/catalog.rs b/crates/diffle/src/reports/catalog.rs new file mode 100644 index 00000000..de522657 --- /dev/null +++ b/crates/diffle/src/reports/catalog.rs @@ -0,0 +1,244 @@ +//! Every report generator diffle knows, and which to run: the built-in rows, replaced or turned off +//! by name in the user config, plus the config's own. +use regex::Regex; +use std::collections::HashMap; + +use crate::lsp::registry::Lookup; +use crate::lsp::which::argv0; +use crate::protocol::{ChangedFile, PluginConfig, PluginConfigScope}; + +/// Which files of a snapshot a generator gets copies of. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Scope { + /// The changed files its globs matched, with a rename's old name on the old side. + Changed, + /// Every file the snapshot shows, so a document's imports resolve. + Tree, +} + +/// One generator to run: its report's id is `name`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Plugin { + pub name: String, + pub command: String, + pub when: Vec, + pub scope: Scope, + /// Where it came from, for `diffle plugins`. + pub configured: bool, +} + +/// A built-in generator whose programs are not on PATH. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Missing { + pub name: String, + pub command: String, + /// The programs looked for, so the UI can name what to install. + pub tried: Vec, +} + +pub struct Resolution { + pub plugins: Vec, + pub missing: Vec, +} + +struct Builtin { + name: &'static str, + command: &'static str, + when: &'static [&'static str], + scope: Scope, +} + +/// The generators diffle starts on its own when their programs are on PATH. +const BUILTINS: &[Builtin] = &[ + Builtin { + name: "pixi", + command: "pixi-diff --before $DIFFLE_OLD/$DIFFLE_FILE --after $DIFFLE_NEW/$DIFFLE_FILE | pixi-diff-to-markdown", + when: &["pixi.lock"], + scope: Scope::Changed, + }, + Builtin { name: "typst", command: "diffle-typst", when: &["*.typ"], scope: Scope::Tree }, +]; + +/// The built-in rows, as `diffle plugins` lists them. +pub fn builtins() -> Vec { + BUILTINS + .iter() + .map(|b| plugin(b.name, b.command, b.when.iter().map(|w| (*w).to_owned()).collect(), b.scope, false)) + .collect() +} + +fn plugin(name: &str, command: &str, when: Vec, scope: Scope, configured: bool) -> Plugin { + Plugin { name: name.to_owned(), command: command.trim().to_owned(), when, scope, configured } +} + +/// The programs a command line runs: the first word of each `|`-separated stage. +pub fn programs_of(command: &str) -> Vec { + command.split('|').map(|stage| argv0(stage.trim())).filter(|p| !p.is_empty()).collect() +} + +/// Which generators to run. A config entry named like a built-in replaces it, and one with an +/// empty command turns it off; a built-in is never run while a program of its is off PATH, and is +/// listed as missing instead. Configured commands are not probed: the user named them, so a typo +/// surfaces as a failed report, not as silence. +pub fn resolve(config: &HashMap, lookup: &Lookup) -> Resolution { + let mut plugins = Vec::new(); + let mut missing = Vec::new(); + for b in BUILTINS { + let Some(entry) = config.get(b.name) else { + let tried: Vec = programs_of(b.command).into_iter().filter(|p| lookup(p).is_none()).collect(); + if tried.is_empty() { + plugins.push(plugin( + b.name, + b.command, + b.when.iter().map(|w| (*w).to_owned()).collect(), + b.scope, + false, + )); + } else { + missing.push(Missing { name: b.name.to_owned(), command: b.command.to_owned(), tried }); + } + continue; + }; + if entry.command.trim().is_empty() { + continue; + } + let when = + if entry.when.is_empty() { b.when.iter().map(|w| (*w).to_owned()).collect() } else { entry.when.clone() }; + plugins.push(plugin(b.name, &entry.command, when, scope_of(entry).unwrap_or(b.scope), true)); + } + let mut named: Vec<(&String, &PluginConfig)> = + config.iter().filter(|(name, _)| !BUILTINS.iter().any(|b| b.name == name.as_str())).collect(); + named.sort_by(|a, b| a.0.cmp(b.0)); + for (name, entry) in named { + if entry.command.trim().is_empty() || entry.when.is_empty() { + continue; + } + plugins.push(plugin(name, &entry.command, entry.when.clone(), scope_of(entry).unwrap_or(Scope::Changed), true)); + } + Resolution { plugins, missing } +} + +fn scope_of(entry: &PluginConfig) -> Option { + entry.scope.map(|s| match s { + PluginConfigScope::Changed => Scope::Changed, + PluginConfigScope::Tree => Scope::Tree, + }) +} + +/// The changed files `plugin` runs for: those a glob of its matches, by new path. +pub fn matched(plugin: &Plugin, changed: &[ChangedFile]) -> Vec { + let globs: Vec = plugin.when.iter().map(|g| Glob::new(g)).collect(); + changed.iter().filter(|f| globs.iter().any(|g| g.is_match(&f.path))).map(|f| f.path.clone()).collect() +} + +/// A picomatch-style glob: `*` and `?` stay within one path segment, `**` spans any number. A +/// pattern without a slash matches the file's name in any folder, as a gitignore line does. +pub struct Glob { + regex: Regex, + basename: bool, +} + +impl Glob { + pub fn new(pattern: &str) -> Self { + let pattern = pattern.trim().trim_start_matches("./"); + let basename = !pattern.contains('/'); + let mut re = String::from("^"); + let mut chars = pattern.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '*' if chars.peek() == Some(&'*') => { + chars.next(); + // `**/` also matches no folder at all. + if chars.peek() == Some(&'/') { + chars.next(); + re.push_str("(?:.*/)?"); + } else { + re.push_str(".*"); + } + } + '*' => re.push_str("[^/]*"), + '?' => re.push_str("[^/]"), + other => re.push_str(®ex::escape(&other.to_string())), + } + } + re.push('$'); + Self { regex: Regex::new(&re).expect("escaped glob"), basename } + } + + pub fn is_match(&self, path: &str) -> bool { + let subject = if self.basename { path.rsplit('/').next().unwrap_or(path) } else { path }; + self.regex.is_match(subject) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + use std::sync::Arc; + + #[test] + fn globs_follow_picomatch_within_segments() { + assert!(Glob::new("pixi.lock").is_match("apps/web/pixi.lock")); + assert!(Glob::new("*.typ").is_match("thesis/chapters/intro.typ")); + assert!(!Glob::new("*.typ").is_match("thesis/intro.typst")); + assert!(Glob::new("docs/**/*.md").is_match("docs/a/b/c.md")); + assert!(Glob::new("docs/**/*.md").is_match("docs/c.md")); + assert!(!Glob::new("docs/*.md").is_match("docs/a/c.md")); + assert!(Glob::new("src/?.rs").is_match("src/a.rs")); + assert!(!Glob::new("src/?.rs").is_match("src/ab.rs")); + assert!(Glob::new("**/pixi.lock").is_match("pixi.lock")); + } + + fn lookup(present: &'static [&'static str]) -> Lookup { + Arc::new(move |p: &str| present.contains(&p).then(|| PathBuf::from(p))) + } + + #[test] + fn resolves_builtins_against_path_and_config() { + let r = resolve(&HashMap::new(), &lookup(&["pixi-diff", "pixi-diff-to-markdown"])); + assert_eq!(r.plugins.iter().map(|p| p.name.as_str()).collect::>(), ["pixi"]); + assert_eq!( + r.missing, + [Missing { name: "typst".into(), command: "diffle-typst".into(), tried: vec!["diffle-typst".into()] }] + ); + + let config = HashMap::from([ + ("pixi".to_owned(), PluginConfig { command: String::new(), when: vec![], scope: None }), + ("typst".to_owned(), PluginConfig { command: "my-typst --fast".into(), when: vec![], scope: None }), + ( + "notes".to_owned(), + PluginConfig { + command: "notes".into(), + when: vec!["*.md".into()], + scope: Some(PluginConfigScope::Tree), + }, + ), + ("nowhen".to_owned(), PluginConfig { command: "x".into(), when: vec![], scope: None }), + ]); + let r = resolve(&config, &lookup(&[])); + assert!(r.missing.is_empty()); + let names: Vec<(&str, &str, Scope, bool)> = + r.plugins.iter().map(|p| (p.name.as_str(), p.command.as_str(), p.scope, p.configured)).collect(); + assert_eq!(names, [("typst", "my-typst --fast", Scope::Tree, true), ("notes", "notes", Scope::Tree, true)]); + assert_eq!(r.plugins[0].when, ["*.typ"]); + } + + #[test] + fn matched_files_follow_the_globs() { + let plugin = plugin("p", "p", vec!["pixi.lock".into()], Scope::Changed, false); + let file = |path: &str| ChangedFile { + path: path.into(), + old_path: None, + status: crate::protocol::ChangeStatus::M, + additions: 0, + deletions: 0, + binary: false, + blob: String::new(), + old_blob: String::new(), + generated: false, + submodule: None, + }; + assert_eq!(matched(&plugin, &[file("a/pixi.lock"), file("pixi.toml")]), ["a/pixi.lock"]); + } +} diff --git a/crates/diffle/src/reports/exec.rs b/crates/diffle/src/reports/exec.rs new file mode 100644 index 00000000..0f113fac --- /dev/null +++ b/crates/diffle/src/reports/exec.rs @@ -0,0 +1,121 @@ +//! Running a generator's command line: the stages a `|` separates, the DIFFLE_* variables, and +//! the pipe from one program's stdout to the next, with no shell in between. +use std::path::PathBuf; +use std::process::Stdio; + +use tokio::io::AsyncWriteExt; + +use super::runner::{FILE_VAR, Failure, failed}; +use crate::error::Result; +use crate::lsp::which::find_on_path; + +/// Lines of stderr kept per run. +const LOG_LINES: usize = 200; + +pub(super) struct Vars { + pub(super) root: String, + pub(super) old: PathBuf, + pub(super) new: PathBuf, + pub(super) out: PathBuf, + pub(super) file: Option, +} + +impl Vars { + pub(super) fn pairs(&self) -> Vec<(&'static str, String)> { + let mut pairs = vec![ + ("DIFFLE_ROOT", self.root.clone()), + ("DIFFLE_OLD", self.old.to_string_lossy().into_owned()), + ("DIFFLE_NEW", self.new.to_string_lossy().into_owned()), + ("DIFFLE_OUT", self.out.to_string_lossy().into_owned()), + ]; + if let Some(file) = &self.file { + pairs.push((FILE_VAR, file.clone())); + } + pairs + } + + /// `word` with every `$NAME` and `${NAME}` of the variables above substituted. + pub(super) fn expand(&self, word: &str) -> String { + let mut out = word.to_owned(); + for (name, value) in self.pairs() { + out = out.replace(&format!("${{{name}}}"), &value).replace(&format!("${name}"), &value); + } + out + } +} + +/// The command line as stages, each a program and its arguments; a `|` word separates them. +pub(super) fn stages_of(command: &str) -> Result>> { + let words = shlex::split(command).ok_or_else(|| failed(format!("unbalanced quotes in \"{command}\"")))?; + let stages: Vec> = words.split(|w| w == "|").map(|s| s.to_vec()).collect(); + if stages.iter().any(|s| s.is_empty()) { + return Err(failed(format!("no program named in \"{command}\""))); + } + Ok(stages) +} + +pub(super) struct Output { + pub(super) stdout: Vec, + pub(super) log: Vec, +} + +/// Runs the stages in turn, each fed the previous one's stdout, the first `input`. +pub(super) async fn pipeline(stages: &[Vec], vars: &Vars, input: Vec) -> Result { + let mut data = input; + let mut log = Vec::new(); + for stage in stages { + let program = &stage[0]; + let args: Vec = stage[1..].iter().map(|w| vars.expand(w)).collect(); + let resolved = find_on_path(program).ok_or_else(|| failed(format!("{program}: not on PATH")))?; + let mut cmd = tokio::process::Command::new(resolved); + cmd.args(&args).envs(vars.pairs()).current_dir(&vars.out); + cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).kill_on_drop(true); + let mut child = cmd.spawn().map_err(|e| failed(format!("{program}: {e}")))?; + if let Some(mut stdin) = child.stdin.take() { + let feed = data; + // A program that never reads stdin closes it; that is not a failure. + tokio::spawn(async move { + let _ = stdin.write_all(&feed).await; + let _ = stdin.shutdown().await; + }); + } + let output = child.wait_with_output().await.map_err(|e| failed(format!("{program}: {e}")))?; + let stderr = String::from_utf8_lossy(&output.stderr); + let name = if stages.len() > 1 { format!("{program}: ") } else { String::new() }; + log.extend(stderr.lines().map(|l| format!("{name}{l}"))); + if log.len() > LOG_LINES { + log.drain(..log.len() - LOG_LINES); + } + if !output.status.success() { + let last = stderr.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or_default(); + let how = output.status.code().map_or_else(|| "killed".to_owned(), |c| format!("exited {c}")); + let message = + format!("{program} {how}{}", if last.is_empty() { String::new() } else { format!(": {last}") }); + return Err(Failure { message, log }); + } + data = output.stdout; + } + Ok(Output { stdout: data, log }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn splits_stages_and_expands_variables() { + let stages = stages_of("pixi-diff --before \"$DIFFLE_OLD/a b\" | md ${DIFFLE_FILE}").unwrap(); + assert_eq!(stages, [vec!["pixi-diff", "--before", "$DIFFLE_OLD/a b"], vec!["md", "${DIFFLE_FILE}"]]); + let vars = Vars { + root: "/r".into(), + old: "/o".into(), + new: "/n".into(), + out: "/out".into(), + file: Some("pixi.lock".into()), + }; + assert_eq!(vars.expand(&stages[0][2]), "/o/a b"); + assert_eq!(vars.expand(&stages[1][1]), "pixi.lock"); + assert!(stages_of("a | | b").is_err()); + assert!(stages_of("a \"b").is_err()); + } +} diff --git a/crates/diffle/src/reports/generated.rs b/crates/diffle/src/reports/generated.rs new file mode 100644 index 00000000..62fa2c30 --- /dev/null +++ b/crates/diffle/src/reports/generated.rs @@ -0,0 +1,129 @@ +//! Reports the generators make: started on every snapshot, superseded by the next, cached by input. +use std::collections::HashMap; +use std::sync::Arc; + +use super::catalog::{self, Plugin}; +use super::runner::{self, Failure, Produced}; +use super::store::Stored; +use super::{Reports, target_of}; +use crate::iterations::now_ms; +use crate::protocol::{ReportSource, ReportStatus, ReportTarget, ServerMessage, Snapshot}; + +impl Reports { + /// Follows a snapshot: loads its pushed reports and starts its generators. + pub(super) async fn sync(self: Arc, snap: Arc) { + if let Err(e) = self.load_pushed(&snap).await { + crate::status!("[diffle] loading reports: {e:#}"); + } + let target = target_of(&snap); + let mut changed = false; + { + let mut state = self.state.lock().unwrap(); + if snap.version < state.version { + return; + } + state.version = snap.version; + for plugin in &self.plugins { + let matched = catalog::matched(plugin, &snap.changed); + if matched.is_empty() { + changed |= state.generated.remove(&plugin.name).is_some(); + if let Some(run) = self.runs.lock().unwrap().remove(&plugin.name) { + run.abort(); + } + continue; + } + changed = true; + let previous = state.generated.get(&plugin.name); + let stored = Stored { + id: plugin.name.clone(), + source: ReportSource::Generator, + title: previous.map_or_else(|| plugin.name.clone(), |p| p.title.clone()), + summary: previous.map(|p| p.summary.clone()).unwrap_or_default(), + target: previous.map_or_else(|| target.clone(), |p| p.target.clone()), + status: ReportStatus::Running, + error: None, + updated_at: now_ms(), + blocks: previous.map(|p| p.blocks.clone()).unwrap_or_default(), + files: HashMap::new(), + }; + state.generated.insert(plugin.name.clone(), stored); + self.start(snap.clone(), target.clone(), plugin.clone(), matched); + } + } + if changed { + self.hub.broadcast(ServerMessage::Reports); + } + } + + /// Runs `plugin` for `snap` on its own task, in place of any run still going for it. + fn start(self: &Arc, snap: Arc, target: ReportTarget, plugin: Plugin, matched: Vec) { + let this = self.clone(); + let name = plugin.name.clone(); + let task = tokio::spawn(async move { + let key = format!("{}\0{}\0{}\0{}", plugin.name, plugin.command, plugin.when.join(","), target.digest); + let cached = this.cache.lock().unwrap().get(&key).cloned(); + let outcome = match cached { + Some(produced) => Ok(produced), + None => runner::run(&this.session, &this.store, &snap, &target, &plugin, &matched).await.map(Arc::new), + }; + if let Ok(produced) = &outcome { + this.cache.lock().unwrap().insert(key, produced.clone()); + } + this.finish(&snap, target, &plugin.name, outcome); + }); + if let Some(previous) = self.runs.lock().unwrap().insert(name, task.abort_handle()) { + previous.abort(); + } + } + + /// Commits a run's outcome, unless a newer snapshot has superseded it. + fn finish( + self: &Arc, + snap: &Snapshot, + target: ReportTarget, + name: &str, + outcome: Result, Failure>, + ) { + { + let mut state = self.state.lock().unwrap(); + if snap.version != state.version { + return; + } + let Some(stored) = state.generated.get_mut(name) else { return }; + match &outcome { + Ok(produced) => { + stored.title = produced.title.clone(); + stored.summary = produced.summary.clone(); + stored.blocks = produced.blocks.clone(); + stored.status = ReportStatus::Ready; + stored.error = None; + } + Err(e) => { + stored.status = ReportStatus::Failed; + stored.error = Some(e.message.clone()); + } + } + stored.target = target; + stored.updated_at = now_ms(); + let log = match &outcome { + Ok(produced) => produced.log.clone(), + Err(e) => e.log.clone(), + }; + state.logs.insert(name.to_owned(), log); + self.runs.lock().unwrap().remove(name); + } + if let Err(e) = &outcome { + crate::status!("[diffle] report {name}: {}", e.message); + } + let this = self.clone(); + tokio::spawn(async move { this.sweep().await }); + self.hub.broadcast(ServerMessage::Reports); + } + + /// Ends every run in flight. + pub fn close(&self) { + for (_, run) in self.runs.lock().unwrap().drain() { + run.abort(); + } + } +} diff --git a/crates/diffle/src/reports/input.rs b/crates/diffle/src/reports/input.rs new file mode 100644 index 00000000..5f8ee4cd --- /dev/null +++ b/crates/diffle/src/reports/input.rs @@ -0,0 +1,260 @@ +//! A report as authored, checked and resolved into one as served: every named file a key, every +//! anchor a place the snapshot shows, every limit kept. +use std::collections::HashMap; + +use crate::error::{Error, Result}; +use crate::protocol::{ + ReportAnchor, ReportBlock, ReportBlockInput, ReportFile, ReportInput, ReportPage, ReportPageInput, Side, +}; + +/// The most blocks one report holds; a reviewer reads a report, not a dump. +pub const MAX_BLOCKS: usize = 200; +/// The most pages one block holds. +pub const MAX_PAGES: usize = 500; +/// The longest markdown block, in bytes. +pub const MAX_MARKDOWN_BYTES: usize = 256 * 1024; +pub const MAX_TITLE_CHARS: usize = 120; +pub const MAX_SUMMARY_CHARS: usize = 200; +pub const MAX_LABEL_CHARS: usize = 80; + +/// A report checked and resolved; `notes` says what was dropped on the way. +#[derive(Debug)] +pub struct Validated { + pub title: String, + pub summary: String, + pub blocks: Vec, + pub notes: Vec, +} + +/// `id` as the API takes it: a lowercase word of letters, digits and dashes, at most 64 long. +pub fn check_id(id: &str) -> Result<()> { + let ok = !id.is_empty() + && id.len() <= 64 + && id.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit()) + && id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'); + if ok { Ok(()) } else { Err(invalid("id: lowercase letters, digits and dashes, at most 64")) } +} + +/// Whether `path` names a file next to a report: relative, with no `..` or empty segment, and no +/// backslash, so it names the same file on every platform. +pub fn safe_relative(path: &str) -> bool { + !path.is_empty() + && !path.starts_with('/') + && !path.contains('\\') + && !path.contains('\0') + && path.split('/').all(|seg| !seg.is_empty() && seg != "." && seg != "..") +} + +fn invalid(message: impl Into) -> Error { + Error::Invalid(message.into()) +} + +/// Checks `input` and resolves it against `files`, the uploaded files by authored path, and +/// `shown`, which says whether a path is in the snapshot on a side. A file the report names but +/// nobody uploaded fails; an anchor nothing shows is dropped with a note. +pub fn validate( + input: ReportInput, + files: &HashMap, + shown: impl Fn(&str, Side) -> bool, +) -> Result { + let title = input.title.trim().to_owned(); + if title.is_empty() { + return Err(invalid("title: required")); + } + if title.chars().count() > MAX_TITLE_CHARS { + return Err(invalid(format!("title: at most {MAX_TITLE_CHARS} characters"))); + } + if input.blocks.len() > MAX_BLOCKS { + return Err(invalid(format!("blocks: at most {MAX_BLOCKS}"))); + } + let mut notes = Vec::new(); + let mut blocks = Vec::with_capacity(input.blocks.len()); + for (i, block) in input.blocks.into_iter().enumerate() { + blocks.push(resolve_block(i, block, files, &shown, &mut notes)?); + } + let summary = match input.summary.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty()) { + Some(s) if s.chars().count() > MAX_SUMMARY_CHARS => { + return Err(invalid(format!("summary: at most {MAX_SUMMARY_CHARS} characters"))); + } + Some(s) => s, + None => first_line(&blocks), + }; + Ok(Validated { title, summary, blocks, notes }) +} + +fn resolve_block( + i: usize, + block: ReportBlockInput, + files: &HashMap, + shown: &impl Fn(&str, Side) -> bool, + notes: &mut Vec, +) -> Result { + match block { + ReportBlockInput::Markdown { text, anchor } => { + if text.len() > MAX_MARKDOWN_BYTES { + return Err(invalid(format!("blocks[{i}].text: at most {MAX_MARKDOWN_BYTES} bytes"))); + } + let anchor = anchor.and_then(|a| placed(a, &format!("blocks[{i}]"), shown, notes)); + Ok(ReportBlock::Markdown { text, anchor }) + } + ReportBlockInput::Pages { label, pages } => { + if pages.len() > MAX_PAGES { + return Err(invalid(format!("blocks[{i}].pages: at most {MAX_PAGES}"))); + } + let label = label.map(|l| l.trim().to_owned()).filter(|l| !l.is_empty()); + if label.as_ref().is_some_and(|l| l.chars().count() > MAX_LABEL_CHARS) { + return Err(invalid(format!("blocks[{i}].label: at most {MAX_LABEL_CHARS} characters"))); + } + let pages = pages + .into_iter() + .enumerate() + .map(|(j, page)| resolve_page(&format!("blocks[{i}].pages[{j}]"), page, files, shown, notes)) + .collect::>>()?; + Ok(ReportBlock::Pages { label, pages }) + } + } +} + +fn resolve_page( + at: &str, + page: ReportPageInput, + files: &HashMap, + shown: &impl Fn(&str, Side) -> bool, + notes: &mut Vec, +) -> Result { + if page.label.chars().count() > MAX_LABEL_CHARS { + return Err(invalid(format!("{at}.label: at most {MAX_LABEL_CHARS} characters"))); + } + if page.old.is_none() && page.new.is_none() { + return Err(invalid(format!("{at}: a page needs an old or a new file"))); + } + let file = |side: &str, path: Option| -> Result> { + let Some(path) = path else { return Ok(None) }; + if !safe_relative(&path) { + return Err(invalid(format!("{at}.{side}: a relative path under the report's files, got \"{path}\""))); + } + files + .get(&path) + .cloned() + .map(Some) + .ok_or_else(|| invalid(format!("{at}.{side}: no file uploaded as \"{path}\""))) + }; + Ok(ReportPage { + label: page.label, + old: file("old", page.old)?, + new: file("new", page.new)?, + anchor: page.anchor.and_then(|a| placed(a, at, shown, notes)), + }) +} + +/// `anchor` when the snapshot shows its path on its side and its lines are in order, else a note. +fn placed( + anchor: ReportAnchor, + at: &str, + shown: &impl Fn(&str, Side) -> bool, + notes: &mut Vec, +) -> Option { + let side = anchor.side.unwrap_or(Side::New); + let end = anchor.end_line.unwrap_or(anchor.line); + let why = if anchor.line < 1 || end < anchor.line { + Some("lines must be 1-based and in order".to_owned()) + } else if !shown(&anchor.path, side) { + Some(format!("the review does not show {} on the {side} side", anchor.path)) + } else { + None + }; + match why { + Some(why) => { + notes.push(format!("{at}.anchor dropped: {why}")); + None + } + None => Some(ReportAnchor { end_line: anchor.end_line.filter(|e| *e != anchor.line), ..anchor }), + } +} + +/// The first non-blank line of the first markdown block, bare of heading marks, cut to the summary length. +fn first_line(blocks: &[ReportBlock]) -> String { + blocks + .iter() + .find_map(|b| match b { + ReportBlock::Markdown { text, .. } => text.lines().map(str::trim).find(|l| !l.is_empty()), + ReportBlock::Pages { .. } => None, + }) + .map(|l| l.trim_start_matches('#').trim().chars().take(MAX_SUMMARY_CHARS).collect()) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::ReportBlockInput as B; + + fn input(blocks: Vec) -> ReportInput { + ReportInput { title: " Notes ".into(), summary: None, target: None, blocks } + } + + fn anchor(path: &str, line: i64, end: Option) -> Option { + Some(ReportAnchor { path: path.into(), side: None, line, end_line: end }) + } + + #[test] + fn resolves_files_and_drops_unplaceable_anchors() { + let files = HashMap::from([("p/1.png".to_owned(), ReportFile { key: "k1".into(), type_: "image/png".into() })]); + let shown = |path: &str, _: Side| path == "a.txt"; + let v = validate( + input(vec![ + B::Markdown { text: "\n# Hello world\nmore".into(), anchor: anchor("a.txt", 3, Some(3)) }, + B::Markdown { text: "x".into(), anchor: anchor("gone.txt", 1, None) }, + B::Pages { + label: Some(" Rendered ".into()), + pages: vec![ReportPageInput { + label: "p. 1".into(), + old: None, + new: Some("p/1.png".into()), + anchor: anchor("a.txt", 0, None), + }], + }, + ]), + &files, + shown, + ) + .unwrap(); + assert_eq!((v.title.as_str(), v.summary.as_str()), ("Notes", "Hello world")); + assert_eq!(v.notes.len(), 2, "{:?}", v.notes); + let ReportBlock::Markdown { anchor: Some(a), .. } = &v.blocks[0] else { panic!("{:?}", v.blocks[0]) }; + assert_eq!((a.line, a.end_line), (3, None)); + let ReportBlock::Markdown { anchor: None, .. } = &v.blocks[1] else { panic!("{:?}", v.blocks[1]) }; + let ReportBlock::Pages { label, pages } = &v.blocks[2] else { panic!("{:?}", v.blocks[2]) }; + assert_eq!(label.as_deref(), Some("Rendered")); + assert_eq!(pages[0].new.as_ref().unwrap().key, "k1"); + assert!(pages[0].anchor.is_none()); + } + + #[test] + fn refuses_what_it_cannot_serve() { + let files = HashMap::new(); + let shown = |_: &str, _: Side| true; + let page = |path: &str| B::Pages { + label: None, + pages: vec![ReportPageInput { label: "p".into(), old: Some(path.into()), new: None, anchor: None }], + }; + let cases: Vec<(ReportInput, &str)> = vec![ + (ReportInput { title: " ".into(), summary: None, target: None, blocks: vec![] }, "title: required"), + (input(vec![page("../x.png")]), "relative path"), + (input(vec![page("x.png")]), "no file uploaded"), + (input(vec![B::Markdown { text: "x".repeat(MAX_MARKDOWN_BYTES + 1), anchor: None }]), "at most"), + (input(vec![B::Pages { label: None, pages: vec![] }; MAX_BLOCKS + 1]), "blocks: at most"), + ]; + for (report, needle) in cases { + let err = validate(report, &files, shown).unwrap_err().to_string(); + assert!(err.contains(needle), "{err}"); + } + for id in ["ok-1", "a"] { + check_id(id).unwrap(); + } + for id in ["", "A", "-a", "a_b", &"a".repeat(65)] { + assert!(check_id(id).is_err(), "{id}"); + } + assert!(safe_relative("a/b.png") && !safe_relative("/a") && !safe_relative("a/../b") && !safe_relative("a\\b")); + } +} diff --git a/crates/diffle/src/reports/mod.rs b/crates/diffle/src/reports/mod.rs new file mode 100644 index 00000000..ac3a4cc8 --- /dev/null +++ b/crates/diffle/src/reports/mod.rs @@ -0,0 +1,221 @@ +//! Reports: documents attached to a snapshot, made by the generators diffle runs for it or pushed +//! over the API by something outside, an agent say. One view in the client shows them all. +pub mod catalog; +mod exec; +mod generated; +pub mod input; +mod pushed; +pub mod runner; +mod sides; +pub mod store; + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::path::Path; +use std::sync::{Arc, Mutex, Weak}; + +use sha1::{Digest, Sha1}; +use tokio::task::AbortHandle; + +use crate::error::{Error, Result}; +use crate::protocol::{Report, ReportFile, ReportTarget, ReportsResponse, Snapshot}; +use crate::session::{Broadcaster, Session}; +use catalog::Plugin; +use runner::Produced; +use store::{ReportStore, Stored}; + +struct State { + /// The newest snapshot seen, which generated reports answer for. + version: i64, + /// The comment key `pushed` was loaded for. + key: Option, + generated: BTreeMap, + pushed: BTreeMap, + /// Files uploaded for a report not put yet, by id then authored path. + uploads: HashMap>, + /// What each generator wrote to stderr on its last run. + logs: HashMap>, +} + +/// Every report of the active review. Generated ones follow the snapshot: each new one starts +/// the generators whose globs match a changed file, a run still going for an older snapshot is +/// killed, and a result commits only while its snapshot is the newest. Pushed ones persist under +/// the review's comment key and go stale, never away, when the snapshot moves. +pub struct Reports { + session: Arc, + hub: Arc, + store: ReportStore, + plugins: Vec, + state: Mutex, + /// The run in flight per generator. + runs: Mutex>, + /// Results by generator and input, so stepping back to a snapshot seen before is instant. + cache: Mutex>>, +} + +/// The snapshot a report answers for: its endpoints, plus the changed files' blobs so a worktree +/// side that moved on is told apart. +pub fn target_of(snap: &Snapshot) -> ReportTarget { + let mut hasher = Sha1::new(); + hasher.update(snap.old_sha.as_bytes()); + hasher.update(b"\0"); + hasher.update(snap.new_sha.as_bytes()); + for f in &snap.changed { + for part in [&f.path, &f.status.to_string(), &f.blob, &f.old_blob] { + hasher.update(b"\0"); + hasher.update(part.as_bytes()); + } + } + ReportTarget { + old_sha: snap.old_sha.clone(), + new_sha: snap.new_sha.clone(), + digest: hex::encode(hasher.finalize()), + } +} + +pub fn same_target(a: &ReportTarget, b: &ReportTarget) -> bool { + a.old_sha == b.old_sha && a.new_sha == b.new_sha && a.digest == b.digest +} + +impl Reports { + /// Reports for `session`, kept under `git_dir`, with `plugins` run on every snapshot from now on. + pub fn new(session: Arc, hub: Arc, git_dir: &Path, plugins: Vec) -> Arc { + let plugins = plugins + .into_iter() + .filter(|p| { + let ok = input::check_id(&p.name).is_ok(); + if !ok { + crate::status!( + "[diffle] ignoring plugin \"{}\": a name is lowercase letters, digits and dashes", + p.name + ); + } + ok + }) + .collect(); + let this = Arc::new(Self { + session: session.clone(), + hub, + store: ReportStore::new(git_dir), + plugins, + state: Mutex::new(State { + version: 0, + key: None, + generated: BTreeMap::new(), + pushed: BTreeMap::new(), + uploads: HashMap::new(), + logs: HashMap::new(), + }), + runs: Mutex::new(HashMap::new()), + cache: Mutex::new(HashMap::new()), + }); + let weak: Weak = Arc::downgrade(&this); + session.on_snapshot(move |snap| { + if let Some(reports) = weak.upgrade() { + let snap = snap.clone(); + tokio::spawn(async move { reports.sync(snap).await }); + } + }); + this + } + + pub fn plugins(&self) -> &[Plugin] { + &self.plugins + } + + /// The current snapshot and its target; the pushed reports are loaded for its key first. + async fn current(&self) -> Result<(Arc, ReportTarget)> { + let snap = self.session.snapshotter()?.current().await?; + self.load_pushed(&snap).await?; + let target = target_of(&snap); + Ok((snap, target)) + } + + async fn load_pushed(&self, snap: &Snapshot) -> Result<()> { + if self.state.lock().unwrap().key.as_deref() == Some(&snap.comment_key) { + return Ok(()); + } + let pushed = self.store.load(&snap.comment_key).await?; + let mut state = self.state.lock().unwrap(); + state.key = Some(snap.comment_key.clone()); + state.pushed = pushed.into_iter().map(|r| (r.id.clone(), r)).collect(); + Ok(()) + } + + fn served(stored: &Stored, current: &ReportTarget) -> Report { + Report { + id: stored.id.clone(), + source: stored.source, + title: stored.title.clone(), + summary: stored.summary.clone(), + target: stored.target.clone(), + status: stored.status, + error: stored.error.clone(), + stale: !same_target(&stored.target, current), + updated_at: stored.updated_at, + blocks: stored.blocks.clone(), + } + } + + pub async fn list(&self) -> Result { + let (_, current) = self.current().await?; + let state = self.state.lock().unwrap(); + let reports = + state.generated.values().chain(state.pushed.values()).map(|r| Self::served(r, ¤t)).collect(); + Ok(ReportsResponse { current, reports }) + } + + pub async fn current_target(&self) -> Result { + Ok(self.current().await?.1) + } + + pub async fn get(&self, id: &str) -> Result { + let (_, current) = self.current().await?; + let state = self.state.lock().unwrap(); + let stored = + state.generated.get(id).or_else(|| state.pushed.get(id)).ok_or_else(|| Error::NotFound(id.into()))?; + Ok(Self::served(stored, ¤t)) + } + + /// What the report's maker wrote to stderr, or what validation dropped. + pub fn log(&self, id: &str) -> Result> { + let state = self.state.lock().unwrap(); + if !state.generated.contains_key(id) && !state.pushed.contains_key(id) { + return Err(Error::NotFound(id.into())); + } + Ok(state.logs.get(id).cloned().unwrap_or_default()) + } + + /// The bytes report `id` names under `key`, or none: a key the report does not name serves nothing. + pub async fn file(&self, id: &str, key: &str) -> Result, &'static str)>> { + let named = { + let state = self.state.lock().unwrap(); + let report = state.generated.get(id).or_else(|| state.pushed.get(id)); + report.is_some_and(|r| r.keys().any(|k| k == key)) + }; + if !named { + return Ok(None); + } + self.store.read_blob(key).await + } + + /// Drops blobs nothing names any more; a failure here costs disk, not correctness. + async fn sweep(&self) { + let keep: HashSet = { + let state = self.state.lock().unwrap(); + let kept = state.generated.values().chain(state.pushed.values()).flat_map(Stored::keys).map(str::to_owned); + kept.chain(state.uploads.values().flatten().map(|(_, f)| f.key.clone())).collect() + }; + if let Err(e) = self.store.sweep(&keep, runner::RUN_TIMEOUT).await { + crate::status!("[diffle] sweeping report files: {e:#}"); + } + } +} + +fn block_keys(block: &crate::protocol::ReportBlock) -> Vec<&str> { + match block { + crate::protocol::ReportBlock::Markdown { .. } => Vec::new(), + crate::protocol::ReportBlock::Pages { pages, .. } => { + pages.iter().flat_map(|p| [p.old.as_ref(), p.new.as_ref()]).flatten().map(|f| f.key.as_str()).collect() + } + } +} diff --git a/crates/diffle/src/reports/pushed.rs b/crates/diffle/src/reports/pushed.rs new file mode 100644 index 00000000..2d9d49a7 --- /dev/null +++ b/crates/diffle/src/reports/pushed.rs @@ -0,0 +1,84 @@ +//! Reports pushed over the API: files uploaded ahead, the put that resolves them, and removal. +use std::collections::HashSet; + +use super::store::Stored; +use super::{Reports, block_keys, input}; +use crate::error::{Error, Result}; +use crate::iterations::now_ms; +use crate::protocol::{Report, ReportFile, ReportInput, ReportSource, ReportStatus, ServerMessage, Side}; + +impl Reports { + /// Keeps `bytes` for the next put of report `id`, under the authored `path`. + pub async fn upload(&self, id: &str, path: &str, bytes: &[u8]) -> Result { + input::check_id(id)?; + if !input::safe_relative(path) { + return Err(Error::Invalid(format!("path: a relative path, got \"{path}\""))); + } + let file = self.store.put_blob(bytes).await?; + self.state.lock().unwrap().uploads.entry(id.to_owned()).or_default().insert(path.to_owned(), file.clone()); + Ok(file) + } + + /// Creates or replaces the pushed report `id` for the current snapshot. True when created. + pub async fn put(&self, id: &str, report: ReportInput) -> Result<(Report, bool)> { + input::check_id(id)?; + let (snap, current) = self.current().await?; + let (files, created) = { + let state = self.state.lock().unwrap(); + if state.generated.contains_key(id) { + return Err(Error::Conflict(format!("\"{id}\" is a generator's report; pick another id"))); + } + let mut files = state.pushed.get(id).map(|r| r.files.clone()).unwrap_or_default(); + // Uploads outlive a refused put: the caller fixes the report, not the files. + files.extend(state.uploads.get(id).cloned().unwrap_or_default()); + (files, !state.pushed.contains_key(id)) + }; + let session = self.session.clone(); + let shown = |path: &str, side: Side| -> bool { session.has_side(&snap, path, side) }; + let valid = input::validate(report, &files, shown)?; + let named: HashSet<&str> = valid.blocks.iter().flat_map(block_keys).collect(); + let files = files.into_iter().filter(|(_, f)| named.contains(f.key.as_str())).collect(); + let stored = Stored { + id: id.to_owned(), + source: ReportSource::Pushed, + title: valid.title, + summary: valid.summary, + target: current.clone(), + status: ReportStatus::Ready, + error: None, + updated_at: now_ms(), + blocks: valid.blocks, + files, + }; + let served = Self::served(&stored, ¤t); + let pushed: Vec = { + let mut state = self.state.lock().unwrap(); + state.uploads.remove(id); + state.logs.insert(id.to_owned(), valid.notes); + state.pushed.insert(id.to_owned(), stored); + state.pushed.values().cloned().collect() + }; + self.store.save(&snap.comment_key, &pushed).await?; + self.sweep().await; + self.hub.broadcast(ServerMessage::Reports); + Ok((served, created)) + } + + /// Removes the pushed report `id`; a generator's cannot go. + pub async fn delete(&self, id: &str) -> Result<()> { + let (snap, _) = self.current().await?; + let pushed: Vec = { + let mut state = self.state.lock().unwrap(); + if state.generated.contains_key(id) { + return Err(Error::Conflict(format!("\"{id}\" is a generator's report; turn the plugin off instead"))); + } + state.pushed.remove(id).ok_or_else(|| Error::NotFound(id.into()))?; + state.logs.remove(id); + state.pushed.values().cloned().collect() + }; + self.store.save(&snap.comment_key, &pushed).await?; + self.sweep().await; + self.hub.broadcast(ServerMessage::Reports); + Ok(()) + } +} diff --git a/crates/diffle/src/reports/runner.rs b/crates/diffle/src/reports/runner.rs new file mode 100644 index 00000000..54a65ae4 --- /dev/null +++ b/crates/diffle/src/reports/runner.rs @@ -0,0 +1,217 @@ +//! Running one generator for one snapshot: copies of the sides in a temporary folder, the program +//! started on them with the review on stdin, and what it wrote read back as a report. +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; + +use super::catalog::{Plugin, Scope}; +use super::exec::{Vars, pipeline, stages_of}; +use super::input::{self, safe_relative}; +use super::sides::materialize; +use super::store::ReportStore; +use crate::error::{Error, Result}; +use crate::protocol::{ + ChangeStatus, ChangedFile, ReportAnchor, ReportBlock, ReportFile, ReportInput, ReportTarget, Side, Snapshot, +}; +use crate::session::Session; + +/// How long one generator may take for one snapshot. +pub const RUN_TIMEOUT: Duration = Duration::from_secs(600); +pub(super) const FILE_VAR: &str = "DIFFLE_FILE"; + +/// What a run made: a report's parts and the files it named, already stored. +pub struct Produced { + pub title: String, + pub summary: String, + pub blocks: Vec, + pub files: HashMap, + pub log: Vec, +} + +/// Why a run made no report, with what the program said on the way. +#[derive(Debug)] +pub struct Failure { + pub message: String, + pub log: Vec, +} + +impl From for Failure { + fn from(e: Error) -> Self { + Self { message: e.to_string(), log: Vec::new() } + } +} + +impl From for Failure { + fn from(e: std::io::Error) -> Self { + Self { message: e.to_string(), log: Vec::new() } + } +} + +pub(super) fn failed(message: impl Into) -> Error { + Error::Invalid(message.into()) +} + +/// Runs `plugin` for `snap`, whose changed files `matched` are the ones its globs chose. +pub async fn run( + session: &Arc, + store: &ReportStore, + snap: &Arc, + target: &ReportTarget, + plugin: &Plugin, + matched: &[String], +) -> Result { + let stages = stages_of(&plugin.command)?; + let dir = tempfile::Builder::new().prefix("diffle-report-").tempdir()?; + let (old, new, out) = (dir.path().join("old"), dir.path().join("new"), dir.path().join("out")); + materialize(session, snap, plugin.scope, matched, &old, &new).await?; + let per_file = + plugin.command.contains(&format!("${FILE_VAR}")) || plugin.command.contains(&format!("${{{FILE_VAR}}}")); + let runs: Vec> = if per_file { matched.iter().cloned().map(Some).collect() } else { vec![None] }; + let mut produced = Produced { + title: plugin.name.clone(), + summary: String::new(), + blocks: Vec::new(), + files: HashMap::new(), + log: Vec::new(), + }; + let changed: Vec<&ChangedFile> = snap.changed.iter().filter(|f| matched.contains(&f.path)).collect(); + for (n, file) in runs.iter().enumerate() { + let run_out = out.join(n.to_string()); + tokio::fs::create_dir_all(&run_out).await?; + let vars = Vars { + root: snap.root.clone(), + old: old.clone(), + new: new.clone(), + out: run_out.clone(), + file: file.clone(), + }; + let stdin = json!({ + "version": 1, + "review": snap.review, + "target": target, + "root": vars.root, + "old": vars.old, + "new": vars.new, + "out": vars.out, + "file": file, + "files": changed.iter().map(|f| json!({ "path": f.path, "oldPath": f.old_path, "status": f.status })).collect::>(), + "scope": if plugin.scope == Scope::Tree { "tree" } else { "changed" }, + }); + let output = + match tokio::time::timeout(RUN_TIMEOUT, pipeline(&stages, &vars, stdin.to_string().into_bytes())).await { + Ok(Ok(output)) => output, + Ok(Err(mut failure)) => { + produced.log.append(&mut failure.log); + return Err(Failure { message: failure.message, log: produced.log }); + } + Err(_) => { + return Err(Failure { + message: format!("timed out after {}s", RUN_TIMEOUT.as_secs()), + log: produced.log, + }); + } + }; + produced.log.extend(output.log); + let (anchor, heading) = match file.as_deref().and_then(|f| changed.iter().find(|c| c.path == f)) { + Some(f) => { + let side = if f.status == ChangeStatus::D { Side::Old } else { Side::New }; + ( + Some(ReportAnchor { path: f.path.clone(), side: Some(side), line: 1, end_line: None }), + Some(f.path.clone()), + ) + } + None => (None, None), + }; + if let Err(e) = collect(session, store, snap, &run_out, output.stdout, anchor, heading, &mut produced).await { + return Err(Failure { message: e.to_string(), log: produced.log }); + } + } + if produced.blocks.is_empty() { + return Err(Failure { + message: "the program wrote neither a report.json nor anything to stdout".into(), + log: produced.log, + }); + } + if produced.summary.is_empty() { + produced.summary = produced + .blocks + .iter() + .find_map(|b| match b { + ReportBlock::Markdown { text, .. } => text + .lines() + .map(str::trim) + .find(|l| !l.is_empty()) + .map(|l| l.trim_start_matches('#').trim().to_owned()), + ReportBlock::Pages { .. } => None, + }) + .unwrap_or_default(); + } + Ok(produced) +} + +/// Reads one run's output: `report.json` with its files, else stdout as one markdown block. +#[allow(clippy::too_many_arguments)] +async fn collect( + session: &Arc, + store: &ReportStore, + snap: &Arc, + out: &Path, + stdout: Vec, + anchor: Option, + heading: Option, + produced: &mut Produced, +) -> Result<()> { + let report_file = out.join("report.json"); + let Ok(text) = tokio::fs::read_to_string(&report_file).await else { + let text = String::from_utf8_lossy(&stdout).trim().to_owned(); + if !text.is_empty() { + let text = match heading { + Some(path) => format!("### {path}\n\n{text}"), + None => text, + }; + produced.blocks.push(ReportBlock::Markdown { text, anchor }); + } + return Ok(()); + }; + let report: ReportInput = serde_json::from_str(&text).map_err(|e| failed(format!("report.json: {e}")))?; + let mut files = HashMap::new(); + for path in named_files(&report) { + if !safe_relative(&path) { + return Err(failed(format!( + "report.json names \"{path}\": a relative path under the output folder is expected" + ))); + } + let bytes = + tokio::fs::read(out.join(&path)).await.map_err(|e| failed(format!("report.json names \"{path}\": {e}")))?; + let file = store.put_blob(&bytes).await.map_err(|e| failed(format!("\"{path}\": {e}")))?; + files.insert(path, file); + } + let shown = |path: &str, side: Side| -> bool { session.has_side(snap, path, side) }; + let valid = input::validate(report, &files, shown)?; + if produced.blocks.is_empty() { + produced.title = valid.title; + produced.summary = valid.summary; + } + produced.blocks.extend(valid.blocks); + produced.files.extend(files); + produced.log.extend(valid.notes); + Ok(()) +} + +fn named_files(report: &ReportInput) -> Vec { + use crate::protocol::ReportBlockInput; + let mut names = Vec::new(); + for block in &report.blocks { + if let ReportBlockInput::Pages { pages, .. } = block { + for page in pages { + names.extend(page.old.iter().chain(page.new.iter()).cloned()); + } + } + } + names.sort(); + names.dedup(); + names +} diff --git a/crates/diffle/src/reports/sides.rs b/crates/diffle/src/reports/sides.rs new file mode 100644 index 00000000..50384a45 --- /dev/null +++ b/crates/diffle/src/reports/sides.rs @@ -0,0 +1,71 @@ +//! The copies of the sides a generator reads: the matched changed files, or the whole snapshot, +//! each side as the review shows it. +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use futures::{StreamExt, TryStreamExt, stream}; + +use super::catalog::Scope; +use super::input::safe_relative; +use crate::error::{Error, Result}; +use crate::protocol::{ChangeStatus, ChangedFile, Side, Snapshot}; +use crate::session::Session; + +/// Concurrent side reads while copying. +const COPY_CONCURRENCY: usize = 8; + +/// Writes the files a generator gets into `old` and `new`: the matched changed files, or the +/// whole snapshot, each side as the review shows it. Ignored files are absent, as everywhere. +pub(super) async fn materialize( + session: &Arc, + snap: &Arc, + scope: Scope, + matched: &[String], + old: &Path, + new: &Path, +) -> Result<()> { + let by_path: HashMap<&str, &ChangedFile> = snap.changed.iter().map(|f| (f.path.as_str(), f)).collect(); + let new_paths: Vec = match scope { + Scope::Changed => matched + .iter() + .filter(|p| by_path.get(p.as_str()).is_none_or(|f| f.status != ChangeStatus::D)) + .cloned() + .collect(), + Scope::Tree => snap.tree.clone(), + }; + // Old side: every path read by its new name, written under its old one; deleted files too. + let mut old_paths: Vec<(String, String)> = match scope { + Scope::Changed => matched.to_vec(), + Scope::Tree => snap.tree.clone(), + } + .into_iter() + .filter(|p| by_path.get(p.as_str()).is_none_or(|f| f.status != ChangeStatus::A)) + .map(|p| { + let target = by_path.get(p.as_str()).and_then(|f| f.old_path.clone()).unwrap_or_else(|| p.clone()); + (p, target) + }) + .collect(); + if scope == Scope::Tree { + old_paths.extend( + snap.changed.iter().filter(|f| f.status == ChangeStatus::D).map(|f| (f.path.clone(), f.path.clone())), + ); + } + let copy = |side: Side, read_as: String, write_as: String, dir: PathBuf| async move { + if !safe_relative(&write_as) { + return Ok::<(), Error>(()); + } + let Some(bytes) = session.read_side(snap, &read_as, side).await? else { return Ok(()) }; + let file = dir.join(&write_as); + if let Some(parent) = file.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(&file, bytes).await?; + Ok(()) + }; + let news = new_paths.into_iter().map(|p| copy(Side::New, p.clone(), p, new.to_path_buf())); + stream::iter(news).buffer_unordered(COPY_CONCURRENCY).try_collect::>().await?; + let olds = old_paths.into_iter().map(|(read_as, write_as)| copy(Side::Old, read_as, write_as, old.to_path_buf())); + stream::iter(olds).buffer_unordered(COPY_CONCURRENCY).try_collect::>().await?; + Ok(()) +} diff --git a/crates/diffle/src/reports/store.rs b/crates/diffle/src/reports/store.rs new file mode 100644 index 00000000..8574103d --- /dev/null +++ b/crates/diffle/src/reports/store.rs @@ -0,0 +1,170 @@ +//! What a report keeps on disk: pushed reports in `/diffle/reports.json`, under the +//! review's comment key, and every report's files as content-addressed blobs beside it. +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use sha1::{Digest, Sha1}; + +use crate::error::{Error, Result}; +use crate::persist::{with_file_lock, write_file_atomic}; +use crate::protocol::{ReportBlock, ReportFile, ReportSource, ReportStatus, ReportTarget}; +use crate::server::image::image_type; + +/// The largest file a report may name. +pub const MAX_FILE_BYTES: usize = 25 * 1024 * 1024; + +/// A report as kept: what the client gets, minus `stale`, plus the files by their authored +/// path, so a report put again may name the same files without uploading them again. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Stored { + pub id: String, + pub source: ReportSource, + pub title: String, + pub summary: String, + pub target: ReportTarget, + pub status: ReportStatus, + pub error: Option, + pub updated_at: i64, + pub blocks: Vec, + #[serde(default)] + pub files: HashMap, +} + +impl Stored { + /// Every blob key the report's pages name. + pub fn keys(&self) -> impl Iterator { + self.blocks.iter().flat_map(|b| match b { + ReportBlock::Markdown { .. } => Vec::new(), + ReportBlock::Pages { pages, .. } => { + pages.iter().flat_map(|p| [p.old.as_ref(), p.new.as_ref()]).flatten().map(|f| f.key.as_str()).collect() + } + }) + } +} + +#[derive(Serialize, Deserialize)] +struct StoreFile { + version: u8, + /// Pushed reports by comment key. + sets: BTreeMap>, +} + +/// Owns `reports.json` and the blob folder. Nothing is cached: every mutation is a read-modify-write +/// under the file lock, touching only its own key, so two servers on one repository keep each +/// other's reports. +pub struct ReportStore { + file: PathBuf, + blobs: PathBuf, +} + +impl ReportStore { + pub fn new(git_dir: &Path) -> Self { + let dir = git_dir.join("diffle"); + Self { file: dir.join("reports.json"), blobs: dir.join("reports") } + } + + /// The pushed reports under `key`, as on disk now. + pub async fn load(&self, key: &str) -> Result> { + Ok(read_store_file(&self.file).await?.sets.remove(key).unwrap_or_default()) + } + + /// Replaces the pushed reports under `key`. + pub async fn save(&self, key: &str, reports: &[Stored]) -> Result<()> { + with_file_lock(&self.file, || async { + let mut all = read_store_file(&self.file).await?; + if reports.is_empty() { + all.sets.remove(key); + } else { + all.sets.insert(key.to_owned(), reports.to_vec()); + } + write_file_atomic(&self.file, &serde_json::to_string_pretty(&all)?).await + }) + .await?; + Ok(()) + } + + /// Keeps `bytes` under their digest, when they are a raster image. The key is the digest, so + /// the same bytes land once however often they are put. + pub async fn put_blob(&self, bytes: &[u8]) -> Result { + let Some(type_) = image_type(bytes) else { + return Err(Error::Invalid("not a raster image (png, jpeg, gif, webp, avif, bmp)".into())); + }; + if bytes.len() > MAX_FILE_BYTES { + return Err(Error::Invalid(format!("at most {} bytes per file", MAX_FILE_BYTES))); + } + let key = hex::encode(Sha1::digest(bytes)); + let file = self.blobs.join(&key); + if tokio::fs::metadata(&file).await.is_err() { + tokio::fs::create_dir_all(&self.blobs).await?; + let tmp = self.blobs.join(format!("{key}.{}.tmp", uuid::Uuid::new_v4().simple())); + tokio::fs::write(&tmp, bytes).await?; + if let Err(e) = tokio::fs::rename(&tmp, &file).await { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(e.into()); + } + } + Ok(ReportFile { key, type_: type_.to_owned() }) + } + + /// The bytes under `key` and their type, or none. + pub async fn read_blob(&self, key: &str) -> Result, &'static str)>> { + if key.len() != 40 || !key.bytes().all(|b| b.is_ascii_hexdigit()) { + return Ok(None); + } + match tokio::fs::read(self.blobs.join(key)).await { + Ok(bytes) => Ok(image_type(&bytes).map(|t| (bytes, t))), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + } + } + + /// Removes every blob no pushed report on disk and nothing in `keep` names. A blob younger + /// than `grace` stays: a run still going may have written it and not named it yet. + pub async fn sweep(&self, keep: &HashSet, grace: Duration) -> Result<()> { + let mut named: HashSet = keep.clone(); + for report in read_store_file(&self.file).await?.sets.values().flatten() { + named.extend(report.keys().map(str::to_owned)); + } + let mut entries = match tokio::fs::read_dir(&self.blobs).await { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(e.into()), + }; + while let Some(entry) = entries.next_entry().await? { + let name = entry.file_name().to_string_lossy().into_owned(); + let young = entry + .metadata() + .await + .and_then(|m| m.modified()) + .map(|at| at.elapsed().unwrap_or_default() < grace) + .unwrap_or(true); + // A `..tmp` is a put in flight; only a finished blob is ever swept. + let blob = name.len() == 40 && name.bytes().all(|b| b.is_ascii_hexdigit()); + if blob && !named.contains(&name) && !young { + let _ = tokio::fs::remove_file(entry.path()).await; + } + } + Ok(()) + } +} + +/// The file as stored, or an empty store when absent or of another format. Malformed JSON fails. +async fn read_store_file(file: &Path) -> anyhow::Result { + let text = match tokio::fs::read_to_string(file).await { + Ok(text) => text, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(empty()), + Err(e) => return Err(e.into()), + }; + let value: serde_json::Value = serde_json::from_str(&text)?; + if value.get("version").and_then(serde_json::Value::as_u64) != Some(1) || !value["sets"].is_object() { + return Ok(empty()); + } + Ok(serde_json::from_value(value)?) +} + +fn empty() -> StoreFile { + StoreFile { version: 1, sets: BTreeMap::new() } +} diff --git a/crates/diffle/src/server/mod.rs b/crates/diffle/src/server/mod.rs index 795b1e3d..3dd9f87d 100644 --- a/crates/diffle/src/server/mod.rs +++ b/crates/diffle/src/server/mod.rs @@ -32,6 +32,8 @@ pub const DEFAULT_PORT: u16 = 4966; const PROBE_PORTS: u16 = 100; /// JSON bodies are small (comments, viewed marks, config); anything bigger is not a client. const MAX_BODY_BYTES: usize = 1024 * 1024; +/// Reports carry rendered pages: their routes take a file's worth. +const MAX_REPORT_BYTES: usize = crate::reports::store::MAX_FILE_BYTES; pub struct ServerOptions { pub port: u16, @@ -49,6 +51,7 @@ pub struct ApiDeps { pub hub: Arc, /// The language servers, unless the run disabled them with `--no-lsp`. pub lsp: Option>, + pub reports: Arc, } struct App { @@ -145,6 +148,7 @@ async fn check_guard(State(app): State>, req: Request, next: Next) -> R /// Reads the body under the cap. An oversized upload is drained before the reply: closing /// mid-write gives clients EPIPE, not 413. async fn limit_body(req: Request, next: Next) -> Response { + let cap = if req.uri().path().starts_with("/api/reports/") { MAX_REPORT_BYTES } else { MAX_BODY_BYTES }; let (parts, mut body) = req.into_parts(); let mut kept = Vec::new(); let mut size = 0; @@ -152,12 +156,12 @@ async fn limit_body(req: Request, next: Next) -> Response { let Ok(frame) = frame else { return ApiError::bad_request("unreadable request body").into_response() }; if let Some(data) = frame.data_ref() { size += data.len(); - if size <= MAX_BODY_BYTES { + if size <= cap { kept.extend_from_slice(data); } } } - if size > MAX_BODY_BYTES { + if size > cap { return ApiError(StatusCode::PAYLOAD_TOO_LARGE, "request body too large".into()).into_response(); } next.run(Request::from_parts(parts, Body::from(Bytes::from(kept)))).await diff --git a/crates/diffle/src/server/routes/mod.rs b/crates/diffle/src/server/routes/mod.rs index 9cbb0207..d8c443b4 100644 --- a/crates/diffle/src/server/routes/mod.rs +++ b/crates/diffle/src/server/routes/mod.rs @@ -3,6 +3,7 @@ mod api; mod comments; mod files; mod lsp; +mod reports; use std::sync::Arc; @@ -16,7 +17,12 @@ use super::ApiDeps; use crate::error::Error; pub fn router() -> Router> { - Router::new().merge(api::routes()).merge(files::routes()).merge(comments::routes()).merge(lsp::routes()) + Router::new() + .merge(api::routes()) + .merge(files::routes()) + .merge(comments::routes()) + .merge(lsp::routes()) + .merge(reports::routes()) } /// An error as the API answers it: a status and `{ "error": message }`. @@ -40,7 +46,7 @@ impl From for ApiError { Error::NotFound(_) => Self::not_found("not found"), Error::Revspec(m) | Error::Invalid(m) | Error::Unquotable(m) => Self::bad_request(m), Error::Git(e) => Self::bad_request(e.message), - Error::LspUnavailable(m) | Error::LspUnsupported(m) => Self(StatusCode::CONFLICT, m), + Error::LspUnavailable(m) | Error::LspUnsupported(m) | Error::Conflict(m) => Self(StatusCode::CONFLICT, m), Error::Github(e) => Self(StatusCode::from_u16(e.status).unwrap_or(StatusCode::BAD_GATEWAY), e.message), Error::Other(e) => { crate::status!("[diffle] {e:#}"); diff --git a/crates/diffle/src/server/routes/reports.rs b/crates/diffle/src/server/routes/reports.rs new file mode 100644 index 00000000..560ceb30 --- /dev/null +++ b/crates/diffle/src/server/routes/reports.rs @@ -0,0 +1,74 @@ +//! Reports: listing, pushing, deleting, and the files and logs behind them. +use std::sync::Arc; + +use axum::Json; +use axum::Router; +use axum::body::Bytes; +use axum::extract::{Path, RawQuery, State}; +use axum::http::{StatusCode, header}; +use axum::response::IntoResponse; +use axum::routing::{get, put}; +use serde_json::json; + +use super::{ApiDeps, ApiError, ApiResult, body, query}; +use crate::protocol::{ReportFileQuery, ReportInput, ReportLogResponse}; +use crate::reports::same_target; + +pub fn routes() -> Router> { + Router::new() + .route("/api/reports", get(list)) + .route("/api/reports/{id}", get(one).put(put_report).delete(remove)) + .route("/api/reports/{id}/files/{*path}", put(upload)) + .route("/api/reports/{id}/file", get(file)) + .route("/api/reports/{id}/log", get(log)) +} + +async fn list(State(d): State>) -> ApiResult { + Ok(Json(d.reports.list().await?).into_response()) +} + +async fn one(State(d): State>, Path(id): Path) -> ApiResult { + Ok(Json(d.reports.get(&id).await?).into_response()) +} + +/// Creates (201) or replaces (200) a pushed report. A `target` that is not the current snapshot's +/// answers 409 with the current one, so the caller can refetch what it describes. +async fn put_report(State(d): State>, Path(id): Path, raw: Bytes) -> ApiResult { + let req: ReportInput = body(&raw)?; + if let Some(target) = &req.target { + let current = d.reports.current_target().await?; + if !same_target(target, ¤t) { + let error = "target: the snapshot moved on; fetch the review again and name the current target"; + return Ok((StatusCode::CONFLICT, Json(json!({ "error": error, "current": current }))).into_response()); + } + } + let (report, created) = d.reports.put(&id, req).await?; + let status = if created { StatusCode::CREATED } else { StatusCode::OK }; + Ok((status, Json(report)).into_response()) +} + +async fn remove(State(d): State>, Path(id): Path) -> ApiResult { + d.reports.delete(&id).await?; + Ok(StatusCode::NO_CONTENT.into_response()) +} + +/// A file the next put of report `id` may name as `path`: raw bytes, a raster image. +async fn upload(State(d): State>, Path((id, path)): Path<(String, String)>, raw: Bytes) -> ApiResult { + let file = d.reports.upload(&id, &path, &raw).await?; + Ok((StatusCode::CREATED, Json(file)).into_response()) +} + +async fn file(State(d): State>, Path(id): Path, RawQuery(raw): RawQuery) -> ApiResult { + let q: ReportFileQuery = query(raw.as_deref())?; + match d.reports.file(&id, &q.key).await? { + Some((bytes, kind)) => { + Ok(([(header::CONTENT_TYPE, kind), (header::CACHE_CONTROL, "private, max-age=31536000, immutable")], bytes) + .into_response()) + } + None => Err(ApiError::not_found("no such file in the report")), + } +} + +async fn log(State(d): State>, Path(id): Path) -> ApiResult { + Ok(Json(ReportLogResponse { lines: d.reports.log(&id)? }).into_response()) +} diff --git a/crates/diffle/src/user_config.rs b/crates/diffle/src/user_config.rs index 1145c987..07f948c6 100644 --- a/crates/diffle/src/user_config.rs +++ b/crates/diffle/src/user_config.rs @@ -6,7 +6,7 @@ use std::sync::Mutex; use serde_json::{Map, Value}; use crate::persist::{user_config_dir, write_file_atomic}; -use crate::protocol::{FollowRefs, LanguageId, UserConfig, language_ids}; +use crate::protocol::{FollowRefs, LanguageId, PluginConfig, UserConfig, language_ids}; /// Every field optional: what `set` merges in (routes' ConfigUpdate plus CLI-only lspCommands/githubHosts). #[derive(Default, Clone, Debug)] @@ -26,6 +26,7 @@ impl Default for UserConfig { line_wrap: true, lsp_commands: HashMap::new(), github_hosts: HashMap::new(), + plugins: HashMap::new(), } } } @@ -117,6 +118,7 @@ fn normalize(value: &Value) -> UserConfig { line_wrap, lsp_commands: lsp_commands(c.get("lspCommands")), github_hosts: github_hosts(c.get("githubHosts")), + plugins: plugins(c.get("plugins")), } } @@ -138,6 +140,22 @@ fn lsp_commands(value: Option<&Value>) -> HashMap { out } +/// Report generators by name. An entry that is not an object with a `command` string is dropped +/// with a warning: a malformed one would otherwise sit there looking effective. +fn plugins(value: Option<&Value>) -> HashMap { + let mut out = HashMap::new(); + for (name, entry) in value.and_then(Value::as_object).into_iter().flatten() { + let name = name.trim(); + match serde_json::from_value::(entry.clone()) { + Ok(plugin) if !name.is_empty() => { + out.insert(name.to_owned(), plugin); + } + _ => crate::status!("[diffle] ignoring config plugins.{name}: expected an object with a command"), + } + } + out +} + /// Lowercased host names; an entry without a web host is dropped, it would point nowhere. fn github_hosts(value: Option<&Value>) -> HashMap { let mut out = HashMap::new(); diff --git a/crates/diffle/tests/cli.rs b/crates/diffle/tests/cli.rs index fbc4242e..bf67f4bc 100644 --- a/crates/diffle/tests/cli.rs +++ b/crates/diffle/tests/cli.rs @@ -221,3 +221,43 @@ fn reports_the_review_and_hands_off_the_open_comments_on_stdout_when_stopped() { } assert_eq!(prompt, "app.py:2\n\n> y = 2\n\nrename y\n\n---\n"); } + +// Stopped with SIGTERM, which Windows has no way to send. +#[cfg(unix)] +#[test] +fn pushes_a_report_into_the_running_review_and_withdraws_the_announcement() { + let t = one_file_repo(); + t.write("app.py", "x = 1\ny = 2\n"); + let config = tempfile::tempdir().unwrap(); + let repo = t.path.to_str().unwrap(); + let notes = config.path().join("Review Notes.md"); + std::fs::write(¬es, "# Watch y\n\nIt is unused.\n").unwrap(); + + let out = run(config.path(), &["report", "put", notes.to_str().unwrap(), "-C", repo]); + assert_eq!(out.status.code(), Some(2), "{}", stderr(&out)); + assert!(stderr(&out).contains("no diffle is running"), "{}", stderr(&out)); + + let (mut child, url, mut lines) = serve(config.path(), &t.path); + let announced = std::fs::read_to_string(t.path.join(".git/diffle/server.json")).unwrap(); + assert!(announced.contains(&url), "{announced}"); + let out = run(config.path(), &["report", "put", notes.to_str().unwrap(), "--anchor", "app.py:2", "-C", repo]); + assert!(out.status.success(), "{}", stderr(&out)); + assert!(stderr(&out).contains("report Watch y added (review-notes, "), "{}", stderr(&out)); + let (status, body) = http(&url, "GET", "/api/reports/review-notes", ""); + assert!(status.contains("200"), "{status}"); + let report: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(report["blocks"][0]["anchor"], serde_json::json!({ "path": "app.py", "line": 2 })); + assert_eq!(report["summary"], "Watch y"); + let out = run(config.path(), &["report", "put", notes.to_str().unwrap(), "--id", "Nope", "-C", repo]); + assert_eq!(out.status.code(), Some(2), "{}", stderr(&out)); + let out = run(config.path(), &["report", "delete", "review-notes", "-C", repo]); + assert!(out.status.success(), "{}", stderr(&out)); + let (status, _) = http(&url, "GET", "/api/reports/review-notes", ""); + assert!(status.contains("404"), "{status}"); + + Command::new("kill").args(["-TERM", &child.id().to_string()]).status().unwrap(); + let mut rest = String::new(); + lines.read_to_string(&mut rest).unwrap(); + assert!(child.wait().unwrap().success(), "{rest}"); + assert!(!t.path.join(".git/diffle/server.json").exists()); +} diff --git a/crates/diffle/tests/common/server.rs b/crates/diffle/tests/common/server.rs index 541fbdac..dfdf3325 100644 --- a/crates/diffle/tests/common/server.rs +++ b/crates/diffle/tests/common/server.rs @@ -4,6 +4,8 @@ use std::sync::Arc; use diffle::github::hosts::Github; use diffle::lsp::pool::{LspPool, PoolOptions}; use diffle::protocol::{EnterRequest, FollowRefs, LanguageId, Side}; +use diffle::reports::Reports; +use diffle::reports::catalog::Plugin; use diffle::server::hub::Hub; use diffle::server::{ApiDeps, Server, ServerOptions}; use diffle::session::{Session, SessionOptions}; @@ -63,6 +65,7 @@ pub struct TestServer { pub base: String, server: Option, pub lsp: Option>, + pub reports: Arc, client: reqwest::Client, } @@ -97,20 +100,36 @@ impl TestServer { repo: TestRepo, req: EnterRequest, overrides: Option>, + ) -> Self { + Self::with_lsp_and_plugins(repo, req, overrides, Vec::new()).await + } + + /// A server over `repo` whose reports come from `plugins`, run as a run would. + pub async fn with_plugins(repo: TestRepo, req: EnterRequest, plugins: Vec) -> Self { + Self::with_lsp_and_plugins(repo, req, None, plugins).await + } + + async fn with_lsp_and_plugins( + repo: TestRepo, + req: EnterRequest, + overrides: Option>, + plugins: Vec, ) -> Self { let git = Arc::new(repo.open().await); let hub = Hub::new(); let config = Arc::new(UserConfigStore::open(repo.path.join("cfg").join("config.json")).await); let opts = SessionOptions { watch: false, github: Github::tokenless(), context: 3, follow_refs: FollowRefs::Off }; - let session = Session::new(git, hub.clone(), opts); + let session = Session::new(git.clone(), hub.clone(), opts); let lsp = overrides.map(|overrides| start_lsp(&session, overrides)); + let reports = Reports::new(session.clone(), hub.clone(), &git.git_dir, plugins); let deps = ApiDeps { session: session.clone(), config: config.clone(), extra_auto_viewed: Vec::new(), hub: hub.clone(), lsp: lsp.clone(), + reports: reports.clone(), }; let opts = ServerOptions { port: 0, @@ -120,7 +139,7 @@ impl TestServer { }; let (server, base) = Server::listen(deps, opts).await.unwrap(); session.start(session.resolve(req).await.unwrap()).await.unwrap(); - Self { repo, session, config, hub, base, server: Some(server), lsp, client: reqwest::Client::new() } + Self { repo, session, config, hub, base, server: Some(server), lsp, reports, client: reqwest::Client::new() } } /// `127.0.0.1:` @@ -145,6 +164,13 @@ impl TestServer { Res { status: res.status().as_u16(), body: res.text().await.unwrap() } } + /// A PUT of raw bytes, as a file upload: the status and the body text. + pub async fn client_put_bytes(&self, path: &str, bytes: Vec) -> (u16, String) { + let url = format!("{}{}", self.base.trim_end_matches('/'), path); + let res = self.client.put(url).body(bytes).send().await.unwrap(); + (res.status().as_u16(), res.text().await.unwrap()) + } + /// A GET whose response the caller reads itself: headers and bytes. pub async fn fetch(&self, path: &str) -> reqwest::Response { self.client.get(format!("{}{}", self.base.trim_end_matches('/'), path)).send().await.unwrap() @@ -158,7 +184,13 @@ impl TestServer { self.send(method, path, &[], Some(body.to_string())).await } - pub async fn close(mut self) { + pub async fn close(self) { + self.stop().await; + } + + /// Closes the server and hands back its repository, for a second server over the same one. + pub async fn stop(mut self) -> TestRepo { + self.reports.close(); if let Some(server) = self.server.take() { server.close().await; } @@ -166,5 +198,6 @@ impl TestServer { lsp.close().await; } self.session.close().await; + self.repo } } diff --git a/crates/diffle/tests/reports.rs b/crates/diffle/tests/reports.rs new file mode 100644 index 00000000..cbea9010 --- /dev/null +++ b/crates/diffle/tests/reports.rs @@ -0,0 +1,247 @@ +mod common; + +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +use common::TestRepo; +use common::server::TestServer; +use diffle::protocol::EnterRequest; +use diffle::reports::catalog::{Plugin, Scope}; +use futures::StreamExt; +use serde_json::{Value, json}; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; + +const PNG: [u8; 8] = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + +/// `a.txt` and `keep.md` committed; the worktree edits `a.txt` and adds `b.txt`. +fn repo() -> TestRepo { + let t = TestRepo::new(); + t.write("a.txt", "a\n"); + t.write("keep.md", "kept\n"); + t.commit("base"); + t.write("a.txt", "a\nb\n"); + t.write("b.txt", "bee\n"); + t +} + +async fn reports(s: &TestServer) -> Value { + let r = s.get("/api/reports").await; + assert_eq!(r.status, 200, "{}", r.body); + r.json() +} + +fn report<'a>(list: &'a Value, id: &str) -> Option<&'a Value> { + list["reports"].as_array().unwrap().iter().find(|r| r["id"] == id) +} + +/// The report `id` once its run ended, or none once it left the list. +async fn settled(s: &TestServer, id: &str) -> Option { + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let list = reports(s).await; + match report(&list, id) { + Some(r) if r["status"] != "running" => return Some(r.clone()), + None => return None, + Some(_) => {} + } + assert!(Instant::now() < deadline, "report {id} never settled: {list}"); + tokio::time::sleep(Duration::from_millis(40)).await; + } +} + +#[tokio::test] +async fn pushes_lists_and_persists_a_report() { + let s = TestServer::over(repo(), EnterRequest::Working).await; + let mut ws = tokio_tungstenite::connect_async(s.ws_url().into_client_request().unwrap()).await.unwrap().0; + let body = json!({ + "title": " Notes ", + "blocks": [ + { "kind": "markdown", "text": "\n# Risky\n\nLook at b", "anchor": { "path": "a.txt", "line": 2 } }, + { "kind": "markdown", "text": "gone", "anchor": { "path": "nope.txt", "line": 1 } } + ] + }); + let r = s.json("PUT", "/api/reports/notes", body.clone()).await; + assert_eq!(r.status, 201, "{}", r.body); + let created = r.json(); + assert_eq!((created["title"].as_str(), created["summary"].as_str()), (Some("Notes"), Some("Risky"))); + assert_eq!((created["source"].as_str(), created["stale"].as_bool()), (Some("pushed"), Some(false))); + assert_eq!(created["blocks"][0]["anchor"], json!({ "path": "a.txt", "line": 2 })); + assert!(created["blocks"][1].get("anchor").is_none(), "{}", created["blocks"][1]); + let pushed = tokio::time::timeout(Duration::from_secs(5), ws.next()).await.unwrap().unwrap().unwrap(); + assert_eq!(pushed.into_text().unwrap().as_str(), r#"{"type":"reports"}"#); + + let list = reports(&s).await; + assert_eq!(list["current"]["newSha"], "worktree"); + assert_eq!(report(&list, "notes").unwrap()["target"], list["current"]); + let log = s.get("/api/reports/notes/log").await.json(); + assert!(log["lines"][0].as_str().unwrap().contains("blocks[1].anchor dropped"), "{log}"); + assert_eq!(s.json("PUT", "/api/reports/notes", body).await.status, 200); + + // The report outlives the server: the next one over the repository lists it, unstale while + // the worktree stands, stale once it moves. + let repo = s.stop().await; + let s = TestServer::over(repo, EnterRequest::Working).await; + let list = reports(&s).await; + assert_eq!(report(&list, "notes").unwrap()["stale"], false, "{list}"); + s.repo.write("a.txt", "a\nb\nc\n"); + s.session.refresh().await; + let list = reports(&s).await; + assert_eq!(report(&list, "notes").unwrap()["stale"], true, "{list}"); + assert_ne!(report(&list, "notes").unwrap()["target"]["digest"], list["current"]["digest"]); + + assert_eq!(s.send("DELETE", "/api/reports/notes", &[], None).await.status, 204); + assert_eq!(s.get("/api/reports/notes").await.status, 404); + assert_eq!(s.send("DELETE", "/api/reports/notes", &[], None).await.status, 404); + s.close().await; +} + +#[tokio::test] +async fn serves_uploaded_pages_by_key() { + let s = TestServer::over(repo(), EnterRequest::Working).await; + let png = [&PNG[..], b"one"].concat(); + let r = s.client_put_bytes("/api/reports/render/files/p/1.png", png.clone()).await; + assert_eq!(r.0, 201, "{}", r.1); + let file: Value = serde_json::from_str(&r.1).unwrap(); + let key = file["key"].as_str().unwrap().to_owned(); + assert_eq!(file["type"], "image/png"); + let bad = s.client_put_bytes("/api/reports/render/files/bad.bin", vec![0, 1, 2, 3]).await; + assert_eq!(bad.0, 400, "{}", bad.1); + let bad = s.client_put_bytes("/api/reports/render/files/../x.png", png.clone()).await; + assert!(bad.0 == 400 || bad.0 == 404, "{} {}", bad.0, bad.1); + + let pages = |file: &str| json!({ "title": "Rendered", "blocks": [{ "kind": "pages", "label": "Pages", "pages": [{ "label": "p. 1", "old": null, "new": file }] }] }); + let r = s.json("PUT", "/api/reports/render", pages("missing.png")).await; + assert_eq!(r.status, 400, "{}", r.body); + assert!(r.body.contains("no file uploaded"), "{}", r.body); + let r = s.json("PUT", "/api/reports/render", pages("p/1.png")).await; + assert_eq!(r.status, 201, "{}", r.body); + assert_eq!(r.json()["blocks"][0]["pages"][0]["new"]["key"], key); + + let res = s.fetch(&format!("/api/reports/render/file?key={key}")).await; + assert_eq!(res.status(), 200); + assert_eq!(res.headers()["content-type"], "image/png"); + assert!(res.headers()["cache-control"].to_str().unwrap().contains("immutable")); + assert_eq!(res.bytes().await.unwrap().to_vec(), png); + assert_eq!(s.fetch("/api/reports/render/file?key=0000000000000000000000000000000000000000").await.status(), 404); + assert_eq!(s.fetch(&format!("/api/reports/other/file?key={key}")).await.status(), 404); + s.close().await; +} + +#[tokio::test] +async fn refuses_bad_ids_and_stale_targets() { + let s = TestServer::over(repo(), EnterRequest::Working).await; + let notes = + |target: Value| json!({ "title": "Notes", "target": target, "blocks": [{ "kind": "markdown", "text": "hi" }] }); + let r = s.json("PUT", "/api/reports/Bad_Id", notes(Value::Null)).await; + assert_eq!(r.status, 400, "{}", r.body); + let r = s.json("PUT", "/api/reports/notes", notes(json!({ "oldSha": "x", "newSha": "y", "digest": "z" }))).await; + assert_eq!(r.status, 409, "{}", r.body); + let current = reports(&s).await["current"].clone(); + assert_eq!(r.json()["current"], current); + let r = s.json("PUT", "/api/reports/notes", notes(current)).await; + assert_eq!(r.status, 201, "{}", r.body); + let r = s.json("PUT", "/api/reports/notes", json!({ "title": "", "blocks": [] })).await; + assert_eq!(r.status, 400, "{}", r.body); + s.close().await; +} + +/// The generator tests spawn `node` with a POSIX path in the command line; a Windows path's +/// backslashes would need quoting the tests do not exercise. +#[cfg(unix)] +mod generators { + use super::*; + + fn fake(name: &str, args: &str, when: &[&str], scope: Scope) -> Plugin { + let script = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../test/reports/fake-generator.mjs"); + let script = dunce::canonicalize(script).unwrap(); + Plugin { + name: name.into(), + command: format!("node '{}' {args}", script.display()), + when: when.iter().map(|w| (*w).to_owned()).collect(), + scope, + configured: true, + } + } + + fn node() -> bool { + let found = diffle::lsp::which::find_on_path("node").is_some(); + if !found { + eprintln!("node is not on PATH; skipping the generator tests"); + } + found + } + + #[tokio::test] + async fn runs_generators_for_matching_files() { + if !node() { + return; + } + let plugins = vec![ + fake("fake", "json", &["*.txt"], Scope::Changed), + fake("each", "stdout $DIFFLE_FILE", &["*.txt"], Scope::Changed), + fake("tree", "json", &["b.txt"], Scope::Tree), + fake("broken", "fail", &["a.txt"], Scope::Changed), + fake("quiet", "json", &["*.rs"], Scope::Changed), + Plugin { + name: "missing".into(), + command: "no-such-program-xyz".into(), + when: vec!["a.txt".into()], + scope: Scope::Changed, + configured: true, + }, + ]; + let s = TestServer::with_plugins(repo(), EnterRequest::Working, plugins).await; + + let fake = settled(&s, "fake").await.unwrap(); + assert_eq!( + (fake["status"].as_str(), fake["title"].as_str(), fake["source"].as_str()), + (Some("ready"), Some("Fake report"), Some("generator")) + ); + let text = fake["blocks"][0]["text"].as_str().unwrap(); + assert!(text.contains("old: a.txt\nnew: a.txt, b.txt\nscope: changed\nfiles: a.txt, b.txt"), "{text}"); + assert!(text.contains("a.txt=a\nb"), "{text}"); + assert_eq!(fake["blocks"][0]["anchor"], json!({ "path": "a.txt", "line": 1 })); + let key = fake["blocks"][1]["pages"][0]["new"]["key"].as_str().unwrap(); + assert_eq!(s.fetch(&format!("/api/reports/fake/file?key={key}")).await.status(), 200); + assert_eq!(s.get("/api/reports/fake/log").await.json()["lines"], json!(["rendered 1 page"])); + + let each = settled(&s, "each").await.unwrap(); + let texts: Vec<&str> = each["blocks"].as_array().unwrap().iter().map(|b| b["text"].as_str().unwrap()).collect(); + assert_eq!( + texts, + ["### a.txt\n\na.txt: old=a.txt new=a.txt,b.txt", "### b.txt\n\nb.txt: old=a.txt new=a.txt,b.txt"] + ); + assert_eq!(each["blocks"][1]["anchor"], json!({ "path": "b.txt", "side": "new", "line": 1 })); + assert_eq!(each["summary"], "a.txt"); + + let tree = settled(&s, "tree").await.unwrap(); + let text = tree["blocks"][0]["text"].as_str().unwrap(); + assert!(text.contains("old: a.txt, keep.md\nnew: a.txt, b.txt, keep.md\nscope: tree\nfiles: b.txt"), "{text}"); + + let broken = settled(&s, "broken").await.unwrap(); + assert_eq!(broken["status"], "failed"); + assert_eq!(broken["error"], "node exited 3: boom: no such thing"); + assert_eq!(s.get("/api/reports/broken/log").await.json()["lines"], json!(["boom: no such thing"])); + let missing = settled(&s, "missing").await.unwrap(); + assert_eq!(missing["error"], "no-such-program-xyz: not on PATH"); + assert!(settled(&s, "quiet").await.is_none()); + + // A generator's id is its own. + let r = s.json("PUT", "/api/reports/fake", json!({ "title": "x", "blocks": [] })).await; + assert_eq!(r.status, 409, "{}", r.body); + assert_eq!(s.send("DELETE", "/api/reports/fake", &[], None).await.status, 409); + + // The worktree moves: the report follows it, then leaves with the last matching file. + s.repo.write("a.txt", "a\nb\nc\n"); + s.session.refresh().await; + let fake = settled(&s, "fake").await.unwrap(); + assert!(fake["blocks"][0]["text"].as_str().unwrap().contains("a.txt=a\nb\nc"), "{fake}"); + assert_eq!(fake["stale"], false); + s.repo.write("a.txt", "a\n"); + s.repo.rm("b.txt"); + s.session.refresh().await; + assert!(settled(&s, "fake").await.is_none()); + assert!(settled(&s, "each").await.is_none()); + s.close().await; + } +} diff --git a/crates/diffle/tests/server.rs b/crates/diffle/tests/server.rs index ccaba6e7..17276cdc 100644 --- a/crates/diffle/tests/server.rs +++ b/crates/diffle/tests/server.rs @@ -24,6 +24,7 @@ const BODY_ROUTES: &[(&str, &str)] = &[ ("POST", "/api/lsp/hover"), ("POST", "/api/lsp/references"), ("POST", "/api/lsp/occurrences"), + ("PUT", "/api/reports/notes"), ]; type Ws = tokio_tungstenite::WebSocketStream>; diff --git a/crates/diffle/tests/snapshots/cli__help.snap b/crates/diffle/tests/snapshots/cli__help.snap index 4089f65a..ab6b26c0 100644 --- a/crates/diffle/tests/snapshots/cli__help.snap +++ b/crates/diffle/tests/snapshots/cli__help.snap @@ -9,6 +9,8 @@ Usage: diffle [OPTIONS] [revs]... [COMMAND] Commands: config print the config file path and contents lsp show the language server each language would get, and what is missing from PATH + plugins show the report generators a review would run, and what is missing from PATH + report push a report into the diffle running for this repository, or remove one completion print a shell completion script Arguments: diff --git a/example/plugins/diffle-typst b/example/plugins/diffle-typst new file mode 100755 index 00000000..3375c3d5 --- /dev/null +++ b/example/plugins/diffle-typst @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""A reference diffle report generator: the rendered pages of changed Typst documents. + +diffle's built-in `typst` plugin runs a `diffle-typst` on PATH whenever a `.typ` file changed, with +copies of both sides of the review. This one compiles each changed document on each side with +`typst compile --format png` and reports the pages as pairs, so diffle shows them side by side, +swiped, or as a pixel difference, each page pointing at the document's first line. A document that +does not compile on its own (a chapter the main file includes) is skipped with a note on stderr. + +Install it on PATH, or point the config at it: + { "plugins": { "typst": { "command": "python3 /path/to/example/plugins/diffle-typst" } } } + +The contract this follows, for writing your own generator in any language: + - stdin: JSON with `review`, `target`, `root`, `old`, `new`, `out`, `file`, `files`, `scope` + - DIFFLE_OLD, DIFFLE_NEW: folders holding the sides (every file of the snapshot for `tree` scope) + - DIFFLE_OUT: where to write `report.json` and the files it names, relative to it + - a program that writes no report.json has its stdout shown as one markdown block +""" +import json +import os +import subprocess +import sys +from pathlib import Path + +PPI = "96" + + +def compile_pages(side_dir: Path, document: str, out: Path, tag: str) -> list[str]: + """The PNG pages of `document` under `side_dir`, as paths relative to `out`; empty when it does not compile.""" + source = side_dir / document + if not source.is_file(): + return [] + pattern = out / tag / f"{{p}}.png" + (out / tag).mkdir(parents=True, exist_ok=True) + run = subprocess.run( + ["typst", "compile", "--root", str(side_dir), "--format", "png", "--ppi", PPI, str(source), str(pattern)], + capture_output=True, + text=True, + ) + if run.returncode != 0: + sys.stderr.write(f"{document} ({tag}): {run.stderr.strip().splitlines()[-1] if run.stderr.strip() else 'did not compile'}\n") + return [] + pages = sorted((out / tag).glob("*.png"), key=lambda p: int(p.stem)) + return [str(p.relative_to(out)) for p in pages] + + +def main() -> int: + spec = json.load(sys.stdin) + old, new, out = Path(spec["old"]), Path(spec["new"]), Path(spec["out"]) + blocks = [] + for entry in spec["files"]: + document = entry["path"] + tag = document.replace("/", "_").removesuffix(".typ") + before = compile_pages(old, entry.get("oldPath") or document, out, f"{tag}-old") + after = compile_pages(new, document, out, f"{tag}-new") + if not before and not after: + continue + pages = [] + for n in range(max(len(before), len(after))): + pages.append( + { + "label": f"{document} · p. {n + 1}", + "old": before[n] if n < len(before) else None, + "new": after[n] if n < len(after) else None, + "anchor": {"path": document, "line": 1} if entry["status"] != "D" else None, + } + ) + blocks.append({"kind": "pages", "label": f"{document}: {len(before)} → {len(after)} pages", "pages": pages}) + if not blocks: + sys.stderr.write("no changed document compiled on its own\n") + return 1 + (out / "report.json").write_text(json.dumps({"title": "Rendered Typst", "blocks": blocks})) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/client/App.tsx b/src/client/App.tsx index 54fa25be..423f8b01 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -9,6 +9,7 @@ import { useStore } from './store.js'; import { FileTreePane } from './tree/FileTreePane.js'; import { TooltipHost } from './ui/TitleTooltips.js'; import './views/registry.js'; +import { useReports } from './reports/state.js'; import { gridColumns, listViews, panelOf, useViews } from './views/state.js'; export function App() { @@ -70,6 +71,7 @@ export function App() { const resync = () => { setLost(false); void boot().then(() => performance.mark('diffle:snapshot')); + void useReports.getState().refresh(); }; return connectWs( (msg) => { @@ -92,6 +94,9 @@ export function App() { case 'lsp': setLspStatus(msg.payload); break; + case 'reports': + void useReports.getState().refresh(); + break; default: break; } diff --git a/src/client/api.ts b/src/client/api.ts index 31ab338c..bf2ba8fc 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -29,6 +29,8 @@ import { PermalinkRequestSchema, PermalinkSchema, RefsResponseSchema, + ReportLogResponseSchema, + ReportsResponseSchema, SearchResponseSchema, ServerMessageSchema, SnapshotSchema, @@ -226,6 +228,13 @@ export const api = { /** Document symbols for a path, or workspace symbols matching a query. */ lspSymbols: (query: SymbolsQuery) => json(LspSymbolsResponseSchema, `api/lsp/symbols?${q(query)}`), + /** Every report of the review, and the target a pushed one should name. */ + reports: () => json(ReportsResponseSchema, 'api/reports'), + /** What a report's maker wrote to stderr, or what validation dropped. */ + reportLog: (id: string) => json(ReportLogResponseSchema, `api/reports/${encodeURIComponent(id)}/log`), + /** Where an `` loads a file a report names; `key` is the file's `ReportFile.key`. */ + reportFileUrl: (id: string, key: string) => + new URL(`api/reports/${encodeURIComponent(id)}/file?${q({ key })}`, location.href).href, config: () => json(UserConfigSchema, 'api/config'), /** lspCommands and githubHosts are CLI-only; the server ignores them. */ saveConfig: (config: ConfigUpdate) => diff --git a/src/client/keyboard/HelpOverlay.tsx b/src/client/keyboard/HelpOverlay.tsx index 7c988528..4fcab860 100644 --- a/src/client/keyboard/HelpOverlay.tsx +++ b/src/client/keyboard/HelpOverlay.tsx @@ -91,6 +91,7 @@ export const DIFF_HELP: HelpSection[][] = [ title: 'Change graph (language server)', rows: [ ['M', 'graph of changed symbols; ? there lists its keys'], + ['gr', 'reports from plugins and agents; ? there lists its keys'], ['right-click a folder', 'Show in graph: only the changes under it'], ], }, diff --git a/src/client/keyboard/useKeymap.ts b/src/client/keyboard/useKeymap.ts index af7a0565..76644c35 100644 --- a/src/client/keyboard/useKeymap.ts +++ b/src/client/keyboard/useKeymap.ts @@ -100,6 +100,7 @@ const KEYMAP: Record = { r: (s) => s.reloadIfMoved(), '?': (s) => s.setHelpOpen(!s.helpOpen), M: () => toggleView('graph'), + gr: () => toggleView('reports'), }; /** The page's text selection, else the focused word (`w`), as a search query: one line only, escaped when regex search is on. */ diff --git a/src/client/reports/Blocks.tsx b/src/client/reports/Blocks.tsx new file mode 100644 index 00000000..74f13b32 --- /dev/null +++ b/src/client/reports/Blocks.tsx @@ -0,0 +1,117 @@ +import { MessageSquarePlus } from 'lucide-react'; +import { twMerge } from 'tailwind-merge'; +import { api } from '@/client/api.js'; +import { FilePath } from '@/client/FilePath.js'; +import { Markdown } from '@/client/Markdown.js'; +import { Pictures } from '@/client/review/ImageDiff.js'; +import { Button } from '@/client/ui/Button.js'; +import type { ReportAnchor, ReportBlock, ReportPage } from '@/shared/protocol.js'; +import { useReports } from './state.js'; + +/** `path:line`, or `path:start-end`, as comments name their place. */ +export function anchorLabel(a: ReportAnchor): string { + const lines = a.endLine != null && a.endLine !== a.line ? `${a.line}-${a.endLine}` : `${a.line}`; + return `${a.path}:${lines}${a.side === 'old' ? ' (removed)' : ''}`; +} + +/** One block of the shown report. The cursor's block is outlined; its anchor is where `c` comments. */ +export function Block({ id, block, index }: { id: string; block: ReportBlock; index: number }) { + const atCursor = useReports((s) => s.cursor === index); + const setCursor = useReports((s) => s.setCursor); + return ( +
setCursor(index)} + className={twMerge( + 'rounded-md border border-border bg-card shadow-card [transition:border-color_120ms_ease]', + atCursor && 'border-accent', + )} + > + {block.kind === 'markdown' ? ( + <> + {block.anchor && } +
+ +
+ + ) : ( + <> + {block.label && ( +
+ {block.label} +
+ )} +
+ {block.pages.map((page, i) => ( + + ))} +
+ + )} +
+ ); +} + +/** Where a block points: a click opens the diff there, the button comments there. */ +function AnchorBar({ anchor }: { anchor: ReportAnchor }) { + const jump = useReports((s) => s.jump); + const comment = useReports((s) => s.comment); + return ( +
+ + +
+ ); +} + +function Page({ id, page }: { id: string; page: ReportPage }) { + const url = { + old: page.old ? api.reportFileUrl(id, page.old.key) : null, + new: page.new ? api.reportFileUrl(id, page.new.key) : null, + }; + const sides = page.old && page.new ? 'both' : page.old ? 'old' : 'new'; + return ( +
+
+ {page.label} + {page.anchor && } +
+ +
+ ); +} + +function PageAnchor({ anchor }: { anchor: ReportAnchor }) { + const jump = useReports((s) => s.jump); + return ( + + ); +} diff --git a/src/client/reports/ReportsPanel.tsx b/src/client/reports/ReportsPanel.tsx new file mode 100644 index 00000000..6cb3718c --- /dev/null +++ b/src/client/reports/ReportsPanel.tsx @@ -0,0 +1,58 @@ +import { AlertTriangle, Bot, FileText, LoaderCircle, Wrench } from 'lucide-react'; +import { twMerge } from 'tailwind-merge'; +import type { Report } from '@/shared/protocol.js'; +import { shownReport, useReports } from './state.js'; + +/** Every report, generated ones first: a click shows it in the main pane. */ +export function ReportsPanel() { + const reports = useReports((s) => s.reports); + const shown = useReports(shownReport); + const select = useReports((s) => s.select); + return ( + + ); +} + +function Row({ report: r, shown, select }: { report: Report; shown: boolean; select: (id: string) => void }) { + const Source = r.source === 'generator' ? Wrench : Bot; + return ( + + ); +} diff --git a/src/client/reports/ReportsView.tsx b/src/client/reports/ReportsView.tsx new file mode 100644 index 00000000..786770f6 --- /dev/null +++ b/src/client/reports/ReportsView.tsx @@ -0,0 +1,105 @@ +import { AlertTriangle, LoaderCircle, ScrollText } from 'lucide-react'; +import { relativeTime } from '@/client/model.js'; +import { Button, TOGGLE_ON } from '@/client/ui/Button.js'; +import type { Report } from '@/shared/protocol.js'; +import { Block } from './Blocks.js'; +import { shownReport, useReports } from './state.js'; + +/** The shown report in the diff's place: its header, its log when opened, and its blocks. */ +export function ReportsView() { + const report = useReports(shownReport); + const loaded = useReports((s) => s.loaded); + const error = useReports((s) => s.error); + return ( +
+ {report ? : } +
+ ); +} + +function Shown({ report }: { report: Report }) { + const log = useReports((s) => (s.log?.id === report.id ? s.log.lines : null)); + const toggleLog = useReports((s) => s.toggleLog); + return ( + <> +
+ {report.title} + + + {relativeTime(report.updatedAt)} + + +
+ {report.error && ( +

+ {report.error} +

+ )} + {log && ( +
+          {log.length ? log.join('\n') : 'Nothing was logged.'}
+        
+ )} +
+ {report.blocks.length === 0 && report.status === 'running' && ( +

+ Running +

+ )} + {report.blocks.map((block, i) => ( + + ))} +
+ + ); +} + +/** Running, failed, or stale, as one word with its mark; nothing while a report is simply ready. */ +function Status({ report }: { report: Report }) { + if (report.status === 'running') + return ( + + running + + ); + if (report.status === 'failed') return failed; + if (report.stale) + return ( + + stale + + ); + return null; +} + +function Empty({ loaded, error }: { loaded: boolean; error: string | null }) { + return ( +
+ {error ? ( +

{error}

+ ) : !loaded ? ( +

Loading reports…

+ ) : ( + <> +

No reports for this diff.

+

+ A plugin on PATH writes one when a changed file matches its globs; diffle plugins lists them. + Anything else can push one: PUT /api/reports/<id> with a title and markdown blocks, or{' '} + diffle report put <file> from a shell. +

+ + )} +
+ ); +} diff --git a/src/client/reports/help.ts b/src/client/reports/help.ts new file mode 100644 index 00000000..898d75c8 --- /dev/null +++ b/src/client/reports/help.ts @@ -0,0 +1,35 @@ +import type { HelpSection } from '@/client/views/state.js'; + +/** The reports' shortcuts: their own keys, then those they leave to the review. */ +export const REPORTS_HELP: HelpSection[][] = [ + [ + { + title: 'Reports', + rows: [ + ['j / k', 'next / previous block'], + ['J / K ] / [', 'next / previous report'], + ['Enter', 'open the block’s line in the diff'], + ['c', 'comment on the block’s line in the diff'], + ['click a report', 'show it; its log opens from the header'], + ], + }, + ], + [ + { + title: 'Review', + rows: [ + ['gr', 'back to the diff'], + ['M', 'the change graph'], + ['Ctrl+o / Ctrl+i', 'jump back / forward in history'], + ['⌘/Ctrl+b', 'toggle the file tree (Shift: reports list)'], + ['t', 'cycle theme'], + ['o', 'GitHub repository and pull request'], + ['m, then 1–6', 'open the mode picker and choose an entry'], + ], + }, + { + title: 'General', + rows: [['?', 'toggle this help']], + }, + ], +]; diff --git a/src/client/reports/keys.ts b/src/client/reports/keys.ts new file mode 100644 index 00000000..0c23e8ed --- /dev/null +++ b/src/client/reports/keys.ts @@ -0,0 +1,40 @@ +import { useStore } from '@/client/store.js'; +import { cursorAnchor, useReports } from './state.js'; + +/** Keys the reports leave to the review: help, theme, the menus, `M` to the graph, and `gr` back to the diff. */ +export const REPORTS_PASSES = new Set(['?', 't', 'm', 'M', 'o', 'g', 'r']); + +/** The reports view's own keys; true when `e` was one. The keymap calls it only while the view shows. */ +export function reportsKeys(e: KeyboardEvent): boolean { + const reports = useReports.getState(); + switch (e.key) { + case 'j': + case 'ArrowDown': + reports.moveCursor(1); + return true; + case 'k': + case 'ArrowUp': + reports.moveCursor(-1); + return true; + case 'J': + case ']': + reports.step(1); + return true; + case 'K': + case '[': + reports.step(-1); + return true; + case 'c': + case 'Enter': { + const anchor = cursorAnchor(reports); + if (!anchor) { + useStore.getState().flash('This block points at no line of the diff'); + return true; + } + void (e.key === 'c' ? reports.comment(anchor) : reports.jump(anchor)); + return true; + } + default: + return false; + } +} diff --git a/src/client/reports/state.ts b/src/client/reports/state.ts new file mode 100644 index 00000000..81aea8d9 --- /dev/null +++ b/src/client/reports/state.ts @@ -0,0 +1,147 @@ +import { create } from 'zustand'; +import { api } from '@/client/api.js'; +import { useStore, type ViewPlace } from '@/client/store.js'; +import { showView } from '@/client/views/state.js'; +import type { Report, ReportAnchor, ReportTarget } from '@/shared/protocol.js'; + +export interface ReportsState { + reports: Report[]; + /** The target a pushed report should name now; null before the first load. */ + current: ReportTarget | null; + /** The first load answered, so an empty list means no reports rather than none yet. */ + loaded: boolean; + error: string | null; + /** The report shown, by id; null shows the first. */ + selected: string | null; + /** The block the cursor is on in the shown report: `c` comments at its anchor. */ + cursor: number; + /** The log open under the shown report's header, by id. */ + log: { id: string; lines: string[] } | null; + refresh(): Promise; + select(id: string | null): void; + moveCursor(by: 1 | -1): void; + setCursor(index: number): void; + /** Shows the next (or previous) report in the list. */ + step(by: 1 | -1): void; + /** Leaves for the diff at `anchor`, and opens a comment there on the new side. */ + comment(anchor: ReportAnchor): Promise; + /** Leaves for the diff at `anchor`. */ + jump(anchor: ReportAnchor): Promise; + /** Opens the log of report `id`, or closes it when it is the one open. */ + toggleLog(id: string): Promise; +} + +let loads = 0; + +/** The reports view. It reads the review store and never the other way round. */ +export const useReports = create((set, get) => ({ + reports: [], + current: null, + loaded: false, + error: null, + selected: null, + cursor: 0, + log: null, + + async refresh() { + const generation = ++loads; + try { + const res = await api.reports(); + if (generation !== loads) return; + const shown = shownReport({ ...get(), reports: res.reports }); + set({ + reports: res.reports, + current: res.current, + loaded: true, + error: null, + cursor: Math.min(get().cursor, Math.max(0, (shown?.blocks.length ?? 1) - 1)), + }); + const log = get().log; + if (log && !res.reports.some((r) => r.id === log.id)) set({ log: null }); + } catch (e) { + if (generation === loads) set({ loaded: true, error: e instanceof Error ? e.message : String(e) }); + } + }, + + select(id) { + if (id === get().selected) return; + set({ selected: id, cursor: 0, log: null }); + }, + + moveCursor(by) { + const shown = shownReport(get()); + if (!shown || shown.blocks.length === 0) return; + const cursor = Math.max(0, Math.min(shown.blocks.length - 1, get().cursor + by)); + set({ cursor }); + document.querySelector(`[data-report-block="${cursor}"]`)?.scrollIntoView({ block: 'nearest' }); + }, + + setCursor(index) { + set({ cursor: index }); + }, + + step(by) { + const { reports } = get(); + if (reports.length === 0) return; + const shown = shownReport(get()); + const at = reports.findIndex((r) => r.id === shown?.id); + const next = reports[(at + by + reports.length) % reports.length]!; + get().select(next.id); + }, + + async comment(anchor) { + await get().jump(anchor); + const review = useStore.getState(); + // The cursor sits on the line once the jump landed on the new side; the old side takes no draft. + if (anchor.side === 'old') return review.flash('Press c on the removed line to comment there'); + if (review.selection) void review.openDraft(review.selection); + }, + + async jump(anchor) { + const review = useStore.getState(); + // Closed only once the jump is made: the jump list records the reports as the place left. + if (anchor.side === 'old') await review.openFile(anchor.path, anchor.endLine ?? anchor.line, 'old'); + else await review.goToLink(anchor.path, anchor.line); + showView('diff'); + }, + + async toggleLog(id) { + if (get().log?.id === id) return set({ log: null }); + try { + const { lines } = await api.reportLog(id); + set({ log: { id, lines } }); + } catch (e) { + useStore.getState().flash(`Loading the log failed: ${e instanceof Error ? e.message : String(e)}`); + } + }, +})); + +/** The report the view shows: the selected one, else the first. */ +export function shownReport(s: Pick): Report | null { + return s.reports.find((r) => r.id === s.selected) ?? s.reports[0] ?? null; +} + +/** The anchor the cursor's block points at, or null. A pages block anchors through its first page. */ +export function cursorAnchor(s: Pick): ReportAnchor | null { + const block = shownReport(s)?.blocks[s.cursor]; + if (!block) return null; + if (block.kind === 'markdown') return block.anchor ?? null; + return block.pages.find((p) => p.anchor)?.anchor ?? null; +} + +export function enterReports(): void { + const s = useReports.getState(); + if (!s.loaded) void s.refresh(); +} + +/** The reports as they stand, for the jump list: Ctrl+o after a jump into the diff restores them. */ +export function reportsPlace(): ViewPlace { + const { selected, cursor } = useReports.getState(); + return { + view: `reports:${selected ?? ''}:${cursor}`, + restore() { + useReports.setState({ selected, cursor }); + showView('reports'); + }, + }; +} diff --git a/src/client/reports/view.ts b/src/client/reports/view.ts new file mode 100644 index 00000000..a992ade5 --- /dev/null +++ b/src/client/reports/view.ts @@ -0,0 +1,24 @@ +import { FileText } from 'lucide-react'; +import type { ViewDef } from '@/client/views/state.js'; +import { REPORTS_HELP } from './help.js'; +import { REPORTS_PASSES, reportsKeys } from './keys.js'; +import { ReportsPanel } from './ReportsPanel.js'; +import { ReportsView } from './ReportsView.js'; +import { enterReports, reportsPlace } from './state.js'; + +/** Reports: what plugins made of the snapshot and what an agent pushed, beside the diff. */ +export const reportsView: ViewDef = { + id: 'reports', + label: 'Reports', + icon: FileText, + title: 'Reports from plugins and agents (gr)', + Main: ReportsView, + Side: ReportsPanel, + help: REPORTS_HELP, + panelLabel: 'reports list', + panelWidth: '18rem', + enter: enterReports, + place: reportsPlace, + keys: reportsKeys, + passes: REPORTS_PASSES, +}; diff --git a/src/client/review/ImageDiff.tsx b/src/client/review/ImageDiff.tsx index fa5d202d..d313fc4d 100644 --- a/src/client/review/ImageDiff.tsx +++ b/src/client/review/ImageDiff.tsx @@ -85,15 +85,19 @@ export function ImageDiff({ path, sides }: { path: string; sides: ImageSides }) }; return (
- {sides === 'both' ? ( - - ) : ( - - )} +
); } +/** + * The pictures of `path` by URL: one side alone, or both with the comparison modes. A report's rendered + * pages use it too, with the page's label as `path`. + */ +export function Pictures({ path, sides, url }: { path: string; sides: ImageSides; url: Record }) { + return sides === 'both' ? : ; +} + function Single({ path, side, url }: { path: string; side: Side; url: string | null }) { return
; } diff --git a/src/client/views/registry.ts b/src/client/views/registry.ts index f7a16e6d..1ba78c61 100644 --- a/src/client/views/registry.ts +++ b/src/client/views/registry.ts @@ -1,6 +1,7 @@ import { FileDiff } from 'lucide-react'; import { CommentPanel } from '@/client/comments/CommentPanel.js'; import { graphView } from '@/client/graph/view.js'; +import { reportsView } from '@/client/reports/view.js'; import { DiffStyleSwitch } from '@/client/header/DiffStyleSwitch.js'; import { DIFF_HELP } from '@/client/keyboard/HelpOverlay.js'; import { ReviewPane } from '@/client/review/ReviewPane.js'; @@ -21,7 +22,7 @@ const diffView: ViewDef = { panelWidth: '21.25rem', }; -defineViews([diffView, graphView]); +defineViews([diffView, graphView, reportsView]); useStore.getState().registerView({ here: () => activeView().place?.() ?? null, leave: () => showView('diff'), diff --git a/src/client/views/state.ts b/src/client/views/state.ts index f0345097..2ab4d4f8 100644 --- a/src/client/views/state.ts +++ b/src/client/views/state.ts @@ -3,7 +3,7 @@ import type { ComponentType } from 'react'; import { create } from 'zustand'; import { type LayoutState, type ReviewState, useStore, type ViewPlace } from '@/client/store.js'; -export type ViewId = 'diff' | 'graph'; +export type ViewId = 'diff' | 'graph' | 'reports'; type HelpRow = [keys: string, action: string]; export type HelpSection = { title: string; rows: HelpRow[] }; diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 89771ede..bb66328b 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -599,6 +599,27 @@ export type SearchResponse = z.infer; export const FollowRefsSchema = z.enum(['on', 'auto', 'off']); export type FollowRefs = z.infer; +/** + * A program that makes a report from a snapshot. It runs whenever a changed file matches `when`, + * with copies of the files in DIFFLE_OLD and DIFFLE_NEW, a JSON description of the review on + * stdin, and DIFFLE_ROOT, DIFFLE_OUT in its environment. A `report.json` it writes into DIFFLE_OUT + * is the report, its files named relative to that directory; otherwise its stdout is one markdown + * block. A command naming $DIFFLE_FILE runs once per matched file, with that path in the variable. + */ +export const PluginConfigSchema = z.object({ + /** + * The program and its arguments, split as a shell would, without a shell: a `|` between words + * pipes one program's stdout into the next, and `$DIFFLE_OLD`, `$DIFFLE_NEW`, `$DIFFLE_OUT`, + * `$DIFFLE_ROOT` and `$DIFFLE_FILE` are substituted in arguments. Empty turns a built-in plugin off. + */ + command: z.string(), + /** Globs (picomatch syntax) of changed files that start it; a built-in plugin keeps its own when absent. */ + when: z.string().array().optional(), + /** What the program gets copies of: the matched changed files, or every file the snapshot shows. Default changed. */ + scope: z.enum(['changed', 'tree']).optional(), +}); +export type PluginConfig = z.infer; + export const UserConfigSchema = z.object({ /** Globs (picomatch syntax) for files that start viewed + collapsed. Empty by default. */ autoViewed: z.string().array(), @@ -619,6 +640,11 @@ export const UserConfigSchema = z.object({ * config file. */ githubHosts: z.record(z.string(), z.string()), + /** + * Report generators by name: a built-in plugin (`typst`, `pixi`) is replaced or turned off by + * its name, any other name adds one. Read-only over HTTP; set by editing the config file. + */ + plugins: z.record(z.string(), PluginConfigSchema), }); export type UserConfig = z.infer; @@ -629,6 +655,7 @@ export const DEFAULT_USER_CONFIG: UserConfig = { lineWrap: true, lspCommands: {}, githubHosts: {}, + plugins: {}, }; /** A point in a new-side file. LSP semantics: the column counts UTF-16 units, like a JS string index. */ @@ -891,6 +918,144 @@ export const GraphResponseSchema = z.object({ }); export type GraphResponse = z.infer; +/** + * A place in the diff a report block points at: `c` on the block comments there. The new side + * unless `side` says old. A path the snapshot does not show is dropped when the report is served. + */ +export const ReportAnchorSchema = z.object({ + path: z.string(), + /** Default 'new'. */ + side: SideSchema.optional(), + /** 1-based. */ + line: z.number().int(), + /** Default `line`. */ + endLine: z.number().int().optional(), +}); +export type ReportAnchor = z.infer; + +/** + * A file a report refers to, as the client fetches it: `GET /api/reports/{id}/file?key=`. + * The key names fixed bytes, so what it serves may be cached for good. + */ +export const ReportFileSchema = z.object({ + key: z.string(), + /** The media type its bytes were sniffed as; only raster images are ever served. */ + type: z.string(), +}); +export type ReportFile = z.infer; + +/** One rendered page or figure, as authored: each side names a file next to the report, relative, or none. */ +export const ReportPageInputSchema = z.object({ + label: z.string(), + old: z.string().nullable(), + new: z.string().nullable(), + anchor: ReportAnchorSchema.optional(), +}); +export type ReportPageInput = z.infer; + +/** One rendered page or figure, as served: a side with a file is shown; both are compared. */ +export const ReportPageSchema = z.object({ + label: z.string(), + old: ReportFileSchema.nullable(), + new: ReportFileSchema.nullable(), + anchor: ReportAnchorSchema.optional(), +}); +export type ReportPage = z.infer; + +/** GitHub-flavored markdown, rendered as a comment body is: raw HTML stays literal and images are dropped. */ +export const ReportMarkdownBlockSchema = z.object({ + kind: z.literal('markdown'), + text: z.string(), + anchor: ReportAnchorSchema.optional(), +}); + +/** A report's blocks as a generator or an agent writes them. */ +export const ReportBlockInputSchema = z.discriminatedUnion('kind', [ + ReportMarkdownBlockSchema, + z.object({ + kind: z.literal('pages'), + /** What the pages are, e.g. "Rendered, removals struck through". */ + label: z.string().optional(), + pages: ReportPageInputSchema.array(), + }), +]); +export type ReportBlockInput = z.infer; + +/** A report's blocks as the client gets them: every file resolved to a key. */ +export const ReportBlockSchema = z.discriminatedUnion('kind', [ + ReportMarkdownBlockSchema, + z.object({ + kind: z.literal('pages'), + label: z.string().optional(), + pages: ReportPageSchema.array(), + }), +]); +export type ReportBlock = z.infer; + +/** + * The snapshot a report was made for. Two shas do not pin a worktree side, so `digest` hashes the + * changed files' blobs too; `GET /api/reports` says the current one. + */ +export const ReportTargetSchema = z.object({ + oldSha: z.string(), + newSha: z.string(), + digest: z.string(), +}); +export type ReportTarget = z.infer; + +/** + * `PUT /api/reports/{id}`: a report as authored. Files it names must be uploaded first, with + * `PUT /api/reports/{id}/files/`. A `target` other than the current snapshot's is refused + * with 409 and the current target, so an agent that fell behind can refetch; absent means current. + */ +export const ReportInputSchema = z.object({ + title: z.string(), + /** One line for the list; the first line of the first markdown block otherwise. */ + summary: z.string().optional(), + target: ReportTargetSchema.optional(), + blocks: ReportBlockInputSchema.array(), +}); +export type ReportInput = z.infer; + +/** A generator diffle ran for the snapshot, or something outside pushed it over the API. */ +export const ReportSourceSchema = z.enum(['generator', 'pushed']); +export type ReportSource = z.infer; + +/** `running` keeps the previous blocks until the new ones land, as a snapshot refresh does. */ +export const ReportStatusSchema = z.enum(['running', 'ready', 'failed']); +export type ReportStatus = z.infer; + +export const ReportSchema = z.object({ + /** Lowercase letters, digits and dashes; a generator's is its plugin name. */ + id: z.string(), + source: ReportSourceSchema, + title: z.string(), + summary: z.string(), + target: ReportTargetSchema, + status: ReportStatusSchema, + /** The failure in one line: a program not on PATH, a nonzero exit, a report that did not validate. */ + error: z.string().nullable(), + /** The snapshot moved away from `target`: the blocks describe an older diff. */ + stale: z.boolean(), + /** Milliseconds since the epoch. */ + updatedAt: z.number().int(), + blocks: ReportBlockSchema.array(), +}); +export type Report = z.infer; + +/** `GET /api/reports`: every report, generated ones first, and the target a pushed one should name. */ +export const ReportsResponseSchema = z.object({ + current: ReportTargetSchema, + reports: ReportSchema.array(), +}); +export type ReportsResponse = z.infer; + +/** `GET /api/reports/{id}/log`: what the generator wrote to stderr, most recent run only. */ +export const ReportLogResponseSchema = z.object({ lines: z.string().array() }); +export type ReportLogResponse = z.infer; + +export const ReportFileQuerySchema = z.object({ key: z.string() }); + /** Derived per changed file: 'restale' means viewed at an older blob. */ export const ViewedStateSchema = z.enum(['unviewed', 'viewed', 'restale']); export type ViewedState = z.infer; @@ -918,6 +1083,10 @@ export const ServerMessageSchema = z.discriminatedUnion('type', [ type: z.literal('lsp'), payload: LspStatusSchema, }), + /** A report was added, replaced, removed, or changed status; `GET /api/reports` has the list. */ + z.object({ + type: z.literal('reports'), + }), ]); export type ServerMessage = z.infer; diff --git a/test/client/reports.test.ts b/test/client/reports.test.ts new file mode 100644 index 00000000..e288ff67 --- /dev/null +++ b/test/client/reports.test.ts @@ -0,0 +1,103 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi } from 'vitest'; +import type { Report, ReportAnchor } from '@/shared/protocol.js'; + +const review = vi.hoisted(() => ({ + flash: vi.fn(), + goToLink: vi.fn(async () => {}), + openFile: vi.fn(async () => {}), + openDraft: vi.fn(async () => {}), + selection: null as { id: string } | null, +})); +vi.mock('@/client/store.js', () => ({ useStore: { getState: () => review } })); +const shown = vi.hoisted(() => ({ ids: [] as string[] })); +vi.mock('@/client/views/state.js', () => ({ showView: (id: string) => shown.ids.push(id) })); +const api = vi.hoisted(() => ({ reports: vi.fn(), reportLog: vi.fn() })); +vi.mock('@/client/api.js', () => ({ api })); + +const { cursorAnchor, shownReport, useReports } = await import('@/client/reports/state.js'); +const { reportsKeys } = await import('@/client/reports/keys.js'); + +const target = { oldSha: 'a', newSha: 'b', digest: 'c' }; +const anchor: ReportAnchor = { path: 'src/app.py', line: 3 }; +function report(id: string, blocks: Report['blocks']): Report { + return { + id, + source: 'pushed', + title: id, + summary: '', + target, + status: 'ready', + error: null, + stale: false, + updatedAt: 0, + blocks, + }; +} +const notes = report('notes', [ + { kind: 'markdown', text: 'plain' }, + { kind: 'markdown', text: 'placed', anchor }, + { + kind: 'pages', + pages: [{ label: 'p. 1', old: null, new: { key: 'k', type: 'image/png' }, anchor: { ...anchor, line: 9 } }], + }, +]); +const other = report('other', []); + +const key = (k: string) => new KeyboardEvent('keydown', { key: k }); + +describe('the reports view state', () => { + it('shows the selected report, else the first, and reads the cursor block’s anchor', () => { + expect(shownReport({ reports: [notes, other], selected: null })?.id).toBe('notes'); + expect(shownReport({ reports: [notes, other], selected: 'other' })?.id).toBe('other'); + expect(shownReport({ reports: [notes], selected: 'gone' })?.id).toBe('notes'); + expect(cursorAnchor({ reports: [notes], selected: null, cursor: 0 })).toBeNull(); + expect(cursorAnchor({ reports: [notes], selected: null, cursor: 1 })).toEqual(anchor); + expect(cursorAnchor({ reports: [notes], selected: null, cursor: 2 })?.line).toBe(9); + expect(cursorAnchor({ reports: [], selected: null, cursor: 0 })).toBeNull(); + }); + + it('refreshes from the API, keeping the cursor inside the shown report', async () => { + api.reports.mockResolvedValueOnce({ current: target, reports: [notes, other] }); + useReports.setState({ cursor: 7 }); + await useReports.getState().refresh(); + expect(useReports.getState().reports.map((r) => r.id)).toEqual(['notes', 'other']); + expect(useReports.getState().cursor).toBe(2); + expect(useReports.getState().loaded).toBe(true); + }); + + it('walks blocks and reports with the keys, and leaves for the diff at an anchor', async () => { + useReports.setState({ reports: [notes, other], selected: null, cursor: 0 }); + expect(reportsKeys(key('j'))).toBe(true); + expect(reportsKeys(key('j'))).toBe(true); + expect(reportsKeys(key('j'))).toBe(true); + expect(useReports.getState().cursor).toBe(2); + expect(reportsKeys(key('k'))).toBe(true); + expect(useReports.getState().cursor).toBe(1); + expect(reportsKeys(key('J'))).toBe(true); + expect(useReports.getState().selected).toBe('other'); + expect(useReports.getState().cursor).toBe(0); + expect(reportsKeys(key('['))).toBe(true); + expect(useReports.getState().selected).toBe('notes'); + expect(reportsKeys(key('x'))).toBe(false); + + // A block without an anchor says so; one with it opens the diff there, `c` with a comment. + useReports.setState({ cursor: 0 }); + expect(reportsKeys(key('Enter'))).toBe(true); + expect(review.flash).toHaveBeenCalledWith('This block points at no line of the diff'); + useReports.setState({ cursor: 1 }); + review.selection = { id: 'src/app.py' }; + expect(reportsKeys(key('c'))).toBe(true); + await vi.waitFor(() => expect(review.openDraft).toHaveBeenCalledWith({ id: 'src/app.py' })); + expect(review.goToLink).toHaveBeenCalledWith('src/app.py', 3); + expect(shown.ids).toEqual(['diff']); + }); + + it('jumps to a removed line without a draft', async () => { + review.openDraft.mockClear(); + await useReports.getState().comment({ path: 'src/app.py', side: 'old', line: 4, endLine: 5 }); + expect(review.openFile).toHaveBeenCalledWith('src/app.py', 5, 'old'); + expect(review.openDraft).not.toHaveBeenCalled(); + expect(review.flash).toHaveBeenCalledWith('Press c on the removed line to comment there'); + }); +}); diff --git a/test/client/store.test.ts b/test/client/store.test.ts index 6d9248d2..2700fae3 100644 --- a/test/client/store.test.ts +++ b/test/client/store.test.ts @@ -64,6 +64,7 @@ const api = { lineWrap: true, lspCommands: { python: 'pyrefly lsp' }, githubHosts: {}, + plugins: {}, })), lspStatus: vi.fn(async (): Promise => LSP_OFF), lspLog: vi.fn(async (): Promise => ({ entries: [] })), @@ -823,6 +824,7 @@ describe('client transitions', () => { lineWrap: true, lspCommands: {}, githubHosts: {}, + plugins: {}, }, lsp: LSP_OFF, }); @@ -835,6 +837,7 @@ describe('client transitions', () => { lineWrap: true, lspCommands: {}, githubHosts: {}, + plugins: {}, }); api.lspStatus.mockResolvedValueOnce(lspStatus('ready')); const boot = useStore.getState().boot(); @@ -1932,6 +1935,7 @@ describe('request ownership', () => { lineWrap: true, lspCommands: {}, githubHosts: {}, + plugins: {}, }); const load = deferred(); const save = deferred(); diff --git a/test/e2e/__snapshots__/reports.spec.ts/generator.png b/test/e2e/__snapshots__/reports.spec.ts/generator.png new file mode 100644 index 0000000000000000000000000000000000000000..a7d1f50668f0bddd9eb0906a2d203244fdc2fb03 GIT binary patch literal 146648 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%0DJ0jIJ@@RwN-Jn;r~_3w!8@FI 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 literal 0 HcmV?d00001 diff --git a/test/e2e/__snapshots__/reports.spec.ts/list.aria.yml b/test/e2e/__snapshots__/reports.spec.ts/list.aria.yml new file mode 100644 index 00000000..f2e6e9d6 --- /dev/null +++ b/test/e2e/__snapshots__/reports.spec.ts/list.aria.yml @@ -0,0 +1,4 @@ +- complementary "Reports list": + - text: Reports 2 + - 'button /Fake report old: \.gitattributes, \.gitignore, README\.md, assets\/logo\.png, docs\/release notes\.md, docs\/Übersicht\.md, pyproject\.toml, requirements\.lock, scripts\/tally[\d,.]+[hmsp]+, tally\/__init__\.py, tally\/cli\.py, tally\/gener/' + - button "Review notes Watch the refund path" \ No newline at end of file diff --git a/test/e2e/__snapshots__/reports.spec.ts/pushed.aria.yml b/test/e2e/__snapshots__/reports.spec.ts/pushed.aria.yml new file mode 100644 index 00000000..69cc777b --- /dev/null +++ b/test/e2e/__snapshots__/reports.spec.ts/pushed.aria.yml @@ -0,0 +1,10 @@ +- region "Reports view": + - text: Review notes just now + - button "Log" + - article: + - button "tests/test_refunds.py:1" + - button "Comment" + - heading "Watch the refund path" [level=1] + - paragraph: The new test only covers the happy path. + - article: + - paragraph: A general remark, pointing nowhere. \ No newline at end of file diff --git a/test/e2e/__snapshots__/reports.spec.ts/pushed.png b/test/e2e/__snapshots__/reports.spec.ts/pushed.png new file mode 100644 index 0000000000000000000000000000000000000000..1a0d5ace940f55a6ae476dba54e920d2e1cdb080 GIT binary patch literal 96078 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 revs: [[`${MAIN_BRANCH}...${FEATURE_BRANCH}`], { option: true }], args: [[], { option: true }], env: [{}, { option: true }], + config: [{}, { option: true }], // Copy the complete checkout, including staged/unstaged files; git clone would lose working-mode inputs. fixtureTemplate: [ @@ -65,8 +68,8 @@ export const test = base.extend({ } }, - diffle: async ({ repo, revs, args, env }, use) => { - const server = await startDiffle({ repo, revs, args, env }); + diffle: async ({ repo, revs, args, env, config }, use) => { + const server = await startDiffle({ repo, revs, args, env, config }); try { await use(server); } finally { diff --git a/test/e2e/reports.spec.ts b/test/e2e/reports.spec.ts new file mode 100644 index 00000000..05c33c61 --- /dev/null +++ b/test/e2e/reports.spec.ts @@ -0,0 +1,82 @@ +// The Reports view: what a plugin made of the snapshot and what was pushed over the API, beside the +// diff. The fake generator (`test/reports/fake-generator.mjs`) stands in for a plugin: it lists the +// files it was given and renders the fixture's logo as one page per side, so the pages block runs +// through the real runner, blob store, file route and image comparison without Typst. +import { join } from 'node:path'; +import { activePath } from './browser.js'; +import { expect, test } from './fixtures.js'; +import { REPO_ROOT } from './server.js'; + +const FAKE = join(REPO_ROOT, 'test/reports/fake-generator.mjs'); +const ANCHORED = 'tests/test_refunds.py'; + +/** Pushes a report as an agent would: the JSON straight at the API. */ +async function pushReport(url: string, id: string, report: object): Promise { + const response = await fetch(new URL(`api/reports/${id}`, url), { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(report), + }); + if (!response.ok) throw new Error(`pushing ${id} failed: HTTP ${response.status} ${await response.text()}`); +} + +test.describe('reports', () => { + test.use({ + config: { + plugins: { + fake: { + command: `${JSON.stringify(process.execPath)} ${JSON.stringify(FAKE)} json`, + when: ['VERSION'], + scope: 'tree', + }, + }, + }, + }); + + test('gr shows the generator’s pages and a pushed report; Enter and c leave for the diff at a block’s line', async ({ + page, + diffle, + }) => { + await pushReport(diffle.url, 'notes', { + title: 'Review notes', + blocks: [ + { + kind: 'markdown', + text: '# Watch the refund path\n\nThe new test only covers the happy path.', + anchor: { path: ANCHORED, line: 1 }, + }, + { kind: 'markdown', text: 'A general remark, pointing nowhere.' }, + ], + }); + await page.keyboard.press('g'); + await page.keyboard.press('r'); + const view = page.getByRole('region', { name: 'Reports view' }); + const list = page.getByRole('complementary', { name: 'Reports list' }); + await expect(view).toBeVisible(); + + // The generator's report comes first and shows once its run ended: the files it saw, then the pages. + await expect(view.getByText(/^old: /)).toBeVisible({ timeout: 20000 }); + await expect(view.getByText('Loading image…')).toHaveCount(0); + await expect(view.getByText('Old · 128 × 96')).toBeVisible(); + await expect(view.getByText('New · 96 × 96')).toBeVisible(); + await expect(list).toMatchAriaSnapshot({ name: 'list.aria.yml' }); + await expect(page).toHaveScreenshot('generator.png'); + + // The pushed one lists beside it; its anchored block names its line, the other does not. + await list.getByRole('button', { name: /Review notes/ }).click(); + await expect(view.getByText('The new test only covers the happy path.')).toBeVisible(); + await expect(view).toMatchAriaSnapshot({ name: 'pushed.aria.yml' }); + await expect(page).toHaveScreenshot('pushed.png'); + + // Enter opens the diff at the block's line; gr brings the report back, and c comments on that line. + await page.keyboard.press('Enter'); + await expect(view).toBeHidden(); + await expect.poll(() => activePath(page)).toBe(ANCHORED); + await page.keyboard.press('g'); + await page.keyboard.press('r'); + await expect(view).toBeVisible(); + await page.keyboard.press('c'); + await expect(view).toBeHidden(); + await expect(page.getByPlaceholder('Leave a comment…')).toBeVisible(); + }); +}); diff --git a/test/e2e/server.ts b/test/e2e/server.ts index 7d3be57c..3ddb346f 100644 --- a/test/e2e/server.ts +++ b/test/e2e/server.ts @@ -1,7 +1,7 @@ import { execFileSync, spawn, type ChildProcess } from 'node:child_process'; import { createHash } from 'node:crypto'; import { existsSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; -import { mkdtemp } from 'node:fs/promises'; +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { rmTmp } from '../tmp.js'; import { dirname, join, relative, resolve } from 'node:path'; @@ -19,6 +19,8 @@ export interface DiffleOptions { /** Further CLI flags. */ args?: string[]; env?: NodeJS.ProcessEnv; + /** The user config this server starts with, as the file holds it; none by default. */ + config?: object; /** The diffle checkout to run, server and client; this one by default. See `baseCheckout`. */ root?: string; timeoutMs?: number; @@ -39,10 +41,15 @@ export async function startDiffle({ revs = [], args = [], env = {}, + config, root = REPO_ROOT, timeoutMs = 30000, }: DiffleOptions): Promise { const configDir = await mkdtemp(join(tmpdir(), 'diffle-e2e-config-')); + if (config) { + await mkdir(join(configDir, 'diffle'), { recursive: true }); + await writeFile(join(configDir, 'diffle', 'config.json'), JSON.stringify(config)); + } const [program, ...launcher] = diffleCommand(root); const proc = spawn( program!, diff --git a/test/reports/fake-generator.mjs b/test/reports/fake-generator.mjs new file mode 100644 index 00000000..f7b00707 --- /dev/null +++ b/test/reports/fake-generator.mjs @@ -0,0 +1,56 @@ +// Minimal report generator for the server tests. Reads the review from stdin and the copied sides +// from DIFFLE_OLD and DIFFLE_NEW. `json` (the default) writes report.json with what it saw and one +// PNG page into DIFFLE_OUT; `stdout` prints one markdown line for DIFFLE_FILE instead; `fail` exits 3. +import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const mode = process.argv[2] ?? 'json'; +const input = JSON.parse(readFileSync(0, 'utf8')); + +function walk(dir, prefix = '') { + let out = []; + for (const name of readdirSync(dir).sort()) { + const rel = prefix ? `${prefix}/${name}` : name; + if (statSync(join(dir, name)).isDirectory()) out = out.concat(walk(join(dir, name), rel)); + else out.push(rel); + } + return out; +} + +const old = walk(process.env.DIFFLE_OLD); +const next = walk(process.env.DIFFLE_NEW); +// The matched files' new text, for a test to check the copy; the rest of a tree would be noise. +const contents = input.files + .filter((f) => existsSync(join(process.env.DIFFLE_NEW, f.path))) + .map((f) => `${f.path}=${readFileSync(join(process.env.DIFFLE_NEW, f.path), 'utf8').trim()}`); + +if (mode === 'fail') { + process.stderr.write('boom: no such thing\n'); + process.exit(3); +} +if (mode === 'stdout') { + console.log(`${process.env.DIFFLE_FILE ?? 'all'}: old=${old.join(',')} new=${next.join(',')}`); + process.exit(0); +} +// A side's logo, when the repository has one (the e2e fixture does), else bytes that only sniff as PNG. +const stub = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + Buffer.from(contents.join(';')), +]); +const logo = (dir) => (existsSync(join(dir, 'assets/logo.png')) ? readFileSync(join(dir, 'assets/logo.png')) : null); +writeFileSync(join(process.env.DIFFLE_OUT, 'page.png'), logo(process.env.DIFFLE_NEW) ?? stub); +const oldLogo = logo(process.env.DIFFLE_OLD); +if (oldLogo) writeFileSync(join(process.env.DIFFLE_OUT, 'old.png'), oldLogo); +const report = { + title: 'Fake report', + blocks: [ + { + kind: 'markdown', + text: `old: ${old.join(', ')}\nnew: ${next.join(', ')}\nscope: ${input.scope}\nfiles: ${input.files.map((f) => f.path).join(', ')}\ncontents: ${contents.join('; ')}`, + anchor: { path: input.files[0]?.path ?? 'none', line: 1 }, + }, + { kind: 'pages', label: 'Rendered', pages: [{ label: 'p. 1', old: oldLogo ? 'old.png' : null, new: 'page.png' }] }, + ], +}; +writeFileSync(join(process.env.DIFFLE_OUT, 'report.json'), JSON.stringify(report)); +process.stderr.write('rendered 1 page\n'); From 06e72bb579b8c8607a85694238ddcde567d1a104 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 10:37:09 +0000 Subject: [PATCH 2/4] docs(reports): add a shell generator for pixi.lock beside the Typst one Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XY9UWfvKQAyZeDnYaZnihy --- example/plugins/diffle-pixi | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100755 example/plugins/diffle-pixi diff --git a/example/plugins/diffle-pixi b/example/plugins/diffle-pixi new file mode 100755 index 00000000..9170b2dd --- /dev/null +++ b/example/plugins/diffle-pixi @@ -0,0 +1,28 @@ +#!/bin/sh +# A diffle report generator for pixi.lock: the lock file's diff as pixi-diff-to-markdown writes it. +# +# diffle's built-in `pixi` plugin runs `pixi-diff | pixi-diff-to-markdown` straight from PATH when +# both are installed (`pixi global install pixi-diff pixi-diff-to-markdown`). This script is the +# same thing as a program of its own, for a repository that wants other flags, pixi's extension +# dispatch (`pixi diff`), or a report with a heading per lock file. Point the config at it: +# { "plugins": { "pixi": { "command": "/path/to/example/plugins/diffle-pixi $DIFFLE_FILE" } } } +# +# The contract: DIFFLE_OLD and DIFFLE_NEW hold copies of the sides, DIFFLE_FILE the matched path +# (the command names $DIFFLE_FILE, so diffle runs the script once per changed lock file), and the +# review arrives as JSON on stdin. Whatever is printed becomes one markdown block anchored at the +# file; a nonzero exit shows the last line of stderr as the report's error. +set -eu + +file="${1:-${DIFFLE_FILE:-pixi.lock}}" +before="$DIFFLE_OLD/$file" +after="$DIFFLE_NEW/$file" +# A lock file added or removed in this diff has one side only: nothing to diff, say so and succeed. +if [ ! -f "$before" ]; then + echo "\`$file\` is new in this diff: no earlier lock file to compare with." + exit 0 +fi +if [ ! -f "$after" ]; then + echo "\`$file\` is removed in this diff." + exit 0 +fi +pixi diff --before "$before" --after "$after" | pixi diff-to-markdown From a82f4b6c96ff3082053768f3eea9f4c498d8647f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 10:37:20 +0000 Subject: [PATCH 3/4] docs(readme): point at the example generators Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XY9UWfvKQAyZeDnYaZnihy --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a136687d..198aefce 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,9 @@ replace a built-in, in the config file (`diffle config` prints its path): The command runs without a shell: a `|` pipes one program into the next, and `$DIFFLE_OLD`, `$DIFFLE_NEW`, `$DIFFLE_OUT`, `$DIFFLE_ROOT` and `$DIFFLE_FILE` are substituted. A program that writes `report.json` into `$DIFFLE_OUT` makes the whole report, rendered pages included; one that -only prints gets its stdout shown as markdown. `diffle report put --help` shows the JSON. +only prints gets its stdout shown as markdown. `diffle report put --help` shows the JSON, and +`example/plugins/` holds two generators to start from: `diffle-pixi`, a shell script around +`pixi diff | pixi diff-to-markdown`, and `diffle-typst`, which compiles each side's pages. Plugins come from your config alone, never from the reviewed tree, so a pull request cannot bring its own. From 2ea72e76a2722a18f3c178a83ba18678208e85e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 10:57:25 +0000 Subject: [PATCH 4/4] docs(skills): add diffle-review for agents that annotate a review Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XY9UWfvKQAyZeDnYaZnihy --- .claude/skills/diffle-review/SKILL.md | 72 +++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 .claude/skills/diffle-review/SKILL.md diff --git a/.claude/skills/diffle-review/SKILL.md b/.claude/skills/diffle-review/SKILL.md new file mode 100644 index 00000000..80e02109 --- /dev/null +++ b/.claude/skills/diffle-review/SKILL.md @@ -0,0 +1,72 @@ +--- +name: diffle-review +description: Review a diff through a running diffle: read what it shows, comment on lines, and push a report with findings that do not fit a line. Use when asked to review changes in diffle, annotate a diff, or leave review notes the reviewer sees beside the code. +--- + +# Review in diffle + +diffle is a local review server. While it runs for a repository, everything you do goes through its HTTP API; the reviewer sees comments inline in the diff and reports in the **Reports** view (`gr`), and can hand your comments to GitHub or copy them as a prompt. + +## Find the server + +```bash +cat "$(git rev-parse --absolute-git-dir)/diffle/server.json" # {"url":"http://127.0.0.1:4966/","pid":…} +``` + +No file, or no answer at the url: nobody is reviewing yet. Start one in the background and read the file again: + +```bash +diffle main...HEAD --no-open --keep-alive & # or `diffle working`, `diffle pr 27`, `diffle show ` +``` + +Every path below is relative to that url. Send JSON bodies with `content-type: application/json`. + +## Read the review + +| Request | Gives | +|---|---| +| `GET api/snapshot` | `changed` (path, status, additions, deletions, oldPath), `oldSha`, `newSha`, `review` | +| `GET api/patch` | every changed file's patch, one unified diff | +| `GET api/patch?path=` | one file's patch | +| `GET api/file?path=&rev=new` | a whole file on the new side (`rev=old` for the old one) | +| `GET api/threads?state=open` | comments already there; do not repeat one | + +Line numbers you quote are the new side's, as the file reads after the change, unless you comment on a removed line (`side: "old"`, old-side numbers). + +## Comment on lines + +`POST api/threads` takes one comment or an array. A comment lands under its last line; the reviewer sees it where the code is. + +```json +[ + { "path": "src/app.py", "startLine": 42, "endLine": 44, "body": "The refund path skips validation here." }, + { "path": "src/app.py", "startLine": 50, "body": "```suggestion\nreturn total.quantize(CENT)\n```" }, + { "path": "docs/guide.md", "body": "Whole-file note: this page still names the old command." } +] +``` + +- `startLine` alone comments one line; without it the comment is on the file as a whole. +- A ```` ```suggestion ```` fence proposes a replacement for the commented lines; it becomes a suggestion on GitHub. +- The reply is the created threads. A line the diff does not show is refused with a message; move the comment to a shown line or leave it on the file. +- Keep comments short and specific. One finding per comment; say what to change, not only what is wrong. + +## Push a report + +For findings that belong to no line, or a view of the whole change (a summary, a risk ranking, a dependency table, rendered output), push a report. It shows under **Reports** and goes stale, not away, when the diff moves on. + +```bash +diffle report put notes.md # the file's first heading is the title +diffle report put notes.md --anchor src/app.py:42 # the block points at a line; Enter opens it, c comments there +diffle report put review.json # a whole report: several blocks, rendered pages +``` + +Over HTTP: `PUT api/reports/` with `{ "title": "…", "blocks": [ { "kind": "markdown", "text": "…", "anchor": { "path": "src/app.py", "line": 42 } } ] }`. The id is lowercase letters, digits and dashes; putting it again replaces the report. Files a `pages` block names go up first with `PUT api/reports//files/` (raw image bytes). A 409 with `current` in the body means the diff moved on since you read it: read it again, then put again, naming `current` as `target` or leaving `target` out. + +`diffle report put --help` shows the full JSON. + +## Do this, in this order + +1. Find or start the server. Read the snapshot and the patches; read whole files where the patch is not enough. +2. Comment on the lines that need a change. Check `api/threads` first so you add to the review instead of repeating it. +3. Push one report named for what it is (`summary`, `risks`, `deps`) with what does not fit a line, each block anchored where it can be. +4. Say what you commented on and what the report holds; the reviewer takes it from there. Never submit to GitHub yourself: the reviewer does that from diffle.