OXYGENHUB
# c79a1111dd56 oxygenhub / crates / hub / src / web / issues.rs
37.2 KiB · Rust · last changed Issue, comment and proposal Markdown resolves relative links against the default bookmark, release notes against the release's tag 1 month ago source blame history raw
18e381ea Andy McCall ↶ 1
//! Issues, labels and milestones (hub spec §8).
2
3
use super::settings::{csrf, require};
4
use super::{filters, lookup, lookup_role, Error, Page, RepoShell, Shell};
5
use crate::auth::{Access, Role, Viewer};
6
use crate::db::{self, accounts, issues as idb, notify as ndb};
7
use crate::notify::{self, Event};
8
use crate::render::{self, refs::Numbers};
9
use crate::repos::view;
10
use crate::Shared;
11
use askama::Template;
12
use axum::extract::{Form, Path, Query, State};
13
use axum::response::{IntoResponse, Redirect, Response};
14
use serde::Deserialize;
15
use std::collections::HashMap;
16
c79a1111 Andy McCall ↶ 17
/// Markdown with cross references linked, for a repository; relative
18
/// links and images resolve against `reference` (the default bookmark for
19
/// issues and proposals, the tag for release notes).
20
pub async fn markdown(app: &Shared, row: &db::repos::RepoRow, reference: &str, text: &str) -> String {
21
    let repo_url = format!("/{}", row.slug());
22
    let (issues, proposals) = idb::numbers(&app.db, row.id).await.unwrap_or_default();
23
    let linked = render::refs::link(text, &Numbers { repo_url: &repo_url, issues: &issues, proposals: &proposals });
24
    render::markdown::render(&linked, &format!("{repo_url}/file/{reference}/"), &format!("{repo_url}/raw/{reference}/"))
18e381ea Andy McCall ↶ 25
}
26
27
/// Re-index a thread for search: title, body and every comment.
28
pub async fn reindex(app: &Shared, repo_id: i64, issue: &idb::IssueRow) {
29
    let comments = idb::comments(&app.db, issue.id).await.unwrap_or_default();
30
    let text = std::iter::once(issue.body.clone()).chain(comments.into_iter().map(|c| c.body)).collect::<Vec<_>>().join("\n\n");
31
    ndb::index(&app.db, "issue", repo_id, issue.id, issue.number, &issue.title, &text).await.ok();
32
}
33
34
// ------------------------------------------------------------------ list
35
36
pub struct IssueLine {
37
    pub issue: idb::IssueRow,
38
    pub labels: Vec<idb::Label>,
39
    pub assignees: Vec<String>,
40
    pub milestone: Option<String>,
41
    pub comments: i64,
42
}
43
44
#[derive(Template)]
45
#[template(path = "issues.html")]
46
pub struct IssuesPage {
47
    shell: Shell,
48
    repo: RepoShell,
49
    rows: Vec<IssueLine>,
50
    state: String,
51
    q: String,
52
    counts: (usize, usize),
1ac84c62 Andy McCall ↶ 53
    sort: String,
54
    pager: super::Pager,
18e381ea Andy McCall ↶ 55
}
56
57
#[derive(Deserialize)]
58
pub struct ListQuery {
59
    state: Option<String>,
60
    q: Option<String>,
1ac84c62 Andy McCall ↶ 61
    sort: Option<String>,
62
    page: Option<usize>,
18e381ea Andy McCall ↶ 63
}
64
1ac84c62 Andy McCall ↶ 65
/// The list's sort orders: `newest` (pinned first, the default),
66
/// `oldest`, `updated`, `comments`.
67
pub const SORTS: &[(&str, &str)] =
68
    &[("newest", "newest"), ("oldest", "oldest"), ("updated", "recently updated"), ("comments", "most comments")];
