forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
47.5 KiB
1//! Parts shared by the issue page and the pull request page: permission checks, the timeline of
2//! comments and events, the sidebar (labels, assignees, milestone), the comment and state form,
3//! and the comment POST logic. Both pages render the same timeline; `ItemKind` picks the URLs.
4//! On a pull request the timeline also holds its submitted reviews, each with its threads.
5
6use std::collections::HashSet;
7
8use forge_core::{Action, Comment, Issue, IssueEvent, IssueState, Session, StateReason, User};
9use forge_core::{Reaction, ReactionSummary, ReactionTarget};
10use forge_core::{ReviewState, Thread, authorize, validate_body};
11use serde::Deserialize;
12use serde_json::Value as Json;
13use topcoat::Result;
14use topcoat::router::error::{bad_request, forbidden, not_found};
15use topcoat::view::{Unescaped, View, component, view};
16
17use crate::auth::check_csrf;
18use crate::i18n::Lang;
19use crate::i18n::issues::{self as g, K};
20use crate::notify::{self, Activity};
21use crate::pages::edit_history::{edit_counts, history_href, thread_edit_counts};
22use crate::pages::notifications::{NotifyBox, notify_box};
23use crate::pages::repo_ctx::RepoCtx;
24use crate::pages::tree::link_context;
25use crate::render::markdown::{LinkContext, render_markdown};
26use crate::state::AppState;
27use crate::views::review_thread::{ThreadCtx, review_thread_in};
28use crate::views::user_facing_message;
29use crate::views::{
30 alert, avatar_url, display_name, iso_time, now_unix, relative_time, repo_href, short_oid,
31 user_href, user_link,
32};
33use crate::webhooks::emit::{Sender, emit_comment, emit_item_action};
34
35/// Which kind of item a page shows. It decides the URL segment of the item's own links and which
36/// row `{number}` names (an issue row or a pull request's issue row).
37#[derive(Clone, Copy, PartialEq, Eq)]
38pub(crate) enum ItemKind {
39 Issue,
40 Pull,
41}
42
43impl ItemKind {
44 pub(crate) fn segment(self) -> &'static str {
45 match self {
46 ItemKind::Issue => "issues",
47 ItemKind::Pull => "pulls",
48 }
49 }
50}
51
52// Permissions
53
54pub(crate) fn viewer(ctx: &RepoCtx) -> Option<&User> {
55 ctx.viewer.as_ref().map(|(user, _)| user)
56}
57
58/// Triage or better on this repo (owner and instance admins included). Anonymous viewers never have it.
59pub(crate) fn triage(ctx: &RepoCtx) -> bool {
60 authorize(
61 viewer(ctx),
62 &ctx.repo,
63 ctx.role,
64 ctx.org_role,
65 Action::Triage,
66 )
67 .is_ok()
68}
69
70/// Whether the viewer may add to an item's conversation: comments, reactions, review threads and
71/// reviews. A locked conversation takes these from Triage holders only. Authors keep their edits.
72pub(crate) fn may_converse(ctx: &RepoCtx, item: &Issue) -> bool {
73 !item.locked || triage(ctx)
74}
75
76/// The notice shown in place of the comment form on a conversation the viewer may not add to.
77pub(crate) fn lock_message(lang: Lang, item: &Issue) -> String {
78 match item.lock_reason.as_deref() {
79 Some(reason) => g::conversation_locked_for(lang, lock_reason_label(lang, reason)),
80 None => g::t(lang, K::ConversationLocked).to_owned(),
81 }
82}
83
84/// The reader-facing text of a lock reason. Unknown values are shown as stored.
85pub(crate) fn lock_reason_label(lang: Lang, reason: &str) -> &str {
86 match reason {
87 "off_topic" => g::t(lang, K::LockOffTopic),
88 "too_heated" => g::t(lang, K::LockTooHeated),
89 "resolved" => g::t(lang, K::LockResolved),
90 "spam" => g::t(lang, K::LockSpam),
91 other => other,
92 }
93}
94
95/// Whether the viewer may edit or delete an item by `author_id`: its author or a Triage holder,
96/// signed in, on a repo that is not archived.
97pub(crate) fn may_change(ctx: &RepoCtx, author_id: &str) -> bool {
98 !ctx.repo.archived && viewer(ctx).is_some_and(|user| user.id == author_id || triage(ctx))
99}
100
101/// Write requests need an unarchived repo and a matching CSRF token.
102pub(crate) fn check_write(ctx: &RepoCtx, session: &Session, csrf: &str) -> Result<()> {
103 if ctx.repo.archived || !check_csrf(session, csrf) {
104 return Err(forbidden().into());
105 }
106 Ok(())
107}
108
109pub(crate) fn csrf_of(ctx: &RepoCtx) -> String {
110 ctx.viewer
111 .as_ref()
112 .map(|(_, session)| session.csrf_token.clone())
113 .unwrap_or_default()
114}
115
116// Lookups
117
118/// Parses an issue number: digits only, greater than zero. Anything else is 404.
119pub(crate) fn parse_number(raw: &str) -> Result<i64> {
120 match raw.parse::<i64>() {
121 Ok(n) if n > 0 && raw.bytes().all(|b| b.is_ascii_digit()) => Ok(n),
122 _ => Err(not_found().into()),
123 }
124}
125
126pub(crate) async fn issue_for(state: &AppState, ctx: &RepoCtx, number: i64) -> Result<Issue> {
127 match state.store.issue_by_number(&ctx.repo.id, number).await? {
128 Some(issue) => Ok(issue),
129 None => Err(not_found().into()),
130 }
131}
132
133/// The item of `kind` numbered `number` in this repo, as its issue row, or 404. A pull request
134/// is the issue row it carries, so the comments and edit history are the same for both kinds.
135pub(crate) async fn item_for(
136 state: &AppState,
137 ctx: &RepoCtx,
138 kind: ItemKind,
139 number: i64,
140) -> Result<Issue> {
141 match kind {
142 ItemKind::Issue => issue_for(state, ctx, number).await,
143 ItemKind::Pull => match state.store.pull_by_number(&ctx.repo.id, number).await? {
144 Some(pr) => Ok(pr.issue),
145 None => Err(not_found().into()),
146 },
147 }
148}
149
150/// A comment of this item. A comment id from another item is 404.
151pub(crate) async fn comment_for(
152 state: &AppState,
153 item: &Issue,
154 comment_id: &str,
155) -> Result<Comment> {
156 match state.store.comment_by_id(&item.id, comment_id).await? {
157 Some(comment) => Ok(comment),
158 None => Err(not_found().into()),
159 }
160}
161
162// Times
163
164/// A stored timestamp shown as an absolute `title` and a relative label.
165#[derive(Clone)]
166pub(crate) struct When {
167 pub(crate) iso: String,
168 pub(crate) absolute: String,
169 pub(crate) relative: String,
170}
171
172impl When {
173 pub(crate) fn new(lang: Lang, stamp: &str, tz: &str, now: i64) -> Self {
174 let unix = stamp_unix(stamp);
175 Self {
176 iso: iso_time(unix),
177 absolute: forge_core::tz::format_in_tz(unix, tz),
178 relative: relative_time(lang, unix, now),
179 }
180 }
181}
182
183/// Unix seconds of a stored RFC 3339 UTC stamp (`YYYY-MM-DDTHH:MM:SS[.frac]Z`). Malformed input is 0.
184pub(crate) fn stamp_unix(stamp: &str) -> i64 {
185 let field = |range: std::ops::Range<usize>| {
186 stamp
187 .get(range)
188 .and_then(|digits| digits.parse::<i64>().ok())
189 };
190 let (Some(y), Some(mo), Some(d), Some(h), Some(mi), Some(s)) = (
191 field(0..4),
192 field(5..7),
193 field(8..10),
194 field(11..13),
195 field(14..16),
196 field(17..19),
197 ) else {
198 return 0;
199 };
200 days_from_civil(y, mo, d) * 86_400 + h * 3_600 + mi * 60 + s
201}
202
203/// Days since 1970-01-01 for a civil date (Howard Hinnant's days-from-civil).
204fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
205 let y = if month <= 2 { year - 1 } else { year };
206 let era = y.div_euclid(400);
207 let yoe = y - era * 400;
208 let mp = (month + 9) % 12;
209 let doy = (153 * mp + 2) / 5 + day - 1;
210 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
211 era * 146_097 + doe - 719_468
212}
213
214pub(crate) fn is_hex_oid(s: &str) -> bool {
215 (4..=64).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_hexdigit())
216}
217
218// Timeline
219
220/// One comment, or the item body (which uses the same item shape without a delete action).
221pub(crate) struct CommentView {
222 anchor: String,
223 author: String,
224 /// The author's username, the link target of the name and avatar.
225 login: String,
226 avatar: String,
227 when: When,
228 html: String,
229 /// Set when the text has earlier versions: links to their history.
230 history_href: Option<String>,
231 edit_href: Option<String>,
232 delete_href: Option<String>,
233 csrf: String,
234 /// The reaction row under the text; `None` when there is nothing to show.
235 reactions: Option<ReactionBar>,
236}
237
238/// One reaction button: a POST toggle when `enabled`, a plain count otherwise.
239pub(crate) struct ReactionButton {
240 pub(crate) action: String,
241 pub(crate) csrf: String,
242 pub(crate) target_kind: &'static str,
243 pub(crate) target_id: String,
244 pub(crate) name: &'static str,
245 pub(crate) emoji: &'static str,
246 pub(crate) count: usize,
247 pub(crate) title: String,
248 pub(crate) active: bool,
249 pub(crate) enabled: bool,
250}
251
252/// The reactions of one target: the existing ones, then the picker of every emoji. The picker is
253/// only shown when `enabled`.
254pub(crate) struct ReactionBar {
255 pub(crate) chips: Vec<ReactionButton>,
256 pub(crate) picker: Vec<ReactionButton>,
257 pub(crate) enabled: bool,
258}
259
260/// One timeline event as a line: the actor, `before`, an optional commit link, then `after`.
261pub(crate) struct EventLine {
262 actor: String,
263 when: When,
264 before: String,
265 link_href: Option<String>,
266 link_label: String,
267 after: String,
268}
269
270/// A submitted review of a pull request: its verb, its body (when it has one) and the threads it
271/// opened, which render under it with their diff hunks.
272pub(crate) struct ReviewView {
273 anchor: String,
274 author: String,
275 login: String,
276 avatar: String,
277 when: When,
278 verb: &'static str,
279 dismissed: bool,
280 dismissed_reason: String,
281 html: Option<String>,
282 threads: Vec<Thread>,
283 ctx: ThreadCtx,
284}
285
286pub(crate) enum Entry {
287 Comment(CommentView),
288 Event(EventLine),
289 Review(ReviewView),
290}
291
292/// The item body and its timeline: comments and events merged by creation time.
293pub(crate) struct Discussion {
294 /// The item's author, by display name, and when it was opened.
295 pub(crate) author: String,
296 /// The author's username, for the profile link.
297 pub(crate) author_login: String,
298 pub(crate) opened: When,
299 pub(crate) body: CommentView,
300 pub(crate) entries: Vec<Entry>,
301}
302
303/// The one-line text for an event. `base` is `/{owner}/{repo}`; `base_branch` names the target
304/// branch for merged events (empty for issues).
305fn event_line(
306 lang: Lang,
307 event: &IssueEvent,
308 base: &str,
309 base_branch: &str,
310 actor: String,
311 when: When,
312) -> EventLine {
313 let text = |key: &str| {
314 event
315 .data
316 .get(key)
317 .and_then(Json::as_str)
318 .unwrap_or("")
319 .to_owned()
320 };
321 let commit = text("commit");
322 let before = match event.kind.as_str() {
323 "closed" => match text("reason").as_str() {
324 "completed" => g::t(lang, K::ClosedCompleted).to_owned(),
325 "not_planned" => g::t(lang, K::ClosedNotPlanned).to_owned(),
326 _ => g::t(lang, K::ClosedThis).to_owned(),
327 },
328 "reopened" => g::t(lang, K::ReopenedThis).to_owned(),
329 "labeled" => g::event_label_added(lang, &text("label")),
330 "unlabeled" => g::event_label_removed(lang, &text("label")),
331 "assigned" => g::event_assigned(lang, &text("user")),
332 "unassigned" => g::event_unassigned(lang, &text("user")),
333 "milestoned" => g::event_milestone_added(lang, &text("title")),
334 "demilestoned" => g::event_milestone_removed(lang, &text("title")),
335 "renamed" => g::event_renamed(lang, &text("from"), &text("to")),
336 "referenced" => g::t(lang, K::ReferencedThis).to_owned(),
337 "locked" => match text("reason").as_str() {
338 "" => g::t(lang, K::LockedConversation).to_owned(),
339 reason => g::event_locked_as(lang, lock_reason_label(lang, reason)),
340 },
341 "unlocked" => g::t(lang, K::UnlockedConversation).to_owned(),
342 "pinned" => g::t(lang, K::PinnedThisIssue).to_owned(),
343 "unpinned" => g::t(lang, K::UnpinnedThisIssue).to_owned(),
344 "merged" => g::t(lang, K::MergedCommit).to_owned(),
345 "head_updated" => g::t(lang, K::PushedNewCommits).to_owned(),
346 "head_deleted" => g::t(lang, K::DeletedHeadBranch).to_owned(),
347 "head_ref_deleted" => g::event_branch_deleted(lang, &text("branch")),
348 "head_ref_restored" => g::event_branch_restored(lang, &text("branch")),
349 "review_dismissed" => format!("{}{}", g::t(lang, K::ReviewDismissedPrefix), text("reason")),
350 "thread_resolved" => g::event_thread_resolved(lang, &text("path")),
351 "thread_unresolved" => g::event_thread_unresolved(lang, &text("path")),
352 "base_changed" => g::event_base_changed(lang, &text("from"), &text("to")),
353 other => other.to_owned(),
354 };
355 let (link_href, link_label) = if is_hex_oid(&commit) {
356 (
357 Some(format!("{base}/commit/{commit}")),
358 short_oid(&commit).to_owned(),
359 )
360 } else {
361 (None, String::new())
362 };
363 let (from, to) = (text("from"), text("to"));
364 let (before, after) = match event.kind.as_str() {
365 kind @ ("closed" | "referenced" | "merged") => {
366 g::commit_event_sides(lang, kind, &before, base_branch, link_href.is_some())
367 }
368 "head_updated" if !from.is_empty() && !to.is_empty() => (
369 before,
370 g::event_pushed_range(lang, short_oid(&from), short_oid(&to)),
371 ),
372 _ => (before, String::new()),
373 };
374 EventLine {
375 actor,
376 when,
377 before,
378 link_href,
379 link_label,
380 after,
381 }
382}
383
384#[component]
385pub(crate) async fn comment_item(lang: Lang, item: CommentView) -> Result<impl View> {
386 let CommentView {
387 anchor,
388 author,
389 login,
390 avatar,
391 when,
392 html,
393 history_href,
394 edit_href,
395 delete_href,
396 csrf,
397 reactions,
398 } = item;
399 let profile = user_href(&login);
400 Ok(view! {
401 <article class="issue-item" id=(anchor.as_str())>
402 <header class="issue-item-head">
403 <a href=(profile.as_str())><img class="avatar avatar-sm" src=(avatar.as_str()) alt="" width="24" height="24"></a>
404 <strong>user_link(shown: author.as_str(), login: login.as_str())</strong>
405 <time datetime=(when.iso.as_str()) title=(when.absolute.as_str())>(when.relative.as_str())</time>
406 if let Some(href) = history_href {
407 <a class="issue-edited" href=(href.as_str())>(g::t(lang, K::Edited))</a>
408 }
409 <span class="issue-item-actions">
410 if let Some(href) = edit_href {
411 <a href=(href.as_str())>(g::t(lang, K::Edit))</a>
412 }
413 if let Some(action) = delete_href {
414 <form method="post" action=(action.as_str())>
415 <input type="hidden" name="csrf" value=(csrf.as_str())>
416 <button type="submit" class="link-button">(g::t(lang, K::Delete))</button>
417 </form>
418 }
419 </span>
420 </header>
421 <div class="issue-item-body markdown-body">(Unescaped::new_unchecked(html))</div>
422 if let Some(bar) = reactions {
423 reaction_row(lang: lang, bar: bar)
424 }
425 </article>
426 })
427}
428
429/// The reaction row under an item or comment: the buttons, then the picker when the viewer may react.
430#[component]
431pub(crate) async fn reaction_row(lang: Lang, bar: ReactionBar) -> Result<impl View> {
432 let ReactionBar {
433 chips,
434 picker,
435 enabled,
436 } = bar;
437 Ok(view! {
438 <div class="reactions">
439 for chip in chips.into_iter() {
440 reaction_button(button: chip)
441 }
442 if enabled {
443 <details class="reaction-picker">
444 <summary>(g::t(lang, K::AddReaction))</summary>
445 <div class="reaction-options">
446 for option in picker.into_iter() {
447 reaction_button(button: option)
448 }
449 </div>
450 </details>
451 }
452 </div>
453 })
454}
455
456/// One reaction button. Enabled buttons are POST forms that toggle the viewer's reaction; the
457/// others only show the emoji and count.
458#[component]
459pub(crate) async fn reaction_button(button: ReactionButton) -> Result<impl View> {
460 let ReactionButton {
461 action,
462 csrf,
463 target_kind,
464 target_id,
465 name,
466 emoji,
467 count,
468 title,
469 active,
470 enabled,
471 } = button;
472 let count_text = count.to_string();
473 let class = if active {
474 "reaction reaction-active"
475 } else {
476 "reaction"
477 };
478 Ok(view! {
479 if enabled {
480 <form class="reaction-form" method="post" action=(action.as_str())>
481 <input type="hidden" name="csrf" value=(csrf.as_str())>
482 <input type="hidden" name="target_kind" value=(target_kind)>
483 <input type="hidden" name="target_id" value=(target_id.as_str())>
484 <input type="hidden" name="reaction" value=(name)>
485 <button type="submit" class=(class) title=(title.as_str())>
486 <span class="reaction-emoji">(emoji)</span>
487 if count > 0 {
488 <span class="reaction-count">(count_text.as_str())</span>
489 }
490 </button>
491 </form>
492 }
493 if !enabled {
494 <span class=(class) title=(title.as_str())>
495 <span class="reaction-emoji">(emoji)</span>
496 if count > 0 {
497 <span class="reaction-count">(count_text.as_str())</span>
498 }
499 </span>
500 }
501 })
502}
503
504#[component]
505pub(crate) async fn event_item(line: EventLine) -> Result<impl View> {
506 let EventLine {
507 actor,
508 when,
509 before,
510 link_href,
511 link_label,
512 after,
513 } = line;
514 Ok(view! {
515 <div class="issue-event">
516 <strong>(actor.as_str())</strong>
517 " "
518 (before.as_str())
519 if let Some(href) = link_href {
520 " "
521 <a class="mono" href=(href.as_str())>(link_label.as_str())</a>
522 }
523 if !after.is_empty() {
524 " "
525 (after.as_str())
526 }
527 " "
528 <time datetime=(when.iso.as_str()) title=(when.absolute.as_str())>(when.relative.as_str())</time>
529 </div>
530 })
531}
532
533/// The verb of a dismissed review: the verb it had before dismissal, marked as dismissed. Reviews
534/// dismissed before the previous state was recorded keep the generic verb.
535fn dismissed_verb(lang: Lang, from: Option<ReviewState>) -> &'static str {
536 match from {
537 Some(ReviewState::Approved) => g::t(lang, K::ApprovedDismissed),
538 Some(ReviewState::ChangesRequested) => g::t(lang, K::RequestedDismissed),
539 _ => g::t(lang, K::ReviewedDismissed),
540 }
541}
542
543/// One submitted review: the reviewer, the verb, the dismissal reason, the body, then its threads.
544#[component]
545pub(crate) async fn review_item(lang: Lang, review: ReviewView) -> Result<impl View> {
546 let ReviewView {
547 anchor,
548 author,
549 login,
550 avatar,
551 when,
552 verb,
553 dismissed,
554 dismissed_reason,
555 html,
556 threads,
557 ctx,
558 } = review;
559 let profile = user_href(&login);
560 let verb_class = if dismissed {
561 "review-verb review-verb-dismissed"
562 } else {
563 "review-verb"
564 };
565 Ok(view! {
566 <article class="issue-item review-item" id=(anchor.as_str())>
567 <header class="issue-item-head">
568 <a href=(profile.as_str())><img class="avatar avatar-sm" src=(avatar.as_str()) alt="" width="24" height="24"></a>
569 <strong>user_link(shown: author.as_str(), login: login.as_str())</strong>
570 <span class=(verb_class)>(verb)</span>
571 <time datetime=(when.iso.as_str()) title=(when.absolute.as_str())>(when.relative.as_str())</time>
572 </header>
573 if dismissed {
574 <p class="review-dismissed">(g::t(lang, K::DismissedPrefix)) (dismissed_reason.as_str())</p>
575 }
576 if let Some(body) = html {
577 <div class="issue-item-body markdown-body">(Unescaped::new_unchecked(body))</div>
578 }
579 for thread in threads.into_iter() {
580 review_thread_in(lang: lang, thread: thread, ctx: ctx.clone(), show_hunk: true, return_to: "conversation")
581 }
582 </article>
583 })
584}
585
586#[component]
587pub(crate) async fn timeline_entry(lang: Lang, entry: Entry) -> Result<impl View> {
588 Ok(view! {
589 match entry {
590 Entry::Comment(item) => comment_item(lang: lang, item: item),
591 Entry::Event(line) => event_item(line: line),
592 Entry::Review(review) => review_item(lang: lang, review: review),
593 }
594 })
595}
596
597/// The body followed by every timeline entry.
598#[component]
599pub(crate) async fn discussion_timeline(lang: Lang, discussion: Discussion) -> Result<impl View> {
600 let Discussion { body, entries, .. } = discussion;
601 Ok(view! {
602 <div class="issue-timeline">
603 comment_item(lang: lang, item: body)
604 for entry in entries.into_iter() {
605 timeline_entry(lang: lang, entry: entry)
606 }
607 </div>
608 })
609}
610
611/// Loads the body and the timeline of `item`, which is an issue or a pull request. Authors and
612/// event actors are looked up in one batch.
613pub(crate) async fn load_discussion(
614 lang: Lang,
615 state: &AppState,
616 ctx: &RepoCtx,
617 item: &Issue,
618 kind: ItemKind,
619 base_branch: &str,
620) -> Result<Discussion> {
621 let store = &state.store;
622 let owner = ctx.repo.owner_name.clone();
623 let name = ctx.repo.name.clone();
624 let number = item.number;
625 let base = repo_href(&owner, &name, "", "");
626 let segment = kind.segment();
627
628 let comments = store.list_comments(&item.id).await?;
629 let events = store.list_events(&item.id).await?;
630
631 // One batched lookup for every author and event actor on the page.
632 let mut ids: Vec<String> = std::iter::once(item.author_id.clone())
633 .chain(comments.iter().map(|c| c.author_id.clone()))
634 .chain(events.iter().filter_map(|e| e.actor_id.clone()))
635 .collect();
636 ids.sort();
637 ids.dedup();
638 let users = store.usernames_by_ids(&ids).await?;
639 let who = |id: &str| -> (String, Option<String>) {
640 match users.get(id) {
641 Some((display, hash)) => (display.clone(), hash.clone()),
642 None => (g::t(lang, K::Unknown).to_owned(), None),
643 }
644 };
645
646 let tz = ctx.viewer_tz().to_owned();
647 let now = now_unix();
648 let link = link_context(state, &ctx.repo, &ctx.repo.default_branch, "");
649 let csrf = csrf_of(ctx);
650
651 // Reactions: one batched read for the body and one for all comments. Viewers who cannot react
652 // (signed out, or an archived repo) see the counts without buttons.
653 let viewer_id = viewer(ctx).map(|user| user.id.clone());
654 let can_react = viewer_id.is_some() && !ctx.repo.archived && may_converse(ctx, item);
655 let reaction_action = repo_href(&owner, &name, segment, &format!("{number}/reactions"));
656 let comment_id_list: Vec<String> = comments.iter().map(|c| c.id.clone()).collect();
657 let body_reaction_map = store
658 .reactions_for(
659 ReactionTarget::Issue,
660 std::slice::from_ref(&item.id),
661 viewer_id.as_deref(),
662 )
663 .await?;
664 let comment_reaction_map = store
665 .reactions_for(
666 ReactionTarget::Comment,
667 &comment_id_list,
668 viewer_id.as_deref(),
669 )
670 .await?;
671
672 // Body: first entry of the timeline.
673 let (author, author_hash) = who(&item.author_id);
674 let body_source = if item.body.trim().is_empty() {
675 g::t(lang, K::NoDescription)
676 } else {
677 item.body.as_str()
678 };
679 let body_anchor = match kind {
680 ItemKind::Issue => format!("issue-{number}"),
681 ItemKind::Pull => format!("pull-{number}"),
682 };
683 let opened = When::new(lang, &item.created_at, &tz, now);
684 let body_edits = edit_counts(state, "issue", &[item.id.as_str()]).await?;
685 let body_history = body_edits
686 .get(&item.id)
687 .is_some_and(|count| *count > 0)
688 .then(|| history_href(&owner, &name, kind, number, "issue", &item.id));
689 let body = CommentView {
690 anchor: body_anchor,
691 author: display_name(lang, &author),
692 login: author.clone(),
693 avatar: avatar_url(&author, author_hash.as_deref()),
694 when: opened.clone(),
695 html: render_markdown(body_source, Some(&link)),
696 history_href: body_history,
697 edit_href: may_change(ctx, &item.author_id)
698 .then(|| repo_href(&owner, &name, segment, &format!("{number}/edit"))),
699 delete_href: None,
700 csrf: csrf.clone(),
701 reactions: reaction_bar_for(
702 lang,
703 body_reaction_map.get(&item.id),
704 can_react,
705 &reaction_action,
706 &csrf,
707 ReactionTarget::Issue,
708 &item.id,
709 ),
710 };
711
712 // Comments and events, merged by creation time.
713 let comment_ids: Vec<&str> = comments.iter().map(|c| c.id.as_str()).collect();
714 let comment_edits = edit_counts(state, "comment", &comment_ids).await?;
715 let mut keyed: Vec<(String, Entry)> = Vec::new();
716 for comment in comments {
717 let (who_name, hash) = who(&comment.author_id);
718 let mine = may_change(ctx, &comment.author_id);
719 let comment_path = format!("{number}/comments/{}", comment.id);
720 let edited = comment_edits
721 .get(&comment.id)
722 .is_some_and(|count| *count > 0);
723 let item_view = CommentView {
724 anchor: format!("comment-{}", comment.id),
725 author: display_name(lang, &who_name),
726 login: who_name.clone(),
727 avatar: avatar_url(&who_name, hash.as_deref()),
728 when: When::new(lang, &comment.created_at, &tz, now),
729 html: render_markdown(&comment.body, Some(&link)),
730 history_href: edited
731 .then(|| history_href(&owner, &name, kind, number, "comment", &comment.id)),
732 edit_href: mine
733 .then(|| repo_href(&owner, &name, segment, &format!("{comment_path}/edit"))),
734 delete_href: mine
735 .then(|| repo_href(&owner, &name, segment, &format!("{comment_path}/delete"))),
736 csrf: csrf.clone(),
737 reactions: reaction_bar_for(
738 lang,
739 comment_reaction_map.get(&comment.id),
740 can_react,
741 &reaction_action,
742 &csrf,
743 ReactionTarget::Comment,
744 &comment.id,
745 ),
746 };
747 keyed.push((comment.created_at.clone(), Entry::Comment(item_view)));
748 }
749 for event in &events {
750 let actor = match &event.actor_id {
751 Some(id) => display_name(lang, &who(id).0),
752 None => g::t(lang, K::System).to_owned(),
753 };
754 let when = When::new(lang, &event.created_at, &tz, now);
755 let line = event_line(lang, event, &base, base_branch, actor, when);
756 keyed.push((event.created_at.clone(), Entry::Event(line)));
757 }
758 if kind == ItemKind::Pull {
759 keyed.extend(review_entries(lang, state, ctx, item, &link, &tz, now).await?);
760 }
761 keyed.sort_by(|a, b| a.0.cmp(&b.0));
762 let entries: Vec<Entry> = keyed.into_iter().map(|(_, entry)| entry).collect();
763
764 Ok(Discussion {
765 author: display_name(lang, &author),
766 author_login: author,
767 opened,
768 body,
769 entries,
770 })
771}
772
773/// The reaction bar of one target, or `None` when there is nothing to show (no reactions and no
774/// way to react). Every reaction in the set gets a picker button.
775pub(crate) fn reaction_bar_for(
776 lang: Lang,
777 summaries: Option<&Vec<ReactionSummary>>,
778 can_react: bool,
779 action: &str,
780 csrf: &str,
781 kind: ReactionTarget,
782 target_id: &str,
783) -> Option<ReactionBar> {
784 let summaries: &[ReactionSummary] = summaries.map_or(&[], Vec::as_slice);
785 if summaries.is_empty() && !can_react {
786 return None;
787 }
788 let button =
789 |name: &'static str, emoji: &'static str, count: usize, title: String, active: bool| {
790 ReactionButton {
791 action: action.to_owned(),
792 csrf: csrf.to_owned(),
793 target_kind: kind.as_str(),
794 target_id: target_id.to_owned(),
795 name,
796 emoji,
797 count,
798 title,
799 active,
800 enabled: can_react,
801 }
802 };
803 let chips = summaries
804 .iter()
805 .map(|s| {
806 button(
807 s.reaction.name(),
808 s.reaction.emoji(),
809 s.count,
810 reaction_title(lang, &s.users, s.count),
811 s.reacted_by_viewer,
812 )
813 })
814 .collect();
815 let picker = Reaction::ALL
816 .into_iter()
817 .map(|r| button(r.name(), r.emoji(), 0, r.name().to_owned(), false))
818 .collect();
819 Some(ReactionBar {
820 chips,
821 picker,
822 enabled: can_react,
823 })
824}
825
826/// The hover text of a reaction: who made it, and how many more beyond the first names.
827fn reaction_title(lang: Lang, users: &[String], count: usize) -> String {
828 let names = users.join(", ");
829 match count.saturating_sub(users.len()) {
830 0 => names,
831 more => g::reaction_more(lang, &names, more),
832 }
833}
834
835/// The submitted reviews of a pull request, each with the threads it opened, keyed by submission
836/// time. A `commented` review with no body and no root comment only holds replies, which show
837/// inside the threads they answer, so it gets no entry of its own.
838async fn review_entries(
839 lang: Lang,
840 state: &AppState,
841 ctx: &RepoCtx,
842 item: &Issue,
843 link: &LinkContext,
844 tz: &str,
845 now: i64,
846) -> Result<Vec<(String, Entry)>> {
847 let store = &state.store;
848 let reviews = store.list_reviews(&item.id).await?;
849 let viewer_id = viewer(ctx).map(|user| user.id.clone());
850 let threads = store.review_threads(&item.id, viewer_id.as_deref()).await?;
851
852 let archived = ctx.repo.archived;
853 let can_comment = viewer_id.is_some() && !archived && may_converse(ctx, item);
854 let is_author = viewer_id.as_deref() == Some(item.author_id.as_str());
855 let can_write = !archived
856 && authorize(
857 viewer(ctx),
858 &ctx.repo,
859 ctx.role,
860 ctx.org_role,
861 Action::Write,
862 )
863 .is_ok();
864 let edits = thread_edit_counts(state, &threads).await?;
865 let thread_ctx = ThreadCtx {
866 pr_href: repo_href(
867 &ctx.repo.owner_name,
868 &ctx.repo.name,
869 ItemKind::Pull.segment(),
870 &item.number.to_string(),
871 ),
872 csrf: csrf_of(ctx),
873 viewer_id: viewer_id.clone(),
874 can_comment,
875 can_resolve: can_comment && (is_author || can_write),
876 can_moderate: can_comment && triage(ctx),
877 can_review: can_comment && matches!(item.state, IssueState::Open),
878 tz: tz.to_owned(),
879 link: Some(link.clone()),
880 edits,
881 };
882
883 let mut keyed = Vec::new();
884 for review in reviews {
885 let own: Vec<Thread> = threads
886 .iter()
887 .filter(|thread| thread.root.review_id == review.id)
888 .cloned()
889 .collect();
890 let body = review.body.trim();
891 if review.state == ReviewState::Commented && body.is_empty() && own.is_empty() {
892 continue;
893 }
894 let (verb, dismissed) = match review.state {
895 ReviewState::Approved => (g::t(lang, K::ApprovedChanges), false),
896 ReviewState::ChangesRequested => (g::t(lang, K::RequestedChanges), false),
897 ReviewState::Dismissed => (dismissed_verb(lang, review.dismissed_from), true),
898 ReviewState::Pending | ReviewState::Commented => (g::t(lang, K::Reviewed), false),
899 };
900 let stamp = review
901 .submitted_at
902 .clone()
903 .unwrap_or_else(|| review.created_at.clone());
904 let view = ReviewView {
905 anchor: format!("review-{}", review.id),
906 author: display_name(lang, &review.author_name),
907 login: review.author_name.clone(),
908 avatar: avatar_url(&review.author_name, None),
909 when: When::new(lang, &stamp, tz, now),
910 verb,
911 dismissed,
912 dismissed_reason: review.dismissed_reason.clone().unwrap_or_default(),
913 html: (!body.is_empty()).then(|| render_markdown(&review.body, Some(link))),
914 threads: own,
915 ctx: thread_ctx.clone(),
916 };
917 keyed.push((stamp, Entry::Review(view)));
918 }
919 Ok(keyed)
920}
921
922// Sidebar
923
924pub(crate) struct LabelRow {
925 id: String,
926 name: String,
927 chip: String,
928 attached: bool,
929}
930
931pub(crate) struct MilestoneRow {
932 id: String,
933 title: String,
934 selected: bool,
935}
936
937/// The sidebar data of one item: its labels, assignees and milestone, and the repo's choices.
938pub(crate) struct Sidebar {
939 labels: Vec<LabelRow>,
940 assignees: Vec<String>,
941 milestones: Vec<MilestoneRow>,
942 current_milestone: String,
943 none_selected: bool,
944 locked: bool,
945 lock_reason: Option<String>,
946}
947
948pub(crate) async fn load_sidebar(state: &AppState, repo_id: &str, item: &Issue) -> Result<Sidebar> {
949 let store = &state.store;
950 let all_labels = store.list_labels(repo_id).await?;
951 let attached = store.issue_labels(&item.id).await?;
952 let assignees = store.issue_assignees(&item.id).await?;
953 let milestones = store.list_milestones(repo_id, None).await?;
954
955 let attached_ids: HashSet<&str> = attached.iter().map(|l| l.id.as_str()).collect();
956 let labels: Vec<LabelRow> = all_labels
957 .iter()
958 .map(|l| LabelRow {
959 id: l.id.clone(),
960 name: l.name.clone(),
961 chip: format!("label-chip label-{}", l.id),
962 attached: attached_ids.contains(l.id.as_str()),
963 })
964 .collect();
965 let assignees: Vec<String> = assignees.into_iter().map(|(_, name)| name).collect();
966 let milestones: Vec<MilestoneRow> = milestones
967 .iter()
968 .map(|m| MilestoneRow {
969 id: m.id.clone(),
970 title: m.title.clone(),
971 selected: item.milestone_id.as_deref() == Some(m.id.as_str()),
972 })
973 .collect();
974 let none_selected = !milestones.iter().any(|m| m.selected);
975 let current_milestone = milestones
976 .iter()
977 .find(|m| m.selected)
978 .map(|m| m.title.clone())
979 .unwrap_or_default();
980 Ok(Sidebar {
981 labels,
982 assignees,
983 milestones,
984 current_milestone,
985 none_selected,
986 locked: item.locked,
987 lock_reason: item.lock_reason.clone(),
988 })
989}
990
991#[component]
992pub(crate) async fn labels_box(
993 lang: Lang,
994 write: bool,
995 action: String,
996 csrf: String,
997 labels: Vec<LabelRow>,
998) -> Result<impl View> {
999 let any_attached = labels.iter().any(|row| row.attached);
1000 Ok(view! {
1001 <section class="issue-side-section">
1002 <h2>(g::t(lang, K::Labels))</h2>
1003 if write {
1004 <form method="post" action=(action.as_str()) class="stack">
1005 <input type="hidden" name="csrf" value=(csrf.as_str())>
1006 if labels.is_empty() {
1007 <p class="muted small">(g::t(lang, K::NoRepoLabels))</p>
1008 }
1009 for row in labels.iter() {
1010 <label class="issue-check">
1011 <input type="checkbox" name="label" value=(row.id.as_str()) checked=(row.attached)>
1012 <span class=(row.chip.as_str())>(row.name.as_str())</span>
1013 </label>
1014 }
1015 <div><button type="submit">(g::t(lang, K::SaveLabels))</button></div>
1016 </form>
1017 } else {
1018 if !any_attached {
1019 <p class="muted small">(g::t(lang, K::NoneYet))</p>
1020 }
1021 <div class="issue-chips">
1022 for row in labels.iter() {
1023 if row.attached {
1024 <span class=(row.chip.as_str())>(row.name.as_str())</span>
1025 }
1026 }
1027 </div>
1028 }
1029 </section>
1030 })
1031}
1032
1033#[component]
1034pub(crate) async fn assignees_box(
1035 lang: Lang,
1036 write: bool,
1037 action: String,
1038 csrf: String,
1039 names: Vec<String>,
1040) -> Result<impl View> {
1041 let text = names.join(", ");
1042 Ok(view! {
1043 <section class="issue-side-section">
1044 <h2>(g::t(lang, K::Assignees))</h2>
1045 if write {
1046 <form method="post" action=(action.as_str()) class="stack">
1047 <input type="hidden" name="csrf" value=(csrf.as_str())>
1048 <input type="text" name="assignees" value=(text.as_str()) autocomplete="off" spellcheck="false" aria-label=(g::t(lang, K::Assignees))>
1049 <p class="muted small">(g::t(lang, K::CommaUsernames))</p>
1050 <div><button type="submit">(g::t(lang, K::SaveAssignees))</button></div>
1051 </form>
1052 } else {
1053 if names.is_empty() {
1054 <p class="muted small">(g::t(lang, K::NoOneAssigned))</p>
1055 }
1056 <ul class="issue-names">
1057 for name in names.iter() {
1058 <li>(name.as_str())</li>
1059 }
1060 </ul>
1061 }
1062 </section>
1063 })
1064}
1065
1066#[component]
1067pub(crate) async fn milestone_box(
1068 lang: Lang,
1069 write: bool,
1070 action: String,
1071 csrf: String,
1072 options: Vec<MilestoneRow>,
1073 current: String,
1074 none_selected: bool,
1075) -> Result<impl View> {
1076 Ok(view! {
1077 <section class="issue-side-section">
1078 <h2>(g::t(lang, K::MilestoneBox))</h2>
1079 if write {
1080 <form method="post" action=(action.as_str()) class="stack">
1081 <input type="hidden" name="csrf" value=(csrf.as_str())>
1082 <select name="milestone_id" aria-label=(g::t(lang, K::MilestoneBox))>
1083 <option value="" selected=(none_selected)>(g::t(lang, K::NoneOption))</option>
1084 for row in options.iter() {
1085 <option value=(row.id.as_str()) selected=(row.selected)>(row.title.as_str())</option>
1086 }
1087 </select>
1088 <div><button type="submit">(g::t(lang, K::SaveMilestone))</button></div>
1089 </form>
1090 } else {
1091 if current.is_empty() {
1092 <p class="muted small">(g::t(lang, K::NoMilestone))</p>
1093 } else {
1094 <p>(current.as_str())</p>
1095 }
1096 }
1097 </section>
1098 })
1099}
1100
1101/// The conversation lock. Triage holders get a form that locks it, with a reason, or unlocks it.
1102/// Everyone else sees the lock state only when there is one.
1103#[component]
1104pub(crate) async fn lock_box(
1105 lang: Lang,
1106 write: bool,
1107 action: String,
1108 csrf: String,
1109 locked: bool,
1110 reason: Option<String>,
1111) -> Result<impl View> {
1112 let reason_text = reason
1113 .as_deref()
1114 .map(|r| lock_reason_label(lang, r))
1115 .unwrap_or("")
1116 .to_owned();
1117 let none = reason.is_none();
1118 Ok(view! {
1119 <section class="issue-side-section">
1120 <h2>(g::t(lang, K::ConversationBox))</h2>
1121 if write {
1122 <form method="post" action=(action.as_str()) class="stack">
1123 <input type="hidden" name="csrf" value=(csrf.as_str())>
1124 if locked {
1125 <p class="muted small">
1126 (g::t(lang, K::Locked))
1127 if !none {
1128 " (" (reason_text.as_str()) ")"
1129 }
1130 </p>
1131 <input type="hidden" name="locked" value="0">
1132 <div><button type="submit">(g::t(lang, K::UnlockConversation))</button></div>
1133 } else {
1134 <label>
1135 <span>(g::t(lang, K::Reason))</span>
1136 <select name="reason" aria-label=(g::t(lang, K::LockReasonAria))>
1137 <option value="" selected=(true)>(g::t(lang, K::NoReason))</option>
1138 <option value="off_topic">(g::t(lang, K::OptOffTopic))</option>
1139 <option value="too_heated">(g::t(lang, K::OptTooHeated))</option>
1140 <option value="resolved">(g::t(lang, K::OptResolved))</option>
1141 <option value="spam">(g::t(lang, K::OptSpam))</option>
1142 </select>
1143 </label>
1144 <input type="hidden" name="locked" value="1">
1145 <div><button type="submit">(g::t(lang, K::LockConversation))</button></div>
1146 }
1147 </form>
1148 } else {
1149 <p class="muted small">
1150 if locked {
1151 (g::t(lang, K::LockedToCollaborators))
1152 if !none {
1153 " (" (reason_text.as_str()) ")"
1154 }
1155 } else {
1156 (g::t(lang, K::OpenToEveryone))
1157 }
1158 </p>
1159 }
1160 </section>
1161 })
1162}
1163
1164/// The sidebar boxes. `base` is the item's URL; the forms post to `{base}/labels`, `/assignees`
1165/// and `/milestone`. `write` shows the forms instead of the read-only lists.
1166#[component]
1167pub(crate) async fn item_sidebar(
1168 lang: Lang,
1169 write: bool,
1170 base: String,
1171 csrf: String,
1172 sidebar: Sidebar,
1173 notify: Option<NotifyBox>,
1174) -> Result<impl View> {
1175 let Sidebar {
1176 labels,
1177 assignees,
1178 milestones,
1179 current_milestone,
1180 none_selected,
1181 locked,
1182 lock_reason,
1183 } = sidebar;
1184 let labels_action = format!("{base}/labels");
1185 let assignees_action = format!("{base}/assignees");
1186 let milestone_action = format!("{base}/milestone");
1187 let lock_action = format!("{base}/lock");
1188 Ok(view! {
1189 labels_box(lang: lang, write: write, action: labels_action, csrf: csrf.clone(), labels: labels)
1190 assignees_box(lang: lang, write: write, action: assignees_action, csrf: csrf.clone(), names: assignees)
1191 milestone_box(lang: lang, write: write, action: milestone_action, csrf: csrf.clone(),
1192 options: milestones, current: current_milestone, none_selected: none_selected)
1193 lock_box(lang: lang, write: write, action: lock_action, csrf: csrf.clone(), locked: locked, reason: lock_reason)
1194 if let Some(notify) = notify {
1195 notify_box(lang: lang, action: notify.action, csrf: notify.csrf, subscribed: notify.subscribed, reason: notify.reason)
1196 }
1197 })
1198}
1199
1200// Comment form
1201
1202/// The comment box with its close, reopen and comment buttons. The comment is posted to
1203/// `action`; the close and reopen buttons post to `state_action` (the same URL for issues).
1204#[component]
1205pub(crate) async fn comment_form(
1206 lang: Lang,
1207 action: String,
1208 state_action: String,
1209 csrf: String,
1210 draft: String,
1211 error: Option<String>,
1212 can_close: bool,
1213 open: bool,
1214) -> Result<impl View> {
1215 Ok(view! {
1216 <section class="card issue-comment">
1217 <h2>(g::t(lang, K::AddComment))</h2>
1218 if let Some(message) = error {
1219 alert(message: message.as_str())
1220 }
1221 <form method="post" action=(action.as_str()) class="stack">
1222 <input type="hidden" name="csrf" value=(csrf.as_str())>
1223 <label>
1224 <span>(g::t(lang, K::Comment))</span>
1225 <textarea name="body" rows="6">(draft.as_str())</textarea>
1226 </label>
1227 <div class="issue-actions">
1228 <button type="submit" class="primary" name="action" value="comment">(g::t(lang, K::Comment))</button>
1229 if can_close {
1230 if open {
1231 <button type="submit" name="action" value="close_completed" formaction=(state_action.as_str())>(g::t(lang, K::CloseCompleted))</button>
1232 <button type="submit" name="action" value="close_not_planned" formaction=(state_action.as_str())>(g::t(lang, K::CloseNotPlanned))</button>
1233 } else {
1234 <button type="submit" name="action" value="reopen" formaction=(state_action.as_str())>(g::t(lang, K::ReopenWithComment))</button>
1235 }
1236 }
1237 </div>
1238 </form>
1239 </section>
1240 })
1241}
1242
1243// Comment and state POSTs
1244
1245/// The form fields of a comment or state POST.
1246#[derive(Deserialize)]
1247pub(crate) struct CommentForm {
1248 #[serde(default)]
1249 pub(crate) csrf: String,
1250 #[serde(default)]
1251 pub(crate) body: String,
1252 #[serde(default)]
1253 pub(crate) action: String,
1254}
1255
1256/// What a comment or state POST did.
1257pub(crate) enum Posted {
1258 /// Written. `comment_id` is the new comment, when there was text.
1259 Saved { comment_id: Option<String> },
1260 /// Not written; `message` goes back to the form.
1261 Refused { message: String },
1262}
1263
1264/// Adds a comment and/or changes the state of an issue or pull request (`pull` says which). Only
1265/// the author or a Triage holder may close or reopen; otherwise 403 and nothing is written. An
1266/// empty comment with no state change, or a body that fails validation, is `Refused`.
1267pub(crate) async fn post_comment(
1268 lang: Lang,
1269 state: &AppState,
1270 ctx: &RepoCtx,
1271 user: &User,
1272 item: &Issue,
1273 pull: bool,
1274 form: &CommentForm,
1275) -> Result<Posted> {
1276 let change = match form.action.as_str() {
1277 "" | "comment" => None,
1278 "close_completed" => Some((IssueState::Closed, Some(StateReason::Completed))),
1279 "close_not_planned" => Some((IssueState::Closed, Some(StateReason::NotPlanned))),
1280 "reopen" => Some((IssueState::Open, None)),
1281 _ => return Err(bad_request("unknown action").into()),
1282 };
1283 if change.is_some() && !may_change(ctx, &item.author_id) {
1284 return Err(forbidden().into());
1285 }
1286
1287 let has_text = !form.body.trim().is_empty();
1288 if has_text && !may_converse(ctx, item) {
1289 return Err(forbidden().into());
1290 }
1291 if change.is_none() && !has_text {
1292 return Ok(Posted::Refused {
1293 message: g::t(lang, K::CommentEmpty).to_owned(),
1294 });
1295 }
1296 if has_text && let Err(e) = validate_body(&form.body) {
1297 let message = user_facing_message(&e).unwrap_or_else(|| e.to_string());
1298 return Ok(Posted::Refused { message });
1299 }
1300
1301 let sender = Sender::of(user);
1302 let mut comment_id = None;
1303 if has_text {
1304 let comment = state
1305 .store
1306 .add_comment(&item.id, &user.id, &form.body)
1307 .await?;
1308 emit_comment(state, &ctx.repo, &sender, "created", pull, item, &comment).await;
1309 notify::dispatch(
1310 state,
1311 &ctx.repo,
1312 item,
1313 &user.id,
1314 Activity::Comment,
1315 Some(&comment.body),
1316 )
1317 .await;
1318 comment_id = Some(comment.id);
1319 }
1320 if let Some((next, reason)) = change {
1321 let changed = state
1322 .store
1323 .set_issue_state(&item.id, Some(&user.id), next, reason, Json::Null)
1324 .await?;
1325 if changed {
1326 let (action, activity) = if next == IssueState::Closed {
1327 ("closed", Activity::Closed)
1328 } else {
1329 ("reopened", Activity::Reopened)
1330 };
1331 emit_item_action(state, &ctx.repo, &sender, pull, item.number, action).await;
1332 if pull
1333 && action == "reopened"
1334 && let Some(pr) = state
1335 .store
1336 .pull_by_number(&ctx.repo.id, item.number)
1337 .await?
1338 {
1339 crate::ci::on_pull_event(state, &ctx.repo, &pr, &user.id).await;
1340 }
1341 notify::dispatch(state, &ctx.repo, item, &user.id, activity, None).await;
1342 }
1343 }
1344 Ok(Posted::Saved { comment_id })
1345}