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
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Test-Driven Development for Workflow-Specific Safety Limits
4
+
5
+ RED Phase: Tests should FAIL initially (implementation broken)
6
+ GREEN Phase: Fix implementation to make tests pass
7
+
8
+ Tests workflow-specific comment counting and safety limits:
9
+ - pr_automation workflow
10
+ - fix_comment workflow
11
+ - codex_update workflow
12
+ - fixpr workflow
13
+ """
14
+
15
+ import unittest
16
+ from unittest.mock import Mock, patch
17
+
18
+ from jleechanorg_pr_automation.jleechanorg_pr_monitor import JleechanorgPRMonitor
19
+
20
+
21
+ class TestWorkflowSpecificLimits(unittest.TestCase):
22
+ """Test workflow-specific comment counting and safety limits"""
23
+
24
+ def setUp(self):
25
+ """Set up test environment"""
26
+ with patch('jleechanorg_pr_automation.jleechanorg_pr_monitor.AutomationSafetyManager'):
27
+ # Initialize with explicit test username to avoid environment dependency
28
+ self.monitor = JleechanorgPRMonitor(automation_username="test-automation-user")
29
+ # Mock safety manager with workflow-specific limits
30
+ self.monitor.safety_manager.pr_automation_limit = 10
31
+ self.monitor.safety_manager.fix_comment_limit = 5
32
+ self.monitor.safety_manager.codex_update_limit = 10
33
+ self.monitor.safety_manager.fixpr_limit = 10
34
+
35
+ def test_count_pr_automation_comments(self):
36
+ """Test counting PR automation comments (codex marker, not fix-comment)"""
37
+ comments = [
38
+ {"body": "<!-- codex-automation-commit:abc123 -->"},
39
+ {"body": "<!-- codex-automation-commit:def456 -->"},
40
+ {"body": "<!-- fix-comment-automation-commit:xyz789 -->"}, # Should NOT count
41
+ {"body": "Regular comment"},
42
+ ]
43
+ count = self.monitor._count_workflow_comments(comments, "pr_automation")
44
+ self.assertEqual(count, 2, "Should count only codex-automation-commit comments without fix-comment marker")
45
+
46
+ def test_count_fix_comment_comments(self):
47
+ """Test counting fix-comment workflow comments"""
48
+ comments = [
49
+ {"body": "<!-- fix-comment-automation-commit:abc123 -->", "author": {"login": "test-automation-user"}},
50
+ {"body": "<!-- fix-comment-automation-commit:def456 -->", "author": {"login": "test-automation-user"}},
51
+ {"body": "<!-- codex-automation-commit:xyz789 -->", "author": {"login": "test-automation-user"}}, # Should NOT count
52
+ {"body": "Regular comment", "author": {"login": "test-automation-user"}},
53
+ ]
54
+ count = self.monitor._count_workflow_comments(comments, "fix_comment")
55
+ self.assertEqual(count, 2, "Should count only fix-comment-automation-commit comments")
56
+
57
+ def test_count_fix_comment_run_comments(self):
58
+ """Test counting fix-comment queued run markers"""
59
+ comments = [
60
+ {"body": "<!-- fix-comment-run-automation-commit:gemini:abc123 -->", "author": {"login": "test-automation-user"}},
61
+ {"body": "<!-- fix-comment-run-automation-commit:codex:def456 -->", "author": {"login": "test-automation-user"}},
62
+ {"body": "<!-- codex-automation-commit:xyz789 -->", "author": {"login": "test-automation-user"}}, # Should NOT count
63
+ {"body": "Regular comment", "author": {"login": "test-automation-user"}},
64
+ ]
65
+ count = self.monitor._count_workflow_comments(comments, "fix_comment")
66
+ self.assertEqual(count, 2, "Should count fix-comment-run-automation-commit comments for fix_comment workflow")
67
+
68
+ def test_count_codex_update_comments(self):
69
+ """Test counting codex-update workflow comments (should always be 0)"""
70
+ comments = [
71
+ {"body": "<!-- codex-automation-commit:abc123 -->"},
72
+ {"body": "<!-- fix-comment-automation-commit:def456 -->"},
73
+ ]
74
+ count = self.monitor._count_workflow_comments(comments, "codex_update")
75
+ self.assertEqual(count, 0, "Codex update workflow doesn't post PR comments, should always return 0")
76
+
77
+ def test_count_fixpr_comments(self):
78
+ """Test counting fixpr workflow comments"""
79
+ comments = [
80
+ {"body": "<!-- fixpr-run-automation-commit:gemini:abc123 -->", "author": {"login": "test-automation-user"}},
81
+ {"body": "<!-- fixpr-run-automation-commit:codex:def456 -->", "author": {"login": "test-automation-user"}},
82
+ {"body": "<!-- codex-automation-commit:xyz789 -->", "author": {"login": "test-automation-user"}}, # Should NOT count
83
+ {"body": "<!-- fix-comment-automation-commit:ghi012 -->", "author": {"login": "test-automation-user"}}, # Should NOT count
84
+ {"body": "Regular comment", "author": {"login": "test-automation-user"}},
85
+ ]
86
+ count = self.monitor._count_workflow_comments(comments, "fixpr")
87
+ self.assertEqual(count, 2, "Should count only fixpr-run-automation-commit comments for fixpr workflow")
88
+
89
+ def test_workflow_specific_limit_pr_automation(self):
90
+ """Test that PR automation workflow uses its own limit"""
91
+ comments = [
92
+ {"body": f"<!-- codex-automation-commit:abc{i} -->"} for i in range(10)
93
+ ]
94
+ count = self.monitor._count_workflow_comments(comments, "pr_automation")
95
+ # Should be at limit (10), not blocked yet
96
+ self.assertEqual(count, 10)
97
+ # 11th comment should exceed limit
98
+ comments.append({"body": "<!-- codex-automation-commit:abc11 -->"})
99
+ count = self.monitor._count_workflow_comments(comments, "pr_automation")
100
+ self.assertEqual(count, 11)
101
+ self.assertGreater(count, self.monitor.safety_manager.pr_automation_limit)
102
+
103
+ def test_workflow_specific_limit_fix_comment(self):
104
+ """Test that fix-comment workflow uses its own limit (5)"""
105
+ comments = [
106
+ {"body": f"<!-- fix-comment-automation-commit:abc{i} -->", "author": {"login": "test-automation-user"}} for i in range(5)
107
+ ]
108
+ count = self.monitor._count_workflow_comments(comments, "fix_comment")
109
+ # Should be at limit (5)
110
+ self.assertEqual(count, 5)
111
+ # 6th comment should exceed limit
112
+ comments.append({"body": "<!-- fix-comment-automation-commit:abc6 -->", "author": {"login": "test-automation-user"}})
113
+ count = self.monitor._count_workflow_comments(comments, "fix_comment")
114
+ self.assertEqual(count, 6)
115
+ self.assertGreater(count, self.monitor.safety_manager.fix_comment_limit)
116
+
117
+ def test_workflow_specific_limit_independence(self):
118
+ """Test that different workflows have independent limits"""
119
+ # PR automation has 10 comments (at limit)
120
+ pr_automation_comments = [
121
+ {"body": f"<!-- codex-automation-commit:pr{i} -->", "author": {"login": "test-automation-user"}} for i in range(10)
122
+ ]
123
+ # Fix-comment has 2 comments (under limit)
124
+ fix_comment_comments = [
125
+ {"body": f"<!-- fix-comment-automation-commit:fix{i} -->", "author": {"login": "test-automation-user"}} for i in range(2)
126
+ ]
127
+
128
+ pr_count = self.monitor._count_workflow_comments(pr_automation_comments, "pr_automation")
129
+ fix_count = self.monitor._count_workflow_comments(fix_comment_comments, "fix_comment")
130
+
131
+ self.assertEqual(pr_count, 10)
132
+ self.assertEqual(fix_count, 2)
133
+ # Fix-comment should still be allowed even though PR automation is at limit
134
+ self.assertLess(fix_count, self.monitor.safety_manager.fix_comment_limit)
135
+ self.assertEqual(pr_count, self.monitor.safety_manager.pr_automation_limit)
136
+
137
+ def test_mixed_comments_pr_automation(self):
138
+ """Test PR automation counting with mixed comment types"""
139
+ comments = [
140
+ {"body": "<!-- codex-automation-commit:abc123 -->"}, # Count
141
+ {"body": "<!-- codex-automation-commit:def456 -->"}, # Count
142
+ {"body": "<!-- fix-comment-automation-commit:xyz789 -->"}, # Don't count
143
+ {"body": "<!-- codex-automation-commit:ghi012 -->"}, # Count
144
+ {"body": "Regular comment"}, # Don't count
145
+ ]
146
+ count = self.monitor._count_workflow_comments(comments, "pr_automation")
147
+ self.assertEqual(count, 3, "Should count only codex comments without fix-comment marker")
148
+
149
+ def test_mixed_comments_fix_comment(self):
150
+ """Test fix-comment counting with mixed comment types"""
151
+ comments = [
152
+ {"body": "<!-- fix-comment-automation-commit:abc123 -->", "author": {"login": "test-automation-user"}}, # Count
153
+ {"body": "<!-- codex-automation-commit:def456 -->", "author": {"login": "test-automation-user"}}, # Don't count
154
+ {"body": "<!-- fix-comment-automation-commit:xyz789 -->", "author": {"login": "test-automation-user"}}, # Count
155
+ {"body": "Regular comment", "author": {"login": "test-automation-user"}}, # Don't count
156
+ ]
157
+ count = self.monitor._count_workflow_comments(comments, "fix_comment")
158
+ self.assertEqual(count, 2, "Should count only fix-comment-automation-commit comments")
159
+
160
+ def test_empty_comments_list(self):
161
+ """Test counting with empty comments list"""
162
+ comments = []
163
+ for workflow_type in ["pr_automation", "fix_comment", "codex_update", "fixpr"]:
164
+ count = self.monitor._count_workflow_comments(comments, workflow_type)
165
+ self.assertEqual(count, 0, f"Empty list should return 0 for {workflow_type}")
166
+
167
+ def test_unknown_workflow_type(self):
168
+ """Test that unknown workflow type falls back to counting all automation comments"""
169
+ comments = [
170
+ {"body": "<!-- codex-automation-commit:abc123 -->"},
171
+ {"body": "<!-- fix-comment-automation-commit:def456 -->"},
172
+ ]
173
+ count = self.monitor._count_workflow_comments(comments, "unknown_workflow")
174
+ # Should count all automation comments as fallback
175
+ self.assertEqual(count, 2)
176
+
177
+ def test_count_fix_comment_ignores_impostors(self):
178
+ """Test that fix-comment counts ignore comments from other users"""
179
+ comments = [
180
+ # Valid marker but wrong author
181
+ {"body": "<!-- fix-comment-automation-commit:abc123 -->", "author": {"login": "impostor"}},
182
+ # Valid marker and correct author
183
+ {"body": "<!-- fix-comment-automation-commit:def456 -->", "author": {"login": "test-automation-user"}},
184
+ ]
185
+ count = self.monitor._count_workflow_comments(comments, "fix_comment")
186
+ self.assertEqual(count, 1, "Should ignore comments from impostor users")
187
+
188
+ def test_count_fixpr_ignores_impostors(self):
189
+ """Test that fixpr counts ignore comments from other users"""
190
+ comments = [
191
+ # Valid marker but wrong author
192
+ {"body": "<!-- fixpr-run-automation-commit:gemini:abc123 -->", "author": {"login": "impostor"}},
193
+ # Valid marker and correct author
194
+ {"body": "<!-- fixpr-run-automation-commit:codex:def456 -->", "author": {"login": "test-automation-user"}},
195
+ ]
196
+ count = self.monitor._count_workflow_comments(comments, "fixpr")
197
+ self.assertEqual(count, 1, "Should ignore comments from impostor users")
198
+
199
+
200
+
201
+ if __name__ == "__main__":
202
+ unittest.main()
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Test for workspace dispatch when workspace directory doesn't exist.
4
+
5
+ This tests the critical bug where automation fails when:
6
+ 1. PR is processed initially (workspace created)
7
+ 2. Workspace is cleaned up
8
+ 3. PR gets new commits
9
+ 4. Automation tries to re-process but fails because workspace is missing
10
+
11
+ Expected behavior: Create workspace if missing, don't fail
12
+ """
13
+
14
+ import shutil
15
+ import sys
16
+ import tempfile
17
+ import unittest
18
+ from pathlib import Path
19
+ from unittest.mock import MagicMock, patch
20
+
21
+ # Import from local source, not installed package
22
+ sys.path.insert(0, str(Path(__file__).parent.parent.parent))
23
+ from jleechanorg_pr_automation.orchestrated_pr_runner import prepare_workspace_dir
24
+
25
+
26
+ class TestWorkspaceDispatchMissingDirectory(unittest.TestCase):
27
+ """Test that workspace dispatch handles missing directories gracefully."""
28
+
29
+ def setUp(self):
30
+ """Set up test workspace directory."""
31
+ self.test_root = Path(tempfile.mkdtemp(prefix="test_workspace_dispatch_"))
32
+
33
+ def tearDown(self):
34
+ """Clean up test workspace directory."""
35
+ if self.test_root.exists():
36
+ shutil.rmtree(self.test_root)
37
+
38
+ def test_prepare_workspace_ignores_rmtree_filenotfound_race(self):
39
+ """
40
+ Test that prepare_workspace_dir ignores FileNotFoundError from rmtree.
41
+
42
+ This can happen if the workspace exists during the initial existence check but
43
+ is removed concurrently (external cleanup, reboot/tmpfs cleanup, etc.) before
44
+ shutil.rmtree() runs.
45
+ """
46
+ repo = "worldarchitect.ai"
47
+ workspace_name = "pr-2915-fix-social-hp-god-tier-enforcement"
48
+
49
+ # Workspace exists, but removal races with external cleanup.
50
+ workspace_path = self.test_root / repo / workspace_name
51
+ workspace_path.mkdir(parents=True, exist_ok=True)
52
+
53
+ with patch("jleechanorg_pr_automation.orchestrated_pr_runner.WORKSPACE_ROOT_BASE", self.test_root):
54
+ with patch(
55
+ "jleechanorg_pr_automation.orchestrated_pr_runner.shutil.rmtree",
56
+ side_effect=FileNotFoundError("[Errno 2] No such file or directory"),
57
+ ) as mock_rmtree:
58
+ result = prepare_workspace_dir(repo, workspace_name)
59
+
60
+ self.assertEqual(result, self.test_root / repo / workspace_name)
61
+ self.assertTrue(result.parent.exists(), f"Parent directory {result.parent} should exist after prepare_workspace_dir")
62
+ self.assertEqual(mock_rmtree.call_count, 1, "Expected a single rmtree attempt")
63
+
64
+ def test_prepare_workspace_raises_on_non_filenotfound_oserror(self):
65
+ """
66
+ Test that prepare_workspace_dir still raises on serious OS errors.
67
+
68
+ Scenario: Workspace exists but rmtree fails (permissions, locks, etc.).
69
+ """
70
+ repo = "worldarchitect.ai"
71
+ workspace_name = "pr-2909-fix-power-absorption-rewards-protocol"
72
+
73
+ workspace_path = self.test_root / repo / workspace_name
74
+ workspace_path.mkdir(parents=True, exist_ok=True)
75
+
76
+ with patch("jleechanorg_pr_automation.orchestrated_pr_runner.WORKSPACE_ROOT_BASE", self.test_root):
77
+ with patch(
78
+ "jleechanorg_pr_automation.orchestrated_pr_runner.shutil.rmtree",
79
+ side_effect=PermissionError("[Errno 13] Permission denied"),
80
+ ):
81
+ with self.assertRaises(PermissionError):
82
+ prepare_workspace_dir(repo, workspace_name)
83
+
84
+ def test_prepare_workspace_with_git_worktree_attempts_cleanup(self):
85
+ """
86
+ Test that prepare_workspace_dir handles git worktrees correctly.
87
+
88
+ Scenario: Workspace is a git worktree that was cleaned up
89
+ Expected: Should clean up worktree metadata and proceed
90
+ """
91
+ repo = "worldarchitect.ai"
92
+ workspace_name = "pr-2902-claude-test-and-fix-system-prompt-RiZyM"
93
+
94
+ # Create workspace with .git file (worktree marker)
95
+ workspace_path = self.test_root / repo / workspace_name
96
+ workspace_path.mkdir(parents=True, exist_ok=True)
97
+ git_file = workspace_path / ".git"
98
+ git_file.write_text(
99
+ f"gitdir: {self.test_root / repo / '.git' / 'worktrees' / workspace_name}"
100
+ )
101
+
102
+ with patch("jleechanorg_pr_automation.orchestrated_pr_runner.WORKSPACE_ROOT_BASE", self.test_root):
103
+ with patch(
104
+ "jleechanorg_pr_automation.orchestrated_pr_runner.run_cmd",
105
+ return_value=MagicMock(returncode=0, stdout="", stderr=""),
106
+ ) as mock_run_cmd:
107
+ with patch(
108
+ "jleechanorg_pr_automation.orchestrated_pr_runner.shutil.rmtree",
109
+ side_effect=FileNotFoundError("[Errno 2] No such file or directory"),
110
+ ):
111
+ result = prepare_workspace_dir(repo, workspace_name)
112
+
113
+ self.assertEqual(result, self.test_root / repo / workspace_name)
114
+ self.assertGreaterEqual(mock_run_cmd.call_count, 2, "Should call git worktree remove and prune")
115
+ self.assertTrue(result.parent.exists(), "Parent directory should exist")
116
+
117
+
118
+ if __name__ == "__main__":
119
+ unittest.main()
@@ -17,7 +17,9 @@ import tempfile
17
17
  import threading