69
18e381ea Andy McCall ↶ 70
/// `label:bug assignee:andy milestone:v1 state:closed words…`
71
struct Filter {
72
    state: String,
73
    labels: Vec<String>,
74
    assignees: Vec<String>,
1ac84c62 Andy McCall ↶ 75
    authors: Vec<String>,
18e381ea Andy McCall ↶ 76
    milestone: Option<String>,
77
    words: Vec<String>,
78
}
79
80
fn parse_filter(state: Option<String>, q: &str) -> Filter {
1ac84c62 Andy McCall ↶ 81
    let mut f = Filter {
82
        state: state.unwrap_or_else(|| "open".into()),
83
        labels: vec![],
84
        assignees: vec![],
85
        authors: vec![],
86
        milestone: None,
87
        words: vec![],
88
    };
18e381ea Andy McCall ↶ 89
    for tok in q.split_whitespace() {
90
        match tok.split_once(':') {
91
            Some(("label", v)) => f.labels.push(v.to_lowercase()),
92
            Some(("assignee", v)) => f.assignees.push(v.to_lowercase()),
1ac84c62 Andy McCall ↶ 93
            Some(("author", v)) => f.authors.push(v.to_lowercase()),
18e381ea Andy McCall ↶ 94
            Some(("milestone", v)) => f.milestone = Some(v.to_lowercase()),
95
            Some(("state", v)) => f.state = v.to_string(),
96
            _ => f.words.push(tok.to_lowercase()),
97
        }
98
    }
99
    f
100
}
101
102
pub async fn list(
103
    State(app): State<Shared>,
104
    viewer: Viewer,
105
    Path((owner, name)): Path<(String, String)>,
106
    Query(q): Query<ListQuery>,
107
) -> Result<Page<IssuesPage>, Error> {
108
    let shell = Shell::new(&app, &format!("/{owner}/{name}/issues"), &viewer);
109
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
110
    let e = |e| Error::from_db(shell.clone(), e);
111
    let all = idb::list(&app.db, row.id).await.map_err(e)?;
112
    let counts = (all.iter().filter(|i| i.state == "open").count(), all.iter().filter(|i| i.state == "closed").count());
113
    let qtext = q.q.clone().unwrap_or_default();
114
    let f = parse_filter(q.state, &qtext);
115
    let mut labels_by: HashMap<i64, Vec<idb::Label>> = HashMap::new();
116
    for (id, l) in idb::labels_by_issue(&app.db, row.id).await.map_err(e)? {
117
        labels_by.entry(id).or_default().push(l);
118
    }
119
    let mut assignees_by: HashMap<i64, Vec<String>> = HashMap::new();
120
    for (id, u) in idb::assignees_by_issue(&app.db, row.id).await.map_err(e)? {
121
        assignees_by.entry(id).or_default().push(u);
122
    }
123
    let milestones: HashMap<i64, String> =
124
        idb::milestones(&app.db, row.id).await.map_err(e)?.into_iter().map(|m| (m.id, m.title)).collect();
125
    let mut rows = Vec::new();
126
    for i in all {
127
        let labels = labels_by.remove(&i.id).unwrap_or_default();
128
        let assignees = assignees_by.remove(&i.id).unwrap_or_default();
129
        let milestone = i.milestone_id.and_then(|m| milestones.get(&m).cloned());
130
        let keep = (f.state == "all" || i.state == f.state)
131
            && f.labels.iter().all(|l| labels.iter().any(|x| x.name.to_lowercase() == *l))
132
            && f.assignees.iter().all(|a| assignees.iter().any(|x| x.to_lowercase() == *a))
1ac84c62 Andy McCall ↶ 133
            && (f.authors.is_empty() || f.authors.contains(&i.author.to_lowercase()))
18e381ea Andy McCall ↶ 134
            && f.milestone.as_ref().is_none_or(|m| milestone.as_ref().is_some_and(|x| x.to_lowercase() == *m))
135
            && f.words.iter().all(|w| i.title.to_lowercase().contains(w) || i.body.to_lowercase().contains(w));
136
        if keep {
137
            let comments: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issue_comments WHERE issue_id = ?")
138
                .bind(i.id)
139
                .fetch_one(&app.db)
140
                .await
141
                .map_err(e)?;
142
            rows.push(IssueLine { issue: i, labels, assignees, milestone, comments });
143
        }
144
    }
1ac84c62 Andy McCall ↶ 145
    let sort = q.sort.filter(|s| SORTS.iter().any(|(k, _)| k == s)).unwrap_or_else(|| "newest".into());
146
    match sort.as_str() {
147
        "oldest" => rows.sort_by_key(|r| r.issue.number),
148
        "updated" => rows.sort_by_key(|r| std::cmp::Reverse(r.issue.updated_ms)),
149
        "comments" => rows.sort_by_key(|r| (std::cmp::Reverse(r.comments), std::cmp::Reverse(r.issue.number))),
150
        _ => {}
151
    }
152
    let pager = super::Pager::new(rows.len(), q.page, &[("state", &f.state), ("q", &qtext), ("sort", &sort)]);
153
    let rows = pager.slice(rows);
154
    Ok(Page(IssuesPage {
155
        shell,
156
        repo: RepoShell::new(&app, &row, "issues").with_role(role),
157
        rows,
158
        state: f.state,
159
        q: qtext,
160
        counts,
161
        sort,
162
        pager,
163
    }))
18e381ea Andy McCall ↶ 164
}
165
166
// ------------------------------------------------------------------- new
167
168
#[derive(Template)]
169
#[template(path = "issue_new.html")]
170
pub struct NewIssuePage {
171
    shell: Shell,
172
    repo: RepoShell,
173
    templates: Vec<(String, String)>,
174
    template: String,
175
    error: Option<String>,
176
}
177
178
#[derive(Deserialize)]
179
pub struct NewQuery {
180
    template: Option<String>,
181
}
182
183
/// `.oxyhub/ISSUE_TEMPLATE/*.md` in the default bookmark's state.
184
async fn issue_templates(app: &Shared, repo: &crate::repos::RepoName, default: &str) -> Vec<(String, String)> {
185
    let default = default.to_string();
186
    app.repos
187
        .read(repo, move |r, cache| {
188
            let Ok(target) = view::resolve(r, cache, &default) else { return Ok(vec![]) };
189
            let loaded = cache.load(r, &target.heads)?;
190
            let mut out = Vec::new();
191
            for (path, f) in &loaded.snapshot.files {
192
                if let Some(rest) = path.strip_prefix(".oxyhub/ISSUE_TEMPLATE/") {
193
                    if let (Some(name), oxy_core::state::FileContent::Text { bytes, .. }) = (rest.strip_suffix(".md"), &f.content) {
194
                        out.push((name.to_string(), String::from_utf8_lossy(bytes).into_owned()));
195
                    }
196
                }
197
            }
198
            Ok(out)
199
        })
200
        .await
201
        .unwrap_or_default()
202
}
203
204
pub async fn new_form(
205
    State(app): State<Shared>,
206
    viewer: Viewer,
207
    Path((owner, name)): Path<(String, String)>,
208
    Query(q): Query<NewQuery>,
209
) -> Result<Page<NewIssuePage>, Error> {
210
    let shell = Shell::new(&app, &format!("/{owner}/{name}/issues/new"), &viewer);
211
    require(&viewer, &shell)?;
212
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
213
    let templates = issue_templates(&app, &repo, &row.default_bookmark).await;
214
    let template = match q.template.as_deref() {
215
        Some("blank") => " ".into(),
216
        Some(t) => templates.iter().find(|(n, _)| n == t).map(|(_, b)| b.clone()).unwrap_or_default(),
217
        None if templates.is_empty() => " ".into(),
218
        None => String::new(),
219
    };
220
    Ok(Page(NewIssuePage { shell, repo: RepoShell::new(&app, &row, "issues").with_role(role), templates, template, error: None }))
221
}
222
223
#[derive(Deserialize)]
224
pub struct NewIssueForm {
225
    #[serde(rename = "_csrf")]
226
    csrf_token: String,
227
    title: String,
228
    #[serde(default)]
229
    body: String,
230
}
231
232
pub async fn create(
233
    State(app): State<Shared>,
234
    viewer: Viewer,
235
    Path((owner, name)): Path<(String, String)>,
236
    Form(f): Form<NewIssueForm>,
237
) -> Result<Response, Error> {
238
    let shell = Shell::new(&app, &format!("/{owner}/{name}/issues/new"), &viewer);
239
    let me = require(&viewer, &shell)?;
240
    csrf(&viewer, &f.csrf_token, &shell)?;
241
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
242
    if f.title.trim().is_empty() {
243
        return Ok(Page(NewIssuePage {
244
            shell,
245
            repo: RepoShell::new(&app, &row, "issues").with_role(role),
246
            templates: vec![],
247
            template: f.body,
248
            error: Some("Give the issue a title.".into()),
249
        })
250
        .into_response());
251
    }
252
    let issue = idb::create(
253
        &app.db,
254
        idb::NewIssue {
255
            repo_id: row.id,
256
            title: f.title.trim(),
257
            body: f.body.trim(),
258
            author_id: Some(me.account.id),
259
            author: &me.account.username,
260
        },
261
    )
262
    .await
263
    .map_err(|e| Error::from_db(shell.clone(), e))?;
264
    idb::add_event(&app.db, issue.id, &me.account.username, "opened", "").await.ok();
265
    reindex(&app, row.id, &issue).await;
266
    let url = format!("/{owner}/{name}/issue/{}", issue.number);
267
    notify::deliver(
268
        &app,
269
        Event {
270
            kind: "issue.opened",
271
            title: format!("{owner}/{name}#{}: {}", issue.number, issue.title),
272
            url: url.clone(),
273
            actor: &me.account,
274
            repo_id: row.id,
275
            thread: Some(("issue", issue.id)),
276
            mention_text: &f.body,
277
            direct: vec![],
278
        },
279
    )
280
    .await;
281
    Ok(Redirect::to(&url).into_response())
282
}
283
284
// ---------------------------------------------------------------- detail
285
286
pub enum Item {
287
    Comment(idb::IssueComment, String),
288
    Event(idb::IssueEvent),
289
}
290
291
#[derive(Template)]
292
#[template(path = "issue.html")]
293
pub struct IssuePage {
294
    shell: Shell,
295
    repo: RepoShell,
296
    i: idb::IssueRow,
297
    body_html: String,
298
    timeline: Vec<Item>,
299
    comments: Vec<idb::IssueComment>,
300
    labels: Vec<idb::Label>,
301
    all_labels: Vec<idb::Label>,
302
    assignees: Vec<String>,
303
    milestone: Option<idb::Milestone>,
304
    milestones: Vec<(idb::Milestone, bool)>,
305
    subscribed: bool,
306
    can_edit: bool,
307
    can_triage: bool,
e1ec9fbf Andy McCall ↶ 308
    /// Projects this issue is on that the viewer may see, with the column.
309
    projects: Vec<(crate::db::projects::Project, String)>,
18e381ea Andy McCall ↶ 310
    notice: Option<String>,
311
    error: Option<String>,
312
}
313
314
/// The page assembler every action ends in.
315
#[allow(clippy::too_many_arguments)]
316
async fn detail(
317
    app: &Shared,
318
    shell: Shell,
319
    viewer: &Viewer,
320
    owner: &str,
321
    name: &str,
322
    number: i64,
323
    notice: Option<String>,
324
    error: Option<String>,
325
) -> Result<Page<IssuePage>, Error> {
326
    let (_, row, role) = lookup(app, &shell, viewer, owner, name).await?;
327
    let e = |e| Error::from_db(shell.clone(), e);
328
    let i = idb::get(&app.db, row.id, number).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?;
c79a1111 Andy McCall ↶ 329
    let body_html = markdown(app, &row, &row.default_bookmark, &i.body).await;
18e381ea Andy McCall ↶ 330
    let comments = idb::comments(&app.db, i.id).await.map_err(e)?;
331
    let events = idb::events(&app.db, i.id).await.map_err(e)?;
332
    let mut timeline: Vec<(i64, Item)> = Vec::new();
333
    for c in &comments {
c79a1111 Andy McCall ↶ 334
        timeline.push((c.created_ms, Item::Comment(c.clone(), markdown(app, &row, &row.default_bookmark, &c.body).await)));
18e381ea Andy McCall ↶ 335
    }
336
    for ev in events {
337
        if ev.event != "opened" {
338
            timeline.push((ev.at_ms, Item::Event(ev)));
339
        }
340
    }
341
    timeline.sort_by_key(|(t, _)| *t);
342
    let labels = idb::issue_labels(&app.db, i.id).await.map_err(e)?;
343
    let all_labels = idb::labels(&app.db, row.id).await.map_err(e)?;
344
    let assignees = idb::assignees(&app.db, i.id).await.map_err(e)?;
345
    let milestones: Vec<(idb::Milestone, bool)> = idb::milestones(&app.db, row.id)
346
        .await
347
        .map_err(e)?
348
        .into_iter()
349
        .map(|m| {
350
            let sel = i.milestone_id == Some(m.id);
351
            (m, sel)
352
        })
353
        .collect();
354
    let milestone = match i.milestone_id {
355
        Some(id) => idb::milestone(&app.db, id).await.map_err(e)?,
356
        None => None,
357
    };
358
    let me = viewer.account();
359
    let subscribed = match me {
360
        Some(a) => ndb::subscribed(&app.db, a.id, "issue", i.id).await.map_err(e)?,
361
        None => false,
362
    };
363
    let can_triage = role >= Role::Triage;
364
    let can_edit = me.is_some_and(|a| Some(a.id) == i.author_id) || can_triage;
e1ec9fbf Andy McCall ↶ 365
    let projects = super::projects::placements_for(app, viewer, &row, i.number).await;
18e381ea Andy McCall ↶ 366
    Ok(Page(IssuePage {
367
        shell,
368
        repo: RepoShell::new(app, &row, "issues").with_role(role),
369
        i,
370
        body_html,
371
        timeline: timeline.into_iter().map(|(_, it)| it).collect(),
372
        comments,
373
        labels,
374
        all_labels,
375
        assignees,
376
        milestone,
377
        milestones,
378
        subscribed,
379
        can_edit,
380
        can_triage,
e1ec9fbf Andy McCall ↶ 381
        projects,
18e381ea Andy McCall ↶ 382
        notice,
383
        error,
384
    }))
385
}
386
387
pub async fn show(
388
    State(app): State<Shared>,
389
    viewer: Viewer,
390
    Path((owner, name, number)): Path<(String, String, i64)>,
391
) -> Result<Response, Error> {
392
    let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer);
