OXYGENHUB
# 1e23d0105921 oxygenhub / crates / hub / src / web / proposals.rs
36.8 KiB · Rust · last changed A conflicted proposal says how to resolve it 1 month ago source blame history raw
1
//! Proposal pages (hub spec §7): list, new, detail with conversation,
2
//! changes and files, comments anchored to atoms, reviews, landing.
3
4
use super::history::{render_files, FileRows};
5
use super::settings::{csrf, require};
6
use super::{filters, lookup, lookup_role, Error, Page, RepoShell, Shell};
7
use crate::auth::{Access, Role, Viewer};
8
use crate::db::{self, accounts, proposals as pdb};
9
use crate::render::{self, diff};
10
use crate::repos::proposals::{self as pl, Projection, Source};
11
use crate::repos::view::{self, ChangeView, ConflictView};
12
use crate::repos::RepoName;
13
use crate::Shared;
14
use askama::Template;
15
use axum::extract::{Form, Path, Query, State};
16
use axum::response::{IntoResponse, Redirect, Response};
17
use hub_api::{Approval, ProposalSource, ProposalState};
18
use oxy_core::hash::ChangeHash;
19
use serde::Deserialize;
20
use std::collections::{BTreeMap, HashMap};
21
22
// ------------------------------------------------------------------ list
23
24
#[derive(Template)]
25
#[template(path = "proposals.html")]
26
pub struct ProposalsPage {
27
    shell: Shell,
28
    repo: RepoShell,
29
    rows: Vec<pdb::ProposalRow>,
30
    filter: String,
31
    counts: (usize, usize, usize),
32
    bookmarks: Vec<String>,
33
}
34
35
#[derive(Deserialize)]
36
pub struct ListQuery {
37
    state: Option<String>,
38
}
39
40
pub async fn list(
41
    State(app): State<Shared>,
42
    viewer: Viewer,
43
    Path((owner, name)): Path<(String, String)>,
44
    Query(q): Query<ListQuery>,
45
) -> Result<Page<ProposalsPage>, Error> {
46
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposals"), &viewer);
47
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
48
    let all = pdb::list(&app.db, row.id, &[]).await.map_err(|e| Error::from_db(shell.clone(), e))?;
49
    let counts = (
50
        all.iter().filter(|p| p.is_open()).count(),
51
        all.iter().filter(|p| p.state == "landed").count(),
52
        all.iter().filter(|p| p.state == "closed").count(),
53
    );
54
    let filter = q.state.unwrap_or_else(|| "open".into());
55
    let rows = all
56
        .into_iter()
57
        .filter(|p| match filter.as_str() {
58
            "open" => p.is_open(),
59
            "landed" => p.state == "landed",
60
            "closed" => p.state == "closed",
61
            _ => true,
62
        })
63
        .collect();
64
    let bookmarks = app
65
        .repos
66
        .read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::<Vec<_>>()))
67
        .await
68
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
69
    Ok(Page(ProposalsPage { shell, repo: RepoShell::new(&app, &row, "proposals").with_role(role), rows, filter, counts, bookmarks }))
70
}
71
72
// ------------------------------------------------------------------- new
73
74
#[derive(Template)]
75
#[template(path = "proposal_new.html")]
76
pub struct NewProposalPage {
77
    shell: Shell,
78
    repo: RepoShell,
79
    bookmarks: Vec<String>,
80
    source: String,
81
    target: String,
82
    error: Option<String>,
83
    /// Preview: the changes the proposal would add.
84
    preview: Vec<ChangeView>,
85
    conflicted: bool,
86
}
87
88
#[derive(Deserialize)]
89
pub struct NewQuery {
90
    source: Option<String>,
91
    target: Option<String>,
92
}
93
94
/// `owner/name:bookmark` for a fork, else a bookmark of this repository.
95
fn parse_source(app: &Shared, repo: &RepoName, source: &str) -> Option<(Option<RepoName>, String)> {
96
    if let Some((slug, bookmark)) = source.split_once(':') {
97
        let fork = RepoName::parse(slug)?;
98
        if !app.repos.exists(&fork) || fork == *repo {
99
            return None;
100
        }
101
        return Some((Some(fork), bookmark.to_string()));
102
    }
103
    crate::repos::names::valid_segment(source).then(|| (None, source.to_string()))
104
}
105
106
async fn preview_for(
107
    app: &Shared,
108
    repo: &RepoName,
109
    target: &str,
110
    source: &str,
111
) -> Result<(Vec<ChangeView>, bool), crate::repos::RepoError> {
112
    let Some((fork, bookmark)) = parse_source(app, repo, source) else { return Ok((vec![], false)) };
113
    let target = target.to_string();
114
    let fork_dir = fork.as_ref().map(|f| app.repos.dir(f));
115
    app.repos
116
        .read(repo, move |r, cache| {
117
            let projection = match &fork_dir {
118
                Some(dir) => {
119
                    let fork_repo = oxy_core::repo::Repo::open(dir)?;
120
                    let heads = fork_repo.bookmarks()?.into_iter().find(|b| b.name == bookmark).map(|b| b.heads).unwrap_or_default();
121
                    pl::project(r, cache, &target, Source::Fork { fork: &fork_repo, heads })?
122
                }
123
                None => {
124
                    let heads = r.bookmarks()?.into_iter().find(|b| b.name == bookmark).map(|b| b.heads).unwrap_or_default();
125
                    pl::project(r, cache, &target, Source::Bookmark(heads))?
126
                }
127
            };
128
            Ok((projection.changes, projection.conflicted))
129
        })
130
        .await
131
}
132
133
pub async fn new_form(
134
    State(app): State<Shared>,
135
    viewer: Viewer,
136
    Path((owner, name)): Path<(String, String)>,
137
    Query(q): Query<NewQuery>,
138
) -> Result<Page<NewProposalPage>, Error> {
139
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposals/new"), &viewer);
140
    require(&viewer, &shell)?;
141
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
142
    let bookmarks = app
143
        .repos
144
        .read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::<Vec<_>>()))