18
18
  from datetime import datetime
19
19
  from pathlib import Path
20
- from typing import Dict, Any, Optional
20
+ from typing import Any, Dict, Mapping, Optional
21
+
22
+ from .logging_utils import setup_logging # noqa: F401
21
23
 
22
24
 
23
25
  class SafeJSONManager:
@@ -46,7 +48,7 @@ class SafeJSONManager:
46
48
  with lock:
47
49
  try:
48
50
  if os.path.exists(file_path):
49
- with open(file_path, 'r') as f:
51
+ with open(file_path) as f:
50
52
  # Add file lock for cross-process safety
51
53
  fcntl.flock(f.fileno(), fcntl.LOCK_SH)
52
54
  try:
@@ -55,7 +57,7 @@ class SafeJSONManager:
55
57
  fcntl.flock(f.fileno(), fcntl.LOCK_UN)
56
58
  else:
57
59
  return default if default is not None else {}
58
- except (json.JSONDecodeError, IOError) as e:
60
+ except (OSError, json.JSONDecodeError) as e:
59
61
  logging.warning(f"Failed to read JSON from {file_path}: {e}")
60
62
  return default if default is not None else {}
61
63
 
@@ -78,7 +80,7 @@ class SafeJSONManager:
78
80
  )
79
81
 
