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