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,
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 mut next_ordinal = 1;
85 render_comments(
86 &mut output,
87 "Comments",
88 &document.comments,
89 document.comments_total_count,
90 document.minimized_comments_count,
91 &mut next_ordinal,
92 resource,
93 );
94
95 if document.kind == GithubDocumentKind::PullRequest {
96 render_files(&mut output, document);
97 render_reviews(&mut output, document, &mut next_ordinal, resource);
98 render_review_comment_sections(
99 &mut output,
100 &document.review_comment_sections,
101 &mut next_ordinal,
102 resource,
103 );
104 }
105
106 output.push_str(&format!(
107 "Discussion drill-down: {}/comments/<sel> (for example 3, 3-5, or 3,7).\n\n",
108 resource.base_spelling()
109 ));
110 Ok(output)
111}
112
113fn render_comments(
114 output: &mut String,
115 heading: &str,
116 comments: &[GithubComment],
117 total_count: Option<usize>,
118 supplied_minimized_count: Option<usize>,
119 next_ordinal: &mut usize,
120 resource: &GithubResource,
121) {
122 if comments.is_empty()
123 && total_count.unwrap_or(0) == 0
124 && supplied_minimized_count.unwrap_or(0) == 0
125 {
126 return;
127 }
128 output.push_str(&format!("\n## {heading}\n\n"));
129 let displayed = newest_comments(comments);
130 let total_count = total_count.unwrap_or(comments.len()).max(comments.len());
131 let omitted = total_count.saturating_sub(displayed.len());
132 if omitted > 0 {
133 output.push_str(&format!("{omitted} earlier comments omitted\n\n"));
134 }
135 for comment in displayed {
136 render_default_discussion_item(
137 output,
138 comment.author.as_deref(),
139 comment.created_at.as_deref(),
140 None,
141 &comment.body,
142 next_ordinal,
143 resource,
144 );
145 }
146 let observed_minimized = comments.iter().filter(|comment| comment.minimized).count();
147 let minimized = supplied_minimized_count
148 .unwrap_or(observed_minimized)
149 .max(observed_minimized);
150 if minimized > 0 {
151 output.push_str(&format!("Minimized comments: {minimized}\n\n"));
152 }
153}
154
155fn render_files(output: &mut String, document: &GithubDocument) {
156 if document.files.is_empty() {
157 return;
158 }
159 output.push_str("\n## Files\n\n");
160 for file in &document.files {
161 output.push_str("- `");
162 output.push_str(&file.path);
163 output.push('`');
164 if file.additions.is_some() || file.deletions.is_some() {
165 output.push_str(&format!(
166 " (+{} -{})",
167 file.additions.unwrap_or(0),
168 file.deletions.unwrap_or(0)
169 ));
170 }
171 if let Some(status) = nonempty(&file.status) {
172 output.push_str(&format!(" [{status}]"));
173 }
174 output.push('\n');
175 }
176}
177
178fn render_reviews(
179 output: &mut String,
180 document: &GithubDocument,
181 next_ordinal: &mut usize,
182 resource: &GithubResource,
183) {
184 if !document.reviews.iter().any(review_is_visible) {
185 return;
186 }
187 output.push_str("\n## Reviews\n\n");
188 for review in &document.reviews {
189 render_review(output, review, next_ordinal, resource);
190 }
191}
192
193fn render_review(
194 output: &mut String,
195 review: &GithubReview,
196 next_ordinal: &mut usize,
197 resource: &GithubResource,
198) {
199 render_default_discussion_item(
200 output,
201 review.author.as_deref(),
202 review.submitted_at.as_deref(),
203 review.state.as_deref(),
204 &review.body,
205 next_ordinal,
206 resource,
207 );
208}
209
210fn render_review_comment_sections(
211 output: &mut String,
212 sections: &[GithubReviewCommentSection],
213 next_ordinal: &mut usize,
214 resource: &GithubResource,
215) {
216 if !sections.iter().any(|section| {
217 section.comments.iter().any(comment_is_visible)
218 || section.comments_total_count.unwrap_or(0) > section.comments.len()
219 || section.minimized_comments_count.unwrap_or(0) > 0
220 }) {
221 return;
222 }
223 output.push_str("\n## Review comments\n\n");
224 for section in sections {
225 let displayed = newest_comments(§ion.comments);
226 let total_count = section
227 .comments_total_count
228 .unwrap_or(section.comments.len())
229 .max(section.comments.len());
230 let omitted = total_count.saturating_sub(displayed.len());
231 if omitted > 0 {
232 let author = section
233 .author
234 .as_deref()
235 .map(format_author)
236 .unwrap_or_else(|| "unknown".to_string());
237 output.push_str(&format!(
238 "{omitted} earlier comments omitted from {author}'s review\n\n"
239 ));
240 }
241 for comment in displayed {
242 render_default_discussion_item(
243 output,
244 comment.author.as_deref(),
245 comment.created_at.as_deref(),
246 None,
247 &comment.body,
248 next_ordinal,
249 resource,
250 );
251 }
252 let observed_minimized = section
253 .comments
254 .iter()
255 .filter(|comment| comment.minimized)
256 .count();
257 let minimized = section
258 .minimized_comments_count
259 .unwrap_or(observed_minimized)
260 .max(observed_minimized);
261 if minimized > 0 {
262 output.push_str(&format!("Minimized comments: {minimized}\n\n"));
263 }
264 }
265}
266
267fn render_default_discussion_item(
268 output: &mut String,
269 author: Option<&str>,
270 date: Option<&str>,
271 state: Option<&str>,
272 body: &str,
273 next_ordinal: &mut usize,
274 resource: &GithubResource,
275) {
276 let compressed = compress_discussion_body(author, body);
277 if compressed.body.is_empty() {
278 return;
279 }
280 let ordinal = *next_ordinal;
281 *next_ordinal += 1;
282 render_item_heading(output, ordinal, author, date);
283 if let Some(state) = state.filter(|state| !state.is_empty()) {
284 output.push_str(&format!("State: {state}\n\n"));
285 }
286 append_body(output, &compressed.body);
287 if compressed.compressed {
288 output.push_str(&format!(
289 "[compressed; full: {}/comments/{ordinal}]\n\n",
290 resource.base_spelling()
291 ));
292 }
293}
294
295fn render_selected_discussion(
296 document: &GithubDocument,
297 selector: &GithubCommentSelector,
298) -> Result<String, GithubReadError> {
299 let items = discussion_items(document);
300 if let Some(ordinal) = selector.first_out_of_range(items.len()) {
301 let valid_range = if items.is_empty() {
302 "empty".to_string()
303 } else {
304 format!("1-{}", items.len())
305 };
306 return Err(GithubReadError::InvalidCommentSelector(format!(
307 "discussion ordinal {ordinal} is out of range; valid range is {valid_range}"
308 )));
309 }
310
311 let mut output = String::new();
312 for (index, item) in items.into_iter().enumerate() {
313 let ordinal = index + 1;
314 if !selector.contains(ordinal) {
315 continue;
316 }
317 render_item_heading(&mut output, ordinal, item.author, item.date);
318 if let Some(state) = item.state.filter(|state| !state.is_empty()) {
319 output.push_str(&format!("State: {state}\n\n"));
320 }
321 append_body(&mut output, &structural_strip(item.body));
322 }
323 Ok(output)
324}
325
326#[derive(Clone, Copy)]
327struct DiscussionItem<'a> {
328 author: Option<&'a str>,
329 date: Option<&'a str>,
330 state: Option<&'a str>,
331 body: &'a str,
332}
333
334fn discussion_items(document: &GithubDocument) -> Vec<DiscussionItem<'_>> {
335 let mut items = Vec::new();
336 for comment in newest_comments(&document.comments) {
337 if comment_is_visible(comment) {
338 items.push(DiscussionItem {
339 author: comment.author.as_deref(),
340 date: comment.created_at.as_deref(),
341 state: None,
342 body: &comment.body,
343 });
344 }
345 }
346 if document.kind == GithubDocumentKind::PullRequest {
347 for review in &document.reviews {
348 if review_is_visible(review) {
349 items.push(DiscussionItem {
350 author: review.author.as_deref(),
351 date: review.submitted_at.as_deref(),
352 state: review.state.as_deref(),
353 body: &review.body,
354 });
355 }
356 }
357 for section in &document.review_comment_sections {
358 for comment in newest_comments(§ion.comments) {
359 if comment_is_visible(comment) {
360 items.push(DiscussionItem {
361 author: comment.author.as_deref(),
362 date: comment.created_at.as_deref(),
363 state: None,
364 body: &comment.body,
365 });
366 }
367 }
368 }
369 }
370 items
371}
372
373fn review_is_visible(review: &GithubReview) -> bool {
374 discussion_body_is_visible(review.author.as_deref(), &review.body)
375}
376
377fn comment_is_visible(comment: &GithubComment) -> bool {
378 discussion_body_is_visible(comment.author.as_deref(), &comment.body)
379}
380
381fn discussion_body_is_visible(author: Option<&str>, body: &str) -> bool {
382 !compress_discussion_body(author, body).body.is_empty()
383}
384
385fn render_item_heading(
386 output: &mut String,
387 ordinal: usize,
388 author: Option<&str>,
389 date: Option<&str>,
390) {
391 let author = author
392 .map(format_author)
393 .unwrap_or_else(|| "unknown".to_string());
394 let date = date.unwrap_or("unknown date");
395 output.push_str(&format!("### [{ordinal}] {author} · {date}\n\n"));
396}
397
398fn newest_comments(comments: &[GithubComment]) -> Vec<&GithubComment> {
399 let mut comments: Vec<_> = comments.iter().collect();
400 comments.sort_by(|left, right| compare_timestamp(&left.created_at, &right.created_at));
401 let drop_count = comments
402 .len()
403 .saturating_sub(MAX_RENDERED_COMMENTS_PER_SECTION);
404 comments.into_iter().skip(drop_count).collect()
405}
406
407fn compare_timestamp(left: &Option<String>, right: &Option<String>) -> Ordering {
408 left.as_deref()
409 .unwrap_or("")
410 .cmp(right.as_deref().unwrap_or(""))
411}
412
413fn render_reactions(reactions: &[GithubReaction]) -> Option<String> {
414 let rendered: Vec<_> = reactions
415 .iter()
416 .filter(|reaction| reaction.count > 0)
417 .map(|reaction| {
418 format!(
419 "{} x{}",
420 reaction_display(&reaction.content),
421 reaction.count
422 )
423 })
424 .collect();
425 (!rendered.is_empty()).then(|| rendered.join(", "))
426}
427
428fn reaction_display(reaction: &str) -> &str {
429 match reaction {
430 "THUMBS_UP" | "+1" => "+1",
431 "THUMBS_DOWN" | "-1" => "-1",
432 "LAUGH" => "laugh",
433 "HOORAY" => "hooray",
434 "CONFUSED" => "confused",
435 "HEART" => "heart",
436 "ROCKET" => "rocket",
437 "EYES" => "eyes",
438 other => other,
439 }
440}
441
442fn format_author(author: &str) -> String {
443 if author.starts_with('@') {
444 author.to_string()
445 } else {
446 format!("@{author}")
447 }
448}
449
450fn nonempty(value: &Option<String>) -> Option<&str> {
451 value.as_deref().filter(|value| !value.is_empty())
452}
453
454fn append_body(output: &mut String, body: &str) {
455 if body.is_empty() {
456 return;
457 }
458 output.push_str(body.trim_end());
459 output.push_str("\n\n");
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465 use crate::github_read::model::{GithubDocument, GithubDocumentKind};
466
467 #[test]
468 fn renderer_keeps_full_human_body_caps_comments_and_omits_comment_reactions() {
469 let mut document = GithubDocument {
470 repository: "owner/repo".to_string(),
471 kind: GithubDocumentKind::Issue,
472 number: 7,
473 title: "Fixture".to_string(),
474 state: "OPEN".to_string(),
475 author: Some("octo".to_string()),
476 body: "body with https://user-images.githubusercontent.com/example.png".to_string(),
477 reactions: vec![GithubReaction {
478 content: "THUMBS_UP".to_string(),
479 count: 2,
480 }],
481 ..GithubDocument::default()
482 };
483 document.comments = (0..51)
484 .map(|number| GithubComment {
485 author: Some("commenter".to_string()),
486 body: format!("comment {number}"),
487 created_at: Some(format!("2026-01-{:02}T00:00:00Z", number.min(28))),
488 minimized: number == 0,
489 ..GithubComment::default()
490 })
491 .collect();
492 document.comments_total_count = Some(51);
493
494 let text = render_document(&document);
495 assert!(text.contains("Repository: owner/repo"));
496 assert!(text.contains("Reactions: +1 x2"));
497 assert!(text.contains("1 earlier comments omitted"));
498 assert!(text.contains("Minimized comments: 1"));
499 assert!(!text.contains("comment 0\n"));
500 assert!(text.contains("### [1] @commenter"));
501 assert!(text.contains("### [50] @commenter"));
502 assert_eq!(text.matches("/comments/<sel>").count(), 1);
503 }
504}