OXYGENHUB
# 27322cd2755a oxygenhub / crates / hub / src / web / proposals.rs
93.8 KiB · Rust · last changed Resolve a proposal's conflicts on the web: the hub brings the target into the source bookmark, as oxy merge would, and opens the resolver; the proposal follows the resolution (closes #337) 8 days ago source blame history raw
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 {
40
    fn labels_of(&self, id: &i64) -> &[db::issues::Label] {
41
        self.labels.get(id).map(Vec::as_slice).unwrap_or(&[])
42
    }
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
56
pub async fn list(
57
    State(app): State<Shared>,
58
    viewer: Viewer,
59
    Path((owner, name)): Path<(String, String)>,
60
    Query(q): Query<ListQuery>,
61
) -> Result<Page<ProposalsPage>, Error> {
62
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposals"), &viewer);
63
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
64
    let all = pdb::list(&app.db, row.id, &[]).await.map_err(|e| Error::from_db(shell.clone(), e))?;
65
    let counts = (
66
        all.iter().filter(|p| p.is_open()).count(),
67
        all.iter().filter(|p| p.state == "landed").count(),
68
        all.iter().filter(|p| p.state == "closed").count(),
69
    );
70
    let filter = q.state.unwrap_or_else(|| "open".into());
71
    let mut labels: std::collections::HashMap<i64, Vec<db::issues::Label>> = std::collections::HashMap::new();
72
    for (id, l) in db::issues::labels_by_proposal(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))? {
73
        labels.entry(id).or_default().push(l);
74
    }
75
    // `author:bob label:ui target:main words…`
76
    let qtext = q.q.clone().unwrap_or_default();
77
    let (mut authors, mut wanted, mut targets, mut words) = (vec![], vec![], vec![], vec![]);
78
    for tok in qtext.split_whitespace() {
79
        match tok.split_once(':') {
80
            Some(("author", v)) => authors.push(v.to_lowercase()),
81
            Some(("label", v)) => wanted.push(v.to_lowercase()),
82
            Some(("target", v)) => targets.push(v.to_string()),
83
            _ => words.push(tok.to_lowercase()),
84
        }
85
    }
86
    let mut rows: Vec<pdb::ProposalRow> = all
87
        .into_iter()
88
        .filter(|p| match filter.as_str() {
89
            "open" => p.is_open(),
90
            "landed" => p.state == "landed",
91
            "closed" => p.state == "closed",
92
            _ => true,
93
        })
94
        .filter(|p| authors.is_empty() || authors.contains(&p.author.to_lowercase()))
95
        .filter(|p| targets.is_empty() || targets.contains(&p.target))
96
        .filter(|p| wanted.iter().all(|w| labels.get(&p.id).is_some_and(|ls| ls.iter().any(|l| l.name.to_lowercase() == *w))))
97
        .filter(|p| words.iter().all(|w| p.title.to_lowercase().contains(w) || p.body.to_lowercase().contains(w)))
98
        .collect();
99
    let sort = q.sort.filter(|s| SORTS.iter().any(|(k, _)| k == s)).unwrap_or_else(|| "updated".into());
100
    match sort.as_str() {
101
        "newest" => rows.sort_by_key(|p| std::cmp::Reverse(p.number)),
102
        "oldest" => rows.sort_by_key(|p| p.number),
103
        _ => rows.sort_by_key(|p| std::cmp::Reverse(p.updated_ms)),
104
    }
105
    let pager = super::Pager::new(rows.len(), q.page, &[("state", &filter), ("q", &qtext), ("sort", &sort)]);
106
    let rows = pager.slice(rows);
107
    let bookmarks = app
108
        .repos
109
        .read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::<Vec<_>>()))
110
        .await
111
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
112
    Ok(Page(ProposalsPage {
113
        shell,
114
        repo: RepoShell::new(&app, &row, "proposals").with_role(role),
115
        rows,
116
        filter,
117
        counts,
118
        bookmarks,
119
        q: qtext,
120
        sort,
121
        pager,
122
        labels,
123
    }))
124
}
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.
157
fn parse_source(app: &Shared, repo: &RepoName, source: &str) -> Option<(Option<RepoName>, String)> {
158
    let plain = |b: &str| !b.is_empty() && !b.chars().any(|c| c.is_control() || c == ':');
159
    if let Some((slug, bookmark)) = source.split_once(':') {
160
        let fork = RepoName::parse(slug)?;
161
        if !plain(bookmark) {
162
            return None;
163
        }
164
        if fork == *repo {
165
            return Some((None, bookmark.to_string()));
166
        }
167
        if !app.repos.exists(&fork) {
168
            return None;
169
        }
170
        return Some((Some(fork), bookmark.to_string()));
171
    }
172
    plain(source).then(|| (None, source.to_string()))
173
}
174
175
async fn preview_for(
176
    app: &Shared,
177
    repo: &RepoName,
178
    target: &str,
179
    source: &str,
180
) -> Result<(Vec<ChangeView>, bool), crate::repos::RepoError> {
181
    let Some((fork, bookmark)) = parse_source(app, repo, source) else { return Ok((vec![], false)) };
182
    let target = target.to_string();
183
    let fork_dir = fork.as_ref().map(|f| app.repos.dir(f));
184
    app.repos
185
        .read(repo, move |r, cache| {
186
            let projection = match &fork_dir {
187
                Some(dir) => {
188
                    let fork_repo = oxy_core::repo::Repo::open(dir)?;
189
                    let heads = fork_repo.bookmarks()?.into_iter().find(|b| b.name == bookmark).map(|b| b.heads).unwrap_or_default();
190
                    pl::project(r, cache, &target, Source::Fork { fork: &fork_repo, heads })?
191
                }
192
                None => {
193
                    let heads = r.bookmarks()?.into_iter().find(|b| b.name == bookmark).map(|b| b.heads).unwrap_or_default();
194
                    pl::project(r, cache, &target, Source::Bookmark(heads))?
195
                }
196
            };
197
            Ok((projection.changes, projection.conflicted))
198
        })
199
        .await
200
}
201
202
pub async fn new_form(
203
    State(app): State<Shared>,
204
    viewer: Viewer,
205
    Path((owner, name)): Path<(String, String)>,
206
    Query(q): Query<NewQuery>,
207
) -> Result<Page<NewProposalPage>, Error> {
208
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposals/new"), &viewer);
209
    require(&viewer, &shell)?;
210
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
211
    let bookmarks = app
212
        .repos
213
        .read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::<Vec<_>>()))
214
        .await
215
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
216
    let target = q.target.unwrap_or_else(|| row.default_bookmark.clone());
217
    let source = q.source.unwrap_or_default();
218
    let (preview, conflicted) =
219
        if source.is_empty() { (vec![], false) } else { preview_for(&app, &repo, &target, &source).await.unwrap_or((vec![], false)) };
220
    let assistants = if preview.is_empty() { vec![] } else { assistant_names(&app, &row).await };
221
    Ok(Page(NewProposalPage {
222
        shell,
223
        repo: RepoShell::new(&app, &row, "proposals").with_role(role),
224
        bookmarks,
225
        source,
226
        target,
227
        error: None,
228
        preview,
229
        conflicted,
230
        title: String::new(),
231
        body: String::new(),
232
        assistants,
233
    }))
234
}
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)]
254
pub async fn open_proposal(
255
    app: &Shared,
256
    repo: &RepoName,
257
    row: &db::repos::RepoRow,
258
    me: &accounts::Account,
259
    title: &str,
260
    body: &str,
261
    target: &str,
262
    source: &str,
263
    draft: bool,
264
) -> Result<pdb::ProposalRow, String> {
265
    if title.trim().is_empty() {
266
        return Err("Give the proposal a title.".into());
267
    }
268
    let bookmarks =
269
        app.repos.read(repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::<Vec<_>>())).await.map_err(|e| e.to_string())?;
270
    if !bookmarks.iter().any(|b| b == target) {
271
        return Err("Pick a target bookmark of this repository.".into());
272
    }
273
    let Some((fork, bookmark)) = parse_source(app, repo, source.trim()) else {
274
        return Err("The source is a bookmark of this repository, or owner/name:bookmark of a fork.".into());
275
    };
276
    if fork.is_none() && !bookmarks.contains(&bookmark) {
277
        return Err(format!("There is no bookmark {bookmark} in this repository."));
278
    }
279
    if fork.is_none() && bookmark == target {
280
        return Err("The source and the target are the same bookmark.".into());
281
    }
282
    let (preview, _) = preview_for(app, repo, target, source.trim()).await.map_err(|e| e.to_string())?;
283
    if preview.is_empty() {
284
        return Err("The source adds nothing to the target.".into());
285
    }
286
    let source_repo_id = match &fork {
287
        Some(fk) => db::repos::get(&app.db, &fk.owner, &fk.name).await.map_err(|e| e.to_string())?.map(|r| r.id),
288
        None => None,
289
    };
290
    let created = pdb::create(
291
        &app.db,
292
        pdb::NewProposal {
293
            repo_id: row.id,
294
            title: title.trim(),
295
            body: body.trim(),
296
            author_id: Some(me.id),
297
            author: &me.username,
298
            draft,
299
            target,
300
            source_kind: if fork.is_some() { "fork" } else { "bookmark" },
301
            source_repo_id,
302
            source_bookmark: &bookmark,
303
            source_changes: "",
304
        },
305
    )
306
    .await
307
    .map_err(|e| e.to_string())?;
308
    pdb::add_event(&app.db, created.id, &me.username, "opened", "").await.ok();
309
    accounts::audit(&app.db, Some(me), "proposal.opened", &format!("{}#{}", row.slug(), created.number), title).await.ok();
310
    db::notify::index(&app.db, "proposal", row.id, created.id, created.number, &created.title, &created.body).await.ok();
311
    let url = format!("/{}/proposal/{}", row.slug(), created.number);
312
    crate::notify::deliver(
313
        app,
314
        crate::notify::Event {
315
            kind: "proposal.opened",
316
            title: format!("{}#{}: {}", row.slug(), created.number, created.title),
317
            url,
318
            actor: me,
319
            repo_id: row.id,
320
            thread: Some(("proposal", created.id)),
321
            mention_text: body,
322
            direct: vec![],
323
        },
324
    )
325
    .await;
326
    crate::hooks::emit(
327
        app,
328
        row,
329
        "proposal.opened",
330
        &me.username,
331
        serde_json::json!({ "number": created.number, "title": created.title, "target": target, "source": source }),
332
    )
333
    .await;
334
    Ok(created)
335
}
336
337
pub async fn create(
338
    State(app): State<Shared>,
339
    viewer: Viewer,
340
    Path((owner, name)): Path<(String, String)>,
341
    Form(f): Form<NewForm>,
342
) -> Result<Response, Error> {
343
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposals/new"), &viewer);
344
    let me = require(&viewer, &shell)?;
345
    csrf(&viewer, &f.csrf_token, &shell)?;
346
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
347
    super::writable(&row, &shell)?;
348
    match open_proposal(&app, &repo, &row, &me.account, &f.title, &f.body, &f.target, &f.source, f.draft.is_some()).await {
349
        Ok(created) => Ok(Redirect::to(&format!("/{owner}/{name}/proposal/{}", created.number)).into_response()),
350
        Err(msg) => {
351
            let bookmarks =
352
                app.repos.read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::<Vec<_>>())).await.unwrap_or_default();
353
            Ok(Page(NewProposalPage {
354
                shell,
355
                repo: RepoShell::new(&app, &row, "proposals").with_role(role),
356
                bookmarks,
357
                source: f.source.clone(),
358
                target: f.target.clone(),
359
                error: Some(msg),
360
                preview: vec![],
361
                conflicted: false,
362
                title: f.title.clone(),
363
                body: f.body.clone(),
364
                assistants: vec![],
365
            })
366
            .into_response())
367
        }
368
    }
369
}
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
    /// A landed proposal's bookmark's changes that are not on its target:
423
    /// pushed after it landed, so no proposal carries them (#329).
424
    pub since_landing: Vec<ChangeView>,
