Skip to content

Commit 7ddc3f1

Browse files
authored
Automated agent workflow: quality gate, email notifications, docs cleanup (#77)
1 parent c2f46f2 commit 7ddc3f1

3 files changed

Lines changed: 164 additions & 13 deletions

File tree

.github/workflows/claude-bot.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ jobs:
114114
- env:
115115
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
116116
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
117+
NOTIFICATION_EMAIL: ${{ secrets.NOTIFICATION_EMAIL }}
118+
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
117119
COMMENT_BODY: ${{ github.event.comment.body }}
118120
ISSUE_NUMBER: ${{ github.event.issue.number }}
119121
REPO_FULL_NAME: ${{ github.repository }}

docs/agents/workflow.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,27 @@ One line per change. No implementation details. Match existing style.
8080
| `bot-analyzed` | Triage bot has processed this issue |
8181
| `ready-for-review` | Draft PR ready for human review |
8282

83+
## Email Notifications
84+
85+
When a draft PR is opened by the issue fixer, two notifications fire:
86+
87+
1. **GitHub native** — the repo owner is auto-assigned, which triggers GitHub's
88+
own notification email. No extra setup needed.
89+
90+
2. **Direct email** — for a dedicated email with PR link and test/lint status,
91+
add two repository secrets (`Settings → Secrets → Actions`):
92+
93+
| Secret | Value |
94+
|--------|-------|
95+
| `NOTIFICATION_EMAIL` | Address to send to (and from, if using Gmail) |
96+
| `SMTP_PASSWORD` | Gmail App Password (not your login password) |
97+
98+
Gmail App Password: `myaccount.google.com → Security → App passwords`.
99+
Defaults to `smtp.gmail.com:587`. Override with `SMTP_HOST`, `SMTP_PORT`,
100+
and `SMTP_FROM` secrets if using a different provider.
101+
102+
If the secrets are absent the step is silently skipped.
103+
83104
## Quality Gate
84105

85106
Before any PR: `./scripts/quality-check.sh` must pass with zero errors.

scripts/issue_fixer.py

Lines changed: 141 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@
1414
import argparse
1515
import os
1616
import re
17+
import smtplib
1718
import subprocess
1819
import sys
20+
from email.mime.text import MIMEText
1921
from pathlib import Path
2022

2123
import anthropic
@@ -114,6 +116,16 @@
114116
"required": [],
115117
},
116118
},
119+
{
120+
"name": "run_lint",
121+
"description": (
122+
"Run black (formatter) and ruff (linter) on the repository. "
123+
"black auto-fixes formatting; ruff --fix auto-fixes safe issues. "
124+
"Call this after run_tests passes. If ruff reports remaining errors, "
125+
"read the output and fix them manually."
126+
),
127+
"input_schema": {"type": "object", "properties": {}, "required": []},
128+
},
117129
]
118130

119131

@@ -193,15 +205,53 @@ def handle_tool(name: str, inputs: dict, repo_root: str, written_files: dict) ->
193205
cmd, cwd=repo_root, capture_output=True, text=True, timeout=120
194206
)
195207
output = result.stdout + result.stderr
196-
# Cap output to avoid flooding context
197208
if len(output) > 6000:
198209
output = output[-6000:] + "\n... (truncated, showing last 6000 chars)"
199210
status = "PASSED" if result.returncode == 0 else "FAILED"
200211
return f"Tests {status} (exit code {result.returncode}):\n\n{output}"
201212

213+
if name == "run_lint":
214+
return _run_lint(repo_root)
215+
202216
return f"ERROR: unknown tool: {name}"
203217

204218