145
        .await
146
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
147
    let target = q.target.unwrap_or_else(|| row.default_bookmark.clone());
148
    let source = q.source.unwrap_or_default();
149
    let (preview, conflicted) =
150
        if source.is_empty() { (vec![], false) } else { preview_for(&app, &repo, &target, &source).await.unwrap_or((vec![], false)) };
151
    Ok(Page(NewProposalPage {
152
        shell,
153
        repo: RepoShell::new(&app, &row, "proposals").with_role(role),
154
        bookmarks,
155
        source,
156
        target,
157
        error: None,
158
        preview,
159
        conflicted,
160
    }))
161
}
162
163
#[derive(Deserialize)]
164
pub struct NewForm {
165
    #[serde(rename = "_csrf")]
166
    csrf_token: String,
167
    title: String,
168
    #[serde(default)]
169
    body: String,
170
    target: String,
171
    source: String,
172
    #[serde(default)]
173
    draft: Option<String>,
174
}
175
176
pub async fn create(
177
    State(app): State<Shared>,
178
    viewer: Viewer,
179
    Path((owner, name)): Path<(String, String)>,
180
    Form(f): Form<NewForm>,
181
) -> Result<Response, Error> {
182
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposals/new"), &viewer);
183
    let me = require(&viewer, &shell)?;
184
    csrf(&viewer, &f.csrf_token, &shell)?;
185
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
186
    let bookmarks = app
187
        .repos
188
        .read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::<Vec<_>>()))
189
        .await
190
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
191
    let fail = |msg: &str, preview: Vec<ChangeView>| {
192
        Page(NewProposalPage {
193
            shell: shell.clone(),
194
            repo: RepoShell::new(&app, &row, "proposals").with_role(role),
195
            bookmarks: bookmarks.clone(),
196
            source: f.source.clone(),
197
            target: f.target.clone(),
198
            error: Some(msg.into()),
199
            preview,
200
            conflicted: false,
201
        })
202
        .into_response()
203
    };
204
    if f.title.trim().is_empty() {
205
        return Ok(fail("Give the proposal a title.", vec![]));
206
    }
207
    if !bookmarks.contains(&f.target) {
208
        return Ok(fail("Pick a target bookmark of this repository.", vec![]));
209
    }
210
    let Some((fork, bookmark)) = parse_source(&app, &repo, f.source.trim()) else {
211
        return Ok(fail("The source is a bookmark of this repository, or owner/name:bookmark of a fork.", vec![]));
212
    };
213
    if fork.is_none() && bookmark == f.target {
214
        return Ok(fail("The source and the target are the same bookmark.", vec![]));
215
    }
216
    let (preview, _) = preview_for(&app, &repo, &f.target, f.source.trim()).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
217
    if preview.is_empty() {
218
        return Ok(fail("The source adds nothing to the target.", vec![]));
219
    }
220
    let source_repo_id = match &fork {
221
        Some(fk) => db::repos::get(&app.db, &fk.owner, &fk.name).await.map_err(|e| Error::from_db(shell.clone(), e))?.map(|r| r.id),
222
        None => None,
223
    };
224
    let created = pdb::create(
225
        &app.db,
226
        pdb::NewProposal {
227
            repo_id: row.id,
228
            title: f.title.trim(),
229
            body: f.body.trim(),
230
            author_id: Some(me.account.id),
231
            author: &me.account.username,
232
            draft: f.draft.is_some(),
233
            target: &f.target,
234
            source_kind: if fork.is_some() { "fork" } else { "bookmark" },
235
            source_repo_id,
236
            source_bookmark: &bookmark,
237
            source_changes: "",
238
        },
239
    )
240
    .await
241
    .map_err(|e| Error::from_db(shell.clone(), e))?;
242
    pdb::add_event(&app.db, created.id, &me.account.username, "opened", "").await.ok();
243
    accounts::audit(&app.db, Some(&me.account), "proposal.opened", &format!("{owner}/{name}#{}", created.number), &f.title).await.ok();
244
    Ok(Redirect::to(&format!("/{owner}/{name}/proposal/{}", created.number)).into_response())
