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