219+
def _run_lint(repo_root: str) -> str:
220+
lines = []
221+
# black auto-formats in place
222+
r = subprocess.run(
223+
["black", "--quiet", "."], cwd=repo_root, capture_output=True, text=True
224+
)
225+
if r.stdout or r.stderr:
226+
lines.append(f"black:\n{(r.stdout + r.stderr).strip()}")
227+
else:
228+
lines.append("black: OK (no changes needed)")
229+
230+
# ruff --fix applies safe auto-fixes
231+
subprocess.run(
232+
["ruff", "check", "--fix", "--quiet", "."],
233+
cwd=repo_root,
234+
capture_output=True,
235+
text=True,
236+
)
237+
238+
# ruff check (no fix) reports what remains
239+
r2 = subprocess.run(
240+
["ruff", "check", "."], cwd=repo_root, capture_output=True, text=True
241+
)
242+
if r2.returncode == 0:
243+
lines.append("ruff: OK")
244+
else:
245+
output = (r2.stdout + r2.stderr).strip()
246+
if len(output) > 3000:
247+
output = output[:3000] + "\n... (truncated)"
248+
lines.append(f"ruff ERRORS (fix these):\n{output}")
249+
250+
passed = r2.returncode == 0
251+
status = "PASSED" if passed else "FAILED"
252+
return f"Lint {status}\n\n" + "\n\n".join(lines)
253+
254+
205255
# ---------------------------------------------------------------------------
206256
# Git helpers
207257
# ---------------------------------------------------------------------------
@@ -254,6 +304,41 @@ def slugify(text: str, max_len: int = 40) -> str:
254304
return text[:max_len].rstrip("-")
255305

256306

307+
def notify_email(
308+
to_addr: str,
309+
smtp_password: str,
310+
pr_url: str,
311+
issue_number: int,
312+
title: str,
313+
test_status: str,
314+
lint_status: str,
315+
) -> None:
316+
smtp_host = os.environ.get("SMTP_HOST", "smtp.gmail.com")
317+
smtp_port = int(os.environ.get("SMTP_PORT", "587"))
318+
from_addr = os.environ.get("SMTP_FROM", to_addr)
319+
320+
body = (
321+
f"Draft PR ready for your review\n\n"
322+
f"Issue #{issue_number}: {title}\n"
323+
f"Tests: {test_status}\n"
324+
f"Lint: {lint_status}\n\n"
325+
f"{pr_url}\n"
326+
)
327+
msg = MIMEText(body)
328+
msg["Subject"] = f"[bess-manager] PR ready: Fix #{issue_number}: {title}"
329+
msg["From"] = from_addr
330+
msg["To"] = to_addr
331+
332+
try:
333+
with smtplib.SMTP(smtp_host, smtp_port) as smtp:
334+
smtp.starttls()
335+
smtp.login(from_addr, smtp_password)
336+
smtp.sendmail(from_addr, to_addr, msg.as_string())
337+
print(f"Email notification sent to {to_addr}.")
338+
except Exception as e:
339+
print(f"Email notification failed (non-fatal): {e}")
340+
341+
257342
# ---------------------------------------------------------------------------
258343
# Main
259344
# ---------------------------------------------------------------------------
@@ -294,9 +379,9 @@ def main() -> None:
294379
"Your job is to implement a fix for a GitHub issue by reading the codebase,",
295380
"understanding the problem, and making targeted, minimal code changes.",
296381
"Use the available tools to explore and modify the repository.",
297-
"After writing all changes, call run_tests to verify nothing is broken.",
298-
"If tests fail, read the failing output, fix the code, and run tests again.",
299-
"When tests pass, stop calling tools and write a clear summary of what you",
382+
"After writing all changes: (1) call run_tests, fix any failures;",
383+
"(2) call run_lint, fix any ruff errors (black auto-formats for you).",
384+
"When both pass, stop calling tools and write a clear summary of what you",
300385
"changed and why, in Markdown.",
301386
]
302387
if rules_md:
@@ -328,7 +413,7 @@ def main() -> None:
328413
"Start by listing the top-level directory to understand the codebase structure.",
329414
"Read docs/agents/architecture.md for component overview and key file locations.",
330415
"Then read the relevant source files and implement the minimal fix.",
331-
"After writing changes, call run_tests. Fix any failures before finishing.",
416+
"After writing changes: call run_tests (fix failures), then call run_lint (fix ruff errors).",
332417
]
333418
)
334419

