OXYGENHUB

oh project: boards from the command line; GET .../cards 17a3a84b00d1

oh project list|view|create|close|reopen and oh project card
add|move|remove work on the repository owner's boards (--owner for
another's), and oh issue create --project N [--column NAME] puts the new
issue on one. GET /projects/{owner}/{n}/cards lists the cards by
column, and an API route asked with a method it does not take answers
405 with a problem body rather than an empty one.

closes #115
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-05 18:56 UTC
Signature
✔ signed by Andy McCall
Hash
17a3a84b00d107b22ce152f9fa3124ac43bfb67b00295287cc2398ac7f13c711
Depends on
2291f8cc9c96 3f4b0ea6eabf 5533e91a2aa4 5c6e92fd8d71 5ea4872f23e8 62ceb2eec18c 8933faff2043 90326c9601f9 93be9ce4653e b5229b56f49b b64e2537d702 d23464c0ac60 ef20fdc14e8b
Depended on by
f2d2776b2a14 0bc95135954d 306aec1839aa 1c52d940b4a0 439ecd8708a1 6a306cd03fc1 456f4197ade3 b1e92a6233ee 617c2840446c
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.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
22 atoms, 0 tree operations, 7 files

Browse the tree at this change

modified crates/hub/src/api/mod.rs +9 −1
…
28 unchanged lines
29 29
    ApiError(StatusCode::NOT_FOUND, "no such API route".into())
30 30
}
31 31
32 +
/// A known route asked with a method it does not take: a problem body
33 +
/// rather than axum's empty 405 (oxygenhub#115).
34 +
async fn wrong_method(method: axum::http::Method, uri: axum::http::Uri) -> ApiError {
35 +
    ApiError(StatusCode::METHOD_NOT_ALLOWED, format!("{} is an API route, but not for {method}", uri.path()))
36 +
}
37 +
32 38
/// The API, with release assets up to `asset_limit` bytes (the web form's
33 39
/// limit too; axum's default of 2 MiB refused ordinary builds).
34 40
pub fn router(asset_limit: usize) -> Router<Shared> {
…
9 unchanged lines
44 50
        )
45 51
        .route("/projects/{owner}", get(projects::list).post(projects::create))
46 52
        .route("/projects/{owner}/{number}", get(projects::board).patch(projects::edit))
47 -
        .route("/projects/{owner}/{number}/cards", post(projects::add_card))
53 +
        .route("/projects/{owner}/{number}/cards", get(projects::cards).post(projects::add_card))
48 54
        .route("/projects/{owner}/{number}/cards/{id}", axum::routing::patch(projects::move_card).delete(projects::remove_card))
49 55
        .route("/device/code", post(device::code))
50 56
        .route("/device/token", post(device::token))
…
66 unchanged lines
117 123
            "/repos/{owner}/{name}/releases/{tag}/assets",
118 124
            post(upload_asset).layer(axum::extract::DefaultBodyLimit::max(asset_limit + (1 << 20))),
119 125
        )
126 +
        // Last: it applies to the routes registered before it.
127 +
        .method_not_allowed_fallback(wrong_method)
