|
1 | | -import textwrap |
| 1 | +import pathlib |
| 2 | +from typing import ClassVar |
2 | 3 |
|
3 | | -from lgtm_ai.ai.schemas import PublishMetadata, Review, ReviewComment, ReviewGuide, ReviewScore |
| 4 | +from jinja2 import Environment, FileSystemLoader |
| 5 | +from lgtm_ai.ai.schemas import PublishMetadata, Review, ReviewComment, ReviewGuide |
4 | 6 | from lgtm_ai.formatters.base import Formatter |
5 | 7 | from lgtm_ai.formatters.constants import CATEGORY_MAP, SCORE_MAP, SEVERITY_MAP |
6 | 8 | from pydantic_ai.usage import Usage |
7 | 9 |
|
8 | 10 |
|
9 | 11 | class MarkDownFormatter(Formatter[str]): |
10 | | - def format_review_summary_section(self, review: Review, comments: list[ReviewComment] | None = None) -> str: |
11 | | - header = textwrap.dedent(f""" |
12 | | - ## 🦉 lgtm Review |
13 | | -
|
14 | | - > **Score:** {self._format_score(review.review_response.score)} |
15 | | -
|
16 | | - ### 🔍 Summary |
17 | | -
|
18 | | - """) |
19 | | - summary = header + review.review_response.summary |
20 | | - if comments: |
21 | | - summary += f"\n\n{self.format_review_comments_section(comments)}" |
| 12 | + REVIEW_SUMMARY_TEMPLATE: ClassVar[str] = "review_summary.md.j2" |
| 13 | + REVIEW_COMMENTS_SECTION_TEMPLATE: ClassVar[str] = "review_comments_section.md.j2" |
| 14 | + REVIEW_COMMENT_TEMPLATE: ClassVar[str] = "review_comment.md.j2" |
| 15 | + REVIEW_GUIDE_TEMPLATE: ClassVar[str] = "review_guide.md.j2" |
| 16 | + SNIPPET_TEMPLATE: ClassVar[str] = "snippet.md.j2" |
| 17 | + USAGES_SUMMARY_TEMPLATE: ClassVar[str] = "usages_summary.md.j2" |
| 18 | + METADATA_TEMPLATE: ClassVar[str] = "metadata.md.j2" |
| 19 | + |
| 20 | + def __init__(self) -> None: |
| 21 | + template_dir = pathlib.Path(__file__).parent / "templates" |
| 22 | + self.env = Environment(loader=FileSystemLoader(template_dir), autoescape=True) |
22 | 23 |
|
23 | | - summary += self._format_metadata(review.metadata) |
24 | | - return summary |
| 24 | + def format_review_summary_section(self, review: Review, comments: list[ReviewComment] | None = None) -> str: |
| 25 | + template = self.env.get_template(self.REVIEW_SUMMARY_TEMPLATE) |
| 26 | + comments_section = self.format_review_comments_section(comments or []) |
| 27 | + metadata = self._format_metadata(review.metadata) |
| 28 | + return template.render( |
| 29 | + score=review.review_response.score, |
| 30 | + score_icon=SCORE_MAP[review.review_response.score], |
| 31 | + summary=review.review_response.summary, |
| 32 | + comments_section=comments_section, |
| 33 | + metadata=metadata, |
| 34 | + ) |
25 | 35 |
|
26 | 36 | def format_review_comments_section(self, comments: list[ReviewComment]) -> str: |
27 | 37 | if not comments: |
28 | 38 | return "" |
29 | | - lines = ["**Specific Comments:**"] |
30 | | - for comment in comments: |
31 | | - lines.append(f"- {self.format_review_comment(comment, with_footer=False)}") |
32 | | - return "\n\n".join(lines) |
| 39 | + template = self.env.get_template(self.REVIEW_COMMENTS_SECTION_TEMPLATE) |
| 40 | + rendered_comments = [self.format_review_comment(comment, with_footer=False) for comment in comments] |
| 41 | + return template.render(comments=rendered_comments) |
33 | 42 |
|
34 | 43 | def format_review_comment(self, comment: ReviewComment, *, with_footer: bool = True) -> str: |
35 | | - header_section = "\n\n".join( |
36 | | - [ |
37 | | - f"#### 🦉 {CATEGORY_MAP[comment.category]} {comment.category}", |
38 | | - f"> **Severity:** {comment.severity} {SEVERITY_MAP[comment.severity]}", |
39 | | - ] |
| 44 | + template = self.env.get_template(self.REVIEW_COMMENT_TEMPLATE) |
| 45 | + header_category = CATEGORY_MAP[comment.category] |
| 46 | + severity_icon = SEVERITY_MAP[comment.severity] |
| 47 | + snippet = self._format_snippet(comment) if comment.quote_snippet else None |
| 48 | + return template.render( |
| 49 | + category=header_category, |
| 50 | + category_key=comment.category, |
| 51 | + severity=comment.severity, |
| 52 | + severity_icon=severity_icon, |
| 53 | + snippet=snippet, |
| 54 | + comment=comment.comment, |
| 55 | + with_footer=with_footer, |
| 56 | + new_path=comment.new_path, |
| 57 | + line_number=comment.line_number, |
| 58 | + relative_line_number=comment.relative_line_number, |
40 | 59 | ) |
41 | | - comment_section = ( |
42 | | - f"\n{self._format_snippet(comment)}\n{comment.comment}" if comment.quote_snippet else comment.comment |
43 | | - ) |
44 | | - |
45 | | - footer_section = ( |
46 | | - textwrap.dedent(f""" |
47 | | -
|
48 | | - <details><summary>More information about this comment</summary> |
49 | | -
|
50 | | - - **File**: `{comment.new_path}` |
51 | | - - **Line**: `{comment.line_number}` |
52 | | - - **Relative line**: `{comment.relative_line_number}` |
53 | | -
|
54 | | - </details> |
55 | | - """) |
56 | | - if with_footer |
57 | | - else "" |
58 | | - ) |
59 | | - |
60 | | - return f"{header_section}\n\n{comment_section}\n\n{footer_section}" |
61 | 60 |
|
62 | 61 | def format_guide(self, guide: ReviewGuide) -> str: |
63 | | - header = textwrap.dedent(""" |
64 | | - ## 🦉 lgtm Reviewer Guide |
65 | | -
|
66 | | - """) |
67 | | - |
68 | | - summary = guide.guide_response.summary |
69 | | - # Format key changes as a markdown table |
70 | | - key_changes = ["| File Name | Description |", "| ---- | ---- |"] + [ |
71 | | - f"| {change.file_name} | {change.description} |" for change in guide.guide_response.key_changes |
72 | | - ] |
73 | | - |
74 | | - # Format checklist items as a checklist |
75 | | - checklist = [f"- [ ] {item.description}" for item in guide.guide_response.checklist] |
76 | | - |
77 | | - # Format references as a list |
78 | | - if guide.guide_response.references: |
79 | | - references = [f"- [{item.title}]({item.url})" for item in guide.guide_response.references] |
80 | | - else: |
81 | | - references = [] |
82 | | - |
83 | | - # Combine all sections |
84 | | - |
85 | | - summary = ( |
86 | | - header |
87 | | - + "### 🔍 Summary\n\n" |
88 | | - + summary |
89 | | - + "\n\n### 🔑 Key Changes\n\n" |
90 | | - + "\n".join(key_changes) |
91 | | - + "\n\n### ✅ Reviewer Checklist\n\n" |
92 | | - + "\n".join(checklist) |
| 62 | + template = self.env.get_template(self.REVIEW_GUIDE_TEMPLATE) |
| 63 | + key_changes = guide.guide_response.key_changes |
| 64 | + checklist = guide.guide_response.checklist |
| 65 | + references = guide.guide_response.references |
| 66 | + metadata = self._format_metadata(guide.metadata) |
| 67 | + return template.render( |
| 68 | + summary=guide.guide_response.summary, |
| 69 | + key_changes=key_changes, |
| 70 | + checklist=checklist, |
| 71 | + references=references, |
| 72 | + metadata=metadata, |
93 | 73 | ) |
94 | | - if references: |
95 | | - summary += "\n\n### 📚 References\n\n" + "\n".join(references) |
96 | | - |
97 | | - summary += self._format_metadata(guide.metadata) |
98 | | - return summary |
99 | | - |
100 | | - def _format_score(self, score: ReviewScore) -> str: |
101 | | - return f"{score} {SCORE_MAP[score]}" |
102 | 74 |
|
103 | 75 | def _format_snippet(self, comment: ReviewComment) -> str: |
104 | | - return f"\n\n```{comment.programming_language.lower()}\n{comment.quote_snippet}\n```\n\n" |
| 76 | + template = self.env.get_template(self.SNIPPET_TEMPLATE) |
| 77 | + return template.render(language=comment.programming_language.lower(), snippet=comment.quote_snippet) |
105 | 78 |
|
106 | 79 | def _format_usages_summary(self, usages: list[Usage]) -> str: |
107 | | - formatted_usage_calls = [] |
108 | | - for i, usage in enumerate(usages): |
109 | | - formatted_usage_calls += [self._format_usage_call_collapsible(usage, i)] |
110 | | - |
111 | | - return f""" |
112 | | - <details><summary>Usage summary</summary> |
113 | | - {"\n".join(formatted_usage_calls)} |
114 | | - **Total tokens**: `{sum([usage.total_tokens or 0 for usage in usages])}` |
115 | | - </details> |
116 | | - """ |
117 | | - |
118 | | - def _format_usage_call_collapsible(self, usage: Usage, index: int) -> str: |
119 | | - return f""" |
120 | | - <details><summary>Call {index + 1}</summary> |
121 | | -
|
122 | | - - **Request count**: `{usage.requests}` |
123 | | - - **Request tokens**: `{usage.request_tokens}` |
124 | | - - **Response tokens**: `{usage.response_tokens}` |
125 | | - - **Total tokens**: `{usage.total_tokens}` |
126 | | - </details> |
127 | | - """ |
| 80 | + template = self.env.get_template(self.USAGES_SUMMARY_TEMPLATE) |
| 81 | + total_tokens = sum(usage.total_tokens or 0 for usage in usages) |
| 82 | + return template.render(usages=usages, total_tokens=total_tokens) |
128 | 83 |
|
129 | 84 | def _format_metadata(self, metadata: PublishMetadata) -> str: |
130 | | - return textwrap.dedent(f""" |
131 | | -
|
132 | | - <details><summary>More information</summary> |
133 | | -
|
134 | | - - **Id**: `{metadata.uuid}` |
135 | | - - **Model**: `{metadata.model_name}` |
136 | | - - **Created at**: `{metadata.created_at}` |
137 | | -
|
138 | | - {self._format_usages_summary(metadata.usages)} |
139 | | -
|
140 | | - > See the [📚 lgtm-ai repository](https://github.com/elementsinteractive/lgtm-ai) for more information about lgtm. |
141 | | -
|
142 | | - </details> |
143 | | - """) |
| 85 | + template = self.env.get_template(self.METADATA_TEMPLATE) |
| 86 | + usages_summary = self._format_usages_summary(metadata.usages) |
| 87 | + return template.render( |
| 88 | + uuid=metadata.uuid, |
| 89 | + model_name=metadata.model_name, |
| 90 | + created_at=metadata.created_at, |
| 91 | + usages_summary=usages_summary, |
| 92 | + ) |
0 commit comments