@@ -337,7 +422,8 @@ def main() -> None:
337422
messages = [{"role": "user", "content": user_message}]
338423
written_files: dict = {}
339424
summary = ""
340-
tests_passed: bool | None = None # None = not run, True/False = result
425+
tests_passed: bool | None = None
426+
lint_passed: bool | None = None
341427

342428
print("Starting agentic fix loop...")
343429
for iteration in range(20): # hard cap to prevent runaway loops
@@ -369,6 +455,8 @@ def main() -> None:
369455
)
370456
if block.name == "run_tests":
371457
tests_passed = "Tests PASSED" in str(result)
458+
if block.name == "run_lint":
459+
lint_passed = "Lint PASSED" in str(result)
372460
tool_results.append(
373461
{
374462
"type": "tool_result",
@@ -394,6 +482,14 @@ def main() -> None:
394482
)
395483
return
396484

485+
# Always auto-format before committing, even if the agent already ran lint.
486+
# This is the final gate — prevents any formatting drift from reaching the PR.
487+
print("Running pre-commit quality gate...")
488+
lint_result = _run_lint(repo_root)
489+
print(lint_result)
490+
if lint_passed is None:
491+
lint_passed = "Lint PASSED" in lint_result
492+
397493
# Commit the changes
398494
branch_name = f"fix/issue-{issue_number}-{slugify(issue.title)}"
399495
print(f"Committing {len(written_files)} file(s) to branch: {branch_name}")
@@ -413,18 +509,30 @@ def main() -> None:
413509
print("Branch pushed.")
414510

415511
# Open draft PR
416-
if tests_passed is True:
417-
test_status = "✅ Tests passed"
418-
elif tests_passed is False:
419-
test_status = "⚠️ Tests failed — review required"
420-
else:
421-
test_status = "⚪ Tests not run"
512+
test_status = (
513+
"✅ Tests passed"
514+
if tests_passed is True
515+
else (
516+
"⚠️ Tests failed — review required"
517+
if tests_passed is False
518+
else "⚪ Not run"
519+
)
520+
)
521+
lint_status = (
522+
"✅ Lint passed"
523+
if lint_passed is True
524+
else (
525+
"⚠️ Lint errors remain — review required"
526+
if lint_passed is False
527+
else "⚪ Not run"
528+
)
529+
)
422530

423531
pr_body = "\n".join(
424532
[
425533
f"Fixes #{issue_number}",
426534
"",
427-
f"**Test status**: {test_status}",
535+
f"**Tests**: {test_status} | **Lint**: {lint_status}",
428536
"",
429537
"## Changes",
430538
summary or "See commits for details.",
@@ -446,6 +554,26 @@ def main() -> None:
446554
)
447555
print(f"Draft PR created: {pr.html_url}")
448556

557+
# Assign the repo owner so GitHub sends a native notification email.
558+
try:
559+
pr.add_to_assignees(gh_repo.owner.login)
560+
except Exception as e:
561+
print(f"Could not assign PR (non-fatal): {e}")
562+
563+
# Optional email notification.
564+
notification_email = os.environ.get("NOTIFICATION_EMAIL", "")
565+
smtp_password = os.environ.get("SMTP_PASSWORD", "")
566+
if notification_email and smtp_password:
567+
notify_email(
568+
notification_email,
569+
smtp_password,
570+
pr.html_url,
571+
issue_number,
572+
issue.title,
573+
test_status,
574+
lint_status,
575+
)
576+
449577
# Post a link on the issue
450578
issue.create_comment(
451579
f"I've implemented a fix and opened a draft PR: {pr.html_url}\n\n"

0 commit comments

Comments
 (0)