Skip to main content

aft/github_read/
render.rs

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
11/// The maximum number of newest comments rendered for an issue or one pull
12/// request review-comment section. Older comments remain available on GitHub
13/// and are disclosed in the canonical document instead of silently dropped.
14pub const MAX_RENDERED_COMMENTS_PER_SECTION: usize = 50;
15
16/// Render the complete, transport-independent GitHub document once in Rust.
17///
18/// Selector handling belongs after this function. The renderer purposefully
19/// never sees a vision capability or an attachment downloader, so its bytes are
20/// identical for text-only and vision-capable callers.
21pub 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(&section.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(&section.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}