OXYGENHUB

Coverage / crates/hub/src/web/issues.rs 82.8%

752 of 908 lines run, 87 of 260 functions · run #501 on main · run missed

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; relative
18
/// links and images resolve against `reference` (the default bookmark for
19
/// issues and proposals, the tag for release notes).
20140×
pub async fn markdown(app: &Shared, row: &db::repos::RepoRow, reference: &str, text: &str) -> String {
21140×
    let repo_url = format!("/{}", row.slug());
22140×
    let (issues, proposals) = idb::numbers(&app.db, row.id).await.unwrap_or_default();
23140×
    let linked = render::refs::link(text, &Numbers { repo_url: &repo_url, issues: &issues, proposals: &proposals });
24140×
    render::markdown::render(&linked, &format!("{repo_url}/file/{reference}/"), &format!("{repo_url}/raw/{reference}/"))
25140×
}
26
27
/// Re-index a thread for search: title, body and every comment.
2834×
pub async fn reindex(app: &Shared, repo_id: i64, issue: &idb::IssueRow) {
2934×
    let comments = idb::comments(&app.db, issue.id).await.unwrap_or_default();
3034×
    let text = std::iter::once(issue.body.clone()).chain(comments.into_iter().map(|c| c.body)).collect::<Vec<_>>().join("\n\n");
3134×
    ndb::index(&app.db, "issue", repo_id, issue.id, issue.number, &issue.title, &text).await.ok();
3234×
}
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),
53
    sort: String,
54
    pager: super::Pager,
