jleechanorg-pr-automation 0.1.1__py3-none-any.whl → 0.2.45__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. jleechanorg_pr_automation/STORAGE_STATE_TESTING_PROTOCOL.md +326 -0
  2. jleechanorg_pr_automation/__init__.py +64 -9
  3. jleechanorg_pr_automation/automation_safety_manager.py +306 -95
  4. jleechanorg_pr_automation/automation_safety_wrapper.py +13 -19
  5. jleechanorg_pr_automation/automation_utils.py +87 -65
  6. jleechanorg_pr_automation/check_codex_comment.py +7 -1
  7. jleechanorg_pr_automation/codex_branch_updater.py +21 -9
  8. jleechanorg_pr_automation/codex_config.py +70 -3
  9. jleechanorg_pr_automation/jleechanorg_pr_monitor.py +1954 -234
  10. jleechanorg_pr_automation/logging_utils.py +86 -0
  11. jleechanorg_pr_automation/openai_automation/__init__.py +3 -0
  12. jleechanorg_pr_automation/openai_automation/codex_github_mentions.py +1111 -0
  13. jleechanorg_pr_automation/openai_automation/debug_page_content.py +88 -0
  14. jleechanorg_pr_automation/openai_automation/oracle_cli.py +364 -0
  15. jleechanorg_pr_automation/openai_automation/test_auth_restoration.py +244 -0
  16. jleechanorg_pr_automation/openai_automation/test_codex_comprehensive.py +355 -0
  17. jleechanorg_pr_automation/openai_automation/test_codex_integration.py +254 -0
  18. jleechanorg_pr_automation/orchestrated_pr_runner.py +516 -0
  19. jleechanorg_pr_automation/tests/__init__.py +0 -0
  20. jleechanorg_pr_automation/tests/test_actionable_counting_matrix.py +84 -86
  21. jleechanorg_pr_automation/tests/test_attempt_limit_logic.py +124 -0
  22. jleechanorg_pr_automation/tests/test_automation_marker_functions.py +175 -0
  23. jleechanorg_pr_automation/tests/test_automation_over_running_reproduction.py +9 -11
  24. jleechanorg_pr_automation/tests/test_automation_safety_limits.py +91 -79
  25. jleechanorg_pr_automation/tests/test_automation_safety_manager_comprehensive.py +53 -53
  26. jleechanorg_pr_automation/tests/test_codex_actor_matching.py +1 -1
  27. jleechanorg_pr_automation/tests/test_fixpr_prompt.py +54 -0
  28. jleechanorg_pr_automation/tests/test_fixpr_return_value.py +140 -0
  29. jleechanorg_pr_automation/tests/test_graphql_error_handling.py +26 -26
  30. jleechanorg_pr_automation/tests/test_model_parameter.py +317 -0
  31. jleechanorg_pr_automation/tests/test_orchestrated_pr_runner.py +697 -0
  32. jleechanorg_pr_automation/tests/test_packaging_integration.py +127 -0
  33. jleechanorg_pr_automation/tests/test_pr_filtering_matrix.py +246 -193
  34. jleechanorg_pr_automation/tests/test_pr_monitor_eligibility.py +354 -0
  35. jleechanorg_pr_automation/tests/test_pr_targeting.py +102 -7
  36. jleechanorg_pr_automation/tests/test_version_consistency.py +51 -0
  37. jleechanorg_pr_automation/tests/test_workflow_specific_limits.py +202 -0
  38. jleechanorg_pr_automation/tests/test_workspace_dispatch_missing_dir.py +119 -0
  39. jleechanorg_pr_automation/utils.py +81 -56
  40. jleechanorg_pr_automation-0.2.45.dist-info/METADATA +864 -0
  41. jleechanorg_pr_automation-0.2.45.dist-info/RECORD +45 -0
  42. jleechanorg_pr_automation-0.1.1.dist-info/METADATA +0 -222
  43. jleechanorg_pr_automation-0.1.1.dist-info/RECORD +0 -23
  44. {jleechanorg_pr_automation-0.1.1.dist-info → jleechanorg_pr_automation-0.2.45.dist-info}/WHEEL +0 -0
  45. {jleechanorg_pr_automation-0.1.1.dist-info → jleechanorg_pr_automation-0.2.45.dist-info}/entry_points.txt +0 -0
  46. {jleechanorg_pr_automation-0.1.1.dist-info → jleechanorg_pr_automation-0.2.45.dist-info}/top_level.txt +0 -0
