//! Parts shared by the issue page and the pull request page: permission checks, the timeline of //! comments and events, the sidebar (labels, assignees, milestone), the comment and state form, //! and the comment POST logic. Both pages render the same timeline; `ItemKind` picks the URLs. //! On a pull request the timeline also holds its submitted reviews, each with its threads. use std::collections::HashSet; use forge_core::{Action, Comment, Issue, IssueEvent, IssueState, Session, StateReason, User}; use forge_core::{Reaction, ReactionSummary, ReactionTarget}; use forge_core::{ReviewState, Thread, authorize, validate_body}; use serde::Deserialize; use serde_json::Value as Json; use topcoat::Result; use topcoat::router::error::{bad_request, forbidden, not_found}; use topcoat::view::{Unescaped, View, component, view}; use crate::auth::check_csrf; use crate::i18n::Lang; use crate::i18n::issues::{self as g, K}; use crate::notify::{self, Activity}; use crate::pages::edit_history::{edit_counts, history_href, thread_edit_counts}; use crate::pages::notifications::{NotifyBox, notify_box}; use crate::pages::repo_ctx::RepoCtx; use crate::pages::tree::link_context; use crate::render::markdown::{LinkContext, render_markdown}; use crate::state::AppState; use crate::views::review_thread::{ThreadCtx, review_thread_in}; use crate::views::user_facing_message; use crate::views::{ alert, avatar_url, display_name, iso_time, now_unix, relative_time, repo_href, short_oid, user_href, user_link, }; use crate::webhooks::emit::{Sender, emit_comment, emit_item_action}; /// Which kind of item a page shows. It decides the URL segment of the item's own links and which /// row `{number}` names (an issue row or a pull request's issue row). #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum ItemKind { Issue, Pull, } impl ItemKind { pub(crate) fn segment(self) -> &'static str { match self { ItemKind::Issue => "issues", ItemKind::Pull => "pulls", } } } // Permissions pub(crate) fn viewer(ctx: &RepoCtx) -> Option<&User> { ctx.viewer.as_ref().map(|(user, _)| user) } /// Triage or better on this repo (owner and instance admins included). Anonymous viewers never have it. pub(crate) fn triage(ctx: &RepoCtx) -> bool { authorize( viewer(ctx), &ctx.repo, ctx.role, ctx.org_role, Action::Triage, ) .is_ok() } /// Whether the viewer may add to an item's conversation: comments, reactions, review threads and /// reviews. A locked conversation takes these from Triage holders only. Authors keep their edits. pub(crate) fn may_converse(ctx: &RepoCtx, item: &Issue) -> bool { !item.locked || triage(ctx) } /// The notice shown in place of the comment form on a conversation the viewer may not add to. pub(crate) fn lock_message(lang: Lang, item: &Issue) -> String { match item.lock_reason.as_deref() { Some(reason) => g::conversation_locked_for(lang, lock_reason_label(lang, reason)), None => g::t(lang, K::ConversationLocked).to_owned(), } } /// The reader-facing text of a lock reason. Unknown values are shown as stored. pub(crate) fn lock_reason_label(lang: Lang, reason: &str) -> &str { match reason { "off_topic" => g::t(lang, K::LockOffTopic), "too_heated" => g::t(lang, K::LockTooHeated), "resolved" => g::t(lang, K::LockResolved), "spam" => g::t(lang, K::LockSpam), other => other, } } /// Whether the viewer may edit or delete an item by `author_id`: its author or a Triage holder, /// signed in, on a repo that is not archived. pub(crate) fn may_change(ctx: &RepoCtx, author_id: &str) -> bool { !ctx.repo.archived && viewer(ctx).is_some_and(|user| user.id == author_id || triage(ctx)) } /// Write requests need an unarchived repo and a matching CSRF token. pub(crate) fn check_write(ctx: &RepoCtx, session: &Session, csrf: &str) -> Result<()> { if ctx.repo.archived || !check_csrf(session, csrf) { return Err(forbidden().into()); } Ok(()) } pub(crate) fn csrf_of(ctx: &RepoCtx) -> String { ctx.viewer .as_ref() .map(|(_, session)| session.csrf_token.clone()) .unwrap_or_default() } // Lookups /// Parses an issue number: digits only, greater than zero. Anything else is 404. pub(crate) fn parse_number(raw: &str) -> Result { match raw.parse::() { Ok(n) if n > 0 && raw.bytes().all(|b| b.is_ascii_digit()) => Ok(n), _ => Err(not_found().into()), } } pub(crate) async fn issue_for(state: &AppState, ctx: &RepoCtx, number: i64) -> Result { match state.store.issue_by_number(&ctx.repo.id, number).await? { Some(issue) => Ok(issue), None => Err(not_found().into()), } } /// The item of `kind` numbered `number` in this repo, as its issue row, or 404. A pull request /// is the issue row it carries, so the comments and edit history are the same for both kinds. pub(crate) async fn item_for( state: &AppState, ctx: &RepoCtx, kind: ItemKind, number: i64, ) -> Result { match kind { ItemKind::Issue => issue_for(state, ctx, number).await, ItemKind::Pull => match state.store.pull_by_number(&ctx.repo.id, number).await? { Some(pr) => Ok(pr.issue), None => Err(not_found().into()), }, } } /// A comment of this item. A comment id from another item is 404. pub(crate) async fn comment_for( state: &AppState, item: &Issue, comment_id: &str, ) -> Result { match state.store.comment_by_id(&item.id, comment_id).await? { Some(comment) => Ok(comment), None => Err(not_found().into()), } } // Times /// A stored timestamp shown as an absolute `title` and a relative label. #[derive(Clone)] pub(crate) struct When { pub(crate) iso: String, pub(crate) absolute: String, pub(crate) relative: String, } impl When { pub(crate) fn new(lang: Lang, stamp: &str, tz: &str, now: i64) -> Self { let unix = stamp_unix(stamp); Self { iso: iso_time(unix), absolute: forge_core::tz::format_in_tz(unix, tz), relative: relative_time(lang, unix, now), } } } /// Unix seconds of a stored RFC 3339 UTC stamp (`YYYY-MM-DDTHH:MM:SS[.frac]Z`). Malformed input is 0. pub(crate) fn stamp_unix(stamp: &str) -> i64 { let field = |range: std::ops::Range| { stamp .get(range) .and_then(|digits| digits.parse::().ok()) }; let (Some(y), Some(mo), Some(d), Some(h), Some(mi), Some(s)) = ( field(0..4), field(5..7), field(8..10), field(11..13), field(14..16), field(17..19), ) else { return 0; }; days_from_civil(y, mo, d) * 86_400 + h * 3_600 + mi * 60 + s } /// Days since 1970-01-01 for a civil date (Howard Hinnant's days-from-civil). fn days_from_civil(year: i64, month: i64, day: i64) -> i64 { let y = if month <= 2 { year - 1 } else { year }; let era = y.div_euclid(400); let yoe = y - era * 400; let mp = (month + 9) % 12; let doy = (153 * mp + 2) / 5 + day - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; era * 146_097 + doe - 719_468 } pub(crate) fn is_hex_oid(s: &str) -> bool { (4..=64).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_hexdigit()) } // Timeline /// One comment, or the item body (which uses the same item shape without a delete action). pub(crate) struct CommentView { anchor: String, author: String, /// The author's username, the link target of the name and avatar. login: String, avatar: String, when: When, html: String, /// Set when the text has earlier versions: links to their history. history_href: Option, edit_href: Option, delete_href: Option, csrf: String, /// The reaction row under the text; `None` when there is nothing to show. reactions: Option, } /// One reaction button: a POST toggle when `enabled`, a plain count otherwise. pub(crate) struct ReactionButton { pub(crate) action: String, pub(crate) csrf: String, pub(crate) target_kind: &'static str, pub(crate) target_id: String, pub(crate) name: &'static str, pub(crate) emoji: &'static str, pub(crate) count: usize, pub(crate) title: String, pub(crate) active: bool, pub(crate) enabled: bool, } /// The reactions of one target: the existing ones, then the picker of every emoji. The picker is /// only shown when `enabled`. pub(crate) struct ReactionBar { pub(crate) chips: Vec, pub(crate) picker: Vec, pub(crate) enabled: bool, } /// One timeline event as a line: the actor, `before`, an optional commit link, then `after`. pub(crate) struct EventLine { actor: String, when: When, before: String, link_href: Option, link_label: String, after: String, } /// A submitted review of a pull request: its verb, its body (when it has one) and the threads it /// opened, which render under it with their diff hunks. pub(crate) struct ReviewView { anchor: String, author: String, login: String, avatar: String, when: When, verb: &'static str, dismissed: bool, dismissed_reason: String, html: Option, threads: Vec, ctx: ThreadCtx, } pub(crate) enum Entry { Comment(CommentView), Event(EventLine), Review(ReviewView), } /// The item body and its timeline: comments and events merged by creation time. pub(crate) struct Discussion { /// The item's author, by display name, and when it was opened. pub(crate) author: String, /// The author's username, for the profile link. pub(crate) author_login: String, pub(crate) opened: When, pub(crate) body: CommentView, pub(crate) entries: Vec, } /// The one-line text for an event. `base` is `/{owner}/{repo}`; `base_branch` names the target /// branch for merged events (empty for issues). fn event_line( lang: Lang, event: &IssueEvent, base: &str, base_branch: &str, actor: String, when: When, ) -> EventLine { let text = |key: &str| { event .data .get(key) .and_then(Json::as_str) .unwrap_or("") .to_owned() }; let commit = text("commit"); let before = match event.kind.as_str() { "closed" => match text("reason").as_str() { "completed" => g::t(lang, K::ClosedCompleted).to_owned(), "not_planned" => g::t(lang, K::ClosedNotPlanned).to_owned(), _ => g::t(lang, K::ClosedThis).to_owned(), }, "reopened" => g::t(lang, K::ReopenedThis).to_owned(), "labeled" => g::event_label_added(lang, &text("label")), "unlabeled" => g::event_label_removed(lang, &text("label")), "assigned" => g::event_assigned(lang, &text("user")), "unassigned" => g::event_unassigned(lang, &text("user")), "milestoned" => g::event_milestone_added(lang, &text("title")), "demilestoned" => g::event_milestone_removed(lang, &text("title")), "renamed" => g::event_renamed(lang, &text("from"), &text("to")), "referenced" => g::t(lang, K::ReferencedThis).to_owned(), "locked" => match text("reason").as_str() { "" => g::t(lang, K::LockedConversation).to_owned(), reason => g::event_locked_as(lang, lock_reason_label(lang, reason)), }, "unlocked" => g::t(lang, K::UnlockedConversation).to_owned(), "pinned" => g::t(lang, K::PinnedThisIssue).to_owned(), "unpinned" => g::t(lang, K::UnpinnedThisIssue).to_owned(), "merged" => g::t(lang, K::MergedCommit).to_owned(), "head_updated" => g::t(lang, K::PushedNewCommits).to_owned(), "head_deleted" => g::t(lang, K::DeletedHeadBranch).to_owned(), "head_ref_deleted" => g::event_branch_deleted(lang, &text("branch")), "head_ref_restored" => g::event_branch_restored(lang, &text("branch")), "review_dismissed" => format!("{}{}", g::t(lang, K::ReviewDismissedPrefix), text("reason")), "thread_resolved" => g::event_thread_resolved(lang, &text("path")), "thread_unresolved" => g::event_thread_unresolved(lang, &text("path")), "base_changed" => g::event_base_changed(lang, &text("from"), &text("to")), other => other.to_owned(), }; let (link_href, link_label) = if is_hex_oid(&commit) { ( Some(format!("{base}/commit/{commit}")), short_oid(&commit).to_owned(), ) } else { (None, String::new()) }; let (from, to) = (text("from"), text("to")); let (before, after) = match event.kind.as_str() { kind @ ("closed" | "referenced" | "merged") => { g::commit_event_sides(lang, kind, &before, base_branch, link_href.is_some()) } "head_updated" if !from.is_empty() && !to.is_empty() => ( before, g::event_pushed_range(lang, short_oid(&from), short_oid(&to)), ), _ => (before, String::new()), }; EventLine { actor, when, before, link_href, link_label, after, } } #[component] pub(crate) async fn comment_item(lang: Lang, item: CommentView) -> Result { let CommentView { anchor, author, login, avatar, when, html, history_href, edit_href, delete_href, csrf, reactions, } = item; let profile = user_href(&login); Ok(view! {
user_link(shown: author.as_str(), login: login.as_str()) if let Some(href) = history_href { (g::t(lang, K::Edited)) } if let Some(href) = edit_href { (g::t(lang, K::Edit)) } if let Some(action) = delete_href {
}
(Unescaped::new_unchecked(html))
if let Some(bar) = reactions { reaction_row(lang: lang, bar: bar) }
}) } /// The reaction row under an item or comment: the buttons, then the picker when the viewer may react. #[component] pub(crate) async fn reaction_row(lang: Lang, bar: ReactionBar) -> Result { let ReactionBar { chips, picker, enabled, } = bar; Ok(view! {
for chip in chips.into_iter() { reaction_button(button: chip) } if enabled {
(g::t(lang, K::AddReaction))
for option in picker.into_iter() { reaction_button(button: option) }
}
}) } /// One reaction button. Enabled buttons are POST forms that toggle the viewer's reaction; the /// others only show the emoji and count. #[component] pub(crate) async fn reaction_button(button: ReactionButton) -> Result { let ReactionButton { action, csrf, target_kind, target_id, name, emoji, count, title, active, enabled, } = button; let count_text = count.to_string(); let class = if active { "reaction reaction-active" } else { "reaction" }; Ok(view! { if enabled {
} if !enabled { (emoji) if count > 0 { (count_text.as_str()) } } }) } #[component] pub(crate) async fn event_item(line: EventLine) -> Result { let EventLine { actor, when, before, link_href, link_label, after, } = line; Ok(view! {
(actor.as_str()) " " (before.as_str()) if let Some(href) = link_href { " " (link_label.as_str()) } if !after.is_empty() { " " (after.as_str()) } " "
}) } /// The verb of a dismissed review: the verb it had before dismissal, marked as dismissed. Reviews /// dismissed before the previous state was recorded keep the generic verb. fn dismissed_verb(lang: Lang, from: Option) -> &'static str { match from { Some(ReviewState::Approved) => g::t(lang, K::ApprovedDismissed), Some(ReviewState::ChangesRequested) => g::t(lang, K::RequestedDismissed), _ => g::t(lang, K::ReviewedDismissed), } } /// One submitted review: the reviewer, the verb, the dismissal reason, the body, then its threads. #[component] pub(crate) async fn review_item(lang: Lang, review: ReviewView) -> Result { let ReviewView { anchor, author, login, avatar, when, verb, dismissed, dismissed_reason, html, threads, ctx, } = review; let profile = user_href(&login); let verb_class = if dismissed { "review-verb review-verb-dismissed" } else { "review-verb" }; Ok(view! {
user_link(shown: author.as_str(), login: login.as_str()) (verb)
if dismissed {

(g::t(lang, K::DismissedPrefix)) (dismissed_reason.as_str())

} if let Some(body) = html {
(Unescaped::new_unchecked(body))
} for thread in threads.into_iter() { review_thread_in(lang: lang, thread: thread, ctx: ctx.clone(), show_hunk: true, return_to: "conversation") }
}) } #[component] pub(crate) async fn timeline_entry(lang: Lang, entry: Entry) -> Result { Ok(view! { match entry { Entry::Comment(item) => comment_item(lang: lang, item: item), Entry::Event(line) => event_item(line: line), Entry::Review(review) => review_item(lang: lang, review: review), } }) } /// The body followed by every timeline entry. #[component] pub(crate) async fn discussion_timeline(lang: Lang, discussion: Discussion) -> Result { let Discussion { body, entries, .. } = discussion; Ok(view! {
comment_item(lang: lang, item: body) for entry in entries.into_iter() { timeline_entry(lang: lang, entry: entry) }
}) } /// Loads the body and the timeline of `item`, which is an issue or a pull request. Authors and /// event actors are looked up in one batch. pub(crate) async fn load_discussion( lang: Lang, state: &AppState, ctx: &RepoCtx, item: &Issue, kind: ItemKind, base_branch: &str, ) -> Result { let store = &state.store; let owner = ctx.repo.owner_name.clone(); let name = ctx.repo.name.clone(); let number = item.number; let base = repo_href(&owner, &name, "", ""); let segment = kind.segment(); let comments = store.list_comments(&item.id).await?; let events = store.list_events(&item.id).await?; // One batched lookup for every author and event actor on the page. let mut ids: Vec = std::iter::once(item.author_id.clone()) .chain(comments.iter().map(|c| c.author_id.clone())) .chain(events.iter().filter_map(|e| e.actor_id.clone())) .collect(); ids.sort(); ids.dedup(); let users = store.usernames_by_ids(&ids).await?; let who = |id: &str| -> (String, Option) { match users.get(id) { Some((display, hash)) => (display.clone(), hash.clone()), None => (g::t(lang, K::Unknown).to_owned(), None), } }; let tz = ctx.viewer_tz().to_owned(); let now = now_unix(); let link = link_context(state, &ctx.repo, &ctx.repo.default_branch, ""); let csrf = csrf_of(ctx); // Reactions: one batched read for the body and one for all comments. Viewers who cannot react // (signed out, or an archived repo) see the counts without buttons. let viewer_id = viewer(ctx).map(|user| user.id.clone()); let can_react = viewer_id.is_some() && !ctx.repo.archived && may_converse(ctx, item); let reaction_action = repo_href(&owner, &name, segment, &format!("{number}/reactions")); let comment_id_list: Vec = comments.iter().map(|c| c.id.clone()).collect(); let body_reaction_map = store .reactions_for( ReactionTarget::Issue, std::slice::from_ref(&item.id), viewer_id.as_deref(), ) .await?; let comment_reaction_map = store .reactions_for( ReactionTarget::Comment, &comment_id_list, viewer_id.as_deref(), ) .await?; // Body: first entry of the timeline. let (author, author_hash) = who(&item.author_id); let body_source = if item.body.trim().is_empty() { g::t(lang, K::NoDescription) } else { item.body.as_str() }; let body_anchor = match kind { ItemKind::Issue => format!("issue-{number}"), ItemKind::Pull => format!("pull-{number}"), }; let opened = When::new(lang, &item.created_at, &tz, now); let body_edits = edit_counts(state, "issue", &[item.id.as_str()]).await?; let body_history = body_edits .get(&item.id) .is_some_and(|count| *count > 0) .then(|| history_href(&owner, &name, kind, number, "issue", &item.id)); let body = CommentView { anchor: body_anchor, author: display_name(lang, &author), login: author.clone(), avatar: avatar_url(&author, author_hash.as_deref()), when: opened.clone(), html: render_markdown(body_source, Some(&link)), history_href: body_history, edit_href: may_change(ctx, &item.author_id) .then(|| repo_href(&owner, &name, segment, &format!("{number}/edit"))), delete_href: None, csrf: csrf.clone(), reactions: reaction_bar_for( lang, body_reaction_map.get(&item.id), can_react, &reaction_action, &csrf, ReactionTarget::Issue, &item.id, ), }; // Comments and events, merged by creation time. let comment_ids: Vec<&str> = comments.iter().map(|c| c.id.as_str()).collect(); let comment_edits = edit_counts(state, "comment", &comment_ids).await?; let mut keyed: Vec<(String, Entry)> = Vec::new(); for comment in comments { let (who_name, hash) = who(&comment.author_id); let mine = may_change(ctx, &comment.author_id); let comment_path = format!("{number}/comments/{}", comment.id); let edited = comment_edits .get(&comment.id) .is_some_and(|count| *count > 0); let item_view = CommentView { anchor: format!("comment-{}", comment.id), author: display_name(lang, &who_name), login: who_name.clone(), avatar: avatar_url(&who_name, hash.as_deref()), when: When::new(lang, &comment.created_at, &tz, now), html: render_markdown(&comment.body, Some(&link)), history_href: edited .then(|| history_href(&owner, &name, kind, number, "comment", &comment.id)), edit_href: mine .then(|| repo_href(&owner, &name, segment, &format!("{comment_path}/edit"))), delete_href: mine .then(|| repo_href(&owner, &name, segment, &format!("{comment_path}/delete"))), csrf: csrf.clone(), reactions: reaction_bar_for( lang, comment_reaction_map.get(&comment.id), can_react, &reaction_action, &csrf, ReactionTarget::Comment, &comment.id, ), }; keyed.push((comment.created_at.clone(), Entry::Comment(item_view))); } for event in &events { let actor = match &event.actor_id { Some(id) => display_name(lang, &who(id).0), None => g::t(lang, K::System).to_owned(), }; let when = When::new(lang, &event.created_at, &tz, now); let line = event_line(lang, event, &base, base_branch, actor, when); keyed.push((event.created_at.clone(), Entry::Event(line))); } if kind == ItemKind::Pull { keyed.extend(review_entries(lang, state, ctx, item, &link, &tz, now).await?); } keyed.sort_by(|a, b| a.0.cmp(&b.0)); let entries: Vec = keyed.into_iter().map(|(_, entry)| entry).collect(); Ok(Discussion { author: display_name(lang, &author), author_login: author, opened, body, entries, }) } /// The reaction bar of one target, or `None` when there is nothing to show (no reactions and no /// way to react). Every reaction in the set gets a picker button. pub(crate) fn reaction_bar_for( lang: Lang, summaries: Option<&Vec>, can_react: bool, action: &str, csrf: &str, kind: ReactionTarget, target_id: &str, ) -> Option { let summaries: &[ReactionSummary] = summaries.map_or(&[], Vec::as_slice); if summaries.is_empty() && !can_react { return None; } let button = |name: &'static str, emoji: &'static str, count: usize, title: String, active: bool| { ReactionButton { action: action.to_owned(), csrf: csrf.to_owned(), target_kind: kind.as_str(), target_id: target_id.to_owned(), name, emoji, count, title, active, enabled: can_react, } }; let chips = summaries .iter() .map(|s| { button( s.reaction.name(), s.reaction.emoji(), s.count, reaction_title(lang, &s.users, s.count), s.reacted_by_viewer, ) }) .collect(); let picker = Reaction::ALL .into_iter() .map(|r| button(r.name(), r.emoji(), 0, r.name().to_owned(), false)) .collect(); Some(ReactionBar { chips, picker, enabled: can_react, }) } /// The hover text of a reaction: who made it, and how many more beyond the first names. fn reaction_title(lang: Lang, users: &[String], count: usize) -> String { let names = users.join(", "); match count.saturating_sub(users.len()) { 0 => names, more => g::reaction_more(lang, &names, more), } } /// The submitted reviews of a pull request, each with the threads it opened, keyed by submission /// time. A `commented` review with no body and no root comment only holds replies, which show /// inside the threads they answer, so it gets no entry of its own. async fn review_entries( lang: Lang, state: &AppState, ctx: &RepoCtx, item: &Issue, link: &LinkContext, tz: &str, now: i64, ) -> Result> { let store = &state.store; let reviews = store.list_reviews(&item.id).await?; let viewer_id = viewer(ctx).map(|user| user.id.clone()); let threads = store.review_threads(&item.id, viewer_id.as_deref()).await?; let archived = ctx.repo.archived; let can_comment = viewer_id.is_some() && !archived && may_converse(ctx, item); let is_author = viewer_id.as_deref() == Some(item.author_id.as_str()); let can_write = !archived && authorize( viewer(ctx), &ctx.repo, ctx.role, ctx.org_role, Action::Write, ) .is_ok(); let edits = thread_edit_counts(state, &threads).await?; let thread_ctx = ThreadCtx { pr_href: repo_href( &ctx.repo.owner_name, &ctx.repo.name, ItemKind::Pull.segment(), &item.number.to_string(), ), csrf: csrf_of(ctx), viewer_id: viewer_id.clone(), can_comment, can_resolve: can_comment && (is_author || can_write), can_moderate: can_comment && triage(ctx), can_review: can_comment && matches!(item.state, IssueState::Open), tz: tz.to_owned(), link: Some(link.clone()), edits, }; let mut keyed = Vec::new(); for review in reviews { let own: Vec = threads .iter() .filter(|thread| thread.root.review_id == review.id) .cloned() .collect(); let body = review.body.trim(); if review.state == ReviewState::Commented && body.is_empty() && own.is_empty() { continue; } let (verb, dismissed) = match review.state { ReviewState::Approved => (g::t(lang, K::ApprovedChanges), false), ReviewState::ChangesRequested => (g::t(lang, K::RequestedChanges), false), ReviewState::Dismissed => (dismissed_verb(lang, review.dismissed_from), true), ReviewState::Pending | ReviewState::Commented => (g::t(lang, K::Reviewed), false), }; let stamp = review .submitted_at .clone() .unwrap_or_else(|| review.created_at.clone()); let view = ReviewView { anchor: format!("review-{}", review.id), author: display_name(lang, &review.author_name), login: review.author_name.clone(), avatar: avatar_url(&review.author_name, None), when: When::new(lang, &stamp, tz, now), verb, dismissed, dismissed_reason: review.dismissed_reason.clone().unwrap_or_default(), html: (!body.is_empty()).then(|| render_markdown(&review.body, Some(link))), threads: own, ctx: thread_ctx.clone(), }; keyed.push((stamp, Entry::Review(view))); } Ok(keyed) } // Sidebar pub(crate) struct LabelRow { id: String, name: String, chip: String, attached: bool, } pub(crate) struct MilestoneRow { id: String, title: String, selected: bool, } /// The sidebar data of one item: its labels, assignees and milestone, and the repo's choices. pub(crate) struct Sidebar { labels: Vec, assignees: Vec, milestones: Vec, current_milestone: String, none_selected: bool, locked: bool, lock_reason: Option, } pub(crate) async fn load_sidebar(state: &AppState, repo_id: &str, item: &Issue) -> Result { let store = &state.store; let all_labels = store.list_labels(repo_id).await?; let attached = store.issue_labels(&item.id).await?; let assignees = store.issue_assignees(&item.id).await?; let milestones = store.list_milestones(repo_id, None).await?; let attached_ids: HashSet<&str> = attached.iter().map(|l| l.id.as_str()).collect(); let labels: Vec = all_labels .iter() .map(|l| LabelRow { id: l.id.clone(), name: l.name.clone(), chip: format!("label-chip label-{}", l.id), attached: attached_ids.contains(l.id.as_str()), }) .collect(); let assignees: Vec = assignees.into_iter().map(|(_, name)| name).collect(); let milestones: Vec = milestones .iter() .map(|m| MilestoneRow { id: m.id.clone(), title: m.title.clone(), selected: item.milestone_id.as_deref() == Some(m.id.as_str()), }) .collect(); let none_selected = !milestones.iter().any(|m| m.selected); let current_milestone = milestones .iter() .find(|m| m.selected) .map(|m| m.title.clone()) .unwrap_or_default(); Ok(Sidebar { labels, assignees, milestones, current_milestone, none_selected, locked: item.locked, lock_reason: item.lock_reason.clone(), }) } #[component] pub(crate) async fn labels_box( lang: Lang, write: bool, action: String, csrf: String, labels: Vec, ) -> Result { let any_attached = labels.iter().any(|row| row.attached); Ok(view! {

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

