Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
187 changes: 68 additions & 119 deletions src/lgtm_ai/formatters/markdown.py
Original file line number Diff line number Diff line change
@@ -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"""

<details><summary>More information about this comment</summary>

- **File**: `{comment.new_path}`
- **Line**: `{comment.line_number}`
- **Relative line**: `{comment.relative_line_number}`

</details>
""")
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"""
<details><summary>Usage summary</summary>
{"\n".join(formatted_usage_calls)}
**Total tokens**: `{sum([usage.total_tokens or 0 for usage in usages])}`
</details>
"""

def _format_usage_call_collapsible(self, usage: Usage, index: int) -> str:
return f"""
<details><summary>Call {index + 1}</summary>

- **Request count**: `{usage.requests}`
- **Request tokens**: `{usage.request_tokens}`
- **Response tokens**: `{usage.response_tokens}`
- **Total tokens**: `{usage.total_tokens}`
</details>
"""
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"""

<details><summary>More information</summary>

- **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.

</details>
""")
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,
)
14 changes: 14 additions & 0 deletions src/lgtm_ai/formatters/templates/metadata.md.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<details><summary>More information</summary>

- **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.

</details>

18 changes: 18 additions & 0 deletions src/lgtm_ai/formatters/templates/review_comment.md.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#### 🦉 {{ category }} {{ category_key }}

> **Severity:** {{ severity }} {{ severity_icon }}

{% if snippet %}
{{ snippet | safe }}

{% endif %}
{{ comment | safe }}

{% if with_footer %}
<details><summary>More information about this comment</summary>

- **File**: `{{ new_path }}`
- **Line**: `{{ line_number }}`
- **Relative line**: `{{ relative_line_number }}`
</details>
{% endif %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{% if comments %}**Specific Comments:**

{% for comment in comments %}- {{ comment | safe }}
{% endfor %}
{% endif %}
31 changes: 31 additions & 0 deletions src/lgtm_ai/formatters/templates/review_guide.md.j2
Original file line number Diff line number Diff line change
@@ -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 }}


14 changes: 14 additions & 0 deletions src/lgtm_ai/formatters/templates/review_summary.md.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@

## 🦉 lgtm Review

> **Score:** {{ score }} {{ score_icon }}

### 🔍 Summary

{{ summary | safe }}

{% if comments_section %}
{{ comments_section | safe}}
{% endif %}

{{ metadata | safe }}
3 changes: 3 additions & 0 deletions src/lgtm_ai/formatters/templates/snippet.md.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```{{ language }}
{{ snippet | safe }}
```
14 changes: 14 additions & 0 deletions src/lgtm_ai/formatters/templates/usages_summary.md.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<details><summary>Usage summary</summary>
{% for usage in usages %}

<details><summary>Call {{ loop.index }}</summary>

- **Request count**: `{{ usage.requests }}`
- **Request tokens**: `{{ usage.request_tokens }}`
- **Response tokens**: `{{ usage.response_tokens }}`
- **Total tokens**: `{{ usage.total_tokens }}`
</details>

{% endfor %}
**Total tokens**: `{{ total_tokens }}`
</details>
Loading