OXYGENHUB
# 7b17d570d7ee oxygenhub / crates / hub / src / web / proposals.rs
46.9 KiB · Rust · last changed Projects: boards of issues, proposals and notes across an owner's repositories, with columns, cards moved without JavaScript, private projects, and the projects shown on issues and proposals 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
    q: String,
34
    sort: String,
35
    pager: super::Pager,
36
    labels: std::collections::HashMap<i64, Vec<db::issues::Label>>,
37
}
38
39
impl ProposalsPage {
40
    fn labels_of(&self, id: &i64) -> &[db::issues::Label] {
41
        self.labels.get(id).map(Vec::as_slice).unwrap_or(&[])
42
    }
43
}
44
45
#[derive(Deserialize)]
46
pub struct ListQuery {
47
    state: Option<String>,
48
    q: Option<String>,
49
    sort: Option<String>,
50
    page: Option<usize>,
51
}
52
53
/// The list's sort orders.
54
pub const SORTS: &[(&str, &str)] = &[("updated", "recently updated"), ("newest", "newest"), ("oldest", "oldest")];
55
56
pub async fn list(
57
    State(app): State<Shared>,
58
    viewer: Viewer,
59
    Path((owner, name)): Path<(String, String)>,
60
    Query(q): Query<ListQuery>,
61
) -> Result<Page<ProposalsPage>, Error> {
62
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposals"), &viewer);
63
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
64
    let all = pdb::list(&app.db, row.id, &[]).await.map_err(|e| Error::from_db(shell.clone(), e))?;
65
    let counts = (
66
        all.iter().filter(|p| p.is_open()).count(),
67
        all.iter().filter(|p| p.state == "landed").count(),
68
        all.iter().filter(|p| p.state == "closed").count(),
69
    );
70
    let filter = q.state.unwrap_or_else(|| "open".into());
71
    let mut labels: std::collections::HashMap<i64, Vec<db::issues::Label>> = std::collections::HashMap::new();
72
    for (id, l) in db::issues::labels_by_proposal(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))? {
73
        labels.entry(id).or_default().push(l);
74
    }
75
    // `author:bob label:ui target:main words…`
76
    let qtext = q.q.clone().unwrap_or_default();
77
    let (mut authors, mut wanted, mut targets, mut words) = (vec![], vec![], vec![], vec![]);
78
    for tok in qtext.split_whitespace() {
79
        match tok.split_once(':') {
80
            Some(("author", v)) => authors.push(v.to_lowercase()),
81
            Some(("label", v)) => wanted.push(v.to_lowercase()),
82
            Some(("target", v)) => targets.push(v.to_string()),
83
            _ => words.push(tok.to_lowercase()),
84
        }
85
    }
86
    let mut rows: Vec<pdb::ProposalRow> = all
87
        .into_iter()
88
        .filter(|p| match filter.as_str() {
89
            "open" => p.is_open(),
90
            "landed" => p.state == "landed",
91
            "closed" => p.state == "closed",
92
            _ => true,
93
        })
94
        .filter(|p| authors.is_empty() || authors.contains(&p.author.to_lowercase()))
95
        .filter(|p| targets.is_empty() || targets.contains(&p.target))
96
        .filter(|p| wanted.iter().all(|w| labels.get(&p.id).is_some_and(|ls| ls.iter().any(|l| l.name.to_lowercase() == *w))))
97
        .filter(|p| words.iter().all(|w| p.title.to_lowercase().contains(w) || p.body.to_lowercase().contains(w)))
98
        .collect();
99
    let sort = q.sort.filter(|s| SORTS.iter().any(|(k, _)| k == s)).unwrap_or_else(|| "updated".into());
100
    match sort.as_str() {
101
        "newest" => rows.sort_by_key(|p| std::cmp::Reverse(p.number)),
102
        "oldest" => rows.sort_by_key(|p| p.number),
103
        _ => rows.sort_by_key(|p| std::cmp::Reverse(p.updated_ms)),
104
    }
105
    let pager = super::Pager::new(rows.len(), q.page, &[("state", &filter), ("q", &qtext), ("sort", &sort)]);
106
    let rows = pager.slice(rows);
107
    let bookmarks = app
108
        .repos
109
        .read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::<Vec<_>>()))
110
        .await
111
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
112
    Ok(Page(ProposalsPage {
113
        shell,
114
        repo: RepoShell::new(&app, &row, "proposals").with_role(role),
115
        rows,
116
        filter,
117
        counts,
118
        bookmarks,
119
        q: qtext,
120
        sort,
121
        pager,
122
        labels,
123
    }))
124
}
125
126
// ------------------------------------------------------------------- new
127
128
#[derive(Template)]
129
#[template(path = "proposal_new.html")]
130
pub struct NewProposalPage {
131
    shell: Shell,
132
    repo: RepoShell,
133
    bookmarks: Vec<String>,
134
    source: String,
135
    target: String,
136
    error: Option<String>,
137
    /// Preview: the changes the proposal would add.
138
    preview: Vec<ChangeView>,
139
    conflicted: bool,
140
}
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.
149
fn parse_source(app: &Shared, repo: &RepoName, source: &str) -> Option<(Option<RepoName>, String)> {
150
    if let Some((slug, bookmark)) = source.split_once(':') {
151
        let fork = RepoName::parse(slug)?;
152
        if !app.repos.exists(&fork) || fork == *repo {
153
            return None;
154
        }
155
        return Some((Some(fork), bookmark.to_string()));
156
    }
157
    crate::repos::names::valid_segment(source).then(|| (None, source.to_string()))
158
}
159
160
async fn preview_for(
161
    app: &Shared,
162
    repo: &RepoName,
163
    target: &str,
164
    source: &str,
165
) -> Result<(Vec<ChangeView>, bool), crate::repos::RepoError> {
166
    let Some((fork, bookmark)) = parse_source(app, repo, source) else { return Ok((vec![], false)) };
167
    let target = target.to_string();
168
    let fork_dir = fork.as_ref().map(|f| app.repos.dir(f));
169
    app.repos
170
        .read(repo, move |r, cache| {
171
            let projection = match &fork_dir {
172
                Some(dir) => {
173
                    let fork_repo = oxy_core::repo::Repo::open(dir)?;
174
                    let heads = fork_repo.bookmarks()?.into_iter().find(|b| b.name == bookmark).map(|b| b.heads).unwrap_or_default();
175
                    pl::project(r, cache, &target, Source::Fork { fork: &fork_repo, heads })?
176
                }
177
                None => {
178
                    let heads = r.bookmarks()?.into_iter().find(|b| b.name == bookmark).map(|b| b.heads).unwrap_or_default();
179
                    pl::project(r, cache, &target, Source::Bookmark(heads))?
180
                }
181
            };
182
            Ok((projection.changes, projection.conflicted))
183
        })
184
        .await
185
}
186
187
pub async fn new_form(
188
    State(app): State<Shared>,
189
    viewer: Viewer,
190
    Path((owner, name)): Path<(String, String)>,
191
    Query(q): Query<NewQuery>,
192
) -> Result<Page<NewProposalPage>, Error> {
193
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposals/new"), &viewer);
194
    require(&viewer, &shell)?;
195
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
196
    let bookmarks = app
197
        .repos
198
        .read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::<Vec<_>>()))