425
}
426
427
/// Compute the projection and everything derived from it.
428
pub async fn load(app: &Shared, repo: &RepoName, row: pdb::ProposalRow) -> Result<Loaded, crate::repos::RepoError> {
429
    load_with(app, repo, row, true).await
430
}
431
432
/// [`load`] without the rendered diff and the line-to-atom map, which only
433
/// the proposal page needs: for lists, the API, landing and the checks that
434
/// follow a push. On a repository with large files those two were most of
435
/// a list's time (metalgear2's took 3-4 s per call).
436
pub async fn load_summary(app: &Shared, repo: &RepoName, row: pdb::ProposalRow) -> Result<Loaded, crate::repos::RepoError> {
437
    load_with(app, repo, row, false).await
438
}
439
440
/// What the API says of a proposal ([`to_api`]): an open one's projection
441
/// now, as [`load_summary`]; a landed or closed one's as recorded, from its
442
/// row, without projecting it again (#331). A list of a few hundred landed
443
/// proposals projected each one and took minutes.
444
pub async fn load_for_api(app: &Shared, repo: &RepoName, row: pdb::ProposalRow) -> Result<Loaded, crate::repos::RepoError> {
445
    if row.is_open() {
446
        return load_summary(app, repo, row).await;
447
    }
448
    let hashes: Vec<ChangeHash> = row.projected_changes().iter().filter_map(|h| ChangeHash::from_hex(h)).collect();
449
    let heads: Vec<ChangeHash> = row.projected_heads().iter().filter_map(|h| ChangeHash::from_hex(h)).collect();
450
    let h2 = hashes.clone();
451
    let (changes, sealing_keys) = app
452
        .repos
453
        .read(repo, move |r, _| {
454
            let labels = HashMap::new();
455
            let changes: Vec<ChangeView> =
456
                h2.iter().filter_map(|h| Some(view::change_view_pub(r, h, &r.store().get_change_meta(h).ok()?, false, &labels))).collect();
457
            let sealing = pl::sealing_keys(r, &h2)?.into_iter().map(|k| k.to_hex()).collect();
458
            Ok((changes, sealing))
459
        })
460
        .await?;
461
    Ok(Loaded {
462
        changes,
463
        heads,
464
        target_heads: Vec::new(),
465
        state: row.projected_state.clone(),
466
        target_state: row.projected_target.clone(),
467
        conflicted: row.conflicted,
468
        conflicts: Vec::new(),
469
        union: None,
470
        files: Vec::new(),
471
        diffs: Vec::new(),
472
        bases: BTreeMap::new(),
473
        atoms: HashMap::new(),
474
        sealing_keys,
475
        touched: Vec::new(),
476
        owners: Vec::new(),
477
        landed_already: row.state == "landed",
478
        source_has_changes: !hashes.is_empty(),
479
        since_landing: Vec::new(),
480
        hashes,
481
        row,
482
    })
483
}
484
485
async fn load_with(app: &Shared, repo: &RepoName, row: pdb::ProposalRow, render: bool) -> Result<Loaded, crate::repos::RepoError> {
486
    let fork_dir = match (row.source_kind.as_str(), row.source_repo_id) {
487
        ("fork", Some(id)) => {
488
            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))
489
        }
490
        _ => None,
491
    };
492
    let r2 = row.clone();
493
    let loaded = app
494
        .repos
495
        .read(repo, move |r, cache| {
496
            let source = match (r2.source_kind.as_str(), &fork_dir) {
497
                ("changes", _) => Source::Changes(r2.source_changes().iter().filter_map(|h| ChangeHash::from_hex(h)).collect()),
498
                ("fork", Some(dir)) => {
499
                    let fork_repo = oxy_core::repo::Repo::open(dir)?;
500
                    let heads =
501
                        fork_repo.bookmarks()?.into_iter().find(|b| b.name == r2.source_bookmark).map(|b| b.heads).unwrap_or_default();
502
                    // Bring the fork's changes over now, so the projection below can read them.
503
                    let fork_state = fork_repo.load_state(&heads)?;
504
                    let missing: Vec<ChangeHash> = fork_state.changes().map(|(h, _)| *h).filter(|h| !r.store().has(h)).collect();
505
                    if !missing.is_empty() {
506
                        r.import_bundle(&fork_repo.bundle_for(&missing)?, r.current_policy()?.is_some())?;
507
                    }
508
                    Source::Changes(heads)
509
                }
510
                _ => {
511
                    let heads = r.bookmarks()?.into_iter().find(|b| b.name == r2.source_bookmark).map(|b| b.heads).unwrap_or_default();
512
                    Source::Bookmark(heads)
513
                }
514
            };
515
            let source_has_changes = match &source {
516
                Source::Changes(h) | Source::Bookmark(h) | Source::Fork { heads: h, .. } => !h.is_empty(),
517
            };
518
            let p: Projection = pl::project(r, cache, &r2.target, source)?;
519
            let target_loaded = cache.load(r, &p.target_heads)?;
520
            let files = if render { render_files(&p.files, &p.bases) } else { Vec::new() };
521
            let mut atoms = HashMap::new();
522
            for f in &files {
523
                // Each file's live lines once, not once per line.
524
                let Some(live) = pl::atoms_of(&p.loaded, &f.path) else { continue };
525
                for l in &f.lines {
526
                    if let Some((h, i)) = l.new_no.and_then(|n| live.get((n as usize).checked_sub(1)?)) {
527
                        atoms.insert((f.path.clone(), l.new_no.unwrap_or(0) as usize), (h.to_hex(), *i));
528
                    }
529
                }
530
            }
531
            let touched: Vec<String> = p.files.iter().map(|f| f.path.clone()).collect();
532
            let owners = pl::owners_for(&target_loaded, &touched);
533
            let sealing = pl::sealing_keys(r, &p.hashes)?.into_iter().map(|k| k.to_hex()).collect();
534
            let landed_already = !p.hashes.is_empty() && p.hashes.iter().all(|h| target_loaded.state.contains(h));
535
            // A landed proposal's changes are in its target now: it shows
536
            // the ones it landed, as recorded when it landed (none when it
537
            // landed by a push before any projection recorded them).
538
            // Whatever its bookmark has that the target lacks cannot be
539
            // part of what landed: it came after, and is the page's to point
540
            // at, not the proposal's (#329).
541
            let recorded: Vec<ChangeHash> = if r2.state == "landed" {
542
                r2.projected_changes().iter().filter_map(|h| ChangeHash::from_hex(h)).collect()
543
            } else {
544
                Vec::new()
545
            };
546
            let (changes, hashes, since_landing) = if r2.state == "landed" {
547
                let labels = HashMap::new();
548
                let changes = recorded
549
                    .iter()
550
                    .filter_map(|h| Some(view::change_view_pub(r, h, &r.store().get_change_meta(h).ok()?, false, &labels)))
551
                    .collect();
552
                let since: Vec<ChangeView> = p.changes.into_iter().filter(|c| !recorded.iter().any(|h| h.to_hex() == c.hash)).collect();
553
                (changes, recorded, since)
554
            } else {
555
                (p.changes, p.hashes, Vec::new())
556
            };
557
            Ok(Loaded {
558
                row: r2,
559
                changes,
560
                hashes,
561
                heads: p.heads,
562
                target_heads: p.target_heads.clone(),
563
                state: p.state.to_hex(),
564
                target_state: p.target_state.to_hex(),
565
                conflicted: p.conflicted,
566
                conflicts: p.conflicts,
567
                union: if p.conflicted { oxy_core::repo::Repo::union_resolution(&p.loaded.state, &p.loaded.snapshot) } else { None },
568
                files,
569
                diffs: p.files.clone(),
570
                bases: p.bases.clone(),
571
                atoms,
572
                sealing_keys: sealing,
573
                touched,
574
                owners,
575
                landed_already,
576
                source_has_changes,
577
                since_landing,
578
            })
579
        })
580
        .await?;
581
    // Landed by another route (#141): `oxy proposal land` from a client (a
582
    // repository with a trust policy needs it), or the work merged and
583
    // pushed by hand. Everything it proposes is in its target, so it is
584
    // landed, not open with nothing to land.
585
    let mut loaded = loaded;
586
    if loaded.row.is_open()
587
        && loaded.source_has_changes
588
        && loaded.hashes.is_empty()
589
        && pdb::set_landed(&app.db, loaded.row.id, &loaded.target_state, "", "push").await.is_ok()
590
    {
591
        loaded.row.state = "landed".into();
592
        loaded.row.landed_via = "push".into();
593
        loaded.row.landed_state = loaded.target_state.clone();
594
        return Ok(loaded);
595
    }
596
    // Remember the projection for the list, the API and other proposals'
597
    // stacking checks, while the proposal is open: a landed one keeps what
598
    // it landed.
599
    if !loaded.row.is_open() {
600
        return Ok(loaded);
601
    }
602
    let heads_csv = loaded.heads.iter().map(|h| h.to_hex()).collect::<Vec<_>>().join(",");
603
    let changes_csv = loaded.hashes.iter().map(|h| h.to_hex()).collect::<Vec<_>>().join(",");
604
    pdb::set_projection(&app.db, loaded.row.id, &loaded.state, &heads_csv, &loaded.target_state, &changes_csv, loaded.conflicted)
605
        .await
606
        .ok();
607
    Ok(loaded)
