OXYGENHUB

Flow runs, jobs and logs in the API; checks by heads 90326c9601f9

GET /api/v1/repos/{o}/{n}/flow/runs (newest first, ?before=N&limit=),
/flow/runs/{n} with its jobs, and /flow/runs/{n}/jobs/{job}/log
(?after=seq&limit=), typed in hub-api as FlowRun, FlowJob and FlowLogLine.
/checks/{state} also takes a state's heads joined with +, the one head of
a single-head state (what oxy log shows) or a bookmark. Unknown routes
under /api/v1 answer with a JSON problem, not the site's page. The flow
tests wait for the checks that follow a run instead of racing them.

closes #26
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-29 00:51 UTC
Signature
✔ signed by Andy McCall
Hash
90326c9601f961ba10c4501883211b726d5e2ea097819caeaf2d3e99ef9fb345
Depends on
1edac4b68c6d 42fa95540468 93be9ce4653e a0d4bbfbedd1 b64e2537d702 d23464c0ac60 ef20fdc14e8b
Depended on by
fac6853c4e46 b1860d6090ec 026a76d251ce 5d90e0e045e2 501d8b268dd4 17a3a84b00d1 4d2e44d79b03 c889911bec12 d7ce41acf22c 5533e91a2aa4 e9bfea8e26db 4f6925337848 bf3d07541368 760a7dd30d6f
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.1.0 v0.2.0 v0.3.0 v0.3.1 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
17 atoms, 0 tree operations, 5 files

Browse the tree at this change

modified crates/hub-api/src/lib.rs +54 −0
…
427 unchanged lines
428 428
    "*".into()
