@jetrabbits/agentic 0.0.4 → 0.1.0

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 (49) hide show
  1. package/AGENTS.md +16 -0
  2. package/Makefile +40 -0
  3. package/README.md +3 -0
  4. package/UPGRADE.md +61 -0
  5. package/agentic +1236 -13
  6. package/areas/software/full-stack/AGENTS.md +1 -4
  7. package/areas/software/full-stack/workflows/debug-issue.md +2 -2
  8. package/docs/agentic-lifecycle.md +114 -0
  9. package/docs/agentic-token-minimization/README.md +81 -0
  10. package/docs/agentic-usage.md +157 -0
  11. package/docs/catalog.schema.json +203 -0
  12. package/docs/guidance-updates/2026-04-10-software-devops-best-practices.md +26 -0
  13. package/docs/opencode_prepare_agents.md +40 -0
  14. package/docs/opencode_setup.md +48 -0
  15. package/docs/prompt-format.md +80 -0
  16. package/docs/site/README.md +44 -0
  17. package/docs/site/app.js +127 -0
  18. package/docs/site/catalog.json +5002 -0
  19. package/docs/site/index.html +52 -0
  20. package/docs/site/styles.css +177 -0
  21. package/extensions/codex/agents/developer.toml +1 -1
  22. package/extensions/codex/agents/devops-engineer.toml +1 -1
  23. package/extensions/codex/agents/product-owner.toml +1 -1
  24. package/extensions/codex/agents/team-lead.toml +1 -1
  25. package/extensions/opencode/plugins/model-checker.json +2 -3
  26. package/extensions/opencode/plugins/model-checker.ts +23 -0
  27. package/extensions/opencode/plugins/telegram-notification.ts +33 -5
  28. package/package.json +6 -2
  29. package/scripts/assess_area_quality.py +216 -0
  30. package/scripts/build_docs_catalog.py +283 -0
  31. package/scripts/lint_prompts.py +113 -0
  32. package/areas/software/full-stack/skills/bash-pro/SKILL.md +0 -310
  33. package/areas/software/full-stack/skills/python-pro/SKILL.md +0 -158
  34. package/areas/software/full-stack/skills/skill-creator/LICENSE.txt +0 -202
  35. package/areas/software/full-stack/skills/skill-creator/SKILL.md +0 -356
  36. package/areas/software/full-stack/skills/skill-creator/references/output-patterns.md +0 -82
  37. package/areas/software/full-stack/skills/skill-creator/references/workflows.md +0 -28
  38. package/areas/software/full-stack/skills/skill-creator/scripts/init_skill.py +0 -303
  39. package/areas/software/full-stack/skills/skill-creator/scripts/package_skill.py +0 -110
  40. package/areas/software/full-stack/skills/skill-creator/scripts/quick_validate.py +0 -95
  41. package/extensions/codex/skills/babysit-pr/SKILL.md +0 -187
  42. package/extensions/codex/skills/babysit-pr/agents/openai.yaml +0 -4
  43. package/extensions/codex/skills/babysit-pr/references/github-api-notes.md +0 -72
  44. package/extensions/codex/skills/babysit-pr/references/heuristics.md +0 -58
  45. package/extensions/codex/skills/babysit-pr/scripts/gh_pr_watch.py +0 -806
  46. package/extensions/codex/skills/babysit-pr/scripts/test_gh_pr_watch.py +0 -155
  47. package/extensions/opencode/skills/code_review_expert/SKILL.md +0 -144
  48. package/extensions/opencode/skills/design_expert/SKILL.md +0 -42
  49. package/extensions/opencode/skills/qa_expert/SKILL.md +0 -116