120 128
}
121 129
122 130
async fn releases(
…
2236 unchanged lines
modified crates/hub/src/api/projects.rs +10 −0
…
136 unchanged lines
137 137
    Ok(Json(project_of(&app, &fresh)))
138 138
}
139 139
140 +
/// `GET /projects/{owner}/{n}/cards`: the cards by column, as the board
141 +
/// has them.
142 +
pub async fn cards(
143 +
    State(app): State<Shared>,
144 +
    headers: HeaderMap,
145 +
    Path((owner, number)): Path<(String, i64)>,
146 +
) -> Result<Json<Vec<ProjectColumn>>, ApiError> {
147 +
    Ok(Json(board(State(app), headers, Path((owner, number))).await?.0.columns))
148 +
}
149 +
140 150
/// `POST /projects/{owner}/{n}/cards`.
141 151
pub async fn add_card(
142 152
    State(app): State<Shared>,
…
51 unchanged lines
modified crates/hub/tests/api.rs +7 −0
…
809 unchanged lines
810 810
        board["columns"].as_array().unwrap().iter().map(|c| (c["name"].as_str().unwrap(), c["cards"].as_array().unwrap().len())).collect();
811 811
    assert_eq!(names, [("Backlog", 1), ("Doing", 0), ("Done", 1)]);
812 812
    assert_eq!(board["columns"][2]["cards"][0]["reference"], "andy/demo#1");
813 +
    // The cards by column (#115), and a wrong method answers with a body.
814 +
    let (st, cols) = api(app, "GET", "/api/v1/projects/andy/1/cards", None, None).await;
815 +
    assert_eq!(st, StatusCode::OK);
816 +
    assert_eq!(cols, board["columns"]);
817 +
    let (st, v) = api(app, "PUT", "/api/v1/projects/andy/1/cards", Some(&a), Some("{}")).await;
818 +
    assert_eq!(st, StatusCode::METHOD_NOT_ALLOWED);
819 +
    assert!(v["detail"].as_str().unwrap().contains("not for PUT"), "{v}");
813 820
814 821
    // Private: hidden from others; closed: read-only; then remove a card.
815 822
    let (_, edited) = api(app, "PATCH", "/api/v1/projects/andy/1", Some(&a), Some(r#"{"private":true}"#)).await;
…
651 unchanged lines
modified crates/oh/src/main.rs +190 −1
…
76 unchanged lines
77 77
        #[command(subcommand)]
78 78
        cmd: MilestoneCmd,
79 79
    },
80 +
    /// Project boards of the repository's owner (or --owner): list,
81 +
    /// create, view, and cards
82 +
    Project {
83 +
        #[command(subcommand)]
84 +
        cmd: ProjectCmd,
85 +
        /// A user or organisation other than the repository's owner
86 +
        #[arg(long, global = true)]
87 +
        owner: Option<String>,
88 +
    },
80 89
    /// AI assistants of the repository (its admins), or with --org or
81 90
    /// --site of an organisation or the site
82 91
    Assistant {
…
68 unchanged lines
151 160
        /// A label (repeatable)
152 161
        #[arg(long, short)]
153 162
        label: Vec<String>,
163 +
        /// Also put it on this board of the repository's owner
164 +
        #[arg(long, short)]
165 +
        project: Option<i64>,
166 +
        /// The board's column for it, by name (default: the first)
167 +
        #[arg(long, requires = "project")]
168 +
        column: Option<String>,
154 169
    },
155 170
    /// Close an issue
156 171
    Close {
…
147 unchanged lines
304 319
    },
305 320
    /// Delete a label, taking it off every issue and proposal (triage)
306 321
    Delete { name: String },
322 +
}
323 +
324 +
#[derive(Subcommand)]
325 +
enum ProjectCmd {
326 +
    /// The owner's boards
327 +
    List,
328 +
    /// One board: its columns and cards
329 +
    View { number: i64 },
330 +
    /// Create a board (owner, or an organisation's members)
331 +
    Create {
332 +
        title: String,
333 +
        #[arg(long, short, default_value = "")]
334 +
        description: String,
335 +
        /// Only the owner (or the organisation's members) can see it
336 +
        #[arg(long)]
337 +
        private: bool,
338 +
    },
339 +
    /// Close a board
340 +
    Close { number: i64 },
341 +
    /// Reopen a board
342 +
    Reopen { number: i64 },
343 +
    /// Cards on a board: add, move, remove
344 +
    Card {
345 +
        #[command(subcommand)]
346 +
        cmd: CardCmd,
347 +
    },
307 348
}
308 349
309 350
#[derive(Subcommand)]
351 +
enum CardCmd {
352 +
    /// Put an issue or proposal on a board (`12` or `#12` in this
353 +
    /// repository, `name#12` or `owner/name#12` in another of the owner's),
354 +
    /// or a note with --note
355 +
    Add {
356 +
        number: i64,
357 +
        /// The issue or proposal; a number means this repository's
358 +
        reference: Option<String>,
359 +
        /// A note card instead
360 +
        #[arg(long, conflicts_with = "reference")]
361 +
        note: Option<String>,
362 +
        /// The column, by name or id (default: the first)
363 +
        #[arg(long, short)]
364 +
        column: Option<String>,
365 +
    },
366 +
    /// Move a card to another column (by name or id)
367 +
    Move {
368 +
        number: i64,
369 +
        card: i64,
370 +
        #[arg(long, short)]
371 +
        column: String,
372 +
    },
373 +
    /// Take a card off a board
374 +
    Remove { number: i64, card: i64 },
375 +
}
376 +
377 +
#[derive(Subcommand)]
310 378
enum MilestoneCmd {
311 379
    /// The repository's milestones
312 380
    List,
…
207 unchanged lines
520 588
        Cmd::Release { cmd } => release(&c, &t, cmd, json),
521 589
        Cmd::Label { cmd } => label(&c, &t, cmd, json),
522 590
        Cmd::Milestone { cmd } => milestone(&c, &t, cmd, json),
591 +
        Cmd::Project { cmd, owner } => project(&c, &t, cmd, owner, json),
523 592
        Cmd::Assistant { cmd, org, site } => assistant(&c, &t, cmd, org, site, json),
524 593
    }
525 594
}
…
133 unchanged lines
659 728
            }
