OXYGENHUB
# c96b32865000 oxygenhub / crates / hub / src / web / issues.rs
36.7 KiB · Rust · last changed Webhooks: Discord and Slack formats (Discord hooks switched over), a masked URL that wraps, the last delivery in the table, an edit form, and web issue events delivered 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}/raw/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
    crate::hooks::emit(
279
        &app,
280
        &row,
281
        "issue.opened",
282
        &me.account.username,
283
        serde_json::json!({ "issue": { "number": issue.number, "title": issue.title, "body": issue.body } }),
284
    )
285
    .await;
286
    Ok(Redirect::to(&url).into_response())
287
}
288
289
// ---------------------------------------------------------------- detail
290
291
pub enum Item {
292
    Comment(idb::IssueComment, String),
293
    Event(idb::IssueEvent),
294
}
295
296
#[derive(Template)]
297
#[template(path = "issue.html")]
298
pub struct IssuePage {
299
    shell: Shell,
300
    repo: RepoShell,
301
    i: idb::IssueRow,
302
    body_html: String,
303
    timeline: Vec<Item>,
304
    comments: Vec<idb::IssueComment>,
305
    labels: Vec<idb::Label>,
306
    all_labels: Vec<idb::Label>,
307
    assignees: Vec<String>,
308
    milestone: Option<idb::Milestone>,
309
    milestones: Vec<(idb::Milestone, bool)>,
310
    subscribed: bool,
311
    can_edit: bool,
312
    can_triage: bool,
313
    /// Projects this issue is on that the viewer may see, with the column.
314
    projects: Vec<(crate::db::projects::Project, String)>,
315
    notice: Option<String>,
316
    error: Option<String>,
317
}
318
319
/// The page assembler every action ends in.
320
#[allow(clippy::too_many_arguments)]
321
async fn detail(
322
    app: &Shared,
323
    shell: Shell,
324
    viewer: &Viewer,
325
    owner: &str,
326
    name: &str,
327
    number: i64,
328
    notice: Option<String>,
329
    error: Option<String>,
330
) -> Result<Page<IssuePage>, Error> {
331
    let (_, row, role) = lookup(app, &shell, viewer, owner, name).await?;
332
    let e = |e| Error::from_db(shell.clone(), e);
333
    let i = idb::get(&app.db, row.id, number).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?;
334
    let repo_url = format!("/{owner}/{name}");
335
    let body_html = markdown(app, row.id, &repo_url, &i.body).await;
336
    let comments = idb::comments(&app.db, i.id).await.map_err(e)?;
337
    let events = idb::events(&app.db, i.id).await.map_err(e)?;
338
    let mut timeline: Vec<(i64, Item)> = Vec::new();
339
    for c in &comments {
340
        timeline.push((c.created_ms, Item::Comment(c.clone(), markdown(app, row.id, &repo_url, &c.body).await)));
341
    }
342
    for ev in events {
343
        if ev.event != "opened" {
344
            timeline.push((ev.at_ms, Item::Event(ev)));
345
        }
346
    }
347
    timeline.sort_by_key(|(t, _)| *t);
348
    let labels = idb::issue_labels(&app.db, i.id).await.map_err(e)?;
349
    let all_labels = idb::labels(&app.db, row.id).await.map_err(e)?;
350
    let assignees = idb::assignees(&app.db, i.id).await.map_err(e)?;
351
    let milestones: Vec<(idb::Milestone, bool)> = idb::milestones(&app.db, row.id)
352
        .await
353
        .map_err(e)?
354
        .into_iter()
355
        .map(|m| {
356
            let sel = i.milestone_id == Some(m.id);
357
            (m, sel)
358
        })
359
        .collect();
360
    let milestone = match i.milestone_id {
361
        Some(id) => idb::milestone(&app.db, id).await.map_err(e)?,
362
        None => None,
363
    };
364
    let me = viewer.account();
365
    let subscribed = match me {
366
        Some(a) => ndb::subscribed(&app.db, a.id, "issue", i.id).await.map_err(e)?,
367
        None => false,
368
    };
369
    let can_triage = role >= Role::Triage;
370
    let can_edit = me.is_some_and(|a| Some(a.id) == i.author_id) || can_triage;
371
    let projects = super::projects::placements_for(app, viewer, &row, i.number).await;
372
    Ok(Page(IssuePage {
373
        shell,
374
        repo: RepoShell::new(app, &row, "issues").with_role(role),
375
        i,
376
        body_html,
377
        timeline: timeline.into_iter().map(|(_, it)| it).collect(),
378
        comments,
379
        labels,
380
        all_labels,
381
        assignees,
382
        milestone,
383
        milestones,
384
        subscribed,
385
        can_edit,
386
        can_triage,
387
        projects,
388
        notice,
389
        error,
390
    }))
391
}
392
393
pub async fn show(
394
    State(app): State<Shared>,
395
    viewer: Viewer,
396
    Path((owner, name, number)): Path<(String, String, i64)>,
397
) -> Result<Response, Error> {
398
    let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer);