199
        .await
200
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
201
    let target = q.target.unwrap_or_else(|| row.default_bookmark.clone());
202
    let source = q.source.unwrap_or_default();
203
    let (preview, conflicted) =
204
        if source.is_empty() { (vec![], false) } else { preview_for(&app, &repo, &target, &source).await.unwrap_or((vec![], false)) };
205
    Ok(Page(NewProposalPage {
206
        shell,
207
        repo: RepoShell::new(&app, &row, "proposals").with_role(role),
208
        bookmarks,
209
        source,
210
        target,
211
        error: None,
212
        preview,
213
        conflicted,
214
    }))
215
}
216
217
#[derive(Deserialize)]
218
pub struct NewForm {
219
    #[serde(rename = "_csrf")]
220
    csrf_token: String,
221
    title: String,
222
    #[serde(default)]
223
    body: String,
224
    target: String,
225
    source: String,
226
    #[serde(default)]
227
    draft: Option<String>,
228
}
229
230
/// Open a proposal from a form or the API: validate, create, index, notify.
231
#[allow(clippy::too_many_arguments)]
232
pub async fn open_proposal(
233
    app: &Shared,
234
    repo: &RepoName,
235
    row: &db::repos::RepoRow,
236
    me: &accounts::Account,
237
    title: &str,
238
    body: &str,
239
    target: &str,
240
    source: &str,
241
    draft: bool,
242
) -> Result<pdb::ProposalRow, String> {
243
    if title.trim().is_empty() {
244
        return Err("Give the proposal a title.".into());
245
    }
246
    let bookmarks =
247
        app.repos.read(repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::<Vec<_>>())).await.map_err(|e| e.to_string())?;
248
    if !bookmarks.iter().any(|b| b == target) {
249
        return Err("Pick a target bookmark of this repository.".into());
250
    }
251
    let Some((fork, bookmark)) = parse_source(app, repo, source.trim()) else {
252
        return Err("The source is a bookmark of this repository, or owner/name:bookmark of a fork.".into());
253
    };
254
    if fork.is_none() && bookmark == target {
255
        return Err("The source and the target are the same bookmark.".into());
256
    }
257
    let (preview, _) = preview_for(app, repo, target, source.trim()).await.map_err(|e| e.to_string())?;
258
    if preview.is_empty() {
259
        return Err("The source adds nothing to the target.".into());
260
    }
261
    let source_repo_id = match &fork {
262
        Some(fk) => db::repos::get(&app.db, &fk.owner, &fk.name).await.map_err(|e| e.to_string())?.map(|r| r.id),
263
        None => None,
264
    };
265
    let created = pdb::create(
266
        &app.db,
267
        pdb::NewProposal {
268
            repo_id: row.id,
269
            title: title.trim(),
270
            body: body.trim(),
271
            author_id: Some(me.id),
272
            author: &me.username,
273
            draft,
274
            target,
275
            source_kind: if fork.is_some() { "fork" } else { "bookmark" },
276
            source_repo_id,
277
            source_bookmark: &bookmark,
278
            source_changes: "",
279
        },
280
    )
281
    .await
282
    .map_err(|e| e.to_string())?;
283
    pdb::add_event(&app.db, created.id, &me.username, "opened", "").await.ok();
284
    accounts::audit(&app.db, Some(me), "proposal.opened", &format!("{}#{}", row.slug(), created.number), title).await.ok();
285
    db::notify::index(&app.db, "proposal", row.id, created.id, created.number, &created.title, &created.body).await.ok();
286
    let url = format!("/{}/proposal/{}", row.slug(), created.number);
287
    crate::notify::deliver(
288
        app,
289
        crate::notify::Event {
290
            kind: "proposal.opened",
291
            title: format!("{}#{}: {}", row.slug(), created.number, created.title),
292
            url,
293
            actor: me,
294
            repo_id: row.id,
295
            thread: Some(("proposal", created.id)),
296
            mention_text: body,
297
            direct: vec![],
298
        },
299
    )
300
    .await;
301
    crate::hooks::emit(
302
        app,
303
        row,
304
        "proposal.opened",
305
        &me.username,
306
        serde_json::json!({ "number": created.number, "title": created.title, "target": target, "source": source }),
307
    )
308
    .await;
309
    Ok(created)
310
}
311
312
pub async fn create(
313
    State(app): State<Shared>,
314
    viewer: Viewer,
315
    Path((owner, name)): Path<(String, String)>,
316
    Form(f): Form<NewForm>,
317
) -> Result<Response, Error> {
318
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposals/new"), &viewer);
319
    let me = require(&viewer, &shell)?;
320
    csrf(&viewer, &f.csrf_token, &shell)?;
321
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
322
    match open_proposal(&app, &repo, &row, &me.account, &f.title, &f.body, &f.target, &f.source, f.draft.is_some()).await {
323
        Ok(created) => Ok(Redirect::to(&format!("/{owner}/{name}/proposal/{}", created.number)).into_response()),
324
        Err(msg) => {
325
            let bookmarks =
326
                app.repos.read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::<Vec<_>>())).await.unwrap_or_default();
327
            Ok(Page(NewProposalPage {
328
                shell,
329
                repo: RepoShell::new(&app, &row, "proposals").with_role(role),
330
                bookmarks,
331
                source: f.source.clone(),
332
                target: f.target.clone(),
333
                error: Some(msg),
334
                preview: vec![],
335
                conflicted: false,
336
            })
337
            .into_response())
338
        }