393
    // A number that belongs to a proposal goes there (shared number space).
394
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
395
    if idb::get(&app.db, row.id, number).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_none()
396
        && db::proposals::get(&app.db, row.id, number).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some()
397
    {
398
        return Ok(Redirect::to(&format!("/{owner}/{name}/proposal/{number}")).into_response());
399
    }
400
    Ok(detail(&app, shell, &viewer, &owner, &name, number, None, None).await?.into_response())
401
}
402
403
// ---------------------------------------------------------------- actions
404
405
async fn issue_for(
406
    app: &Shared,
407
    shell: &Shell,
408
    viewer: &Viewer,
409
    owner: &str,
410
    name: &str,
411
    number: i64,
412
    access: Access,
413
) -> Result<(db::repos::RepoRow, idb::IssueRow, Role), Error> {
414
    let (_, row, role) = lookup_role(app, shell, viewer, owner, name, access).await?;
415
    let i = idb::get(&app.db, row.id, number)
416
        .await
417
        .map_err(|e| Error::from_db(shell.clone(), e))?
418
        .ok_or_else(|| Error::not_found(shell.clone()))?;
419
    Ok((row, i, role))
420
}
421
422
#[derive(Deserialize)]
423
pub struct CommentForm {
424
    #[serde(rename = "_csrf")]
425
    csrf_token: String,
426
    body: String,
427
    #[serde(default)]
428
    close: Option<String>,
429
    #[serde(default)]
430
    reopen: Option<String>,
431
}
432
433
pub async fn comment(
434
    State(app): State<Shared>,
435
    viewer: Viewer,
436
    Path((owner, name, number)): Path<(String, String, i64)>,
437
    Form(f): Form<CommentForm>,
438
) -> Result<Page<IssuePage>, Error> {
439
    let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer);
