forge
ExploreLog in

Tahinli/forge

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