Skip to content

Commit ed6e38f

Browse files
authored
Merge pull request #33 from naaa760/feat/repo-analysis-pr-creation
fix: follow up fixes for the repository analysis / automated PR creation
2 parents ab53254 + 513b5bd commit ed6e38f

5 files changed

Lines changed: 152 additions & 34 deletions

File tree

scripts/start-dev.sh

100644100755
File mode changed.

src/agents/repository_analysis_agent/nodes.py

Lines changed: 105 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,9 @@ async def analyze_contributing_guidelines(state: RepositoryAnalysisState) -> Non
9999
)
100100

101101

102-
def _get_language_specific_patterns(language: str | None) -> tuple[list[str], list[str]]:
102+
def _get_language_specific_patterns(
103+
language: str | None,
104+
) -> tuple[list[str], list[str]]:
103105
"""
104106
Get source and test patterns based on repository language.
105107
@@ -140,13 +142,70 @@ def _get_language_specific_patterns(language: str | None) -> tuple[list[str], li
140142
# Default fallback patterns for unknown languages
141143
return (
142144
["**/*.py", "**/*.ts", "**/*.tsx", "**/*.js", "**/*.go"],
143-
["**/tests/**", "**/*_test.py", "**/*.spec.ts", "**/*.test.js", "**/*.test.ts", "**/*.test.jsx"],
145+
[
146+
"**/tests/**",
147+
"**/*_test.py",
148+
"**/*.spec.ts",
149+
"**/*.test.js",
150+
"**/*.test.ts",
151+
"**/*.test.jsx",
152+
],
144153
)
145154

146155

147-
def _default_recommendations(state: RepositoryAnalysisState) -> list[RuleRecommendation]:
156+
def _analyze_pr_bad_habits(state: RepositoryAnalysisState) -> dict[str, Any]:
157+
"""
158+
Analyze PR history to detect bad habits and patterns.
159+
160+
Returns a dict with detected issues like:
161+
- missing_tests: PRs without test files (estimated based on changed_files)
162+
- short_titles: PRs with very short titles (< 10 characters)
163+
- no_reviews: PRs merged without reviews (always 0, as we can't determine this from list API)
164+
165+
Note: We can't analyze PR diffs/descriptions from the basic PR list API.
166+
This would require fetching individual PR details which is expensive.
167+
We analyze what we can from the PR list metadata.
168+
"""
169+
if not state.pr_samples:
170+
return {}
171+
172+
issues: dict[str, Any] = {
173+
"missing_tests": 0,
174+
"short_titles": 0,
175+
"no_reviews": 0,
176+
"total_analyzed": len(state.pr_samples),
177+
}
178+
179+
# Analyze PR titles for very short ones (likely missing context)
180+
# A title < 10 characters is likely too short to be meaningful
181+
short_title_threshold = 10
182+
for pr in state.pr_samples:
183+
if pr.title and len(pr.title.strip()) < short_title_threshold:
184+
issues["short_titles"] += 1
185+
186+
# Estimate missing tests: if PR has changed_files but no test-related patterns
187+
# This is a heuristic - we can't know for sure without fetching diffs
188+
# For now, we'll use a simple heuristic: if changed_files > 0 and title doesn't mention tests
189+
if pr.changed_files and pr.changed_files > 0:
190+
title_lower = (pr.title or "").lower()
191+
# If PR has code changes but title doesn't mention tests/test/tested/testing
192+
if not any(word in title_lower for word in ["test", "tests", "tested", "testing", "spec"]):
193+
# This is a weak signal, but we'll count it
194+
issues["missing_tests"] += 1
195+
196+
return issues
197+
198+
199+
def _default_recommendations(
200+
state: RepositoryAnalysisState,
201+
) -> list[RuleRecommendation]:
148202
"""
149-
Return a minimal, deterministic set of diff-aware rules.
203+
Return a minimal, deterministic set of diff-aware rules based on repository analysis.
204+
205+
Rules are generated based on:
206+
1. Repository language (for test patterns)
207+
2. PR history analysis (for bad habits)
208+
3. Contributing guidelines (if present)
150209
151210
Note: These recommendations use repository-specific patterns when available.
152211
For more advanced use cases like restricting specific authors from specific paths
@@ -161,30 +220,47 @@ def _default_recommendations(state: RepositoryAnalysisState) -> list[RuleRecomme
161220
# Get language-specific patterns based on repository analysis
162221
source_patterns, test_patterns = _get_language_specific_patterns(state.repository_features.language)
163222

223+
# Analyze PR history for bad habits
224+
pr_issues = _analyze_pr_bad_habits(state)
225+
164226
# Require tests when source code changes.
227+
# This is especially important if we detect missing tests in PR history
228+
test_reasoning = f"Default guardrail for code changes without tests. Patterns adapted for {state.repository_features.language or 'multi-language'} repository."
229+
if pr_issues.get("missing_tests", 0) > 0:
230+
test_reasoning += f" Detected {pr_issues['missing_tests']} recent PRs without test files."
231+
232+
# Build YAML rule with proper indentation
233+
# parameters: is at column 0, source_patterns: at column 2, list items at column 4
234+
source_patterns_yaml = "\n".join(f' - "{pattern}"' for pattern in source_patterns)
235+
test_patterns_yaml = "\n".join(f' - "{pattern}"' for pattern in test_patterns)
236+
237+
yaml_content = f"""description: "Require tests when code changes"
238+
enabled: true
239+
severity: medium
240+
event_types:
241+
- pull_request
242+
parameters:
243+
source_patterns:
244+
{source_patterns_yaml}
245+
test_patterns:
246+
{test_patterns_yaml}
247+
"""
248+
165249
recommendations.append(
166250
RuleRecommendation(
167-
yaml_rule=textwrap.dedent(
168-
f"""
169-
description: "Require tests when code changes"
170-
enabled: true
171-
severity: medium
172-
event_types:
173-
- pull_request
174-
parameters:
175-
source_patterns:
176-
{chr(10).join(f' - "{pattern}"' for pattern in source_patterns)}
177-
test_patterns:
178-
{chr(10).join(f' - "{pattern}"' for pattern in test_patterns)}
179-
"""
180-
).strip(),
181-
confidence=0.74,
182-
reasoning=f"Default guardrail for code changes without tests. Patterns adapted for {state.repository_features.language or 'multi-language'} repository.",
251+
yaml_rule=yaml_content.strip(),
252+
confidence=0.74 if pr_issues.get("missing_tests", 0) == 0 else 0.85,
253+
reasoning=test_reasoning,
183254
strategy_used="hybrid",
184255
)
185256
)
186257

187258
# Require description in PR body.
259+
# Increase confidence if we detect short titles in PR history (indicator of missing context)
260+
desc_reasoning = "Encourage context for reviewers; lightweight default."
261+
if pr_issues.get("short_titles", 0) > 0:
262+
desc_reasoning += f" Detected {pr_issues['short_titles']} PRs with very short titles (likely missing context)."
263+
188264
recommendations.append(
189265
RuleRecommendation(
190266
yaml_rule=textwrap.dedent(
@@ -198,15 +274,19 @@ def _default_recommendations(state: RepositoryAnalysisState) -> list[RuleRecomme
198274
min_description_length: 50
199275
"""
200276
).strip(),
201-
confidence=0.68,
202-
reasoning="Encourage context for reviewers; lightweight default.",
277+
confidence=0.68 if pr_issues.get("short_titles", 0) == 0 else 0.80,
278+
reasoning=desc_reasoning,
203279
strategy_used="static",
204280
)
205281
)
206282

207-
# If no CODEOWNERS, suggest one for shared ownership signals.
208-
# Note: This is informational only - we can't enforce CODEOWNERS creation via validators
209-
# but we can encourage it through the recommendation reasoning.
283+
# If contributing guidelines require tests, increase confidence
284+
if state.contributing_analysis.content is not None and state.contributing_analysis.requires_tests:
285+
# Find the test rule and boost its confidence
286+
for rec in recommendations:
287+
if "tests" in rec.yaml_rule.lower():
288+
rec.confidence = min(0.95, rec.confidence + 0.1)
289+
rec.reasoning += " Contributing guidelines explicitly require tests."
210290

211291
return recommendations
212292

src/api/recommendations.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -171,11 +171,16 @@ async def proceed_with_pr(request: ProceedWithPullRequestRequest) -> ProceedWith
171171
operation="proceed_with_pr",
172172
subject_ids=[repo],
173173
branch=request.branch_name,
174+
base_branch=base_branch,
174175
base_sha=base_sha,
175-
error="Failed to create branch",
176+
error="Failed to create branch - check logs for GitHub API error details",
176177
)
177178
raise HTTPException(
178-
status_code=400, detail=f"Failed to create branch '{request.branch_name}'. It may already exist."
179+
status_code=400,
180+
detail=(
181+
f"Failed to create branch '{request.branch_name}' from '{base_branch}'. "
182+
"The branch may already exist or you may not have permission to create branches."
183+
),
179184
)
180185

181186
file_result = await github_client.create_or_update_file(
@@ -194,9 +199,15 @@ async def proceed_with_pr(request: ProceedWithPullRequestRequest) -> ProceedWith
194199
subject_ids=[repo],
195200
branch=request.branch_name,
196201
file_path=request.file_path,
197-
error="Failed to create or update file",
202+
error="Failed to create or update file - check logs for GitHub API error details",
203+
)
204+
raise HTTPException(
205+
status_code=400,
206+
detail=(
207+
f"Failed to create or update file '{request.file_path}' on branch '{request.branch_name}'. "
208+
"Check server logs for detailed error information."
209+
),
198210
)
199-
raise HTTPException(status_code=400, detail="Failed to create or update rules file")
200211

201212
pr = await github_client.create_pull_request(
202213
repo_full_name=repo,
@@ -214,9 +225,16 @@ async def proceed_with_pr(request: ProceedWithPullRequestRequest) -> ProceedWith
214225
subject_ids=[repo],
215226
branch=request.branch_name,
216227
base_branch=base_branch,
217-
error="Failed to create pull request",
228+
pr_title=request.pr_title,
229+
error="Failed to create pull request - check logs for GitHub API error details",
230+
)
231+
raise HTTPException(
232+
status_code=400,
233+
detail=(
234+
f"Failed to create pull request from '{request.branch_name}' to '{base_branch}'. "
235+
"The PR may already exist, or you may not have permission to create PRs. Check server logs for details."
236+
),
218237
)
219-
raise HTTPException(status_code=400, detail="Failed to create pull request")
220238

221239
pr_url = pr.get("html_url", "")
222240
if not pr_url:

src/integrations/github/api.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -813,6 +813,7 @@ async def create_or_update_file(
813813
"""Create or update a file via the Contents API."""
814814
headers = await self._get_auth_headers(installation_id=installation_id, user_token=user_token)
815815
if not headers:
816+
logger.error(f"Failed to get auth headers for create_or_update_file in {repo_full_name}")
816817
return None
817818
url = f"{config.github.api_base_url}/repos/{repo_full_name}/contents/{path.lstrip('/')}"
818819
payload: dict[str, Any] = {
@@ -825,7 +826,14 @@ async def create_or_update_file(
825826
session = await self._get_session()
826827
async with session.put(url, headers=headers, json=payload) as response:
827828
if response.status in (200, 201):
828-
return await response.json()
829+
result = await response.json()
830+
logger.info(f"Successfully created/updated file {path} in {repo_full_name} on branch {branch}")
831+
return result
832+
error_text = await response.text()
833+
logger.error(
834+
f"Failed to create/update file {path} in {repo_full_name} on branch {branch}. "
835+
f"Status: {response.status}, Response: {error_text}"
836+
)
829837
return None
830838

831839
async def create_pull_request(
@@ -841,13 +849,25 @@ async def create_pull_request(
841849
"""Open a pull request."""
842850
headers = await self._get_auth_headers(installation_id=installation_id, user_token=user_token)
843851
if not headers:
852+
logger.error(f"Failed to get auth headers for create_pull_request in {repo_full_name}")
844853
return None
845854
url = f"{config.github.api_base_url}/repos/{repo_full_name}/pulls"
846855
payload = {"title": title, "head": head, "base": base, "body": body}
847856
session = await self._get_session()
848857
async with session.post(url, headers=headers, json=payload) as response:
849858
if response.status in (200, 201):
850-
return await response.json()
859+
result = await response.json()
860+
pr_number = result.get("number")
861+
pr_url = result.get("html_url", "")
862+
logger.info(
863+
f"Successfully created PR #{pr_number} in {repo_full_name}: {pr_url} (head: {head}, base: {base})"
864+
)
865+
return result
866+
error_text = await response.text()
867+
logger.error(
868+
f"Failed to create PR in {repo_full_name} (head: {head}, base: {base}). "
869+
f"Status: {response.status}, Response: {error_text}"
870+
)
851871
return None
852872

853873
async def _get_session(self) -> aiohttp.ClientSession:

tests/unit/api/test_proceed_with_pr.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ async def _fake_get_sha(repo_full_name, ref, installation_id=None, user_token=No
1313
return "base-sha"
1414

1515
async def _fake_create_ref(repo_full_name, ref, sha, installation_id=None, user_token=None):
16-
return True
16+
return {"ref": f"refs/heads/{ref}", "object": {"sha": sha}}
1717

1818
async def _fake_create_or_update_file(
1919
repo_full_name, path, content, message, branch, installation_id=None, user_token=None, sha=None

0 commit comments

Comments
 (0)