55
}
56
57
#[derive(Deserialize)]
58
pub struct ListQuery {
59
    state: Option<String>,
60
    q: Option<String>,
61
    sort: Option<String>,
62
    page: Option<usize>,
63
}
64
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
70
/// `label:bug assignee:andy milestone:v1 state:closed words…`
71
struct Filter {
72
    state: String,
73
    labels: Vec<String>,
74
    assignees: Vec<String>,
75
    authors: Vec<String>,
76
    milestone: Option<String>,
77
    words: Vec<String>,
78
}
79
807×
fn parse_filter(state: Option<String>, q: &str) -> Filter {
817×
    let mut f = Filter {
827×
        state: state.unwrap_or_else(|| "open".into()),
837×
        labels: vec![],
847×
        assignees: vec![],
857×
        authors: vec![],
867×
        milestone: None,
877×
        words: vec![],
88
    };
897×
    for tok in q.split_whitespace() {
905×
        match tok.split_once(':') {
915×
            Some(("label", v)) => f.labels.push(v.to_lowercase()),
923×
            Some(("assignee", v)) => f.assignees.push(v.to_lowercase()),
932×
            Some(("author", v)) => f.authors.push(v.to_lowercase()),
941×
            Some(("milestone", v)) => f.milestone = Some(v.to_lowercase()),
950×
            Some(("state", v)) => f.state = v.to_string(),
960×
            _ => f.words.push(tok.to_lowercase()),
97
        }
98
    }
997×
    f
1007×
}
101
1027×
pub async fn list(
1037×
    State(app): State<Shared>,
1047×
    viewer: Viewer,
1057×
    Path((owner, name)): Path<(String, String)>,
1067×
    Query(q): Query<ListQuery>,
1077×
) -> Result<Page<IssuesPage>, Error> {
1087×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/issues"), &viewer);
1097×
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1107×
    let e = |e| Error::from_db(shell.clone(), e);
1117×
    let all = idb::list(&app.db, row.id).await.map_err(e)?;
112123×
    let counts = (all.iter().filter(|i| i.state == "open").count(), all.iter().filter(|i| i.state == "closed").count());
1137×
    let qtext = q.q.clone().unwrap_or_default();
1147×
    let f = parse_filter(q.state, &qtext);
1157×
    let mut labels_by: HashMap<i64, Vec<idb::Label>> = HashMap::new();
1167×
    for (id, l) in idb::labels_by_issue(&app.db, row.id).await.map_err(e)? {
1173×
        labels_by.entry(id).or_default().push(l);
1183×
    }
1197×
    let mut assignees_by: HashMap<i64, Vec<String>> = HashMap::new();
1207×
    for (id, u) in idb::assignees_by_issue(&app.db, row.id).await.map_err(e)? {
1213×
        assignees_by.entry(id).or_default().push(u);
1223×
    }
1237×
    let milestones: HashMap<i64, String> =
1247×
        idb::milestones(&app.db, row.id).await.map_err(e)?.into_iter().map(|m| (m.id, m.title)).collect();
1257×
    let mut rows = Vec::new();
126123×
    for i in all {
127123×
        let labels = labels_by.remove(&i.id).unwrap_or_default();
128123×
        let assignees = assignees_by.remove(&i.id).unwrap_or_default();
129123×
        let milestone = i.milestone_id.and_then(|m| milestones.get(&m).cloned());
130123×
        let keep = (f.state == "all" || i.state == f.state)
131123×
            && f.labels.iter().all(|l| labels.iter().any(|x| x.name.to_lowercase() == *l))
132122×
            && f.assignees.iter().all(|a| assignees.iter().any(|x| x.to_lowercase() == *a))
133122×
            && (f.authors.is_empty() || f.authors.contains(&i.author.to_lowercase()))
134102×
            && f.milestone.as_ref().is_none_or(|m| milestone.as_ref().is_some_and(|x| x.to_lowercase() == *m))
135102×
            && f.words.iter().all(|w| i.title.to_lowercase().contains(w) || i.body.to_lowercase().contains(w));
136123×
        if keep {
137102×
            let comments: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issue_comments WHERE issue_id = $1")
138102×
                .bind(i.id)
139102×
                .fetch_one(&app.db)
140102×
                .await
141102×
                .map_err(e)?;
142102×
            rows.push(IssueLine { issue: i, labels, assignees, milestone, comments });
14321×
        }
144
    }
1457×
    let sort = q.sort.filter(|s| SORTS.iter().any(|(k, _)| k == s)).unwrap_or_else(|| "newest".into());
1467×
    match sort.as_str() {
1477×
        "oldest" => rows.sort_by_key(|r| r.issue.number),
1486×
        "updated" => rows.sort_by_key(|r| std::cmp::Reverse(r.issue.updated_ms)),
1496×
        "comments" => rows.sort_by_key(|r| (std::cmp::Reverse(r.comments), std::cmp::Reverse(r.issue.number))),
1506×
        _ => {}
151
    }
1527×
    let pager = super::Pager::new(rows.len(), q.page, &[("state", &f.state), ("q", &qtext), ("sort", &sort)]);
1537×
    let rows = pager.slice(rows);
1547×
    Ok(Page(IssuesPage {
1557×
        shell,
1567×
        repo: RepoShell::new(&app, &row, "issues").with_role(role),
1577×
        rows,
1587×
        state: f.state,
1597×
        q: qtext,
1607×
        counts,
1617×
        sort,
1627×
        pager,
1637×
    }))
1647×
}
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.
1846×
async fn issue_templates(app: &Shared, repo: &crate::repos::RepoName, default: &str) -> Vec<(String, String)> {
1856×
    let default = default.to_string();
1866×
    app.repos
1876×
        .read(repo, move |r, cache| {
1886×
            let Ok(target) = view::resolve(r, cache, &default) else { return Ok(vec![]) };
1896×
            let loaded = cache.load(r, &target.heads)?;
1906×
            let mut out = Vec::new();
1916×
            for (path, f) in &loaded.snapshot.files {
1923×
                if let Some(rest) = path.strip_prefix(".oxyhub/ISSUE_TEMPLATE/") {
1930×
                    if let (Some(name), oxy_core::state::FileContent::Text { bytes, .. }) = (rest.strip_suffix(".md"), &f.content) {
1940×
                        out.push((name.to_string(), String::from_utf8_lossy(bytes).into_owned()));
1950×
                    }
1963×
                }
197
            }
1986×
            Ok(out)
1996×
        })
2006×
        .await
2016×
        .unwrap_or_default()
2026×
}
203
2046×
pub async fn new_form(
2056×
    State(app): State<Shared>,
2066×
    viewer: Viewer,
2076×
    Path((owner, name)): Path<(String, String)>,
2086×
    Query(q): Query<NewQuery>,
2096×
) -> Result<Page<NewIssuePage>, Error> {
2106×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/issues/new"), &viewer);
2116×
    require(&viewer, &shell)?;
2126×
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
2136×
    let templates = issue_templates(&app, &repo, &row.default_bookmark).await;
2146×
    let template = match q.template.as_deref() {
2150×
        Some("blank") => " ".into(),
2160×
        Some(t) => templates.iter().find(|(n, _)| n == t).map(|(_, b)| b.clone()).unwrap_or_default(),
2176×
        None if templates.is_empty() => " ".into(),
2180×
        None => String::new(),
219
    };
2206×
    Ok(Page(NewIssuePage { shell, repo: RepoShell::new(&app, &row, "issues").with_role(role), templates, template, error: None }))
2216×
}
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
2327×
pub async fn create(
2337×
    State(app): State<Shared>,
2347×
    viewer: Viewer,
2357×
    Path((owner, name)): Path<(String, String)>,
2367×
    Form(f): Form<NewIssueForm>,
2377×
) -> Result<Response, Error> {
2387×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/issues/new"), &viewer);
2397×
    let me = require(&viewer, &shell)?;
2407×
    csrf(&viewer, &f.csrf_token, &shell)?;
2417×
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
2427×
    super::writable(&row, &shell)?;
2436×
    if f.title.trim().is_empty() {
2440×
        return Ok(Page(NewIssuePage {
2450×
            shell,
2460×
            repo: RepoShell::new(&app, &row, "issues").with_role(role),
2470×
            templates: vec![],
2480×
            template: f.body,
2490×
            error: Some("Give the issue a title.".into()),
2500×
        })
2510×
        .into_response());
2526×
    }
2536×
    let issue = idb::create(
2546×
        &app.db,
2556×
        idb::NewIssue {
2566×
            repo_id: row.id,
2576×
            title: f.title.trim(),
2586×
            body: f.body.trim(),
2596×
            author_id: Some(me.account.id),
2606×
            author: &me.account.username,
2616×
        },
2626×
    )
2636×
    .await
2646×
    .map_err(|e| Error::from_db(shell.clone(), e))?;
2656×
    idb::add_event(&app.db, issue.id, &me.account.username, "opened", "").await.ok();
2666×
    reindex(&app, row.id, &issue).await;
2676×
    let url = format!("/{owner}/{name}/issue/{}", issue.number);
2686×
    notify::deliver(
2696×
        &app,
2706×
        Event {
2716×
            kind: "issue.opened",
2726×
            title: format!("{owner}/{name}#{}: {}", issue.number, issue.title),
2736×
            url: url.clone(),
2746×
            actor: &me.account,
2756×
            repo_id: row.id,
2766×
            thread: Some(("issue", issue.id)),
2776×
            mention_text: &f.body,
2786×
            direct: vec![],
2796×
        },
2806×
    )
2816×
    .await;
2826×
    crate::hooks::emit(
2836×
        &app,
2846×
        &row,
2856×
        "issue.opened",
2866×
        &me.account.username,
2876×
        serde_json::json!({ "issue": { "number": issue.number, "title": issue.title, "body": issue.body } }),
2886×
    )
2896×
    .await;
2906×
    Ok(Redirect::to(&url).into_response())
2917×
}
292
293
// ---------------------------------------------------------------- detail
294
295
pub enum Item {
296
    Comment(idb::IssueComment, String),
297
    Event(idb::IssueEvent),
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),
301
}
302
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.
30629×
fn group_references(timeline: Vec<(i64, Item)>) -> Vec<(i64, Item)> {
30729×
    let day = |ms: i64| ms.div_euclid(86_400_000);
30829×
    let mut out: Vec<(i64, Item)> = Vec::new();
30929×
    let mut run: Vec<idb::IssueEvent> = Vec::new();
310
    // Each change once, at its first reference (#318): before that was
311
    // recorded once, a change was recorded again for every bookmark.
31229×
    let mut seen = std::collections::HashSet::new();
313130×
    let flush = |run: &mut Vec<idb::IssueEvent>, out: &mut Vec<(i64, Item)>| match run.len() {
314123×
        0 => {}
3155×
        1 => {
3165×
            let e = run.remove(0);
3175×
            out.push((e.at_ms, Item::Event(e)));
3185×
        }
3192×
        _ => {
3202×
            let events = std::mem::take(run);
3212×
            let at = events[0].at_ms;
3222×
            out.push((at, Item::References(crate::render::time::day(at as u64), events, false)));
3232×
        }
324130×
    };
325112×
    for (at, item) in timeline {
32694×
        match item {
32794×
            Item::Event(e) if e.event == "referenced by change" => {
32813×
                if !seen.insert(e.detail.split(' ').next().unwrap_or("").to_string()) {
3292×
                    continue;
33011×
                }
33111×
                if run.last().is_some_and(|l| day(l.at_ms) != day(e.at_ms)) {
3322×
                    flush(&mut run, &mut out);
3339×
                }
33411×
                run.push(e);
335
            }
33699×
            other => {
33799×
                flush(&mut run, &mut out);
33899×
                out.push((at, other));
33999×
            }
340
        }
341
    }
34229×
    flush(&mut run, &mut out);
343104×
    if let Some((_, Item::References(_, _, open))) = out.iter_mut().rev().find(|(_, i)| matches!(i, Item::References(..))) {
3442×
        *open = true;
34527×
    }
34629×
    out
34729×
}
348
349
#[derive(Template)]
350
#[template(path = "issue.html")]
351
pub struct IssuePage {
352
    shell: Shell,
353
    repo: RepoShell,
354
    i: idb::IssueRow,
355
    body_html: String,
356
    timeline: Vec<Item>,
357
    comments: Vec<idb::IssueComment>,
358
    labels: Vec<idb::Label>,
359
    all_labels: Vec<idb::Label>,
360
    assignees: Vec<String>,
361
    /// Who it can be assigned to, for triagers' menu (oxygenhub#82).
362
    assignable: Vec<String>,
363
    milestone: Option<idb::Milestone>,
364
    milestones: Vec<(idb::Milestone, bool)>,
365
    subscribed: bool,
366
    can_edit: bool,
367
    can_triage: bool,
368
    /// Projects this issue is on that the viewer may see, with the column.
369
    projects: Vec<(crate::db::projects::Project, String)>,
370
    /// Proposals whose description closes it.
371
    closed_by: Vec<super::chain::Link>,
372
    /// Projects the viewer may add it to.
373
    project_choices: Option<Vec<crate::db::projects::Project>>,
374
    notice: Option<String>,
375
    error: Option<String>,
376
}
377
378
impl IssuePage {
379
    /// The issue's live topic (docs/live.md L5).
38029×
    pub fn live_topic(&self) -> String {
38129×
        crate::events::topic::issue(self.i.repo_id, self.i.number)
38229×
    }
383
}
384
385
/// The page assembler every action ends in.
386
#[allow(clippy::too_many_arguments)]
38729×
async fn detail(
38829×
    app: &Shared,
38929×
    shell: Shell,
39029×
    viewer: &Viewer,
39129×
    owner: &str,
39229×
    name: &str,
39329×
    number: i64,
39429×
    notice: Option<String>,
39529×
    error: Option<String>,
39629×
) -> Result<Page<IssuePage>, Error> {
39729×
    let (_, row, role) = lookup(app, &shell, viewer, owner, name).await?;
39829×
    let e = |e| Error::from_db(shell.clone(), e);
39929×
    let i = idb::get(&app.db, row.id, number).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?;
40029×
    let body_html = markdown(app, &row, &row.default_bookmark, &i.body).await;
40129×
    let comments = idb::comments(&app.db, i.id).await.map_err(e)?;
40229×
    let events = idb::events(&app.db, i.id).await.map_err(e)?;
40329×
    let mut timeline: Vec<(i64, Item)> = Vec::new();
40429×
    for c in &comments {
40518×
        timeline.push((c.created_ms, Item::Comment(c.clone(), markdown(app, &row, &row.default_bookmark, &c.body).await)));
406
    }
407117×
    for ev in events {
408117×
        if ev.event != "opened" {
40994×
            timeline.push((ev.at_ms, Item::Event(ev)));
41094×
        }
411
    }
41229×
    timeline.sort_by_key(|(t, _)| *t);
41329×
    let timeline = group_references(timeline);
41429×
    let labels = idb::issue_labels(&app.db, i.id).await.map_err(e)?;
41529×
    let all_labels = idb::labels(&app.db, row.id).await.map_err(e)?;
41629×
    let assignees = idb::assignees(&app.db, i.id).await.map_err(e)?;
41729×
    let milestones: Vec<(idb::Milestone, bool)> = idb::milestones(&app.db, row.id)
41829×
        .await
41929×
        .map_err(e)?
42029×
        .into_iter()
42129×
        .map(|m| {
42220×
            let sel = i.milestone_id == Some(m.id);
42320×
            (m, sel)
42420×
        })
42529×
        .collect();
42629×
    let milestone = match i.milestone_id {
42710×
        Some(id) => idb::milestone(&app.db, id).await.map_err(e)?,
42819×
        None => None,
429
    };
43029×
    let me = viewer.account();
43129×
    let subscribed = match me {
43217×
        Some(a) => ndb::subscribed(&app.db, a.id, "issue", i.id).await.map_err(e)?,
43312×
        None => false,
434
    };
43529×
    let can_triage = role >= Role::Triage;
43629×
    let can_edit = me.is_some_and(|a| Some(a.id) == i.author_id) || can_triage;
43729×
    let projects = super::projects::placements_for(app, viewer, &row, i.number).await;
43829×
    let closed_by = super::chain::proposals_closing(app, &row, i.number).await;
43929×
    let project_choices = super::projects::project_choices(app, viewer, &row, &projects).await;
44029×
    let mut assignable = if can_triage { accounts::assignable(&app.db, row.id, &row.owner).await.map_err(e)? } else { Vec::new() };
44129×
    for a in &assignees {
44214×
        if !assignable.iter().any(|x| x.eq_ignore_ascii_case(a)) {
44313×
            assignable.push(a.clone());
44413×
        }
445
    }
446
    Ok(Page(IssuePage {
44729×
        shell,
44829×
        repo: RepoShell::new(app, &row, "issues").with_role(role),
44929×
        i,
45029×
        closed_by,
45129×
        project_choices,
45229×
        body_html,
45329×
        timeline: timeline.into_iter().map(|(_, it)| it).collect(),
45429×
        comments,
45529×
        labels,
45629×
        all_labels,
45729×
        assignees,
45829×
        assignable,
45929×
        milestone,
46029×
        milestones,
46129×
        subscribed,
46229×
        can_edit,
46329×
        can_triage,
46429×
        projects,
46529×
        notice,
46629×
        error,
467
    }))
46829×
}
469
47018×
pub async fn show(
47118×
    State(app): State<Shared>,
47218×
    viewer: Viewer,
47318×
    Path((owner, name, number)): Path<(String, String, i64)>,
47418×
) -> Result<Response, Error> {
47518×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer);
476
    // A number that belongs to a proposal goes there (shared number space).