245
}
246
247
// ---------------------------------------------------------------- detail
248
249
/// A comment placed on a diff row, or general.
250
pub struct CommentView {
251
    pub row: pdb::CommentRow,
252
    pub body_html: String,
253
    pub line: Option<usize>,
254
}
255
256
pub struct ReviewView {
257
    pub row: pdb::ReviewRow,
258
    pub body_html: String,
259
    pub sealed: bool,
260
}
261
262
/// Everything the detail page and the API need, computed once under the lock.
263
pub struct Loaded {
264
    pub row: pdb::ProposalRow,
265
    pub changes: Vec<ChangeView>,
266
    pub hashes: Vec<ChangeHash>,
267
    pub heads: Vec<ChangeHash>,
268
    pub state: String,
269
    pub target_state: String,
270
    pub conflicted: bool,
271
    pub conflicts: Vec<ConflictView>,
272
    pub files: Vec<FileRows>,
273
    /// Atom per (path, new line number) of the projected state.
274
    pub atoms: HashMap<(String, usize), (String, u32)>,
275
    pub sealing_keys: Vec<String>,
276
    pub touched: Vec<String>,
277
    pub owners: Vec<String>,
278
    pub landed_already: bool,
279
}
280
281
/// Compute the projection and everything derived from it.
282
pub async fn load(app: &Shared, repo: &RepoName, row: pdb::ProposalRow) -> Result<Loaded, crate::repos::RepoError> {
283
    let fork_dir = match (row.source_kind.as_str(), row.source_repo_id) {
284
        ("fork", Some(id)) => {
285
            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))
286
        }
287
        _ => None,
288
    };
289
    let r2 = row.clone();
290
    let loaded = app
291
        .repos
292
        .read(repo, move |r, cache| {
293
            let source = match (r2.source_kind.as_str(), &fork_dir) {
294
                ("changes", _) => Source::Changes(r2.source_changes().iter().filter_map(|h| ChangeHash::from_hex(h)).collect()),
295
                ("fork", Some(dir)) => {
296
                    let fork_repo = oxy_core::repo::Repo::open(dir)?;
297
                    let heads =
298
                        fork_repo.bookmarks()?.into_iter().find(|b| b.name == r2.source_bookmark).map(|b| b.heads).unwrap_or_default();
299
                    // Bring the fork's changes over now, so the projection below can read them.
300
                    let fork_state = fork_repo.load_state(&heads)?;
301
                    let missing: Vec<ChangeHash> = fork_state.changes().map(|(h, _)| *h).filter(|h| !r.store().has(h)).collect();
302
                    if !missing.is_empty() {
303
                        r.import_bundle(&fork_repo.bundle_for(&missing)?, r.current_policy()?.is_some())?;
304
                    }
305
                    Source::Changes(heads)
306
                }
307
                _ => {
308
                    let heads = r.bookmarks()?.into_iter().find(|b| b.name == r2.source_bookmark).map(|b| b.heads).unwrap_or_default();
309
                    Source::Bookmark(heads)
310
                }
311
            };
312
            let p: Projection = pl::project(r, cache, &r2.target, source)?;
313
            let target_loaded = cache.load(r, &p.target_heads)?;
314
            let files = render_files(&p.files, &p.bases);
315
            let mut atoms = HashMap::new();
316
            for f in &files {
317
                for l in &f.lines {
318
                    if let Some(n) = l.new_no {
319
                        if let Some((h, i)) = pl::atom_at(&p.loaded, &f.path, n as usize) {
320
                            atoms.insert((f.path.clone(), n as usize), (h.to_hex(), i));
321
                        }
322
                    }
323
                }
324
            }
325
            let touched: Vec<String> = p.files.iter().map(|f| f.path.clone()).collect();
326
            let owners = pl::owners_for(&target_loaded, &touched);
327
            let sealing = pl::sealing_keys(r, &p.hashes)?.into_iter().map(|k| k.to_hex()).collect();
328
            let landed_already = !p.hashes.is_empty() && p.hashes.iter().all(|h| target_loaded.state.contains(h));
329
            Ok(Loaded {
330
                row: r2,
331
                changes: p.changes,
332
                hashes: p.hashes,
333
                heads: p.heads,
334
                state: p.state.to_hex(),
335
                target_state: p.target_state.to_hex(),
336
                conflicted: p.conflicted,
337
                conflicts: p.conflicts,
338
                files,
339
                atoms,
340
                sealing_keys: sealing,
341
                touched,
342
                owners,
343
                landed_already,
344
            })
345
        })
346
        .await?;
347
    // Remember the projection for the list, the API and other proposals' stacking checks.
348
    let heads_csv = loaded.heads.iter().map(|h| h.to_hex()).collect::<Vec<_>>().join(",");
349
    let changes_csv = loaded.hashes.iter().map(|h| h.to_hex()).collect::<Vec<_>>().join(",");
350
    pdb::set_projection(&app.db, loaded.row.id, &loaded.state, &heads_csv, &loaded.target_state, &changes_csv, loaded.conflicted)
351
        .await
352
        .ok();
353
    Ok(loaded)