if write {
if labels.is_empty() {

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

} for row in labels.iter() { }
} else { if !any_attached {

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

}
for row in labels.iter() { if row.attached { (row.name.as_str()) } }
}
}) } #[component] pub(crate) async fn assignees_box( lang: Lang, write: bool, action: String, csrf: String, names: Vec, ) -> Result { let text = names.join(", "); Ok(view! {

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

if write {

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

} else { if names.is_empty() {

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

}
    for name in names.iter() {
  • (name.as_str())
  • }
}
}) } #[component] pub(crate) async fn milestone_box( lang: Lang, write: bool, action: String, csrf: String, options: Vec, current: String, none_selected: bool, ) -> Result { Ok(view! {

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

if write {
} else { if current.is_empty() {

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

} else {

(current.as_str())

} }
}) } /// The conversation lock. Triage holders get a form that locks it, with a reason, or unlocks it. /// Everyone else sees the lock state only when there is one. #[component] pub(crate) async fn lock_box( lang: Lang, write: bool, action: String, csrf: String, locked: bool, reason: Option, ) -> Result { let reason_text = reason .as_deref() .map(|r| lock_reason_label(lang, r)) .unwrap_or("") .to_owned(); let none = reason.is_none(); Ok(view! {

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

if write {
if locked {

(g::t(lang, K::Locked)) if !none { " (" (reason_text.as_str()) ")" }

} else {
}
} else {

if locked { (g::t(lang, K::LockedToCollaborators)) if !none { " (" (reason_text.as_str()) ")" } } else { (g::t(lang, K::OpenToEveryone)) }

}
}) } /// The sidebar boxes. `base` is the item's URL; the forms post to `{base}/labels`, `/assignees` /// and `/milestone`. `write` shows the forms instead of the read-only lists. #[component] pub(crate) async fn item_sidebar( lang: Lang, write: bool, base: String, csrf: String, sidebar: Sidebar, notify: Option, ) -> Result { let Sidebar { labels, assignees, milestones, current_milestone, none_selected, locked, lock_reason, } = sidebar; let labels_action = format!("{base}/labels"); let assignees_action = format!("{base}/assignees"); let milestone_action = format!("{base}/milestone"); let lock_action = format!("{base}/lock"); Ok(view! { labels_box(lang: lang, write: write, action: labels_action, csrf: csrf.clone(), labels: labels) assignees_box(lang: lang, write: write, action: assignees_action, csrf: csrf.clone(), names: assignees) milestone_box(lang: lang, write: write, action: milestone_action, csrf: csrf.clone(), options: milestones, current: current_milestone, none_selected: none_selected) lock_box(lang: lang, write: write, action: lock_action, csrf: csrf.clone(), locked: locked, reason: lock_reason) if let Some(notify) = notify { notify_box(lang: lang, action: notify.action, csrf: notify.csrf, subscribed: notify.subscribed, reason: notify.reason) } }) } // Comment form /// The comment box with its close, reopen and comment buttons. The comment is posted to /// `action`; the close and reopen buttons post to `state_action` (the same URL for issues). #[component] pub(crate) async fn comment_form( lang: Lang, action: String, state_action: String, csrf: String, draft: String, error: Option, can_close: bool, open: bool, ) -> Result { Ok(view! {

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