47718×
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
47818×
    if idb::get(&app.db, row.id, number).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_none()
4791×
        && db::proposals::get(&app.db, row.id, number).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some()
480
    {
4811×
        return Ok(Redirect::to(&format!("/{owner}/{name}/proposal/{number}")).into_response());
48217×
    }
48317×
    Ok(detail(&app, shell, &viewer, &owner, &name, number, None, None).await?.into_response())
48418×
}
485
486
// ---------------------------------------------------------------- actions
487
48813×
async fn issue_for(
48913×
    app: &Shared,
49013×
    shell: &Shell,
49113×
    viewer: &Viewer,
49213×
    owner: &str,
49313×
    name: &str,
49413×
    number: i64,
49513×
    access: Access,
49613×
) -> Result<(db::repos::RepoRow, idb::IssueRow, Role), Error> {
49713×
    let (_, row, role) = lookup_role(app, shell, viewer, owner, name, access).await?;
49813×
    let i = idb::get(&app.db, row.id, number)
49913×
        .await
50013×
        .map_err(|e| Error::from_db(shell.clone(), e))?
50113×
        .ok_or_else(|| Error::not_found(shell.clone()))?;
50213×
    Ok((row, i, role))
50313×
}
504
505
#[derive(Deserialize)]
506
pub struct CommentForm {
507
    #[serde(rename = "_csrf")]
508
    csrf_token: String,
509
    body: String,
510
    #[serde(default)]
511
    close: Option<String>,
512
    #[serde(default)]
513
    reopen: Option<String>,
514
}
515
5165×
pub async fn comment(
5175×
    State(app): State<Shared>,
5185×
    viewer: Viewer,
5195×
    Path((owner, name, number)): Path<(String, String, i64)>,
5205×
    Form(f): Form<CommentForm>,
5215×
) -> Result<Page<IssuePage>, Error> {
5225×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer);
5235×
    let me = require(&viewer, &shell)?;