@@ -1,806 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Watch GitHub PR CI and review activity for Codex PR babysitting workflows."""
3
-
4
- import argparse
5
- import json
6
- import os
7
- import re
8
- import subprocess
9
- import sys
10
- import tempfile
11
- import time
12
- from pathlib import Path
13
- from urllib.parse import urlparse
14
-
15
- FAILED_RUN_CONCLUSIONS = {
16
- "failure",
17
- "timed_out",
18
- "cancelled",
19
- "action_required",
20
- "startup_failure",
21
- "stale",
22
- }
23
- PENDING_CHECK_STATES = {
24
- "QUEUED",
25
- "IN_PROGRESS",
26
- "PENDING",
27
- "WAITING",
28
- "REQUESTED",
29
- }
30
- REVIEW_BOT_LOGIN_KEYWORDS = {
31
- "codex",
32
- }
33
- TRUSTED_AUTHOR_ASSOCIATIONS = {
34
- "OWNER",
35
- "MEMBER",
36
- "COLLABORATOR",
37
- }
38
- MERGE_BLOCKING_REVIEW_DECISIONS = {
39
- "REVIEW_REQUIRED",
40
- "CHANGES_REQUESTED",
41
- }
42
- MERGE_CONFLICT_OR_BLOCKING_STATES = {
43
- "BLOCKED",
44
- "DIRTY",
45
- "DRAFT",
46
- "UNKNOWN",
47
- }
48
-
49
-
50
- class GhCommandError(RuntimeError):
51
- pass
52
-
53
-
54
- def parse_args():
55
- parser = argparse.ArgumentParser(
56
- description=(
57
- "Normalize PR/CI/review state for Codex PR babysitting and optionally "
58
- "trigger flaky reruns."
59
- )
60
- )
61
- parser.add_argument("--pr", default="auto", help="auto, PR number, or PR URL")
62
- parser.add_argument("--repo", help="Optional OWNER/REPO override")
63
- parser.add_argument("--poll-seconds", type=int, default=30, help="Watch poll interval")
64
- parser.add_argument(
65
- "--max-flaky-retries",
66
- type=int,
67
- default=3,
68
- help="Max rerun cycles per head SHA before stop recommendation",
69
- )
70
- parser.add_argument("--state-file", help="Path to state JSON file")
71
- parser.add_argument("--once", action="store_true", help="Emit one snapshot and exit")
72
- parser.add_argument("--watch", action="store_true", help="Continuously emit JSONL snapshots")
73
- parser.add_argument(
74
- "--retry-failed-now",
75
- action="store_true",
76
- help="Rerun failed jobs for current failed workflow runs when policy allows",
77
- )
78
- parser.add_argument(
79
- "--json",
80
- action="store_true",
81
- help="Emit machine-readable output (default behavior for --once and --retry-failed-now)",
82
- )
83
- args = parser.parse_args()
84
-
85
- if args.poll_seconds <= 0:
86
- parser.error("--poll-seconds must be > 0")
87
- if args.max_flaky_retries < 0:
88
- parser.error("--max-flaky-retries must be >= 0")
89
- if args.watch and args.retry_failed_now:
90
- parser.error("--watch cannot be combined with --retry-failed-now")
91
- if not args.once and not args.watch and not args.retry_failed_now:
92
- args.once = True
93
- return args
94
-
95
-
96
- def _format_gh_error(cmd, err):
97
- stdout = (err.stdout or "").strip()
98
- stderr = (err.stderr or "").strip()
99
- parts = [f"GitHub CLI command failed: {' '.join(cmd)}"]
100
- if stdout:
101
- parts.append(f"stdout: {stdout}")
102
- if stderr:
103
- parts.append(f"stderr: {stderr}")
104
- return "\n".join(parts)
105
-
106
-
107
- def gh_text(args, repo=None):
108
- cmd = ["gh"]
109
- # `gh api` does not accept `-R/--repo` on all gh versions. The watcher's
110
- # API calls use explicit endpoints (e.g. repos/{owner}/{repo}/...), so the
111
- # repo flag is unnecessary there.
112
- if repo and (not args or args[0] != "api"):
113
- cmd.extend(["-R", repo])
114
- cmd.extend(args)
115
- try:
116
- proc = subprocess.run(cmd, check=True, capture_output=True, text=True)
117
- except FileNotFoundError as err:
118
- raise GhCommandError("`gh` command not found") from err
119
- except subprocess.CalledProcessError as err:
120
- raise GhCommandError(_format_gh_error(cmd, err)) from err
121
- return proc.stdout
122
-
123
-
124
- def gh_json(args, repo=None):
125
- raw = gh_text(args, repo=repo).strip()
126
- if not raw:
127
- return None
128
- try:
129
- return json.loads(raw)
130
- except json.JSONDecodeError as err:
131
- raise GhCommandError(f"Failed to parse JSON from gh output for {' '.join(args)}") from err
132
-
133
-
134
- def parse_pr_spec(pr_spec):
135
- if pr_spec == "auto":
136
- return {"mode": "auto", "value": None}
137
- if re.fullmatch(r"\d+", pr_spec):
138
- return {"mode": "number", "value": pr_spec}
139
- parsed = urlparse(pr_spec)
140
- if parsed.scheme and parsed.netloc and "/pull/" in parsed.path:
141
- return {"mode": "url", "value": pr_spec}
142
- raise ValueError("--pr must be 'auto', a PR number, or a PR URL")
143
-
144
-
145
- def pr_view_fields():
146
- return (
147
- "number,url,state,mergedAt,closedAt,headRefName,headRefOid,"
148
- "headRepository,headRepositoryOwner,mergeable,mergeStateStatus,reviewDecision"
149
- )
150
-
151
-
152
- def checks_fields():
153
- return "name,state,bucket,link,workflow,event,startedAt,completedAt"
154
-
155
-
156
- def resolve_pr(pr_spec, repo_override=None):
157
- parsed = parse_pr_spec(pr_spec)
158
- cmd = ["pr", "view"]
159
- if parsed["value"] is not None:
160
- cmd.append(parsed["value"])
161
- cmd.extend(["--json", pr_view_fields()])
162
- data = gh_json(cmd, repo=repo_override)
163
- if not isinstance(data, dict):
164
- raise GhCommandError("Unexpected PR payload from `gh pr view`")
165
-
166
- pr_url = str(data.get("url") or "")
167
- repo = (
168
- repo_override
169
- or extract_repo_from_pr_url(pr_url)
170
- or extract_repo_from_pr_view(data)
171
- )
172
- if not repo:
173
- raise GhCommandError("Unable to determine OWNER/REPO for the PR")
174
-
175
- state = str(data.get("state") or "")
176
- merged = bool(data.get("mergedAt"))
177
- closed = bool(data.get("closedAt")) or state.upper() == "CLOSED"
178
-
179
- return {
180
- "number": int(data["number"]),
181
- "url": pr_url,
182
- "repo": repo,
183
- "head_sha": str(data.get("headRefOid") or ""),
184
- "head_branch": str(data.get("headRefName") or ""),
185
- "state": state,
186
- "merged": merged,
187
- "closed": closed,
188
- "mergeable": str(data.get("mergeable") or ""),
189
- "merge_state_status": str(data.get("mergeStateStatus") or ""),
190
- "review_decision": str(data.get("reviewDecision") or ""),
191
- }
192
-
193
-
194
- def extract_repo_from_pr_view(data):
195
- head_repo = data.get("headRepository")
196
- head_owner = data.get("headRepositoryOwner")
197
- owner = None
198
- name = None
199
- if isinstance(head_owner, dict):
200
- owner = head_owner.get("login") or head_owner.get("name")
201
- elif isinstance(head_owner, str):
202
- owner = head_owner
203
- if isinstance(head_repo, dict):
204
- name = head_repo.get("name")
205
- repo_owner = head_repo.get("owner")
206
- if not owner and isinstance(repo_owner, dict):
207
- owner = repo_owner.get("login") or repo_owner.get("name")
208
- elif isinstance(head_repo, str):
209
- name = head_repo
210
- if owner and name:
211
- return f"{owner}/{name}"
212
- return None
213
- def extract_repo_from_pr_url(pr_url):
214
- parsed = urlparse(pr_url)
215
- parts = [p for p in parsed.path.split("/") if p]
216
- if len(parts) >= 4 and parts[2] == "pull":
217
- return f"{parts[0]}/{parts[1]}"
218
- return None
219
-
220
-
221
- def load_state(path):
222
- if path.exists():
223
- try:
224
- data = json.loads(path.read_text())
225
- except json.JSONDecodeError as err:
226
- raise RuntimeError(f"State file is not valid JSON: {path}") from err
227
- if not isinstance(data, dict):
228
- raise RuntimeError(f"State file must contain an object: {path}")
229
- return data, False
230
- return {
231
- "pr": {},
232
- "started_at": None,
233
- "last_seen_head_sha": None,
234
- "retries_by_sha": {},
235
- "seen_issue_comment_ids": [],
236
- "seen_review_comment_ids": [],
237
- "seen_review_ids": [],
238
- "last_snapshot_at": None,
239
- }, True
240
-
241
-
242
- def save_state(path, state):
243
- path.parent.mkdir(parents=True, exist_ok=True)
244
- payload = json.dumps(state, indent=2, sort_keys=True) + "\n"
245
- fd, tmp_name = tempfile.mkstemp(prefix=f"{path.name}.", suffix=".tmp", dir=path.parent)
246
- tmp_path = Path(tmp_name)
247
- try:
248
- with os.fdopen(fd, "w", encoding="utf-8") as tmp_file:
249
- tmp_file.write(payload)
250
- os.replace(tmp_path, path)
251
- except Exception:
252
- try:
253
- tmp_path.unlink(missing_ok=True)
254
- except OSError:
255
- pass
256
- raise
257
-
258
-
259
- def default_state_file_for(pr):
260
- repo_slug = pr["repo"].replace("/", "-")
261
- return Path(f"/tmp/codex-babysit-pr-{repo_slug}-pr{pr['number']}.json")
262
-
263
-
264
- def get_pr_checks(pr_spec, repo):
265
- parsed = parse_pr_spec(pr_spec)
266
- cmd = ["pr", "checks"]
267
- if parsed["value"] is not None:
268
- cmd.append(parsed["value"])
269
- cmd.extend(["--json", checks_fields()])
270
- data = gh_json(cmd, repo=repo)
271
- if data is None:
272
- return []
273
- if not isinstance(data, list):
274
- raise GhCommandError("Unexpected payload from `gh pr checks`")
275
- return data
276
-
277
-
278
- def is_pending_check(check):
279
- bucket = str(check.get("bucket") or "").lower()
280
- state = str(check.get("state") or "").upper()
281
- return bucket == "pending" or state in PENDING_CHECK_STATES
282
-
283
-
284
- def summarize_checks(checks):
285
- pending_count = 0
286
- failed_count = 0
287
- passed_count = 0
288
- for check in checks:
289
- bucket = str(check.get("bucket") or "").lower()
290
- if is_pending_check(check):
291
- pending_count += 1
292
- if bucket == "fail":
293
- failed_count += 1
294
- if bucket == "pass":
295
- passed_count += 1
296
- return {
297
- "pending_count": pending_count,
298
- "failed_count": failed_count,
299
- "passed_count": passed_count,
300
- "all_terminal": pending_count == 0,
301
- }
302
-
303
-
304
- def get_workflow_runs_for_sha(repo, head_sha):
305
- endpoint = f"repos/{repo}/actions/runs"
306
- data = gh_json(
307
- ["api", endpoint, "-X", "GET", "-f", f"head_sha={head_sha}", "-f", "per_page=100"],
308
- repo=repo,
309
- )
310
- if not isinstance(data, dict):
311
- raise GhCommandError("Unexpected payload from actions runs API")
312
- runs = data.get("workflow_runs") or []
313
- if not isinstance(runs, list):
314
- raise GhCommandError("Expected `workflow_runs` to be a list")
315
- return runs
316
-
317
-
318
- def failed_runs_from_workflow_runs(runs, head_sha):
319
- failed_runs = []
320
- for run in runs:
321
- if not isinstance(run, dict):
322
- continue
323
- if str(run.get("head_sha") or "") != head_sha:
324
- continue
325
- conclusion = str(run.get("conclusion") or "")
326
- if conclusion not in FAILED_RUN_CONCLUSIONS:
327
- continue
328
- failed_runs.append(
329
- {
330
- "run_id": run.get("id"),
331
- "workflow_name": run.get("name") or run.get("display_title") or "",
332
- "status": str(run.get("status") or ""),
333
- "conclusion": conclusion,
334
- "html_url": str(run.get("html_url") or ""),
335
- }
336
- )
337
- failed_runs.sort(key=lambda item: (str(item.get("workflow_name") or ""), str(item.get("run_id") or "")))
338
- return failed_runs
339
-
340
-
341
- def get_authenticated_login():
342
- data = gh_json(["api", "user"])
343
- if not isinstance(data, dict) or not data.get("login"):
344
- raise GhCommandError("Unable to determine authenticated GitHub login from `gh api user`")
345
- return str(data["login"])
346
-
347
-
348
- def comment_endpoints(repo, pr_number):
349
- return {
350
- "issue_comment": f"repos/{repo}/issues/{pr_number}/comments",
351
- "review_comment": f"repos/{repo}/pulls/{pr_number}/comments",
352
- "review": f"repos/{repo}/pulls/{pr_number}/reviews",
353
- }
354
-
355
-
356
- def gh_api_list_paginated(endpoint, repo=None, per_page=100):
357
- items = []
358
- page = 1
359
- while True:
360
- sep = "&" if "?" in endpoint else "?"
361
- page_endpoint = f"{endpoint}{sep}per_page={per_page}&page={page}"
362
- payload = gh_json(["api", page_endpoint], repo=repo)
363
- if payload is None:
364
- break
365
- if not isinstance(payload, list):
366
- raise GhCommandError(f"Unexpected paginated payload from gh api {endpoint}")
367
- items.extend(payload)
368
- if len(payload) < per_page:
369
- break
370
- page += 1
371
- return items
372
-
373
-
374
- def normalize_issue_comments(items):
375
- out = []
376
- for item in items:
377
- if not isinstance(item, dict):
378
- continue
379
- out.append(
380
- {
381
- "kind": "issue_comment",
382
- "id": str(item.get("id") or ""),
383
- "author": extract_login(item.get("user")),
384
- "author_association": str(item.get("author_association") or ""),
385
- "created_at": str(item.get("created_at") or ""),
386
- "body": str(item.get("body") or ""),
387
- "path": None,
388
- "line": None,
389
- "url": str(item.get("html_url") or ""),
390
- }
391
- )
392
- return out
393
-
394
-
395
- def normalize_review_comments(items):
396
- out = []
397
- for item in items:
398
- if not isinstance(item, dict):
399
- continue
400
- line = item.get("line")
401
- if line is None:
402
- line = item.get("original_line")
403
- out.append(
404
- {
405
- "kind": "review_comment",
406
- "id": str(item.get("id") or ""),
407
- "author": extract_login(item.get("user")),
408
- "author_association": str(item.get("author_association") or ""),
409
- "created_at": str(item.get("created_at") or ""),
410
- "body": str(item.get("body") or ""),
411
- "path": item.get("path"),
412
- "line": line,
413
- "url": str(item.get("html_url") or ""),
414
- }
415
- )
416
- return out
417
-
418
-
419
- def normalize_reviews(items):
420
- out = []
421
- for item in items:
422
- if not isinstance(item, dict):
423
- continue
424
- out.append(
425
- {
426
- "kind": "review",
427
- "id": str(item.get("id") or ""),
428
- "author": extract_login(item.get("user")),
429
- "author_association": str(item.get("author_association") or ""),
430
- "created_at": str(item.get("submitted_at") or item.get("created_at") or ""),
431
- "body": str(item.get("body") or ""),
432
- "path": None,
433
- "line": None,
434
- "url": str(item.get("html_url") or ""),
435
- }
436
- )
437
- return out
438
-
439
-
440
- def extract_login(user_obj):
441
- if isinstance(user_obj, dict):
442
- return str(user_obj.get("login") or "")
443
- return ""
444
-
445
-
446
- def is_bot_login(login):
447
- return bool(login) and login.endswith("[bot]")
448
-
449
-
450
- def is_actionable_review_bot_login(login):
451
- if not is_bot_login(login):
452
- return False
453
- lower_login = login.lower()
454
- return any(keyword in lower_login for keyword in REVIEW_BOT_LOGIN_KEYWORDS)
455
-
456
-
457
- def is_trusted_human_review_author(item, authenticated_login):
458
- author = str(item.get("author") or "")
459
- if not author:
460
- return False
461
- if authenticated_login and author == authenticated_login:
462
- return True
463
- association = str(item.get("author_association") or "").upper()
464
- return association in TRUSTED_AUTHOR_ASSOCIATIONS
465
-
466
-
467
- def fetch_new_review_items(pr, state, fresh_state, authenticated_login=None):
468
- repo = pr["repo"]
469
- pr_number = pr["number"]
470
- endpoints = comment_endpoints(repo, pr_number)
471
-
472
- issue_payload = gh_api_list_paginated(endpoints["issue_comment"], repo=repo)
473
- review_comment_payload = gh_api_list_paginated(endpoints["review_comment"], repo=repo)
474
- review_payload = gh_api_list_paginated(endpoints["review"], repo=repo)
475
-
476
- issue_items = normalize_issue_comments(issue_payload)
477
- review_comment_items = normalize_review_comments(review_comment_payload)
478
- review_items = normalize_reviews(review_payload)
479
- all_items = issue_items + review_comment_items + review_items
480
-
481
- seen_issue = {str(x) for x in state.get("seen_issue_comment_ids") or []}
482
- seen_review_comment = {str(x) for x in state.get("seen_review_comment_ids") or []}
483
- seen_review = {str(x) for x in state.get("seen_review_ids") or []}
484
-
485
- # On a brand-new state file, surface existing review activity instead of
486
- # silently treating it as seen. This avoids missing already-pending review
487
- # feedback when monitoring starts after comments were posted.
488
-
489
- new_items = []
490
- for item in all_items:
491
- item_id = item.get("id")
492
- if not item_id:
493
- continue
494
- author = item.get("author") or ""
495
- if not author:
496
- continue
497
- if is_bot_login(author):
498
- if not is_actionable_review_bot_login(author):
499
- continue
500
- elif not is_trusted_human_review_author(item, authenticated_login):
501
- continue
502
-
503
- kind = item["kind"]
504
- if kind == "issue_comment" and item_id in seen_issue:
505
- continue
506
- if kind == "review_comment" and item_id in seen_review_comment:
507
- continue
508
- if kind == "review" and item_id in seen_review:
509
- continue
510
-
511
- new_items.append(item)
512
- if kind == "issue_comment":
513
- seen_issue.add(item_id)
514
- elif kind == "review_comment":
515
- seen_review_comment.add(item_id)
516
- elif kind == "review":
517
- seen_review.add(item_id)
518
-
519
- new_items.sort(key=lambda item: (item.get("created_at") or "", item.get("kind") or "", item.get("id") or ""))
520
- state["seen_issue_comment_ids"] = sorted(seen_issue)
521
- state["seen_review_comment_ids"] = sorted(seen_review_comment)
522
- state["seen_review_ids"] = sorted(seen_review)
523
- return new_items
524
-
525
-
526
- def current_retry_count(state, head_sha):
527
- retries = state.get("retries_by_sha") or {}
528
- value = retries.get(head_sha, 0)
529
- try:
530
- return int(value)
531
- except (TypeError, ValueError):
532
- return 0
533
-
534
-
535
- def set_retry_count(state, head_sha, count):
536
- retries = state.get("retries_by_sha")
537
- if not isinstance(retries, dict):
538
- retries = {}
539
- retries[head_sha] = int(count)
540
- state["retries_by_sha"] = retries
541
-
542
-
543
- def unique_actions(actions):
544
- out = []
545
- seen = set()
546
- for action in actions:
547
- if action not in seen:
548
- out.append(action)
549
- seen.add(action)
550
- return out
551
-
552
-
553
- def is_pr_ready_to_merge(pr, checks_summary, new_review_items):
554
- if pr["closed"] or pr["merged"]:
555
- return False
556
- if not checks_summary["all_terminal"]:
557
- return False
558
- if checks_summary["failed_count"] > 0 or checks_summary["pending_count"] > 0:
559
- return False
560
- if new_review_items:
561
- return False
562
- if str(pr.get("mergeable") or "") != "MERGEABLE":
563
- return False
564
- if str(pr.get("merge_state_status") or "") in MERGE_CONFLICT_OR_BLOCKING_STATES:
565
- return False
566
- if str(pr.get("review_decision") or "") in MERGE_BLOCKING_REVIEW_DECISIONS:
567
- return False
568
- return True
569
-
570
-
571
- def recommend_actions(pr, checks_summary, failed_runs, new_review_items, retries_used, max_retries):
572
- actions = []
573
- if pr["closed"] or pr["merged"]:
574
- if new_review_items:
575
- actions.append("process_review_comment")
576
- actions.append("stop_pr_closed")
577
- return unique_actions(actions)
578
-
579
- if is_pr_ready_to_merge(pr, checks_summary, new_review_items):
580
- actions.append("ready_to_merge")
581
- return unique_actions(actions)
582
-
583
- if new_review_items:
584
- actions.append("process_review_comment")
585
-
586
- has_failed_pr_checks = checks_summary["failed_count"] > 0
587
- if has_failed_pr_checks:
588
- if checks_summary["all_terminal"] and retries_used >= max_retries:
589
- actions.append("stop_exhausted_retries")
590
- else:
591
- actions.append("diagnose_ci_failure")
592
- if checks_summary["all_terminal"] and failed_runs and retries_used < max_retries:
593
- actions.append("retry_failed_checks")
594
-
595
- if not actions:
596
- actions.append("idle")
597
- return unique_actions(actions)
598
-
599
-
600
- def collect_snapshot(args):
601
- pr = resolve_pr(args.pr, repo_override=args.repo)
602
- state_path = Path(args.state_file) if args.state_file else default_state_file_for(pr)
603
- state, fresh_state = load_state(state_path)
604
-
605
- if not state.get("started_at"):
606
- state["started_at"] = int(time.time())
607
-
608
- authenticated_login = get_authenticated_login()
609
- new_review_items = fetch_new_review_items(
610
- pr,
611
- state,
612
- fresh_state=fresh_state,
613
- authenticated_login=authenticated_login,
614
- )
615
- # Surface review feedback before drilling into CI and mergeability details.
616
- # That keeps the babysitter responsive to new comments even when other
617
- # actions are also available.
618
- # `gh pr checks -R <repo>` requires an explicit PR/branch/url argument.
619
- # After resolving `--pr auto`, reuse the concrete PR number.
620
- checks = get_pr_checks(str(pr["number"]), repo=pr["repo"])
621
- checks_summary = summarize_checks(checks)
622
- workflow_runs = get_workflow_runs_for_sha(pr["repo"], pr["head_sha"])
623
- failed_runs = failed_runs_from_workflow_runs(workflow_runs, pr["head_sha"])
624
-
625
- retries_used = current_retry_count(state, pr["head_sha"])
626
- actions = recommend_actions(
627
- pr,
628
- checks_summary,
629
- failed_runs,
630
- new_review_items,
631
- retries_used,
632
- args.max_flaky_retries,
633
- )
634
-
635
- state["pr"] = {"repo": pr["repo"], "number": pr["number"]}
636
- state["last_seen_head_sha"] = pr["head_sha"]
637
- state["last_snapshot_at"] = int(time.time())
638
- save_state(state_path, state)
639
-
640
- snapshot = {
641
- "pr": pr,
642
- "checks": checks_summary,
643
- "failed_runs": failed_runs,
644
- "new_review_items": new_review_items,
645
- "actions": actions,
646
- "retry_state": {
647
- "current_sha_retries_used": retries_used,
648
- "max_flaky_retries": args.max_flaky_retries,
649
- },
650
- }
651
- return snapshot, state_path
652
-
653
-
654
- def retry_failed_now(args):
655
- snapshot, state_path = collect_snapshot(args)
656
- pr = snapshot["pr"]
657
- checks_summary = snapshot["checks"]
658
- failed_runs = snapshot["failed_runs"]
659
- retries_used = snapshot["retry_state"]["current_sha_retries_used"]
660
- max_retries = snapshot["retry_state"]["max_flaky_retries"]
661
-
662
- result = {
663
- "snapshot": snapshot,
664
- "state_file": str(state_path),
665
- "rerun_attempted": False,
666
- "rerun_count": 0,
667
- "rerun_run_ids": [],
668
- "reason": None,
669
- }
670
-
671
- if pr["closed"] or pr["merged"]:
672
- result["reason"] = "pr_closed"
673
- return result
674
- if checks_summary["failed_count"] <= 0:
675
- result["reason"] = "no_failed_pr_checks"
676
- return result
677
- if not failed_runs:
678
- result["reason"] = "no_failed_runs"
679
- return result
680
- if not checks_summary["all_terminal"]:
681
- result["reason"] = "checks_still_pending"
682
- return result
683
- if retries_used >= max_retries:
684
- result["reason"] = "retry_budget_exhausted"
685
- return result
686
-
687
- for run in failed_runs:
688
- run_id = run.get("run_id")
689
- if run_id in (None, ""):
690
- continue
691
- gh_text(["run", "rerun", str(run_id), "--failed"], repo=pr["repo"])
692
- result["rerun_run_ids"].append(run_id)
693
-
694
- if result["rerun_run_ids"]:
695
- state, _ = load_state(state_path)
696
- new_count = current_retry_count(state, pr["head_sha"]) + 1
697
- set_retry_count(state, pr["head_sha"], new_count)
698
- state["last_snapshot_at"] = int(time.time())
699
- save_state(state_path, state)
700
- result["rerun_attempted"] = True
701
- result["rerun_count"] = len(result["rerun_run_ids"])
702
- result["reason"] = "rerun_triggered"
703
- else:
704
- result["reason"] = "failed_runs_missing_ids"
705
-
706
- return result
707
-
708
-
709
- def print_json(obj):
710
- sys.stdout.write(json.dumps(obj, sort_keys=True) + "\n")
711
- sys.stdout.flush()
712
-
713
-
714
- def print_event(event, payload):
715
- print_json({"event": event, "payload": payload})
716
-
717
-
718
- def is_ci_green(snapshot):
719
- checks = snapshot.get("checks") or {}
720
- return (
721
- bool(checks.get("all_terminal"))
722
- and int(checks.get("failed_count") or 0) == 0
723
- and int(checks.get("pending_count") or 0) == 0
724
- )
725
-
726
-
727
- def snapshot_change_key(snapshot):
728
- pr = snapshot.get("pr") or {}
729
- checks = snapshot.get("checks") or {}
730
- review_items = snapshot.get("new_review_items") or []
731
- return (
732
- str(pr.get("head_sha") or ""),
733
- str(pr.get("state") or ""),
734
- str(pr.get("mergeable") or ""),
735
- str(pr.get("merge_state_status") or ""),
736
- str(pr.get("review_decision") or ""),
737
- int(checks.get("passed_count") or 0),
738
- int(checks.get("failed_count") or 0),
739
- int(checks.get("pending_count") or 0),
740
- tuple(
741
- (str(item.get("kind") or ""), str(item.get("id") or ""))
742
- for item in review_items
743
- if isinstance(item, dict)
744
- ),
745
- tuple(snapshot.get("actions") or []),
746
- )
747
-
748
-
749
- def run_watch(args):
750
- poll_seconds = args.poll_seconds
751
- last_change_key = None
752
- while True:
753
- snapshot, state_path = collect_snapshot(args)
754
- print_event(
755
- "snapshot",
756
- {
757
- "snapshot": snapshot,
758
- "state_file": str(state_path),
759
- "next_poll_seconds": poll_seconds,
760
- },
761
- )
762
- actions = set(snapshot.get("actions") or [])
763
- if (
764
- "stop_pr_closed" in actions
765
- or "stop_exhausted_retries" in actions
766
- ):
767
- print_event("stop", {"actions": snapshot.get("actions"), "pr": snapshot.get("pr")})
768
- return 0
769
-
770
- current_change_key = snapshot_change_key(snapshot)
771
- changed = current_change_key != last_change_key
772
- green = is_ci_green(snapshot)
773
- pr = snapshot.get("pr") or {}
774
- pr_open = not bool(pr.get("closed")) and not bool(pr.get("merged"))
775
-
776
- if not green or pr_open:
777
- poll_seconds = args.poll_seconds
778
- elif changed or last_change_key is None:
779
- poll_seconds = args.poll_seconds
780
-
781
- last_change_key = current_change_key
782
- time.sleep(poll_seconds)
783
-
784
-
785
- def main():
786
- args = parse_args()
787
- try:
788
- if args.retry_failed_now:
789
- print_json(retry_failed_now(args))
790
- return 0
791
- if args.watch:
792
- return run_watch(args)
793
- snapshot, state_path = collect_snapshot(args)
794
- snapshot["state_file"] = str(state_path)
795
- print_json(snapshot)
796
- return 0
797
- except (GhCommandError, RuntimeError, ValueError) as err:
798
- sys.stderr.write(f"gh_pr_watch.py error: {err}\n")
799
- return 1
800
- except KeyboardInterrupt:
801
- sys.stderr.write("gh_pr_watch.py interrupted\n")
802
- return 130
803
-
804
-
805
- if __name__ == "__main__":
806
- raise SystemExit(main())