Skip to content

Commit d141d44

Browse files
authored
Merge pull request #436 from Mng-dev-ai/feat/ai-pr-description
AI-Generated PR Description Feature Request
2 parents 65317c9 + b24e530 commit d141d44

11 files changed

Lines changed: 266 additions & 24 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@
3737
- Narrow `except` clauses to specific exception types — never `except Exception` when the actual failure modes are known (e.g., `except (KeyError, SomeLibError)` not `except Exception`)
3838
- Do not translate exceptions across boundaries just to change the type — if an upstream function already raises a meaningful error, let it propagate; only catch-and-wrap when the caller genuinely needs a different status code or error shape that the original doesn't provide
3939
- Do not handle hypothetical input shapes — if you have evidence of the actual data format (logs, tests, type definitions), write code for that format only; do not add branches for types or structures you have not observed
40+
- Do not use Python's `str.format()` or f-strings to interpolate untrusted/user-provided content that may contain `{` or `}` characters (e.g., code diffs, source code snippets, JSON) — use string concatenation or `string.Template` instead
41+
- Add `Field(max_length=...)` to all `str` fields on Pydantic request models — bare `str` allows unbounded payloads; also add `min_length=1` when empty strings are invalid
42+
- Do not instantiate services directly in FastAPI route handlers — add a factory function in `deps.py` and inject via `Depends()`; route files should not import `SessionLocal`
4043
- Don't add comments or docstrings for self-explanatory code — but do add inline comments for non-obvious logic, implicit conventions, or design decisions that aren't clear from the code alone
4144
- Do not delete existing comments without asking first — they may capture context that isn't obvious from the code
4245
- Let the code speak for itself - use clear variable/function names instead of comments
@@ -213,7 +216,7 @@
213216
- Use CSS keyframe animations via Tailwind (`animate-fade-in`, `animate-fade-in-up`, `animate-dot-pulse`, etc.) for enter/state transitions — do not use `framer-motion` or other JS animation libraries
214217
- Use `transition-colors duration-200` for hover/focus, `transition-all duration-300` for complex state changes like drag-and-drop
215218
- Use `transition-[padding] duration-500 ease-in-out` for sidebar/layout animations
216-
- Loading states: `animate-spin` for spinners, `animate-pulse` for skeletons, `animate-bounce` with staggered `animationDelay` for dot loaders
219+
- Loading states: `animate-spin` for circular spinner icons only (e.g., `Loader2`), `animate-pulse` for non-circular icons used as loading indicators and skeletons, `animate-bounce` with staggered `animationDelay` for dot loaders
217220
- Expandable content: `transition-all duration-200` with `max-h-*` and `opacity` toggling
218221
- Dropdowns: `animate-fadeIn` for entry — no scale transforms on buttons
219222

backend/app/api/endpoints/github.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,24 @@
11
from typing import NoReturn
22

3-
from fastapi import APIRouter, Depends, HTTPException, Query
3+
from fastapi import APIRouter, Depends, HTTPException, Query, status
44

5-
from app.core.deps import get_github_service
5+
from app.core.deps import get_claude_agent_service, get_github_service
66
from app.core.security import get_current_user
77
from app.models.db_models.user import User
88
from app.models.schemas.github import (
99
CreatePullRequestRequest,
1010
CreatePullRequestResponse,
11+
GeneratePRDescriptionRequest,
12+
GeneratePRDescriptionResponse,
1113
GitHubCollaborator,
1214
GitHubPRCommentsResponse,
1315
GitHubPRListResponse,
1416
GitHubReposResponse,
1517
)
16-
from app.services.exceptions import GitHubException
18+
from app.services.claude_agent import ClaudeAgentService
19+
from app.services.exceptions import ClaudeAgentException, GitHubException
1720
from app.services.github import GitHubService
21+
from app.utils.validators import APIKeyValidationError
1822

1923
router = APIRouter()
2024