if let Some(message) = error { alert(message: message.as_str()) }
if can_close { if open { } else { } }
}) } // Comment and state POSTs /// The form fields of a comment or state POST. #[derive(Deserialize)] pub(crate) struct CommentForm { #[serde(default)] pub(crate) csrf: String, #[serde(default)] pub(crate) body: String, #[serde(default)] pub(crate) action: String, } /// What a comment or state POST did. pub(crate) enum Posted { /// Written. `comment_id` is the new comment, when there was text. Saved { comment_id: Option }, /// Not written; `message` goes back to the form. Refused { message: String }, } /// Adds a comment and/or changes the state of an issue or pull request (`pull` says which). Only /// the author or a Triage holder may close or reopen; otherwise 403 and nothing is written. An /// empty comment with no state change, or a body that fails validation, is `Refused`. pub(crate) async fn post_comment( lang: Lang, state: &AppState, ctx: &RepoCtx, user: &User, item: &Issue, pull: bool, form: &CommentForm, ) -> Result { let change = match form.action.as_str() { "" | "comment" => None, "close_completed" => Some((IssueState::Closed, Some(StateReason::Completed))), "close_not_planned" => Some((IssueState::Closed, Some(StateReason::NotPlanned))), "reopen" => Some((IssueState::Open, None)), _ => return Err(bad_request("unknown action").into()), }; if change.is_some() && !may_change(ctx, &item.author_id) { return Err(forbidden().into()); } let has_text = !form.body.trim().is_empty(); if has_text && !may_converse(ctx, item) { return Err(forbidden().into()); } if change.is_none() && !has_text { return Ok(Posted::Refused { message: g::t(lang, K::CommentEmpty).to_owned(), }); } if has_text && let Err(e) = validate_body(&form.body) { let message = user_facing_message(&e).unwrap_or_else(|| e.to_string()); return Ok(Posted::Refused { message }); } let sender = Sender::of(user); let mut comment_id = None; if has_text { let comment = state .store .add_comment(&item.id, &user.id, &form.body) .await?; emit_comment(state, &ctx.repo, &sender, "created", pull, item, &comment).await; notify::dispatch( state, &ctx.repo, item, &user.id, Activity::Comment, Some(&comment.body), ) .await; comment_id = Some(comment.id); } if let Some((next, reason)) = change { let changed = state .store .set_issue_state(&item.id, Some(&user.id), next, reason, Json::Null) .await?; if changed { let (action, activity) = if next == IssueState::Closed { ("closed", Activity::Closed) } else { ("reopened", Activity::Reopened) }; emit_item_action(state, &ctx.repo, &sender, pull, item.number, action).await; if pull && action == "reopened" && let Some(pr) = state .store .pull_by_number(&ctx.repo.id, item.number) .await? { crate::ci::on_pull_event(state, &ctx.repo, &pr, &user.id).await; } notify::dispatch(state, &ctx.repo, item, &user.id, activity, None).await; } } Ok(Posted::Saved { comment_id }) }