Land now, over the checks 2a8b621676b4
When required checks are the only thing in the way (pending, or failed,
say with the runners down and the fix in the proposal itself), a
maintainer may land now with a reason: on the proposal (behind a toggle,
with a reason field and a confirmation), through the API's land with
{"over_checks": "reason"}, or oh proposal land N --over-checks REASON.
Only the checks are passed over; approvals, code owners, changes
requested, drafts, dependencies and the trust policy still apply. A
writer is refused, as is an empty reason. How it landed says so ('web,
over checks: the runners are down') on the proposal, its timeline and
the pushes page, and it is audited (proposal.land_over_checks).
Refs #261
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 221625556d3e
- When
- · 2026-10-18 18:39 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
2a8b621676b42cbc143f985eb3226e83d515504dda995db118836ab6beb672f6- Depends on
- 0b20f1ec4007 1f1eb06a9009 2291f8cc9c96 42fa95540468 4866be1c9040 5c6e92fd8d71 62ceb2eec18c 71e65fcf95ab 93be9ce4653e a1b9869565aa acc85ac0f70b b64e2537d702 cc1cd197df1d d23464c0ac60 de505df5cf8d e97500b70f9a f4d6ef418a96 f677d6270298 f7bcee49bf24
- Depended on by
- 217814c239f0 2abea30809dd 86a69b53aff0
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main proposal-list-fast
- In tags
- 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
- 34 atoms, 0 tree operations, 9 files
Browse the tree at this change
modified crates/hub/src/api/mod.rs +15 −2
| … | 1124 unchanged lines |
||
| 1125 | 1125 | ||
| 1126 | 1126 | /// `POST /repos/{o}/{n}/proposals/{number}/land` (repo:write): land it as |
|
| 1127 | 1127 | /// the page's Land button does; `409` with the blockers when it cannot. |
|
| 1128 | + | /// `{"over_checks": "reason"}` lands over required checks that have not |
|
| 1129 | + | /// passed, for maintainers (#261). |
|
| 1128 | 1130 | async fn land_proposal( |
|
| 1129 | 1131 | State(app): State<Shared>, |
|
| 1130 | 1132 | headers: HeaderMap, |
|
| 1131 | 1133 | Path((owner, name, number)): Path<(String, String, i64)>, |
|
| 1134 | + | body: axum::body::Bytes, |
|
| 1132 | 1135 | ) -> Result<Json<Proposal>, ApiError> { |
|
| 1133 | 1136 | let c = caller(&app, &headers).await?; |
|
| 1134 | 1137 | let me = c.require("repo:write")?.clone(); |
|
| 1135 | - | let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?; |
|
| 1138 | + | let over_checks: Option<String> = if body.iter().all(u8::is_ascii_whitespace) { |
|
| 1139 | + | None |
|
| 1140 | + | } else { |
|
| 1141 | + | serde_json::from_slice::<serde_json::Value>(&body) |
|
| 1142 | + | .map_err(|_| ApiError(StatusCode::BAD_REQUEST, "the body is JSON: {} or {\"over_checks\": \"reason\"}".into()))? |
|
| 1143 | + | .get("over_checks") |
|
| 1144 | + | .and_then(|v| v.as_str()) |
|
| 1145 | + | .map(String::from) |
|
| 1146 | + | }; |
|
| 1147 | + | let need = if over_checks.is_some() { Access::Maintain } else { Access::Write }; |
|
| 1148 | + | let (repo, row, _) = reach(&app, &c, &owner, &name, need).await?; |
|
| 1136 | 1149 | let via = format!("api (token {})", c.token); |
|
| 1137 | - | match crate::web::proposals::land_as(&app, &repo, &row, &me, number, &via, false).await { |
|
| 1150 | + | match crate::web::proposals::land_as(&app, &repo, &row, &me, number, &via, false, over_checks.as_deref()).await { |
|
| 1138 | 1151 | Ok(_) => proposal(State(app), headers, Path((owner, name, number))).await, |
|
| 1139 | 1152 | Err(crate::web::proposals::Landing::NotFound) => Err(not_found("no such proposal")), |
|
| 1140 | 1153 | Err(crate::web::proposals::Landing::Refused(m)) => Err(ApiError(StatusCode::CONFLICT, m)), |
|
| … | 1560 unchanged lines |
modified crates/hub/src/diffusion.rs +1 −1
| … | 1232 unchanged lines |
||
| 1233 | 1233 | } |
|
| 1234 | 1234 | let me = account(&app.db).await.ok_or_else(|| Held::Refused("the diffusion account is missing".into()))?; |
|
| 1235 | 1235 | let via = "Diffusion, both halves green"; |
|
| 1236 | - | match crate::web::proposals::land_as(app, &cname, consumer, &me, bump.number, via, true).await { |
|
| 1236 | + | match crate::web::proposals::land_as(app, &cname, consumer, &me, bump.number, via, true, None).await { |
|
| 1237 | 1237 | Ok(_) => { |
|
| 1238 | 1238 | tracing::info!("diffusion: {}/{} landed #{} itself (both halves green)", consumer.owner, consumer.name, bump.number); |
|
| 1239 | 1239 | Ok(bump.number) |
|
| … | 322 unchanged lines |
modified crates/hub/src/web/proposals.rs +52 −10
| … | 570 unchanged lines |
||
| 571 | 571 | pub reviews: Vec<ReviewView>, |
|
| 572 | 572 | pub approvals: Vec<Approval>, |
|
| 573 | 573 | pub blockers: Vec<String>, |
|
| 574 | + | /// The required-checks blocker, also in `blockers`: the one a |
|
| 575 | + | /// maintainer may land over with a reason (#261). |
|
| 576 | + | pub checks_blocker: Option<String>, |
|
| 574 | 577 | pub depends_on: Vec<i64>, |
|
| 575 | 578 | pub requirements: pdb::Requirements, |
|
| 576 | 579 | } |
|
| … | 28 unchanged lines |
||
| 605 | 608 | let mut blockers = Vec::new(); |
|
| 606 | 609 | // Only an open proposal can be held back. |
|
| 607 | 610 | if !loaded.row.is_open() { |
|
| 608 | - | return Ok(Verdict { reviews, approvals, blockers, depends_on: vec![], requirements }); |
|
| 611 | + | return Ok(Verdict { reviews, approvals, blockers, checks_blocker: None, depends_on: vec![], requirements }); |
|
| 609 | 612 | } |
|
| 610 | 613 | if loaded.row.state == "draft" { |
|
| 611 | 614 | blockers.push("the proposal is a draft".into()); |
|
| … | 14 unchanged lines |
||
| 626 | 629 | if loaded.conflicted && !requirements.allow_conflicts { |
|
| 627 | 630 | blockers.push("landing would carry conflicts, which this repository refuses".into()); |
|
| 628 | 631 | } |
|
| 632 | + | let mut checks_blocker = None; |
|
| 629 | 633 | let required = db::hooks::required_checks(&app.db, repo_id).await?; |
|
| 630 | 634 | if !required.is_empty() { |
|
| 631 | 635 | let checks = db::hooks::checks_for_state(&app.db, repo_id, &loaded.state).await?; |
|
| 632 | 636 | let missing: Vec<String> = |
|
| 633 | 637 | required.iter().filter(|n| !checks.iter().any(|c| &c.name == *n && c.status == "passed")).cloned().collect(); |
|
| 634 | 638 | if !missing.is_empty() { |
|
| 635 | - | blockers.push(format!("required checks not passed on the projected state: {}", missing.join(", "))); |
|
| 639 | + | let b = format!("required checks not passed on the projected state: {}", missing.join(", ")); |
|
| 640 | + | checks_blocker = Some(b.clone()); |
|
| 641 | + | blockers.push(b); |
|
| 636 | 642 | } |
|
| 637 | 643 | } |
|
| 638 | 644 | let requesting: Vec<&String> = latest.iter().filter(|(_, r)| r.verdict == "changes").map(|(who, _)| who).collect(); |
|
| … | 14 unchanged lines |
||
| 653 | 659 | if !depends_on.is_empty() { |
|
| 654 | 660 | blockers.push(format!("depends on {}", depends_on.iter().map(|n| format!("#{n}")).collect::<Vec<_>>().join(", "))); |
|
| 655 | 661 | } |
|
| 656 | - | Ok(Verdict { reviews, approvals, blockers, depends_on, requirements }) |
|
| 662 | + | Ok(Verdict { reviews, approvals, blockers, checks_blocker, depends_on, requirements }) |
|
| 657 | 663 | } |
|
| 658 | 664 | ||
| 659 | 665 | #[derive(Template)] |
|
| … | 43 unchanged lines |
||
| 703 | 709 | /// still there, and the viewer may delete it (oxygenhub#86). |
|
| 704 | 710 | deletable_source: Option<String>, |
|
| 705 | 711 | can_land: bool, |
|
| 712 | + | can_land_over_checks: bool, |
|
| 706 | 713 | hub_can_land: bool, |
|
| 707 | 714 | notice: Option<String>, |
|
| 708 | 715 | error: Option<String>, |
|
| … | 102 unchanged lines |
||
| 811 | 818 | None |
|
| 812 | 819 | }; |
|
| 813 | 820 | let can_land = role >= Role::Write && p.is_open() && v.blockers.is_empty(); |
|
| 821 | + | // Checks alone in the way: a maintainer may land over them (#261). |
|
| 822 | + | let can_land_over_checks = role >= Role::Maintain && p.is_open() && v.checks_blocker.is_some() && v.blockers.len() == 1; |
|
| 814 | 823 | let can_request = p.is_open() && me.is_some_and(|a| Some(a.id) == p.author_id || role >= Role::Write); |
|
| 815 | 824 | let requested = pdb::review_requests(&app.db, p.id).await.unwrap_or_default(); |
|
| 816 | 825 | let mut reviewable: Vec<String> = if can_request { |
|
| … | 79 unchanged lines |
||
| 896 | 905 | can_edit, |
|
| 897 | 906 | deletable_source, |
|
| 898 | 907 | can_land, |
|
| 908 | + | can_land_over_checks, |
|
| 899 | 909 | hub_can_land, |
|
| 900 | 910 | notice, |
|
| 901 | 911 | error, |
|
| … | 252 unchanged lines |
||
| 1154 | 1164 | ||
| 1155 | 1165 | /// Land from the hub: only when the repository has no trust policy (the |
|
| 1156 | 1166 | /// hub never signs a reference update the policy would have to admit). |
|
| 1167 | + | #[derive(serde::Deserialize)] |
|
| 1168 | + | pub struct LandForm { |
|
| 1169 | + | #[serde(rename = "_csrf")] |
|
| 1170 | + | csrf: String, |
|
| 1171 | + | /// Why it lands over its required checks (#261), for maintainers. |
|
| 1172 | + | #[serde(default)] |
|
| 1173 | + | over_checks: Option<String>, |
|
| 1174 | + | } |
|
| 1175 | + | ||
| 1157 | 1176 | pub async fn land( |
|
| 1158 | 1177 | State(app): State<Shared>, |
|
| 1159 | 1178 | viewer: Viewer, |
|
| 1160 | 1179 | Path((owner, name, number)): Path<(String, String, i64)>, |
|
| 1161 | - | Form(f): Form<super::session::CsrfForm>, |
|
| 1180 | + | Form(f): Form<LandForm>, |
|
| 1162 | 1181 | ) -> Result<Response, Error> { |
|
| 1163 | 1182 | let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer); |
|
| 1164 | 1183 | let me = require(&viewer, &shell)?; |
|
| 1165 | 1184 | csrf(&viewer, &f.csrf, &shell)?; |
|
| 1166 | - | let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; |
|
| 1167 | - | match land_as(&app, &repo, &row, &me.account, number, "web", false).await { |
|
| 1185 | + | let need = if f.over_checks.is_some() { Access::Maintain } else { Access::Write }; |
|
| 1186 | + | let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, need).await?; |
|
| 1187 | + | match land_as(&app, &repo, &row, &me.account, number, "web", false, f.over_checks.as_deref()).await { |
|
| 1168 | 1188 | Ok(_) => Ok(back(&owner, &name, number, "conversation", Some("Landed.".into()), None)), |
|
| 1169 | 1189 | Err(Landing::NotFound) => Err(Error::not_found(shell)), |
|
| 1170 | 1190 | Err(Landing::Refused(m)) => Ok(back(&owner, &name, number, "conversation", None, Some(m))), |
|
| … | 11 unchanged lines |
||
| 1182 | 1202 | /// (attested by the hub key on `me`'s behalf when they delegated to it), |
|
| 1183 | 1203 | /// then records, announces and audits the landing, saying how it was done |
|
| 1184 | 1204 | /// (`via`: `web`, or `api (token NAME)`). `as_hub` signs with the hub's own |
|
| 1185 | - | /// key rather than `me`'s delegation (Diffusion's auto-land). Returns the |
|
| 1186 | - | /// new state. |
|
| 1205 | + | /// key rather than `me`'s delegation (Diffusion's auto-land). |
|
| 1206 | + | /// `over_checks`, a reason, lands over required checks that have not |
|
| 1207 | + | /// passed (#261): the caller has checked `me` maintains the repository; |
|
| 1208 | + | /// every other blocker still holds. Returns the new state. |
|
| 1209 | + | #[allow(clippy::too_many_arguments)] |
|
| 1187 | 1210 | pub async fn land_as( |
|
| 1188 | 1211 | app: &Shared, |
|
| 1189 | 1212 | repo: &RepoName, |
|
| … | 2 unchanged lines |
||
| 1192 | 1215 | number: i64, |
|
| 1193 | 1216 | via: &str, |
|
| 1194 | 1217 | as_hub: bool, |
|
| 1218 | + | over_checks: Option<&str>, |
|
| 1195 | 1219 | ) -> Result<String, Landing> { |
|
| 1196 | 1220 | let (owner, name) = (row.owner.clone(), row.name.clone()); |
|
| 1197 | 1221 | let refused = |e: String| Landing::Refused(e); |
|
| … | 2 unchanged lines |
||
| 1200 | 1224 | let v = verdict(app, &loaded, row.id).await.map_err(|e| refused(e.to_string()))?; |
|
| 1201 | 1225 | if !p.is_open() { |
|
| 1202 | 1226 | return Err(refused(format!("Cannot land: the proposal is {}", p.state))); |
|
| 1227 | + | } |
|
| 1228 | + | let reason = over_checks.map(str::trim); |
|
| 1229 | + | if reason == Some("") { |
|
| 1230 | + | return Err(refused("Landing over checks needs a reason: say why it can't wait for them".into())); |
|
| 1203 | 1231 | } |
|
| 1204 | - | if !v.blockers.is_empty() { |
|
| 1205 | - | return Err(refused(format!("Cannot land: {}", v.blockers.join("; ")))); |
|
| 1232 | + | let blocking: Vec<&String> = v.blockers.iter().filter(|b| !(reason.is_some() && v.checks_blocker.as_ref() == Some(*b))).collect(); |
|
| 1233 | + | if !blocking.is_empty() { |
|
| 1234 | + | let list: Vec<&str> = blocking.iter().map(|b| b.as_str()).collect(); |
|
| 1235 | + | return Err(refused(format!("Cannot land: {}", list.join("; ")))); |
|
| 1206 | 1236 | } |
|
| 1237 | + | // Only when the checks were in the way does the landing say so. |
|
| 1238 | + | let over = reason.filter(|_| v.checks_blocker.is_some()); |
|
| 1239 | + | let via_owned = match over { |
|
| 1240 | + | Some(r) => format!("{via}, over checks: {r}"), |
|
| 1241 | + | None => via.to_string(), |
|
| 1242 | + | }; |
|
| 1243 | + | let via = via_owned.as_str(); |
|
| 1207 | 1244 | let target = p.target.clone(); |
|
| 1208 | 1245 | let heads = loaded.heads.clone(); |
|
| 1209 | 1246 | // With a delegation the landing is attested by the hub key on the |
|
| … | 48 unchanged lines |
||
| 1258 | 1295 | super::issues::close_from_messages(app, row, &messages).await; |
|
| 1259 | 1296 | } |
|
| 1260 | 1297 | accounts::audit(&app.db, Some(me), "proposal.landed", &format!("{owner}/{name}#{number}"), &how).await.ok(); |
|
| 1298 | + | if let (Some(r), Some(checks)) = (over, &v.checks_blocker) { |
|
| 1299 | + | accounts::audit(&app.db, Some(me), "proposal.land_over_checks", &format!("{owner}/{name}#{number}"), &format!("{r} ({checks})")) |
|
| 1300 | + | .await |
|
| 1301 | + | .ok(); |
|
| 1302 | + | } |
|
| 1261 | 1303 | db::repos::record_push( |
|
| 1262 | 1304 | &app.db, |
|
| 1263 | 1305 | row.id, |
|
| … | 663 unchanged lines |
modified crates/hub/tests/flow.rs +1 −1
| … | 4249 unchanged lines |
||
| 4250 | 4250 | .await |
|
| 4251 | 4251 | .unwrap(); |
|
| 4252 | 4252 | let repo = RepoName::parse("andy/demo").unwrap(); |
|
| 4253 | - | let state = oxygenhub::web::proposals::land_as(&h.app, &repo, &row, &andy, 1, "web", false).await.ok().expect("landed"); |
|
| 4253 | + | let state = oxygenhub::web::proposals::land_as(&h.app, &repo, &row, &andy, 1, "web", false, None).await.ok().expect("landed"); |
|
| 4254 | 4254 | let mut runs = vec![]; |
|
| 4255 | 4255 | for _ in 0..100 { |
|
| 4256 | 4256 | runs = db::flow::runs(&h.app.db, row.id, 10, None).await.unwrap(); |
|
| … | 1522 unchanged lines |
modified crates/hub/tests/proposals.rs +71 −0
| … | 681 unchanged lines |
||
| 682 | 682 | assert_eq!(state().await, "open"); |
|
| 683 | 683 | } |
|
| 684 | 684 | ||
| 685 | + | /// Landing over checks (#261): with required checks not passed, a |
|
| 686 | + | /// maintainer may land now with a reason, recorded and audited; a writer |
|
| 687 | + | /// may not, nor anyone without a reason. |
|
| 688 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 689 | + | async fn a_maintainer_lands_over_checks_with_a_reason() { |
|
| 690 | + | let h = hub().await; |
|
| 691 | + | let app = &h.app; |
|
| 692 | + | let andy = register(app, "andy").await; |
|
| 693 | + | let bob = register(app, "bob").await; |
|
| 694 | + | let repo = RepoName::parse("andy/demo").unwrap(); |
|
| 695 | + | app.repos.create(&repo, None).unwrap(); |
|
| 696 | + | db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap(); |
|
| 697 | + | let row = db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap(); |
|
| 698 | + | let bob_id = db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id; |
|
| 699 | + | db::accounts::set_collaborator(&app.db, row.id, Some(bob_id), None, "write").await.unwrap(); |
|
| 700 | + | let work = tempfile::tempdir().unwrap(); |
|
| 701 | + | let mut client = Repo::init(work.path()).unwrap(); |
|
| 702 | + | client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap(); |
|
| 703 | + | client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap(); |
|
| 704 | + | client.config.set("remote.origin.token", "site").unwrap(); |
|
| 705 | + | client.save_config().unwrap(); |
|
| 706 | + | write(work.path(), "a.txt", b"one\n"); |
|
| 707 | + | client.snapshot("t").unwrap(); |
|
| 708 | + | let base = client.commit("base", &[]).unwrap(); |
|
| 709 | + | assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 710 | + | client.bookmark_create("feature", Some(&[base])).unwrap(); |
|
| 711 | + | client.bookmark_switch("feature", false).unwrap(); |
|
| 712 | + | write(work.path(), "b.txt", b"b\n"); |
|
| 713 | + | client.snapshot("t").unwrap(); |
|
| 714 | + | client.commit("the fix", &[]).unwrap(); |
|
| 715 | + | assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok)); |
|
| 716 | + | let preview = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&andy)).await; |
|
| 717 | + | let r = post( |
|
| 718 | + | app, |
|
| 719 | + | "/andy/demo/proposals/new", |
|
| 720 | + | Some(&andy), |
|
| 721 | + | &format!("_csrf={}&title=Fix&body=&target=main&source=feature", csrf_of(&preview.body)), |
|
| 722 | + | ) |
|
| 723 | + | .await; |
|
| 724 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 725 | + | db::hooks::set_required_checks(&app.db, row.id, &["flow/test".to_string()]).await.unwrap(); |
|
| 726 | + | ||
| 727 | + | // The checks block it; the maintainer is offered landing over them. |
|
| 728 | + | let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await; |
|
| 729 | + | assert!(page.body.contains("required checks not passed") && page.body.contains("Land now, over the checks"), "{}", page.body); |
|
| 730 | + | let csrf = csrf_of(&page.body); |
|
| 731 | + | let r = act(app, "/andy/demo/proposal/1/land", Some(&andy), &format!("_csrf={csrf}")).await; |
|
| 732 | + | assert!(r.body.contains("Cannot land: required checks not passed"), "plain land is refused: {}", r.body); |
|
| 733 | + | assert_eq!(db::proposals::get(&app.db, row.id, 1).await.unwrap().unwrap().state, "open"); |
|
| 734 | + | // A writer is not offered it and cannot do it. |
|
| 735 | + | let bob_page = get(app, "/andy/demo/proposal/1", Some(&bob)).await; |
|
| 736 | + | assert!(!bob_page.body.contains("Land now, over the checks")); |
|
| 737 | + | let r = post(app, "/andy/demo/proposal/1/land", Some(&bob), &format!("_csrf={}&over_checks=please", csrf_of(&bob_page.body))).await; |
|
| 738 | + | assert_eq!(r.status, StatusCode::NOT_FOUND, "a writer cannot land over checks"); |
|
| 739 | + | assert_eq!(db::proposals::get(&app.db, row.id, 1).await.unwrap().unwrap().state, "open"); |
|
| 740 | + | // No reason, no landing. |
|
| 741 | + | let r = act(app, "/andy/demo/proposal/1/land", Some(&andy), &format!("_csrf={csrf}&over_checks=+")).await; |
|
| 742 | + | assert!(r.body.contains("needs a reason"), "{}", r.body); |
|
| 743 | + | assert_eq!(db::proposals::get(&app.db, row.id, 1).await.unwrap().unwrap().state, "open"); |
|
| 744 | + | // With one: landed, and it says so. |
|
| 745 | + | let r = act(app, "/andy/demo/proposal/1/land", Some(&andy), &format!("_csrf={csrf}&over_checks=the+runners+are+down")).await; |
|
| 746 | + | assert!(r.body.contains("over checks: the runners are down"), "the landing says so: {}", r.body); |
|
| 747 | + | let p = db::proposals::get(&app.db, row.id, 1).await.unwrap().unwrap(); |
|
| 748 | + | assert_eq!(p.state, "landed"); |
|
| 749 | + | assert!(p.landed_via.contains("over checks: the runners are down"), "{}", p.landed_via); |
|
| 750 | + | let audit = db::accounts::audit_rows(&app.db, 20).await.unwrap(); |
|
| 751 | + | assert!(audit |
|
| 752 | + | .iter() |
|
| 753 | + | .any(|a| a.action == "proposal.land_over_checks" && a.detail.contains("the runners are down") && a.detail.contains("flow/test"))); |
|
| 754 | + | } |
|
| 755 | + | ||
| 685 | 756 | /// Metal Gear 2's finding: a landed proposal's "Closes #N" closed nothing, |
|
| 686 | 757 | /// because only changes a push imports were read. Changes that arrive on |
|
| 687 | 758 | /// the default bookmark close issues however they get there. |
|
| … | 173 unchanged lines |
modified crates/oh/src/main.rs +13 −3
| … | 295 unchanged lines |
||
| 296 | 296 | /// The checks on a proposal's projected state |
|
| 297 | 297 | Checks { number: i64 }, |
|
| 298 | 298 | /// Land a proposal on its target, as the page's Land button does |
|
| 299 | - | Land { number: i64 }, |
|
| 299 | + | Land { |
|
| 300 | + | number: i64, |
|
| 301 | + | /// Land before its required checks pass, saying why (maintainers; |
|
| 302 | + | /// recorded and audited, #261) |
|
| 303 | + | #[arg(long, value_name = "REASON")] |
|
| 304 | + | over_checks: Option<String>, |
|
| 305 | + | }, |
|
| 300 | 306 | /// Close a proposal without landing it, with an optional comment |
|
| 301 | 307 | Close { |
|
| 302 | 308 | number: i64, |
|
| … | 840 unchanged lines |
||
| 1143 | 1149 | c.send_text("POST", &format!("{base}/proposals/{number}/comments"), &NewComment { body, path: None, line: None })?; |
|
| 1144 | 1150 | println!("{} commented on #{number}", ui::ok("✔")); |
|
| 1145 | 1151 | } |
|
| 1146 | - | ProposalCmd::Land { number } => { |
|
| 1147 | - | let p: Proposal = c.send("POST", &format!("{base}/proposals/{number}/land"), &serde_json::json!({}))?; |
|
| 1152 | + | ProposalCmd::Land { number, over_checks } => { |
|
| 1153 | + | let body = match &over_checks { |
|
| 1154 | + | Some(r) => serde_json::json!({ "over_checks": r }), |
|
| 1155 | + | None => serde_json::json!({}), |
|
| 1156 | + | }; |
|
| 1157 | + | let p: Proposal = c.send("POST", &format!("{base}/proposals/{number}/land"), &body)?; |
|
| 1148 | 1158 | if json { |
|
| 1149 | 1159 | println!("{}", serde_json::to_string(&p)?); |
|
| 1150 | 1160 | } else { |
|
| … | 597 unchanged lines |
modified docs/spec.md +2 −0
| … | 396 unchanged lines |
||
| 397 | 397 | ||
| 398 | 398 | The landed state hash is recorded. Nothing is rewritten: the changes keep their hashes, so links to them stay valid and check results carry over. "Squash on land" is offered as a client-side option only, since it rewrites signed changes. |
|
| 399 | 399 | ||
| 400 | + | **Landing over checks** (oxygenhub#261). When required checks are the only thing in the way (pending or failed on the projected state, for instance with the runners down and the fix in this very proposal), a repository maintainer or admin may land now with a reason: *Land now, over the checks* on the proposal (a reason field, and a confirmation), `POST /repos/{owner}/{name}/proposals/{n}/land` with `{"over_checks": "REASON"}`, or `oh proposal land N --over-checks REASON`. Only the checks are passed over: approvals, code owners, changes requested, drafts, dependencies and the trust policy still apply, a writer is refused, and an empty reason is refused. How it landed says so ("web, over checks: the runners are down"), on the proposal, its timeline and the pushes page, and it is audited (`proposal.land_over_checks`, with the reason and the checks that had not passed). |
|
| 401 | + | ||
| 400 | 402 | ### 7.7 Closing and reopening |
|
| 401 | 403 | ||
| 402 | 404 | `closed` keeps everything; the source bookmark may be deleted on close if the user asks. A proposal whose changes all appear in the target through another route is marked `landed` automatically with a note. |
|
| … | 433 unchanged lines |
modified templates/proposal.html +6 −0
| … | 194 unchanged lines |
||
| 195 | 195 | {% if hub_can_land %}<form method="post" action="{{ repo.url }}/proposal/{{ p.number }}/land"{% if approvals.is_empty() %} onsubmit="return confirm('Land #{{ p.number }} without any approval?')"{% endif %}><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" data-busy="LANDING">LAND</button></form> |
|
| 196 | 196 | {% else %}<p class="small">This repository has a trust policy, so land from a client with your key:</p><p class="secret">oxy proposal land {{ shell.base_url }}{{ repo.url }}/proposal/{{ p.number }}</p>{% endif %} |
|
| 197 | 197 | {% endif %} |
|
| 198 | + | {% if can_land_over_checks && hub_can_land %}<details class="land-over"><summary class="small">Land now, over the checks</summary> |
|
| 199 | + | <form method="post" action="{{ repo.url }}/proposal/{{ p.number }}/land" data-confirm="Land #{{ p.number }} before its required checks pass?"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 200 | + | <label for="over-checks" class="small">Why it can't wait for them (shown on the proposal, and audited)</label> |
|
| 201 | + | <input type="text" id="over-checks" name="over_checks" required maxlength="300" placeholder="the runners are down; this is the fix"> |
|
| 202 | + | <button type="submit" class="danger-btn" data-busy="LANDING">LAND OVER CHECKS</button> |
|
| 203 | + | </form></details>{% endif %} |
|
| 198 | 204 | {% endif %} |
|
| 199 | 205 | <!--/frag:landing--></section> |
|
| 200 | 206 | {% if !closes.is_empty() %}<section class="side-sec"><div class="side-head"><h3>Closes</h3></div><ul class="plain small side-list">{% for c in closes %}<li><a href="{{ c.url }}" class="kind-issue">issue #{{ c.number }}</a> {{ c.title }} <span class="mute">{{ c.state }}</span></li>{% endfor %}</ul></section>{% endif %} |
|
| … | 56 unchanged lines |
modified templates/style.css +4 −0
| … | 896 unchanged lines |
||
| 897 | 897 | body.wall .kpi-v { font-size: 40px; } |
|
| 898 | 898 | body.wall .kpi-l { font-size: 15px; } |
|
| 899 | 899 | body.wall .chart-grid { grid-template-columns: repeat(auto-fit, minmax(min(100%, 560px), 1fr)); } |
|
| 900 | + | /* Landing over required checks (#261): behind a toggle, with its reason. */ |
|
| 901 | + | details.land-over { margin-top: 10px; } |
|
| 902 | + | details.land-over > summary { cursor: pointer; color: var(--mute); } |
|
| 903 | + | details.land-over form { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; } |
|
| 900 | 904 | /* A Terraform state's kept versions (#255). */ |
|
| 901 | 905 | details.tf-versions { margin-top: 14px; } |
|
| 902 | 906 | details.tf-versions > summary { cursor: pointer; } |
|
| … | 459 unchanged lines |