OXYGENHUB

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