jleechanorg-pr-automation 0.1.0__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.
Potentially problematic release.
This version of jleechanorg-pr-automation might be problematic. Click here for more details.
- jleechanorg_pr_automation/__init__.py +32 -0
- jleechanorg_pr_automation/automation_safety_manager.py +700 -0
- jleechanorg_pr_automation/automation_safety_wrapper.py +116 -0
- jleechanorg_pr_automation/automation_utils.py +314 -0
- jleechanorg_pr_automation/check_codex_comment.py +76 -0
- jleechanorg_pr_automation/codex_branch_updater.py +272 -0
- jleechanorg_pr_automation/codex_config.py +57 -0
- jleechanorg_pr_automation/jleechanorg_pr_monitor.py +1202 -0
- jleechanorg_pr_automation/tests/conftest.py +12 -0
- jleechanorg_pr_automation/tests/test_actionable_counting_matrix.py +221 -0
- jleechanorg_pr_automation/tests/test_automation_over_running_reproduction.py +147 -0
- jleechanorg_pr_automation/tests/test_automation_safety_limits.py +340 -0
- jleechanorg_pr_automation/tests/test_automation_safety_manager_comprehensive.py +615 -0
- jleechanorg_pr_automation/tests/test_codex_actor_matching.py +137 -0
- jleechanorg_pr_automation/tests/test_graphql_error_handling.py +155 -0
- jleechanorg_pr_automation/tests/test_pr_filtering_matrix.py +473 -0
- jleechanorg_pr_automation/tests/test_pr_targeting.py +95 -0
- jleechanorg_pr_automation/utils.py +232 -0
- jleechanorg_pr_automation-0.1.0.dist-info/METADATA +217 -0
- jleechanorg_pr_automation-0.1.0.dist-info/RECORD +23 -0
- jleechanorg_pr_automation-0.1.0.dist-info/WHEEL +5 -0
- jleechanorg_pr_automation-0.1.0.dist-info/entry_points.txt +3 -0
- jleechanorg_pr_automation-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Playwright automation for managing Codex tasks on ChatGPT."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import random
|
|
9
|
+
from getpass import getpass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Dict, Tuple
|
|
12
|
+
|
|
13
|
+
from playwright.async_api import (
|
|
14
|
+
Browser,
|
|
15
|
+
BrowserContext,
|
|
16
|
+
Error as PlaywrightError,
|
|
17
|
+
Page,
|
|
18
|
+
TimeoutError as PlaywrightTimeoutError,
|
|
19
|
+
async_playwright,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
CHATGPT_CODEX_URL = "https://chatgpt.com/codex"
|
|
23
|
+
CREDENTIALS_PATH = Path.home() / ".chatgpt_codex_credentials.json"
|
|
24
|
+
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\"]"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _save_credentials(credentials: Dict[str, str]) -> None:
|
|
31
|
+
"""Persist credentials to disk with restrictive permissions."""
|
|
32
|
+
|
|
33
|
+
CREDENTIALS_PATH.write_text(json.dumps(credentials, indent=2))
|
|
34
|
+
os.chmod(CREDENTIALS_PATH, 0o600)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _load_credentials() -> Dict[str, str] | None:
|
|
38
|
+
"""Return stored credentials if available and well formed."""
|
|
39
|
+
|
|
40
|
+
if not CREDENTIALS_PATH.exists():
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
data = json.loads(CREDENTIALS_PATH.read_text())
|
|
45
|
+
except json.JSONDecodeError:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
email = data.get("email")
|
|
49
|
+
password = data.get("password")
|
|
50
|
+
if not email or not password:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
return {"email": email, "password": password}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def prompt_for_credentials() -> Dict[str, str]:
|
|
57
|
+
"""Prompt the user for credentials and persist them."""
|
|
58
|
+
|
|
59
|
+
print("🔐 ChatGPT Codex credentials not found. They will be stored locally at")
|
|
60
|
+
print(f" {CREDENTIALS_PATH}")
|
|
61
|
+
email = input("ChatGPT email: ").strip()
|
|
62
|
+
password = getpass("ChatGPT password: ")
|
|
63
|
+
|
|
64
|
+
credentials = {"email": email, "password": password}
|
|
65
|
+
_save_credentials(credentials)
|
|
66
|
+
print("✅ Credentials saved locally (chmod 600).")
|
|
67
|
+
return credentials
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_credentials() -> Dict[str, str]:
|
|
71
|
+
"""Load stored credentials or prompt the user."""
|
|
72
|
+
|
|
73
|
+
credentials = _load_credentials()
|
|
74
|
+
if credentials is None:
|
|
75
|
+
credentials = prompt_for_credentials()
|
|
76
|
+
return credentials
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def ensure_logged_in(page: Page, credentials: Dict[str, str] | None = None) -> None:
|
|
80
|
+
"""Log into ChatGPT Codex if required."""
|
|
81
|
+
|
|
82
|
+
await page.goto(CHATGPT_CODEX_URL, wait_until="domcontentloaded")
|
|
83
|
+
|
|
84
|
+
if await is_task_list_visible(page):
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
if credentials is None:
|
|
88
|
+
credentials = get_credentials()
|
|
89
|
+
|
|
90
|
+
login_trigger = page.get_by_role("link", name="Log in")
|
|
91
|
+
if await login_trigger.count() == 0:
|
|
92
|
+
login_trigger = page.get_by_role("button", name="Log in")
|
|
93
|
+
if await login_trigger.count():
|
|
94
|
+
await login_trigger.first().click()
|
|
95
|
+
|
|
96
|
+
await _complete_login_flow(page, credentials)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def _complete_login_flow(page: Page, credentials: Dict[str, str]) -> None:
|
|
100
|
+
"""Fill in the login flow using the provided credentials."""
|
|
101
|
+
|
|
102
|
+
email_field = page.locator("input[type='email']")
|
|
103
|
+
await email_field.first().wait_for(timeout=30000)
|
|
104
|
+
await email_field.first().fill(credentials["email"])
|
|
105
|
+
|
|
106
|
+
next_button = page.get_by_role("button", name="Continue")
|
|
107
|
+
if await next_button.count():
|
|
108
|
+
await next_button.first().click()
|
|
109
|
+
else:
|
|
110
|
+
await page.keyboard.press("Enter")
|
|
111
|
+
|
|
112
|
+
password_field = page.locator("input[type='password']")
|
|
113
|
+
await password_field.first().wait_for(timeout=30000)
|
|
114
|
+
await password_field.first().fill(credentials["password"])
|
|
115
|
+
|
|
116
|
+
signin_button = page.get_by_role("button", name="Continue")
|
|
117
|
+
if await signin_button.count() == 0:
|
|
118
|
+
signin_button = page.get_by_role("button", name="Sign in")
|
|
119
|
+
if await signin_button.count() == 0:
|
|
120
|
+
signin_button = page.get_by_role("button", name="Log in")
|
|
121
|
+
|
|
122
|
+
if await signin_button.count():
|
|
123
|
+
await signin_button.first().click()
|
|
124
|
+
else:
|
|
125
|
+
await page.keyboard.press("Enter")
|
|
126
|
+
|
|
127
|
+
await page.wait_for_url("**/codex**", timeout=60000)
|
|
128
|
+
await wait_for_task_list(page)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def is_task_list_visible(page: Page) -> bool:
|
|
132
|
+
"""Return True if the Codex task list is visible."""
|
|
133
|
+
|
|
134
|
+
task_cards = page.locator(TASK_CARD_SELECTOR)
|
|
135
|
+
try:
|
|
136
|
+
await task_cards.first().wait_for(timeout=5000)
|
|
137
|
+
except PlaywrightTimeoutError:
|
|
138
|
+
return False
|
|
139
|
+
return await task_cards.count() > 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def wait_for_task_list(page: Page) -> None:
|
|
143
|
+
"""Wait until task cards are available."""
|
|
144
|
+
|
|
145
|
+
await page.wait_for_selector(TASK_CARD_SELECTOR, timeout=60000)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def collect_task_metadata(page: Page, index: int) -> Tuple[str, str]:
|
|
149
|
+
"""Return the title and status text for a given task card."""
|
|
150
|
+
|
|
151
|
+
task_card = page.locator(TASK_CARD_SELECTOR).nth(index)
|
|
152
|
+
title_locator = task_card.locator(TASK_TITLE_SELECTOR)
|
|
153
|
+
title = await title_locator.inner_text() if await title_locator.count() else f"Task #{index + 1}"
|
|
154
|
+
status_locator = task_card.locator("[data-testid='codex-task-status']")
|
|
155
|
+
status = await status_locator.inner_text() if await status_locator.count() else ""
|
|
156
|
+
return title.strip(), status.strip()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
async def process_tasks(page: Page) -> None:
|
|
160
|
+
"""Iterate through all Codex tasks and click Update Branch when present."""
|
|
161
|
+
|
|
162
|
+
processed_indices: set[int] = set()
|
|
163
|
+
|
|
164
|
+
while True:
|
|
165
|
+
task_cards = page.locator(TASK_CARD_SELECTOR)
|
|
166
|
+
total_tasks = await task_cards.count()
|
|
167
|
+
if total_tasks == 0:
|
|
168
|
+
print("ℹ️ No tasks detected on Codex dashboard.")
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
pending_indices = [idx for idx in range(total_tasks) if idx not in processed_indices]
|
|
172
|
+
if not pending_indices:
|
|
173
|
+
print("🏁 Completed processing available tasks.")
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
for index in pending_indices:
|
|
177
|
+
task_card = task_cards.nth(index)
|
|
178
|
+
await task_card.scroll_into_view_if_needed()
|
|
179
|
+
|
|
180
|
+
title, status = await collect_task_metadata(page, index)
|
|
181
|
+
print(f"🔍 Inspecting {title} ({status or 'no status'})")
|
|
182
|
+
|
|
183
|
+
update_button = task_card.locator(UPDATE_BRANCH_BUTTON_SELECTOR)
|
|
184
|
+
button_count = await update_button.count()
|
|
185
|
+
if button_count:
|
|
186
|
+
try:
|
|
187
|
+
await update_button.first().click()
|
|
188
|
+
print("✅ Clicked Update Branch directly from task card.")
|
|
189
|
+
processed_indices.add(index)
|
|
190
|
+
await _post_action_delay(page)
|
|
191
|
+
continue
|
|
192
|
+
except PlaywrightError as exc:
|
|
193
|
+
print(f"⚠️ Failed to click Update Branch on card: {exc}")
|
|
194
|
+
|
|
195
|
+
await task_card.click()
|
|
196
|
+
await page.wait_for_timeout(1000)
|
|
197
|
+
|
|
198
|
+
modal_update_button = page.locator(UPDATE_BRANCH_BUTTON_SELECTOR)
|
|
199
|
+
try:
|
|
200
|
+
await modal_update_button.first().wait_for(timeout=5000)
|
|
201
|
+
await modal_update_button.first().click()
|
|
202
|
+
print("✅ Clicked Update Branch inside task detail.")
|
|
203
|
+
except PlaywrightTimeoutError:
|
|
204
|
+
print("➡️ No Update Branch button found for this task. Skipping.")
|
|
205
|
+
except PlaywrightError as exc:
|
|
206
|
+
print(f"⚠️ Error clicking Update Branch in detail view: {exc}")
|
|
207
|
+
|
|
208
|
+
await _close_task_detail(page)
|
|
209
|
+
processed_indices.add(index)
|
|
210
|
+
await _post_action_delay(page)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
async def _close_task_detail(page: Page) -> None:
|
|
214
|
+
"""Attempt to close a task detail view or navigate back."""
|
|
215
|
+
|
|
216
|
+
for name in ("Close", "Back", "Done"):
|
|
217
|
+
button = page.get_by_role("button", name=name)
|
|
218
|
+
if await button.count():
|
|
219
|
+
await button.first().click()
|
|
220
|
+
await page.wait_for_timeout(500)
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
await page.go_back(wait_until="domcontentloaded")
|
|
225
|
+
await wait_for_task_list(page)
|
|
226
|
+
except PlaywrightError:
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
async def _post_action_delay(page: Page) -> None:
|
|
231
|
+
"""Add a small randomized delay to appear human-like."""
|
|
232
|
+
|
|
233
|
+
delay_ms = random.randint(1000, 2500)
|
|
234
|
+
await page.wait_for_timeout(delay_ms)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
async def run() -> None:
|
|
238
|
+
"""Entry point for running the Playwright automation."""
|
|
239
|
+
|
|
240
|
+
playwright = await async_playwright().start()
|
|
241
|
+
browser: Browser | None = None
|
|
242
|
+
context: BrowserContext | None = None
|
|
243
|
+
try:
|
|
244
|
+
browser = await playwright.chromium.launch(headless=False)
|
|
245
|
+
context_kwargs = {}
|
|
246
|
+
if AUTH_STATE_PATH.exists():
|
|
247
|
+
context_kwargs["storage_state"] = str(AUTH_STATE_PATH)
|
|
248
|
+
context = await browser.new_context(**context_kwargs)
|
|
249
|
+
page = await context.new_page()
|
|
250
|
+
|
|
251
|
+
await ensure_logged_in(page)
|
|
252
|
+
await wait_for_task_list(page)
|
|
253
|
+
await process_tasks(page)
|
|
254
|
+
|
|
255
|
+
await context.storage_state(path=str(AUTH_STATE_PATH))
|
|
256
|
+
print(f"💾 Authentication state saved to {AUTH_STATE_PATH}.")
|
|
257
|
+
finally:
|
|
258
|
+
if context is not None:
|
|
259
|
+
await context.close()
|
|
260
|
+
if browser is not None:
|
|
261
|
+
await browser.close()
|
|
262
|
+
await playwright.stop()
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def main() -> None:
|
|
266
|
+
"""Synchronous wrapper for asyncio entry point."""
|
|
267
|
+
|
|
268
|
+
asyncio.run(run())
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
if __name__ == "__main__":
|
|
272
|
+
main()
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Shared configuration for Codex automation workflows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
DEFAULT_ASSISTANT_HANDLE = "coderabbitai"
|
|
9
|
+
|
|
10
|
+
# Core instruction template with hardcoded AI assistant mentions
|
|
11
|
+
CODEX_COMMENT_TEMPLATE = (
|
|
12
|
+
"@codex @coderabbitai @copilot @cursor [AI automation] Please make the following changes to this PR\n\n"
|
|
13
|
+
"Use your judgment to fix comments from everyone or explain why it should not be fixed. "
|
|
14
|
+
"Follow binary response protocol - every comment needs \"DONE\" or \"NOT DONE\" classification "
|
|
15
|
+
"explicitly with an explanation. Address all comments on this PR. Fix any failing tests and "
|
|
16
|
+
"resolve merge conflicts. Push any commits needed to remote so the PR is updated."
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
CODEX_COMMIT_MARKER_PREFIX = "<!-- codex-automation-commit:"
|
|
20
|
+
CODEX_COMMIT_MARKER_SUFFIX = "-->"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def normalise_handle(assistant_handle: str | None) -> str:
|
|
24
|
+
"""Return a sanitized assistant handle without a leading '@'."""
|
|
25
|
+
|
|
26
|
+
if assistant_handle is None:
|
|
27
|
+
return DEFAULT_ASSISTANT_HANDLE
|
|
28
|
+
|
|
29
|
+
# Treat an empty string as "unspecified" so we fall back to the default
|
|
30
|
+
# handle rather than emitting a bare "@" mention in comments.
|
|
31
|
+
cleaned = assistant_handle.lstrip("@")
|
|
32
|
+
return cleaned or DEFAULT_ASSISTANT_HANDLE
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_default_comment(assistant_handle: str | None = None) -> str:
|
|
36
|
+
"""Return the default Codex instruction text for the given handle."""
|
|
37
|
+
|
|
38
|
+
handle = normalise_handle(assistant_handle)
|
|
39
|
+
return CODEX_COMMENT_TEMPLATE.format(assistant_handle=handle)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class CodexConfig:
|
|
44
|
+
"""Convenience container for sharing Codex automation constants."""
|
|
45
|
+
|
|
46
|
+
assistant_handle: str
|
|
47
|
+
comment_text: str
|
|
48
|
+
commit_marker_prefix: str = CODEX_COMMIT_MARKER_PREFIX
|
|
49
|
+
commit_marker_suffix: str = CODEX_COMMIT_MARKER_SUFFIX
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_env(cls, assistant_handle: str | None) -> "CodexConfig":
|
|
53
|
+
handle = normalise_handle(assistant_handle)
|
|
54
|
+
return cls(
|
|
55
|
+
assistant_handle=handle,
|
|
56
|
+
comment_text=build_default_comment(handle),
|
|
57
|
+
)
|