@@ -3,33 +3,27 @@
3
3
  Automation Safety Wrapper for launchd
4
4
 
5
5
  This wrapper enforces safety limits before running PR automation:
6
- - Max 5 attempts per PR
6
+ - Max 10 attempts per PR
7
7
  - Max 50 total automation runs before requiring manual approval
8
8
  - Email notifications when limits are reached
9
9
  """
10
10
 
11
- import sys
11
+ import logging
12
12
  import os
13
13
  import subprocess
14
- import logging
14
+ import sys
15
15
  from pathlib import Path
16
+
16
17
  from .automation_safety_manager import AutomationSafetyManager
18
+ from .logging_utils import setup_logging as _setup_logging
17
19
 
18
20
 
19
21
  def setup_logging() -> logging.Logger:
20
- """Set up logging for automation wrapper"""
22
+ """Set up logging delegated to centralized logging_utils"""
21
23
  log_dir = Path.home() / "Library" / "Logs" / "worldarchitect-automation"
22
- log_dir.mkdir(parents=True, exist_ok=True)
24
+ log_file = log_dir / "automation_safety.log"
23
25
 
24
- logging.basicConfig(
25
- level=logging.INFO,
26
- format='%(asctime)s - %(levelname)s - %(message)s',
27
- handlers=[
28
- logging.FileHandler(log_dir / "automation_safety.log"),
29
- logging.StreamHandler()
30
- ]
31
- )
32
- return logging.getLogger(__name__)
26
+ return _setup_logging(__name__, log_file=str(log_file))
33
27
 
34
28
 
35
29
  def main() -> int:
@@ -66,8 +60,8 @@ def main() -> int:
66
60
 
67
61
  # Execute with environment variables for safety integration
68
62
  env = os.environ.copy()
69
- env['AUTOMATION_SAFETY_DATA_DIR'] = str(data_dir)
70
- env['AUTOMATION_SAFETY_WRAPPER'] = '1'
63
+ env["AUTOMATION_SAFETY_DATA_DIR"] = str(data_dir)
64
+ env["AUTOMATION_SAFETY_WRAPPER"] = "1"
71
65
 
72
66
  # Record the global run *before* launching the monitor so the attempt
73
67
  # is counted even if the subprocess fails to start or exits early.
@@ -78,8 +72,8 @@ def main() -> int:
78
72
  )
79
73
 
80
74
  result = subprocess.run(
81
- [sys.executable, '-m', 'jleechanorg_pr_automation.jleechanorg_pr_monitor'],
82
- env=env,
75
+ [sys.executable, "-m", "jleechanorg_pr_automation.jleechanorg_pr_monitor"],
76
+ check=False, env=env,
83
77
  capture_output=True,
84
78
  text=True,
85
79
  timeout=3600,
@@ -112,5 +106,5 @@ def main() -> int:
112
106
  manager.check_and_notify_limits()
113
107
 
114
108
 
115
- if __name__ == '__main__':
109
+ if __name__ == "__main__":
116
110
  sys.exit(main())
@@ -10,20 +10,21 @@ Consolidates common functionality used across automation components:
10
10
  - File and directory management utilities
11
11
  """
12
12
 
13
- import os
14
- import sys
13
+ import fcntl
15
14
  import json
16
15
  import logging
16
+ import os
17
17
  import smtplib
18
- import tempfile
19
- import threading
20
- import fcntl
21
18
  import subprocess
19
+ import tempfile
20
+ import time
22
21
  from datetime import datetime
23
- from pathlib import Path
24
- from typing import Dict, Optional, Tuple, Any
25
- from email.mime.text import MIMEText
26
22
  from email.mime.multipart import MIMEMultipart
23
+ from email.mime.text import MIMEText
24
+ from pathlib import Path
25
+ from typing import Any, Dict, Optional, Sequence, Tuple
26
+
27
+ from .logging_utils import setup_logging as _setup_logging
27
28
 
28
29
  try:
29
30
  import keyring
@@ -37,17 +38,17 @@ class AutomationUtils:
37
38
 
38
39
  # Default configuration
