OXYGENHUB
# 42fa95540468 oxygenhub / crates / hub / src / web / proposals.rs
46.6 KiB · Rust · last changed Lists: filters, sort orders, pages, and counts on the tabs 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 state: String,
364
    pub target_state: String,
365
    pub conflicted: bool,
366
    pub conflicts: Vec<ConflictView>,
367
    pub files: Vec<FileRows>,
368
    /// The per-file hunks and bases behind `files`, to render again with other options.
369
    pub diffs: Vec<oxy_core::repo::FileDiff>,
370
    pub bases: std::collections::BTreeMap<String, Vec<u8>>,
371
    /// Atom per (path, new line number) of the projected state.
372
    pub atoms: HashMap<(String, usize), (String, u32)>,
373
    pub sealing_keys: Vec<String>,
374
    pub touched: Vec<String>,
375
    pub owners: Vec<String>,
376
    pub landed_already: bool,
377
}
378
379
/// Compute the projection and everything derived from it.
380
pub async fn load(app: &Shared, repo: &RepoName, row: pdb::ProposalRow) -> Result<Loaded, crate::repos::RepoError> {
381
    let fork_dir = match (row.source_kind.as_str(), row.source_repo_id) {
382
        ("fork", Some(id)) => {
383
            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))
384
        }
385
        _ => None,
386
    };
387
    let r2 = row.clone();
388
    let loaded = app
389
        .repos
390
        .read(repo, move |r, cache| {
391
            let source = match (r2.source_kind.as_str(), &fork_dir) {
392
                ("changes", _) => Source::Changes(r2.source_changes().iter().filter_map(|h| ChangeHash::from_hex(h)).collect()),
393
                ("fork", Some(dir)) => {
394
                    let fork_repo = oxy_core::repo::Repo::open(dir)?;
395
                    let heads =
396
                        fork_repo.bookmarks()?.into_iter().find(|b| b.name == r2.source_bookmark).map(|b| b.heads).unwrap_or_default();
397
                    // Bring the fork's changes over now, so the projection below can read them.
398
                    let fork_state = fork_repo.load_state(&heads)?;
399
                    let missing: Vec<ChangeHash> = fork_state.changes().map(|(h, _)| *h).filter(|h| !r.store().has(h)).collect();
400
                    if !missing.is_empty() {
401
                        r.import_bundle(&fork_repo.bundle_for(&missing)?, r.current_policy()?.is_some())?;
402
                    }
403
                    Source::Changes(heads)
404
                }
405
                _ => {
406
                    let heads = r.bookmarks()?.into_iter().find(|b| b.name == r2.source_bookmark).map(|b| b.heads).unwrap_or_default();
407
                    Source::Bookmark(heads)
408
                }
409
            };
410
            let p: Projection = pl::project(r, cache, &r2.target, source)?;
411
            let target_loaded = cache.load(r, &p.target_heads)?;
412
            let files = render_files(&p.files, &p.bases);
413
            let mut atoms = HashMap::new();
414
            for f in &files {
415
                for l in &f.lines {
416
                    if let Some(n) = l.new_no {
417
                        if let Some((h, i)) = pl::atom_at(&p.loaded, &f.path, n as usize) {
418
                            atoms.insert((f.path.clone(), n as usize), (h.to_hex(), i));
419
                        }
420
                    }
421
                }
422
            }
423
            let touched: Vec<String> = p.files.iter().map(|f| f.path.clone()).collect();
424
            let owners = pl::owners_for(&target_loaded, &touched);
425
            let sealing = pl::sealing_keys(r, &p.hashes)?.into_iter().map(|k| k.to_hex()).collect();
426
            let landed_already = !p.hashes.is_empty() && p.hashes.iter().all(|h| target_loaded.state.contains(h));
427
            Ok(Loaded {
428
                row: r2,
429
                changes: p.changes,
430
                hashes: p.hashes,
431
                heads: p.heads,
432
                state: p.state.to_hex(),
433
                target_state: p.target_state.to_hex(),
434
                conflicted: p.conflicted,
435
                conflicts: p.conflicts,
436
                files,
437
                diffs: p.files.clone(),
438
                bases: p.bases.clone(),
439
                atoms,
440
                sealing_keys: sealing,
441
                touched,
442
                owners,
443
                landed_already,
444
            })
445
        })
446
        .await?;
447
    // Remember the projection for the list, the API and other proposals' stacking checks.
448
    let heads_csv = loaded.heads.iter().map(|h| h.to_hex()).collect::<Vec<_>>().join(",");
449
    let changes_csv = loaded.hashes.iter().map(|h| h.to_hex()).collect::<Vec<_>>().join(",");
