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`",
- "\nUsage 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",
+ "",
+ "",
+ "",
+ "",
+ "",
]