39
40
  DEFAULT_CONFIG = {
40
- 'SMTP_SERVER': 'smtp.gmail.com',
41
- 'SMTP_PORT': 587,
42
- 'LOG_DIR': '~/Library/Logs/worldarchitect-automation',
43
- 'DATA_DIR': '~/Library/Application Support/worldarchitect-automation',
44
- 'MAX_SUBPROCESS_TIMEOUT': int(os.getenv('AUTOMATION_SUBPROCESS_TIMEOUT', '300')), # 5 minutes (configurable)
45
- 'EMAIL_SUBJECT_PREFIX': '[WorldArchitect Automation]'
41
+ "SMTP_SERVER": "smtp.gmail.com",
42
+ "SMTP_PORT": 587,
43
+ "LOG_DIR": "~/Library/Logs/worldarchitect-automation",
44
+ "DATA_DIR": "~/Library/Application Support/worldarchitect-automation",
45
+ "MAX_SUBPROCESS_TIMEOUT": int(os.getenv("AUTOMATION_SUBPROCESS_TIMEOUT", "300")), # 5 minutes (configurable)
46
+ "EMAIL_SUBJECT_PREFIX": "[WorldArchitect Automation]"
46
47
  }
47
48
 
48
49
  @classmethod
49
50
  def setup_logging(cls, name: str, log_filename: str = None) -> logging.Logger:
50
- """Unified logging setup for all automation components
51
+ """Unified logging setup delegated to centralized logging_utils.
51
52
 
52
53
  Args:
53
54
  name: Logger name (typically __name__)
@@ -56,25 +57,17 @@ class AutomationUtils:
56
57
  Returns:
57
58
  Configured logger instance
58
59
  """
60
+ # Use centralized logging with DEFAULT_CONFIG log directory
61
+ log_dir = Path(cls.DEFAULT_CONFIG["LOG_DIR"]).expanduser()
62
+
63
+ # If no filename specified, create one from module name
59
64
  if log_filename is None:
60
- log_filename = f"{name.split('.')[-1]}.log"
61
-
62
- # Create log directory
63
- log_dir = Path(cls.DEFAULT_CONFIG['LOG_DIR']).expanduser()
64
- log_dir.mkdir(parents=True, exist_ok=True)
65
-
66
- # Set up logging with consistent format
67
- logging.basicConfig(
68
- level=logging.INFO,
69
- format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
70
- handlers=[
71
- logging.FileHandler(log_dir / log_filename),
72
- logging.StreamHandler()
73
- ]
74
- )
75
-
76
- logger = logging.getLogger(name)
77
- logger.info(f"🛠️ Logging initialized - logs: {log_dir / log_filename}")
65
+ log_file = log_dir / f"{name.split('.')[-1]}.log"
66
+ else:
67
+ log_file = log_dir / log_filename if not Path(log_filename).is_absolute() else log_filename
68
+
69
+ logger = _setup_logging(name, log_file=str(log_file))
70
+ logger.info(f"🛠️ Logging initialized - logs: {log_file}")
78
71
  return logger
79
72
 
80
73
  @classmethod
@@ -84,8 +77,8 @@ class AutomationUtils:
84
77
  if env_value is not None:
85
78
  # Try to convert to appropriate type
86
79
  if isinstance(default, bool):
87
- return env_value.lower() in ('true', '1', 'yes', 'on')
88
- elif isinstance(default, int):
80
+ return env_value.lower() in ("true", "1", "yes", "on")
81
+ if isinstance(default, int):
89
82
  try:
90
83
  return int(env_value)
91
84
  except ValueError:
@@ -96,7 +89,7 @@ class AutomationUtils:
96
89
  @classmethod
97
90
  def get_data_directory(cls, subdir: str = None) -> Path:
98
91
  """Get standardized data directory path"""
99
- data_dir = Path(cls.DEFAULT_CONFIG['DATA_DIR']).expanduser()
92
+ data_dir = Path(cls.DEFAULT_CONFIG["DATA_DIR"]).expanduser()
100
93
  if subdir:
101
94
  data_dir = data_dir / subdir
102
95
  data_dir.mkdir(parents=True, exist_ok=True)
@@ -117,9 +110,9 @@ class AutomationUtils:
117
110
 
118
111
  # Fallback to environment variables if keyring fails or unavailable
119
112
  if not username:
120
- username = os.environ.get('SMTP_USERNAME')
113
+ username = os.environ.get("SMTP_USERNAME")
121
114
  if not password:
122
- password = os.environ.get('SMTP_PASSWORD')
115
+ password = os.environ.get("SMTP_PASSWORD")
123
116
 
124
117
  return username, password
125
118
 
@@ -139,13 +132,13 @@ class AutomationUtils:
139
132
  """
