OXYGENHUB

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