OXYGENHUB

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