339
    }
340
}
341
342
// ---------------------------------------------------------------- detail
343
344
/// A comment placed on a diff row, or general.
345
pub struct CommentView {
346
    pub row: pdb::CommentRow,
347
    pub body_html: String,
348
    pub line: Option<usize>,
349
}
350
351
pub struct ReviewView {
352
    pub row: pdb::ReviewRow,
353
    pub body_html: String,
354
    pub sealed: bool,
355
}
356
357
/// Everything the detail page and the API need, computed once under the lock.
358
pub struct Loaded {
359
    pub row: pdb::ProposalRow,
360
    pub changes: Vec<ChangeView>,
361
    pub hashes: Vec<ChangeHash>,
362
    pub heads: Vec<ChangeHash>,
363
    pub target_heads: Vec<ChangeHash>,
364
    pub state: String,
365
    pub target_state: String,
366
    pub conflicted: bool,
367
    pub conflicts: Vec<ConflictView>,
368
    pub files: Vec<FileRows>,
369
    /// The per-file hunks and bases behind `files`, to render again with other options.
370
    pub diffs: Vec<oxy_core::repo::FileDiff>,
371
    pub bases: std::collections::BTreeMap<String, Vec<u8>>,
372
    /// Atom per (path, new line number) of the projected state.
373
    pub atoms: HashMap<(String, usize), (String, u32)>,
374
    pub sealing_keys: Vec<String>,
375
    pub touched: Vec<String>,
376
    pub owners: Vec<String>,
377
    pub landed_already: bool,
378
}
379
380
/// Compute the projection and everything derived from it.
381
pub async fn load(app: &Shared, repo: &RepoName, row: pdb::ProposalRow) -> Result<Loaded, crate::repos::RepoError> {
382
    let fork_dir = match (row.source_kind.as_str(), row.source_repo_id) {
383
        ("fork", Some(id)) => {
384
            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))
385
        }
386
        _ => None,
387
    };
388
    let r2 = row.clone();
389
    let loaded = app
390
        .repos
391
        .read(repo, move |r, cache| {
392
            let source = match (r2.source_kind.as_str(), &fork_dir) {
393
                ("changes", _) => Source::Changes(r2.source_changes().iter().filter_map(|h| ChangeHash::from_hex(h)).collect()),
394
                ("fork", Some(dir)) => {
395
                    let fork_repo = oxy_core::repo::Repo::open(dir)?;
396
                    let heads =
397
                        fork_repo.bookmarks()?.into_iter().find(|b| b.name == r2.source_bookmark).map(|b| b.heads).unwrap_or_default();
398
                    // Bring the fork's changes over now, so the projection below can read them.
399
                    let fork_state = fork_repo.load_state(&heads)?;
400
                    let missing: Vec<ChangeHash> = fork_state.changes().map(|(h, _)| *h).filter(|h| !r.store().has(h)).collect();
401
                    if !missing.is_empty() {
402
                        r.import_bundle(&fork_repo.bundle_for(&missing)?, r.current_policy()?.is_some())?;
403
                    }
404
                    Source::Changes(heads)
405
                }
406
                _ => {
407
                    let heads = r.bookmarks()?.into_iter().find(|b| b.name == r2.source_bookmark).map(|b| b.heads).unwrap_or_default();
408
                    Source::Bookmark(heads)
409
                }
410
            };
411
            let p: Projection = pl::project(r, cache, &r2.target, source)?;
412
            let target_loaded = cache.load(r, &p.target_heads)?;
413
            let files = render_files(&p.files, &p.bases);
414
            let mut atoms = HashMap::new();
415
            for f in &files {
416
                for l in &f.lines {
417
                    if let Some(n) = l.new_no {
418
                        if let Some((h, i)) = pl::atom_at(&p.loaded, &f.path, n as usize) {
419
                            atoms.insert((f.path.clone(), n as usize), (h.to_hex(), i));
420
                        }
421
                    }
422
                }
423
            }
424
            let touched: Vec<String> = p.files.iter().map(|f| f.path.clone()).collect();
425
            let owners = pl::owners_for(&target_loaded, &touched);
426
            let sealing = pl::sealing_keys(r, &p.hashes)?.into_iter().map(|k| k.to_hex()).collect();
427
            let landed_already = !p.hashes.is_empty() && p.hashes.iter().all(|h| target_loaded.state.contains(h));
428
            Ok(Loaded {
429
                row: r2,
430
                changes: p.changes,
431
                hashes: p.hashes,
432
                heads: p.heads,
433
                target_heads: p.target_heads.clone(),
434
                state: p.state.to_hex(),
435
                target_state: p.target_state.to_hex(),
436
                conflicted: p.conflicted,
437
                conflicts: p.conflicts,
438
                files,
439
                diffs: p.files.clone(),
440
                bases: p.bases.clone(),
441
                atoms,
442
                sealing_keys: sealing,
443
                touched,
444
                owners,
445
                landed_already,
446
            })
447
        })
448
        .await?;
449
    // Remember the projection for the list, the API and other proposals' stacking checks.
450
    let heads_csv = loaded.heads.iter().map(|h| h.to_hex()).collect::<Vec<_>>().join(",");
451
    let changes_csv = loaded.hashes.iter().map(|h| h.to_hex()).collect::<Vec<_>>().join(",");
452
    pdb::set_projection(&app.db, loaded.row.id, &loaded.state, &heads_csv, &loaded.target_state, &changes_csv, loaded.conflicted)
453
        .await
454
        .ok();
455
    Ok(loaded)