140
133
  try:
141
134
  # Get SMTP configuration
142
- smtp_server = cls.get_config_value('SMTP_SERVER')
143
- smtp_port = cls.get_config_value('SMTP_PORT')
135
+ smtp_server = cls.get_config_value("SMTP_SERVER")
136
+ smtp_port = cls.get_config_value("SMTP_PORT")
144
137
  username, password = cls.get_smtp_credentials()
145
138
 
146
139
  # Get email addresses
147
- from_email = from_email or os.environ.get('MEMORY_EMAIL_FROM')
148
- to_email = to_email or os.environ.get('MEMORY_EMAIL_TO')
140
+ from_email = from_email or os.environ.get("MEMORY_EMAIL_FROM")
141
+ to_email = to_email or os.environ.get("MEMORY_EMAIL_TO")
149
142
 
150
143
  if not all([username, password, from_email, to_email]):
151
144
  print("Email configuration incomplete - skipping notification")
@@ -153,9 +146,9 @@ class AutomationUtils:
153
146
 
154
147
  # Build email message
155
148
  msg = MIMEMultipart()
156
- msg['From'] = from_email
157
- msg['To'] = to_email
158
- msg['Subject'] = f"{cls.DEFAULT_CONFIG['EMAIL_SUBJECT_PREFIX']} {subject}"
149
+ msg["From"] = from_email
150
+ msg["To"] = to_email
151
+ msg["Subject"] = f"{cls.DEFAULT_CONFIG['EMAIL_SUBJECT_PREFIX']} {subject}"
159
152
 
160
153
  # Add timestamp to message
161
154
  full_message = f"""{message}
@@ -165,7 +158,7 @@ System: WorldArchitect Automation
165
158
 
166
159
  This is an automated notification from the WorldArchitect.AI automation system."""
167
160
 
168
- msg.attach(MIMEText(full_message, 'plain'))
161
+ msg.attach(MIMEText(full_message, "plain"))
169
162
 
170
163
  # Send email with timeout and proper error handling
171
164
  with smtplib.SMTP(smtp_server, smtp_port, timeout=30) as server:
@@ -192,7 +185,11 @@ This is an automated notification from the WorldArchitect.AI automation system."
192
185
  @classmethod
193
186
  def execute_subprocess_with_timeout(cls, command: list, timeout: int = None,
194
187
  cwd: str = None, capture_output: bool = True,
195
- check: bool = True) -> subprocess.CompletedProcess:
188
+ check: bool = True,
189
+ retry_attempts: int = 1,
190
+ retry_backoff_seconds: float = 1.0,
191
+ retry_backoff_multiplier: float = 2.0,
192
+ retry_on_stderr_substrings: Optional[Sequence[str]] = None) -> subprocess.CompletedProcess:
196
193
  """Execute subprocess with standardized timeout and error handling
197
194
 
198
195
  Args:
@@ -201,6 +198,10 @@ This is an automated notification from the WorldArchitect.AI automation system."
201
198
  cwd: Working directory
202
199
  capture_output: Whether to capture stdout/stderr
203
200
  check: Whether to raise CalledProcessError on non-zero exit (default True)
201
+ retry_attempts: Total attempts for retryable failures (default 1 = no retries)
202
+ retry_backoff_seconds: Initial backoff between retries
203
+ retry_backoff_multiplier: Backoff multiplier per attempt
204
+ retry_on_stderr_substrings: If provided, only retry when stderr/stdout contains one of these substrings
204
205
 
205
206
  Returns:
206
207
  CompletedProcess instance
@@ -210,26 +211,47 @@ This is an automated notification from the WorldArchitect.AI automation system."
210
211
  subprocess.CalledProcessError: If command fails and check=True
211
212
  """
212
213
  if timeout is None:
213
- timeout = cls.get_config_value('MAX_SUBPROCESS_TIMEOUT')
214
+ timeout = cls.get_config_value("MAX_SUBPROCESS_TIMEOUT")
215
+
216
+ try:
217
+ retry_attempts_int = int(retry_attempts)
218
+ except (TypeError, ValueError):
219
+ retry_attempts_int = 1
220
+ if retry_attempts_int < 1:
221
+ retry_attempts_int = 1
222
+
223
+ attempt = 1
224
+ while True:
225
+ try:
226
+ # Ensure shell=False for security, check parameter controls error handling
227
+ return subprocess.run(
228
+ command,
229
+ timeout=timeout,
230
+ cwd=cwd,
231
+ capture_output=capture_output,
232
+ text=True,
233
+ shell=False,
234
+ check=check,
235
+ )
236
+ except subprocess.CalledProcessError as exc:
237
+ if attempt >= retry_attempts_int:
238
+ raise
214
239
 
