OXYGENHUB

Coverage / crates/hub/src/web/proposals.rs 81.2%

1388 of 1709 lines run, 169 of 526 functions · run #488 on proposal/340 · run missed

1
//! Proposal pages (hub spec §7): list, new, detail with conversation,
2
//! changes and files, comments anchored to atoms, reviews, landing.
3
4
use super::history::{render_files, FileRows};
5
use super::settings::{csrf, require};
6
use super::{filters, lookup, lookup_role, Error, Page, RepoShell, Shell};
7
use crate::auth::{Access, Role, Viewer};
8
use crate::db::{self, accounts, proposals as pdb};
9
use crate::render::{self, diff};
10
use crate::repos::proposals::{self as pl, Projection, Source};
11
use crate::repos::view::{self, ChangeView, ConflictView};
12
use crate::repos::RepoName;
13
use crate::Shared;
14
use askama::Template;
15
use axum::extract::{Form, Path, Query, State};
16
use axum::response::{IntoResponse, Redirect, Response};
17
use hub_api::{Approval, ProposalSource, ProposalState};
18
use oxy_core::hash::ChangeHash;
19
use serde::Deserialize;
20
use std::collections::{BTreeMap, HashMap};
21
22
// ------------------------------------------------------------------ list
23
24
#[derive(Template)]
25
#[template(path = "proposals.html")]
26
pub struct ProposalsPage {
27
    shell: Shell,
28
    repo: RepoShell,
29
    rows: Vec<pdb::ProposalRow>,
30
    filter: String,
31
    counts: (usize, usize, usize),
32
    bookmarks: Vec<String>,
33
    q: String,
34
    sort: String,
35
    pager: super::Pager,
36
    labels: std::collections::HashMap<i64, Vec<db::issues::Label>>,
37
}
38
39
impl ProposalsPage {
405×
    fn labels_of(&self, id: &i64) -> &[db::issues::Label] {
415×
        self.labels.get(id).map(Vec::as_slice).unwrap_or(&[])
425×
    }
43
}
44
45
#[derive(Deserialize)]
46
pub struct ListQuery {
47
    state: Option<String>,
48
    q: Option<String>,
49
    sort: Option<String>,
50
    page: Option<usize>,
51
}
52
53
/// The list's sort orders.
54
pub const SORTS: &[(&str, &str)] = &[("updated", "recently updated"), ("newest", "newest"), ("oldest", "oldest")];
55
565×
pub async fn list(
575×
    State(app): State<Shared>,
585×
    viewer: Viewer,
595×
    Path((owner, name)): Path<(String, String)>,
605×
    Query(q): Query<ListQuery>,
615×
) -> Result<Page<ProposalsPage>, Error> {
625×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposals"), &viewer);
635×
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
645×
    let all = pdb::list(&app.db, row.id, &[]).await.map_err(|e| Error::from_db(shell.clone(), e))?;
655×
    let counts = (
6611×
        all.iter().filter(|p| p.is_open()).count(),
6711×
        all.iter().filter(|p| p.state == "landed").count(),
6811×
        all.iter().filter(|p| p.state == "closed").count(),
69
    );
705×
    let filter = q.state.unwrap_or_else(|| "open".into());
715×
    let mut labels: std::collections::HashMap<i64, Vec<db::issues::Label>> = std::collections::HashMap::new();
725×
    for (id, l) in db::issues::labels_by_proposal(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))? {
733×
        labels.entry(id).or_default().push(l);
743×
    }
75
    // `author:bob label:ui target:main words…`
765×
    let qtext = q.q.clone().unwrap_or_default();
775×
    let (mut authors, mut wanted, mut targets, mut words) = (vec![], vec![], vec![], vec![]);
785×
    for tok in qtext.split_whitespace() {
793×
        match tok.split_once(':') {
803×
            Some(("author", v)) => authors.push(v.to_lowercase()),
812×
            Some(("label", v)) => wanted.push(v.to_lowercase()),
821×
            Some(("target", v)) => targets.push(v.to_string()),
830×
            _ => words.push(tok.to_lowercase()),
84
        }
85
    }
865×
    let mut rows: Vec<pdb::ProposalRow> = all
875×
        .into_iter()
8811×
        .filter(|p| match filter.as_str() {
8911×
            "open" => p.is_open(),
901×
            "landed" => p.state == "landed",
910×
            "closed" => p.state == "closed",
920×
            _ => true,
9311×
        })
9411×
        .filter(|p| authors.is_empty() || authors.contains(&p.author.to_lowercase()))
959×
        .filter(|p| targets.is_empty() || targets.contains(&p.target))
967×
        .filter(|p| wanted.iter().all(|w| labels.get(&p.id).is_some_and(|ls| ls.iter().any(|l| l.name.to_lowercase() == *w))))
975×
        .filter(|p| words.iter().all(|w| p.title.to_lowercase().contains(w) || p.body.to_lowercase().contains(w)))
985×
        .collect();
995×
    let sort = q.sort.filter(|s| SORTS.iter().any(|(k, _)| k == s)).unwrap_or_else(|| "updated".into());
1005×
    match sort.as_str() {
1015×
        "newest" => rows.sort_by_key(|p| std::cmp::Reverse(p.number)),
1025×
        "oldest" => rows.sort_by_key(|p| p.number),
1035×
        _ => rows.sort_by_key(|p| std::cmp::Reverse(p.updated_ms)),
104
    }
1055×
    let pager = super::Pager::new(rows.len(), q.page, &[("state", &filter), ("q", &qtext), ("sort", &sort)]);
1065×
    let rows = pager.slice(rows);
1075×
    let bookmarks = app
1085×
        .repos
1095×
        .read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::<Vec<_>>()))
1105×
        .await
1115×
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
1125×
    Ok(Page(ProposalsPage {
1135×
        shell,
1145×
        repo: RepoShell::new(&app, &row, "proposals").with_role(role),
1155×
        rows,
1165×
        filter,
1175×
        counts,
1185×
        bookmarks,
1195×
        q: qtext,
1205×
        sort,
1215×
        pager,
1225×
        labels,
1235×
    }))
1245×
}
125
126
// ------------------------------------------------------------------- new
127
128
#[derive(Template)]
129
#[template(path = "proposal_new.html")]
130
pub struct NewProposalPage {
131
    shell: Shell,
132
    repo: RepoShell,
133
    bookmarks: Vec<String>,
134
    source: String,
135
    target: String,
136
    error: Option<String>,
137
    /// Preview: the changes the proposal would add.
138
    preview: Vec<ChangeView>,
139
    conflicted: bool,
140
    /// What the form holds so far (after a failed open, or a draft).
141
    title: String,
142
    body: String,
143
    /// AI assistants that may draft the description (hub spec §13.4).
144
    assistants: Vec<String>,
145
}
146
147
#[derive(Deserialize)]
148
pub struct NewQuery {
149
    source: Option<String>,
150
    target: Option<String>,
151
}
152
153
/// `owner/name:bookmark` for a fork, else a bookmark of this repository.
154
/// A proposal's source: a bookmark of this repository by name (which may
155
/// hold `/`, as `feature/16-hud` does), or `owner/name:bookmark` of a fork;
156
/// `owner/name:bookmark` naming this repository is its own bookmark.
15781×
fn parse_source(app: &Shared, repo: &RepoName, source: &str) -> Option<(Option<RepoName>, String)> {
158512×
    let plain = |b: &str| !b.is_empty() && !b.chars().any(|c| c.is_control() || c == ':');
15981×
    if let Some((slug, bookmark)) = source.split_once(':') {
1608×
        let fork = RepoName::parse(slug)?;
1618×
        if !plain(bookmark) {
1620×
            return None;
1638×
        }
1648×
        if fork == *repo {
1652×
            return Some((None, bookmark.to_string()));
1666×
        }
1676×
        if !app.repos.exists(&fork) {
1680×
            return None;
1696×
        }
1706×
        return Some((Some(fork), bookmark.to_string()));
17173×
    }
17273×
    plain(source).then(|| (None, source.to_string()))
17381×
}
174
17547×
async fn preview_for(
17647×
    app: &Shared,
17747×
    repo: &RepoName,
17847×
    target: &str,
17947×
    source: &str,
18047×
) -> Result<(Vec<ChangeView>, bool), crate::repos::RepoError> {
18147×
    let Some((fork, bookmark)) = parse_source(app, repo, source) else { return Ok((vec![], false)) };
18247×
    let target = target.to_string();
18347×
    let fork_dir = fork.as_ref().map(|f| app.repos.dir(f));
18447×
    app.repos
18547×
        .read(repo, move |r, cache| {
18647×
            let projection = match &fork_dir {
1874×
                Some(dir) => {
1884×
                    let fork_repo = oxy_core::repo::Repo::open(dir)?;
1894×
                    let heads = fork_repo.bookmarks()?.into_iter().find(|b| b.name == bookmark).map(|b| b.heads).unwrap_or_default();
1904×
                    pl::project(r, cache, &target, Source::Fork { fork: &fork_repo, heads })?
191
                }
192
                None => {
19374×
                    let heads = r.bookmarks()?.into_iter().find(|b| b.name == bookmark).map(|b| b.heads).unwrap_or_default();
19443×
                    pl::project(r, cache, &target, Source::Bookmark(heads))?
195
                }
196
            };
19747×
            Ok((projection.changes, projection.conflicted))
19847×
        })
19947×
        .await
20047×
}
201
20214×
pub async fn new_form(
20314×
    State(app): State<Shared>,
20414×
    viewer: Viewer,
20514×
    Path((owner, name)): Path<(String, String)>,
20614×
    Query(q): Query<NewQuery>,
20714×
) -> Result<Page<NewProposalPage>, Error> {
20814×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposals/new"), &viewer);
20914×
    require(&viewer, &shell)?;
21014×
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
21114×
    let bookmarks = app
21214×
        .repos
21314×
        .read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::<Vec<_>>()))
21414×
        .await
21514×
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
21614×
    let target = q.target.unwrap_or_else(|| row.default_bookmark.clone());
21714×
    let source = q.source.unwrap_or_default();
21814×
    let (preview, conflicted) =
21914×
        if source.is_empty() { (vec![], false) } else { preview_for(&app, &repo, &target, &source).await.unwrap_or((vec![], false)) };
22014×
    let assistants = if preview.is_empty() { vec![] } else { assistant_names(&app, &row).await };
22114×
    Ok(Page(NewProposalPage {
22214×
        shell,
22314×
        repo: RepoShell::new(&app, &row, "proposals").with_role(role),
22414×
        bookmarks,
22514×
        source,
22614×
        target,
22714×
        error: None,
22814×
        preview,
22914×
        conflicted,
23014×
        title: String::new(),
23114×
        body: String::new(),
23214×
        assistants,
23314×
    }))
23414×
}
235
236
#[derive(Deserialize)]
237
pub struct NewForm {
238
    #[serde(rename = "_csrf")]
239
    csrf_token: String,
240
    title: String,
241
    #[serde(default)]
242
    body: String,
243
    target: String,
244
    source: String,
245
    #[serde(default)]
246
    draft: Option<String>,
247
    /// For `/proposals/new/draft`: which assistant writes the description.
248
    #[serde(default)]
249
    assistant: String,
250
}
251
252
/// Open a proposal from a form or the API: validate, create, index, notify.
253
#[allow(clippy::too_many_arguments)]
25432×
pub async fn open_proposal(
25532×
    app: &Shared,
25632×
    repo: &RepoName,
25732×
    row: &db::repos::RepoRow,
25832×
    me: &accounts::Account,
25932×
    title: &str,
26032×
    body: &str,
26132×
    target: &str,
26232×
    source: &str,
26332×
    draft: bool,
26432×
) -> Result<pdb::ProposalRow, String> {
26532×
    if title.trim().is_empty() {
2660×
        return Err("Give the proposal a title.".into());
26732×
    }
26832×
    let bookmarks =
26932×
        app.repos.read(repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::<Vec<_>>())).await.map_err(|e| e.to_string())?;
27083×
    if !bookmarks.iter().any(|b| b == target) {
2710×
        return Err("Pick a target bookmark of this repository.".into());
27232×
    }
27332×
    let Some((fork, bookmark)) = parse_source(app, repo, source.trim()) else {
2740×
        return Err("The source is a bookmark of this repository, or owner/name:bookmark of a fork.".into());
275
    };
27632×
    if fork.is_none() && !bookmarks.contains(&bookmark) {
2771×
        return Err(format!("There is no bookmark {bookmark} in this repository."));
27831×
    }
27931×
    if fork.is_none() && bookmark == target {
2800×
        return Err("The source and the target are the same bookmark.".into());
28131×
    }
28231×
    let (preview, _) = preview_for(app, repo, target, source.trim()).await.map_err(|e| e.to_string())?;
28331×
    if preview.is_empty() {
2840×
        return Err("The source adds nothing to the target.".into());
28531×
    }
28631×
    let source_repo_id = match &fork {
2872×
        Some(fk) => db::repos::get(&app.db, &fk.owner, &fk.name).await.map_err(|e| e.to_string())?.map(|r| r.id),
28829×
        None => None,
289
    };