399
    // A number that belongs to a proposal goes there (shared number space).
400
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
401
    if idb::get(&app.db, row.id, number).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_none()
402
        && db::proposals::get(&app.db, row.id, number).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some()
403
    {
404
        return Ok(Redirect::to(&format!("/{owner}/{name}/proposal/{number}")).into_response());
405
    }
406
    Ok(detail(&app, shell, &viewer, &owner, &name, number, None, None).await?.into_response())
407
}
408
409
// ---------------------------------------------------------------- actions
410
411
async fn issue_for(
412
    app: &Shared,
413
    shell: &Shell,
414
    viewer: &Viewer,
415
    owner: &str,
416
    name: &str,
417
    number: i64,
418
    access: Access,
419
) -> Result<(db::repos::RepoRow, idb::IssueRow, Role), Error> {
420
    let (_, row, role) = lookup_role(app, shell, viewer, owner, name, access).await?;
421
    let i = idb::get(&app.db, row.id, number)
422
        .await
423
        .map_err(|e| Error::from_db(shell.clone(), e))?
424
        .ok_or_else(|| Error::not_found(shell.clone()))?;
425
    Ok((row, i, role))
426
}
427
428
#[derive(Deserialize)]
429
pub struct CommentForm {
430
    #[serde(rename = "_csrf")]
431
    csrf_token: String,
432
    body: String,
433
    #[serde(default)]
434
    close: Option<String>,
435
    #[serde(default)]
436
    reopen: Option<String>,
437
}
438
439
pub async fn comment(
440
    State(app): State<Shared>,
441
    viewer: Viewer,
442
    Path((owner, name, number)): Path<(String, String, i64)>,
443
    Form(f): Form<CommentForm>,
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_token, &shell)?;
448
    let (row, i, role) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?;
449
    if i.locked && role < Role::Triage {
450
        return detail(&app, shell, &viewer, &owner, &name, number, None, Some("This issue is locked.".into())).await;
451
    }
452
    if f.body.trim().is_empty() {
453
        return detail(&app, shell, &viewer, &owner, &name, number, None, Some("An empty comment says nothing.".into())).await;
454
    }
455
    let e = |e| Error::from_db(shell.clone(), e);
456
    idb::add_comment(&app.db, i.id, Some(me.account.id), &me.account.username, f.body.trim()).await.map_err(e)?;
457
    let url = format!("/{owner}/{name}/issue/{number}");
458
    let mut kind = "issue.comment";
459
    let may_close = Some(me.account.id) == i.author_id || role >= Role::Triage;
460
    if f.close.is_some() && i.state == "open" && may_close {
461
        idb::set_state(&app.db, i.id, "closed", "completed").await.map_err(e)?;
462
        idb::add_event(&app.db, i.id, &me.account.username, "closed", "").await.ok();
463
        kind = "issue.closed";
464
    } else if f.reopen.is_some() && i.state == "closed" && may_close {
465
        idb::set_state(&app.db, i.id, "open", "").await.map_err(e)?;
466
        idb::add_event(&app.db, i.id, &me.account.username, "reopened", "").await.ok();
467
        kind = "issue.reopened";
468
    }
469
    reindex(&app, row.id, &i).await;
470
    let hook_event = if kind == "issue.comment" { "comment.issue" } else { kind };
471
    crate::hooks::emit(
472
        &app,
473
        &row,
474
        hook_event,
475
        &me.account.username,
476
        serde_json::json!({ "number": number, "issue": { "number": number, "title": i.title }, "comment": { "body": f.body.trim() } }),
477
    )
