//! Triage writes on an issue's labels, assignees, milestone and conversation lock //! (`/{owner}/{repo}/issues/{n}/...`). //! Every handler checks CSRF, `Action::Triage` and that the repo is not archived (403). A hidden //! repo or a missing issue is 404. A store validation error (unknown user, more than 10 assignees, //! a label or milestone from another repo) re-renders a small error page with the reason. Success //! redirects back to the issue. use forge_core::User; use forge_core::authz::{Action, authorize}; use forge_core::issues::{Issue, Label}; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::RawForm; use topcoat::router::error::{forbidden, not_found, see_other}; use topcoat::router::{page, path_param}; use topcoat::view::{View, component, view}; use crate::auth::require_csrf; use crate::i18n::issues::{self as g, K}; use crate::i18n::{self, Lang}; use crate::notify; use crate::pages::issue_parts::ItemKind; use crate::pages::repo_ctx::{RepoCtx, RepoHead, load_repo}; use crate::state::AppState; use crate::views::{alert, shell, user_facing_message}; use crate::webhooks::emit::{ Sender, emit_assignee_changes, emit_item_action, emit_label_changes, emit_milestone_change, }; path_param!(owner); path_param!(repo); path_param!(number); /// Whether the viewer may triage this repo's issues, labels and milestones: Triage or higher, or /// the owner or an instance admin, on a repo that is not archived. pub(crate) fn can_triage(ctx: &RepoCtx) -> bool { let actor = ctx.viewer.as_ref().map(|(user, _)| user); !ctx.repo.archived && authorize(actor, &ctx.repo, ctx.role, ctx.org_role, Action::Triage).is_ok() } /// The repo fields and viewer flags that the label and milestone pages share. pub(crate) struct RepoFrame { /// `owner/name`. pub title: String, pub csrf: String, pub archived: bool, pub can_triage: bool, /// The repo header, which `repo_head` renders. pub head: RepoHead, } impl RepoFrame { /// The frame for a page on the Issues tab group. `active` is the header tab to mark. pub(crate) async fn new(ctx: &RepoCtx, state: &AppState, active: &'static str) -> Result { let csrf = ctx .viewer .as_ref() .map(|(_, session)| session.csrf_token.clone()) .unwrap_or_default(); let head = ctx.head(state, active).await?; Ok(Self { title: format!("{}/{}", ctx.repo.owner_name, ctx.repo.name), csrf, archived: ctx.repo.archived, can_triage: can_triage(ctx), head, }) } } /// The message for a failed store write that the form can show, or the error to propagate. A /// missing row is 404 and anything that is not a validation error is a server error. pub(crate) fn refusal_message(e: forge_core::Error) -> Result { if matches!(e, forge_core::Error::NotFound) { return Err(not_found().into()); } match user_facing_message(&e) { Some(message) => Ok(message), None => Err(e.into()), } } /// Class for one sub-navigation link: the one named by `active` is marked current. fn subnav_class(active: &str, link: &str) -> &'static str { if active == link { "subnav-link current" } else { "subnav-link" } } /// Links between the issue-adjacent pages of a repo: Issues, Labels and Milestones. #[component] pub(crate) async fn issue_subnav(lang: Lang, title: String, active: String) -> Result { let issues = format!("/{title}/issues"); let labels = format!("/{title}/labels"); let milestones = format!("/{title}/milestones"); Ok(view! { }) } /// Every `key=value` pair of a urlencoded body, in order, with repeated keys kept. pub(crate) fn form_pairs(raw: &[u8]) -> Vec<(String, String)> { form_urlencoded::parse(raw) .map(|(k, v)| (k.into_owned(), v.into_owned())) .collect() } /// The first value for `key`, or an empty string. pub(crate) fn form_field(pairs: &[(String, String)], key: &str) -> String { pairs .iter() .find(|(k, _)| k == key) .map(|(_, v)| v.clone()) .unwrap_or_default() } /// Every value for `key`, in order. pub(crate) fn form_all(pairs: &[(String, String)], key: &str) -> Vec { pairs .iter() .filter(|(k, _)| k == key) .map(|(_, v)| v.clone()) .collect() } /// The CSRF check and triage gate for a write. Returns the signed-in user. Anonymous viewers are /// sent to log in, a bad token is 403, and a viewer without Triage (or an archived repo) is 403. pub(crate) async fn triage_guard(cx: &Cx, ctx: &RepoCtx, csrf: &str) -> Result { let (user, _session) = require_csrf(cx, csrf).await?; if ctx.repo.archived || authorize( Some(&user), &ctx.repo, ctx.role, ctx.org_role, Action::Triage, ) .is_err() { return Err(forbidden().into()); } Ok(user) } /// Loads the issue or pull request named by `{number}` in this repo, or 404. A pull request's /// sidebar data lives on its issue row, so the returned `Issue` serves both. pub(crate) async fn item_from_path( cx: &Cx, ctx: &RepoCtx, kind: ItemKind, number: &str, ) -> Result { let Some(n) = number.parse::().ok() else { return Err(not_found().into()); }; let store = &app_context::(cx).store; let found = match kind { ItemKind::Issue => store.issue_by_number(&ctx.repo.id, n).await?, ItemKind::Pull => store .pull_by_number(&ctx.repo.id, n) .await? .map(|pr| pr.issue), }; match found { Some(item) => Ok(item), None => Err(not_found().into()), } } /// `/{owner}/{repo}/issues/{n}` or `/{owner}/{repo}/pulls/{n}` for this repo. pub(crate) fn item_href(ctx: &RepoCtx, kind: ItemKind, number: i64) -> String { format!( "/{}/{}/{}/{number}", ctx.repo.owner_name, ctx.repo.name, kind.segment() ) } /// The error box with a link back to where the action came from, in the viewer's language. #[component] pub(crate) async fn action_error_at( lang: Lang, message: String, back: String, ) -> Result { Ok(view! {
alert(message: message.as_str())

