Proposals from feature/ bookmarks; a landing says who and how 0b20f1ec4007
A proposal's source is any bookmark of the repository by name, so feature/16-hud works, and owner/name:bookmark naming the repository itself is its own bookmark; a source that is not a bookmark is named in the refusal (closes #62). A landing records who landed the proposal and how (web, or the API with the token's name), in the proposal, its event, the audit log and the push record, adding when nobody approved. The page says when the repository requires no approval and nobody has approved, and Land asks first (refs #47). A landed proposal keeps showing the changes it landed, with who landed it, how and when, and reports no blockers (closes #48). The proposals API takes ?state=open|draft|landed|closed|all, and oh proposal list takes --state (closes #49).
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-30 13:47 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
0b20f1ec4007b9c864a8cebcb8945898b88c391cc0c6d85f8f58aeb60f685b47- Depends on
- 145306044896 1f1eb06a9009 42a0ba4bac4e 5c6e92fd8d71 93be9ce4653e a1b9869565aa b64e2537d702 d23464c0ac60 de505df5cf8d
- Depended on by
- 668b2ad7495c d7e2c2754807 1000b40a2629 ecac6e8d9b41 8597fb5ca16c c7067c7bda56 2abea30809dd 86a69b53aff0 2a8b621676b4 05f1a85a2842 5d90e0e045e2 f4d6ef418a96 46b5e2c59c6b 16626c0d69c0 ac713df7b38b 4f37afcc3583 2291f8cc9c96 739a763a558c 60f11b71a740 cc1cd197df1d
- 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
- 50 atoms, 1 tree operation, 13 files
Browse the tree at this change
added crates/hub/migrations/0024_proposal_landing.sql +5 −0
| 1 | + | -- Who landed a proposal, how (from the web, or through the API with which |
|
| 2 | + | -- token) and when (oxygenhub#47, #48). |
|
| 3 | + | ALTER TABLE proposals ADD COLUMN landed_by TEXT NOT NULL DEFAULT ''; |
|
| 4 | + | ALTER TABLE proposals ADD COLUMN landed_via TEXT NOT NULL DEFAULT ''; |
|
| 5 | + | ALTER TABLE proposals ADD COLUMN landed_ms INTEGER NOT NULL DEFAULT 0; |
modified crates/hub-api/src/lib.rs +12 −0
| … | 166 unchanged lines |
||
| 167 | 167 | pub repository_url: String, |
|
| 168 | 168 | /// The fork's clone URL when the source is a fork. |
|
| 169 | 169 | pub source_url: Option<String>, |
|
| 170 | + | /// Who landed it, how and when, once landed. |
|
| 171 | + | #[serde(default)] |
|
| 172 | + | pub landed: Option<Landed>, |
|
| 173 | + | } |
|
| 174 | + | ||
| 175 | + | /// How a proposal was landed. |
|
| 176 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 177 | + | pub struct Landed { |
|
| 178 | + | pub by: String, |
|
| 179 | + | /// `web`, or `api (token NAME)`. |
|
| 180 | + | pub via: String, |
|
| 181 | + | pub when_ms: i64, |
|
| 170 | 182 | } |
|
| 171 | 183 | ||
| 172 | 184 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| … | 746 unchanged lines |
modified crates/hub/src/api/mod.rs +26 −5
| … | 179 unchanged lines |
||
| 180 | 180 | pub account: Option<Account>, |
|
| 181 | 181 | pub scopes: Vec<String>, |
|
| 182 | 182 | pub site: bool, |
|
| 183 | + | /// The token's name, for records of what a call did. |
|
| 184 | + | pub token: String, |
|
| 183 | 185 | } |
|
| 184 | 186 | ||
| 185 | 187 | impl Caller { |
|
| … | 15 unchanged lines |
||
| 201 | 203 | } |
|
| 202 | 204 | ||
| 203 | 205 | pub async fn caller(app: &Shared, headers: &HeaderMap) -> Result<Caller, ApiError> { |
|
| 204 | - | let Some(t) = oxy_server::bearer_token(headers) else { return Ok(Caller { account: None, scopes: vec![], site: false }) }; |
|
| 206 | + | let Some(t) = oxy_server::bearer_token(headers) else { |
|
| 207 | + | return Ok(Caller { account: None, scopes: vec![], site: false, token: String::new() }); |
|
| 208 | + | }; |
|
| 205 | 209 | if app.config.auth.push_tokens.iter().any(|x| auth::mac_equal(x, t)) { |
|
| 206 | 210 | let admin = db::accounts::accounts(&app.db).await?.into_iter().find(|a| a.is_admin); |
|
| 207 | - | return Ok(Caller { account: admin, scopes: vec!["repo:admin".into(), "user:read".into(), "checks:write".into()], site: true }); |
|
| 211 | + | return Ok(Caller { |
|
| 212 | + | account: admin, |
|
| 213 | + | scopes: vec!["repo:admin".into(), "user:read".into(), "checks:write".into()], |
|
| 214 | + | site: true, |
|
| 215 | + | token: "site token".into(), |
|
| 216 | + | }); |
|
| 208 | 217 | } |
|
| 209 | 218 | let token = db::accounts::token_by_hash(&app.db, &auth::token_hash(t)).await?; |
|
| 210 | 219 | match token { |
|
| 211 | 220 | Some(tok) if !tok.expired(db::now_ms()) => { |
|
| 212 | 221 | let account = db::accounts::account_by_id(&app.db, tok.account_id).await?.filter(|a| !a.banned); |
|
| 213 | 222 | db::accounts::touch_token(&app.db, tok.id).await.ok(); |
|
| 214 | - | Ok(Caller { account, scopes: tok.scope_list().iter().map(|s| s.to_string()).collect(), site: false }) |
|
| 223 | + | Ok(Caller { account, scopes: tok.scope_list().iter().map(|s| s.to_string()).collect(), site: false, token: tok.name.clone() }) |
|
| 215 | 224 | } |
|
| 216 | 225 | _ => Err(ApiError(StatusCode::UNAUTHORIZED, "unknown or expired token".into())), |
|
| 217 | 226 | } |
|
| … | 465 unchanged lines |
||
| 683 | 692 | ||
| 684 | 693 | // --------------------------------------------------------------- proposals |
|
| 685 | 694 | ||
| 695 | + | /// `GET /repos/{o}/{n}/proposals?state=open|draft|landed|closed|all`: |
|
| 696 | + | /// open ones (drafts too) by default. |
|
| 686 | 697 | async fn proposals( |
|
| 687 | 698 | State(app): State<Shared>, |
|
| 688 | 699 | headers: HeaderMap, |
|
| 689 | 700 | Path((owner, name)): Path<(String, String)>, |
|
| 701 | + | Query(q): Query<IssuesQuery>, |
|
| 690 | 702 | ) -> Result<Json<Vec<Proposal>>, ApiError> { |
|
| 691 | 703 | let c = caller(&app, &headers).await?; |
|
| 692 | 704 | let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
|
| 705 | + | let states: &[&str] = match q.state.as_deref().unwrap_or("open") { |
|
| 706 | + | "open" => &["open", "draft"], |
|
| 707 | + | "draft" => &["draft"], |
|
| 708 | + | "landed" => &["landed"], |
|
| 709 | + | "closed" => &["closed"], |
|
| 710 | + | "all" => &["open", "draft", "landed", "closed"], |
|
| 711 | + | other => return Err(ApiError(StatusCode::BAD_REQUEST, format!("state is open, draft, landed, closed or all, not {other:?}"))), |
|
| 712 | + | }; |
|
| 693 | 713 | let mut out = Vec::new(); |
|
| 694 | - | for p in db::proposals::list(&app.db, row.id, &["open", "draft"]).await? { |
|
| 714 | + | for p in db::proposals::list(&app.db, row.id, states).await? { |
|
| 695 | 715 | let loaded = crate::web::proposals::load(&app, &repo, p).await?; |
|
| 696 | 716 | let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?; |
|
| 697 | 717 | out.push(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, None).await); |
|
| … | 28 unchanged lines |
||
| 726 | 746 | let c = caller(&app, &headers).await?; |
|
| 727 | 747 | let me = c.require("repo:write")?.clone(); |
|
| 728 | 748 | let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?; |
|
| 729 | - | match crate::web::proposals::land_as(&app, &repo, &row, &me, number).await { |
|
| 749 | + | let via = format!("api (token {})", c.token); |
|
| 750 | + | match crate::web::proposals::land_as(&app, &repo, &row, &me, number, &via).await { |
|
| 730 | 751 | Ok(_) => proposal(State(app), headers, Path((owner, name, number))).await, |
|
| 731 | 752 | Err(crate::web::proposals::Landing::NotFound) => Err(not_found("no such proposal")), |
|
| 732 | 753 | Err(crate::web::proposals::Landing::Refused(m)) => Err(ApiError(StatusCode::CONFLICT, m)), |
|
| … | 955 unchanged lines |
modified crates/hub/src/db/proposals.rs +20 −0
| … | 24 unchanged lines |
||
| 25 | 25 | pub projected_changes: String, |
|
| 26 | 26 | pub conflicted: bool, |
|
| 27 | 27 | pub landed_state: String, |
|
| 28 | + | pub landed_by: String, |
|
| 29 | + | pub landed_via: String, |
|
| 30 | + | pub landed_ms: i64, |
|
| 28 | 31 | pub created_ms: i64, |
|
| 29 | 32 | pub updated_ms: i64, |
|
| 30 | 33 | } |
|
| … | 146 unchanged lines |
||
| 177 | 180 | .bind(id) |
|
| 178 | 181 | .execute(pool) |
|
| 179 | 182 | .await?; |
|
| 183 | + | Ok(()) |
|
| 184 | + | } |
|
| 185 | + | ||
| 186 | + | /// Mark a proposal landed, with who landed it and how. |
|
| 187 | + | pub async fn set_landed(pool: &Pool, id: i64, landed_state: &str, by: &str, via: &str) -> sqlx::Result<()> { |
|
| 188 | + | let now = now_ms(); |
|
| 189 | + | sqlx::query( |
|
| 190 | + | "UPDATE proposals SET state = 'landed', landed_state = ?, landed_by = ?, landed_via = ?, landed_ms = ?, updated_ms = ? WHERE id = ?", |
|
| 191 | + | ) |
|
| 192 | + | .bind(landed_state) |
|
| 193 | + | .bind(by) |
|
| 194 | + | .bind(via) |
|
| 195 | + | .bind(now) |
|
| 196 | + | .bind(now) |
|
| 197 | + | .bind(id) |
|
| 198 | + | .execute(pool) |
|
| 199 | + | .await?; |
|
| 180 | 200 | Ok(()) |
|
| 181 | 201 | } |
|
| 182 | 202 | ||
| … | 116 unchanged lines |
modified crates/hub/src/web/proposals.rs +54 −11
| … | 145 unchanged lines |
||
| 146 | 146 | } |
|
| 147 | 147 | ||
| 148 | 148 | /// `owner/name:bookmark` for a fork, else a bookmark of this repository. |
|
| 149 | + | /// A proposal's source: a bookmark of this repository by name (which may |
|
| 150 | + | /// hold `/`, as `feature/16-hud` does), or `owner/name:bookmark` of a fork; |
|
| 151 | + | /// `owner/name:bookmark` naming this repository is its own bookmark. |
|
| 149 | 152 | fn parse_source(app: &Shared, repo: &RepoName, source: &str) -> Option<(Option<RepoName>, String)> { |
|
| 153 | + | let plain = |b: &str| !b.is_empty() && !b.chars().any(|c| c.is_control() || c == ':'); |
|
| 150 | 154 | if let Some((slug, bookmark)) = source.split_once(':') { |
|
| 151 | 155 | let fork = RepoName::parse(slug)?; |
|
| 152 | - | if !app.repos.exists(&fork) || fork == *repo { |
|
| 156 | + | if !plain(bookmark) { |
|
| 153 | 157 | return None; |
|
| 158 | + | } |
|
| 159 | + | if fork == *repo { |
|
| 160 | + | return Some((None, bookmark.to_string())); |
|
| 154 | 161 | } |
|
| 162 | + | if !app.repos.exists(&fork) { |
|
| 163 | + | return None; |
|
| 164 | + | } |
|
| 155 | 165 | return Some((Some(fork), bookmark.to_string())); |
|
| 156 | 166 | } |
|
| 157 | - | crate::repos::names::valid_segment(source).then(|| (None, source.to_string())) |
|
| 167 | + | plain(source).then(|| (None, source.to_string())) |
|
| 158 | 168 | } |
|
| 159 | 169 | ||
| 160 | 170 | async fn preview_for( |
|
| … | 90 unchanged lines |
||
| 251 | 261 | let Some((fork, bookmark)) = parse_source(app, repo, source.trim()) else { |
|
| 252 | 262 | return Err("The source is a bookmark of this repository, or owner/name:bookmark of a fork.".into()); |
|
| 253 | 263 | }; |
|
| 264 | + | if fork.is_none() && !bookmarks.contains(&bookmark) { |
|
| 265 | + | return Err(format!("There is no bookmark {bookmark} in this repository.")); |
|
| 266 | + | } |
|
| 254 | 267 | if fork.is_none() && bookmark == target { |
|
| 255 | 268 | return Err("The source and the target are the same bookmark.".into()); |
|
| 256 | 269 | } |
|
| … | 175 unchanged lines |
||
| 432 | 445 | let owners = pl::owners_for(&target_loaded, &touched); |
|
| 433 | 446 | let sealing = pl::sealing_keys(r, &p.hashes)?.into_iter().map(|k| k.to_hex()).collect(); |
|
| 434 | 447 | let landed_already = !p.hashes.is_empty() && p.hashes.iter().all(|h| target_loaded.state.contains(h)); |
|
| 448 | + | // A landed proposal's changes are in its target now: it shows |
|
| 449 | + | // the ones it landed, as recorded when it landed. |
|
| 450 | + | let (changes, hashes) = if r2.state == "landed" && p.hashes.is_empty() { |
|
| 451 | + | let hashes: Vec<ChangeHash> = r2.projected_changes().iter().filter_map(|h| ChangeHash::from_hex(h)).collect(); |
|
| 452 | + | let labels = HashMap::new(); |
|
| 453 | + | let changes = hashes |
|
| 454 | + | .iter() |
|
| 455 | + | .filter_map(|h| Some(view::change_view_pub(r, h, &r.store().get_change_meta(h).ok()?, false, &labels))) |
|
| 456 | + | .collect(); |
|
| 457 | + | (changes, hashes) |
|
| 458 | + | } else { |
|
| 459 | + | (p.changes, p.hashes) |
|
| 460 | + | }; |
|
| 435 | 461 | Ok(Loaded { |
|
| 436 | 462 | row: r2, |
|
| 437 | - | changes: p.changes, |
|
| 438 | - | hashes: p.hashes, |
|
| 463 | + | changes, |
|
| 464 | + | hashes, |
|
| 439 | 465 | heads: p.heads, |
|
| 440 | 466 | target_heads: p.target_heads.clone(), |
|
| 441 | 467 | state: p.state.to_hex(), |
|
| … | 11 unchanged lines |
||
| 453 | 479 | }) |
|
| 454 | 480 | }) |
|
| 455 | 481 | .await?; |
|
| 456 | - | // Remember the projection for the list, the API and other proposals' stacking checks. |
|
| 482 | + | // Remember the projection for the list, the API and other proposals' |
|
| 483 | + | // stacking checks, while the proposal is open: a landed one keeps what |
|
| 484 | + | // it landed. |
|
| 485 | + | if !loaded.row.is_open() { |
|
| 486 | + | return Ok(loaded); |
|
| 487 | + | } |
|
| 457 | 488 | let heads_csv = loaded.heads.iter().map(|h| h.to_hex()).collect::<Vec<_>>().join(","); |
|
| 458 | 489 | let changes_csv = loaded.hashes.iter().map(|h| h.to_hex()).collect::<Vec<_>>().join(","); |
|
| 459 | 490 | pdb::set_projection(&app.db, loaded.row.id, &loaded.state, &heads_csv, &loaded.target_state, &changes_csv, loaded.conflicted) |
|
| … | 38 unchanged lines |
||
| 498 | 529 | .collect(); |
|
| 499 | 530 | let requirements = pdb::requirements(&app.db, repo_id).await?; |
|
| 500 | 531 | let mut blockers = Vec::new(); |
|
| 532 | + | // Only an open proposal can be held back. |
|
| 533 | + | if !loaded.row.is_open() { |
|
| 534 | + | return Ok(Verdict { reviews, approvals, blockers, depends_on: vec![], requirements }); |
|
| 535 | + | } |
|
| 501 | 536 | if loaded.row.state == "draft" { |
|
| 502 | 537 | blockers.push("the proposal is a draft".into()); |
|
| 503 | 538 | } |
|
| … | 481 unchanged lines |
||
| 985 | 1020 | let me = require(&viewer, &shell)?; |
|
| 986 | 1021 | csrf(&viewer, &f.csrf, &shell)?; |
|
| 987 | 1022 | let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; |
|
| 988 | - | match land_as(&app, &repo, &row, &me.account, number).await { |
|
| 1023 | + | match land_as(&app, &repo, &row, &me.account, number, "web").await { |
|
| 989 | 1024 | Ok(_) => Ok(back(&owner, &name, number, "conversation", Some("Landed.".into()), None)), |
|
| 990 | 1025 | Err(Landing::NotFound) => Err(Error::not_found(shell)), |
|
| 991 | 1026 | Err(Landing::Refused(m)) => Ok(back(&owner, &name, number, "conversation", None, Some(m))), |
|
| … | 9 unchanged lines |
||
| 1001 | 1036 | /// Land proposal `number` as `me` (hub spec §8.4), who has write access: |
|
| 1002 | 1037 | /// refused while it is not open or has blockers. Moves the target bookmark |
|
| 1003 | 1038 | /// (attested by the hub key on `me`'s behalf when they delegated to it), |
|
| 1004 | - | /// then records, announces and audits the landing. Returns the new state. |
|
| 1039 | + | /// then records, announces and audits the landing, saying how it was done |
|
| 1040 | + | /// (`via`: `web`, or `api (token NAME)`). Returns the new state. |
|
| 1005 | 1041 | pub async fn land_as( |
|
| 1006 | 1042 | app: &Shared, |
|
| 1007 | 1043 | repo: &RepoName, |
|
| 1008 | 1044 | row: &db::repos::RepoRow, |
|
| 1009 | 1045 | me: &accounts::Account, |
|
| 1010 | 1046 | number: i64, |
|
| 1047 | + | via: &str, |
|
| 1011 | 1048 | ) -> Result<String, Landing> { |
|
| 1012 | 1049 | let (owner, name) = (row.owner.clone(), row.name.clone()); |
|
| 1013 | 1050 | let refused = |e: String| Landing::Refused(e); |
|
| … | 29 unchanged lines |
||
| 1043 | 1080 | } |
|
| 1044 | 1081 | }; |
|
| 1045 | 1082 | let state = result.map_err(|e| refused(e.to_string()))?; |
|
| 1046 | - | pdb::set_state(&app.db, p.id, "landed", &state).await.map_err(|e| refused(e.to_string()))?; |
|
| 1047 | - | pdb::add_event(&app.db, p.id, &me.username, "landed", "from the hub").await.ok(); |
|
| 1083 | + | pdb::set_landed(&app.db, p.id, &state, &me.username, via).await.map_err(|e| refused(e.to_string()))?; |
|
| 1084 | + | let how = if v.approvals.is_empty() { format!("{via}, with no approval") } else { via.to_string() }; |
|
| 1085 | + | pdb::add_event(&app.db, p.id, &me.username, "landed", &how).await.ok(); |
|
| 1048 | 1086 | crate::hooks::emit( |
|
| 1049 | 1087 | app, |
|
| 1050 | 1088 | row, |
|
| … | 20 unchanged lines |
||
| 1071 | 1109 | let messages: Vec<(String, String)> = loaded.changes.iter().map(|c| (c.hash.clone(), format!("{}\n{}", c.title, c.body))).collect(); |
|
| 1072 | 1110 | super::issues::close_from_messages(app, row, &messages).await; |
|
| 1073 | 1111 | } |
|
| 1074 | - | accounts::audit(&app.db, Some(me), "proposal.landed", &format!("{owner}/{name}#{number}"), "").await.ok(); |
|
| 1112 | + | accounts::audit(&app.db, Some(me), "proposal.landed", &format!("{owner}/{name}#{number}"), &how).await.ok(); |
|
| 1075 | 1113 | db::repos::record_push( |
|
| 1076 | 1114 | &app.db, |
|
| 1077 | 1115 | row.id, |
|
| … | 1 unchanged lines |
||
| 1079 | 1117 | &[db::repos::PushOutcome { |
|
| 1080 | 1118 | reference: p.target.clone(), |
|
| 1081 | 1119 | ok: true, |
|
| 1082 | - | reason: format!("landed #{number}"), |
|
| 1120 | + | reason: format!("landed #{number} ({how})"), |
|
| 1083 | 1121 | old_state: loaded.target_state.clone(), |
|
| 1084 | 1122 | new_state: state.clone(), |
|
| 1085 | 1123 | heads: loaded.heads.len() as i64, |
|
| … | 118 unchanged lines |
||
| 1204 | 1242 | url: format!("{}/{owner}/{name}/proposal/{}", app.config.base_url(), p.number), |
|
| 1205 | 1243 | repository_url: format!("{}/{owner}/{name}", app.config.base_url()), |
|
| 1206 | 1244 | source_url, |
|
| 1245 | + | landed: (p.state == "landed" && p.landed_ms > 0).then(|| hub_api::Landed { |
|
| 1246 | + | by: p.landed_by.clone(), |
|
| 1247 | + | via: p.landed_via.clone(), |
|
| 1248 | + | when_ms: p.landed_ms, |
|
| 1249 | + | }), |
|
| 1207 | 1250 | } |
|
| 1208 | 1251 | } |
|
| 1209 | 1252 | ||
| … | 7 unchanged lines |
modified crates/hub/tests/api.rs +23 −0
| … | 374 unchanged lines |
||
| 375 | 375 | assert_eq!(api(app, "GET", "/api/v1/notifications", Some(&bob_user), None).await.1["unread"], 0); |
|
| 376 | 376 | // Another account's notification is not the caller's to clear, and a token without user:read sees nothing. |
|
| 377 | 377 | assert_eq!(api(app, "GET", "/api/v1/notifications", Some(&bob_rw), None).await.0, StatusCode::FORBIDDEN); |
|
| 378 | + | ||
| 379 | + | // A source bookmark with `/` in its name, as a feature/N-name workflow |
|
| 380 | + | // makes them: by name, and as this repository's owner/name:bookmark. |
|
| 381 | + | for (b, file) in [("feature/1-one", "c.txt"), ("feature/2-two", "d.txt")] { |
|
| 382 | + | client.bookmark_create(b, Some(&[base])).unwrap(); |
|
| 383 | + | client.bookmark_switch(b, false).unwrap(); |
|
| 384 | + | write(work.path(), file, b"c\n"); |
|
| 385 | + | client.snapshot("t").unwrap(); |
|
| 386 | + | client.commit(b, &[]).unwrap(); |
|
| 387 | + | assert!(client.push_bookmark("origin", b, false).unwrap().iter().all(|r| r.ok)); |
|
| 388 | + | } |
|
| 389 | + | for source in ["feature/1-one", "andy/demo:feature/2-two"] { |
|
| 390 | + | let body = format!(r#"{{"title":"Slash","target":"main","source":"{source}"}}"#); |
|
| 391 | + | let (st, v) = api(app, "POST", "/api/v1/repos/andy/demo/proposals", Some(&bob_rw), Some(&body)).await; |
|
| 392 | + | assert_eq!(st, StatusCode::CREATED, "{source}: {v}"); |
|
| 393 | + | let n = v["number"].as_i64().unwrap(); |
|
| 394 | + | let page = call(app, "GET", &format!("/andy/demo/proposal/{n}"), None, None, None, None).await; |
|
| 395 | + | assert_eq!(page.status, StatusCode::OK); |
|
| 396 | + | assert!(page.body.contains("feature/"), "the page names its source"); |
|
| 397 | + | } |
|
| 398 | + | let body = r#"{"title":"None","target":"main","source":"feature/3-none"}"#; |
|
| 399 | + | let (st, v) = api(app, "POST", "/api/v1/repos/andy/demo/proposals", Some(&bob_rw), Some(body)).await; |
|
| 400 | + | assert!(st.is_client_error() && v.to_string().contains("no bookmark feature/3-none"), "{st} {v}"); |
|
| 378 | 401 | } |
|
| 379 | 402 | ||
| 380 | 403 | #[tokio::test(flavor = "multi_thread")] |
|
| … | 754 unchanged lines |
modified crates/hub/tests/edits.rs +3 −0
| … | 232 unchanged lines |
||
| 233 | 233 | assert_eq!(r.location.as_deref(), Some("/andy/demo/proposal/2"), "{}", r.body); |
|
| 234 | 234 | let page = send(app, "GET", "/andy/demo/proposal/2", Some(&andy), None).await; |
|
| 235 | 235 | assert!(page.body.contains("ready to land") && page.body.contains(">LAND<"), "{}", page.body); |
|
| 236 | + | // Nobody approved, and the repository requires no approval: the page says so and Land asks first. |
|
| 237 | + | assert!(page.body.contains("settings</a> require none."), "{}", page.body); |
|
| 238 | + | assert!(page.body.contains("without any approval?"), "{}", page.body); |
|
| 236 | 239 | let r = send(app, "POST", "/andy/demo/proposal/2/land", Some(&andy), Some(&format!("_csrf={}", csrf_of(&page.body)))).await; |
|
| 237 | 240 | // The action redirects back (so a reload never repeats it), saying so. |
|
| 238 | 241 | let back = r.location.clone().unwrap_or_default(); |
|
| … | 237 unchanged lines |
modified crates/hub/tests/proposals.rs +19 −1
| … | 236 unchanged lines |
||
| 237 | 237 | let heads = app.repos.read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().find(|b| b.name == "main").unwrap().heads)).await.unwrap(); |
|
| 238 | 238 | assert_eq!(heads, vec![feat]); |
|
| 239 | 239 | assert!(get(app, "/andy/demo/proposals?state=landed", None).await.body.contains("#1")); |
|
| 240 | - | assert!(get(app, "/andy/demo/pushes", None).await.body.contains("landed #1")); |
|
| 240 | + | assert!(get(app, "/andy/demo/pushes", None).await.body.contains("landed #1 (web)")); |
|
| 241 | + | // Landed, it still shows what it landed, by whom and how, and nothing holds it back. |
|
| 242 | + | let api = send(app, "GET", "/api/v1/repos/andy/demo/proposals/1", None, None, Some("site")).await; |
|
| 243 | + | let v: serde_json::Value = serde_json::from_str(&api.body).unwrap(); |
|
| 244 | + | assert_eq!(v["state"], "landed", "{}", api.body); |
|
| 245 | + | assert_eq!(v["changes"][0]["hash"], feat.to_hex(), "{}", api.body); |
|
| 246 | + | assert_eq!(v["blockers"], serde_json::json!([]), "{}", api.body); |
|
| 247 | + | assert_eq!(v["landed"]["by"], "andy"); |
|
| 248 | + | assert_eq!(v["landed"]["via"], "web"); |
|
| 249 | + | let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await; |
|
| 250 | + | assert!(page.body.contains("by <a href=\"/andy\">andy</a>, web"), "{}", page.body); |
|
| 251 | + | // Lists by state through the API. |
|
| 252 | + | let list = |q: &'static str| async move { |
|
| 253 | + | let r = send(app, "GET", &format!("/api/v1/repos/andy/demo/proposals{q}"), None, None, Some("site")).await; |
|
| 254 | + | serde_json::from_str::<serde_json::Value>(&r.body).unwrap().as_array().unwrap().len() |
|
| 255 | + | }; |
|
| 256 | + | assert_eq!(list("").await, 0, "open ones only by default"); |
|
| 257 | + | assert_eq!(list("?state=landed").await, 1); |
|
| 258 | + | assert_eq!(list("?state=all").await, 1); |
|
| 241 | 259 | ||
| 242 | 260 | // A proposal from a fork: a copy of the repository with the same id. |
|
| 243 | 261 | let fork = RepoName::parse("bob/demo").unwrap(); |
|
| … | 41 unchanged lines |
modified crates/oh/src/main.rs +8 −5
| … | 144 unchanged lines |
||
| 145 | 145 | ||
| 146 | 146 | #[derive(Subcommand)] |
|
| 147 | 147 | enum ProposalCmd { |
|
| 148 | - | /// Open proposals |
|
| 149 | - | List, |
|
| 148 | + | /// Open proposals (or --state draft|landed|closed|all) |
|
| 149 | + | List { |
|
| 150 | + | #[arg(long, default_value = "open")] |
|
| 151 | + | state: String, |
|
| 152 | + | }, |
|
| 150 | 153 | /// One proposal: its changes, approvals and whether it can land |
|
| 151 | 154 | View { number: i64 }, |
|
| 152 | 155 | /// Propose a bookmark's changes for another bookmark |
|
| … | 281 unchanged lines |
||
| 434 | 437 | fn proposal(c: &Client, t: &Target, cmd: ProposalCmd, json: bool) -> Result<i32> { |
|
| 435 | 438 | let base = t.repo_path()?; |
|
| 436 | 439 | match cmd { |
|
| 437 | - | ProposalCmd::List => { |
|
| 438 | - | let path = format!("{base}/proposals"); |
|
| 440 | + | ProposalCmd::List { state } => { |
|
| 441 | + | let path = format!("{base}/proposals?state={state}"); |
|
| 439 | 442 | if json { |
|
| 440 | 443 | println!("{}", c.get_text(&path)?); |
|
| 441 | 444 | return Ok(0); |
|
| 442 | 445 | } |
|
| 443 | 446 | let list: Vec<Proposal> = c.get(&path)?; |
|
| 444 | 447 | if list.is_empty() { |
|
| 445 | - | println!("{}", ui::dim("No open proposals.")); |
|
| 448 | + | println!("{}", ui::dim(&format!("No {} proposals.", if state == "all" { "" } else { &state }).replace(" ", " "))); |
|
| 446 | 449 | } |
|
| 447 | 450 | for p in &list { |
|
| 448 | 451 | println!("{}", proposal_line(p)); |
|
| … | 279 unchanged lines |
modified docs/oh.md +1 −1
| … | 36 unchanged lines |
||
| 37 | 37 | | `oh issue create -t TITLE [-b BODY \| -F FILE] [-l LABEL]…` | Open an issue | |
|
| 38 | 38 | | `oh issue close N [--reason not_planned]`, `oh issue reopen N` | Close or reopen | |
|
| 39 | 39 | | `oh issue comment N -b BODY` | Comment | |
|
| 40 | - | | `oh proposal list`, `oh proposal view N` | Proposals, with changes, approvals and blockers | |
|
| 40 | + | | `oh proposal list [--state open\|draft\|landed\|closed\|all]`, `oh proposal view N` | Proposals, with changes, approvals and blockers; a landed one with who landed it and how | |
|
| 41 | 41 | | `oh proposal create -t TITLE --from BOOKMARK [--into BOOKMARK] [--draft]` | Propose a bookmark's changes | |
|
| 42 | 42 | | `oh proposal checks N` | Checks on the projected state | |
|
| 43 | 43 | | `oh proposal land N` | Land it on its target, as the page's Land button does | |
|
| … | 16 unchanged lines |
modified docs/spec.md +1 −1
| … | 434 unchanged lines |
||
| 435 | 435 | ||
| 436 | 436 | ### 12.1 API |
|
| 437 | 437 | ||
| 438 | - | 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`, `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot), `/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"}` 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, `/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. |
|
| 438 | + | 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`, `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`, `/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"}` 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, `/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. |
|
| 439 | 439 | ||
| 440 | 440 | ### 12.2 Webhooks |
|
| 441 | 441 | ||
| … | 205 unchanged lines |
modified templates/proposal.html +5 −2
| … | 153 unchanged lines |
||
| 154 | 154 | <aside class="side"> |
|
| 155 | 155 | <h3>Landing</h3> |
|
| 156 | 156 | {% if p.state == "landed" %}<p><span class="sig sig-valid">✔</span> landed{% if !p.landed_state.is_empty() %} at <code>{{ p.landed_state|short }}</code>{% endif %}</p> |
|
| 157 | + | {% if !p.landed_by.is_empty() %}<p class="small mute">by <a href="/{{ p.landed_by }}">{{ p.landed_by }}</a>, {{ p.landed_via }}, {{ p.landed_ms|ago }}</p>{% endif %} |
|
| 157 | 158 | {% else if p.state == "closed" %}<p class="mute">closed</p> |
|
| 158 | 159 | {% else %} |
|
| 159 | - | {% if blockers.is_empty() %}<p><span class="sig sig-valid">✔</span> ready to land</p>{% else %}<ul class="plain small">{% for b in blockers %}<li><span class="sig sig-invalid">✘</span> {{ b }}</li>{% endfor %}</ul>{% endif %} |
|
| 160 | + | {% if blockers.is_empty() %}<p><span class="sig sig-valid">✔</span> ready to land</p> |
|
| 161 | + | {% if requirements.required_approvals == 0 && approvals.is_empty() %}<p class="small warn-text">No approval yet, and this repository's <a href="{{ repo.url }}/settings">settings</a> require none.</p>{% endif %} |
|
| 162 | + | {% else %}<ul class="plain small">{% for b in blockers %}<li><span class="sig sig-invalid">✘</span> {{ b }}</li>{% endfor %}</ul>{% endif %} |
|
| 160 | 163 | {% if can_land %} |
|
| 161 | - | {% if hub_can_land %}<form method="post" action="{{ repo.url }}/proposal/{{ p.number }}/land"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit">LAND</button></form> |
|
| 164 | + | {% 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">LAND</button></form> |
|
| 162 | 165 | {% 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 %} |
|
| 163 | 166 | {% endif %} |
|
| 164 | 167 | {% endif %} |
|
| … | 29 unchanged lines |
modified templates/style.css +1 −0
| … | 104 unchanged lines |
||
| 105 | 105 | .tag { display: inline-block; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--acc); background: var(--soft); padding: 1px 6px; vertical-align: middle; } |
|
| 106 | 106 | .tag.bad { color: var(--bad); } |
|
| 107 | 107 | .tag.warn { color: var(--warn); } |
|
| 108 | + | .warn-text { color: var(--warn); } |
|
| 108 | 109 | .tag.head { color: var(--now-ink); background: var(--now); } |
|
| 109 | 110 | .chip { font-family: "IBM Plex Mono", ui-monospace, monospace; font-size: 12px; background: var(--soft); padding: 1px 6px; border: 1px solid var(--line); } |
|
| 110 | 111 | .lbl { display: inline-block; font-size: 11px; padding: 1px 7px; margin-right: 4px; border: 1px solid; vertical-align: middle; } |
|
| … | 529 unchanged lines |