478
    .await;
479
    let direct = i.author_id.into_iter().collect();
480
    notify::deliver(
481
        &app,
482
        Event {
483
            kind,
484
            title: format!("{owner}/{name}#{number}: {}", i.title),
485
            url,
486
            actor: &me.account,
487
            repo_id: row.id,
488
            thread: Some(("issue", i.id)),
489
            mention_text: &f.body,
490
            direct,
491
        },
492
    )
493
    .await;
494
    detail(&app, shell, &viewer, &owner, &name, number, None, None).await
495
}
496
497
pub async fn delete_comment(
498
    State(app): State<Shared>,
499
    viewer: Viewer,
500
    Path((owner, name, number, id)): Path<(String, String, i64, i64)>,
501
    Form(f): Form<super::session::CsrfForm>,
502
) -> Result<Page<IssuePage>, Error> {
503
    let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer);
504
    let me = require(&viewer, &shell)?;
505
    csrf(&viewer, &f.csrf, &shell)?;
506
    let (row, i, role) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?;
507
    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);
508
    if let Some(c) = c {
509
        if c.author_id == Some(me.account.id) || role >= Role::Triage {
510
            idb::delete_comment(&app.db, id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
511
            idb::add_event(&app.db, i.id, &me.account.username, "deleted a comment", "").await.ok();
512
            reindex(&app, row.id, &i).await;
513
        }
514
    }
515
    detail(&app, shell, &viewer, &owner, &name, number, None, None).await
516
}
517
518
#[derive(Deserialize)]
519
pub struct EditForm {
520
    #[serde(rename = "_csrf")]
521
    csrf_token: String,
522
    title: String,
523
    #[serde(default)]
524
    body: String,
525
}
526
527
pub async fn edit(
528
    State(app): State<Shared>,
529
    viewer: Viewer,
530
    Path((owner, name, number)): Path<(String, String, i64)>,
531
    Form(f): Form<EditForm>,
532
) -> Result<Page<IssuePage>, Error> {
533
    let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer);
534
    let me = require(&viewer, &shell)?;
535
    csrf(&viewer, &f.csrf_token, &shell)?;
536
    let (row, i, role) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?;
537
    if Some(me.account.id) != i.author_id && role < Role::Triage {
538
        return Err(Error::not_found(shell));
539
    }
540
    if f.title.trim().is_empty() {
541
        return detail(&app, shell, &viewer, &owner, &name, number, None, Some("An issue keeps a title.".into())).await;
542
    }
543
    idb::edit(&app.db, i.id, f.title.trim(), f.body.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))?;
544
    idb::add_event(&app.db, i.id, &me.account.username, "edited", "").await.ok();
545
    if let Ok(Some(fresh)) = idb::get(&app.db, row.id, number).await {
546
        reindex(&app, row.id, &fresh).await;
547
    }
548
    detail(&app, shell, &viewer, &owner, &name, number, Some("Saved.".into()), None).await
549
}
550
551
#[derive(Deserialize)]
552
pub struct StateForm {
553
    #[serde(rename = "_csrf")]
554
    csrf_token: String,
555
    action: String,
556
}
557
558
pub async fn state(
559
    State(app): State<Shared>,
560
    viewer: Viewer,
561
    Path((owner, name, number)): Path<(String, String, i64)>,
562
    Form(f): Form<StateForm>,
563
) -> Result<Page<IssuePage>, Error> {
564
    let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer);
565
    let me = require(&viewer, &shell)?;
566
    csrf(&viewer, &f.csrf_token, &shell)?;
567
    let (row, i, _) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?;
568
    let role = crate::auth::roles::role_for(&app.db, Some(&me.account), &row).await.map_err(|e| Error::from_db(shell.clone(), e))?;
569
    if !crate::auth::roles::allows(role, Access::Read) || (role < Some(Role::Triage) && Some(me.account.id) != i.author_id) {
570
        return Err(Error::not_found(shell));
571
    }
572
    let e = |e| Error::from_db(shell.clone(), e);
