Board columns by API and oh, newest-run watch, close with a comment (closes #366, closes #367, closes #368) 306aec1839aa
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 6683e4ef5f9a
- When
- · 2026-11-03 21:51 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
306aec1839aae6645613e3e938b1f191b7dfeb734eb71f0d91b1ff25fcebe688- Depends on
- 17a3a84b00d1 1c52d940b4a0 2291f8cc9c96 439ecd8708a1 456f4197ade3 5c6e92fd8d71 8f1d76f4169a 93be9ce4653e b5229b56f49b b5668108487f b64e2537d702 d23464c0ac60 e1ec9fbf538e e4f1f52f2929 ef20fdc14e8b fac6853c4e46
- Depended on by
- 40a43921ef42 f2d2776b2a14 0bc95135954d 64e5964e9f13 50928fab5ffc
- In bookmarks
- main
- In tags
- v0.9.23 v0.9.24
- Edits
- 26 atoms, 0 tree operations, 8 files
Browse the tree at this change
modified CHANGELOG.md +6 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | OxygenHub runs at https://oxygenhub.io; each deploy's build is on the About page and at `/api/v1/version`. |
|
| 4 | 4 | ||
| 5 | + | ## Unreleased |
|
| 6 | + | ||
| 7 | + | - **A board's columns from the API and `oh` (#366):** `POST /projects/{owner}/{n}/columns` adds one at the right, `PATCH …/columns/{column}` (by id or name) renames it or moves it to a `position`, and `DELETE` removes it, its cards going to the first remaining column; each answers the board. `oh project column add|rename|move|remove BOARD …` (`move --to N`, counting from 1 at the left, `--left`, `--right`), so a sprint column is renamed without the web page. |
|
| 8 | + | - **`oh run watch` and `oh run view` take the newest run (#367):** with no number they follow or show the newest run, or the newest of `--bookmark NAME`, so the run a push just started needs no lookup. |
|
| 9 | + | - **`oh issue close` and `reopen` say why (#368):** `-b`, `-F` and `--attach` post a comment first, in the same command. |
|
| 10 | + | ||
| 5 | 11 | ## 0.9.22 (2026-11-02) |
|
| 6 | 12 | ||
| 7 | 13 | - **Pictures in issues and comments fit their box (#364):** an attached screenshot was shown at its own size, running out of the card on a phone and dwarfing the text on a desktop; pictures in all rendered Markdown now scale down to the width they have. |
|
| … | 447 unchanged lines |
modified crates/hub-api/src/lib.rs +19 −0
| … | 1080 unchanged lines |
||
| 1081 | 1081 | pub cards: Vec<ProjectCard>, |
|
| 1082 | 1082 | } |
|
| 1083 | 1083 | ||
| 1084 | + | /// `POST /projects/{owner}/{n}/columns` (#366): a column at the right. |
|
| 1085 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 1086 | + | pub struct NewColumn { |
|
| 1087 | + | pub name: String, |
|
| 1088 | + | } |
|
| 1089 | + | ||
| 1090 | + | /// `PATCH /projects/{owner}/{n}/columns/{column}` (#366): rename it, move |
|
| 1091 | + | /// it to `position` (0 is the leftmost; past the end is the rightmost), or |
|
| 1092 | + | /// both. What is not given stays. |
|
| 1093 | + | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 1094 | + | pub struct ColumnEdit { |
|
| 1095 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 1096 | + | pub name: Option<String>, |
|
| 1097 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 1098 | + | pub position: Option<i64>, |
|
| 1099 | + | } |
|
| 1100 | + | ||
| 1084 | 1101 | /// `GET /projects/{owner}/{n}`. |
|
| 1085 | 1102 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 1086 | 1103 | pub struct Board { |
|
| … | 525 unchanged lines |
||
| 1612 | 1629 | ("Board", schemars::schema_for!(Board)), |
|
| 1613 | 1630 | ("NewProject", schemars::schema_for!(NewProject)), |
|
| 1614 | 1631 | ("ProjectEdit", schemars::schema_for!(ProjectEdit)), |
|
| 1632 | + | ("NewColumn", schemars::schema_for!(NewColumn)), |
|
| 1633 | + | ("ColumnEdit", schemars::schema_for!(ColumnEdit)), |
|
| 1615 | 1634 | ("NewCard", schemars::schema_for!(NewCard)), |
|
| 1616 | 1635 | ("CardMove", schemars::schema_for!(CardMove)), |
|
| 1617 | 1636 | ("NewComment", schemars::schema_for!(NewComment)), |
|
| … | 333 unchanged lines |
modified crates/hub/src/api/mod.rs +2 −0
| … | 97 unchanged lines |
||
| 98 | 98 | .route("/projects/{owner}", get(projects::list).post(projects::create)) |
|
| 99 | 99 | .route("/projects/{owner}/{number}", get(projects::board).patch(projects::edit)) |
|
| 100 | 100 | .route("/projects/{owner}/{number}/cards", get(projects::cards).post(projects::add_card)) |
|
| 101 | + | .route("/projects/{owner}/{number}/columns", post(projects::add_column)) |
|
| 102 | + | .route("/projects/{owner}/{number}/columns/{column}", axum::routing::patch(projects::edit_column).delete(projects::remove_column)) |
|
| 101 | 103 | .route("/projects/{owner}/{number}/cards/move", post(projects::move_cards)) |
|
| 102 | 104 | .route("/projects/{owner}/{number}/cards/archive", post(projects::archive_cards)) |
|
| 103 | 105 | .route("/projects/{owner}/{number}/cards/{id}/restore", post(projects::restore_card)) |
|
| … | 3411 unchanged lines |
modified crates/hub/src/api/projects.rs +91 −9
| … | 115 unchanged lines |
||
| 116 | 116 | Path((owner, number)): Path<(String, i64)>, |
|
| 117 | 117 | ) -> Result<Json<Board>, ApiError> { |
|
| 118 | 118 | let c = caller(&app, &headers).await?; |
|
| 119 | - | let (_, p) = visible(&app, &c, &owner, number).await?; |
|
| 120 | - | let (columns, _) = wp::board_columns(&app, c.account.as_ref(), &owner, &p, false).await?; |
|
| 121 | - | Ok(Json(Board { |
|
| 122 | - | project: project_of(&app, &p), |
|
| 123 | - | columns: columns |
|
| 124 | - | .iter() |
|
| 125 | - | .map(|col| ProjectColumn { id: col.column.id, name: col.column.name.clone(), cards: col.cards.iter().map(card_of).collect() }) |
|
| 126 | - | .collect(), |
|
| 127 | - | })) |
|
| 119 | + | board_of(&app, &c, &owner, number).await |
|
| 128 | 120 | } |
|
| 129 | 121 | ||
| 130 | 122 | /// `PATCH /projects/{owner}/{n}`. |
|
| … | 30 unchanged lines |
||
| 161 | 153 | .await?; |
|
| 162 | 154 | let fresh = pdb::get(&app.db, &owner, number).await?.unwrap_or(p); |
|
| 163 | 155 | Ok(Json(project_of(&app, &fresh))) |
|
| 156 | + | } |
|
| 157 | + | ||
| 158 | + | /// A board's columns as `GET /projects/{owner}/{n}` gives them, for the |
|
| 159 | + | /// column calls to answer with (#366). |
|
| 160 | + | async fn board_of(app: &Shared, c: &Caller, owner: &str, number: i64) -> Result<Json<Board>, ApiError> { |
|
| 161 | + | let (_, p) = visible(app, c, owner, number).await?; |
|
| 162 | + | let (columns, _) = wp::board_columns(app, c.account.as_ref(), owner, &p, false).await?; |
|
| 163 | + | Ok(Json(Board { |
|
| 164 | + | project: project_of(app, &p), |
|
| 165 | + | columns: columns |
|
| 166 | + | .iter() |
|
| 167 | + | .map(|col| ProjectColumn { id: col.column.id, name: col.column.name.clone(), cards: col.cards.iter().map(card_of).collect() }) |
|
| 168 | + | .collect(), |
|
| 169 | + | })) |
|
| 170 | + | } |
|
| 171 | + | ||
| 172 | + | /// A column's name: trimmed, 1 to 40 characters, as the board page has it. |
|
| 173 | + | fn column_name(name: &str) -> Result<&str, ApiError> { |
|
| 174 | + | let name = name.trim(); |
|
| 175 | + | if name.is_empty() || name.chars().count() > 40 { |
|
| 176 | + | return Err(ApiError(StatusCode::BAD_REQUEST, "a column has a name of 1 to 40 characters".into())); |
|
| 177 | + | } |
|
| 178 | + | Ok(name) |
|
| 179 | + | } |
|
| 180 | + | ||
| 181 | + | /// `POST /projects/{owner}/{n}/columns` (#366): add a column at the right; |
|
| 182 | + | /// answers the board. |
|
| 183 | + | pub async fn add_column( |
|
| 184 | + | State(app): State<Shared>, |
|
| 185 | + | headers: HeaderMap, |
|
| 186 | + | Path((owner, number)): Path<(String, i64)>, |
|
| 187 | + | Json(body): Json<hub_api::NewColumn>, |
|
| 188 | + | ) -> Result<Json<Board>, ApiError> { |
|
| 189 | + | let (c, p) = changeable(&app, &headers, &owner, number, false).await?; |
|
| 190 | + | let name = column_name(&body.name)?; |
|
| 191 | + | if pdb::columns(&app.db, p.id).await?.iter().any(|col| col.name.eq_ignore_ascii_case(name)) { |
|
| 192 | + | return Err(ApiError(StatusCode::CONFLICT, format!("the board has a column {name} already"))); |
|
| 193 | + | } |
|
| 194 | + | pdb::add_column(&app.db, p.id, name).await?; |
|
| 195 | + | board_of(&app, &c, &owner, number).await |
|
| 196 | + | } |
|
| 197 | + | ||
| 198 | + | /// `PATCH /projects/{owner}/{n}/columns/{column}` (#366): rename or move a |
|
| 199 | + | /// column named by id or name; answers the board. |
|
| 200 | + | pub async fn edit_column( |
|
| 201 | + | State(app): State<Shared>, |
|
| 202 | + | headers: HeaderMap, |
|
| 203 | + | Path((owner, number, spec)): Path<(String, i64, String)>, |
|
| 204 | + | Json(body): Json<hub_api::ColumnEdit>, |
|
| 205 | + | ) -> Result<Json<Board>, ApiError> { |
|
| 206 | + | let (c, p) = changeable(&app, &headers, &owner, number, false).await?; |
|
| 207 | + | let col = named_column(&app, p.id, &spec).await?; |
|
| 208 | + | if body.name.is_none() && body.position.is_none() { |
|
| 209 | + | return Err(ApiError(StatusCode::BAD_REQUEST, "say what to change: name or position".into())); |
|
| 210 | + | } |
|
| 211 | + | if let Some(name) = body.name.as_deref() { |
|
| 212 | + | let name = column_name(name)?; |
|
| 213 | + | let others = pdb::columns(&app.db, p.id).await?; |
|
| 214 | + | if others.iter().any(|o| o.id != col.id && o.name.eq_ignore_ascii_case(name)) { |
|
| 215 | + | return Err(ApiError(StatusCode::CONFLICT, format!("the board has a column {name} already"))); |
|
| 216 | + | } |
|
| 217 | + | pdb::rename_column(&app.db, p.id, col.id, name).await?; |
|
| 218 | + | } |
|
| 219 | + | if let Some(pos) = body.position { |
|
| 220 | + | pdb::place_column(&app.db, p.id, col.id, pos).await?; |
|
| 221 | + | } |
|
| 222 | + | board_of(&app, &c, &owner, number).await |
|
| 223 | + | } |
|
| 224 | + | ||
| 225 | + | /// `DELETE /projects/{owner}/{n}/columns/{column}` (#366): its cards go to |
|
| 226 | + | /// the first remaining column; a board keeps at least one. Answers the board. |
|
| 227 | + | pub async fn remove_column( |
|
| 228 | + | State(app): State<Shared>, |
|
| 229 | + | headers: HeaderMap, |
|
| 230 | + | Path((owner, number, spec)): Path<(String, i64, String)>, |
|
| 231 | + | ) -> Result<Json<Board>, ApiError> { |
|
| 232 | + | let (c, p) = changeable(&app, &headers, &owner, number, false).await?; |
|
| 233 | + | let col = named_column(&app, p.id, &spec).await?; |
|
| 234 | + | if !pdb::remove_column(&app.db, p.id, col.id).await? { |
|
| 235 | + | return Err(ApiError(StatusCode::CONFLICT, "a board keeps at least one column".into())); |
|
| 236 | + | } |
|
| 237 | + | board_of(&app, &c, &owner, number).await |
|
| 238 | + | } |
|
| 239 | + | ||
| 240 | + | /// A column named by id or name, or 404. |
|
| 241 | + | async fn named_column(app: &Shared, project_id: i64, spec: &str) -> Result<pdb::Column, ApiError> { |
|
| 242 | + | let cols = pdb::columns(&app.db, project_id).await?; |
|
| 243 | + | cols.into_iter() |
|
| 244 | + | .find(|c| c.id.to_string() == spec.trim() || c.name.eq_ignore_ascii_case(spec.trim())) |
|
| 245 | + | .ok_or_else(|| ApiError(StatusCode::NOT_FOUND, format!("no column {spec}"))) |
|
| 164 | 246 | } |
|
| 165 | 247 | ||
| 166 | 248 | /// `GET /projects/{owner}/{n}/cards`: the cards by column, as the board |
|
| … | 184 unchanged lines |
modified crates/hub/src/db/projects.rs +13 −0
| … | 212 unchanged lines |
||
| 213 | 213 | touch(pool, project_id).await |
|
| 214 | 214 | } |
|
| 215 | 215 | ||
| 216 | + | /// Move a column to `position` (0 the leftmost; past the end the rightmost). |
|
| 217 | + | pub async fn place_column(pool: &Pool, project_id: i64, column_id: i64, position: i64) -> sqlx::Result<()> { |
|
| 218 | + | let cols = columns(pool, project_id).await?; |
|
| 219 | + | let Some(i) = cols.iter().position(|c| c.id == column_id) else { return Ok(()) }; |
|
| 220 | + | let mut ids: Vec<i64> = cols.iter().map(|c| c.id).collect(); |
|
| 221 | + | let id = ids.remove(i); |
|
| 222 | + | ids.insert((position.max(0) as usize).min(ids.len()), id); |
|
| 223 | + | for (pos, id) in ids.iter().enumerate() { |
|
| 224 | + | sqlx::query("UPDATE project_columns SET position = $1 WHERE id = $2").bind(pos as i64).bind(id).execute(pool).await?; |
|
| 225 | + | } |
|
| 226 | + | touch(pool, project_id).await |
|
| 227 | + | } |
|
| 228 | + | ||
| 216 | 229 | /// Remove a column; its cards go to the first remaining column. The last column stays. |
|
| 217 | 230 | pub async fn remove_column(pool: &Pool, project_id: i64, column_id: i64) -> sqlx::Result<bool> { |
|
| 218 | 231 | let cols = columns(pool, project_id).await?; |
|
| … | 395 unchanged lines |
modified crates/hub/tests/api.rs +26 −0
| … | 1018 unchanged lines |
||
| 1019 | 1019 | assert_eq!(names, [("Backlog", 1), ("Doing", 0), ("Done", 1)]); |
|
| 1020 | 1020 | assert_eq!(board["columns"][2]["cards"][0]["reference"], "andy/demo#1"); |
|
| 1021 | 1021 | // The cards by column (#115), and a wrong method answers with a body. |
|
| 1022 | + | // Columns by API (#366): add, rename, move, remove; answers are the board. |
|
| 1023 | + | let col_names = |v: &serde_json::Value| -> Vec<String> { |
|
| 1024 | + | v["columns"].as_array().unwrap().iter().map(|c| c["name"].as_str().unwrap().to_string()).collect() |
|
| 1025 | + | }; |
|
| 1026 | + | let (st, v) = api(app, "POST", "/api/v1/projects/andy/1/columns", Some(&a), Some(r#"{"name":"Sprint 1"}"#)).await; |
|
| 1027 | + | assert_eq!((st, col_names(&v)), (StatusCode::OK, vec!["Backlog".to_string(), "Doing".into(), "Done".into(), "Sprint 1".into()]), "{v}"); |
|
| 1028 | + | assert_eq!(api(app, "POST", "/api/v1/projects/andy/1/columns", Some(&a), Some(r#"{"name":"sprint 1"}"#)).await.0, StatusCode::CONFLICT); |
|
| 1029 | + | assert_eq!(api(app, "POST", "/api/v1/projects/andy/1/columns", Some(&a), Some(r#"{"name":" "}"#)).await.0, StatusCode::BAD_REQUEST); |
|
| 1030 | + | assert_eq!(api(app, "POST", "/api/v1/projects/andy/1/columns", Some(&b), Some(r#"{"name":"Mine"}"#)).await.0, StatusCode::FORBIDDEN); |
|
| 1031 | + | let (st, v) = |
|
| 1032 | + | api(app, "PATCH", "/api/v1/projects/andy/1/columns/Sprint%201", Some(&a), Some(r#"{"name":"Sprint 2","position":0}"#)).await; |
|
| 1033 | + | assert_eq!((st, col_names(&v)), (StatusCode::OK, vec!["Sprint 2".to_string(), "Backlog".into(), "Doing".into(), "Done".into()]), "{v}"); |
|
| 1034 | + | let sprint = v["columns"][0]["id"].as_i64().unwrap(); |
|
| 1035 | + | let (_, v) = api(app, "PATCH", &format!("/api/v1/projects/andy/1/columns/{sprint}"), Some(&a), Some(r#"{"position":99}"#)).await; |
|
| 1036 | + | assert_eq!(col_names(&v).last().map(String::as_str), Some("Sprint 2")); |
|
| 1037 | + | assert_eq!(api(app, "PATCH", "/api/v1/projects/andy/1/columns/Sprint%202", Some(&a), Some("{}")).await.0, StatusCode::BAD_REQUEST); |
|
| 1038 | + | assert_eq!( |
|
| 1039 | + | api(app, "PATCH", "/api/v1/projects/andy/1/columns/Sprint%202", Some(&a), Some(r#"{"name":"done"}"#)).await.0, |
|
| 1040 | + | StatusCode::CONFLICT |
|
| 1041 | + | ); |
|
| 1042 | + | assert_eq!( |
|
| 1043 | + | api(app, "PATCH", "/api/v1/projects/andy/1/columns/nope", Some(&a), Some(r#"{"position":0}"#)).await.0, |
|
| 1044 | + | StatusCode::NOT_FOUND |
|
| 1045 | + | ); |
|
| 1046 | + | let (st, v) = api(app, "DELETE", "/api/v1/projects/andy/1/columns/Sprint%202", Some(&a), None).await; |
|
| 1047 | + | assert_eq!((st, col_names(&v)), (StatusCode::OK, vec!["Backlog".to_string(), "Doing".into(), "Done".into()]), "{v}"); |
|
| 1022 | 1048 | let (st, cols) = api(app, "GET", "/api/v1/projects/andy/1/cards", None, None).await; |
|
| 1023 | 1049 | assert_eq!(st, StatusCode::OK); |
|
| 1024 | 1050 | assert_eq!(cols, board["columns"]); |
|
| … | 815 unchanged lines |
modified crates/oh/src/main.rs +162 −11
| … | 325 unchanged lines |
||
| 326 | 326 | #[arg(long, short = 'a', value_name = "FILE")] |
|
| 327 | 327 | attach: Vec<std::path::PathBuf>, |
|
| 328 | 328 | }, |
|
| 329 | - | /// Close an issue |
|
| 329 | + | /// Close an issue, with an optional comment saying why |
|
| 330 | 330 | Close { |
|
| 331 | 331 | number: i64, |
|
| 332 | 332 | /// `completed` or `not_planned` |
|
| 333 | 333 | #[arg(long, default_value = "completed")] |
|
| 334 | 334 | reason: String, |
|
| 335 | + | #[command(flatten)] |
|
| 336 | + | comment: StateComment, |
|
| 335 | 337 | }, |
|
| 336 | - | /// Reopen an issue |
|
| 337 | - | Reopen { number: i64 }, |
|
| 338 | + | /// Reopen an issue, with an optional comment saying why |
|
| 339 | + | Reopen { |
|
| 340 | + | number: i64, |
|
| 341 | + | #[command(flatten)] |
|
| 342 | + | comment: StateComment, |
|
| 343 | + | }, |
|
| 338 | 344 | /// Change an issue's title or body (its author or triage), or its |
|
| 339 | 345 | /// labels, assignees or milestone as whole sets (triage) |
|
| 340 | 346 | Edit { |
|
| … | 213 unchanged lines |
||
| 554 | 560 | Card { |
|
| 555 | 561 | #[command(subcommand)] |
|
| 556 | 562 | cmd: CardCmd, |
|
| 563 | + | }, |
|
| 564 | + | /// A board's columns: add, rename, move, remove (#366) |
|
| 565 | + | Column { |
|
| 566 | + | #[command(subcommand)] |
|
| 567 | + | cmd: ColumnCmd, |
|
| 568 | + | }, |
|
| 569 | + | } |
|
| 570 | + | ||
| 571 | + | #[derive(Subcommand)] |
|
| 572 | + | enum ColumnCmd { |
|
| 573 | + | /// Add a column at the right |
|
| 574 | + | Add { |
|
| 575 | + | /// The board |
|
| 576 | + | number: i64, |
|
| 577 | + | name: String, |
|
| 557 | 578 | }, |
|
| 579 | + | /// Rename a column (by name or id) |
|
| 580 | + | Rename { |
|
| 581 | + | /// The board |
|
| 582 | + | number: i64, |
|
| 583 | + | column: String, |
|
| 584 | + | /// Its new name |
|
| 585 | + | to: String, |
|
| 586 | + | }, |
|
| 587 | + | /// Move a column: --to POSITION (1 is the leftmost), --left or --right |
|
| 588 | + | Move { |
|
| 589 | + | /// The board |
|
| 590 | + | number: i64, |
|
| 591 | + | column: String, |
|
| 592 | + | /// Its place, counting from 1 at the left |
|
| 593 | + | #[arg(long, conflicts_with_all = ["left", "right"], required_unless_present_any = ["left", "right"])] |
|
| 594 | + | to: Option<i64>, |
|
| 595 | + | /// One place to the left |
|
| 596 | + | #[arg(long, conflicts_with = "right")] |
|
| 597 | + | left: bool, |
|
| 598 | + | /// One place to the right |
|
| 599 | + | #[arg(long)] |
|
| 600 | + | right: bool, |
|
| 601 | + | }, |
|
| 602 | + | /// Remove a column; its cards go to the first remaining one |
|
| 603 | + | Remove { |
|
| 604 | + | /// The board |
|
| 605 | + | number: i64, |
|
| 606 | + | column: String, |
|
| 607 | + | }, |
|
| 558 | 608 | } |
|
| 559 | 609 | ||
| 560 | 610 | #[derive(Subcommand)] |
|
| … | 147 unchanged lines |
||
| 708 | 758 | #[arg(long, default_value_t = 20)] |
|
| 709 | 759 | limit: i64, |
|
| 710 | 760 | }, |
|
| 711 | - | /// A run and its jobs |
|
| 761 | + | /// A run and its jobs (default: the newest run) |
|
| 712 | 762 | View { |
|
| 713 | - | number: i64, |
|
| 763 | + | /// The run (default: the newest, or the newest of --bookmark) |
|
| 764 | + | number: Option<i64>, |
|
| 765 | + | /// With no number: the newest run of this bookmark |
|
| 766 | + | #[arg(long, conflicts_with = "number")] |
|
| 767 | + | bookmark: Option<String>, |
|
| 714 | 768 | /// Each job's wait, phases (image, prepare, run, finish) and peak disk and memory |
|
| 715 | 769 | #[arg(long)] |
|
| 716 | 770 | timings: bool, |
|
| … | 12 unchanged lines |
||
| 729 | 783 | #[arg(long)] |
|
| 730 | 784 | watch: bool, |
|
| 731 | 785 | }, |
|
| 732 | - | /// Follow a run until it finishes; exit 1 unless it passes |
|
| 733 | - | Watch { number: i64 }, |
|
| 786 | + | /// Follow a run until it finishes (default: the newest run); exit 1 unless it passes |
|
| 787 | + | Watch { |
|
| 788 | + | /// The run (default: the newest, or the newest of --bookmark) (#367) |
|
| 789 | + | number: Option<i64>, |
|
| 790 | + | /// With no number: the newest run of this bookmark |
|
| 791 | + | #[arg(long, conflicts_with = "number")] |
|
| 792 | + | bookmark: Option<String>, |
|
| 793 | + | }, |
|
| 734 | 794 | /// A job's log |
|
| 735 | 795 | Log { number: i64, job: String }, |
|
| 736 | 796 | /// A report a job published: its report.toml; --list for what it holds, --file for one file |
|
| … | 771 unchanged lines |
||
| 1508 | 1568 | } |
|
| 1509 | 1569 | } |
|
| 1510 | 1570 | } |
|
| 1511 | - | IssueCmd::Close { number, reason } => { |
|
| 1571 | + | IssueCmd::Close { number, reason, comment } => { |
|
| 1572 | + | comment.post(c, &base, &format!("{base}/issues/{number}/comments"))?; |
|
| 1512 | 1573 | let i: Issue = c.send("POST", &format!("{base}/issues/{number}"), &IssueStateChange { state: "closed".into(), reason })?; |
|
| 1513 | 1574 | println!("{} closed #{} {}", ui::ok("✔"), i.number, i.title); |
|
| 1514 | 1575 | } |
|
| 1515 | - | IssueCmd::Reopen { number } => { |
|
| 1576 | + | IssueCmd::Reopen { number, comment } => { |
|
| 1577 | + | comment.post(c, &base, &format!("{base}/issues/{number}/comments"))?; |
|
| 1516 | 1578 | let i: Issue = |
|
| 1517 | 1579 | c.send("POST", &format!("{base}/issues/{number}"), &IssueStateChange { state: "open".into(), reason: String::new() })?; |
|
| 1518 | 1580 | println!("{} reopened #{} {}", ui::ok("✔"), i.number, i.title); |
|
| … | 50 unchanged lines |
||
| 1569 | 1631 | } |
|
| 1570 | 1632 | } |
|
| 1571 | 1633 | Ok(0) |
|
| 1634 | + | } |
|
| 1635 | + | ||
| 1636 | + | /// A comment to post with a close or reopen (#368): saying why, in the |
|
| 1637 | + | /// same command. |
|
| 1638 | + | #[derive(clap::Args)] |
|
| 1639 | + | struct StateComment { |
|
| 1640 | + | /// Say why, as a comment posted first |
|
| 1641 | + | #[arg(long, short)] |
|
| 1642 | + | body: Option<String>, |
|
| 1643 | + | /// Read the comment from a file ("-" for stdin) |
|
| 1644 | + | #[arg(long, short = 'F', conflicts_with = "body")] |
|
| 1645 | + | body_file: Option<String>, |
|
| 1646 | + | /// Attach a file to the comment (repeatable) |
|
| 1647 | + | #[arg(long, short = 'a', value_name = "FILE")] |
|
| 1648 | + | attach: Vec<std::path::PathBuf>, |
|
| 1649 | + | } |
|
| 1650 | + | ||
| 1651 | + | impl StateComment { |
|
| 1652 | + | fn post(self, c: &Client, base: &str, path: &str) -> Result<()> { |
|
| 1653 | + | if self.body.is_none() && self.body_file.is_none() && self.attach.is_empty() { |
|
| 1654 | + | return Ok(()); |
|
| 1655 | + | } |
|
| 1656 | + | let body = with_attachments(c, base, body_of(self.body, self.body_file)?, &self.attach)?; |
|
| 1657 | + | if !body.trim().is_empty() { |
|
| 1658 | + | c.send_text("POST", path, &NewComment { body, path: None, line: None })?; |
|
| 1659 | + | } |
|
| 1660 | + | Ok(()) |
|
| 1661 | + | } |
|
| 1662 | + | } |
|
| 1663 | + | ||
| 1664 | + | /// `number`, or the newest run (of `bookmark`, when given): what a push |
|
| 1665 | + | /// just started is what one usually wants to follow (#367). |
|
| 1666 | + | fn run_or_newest(c: &Client, base: &str, number: Option<i64>, bookmark: Option<&str>) -> Result<i64> { |
|
| 1667 | + | if let Some(n) = number { |
|
| 1668 | + | return Ok(n); |
|
| 1669 | + | } |
|
| 1670 | + | let runs: Vec<FlowRun> = c.get(&format!("{base}/flow/runs?limit=100"))?; |
|
| 1671 | + | runs.iter().filter(|r| bookmark.is_none_or(|b| r.bookmark == b)).map(|r| r.number).max().with_context(|| match bookmark { |
|
| 1672 | + | Some(b) => format!("no runs of {b} among the latest 100"), |
|
| 1673 | + | None => "no runs yet".to_string(), |
|
| 1674 | + | }) |
|
| 1572 | 1675 | } |
|
| 1573 | 1676 | ||
| 1574 | 1677 | /// `body` with each file attached to the repository and linked under it: |
|
| … | 465 unchanged lines |
||
| 2040 | 2143 | println!("{}", run_line(r)); |
|
| 2041 | 2144 | } |
|
| 2042 | 2145 | } |
|
| 2043 | - | RunCmd::View { number, timings } => { |
|
| 2146 | + | RunCmd::View { number, bookmark, timings } => { |
|
| 2147 | + | let number = run_or_newest(c, &base, number, bookmark.as_deref())?; |
|
| 2044 | 2148 | let path = format!("{base}/flow/runs/{number}"); |
|
| 2045 | 2149 | if json { |
|
| 2046 | 2150 | println!("{}", c.get_text(&path)?); |
|
| … | 23 unchanged lines |
||
| 2070 | 2174 | return watch(c, &base, n, json); |
|
| 2071 | 2175 | } |
|
| 2072 | 2176 | } |
|
| 2073 | - | RunCmd::Watch { number } => return watch(c, &base, number, json), |
|
| 2177 | + | RunCmd::Watch { number, bookmark } => { |
|
| 2178 | + | let number = run_or_newest(c, &base, number, bookmark.as_deref())?; |
|
| 2179 | + | return watch(c, &base, number, json); |
|
| 2180 | + | } |
|
| 2074 | 2181 | RunCmd::Log { number, job } => { |
|
| 2075 | 2182 | let mut after = 0; |
|
| 2076 | 2183 | loop { |
|
| … | 233 unchanged lines |
||
| 2310 | 2417 | println!("{}", serde_json::to_string(&p)?); |
|
| 2311 | 2418 | } else { |
|
| 2312 | 2419 | println!("{} project {} {} {}", ui::ok("✔"), p.number, p.title, ui::dim(&p.url)); |
|
| 2420 | + | } |
|
| 2421 | + | } |
|
| 2422 | + | ProjectCmd::Column { cmd } => { |
|
| 2423 | + | let col_path = |number: i64, column: &str| format!("{base}/{number}/columns/{}", enc(column)); |
|
| 2424 | + | let (number, b, what): (i64, hub_api::Board, String) = match cmd { |
|
| 2425 | + | ColumnCmd::Add { number, name } => { |
|
| 2426 | + | let b = c.send("POST", &format!("{base}/{number}/columns"), &hub_api::NewColumn { name: name.clone() })?; |
|
| 2427 | + | (number, b, format!("added column {name}")) |
|
| 2428 | + | } |
|
| 2429 | + | ColumnCmd::Rename { number, column, to } => { |
|
| 2430 | + | let edit = hub_api::ColumnEdit { name: Some(to.clone()), position: None }; |
|
| 2431 | + | (number, c.send("PATCH", &col_path(number, &column), &edit)?, format!("renamed column {column} to {to}")) |
|
| 2432 | + | } |
|
| 2433 | + | ColumnCmd::Move { number, column, to, left, right } => { |
|
| 2434 | + | let position = match to { |
|
| 2435 | + | Some(n) => n - 1, |
|
| 2436 | + | None => { |
|
| 2437 | + | let now: hub_api::Board = c.get(&format!("{base}/{number}"))?; |
|
| 2438 | + | let at = now |
|
| 2439 | + | .columns |
|
| 2440 | + | .iter() |
|
| 2441 | + | .position(|col| col.id.to_string() == column || col.name.eq_ignore_ascii_case(&column)) |
|
| 2442 | + | .with_context(|| format!("no column {column} on board {number}"))? |
|
| 2443 | + | as i64; |
|
| 2444 | + | if left { |
|
| 2445 | + | (at - 1).max(0) |
|
| 2446 | + | } else { |
|
| 2447 | + | debug_assert!(right); |
|
| 2448 | + | at + 1 |
|
| 2449 | + | } |
|
| 2450 | + | } |
|
| 2451 | + | }; |
|
| 2452 | + | let edit = hub_api::ColumnEdit { name: None, position: Some(position) }; |
|
| 2453 | + | (number, c.send("PATCH", &col_path(number, &column), &edit)?, format!("moved column {column}")) |
|
| 2454 | + | } |
|
| 2455 | + | ColumnCmd::Remove { number, column } => { |
|
| 2456 | + | (number, c.send("DELETE", &col_path(number, &column), &serde_json::json!({}))?, format!("removed column {column}")) |
|
| 2457 | + | } |
|
| 2458 | + | }; |
|
| 2459 | + | if json { |
|
| 2460 | + | println!("{}", serde_json::to_string(&b)?); |
|
| 2461 | + | } else { |
|
| 2462 | + | let order: Vec<&str> = b.columns.iter().map(|col| col.name.as_str()).collect(); |
|
| 2463 | + | println!("{} {what} on board {number}: {}", ui::ok("✔"), ui::dim(&order.join(" · "))); |
|
| 2313 | 2464 | } |
|
| 2314 | 2465 | } |
|
| 2315 | 2466 | ProjectCmd::Close { number } => state(number, true, "closed")?, |
|
| … | 403 unchanged lines |
modified docs/spec.md +1 −1
| … | 493 unchanged lines |
||
| 494 | 494 | ||
| 495 | 495 | ### 12.1 API |
|
| 496 | 496 | ||
| 497 | - | 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`, `/user/repos` (the caller's own, organisations' and collaborations, `?affiliation=&after=&limit=`, #302), `/user/orgs`, `/user/sky` (Your sky's data, the dashboard backdrop's: the caller's repositories and their events of the last 30 days as `[repo, kind, at_ms, n]`, for the Android app; oxygenhub-android#23; `/repos/{owner}/{name}/sky` gives one repository's, its pages' backdrop), `/user/issues` (the open issues assigned to the caller, in repositories it can see, newest-updated first, each with its repository and address; #359), `/user/flow/runs` (the newest Flow runs of the caller's repositories, each with its repository and jobs, `?before=MS&limit=`; oxygenhub-android#26), `/user/starter?owner=X` and `/repos/{owner}/{name}/starter` (#347: the assistants a new repository under X may ask, and, `POST {"description", "assistant"}` by its admin, an assistant's draft of an empty repository's starting files, committed in the background as the caller, the hub signing as their delegate; `GET` says `running`, `done` with the change and files, or `failed` with why), `/notifications` (the caller's inbox and unread count, each with its repository, reason, thread and label, #305; `/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; `POST /cards/archive` (#293: `{"from", "older_days", "except", "state", "dry_run"}`, the same selection and an age) to archive them, `GET /cards?archived=true` to list the archived ones (each with `archived_ms`) and `POST /cards/{id}/restore` to put one back; cards carry their `labels` and `assignees`; `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, Cobertura or JaCoCo 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, each with its timings: `queued_ms`, `ready_ms`, the `phases` its runner reported (image, prepare, run, finish, in milliseconds) and its peak disk and memory; #348, `oh run view N --timings`), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`), `/flow/runs/{n}/jobs/{job}/reports` and `/flow/runs/{n}/jobs/{job}/report` (`?name=`; with its files, and `…/report/{path}` one file as published, sandboxed) and `/reports` (`?bookmark=`: the newest run of each) and `/reports/{name}` (`?id=` or `?bookmark=`; `…/files/{path}`): the reports Flow jobs publish (#348, docs/reports.md §6; `oh run report N JOB`, `oh report`) 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. |
|
| 497 | + | 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`, `/user/repos` (the caller's own, organisations' and collaborations, `?affiliation=&after=&limit=`, #302), `/user/orgs`, `/user/sky` (Your sky's data, the dashboard backdrop's: the caller's repositories and their events of the last 30 days as `[repo, kind, at_ms, n]`, for the Android app; oxygenhub-android#23; `/repos/{owner}/{name}/sky` gives one repository's, its pages' backdrop), `/user/issues` (the open issues assigned to the caller, in repositories it can see, newest-updated first, each with its repository and address; #359), `/user/flow/runs` (the newest Flow runs of the caller's repositories, each with its repository and jobs, `?before=MS&limit=`; oxygenhub-android#26), `/user/starter?owner=X` and `/repos/{owner}/{name}/starter` (#347: the assistants a new repository under X may ask, and, `POST {"description", "assistant"}` by its admin, an assistant's draft of an empty repository's starting files, committed in the background as the caller, the hub signing as their delegate; `GET` says `running`, `done` with the change and files, or `failed` with why), `/notifications` (the caller's inbox and unread count, each with its repository, reason, thread and label, #305; `/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 /columns` (`{"name"}`, at the right), `PATCH /columns/{column}` (by id or name: `{"name", "position"}`, 0 the leftmost) and `DELETE /columns/{column}` (its cards to the first remaining column; a board keeps one) for its columns, each answering the board (#366), `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; `POST /cards/archive` (#293: `{"from", "older_days", "except", "state", "dry_run"}`, the same selection and an age) to archive them, `GET /cards?archived=true` to list the archived ones (each with `archived_ms`) and `POST /cards/{id}/restore` to put one back; cards carry their `labels` and `assignees`; `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, Cobertura or JaCoCo 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, each with its timings: `queued_ms`, `ready_ms`, the `phases` its runner reported (image, prepare, run, finish, in milliseconds) and its peak disk and memory; #348, `oh run view N --timings`), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`), `/flow/runs/{n}/jobs/{job}/reports` and `/flow/runs/{n}/jobs/{job}/report` (`?name=`; with its files, and `…/report/{path}` one file as published, sandboxed) and `/reports` (`?bookmark=`: the newest run of each) and `/reports/{name}` (`?id=` or `?bookmark=`; `…/files/{path}`): the reports Flow jobs publish (#348, docs/reports.md §6; `oh run report N JOB`, `oh report`) 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. |
|
| 498 | 498 | ||
| 499 | 499 | ### 12.2 Webhooks |
|
| 500 | 500 | ||
| … | 398 unchanged lines |