440
    let me = require(&viewer, &shell)?;
441
    csrf(&viewer, &f.csrf_token, &shell)?;
442
    let (row, i, role) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?;
443
    if i.locked && role < Role::Triage {
444
        return detail(&app, shell, &viewer, &owner, &name, number, None, Some("This issue is locked.".into())).await;
445
    }
446
    if f.body.trim().is_empty() {
447
        return detail(&app, shell, &viewer, &owner, &name, number, None, Some("An empty comment says nothing.".into())).await;
448
    }
449
    let e = |e| Error::from_db(shell.clone(), e);
450
    idb::add_comment(&app.db, i.id, Some(me.account.id), &me.account.username, f.body.trim()).await.map_err(e)?;
451
    let url = format!("/{owner}/{name}/issue/{number}");
452
    let mut kind = "issue.comment";
453
    let may_close = Some(me.account.id) == i.author_id || role >= Role::Triage;
454
    if f.close.is_some() && i.state == "open" && may_close {
455
        idb::set_state(&app.db, i.id, "closed", "completed").await.map_err(e)?;
456
        idb::add_event(&app.db, i.id, &me.account.username, "closed", "").await.ok();
457
        kind = "issue.closed";
458
    } else if f.reopen.is_some() && i.state == "closed" && may_close {
459
        idb::set_state(&app.db, i.id, "open", "").await.map_err(e)?;
460
        idb::add_event(&app.db, i.id, &me.account.username, "reopened", "").await.ok();
461
        kind = "issue.reopened";
462
    }
463
    reindex(&app, row.id, &i).await;
464
    let direct = i.author_id.into_iter().collect();
465
    notify::deliver(
466
        &app,
467
        Event {
468
            kind,
469
            title: format!("{owner}/{name}#{number}: {}", i.title),
470
            url,
471
            actor: &me.account,
472
            repo_id: row.id,
473
            thread: Some(("issue", i.id)),
474
            mention_text: &f.body,
475
            direct,
476
        },
477
    )