456
}
457
458
/// Reviews, approvals and blockers.
459
pub struct Verdict {
460
    pub reviews: Vec<ReviewView>,
461
    pub approvals: Vec<Approval>,
462
    pub blockers: Vec<String>,
463
    pub depends_on: Vec<i64>,
464
    pub requirements: pdb::Requirements,
465
}
466
467
pub async fn verdict(app: &Shared, loaded: &Loaded, repo_id: i64) -> Result<Verdict, sqlx::Error> {
468
    let rows = pdb::reviews(&app.db, loaded.row.id).await?;
469
    let links = accounts::identity_accounts(&app.db).await?;
470
    let sealed_accounts: Vec<String> = links.iter().filter(|(k, _)| loaded.sealing_keys.contains(k)).map(|(_, u)| u.clone()).collect();
471
    // Latest verdict per reviewer.
472
    let mut latest: BTreeMap<String, &pdb::ReviewRow> = BTreeMap::new();
473
    for r in &rows {
474
        latest.insert(r.author.clone(), r);
475
    }
476
    let mut approvals = Vec::new();
477
    for (who, r) in &latest {
478
        if r.verdict == "approve" {
479
            let sealed = sealed_accounts.contains(who);
480
            let key = links.iter().find(|(k, u)| u == who && loaded.sealing_keys.contains(k)).map(|(k, _)| k.clone());
481
            approvals.push(Approval { account: who.clone(), sealed, key });
482
        }
483
    }
484
    let reviews = rows
485
        .iter()
486
        .map(|r| ReviewView {
487
            row: r.clone(),
488
            body_html: render::markdown::render(&r.body, "/", "/"),
489
            sealed: r.verdict == "approve" && sealed_accounts.contains(&r.author),
490
        })
491
        .collect();
492
    let requirements = pdb::requirements(&app.db, repo_id).await?;
493
    let mut blockers = Vec::new();
494
    if loaded.row.state == "draft" {
495
        blockers.push("the proposal is a draft".into());
496
    }
497
    if loaded.hashes.is_empty() {
498
        blockers.push("the proposal adds no changes".into());
499
    }
500
    let sealed_count = approvals.iter().filter(|a| a.sealed).count() as i64;
501
    if sealed_count < requirements.required_approvals {
502
        blockers.push(format!("{sealed_count} of {} required sealed approvals", requirements.required_approvals));
503
    }
504
    if requirements.require_owners {
505
        let missing: Vec<&String> = loaded.owners.iter().filter(|o| !approvals.iter().any(|a| a.sealed && &a.account == *o)).collect();
506
        if !missing.is_empty() {
507
            blockers.push(format!("code owners still to approve: {}", missing.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")));
508
        }
509
    }
510
    if loaded.conflicted && !requirements.allow_conflicts {
511
        blockers.push("landing would carry conflicts, which this repository refuses".into());
512
    }
513
    let required = db::hooks::required_checks(&app.db, repo_id).await?;
514
    if !required.is_empty() {
515
        let checks = db::hooks::checks_for_state(&app.db, repo_id, &loaded.state).await?;
516
        let missing: Vec<String> =
517
            required.iter().filter(|n| !checks.iter().any(|c| &c.name == *n && c.status == "passed")).cloned().collect();
518
        if !missing.is_empty() {
519
            blockers.push(format!("required checks not passed on the projected state: {}", missing.join(", ")));
520
        }
521
    }
522
    let requesting: Vec<&String> = latest.iter().filter(|(_, r)| r.verdict == "changes").map(|(who, _)| who).collect();
523
    if !requesting.is_empty() {
524
        blockers.push(format!("changes requested by {}", requesting.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")));
525
    }
526
    // Stacking: another open, earlier proposal whose changes this one includes.
527
    let mut depends_on = Vec::new();
528
    for other in pdb::list(&app.db, repo_id, &["open", "draft"]).await? {
529
        if other.id == loaded.row.id || other.number > loaded.row.number {
530
            continue;
531
        }
532
        let theirs = other.projected_changes();
533
        if !theirs.is_empty() && theirs.iter().all(|h| loaded.hashes.iter().any(|m| m.to_hex() == *h)) {
534
            depends_on.push(other.number);
535
        }
536
    }
537
    if !depends_on.is_empty() {
538
        blockers.push(format!("depends on {}", depends_on.iter().map(|n| format!("#{n}")).collect::<Vec<_>>().join(", ")));
539
    }
540
    Ok(Verdict { reviews, approvals, blockers, depends_on, requirements })
541
}
542
543
#[derive(Template)]
544
#[template(path = "proposal.html")]
545
pub struct ProposalPage {
546
    shell: Shell,
547
    repo: RepoShell,
548
    p: pdb::ProposalRow,
549
    body_html: String,
550
    tab: String,
551
    changes: Vec<ChangeView>,
552
    files: Vec<FileRows>,
553
    diffs: Vec<oxy_core::repo::FileDiff>,
554
    bases: std::collections::BTreeMap<String, Vec<u8>>,
555
    /// Whitespace-only changes are hidden (`?w=1`).
556
    ignore_ws: bool,
557
    conflicts: Vec<ConflictView>,
558
    conflicted: bool,
559
    comments: Vec<CommentView>,
560
    /// Anchored comments by (path, current line).
561
    placed: HashMap<(String, usize), Vec<usize>>,
562
    outdated: Vec<usize>,
563
    reviews: Vec<ReviewView>,
564
    approvals: Vec<Approval>,
565
    blockers: Vec<String>,
566
    depends_on: Vec<i64>,
567
    events: Vec<pdb::EventRow>,
568
    owners: Vec<String>,
569
    requirements: pdb::Requirements,
570
    landed_already: bool,
571
    can_edit: bool,
572
    can_land: bool,
573
    hub_can_land: bool,
574
    notice: Option<String>,
575
    error: Option<String>,
576
    heads: Vec<String>,
577
    state_hash: String,
578
    source_label: String,
579
    api_url: String,
580
    labels: Vec<db::issues::Label>,
581
    all_labels: Vec<db::issues::Label>,
582
    checks: Vec<db::hooks::Check>,
583
    required_checks: Vec<String>,
584
    /// Projects this proposal is on that the viewer may see, with the column.
585
    projects: Vec<(db::projects::Project, String)>,
586
}
587
588
#[derive(Deserialize)]
589
pub struct DetailQuery {
590
    tab: Option<String>,
591
    #[serde(default)]
592
    w: Option<String>,
593
}
594
595
/// The page assembler every action ends in; its arguments are the route
596
/// plus what to say, and a struct would only rename them.
597
#[allow(clippy::too_many_arguments)]
598
async fn detail(
599
    app: &Shared,
600
    shell: Shell,
601
    viewer: &Viewer,
602
    owner: &str,
603
    name: &str,
604
    number: i64,
605
    tab: String,
606
    notice: Option<String>,
607
    error: Option<String>,
608
) -> Result<Page<ProposalPage>, Error> {
609
    let (repo, row, role) = lookup(app, &shell, viewer, owner, name).await?;
610
    let p = pdb::get(&app.db, row.id, number)
611
        .await
612
        .map_err(|e| Error::from_db(shell.clone(), e))?
613
        .ok_or_else(|| Error::not_found(shell.clone()))?;
614
    let loaded = load(app, &repo, p).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
615
    // A proposal whose changes all reached the target landed by another route.
616
    if loaded.landed_already && loaded.row.is_open() {
617
        pdb::set_state(&app.db, loaded.row.id, "landed", &loaded.target_state).await.ok();
618
        pdb::add_event(&app.db, loaded.row.id, "", "landed", "every change reached the target").await.ok();
619
    }
620
    let p = pdb::get(&app.db, row.id, number).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(loaded.row.clone());
621
    let v = verdict(app, &loaded, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
622
    let comment_rows = pdb::comments(&app.db, loaded.row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
623
    let events = pdb::events(&app.db, loaded.row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
624
    // Place anchored comments at their atom's current line.
625
    let mut by_atom: HashMap<(String, String, u32), usize> = HashMap::new();
626
    for ((path, line), (h, i)) in &loaded.atoms {
627
        by_atom.insert((path.clone(), h.clone(), *i), *line);
628
    }
629
    let mut comments = Vec::new();
630
    let mut placed: HashMap<(String, usize), Vec<usize>> = HashMap::new();
631
    let mut outdated = Vec::new();
632
    for (idx, c) in comment_rows.into_iter().enumerate() {
633
        let line = if c.anchor_change.is_empty() {
634
            None
635
        } else {
636
            by_atom.get(&(c.path.clone(), c.anchor_change.clone(), c.anchor_index as u32)).copied()
637
        };
638
        if !c.anchor_change.is_empty() {
639
            match line {
640
                Some(l) => placed.entry((c.path.clone(), l)).or_default().push(idx),
641
                None => outdated.push(idx),
642
            }
643
        }
644
        comments.push(CommentView { body_html: render::markdown::render(&c.body, "/", "/"), row: c, line });
645
    }
646
    let mut changes = loaded.changes.clone();
647
    super::attribute(app, &mut changes.iter_mut().collect::<Vec<_>>()).await;
648
    let me = viewer.account();
649
    let can_edit = me.is_some_and(|a| Some(a.id) == p.author_id || role >= Role::Maintain);
650
    let can_land = role >= Role::Write && p.is_open() && v.blockers.is_empty();
651
    let delegated = match viewer.account() {
652
        Some(a) => super::edit::delegated_identity(app, a.id).await.is_some(),
653
        None => false,
654
    };
655
    let hub_can_land = delegated || app.repos.read(&repo, |r, _| Ok(r.current_policy()?.is_none())).await.unwrap_or(false);
656
    let source_label = match p.source_kind.as_str() {
657
        "fork" => format!(
658
            "fork {}",
659
            db::repos::get_by_id(&app.db, p.source_repo_id.unwrap_or(0))
660
                .await
661
                .ok()
662
                .flatten()
663
                .map(|r| format!("{}:{}", r.slug(), p.source_bookmark))
664
                .unwrap_or_default()
665
        ),
666
        "changes" => format!("{} changes", p.source_changes().len()),
667
        _ => p.source_bookmark.clone(),
668
    };
669
    let projects = super::projects::placements_for(app, viewer, &row, p.number).await;
670
    Ok(Page(ProposalPage {
671
        projects,
672
        shell,
673
        repo: RepoShell::new(app, &row, "proposals").with_role(role),
674
        body_html: super::issues::markdown(app, row.id, &format!("/{owner}/{name}"), &p.body).await,
675
        p,
676
        tab,
677
        changes,
678
        files: loaded.files,
679
        diffs: loaded.diffs,
680
        bases: loaded.bases,
681
        ignore_ws: false,
682
        conflicts: loaded.conflicts,
683
        conflicted: loaded.conflicted,
684
        comments,
685
        placed,
686
        outdated,
687
        reviews: v.reviews,
688
        approvals: v.approvals,
689
        blockers: v.blockers,
690
        depends_on: v.depends_on,
691
        events,
692
        owners: loaded.owners,
693
        requirements: v.requirements,
694
        landed_already: loaded.landed_already,
695
        can_edit,
696
        can_land,
697
        hub_can_land,
698
        notice,
699
        error,
700
        heads: loaded.heads.iter().map(|h| h.to_hex()).collect(),
701
        state_hash: loaded.state.clone(),
702
        source_label,
703
        api_url: format!("{}/api/v1/repos/{owner}/{name}/proposals/{number}", app.config.base_url()),
704
        labels: db::issues::proposal_labels(&app.db, loaded.row.id).await.unwrap_or_default(),
705
        all_labels: db::issues::labels(&app.db, row.id).await.unwrap_or_default(),
706
        checks: db::hooks::checks_for_state(&app.db, row.id, &loaded.state).await.unwrap_or_default(),
707
        required_checks: db::hooks::required_checks(&app.db, row.id).await.unwrap_or_default(),
708
    }))
709
}
710
711
pub async fn show(
712
    State(app): State<Shared>,
713
    viewer: Viewer,
714
    Path((owner, name, number)): Path<(String, String, i64)>,
715
    Query(q): Query<DetailQuery>,
716
) -> Result<Page<ProposalPage>, Error> {
717
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
718
    let mut page = detail(&app, shell, &viewer, &owner, &name, number, q.tab.unwrap_or_else(|| "conversation".into()), None, None).await?;
719
    if q.w.as_deref() == Some("1") {
720
        // Hide whitespace-only changes: the same hunks, rendered again.
721
        page.0.files =
722
            super::history::render_files_with(&page.0.diffs, &page.0.bases, crate::render::diff::Options { ignore_whitespace: true });
723
        page.0.ignore_ws = true;
724
    }
725
    Ok(page)
726
}
727
728
// ---------------------------------------------------------------- actions
729
730
#[derive(Deserialize)]
731
pub struct CommentForm {
732
    #[serde(rename = "_csrf")]
733
    csrf_token: String,
734
    body: String,
735
    #[serde(default)]
736
    path: String,
737
    #[serde(default)]
738
    line: Option<usize>,
739
}
740
741
pub async fn comment(
742
    State(app): State<Shared>,
743
    viewer: Viewer,
744
    Path((owner, name, number)): Path<(String, String, i64)>,
745
    Form(f): Form<CommentForm>,
746
) -> Result<Page<ProposalPage>, Error> {
747
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
748
    let me = require(&viewer, &shell)?;
749
    csrf(&viewer, &f.csrf_token, &shell)?;
750
    let (repo, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
751
    let p = pdb::get(&app.db, row.id, number)
752
        .await
753
        .map_err(|e| Error::from_db(shell.clone(), e))?
754
        .ok_or_else(|| Error::not_found(shell.clone()))?;
755
    if f.body.trim().is_empty() {
756
        return detail(
757
            &app,
758
            shell,
759
            &viewer,
760
            &owner,
761
            &name,
762
            number,
763
            "conversation".into(),
764
            None,
765
            Some("An empty comment says nothing.".into()),
766
        )
767
        .await;
768
    }
769
    let anchor = match (f.path.trim(), f.line) {
770
        ("", _) | (_, None) => None,
771
        (path, Some(line)) => {
772
            let loaded = load(&app, &repo, p.clone()).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
773
            loaded.atoms.get(&(path.to_string(), line)).cloned()
774
        }
775
    };
776
    let tab = if anchor.is_some() { "files" } else { "conversation" };
777
    pdb::add_comment(
778
        &app.db,
779
        p.id,
780
        Some(me.account.id),
781
        &me.account.username,
782
        f.body.trim(),
783
        f.path.trim(),
784
        anchor.as_ref().map(|(h, i)| (h.as_str(), *i as i64)),
785
    )
786
    .await
787
    .map_err(|e| Error::from_db(shell.clone(), e))?;
788
    detail(&app, shell, &viewer, &owner, &name, number, tab.into(), None, None).await
789
}
790
791
#[derive(Deserialize)]
792
pub struct ReviewForm {
793
    #[serde(rename = "_csrf")]
794
    csrf_token: String,
795
    verdict: String,
796
    #[serde(default)]
797
    body: String,
798
}
799
800
pub async fn review(
801
    State(app): State<Shared>,
802
    viewer: Viewer,
803
    Path((owner, name, number)): Path<(String, String, i64)>,
804
    Form(f): Form<ReviewForm>,
805
) -> Result<Page<ProposalPage>, Error> {
806
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
807
    let me = require(&viewer, &shell)?;
808
    csrf(&viewer, &f.csrf_token, &shell)?;
809
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
810
    let p = pdb::get(&app.db, row.id, number)
811
        .await
812
        .map_err(|e| Error::from_db(shell.clone(), e))?
813
        .ok_or_else(|| Error::not_found(shell.clone()))?;
814
    if !matches!(f.verdict.as_str(), "comment" | "changes" | "approve") {
815
        return Err(Error::bad(shell, "a review is comment, changes or approve"));
816
    }
817
    if f.verdict == "approve" && Some(me.account.id) == p.author_id {
818
        return detail(
819
            &app,
820
            shell,
821
            &viewer,
822
            &owner,
823
            &name,
824
            number,
825
            "conversation".into(),
826
            None,
827
            Some("You cannot approve your own proposal.".into()),
828
        )
829
        .await;
830
    }
831
    pdb::add_review(&app.db, p.id, me.account.id, &me.account.username, &f.verdict, f.body.trim())
832
        .await
833
        .map_err(|e| Error::from_db(shell.clone(), e))?;
834
    pdb::add_event(&app.db, p.id, &me.account.username, &format!("review.{}", f.verdict), "").await.ok();
835
    crate::hooks::emit(&app, &row, "review", &me.account.username, serde_json::json!({ "number": number, "verdict": f.verdict })).await;
836
    let notice = if f.verdict == "approve" {
837
        Some(format!(
838
            "Approval recorded. It counts once you seal the changes: oxy seal --proposal {}/{owner}/{name}/proposal/{number}",
839
            app.config.base_url()
840
        ))
841
    } else {
842
        None
843
    };
844
    detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), notice, None).await
845
}
846
847
#[derive(Deserialize)]
848
pub struct EditForm {
849
    #[serde(rename = "_csrf")]
850
    csrf_token: String,
851
    title: String,
852
    #[serde(default)]
853
    body: String,
854
    target: String,
855
}
856
857
pub async fn edit(
858
    State(app): State<Shared>,
859
    viewer: Viewer,
860
    Path((owner, name, number)): Path<(String, String, i64)>,
861
    Form(f): Form<EditForm>,
862
) -> Result<Page<ProposalPage>, Error> {
863
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
864
    let me = require(&viewer, &shell)?;
865
    csrf(&viewer, &f.csrf_token, &shell)?;
866
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
867
    let p = pdb::get(&app.db, row.id, number)
868
        .await
869
        .map_err(|e| Error::from_db(shell.clone(), e))?
870
        .ok_or_else(|| Error::not_found(shell.clone()))?;
871
    if Some(me.account.id) != p.author_id && role < Role::Maintain {
872
        return Err(Error::not_found(shell));
873
    }
874
    let target = if crate::repos::names::valid_segment(f.target.trim()) { f.target.trim() } else { &p.target };
875
    pdb::edit(&app.db, p.id, f.title.trim(), f.body.trim(), target).await.map_err(|e| Error::from_db(shell.clone(), e))?;
876
    pdb::add_event(&app.db, p.id, &me.account.username, "edited", "").await.ok();
877
    detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), Some("Saved.".into()), None).await
878
}
879
880
#[derive(Deserialize)]
881
pub struct StateForm {
882
    #[serde(rename = "_csrf")]
883
    csrf_token: String,
884
    action: String,
885
}
886
887
pub async fn state(
888
    State(app): State<Shared>,
889
    viewer: Viewer,
890
    Path((owner, name, number)): Path<(String, String, i64)>,
891
    Form(f): Form<StateForm>,
892
) -> Result<Page<ProposalPage>, Error> {
893
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
894
    let me = require(&viewer, &shell)?;
895
    csrf(&viewer, &f.csrf_token, &shell)?;
896
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
897
    let p = pdb::get(&app.db, row.id, number)
898
        .await
899
        .map_err(|e| Error::from_db(shell.clone(), e))?
900
        .ok_or_else(|| Error::not_found(shell.clone()))?;
901
    if Some(me.account.id) != p.author_id && role < Role::Triage {
902
        return Err(Error::not_found(shell));
903
    }
904
    let new_state = match (f.action.as_str(), p.state.as_str()) {
905
        ("close", "open" | "draft") => "closed",
906
        ("reopen", "closed") => "open",
907
        ("ready", "draft") => "open",
908
        ("draft", "open") => "draft",
909
        _ => return Err(Error::bad(shell, "that transition is not possible")),
910
    };
911
    pdb::set_state(&app.db, p.id, new_state, "").await.map_err(|e| Error::from_db(shell.clone(), e))?;
912
    pdb::add_event(&app.db, p.id, &me.account.username, new_state, "").await.ok();
913
    crate::hooks::emit(&app, &row, &format!("proposal.{new_state}"), &me.account.username, serde_json::json!({ "number": number })).await;
914
    detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), None, None).await
915
}
916
917
pub async fn resolve_comment(
918
    State(app): State<Shared>,
919
    viewer: Viewer,
920
    Path((owner, name, number, id)): Path<(String, String, i64, i64)>,
921
    Form(f): Form<super::session::CsrfForm>,
922
) -> Result<Page<ProposalPage>, Error> {
923
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
924
    require(&viewer, &shell)?;
925
    csrf(&viewer, &f.csrf, &shell)?;
926
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
927
    let p = pdb::get(&app.db, row.id, number)
928
        .await
929
        .map_err(|e| Error::from_db(shell.clone(), e))?
930
        .ok_or_else(|| Error::not_found(shell.clone()))?;
931
    let current: Option<bool> = sqlx::query_scalar("SELECT resolved FROM proposal_comments WHERE id = ? AND proposal_id = ?")
932
        .bind(id)
933
        .bind(p.id)
934
        .fetch_optional(&app.db)
935
        .await
936
        .map_err(|e| Error::from_db(shell.clone(), e))?;
937
    if let Some(r) = current {
938
        pdb::set_comment_resolved(&app.db, id, !r).await.map_err(|e| Error::from_db(shell.clone(), e))?;
939
    }
940
    detail(&app, shell, &viewer, &owner, &name, number, "files".into(), None, None).await
941
}
942
943
/// Land from the hub: only when the repository has no trust policy (the
944
/// hub never signs a reference update the policy would have to admit).
945
pub async fn land(
946
    State(app): State<Shared>,
947
    viewer: Viewer,
948
    Path((owner, name, number)): Path<(String, String, i64)>,
949
    Form(f): Form<super::session::CsrfForm>,
950
) -> Result<Page<ProposalPage>, Error> {
951
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
952
    let me = require(&viewer, &shell)?;
953
    csrf(&viewer, &f.csrf, &shell)?;
954
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
955
    let p = pdb::get(&app.db, row.id, number)
956
        .await
957
        .map_err(|e| Error::from_db(shell.clone(), e))?
958
        .ok_or_else(|| Error::not_found(shell.clone()))?;
959
    let loaded = load(&app, &repo, p.clone()).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
960
    let v = verdict(&app, &loaded, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
961
    if !p.is_open() || !v.blockers.is_empty() {
962
        return detail(
963
            &app,
964
            shell,
965
            &viewer,
966
            &owner,
967
            &name,
968
            number,
969
            "conversation".into(),
970
            None,
971
            Some(format!("Cannot land: {}", v.blockers.join("; "))),
972
        )
973
        .await;
974
    }
975
    let target = p.target.clone();
976
    let heads = loaded.heads.clone();
977
    // With a delegation the landing is attested by the hub key on the
978
    // user's behalf, which a policy accepts when it lets the user update
979
    // the target (spec §14.5); without one, only a policy-free repository
980
    // can be landed here.
981
    let result = match super::edit::signer_for(&app, me.account.id).await {
982
        Ok(signer) => crate::repos::edits::move_bookmark(&app.repos, &repo, target.clone(), heads.clone(), signer).await.map(|_| pl::state_ref(&heads).to_hex()),
983
        Err(why) => {
984
            app.repos
985
                .write(&repo, move |r, _| {
986
                    if r.current_policy()?.is_some() {
987
                        return Err(crate::repos::RepoError::Oxy(oxy_core::repo::RepoError::Msg(format!(
988
                            "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}"
989
                        ))));
990
                    }
991
                    r.bookmark_set(&target, &heads)?;
992
                    Ok(pl::state_ref(&heads).to_hex())
993
                })
994
                .await
995
        }
996
    };
997
    match result {
998
        Ok(state) => {
999
            pdb::set_state(&app.db, p.id, "landed", &state).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1000
            pdb::add_event(&app.db, p.id, &me.account.username, "landed", "from the hub").await.ok();
1001
            crate::hooks::emit(
1002
                &app,
1003
                &row,
1004
                "proposal.landed",
1005
                &me.account.username,
1006
                serde_json::json!({ "number": number, "target": p.target, "state": state }),
1007
            )
1008
            .await;
1009
            crate::notify::deliver(
1010
                &app,
1011
                crate::notify::Event {
1012
                    kind: "proposal.landed",
1013
                    title: format!("{owner}/{name}#{number}: {} landed", p.title),
1014
                    url: format!("/{owner}/{name}/proposal/{number}"),
1015
                    actor: &me.account,
1016
                    repo_id: row.id,
1017
                    thread: Some(("proposal", p.id)),
1018
                    mention_text: "",
1019
                    direct: p.author_id.into_iter().collect(),
1020
                },
1021
            )
1022
            .await;
1023
            if p.target == row.default_bookmark {
1024
                let messages: Vec<(String, String)> =
1025
                    loaded.changes.iter().map(|c| (c.hash.clone(), format!("{}\n{}", c.title, c.body))).collect();
1026
                super::issues::close_from_messages(&app, &row, &messages).await;
1027
            }
1028
            accounts::audit(&app.db, Some(&me.account), "proposal.landed", &format!("{owner}/{name}#{number}"), "").await.ok();
1029
            db::repos::record_push(
1030
                &app.db,
1031
                row.id,
1032
                Some((me.account.id, me.account.username.as_str())),
1033
                &[db::repos::PushOutcome {
1034
                    reference: p.target.clone(),
1035
                    ok: true,
1036
                    reason: format!("landed #{number}"),
1037
                    old_state: loaded.target_state.clone(),
1038
                    new_state: state,
1039
                    heads: loaded.heads.len() as i64,
1040
                    changes: loaded.hashes.len() as i64,
1041
                }],
1042
            )
1043
            .await
1044
            .ok();
1045
            detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), Some("Landed.".into()), None).await
1046
        }
1047
        Err(e) => detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), None, Some(e.to_string())).await,
1048
    }