608
}
609
610
/// After a push moved `bookmark`: open proposals into it whose work it now
611
/// holds are marked landed, by the pusher (#141).
612
pub async fn settle_after_push(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, actor: &str) {
613
    let Some(repo) = RepoName::new(&row.owner, &row.name) else { return };
614
    let Ok(open) = pdb::list(&app.db, row.id, &["open", "draft"]).await else { return };
615
    for p in open.into_iter().filter(|p| p.target == bookmark) {
616
        let id = p.id;
617
        let number = p.number;
618
        if let Ok(l) = load_summary(app, &repo, p).await {
619
            if l.row.state == "landed" && l.row.landed_by.is_empty() {
620
                pdb::set_landed(&app.db, id, &l.target_state, actor, "push").await.ok();
621
                crate::flow::moot_after_landing(app, row, &l.row).await;
622
            } else if l.row.is_open() && !l.hashes.is_empty() && crate::flow::enabled(app, row) {
623
                // The target moved, so the proposal's projected state is
624
                // new and its checks would be for an older one (#134): run
625
                // the pipeline on it, once.
626
                let ran: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM flow_runs WHERE repo_id = $1 AND state = $2")
627
                    .bind(row.id)
628
                    .bind(&l.state)
629
                    .fetch_one(&app.db)
630
                    .await
631
                    .unwrap_or(1);
632
                if ran == 0 {
633
                    let label = format!("proposal/{number}");
634
                    match crate::flow::start_run_scoped(app, row, &label, &l.state, &l.heads, actor, "proposal", None).await {
635
                        Ok(Some(n)) => tracing::info!("proposal {number}: its target moved; run #{n} checks its new projected state"),
636
                        Ok(None) => {}
637
                        Err(e) => tracing::warn!("proposal {number}: a run of its new projected state did not start: {e}"),
638
                    }
639
                }
640
            }
641
        }
642
    }
643
}
644
645
/// Reviews, approvals and blockers.
646
pub struct Verdict {
647
    pub reviews: Vec<ReviewView>,
648
    pub approvals: Vec<Approval>,
649
    pub blockers: Vec<String>,
650
    /// The required-checks blocker, also in `blockers`: the one a
651
    /// maintainer may land over with a reason (#261).
652
    pub checks_blocker: Option<String>,
653
    pub depends_on: Vec<i64>,
654
    pub requirements: pdb::Requirements,
655
}
656
657
pub async fn verdict(app: &Shared, loaded: &Loaded, repo_id: i64) -> Result<Verdict, sqlx::Error> {
658
    let rows = pdb::reviews(&app.db, loaded.row.id).await?;
659
    let links = accounts::identity_accounts(&app.db).await?;
660
    let sealed_accounts: Vec<String> = links.iter().filter(|(k, _)| loaded.sealing_keys.contains(k)).map(|(_, u)| u.clone()).collect();
661
    // Latest verdict per reviewer.
662
    let mut latest: BTreeMap<String, &pdb::ReviewRow> = BTreeMap::new();
663
    for r in rows.iter().filter(|r| r.via.is_empty()) {
664
        // An assistant's answer is never the person's own verdict.
665
        latest.insert(r.author.clone(), r);
666
    }
667
    let mut approvals = Vec::new();
668
    for (who, r) in &latest {
669
        if r.verdict == "approve" {
670
            let sealed = sealed_accounts.contains(who);
671
            let key = links.iter().find(|(k, u)| u == who && loaded.sealing_keys.contains(k)).map(|(k, _)| k.clone());
672
            approvals.push(Approval { account: who.clone(), sealed, key });
673
        }
674
    }
675
    let reviews = rows
676
        .iter()
677
        .map(|r| ReviewView {
678
            row: r.clone(),
679
            body_html: render::markdown::render(&r.body, "/", "/"),
680
            sealed: r.verdict == "approve" && sealed_accounts.contains(&r.author),
681
        })
682
        .collect();
683
    let requirements = pdb::requirements(&app.db, repo_id).await?;
684
    let mut blockers = Vec::new();
685
    // Only an open proposal can be held back.
686
    if !loaded.row.is_open() {
687
        return Ok(Verdict { reviews, approvals, blockers, checks_blocker: None, depends_on: vec![], requirements });
688
    }
689
    if loaded.row.state == "draft" {
690
        blockers.push("the proposal is a draft".into());
691
    }
692
    if loaded.hashes.is_empty() {
693
        blockers.push("the proposal adds no changes".into());
694
    }
695
    let sealed_count = approvals.iter().filter(|a| a.sealed).count() as i64;
696
    if sealed_count < requirements.required_approvals {
697
        blockers.push(format!("{sealed_count} of {} required sealed approvals", requirements.required_approvals));
698
    }
699
    if requirements.require_owners {
700
        let missing: Vec<&String> = loaded.owners.iter().filter(|o| !approvals.iter().any(|a| a.sealed && &a.account == *o)).collect();
701
        if !missing.is_empty() {
702
            blockers.push(format!("code owners still to approve: {}", missing.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")));
703
        }
704
    }
705
    if loaded.conflicted && loaded.union.is_none() && !requirements.allow_conflicts {
706
        blockers.push(format!(
707
            "landing would carry conflicts, which this repository refuses: resolve them on the source (oxy merge {} there, fix the marked files, oxy commit, push)",
708
            loaded.row.target
709
        ));
710
    }
711
    let mut checks_blocker = None;
712
    let required = db::hooks::required_checks(&app.db, repo_id).await?;
713
    if !required.is_empty() {
714
        let checks = db::hooks::checks_for_state(&app.db, repo_id, &loaded.state).await?;
715
        let missing: Vec<String> =
716
            required.iter().filter(|n| !checks.iter().any(|c| &c.name == *n && c.status == "passed")).cloned().collect();
717
        if !missing.is_empty() {
718
            let b = format!("required checks not passed on the projected state: {}", missing.join(", "));
719
            checks_blocker = Some(b.clone());
720
            blockers.push(b);
721
        }
722
    }
723
    let requesting: Vec<&String> = latest.iter().filter(|(_, r)| r.verdict == "changes").map(|(who, _)| who).collect();
724
    if !requesting.is_empty() {
725
        blockers.push(format!("changes requested by {}", requesting.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")));
726
    }
727
    // Stacking: another open, earlier proposal whose changes this one includes.
728
    let mut depends_on = Vec::new();
729
    for other in pdb::list(&app.db, repo_id, &["open", "draft"]).await? {
730
        if other.id == loaded.row.id || other.number > loaded.row.number {
731
            continue;
732
        }
733
        let theirs = other.projected_changes();
734
        if !theirs.is_empty() && theirs.iter().all(|h| loaded.hashes.iter().any(|m| m.to_hex() == *h)) {
735
            depends_on.push(other.number);
736
        }
737
    }
738
    if !depends_on.is_empty() {
739
        blockers.push(format!("depends on {}", depends_on.iter().map(|n| format!("#{n}")).collect::<Vec<_>>().join(", ")));
740
    }
741
    Ok(Verdict { reviews, approvals, blockers, checks_blocker, depends_on, requirements })
742
}
743
744
#[derive(Template)]
745
#[template(path = "proposal.html")]
746
pub struct ProposalPage {
747
    shell: Shell,
748
    repo: RepoShell,
749
    p: pdb::ProposalRow,
750
    /// Live topics (oxygenhub#205): the proposal's and its repository's
751
    /// (checks arrive on the latter).
752
    live_topic: String,
753
    repo_topic: String,
754
    /// Assistants answering now: shown as pending until they do.
755
    pending: Vec<String>,
756
    /// Issues it closes, from its description and its changes.
757
    closes: Vec<super::chain::Link>,
758
    /// Projects the viewer may add it to.
759
    project_choices: Option<Vec<db::projects::Project>>,
760
    /// Comments (not anchored to a line), reviews and events, oldest first.
761
    timeline: Vec<Entry>,
762
    /// The viewer may set labels (triage or above).
763
    can_triage: bool,
764
    body_html: String,
765
    tab: String,
766
    changes: Vec<ChangeView>,
767
    files: Vec<FileRows>,
768
    diffs: Vec<oxy_core::repo::FileDiff>,
769
    bases: std::collections::BTreeMap<String, Vec<u8>>,
770
    /// Whitespace-only changes are hidden (`?w=1`).
771
    ignore_ws: bool,
772
    conflicts: Vec<ConflictView>,
773
    conflicted: bool,
774
    comments: Vec<CommentView>,
775
    /// Anchored comments by (path, current line).
776
    placed: HashMap<(String, usize), Vec<usize>>,
777
    outdated: Vec<usize>,
778
    reviews: Vec<ReviewView>,
779
    approvals: Vec<Approval>,
780
    blockers: Vec<String>,
781
    depends_on: Vec<i64>,
782
    events: Vec<pdb::EventRow>,
783
    owners: Vec<String>,
784
    requirements: pdb::Requirements,
785
    landed_already: bool,
786
    can_edit: bool,
787
    /// The landed proposal's source bookmark, when it is this repository's,
788
    /// still there, and the viewer may delete it (oxygenhub#86).
789
    deletable_source: Option<String>,
790
    /// Changes its bookmark gained after it landed, on no target (#329).
791
    since_landing: Vec<ChangeView>,
792
    can_land: bool,
793
    can_land_over_checks: bool,
794
    /// May bring the target into the source and resolve on the web (#337).
795
    can_resolve: bool,
796
    can_auto_land: bool,
797
    hub_can_land: bool,
798
    notice: Option<String>,
799
    error: Option<String>,
800
    heads: Vec<String>,
801
    state_hash: String,
802
    source_label: String,
803
    api_url: String,
804
    labels: Vec<db::issues::Label>,
805
    all_labels: Vec<db::issues::Label>,
806
    checks: Vec<db::hooks::Check>,
807
    required_checks: Vec<String>,
808
    /// Projects this proposal is on that the viewer may see, with the column.
809
    projects: Vec<(db::projects::Project, String)>,
810
    /// Asked to review and not yet reviewing.
811
    requested: Vec<String>,
812
    /// Who can be asked to review, for the sidebar's menu (oxygenhub#84).
813
    reviewable: Vec<String>,
814
    /// The viewer may ask for reviews (the author, or a writer).
815
    can_request: bool,
816
    /// AI assistants the viewer may ask, by name (hub spec §13.4).
817
    assistants: Vec<String>,
818
    /// A description an assistant just drafted, for the editor.
819
    draft: Option<String>,
820
}
821
822
#[derive(Deserialize)]
823
pub struct DetailQuery {
824
    tab: Option<String>,
825
    /// What an action said, after it redirected here.
826
    #[serde(default)]
827
    notice: Option<String>,
828
    #[serde(default)]
829
    error: Option<String>,
830
    #[serde(default)]
831
    w: Option<String>,
832
    /// Only this part of the page (live pages, oxygenhub#205).
833
    #[serde(default)]
834
    fragment: Option<String>,
835
}
836
837
/// The page assembler every action ends in; its arguments are the route
838
/// plus what to say, and a struct would only rename them.
839
#[allow(clippy::too_many_arguments)]
840
async fn detail(
841
    app: &Shared,
842
    shell: Shell,
843
    viewer: &Viewer,
844
    owner: &str,
845
    name: &str,
846
    number: i64,
847
    tab: String,
848
    notice: Option<String>,
849
    error: Option<String>,
850
) -> Result<Page<ProposalPage>, Error> {
851
    let (repo, row, role) = lookup(app, &shell, viewer, owner, name).await?;
852
    let p = pdb::get(&app.db, row.id, number)
853
        .await
854
        .map_err(|e| Error::from_db(shell.clone(), e))?
855
        .ok_or_else(|| Error::not_found(shell.clone()))?;
856
    let p_id = p.id;
857
    let loaded = load(app, &repo, p).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
858
    // A proposal whose changes all reached the target landed by another route.
859
    if loaded.landed_already && loaded.row.is_open() {
860
        pdb::set_state(&app.db, loaded.row.id, "landed", &loaded.target_state).await.ok();
861
        pdb::add_event(&app.db, loaded.row.id, "", "landed", "every change reached the target").await.ok();
862
        crate::flow::moot_after_landing(app, &row, &loaded.row).await;
863
    }
864
    let p = pdb::get(&app.db, row.id, number).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(loaded.row.clone());
865
    let v = verdict(app, &loaded, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
866
    let comment_rows = pdb::comments(&app.db, loaded.row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
867
    let events = pdb::events(&app.db, loaded.row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
868
    // Place anchored comments at their atom's current line.
869
    let mut by_atom: HashMap<(String, String, u32), usize> = HashMap::new();
870
    for ((path, line), (h, i)) in &loaded.atoms {
871
        by_atom.insert((path.clone(), h.clone(), *i), *line);
872
    }
873
    let mut comments = Vec::new();
874
    let mut placed: HashMap<(String, usize), Vec<usize>> = HashMap::new();
875
    let mut outdated = Vec::new();
876
    for (idx, c) in comment_rows.into_iter().enumerate() {
877
        let line = if c.anchor_change.is_empty() {
878
            None
879
        } else {
880
            by_atom.get(&(c.path.clone(), c.anchor_change.clone(), c.anchor_index as u32)).copied()
881
        };
882
        if !c.anchor_change.is_empty() {
883
            match line {
884
                Some(l) => placed.entry((c.path.clone(), l)).or_default().push(idx),
885
                None => outdated.push(idx),
886
            }
887
        }
888
        comments.push(CommentView { body_html: render::markdown::render(&c.body, "/", "/"), row: c, line });
889
    }
890
    let mut changes = loaded.changes.clone();
891
    super::attribute(app, &mut changes.iter_mut().collect::<Vec<_>>()).await;
892
    let me = viewer.account();
893
    let can_edit = me.is_some_and(|a| Some(a.id) == p.author_id || role >= Role::Maintain);
894
    // Not while it holds work that came after (#329).
895
    let deletable_source = if p.state == "landed"
896
        && p.source_kind == "bookmark"
897
        && role >= Role::Write
898
        && p.source_bookmark != row.default_bookmark
899
        && loaded.since_landing.is_empty()
900
    {
901
        let b = p.source_bookmark.clone();
902
        app.repos
903
            .read(&repo, move |r, _| Ok(r.view()?.bookmarks.contains_key(&b)))
904
            .await
905
            .unwrap_or(false)
906
            .then(|| p.source_bookmark.clone())
907
    } else {
908
        None
909
    };
910
    let can_land = role >= Role::Write && p.is_open() && v.blockers.is_empty();
911
    // Not landable yet: anyone who may land can set it to land when its
912
    // checks pass (#261).
913
    let can_auto_land = role >= Role::Write && p.is_open() && !v.blockers.is_empty();
914
    // Checks alone in the way: a maintainer may land over them (#261).
915
    let can_resolve = role >= Role::Write && p.is_open() && p.source_kind == "bookmark";
916
    let can_land_over_checks = role >= Role::Maintain && p.is_open() && v.checks_blocker.is_some() && v.blockers.len() == 1;
917
    let can_request = p.is_open() && me.is_some_and(|a| Some(a.id) == p.author_id || role >= Role::Write);
918
    let requested = pdb::review_requests(&app.db, p.id).await.unwrap_or_default();
919
    let mut reviewable: Vec<String> = if can_request {
920
        accounts::assignable(&app.db, row.id, &row.owner).await.unwrap_or_default().into_iter().filter(|u| *u != p.author).collect()
921
    } else {
922
        Vec::new()
923
    };
924
    for r in &requested {
925
        if !reviewable.iter().any(|x| x.eq_ignore_ascii_case(r)) {
926
            reviewable.push(r.clone());
927
        }
928
    }
929
    let assistants: Vec<String> = if (can_request || can_edit) && p.is_open() { assistant_names(app, &row).await } else { Vec::new() };
930
    let delegated = match viewer.account() {
931
        Some(a) => super::edit::delegated_identity(app, a.id).await.is_some(),
932
        None => false,
933
    };
934
    let hub_can_land = delegated || app.repos.read(&repo, |r, _| Ok(r.current_policy()?.is_none())).await.unwrap_or(false);
935
    let source_label = match p.source_kind.as_str() {
936
        "fork" => format!(
937
            "fork {}",
938
            db::repos::get_by_id(&app.db, p.source_repo_id.unwrap_or(0))
939
                .await
940
                .ok()
941
                .flatten()
942
                .map(|r| format!("{}:{}", r.slug(), p.source_bookmark))
943
                .unwrap_or_default()
944
        ),
945
        "changes" => format!("{} changes", p.source_changes().len()),
946
        _ => p.source_bookmark.clone(),
947
    };
948
    let projects = super::projects::placements_for(app, viewer, &row, p.number).await;
949
    let texts: Vec<String> = std::iter::once(p.body.clone()).chain(changes.iter().map(|c| format!("{}\n{}", c.title, c.body))).collect();
950
    let closes = super::chain::issues_closed_by(app, &row, &texts.iter().map(String::as_str).collect::<Vec<_>>()).await;
951
    let project_choices = super::projects::project_choices(app, viewer, &row, &projects).await;
952
    let mut dated: Vec<(i64, Entry)> = Vec::new();
953
    for (index, c) in comments.iter().enumerate() {
954
        if c.row.anchor_change.is_empty() {
955
            dated.push((c.row.created_ms, Entry { kind: "comment", index }));
956
        }
957
    }
958
    for (index, r) in v.reviews.iter().enumerate() {
959
        dated.push((r.row.created_ms, Entry { kind: "review", index }));
960
    }
961
    for (index, e) in events.iter().enumerate() {
962
        dated.push((e.at_ms, Entry { kind: "event", index }));
963
    }
964
    dated.sort_by_key(|(at, _)| *at);
965
    let timeline: Vec<Entry> = dated.into_iter().map(|(_, e)| e).collect();
966
    let pending = app.assistant_gate.lock().unwrap_or_else(|e| e.into_inner()).answering(p_id);
967
    Ok(Page(ProposalPage {
968
        live_topic: crate::events::topic::proposal(row.id, number),
969
        repo_topic: crate::events::topic::repo(row.id),
970
        pending,
971
        can_triage: role >= Role::Triage,
972
        timeline,
973
        closes,
974
        project_choices,
975
        projects,
976
        shell,
977
        repo: RepoShell::new(app, &row, "proposals").with_role(role).with_sky(app, &row).await,
978
        body_html: super::issues::markdown(app, &row, &row.default_bookmark, &p.body).await,
979
        p,
980
        tab,
981
        changes,
982
        files: loaded.files,
983
        diffs: loaded.diffs,
984
        bases: loaded.bases,
985
        ignore_ws: false,
986
        conflicts: loaded.conflicts,
987
        conflicted: loaded.conflicted,
988
        since_landing: loaded.since_landing,
989
        comments,
990
        placed,
991
        outdated,
992
        reviews: v.reviews,
993
        approvals: v.approvals,
994
        blockers: v.blockers,
995
        depends_on: v.depends_on,
996
        events,
997
        owners: loaded.owners,
998
        requirements: v.requirements,
999
        landed_already: loaded.landed_already,
1000
        can_edit,
1001
        deletable_source,
1002
        can_land,
1003
        can_land_over_checks,
1004
        can_resolve,
1005
        can_auto_land,
1006
        hub_can_land,
1007
        notice,
1008
        error,
1009
        heads: loaded.heads.iter().map(|h| h.to_hex()).collect(),
1010
        state_hash: loaded.state.clone(),
1011
        source_label,
1012
        api_url: format!("{}/api/v1/repos/{owner}/{name}/proposals/{number}", app.config.base_url()),
1013
        labels: db::issues::proposal_labels(&app.db, loaded.row.id).await.unwrap_or_default(),
1014
        all_labels: db::issues::labels(&app.db, row.id).await.unwrap_or_default(),
1015
        checks: db::hooks::checks_for_state(&app.db, row.id, &loaded.state).await.unwrap_or_default(),
1016
        required_checks: db::hooks::required_checks(&app.db, row.id).await.unwrap_or_default(),
1017
        requested,
1018
        reviewable,
1019
        assistants,
1020
        draft: None,
1021
        can_request,
1022
    }))
1023
}
1024
1025
/// Back to the proposal after an action, with what to say: a redirect, so
1026
/// reloading the page (the checks script does) never repeats the action.
1027
fn back(owner: &str, name: &str, number: i64, tab: &str, notice: Option<String>, error: Option<String>) -> Response {
1028
    let mut url = format!("/{owner}/{name}/proposal/{number}?tab={tab}");
1029
    if let Some(n) = notice {
1030
        url.push_str(&format!("&notice={}", super::settings::urlencode(&n)));
1031
    }
1032
    if let Some(e) = error {
1033
        url.push_str(&format!("&error={}", super::settings::urlencode(&e)));
1034
    }
1035
    Redirect::to(&url).into_response()
1036
}
1037
1038
pub async fn show(
1039
    State(app): State<Shared>,
1040
    viewer: Viewer,
1041
    Path((owner, name, number)): Path<(String, String, i64)>,
1042
    Query(q): Query<DetailQuery>,
1043
) -> Result<Response, Error> {
1044
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1045
    let mut page =
1046
        detail(&app, shell, &viewer, &owner, &name, number, q.tab.unwrap_or_else(|| "conversation".into()), q.notice, q.error).await?;
1047
    if q.w.as_deref() == Some("1") {
1048
        // Hide whitespace-only changes: the same hunks, rendered again.
1049
        page.0.files =
1050
            super::history::render_files_with(&page.0.diffs, &page.0.bases, crate::render::diff::Options { ignore_whitespace: true });
1051
        page.0.ignore_ws = true;
1052
    }
1053
    // A live page asks for one part again (oxygenhub#205): rendered by the
1054
    // same route, so the same access, and cut from the whole.
1055
    if let Some(name) = q.fragment.as_deref() {
1056
        let html = page.0.render().map_err(|e| Error::bad(page.0.shell.clone(), e.to_string()))?;
1057
        return Ok(match super::fragment(&html, name) {
1058
            Some(part) => ([(axum::http::header::CACHE_CONTROL, "no-store")], axum::response::Html(part)).into_response(),
1059
            None => (axum::http::StatusCode::NOT_FOUND, "no such fragment").into_response(),
1060
        });
1061
    }
1062
    Ok(page.into_response())
1063
}
1064
1065
// ---------------------------------------------------------------- actions
1066
1067
#[derive(Deserialize)]
1068
pub struct CommentForm {
1069
    #[serde(rename = "_csrf")]
1070
    csrf_token: String,
1071
    body: String,
1072
    #[serde(default)]
1073
    path: String,
1074
    #[serde(default)]
1075
    line: Option<usize>,
1076
}
1077
1078
pub async fn comment(
1079
    State(app): State<Shared>,
1080
    viewer: Viewer,
1081
    Path((owner, name, number)): Path<(String, String, i64)>,
1082
    Form(f): Form<CommentForm>,
1083
) -> Result<Response, Error> {
1084
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1085
    let me = require(&viewer, &shell)?;
1086
    csrf(&viewer, &f.csrf_token, &shell)?;
1087
    let (repo, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1088
    super::writable(&row, &shell)?;
1089
    let p = pdb::get(&app.db, row.id, number)
1090
        .await
1091
        .map_err(|e| Error::from_db(shell.clone(), e))?
1092
        .ok_or_else(|| Error::not_found(shell.clone()))?;
1093
    if f.body.trim().is_empty() {
1094
        return Ok(back(&owner, &name, number, "conversation", None, Some("An empty comment says nothing.".into())));
1095
    }
1096
    let anchor = match (f.path.trim(), f.line) {
1097
        ("", _) | (_, None) => None,
1098
        (path, Some(line)) => {
1099
            let loaded = load(&app, &repo, p.clone()).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
1100
            loaded.atoms.get(&(path.to_string(), line)).cloned()
1101
        }
1102
    };
1103
    let tab = if anchor.is_some() { "files" } else { "conversation" };
1104
    pdb::add_comment(
1105
        &app.db,
1106
        p.id,
1107
        Some(me.account.id),
1108
        &me.account.username,
1109
        f.body.trim(),
1110
        f.path.trim(),
1111
        anchor.as_ref().map(|(h, i)| (h.as_str(), *i as i64)),
1112
    )
1113
    .await
1114
    .map_err(|e| Error::from_db(shell.clone(), e))?;
1115
    app.bus.live(&crate::events::topic::proposal(row.id, number), "comment");
1116
    Ok(back(&owner, &name, number, tab, None, None))
1117
}
1118
1119
#[derive(Deserialize)]
1120
pub struct ReviewForm {
1121
    #[serde(rename = "_csrf")]
1122
    csrf_token: String,
1123
    verdict: String,
1124
    #[serde(default)]
1125
    body: String,
1126
}
1127
1128
pub async fn review(
1129
    State(app): State<Shared>,
1130
    viewer: Viewer,
1131
    Path((owner, name, number)): Path<(String, String, i64)>,
1132
    Form(f): Form<ReviewForm>,
1133
) -> Result<Response, Error> {
1134
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1135
    let me = require(&viewer, &shell)?;
1136
    csrf(&viewer, &f.csrf_token, &shell)?;
1137
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1138
    super::writable(&row, &shell)?;
1139
    let p = pdb::get(&app.db, row.id, number)
1140
        .await
1141
        .map_err(|e| Error::from_db(shell.clone(), e))?
1142
        .ok_or_else(|| Error::not_found(shell.clone()))?;
1143
    if !matches!(f.verdict.as_str(), "comment" | "changes" | "approve") {
1144
        return Err(Error::bad(shell, "a review is comment, changes or approve"));
1145
    }
1146
    if f.verdict == "approve" && Some(me.account.id) == p.author_id {
1147
        return Ok(back(&owner, &name, number, "conversation", None, Some("You cannot approve your own proposal.".into())));
1148
    }
1149
    pdb::add_review(&app.db, p.id, me.account.id, &me.account.username, &f.verdict, f.body.trim())
1150
        .await
1151
        .map_err(|e| Error::from_db(shell.clone(), e))?;
1152
    pdb::add_event(&app.db, p.id, &me.account.username, &format!("review.{}", f.verdict), "").await.ok();
1153
    reviewed(&app, &row, &p, &me.account, &f.verdict).await;
1154
    crate::hooks::emit(&app, &row, "review", &me.account.username, serde_json::json!({ "number": number, "verdict": f.verdict })).await;
1155
    let notice = if f.verdict == "approve" {
1156
        Some(format!(
1157
            "Approval recorded. It counts once you seal the changes: oxy seal --proposal {}/{owner}/{name}/proposal/{number}",
1158
            app.config.base_url()
1159
        ))
1160
    } else {
1161
        None
1162
    };
1163
    Ok(back(&owner, &name, number, "conversation", notice, None))
1164
}
1165
1166
#[derive(Deserialize)]
1167
pub struct EditForm {
1168
    #[serde(rename = "_csrf")]
1169
    csrf_token: String,
1170
    title: String,
1171
    #[serde(default)]
1172
    body: String,
1173
    target: String,
1174
}
1175
1176
pub async fn edit(
1177
    State(app): State<Shared>,
1178
    viewer: Viewer,
1179
    Path((owner, name, number)): Path<(String, String, i64)>,
1180
    Form(f): Form<EditForm>,
1181
) -> Result<Response, Error> {
1182
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1183
    let me = require(&viewer, &shell)?;
1184
    csrf(&viewer, &f.csrf_token, &shell)?;
1185
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1186
    let p = pdb::get(&app.db, row.id, number)
1187
        .await
1188
        .map_err(|e| Error::from_db(shell.clone(), e))?
1189
        .ok_or_else(|| Error::not_found(shell.clone()))?;
1190
    if Some(me.account.id) != p.author_id && role < Role::Maintain {
1191
        return Err(Error::not_found(shell));
1192
    }
1193
    let target = if crate::repos::names::valid_segment(f.target.trim()) { f.target.trim() } else { &p.target };
1194
    pdb::edit(&app.db, p.id, f.title.trim(), f.body.trim(), target).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1195
    pdb::add_event(&app.db, p.id, &me.account.username, "edited", "").await.ok();
1196
    Ok(back(&owner, &name, number, "conversation", Some("Saved.".into()), None))
1197
}
1198
1199
#[derive(Deserialize)]
1200
pub struct StateForm {
1201
    #[serde(rename = "_csrf")]
1202
    csrf_token: String,
1203
    action: String,
1204
}
1205
1206
pub async fn state(
1207
    State(app): State<Shared>,
1208
    viewer: Viewer,
1209
    Path((owner, name, number)): Path<(String, String, i64)>,
1210
    Form(f): Form<StateForm>,
1211
) -> Result<Response, Error> {
1212
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1213
    let me = require(&viewer, &shell)?;
1214
    csrf(&viewer, &f.csrf_token, &shell)?;
1215
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1216
    let p = pdb::get(&app.db, row.id, number)
1217
        .await
1218
        .map_err(|e| Error::from_db(shell.clone(), e))?
1219
        .ok_or_else(|| Error::not_found(shell.clone()))?;
1220
    if Some(me.account.id) != p.author_id && role < Role::Triage {
1221
        return Err(Error::not_found(shell));
1222
    }
1223
    let new_state = match (f.action.as_str(), p.state.as_str()) {
1224
        ("close", "open" | "draft") => "closed",
1225
        ("reopen", "closed") => "open",
1226
        ("ready", "draft") => "open",
1227
        ("draft", "open") => "draft",
1228
        _ => return Err(Error::bad(shell, "that transition is not possible")),
1229
    };
1230
    pdb::set_state(&app.db, p.id, new_state, "").await.map_err(|e| Error::from_db(shell.clone(), e))?;
1231
    pdb::add_event(&app.db, p.id, &me.account.username, new_state, "").await.ok();
1232
    crate::hooks::emit(&app, &row, &format!("proposal.{new_state}"), &me.account.username, serde_json::json!({ "number": number })).await;
1233
    Ok(back(&owner, &name, number, "conversation", None, None))
1234
}
1235
1236
pub async fn resolve_comment(
1237
    State(app): State<Shared>,
1238
    viewer: Viewer,
1239
    Path((owner, name, number, id)): Path<(String, String, i64, i64)>,
1240
    Form(f): Form<super::session::CsrfForm>,
1241
) -> Result<Response, Error> {
1242
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1243
    require(&viewer, &shell)?;
1244
    csrf(&viewer, &f.csrf, &shell)?;
1245
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1246
    let p = pdb::get(&app.db, row.id, number)
1247
        .await
1248
        .map_err(|e| Error::from_db(shell.clone(), e))?
1249
        .ok_or_else(|| Error::not_found(shell.clone()))?;
1250
    let current: Option<bool> = sqlx::query_scalar("SELECT resolved FROM proposal_comments WHERE id = $1 AND proposal_id = $2")
1251
        .bind(id)
1252
        .bind(p.id)
1253
        .fetch_optional(&app.db)
1254
        .await
1255
        .map_err(|e| Error::from_db(shell.clone(), e))?;
1256
    if let Some(r) = current {
1257
        pdb::set_comment_resolved(&app.db, id, !r).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1258
    }
1259
    Ok(back(&owner, &name, number, "files", None, None))
1260
}
1261
1262
/// Land from the hub: only when the repository has no trust policy (the
1263
/// hub never signs a reference update the policy would have to admit).
1264
#[derive(serde::Deserialize)]
1265
pub struct LandForm {
1266
    #[serde(rename = "_csrf")]
1267
    csrf: String,
1268
    /// Why it lands over its required checks (#261), for maintainers.
1269
    #[serde(default)]
1270
    over_checks: Option<String>,
1271
}
1272
1273
pub async fn land(
1274
    State(app): State<Shared>,
1275
    viewer: Viewer,
1276
    Path((owner, name, number)): Path<(String, String, i64)>,
1277
    Form(f): Form<LandForm>,
1278
) -> Result<Response, Error> {
1279
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1280
    let me = require(&viewer, &shell)?;
1281
    csrf(&viewer, &f.csrf, &shell)?;
1282
    let need = if f.over_checks.is_some() { Access::Maintain } else { Access::Write };
1283
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, need).await?;
1284
    match land_as(&app, &repo, &row, &me.account, number, "web", false, f.over_checks.as_deref()).await {
1285
        Ok(_) => Ok(back(&owner, &name, number, "conversation", Some("Landed.".into()), None)),
1286
        Err(Landing::NotFound) => Err(Error::not_found(shell)),
1287
        Err(Landing::Refused(m)) => Ok(back(&owner, &name, number, "conversation", None, Some(m))),
1288
    }
1289
}
1290
1291
#[derive(serde::Deserialize)]
1292
pub struct AutoLandForm {
1293
    #[serde(rename = "_csrf")]
1294
    csrf: String,
1295
    /// `1` to set it, else off.
1296
    #[serde(default)]
1297
    on: String,
1298
}
1299
1300
/// `POST …/proposal/{n}/auto-land` (#261): land when its checks pass, set
1301
/// or cleared by someone who may land it.
1302
pub async fn auto_land(
1303
    State(app): State<Shared>,
1304
    viewer: Viewer,
1305
    Path((owner, name, number)): Path<(String, String, i64)>,
1306
    Form(f): Form<AutoLandForm>,
1307
) -> Result<Response, Error> {
1308
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1309
    let me = require(&viewer, &shell)?;
1310
    csrf(&viewer, &f.csrf, &shell)?;
1311
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
1312
    let p = pdb::get(&app.db, row.id, number)
1313
        .await
1314
        .map_err(|e| Error::from_db(shell.clone(), e))?
1315
        .filter(|p| p.is_open())
1316
        .ok_or_else(|| Error::not_found(shell.clone()))?;
1317
    let on = f.on == "1";
1318
    crate::autoland::set(&app, &row, &p, &me.account, on).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1319
    let note = if on { "It will land when its checks pass." } else { "It will not land by itself." };
1320
    Ok(back(&owner, &name, number, "conversation", Some(note.into()), None))
1321
}
1322
1323
/// `POST …/proposal/{n}/resolve` (#337): bring a conflicted proposal's
1324
/// target into its source bookmark on the hub, as `oxy merge TARGET` on the
1325
/// source would, so the conflict is on the source; then open the resolver
1326
/// on its first conflicted file. The proposal follows its source, so the
1327
/// resolution committed there clears it. For those who may push to the
1328
/// source, and a source in this repository.
1329
pub async fn resolve_on_web(
1330
    State(app): State<Shared>,
1331
    viewer: Viewer,
1332
    Path((owner, name, number)): Path<(String, String, i64)>,
1333
    Form(f): Form<LandForm>,
1334
) -> Result<Response, Error> {
1335
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1336
    let me = require(&viewer, &shell)?;
1337
    csrf(&viewer, &f.csrf, &shell)?;
1338
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
1339
    let p = pdb::get(&app.db, row.id, number)
1340
        .await
1341
        .map_err(|e| Error::from_db(shell.clone(), e))?
1342
        .filter(|p| p.is_open())
1343
        .ok_or_else(|| Error::not_found(shell.clone()))?;
1344
    if p.source_kind != "bookmark" {
1345
        let msg = "Its source is not a bookmark of this repository: resolve it there, then push.".to_string();
1346
        return Ok(back(&owner, &name, number, "conversation", None, Some(msg)));
1347
    }
1348
    let (source, target) = (p.source_bookmark.clone(), p.target.clone());
1349
    let (s, t) = (source.clone(), target.clone());
1350
    let union = app
1351
        .repos
1352
        .read(&repo, move |r, _| {
1353
            let view = r.view()?;
1354
            let (Some(ours), Some(theirs)) = (view.bookmarks.get(&s).copied(), view.bookmarks.get(&t).copied()) else { return Ok(None) };
1355
            let mut all = r.store().get_state(&ours)?.heads;
1356
            all.extend(r.store().get_state(&theirs)?.heads);
1357
            all.sort();
1358
            all.dedup();
1359
            let state = r.load_state(&all)?;
1360
            Ok(Some(state.maximal(&all)))
1361
        })
1362
        .await
1363
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
1364
    let Some(union) = union else {
1365
        return Ok(back(&owner, &name, number, "conversation", None, Some(format!("{source} or {target} is not a bookmark any more."))));
1366
    };
1367
    if let Err(why) = super::refs::set_bookmark(&app, &repo, me.account.id, &source, union.clone()).await {
1368
        return Ok(back(&owner, &name, number, "conversation", None, Some(format!("Could not bring {target} into {source}: {why}"))));
1369
    }
1370
    let detail = format!("{target} into {source} for proposal #{number}");
1371
    accounts::audit(&app.db, Some(&me.account), "proposal.merged_target", &format!("{owner}/{name}"), &detail).await.ok();
1372
    let s = source.clone();
1373
    let first = app
1374
        .repos
1375
        .read(&repo, move |r, cache| Ok(view::conflicts(r, cache)?.into_iter().find(|c| c.bookmark == s).map(|c| c.path)))
1376
        .await
1377
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
1378
    Ok(match first {
1379
        Some(path) => Redirect::to(&format!("/{owner}/{name}/resolve/{source}/{path}")).into_response(),
1380
        None => {
1381
            back(&owner, &name, number, "conversation", Some(format!("Brought {target} into {source}: nothing left to resolve.")), None)
1382
        }
1383
    })
1384
}
1385
1386
/// Why a proposal did not land.
1387
pub enum Landing {
1388
    NotFound,
1389
    Refused(String),
1390
}
1391
1392
/// Land proposal `number` as `me` (hub spec §8.4), who has write access:
1393
/// refused while it is not open or has blockers. Moves the target bookmark
1394
/// (attested by the hub key on `me`'s behalf when they delegated to it),
1395
/// then records, announces and audits the landing, saying how it was done
1396
/// (`via`: `web`, or `api (token NAME)`). `as_hub` signs with the hub's own
1397
/// key rather than `me`'s delegation (Diffusion's auto-land).
1398
/// `over_checks`, a reason, lands over required checks that have not
1399
/// passed (#261): the caller has checked `me` maintains the repository;
1400
/// every other blocker still holds. Returns the new state.
1401
#[allow(clippy::too_many_arguments)]
1402
pub async fn land_as(
1403
    app: &Shared,
1404
    repo: &RepoName,
1405
    row: &db::repos::RepoRow,
1406
    me: &accounts::Account,
1407
    number: i64,
1408
    via: &str,
1409
    as_hub: bool,
1410
    over_checks: Option<&str>,
1411
) -> Result<String, Landing> {
1412
    let (owner, name) = (row.owner.clone(), row.name.clone());
1413
    let refused = |e: String| Landing::Refused(e);
1414
    let p = pdb::get(&app.db, row.id, number).await.map_err(|e| refused(e.to_string()))?.ok_or(Landing::NotFound)?;
1415
    let loaded = load(app, repo, p.clone()).await.map_err(|e| refused(e.to_string()))?;
1416
    let v = verdict(app, &loaded, row.id).await.map_err(|e| refused(e.to_string()))?;
1417
    if !p.is_open() {
1418
        return Err(refused(format!("Cannot land: the proposal is {}", p.state)));
1419
    }
1420
    let reason = over_checks.map(str::trim);
1421
    if reason == Some("") {
1422
        return Err(refused("Landing over checks needs a reason: say why it can't wait for them".into()));
1423
    }
1424
    let blocking: Vec<&String> = v.blockers.iter().filter(|b| !(reason.is_some() && v.checks_blocker.as_ref() == Some(*b))).collect();
1425
    if !blocking.is_empty() {
1426
        let list: Vec<&str> = blocking.iter().map(|b| b.as_str()).collect();
1427
        return Err(refused(format!("Cannot land: {}", list.join("; "))));
1428
    }
1429
    // Only when the checks were in the way does the landing say so.
1430
    let over = reason.filter(|_| v.checks_blocker.is_some());
1431
    let via_owned = match over {
1432
        Some(r) => format!("{via}, over checks: {r}"),
1433
        None => via.to_string(),
1434
    };
1435
    let via = via_owned.as_str();
1436
    let target = p.target.clone();
1437
    let heads = loaded.heads.clone();
1438
    // With a delegation the landing is attested by the hub key on the
1439
    // user's behalf, which a policy accepts when it lets the user update
1440
    // the target (spec §14.5); without one, only a policy-free repository
1441
    // can be landed here.
1442
    let signer = if as_hub { super::edit::hub_signer(app) } else { super::edit::signer_for(app, me.id).await };
1443
    // Only adjacent insertions in union files in the way (oxygen#145): the
1444
    // landing carries their resolution, both sides kept.
1445
    let union = loaded.union.clone().filter(|_| loaded.conflicted);
1446
    let mut heads = heads;
1447
    let result = match (signer, union) {
1448
        (Ok(signer), Some(files)) => {
1449
            let names: Vec<&str> = files.iter().map(|(p, _)| p.as_str()).collect();
1450
            let message = format!("Keep both sides in {} (merge=union), landing #{number}", names.join(", "));
1451
            let edits = files.into_iter().map(|(path, text)| oxy_core::repo::FileEdit { path, content: Some(text) }).collect();
1452
            crate::repos::edits::land_resolved(&app.repos, repo, target.clone(), heads.clone(), edits, message, signer).await.map(|h| {
1453
                heads = h;
1454
                pl::state_ref(&heads).to_hex()
1455
            })
1456
        }
1457
        (Err(why), Some(_)) => {
1458
            return Err(refused(format!(
1459
                "Cannot land: its changelog-style conflicts are settled by a resolution the hub signs for you, and it cannot: {why}"
1460
            )))
1461
        }
1462
        (signer, None) => match signer {
1463
            Ok(signer) => crate::repos::edits::move_bookmark(&app.repos, repo, target.clone(), heads.clone(), signer)
1464
                .await
1465
                .map(|_| pl::state_ref(&heads).to_hex()),
1466
            Err(why) => {
1467
                let (target, heads) = (target.clone(), heads.clone());
1468
                app.repos
1469
                .write(repo, move |r, _| {
1470
                    if r.current_policy()?.is_some() {
1471
                        return Err(crate::repos::RepoError::Oxy(oxy_core::repo::RepoError::Msg(format!(
1472
                            "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}"
1473
                        ))));
1474
                    }
1475
                    r.bookmark_set(&target, &heads)?;
1476
                    Ok(pl::state_ref(&heads).to_hex())
1477
                })
1478
                .await
1479
            }
1480
        },
1481
    };
1482
    let state = result.map_err(|e| refused(e.to_string()))?;
1483
    pdb::set_landed(&app.db, p.id, &state, &me.username, via).await.map_err(|e| refused(e.to_string()))?;
1484
    crate::flow::moot_after_landing(app, row, &p).await;
1485
    let how = if v.approvals.is_empty() { format!("{via}, with no approval") } else { via.to_string() };
1486
    pdb::add_event(&app.db, p.id, &me.username, "landed", &how).await.ok();
1487
    crate::hooks::emit(
1488
        app,
1489
        row,
1490
        "proposal.landed",
1491
        &me.username,
1492
        serde_json::json!({ "number": number, "target": p.target, "state": state }),
1493
    )
1494
    .await;
1495
    crate::notify::deliver(
1496
        app,
1497
        crate::notify::Event {
1498
            kind: "proposal.landed",
1499
            title: format!("{owner}/{name}#{number}: {} landed", p.title),
1500
            url: format!("/{owner}/{name}/proposal/{number}"),
1501
            actor: me,
1502
            repo_id: row.id,
1503
            thread: Some(("proposal", p.id)),
1504
            mention_text: "",
1505
            direct: p.author_id.into_iter().collect(),
1506
        },
1507
    )
1508
    .await;
1509
    if p.target == row.default_bookmark {
1510
        let messages: Vec<(String, String)> = loaded.changes.iter().map(|c| (c.hash.clone(), format!("{}\n{}", c.title, c.body))).collect();
1511
        super::issues::close_from_messages(app, row, &messages).await;
1512
    }
1513
    accounts::audit(&app.db, Some(me), "proposal.landed", &format!("{owner}/{name}#{number}"), &how).await.ok();
1514
    if let (Some(r), Some(checks)) = (over, &v.checks_blocker) {
1515
        accounts::audit(&app.db, Some(me), "proposal.land_over_checks", &format!("{owner}/{name}#{number}"), &format!("{r} ({checks})"))
1516
            .await
1517
            .ok();
1518
    }
1519
    db::repos::record_push(
1520
        &app.db,
1521
        row.id,
1522
        Some((me.id, me.username.as_str())),
1523
        &[db::repos::PushOutcome {
1524
            reference: p.target.clone(),
1525
            ok: true,
1526
            reason: format!("landed #{number} ({how})"),
1527
            old_state: loaded.target_state.clone(),
1528
            new_state: state.clone(),
1529
            heads: loaded.heads.len() as i64,
1530
            changes: loaded.hashes.len() as i64,
1531
        }],
1532
    )
1533
    .await
1534
    .ok();
1535
    // The target moved: its other open proposals are checked again (#134).
1536
    settle_after_push(app, row, &p.target, &me.username).await;
1537
    // ...and its pipeline runs, as a push of the same heads would start it
1538
    // (#197): without the run a consumer following it when green would wait
1539
    // for a state that never runs.
1540
    crate::flow::on_push(app, row, &p.target, &state, &heads, &me.username).await;
1541
    // A landing on the default bookmark closes the issues its changes (and
1542
    // its description) say they close, as a push there would.
1543
    if p.target == row.default_bookmark {
1544
        let body = (format!("proposal #{number}"), p.body.clone());
1545
        crate::web::issues::close_from_arrived(app, row, loaded.target_heads.clone(), heads.clone(), Some(body)).await;
1546
    }
1547
    // Diffusion (#150): a landing moves the target as a push would, so the
1548
    // pin index follows it, and the consumers of a moved default bookmark
1549
    // are bumped.
1550
    let heads = heads.clone();
1551
    let moved = vec![(p.target.clone(), state.clone(), heads.clone())];
1552
    crate::diffusion::after_push(app, row, &moved).await;
1553
    if p.target == row.default_bookmark {
1554
        let (app, row) = (app.clone(), row.clone());
1555
        tokio::spawn(async move { crate::diffusion::propagate(&app, &row, heads, false).await });
1556
    }
1557
    Ok(state)
1558
}
1559
1560
#[derive(Deserialize)]
1561
pub struct LabelsForm {
1562
    #[serde(rename = "_csrf")]
1563
    csrf_token: String,
1564
    #[serde(default)]
1565
    label: Vec<i64>,
1566
}
1567
1568
pub async fn set_labels(
1569
    State(app): State<Shared>,
1570
    viewer: Viewer,
1571
    Path((owner, name, number)): Path<(String, String, i64)>,
1572
    axum_extra::extract::Form(f): axum_extra::extract::Form<LabelsForm>,
1573
) -> Result<Response, Error> {
1574
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1575
    require(&viewer, &shell)?;
1576
    csrf(&viewer, &f.csrf_token, &shell)?;
1577
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1578
    if role < Role::Triage {
1579
        return Err(Error::not_found(shell));
1580
    }
1581
    let p = pdb::get(&app.db, row.id, number)
1582
        .await
1583
        .map_err(|e| Error::from_db(shell.clone(), e))?
1584
        .ok_or_else(|| Error::not_found(shell.clone()))?;
1585
    for l in db::issues::labels(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1586
        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))?;
1587
    }
1588
    Ok(back(&owner, &name, number, "conversation", None, None))
1589
}
1590
1591
/// `GET /{owner}/{name}/proposal/{number}/events`: server-sent events for
1592
/// checks on the projected state (hub spec §13.1).
1593
pub async fn events(
1594
    State(app): State<Shared>,
1595
    viewer: Viewer,
1596
    Path((owner, name, number)): Path<(String, String, i64)>,
1597
) -> Result<axum::response::Sse<impl futures::Stream<Item = Result<axum::response::sse::Event, std::convert::Infallible>>>, Error> {
1598
    use futures::StreamExt;
1599
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1600
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1601
    let rx = app.bus.subscribe(crate::bus::Topic::Proposals);
1602
    let repo_id = row.id;
1603
    let stream = tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(move |item| async move {
1604
        match item {
1605
            Ok(crate::bus::Event::Proposal { repo, event: ev }) if repo == repo_id => {
1606
                Some(Ok(axum::response::sse::Event::default().event(ev.kind.clone()).data(serde_json::to_string(&ev).unwrap_or_default())))
1607
            }
1608
            _ => None,
1609
        }
1610
    });
1611
    Ok(axum::response::Sse::new(futures::StreamExt::take_until(stream, crate::shutdown::stopping()))
1612
        .keep_alive(axum::response::sse::KeepAlive::default()))
1613
}
1614
1615
// -------------------------------------------------------------------- API
1616
1617
pub async fn to_api(app: &Shared, owner: &str, name: &str, loaded: &Loaded, v: &Verdict, source_url: Option<String>) -> hub_api::Proposal {
1618
    let p = &loaded.row;
1619
    hub_api::Proposal {
1620
        number: p.number,
1621
        title: p.title.clone(),
1622
        body: p.body.clone(),
1623
        author: p.author.clone(),
1624
        state: ProposalState::parse(&p.state).unwrap_or(ProposalState::Open),
1625
        target: p.target.clone(),
1626
        source: match p.source_kind.as_str() {
1627
            "fork" => {
1628
                let (o, n) = source_url
1629
                    .as_deref()
1630
                    .and_then(|u| u.rsplit_once('/'))
1631
                    .and_then(|(b, n)| b.rsplit_once('/').map(|(_, o)| (o.to_string(), n.to_string())))
1632
                    .unwrap_or_default();
1633
                ProposalSource::Fork { owner: o, name: n, bookmark: p.source_bookmark.clone() }
1634
            }
1635
            "changes" => ProposalSource::Changes { hashes: p.source_changes() },
1636
            _ => ProposalSource::Bookmark { bookmark: p.source_bookmark.clone() },
1637
        },
1638
        changes: loaded
1639
            .changes
1640
            .iter()
1641
            .map(|c| hub_api::ChangeSummary {
1642
                hash: c.hash.clone(),
1643
                message: if c.body.is_empty() { c.title.clone() } else { format!("{}\n\n{}", c.title, c.body) },
1644
                author: hub_api::Author {
1645
                    name: c.author.name.clone(),
1646
                    email: c.author.email.clone(),
1647
                    key: c.author.key.clone(),
1648
                    account: c.author.account.clone(),
1649
                    note: hub_api::Author::note_for(&c.author.key, &c.author.account),
1650
                },
1651
                when_ms: c.when_ms,
1652
                deps: c.deps.clone(),
1653
                head: c.head,
1654
                via: c.via.as_ref().map(|v| hub_api::Via {
1655
                    name: v.name.clone(),
1656
                    key: v.key.clone(),
1657
                    scope: v.scope.clone(),
1658
                    expires_ms: v.expires_ms,
1659
                    revoked: v.revoked,
1660
                }),
1661
                signature: match &c.signature {
1662
                    view::SigView::Unsigned => hub_api::Signature::Unsigned,
1663
                    view::SigView::Valid { key, sealed, revoked, .. } => {
1664
                        hub_api::Signature::Valid { key: key.clone(), sealed: *sealed, revoked: *revoked }
1665
                    }
1666
                    view::SigView::Invalid(why) => hub_api::Signature::Invalid { reason: why.clone() },
1667
                },
1668
            })
1669
            .collect(),
1670
        projected_heads: loaded.heads.iter().map(|h| h.to_hex()).collect(),
1671
        projected_state: Some(loaded.state.clone()),
1672
        conflicted: loaded.conflicted,
1673
        approvals: v.approvals.clone(),
1674
        landable: p.is_open() && v.blockers.is_empty(),
1675
        blockers: v.blockers.clone(),
1676
        created_ms: p.created_ms,
1677
        updated_ms: p.updated_ms,
1678
        url: format!("{}/{owner}/{name}/proposal/{}", app.config.base_url(), p.number),
1679
        repository_url: format!("{}/{owner}/{name}", app.config.base_url()),
1680
        source_url,
1681
        landed: (p.state == "landed" && p.landed_ms > 0).then(|| hub_api::Landed {
1682
            by: p.landed_by.clone(),
1683
            via: p.landed_via.clone(),
1684
            when_ms: p.landed_ms,
1685
        }),
1686
        auto_land: (p.is_open() && !p.auto_land_by.is_empty()).then(|| p.auto_land_by.clone()),
1687
        requested_reviewers: pdb::review_requests(&app.db, p.id).await.unwrap_or_default(),
1688
        labels: db::issues::proposal_labels(&app.db, p.id)
1689
            .await
1690
            .unwrap_or_default()
1691
            .into_iter()
1692
            .map(|l| hub_api::Label { name: l.name, colour: l.colour, description: l.description })
1693
            .collect(),
1694
    }
1695
}
1696
1697
/// The line comment form's placement key, for templates.
1698
pub fn key(path: &str, line: impl filters::Num) -> (String, usize) {
1699
    (path.to_string(), line.as_u64() as usize)
1700
}
1701
1702
#[allow(dead_code)]
1703
fn _unused(_: &diff::Line, _: &ConflictView, _: &Role) {}
1704
1705
// ------------------------------------------------------- review requests
1706
1707
/// Ask accounts to review a proposal (hub plan 0.4): each must exist, be
1708
/// someone other than the author, and be able to read the repository. Each
1709
/// newly asked is told, and the proposal records it. The usernames newly
1710
/// asked, or why none could be.
1711
pub async fn request_reviewers(
1712
    app: &Shared,
1713
    row: &db::repos::RepoRow,
1714
    p: &pdb::ProposalRow,
1715
    by: &accounts::Account,
1716
    names: &[String],
1717
) -> Result<Vec<String>, String> {
1718
    let mut found = Vec::new();
1719
    for name in names.iter().map(|n| n.trim().trim_start_matches('@')).filter(|n| !n.is_empty()) {
1720
        let Some(a) = accounts::account_by_name(&app.db, name).await.ok().flatten() else {
1721
            return Err(format!("There is no account {name}."));
1722
        };
1723
        if Some(a.id) == p.author_id {
1724
            return Err(format!("{name} wrote this proposal, and cannot review it."));
1725
        }
1726
        let role = crate::auth::roles::role_for(&app.db, Some(&a), row).await.ok().flatten();
1727
        if !crate::auth::roles::allows(role, crate::auth::Access::Read) {
1728
            return Err(format!("{name} cannot read this repository."));
1729
        }
1730
        found.push(a);
1731
    }
1732
    let mut asked = Vec::new();
1733
    for a in found {
1734
        if !pdb::request_review(&app.db, p.id, a.id, &by.username).await.map_err(|e| e.to_string())? {
1735
            continue;
1736
        }
1737
        pdb::add_event(&app.db, p.id, &by.username, "review requested", &a.username).await.ok();
1738
        crate::notify::deliver(
1739
            app,
1740
            crate::notify::Event {
1741
                kind: "proposal.review_requested",
1742
                title: format!("{}/{}#{}: {} (review requested from {})", row.owner, row.name, p.number, p.title, a.username),
1743
                url: format!("/{}/{}/proposal/{}", row.owner, row.name, p.number),
1744
                actor: by,
1745
                repo_id: row.id,
1746
                thread: None,
1747
                mention_text: "",
1748
                direct: vec![a.id],
1749
            },
1750
        )
1751
        .await;
1752
        asked.push(a.username);
1753
    }
1754
    Ok(asked)
1755
}
1756
1757
/// After a review: it answers the reviewer's request, if there was one, and
1758
/// the proposal's author is told.
1759
pub async fn reviewed(app: &Shared, row: &db::repos::RepoRow, p: &pdb::ProposalRow, by: &accounts::Account, verdict: &str) {
1760
    pdb::unrequest_review(&app.db, p.id, by.id).await.ok();
1761
    let what = match verdict {
1762
        "approve" => "approved",
1763
        "changes" => "changes requested",
1764
        _ => "reviewed",
1765
    };
1766
    crate::notify::deliver(
1767
        app,
1768
        crate::notify::Event {
1769
            kind: "proposal.review",
1770
            title: format!("{}/{}#{}: {} ({what} by {})", row.owner, row.name, p.number, p.title, by.username),
1771
            url: format!("/{}/{}/proposal/{}", row.owner, row.name, p.number),
1772
            actor: by,
1773
            repo_id: row.id,
1774
            thread: Some(("proposal", p.id)),
1775
            mention_text: "",
1776
            direct: p.author_id.into_iter().collect(),
1777
        },
1778
    )
1779
    .await;
1780
}
1781
1782
#[derive(Deserialize)]
1783
pub struct ReviewersForm {
1784
    #[serde(rename = "_csrf")]
1785
    csrf_token: String,
1786
    /// `add`, `remove`, or `set` (the menu: exactly these are asked).
1787
    action: String,
1788
    #[serde(default)]
1789
    username: Vec<String>,
1790
}
1791
1792
/// `POST /{owner}/{name}/proposal/{number}/reviewers`: the author or a
1793
/// writer asks someone to review, or withdraws the request.
1794
pub async fn reviewers(
1795
    State(app): State<Shared>,
1796
    viewer: Viewer,
1797
    Path((owner, name, number)): Path<(String, String, i64)>,
1798
    axum_extra::extract::Form(f): axum_extra::extract::Form<ReviewersForm>,
1799
) -> Result<Response, Error> {
1800
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1801
    let me = require(&viewer, &shell)?;
1802
    csrf(&viewer, &f.csrf_token, &shell)?;
1803
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1804
    let p = pdb::get(&app.db, row.id, number)
1805
        .await
1806
        .map_err(|e| Error::from_db(shell.clone(), e))?
1807
        .ok_or_else(|| Error::not_found(shell.clone()))?;
1808
    if Some(me.account.id) != p.author_id && role < Role::Write {
1809
        return Err(Error::not_found(shell));
1810
    }
1811
    let names: Vec<String> = f.username.iter().map(|n| n.trim().trim_start_matches('@').to_string()).filter(|n| !n.is_empty()).collect();
1812
    let current = pdb::review_requests(&app.db, p.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1813
    // Withdrawn: those named (remove), or those the menu left out (set).
1814
    let withdraw: Vec<String> = match f.action.as_str() {
1815
        "remove" => names.clone(),
1816
        "set" => current.iter().filter(|c| !names.iter().any(|n| n.eq_ignore_ascii_case(c))).cloned().collect(),
1817
        _ => Vec::new(),
1818
    };
1819
    for who in &withdraw {
1820
        if let Some(a) = accounts::account_by_name(&app.db, who).await.ok().flatten() {
1821
            if pdb::unrequest_review(&app.db, p.id, a.id).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1822
                pdb::add_event(&app.db, p.id, &me.account.username, "review request withdrawn", &a.username).await.ok();
1823
            }
1824
        }
1825
    }
1826
    let ask: Vec<String> = match f.action.as_str() {
1827
        "add" => names,
1828
        "set" => names.into_iter().filter(|n| !current.iter().any(|c| c.eq_ignore_ascii_case(n))).collect(),
1829
        _ => return Ok(back(&owner, &name, number, "conversation", None, None)),
1830
    };
1831
    if ask.is_empty() {
1832
        return Ok(back(&owner, &name, number, "conversation", None, None));
1833
    }
1834
    match request_reviewers(&app, &row, &p, &me.account, &ask).await {
1835
        Ok(asked) if asked.is_empty() => Ok(back(&owner, &name, number, "conversation", Some("Already asked.".into()), None)),
1836
        Ok(asked) => Ok(back(&owner, &name, number, "conversation", Some(format!("Asked {} to review.", asked.join(", "))), None)),
1837
        Err(e) => Ok(back(&owner, &name, number, "conversation", None, Some(e))),
1838
    }
1839
}
1840
1841
#[derive(Deserialize)]
1842
pub struct AssistantForm {
1843
    #[serde(rename = "_csrf")]
1844
    csrf_token: String,
1845
    assistant: String,
1846
    /// `summary` asks for a summary comment (oxygenhub#190); otherwise a review.
1847
    #[serde(default)]
1848
    task: String,
1849
}
1850
1851
/// `POST /{owner}/{name}/proposal/{number}/assistant`: the author or a
1852
/// writer asks an AI assistant to review (hub spec §13.4). The answer
1853
/// comes later as a review via the assistant.
1854
pub async fn assistant(
1855
    State(app): State<Shared>,
1856
    viewer: Viewer,
1857
    Path((owner, name, number)): Path<(String, String, i64)>,
1858
    Form(f): Form<AssistantForm>,
1859
) -> Result<Response, Error> {
1860
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1861
    let me = require(&viewer, &shell)?;
1862
    csrf(&viewer, &f.csrf_token, &shell)?;
1863
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1864
    super::writable(&row, &shell)?;
1865
    let p = pdb::get(&app.db, row.id, number)
1866
        .await
1867
        .map_err(|e| Error::from_db(shell.clone(), e))?
1868
        .ok_or_else(|| Error::not_found(shell.clone()))?;
1869
    if Some(me.account.id) != p.author_id && role < Role::Write {
1870
        return Err(Error::not_found(shell));
1871
    }
1872
    if !p.is_open() {
1873
        return Ok(back(&owner, &name, number, "conversation", None, Some("The proposal is not open.".into())));
1874
    }
1875
    let Some(a) = crate::assistants::enabled_for_repo(&app.db, &row)
1876
        .await
1877
        .map_err(|e| Error::from_db(shell.clone(), e))?
1878
        .into_iter()
1879
        .find(|a| a.name.eq_ignore_ascii_case(f.assistant.trim()))
1880
    else {
1881
        return Ok(back(&owner, &name, number, "conversation", None, Some(format!("No assistant called {} here.", f.assistant.trim()))));
1882
    };
1883
    let who = a.name.clone();
1884
    let summary = f.task == "summary";
1885
    let asked = if summary {
1886
        crate::assistants::request_summary(&app, &row, &p, &me.account, a).await
1887
    } else {
1888
        crate::assistants::request_review(&app, &row, &p, &me.account, a).await
1889
    };
1890
    match asked {
1891
        Ok(()) => Ok(back(
1892
            &owner,
1893
            &name,
1894
            number,
1895
            "conversation",
1896
            Some(if summary {
1897
                format!("Asked {who} for a summary; its comment appears here when it comes.")
1898
            } else {
1899
                format!("Asked {who} to review; its answer appears here when it comes.")
1900
            }),
1901
            None,
1902
        )),
1903
        Err(e) => Ok(back(&owner, &name, number, "conversation", None, Some(format!("Not asked: {e}.")))),
1904
    }
1905
}
1906
1907
/// The assistants a repository may use, by name.
1908
async fn assistant_names(app: &Shared, row: &db::repos::RepoRow) -> Vec<String> {
1909
    crate::assistants::enabled_for_repo(&app.db, row).await.unwrap_or_default().into_iter().map(|a| a.name).collect()
1910
}
1911
1912
/// The changes and the file diffs a proposal from `source` into `target`
1913
/// would carry.
1914
async fn preview_diff(
1915
    app: &Shared,
1916
    repo: &RepoName,
1917
    target: &str,
1918
    source: &str,
1919
) -> Result<(Vec<ChangeView>, Vec<oxy_core::repo::FileDiff>), crate::repos::RepoError> {
1920
    let Some((fork, bookmark)) = parse_source(app, repo, source) else { return Ok((vec![], vec![])) };
1921
    let target = target.to_string();
1922
    let fork_dir = fork.as_ref().map(|f| app.repos.dir(f));
1923
    app.repos
1924
        .read(repo, move |r, cache| {
1925
            let projection = match &fork_dir {
1926
                Some(dir) => {
1927
                    let fork_repo = oxy_core::repo::Repo::open(dir)?;
1928
                    let heads = fork_repo.bookmarks()?.into_iter().find(|b| b.name == bookmark).map(|b| b.heads).unwrap_or_default();
1929
                    pl::project(r, cache, &target, Source::Fork { fork: &fork_repo, heads })?
1930
                }
1931
                None => {
1932
                    let heads = r.bookmarks()?.into_iter().find(|b| b.name == bookmark).map(|b| b.heads).unwrap_or_default();
1933
                    pl::project(r, cache, &target, Source::Bookmark(heads))?
1934
                }
1935
            };
1936
            Ok((projection.changes, projection.files))
1937
        })
1938
        .await
1939
}
1940
1941
/// `POST /{owner}/{name}/proposals/new/draft`: an assistant drafts the
1942
/// description from the changes the proposal would add; the form comes
1943
/// back with the draft to edit (hub spec §13.4).
1944
pub async fn new_draft(
1945
    State(app): State<Shared>,
1946
    viewer: Viewer,
1947
    Path((owner, name)): Path<(String, String)>,
1948
    Form(f): Form<NewForm>,
1949
) -> Result<Page<NewProposalPage>, Error> {
1950
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposals/new"), &viewer);
1951
    let me = require(&viewer, &shell)?;
1952
    csrf(&viewer, &f.csrf_token, &shell)?;
1953
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1954
    super::writable(&row, &shell)?;
1955
    let bookmarks =
1956
        app.repos.read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::<Vec<_>>())).await.unwrap_or_default();
1957
    let (preview, conflicted) = preview_for(&app, &repo, &f.target, &f.source).await.unwrap_or((vec![], false));
1958
    let assistants = if preview.is_empty() { vec![] } else { assistant_names(&app, &row).await };
1959
    let mut page = NewProposalPage {
1960
        shell,
1961
        repo: RepoShell::new(&app, &row, "proposals").with_role(role),
1962
        bookmarks,
1963
        source: f.source.clone(),
1964
        target: f.target.clone(),
1965
        error: None,
1966
        preview,
1967
        conflicted,
1968
        title: f.title.clone(),
1969
        body: f.body.clone(),
1970
        assistants,
1971
    };
1972
    let assistant = crate::assistants::enabled_for_repo(&app.db, &row)
1973
        .await
1974
        .unwrap_or_default()
1975
        .into_iter()
1976
        .find(|a| a.name.eq_ignore_ascii_case(f.assistant.trim()));
1977
    let Some(a) = assistant else {
1978
        page.error = Some(format!("No assistant called {} here.", f.assistant.trim()));
1979
        return Ok(Page(page));
1980
    };
1981
    if page.preview.is_empty() {
1982
        page.error = Some("Nothing to describe: the source adds nothing to the target.".into());
1983
        return Ok(Page(page));
1984
    }
1985
    let (changes, diffs) = match preview_diff(&app, &repo, &f.target, &f.source).await {
1986
        Ok(x) => x,
1987
        Err(e) => {
1988
            page.error = Some(e.to_string());
1989
            return Ok(Page(page));
1990
        }
1991
    };
1992
    let d =
1993
        crate::assistants::Draft { slot: -row.id, title: &f.title, target: &f.target, changes: &changes, diffs: &diffs, with_title: true };
1994
    match crate::assistants::draft_description(&app, &row, &me.account, &a, d).await {
1995
        Ok(text) => {
1996
            let (title, body) = crate::assistants::split_title(&text);
1997
            page.body = body;
1998
            // The title too, unless the person wrote one (#316): the page's
1999
            // own suggestion, a single change's title, counts as theirs not.
2000
            let suggested = if page.preview.len() == 1 { page.preview[0].title.clone() } else { String::new() };
2001
            if let Some(t) = title.filter(|_| page.title.trim().is_empty() || page.title.trim() == suggested.trim()) {
2002
                page.title = t;
2003
            }
2004
        }
2005
        Err(why) => page.error = Some(format!("{} did not answer: {why}.", a.name)),
2006
    }
2007
    Ok(Page(page))
2008
}
2009
2010
#[derive(Deserialize)]
2011
pub struct DraftForm {
2012
    #[serde(rename = "_csrf")]
2013
    csrf_token: String,
2014
    assistant: String,
2015
    #[serde(default)]
2016
    title: String,
2017
}
2018
2019
/// `POST /{owner}/{name}/proposal/{number}/draft`: an assistant drafts the
2020
/// description of an open proposal from its changes; the editor opens
2021
/// with the draft, nothing is saved (hub spec §13.4).
2022
pub async fn draft(
2023
    State(app): State<Shared>,
2024
    viewer: Viewer,
2025
    Path((owner, name, number)): Path<(String, String, i64)>,
2026
    Form(f): Form<DraftForm>,
2027
) -> Result<Response, Error> {
2028
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
2029
    let me = require(&viewer, &shell)?;
2030
    csrf(&viewer, &f.csrf_token, &shell)?;
2031
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
2032
    super::writable(&row, &shell)?;
2033
    let p = pdb::get(&app.db, row.id, number)
2034
        .await
2035
        .map_err(|e| Error::from_db(shell.clone(), e))?
2036
        .ok_or_else(|| Error::not_found(shell.clone()))?;
2037
    if Some(me.account.id) != p.author_id && role < Role::Maintain {
2038
        return Err(Error::not_found(shell));
2039
    }
2040
    let Some(a) = crate::assistants::enabled_for_repo(&app.db, &row)
2041
        .await
2042
        .map_err(|e| Error::from_db(shell.clone(), e))?
2043
        .into_iter()
2044
        .find(|a| a.name.eq_ignore_ascii_case(f.assistant.trim()))
2045
    else {
2046
        return Ok(back(&owner, &name, number, "conversation", None, Some(format!("No assistant called {} here.", f.assistant.trim()))));
2047
    };
2048
    let loaded = crate::web::proposals::load(&app, &repo, p.clone()).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
2049
    let title = if f.title.trim().is_empty() { p.title.clone() } else { f.title.clone() };
2050
    let d = crate::assistants::Draft {
2051
        slot: p.id,
2052
        title: &title,
2053
        target: &p.target,
2054
        changes: &loaded.changes,
2055
        diffs: &loaded.diffs,
2056
        with_title: false,
2057
    };
2058
    let draft = crate::assistants::draft_description(&app, &row, &me.account, &a, d).await;
2059
    match draft {
2060
        Ok(text) => {
2061
            let mut page = detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), None, None).await?;
2062
            page.0.draft = Some(text);
2063
            Ok(page.into_response())
2064
        }
2065
        Err(why) => Ok(back(&owner, &name, number, "conversation", None, Some(format!("{} did not answer: {why}.", a.name)))),
2066
    }
2067
}
2068
2069
/// A draft being written, as server-sent events for the page: `part` with
2070
/// each piece of text, then `done` with the whole draft or `error` with
2071
/// why there is none (each a JSON string). The response starts at once, so
2072
/// a draft that takes minutes is not cut off by a proxy waiting for it
2073
/// (oxygenhub#248).
2074
type DraftEvents = axum::response::Sse<
2075
    axum::response::sse::KeepAliveStream<
2076
        tokio_stream::wrappers::ReceiverStream<Result<axum::response::sse::Event, std::convert::Infallible>>,
2077
    >,
2078
>;
2079
2080
fn draft_events(rx: tokio::sync::mpsc::Receiver<Result<axum::response::sse::Event, std::convert::Infallible>>) -> DraftEvents {
2081
    axum::response::Sse::new(tokio_stream::wrappers::ReceiverStream::new(rx))
2082
        .keep_alive(axum::response::sse::KeepAlive::new().interval(std::time::Duration::from_secs(15)))
2083
}
2084
2085
fn draft_event(kind: &str, text: &str) -> Result<axum::response::sse::Event, std::convert::Infallible> {
2086
    Ok(axum::response::sse::Event::default().event(kind).data(serde_json::to_string(text).unwrap_or_default()))
2087
}
2088
2089
/// Only an error, for a draft that cannot start.
2090
fn draft_refused(why: String) -> DraftEvents {
2091
    let (tx, rx) = tokio::sync::mpsc::channel(1);
2092
    let _ = tx.try_send(draft_event("error", &why));
2093
    draft_events(rx)
2094
}
2095
2096
/// What a draft is written from, owned so a task of its own can have it.
2097
struct DraftJob {
2098
    row: db::repos::RepoRow,
2099
    by: accounts::Account,
2100
    a: crate::assistants::Assistant,
2101
    slot: i64,
2102
    title: String,
2103
    target: String,
2104
    changes: Vec<ChangeView>,
2105
    diffs: Vec<oxy_core::repo::FileDiff>,
2106
    /// Not yet opened: the draft proposes a title too (#316).
2107
    with_title: bool,
2108
}
2109
2110
/// Write the draft in a task of its own, its parts to the page.
2111
fn draft_in_background(app: Shared, job: DraftJob) -> DraftEvents {
2112
    let (tx, rx) = tokio::sync::mpsc::channel(64);
2113
    tokio::spawn(async move {
2114
        let DraftJob { row, by, a, slot, title, target, changes, diffs, with_title } = job;
2115
        let d = crate::assistants::Draft { slot, title: &title, target: &target, changes: &changes, diffs: &diffs, with_title };
2116
        let parts = tx.clone();
2117
        let result = crate::assistants::draft_description_streaming(&app, &row, &by, &a, d, move |p: &str| {
2118
            // A page that went away stops nothing: the draft finishes.
2119
            let _ = parts.try_send(draft_event("part", p));
2120
        })
2121
        .await;
2122
        let last = match result {
2123
            Ok(text) if with_title => {
2124
                // The title first, for the page to put in its field (#316).
2125
                let (t, body) = crate::assistants::split_title(&text);
2126
                if let Some(t) = t {
2127
                    let _ = tx.send(draft_event("title", &t)).await;
2128
                }
2129
                draft_event("done", &body)
2130
            }
2131
            Ok(text) => draft_event("done", text.trim()),
2132
            Err(why) => draft_event("error", &format!("{} did not answer: {why}.", a.name)),
2133
        };
2134
        let _ = tx.send(last).await;
2135
    });
2136
    draft_events(rx)
2137
}
2138
2139
/// `POST /{owner}/{name}/proposal/{number}/draft/stream`: [`draft`], with
2140
/// the draft streamed to the page as it is written.
2141
pub async fn draft_stream(
2142
    State(app): State<Shared>,
2143
    viewer: Viewer,
2144
    Path((owner, name, number)): Path<(String, String, i64)>,
2145
    Form(f): Form<DraftForm>,
2146
) -> Result<DraftEvents, Error> {
2147
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
2148
    let me = require(&viewer, &shell)?;
2149
    csrf(&viewer, &f.csrf_token, &shell)?;
2150
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
2151
    super::writable(&row, &shell)?;
2152
    let p = pdb::get(&app.db, row.id, number)
2153
        .await
2154
        .map_err(|e| Error::from_db(shell.clone(), e))?
2155
        .ok_or_else(|| Error::not_found(shell.clone()))?;
2156
    if Some(me.account.id) != p.author_id && role < Role::Maintain {
2157
        return Err(Error::not_found(shell));
2158
    }
2159
    let Some(a) = crate::assistants::enabled_for_repo(&app.db, &row)
2160
        .await
2161
        .map_err(|e| Error::from_db(shell.clone(), e))?
2162
        .into_iter()
2163
        .find(|a| a.name.eq_ignore_ascii_case(f.assistant.trim()))
2164
    else {
2165
        return Ok(draft_refused(format!("No assistant called {} here.", f.assistant.trim())));
2166
    };
2167
    let loaded = load(&app, &repo, p.clone()).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
2168
    let title = if f.title.trim().is_empty() { p.title.clone() } else { f.title.clone() };
2169
    let job = DraftJob {
2170
        row,
2171
        by: me.account.clone(),
2172
        a,
2173
        slot: p.id,
2174
        title,
2175
        target: p.target.clone(),
2176
        changes: loaded.changes,
2177
        diffs: loaded.diffs,
2178
        with_title: false,
2179
    };
2180
    Ok(draft_in_background(app.clone(), job))
2181
}
2182
2183
/// `POST /{owner}/{name}/proposals/new/draft/stream`: [`new_draft`], with
2184
/// the draft streamed to the page as it is written.
2185
pub async fn new_draft_stream(
2186
    State(app): State<Shared>,
2187
    viewer: Viewer,
2188
    Path((owner, name)): Path<(String, String)>,
2189
    Form(f): Form<NewForm>,
2190
) -> Result<DraftEvents, Error> {
2191
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposals/new"), &viewer);
2192
    let me = require(&viewer, &shell)?;
2193
    csrf(&viewer, &f.csrf_token, &shell)?;
2194
    let (repo, row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
2195
    super::writable(&row, &shell)?;
2196
    let Some(a) = crate::assistants::enabled_for_repo(&app.db, &row)
2197
        .await
2198
        .unwrap_or_default()
2199
        .into_iter()
2200
        .find(|a| a.name.eq_ignore_ascii_case(f.assistant.trim()))
2201
    else {
2202
        return Ok(draft_refused(format!("No assistant called {} here.", f.assistant.trim())));
2203
    };
2204
    let (changes, diffs) = match preview_diff(&app, &repo, &f.target, &f.source).await {
2205
        Ok(x) => x,
2206
        Err(e) => return Ok(draft_refused(e.to_string())),
2207
    };
2208
    if changes.is_empty() {
2209
        return Ok(draft_refused("Nothing to describe: the source adds nothing to the target.".into()));
2210
    }
2211
    let job = DraftJob {
2212
        slot: -row.id,
2213
        row,
2214
        by: me.account.clone(),
2215
        a,
2216
        title: f.title.clone(),
2217
        target: f.target.clone(),
2218
        changes,
2219
        diffs,
2220
        with_title: true,
2221
    };
2222
    Ok(draft_in_background(app.clone(), job))
2223
}