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