Labels from oh: on proposals and issues, and the repository's labels 2291f8cc9c96
PATCH /proposals/{number} takes labels, the whole set by name, for
triage, every name checked before anything changes, recorded on the
timeline; a proposal's JSON lists its labels. oh proposal label N and
oh issue label N take --add and --remove; oh label list, create and
delete. ProposalEdit and ReleaseEdit join the schema list. Test.
closes #80
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-02 12:57 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
2291f8cc9c9678aa1529be16a0e644cf466f14fb3602765d902993fcff467014- Depends on
- 0b20f1ec4007 1f1eb06a9009 5c6e92fd8d71 739a763a558c 749d9a5fcd9f 76f262e1a666 93be9ce4653e b5668108487f b64e2537d702 bf5101d394c0 cc1cd197df1d d23464c0ac60 de505df5cf8d
- Depended on by
- 306aec1839aa 09ffa7b600a9 ba0a55bb5b3c 2a8b621676b4 dc59d1b56c3d 71e65fcf95ab 40dd3f86379a 17a3a84b00d1 62ceb2eec18c 5ea4872f23e8 b5229b56f49b 745d225de344 6a1780558353
- 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.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
- 23 atoms, 0 tree operations, 7 files
Browse the tree at this change
modified CHANGELOG.md +2 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **Labels from `oh` (#80).** `oh proposal label N --add NAME --remove NAME` and `oh issue label N …` put labels on or take them off (triage), and `oh label list|create|delete` manage the repository's labels. Through the API, `PATCH /proposals/{n}` takes `labels` (the whole set, by name, every name checked before anything changes), and a proposal's JSON lists its labels. |
|
| 8 | + | ||
| 7 | 9 | - **A clearer release trust page (#79).** A row of badges at the top says whether the tag is signed and by whom, how many changes are signed or sealed, and how many assets an attested OxygenFlow build produced. The graph is drawn at its own size (it filled the screen), drops the builds column when nothing was built, and marks every box: a lock where the claim is signed and checked, `!` where it is not (unsigned, uploaded by hand), `✘` where it fails, with the reason underneath and the details on hover. Each asset's heading carries its badge too. The GitHub look no longer strokes the graph's text. |
|
| 8 | 10 | ||
| 9 | 11 | ## 0.3.0 (2026-10-02) |
|
| … | 31 unchanged lines |
modified crates/hub-api/src/lib.rs +7 −0
| … | 172 unchanged lines |
||
| 173 | 173 | /// Asked to review and not yet reviewing, in the order asked. |
|
| 174 | 174 | #[serde(default)] |
|
| 175 | 175 | pub requested_reviewers: Vec<String>, |
|
| 176 | + | #[serde(default)] |
|
| 177 | + | pub labels: Vec<Label>, |
|
| 176 | 178 | } |
|
| 177 | 179 | ||
| 178 | 180 | /// How a proposal was landed. |
|
| … | 691 unchanged lines |
||
| 870 | 872 | pub title: Option<String>, |
|
| 871 | 873 | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 872 | 874 | pub body: Option<String>, |
|
| 875 | + | /// The whole set of labels, by name (triage); oxygenhub#80. |
|
| 876 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 877 | + | pub labels: Option<Vec<String>>, |
|
| 873 | 878 | } |
|
| 874 | 879 | ||
| 875 | 880 | /// `PATCH /repos/{owner}/{name}/releases/{tag}`: what to change; fields |
|
| … | 62 unchanged lines |
||
| 938 | 943 | ("NewRepository", schemars::schema_for!(NewRepository)), |
|
| 939 | 944 | ("NewIssue", schemars::schema_for!(NewIssue)), |
|
| 940 | 945 | ("IssueEdit", schemars::schema_for!(IssueEdit)), |
|
| 946 | + | ("ReleaseEdit", schemars::schema_for!(ReleaseEdit)), |
|
| 947 | + | ("ProposalEdit", schemars::schema_for!(ProposalEdit)), |
|
| 941 | 948 | ("NewLabel", schemars::schema_for!(NewLabel)), |
|
| 942 | 949 | ("RequiredChecks", schemars::schema_for!(RequiredChecks)), |
|
| 943 | 950 | ("Project", schemars::schema_for!(Project)), |
|
| … | 67 unchanged lines |
modified crates/hub/src/api/mod.rs +32 −9
| … | 832 unchanged lines |
||
| 833 | 833 | let me = c.require("proposals:write").or_else(|_| c.require("repo:write"))?.clone(); |
|
| 834 | 834 | let (repo, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?; |
|
| 835 | 835 | let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; |
|
| 836 | - | if Some(me.id) != p.author_id && role < Role::Maintain { |
|
| 836 | + | let text = body.title.is_some() || body.body.is_some(); |
|
| 837 | + | if text && Some(me.id) != p.author_id && role < Role::Maintain { |
|
| 837 | 838 | return Err(ApiError(StatusCode::FORBIDDEN, "only the proposal's author or a maintainer can edit it".into())); |
|
| 839 | + | } |
|
| 840 | + | if body.labels.is_some() && role < Role::Triage { |
|
| 841 | + | return Err(forbidden("setting labels needs triage access")); |
|
| 842 | + | } |
|
| 843 | + | // Every name is checked before anything changes. |
|
| 844 | + | let labels = resolve_triage(&app, row.id, body.labels.as_deref(), None, None).await?.labels; |
|
| 845 | + | if text { |
|
| 846 | + | let title = match body.title.as_deref().map(str::trim) { |
|
| 847 | + | Some("") => return Err(ApiError(StatusCode::BAD_REQUEST, "a proposal needs a title".into())), |
|
| 848 | + | Some(t) => t, |
|
| 849 | + | None => p.title.as_str(), |
|
| 850 | + | }; |
|
| 851 | + | let text = body.body.as_deref().map(str::trim).unwrap_or(&p.body); |
|
| 852 | + | db::proposals::edit(&app.db, p.id, title, text, &p.target).await?; |
|
| 853 | + | db::proposals::add_event(&app.db, p.id, &me.username, "edited", "").await.ok(); |
|
| 838 | 854 | } |
|
| 839 | - | let title = match body.title.as_deref().map(str::trim) { |
|
| 840 | - | Some("") => return Err(ApiError(StatusCode::BAD_REQUEST, "a proposal needs a title".into())), |
|
| 841 | - | Some(t) => t, |
|
| 842 | - | None => p.title.as_str(), |
|
| 843 | - | }; |
|
| 844 | - | let text = body.body.as_deref().map(str::trim).unwrap_or(&p.body); |
|
| 845 | - | db::proposals::edit(&app.db, p.id, title, text, &p.target).await?; |
|
| 846 | - | db::proposals::add_event(&app.db, p.id, &me.username, "edited", "").await.ok(); |
|
| 855 | + | if let Some(want) = labels { |
|
| 856 | + | let have = db::issues::proposal_labels(&app.db, p.id).await?; |
|
| 857 | + | for l in &have { |
|
| 858 | + | if !want.iter().any(|w| w.id == l.id) { |
|
| 859 | + | db::issues::set_proposal_label(&app.db, p.id, l.id, false).await?; |
|
| 860 | + | } |
|
| 861 | + | } |
|
| 862 | + | for w in &want { |
|
| 863 | + | if !have.iter().any(|l| l.id == w.id) { |
|
| 864 | + | db::issues::set_proposal_label(&app.db, p.id, w.id, true).await?; |
|
| 865 | + | } |
|
| 866 | + | } |
|
| 867 | + | let names: Vec<&str> = want.iter().map(|l| l.name.as_str()).collect(); |
|
| 868 | + | db::proposals::add_event(&app.db, p.id, &me.username, "set labels", &names.join(", ")).await.ok(); |
|
| 869 | + | } |
|
| 847 | 870 | let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; |
|
| 848 | 871 | let source_url = match p.source_repo_id { |
|
| 849 | 872 | Some(id) => db::repos::get_by_id(&app.db, id).await?.map(|r| format!("{}/{}", app.config.base_url(), r.slug())), |
|
| … | 1133 unchanged lines |
modified crates/hub/src/web/proposals.rs +6 −0
| … | 1255 unchanged lines |
||
| 1256 | 1256 | when_ms: p.landed_ms, |
|
| 1257 | 1257 | }), |
|
| 1258 | 1258 | requested_reviewers: pdb::review_requests(&app.db, p.id).await.unwrap_or_default(), |
|
| 1259 | + | labels: db::issues::proposal_labels(&app.db, p.id) |
|
| 1260 | + | .await |
|
| 1261 | + | .unwrap_or_default() |
|
| 1262 | + | .into_iter() |
|
| 1263 | + | .map(|l| hub_api::Label { name: l.name, colour: l.colour, description: l.description }) |
|
| 1264 | + | .collect(), |
|
| 1259 | 1265 | } |
|
| 1260 | 1266 | } |
|
| 1261 | 1267 | ||
| … | 128 unchanged lines |
modified crates/hub/tests/api.rs +22 −0
| … | 459 unchanged lines |
||
| 460 | 460 | let n = v["number"].as_i64().unwrap(); |
|
| 461 | 461 | assert_eq!(edit(&bob_rw, n, r#"{"body":"mine now"}"#).await.0, StatusCode::FORBIDDEN); |
|
| 462 | 462 | assert_eq!(edit(&andy_ro, 2, r#"{"body":"x"}"#).await.0, StatusCode::FORBIDDEN, "a read-only token cannot edit"); |
|
| 463 | + | ||
| 464 | + | // oxygenhub#80: labels on a proposal, the whole set by name, checked |
|
| 465 | + | // before anything changes, shown in its JSON. |
|
| 466 | + | for l in ["ui", "flow"] { |
|
| 467 | + | let body = format!(r#"{{"name":"{l}"}}"#); |
|
| 468 | + | assert_eq!(api(app, "POST", "/api/v1/repos/andy/demo/labels", Some(&andy_admin), Some(&body)).await.0, StatusCode::CREATED); |
|
| 469 | + | } |
|
| 470 | + | assert_eq!(edit(&bob_rw, 2, r#"{"labels":["ui"]}"#).await.0, StatusCode::FORBIDDEN, "labels need triage, not authorship"); |
|
| 471 | + | let (st, v) = edit(&andy_pw, 2, r#"{"labels":["ui","flow"]}"#).await; |
|
| 472 | + | assert_eq!(st, StatusCode::OK, "{v}"); |
|
| 473 | + | let names = |v: &serde_json::Value| -> Vec<String> { |
|
| 474 | + | v["labels"].as_array().unwrap().iter().map(|l| l["name"].as_str().unwrap().to_string()).collect() |
|
| 475 | + | }; |
|
| 476 | + | assert_eq!(names(&v), ["flow", "ui"]); |
|
| 477 | + | assert_eq!(v["title"], "Feat, renamed", "labels alone leave the text"); |
|
| 478 | + | let (st, _) = edit(&andy_pw, 2, r#"{"labels":["ui","nope"]}"#).await; |
|
| 479 | + | assert_eq!(st, StatusCode::BAD_REQUEST, "an unknown label is refused"); |
|
| 480 | + | let (_, v) = api(app, "GET", "/api/v1/repos/andy/demo/proposals/2", None, None).await; |
|
| 481 | + | assert_eq!(names(&v), ["flow", "ui"], "and nothing changed"); |
|
| 482 | + | let (_, v) = edit(&andy_pw, 2, r#"{"labels":["flow"]}"#).await; |
|
| 483 | + | assert_eq!(names(&v), ["flow"]); |
|
| 484 | + | assert_eq!(edit(&andy_ro, 2, r#"{"labels":[]}"#).await.0, StatusCode::FORBIDDEN); |
|
| 463 | 485 | } |
|
| 464 | 486 | ||
| 465 | 487 | #[tokio::test(flavor = "multi_thread")] |
|
| … | 841 unchanged lines |
modified crates/oh/src/main.rs +121 −1
| … | 66 unchanged lines |
||
| 67 | 67 | #[command(subcommand)] |
|
| 68 | 68 | cmd: ReleaseCmd, |
|
| 69 | 69 | }, |
|
| 70 | + | /// The repository's labels |
|
| 71 | + | Label { |
|
| 72 | + | #[command(subcommand)] |
|
| 73 | + | cmd: LabelCmd, |
|
| 74 | + | }, |
|
| 70 | 75 | /// Call the API directly: `oh api /repos/OWNER/NAME/issues` |
|
| 71 | 76 | Api { |
|
| 72 | 77 | /// A path under /api/v1 |
|
| … | 35 unchanged lines |
||
| 108 | 113 | ||
| 109 | 114 | #[derive(Subcommand)] |
|
| 110 | 115 | enum IssueCmd { |
|
| 116 | + | /// Put labels on an issue or take them off (triage) |
|
| 117 | + | Label(LabelChange), |
|
| 111 | 118 | /// Open issues (or --state closed|all) |
|
| 112 | 119 | List { |
|
| 113 | 120 | #[arg(long, default_value = "open")] |
|
| … | 69 unchanged lines |
||
| 183 | 190 | #[arg(long, short = 'F', conflicts_with = "body")] |
|
| 184 | 191 | body_file: Option<String>, |
|
| 185 | 192 | }, |
|
| 193 | + | /// Put labels on a proposal or take them off (triage) |
|
| 194 | + | Label(LabelChange), |
|
| 186 | 195 | /// Comment on a proposal's conversation |
|
| 187 | 196 | Comment { |
|
| 188 | 197 | number: i64, |
|
| … | 7 unchanged lines |
||
| 196 | 205 | Checks { number: i64 }, |
|
| 197 | 206 | /// Land a proposal on its target, as the page's Land button does |
|
| 198 | 207 | Land { number: i64 }, |
|
| 208 | + | } |
|
| 209 | + | ||
| 210 | + | #[derive(Subcommand)] |
|
| 211 | + | enum LabelCmd { |
|
| 212 | + | /// The repository's labels |
|
| 213 | + | List, |
|
| 214 | + | /// Create a label, or change the one of that name (triage) |
|
| 215 | + | Create { |
|
| 216 | + | name: String, |
|
| 217 | + | /// `#rrggbb` (default: one the hub picks) |
|
| 218 | + | #[arg(long)] |
|
| 219 | + | colour: Option<String>, |
|
| 220 | + | #[arg(long, short, default_value = "")] |
|
| 221 | + | description: String, |
|
| 222 | + | }, |
|
| 223 | + | /// Delete a label, taking it off every issue and proposal (triage) |
|
| 224 | + | Delete { name: String }, |
|
| 225 | + | } |
|
| 226 | + | ||
| 227 | + | /// Labels to put on or take off an issue or proposal. |
|
| 228 | + | #[derive(clap::Args)] |
|
| 229 | + | struct LabelChange { |
|
| 230 | + | number: i64, |
|
| 231 | + | /// A label to add (repeatable) |
|
| 232 | + | #[arg(long, short)] |
|
| 233 | + | add: Vec<String>, |
|
| 234 | + | /// A label to take off (repeatable) |
|
| 235 | + | #[arg(long, short)] |
|
| 236 | + | remove: Vec<String>, |
|
| 237 | + | } |
|
| 238 | + | ||
| 239 | + | impl LabelChange { |
|
| 240 | + | /// The set after the change, from the one there now. |
|
| 241 | + | fn apply(&self, now: &[hub_api::Label]) -> Result<Vec<String>> { |
|
| 242 | + | if self.add.is_empty() && self.remove.is_empty() { |
|
| 243 | + | bail!("say what to change: --add NAME or --remove NAME"); |
|
| 244 | + | } |
|
| 245 | + | let mut names: Vec<String> = now.iter().map(|l| l.name.clone()).filter(|n| !self.remove.contains(n)).collect(); |
|
| 246 | + | for a in &self.add { |
|
| 247 | + | if !names.contains(a) { |
|
| 248 | + | names.push(a.clone()); |
|
| 249 | + | } |
|
| 250 | + | } |
|
| 251 | + | Ok(names) |
|
| 252 | + | } |
|
| 253 | + | } |
|
| 254 | + | ||
| 255 | + | fn label_names(labels: &[String]) -> String { |
|
| 256 | + | if labels.is_empty() { |
|
| 257 | + | ui::dim("no labels") |
|
| 258 | + | } else { |
|
| 259 | + | labels.iter().map(|l| format!("[{l}]")).collect::<Vec<_>>().join(" ") |
|
| 260 | + | } |
|
| 199 | 261 | } |
|
| 200 | 262 | ||
| 201 | 263 | #[derive(Subcommand)] |
|
| … | 133 unchanged lines |
||
| 335 | 397 | Cmd::Proposal { cmd } => proposal(&c, &t, cmd, json), |
|
| 336 | 398 | Cmd::Run { cmd } => flow(&c, &t, cmd, json), |
|
| 337 | 399 | Cmd::Release { cmd } => release(&c, &t, cmd, json), |
|
| 400 | + | Cmd::Label { cmd } => label(&c, &t, cmd, json), |
|
| 338 | 401 | } |
|
| 339 | 402 | } |
|
| 340 | 403 | ||
| … | 149 unchanged lines |
||
| 490 | 553 | let i: Issue = |
|
| 491 | 554 | c.send("POST", &format!("{base}/issues/{number}"), &IssueStateChange { state: "open".into(), reason: String::new() })?; |
|
| 492 | 555 | println!("{} reopened #{} {}", ui::ok("✔"), i.number, i.title); |
|
| 556 | + | } |
|
| 557 | + | IssueCmd::Label(change) => { |
|
| 558 | + | let path = format!("{base}/issues/{}", change.number); |
|
| 559 | + | let now: Issue = c.get(&path)?; |
|
| 560 | + | let labels = change.apply(&now.labels)?; |
|
| 561 | + | let edit = hub_api::IssueEdit { title: None, body: None, labels: Some(labels.clone()), assignees: None, milestone: None }; |
|
| 562 | + | let i: Issue = c.send("PATCH", &path, &edit)?; |
|
| 563 | + | if json { |
|
| 564 | + | println!("{}", serde_json::to_string(&i)?); |
|
| 565 | + | } else { |
|
| 566 | + | println!("{} #{} {}", ui::ok("✔"), i.number, label_names(&labels)); |
|
| 567 | + | } |
|
| 493 | 568 | } |
|
| 494 | 569 | IssueCmd::Comment { number, body } => { |
|
| 495 | 570 | c.send_text("POST", &format!("{base}/issues/{number}/comments"), &NewComment { body, path: None, line: None })?; |
|
| … | 94 unchanged lines |
||
| 590 | 665 | if title.is_none() && body.is_none() { |
|
| 591 | 666 | bail!("say what to change: --title, --body or --body-file"); |
|
| 592 | 667 | } |
|
| 593 | - | let edit = hub_api::ProposalEdit { title, body }; |
|
| 668 | + | let edit = hub_api::ProposalEdit { title, body, labels: None }; |
|
| 594 | 669 | let p: Proposal = c.send("PATCH", &format!("{base}/proposals/{number}"), &edit)?; |
|
| 595 | 670 | if json { |
|
| 596 | 671 | println!("{}", serde_json::to_string(&p)?); |
|
| 597 | 672 | } else { |
|
| 598 | 673 | println!("{} edited #{} {}", ui::ok("✔"), p.number, ui::dim(&p.url)); |
|
| 674 | + | } |
|
| 675 | + | } |
|
| 676 | + | ProposalCmd::Label(change) => { |
|
| 677 | + | let path = format!("{base}/proposals/{}", change.number); |
|
| 678 | + | let now: Proposal = c.get(&path)?; |
|
| 679 | + | let labels = change.apply(&now.labels)?; |
|
| 680 | + | let edit = hub_api::ProposalEdit { labels: Some(labels.clone()), ..Default::default() }; |
|
| 681 | + | let p: Proposal = c.send("PATCH", &path, &edit)?; |
|
| 682 | + | if json { |
|
| 683 | + | println!("{}", serde_json::to_string(&p)?); |
|
| 684 | + | } else { |
|
| 685 | + | println!("{} #{} {}", ui::ok("✔"), p.number, label_names(&labels)); |
|
| 599 | 686 | } |
|
| 600 | 687 | } |
|
| 601 | 688 | ProposalCmd::Comment { number, body, body_file } => { |
|
| … | 158 unchanged lines |
||
| 760 | 847 | println!("{started}"); |
|
| 761 | 848 | } else { |
|
| 762 | 849 | println!("{} started run #{} {}", ui::ok("✔"), started["number"], ui::dim(started["url"].as_str().unwrap_or(""))); |
|
| 850 | + | } |
|
| 851 | + | } |
|
| 852 | + | } |
|
| 853 | + | Ok(0) |
|
| 854 | + | } |
|
| 855 | + | ||
| 856 | + | fn label(c: &Client, t: &Target, cmd: LabelCmd, json: bool) -> Result<i32> { |
|
| 857 | + | let base = t.repo_path()?; |
|
| 858 | + | match cmd { |
|
| 859 | + | LabelCmd::List => { |
|
| 860 | + | let path = format!("{base}/labels"); |
|
| 861 | + | if json { |
|
| 862 | + | println!("{}", c.get_text(&path)?); |
|
| 863 | + | return Ok(0); |
|
| 864 | + | } |
|
| 865 | + | let labels: Vec<hub_api::Label> = c.get(&path)?; |
|
| 866 | + | if labels.is_empty() { |
|
| 867 | + | println!("{}", ui::dim("No labels.")); |
|
| 763 | 868 | } |
|
| 869 | + | for l in labels { |
|
| 870 | + | println!("{} {} {}", ui::dim(&l.colour), l.name, ui::dim(&l.description)); |
|
| 871 | + | } |
|
| 872 | + | } |
|
| 873 | + | LabelCmd::Create { name, colour, description } => { |
|
| 874 | + | let l: hub_api::Label = c.send("POST", &format!("{base}/labels"), &hub_api::NewLabel { name, colour, description })?; |
|
| 875 | + | if json { |
|
| 876 | + | println!("{}", serde_json::to_string(&l)?); |
|
| 877 | + | } else { |
|
| 878 | + | println!("{} label {} {}", ui::ok("✔"), l.name, ui::dim(&l.colour)); |
|
| 879 | + | } |
|
| 880 | + | } |
|
| 881 | + | LabelCmd::Delete { name } => { |
|
| 882 | + | c.send_text("DELETE", &format!("{base}/labels/{}", enc(&name)), &serde_json::json!({}))?; |
|
| 883 | + | println!("{} deleted label {name}", ui::ok("✔")); |
|
| 764 | 884 | } |
|
| 765 | 885 | } |
|
| 766 | 886 | Ok(0) |
|
| … | 94 unchanged lines |
modified docs/spec.md +1 −1
| … | 440 unchanged lines |
||
| 441 | 441 | ||
| 442 | 442 | ### 12.1 API |
|
| 443 | 443 | ||
| 444 | - | JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route too), 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` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/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` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones`, `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags`, `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/notifications` (the caller's inbox and unread count; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, `/cards` to add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one; `repo:write` and the page's rights to change), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state), `/flow/runs/{n}` (a run with its jobs), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). 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. |
|
| 444 | + | JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route too), 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` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/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` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones`, `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags`, `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/notifications` (the caller's inbox and unread count; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, `/cards` to add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one; `repo:write` and the page's rights to change), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state), `/flow/runs/{n}` (a run with its jobs), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). 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. |
|
| 445 | 445 | ||
| 446 | 446 | ### 12.2 Webhooks |
|
| 447 | 447 | ||
| … | 215 unchanged lines |