573
    let (event, kind) = match f.action.as_str() {
574
        "close" => {
575
            idb::set_state(&app.db, i.id, "closed", "completed").await.map_err(e)?;
576
            ("closed", "issue.closed")
577
        }
578
        "not_planned" => {
579
            idb::set_state(&app.db, i.id, "closed", "not_planned").await.map_err(e)?;
580
            ("closed as not planned", "issue.closed")
581
        }
582
        "reopen" => {
583
            idb::set_state(&app.db, i.id, "open", "").await.map_err(e)?;
584
            ("reopened", "issue.reopened")
585
        }
586
        "lock" if role >= Some(Role::Triage) => {
587
            idb::set_flags(&app.db, i.id, Some(true), None).await.map_err(e)?;
588
            ("locked", "")
589
        }
590
        "unlock" if role >= Some(Role::Triage) => {
591
            idb::set_flags(&app.db, i.id, Some(false), None).await.map_err(e)?;
592
            ("unlocked", "")
593
        }
594
        "pin" if role >= Some(Role::Triage) => {
595
            idb::set_flags(&app.db, i.id, None, Some(true)).await.map_err(e)?;
596
            ("pinned", "")
597
        }
598
        "unpin" if role >= Some(Role::Triage) => {
599
            idb::set_flags(&app.db, i.id, None, Some(false)).await.map_err(e)?;
600
            ("unpinned", "")
601
        }
602
        _ => return Err(Error::bad(shell, "unknown action")),
603
    };
604
    idb::add_event(&app.db, i.id, &me.account.username, event, "").await.ok();
605
    if !kind.is_empty() {
606
        crate::hooks::emit(
607
            &app,
608
            &row,
609
            kind,
610
            &me.account.username,
611
            serde_json::json!({ "issue": { "number": number, "title": i.title }, "reason": event }),
612
        )
613
        .await;
614
        let direct = i.author_id.into_iter().collect();
615
        notify::deliver(
616
            &app,
617
            Event {
618
                kind,
619
                title: format!("{owner}/{name}#{number}: {}", i.title),
620
                url: format!("/{owner}/{name}/issue/{number}"),
621
                actor: &me.account,
622
                repo_id: row.id,
623
                thread: Some(("issue", i.id)),
624
                mention_text: "",
625
                direct,
626
            },
627
        )
628
        .await;
629
    }
630
    detail(&app, shell, &viewer, &owner, &name, number, None, None).await
631
}
632
633
#[derive(Deserialize)]
634
pub struct LabelsForm {
635
    #[serde(rename = "_csrf")]
636
    csrf_token: String,
637
    #[serde(default)]
638
    label: Vec<i64>,
639
}
640
641
pub async fn set_labels(
642
    State(app): State<Shared>,
643
    viewer: Viewer,
644
    Path((owner, name, number)): Path<(String, String, i64)>,
645
    axum_extra::extract::Form(f): axum_extra::extract::Form<LabelsForm>,
646
) -> Result<Page<IssuePage>, Error> {
647
    let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer);
648
    let me = require(&viewer, &shell)?;
649
    csrf(&viewer, &f.csrf_token, &shell)?;
650
    let (row, i, _) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?;
651
    let role = crate::auth::roles::role_for(&app.db, Some(&me.account), &row).await.map_err(|e| Error::from_db(shell.clone(), e))?;
652
    if role < Some(Role::Triage) {
653
        return Err(Error::not_found(shell));
654
    }
655
    let e = |e| Error::from_db(shell.clone(), e);
656
    for l in idb::labels(&app.db, row.id).await.map_err(e)? {
657
        idb::set_issue_label(&app.db, i.id, l.id, f.label.contains(&l.id)).await.map_err(e)?;
658
    }
659
    idb::add_event(&app.db, i.id, &me.account.username, "set labels", "").await.ok();
660
    detail(&app, shell, &viewer, &owner, &name, number, None, None).await
661
}
662
663
#[derive(Deserialize)]
664
pub struct AssignForm {
665
    #[serde(rename = "_csrf")]
666
    csrf_token: String,
667
    username: String,
668
    action: String,
669
}
670
671
pub async fn assign(
672
    State(app): State<Shared>,
673
    viewer: Viewer,
674
    Path((owner, name, number)): Path<(String, String, i64)>,
675
    Form(f): Form<AssignForm>,
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_token, &shell)?;
680
    let (row, i, role) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?;
