OXYGENHUB
# ac713df7b38b oxygenhub / crates / hub / src / web / proposals.rs
70.0 KiB · Rust · last changed Proposals: mark landed when their work reaches the target by push 26 days ago source blame history raw
de505df5 Andy McCall ↶ 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>,
1ac84c62 Andy McCall ↶ 33
    q: String,
34
    sort: String,
35
    pager: super::Pager,
36
    labels: std::collections::HashMap<i64, Vec<db::issues::Label>>,
de505df5 Andy McCall ↶ 37
}
38
1ac84c62 Andy McCall ↶ 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
de505df5 Andy McCall ↶ 45
#[derive(Deserialize)]
46
pub struct ListQuery {
47
    state: Option<String>,
1ac84c62 Andy McCall ↶ 48
    q: Option<String>,
49
    sort: Option<String>,
50
    page: Option<usize>,
de505df5 Andy McCall ↶ 51
}
52
1ac84c62 Andy McCall ↶ 53
/// The list's sort orders.
54
pub const SORTS: &[(&str, &str)] = &[("updated", "recently updated"), ("newest", "newest"), ("oldest", "oldest")];
55
de505df5 Andy McCall ↶ 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());
1ac84c62 Andy McCall ↶ 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
de505df5 Andy McCall ↶ 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
        })
1ac84c62 Andy McCall ↶ 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)))
de505df5 Andy McCall ↶ 98
        .collect();
1ac84c62 Andy McCall ↶ 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);
de505df5 Andy McCall ↶ 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))?;
1ac84c62 Andy McCall ↶ 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
    }))
de505df5 Andy McCall ↶ 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,
7d2eb590 Andy McCall ↶ 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>,
de505df5 Andy McCall ↶ 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.
0b20f1ec Andy McCall ↶ 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.
de505df5 Andy McCall ↶ 157
fn parse_source(app: &Shared, repo: &RepoName, source: &str) -> Option<(Option<RepoName>, String)> {
0b20f1ec Andy McCall ↶ 158
    let plain = |b: &str| !b.is_empty() && !b.chars().any(|c| c.is_control() || c == ':');
de505df5 Andy McCall ↶ 159
    if let Some((slug, bookmark)) = source.split_once(':') {
160
        let fork = RepoName::parse(slug)?;
0b20f1ec Andy McCall ↶ 161
        if !plain(bookmark) {
de505df5 Andy McCall ↶ 162
            return None;
0b20f1ec Andy McCall ↶ 163
        }
164
        if fork == *repo {
165
            return Some((None, bookmark.to_string()));
de505df5 Andy McCall ↶ 166
        }
0b20f1ec Andy McCall ↶ 167
        if !app.repos.exists(&fork) {
168
            return None;
169
        }
de505df5 Andy McCall ↶ 170
        return Some((Some(fork), bookmark.to_string()));
171
    }
0b20f1ec Andy McCall ↶ 172
    plain(source).then(|| (None, source.to_string()))
de505df5 Andy McCall ↶ 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)) };
7d2eb590 Andy McCall ↶ 220
    let assistants = if preview.is_empty() { vec![] } else { assistant_names(&app, &row).await };
de505df5 Andy McCall ↶ 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,
7d2eb590 Andy McCall ↶ 230
        title: String::new(),
231
        body: String::new(),
232
        assistants,
de505df5 Andy McCall ↶ 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>,
7d2eb590 Andy McCall ↶ 247
    /// For `/proposals/new/draft`: which assistant writes the description.
248
    #[serde(default)]
249
    assistant: String,
de505df5 Andy McCall ↶ 250
}
251
b64e2537 Andy McCall ↶ 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());
de505df5 Andy McCall ↶ 267
    }
b64e2537 Andy McCall ↶ 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());
de505df5 Andy McCall ↶ 272
    }
b64e2537 Andy McCall ↶ 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());
de505df5 Andy McCall ↶ 275
    };
0b20f1ec Andy McCall ↶ 276
    if fork.is_none() && !bookmarks.contains(&bookmark) {
277
        return Err(format!("There is no bookmark {bookmark} in this repository."));
278
    }
b64e2537 Andy McCall ↶ 279
    if fork.is_none() && bookmark == target {
280
        return Err("The source and the target are the same bookmark.".into());
de505df5 Andy McCall ↶ 281
    }
b64e2537 Andy McCall ↶ 282
    let (preview, _) = preview_for(app, repo, target, source.trim()).await.map_err(|e| e.to_string())?;
de505df5 Andy McCall ↶ 283
    if preview.is_empty() {
b64e2537 Andy McCall ↶ 284
        return Err("The source adds nothing to the target.".into());
de505df5 Andy McCall ↶ 285
    }
286
    let source_repo_id = match &fork {
b64e2537 Andy McCall ↶ 287
        Some(fk) => db::repos::get(&app.db, &fk.owner, &fk.name).await.map_err(|e| e.to_string())?.map(|r| r.id),
de505df5 Andy McCall ↶ 288
        None => None,
289
    };
290
    let created = pdb::create(
291
        &app.db,
292
        pdb::NewProposal {
293
            repo_id: row.id,
b64e2537 Andy McCall ↶ 294
            title: title.trim(),
295
            body: body.trim(),
296
            author_id: Some(me.id),
297
            author: &me.username,
298
            draft,
299
            target,
de505df5 Andy McCall ↶ 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
b64e2537 Andy McCall ↶ 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();
18e381ea Andy McCall ↶ 310
    db::notify::index(&app.db, "proposal", row.id, created.id, created.number, &created.title, &created.body).await.ok();
b64e2537 Andy McCall ↶ 311
    let url = format!("/{}/proposal/{}", row.slug(), created.number);
18e381ea Andy McCall ↶ 312
    crate::notify::deliver(
b64e2537 Andy McCall ↶ 313
        app,
18e381ea Andy McCall ↶ 314
        crate::notify::Event {
315
            kind: "proposal.opened",
b64e2537 Andy McCall ↶ 316
            title: format!("{}#{}: {}", row.slug(), created.number, created.title),
317
            url,
318
            actor: me,
18e381ea Andy McCall ↶ 319
            repo_id: row.id,
320
            thread: Some(("proposal", created.id)),
b64e2537 Andy McCall ↶ 321
            mention_text: body,
18e381ea Andy McCall ↶ 322
            direct: vec![],
323
        },
324
    )
325
    .await;
b64e2537 Andy McCall ↶ 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
    match open_proposal(&app, &repo, &row, &me.account, &f.title, &f.body, &f.target, &f.source, f.draft.is_some()).await {
348
        Ok(created) => Ok(Redirect::to(&format!("/{owner}/{name}/proposal/{}", created.number)).into_response()),
349
        Err(msg) => {
350
            let bookmarks =
351
                app.repos.read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::<Vec<_>>())).await.unwrap_or_default();
352
            Ok(Page(NewProposalPage {
353
                shell,
354
                repo: RepoShell::new(&app, &row, "proposals").with_role(role),
355
                bookmarks,
356
                source: f.source.clone(),
357
                target: f.target.clone(),
358
                error: Some(msg),
359
                preview: vec![],
360
                conflicted: false,
7d2eb590 Andy McCall ↶ 361
                title: f.title.clone(),
362
                body: f.body.clone(),
363
                assistants: vec![],
b64e2537 Andy McCall ↶ 364
            })
365
            .into_response())
366
        }
367
    }
de505df5 Andy McCall ↶ 368
}
369
370
// ---------------------------------------------------------------- detail
371
372
/// A comment placed on a diff row, or general.
373
pub struct CommentView {
374
    pub row: pdb::CommentRow,
375
    pub body_html: String,
376
    pub line: Option<usize>,
377
}
378
a1b98695 Andy McCall ↶ 379
/// One entry of a proposal's conversation, in time order: what kind it
380
/// is and its index in `comments`, `reviews` or `events`.
381
pub struct Entry {
382
    pub kind: &'static str,
383
    pub index: usize,
384
}
385
de505df5 Andy McCall ↶ 386
pub struct ReviewView {
387
    pub row: pdb::ReviewRow,
388
    pub body_html: String,
389
    pub sealed: bool,
390
}
391
392
/// Everything the detail page and the API need, computed once under the lock.
393
pub struct Loaded {
394
    pub row: pdb::ProposalRow,
395
    pub changes: Vec<ChangeView>,
396
    pub hashes: Vec<ChangeHash>,
397
    pub heads: Vec<ChangeHash>,
3f02e5ea Andy McCall ↶ 398
    pub target_heads: Vec<ChangeHash>,
de505df5 Andy McCall ↶ 399
    pub state: String,
400
    pub target_state: String,
401
    pub conflicted: bool,
402
    pub conflicts: Vec<ConflictView>,
403
    pub files: Vec<FileRows>,
9df855f2 Andy McCall ↶ 404
    /// The per-file hunks and bases behind `files`, to render again with other options.
405
    pub diffs: Vec<oxy_core::repo::FileDiff>,
406
    pub bases: std::collections::BTreeMap<String, Vec<u8>>,
de505df5 Andy McCall ↶ 407
    /// Atom per (path, new line number) of the projected state.
408
    pub atoms: HashMap<(String, usize), (String, u32)>,
409
    pub sealing_keys: Vec<String>,
410
    pub touched: Vec<String>,
411
    pub owners: Vec<String>,
412
    pub landed_already: bool,
ac713df7 Andy McCall ↶ 413
    /// Whether its source has any changes at all (#141): an open proposal
414
    /// whose source has changes, none of them missing from the target, was
415
    /// landed by some other route.
416
    pub source_has_changes: bool,
de505df5 Andy McCall ↶ 417
}
418
419
/// Compute the projection and everything derived from it.
420
pub async fn load(app: &Shared, repo: &RepoName, row: pdb::ProposalRow) -> Result<Loaded, crate::repos::RepoError> {
421
    let fork_dir = match (row.source_kind.as_str(), row.source_repo_id) {
422
        ("fork", Some(id)) => {
423
            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))
424
        }