478
    .await;
479
    detail(&app, shell, &viewer, &owner, &name, number, None, None).await
480
}
481
482
pub async fn delete_comment(
483
    State(app): State<Shared>,
484
    viewer: Viewer,
485
    Path((owner, name, number, id)): Path<(String, String, i64, i64)>,
486
    Form(f): Form<super::session::CsrfForm>,
487
) -> Result<Page<IssuePage>, Error> {
488
    let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer);
489
    let me = require(&viewer, &shell)?;
490
    csrf(&viewer, &f.csrf, &shell)?;
491
    let (row, i, role) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?;
492
    let c = idb::comments(&app.db, i.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.into_iter().find(|c| c.id == id);
493
    if let Some(c) = c {
494
        if c.author_id == Some(me.account.id) || role >= Role::Triage {
495
            idb::delete_comment(&app.db, id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
496
            idb::add_event(&app.db, i.id, &me.account.username, "deleted a comment", "").await.ok();
497
            reindex(&app, row.id, &i).await;
498
        }
499
    }
500
    detail(&app, shell, &viewer, &owner, &name, number, None, None).await
501
}
502
503
#[derive(Deserialize)]
504
pub struct EditForm {
505
    #[serde(rename = "_csrf")]
506
    csrf_token: String,
507
    title: String,
508
    #[serde(default)]
509
    body: String,
510
}
511
512
pub async fn edit(
513
    State(app): State<Shared>,
514
    viewer: Viewer,
515
    Path((owner, name, number)): Path<(String, String, i64)>,
516
    Form(f): Form<EditForm>,
517
) -> Result<Page<IssuePage>, Error> {
518
    let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer);
519
    let me = require(&viewer, &shell)?;
520
    csrf(&viewer, &f.csrf_token, &shell)?;
521
    let (row, i, role) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?;
522
    if Some(me.account.id) != i.author_id && role < Role::Triage {
523
        return Err(Error::not_found(shell));
524
    }
525
    if f.title.trim().is_empty() {
526
        return detail(&app, shell, &viewer, &owner, &name, number, None, Some("An issue keeps a title.".into())).await;
527
    }
528
    idb::edit(&app.db, i.id, f.title.trim(), f.body.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))?;
529
    idb::add_event(&app.db, i.id, &me.account.username, "edited", "").await.ok();
530
    if let Ok(Some(fresh)) = idb::get(&app.db, row.id, number).await {
531
        reindex(&app, row.id, &fresh).await;
532
    }
533
    detail(&app, shell, &viewer, &owner, &name, number, Some("Saved.".into()), None).await
534
}
535
536
#[derive(Deserialize)]
537
pub struct StateForm {
538
    #[serde(rename = "_csrf")]
539
    csrf_token: String,
540
    action: String,
541
}
542
543
pub async fn state(
544
    State(app): State<Shared>,
545
    viewer: Viewer,
546
    Path((owner, name, number)): Path<(String, String, i64)>,
547
    Form(f): Form<StateForm>,
548
) -> Result<Page<IssuePage>, Error> {
549
    let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer);
550
    let me = require(&viewer, &shell)?;
551
    csrf(&viewer, &f.csrf_token, &shell)?;
552
    let (row, i, _) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?;
553
    let role = crate::auth::roles::role_for(&app.db, Some(&me.account), &row).await.map_err(|e| Error::from_db(shell.clone(), e))?;
554
    if !crate::auth::roles::allows(role, Access::Read) || (role < Some(Role::Triage) && Some(me.account.id) != i.author_id) {
555
        return Err(Error::not_found(shell));
556
    }
557
    let e = |e| Error::from_db(shell.clone(), e);
558
    let (event, kind) = match f.action.as_str() {
559
        "close" => {
560
            idb::set_state(&app.db, i.id, "closed", "completed").await.map_err(e)?;
561
            ("closed", "issue.closed")
562
        }
563
        "not_planned" => {
564
            idb::set_state(&app.db, i.id, "closed", "not_planned").await.map_err(e)?;
565
            ("closed as not planned", "issue.closed")
566
        }
567
        "reopen" => {
568
            idb::set_state(&app.db, i.id, "open", "").await.map_err(e)?;
569
            ("reopened", "issue.reopened")
570
        }
571
        "lock" if role >= Some(Role::Triage) => {
572
            idb::set_flags(&app.db, i.id, Some(true), None).await.map_err(e)?;
573
            ("locked", "")
574
        }
575
        "unlock" if role >= Some(Role::Triage) => {
576
            idb::set_flags(&app.db, i.id, Some(false), None).await.map_err(e)?;
577
            ("unlocked", "")
578
        }
579
        "pin" if role >= Some(Role::Triage) => {
580
            idb::set_flags(&app.db, i.id, None, Some(true)).await.map_err(e)?;
581
            ("pinned", "")
582
        }
583
        "unpin" if role >= Some(Role::Triage) => {
584
            idb::set_flags(&app.db, i.id, None, Some(false)).await.map_err(e)?;
585
            ("unpinned", "")
586
        }
587
        _ => return Err(Error::bad(shell, "unknown action")),
588
    };
589
    idb::add_event(&app.db, i.id, &me.account.username, event, "").await.ok();