450
    pdb::set_projection(&app.db, loaded.row.id, &loaded.state, &heads_csv, &loaded.target_state, &changes_csv, loaded.conflicted)
451
        .await
452
        .ok();
453
    Ok(loaded)
454
}
455
456
/// Reviews, approvals and blockers.
457
pub struct Verdict {
458
    pub reviews: Vec<ReviewView>,
459
    pub approvals: Vec<Approval>,
460
    pub blockers: Vec<String>,
461
    pub depends_on: Vec<i64>,
462
    pub requirements: pdb::Requirements,
463
}
464
465
pub async fn verdict(app: &Shared, loaded: &Loaded, repo_id: i64) -> Result<Verdict, sqlx::Error> {
466
    let rows = pdb::reviews(&app.db, loaded.row.id).await?;
467
    let links = accounts::identity_accounts(&app.db).await?;
468
    let sealed_accounts: Vec<String> = links.iter().filter(|(k, _)| loaded.sealing_keys.contains(k)).map(|(_, u)| u.clone()).collect();
469
    // Latest verdict per reviewer.
470
    let mut latest: BTreeMap<String, &pdb::ReviewRow> = BTreeMap::new();
471
    for r in &rows {
472
        latest.insert(r.author.clone(), r);
473
    }
474
    let mut approvals = Vec::new();
475
    for (who, r) in &latest {
476
        if r.verdict == "approve" {
477
            let sealed = sealed_accounts.contains(who);
478
            let key = links.iter().find(|(k, u)| u == who && loaded.sealing_keys.contains(k)).map(|(k, _)| k.clone());
479
            approvals.push(Approval { account: who.clone(), sealed, key });
480
        }
481
    }
482
    let reviews = rows
483
        .iter()
484
        .map(|r| ReviewView {
485
            row: r.clone(),
486
            body_html: render::markdown::render(&r.body, "/", "/"),
487
            sealed: r.verdict == "approve" && sealed_accounts.contains(&r.author),
488
        })
489
        .collect();
490
    let requirements = pdb::requirements(&app.db, repo_id).await?;
491
    let mut blockers = Vec::new();
492
    if loaded.row.state == "draft" {
493
        blockers.push("the proposal is a draft".into());
494
    }
495
    if loaded.hashes.is_empty() {
496
        blockers.push("the proposal adds no changes".into());
497
    }
498
    let sealed_count = approvals.iter().filter(|a| a.sealed).count() as i64;
499
    if sealed_count < requirements.required_approvals {
500
        blockers.push(format!("{sealed_count} of {} required sealed approvals", requirements.required_approvals));
501
    }
502
    if requirements.require_owners {
503
        let missing: Vec<&String> = loaded.owners.iter().filter(|o| !approvals.iter().any(|a| a.sealed && &a.account == *o)).collect();
504
        if !missing.is_empty() {
505
            blockers.push(format!("code owners still to approve: {}", missing.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")));
506
        }
507
    }
508
    if loaded.conflicted && !requirements.allow_conflicts {
509
        blockers.push("landing would carry conflicts, which this repository refuses".into());
510
    }
511
    let required = db::hooks::required_checks(&app.db, repo_id).await?;
512
    if !required.is_empty() {
513
        let checks = db::hooks::checks_for_state(&app.db, repo_id, &loaded.state).await?;
514
        let missing: Vec<String> =
515
            required.iter().filter(|n| !checks.iter().any(|c| &c.name == *n && c.status == "passed")).cloned().collect();
516
        if !missing.is_empty() {
517
            blockers.push(format!("required checks not passed on the projected state: {}", missing.join(", ")));
518
        }
519
    }
520
    let requesting: Vec<&String> = latest.iter().filter(|(_, r)| r.verdict == "changes").map(|(who, _)| who).collect();
521
    if !requesting.is_empty() {
522
        blockers.push(format!("changes requested by {}", requesting.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")));
523
    }
524
    // Stacking: another open, earlier proposal whose changes this one includes.
525
    let mut depends_on = Vec::new();
526
    for other in pdb::list(&app.db, repo_id, &["open", "draft"]).await? {
527
        if other.id == loaded.row.id || other.number > loaded.row.number {
528
            continue;
529
        }
530
        let theirs = other.projected_changes();
531
        if !theirs.is_empty() && theirs.iter().all(|h| loaded.hashes.iter().any(|m| m.to_hex() == *h)) {
532
            depends_on.push(other.number);
533
        }
534
    }
535
    if !depends_on.is_empty() {
536
        blockers.push(format!("depends on {}", depends_on.iter().map(|n| format!("#{n}")).collect::<Vec<_>>().join(", ")));
537
    }
538
    Ok(Verdict { reviews, approvals, blockers, depends_on, requirements })
539
}
540
541
#[derive(Template)]
542
#[template(path = "proposal.html")]
543
pub struct ProposalPage {
544
    shell: Shell,
545
    repo: RepoShell,
546
    p: pdb::ProposalRow,
547
    body_html: String,
548
    tab: String,
549
    changes: Vec<ChangeView>,
550
    files: Vec<FileRows>,
551
    diffs: Vec<oxy_core::repo::FileDiff>,
552
    bases: std::collections::BTreeMap<String, Vec<u8>>,
553
    /// Whitespace-only changes are hidden (`?w=1`).
554
    ignore_ws: bool,
555
    conflicts: Vec<ConflictView>,
556
    conflicted: bool,
557
    comments: Vec<CommentView>,
558
    /// Anchored comments by (path, current line).
559
    placed: HashMap<(String, usize), Vec<usize>>,
560
    outdated: Vec<usize>,
561
    reviews: Vec<ReviewView>,
562
    approvals: Vec<Approval>,
563
    blockers: Vec<String>,
564
    depends_on: Vec<i64>,
565
    events: Vec<pdb::EventRow>,
566
    owners: Vec<String>,
567
    requirements: pdb::Requirements,
568
    landed_already: bool,
569
    can_edit: bool,
570
    can_land: bool,
571
    hub_can_land: bool,
572
    notice: Option<String>,
573
    error: Option<String>,
574
    heads: Vec<String>,
575
    state_hash: String,
576
    source_label: String,
577
    api_url: String,
578
    labels: Vec<db::issues::Label>,
579
    all_labels: Vec<db::issues::Label>,
580
    checks: Vec<db::hooks::Check>,
581
    required_checks: Vec<String>,
582
}
583
584
#[derive(Deserialize)]
585
pub struct DetailQuery {
586
    tab: Option<String>,
587
    #[serde(default)]
588
    w: Option<String>,
589
}
590
591
/// The page assembler every action ends in; its arguments are the route
592
/// plus what to say, and a struct would only rename them.
593
#[allow(clippy::too_many_arguments)]
594
async fn detail(
595
    app: &Shared,
596
    shell: Shell,
597
    viewer: &Viewer,
598
    owner: &str,
599
    name: &str,
600
    number: i64,
601
    tab: String,
602
    notice: Option<String>,
603
    error: Option<String>,
604
) -> Result<Page<ProposalPage>, Error> {
605
    let (repo, row, role) = lookup(app, &shell, viewer, owner, name).await?;
606
    let p = pdb::get(&app.db, row.id, number)
607
        .await
608
        .map_err(|e| Error::from_db(shell.clone(), e))?
609
        .ok_or_else(|| Error::not_found(shell.clone()))?;
610
    let loaded = load(app, &repo, p).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
611
    // A proposal whose changes all reached the target landed by another route.
612
    if loaded.landed_already && loaded.row.is_open() {
613
        pdb::set_state(&app.db, loaded.row.id, "landed", &loaded.target_state).await.ok();
614
        pdb::add_event(&app.db, loaded.row.id, "", "landed", "every change reached the target").await.ok();
615
    }
616
    let p = pdb::get(&app.db, row.id, number).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(loaded.row.clone());
617
    let v = verdict(app, &loaded, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
618
    let comment_rows = pdb::comments(&app.db, loaded.row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
619
    let events = pdb::events(&app.db, loaded.row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
620
    // Place anchored comments at their atom's current line.
621
    let mut by_atom: HashMap<(String, String, u32), usize> = HashMap::new();
622
    for ((path, line), (h, i)) in &loaded.atoms {
623
        by_atom.insert((path.clone(), h.clone(), *i), *line);
624
    }
625
    let mut comments = Vec::new();
626
    let mut placed: HashMap<(String, usize), Vec<usize>> = HashMap::new();
627
    let mut outdated = Vec::new();
628
    for (idx, c) in comment_rows.into_iter().enumerate() {
629
        let line = if c.anchor_change.is_empty() {
630
            None
631
        } else {
632
            by_atom.get(&(c.path.clone(), c.anchor_change.clone(), c.anchor_index as u32)).copied()
633
        };
634
        if !c.anchor_change.is_empty() {
635
            match line {
636
                Some(l) => placed.entry((c.path.clone(), l)).or_default().push(idx),
637
                None => outdated.push(idx),
638
            }
639
        }
640
        comments.push(CommentView { body_html: render::markdown::render(&c.body, "/", "/"), row: c, line });
641
    }
642
    let mut changes = loaded.changes.clone();
643
    super::attribute(app, &mut changes.iter_mut().collect::<Vec<_>>()).await;
644
    let me = viewer.account();
645
    let can_edit = me.is_some_and(|a| Some(a.id) == p.author_id || role >= Role::Maintain);
646
    let can_land = role >= Role::Write && p.is_open() && v.blockers.is_empty();
647
    let delegated = match viewer.account() {
648
        Some(a) => super::edit::delegated_identity(app, a.id).await.is_some(),
649
        None => false,
650
    };
651
    let hub_can_land = delegated || app.repos.read(&repo, |r, _| Ok(r.current_policy()?.is_none())).await.unwrap_or(false);
652
    let source_label = match p.source_kind.as_str() {
653
        "fork" => format!(
654
            "fork {}",
655
            db::repos::get_by_id(&app.db, p.source_repo_id.unwrap_or(0))
656
                .await
657
                .ok()
658
                .flatten()
659
                .map(|r| format!("{}:{}", r.slug(), p.source_bookmark))
660
                .unwrap_or_default()
661
        ),
662
        "changes" => format!("{} changes", p.source_changes().len()),
663
        _ => p.source_bookmark.clone(),
664
    };
665
    Ok(Page(ProposalPage {
666
        shell,
667
        repo: RepoShell::new(app, &row, "proposals").with_role(role),
668
        body_html: super::issues::markdown(app, row.id, &format!("/{owner}/{name}"), &p.body).await,
669
        p,
670
        tab,
671
        changes,
672
        files: loaded.files,
673
        diffs: loaded.diffs,
674
        bases: loaded.bases,
675
        ignore_ws: false,
676
        conflicts: loaded.conflicts,
677
        conflicted: loaded.conflicted,
678
        comments,
679
        placed,
680
        outdated,
681
        reviews: v.reviews,
682
        approvals: v.approvals,
683
        blockers: v.blockers,
684
        depends_on: v.depends_on,
685
        events,
686
        owners: loaded.owners,
687
        requirements: v.requirements,
688
        landed_already: loaded.landed_already,
689
        can_edit,
690
        can_land,
691
        hub_can_land,
692
        notice,
693
        error,
694
        heads: loaded.heads.iter().map(|h| h.to_hex()).collect(),
695
        state_hash: loaded.state.clone(),
696
        source_label,
697
        api_url: format!("{}/api/v1/repos/{owner}/{name}/proposals/{number}", app.config.base_url()),
698
        labels: db::issues::proposal_labels(&app.db, loaded.row.id).await.unwrap_or_default(),
699
        all_labels: db::issues::labels(&app.db, row.id).await.unwrap_or_default(),
700
        checks: db::hooks::checks_for_state(&app.db, row.id, &loaded.state).await.unwrap_or_default(),
701
        required_checks: db::hooks::required_checks(&app.db, row.id).await.unwrap_or_default(),
702
    }))
703
}
704
705
pub async fn show(
706
    State(app): State<Shared>,
707
    viewer: Viewer,
708
    Path((owner, name, number)): Path<(String, String, i64)>,
709
    Query(q): Query<DetailQuery>,
710
) -> Result<Page<ProposalPage>, Error> {
711
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
712
    let mut page = detail(&app, shell, &viewer, &owner, &name, number, q.tab.unwrap_or_else(|| "conversation".into()), None, None).await?;
713
    if q.w.as_deref() == Some("1") {
714
        // Hide whitespace-only changes: the same hunks, rendered again.
715
        page.0.files =
716
            super::history::render_files_with(&page.0.diffs, &page.0.bases, crate::render::diff::Options { ignore_whitespace: true });
717
        page.0.ignore_ws = true;
718
    }
719
    Ok(page)
720
}
721
722
// ---------------------------------------------------------------- actions
723
724
#[derive(Deserialize)]
725
pub struct CommentForm {
726
    #[serde(rename = "_csrf")]
727
    csrf_token: String,
728
    body: String,
729
    #[serde(default)]
730
    path: String,
731
    #[serde(default)]
732
    line: Option<usize>,
733
}
734
735
pub async fn comment(
736
    State(app): State<Shared>,
737
    viewer: Viewer,
738
    Path((owner, name, number)): Path<(String, String, i64)>,
739
    Form(f): Form<CommentForm>,
740
) -> Result<Page<ProposalPage>, Error> {
741
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
742
    let me = require(&viewer, &shell)?;
743
    csrf(&viewer, &f.csrf_token, &shell)?;
744
    let (repo, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
745
    let p = pdb::get(&app.db, row.id, number)
746
        .await
747
        .map_err(|e| Error::from_db(shell.clone(), e))?
748
        .ok_or_else(|| Error::not_found(shell.clone()))?;
749
    if f.body.trim().is_empty() {
750
        return detail(
751
            &app,
752
            shell,
753
            &viewer,
754
            &owner,
755
            &name,
756
            number,
757
            "conversation".into(),
758
            None,
759
            Some("An empty comment says nothing.".into()),
760
        )
761
        .await;
762
    }
763
    let anchor = match (f.path.trim(), f.line) {
764
        ("", _) | (_, None) => None,
765
        (path, Some(line)) => {
766
            let loaded = load(&app, &repo, p.clone()).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
767
            loaded.atoms.get(&(path.to_string(), line)).cloned()
768
        }
769
    };
770
    let tab = if anchor.is_some() { "files" } else { "conversation" };
771
    pdb::add_comment(
772
        &app.db,
773
        p.id,
774
        Some(me.account.id),
775
        &me.account.username,
776
        f.body.trim(),
777
        f.path.trim(),
778
        anchor.as_ref().map(|(h, i)| (h.as_str(), *i as i64)),
779
    )
780
    .await
781
    .map_err(|e| Error::from_db(shell.clone(), e))?;
782
    detail(&app, shell, &viewer, &owner, &name, number, tab.into(), None, None).await
783
}
784
785
#[derive(Deserialize)]
786
pub struct ReviewForm {
787
    #[serde(rename = "_csrf")]
788
    csrf_token: String,
789
    verdict: String,
790
    #[serde(default)]
791
    body: String,
792
}
793
794
pub async fn review(
795
    State(app): State<Shared>,
796
    viewer: Viewer,
797
    Path((owner, name, number)): Path<(String, String, i64)>,
798
    Form(f): Form<ReviewForm>,
799
) -> Result<Page<ProposalPage>, Error> {
800
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
801
    let me = require(&viewer, &shell)?;
802
    csrf(&viewer, &f.csrf_token, &shell)?;
803
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
804
    let p = pdb::get(&app.db, row.id, number)
805
        .await
806
        .map_err(|e| Error::from_db(shell.clone(), e))?
807
        .ok_or_else(|| Error::not_found(shell.clone()))?;
808
    if !matches!(f.verdict.as_str(), "comment" | "changes" | "approve") {
809
        return Err(Error::bad(shell, "a review is comment, changes or approve"));
810
    }
811
    if f.verdict == "approve" && Some(me.account.id) == p.author_id {
812
        return detail(
813
            &app,
814
            shell,
815
            &viewer,
816
            &owner,
817
            &name,
818
            number,
819
            "conversation".into(),
820
            None,
821
            Some("You cannot approve your own proposal.".into()),
822
        )
823
        .await;
824
    }
825
    pdb::add_review(&app.db, p.id, me.account.id, &me.account.username, &f.verdict, f.body.trim())
826
        .await
827
        .map_err(|e| Error::from_db(shell.clone(), e))?;
828
    pdb::add_event(&app.db, p.id, &me.account.username, &format!("review.{}", f.verdict), "").await.ok();
829
    crate::hooks::emit(&app, &row, "review", &me.account.username, serde_json::json!({ "number": number, "verdict": f.verdict })).await;
830
    let notice = if f.verdict == "approve" {
831
        Some(format!(
832
            "Approval recorded. It counts once you seal the changes: oxy seal --proposal {}/{owner}/{name}/proposal/{number}",
833
            app.config.base_url()
834
        ))
835
    } else {
836
        None
837
    };
838
    detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), notice, None).await
839
}
840
841
#[derive(Deserialize)]
842
pub struct EditForm {
843
    #[serde(rename = "_csrf")]
844
    csrf_token: String,
845
    title: String,
846
    #[serde(default)]
847
    body: String,
848
    target: String,
849
}
850
851
pub async fn edit(
852
    State(app): State<Shared>,
853
    viewer: Viewer,
854
    Path((owner, name, number)): Path<(String, String, i64)>,
855
    Form(f): Form<EditForm>,
856
) -> Result<Page<ProposalPage>, Error> {
857
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
858
    let me = require(&viewer, &shell)?;
859
    csrf(&viewer, &f.csrf_token, &shell)?;
860
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
861
    let p = pdb::get(&app.db, row.id, number)
862
        .await
863
        .map_err(|e| Error::from_db(shell.clone(), e))?
864
        .ok_or_else(|| Error::not_found(shell.clone()))?;
865
    if Some(me.account.id) != p.author_id && role < Role::Maintain {
866
        return Err(Error::not_found(shell));
867
    }
868
    let target = if crate::repos::names::valid_segment(f.target.trim()) { f.target.trim() } else { &p.target };
869
    pdb::edit(&app.db, p.id, f.title.trim(), f.body.trim(), target).await.map_err(|e| Error::from_db(shell.clone(), e))?;
870
    pdb::add_event(&app.db, p.id, &me.account.username, "edited", "").await.ok();
871
    detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), Some("Saved.".into()), None).await
872
}
873
874
#[derive(Deserialize)]
875
pub struct StateForm {
876
    #[serde(rename = "_csrf")]
877
    csrf_token: String,
878
    action: String,
879
}
880
881
pub async fn state(
882
    State(app): State<Shared>,
883
    viewer: Viewer,
884
    Path((owner, name, number)): Path<(String, String, i64)>,
885
    Form(f): Form<StateForm>,
886
) -> Result<Page<ProposalPage>, Error> {
887
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
888
    let me = require(&viewer, &shell)?;
889
    csrf(&viewer, &f.csrf_token, &shell)?;
890
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
891
    let p = pdb::get(&app.db, row.id, number)
892
        .await
893
        .map_err(|e| Error::from_db(shell.clone(), e))?
894
        .ok_or_else(|| Error::not_found(shell.clone()))?;
895
    if Some(me.account.id) != p.author_id && role < Role::Triage {
896
        return Err(Error::not_found(shell));
897
    }
898
    let new_state = match (f.action.as_str(), p.state.as_str()) {
899
        ("close", "open" | "draft") => "closed",
900
        ("reopen", "closed") => "open",
901
        ("ready", "draft") => "open",
902
        ("draft", "open") => "draft",
903
        _ => return Err(Error::bad(shell, "that transition is not possible")),
904
    };
905
    pdb::set_state(&app.db, p.id, new_state, "").await.map_err(|e| Error::from_db(shell.clone(), e))?;
906
    pdb::add_event(&app.db, p.id, &me.account.username, new_state, "").await.ok();
907
    crate::hooks::emit(&app, &row, &format!("proposal.{new_state}"), &me.account.username, serde_json::json!({ "number": number })).await;
908
    detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), None, None).await
909
}
910
911
pub async fn resolve_comment(
912
    State(app): State<Shared>,
913
    viewer: Viewer,
914
    Path((owner, name, number, id)): Path<(String, String, i64, i64)>,
915
    Form(f): Form<super::session::CsrfForm>,
916
) -> Result<Page<ProposalPage>, Error> {
917
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
918
    require(&viewer, &shell)?;
919
    csrf(&viewer, &f.csrf, &shell)?;
920
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
921
    let p = pdb::get(&app.db, row.id, number)
922
        .await
923
        .map_err(|e| Error::from_db(shell.clone(), e))?
924
        .ok_or_else(|| Error::not_found(shell.clone()))?;
925
    let current: Option<bool> = sqlx::query_scalar("SELECT resolved FROM proposal_comments WHERE id = ? AND proposal_id = ?")
926
        .bind(id)
927
        .bind(p.id)
928
        .fetch_optional(&app.db)
929
        .await
930
        .map_err(|e| Error::from_db(shell.clone(), e))?;
931
    if let Some(r) = current {
932
        pdb::set_comment_resolved(&app.db, id, !r).await.map_err(|e| Error::from_db(shell.clone(), e))?;
933
    }
934
    detail(&app, shell, &viewer, &owner, &name, number, "files".into(), None, None).await
935
}
936
937
/// Land from the hub: only when the repository has no trust policy (the
938
/// hub never signs a reference update the policy would have to admit).
939
pub async fn land(
940
    State(app): State<Shared>,
941
    viewer: Viewer,
942
    Path((owner, name, number)): Path<(String, String, i64)>,
943
    Form(f): Form<super::session::CsrfForm>,
944
) -> Result<Page<ProposalPage>, Error> {
945
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
946
    let me = require(&viewer, &shell)?;
947
    csrf(&viewer, &f.csrf, &shell)?;
948
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
949
    let p = pdb::get(&app.db, row.id, number)
950
        .await
951
        .map_err(|e| Error::from_db(shell.clone(), e))?
952
        .ok_or_else(|| Error::not_found(shell.clone()))?;
953
    let loaded = load(&app, &repo, p.clone()).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
954
    let v = verdict(&app, &loaded, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
955
    if !p.is_open() || !v.blockers.is_empty() {
956
        return detail(
957
            &app,
958
            shell,
959
            &viewer,
960
            &owner,
961
            &name,
962
            number,
963
            "conversation".into(),
964
            None,
965
            Some(format!("Cannot land: {}", v.blockers.join("; "))),
966
        )
967
        .await;
968
    }
969
    let target = p.target.clone();
970
    let heads = loaded.heads.clone();
971
    // With a delegation the landing is attested by the hub key on the
972
    // user's behalf, which a policy accepts when it lets the user update
973
    // the target (spec §14.5); without one, only a policy-free repository
974
    // can be landed here.
975
    let result = match super::edit::signer_for(&app, me.account.id).await {
976
        Ok(signer) => crate::repos::edits::move_bookmark(&app.repos, &repo, target.clone(), heads.clone(), signer).await.map(|_| pl::state_ref(&heads).to_hex()),
977
        Err(why) => {
978
            app.repos
979
                .write(&repo, move |r, _| {
980
                    if r.current_policy()?.is_some() {
981
                        return Err(crate::repos::RepoError::Oxy(oxy_core::repo::RepoError::Msg(format!(
982
                            "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}"
983
                        ))));
984
                    }
985
                    r.bookmark_set(&target, &heads)?;
986
                    Ok(pl::state_ref(&heads).to_hex())
987
                })
988
                .await
989
        }
990
    };
991
    match result {
992
        Ok(state) => {
993
            pdb::set_state(&app.db, p.id, "landed", &state).await.map_err(|e| Error::from_db(shell.clone(), e))?;
994
            pdb::add_event(&app.db, p.id, &me.account.username, "landed", "from the hub").await.ok();
995
            crate::hooks::emit(
996
                &app,
997
                &row,
998
                "proposal.landed",
999
                &me.account.username,
1000
                serde_json::json!({ "number": number, "target": p.target, "state": state }),
1001
            )
1002
            .await;
1003
            crate::notify::deliver(
1004
                &app,
1005
                crate::notify::Event {
1006
                    kind: "proposal.landed",
1007
                    title: format!("{owner}/{name}#{number}: {} landed", p.title),
1008
                    url: format!("/{owner}/{name}/proposal/{number}"),
1009
                    actor: &me.account,
1010
                    repo_id: row.id,
1011
                    thread: Some(("proposal", p.id)),
1012
                    mention_text: "",
1013
                    direct: p.author_id.into_iter().collect(),
1014
                },
1015
            )
1016
            .await;
1017
            if p.target == row.default_bookmark {
1018
                let messages: Vec<(String, String)> =
1019
                    loaded.changes.iter().map(|c| (c.hash.clone(), format!("{}\n{}", c.title, c.body))).collect();
1020
                super::issues::close_from_messages(&app, &row, &messages).await;
1021
            }
1022
            accounts::audit(&app.db, Some(&me.account), "proposal.landed", &format!("{owner}/{name}#{number}"), "").await.ok();
1023
            db::repos::record_push(
1024
                &app.db,
1025
                row.id,
1026
                Some((me.account.id, me.account.username.as_str())),
1027
                &[db::repos::PushOutcome {
1028
                    reference: p.target.clone(),
1029
                    ok: true,
1030
                    reason: format!("landed #{number}"),
1031
                    old_state: loaded.target_state.clone(),
1032
                    new_state: state,
1033
                    heads: loaded.heads.len() as i64,
1034
                    changes: loaded.hashes.len() as i64,
1035
                }],
1036
            )
1037
            .await
1038
            .ok();
1039
            detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), Some("Landed.".into()), None).await
1040
        }
1041
        Err(e) => detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), None, Some(e.to_string())).await,
1042
    }