80
82
  try:
81
- with os.fdopen(fd, 'w') as f:
83
+ with os.fdopen(fd, "w") as f:
82
84
  # Add exclusive file lock for cross-process safety
83
85
  fcntl.flock(f.fileno(), fcntl.LOCK_EX)
84
86
  try:
@@ -99,8 +101,8 @@ class SafeJSONManager:
99
101
  except OSError:
100
102
  pass
101
103
 
102
- except (IOError, OSError) as e:
103
- logging.error(f"Failed to write JSON to {file_path}: {e}")
104
+ except OSError as e:
105
+ logging.exception(f"Failed to write JSON to {file_path}: {e}")
104
106
  return False
105
107
 
106
108
  def update_json(self, file_path: str, update_func, lock_timeout: int = 10) -> bool:
@@ -112,7 +114,7 @@ class SafeJSONManager:
112
114
  updated_data = update_func(data)
113
115
  return self.write_json(file_path, updated_data)
114
116
  except Exception as e:
115
- logging.error(f"Failed to update JSON {file_path}: {e}")
117
+ logging.exception(f"Failed to update JSON {file_path}: {e}")
116
118
  return False
117
119
 
118
120
 
@@ -120,37 +122,6 @@ class SafeJSONManager:
120
122
  json_manager = SafeJSONManager()
