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