OXYGENHUB
◆ v0.2.0 oxygenhub / crates / hub / src / web / proposals.rs
56.5 KiB · Rust · last changed Review requests: ask someone to review a proposal (closes #68) 1 month 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,
140
}
141
142
#[derive(Deserialize)]
143
pub struct NewQuery {
144
    source: Option<String>,
145
    target: Option<String>,
146
}
147
148
/// `owner/name:bookmark` for a fork, else a bookmark of this repository.
0b20f1ec Andy McCall ↶ 149
/// A proposal's source: a bookmark of this repository by name (which may
150
/// hold `/`, as `feature/16-hud` does), or `owner/name:bookmark` of a fork;
151
/// `owner/name:bookmark` naming this repository is its own bookmark.
de505df5 Andy McCall ↶ 152
fn parse_source(app: &Shared, repo: &RepoName, source: &str) -> Option<(Option<RepoName>, String)> {
0b20f1ec Andy McCall ↶ 153
    let plain = |b: &str| !b.is_empty() && !b.chars().any(|c| c.is_control() || c == ':');
de505df5 Andy McCall ↶ 154
    if let Some((slug, bookmark)) = source.split_once(':') {
155
        let fork = RepoName::parse(slug)?;
0b20f1ec Andy McCall ↶ 156
        if !plain(bookmark) {
de505df5 Andy McCall ↶ 157
            return None;
0b20f1ec Andy McCall ↶ 158
        }
159
        if fork == *repo {
160
            return Some((None, bookmark.to_string()));
de505df5 Andy McCall ↶ 161
        }
0b20f1ec Andy McCall ↶ 162
        if !app.repos.exists(&fork) {
163
            return None;
164
        }
de505df5 Andy McCall ↶ 165
        return Some((Some(fork), bookmark.to_string()));
166
    }
0b20f1ec Andy McCall ↶ 167
    plain(source).then(|| (None, source.to_string()))
de505df5 Andy McCall ↶ 168
}
169
170
async fn preview_for(
171
    app: &Shared,
172
    repo: &RepoName,
173
    target: &str,
174
    source: &str,
175
) -> Result<(Vec<ChangeView>, bool), crate::repos::RepoError> {
176
    let Some((fork, bookmark)) = parse_source(app, repo, source) else { return Ok((vec![], false)) };
177
    let target = target.to_string();
178
    let fork_dir = fork.as_ref().map(|f| app.repos.dir(f));
179
    app.repos
180
        .read(repo, move |r, cache| {
181
            let projection = match &fork_dir {
182
                Some(dir) => {
183
                    let fork_repo = oxy_core::repo::Repo::open(dir)?;
184
                    let heads = fork_repo.bookmarks()?.into_iter().find(|b| b.name == bookmark).map(|b| b.heads).unwrap_or_default();
185
                    pl::project(r, cache, &target, Source::Fork { fork: &fork_repo, heads })?
186
                }
187
                None => {
188
                    let heads = r.bookmarks()?.into_iter().find(|b| b.name == bookmark).map(|b| b.heads).unwrap_or_default();
189
                    pl::project(r, cache, &target, Source::Bookmark(heads))?
190
                }
191
            };
192
            Ok((projection.changes, projection.conflicted))
193
        })
194
        .await
195
}
196
197
pub async fn new_form(
198
    State(app): State<Shared>,
199
    viewer: Viewer,
200
    Path((owner, name)): Path<(String, String)>,
201
    Query(q): Query<NewQuery>,
202
) -> Result<Page<NewProposalPage>, Error> {
203
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposals/new"), &viewer);
204
    require(&viewer, &shell)?;
205
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
206
    let bookmarks = app
207
        .repos
208
        .read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::<Vec<_>>()))
209
        .await
210
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
211
    let target = q.target.unwrap_or_else(|| row.default_bookmark.clone());
212
    let source = q.source.unwrap_or_default();
213
    let (preview, conflicted) =
214
        if source.is_empty() { (vec![], false) } else { preview_for(&app, &repo, &target, &source).await.unwrap_or((vec![], false)) };
215
    Ok(Page(NewProposalPage {
216
        shell,
217
        repo: RepoShell::new(&app, &row, "proposals").with_role(role),
218
        bookmarks,
219
        source,
220
        target,
221
        error: None,
222
        preview,
223
        conflicted,
224
    }))
225
}
226
227
#[derive(Deserialize)]
228
pub struct NewForm {
229
    #[serde(rename = "_csrf")]
230
    csrf_token: String,
231
    title: String,
232
    #[serde(default)]
233
    body: String,
234
    target: String,
235
    source: String,
236
    #[serde(default)]
237
    draft: Option<String>,
238
}
239
b64e2537 Andy McCall ↶ 240
/// Open a proposal from a form or the API: validate, create, index, notify.
241
#[allow(clippy::too_many_arguments)]
242
pub async fn open_proposal(
243
    app: &Shared,
244
    repo: &RepoName,
245
    row: &db::repos::RepoRow,
246
    me: &accounts::Account,
247
    title: &str,
248
    body: &str,
249
    target: &str,
250
    source: &str,
251
    draft: bool,
252
) -> Result<pdb::ProposalRow, String> {
253
    if title.trim().is_empty() {
254
        return Err("Give the proposal a title.".into());
de505df5 Andy McCall ↶ 255
    }
b64e2537 Andy McCall ↶ 256
    let bookmarks =
257
        app.repos.read(repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::<Vec<_>>())).await.map_err(|e| e.to_string())?;
258
    if !bookmarks.iter().any(|b| b == target) {
259
        return Err("Pick a target bookmark of this repository.".into());
de505df5 Andy McCall ↶ 260
    }
b64e2537 Andy McCall ↶ 261
    let Some((fork, bookmark)) = parse_source(app, repo, source.trim()) else {
262
        return Err("The source is a bookmark of this repository, or owner/name:bookmark of a fork.".into());
de505df5 Andy McCall ↶ 263
    };
0b20f1ec Andy McCall ↶ 264
    if fork.is_none() && !bookmarks.contains(&bookmark) {
265
        return Err(format!("There is no bookmark {bookmark} in this repository."));
266
    }
b64e2537 Andy McCall ↶ 267
    if fork.is_none() && bookmark == target {
268
        return Err("The source and the target are the same bookmark.".into());
de505df5 Andy McCall ↶ 269
    }
b64e2537 Andy McCall ↶ 270
    let (preview, _) = preview_for(app, repo, target, source.trim()).await.map_err(|e| e.to_string())?;
de505df5 Andy McCall ↶ 271
    if preview.is_empty() {
b64e2537 Andy McCall ↶ 272
        return Err("The source adds nothing to the target.".into());
de505df5 Andy McCall ↶ 273
    }
274
    let source_repo_id = match &fork {
b64e2537 Andy McCall ↶ 275
        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 ↶ 276
        None => None,
277
    };
278
    let created = pdb::create(
279
        &app.db,
280
        pdb::NewProposal {
281
            repo_id: row.id,
b64e2537 Andy McCall ↶ 282
            title: title.trim(),
283
            body: body.trim(),
284
            author_id: Some(me.id),
285
            author: &me.username,
286
            draft,
287
            target,
de505df5 Andy McCall ↶ 288
            source_kind: if fork.is_some() { "fork" } else { "bookmark" },
289
            source_repo_id,
290
            source_bookmark: &bookmark,
291
            source_changes: "",
292
        },
293
    )
294
    .await
b64e2537 Andy McCall ↶ 295
    .map_err(|e| e.to_string())?;
296
    pdb::add_event(&app.db, created.id, &me.username, "opened", "").await.ok();
297
    accounts::audit(&app.db, Some(me), "proposal.opened", &format!("{}#{}", row.slug(), created.number), title).await.ok();
18e381ea Andy McCall ↶ 298
    db::notify::index(&app.db, "proposal", row.id, created.id, created.number, &created.title, &created.body).await.ok();
b64e2537 Andy McCall ↶ 299
    let url = format!("/{}/proposal/{}", row.slug(), created.number);