590
    if !kind.is_empty() {
591
        let direct = i.author_id.into_iter().collect();
592
        notify::deliver(
593
            &app,
594
            Event {
595
                kind,
596
                title: format!("{owner}/{name}#{number}: {}", i.title),
597
                url: format!("/{owner}/{name}/issue/{number}"),
598
                actor: &me.account,
599
                repo_id: row.id,
600
                thread: Some(("issue", i.id)),
601
                mention_text: "",
602
                direct,
603
            },
604
        )
605
        .await;
606
    }
607
    detail(&app, shell, &viewer, &owner, &name, number, None, None).await
608
}
609
610
#[derive(Deserialize)]
611
pub struct LabelsForm {
612
    #[serde(rename = "_csrf")]
613
    csrf_token: String,
614
    #[serde(default)]
615
    label: Vec<i64>,
616
}
617
618
pub async fn set_labels(
619
    State(app): State<Shared>,
620
    viewer: Viewer,
621
    Path((owner, name, number)): Path<(String, String, i64)>,
622
    axum_extra::extract::Form(f): axum_extra::extract::Form<LabelsForm>,
623
) -> Result<Page<IssuePage>, Error> {
624
    let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer);
625
    let me = require(&viewer, &shell)?;
626
    csrf(&viewer, &f.csrf_token, &shell)?;
627
    let (row, i, _) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?;
628
    let role = crate::auth::roles::role_for(&app.db, Some(&me.account), &row).await.map_err(|e| Error::from_db(shell.clone(), e))?;
629
    if role < Some(Role::Triage) {
630
        return Err(Error::not_found(shell));
631
    }
632
    let e = |e| Error::from_db(shell.clone(), e);
633
    for l in idb::labels(&app.db, row.id).await.map_err(e)? {
634
        idb::set_issue_label(&app.db, i.id, l.id, f.label.contains(&l.id)).await.map_err(e)?;
635
    }
636
    idb::add_event(&app.db, i.id, &me.account.username, "set labels", "").await.ok();
637
    detail(&app, shell, &viewer, &owner, &name, number, None, None).await
638
}
639
640
#[derive(Deserialize)]
641
pub struct AssignForm {
642
    #[serde(rename = "_csrf")]
643
    csrf_token: String,
644
    username: String,
645
    action: String,
646
}
647
648
pub async fn assign(
649
    State(app): State<Shared>,
650
    viewer: Viewer,
651
    Path((owner, name, number)): Path<(String, String, i64)>,
652
    Form(f): Form<AssignForm>,
653
) -> Result<Page<IssuePage>, Error> {
654
    let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer);
655
    let me = require(&viewer, &shell)?;
656
    csrf(&viewer, &f.csrf_token, &shell)?;
657
    let (row, i, role) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?;
658
    if role < Role::Triage {
659
        return Err(Error::not_found(shell));
660
    }
661
    let Some(target) = accounts::account_by_name(&app.db, f.username.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))? else {
662
        return detail(&app, shell, &viewer, &owner, &name, number, None, Some("No such account.".into())).await;
663
    };
664
    let on = f.action == "add";
665
    idb::set_assignee(&app.db, i.id, target.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?;
666
    idb::add_event(&app.db, i.id, &me.account.username, if on { "assigned" } else { "unassigned" }, &target.username).await.ok();
667
    if on {
668
        notify::deliver(
669
            &app,
670
            Event {
671
                kind: "issue.assigned",
672
                title: format!("{owner}/{name}#{number}: {}", i.title),
673
                url: format!("/{owner}/{name}/issue/{number}"),
674
                actor: &me.account,
675
                repo_id: row.id,
676
                thread: None,
677
                mention_text: "",
678
                direct: vec![target.id],
679
            },
680
        )
681
        .await;
682
        ndb::subscribe(&app.db, target.id, "issue", i.id).await.ok();
683
    }
684
    detail(&app, shell, &viewer, &owner, &name, number, None, None).await
685
}
686
687
#[derive(Deserialize)]
688
pub struct MilestoneForm {
689
    #[serde(rename = "_csrf")]
690
    csrf_token: String,
691
    #[serde(default)]
692
    milestone: String,
693
}
694
695
pub async fn set_milestone(
696
    State(app): State<Shared>,
697
    viewer: Viewer,
698
    Path((owner, name, number)): Path<(String, String, i64)>,
699
    Form(f): Form<MilestoneForm>,
700
) -> Result<Page<IssuePage>, Error> {
701
    let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer);
702
    let me = require(&viewer, &shell)?;
703
    csrf(&viewer, &f.csrf_token, &shell)?;
704
    let (_, i, role) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?;
705
    if role < Role::Triage {
706
        return Err(Error::not_found(shell));
707
    }
708
    let id = f.milestone.trim().parse::<i64>().ok();
709
    idb::set_milestone(&app.db, i.id, id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
710
    idb::add_event(&app.db, i.id, &me.account.username, "set milestone", "").await.ok();
711
    detail(&app, shell, &viewer, &owner, &name, number, None, None).await
712
}
713
714
pub async fn subscribe(
715
    State(app): State<Shared>,
716
    viewer: Viewer,
717
    Path((owner, name, number)): Path<(String, String, i64)>,
718
    Form(f): Form<super::session::CsrfForm>,
719
) -> Result<Page<IssuePage>, Error> {
720
    let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer);
721
    let me = require(&viewer, &shell)?;
722
    csrf(&viewer, &f.csrf, &shell)?;
723
    let (_, i, _) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?;
724
    let e = |e| Error::from_db(shell.clone(), e);
725
    if ndb::subscribed(&app.db, me.account.id, "issue", i.id).await.map_err(e)? {
726
        ndb::unsubscribe(&app.db, me.account.id, "issue", i.id).await.map_err(e)?;
727
    } else {
728
        ndb::subscribe(&app.db, me.account.id, "issue", i.id).await.map_err(e)?;
729
    }