(g::t(lang, K::GoBack))

}) } /// Sets the labels on an item. The form repeats `label` once per label id. #[page(POST "/{owner}/{repo}/issues/{number}/labels")] async fn issue_labels_save(cx: &Cx, RawForm(raw): RawForm) -> Result { labels_save(cx, ItemKind::Issue, form_pairs(&raw[..])).await } #[page(POST "/{owner}/{repo}/pulls/{number}/labels")] async fn pull_labels_save(cx: &Cx, RawForm(raw): RawForm) -> Result { labels_save(cx, ItemKind::Pull, form_pairs(&raw[..])).await } async fn labels_save(cx: &Cx, kind: ItemKind, fields: Vec<(String, String)>) -> Result { let ctx = load_repo(cx, path_param::(cx), path_param::(cx)).await?; let item = item_from_path(cx, &ctx, kind, path_param::(cx)).await?; let user = triage_guard(cx, &ctx, &form_field(&fields, "csrf")).await?; let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(u, _)| u)); let ids = form_all(&fields, "label"); let back = item_href(&ctx, kind, item.number); let state = app_context::(cx); let pull = matches!(kind, ItemKind::Pull); let before = item_labels(state, &item.id).await; let result = state.store.set_issue_labels(&item.id, &user.id, &ids).await; if let Err(e) = result { let message = refusal_message(e)?; return Ok(view! { cx => shell(title: g::t(lang, K::CouldNotSave), viewer: ctx.viewer.as_ref(), action_error_at(lang: lang, message: message, back: back)) }); } let after = item_labels(state, &item.id).await; let sender = Sender::of(&user); emit_label_changes(state, &ctx.repo, &sender, pull, &item, &before, &after).await; Err(see_other(back).into()) } /// Sets the assignees. The field is a comma-separated list of usernames; empty clears them. #[page(POST "/{owner}/{repo}/issues/{number}/assignees")] async fn issue_assignees_save(cx: &Cx, RawForm(raw): RawForm) -> Result { assignees_save(cx, ItemKind::Issue, form_pairs(&raw[..])).await } #[page(POST "/{owner}/{repo}/pulls/{number}/assignees")] async fn pull_assignees_save(cx: &Cx, RawForm(raw): RawForm) -> Result { assignees_save(cx, ItemKind::Pull, form_pairs(&raw[..])).await } async fn assignees_save( cx: &Cx, kind: ItemKind, fields: Vec<(String, String)>, ) -> Result { let ctx = load_repo(cx, path_param::(cx), path_param::(cx)).await?; let item = item_from_path(cx, &ctx, kind, path_param::(cx)).await?; let user = triage_guard(cx, &ctx, &form_field(&fields, "csrf")).await?; let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(u, _)| u)); let usernames: Vec = form_field(&fields, "assignees") .split(',') .map(str::trim) .filter(|s| !s.is_empty()) .map(str::to_owned) .collect(); let back = item_href(&ctx, kind, item.number); let state = app_context::(cx); let pull = matches!(kind, ItemKind::Pull); let before = assignee_names(state, &item.id).await; let result = state .store .set_issue_assignees(&item.id, &user.id, &usernames) .await; if let Err(e) = result { let message = refusal_message(e)?; return Ok(view! { cx => shell(title: g::t(lang, K::CouldNotSave), viewer: ctx.viewer.as_ref(), action_error_at(lang: lang, message: message, back: back)) }); } let after = assignee_names(state, &item.id).await; let sender = Sender::of(&user); emit_assignee_changes(state, &ctx.repo, &sender, pull, &item, &before, &after).await; notify::dispatch_assigned(state, &ctx.repo, &item, &user.id, &before, &after).await; Err(see_other(back).into()) } /// Labels on an issue, for diffing around a save. A lookup error reads as none. async fn item_labels(state: &AppState, issue_id: &str) -> Vec