660 729
            println!("{}", ui::dim(&i.url));
661 730
        }
662 -
        IssueCmd::Create { title, body, body_file, label } => {
731 +
        IssueCmd::Create { title, body, body_file, label, project, column } => {
663 732
            let new = NewIssue { title, body: body_of(body, body_file)?, labels: label, assignees: vec![], milestone: None };
664 733
            let i: Issue = c.send("POST", &format!("{base}/issues"), &new)?;
665 734
            if json {
666 735
                println!("{}", serde_json::to_string(&i)?);
667 736
            } else {
668 737
                println!("{} opened #{} {}", ui::ok("✔"), i.number, ui::dim(&i.url));
738 +
            }
739 +
            if let Some(n) = project {
740 +
                let card = hub_api::NewCard { column, reference: format!("{}#{}", t.name, i.number), note: String::new() };
741 +
                let added: hub_api::ProjectCard = c.send("POST", &format!("/projects/{}/{n}/cards", t.owner), &card)?;
742 +
                if !json {
743 +
                    println!("{} on project {n} as card {}", ui::ok("✔"), added.id);
744 +
                }
669 745
            }
670 746
        }
671 747
        IssueCmd::Close { number, reason } => {
…
443 unchanged lines
1115 1191
            } else {
1116 1192
                println!("{}\n{}", ui::dim("── this level"), p.prompt);
1117 1193
            }
1194 +
        }
1195 +
    }
1196 +
    Ok(0)