@@ -79,6 +83,32 @@ async def create_pull_request(
7983
_raise_from_github(exc)
8084

8185

86+
@router.post(
87+
"/generate-pr-description",
88+
response_model=GeneratePRDescriptionResponse,
89+
)
90+
async def generate_pr_description(
91+
request: GeneratePRDescriptionRequest,
92+
current_user: User = Depends(get_current_user),
93+
ai_service: ClaudeAgentService = Depends(get_claude_agent_service),
94+
) -> GeneratePRDescriptionResponse:
95+
try:
96+
description = await ai_service.generate_pr_description(
97+
request.title, request.diff, request.model_id, current_user
98+
)
99+
except APIKeyValidationError as e:
100+
raise HTTPException(
101+
status_code=status.HTTP_400_BAD_REQUEST,
102+
detail=str(e),
103+
) from e
104+
except ClaudeAgentException as e:
105+
raise HTTPException(
106+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
107+
detail=str(e),
108+
) from e
109+
return GeneratePRDescriptionResponse(description=description)
110+
111+
82112
@router.get("/collaborators")
83113
async def list_collaborators(
84114
owner: str = Query(..., min_length=1),

backend/app/core/deps.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from app.models.db_models.user import User
1313
from app.services.agent import AgentService
1414
from app.services.chat import ChatService
15+
from app.services.claude_agent import ClaudeAgentService
1516
from app.services.command import CommandService
1617
from app.services.exceptions import UserException
1718
from app.services.github import GitHubService
@@ -86,6 +87,10 @@ def get_github_service(
8687
return GitHubService(token=github_token)
8788

8889

90+
def get_claude_agent_service() -> ClaudeAgentService:
91+
return ClaudeAgentService(session_factory=SessionLocal)
92+
93+
8994
def get_marketplace_service() -> MarketplaceService:
9095
return MarketplaceService()
9196

backend/app/models/schemas/github.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from typing import Any
22

3-
from pydantic import BaseModel
3+
from pydantic import BaseModel, Field
44

55

66
class GitHubRepo(BaseModel):
@@ -70,3 +70,13 @@ class CreatePullRequestResponse(BaseModel):
7070
class GitHubCollaborator(BaseModel):
7171
login: str
7272
avatar_url: str
73+
74+
75+
class GeneratePRDescriptionRequest(BaseModel):
76+
title: str = Field(max_length=256)
77+
diff: str = Field(min_length=1, max_length=200_000)
78+
model_id: str = Field(max_length=128)
79+
80+
81+
class GeneratePRDescriptionResponse(BaseModel):
82+
description: str
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
GENERATE_PR_DESCRIPTION_SYSTEM_PROMPT = """You are a pull request description writer. Given a git diff and a PR title, produce a clear, well-structured PR description in GitHub-flavored Markdown.
2+
3+
Output ONLY the description with no preamble, explanation, or meta-commentary.
4+
5+
<guidelines>
6+
- Start with a concise summary (1-3 sentences) explaining what changed and why
7+
- Use a "## Changes" section with bullet points for notable changes
8+
- Group related changes logically (e.g., by feature area, file type)
9+
- Mention any breaking changes, new dependencies, or configuration changes if present
10+
- Keep it concise — focus on the "what" and "why", not line-by-line details
11+
- Do not include a test plan section unless the diff contains test files
12+
- Do not include generic boilerplate like "Please review" or "Let me know if you have questions"
13+
- If the diff is too large or unclear, summarize at a high level based on the file paths and change patterns
14+
</guidelines>"""
15+
16+
GENERATE_PR_DESCRIPTION_TITLE_PREFIX = "Title: "

backend/app/services/claude_agent.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@
2525
from app.models.db_models.user import User, UserSettings
2626
from app.models.schemas.settings import ProviderType
2727
from app.prompts.enhance_prompt import ENHANCE_PROMPT
28+
from app.prompts.generate_pr_description import (
29+
GENERATE_PR_DESCRIPTION_SYSTEM_PROMPT,
30+
GENERATE_PR_DESCRIPTION_TITLE_PREFIX,
31+
)
2832
from app.prompts.system_prompt import DEFAULT_PERSONA_NAME
2933
from app.prompts.generate_title import (
3034
GENERATE_TITLE_SYSTEM_PROMPT,
@@ -39,6 +43,7 @@
3943
from app.services.transports import SandboxTransport
4044
from app.services.transports.factory import create_sandbox_transport
4145
from app.services.user import UserService
46+
from app.utils.validators import validate_model_api_keys
4247

4348
settings = get_settings()
4449
logger = logging.getLogger(__name__)
@@ -331,6 +336,41 @@ async def generate_title(self, prompt: str, user: User) -> str | None:
331336
logger.debug("Title generation SDK call failed for user %s", user.id)
332337
return None
333338

339+
async def generate_pr_description(
340+
self, title: str, diff: str, model_id: str, user: User
341+
) -> str:
342+
user_settings = await UserService(
343+
session_factory=self.session_factory
344+
).get_user_settings(user.id)
345+
346+
validate_model_api_keys(user_settings, model_id)
347+
env, _, actual_model_id = self._build_auth_env(model_id, user_settings)
348+
349+
options = ClaudeAgentOptions(
350+
system_prompt=GENERATE_PR_DESCRIPTION_SYSTEM_PROMPT,
351+
permission_mode="default",
352+
model=actual_model_id,
353+
max_turns=1,
354+
env=env,
355+
)
356+
357+
try:
358+
description = ""
359+
async with ClaudeSDKClient(options=options) as client:
360+
user_message = (
361+
GENERATE_PR_DESCRIPTION_TITLE_PREFIX + title + "\n\n" + diff
362+
)
363+
await client.query(user_message)
364+
async for message in client.receive_response():
365+
if isinstance(message, ResultMessage) and message.result:
366+
description = message.result
367+
368+
if not description:
369+
raise ClaudeAgentException("AI returned an empty description")
370+
return description
371+
except ClaudeSDKError as e:
372+
raise ClaudeAgentException(f"Failed to generate PR description: {e}") from e
373+
334374
@staticmethod
335375
def _build_permission_server(
336376
permission_mode: str, chat_id: str, sandbox_provider: str = "docker"

0 commit comments

Comments
 (0)