OXYGENHUB

A change's diff lines through the API d2263e299da7

GET /api/v1/repos/{owner}/{name}/changes/{hash} now gives each file's
diff lines (kind, old and new numbers, text), three lines of context,
up to 2000 a file with truncated saying when it stops short, so clients
can show a diff themselves.

closes #320
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-26 00:32 UTC
Signature
◈ sealed by Andy McCall
Hash
d2263e299da7b94c34efdbabdf0f5232fcb7e0ac463a85dd9987532a9b0bf718
Depends on
17dc8de4fca8 a7e6e8e6094c b5668108487f b64e2537d702 d23464c0ac60
Depended on by
5f3fa151688e
In bookmarks
api-unknown-fields diffusion-pin-bookmark main proposal-list-fast
In tags
v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
6 atoms, 0 tree operations, 4 files

Browse the tree at this change

modified CHANGELOG.md +1 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **A change's diff through the API (#320):** `GET /api/v1/repos/{owner}/{name}/changes/{hash}` gives each file's diff lines too (`lines`: `kind` context, add, del or gap, `old` and `new` line numbers, `text`), with three lines of context, up to 2000 lines a file (`truncated` says when it stops short), so the Android app can show a diff itself.
7 8
- **JaCoCo coverage is read (#308):** Gradle's `jacocoTestReport` and Kover write JaCoCo XML, which a `coverage` artifact (or `POST /coverage`) may now be, beside LCOV and Cobertura: lines hit when an instruction ran, branches, methods. Its paths are relative to a source root (`io/oxygenhub/…/X.kt`), so a report's path not in the state is matched by its ending to the one file it can be.
8 9
## 0.9.4 (2026-10-25)
9 10
…
327 unchanged lines
modified crates/hub-api/src/lib.rs +21 −0
…
433 unchanged lines
434 434
    pub from: Option<String>,
435 435
    pub added: usize,
436 436
    pub removed: usize,
437 +
    /// The diff with three lines of context, up to `DIFF_LINES` lines.
438 +
    #[serde(default)]
439 +
    pub lines: Vec<DiffLine>,
440 +
    /// Whether `lines` stops short of the whole diff.
441 +
    #[serde(default)]
442 +
    pub truncated: bool,
443 +
}
444 +
445 +
/// Lines of one file's diff the API sends at most.
446 +
pub const DIFF_LINES: usize = 2000;
447 +
448 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
449 +
pub struct DiffLine {
450 +
    /// `context`, `add`, `del`, or `gap` (lines left out between hunks).
451 +
    pub kind: String,
452 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
453 +
    pub old: Option<u32>,
454 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
455 +
    pub new: Option<u32>,
456 +
    #[serde(default)]
457 +
    pub text: String,
437 458
}
438 459
439 460
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
…
1109 unchanged lines
modified crates/hub/src/api/mod.rs +24 −4
…
1053 unchanged lines
1054 1054
        .await?;
1055 1055
    let mut v = d.view;
1056 1056
    crate::web::attribute(&app, &mut [&mut v]).await;
1057 -
    let files = crate::web::history::render_files(&d.files, &d.bases)
1058 -
        .into_iter()
1059 -
        .map(|f| FileChange { path: f.path, kind: f.kind, from: f.from, added: f.added, removed: f.removed })
1060 -
        .collect();
1057 +
    let files = crate::web::history::render_files(&d.files, &d.bases).into_iter().map(file_change).collect();
1061 1058
    Ok(Json(ChangeDetail {
1062 1059
        body: v.body.clone(),
1063 1060
        change: summary(&v),
…
2 unchanged lines
1066 1063
        in_tags: d.in_tags,
1067 1064
        files,
1068 1065
    }))
1066 +
}
1067 +
1068 +
fn file_change(f: crate::web::history::FileRows) -> FileChange {
1069 +
    use crate::render::diff::Kind;
1070 +
    let truncated = f.lines.len() > hub_api::DIFF_LINES;
1071 +
    let lines = f
1072 +
        .lines
1073 +
        .into_iter()
1074 +
        .take(hub_api::DIFF_LINES)
1075 +
        .map(|l| DiffLine {
1076 +
            kind: match l.kind {
1077 +
                Kind::Context => "context",
1078 +
                Kind::Add => "add",
1079 +
                Kind::Del => "del",
1080 +
                Kind::Gap => "gap",
1081 +
            }
1082 +
            .into(),
1083 +
            old: l.old_no,
1084 +
            new: l.new_no,
1085 +
            text: l.text,
1086 +
        })
1087 +
        .collect();
1088 +
    FileChange { path: f.path, kind: f.kind, from: f.from, added: f.added, removed: f.removed, lines, truncated }
1069 1089
}
1070 1090
1071 1091
fn entry(e: &view::Entry) -> TreeEntry {
…
1993 unchanged lines
modified crates/hub/tests/api.rs +2 −0
…
274 unchanged lines
275 275
    assert_eq!(v[0]["change"], base.to_hex());
276 276
    let (_, v) = api(app, "GET", &format!("/api/v1/repos/andy/demo/changes/{}", base.to_hex()), None, None).await;
277 277
    assert_eq!(v["files"][0]["path"], "a.txt");
278 +
    assert_eq!(v["files"][0]["lines"][0], serde_json::json!({"kind": "add", "new": 1, "text": "a"}), "{v}");
279 +
    assert_eq!(v["files"][0]["truncated"], false);
278 280
    let (_, v) = api(app, "GET", "/api/v1/repos/andy/demo/trust", None, None).await;
279 281
    assert!(v["policy"].is_null());
280 282
    let (_, v) = api(app, "GET", "/api/v1/repos/andy/demo/pushes", None, None).await;
…
1526 unchanged lines