5245×
    csrf(&viewer, &f.csrf_token, &shell)?;
5255×
    let (row, i, role) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?;
5265×
    if i.locked && role < Role::Triage {
5270×
        return detail(&app, shell, &viewer, &owner, &name, number, None, Some("This issue is locked.".into())).await;
5285×
    }
5295×
    if f.body.trim().is_empty() {
5300×
        return detail(&app, shell, &viewer, &owner, &name, number, None, Some("An empty comment says nothing.".into())).await;
5315×
    }
5325×
    let e = |e| Error::from_db(shell.clone(), e);
5335×
    idb::add_comment(&app.db, i.id, Some(me.account.id), &me.account.username, f.body.trim()).await.map_err(e)?;
5345×
    let url = format!("/{owner}/{name}/issue/{number}");
5355×
    let mut kind = "issue.comment";
5365×
    let may_close = Some(me.account.id) == i.author_id || role >= Role::Triage;
5375×
    if f.close.is_some() && i.state == "open" && may_close {
5381×
        idb::set_state(&app.db, i.id, "closed", "completed").await.map_err(e)?;
5391×
        idb::add_event(&app.db, i.id, &me.account.username, "closed", "").await.ok();
5401×
        kind = "issue.closed";
5414×
    } else if f.reopen.is_some() && i.state == "closed" && may_close {
5420×
        idb::set_state(&app.db, i.id, "open", "").await.map_err(e)?;
5430×
        idb::add_event(&app.db, i.id, &me.account.username, "reopened", "").await.ok();
5440×
        kind = "issue.reopened";
5454×
    }
5465×
    reindex(&app, row.id, &i).await;
5475×
    let hook_event = if kind == "issue.comment" { "comment.issue" } else { kind };
5485×
    crate::hooks::emit(
5495×
        &app,
5505×
        &row,
5515×
        hook_event,
5525×
        &me.account.username,
5535×
        serde_json::json!({ "number": number, "issue": { "number": number, "title": i.title }, "comment": { "body": f.body.trim() } }),
5545×
    )
5555×
    .await;
5565×
    let direct = i.author_id.into_iter().collect();
5575×
    notify::deliver(
5585×
        &app,
5595×
        Event {
5605×
            kind,
5615×
            title: format!("{owner}/{name}#{number}: {}", i.title),
5625×
            url,
5635×
            actor: &me.account,
5645×
            repo_id: row.id,
5655×
            thread: Some(("issue", i.id)),
5665×
            mention_text: &f.body,
5675×
            direct,
5685×
        },
5695×
    )
5705×
    .await;
5715×
    detail(&app, shell, &viewer, &owner, &name, number, None, None).await
5725×
}
573
5740×
pub async fn delete_comment(
5750×
    State(app): State<Shared>,
5760×
    viewer: Viewer,
5770×
    Path((owner, name, number, id)): Path<(String, String, i64, i64)>,
5780×
    Form(f): Form<super::session::CsrfForm>,
5790×
) -> Result<Page<IssuePage>, Error> {
5800×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer);
5810×
    let me = require(&viewer, &shell)?;
5820×
    csrf(&viewer, &f.csrf, &shell)?;
5830×
    let (row, i, role) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?;
5840×
    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);
5850×
    if let Some(c) = c {
5860×
        if c.author_id == Some(me.account.id) || role >= Role::Triage {
5870×
            idb::delete_comment(&app.db, id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
5880×
            idb::add_event(&app.db, i.id, &me.account.username, "deleted a comment", "").await.ok();
5890×
            reindex(&app, row.id, &i).await;
5900×
        }
5910×
    }
5920×
    detail(&app, shell, &viewer, &owner, &name, number, None, None).await
5930×
}
594
595
#[derive(Deserialize)]
596
pub struct EditForm {
597
    #[serde(rename = "_csrf")]
598
    csrf_token: String,
599
    title: String,
600
    #[serde(default)]
601
    body: String,
602
}
603
6040×
pub async fn edit(
6050×
    State(app): State<Shared>,
6060×
    viewer: Viewer,
6070×
    Path((owner, name, number)): Path<(String, String, i64)>,
6080×
    Form(f): Form<EditForm>,
6090×
) -> Result<Page<IssuePage>, Error> {
6100×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer);
6110×
    let me = require(&viewer, &shell)?;
6120×
    csrf(&viewer, &f.csrf_token, &shell)?;
