//! 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, /// What the form holds so far (after a failed open, or a draft). title: String, body: String, /// AI assistants that may draft the description (hub spec §13.4). assistants: Vec, } #[derive(Deserialize)] pub struct NewQuery { source: Option, target: Option, } /// `owner/name:bookmark` for a fork, else a bookmark of this repository. /// A proposal's source: a bookmark of this repository by name (which may /// hold `/`, as `feature/16-hud` does), or `owner/name:bookmark` of a fork; /// `owner/name:bookmark` naming this repository is its own bookmark. fn parse_source(app: &Shared, repo: &RepoName, source: &str) -> Option<(Option, String)> { let plain = |b: &str| !b.is_empty() && !b.chars().any(|c| c.is_control() || c == ':'); if let Some((slug, bookmark)) = source.split_once(':') { let fork = RepoName::parse(slug)?; if !plain(bookmark) { return None; } if fork == *repo { return Some((None, bookmark.to_string())); } if !app.repos.exists(&fork) { return None; } return Some((Some(fork), bookmark.to_string())); } plain(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)) }; let assistants = if preview.is_empty() { vec![] } else { assistant_names(&app, &row).await }; Ok(Page(NewProposalPage { shell, repo: RepoShell::new(&app, &row, "proposals").with_role(role), bookmarks, source, target, error: None, preview, conflicted, title: String::new(), body: String::new(), assistants, })) } #[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, /// For `/proposals/new/draft`: which assistant writes the description. #[serde(default)] assistant: String, } /// 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() && !bookmarks.contains(&bookmark) { return Err(format!("There is no bookmark {bookmark} in this repository.")); } 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?; super::writable(&row, &shell)?; 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, title: f.title.clone(), body: f.body.clone(), assistants: vec![], }) .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, } /// One entry of a proposal's conversation, in time order: what kind it /// is and its index in `comments`, `reviews` or `events`. pub struct Entry { pub kind: &'static str, pub index: usize, } 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 target_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, /// Whether its source has any changes at all (#141): an open proposal /// whose source has changes, none of them missing from the target, was /// landed by some other route. pub source_has_changes: bool, } /// Compute the projection and everything derived from it. pub async fn load(app: &Shared, repo: &RepoName, row: pdb::ProposalRow) -> Result { load_with(app, repo, row, true).await } /// [`load`] without the rendered diff and the line-to-atom map, which only /// the proposal page needs: for lists, the API, landing and the checks that /// follow a push. On a repository with large files those two were most of /// a list's time (metalgear2's took 3-4 s per call). pub async fn load_summary(app: &Shared, repo: &RepoName, row: pdb::ProposalRow) -> Result { load_with(app, repo, row, false).await } async fn load_with(app: &Shared, repo: &RepoName, row: pdb::ProposalRow, render: bool) -> 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 source_has_changes = match &source { Source::Changes(h) | Source::Bookmark(h) | Source::Fork { heads: h, .. } => !h.is_empty(), }; let p: Projection = pl::project(r, cache, &r2.target, source)?; let target_loaded = cache.load(r, &p.target_heads)?; let files = if render { render_files(&p.files, &p.bases) } else { Vec::new() }; let mut atoms = HashMap::new(); for f in &files { // Each file's live lines once, not once per line. let Some(live) = pl::atoms_of(&p.loaded, &f.path) else { continue }; for l in &f.lines { if let Some((h, i)) = l.new_no.and_then(|n| live.get((n as usize).checked_sub(1)?)) { atoms.insert((f.path.clone(), l.new_no.unwrap_or(0) 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)); // A landed proposal's changes are in its target now: it shows // the ones it landed, as recorded when it landed. let (changes, hashes) = if r2.state == "landed" && p.hashes.is_empty() { let hashes: Vec = r2.projected_changes().iter().filter_map(|h| ChangeHash::from_hex(h)).collect(); let labels = HashMap::new(); let changes = hashes .iter() .filter_map(|h| Some(view::change_view_pub(r, h, &r.store().get_change_meta(h).ok()?, false, &labels))) .collect(); (changes, hashes) } else { (p.changes, p.hashes) }; Ok(Loaded { row: r2, changes, hashes, heads: p.heads, target_heads: p.target_heads.clone(), 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, source_has_changes, }) }) .await?; // Landed by another route (#141): `oxy proposal land` from a client (a // repository with a trust policy needs it), or the work merged and // pushed by hand. Everything it proposes is in its target, so it is // landed, not open with nothing to land. let mut loaded = loaded; if loaded.row.is_open() && loaded.source_has_changes && loaded.hashes.is_empty() && pdb::set_landed(&app.db, loaded.row.id, &loaded.target_state, "", "push").await.is_ok() { loaded.row.state = "landed".into(); loaded.row.landed_via = "push".into(); loaded.row.landed_state = loaded.target_state.clone(); return Ok(loaded); } // Remember the projection for the list, the API and other proposals' // stacking checks, while the proposal is open: a landed one keeps what // it landed. if !loaded.row.is_open() { return Ok(loaded); } 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) } /// After a push moved `bookmark`: open proposals into it whose work it now /// holds are marked landed, by the pusher (#141). pub async fn settle_after_push(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, actor: &str) { let Some(repo) = RepoName::new(&row.owner, &row.name) else { return }; let Ok(open) = pdb::list(&app.db, row.id, &["open", "draft"]).await else { return }; for p in open.into_iter().filter(|p| p.target == bookmark) { let id = p.id; let number = p.number; if let Ok(l) = load_summary(app, &repo, p).await { if l.row.state == "landed" && l.row.landed_by.is_empty() { pdb::set_landed(&app.db, id, &l.target_state, actor, "push").await.ok(); crate::flow::moot_after_landing(app, row, &l.row).await; } else if l.row.is_open() && !l.hashes.is_empty() && crate::flow::enabled(app, row) { // The target moved, so the proposal's projected state is // new and its checks would be for an older one (#134): run // the pipeline on it, once. let ran: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM flow_runs WHERE repo_id = $1 AND state = $2") .bind(row.id) .bind(&l.state) .fetch_one(&app.db) .await .unwrap_or(1); if ran == 0 { let label = format!("proposal/{number}"); match crate::flow::start_run_scoped(app, row, &label, &l.state, &l.heads, actor, "proposal", None).await { Ok(Some(n)) => tracing::info!("proposal {number}: its target moved; run #{n} checks its new projected state"), Ok(None) => {} Err(e) => tracing::warn!("proposal {number}: a run of its new projected state did not start: {e}"), } } } } } } /// Reviews, approvals and blockers. pub struct Verdict { pub reviews: Vec, pub approvals: Vec, pub blockers: Vec, /// The required-checks blocker, also in `blockers`: the one a /// maintainer may land over with a reason (#261). pub checks_blocker: Option, 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.iter().filter(|r| r.via.is_empty()) { // An assistant's answer is never the person's own verdict. 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(); // Only an open proposal can be held back. if !loaded.row.is_open() { return Ok(Verdict { reviews, approvals, blockers, checks_blocker: None, depends_on: vec![], requirements }); } 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(format!( "landing would carry conflicts, which this repository refuses: resolve them on the source (oxy merge {} there, fix the marked files, oxy commit, push)", loaded.row.target )); } let mut checks_blocker = None; 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() { let b = format!("required checks not passed on the projected state: {}", missing.join(", ")); checks_blocker = Some(b.clone()); blockers.push(b); } } 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, checks_blocker, depends_on, requirements }) } #[derive(Template)] #[template(path = "proposal.html")] pub struct ProposalPage { shell: Shell, repo: RepoShell, p: pdb::ProposalRow, /// Live topics (oxygenhub#205): the proposal's and its repository's /// (checks arrive on the latter). live_topic: String, repo_topic: String, /// Assistants answering now: shown as pending until they do. pending: Vec, /// Issues it closes, from its description and its changes. closes: Vec, /// Projects the viewer may add it to. project_choices: Option>, /// Comments (not anchored to a line), reviews and events, oldest first. timeline: Vec, /// The viewer may set labels (triage or above). can_triage: bool, 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, /// The landed proposal's source bookmark, when it is this repository's, /// still there, and the viewer may delete it (oxygenhub#86). deletable_source: Option, can_land: bool, can_land_over_checks: bool, can_auto_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, /// Projects this proposal is on that the viewer may see, with the column. projects: Vec<(db::projects::Project, String)>, /// Asked to review and not yet reviewing. requested: Vec, /// Who can be asked to review, for the sidebar's menu (oxygenhub#84). reviewable: Vec, /// The viewer may ask for reviews (the author, or a writer). can_request: bool, /// AI assistants the viewer may ask, by name (hub spec §13.4). assistants: Vec, /// A description an assistant just drafted, for the editor. draft: Option, } #[derive(Deserialize)] pub struct DetailQuery { tab: Option, /// What an action said, after it redirected here. #[serde(default)] notice: Option, #[serde(default)] error: Option, #[serde(default)] w: Option, /// Only this part of the page (live pages, oxygenhub#205). #[serde(default)] fragment: 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 p_id = p.id; 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(); crate::flow::moot_after_landing(app, &row, &loaded.row).await; } 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 deletable_source = if p.state == "landed" && p.source_kind == "bookmark" && role >= Role::Write && p.source_bookmark != row.default_bookmark { let b = p.source_bookmark.clone(); app.repos .read(&repo, move |r, _| Ok(r.view()?.bookmarks.contains_key(&b))) .await .unwrap_or(false) .then(|| p.source_bookmark.clone()) } else { None }; let can_land = role >= Role::Write && p.is_open() && v.blockers.is_empty(); // Not landable yet: anyone who may land can set it to land when its // checks pass (#261). let can_auto_land = role >= Role::Write && p.is_open() && !v.blockers.is_empty(); // Checks alone in the way: a maintainer may land over them (#261). let can_land_over_checks = role >= Role::Maintain && p.is_open() && v.checks_blocker.is_some() && v.blockers.len() == 1; let can_request = p.is_open() && me.is_some_and(|a| Some(a.id) == p.author_id || role >= Role::Write); let requested = pdb::review_requests(&app.db, p.id).await.unwrap_or_default(); let mut reviewable: Vec = if can_request { accounts::assignable(&app.db, row.id, &row.owner).await.unwrap_or_default().into_iter().filter(|u| *u != p.author).collect() } else { Vec::new() }; for r in &requested { if !reviewable.iter().any(|x| x.eq_ignore_ascii_case(r)) { reviewable.push(r.clone()); } } let assistants: Vec = if (can_request || can_edit) && p.is_open() { assistant_names(app, &row).await } else { Vec::new() }; 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(), }; let projects = super::projects::placements_for(app, viewer, &row, p.number).await; let texts: Vec = std::iter::once(p.body.clone()).chain(changes.iter().map(|c| format!("{}\n{}", c.title, c.body))).collect(); let closes = super::chain::issues_closed_by(app, &row, &texts.iter().map(String::as_str).collect::>()).await; let project_choices = super::projects::project_choices(app, viewer, &row, &projects).await; let mut dated: Vec<(i64, Entry)> = Vec::new(); for (index, c) in comments.iter().enumerate() { if c.row.anchor_change.is_empty() { dated.push((c.row.created_ms, Entry { kind: "comment", index })); } } for (index, r) in v.reviews.iter().enumerate() { dated.push((r.row.created_ms, Entry { kind: "review", index })); } for (index, e) in events.iter().enumerate() { dated.push((e.at_ms, Entry { kind: "event", index })); } dated.sort_by_key(|(at, _)| *at); let timeline: Vec = dated.into_iter().map(|(_, e)| e).collect(); let pending = app.assistant_gate.lock().unwrap_or_else(|e| e.into_inner()).answering(p_id); Ok(Page(ProposalPage { live_topic: crate::events::topic::proposal(row.id, number), repo_topic: crate::events::topic::repo(row.id), pending, can_triage: role >= Role::Triage, timeline, closes, project_choices, projects, shell, repo: RepoShell::new(app, &row, "proposals").with_role(role).with_sky(app, &row).await, body_html: super::issues::markdown(app, &row, &row.default_bookmark, &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, deletable_source, can_land, can_land_over_checks, can_auto_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(), requested, reviewable, assistants, draft: None, can_request, })) } /// Back to the proposal after an action, with what to say: a redirect, so /// reloading the page (the checks script does) never repeats the action. fn back(owner: &str, name: &str, number: i64, tab: &str, notice: Option, error: Option) -> Response { let mut url = format!("/{owner}/{name}/proposal/{number}?tab={tab}"); if let Some(n) = notice { url.push_str(&format!("¬ice={}", super::settings::urlencode(&n))); } if let Some(e) = error { url.push_str(&format!("&error={}", super::settings::urlencode(&e))); } Redirect::to(&url).into_response() } pub async fn show( State(app): State, viewer: Viewer, Path((owner, name, number)): Path<(String, String, i64)>, Query(q): Query, ) -> Result { 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()), q.notice, q.error).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; } // A live page asks for one part again (oxygenhub#205): rendered by the // same route, so the same access, and cut from the whole. if let Some(name) = q.fragment.as_deref() { let html = page.0.render().map_err(|e| Error::bad(page.0.shell.clone(), e.to_string()))?; return Ok(match super::fragment(&html, name) { Some(part) => ([(axum::http::header::CACHE_CONTROL, "no-store")], axum::response::Html(part)).into_response(), None => (axum::http::StatusCode::NOT_FOUND, "no such fragment").into_response(), }); } Ok(page.into_response()) } // ---------------------------------------------------------------- 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 { 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?; super::writable(&row, &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()))?; if f.body.trim().is_empty() { return Ok(back(&owner, &name, number, "conversation", None, Some("An empty comment says nothing.".into()))); } 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))?; app.bus.live(&crate::events::topic::proposal(row.id, number), "comment"); Ok(back(&owner, &name, number, tab, None, None)) } #[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 { 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?; super::writable(&row, &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()))?; 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 Ok(back(&owner, &name, number, "conversation", None, Some("You cannot approve your own proposal.".into()))); } 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(); reviewed(&app, &row, &p, &me.account, &f.verdict).await; 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 }; Ok(back(&owner, &name, number, "conversation", notice, None)) } #[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 { 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(); Ok(back(&owner, &name, number, "conversation", Some("Saved.".into()), None)) } #[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 { 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; Ok(back(&owner, &name, number, "conversation", None, None)) } pub async fn resolve_comment( State(app): State, viewer: Viewer, Path((owner, name, number, id)): Path<(String, String, i64, i64)>, Form(f): Form, ) -> Result { 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 = $1 AND proposal_id = $2") .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))?; } Ok(back(&owner, &name, number, "files", None, None)) } /// 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). #[derive(serde::Deserialize)] pub struct LandForm { #[serde(rename = "_csrf")] csrf: String, /// Why it lands over its required checks (#261), for maintainers. #[serde(default)] over_checks: Option, } pub async fn land( State(app): State, viewer: Viewer, Path((owner, name, number)): Path<(String, String, i64)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf, &shell)?; let need = if f.over_checks.is_some() { Access::Maintain } else { Access::Write }; let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, need).await?; match land_as(&app, &repo, &row, &me.account, number, "web", false, f.over_checks.as_deref()).await { Ok(_) => Ok(back(&owner, &name, number, "conversation", Some("Landed.".into()), None)), Err(Landing::NotFound) => Err(Error::not_found(shell)), Err(Landing::Refused(m)) => Ok(back(&owner, &name, number, "conversation", None, Some(m))), } } #[derive(serde::Deserialize)] pub struct AutoLandForm { #[serde(rename = "_csrf")] csrf: String, /// `1` to set it, else off. #[serde(default)] on: String, } /// `POST …/proposal/{n}/auto-land` (#261): land when its checks pass, set /// or cleared by someone who may land it. pub async fn auto_land( State(app): State, viewer: Viewer, Path((owner, name, number)): Path<(String, String, i64)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf, &shell)?; let (_, 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))? .filter(|p| p.is_open()) .ok_or_else(|| Error::not_found(shell.clone()))?; let on = f.on == "1"; crate::autoland::set(&app, &row, &p, &me.account, on).await.map_err(|e| Error::from_db(shell.clone(), e))?; let note = if on { "It will land when its checks pass." } else { "It will not land by itself." }; Ok(back(&owner, &name, number, "conversation", Some(note.into()), None)) } /// Why a proposal did not land. pub enum Landing { NotFound, Refused(String), } /// Land proposal `number` as `me` (hub spec §8.4), who has write access: /// refused while it is not open or has blockers. Moves the target bookmark /// (attested by the hub key on `me`'s behalf when they delegated to it), /// then records, announces and audits the landing, saying how it was done /// (`via`: `web`, or `api (token NAME)`). `as_hub` signs with the hub's own /// key rather than `me`'s delegation (Diffusion's auto-land). /// `over_checks`, a reason, lands over required checks that have not /// passed (#261): the caller has checked `me` maintains the repository; /// every other blocker still holds. Returns the new state. #[allow(clippy::too_many_arguments)] pub async fn land_as( app: &Shared, repo: &RepoName, row: &db::repos::RepoRow, me: &accounts::Account, number: i64, via: &str, as_hub: bool, over_checks: Option<&str>, ) -> Result { let (owner, name) = (row.owner.clone(), row.name.clone()); let refused = |e: String| Landing::Refused(e); let p = pdb::get(&app.db, row.id, number).await.map_err(|e| refused(e.to_string()))?.ok_or(Landing::NotFound)?; let loaded = load(app, repo, p.clone()).await.map_err(|e| refused(e.to_string()))?; let v = verdict(app, &loaded, row.id).await.map_err(|e| refused(e.to_string()))?; if !p.is_open() { return Err(refused(format!("Cannot land: the proposal is {}", p.state))); } let reason = over_checks.map(str::trim); if reason == Some("") { return Err(refused("Landing over checks needs a reason: say why it can't wait for them".into())); } let blocking: Vec<&String> = v.blockers.iter().filter(|b| !(reason.is_some() && v.checks_blocker.as_ref() == Some(*b))).collect(); if !blocking.is_empty() { let list: Vec<&str> = blocking.iter().map(|b| b.as_str()).collect(); return Err(refused(format!("Cannot land: {}", list.join("; ")))); } // Only when the checks were in the way does the landing say so. let over = reason.filter(|_| v.checks_blocker.is_some()); let via_owned = match over { Some(r) => format!("{via}, over checks: {r}"), None => via.to_string(), }; let via = via_owned.as_str(); 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 signer = if as_hub { super::edit::hub_signer(app) } else { super::edit::signer_for(app, me.id).await }; let result = match signer { 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 } }; let state = result.map_err(|e| refused(e.to_string()))?; pdb::set_landed(&app.db, p.id, &state, &me.username, via).await.map_err(|e| refused(e.to_string()))?; crate::flow::moot_after_landing(app, row, &p).await; let how = if v.approvals.is_empty() { format!("{via}, with no approval") } else { via.to_string() }; pdb::add_event(&app.db, p.id, &me.username, "landed", &how).await.ok(); crate::hooks::emit( app, row, "proposal.landed", &me.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, 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), "proposal.landed", &format!("{owner}/{name}#{number}"), &how).await.ok(); if let (Some(r), Some(checks)) = (over, &v.checks_blocker) { accounts::audit(&app.db, Some(me), "proposal.land_over_checks", &format!("{owner}/{name}#{number}"), &format!("{r} ({checks})")) .await .ok(); } db::repos::record_push( &app.db, row.id, Some((me.id, me.username.as_str())), &[db::repos::PushOutcome { reference: p.target.clone(), ok: true, reason: format!("landed #{number} ({how})"), old_state: loaded.target_state.clone(), new_state: state.clone(), heads: loaded.heads.len() as i64, changes: loaded.hashes.len() as i64, }], ) .await .ok(); // The target moved: its other open proposals are checked again (#134). settle_after_push(app, row, &p.target, &me.username).await; // ...and its pipeline runs, as a push of the same heads would start it // (#197): without the run a consumer following it when green would wait // for a state that never runs. crate::flow::on_push(app, row, &p.target, &state, &loaded.heads, &me.username).await; // A landing on the default bookmark closes the issues its changes (and // its description) say they close, as a push there would. if p.target == row.default_bookmark { let body = (format!("proposal #{number}"), p.body.clone()); crate::web::issues::close_from_arrived(app, row, loaded.target_heads.clone(), loaded.heads.clone(), Some(body)).await; } // Diffusion (#150): a landing moves the target as a push would, so the // pin index follows it, and the consumers of a moved default bookmark // are bumped. let heads = loaded.heads.clone(); let moved = vec![(p.target.clone(), state.clone(), heads.clone())]; crate::diffusion::after_push(app, row, &moved).await; if p.target == row.default_bookmark { let (app, row) = (app.clone(), row.clone()); tokio::spawn(async move { crate::diffusion::propagate(&app, &row, heads, false).await }); } Ok(state) } #[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 { 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))?; } Ok(back(&owner, &name, number, "conversation", None, None)) } /// `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.bus.subscribe(crate::bus::Topic::Proposals); let repo_id = row.id; let stream = tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(move |item| async move { match item { Ok(crate::bus::Event::Proposal { repo, event: ev }) if repo == 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(futures::StreamExt::take_until(stream, crate::shutdown::stopping())) .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(), note: hub_api::Author::note_for(&c.author.key, &c.author.account), }, when_ms: c.when_ms, deps: c.deps.clone(), head: c.head, via: c.via.as_ref().map(|v| hub_api::Via { name: v.name.clone(), key: v.key.clone(), scope: v.scope.clone(), expires_ms: v.expires_ms, revoked: v.revoked, }), 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, landed: (p.state == "landed" && p.landed_ms > 0).then(|| hub_api::Landed { by: p.landed_by.clone(), via: p.landed_via.clone(), when_ms: p.landed_ms, }), auto_land: (p.is_open() && !p.auto_land_by.is_empty()).then(|| p.auto_land_by.clone()), requested_reviewers: pdb::review_requests(&app.db, p.id).await.unwrap_or_default(), labels: db::issues::proposal_labels(&app.db, p.id) .await .unwrap_or_default() .into_iter() .map(|l| hub_api::Label { name: l.name, colour: l.colour, description: l.description }) .collect(), } } /// 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) {} // ------------------------------------------------------- review requests /// Ask accounts to review a proposal (hub plan 0.4): each must exist, be /// someone other than the author, and be able to read the repository. Each /// newly asked is told, and the proposal records it. The usernames newly /// asked, or why none could be. pub async fn request_reviewers( app: &Shared, row: &db::repos::RepoRow, p: &pdb::ProposalRow, by: &accounts::Account, names: &[String], ) -> Result, String> { let mut found = Vec::new(); for name in names.iter().map(|n| n.trim().trim_start_matches('@')).filter(|n| !n.is_empty()) { let Some(a) = accounts::account_by_name(&app.db, name).await.ok().flatten() else { return Err(format!("There is no account {name}.")); }; if Some(a.id) == p.author_id { return Err(format!("{name} wrote this proposal, and cannot review it.")); } let role = crate::auth::roles::role_for(&app.db, Some(&a), row).await.ok().flatten(); if !crate::auth::roles::allows(role, crate::auth::Access::Read) { return Err(format!("{name} cannot read this repository.")); } found.push(a); } let mut asked = Vec::new(); for a in found { if !pdb::request_review(&app.db, p.id, a.id, &by.username).await.map_err(|e| e.to_string())? { continue; } pdb::add_event(&app.db, p.id, &by.username, "review requested", &a.username).await.ok(); crate::notify::deliver( app, crate::notify::Event { kind: "proposal.review_requested", title: format!("{}/{}#{}: {} (review requested from {})", row.owner, row.name, p.number, p.title, a.username), url: format!("/{}/{}/proposal/{}", row.owner, row.name, p.number), actor: by, repo_id: row.id, thread: None, mention_text: "", direct: vec![a.id], }, ) .await; asked.push(a.username); } Ok(asked) } /// After a review: it answers the reviewer's request, if there was one, and /// the proposal's author is told. pub async fn reviewed(app: &Shared, row: &db::repos::RepoRow, p: &pdb::ProposalRow, by: &accounts::Account, verdict: &str) { pdb::unrequest_review(&app.db, p.id, by.id).await.ok(); let what = match verdict { "approve" => "approved", "changes" => "changes requested", _ => "reviewed", }; crate::notify::deliver( app, crate::notify::Event { kind: "proposal.review", title: format!("{}/{}#{}: {} ({what} by {})", row.owner, row.name, p.number, p.title, by.username), url: format!("/{}/{}/proposal/{}", row.owner, row.name, p.number), actor: by, repo_id: row.id, thread: Some(("proposal", p.id)), mention_text: "", direct: p.author_id.into_iter().collect(), }, ) .await; } #[derive(Deserialize)] pub struct ReviewersForm { #[serde(rename = "_csrf")] csrf_token: String, /// `add`, `remove`, or `set` (the menu: exactly these are asked). action: String, #[serde(default)] username: Vec, } /// `POST /{owner}/{name}/proposal/{number}/reviewers`: the author or a /// writer asks someone to review, or withdraws the request. pub async fn reviewers( State(app): State, viewer: Viewer, Path((owner, name, number)): Path<(String, String, i64)>, axum_extra::extract::Form(f): axum_extra::extract::Form, ) -> Result { 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::Write { return Err(Error::not_found(shell)); } let names: Vec = f.username.iter().map(|n| n.trim().trim_start_matches('@').to_string()).filter(|n| !n.is_empty()).collect(); let current = pdb::review_requests(&app.db, p.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; // Withdrawn: those named (remove), or those the menu left out (set). let withdraw: Vec = match f.action.as_str() { "remove" => names.clone(), "set" => current.iter().filter(|c| !names.iter().any(|n| n.eq_ignore_ascii_case(c))).cloned().collect(), _ => Vec::new(), }; for who in &withdraw { if let Some(a) = accounts::account_by_name(&app.db, who).await.ok().flatten() { if pdb::unrequest_review(&app.db, p.id, a.id).await.map_err(|e| Error::from_db(shell.clone(), e))? { pdb::add_event(&app.db, p.id, &me.account.username, "review request withdrawn", &a.username).await.ok(); } } } let ask: Vec = match f.action.as_str() { "add" => names, "set" => names.into_iter().filter(|n| !current.iter().any(|c| c.eq_ignore_ascii_case(n))).collect(), _ => return Ok(back(&owner, &name, number, "conversation", None, None)), }; if ask.is_empty() { return Ok(back(&owner, &name, number, "conversation", None, None)); } match request_reviewers(&app, &row, &p, &me.account, &ask).await { Ok(asked) if asked.is_empty() => Ok(back(&owner, &name, number, "conversation", Some("Already asked.".into()), None)), Ok(asked) => Ok(back(&owner, &name, number, "conversation", Some(format!("Asked {} to review.", asked.join(", "))), None)), Err(e) => Ok(back(&owner, &name, number, "conversation", None, Some(e))), } } #[derive(Deserialize)] pub struct AssistantForm { #[serde(rename = "_csrf")] csrf_token: String, assistant: String, /// `summary` asks for a summary comment (oxygenhub#190); otherwise a review. #[serde(default)] task: String, } /// `POST /{owner}/{name}/proposal/{number}/assistant`: the author or a /// writer asks an AI assistant to review (hub spec §13.4). The answer /// comes later as a review via the assistant. pub async fn assistant( State(app): State, viewer: Viewer, Path((owner, name, number)): Path<(String, String, i64)>, Form(f): Form, ) -> Result { 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?; super::writable(&row, &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()))?; if Some(me.account.id) != p.author_id && role < Role::Write { return Err(Error::not_found(shell)); } if !p.is_open() { return Ok(back(&owner, &name, number, "conversation", None, Some("The proposal is not open.".into()))); } let Some(a) = crate::assistants::enabled_for_repo(&app.db, &row) .await .map_err(|e| Error::from_db(shell.clone(), e))? .into_iter() .find(|a| a.name.eq_ignore_ascii_case(f.assistant.trim())) else { return Ok(back(&owner, &name, number, "conversation", None, Some(format!("No assistant called {} here.", f.assistant.trim())))); }; let who = a.name.clone(); let summary = f.task == "summary"; let asked = if summary { crate::assistants::request_summary(&app, &row, &p, &me.account, a).await } else { crate::assistants::request_review(&app, &row, &p, &me.account, a).await }; match asked { Ok(()) => Ok(back( &owner, &name, number, "conversation", Some(if summary { format!("Asked {who} for a summary; its comment appears here when it comes.") } else { format!("Asked {who} to review; its answer appears here when it comes.") }), None, )), Err(e) => Ok(back(&owner, &name, number, "conversation", None, Some(format!("Not asked: {e}.")))), } } /// The assistants a repository may use, by name. async fn assistant_names(app: &Shared, row: &db::repos::RepoRow) -> Vec { crate::assistants::enabled_for_repo(&app.db, row).await.unwrap_or_default().into_iter().map(|a| a.name).collect() } /// The changes and the file diffs a proposal from `source` into `target` /// would carry. async fn preview_diff( app: &Shared, repo: &RepoName, target: &str, source: &str, ) -> Result<(Vec, Vec), crate::repos::RepoError> { let Some((fork, bookmark)) = parse_source(app, repo, source) else { return Ok((vec![], vec![])) }; 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.files)) }) .await } /// `POST /{owner}/{name}/proposals/new/draft`: an assistant drafts the /// description from the changes the proposal would add; the form comes /// back with the draft to edit (hub spec §13.4). pub async fn new_draft( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result, Error> { 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?; super::writable(&row, &shell)?; let bookmarks = app.repos.read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::>())).await.unwrap_or_default(); let (preview, conflicted) = preview_for(&app, &repo, &f.target, &f.source).await.unwrap_or((vec![], false)); let assistants = if preview.is_empty() { vec![] } else { assistant_names(&app, &row).await }; let mut page = NewProposalPage { shell, repo: RepoShell::new(&app, &row, "proposals").with_role(role), bookmarks, source: f.source.clone(), target: f.target.clone(), error: None, preview, conflicted, title: f.title.clone(), body: f.body.clone(), assistants, }; let assistant = crate::assistants::enabled_for_repo(&app.db, &row) .await .unwrap_or_default() .into_iter() .find(|a| a.name.eq_ignore_ascii_case(f.assistant.trim())); let Some(a) = assistant else { page.error = Some(format!("No assistant called {} here.", f.assistant.trim())); return Ok(Page(page)); }; if page.preview.is_empty() { page.error = Some("Nothing to describe: the source adds nothing to the target.".into()); return Ok(Page(page)); } let (changes, diffs) = match preview_diff(&app, &repo, &f.target, &f.source).await { Ok(x) => x, Err(e) => { page.error = Some(e.to_string()); return Ok(Page(page)); } }; let d = crate::assistants::Draft { slot: -row.id, title: &f.title, target: &f.target, changes: &changes, diffs: &diffs, with_title: true }; match crate::assistants::draft_description(&app, &row, &me.account, &a, d).await { Ok(text) => { let (title, body) = crate::assistants::split_title(&text); page.body = body; // The title too, unless the person wrote one (#316): the page's // own suggestion, a single change's title, counts as theirs not. let suggested = if page.preview.len() == 1 { page.preview[0].title.clone() } else { String::new() }; if let Some(t) = title.filter(|_| page.title.trim().is_empty() || page.title.trim() == suggested.trim()) { page.title = t; } } Err(why) => page.error = Some(format!("{} did not answer: {why}.", a.name)), } Ok(Page(page)) } #[derive(Deserialize)] pub struct DraftForm { #[serde(rename = "_csrf")] csrf_token: String, assistant: String, #[serde(default)] title: String, } /// `POST /{owner}/{name}/proposal/{number}/draft`: an assistant drafts the /// description of an open proposal from its changes; the editor opens /// with the draft, nothing is saved (hub spec §13.4). pub async fn draft( State(app): State, viewer: Viewer, Path((owner, name, number)): Path<(String, String, i64)>, Form(f): Form, ) -> Result { 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, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; super::writable(&row, &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()))?; if Some(me.account.id) != p.author_id && role < Role::Maintain { return Err(Error::not_found(shell)); } let Some(a) = crate::assistants::enabled_for_repo(&app.db, &row) .await .map_err(|e| Error::from_db(shell.clone(), e))? .into_iter() .find(|a| a.name.eq_ignore_ascii_case(f.assistant.trim())) else { return Ok(back(&owner, &name, number, "conversation", None, Some(format!("No assistant called {} here.", f.assistant.trim())))); }; let loaded = crate::web::proposals::load(&app, &repo, p.clone()).await.map_err(|e| Error::from_repo(shell.clone(), e))?; let title = if f.title.trim().is_empty() { p.title.clone() } else { f.title.clone() }; let d = crate::assistants::Draft { slot: p.id, title: &title, target: &p.target, changes: &loaded.changes, diffs: &loaded.diffs, with_title: false, }; let draft = crate::assistants::draft_description(&app, &row, &me.account, &a, d).await; match draft { Ok(text) => { let mut page = detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), None, None).await?; page.0.draft = Some(text); Ok(page.into_response()) } Err(why) => Ok(back(&owner, &name, number, "conversation", None, Some(format!("{} did not answer: {why}.", a.name)))), } } /// A draft being written, as server-sent events for the page: `part` with /// each piece of text, then `done` with the whole draft or `error` with /// why there is none (each a JSON string). The response starts at once, so /// a draft that takes minutes is not cut off by a proxy waiting for it /// (oxygenhub#248). type DraftEvents = axum::response::Sse< axum::response::sse::KeepAliveStream< tokio_stream::wrappers::ReceiverStream>, >, >; fn draft_events(rx: tokio::sync::mpsc::Receiver>) -> DraftEvents { axum::response::Sse::new(tokio_stream::wrappers::ReceiverStream::new(rx)) .keep_alive(axum::response::sse::KeepAlive::new().interval(std::time::Duration::from_secs(15))) } fn draft_event(kind: &str, text: &str) -> Result { Ok(axum::response::sse::Event::default().event(kind).data(serde_json::to_string(text).unwrap_or_default())) } /// Only an error, for a draft that cannot start. fn draft_refused(why: String) -> DraftEvents { let (tx, rx) = tokio::sync::mpsc::channel(1); let _ = tx.try_send(draft_event("error", &why)); draft_events(rx) } /// What a draft is written from, owned so a task of its own can have it. struct DraftJob { row: db::repos::RepoRow, by: accounts::Account, a: crate::assistants::Assistant, slot: i64, title: String, target: String, changes: Vec, diffs: Vec, /// Not yet opened: the draft proposes a title too (#316). with_title: bool, } /// Write the draft in a task of its own, its parts to the page. fn draft_in_background(app: Shared, job: DraftJob) -> DraftEvents { let (tx, rx) = tokio::sync::mpsc::channel(64); tokio::spawn(async move { let DraftJob { row, by, a, slot, title, target, changes, diffs, with_title } = job; let d = crate::assistants::Draft { slot, title: &title, target: &target, changes: &changes, diffs: &diffs, with_title }; let parts = tx.clone(); let result = crate::assistants::draft_description_streaming(&app, &row, &by, &a, d, move |p: &str| { // A page that went away stops nothing: the draft finishes. let _ = parts.try_send(draft_event("part", p)); }) .await; let last = match result { Ok(text) if with_title => { // The title first, for the page to put in its field (#316). let (t, body) = crate::assistants::split_title(&text); if let Some(t) = t { let _ = tx.send(draft_event("title", &t)).await; } draft_event("done", &body) } Ok(text) => draft_event("done", text.trim()), Err(why) => draft_event("error", &format!("{} did not answer: {why}.", a.name)), }; let _ = tx.send(last).await; }); draft_events(rx) } /// `POST /{owner}/{name}/proposal/{number}/draft/stream`: [`draft`], with /// the draft streamed to the page as it is written. pub async fn draft_stream( State(app): State, viewer: Viewer, Path((owner, name, number)): Path<(String, String, i64)>, Form(f): Form, ) -> Result { 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, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; super::writable(&row, &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()))?; if Some(me.account.id) != p.author_id && role < Role::Maintain { return Err(Error::not_found(shell)); } let Some(a) = crate::assistants::enabled_for_repo(&app.db, &row) .await .map_err(|e| Error::from_db(shell.clone(), e))? .into_iter() .find(|a| a.name.eq_ignore_ascii_case(f.assistant.trim())) else { return Ok(draft_refused(format!("No assistant called {} here.", f.assistant.trim()))); }; let loaded = load(&app, &repo, p.clone()).await.map_err(|e| Error::from_repo(shell.clone(), e))?; let title = if f.title.trim().is_empty() { p.title.clone() } else { f.title.clone() }; let job = DraftJob { row, by: me.account.clone(), a, slot: p.id, title, target: p.target.clone(), changes: loaded.changes, diffs: loaded.diffs, with_title: false, }; Ok(draft_in_background(app.clone(), job)) } /// `POST /{owner}/{name}/proposals/new/draft/stream`: [`new_draft`], with /// the draft streamed to the page as it is written. pub async fn new_draft_stream( 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?; super::writable(&row, &shell)?; let Some(a) = crate::assistants::enabled_for_repo(&app.db, &row) .await .unwrap_or_default() .into_iter() .find(|a| a.name.eq_ignore_ascii_case(f.assistant.trim())) else { return Ok(draft_refused(format!("No assistant called {} here.", f.assistant.trim()))); }; let (changes, diffs) = match preview_diff(&app, &repo, &f.target, &f.source).await { Ok(x) => x, Err(e) => return Ok(draft_refused(e.to_string())), }; if changes.is_empty() { return Ok(draft_refused("Nothing to describe: the source adds nothing to the target.".into())); } let job = DraftJob { slot: -row.id, row, by: me.account.clone(), a, title: f.title.clone(), target: f.target.clone(), changes, diffs, with_title: true, }; Ok(draft_in_background(app.clone(), job)) }