Milestones over the API, and oh milestone 5ea4872f23e8
POST /repos/{o}/{n}/milestones creates a milestone (title,
description, due YYYY-MM-DD) or changes the one with that title, and
PATCH /milestones/{title} changes title, description, due date or
state, what is not given staying; both need triage. oh milestone
list|create|edit|close|reopen. Spec §12 and docs/oh.md say so.
Also §13.3: design notes for web workspaces (data, runner side, hub
side, security, costs, open questions) to review before anything is
built, refs #69.
closes #91
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-04 03:16 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
5ea4872f23e86a93929d6d7c4ac16216eb188a41f8553dfcc40c0bb6087210a3- Depends on
- 00cec4e18792 18e381ea88c2 2291f8cc9c96 5c6e92fd8d71 745d225de344 76f262e1a666 93be9ce4653e b5229b56f49b b64e2537d702 d23464c0ac60
- Depended on by
- 5a9a6e9a15bc ba0a55bb5b3c 5d90e0e045e2 40dd3f86379a 617c2840446c 17a3a84b00d1 62ceb2eec18c 7d2eb590051f e71846aa07b9 d4792b4a4629 f28389bd6aab
- 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.0 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
- 16 atoms, 0 tree operations, 7 files
Browse the tree at this change
modified crates/hub-api/src/lib.rs +30 −0
| … | 274 unchanged lines |
||
| 275 | 275 | pub closed: i64, |
|
| 276 | 276 | } |
|
| 277 | 277 | ||
| 278 | + | /// `POST /repos/{o}/{n}/milestones`: create a milestone, or change the |
|
| 279 | + | /// description and due date of the one with that title (triage). |
|
| 278 | 280 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 281 | + | pub struct NewMilestone { |
|
| 282 | + | pub title: String, |
|
| 283 | + | #[serde(default)] |
|
| 284 | + | pub description: String, |
|
| 285 | + | /// `YYYY-MM-DD`, UTC; absent for no due date. |
|
| 286 | + | #[serde(default)] |
|
| 287 | + | pub due: Option<String>, |
|
| 288 | + | } |
|
| 289 | + | ||
| 290 | + | /// `PATCH /repos/{o}/{n}/milestones/{title}`: only the fields given change |
|
| 291 | + | /// (triage). |
|
| 292 | + | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 293 | + | pub struct MilestoneEdit { |
|
| 294 | + | #[serde(default)] |
|
| 295 | + | pub title: Option<String>, |
|
| 296 | + | #[serde(default)] |
|
| 297 | + | pub description: Option<String>, |
|
| 298 | + | /// `YYYY-MM-DD`; the empty string clears the due date. |
|
| 299 | + | #[serde(default)] |
|
| 300 | + | pub due: Option<String>, |
|
| 301 | + | /// `open` or `closed`. |
|
| 302 | + | #[serde(default)] |
|
| 303 | + | pub state: Option<String>, |
|
| 304 | + | } |
|
| 305 | + | ||
| 306 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 279 | 307 | pub struct Comment { |
|
| 280 | 308 | pub id: i64, |
|
| 281 | 309 | pub author: String, |
|
| … | 635 unchanged lines |
||
| 917 | 945 | ("Issue", schemars::schema_for!(Issue)), |
|
| 918 | 946 | ("Label", schemars::schema_for!(Label)), |
|
| 919 | 947 | ("Milestone", schemars::schema_for!(Milestone)), |
|
| 948 | + | ("NewMilestone", schemars::schema_for!(NewMilestone)), |
|
| 949 | + | ("MilestoneEdit", schemars::schema_for!(MilestoneEdit)), |
|
| 920 | 950 | ("Comment", schemars::schema_for!(Comment)), |
|
| 921 | 951 | ("ProposalFile", schemars::schema_for!(ProposalFile)), |
|
| 922 | 952 | ("Notification", schemars::schema_for!(Notification)), |
|
| … | 95 unchanged lines |
modified crates/hub/src/api/mod.rs +84 −1
| … | 88 unchanged lines |
||
| 89 | 89 | .route("/repos/{owner}/{name}/issues/{number}/comments", get(issue_comments).post(issue_comment)) |
|
| 90 | 90 | .route("/repos/{owner}/{name}/labels", get(labels).post(create_label)) |
|
| 91 | 91 | .route("/repos/{owner}/{name}/labels/{label}", axum::routing::delete(delete_label)) |
|
| 92 | - | .route("/repos/{owner}/{name}/milestones", get(milestones)) |
|
| 92 | + | .route("/repos/{owner}/{name}/milestones", get(milestones).post(create_milestone)) |
|
| 93 | + | .route("/repos/{owner}/{name}/milestones/{title}", axum::routing::patch(edit_milestone)) |
|
| 93 | 94 | .route("/repos/{owner}/{name}/hooks", get(hooks).post(create_hook)) |
|
| 94 | 95 | .route("/repos/{owner}/{name}/hooks/{id}/delete", post(delete_hook)) |
|
| 95 | 96 | .route("/repos/{owner}/{name}/checks", post(post_check)) |
|
| … | 1493 unchanged lines |
||
| 1589 | 1590 | Ok(Json(out)) |
|
| 1590 | 1591 | } |
|
| 1591 | 1592 | ||
| 1593 | + | fn milestone_of(m: db::issues::Milestone, open: i64, closed: i64) -> Milestone { |
|
| 1594 | + | Milestone { title: m.title, description: m.description, due_ms: m.due_ms, state: m.state, open, closed } |
|
| 1595 | + | } |
|
| 1596 | + | ||
| 1597 | + | /// A due date as `YYYY-MM-DD` (UTC midnight), or the empty string for none. |
|
| 1598 | + | fn due_ms_of(due: &str) -> Result<Option<i64>, ApiError> { |
|
| 1599 | + | let due = due.trim(); |
|
| 1600 | + | if due.is_empty() { |
|
| 1601 | + | return Ok(None); |
|
| 1602 | + | } |
|
| 1603 | + | time::Date::parse(due, &time::macros::format_description!("[year]-[month]-[day]")) |
|
| 1604 | + | .map(|d| Some(d.midnight().assume_utc().unix_timestamp() * 1000)) |
|
| 1605 | + | .map_err(|_| ApiError(StatusCode::BAD_REQUEST, format!("due {due:?} is not YYYY-MM-DD"))) |
|
| 1606 | + | } |
|
| 1607 | + | ||
| 1608 | + | async fn triage_for_milestones(app: &Shared, headers: &HeaderMap, owner: &str, name: &str) -> Result<db::repos::RepoRow, ApiError> { |
|
| 1609 | + | let c = caller(app, headers).await?; |
|
| 1610 | + | c.require("issues:write").or_else(|_| c.require("repo:write"))?; |
|
| 1611 | + | let (_, row, role) = reach(app, &c, owner, name, Access::Read).await?; |
|
| 1612 | + | if role < Role::Triage { |
|
| 1613 | + | return Err(forbidden("milestones need triage access")); |
|
| 1614 | + | } |
|
| 1615 | + | Ok(row) |
|
| 1616 | + | } |
|
| 1617 | + | ||
| 1618 | + | /// `POST /repos/{o}/{n}/milestones` (triage, oxygenhub#91): create a |
|
| 1619 | + | /// milestone, or change the description and due date of the one with |
|
| 1620 | + | /// that title. |
|
| 1621 | + | async fn create_milestone( |
|
| 1622 | + | State(app): State<Shared>, |
|
| 1623 | + | headers: HeaderMap, |
|
| 1624 | + | Path((owner, name)): Path<(String, String)>, |
|
| 1625 | + | Json(body): Json<NewMilestone>, |
|
| 1626 | + | ) -> Result<(StatusCode, Json<Milestone>), ApiError> { |
|
| 1627 | + | let row = triage_for_milestones(&app, &headers, &owner, &name).await?; |
|
| 1628 | + | let title = body.title.trim(); |
|
| 1629 | + | if title.is_empty() || title.len() > 100 { |
|
| 1630 | + | return Err(ApiError(StatusCode::BAD_REQUEST, "a milestone has a title of 1 to 100 characters".into())); |
|
| 1631 | + | } |
|
| 1632 | + | let due_ms = due_ms_of(body.due.as_deref().unwrap_or(""))?; |
|
| 1633 | + | let m = db::issues::upsert_milestone(&app.db, row.id, title, body.description.trim(), due_ms).await?; |
|
| 1634 | + | let (open, closed) = db::issues::milestone_counts(&app.db, m.id).await?; |
|
| 1635 | + | Ok((StatusCode::CREATED, Json(milestone_of(m, open, closed)))) |
|
| 1636 | + | } |
|
| 1637 | + | ||
| 1638 | + | /// `PATCH /repos/{o}/{n}/milestones/{title}` (triage): title, |
|
| 1639 | + | /// description, due date and state; what is not given stays. |
|
| 1640 | + | async fn edit_milestone( |
|
| 1641 | + | State(app): State<Shared>, |
|
| 1642 | + | headers: HeaderMap, |
|
| 1643 | + | Path((owner, name, title)): Path<(String, String, String)>, |
|
| 1644 | + | Json(body): Json<MilestoneEdit>, |
|
| 1645 | + | ) -> Result<Json<Milestone>, ApiError> { |
|
| 1646 | + | let row = triage_for_milestones(&app, &headers, &owner, &name).await?; |
|
| 1647 | + | let m = db::issues::milestones(&app.db, row.id) |
|
| 1648 | + | .await? |
|
| 1649 | + | .into_iter() |
|
| 1650 | + | .find(|m| m.title == title) |
|
| 1651 | + | .ok_or_else(|| not_found("no such milestone"))?; |
|
| 1652 | + | let new_title = body.title.as_deref().map(str::trim).unwrap_or(&m.title); |
|
| 1653 | + | if new_title.is_empty() || new_title.len() > 100 { |
|
| 1654 | + | return Err(ApiError(StatusCode::BAD_REQUEST, "a milestone has a title of 1 to 100 characters".into())); |
|
| 1655 | + | } |
|
| 1656 | + | let due_ms = match body.due.as_deref() { |
|
| 1657 | + | Some(d) => due_ms_of(d)?, |
|
| 1658 | + | None => m.due_ms, |
|
| 1659 | + | }; |
|
| 1660 | + | let description = body.description.as_deref().map(str::trim).unwrap_or(&m.description); |
|
| 1661 | + | if body.title.is_some() || body.description.is_some() || body.due.is_some() { |
|
| 1662 | + | db::issues::update_milestone(&app.db, m.id, new_title, description, due_ms).await?; |
|
| 1663 | + | } |
|
| 1664 | + | if let Some(state) = body.state.as_deref() { |
|
| 1665 | + | match state { |
|
| 1666 | + | "open" | "closed" => db::issues::set_milestone_state(&app.db, m.id, state).await?, |
|
| 1667 | + | other => return Err(ApiError(StatusCode::BAD_REQUEST, format!("state {other:?} is open or closed"))), |
|
| 1668 | + | } |
|
| 1669 | + | } |
|
| 1670 | + | let fresh = db::issues::milestone(&app.db, m.id).await?.ok_or_else(|| not_found("no such milestone"))?; |
|
| 1671 | + | let (open, closed) = db::issues::milestone_counts(&app.db, fresh.id).await?; |
|
| 1672 | + | Ok(Json(milestone_of(fresh, open, closed))) |
|
| 1673 | + | } |
|
| 1674 | + | ||
| 1592 | 1675 | // --------------------------------------------------------- hooks, checks |
|
| 1593 | 1676 | ||
| 1594 | 1677 | fn hook_of(h: &db::hooks::Hook) -> Hook { |
|
| … | 428 unchanged lines |
modified crates/hub/src/db/issues.rs +12 −0
| … | 366 unchanged lines |
||
| 367 | 367 | .await |
|
| 368 | 368 | } |
|
| 369 | 369 | ||
| 370 | + | /// Change a milestone's title, description and due date. |
|
| 371 | + | pub async fn update_milestone(pool: &Pool, id: i64, title: &str, description: &str, due_ms: Option<i64>) -> sqlx::Result<()> { |
|
| 372 | + | sqlx::query("UPDATE milestones SET title = ?, description = ?, due_ms = ? WHERE id = ?") |
|
| 373 | + | .bind(title) |
|
| 374 | + | .bind(description) |
|
| 375 | + | .bind(due_ms) |
|
| 376 | + | .bind(id) |
|
| 377 | + | .execute(pool) |
|
| 378 | + | .await?; |
|
| 379 | + | Ok(()) |
|
| 380 | + | } |
|
| 381 | + | ||
| 370 | 382 | pub async fn set_milestone_state(pool: &Pool, id: i64, state: &str) -> sqlx::Result<()> { |
|
| 371 | 383 | sqlx::query("UPDATE milestones SET state = ? WHERE id = ?").bind(state).bind(id).execute(pool).await?; |
|
| 372 | 384 | Ok(()) |
|
| … | 14 unchanged lines |
modified crates/hub/tests/api.rs +39 −0
| … | 541 unchanged lines |
||
| 542 | 542 | assert_eq!(api(app, "POST", "/api/v1/repos/andy/demo/labels", Some(&bob_t), Some(r#"{"name":"mine"}"#)).await.0, StatusCode::FORBIDDEN); |
|
| 543 | 543 | let (_, labels) = api(app, "GET", "/api/v1/repos/andy/demo/labels", None, None).await; |
|
| 544 | 544 | assert_eq!(labels.as_array().unwrap().len(), 2); |
|
| 545 | + | // oxygenhub#91: milestones over the API, for triage. |
|
| 546 | + | let (st, m) = api( |
|
| 547 | + | app, |
|
| 548 | + | "POST", |
|
| 549 | + | "/api/v1/repos/andy/demo/milestones", |
|
| 550 | + | Some(&andy_t), |
|
| 551 | + | Some(r#"{"title":"v1","description":"First","due":"2027-01-01"}"#), |
|
| 552 | + | ) |
|
| 553 | + | .await; |
|
| 554 | + | assert_eq!((st, m["title"].as_str(), m["state"].as_str()), (StatusCode::CREATED, Some("v1"), Some("open")), "{m}"); |
|
| 555 | + | assert_eq!(m["due_ms"].as_i64(), Some(1_798_761_600_000)); |
|
| 556 | + | assert_eq!( |
|
| 557 | + | api(app, "POST", "/api/v1/repos/andy/demo/milestones", Some(&andy_t), Some(r#"{"title":"v2","due":"soon"}"#)).await.0, |
|
| 558 | + | StatusCode::BAD_REQUEST |
|
| 559 | + | ); |
|
| 560 | + | assert_eq!( |
|
| 561 | + | api(app, "POST", "/api/v1/repos/andy/demo/milestones", Some(&bob_t), Some(r#"{"title":"mine"}"#)).await.0, |
|
| 562 | + | StatusCode::FORBIDDEN |
|
| 563 | + | ); |
|
| 564 | + | let (st, m) = |
|
| 565 | + | api(app, "PATCH", "/api/v1/repos/andy/demo/milestones/v1", Some(&andy_t), Some(r#"{"title":"v1.0","due":"","state":"closed"}"#)) |
|
| 566 | + | .await; |
|
| 567 | + | assert_eq!( |
|
| 568 | + | (st, m["title"].as_str(), m["state"].as_str(), m["due_ms"].is_null()), |
|
| 569 | + | (StatusCode::OK, Some("v1.0"), Some("closed"), true), |
|
| 570 | + | "{m}" |
|
| 571 | + | ); |
|
| 572 | + | assert_eq!(m["description"].as_str(), Some("First"), "what is not given stays"); |
|
| 573 | + | assert_eq!( |
|
| 574 | + | api(app, "PATCH", "/api/v1/repos/andy/demo/milestones/v1", Some(&andy_t), Some(r#"{"state":"open"}"#)).await.0, |
|
| 575 | + | StatusCode::NOT_FOUND, |
|
| 576 | + | "renamed" |
|
| 577 | + | ); |
|
| 578 | + | assert_eq!( |
|
| 579 | + | api(app, "PATCH", "/api/v1/repos/andy/demo/milestones/v1.0", Some(&andy_t), Some(r#"{"state":"done"}"#)).await.0, |
|
| 580 | + | StatusCode::BAD_REQUEST |
|
| 581 | + | ); |
|
| 582 | + | let (_, list) = api(app, "GET", "/api/v1/repos/andy/demo/milestones", None, None).await; |
|
| 583 | + | assert_eq!(list.as_array().unwrap().len(), 1); |
|
| 545 | 584 | ||
| 546 | 585 | // An issue created with its labels and assignee in one call. |
|
| 547 | 586 | let (st, v) = api( |
|
| … | 800 unchanged lines |
modified crates/oh/src/main.rs +93 −0
| … | 71 unchanged lines |
||
| 72 | 72 | #[command(subcommand)] |
|
| 73 | 73 | cmd: LabelCmd, |
|
| 74 | 74 | }, |
|
| 75 | + | /// Milestones: list, create, edit, close |
|
| 76 | + | Milestone { |
|
| 77 | + | #[command(subcommand)] |
|
| 78 | + | cmd: MilestoneCmd, |
|
| 79 | + | }, |
|
| 75 | 80 | /// Call the API directly: `oh api /repos/OWNER/NAME/issues` |
|
| 76 | 81 | Api { |
|
| 77 | 82 | /// A path under /api/v1 |
|
| … | 173 unchanged lines |
||
| 251 | 256 | Delete { name: String }, |
|
| 252 | 257 | } |
|
| 253 | 258 | ||
| 259 | + | #[derive(Subcommand)] |
|
| 260 | + | enum MilestoneCmd { |
|
| 261 | + | /// The repository's milestones |
|
| 262 | + | List, |
|
| 263 | + | /// Create a milestone, or change the one of that title (triage) |
|
| 264 | + | Create { |
|
| 265 | + | title: String, |
|
| 266 | + | #[arg(long, short, default_value = "")] |
|
| 267 | + | description: String, |
|
| 268 | + | /// Due date, YYYY-MM-DD |
|
| 269 | + | #[arg(long)] |
|
| 270 | + | due: Option<String>, |
|
| 271 | + | }, |
|
| 272 | + | /// Change a milestone's title, description or due date (triage) |
|
| 273 | + | Edit { |
|
| 274 | + | title: String, |
|
| 275 | + | #[arg(long, short)] |
|
| 276 | + | rename: Option<String>, |
|
| 277 | + | #[arg(long, short)] |
|
| 278 | + | description: Option<String>, |
|
| 279 | + | /// Due date, YYYY-MM-DD ("" for none) |
|
| 280 | + | #[arg(long)] |
|
| 281 | + | due: Option<String>, |
|
| 282 | + | }, |
|
| 283 | + | /// Close a milestone |
|
| 284 | + | Close { title: String }, |
|
| 285 | + | /// Reopen a milestone |
|
| 286 | + | Reopen { title: String }, |
|
| 287 | + | } |
|
| 288 | + | ||
| 254 | 289 | /// Labels to put on or take off an issue or proposal. |
|
| 255 | 290 | #[derive(clap::Args)] |
|
| 256 | 291 | struct LabelChange { |
|
| … | 177 unchanged lines |
||
| 434 | 469 | Cmd::Run { cmd } => flow(&c, &t, cmd, json), |
|
| 435 | 470 | Cmd::Release { cmd } => release(&c, &t, cmd, json), |
|
| 436 | 471 | Cmd::Label { cmd } => label(&c, &t, cmd, json), |
|
| 472 | + | Cmd::Milestone { cmd } => milestone(&c, &t, cmd, json), |
|
| 437 | 473 | } |
|
| 438 | 474 | } |
|
| 439 | 475 | ||
| … | 515 unchanged lines |
||
| 955 | 991 | LabelCmd::Delete { name } => { |
|
| 956 | 992 | c.send_text("DELETE", &format!("{base}/labels/{}", enc(&name)), &serde_json::json!({}))?; |
|
| 957 | 993 | println!("{} deleted label {name}", ui::ok("✔")); |
|
| 994 | + | } |
|
| 995 | + | } |
|
| 996 | + | Ok(0) |
|
| 997 | + | } |
|
| 998 | + | ||
| 999 | + | fn milestone_line(m: &hub_api::Milestone) -> String { |
|
| 1000 | + | let glyph = if m.state == "open" { ui::ok("◆") } else { ui::dim("◇") }; |
|
| 1001 | + | let due = m.due_ms.map(|d| format!(" · due {}", ui::ago(d))).unwrap_or_default(); |
|
| 1002 | + | format!("{glyph} {} {}{}", m.title, ui::dim(&format!("{} open, {} closed", m.open, m.closed)), ui::dim(&due)) |
|
| 1003 | + | } |
|
| 1004 | + | ||
| 1005 | + | fn milestone(c: &Client, t: &Target, cmd: MilestoneCmd, json: bool) -> Result<i32> { |
|
| 1006 | + | let base = t.repo_path()?; |
|
| 1007 | + | let edit = |title: &str, e: hub_api::MilestoneEdit, verb: &str| -> Result<()> { |
|
| 1008 | + | let m: hub_api::Milestone = c.send("PATCH", &format!("{base}/milestones/{}", enc(title)), &e)?; |
|
| 1009 | + | if json { |
|
| 1010 | + | println!("{}", serde_json::to_string(&m)?); |
|
| 1011 | + | } else { |
|
| 1012 | + | println!("{} {verb} milestone {}", ui::ok("✔"), m.title); |
|
| 1013 | + | } |
|
| 1014 | + | Ok(()) |
|
| 1015 | + | }; |
|
| 1016 | + | match cmd { |
|
| 1017 | + | MilestoneCmd::List => { |
|
| 1018 | + | let path = format!("{base}/milestones"); |
|
| 1019 | + | if json { |
|
| 1020 | + | println!("{}", c.get_text(&path)?); |
|
| 1021 | + | return Ok(0); |
|
| 1022 | + | } |
|
| 1023 | + | let list: Vec<hub_api::Milestone> = c.get(&path)?; |
|
| 1024 | + | if list.is_empty() { |
|
| 1025 | + | println!("{}", ui::dim("No milestones.")); |
|
| 1026 | + | } |
|
| 1027 | + | for m in &list { |
|
| 1028 | + | println!("{}", milestone_line(m)); |
|
| 1029 | + | } |
|
| 1030 | + | } |
|
| 1031 | + | MilestoneCmd::Create { title, description, due } => { |
|
| 1032 | + | let m: hub_api::Milestone = |
|
| 1033 | + | c.send("POST", &format!("{base}/milestones"), &hub_api::NewMilestone { title, description, due })?; |
|
| 1034 | + | if json { |
|
| 1035 | + | println!("{}", serde_json::to_string(&m)?); |
|
| 1036 | + | } else { |
|
| 1037 | + | println!("{} milestone {}", ui::ok("✔"), m.title); |
|
| 1038 | + | } |
|
| 1039 | + | } |
|
| 1040 | + | MilestoneCmd::Edit { title, rename, description, due } => { |
|
| 1041 | + | if rename.is_none() && description.is_none() && due.is_none() { |
|
| 1042 | + | bail!("say what to change: --rename, --description or --due"); |
|
| 1043 | + | } |
|
| 1044 | + | edit(&title, hub_api::MilestoneEdit { title: rename, description, due, state: None }, "edited")?; |
|
| 1045 | + | } |
|
| 1046 | + | MilestoneCmd::Close { title } => { |
|
| 1047 | + | edit(&title, hub_api::MilestoneEdit { state: Some("closed".into()), ..Default::default() }, "closed")?; |
|
| 1048 | + | } |
|
| 1049 | + | MilestoneCmd::Reopen { title } => { |
|
| 1050 | + | edit(&title, hub_api::MilestoneEdit { state: Some("open".into()), ..Default::default() }, "reopened")?; |
|
| 958 | 1051 | } |
|
| 959 | 1052 | } |
|
| 960 | 1053 | Ok(0) |
|
| … | 94 unchanged lines |
modified docs/oh.md +1 −0
| … | 39 unchanged lines |
||
| 40 | 40 | | `oh issue create -t TITLE [-b BODY \| -F FILE] [-l LABEL]…` | Open an issue | |
|
| 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 | + | | `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) | |
|
| 43 | 44 | | `oh issue close N [--reason not_planned]`, `oh issue reopen N` | Close or reopen | |
|
| 44 | 45 | | `oh issue comment N -b BODY` | Comment | |
|
| 45 | 46 | | `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 | |
|
| … | 20 unchanged lines |
modified docs/spec.md +10 −1
| … | 440 unchanged lines |
||
| 441 | 441 | ||
| 442 | 442 | ### 12.1 API |
|
| 443 | 443 | ||
| 444 | - | 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`, `/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`, `/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 (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. |
|
| 444 | + | 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`, `/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 (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. |
|
| 445 | 445 | ||
| 446 | 446 | ### 12.2 Webhooks |
|
| 447 | 447 | ||
| … | 77 unchanged lines |
||
| 525 | 525 | - **Lifecycle.** A workspace stops after 30 minutes without a connection (configurable) and on request. Its overlay is discarded when it stops: anything not pushed to its workspace bookmark is gone, and the editor says so before stopping. A stopped workspace reopens from its bookmark. The audit log records starts, stops and pushes. |
|
| 526 | 526 | - **Measured** when built: mount time, microVM boot, time to the editor's first paint, and a full read of the Linux tree through the mount. |
|
| 527 | 527 | ||
| 528 | + | **Design notes (2026-10-04), for review before anything is built.** |
|
| 529 | + | ||
| 530 | + | - *Data.* A `workspaces` table: id, account, repository, state (ObjectRef), bookmark (nullable), workspace bookmark name (`ws/{account}/{id}`), status (`queued`, `starting`, `open`, `stopping`, `stopped`, `failed`), runner, job id, secret hash, created, last connection, stopped, and why. A workspace is a row plus one Flow job of kind `workspace`; the job's log is the editor's server log. `GET /api/v1/user/workspaces` lists the caller's; `POST /repos/{o}/{n}/workspaces {"state"|"bookmark"}` opens one; `DELETE /workspaces/{id}` stops it. Limits are per account: three open at once (configurable), and a site cap. |
|
| 531 | + | - *Runner side.* The runner gains a job kind beside pipeline jobs: it boots the pinned workspace rootfs (openvscode-server, `oxy`, the Oxygen VCS extension, a non-root user), mounts the state at `/workspace` through flow-casfs with a writable overlay, runs `oxy` in the microVM against a repository that fetches history lazily over the wire protocol with the job's read-only token, and opens a tunnel back to the hub (the same outbound connection the runner uses for claims) carrying the editor's HTTP and WebSocket traffic. The runner marks the job running when the editor answers on its port, and reports a heartbeat (connections open, last activity) every 30 s in place of log batches; the hub stops the workspace when the heartbeat shows no connection for the idle limit. |
|
| 532 | + | - *Hub side.* `/{owner}/{name}/workspace/{id}/` is a reverse proxy to that tunnel, for the owner's session only, with the workspace secret in a header the runner checks; CSRF does not apply (the editor is same-origin under the hub, and the proxy forwards only to the workspace the path names). Commits: the extension's `oxy commit` writes to the microVM's repository; `oxy push` goes to the workspace bookmark with the job token, and the hub signs the pushed changes as the account's delegate (web edits' path) if the account has delegated to the hub key with `changes,refs`; otherwise the push is refused with the recipe. The bookmark is ordinary afterwards: propose, land, or delete. |
|
| 533 | + | - *Security.* The microVM has no credentials but the job token (read this repository, push this workspace bookmark) and the workspace secret; the account's keys never enter it. Network egress from the microVM is off by default (as for pipeline jobs); a site setting may allow it for package installs, with the audit log recording the choice. The overlay is ephemeral and the rootfs is pinned by hash, so a workspace cannot be left altered. |
|
| 534 | + | - *Costs.* One microVM per open workspace: 2 vCPU and 4 GB by default, from the runner's capacity like any job, so open workspaces compete with pipeline jobs; a runner label `workspace` keeps them on runners meant for it. Idle stop at 30 minutes keeps the fleet small. |
|
| 535 | + | - *Open questions.* Whether the Oxygen VCS extension should talk to the microVM's `oxy` (simplest; works offline in the VM) or to the hub's API (no `oxy` in the VM, but a second code path for every operation): the first. Whether a stopped workspace's overlay should be kept for a day so an unpushed edit is not lost: probably yes, as a tarball artifact of the job, restored on reopen. Whether to allow terminals: yes, it is the point. |
|
| 536 | + | ||
| 528 | 537 | ## 14. Git interoperability |
|
| 529 | 538 | ||
| 530 | 539 | A repository may enable a **Git mirror**: on every landing on selected bookmarks the hub runs `oxy git export` into `<data>/mirrors/<owner>/<name>.git` and serves it read-only over Git's smart HTTP protocol at `/{owner}/{name}.git` (implemented with the `git` binary's `http-backend` behind the hub's authorization, since reimplementing pack negotiation is not the product). This lets GitHub Actions runners, IDEs and tooling that only speak Git read hosted Oxygen repositories. Pushing to a mirror is refused with a message pointing at `oxy`. |
|
| … | 144 unchanged lines |