OXYGENHUB

Required checks over the API: GET and PUT /repos/{o}/{n}/checks/required (admins, the whole list, audited) 961c10bee174

refs #20
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-28 19:53 UTC
Signature
✔ signed by Andy McCall
Hash
961c10bee174b10c2192a049e553e297013a2f56b3fef0df6800012b64c62602
Depends on
76f262e1a666 93be9ce4653e b64e2537d702 d23464c0ac60 ef20fdc14e8b
Depended on by
c7067c7bda56 c8684d44d549 47e374020b59 417184c73f4e 745d225de344 1edac4b68c6d 9008bf6c2b8c
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
8 atoms, 0 tree operations, 5 files

Browse the tree at this change

modified crates/hub-api/src/lib.rs +9 −0
…
562 unchanged lines
563 563
    pub milestone: Option<String>,
564 564
}
565 565
566 +
/// The checks a proposal needs, passed, to land on the repository's
567 +
/// default bookmark (hub spec §13.1). `GET` and `PUT
568 +
/// /repos/{o}/{n}/checks/required`.
569 +
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
570 +
pub struct RequiredChecks {
571 +
    pub names: Vec<String>,
572 +
}
573 +
566 574
/// A project (hub spec §8.2).
567 575
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
568 576
pub struct Project {
…
229 unchanged lines
798 806
        ("NewIssue", schemars::schema_for!(NewIssue)),
799 807
        ("IssueEdit", schemars::schema_for!(IssueEdit)),
800 808
        ("NewLabel", schemars::schema_for!(NewLabel)),
809 +
        ("RequiredChecks", schemars::schema_for!(RequiredChecks)),
801 810
        ("Project", schemars::schema_for!(Project)),
802 811
        ("ProjectCard", schemars::schema_for!(ProjectCard)),
803 812
        ("ProjectColumn", schemars::schema_for!(ProjectColumn)),
…
16 unchanged lines
modified crates/hub-api/ts/index.d.ts +4 −0
…
354 unchanged lines
355 355
  name: string;
356 356
};
357 357
358 +
export type RequiredChecks = {
359 +
  names: string[];
360 +
};
361 +
358 362
export type Project = {
359 363
  closed: boolean;
360 364
  description: string;
…
120 unchanged lines
modified crates/hub/src/api/mod.rs +33 −0
…
61 unchanged lines
62 62
        .route("/repos/{owner}/{name}/hooks", get(hooks).post(create_hook))
63 63
        .route("/repos/{owner}/{name}/hooks/{id}/delete", post(delete_hook))
64 64
        .route("/repos/{owner}/{name}/checks", post(post_check))
65 +
        .route("/repos/{owner}/{name}/checks/required", get(required_checks).put(set_required_checks))
65 66
        .route("/repos/{owner}/{name}/checks/{state}", get(checks_for_state))
66 67
        .route("/repos/{owner}/{name}/releases", get(releases).post(create_release))
67 68
        .route("/repos/{owner}/{name}/releases/{tag}/assets", post(upload_asset))
…
1322 unchanged lines
1390 1391
        started_ms: c.started_ms,
1391 1392
        finished_ms: c.finished_ms,
1392 1393
    }
1394 +
}
1395 +
1396 +
/// `GET /repos/{o}/{n}/checks/required`.
1397 +
async fn required_checks(
1398 +
    State(app): State<Shared>,
1399 +
    headers: HeaderMap,
1400 +
    Path((owner, name)): Path<(String, String)>,
1401 +
) -> Result<Json<RequiredChecks>, ApiError> {
1402 +
    let c = caller(&app, &headers).await?;
1403 +
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
1404 +
    Ok(Json(RequiredChecks { names: db::hooks::required_checks(&app.db, row.id).await? }))
1405 +
}
1406 +
1407 +
/// `PUT /repos/{o}/{n}/checks/required` (repository admin): the whole list.
1408 +
async fn set_required_checks(
1409 +
    State(app): State<Shared>,
1410 +
    headers: HeaderMap,
1411 +
    Path((owner, name)): Path<(String, String)>,
1412 +
    Json(body): Json<RequiredChecks>,
1413 +
) -> Result<Json<RequiredChecks>, ApiError> {
1414 +
    let c = caller(&app, &headers).await?;
1415 +
    c.require("repo:admin")?;
1416 +
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Admin).await?;
1417 +
    let mut names: Vec<String> = body.names.iter().map(|n| n.trim().to_string()).filter(|n| !n.is_empty()).collect();
1418 +
    names.sort();
1419 +
    names.dedup();
