OXYGENHUB

History API takes a revset: ?r= f28389bd6aab

GET /repos/{o}/{n}/history/{ref}?r=REVSET answers the changes the
revset selects, newest first and paged as before, evaluated under a
two-second budget so a query cannot load the server; one that does
not parse, names nothing known, or goes past the budget answers 400
with the reason. The reference still gives the labels and heads.
Pins oxygenflow main, which pins oxygen main with revsets
(oxygen#65). Spec §12 says so.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-04 03:28 UTC
Signature
✔ signed by Andy McCall
Hash
f28389bd6aabb9fe6ca2af4a99ff344672ec5032b80cf05562c3e7cce8d59561
Depends on
380996db84e5 5ea4872f23e8 93be9ce4653e b43a9ab0498f b64e2537d702 d23464c0ac60
Depended on by
8933faff2043
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 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
10 atoms, 0 tree operations, 5 files

Browse the tree at this change

modified crates/hub/src/api/mod.rs +19 −2
…
531 unchanged lines
532 532
    path: Option<String>,
533 533
    offset: Option<usize>,
534 534
    limit: Option<usize>,
535 +
    /// A revset (Oxygen's docs/revset_query_language.md) in place of the
536 +
    /// reference's whole history; the reference still gives the labels.
537 +
    r: Option<String>,
535 538
}
536 539
537 540
async fn history(
…
5 unchanged lines
543 546
    let c = caller(&app, &headers).await?;
544 547
    let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
545 548
    let (offset, limit) = (q.offset.unwrap_or(0), q.limit.unwrap_or(50).min(500));
549 +
    let revset = q.r.clone().filter(|s| !s.trim().is_empty());
550 +
    let by_revset = revset.is_some();
546 551
    let h = app
547 552
        .repos
548 553
        .read(&repo, move |repo, cache| {
549 554
            let target = view::resolve(repo, cache, &r)?;
550 -
            view::history(repo, cache, target, q.path.as_deref(), offset, limit)
555 +
            match &revset {
556 +
                Some(rs) => view::history_revset(repo, cache, target, rs, offset, limit),
557 +
                None => view::history(repo, cache, target, q.path.as_deref(), offset, limit),
558 +
            }
551 559
        })
552 -
        .await?;
560 +
        .await
561 +
        .map_err(|e| {
562 +
            // A revset that does not parse, names nothing, or goes past the
563 +
            // budget is the caller's to fix.
564 +
            if by_revset {
565 +
                ApiError(StatusCode::BAD_REQUEST, e.to_string())
566 +
            } else {
567 +
                e.into()
568 +
            }
569 +
        })?;
553 570
    let mut entries = h.entries;
554 571
    crate::web::attribute(&app, &mut entries.iter_mut().collect::<Vec<_>>()).await;
555 572
    Ok(Json(History {
…
1550 unchanged lines
modified crates/hub/src/repos/view.rs +20 −0
…
332 unchanged lines
333 333
    Ok(History { target, entries: window, total, offset, has_more: offset + limit < total, path: path.map(String::from) })
334 334
}
335 335
336 +
/// A window of the changes a revset selects (Oxygen's
337 +
/// docs/revset_query_language.md §3), newest first, under a budget
338 +
/// stricter than the command line's so a query cannot load the server.
339 +
/// `target` is the reference the request named, for labels and heads.
340 +
pub fn history_revset(repo: &Repo, cache: &StateCache, target: RefTarget, revset: &str, offset: usize, limit: usize) -> Result<History> {
341 +
    let loaded = cache.load(repo, &target.heads)?;
342 +
    let head_set: BTreeSet<ChangeHash> = loaded.state.heads().into_iter().collect();
343 +
    let selected = repo.revset_with_budget(revset, std::time::Duration::from_secs(2))?;
344 +
    let total = selected.hashes.len();
345 +
    let labels = head_labels(repo)?;
346 +
    let window: Vec<ChangeView> = selected
347 +
        .hashes
348 +
        .iter()
349 +
        .skip(offset)
350 +
        .take(limit)
351 +
        .filter_map(|h| selected.metas.get(h).map(|m| change_view(repo, h, m, head_set.contains(h), &labels)))
352 +
        .collect();
353 +
    Ok(History { target, entries: window, total, offset, has_more: offset + limit < total, path: None })
354 +
}
355 +
336 356
// ----------------------------------------------------------------- change
337 357
338 358
#[derive(Clone, Debug)]
…
1039 unchanged lines
modified crates/hub/tests/api.rs +10 −0
…
225 unchanged lines
226 226
    let (st, v) = api(app, "GET", "/api/v1/repos/andy/demo/history/main", None, None).await;
227 227
    assert_eq!(st, StatusCode::OK);
228 228
    assert_eq!(v["changes"][0]["hash"], base.to_hex());
229 +
    // A revset in place of the whole history (`?r=`), under a budget; a
230 +
    // bad one is the caller's problem.
231 +
    let (st, v) = api(app, "GET", "/api/v1/repos/andy/demo/history/main?r=%3A%3Amain%20%26%20kind(normal)", None, None).await;
232 +
    assert_eq!(st, StatusCode::OK, "{v}");
233 +
    assert_eq!(v["total"].as_u64(), Some(1));
234 +
    assert_eq!(v["changes"][0]["hash"], base.to_hex());
235 +
    let (st, v) = api(app, "GET", "/api/v1/repos/andy/demo/history/main?r=none()", None, None).await;
236 +
    assert_eq!((st, v["total"].as_u64()), (StatusCode::OK, Some(0)));
237 +
    assert_eq!(api(app, "GET", "/api/v1/repos/andy/demo/history/main?r=main%20%26", None, None).await.0, StatusCode::BAD_REQUEST);
238 +
    assert_eq!(api(app, "GET", "/api/v1/repos/andy/demo/history/main?r=nosuch()", None, None).await.0, StatusCode::BAD_REQUEST);
229 239
    let (_, v) = api(app, "GET", "/api/v1/repos/andy/demo/tree/main", None, None).await;
230 240
    assert_eq!(v[0]["name"], "a.txt");
231 241
    let r = call(app, "GET", "/api/v1/repos/andy/demo/file/main/a.txt", None, None, None, None).await;
…
1155 unchanged lines
replaced deps/oxygenflow +0 −0

No line changes.

modified docs/spec.md +1 −1
…
440 unchanged lines
441 441
442 442
### 12.1 API
443 443
444 -
JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route 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`, `/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`, `/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}`, `/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, `/cards` to add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one; `repo:write` and the page's rights to change), `/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), `/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}/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 (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.
444 +
JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route 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`, `/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}`, `/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, `/cards` to add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one; `repo:write` and the page's rights to change), `/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), `/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}/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 (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.
445 445
446 446
### 12.2 Webhooks
447 447
…
236 unchanged lines