OXYGENHUB

GET /api/v1/user/proposals: the dashboard's proposals, for apps f4e138301198

review (open proposals by others in repositories the caller belongs to,
not yet reviewed by them) and authored (the caller's own open ones and
drafts, in repositories it can still see), from the same queries as the
web dashboard, each with its repository, state, conflicts and address;
hub-api UserProposals and ProposalBrief. For oxygenhub-android#18.
Tested on SQLite and PostgreSQL.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 0065c2cee84c
When
· 2026-10-25 12:00 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
f4e13830119830eb12f7474f7478128e782f16a65bc3a625495c9f5ef2eaac36
Depends on
439ecd8708a1 60f11b71a740 a7e6e8e6094c b5668108487f b64e2537d702 d23464c0ac60 ecac6e8d9b41
Depended on by
8f1d76f4169a 4dfa4599b3f7 5d818ec7d5fc 5714347a6233 b7d2b0b30b4a
In bookmarks
api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
In tags
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
5 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` (repo:read):** the proposals the web dashboard lists, for apps: `review` (open ones by others in repositories the caller belongs to, not yet reviewed by them) and `authored` (their own open and drafts, in repositories they can still see), each with its repository, state, conflicts and web address. The Android app's dashboard shows them (oxygenhub-android#18).
7 8
- **Board cards can be archived (#293, archive):** off the board but kept, on the board's archive view (`?archived=1`, with the same filter) and restored to the end of their column. A column archives its cards that reached it more than N days ago at once ("Archive cards here more than 14 days"), or on its own once set to (checked hourly, `projects.archiver`), so a Done column stops growing. An issue's or proposal's page says its card is archived. The API has `POST …/cards/archive` (#289's selection plus `older_days`, with `dry_run`), `GET …/cards?archived=true` and `POST …/cards/{id}/restore`; `oh project card archive N --from Done --older 14d`, `oh project card restore N CARD` and `oh project view N --archived`. Migration 0093 adds `project_cards.archived_ms` and `moved_ms` (when a card joined its column) and `project_columns.archive_after_days`.
8 9
- **Board cards show their labels and assignees, and the board filters by them (#293, filters):** an issue's or proposal's card shows its labels (each a link to the board filtered by it) and an issue's assignees; the filter takes `label:NAME`, `assignee:NAME` and `state:STATE` with words, as the issue list does, every term holding. The board reads what its cards show once per repository rather than once per card (a board of 300 cards made 300 queries). The API's cards and `oh project view` carry the labels and assignees too.
9 10
…
298 unchanged lines
modified crates/hub-api/src/lib.rs +24 −0
…
677 unchanged lines
678 678
    pub members: Vec<String>,
679 679
}
680 680
681 +
/// `GET /user/proposals`: the caller's proposals to act on, as the web
682 +
/// dashboard lists them: open ones by others in repositories they belong
683 +
/// to that they have not reviewed yet, and their own open and draft ones.
684 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
685 +
pub struct UserProposals {
686 +
    pub review: Vec<ProposalBrief>,
687 +
    pub authored: Vec<ProposalBrief>,
688 +
}
689 +
690 +
/// A proposal in a list across repositories.
691 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
692 +
pub struct ProposalBrief {
693 +
    /// `owner/name`.
694 +
    pub repository: String,
695 +
    pub number: i64,
696 +
    pub title: String,
697 +
    pub author: String,
698 +
    pub state: ProposalState,
699 +
    pub conflicted: bool,
700 +
    pub updated_ms: i64,
701 +
    /// `https://hub/{owner}/{name}/proposal/{number}`.
702 +
    pub url: String,
703 +
}
704 +
681 705
/// `GET /user/orgs` (#302): an organisation the caller belongs to, and
682 706
/// their role there.
683 707
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
…
810 unchanged lines
modified crates/hub/src/api/mod.rs +36 −0
…
94 unchanged lines
95 95
        .route("/user", get(me))
96 96
        .route("/user/repos", get(user_repos))
97 97
        .route("/user/orgs", get(user_orgs))
98 +
        .route("/user/proposals", get(user_proposals))
98 99
        .route("/user/token", axum::routing::delete(revoke_own_token))
99 100
        .route("/version", get(version))
100 101
        .route("/notifications", get(notifications))
