Review requests: ask someone to review a proposal (closes #68) cc1cd197df1d
The author or a writer asks anyone who can read the repository (not the
author) under Reviewers on the proposal page, or with POST
.../proposals/{number}/reviewers; DELETE .../reviewers/{username}
withdraws it. The person asked is notified, the timeline records it,
the proposal's JSON lists requested_reviewers, and their review answers
the request. Every review now notifies the author, from the page and
the API alike. Spec section 7.4; plan 0.4's notification item ticked.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-01 01:27 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
cc1cd197df1dd319220979c95de00157f3b2736e2f2824853936f76dde892dba- Depends on
- 0b20f1ec4007 145306044896 93be9ce4653e b64e2537d702 d23464c0ac60 de505df5cf8d e1ec9fbf538e
- Depended on by
- ecac6e8d9b41 86a69b53aff0 2a8b621676b4 119fd17d213f d1bf2d698777 5d90e0e045e2 7d2eb590051f 8e96499f2705 2291f8cc9c96 6978fd76a066
- 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.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
- 19 atoms, 1 tree operation, 10 files
Browse the tree at this change
added crates/hub/migrations/0029_review_requests.sql +9 −0
| 1 | + | -- Review requests (hub plan 0.4): who was asked to review a proposal, by |
|
| 2 | + | -- whom and when; a request goes when that reviewer reviews. |
|
| 3 | + | CREATE TABLE proposal_review_requests ( |
|
| 4 | + | proposal_id INTEGER NOT NULL REFERENCES proposals(id) ON DELETE CASCADE, |
|
| 5 | + | account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 6 | + | requested_by TEXT NOT NULL, |
|
| 7 | + | requested_ms INTEGER NOT NULL, |
|
| 8 | + | PRIMARY KEY (proposal_id, account_id) |
|
| 9 | + | ); |
modified crates/hub-api/src/lib.rs +9 −0
| … | 169 unchanged lines |
||
| 170 | 170 | /// Who landed it, how and when, once landed. |
|
| 171 | 171 | #[serde(default)] |
|
| 172 | 172 | pub landed: Option<Landed>, |
|
| 173 | + | /// Asked to review and not yet reviewing, in the order asked. |
|
| 174 | + | #[serde(default)] |
|
| 175 | + | pub requested_reviewers: Vec<String>, |
|
| 173 | 176 | } |
|
| 174 | 177 | ||
| 175 | 178 | /// How a proposal was landed. |
|
| … | 595 unchanged lines |
||
| 771 | 774 | pub source: String, |
|
| 772 | 775 | #[serde(default)] |
|
| 773 | 776 | pub draft: bool, |
|
| 777 | + | } |
|
| 778 | + | ||
| 779 | + | /// `POST .../proposals/{number}/reviewers`: accounts to ask for a review. |
|
| 780 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 781 | + | pub struct ReviewRequest { |
|
| 782 | + | pub reviewers: Vec<String>, |
|
| 774 | 783 | } |
|
| 775 | 784 | ||
| 776 | 785 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| … | 154 unchanged lines |
modified crates/hub/src/api/mod.rs +47 −0
| … | 62 unchanged lines |
||
| 63 | 63 | .route("/repos/{owner}/{name}/proposals/{number}/land", post(land_proposal)) |
|
| 64 | 64 | .route("/repos/{owner}/{name}/proposals/{number}/comments", get(proposal_comments).post(proposal_comment)) |
|
| 65 | 65 | .route("/repos/{owner}/{name}/proposals/{number}/reviews", get(proposal_reviews).post(proposal_review)) |
|
| 66 | + | .route("/repos/{owner}/{name}/proposals/{number}/reviewers", post(request_reviewers)) |
|
| 67 | + | .route("/repos/{owner}/{name}/proposals/{number}/reviewers/{username}", axum::routing::delete(withdraw_reviewer)) |
|
| 66 | 68 | .route("/repos/{owner}/{name}/proposals/{number}/checks", get(proposal_checks)) |
|
| 67 | 69 | .route("/repos/{owner}/{name}/proposals/{number}/files", get(proposal_files)) |
|
| 68 | 70 | .route("/repos/{owner}/{name}/proposals/{number}/content/{side}/{*path}", get(proposal_content)) |
|
| … | 886 unchanged lines |
||
| 955 | 957 | } |
|
| 956 | 958 | let r = db::proposals::add_review(&app.db, p.id, me.id, &me.username, &body.verdict, body.body.trim()).await?; |
|
| 957 | 959 | db::proposals::add_event(&app.db, p.id, &me.username, &format!("review.{}", body.verdict), "").await.ok(); |
|
| 960 | + | crate::web::proposals::reviewed(&app, &row, &p, &me, &body.verdict).await; |
|
| 958 | 961 | crate::hooks::emit(&app, &row, "review", &me.username, serde_json::json!({ "number": number, "verdict": body.verdict })).await; |
|
| 959 | 962 | Ok(( |
|
| 960 | 963 | StatusCode::CREATED, |
|
| … | 1 unchanged lines |
||
| 962 | 965 | )) |
|
| 963 | 966 | } |
|
| 964 | 967 | ||
| 968 | + | /// `POST .../proposals/{number}/reviewers`: ask accounts to review (the |
|
| 969 | + | /// author, or a writer). Answers with everyone asked now. |
|
| 970 | + | async fn request_reviewers( |
|
| 971 | + | State(app): State<Shared>, |
|
| 972 | + | headers: HeaderMap, |
|
| 973 | + | Path((owner, name, number)): Path<(String, String, i64)>, |
|
| 974 | + | Json(body): Json<ReviewRequest>, |
|
| 975 | + | ) -> Result<Json<Vec<String>>, ApiError> { |
|
| 976 | + | let c = caller(&app, &headers).await?; |
|
| 977 | + | let me = c.require("proposals:write").or_else(|_| c.require("repo:write"))?.clone(); |
|
| 978 | + | let (_, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?; |
|
| 979 | + | let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; |
|
| 980 | + | if Some(me.id) != p.author_id && role < Role::Write { |
|
| 981 | + | return Err(forbidden("only the author or a writer asks for reviews")); |
|
| 982 | + | } |
|
| 983 | + | if !p.is_open() { |
|
| 984 | + | return Err(ApiError(StatusCode::CONFLICT, "the proposal is not open".into())); |
|
| 985 | + | } |
|
| 986 | + | crate::web::proposals::request_reviewers(&app, &row, &p, &me, &body.reviewers) |
|
| 987 | + | .await |
|
| 988 | + | .map_err(|e| ApiError(StatusCode::UNPROCESSABLE_ENTITY, e))?; |
|
| 989 | + | Ok(Json(db::proposals::review_requests(&app.db, p.id).await?)) |
|
| 990 | + | } |
|
| 991 | + | ||
| 992 | + | /// `DELETE .../proposals/{number}/reviewers/{username}`: withdraw a request. |
|
| 993 | + | async fn withdraw_reviewer( |
|
| 994 | + | State(app): State<Shared>, |
|
| 995 | + | headers: HeaderMap, |
|
| 996 | + | Path((owner, name, number, username)): Path<(String, String, i64, String)>, |
|
| 997 | + | ) -> Result<StatusCode, ApiError> { |
|
| 998 | + | let c = caller(&app, &headers).await?; |
|
| 999 | + | let me = c.require("proposals:write").or_else(|_| c.require("repo:write"))?.clone(); |
|
| 1000 | + | let (_, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?; |
|
| 1001 | + | let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; |
|
| 1002 | + | if Some(me.id) != p.author_id && role < Role::Write { |
|
| 1003 | + | return Err(forbidden("only the author or a writer withdraws review requests")); |
|
| 1004 | + | } |
|
| 1005 | + | let a = db::accounts::account_by_name(&app.db, &username).await?.ok_or_else(|| not_found("no such account"))?; |
|
| 1006 | + | if db::proposals::unrequest_review(&app.db, p.id, a.id).await? { |
|
| 1007 | + | db::proposals::add_event(&app.db, p.id, &me.username, "review request withdrawn", &a.username).await.ok(); |
|
| 1008 | + | } |
|
| 1009 | + | Ok(StatusCode::NO_CONTENT) |
|
| 1010 | + | } |
|
| 1011 | + | ||
| 965 | 1012 | async fn proposal_checks( |
|
| 966 | 1013 | State(app): State<Shared>, |
|
| 967 | 1014 | headers: HeaderMap, |
|
| … | 741 unchanged lines |
modified crates/hub/src/db/proposals.rs +38 −0
| … | 315 unchanged lines |
||
| 316 | 316 | .await?; |
|
| 317 | 317 | Ok(()) |
|
| 318 | 318 | } |
|
| 319 | + | ||
| 320 | + | // ------------------------------------------------------- review requests |
|
| 321 | + | ||
| 322 | + | /// Ask an account to review a proposal; false when it was already asked. |
|
| 323 | + | pub async fn request_review(pool: &Pool, proposal_id: i64, account_id: i64, by: &str) -> sqlx::Result<bool> { |
|
| 324 | + | let r = sqlx::query( |
|
| 325 | + | "INSERT OR IGNORE INTO proposal_review_requests (proposal_id, account_id, requested_by, requested_ms) VALUES (?, ?, ?, ?)", |
|
| 326 | + | ) |
|
| 327 | + | .bind(proposal_id) |
|
| 328 | + | .bind(account_id) |
|
| 329 | + | .bind(by) |
|
| 330 | + | .bind(now_ms()) |
|
| 331 | + | .execute(pool) |
|
| 332 | + | .await?; |
|
| 333 | + | Ok(r.rows_affected() == 1) |
|
| 334 | + | } |
|
| 335 | + | ||
| 336 | + | /// Withdraw a request, or mark it answered; false when there was none. |
|
| 337 | + | pub async fn unrequest_review(pool: &Pool, proposal_id: i64, account_id: i64) -> sqlx::Result<bool> { |
|
| 338 | + | let r = sqlx::query("DELETE FROM proposal_review_requests WHERE proposal_id = ? AND account_id = ?") |
|
| 339 | + | .bind(proposal_id) |
|
| 340 | + | .bind(account_id) |
|
| 341 | + | .execute(pool) |
|
| 342 | + | .await?; |
|
| 343 | + | Ok(r.rows_affected() == 1) |
|
| 344 | + | } |
|
| 345 | + | ||
| 346 | + | /// The usernames asked to review a proposal and not yet reviewing, in the |
|
| 347 | + | /// order they were asked. |
|
| 348 | + | pub async fn review_requests(pool: &Pool, proposal_id: i64) -> sqlx::Result<Vec<String>> { |
|
| 349 | + | sqlx::query_scalar( |
|
| 350 | + | "SELECT a.username FROM proposal_review_requests r JOIN accounts a ON a.id = r.account_id \ |
|
| 351 | + | WHERE r.proposal_id = ? ORDER BY r.requested_ms, a.username", |
|
| 352 | + | ) |
|
| 353 | + | .bind(proposal_id) |
|
| 354 | + | .fetch_all(pool) |
|
| 355 | + | .await |
|
| 356 | + | } |
modified crates/hub/src/web/mod.rs +1 −0
| … | 137 unchanged lines |
||
| 138 | 138 | .route("/{owner}/{name}/proposal/{number}/comment", post(proposals::comment)) |
|
| 139 | 139 | .route("/{owner}/{name}/proposal/{number}/comment/{id}/resolve", post(proposals::resolve_comment)) |
|
| 140 | 140 | .route("/{owner}/{name}/proposal/{number}/review", post(proposals::review)) |
|
| 141 | + | .route("/{owner}/{name}/proposal/{number}/reviewers", post(proposals::reviewers)) |
|
| 141 | 142 | .route("/{owner}/{name}/proposal/{number}/edit", post(proposals::edit)) |
|
| 142 | 143 | .route("/{owner}/{name}/proposal/{number}/state", post(proposals::state)) |
|
| 143 | 144 | .route("/{owner}/{name}/proposal/{number}/land", post(proposals::land)) |
|
| … | 460 unchanged lines |
modified crates/hub/src/web/proposals.rs +130 −0
| … | 632 unchanged lines |
||
| 633 | 633 | required_checks: Vec<String>, |
|
| 634 | 634 | /// Projects this proposal is on that the viewer may see, with the column. |
|
| 635 | 635 | projects: Vec<(db::projects::Project, String)>, |
|
| 636 | + | /// Asked to review and not yet reviewing. |
|
| 637 | + | requested: Vec<String>, |
|
| 638 | + | /// The viewer may ask for reviews (the author, or a writer). |
|
| 639 | + | can_request: bool, |
|
| 636 | 640 | } |
|
| 637 | 641 | ||
| 638 | 642 | #[derive(Deserialize)] |
|
| … | 64 unchanged lines |
||
| 703 | 707 | let me = viewer.account(); |
|
| 704 | 708 | let can_edit = me.is_some_and(|a| Some(a.id) == p.author_id || role >= Role::Maintain); |
|
| 705 | 709 | let can_land = role >= Role::Write && p.is_open() && v.blockers.is_empty(); |
|
| 710 | + | let can_request = p.is_open() && me.is_some_and(|a| Some(a.id) == p.author_id || role >= Role::Write); |
|
| 706 | 711 | let delegated = match viewer.account() { |
|
| 707 | 712 | Some(a) => super::edit::delegated_identity(app, a.id).await.is_some(), |
|
| 708 | 713 | None => false, |
|
| … | 72 unchanged lines |
||
| 781 | 786 | all_labels: db::issues::labels(&app.db, row.id).await.unwrap_or_default(), |
|
| 782 | 787 | checks: db::hooks::checks_for_state(&app.db, row.id, &loaded.state).await.unwrap_or_default(), |
|
| 783 | 788 | required_checks: db::hooks::required_checks(&app.db, row.id).await.unwrap_or_default(), |
|
| 789 | + | requested: pdb::review_requests(&app.db, loaded.row.id).await.unwrap_or_default(), |
|
| 790 | + | can_request, |
|
| 784 | 791 | })) |
|
| 785 | 792 | } |
|
| 786 | 793 | ||
| … | 113 unchanged lines |
||
| 900 | 907 | .await |
|
| 901 | 908 | .map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 902 | 909 | pdb::add_event(&app.db, p.id, &me.account.username, &format!("review.{}", f.verdict), "").await.ok(); |
|
| 910 | + | reviewed(&app, &row, &p, &me.account, &f.verdict).await; |
|
| 903 | 911 | crate::hooks::emit(&app, &row, "review", &me.account.username, serde_json::json!({ "number": number, "verdict": f.verdict })).await; |
|
| 904 | 912 | let notice = if f.verdict == "approve" { |
|
| 905 | 913 | Some(format!( |
|
| … | 341 unchanged lines |
||
| 1247 | 1255 | via: p.landed_via.clone(), |
|
| 1248 | 1256 | when_ms: p.landed_ms, |
|
| 1249 | 1257 | }), |
|
| 1258 | + | requested_reviewers: pdb::review_requests(&app.db, p.id).await.unwrap_or_default(), |
|
| 1250 | 1259 | } |
|
| 1251 | 1260 | } |
|
| 1252 | 1261 | ||
| … | 4 unchanged lines |
||
| 1257 | 1266 | ||
| 1258 | 1267 | #[allow(dead_code)] |
|
| 1259 | 1268 | fn _unused(_: &diff::Line, _: &ConflictView, _: &Role) {} |
|
| 1269 | + | ||
| 1270 | + | // ------------------------------------------------------- review requests |
|
| 1271 | + | ||
| 1272 | + | /// Ask accounts to review a proposal (hub plan 0.4): each must exist, be |
|
| 1273 | + | /// someone other than the author, and be able to read the repository. Each |
|
| 1274 | + | /// newly asked is told, and the proposal records it. The usernames newly |
|
| 1275 | + | /// asked, or why none could be. |
|
| 1276 | + | pub async fn request_reviewers( |
|
| 1277 | + | app: &Shared, |
|
| 1278 | + | row: &db::repos::RepoRow, |
|
| 1279 | + | p: &pdb::ProposalRow, |
|
| 1280 | + | by: &accounts::Account, |
|
| 1281 | + | names: &[String], |
|
| 1282 | + | ) -> Result<Vec<String>, String> { |
|
| 1283 | + | let mut found = Vec::new(); |
|
| 1284 | + | for name in names.iter().map(|n| n.trim().trim_start_matches('@')).filter(|n| !n.is_empty()) { |
|
| 1285 | + | let Some(a) = accounts::account_by_name(&app.db, name).await.ok().flatten() else { |
|
| 1286 | + | return Err(format!("There is no account {name}.")); |
|
| 1287 | + | }; |
|
| 1288 | + | if Some(a.id) == p.author_id { |
|
| 1289 | + | return Err(format!("{name} wrote this proposal, and cannot review it.")); |
|
| 1290 | + | } |
|
| 1291 | + | let role = crate::auth::roles::role_for(&app.db, Some(&a), row).await.ok().flatten(); |
|
| 1292 | + | if !crate::auth::roles::allows(role, crate::auth::Access::Read) { |
|
| 1293 | + | return Err(format!("{name} cannot read this repository.")); |
|
| 1294 | + | } |
|
| 1295 | + | found.push(a); |
|
| 1296 | + | } |
|
| 1297 | + | let mut asked = Vec::new(); |
|
| 1298 | + | for a in found { |
|
| 1299 | + | if !pdb::request_review(&app.db, p.id, a.id, &by.username).await.map_err(|e| e.to_string())? { |
|
| 1300 | + | continue; |
|
| 1301 | + | } |
|
| 1302 | + | pdb::add_event(&app.db, p.id, &by.username, "review requested", &a.username).await.ok(); |
|
| 1303 | + | crate::notify::deliver( |
|
| 1304 | + | app, |
|
| 1305 | + | crate::notify::Event { |
|
| 1306 | + | kind: "proposal.review_requested", |
|
| 1307 | + | title: format!("{}/{}#{}: {} (review requested from {})", row.owner, row.name, p.number, p.title, a.username), |
|
| 1308 | + | url: format!("/{}/{}/proposal/{}", row.owner, row.name, p.number), |
|
| 1309 | + | actor: by, |
|
| 1310 | + | repo_id: row.id, |
|
| 1311 | + | thread: None, |
|
| 1312 | + | mention_text: "", |
|
| 1313 | + | direct: vec![a.id], |
|
| 1314 | + | }, |
|
| 1315 | + | ) |
|
| 1316 | + | .await; |
|
| 1317 | + | asked.push(a.username); |
|
| 1318 | + | } |
|
| 1319 | + | Ok(asked) |
|
| 1320 | + | } |
|
| 1321 | + | ||
| 1322 | + | /// After a review: it answers the reviewer's request, if there was one, and |
|
| 1323 | + | /// the proposal's author is told. |
|
| 1324 | + | pub async fn reviewed(app: &Shared, row: &db::repos::RepoRow, p: &pdb::ProposalRow, by: &accounts::Account, verdict: &str) { |
|
| 1325 | + | pdb::unrequest_review(&app.db, p.id, by.id).await.ok(); |
|
| 1326 | + | let what = match verdict { |
|
| 1327 | + | "approve" => "approved", |
|
| 1328 | + | "changes" => "changes requested", |
|
| 1329 | + | _ => "reviewed", |
|
| 1330 | + | }; |
|
| 1331 | + | crate::notify::deliver( |
|
| 1332 | + | app, |
|
| 1333 | + | crate::notify::Event { |
|
| 1334 | + | kind: "proposal.review", |
|
| 1335 | + | title: format!("{}/{}#{}: {} ({what} by {})", row.owner, row.name, p.number, p.title, by.username), |
|
| 1336 | + | url: format!("/{}/{}/proposal/{}", row.owner, row.name, p.number), |
|
| 1337 | + | actor: by, |
|
| 1338 | + | repo_id: row.id, |
|
| 1339 | + | thread: Some(("proposal", p.id)), |
|
| 1340 | + | mention_text: "", |
|
| 1341 | + | direct: p.author_id.into_iter().collect(), |
|
| 1342 | + | }, |
|
| 1343 | + | ) |
|
| 1344 | + | .await; |
|
| 1345 | + | } |
|
| 1346 | + | ||
| 1347 | + | #[derive(Deserialize)] |
|
| 1348 | + | pub struct ReviewersForm { |
|
| 1349 | + | #[serde(rename = "_csrf")] |
|
| 1350 | + | csrf_token: String, |
|
| 1351 | + | /// `add` or `remove`. |
|
| 1352 | + | action: String, |
|
| 1353 | + | username: String, |
|
| 1354 | + | } |
|
| 1355 | + | ||
| 1356 | + | /// `POST /{owner}/{name}/proposal/{number}/reviewers`: the author or a |
|
| 1357 | + | /// writer asks someone to review, or withdraws the request. |
|
| 1358 | + | pub async fn reviewers( |
|
| 1359 | + | State(app): State<Shared>, |
|
| 1360 | + | viewer: Viewer, |
|
| 1361 | + | Path((owner, name, number)): Path<(String, String, i64)>, |
|
| 1362 | + | Form(f): Form<ReviewersForm>, |
|
| 1363 | + | ) -> Result<Response, Error> { |
|
| 1364 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer); |
|
| 1365 | + | let me = require(&viewer, &shell)?; |
|
| 1366 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 1367 | + | let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 1368 | + | let p = pdb::get(&app.db, row.id, number) |
|
| 1369 | + | .await |
|
| 1370 | + | .map_err(|e| Error::from_db(shell.clone(), e))? |
|
| 1371 | + | .ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 1372 | + | if Some(me.account.id) != p.author_id && role < Role::Write { |
|
| 1373 | + | return Err(Error::not_found(shell)); |
|
| 1374 | + | } |
|
| 1375 | + | let who = f.username.trim().trim_start_matches('@').to_string(); |
|
| 1376 | + | if f.action == "remove" { |
|
| 1377 | + | if let Some(a) = accounts::account_by_name(&app.db, &who).await.ok().flatten() { |
|
| 1378 | + | if pdb::unrequest_review(&app.db, p.id, a.id).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 1379 | + | pdb::add_event(&app.db, p.id, &me.account.username, "review request withdrawn", &a.username).await.ok(); |
|
| 1380 | + | } |
|
| 1381 | + | } |
|
| 1382 | + | return Ok(back(&owner, &name, number, "conversation", None, None)); |
|
| 1383 | + | } |
|
| 1384 | + | match request_reviewers(&app, &row, &p, &me.account, &[who]).await { |
|
| 1385 | + | Ok(asked) if asked.is_empty() => Ok(back(&owner, &name, number, "conversation", Some("Already asked.".into()), None)), |
|
| 1386 | + | Ok(asked) => Ok(back(&owner, &name, number, "conversation", Some(format!("Asked {} to review.", asked.join(", "))), None)), |
|
| 1387 | + | Err(e) => Ok(back(&owner, &name, number, "conversation", None, Some(e))), |
|
| 1388 | + | } |
|
| 1389 | + | } |
modified crates/hub/tests/api.rs +34 −0
| … | 375 unchanged lines |
||
| 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 | 378 | ||
| 379 | + | // Review requests: the author asks andy; andy is told, reviews, and the |
|
| 380 | + | // request is answered; the author hears of the review. |
|
| 381 | + | let andy_user = mint(andy.clone(), "scopes=user%3Aread").await; |
|
| 382 | + | let andy_pw = mint(andy.clone(), "scopes=proposals%3Awrite").await; |
|
| 383 | + | oxygenhub::db::notify::mark_read(&app.db, oxygenhub::db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id, None) |
|
| 384 | + | .await |
|
| 385 | + | .unwrap(); |
|
| 386 | + | let path = "/api/v1/repos/andy/demo/proposals/2/reviewers"; |
|
| 387 | + | let (st, v) = api(app, "POST", path, Some(&bob_rw), Some(r#"{"reviewers":["nobody"]}"#)).await; |
|
| 388 | + | assert_eq!(st, StatusCode::UNPROCESSABLE_ENTITY, "{v}"); |
|
| 389 | + | let (st, v) = api(app, "POST", path, Some(&bob_rw), Some(r#"{"reviewers":["bob"]}"#)).await; |
|
| 390 | + | assert_eq!(st, StatusCode::UNPROCESSABLE_ENTITY, "the author cannot review: {v}"); |
|
| 391 | + | let (st, v) = api(app, "POST", path, Some(&bob_rw), Some(r#"{"reviewers":["@andy"]}"#)).await; |
|
| 392 | + | assert_eq!((st, v.clone()), (StatusCode::OK, serde_json::json!(["andy"]))); |
|
| 393 | + | let (_, v) = api(app, "GET", "/api/v1/repos/andy/demo/proposals/2", None, None).await; |
|
| 394 | + | assert_eq!(v["requested_reviewers"], serde_json::json!(["andy"])); |
|
| 395 | + | let (_, v) = api(app, "GET", "/api/v1/notifications", Some(&andy_user), None).await; |
|
| 396 | + | assert!(v["items"].as_array().unwrap().iter().any(|n| n["kind"] == "proposal.review_requested" && n["read"] == false), "{v}"); |
|
| 397 | + | let page = call(app, "GET", "/andy/demo/proposal/2", None, Some(&andy), None, None).await; |
|
| 398 | + | assert!(page.body.contains("Reviewers") && page.body.contains("review requested"), "{}", page.body); |
|
| 399 | + | let (st, _) = |
|
| 400 | + | api(app, "POST", "/api/v1/repos/andy/demo/proposals/2/reviews", Some(&andy_pw), Some(r#"{"verdict":"comment","body":"ok"}"#)).await; |
|
| 401 | + | assert_eq!(st, StatusCode::CREATED); |
|
| 402 | + | let (_, v) = api(app, "GET", "/api/v1/repos/andy/demo/proposals/2", None, None).await; |
|
| 403 | + | assert_eq!(v["requested_reviewers"], serde_json::json!([]), "answered by the review"); |
|
| 404 | + | let (_, v) = api(app, "GET", "/api/v1/notifications", Some(&bob_user), None).await; |
|
| 405 | + | assert!(v["items"].as_array().unwrap().iter().any(|n| n["kind"] == "proposal.review" && n["read"] == false), "{v}"); |
|
| 406 | + | // Asking again and withdrawing it. |
|
| 407 | + | api(app, "POST", path, Some(&bob_rw), Some(r#"{"reviewers":["andy"]}"#)).await; |
|
| 408 | + | let (st, _) = api(app, "DELETE", &format!("{path}/andy"), Some(&bob_rw), None).await; |
|
| 409 | + | assert_eq!(st, StatusCode::NO_CONTENT); |
|
| 410 | + | let (_, v) = api(app, "GET", "/api/v1/repos/andy/demo/proposals/2", None, None).await; |
|
| 411 | + | assert_eq!(v["requested_reviewers"], serde_json::json!([])); |
|
| 412 | + | ||
| 379 | 413 | // A source bookmark with `/` in its name, as a feature/N-name workflow |
|
| 380 | 414 | // makes them: by name, and as this repository's owner/name:bookmark. |
|
| 381 | 415 | for (b, file) in [("feature/1-one", "c.txt"), ("feature/2-two", "d.txt")] { |
|
| … | 776 unchanged lines |
modified docs/plan.md +1 −1
| … | 39 unchanged lines |
||
| 40 | 40 | - [x] Landing: from the hub when the repository has no policy; otherwise the page gives `oxy proposal land URL`, which fetches the projection, sets the bookmark and pushes with the user's key. A proposal whose changes all reach the target by any route is marked landed. |
|
| 41 | 41 | - [x] Pages: list with counts, new with preview, detail with conversation, changes and files tabs; JSON API for proposals used by `oxy proposal show|land` and `oxy seal --proposal`. |
|
| 42 | 42 | - [x] Tests: `tests/proposals.rs` covers bookmark and fork sources, anchoring, approval before and after sealing, requirements, landing. |
|
| 43 | - | - [ ] Notifications for review requests, mentions and landings (0.4). |
|
| 43 | + | - [x] Notifications for review requests, mentions and landings (0.4). Mentions and landings were in already; review requests (ask, withdraw, answered by a review, the author told of each review) added 2026-10-01. |
|
| 44 | 44 | - [ ] Hub-side landing under a policy through the delegate key (needs the attestation delegate rule in oxy, see spec §18). |
|
| 45 | 45 | ||
| 46 | 46 | ## 0.4 Issues and notifications (2026-09-27) |
|
| … | 46 unchanged lines |
modified docs/spec.md +2 −0
| … | 350 unchanged lines |
||
| 351 | 351 | ||
| 352 | 352 | ### 7.4 Reviews and approvals |
|
| 353 | 353 | ||
| 354 | + | The author or a writer can **ask for a review** from anyone who can read the repository (not the author), under Reviewers on the proposal page or with `POST /api/v1/repos/{o}/{n}/proposals/{number}/reviewers` and `{"reviewers": [...]}`; `DELETE .../reviewers/{username}` withdraws a request. Each person asked gets a notification (`proposal.review_requested`), the timeline records it, the proposal's JSON lists `requested_reviewers`, and a review from that person answers the request. Every review notifies the proposal's author (`proposal.review`). |
|
| 355 | + | ||
| 354 | 356 | A review is `comment`, `request changes`, or `approve`. An approval MUST be backed by a **seal attestation** (spec §14.4, `kind = seal`) over the proposal's change hashes, signed with a key linked to the reviewer's account. Private keys never enter the browser, so the reviewer signs from a client: `oxy seal --proposal <url>` or `oxy seal <hashes>` (§18, a new command; the seal object already exists in the model) signs and pushes the seal; the hub matches the seal to the proposal and records the approval. The page shows the command ready to copy. An approval not yet backed by a seal is shown as "approval pending signature" and does not count towards requirements. |
|
| 355 | 357 | ||
| 356 | 358 | Landing requirements per repository (settings): N approvals, approvals from code owners (`.oxyowners` in the target state, GitHub `CODEOWNERS` syntax), required checks green on the projected state, no conflicts (or conflicts allowed, matching the policy rule's `allow_conflicts`), and no outstanding "request changes". |
|
| … | 290 unchanged lines |
modified templates/proposal.html +3 −0
| … | 184 unchanged lines |
||
| 185 | 185 | <ul class="plain">{% for c in checks %}<li><span class="sig{% if c.status == "passed" %} sig-valid{% else if c.status == "failed" %} sig-invalid{% else %} sig-unsigned{% endif %}">{% if c.status == "passed" %}✔{% else if c.status == "failed" %}✘{% else if c.status == "running" %}…{% else %}○{% endif %}</span> {% if c.url.is_empty() %}{{ c.name }}{% else %}<a href="{{ c.url }}">{{ c.name }}</a>{% endif %} <span class="mute small">{{ c.status }}{% if !c.summary.is_empty() %} · {{ c.summary }}{% endif %}{% if !c.seal.is_empty() %} · sealed{% endif %}</span></li>{% endfor %}</ul>{% endif %} |
|
| 186 | 186 | </div> |
|
| 187 | 187 | <script>(function(){if(!window.EventSource)return;var es=new EventSource("{{ repo.url }}/proposal/{{ p.number }}/events");es.addEventListener("check",function(){es.close();location.reload();});})();</script> |
|
| 188 | + | <h3>Reviewers</h3> |
|
| 189 | + | {% if requested.is_empty() %}<p class="small mute">No review requested.</p>{% else %}<ul class="plain">{% for r in requested %}<li><img class="tl-mini" src="/avatar/{{ r }}" alt="" width="18" height="18"> <a href="/{{ r }}">{{ r }}</a> <span class="small mute">asked</span>{% if can_request %} <form class="tiny inline" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/reviewers"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="action" value="remove"><input type="hidden" name="username" value="{{ r }}"><button title="Withdraw the request">×</button></form>{% endif %}</li>{% endfor %}</ul>{% endif %} |
|
| 190 | + | {% if can_request %}<form class="side-picker-inline" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/reviewers"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="action" value="add"><input type="text" name="username" placeholder="username" aria-label="Ask someone to review" size="12" required> <button class="small">Ask</button></form>{% endif %} |
|
| 188 | 191 | <h3>Approvals</h3> |
|
| 189 | 192 | {% if approvals.is_empty() %}<p class="mute small">None yet{% if requirements.required_approvals > 0 %}; {{ requirements.required_approvals }} sealed required{% endif %}.</p>{% else %} |
|
| 190 | 193 | <ul class="plain">{% for a in approvals %}<li>{% if a.sealed %}<span class="sig sig-valid">◈</span>{% else %}<span class="sig sig-unsigned">○</span>{% endif %} <a href="/{{ a.account }}">{{ a.account }}</a>{% if !a.sealed %} <span class="mute small">seal pending</span>{% endif %}</li>{% endfor %}</ul>{% endif %} |
|
| … | 6 unchanged lines |