681
    if role < Role::Triage {
682
        return Err(Error::not_found(shell));
683
    }
684
    let Some(target) = accounts::account_by_name(&app.db, f.username.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))? else {
685
        return detail(&app, shell, &viewer, &owner, &name, number, None, Some("No such account.".into())).await;
686
    };
687
    let on = f.action == "add";
688
    idb::set_assignee(&app.db, i.id, target.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?;
689
    idb::add_event(&app.db, i.id, &me.account.username, if on { "assigned" } else { "unassigned" }, &target.username).await.ok();
690
    if on {
691
        notify::deliver(
692
            &app,
693
            Event {
694
                kind: "issue.assigned",
695
                title: format!("{owner}/{name}#{number}: {}", i.title),
696
                url: format!("/{owner}/{name}/issue/{number}"),
697
                actor: &me.account,
698
                repo_id: row.id,
699
                thread: None,
700
                mention_text: "",
701
                direct: vec![target.id],
702
            },
703
        )
704
        .await;
705
        ndb::subscribe(&app.db, target.id, "issue", i.id).await.ok();
706
    }
707
    detail(&app, shell, &viewer, &owner, &name, number, None, None).await
708
}
709
710
#[derive(Deserialize)]
711
pub struct MilestoneForm {
712
    #[serde(rename = "_csrf")]
713
    csrf_token: String,
714
    #[serde(default)]
715
    milestone: String,
716
}
717
718
pub async fn set_milestone(
719
    State(app): State<Shared>,
720
    viewer: Viewer,
721
    Path((owner, name, number)): Path<(String, String, i64)>,
722
    Form(f): Form<MilestoneForm>,
723
) -> Result<Page<IssuePage>, Error> {
724
    let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer);
725
    let me = require(&viewer, &shell)?;
726
    csrf(&viewer, &f.csrf_token, &shell)?;
727
    let (_, i, role) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?;
728
    if role < Role::Triage {
729
        return Err(Error::not_found(shell));
730
    }
731
    let id = f.milestone.trim().parse::<i64>().ok();
732
    idb::set_milestone(&app.db, i.id, id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
733
    idb::add_event(&app.db, i.id, &me.account.username, "set milestone", "").await.ok();
734
    detail(&app, shell, &viewer, &owner, &name, number, None, None).await
735
}
736
737
pub async fn subscribe(
738
    State(app): State<Shared>,
739
    viewer: Viewer,
740
    Path((owner, name, number)): Path<(String, String, i64)>,
741
    Form(f): Form<super::session::CsrfForm>,
742
) -> Result<Page<IssuePage>, Error> {
743
    let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer);
744
    let me = require(&viewer, &shell)?;
745
    csrf(&viewer, &f.csrf, &shell)?;
746
    let (_, i, _) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?;
747
    let e = |e| Error::from_db(shell.clone(), e);
748
    if ndb::subscribed(&app.db, me.account.id, "issue", i.id).await.map_err(e)? {
749
        ndb::unsubscribe(&app.db, me.account.id, "issue", i.id).await.map_err(e)?;
750
    } else {
751
        ndb::subscribe(&app.db, me.account.id, "issue", i.id).await.map_err(e)?;
752
    }
753
    detail(&app, shell, &viewer, &owner, &name, number, None, None).await