…
505 unchanged lines
606 607
/// `GET /user/repos` (#302): the caller's repositories, their
607 608
/// organisations' and those they collaborate on, by `owner/name`, a page
608 609
/// at a time (`?after=owner/name&limit=`, at most 100, default 50).
610 +
/// `GET /user/proposals` (repo:read): what the dashboard lists, for apps.
611 +
async fn user_proposals(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<UserProposals>, ApiError> {
612 +
    let c = caller(&app, &headers).await?;
613 +
    let me = c.require("repo:read")?.clone();
614 +
    // As the dashboard: reviews in repositories the caller belongs to, its
615 +
    // own proposals in any it can still see.
616 +
    let member = db::accounts::visible_repo_ids(&app.db, me.id).await?;
617 +
    let seen: Vec<i64> = db::repos::list(&app.db)
618 +
        .await?
619 +
        .into_iter()
620 +
        .filter(|r| me.is_admin || r.visibility() != Visibility::Private || member.contains(&r.id))
621 +
        .map(|r| r.id)
622 +
        .collect();
623 +
    let base = app.config.base_url().trim_end_matches('/').to_string();
624 +
    let brief = |p: db::activity::ProposalLine| ProposalBrief {
625 +
        url: format!("{base}/{}/{}/proposal/{}", p.owner, p.name, p.number),
626 +
        repository: format!("{}/{}", p.owner, p.name),
627 +
        number: p.number,
628 +
        title: p.title,
629 +
        author: p.author,
630 +
        state: match p.state.as_str() {
631 +
            "draft" => ProposalState::Draft,
632 +
            "landed" => ProposalState::Landed,
633 +
            "closed" => ProposalState::Closed,
634 +
            _ => ProposalState::Open,
635 +
        },
636 +
        conflicted: p.conflicted,
637 +
        updated_ms: p.updated_ms,
638 +
    };
639 +
    Ok(Json(UserProposals {
640 +
        review: db::activity::awaiting_review(&app.db, me.id, &member).await?.into_iter().map(brief).collect(),
641 +
        authored: db::activity::authored(&app.db, me.id, &seen).await?.into_iter().map(brief).collect(),
642 +
    }))
643 +
}
644 +
609 645
async fn user_repos(
610 646
    State(app): State<Shared>,
611 647
    headers: HeaderMap,
…
2337 unchanged lines
modified crates/hub/tests/api.rs +32 −0
…
907 unchanged lines
908 908
    assert!(names(&api(app, "GET", "/api/v1/user/repos", Some(&b), None).await.1).is_empty(), "not bob's");
909 909
    assert_eq!(api(app, "GET", "/api/v1/user/repos?affiliation=friend", Some(&a), None).await.0, StatusCode::BAD_REQUEST);
910 910
    assert_eq!(api(app, "GET", "/api/v1/user/repos", None, None).await.0, StatusCode::UNAUTHORIZED);
911 +
    // The proposals to act on, as the dashboard lists them: bob's in andy's
912 +
    // repository is andy's to review and bob's own.
913 +
    let demo = oxygenhub::db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap();
914 +
    let bob_id = oxygenhub::db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id;
915 +
    oxygenhub::db::proposals::create(
916 +
        &app.db,
917 +
        oxygenhub::db::proposals::NewProposal {
918 +
            repo_id: demo.id,
919 +
            title: "Faster startup",
920 +
            body: "",
921 +
            author_id: Some(bob_id),
922 +
            author: "bob",
923 +
            draft: false,
924 +
            target: "main",
925 +
            source_kind: "bookmark",
926 +
            source_repo_id: Some(demo.id),
927 +
            source_bookmark: "fast",
928 +
            source_changes: "",
929 +
        },
930 +
    )
931 +
    .await
932 +
    .unwrap();
933 +
    let (st, v) = api(app, "GET", "/api/v1/user/proposals", Some(&a), None).await;
934 +
    assert_eq!(st, StatusCode::OK, "{v}");
935 +
    assert_eq!((v["review"][0]["repository"].as_str(), v["review"][0]["number"].as_i64()), (Some("andy/demo"), Some(2)), "{v}");
936 +
    assert_eq!(v["review"][0]["state"], "open");
937 +
    assert!(v["review"][0]["url"].as_str().unwrap().ends_with("/andy/demo/proposal/2"));
938 +
    assert_eq!(v["authored"].as_array().map(Vec::len), Some(0));
939 +
    let (_, v) = api(app, "GET", "/api/v1/user/proposals", Some(&b), None).await;
940 +
    assert_eq!((v["review"].as_array().map(Vec::len), v["authored"][0]["title"].as_str()), (Some(0), Some("Faster startup")), "{v}");
941 +
    assert_eq!(api(app, "GET", "/api/v1/user/proposals", None, None).await.0, StatusCode::UNAUTHORIZED);
942 +
911 943
    // Organisations need user:read, which this token lacks.
912 944
    assert_eq!(api(app, "GET", "/api/v1/user/orgs", Some(&a), None).await.0, StatusCode::FORBIDDEN);
913 945
…
855 unchanged lines