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 |