29031×
    let created = pdb::create(
29131×
        &app.db,
292
        pdb::NewProposal {
29331×
            repo_id: row.id,
29431×
            title: title.trim(),
29531×
            body: body.trim(),
29631×
            author_id: Some(me.id),
29731×
            author: &me.username,
29831×
            draft,
29931×
            target,
30031×
            source_kind: if fork.is_some() { "fork" } else { "bookmark" },
30131×
            source_repo_id,
30231×
            source_bookmark: &bookmark,
30331×
            source_changes: "",
304
        },
305
    )
30631×
    .await
30731×
    .map_err(|e| e.to_string())?;
30831×
    pdb::add_event(&app.db, created.id, &me.username, "opened", "").await.ok();
30931×
    accounts::audit(&app.db, Some(me), "proposal.opened", &format!("{}#{}", row.slug(), created.number), title).await.ok();
31031×
    db::notify::index(&app.db, "proposal", row.id, created.id, created.number, &created.title, &created.body).await.ok();
31131×
    let url = format!("/{}/proposal/{}", row.slug(), created.number);
31231×
    crate::notify::deliver(
31331×
        app,
31431×
        crate::notify::Event {
31531×
            kind: "proposal.opened",
31631×
            title: format!("{}#{}: {}", row.slug(), created.number, created.title),
31731×
            url,
31831×
            actor: me,
31931×
            repo_id: row.id,
32031×
            thread: Some(("proposal", created.id)),
32131×
            mention_text: body,
32231×
            direct: vec![],
32331×
        },
32431×
    )
32531×
    .await;
32631×
    crate::hooks::emit(
32731×
        app,
32831×
        row,
32931×
        "proposal.opened",
33031×
        &me.username,
33131×
        serde_json::json!({ "number": created.number, "title": created.title, "target": target, "source": source }),
33231×
    )
33331×
    .await;
33431×
    Ok(created)
33532×
}
336
33712×
pub async fn create(
33812×
    State(app): State<Shared>,
33912×
    viewer: Viewer,
34012×
    Path((owner, name)): Path<(String, String)>,
34112×
    Form(f): Form<NewForm>,
34212×
) -> Result<Response, Error> {
34312×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposals/new"), &viewer);
34412×
    let me = require(&viewer, &shell)?;
34512×
    csrf(&viewer, &f.csrf_token, &shell)?;
34612×
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
34712×
    super::writable(&row, &shell)?;
34812×
    match open_proposal(&app, &repo, &row, &me.account, &f.title, &f.body, &f.target, &f.source, f.draft.is_some()).await {
34912×
        Ok(created) => Ok(Redirect::to(&format!("/{owner}/{name}/proposal/{}", created.number)).into_response()),
3500×
        Err(msg) => {
3510×
            let bookmarks =
3520×
                app.repos.read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::<Vec<_>>())).await.unwrap_or_default();
3530×
            Ok(Page(NewProposalPage {
3540×
                shell,
3550×
                repo: RepoShell::new(&app, &row, "proposals").with_role(role),
3560×
                bookmarks,
3570×
                source: f.source.clone(),
3580×
                target: f.target.clone(),
3590×
                error: Some(msg),
3600×
                preview: vec![],
3610×
                conflicted: false,
3620×
                title: f.title.clone(),
3630×
                body: f.body.clone(),
3640×
                assistants: vec![],
3650×
            })
3660×
            .into_response())
367
        }
368
    }
36912×
}
370
371
// ---------------------------------------------------------------- detail
372
373
/// A comment placed on a diff row, or general.
374
pub struct CommentView {
375
    pub row: pdb::CommentRow,
376
    pub body_html: String,
377
    pub line: Option<usize>,
378
}
379
380
/// One entry of a proposal's conversation, in time order: what kind it
381
/// is and its index in `comments`, `reviews` or `events`.
382
pub struct Entry {
383
    pub kind: &'static str,
384
    pub index: usize,
385
}
386
387
pub struct ReviewView {
388
    pub row: pdb::ReviewRow,
389
    pub body_html: String,
390
    pub sealed: bool,
391
}
392
393
/// Everything the detail page and the API need, computed once under the lock.
394
pub struct Loaded {
395
    pub row: pdb::ProposalRow,
396
    pub changes: Vec<ChangeView>,
397
    pub hashes: Vec<ChangeHash>,
398
    pub heads: Vec<ChangeHash>,
399
    pub target_heads: Vec<ChangeHash>,
400
    pub state: String,
401
    pub target_state: String,
402
    pub conflicted: bool,
403
    pub conflicts: Vec<ConflictView>,
404
    /// When every conflict is an adjacent insertion in a `merge=union`
405
    /// file (oxygen#145): each such file's text with both sides kept, which
406
    /// landing commits as the resolution.
407
    pub union: Option<Vec<(String, Vec<u8>)>>,
408
    pub files: Vec<FileRows>,
409
    /// The per-file hunks and bases behind `files`, to render again with other options.
410
    pub diffs: Vec<oxy_core::repo::FileDiff>,
411
    pub bases: std::collections::BTreeMap<String, Vec<u8>>,
412
    /// Atom per (path, new line number) of the projected state.
413
    pub atoms: HashMap<(String, usize), (String, u32)>,
414
    pub sealing_keys: Vec<String>,
415
    pub touched: Vec<String>,
416
    pub owners: Vec<String>,
417
    pub landed_already: bool,
418
    /// Whether its source has any changes at all (#141): an open proposal
419
    /// whose source has changes, none of them missing from the target, was
420
    /// landed by some other route.
421
    pub source_has_changes: bool,
422
}
423
424
/// Compute the projection and everything derived from it.
425106×
pub async fn load(app: &Shared, repo: &RepoName, row: pdb::ProposalRow) -> Result<Loaded, crate::repos::RepoError> {
426106×
    load_with(app, repo, row, true).await
427106×
}
428
429
/// [`load`] without the rendered diff and the line-to-atom map, which only
430
/// the proposal page needs: for lists, the API, landing and the checks that
431
/// follow a push. On a repository with large files those two were most of
432
/// a list's time (metalgear2's took 3-4 s per call).
43356×
pub async fn load_summary(app: &Shared, repo: &RepoName, row: pdb::ProposalRow) -> Result<Loaded, crate::repos::RepoError> {
43456×
    load_with(app, repo, row, false).await
43555×
}
436
437
/// What the API says of a proposal ([`to_api`]): an open one's projection
438
/// now, as [`load_summary`]; a landed or closed one's as recorded, from its
439
/// row, without projecting it again (#331). A list of a few hundred landed
440
/// proposals projected each one and took minutes.
44130×
pub async fn load_for_api(app: &Shared, repo: &RepoName, row: pdb::ProposalRow) -> Result<Loaded, crate::repos::RepoError> {
44230×
    if row.is_open() {
44319×
        return load_summary(app, repo, row).await;
44411×
    }
44511×
    let hashes: Vec<ChangeHash> = row.projected_changes().iter().filter_map(|h| ChangeHash::from_hex(h)).collect();
44612×
    let heads: Vec<ChangeHash> = row.projected_heads().iter().filter_map(|h| ChangeHash::from_hex(h)).collect();
44711×
    let h2 = hashes.clone();
44811×
    let (changes, sealing_keys) = app
44911×
        .repos
45011×
        .read(repo, move |r, _| {
45111×
            let labels = HashMap::new();
45211×
            let changes: Vec<ChangeView> =
45311×
                h2.iter().filter_map(|h| Some(view::change_view_pub(r, h, &r.store().get_change_meta(h).ok()?, false, &labels))).collect();
45411×
            let sealing = pl::sealing_keys(r, &h2)?.into_iter().map(|k| k.to_hex()).collect();
45511×
            Ok((changes, sealing))
45611×
        })
45711×
        .await?;
45811×
    Ok(Loaded {
45911×
        changes,
46011×
        heads,
46111×
        target_heads: Vec::new(),
46211×
        state: row.projected_state.clone(),
46311×
        target_state: row.projected_target.clone(),
46411×
        conflicted: row.conflicted,
46511×
        conflicts: Vec::new(),
46611×
        union: None,
46711×
        files: Vec::new(),
46811×
        diffs: Vec::new(),
46911×
        bases: BTreeMap::new(),
47011×
        atoms: HashMap::new(),
47111×
        sealing_keys,
47211×
        touched: Vec::new(),
47311×
        owners: Vec::new(),
47411×
        landed_already: row.state == "landed",
47511×
        source_has_changes: !hashes.is_empty(),
47611×
        hashes,
47711×
        row,
47811×
    })
47930×
}
480
481162×
async fn load_with(app: &Shared, repo: &RepoName, row: pdb::ProposalRow, render: bool) -> Result<Loaded, crate::repos::RepoError> {
482162×
    let fork_dir = match (row.source_kind.as_str(), row.source_repo_id) {
483162×
        ("fork", Some(id)) => {
4846×
            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))
485
        }
486156×
        _ => None,
487
    };
488162×
    let r2 = row.clone();
489162×
    let loaded = app
490162×
        .repos
491162×
        .read(repo, move |r, cache| {
492162×
            let source = match (r2.source_kind.as_str(), &fork_dir) {
493162×
                ("changes", _) => Source::Changes(r2.source_changes().iter().filter_map(|h| ChangeHash::from_hex(h)).collect()),
494162×
                ("fork", Some(dir)) => {
4956×
                    let fork_repo = oxy_core::repo::Repo::open(dir)?;
4966×
                    let heads =
4976×
                        fork_repo.bookmarks()?.into_iter().find(|b| b.name == r2.source_bookmark).map(|b| b.heads).unwrap_or_default();
498
                    // Bring the fork's changes over now, so the projection below can read them.
4996×
                    let fork_state = fork_repo.load_state(&heads)?;
50016×
                    let missing: Vec<ChangeHash> = fork_state.changes().map(|(h, _)| *h).filter(|h| !r.store().has(h)).collect();
5016×
                    if !missing.is_empty() {
5020×
                        r.import_bundle(&fork_repo.bundle_for(&missing)?, r.current_policy()?.is_some())?;
5036×
                    }
5046×
                    Source::Changes(heads)
505
                }
506
                _ => {
507248×
                    let heads = r.bookmarks()?.into_iter().find(|b| b.name == r2.source_bookmark).map(|b| b.heads).unwrap_or_default();
508156×
                    Source::Bookmark(heads)
509
                }
510
            };
511162×
            let source_has_changes = match &source {
512162×
                Source::Changes(h) | Source::Bookmark(h) | Source::Fork { heads: h, .. } => !h.is_empty(),
513
            };
514162×
            let p: Projection = pl::project(r, cache, &r2.target, source)?;
515162×
            let target_loaded = cache.load(r, &p.target_heads)?;
516161×
            let files = if render { render_files(&p.files, &p.bases) } else { Vec::new() };
517161×
            let mut atoms = HashMap::new();
518161×
            for f in &files {
519
                // Each file's live lines once, not once per line.
520104×
                let Some(live) = pl::atoms_of(&p.loaded, &f.path) else { continue };
521232×
                for l in &f.lines {
522232×
                    if let Some((h, i)) = l.new_no.and_then(|n| live.get((n as usize).checked_sub(1)?)) {
523216×
                        atoms.insert((f.path.clone(), l.new_no.unwrap_or(0) as usize), (h.to_hex(), *i));
524216×
                    }
525
                }
526
            }
527181×
            let touched: Vec<String> = p.files.iter().map(|f| f.path.clone()).collect();
528161×
            let owners = pl::owners_for(&target_loaded, &touched);
529161×
            let sealing = pl::sealing_keys(r, &p.hashes)?.into_iter().map(|k| k.to_hex()).collect();
530161×
            let landed_already = !p.hashes.is_empty() && p.hashes.iter().all(|h| target_loaded.state.contains(h));
531
            // A landed proposal's changes are in its target now: it shows
532
            // the ones it landed, as recorded when it landed.
533161×
            let (changes, hashes) = if r2.state == "landed" && p.hashes.is_empty() {
5347×
                let hashes: Vec<ChangeHash> = r2.projected_changes().iter().filter_map(|h| ChangeHash::from_hex(h)).collect();
5357×
                let labels = HashMap::new();
5367×
                let changes = hashes
5377×
                    .iter()
5387×
                    .filter_map(|h| Some(view::change_view_pub(r, h, &r.store().get_change_meta(h).ok()?, false, &labels)))
5397×
                    .collect();
5407×
                (changes, hashes)
541
            } else {
542154×
                (p.changes, p.hashes)
543
            };
544
            Ok(Loaded {
545161×
                row: r2,
546161×
                changes,
547161×
                hashes,
548161×
                heads: p.heads,
549161×
                target_heads: p.target_heads.clone(),
550161×
                state: p.state.to_hex(),
551161×
                target_state: p.target_state.to_hex(),
552161×
                conflicted: p.conflicted,
553161×
                conflicts: p.conflicts,
554161×
                union: if p.conflicted { oxy_core::repo::Repo::union_resolution(&p.loaded.state, &p.loaded.snapshot) } else { None },
555161×
                files,
556161×
                diffs: p.files.clone(),
557161×
                bases: p.bases.clone(),
558161×
                atoms,
559161×
                sealing_keys: sealing,
560161×
                touched,
561161×
                owners,
562161×
                landed_already,
563161×
                source_has_changes,
564
            })
565162×
        })
566162×
        .await?;
