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 |