1043
}
1044
1045
#[derive(Deserialize)]
1046
pub struct LabelsForm {
1047
    #[serde(rename = "_csrf")]
1048
    csrf_token: String,
1049
    #[serde(default)]
1050
    label: Vec<i64>,
1051
}
1052
1053
pub async fn set_labels(
1054
    State(app): State<Shared>,
1055
    viewer: Viewer,
1056
    Path((owner, name, number)): Path<(String, String, i64)>,
1057
    axum_extra::extract::Form(f): axum_extra::extract::Form<LabelsForm>,
1058
) -> Result<Page<ProposalPage>, Error> {
1059
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1060
    require(&viewer, &shell)?;
1061
    csrf(&viewer, &f.csrf_token, &shell)?;
1062
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1063
    if role < Role::Triage {
1064
        return Err(Error::not_found(shell));
1065
    }
1066
    let p = pdb::get(&app.db, row.id, number)
1067
        .await
1068
        .map_err(|e| Error::from_db(shell.clone(), e))?
1069
        .ok_or_else(|| Error::not_found(shell.clone()))?;
1070
    for l in db::issues::labels(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1071
        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))?;
1072
    }
1073
    detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), None, None).await
1074
}
1075
1076
/// `GET /{owner}/{name}/proposal/{number}/events`: server-sent events for
1077
/// checks on the projected state (hub spec §13.1).
1078
pub async fn events(
1079
    State(app): State<Shared>,
1080
    viewer: Viewer,
1081
    Path((owner, name, number)): Path<(String, String, i64)>,
1082
) -> Result<axum::response::Sse<impl futures::Stream<Item = Result<axum::response::sse::Event, std::convert::Infallible>>>, Error> {
1083
    use futures::StreamExt;
1084
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1085
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1086
    let rx = app.events.proposals.subscribe();
1087
    let repo_id = row.id;
1088
    let stream = tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(move |item| async move {
1089
        match item {
1090
            Ok((id, ev)) if id == repo_id => {
1091
                Some(Ok(axum::response::sse::Event::default().event(ev.kind.clone()).data(serde_json::to_string(&ev).unwrap_or_default())))
1092
            }
1093
            _ => None,
1094
        }
1095
    });
1096
    Ok(axum::response::Sse::new(stream).keep_alive(axum::response::sse::KeepAlive::default()))
1097
}
1098
1099
// -------------------------------------------------------------------- API
1100
1101
pub async fn to_api(app: &Shared, owner: &str, name: &str, loaded: &Loaded, v: &Verdict, source_url: Option<String>) -> hub_api::Proposal {
1102
    let p = &loaded.row;
1103
    hub_api::Proposal {
1104
        number: p.number,
1105
        title: p.title.clone(),
1106
        body: p.body.clone(),
1107
        author: p.author.clone(),
1108
        state: ProposalState::parse(&p.state).unwrap_or(ProposalState::Open),
1109
        target: p.target.clone(),
1110
        source: match p.source_kind.as_str() {
1111
            "fork" => {
1112
                let (o, n) = source_url
1113
                    .as_deref()
1114
                    .and_then(|u| u.rsplit_once('/'))
1115
                    .and_then(|(b, n)| b.rsplit_once('/').map(|(_, o)| (o.to_string(), n.to_string())))
1116
                    .unwrap_or_default();
1117
                ProposalSource::Fork { owner: o, name: n, bookmark: p.source_bookmark.clone() }
1118
            }
1119
            "changes" => ProposalSource::Changes { hashes: p.source_changes() },
1120
            _ => ProposalSource::Bookmark { bookmark: p.source_bookmark.clone() },
1121
        },
1122
        changes: loaded
1123
            .changes
1124
            .iter()
1125
            .map(|c| hub_api::ChangeSummary {
1126
                hash: c.hash.clone(),
1127
                message: if c.body.is_empty() { c.title.clone() } else { format!("{}\n\n{}", c.title, c.body) },
1128
                author: hub_api::Author {
1129
                    name: c.author.name.clone(),
1130
                    email: c.author.email.clone(),
1131
                    key: c.author.key.clone(),
1132
                    account: c.author.account.clone(),
1133
                },
1134
                when_ms: c.when_ms,
1135
                deps: c.deps.clone(),
1136
                head: c.head,
1137
                signature: match &c.signature {
1138
                    view::SigView::Unsigned => hub_api::Signature::Unsigned,
1139
                    view::SigView::Valid { key, sealed, revoked, .. } => {
1140
                        hub_api::Signature::Valid { key: key.clone(), sealed: *sealed, revoked: *revoked }
1141
                    }
1142
                    view::SigView::Invalid(why) => hub_api::Signature::Invalid { reason: why.clone() },
1143
                },
1144
            })
1145
            .collect(),
1146
        projected_heads: loaded.heads.iter().map(|h| h.to_hex()).collect(),
1147
        projected_state: Some(loaded.state.clone()),
1148
        conflicted: loaded.conflicted,
1149
        approvals: v.approvals.clone(),
1150
        landable: p.is_open() && v.blockers.is_empty(),
1151
        blockers: v.blockers.clone(),
1152
        created_ms: p.created_ms,
1153
        updated_ms: p.updated_ms,
1154
        url: format!("{}/{owner}/{name}/proposal/{}", app.config.base_url(), p.number),
1155
        repository_url: format!("{}/{owner}/{name}", app.config.base_url()),
1156
        source_url,
1157
    }
1158
}
1159
1160
/// The line comment form's placement key, for templates.
1161
pub fn key(path: &str, line: impl filters::Num) -> (String, usize) {
1162
    (path.to_string(), line.as_u64() as usize)
1163
}
1164
1165
#[allow(dead_code)]
1166
fn _unused(_: &diff::Line, _: &ConflictView, _: &Role) {}