730
    detail(&app, shell, &viewer, &owner, &name, number, None, None).await
731
}
732
733
// ---------------------------------------------------------------- labels
734
735
#[derive(Template)]
736
#[template(path = "labels.html")]
737
pub struct LabelsPage {
738
    shell: Shell,
739
    repo: RepoShell,
740
    labels: Vec<idb::Label>,
741
    can_triage: bool,
742
    notice: Option<String>,
743
}
744
745
pub async fn labels(
746
    State(app): State<Shared>,
747
    viewer: Viewer,
748
    Path((owner, name)): Path<(String, String)>,
749
) -> Result<Page<LabelsPage>, Error> {
750
    let shell = Shell::new(&app, &format!("/{owner}/{name}/labels"), &viewer);
751
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
752
    let labels = idb::labels(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
753
    Ok(Page(LabelsPage {
754
        shell,
755
        repo: RepoShell::new(&app, &row, "issues").with_role(role),
756
        labels,
757
        can_triage: role >= Role::Triage,
758
        notice: None,
759
    }))
760
}
761
762
#[derive(Deserialize)]
763
pub struct LabelForm {
764
    #[serde(rename = "_csrf")]
765
    csrf_token: String,
766
    name: String,
767
    #[serde(default = "default_colour")]
768
    colour: String,
769
    #[serde(default)]
770
    description: String,
771
    #[serde(default)]
772
    delete: Option<String>,
773
}
774
775
fn default_colour() -> String {
776
    "#0072ff".into()
777
}
778
779
pub async fn save_label(
780
    State(app): State<Shared>,
781
    viewer: Viewer,
782
    Path((owner, name)): Path<(String, String)>,
783
    Form(f): Form<LabelForm>,
784
) -> Result<Page<LabelsPage>, Error> {
785
    let shell = Shell::new(&app, &format!("/{owner}/{name}/labels"), &viewer);
786
    require(&viewer, &shell)?;
787
    csrf(&viewer, &f.csrf_token, &shell)?;
788
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
789
    if role < Role::Triage {
790
        return Err(Error::not_found(shell));
791
    }
792
    let e = |e| Error::from_db(shell.clone(), e);
793
    let notice = if f.delete.is_some() {
794
        if let Some(l) = idb::label_by_name(&app.db, row.id, f.name.trim()).await.map_err(e)? {
795
            idb::delete_label(&app.db, l.id).await.map_err(e)?;
796
        }
797
        format!("Deleted {}.", f.name.trim())
798
    } else {
799
        let colour = if regex::Regex::new(r"^#[0-9a-fA-F]{6}$").unwrap().is_match(&f.colour) { f.colour.clone() } else { default_colour() };
800
        idb::upsert_label(&app.db, row.id, f.name.trim(), &colour, f.description.trim()).await.map_err(e)?;
801
        format!("Saved {}.", f.name.trim())
802
    };
803
    let labels = idb::labels(&app.db, row.id).await.map_err(e)?;
804
    Ok(Page(LabelsPage {
805
        shell,
806
        repo: RepoShell::new(&app, &row, "issues").with_role(role),
807
        labels,
808
        can_triage: true,
809
        notice: Some(notice),
810
    }))
811
}
812
813
// ------------------------------------------------------------ milestones
814
815
#[derive(Template)]
816
#[template(path = "milestones.html")]
817
pub struct MilestonesPage {
818
    shell: Shell,
819
    repo: RepoShell,
820
    rows: Vec<(idb::Milestone, i64, i64)>,
821
    can_triage: bool,
822
    notice: Option<String>,
823
}
824
825
async fn milestones_page(
826
    app: &Shared,
827
    shell: Shell,
828
    row: db::repos::RepoRow,
829
    role: Role,
830
    notice: Option<String>,
831
) -> Result<Page<MilestonesPage>, Error> {
832
    let mut rows = Vec::new();
833
    for m in idb::milestones(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))? {
834
        let (open, closed) = idb::milestone_counts(&app.db, m.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
835
        rows.push((m, open, closed));
836
    }
837
    Ok(Page(MilestonesPage {
838
        shell,
839
        repo: RepoShell::new(app, &row, "issues").with_role(role),
840
        rows,
841
        can_triage: role >= Role::Triage,
842
        notice,
843
    }))
844
}
845
846
pub async fn milestones(
847
    State(app): State<Shared>,
848
    viewer: Viewer,
849
    Path((owner, name)): Path<(String, String)>,
850
) -> Result<Page<MilestonesPage>, Error> {
851
    let shell = Shell::new(&app, &format!("/{owner}/{name}/milestones"), &viewer);
852
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
853
    milestones_page(&app, shell, row, role, None).await
854
}
855
856
#[derive(Deserialize)]
857
pub struct MilestoneSaveForm {
858
    #[serde(rename = "_csrf")]
859
    csrf_token: String,
860
    title: String,
861
    #[serde(default)]
862
    description: String,
863
    #[serde(default)]
864
    due: String,
865
    #[serde(default)]
866
    state: Option<String>,
867
}
868
869
pub async fn save_milestone(
870
    State(app): State<Shared>,
871
    viewer: Viewer,
872
    Path((owner, name)): Path<(String, String)>,
873
    Form(f): Form<MilestoneSaveForm>,
874
) -> Result<Page<MilestonesPage>, Error> {
875
    let shell = Shell::new(&app, &format!("/{owner}/{name}/milestones"), &viewer);
876
    require(&viewer, &shell)?;
877
    csrf(&viewer, &f.csrf_token, &shell)?;
878
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
879
    if role < Role::Triage {
880
        return Err(Error::not_found(shell));
881
    }
882
    let e = |e| Error::from_db(shell.clone(), e);
883
    if let Some(state) = f.state.as_deref() {
884
        if let Some(m) = idb::milestones(&app.db, row.id).await.map_err(e)?.into_iter().find(|m| m.title == f.title) {
885
            idb::set_milestone_state(&app.db, m.id, if state == "closed" { "closed" } else { "open" }).await.map_err(e)?;
886
        }
887
        return milestones_page(&app, shell, row, role, Some("Updated.".into())).await;
888
    }
889
    let due_ms = time::Date::parse(f.due.trim(), &time::macros::format_description!("[year]-[month]-[day]"))
890
        .ok()
891
        .map(|d| d.midnight().assume_utc().unix_timestamp() * 1000);
892
    idb::upsert_milestone(&app.db, row.id, f.title.trim(), f.description.trim(), due_ms).await.map_err(e)?;
893
    milestones_page(&app, shell, row, role, Some("Saved.".into())).await
894
}
895
896
// ----------------------------------------------------------------- watch
897
898
pub async fn watch(
899
    State(app): State<Shared>,
900
    viewer: Viewer,
901
    Path((owner, name)): Path<(String, String)>,
902
    Form(f): Form<super::session::CsrfForm>,
903
) -> Result<Response, Error> {
904
    let shell = Shell::new(&app, &format!("/{owner}/{name}"), &viewer);
905
    let me = require(&viewer, &shell)?;
906
    csrf(&viewer, &f.csrf, &shell)?;
907
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
908
    let on = !ndb::watching(&app.db, me.account.id, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
909
    ndb::set_watch(&app.db, me.account.id, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?;
910
    Ok(Redirect::to(&format!("/{owner}/{name}")).into_response())
911
}
912
1553bfc9 Andy McCall ↶ 913
/// Star or unstar a repository.
914
pub async fn star(
915
    State(app): State<Shared>,
916
    viewer: Viewer,
917
    Path((owner, name)): Path<(String, String)>,
918
    Form(f): Form<super::session::CsrfForm>,
919
) -> Result<Response, Error> {
920
    let shell = Shell::new(&app, &format!("/{owner}/{name}"), &viewer);
921
    let me = require(&viewer, &shell)?;
922
    csrf(&viewer, &f.csrf, &shell)?;
923
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
924
    db::repos::toggle_star(&app.db, me.account.id, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
925
    Ok(Redirect::to(&format!("/{owner}/{name}")).into_response())
926
}
927
78001759 Andy McCall ↶ 928
/// Put `#N` references of pushed changes on the timelines of those issues
929
/// and proposals: "referenced by change <hash> on <bookmark>".
930
pub async fn references_from_messages(app: &Shared, row: &db::repos::RepoRow, messages: &[(String, String)], bookmark: &str, actor: &str) {
931
    let Ok((issues, proposals)) = idb::numbers(&app.db, row.id).await else { return };
932
    for (hash, message) in messages {
933
        let detail = format!("{} on {bookmark}", &hash[..12.min(hash.len())]);
934
        for n in render::refs::references(message) {
935
            if issues.contains(&n) {
936
                if let Ok(Some(i)) = idb::get(&app.db, row.id, n).await {
937
                    idb::add_event(&app.db, i.id, actor, "referenced by change", &detail).await.ok();
938
                }
939
            } else if proposals.contains(&n) {
940
                if let Ok(Some(p)) = db::proposals::get(&app.db, row.id, n).await {
941
                    db::proposals::add_event(&app.db, p.id, actor, "referenced by change", &detail).await.ok();
942
                }
943
            }
944
        }
945
    }
946
}
947
18e381ea Andy McCall ↶ 948
/// Close issues a landed change's message names (`closes #12`), when the
949
/// landing reached the default bookmark. Called from the push hook and
950
/// the hub's own landing.
951
pub async fn close_from_messages(app: &Shared, row: &db::repos::RepoRow, messages: &[(String, String)]) {
952
    for (hash, message) in messages {
953
        for n in render::refs::closes(message) {
954
            if let Ok(Some(i)) = idb::get(&app.db, row.id, n).await {
955
                if i.state == "open" {
956
                    idb::set_state(&app.db, i.id, "closed", "completed").await.ok();
957
                    idb::add_event(&app.db, i.id, "", "closed by change", &hash[..12.min(hash.len())]).await.ok();
958
                    for id in ndb::subscribers(&app.db, "issue", i.id).await.unwrap_or_default() {
eb0c44a9 Andy McCall ↶ 959
                        let title = format!("{}#{}: {} (closed by {})", row.slug(), i.number, i.title, &hash[..12.min(hash.len())]);
960
                        let url = format!("/{}/issue/{}", row.slug(), i.number);
18e381ea Andy McCall ↶ 961
                        ndb::add(
962
                            &app.db,
eb0c44a9 Andy McCall ↶ 963
                            ndb::New {
964
                                account_id: id,
965
                                repo_id: Some(row.id),
966
                                reason: ndb::Reason::Participating,
967
                                kind: "issue.closed",
968
                                title: &title,
969
                                url: &url,
970
                                actor: "",
971
                            },
18e381ea Andy McCall ↶ 972
                        )
973
                        .await
974
                        .ok();
975
                    }
976
                }
977
            }
978
        }
979
    }
980
}