OXYGENHUB

API: a proposal's files and each side's bytes 3f02e5ea4f2d

GET /proposals/{n}/files lists the files a proposal changes (path,
rename source, kind, lines added and removed), and
GET /proposals/{n}/content/{base|head}/{path} returns a file's bytes in
the target's state or the projected state, so editors can show a
proposal in their own diff views without checking it out. ProposalFile
joins the hub-api types.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-27 23:35 UTC
Signature
✔ signed by Andy McCall
Hash
3f02e5ea4f2d8175ba0ae489317f5379d2a47997b438a52b25d2e36f7961869a
Depends on
93be9ce4653e b64e2537d702 d23464c0ac60 de505df5cf8d
Depended on by
70c3d02998a2 3571c519ada6 86a69b53aff0 a0dd06b0094e 42a0ba4bac4e c0fac31d36fd
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
12 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified crates/hub-api/src/lib.rs +15 −0
…
254 unchanged lines
255 255
    pub resolved: bool,
256 256
}
257 257
258 +
/// A file a proposal changes, between the target's state and the
259 +
/// projected state (hub spec §7.2). Fetch either side's bytes from
260 +
/// `/proposals/{number}/content/{base|head}/{path}`.
258 261
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
262 +
pub struct ProposalFile {
263 +
    pub path: String,
264 +
    /// For a rename, the path in the target's state.
265 +
    pub from: Option<String>,
266 +
    /// `added`, `removed`, `modified`, `renamed`, `mode` or `replaced`.
267 +
    pub kind: String,
268 +
    pub added: usize,
269 +
    pub removed: usize,
270 +
}
271 +
272 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
259 273
pub struct Atom {
260 274
    pub change: String,
261 275
    pub index: u32,
…
308 unchanged lines
570 584
        ("Label", schemars::schema_for!(Label)),
571 585
        ("Milestone", schemars::schema_for!(Milestone)),
572 586
        ("Comment", schemars::schema_for!(Comment)),
587 +
        ("ProposalFile", schemars::schema_for!(ProposalFile)),
573 588
        ("Atom", schemars::schema_for!(Atom)),
574 589
        ("Review", schemars::schema_for!(Review)),
575 590
        ("TreeEntry", schemars::schema_for!(TreeEntry)),
…
26 unchanged lines
modified crates/hub-api/ts/index.d.ts +8 −0
…
154 unchanged lines
155 155
  resolved: boolean;
156 156
};
157 157
158 +
export type ProposalFile = {
159 +
  added: number;
160 +
  from?: string | null;
161 +
  kind: string;
162 +
  path: string;
163 +
  removed: number;
164 +
};
165 +
158 166
export type Review = {
159 167
  author: string;
160 168
  body: string;
…
194 unchanged lines
modified crates/hub/src/api/mod.rs +50 −0
…
38 unchanged lines
39 39
        .route("/repos/{owner}/{name}/proposals/{number}/comments", get(proposal_comments).post(proposal_comment))
40 40
        .route("/repos/{owner}/{name}/proposals/{number}/reviews", get(proposal_reviews).post(proposal_review))
41 41
        .route("/repos/{owner}/{name}/proposals/{number}/checks", get(proposal_checks))
42 +
        .route("/repos/{owner}/{name}/proposals/{number}/files", get(proposal_files))
43 +
        .route("/repos/{owner}/{name}/proposals/{number}/content/{side}/{*path}", get(proposal_content))
42 44
        .route("/repos/{owner}/{name}/issues", get(issues).post(create_issue))
43 45
        .route("/repos/{owner}/{name}/issues/{number}", get(issue).post(issue_state))
44 46
        .route("/repos/{owner}/{name}/issues/{number}/comments", get(issue_comments).post(issue_comment))
…
516 unchanged lines
561 563
    Ok(Json(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, source_url).await))