6130×
    let (row, i, role) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?;
6140×
    if Some(me.account.id) != i.author_id && role < Role::Triage {
6150×
        return Err(Error::not_found(shell));
6160×
    }
6170×
    if f.title.trim().is_empty() {
6180×
        return detail(&app, shell, &viewer, &owner, &name, number, None, Some("An issue keeps a title.".into())).await;
6190×
    }
6200×
    idb::edit(&app.db, i.id, f.title.trim(), f.body.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))?;
6210×
    idb::add_event(&app.db, i.id, &me.account.username, "edited", "").await.ok();
6220×
    if let Ok(Some(fresh)) = idb::get(&app.db, row.id, number).await {
6230×
        reindex(&app, row.id, &fresh).await;
6240×
    }
6250×
    detail(&app, shell, &viewer, &owner, &name, number, Some("Saved.".into()), None).await
6260×
}
627
628
#[derive(Deserialize)]
629
pub struct StateForm {
630
    #[serde(rename = "_csrf")]
631
    csrf_token: String,
632
    action: String,
633
}
634
6353×
pub async fn state(
6363×
    State(app): State<Shared>,
6373×
    viewer: Viewer,
6383×
    Path((owner, name, number)): Path<(String, String, i64)>,
6393×
    Form(f): Form<StateForm>,
6403×
) -> Result<Page<IssuePage>, Error> {
6413×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer);
6423×
    let me = require(&viewer, &shell)?;
6433×
    csrf(&viewer, &f.csrf_token, &shell)?;
6443×
    let (row, i, _) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?;
6453×
    let role = crate::auth::roles::role_for(&app.db, Some(&me.account), &row).await.map_err(|e| Error::from_db(shell.clone(), e))?;
6463×
    if !crate::auth::roles::allows(role, Access::Read) || (role < Some(Role::Triage) && Some(me.account.id) != i.author_id) {
6471×
        return Err(Error::not_found(shell));
6482×
    }
6492×
    let e = |e| Error::from_db(shell.clone(), e);
6502×
    let (event, kind) = match f.action.as_str() {
6512×
        "close" => {
6521×
            idb::set_state(&app.db, i.id, "closed", "completed").await.map_err(e)?;
6531×
            ("closed", "issue.closed")
654
        }
6551×
        "not_planned" => {
6560×
            idb::set_state(&app.db, i.id, "closed", "not_planned").await.map_err(e)?;
6570×
            ("closed as not planned", "issue.closed")
658
        }
6591×
        "reopen" => {
6600×
            idb::set_state(&app.db, i.id, "open", "").await.map_err(e)?;
6610×
            ("reopened", "issue.reopened")
662
        }
6631×
        "lock" if role >= Some(Role::Triage) => {
6640×
            idb::set_flags(&app.db, i.id, Some(true), None).await.map_err(e)?;
6650×
            ("locked", "")
666
        }
6671×
        "unlock" if role >= Some(Role::Triage) => {
6680×
            idb::set_flags(&app.db, i.id, Some(false), None).await.map_err(e)?;
6690×
            ("unlocked", "")
670
        }
6711×
        "pin" if role >= Some(Role::Triage) => {
6721×
            idb::set_flags(&app.db, i.id, None, Some(true)).await.map_err(e)?;
6731×
            ("pinned", "")
674
        }
6750×
        "unpin" if role >= Some(Role::Triage) => {
6760×
            idb::set_flags(&app.db, i.id, None, Some(false)).await.map_err(e)?;
6770×
            ("unpinned", "")
678
        }
6790×
        _ => return Err(Error::bad(shell, "unknown action")),
680
    };
6812×
    idb::add_event(&app.db, i.id, &me.account.username, event, "").await.ok();
6822×
    if !kind.is_empty() {
6831×
        crate::hooks::emit(
6841×
            &app,
6851×
            &row,
6861×
            kind,
6871×
            &me.account.username,
6881×
            serde_json::json!({ "issue": { "number": number, "title": i.title }, "reason": event }),
6891×
        )
6901×
        .await;
6911×
        let direct = i.author_id.into_iter().collect();
6921×
        notify::deliver(
6931×
            &app,
6941×
            Event {
6951×
                kind,
6961×
                title: format!("{owner}/{name}#{number}: {}", i.title),
6971×
                url: format!("/{owner}/{name}/issue/{number}"),
6981×
                actor: &me.account,
6991×
                repo_id: row.id,
7001×
                thread: Some(("issue", i.id)),
7011×
                mention_text: "",
7021×
                direct,
7031×
            },
7041×
        )
7051×
        .await;
7061×
    }
7072×
    detail(&app, shell, &viewer, &owner, &name, number, None, None).await
7083×
}
709
710
#[derive(Deserialize)]
711
pub struct LabelsForm {
712
    #[serde(rename = "_csrf")]
713
    csrf_token: String,
714
    #[serde(default)]
715
    label: Vec<i64>,
716
}
717
7181×
pub async fn set_labels(
7191×
    State(app): State<Shared>,
7201×
    viewer: Viewer,
7211×
    Path((owner, name, number)): Path<(String, String, i64)>,
7221×
    axum_extra::extract::Form(f): axum_extra::extract::Form<LabelsForm>,
7231×
) -> Result<Page<IssuePage>, Error> {
7241×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer);
7251×
    let me = require(&viewer, &shell)?;
7261×
    csrf(&viewer, &f.csrf_token, &shell)?;
7271×
    let (row, i, _) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?;
7281×
    let role = crate::auth::roles::role_for(&app.db, Some(&me.account), &row).await.map_err(|e| Error::from_db(shell.clone(), e))?;
7291×
    if role < Some(Role::Triage) {
7300×
        return Err(Error::not_found(shell));
7311×
    }
7321×
    let e = |e| Error::from_db(shell.clone(), e);
7331×
    for l in idb::labels(&app.db, row.id).await.map_err(e)? {
7341×
        idb::set_issue_label(&app.db, i.id, l.id, f.label.contains(&l.id)).await.map_err(e)?;
735
    }
7361×
    idb::add_event(&app.db, i.id, &me.account.username, "set labels", "").await.ok();
7371×
    detail(&app, shell, &viewer, &owner, &name, number, None, None).await
