OXYGENHUB

Landed and closed proposals are listed as recorded, not projected again; the list takes a limit (closes #331) d7e2c2754807

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key f3553ca5b43c
When
· 2026-10-26 16:57 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
d7e2c27548078f2ce2a76373feabfe92c097ba0cedb6d6ed76a0e1f4d204f57a
Depends on
0b20f1ec4007 34388e612963 3571c519ada6 46b5e2c59c6b 5c6e92fd8d71 739a763a558c a7e6e8e6094c b5668108487f b64e2537d702 d23464c0ac60 de505df5cf8d
Depended on by
1c52d940b4a0 668b2ad7495c d9d0d314fdcb 4b49c41599f3
In bookmarks
diffusion-pin-bookmark 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.7 v0.9.8 v0.9.9
Edits
18 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified CHANGELOG.md +1 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Listing landed proposals is quick, and pages (#331).** `oh proposal list --state landed` on a repository with a few hundred proposals timed out: the API worked out every proposal's projected state again, landed and closed ones too. Those now come from what was recorded, and the list takes `limit` (`oh proposal list --limit N`, 30 by default), newest first.
7 8
- **The API refuses a field an edit does not take (#330).** `PATCH` on an issue, proposal, project, milestone, release or drop answered 200 to a field it does not know and changed nothing (`{"ready": true}` on a draft proposal, say), which looked like success. It now answers 422 naming the field and the ones it takes.
8 9
- **Mail reads `[OxygenHub]`, in a wider message (#334).** A notification's subject was tagged with the site's name (`[Oxygenhub on Earth]`); mail now goes by the From address's display name (`OxygenHub <noreply@…>`) in the subject and the footer, falling back to the site's name, so the site keeps its own name (which its apt repository is labelled with). The message card is 720px wide rather than 560.
9 10
- **An agent follows its owner's newest identity (oxygen#82).** A keyless certificate's answer now carries `author_chain`: the grant owner's newest linked identity and its predecessors back to the grant's key, so an agent home with `author.for` authors as the owner's current identity, not one several rotations old.
…
349 unchanged lines
modified crates/hub/src/api/mod.rs +19 −8
…
1284 unchanged lines
1285 1285
1286 1286
// --------------------------------------------------------------- proposals
1287 1287
1288 -
/// `GET /repos/{o}/{n}/proposals?state=open|draft|landed|closed|all`:
1289 -
/// open ones (drafts too) by default.
1288 +
/// `GET /repos/{o}/{n}/proposals?state=open|draft|landed|closed|all&limit=N`:
1289 +
/// open ones (drafts too) by default, newest first, the first `limit` if
1290 +
/// given (#331).
1290 1291
async fn proposals(
1291 1292
    State(app): State<Shared>,
1292 1293
    headers: HeaderMap,
1293 1294
    Path((owner, name)): Path<(String, String)>,
1294 -
    Query(q): Query<IssuesQuery>,
1295 +
    Query(q): Query<ProposalsQuery>,
1295 1296
) -> Result<Json<Vec<Proposal>>, ApiError> {
1296 1297
    let c = caller(&app, &headers).await?;
1297 1298
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
…
8 unchanged lines
1306 1307
    // Each proposal's projected state is worked out from the store, which
1307 1308
    // for a bucket-backed repository is many slow round trips: four at a
1308 1309
    // time, in order, each under the repository's read lock only. One at
1309 -
    // a time took 28 s for a repository on GCS (oxygenhub#171).
1310 +
    // a time took 28 s for a repository on GCS (oxygenhub#171). A landed
1311 +
    // or closed one is as recorded, not projected again (#331).
1310 1312
    use futures::StreamExt;
1311 -
    let rows = db::proposals::list(&app.db, row.id, states).await?;
1313 +
    let mut rows = db::proposals::list(&app.db, row.id, states).await?;
1314 +
    if let Some(limit) = q.limit {
1315 +
        rows.truncate(limit);
1316 +
    }
1312 1317
    let out: Vec<Result<Proposal, ApiError>> = futures::stream::iter(rows)
1313 1318
        .map(|p| {
1314 1319
            let (app, repo, owner, name, id) = (app.clone(), repo.clone(), owner.clone(), name.clone(), row.id);
1315 1320
            async move {
1316 -
                let loaded = crate::web::proposals::load_summary(&app, &repo, p).await?;
1321 +
                let loaded = crate::web::proposals::load_for_api(&app, &repo, p).await?;
1317 1322
                let v = crate::web::proposals::verdict(&app, &loaded, id).await?;
1318 1323
                Ok(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, None).await)
1319 1324
            }
…
16 unchanged lines
1336 1341
        Some(id) => db::repos::get_by_id(&app.db, id).await?.map(|r| format!("{}/{}", app.config.base_url(), r.slug())),
1337 1342
        None => None,
1338 1343
    };
1339 -
    let loaded = crate::web::proposals::load_summary(&app, &repo, p).await?;
1344 +
    let loaded = crate::web::proposals::load_for_api(&app, &repo, p).await?;
1340 1345
    let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?;
1341 1346
    Ok(Json(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, source_url).await))
1342 1347
}
…
85 unchanged lines
1428 1433
        Some(id) => db::repos::get_by_id(&app.db, id).await?.map(|r| format!("{}/{}", app.config.base_url(), r.slug())),
1429 1434
        None => None,
1430 1435
    };
1431 -
    let loaded = crate::web::proposals::load_summary(&app, &repo, p).await?;
1436 +
    let loaded = crate::web::proposals::load_for_api(&app, &repo, p).await?;
1432 1437
    let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?;
1433 1438
    Ok(Json(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, source_url).await))
1434 1439
}
…
393 unchanged lines
1828 1833
#[derive(Deserialize)]
1829 1834
struct IssuesQuery {
1830 1835
    state: Option<String>,
1836 +
}
1837 +
1838 +
#[derive(Deserialize)]
1839 +
struct ProposalsQuery {
1840 +
    state: Option<String>,
1841 +
    limit: Option<usize>,
1831 1842
}
1832 1843
1833 1844
async fn issues(
…
1355 unchanged lines
modified crates/hub/src/web/proposals.rs +44 −0
…
433 unchanged lines
434 434
    load_with(app, repo, row, false).await
435 435
}
436 436
437 +
/// What the API says of a proposal ([`to_api`]): an open one's projection
438 +
/// now, as [`load_summary`]; a landed or closed one's as recorded, from its
439 +
/// row, without projecting it again (#331). A list of a few hundred landed
440 +
/// proposals projected each one and took minutes.
441 +
pub async fn load_for_api(app: &Shared, repo: &RepoName, row: pdb::ProposalRow) -> Result<Loaded, crate::repos::RepoError> {
442 +
    if row.is_open() {
443 +
        return load_summary(app, repo, row).await;
444 +
    }
445 +
    let hashes: Vec<ChangeHash> = row.projected_changes().iter().filter_map(|h| ChangeHash::from_hex(h)).collect();
446 +
    let heads: Vec<ChangeHash> = row.projected_heads().iter().filter_map(|h| ChangeHash::from_hex(h)).collect();
447 +
    let h2 = hashes.clone();
448 +
    let (changes, sealing_keys) = app
449 +
        .repos
450 +
        .read(repo, move |r, _| {
451 +
            let labels = HashMap::new();
452 +
            let changes: Vec<ChangeView> =
453 +
                h2.iter().filter_map(|h| Some(view::change_view_pub(r, h, &r.store().get_change_meta(h).ok()?, false, &labels))).collect();
454 +
            let sealing = pl::sealing_keys(r, &h2)?.into_iter().map(|k| k.to_hex()).collect();
455 +
            Ok((changes, sealing))
456 +
        })
457 +
        .await?;
458 +
    Ok(Loaded {
459 +
        changes,
460 +
        heads,
461 +
        target_heads: Vec::new(),
462 +
        state: row.projected_state.clone(),
463 +
        target_state: row.projected_target.clone(),
464 +
        conflicted: row.conflicted,
465 +
        conflicts: Vec::new(),
466 +
        union: None,
467 +
        files: Vec::new(),
468 +
        diffs: Vec::new(),
469 +
        bases: BTreeMap::new(),
470 +
        atoms: HashMap::new(),
471 +
        sealing_keys,
472 +
        touched: Vec::new(),
473 +
        owners: Vec::new(),
474 +
        landed_already: row.state == "landed",
475 +
        source_has_changes: !hashes.is_empty(),
476 +
        hashes,
477 +
        row,
478 +
    })
479 +
}
480 +
437 481
async fn load_with(app: &Shared, repo: &RepoName, row: pdb::ProposalRow, render: bool) -> Result<Loaded, crate::repos::RepoError> {
438 482
    let fork_dir = match (row.source_kind.as_str(), row.source_repo_id) {
439 483
        ("fork", Some(id)) => {
…
1651 unchanged lines
modified crates/hub/tests/proposals.rs +11 −0
…
281 unchanged lines
282 282
    assert_eq!(v["blockers"], serde_json::json!([]), "{}", api.body);
283 283
    assert_eq!(v["landed"]["by"], "andy");
284 284
    assert_eq!(v["landed"]["via"], "web");
285 +
    assert_eq!(v["approvals"][0]["sealed"], true, "{}", api.body);
286 +
    // The list says the same from what was recorded, without projecting
287 +
    // it again (#331), newest first, and takes a limit.
288 +
    let list = send(app, "GET", "/api/v1/repos/andy/demo/proposals?state=landed", None, None, Some("site")).await;
289 +
    let l: serde_json::Value = serde_json::from_str(&list.body).unwrap();
290 +
    assert_eq!(l[0]["number"], 1, "{}", list.body);
291 +
    assert_eq!(l[0]["changes"][0]["hash"], feat.to_hex(), "{}", list.body);
292 +
    assert_eq!(l[0]["projected_heads"][0], feat.to_hex(), "{}", list.body);
293 +
    assert_eq!(l[0]["approvals"][0]["sealed"], true, "{}", list.body);
294 +
    let list = send(app, "GET", "/api/v1/repos/andy/demo/proposals?state=all&limit=0", None, None, Some("site")).await;
295 +
    assert_eq!(list.body.trim(), "[]");
285 296
    let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await;
286 297
    assert!(page.body.contains("by <a href=\"/andy\">andy</a>, web"), "{}", page.body);
287 298
    // oxygenhub#86: a landed card offering to delete the source bookmark,
…
841 unchanged lines
modified crates/oh/src/main.rs +6 −3
…
326 unchanged lines
327 327
328 328
#[derive(Subcommand)]
329 329
enum ProposalCmd {
330 -
    /// Open proposals (or --state draft|landed|closed|all)
330 +
    /// Open proposals (or --state draft|landed|closed|all), newest first
331 331
    List {
332 332
        #[arg(long, default_value = "open")]
333 333
        state: String,
334 +
        /// At most this many
335 +
        #[arg(long, default_value_t = 30)]
336 +
        limit: usize,
334 337
    },
335 338
    /// One proposal: its changes, approvals and whether it can land
336 339
    View { number: i64 },
…
1093 unchanged lines
1430 1433
fn proposal(c: &Client, t: &Target, cmd: ProposalCmd, json: bool) -> Result<i32> {
1431 1434
    let base = t.repo_path()?;
1432 1435
    match cmd {
1433 -
        ProposalCmd::List { state } => {
1434 -
            let path = format!("{base}/proposals?state={state}");
1436 +
        ProposalCmd::List { state, limit } => {
1437 +
            let path = format!("{base}/proposals?state={state}&limit={limit}");
1435 1438
            if json {
1436 1439
                println!("{}", c.get_text(&path)?);
1437 1440
                return Ok(0);
…
873 unchanged lines
modified docs/oh.md +1 −1
…
44 unchanged lines
45 45
| `oh project list`, `oh project view N`, `oh project create TITLE [-d DESC] [--private]`, `oh project close\|reopen N` | The repository owner's project boards (`--owner NAME` for another's) |
46 46
| `oh project card add N REF [-c COLUMN]`, `oh project card add N --note TEXT`, `oh project card move N CARD -c COLUMN`, `oh project card move N --from COLUMN -c COLUMN [--except 12,#13,name#14,CARD] [--state closed,landed] [--dry-run]`, `oh project card remove N CARD` | Cards: REF is `12` or `#12` here, or `name#12` in another of the owner's repositories; a column by name or id; `--from` moves a whole column at once (#289) |
47 47
| `oh issue comment N -b BODY` | Comment |
48 -
| `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 |
48 +
| `oh proposal list [--state open\|draft\|landed\|closed\|all] [--limit N]`, `oh proposal view N` | Proposals, newest first (30 unless `--limit` says), with changes, approvals and blockers; a landed one with who landed it and how |
49 49
| `oh proposal create -t TITLE --from BOOKMARK [--into BOOKMARK] [--draft]` | Propose a bookmark's changes |
50 50
| `oh proposal checks N` | Checks on the projected state |
51 51
| `oh proposal land N` | Land it on its target, as the page's Land button does |
…
27 unchanged lines