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 |