7381×
}
739
740
#[derive(Deserialize)]
741
pub struct AssignForm {
742
    #[serde(rename = "_csrf")]
743
    csrf_token: String,
744
    /// One or more; with `set`, the whole list of assignees.
745
    #[serde(default)]
746
    username: Vec<String>,
747
    /// `add`, `remove`, or `set` (the menu: exactly these).
748
    action: String,
749
}
750
7513×
pub async fn assign(
7523×
    State(app): State<Shared>,
7533×
    viewer: Viewer,
7543×
    Path((owner, name, number)): Path<(String, String, i64)>,
7553×
    axum_extra::extract::Form(f): axum_extra::extract::Form<AssignForm>,
7563×
) -> Result<Page<IssuePage>, Error> {
7573×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer);
7583×
    let me = require(&viewer, &shell)?;
7593×
    csrf(&viewer, &f.csrf_token, &shell)?;
7603×
    let (row, i, role) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?;
7613×
    if role < Role::Triage {
7620×
        return Err(Error::not_found(shell));
7633×
    }
7643×
    let e = |e| Error::from_db(shell.clone(), e);
7653×
    let mut named = Vec::new();
7664×
    for n in f.username.iter().map(|n| n.trim()).filter(|n| !n.is_empty()) {
7674×
        match accounts::account_by_name(&app.db, n).await.map_err(e)? {
7684×
            Some(a) => named.push(a),
7690×
            None => return detail(&app, shell, &viewer, &owner, &name, number, None, Some(format!("No account {n}."))).await,
770
        }
771
    }
7723×
    let current = idb::assignees(&app.db, i.id).await.map_err(e)?;
7734×
    let has = |a: &accounts::Account| current.iter().any(|c| c.eq_ignore_ascii_case(&a.username));
774
    // What to add and to take off: one or more names, or (from the menu)
775
    // the whole list, compared with who is assigned now.
7763×
    let (add, remove): (Vec<&accounts::Account>, Vec<String>) = match f.action.as_str() {
7773×
        "add" => (named.iter().filter(|a| !has(a)).collect(), Vec::new()),
7782×
        "remove" => (Vec::new(), named.iter().filter(|a| has(a)).map(|a| a.username.clone()).collect()),
7792×
        "set" => (
7803×
            named.iter().filter(|a| !has(a)).collect(),
7814×
            current.iter().filter(|c| !named.iter().any(|a| a.username.eq_ignore_ascii_case(c))).cloned().collect(),
782
        ),
7830×
        _ => return Err(Error::bad(shell, "unknown action")),
784
    };
7853×
    for n in &remove {
7861×
        if let Some(a) = accounts::account_by_name(&app.db, n).await.map_err(e)? {
7871×
            idb::set_assignee(&app.db, i.id, a.id, false).await.map_err(e)?;
7881×
            idb::add_event(&app.db, i.id, &me.account.username, "unassigned", &a.username).await.ok();
7890×
        }
790
    }
7913×
    for target in add {
7922×
        idb::set_assignee(&app.db, i.id, target.id, true).await.map_err(e)?;
7932×
        idb::add_event(&app.db, i.id, &me.account.username, "assigned", &target.username).await.ok();
7942×
        notify::deliver(
7952×
            &app,
7962×
            Event {
7972×
                kind: "issue.assigned",
7982×
                title: format!("{owner}/{name}#{number}: {}", i.title),
7992×
                url: format!("/{owner}/{name}/issue/{number}"),
8002×
                actor: &me.account,
8012×
                repo_id: row.id,
8022×
                thread: None,
8032×
                mention_text: "",
8042×
                direct: vec![target.id],
8052×
            },
8062×
        )
8072×
        .await;
8082×
        ndb::subscribe(&app.db, target.id, "issue", i.id).await.ok();
809
    }
8103×
    detail(&app, shell, &viewer, &owner, &name, number, None, None).await
8113×
}
812
813
#[derive(Deserialize)]
814
pub struct MilestoneForm {
815
    #[serde(rename = "_csrf")]
816
    csrf_token: String,
817
    #[serde(default)]
818
    milestone: String,
819
}
820
8211×
pub async fn set_milestone(
8221×
    State(app): State<Shared>,
8231×
    viewer: Viewer,
8241×
    Path((owner, name, number)): Path<(String, String, i64)>,
8251×
    Form(f): Form<MilestoneForm>,
8261×
) -> Result<Page<IssuePage>, Error> {
8271×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer);
8281×
    let me = require(&viewer, &shell)?;
8291×
    csrf(&viewer, &f.csrf_token, &shell)?;
8301×
    let (_, i, role) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?;
8311×
    if role < Role::Triage {
8320×
        return Err(Error::not_found(shell));
8331×
    }
8341×
    let id = f.milestone.trim().parse::<i64>().ok();
8351×
    idb::set_milestone(&app.db, i.id, id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
8361×
    idb::add_event(&app.db, i.id, &me.account.username, "set milestone", "").await.ok();
8371×
    detail(&app, shell, &viewer, &owner, &name, number, None, None).await
8381×
}
839
8400×
pub async fn subscribe(
8410×
    State(app): State<Shared>,
8420×
    viewer: Viewer,
8430×
    Path((owner, name, number)): Path<(String, String, i64)>,
8440×
    Form(f): Form<super::session::CsrfForm>,
8450×
) -> Result<Page<IssuePage>, Error> {
8460×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer);
8470×
    let me = require(&viewer, &shell)?;
8480×
    csrf(&viewer, &f.csrf, &shell)?;
8490×
    let (_, i, _) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?;
8500×
    let e = |e| Error::from_db(shell.clone(), e);
8510×
    if ndb::subscribed(&app.db, me.account.id, "issue", i.id).await.map_err(e)? {
8520×
        ndb::unsubscribe(&app.db, me.account.id, "issue", i.id).await.map_err(e)?;
853
    } else {
8540×
        ndb::subscribe(&app.db, me.account.id, "issue", i.id).await.map_err(e)?;
855
    }
8560×
    detail(&app, shell, &viewer, &owner, &name, number, None, None).await