121
123
 
122
124
 
123
- def setup_logging(name: str, level: int = logging.INFO,
124
- log_file: Optional[str] = None) -> logging.Logger:
125
- """Standardized logging setup for automation components"""
126
- logger = logging.getLogger(name)
127
-
128
- # Avoid duplicate handlers
129
- if logger.handlers:
130
- return logger
131
-
132
- logger.setLevel(level)
133
-
134
- # Create formatter
135
- formatter = logging.Formatter(
136
- '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
137
- )
138
-
139
- # Console handler
140
- console_handler = logging.StreamHandler()
141
- console_handler.setFormatter(formatter)
142
- logger.addHandler(console_handler)
143
-
144
- # File handler if specified
145
- if log_file:
146
- os.makedirs(os.path.dirname(log_file), exist_ok=True)
147
- file_handler = logging.FileHandler(log_file)
148
- file_handler.setFormatter(formatter)
149
- logger.addHandler(file_handler)
150
-
151
- return logger
152
-
153
-
154
125
  def get_env_config(prefix: str = "AUTOMATION_") -> Dict[str, str]:
155
126
  """Get all environment variables with specified prefix"""
156
127
  config = {}
@@ -165,31 +136,85 @@ def get_env_config(prefix: str = "AUTOMATION_") -> Dict[str, str]:
165
136
  def get_email_config() -> Dict[str, str]:
