diff --git a/poetry.lock b/poetry.lock index 2883895..42d9ba2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. [[package]] name = "ag-ui-protocol" @@ -1293,7 +1293,6 @@ description = "Consume Server-Sent Event (SSE) messages with HTTPX." optional = false python-versions = ">=3.8" groups = ["main"] -markers = "platform_system != \"Emscripten\"" files = [ {file = "httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721"}, {file = "httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f"}, @@ -1494,7 +1493,7 @@ version = "3.1.6" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, @@ -1700,7 +1699,7 @@ version = "3.0.2" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, @@ -3995,4 +3994,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.12,<4" -content-hash = "3b07173aee03bc879861259f0087d89ceaed03d405344a6d8cc0381b7772d398" +content-hash = "153e9037ab8c82802d4b638f6b043ae346581db66a478cf481d2fc8a60a3427c" diff --git a/pyproject.toml b/pyproject.toml index 5f3ff96..cbd0f7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,8 @@ dependencies = [ "python-gitlab>=5.1.0,<6.0.0", "rich>=13.9.4,<14.0.0", "pygithub>=2.6.1,<3.0.0", - "httpx (>=0.28.1,<0.29.0)" + "httpx (>=0.28.1,<0.29.0)", + "jinja2 (>=3.1.6,<4.0.0)" ] [tool.poetry.group.dev.dependencies] diff --git a/src/lgtm_ai/formatters/markdown.py b/src/lgtm_ai/formatters/markdown.py index f97ae3d..dced7b5 100644 --- a/src/lgtm_ai/formatters/markdown.py +++ b/src/lgtm_ai/formatters/markdown.py @@ -1,143 +1,92 @@ -import textwrap +import pathlib +from typing import ClassVar -from lgtm_ai.ai.schemas import PublishMetadata, Review, ReviewComment, ReviewGuide, ReviewScore +from jinja2 import Environment, FileSystemLoader +from lgtm_ai.ai.schemas import PublishMetadata, Review, ReviewComment, ReviewGuide from lgtm_ai.formatters.base import Formatter from lgtm_ai.formatters.constants import CATEGORY_MAP, SCORE_MAP, SEVERITY_MAP from pydantic_ai.usage import Usage class MarkDownFormatter(Formatter[str]): - def format_review_summary_section(self, review: Review, comments: list[ReviewComment] | None = None) -> str: - header = textwrap.dedent(f""" - ## ๐Ÿฆ‰ lgtm Review - - > **Score:** {self._format_score(review.review_response.score)} - - ### ๐Ÿ” Summary - - """) - summary = header + review.review_response.summary - if comments: - summary += f"\n\n{self.format_review_comments_section(comments)}" + REVIEW_SUMMARY_TEMPLATE: ClassVar[str] = "review_summary.md.j2" + REVIEW_COMMENTS_SECTION_TEMPLATE: ClassVar[str] = "review_comments_section.md.j2" + REVIEW_COMMENT_TEMPLATE: ClassVar[str] = "review_comment.md.j2" + REVIEW_GUIDE_TEMPLATE: ClassVar[str] = "review_guide.md.j2" + SNIPPET_TEMPLATE: ClassVar[str] = "snippet.md.j2" + USAGES_SUMMARY_TEMPLATE: ClassVar[str] = "usages_summary.md.j2" + METADATA_TEMPLATE: ClassVar[str] = "metadata.md.j2" + + def __init__(self) -> None: + template_dir = pathlib.Path(__file__).parent / "templates" + self.env = Environment(loader=FileSystemLoader(template_dir), autoescape=True) - summary += self._format_metadata(review.metadata) - return summary + def format_review_summary_section(self, review: Review, comments: list[ReviewComment] | None = None) -> str: + template = self.env.get_template(self.REVIEW_SUMMARY_TEMPLATE) + comments_section = self.format_review_comments_section(comments or []) + metadata = self._format_metadata(review.metadata) + return template.render( + score=review.review_response.score, + score_icon=SCORE_MAP[review.review_response.score], + summary=review.review_response.summary, + comments_section=comments_section, + metadata=metadata, + ) def format_review_comments_section(self, comments: list[ReviewComment]) -> str: if not comments: return "" - lines = ["**Specific Comments:**"] - for comment in comments: - lines.append(f"- {self.format_review_comment(comment, with_footer=False)}") - return "\n\n".join(lines) + template = self.env.get_template(self.REVIEW_COMMENTS_SECTION_TEMPLATE) + rendered_comments = [self.format_review_comment(comment, with_footer=False) for comment in comments] + return template.render(comments=rendered_comments) def format_review_comment(self, comment: ReviewComment, *, with_footer: bool = True) -> str: - header_section = "\n\n".join( - [ - f"#### ๐Ÿฆ‰ {CATEGORY_MAP[comment.category]} {comment.category}", - f"> **Severity:** {comment.severity} {SEVERITY_MAP[comment.severity]}", - ] + template = self.env.get_template(self.REVIEW_COMMENT_TEMPLATE) + header_category = CATEGORY_MAP[comment.category] + severity_icon = SEVERITY_MAP[comment.severity] + snippet = self._format_snippet(comment) if comment.quote_snippet else None + return template.render( + category=header_category, + category_key=comment.category, + severity=comment.severity, + severity_icon=severity_icon, + snippet=snippet, + comment=comment.comment, + with_footer=with_footer, + new_path=comment.new_path, + line_number=comment.line_number, + relative_line_number=comment.relative_line_number, ) - comment_section = ( - f"\n{self._format_snippet(comment)}\n{comment.comment}" if comment.quote_snippet else comment.comment - ) - - footer_section = ( - textwrap.dedent(f""" - -
More information about this comment - - - **File**: `{comment.new_path}` - - **Line**: `{comment.line_number}` - - **Relative line**: `{comment.relative_line_number}` - -
- """) - if with_footer - else "" - ) - - return f"{header_section}\n\n{comment_section}\n\n{footer_section}" def format_guide(self, guide: ReviewGuide) -> str: - header = textwrap.dedent(""" - ## ๐Ÿฆ‰ lgtm Reviewer Guide - - """) - - summary = guide.guide_response.summary - # Format key changes as a markdown table - key_changes = ["| File Name | Description |", "| ---- | ---- |"] + [ - f"| {change.file_name} | {change.description} |" for change in guide.guide_response.key_changes - ] - - # Format checklist items as a checklist - checklist = [f"- [ ] {item.description}" for item in guide.guide_response.checklist] - - # Format references as a list - if guide.guide_response.references: - references = [f"- [{item.title}]({item.url})" for item in guide.guide_response.references] - else: - references = [] - - # Combine all sections - - summary = ( - header - + "### ๐Ÿ” Summary\n\n" - + summary - + "\n\n### ๐Ÿ”‘ Key Changes\n\n" - + "\n".join(key_changes) - + "\n\n### โœ… Reviewer Checklist\n\n" - + "\n".join(checklist) + template = self.env.get_template(self.REVIEW_GUIDE_TEMPLATE) + key_changes = guide.guide_response.key_changes + checklist = guide.guide_response.checklist + references = guide.guide_response.references + metadata = self._format_metadata(guide.metadata) + return template.render( + summary=guide.guide_response.summary, + key_changes=key_changes, + checklist=checklist, + references=references, + metadata=metadata, ) - if references: - summary += "\n\n### ๐Ÿ“š References\n\n" + "\n".join(references) - - summary += self._format_metadata(guide.metadata) - return summary - - def _format_score(self, score: ReviewScore) -> str: - return f"{score} {SCORE_MAP[score]}" def _format_snippet(self, comment: ReviewComment) -> str: - return f"\n\n```{comment.programming_language.lower()}\n{comment.quote_snippet}\n```\n\n" + template = self.env.get_template(self.SNIPPET_TEMPLATE) + return template.render(language=comment.programming_language.lower(), snippet=comment.quote_snippet) def _format_usages_summary(self, usages: list[Usage]) -> str: - formatted_usage_calls = [] - for i, usage in enumerate(usages): - formatted_usage_calls += [self._format_usage_call_collapsible(usage, i)] - - return f""" -
Usage summary - {"\n".join(formatted_usage_calls)} - **Total tokens**: `{sum([usage.total_tokens or 0 for usage in usages])}` -
- """ - - def _format_usage_call_collapsible(self, usage: Usage, index: int) -> str: - return f""" -
Call {index + 1} - - - **Request count**: `{usage.requests}` - - **Request tokens**: `{usage.request_tokens}` - - **Response tokens**: `{usage.response_tokens}` - - **Total tokens**: `{usage.total_tokens}` -
- """ + template = self.env.get_template(self.USAGES_SUMMARY_TEMPLATE) + total_tokens = sum(usage.total_tokens or 0 for usage in usages) + return template.render(usages=usages, total_tokens=total_tokens) def _format_metadata(self, metadata: PublishMetadata) -> str: - return textwrap.dedent(f""" - -
More information - - - **Id**: `{metadata.uuid}` - - **Model**: `{metadata.model_name}` - - **Created at**: `{metadata.created_at}` - - {self._format_usages_summary(metadata.usages)} - - > See the [๐Ÿ“š lgtm-ai repository](https://github.com/elementsinteractive/lgtm-ai) for more information about lgtm. - -
- """) + template = self.env.get_template(self.METADATA_TEMPLATE) + usages_summary = self._format_usages_summary(metadata.usages) + return template.render( + uuid=metadata.uuid, + model_name=metadata.model_name, + created_at=metadata.created_at, + usages_summary=usages_summary, + ) diff --git a/src/lgtm_ai/formatters/templates/metadata.md.j2 b/src/lgtm_ai/formatters/templates/metadata.md.j2 new file mode 100644 index 0000000..a4749cb --- /dev/null +++ b/src/lgtm_ai/formatters/templates/metadata.md.j2 @@ -0,0 +1,14 @@ +
More information + +- **Id**: `{{ uuid }}` +- **Model**: `{{ model_name }}` +- **Created at**: `{{ created_at }}` + + +{{ usages_summary | safe }} + + +> See the [๐Ÿ“š lgtm-ai repository](https://github.com/elementsinteractive/lgtm-ai) for more information about lgtm. + +
+ diff --git a/src/lgtm_ai/formatters/templates/review_comment.md.j2 b/src/lgtm_ai/formatters/templates/review_comment.md.j2 new file mode 100644 index 0000000..c4012a2 --- /dev/null +++ b/src/lgtm_ai/formatters/templates/review_comment.md.j2 @@ -0,0 +1,18 @@ +#### ๐Ÿฆ‰ {{ category }} {{ category_key }} + +> **Severity:** {{ severity }} {{ severity_icon }} + +{% if snippet %} +{{ snippet | safe }} + +{% endif %} +{{ comment | safe }} + +{% if with_footer %} +
More information about this comment + +- **File**: `{{ new_path }}` +- **Line**: `{{ line_number }}` +- **Relative line**: `{{ relative_line_number }}` +
+{% endif %} \ No newline at end of file diff --git a/src/lgtm_ai/formatters/templates/review_comments_section.md.j2 b/src/lgtm_ai/formatters/templates/review_comments_section.md.j2 new file mode 100644 index 0000000..8b85a8b --- /dev/null +++ b/src/lgtm_ai/formatters/templates/review_comments_section.md.j2 @@ -0,0 +1,5 @@ +{% if comments %}**Specific Comments:** + +{% for comment in comments %}- {{ comment | safe }} +{% endfor %} +{% endif %} \ No newline at end of file diff --git a/src/lgtm_ai/formatters/templates/review_guide.md.j2 b/src/lgtm_ai/formatters/templates/review_guide.md.j2 new file mode 100644 index 0000000..424d977 --- /dev/null +++ b/src/lgtm_ai/formatters/templates/review_guide.md.j2 @@ -0,0 +1,31 @@ + +## ๐Ÿฆ‰ lgtm Reviewer Guide + +### ๐Ÿ” Summary + +{{ summary | safe }} + +### ๐Ÿ”‘ Key Changes + +| File Name | Description | +| ---- | ---- | +{% for change in key_changes -%} +| {{ change.file_name }} | {{ change.description }} | +{% endfor %} + +### โœ… Reviewer Checklist + +{% for item in checklist %} +- [ ] {{ item.description }} +{% endfor %} + +{% if references %} +### ๐Ÿ“š References +{% for ref in references %} +- [{{ ref.title }}]({{ ref.url }}) +{% endfor %} +{% endif %} + +{{ metadata | safe }} + + diff --git a/src/lgtm_ai/formatters/templates/review_summary.md.j2 b/src/lgtm_ai/formatters/templates/review_summary.md.j2 new file mode 100644 index 0000000..d241c86 --- /dev/null +++ b/src/lgtm_ai/formatters/templates/review_summary.md.j2 @@ -0,0 +1,14 @@ + +## ๐Ÿฆ‰ lgtm Review + +> **Score:** {{ score }} {{ score_icon }} + +### ๐Ÿ” Summary + +{{ summary | safe }} + +{% if comments_section %} +{{ comments_section | safe}} +{% endif %} + +{{ metadata | safe }} diff --git a/src/lgtm_ai/formatters/templates/snippet.md.j2 b/src/lgtm_ai/formatters/templates/snippet.md.j2 new file mode 100644 index 0000000..f058406 --- /dev/null +++ b/src/lgtm_ai/formatters/templates/snippet.md.j2 @@ -0,0 +1,3 @@ +```{{ language }} +{{ snippet | safe }} +``` diff --git a/src/lgtm_ai/formatters/templates/usages_summary.md.j2 b/src/lgtm_ai/formatters/templates/usages_summary.md.j2 new file mode 100644 index 0000000..a3d3ee7 --- /dev/null +++ b/src/lgtm_ai/formatters/templates/usages_summary.md.j2 @@ -0,0 +1,14 @@ +
Usage summary +{% for usage in usages %} + +
Call {{ loop.index }} + +- **Request count**: `{{ usage.requests }}` +- **Request tokens**: `{{ usage.request_tokens }}` +- **Response tokens**: `{{ usage.response_tokens }}` +- **Total tokens**: `{{ usage.total_tokens }}` +
+ +{% endfor %} +**Total tokens**: `{{ total_tokens }}` +
diff --git a/tests/formatters/test_markdown.py b/tests/formatters/test_markdown.py index d30db78..90ef930 100644 --- a/tests/formatters/test_markdown.py +++ b/tests/formatters/test_markdown.py @@ -44,6 +44,8 @@ def test_format_summary_section(self) -> None: "", "summary", "", + "", + "", "
More information", "", "- **Id**: `fb64cb958fcf49219545912156e0a4a0`", @@ -53,6 +55,7 @@ def test_format_summary_section(self) -> None: "", "
Usage summary", "", + "", "
Call 1", "", "- **Request count**: `1`", @@ -62,6 +65,7 @@ def test_format_summary_section(self) -> None: "
", "", "", + "", "
Call 2", "", "- **Request count**: `1`", @@ -71,6 +75,7 @@ def test_format_summary_section(self) -> None: "
", "", "", + "", "
Call 3", "", "- **Request count**: `1`", @@ -79,6 +84,7 @@ def test_format_summary_section(self) -> None: "- **Total tokens**: `300`", "
", "", + "", "**Total tokens**: `900`", "
", "", @@ -148,20 +154,35 @@ def test_format_comments_section_several_comments(self) -> None: expected = [ "**Specific Comments:**", + "", "- #### ๐Ÿฆ‰ ๐Ÿงช Testing", + "", "> **Severity:** HIGH ๐Ÿ”ด", + "", + "", "comment 2", "", + "", "- #### ๐Ÿฆ‰ ๐Ÿงช Testing", + "", "> **Severity:** MEDIUM ๐ŸŸก", + "", + "", "comment 3", "", + "", "- #### ๐Ÿฆ‰ ๐ŸŽฏ Correctness", + "", "> **Severity:** LOW ๐Ÿ”ต", + "", + "", "comment 1", "", + "", + "", + "", ] - assert self.formatter.format_review_comments_section(review.review_response.comments) == "\n\n".join(expected) + assert self.formatter.format_review_comments_section(review.review_response.comments).split("\n") == expected def test_format_comment_with_snippet(self) -> None: review = Review( @@ -189,16 +210,27 @@ def test_format_comment_with_snippet(self) -> None: expected = [ "#### ๐Ÿฆ‰ ๐ŸŽฏ Correctness", + "", "> **Severity:** LOW ๐Ÿ”ต", "", - "\n```python\nprint('Hello World')\n```", - "\ncomment", + "", + "```python", + "print('Hello World')", + "```", + "", + "", + "comment", + "", "", "
More information about this comment", - "- **File**: `new_path`\n- **Line**: `1`\n- **Relative line**: `1`", - "
\n", + "", + "- **File**: `new_path`", + "- **Line**: `1`", + "- **Relative line**: `1`", + "
", + "", ] - assert self.formatter.format_review_comment(review.review_response.comments[0]) == "\n\n".join(expected) + assert self.formatter.format_review_comment(review.review_response.comments[0]).split("\n") == expected def test_format_guide(self) -> None: guide = ReviewGuide( @@ -228,27 +260,62 @@ def test_format_guide(self) -> None: spec=PublishMetadata, ), ) - assert self.formatter.format_guide(guide).split("\n\n") == [ - "\n## ๐Ÿฆ‰ lgtm Reviewer Guide", + assert self.formatter.format_guide(guide).split("\n") == [ + "", + "## ๐Ÿฆ‰ lgtm Reviewer Guide", + "", "### ๐Ÿ” Summary", + "", "summary", + "", "### ๐Ÿ”‘ Key Changes", - "| File Name | Description |\n| ---- | ---- |\n| foo.py | description |\n| bar.py | description |", + "", + "| File Name | Description |", + "| ---- | ---- |", + "| foo.py | description |", + "| bar.py | description |", + "", + "", "### โœ… Reviewer Checklist", + "", + "", "- [ ] item 1", + "", + "", + "", "### ๐Ÿ“š References", + "", "- [title](https://example.com)", + "", + "", + "", "
More information", - "- **Id**: `fb64cb958fcf49219545912156e0a4a0`\n- **Model**: `whatever`\n- **Created at**: `2025-05-15T09:43:01.654374+00:00`", - "\n
Usage summary", + "", + "- **Id**: `fb64cb958fcf49219545912156e0a4a0`", + "- **Model**: `whatever`", + "- **Created at**: `2025-05-15T09:43:01.654374+00:00`", + "", + "", + "
Usage summary", + "", + "", "
Call 1", - "- **Request count**: `1`\n" - "- **Request tokens**: `200`\n" - "- **Response tokens**: `100`\n" - "- **Total tokens**: `300`\n" + "", + "- **Request count**: `1`", + "- **Request tokens**: `200`", + "- **Response tokens**: `100`", + "- **Total tokens**: `300`", "
", - "**Total tokens**: `300`\n
", - "\n" + "", + "", + "**Total tokens**: `300`", + "
", + "", + "", "> See the [๐Ÿ“š lgtm-ai repository](https://github.com/elementsinteractive/lgtm-ai) for more information about lgtm.", - "
\n", + "", + "", + "", + "", + "", ]