354
}
355
356
/// Reviews, approvals and blockers.
357
pub struct Verdict {
358
    pub reviews: Vec<ReviewView>,
359
    pub approvals: Vec<Approval>,
360
    pub blockers: Vec<String>,
361
    pub depends_on: Vec<i64>,
362
    pub requirements: pdb::Requirements,
363
}
364
365
pub async fn verdict(app: &Shared, loaded: &Loaded, repo_id: i64) -> Result<Verdict, sqlx::Error> {
366
    let rows = pdb::reviews(&app.db, loaded.row.id).await?;
367
    let links = accounts::identity_accounts(&app.db).await?;
368
    let sealed_accounts: Vec<String> = links.iter().filter(|(k, _)| loaded.sealing_keys.contains(k)).map(|(_, u)| u.clone()).collect();
369
    // Latest verdict per reviewer.
370
    let mut latest: BTreeMap<String, &pdb::ReviewRow> = BTreeMap::new();
371
    for r in &rows {
372
        latest.insert(r.author.clone(), r);
373
    }
374
    let mut approvals = Vec::new();
375
    for (who, r) in &latest {
376
        if r.verdict == "approve" {
377
            let sealed = sealed_accounts.contains(who);
378
            let key = links.iter().find(|(k, u)| u == who && loaded.sealing_keys.contains(k)).map(|(k, _)| k.clone());
379
            approvals.push(Approval { account: who.clone(), sealed, key });
380
        }
381
    }
382
    let reviews = rows
383
        .iter()
384
        .map(|r| ReviewView {
385
            row: r.clone(),
386
            body_html: render::markdown::render(&r.body, "/", "/"),
387
            sealed: r.verdict == "approve" && sealed_accounts.contains(&r.author),
388
        })
389
        .collect();
390
    let requirements = pdb::requirements(&app.db, repo_id).await?;
391
    let mut blockers = Vec::new();
392
    if loaded.row.state == "draft" {
393
        blockers.push("the proposal is a draft".into());
394
    }
395
    if loaded.hashes.is_empty() {
396
        blockers.push("the proposal adds no changes".into());
397
    }
398
    let sealed_count = approvals.iter().filter(|a| a.sealed).count() as i64;
399
    if sealed_count < requirements.required_approvals {
400
        blockers.push(format!("{sealed_count} of {} required sealed approvals", requirements.required_approvals));
401
    }
402
    if requirements.require_owners {
403
        let missing: Vec<&String> = loaded.owners.iter().filter(|o| !approvals.iter().any(|a| a.sealed && &a.account == *o)).collect();
404
        if !missing.is_empty() {
405
            blockers.push(format!("code owners still to approve: {}", missing.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")));
406
        }
407
    }
408
    if loaded.conflicted && !requirements.allow_conflicts {
409
        blockers.push(format!(
410
            "landing would carry conflicts, which this repository refuses: resolve them on the source (oxy merge {} there, fix the marked files, oxy commit, push)",
411
            loaded.row.target
412
        ));
413
    }
414
    let requesting: Vec<&String> = latest.iter().filter(|(_, r)| r.verdict == "changes").map(|(who, _)| who).collect();
415
    if !requesting.is_empty() {
416
        blockers.push(format!("changes requested by {}", requesting.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")));
417
    }
418
    // Stacking: another open, earlier proposal whose changes this one includes.
419
    let mut depends_on = Vec::new();
420
    for other in pdb::list(&app.db, repo_id, &["open", "draft"]).await? {
421
        if other.id == loaded.row.id || other.number > loaded.row.number {
422
            continue;
423
        }
424
        let theirs = other.projected_changes();
425
        if !theirs.is_empty() && theirs.iter().all(|h| loaded.hashes.iter().any(|m| m.to_hex() == *h)) {
426
            depends_on.push(other.number);
427
        }
428
    }
429
    if !depends_on.is_empty() {
430
        blockers.push(format!("depends on {}", depends_on.iter().map(|n| format!("#{n}")).collect::<Vec<_>>().join(", ")));
431
    }
432
    Ok(Verdict { reviews, approvals, blockers, depends_on, requirements })
433
}
434
435
#[derive(Template)]
436
#[template(path = "proposal.html")]
437
pub struct ProposalPage {
438
    shell: Shell,
439
    repo: RepoShell,
440
    p: pdb::ProposalRow,
441
    body_html: String,
442
    tab: String,
443
    changes: Vec<ChangeView>,
444
    files: Vec<FileRows>,
445
    conflicts: Vec<ConflictView>,
446
    conflicted: bool,
447
    comments: Vec<CommentView>,
448
    /// Anchored comments by (path, current line).
449
    placed: HashMap<(String, usize), Vec<usize>>,
450
    outdated: Vec<usize>,
451
    reviews: Vec<ReviewView>,
452
    approvals: Vec<Approval>,
453
    blockers: Vec<String>,
454
    depends_on: Vec<i64>,
455
    events: Vec<pdb::EventRow>,
456
    owners: Vec<String>,
457
    requirements: pdb::Requirements,
458
    landed_already: bool,
459
    can_edit: bool,
460
    can_land: bool,
461
    hub_can_land: bool,
462
    notice: Option<String>,
463
    error: Option<String>,
464
    heads: Vec<String>,
465
    state_hash: String,
466
    source_label: String,
467
    api_url: String,
468
}
469
470
#[derive(Deserialize)]
471
pub struct DetailQuery {
472
    tab: Option<String>,
473
}
474
475
/// The page assembler every action ends in; its arguments are the route
476
/// plus what to say, and a struct would only rename them.
477
#[allow(clippy::too_many_arguments)]
478
async fn detail(
479
    app: &Shared,
480
    shell: Shell,
481
    viewer: &Viewer,
482
    owner: &str,
483
    name: &str,
484
    number: i64,
485
    tab: String,
486
    notice: Option<String>,
487
    error: Option<String>,
488
) -> Result<Page<ProposalPage>, Error> {
489
    let (repo, row, role) = lookup(app, &shell, viewer, owner, name).await?;
490
    let p = pdb::get(&app.db, row.id, number)
491
        .await
492
        .map_err(|e| Error::from_db(shell.clone(), e))?
493
        .ok_or_else(|| Error::not_found(shell.clone()))?;
494
    let loaded = load(app, &repo, p).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
495
    // A proposal whose changes all reached the target landed by another route.
496
    if loaded.landed_already && loaded.row.is_open() {
497
        pdb::set_state(&app.db, loaded.row.id, "landed", &loaded.target_state).await.ok();
498
        pdb::add_event(&app.db, loaded.row.id, "", "landed", "every change reached the target").await.ok();
499
    }
500
    let p = pdb::get(&app.db, row.id, number).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(loaded.row.clone());
501
    let v = verdict(app, &loaded, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
502
    let comment_rows = pdb::comments(&app.db, loaded.row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
503
    let events = pdb::events(&app.db, loaded.row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
504
    // Place anchored comments at their atom's current line.
505
    let mut by_atom: HashMap<(String, String, u32), usize> = HashMap::new();
506
    for ((path, line), (h, i)) in &loaded.atoms {
507
        by_atom.insert((path.clone(), h.clone(), *i), *line);
508
    }
509
    let mut comments = Vec::new();
510
    let mut placed: HashMap<(String, usize), Vec<usize>> = HashMap::new();
511
    let mut outdated = Vec::new();
512
    for (idx, c) in comment_rows.into_iter().enumerate() {
513
        let line = if c.anchor_change.is_empty() {
514
            None
515
        } else {
516
            by_atom.get(&(c.path.clone(), c.anchor_change.clone(), c.anchor_index as u32)).copied()
517
        };
518
        if !c.anchor_change.is_empty() {
519
            match line {
520
                Some(l) => placed.entry((c.path.clone(), l)).or_default().push(idx),
521
                None => outdated.push(idx),
522
            }
523
        }
524
        comments.push(CommentView { body_html: render::markdown::render(&c.body, "/", "/"), row: c, line });
525
    }
526
    let mut changes = loaded.changes.clone();
527
    super::attribute(app, &mut changes.iter_mut().collect::<Vec<_>>()).await;
528
    let me = viewer.account();
529
    let can_edit = me.is_some_and(|a| Some(a.id) == p.author_id || role >= Role::Maintain);
530
    let can_land = role >= Role::Write && p.is_open() && v.blockers.is_empty();
531
    let hub_can_land = app.repos.read(&repo, |r, _| Ok(r.current_policy()?.is_none())).await.unwrap_or(false);
532
    let source_label = match p.source_kind.as_str() {
533
        "fork" => format!(
534
            "fork {}",
535
            db::repos::get_by_id(&app.db, p.source_repo_id.unwrap_or(0))
536
                .await
537
                .ok()
538
                .flatten()
539
                .map(|r| format!("{}:{}", r.slug(), p.source_bookmark))
540
                .unwrap_or_default()
541
        ),
542
        "changes" => format!("{} changes", p.source_changes().len()),
543
        _ => p.source_bookmark.clone(),
544
    };
545
    Ok(Page(ProposalPage {
546
        shell,
547
        repo: RepoShell::new(app, &row, "proposals").with_role(role),
548
        body_html: render::markdown::render(&p.body, "/", "/"),
549
        p,
550
        tab,
551
        changes,
552
        files: loaded.files,
553
        conflicts: loaded.conflicts,
554
        conflicted: loaded.conflicted,
555
        comments,
556
        placed,
557
        outdated,
558
        reviews: v.reviews,
559
        approvals: v.approvals,
560
        blockers: v.blockers,
561
        depends_on: v.depends_on,
562
        events,
563
        owners: loaded.owners,
564
        requirements: v.requirements,
565
        landed_already: loaded.landed_already,
566
        can_edit,
567
        can_land,
568
        hub_can_land,
569
        notice,
570
        error,
571
        heads: loaded.heads.iter().map(|h| h.to_hex()).collect(),
572
        state_hash: loaded.state,
573
        source_label,
574
        api_url: format!("{}/api/v1/repos/{owner}/{name}/proposals/{number}", app.config.base_url()),
575
    }))
576
}
577
578
pub async fn show(
579
    State(app): State<Shared>,
580
    viewer: Viewer,
581
    Path((owner, name, number)): Path<(String, String, i64)>,
582
    Query(q): Query<DetailQuery>,
583
) -> Result<Page<ProposalPage>, Error> {
584
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
585
    detail(&app, shell, &viewer, &owner, &name, number, q.tab.unwrap_or_else(|| "conversation".into()), None, None).await
586
}
587
588
// ---------------------------------------------------------------- actions
589
590
#[derive(Deserialize)]
591
pub struct CommentForm {
592
    #[serde(rename = "_csrf")]
593
    csrf_token: String,
594
    body: String,
595
    #[serde(default)]
596
    path: String,
597
    #[serde(default)]
598
    line: Option<usize>,
599
}
600
601
pub async fn comment(
602
    State(app): State<Shared>,
603
    viewer: Viewer,
604
    Path((owner, name, number)): Path<(String, String, i64)>,
605
    Form(f): Form<CommentForm>,
606
) -> Result<Page<ProposalPage>, Error> {
607
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
608
    let me = require(&viewer, &shell)?;
609
    csrf(&viewer, &f.csrf_token, &shell)?;
610
    let (repo, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
611
    let p = pdb::get(&app.db, row.id, number)
612
        .await
613
        .map_err(|e| Error::from_db(shell.clone(), e))?
614
        .ok_or_else(|| Error::not_found(shell.clone()))?;
615
    if f.body.trim().is_empty() {
616
        return detail(
617
            &app,
618
            shell,
619
            &viewer,
620
            &owner,
621
            &name,
622
            number,
623
            "conversation".into(),
624
            None,
625
            Some("An empty comment says nothing.".into()),
626
        )
627
        .await;
628
    }
629
    let anchor = match (f.path.trim(), f.line) {
630
        ("", _) | (_, None) => None,
631
        (path, Some(line)) => {
632
            let loaded = load(&app, &repo, p.clone()).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
633
            loaded.atoms.get(&(path.to_string(), line)).cloned()
634
        }
635
    };
636
    let tab = if anchor.is_some() { "files" } else { "conversation" };
637
    pdb::add_comment(
638
        &app.db,
639
        p.id,
640
        Some(me.account.id),
641
        &me.account.username,
642
        f.body.trim(),
643
        f.path.trim(),
644
        anchor.as_ref().map(|(h, i)| (h.as_str(), *i as i64)),
645
    )
646
    .await
647
    .map_err(|e| Error::from_db(shell.clone(), e))?;
648
    detail(&app, shell, &viewer, &owner, &name, number, tab.into(), None, None).await
649
}
650
651
#[derive(Deserialize)]
652
pub struct ReviewForm {
653
    #[serde(rename = "_csrf")]
654
    csrf_token: String,
655
    verdict: String,
656
    #[serde(default)]
657
    body: String,
658
}
659
660
pub async fn review(
661
    State(app): State<Shared>,
662
    viewer: Viewer,
663
    Path((owner, name, number)): Path<(String, String, i64)>,
664
    Form(f): Form<ReviewForm>,
665
) -> Result<Page<ProposalPage>, Error> {
666
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
667
    let me = require(&viewer, &shell)?;
668
    csrf(&viewer, &f.csrf_token, &shell)?;
669
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
670
    let p = pdb::get(&app.db, row.id, number)
671
        .await
672
        .map_err(|e| Error::from_db(shell.clone(), e))?
673
        .ok_or_else(|| Error::not_found(shell.clone()))?;
674
    if !matches!(f.verdict.as_str(), "comment" | "changes" | "approve") {
675
        return Err(Error::bad(shell, "a review is comment, changes or approve"));
676
    }
677
    if f.verdict == "approve" && Some(me.account.id) == p.author_id {
678
        return detail(
679
            &app,
680
            shell,
681
            &viewer,
682
            &owner,
683
            &name,
684
            number,
685
            "conversation".into(),
686
            None,
687
            Some("You cannot approve your own proposal.".into()),
688
        )
689
        .await;
690
    }
691
    pdb::add_review(&app.db, p.id, me.account.id, &me.account.username, &f.verdict, f.body.trim())
692
        .await
693
        .map_err(|e| Error::from_db(shell.clone(), e))?;
694
    pdb::add_event(&app.db, p.id, &me.account.username, &format!("review.{}", f.verdict), "").await.ok();
695
    let notice = if f.verdict == "approve" {
696
        Some(format!(
697
            "Approval recorded. It counts once you seal the changes: oxy seal --proposal {}/{owner}/{name}/proposal/{number}",
698
            app.config.base_url()
699
        ))
700
    } else {
701
        None
702
    };
703
    detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), notice, None).await
704
}
705
706
#[derive(Deserialize)]
707
pub struct EditForm {
708
    #[serde(rename = "_csrf")]
709
    csrf_token: String,
710
    title: String,
711
    #[serde(default)]
712
    body: String,
713
    target: String,
714
}
715
716
pub async fn edit(
717
    State(app): State<Shared>,
718
    viewer: Viewer,
719
    Path((owner, name, number)): Path<(String, String, i64)>,
720
    Form(f): Form<EditForm>,
721
) -> Result<Page<ProposalPage>, Error> {
722
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
723
    let me = require(&viewer, &shell)?;
724
    csrf(&viewer, &f.csrf_token, &shell)?;
725
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
726
    let p = pdb::get(&app.db, row.id, number)
727
        .await
728
        .map_err(|e| Error::from_db(shell.clone(), e))?
729
        .ok_or_else(|| Error::not_found(shell.clone()))?;
730
    if Some(me.account.id) != p.author_id && role < Role::Maintain {
731
        return Err(Error::not_found(shell));
732
    }
733
    let target = if crate::repos::names::valid_segment(f.target.trim()) { f.target.trim() } else { &p.target };
734
    pdb::edit(&app.db, p.id, f.title.trim(), f.body.trim(), target).await.map_err(|e| Error::from_db(shell.clone(), e))?;
735
    pdb::add_event(&app.db, p.id, &me.account.username, "edited", "").await.ok();
736
    detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), Some("Saved.".into()), None).await
737
}
738
739
#[derive(Deserialize)]
740
pub struct StateForm {
741
    #[serde(rename = "_csrf")]
742
    csrf_token: String,
743
    action: String,
744
}
745
746
pub async fn state(
747
    State(app): State<Shared>,
748
    viewer: Viewer,
749
    Path((owner, name, number)): Path<(String, String, i64)>,
750
    Form(f): Form<StateForm>,
751
) -> Result<Page<ProposalPage>, Error> {
752
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
753
    let me = require(&viewer, &shell)?;
754
    csrf(&viewer, &f.csrf_token, &shell)?;
755
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
756
    let p = pdb::get(&app.db, row.id, number)
757
        .await
758
        .map_err(|e| Error::from_db(shell.clone(), e))?
759
        .ok_or_else(|| Error::not_found(shell.clone()))?;
760
    if Some(me.account.id) != p.author_id && role < Role::Triage {
761
        return Err(Error::not_found(shell));
762
    }
763
    let new_state = match (f.action.as_str(), p.state.as_str()) {
764
        ("close", "open" | "draft") => "closed",
765
        ("reopen", "closed") => "open",
766
        ("ready", "draft") => "open",
767
        ("draft", "open") => "draft",
768
        _ => return Err(Error::bad(shell, "that transition is not possible")),
769
    };
770
    pdb::set_state(&app.db, p.id, new_state, "").await.map_err(|e| Error::from_db(shell.clone(), e))?;
771
    pdb::add_event(&app.db, p.id, &me.account.username, new_state, "").await.ok();
772
    detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), None, None).await
773
}
774
775
pub async fn resolve_comment(
776
    State(app): State<Shared>,
777
    viewer: Viewer,
778
    Path((owner, name, number, id)): Path<(String, String, i64, i64)>,
779
    Form(f): Form<super::session::CsrfForm>,
780
) -> Result<Page<ProposalPage>, Error> {
781
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
782
    require(&viewer, &shell)?;
783
    csrf(&viewer, &f.csrf, &shell)?;
784
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
785
    let p = pdb::get(&app.db, row.id, number)
786
        .await
787
        .map_err(|e| Error::from_db(shell.clone(), e))?
788
        .ok_or_else(|| Error::not_found(shell.clone()))?;
789
    let current: Option<bool> = sqlx::query_scalar("SELECT resolved FROM proposal_comments WHERE id = ? AND proposal_id = ?")
790
        .bind(id)
791
        .bind(p.id)
792
        .fetch_optional(&app.db)
793
        .await
794
        .map_err(|e| Error::from_db(shell.clone(), e))?;
795
    if let Some(r) = current {
796
        pdb::set_comment_resolved(&app.db, id, !r).await.map_err(|e| Error::from_db(shell.clone(), e))?;
797
    }
798
    detail(&app, shell, &viewer, &owner, &name, number, "files".into(), None, None).await
799
}
800
801
/// Land from the hub: only when the repository has no trust policy (the
802
/// hub never signs a reference update the policy would have to admit).
803
pub async fn land(
804
    State(app): State<Shared>,
805
    viewer: Viewer,
806
    Path((owner, name, number)): Path<(String, String, i64)>,
807
    Form(f): Form<super::session::CsrfForm>,
808
) -> Result<Page<ProposalPage>, Error> {
809
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
810
    let me = require(&viewer, &shell)?;
811
    csrf(&viewer, &f.csrf, &shell)?;
812
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
813
    let p = pdb::get(&app.db, row.id, number)
814
        .await
815
        .map_err(|e| Error::from_db(shell.clone(), e))?
816
        .ok_or_else(|| Error::not_found(shell.clone()))?;
817
    let loaded = load(&app, &repo, p.clone()).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
818
    let v = verdict(&app, &loaded, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
819
    if !p.is_open() || !v.blockers.is_empty() {
820
        return detail(
821
            &app,
822
            shell,
823
            &viewer,
824
            &owner,
825
            &name,
826
            number,
827
            "conversation".into(),
828
            None,
829
            Some(format!("Cannot land: {}", v.blockers.join("; "))),
830
        )
831
        .await;
832
    }
833
    let target = p.target.clone();
834
    let heads = loaded.heads.clone();
835
    let result = app
836
        .repos
837
        .write(&repo, move |r, _| {
838
            if r.current_policy()?.is_some() {
839
                return Err(crate::repos::RepoError::Oxy(oxy_core::repo::RepoError::Msg(
840
                    "this repository has a trust policy, so a reference update must be signed by an authorised key: land from a client with oxy proposal land".into(),
841
                )));
842
            }
843
            r.bookmark_set(&target, &heads)?;
844
            Ok(pl::state_ref(&heads).to_hex())
845
        })
846
        .await;
847
    match result {
848
        Ok(state) => {
849
            pdb::set_state(&app.db, p.id, "landed", &state).await.map_err(|e| Error::from_db(shell.clone(), e))?;
850
            pdb::add_event(&app.db, p.id, &me.account.username, "landed", "from the hub").await.ok();
851
            accounts::audit(&app.db, Some(&me.account), "proposal.landed", &format!("{owner}/{name}#{number}"), "").await.ok();
852
            db::repos::record_push(
853
                &app.db,
854
                row.id,
855
                Some((me.account.id, me.account.username.as_str())),
856
                &[db::repos::PushOutcome {
857
                    reference: p.target.clone(),
858
                    ok: true,
859
                    reason: format!("landed #{number}"),
860
                    old_state: loaded.target_state.clone(),
861
                    new_state: state,
862
                    heads: loaded.heads.len() as i64,
863
                    changes: loaded.hashes.len() as i64,
864
                }],
865
            )
866
            .await
867
            .ok();
868
            detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), Some("Landed.".into()), None).await
869
        }
870
        Err(e) => detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), None, Some(e.to_string())).await,
871
    }
