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 |