1049
}
1050
1051
#[derive(Deserialize)]
1052
pub struct LabelsForm {
1053
    #[serde(rename = "_csrf")]
1054
    csrf_token: String,
1055
    #[serde(default)]
1056
    label: Vec<i64>,
1057
}
1058
1059
pub async fn set_labels(
1060
    State(app): State<Shared>,
1061
    viewer: Viewer,
1062
    Path((owner, name, number)): Path<(String, String, i64)>,
1063
    axum_extra::extract::Form(f): axum_extra::extract::Form<LabelsForm>,
1064
) -> Result<Page<ProposalPage>, Error> {
1065
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1066
    require(&viewer, &shell)?;
1067
    csrf(&viewer, &f.csrf_token, &shell)?;
1068
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1069
    if role < Role::Triage {
1070
        return Err(Error::not_found(shell));
1071
    }
1072
    let p = pdb::get(&app.db, row.id, number)
1073
        .await
1074
        .map_err(|e| Error::from_db(shell.clone(), e))?
1075
        .ok_or_else(|| Error::not_found(shell.clone()))?;
1076
    for l in db::issues::labels(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1077
        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))?;
1078
    }
1079
    detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), None, None).await
1080
}
1081
1082
/// `GET /{owner}/{name}/proposal/{number}/events`: server-sent events for
1083
/// checks on the projected state (hub spec §13.1).
1084
pub async fn events(
1085
    State(app): State<Shared>,
1086
    viewer: Viewer,
1087
    Path((owner, name, number)): Path<(String, String, i64)>,
1088
) -> Result<axum::response::Sse<impl futures::Stream<Item = Result<axum::response::sse::Event, std::convert::Infallible>>>, Error> {
1089
    use futures::StreamExt;
1090
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1091
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1092
    let rx = app.events.proposals.subscribe();
1093
    let repo_id = row.id;
1094
    let stream = tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(move |item| async move {
1095
        match item {
1096
            Ok((id, ev)) if id == repo_id => {
1097
                Some(Ok(axum::response::sse::Event::default().event(ev.kind.clone()).data(serde_json::to_string(&ev).unwrap_or_default())))
1098
            }
1099
            _ => None,
1100
        }
1101
    });
1102
    Ok(axum::response::Sse::new(stream).keep_alive(axum::response::sse::KeepAlive::default()))
1103
}
1104
1105
// -------------------------------------------------------------------- API
1106
1107
pub async fn to_api(app: &Shared, owner: &str, name: &str, loaded: &Loaded, v: &Verdict, source_url: Option<String>) -> hub_api::Proposal {
1108
    let p = &loaded.row;
1109
    hub_api::Proposal {
1110
        number: p.number,
1111
        title: p.title.clone(),
1112
        body: p.body.clone(),
1113
        author: p.author.clone(),
1114
        state: ProposalState::parse(&p.state).unwrap_or(ProposalState::Open),
1115
        target: p.target.clone(),
1116
        source: match p.source_kind.as_str() {
1117
            "fork" => {
1118
                let (o, n) = source_url
1119
                    .as_deref()
1120
                    .and_then(|u| u.rsplit_once('/'))
1121
                    .and_then(|(b, n)| b.rsplit_once('/').map(|(_, o)| (o.to_string(), n.to_string())))
1122
                    .unwrap_or_default();
1123
                ProposalSource::Fork { owner: o, name: n, bookmark: p.source_bookmark.clone() }
1124
            }
1125
            "changes" => ProposalSource::Changes { hashes: p.source_changes() },
1126
            _ => ProposalSource::Bookmark { bookmark: p.source_bookmark.clone() },
1127
        },
1128
        changes: loaded
1129
            .changes
1130
            .iter()
1131
            .map(|c| hub_api::ChangeSummary {
1132
                hash: c.hash.clone(),
1133
                message: if c.body.is_empty() { c.title.clone() } else { format!("{}\n\n{}", c.title, c.body) },
1134
                author: hub_api::Author {
1135
                    name: c.author.name.clone(),
1136
                    email: c.author.email.clone(),
1137
                    key: c.author.key.clone(),
1138
                    account: c.author.account.clone(),
1139
                },
1140
                when_ms: c.when_ms,
1141
                deps: c.deps.clone(),
1142
                head: c.head,
1143
                signature: match &c.signature {
1144
                    view::SigView::Unsigned => hub_api::Signature::Unsigned,
1145
                    view::SigView::Valid { key, sealed, revoked, .. } => {
1146
                        hub_api::Signature::Valid { key: key.clone(), sealed: *sealed, revoked: *revoked }
1147
                    }
1148
                    view::SigView::Invalid(why) => hub_api::Signature::Invalid { reason: why.clone() },
1149
                },
1150
            })
1151
            .collect(),
1152
        projected_heads: loaded.heads.iter().map(|h| h.to_hex()).collect(),
1153
        projected_state: Some(loaded.state.clone()),
1154
        conflicted: loaded.conflicted,
1155
        approvals: v.approvals.clone(),
1156
        landable: p.is_open() && v.blockers.is_empty(),
1157
        blockers: v.blockers.clone(),
1158
        created_ms: p.created_ms,
1159
        updated_ms: p.updated_ms,
1160
        url: format!("{}/{owner}/{name}/proposal/{}", app.config.base_url(), p.number),
1161
        repository_url: format!("{}/{owner}/{name}", app.config.base_url()),
1162
        source_url,
1163
    }
1164
}
1165
1166
/// The line comment form's placement key, for templates.
1167
pub fn key(path: &str, line: impl filters::Num) -> (String, usize) {
1168
    (path.to_string(), line.as_u64() as usize)
1169
}
1170
1171
#[allow(dead_code)]
1172
fn _unused(_: &diff::Line, _: &ConflictView, _: &Role) {}