A draft proposal can be made ready from oh and the API (refs #313) 09ffa7b600a9
The API's edit took only close and reopen, so a draft could leave draft only on the page; draft: false was not a field and was ignored with a 200. It now takes the page's transitions, by state or by draft, and oh proposal ready N (--undo) uses them.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 73fbcb9910d8
- When
- · 2026-10-25 17:25 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
09ffa7b600a913f425e49a1c43cc9ca255b7cbf28d256bd91c16d1aa76392a14- Depends on
- 2291f8cc9c96 5c6e92fd8d71 62ceb2eec18c 71e65fcf95ab 739a763a558c 73c01b3448ee a7e6e8e6094c b5668108487f d23464c0ac60 de505df5cf8d
- Depended on by
- 5a9a6e9a15bc a36c79fd9493
- In bookmarks
- api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
- In tags
- v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 16 atoms, 0 tree operations, 5 files
Browse the tree at this change
modified CHANGELOG.md +1 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **A draft proposal can be made ready from `oh` and the API (#313):** `oh proposal ready N` (and `--undo` to turn it back into a draft); `PATCH …/proposals/{n}` takes `state: "open"` for a draft and `state: "draft"` for an open one, as the page's buttons do, and `draft: false|true` in the words a proposal is created with. `{"draft": false}` used to answer 200 and change nothing. |
|
| 7 | 8 | ## 0.9.2 (2026-10-25) |
|
| 8 | 9 | ||
| 9 | 10 | - **`GET /api/v1/user/proposals?landable=true` (#311):** also lists the open proposals the caller could land now (in repositories they can write to, with no blockers, as each proposal's `landable` says), for the Android app to say when there is something to land. Only when asked for, since it works out each one's projected state. |
|
| … | 311 unchanged lines |
modified crates/hub-api/src/lib.rs +9 −2
| … | 1078 unchanged lines |
||
| 1079 | 1079 | /// The whole set of labels, by name (triage); oxygenhub#80. |
|
| 1080 | 1080 | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 1081 | 1081 | pub labels: Option<Vec<String>>, |
|
| 1082 | - | /// `closed` or `open` (reopen), by its author or with triage access, |
|
| 1083 | - | /// as the page's Close and Reopen do (oxygenhub#140). |
|
| 1082 | + | /// `closed`, `open` (reopen a closed one, or make a draft ready for |
|
| 1083 | + | /// review) or `draft` (turn an open one back into a draft), by its |
|
| 1084 | + | /// author or with triage access, as the page's buttons do |
|
| 1085 | + | /// (oxygenhub#140, #313). |
|
| 1084 | 1086 | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 1085 | 1087 | pub state: Option<String>, |
|
| 1088 | + | /// `false` makes a draft ready for review, `true` turns an open |
|
| 1089 | + | /// proposal back into a draft: the same as `state`, in the words a |
|
| 1090 | + | /// proposal is created with (#313). |
|
| 1091 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 1092 | + | pub draft: Option<bool>, |
|
| 1086 | 1093 | } |
|
| 1087 | 1094 | ||
| 1088 | 1095 | /// `PATCH /repos/{owner}/{name}/releases/{tag}`: what to change; fields |
|
| … | 453 unchanged lines |
modified crates/hub/src/api/mod.rs +18 −5
| … | 1356 unchanged lines |
||
| 1357 | 1357 | let names: Vec<&str> = want.iter().map(|l| l.name.as_str()).collect(); |
|
| 1358 | 1358 | db::proposals::add_event(&app.db, p.id, &me.username, "set labels", &names.join(", ")).await.ok(); |
|
| 1359 | 1359 | } |
|
| 1360 | - | if let Some(want) = body.state.as_deref() { |
|
| 1360 | + | // `draft` says the same as `state`, in the words of creation (#313). |
|
| 1361 | + | let want = match (body.state.as_deref(), body.draft) { |
|
| 1362 | + | (Some(s), Some(d)) if (s == "draft") != d => { |
|
| 1363 | + | return Err(ApiError(StatusCode::BAD_REQUEST, format!("state {s:?} and draft {d} disagree"))); |
|
| 1364 | + | } |
|
| 1365 | + | (Some(s), _) => Some(s), |
|
| 1366 | + | (None, Some(true)) => Some("draft"), |
|
| 1367 | + | (None, Some(false)) => Some("open"), |
|
| 1368 | + | (None, None) => None, |
|
| 1369 | + | }; |
|
| 1370 | + | if let Some(want) = want { |
|
| 1361 | 1371 | if Some(me.id) != p.author_id && role < Role::Triage { |
|
| 1362 | 1372 | return Err(ApiError( |
|
| 1363 | 1373 | StatusCode::FORBIDDEN, |
|
| 1364 | - | "only the proposal's author or someone with triage access can close or reopen it".into(), |
|
| 1374 | + | "only the proposal's author or someone with triage access can close, reopen or change whether it is a draft".into(), |
|
| 1365 | 1375 | )); |
|
| 1366 | 1376 | } |
|
| 1377 | + | // The page's transitions: a draft is made ready (open), and an open |
|
| 1378 | + | // proposal turned back into a draft. |
|
| 1367 | 1379 | let new_state = match (want, p.state.as_str()) { |
|
| 1368 | 1380 | ("closed", "open" | "draft") => "closed", |
|
| 1369 | - | ("open", "closed") => "open", |
|
| 1381 | + | ("open", "closed" | "draft") => "open", |
|
| 1382 | + | ("draft", "open") => "draft", |
|
| 1370 | 1383 | (w, now) if w == now => now, |
|
| 1371 | - | ("closed" | "open", now) => { |
|
| 1384 | + | ("closed" | "open" | "draft", now) => { |
|
| 1372 | 1385 | return Err(ApiError(StatusCode::CONFLICT, format!("a proposal that is {now} cannot become {want}"))) |
|
| 1373 | 1386 | } |
|
| 1374 | - | _ => return Err(ApiError(StatusCode::BAD_REQUEST, "state is closed or open".into())), |
|
| 1387 | + | _ => return Err(ApiError(StatusCode::BAD_REQUEST, "state is closed, open or draft".into())), |
|
| 1375 | 1388 | }; |
|
| 1376 | 1389 | if new_state != p.state { |
|
| 1377 | 1390 | db::proposals::set_state(&app.db, p.id, new_state, "").await?; |
|
| … | 1673 unchanged lines |
modified crates/hub/tests/proposals.rs +14 −0
| … | 680 unchanged lines |
||
| 681 | 681 | assert_eq!(patch(r#"{"state":"landed"}"#, Some("site")).await.0, StatusCode::BAD_REQUEST, "landing has its own endpoint"); |
|
| 682 | 682 | assert_ne!(patch(r#"{"state":"closed"}"#, None).await.0, StatusCode::OK, "not without a token"); |
|
| 683 | 683 | assert_eq!(state().await, "open"); |
|
| 684 | + | // In and out of draft (#313), as the page's Convert to draft and Ready |
|
| 685 | + | // for review do, by `state` or by `draft` as at creation. |
|
| 686 | + | assert_eq!(patch(r#"{"draft":true}"#, Some("site")).await.0, StatusCode::OK); |
|
| 687 | + | assert_eq!(state().await, "draft"); |
|
| 688 | + | let (st, body) = patch(r#"{"draft":false}"#, Some("site")).await; |
|
| 689 | + | assert_eq!(st, StatusCode::OK, "{body}"); |
|
| 690 | + | assert_eq!(state().await, "open", "a draft made ready"); |
|
| 691 | + | assert_eq!(patch(r#"{"state":"draft"}"#, Some("site")).await.0, StatusCode::OK); |
|
| 692 | + | assert_eq!(state().await, "draft"); |
|
| 693 | + | assert_eq!(patch(r#"{"state":"open"}"#, Some("site")).await.0, StatusCode::OK); |
|
| 694 | + | assert_eq!(state().await, "open"); |
|
| 695 | + | assert_eq!(patch(r#"{"state":"open","draft":true}"#, Some("site")).await.0, StatusCode::BAD_REQUEST, "they disagree"); |
|
| 696 | + | patch(r#"{"state":"closed"}"#, Some("site")).await; |
|
| 697 | + | assert_eq!(patch(r#"{"draft":true}"#, Some("site")).await.0, StatusCode::CONFLICT, "a closed proposal is reopened, not drafted"); |
|
| 684 | 698 | } |
|
| 685 | 699 | ||
| 686 | 700 | /// Landing over checks (#261): with required checks not passed, a |
|
| … | 421 unchanged lines |
modified crates/oh/src/main.rs +19 −1
| … | 366 unchanged lines |
||
| 367 | 367 | }, |
|
| 368 | 368 | /// Reopen a closed proposal |
|
| 369 | 369 | Reopen { number: i64 }, |
|
| 370 | + | /// Take a draft proposal out of draft, ready for review (its author or triage) |
|
| 371 | + | Ready { |
|
| 372 | + | number: i64, |
|
| 373 | + | /// Turn an open proposal back into a draft instead |
|
| 374 | + | #[arg(long)] |
|
| 375 | + | undo: bool, |
|
| 376 | + | }, |
|
| 370 | 377 | } |
|
| 371 | 378 | ||
| 372 | 379 | #[derive(Subcommand)] |
|
| … | 969 unchanged lines |
||
| 1342 | 1349 | if title.is_none() && body.is_none() { |
|
| 1343 | 1350 | bail!("say what to change: --title, --body or --body-file"); |
|
| 1344 | 1351 | } |
|
| 1345 | - | let edit = hub_api::ProposalEdit { title, body, labels: None, state: None }; |
|
| 1352 | + | let edit = hub_api::ProposalEdit { title, body, ..Default::default() }; |
|
| 1346 | 1353 | let p: Proposal = c.send("PATCH", &format!("{base}/proposals/{number}"), &edit)?; |
|
| 1347 | 1354 | if json { |
|
| 1348 | 1355 | println!("{}", serde_json::to_string(&p)?); |
|
| … | 64 unchanged lines |
||
| 1413 | 1420 | println!("{}", serde_json::to_string(&p)?); |
|
| 1414 | 1421 | } else { |
|
| 1415 | 1422 | println!("{} reopened #{} {}", ui::ok("✔"), p.number, p.title); |
|
| 1423 | + | } |
|
| 1424 | + | } |
|
| 1425 | + | ProposalCmd::Ready { number, undo } => { |
|
| 1426 | + | let edit = hub_api::ProposalEdit { draft: Some(undo), ..Default::default() }; |
|
| 1427 | + | let p: Proposal = c.send("PATCH", &format!("{base}/proposals/{number}"), &edit)?; |
|
| 1428 | + | if json { |
|
| 1429 | + | println!("{}", serde_json::to_string(&p)?); |
|
| 1430 | + | } else if undo { |
|
| 1431 | + | println!("{} #{} {} is a draft again", ui::ok("✔"), p.number, p.title); |
|
| 1432 | + | } else { |
|
| 1433 | + | println!("{} #{} {} is ready for review", ui::ok("✔"), p.number, p.title); |
|
| 1416 | 1434 | } |
|
| 1417 | 1435 | } |
|
| 1418 | 1436 | ProposalCmd::Checks { number } => { |
|
| … | 702 unchanged lines |