1use std::cmp::Ordering;
2
3use super::bot_compress::{compress_discussion_body, compress_document_body, structural_strip};
4use super::fetch::GithubReadError;
5use super::model::{
6 GithubComment, GithubDocument, GithubDocumentKind, GithubReaction, GithubReview,
7 GithubReviewCommentSection, GithubTimelineEvent,
8};
9use super::resource::{GithubCommentSelector, GithubResource};
10
11pub const MAX_RENDERED_COMMENTS_PER_SECTION: usize = 50;
15
16pub fn render_document(document: &GithubDocument) -> String {
22 let resource = GithubResource {
23 kind: document.resource_kind(),
24 number: document.number,
25 repository: Some(document.repository.clone()),
26 comment_selector: None,
27 };
28 render_document_for_resource(document, &resource)
29 .expect("a whole-document render has no fallible selector")
30}
31
32pub(super) fn render_document_for_resource(
33 document: &GithubDocument,
34 resource: &GithubResource,
35) -> Result<String, GithubReadError> {
36 if let Some(selector) = &resource.comment_selector {
37 return render_selected_discussion(document, selector);
38 }
39
40 let mut output = String::new();
41 let resource_label = match document.kind {
42 GithubDocumentKind::Issue => "Issue",
43 GithubDocumentKind::PullRequest => "Pull request",
44 };
45 output.push_str(&format!(
46 "# {resource_label} #{}: {}\n\n",
47 document.number, document.title
48 ));
49 output.push_str(&format!("Repository: {}\n", document.repository));
50 output.push_str(&format!("State: {}\n", document.state));
51 if let Some(author) = nonempty(&document.author) {
52 output.push_str(&format!("Author: {}\n", format_author(author)));
53 }
54 if let Some(created_at) = nonempty(&document.created_at) {
55 output.push_str(&format!("Created: {created_at}\n"));
56 }
57 if let Some(updated_at) = nonempty(&document.updated_at) {
58 output.push_str(&format!("Updated: {updated_at}\n"));
59 }
60 if !document.labels.is_empty() {
61 output.push_str(&format!("Labels: {}\n", document.labels.join(", ")));
62 }
63 if !document.assignees.is_empty() {
64 output.push_str(&format!(
65 "Assignees: {}\n",
66 document
67 .assignees
68 .iter()
69 .map(|assignee| format_author(assignee))
70 .collect::<Vec<_>>()
71 .join(", ")
72 ));
73 }
74 if let Some(milestone) = nonempty(&document.milestone) {
75 output.push_str(&format!("Milestone: {milestone}\n"));
76 }
77 if let Some(reactions) = render_reactions(&document.reactions) {
78 output.push_str(&format!("Reactions: {reactions}\n"));
79 }
80
81 output.push_str("\n## Body\n\n");
82 append_body(&mut output, &compress_document_body(&document.body).body);
83
84 let discussion = discussion_items(document);
85 render_comments(
86 &mut output,
87 "Comments",
88 &document.comments,
89 document.comments_total_count,
90 document.minimized_comments_count,
91 &discussion,
92 resource,
93 );
94
95 if document.kind == GithubDocumentKind::PullRequest {
96 render_files(&mut output, document);
97 render_reviews(&mut output, document, &discussion, resource);
98 render_review_comment_sections(
99 &mut output,
100 &document.review_comment_sections,
101 &discussion,
102 resource,
103 );
104 }
105 render_timeline(&mut output, &discussion);
106
107 output.push_str(&format!(
108 "Discussion drill-down: {}/comments/<sel> (for example 3, 3-5, 3,7, or -1).\n\n",
109 resource.base_spelling()
110 ));
111 Ok(output)
112}
113
114fn render_comments(
115 output: &mut String,
116 heading: &str,
117 comments: &[GithubComment],
118 total_count: Option<usize>,
119 supplied_minimized_count: Option<usize>,
120 discussion: &[DiscussionItem<'_>],
121 resource: &GithubResource,
122) {
123 if comments.is_empty()
124 && total_count.unwrap_or(0) == 0
125 && supplied_minimized_count.unwrap_or(0) == 0
126 {
127 return;
128 }
129 output.push_str(&format!("\n## {heading}\n\n"));
130 let displayed = newest_comments(comments);
131 let total_count = total_count.unwrap_or(comments.len()).max(comments.len());
132 let omitted = total_count.saturating_sub(displayed.len());
133 if omitted > 0 {
134 output.push_str(&format!("{omitted} earlier comments omitted\n\n"));
135 }
136 for comment in displayed {
137 if let Some(ordinal) = discussion_ordinal_for_comment(discussion, comment, false) {
138 render_default_discussion_item(
139 output,
140 ordinal,
141 comment.author.as_deref(),
142 comment.created_at.as_deref(),
143 None,
144 &comment.body,
145 resource,
146 );
147 }
148 }
149 let observed_minimized = comments.iter().filter(|comment| comment.minimized).count();
150 let minimized = supplied_minimized_count
151 .unwrap_or(observed_minimized)
152 .max(observed_minimized);
153 if minimized > 0 {
154 output.push_str(&format!("Minimized comments: {minimized}\n\n"));
155 }
156}
157
158fn render_files(output: &mut String, document: &GithubDocument) {
159 if document.files.is_empty() {
160 return;
161 }
162 output.push_str("\n## Files\n\n");
163 for file in &document.files {
164 output.push_str("- `");
165 output.push_str(&file.path);
166 output.push('`');
167 if file.additions.is_some() || file.deletions.is_some() {
168 output.push_str(&format!(
169 " (+{} -{})",
170 file.additions.unwrap_or(0),
171 file.deletions.unwrap_or(0)
172 ));
173 }
174 if let Some(status) = nonempty(&file.status) {
175 output.push_str(&format!(" [{status}]"));
176 }
177 output.push('\n');
178 }
179}
180
181fn render_reviews(
182 output: &mut String,
183 document: &GithubDocument,
184 discussion: &[DiscussionItem<'_>],
185 resource: &GithubResource,
186) {
187 if !document.reviews.iter().any(review_is_visible) {
188 return;
189 }
190 output.push_str("\n## Reviews\n\n");
191 for review in &document.reviews {
192 let Some(ordinal) = discussion_ordinal_for_review(discussion, review) else {
193 continue;
194 };
195 render_default_discussion_item(
196 output,
197 ordinal,
198 review.author.as_deref(),
199 review.submitted_at.as_deref(),
200 review.state.as_deref(),
201 &review.body,
202 resource,
203 );
204 }
205}
206
207fn render_review_comment_sections(
208 output: &mut String,
209 sections: &[GithubReviewCommentSection],
210 discussion: &[DiscussionItem<'_>],
211 resource: &GithubResource,
212) {
213 if !sections.iter().any(|section| {
214 section.comments.iter().any(comment_is_visible)
215 || section.comments_total_count.unwrap_or(0) > section.comments.len()
216 || section.minimized_comments_count.unwrap_or(0) > 0
217 }) {
218 return;
219 }
220 output.push_str("\n## Review comments\n\n");
221 for section in sections {
222 let displayed = newest_comments(§ion.comments);
223 let total_count = section
224 .comments_total_count
225 .unwrap_or(section.comments.len())
226 .max(section.comments.len());
227 let omitted = total_count.saturating_sub(displayed.len());
228 if omitted > 0 {
229 let author = section
230 .author
231 .as_deref()
232 .map(format_author)
233 .unwrap_or_else(|| "unknown".to_string());
234 output.push_str(&format!(
235 "{omitted} earlier comments omitted from {author}'s review\n\n"
236 ));
237 }
238 for comment in displayed {
239 if let Some(ordinal) = discussion_ordinal_for_comment(discussion, comment, true) {
240 render_default_discussion_item(
241 output,
242 ordinal,
243 comment.author.as_deref(),
244 comment.created_at.as_deref(),
245 None,
246 &comment.body,
247 resource,
248 );
249 }
250 }
251 let observed_minimized = section
252 .comments
253 .iter()
254 .filter(|comment| comment.minimized)
255 .count();
256 let minimized = section
257 .minimized_comments_count
258 .unwrap_or(observed_minimized)
259 .max(observed_minimized);
260 if minimized > 0 {
261 output.push_str(&format!("Minimized comments: {minimized}\n\n"));
262 }
263 }
264}
265
266fn render_timeline(output: &mut String, discussion: &[DiscussionItem<'_>]) {
267 let events = discussion
268 .iter()
269 .enumerate()
270 .filter_map(|(index, item)| item.event().map(|event| (index + 1, event)));
271 let mut rendered_any = false;
272 for (ordinal, event) in events {
273 if !rendered_any {
274 output.push_str("\n## Timeline\n\n");
275 rendered_any = true;
276 }
277 render_item_heading(
278 output,
279 ordinal,
280 event.actor.as_deref(),
281 event.created_at.as_deref(),
282 );
283 render_timeline_event_body(output, event);
284 }
285}
286
287fn render_default_discussion_item(
288 output: &mut String,
289 ordinal: usize,
290 author: Option<&str>,
291 date: Option<&str>,
292 state: Option<&str>,
293 body: &str,
294 resource: &GithubResource,
295) {
296 let compressed = compress_discussion_body(author, body);
297 if compressed.body.is_empty() {
298 return;
299 }
300 render_item_heading(output, ordinal, author, date);
301 if let Some(state) = state.filter(|state| !state.is_empty()) {
302 output.push_str(&format!("State: {state}\n\n"));
303 }
304 append_body(output, &compressed.body);
305 if compressed.compressed {
306 output.push_str(&format!(
307 "[compressed; full: {}/comments/{ordinal}]\n\n",
308 resource.base_spelling()
309 ));
310 }
311}
312
313fn render_selected_discussion(
314 document: &GithubDocument,
315 selector: &GithubCommentSelector,
316) -> Result<String, GithubReadError> {
317 let items = discussion_items(document);
318 let resolved = selector.resolve(items.len()).map_err(|ordinal| {
319 let valid_range = if items.is_empty() {
320 "empty".to_string()
321 } else {
322 format!("1-{}", items.len())
323 };
324 GithubReadError::InvalidCommentSelector(format!(
325 "discussion ordinal {ordinal} is out of range; valid range is {valid_range}"
326 ))
327 })?;
328
329 let mut output = String::new();
330 for (index, item) in items.into_iter().enumerate() {
331 let ordinal = index + 1;
332 if !resolved.contains(ordinal) {
333 continue;
334 }
335 render_item_heading(&mut output, ordinal, item.author(), item.date());
336 if let Some(event) = item.event() {
337 render_timeline_event_body(&mut output, event);
338 continue;
339 }
340 if let Some(state) = item.state().filter(|state| !state.is_empty()) {
341 output.push_str(&format!("State: {state}\n\n"));
342 }
343 append_body(
344 &mut output,
345 &structural_strip(item.body().unwrap_or_default()),
346 );
347 }
348 Ok(output)
349}
350
351#[derive(Clone, Copy)]
352enum DiscussionItemKind<'a> {
353 Comment(&'a GithubComment),
354 Review(&'a GithubReview),
355 ReviewComment(&'a GithubComment),
356 Event(&'a GithubTimelineEvent),
357}
358
359#[derive(Clone, Copy)]
360struct DiscussionItem<'a> {
361 kind: DiscussionItemKind<'a>,
362}
363
364impl<'a> DiscussionItem<'a> {
365 fn author(self) -> Option<&'a str> {
366 match self.kind {
367 DiscussionItemKind::Comment(comment) | DiscussionItemKind::ReviewComment(comment) => {
368 comment.author.as_deref()
369 }
370 DiscussionItemKind::Review(review) => review.author.as_deref(),
371 DiscussionItemKind::Event(event) => event.actor.as_deref(),
372 }
373 }
374
375 fn date(self) -> Option<&'a str> {
376 match self.kind {
377 DiscussionItemKind::Comment(comment) | DiscussionItemKind::ReviewComment(comment) => {
378 comment.created_at.as_deref()
379 }
380 DiscussionItemKind::Review(review) => review.submitted_at.as_deref(),
381 DiscussionItemKind::Event(event) => event.created_at.as_deref(),
382 }
383 }
384
385 fn state(self) -> Option<&'a str> {
386 match self.kind {
387 DiscussionItemKind::Review(review) => review.state.as_deref(),
388 _ => None,
389 }
390 }
391
392 fn body(self) -> Option<&'a str> {
393 match self.kind {
394 DiscussionItemKind::Comment(comment) | DiscussionItemKind::ReviewComment(comment) => {
395 Some(&comment.body)
396 }
397 DiscussionItemKind::Review(review) => Some(&review.body),
398 DiscussionItemKind::Event(_) => None,
399 }
400 }
401
402 fn event(self) -> Option<&'a GithubTimelineEvent> {
403 match self.kind {
404 DiscussionItemKind::Event(event) => Some(event),
405 _ => None,
406 }
407 }
408}
409
410fn discussion_items(document: &GithubDocument) -> Vec<DiscussionItem<'_>> {
411 let mut items = Vec::new();
412 for comment in newest_comments(&document.comments) {
413 if comment_is_visible(comment) {
414 items.push(DiscussionItem {
415 kind: DiscussionItemKind::Comment(comment),
416 });
417 }
418 }
419 if document.kind == GithubDocumentKind::PullRequest {
420 for review in &document.reviews {
421 if review_is_visible(review) {
422 items.push(DiscussionItem {
423 kind: DiscussionItemKind::Review(review),
424 });
425 }
426 }
427 for section in &document.review_comment_sections {
428 for comment in newest_comments(§ion.comments) {
429 if comment_is_visible(comment) {
430 items.push(DiscussionItem {
431 kind: DiscussionItemKind::ReviewComment(comment),
432 });
433 }
434 }
435 }
436 }
437 if document.timeline.is_empty() {
438 return items;
439 }
440 for event in &document.timeline {
441 items.push(DiscussionItem {
442 kind: DiscussionItemKind::Event(event),
443 });
444 }
445 items.sort_by(|left, right| left.date().unwrap_or("").cmp(right.date().unwrap_or("")));
446 items
447}
448
449#[derive(Clone, Copy, Debug, Eq, PartialEq)]
451pub enum GithubDiscussionTarget<'a> {
452 Comment(&'a GithubComment),
453 ReviewThreadComment,
454 Other,
455}
456
457pub fn discussion_target_at_ordinal(
459 document: &GithubDocument,
460 ordinal: usize,
461) -> Option<GithubDiscussionTarget<'_>> {
462 let discussion = discussion_items(document);
463 let item = discussion.get(ordinal.checked_sub(1)?)?;
464 Some(match item.kind {
465 DiscussionItemKind::Comment(comment) => GithubDiscussionTarget::Comment(comment),
466 DiscussionItemKind::ReviewComment(_) => GithubDiscussionTarget::ReviewThreadComment,
467 DiscussionItemKind::Review(_) | DiscussionItemKind::Event(_) => {
468 GithubDiscussionTarget::Other
469 }
470 })
471}
472
473pub fn discussion_ordinal_for_comment_url(
475 document: &GithubDocument,
476 comment_url: &str,
477) -> Option<usize> {
478 let discussion = discussion_items(document);
479 let comment = document
480 .comments
481 .iter()
482 .find(|comment| comment.url.as_deref() == Some(comment_url))?;
483 discussion_ordinal_for_comment(&discussion, comment, false)
484}
485
486fn discussion_ordinal_for_comment(
487 discussion: &[DiscussionItem<'_>],
488 comment: &GithubComment,
489 review_comment: bool,
490) -> Option<usize> {
491 discussion
492 .iter()
493 .position(|item| {
494 matches!(
495 item.kind,
496 DiscussionItemKind::ReviewComment(candidate)
497 if review_comment && std::ptr::eq(candidate, comment)
498 ) || matches!(
499 item.kind,
500 DiscussionItemKind::Comment(candidate)
501 if !review_comment && std::ptr::eq(candidate, comment)
502 )
503 })
504 .map(|index| index + 1)
505}
506
507fn discussion_ordinal_for_review(
508 discussion: &[DiscussionItem<'_>],
509 review: &GithubReview,
510) -> Option<usize> {
511 discussion
512 .iter()
513 .position(|item| matches!(item.kind, DiscussionItemKind::Review(candidate) if std::ptr::eq(candidate, review)))
514 .map(|index| index + 1)
515}
516
517pub fn render_outline_for_resource(document: &GithubDocument, resource: &GithubResource) -> String {
519 let items = discussion_items(document);
520 let mut lines = vec![format!("#{} {}", document.number, document.title)];
521 let state = if document
522 .timeline
523 .iter()
524 .any(|event| event.event.eq_ignore_ascii_case("merged"))
525 {
526 "merged".to_string()
527 } else {
528 document.state.to_ascii_lowercase()
529 };
530 let author = document
531 .author
532 .as_deref()
533 .map(format_author)
534 .unwrap_or_else(|| "@unknown".to_string());
535 let created = document.created_at.as_deref().unwrap_or("unknown");
536 let updated = document.updated_at.as_deref().unwrap_or("unknown");
537 let labels = document.labels.join(",");
538 let mut metadata = format!(
539 "state={state} author={author} created={created} updated={updated} labels=[{labels}]"
540 );
541 if document.kind == GithubDocumentKind::PullRequest {
542 let base = document.base_ref_name.as_deref().unwrap_or("?");
543 let head = document.head_ref_name.as_deref().unwrap_or("?");
544 let additions = document
545 .files
546 .iter()
547 .map(|file| file.additions.unwrap_or(0))
548 .sum::<u64>();
549 let deletions = document
550 .files
551 .iter()
552 .map(|file| file.deletions.unwrap_or(0))
553 .sum::<u64>();
554 let decision = document
555 .review_decision
556 .as_deref()
557 .unwrap_or("unknown")
558 .to_ascii_lowercase();
559 metadata.push_str(&format!(
560 " {base}<-{head} +{additions}/-{deletions} files={} review={decision}",
561 document.files.len()
562 ));
563 }
564 lines.push(metadata);
565
566 const OUTLINE_ITEM_CAP: usize = 200;
567 let omitted = items.len().saturating_sub(OUTLINE_ITEM_CAP);
568 let first_count = if omitted > 0 { 20 } else { items.len() };
569 for (index, item) in items.iter().take(first_count).enumerate() {
570 lines.push(render_outline_item(index + 1, item));
571 }
572 if omitted > 0 {
573 let first_omitted = first_count + 1;
574 let last_omitted = items.len() - 180;
575 lines.push(format!(
576 "… ({omitted} omitted; read {}/comments/{first_omitted}-{last_omitted})",
577 resource.base_spelling()
578 ));
579 for (index, item) in items.iter().enumerate().skip(items.len() - 180) {
580 lines.push(render_outline_item(index + 1, item));
581 }
582 }
583 lines.push(format!(
584 "Zoom items: aft_zoom {} <k>[,k..] · full: read {}",
585 resource.base_spelling(),
586 resource.base_spelling()
587 ));
588 format!("{}\n", lines.join("\n"))
589}
590
591fn render_outline_item(ordinal: usize, item: &DiscussionItem<'_>) -> String {
592 let kind = match item.kind {
593 DiscussionItemKind::Comment(_) => "comment".to_string(),
594 DiscussionItemKind::Review(_) => format!(
595 "review({})",
596 item.state().unwrap_or("unknown").to_ascii_lowercase()
597 ),
598 DiscussionItemKind::ReviewComment(comment) => format!(
599 "review-comment({}:{})",
600 comment.path.as_deref().unwrap_or("unknown"),
601 comment
602 .line
603 .map(|line| line.to_string())
604 .unwrap_or_else(|| "?".to_string())
605 ),
606 DiscussionItemKind::Event(event) => format!("event({})", event.event),
607 };
608 let author = item
609 .author()
610 .map(format_author)
611 .unwrap_or_else(|| "@unknown".to_string());
612 let date = outline_timestamp(item.date().unwrap_or("unknown"));
613 let body = item.body().map(single_line_excerpt).unwrap_or_else(|| {
614 single_line_excerpt(&timeline_event_payload(item.event().expect("event item")))
615 });
616 format!("[{ordinal}] {kind} {author} {date} · {body}")
617}
618
619fn outline_timestamp(timestamp: &str) -> String {
620 timestamp
621 .strip_suffix(":00Z")
622 .map(|value| value.replace('T', " "))
623 .unwrap_or_else(|| timestamp.replace('T', " "))
624}
625
626fn single_line_excerpt(value: &str) -> String {
627 let text = value.split_whitespace().collect::<Vec<_>>().join(" ");
628 const MAX_CHARS: usize = 80;
629 if text.chars().count() <= MAX_CHARS {
630 return text;
631 }
632 let prefix = text
633 .chars()
634 .take(MAX_CHARS.saturating_sub(1))
635 .collect::<String>();
636 format!("{prefix}…")
637}
638
639fn render_timeline_event_body(output: &mut String, event: &GithubTimelineEvent) {
640 output.push_str(&format!("Event: {}\n\n", event.event));
641 append_body(output, &timeline_event_payload(event));
642}
643
644fn timeline_event_payload(event: &GithubTimelineEvent) -> String {
645 let mut payload = Vec::new();
646 if let Some(label) = &event.label {
647 payload.push(format!("Label: {label}"));
648 }
649 if let Some(assignee) = &event.assignee {
650 payload.push(format!("Assignee: {}", format_author(assignee)));
651 }
652 if let Some(milestone) = &event.milestone {
653 payload.push(format!("Milestone: {milestone}"));
654 }
655 if event.rename_from.is_some() || event.rename_to.is_some() {
656 payload.push(format!(
657 "Renamed: {} -> {}",
658 event.rename_from.as_deref().unwrap_or("unknown"),
659 event.rename_to.as_deref().unwrap_or("unknown")
660 ));
661 }
662 if let Some(reviewer) = &event.requested_reviewer {
663 payload.push(format!("Requested reviewer: {}", format_author(reviewer)));
664 }
665 if let Some(commit_id) = &event.commit_id {
666 payload.push(format!("Merge commit: {commit_id}"));
667 }
668 if payload.is_empty() {
669 event.event.clone()
670 } else {
671 payload.join("; ")
672 }
673}
674
675fn review_is_visible(review: &GithubReview) -> bool {
676 discussion_body_is_visible(review.author.as_deref(), &review.body)
677}
678
679fn comment_is_visible(comment: &GithubComment) -> bool {
680 discussion_body_is_visible(comment.author.as_deref(), &comment.body)
681}
682
683fn discussion_body_is_visible(author: Option<&str>, body: &str) -> bool {
684 !compress_discussion_body(author, body).body.is_empty()
685}
686
687fn render_item_heading(
688 output: &mut String,
689 ordinal: usize,
690 author: Option<&str>,
691 date: Option<&str>,
692) {
693 let author = author
694 .map(format_author)
695 .unwrap_or_else(|| "unknown".to_string());
696 let date = date.unwrap_or("unknown date");
697 output.push_str(&format!("### [{ordinal}] {author} · {date}\n\n"));
698}
699
700fn newest_comments(comments: &[GithubComment]) -> Vec<&GithubComment> {
701 let mut comments: Vec<_> = comments.iter().collect();
702 comments.sort_by(|left, right| compare_timestamp(&left.created_at, &right.created_at));
703 let drop_count = comments
704 .len()
705 .saturating_sub(MAX_RENDERED_COMMENTS_PER_SECTION);
706 comments.into_iter().skip(drop_count).collect()
707}
708
709fn compare_timestamp(left: &Option<String>, right: &Option<String>) -> Ordering {
710 left.as_deref()
711 .unwrap_or("")
712 .cmp(right.as_deref().unwrap_or(""))
713}
714
715fn render_reactions(reactions: &[GithubReaction]) -> Option<String> {
716 let rendered: Vec<_> = reactions
717 .iter()
718 .filter(|reaction| reaction.count > 0)
719 .map(|reaction| {
720 format!(
721 "{} x{}",
722 reaction_display(&reaction.content),
723 reaction.count
724 )
725 })
726 .collect();
727 (!rendered.is_empty()).then(|| rendered.join(", "))
728}
729
730fn reaction_display(reaction: &str) -> &str {
731 match reaction {
732 "THUMBS_UP" | "+1" => "+1",
733 "THUMBS_DOWN" | "-1" => "-1",
734 "LAUGH" => "laugh",
735 "HOORAY" => "hooray",
736 "CONFUSED" => "confused",
737 "HEART" => "heart",
738 "ROCKET" => "rocket",
739 "EYES" => "eyes",
740 other => other,
741 }
742}
743
744fn format_author(author: &str) -> String {
745 if author.starts_with('@') {
746 author.to_string()
747 } else {
748 format!("@{author}")
749 }
750}
751
752fn nonempty(value: &Option<String>) -> Option<&str> {
753 value.as_deref().filter(|value| !value.is_empty())
754}
755
756fn append_body(output: &mut String, body: &str) {
757 if body.is_empty() {
758 return;
759 }
760 output.push_str(body.trim_end());
761 output.push_str("\n\n");
762}
763
764#[cfg(test)]
765mod tests {
766 use super::*;
767 use crate::github_read::model::{GithubDocument, GithubDocumentKind, GithubTimelineEvent};
768 use crate::github_read::GithubResourceKind;
769
770 #[test]
771 fn renderer_keeps_full_human_body_caps_comments_and_omits_comment_reactions() {
772 let mut document = GithubDocument {
773 repository: "owner/repo".to_string(),
774 kind: GithubDocumentKind::Issue,
775 number: 7,
776 title: "Fixture".to_string(),
777 state: "OPEN".to_string(),
778 author: Some("octo".to_string()),
779 body: "body with https://user-images.githubusercontent.com/example.png".to_string(),
780 reactions: vec![GithubReaction {
781 content: "THUMBS_UP".to_string(),
782 count: 2,
783 }],
784 ..GithubDocument::default()
785 };
786 document.comments = (0..51)
787 .map(|number| GithubComment {
788 author: Some("commenter".to_string()),
789 body: format!("comment {number}"),
790 created_at: Some(format!("2026-01-{:02}T00:00:00Z", number.min(28))),
791 minimized: number == 0,
792 ..GithubComment::default()
793 })
794 .collect();
795 document.comments_total_count = Some(51);
796
797 let text = render_document(&document);
798 assert!(text.contains("Repository: owner/repo"));
799 assert!(text.contains("Reactions: +1 x2"));
800 assert!(text.contains("1 earlier comments omitted"));
801 assert!(text.contains("Minimized comments: 1"));
802 assert!(!text.contains("comment 0\n"));
803 assert!(text.contains("### [1] @commenter"));
804 assert!(text.contains("### [50] @commenter"));
805 assert_eq!(text.matches("/comments/<sel>").count(), 1);
806 }
807
808 #[test]
809 fn timeline_events_share_ordinals_between_outline_read_and_selector() {
810 let document = GithubDocument {
811 repository: "cortexkit/aft".to_string(),
812 kind: GithubDocumentKind::PullRequest,
813 number: 999,
814 title: "Timeline fixture".to_string(),
815 state: "OPEN".to_string(),
816 author: Some("author".to_string()),
817 created_at: Some("2026-09-03T05:00:00Z".to_string()),
818 updated_at: Some("2026-09-03T07:00:00Z".to_string()),
819 comments: vec![
820 GithubComment {
821 author: Some("commenter".to_string()),
822 body: "First comment".to_string(),
823 created_at: Some("2026-09-03T05:10:00Z".to_string()),
824 ..GithubComment::default()
825 },
826 GithubComment {
827 author: Some("commenter".to_string()),
828 body: "Last comment".to_string(),
829 created_at: Some("2026-09-03T06:50:00Z".to_string()),
830 ..GithubComment::default()
831 },
832 ],
833 timeline: vec![GithubTimelineEvent {
834 actor: Some("aft-alfonso[bot]".to_string()),
835 event: "closed".to_string(),
836 created_at: Some("2026-09-03T06:57:00Z".to_string()),
837 commit_id: Some("0123456789abcdef".to_string()),
838 ..GithubTimelineEvent::default()
839 }],
840 ..GithubDocument::default()
841 };
842 let resource = GithubResource {
843 kind: GithubResourceKind::PullRequest,
844 number: 999,
845 repository: Some("cortexkit/aft".to_string()),
846 comment_selector: None,
847 };
848
849 let outline = render_outline_for_resource(&document, &resource);
850 let rendered = render_document_for_resource(&document, &resource).expect("render read");
851 let selected = render_document_for_resource(
852 &document,
853 &GithubResource {
854 comment_selector: Some(GithubCommentSelector::parse("3").expect("parse selector")),
855 ..resource.clone()
856 },
857 )
858 .expect("render selected event");
859
860 assert!(outline.contains("[3] event(closed) @aft-alfonso[bot] 2026-09-03 06:57"));
861 assert!(rendered.contains("## Timeline\n\n### [3] @aft-alfonso[bot]"));
862 assert!(selected.contains("Event: closed"));
863 assert!(selected.contains("0123456789abcdef"));
864 }
865
866 #[test]
867 fn zoom_parity_via_shared_enumeration() {
868 let document = GithubDocument {
869 repository: "cortexkit/aft".to_string(),
870 kind: GithubDocumentKind::PullRequest,
871 number: 999,
872 title: "Timeline fixture".to_string(),
873 state: "OPEN".to_string(),
874 comments: vec![
875 GithubComment {
876 author: Some("commenter".to_string()),
877 body: "First comment".to_string(),
878 created_at: Some("2026-09-03T05:10:00Z".to_string()),
879 ..GithubComment::default()
880 },
881 GithubComment {
882 author: Some("commenter".to_string()),
883 body: "Last comment".to_string(),
884 created_at: Some("2026-09-03T06:50:00Z".to_string()),
885 ..GithubComment::default()
886 },
887 ],
888 timeline: vec![GithubTimelineEvent {
889 actor: Some("aft-alfonso[bot]".to_string()),
890 event: "closed".to_string(),
891 created_at: Some("2026-09-03T06:57:00Z".to_string()),
892 commit_id: Some("0123456789abcdef".to_string()),
893 ..GithubTimelineEvent::default()
894 }],
895 ..GithubDocument::default()
896 };
897 let resource = GithubResource {
898 kind: GithubResourceKind::PullRequest,
899 number: 999,
900 repository: Some("cortexkit/aft".to_string()),
901 comment_selector: None,
902 };
903
904 let outline = render_outline_for_resource(&document, &resource);
905 assert!(outline.contains("[3] event(closed) @aft-alfonso[bot] 2026-09-03 06:57"));
906
907 let positive = render_document_for_resource(
908 &document,
909 &GithubResource {
910 comment_selector: Some(GithubCommentSelector::parse("3").unwrap()),
911 ..resource.clone()
912 },
913 )
914 .unwrap();
915
916 let tail = render_document_for_resource(
917 &document,
918 &GithubResource {
919 comment_selector: Some(GithubCommentSelector::parse("-1").unwrap()),
920 ..resource.clone()
921 },
922 )
923 .unwrap();
924
925 assert_eq!(tail, positive);
926 assert!(tail.contains("Event: closed"));
927 }
928
929 #[test]
930 fn outline_cap_keeps_first_twenty_last_one_hundred_eighty_and_escape_hatch() {
931 let mut document = GithubDocument {
932 repository: "owner/repo".to_string(),
933 kind: GithubDocumentKind::Issue,
934 number: 7,
935 title: "Cap fixture".to_string(),
936 state: "OPEN".to_string(),
937 ..GithubDocument::default()
938 };
939 document.timeline = (1..=250)
940 .map(|number| GithubTimelineEvent {
941 actor: Some("maintainer".to_string()),
942 event: "labeled".to_string(),
943 created_at: Some(format!("2026-01-01T00:{number:02}:00Z")),
944 label: Some(format!("label {number}")),
945 ..GithubTimelineEvent::default()
946 })
947 .collect();
948 let resource = GithubResource {
949 kind: GithubResourceKind::Issue,
950 number: 7,
951 repository: Some("owner/repo".to_string()),
952 comment_selector: None,
953 };
954
955 let outline = render_outline_for_resource(&document, &resource);
956 assert!(outline.contains("[20] event(labeled)"));
957 assert!(outline.contains("… (50 omitted; read issue://owner/repo/7/comments/21-70)"));
958 assert!(outline.contains("[71] event(labeled)"));
959 assert!(outline.contains("[250] event(labeled)"));
960 assert!(!outline.contains("[21] event(labeled)"));
961 }
962}