8570×
}
858
859
// ---------------------------------------------------------------- labels
860
861
#[derive(Template)]
862
#[template(path = "labels.html")]
863
pub struct LabelsPage {
864
    shell: Shell,
865
    repo: RepoShell,
866
    labels: Vec<idb::Label>,
867
    can_triage: bool,
868
    notice: Option<String>,
869
}
870
8713×
pub async fn labels(
8723×
    State(app): State<Shared>,
8733×
    viewer: Viewer,
8743×
    Path((owner, name)): Path<(String, String)>,
8753×
) -> Result<Page<LabelsPage>, Error> {
8763×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/labels"), &viewer);
8773×
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
8783×
    super::writable(&row, &shell)?;
8793×
    let labels = idb::labels(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
8803×
    Ok(Page(LabelsPage {
8813×
        shell,
8823×
        repo: RepoShell::new(&app, &row, "issues").with_role(role),
8833×
        labels,
8843×
        can_triage: role >= Role::Triage,
8853×
        notice: None,
8863×
    }))
8873×
}
888
889
#[derive(Deserialize)]
890
pub struct LabelForm {
891
    #[serde(rename = "_csrf")]
892
    csrf_token: String,
893
    name: String,
894
    #[serde(default = "default_colour")]
895
    colour: String,
896
    #[serde(default)]
897
    description: String,
898
    #[serde(default)]
899
    delete: Option<String>,
900
}
901
9020×
fn default_colour() -> String {
9030×
    "#0072ff".into()
9040×
}
905
9061×
pub async fn save_label(
9071×
    State(app): State<Shared>,
9081×
    viewer: Viewer,
9091×
    Path((owner, name)): Path<(String, String)>,
9101×
    Form(f): Form<LabelForm>,
9111×
) -> Result<Page<LabelsPage>, Error> {
9121×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/labels"), &viewer);
9131×
    require(&viewer, &shell)?;
9141×
    csrf(&viewer, &f.csrf_token, &shell)?;
9151×
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
9161×
    if role < Role::Triage {
9170×
        return Err(Error::not_found(shell));
9181×
    }
9191×
    let e = |e| Error::from_db(shell.clone(), e);
9201×
    let notice = if f.delete.is_some() {
9210×
        if let Some(l) = idb::label_by_name(&app.db, row.id, f.name.trim()).await.map_err(e)? {
9220×
            idb::delete_label(&app.db, l.id).await.map_err(e)?;
9230×
        }
9240×
        format!("Deleted {}.", f.name.trim())
925
    } else {
9261×
        let colour = if regex::Regex::new(r"^#[0-9a-fA-F]{6}$").unwrap().is_match(&f.colour) { f.colour.clone() } else { default_colour() };
9271×
        idb::upsert_label(&app.db, row.id, f.name.trim(), &colour, f.description.trim()).await.map_err(e)?;
9281×
        format!("Saved {}.", f.name.trim())
929
    };
9301×
    let labels = idb::labels(&app.db, row.id).await.map_err(e)?;
9311×
    Ok(Page(LabelsPage {
9321×
        shell,
9331×
        repo: RepoShell::new(&app, &row, "issues").with_role(role),
9341×
        labels,
9351×
        can_triage: true,
9361×
        notice: Some(notice),
9371×
    }))
9381×
}
939
940
// ------------------------------------------------------------ milestones
941
942
#[derive(Template)]
943
#[template(path = "milestones.html")]
944
pub struct MilestonesPage {
945
    shell: Shell,
946
    repo: RepoShell,
947
    rows: Vec<(idb::Milestone, i64, i64)>,
948
    can_triage: bool,
949
    notice: Option<String>,
950
}
951
9522×
async fn milestones_page(
9532×
    app: &Shared,
9542×
    shell: Shell,
9552×
    row: db::repos::RepoRow,
9562×
    role: Role,
9572×
    notice: Option<String>,
9582×
) -> Result<Page<MilestonesPage>, Error> {
9592×
    let mut rows = Vec::new();
9602×
    for m in idb::milestones(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))? {
9612×
        let (open, closed) = idb::milestone_counts(&app.db, m.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
9622×
        rows.push((m, open, closed));
963
    }
9642×
    Ok(Page(MilestonesPage {
9652×
        shell,
9662×
        repo: RepoShell::new(app, &row, "issues").with_role(role),
9672×
        rows,
9682×
        can_triage: role >= Role::Triage,
9692×
        notice,
9702×
    }))
9712×
}
972
9731×
pub async fn milestones(
9741×
    State(app): State<Shared>,
9751×
    viewer: Viewer,
9761×
    Path((owner, name)): Path<(String, String)>,
9771×
) -> Result<Page<MilestonesPage>, Error> {
9781×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/milestones"), &viewer);
9791×
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
9801×
    milestones_page(&app, shell, row, role, None).await
9811×
}
982
983
#[derive(Deserialize)]
984
pub struct MilestoneSaveForm {
985
    #[serde(rename = "_csrf")]
986
    csrf_token: String,
987
    title: String,
988
    #[serde(default)]
989
    description: String,
990
    #[serde(default)]
991
    due: String,
992
    #[serde(default)]
993
    state: Option<String>,
994
}
995
9961×
pub async fn save_milestone(
9971×
    State(app): State<Shared>,
9981×
    viewer: Viewer,
9991×
    Path((owner, name)): Path<(String, String)>,
10001×
    Form(f): Form<MilestoneSaveForm>,
10011×
) -> Result<Page<MilestonesPage>, Error> {
10021×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/milestones"), &viewer);
10031×
    require(&viewer, &shell)?;
10041×
    csrf(&viewer, &f.csrf_token, &shell)?;
10051×
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
10061×
    if role < Role::Triage {
10070×
        return Err(Error::not_found(shell));
10081×
    }
10091×
    let e = |e| Error::from_db(shell.clone(), e);
10101×
    if let Some(state) = f.state.as_deref() {
10110×
        if let Some(m) = idb::milestones(&app.db, row.id).await.map_err(e)?.into_iter().find(|m| m.title == f.title) {
10120×
            idb::set_milestone_state(&app.db, m.id, if state == "closed" { "closed" } else { "open" }).await.map_err(e)?;
10130×
        }
10140×
        return milestones_page(&app, shell, row, role, Some("Updated.".into())).await;
10151×
    }
10161×
    let due_ms = time::Date::parse(f.due.trim(), &time::macros::format_description!("[year]-[month]-[day]"))
10171×
        .ok()
10181×
        .map(|d| d.midnight().assume_utc().unix_timestamp() * 1000);
10191×
    idb::upsert_milestone(&app.db, row.id, f.title.trim(), f.description.trim(), due_ms).await.map_err(e)?;
10201×
    milestones_page(&app, shell, row, role, Some("Saved.".into())).await
10211×
}
1022
1023
// ----------------------------------------------------------------- watch
1024
10253×
pub async fn watch(
10263×
    State(app): State<Shared>,
10273×
    viewer: Viewer,
10283×
    Path((owner, name)): Path<(String, String)>,
10293×
    Form(f): Form<super::session::CsrfForm>,
10303×
) -> Result<Response, Error> {
10313×
    let shell = Shell::new(&app, &format!("/{owner}/{name}"), &viewer);
10323×
    let me = require(&viewer, &shell)?;
10333×
    csrf(&viewer, &f.csrf, &shell)?;
10343×
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
10353×
    let on = !ndb::watching(&app.db, me.account.id, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
10363×
    ndb::set_watch(&app.db, me.account.id, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?;
10373×
    Ok(Redirect::to(&format!("/{owner}/{name}")).into_response())
10383×
}
1039
1040
/// Star or unstar a repository.
10415×
pub async fn star(
10425×
    State(app): State<Shared>,
10435×
    viewer: Viewer,
10445×
    Path((owner, name)): Path<(String, String)>,
10455×
    Form(f): Form<super::session::CsrfForm>,
10465×
) -> Result<Response, Error> {
10475×
    let shell = Shell::new(&app, &format!("/{owner}/{name}"), &viewer);
10485×
    let me = require(&viewer, &shell)?;
10495×
    csrf(&viewer, &f.csrf, &shell)?;
10505×
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
10515×
    db::repos::toggle_star(&app.db, me.account.id, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
10525×
    Ok(Redirect::to(&format!("/{owner}/{name}")).into_response())
10535×
}
1054
1055
/// Put `#N` references of pushed changes on the timelines of those issues
1056
/// and proposals: "referenced by change <hash> on <bookmark>".
1057242×
pub async fn references_from_messages(app: &Shared, row: &db::repos::RepoRow, messages: &[(String, String)], bookmark: &str, actor: &str) {
1058242×
    let Ok((issues, proposals)) = idb::numbers(&app.db, row.id).await else { return };
1059241×
    for (hash, message) in messages {
1060234×
        let short = &hash[..12.min(hash.len())];
1061234×
        let detail = format!("{short} on {bookmark}");
1062
        // Once per change (#318): not again for a second mention, nor when
1063
        // the change is pushed on another bookmark or landed.
1064234×
        let mut named = render::refs::references(message);
1065234×
        named.sort_unstable();
1066234×
        named.dedup();
1067234×
        for n in named {
10682×
            if issues.contains(&n) {
10691×
                if let Ok(Some(i)) = idb::get(&app.db, row.id, n).await {
10701×
                    if !idb::referenced_by(&app.db, i.id, short).await.unwrap_or(false) {
10711×
                        idb::add_event(&app.db, i.id, actor, "referenced by change", &detail).await.ok();
10720×
                    }
10730×
                }
10741×
            } else if proposals.contains(&n) {
10751×
                if let Ok(Some(p)) = db::proposals::get(&app.db, row.id, n).await {
10761×
                    if !db::proposals::referenced_by(&app.db, p.id, short).await.unwrap_or(false) {
10771×
                        db::proposals::add_event(&app.db, p.id, actor, "referenced by change", &detail).await.ok();
10780×
                    }
10790×
                }
10800×
            }
1081
        }
1082
    }
1083241×
}
1084
1085
/// Close issues a landed change's message names (`closes #12`), when the
1086
/// landing reached the default bookmark. Called from the push hook and
1087
/// the hub's own landing.
1088
/// Close the issues that the changes newly on the default bookmark say
1089
/// they close: those in `new_heads`'s state and not in `old_heads`'s,
1090
/// however they got there (a push, a landing on the hub, a client's
1091
/// `oxy proposal land` of changes the hub already held). `extra` adds
1092
/// text that closes as well, such as a landed proposal's description.
1093224×
pub async fn close_from_arrived(
1094224×
    app: &Shared,
1095224×
    row: &db::repos::RepoRow,
1096224×
    old_heads: Vec<oxy_core::hash::ChangeHash>,
1097224×
    new_heads: Vec<oxy_core::hash::ChangeHash>,
1098224×
    extra: Option<(String, String)>,
1099224×
) {
1100224×
    let Some(name) = crate::repos::RepoName::new(&row.owner, &row.name) else { return };
1101224×
    let mut messages: Vec<(String, String)> = app
1102224×
        .repos
1103224×
        .read(&name, move |r, cache| {
1104224×
            let new = cache.summary(r, &new_heads)?;
1105224×
            let old = if old_heads.is_empty() { None } else { cache.summary(r, &old_heads).ok() };
1106224×
            Ok(new
1107224×
                .changes
1108224×
                .iter()
1109441×
                .filter(|h| !old.as_ref().is_some_and(|o| o.changes.contains(*h)))
1110228×
                .filter_map(|h| r.store().get_change_meta(h).ok().map(|c| (h.to_hex(), c.message)))
1111224×
                .collect())
1112224×
        })
1113224×
        .await
1114224×
        .unwrap_or_default();
1115224×
    messages.extend(extra);
1116224×
    close_from_messages(app, row, &messages).await;
1117224×
}
1118
1119290×
pub async fn close_from_messages(app: &Shared, row: &db::repos::RepoRow, messages: &[(String, String)]) {
1120290×
    for (hash, message) in messages {
1121276×
        for n in render::refs::closes(message) {
11225×
            if let Ok(Some(i)) = idb::get(&app.db, row.id, n).await {
11235×
                if i.state == "open" {
11244×
                    idb::set_state(&app.db, i.id, "closed", "completed").await.ok();
11254×
                    idb::add_event(&app.db, i.id, "", "closed by change", &hash[..12.min(hash.len())]).await.ok();
11264×
                    for id in ndb::subscribers(&app.db, "issue", i.id).await.unwrap_or_default() {
11274×
                        let title = format!("{}#{}: {} (closed by {})", row.slug(), i.number, i.title, &hash[..12.min(hash.len())]);
11284×
                        let url = format!("/{}/issue/{}", row.slug(), i.number);
11294×
                        ndb::add(
11304×
                            &app.db,
11314×
                            ndb::New {
11324×
                                account_id: id,
11334×
                                repo_id: Some(row.id),
11344×
                                reason: ndb::Reason::Participating,
11354×
                                kind: "issue.closed",
11364×
                                title: &title,
11374×
                                url: &url,
11384×
                                actor: "",
11394×
                            },
11404×
                        )
11414×
                        .await
11424×
                        .ok();
1143
                    }
11441×
                }
11450×
            }
1146
        }
1147
    }
1148290×
}