215
- # Ensure shell=False for security, check parameter controls error handling
216
- result = subprocess.run(
217
- command,
218
- timeout=timeout,
219
- cwd=cwd,
220
- capture_output=capture_output,
221
- text=True,
222
- shell=False,
223
- check=check
224
- )
240
+ if retry_on_stderr_substrings:
241
+ stderr = (exc.stderr or "") + "\n" + (exc.stdout or "")
242
+ if not any(token in stderr for token in retry_on_stderr_substrings):
243
+ raise
225
244
 
226
- return result
245
+ delay = float(retry_backoff_seconds) * (float(retry_backoff_multiplier) ** (attempt - 1))
246
+ delay = max(0.0, min(delay, 60.0))
247
+ time.sleep(delay)
248
+ attempt += 1
227
249
 
228
250
  @classmethod
229
251
  def safe_read_json(cls, file_path: Path) -> dict:
230
252
  """Safely read JSON file with file locking"""
231
253
  try:
232
- with open(file_path, 'r') as f:
254
+ with open(file_path) as f:
233
255
  fcntl.flock(f.fileno(), fcntl.LOCK_SH) # Shared lock for reading
234
256
  data = json.load(f)
235
257
  fcntl.flock(f.fileno(), fcntl.LOCK_UN) # Unlock
@@ -245,7 +267,7 @@ This is an automated notification from the WorldArchitect.AI automation system."
245
267
  try:
246
268
  # Create temporary file securely
247
269
  with tempfile.NamedTemporaryFile(
248
- mode='w',
270
+ mode="w",
249
271
  suffix=".tmp",
250
272
  delete=False
251
273
  ) as temp_file:
@@ -263,7 +285,7 @@ This is an automated notification from the WorldArchitect.AI automation system."
263
285
  os.rename(temp_path, file_path)
264
286
  temp_path = None # Successful, don't clean up
265
287
 
266
- except (OSError, IOError, json.JSONEncodeError) as e:
288
+ except (OSError, json.JSONEncodeError) as e:
267
289
  # Clean up temp file on error
268
290
  if temp_path and os.path.exists(temp_path):
269
291
  try:
@@ -282,13 +304,13 @@ This is an automated notification from the WorldArchitect.AI automation system."
282
304
  try:
283
305
  # Source the bash config file by running it and capturing environment
284
306
  result = cls.execute_subprocess_with_timeout(
285
- ['bash', '-c', f'source {config_file} && env'],
307
+ ["bash", "-c", f"source {config_file} && env"],
286
308
  timeout=10
287
309
  )
288
310
 
289
311
  for line in result.stdout.splitlines():
290
- if '=' in line:
291
- key, value = line.split('=', 1)
312
+ if "=" in line:
313
+ key, value = line.split("=", 1)
292
314
  config[key] = value
293
315
 
294
316
  except Exception as e:
@@ -55,7 +55,13 @@ def decide(marker_prefix: str, marker_suffix: str) -> Tuple[str, str]:
55
55
  if end_index == -1:
56
56
  continue
57
57
 
58
- marker_sha = body[start_index:end_index].strip()
58
+ marker_content = body[start_index:end_index].strip()
59
+ # Handle new format: SHA:cli -> extract just SHA
60
+ # Also handles old format: SHA (no colon)
61
+ if ":" in marker_content:
62
+ marker_sha = marker_content.split(":")[0]
63
+ else:
64
+ marker_sha = marker_content
59
65
  if marker_sha == head_sha:
60
66
  return "skip", head_sha
61
67
 
@@ -13,18 +13,22 @@ from typing import Dict, Tuple
13
13
  from playwright.async_api import (
14
14
  Browser,
15
15
  BrowserContext,
16
- Error as PlaywrightError,
17
16
  Page,
18
- TimeoutError as PlaywrightTimeoutError,
19
17
  async_playwright,
20
18
  )
