OXYGENHUB

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