754
}
755
756
// ---------------------------------------------------------------- labels
757
758
#[derive(Template)]
759
#[template(path = "labels.html")]
760
pub struct LabelsPage {
761
    shell: Shell,
762
    repo: RepoShell,
763
    labels: Vec<idb::Label>,
764
    can_triage: bool,
765
    notice: Option<String>,
766
}
767
768
pub async fn labels(
769
    State(app): State<Shared>,
770
    viewer: Viewer,
771
    Path((owner, name)): Path<(String, String)>,
772
) -> Result<Page<LabelsPage>, Error> {
773
    let shell = Shell::new(&app, &format!("/{owner}/{name}/labels"), &viewer);
774
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
775
    let labels = idb::labels(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
776
    Ok(Page(LabelsPage {
777
        shell,
778
        repo: RepoShell::new(&app, &row, "issues").with_role(role),
779
        labels,
780
        can_triage: role >= Role::Triage,
781
        notice: None,
782
    }))
783
}
784
785
#[derive(Deserialize)]
786
pub struct LabelForm {
787
    #[serde(rename = "_csrf")]
788
    csrf_token: String,
789
    name: String,
790
    #[serde(default = "default_colour")]
791
    colour: String,
792
    #[serde(default)]
793
    description: String,
794
    #[serde(default)]
795
    delete: Option<String>,
796
}
797
798
fn default_colour() -> String {
799
    "#0072ff".into()
800
}
801
802
pub async fn save_label(
803
    State(app): State<Shared>,
804
    viewer: Viewer,
805
    Path((owner, name)): Path<(String, String)>,
806
    Form(f): Form<LabelForm>,
807
) -> Result<Page<LabelsPage>, Error> {
808
    let shell = Shell::new(&app, &format!("/{owner}/{name}/labels"), &viewer);
809
    require(&viewer, &shell)?;
810
    csrf(&viewer, &f.csrf_token, &shell)?;
811
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
812
    if role < Role::Triage {
813
        return Err(Error::not_found(shell));
814
    }
815
    let e = |e| Error::from_db(shell.clone(), e);
816
    let notice = if f.delete.is_some() {
817
        if let Some(l) = idb::label_by_name(&app.db, row.id, f.name.trim()).await.map_err(e)? {
818
            idb::delete_label(&app.db, l.id).await.map_err(e)?;
819
        }
820
        format!("Deleted {}.", f.name.trim())
821
    } else {
822
        let colour = if regex::Regex::new(r"^#[0-9a-fA-F]{6}$").unwrap().is_match(&f.colour) { f.colour.clone() } else { default_colour() };
823
        idb::upsert_label(&app.db, row.id, f.name.trim(), &colour, f.description.trim()).await.map_err(e)?;
824
        format!("Saved {}.", f.name.trim())
825
    };
826
    let labels = idb::labels(&app.db, row.id).await.map_err(e)?;
827
    Ok(Page(LabelsPage {
828
        shell,
829
        repo: RepoShell::new(&app, &row, "issues").with_role(role),
830
        labels,
831
        can_triage: true,
832
        notice: Some(notice),
833
    }))
834
}
835
836
// ------------------------------------------------------------ milestones
837
838
#[derive(Template)]
839
#[template(path = "milestones.html")]
840
pub struct MilestonesPage {
841
    shell: Shell,
842
    repo: RepoShell,
843
    rows: Vec<(idb::Milestone, i64, i64)>,
844
    can_triage: bool,
845
    notice: Option<String>,
846
}
847
848
async fn milestones_page(
849
    app: &Shared,
850
    shell: Shell,
851
    row: db::repos::RepoRow,
852
    role: Role,
853
    notice: Option<String>,
854
) -> Result<Page<MilestonesPage>, Error> {
855
    let mut rows = Vec::new();
856
    for m in idb::milestones(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))? {
857
        let (open, closed) = idb::milestone_counts(&app.db, m.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
858
        rows.push((m, open, closed));
859
    }
860
    Ok(Page(MilestonesPage {
861
        shell,
862
        repo: RepoShell::new(app, &row, "issues").with_role(role),
863
        rows,
864
        can_triage: role >= Role::Triage,
865
        notice,
866
    }))
867
}
868
869
pub async fn milestones(
870
    State(app): State<Shared>,
871
    viewer: Viewer,
872
    Path((owner, name)): Path<(String, String)>,
873
) -> Result<Page<MilestonesPage>, Error> {
874
    let shell = Shell::new(&app, &format!("/{owner}/{name}/milestones"), &viewer);
875
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
876
    milestones_page(&app, shell, row, role, None).await
877
}
878
879
#[derive(Deserialize)]
880
pub struct MilestoneSaveForm {
881
    #[serde(rename = "_csrf")]
882
    csrf_token: String,
883
    title: String,
884
    #[serde(default)]
885
    description: String,
886
    #[serde(default)]
887
    due: String,
888
    #[serde(default)]
889
    state: Option<String>,
890
}
891
892
pub async fn save_milestone(
893
    State(app): State<Shared>,
894
    viewer: Viewer,
895
    Path((owner, name)): Path<(String, String)>,
896
    Form(f): Form<MilestoneSaveForm>,
897
) -> Result<Page<MilestonesPage>, Error> {
898
    let shell = Shell::new(&app, &format!("/{owner}/{name}/milestones"), &viewer);
899
    require(&viewer, &shell)?;
900
    csrf(&viewer, &f.csrf_token, &shell)?;
901
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
902
    if role < Role::Triage {
903
        return Err(Error::not_found(shell));
904
    }
905
    let e = |e| Error::from_db(shell.clone(), e);
906
    if let Some(state) = f.state.as_deref() {
907
        if let Some(m) = idb::milestones(&app.db, row.id).await.map_err(e)?.into_iter().find(|m| m.title == f.title) {
908
            idb::set_milestone_state(&app.db, m.id, if state == "closed" { "closed" } else { "open" }).await.map_err(e)?;
909
        }
910
        return milestones_page(&app, shell, row, role, Some("Updated.".into())).await;
911
    }
912
    let due_ms = time::Date::parse(f.due.trim(), &time::macros::format_description!("[year]-[month]-[day]"))
913
        .ok()
914
        .map(|d| d.midnight().assume_utc().unix_timestamp() * 1000);
915
    idb::upsert_milestone(&app.db, row.id, f.title.trim(), f.description.trim(), due_ms).await.map_err(e)?;
916
    milestones_page(&app, shell, row, role, Some("Saved.".into())).await
917
}
918
919
// ----------------------------------------------------------------- watch
920
921
pub async fn watch(
922
    State(app): State<Shared>,
923
    viewer: Viewer,
924
    Path((owner, name)): Path<(String, String)>,
925
    Form(f): Form<super::session::CsrfForm>,
926
) -> Result<Response, Error> {
927
    let shell = Shell::new(&app, &format!("/{owner}/{name}"), &viewer);
928
    let me = require(&viewer, &shell)?;
929
    csrf(&viewer, &f.csrf, &shell)?;
930
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
931
    let on = !ndb::watching(&app.db, me.account.id, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
932
    ndb::set_watch(&app.db, me.account.id, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?;
933
    Ok(Redirect::to(&format!("/{owner}/{name}")).into_response())
934
}
935
936
/// Star or unstar a repository.
937
pub async fn star(
938
    State(app): State<Shared>,
939
    viewer: Viewer,
940
    Path((owner, name)): Path<(String, String)>,
941
    Form(f): Form<super::session::CsrfForm>,
942
) -> Result<Response, Error> {
943
    let shell = Shell::new(&app, &format!("/{owner}/{name}"), &viewer);
944
    let me = require(&viewer, &shell)?;
945
    csrf(&viewer, &f.csrf, &shell)?;
946
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
947
    db::repos::toggle_star(&app.db, me.account.id, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
948
    Ok(Redirect::to(&format!("/{owner}/{name}")).into_response())
949
}
950
951
/// Close issues a landed change's message names (`closes #12`), when the
952
/// landing reached the default bookmark. Called from the push hook and
953
/// the hub's own landing.
954
pub async fn close_from_messages(app: &Shared, row: &db::repos::RepoRow, messages: &[(String, String)]) {
955
    for (hash, message) in messages {
956
        for n in render::refs::closes(message) {
957
            if let Ok(Some(i)) = idb::get(&app.db, row.id, n).await {
958
                if i.state == "open" {
959
                    idb::set_state(&app.db, i.id, "closed", "completed").await.ok();
960
                    idb::add_event(&app.db, i.id, "", "closed by change", &hash[..12.min(hash.len())]).await.ok();
961
                    for id in ndb::subscribers(&app.db, "issue", i.id).await.unwrap_or_default() {
962
                        let title = format!("{}#{}: {} (closed by {})", row.slug(), i.number, i.title, &hash[..12.min(hash.len())]);
963
                        let url = format!("/{}/issue/{}", row.slug(), i.number);
964
                        ndb::add(
965
                            &app.db,
966
                            ndb::New {
967
                                account_id: id,
968
                                repo_id: Some(row.id),
969
                                reason: ndb::Reason::Participating,
970
                                kind: "issue.closed",
971
                                title: &title,
972
                                url: &url,
973
                                actor: "",
974
                            },
975
                        )
976
                        .await
977
                        .ok();
978
                    }
979
                }
980
            }
981
        }
982
    }
983
}