872
}
873
874
// -------------------------------------------------------------------- API
875
876
pub async fn to_api(app: &Shared, owner: &str, name: &str, loaded: &Loaded, v: &Verdict, source_url: Option<String>) -> hub_api::Proposal {
877
    let p = &loaded.row;
878
    hub_api::Proposal {
879
        number: p.number,
880
        title: p.title.clone(),
881
        body: p.body.clone(),
882
        author: p.author.clone(),
883
        state: ProposalState::parse(&p.state).unwrap_or(ProposalState::Open),
884
        target: p.target.clone(),
885
        source: match p.source_kind.as_str() {
886
            "fork" => {
887
                let (o, n) = source_url
888
                    .as_deref()
889
                    .and_then(|u| u.rsplit_once('/'))
890
                    .and_then(|(b, n)| b.rsplit_once('/').map(|(_, o)| (o.to_string(), n.to_string())))
891
                    .unwrap_or_default();
892
                ProposalSource::Fork { owner: o, name: n, bookmark: p.source_bookmark.clone() }
893
            }
894
            "changes" => ProposalSource::Changes { hashes: p.source_changes() },
895
            _ => ProposalSource::Bookmark { bookmark: p.source_bookmark.clone() },
896
        },
897
        changes: loaded
898
            .changes
899
            .iter()
900
            .map(|c| hub_api::ChangeSummary {
901
                hash: c.hash.clone(),
902
                message: if c.body.is_empty() { c.title.clone() } else { format!("{}\n\n{}", c.title, c.body) },
903
                author: hub_api::Author {
904
                    name: c.author.name.clone(),
905
                    email: c.author.email.clone(),
906
                    key: c.author.key.clone(),
907
                    account: c.author.account.clone(),
908
                },
909
                when_ms: c.when_ms,
910
                deps: c.deps.clone(),
911
                head: c.head,
912
                signature: match &c.signature {
913
                    view::SigView::Unsigned => hub_api::Signature::Unsigned,
914
                    view::SigView::Valid { key, sealed, revoked, .. } => {
915
                        hub_api::Signature::Valid { key: key.clone(), sealed: *sealed, revoked: *revoked }
916
                    }
917
                    view::SigView::Invalid(why) => hub_api::Signature::Invalid { reason: why.clone() },
918
                },
919
            })
920
            .collect(),
921
        projected_heads: loaded.heads.iter().map(|h| h.to_hex()).collect(),
922
        projected_state: Some(loaded.state.clone()),
923
        conflicted: loaded.conflicted,
924
        approvals: v.approvals.clone(),
925
        landable: p.is_open() && v.blockers.is_empty(),
926
        blockers: v.blockers.clone(),
927
        created_ms: p.created_ms,
928
        updated_ms: p.updated_ms,
929
        url: format!("{}/{owner}/{name}/proposal/{}", app.config.base_url(), p.number),
930
        repository_url: format!("{}/{owner}/{name}", app.config.base_url()),
931
        source_url,
932
    }
933
}
934
935
/// The line comment form's placement key, for templates.
936
pub fn key(path: &str, line: impl filters::Num) -> (String, usize) {
937
    (path.to_string(), line.as_u64() as usize)
938
}
939
940
#[allow(dead_code)]
941
fn _unused(_: &diff::Line, _: &ConflictView, _: &Role) {}