OXYGENHUB

Proposals from feature/ bookmarks; a landing says who and how 0b20f1ec4007

A proposal's source is any bookmark of the repository by name, so
feature/16-hud works, and owner/name:bookmark naming the repository
itself is its own bookmark; a source that is not a bookmark is named in
the refusal (closes #62).

A landing records who landed the proposal and how (web, or the API
with the token's name), in the proposal, its event, the audit log and
the push record, adding when nobody approved. The page says when the
repository requires no approval and nobody has approved, and Land asks
first (refs #47). A landed proposal keeps showing the changes it landed,
with who landed it, how and when, and reports no blockers (closes #48).

The proposals API takes ?state=open|draft|landed|closed|all, and
oh proposal list takes --state (closes #49).
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-30 13:47 UTC
Signature
✔ signed by Andy McCall
Hash
0b20f1ec4007b9c864a8cebcb8945898b88c391cc0c6d85f8f58aeb60f685b47
Depends on
145306044896 1f1eb06a9009 42a0ba4bac4e 5c6e92fd8d71 93be9ce4653e a1b9869565aa b64e2537d702 d23464c0ac60 de505df5cf8d
Depended on by
668b2ad7495c d7e2c2754807 1000b40a2629 ecac6e8d9b41 8597fb5ca16c c7067c7bda56 2abea30809dd 86a69b53aff0 2a8b621676b4 05f1a85a2842 5d90e0e045e2 f4d6ef418a96 46b5e2c59c6b 16626c0d69c0 ac713df7b38b 4f37afcc3583 2291f8cc9c96 739a763a558c 60f11b71a740 cc1cd197df1d
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.1.0 v0.2.0 v0.3.0 v0.3.1 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
50 atoms, 1 tree operation, 13 files

Browse the tree at this change

added crates/hub/migrations/0024_proposal_landing.sql +5 −0
1 +
-- Who landed a proposal, how (from the web, or through the API with which
2 +
-- token) and when (oxygenhub#47, #48).
3 +
ALTER TABLE proposals ADD COLUMN landed_by TEXT NOT NULL DEFAULT '';
4 +
ALTER TABLE proposals ADD COLUMN landed_via TEXT NOT NULL DEFAULT '';
5 +
ALTER TABLE proposals ADD COLUMN landed_ms INTEGER NOT NULL DEFAULT 0;
modified crates/hub-api/src/lib.rs +12 −0
…
166 unchanged lines
167 167
    pub repository_url: String,
168 168
    /// The fork's clone URL when the source is a fork.
169 169
    pub source_url: Option<String>,
170 +
    /// Who landed it, how and when, once landed.
171 +
    #[serde(default)]
172 +
    pub landed: Option<Landed>,
173 +
}
174 +
175 +
/// How a proposal was landed.
176 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
177 +
pub struct Landed {
178 +
    pub by: String,
179 +
    /// `web`, or `api (token NAME)`.
180 +
    pub via: String,
181 +
    pub when_ms: i64,
170 182
}
171 183
172 184
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
…
746 unchanged lines
modified crates/hub/src/api/mod.rs +26 −5
…
179 unchanged lines
180 180
    pub account: Option<Account>,
181 181
    pub scopes: Vec<String>,
182 182
    pub site: bool,
183 +
    /// The token's name, for records of what a call did.
184 +
    pub token: String,
183 185
}
184 186
185 187
impl Caller {
…
15 unchanged lines
201 203
}
202 204
203 205
pub async fn caller(app: &Shared, headers: &HeaderMap) -> Result<Caller, ApiError> {
204 -
    let Some(t) = oxy_server::bearer_token(headers) else { return Ok(Caller { account: None, scopes: vec![], site: false }) };
206 +
    let Some(t) = oxy_server::bearer_token(headers) else {
207 +
        return Ok(Caller { account: None, scopes: vec![], site: false, token: String::new() });
208 +
    };
205 209
    if app.config.auth.push_tokens.iter().any(|x| auth::mac_equal(x, t)) {
206 210
        let admin = db::accounts::accounts(&app.db).await?.into_iter().find(|a| a.is_admin);
207 -
        return Ok(Caller { account: admin, scopes: vec!["repo:admin".into(), "user:read".into(), "checks:write".into()], site: true });
211 +
        return Ok(Caller {
212 +
            account: admin,
213 +
            scopes: vec!["repo:admin".into(), "user:read".into(), "checks:write".into()],
214 +
            site: true,
215 +
            token: "site token".into(),
216 +
        });
208 217
    }
209 218
    let token = db::accounts::token_by_hash(&app.db, &auth::token_hash(t)).await?;
210 219
    match token {
211 220
        Some(tok) if !tok.expired(db::now_ms()) => {
212 221
            let account = db::accounts::account_by_id(&app.db, tok.account_id).await?.filter(|a| !a.banned);
213 222
            db::accounts::touch_token(&app.db, tok.id).await.ok();
214 -
            Ok(Caller { account, scopes: tok.scope_list().iter().map(|s| s.to_string()).collect(), site: false })
223 +
            Ok(Caller { account, scopes: tok.scope_list().iter().map(|s| s.to_string()).collect(), site: false, token: tok.name.clone() })
215 224
        }
216 225
        _ => Err(ApiError(StatusCode::UNAUTHORIZED, "unknown or expired token".into())),
217 226
    }
…
465 unchanged lines
683 692
684 693
// --------------------------------------------------------------- proposals
685 694
695 +
/// `GET /repos/{o}/{n}/proposals?state=open|draft|landed|closed|all`:
696 +
/// open ones (drafts too) by default.
686 697
async fn proposals(
687 698
    State(app): State<Shared>,
688 699
    headers: HeaderMap,
689 700
    Path((owner, name)): Path<(String, String)>,
701 +
    Query(q): Query<IssuesQuery>,
690 702
) -> Result<Json<Vec<Proposal>>, ApiError> {
691 703
    let c = caller(&app, &headers).await?;
692 704
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
705 +
    let states: &[&str] = match q.state.as_deref().unwrap_or("open") {
706 +
        "open" => &["open", "draft"],
707 +
        "draft" => &["draft"],
708 +
        "landed" => &["landed"],
709 +
        "closed" => &["closed"],
710 +
        "all" => &["open", "draft", "landed", "closed"],
711 +
        other => return Err(ApiError(StatusCode::BAD_REQUEST, format!("state is open, draft, landed, closed or all, not {other:?}"))),
712 +
    };
693 713
    let mut out = Vec::new();
694 -
    for p in db::proposals::list(&app.db, row.id, &["open", "draft"]).await? {
714 +
    for p in db::proposals::list(&app.db, row.id, states).await? {
695 715
        let loaded = crate::web::proposals::load(&app, &repo, p).await?;
696 716
        let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?;
697 717
        out.push(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, None).await);
…
28 unchanged lines
726 746
    let c = caller(&app, &headers).await?;
727 747
    let me = c.require("repo:write")?.clone();
728 748
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?;
729 -
    match crate::web::proposals::land_as(&app, &repo, &row, &me, number).await {
749 +
    let via = format!("api (token {})", c.token);
750 +
    match crate::web::proposals::land_as(&app, &repo, &row, &me, number, &via).await {
730 751
        Ok(_) => proposal(State(app), headers, Path((owner, name, number))).await,
731 752
        Err(crate::web::proposals::Landing::NotFound) => Err(not_found("no such proposal")),
732 753
        Err(crate::web::proposals::Landing::Refused(m)) => Err(ApiError(StatusCode::CONFLICT, m)),
…
955 unchanged lines
modified crates/hub/src/db/proposals.rs +20 −0
…
24 unchanged lines
25 25
    pub projected_changes: String,
26 26
    pub conflicted: bool,
27 27
    pub landed_state: String,
28 +
    pub landed_by: String,
29 +
    pub landed_via: String,
30 +
    pub landed_ms: i64,
28 31
    pub created_ms: i64,
29 32
    pub updated_ms: i64,
30 33
}
…
146 unchanged lines
177 180
        .bind(id)
178 181
        .execute(pool)
179 182
        .await?;
183 +
    Ok(())
184 +
}
185 +
186 +
/// Mark a proposal landed, with who landed it and how.
187 +
pub async fn set_landed(pool: &Pool, id: i64, landed_state: &str, by: &str, via: &str) -> sqlx::Result<()> {
188 +
    let now = now_ms();
189 +
    sqlx::query(
190 +
        "UPDATE proposals SET state = 'landed', landed_state = ?, landed_by = ?, landed_via = ?, landed_ms = ?, updated_ms = ? WHERE id = ?",
191 +
    )
192 +
    .bind(landed_state)
193 +
    .bind(by)
194 +
    .bind(via)
195 +
    .bind(now)
196 +
    .bind(now)
197 +
    .bind(id)
198 +
    .execute(pool)
199 +
    .await?;
180 200
    Ok(())
181 201
}
182 202
…
116 unchanged lines
modified crates/hub/src/web/proposals.rs +54 −11
…
145 unchanged lines
146 146
}
147 147
148 148
/// `owner/name:bookmark` for a fork, else a bookmark of this repository.
149 +
/// A proposal's source: a bookmark of this repository by name (which may
150 +
/// hold `/`, as `feature/16-hud` does), or `owner/name:bookmark` of a fork;
151 +
/// `owner/name:bookmark` naming this repository is its own bookmark.
149 152
fn parse_source(app: &Shared, repo: &RepoName, source: &str) -> Option<(Option<RepoName>, String)> {
153 +
    let plain = |b: &str| !b.is_empty() && !b.chars().any(|c| c.is_control() || c == ':');
150 154
    if let Some((slug, bookmark)) = source.split_once(':') {
151 155
        let fork = RepoName::parse(slug)?;
152 -
        if !app.repos.exists(&fork) || fork == *repo {
156 +
        if !plain(bookmark) {
153 157
            return None;
158 +
        }
159 +
        if fork == *repo {
160 +
            return Some((None, bookmark.to_string()));
154 161
        }
162 +
        if !app.repos.exists(&fork) {
163 +
            return None;
164 +
        }
155 165
        return Some((Some(fork), bookmark.to_string()));
156 166
    }
157 -
    crate::repos::names::valid_segment(source).then(|| (None, source.to_string()))
167 +
    plain(source).then(|| (None, source.to_string()))
158 168
}
159 169
160 170
async fn preview_for(
…
90 unchanged lines
251 261
    let Some((fork, bookmark)) = parse_source(app, repo, source.trim()) else {
252 262
        return Err("The source is a bookmark of this repository, or owner/name:bookmark of a fork.".into());
253 263
    };
264 +
    if fork.is_none() && !bookmarks.contains(&bookmark) {
265 +
        return Err(format!("There is no bookmark {bookmark} in this repository."));
266 +
    }
254 267
    if fork.is_none() && bookmark == target {
255 268
        return Err("The source and the target are the same bookmark.".into());
256 269
    }
…
175 unchanged lines
432 445
            let owners = pl::owners_for(&target_loaded, &touched);
433 446
            let sealing = pl::sealing_keys(r, &p.hashes)?.into_iter().map(|k| k.to_hex()).collect();
434 447
            let landed_already = !p.hashes.is_empty() && p.hashes.iter().all(|h| target_loaded.state.contains(h));
448 +
            // A landed proposal's changes are in its target now: it shows
449 +
            // the ones it landed, as recorded when it landed.
450 +
            let (changes, hashes) = if r2.state == "landed" && p.hashes.is_empty() {
451 +
                let hashes: Vec<ChangeHash> = r2.projected_changes().iter().filter_map(|h| ChangeHash::from_hex(h)).collect();
452 +
                let labels = HashMap::new();
453 +
                let changes = hashes
454 +
                    .iter()
455 +
                    .filter_map(|h| Some(view::change_view_pub(r, h, &r.store().get_change_meta(h).ok()?, false, &labels)))
456 +
                    .collect();
457 +
                (changes, hashes)
458 +
            } else {
459 +
                (p.changes, p.hashes)
460 +
            };
435 461
            Ok(Loaded {
436 462
                row: r2,
437 -
                changes: p.changes,
438 -
                hashes: p.hashes,
463 +
                changes,
464 +
                hashes,
439 465
                heads: p.heads,
440 466
                target_heads: p.target_heads.clone(),
441 467
                state: p.state.to_hex(),
…
11 unchanged lines
453 479
            })
454 480
        })
455 481
        .await?;
456 -
    // Remember the projection for the list, the API and other proposals' stacking checks.
482 +
    // Remember the projection for the list, the API and other proposals'
483 +
    // stacking checks, while the proposal is open: a landed one keeps what
484 +
    // it landed.
485 +
    if !loaded.row.is_open() {
486 +
        return Ok(loaded);
487 +
    }
457 488
    let heads_csv = loaded.heads.iter().map(|h| h.to_hex()).collect::<Vec<_>>().join(",");
458 489
    let changes_csv = loaded.hashes.iter().map(|h| h.to_hex()).collect::<Vec<_>>().join(",");
459 490
    pdb::set_projection(&app.db, loaded.row.id, &loaded.state, &heads_csv, &loaded.target_state, &changes_csv, loaded.conflicted)
…
38 unchanged lines
498 529
        .collect();
499 530
    let requirements = pdb::requirements(&app.db, repo_id).await?;
500 531
    let mut blockers = Vec::new();
532 +
    // Only an open proposal can be held back.
533 +
    if !loaded.row.is_open() {
534 +
        return Ok(Verdict { reviews, approvals, blockers, depends_on: vec![], requirements });
535 +
    }
501 536
    if loaded.row.state == "draft" {
502 537
        blockers.push("the proposal is a draft".into());
503 538
    }
…
481 unchanged lines
985 1020
    let me = require(&viewer, &shell)?;
986 1021
    csrf(&viewer, &f.csrf, &shell)?;
987 1022
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
988 -
    match land_as(&app, &repo, &row, &me.account, number).await {
1023 +
    match land_as(&app, &repo, &row, &me.account, number, "web").await {
989 1024
        Ok(_) => Ok(back(&owner, &name, number, "conversation", Some("Landed.".into()), None)),
990 1025
        Err(Landing::NotFound) => Err(Error::not_found(shell)),
991 1026
        Err(Landing::Refused(m)) => Ok(back(&owner, &name, number, "conversation", None, Some(m))),
…
9 unchanged lines
1001 1036
/// Land proposal `number` as `me` (hub spec §8.4), who has write access:
1002 1037
/// refused while it is not open or has blockers. Moves the target bookmark
1003 1038
/// (attested by the hub key on `me`'s behalf when they delegated to it),
1004 -
/// then records, announces and audits the landing. Returns the new state.
1039 +
/// then records, announces and audits the landing, saying how it was done
1040 +
/// (`via`: `web`, or `api (token NAME)`). Returns the new state.
1005 1041
pub async fn land_as(
1006 1042
    app: &Shared,
1007 1043
    repo: &RepoName,
1008 1044
    row: &db::repos::RepoRow,
1009 1045
    me: &accounts::Account,
1010 1046
    number: i64,
1047 +
    via: &str,
1011 1048
) -> Result<String, Landing> {
1012 1049
    let (owner, name) = (row.owner.clone(), row.name.clone());
1013 1050
    let refused = |e: String| Landing::Refused(e);
…
29 unchanged lines
1043 1080
        }
1044 1081
    };
1045 1082
    let state = result.map_err(|e| refused(e.to_string()))?;
1046 -
    pdb::set_state(&app.db, p.id, "landed", &state).await.map_err(|e| refused(e.to_string()))?;
1047 -
    pdb::add_event(&app.db, p.id, &me.username, "landed", "from the hub").await.ok();
1083 +
    pdb::set_landed(&app.db, p.id, &state, &me.username, via).await.map_err(|e| refused(e.to_string()))?;
1084 +
    let how = if v.approvals.is_empty() { format!("{via}, with no approval") } else { via.to_string() };
1085 +
    pdb::add_event(&app.db, p.id, &me.username, "landed", &how).await.ok();
1048 1086
    crate::hooks::emit(
1049 1087
        app,
1050 1088
        row,
…
20 unchanged lines
1071 1109
        let messages: Vec<(String, String)> = loaded.changes.iter().map(|c| (c.hash.clone(), format!("{}\n{}", c.title, c.body))).collect();
1072 1110
        super::issues::close_from_messages(app, row, &messages).await;
1073 1111
    }
1074 -
    accounts::audit(&app.db, Some(me), "proposal.landed", &format!("{owner}/{name}#{number}"), "").await.ok();
1112 +
    accounts::audit(&app.db, Some(me), "proposal.landed", &format!("{owner}/{name}#{number}"), &how).await.ok();
1075 1113
    db::repos::record_push(
1076 1114
        &app.db,
1077 1115
        row.id,
…
1 unchanged lines
1079 1117
        &[db::repos::PushOutcome {
1080 1118
            reference: p.target.clone(),
1081 1119
            ok: true,
1082 -
            reason: format!("landed #{number}"),
1120 +
            reason: format!("landed #{number} ({how})"),
1083 1121
            old_state: loaded.target_state.clone(),
1084 1122
            new_state: state.clone(),
1085 1123
            heads: loaded.heads.len() as i64,
…
118 unchanged lines
1204 1242
        url: format!("{}/{owner}/{name}/proposal/{}", app.config.base_url(), p.number),
1205 1243
        repository_url: format!("{}/{owner}/{name}", app.config.base_url()),
1206 1244
        source_url,
1245 +
        landed: (p.state == "landed" && p.landed_ms > 0).then(|| hub_api::Landed {
1246 +
            by: p.landed_by.clone(),
1247 +
            via: p.landed_via.clone(),
1248 +
            when_ms: p.landed_ms,
1249 +
        }),
1207 1250
    }
1208 1251
}
1209 1252
…
7 unchanged lines
modified crates/hub/tests/api.rs +23 −0
…
374 unchanged lines
375 375
    assert_eq!(api(app, "GET", "/api/v1/notifications", Some(&bob_user), None).await.1["unread"], 0);
376 376
    // Another account's notification is not the caller's to clear, and a token without user:read sees nothing.
377 377
    assert_eq!(api(app, "GET", "/api/v1/notifications", Some(&bob_rw), None).await.0, StatusCode::FORBIDDEN);
378 +
379 +
    // A source bookmark with `/` in its name, as a feature/N-name workflow
380 +
    // makes them: by name, and as this repository's owner/name:bookmark.
381 +
    for (b, file) in [("feature/1-one", "c.txt"), ("feature/2-two", "d.txt")] {
382 +
        client.bookmark_create(b, Some(&[base])).unwrap();
383 +
        client.bookmark_switch(b, false).unwrap();
384 +
        write(work.path(), file, b"c\n");
385 +
        client.snapshot("t").unwrap();
386 +
        client.commit(b, &[]).unwrap();
387 +
        assert!(client.push_bookmark("origin", b, false).unwrap().iter().all(|r| r.ok));
388 +
    }
389 +
    for source in ["feature/1-one", "andy/demo:feature/2-two"] {
390 +
        let body = format!(r#"{{"title":"Slash","target":"main","source":"{source}"}}"#);
391 +
        let (st, v) = api(app, "POST", "/api/v1/repos/andy/demo/proposals", Some(&bob_rw), Some(&body)).await;
392 +
        assert_eq!(st, StatusCode::CREATED, "{source}: {v}");
393 +
        let n = v["number"].as_i64().unwrap();
394 +
        let page = call(app, "GET", &format!("/andy/demo/proposal/{n}"), None, None, None, None).await;
395 +
        assert_eq!(page.status, StatusCode::OK);
396 +
        assert!(page.body.contains("feature/"), "the page names its source");
397 +
    }
398 +
    let body = r#"{"title":"None","target":"main","source":"feature/3-none"}"#;
399 +
    let (st, v) = api(app, "POST", "/api/v1/repos/andy/demo/proposals", Some(&bob_rw), Some(body)).await;
400 +
    assert!(st.is_client_error() && v.to_string().contains("no bookmark feature/3-none"), "{st} {v}");
378 401
}
379 402
380 403
#[tokio::test(flavor = "multi_thread")]
…
754 unchanged lines
modified crates/hub/tests/edits.rs +3 −0
…
232 unchanged lines
233 233
    assert_eq!(r.location.as_deref(), Some("/andy/demo/proposal/2"), "{}", r.body);
234 234
    let page = send(app, "GET", "/andy/demo/proposal/2", Some(&andy), None).await;
235 235
    assert!(page.body.contains("ready to land") && page.body.contains(">LAND<"), "{}", page.body);
236 +
    // Nobody approved, and the repository requires no approval: the page says so and Land asks first.
237 +
    assert!(page.body.contains("settings</a> require none."), "{}", page.body);
238 +
    assert!(page.body.contains("without any approval?"), "{}", page.body);
236 239
    let r = send(app, "POST", "/andy/demo/proposal/2/land", Some(&andy), Some(&format!("_csrf={}", csrf_of(&page.body)))).await;
237 240
    // The action redirects back (so a reload never repeats it), saying so.
238 241
    let back = r.location.clone().unwrap_or_default();
…
237 unchanged lines
modified crates/hub/tests/proposals.rs +19 −1
…
236 unchanged lines
237 237
    let heads = app.repos.read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().find(|b| b.name == "main").unwrap().heads)).await.unwrap();
238 238
    assert_eq!(heads, vec![feat]);
239 239
    assert!(get(app, "/andy/demo/proposals?state=landed", None).await.body.contains("#1"));
240 -
    assert!(get(app, "/andy/demo/pushes", None).await.body.contains("landed #1"));
240 +
    assert!(get(app, "/andy/demo/pushes", None).await.body.contains("landed #1 (web)"));
241 +
    // Landed, it still shows what it landed, by whom and how, and nothing holds it back.
242 +
    let api = send(app, "GET", "/api/v1/repos/andy/demo/proposals/1", None, None, Some("site")).await;
243 +
    let v: serde_json::Value = serde_json::from_str(&api.body).unwrap();
244 +
    assert_eq!(v["state"], "landed", "{}", api.body);
245 +
    assert_eq!(v["changes"][0]["hash"], feat.to_hex(), "{}", api.body);
246 +
    assert_eq!(v["blockers"], serde_json::json!([]), "{}", api.body);
247 +
    assert_eq!(v["landed"]["by"], "andy");
248 +
    assert_eq!(v["landed"]["via"], "web");
249 +
    let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await;
250 +
    assert!(page.body.contains("by <a href=\"/andy\">andy</a>, web"), "{}", page.body);
251 +
    // Lists by state through the API.
252 +
    let list = |q: &'static str| async move {
253 +
        let r = send(app, "GET", &format!("/api/v1/repos/andy/demo/proposals{q}"), None, None, Some("site")).await;
254 +
        serde_json::from_str::<serde_json::Value>(&r.body).unwrap().as_array().unwrap().len()
255 +
    };
256 +
    assert_eq!(list("").await, 0, "open ones only by default");
257 +
    assert_eq!(list("?state=landed").await, 1);
258 +
    assert_eq!(list("?state=all").await, 1);
241 259
242 260
    // A proposal from a fork: a copy of the repository with the same id.
243 261
    let fork = RepoName::parse("bob/demo").unwrap();
…
41 unchanged lines
modified crates/oh/src/main.rs +8 −5
…
144 unchanged lines
145 145
146 146
#[derive(Subcommand)]
147 147
enum ProposalCmd {
148 -
    /// Open proposals
149 -
    List,
148 +
    /// Open proposals (or --state draft|landed|closed|all)
149 +
    List {
150 +
        #[arg(long, default_value = "open")]
151 +
        state: String,
152 +
    },
150 153
    /// One proposal: its changes, approvals and whether it can land
151 154
    View { number: i64 },
152 155
    /// Propose a bookmark's changes for another bookmark
…
281 unchanged lines
434 437
fn proposal(c: &Client, t: &Target, cmd: ProposalCmd, json: bool) -> Result<i32> {
435 438
    let base = t.repo_path()?;
436 439
    match cmd {
437 -
        ProposalCmd::List => {
438 -
            let path = format!("{base}/proposals");
440 +
        ProposalCmd::List { state } => {
441 +
            let path = format!("{base}/proposals?state={state}");
439 442
            if json {
440 443
                println!("{}", c.get_text(&path)?);
441 444
                return Ok(0);
442 445
            }
443 446
            let list: Vec<Proposal> = c.get(&path)?;
444 447
            if list.is_empty() {
445 -
                println!("{}", ui::dim("No open proposals."));
448 +
                println!("{}", ui::dim(&format!("No {} proposals.", if state == "all" { "" } else { &state }).replace("  ", " ")));
446 449
            }
447 450
            for p in &list {
448 451
                println!("{}", proposal_line(p));
…
279 unchanged lines
modified docs/oh.md +1 −1
…
36 unchanged lines
37 37
| `oh issue create -t TITLE [-b BODY \| -F FILE] [-l LABEL]…` | Open an issue |
38 38
| `oh issue close N [--reason not_planned]`, `oh issue reopen N` | Close or reopen |
39 39
| `oh issue comment N -b BODY` | Comment |
40 -
| `oh proposal list`, `oh proposal view N` | Proposals, with changes, approvals and blockers |
40 +
| `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 |
41 41
| `oh proposal create -t TITLE --from BOOKMARK [--into BOOKMARK] [--draft]` | Propose a bookmark's changes |
42 42
| `oh proposal checks N` | Checks on the projected state |
43 43
| `oh proposal land N` | Land it on its target, as the page's Land button does |
…
16 unchanged lines
modified docs/spec.md +1 −1
…
434 unchanged lines
435 435
436 436
### 12.1 API
437 437
438 -
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`, `/proposals/{n}/comments|reviews|checks|files`, `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot), `/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`, `/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, `/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.
438 +
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`, `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`, `/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, `/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.
439 439
440 440
### 12.2 Webhooks
441 441
…
205 unchanged lines
modified templates/proposal.html +5 −2
…
153 unchanged lines
154 154
<aside class="side">
155 155
  <h3>Landing</h3>
156 156
  {% if p.state == "landed" %}<p><span class="sig sig-valid">✔</span> landed{% if !p.landed_state.is_empty() %} at <code>{{ p.landed_state|short }}</code>{% endif %}</p>
157 +
  {% if !p.landed_by.is_empty() %}<p class="small mute">by <a href="/{{ p.landed_by }}">{{ p.landed_by }}</a>, {{ p.landed_via }}, {{ p.landed_ms|ago }}</p>{% endif %}
157 158
  {% else if p.state == "closed" %}<p class="mute">closed</p>
158 159
  {% else %}
159 -
    {% if blockers.is_empty() %}<p><span class="sig sig-valid">✔</span> ready to land</p>{% else %}<ul class="plain small">{% for b in blockers %}<li><span class="sig sig-invalid">✘</span> {{ b }}</li>{% endfor %}</ul>{% endif %}
160 +
    {% if blockers.is_empty() %}<p><span class="sig sig-valid">✔</span> ready to land</p>
161 +
    {% if requirements.required_approvals == 0 && approvals.is_empty() %}<p class="small warn-text">No approval yet, and this repository's <a href="{{ repo.url }}/settings">settings</a> require none.</p>{% endif %}
162 +
    {% else %}<ul class="plain small">{% for b in blockers %}<li><span class="sig sig-invalid">✘</span> {{ b }}</li>{% endfor %}</ul>{% endif %}
160 163
    {% if can_land %}
161 -
      {% if hub_can_land %}<form method="post" action="{{ repo.url }}/proposal/{{ p.number }}/land"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit">LAND</button></form>
164 +
      {% if hub_can_land %}<form method="post" action="{{ repo.url }}/proposal/{{ p.number }}/land"{% if approvals.is_empty() %} onsubmit="return confirm('Land #{{ p.number }} without any approval?')"{% endif %}><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit">LAND</button></form>
162 165
      {% else %}<p class="small">This repository has a trust policy, so land from a client with your key:</p><p class="secret">oxy proposal land {{ shell.base_url }}{{ repo.url }}/proposal/{{ p.number }}</p>{% endif %}
163 166
    {% endif %}
164 167
  {% endif %}
…
29 unchanged lines
modified templates/style.css +1 −0
…
104 unchanged lines
105 105
.tag { display: inline-block; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--acc); background: var(--soft); padding: 1px 6px; vertical-align: middle; }
106 106
.tag.bad { color: var(--bad); }
107 107
.tag.warn { color: var(--warn); }
108 +
.warn-text { color: var(--warn); }
108 109
.tag.head { color: var(--now-ink); background: var(--now); }
109 110
.chip { font-family: "IBM Plex Mono", ui-monospace, monospace; font-size: 12px; background: var(--soft); padding: 1px 6px; border: 1px solid var(--line); }
110 111
.lbl { display: inline-block; font-size: 11px; padding: 1px 7px; margin-right: 4px; border: 1px solid; vertical-align: middle; }
…
529 unchanged lines