567
    // Landed by another route (#141): `oxy proposal land` from a client (a
568
    // repository with a trust policy needs it), or the work merged and
569
    // pushed by hand. Everything it proposes is in its target, so it is
570
    // landed, not open with nothing to land.
571161×
    let mut loaded = loaded;
572161×
    if loaded.row.is_open()
573153×
        && loaded.source_has_changes
574152×
        && loaded.hashes.is_empty()
5751×
        && pdb::set_landed(&app.db, loaded.row.id, &loaded.target_state, "", "push").await.is_ok()
576
    {
5771×
        loaded.row.state = "landed".into();
5781×
        loaded.row.landed_via = "push".into();
5791×
        loaded.row.landed_state = loaded.target_state.clone();
5801×
        return Ok(loaded);
581160×
    }
582
    // Remember the projection for the list, the API and other proposals'
583
    // stacking checks, while the proposal is open: a landed one keeps what
584
    // it landed.
585160×
    if !loaded.row.is_open() {
5868×
        return Ok(loaded);
587152×
    }
588313×
    let heads_csv = loaded.heads.iter().map(|h| h.to_hex()).collect::<Vec<_>>().join(",");
589153×
    let changes_csv = loaded.hashes.iter().map(|h| h.to_hex()).collect::<Vec<_>>().join(",");
590152×
    pdb::set_projection(&app.db, loaded.row.id, &loaded.state, &heads_csv, &loaded.target_state, &changes_csv, loaded.conflicted)
591152×
        .await
592152×
        .ok();
593152×
    Ok(loaded)
