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 |