429 429
}
430 430
431 +
/// An OxygenFlow run (hub spec §13.2).
432 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
433 +
pub struct FlowRun {
434 +
    /// The run's number within its repository.
435 +
    pub number: i64,
436 +
    pub bookmark: String,
437 +
    /// The state's hash, as checks are keyed.
438 +
    pub state: String,
439 +
    /// Its heads, full change hashes.
440 +
    pub heads: Vec<String>,
441 +
    /// `queued`, `running`, `passed`, `failed` or `error` (an invalid pipeline).
442 +
    pub status: String,
443 +
    /// `push`, `manual` or `rerun:N`.
444 +
    pub trigger: String,
445 +
    pub actor: String,
446 +
    pub pipeline_hash: String,
447 +
    /// An invalid pipeline's problems.
448 +
    pub error: String,
449 +
    pub created_ms: i64,
450 +
    pub finished_ms: Option<i64>,
451 +
    pub url: String,
452 +
    /// Filled by the single-run endpoint.
453 +
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
454 +
    pub jobs: Vec<FlowJob>,
455 +
}
456 +
457 +
/// A job of a run.
458 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
459 +
pub struct FlowJob {
460 +
    pub name: String,
461 +
    /// `queued`, `running`, `passed`, `failed`, `skipped` or `error`.
462 +
    pub status: String,
463 +
    pub depends_on: Vec<String>,
464 +
    pub runner: String,
465 +
    pub exit_code: Option<i64>,
466 +
    pub error: String,
467 +
    pub started_ms: Option<i64>,
468 +
    pub finished_ms: Option<i64>,
469 +
    /// Log lines stored so far.
470 +
    pub log_lines: i64,
471 +
}
472 +
473 +
/// A line of a job's log.
474 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
475 +
pub struct FlowLogLine {
476 +
    /// Increasing within the job; pass the last one as `after` for more.
477 +
    pub seq: i64,
478 +
    /// Milliseconds since the job started.
479 +
    pub t: i64,
480 +
    /// `stdout`, `stderr` or `flow` (the runner's own notes).
481 +
    pub stream: String,
482 +
    pub text: String,
483 +
}
484 +
431 485
/// A check on a state hash (hub spec §13.1).
432 486
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
433 487
pub struct Check {
…
395 unchanged lines
modified crates/hub/src/api/mod.rs +123 −2
…
16 unchanged lines
17 17
18 18
mod projects;
19 19
20 +
/// Any other path under `/api/v1`: a problem document, not the site's page.
21 +
async fn no_route() -> ApiError {
22 +
    ApiError(StatusCode::NOT_FOUND, "no such API route".into())
23 +
}
24 +
20 25
pub fn router() -> Router<Shared> {
21 26
    Router::new()
27 +
        .fallback(no_route)
22 28
        .route("/flow/claim", post(crate::flow::claim))
23 29
        .route("/flow/jobs/{id}/log", post(crate::flow::log))
24 30
        .route("/flow/jobs/{id}/finish", post(crate::flow::finish))
…
37 unchanged lines
62 68
        .route("/repos/{owner}/{name}/hooks", get(hooks).post(create_hook))
63 69
        .route("/repos/{owner}/{name}/hooks/{id}/delete", post(delete_hook))
64 70
        .route("/repos/{owner}/{name}/checks", post(post_check))
65 -
        .route("/repos/{owner}/{name}/flow/runs", post(start_flow_run))
71 +
        .route("/repos/{owner}/{name}/flow/runs", get(flow_runs).post(start_flow_run))
72 +
        .route("/repos/{owner}/{name}/flow/runs/{number}", get(flow_run))
73 +
        .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/log", get(flow_job_log))
66 74
        .route("/repos/{owner}/{name}/flow/runs/{number}/rerun", post(rerun_flow_run))
67 75
        .route("/repos/{owner}/{name}/checks/required", get(required_checks).put(set_required_checks))
68 76
        .route("/repos/{owner}/{name}/checks/{state}", get(checks_for_state))
…
1358 unchanged lines
1427 1435
    Ok(Json(RequiredChecks { names }))
1428 1436
}
1429 1437
1438 +
fn flow_run_of(app: &Shared, row: &db::repos::RepoRow, r: &db::flow::Run, jobs: Vec<db::flow::Job>) -> FlowRun {
1439 +
    FlowRun {
1440 +
        number: r.number,
1441 +
        bookmark: r.bookmark.clone(),
1442 +
        state: r.state.clone(),
1443 +
        heads: r.head_list().into_iter().map(String::from).collect(),
1444 +
        status: r.status.clone(),
1445 +
        trigger: r.trigger.clone(),
1446 +
        actor: r.actor.clone(),
1447 +
        pipeline_hash: r.pipeline_hash.clone(),
1448 +
        error: r.error.clone(),
1449 +
        created_ms: r.created_ms,
1450 +
        finished_ms: r.finished_ms,
1451 +
        url: format!("{}/{}/{}/flow/{}", app.config.base_url(), row.owner, row.name, r.number),
1452 +
        jobs: jobs
1453 +
            .into_iter()
1454 +
            .map(|j| FlowJob {
1455 +
                depends_on: j.depends_on(),
1456 +
                name: j.name,
1457 +
                status: j.status,
1458 +
                runner: j.runner,
1459 +
                exit_code: j.exit_code,
1460 +
                error: j.error,
1461 +
                started_ms: j.started_ms,
1462 +
                finished_ms: j.finished_ms,
1463 +
                log_lines: j.log_count,
1464 +
            })
1465 +
            .collect(),
1466 +
    }
1467 +
}
1468 +
1430 1469
#[derive(Deserialize)]
1470 +
struct RunsPage {
1471 +
    /// Runs numbered below this one (the next page).
1472 +
    before: Option<i64>,
1473 +
    limit: Option<i64>,
1474 +
}
1475 +
1476 +
/// `GET /repos/{o}/{n}/flow/runs`: newest first, `?before=N&limit=` (max 100).
1477 +
async fn flow_runs(
1478 +
    State(app): State<Shared>,
1479 +
    headers: HeaderMap,
1480 +
    Path((owner, name)): Path<(String, String)>,
1481 +
    Query(q): Query<RunsPage>,
1482 +
) -> Result<Json<Vec<FlowRun>>, ApiError> {
1483 +
    let c = caller(&app, &headers).await?;
1484 +
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
1485 +
    let limit = q.limit.unwrap_or(30).clamp(1, 100);
1486 +
    let runs = db::flow::runs_by_number(&app.db, row.id, limit, q.before).await?;
1487 +
    Ok(Json(runs.iter().map(|r| flow_run_of(&app, &row, r, vec![])).collect()))
1488 +
}
1489 +
1490 +
/// `GET /repos/{o}/{n}/flow/runs/{number}`: a run with its jobs.
1491 +
async fn flow_run(
1492 +
    State(app): State<Shared>,
1493 +
    headers: HeaderMap,
1494 +
    Path((owner, name, number)): Path<(String, String, i64)>,
1495 +
) -> Result<Json<FlowRun>, ApiError> {
1496 +
    let c = caller(&app, &headers).await?;
1497 +
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
1498 +
    let run = db::flow::run(&app.db, row.id, number).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
1499 +
    let jobs = db::flow::jobs(&app.db, run.id).await?;
1500 +
    Ok(Json(flow_run_of(&app, &row, &run, jobs)))
1501 +
}
1502 +
1503 +
#[derive(Deserialize)]
1504 +
struct LogPage {
1505 +
    /// Lines after this `seq`.
1506 +
    #[serde(default)]
1507 +
    after: i64,
1508 +
    limit: Option<i64>,
1509 +
}
1510 +
1511 +
/// `GET /repos/{o}/{n}/flow/runs/{number}/jobs/{job}/log`: `?after=seq&limit=` (max 5000).
1512 +
async fn flow_job_log(
1513 +
    State(app): State<Shared>,
1514 +
    headers: HeaderMap,
1515 +
    Path((owner, name, number, job)): Path<(String, String, i64, String)>,
1516 +
    Query(q): Query<LogPage>,
1517 +
) -> Result<Json<Vec<FlowLogLine>>, ApiError> {
1518 +
    let c = caller(&app, &headers).await?;
1519 +
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
1520 +
    let run = db::flow::run(&app.db, row.id, number).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
1521 +
    let j = db::flow::jobs(&app.db, run.id)
1522 +
        .await?
1523 +
        .into_iter()
1524 +
        .find(|j| j.name == job)
1525 +
        .ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such job".into()))?;
1526 +
    let lines = db::flow::log(&app.db, j.id, q.after, q.limit.unwrap_or(1000).clamp(1, 5000)).await?;
1527 +
    Ok(Json(lines.into_iter().map(|l| FlowLogLine { seq: l.seq, t: l.t, stream: l.stream, text: l.text }).collect()))
1528 +
}
1529 +
1530 +
#[derive(Deserialize)]
1431 1531
struct StartRun {
1432 1532
    /// Default: the repository's default bookmark.
1433 1533
    #[serde(default)]
…
78 unchanged lines
1512 1612
    Path((owner, name, state)): Path<(String, String, String)>,
1513 1613
) -> Result<Json<Vec<Check>>, ApiError> {
1514 1614
    let c = caller(&app, &headers).await?;
1515 -
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
1615 +
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
1616 +
    let state = state_key(&app, &repo, &state).await?;
1516 1617
    Ok(Json(db::hooks::checks_for_state(&app.db, row.id, &state).await?.iter().map(check_of).collect()))
1618 +
}
1619 +
1620 +
/// The state hash checks are keyed by, from what a client has: a state
1621 +
/// hash, its heads (full change hashes joined with `+`, or the one head of
1622 +
/// a single-head state, which is what `oxy log` shows), or a bookmark.
1623 +
async fn state_key(app: &Shared, repo: &RepoName, s: &str) -> Result<String, ApiError> {
1624 +
    use oxy_core::hash::{hash_object, ChangeHash, ObjectType};
1625 +
    let hexes: Vec<&str> = s.split('+').collect();
1626 +
    let all_hashes = hexes.iter().all(|h| h.len() == 64 && h.bytes().all(|b| b.is_ascii_hexdigit()));
1627 +
    if all_hashes {
1628 +
        let row = db::repos::get(&app.db, &repo.owner, &repo.name).await?.ok_or_else(|| not_found("no such repository"))?;
1629 +
        if hexes.len() == 1 && !db::hooks::checks_for_state(&app.db, row.id, s).await?.is_empty() {
1630 +
            return Ok(s.to_string());
1631 +
        }
1632 +
        let heads: Vec<ChangeHash> = hexes.iter().filter_map(|h| ChangeHash::from_hex(h)).collect();
1633 +
        return Ok(hash_object(ObjectType::State, &oxy_core::objects::StateObject::new(heads).encode()).to_hex());
1634 +
    }
1635 +
    let b = s.to_string();
1636 +
    let found = app.repos.read(repo, move |r, _| Ok(r.view()?.bookmarks.get(&b).copied())).await?;
1637 +
    found.map(|st| st.to_hex()).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, format!("{s:?} is not a state hash, heads or a bookmark")))