18e381ea Andy McCall ↶ 300
    crate::notify::deliver(
b64e2537 Andy McCall ↶ 301
        app,
18e381ea Andy McCall ↶ 302
        crate::notify::Event {
303
            kind: "proposal.opened",
b64e2537 Andy McCall ↶ 304
            title: format!("{}#{}: {}", row.slug(), created.number, created.title),
305
            url,
306
            actor: me,
18e381ea Andy McCall ↶ 307
            repo_id: row.id,
308
            thread: Some(("proposal", created.id)),
b64e2537 Andy McCall ↶ 309
            mention_text: body,
18e381ea Andy McCall ↶ 310
            direct: vec![],
311
        },
312
    )
313
    .await;
b64e2537 Andy McCall ↶ 314
    crate::hooks::emit(
315
        app,
316
        row,
317
        "proposal.opened",
318
        &me.username,
319
        serde_json::json!({ "number": created.number, "title": created.title, "target": target, "source": source }),
320
    )
321
    .await;
322
    Ok(created)
323
}
324
325
pub async fn create(
326
    State(app): State<Shared>,
327
    viewer: Viewer,
328
    Path((owner, name)): Path<(String, String)>,
329
    Form(f): Form<NewForm>,
330
) -> Result<Response, Error> {
331
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposals/new"), &viewer);
332
    let me = require(&viewer, &shell)?;
333
    csrf(&viewer, &f.csrf_token, &shell)?;
334
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
335
    match open_proposal(&app, &repo, &row, &me.account, &f.title, &f.body, &f.target, &f.source, f.draft.is_some()).await {
336
        Ok(created) => Ok(Redirect::to(&format!("/{owner}/{name}/proposal/{}", created.number)).into_response()),
337
        Err(msg) => {
338
            let bookmarks =
339
                app.repos.read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::<Vec<_>>())).await.unwrap_or_default();
340
            Ok(Page(NewProposalPage {
341
                shell,
342
                repo: RepoShell::new(&app, &row, "proposals").with_role(role),
343
                bookmarks,
344
                source: f.source.clone(),
345
                target: f.target.clone(),
346
                error: Some(msg),
347
                preview: vec![],
348
                conflicted: false,
349
            })
350
            .into_response())
351
        }
352
    }
de505df5 Andy McCall ↶ 353
}
354
355
// ---------------------------------------------------------------- detail
356
357
/// A comment placed on a diff row, or general.
358
pub struct CommentView {
359
    pub row: pdb::CommentRow,
360
    pub body_html: String,
361
    pub line: Option<usize>,
362
}
363
a1b98695 Andy McCall ↶ 364
/// One entry of a proposal's conversation, in time order: what kind it
365
/// is and its index in `comments`, `reviews` or `events`.
366
pub struct Entry {
367
    pub kind: &'static str,
368
    pub index: usize,
369
}
370
de505df5 Andy McCall ↶ 371
pub struct ReviewView {
372
    pub row: pdb::ReviewRow,
373
    pub body_html: String,
374
    pub sealed: bool,
375
}
376
377
/// Everything the detail page and the API need, computed once under the lock.
378
pub struct Loaded {
379
    pub row: pdb::ProposalRow,
380
    pub changes: Vec<ChangeView>,
381
    pub hashes: Vec<ChangeHash>,
382
    pub heads: Vec<ChangeHash>,
3f02e5ea Andy McCall ↶ 383
    pub target_heads: Vec<ChangeHash>,
de505df5 Andy McCall ↶ 384
    pub state: String,
385
    pub target_state: String,
386
    pub conflicted: bool,
387
    pub conflicts: Vec<ConflictView>,
388
    pub files: Vec<FileRows>,
9df855f2 Andy McCall ↶ 389
    /// The per-file hunks and bases behind `files`, to render again with other options.
390
    pub diffs: Vec<oxy_core::repo::FileDiff>,
391
    pub bases: std::collections::BTreeMap<String, Vec<u8>>,
de505df5 Andy McCall ↶ 392
    /// Atom per (path, new line number) of the projected state.
393
    pub atoms: HashMap<(String, usize), (String, u32)>,
394
    pub sealing_keys: Vec<String>,
395
    pub touched: Vec<String>,
396
    pub owners: Vec<String>,
397
    pub landed_already: bool,
398
}
399
400
/// Compute the projection and everything derived from it.
401
pub async fn load(app: &Shared, repo: &RepoName, row: pdb::ProposalRow) -> Result<Loaded, crate::repos::RepoError> {
402
    let fork_dir = match (row.source_kind.as_str(), row.source_repo_id) {
403
        ("fork", Some(id)) => {
404
            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))
405
        }
406
        _ => None,
407
    };
408
    let r2 = row.clone();
409
    let loaded = app
410
        .repos
411
        .read(repo, move |r, cache| {
412
            let source = match (r2.source_kind.as_str(), &fork_dir) {
413
                ("changes", _) => Source::Changes(r2.source_changes().iter().filter_map(|h| ChangeHash::from_hex(h)).collect()),
414
                ("fork", Some(dir)) => {
415
                    let fork_repo = oxy_core::repo::Repo::open(dir)?;
416
                    let heads =
417
                        fork_repo.bookmarks()?.into_iter().find(|b| b.name == r2.source_bookmark).map(|b| b.heads).unwrap_or_default();
418
                    // Bring the fork's changes over now, so the projection below can read them.
419
                    let fork_state = fork_repo.load_state(&heads)?;
420
                    let missing: Vec<ChangeHash> = fork_state.changes().map(|(h, _)| *h).filter(|h| !r.store().has(h)).collect();
421
                    if !missing.is_empty() {
422
                        r.import_bundle(&fork_repo.bundle_for(&missing)?, r.current_policy()?.is_some())?;
423
                    }
424
                    Source::Changes(heads)
425
                }
426
                _ => {
427
                    let heads = r.bookmarks()?.into_iter().find(|b| b.name == r2.source_bookmark).map(|b| b.heads).unwrap_or_default();
428
                    Source::Bookmark(heads)
429
                }
430
            };
431
            let p: Projection = pl::project(r, cache, &r2.target, source)?;
432
            let target_loaded = cache.load(r, &p.target_heads)?;
433
            let files = render_files(&p.files, &p.bases);
434
            let mut atoms = HashMap::new();
435
            for f in &files {
436
                for l in &f.lines {
437
                    if let Some(n) = l.new_no {
438
                        if let Some((h, i)) = pl::atom_at(&p.loaded, &f.path, n as usize) {
439
                            atoms.insert((f.path.clone(), n as usize), (h.to_hex(), i));
440
                        }
441
                    }
442
                }
443
            }
444
            let touched: Vec<String> = p.files.iter().map(|f| f.path.clone()).collect();
445
            let owners = pl::owners_for(&target_loaded, &touched);
446
            let sealing = pl::sealing_keys(r, &p.hashes)?.into_iter().map(|k| k.to_hex()).collect();
447
            let landed_already = !p.hashes.is_empty() && p.hashes.iter().all(|h| target_loaded.state.contains(h));
0b20f1ec Andy McCall ↶ 448
            // A landed proposal's changes are in its target now: it shows
449
            // the ones it landed, as recorded when it landed.
450
            let (changes, hashes) = if r2.state == "landed" && p.hashes.is_empty() {
451
                let hashes: Vec<ChangeHash> = r2.projected_changes().iter().filter_map(|h| ChangeHash::from_hex(h)).collect();
452
                let labels = HashMap::new();
453
                let changes = hashes
454
                    .iter()
455
                    .filter_map(|h| Some(view::change_view_pub(r, h, &r.store().get_change_meta(h).ok()?, false, &labels)))
456
                    .collect();
457
                (changes, hashes)
458
            } else {
459
                (p.changes, p.hashes)
460
            };
de505df5 Andy McCall ↶ 461
            Ok(Loaded {
462
                row: r2,
0b20f1ec Andy McCall ↶ 463
                changes,
464
                hashes,
de505df5 Andy McCall ↶ 465
                heads: p.heads,
3f02e5ea Andy McCall ↶ 466
                target_heads: p.target_heads.clone(),
de505df5 Andy McCall ↶ 467
                state: p.state.to_hex(),
468
                target_state: p.target_state.to_hex(),
469
                conflicted: p.conflicted,
470
                conflicts: p.conflicts,
471
                files,
9df855f2 Andy McCall ↶ 472
                diffs: p.files.clone(),
473
                bases: p.bases.clone(),
de505df5 Andy McCall ↶ 474
                atoms,
475
                sealing_keys: sealing,
476
                touched,
477
                owners,
478
                landed_already,
479
            })
480
        })
