Move a board's column at once (closes #289) 456f4197ade3
oh project card move N --from COLUMN -c COLUMN, with --except, --state
and --dry-run: one call (POST …/projects/{owner}/{n}/cards/move), all or
none, one audit entry.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 5c091defb1e9
- When
- · 2026-10-24 23:01 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
456f4197ade3c7be8561d727ed3ce28b309815ebe4a94765062ee3a9fb7575b2- Depends on
- 17a3a84b00d1 5c6e92fd8d71 76f262e1a666 794ff94f194a 93be9ce4653e a7e6e8e6094c b5668108487f b64e2537d702 ba0a55bb5b3c d23464c0ac60 e1ec9fbf538e ef20fdc14e8b
- Depended on by
- 0bc95135954d 306aec1839aa 1c52d940b4a0 946fe7a09faa 439ecd8708a1 ecac6e8d9b41 4f8404093d30
- In bookmarks
- api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
- In tags
- 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
- 17 atoms, 0 tree operations, 9 files
Browse the tree at this change
modified CHANGELOG.md +1 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **Move a board's column at once (#289):** `oh project card move N --from Doing -c Done` moves every card of a column, `--except 317,#320` leaves some, `--state closed,landed` takes only cards whose issue or proposal is done, and `--dry-run` lists what would move. One call (`POST …/projects/{owner}/{n}/cards/move`), all or none, one audit entry: tidying a board of 115 cards took 115 commands. |
|
| 7 | 8 | - **Expediting a run moves the queue at once (#291):** a run's jobs kept the place they had ("9 of 9 jobs come before") until the next placement pass and some other change on the page. Expediting now places every queued job again straight away, every run whose jobs' notes changed updates live, and a job an expedited run moved down says so ("2 ready jobs come first (after expedited run andy/demo #12)"). |
|
| 8 | 9 | - **Create and withdraw tags on the website (#295, #301):** a writer creates a tag on the Tags page (or `oh tag create`, `POST …/tags`), signed by the hub as their delegate, under the policy, and its tag jobs run; a maintainer withdraws one there (or `oh tag delete`, `DELETE …/tags/{tag}`, or `oxy push --delete tags/NAME`) with a signed withdrawal, never while a release uses it. The withdrawal is logged, audited and listed on the page with why, and clones drop the tag on their next fetch. Tags never move, and a withdrawn name is not used again. Pins oxygen with tag withdrawal and with keyless grants that survive their owner's key rotation (oxygen#135). |
|
| 9 | 10 | - **An agent's changes verify in a new repository (oxygen#134):** a keyless agent's change in a repository its owner had never pushed to read INVALID ("grant is by an unknown author"). The hub now sends the grant's signer with each certificate, and with oxy's fix the agent keeps it and pushes it beside the certificate, so the repository can check the grant. |
|
| … | 288 unchanged lines |
modified crates/hub-api/src/lib.rs +25 −0
| … | 809 unchanged lines |
||
| 810 | 810 | pub column: String, |
|
| 811 | 811 | } |
|
| 812 | 812 | ||
| 813 | + | /// `POST /projects/{owner}/{n}/cards/move` (#289): every card of column |
|
| 814 | + | /// `from` to `column` at once, but those `except` names (card ids, or issue |
|
| 815 | + | /// and proposal numbers as `12`, `#12`, `name#12` or `owner/name#12`) and, |
|
| 816 | + | /// given `state`, only cards whose issue or proposal is in one of them |
|
| 817 | + | /// (`closed`, `landed`…). With `dry_run`, nothing moves. |
|
| 818 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 819 | + | pub struct CardsMove { |
|
| 820 | + | pub from: String, |
|
| 821 | + | pub column: String, |
|
| 822 | + | #[serde(default)] |
|
| 823 | + | pub except: Vec<String>, |
|
| 824 | + | #[serde(default)] |
|
| 825 | + | pub state: Vec<String>, |
|
| 826 | + | #[serde(default)] |
|
| 827 | + | pub dry_run: bool, |
|
| 828 | + | } |
|
| 829 | + | ||
| 830 | + | /// What a bulk move moved, and what it left in the column. |
|
| 831 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 832 | + | pub struct CardsMoved { |
|
| 833 | + | pub moved: Vec<ProjectCard>, |
|
| 834 | + | pub kept: Vec<ProjectCard>, |
|
| 835 | + | pub dry_run: bool, |
|
| 836 | + | } |
|
| 837 | + | ||
| 813 | 838 | /// `POST /repos/{o}/{n}/labels`: create a label, or change one of that name. |
|
| 814 | 839 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 815 | 840 | pub struct NewLabel { |
|
| … | 596 unchanged lines |
modified crates/hub/src/api/mod.rs +1 −0
| … | 82 unchanged lines |
||
| 83 | 83 | .route("/projects/{owner}", get(projects::list).post(projects::create)) |
|
| 84 | 84 | .route("/projects/{owner}/{number}", get(projects::board).patch(projects::edit)) |
|
| 85 | 85 | .route("/projects/{owner}/{number}/cards", get(projects::cards).post(projects::add_card)) |
|
| 86 | + | .route("/projects/{owner}/{number}/cards/move", post(projects::move_cards)) |
|
| 86 | 87 | .route("/projects/{owner}/{number}/cards/{id}", axum::routing::patch(projects::move_card).delete(projects::remove_card)) |
|
| 87 | 88 | .route("/device/code", post(device::code)) |
|
| 88 | 89 | .route("/device/token", post(device::token)) |
|
| … | 2773 unchanged lines |
modified crates/hub/src/api/projects.rs +49 −1
| … | 7 unchanged lines |
||
| 8 | 8 | use axum::extract::{Path, State}; |
|
| 9 | 9 | use axum::http::{HeaderMap, StatusCode}; |
|
| 10 | 10 | use axum::Json; |
|
| 11 | - | use hub_api::{Board, CardMove, NewCard, NewProject, Project, ProjectCard, ProjectColumn, ProjectEdit}; |
|
| 11 | + | use hub_api::{Board, CardMove, CardsMove, CardsMoved, NewCard, NewProject, Project, ProjectCard, ProjectColumn, ProjectEdit}; |
|
| 12 | 12 | ||
| 13 | 13 | fn project_of(app: &Shared, p: &pdb::Project) -> Project { |
|
| 14 | 14 | Project { |
|
| … | 187 unchanged lines |
||
| 202 | 202 | Ok(StatusCode::NO_CONTENT) |
|
| 203 | 203 | } |
|
| 204 | 204 | ||
| 205 | + | /// Whether `card` is the one `name` names: its id, or its issue or |
|
| 206 | + | /// proposal as `12`, `#12`, `name#12` or `owner/name#12`. |
|
| 207 | + | fn names_card(name: &str, card: &ProjectCard) -> bool { |
|
| 208 | + | let name = name.trim(); |
|
| 209 | + | if name.is_empty() { |
|
| 210 | + | return false; |
|
| 211 | + | } |
|
| 212 | + | if name.parse::<i64>().is_ok_and(|id| id == card.id) { |
|
| 213 | + | return true; |
|
| 214 | + | } |
|
| 215 | + | let Some((_, number)) = card.reference.rsplit_once('#') else { return false }; |
|
| 216 | + | match name.rsplit_once('#') { |
|
| 217 | + | None => name == number, |
|
| 218 | + | Some(("", n)) => n == number, |
|
| 219 | + | Some((repo, n)) => n == number && (card.reference == format!("{repo}#{n}") || card.reference.ends_with(&format!("/{repo}#{n}"))), |
|
| 220 | + | } |
|
| 221 | + | } |
|
| 222 | + | ||
| 223 | + | /// `POST /projects/{owner}/{n}/cards/move` (#289): a column's cards to |
|
| 224 | + | /// another at once, with exceptions and a state filter; one audit entry. |
|
| 225 | + | pub async fn move_cards( |
|
| 226 | + | State(app): State<Shared>, |
|
| 227 | + | headers: HeaderMap, |
|
| 228 | + | Path((owner, number)): Path<(String, i64)>, |
|
| 229 | + | Json(body): Json<CardsMove>, |
|
| 230 | + | ) -> Result<Json<CardsMoved>, ApiError> { |
|
| 231 | + | let (c, p) = changeable(&app, &headers, &owner, number, false).await?; |
|
| 232 | + | let from = column(&app, p.id, Some(&body.from)).await?; |
|
| 233 | + | let to = column(&app, p.id, Some(&body.column)).await?; |
|
| 234 | + | if from.id == to.id { |
|
| 235 | + | return Err(ApiError(StatusCode::BAD_REQUEST, "the cards are in that column already".into())); |
|
| 236 | + | } |
|
| 237 | + | let (columns, _) = wp::board_columns(&app, c.account.as_ref(), &owner, &p).await?; |
|
| 238 | + | let cards: Vec<ProjectCard> = |
|
| 239 | + | columns.iter().filter(|col| col.column.id == from.id).flat_map(|col| col.cards.iter().map(card_of)).collect(); |
|
| 240 | + | let states: Vec<String> = body.state.iter().map(|s| s.trim().to_ascii_lowercase()).filter(|s| !s.is_empty()).collect(); |
|
| 241 | + | let (moved, kept): (Vec<ProjectCard>, Vec<ProjectCard>) = cards.into_iter().partition(|card| { |
|
| 242 | + | !body.except.iter().any(|e| names_card(e, card)) && (states.is_empty() || states.iter().any(|s| card.state.eq_ignore_ascii_case(s))) |
|
| 243 | + | }); |
|
| 244 | + | if !body.dry_run && !moved.is_empty() { |
|
| 245 | + | let ids: Vec<i64> = moved.iter().map(|c| c.id).collect(); |
|
| 246 | + | pdb::move_cards(&app.db, p.id, &ids, to.id).await?; |
|
| 247 | + | let detail = format!("{} card(s) from {} to {}", moved.len(), from.name, to.name); |
|
| 248 | + | crate::db::accounts::audit(&app.db, c.account.as_ref(), "project.cards_moved", &format!("{owner}/{number}"), &detail).await.ok(); |
|
| 249 | + | } |
|
| 250 | + | Ok(Json(CardsMoved { moved, kept, dry_run: body.dry_run })) |
|
| 251 | + | } |
|
| 252 | + | ||
| 205 | 253 | /// `DELETE /projects/{owner}/{n}/cards/{id}`. |
|
| 206 | 254 | pub async fn remove_card( |
|
| 207 | 255 | State(app): State<Shared>, |
|
| … | 10 unchanged lines |
modified crates/hub/src/db/projects.rs +21 −0
| … | 266 unchanged lines |
||
| 267 | 267 | touch(pool, project_id).await |
|
| 268 | 268 | } |
|
| 269 | 269 | ||
| 270 | + | /// Move `card_ids`, in that order, to the end of `column_id`, all or none |
|
| 271 | + | /// (#289). |
|
| 272 | + | pub async fn move_cards(pool: &Pool, project_id: i64, card_ids: &[i64], column_id: i64) -> sqlx::Result<()> { |
|
| 273 | + | let mut tx = pool.begin().await?; |
|
| 274 | + | let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = $1") |
|
| 275 | + | .bind(column_id) |
|
| 276 | + | .fetch_one(&mut *tx) |
|
| 277 | + | .await?; |
|
| 278 | + | for (i, id) in card_ids.iter().enumerate() { |
|
| 279 | + | sqlx::query("UPDATE project_cards SET column_id = $1, position = $2 WHERE id = $3 AND project_id = $4") |
|
| 280 | + | .bind(column_id) |
|
| 281 | + | .bind(pos + i as i64) |
|
| 282 | + | .bind(id) |
|
| 283 | + | .bind(project_id) |
|
| 284 | + | .execute(&mut *tx) |
|
| 285 | + | .await?; |
|
| 286 | + | } |
|
| 287 | + | sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now_ms()).bind(project_id).execute(&mut *tx).await?; |
|
| 288 | + | tx.commit().await |
|
| 289 | + | } |
|
| 290 | + | ||
| 270 | 291 | /// Swap a card with its neighbour in its column (`-1` up, `1` down). |
|
| 271 | 292 | pub async fn shift_card(pool: &Pool, project_id: i64, card_id: i64, by: i64) -> sqlx::Result<()> { |
|
| 272 | 293 | let Some(card): Option<Card> = sqlx::query_as("SELECT * FROM project_cards WHERE id = $1 AND project_id = $2") |
|
| … | 46 unchanged lines |
modified crates/hub/tests/api.rs +35 −0
| … | 926 unchanged lines |
||
| 927 | 927 | assert_eq!(st, StatusCode::METHOD_NOT_ALLOWED); |
|
| 928 | 928 | assert!(v["detail"].as_str().unwrap().contains("not for PUT"), "{v}"); |
|
| 929 | 929 | ||
| 930 | + | // A column's cards at once (#289): with exceptions, a state filter and |
|
| 931 | + | // a dry run; one audit entry. |
|
| 932 | + | for t in ["Second", "Third"] { |
|
| 933 | + | api(app, "POST", "/api/v1/repos/andy/demo/issues", Some(&a), Some(&format!(r#"{{"title":"{t}"}}"#))).await; |
|
| 934 | + | } |
|
| 935 | + | for n in [2, 3] { |
|
| 936 | + | let body = format!(r#"{{"reference":"demo#{n}","column":"Doing"}}"#); |
|
| 937 | + | assert_eq!(api(app, "POST", "/api/v1/projects/andy/1/cards", Some(&a), Some(&body)).await.0, StatusCode::CREATED); |
|
| 938 | + | } |
|
| 939 | + | let bulk = |body: &'static str| api(app, "POST", "/api/v1/projects/andy/1/cards/move", Some(&a), Some(body)); |
|
| 940 | + | let refs = |v: &serde_json::Value, k: &str| -> Vec<String> { |
|
| 941 | + | v[k].as_array().unwrap().iter().map(|c| c["reference"].as_str().unwrap().to_string()).collect() |
|
| 942 | + | }; |
|
| 943 | + | let (st, v) = bulk(r##"{"from":"Doing","column":"Done","except":["#2"],"dry_run":true}"##).await; |
|
| 944 | + | assert_eq!(st, StatusCode::OK, "{v}"); |
|
| 945 | + | assert_eq!((refs(&v, "moved"), refs(&v, "kept")), (vec!["andy/demo#3".to_string()], vec!["andy/demo#2".to_string()])); |
|
| 946 | + | let (_, board) = api(app, "GET", "/api/v1/projects/andy/1", None, None).await; |
|
| 947 | + | assert_eq!(board["columns"][1]["cards"].as_array().unwrap().len(), 2, "a dry run moves nothing"); |
|
| 948 | + | let (_, v) = bulk(r#"{"from":"Doing","column":"Done","state":["closed"]}"#).await; |
|
| 949 | + | assert!(refs(&v, "moved").is_empty(), "both are open: {v}"); |
|
| 950 | + | let (_, v) = bulk(r#"{"from":"doing","column":"done","except":["demo#2"]}"#).await; |
|
| 951 | + | assert_eq!(refs(&v, "moved"), ["andy/demo#3"]); |
|
| 952 | + | let (_, board) = api(app, "GET", "/api/v1/projects/andy/1", None, None).await; |
|
| 953 | + | let names: Vec<(&str, usize)> = |
|
| 954 | + | board["columns"].as_array().unwrap().iter().map(|c| (c["name"].as_str().unwrap(), c["cards"].as_array().unwrap().len())).collect(); |
|
| 955 | + | assert_eq!(names, [("Backlog", 1), ("Doing", 1), ("Done", 2)]); |
|
| 956 | + | let audited: i64 = |
|
| 957 | + | sqlx::query_scalar("SELECT COUNT(*) FROM audit WHERE action = 'project.cards_moved'").fetch_one(&app.db).await.unwrap(); |
|
| 958 | + | assert_eq!(audited, 1, "the dry run and the empty move are not audited"); |
|
| 959 | + | assert_eq!(bulk(r#"{"from":"Done","column":"Done"}"#).await.0, StatusCode::BAD_REQUEST); |
|
| 960 | + | assert_eq!( |
|
| 961 | + | api(app, "POST", "/api/v1/projects/andy/1/cards/move", Some(&b), Some(r#"{"from":"Doing","column":"Done"}"#)).await.0, |
|
| 962 | + | StatusCode::FORBIDDEN |
|
| 963 | + | ); |
|
| 964 | + | ||
| 930 | 965 | // Private: hidden from others; closed: read-only; then remove a card. |
|
| 931 | 966 | let (_, edited) = api(app, "PATCH", "/api/v1/projects/andy/1", Some(&a), Some(r#"{"private":true}"#)).await; |
|
| 932 | 967 | assert_eq!(edited["public"], false); |
|
| … | 733 unchanged lines |
modified crates/oh/src/main.rs +40 −6
| … | 473 unchanged lines |
||
| 474 | 474 | #[arg(long, short)] |
|
| 475 | 475 | column: Option<String>, |
|
| 476 | 476 | }, |
|
| 477 | - | /// Move a card to another column (by name or id) |
|
| 477 | + | /// Move a card to another column (by name or id), or with --from every |
|
| 478 | + | /// card of a column at once |
|
| 478 | 479 | Move { |
|
| 479 | 480 | number: i64, |
|
| 480 | - | card: i64, |
|
| 481 | + | /// The card (its id); leave it out with --from |
|
| 482 | + | card: Option<i64>, |
|
| 481 | 483 | #[arg(long, short)] |
|
| 482 | 484 | column: String, |
|
| 485 | + | /// Move every card of this column (#289) |
|
| 486 | + | #[arg(long, conflicts_with = "card")] |
|
| 487 | + | from: Option<String>, |
|
| 488 | + | /// With --from: leave these where they are (card ids, or 12, #12, name#12, owner/name#12), comma-separated |
|
| 489 | + | #[arg(long, value_delimiter = ',', requires = "from")] |
|
| 490 | + | except: Vec<String>, |
|
| 491 | + | /// With --from: only cards whose issue or proposal is in one of these states (closed, landed…), comma-separated |
|
| 492 | + | #[arg(long, value_delimiter = ',', requires = "from")] |
|
| 493 | + | state: Vec<String>, |
|
| 494 | + | /// With --from: say what would move, and move nothing |
|
| 495 | + | #[arg(long, requires = "from")] |
|
| 496 | + | dry_run: bool, |
|
| 483 | 497 | }, |
|
| 484 | 498 | /// Take a card off a board |
|
| 485 | 499 | Remove { number: i64, card: i64 }, |
|
| … | 1303 unchanged lines |
||
| 1789 | 1803 | println!("{} card {} {}", ui::ok("✔"), card.id, if card.reference.is_empty() { &card.title } else { &card.reference }); |
|
| 1790 | 1804 | } |
|
| 1791 | 1805 | } |
|
| 1792 | - | ProjectCmd::Card { cmd: CardCmd::Move { number, card, column } } => { |
|
| 1793 | - | c.send_text("PATCH", &format!("{base}/{number}/cards/{card}"), &hub_api::CardMove { column: column.clone() })?; |
|
| 1794 | - | println!("{} card {card} moved to {column}", ui::ok("✔")); |
|
| 1795 | - | } |
|
| 1806 | + | ProjectCmd::Card { cmd: CardCmd::Move { number, card, column, from, except, state, dry_run } } => match (card, from) { |
|
| 1807 | + | (Some(card), None) => { |
|
| 1808 | + | c.send_text("PATCH", &format!("{base}/{number}/cards/{card}"), &hub_api::CardMove { column: column.clone() })?; |
|
| 1809 | + | println!("{} card {card} moved to {column}", ui::ok("✔")); |
|
| 1810 | + | } |
|
| 1811 | + | (None, Some(from)) => { |
|
| 1812 | + | let r: hub_api::CardsMoved = c.send( |
|
| 1813 | + | "POST", |
|
| 1814 | + | &format!("{base}/{number}/cards/move"), |
|
| 1815 | + | &hub_api::CardsMove { from: from.clone(), column: column.clone(), except, state, dry_run }, |
|
| 1816 | + | )?; |
|
| 1817 | + | if json { |
|
| 1818 | + | println!("{}", serde_json::to_string(&r)?); |
|
| 1819 | + | return Ok(0); |
|
| 1820 | + | } |
|
| 1821 | + | let name = |c: &hub_api::ProjectCard| if c.reference.is_empty() { c.title.clone() } else { c.reference.clone() }; |
|
| 1822 | + | for m in &r.moved { |
|
| 1823 | + | println!(" {} {} {}", if dry_run { "would move" } else { "moved" }, name(m), ui::dim(&m.title)); |
|
| 1824 | + | } |
|
| 1825 | + | let verb = if dry_run { "would move" } else { "moved" }; |
|
| 1826 | + | println!("{} {verb} {} card(s) from {from} to {column}; {} left in {from}", ui::ok("✔"), r.moved.len(), r.kept.len()); |
|
| 1827 | + | } |
|
| 1828 | + | _ => bail!("name a card, or --from COLUMN to move every card of a column"), |
|
| 1829 | + | }, |
|
| 1796 | 1830 | ProjectCmd::Card { cmd: CardCmd::Remove { number, card } } => { |
|
| 1797 | 1831 | c.send_text("DELETE", &format!("{base}/{number}/cards/{card}"), &serde_json::json!({}))?; |
|
| 1798 | 1832 | println!("{} card {card} removed", ui::ok("✔")); |
|
| … | 230 unchanged lines |
modified docs/oh.md +1 −1
| … | 42 unchanged lines |
||
| 43 | 43 | | `oh milestone list`, `oh milestone create TITLE [-d DESC] [--due YYYY-MM-DD]`, `oh milestone edit TITLE [--rename T] [-d DESC] [--due D]`, `oh milestone close\|reopen TITLE` | Milestones (triage to change) | |
|
| 44 | 44 | | `oh issue close N [--reason not_planned]`, `oh issue reopen N` | Close or reopen | |
|
| 45 | 45 | | `oh project list`, `oh project view N`, `oh project create TITLE [-d DESC] [--private]`, `oh project close\|reopen N` | The repository owner's project boards (`--owner NAME` for another's) | |
|
| 46 | - | | `oh project card add N REF [-c COLUMN]`, `oh project card add N --note TEXT`, `oh project card move N CARD -c COLUMN`, `oh project card remove N CARD` | Cards: REF is `12` or `#12` here, or `name#12` in another of the owner's repositories; a column by name or id | |
|
| 46 | + | | `oh project card add N REF [-c COLUMN]`, `oh project card add N --note TEXT`, `oh project card move N CARD -c COLUMN`, `oh project card move N --from COLUMN -c COLUMN [--except 12,#13,name#14,CARD] [--state closed,landed] [--dry-run]`, `oh project card remove N CARD` | Cards: REF is `12` or `#12` here, or `name#12` in another of the owner's repositories; a column by name or id; `--from` moves a whole column at once (#289) | |
|
| 47 | 47 | | `oh issue comment N -b BODY` | Comment | |
|
| 48 | 48 | | `oh proposal list [--state open\|draft\|landed\|closed\|all]`, `oh proposal view N` | Proposals, with changes, approvals and blockers; a landed one with who landed it and how | |
|
| 49 | 49 | | `oh proposal create -t TITLE --from BOOKMARK [--into BOOKMARK] [--draft]` | Propose a bookmark's changes | |
|
| … | 26 unchanged lines |
modified docs/spec.md +1 −1
| … | 489 unchanged lines |
||
| 490 | 490 | ||
| 491 | 491 | ### 12.1 API |
|
| 492 | 492 | ||
| 493 | - | JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, 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/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/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` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags` (`POST` creates one, repo:write; `DELETE /tags/{tag}` withdraws one, a maintainer, `409` while a release uses it; §9), `/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}`, `/users/{name}/drops` and `/drops` (§4.5), `/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, and `owner` to transfer it to an owner the caller may make boards under (#125: it takes the next number there, and its old address redirects), `/cards` to list them by column or 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), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `POST /coverage` (repo:write, #136: `{"bookmark"}` or `{"change"}` and `content`, an LCOV or Cobertura report measured off the hub, kept for that state as uploaded by the caller, shown "uploaded by NAME" wherever a run number would be, and setting no check; `oh coverage upload FILE`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/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; with `"jobs": [NAME..]` only those jobs and what they depend on, each one a manual run starts, else `409` naming the ones it does (#130); `oh run start --job NAME`), `/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, `/flow/runs/{n}/jobs/{job}/rerun` (repo:write, oxygenflow#31: that job of a finished run again, in the same run, with the jobs that depend on it; what it depends on keeps its result, so a flaky test reruns without its build; their logs, artifacts and reports go, their checks return to queued and the run is running again; `{"queued": N}`, or `409` while the run is live or for a job it lacks; RE-RUN THIS JOB on the run page, `oh run rerun N --job NAME`), `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going), `/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; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (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. |
|
| 493 | + | JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, 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/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/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` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags` (`POST` creates one, repo:write; `DELETE /tags/{tag}` withdraws one, a maintainer, `409` while a release uses it; §9), `/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}`, `/users/{name}/drops` and `/drops` (§4.5), `/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, and `owner` to transfer it to an owner the caller may make boards under (#125: it takes the next number there, and its old address redirects), `/cards` to list them by column or add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one, `POST /cards/move` (#289: `{"from", "column", "except", "state", "dry_run"}`) to move a column's cards at once, but those `except` names (card ids or issue and proposal references) and, given `state`, only those whose issue or proposal is in one, all or none and audited once, answering what moved and what was kept; `repo:write` and the page's rights to change), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `POST /coverage` (repo:write, #136: `{"bookmark"}` or `{"change"}` and `content`, an LCOV or Cobertura report measured off the hub, kept for that state as uploaded by the caller, shown "uploaded by NAME" wherever a run number would be, and setting no check; `oh coverage upload FILE`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/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; with `"jobs": [NAME..]` only those jobs and what they depend on, each one a manual run starts, else `409` naming the ones it does (#130); `oh run start --job NAME`), `/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, `/flow/runs/{n}/jobs/{job}/rerun` (repo:write, oxygenflow#31: that job of a finished run again, in the same run, with the jobs that depend on it; what it depends on keeps its result, so a flaky test reruns without its build; their logs, artifacts and reports go, their checks return to queued and the run is running again; `{"queued": N}`, or `409` while the run is live or for a job it lacks; RE-RUN THIS JOB on the run page, `oh run rerun N --job NAME`), `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going), `/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; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (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. |
|
| 494 | 494 | ||
| 495 | 495 | ### 12.2 Webhooks |
|
| 496 | 496 | ||
| … | 396 unchanged lines |