1414import argparse
1515import os
1616import re
17+ import smtplib
1718import subprocess
1819import sys
20+ from email .mime .text import MIMEText
1921from pathlib import Path
2022
2123import anthropic
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