481
        .await?;
0b20f1ec Andy McCall ↶ 482
    // Remember the projection for the list, the API and other proposals'
483
    // stacking checks, while the proposal is open: a landed one keeps what
484
    // it landed.
485
    if !loaded.row.is_open() {
486
        return Ok(loaded);
487
    }
de505df5 Andy McCall ↶ 488
    let heads_csv = loaded.heads.iter().map(|h| h.to_hex()).collect::<Vec<_>>().join(",");
489
    let changes_csv = loaded.hashes.iter().map(|h| h.to_hex()).collect::<Vec<_>>().join(",");
490
    pdb::set_projection(&app.db, loaded.row.id, &loaded.state, &heads_csv, &loaded.target_state, &changes_csv, loaded.conflicted)
491
        .await
492
        .ok();
493
    Ok(loaded)
494
}
495
496
/// Reviews, approvals and blockers.
497
pub struct Verdict {
498
    pub reviews: Vec<ReviewView>,
499
    pub approvals: Vec<Approval>,
500
    pub blockers: Vec<String>,
501
    pub depends_on: Vec<i64>,
502
    pub requirements: pdb::Requirements,
503
}
504
505
pub async fn verdict(app: &Shared, loaded: &Loaded, repo_id: i64) -> Result<Verdict, sqlx::Error> {
506
    let rows = pdb::reviews(&app.db, loaded.row.id).await?;
507
    let links = accounts::identity_accounts(&app.db).await?;
508
    let sealed_accounts: Vec<String> = links.iter().filter(|(k, _)| loaded.sealing_keys.contains(k)).map(|(_, u)| u.clone()).collect();
509
    // Latest verdict per reviewer.
510
    let mut latest: BTreeMap<String, &pdb::ReviewRow> = BTreeMap::new();
511
    for r in &rows {
512
        latest.insert(r.author.clone(), r);
513
    }
514
    let mut approvals = Vec::new();
515
    for (who, r) in &latest {
516
        if r.verdict == "approve" {
517
            let sealed = sealed_accounts.contains(who);
518
            let key = links.iter().find(|(k, u)| u == who && loaded.sealing_keys.contains(k)).map(|(k, _)| k.clone());
519
            approvals.push(Approval { account: who.clone(), sealed, key });
520
        }
521
    }
522
    let reviews = rows
523
        .iter()
524
        .map(|r| ReviewView {
525
            row: r.clone(),
526
            body_html: render::markdown::render(&r.body, "/", "/"),
527
            sealed: r.verdict == "approve" && sealed_accounts.contains(&r.author),
528
        })
529
        .collect();
530
    let requirements = pdb::requirements(&app.db, repo_id).await?;
531
    let mut blockers = Vec::new();
0b20f1ec Andy McCall ↶ 532
    // Only an open proposal can be held back.
533
    if !loaded.row.is_open() {
534
        return Ok(Verdict { reviews, approvals, blockers, depends_on: vec![], requirements });
535
    }
de505df5 Andy McCall ↶ 536
    if loaded.row.state == "draft" {
537
        blockers.push("the proposal is a draft".into());
538
    }
539
    if loaded.hashes.is_empty() {
540
        blockers.push("the proposal adds no changes".into());
541
    }
542
    let sealed_count = approvals.iter().filter(|a| a.sealed).count() as i64;
543
    if sealed_count < requirements.required_approvals {
544
        blockers.push(format!("{sealed_count} of {} required sealed approvals", requirements.required_approvals));
545
    }
546
    if requirements.require_owners {
547
        let missing: Vec<&String> = loaded.owners.iter().filter(|o| !approvals.iter().any(|a| a.sealed && &a.account == *o)).collect();
548
        if !missing.is_empty() {
549
            blockers.push(format!("code owners still to approve: {}", missing.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")));
550
        }
551
    }
552
    if loaded.conflicted && !requirements.allow_conflicts {
1e23d010 Andy McCall ↶ 553
        blockers.push(format!(
554
            "landing would carry conflicts, which this repository refuses: resolve them on the source (oxy merge {} there, fix the marked files, oxy commit, push)",
555
            loaded.row.target
556
        ));
de505df5 Andy McCall ↶ 557
    }
b64e2537 Andy McCall ↶ 558
    let required = db::hooks::required_checks(&app.db, repo_id).await?;
559
    if !required.is_empty() {
560
        let checks = db::hooks::checks_for_state(&app.db, repo_id, &loaded.state).await?;
561
        let missing: Vec<String> =
562
            required.iter().filter(|n| !checks.iter().any(|c| &c.name == *n && c.status == "passed")).cloned().collect();
563
        if !missing.is_empty() {
564
            blockers.push(format!("required checks not passed on the projected state: {}", missing.join(", ")));
565
        }
566
    }
de505df5 Andy McCall ↶ 567
    let requesting: Vec<&String> = latest.iter().filter(|(_, r)| r.verdict == "changes").map(|(who, _)| who).collect();
568
    if !requesting.is_empty() {
569
        blockers.push(format!("changes requested by {}", requesting.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")));
570
    }
571
    // Stacking: another open, earlier proposal whose changes this one includes.
572
    let mut depends_on = Vec::new();
573
    for other in pdb::list(&app.db, repo_id, &["open", "draft"]).await? {
574
        if other.id == loaded.row.id || other.number > loaded.row.number {
575
            continue;
576
        }
577
        let theirs = other.projected_changes();
578
        if !theirs.is_empty() && theirs.iter().all(|h| loaded.hashes.iter().any(|m| m.to_hex() == *h)) {
579
            depends_on.push(other.number);
580
        }
581
    }
582
    if !depends_on.is_empty() {
583
        blockers.push(format!("depends on {}", depends_on.iter().map(|n| format!("#{n}")).collect::<Vec<_>>().join(", ")));
584
    }
585
    Ok(Verdict { reviews, approvals, blockers, depends_on, requirements })
586
}
587
588
#[derive(Template)]
589
#[template(path = "proposal.html")]
590
pub struct ProposalPage {
591
    shell: Shell,
592
    repo: RepoShell,
593
    p: pdb::ProposalRow,
38833ed7 Andy McCall ↶ 594
    /// Issues it closes, from its description and its changes.
595
    closes: Vec<super::chain::Link>,
a1b98695 Andy McCall ↶ 596
    /// Projects the viewer may add it to.
597
    project_choices: Option<Vec<db::projects::Project>>,
598
    /// Comments (not anchored to a line), reviews and events, oldest first.
599
    timeline: Vec<Entry>,
600
    /// The viewer may set labels (triage or above).
601
    can_triage: bool,
de505df5 Andy McCall ↶ 602
    body_html: String,
603
    tab: String,
604
    changes: Vec<ChangeView>,
605
    files: Vec<FileRows>,
9df855f2 Andy McCall ↶ 606
    diffs: Vec<oxy_core::repo::FileDiff>,
607
    bases: std::collections::BTreeMap<String, Vec<u8>>,
608
    /// Whitespace-only changes are hidden (`?w=1`).
609
    ignore_ws: bool,
de505df5 Andy McCall ↶ 610
    conflicts: Vec<ConflictView>,
611
    conflicted: bool,
612
    comments: Vec<CommentView>,
613
    /// Anchored comments by (path, current line).
614
    placed: HashMap<(String, usize), Vec<usize>>,
615
    outdated: Vec<usize>,
616
    reviews: Vec<ReviewView>,
617
    approvals: Vec<Approval>,
618
    blockers: Vec<String>,
619
    depends_on: Vec<i64>,
620
    events: Vec<pdb::EventRow>,
621
    owners: Vec<String>,
622
    requirements: pdb::Requirements,
623
    landed_already: bool,
624
    can_edit: bool,
625
    can_land: bool,
626
    hub_can_land: bool,
627
    notice: Option<String>,
628
    error: Option<String>,
629
    heads: Vec<String>,
630
    state_hash: String,
631
    source_label: String,
632
    api_url: String,
18e381ea Andy McCall ↶ 633
    labels: Vec<db::issues::Label>,
634
    all_labels: Vec<db::issues::Label>,
b64e2537 Andy McCall ↶ 635
    checks: Vec<db::hooks::Check>,
636
    required_checks: Vec<String>,
e1ec9fbf Andy McCall ↶ 637
    /// Projects this proposal is on that the viewer may see, with the column.
638
    projects: Vec<(db::projects::Project, String)>,
cc1cd197 Andy McCall ↶ 639
    /// Asked to review and not yet reviewing.
640
    requested: Vec<String>,
641
    /// The viewer may ask for reviews (the author, or a writer).
642
    can_request: bool,
de505df5 Andy McCall ↶ 643
}
644
645
#[derive(Deserialize)]
646
pub struct DetailQuery {
647
    tab: Option<String>,
a1b98695 Andy McCall ↶ 648
    /// What an action said, after it redirected here.
9df855f2 Andy McCall ↶ 649
    #[serde(default)]
a1b98695 Andy McCall ↶ 650
    notice: Option<String>,
651
    #[serde(default)]
652
    error: Option<String>,
653
    #[serde(default)]
9df855f2 Andy McCall ↶ 654
    w: Option<String>,
de505df5 Andy McCall ↶ 655
}
656
657
/// The page assembler every action ends in; its arguments are the route
658
/// plus what to say, and a struct would only rename them.
659
#[allow(clippy::too_many_arguments)]
660
async fn detail(
661
    app: &Shared,
662
    shell: Shell,
663
    viewer: &Viewer,
664
    owner: &str,
665
    name: &str,
666
    number: i64,
667
    tab: String,
668
    notice: Option<String>,
669
    error: Option<String>,
670
) -> Result<Page<ProposalPage>, Error> {
671
    let (repo, row, role) = lookup(app, &shell, viewer, owner, name).await?;
672
    let p = pdb::get(&app.db, row.id, number)
673
        .await
674
        .map_err(|e| Error::from_db(shell.clone(), e))?
675
        .ok_or_else(|| Error::not_found(shell.clone()))?;
676
    let loaded = load(app, &repo, p).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
677
    // A proposal whose changes all reached the target landed by another route.
678
    if loaded.landed_already && loaded.row.is_open() {
679
        pdb::set_state(&app.db, loaded.row.id, "landed", &loaded.target_state).await.ok();
680
        pdb::add_event(&app.db, loaded.row.id, "", "landed", "every change reached the target").await.ok();
681
    }
682
    let p = pdb::get(&app.db, row.id, number).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(loaded.row.clone());
683
    let v = verdict(app, &loaded, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
684
    let comment_rows = pdb::comments(&app.db, loaded.row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
685
    let events = pdb::events(&app.db, loaded.row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
686
    // Place anchored comments at their atom's current line.
687
    let mut by_atom: HashMap<(String, String, u32), usize> = HashMap::new();
688
    for ((path, line), (h, i)) in &loaded.atoms {
689
        by_atom.insert((path.clone(), h.clone(), *i), *line);
690
    }
691
    let mut comments = Vec::new();
692
    let mut placed: HashMap<(String, usize), Vec<usize>> = HashMap::new();
693
    let mut outdated = Vec::new();
694
    for (idx, c) in comment_rows.into_iter().enumerate() {
695
        let line = if c.anchor_change.is_empty() {
696
            None
697
        } else {
698
            by_atom.get(&(c.path.clone(), c.anchor_change.clone(), c.anchor_index as u32)).copied()
699
        };
700
        if !c.anchor_change.is_empty() {
701
            match line {
702
                Some(l) => placed.entry((c.path.clone(), l)).or_default().push(idx),
703
                None => outdated.push(idx),
704
            }
705
        }
706
        comments.push(CommentView { body_html: render::markdown::render(&c.body, "/", "/"), row: c, line });
707
    }
708
    let mut changes = loaded.changes.clone();
709
    super::attribute(app, &mut changes.iter_mut().collect::<Vec<_>>()).await;
710
    let me = viewer.account();
711
    let can_edit = me.is_some_and(|a| Some(a.id) == p.author_id || role >= Role::Maintain);
712
    let can_land = role >= Role::Write && p.is_open() && v.blockers.is_empty();
cc1cd197 Andy McCall ↶ 713
    let can_request = p.is_open() && me.is_some_and(|a| Some(a.id) == p.author_id || role >= Role::Write);
14530604 Andy McCall ↶ 714
    let delegated = match viewer.account() {
715
        Some(a) => super::edit::delegated_identity(app, a.id).await.is_some(),
716
        None => false,
717
    };
718
    let hub_can_land = delegated || app.repos.read(&repo, |r, _| Ok(r.current_policy()?.is_none())).await.unwrap_or(false);
de505df5 Andy McCall ↶ 719
    let source_label = match p.source_kind.as_str() {
720
        "fork" => format!(
721
            "fork {}",
722
            db::repos::get_by_id(&app.db, p.source_repo_id.unwrap_or(0))
723
                .await
724
                .ok()
725
                .flatten()
726
                .map(|r| format!("{}:{}", r.slug(), p.source_bookmark))
727
                .unwrap_or_default()
728
        ),
729
        "changes" => format!("{} changes", p.source_changes().len()),
730
        _ => p.source_bookmark.clone(),
731
    };
e1ec9fbf Andy McCall ↶ 732
    let projects = super::projects::placements_for(app, viewer, &row, p.number).await;
38833ed7 Andy McCall ↶ 733
    let texts: Vec<String> = std::iter::once(p.body.clone()).chain(changes.iter().map(|c| format!("{}\n{}", c.title, c.body))).collect();
734
    let closes = super::chain::issues_closed_by(app, &row, &texts.iter().map(String::as_str).collect::<Vec<_>>()).await;
a1b98695 Andy McCall ↶ 735
    let project_choices = super::projects::project_choices(app, viewer, &row, &projects).await;
736
    let mut dated: Vec<(i64, Entry)> = Vec::new();
737
    for (index, c) in comments.iter().enumerate() {
738
        if c.row.anchor_change.is_empty() {
739
            dated.push((c.row.created_ms, Entry { kind: "comment", index }));
740
        }
741
    }
742
    for (index, r) in v.reviews.iter().enumerate() {
743
        dated.push((r.row.created_ms, Entry { kind: "review", index }));
744
    }
745
    for (index, e) in events.iter().enumerate() {
746
        dated.push((e.at_ms, Entry { kind: "event", index }));
747
    }
748
    dated.sort_by_key(|(at, _)| *at);
749
    let timeline: Vec<Entry> = dated.into_iter().map(|(_, e)| e).collect();
de505df5 Andy McCall ↶ 750
    Ok(Page(ProposalPage {
a1b98695 Andy McCall ↶ 751
        can_triage: role >= Role::Triage,
752
        timeline,
38833ed7 Andy McCall ↶ 753
        closes,
a1b98695 Andy McCall ↶ 754
        project_choices,
e1ec9fbf Andy McCall ↶ 755
        projects,
de505df5 Andy McCall ↶ 756
        shell,
757
        repo: RepoShell::new(app, &row, "proposals").with_role(role),
c79a1111 Andy McCall ↶ 758
        body_html: super::issues::markdown(app, &row, &row.default_bookmark, &p.body).await,
de505df5 Andy McCall ↶ 759
        p,
760
        tab,
761
        changes,
762
        files: loaded.files,
9df855f2 Andy McCall ↶ 763
        diffs: loaded.diffs,
764
        bases: loaded.bases,
765
        ignore_ws: false,
de505df5 Andy McCall ↶ 766
        conflicts: loaded.conflicts,
767
        conflicted: loaded.conflicted,
768
        comments,
769
        placed,
770
        outdated,
771
        reviews: v.reviews,
772
        approvals: v.approvals,
773
        blockers: v.blockers,
774
        depends_on: v.depends_on,
775
        events,
776
        owners: loaded.owners,
777
        requirements: v.requirements,
778
        landed_already: loaded.landed_already,
779
        can_edit,
780
        can_land,
781
        hub_can_land,
782
        notice,
783
        error,
784
        heads: loaded.heads.iter().map(|h| h.to_hex()).collect(),
b64e2537 Andy McCall ↶ 785
        state_hash: loaded.state.clone(),
de505df5 Andy McCall ↶ 786
        source_label,
787
        api_url: format!("{}/api/v1/repos/{owner}/{name}/proposals/{number}", app.config.base_url()),
18e381ea Andy McCall ↶ 788
        labels: db::issues::proposal_labels(&app.db, loaded.row.id).await.unwrap_or_default(),
789
        all_labels: db::issues::labels(&app.db, row.id).await.unwrap_or_default(),
b64e2537 Andy McCall ↶ 790
        checks: db::hooks::checks_for_state(&app.db, row.id, &loaded.state).await.unwrap_or_default(),
791
        required_checks: db::hooks::required_checks(&app.db, row.id).await.unwrap_or_default(),
cc1cd197 Andy McCall ↶ 792
        requested: pdb::review_requests(&app.db, loaded.row.id).await.unwrap_or_default(),
793
        can_request,
de505df5 Andy McCall ↶ 794
    }))
795
}
796
a1b98695 Andy McCall ↶ 797
/// Back to the proposal after an action, with what to say: a redirect, so
798
/// reloading the page (the checks script does) never repeats the action.
799
fn back(owner: &str, name: &str, number: i64, tab: &str, notice: Option<String>, error: Option<String>) -> Response {
800
    let mut url = format!("/{owner}/{name}/proposal/{number}?tab={tab}");
801
    if let Some(n) = notice {
802
        url.push_str(&format!("&notice={}", super::settings::urlencode(&n)));
803
    }
804
    if let Some(e) = error {
805
        url.push_str(&format!("&error={}", super::settings::urlencode(&e)));
806
    }
807
    Redirect::to(&url).into_response()
808
}
809
de505df5 Andy McCall ↶ 810
pub async fn show(
811
    State(app): State<Shared>,
812
    viewer: Viewer,
813
    Path((owner, name, number)): Path<(String, String, i64)>,
814
    Query(q): Query<DetailQuery>,
815
) -> Result<Page<ProposalPage>, Error> {
816
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
a1b98695 Andy McCall ↶ 817
    let mut page =
818
        detail(&app, shell, &viewer, &owner, &name, number, q.tab.unwrap_or_else(|| "conversation".into()), q.notice, q.error).await?;
9df855f2 Andy McCall ↶ 819
    if q.w.as_deref() == Some("1") {
820
        // Hide whitespace-only changes: the same hunks, rendered again.
821
        page.0.files =
822
            super::history::render_files_with(&page.0.diffs, &page.0.bases, crate::render::diff::Options { ignore_whitespace: true });
823
        page.0.ignore_ws = true;
824
    }
825
    Ok(page)
de505df5 Andy McCall ↶ 826
}
827
828
// ---------------------------------------------------------------- actions
829
830
#[derive(Deserialize)]
831
pub struct CommentForm {
832
    #[serde(rename = "_csrf")]
833
    csrf_token: String,
834
    body: String,
835
    #[serde(default)]
836
    path: String,
837
    #[serde(default)]
838
    line: Option<usize>,
839
}
840
841
pub async fn comment(
842
    State(app): State<Shared>,
843
    viewer: Viewer,
844
    Path((owner, name, number)): Path<(String, String, i64)>,
845
    Form(f): Form<CommentForm>,
a1b98695 Andy McCall ↶ 846
) -> Result<Response, Error> {
de505df5 Andy McCall ↶ 847
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
848
    let me = require(&viewer, &shell)?;
849
    csrf(&viewer, &f.csrf_token, &shell)?;
850
    let (repo, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
851
    let p = pdb::get(&app.db, row.id, number)
852
        .await
853
        .map_err(|e| Error::from_db(shell.clone(), e))?
854
        .ok_or_else(|| Error::not_found(shell.clone()))?;
855
    if f.body.trim().is_empty() {
a1b98695 Andy McCall ↶ 856
        return Ok(back(&owner, &name, number, "conversation", None, Some("An empty comment says nothing.".into())));
de505df5 Andy McCall ↶ 857
    }
858
    let anchor = match (f.path.trim(), f.line) {
859
        ("", _) | (_, None) => None,
860
        (path, Some(line)) => {
861
            let loaded = load(&app, &repo, p.clone()).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
862
            loaded.atoms.get(&(path.to_string(), line)).cloned()
863
        }
864
    };
865
    let tab = if anchor.is_some() { "files" } else { "conversation" };
866
    pdb::add_comment(
867
        &app.db,
868
        p.id,
869
        Some(me.account.id),
870
        &me.account.username,
871
        f.body.trim(),
872
        f.path.trim(),
873
        anchor.as_ref().map(|(h, i)| (h.as_str(), *i as i64)),
874
    )
875
    .await
876
    .map_err(|e| Error::from_db(shell.clone(), e))?;
a1b98695 Andy McCall ↶ 877
    Ok(back(&owner, &name, number, tab, None, None))
de505df5 Andy McCall ↶ 878
}
879
880
#[derive(Deserialize)]
881
pub struct ReviewForm {
882
    #[serde(rename = "_csrf")]
883
    csrf_token: String,
884
    verdict: String,
885
    #[serde(default)]
886
    body: String,
887
}
888
889
pub async fn review(
890
    State(app): State<Shared>,
891
    viewer: Viewer,
892
    Path((owner, name, number)): Path<(String, String, i64)>,
893
    Form(f): Form<ReviewForm>,
a1b98695 Andy McCall ↶ 894
) -> Result<Response, Error> {
de505df5 Andy McCall ↶ 895
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
896
    let me = require(&viewer, &shell)?;
897
    csrf(&viewer, &f.csrf_token, &shell)?;
898
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
899
    let p = pdb::get(&app.db, row.id, number)
900
        .await
901
        .map_err(|e| Error::from_db(shell.clone(), e))?
902
        .ok_or_else(|| Error::not_found(shell.clone()))?;
903
    if !matches!(f.verdict.as_str(), "comment" | "changes" | "approve") {
904
        return Err(Error::bad(shell, "a review is comment, changes or approve"));
905
    }
906
    if f.verdict == "approve" && Some(me.account.id) == p.author_id {
a1b98695 Andy McCall ↶ 907
        return Ok(back(&owner, &name, number, "conversation", None, Some("You cannot approve your own proposal.".into())));
de505df5 Andy McCall ↶ 908
    }
909
    pdb::add_review(&app.db, p.id, me.account.id, &me.account.username, &f.verdict, f.body.trim())
910
        .await
911
        .map_err(|e| Error::from_db(shell.clone(), e))?;
912
    pdb::add_event(&app.db, p.id, &me.account.username, &format!("review.{}", f.verdict), "").await.ok();
cc1cd197 Andy McCall ↶ 913
    reviewed(&app, &row, &p, &me.account, &f.verdict).await;
b64e2537 Andy McCall ↶ 914
    crate::hooks::emit(&app, &row, "review", &me.account.username, serde_json::json!({ "number": number, "verdict": f.verdict })).await;
de505df5 Andy McCall ↶ 915
    let notice = if f.verdict == "approve" {
916
        Some(format!(
917
            "Approval recorded. It counts once you seal the changes: oxy seal --proposal {}/{owner}/{name}/proposal/{number}",
918
            app.config.base_url()
919
        ))
920
    } else {
921
        None
922
    };
a1b98695 Andy McCall ↶ 923
    Ok(back(&owner, &name, number, "conversation", notice, None))
de505df5 Andy McCall ↶ 924
}
925
926
#[derive(Deserialize)]
927
pub struct EditForm {
928
    #[serde(rename = "_csrf")]
929
    csrf_token: String,
930
    title: String,
931
    #[serde(default)]
932
    body: String,
933
    target: String,
934
}
935
936
pub async fn edit(
937
    State(app): State<Shared>,
938
    viewer: Viewer,
939
    Path((owner, name, number)): Path<(String, String, i64)>,
940
    Form(f): Form<EditForm>,
a1b98695 Andy McCall ↶ 941
) -> Result<Response, Error> {
de505df5 Andy McCall ↶ 942
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
943
    let me = require(&viewer, &shell)?;
944
    csrf(&viewer, &f.csrf_token, &shell)?;
945
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
946
    let p = pdb::get(&app.db, row.id, number)
947
        .await
948
        .map_err(|e| Error::from_db(shell.clone(), e))?
949
        .ok_or_else(|| Error::not_found(shell.clone()))?;
950
    if Some(me.account.id) != p.author_id && role < Role::Maintain {
951
        return Err(Error::not_found(shell));
952
    }
953
    let target = if crate::repos::names::valid_segment(f.target.trim()) { f.target.trim() } else { &p.target };
954
    pdb::edit(&app.db, p.id, f.title.trim(), f.body.trim(), target).await.map_err(|e| Error::from_db(shell.clone(), e))?;
955
    pdb::add_event(&app.db, p.id, &me.account.username, "edited", "").await.ok();
a1b98695 Andy McCall ↶ 956
    Ok(back(&owner, &name, number, "conversation", Some("Saved.".into()), None))
de505df5 Andy McCall ↶ 957
}
958
959
#[derive(Deserialize)]
960
pub struct StateForm {
961
    #[serde(rename = "_csrf")]
962
    csrf_token: String,
963
    action: String,
964
}
965
966
pub async fn state(
967
    State(app): State<Shared>,
968
    viewer: Viewer,
969
    Path((owner, name, number)): Path<(String, String, i64)>,
970
    Form(f): Form<StateForm>,
a1b98695 Andy McCall ↶ 971
) -> Result<Response, Error> {
de505df5 Andy McCall ↶ 972
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
973
    let me = require(&viewer, &shell)?;
974
    csrf(&viewer, &f.csrf_token, &shell)?;
975
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
976
    let p = pdb::get(&app.db, row.id, number)
977
        .await
978
        .map_err(|e| Error::from_db(shell.clone(), e))?
979
        .ok_or_else(|| Error::not_found(shell.clone()))?;
980
    if Some(me.account.id) != p.author_id && role < Role::Triage {
981
        return Err(Error::not_found(shell));
982
    }
983
    let new_state = match (f.action.as_str(), p.state.as_str()) {
984
        ("close", "open" | "draft") => "closed",
985
        ("reopen", "closed") => "open",
986
        ("ready", "draft") => "open",
987
        ("draft", "open") => "draft",
988
        _ => return Err(Error::bad(shell, "that transition is not possible")),
989
    };
990
    pdb::set_state(&app.db, p.id, new_state, "").await.map_err(|e| Error::from_db(shell.clone(), e))?;
991
    pdb::add_event(&app.db, p.id, &me.account.username, new_state, "").await.ok();
b64e2537 Andy McCall ↶ 992
    crate::hooks::emit(&app, &row, &format!("proposal.{new_state}"), &me.account.username, serde_json::json!({ "number": number })).await;
a1b98695 Andy McCall ↶ 993
    Ok(back(&owner, &name, number, "conversation", None, None))
de505df5 Andy McCall ↶ 994
}
995
996
pub async fn resolve_comment(
997
    State(app): State<Shared>,
998
    viewer: Viewer,
999
    Path((owner, name, number, id)): Path<(String, String, i64, i64)>,
1000
    Form(f): Form<super::session::CsrfForm>,
a1b98695 Andy McCall ↶ 1001
) -> Result<Response, Error> {
de505df5 Andy McCall ↶ 1002
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1003
    require(&viewer, &shell)?;
1004
    csrf(&viewer, &f.csrf, &shell)?;
1005
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1006
    let p = pdb::get(&app.db, row.id, number)
1007
        .await
1008
        .map_err(|e| Error::from_db(shell.clone(), e))?
1009
        .ok_or_else(|| Error::not_found(shell.clone()))?;
1010
    let current: Option<bool> = sqlx::query_scalar("SELECT resolved FROM proposal_comments WHERE id = ? AND proposal_id = ?")
1011
        .bind(id)
1012
        .bind(p.id)
1013
        .fetch_optional(&app.db)
1014
        .await
1015
        .map_err(|e| Error::from_db(shell.clone(), e))?;
1016
    if let Some(r) = current {
1017
        pdb::set_comment_resolved(&app.db, id, !r).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1018
    }
a1b98695 Andy McCall ↶ 1019
    Ok(back(&owner, &name, number, "files", None, None))
de505df5 Andy McCall ↶ 1020
}
1021
1022
/// Land from the hub: only when the repository has no trust policy (the
1023
/// hub never signs a reference update the policy would have to admit).
1024
pub async fn land(
1025
    State(app): State<Shared>,
1026
    viewer: Viewer,
1027
    Path((owner, name, number)): Path<(String, String, i64)>,
1028
    Form(f): Form<super::session::CsrfForm>,
a1b98695 Andy McCall ↶ 1029
) -> Result<Response, Error> {
de505df5 Andy McCall ↶ 1030
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1031
    let me = require(&viewer, &shell)?;
1032
    csrf(&viewer, &f.csrf, &shell)?;
1033
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
0b20f1ec Andy McCall ↶ 1034
    match land_as(&app, &repo, &row, &me.account, number, "web").await {
a1b98695 Andy McCall ↶ 1035
        Ok(_) => Ok(back(&owner, &name, number, "conversation", Some("Landed.".into()), None)),
1f1eb06a Andy McCall ↶ 1036
        Err(Landing::NotFound) => Err(Error::not_found(shell)),
a1b98695 Andy McCall ↶ 1037
        Err(Landing::Refused(m)) => Ok(back(&owner, &name, number, "conversation", None, Some(m))),
1f1eb06a Andy McCall ↶ 1038
    }
1039
}
1040
1041
/// Why a proposal did not land.
1042
pub enum Landing {
1043
    NotFound,
1044
    Refused(String),
1045
}
1046
1047
/// Land proposal `number` as `me` (hub spec §8.4), who has write access:
1048
/// refused while it is not open or has blockers. Moves the target bookmark
1049
/// (attested by the hub key on `me`'s behalf when they delegated to it),
0b20f1ec Andy McCall ↶ 1050
/// then records, announces and audits the landing, saying how it was done
1051
/// (`via`: `web`, or `api (token NAME)`). Returns the new state.
1f1eb06a Andy McCall ↶ 1052
pub async fn land_as(
1053
    app: &Shared,
1054
    repo: &RepoName,
1055
    row: &db::repos::RepoRow,
1056
    me: &accounts::Account,
1057
    number: i64,
0b20f1ec Andy McCall ↶ 1058
    via: &str,
1f1eb06a Andy McCall ↶ 1059
) -> Result<String, Landing> {
1060
    let (owner, name) = (row.owner.clone(), row.name.clone());
1061
    let refused = |e: String| Landing::Refused(e);
1062
    let p = pdb::get(&app.db, row.id, number).await.map_err(|e| refused(e.to_string()))?.ok_or(Landing::NotFound)?;
1063
    let loaded = load(app, repo, p.clone()).await.map_err(|e| refused(e.to_string()))?;
1064
    let v = verdict(app, &loaded, row.id).await.map_err(|e| refused(e.to_string()))?;
1065
    if !p.is_open() {
1066
        return Err(refused(format!("Cannot land: the proposal is {}", p.state)));
1067
    }
1068
    if !v.blockers.is_empty() {
1069
        return Err(refused(format!("Cannot land: {}", v.blockers.join("; "))));
de505df5 Andy McCall ↶ 1070
    }
1071
    let target = p.target.clone();
1072
    let heads = loaded.heads.clone();
14530604 Andy McCall ↶ 1073
    // With a delegation the landing is attested by the hub key on the
1074
    // user's behalf, which a policy accepts when it lets the user update
1075
    // the target (spec §14.5); without one, only a policy-free repository
1076
    // can be landed here.
1f1eb06a Andy McCall ↶ 1077
    let result = match super::edit::signer_for(app, me.id).await {
1078
        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 ↶ 1079
        Err(why) => {
1080
            app.repos
1f1eb06a Andy McCall ↶ 1081
                .write(repo, move |r, _| {
14530604 Andy McCall ↶ 1082
                    if r.current_policy()?.is_some() {
1083
                        return Err(crate::repos::RepoError::Oxy(oxy_core::repo::RepoError::Msg(format!(
1084
                            "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}"
1085
                        ))));
1086
                    }
1087
                    r.bookmark_set(&target, &heads)?;
1088
                    Ok(pl::state_ref(&heads).to_hex())
1089
                })
1090
                .await
1091
        }
1092
    };
1f1eb06a Andy McCall ↶ 1093
    let state = result.map_err(|e| refused(e.to_string()))?;
0b20f1ec Andy McCall ↶ 1094
    pdb::set_landed(&app.db, p.id, &state, &me.username, via).await.map_err(|e| refused(e.to_string()))?;
1095
    let how = if v.approvals.is_empty() { format!("{via}, with no approval") } else { via.to_string() };
1096
    pdb::add_event(&app.db, p.id, &me.username, "landed", &how).await.ok();
1f1eb06a Andy McCall ↶ 1097
    crate::hooks::emit(
1098
        app,
1099
        row,
1100
        "proposal.landed",
1101
        &me.username,
1102
        serde_json::json!({ "number": number, "target": p.target, "state": state }),
1103
    )
1104
    .await;
1105
    crate::notify::deliver(
1106
        app,
1107
        crate::notify::Event {
1108
            kind: "proposal.landed",
1109
            title: format!("{owner}/{name}#{number}: {} landed", p.title),
1110
            url: format!("/{owner}/{name}/proposal/{number}"),
1111
            actor: me,
1112
            repo_id: row.id,
1113
            thread: Some(("proposal", p.id)),
1114
            mention_text: "",
1115
            direct: p.author_id.into_iter().collect(),
1116
        },
1117
    )
1118
    .await;
1119
    if p.target == row.default_bookmark {
1120
        let messages: Vec<(String, String)> = loaded.changes.iter().map(|c| (c.hash.clone(), format!("{}\n{}", c.title, c.body))).collect();
1121
        super::issues::close_from_messages(app, row, &messages).await;
de505df5 Andy McCall ↶ 1122
    }
0b20f1ec Andy McCall ↶ 1123
    accounts::audit(&app.db, Some(me), "proposal.landed", &format!("{owner}/{name}#{number}"), &how).await.ok();
1f1eb06a Andy McCall ↶ 1124
    db::repos::record_push(
1125
        &app.db,
1126
        row.id,
1127
        Some((me.id, me.username.as_str())),
1128
        &[db::repos::PushOutcome {
1129
            reference: p.target.clone(),
1130
            ok: true,
0b20f1ec Andy McCall ↶ 1131
            reason: format!("landed #{number} ({how})"),
1f1eb06a Andy McCall ↶ 1132
            old_state: loaded.target_state.clone(),
1133
            new_state: state.clone(),
1134
            heads: loaded.heads.len() as i64,
1135
            changes: loaded.hashes.len() as i64,
1136
        }],
1137
    )
1138
    .await
1139
    .ok();
1140
    Ok(state)
de505df5 Andy McCall ↶ 1141
}
1142
18e381ea Andy McCall ↶ 1143
#[derive(Deserialize)]
1144
pub struct LabelsForm {
1145
    #[serde(rename = "_csrf")]
1146
    csrf_token: String,
1147
    #[serde(default)]
1148
    label: Vec<i64>,
1149
}
1150
1151
pub async fn set_labels(
1152
    State(app): State<Shared>,
1153
    viewer: Viewer,
1154
    Path((owner, name, number)): Path<(String, String, i64)>,
1155
    axum_extra::extract::Form(f): axum_extra::extract::Form<LabelsForm>,
a1b98695 Andy McCall ↶ 1156
) -> Result<Response, Error> {
18e381ea Andy McCall ↶ 1157
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1158
    require(&viewer, &shell)?;
1159
    csrf(&viewer, &f.csrf_token, &shell)?;
1160
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1161
    if role < Role::Triage {
1162
        return Err(Error::not_found(shell));
1163
    }
1164
    let p = pdb::get(&app.db, row.id, number)
1165
        .await
1166
        .map_err(|e| Error::from_db(shell.clone(), e))?
1167
        .ok_or_else(|| Error::not_found(shell.clone()))?;
1168
    for l in db::issues::labels(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1169
        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))?;
1170
    }
a1b98695 Andy McCall ↶ 1171
    Ok(back(&owner, &name, number, "conversation", None, None))
18e381ea Andy McCall ↶ 1172
}
1173
b64e2537 Andy McCall ↶ 1174
/// `GET /{owner}/{name}/proposal/{number}/events`: server-sent events for
1175
/// checks on the projected state (hub spec §13.1).
1176
pub async fn events(
1177
    State(app): State<Shared>,
1178
    viewer: Viewer,
1179
    Path((owner, name, number)): Path<(String, String, i64)>,
1180
) -> Result<axum::response::Sse<impl futures::Stream<Item = Result<axum::response::sse::Event, std::convert::Infallible>>>, Error> {
1181
    use futures::StreamExt;
1182
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1183
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1184
    let rx = app.events.proposals.subscribe();
1185
    let repo_id = row.id;
1186
    let stream = tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(move |item| async move {
1187
        match item {
1188
            Ok((id, ev)) if id == repo_id => {
1189
                Some(Ok(axum::response::sse::Event::default().event(ev.kind.clone()).data(serde_json::to_string(&ev).unwrap_or_default())))
1190
            }
1191
            _ => None,
1192
        }
1193
    });
1194
    Ok(axum::response::Sse::new(stream).keep_alive(axum::response::sse::KeepAlive::default()))
1195
}
1196
de505df5 Andy McCall ↶ 1197
// -------------------------------------------------------------------- API
1198
1199
pub async fn to_api(app: &Shared, owner: &str, name: &str, loaded: &Loaded, v: &Verdict, source_url: Option<String>) -> hub_api::Proposal {
1200
    let p = &loaded.row;
1201
    hub_api::Proposal {
1202
        number: p.number,
1203
        title: p.title.clone(),
1204
        body: p.body.clone(),
1205
        author: p.author.clone(),
1206
        state: ProposalState::parse(&p.state).unwrap_or(ProposalState::Open),
1207
        target: p.target.clone(),
1208
        source: match p.source_kind.as_str() {
1209
            "fork" => {
1210
                let (o, n) = source_url
1211
                    .as_deref()
1212
                    .and_then(|u| u.rsplit_once('/'))
1213
                    .and_then(|(b, n)| b.rsplit_once('/').map(|(_, o)| (o.to_string(), n.to_string())))
1214
                    .unwrap_or_default();
1215
                ProposalSource::Fork { owner: o, name: n, bookmark: p.source_bookmark.clone() }
1216
            }
1217
            "changes" => ProposalSource::Changes { hashes: p.source_changes() },
1218
            _ => ProposalSource::Bookmark { bookmark: p.source_bookmark.clone() },
1219
        },
1220
        changes: loaded
1221
            .changes
1222
            .iter()
1223
            .map(|c| hub_api::ChangeSummary {
1224
                hash: c.hash.clone(),
1225
                message: if c.body.is_empty() { c.title.clone() } else { format!("{}\n\n{}", c.title, c.body) },
1226
                author: hub_api::Author {
1227
                    name: c.author.name.clone(),
1228
                    email: c.author.email.clone(),
1229
                    key: c.author.key.clone(),
1230
                    account: c.author.account.clone(),
760a7dd3 Andy McCall ↶ 1231
                    note: hub_api::Author::note_for(&c.author.key, &c.author.account),
de505df5 Andy McCall ↶ 1232
                },
1233
                when_ms: c.when_ms,
1234
                deps: c.deps.clone(),
1235
                head: c.head,
1236
                signature: match &c.signature {
1237
                    view::SigView::Unsigned => hub_api::Signature::Unsigned,
1238
                    view::SigView::Valid { key, sealed, revoked, .. } => {
1239
                        hub_api::Signature::Valid { key: key.clone(), sealed: *sealed, revoked: *revoked }
1240
                    }
1241
                    view::SigView::Invalid(why) => hub_api::Signature::Invalid { reason: why.clone() },
1242
                },
1243
            })
1244
            .collect(),
1245
        projected_heads: loaded.heads.iter().map(|h| h.to_hex()).collect(),
1246
        projected_state: Some(loaded.state.clone()),
1247
        conflicted: loaded.conflicted,
1248
        approvals: v.approvals.clone(),
1249
        landable: p.is_open() && v.blockers.is_empty(),
1250
        blockers: v.blockers.clone(),
1251
        created_ms: p.created_ms,
1252
        updated_ms: p.updated_ms,
1253
        url: format!("{}/{owner}/{name}/proposal/{}", app.config.base_url(), p.number),
1254
        repository_url: format!("{}/{owner}/{name}", app.config.base_url()),
1255
        source_url,
0b20f1ec Andy McCall ↶ 1256
        landed: (p.state == "landed" && p.landed_ms > 0).then(|| hub_api::Landed {
1257
            by: p.landed_by.clone(),
1258
            via: p.landed_via.clone(),
1259
            when_ms: p.landed_ms,
1260
        }),
cc1cd197 Andy McCall ↶ 1261
        requested_reviewers: pdb::review_requests(&app.db, p.id).await.unwrap_or_default(),
de505df5 Andy McCall ↶ 1262
    }
1263
}
1264
1265
/// The line comment form's placement key, for templates.
1266
pub fn key(path: &str, line: impl filters::Num) -> (String, usize) {
1267
    (path.to_string(), line.as_u64() as usize)
1268
}
1269
1270
#[allow(dead_code)]
1271
fn _unused(_: &diff::Line, _: &ConflictView, _: &Role) {}
cc1cd197 Andy McCall ↶ 1272
1273
// ------------------------------------------------------- review requests
1274
1275
/// Ask accounts to review a proposal (hub plan 0.4): each must exist, be
1276
/// someone other than the author, and be able to read the repository. Each
1277
/// newly asked is told, and the proposal records it. The usernames newly
1278
/// asked, or why none could be.
1279
pub async fn request_reviewers(
1280
    app: &Shared,
1281
    row: &db::repos::RepoRow,
1282
    p: &pdb::ProposalRow,
1283
    by: &accounts::Account,
1284
    names: &[String],
1285
) -> Result<Vec<String>, String> {
1286
    let mut found = Vec::new();
1287
    for name in names.iter().map(|n| n.trim().trim_start_matches('@')).filter(|n| !n.is_empty()) {
1288
        let Some(a) = accounts::account_by_name(&app.db, name).await.ok().flatten() else {
1289
            return Err(format!("There is no account {name}."));
1290
        };
1291
        if Some(a.id) == p.author_id {
1292
            return Err(format!("{name} wrote this proposal, and cannot review it."));
1293
        }
1294
        let role = crate::auth::roles::role_for(&app.db, Some(&a), row).await.ok().flatten();
1295
        if !crate::auth::roles::allows(role, crate::auth::Access::Read) {
1296
            return Err(format!("{name} cannot read this repository."));
1297
        }
1298
        found.push(a);
1299
    }
1300
    let mut asked = Vec::new();
1301
    for a in found {
1302
        if !pdb::request_review(&app.db, p.id, a.id, &by.username).await.map_err(|e| e.to_string())? {
1303
            continue;
1304
        }
1305
        pdb::add_event(&app.db, p.id, &by.username, "review requested", &a.username).await.ok();
1306
        crate::notify::deliver(
1307
            app,
1308
            crate::notify::Event {
1309
                kind: "proposal.review_requested",
1310
                title: format!("{}/{}#{}: {} (review requested from {})", row.owner, row.name, p.number, p.title, a.username),
1311
                url: format!("/{}/{}/proposal/{}", row.owner, row.name, p.number),
1312
                actor: by,
1313
                repo_id: row.id,
1314
                thread: None,
1315
                mention_text: "",
1316
                direct: vec![a.id],
1317
            },
1318
        )
1319
        .await;
1320
        asked.push(a.username);
1321
    }
1322
    Ok(asked)
1323
}
1324
1325
/// After a review: it answers the reviewer's request, if there was one, and
1326
/// the proposal's author is told.
1327
pub async fn reviewed(app: &Shared, row: &db::repos::RepoRow, p: &pdb::ProposalRow, by: &accounts::Account, verdict: &str) {
1328
    pdb::unrequest_review(&app.db, p.id, by.id).await.ok();
1329
    let what = match verdict {
1330
        "approve" => "approved",
1331
        "changes" => "changes requested",
1332
        _ => "reviewed",
1333
    };
1334
    crate::notify::deliver(
1335
        app,
1336
        crate::notify::Event {
1337
            kind: "proposal.review",
1338
            title: format!("{}/{}#{}: {} ({what} by {})", row.owner, row.name, p.number, p.title, by.username),
1339
            url: format!("/{}/{}/proposal/{}", row.owner, row.name, p.number),
1340
            actor: by,
1341
            repo_id: row.id,
1342
            thread: Some(("proposal", p.id)),
1343
            mention_text: "",
1344
            direct: p.author_id.into_iter().collect(),
1345
        },
1346
    )
1347
    .await;
1348
}
1349
1350
#[derive(Deserialize)]
1351
pub struct ReviewersForm {
1352
    #[serde(rename = "_csrf")]
1353
    csrf_token: String,
1354
    /// `add` or `remove`.
1355
    action: String,
1356
    username: String,
1357
}
1358
1359
/// `POST /{owner}/{name}/proposal/{number}/reviewers`: the author or a
1360
/// writer asks someone to review, or withdraws the request.
1361
pub async fn reviewers(
1362
    State(app): State<Shared>,
1363
    viewer: Viewer,
1364
    Path((owner, name, number)): Path<(String, String, i64)>,
1365
    Form(f): Form<ReviewersForm>,
1366
) -> Result<Response, Error> {
1367
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1368
    let me = require(&viewer, &shell)?;
1369
    csrf(&viewer, &f.csrf_token, &shell)?;
1370
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1371
    let p = pdb::get(&app.db, row.id, number)
1372
        .await
1373
        .map_err(|e| Error::from_db(shell.clone(), e))?
1374
        .ok_or_else(|| Error::not_found(shell.clone()))?;
1375
    if Some(me.account.id) != p.author_id && role < Role::Write {
1376
        return Err(Error::not_found(shell));
1377
    }
1378
    let who = f.username.trim().trim_start_matches('@').to_string();
1379
    if f.action == "remove" {
1380
        if let Some(a) = accounts::account_by_name(&app.db, &who).await.ok().flatten() {
1381
            if pdb::unrequest_review(&app.db, p.id, a.id).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1382
                pdb::add_event(&app.db, p.id, &me.account.username, "review request withdrawn", &a.username).await.ok();
1383
            }
1384
        }
1385
        return Ok(back(&owner, &name, number, "conversation", None, None));
1386
    }
1387
    match request_reviewers(&app, &row, &p, &me.account, &[who]).await {
1388
        Ok(asked) if asked.is_empty() => Ok(back(&owner, &name, number, "conversation", Some("Already asked.".into()), None)),
1389
        Ok(asked) => Ok(back(&owner, &name, number, "conversation", Some(format!("Asked {} to review.", asked.join(", "))), None)),
1390
        Err(e) => Ok(back(&owner, &name, number, "conversation", None, Some(e))),
1391
    }
1392
}