562 564
}
563 565
566 +
/// The files a proposal changes (hub spec §12.1).
567 +
async fn proposal_files(
568 +
    State(app): State<Shared>,
569 +
    headers: HeaderMap,
570 +
    Path((owner, name, number)): Path<(String, String, i64)>,
571 +
) -> Result<Json<Vec<hub_api::ProposalFile>>, ApiError> {
572 +
    let c = caller(&app, &headers).await?;
573 +
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
574 +
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?;
575 +
    let loaded = crate::web::proposals::load(&app, &repo, p).await?;
576 +
    Ok(Json(
577 +
        loaded
578 +
            .files
579 +
            .iter()
580 +
            .map(|f| hub_api::ProposalFile {
581 +
                path: f.path.clone(),
582 +
                from: f.from.clone(),
583 +
                kind: f.kind.clone(),
584 +
                added: f.added,
585 +
                removed: f.removed,
586 +
            })
587 +
            .collect(),
588 +
    ))
589 +
}
590 +
591 +
/// One file's bytes on a side of a proposal: `base` is the target's
592 +
/// state, `head` the projected state. 404 when the file does not exist
593 +
/// on that side.
594 +
async fn proposal_content(
595 +
    State(app): State<Shared>,
596 +
    headers: HeaderMap,
597 +
    Path((owner, name, number, side, path)): Path<(String, String, i64, String, String)>,
598 +
) -> Result<Response, ApiError> {
599 +
    let c = caller(&app, &headers).await?;
600 +
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
601 +
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?;
602 +
    let loaded = crate::web::proposals::load(&app, &repo, p).await?;
603 +
    let heads = match side.as_str() {
604 +
        "base" => loaded.target_heads,
605 +
        "head" => loaded.heads,
606 +
        _ => return Err(ApiError(StatusCode::BAD_REQUEST, "the side is base or head".into())),
607 +
    };
608 +
    let limit = app.config.limits.render_limit.0;
609 +
    let target = view::RefTarget { label: side, kind: view::RefKind::Change, heads };
610 +
    let f = app.repos.read(&repo, move |repo, cache| view::file(repo, cache, target, &path, limit)).await?;
611 +
    Ok(crate::web::raw(f.bytes, &f.name, false))
612 +
}
613 +
564 614
async fn create_proposal(
565 615
    State(app): State<Shared>,
566 616
    headers: HeaderMap,
…
487 unchanged lines
modified crates/hub/src/web/proposals.rs +2 −0
…
305 unchanged lines
306 306
    pub changes: Vec<ChangeView>,
307 307
    pub hashes: Vec<ChangeHash>,
308 308
    pub heads: Vec<ChangeHash>,
309 +
    pub target_heads: Vec<ChangeHash>,
309 310
    pub state: String,
310 311
    pub target_state: String,
311 312
    pub conflicted: bool,
…
60 unchanged lines
372 373
                changes: p.changes,
373 374
                hashes: p.hashes,
374 375
                heads: p.heads,
376 +
                target_heads: p.target_heads.clone(),
375 377
                state: p.state.to_hex(),
376 378
                target_state: p.target_state.to_hex(),
377 379
                conflicted: p.conflicted,
…
702 unchanged lines
modified crates/hub/tests/api.rs +14 −0
…
256 unchanged lines
257 257
    client.bookmark_create("feat", Some(&[base])).unwrap();
258 258
    client.bookmark_switch("feat", false).unwrap();
259 259
    write(work.path(), "b.txt", b"b\n");
260 +
    write(work.path(), "a.txt", b"a\nA\n");
260 261
    client.snapshot("t").unwrap();
261 262
    let feat = client.commit("feat", &[]).unwrap();
262 263
    assert!(client.push_bookmark("origin", "feat", false).unwrap().iter().all(|r| r.ok));
…
4 unchanged lines
267 268
    assert_eq!(v["number"], 2);
268 269
    let state = v["projected_state"].as_str().unwrap().to_string();
269 270
    assert_eq!(v["landable"], true);
271 +
    // Its files, and each side's bytes, for editors' diff views.
272 +
    let (st, v) = api(app, "GET", "/api/v1/repos/andy/demo/proposals/2/files", None, None).await;
273 +
    assert_eq!(st, StatusCode::OK, "{v}");
274 +
    let files: Vec<(String, String)> =
275 +
        v.as_array().unwrap().iter().map(|f| (f["path"].as_str().unwrap().to_string(), f["kind"].as_str().unwrap().to_string())).collect();
276 +
    assert_eq!(files, vec![("a.txt".into(), "modified".into()), ("b.txt".into(), "added".into())], "{v}");
277 +
    assert_eq!(v[0]["added"], 1);
278 +
    let side = |s: &str, p: &str| format!("/api/v1/repos/andy/demo/proposals/2/content/{s}/{p}");
279 +
    assert_eq!(call(app, "GET", &side("base", "a.txt"), None, None, None, None).await.body, "a\n");
280 +
    assert_eq!(call(app, "GET", &side("head", "a.txt"), None, None, None, None).await.body, "a\nA\n");
281 +
    assert_eq!(call(app, "GET", &side("head", "b.txt"), None, None, None, None).await.body, "b\n");
282 +
    assert_eq!(call(app, "GET", &side("base", "b.txt"), None, None, None, None).await.status, StatusCode::NOT_FOUND, "b.txt is new");
283 +
    assert_eq!(call(app, "GET", &side("middle", "a.txt"), None, None, None, None).await.status, StatusCode::BAD_REQUEST);
270 284
    // Require the "build" check.
271 285
    let settings = call(app, "GET", "/andy/demo/settings", None, Some(&andy), None, None).await;
272 286
    let csrf = csrf_of(&settings.body);
…
46 unchanged lines
modified docs/spec.md +1 −1
…
363 unchanged lines
364 364
365 365
### 12.1 API
366 366
367 -
JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses, cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history`, `/compare`, `/conflicts`, `/proposals`, `/proposals/{n}/comments|reviews|checks|land`, `/issues`, `/labels`, `/milestones`, `/releases`, `/tags`, `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/users/{name}`, `/orgs/{name}`, `/search`. Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link.
367 +
JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses, cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history`, `/compare`, `/conflicts`, `/proposals`, `/proposals/{n}/comments|reviews|checks|files|land`, `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues`, `/labels`, `/milestones`, `/releases`, `/tags`, `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/users/{name}`, `/orgs/{name}`, `/search`. Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link.
368 368
369 369
### 12.2 Webhooks
370 370
…
172 unchanged lines