OXYGENHUB

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