1197 +
}
1198 +
1199 +
fn project_line(p: &hub_api::Project) -> String {
1200 +
    let glyph = if p.closed { ui::dim("▢") } else { ui::ok("▣") };
1201 +
    let private = if p.public { "" } else { " · private" };
1202 +
    format!("{glyph} {} {}  {}", ui::dim(&p.number.to_string()), p.title, ui::dim(&format!("updated {}{private}", ui::ago(p.updated_ms))))
1203 +
}
1204 +
1205 +
fn project(c: &Client, t: &Target, cmd: ProjectCmd, owner: Option<String>, json: bool) -> Result<i32> {
1206 +
    let owner = match owner {
1207 +
        Some(o) => o,
1208 +
        None => t.repo()?.0.to_string(),
1209 +
    };
1210 +
    let base = format!("/projects/{}", enc(&owner));
1211 +
    let state = |number: i64, closed: bool, verb: &str| -> Result<()> {
1212 +
        let p: hub_api::Project =
1213 +
            c.send("PATCH", &format!("{base}/{number}"), &hub_api::ProjectEdit { closed: Some(closed), ..Default::default() })?;
1214 +
        if json {
1215 +
            println!("{}", serde_json::to_string(&p)?);
1216 +
        } else {
1217 +
            println!("{} {verb} project {} {}", ui::ok("✔"), p.number, p.title);
1218 +
        }
1219 +
        Ok(())
1220 +
    };
1221 +
    match cmd {
1222 +
        ProjectCmd::List => {
1223 +
            if json {
1224 +
                println!("{}", c.get_text(&base)?);
1225 +
                return Ok(0);
1226 +
            }
1227 +
            let list: Vec<hub_api::Project> = c.get(&base)?;
1228 +
            if list.is_empty() {
1229 +
                println!("{}", ui::dim(&format!("No projects for {owner}: oh project create TITLE")));
1230 +
            }
1231 +
            for p in &list {
1232 +
                println!("{}", project_line(p));
1233 +
            }
1234 +
        }
1235 +
        ProjectCmd::View { number } => {
1236 +
            let path = format!("{base}/{number}");
1237 +
            if json {
1238 +
                println!("{}", c.get_text(&path)?);
1239 +
                return Ok(0);
1240 +
            }
1241 +
            let b: hub_api::Board = c.get(&path)?;
1242 +
            println!("{}", project_line(&b.project));
1243 +
            if !b.project.description.is_empty() {
1244 +
                println!("{}", b.project.description);
1245 +
            }
1246 +
            for col in &b.columns {
1247 +
                println!();
1248 +
                println!("{} {}", ui::strong(&col.name), ui::dim(&format!("{} · column {}", col.cards.len(), col.id)));
1249 +
                if col.cards.is_empty() {
1250 +
                    println!("  {}", ui::dim("(empty)"));
1251 +
                }
1252 +
                for card in &col.cards {
1253 +
                    let what = if card.reference.is_empty() { ui::dim("note") } else { card.reference.clone() };
1254 +
                    let st = if card.state.is_empty() { String::new() } else { format!(" {}", ui::dim(&card.state)) };
1255 +
                    println!("  {} {what}{st}  {}", ui::dim(&format!("{:>4}", card.id)), card.title);
1256 +
                }
1257 +
            }
1258 +
            println!("{}", ui::dim(&format!("\n{}", b.project.url)));
1259 +
        }
1260 +
        ProjectCmd::Create { title, description, private } => {
1261 +
            let p: hub_api::Project = c.send("POST", &base, &hub_api::NewProject { title, description, private })?;
1262 +
            if json {
1263 +
                println!("{}", serde_json::to_string(&p)?);
1264 +
            } else {
1265 +
                println!("{} project {} {} {}", ui::ok("✔"), p.number, p.title, ui::dim(&p.url));
1266 +
            }
1267 +
        }
1268 +
        ProjectCmd::Close { number } => state(number, true, "closed")?,
1269 +
        ProjectCmd::Reopen { number } => state(number, false, "reopened")?,
1270 +
        ProjectCmd::Card { cmd: CardCmd::Add { number, reference, note, column } } => {
1271 +
            // The hub names a card's issue as `repo#N` among the owner's
1272 +
            // repositories.
1273 +
            let reference = match reference {
1274 +
                None => String::new(),
1275 +
                Some(r) if r.contains('#') && !r.starts_with('#') => match r.split_once('/') {
1276 +
                    Some((o, rest)) if o == owner => rest.to_string(),
1277 +
                    Some(_) => bail!("{r}: a project holds only {owner}'s issues and proposals"),
1278 +
                    None => r,
1279 +
                },
1280 +
                Some(r) => {
1281 +
                    let n = r.trim_start_matches('#');
1282 +
                    if n.parse::<i64>().is_err() {
1283 +
                        bail!("{r}: give an issue or proposal number, or NAME#N");
1284 +
                    }
1285 +
                    format!("{}#{n}", t.repo()?.1)
1286 +
                }
1287 +
            };
1288 +
            let note = note.unwrap_or_default();
1289 +
            if reference.is_empty() && note.is_empty() {
1290 +
                bail!("say what to add: an issue or proposal (12, #12, owner/name#12) or --note TEXT");
1291 +
            }
1292 +
            let card: hub_api::ProjectCard =
1293 +
                c.send("POST", &format!("{base}/{number}/cards"), &hub_api::NewCard { column, reference, note })?;
1294 +
            if json {
1295 +
                println!("{}", serde_json::to_string(&card)?);
1296 +
            } else {
1297 +
                println!("{} card {} {}", ui::ok("✔"), card.id, if card.reference.is_empty() { &card.title } else { &card.reference });
1298 +
            }
1299 +
        }
1300 +
        ProjectCmd::Card { cmd: CardCmd::Move { number, card, column } } => {
1301 +
            c.send_text("PATCH", &format!("{base}/{number}/cards/{card}"), &hub_api::CardMove { column: column.clone() })?;
1302 +
            println!("{} card {card} moved to {column}", ui::ok("✔"));
1303 +
        }
1304 +
        ProjectCmd::Card { cmd: CardCmd::Remove { number, card } } => {
1305 +
            c.send_text("DELETE", &format!("{base}/{number}/cards/{card}"), &serde_json::json!({}))?;
1306 +
            println!("{} card {card} removed", ui::ok("✔"));
1118 1307
        }
1119 1308
    }
1120 1309
    Ok(0)
…
151 unchanged lines
modified crates/oh/tests/cli.rs +33 −0
…
137 unchanged lines
138 138
    let (_, out, _) = oh(w, &[], &["issue", "list", "--plain"]).await;
139 139
    assert!(out.contains("No open issues."), "{out}");
140 140
141 +
    // Project boards (#115): create, an issue straight onto a column, a
142 +
    // note, view, move, remove.
143 +
    let (code, out, err) = oh(w, &[], &["project", "create", "Roadmap", "-d", "Next up"]).await;
144 +
    assert_eq!(code, 0, "{err}");
145 +
    assert!(out.contains("project 1 Roadmap"), "{out}");
146 +
    let (code, out, err) = oh(w, &[], &["issue", "create", "--title", "Plan it", "--project", "1", "--column", "doing"]).await;
147 +
    assert_eq!(code, 0, "{err}");
148 +
    assert!(out.contains("opened #2") && out.contains("on project 1"), "{out}");
149 +
    let (code, _, err) = oh(w, &[], &["project", "card", "add", "1", "--note", "Write docs"]).await;
150 +
    assert_eq!(code, 0, "{err}");
151 +
    let (code, out, err) = oh(w, &[], &["project", "view", "1", "--plain"]).await;
152 +
    assert_eq!(code, 0, "{err}");
153 +
    assert!(out.contains("Doing") && out.contains("andy/demo#2") && out.contains("Plan it") && out.contains("Write docs"), "{out}");
154 +
    let (_, out, _) = oh(w, &[], &["project", "view", "1", "--json"]).await;
155 +
    let board: serde_json::Value = serde_json::from_str(&out).unwrap();
156 +
    let card = board["columns"][1]["cards"][0]["id"].as_i64().unwrap().to_string();
157 +
    let (code, out, err) = oh(w, &[], &["project", "card", "move", "1", &card, "--column", "Done"]).await;
158 +
    assert_eq!(code, 0, "{err}");
159 +
    assert!(out.contains("moved to Done"), "{out}");
160 +
    let (_, out, _) = oh(w, &[], &["project", "view", "1", "--json"]).await;
161 +
    let board: serde_json::Value = serde_json::from_str(&out).unwrap();
162 +
    assert_eq!(board["columns"][2]["cards"][0]["reference"], "andy/demo#2");
163 +
    let (code, _, err) = oh(w, &[], &["project", "card", "add", "1", "2"]).await;
164 +
    assert_ne!(code, 0);
165 +
    assert!(err.contains("already on this project"), "{err}");
166 +
    let (code, _, err) = oh(w, &[], &["project", "card", "remove", "1", &card]).await;
167 +
    assert_eq!(code, 0, "{err}");
168 +
    let (_, out, _) = oh(w, &[], &["project", "list", "--plain"]).await;
169 +
    assert!(out.contains("Roadmap"), "{out}");
170 +
    let (code, out, _) = oh(w, &[], &["project", "close", "1"]).await;
171 +
    assert_eq!(code, 0);
172 +
    assert!(out.contains("closed project 1"), "{out}");
173 +
141 174
    // Flow: the push started run 1; start another without a push.
142 175
    let (code, out, err) = oh(w, &[], &["run", "start"]).await;
143 176
    assert_eq!(code, 0, "{err}");
…
71 unchanged lines
modified docs/oh.md +4 −2
…
36 unchanged lines
37 37
| `oh repo view` | Name, description, visibility, default bookmark, clone command |
38 38
| `oh issue list [--state open\|closed\|all]` | Issues |
39 39
| `oh issue view N` | An issue with its comments |
40 -
| `oh issue create -t TITLE [-b BODY \| -F FILE] [-l LABEL]…` | Open an issue |
40 +
| `oh issue create -t TITLE [-b BODY \| -F FILE] [-l LABEL]… [-p N [--column NAME]]` | Open an issue; `-p` also puts it on the owner's project N |
41 41
| `oh issue edit N [-t TITLE] [-b BODY \| -F FILE] [-l LABEL]… [-a USER]… [-m MILESTONE]` | Change it; labels and assignees are the whole set (`--no-labels`, `--no-assignees` clear them), `-m ""` clears the milestone |
42 42
| `oh issue label N --add L --remove L` | Put labels on or take them off |
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 +
| `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 |
45 47
| `oh issue comment N -b BODY` | Comment |
46 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 |
47 49
| `oh proposal create -t TITLE --from BOOKMARK [--into BOOKMARK] [--draft]` | Propose a bookmark's changes |
…
15 unchanged lines
63 65
64 66
## Not yet
65 67
66 -
- Also still to come: reviews, labels and milestones as commands, runner management (oxygenhub#41), and shell completions.
68 +
- Also still to come: reviews, project columns as commands, runner management (oxygenhub#41), and shell completions.
modified docs/spec.md +1 −1
…
448 unchanged lines
449 449
450 450
### 12.1 API
451 451
452 -
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/{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`, `/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, `/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.
452 +
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`, `/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 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), `/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, `/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.
453 453
454 454
### 12.2 Webhooks
455 455
…
263 unchanged lines