//! Issues, labels and milestones (hub spec §8). use super::settings::{csrf, require}; use super::{filters, lookup, lookup_role, Error, Page, RepoShell, Shell}; use crate::auth::{Access, Role, Viewer}; use crate::db::{self, accounts, issues as idb, notify as ndb}; use crate::notify::{self, Event}; use crate::render::{self, refs::Numbers}; use crate::repos::view; use crate::Shared; use askama::Template; use axum::extract::{Form, Path, Query, State}; use axum::response::{IntoResponse, Redirect, Response}; use serde::Deserialize; use std::collections::HashMap; /// Markdown with cross references linked, for a repository. pub async fn markdown(app: &Shared, repo_id: i64, repo_url: &str, text: &str) -> String { let (issues, proposals) = idb::numbers(&app.db, repo_id).await.unwrap_or_default(); let linked = render::refs::link(text, &Numbers { repo_url, issues: &issues, proposals: &proposals }); render::markdown::render(&linked, &format!("{repo_url}/file/main/"), &format!("{repo_url}/file/main/")) } /// Re-index a thread for search: title, body and every comment. pub async fn reindex(app: &Shared, repo_id: i64, issue: &idb::IssueRow) { let comments = idb::comments(&app.db, issue.id).await.unwrap_or_default(); let text = std::iter::once(issue.body.clone()).chain(comments.into_iter().map(|c| c.body)).collect::>().join("\n\n"); ndb::index(&app.db, "issue", repo_id, issue.id, issue.number, &issue.title, &text).await.ok(); } // ------------------------------------------------------------------ list pub struct IssueLine { pub issue: idb::IssueRow, pub labels: Vec, pub assignees: Vec, pub milestone: Option, pub comments: i64, } #[derive(Template)] #[template(path = "issues.html")] pub struct IssuesPage { shell: Shell, repo: RepoShell, rows: Vec, state: String, q: String, counts: (usize, usize), sort: String, pager: super::Pager, } #[derive(Deserialize)] pub struct ListQuery { state: Option, q: Option, sort: Option, page: Option, } /// The list's sort orders: `newest` (pinned first, the default), /// `oldest`, `updated`, `comments`. pub const SORTS: &[(&str, &str)] = &[("newest", "newest"), ("oldest", "oldest"), ("updated", "recently updated"), ("comments", "most comments")]; /// `label:bug assignee:andy milestone:v1 state:closed words…` struct Filter { state: String, labels: Vec, assignees: Vec, authors: Vec, milestone: Option, words: Vec, } fn parse_filter(state: Option, q: &str) -> Filter { let mut f = Filter { state: state.unwrap_or_else(|| "open".into()), labels: vec![], assignees: vec![], authors: vec![], milestone: None, words: vec![], }; for tok in q.split_whitespace() { match tok.split_once(':') { Some(("label", v)) => f.labels.push(v.to_lowercase()), Some(("assignee", v)) => f.assignees.push(v.to_lowercase()), Some(("author", v)) => f.authors.push(v.to_lowercase()), Some(("milestone", v)) => f.milestone = Some(v.to_lowercase()), Some(("state", v)) => f.state = v.to_string(), _ => f.words.push(tok.to_lowercase()), } } f } pub async fn list( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Query(q): Query, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/issues"), &viewer); let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; let e = |e| Error::from_db(shell.clone(), e); let all = idb::list(&app.db, row.id).await.map_err(e)?; let counts = (all.iter().filter(|i| i.state == "open").count(), all.iter().filter(|i| i.state == "closed").count()); let qtext = q.q.clone().unwrap_or_default(); let f = parse_filter(q.state, &qtext); let mut labels_by: HashMap> = HashMap::new(); for (id, l) in idb::labels_by_issue(&app.db, row.id).await.map_err(e)? { labels_by.entry(id).or_default().push(l); } let mut assignees_by: HashMap> = HashMap::new(); for (id, u) in idb::assignees_by_issue(&app.db, row.id).await.map_err(e)? { assignees_by.entry(id).or_default().push(u); } let milestones: HashMap = idb::milestones(&app.db, row.id).await.map_err(e)?.into_iter().map(|m| (m.id, m.title)).collect(); let mut rows = Vec::new(); for i in all { let labels = labels_by.remove(&i.id).unwrap_or_default(); let assignees = assignees_by.remove(&i.id).unwrap_or_default(); let milestone = i.milestone_id.and_then(|m| milestones.get(&m).cloned()); let keep = (f.state == "all" || i.state == f.state) && f.labels.iter().all(|l| labels.iter().any(|x| x.name.to_lowercase() == *l)) && f.assignees.iter().all(|a| assignees.iter().any(|x| x.to_lowercase() == *a)) && (f.authors.is_empty() || f.authors.contains(&i.author.to_lowercase())) && f.milestone.as_ref().is_none_or(|m| milestone.as_ref().is_some_and(|x| x.to_lowercase() == *m)) && f.words.iter().all(|w| i.title.to_lowercase().contains(w) || i.body.to_lowercase().contains(w)); if keep { let comments: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issue_comments WHERE issue_id = ?") .bind(i.id) .fetch_one(&app.db) .await .map_err(e)?; rows.push(IssueLine { issue: i, labels, assignees, milestone, comments }); } } let sort = q.sort.filter(|s| SORTS.iter().any(|(k, _)| k == s)).unwrap_or_else(|| "newest".into()); match sort.as_str() { "oldest" => rows.sort_by_key(|r| r.issue.number), "updated" => rows.sort_by_key(|r| std::cmp::Reverse(r.issue.updated_ms)), "comments" => rows.sort_by_key(|r| (std::cmp::Reverse(r.comments), std::cmp::Reverse(r.issue.number))), _ => {} } let pager = super::Pager::new(rows.len(), q.page, &[("state", &f.state), ("q", &qtext), ("sort", &sort)]); let rows = pager.slice(rows); Ok(Page(IssuesPage { shell, repo: RepoShell::new(&app, &row, "issues").with_role(role), rows, state: f.state, q: qtext, counts, sort, pager, })) } // ------------------------------------------------------------------- new #[derive(Template)] #[template(path = "issue_new.html")] pub struct NewIssuePage { shell: Shell, repo: RepoShell, templates: Vec<(String, String)>, template: String, error: Option, } #[derive(Deserialize)] pub struct NewQuery { template: Option, } /// `.oxyhub/ISSUE_TEMPLATE/*.md` in the default bookmark's state. async fn issue_templates(app: &Shared, repo: &crate::repos::RepoName, default: &str) -> Vec<(String, String)> { let default = default.to_string(); app.repos .read(repo, move |r, cache| { let Ok(target) = view::resolve(r, cache, &default) else { return Ok(vec![]) }; let loaded = cache.load(r, &target.heads)?; let mut out = Vec::new(); for (path, f) in &loaded.snapshot.files { if let Some(rest) = path.strip_prefix(".oxyhub/ISSUE_TEMPLATE/") { if let (Some(name), oxy_core::state::FileContent::Text { bytes, .. }) = (rest.strip_suffix(".md"), &f.content) { out.push((name.to_string(), String::from_utf8_lossy(bytes).into_owned())); } } } Ok(out) }) .await .unwrap_or_default() } pub async fn new_form( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Query(q): Query, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/issues/new"), &viewer); require(&viewer, &shell)?; let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; let templates = issue_templates(&app, &repo, &row.default_bookmark).await; let template = match q.template.as_deref() { Some("blank") => " ".into(), Some(t) => templates.iter().find(|(n, _)| n == t).map(|(_, b)| b.clone()).unwrap_or_default(), None if templates.is_empty() => " ".into(), None => String::new(), }; Ok(Page(NewIssuePage { shell, repo: RepoShell::new(&app, &row, "issues").with_role(role), templates, template, error: None })) } #[derive(Deserialize)] pub struct NewIssueForm { #[serde(rename = "_csrf")] csrf_token: String, title: String, #[serde(default)] body: String, } pub async fn create( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/issues/new"), &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; if f.title.trim().is_empty() { return Ok(Page(NewIssuePage { shell, repo: RepoShell::new(&app, &row, "issues").with_role(role), templates: vec![], template: f.body, error: Some("Give the issue a title.".into()), }) .into_response()); } let issue = idb::create( &app.db, idb::NewIssue { repo_id: row.id, title: f.title.trim(), body: f.body.trim(), author_id: Some(me.account.id), author: &me.account.username, }, ) .await .map_err(|e| Error::from_db(shell.clone(), e))?; idb::add_event(&app.db, issue.id, &me.account.username, "opened", "").await.ok(); reindex(&app, row.id, &issue).await; let url = format!("/{owner}/{name}/issue/{}", issue.number); notify::deliver( &app, Event { kind: "issue.opened", title: format!("{owner}/{name}#{}: {}", issue.number, issue.title), url: url.clone(), actor: &me.account, repo_id: row.id, thread: Some(("issue", issue.id)), mention_text: &f.body, direct: vec![], }, ) .await; Ok(Redirect::to(&url).into_response()) } // ---------------------------------------------------------------- detail pub enum Item { Comment(idb::IssueComment, String), Event(idb::IssueEvent), } #[derive(Template)] #[template(path = "issue.html")] pub struct IssuePage { shell: Shell, repo: RepoShell, i: idb::IssueRow, body_html: String, timeline: Vec, comments: Vec, labels: Vec, all_labels: Vec, assignees: Vec, milestone: Option, milestones: Vec<(idb::Milestone, bool)>, subscribed: bool, can_edit: bool, can_triage: bool, notice: Option, error: Option, } /// The page assembler every action ends in. #[allow(clippy::too_many_arguments)] async fn detail( app: &Shared, shell: Shell, viewer: &Viewer, owner: &str, name: &str, number: i64, notice: Option, error: Option, ) -> Result, Error> { let (_, row, role) = lookup(app, &shell, viewer, owner, name).await?; let e = |e| Error::from_db(shell.clone(), e); let i = idb::get(&app.db, row.id, number).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?; let repo_url = format!("/{owner}/{name}"); let body_html = markdown(app, row.id, &repo_url, &i.body).await; let comments = idb::comments(&app.db, i.id).await.map_err(e)?; let events = idb::events(&app.db, i.id).await.map_err(e)?; let mut timeline: Vec<(i64, Item)> = Vec::new(); for c in &comments { timeline.push((c.created_ms, Item::Comment(c.clone(), markdown(app, row.id, &repo_url, &c.body).await))); } for ev in events { if ev.event != "opened" { timeline.push((ev.at_ms, Item::Event(ev))); } } timeline.sort_by_key(|(t, _)| *t); let labels = idb::issue_labels(&app.db, i.id).await.map_err(e)?; let all_labels = idb::labels(&app.db, row.id).await.map_err(e)?; let assignees = idb::assignees(&app.db, i.id).await.map_err(e)?; let milestones: Vec<(idb::Milestone, bool)> = idb::milestones(&app.db, row.id) .await .map_err(e)? .into_iter() .map(|m| { let sel = i.milestone_id == Some(m.id); (m, sel) }) .collect(); let milestone = match i.milestone_id { Some(id) => idb::milestone(&app.db, id).await.map_err(e)?, None => None, }; let me = viewer.account(); let subscribed = match me { Some(a) => ndb::subscribed(&app.db, a.id, "issue", i.id).await.map_err(e)?, None => false, }; let can_triage = role >= Role::Triage; let can_edit = me.is_some_and(|a| Some(a.id) == i.author_id) || can_triage; Ok(Page(IssuePage { shell, repo: RepoShell::new(app, &row, "issues").with_role(role), i, body_html, timeline: timeline.into_iter().map(|(_, it)| it).collect(), comments, labels, all_labels, assignees, milestone, milestones, subscribed, can_edit, can_triage, notice, error, })) } pub async fn show( State(app): State, viewer: Viewer, Path((owner, name, number)): Path<(String, String, i64)>, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer); // A number that belongs to a proposal goes there (shared number space). let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?; if idb::get(&app.db, row.id, number).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_none() && db::proposals::get(&app.db, row.id, number).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some() { return Ok(Redirect::to(&format!("/{owner}/{name}/proposal/{number}")).into_response()); } Ok(detail(&app, shell, &viewer, &owner, &name, number, None, None).await?.into_response()) } // ---------------------------------------------------------------- actions async fn issue_for( app: &Shared, shell: &Shell, viewer: &Viewer, owner: &str, name: &str, number: i64, access: Access, ) -> Result<(db::repos::RepoRow, idb::IssueRow, Role), Error> { let (_, row, role) = lookup_role(app, shell, viewer, owner, name, access).await?; let i = idb::get(&app.db, row.id, number) .await .map_err(|e| Error::from_db(shell.clone(), e))? .ok_or_else(|| Error::not_found(shell.clone()))?; Ok((row, i, role)) } #[derive(Deserialize)] pub struct CommentForm { #[serde(rename = "_csrf")] csrf_token: String, body: String, #[serde(default)] close: Option, #[serde(default)] reopen: Option, } pub async fn comment( State(app): State, viewer: Viewer, Path((owner, name, number)): Path<(String, String, i64)>, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let (row, i, role) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?; if i.locked && role < Role::Triage { return detail(&app, shell, &viewer, &owner, &name, number, None, Some("This issue is locked.".into())).await; } if f.body.trim().is_empty() { return detail(&app, shell, &viewer, &owner, &name, number, None, Some("An empty comment says nothing.".into())).await; } let e = |e| Error::from_db(shell.clone(), e); idb::add_comment(&app.db, i.id, Some(me.account.id), &me.account.username, f.body.trim()).await.map_err(e)?; let url = format!("/{owner}/{name}/issue/{number}"); let mut kind = "issue.comment"; let may_close = Some(me.account.id) == i.author_id || role >= Role::Triage; if f.close.is_some() && i.state == "open" && may_close { idb::set_state(&app.db, i.id, "closed", "completed").await.map_err(e)?; idb::add_event(&app.db, i.id, &me.account.username, "closed", "").await.ok(); kind = "issue.closed"; } else if f.reopen.is_some() && i.state == "closed" && may_close { idb::set_state(&app.db, i.id, "open", "").await.map_err(e)?; idb::add_event(&app.db, i.id, &me.account.username, "reopened", "").await.ok(); kind = "issue.reopened"; } reindex(&app, row.id, &i).await; let direct = i.author_id.into_iter().collect(); notify::deliver( &app, Event { kind, title: format!("{owner}/{name}#{number}: {}", i.title), url, actor: &me.account, repo_id: row.id, thread: Some(("issue", i.id)), mention_text: &f.body, direct, }, ) .await; detail(&app, shell, &viewer, &owner, &name, number, None, None).await } pub async fn delete_comment( State(app): State, viewer: Viewer, Path((owner, name, number, id)): Path<(String, String, i64, i64)>, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf, &shell)?; let (row, i, role) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?; 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); if let Some(c) = c { if c.author_id == Some(me.account.id) || role >= Role::Triage { idb::delete_comment(&app.db, id).await.map_err(|e| Error::from_db(shell.clone(), e))?; idb::add_event(&app.db, i.id, &me.account.username, "deleted a comment", "").await.ok(); reindex(&app, row.id, &i).await; } } detail(&app, shell, &viewer, &owner, &name, number, None, None).await } #[derive(Deserialize)] pub struct EditForm { #[serde(rename = "_csrf")] csrf_token: String, title: String, #[serde(default)] body: String, } pub async fn edit( State(app): State, viewer: Viewer, Path((owner, name, number)): Path<(String, String, i64)>, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let (row, i, role) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?; if Some(me.account.id) != i.author_id && role < Role::Triage { return Err(Error::not_found(shell)); } if f.title.trim().is_empty() { return detail(&app, shell, &viewer, &owner, &name, number, None, Some("An issue keeps a title.".into())).await; } idb::edit(&app.db, i.id, f.title.trim(), f.body.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))?; idb::add_event(&app.db, i.id, &me.account.username, "edited", "").await.ok(); if let Ok(Some(fresh)) = idb::get(&app.db, row.id, number).await { reindex(&app, row.id, &fresh).await; } detail(&app, shell, &viewer, &owner, &name, number, Some("Saved.".into()), None).await } #[derive(Deserialize)] pub struct StateForm { #[serde(rename = "_csrf")] csrf_token: String, action: String, } pub async fn state( State(app): State, viewer: Viewer, Path((owner, name, number)): Path<(String, String, i64)>, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let (row, i, _) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?; let role = crate::auth::roles::role_for(&app.db, Some(&me.account), &row).await.map_err(|e| Error::from_db(shell.clone(), e))?; if !crate::auth::roles::allows(role, Access::Read) || (role < Some(Role::Triage) && Some(me.account.id) != i.author_id) { return Err(Error::not_found(shell)); } let e = |e| Error::from_db(shell.clone(), e); let (event, kind) = match f.action.as_str() { "close" => { idb::set_state(&app.db, i.id, "closed", "completed").await.map_err(e)?; ("closed", "issue.closed") } "not_planned" => { idb::set_state(&app.db, i.id, "closed", "not_planned").await.map_err(e)?; ("closed as not planned", "issue.closed") } "reopen" => { idb::set_state(&app.db, i.id, "open", "").await.map_err(e)?; ("reopened", "issue.reopened") } "lock" if role >= Some(Role::Triage) => { idb::set_flags(&app.db, i.id, Some(true), None).await.map_err(e)?; ("locked", "") } "unlock" if role >= Some(Role::Triage) => { idb::set_flags(&app.db, i.id, Some(false), None).await.map_err(e)?; ("unlocked", "") } "pin" if role >= Some(Role::Triage) => { idb::set_flags(&app.db, i.id, None, Some(true)).await.map_err(e)?; ("pinned", "") } "unpin" if role >= Some(Role::Triage) => { idb::set_flags(&app.db, i.id, None, Some(false)).await.map_err(e)?; ("unpinned", "") } _ => return Err(Error::bad(shell, "unknown action")), }; idb::add_event(&app.db, i.id, &me.account.username, event, "").await.ok(); if !kind.is_empty() { let direct = i.author_id.into_iter().collect(); notify::deliver( &app, Event { kind, title: format!("{owner}/{name}#{number}: {}", i.title), url: format!("/{owner}/{name}/issue/{number}"), actor: &me.account, repo_id: row.id, thread: Some(("issue", i.id)), mention_text: "", direct, }, ) .await; } detail(&app, shell, &viewer, &owner, &name, number, None, None).await } #[derive(Deserialize)] pub struct LabelsForm { #[serde(rename = "_csrf")] csrf_token: String, #[serde(default)] label: Vec, } pub async fn set_labels( State(app): State, viewer: Viewer, Path((owner, name, number)): Path<(String, String, i64)>, axum_extra::extract::Form(f): axum_extra::extract::Form, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let (row, i, _) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?; let role = crate::auth::roles::role_for(&app.db, Some(&me.account), &row).await.map_err(|e| Error::from_db(shell.clone(), e))?; if role < Some(Role::Triage) { return Err(Error::not_found(shell)); } let e = |e| Error::from_db(shell.clone(), e); for l in idb::labels(&app.db, row.id).await.map_err(e)? { idb::set_issue_label(&app.db, i.id, l.id, f.label.contains(&l.id)).await.map_err(e)?; } idb::add_event(&app.db, i.id, &me.account.username, "set labels", "").await.ok(); detail(&app, shell, &viewer, &owner, &name, number, None, None).await } #[derive(Deserialize)] pub struct AssignForm { #[serde(rename = "_csrf")] csrf_token: String, username: String, action: String, } pub async fn assign( State(app): State, viewer: Viewer, Path((owner, name, number)): Path<(String, String, i64)>, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let (row, i, role) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?; if role < Role::Triage { return Err(Error::not_found(shell)); } let Some(target) = accounts::account_by_name(&app.db, f.username.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))? else { return detail(&app, shell, &viewer, &owner, &name, number, None, Some("No such account.".into())).await; }; let on = f.action == "add"; idb::set_assignee(&app.db, i.id, target.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?; idb::add_event(&app.db, i.id, &me.account.username, if on { "assigned" } else { "unassigned" }, &target.username).await.ok(); if on { notify::deliver( &app, Event { kind: "issue.assigned", title: format!("{owner}/{name}#{number}: {}", i.title), url: format!("/{owner}/{name}/issue/{number}"), actor: &me.account, repo_id: row.id, thread: None, mention_text: "", direct: vec![target.id], }, ) .await; ndb::subscribe(&app.db, target.id, "issue", i.id).await.ok(); } detail(&app, shell, &viewer, &owner, &name, number, None, None).await } #[derive(Deserialize)] pub struct MilestoneForm { #[serde(rename = "_csrf")] csrf_token: String, #[serde(default)] milestone: String, } pub async fn set_milestone( State(app): State, viewer: Viewer, Path((owner, name, number)): Path<(String, String, i64)>, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let (_, i, role) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?; if role < Role::Triage { return Err(Error::not_found(shell)); } let id = f.milestone.trim().parse::().ok(); idb::set_milestone(&app.db, i.id, id).await.map_err(|e| Error::from_db(shell.clone(), e))?; idb::add_event(&app.db, i.id, &me.account.username, "set milestone", "").await.ok(); detail(&app, shell, &viewer, &owner, &name, number, None, None).await } pub async fn subscribe( State(app): State, viewer: Viewer, Path((owner, name, number)): Path<(String, String, i64)>, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf, &shell)?; let (_, i, _) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?; let e = |e| Error::from_db(shell.clone(), e); if ndb::subscribed(&app.db, me.account.id, "issue", i.id).await.map_err(e)? { ndb::unsubscribe(&app.db, me.account.id, "issue", i.id).await.map_err(e)?; } else { ndb::subscribe(&app.db, me.account.id, "issue", i.id).await.map_err(e)?; } detail(&app, shell, &viewer, &owner, &name, number, None, None).await } // ---------------------------------------------------------------- labels #[derive(Template)] #[template(path = "labels.html")] pub struct LabelsPage { shell: Shell, repo: RepoShell, labels: Vec, can_triage: bool, notice: Option, } pub async fn labels( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/labels"), &viewer); let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; let labels = idb::labels(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; Ok(Page(LabelsPage { shell, repo: RepoShell::new(&app, &row, "issues").with_role(role), labels, can_triage: role >= Role::Triage, notice: None, })) } #[derive(Deserialize)] pub struct LabelForm { #[serde(rename = "_csrf")] csrf_token: String, name: String, #[serde(default = "default_colour")] colour: String, #[serde(default)] description: String, #[serde(default)] delete: Option, } fn default_colour() -> String { "#0072ff".into() } pub async fn save_label( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/labels"), &viewer); require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; if role < Role::Triage { return Err(Error::not_found(shell)); } let e = |e| Error::from_db(shell.clone(), e); let notice = if f.delete.is_some() { if let Some(l) = idb::label_by_name(&app.db, row.id, f.name.trim()).await.map_err(e)? { idb::delete_label(&app.db, l.id).await.map_err(e)?; } format!("Deleted {}.", f.name.trim()) } else { let colour = if regex::Regex::new(r"^#[0-9a-fA-F]{6}$").unwrap().is_match(&f.colour) { f.colour.clone() } else { default_colour() }; idb::upsert_label(&app.db, row.id, f.name.trim(), &colour, f.description.trim()).await.map_err(e)?; format!("Saved {}.", f.name.trim()) }; let labels = idb::labels(&app.db, row.id).await.map_err(e)?; Ok(Page(LabelsPage { shell, repo: RepoShell::new(&app, &row, "issues").with_role(role), labels, can_triage: true, notice: Some(notice), })) } // ------------------------------------------------------------ milestones #[derive(Template)] #[template(path = "milestones.html")] pub struct MilestonesPage { shell: Shell, repo: RepoShell, rows: Vec<(idb::Milestone, i64, i64)>, can_triage: bool, notice: Option, } async fn milestones_page( app: &Shared, shell: Shell, row: db::repos::RepoRow, role: Role, notice: Option, ) -> Result, Error> { let mut rows = Vec::new(); for m in idb::milestones(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))? { let (open, closed) = idb::milestone_counts(&app.db, m.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; rows.push((m, open, closed)); } Ok(Page(MilestonesPage { shell, repo: RepoShell::new(app, &row, "issues").with_role(role), rows, can_triage: role >= Role::Triage, notice, })) } pub async fn milestones( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/milestones"), &viewer); let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; milestones_page(&app, shell, row, role, None).await } #[derive(Deserialize)] pub struct MilestoneSaveForm { #[serde(rename = "_csrf")] csrf_token: String, title: String, #[serde(default)] description: String, #[serde(default)] due: String, #[serde(default)] state: Option, } pub async fn save_milestone( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/milestones"), &viewer); require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; if role < Role::Triage { return Err(Error::not_found(shell)); } let e = |e| Error::from_db(shell.clone(), e); if let Some(state) = f.state.as_deref() { if let Some(m) = idb::milestones(&app.db, row.id).await.map_err(e)?.into_iter().find(|m| m.title == f.title) { idb::set_milestone_state(&app.db, m.id, if state == "closed" { "closed" } else { "open" }).await.map_err(e)?; } return milestones_page(&app, shell, row, role, Some("Updated.".into())).await; } let due_ms = time::Date::parse(f.due.trim(), &time::macros::format_description!("[year]-[month]-[day]")) .ok() .map(|d| d.midnight().assume_utc().unix_timestamp() * 1000); idb::upsert_milestone(&app.db, row.id, f.title.trim(), f.description.trim(), due_ms).await.map_err(e)?; milestones_page(&app, shell, row, role, Some("Saved.".into())).await } // ----------------------------------------------------------------- watch pub async fn watch( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}"), &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf, &shell)?; let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?; let on = !ndb::watching(&app.db, me.account.id, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; ndb::set_watch(&app.db, me.account.id, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?; Ok(Redirect::to(&format!("/{owner}/{name}")).into_response()) } /// Star or unstar a repository. pub async fn star( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}"), &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf, &shell)?; let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?; db::repos::toggle_star(&app.db, me.account.id, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; Ok(Redirect::to(&format!("/{owner}/{name}")).into_response()) } /// Close issues a landed change's message names (`closes #12`), when the /// landing reached the default bookmark. Called from the push hook and /// the hub's own landing. pub async fn close_from_messages(app: &Shared, row: &db::repos::RepoRow, messages: &[(String, String)]) { for (hash, message) in messages { for n in render::refs::closes(message) { if let Ok(Some(i)) = idb::get(&app.db, row.id, n).await { if i.state == "open" { idb::set_state(&app.db, i.id, "closed", "completed").await.ok(); idb::add_event(&app.db, i.id, "", "closed by change", &hash[..12.min(hash.len())]).await.ok(); for id in ndb::subscribers(&app.db, "issue", i.id).await.unwrap_or_default() { ndb::add( &app.db, id, "issue.closed", &format!("{}#{}: {} (closed by {})", row.slug(), i.number, i.title, &hash[..12.min(hash.len())]), &format!("/{}/issue/{}", row.slug(), i.number), "", ) .await .ok(); } } } } } }