425
        _ => None,
426
    };
427
    let r2 = row.clone();
428
    let loaded = app
429
        .repos
430
        .read(repo, move |r, cache| {
431
            let source = match (r2.source_kind.as_str(), &fork_dir) {
432
                ("changes", _) => Source::Changes(r2.source_changes().iter().filter_map(|h| ChangeHash::from_hex(h)).collect()),
433
                ("fork", Some(dir)) => {
434
                    let fork_repo = oxy_core::repo::Repo::open(dir)?;
435
                    let heads =
436
                        fork_repo.bookmarks()?.into_iter().find(|b| b.name == r2.source_bookmark).map(|b| b.heads).unwrap_or_default();
437
                    // Bring the fork's changes over now, so the projection below can read them.
438
                    let fork_state = fork_repo.load_state(&heads)?;
439
                    let missing: Vec<ChangeHash> = fork_state.changes().map(|(h, _)| *h).filter(|h| !r.store().has(h)).collect();
440
                    if !missing.is_empty() {
441
                        r.import_bundle(&fork_repo.bundle_for(&missing)?, r.current_policy()?.is_some())?;
442
                    }
443
                    Source::Changes(heads)
444
                }
445
                _ => {
446
                    let heads = r.bookmarks()?.into_iter().find(|b| b.name == r2.source_bookmark).map(|b| b.heads).unwrap_or_default();
447
                    Source::Bookmark(heads)
448
                }
449
            };
ac713df7 Andy McCall ↶ 450
            let source_has_changes = match &source {
451
                Source::Changes(h) | Source::Bookmark(h) | Source::Fork { heads: h, .. } => !h.is_empty(),
452
            };
de505df5 Andy McCall ↶ 453
            let p: Projection = pl::project(r, cache, &r2.target, source)?;
454
            let target_loaded = cache.load(r, &p.target_heads)?;
455
            let files = render_files(&p.files, &p.bases);
456
            let mut atoms = HashMap::new();
457
            for f in &files {
458
                for l in &f.lines {
459
                    if let Some(n) = l.new_no {
460
                        if let Some((h, i)) = pl::atom_at(&p.loaded, &f.path, n as usize) {
461
                            atoms.insert((f.path.clone(), n as usize), (h.to_hex(), i));
462
                        }
463
                    }
464
                }
465
            }
466
            let touched: Vec<String> = p.files.iter().map(|f| f.path.clone()).collect();
467
            let owners = pl::owners_for(&target_loaded, &touched);
468
            let sealing = pl::sealing_keys(r, &p.hashes)?.into_iter().map(|k| k.to_hex()).collect();
469
            let landed_already = !p.hashes.is_empty() && p.hashes.iter().all(|h| target_loaded.state.contains(h));
0b20f1ec Andy McCall ↶ 470
            // A landed proposal's changes are in its target now: it shows
471
            // the ones it landed, as recorded when it landed.
472
            let (changes, hashes) = if r2.state == "landed" && p.hashes.is_empty() {
473
                let hashes: Vec<ChangeHash> = r2.projected_changes().iter().filter_map(|h| ChangeHash::from_hex(h)).collect();
474
                let labels = HashMap::new();
475
                let changes = hashes
476
                    .iter()
477
                    .filter_map(|h| Some(view::change_view_pub(r, h, &r.store().get_change_meta(h).ok()?, false, &labels)))
478
                    .collect();
479
                (changes, hashes)
480
            } else {
481
                (p.changes, p.hashes)
482
            };
de505df5 Andy McCall ↶ 483
            Ok(Loaded {
484
                row: r2,
0b20f1ec Andy McCall ↶ 485
                changes,
486
                hashes,
de505df5 Andy McCall ↶ 487
                heads: p.heads,
3f02e5ea Andy McCall ↶ 488
                target_heads: p.target_heads.clone(),
de505df5 Andy McCall ↶ 489
                state: p.state.to_hex(),
490
                target_state: p.target_state.to_hex(),
491
                conflicted: p.conflicted,
492
                conflicts: p.conflicts,
493
                files,
9df855f2 Andy McCall ↶ 494
                diffs: p.files.clone(),
495
                bases: p.bases.clone(),
de505df5 Andy McCall ↶ 496
                atoms,
497
                sealing_keys: sealing,
498
                touched,
499
                owners,
500
                landed_already,
ac713df7 Andy McCall ↶ 501
                source_has_changes,
de505df5 Andy McCall ↶ 502
            })
503
        })
504
        .await?;
ac713df7 Andy McCall ↶ 505
    // Landed by another route (#141): `oxy proposal land` from a client (a
506
    // repository with a trust policy needs it), or the work merged and
507
    // pushed by hand. Everything it proposes is in its target, so it is
508
    // landed, not open with nothing to land.
509
    let mut loaded = loaded;
510
    if loaded.row.is_open()
511
        && loaded.source_has_changes
512
        && loaded.hashes.is_empty()
513
        && pdb::set_landed(&app.db, loaded.row.id, &loaded.target_state, "", "push").await.is_ok()
514
    {
515
        loaded.row.state = "landed".into();
516
        loaded.row.landed_via = "push".into();
517
        loaded.row.landed_state = loaded.target_state.clone();
518
        return Ok(loaded);
519
    }
0b20f1ec Andy McCall ↶ 520
    // Remember the projection for the list, the API and other proposals'
521
    // stacking checks, while the proposal is open: a landed one keeps what
522
    // it landed.
523
    if !loaded.row.is_open() {
524
        return Ok(loaded);
525
    }
de505df5 Andy McCall ↶ 526
    let heads_csv = loaded.heads.iter().map(|h| h.to_hex()).collect::<Vec<_>>().join(",");
527
    let changes_csv = loaded.hashes.iter().map(|h| h.to_hex()).collect::<Vec<_>>().join(",");
528
    pdb::set_projection(&app.db, loaded.row.id, &loaded.state, &heads_csv, &loaded.target_state, &changes_csv, loaded.conflicted)
529
        .await
530
        .ok();
531
    Ok(loaded)
