The API for apps: own repositories, organisations, 401, device cancel, richer notifications (closes #302, closes #303, closes #304, closes #305) ecac6e8d9b41
GET /user/repos (affiliation, paged) and /user/orgs; no token answers 401 before a scope check's 403; DELETE /device/code withdraws a pending device sign-in; notifications carry repository, reason, thread and label.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 80fcd67f80a2
- When
- · 2026-10-25 03:02 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
ecac6e8d9b418b87593f8c58c1e36449c8f9fae5fb68a5e4d08e9bb1f8e6e6ab- Depends on
- 0b20f1ec4007 42a0ba4bac4e 456f4197ade3 60f11b71a740 657e7d5c9da9 93be9ce4653e a7e6e8e6094c b5668108487f b64e2537d702 cc1cd197df1d d23464c0ac60 df71c7892d27 eb0c44a9ad37 ef20fdc14e8b
- Depended on by
- 8f1d76f4169a 3d086bc3d69b 4dfa4599b3f7 5714347a6233 f4e138301198 439ecd8708a1 69e4f27c5633
- In bookmarks
- api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
- In tags
- v0.8.0 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
- 24 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 | + | - **The API for apps (#302-#305), found building the Android app:** `GET /api/v1/user/repos` lists the caller's own repositories, their organisations' and those they collaborate on (`?affiliation=owner,organization,collaborator`, paged with `?after=owner/name&limit=`), and `GET /api/v1/user/orgs` their organisations and role (#302); a call with no token now answers 401 (sign in) rather than 403 (#303); `DELETE /api/v1/device/code` withdraws a device sign-in the app gave up on, at once (#304); each notification says its `repository`, `reason` (participating, watching), `thread` and the kind's `label` (#305). |
|
| 7 | 8 | - **Boards that keep growing stay readable (#293, first part):** a column of more than 30 cards shows its newest 30 (cards join a column at its end), says how many older ones it leaves out and links to "show all"; a filter above the board finds cards by title or reference, and by state (open, closed, landed, draft), in every column, counting "3 of 312". |
|
| 8 | 9 | - **An issue's references come in a box per day (#292):** an issue many changes refer to listed a line per change and ran on down the page. Two or more on one day are now one box, "referenced by 12 changes on 21 Oct 2026", their changes as wrapping chips, collapsible, the newest day's open. |
|
| 9 | 10 | - **Move a board's column at once (#289):** `oh project card move N --from Doing -c Done` moves every card of a column, `--except 317,#320` leaves some, `--state closed,landed` takes only cards whose issue or proposal is done, and `--dry-run` lists what would move. One call (`POST …/projects/{owner}/{n}/cards/move`), all or none, one audit entry: tidying a board of 115 cards took 115 commands. |
|
| … | 291 unchanged lines |
modified crates/hub-api/src/lib.rs +22 −0
| … | 348 unchanged lines |
||
| 349 | 349 | pub url: String, |
|
| 350 | 350 | pub actor: String, |
|
| 351 | 351 | pub read: bool, |
|
| 352 | + | /// The repository it is about, `owner/name` (#305). |
|
| 353 | + | #[serde(default)] |
|
| 354 | + | pub repository: Option<String>, |
|
| 355 | + | /// Why the caller is told: `participating` or `watching`. |
|
| 356 | + | #[serde(default)] |
|
| 357 | + | pub reason: String, |
|
| 358 | + | /// The thread it belongs to (its page, without a fragment), absolute: |
|
| 359 | + | /// a client groups the inbox by it. |
|
| 360 | + | #[serde(default)] |
|
| 361 | + | pub thread: String, |
|
| 362 | + | /// The kind as the hub's pages label it ("🐛 New Issue"), when it has one. |
|
| 363 | + | #[serde(default)] |
|
| 364 | + | pub label: Option<String>, |
|
| 352 | 365 | } |
|
| 353 | 366 | ||
| 354 | 367 | /// `GET /notifications`: the unread count and the newest entries, unread first. |
|
| … | 308 unchanged lines |
||
| 663 | 676 | pub display_name: String, |
|
| 664 | 677 | pub description: String, |
|
| 665 | 678 | pub members: Vec<String>, |
|
| 679 | + | } |
|
| 680 | + | ||
| 681 | + | /// `GET /user/orgs` (#302): an organisation the caller belongs to, and |
|
| 682 | + | /// their role there. |
|
| 683 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 684 | + | pub struct UserOrg { |
|
| 685 | + | pub name: String, |
|
| 686 | + | pub display_name: String, |
|
| 687 | + | pub role: String, |
|
| 666 | 688 | } |
|
| 667 | 689 | ||
| 668 | 690 | /// Forms accepted by the write endpoints. |
|
| … | 768 unchanged lines |
modified crates/hub/src/api/device.rs +17 −0
| … | 102 unchanged lines |
||
| 103 | 103 | device_code: String, |
|
| 104 | 104 | } |
|
| 105 | 105 | ||
| 106 | + | /// `DELETE /api/v1/device/code` (#304): the app gave up (its user pressed |
|
| 107 | + | /// Cancel): the code is withdrawn at once, so nobody can approve it later, |
|
| 108 | + | /// rather than waiting out its fifteen minutes. Answers 204 whether or not |
|
| 109 | + | /// there was such a code; a code already approved and taken is a token |
|
| 110 | + | /// now, which the app revokes as any other. |
|
| 111 | + | pub async fn cancel(State(app): State<Shared>, Json(req): Json<TokenRequest>) -> Response { |
|
| 112 | + | let hash = crate::auth::token_hash(req.device_code.trim()); |
|
| 113 | + | match accounts::device_by_hash(&app.db, &hash).await { |
|
| 114 | + | Ok(Some(d)) => match accounts::delete_device(&app.db, d.id).await { |
|
| 115 | + | Ok(_) => StatusCode::NO_CONTENT.into_response(), |
|
| 116 | + | Err(e) => oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()), |
|
| 117 | + | }, |
|
| 118 | + | Ok(None) => StatusCode::NO_CONTENT.into_response(), |
|
| 119 | + | Err(e) => oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()), |
|
| 120 | + | } |
|
| 121 | + | } |
|
| 122 | + | ||
| 106 | 123 | /// `POST /api/v1/device/token`: the token once the user approved, or why |
|
| 107 | 124 | /// not yet (`authorization_pending`, `slow_down`, `access_denied`, |
|
| 108 | 125 | /// `expired_token`), as RFC 8628 has it. |
|
| … | 55 unchanged lines |
modified crates/hub/src/api/mod.rs +89 −5
| … | 84 unchanged lines |
||
| 85 | 85 | .route("/projects/{owner}/{number}/cards", get(projects::cards).post(projects::add_card)) |
|
| 86 | 86 | .route("/projects/{owner}/{number}/cards/move", post(projects::move_cards)) |
|
| 87 | 87 | .route("/projects/{owner}/{number}/cards/{id}", axum::routing::patch(projects::move_card).delete(projects::remove_card)) |
|
| 88 | - | .route("/device/code", post(device::code)) |
|
| 88 | + | .route("/device/code", post(device::code).delete(device::cancel)) |
|
| 89 | 89 | .route("/device/token", post(device::token)) |
|
| 90 | 90 | .route("/runners", get(runners)) |
|
| 91 | 91 | .route("/runners/queue", get(runner_queue)) |
|
| 92 | 92 | .route("/runners/{id}/jobs", get(runner_jobs)) |
|
| 93 | 93 | .route("/user", get(me)) |
|
| 94 | + | .route("/user/repos", get(user_repos)) |
|
| 95 | + | .route("/user/orgs", get(user_orgs)) |
|
| 94 | 96 | .route("/user/token", axum::routing::delete(revoke_own_token)) |
|
| 95 | 97 | .route("/version", get(version)) |
|
| 96 | 98 | .route("/notifications", get(notifications)) |
|
| … | 261 unchanged lines |
||
| 358 | 360 | } |
|
| 359 | 361 | ||
| 360 | 362 | fn require(&self, scope: &str) -> Result<&Account, ApiError> { |
|
| 363 | + | // No token is 401, sign in; a token without the scope is 403 (#303). |
|
| 364 | + | let Some(account) = self.account.as_ref() else { |
|
| 365 | + | return Err(ApiError(StatusCode::UNAUTHORIZED, "a personal access token is required: sign in (`oh auth login`)".into())); |
|
| 366 | + | }; |
|
| 361 | 367 | if !self.has(scope) { |
|
| 362 | 368 | return Err(forbidden(&format!("this token lacks the {scope} scope"))); |
|
| 363 | 369 | } |
|
| 364 | - | self.account.as_ref().ok_or_else(|| ApiError(StatusCode::UNAUTHORIZED, "a personal access token is required".into())) |
|
| 370 | + | Ok(account) |
|
| 365 | 371 | } |
|
| 366 | 372 | } |
|
| 367 | 373 | ||
| … | 118 unchanged lines |
||
| 486 | 492 | let a = c.require("user:read")?; |
|
| 487 | 493 | let limit = q.limit.unwrap_or(50).clamp(1, 200); |
|
| 488 | 494 | let base = app.config.base_url().trim_end_matches('/').to_string(); |
|
| 489 | - | let items = db::notify::inbox(&app.db, a.id, limit) |
|
| 490 | - | .await? |
|
| 495 | + | let rows = db::notify::inbox(&app.db, a.id, limit).await?; |
|
| 496 | + | // Repositories by id, each looked up once (#305). |
|
| 497 | + | let mut repos: std::collections::HashMap<i64, Option<String>> = std::collections::HashMap::new(); |
|
| 498 | + | for id in rows.iter().filter_map(|n| n.repo_id) { |
|
| 499 | + | if let std::collections::hash_map::Entry::Vacant(e) = repos.entry(id) { |
|
| 500 | + | e.insert(db::repos::get_by_id(&app.db, id).await?.map(|r| format!("{}/{}", r.owner, r.name))); |
|
| 501 | + | } |
|
| 502 | + | } |
|
| 503 | + | let absolute = |u: String| if u.starts_with('/') { format!("{base}{u}") } else { u }; |
|
| 504 | + | let items = rows |
|
| 491 | 505 | .into_iter() |
|
| 492 | 506 | .map(|n| hub_api::Notification { |
|
| 493 | 507 | id: n.id, |
|
| 494 | 508 | at_ms: n.at_ms, |
|
| 509 | + | label: crate::notify::label(&n.kind).map(String::from), |
|
| 495 | 510 | kind: n.kind, |
|
| 496 | 511 | title: n.title, |
|
| 497 | - | url: if n.url.starts_with('/') { format!("{base}{}", n.url) } else { n.url }, |
|
| 512 | + | url: absolute(n.url), |
|
| 498 | 513 | actor: n.actor, |
|
| 499 | 514 | read: n.read, |
|
| 515 | + | repository: n.repo_id.and_then(|id| repos.get(&id).cloned().flatten()), |
|
| 516 | + | reason: n.reason, |
|
| 517 | + | thread: if n.thread.is_empty() { String::new() } else { absolute(n.thread) }, |
|
| 500 | 518 | }) |
|
| 501 | 519 | .collect(); |
|
| 502 | 520 | Ok(Json(hub_api::Inbox { unread: db::notify::unread(&app.db, a.id).await?, items })) |
|
| … | 66 unchanged lines |
||
| 569 | 587 | } |
|
| 570 | 588 | } |
|
| 571 | 589 | Ok(Json(out)) |
|
| 590 | + | } |
|
| 591 | + | ||
| 592 | + | #[derive(Deserialize)] |
|
| 593 | + | struct UserReposQuery { |
|
| 594 | + | /// Which: `owner`, `organization`, `collaborator`, comma-separated (all three by default). |
|
| 595 | + | #[serde(default)] |
|
| 596 | + | affiliation: Option<String>, |
|
| 597 | + | /// The last `owner/name` of the previous page. |
|
| 598 | + | #[serde(default)] |
|
| 599 | + | after: Option<String>, |
|
| 600 | + | #[serde(default)] |
|
| 601 | + | limit: Option<usize>, |
|
| 602 | + | } |
|
| 603 | + | ||
| 604 | + | /// `GET /user/repos` (#302): the caller's repositories, their |
|
| 605 | + | /// organisations' and those they collaborate on, by `owner/name`, a page |
|
| 606 | + | /// at a time (`?after=owner/name&limit=`, at most 100, default 50). |
|
| 607 | + | async fn user_repos( |
|
| 608 | + | State(app): State<Shared>, |
|
| 609 | + | headers: HeaderMap, |
|
| 610 | + | Query(q): Query<UserReposQuery>, |
|
| 611 | + | ) -> Result<Json<Vec<Repository>>, ApiError> { |
|
| 612 | + | let c = caller(&app, &headers).await?; |
|
| 613 | + | let me = c.require("repo:read")?.clone(); |
|
| 614 | + | let wanted: Vec<String> = q |
|
| 615 | + | .affiliation |
|
| 616 | + | .as_deref() |
|
| 617 | + | .unwrap_or("owner,organization,collaborator") |
|
| 618 | + | .split(',') |
|
| 619 | + | .map(|s| s.trim().to_ascii_lowercase()) |
|
| 620 | + | .filter(|s| !s.is_empty()) |
|
| 621 | + | .collect(); |
|
| 622 | + | if let Some(bad) = wanted.iter().find(|w| !["owner", "organization", "collaborator"].contains(&w.as_str())) { |
|
| 623 | + | return Err(ApiError(StatusCode::BAD_REQUEST, format!("unknown affiliation {bad:?}: owner, organization or collaborator"))); |
|
| 624 | + | } |
|
| 625 | + | let orgs: Vec<String> = db::accounts::orgs_of(&app.db, me.id).await?.into_iter().map(|(o, _)| o.name).collect(); |
|
| 626 | + | let limit = q.limit.unwrap_or(50).clamp(1, 100); |
|
| 627 | + | let mut rows = db::repos::list(&app.db).await?; |
|
| 628 | + | rows.sort_by(|a, b| (&a.owner, &a.name).cmp(&(&b.owner, &b.name))); |
|
| 629 | + | let mut out = Vec::new(); |
|
| 630 | + | for r in rows { |
|
| 631 | + | if let Some(after) = q.after.as_deref() { |
|
| 632 | + | if format!("{}/{}", r.owner, r.name).as_str() <= after { |
|
| 633 | + | continue; |
|
| 634 | + | } |
|
| 635 | + | } |
|
| 636 | + | let mine = (wanted.iter().any(|w| w == "owner") && r.owner == me.username) |
|
| 637 | + | || (wanted.iter().any(|w| w == "organization") && orgs.contains(&r.owner)) |
|
| 638 | + | || (wanted.iter().any(|w| w == "collaborator") && db::accounts::collaborator_role(&app.db, r.id, me.id).await?.is_some()); |
|
| 639 | + | if !mine || !auth::roles::allows(auth::roles::role_for(&app.db, Some(&me), &r).await?, Access::Read) { |
|
| 640 | + | continue; |
|
| 641 | + | } |
|
| 642 | + | out.push(repository(&app, &r)); |
|
| 643 | + | if out.len() == limit { |
|
| 644 | + | break; |
|
| 645 | + | } |
|
| 646 | + | } |
|
| 647 | + | Ok(Json(out)) |
|
| 648 | + | } |
|
| 649 | + | ||
| 650 | + | /// `GET /user/orgs` (#302): the organisations the caller belongs to. |
|
| 651 | + | async fn user_orgs(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<Vec<hub_api::UserOrg>>, ApiError> { |
|
| 652 | + | let c = caller(&app, &headers).await?; |
|
| 653 | + | let me = c.require("user:read")?; |
|
| 654 | + | let orgs = db::accounts::orgs_of(&app.db, me.id).await?; |
|
| 655 | + | Ok(Json(orgs.into_iter().map(|(o, role)| hub_api::UserOrg { name: o.name, display_name: o.display_name, role }).collect())) |
|
| 572 | 656 | } |
|
| 573 | 657 | ||
| 574 | 658 | async fn create_repo( |
|
| … | 2288 unchanged lines |
modified crates/hub/tests/api.rs +43 −2
| … | 390 unchanged lines |
||
| 391 | 391 | &app.db, |
|
| 392 | 392 | oxygenhub::db::notify::New { |
|
| 393 | 393 | account_id: bob_id, |
|
| 394 | - | repo_id: None, |
|
| 394 | + | repo_id: Some(oxygenhub::db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap().id), |
|
| 395 | 395 | reason: oxygenhub::db::notify::Reason::Participating, |
|
| 396 | - | kind: "mention", |
|
| 396 | + | kind: "issue.opened", |
|
| 397 | 397 | title: "andy/demo#1: Bug", |
|
| 398 | 398 | url: "/andy/demo/issue/1", |
|
| 399 | 399 | actor: "andy", |
|
| … | 20 unchanged lines |
||
| 420 | 420 | assert_eq!(v["unread"], 2); |
|
| 421 | 421 | assert_eq!(v["items"][0]["read"], false); |
|
| 422 | 422 | assert!(v["items"][0]["url"].as_str().unwrap().starts_with(&h.base), "absolute urls: {v}"); |
|
| 423 | + | // Its repository, why, its thread and the kind's label (#305). |
|
| 424 | + | let bug = v["items"].as_array().unwrap().iter().find(|n| n["title"] == "andy/demo#1: Bug").unwrap(); |
|
| 425 | + | assert_eq!( |
|
| 426 | + | (bug["repository"].as_str(), bug["reason"].as_str(), bug["label"].as_str()), |
|
| 427 | + | (Some("andy/demo"), Some("participating"), Some("🐛 New Issue")), |
|
| 428 | + | "{bug}" |
|
| 429 | + | ); |
|
| 430 | + | assert_eq!(bug["thread"].as_str(), Some(format!("{}/andy/demo/issue/1", h.base).as_str())); |
|
| 423 | 431 | let first = v["items"][0]["id"].as_i64().unwrap(); |
|
| 424 | 432 | let (st, _) = api(app, "POST", &format!("/api/v1/notifications/{first}/read"), Some(&bob_user), None).await; |
|
| 425 | 433 | assert_eq!(st, StatusCode::NO_CONTENT); |
|
| … | 2 unchanged lines |
||
| 428 | 436 | assert_eq!(api(app, "GET", "/api/v1/notifications", Some(&bob_user), None).await.1["unread"], 0); |
|
| 429 | 437 | // Another account's notification is not the caller's to clear, and a token without user:read sees nothing. |
|
| 430 | 438 | assert_eq!(api(app, "GET", "/api/v1/notifications", Some(&bob_rw), None).await.0, StatusCode::FORBIDDEN); |
|
| 439 | + | // No token at all is 401, so a client knows to sign in (#303). |
|
| 440 | + | assert_eq!(api(app, "GET", "/api/v1/notifications", None, None).await.0, StatusCode::UNAUTHORIZED); |
|
| 441 | + | assert_eq!(api(app, "GET", "/api/v1/user", None, None).await.0, StatusCode::UNAUTHORIZED); |
|
| 431 | 442 | ||
| 432 | 443 | // Review requests: the author asks andy; andy is told, reviews, and the |
|
| 433 | 444 | // request is answered; the author hears of the review. |
|
| 434 | 445 | let andy_user = mint(andy.clone(), "scopes=user%3Aread").await; |
|
| 446 | + | let (st, orgs) = api(app, "GET", "/api/v1/user/orgs", Some(&andy_user), None).await; |
|
| 447 | + | assert!(st == StatusCode::OK && orgs.is_array(), "the caller's organisations (#302): {orgs}"); |
|
| 435 | 448 | let andy_pw = mint(andy.clone(), "scopes=proposals%3Awrite").await; |
|
| 436 | 449 | oxygenhub::db::notify::mark_read(&app.db, oxygenhub::db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id, None) |
|
| 437 | 450 | .await |
|
| … | 446 unchanged lines |
||
| 884 | 897 | api(app, "POST", "/api/v1/repos", Some(&a), Some(r#"{"owner":"andy","name":"demo","visibility":"public"}"#)).await; |
|
| 885 | 898 | api(app, "POST", "/api/v1/repos/andy/demo/issues", Some(&a), Some(r#"{"title":"Crash"}"#)).await; |
|
| 886 | 899 | ||
| 900 | + | // The caller's own repositories, a page at a time (#302); bob has none. |
|
| 901 | + | api(app, "POST", "/api/v1/repos", Some(&a), Some(r#"{"owner":"andy","name":"zeta","visibility":"private"}"#)).await; |
|
| 902 | + | let names = |
|
| 903 | + | |v: &serde_json::Value| -> Vec<String> { v.as_array().unwrap().iter().map(|r| r["name"].as_str().unwrap().to_string()).collect() }; |
|
| 904 | + | let (st, v) = api(app, "GET", "/api/v1/user/repos", Some(&a), None).await; |
|
| 905 | + | assert_eq!((st, names(&v)), (StatusCode::OK, vec!["demo".to_string(), "zeta".to_string()]), "{v}"); |
|
| 906 | + | assert_eq!(names(&api(app, "GET", "/api/v1/user/repos?limit=1", Some(&a), None).await.1), ["demo"]); |
|
| 907 | + | assert_eq!(names(&api(app, "GET", "/api/v1/user/repos?after=andy/demo", Some(&a), None).await.1), ["zeta"]); |
|
| 908 | + | assert!(names(&api(app, "GET", "/api/v1/user/repos", Some(&b), None).await.1).is_empty(), "not bob's"); |
|
| 909 | + | assert_eq!(api(app, "GET", "/api/v1/user/repos?affiliation=friend", Some(&a), None).await.0, StatusCode::BAD_REQUEST); |
|
| 910 | + | assert_eq!(api(app, "GET", "/api/v1/user/repos", None, None).await.0, StatusCode::UNAUTHORIZED); |
|
| 911 | + | // Organisations need user:read, which this token lacks. |
|
| 912 | + | assert_eq!(api(app, "GET", "/api/v1/user/orgs", Some(&a), None).await.0, StatusCode::FORBIDDEN); |
|
| 913 | + | ||
| 887 | 914 | let (st, p) = api(app, "POST", "/api/v1/projects/andy", Some(&a), Some(r#"{"title":"Roadmap","description":"Next"}"#)).await; |
|
| 888 | 915 | assert_eq!((st, p["number"].as_i64()), (StatusCode::CREATED, Some(1)), "{p}"); |
|
| 889 | 916 | assert!(p["url"].as_str().unwrap().ends_with("/projects/andy/1")); |
|
| … | 725 unchanged lines |
||
| 1615 | 1642 | call(app, "POST", "/device", None, Some(&andy), None, Some(&format!("_csrf={csrf}&code={user}&action=deny"))).await; |
|
| 1616 | 1643 | let (_, v) = api(app, "POST", "/api/v1/device/token", None, Some(&format!(r#"{{"device_code":"{device}"}}"#))).await; |
|
| 1617 | 1644 | assert_eq!(v["error"], "access_denied"); |
|
| 1645 | + | ||
| 1646 | + | // A code the app gave up on (#304) is gone at once: the page no longer |
|
| 1647 | + | // takes it, and the app's poll says so. |
|
| 1648 | + | let (_, v) = api(app, "POST", "/api/v1/device/code", None, Some(r#"{"client":"Cancelled"}"#)).await; |
|
| 1649 | + | let (device, user) = (v["device_code"].as_str().unwrap().to_string(), v["user_code"].as_str().unwrap().to_string()); |
|
| 1650 | + | let (st, _) = api(app, "DELETE", "/api/v1/device/code", None, Some(&format!(r#"{{"device_code":"{device}"}}"#))).await; |
|
| 1651 | + | assert_eq!(st, StatusCode::NO_CONTENT); |
|
| 1652 | + | let page = call(app, "GET", &format!("/device?code={user}"), None, Some(&andy), None, None).await; |
|
| 1653 | + | let csrf = csrf_of(&page.body); |
|
| 1654 | + | call(app, "POST", "/device", None, Some(&andy), None, Some(&format!("_csrf={csrf}&code={user}&action=approve"))).await; |
|
| 1655 | + | let (_, v) = api(app, "POST", "/api/v1/device/token", None, Some(&format!(r#"{{"device_code":"{device}"}}"#))).await; |
|
| 1656 | + | assert_eq!(v["error"], "expired_token", "{v}"); |
|
| 1657 | + | let (st, _) = api(app, "DELETE", "/api/v1/device/code", None, Some(&format!(r#"{{"device_code":"{device}"}}"#))).await; |
|
| 1658 | + | assert_eq!(st, StatusCode::NO_CONTENT, "again: still fine"); |
|
| 1618 | 1659 | ||
| 1619 | 1660 | // Signed out, the page asks for a sign-in first. |
|
| 1620 | 1661 | let r = call(app, "GET", "/device", None, None, None, None).await; |
|
| … | 80 unchanged lines |
modified docs/spec.md +1 −1
| … | 489 unchanged lines |
||
| 490 | 490 | ||
| 491 | 491 | ### 12.1 API |
|
| 492 | 492 | ||
| 493 | - | JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags` (`POST` creates one, repo:write; `DELETE /tags/{tag}` withdraws one, a maintainer, `409` while a release uses it; §9), `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/notifications` (the caller's inbox and unread count; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/users/{name}/drops` and `/drops` (§4.5), `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, and `owner` to transfer it to an owner the caller may make boards under (#125: it takes the next number there, and its old address redirects), `/cards` to list them by column or add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one, `POST /cards/move` (#289: `{"from", "column", "except", "state", "dry_run"}`) to move a column's cards at once, but those `except` names (card ids or issue and proposal references) and, given `state`, only those whose issue or proposal is in one, all or none and audited once, answering what moved and what was kept; `repo:write` and the page's rights to change), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `POST /coverage` (repo:write, #136: `{"bookmark"}` or `{"change"}` and `content`, an LCOV or Cobertura report measured off the hub, kept for that state as uploaded by the caller, shown "uploaded by NAME" wherever a run number would be, and setting no check; `oh coverage upload FILE`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state; with `"jobs": [NAME..]` only those jobs and what they depend on, each one a manual run starts, else `409` naming the ones it does (#130); `oh run start --job NAME`), `/flow/runs/{n}` (a run with its jobs), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/jobs/{job}/rerun` (repo:write, oxygenflow#31: that job of a finished run again, in the same run, with the jobs that depend on it; what it depends on keeps its result, so a flaky test reruns without its build; their logs, artifacts and reports go, their checks return to queued and the run is running again; `{"queued": N}`, or `409` while the run is live or for a job it lacks; RE-RUN THIS JOB on the run page, `oh run rerun N --job NAME`), `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link. |
|
| 493 | + | JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags` (`POST` creates one, repo:write; `DELETE /tags/{tag}` withdraws one, a maintainer, `409` while a release uses it; §9), `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/user/repos` (the caller's own, organisations' and collaborations, `?affiliation=&after=&limit=`, #302), `/user/orgs`, `/notifications` (the caller's inbox and unread count, each with its repository, reason, thread and label, #305; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/users/{name}/drops` and `/drops` (§4.5), `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, and `owner` to transfer it to an owner the caller may make boards under (#125: it takes the next number there, and its old address redirects), `/cards` to list them by column or add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one, `POST /cards/move` (#289: `{"from", "column", "except", "state", "dry_run"}`) to move a column's cards at once, but those `except` names (card ids or issue and proposal references) and, given `state`, only those whose issue or proposal is in one, all or none and audited once, answering what moved and what was kept; `repo:write` and the page's rights to change), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `POST /coverage` (repo:write, #136: `{"bookmark"}` or `{"change"}` and `content`, an LCOV or Cobertura report measured off the hub, kept for that state as uploaded by the caller, shown "uploaded by NAME" wherever a run number would be, and setting no check; `oh coverage upload FILE`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state; with `"jobs": [NAME..]` only those jobs and what they depend on, each one a manual run starts, else `409` naming the ones it does (#130); `oh run start --job NAME`), `/flow/runs/{n}` (a run with its jobs), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/jobs/{job}/rerun` (repo:write, oxygenflow#31: that job of a finished run again, in the same run, with the jobs that depend on it; what it depends on keeps its result, so a flaky test reruns without its build; their logs, artifacts and reports go, their checks return to queued and the run is running again; `{"queued": N}`, or `409` while the run is live or for a job it lacks; RE-RUN THIS JOB on the run page, `oh run rerun N --job NAME`), `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link. |
|
| 494 | 494 | ||
| 495 | 495 | ### 12.2 Webhooks |
|
| 496 | 496 | ||
| … | 396 unchanged lines |