19
+ from playwright.async_api import (
20
+ Error as PlaywrightError,
21
+ )
22
+ from playwright.async_api import (
23
+ TimeoutError as PlaywrightTimeoutError,
24
+ )
21
25
 
22
26
  CHATGPT_CODEX_URL = "https://chatgpt.com/codex"
23
27
  CREDENTIALS_PATH = Path.home() / ".chatgpt_codex_credentials.json"
24
28
  AUTH_STATE_PATH = Path.home() / ".chatgpt_codex_auth_state.json"
25
- TASK_CARD_SELECTOR = "[data-testid=\"codex-task-card\"]"
26
- UPDATE_BRANCH_BUTTON_SELECTOR = "button:has-text(\"Update Branch\")"
27
- TASK_TITLE_SELECTOR = "[data-testid=\"codex-task-title\"]"
29
+ TASK_CARD_SELECTOR = '[data-testid="codex-task-card"]'
30
+ UPDATE_BRANCH_BUTTON_SELECTOR = 'button:has-text("Update Branch")'
31
+ TASK_TITLE_SELECTOR = '[data-testid="codex-task-title"]'
28
32
 
29
33
 
30
34
  def _save_credentials(credentials: Dict[str, str]) -> None:
@@ -76,14 +80,16 @@ def get_credentials() -> Dict[str, str]:
76
80
  return credentials
77
81
 
78
82
 
79
- async def ensure_logged_in(page: Page, credentials: Dict[str, str] | None = None) -> None:
80
- """Log into ChatGPT Codex if required."""
83
+ async def ensure_logged_in(page: Page, context: BrowserContext, credentials: Dict[str, str] | None = None) -> None:
84
+ """Log into ChatGPT Codex if required and save auth state immediately."""
81
85
 
82
86
  await page.goto(CHATGPT_CODEX_URL, wait_until="domcontentloaded")
83
87
 
84
88
  if await is_task_list_visible(page):
89
+ print("✅ Session still valid (task list visible).")
85
90
  return
86
91
 
92
+ print("⚠️ Session expired. Re-authenticating...")
87
93
  if credentials is None:
88
94
  credentials = get_credentials()
89
95
 