594161×
}
595
596
/// After a push moved `bookmark`: open proposals into it whose work it now
597
/// holds are marked landed, by the pusher (#141).
598259×
pub async fn settle_after_push(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, actor: &str) {
599259×
    let Some(repo) = RepoName::new(&row.owner, &row.name) else { return };
600259×
    let Ok(open) = pdb::list(&app.db, row.id, &["open", "draft"]).await else { return };
601259×
    for p in open.into_iter().filter(|p| p.target == bookmark) {
60211×
        let id = p.id;
60311×
        let number = p.number;
60411×
        if let Ok(l) = load_summary(app, &repo, p).await {
60510×
            if l.row.state == "landed" && l.row.landed_by.is_empty() {
6061×
                pdb::set_landed(&app.db, id, &l.target_state, actor, "push").await.ok();
6071×
                crate::flow::moot_after_landing(app, row, &l.row).await;
6089×
            } else if l.row.is_open() && !l.hashes.is_empty() && crate::flow::enabled(app, row) {
609
                // The target moved, so the proposal's projected state is
610
                // new and its checks would be for an older one (#134): run
611
                // the pipeline on it, once.
6122×
                let ran: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM flow_runs WHERE repo_id = $1 AND state = $2")
6132×
                    .bind(row.id)
6142×
                    .bind(&l.state)
6152×
                    .fetch_one(&app.db)
6162×
                    .await
6172×
                    .unwrap_or(1);
6182×
                if ran == 0 {
6191×
                    let label = format!("proposal/{number}");
6201×
                    match crate::flow::start_run_scoped(app, row, &label, &l.state, &l.heads, actor, "proposal", None).await {
6211×
                        Ok(Some(n)) => tracing::info!("proposal {number}: its target moved; run #{n} checks its new projected state"),
6220×
                        Ok(None) => {}
6230×
                        Err(e) => tracing::warn!("proposal {number}: a run of its new projected state did not start: {e}"),
624
                    }
6251×
                }
6267×
            }
6270×
        }
628
    }
629258×
}
630
631
/// Reviews, approvals and blockers.
632
pub struct Verdict {
633
    pub reviews: Vec<ReviewView>,
634
    pub approvals: Vec<Approval>,
635
    pub blockers: Vec<String>,
636
    /// The required-checks blocker, also in `blockers`: the one a
637
    /// maintainer may land over with a reason (#261).
638
    pub checks_blocker: Option<String>,
639
    pub depends_on: Vec<i64>,
640
    pub requirements: pdb::Requirements,
641
}
642
643144×
pub async fn verdict(app: &Shared, loaded: &Loaded, repo_id: i64) -> Result<Verdict, sqlx::Error> {
644144×
    let rows = pdb::reviews(&app.db, loaded.row.id).await?;
645144×
    let links = accounts::identity_accounts(&app.db).await?;
646144×
    let sealed_accounts: Vec<String> = links.iter().filter(|(k, _)| loaded.sealing_keys.contains(k)).map(|(_, u)| u.clone()).collect();
647
    // Latest verdict per reviewer.
648144×
    let mut latest: BTreeMap<String, &pdb::ReviewRow> = BTreeMap::new();
649144×
    for r in rows.iter().filter(|r| r.via.is_empty()) {
65032×
        // An assistant's answer is never the person's own verdict.
65132×
        latest.insert(r.author.clone(), r);
65232×
    }
653144×
    let mut approvals = Vec::new();
654144×
    for (who, r) in &latest {
65532×
        if r.verdict == "approve" {
65622×
            let sealed = sealed_accounts.contains(who);
65722×
            let key = links.iter().find(|(k, u)| u == who && loaded.sealing_keys.contains(k)).map(|(k, _)| k.clone());
65822×
            approvals.push(Approval { account: who.clone(), sealed, key });
65910×
        }
660
    }
661144×
    let reviews = rows
662144×
        .iter()
663144×
        .map(|r| ReviewView {
66442×
            row: r.clone(),
66542×
            body_html: render::markdown::render(&r.body, "/", "/"),
66642×
            sealed: r.verdict == "approve" && sealed_accounts.contains(&r.author),
66742×
        })
668144×
        .collect();
669144×
    let requirements = pdb::requirements(&app.db, repo_id).await?;
670144×
    let mut blockers = Vec::new();
671
    // Only an open proposal can be held back.
672144×
    if !loaded.row.is_open() {
67319×
        return Ok(Verdict { reviews, approvals, blockers, checks_blocker: None, depends_on: vec![], requirements });
674125×
    }
675125×
    if loaded.row.state == "draft" {
6762×
        blockers.push("the proposal is a draft".into());
677123×
    }
678125×
    if loaded.hashes.is_empty() {
6790×
        blockers.push("the proposal adds no changes".into());
680125×
    }
681125×
    let sealed_count = approvals.iter().filter(|a| a.sealed).count() as i64;
682125×
    if sealed_count < requirements.required_approvals {
6837×
        blockers.push(format!("{sealed_count} of {} required sealed approvals", requirements.required_approvals));
684118×
    }
685125×
    if requirements.require_owners {
68610×
        let missing: Vec<&String> = loaded.owners.iter().filter(|o| !approvals.iter().any(|a| a.sealed && &a.account == *o)).collect();
68710×
        if !missing.is_empty() {
6887×
            blockers.push(format!("code owners still to approve: {}", missing.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")));
6893×
        }
690115×
    }
691125×
    if loaded.conflicted && loaded.union.is_none() && !requirements.allow_conflicts {
6920×
        blockers.push(format!(
6930×
            "landing would carry conflicts, which this repository refuses: resolve them on the source (oxy merge {} there, fix the marked files, oxy commit, push)",
6940×
            loaded.row.target
6950×
        ));
696125×
    }
697125×
    let mut checks_blocker = None;
698125×
    let required = db::hooks::required_checks(&app.db, repo_id).await?;
699125×
    if !required.is_empty() {
70044×
        let checks = db::hooks::checks_for_state(&app.db, repo_id, &loaded.state).await?;
70144×
        let missing: Vec<String> =
70244×
            required.iter().filter(|n| !checks.iter().any(|c| &c.name == *n && c.status == "passed")).cloned().collect();
70344×
        if !missing.is_empty() {
70425×
            let b = format!("required checks not passed on the projected state: {}", missing.join(", "));
70525×
            checks_blocker = Some(b.clone());
70625×
            blockers.push(b);
70725×
        }
70881×
    }
709125×
    let requesting: Vec<&String> = latest.iter().filter(|(_, r)| r.verdict == "changes").map(|(who, _)| who).collect();
710125×
    if !requesting.is_empty() {
7110×
        blockers.push(format!("changes requested by {}", requesting.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")));
712125×
    }
713
    // Stacking: another open, earlier proposal whose changes this one includes.
714125×
    let mut depends_on = Vec::new();
715163×
    for other in pdb::list(&app.db, repo_id, &["open", "draft"]).await? {
716163×
        if other.id == loaded.row.id || other.number > loaded.row.number {
717153×
            continue;
71810×
        }
71910×
        let theirs = other.projected_changes();
72010×
        if !theirs.is_empty() && theirs.iter().all(|h| loaded.hashes.iter().any(|m| m.to_hex() == *h)) {
7211×
            depends_on.push(other.number);
7229×
        }
723
    }
724125×
    if !depends_on.is_empty() {
7251×
        blockers.push(format!("depends on {}", depends_on.iter().map(|n| format!("#{n}")).collect::<Vec<_>>().join(", ")));
726124×
    }
727125×
    Ok(Verdict { reviews, approvals, blockers, checks_blocker, depends_on, requirements })
728144×
}
729
730
#[derive(Template)]
731
#[template(path = "proposal.html")]
732
pub struct ProposalPage {
733
    shell: Shell,
734
    repo: RepoShell,
735
    p: pdb::ProposalRow,
736
    /// Live topics (oxygenhub#205): the proposal's and its repository's
737
    /// (checks arrive on the latter).
738
    live_topic: String,
739
    repo_topic: String,
740
    /// Assistants answering now: shown as pending until they do.
741
    pending: Vec<String>,
742
    /// Issues it closes, from its description and its changes.
743
    closes: Vec<super::chain::Link>,
744
    /// Projects the viewer may add it to.
745
    project_choices: Option<Vec<db::projects::Project>>,
746
    /// Comments (not anchored to a line), reviews and events, oldest first.
747
    timeline: Vec<Entry>,
748
    /// The viewer may set labels (triage or above).
749
    can_triage: bool,
750
    body_html: String,
751
    tab: String,
752
    changes: Vec<ChangeView>,
753
    files: Vec<FileRows>,
754
    diffs: Vec<oxy_core::repo::FileDiff>,
755
    bases: std::collections::BTreeMap<String, Vec<u8>>,
756
    /// Whitespace-only changes are hidden (`?w=1`).
757
    ignore_ws: bool,
758
    conflicts: Vec<ConflictView>,
759
    conflicted: bool,
760
    comments: Vec<CommentView>,
761
    /// Anchored comments by (path, current line).
762
    placed: HashMap<(String, usize), Vec<usize>>,
763
    outdated: Vec<usize>,
764
    reviews: Vec<ReviewView>,
765
    approvals: Vec<Approval>,
766
    blockers: Vec<String>,
767
    depends_on: Vec<i64>,
768
    events: Vec<pdb::EventRow>,
769
    owners: Vec<String>,
770
    requirements: pdb::Requirements,
771
    landed_already: bool,
772
    can_edit: bool,
773
    /// The landed proposal's source bookmark, when it is this repository's,
774
    /// still there, and the viewer may delete it (oxygenhub#86).
775
    deletable_source: Option<String>,
776
    can_land: bool,
777
    can_land_over_checks: bool,
778
    can_auto_land: bool,
779
    hub_can_land: bool,
780
    notice: Option<String>,
781
    error: Option<String>,
782
    heads: Vec<String>,
783
    state_hash: String,
784
    source_label: String,
785
    api_url: String,
786
    labels: Vec<db::issues::Label>,
787
    all_labels: Vec<db::issues::Label>,
788
    checks: Vec<db::hooks::Check>,
789
    required_checks: Vec<String>,
790
    /// Projects this proposal is on that the viewer may see, with the column.
791
    projects: Vec<(db::projects::Project, String)>,
792
    /// Asked to review and not yet reviewing.
793
    requested: Vec<String>,
794
    /// Who can be asked to review, for the sidebar's menu (oxygenhub#84).
795
    reviewable: Vec<String>,
796
    /// The viewer may ask for reviews (the author, or a writer).
797
    can_request: bool,
798
    /// AI assistants the viewer may ask, by name (hub spec §13.4).
799
    assistants: Vec<String>,
800
    /// A description an assistant just drafted, for the editor.
801
    draft: Option<String>,
802
}
803
804
#[derive(Deserialize)]
805
pub struct DetailQuery {
806
    tab: Option<String>,
807
    /// What an action said, after it redirected here.
808
    #[serde(default)]
809
    notice: Option<String>,
810
    #[serde(default)]
811
    error: Option<String>,
812
    #[serde(default)]
813
    w: Option<String>,
814
    /// Only this part of the page (live pages, oxygenhub#205).
815
    #[serde(default)]
816
    fragment: Option<String>,
817
}
818
819
/// The page assembler every action ends in; its arguments are the route
820
/// plus what to say, and a struct would only rename them.
821
#[allow(clippy::too_many_arguments)]
82274×
async fn detail(
82374×
    app: &Shared,
82474×
    shell: Shell,
82574×
    viewer: &Viewer,
82674×
    owner: &str,
82774×
    name: &str,
82874×
    number: i64,
82974×
    tab: String,
83074×
    notice: Option<String>,
83174×
    error: Option<String>,
83274×
) -> Result<Page<ProposalPage>, Error> {
83374×
    let (repo, row, role) = lookup(app, &shell, viewer, owner, name).await?;
83474×
    let p = pdb::get(&app.db, row.id, number)
83574×
        .await
83674×
        .map_err(|e| Error::from_db(shell.clone(), e))?
83774×
        .ok_or_else(|| Error::not_found(shell.clone()))?;
83874×
    let p_id = p.id;
83974×
    let loaded = load(app, &repo, p).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
840
    // A proposal whose changes all reached the target landed by another route.
84174×
    if loaded.landed_already && loaded.row.is_open() {
8420×
        pdb::set_state(&app.db, loaded.row.id, "landed", &loaded.target_state).await.ok();
8430×
        pdb::add_event(&app.db, loaded.row.id, "", "landed", "every change reached the target").await.ok();
8440×
        crate::flow::moot_after_landing(app, &row, &loaded.row).await;
84574×
    }
84674×
    let p = pdb::get(&app.db, row.id, number).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(loaded.row.clone());
84774×
    let v = verdict(app, &loaded, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
84874×
    let comment_rows = pdb::comments(&app.db, loaded.row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
84974×
    let events = pdb::events(&app.db, loaded.row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
850
    // Place anchored comments at their atom's current line.
85174×
    let mut by_atom: HashMap<(String, String, u32), usize> = HashMap::new();
852150×
    for ((path, line), (h, i)) in &loaded.atoms {
853150×
        by_atom.insert((path.clone(), h.clone(), *i), *line);
854150×
    }
85574×
    let mut comments = Vec::new();
85674×
    let mut placed: HashMap<(String, usize), Vec<usize>> = HashMap::new();
85774×
    let mut outdated = Vec::new();
85874×
    for (idx, c) in comment_rows.into_iter().enumerate() {
85936×
        let line = if c.anchor_change.is_empty() {
86017×
            None
861
        } else {
86219×
            by_atom.get(&(c.path.clone(), c.anchor_change.clone(), c.anchor_index as u32)).copied()
863
        };
86436×
        if !c.anchor_change.is_empty() {
86519×
            match line {
86616×
                Some(l) => placed.entry((c.path.clone(), l)).or_default().push(idx),
8673×
                None => outdated.push(idx),
868
            }
86917×
        }
87036×
        comments.push(CommentView { body_html: render::markdown::render(&c.body, "/", "/"), row: c, line });
871
    }
87274×
    let mut changes = loaded.changes.clone();
87374×
    super::attribute(app, &mut changes.iter_mut().collect::<Vec<_>>()).await;
87474×
    let me = viewer.account();
87574×
    let can_edit = me.is_some_and(|a| Some(a.id) == p.author_id || role >= Role::Maintain);
87674×
    let deletable_source =
87774×
        if p.state == "landed" && p.source_kind == "bookmark" && role >= Role::Write && p.source_bookmark != row.default_bookmark {
8786×
            let b = p.source_bookmark.clone();
8796×
            app.repos
8806×
                .read(&repo, move |r, _| Ok(r.view()?.bookmarks.contains_key(&b)))
8816×
                .await
8826×
                .unwrap_or(false)
8836×
                .then(|| p.source_bookmark.clone())
884
        } else {
88568×
            None
886
        };
88774×
    let can_land = role >= Role::Write && p.is_open() && v.blockers.is_empty();
888
    // Not landable yet: anyone who may land can set it to land when its
889
    // checks pass (#261).
89074×
    let can_auto_land = role >= Role::Write && p.is_open() && !v.blockers.is_empty();
891
    // Checks alone in the way: a maintainer may land over them (#261).
89274×
    let can_land_over_checks = role >= Role::Maintain && p.is_open() && v.checks_blocker.is_some() && v.blockers.len() == 1;
89374×
    let can_request = p.is_open() && me.is_some_and(|a| Some(a.id) == p.author_id || role >= Role::Write);
89474×
    let requested = pdb::review_requests(&app.db, p.id).await.unwrap_or_default();
89574×
    let mut reviewable: Vec<String> = if can_request {
89668×
        accounts::assignable(&app.db, row.id, &row.owner).await.unwrap_or_default().into_iter().filter(|u| *u != p.author).collect()
897
    } else {
89810×
        Vec::new()
899
    };
90074×
    for r in &requested {
9011×
        if !reviewable.iter().any(|x| x.eq_ignore_ascii_case(r)) {
9020×
            reviewable.push(r.clone());
9031×
        }
904
    }
90574×
    let assistants: Vec<String> = if (can_request || can_edit) && p.is_open() { assistant_names(app, &row).await } else { Vec::new() };
90674×
    let delegated = match viewer.account() {
90771×
        Some(a) => super::edit::delegated_identity(app, a.id).await.is_some(),
9083×
        None => false,
909
    };
91074×
    let hub_can_land = delegated || app.repos.read(&repo, |r, _| Ok(r.current_policy()?.is_none())).await.unwrap_or(false);
91174×
    let source_label = match p.source_kind.as_str() {
91274×
        "fork" => format!(
913
            "fork {}",
9146×
            db::repos::get_by_id(&app.db, p.source_repo_id.unwrap_or(0))
9156×
                .await
9166×
                .ok()
9176×
                .flatten()
9186×
                .map(|r| format!("{}:{}", r.slug(), p.source_bookmark))
9196×
                .unwrap_or_default()
920
        ),
92168×
        "changes" => format!("{} changes", p.source_changes().len()),
92268×
        _ => p.source_bookmark.clone(),
923
    };
92474×
    let projects = super::projects::placements_for(app, viewer, &row, p.number).await;
92574×
    let texts: Vec<String> = std::iter::once(p.body.clone()).chain(changes.iter().map(|c| format!("{}\n{}", c.title, c.body))).collect();
92674×
    let closes = super::chain::issues_closed_by(app, &row, &texts.iter().map(String::as_str).collect::<Vec<_>>()).await;
92774×
    let project_choices = super::projects::project_choices(app, viewer, &row, &projects).await;
92874×
    let mut dated: Vec<(i64, Entry)> = Vec::new();
92974×
    for (index, c) in comments.iter().enumerate() {
93036×
        if c.row.anchor_change.is_empty() {
93117×
            dated.push((c.row.created_ms, Entry { kind: "comment", index }));
93219×
        }
933
    }
93474×
    for (index, r) in v.reviews.iter().enumerate() {
93523×
        dated.push((r.row.created_ms, Entry { kind: "review", index }));
93623×
    }
937140×
    for (index, e) in events.iter().enumerate() {
938140×
        dated.push((e.at_ms, Entry { kind: "event", index }));
939140×
    }
94074×
    dated.sort_by_key(|(at, _)| *at);
94174×
    let timeline: Vec<Entry> = dated.into_iter().map(|(_, e)| e).collect();
94274×
    let pending = app.assistant_gate.lock().unwrap_or_else(|e| e.into_inner()).answering(p_id);
943
    Ok(Page(ProposalPage {
94474×
        live_topic: crate::events::topic::proposal(row.id, number),
94574×
        repo_topic: crate::events::topic::repo(row.id),
94674×
        pending,
94774×
        can_triage: role >= Role::Triage,
94874×
        timeline,
94974×
        closes,
95074×
        project_choices,
95174×
        projects,
95274×
        shell,
95374×
        repo: RepoShell::new(app, &row, "proposals").with_role(role).with_sky(app, &row).await,
95474×
        body_html: super::issues::markdown(app, &row, &row.default_bookmark, &p.body).await,
95574×
        p,
95674×
        tab,
95774×
        changes,
95874×
        files: loaded.files,
95974×
        diffs: loaded.diffs,
96074×
        bases: loaded.bases,
961
        ignore_ws: false,
96274×
        conflicts: loaded.conflicts,
96374×
        conflicted: loaded.conflicted,
96474×
        comments,
96574×
        placed,
96674×
        outdated,
96774×
        reviews: v.reviews,
96874×
        approvals: v.approvals,
96974×
        blockers: v.blockers,
97074×
        depends_on: v.depends_on,
97174×
        events,
97274×
        owners: loaded.owners,
97374×
        requirements: v.requirements,
97474×
        landed_already: loaded.landed_already,
97574×
        can_edit,
97674×
        deletable_source,
97774×
        can_land,
97874×
        can_land_over_checks,
97974×
        can_auto_land,
98074×
        hub_can_land,
98174×
        notice,
98274×
        error,
983145×
        heads: loaded.heads.iter().map(|h| h.to_hex()).collect(),
98474×
        state_hash: loaded.state.clone(),
98574×
        source_label,
98674×
        api_url: format!("{}/api/v1/repos/{owner}/{name}/proposals/{number}", app.config.base_url()),
98774×
        labels: db::issues::proposal_labels(&app.db, loaded.row.id).await.unwrap_or_default(),
98874×
        all_labels: db::issues::labels(&app.db, row.id).await.unwrap_or_default(),
98974×
        checks: db::hooks::checks_for_state(&app.db, row.id, &loaded.state).await.unwrap_or_default(),
99074×
        required_checks: db::hooks::required_checks(&app.db, row.id).await.unwrap_or_default(),
99174×
        requested,
99274×
        reviewable,
99374×
        assistants,
99474×
        draft: None,
99574×
        can_request,
996
    }))
99774×
}
998
999
/// Back to the proposal after an action, with what to say: a redirect, so
1000
/// reloading the page (the checks script does) never repeats the action.
100130×
fn back(owner: &str, name: &str, number: i64, tab: &str, notice: Option<String>, error: Option<String>) -> Response {
100230×
    let mut url = format!("/{owner}/{name}/proposal/{number}?tab={tab}");
100330×
    if let Some(n) = notice {
100421×
        url.push_str(&format!("&notice={}", super::settings::urlencode(&n)));
100521×
    }
100630×
    if let Some(e) = error {
10074×
        url.push_str(&format!("&error={}", super::settings::urlencode(&e)));
100826×
    }
100930×
    Redirect::to(&url).into_response()
101030×
}
1011
101273×
pub async fn show(
101373×
    State(app): State<Shared>,
101473×
    viewer: Viewer,
101573×
    Path((owner, name, number)): Path<(String, String, i64)>,
101673×
    Query(q): Query<DetailQuery>,
101773×
) -> Result<Response, Error> {
101873×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
101973×
    let mut page =
102073×
        detail(&app, shell, &viewer, &owner, &name, number, q.tab.unwrap_or_else(|| "conversation".into()), q.notice, q.error).await?;
102173×
    if q.w.as_deref() == Some("1") {
10220×
        // Hide whitespace-only changes: the same hunks, rendered again.
10230×
        page.0.files =
10240×
            super::history::render_files_with(&page.0.diffs, &page.0.bases, crate::render::diff::Options { ignore_whitespace: true });
10250×
        page.0.ignore_ws = true;
102673×
    }
1027
    // A live page asks for one part again (oxygenhub#205): rendered by the
1028
    // same route, so the same access, and cut from the whole.
102973×
    if let Some(name) = q.fragment.as_deref() {
10304×
        let html = page.0.render().map_err(|e| Error::bad(page.0.shell.clone(), e.to_string()))?;
10314×
        return Ok(match super::fragment(&html, name) {
10323×
            Some(part) => ([(axum::http::header::CACHE_CONTROL, "no-store")], axum::response::Html(part)).into_response(),
10331×
            None => (axum::http::StatusCode::NOT_FOUND, "no such fragment").into_response(),
1034
        });
103569×
    }
103669×
    Ok(page.into_response())
103773×
}
1038
1039
// ---------------------------------------------------------------- actions
1040
1041
#[derive(Deserialize)]
1042
pub struct CommentForm {
1043
    #[serde(rename = "_csrf")]
1044
    csrf_token: String,
1045
    body: String,
1046
    #[serde(default)]
1047
    path: String,
1048
    #[serde(default)]
1049
    line: Option<usize>,
1050
}
1051
10522×
pub async fn comment(
10532×
    State(app): State<Shared>,
10542×
    viewer: Viewer,
10552×
    Path((owner, name, number)): Path<(String, String, i64)>,
10562×
    Form(f): Form<CommentForm>,
10572×
) -> Result<Response, Error> {
10582×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
10592×
    let me = require(&viewer, &shell)?;
10602×
    csrf(&viewer, &f.csrf_token, &shell)?;
10612×
    let (repo, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
10622×
    super::writable(&row, &shell)?;
10632×
    let p = pdb::get(&app.db, row.id, number)
10642×
        .await
10652×
        .map_err(|e| Error::from_db(shell.clone(), e))?
10662×
        .ok_or_else(|| Error::not_found(shell.clone()))?;
10672×
    if f.body.trim().is_empty() {
10680×
        return Ok(back(&owner, &name, number, "conversation", None, Some("An empty comment says nothing.".into())));
10692×
    }
10702×
    let anchor = match (f.path.trim(), f.line) {
10712×
        ("", _) | (_, None) => None,
10721×
        (path, Some(line)) => {
10731×
            let loaded = load(&app, &repo, p.clone()).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
10741×
            loaded.atoms.get(&(path.to_string(), line)).cloned()
1075
        }
1076
    };
10772×
    let tab = if anchor.is_some() { "files" } else { "conversation" };
10782×
    pdb::add_comment(
10792×
        &app.db,
10802×
        p.id,
10812×
        Some(me.account.id),
10822×
        &me.account.username,
10832×
        f.body.trim(),
10842×
        f.path.trim(),
10852×
        anchor.as_ref().map(|(h, i)| (h.as_str(), *i as i64)),
1086
    )
10872×
    .await
10882×
    .map_err(|e| Error::from_db(shell.clone(), e))?;
10892×
    app.bus.live(&crate::events::topic::proposal(row.id, number), "comment");
10902×
    Ok(back(&owner, &name, number, tab, None, None))
10912×
}
1092
1093
#[derive(Deserialize)]
1094
pub struct ReviewForm {
1095
    #[serde(rename = "_csrf")]
1096
    csrf_token: String,
1097
    verdict: String,
1098
    #[serde(default)]
1099
    body: String,
1100
}
1101
11023×
pub async fn review(
11033×
    State(app): State<Shared>,
11043×
    viewer: Viewer,
11053×
    Path((owner, name, number)): Path<(String, String, i64)>,
11063×
    Form(f): Form<ReviewForm>,
11073×
) -> Result<Response, Error> {
11083×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
11093×
    let me = require(&viewer, &shell)?;
11103×
    csrf(&viewer, &f.csrf_token, &shell)?;
11113×
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
11123×
    super::writable(&row, &shell)?;
11133×
    let p = pdb::get(&app.db, row.id, number)
11143×
        .await
11153×
        .map_err(|e| Error::from_db(shell.clone(), e))?
11163×
        .ok_or_else(|| Error::not_found(shell.clone()))?;
11173×
    if !matches!(f.verdict.as_str(), "comment" | "changes" | "approve") {
11180×
        return Err(Error::bad(shell, "a review is comment, changes or approve"));
11193×
    }
11203×
    if f.verdict == "approve" && Some(me.account.id) == p.author_id {
11211×
        return Ok(back(&owner, &name, number, "conversation", None, Some("You cannot approve your own proposal.".into())));
11222×
    }
11232×
    pdb::add_review(&app.db, p.id, me.account.id, &me.account.username, &f.verdict, f.body.trim())
11242×
        .await
11252×
        .map_err(|e| Error::from_db(shell.clone(), e))?;
11262×
    pdb::add_event(&app.db, p.id, &me.account.username, &format!("review.{}", f.verdict), "").await.ok();
11272×
    reviewed(&app, &row, &p, &me.account, &f.verdict).await;
11282×
    crate::hooks::emit(&app, &row, "review", &me.account.username, serde_json::json!({ "number": number, "verdict": f.verdict })).await;
11292×
    let notice = if f.verdict == "approve" {
11302×
        Some(format!(
11312×
            "Approval recorded. It counts once you seal the changes: oxy seal --proposal {}/{owner}/{name}/proposal/{number}",
11322×
            app.config.base_url()
11332×
        ))
1134
    } else {
11350×
        None
1136
    };
11372×
    Ok(back(&owner, &name, number, "conversation", notice, None))
11383×
}
1139
1140
#[derive(Deserialize)]
1141
pub struct EditForm {
1142
    #[serde(rename = "_csrf")]
1143
    csrf_token: String,
1144
    title: String,
1145
    #[serde(default)]
1146
    body: String,
1147
    target: String,
1148
}
1149
11500×
pub async fn edit(
11510×
    State(app): State<Shared>,
11520×
    viewer: Viewer,
11530×
    Path((owner, name, number)): Path<(String, String, i64)>,
11540×
    Form(f): Form<EditForm>,
11550×
) -> Result<Response, Error> {
11560×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
11570×
    let me = require(&viewer, &shell)?;
11580×
    csrf(&viewer, &f.csrf_token, &shell)?;
11590×
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
11600×
    let p = pdb::get(&app.db, row.id, number)
11610×
        .await
11620×
        .map_err(|e| Error::from_db(shell.clone(), e))?
11630×
        .ok_or_else(|| Error::not_found(shell.clone()))?;
11640×
    if Some(me.account.id) != p.author_id && role < Role::Maintain {
11650×
        return Err(Error::not_found(shell));
11660×
    }
11670×
    let target = if crate::repos::names::valid_segment(f.target.trim()) { f.target.trim() } else { &p.target };
11680×
    pdb::edit(&app.db, p.id, f.title.trim(), f.body.trim(), target).await.map_err(|e| Error::from_db(shell.clone(), e))?;
11690×
    pdb::add_event(&app.db, p.id, &me.account.username, "edited", "").await.ok();
11700×
    Ok(back(&owner, &name, number, "conversation", Some("Saved.".into()), None))
11710×
}
1172
1173
#[derive(Deserialize)]
1174
pub struct StateForm {
1175
    #[serde(rename = "_csrf")]
1176
    csrf_token: String,
1177
    action: String,
1178
}
1179
11802×
pub async fn state(
11812×
    State(app): State<Shared>,
11822×
    viewer: Viewer,
11832×
    Path((owner, name, number)): Path<(String, String, i64)>,
11842×
    Form(f): Form<StateForm>,
11852×
) -> Result<Response, Error> {
11862×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
11872×
    let me = require(&viewer, &shell)?;
11882×
    csrf(&viewer, &f.csrf_token, &shell)?;
11892×
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
11902×
    let p = pdb::get(&app.db, row.id, number)
11912×
        .await
11922×
        .map_err(|e| Error::from_db(shell.clone(), e))?
11932×
        .ok_or_else(|| Error::not_found(shell.clone()))?;
11942×
    if Some(me.account.id) != p.author_id && role < Role::Triage {
11950×
        return Err(Error::not_found(shell));
11962×
    }
11972×
    let new_state = match (f.action.as_str(), p.state.as_str()) {
11982×
        ("close", "open" | "draft") => "closed",
11991×
        ("reopen", "closed") => "open",
12000×
        ("ready", "draft") => "open",
12010×
        ("draft", "open") => "draft",
12020×
        _ => return Err(Error::bad(shell, "that transition is not possible")),
1203
    };
12042×
    pdb::set_state(&app.db, p.id, new_state, "").await.map_err(|e| Error::from_db(shell.clone(), e))?;
12052×
    pdb::add_event(&app.db, p.id, &me.account.username, new_state, "").await.ok();
12062×
    crate::hooks::emit(&app, &row, &format!("proposal.{new_state}"), &me.account.username, serde_json::json!({ "number": number })).await;
12072×
    Ok(back(&owner, &name, number, "conversation", None, None))
12082×
}
1209
12100×
pub async fn resolve_comment(
12110×
    State(app): State<Shared>,
12120×
    viewer: Viewer,
12130×
    Path((owner, name, number, id)): Path<(String, String, i64, i64)>,
12140×
    Form(f): Form<super::session::CsrfForm>,
12150×
) -> Result<Response, Error> {
12160×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
12170×
    require(&viewer, &shell)?;
12180×
    csrf(&viewer, &f.csrf, &shell)?;
12190×
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
12200×
    let p = pdb::get(&app.db, row.id, number)
12210×
        .await
12220×
        .map_err(|e| Error::from_db(shell.clone(), e))?
12230×
        .ok_or_else(|| Error::not_found(shell.clone()))?;
12240×
    let current: Option<bool> = sqlx::query_scalar("SELECT resolved FROM proposal_comments WHERE id = $1 AND proposal_id = $2")
12250×
        .bind(id)
12260×
        .bind(p.id)
12270×
        .fetch_optional(&app.db)
12280×
        .await
12290×
        .map_err(|e| Error::from_db(shell.clone(), e))?;
12300×
    if let Some(r) = current {
12310×
        pdb::set_comment_resolved(&app.db, id, !r).await.map_err(|e| Error::from_db(shell.clone(), e))?;
12320×
    }
12330×
    Ok(back(&owner, &name, number, "files", None, None))
12340×
}
1235
1236
/// Land from the hub: only when the repository has no trust policy (the
1237
/// hub never signs a reference update the policy would have to admit).
1238
#[derive(serde::Deserialize)]
1239
pub struct LandForm {
1240
    #[serde(rename = "_csrf")]
1241
    csrf: String,
1242
    /// Why it lands over its required checks (#261), for maintainers.
1243
    #[serde(default)]
1244
    over_checks: Option<String>,
1245
}
1246
124715×
pub async fn land(
124815×
    State(app): State<Shared>,
124915×
    viewer: Viewer,
125015×
    Path((owner, name, number)): Path<(String, String, i64)>,
125115×
    Form(f): Form<LandForm>,
125215×
) -> Result<Response, Error> {
125315×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
125415×
    let me = require(&viewer, &shell)?;
125515×
    csrf(&viewer, &f.csrf, &shell)?;
125615×
    let need = if f.over_checks.is_some() { Access::Maintain } else { Access::Write };
125715×
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, need).await?;
125814×
    match land_as(&app, &repo, &row, &me.account, number, "web", false, f.over_checks.as_deref()).await {
125912×
        Ok(_) => Ok(back(&owner, &name, number, "conversation", Some("Landed.".into()), None)),
12600×
        Err(Landing::NotFound) => Err(Error::not_found(shell)),
12612×
        Err(Landing::Refused(m)) => Ok(back(&owner, &name, number, "conversation", None, Some(m))),
1262
    }
126315×
}
1264
1265
#[derive(serde::Deserialize)]
1266
pub struct AutoLandForm {
1267
    #[serde(rename = "_csrf")]
1268
    csrf: String,
1269
    /// `1` to set it, else off.
1270
    #[serde(default)]
1271
    on: String,
1272
}
1273
1274
/// `POST …/proposal/{n}/auto-land` (#261): land when its checks pass, set
1275
/// or cleared by someone who may land it.
12764×
pub async fn auto_land(
12774×
    State(app): State<Shared>,
12784×
    viewer: Viewer,
12794×
    Path((owner, name, number)): Path<(String, String, i64)>,
12804×
    Form(f): Form<AutoLandForm>,
12814×
) -> Result<Response, Error> {
12824×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
12834×
    let me = require(&viewer, &shell)?;
12844×
    csrf(&viewer, &f.csrf, &shell)?;
12854×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
12864×
    let p = pdb::get(&app.db, row.id, number)
12874×
        .await
12884×
        .map_err(|e| Error::from_db(shell.clone(), e))?
12894×
        .filter(|p| p.is_open())
12904×
        .ok_or_else(|| Error::not_found(shell.clone()))?;
12914×
    let on = f.on == "1";
12924×
    crate::autoland::set(&app, &row, &p, &me.account, on).await.map_err(|e| Error::from_db(shell.clone(), e))?;
12934×
    let note = if on { "It will land when its checks pass." } else { "It will not land by itself." };
12944×
    Ok(back(&owner, &name, number, "conversation", Some(note.into()), None))
12954×
}
1296
1297
/// Why a proposal did not land.
1298
pub enum Landing {
1299
    NotFound,
1300
    Refused(String),
1301
}
1302
1303
/// Land proposal `number` as `me` (hub spec §8.4), who has write access:
1304
/// refused while it is not open or has blockers. Moves the target bookmark
1305
/// (attested by the hub key on `me`'s behalf when they delegated to it),
1306
/// then records, announces and audits the landing, saying how it was done
1307
/// (`via`: `web`, or `api (token NAME)`). `as_hub` signs with the hub's own
1308
/// key rather than `me`'s delegation (Diffusion's auto-land).
1309
/// `over_checks`, a reason, lands over required checks that have not
1310
/// passed (#261): the caller has checked `me` maintains the repository;
1311
/// every other blocker still holds. Returns the new state.
1312
#[allow(clippy::too_many_arguments)]
131322×
pub async fn land_as(
131422×
    app: &Shared,
131522×
    repo: &RepoName,
131622×
    row: &db::repos::RepoRow,
131722×
    me: &accounts::Account,
131822×
    number: i64,
131922×
    via: &str,
132022×
    as_hub: bool,
132122×
    over_checks: Option<&str>,
132222×
) -> Result<String, Landing> {
132322×
    let (owner, name) = (row.owner.clone(), row.name.clone());
132422×
    let refused = |e: String| Landing::Refused(e);
132522×
    let p = pdb::get(&app.db, row.id, number).await.map_err(|e| refused(e.to_string()))?.ok_or(Landing::NotFound)?;
132622×
    let loaded = load(app, repo, p.clone()).await.map_err(|e| refused(e.to_string()))?;
132722×
    let v = verdict(app, &loaded, row.id).await.map_err(|e| refused(e.to_string()))?;
132822×
    if !p.is_open() {
13291×
        return Err(refused(format!("Cannot land: the proposal is {}", p.state)));
133021×
    }
133121×
    let reason = over_checks.map(str::trim);
133221×
    if reason == Some("") {
13331×
        return Err(refused("Landing over checks needs a reason: say why it can't wait for them".into()));
133420×
    }
133520×
    let blocking: Vec<&String> = v.blockers.iter().filter(|b| !(reason.is_some() && v.checks_blocker.as_ref() == Some(*b))).collect();
133620×
    if !blocking.is_empty() {
13371×
        let list: Vec<&str> = blocking.iter().map(|b| b.as_str()).collect();
13381×
        return Err(refused(format!("Cannot land: {}", list.join("; "))));
133919×
    }
1340
    // Only when the checks were in the way does the landing say so.
134119×
    let over = reason.filter(|_| v.checks_blocker.is_some());
134219×
    let via_owned = match over {
13431×
        Some(r) => format!("{via}, over checks: {r}"),
134418×
        None => via.to_string(),
1345
    };
134619×
    let via = via_owned.as_str();
134719×
    let target = p.target.clone();
134819×
    let heads = loaded.heads.clone();
1349
    // With a delegation the landing is attested by the hub key on the
1350
    // user's behalf, which a policy accepts when it lets the user update
1351
    // the target (spec §14.5); without one, only a policy-free repository
1352
    // can be landed here.
135319×
    let signer = if as_hub { super::edit::hub_signer(app) } else { super::edit::signer_for(app, me.id).await };
1354
    // Only adjacent insertions in union files in the way (oxygen#145): the
1355
    // landing carries their resolution, both sides kept.
135619×
    let union = loaded.union.clone().filter(|_| loaded.conflicted);
135719×
    let mut heads = heads;
135819×
    let result = match (signer, union) {
13591×
        (Ok(signer), Some(files)) => {
13601×
            let names: Vec<&str> = files.iter().map(|(p, _)| p.as_str()).collect();
13611×
            let message = format!("Keep both sides in {} (merge=union), landing #{number}", names.join(", "));
13621×
            let edits = files.into_iter().map(|(path, text)| oxy_core::repo::FileEdit { path, content: Some(text) }).collect();
13631×
            crate::repos::edits::land_resolved(&app.repos, repo, target.clone(), heads.clone(), edits, message, signer).await.map(|h| {
13641×
                heads = h;
13651×
                pl::state_ref(&heads).to_hex()
13661×
            })
1367
        }
13680×
        (Err(why), Some(_)) => {
13690×
            return Err(refused(format!(
13700×
                "Cannot land: its changelog-style conflicts are settled by a resolution the hub signs for you, and it cannot: {why}"
13710×
            )))
1372
        }
137318×
        (signer, None) => match signer {
13749×
            Ok(signer) => crate::repos::edits::move_bookmark(&app.repos, repo, target.clone(), heads.clone(), signer)
13759×
                .await
13769×
                .map(|_| pl::state_ref(&heads).to_hex()),
13779×
            Err(why) => {
13789×
                let (target, heads) = (target.clone(), heads.clone());
13799×
                app.repos
13809×
                .write(repo, move |r, _| {
13819×
                    if r.current_policy()?.is_some() {
13820×
                        return Err(crate::repos::RepoError::Oxy(oxy_core::repo::RepoError::Msg(format!(
13830×
                            "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}"
13840×
                        ))));
13859×
                    }
13869×
                    r.bookmark_set(&target, &heads)?;
13879×
                    Ok(pl::state_ref(&heads).to_hex())
13889×
                })
13899×
                .await
1390
            }
1391
        },
1392
    };
139319×
    let state = result.map_err(|e| refused(e.to_string()))?;
139419×
    pdb::set_landed(&app.db, p.id, &state, &me.username, via).await.map_err(|e| refused(e.to_string()))?;
139519×
    crate::flow::moot_after_landing(app, row, &p).await;
139619×
    let how = if v.approvals.is_empty() { format!("{via}, with no approval") } else { via.to_string() };
139719×
    pdb::add_event(&app.db, p.id, &me.username, "landed", &how).await.ok();
139819×
    crate::hooks::emit(
139919×
        app,
140019×
        row,
140119×
        "proposal.landed",
140219×
        &me.username,
140319×
        serde_json::json!({ "number": number, "target": p.target, "state": state }),
140419×
    )
140519×
    .await;
140619×
    crate::notify::deliver(
140719×
        app,
140819×
        crate::notify::Event {
140919×
            kind: "proposal.landed",
141019×
            title: format!("{owner}/{name}#{number}: {} landed", p.title),
141119×
            url: format!("/{owner}/{name}/proposal/{number}"),
141219×
            actor: me,
141319×
            repo_id: row.id,
141419×
            thread: Some(("proposal", p.id)),
141519×
            mention_text: "",
141619×
            direct: p.author_id.into_iter().collect(),
141719×
        },
141819×
    )
141919×
    .await;
142019×
    if p.target == row.default_bookmark {
142119×
        let messages: Vec<(String, String)> = loaded.changes.iter().map(|c| (c.hash.clone(), format!("{}\n{}", c.title, c.body))).collect();
142219×
        super::issues::close_from_messages(app, row, &messages).await;
14230×
    }
142419×
    accounts::audit(&app.db, Some(me), "proposal.landed", &format!("{owner}/{name}#{number}"), &how).await.ok();
142519×
    if let (Some(r), Some(checks)) = (over, &v.checks_blocker) {
14261×
        accounts::audit(&app.db, Some(me), "proposal.land_over_checks", &format!("{owner}/{name}#{number}"), &format!("{r} ({checks})"))
14271×
            .await
14281×
            .ok();
142918×
    }
143019×
    db::repos::record_push(
143119×
        &app.db,
143219×
        row.id,
143319×
        Some((me.id, me.username.as_str())),
143419×
        &[db::repos::PushOutcome {
143519×
            reference: p.target.clone(),
143619×
            ok: true,
143719×
            reason: format!("landed #{number} ({how})"),
143819×
            old_state: loaded.target_state.clone(),
143919×
            new_state: state.clone(),
144019×
            heads: loaded.heads.len() as i64,
144119×
            changes: loaded.hashes.len() as i64,
144219×
        }],
144319×
    )
144419×
    .await
144519×
    .ok();
1446
    // The target moved: its other open proposals are checked again (#134).
144719×
    settle_after_push(app, row, &p.target, &me.username).await;
1448
    // ...and its pipeline runs, as a push of the same heads would start it
1449
    // (#197): without the run a consumer following it when green would wait
1450
    // for a state that never runs.
145119×
    crate::flow::on_push(app, row, &p.target, &state, &heads, &me.username).await;
1452
    // A landing on the default bookmark closes the issues its changes (and
1453
    // its description) say they close, as a push there would.
145419×
    if p.target == row.default_bookmark {
145519×
        let body = (format!("proposal #{number}"), p.body.clone());
145619×
        crate::web::issues::close_from_arrived(app, row, loaded.target_heads.clone(), heads.clone(), Some(body)).await;
14570×
    }
1458
    // Diffusion (#150): a landing moves the target as a push would, so the
1459
    // pin index follows it, and the consumers of a moved default bookmark
1460
    // are bumped.
146119×
    let heads = heads.clone();
146219×
    let moved = vec![(p.target.clone(), state.clone(), heads.clone())];
146319×
    crate::diffusion::after_push(app, row, &moved).await;
146419×
    if p.target == row.default_bookmark {
146519×
        let (app, row) = (app.clone(), row.clone());
146619×
        tokio::spawn(async move { crate::diffusion::propagate(&app, &row, heads, false).await });
14670×
    }
146819×
    Ok(state)
146922×
}
1470
1471
#[derive(Deserialize)]
1472
pub struct LabelsForm {
1473
    #[serde(rename = "_csrf")]
1474
    csrf_token: String,
1475
    #[serde(default)]
1476
    label: Vec<i64>,
1477
}
1478
14790×
pub async fn set_labels(
14800×
    State(app): State<Shared>,
14810×
    viewer: Viewer,
14820×
    Path((owner, name, number)): Path<(String, String, i64)>,
14830×
    axum_extra::extract::Form(f): axum_extra::extract::Form<LabelsForm>,
14840×
) -> Result<Response, Error> {
14850×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
14860×
    require(&viewer, &shell)?;
14870×
    csrf(&viewer, &f.csrf_token, &shell)?;
14880×
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
14890×
    if role < Role::Triage {
14900×
        return Err(Error::not_found(shell));
14910×
    }
14920×
    let p = pdb::get(&app.db, row.id, number)
14930×
        .await
14940×
        .map_err(|e| Error::from_db(shell.clone(), e))?
14950×
        .ok_or_else(|| Error::not_found(shell.clone()))?;
14960×
    for l in db::issues::labels(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))? {
14970×
        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))?;
1498
    }
14990×
    Ok(back(&owner, &name, number, "conversation", None, None))
15000×
}
1501
1502
/// `GET /{owner}/{name}/proposal/{number}/events`: server-sent events for
1503
/// checks on the projected state (hub spec §13.1).
15040×
pub async fn events(
15050×
    State(app): State<Shared>,
15060×
    viewer: Viewer,
15070×
    Path((owner, name, number)): Path<(String, String, i64)>,
15080×
) -> Result<axum::response::Sse<impl futures::Stream<Item = Result<axum::response::sse::Event, std::convert::Infallible>>>, Error> {
1509
    use futures::StreamExt;
15100×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
15110×
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
15120×
    let rx = app.bus.subscribe(crate::bus::Topic::Proposals);
15130×
    let repo_id = row.id;
15140×
    let stream = tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(move |item| async move {
15150×
        match item {
15160×
            Ok(crate::bus::Event::Proposal { repo, event: ev }) if repo == repo_id => {
15170×
                Some(Ok(axum::response::sse::Event::default().event(ev.kind.clone()).data(serde_json::to_string(&ev).unwrap_or_default())))
1518
            }
15190×
            _ => None,
1520
        }
15210×
    });
15220×
    Ok(axum::response::Sse::new(futures::StreamExt::take_until(stream, crate::shutdown::stopping()))
15230×
        .keep_alive(axum::response::sse::KeepAlive::default()))
15240×
}
1525
1526
// -------------------------------------------------------------------- API
1527
152840×
pub async fn to_api(app: &Shared, owner: &str, name: &str, loaded: &Loaded, v: &Verdict, source_url: Option<String>) -> hub_api::Proposal {
152940×
    let p = &loaded.row;
1530
    hub_api::Proposal {
153140×
        number: p.number,
153240×
        title: p.title.clone(),
153340×
        body: p.body.clone(),
153440×
        author: p.author.clone(),
153540×
        state: ProposalState::parse(&p.state).unwrap_or(ProposalState::Open),
153640×
        target: p.target.clone(),
153740×
        source: match p.source_kind.as_str() {
153840×
            "fork" => {
15390×
                let (o, n) = source_url
15400×
                    .as_deref()
15410×
                    .and_then(|u| u.rsplit_once('/'))
15420×
                    .and_then(|(b, n)| b.rsplit_once('/').map(|(_, o)| (o.to_string(), n.to_string())))
15430×
                    .unwrap_or_default();
15440×
                ProposalSource::Fork { owner: o, name: n, bookmark: p.source_bookmark.clone() }
1545
            }
154640×
            "changes" => ProposalSource::Changes { hashes: p.source_changes() },
154740×
            _ => ProposalSource::Bookmark { bookmark: p.source_bookmark.clone() },
1548
        },
154940×
        changes: loaded
155040×
            .changes
155140×
            .iter()
155240×
            .map(|c| hub_api::ChangeSummary {
155340×
                hash: c.hash.clone(),
155440×
                message: if c.body.is_empty() { c.title.clone() } else { format!("{}\n\n{}", c.title, c.body) },
155540×
                author: hub_api::Author {
155640×
                    name: c.author.name.clone(),
155740×
                    email: c.author.email.clone(),
155840×
                    key: c.author.key.clone(),
155940×
                    account: c.author.account.clone(),
156040×
                    note: hub_api::Author::note_for(&c.author.key, &c.author.account),
156140×
                },
156240×
                when_ms: c.when_ms,
156340×
                deps: c.deps.clone(),
156440×
                head: c.head,
156540×
                via: c.via.as_ref().map(|v| hub_api::Via {
15660×
                    name: v.name.clone(),
15670×
                    key: v.key.clone(),
15680×
                    scope: v.scope.clone(),
15690×
                    expires_ms: v.expires_ms,
15700×
                    revoked: v.revoked,
15710×
                }),
157240×
                signature: match &c.signature {
157333×
                    view::SigView::Unsigned => hub_api::Signature::Unsigned,
15747×
                    view::SigView::Valid { key, sealed, revoked, .. } => {
15757×
                        hub_api::Signature::Valid { key: key.clone(), sealed: *sealed, revoked: *revoked }
1576
                    }
15770×
                    view::SigView::Invalid(why) => hub_api::Signature::Invalid { reason: why.clone() },
1578
                },
157940×
            })
158040×
            .collect(),
158150×
        projected_heads: loaded.heads.iter().map(|h| h.to_hex()).collect(),
158240×
        projected_state: Some(loaded.state.clone()),
158340×
        conflicted: loaded.conflicted,
158440×
        approvals: v.approvals.clone(),
158540×
        landable: p.is_open() && v.blockers.is_empty(),
158640×
        blockers: v.blockers.clone(),
158740×
        created_ms: p.created_ms,
158840×
        updated_ms: p.updated_ms,
158940×
        url: format!("{}/{owner}/{name}/proposal/{}", app.config.base_url(), p.number),
159040×
        repository_url: format!("{}/{owner}/{name}", app.config.base_url()),
159140×
        source_url,
159240×
        landed: (p.state == "landed" && p.landed_ms > 0).then(|| hub_api::Landed {
15938×
            by: p.landed_by.clone(),
15948×
            via: p.landed_via.clone(),
15958×
            when_ms: p.landed_ms,
15968×
        }),
159740×
        auto_land: (p.is_open() && !p.auto_land_by.is_empty()).then(|| p.auto_land_by.clone()),
159840×
        requested_reviewers: pdb::review_requests(&app.db, p.id).await.unwrap_or_default(),
159940×
        labels: db::issues::proposal_labels(&app.db, p.id)
160040×
            .await
160140×
            .unwrap_or_default()
160240×
            .into_iter()
160340×
            .map(|l| hub_api::Label { name: l.name, colour: l.colour, description: l.description })
160440×
            .collect(),
1605
    }
160640×
}
1607
1608
/// The line comment form's placement key, for templates.
16097×
pub fn key(path: &str, line: impl filters::Num) -> (String, usize) {
16107×
    (path.to_string(), line.as_u64() as usize)
16117×
}
1612
1613
#[allow(dead_code)]
16140×
fn _unused(_: &diff::Line, _: &ConflictView, _: &Role) {}
1615
1616
// ------------------------------------------------------- review requests
1617
1618
/// Ask accounts to review a proposal (hub plan 0.4): each must exist, be
1619
/// someone other than the author, and be able to read the repository. Each
1620
/// newly asked is told, and the proposal records it. The usernames newly
1621
/// asked, or why none could be.
16225×
pub async fn request_reviewers(
16235×
    app: &Shared,
16245×
    row: &db::repos::RepoRow,
16255×
    p: &pdb::ProposalRow,
16265×
    by: &accounts::Account,
16275×
    names: &[String],
16285×
) -> Result<Vec<String>, String> {
16295×
    let mut found = Vec::new();
16305×
    for name in names.iter().map(|n| n.trim().trim_start_matches('@')).filter(|n| !n.is_empty()) {
16315×
        let Some(a) = accounts::account_by_name(&app.db, name).await.ok().flatten() else {
16321×
            return Err(format!("There is no account {name}."));
1633
        };
16344×
        if Some(a.id) == p.author_id {
16351×
            return Err(format!("{name} wrote this proposal, and cannot review it."));
16363×
        }
16373×
        let role = crate::auth::roles::role_for(&app.db, Some(&a), row).await.ok().flatten();
16383×
        if !crate::auth::roles::allows(role, crate::auth::Access::Read) {
16390×
            return Err(format!("{name} cannot read this repository."));
16403×
        }
16413×
        found.push(a);
1642
    }
16433×
    let mut asked = Vec::new();
16443×
    for a in found {
16453×
        if !pdb::request_review(&app.db, p.id, a.id, &by.username).await.map_err(|e| e.to_string())? {
16460×
            continue;
16473×
        }
16483×
        pdb::add_event(&app.db, p.id, &by.username, "review requested", &a.username).await.ok();
16493×
        crate::notify::deliver(
16503×
            app,
16513×
            crate::notify::Event {
16523×
                kind: "proposal.review_requested",
16533×
                title: format!("{}/{}#{}: {} (review requested from {})", row.owner, row.name, p.number, p.title, a.username),
16543×
                url: format!("/{}/{}/proposal/{}", row.owner, row.name, p.number),
16553×
                actor: by,
16563×
                repo_id: row.id,
16573×
                thread: None,
16583×
                mention_text: "",
16593×
                direct: vec![a.id],
16603×
            },
16613×
        )
16623×
        .await;
16633×
        asked.push(a.username);
1664
    }
16653×
    Ok(asked)
16665×
}
1667
1668
/// After a review: it answers the reviewer's request, if there was one, and
1669
/// the proposal's author is told.
16704×
pub async fn reviewed(app: &Shared, row: &db::repos::RepoRow, p: &pdb::ProposalRow, by: &accounts::Account, verdict: &str) {
16714×
    pdb::unrequest_review(&app.db, p.id, by.id).await.ok();
16724×
    let what = match verdict {
16734×
        "approve" => "approved",
16742×
        "changes" => "changes requested",
16752×
        _ => "reviewed",
1676
    };
16774×
    crate::notify::deliver(
16784×
        app,
16794×
        crate::notify::Event {
16804×
            kind: "proposal.review",
16814×
            title: format!("{}/{}#{}: {} ({what} by {})", row.owner, row.name, p.number, p.title, by.username),
16824×
            url: format!("/{}/{}/proposal/{}", row.owner, row.name, p.number),
16834×
            actor: by,
16844×
            repo_id: row.id,
16854×
            thread: Some(("proposal", p.id)),
16864×
            mention_text: "",
16874×
            direct: p.author_id.into_iter().collect(),
16884×
        },
16894×
    )
16904×
    .await;
16914×
}
1692
1693
#[derive(Deserialize)]
1694
pub struct ReviewersForm {
1695
    #[serde(rename = "_csrf")]
1696
    csrf_token: String,
1697
    /// `add`, `remove`, or `set` (the menu: exactly these are asked).
1698
    action: String,
1699
    #[serde(default)]
1700
    username: Vec<String>,
1701
}
1702
1703
/// `POST /{owner}/{name}/proposal/{number}/reviewers`: the author or a
1704
/// writer asks someone to review, or withdraws the request.
17052×
pub async fn reviewers(
17062×
    State(app): State<Shared>,
17072×
    viewer: Viewer,
17082×
    Path((owner, name, number)): Path<(String, String, i64)>,
17092×
    axum_extra::extract::Form(f): axum_extra::extract::Form<ReviewersForm>,
17102×
) -> Result<Response, Error> {
17112×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
17122×
    let me = require(&viewer, &shell)?;
17132×
    csrf(&viewer, &f.csrf_token, &shell)?;
17142×
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
17152×
    let p = pdb::get(&app.db, row.id, number)
17162×
        .await
17172×
        .map_err(|e| Error::from_db(shell.clone(), e))?
17182×
        .ok_or_else(|| Error::not_found(shell.clone()))?;
17192×
    if Some(me.account.id) != p.author_id && role < Role::Write {
17200×
        return Err(Error::not_found(shell));
17212×
    }
17222×
    let names: Vec<String> = f.username.iter().map(|n| n.trim().trim_start_matches('@').to_string()).filter(|n| !n.is_empty()).collect();
17232×
    let current = pdb::review_requests(&app.db, p.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1724
    // Withdrawn: those named (remove), or those the menu left out (set).
17252×
    let withdraw: Vec<String> = match f.action.as_str() {
17262×
        "remove" => names.clone(),
17272×
        "set" => current.iter().filter(|c| !names.iter().any(|n| n.eq_ignore_ascii_case(c))).cloned().collect(),
17280×
        _ => Vec::new(),
1729
    };
17302×
    for who in &withdraw {
17311×
        if let Some(a) = accounts::account_by_name(&app.db, who).await.ok().flatten() {
17321×
            if pdb::unrequest_review(&app.db, p.id, a.id).await.map_err(|e| Error::from_db(shell.clone(), e))? {
17331×
                pdb::add_event(&app.db, p.id, &me.account.username, "review request withdrawn", &a.username).await.ok();
17340×
            }
17350×
        }
1736
    }
17372×
    let ask: Vec<String> = match f.action.as_str() {
17382×
        "add" => names,
17392×
        "set" => names.into_iter().filter(|n| !current.iter().any(|c| c.eq_ignore_ascii_case(n))).collect(),
17400×
        _ => return Ok(back(&owner, &name, number, "conversation", None, None)),
1741
    };
17422×
    if ask.is_empty() {
17431×
        return Ok(back(&owner, &name, number, "conversation", None, None));
17441×
    }
17451×
    match request_reviewers(&app, &row, &p, &me.account, &ask).await {
17461×
        Ok(asked) if asked.is_empty() => Ok(back(&owner, &name, number, "conversation", Some("Already asked.".into()), None)),
17471×
        Ok(asked) => Ok(back(&owner, &name, number, "conversation", Some(format!("Asked {} to review.", asked.join(", "))), None)),
17480×
        Err(e) => Ok(back(&owner, &name, number, "conversation", None, Some(e))),
1749
    }
17502×
}
1751
1752
#[derive(Deserialize)]
1753
pub struct AssistantForm {
1754
    #[serde(rename = "_csrf")]
1755
    csrf_token: String,
1756
    assistant: String,
1757
    /// `summary` asks for a summary comment (oxygenhub#190); otherwise a review.
1758
    #[serde(default)]
1759
    task: String,
1760
}
1761
1762
/// `POST /{owner}/{name}/proposal/{number}/assistant`: the author or a
1763
/// writer asks an AI assistant to review (hub spec §13.4). The answer
1764
/// comes later as a review via the assistant.
17654×
pub async fn assistant(
17664×
    State(app): State<Shared>,
17674×
    viewer: Viewer,
17684×
    Path((owner, name, number)): Path<(String, String, i64)>,
17694×
    Form(f): Form<AssistantForm>,
17704×
) -> Result<Response, Error> {
17714×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
17724×
    let me = require(&viewer, &shell)?;
17733×
    csrf(&viewer, &f.csrf_token, &shell)?;
17743×
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
17753×
    super::writable(&row, &shell)?;
17763×
    let p = pdb::get(&app.db, row.id, number)
17773×
        .await
17783×
        .map_err(|e| Error::from_db(shell.clone(), e))?
17793×
        .ok_or_else(|| Error::not_found(shell.clone()))?;
17803×
    if Some(me.account.id) != p.author_id && role < Role::Write {
17810×
        return Err(Error::not_found(shell));
17823×
    }
17833×
    if !p.is_open() {
17840×
        return Ok(back(&owner, &name, number, "conversation", None, Some("The proposal is not open.".into())));
17853×
    }
17863×
    let Some(a) = crate::assistants::enabled_for_repo(&app.db, &row)
17873×
        .await
17883×
        .map_err(|e| Error::from_db(shell.clone(), e))?
17893×
        .into_iter()
17903×
        .find(|a| a.name.eq_ignore_ascii_case(f.assistant.trim()))
1791
    else {
17921×
        return Ok(back(&owner, &name, number, "conversation", None, Some(format!("No assistant called {} here.", f.assistant.trim()))));
1793
    };
17942×
    let who = a.name.clone();
17952×
    let summary = f.task == "summary";
17962×
    let asked = if summary {
17971×
        crate::assistants::request_summary(&app, &row, &p, &me.account, a).await
1798
    } else {
17991×
        crate::assistants::request_review(&app, &row, &p, &me.account, a).await
1800
    };
18012×
    match asked {
18022×
        Ok(()) => Ok(back(
18032×
            &owner,
18042×
            &name,
18052×
            number,
18062×
            "conversation",
18072×
            Some(if summary {
18081×
                format!("Asked {who} for a summary; its comment appears here when it comes.")
1809
            } else {
18101×
                format!("Asked {who} to review; its answer appears here when it comes.")
1811
            }),
18122×
            None,
1813
        )),
18140×
        Err(e) => Ok(back(&owner, &name, number, "conversation", None, Some(format!("Not asked: {e}.")))),
1815
    }
18164×
}
1817
1818
/// The assistants a repository may use, by name.
181980×
async fn assistant_names(app: &Shared, row: &db::repos::RepoRow) -> Vec<String> {
182080×
    crate::assistants::enabled_for_repo(&app.db, row).await.unwrap_or_default().into_iter().map(|a| a.name).collect()
182180×
}
1822
1823
/// The changes and the file diffs a proposal from `source` into `target`
1824
/// would carry.
18252×
async fn preview_diff(
18262×
    app: &Shared,
18272×
    repo: &RepoName,
18282×
    target: &str,
18292×
    source: &str,
18302×
) -> Result<(Vec<ChangeView>, Vec<oxy_core::repo::FileDiff>), crate::repos::RepoError> {
18312×
    let Some((fork, bookmark)) = parse_source(app, repo, source) else { return Ok((vec![], vec![])) };
18322×
    let target = target.to_string();
18332×
    let fork_dir = fork.as_ref().map(|f| app.repos.dir(f));
18342×
    app.repos
18352×
        .read(repo, move |r, cache| {
18362×
            let projection = match &fork_dir {
18370×
                Some(dir) => {
18380×
                    let fork_repo = oxy_core::repo::Repo::open(dir)?;
18390×
                    let heads = fork_repo.bookmarks()?.into_iter().find(|b| b.name == bookmark).map(|b| b.heads).unwrap_or_default();
18400×
                    pl::project(r, cache, &target, Source::Fork { fork: &fork_repo, heads })?
1841
                }
1842
                None => {
18432×
                    let heads = r.bookmarks()?.into_iter().find(|b| b.name == bookmark).map(|b| b.heads).unwrap_or_default();
18442×
                    pl::project(r, cache, &target, Source::Bookmark(heads))?
1845
                }
1846
            };
18472×
            Ok((projection.changes, projection.files))
18482×
        })
18492×
        .await
18502×
}
1851
1852
/// `POST /{owner}/{name}/proposals/new/draft`: an assistant drafts the
1853
/// description from the changes the proposal would add; the form comes
1854
/// back with the draft to edit (hub spec §13.4).
18552×
pub async fn new_draft(
18562×
    State(app): State<Shared>,
18572×
    viewer: Viewer,
18582×
    Path((owner, name)): Path<(String, String)>,
18592×
    Form(f): Form<NewForm>,
18602×
) -> Result<Page<NewProposalPage>, Error> {
18612×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposals/new"), &viewer);
18622×
    let me = require(&viewer, &shell)?;
18632×
    csrf(&viewer, &f.csrf_token, &shell)?;
18642×
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
18652×
    super::writable(&row, &shell)?;
18662×
    let bookmarks =
18672×
        app.repos.read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::<Vec<_>>())).await.unwrap_or_default();
18682×
    let (preview, conflicted) = preview_for(&app, &repo, &f.target, &f.source).await.unwrap_or((vec![], false));
18692×
    let assistants = if preview.is_empty() { vec![] } else { assistant_names(&app, &row).await };
18702×
    let mut page = NewProposalPage {
18712×
        shell,
18722×
        repo: RepoShell::new(&app, &row, "proposals").with_role(role),
18732×
        bookmarks,
18742×
        source: f.source.clone(),
18752×
        target: f.target.clone(),
18762×
        error: None,
18772×
        preview,
18782×
        conflicted,
18792×
        title: f.title.clone(),
18802×
        body: f.body.clone(),
18812×
        assistants,
18822×
    };
18832×
    let assistant = crate::assistants::enabled_for_repo(&app.db, &row)
18842×
        .await
18852×
        .unwrap_or_default()
18862×
        .into_iter()
18872×
        .find(|a| a.name.eq_ignore_ascii_case(f.assistant.trim()));
18882×
    let Some(a) = assistant else {
18890×
        page.error = Some(format!("No assistant called {} here.", f.assistant.trim()));
18900×
        return Ok(Page(page));
1891
    };
18922×
    if page.preview.is_empty() {
18930×
        page.error = Some("Nothing to describe: the source adds nothing to the target.".into());
18940×
        return Ok(Page(page));
18952×
    }
18962×
    let (changes, diffs) = match preview_diff(&app, &repo, &f.target, &f.source).await {
18972×
        Ok(x) => x,
18980×
        Err(e) => {
18990×
            page.error = Some(e.to_string());
19000×
            return Ok(Page(page));
1901
        }
1902
    };
19032×
    let d =
19042×
        crate::assistants::Draft { slot: -row.id, title: &f.title, target: &f.target, changes: &changes, diffs: &diffs, with_title: true };
19052×
    match crate::assistants::draft_description(&app, &row, &me.account, &a, d).await {
19062×
        Ok(text) => {
19072×
            let (title, body) = crate::assistants::split_title(&text);
19082×
            page.body = body;
1909
            // The title too, unless the person wrote one (#316): the page's
1910
            // own suggestion, a single change's title, counts as theirs not.
19112×
            let suggested = if page.preview.len() == 1 { page.preview[0].title.clone() } else { String::new() };
19122×
            if let Some(t) = title.filter(|_| page.title.trim().is_empty() || page.title.trim() == suggested.trim()) {
19130×
                page.title = t;
19142×
            }
1915
        }
19160×
        Err(why) => page.error = Some(format!("{} did not answer: {why}.", a.name)),
1917
    }
19182×
    Ok(Page(page))
19192×
}
1920
1921
#[derive(Deserialize)]
1922
pub struct DraftForm {
1923
    #[serde(rename = "_csrf")]
1924
    csrf_token: String,
1925
    assistant: String,
1926
    #[serde(default)]
1927
    title: String,
1928
}
1929
1930
/// `POST /{owner}/{name}/proposal/{number}/draft`: an assistant drafts the
1931
/// description of an open proposal from its changes; the editor opens
1932
/// with the draft, nothing is saved (hub spec §13.4).
19331×
pub async fn draft(
19341×
    State(app): State<Shared>,
19351×
    viewer: Viewer,
19361×
    Path((owner, name, number)): Path<(String, String, i64)>,
19371×
    Form(f): Form<DraftForm>,
19381×
) -> Result<Response, Error> {
19391×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
19401×
    let me = require(&viewer, &shell)?;
19411×
    csrf(&viewer, &f.csrf_token, &shell)?;
19421×
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
19431×
    super::writable(&row, &shell)?;
19441×
    let p = pdb::get(&app.db, row.id, number)
19451×
        .await
19461×
        .map_err(|e| Error::from_db(shell.clone(), e))?
19471×
        .ok_or_else(|| Error::not_found(shell.clone()))?;
19481×
    if Some(me.account.id) != p.author_id && role < Role::Maintain {
19490×
        return Err(Error::not_found(shell));
19501×
    }
19511×
    let Some(a) = crate::assistants::enabled_for_repo(&app.db, &row)
19521×
        .await
19531×
        .map_err(|e| Error::from_db(shell.clone(), e))?
19541×
        .into_iter()
19551×
        .find(|a| a.name.eq_ignore_ascii_case(f.assistant.trim()))
1956
    else {
19570×
        return Ok(back(&owner, &name, number, "conversation", None, Some(format!("No assistant called {} here.", f.assistant.trim()))));
1958
    };
19591×
    let loaded = crate::web::proposals::load(&app, &repo, p.clone()).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
19601×
    let title = if f.title.trim().is_empty() { p.title.clone() } else { f.title.clone() };
19611×
    let d = crate::assistants::Draft {
19621×
        slot: p.id,
19631×
        title: &title,
19641×
        target: &p.target,
19651×
        changes: &loaded.changes,
19661×
        diffs: &loaded.diffs,
19671×
        with_title: false,
19681×
    };
19691×
    let draft = crate::assistants::draft_description(&app, &row, &me.account, &a, d).await;
19701×
    match draft {
19711×
        Ok(text) => {
19721×
            let mut page = detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), None, None).await?;
19731×
            page.0.draft = Some(text);
19741×
            Ok(page.into_response())
1975
        }
19760×
        Err(why) => Ok(back(&owner, &name, number, "conversation", None, Some(format!("{} did not answer: {why}.", a.name)))),
1977
    }
19781×
}
1979
1980
/// A draft being written, as server-sent events for the page: `part` with
1981
/// each piece of text, then `done` with the whole draft or `error` with
1982
/// why there is none (each a JSON string). The response starts at once, so
1983
/// a draft that takes minutes is not cut off by a proxy waiting for it
1984
/// (oxygenhub#248).
1985
type DraftEvents = axum::response::Sse<
1986
    axum::response::sse::KeepAliveStream<
1987
        tokio_stream::wrappers::ReceiverStream<Result<axum::response::sse::Event, std::convert::Infallible>>,
1988
    >,
1989
>;
1990
19912×
fn draft_events(rx: tokio::sync::mpsc::Receiver<Result<axum::response::sse::Event, std::convert::Infallible>>) -> DraftEvents {
19922×
    axum::response::Sse::new(tokio_stream::wrappers::ReceiverStream::new(rx))
19932×
        .keep_alive(axum::response::sse::KeepAlive::new().interval(std::time::Duration::from_secs(15)))
19942×
}
1995
19963×
fn draft_event(kind: &str, text: &str) -> Result<axum::response::sse::Event, std::convert::Infallible> {
19973×
    Ok(axum::response::sse::Event::default().event(kind).data(serde_json::to_string(text).unwrap_or_default()))
19983×
}
1999
2000
/// Only an error, for a draft that cannot start.
20011×
fn draft_refused(why: String) -> DraftEvents {
20021×
    let (tx, rx) = tokio::sync::mpsc::channel(1);
20031×
    let _ = tx.try_send(draft_event("error", &why));
20041×
    draft_events(rx)
20051×
}
2006
2007
/// What a draft is written from, owned so a task of its own can have it.
2008
struct DraftJob {
2009
    row: db::repos::RepoRow,
2010
    by: accounts::Account,
2011
    a: crate::assistants::Assistant,
2012
    slot: i64,
2013
    title: String,
2014
    target: String,
2015
    changes: Vec<ChangeView>,
2016
    diffs: Vec<oxy_core::repo::FileDiff>,
2017
    /// Not yet opened: the draft proposes a title too (#316).
2018
    with_title: bool,
2019
}
2020
2021
/// Write the draft in a task of its own, its parts to the page.
20221×
fn draft_in_background(app: Shared, job: DraftJob) -> DraftEvents {
20231×
    let (tx, rx) = tokio::sync::mpsc::channel(64);
20241×
    tokio::spawn(async move {
20251×
        let DraftJob { row, by, a, slot, title, target, changes, diffs, with_title } = job;
20261×
        let d = crate::assistants::Draft { slot, title: &title, target: &target, changes: &changes, diffs: &diffs, with_title };
20271×
        let parts = tx.clone();
20281×
        let result = crate::assistants::draft_description_streaming(&app, &row, &by, &a, d, move |p: &str| {
2029
            // A page that went away stops nothing: the draft finishes.
20301×
            let _ = parts.try_send(draft_event("part", p));
20311×
        })
20321×
        .await;
20331×
        let last = match result {
20340×
            Ok(text) if with_title => {
2035
                // The title first, for the page to put in its field (#316).
20360×
                let (t, body) = crate::assistants::split_title(&text);
20370×
                if let Some(t) = t {
20380×
                    let _ = tx.send(draft_event("title", &t)).await;
20390×
                }
20400×
                draft_event("done", &body)
2041
            }
20421×
            Ok(text) => draft_event("done", text.trim()),
20430×
            Err(why) => draft_event("error", &format!("{} did not answer: {why}.", a.name)),
2044
        };
20451×
        let _ = tx.send(last).await;
20461×
    });
20471×
    draft_events(rx)
20481×
}
2049
2050
/// `POST /{owner}/{name}/proposal/{number}/draft/stream`: [`draft`], with
2051
/// the draft streamed to the page as it is written.
20522×
pub async fn draft_stream(
20532×
    State(app): State<Shared>,
20542×
    viewer: Viewer,
20552×
    Path((owner, name, number)): Path<(String, String, i64)>,
20562×
    Form(f): Form<DraftForm>,
20572×
) -> Result<DraftEvents, Error> {
20582×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
20592×
    let me = require(&viewer, &shell)?;
20602×
    csrf(&viewer, &f.csrf_token, &shell)?;
20612×
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
20622×
    super::writable(&row, &shell)?;
20632×
    let p = pdb::get(&app.db, row.id, number)
20642×
        .await
20652×
        .map_err(|e| Error::from_db(shell.clone(), e))?
20662×
        .ok_or_else(|| Error::not_found(shell.clone()))?;
20672×
    if Some(me.account.id) != p.author_id && role < Role::Maintain {
20680×
        return Err(Error::not_found(shell));
20692×
    }
20702×
    let Some(a) = crate::assistants::enabled_for_repo(&app.db, &row)
20712×
        .await
20722×
        .map_err(|e| Error::from_db(shell.clone(), e))?
20732×
        .into_iter()
20742×
        .find(|a| a.name.eq_ignore_ascii_case(f.assistant.trim()))
2075
    else {
20761×
        return Ok(draft_refused(format!("No assistant called {} here.", f.assistant.trim())));
2077
    };
20781×
    let loaded = load(&app, &repo, p.clone()).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
20791×
    let title = if f.title.trim().is_empty() { p.title.clone() } else { f.title.clone() };
20801×
    let job = DraftJob {
20811×
        row,
20821×
        by: me.account.clone(),
20831×
        a,
20841×
        slot: p.id,
20851×
        title,
20861×
        target: p.target.clone(),
20871×
        changes: loaded.changes,
20881×
        diffs: loaded.diffs,
20891×
        with_title: false,
20901×
    };
20911×
    Ok(draft_in_background(app.clone(), job))
20922×
}
2093
2094
/// `POST /{owner}/{name}/proposals/new/draft/stream`: [`new_draft`], with
2095
/// the draft streamed to the page as it is written.
20960×
pub async fn new_draft_stream(
20970×
    State(app): State<Shared>,
20980×
    viewer: Viewer,
20990×
    Path((owner, name)): Path<(String, String)>,
21000×
    Form(f): Form<NewForm>,
21010×
) -> Result<DraftEvents, Error> {
21020×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposals/new"), &viewer);
21030×
    let me = require(&viewer, &shell)?;
21040×
    csrf(&viewer, &f.csrf_token, &shell)?;
21050×
    let (repo, row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
21060×
    super::writable(&row, &shell)?;
21070×
    let Some(a) = crate::assistants::enabled_for_repo(&app.db, &row)
21080×
        .await
21090×
        .unwrap_or_default()
21100×
        .into_iter()
21110×
        .find(|a| a.name.eq_ignore_ascii_case(f.assistant.trim()))
2112
    else {
21130×
        return Ok(draft_refused(format!("No assistant called {} here.", f.assistant.trim())));
2114
    };
21150×
    let (changes, diffs) = match preview_diff(&app, &repo, &f.target, &f.source).await {
21160×
        Ok(x) => x,
21170×
        Err(e) => return Ok(draft_refused(e.to_string())),
2118
    };
21190×
    if changes.is_empty() {
21200×
        return Ok(draft_refused("Nothing to describe: the source adds nothing to the target.".into()));
21210×
    }
21220×
    let job = DraftJob {
21230×
        slot: -row.id,
21240×
        row,
21250×
        by: me.account.clone(),
21260×
        a,
21270×
        title: f.title.clone(),
21280×
        target: f.target.clone(),
21290×
        changes,
21300×
        diffs,
21310×
        with_title: true,
21320×
    };
21330×
    Ok(draft_in_background(app.clone(), job))
21340×
}