166
137
  """Get email configuration from environment variables"""
167
138
  email_config = {
168
- 'smtp_server': os.getenv('SMTP_SERVER', 'localhost'),
169
- 'smtp_port': int(os.getenv('SMTP_PORT', '587')),
170
- 'email_user': os.getenv('EMAIL_USER', ''),
171
- 'email_pass': os.getenv('EMAIL_PASS', ''),
172
- 'email_to': os.getenv('EMAIL_TO', ''),
173
- 'email_from': os.getenv('EMAIL_FROM', os.getenv('EMAIL_USER', ''))
139
+ "smtp_server": os.getenv("SMTP_SERVER", "localhost"),
140
+ "smtp_port": int(os.getenv("SMTP_PORT", "587")),
141
+ "email_user": os.getenv("EMAIL_USER", ""),
142
+ "email_pass": os.getenv("EMAIL_PASS", ""),
143
+ "email_to": os.getenv("EMAIL_TO", ""),
144
+ "email_from": os.getenv("EMAIL_FROM", os.getenv("EMAIL_USER", ""))
174
145
  }
175
146
  return email_config
176
147
 
177
148
 
178
149
  def validate_email_config(config: Dict[str, str]) -> bool:
179
150
  """Validate that required email configuration is present"""
180
- required_fields = ['smtp_server', 'email_user', 'email_pass', 'email_to']
151
+ required_fields = ["smtp_server", "email_user", "email_pass", "email_to"]
181
152
  return all(config.get(field) for field in required_fields)
