GET /user/proposals?landable=true: the proposals the caller can land now (closes #311) 5d818ec7d5fc
For the Android app to say when there is something to land: open proposals in repositories the caller can write to whose verdict has no blockers. A proposal the caller approved leaves the review list, which is when it often becomes landable, so the other two lists cannot say.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 390b0dba0b96
- When
- · 2026-10-25 16:38 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
5d818ec7d5fcbc9f08a9210d00cb0197705cd79eeeaf35507232317f440c6d27- Depends on
- 03bfb945f5c1 5714347a6233 a7e6e8e6094c b5668108487f b64e2537d702 d23464c0ac60 f4e138301198
- Depended on by
- a22e4aec2e28 8f1d76f4169a 3d086bc3d69b 4dfa4599b3f7 73c01b3448ee
- 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.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
- 9 atoms, 0 tree operations, 4 files
Browse the tree at this change
modified CHANGELOG.md +1 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **`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. |
|
| 7 | 8 | - **One logo, with its arms (#306):** the Oxygen mark's two arms were never drawn: their gradient was in bounding-box units, and a horizontal line's box has no height, so SVG paints nothing. The gradient is now in user space everywhere (header, footer, favicon, `mark.svg`; the VS Code extensions and the Android app too), and while a page loads the arms slide back and forth in turn beside the turning rings (still with reduced motion). |
|
| 8 | 9 | - **Admin › Runners: the Serves column lines up (#309):** the scope sits above its input, so the inputs line up whatever the scope's length. |
|
| 9 | 10 | - **A job's RE-RUN THIS JOB button no longer touches its log (#310).** |
|
| … | 308 unchanged lines |
modified crates/hub-api/src/lib.rs +13 −0
| … | 695 unchanged lines |
||
| 696 | 696 | pub struct UserProposals { |
|
| 697 | 697 | pub review: Vec<ProposalBrief>, |
|
| 698 | 698 | pub authored: Vec<ProposalBrief>, |
|
| 699 | + | /// With `?landable=true` (#311): the open proposals the caller could |
|
| 700 | + | /// land now, in repositories they can write to, with no blockers. |
|
| 701 | + | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
|
| 702 | + | pub landable: Vec<ProposalBrief>, |
|
| 703 | + | } |
|
| 704 | + | ||
| 705 | + | /// `GET /user/proposals` query. |
|
| 706 | + | #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] |
|
| 707 | + | pub struct UserProposalsQuery { |
|
| 708 | + | /// Also list what the caller can land now (slower: each proposal's |
|
| 709 | + | /// projected state is worked out). |
|
| 710 | + | #[serde(default)] |
|
| 711 | + | pub landable: bool, |
|
| 699 | 712 | } |
|
| 700 | 713 | ||
| 701 | 714 | /// A proposal in a list across repositories. |
|
| … | 827 unchanged lines |
modified crates/hub/src/api/mod.rs +51 −2
| … | 623 unchanged lines |
||
| 624 | 624 | Ok((StatusCode::CREATED, Json(RunnerToken { token, owner: me.username }))) |
|
| 625 | 625 | } |
|
| 626 | 626 | ||
| 627 | - | /// `GET /user/proposals` (repo:read): what the dashboard lists, for apps. |
|
| 628 | - | async fn user_proposals(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<UserProposals>, ApiError> { |
|
| 627 | + | /// `GET /user/proposals` (repo:read): what the dashboard lists, for apps; |
|
| 628 | + | /// with `?landable=true`, also what the caller can land now (#311). |
|
| 629 | + | async fn user_proposals( |
|
| 630 | + | State(app): State<Shared>, |
|
| 631 | + | headers: HeaderMap, |
|
| 632 | + | Query(q): Query<UserProposalsQuery>, |
|
| 633 | + | ) -> Result<Json<UserProposals>, ApiError> { |
|
| 629 | 634 | let c = caller(&app, &headers).await?; |
|
| 630 | 635 | let me = c.require("repo:read")?.clone(); |
|
| 631 | 636 | // As the dashboard: reviews in repositories the caller belongs to, its |
|
| … | 21 unchanged lines |
||
| 653 | 658 | conflicted: p.conflicted, |
|
| 654 | 659 | updated_ms: p.updated_ms, |
|
| 655 | 660 | }; |
|
| 661 | + | let landable = if q.landable { landable_for(&app, &c, &member, &base).await? } else { Vec::new() }; |
|
| 656 | 662 | Ok(Json(UserProposals { |
|
| 657 | 663 | review: db::activity::awaiting_review(&app.db, me.id, &member).await?.into_iter().map(brief).collect(), |
|
| 658 | 664 | authored: db::activity::authored(&app.db, me.id, &seen).await?.into_iter().map(brief).collect(), |
|
| 665 | + | landable, |
|
| 659 | 666 | })) |
|
| 667 | + | } |
|
| 668 | + | ||
| 669 | + | /// The open proposals the caller could land now (#311): in repositories |
|
| 670 | + | /// it belongs to and can write to, whose verdict has no blockers. Each |
|
| 671 | + | /// projected state is worked out four at a time, as `proposals` does |
|
| 672 | + | /// (#171); one that cannot be is left out rather than failing the list. |
|
| 673 | + | async fn landable_for(app: &Shared, c: &Caller, member: &[i64], base: &str) -> Result<Vec<ProposalBrief>, ApiError> { |
|
| 674 | + | use futures::StreamExt; |
|
| 675 | + | let mut todo = Vec::new(); |
|
| 676 | + | for row in db::repos::list(&app.db).await?.into_iter().filter(|r| member.contains(&r.id) && !r.is_drop()) { |
|
| 677 | + | let role = auth::roles::role_for(&app.db, c.account.as_ref(), &row).await?; |
|
| 678 | + | if !auth::roles::allows(role, Access::Write) { |
|
| 679 | + | continue; |
|
| 680 | + | } |
|
| 681 | + | let Some(repo) = RepoName::new(&row.owner, &row.name) else { continue }; |
|
| 682 | + | for p in db::proposals::list(&app.db, row.id, &["open"]).await? { |
|
| 683 | + | todo.push((repo.clone(), row.clone(), p)); |
|
| 684 | + | } |
|
| 685 | + | } |
|
| 686 | + | let found: Vec<Option<ProposalBrief>> = futures::stream::iter(todo) |
|
| 687 | + | .map(|(repo, row, p)| { |
|
| 688 | + | let (app, base) = (app.clone(), base.to_string()); |
|
| 689 | + | async move { |
|
| 690 | + | let (number, title, author, updated_ms) = (p.number, p.title.clone(), p.author.clone(), p.updated_ms); |
|
| 691 | + | let loaded = crate::web::proposals::load_summary(&app, &repo, p).await.ok()?; |
|
| 692 | + | let v = crate::web::proposals::verdict(&app, &loaded, row.id).await.ok()?; |
|
| 693 | + | v.blockers.is_empty().then(|| ProposalBrief { |
|
| 694 | + | url: format!("{base}/{}/{}/proposal/{number}", row.owner, row.name), |
|
| 695 | + | repository: format!("{}/{}", row.owner, row.name), |
|
| 696 | + | number, |
|
| 697 | + | title, |
|
| 698 | + | author, |
|
| 699 | + | state: ProposalState::Open, |
|
| 700 | + | conflicted: loaded.conflicted, |
|
| 701 | + | updated_ms, |
|
| 702 | + | }) |
|
| 703 | + | } |
|
| 704 | + | }) |
|
| 705 | + | .buffered(4) |
|
| 706 | + | .collect() |
|
| 707 | + | .await; |
|
| 708 | + | Ok(found.into_iter().flatten().collect()) |
|
| 660 | 709 | } |
|
| 661 | 710 | ||
| 662 | 711 | async fn user_repos( |
|
| … | 2339 unchanged lines |
modified crates/hub/tests/api.rs +6 −0
| … | 345 unchanged lines |
||
| 346 | 346 | let (_, v) = api(app, "GET", "/api/v1/repos/andy/demo/proposals/2", None, None).await; |
|
| 347 | 347 | assert_eq!(v["landable"], false); |
|
| 348 | 348 | assert!(v["blockers"][0].as_str().unwrap().contains("build"), "{v}"); |
|
| 349 | + | // Nothing to land yet for its owner (#311), and the list is only there when asked for. |
|
| 350 | + | let (_, v) = api(app, "GET", "/api/v1/user/proposals?landable=true", Some(&andy_ro), None).await; |
|
| 351 | + | assert!(v.get("landable").is_none(), "{v}"); |
|
| 349 | 352 | // A checker reports on the projected state: first running, then passed; the page streams it. |
|
| 350 | 353 | let (st, v) = api( |
|
| 351 | 354 | app, |
|
| … | 21 unchanged lines |
||
| 373 | 376 | assert_eq!(v[0]["status"], "passed"); |
|
| 374 | 377 | let (_, v) = api(app, "GET", "/api/v1/repos/andy/demo/proposals/2", None, None).await; |
|
| 375 | 378 | assert_eq!(v["landable"], true, "{v}"); |
|
| 379 | + | let (_, v) = api(app, "GET", "/api/v1/user/proposals?landable=true", Some(&andy_ro), None).await; |
|
| 380 | + | assert_eq!((v["landable"][0]["repository"].as_str(), v["landable"][0]["number"].as_i64()), (Some("andy/demo"), Some(2)), "{v}"); |
|
| 381 | + | assert!(api(app, "GET", "/api/v1/user/proposals", Some(&andy_ro), None).await.1.get("landable").is_none(), "only when asked"); |
|
| 376 | 382 | let page = call(app, "GET", "/andy/demo/proposal/2", None, Some(&andy), None, None).await; |
|
| 377 | 383 | assert!(page.body.contains("42 tests"), "{}", page.body); |
|
| 378 | 384 | // The check and the proposal reached the webhook too. |
|
| … | 1422 unchanged lines |