//! Proposal pages (hub spec §7): list, new, detail with conversation, //! changes and files, comments anchored to atoms, reviews, landing. use super::history::{render_files, FileRows}; use super::settings::{csrf, require}; use super::{filters, lookup, lookup_role, Error, Page, RepoShell, Shell}; use crate::auth::{Access, Role, Viewer}; use crate::db::{self, accounts, proposals as pdb}; use crate::render::{self, diff}; use crate::repos::proposals::{self as pl, Projection, Source}; use crate::repos::view::{self, ChangeView, ConflictView}; use crate::repos::RepoName; use crate::Shared; use askama::Template; use axum::extract::{Form, Path, Query, State}; use axum::response::{IntoResponse, Redirect, Response}; use hub_api::{Approval, ProposalSource, ProposalState}; use oxy_core::hash::ChangeHash; use serde::Deserialize; use std::collections::{BTreeMap, HashMap}; // ------------------------------------------------------------------ list #[derive(Template)] #[template(path = "proposals.html")] pub struct ProposalsPage { shell: Shell, repo: RepoShell, rows: Vec, filter: String, counts: (usize, usize, usize), bookmarks: Vec, q: String, sort: String, pager: super::Pager, labels: std::collections::HashMap>, } impl ProposalsPage { fn labels_of(&self, id: &i64) -> &[db::issues::Label] { self.labels.get(id).map(Vec::as_slice).unwrap_or(&[]) } } #[derive(Deserialize)] pub struct ListQuery { state: Option, q: Option, sort: Option, page: Option, } /// The list's sort orders. pub const SORTS: &[(&str, &str)] = &[("updated", "recently updated"), ("newest", "newest"), ("oldest", "oldest")]; pub async fn list( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Query(q): Query, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/proposals"), &viewer); let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; let all = pdb::list(&app.db, row.id, &[]).await.map_err(|e| Error::from_db(shell.clone(), e))?; let counts = ( all.iter().filter(|p| p.is_open()).count(), all.iter().filter(|p| p.state == "landed").count(), all.iter().filter(|p| p.state == "closed").count(), ); let filter = q.state.unwrap_or_else(|| "open".into()); let mut labels: std::collections::HashMap> = std::collections::HashMap::new(); for (id, l) in db::issues::labels_by_proposal(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))? { labels.entry(id).or_default().push(l); } // `author:bob label:ui target:main words…` let qtext = q.q.clone().unwrap_or_default(); let (mut authors, mut wanted, mut targets, mut words) = (vec![], vec![], vec![], vec![]); for tok in qtext.split_whitespace() { match tok.split_once(':') { Some(("author", v)) => authors.push(v.to_lowercase()), Some(("label", v)) => wanted.push(v.to_lowercase()), Some(("target", v)) => targets.push(v.to_string()), _ => words.push(tok.to_lowercase()), } } let mut rows: Vec = all .into_iter() .filter(|p| match filter.as_str() { "open" => p.is_open(), "landed" => p.state == "landed", "closed" => p.state == "closed", _ => true, }) .filter(|p| authors.is_empty() || authors.contains(&p.author.to_lowercase())) .filter(|p| targets.is_empty() || targets.contains(&p.target)) .filter(|p| wanted.iter().all(|w| labels.get(&p.id).is_some_and(|ls| ls.iter().any(|l| l.name.to_lowercase() == *w)))) .filter(|p| words.iter().all(|w| p.title.to_lowercase().contains(w) || p.body.to_lowercase().contains(w))) .collect(); let sort = q.sort.filter(|s| SORTS.iter().any(|(k, _)| k == s)).unwrap_or_else(|| "updated".into()); match sort.as_str() { "newest" => rows.sort_by_key(|p| std::cmp::Reverse(p.number)), "oldest" => rows.sort_by_key(|p| p.number), _ => rows.sort_by_key(|p| std::cmp::Reverse(p.updated_ms)), } let pager = super::Pager::new(rows.len(), q.page, &[("state", &filter), ("q", &qtext), ("sort", &sort)]); let rows = pager.slice(rows); let bookmarks = app .repos .read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::>())) .await .map_err(|e| Error::from_repo(shell.clone(), e))?; Ok(Page(ProposalsPage { shell, repo: RepoShell::new(&app, &row, "proposals").with_role(role), rows, filter, counts, bookmarks, q: qtext, sort, pager, labels, })) } // ------------------------------------------------------------------- new #[derive(Template)] #[template(path = "proposal_new.html")] pub struct NewProposalPage { shell: Shell, repo: RepoShell, bookmarks: Vec, source: String, target: String, error: Option, /// Preview: the changes the proposal would add. preview: Vec, conflicted: bool, } #[derive(Deserialize)] pub struct NewQuery { source: Option, target: Option, } /// `owner/name:bookmark` for a fork, else a bookmark of this repository. fn parse_source(app: &Shared, repo: &RepoName, source: &str) -> Option<(Option, String)> { if let Some((slug, bookmark)) = source.split_once(':') { let fork = RepoName::parse(slug)?; if !app.repos.exists(&fork) || fork == *repo { return None; } return Some((Some(fork), bookmark.to_string())); } crate::repos::names::valid_segment(source).then(|| (None, source.to_string())) } async fn preview_for( app: &Shared, repo: &RepoName, target: &str, source: &str, ) -> Result<(Vec, bool), crate::repos::RepoError> { let Some((fork, bookmark)) = parse_source(app, repo, source) else { return Ok((vec![], false)) }; let target = target.to_string(); let fork_dir = fork.as_ref().map(|f| app.repos.dir(f)); app.repos .read(repo, move |r, cache| { let projection = match &fork_dir { Some(dir) => { let fork_repo = oxy_core::repo::Repo::open(dir)?; let heads = fork_repo.bookmarks()?.into_iter().find(|b| b.name == bookmark).map(|b| b.heads).unwrap_or_default(); pl::project(r, cache, &target, Source::Fork { fork: &fork_repo, heads })? } None => { let heads = r.bookmarks()?.into_iter().find(|b| b.name == bookmark).map(|b| b.heads).unwrap_or_default(); pl::project(r, cache, &target, Source::Bookmark(heads))? } }; Ok((projection.changes, projection.conflicted)) }) .await } pub async fn new_form( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Query(q): Query, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/proposals/new"), &viewer); require(&viewer, &shell)?; let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; let bookmarks = app .repos .read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::>())) .await .map_err(|e| Error::from_repo(shell.clone(), e))?; let target = q.target.unwrap_or_else(|| row.default_bookmark.clone()); let source = q.source.unwrap_or_default(); let (preview, conflicted) = if source.is_empty() { (vec![], false) } else { preview_for(&app, &repo, &target, &source).await.unwrap_or((vec![], false)) }; Ok(Page(NewProposalPage { shell, repo: RepoShell::new(&app, &row, "proposals").with_role(role), bookmarks, source, target, error: None, preview, conflicted, })) } #[derive(Deserialize)] pub struct NewForm { #[serde(rename = "_csrf")] csrf_token: String, title: String, #[serde(default)] body: String, target: String, source: String, #[serde(default)] draft: Option, } /// Open a proposal from a form or the API: validate, create, index, notify. #[allow(clippy::too_many_arguments)] pub async fn open_proposal( app: &Shared, repo: &RepoName, row: &db::repos::RepoRow, me: &accounts::Account, title: &str, body: &str, target: &str, source: &str, draft: bool, ) -> Result { if title.trim().is_empty() { return Err("Give the proposal a title.".into()); } let bookmarks = app.repos.read(repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::>())).await.map_err(|e| e.to_string())?; if !bookmarks.iter().any(|b| b == target) { return Err("Pick a target bookmark of this repository.".into()); } let Some((fork, bookmark)) = parse_source(app, repo, source.trim()) else { return Err("The source is a bookmark of this repository, or owner/name:bookmark of a fork.".into()); }; if fork.is_none() && bookmark == target { return Err("The source and the target are the same bookmark.".into()); } let (preview, _) = preview_for(app, repo, target, source.trim()).await.map_err(|e| e.to_string())?; if preview.is_empty() { return Err("The source adds nothing to the target.".into()); } let source_repo_id = match &fork { Some(fk) => db::repos::get(&app.db, &fk.owner, &fk.name).await.map_err(|e| e.to_string())?.map(|r| r.id), None => None, }; let created = pdb::create( &app.db, pdb::NewProposal { repo_id: row.id, title: title.trim(), body: body.trim(), author_id: Some(me.id), author: &me.username, draft, target, source_kind: if fork.is_some() { "fork" } else { "bookmark" }, source_repo_id, source_bookmark: &bookmark, source_changes: "", }, ) .await .map_err(|e| e.to_string())?; pdb::add_event(&app.db, created.id, &me.username, "opened", "").await.ok(); accounts::audit(&app.db, Some(me), "proposal.opened", &format!("{}#{}", row.slug(), created.number), title).await.ok(); db::notify::index(&app.db, "proposal", row.id, created.id, created.number, &created.title, &created.body).await.ok(); let url = format!("/{}/proposal/{}", row.slug(), created.number); crate::notify::deliver( app, crate::notify::Event { kind: "proposal.opened", title: format!("{}#{}: {}", row.slug(), created.number, created.title), url, actor: me, repo_id: row.id, thread: Some(("proposal", created.id)), mention_text: body, direct: vec![], }, ) .await; crate::hooks::emit( app, row, "proposal.opened", &me.username, serde_json::json!({ "number": created.number, "title": created.title, "target": target, "source": source }), ) .await; Ok(created) } pub async fn create( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/proposals/new"), &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; match open_proposal(&app, &repo, &row, &me.account, &f.title, &f.body, &f.target, &f.source, f.draft.is_some()).await { Ok(created) => Ok(Redirect::to(&format!("/{owner}/{name}/proposal/{}", created.number)).into_response()), Err(msg) => { let bookmarks = app.repos.read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::>())).await.unwrap_or_default(); Ok(Page(NewProposalPage { shell, repo: RepoShell::new(&app, &row, "proposals").with_role(role), bookmarks, source: f.source.clone(), target: f.target.clone(), error: Some(msg), preview: vec![], conflicted: false, }) .into_response()) } } } // ---------------------------------------------------------------- detail /// A comment placed on a diff row, or general. pub struct CommentView { pub row: pdb::CommentRow, pub body_html: String, pub line: Option, } pub struct ReviewView { pub row: pdb::ReviewRow, pub body_html: String, pub sealed: bool, } /// Everything the detail page and the API need, computed once under the lock. pub struct Loaded { pub row: pdb::ProposalRow, pub changes: Vec, pub hashes: Vec, pub heads: Vec, pub state: String, pub target_state: String, pub conflicted: bool, pub conflicts: Vec, pub files: Vec, /// The per-file hunks and bases behind `files`, to render again with other options. pub diffs: Vec, pub bases: std::collections::BTreeMap>, /// Atom per (path, new line number) of the projected state. pub atoms: HashMap<(String, usize), (String, u32)>, pub sealing_keys: Vec, pub touched: Vec, pub owners: Vec, pub landed_already: bool, } /// Compute the projection and everything derived from it. pub async fn load(app: &Shared, repo: &RepoName, row: pdb::ProposalRow) -> Result { let fork_dir = match (row.source_kind.as_str(), row.source_repo_id) { ("fork", Some(id)) => { db::repos::get_by_id(&app.db, id).await.ok().flatten().and_then(|r| RepoName::new(&r.owner, &r.name)).map(|n| app.repos.dir(&n)) } _ => None, }; let r2 = row.clone(); let loaded = app .repos .read(repo, move |r, cache| { let source = match (r2.source_kind.as_str(), &fork_dir) { ("changes", _) => Source::Changes(r2.source_changes().iter().filter_map(|h| ChangeHash::from_hex(h)).collect()), ("fork", Some(dir)) => { let fork_repo = oxy_core::repo::Repo::open(dir)?; let heads = fork_repo.bookmarks()?.into_iter().find(|b| b.name == r2.source_bookmark).map(|b| b.heads).unwrap_or_default(); // Bring the fork's changes over now, so the projection below can read them. let fork_state = fork_repo.load_state(&heads)?; let missing: Vec = fork_state.changes().map(|(h, _)| *h).filter(|h| !r.store().has(h)).collect(); if !missing.is_empty() { r.import_bundle(&fork_repo.bundle_for(&missing)?, r.current_policy()?.is_some())?; } Source::Changes(heads) } _ => { let heads = r.bookmarks()?.into_iter().find(|b| b.name == r2.source_bookmark).map(|b| b.heads).unwrap_or_default(); Source::Bookmark(heads) } }; let p: Projection = pl::project(r, cache, &r2.target, source)?; let target_loaded = cache.load(r, &p.target_heads)?; let files = render_files(&p.files, &p.bases); let mut atoms = HashMap::new(); for f in &files { for l in &f.lines { if let Some(n) = l.new_no { if let Some((h, i)) = pl::atom_at(&p.loaded, &f.path, n as usize) { atoms.insert((f.path.clone(), n as usize), (h.to_hex(), i)); } } } } let touched: Vec = p.files.iter().map(|f| f.path.clone()).collect(); let owners = pl::owners_for(&target_loaded, &touched); let sealing = pl::sealing_keys(r, &p.hashes)?.into_iter().map(|k| k.to_hex()).collect(); let landed_already = !p.hashes.is_empty() && p.hashes.iter().all(|h| target_loaded.state.contains(h)); Ok(Loaded { row: r2, changes: p.changes, hashes: p.hashes, heads: p.heads, state: p.state.to_hex(), target_state: p.target_state.to_hex(), conflicted: p.conflicted, conflicts: p.conflicts, files, diffs: p.files.clone(), bases: p.bases.clone(), atoms, sealing_keys: sealing, touched, owners, landed_already, }) }) .await?; // Remember the projection for the list, the API and other proposals' stacking checks. let heads_csv = loaded.heads.iter().map(|h| h.to_hex()).collect::>().join(","); let changes_csv = loaded.hashes.iter().map(|h| h.to_hex()).collect::>().join(","); pdb::set_projection(&app.db, loaded.row.id, &loaded.state, &heads_csv, &loaded.target_state, &changes_csv, loaded.conflicted) .await .ok(); Ok(loaded) } /// Reviews, approvals and blockers. pub struct Verdict { pub reviews: Vec, pub approvals: Vec, pub blockers: Vec, pub depends_on: Vec, pub requirements: pdb::Requirements, } pub async fn verdict(app: &Shared, loaded: &Loaded, repo_id: i64) -> Result { let rows = pdb::reviews(&app.db, loaded.row.id).await?; let links = accounts::identity_accounts(&app.db).await?; let sealed_accounts: Vec = links.iter().filter(|(k, _)| loaded.sealing_keys.contains(k)).map(|(_, u)| u.clone()).collect(); // Latest verdict per reviewer. let mut latest: BTreeMap = BTreeMap::new(); for r in &rows { latest.insert(r.author.clone(), r); } let mut approvals = Vec::new(); for (who, r) in &latest { if r.verdict == "approve" { let sealed = sealed_accounts.contains(who); let key = links.iter().find(|(k, u)| u == who && loaded.sealing_keys.contains(k)).map(|(k, _)| k.clone()); approvals.push(Approval { account: who.clone(), sealed, key }); } } let reviews = rows .iter() .map(|r| ReviewView { row: r.clone(), body_html: render::markdown::render(&r.body, "/", "/"), sealed: r.verdict == "approve" && sealed_accounts.contains(&r.author), }) .collect(); let requirements = pdb::requirements(&app.db, repo_id).await?; let mut blockers = Vec::new(); if loaded.row.state == "draft" { blockers.push("the proposal is a draft".into()); } if loaded.hashes.is_empty() { blockers.push("the proposal adds no changes".into()); } let sealed_count = approvals.iter().filter(|a| a.sealed).count() as i64; if sealed_count < requirements.required_approvals { blockers.push(format!("{sealed_count} of {} required sealed approvals", requirements.required_approvals)); } if requirements.require_owners { let missing: Vec<&String> = loaded.owners.iter().filter(|o| !approvals.iter().any(|a| a.sealed && &a.account == *o)).collect(); if !missing.is_empty() { blockers.push(format!("code owners still to approve: {}", missing.iter().map(|s| s.as_str()).collect::>().join(", "))); } } if loaded.conflicted && !requirements.allow_conflicts { blockers.push("landing would carry conflicts, which this repository refuses".into()); } let required = db::hooks::required_checks(&app.db, repo_id).await?; if !required.is_empty() { let checks = db::hooks::checks_for_state(&app.db, repo_id, &loaded.state).await?; let missing: Vec = required.iter().filter(|n| !checks.iter().any(|c| &c.name == *n && c.status == "passed")).cloned().collect(); if !missing.is_empty() { blockers.push(format!("required checks not passed on the projected state: {}", missing.join(", "))); } } let requesting: Vec<&String> = latest.iter().filter(|(_, r)| r.verdict == "changes").map(|(who, _)| who).collect(); if !requesting.is_empty() { blockers.push(format!("changes requested by {}", requesting.iter().map(|s| s.as_str()).collect::>().join(", "))); } // Stacking: another open, earlier proposal whose changes this one includes. let mut depends_on = Vec::new(); for other in pdb::list(&app.db, repo_id, &["open", "draft"]).await? { if other.id == loaded.row.id || other.number > loaded.row.number { continue; } let theirs = other.projected_changes(); if !theirs.is_empty() && theirs.iter().all(|h| loaded.hashes.iter().any(|m| m.to_hex() == *h)) { depends_on.push(other.number); } } if !depends_on.is_empty() { blockers.push(format!("depends on {}", depends_on.iter().map(|n| format!("#{n}")).collect::>().join(", "))); } Ok(Verdict { reviews, approvals, blockers, depends_on, requirements }) } #[derive(Template)] #[template(path = "proposal.html")] pub struct ProposalPage { shell: Shell, repo: RepoShell, p: pdb::ProposalRow, body_html: String, tab: String, changes: Vec, files: Vec, diffs: Vec, bases: std::collections::BTreeMap>, /// Whitespace-only changes are hidden (`?w=1`). ignore_ws: bool, conflicts: Vec, conflicted: bool, comments: Vec, /// Anchored comments by (path, current line). placed: HashMap<(String, usize), Vec>, outdated: Vec, reviews: Vec, approvals: Vec, blockers: Vec, depends_on: Vec, events: Vec, owners: Vec, requirements: pdb::Requirements, landed_already: bool, can_edit: bool, can_land: bool, hub_can_land: bool, notice: Option, error: Option, heads: Vec, state_hash: String, source_label: String, api_url: String, labels: Vec, all_labels: Vec, checks: Vec, required_checks: Vec, } #[derive(Deserialize)] pub struct DetailQuery { tab: Option, #[serde(default)] w: Option, } /// The page assembler every action ends in; its arguments are the route /// plus what to say, and a struct would only rename them. #[allow(clippy::too_many_arguments)] async fn detail( app: &Shared, shell: Shell, viewer: &Viewer, owner: &str, name: &str, number: i64, tab: String, notice: Option, error: Option, ) -> Result, Error> { let (repo, row, role) = lookup(app, &shell, viewer, owner, name).await?; let p = pdb::get(&app.db, row.id, number) .await .map_err(|e| Error::from_db(shell.clone(), e))? .ok_or_else(|| Error::not_found(shell.clone()))?; let loaded = load(app, &repo, p).await.map_err(|e| Error::from_repo(shell.clone(), e))?; // A proposal whose changes all reached the target landed by another route. if loaded.landed_already && loaded.row.is_open() { pdb::set_state(&app.db, loaded.row.id, "landed", &loaded.target_state).await.ok(); pdb::add_event(&app.db, loaded.row.id, "", "landed", "every change reached the target").await.ok(); } let p = pdb::get(&app.db, row.id, number).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(loaded.row.clone()); let v = verdict(app, &loaded, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; let comment_rows = pdb::comments(&app.db, loaded.row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; let events = pdb::events(&app.db, loaded.row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; // Place anchored comments at their atom's current line. let mut by_atom: HashMap<(String, String, u32), usize> = HashMap::new(); for ((path, line), (h, i)) in &loaded.atoms { by_atom.insert((path.clone(), h.clone(), *i), *line); } let mut comments = Vec::new(); let mut placed: HashMap<(String, usize), Vec> = HashMap::new(); let mut outdated = Vec::new(); for (idx, c) in comment_rows.into_iter().enumerate() { let line = if c.anchor_change.is_empty() { None } else { by_atom.get(&(c.path.clone(), c.anchor_change.clone(), c.anchor_index as u32)).copied() }; if !c.anchor_change.is_empty() { match line { Some(l) => placed.entry((c.path.clone(), l)).or_default().push(idx), None => outdated.push(idx), } } comments.push(CommentView { body_html: render::markdown::render(&c.body, "/", "/"), row: c, line }); } let mut changes = loaded.changes.clone(); super::attribute(app, &mut changes.iter_mut().collect::>()).await; let me = viewer.account(); let can_edit = me.is_some_and(|a| Some(a.id) == p.author_id || role >= Role::Maintain); let can_land = role >= Role::Write && p.is_open() && v.blockers.is_empty(); let delegated = match viewer.account() { Some(a) => super::edit::delegated_identity(app, a.id).await.is_some(), None => false, }; let hub_can_land = delegated || app.repos.read(&repo, |r, _| Ok(r.current_policy()?.is_none())).await.unwrap_or(false); let source_label = match p.source_kind.as_str() { "fork" => format!( "fork {}", db::repos::get_by_id(&app.db, p.source_repo_id.unwrap_or(0)) .await .ok() .flatten() .map(|r| format!("{}:{}", r.slug(), p.source_bookmark)) .unwrap_or_default() ), "changes" => format!("{} changes", p.source_changes().len()), _ => p.source_bookmark.clone(), }; Ok(Page(ProposalPage { shell, repo: RepoShell::new(app, &row, "proposals").with_role(role), body_html: super::issues::markdown(app, row.id, &format!("/{owner}/{name}"), &p.body).await, p, tab, changes, files: loaded.files, diffs: loaded.diffs, bases: loaded.bases, ignore_ws: false, conflicts: loaded.conflicts, conflicted: loaded.conflicted, comments, placed, outdated, reviews: v.reviews, approvals: v.approvals, blockers: v.blockers, depends_on: v.depends_on, events, owners: loaded.owners, requirements: v.requirements, landed_already: loaded.landed_already, can_edit, can_land, hub_can_land, notice, error, heads: loaded.heads.iter().map(|h| h.to_hex()).collect(), state_hash: loaded.state.clone(), source_label, api_url: format!("{}/api/v1/repos/{owner}/{name}/proposals/{number}", app.config.base_url()), labels: db::issues::proposal_labels(&app.db, loaded.row.id).await.unwrap_or_default(), all_labels: db::issues::labels(&app.db, row.id).await.unwrap_or_default(), checks: db::hooks::checks_for_state(&app.db, row.id, &loaded.state).await.unwrap_or_default(), required_checks: db::hooks::required_checks(&app.db, row.id).await.unwrap_or_default(), })) } pub async fn show( State(app): State, viewer: Viewer, Path((owner, name, number)): Path<(String, String, i64)>, Query(q): Query, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer); let mut page = detail(&app, shell, &viewer, &owner, &name, number, q.tab.unwrap_or_else(|| "conversation".into()), None, None).await?; if q.w.as_deref() == Some("1") { // Hide whitespace-only changes: the same hunks, rendered again. page.0.files = super::history::render_files_with(&page.0.diffs, &page.0.bases, crate::render::diff::Options { ignore_whitespace: true }); page.0.ignore_ws = true; } Ok(page) } // ---------------------------------------------------------------- actions #[derive(Deserialize)] pub struct CommentForm { #[serde(rename = "_csrf")] csrf_token: String, body: String, #[serde(default)] path: String, #[serde(default)] line: Option, } pub async fn comment( State(app): State, viewer: Viewer, Path((owner, name, number)): Path<(String, String, i64)>, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let (repo, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?; let p = pdb::get(&app.db, row.id, number) .await .map_err(|e| Error::from_db(shell.clone(), e))? .ok_or_else(|| Error::not_found(shell.clone()))?; if f.body.trim().is_empty() { return detail( &app, shell, &viewer, &owner, &name, number, "conversation".into(), None, Some("An empty comment says nothing.".into()), ) .await; } let anchor = match (f.path.trim(), f.line) { ("", _) | (_, None) => None, (path, Some(line)) => { let loaded = load(&app, &repo, p.clone()).await.map_err(|e| Error::from_repo(shell.clone(), e))?; loaded.atoms.get(&(path.to_string(), line)).cloned() } }; let tab = if anchor.is_some() { "files" } else { "conversation" }; pdb::add_comment( &app.db, p.id, Some(me.account.id), &me.account.username, f.body.trim(), f.path.trim(), anchor.as_ref().map(|(h, i)| (h.as_str(), *i as i64)), ) .await .map_err(|e| Error::from_db(shell.clone(), e))?; detail(&app, shell, &viewer, &owner, &name, number, tab.into(), None, None).await } #[derive(Deserialize)] pub struct ReviewForm { #[serde(rename = "_csrf")] csrf_token: String, verdict: String, #[serde(default)] body: String, } pub async fn review( State(app): State, viewer: Viewer, Path((owner, name, number)): Path<(String, String, i64)>, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?; let p = pdb::get(&app.db, row.id, number) .await .map_err(|e| Error::from_db(shell.clone(), e))? .ok_or_else(|| Error::not_found(shell.clone()))?; if !matches!(f.verdict.as_str(), "comment" | "changes" | "approve") { return Err(Error::bad(shell, "a review is comment, changes or approve")); } if f.verdict == "approve" && Some(me.account.id) == p.author_id { return detail( &app, shell, &viewer, &owner, &name, number, "conversation".into(), None, Some("You cannot approve your own proposal.".into()), ) .await; } pdb::add_review(&app.db, p.id, me.account.id, &me.account.username, &f.verdict, f.body.trim()) .await .map_err(|e| Error::from_db(shell.clone(), e))?; pdb::add_event(&app.db, p.id, &me.account.username, &format!("review.{}", f.verdict), "").await.ok(); crate::hooks::emit(&app, &row, "review", &me.account.username, serde_json::json!({ "number": number, "verdict": f.verdict })).await; let notice = if f.verdict == "approve" { Some(format!( "Approval recorded. It counts once you seal the changes: oxy seal --proposal {}/{owner}/{name}/proposal/{number}", app.config.base_url() )) } else { None }; detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), notice, None).await } #[derive(Deserialize)] pub struct EditForm { #[serde(rename = "_csrf")] csrf_token: String, title: String, #[serde(default)] body: String, target: String, } pub async fn edit( State(app): State, viewer: Viewer, Path((owner, name, number)): Path<(String, String, i64)>, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; let p = pdb::get(&app.db, row.id, number) .await .map_err(|e| Error::from_db(shell.clone(), e))? .ok_or_else(|| Error::not_found(shell.clone()))?; if Some(me.account.id) != p.author_id && role < Role::Maintain { return Err(Error::not_found(shell)); } let target = if crate::repos::names::valid_segment(f.target.trim()) { f.target.trim() } else { &p.target }; pdb::edit(&app.db, p.id, f.title.trim(), f.body.trim(), target).await.map_err(|e| Error::from_db(shell.clone(), e))?; pdb::add_event(&app.db, p.id, &me.account.username, "edited", "").await.ok(); detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), Some("Saved.".into()), None).await } #[derive(Deserialize)] pub struct StateForm { #[serde(rename = "_csrf")] csrf_token: String, action: String, } pub async fn state( State(app): State, viewer: Viewer, Path((owner, name, number)): Path<(String, String, i64)>, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; let p = pdb::get(&app.db, row.id, number) .await .map_err(|e| Error::from_db(shell.clone(), e))? .ok_or_else(|| Error::not_found(shell.clone()))?; if Some(me.account.id) != p.author_id && role < Role::Triage { return Err(Error::not_found(shell)); } let new_state = match (f.action.as_str(), p.state.as_str()) { ("close", "open" | "draft") => "closed", ("reopen", "closed") => "open", ("ready", "draft") => "open", ("draft", "open") => "draft", _ => return Err(Error::bad(shell, "that transition is not possible")), }; pdb::set_state(&app.db, p.id, new_state, "").await.map_err(|e| Error::from_db(shell.clone(), e))?; pdb::add_event(&app.db, p.id, &me.account.username, new_state, "").await.ok(); crate::hooks::emit(&app, &row, &format!("proposal.{new_state}"), &me.account.username, serde_json::json!({ "number": number })).await; detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), None, None).await } pub async fn resolve_comment( State(app): State, viewer: Viewer, Path((owner, name, number, id)): Path<(String, String, i64, i64)>, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer); require(&viewer, &shell)?; csrf(&viewer, &f.csrf, &shell)?; let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?; let p = pdb::get(&app.db, row.id, number) .await .map_err(|e| Error::from_db(shell.clone(), e))? .ok_or_else(|| Error::not_found(shell.clone()))?; let current: Option = sqlx::query_scalar("SELECT resolved FROM proposal_comments WHERE id = ? AND proposal_id = ?") .bind(id) .bind(p.id) .fetch_optional(&app.db) .await .map_err(|e| Error::from_db(shell.clone(), e))?; if let Some(r) = current { pdb::set_comment_resolved(&app.db, id, !r).await.map_err(|e| Error::from_db(shell.clone(), e))?; } detail(&app, shell, &viewer, &owner, &name, number, "files".into(), None, None).await } /// Land from the hub: only when the repository has no trust policy (the /// hub never signs a reference update the policy would have to admit). pub async fn land( State(app): State, viewer: Viewer, Path((owner, name, number)): Path<(String, String, i64)>, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf, &shell)?; let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; let p = pdb::get(&app.db, row.id, number) .await .map_err(|e| Error::from_db(shell.clone(), e))? .ok_or_else(|| Error::not_found(shell.clone()))?; let loaded = load(&app, &repo, p.clone()).await.map_err(|e| Error::from_repo(shell.clone(), e))?; let v = verdict(&app, &loaded, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; if !p.is_open() || !v.blockers.is_empty() { return detail( &app, shell, &viewer, &owner, &name, number, "conversation".into(), None, Some(format!("Cannot land: {}", v.blockers.join("; "))), ) .await; } let target = p.target.clone(); let heads = loaded.heads.clone(); // With a delegation the landing is attested by the hub key on the // user's behalf, which a policy accepts when it lets the user update // the target (spec §14.5); without one, only a policy-free repository // can be landed here. let result = match super::edit::signer_for(&app, me.account.id).await { Ok(signer) => crate::repos::edits::move_bookmark(&app.repos, &repo, target.clone(), heads.clone(), signer).await.map(|_| pl::state_ref(&heads).to_hex()), Err(why) => { app.repos .write(&repo, move |r, _| { if r.current_policy()?.is_some() { return Err(crate::repos::RepoError::Oxy(oxy_core::repo::RepoError::Msg(format!( "this repository has a trust policy, so the landing must be signed by a key it authorises. Land from a client with oxy proposal land, or delegate to the hub. {why}" )))); } r.bookmark_set(&target, &heads)?; Ok(pl::state_ref(&heads).to_hex()) }) .await } }; match result { Ok(state) => { pdb::set_state(&app.db, p.id, "landed", &state).await.map_err(|e| Error::from_db(shell.clone(), e))?; pdb::add_event(&app.db, p.id, &me.account.username, "landed", "from the hub").await.ok(); crate::hooks::emit( &app, &row, "proposal.landed", &me.account.username, serde_json::json!({ "number": number, "target": p.target, "state": state }), ) .await; crate::notify::deliver( &app, crate::notify::Event { kind: "proposal.landed", title: format!("{owner}/{name}#{number}: {} landed", p.title), url: format!("/{owner}/{name}/proposal/{number}"), actor: &me.account, repo_id: row.id, thread: Some(("proposal", p.id)), mention_text: "", direct: p.author_id.into_iter().collect(), }, ) .await; if p.target == row.default_bookmark { let messages: Vec<(String, String)> = loaded.changes.iter().map(|c| (c.hash.clone(), format!("{}\n{}", c.title, c.body))).collect(); super::issues::close_from_messages(&app, &row, &messages).await; } accounts::audit(&app.db, Some(&me.account), "proposal.landed", &format!("{owner}/{name}#{number}"), "").await.ok(); db::repos::record_push( &app.db, row.id, Some((me.account.id, me.account.username.as_str())), &[db::repos::PushOutcome { reference: p.target.clone(), ok: true, reason: format!("landed #{number}"), old_state: loaded.target_state.clone(), new_state: state, heads: loaded.heads.len() as i64, changes: loaded.hashes.len() as i64, }], ) .await .ok(); detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), Some("Landed.".into()), None).await } Err(e) => detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), None, Some(e.to_string())).await, } } #[derive(Deserialize)] pub struct LabelsForm { #[serde(rename = "_csrf")] csrf_token: String, #[serde(default)] label: Vec, } pub async fn set_labels( State(app): State, viewer: Viewer, Path((owner, name, number)): Path<(String, String, i64)>, axum_extra::extract::Form(f): axum_extra::extract::Form, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer); require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; if role < Role::Triage { return Err(Error::not_found(shell)); } let p = pdb::get(&app.db, row.id, number) .await .map_err(|e| Error::from_db(shell.clone(), e))? .ok_or_else(|| Error::not_found(shell.clone()))?; for l in db::issues::labels(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))? { db::issues::set_proposal_label(&app.db, p.id, l.id, f.label.contains(&l.id)).await.map_err(|e| Error::from_db(shell.clone(), e))?; } detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), None, None).await } /// `GET /{owner}/{name}/proposal/{number}/events`: server-sent events for /// checks on the projected state (hub spec §13.1). pub async fn events( State(app): State, viewer: Viewer, Path((owner, name, number)): Path<(String, String, i64)>, ) -> Result>>, Error> { use futures::StreamExt; let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer); let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?; let rx = app.events.proposals.subscribe(); let repo_id = row.id; let stream = tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(move |item| async move { match item { Ok((id, ev)) if id == repo_id => { Some(Ok(axum::response::sse::Event::default().event(ev.kind.clone()).data(serde_json::to_string(&ev).unwrap_or_default()))) } _ => None, } }); Ok(axum::response::Sse::new(stream).keep_alive(axum::response::sse::KeepAlive::default())) } // -------------------------------------------------------------------- API pub async fn to_api(app: &Shared, owner: &str, name: &str, loaded: &Loaded, v: &Verdict, source_url: Option) -> hub_api::Proposal { let p = &loaded.row; hub_api::Proposal { number: p.number, title: p.title.clone(), body: p.body.clone(), author: p.author.clone(), state: ProposalState::parse(&p.state).unwrap_or(ProposalState::Open), target: p.target.clone(), source: match p.source_kind.as_str() { "fork" => { let (o, n) = source_url .as_deref() .and_then(|u| u.rsplit_once('/')) .and_then(|(b, n)| b.rsplit_once('/').map(|(_, o)| (o.to_string(), n.to_string()))) .unwrap_or_default(); ProposalSource::Fork { owner: o, name: n, bookmark: p.source_bookmark.clone() } } "changes" => ProposalSource::Changes { hashes: p.source_changes() }, _ => ProposalSource::Bookmark { bookmark: p.source_bookmark.clone() }, }, changes: loaded .changes .iter() .map(|c| hub_api::ChangeSummary { hash: c.hash.clone(), message: if c.body.is_empty() { c.title.clone() } else { format!("{}\n\n{}", c.title, c.body) }, author: hub_api::Author { name: c.author.name.clone(), email: c.author.email.clone(), key: c.author.key.clone(), account: c.author.account.clone(), }, when_ms: c.when_ms, deps: c.deps.clone(), head: c.head, signature: match &c.signature { view::SigView::Unsigned => hub_api::Signature::Unsigned, view::SigView::Valid { key, sealed, revoked, .. } => { hub_api::Signature::Valid { key: key.clone(), sealed: *sealed, revoked: *revoked } } view::SigView::Invalid(why) => hub_api::Signature::Invalid { reason: why.clone() }, }, }) .collect(), projected_heads: loaded.heads.iter().map(|h| h.to_hex()).collect(), projected_state: Some(loaded.state.clone()), conflicted: loaded.conflicted, approvals: v.approvals.clone(), landable: p.is_open() && v.blockers.is_empty(), blockers: v.blockers.clone(), created_ms: p.created_ms, updated_ms: p.updated_ms, url: format!("{}/{owner}/{name}/proposal/{}", app.config.base_url(), p.number), repository_url: format!("{}/{owner}/{name}", app.config.base_url()), source_url, } } /// The line comment form's placement key, for templates. pub fn key(path: &str, line: impl filters::Num) -> (String, usize) { (path.to_string(), line.as_u64() as usize) } #[allow(dead_code)] fn _unused(_: &diff::Line, _: &ConflictView, _: &Role) {}