ac713df7 Andy McCall ↶ 532
}
533
534
/// After a push moved `bookmark`: open proposals into it whose work it now
535
/// holds are marked landed, by the pusher (#141).
536
pub async fn settle_after_push(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, actor: &str) {
537
    let Some(repo) = RepoName::new(&row.owner, &row.name) else { return };
538
    let Ok(open) = pdb::list(&app.db, row.id, &["open", "draft"]).await else { return };
539
    for p in open.into_iter().filter(|p| p.target == bookmark) {
540
        let id = p.id;
541
        if let Ok(l) = load(app, &repo, p).await {
542
            if l.row.state == "landed" && l.row.landed_by.is_empty() {
543
                pdb::set_landed(&app.db, id, &l.target_state, actor, "push").await.ok();
544
            }
545
        }
546
    }
de505df5 Andy McCall ↶ 547
}
548
549
/// Reviews, approvals and blockers.
550
pub struct Verdict {
551
    pub reviews: Vec<ReviewView>,
552
    pub approvals: Vec<Approval>,
553
    pub blockers: Vec<String>,
554
    pub depends_on: Vec<i64>,
555
    pub requirements: pdb::Requirements,
556
}
557
558
pub async fn verdict(app: &Shared, loaded: &Loaded, repo_id: i64) -> Result<Verdict, sqlx::Error> {
559
    let rows = pdb::reviews(&app.db, loaded.row.id).await?;
560
    let links = accounts::identity_accounts(&app.db).await?;
561
    let sealed_accounts: Vec<String> = links.iter().filter(|(k, _)| loaded.sealing_keys.contains(k)).map(|(_, u)| u.clone()).collect();
562
    // Latest verdict per reviewer.
563
    let mut latest: BTreeMap<String, &pdb::ReviewRow> = BTreeMap::new();
7d2eb590 Andy McCall ↶ 564
    for r in rows.iter().filter(|r| r.via.is_empty()) {
565
        // An assistant's answer is never the person's own verdict.
de505df5 Andy McCall ↶ 566
        latest.insert(r.author.clone(), r);
567
    }
568
    let mut approvals = Vec::new();
569
    for (who, r) in &latest {
570
        if r.verdict == "approve" {
571
            let sealed = sealed_accounts.contains(who);
572
            let key = links.iter().find(|(k, u)| u == who && loaded.sealing_keys.contains(k)).map(|(k, _)| k.clone());
573
            approvals.push(Approval { account: who.clone(), sealed, key });
574
        }
575
    }
576
    let reviews = rows
577
        .iter()
578
        .map(|r| ReviewView {
579
            row: r.clone(),
580
            body_html: render::markdown::render(&r.body, "/", "/"),
581
            sealed: r.verdict == "approve" && sealed_accounts.contains(&r.author),
582
        })
583
        .collect();
584
    let requirements = pdb::requirements(&app.db, repo_id).await?;
585
    let mut blockers = Vec::new();
0b20f1ec Andy McCall ↶ 586
    // Only an open proposal can be held back.
587
    if !loaded.row.is_open() {
588
        return Ok(Verdict { reviews, approvals, blockers, depends_on: vec![], requirements });
589
    }
de505df5 Andy McCall ↶ 590
    if loaded.row.state == "draft" {
591
        blockers.push("the proposal is a draft".into());
592
    }
593
    if loaded.hashes.is_empty() {
594
        blockers.push("the proposal adds no changes".into());
595
    }
596
    let sealed_count = approvals.iter().filter(|a| a.sealed).count() as i64;
597
    if sealed_count < requirements.required_approvals {
598
        blockers.push(format!("{sealed_count} of {} required sealed approvals", requirements.required_approvals));
599
    }
600
    if requirements.require_owners {
601
        let missing: Vec<&String> = loaded.owners.iter().filter(|o| !approvals.iter().any(|a| a.sealed && &a.account == *o)).collect();
602
        if !missing.is_empty() {
603
            blockers.push(format!("code owners still to approve: {}", missing.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")));
604
        }
605
    }
606
    if loaded.conflicted && !requirements.allow_conflicts {
607
        blockers.push("landing would carry conflicts, which this repository refuses".into());
608
    }
b64e2537 Andy McCall ↶ 609
    let required = db::hooks::required_checks(&app.db, repo_id).await?;
610
    if !required.is_empty() {
611
        let checks = db::hooks::checks_for_state(&app.db, repo_id, &loaded.state).await?;
612
        let missing: Vec<String> =
613
            required.iter().filter(|n| !checks.iter().any(|c| &c.name == *n && c.status == "passed")).cloned().collect();
614
        if !missing.is_empty() {
615
            blockers.push(format!("required checks not passed on the projected state: {}", missing.join(", ")));
616
        }
617
    }
de505df5 Andy McCall ↶ 618
    let requesting: Vec<&String> = latest.iter().filter(|(_, r)| r.verdict == "changes").map(|(who, _)| who).collect();
619
    if !requesting.is_empty() {
620
        blockers.push(format!("changes requested by {}", requesting.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")));
621
    }
622
    // Stacking: another open, earlier proposal whose changes this one includes.
623
    let mut depends_on = Vec::new();
624
    for other in pdb::list(&app.db, repo_id, &["open", "draft"]).await? {
625
        if other.id == loaded.row.id || other.number > loaded.row.number {
626
            continue;
627
        }
628
        let theirs = other.projected_changes();
629
        if !theirs.is_empty() && theirs.iter().all(|h| loaded.hashes.iter().any(|m| m.to_hex() == *h)) {
630
            depends_on.push(other.number);
631
        }
632
    }
633
    if !depends_on.is_empty() {
634
        blockers.push(format!("depends on {}", depends_on.iter().map(|n| format!("#{n}")).collect::<Vec<_>>().join(", ")));
635
    }
636
    Ok(Verdict { reviews, approvals, blockers, depends_on, requirements })
637
}
638
639
#[derive(Template)]
640
#[template(path = "proposal.html")]
641
pub struct ProposalPage {
642
    shell: Shell,
643
    repo: RepoShell,
644
    p: pdb::ProposalRow,
38833ed7 Andy McCall ↶ 645
    /// Issues it closes, from its description and its changes.
646
    closes: Vec<super::chain::Link>,
a1b98695 Andy McCall ↶ 647
    /// Projects the viewer may add it to.
648
    project_choices: Option<Vec<db::projects::Project>>,
649
    /// Comments (not anchored to a line), reviews and events, oldest first.
650
    timeline: Vec<Entry>,
651
    /// The viewer may set labels (triage or above).
652
    can_triage: bool,
de505df5 Andy McCall ↶ 653
    body_html: String,
654
    tab: String,
655
    changes: Vec<ChangeView>,
656
    files: Vec<FileRows>,
9df855f2 Andy McCall ↶ 657
    diffs: Vec<oxy_core::repo::FileDiff>,
658
    bases: std::collections::BTreeMap<String, Vec<u8>>,
659
    /// Whitespace-only changes are hidden (`?w=1`).
660
    ignore_ws: bool,
de505df5 Andy McCall ↶ 661
    conflicts: Vec<ConflictView>,
662
    conflicted: bool,
663
    comments: Vec<CommentView>,
664
    /// Anchored comments by (path, current line).
665
    placed: HashMap<(String, usize), Vec<usize>>,
666
    outdated: Vec<usize>,
667
    reviews: Vec<ReviewView>,
668
    approvals: Vec<Approval>,
669
    blockers: Vec<String>,
670
    depends_on: Vec<i64>,
671
    events: Vec<pdb::EventRow>,
672
    owners: Vec<String>,
673
    requirements: pdb::Requirements,
674
    landed_already: bool,
675
    can_edit: bool,
4f37afcc Andy McCall ↶ 676
    /// The landed proposal's source bookmark, when it is this repository's,
677
    /// still there, and the viewer may delete it (oxygenhub#86).
678
    deletable_source: Option<String>,
de505df5 Andy McCall ↶ 679
    can_land: bool,
680
    hub_can_land: bool,
681
    notice: Option<String>,
682
    error: Option<String>,
683
    heads: Vec<String>,
684
    state_hash: String,
685
    source_label: String,
686
    api_url: String,
18e381ea Andy McCall ↶ 687
    labels: Vec<db::issues::Label>,
688
    all_labels: Vec<db::issues::Label>,
b64e2537 Andy McCall ↶ 689
    checks: Vec<db::hooks::Check>,
690
    required_checks: Vec<String>,
e1ec9fbf Andy McCall ↶ 691
    /// Projects this proposal is on that the viewer may see, with the column.
692
    projects: Vec<(db::projects::Project, String)>,
cc1cd197 Andy McCall ↶ 693
    /// Asked to review and not yet reviewing.
694
    requested: Vec<String>,
8e96499f Andy McCall ↶ 695
    /// Who can be asked to review, for the sidebar's menu (oxygenhub#84).
696
    reviewable: Vec<String>,
cc1cd197 Andy McCall ↶ 697
    /// The viewer may ask for reviews (the author, or a writer).
698
    can_request: bool,
7d2eb590 Andy McCall ↶ 699
    /// AI assistants the viewer may ask, by name (hub spec §13.4).
700
    assistants: Vec<String>,
701
    /// A description an assistant just drafted, for the editor.
702
    draft: Option<String>,
de505df5 Andy McCall ↶ 703
}
704
705
#[derive(Deserialize)]
706
pub struct DetailQuery {
707
    tab: Option<String>,
a1b98695 Andy McCall ↶ 708
    /// What an action said, after it redirected here.
9df855f2 Andy McCall ↶ 709
    #[serde(default)]
a1b98695 Andy McCall ↶ 710
    notice: Option<String>,
711
    #[serde(default)]
712
    error: Option<String>,
713
    #[serde(default)]
9df855f2 Andy McCall ↶ 714
    w: Option<String>,
de505df5 Andy McCall ↶ 715
}
716
717
/// The page assembler every action ends in; its arguments are the route
718
/// plus what to say, and a struct would only rename them.
719
#[allow(clippy::too_many_arguments)]
720
async fn detail(
721
    app: &Shared,
722
    shell: Shell,
723
    viewer: &Viewer,
724
    owner: &str,
725
    name: &str,
726
    number: i64,
727
    tab: String,
728
    notice: Option<String>,
729
    error: Option<String>,
730
) -> Result<Page<ProposalPage>, Error> {
731
    let (repo, row, role) = lookup(app, &shell, viewer, owner, name).await?;
732
    let p = pdb::get(&app.db, row.id, number)
733
        .await
734
        .map_err(|e| Error::from_db(shell.clone(), e))?
735
        .ok_or_else(|| Error::not_found(shell.clone()))?;
736
    let loaded = load(app, &repo, p).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
737
    // A proposal whose changes all reached the target landed by another route.
738
    if loaded.landed_already && loaded.row.is_open() {
739
        pdb::set_state(&app.db, loaded.row.id, "landed", &loaded.target_state).await.ok();
740
        pdb::add_event(&app.db, loaded.row.id, "", "landed", "every change reached the target").await.ok();
741
    }
742
    let p = pdb::get(&app.db, row.id, number).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(loaded.row.clone());
743
    let v = verdict(app, &loaded, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
744
    let comment_rows = pdb::comments(&app.db, loaded.row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
745
    let events = pdb::events(&app.db, loaded.row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
746
    // Place anchored comments at their atom's current line.
747
    let mut by_atom: HashMap<(String, String, u32), usize> = HashMap::new();
748
    for ((path, line), (h, i)) in &loaded.atoms {
749
        by_atom.insert((path.clone(), h.clone(), *i), *line);
750
    }
751
    let mut comments = Vec::new();
752
    let mut placed: HashMap<(String, usize), Vec<usize>> = HashMap::new();
753
    let mut outdated = Vec::new();
754
    for (idx, c) in comment_rows.into_iter().enumerate() {
755
        let line = if c.anchor_change.is_empty() {
756
            None
757
        } else {
758
            by_atom.get(&(c.path.clone(), c.anchor_change.clone(), c.anchor_index as u32)).copied()
759
        };
760
        if !c.anchor_change.is_empty() {
761
            match line {
762
                Some(l) => placed.entry((c.path.clone(), l)).or_default().push(idx),
763
                None => outdated.push(idx),
764
            }
765
        }
766
        comments.push(CommentView { body_html: render::markdown::render(&c.body, "/", "/"), row: c, line });
767
    }
768
    let mut changes = loaded.changes.clone();
769
    super::attribute(app, &mut changes.iter_mut().collect::<Vec<_>>()).await;
770
    let me = viewer.account();
771
    let can_edit = me.is_some_and(|a| Some(a.id) == p.author_id || role >= Role::Maintain);
4f37afcc Andy McCall ↶ 772
    let deletable_source =
773
        if p.state == "landed" && p.source_kind == "bookmark" && role >= Role::Write && p.source_bookmark != row.default_bookmark {
774
            let b = p.source_bookmark.clone();
775
            app.repos
776
                .read(&repo, move |r, _| Ok(r.view()?.bookmarks.contains_key(&b)))
777
                .await
778
                .unwrap_or(false)
779
                .then(|| p.source_bookmark.clone())
780
        } else {
781
            None
782
        };
de505df5 Andy McCall ↶ 783
    let can_land = role >= Role::Write && p.is_open() && v.blockers.is_empty();
cc1cd197 Andy McCall ↶ 784
    let can_request = p.is_open() && me.is_some_and(|a| Some(a.id) == p.author_id || role >= Role::Write);
8e96499f Andy McCall ↶ 785
    let requested = pdb::review_requests(&app.db, p.id).await.unwrap_or_default();
786
    let mut reviewable: Vec<String> = if can_request {
787
        accounts::assignable(&app.db, row.id, &row.owner).await.unwrap_or_default().into_iter().filter(|u| *u != p.author).collect()
788
    } else {
789
        Vec::new()
790
    };
791
    for r in &requested {
792
        if !reviewable.iter().any(|x| x.eq_ignore_ascii_case(r)) {
793
            reviewable.push(r.clone());
794
        }
795
    }
7d2eb590 Andy McCall ↶ 796
    let assistants: Vec<String> = if (can_request || can_edit) && p.is_open() { assistant_names(app, &row).await } else { Vec::new() };
14530604 Andy McCall ↶ 797
    let delegated = match viewer.account() {
798
        Some(a) => super::edit::delegated_identity(app, a.id).await.is_some(),
799
        None => false,
800
    };
801
    let hub_can_land = delegated || app.repos.read(&repo, |r, _| Ok(r.current_policy()?.is_none())).await.unwrap_or(false);
de505df5 Andy McCall ↶ 802
    let source_label = match p.source_kind.as_str() {
803
        "fork" => format!(
804
            "fork {}",
805
            db::repos::get_by_id(&app.db, p.source_repo_id.unwrap_or(0))
806
                .await
807
                .ok()
808
                .flatten()
809
                .map(|r| format!("{}:{}", r.slug(), p.source_bookmark))
810
                .unwrap_or_default()
811
        ),
812
        "changes" => format!("{} changes", p.source_changes().len()),
813
        _ => p.source_bookmark.clone(),
814
    };
e1ec9fbf Andy McCall ↶ 815
    let projects = super::projects::placements_for(app, viewer, &row, p.number).await;
38833ed7 Andy McCall ↶ 816
    let texts: Vec<String> = std::iter::once(p.body.clone()).chain(changes.iter().map(|c| format!("{}\n{}", c.title, c.body))).collect();
817
    let closes = super::chain::issues_closed_by(app, &row, &texts.iter().map(String::as_str).collect::<Vec<_>>()).await;
a1b98695 Andy McCall ↶ 818
    let project_choices = super::projects::project_choices(app, viewer, &row, &projects).await;
819
    let mut dated: Vec<(i64, Entry)> = Vec::new();
820
    for (index, c) in comments.iter().enumerate() {
821
        if c.row.anchor_change.is_empty() {
822
            dated.push((c.row.created_ms, Entry { kind: "comment", index }));
823
        }
824
    }
825
    for (index, r) in v.reviews.iter().enumerate() {
826
        dated.push((r.row.created_ms, Entry { kind: "review", index }));
827
    }
828
    for (index, e) in events.iter().enumerate() {
829
        dated.push((e.at_ms, Entry { kind: "event", index }));
830
    }
831
    dated.sort_by_key(|(at, _)| *at);
832
    let timeline: Vec<Entry> = dated.into_iter().map(|(_, e)| e).collect();
de505df5 Andy McCall ↶ 833
    Ok(Page(ProposalPage {
a1b98695 Andy McCall ↶ 834
        can_triage: role >= Role::Triage,
835
        timeline,
38833ed7 Andy McCall ↶ 836
        closes,
a1b98695 Andy McCall ↶ 837
        project_choices,
e1ec9fbf Andy McCall ↶ 838
        projects,
de505df5 Andy McCall ↶ 839
        shell,
840
        repo: RepoShell::new(app, &row, "proposals").with_role(role),
c79a1111 Andy McCall ↶ 841
        body_html: super::issues::markdown(app, &row, &row.default_bookmark, &p.body).await,
de505df5 Andy McCall ↶ 842
        p,
843
        tab,
844
        changes,
845
        files: loaded.files,
9df855f2 Andy McCall ↶ 846
        diffs: loaded.diffs,
847
        bases: loaded.bases,
848
        ignore_ws: false,
de505df5 Andy McCall ↶ 849
        conflicts: loaded.conflicts,
850
        conflicted: loaded.conflicted,
851
        comments,
852
        placed,
853
        outdated,
854
        reviews: v.reviews,
855
        approvals: v.approvals,
856
        blockers: v.blockers,
857
        depends_on: v.depends_on,
858
        events,
859
        owners: loaded.owners,
860
        requirements: v.requirements,
861
        landed_already: loaded.landed_already,
862
        can_edit,
4f37afcc Andy McCall ↶ 863
        deletable_source,
de505df5 Andy McCall ↶ 864
        can_land,
865
        hub_can_land,
866
        notice,
867
        error,
868
        heads: loaded.heads.iter().map(|h| h.to_hex()).collect(),
b64e2537 Andy McCall ↶ 869
        state_hash: loaded.state.clone(),
de505df5 Andy McCall ↶ 870
        source_label,
871
        api_url: format!("{}/api/v1/repos/{owner}/{name}/proposals/{number}", app.config.base_url()),
18e381ea Andy McCall ↶ 872
        labels: db::issues::proposal_labels(&app.db, loaded.row.id).await.unwrap_or_default(),
873
        all_labels: db::issues::labels(&app.db, row.id).await.unwrap_or_default(),
b64e2537 Andy McCall ↶ 874
        checks: db::hooks::checks_for_state(&app.db, row.id, &loaded.state).await.unwrap_or_default(),
875
        required_checks: db::hooks::required_checks(&app.db, row.id).await.unwrap_or_default(),
8e96499f Andy McCall ↶ 876
        requested,
877
        reviewable,
7d2eb590 Andy McCall ↶ 878
        assistants,
879
        draft: None,
cc1cd197 Andy McCall ↶ 880
        can_request,
de505df5 Andy McCall ↶ 881
    }))
882
}
883
a1b98695 Andy McCall ↶ 884
/// Back to the proposal after an action, with what to say: a redirect, so
885
/// reloading the page (the checks script does) never repeats the action.
886
fn back(owner: &str, name: &str, number: i64, tab: &str, notice: Option<String>, error: Option<String>) -> Response {
887
    let mut url = format!("/{owner}/{name}/proposal/{number}?tab={tab}");
888
    if let Some(n) = notice {
889
        url.push_str(&format!("&notice={}", super::settings::urlencode(&n)));
890
    }
891
    if let Some(e) = error {
892
        url.push_str(&format!("&error={}", super::settings::urlencode(&e)));
893
    }
894
    Redirect::to(&url).into_response()
895
}
896
de505df5 Andy McCall ↶ 897
pub async fn show(
898
    State(app): State<Shared>,
899
    viewer: Viewer,
900
    Path((owner, name, number)): Path<(String, String, i64)>,
901
    Query(q): Query<DetailQuery>,
902
) -> Result<Page<ProposalPage>, Error> {
903
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
a1b98695 Andy McCall ↶ 904
    let mut page =
905
        detail(&app, shell, &viewer, &owner, &name, number, q.tab.unwrap_or_else(|| "conversation".into()), q.notice, q.error).await?;
9df855f2 Andy McCall ↶ 906
    if q.w.as_deref() == Some("1") {
907
        // Hide whitespace-only changes: the same hunks, rendered again.
908
        page.0.files =
909
            super::history::render_files_with(&page.0.diffs, &page.0.bases, crate::render::diff::Options { ignore_whitespace: true });
910
        page.0.ignore_ws = true;
911
    }
912
    Ok(page)
de505df5 Andy McCall ↶ 913
}
914
915
// ---------------------------------------------------------------- actions
916
917
#[derive(Deserialize)]
918
pub struct CommentForm {
919
    #[serde(rename = "_csrf")]
920
    csrf_token: String,
921
    body: String,
922
    #[serde(default)]
923
    path: String,
924
    #[serde(default)]
925
    line: Option<usize>,
926
}
927
928
pub async fn comment(
929
    State(app): State<Shared>,
930
    viewer: Viewer,
931
    Path((owner, name, number)): Path<(String, String, i64)>,
932
    Form(f): Form<CommentForm>,
a1b98695 Andy McCall ↶ 933
) -> Result<Response, Error> {
de505df5 Andy McCall ↶ 934
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
935
    let me = require(&viewer, &shell)?;
936
    csrf(&viewer, &f.csrf_token, &shell)?;
937
    let (repo, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
938
    let p = pdb::get(&app.db, row.id, number)
939
        .await
940
        .map_err(|e| Error::from_db(shell.clone(), e))?
941
        .ok_or_else(|| Error::not_found(shell.clone()))?;
942
    if f.body.trim().is_empty() {
a1b98695 Andy McCall ↶ 943
        return Ok(back(&owner, &name, number, "conversation", None, Some("An empty comment says nothing.".into())));
de505df5 Andy McCall ↶ 944
    }
945
    let anchor = match (f.path.trim(), f.line) {
946
        ("", _) | (_, None) => None,
947
        (path, Some(line)) => {
948
            let loaded = load(&app, &repo, p.clone()).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
949
            loaded.atoms.get(&(path.to_string(), line)).cloned()
950
        }
951
    };
952
    let tab = if anchor.is_some() { "files" } else { "conversation" };
953
    pdb::add_comment(
954
        &app.db,
955
        p.id,
956
        Some(me.account.id),
957
        &me.account.username,
958
        f.body.trim(),
959
        f.path.trim(),
960
        anchor.as_ref().map(|(h, i)| (h.as_str(), *i as i64)),
961
    )
962
    .await
963
    .map_err(|e| Error::from_db(shell.clone(), e))?;
a1b98695 Andy McCall ↶ 964
    Ok(back(&owner, &name, number, tab, None, None))
de505df5 Andy McCall ↶ 965
}
966
967
#[derive(Deserialize)]
968
pub struct ReviewForm {
969
    #[serde(rename = "_csrf")]
970
    csrf_token: String,
971
    verdict: String,
972
    #[serde(default)]
973
    body: String,
974
}
975
976
pub async fn review(
977
    State(app): State<Shared>,
978
    viewer: Viewer,
979
    Path((owner, name, number)): Path<(String, String, i64)>,
980
    Form(f): Form<ReviewForm>,
a1b98695 Andy McCall ↶ 981
) -> Result<Response, Error> {
de505df5 Andy McCall ↶ 982
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
983
    let me = require(&viewer, &shell)?;
984
    csrf(&viewer, &f.csrf_token, &shell)?;
985
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
986
    let p = pdb::get(&app.db, row.id, number)
987
        .await
988
        .map_err(|e| Error::from_db(shell.clone(), e))?
989
        .ok_or_else(|| Error::not_found(shell.clone()))?;
990
    if !matches!(f.verdict.as_str(), "comment" | "changes" | "approve") {
991
        return Err(Error::bad(shell, "a review is comment, changes or approve"));
992
    }
993
    if f.verdict == "approve" && Some(me.account.id) == p.author_id {
a1b98695 Andy McCall ↶ 994
        return Ok(back(&owner, &name, number, "conversation", None, Some("You cannot approve your own proposal.".into())));
de505df5 Andy McCall ↶ 995
    }
996
    pdb::add_review(&app.db, p.id, me.account.id, &me.account.username, &f.verdict, f.body.trim())
997
        .await
998
        .map_err(|e| Error::from_db(shell.clone(), e))?;
999
    pdb::add_event(&app.db, p.id, &me.account.username, &format!("review.{}", f.verdict), "").await.ok();
cc1cd197 Andy McCall ↶ 1000
    reviewed(&app, &row, &p, &me.account, &f.verdict).await;
b64e2537 Andy McCall ↶ 1001
    crate::hooks::emit(&app, &row, "review", &me.account.username, serde_json::json!({ "number": number, "verdict": f.verdict })).await;
de505df5 Andy McCall ↶ 1002
    let notice = if f.verdict == "approve" {
1003
        Some(format!(
1004
            "Approval recorded. It counts once you seal the changes: oxy seal --proposal {}/{owner}/{name}/proposal/{number}",
1005
            app.config.base_url()
1006
        ))
1007
    } else {
1008
        None
1009
    };
a1b98695 Andy McCall ↶ 1010
    Ok(back(&owner, &name, number, "conversation", notice, None))
de505df5 Andy McCall ↶ 1011
}
1012
1013
#[derive(Deserialize)]
1014
pub struct EditForm {
1015
    #[serde(rename = "_csrf")]
1016
    csrf_token: String,
1017
    title: String,
1018
    #[serde(default)]
1019
    body: String,
1020
    target: String,
1021
}
1022
1023
pub async fn edit(
1024
    State(app): State<Shared>,
1025
    viewer: Viewer,
1026
    Path((owner, name, number)): Path<(String, String, i64)>,
1027
    Form(f): Form<EditForm>,
a1b98695 Andy McCall ↶ 1028
) -> Result<Response, Error> {
de505df5 Andy McCall ↶ 1029
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1030
    let me = require(&viewer, &shell)?;
1031
    csrf(&viewer, &f.csrf_token, &shell)?;
1032
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1033
    let p = pdb::get(&app.db, row.id, number)
1034
        .await
1035
        .map_err(|e| Error::from_db(shell.clone(), e))?
1036
        .ok_or_else(|| Error::not_found(shell.clone()))?;
1037
    if Some(me.account.id) != p.author_id && role < Role::Maintain {
1038
        return Err(Error::not_found(shell));
1039
    }
1040
    let target = if crate::repos::names::valid_segment(f.target.trim()) { f.target.trim() } else { &p.target };
1041
    pdb::edit(&app.db, p.id, f.title.trim(), f.body.trim(), target).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1042
    pdb::add_event(&app.db, p.id, &me.account.username, "edited", "").await.ok();
a1b98695 Andy McCall ↶ 1043
    Ok(back(&owner, &name, number, "conversation", Some("Saved.".into()), None))
de505df5 Andy McCall ↶ 1044
}
1045
1046
#[derive(Deserialize)]
1047
pub struct StateForm {
1048
    #[serde(rename = "_csrf")]
1049
    csrf_token: String,
1050
    action: String,
1051
}
1052
1053
pub async fn state(
1054
    State(app): State<Shared>,
1055
    viewer: Viewer,
1056
    Path((owner, name, number)): Path<(String, String, i64)>,
1057
    Form(f): Form<StateForm>,
a1b98695 Andy McCall ↶ 1058
) -> Result<Response, Error> {
de505df5 Andy McCall ↶ 1059
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1060
    let me = require(&viewer, &shell)?;
1061
    csrf(&viewer, &f.csrf_token, &shell)?;
1062
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1063
    let p = pdb::get(&app.db, row.id, number)
1064
        .await
1065
        .map_err(|e| Error::from_db(shell.clone(), e))?
1066
        .ok_or_else(|| Error::not_found(shell.clone()))?;
1067
    if Some(me.account.id) != p.author_id && role < Role::Triage {
1068
        return Err(Error::not_found(shell));
1069
    }
1070
    let new_state = match (f.action.as_str(), p.state.as_str()) {
1071
        ("close", "open" | "draft") => "closed",
1072
        ("reopen", "closed") => "open",
1073
        ("ready", "draft") => "open",
1074
        ("draft", "open") => "draft",
1075
        _ => return Err(Error::bad(shell, "that transition is not possible")),
1076
    };
1077
    pdb::set_state(&app.db, p.id, new_state, "").await.map_err(|e| Error::from_db(shell.clone(), e))?;
1078
    pdb::add_event(&app.db, p.id, &me.account.username, new_state, "").await.ok();
b64e2537 Andy McCall ↶ 1079
    crate::hooks::emit(&app, &row, &format!("proposal.{new_state}"), &me.account.username, serde_json::json!({ "number": number })).await;
a1b98695 Andy McCall ↶ 1080
    Ok(back(&owner, &name, number, "conversation", None, None))
de505df5 Andy McCall ↶ 1081
}
1082
1083
pub async fn resolve_comment(
1084
    State(app): State<Shared>,
1085
    viewer: Viewer,
1086
    Path((owner, name, number, id)): Path<(String, String, i64, i64)>,
1087
    Form(f): Form<super::session::CsrfForm>,
a1b98695 Andy McCall ↶ 1088
) -> Result<Response, Error> {
de505df5 Andy McCall ↶ 1089
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1090
    require(&viewer, &shell)?;
1091
    csrf(&viewer, &f.csrf, &shell)?;
1092
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1093
    let p = pdb::get(&app.db, row.id, number)
1094
        .await
1095
        .map_err(|e| Error::from_db(shell.clone(), e))?
1096
        .ok_or_else(|| Error::not_found(shell.clone()))?;
1097
    let current: Option<bool> = sqlx::query_scalar("SELECT resolved FROM proposal_comments WHERE id = ? AND proposal_id = ?")
1098
        .bind(id)
1099
        .bind(p.id)
1100
        .fetch_optional(&app.db)
1101
        .await
1102
        .map_err(|e| Error::from_db(shell.clone(), e))?;
1103
    if let Some(r) = current {
1104
        pdb::set_comment_resolved(&app.db, id, !r).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1105
    }
a1b98695 Andy McCall ↶ 1106
    Ok(back(&owner, &name, number, "files", None, None))
de505df5 Andy McCall ↶ 1107
}
1108
1109
/// Land from the hub: only when the repository has no trust policy (the
1110
/// hub never signs a reference update the policy would have to admit).
1111
pub async fn land(
1112
    State(app): State<Shared>,
1113
    viewer: Viewer,
1114
    Path((owner, name, number)): Path<(String, String, i64)>,
1115
    Form(f): Form<super::session::CsrfForm>,
a1b98695 Andy McCall ↶ 1116
) -> Result<Response, Error> {
de505df5 Andy McCall ↶ 1117
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1118
    let me = require(&viewer, &shell)?;
1119
    csrf(&viewer, &f.csrf, &shell)?;
1120
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
0b20f1ec Andy McCall ↶ 1121
    match land_as(&app, &repo, &row, &me.account, number, "web").await {
a1b98695 Andy McCall ↶ 1122
        Ok(_) => Ok(back(&owner, &name, number, "conversation", Some("Landed.".into()), None)),
1f1eb06a Andy McCall ↶ 1123
        Err(Landing::NotFound) => Err(Error::not_found(shell)),
a1b98695 Andy McCall ↶ 1124
        Err(Landing::Refused(m)) => Ok(back(&owner, &name, number, "conversation", None, Some(m))),
1f1eb06a Andy McCall ↶ 1125
    }
1126
}
1127
1128
/// Why a proposal did not land.
1129
pub enum Landing {
1130
    NotFound,
1131
    Refused(String),
1132
}
1133
1134
/// Land proposal `number` as `me` (hub spec §8.4), who has write access:
1135
/// refused while it is not open or has blockers. Moves the target bookmark
1136
/// (attested by the hub key on `me`'s behalf when they delegated to it),
0b20f1ec Andy McCall ↶ 1137
/// then records, announces and audits the landing, saying how it was done
1138
/// (`via`: `web`, or `api (token NAME)`). Returns the new state.
1f1eb06a Andy McCall ↶ 1139
pub async fn land_as(
1140
    app: &Shared,
1141
    repo: &RepoName,
1142
    row: &db::repos::RepoRow,
1143
    me: &accounts::Account,
1144
    number: i64,
0b20f1ec Andy McCall ↶ 1145
    via: &str,
1f1eb06a Andy McCall ↶ 1146
) -> Result<String, Landing> {
1147
    let (owner, name) = (row.owner.clone(), row.name.clone());
1148
    let refused = |e: String| Landing::Refused(e);
1149
    let p = pdb::get(&app.db, row.id, number).await.map_err(|e| refused(e.to_string()))?.ok_or(Landing::NotFound)?;
1150
    let loaded = load(app, repo, p.clone()).await.map_err(|e| refused(e.to_string()))?;
1151
    let v = verdict(app, &loaded, row.id).await.map_err(|e| refused(e.to_string()))?;
1152
    if !p.is_open() {
1153
        return Err(refused(format!("Cannot land: the proposal is {}", p.state)));
1154
    }
1155
    if !v.blockers.is_empty() {
1156
        return Err(refused(format!("Cannot land: {}", v.blockers.join("; "))));
de505df5 Andy McCall ↶ 1157
    }
1158
    let target = p.target.clone();
1159
    let heads = loaded.heads.clone();
14530604 Andy McCall ↶ 1160
    // With a delegation the landing is attested by the hub key on the
1161
    // user's behalf, which a policy accepts when it lets the user update
1162
    // the target (spec §14.5); without one, only a policy-free repository
1163
    // can be landed here.
1f1eb06a Andy McCall ↶ 1164
    let result = match super::edit::signer_for(app, me.id).await {
1165
        Ok(signer) => crate::repos::edits::move_bookmark(&app.repos, repo, target.clone(), heads.clone(), signer).await.map(|_| pl::state_ref(&heads).to_hex()),
14530604 Andy McCall ↶ 1166
        Err(why) => {
1167
            app.repos
1f1eb06a Andy McCall ↶ 1168
                .write(repo, move |r, _| {
14530604 Andy McCall ↶ 1169
                    if r.current_policy()?.is_some() {
1170
                        return Err(crate::repos::RepoError::Oxy(oxy_core::repo::RepoError::Msg(format!(
1171
                            "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}"
1172
                        ))));
1173
                    }
1174
                    r.bookmark_set(&target, &heads)?;
1175
                    Ok(pl::state_ref(&heads).to_hex())
1176
                })
1177
                .await
1178
        }
1179
    };
1f1eb06a Andy McCall ↶ 1180
    let state = result.map_err(|e| refused(e.to_string()))?;
0b20f1ec Andy McCall ↶ 1181
    pdb::set_landed(&app.db, p.id, &state, &me.username, via).await.map_err(|e| refused(e.to_string()))?;
1182
    let how = if v.approvals.is_empty() { format!("{via}, with no approval") } else { via.to_string() };
1183
    pdb::add_event(&app.db, p.id, &me.username, "landed", &how).await.ok();
1f1eb06a Andy McCall ↶ 1184
    crate::hooks::emit(
1185
        app,
1186
        row,
1187
        "proposal.landed",
1188
        &me.username,
1189
        serde_json::json!({ "number": number, "target": p.target, "state": state }),
1190
    )
1191
    .await;
1192
    crate::notify::deliver(
1193
        app,
1194
        crate::notify::Event {
1195
            kind: "proposal.landed",
1196
            title: format!("{owner}/{name}#{number}: {} landed", p.title),
1197
            url: format!("/{owner}/{name}/proposal/{number}"),
1198
            actor: me,
1199
            repo_id: row.id,
1200
            thread: Some(("proposal", p.id)),
1201
            mention_text: "",
1202
            direct: p.author_id.into_iter().collect(),
1203
        },
1204
    )
1205
    .await;
1206
    if p.target == row.default_bookmark {
1207
        let messages: Vec<(String, String)> = loaded.changes.iter().map(|c| (c.hash.clone(), format!("{}\n{}", c.title, c.body))).collect();
1208
        super::issues::close_from_messages(app, row, &messages).await;
de505df5 Andy McCall ↶ 1209
    }
0b20f1ec Andy McCall ↶ 1210
    accounts::audit(&app.db, Some(me), "proposal.landed", &format!("{owner}/{name}#{number}"), &how).await.ok();
1f1eb06a Andy McCall ↶ 1211
    db::repos::record_push(
1212
        &app.db,
1213
        row.id,
1214
        Some((me.id, me.username.as_str())),
1215
        &[db::repos::PushOutcome {
1216
            reference: p.target.clone(),
1217
            ok: true,
0b20f1ec Andy McCall ↶ 1218
            reason: format!("landed #{number} ({how})"),
1f1eb06a Andy McCall ↶ 1219
            old_state: loaded.target_state.clone(),
1220
            new_state: state.clone(),
1221
            heads: loaded.heads.len() as i64,
1222
            changes: loaded.hashes.len() as i64,
1223
        }],
1224
    )
1225
    .await
1226
    .ok();
1227
    Ok(state)
de505df5 Andy McCall ↶ 1228
}
1229
18e381ea Andy McCall ↶ 1230
#[derive(Deserialize)]
1231
pub struct LabelsForm {
1232
    #[serde(rename = "_csrf")]
1233
    csrf_token: String,
1234
    #[serde(default)]
1235
    label: Vec<i64>,
1236
}
1237
1238
pub async fn set_labels(
1239
    State(app): State<Shared>,
1240
    viewer: Viewer,
1241
    Path((owner, name, number)): Path<(String, String, i64)>,
1242
    axum_extra::extract::Form(f): axum_extra::extract::Form<LabelsForm>,
a1b98695 Andy McCall ↶ 1243
) -> Result<Response, Error> {
18e381ea Andy McCall ↶ 1244
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1245
    require(&viewer, &shell)?;
1246
    csrf(&viewer, &f.csrf_token, &shell)?;
1247
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1248
    if role < Role::Triage {
1249
        return Err(Error::not_found(shell));
1250
    }
1251
    let p = pdb::get(&app.db, row.id, number)
1252
        .await
1253
        .map_err(|e| Error::from_db(shell.clone(), e))?
1254
        .ok_or_else(|| Error::not_found(shell.clone()))?;
1255
    for l in db::issues::labels(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1256
        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))?;
1257
    }
a1b98695 Andy McCall ↶ 1258
    Ok(back(&owner, &name, number, "conversation", None, None))
18e381ea Andy McCall ↶ 1259
}
1260
b64e2537 Andy McCall ↶ 1261
/// `GET /{owner}/{name}/proposal/{number}/events`: server-sent events for
1262
/// checks on the projected state (hub spec §13.1).
1263
pub async fn events(
1264
    State(app): State<Shared>,
1265
    viewer: Viewer,
1266
    Path((owner, name, number)): Path<(String, String, i64)>,
1267
) -> Result<axum::response::Sse<impl futures::Stream<Item = Result<axum::response::sse::Event, std::convert::Infallible>>>, Error> {
1268
    use futures::StreamExt;
1269
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1270
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1271
    let rx = app.events.proposals.subscribe();
1272
    let repo_id = row.id;
1273
    let stream = tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(move |item| async move {
1274
        match item {
1275
            Ok((id, ev)) if id == repo_id => {
1276
                Some(Ok(axum::response::sse::Event::default().event(ev.kind.clone()).data(serde_json::to_string(&ev).unwrap_or_default())))
1277
            }
1278
            _ => None,
1279
        }
1280
    });
1281
    Ok(axum::response::Sse::new(stream).keep_alive(axum::response::sse::KeepAlive::default()))
1282
}
1283
de505df5 Andy McCall ↶ 1284
// -------------------------------------------------------------------- API
1285
1286
pub async fn to_api(app: &Shared, owner: &str, name: &str, loaded: &Loaded, v: &Verdict, source_url: Option<String>) -> hub_api::Proposal {
1287
    let p = &loaded.row;
1288
    hub_api::Proposal {
1289
        number: p.number,
1290
        title: p.title.clone(),
1291
        body: p.body.clone(),
1292
        author: p.author.clone(),
1293
        state: ProposalState::parse(&p.state).unwrap_or(ProposalState::Open),
1294
        target: p.target.clone(),
1295
        source: match p.source_kind.as_str() {
1296
            "fork" => {
1297
                let (o, n) = source_url
1298
                    .as_deref()
1299
                    .and_then(|u| u.rsplit_once('/'))
1300
                    .and_then(|(b, n)| b.rsplit_once('/').map(|(_, o)| (o.to_string(), n.to_string())))
1301
                    .unwrap_or_default();
1302
                ProposalSource::Fork { owner: o, name: n, bookmark: p.source_bookmark.clone() }
1303
            }
1304
            "changes" => ProposalSource::Changes { hashes: p.source_changes() },
1305
            _ => ProposalSource::Bookmark { bookmark: p.source_bookmark.clone() },
1306
        },
1307
        changes: loaded
1308
            .changes
1309
            .iter()
1310
            .map(|c| hub_api::ChangeSummary {
1311
                hash: c.hash.clone(),
1312
                message: if c.body.is_empty() { c.title.clone() } else { format!("{}\n\n{}", c.title, c.body) },
1313
                author: hub_api::Author {
1314
                    name: c.author.name.clone(),
1315
                    email: c.author.email.clone(),
1316
                    key: c.author.key.clone(),
1317
                    account: c.author.account.clone(),
760a7dd3 Andy McCall ↶ 1318
                    note: hub_api::Author::note_for(&c.author.key, &c.author.account),
de505df5 Andy McCall ↶ 1319
                },
1320
                when_ms: c.when_ms,
1321
                deps: c.deps.clone(),
1322
                head: c.head,
8933faff Andy McCall ↶ 1323
                via: c.via.as_ref().map(|v| hub_api::Via {
1324
                    name: v.name.clone(),
1325
                    key: v.key.clone(),
1326
                    scope: v.scope.clone(),
1327
                    expires_ms: v.expires_ms,
1328
                    revoked: v.revoked,
1329
                }),
de505df5 Andy McCall ↶ 1330
                signature: match &c.signature {
1331
                    view::SigView::Unsigned => hub_api::Signature::Unsigned,
1332
                    view::SigView::Valid { key, sealed, revoked, .. } => {
1333
                        hub_api::Signature::Valid { key: key.clone(), sealed: *sealed, revoked: *revoked }
1334
                    }
1335
                    view::SigView::Invalid(why) => hub_api::Signature::Invalid { reason: why.clone() },
1336
                },
1337
            })
1338
            .collect(),
1339
        projected_heads: loaded.heads.iter().map(|h| h.to_hex()).collect(),
1340
        projected_state: Some(loaded.state.clone()),
1341
        conflicted: loaded.conflicted,
1342
        approvals: v.approvals.clone(),
1343
        landable: p.is_open() && v.blockers.is_empty(),
1344
        blockers: v.blockers.clone(),
1345
        created_ms: p.created_ms,
1346
        updated_ms: p.updated_ms,
1347
        url: format!("{}/{owner}/{name}/proposal/{}", app.config.base_url(), p.number),
1348
        repository_url: format!("{}/{owner}/{name}", app.config.base_url()),
1349
        source_url,
0b20f1ec Andy McCall ↶ 1350
        landed: (p.state == "landed" && p.landed_ms > 0).then(|| hub_api::Landed {
1351
            by: p.landed_by.clone(),
1352
            via: p.landed_via.clone(),
1353
            when_ms: p.landed_ms,
1354
        }),
cc1cd197 Andy McCall ↶ 1355
        requested_reviewers: pdb::review_requests(&app.db, p.id).await.unwrap_or_default(),
2291f8cc Andy McCall ↶ 1356
        labels: db::issues::proposal_labels(&app.db, p.id)
1357
            .await
1358
            .unwrap_or_default()
1359
            .into_iter()
1360
            .map(|l| hub_api::Label { name: l.name, colour: l.colour, description: l.description })
1361
            .collect(),
de505df5 Andy McCall ↶ 1362
    }
1363
}
1364
1365
/// The line comment form's placement key, for templates.
1366
pub fn key(path: &str, line: impl filters::Num) -> (String, usize) {
1367
    (path.to_string(), line.as_u64() as usize)
1368
}
1369
1370
#[allow(dead_code)]
1371
fn _unused(_: &diff::Line, _: &ConflictView, _: &Role) {}
cc1cd197 Andy McCall ↶ 1372
1373
// ------------------------------------------------------- review requests
1374
1375
/// Ask accounts to review a proposal (hub plan 0.4): each must exist, be
1376
/// someone other than the author, and be able to read the repository. Each
1377
/// newly asked is told, and the proposal records it. The usernames newly
1378
/// asked, or why none could be.
1379
pub async fn request_reviewers(
1380
    app: &Shared,
1381
    row: &db::repos::RepoRow,
1382
    p: &pdb::ProposalRow,
1383
    by: &accounts::Account,
1384
    names: &[String],
1385
) -> Result<Vec<String>, String> {
1386
    let mut found = Vec::new();
1387
    for name in names.iter().map(|n| n.trim().trim_start_matches('@')).filter(|n| !n.is_empty()) {
1388
        let Some(a) = accounts::account_by_name(&app.db, name).await.ok().flatten() else {
1389
            return Err(format!("There is no account {name}."));
1390
        };
1391
        if Some(a.id) == p.author_id {
1392
            return Err(format!("{name} wrote this proposal, and cannot review it."));
1393
        }
1394
        let role = crate::auth::roles::role_for(&app.db, Some(&a), row).await.ok().flatten();
1395
        if !crate::auth::roles::allows(role, crate::auth::Access::Read) {
1396
            return Err(format!("{name} cannot read this repository."));
1397
        }
1398
        found.push(a);
1399
    }
1400
    let mut asked = Vec::new();
1401
    for a in found {
1402
        if !pdb::request_review(&app.db, p.id, a.id, &by.username).await.map_err(|e| e.to_string())? {
1403
            continue;
1404
        }
1405
        pdb::add_event(&app.db, p.id, &by.username, "review requested", &a.username).await.ok();
1406
        crate::notify::deliver(
1407
            app,
1408
            crate::notify::Event {
1409
                kind: "proposal.review_requested",
1410
                title: format!("{}/{}#{}: {} (review requested from {})", row.owner, row.name, p.number, p.title, a.username),
1411
                url: format!("/{}/{}/proposal/{}", row.owner, row.name, p.number),
1412
                actor: by,
1413
                repo_id: row.id,
1414
                thread: None,
1415
                mention_text: "",
1416
                direct: vec![a.id],
1417
            },
1418
        )
1419
        .await;
1420
        asked.push(a.username);
1421
    }
1422
    Ok(asked)
1423
}
1424
1425
/// After a review: it answers the reviewer's request, if there was one, and
1426
/// the proposal's author is told.
1427
pub async fn reviewed(app: &Shared, row: &db::repos::RepoRow, p: &pdb::ProposalRow, by: &accounts::Account, verdict: &str) {
1428
    pdb::unrequest_review(&app.db, p.id, by.id).await.ok();
1429
    let what = match verdict {
1430
        "approve" => "approved",
1431
        "changes" => "changes requested",
1432
        _ => "reviewed",
1433
    };
1434
    crate::notify::deliver(
1435
        app,
1436
        crate::notify::Event {
1437
            kind: "proposal.review",
1438
            title: format!("{}/{}#{}: {} ({what} by {})", row.owner, row.name, p.number, p.title, by.username),
1439
            url: format!("/{}/{}/proposal/{}", row.owner, row.name, p.number),
1440
            actor: by,
1441
            repo_id: row.id,
1442
            thread: Some(("proposal", p.id)),
1443
            mention_text: "",
1444
            direct: p.author_id.into_iter().collect(),
1445
        },
1446
    )
1447
    .await;
1448
}
1449
1450
#[derive(Deserialize)]
1451
pub struct ReviewersForm {
1452
    #[serde(rename = "_csrf")]
1453
    csrf_token: String,
8e96499f Andy McCall ↶ 1454
    /// `add`, `remove`, or `set` (the menu: exactly these are asked).
cc1cd197 Andy McCall ↶ 1455
    action: String,
8e96499f Andy McCall ↶ 1456
    #[serde(default)]
1457
    username: Vec<String>,
cc1cd197 Andy McCall ↶ 1458
}
1459
1460
/// `POST /{owner}/{name}/proposal/{number}/reviewers`: the author or a
1461
/// writer asks someone to review, or withdraws the request.
1462
pub async fn reviewers(
1463
    State(app): State<Shared>,
1464
    viewer: Viewer,
1465
    Path((owner, name, number)): Path<(String, String, i64)>,
8e96499f Andy McCall ↶ 1466
    axum_extra::extract::Form(f): axum_extra::extract::Form<ReviewersForm>,
cc1cd197 Andy McCall ↶ 1467
) -> Result<Response, Error> {
1468
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1469
    let me = require(&viewer, &shell)?;
1470
    csrf(&viewer, &f.csrf_token, &shell)?;
1471
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1472
    let p = pdb::get(&app.db, row.id, number)
1473
        .await
1474
        .map_err(|e| Error::from_db(shell.clone(), e))?
1475
        .ok_or_else(|| Error::not_found(shell.clone()))?;
1476
    if Some(me.account.id) != p.author_id && role < Role::Write {
1477
        return Err(Error::not_found(shell));
1478
    }
8e96499f Andy McCall ↶ 1479
    let names: Vec<String> = f.username.iter().map(|n| n.trim().trim_start_matches('@').to_string()).filter(|n| !n.is_empty()).collect();
1480
    let current = pdb::review_requests(&app.db, p.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1481
    // Withdrawn: those named (remove), or those the menu left out (set).
1482
    let withdraw: Vec<String> = match f.action.as_str() {
1483
        "remove" => names.clone(),
1484
        "set" => current.iter().filter(|c| !names.iter().any(|n| n.eq_ignore_ascii_case(c))).cloned().collect(),
1485
        _ => Vec::new(),
1486
    };
1487
    for who in &withdraw {
1488
        if let Some(a) = accounts::account_by_name(&app.db, who).await.ok().flatten() {
cc1cd197 Andy McCall ↶ 1489
            if pdb::unrequest_review(&app.db, p.id, a.id).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1490
                pdb::add_event(&app.db, p.id, &me.account.username, "review request withdrawn", &a.username).await.ok();
1491
            }
1492
        }
8e96499f Andy McCall ↶ 1493
    }
1494
    let ask: Vec<String> = match f.action.as_str() {
1495
        "add" => names,
1496
        "set" => names.into_iter().filter(|n| !current.iter().any(|c| c.eq_ignore_ascii_case(n))).collect(),
1497
        _ => return Ok(back(&owner, &name, number, "conversation", None, None)),
1498
    };
1499
    if ask.is_empty() {
cc1cd197 Andy McCall ↶ 1500
        return Ok(back(&owner, &name, number, "conversation", None, None));
1501
    }
8e96499f Andy McCall ↶ 1502
    match request_reviewers(&app, &row, &p, &me.account, &ask).await {
cc1cd197 Andy McCall ↶ 1503
        Ok(asked) if asked.is_empty() => Ok(back(&owner, &name, number, "conversation", Some("Already asked.".into()), None)),
1504
        Ok(asked) => Ok(back(&owner, &name, number, "conversation", Some(format!("Asked {} to review.", asked.join(", "))), None)),
1505
        Err(e) => Ok(back(&owner, &name, number, "conversation", None, Some(e))),
7d2eb590 Andy McCall ↶ 1506
    }
1507
}
1508
1509
#[derive(Deserialize)]
1510
pub struct AssistantForm {
1511
    #[serde(rename = "_csrf")]
1512
    csrf_token: String,
1513
    assistant: String,
1514
}
1515
1516
/// `POST /{owner}/{name}/proposal/{number}/assistant`: the author or a
1517
/// writer asks an AI assistant to review (hub spec §13.4). The answer
1518
/// comes later as a review via the assistant.
1519
pub async fn assistant(
1520
    State(app): State<Shared>,
1521
    viewer: Viewer,
1522
    Path((owner, name, number)): Path<(String, String, i64)>,
1523
    Form(f): Form<AssistantForm>,
1524
) -> Result<Response, Error> {
1525
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1526
    let me = require(&viewer, &shell)?;
1527
    csrf(&viewer, &f.csrf_token, &shell)?;
1528
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1529
    let p = pdb::get(&app.db, row.id, number)
1530
        .await
1531
        .map_err(|e| Error::from_db(shell.clone(), e))?
1532
        .ok_or_else(|| Error::not_found(shell.clone()))?;
1533
    if Some(me.account.id) != p.author_id && role < Role::Write {
1534
        return Err(Error::not_found(shell));
1535
    }
1536
    if !p.is_open() {
1537
        return Ok(back(&owner, &name, number, "conversation", None, Some("The proposal is not open.".into())));
1538
    }
1539
    let Some(a) = crate::assistants::enabled_for_repo(&app.db, &row)
1540
        .await
1541
        .map_err(|e| Error::from_db(shell.clone(), e))?
1542
        .into_iter()
1543
        .find(|a| a.name.eq_ignore_ascii_case(f.assistant.trim()))
1544
    else {
1545
        return Ok(back(&owner, &name, number, "conversation", None, Some(format!("No assistant called {} here.", f.assistant.trim()))));
1546
    };
1547
    let who = a.name.clone();
1548
    match crate::assistants::request_review(&app, &row, &p, &me.account, a).await {
1549
        Ok(()) => Ok(back(
1550
            &owner,
1551
            &name,
1552
            number,
1553
            "conversation",
1554
            Some(format!("Asked {who} to review; its answer appears here when it comes.")),
1555
            None,
1556
        )),
1557
        Err(e) => Ok(back(&owner, &name, number, "conversation", None, Some(format!("Not asked: {e}.")))),
1558
    }
1559
}
1560
1561
/// The assistants a repository may use, by name.
1562
async fn assistant_names(app: &Shared, row: &db::repos::RepoRow) -> Vec<String> {
1563
    crate::assistants::enabled_for_repo(&app.db, row).await.unwrap_or_default().into_iter().map(|a| a.name).collect()
1564
}
1565
1566
/// The changes and the file diffs a proposal from `source` into `target`
1567
/// would carry.
1568
async fn preview_diff(
1569
    app: &Shared,
1570
    repo: &RepoName,
1571
    target: &str,
1572
    source: &str,
1573
) -> Result<(Vec<ChangeView>, Vec<oxy_core::repo::FileDiff>), crate::repos::RepoError> {
1574
    let Some((fork, bookmark)) = parse_source(app, repo, source) else { return Ok((vec![], vec![])) };
1575
    let target = target.to_string();
1576
    let fork_dir = fork.as_ref().map(|f| app.repos.dir(f));
1577
    app.repos
1578
        .read(repo, move |r, cache| {
1579
            let projection = match &fork_dir {
1580
                Some(dir) => {
1581
                    let fork_repo = oxy_core::repo::Repo::open(dir)?;
1582
                    let heads = fork_repo.bookmarks()?.into_iter().find(|b| b.name == bookmark).map(|b| b.heads).unwrap_or_default();
1583
                    pl::project(r, cache, &target, Source::Fork { fork: &fork_repo, heads })?
1584
                }
1585
                None => {
1586
                    let heads = r.bookmarks()?.into_iter().find(|b| b.name == bookmark).map(|b| b.heads).unwrap_or_default();
1587
                    pl::project(r, cache, &target, Source::Bookmark(heads))?
1588
                }
1589
            };
1590
            Ok((projection.changes, projection.files))
1591
        })
1592
        .await
1593
}
1594
1595
/// `POST /{owner}/{name}/proposals/new/draft`: an assistant drafts the
1596
/// description from the changes the proposal would add; the form comes
1597
/// back with the draft to edit (hub spec §13.4).
1598
pub async fn new_draft(
1599
    State(app): State<Shared>,
1600
    viewer: Viewer,
1601
    Path((owner, name)): Path<(String, String)>,
1602
    Form(f): Form<NewForm>,
1603
) -> Result<Page<NewProposalPage>, Error> {
1604
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposals/new"), &viewer);
1605
    let me = require(&viewer, &shell)?;
1606
    csrf(&viewer, &f.csrf_token, &shell)?;
1607
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1608
    let bookmarks =
1609
        app.repos.read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::<Vec<_>>())).await.unwrap_or_default();
1610
    let (preview, conflicted) = preview_for(&app, &repo, &f.target, &f.source).await.unwrap_or((vec![], false));
1611
    let assistants = if preview.is_empty() { vec![] } else { assistant_names(&app, &row).await };
1612
    let mut page = NewProposalPage {
1613
        shell,
1614
        repo: RepoShell::new(&app, &row, "proposals").with_role(role),
1615
        bookmarks,
1616
        source: f.source.clone(),
1617
        target: f.target.clone(),
1618
        error: None,
1619
        preview,
1620
        conflicted,
1621
        title: f.title.clone(),
1622
        body: f.body.clone(),
1623
        assistants,
1624
    };
1625
    let assistant = crate::assistants::enabled_for_repo(&app.db, &row)
1626
        .await
1627
        .unwrap_or_default()
1628
        .into_iter()
1629
        .find(|a| a.name.eq_ignore_ascii_case(f.assistant.trim()));
1630
    let Some(a) = assistant else {
1631
        page.error = Some(format!("No assistant called {} here.", f.assistant.trim()));
1632
        return Ok(Page(page));
1633
    };
1634
    if page.preview.is_empty() {
1635
        page.error = Some("Nothing to describe: the source adds nothing to the target.".into());
1636
        return Ok(Page(page));
1637
    }
1638
    let (changes, diffs) = match preview_diff(&app, &repo, &f.target, &f.source).await {
1639
        Ok(x) => x,
1640
        Err(e) => {
1641
            page.error = Some(e.to_string());
1642
            return Ok(Page(page));
1643
        }
1644
    };
1645
    let d = crate::assistants::Draft { slot: -row.id, title: &f.title, target: &f.target, changes: &changes, diffs: &diffs };
1646
    match crate::assistants::draft_description(&app, &row, &me.account, &a, d).await {
1647
        Ok(text) => page.body = text,
1648
        Err(why) => page.error = Some(format!("{} did not answer: {why}.", a.name)),
1649
    }
1650
    Ok(Page(page))
1651
}
1652
1653
#[derive(Deserialize)]
1654
pub struct DraftForm {
1655
    #[serde(rename = "_csrf")]
1656
    csrf_token: String,
1657
    assistant: String,
1658
    #[serde(default)]
1659
    title: String,
1660
}
1661
1662
/// `POST /{owner}/{name}/proposal/{number}/draft`: an assistant drafts the
1663
/// description of an open proposal from its changes; the editor opens
1664
/// with the draft, nothing is saved (hub spec §13.4).
1665
pub async fn draft(
1666
    State(app): State<Shared>,
1667
    viewer: Viewer,
1668
    Path((owner, name, number)): Path<(String, String, i64)>,
1669
    Form(f): Form<DraftForm>,
1670
) -> Result<Response, Error> {
1671
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1672
    let me = require(&viewer, &shell)?;
1673
    csrf(&viewer, &f.csrf_token, &shell)?;
1674
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1675
    let p = pdb::get(&app.db, row.id, number)
1676
        .await
1677
        .map_err(|e| Error::from_db(shell.clone(), e))?
1678
        .ok_or_else(|| Error::not_found(shell.clone()))?;
1679
    if Some(me.account.id) != p.author_id && role < Role::Maintain {
1680
        return Err(Error::not_found(shell));
1681
    }
1682
    let Some(a) = crate::assistants::enabled_for_repo(&app.db, &row)
1683
        .await
1684
        .map_err(|e| Error::from_db(shell.clone(), e))?
1685
        .into_iter()
1686
        .find(|a| a.name.eq_ignore_ascii_case(f.assistant.trim()))
1687
    else {
1688
        return Ok(back(&owner, &name, number, "conversation", None, Some(format!("No assistant called {} here.", f.assistant.trim()))));
1689
    };
1690
    let loaded = crate::web::proposals::load(&app, &repo, p.clone()).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
1691
    let title = if f.title.trim().is_empty() { p.title.clone() } else { f.title.clone() };
1692
    let d = crate::assistants::Draft { slot: p.id, title: &title, target: &p.target, changes: &loaded.changes, diffs: &loaded.diffs };
1693
    let draft = crate::assistants::draft_description(&app, &row, &me.account, &a, d).await;
1694
    match draft {
1695
        Ok(text) => {
1696
            let mut page = detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), None, None).await?;
1697
            page.0.draft = Some(text);
1698
            Ok(page.into_response())
1699
        }
1700
        Err(why) => Ok(back(&owner, &name, number, "conversation", None, Some(format!("{} did not answer: {why}.", a.name)))),
cc1cd197 Andy McCall ↶ 1701
    }
1702
}