// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! Issues: list, create, view, comment, label, assign, and open/close. //! //! Pull requests share the underlying `issues` table and much of this rendering; //! the PR-specific views live in [`crate::pulls`]. use axum::Form; use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, Uri}; use axum::response::{IntoResponse, Redirect, Response}; use axum_extra::extract::cookie::CookieJar; use maud::{Markup, html}; use model::{Issue, IssueState, Label, User}; use serde::Deserialize; use crate::AppState; use crate::error::{AppError, AppResult}; use crate::icons::{Icon, icon}; use crate::layout::page; use crate::markdown; use crate::pages::build_chrome; use crate::repo::{RepoCtx, Tab, css_color, label_chip, repo_header}; use crate::session::{MaybeUser, verify_csrf}; /// Whether these views are for issues or pull requests. Keeps the shared /// rendering parameterized so pulls can reuse it. #[derive(Clone, Copy)] pub(crate) struct Kind { /// True for pull requests. pub is_pull: bool, /// URL segment (`issues` | `pulls`). pub seg: &'static str, /// Human noun (`issue` | `pull request`). pub noun: &'static str, /// The active repo tab. pub tab: Tab, } impl Kind { /// The issues kind. pub(crate) fn issues() -> Self { Self { is_pull: false, seg: "issues", noun: "issue", tab: Tab::Issues, } } /// The pull-requests kind. pub(crate) fn pulls() -> Self { Self { is_pull: true, seg: "pulls", noun: "pull request", tab: Tab::Pulls, } } } /// `GET …/-/issues` — the issue list with open/closed tabs. pub(crate) async fn list( state: &AppState, viewer: Option, jar: CookieJar, uri: &Uri, ctx: RepoCtx, kind: Kind, show_closed: bool, ) -> AppResult { if (kind.is_pull && !ctx.repo.pulls_enabled) || (!kind.is_pull && !ctx.repo.issues_enabled) { return Err(AppError::NotFound); } let filter = if show_closed { IssueState::Closed } else { IssueState::Open }; // Paginate: fetch one extra row to detect a next page. let paging = crate::repo::Pagination::from_query(uri.query()); let limit = i64::try_from(paging.per_page + 1).unwrap_or(i64::MAX); let offset = i64::try_from(paging.offset()).unwrap_or(0); let mut items = state .store .list_issues(&ctx.repo.id, kind.is_pull, Some(filter), limit, offset) .await?; let has_next = items.len() > paging.per_page; items.truncate(paging.per_page); let counts = state.store.issue_counts(&ctx.repo.id, kind.is_pull).await?; // Resolve author usernames and each item's labels. let mut rows = Vec::with_capacity(items.len()); for issue in &items { let author = username(state, &issue.author_id).await; let labels = state.store.issue_labels(&issue.id).await?; rows.push((issue, author, labels)); } let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path); let branches = crate::repo::branch_names(state, &ctx.repo.id).await; let can_write = viewer.is_some() && ctx.access >= auth::Access::Write; let body = html! { (repo_header(state, &ctx, kind.tab, &ctx.repo.default_branch, &branches)) div class="issues-head" { nav class="subnav" aria-label=(kind.noun) { a href=(format!("{base}/-/{}?state=open", kind.seg)) aria-current=[(!show_closed).then_some("page")] { (icon(Icon::Issue)) span { (counts.open) " Open" } } a href=(format!("{base}/-/{}?state=closed", kind.seg)) aria-current=[show_closed.then_some("page")] { span { (counts.closed) " Closed" } } } @if can_write { a class="btn btn-primary" href=(format!("{base}/-/{}/new", kind.seg)) { (icon(Icon::Plus)) span { "New " (kind.noun) } } } } @if rows.is_empty() { div class="card" { p class="muted" { "No " (if show_closed { "closed" } else { "open" }) " " (kind.noun) "s." } } } @else { ul class="entry-list card issue-list" { @for (issue, author, labels) in &rows { li class="entry-row" { div class="entry-row-body" { div class="issue-title-row" { (state_icon(issue.state, kind.is_pull)) a class="entry-row-title" href=(format!("{base}/-/{}/{}", kind.seg, issue.number)) { (issue.title) } @for l in labels { (label_chip(l)) } } div class="entry-row-meta muted" { "#" (issue.number) " · opened by " (author) } } } } } @if has_next || paging.page > 1 { (crate::repo::pagination_nav(uri.path(), uri.query(), paging, has_next)) } } }; let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); let title = format!("{}/{}: {}s", ctx.owner.username, ctx.repo.path, kind.noun); Ok((jar, page(&chrome, &title, body)).into_response()) } /// `GET …/-/issues/new` — the new-issue form. pub(crate) async fn new_form( state: &AppState, viewer: Option, jar: CookieJar, uri: &Uri, ctx: RepoCtx, kind: Kind, ) -> AppResult { if (kind.is_pull && !ctx.repo.pulls_enabled) || (!kind.is_pull && !ctx.repo.issues_enabled) { return Err(AppError::NotFound); } // Opening an issue requires Read (the resolve already guaranteed it) and a // signed-in viewer. if viewer.is_none() { return Ok(Redirect::to("/login").into_response()); } let branches = crate::repo::branch_names(state, &ctx.repo.id).await; let repo_labels = state.store.list_labels(&ctx.repo.id).await?; let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); let body = html! { (repo_header(state, &ctx, kind.tab, &ctx.repo.default_branch, &branches)) h2 { "New " (kind.noun) } div class="card" { form method="post" action=(format!("/issue-new/{}?is_pull={}", ctx.repo.id, kind.is_pull)) { input type="hidden" name="_csrf" value=(chrome.csrf); label for="title" { "Title" } input type="text" id="title" name="title" required autofocus; label for="body" { "Description" } textarea id="body" name="body" rows="8" placeholder="Markdown supported" {} div class="new-issue-actions" { button class="btn btn-primary inline-btn" type="submit" { "Create " (kind.noun) } @if !repo_labels.is_empty() { (label_dropdown(&repo_labels)) } } } } }; let title = format!( "New {} · {}/{}", kind.noun, ctx.owner.username, ctx.repo.path ); Ok((jar, page(&chrome, &title, body)).into_response()) } /// `GET …/-/issues/{n}` — a single issue with comments and controls. #[allow(clippy::too_many_lines)] // Cohesive view: header, thread, sidebar. pub(crate) async fn view( state: &AppState, viewer: Option, jar: CookieJar, uri: &Uri, ctx: RepoCtx, kind: Kind, number: i64, ) -> AppResult { if (kind.is_pull && !ctx.repo.pulls_enabled) || (!kind.is_pull && !ctx.repo.issues_enabled) { return Err(AppError::NotFound); } let Some(issue) = state .store .issue_by_number(&ctx.repo.id, number, kind.is_pull) .await? else { // A `#n` reference may point at the other kind — redirect there. if let Some(other) = state .store .issue_by_number(&ctx.repo.id, number, !kind.is_pull) .await? { let seg = if other.is_pull { "pulls" } else { "issues" }; let dest = format!("/{}/{}/-/{seg}/{number}", ctx.owner.username, ctx.repo.path); return Ok(Redirect::to(&dest).into_response()); } return Err(AppError::NotFound); }; let author = username(state, &issue.author_id).await; let comments = state.store.list_comments(&issue.id).await?; let issue_labels = state.store.issue_labels(&issue.id).await?; let repo_labels = state.store.list_labels(&ctx.repo.id).await?; let assignee = match &issue.assignee_id { Some(id) => Some(username(state, id).await), None => None, }; let deps = state.store.dependencies_of(&issue.id).await?; let dependents = state.store.dependents_of(&issue.id).await?; let can_write = viewer.is_some() && ctx.access >= auth::Access::Write; let is_author = viewer.as_ref().is_some_and(|v| v.id == issue.author_id); let viewer_id: Option = viewer.as_ref().map(|u| u.id.clone()); let branches = crate::repo::branch_names(state, &ctx.repo.id).await; // Paginate long threads (the pager only appears past one page). let paging = crate::repo::Pagination::from_query(uri.query()); let (page_comments, comments_next) = crate::repo::page_slice(&comments, paging); let mut comment_rows = Vec::with_capacity(page_comments.len()); for c in &page_comments { comment_rows.push((c, username(state, &c.author_id).await)); } let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); let csrf = chrome.csrf.clone(); let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path); let body = html! { (repo_header(state, &ctx, kind.tab, &ctx.repo.default_branch, &branches)) div class="issue-view" { header class="issue-header" { div class="issue-title-line" { h1 { (issue.title) " " span class="muted" { "#" (issue.number) } } @if can_write || is_author { (edit_issue_form(&issue, &csrf)) } } div class="issue-sub" { (state_badge(issue.state, kind.is_pull)) span class="muted" { " " (author) " opened this " (kind.noun) } } } div class="issue-main" { article class="issue-thread" { @let body_action = format!("/issue/{}/edit-body", issue.id); (comment_card(&author, &base, &issue.body, issue.created_at, (can_write || is_author).then_some((body_action.as_str(), csrf.as_str())))) @for (c, cauthor) in &comment_rows { @let editable = can_write || viewer_id.as_deref() == Some(c.author_id.as_str()); @let action = format!("/comment/{}/edit", c.id); (comment_card(cauthor, &base, &c.body, c.created_at, editable.then_some((action.as_str(), csrf.as_str())))) } @if comments_next || paging.page > 1 { (crate::repo::pagination_nav(uri.path(), uri.query(), paging, comments_next)) } @if issue.locked() { (locked_note(can_write)) } @if can_write || (is_author && !issue.locked()) { (comment_box(&issue, &csrf, can_write)) } } aside class="issue-side" { section { h3 { "Assignee" } @if can_write { (assignee_form(&issue, &ctx, state, &csrf).await) } @else if let Some(a) = &assignee { p { (a) } } @else { p class="muted" { "No one" } } } section { h3 { "Labels" } @if can_write && !repo_labels.is_empty() { (label_form(&issue, &repo_labels, &issue_labels, &csrf)) } @else if issue_labels.is_empty() { p class="muted" { "None" } } @else { div class="label-set" { @for l in &issue_labels { (label_chip(l)) } } } } (deps_section(&issue, &ctx, &deps, &dependents, &csrf, can_write)) } } } }; let title = format!("{} · {}/{}", issue.title, ctx.owner.username, ctx.repo.path); Ok((jar, page(&chrome, &title, body)).into_response()) } // ---- shared render helpers ---- /// A single comment card (also used for the issue body). When `edit` is /// `Some((action, csrf))`, the header offers an inline edit form posting to /// `action` — `/comment/{id}/edit` for comments, `/issue/{id}/edit-body` for the /// issue/PR description. pub(crate) fn comment_card( author: &str, repo_base: &str, body: &str, created_at: i64, edit: Option<(&str, &str)>, ) -> Markup { html! { div class="card comment" { div class="comment-head muted" { strong { (author) } span { " commented " (crate::activity::fmt_relative(created_at, crate::now_ms())) } // A bare toggle; CSS `:has(.comment-edit[open])` swaps the rendered // body for the editor below, so no JavaScript is needed. @if edit.is_some() { details class="comment-edit" { summary { span class="edit-open" { "Edit" } span class="edit-close" { "Cancel" } } } } } div class="comment-body markdown" { (markdown::render_with_refs(body, repo_base)) } @if let Some((action, csrf)) = edit { form class="comment-editor" method="post" action=(action) { input type="hidden" name="_csrf" value=(csrf); textarea name="body" rows="6" required { (body) } div class="comment-editor-actions" { button class="btn btn-primary inline-btn" type="submit" { "Save" } } } } } } } /// The open/closed state icon. fn state_icon(st: IssueState, is_pull: bool) -> Markup { let glyph = if is_pull { Icon::GitPull } else { Icon::Issue }; let class = if st == IssueState::Open { "state-open" } else { "state-closed" }; html! { span class=(class) { (icon(glyph)) } } } /// The open/closed state badge. pub(crate) fn state_badge(st: IssueState, is_pull: bool) -> Markup { let (class, label) = match st { IssueState::Open => ("badge badge-state-open", "Open"), IssueState::Closed => ("badge badge-state-closed", "Closed"), }; html! { span class=(class) { (state_icon(st, is_pull)) " " (label) } } } /// The close/reopen button (posts to the state endpoint). pub(crate) fn state_button(issue: &Issue, csrf: &str) -> Markup { let (target, label) = match issue.state { IssueState::Open => ("closed", "Close"), IssueState::Closed => ("open", "Reopen"), }; html! { form method="post" action=(format!("/issue/{}/state", issue.id)) class="inline-form" { input type="hidden" name="_csrf" value=(csrf); input type="hidden" name="state" value=(target); button class="btn inline-btn" type="submit" { (label) } } } } /// The "conversation is locked" notice. pub(crate) fn locked_note(can_write: bool) -> Markup { html! { div class="card locked-note muted" { "🔒 This conversation is locked." @if can_write { " Only those with write access can comment." } } } } /// The comment box: a textarea plus the state/lock/comment actions. The Comment /// button references the form via `form=` so no forms are nested. pub(crate) fn comment_box(issue: &Issue, csrf: &str, can_write: bool) -> Markup { let comment_form = format!("comment-{}", issue.id); html! { div class="card comment-form" { form id=(comment_form) method="post" action=(format!("/issue/{}/comment", issue.id)) { input type="hidden" name="_csrf" value=(csrf); textarea name="body" rows="4" placeholder="Leave a comment (markdown)" required {} } div class="comment-actions" { (state_button(issue, csrf)) @if can_write { (lock_button(issue, csrf)) } button class="btn btn-primary inline-btn" type="submit" form=(comment_form) { "Comment" } } } } } /// The lock/unlock button (posts to the lock endpoint). Writers only. pub(crate) fn lock_button(issue: &Issue, csrf: &str) -> Markup { let (target, label) = if issue.locked() { ("false", "Unlock") } else { ("true", "Lock") }; html! { form method="post" action=(format!("/issue/{}/lock", issue.id)) class="inline-form" { input type="hidden" name="_csrf" value=(csrf); input type="hidden" name="locked" value=(target); button class="btn inline-btn" type="submit" { (label) } } } } /// The edit-title toggle in the issue/PR header. When open it hides the title and /// shows a full-width single-line input with Cancel (the toggle) and Save beside /// it, GitHub-style. (The description is edited from its own comment card.) pub(crate) fn edit_issue_form(issue: &Issue, csrf: &str) -> Markup { // The toggle, the input form, and Save are siblings of the title (not nested // in the
), so `:has(.issue-edit[open])` can reveal them without // hitting the ::details-content wrapper that would stack them. let form_id = format!("title-edit-{}", issue.id); html! { details class="issue-edit" { summary class="btn edit-toggle" { span class="edit-open" { "Edit" } span class="edit-close" { "Cancel" } } } form id=(form_id) class="title-edit-form" method="post" action=(format!("/issue/{}/edit-title", issue.id)) { input type="hidden" name="_csrf" value=(csrf); input type="text" name="title" value=(issue.title) aria-label="Title" required; } button class="btn btn-primary title-save" type="submit" form=(form_id) { "Save" } } } /// The assignee select form (owner + collaborators). pub(crate) async fn assignee_form( issue: &Issue, ctx: &RepoCtx, state: &AppState, csrf: &str, ) -> Markup { let mut options: Vec<(String, String)> = vec![(ctx.owner.id.clone(), ctx.owner.username.clone())]; if let Ok(collabs) = state.store.list_collaborators(&ctx.repo.id).await { for c in collabs { options.push((c.user_id, c.username)); } } html! { form method="post" action=(format!("/issue/{}/assignee", issue.id)) class="side-form" { input type="hidden" name="_csrf" value=(csrf); select name="assignee_id" aria-label="Assignee" { option value="" selected[issue.assignee_id.is_none()] { "No one" } @for (id, name) in &options { option value=(id) selected[issue.assignee_id.as_deref() == Some(id.as_str())] { (name) } } } button class="btn inline-btn" type="submit" { "Set" } } } } /// The label editor for an existing issue/PR: a `
` dropdown whose toggle /// shows the applied labels as chips (JS replaces the "Select labels" text), the /// same control as the new-issue picker. Toggling a label auto-submits the whole /// set; a noscript Apply button keeps it working without JS. pub(crate) fn label_form( issue: &Issue, repo_labels: &[Label], applied: &[Label], csrf: &str, ) -> Markup { let is_on = |id: &str| applied.iter().any(|l| l.id == id); html! { form method="post" action=(format!("/issue/{}/labels", issue.id)) class="side-form" { input type="hidden" name="_csrf" value=(csrf); details class="menu label-dropdown" data-menu data-label-dropdown { summary class="btn" { span class="label-dropdown-label" data-label-summary { "Select labels" } (icon(Icon::ChevronDown)) } div class="menu-popover label-menu" { @for l in repo_labels { label class="checkbox" { input type="checkbox" name="label" value=(l.id) checked[is_on(&l.id)] data-label-name=(l.name) data-label-color=(css_color(&l.color)) data-autosubmit; " " span class="label-swatch" style=(format!("--label-color: {}", css_color(&l.color))) {} " " (l.name) } } noscript { button class="btn inline-btn label-apply" type="submit" { "Apply" } } } } } } } /// A `
` dropdown of label checkboxes, for selecting several labels when /// opening an issue. The checkboxes submit with the surrounding form; JS replaces /// the "Select labels" text inside the toggle with chips for the checked labels. fn label_dropdown(repo_labels: &[Label]) -> Markup { html! { details class="menu label-dropdown" data-menu data-label-dropdown { summary class="btn" { span class="label-dropdown-label" data-label-summary { "Select labels" } (icon(Icon::ChevronDown)) } div class="menu-popover label-menu" { @for l in repo_labels { label class="checkbox" { input type="checkbox" name="label" value=(l.id) data-label-name=(l.name) data-label-color=(css_color(&l.color)); " " span class="label-swatch" style=(format!("--label-color: {}", css_color(&l.color))) {} " " (l.name) } } } } } } /// The dependencies sidebar section: "Blocked by" (with add/remove for writers) /// and "Blocks" (read-only). Shared by the issue and PR views. pub(crate) fn deps_section( issue: &Issue, ctx: &RepoCtx, deps: &[Issue], dependents: &[Issue], csrf: &str, can_write: bool, ) -> Markup { let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path); let row = |dep: &Issue, removable: bool| { let seg = if dep.is_pull { "pulls" } else { "issues" }; html! { li class="dep-row" { (state_icon(dep.state, dep.is_pull)) a href=(format!("{base}/-/{seg}/{}", dep.number)) { span class="muted mono" { "#" (dep.number) } " " (dep.title) } @if removable && can_write { form method="post" action=(format!("/issue/{}/deps/remove", issue.id)) class="dep-remove" { input type="hidden" name="_csrf" value=(csrf); input type="hidden" name="depends_on" value=(dep.id); button class="icon-btn inline-btn" type="submit" aria-label="Remove dependency" { (icon(Icon::X)) } } } } } }; html! { section { h3 { "Blocked by" } @if deps.is_empty() { p class="muted" { "Nothing" } } @else { ul class="dep-list" { @for d in deps { (row(d, true)) } } } @if can_write { form method="post" action=(format!("/issue/{}/deps/add", issue.id)) class="side-form dep-add" { input type="hidden" name="_csrf" value=(csrf); input type="number" name="number" min="1" placeholder="#123" aria-label="Depends on number" required; button class="btn inline-btn" type="submit" { "Add" } } } } @if !dependents.is_empty() { section { h3 { "Blocks" } ul class="dep-list" { @for d in dependents { (row(d, false)) } } } } } } /// Resolve a username for display, falling back to the id. pub(crate) async fn username(state: &AppState, user_id: &str) -> String { state .store .user_by_id(user_id) .await .ok() .flatten() .map_or_else(|| user_id.to_string(), |u| u.username) } // ---- POST handlers (fixed-prefix routes, keyed by id) ---- /// Load an issue's repo and the viewer's access to it. pub(crate) async fn repo_of_issue( state: &AppState, viewer: Option<&User>, issue: &Issue, ) -> AppResult<(model::Repo, auth::Access)> { let repo = state .store .repo_by_id(&issue.repo_id) .await? .ok_or(AppError::NotFound)?; let access = access_for(state, viewer, &repo).await?; Ok((repo, access)) } /// Compute the viewer's access to a repo. pub(crate) async fn access_for( state: &AppState, viewer: Option<&User>, repo: &model::Repo, ) -> AppResult { let viewer_id = viewer.map(|u| auth::Viewer { id: u.id.clone(), is_admin: u.is_admin, }); let collab = match viewer { Some(u) => state .store .effective_permission(&repo.id, &u.id) .await? .and_then(|p| auth::Permission::parse(&p)), None => None, }; Ok(auth::access( viewer_id.as_ref(), repo, collab, state.allow_anonymous(), )) } /// The path to an issue/PR. pub(crate) async fn issue_url(state: &AppState, repo: &model::Repo, issue: &Issue) -> String { let owner = username(state, &repo.owner_id).await; let seg = if issue.is_pull { "pulls" } else { "issues" }; format!("/{owner}/{}/-/{seg}/{}", repo.path, issue.number) } /// `?is_pull=` query for the create endpoint. #[derive(Debug, Default, Deserialize)] pub struct CreateQuery { /// Whether the new item is a pull request. #[serde(default)] is_pull: bool, } /// `POST /issue-new/{repo_id}` — create an issue (PRs use their own flow). /// /// The body is parsed manually because it carries repeated `label` fields, which /// `serde_urlencoded` cannot represent. pub async fn create( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, headers: HeaderMap, Path(repo_id): Path, Query(q): Query, body: axum::body::Bytes, ) -> Response { let (mut title, mut desc, mut csrf) = (String::new(), String::new(), String::new()); let mut chosen: Vec = Vec::new(); for (k, v) in url::form_urlencoded::parse(&body) { match k.as_ref() { "title" => title = v.into_owned(), "body" => desc = v.into_owned(), "_csrf" => csrf = v.into_owned(), "label" => chosen.push(v.into_owned()), _ => {} } } if verify_csrf(&jar, &headers, Some(&csrf)).is_err() { return AppError::Forbidden.into_response(); } let result = async { let user = viewer.ok_or(AppError::Forbidden)?; let repo = state .store .repo_by_id(&repo_id) .await? .ok_or(AppError::NotFound)?; if q.is_pull || !repo.issues_enabled { return Err(AppError::NotFound); } // Read access is enough to open an issue. if access_for(&state, Some(&user), &repo).await? < auth::Access::Read { return Err(AppError::NotFound); } let title = title.trim(); if title.is_empty() { return Err(AppError::BadRequest("A title is required.".to_string())); } let issue = state .store .create_issue(&repo.id, &user.id, title, desc.trim(), false) .await?; // Apply any selected labels that belong to this repo. if !chosen.is_empty() { let valid: std::collections::HashSet = state .store .list_labels(&repo.id) .await? .into_iter() .map(|l| l.id) .collect(); for id in chosen.iter().filter(|id| valid.contains(*id)) { state.store.add_issue_label(&issue.id, id).await?; } } notify_mentions(&state, &user.id, &repo, &issue.id, desc.trim()).await; Ok::(issue_url(&state, &repo, &issue).await) } .await; respond_redirect(result) } /// Create a "mention" notification for every `@user` in `body` who can read the /// repo. Best-effort: failures are ignored. pub(crate) async fn notify_mentions( state: &AppState, actor_id: &str, repo: &model::Repo, issue_id: &str, body: &str, ) { for name in markdown::mentions(body) { let Ok(Some(user)) = state.store.user_by_username(&name).await else { continue; }; let collab = state .store .effective_permission(&repo.id, &user.id) .await .ok() .flatten() .and_then(|p| auth::Permission::parse(&p)); let viewer = auth::Viewer { id: user.id.clone(), is_admin: user.is_admin, }; let access = auth::access(Some(&viewer), repo, collab, state.allow_anonymous()); if access >= auth::Access::Read { let _ = state .store .create_notification(store::NewNotification { user_id: user.id, actor_id: actor_id.to_string(), kind: "mention".to_string(), repo_id: Some(repo.id.clone()), issue_id: Some(issue_id.to_string()), }) .await; } } } /// Edit-title form. #[derive(Debug, Deserialize)] pub struct EditTitleForm { /// New title. #[serde(default)] title: String, /// CSRF token. #[serde(rename = "_csrf")] csrf: String, } /// `POST /issue/{id}/edit-title` — edit an issue/PR's title (Write or author). pub async fn edit_title( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, headers: HeaderMap, Path(id): Path, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let result = async { let (repo, issue) = edit_target(&state, viewer.as_ref(), &id).await?; let title = form.title.trim(); if title.is_empty() { return Err(AppError::BadRequest("A title is required.".to_string())); } state .store .update_issue(&issue.id, title, &issue.body) .await?; Ok::(issue_url(&state, &repo, &issue).await) } .await; respond_redirect(result) } /// `POST /issue/{id}/edit-body` — edit an issue/PR's description (Write or /// author). Reuses [`CommentForm`] (the `body` field). pub async fn edit_body( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, headers: HeaderMap, Path(id): Path, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let result = async { let (repo, issue) = edit_target(&state, viewer.as_ref(), &id).await?; state .store .update_issue(&issue.id, &issue.title, form.body.trim()) .await?; Ok::(issue_url(&state, &repo, &issue).await) } .await; respond_redirect(result) } /// Resolve the issue and authorize the viewer to edit it (Write or the author). async fn edit_target( state: &AppState, viewer: Option<&User>, id: &str, ) -> AppResult<(model::Repo, Issue)> { let user = viewer.ok_or(AppError::Forbidden)?; let issue = state .store .issue_by_id(id) .await? .ok_or(AppError::NotFound)?; let (repo, access) = repo_of_issue(state, Some(user), &issue).await?; if access < auth::Access::Write && user.id != issue.author_id { return Err(AppError::Forbidden); } Ok((repo, issue)) } /// `POST /comment/{id}/edit` — edit a comment (Write or the comment's author). pub async fn comment_edit( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, headers: HeaderMap, Path(id): Path, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let result = async { let user = viewer.ok_or(AppError::Forbidden)?; let comment = state .store .comment_by_id(&id) .await? .ok_or(AppError::NotFound)?; let issue = state .store .issue_by_id(&comment.issue_id) .await? .ok_or(AppError::NotFound)?; let (repo, access) = repo_of_issue(&state, Some(&user), &issue).await?; if access < auth::Access::Write && user.id != comment.author_id { return Err(AppError::Forbidden); } let body = form.body.trim(); if !body.is_empty() { state.store.update_comment(&comment.id, body).await?; } Ok::(issue_url(&state, &repo, &issue).await) } .await; respond_redirect(result) } /// Comment form. #[derive(Debug, Deserialize)] pub struct CommentForm { /// Markdown body. #[serde(default)] body: String, /// CSRF token. #[serde(rename = "_csrf")] csrf: String, } /// `POST /issue/{id}/comment` — add a comment. pub async fn comment( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, headers: HeaderMap, Path(id): Path, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let result = async { let user = viewer.ok_or(AppError::Forbidden)?; let issue = state .store .issue_by_id(&id) .await? .ok_or(AppError::NotFound)?; let (repo, access) = repo_of_issue(&state, Some(&user), &issue).await?; if access < auth::Access::Read { return Err(AppError::NotFound); } // A locked conversation only accepts comments from writers. if issue.locked() && access < auth::Access::Write { return Err(AppError::BadRequest( "This conversation is locked.".to_string(), )); } let body = form.body.trim(); if !body.is_empty() { state.store.add_comment(&issue.id, &user.id, body).await?; notify_mentions(&state, &user.id, &repo, &issue.id, body).await; } Ok::(issue_url(&state, &repo, &issue).await) } .await; respond_redirect(result) } /// Lock/unlock form. #[derive(Debug, Deserialize)] pub struct LockForm { /// `true` to lock, else unlock. #[serde(default)] locked: String, /// CSRF token. #[serde(rename = "_csrf")] csrf: String, } /// `POST /issue/{id}/lock` — lock or unlock the conversation (Write only). pub async fn set_lock( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, headers: HeaderMap, Path(id): Path, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let result = async { let user = viewer.ok_or(AppError::Forbidden)?; let issue = state .store .issue_by_id(&id) .await? .ok_or(AppError::NotFound)?; let (repo, access) = repo_of_issue(&state, Some(&user), &issue).await?; if access < auth::Access::Write { return Err(AppError::Forbidden); } state .store .set_issue_locked(&issue.id, form.locked == "true") .await?; Ok::(issue_url(&state, &repo, &issue).await) } .await; respond_redirect(result) } /// State-change form. #[derive(Debug, Deserialize)] pub struct StateForm { /// `open` | `closed`. #[serde(default)] state: String, /// CSRF token. #[serde(rename = "_csrf")] csrf: String, } /// `POST /issue/{id}/state` — open/close (author or Write). pub async fn set_state( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, headers: HeaderMap, Path(id): Path, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let result = async { let user = viewer.ok_or(AppError::Forbidden)?; let issue = state .store .issue_by_id(&id) .await? .ok_or(AppError::NotFound)?; let (repo, access) = repo_of_issue(&state, Some(&user), &issue).await?; let allowed = access >= auth::Access::Write || user.id == issue.author_id; if !allowed { return Err(AppError::Forbidden); } state .store .set_issue_state(&issue.id, IssueState::from_token(&form.state)) .await?; Ok::(issue_url(&state, &repo, &issue).await) } .await; respond_redirect(result) } /// Assignee form. #[derive(Debug, Deserialize)] pub struct AssigneeForm { /// The assignee user id (empty clears it). #[serde(default)] assignee_id: String, /// CSRF token. #[serde(rename = "_csrf")] csrf: String, } /// `POST /issue/{id}/assignee` — set/clear the assignee (Write). pub async fn set_assignee( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, headers: HeaderMap, Path(id): Path, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let result = async { let user = viewer.ok_or(AppError::Forbidden)?; let issue = state .store .issue_by_id(&id) .await? .ok_or(AppError::NotFound)?; let (repo, access) = repo_of_issue(&state, Some(&user), &issue).await?; if access < auth::Access::Write { return Err(AppError::Forbidden); } let assignee = form.assignee_id.trim(); state .store .set_issue_assignee(&issue.id, (!assignee.is_empty()).then_some(assignee)) .await?; Ok::(issue_url(&state, &repo, &issue).await) } .await; respond_redirect(result) } /// `POST /issue/{id}/labels` — replace the applied labels (Write). Scoped labels /// are kept mutually exclusive within their scope by the checkbox set itself. pub async fn set_labels( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, headers: HeaderMap, Path(id): Path, body: axum::body::Bytes, ) -> Response { // Parse the repeated `label` fields and `_csrf` from the urlencoded body // (serde_urlencoded has no sequence support). let mut chosen: Vec = Vec::new(); let mut csrf = String::new(); for (k, v) in url::form_urlencoded::parse(&body) { match k.as_ref() { "label" => chosen.push(v.into_owned()), "_csrf" => csrf = v.into_owned(), _ => {} } } if verify_csrf(&jar, &headers, Some(&csrf)).is_err() { return AppError::Forbidden.into_response(); } let result = async { let user = viewer.ok_or(AppError::Forbidden)?; let issue = state .store .issue_by_id(&id) .await? .ok_or(AppError::NotFound)?; let (repo, access) = repo_of_issue(&state, Some(&user), &issue).await?; if access < auth::Access::Write { return Err(AppError::Forbidden); } // Only accept labels that belong to this repo. let repo_labels = state.store.list_labels(&repo.id).await?; let valid: std::collections::HashSet<&str> = repo_labels.iter().map(|l| l.id.as_str()).collect(); let applied = state.store.issue_labels(&issue.id).await?; // Remove those unchecked, add those newly checked. for l in &applied { if !chosen.iter().any(|c| c == &l.id) { state.store.remove_issue_label(&issue.id, &l.id).await?; } } for c in &chosen { if valid.contains(c.as_str()) && !applied.iter().any(|l| &l.id == c) { state.store.add_issue_label(&issue.id, c).await?; } } Ok::(issue_url(&state, &repo, &issue).await) } .await; respond_redirect(result) } /// Add-dependency form: the number of the blocking issue/PR. #[derive(Debug, Deserialize)] pub struct DepAddForm { /// The number of the issue/PR this one depends on. #[serde(default)] number: i64, /// CSRF token. #[serde(rename = "_csrf")] csrf: String, } /// `POST /issue/{id}/deps/add` — mark this issue/PR blocked by another (Write). pub async fn deps_add( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, headers: HeaderMap, Path(id): Path, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let result = async { let user = viewer.ok_or(AppError::Forbidden)?; let issue = state .store .issue_by_id(&id) .await? .ok_or(AppError::NotFound)?; let (repo, access) = repo_of_issue(&state, Some(&user), &issue).await?; if access < auth::Access::Write { return Err(AppError::Forbidden); } // Resolve the referenced number within the same repo (issue or PR). let target = state .store .issue_by_number_any(&repo.id, form.number) .await? .ok_or_else(|| AppError::BadRequest(format!("No issue or PR #{}.", form.number)))?; if target.id == issue.id { return Err(AppError::BadRequest( "An item cannot depend on itself.".to_string(), )); } state.store.add_dependency(&issue.id, &target.id).await?; Ok::(issue_url(&state, &repo, &issue).await) } .await; respond_redirect(result) } /// Remove-dependency form: the depended-on issue id. #[derive(Debug, Deserialize)] pub struct DepRemoveForm { /// The `issues` id this one no longer depends on. #[serde(default)] depends_on: String, /// CSRF token. #[serde(rename = "_csrf")] csrf: String, } /// `POST /issue/{id}/deps/remove` — drop a dependency edge (Write). pub async fn deps_remove( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, headers: HeaderMap, Path(id): Path, Form(form): Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let result = async { let user = viewer.ok_or(AppError::Forbidden)?; let issue = state .store .issue_by_id(&id) .await? .ok_or(AppError::NotFound)?; let (repo, access) = repo_of_issue(&state, Some(&user), &issue).await?; if access < auth::Access::Write { return Err(AppError::Forbidden); } state .store .remove_dependency(&issue.id, &form.depends_on) .await?; Ok::(issue_url(&state, &repo, &issue).await) } .await; respond_redirect(result) } /// Redirect on success, render the error otherwise. pub(crate) fn respond_redirect(result: AppResult) -> Response { match result { Ok(dest) => Redirect::to(&dest).into_response(), Err(err) => err.into_response(), } }