1517 1638
}
modified crates/hub/src/db/flow.rs +10 −0
…
147 unchanged lines
148 148
    Ok(id)
149 149
}
150 150
151 +
/// A repository's runs, newest first, numbered below `before`.
152 +
pub async fn runs_by_number(pool: &Pool, repo_id: i64, limit: i64, before: Option<i64>) -> sqlx::Result<Vec<Run>> {
153 +
    sqlx::query_as("SELECT * FROM flow_runs WHERE repo_id = ? AND number < ? ORDER BY number DESC LIMIT ?")
154 +
        .bind(repo_id)
155 +
        .bind(before.unwrap_or(i64::MAX))
156 +
        .bind(limit)
157 +
        .fetch_all(pool)
158 +
        .await
159 +
}
160 +
151 161
pub async fn runs(pool: &Pool, repo_id: i64, limit: i64, before: Option<i64>) -> sqlx::Result<Vec<Run>> {
152 162
    sqlx::query_as("SELECT * FROM flow_runs WHERE repo_id = ? AND id < ? ORDER BY id DESC LIMIT ?")
153 163
        .bind(repo_id)
…
231 unchanged lines
modified crates/hub/tests/flow.rs +87 −2
…
129 unchanged lines
130 130
    vec![]
131 131
}
132 132
133 +
/// The checks on a state once there are `n` of them (they follow the run).
134 +
async fn checks_when(app: &Shared, repo_id: i64, state: &str, n: usize) -> Vec<db::hooks::Check> {
135 +
    for _ in 0..100 {
136 +
        let c = db::hooks::checks_for_state(&app.db, repo_id, state).await.unwrap();
137 +
        if c.len() >= n {
138 +
            return c;
139 +
        }
140 +
        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
141 +
    }
142 +
    db::hooks::checks_for_state(&app.db, repo_id, state).await.unwrap()
143 +
}
144 +
133 145
#[tokio::test(flavor = "multi_thread")]
134 146
async fn a_push_runs_the_pipeline_through_a_runner() {
135 147
    let h = hub().await;
…
17 unchanged lines
153 165
    assert_eq!(run.pipeline_hash.len(), 64);
154 166
    let jobs = db::flow::jobs(&h.app.db, run.id).await.unwrap();
155 167
    assert_eq!(jobs.iter().map(|j| (j.name.as_str(), j.depth)).collect::<Vec<_>>(), [("build", 0), ("lint", 0), ("test", 1)]);
156 -
    let checks = db::hooks::checks_for_state(&h.app.db, run.repo_id, &run.state).await.unwrap();
168 +
    // The checks are reported just after the run is recorded.
169 +
    let checks = checks_when(&h.app, run.repo_id, &run.state, 3).await;
157 170
    assert_eq!(
158 171
        checks.iter().map(|c| (c.name.as_str(), c.status.as_str())).collect::<Vec<_>>(),
159 172
        [("flow/build", "queued"), ("flow/lint", "queued"), ("flow/test", "queued")]
…
371 unchanged lines
531 544
    // Runs are numbered per repository: this is plain's first, whatever
532 545
    // other repositories ran before.
533 546
    assert_eq!(plain[0].number, 1);
534 -
    let checks = db::hooks::checks_for_state(&h.app.db, row.id, &plain[0].state).await.unwrap();
547 +
    let checks = checks_when(&h.app, row.id, &plain[0].state, 3).await;
535 548
    assert!(checks.iter().all(|c| c.url.ends_with("/other/plain/flow/1")), "{checks:?}");
536 549
    let (st, page) = call(&h.app, "GET", "/other/plain/flow/1", None, None).await;
537 550
    assert!(st == StatusCode::OK && page.contains("Run #1"), "{st}");
…
66 unchanged lines
604 617
    db::repos::set_flow(&h.app.db, row.id, "off").await.unwrap();
605 618
    let (st, body) = call(&h.app, "POST", "/api/v1/repos/andy/demo/flow/runs", Some("site"), Some("{}".into())).await;
606 619
    assert!(st == StatusCode::CONFLICT && body.contains("not enabled"), "{body}");
620 +
}
621 +
622 +
/// The Flow API (hub spec §13.2): runs, a run with its jobs, a job's log,
623 +
/// checks by the heads a client has, and JSON for unknown API routes.
624 +
#[tokio::test(flavor = "multi_thread")]
625 +
async fn runs_jobs_and_logs_are_in_the_api() {
626 +
    let h = hub().await;
627 +
    create(&h.app, "andy", "demo").await;
628 +
    let url = format!("{}/andy/demo", h.base);
629 +
    let work = tempfile::tempdir().unwrap();
630 +
    let c = client(&url, work.path());
631 +
    write(work.path(), ".flow/pipeline.kdl", &pipeline(""));
632 +
    write(work.path(), "test.sh", "echo ok\n");
633 +
    let head = c.commit("Add a pipeline", &[]).unwrap();
634 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
635 +
    let run = runs_of(&h.app, "andy", "demo").await.remove(0);
636 +
637 +
    // A job runs and logs.
638 +
    let a = claim(&h.app).await.unwrap();
639 +
    let id = a["job_id"].as_i64().unwrap();
640 +
    let recs =
641 +
        serde_json::json!({ "records": [{ "t": 1, "stream": "stdout", "text": "one" }, { "t": 3, "stream": "stderr", "text": "three" }] });
642 +
    let (st, _) = call(&h.app, "POST", &format!("/api/v1/flow/jobs/{id}/log"), Some("runner-secret"), Some(recs.to_string())).await;
643 +
    assert!(st.is_success(), "{st}");
644 +
645 +
    let (st, body) = call(&h.app, "GET", "/api/v1/repos/andy/demo/flow/runs", None, None).await;
646 +
    assert_eq!(st, StatusCode::OK, "{body}");
647 +
    let runs: Vec<hub_api::FlowRun> = serde_json::from_str(&body).unwrap();
648 +
    assert_eq!(runs.len(), 1);
649 +
    assert_eq!((runs[0].number, runs[0].state.as_str(), runs[0].trigger.as_str()), (1, run.state.as_str(), "push"));
650 +
    assert!(runs[0].url.ends_with("/andy/demo/flow/1") && runs[0].jobs.is_empty());
651 +
652 +
    let (_, body) = call(&h.app, "GET", "/api/v1/repos/andy/demo/flow/runs/1", None, None).await;
653 +
    let one: hub_api::FlowRun = serde_json::from_str(&body).unwrap();
654 +
    let names: Vec<(&str, &str)> = one.jobs.iter().map(|j| (j.name.as_str(), j.status.as_str())).collect();
655 +
    assert_eq!(names.len(), 3);
656 +
    let running = one.jobs.iter().find(|j| j.status == "running").unwrap();
657 +
    assert_eq!(running.name, a["job"]["name"].as_str().unwrap());
658 +
    assert!(one.jobs.iter().any(|j| j.name == "test" && j.depends_on == ["build"]));
659 +
660 +
    let job = running.name.clone();
661 +
    let (st, body) = call(&h.app, "GET", &format!("/api/v1/repos/andy/demo/flow/runs/1/jobs/{job}/log"), None, None).await;
662 +
    assert_eq!(st, StatusCode::OK, "{body}");
663 +
    let lines: Vec<hub_api::FlowLogLine> = serde_json::from_str(&body).unwrap();
664 +
    assert!(lines.iter().any(|l| l.text == "three"), "{body}");
665 +
    let last = lines.last().unwrap().seq;
666 +
    let (_, body) = call(&h.app, "GET", &format!("/api/v1/repos/andy/demo/flow/runs/1/jobs/{job}/log?after={last}"), None, None).await;
667 +
    assert_eq!(body, "[]", "nothing after the last line");
668 +
    assert_eq!(call(&h.app, "GET", "/api/v1/repos/andy/demo/flow/runs/9", None, None).await.0, StatusCode::NOT_FOUND);
669 +
    assert_eq!(call(&h.app, "GET", "/api/v1/repos/andy/demo/flow/runs/1/jobs/nope/log", None, None).await.0, StatusCode::NOT_FOUND);
670 +
671 +
    // Checks by the state hash, by the head `oxy log` shows, and by bookmark.
672 +
    let by = |key: String| {
673 +
        let app = h.app.clone();
674 +
        async move {
675 +
            let (st, body) = call(&app, "GET", &format!("/api/v1/repos/andy/demo/checks/{key}"), None, None).await;
676 +
            assert_eq!(st, StatusCode::OK, "{key}: {body}");
677 +
            serde_json::from_str::<Vec<hub_api::Check>>(&body).unwrap().len()
678 +
        }
679 +
    };
680 +
    checks_when(&h.app, run.repo_id, &run.state, 3).await;
681 +
    assert_eq!(by(run.state.clone()).await, 3);
682 +
    assert_eq!(by(head.to_hex()).await, 3, "a single head names its state");
683 +
    assert_eq!(by("main".into()).await, 3);
684 +
    let (st, _) = call(&h.app, "GET", "/api/v1/repos/andy/demo/checks/no-such-bookmark", None, None).await;
685 +
    assert_eq!(st, StatusCode::NOT_FOUND);
686 +
687 +
    // An unknown API route answers with a problem document.
688 +
    let (st, body) = call(&h.app, "GET", "/api/v1/users/andy/repos", None, None).await;
689 +
    assert_eq!(st, StatusCode::NOT_FOUND);
690 +
    let v: serde_json::Value = serde_json::from_str(&body).unwrap();
691 +
    assert_eq!(v["status"], 404, "{body}");
607 692
}
modified docs/spec.md +1 −1
…
399 unchanged lines
400 400
401 401
### 12.1 API
402 402
403 -
JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses, 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`, `/proposals/{n}/comments|reviews|checks|files|land`, `/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`, `/releases`, `/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/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` (`POST {"bookmark"}`, repo:write: run a bookmark's current state, the default bookmark when none is named) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/search`. 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.
403 +
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`, `/proposals/{n}/comments|reviews|checks|files|land`, `/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`, `/releases`, `/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"}`, repo:write: run a bookmark's current state, the default bookmark when none is named), `/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, `/search`. 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.
404 404
405 405
### 12.2 Webhooks
406 406
…
178 unchanged lines