1420 +
    if names.len() > 32 || names.iter().any(|n| n.len() > 100 || n.contains(',')) {
1421 +
        return Err(ApiError(StatusCode::BAD_REQUEST, "at most 32 check names, each under 100 characters and without commas".into()));
1422 +
    }
1423 +
    db::hooks::set_required_checks(&app.db, row.id, &names).await?;
1424 +
    db::accounts::audit(&app.db, c.account.as_ref(), "repo.required_checks", &format!("{owner}/{name}"), &names.join(", ")).await.ok();
1425 +
    Ok(Json(RequiredChecks { names }))
1393 1426
}
1394 1427
1395 1428
/// `POST /repos/{o}/{n}/checks`: any `checks:write` (or write) token may
…
43 unchanged lines
modified crates/hub/tests/api.rs +50 −0
…
651 unchanged lines
652 652
    assert_eq!(api(app, "DELETE", &format!("/api/v1/projects/andy/1/cards/{id}"), Some(&a), None).await.0, StatusCode::NO_CONTENT);
653 653
    assert_eq!(api(app, "DELETE", &format!("/api/v1/projects/andy/1/cards/{id}"), Some(&a), None).await.0, StatusCode::NOT_FOUND);
654 654
}
655 +
656 +
#[tokio::test(flavor = "multi_thread")]
657 +
async fn api_required_checks() {
658 +
    let h = hub().await;
659 +
    let app = &h.app;
660 +
    let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery"))
661 +
        .await
662 +
        .cookie
663 +
        .unwrap();
664 +
    let page = call(app, "GET", "/settings/tokens", None, Some(&andy), None, None).await;
665 +
    let csrf = csrf_of(&page.body);
666 +
    let mint = |scopes: &'static str| {
667 +
        let (app, andy, csrf) = (app.clone(), andy.clone(), csrf.clone());
668 +
        async move {
669 +
            let r = call(&app, "POST", "/settings/tokens", None, Some(&andy), None, Some(&format!("_csrf={csrf}&name=t&{scopes}"))).await;
670 +
            r.body.split("oxyhub_").nth(1).map(|s| format!("oxyhub_{}", s.split('<').next().unwrap().trim())).unwrap()
671 +
        }
672 +
    };
673 +
    let admin = mint("scopes=repo%3Aadmin").await;
674 +
    let write = mint("scopes=repo%3Aread&scopes=repo%3Awrite").await;
675 +
    api(app, "POST", "/api/v1/repos", Some(&admin), Some(r#"{"owner":"andy","name":"demo","visibility":"public"}"#)).await;
676 +
677 +
    let (st, v) = api(app, "GET", "/api/v1/repos/andy/demo/checks/required", None, None).await;
678 +
    assert_eq!((st, v["names"].clone()), (StatusCode::OK, serde_json::json!([])));
679 +
    let (st, v) = api(
680 +
        app,
681 +
        "PUT",
682 +
        "/api/v1/repos/andy/demo/checks/required",
683 +
        Some(&admin),
684 +
        Some(r#"{"names":["flow/test"," flow/fmt ","flow/test",""]}"#),
685 +
    )
686 +
    .await;
687 +
    assert_eq!((st, v["names"].clone()), (StatusCode::OK, serde_json::json!(["flow/fmt", "flow/test"])), "trimmed, sorted, once each");
688 +
    assert_eq!(oxygenhub::db::hooks::required_checks(&app.db, 1).await.unwrap(), ["flow/fmt", "flow/test"]);
689 +
    assert_eq!(
690 +
        api(app, "GET", "/api/v1/repos/andy/demo/checks/required", None, None).await.1["names"],
691 +
        serde_json::json!(["flow/fmt", "flow/test"])
692 +
    );
693 +
    assert_eq!(
694 +
        api(app, "PUT", "/api/v1/repos/andy/demo/checks/required", Some(&write), Some(r#"{"names":[]}"#)).await.0,
695 +
        StatusCode::FORBIDDEN,
696 +
        "repo:admin only"
697 +
    );
698 +
    assert_eq!(
699 +
        api(app, "PUT", "/api/v1/repos/andy/demo/checks/required", Some(&admin), Some(r#"{"names":["a,b"]}"#)).await.0,
700 +
        StatusCode::BAD_REQUEST
701 +
    );
702 +
    // The settings page shows the same list.
703 +
    assert!(call(app, "GET", "/andy/demo/settings", None, Some(&andy), None, None).await.body.contains("flow/fmt, flow/test"));
704 +
}
modified docs/spec.md +1 −1
…
398 unchanged lines
399 399
400 400
### 12.1 API
401 401
402 -
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}`, `/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), `/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.
402 +
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), `/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 403
404 404
### 12.2 Webhooks
405 405
…
171 unchanged lines