@@ -95,6 +101,9 @@ async def ensure_logged_in(page: Page, credentials: Dict[str, str] | None = None
95
101
 
96
102
  await _complete_login_flow(page, credentials)
97
103
 
104
+ await context.storage_state(path=str(AUTH_STATE_PATH))
105
+ print(f"💾 New authentication state saved immediately to {AUTH_STATE_PATH}.")
106
+
98
107
 
99
108
  async def _complete_login_flow(page: Page, credentials: Dict[str, str]) -> None:
100
109
  """Fill in the login flow using the provided credentials."""
@@ -244,16 +253,19 @@ async def run() -> None:
244
253
  browser = await playwright.chromium.launch(headless=False)
245
254
  context_kwargs = {}
246
255
  if AUTH_STATE_PATH.exists():
256
+ print(f"🔄 Loading saved authentication state from {AUTH_STATE_PATH}")
247
257
  context_kwargs["storage_state"] = str(AUTH_STATE_PATH)
258
+ else:
259
+ print("ℹ️ No saved authentication state found. Fresh login required.")
248
260
  context = await browser.new_context(**context_kwargs)
249
261
  page = await context.new_page()
250
262
 
251
- await ensure_logged_in(page)
263
+ await ensure_logged_in(page, context)
252
264
  await wait_for_task_list(page)
253
265
  await process_tasks(page)
254
266
 
255
267
  await context.storage_state(path=str(AUTH_STATE_PATH))
256
- print(f"💾 Authentication state saved to {AUTH_STATE_PATH}.")
268
+ print(f"💾 Final authentication state saved to {AUTH_STATE_PATH}.")
257
269
  finally:
258
270
  if context is not None:
259
271
  await context.close()
@@ -4,7 +4,6 @@ from __future__ import annotations
4
4
 
5
5
  from dataclasses import dataclass
6
6
 
7
-
8
7
  DEFAULT_ASSISTANT_HANDLE = "coderabbitai"
9
8
 
10
9
 
@@ -25,13 +24,81 @@ CODEX_COMMENT_INTRO_BODY = (
25
24
  CODEX_COMMENT_TEMPLATE = (
26
25
  "{comment_intro}\n\n"
27
26
  "Use your judgment to fix comments from everyone or explain why it should not be fixed. "
28
- "Follow binary response protocol - every comment needs \"DONE\" or \"NOT DONE\" classification "
27
+ 'Follow binary response protocol - every comment needs "DONE" or "NOT DONE" classification '
29
28
  "explicitly with an explanation. Address all comments on this PR. Fix any failing tests and "
30
29
  "resolve merge conflicts. Push any commits needed to remote so the PR is updated."
31
30
  )
32
31
 
33
32
  CODEX_COMMIT_MARKER_PREFIX = "<!-- codex-automation-commit:"
34
33
  CODEX_COMMIT_MARKER_SUFFIX = "-->"
34
+ FIX_COMMENT_MARKER_PREFIX = "<!-- fix-comment-automation-commit:"
35
+ FIX_COMMENT_MARKER_SUFFIX = "-->"
36
+ # Updated to match new format from build_automation_marker()
37
+ FIX_COMMENT_RUN_MARKER_PREFIX = "<!-- fix-comment-run-automation-commit:"
38
+ FIX_COMMENT_RUN_MARKER_SUFFIX = "-->"
39
+ # Updated to match new format from build_automation_marker()
40
+ FIXPR_MARKER_PREFIX = "<!-- fixpr-run-automation-commit:"
41
+ FIXPR_MARKER_SUFFIX = "-->"
42
+
43
+
44
+ def build_automation_marker(workflow: str, agent: str, commit_sha: str) -> str:
45
+ """Build enhanced automation marker with workflow, agent, and commit info.
46
+
47
+ Args:
48
+ workflow: Workflow type (e.g., 'fix-comment-run', 'fixpr-run', 'codex')
49
+ agent: Agent/CLI name (e.g., 'gemini', 'codex', 'claude')
50
+ commit_sha: Git commit SHA
51
+
52
+ Returns:
53
+ HTML comment marker with format: <!-- workflow-automation-commit:agent:sha -->
54
+
55
+ Example:
56
+ >>> build_automation_marker('fix-comment-run', 'gemini', 'abc123')
57
+ '<!-- fix-comment-run-automation-commit:gemini:abc123-->'
58
+ """
59
+ return f"<!-- {workflow}-automation-commit:{agent}:{commit_sha}-->"
60
+
61
+
62
+ def parse_automation_marker(marker: str) -> dict[str, str] | None:
63
+ """Parse automation marker to extract workflow, agent, and commit.
64
+
65
+ Args:
66
+ marker: Automation marker string
67
+
68
+ Returns:
69
+ Dict with 'workflow', 'agent', 'commit' keys, or None if invalid
70
+
71
+ Example:
72
+ >>> parse_automation_marker('<!-- fix-comment-automation-commit:gemini:abc123-->')
73
+ {'workflow': 'fix-comment', 'agent': 'gemini', 'commit': 'abc123'}
74
+ """
75
+ if not marker.startswith('<!--') or not marker.endswith('-->'):
76
+ return None
77
+
78
+ # Remove HTML comment markers (slice off "<!--" and "-->")
79
+ content = marker[4:-3].strip()
80
+
81
+ # Try new format first: workflow-automation-commit:agent:sha
82
+ if '-automation-commit:' in content and content.count(':') == 2:
83
+ parts = content.split(':')
84
+ workflow = parts[0].replace('-automation-commit', '')
85
+ return {
86
+ 'workflow': workflow,
87
+ 'agent': parts[1],
88
+ 'commit': parts[2]
89
+ }
90
+
91
+ # Legacy format: workflow-automation-commit:sha (no agent)
92
+ if '-automation-commit:' in content and content.count(':') == 1:
93
+ parts = content.split(':')
94
+ workflow = parts[0].replace('-automation-commit', '')
95
+ return {
96
+ 'workflow': workflow,
97
+ 'agent': 'unknown',
98
+ 'commit': parts[1]
99
+ }
100
+
101
+ return None
35
102
 
36
103
 
37
104
  def normalise_handle(assistant_handle: str | None) -> str:
@@ -115,7 +182,7 @@ class CodexConfig:
115
182
  commit_marker_suffix: str = CODEX_COMMIT_MARKER_SUFFIX
116
183
 
117
184
  @classmethod
118
- def from_env(cls, assistant_handle: str | None) -> "CodexConfig":
185
+ def from_env(cls, assistant_handle: str | None) -> CodexConfig:
119
186
  handle = normalise_handle(assistant_handle)
120
187
  return cls(
121
188
  assistant_handle=handle,