182
153
 
183
154
 
184
155
  def get_automation_limits() -> Dict[str, int]:
185
- """Get automation safety limits from environment or defaults"""
186
- return {
187
- 'pr_limit': int(os.getenv('AUTOMATION_PR_LIMIT', '5')),
188
- 'global_limit': int(os.getenv('AUTOMATION_GLOBAL_LIMIT', '50')),
189
- 'approval_hours': int(os.getenv('AUTOMATION_APPROVAL_HOURS', '24')),
190
- 'subprocess_timeout': int(os.getenv('AUTOMATION_SUBPROCESS_TIMEOUT', '300'))
156
+ """Get automation safety limits from defaults with optional overrides.
157
+
158
+ Supports workflow-specific limits:
159
+ - pr_automation: Default PR monitoring workflow (posts codex comments)
160
+ - fix_comment: Fix-comment workflow (addresses review comments)
161
+ - codex_update: Codex update workflow (browser automation)
162
+ - fixpr: FixPR workflow (resolves conflicts/failing checks)
163
+ """
164
+ return get_automation_limits_with_overrides()
165
+
166
+
167
+ def coerce_positive_int(value: Any, *, default: int) -> int:
168
+ try:
169
+ parsed = int(value)
170
+ except (TypeError, ValueError):
171
+ return default
172
+ return parsed if parsed > 0 else default
173
+
174
+
175
+ def get_automation_limits_with_overrides(overrides: Optional[Mapping[str, Any]] = None) -> Dict[str, int]:
176
+ """Internal helper to keep defaults centralized and overrides explicit.
177
+
178
+ New limit structure:
179
+ - pr_limit: 50 total attempts across ALL workflows for a PR
180
+ - workflow limits: 10 attempts per workflow (counts ALL attempts, not just failures)
181
+ """
182
+ # Global PR limit: 50 total attempts across all workflows
183
+ pr_limit_default = 50
184
+ pr_limit = coerce_positive_int(os.getenv("AUTOMATION_PR_LIMIT"), default=pr_limit_default)
185
+
186
+ # Per-workflow limit: 10 attempts per workflow
187
+ workflow_limit_default = 10
188
+
189
+ defaults: Dict[str, int] = {
190
+ # Global PR limit: counts ALL attempts across ALL workflows
191
+ "pr_limit": pr_limit,
192
+ "global_limit": coerce_positive_int(os.getenv("AUTOMATION_GLOBAL_LIMIT"), default=50),
193
+ "approval_hours": 24,
194
+ "subprocess_timeout": 300,
195
+ # Workflow-specific limits: 10 attempts per workflow (counts ALL attempts)
196
+ "pr_automation_limit": coerce_positive_int(
197
+ os.getenv("AUTOMATION_PR_AUTOMATION_LIMIT"), default=workflow_limit_default
198
+ ),
199
+ "fix_comment_limit": coerce_positive_int(
200
+ os.getenv("AUTOMATION_FIX_COMMENT_LIMIT"), default=workflow_limit_default
201
+ ),
202
+ "codex_update_limit": coerce_positive_int(
203
+ os.getenv("AUTOMATION_CODEX_UPDATE_LIMIT"), default=workflow_limit_default
204
+ ),
205
+ "fixpr_limit": coerce_positive_int(os.getenv("AUTOMATION_FIXPR_LIMIT"), default=workflow_limit_default),
191
206
  }
192
207
 
208
+ if not overrides:
209
+ return dict(defaults)
210
+
211
+ limits = dict(defaults)
212
+ for key in list(defaults.keys()):
213
+ if key in overrides:
214
+ limits[key] = coerce_positive_int(overrides.get(key), default=defaults[key])
215
+
216
+ return limits
217
+
193
218
 
194
219
  def ensure_directory(file_path: str) -> None:
195
220
  """Ensure directory exists for given file path"""
@@ -224,9 +249,9 @@ def parse_timestamp(timestamp_str: str) -> datetime:
224
249
  def get_test_email_config() -> Dict[str, str]:
225
250
  """Get standardized test email configuration"""
226
251
  return {
227
- 'SMTP_SERVER': 'smtp.example.com',
228
- 'SMTP_PORT': '587',
229
- 'EMAIL_USER': 'test@example.com',
230
- 'EMAIL_PASS': 'testpass',
231
- 'EMAIL_TO': 'admin@example.com'
252
+ "SMTP_SERVER": "smtp.example.com",
253
+ "SMTP_PORT": "587",
254
+ "EMAIL_USER": "test@example.com",
255
+ "EMAIL_PASS": "testpass",
256
+ "EMAIL_TO": "admin@example.com"
232
257
  }