claude-dev-env 1.38.0 → 1.39.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 (271) hide show
  1. package/CLAUDE.md +10 -36
  2. package/_shared/pr-loop/audit-reply-template.md +147 -0
  3. package/_shared/pr-loop/fix-protocol.md +25 -4
  4. package/_shared/pr-loop/gh-payloads.md +37 -50
  5. package/_shared/pr-loop/scripts/code_rules_gate.py +0 -60
  6. package/_shared/pr-loop/scripts/config/post_audit_thread_constants.py +189 -0
  7. package/_shared/pr-loop/scripts/post_audit_thread.py +947 -0
  8. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +0 -19
  9. package/_shared/pr-loop/scripts/tests/test_post_audit_thread.py +923 -0
  10. package/_shared/pr-loop/scripts/tests/test_post_audit_thread_constants.py +127 -0
  11. package/_shared/pr-loop/state-schema.md +1 -1
  12. package/agents/clean-coder.md +2 -2
  13. package/bin/install.mjs +6 -7
  14. package/bin/install.test.mjs +8 -0
  15. package/commands/doc-gist.md +16 -0
  16. package/commands/plan.md +0 -2
  17. package/commands/review-plan.md +1 -1
  18. package/docs/CODE_RULES.md +122 -2
  19. package/hooks/blocking/bot_mention_comment_blocker.py +75 -0
  20. package/hooks/blocking/code_rules_enforcer.py +1236 -161
  21. package/hooks/blocking/convergence_gate_blocker.py +130 -0
  22. package/hooks/blocking/destructive_command_blocker.py +74 -0
  23. package/hooks/blocking/gh_body_arg_blocker.py +30 -0
  24. package/hooks/blocking/md_to_html_blocker.py +119 -0
  25. package/hooks/blocking/test_bot_mention_comment_blocker.py +131 -0
  26. package/hooks/blocking/test_code_rules_enforcer.py +21 -0
  27. package/hooks/blocking/test_code_rules_enforcer_any_exempt_files.py +70 -0
  28. package/hooks/blocking/test_code_rules_enforcer_any_imports_and_cast.py +92 -0
  29. package/hooks/blocking/test_code_rules_enforcer_banned_import_alias.py +143 -0
  30. package/hooks/blocking/test_code_rules_enforcer_banned_prefixes.py +152 -0
  31. package/hooks/blocking/test_code_rules_enforcer_bare_except.py +120 -0
  32. package/hooks/blocking/test_code_rules_enforcer_boundary_types.py +175 -0
  33. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -1
  34. package/hooks/blocking/test_code_rules_enforcer_collection_prefix.py +50 -0
  35. package/hooks/blocking/test_code_rules_enforcer_docstring_format.py +255 -0
  36. package/hooks/blocking/test_code_rules_enforcer_inline_tuple_string_magic.py +130 -0
  37. package/hooks/blocking/test_code_rules_enforcer_stub_implementations.py +141 -0
  38. package/hooks/blocking/test_code_rules_enforcer_test_branching.py +143 -0
  39. package/hooks/blocking/test_code_rules_enforcer_thin_wrapper_files.py +169 -0
  40. package/hooks/blocking/test_code_rules_enforcer_todo_markers.py +99 -0
  41. package/hooks/blocking/test_code_rules_enforcer_typed_dict_pairs.py +141 -0
  42. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +158 -0
  43. package/hooks/blocking/test_convergence_gate_blocker.py +63 -0
  44. package/hooks/blocking/test_destructive_command_blocker.py +146 -0
  45. package/hooks/blocking/test_destructive_command_blocker_no_verify.py +102 -0
  46. package/hooks/blocking/test_gh_body_arg_blocker.py +45 -0
  47. package/hooks/blocking/test_md_to_html_blocker.py +317 -0
  48. package/hooks/config/any_type_config.py +7 -0
  49. package/hooks/config/banned_identifiers_constants.py +11 -0
  50. package/hooks/config/blocking_check_limits.py +38 -0
  51. package/hooks/config/bot_mention_comment_blocker_constants.py +20 -0
  52. package/hooks/config/code_rules_enforcer_constants.py +53 -0
  53. package/hooks/config/convergence_branch_constants.py +9 -0
  54. package/hooks/config/doc_gist_auto_publish_constants.py +18 -0
  55. package/hooks/config/html_companion_constants.py +20 -0
  56. package/hooks/config/inline_tuple_string_magic_constants.py +22 -0
  57. package/hooks/config/test_banned_identifiers_constants.py +17 -0
  58. package/hooks/hooks.json +28 -20
  59. package/hooks/pyproject.toml +69 -0
  60. package/hooks/validators/mypy_integration.py +47 -1
  61. package/hooks/validators/run_all_validators.py +3 -3
  62. package/hooks/validators/test_mypy_integration.py +50 -1
  63. package/hooks/workflow/doc_gist_auto_publish.py +144 -0
  64. package/hooks/workflow/md_to_html_companion.py +365 -0
  65. package/hooks/workflow/test_doc_gist_auto_publish.py +117 -0
  66. package/hooks/workflow/test_md_to_html_companion.py +452 -0
  67. package/package.json +1 -1
  68. package/rules/gh-body-file.md +2 -0
  69. package/scripts/Install-SweepEmptyDirs.ps1 +111 -0
  70. package/scripts/check.ps1 +106 -0
  71. package/scripts/config/timing.py +11 -0
  72. package/scripts/sweep_empty_dirs.py +138 -0
  73. package/scripts/sync_to_cursor/rules.py +1 -1
  74. package/scripts/test_sweep_empty_dirs.py +183 -0
  75. package/skills/_shared/pr-loop/prompts/pr-consistency-audit.xml +323 -0
  76. package/skills/_shared/pr-loop/scripts/_cli_utils.py +22 -0
  77. package/skills/_shared/pr-loop/scripts/_path_resolver.py +165 -0
  78. package/skills/_shared/pr-loop/scripts/_xml_utils.py +20 -0
  79. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +182 -0
  80. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +185 -0
  81. package/skills/_shared/pr-loop/scripts/config/__init__.py +0 -0
  82. package/skills/_shared/pr-loop/scripts/config/path_resolver_constants.py +78 -0
  83. package/skills/_shared/pr-loop/scripts/init_loop_state.py +135 -0
  84. package/skills/_shared/pr-loop/scripts/teardown_worktrees.py +175 -0
  85. package/skills/_shared/pr-loop/scripts/write_audit_outcomes.py +182 -0
  86. package/skills/_shared/pr-loop/scripts/write_fix_outcomes.py +206 -0
  87. package/skills/bugteam/CONSTRAINTS.md +21 -22
  88. package/skills/bugteam/EXAMPLES.md +3 -3
  89. package/skills/bugteam/PROMPTS.md +227 -67
  90. package/skills/bugteam/SKILL.md +114 -455
  91. package/skills/bugteam/reference/README.md +1 -1
  92. package/skills/bugteam/reference/audit-and-teammates.md +112 -39
  93. package/skills/bugteam/reference/audit-contract.md +4 -22
  94. package/skills/bugteam/reference/copilot-gap-analysis.md +8 -5
  95. package/skills/bugteam/reference/design-rationale.md +2 -2
  96. package/skills/bugteam/reference/github-pr-reviews.md +50 -57
  97. package/skills/bugteam/reference/obstacles/audit-assign-ids.md +13 -0
  98. package/skills/bugteam/reference/obstacles/audit-capture-excerpts.md +13 -0
  99. package/skills/bugteam/reference/obstacles/audit-walk-categories.md +13 -0
  100. package/skills/bugteam/reference/obstacles/audit-write-xml.md +13 -0
  101. package/skills/bugteam/reference/obstacles/fix-append-summary.md +13 -0
  102. package/skills/bugteam/reference/obstacles/fix-apply-fixes.md +13 -0
  103. package/skills/bugteam/reference/obstacles/fix-git-add-commit.md +13 -0
  104. package/skills/bugteam/reference/obstacles/fix-git-push.md +13 -0
  105. package/skills/bugteam/reference/obstacles/fix-post-reply.md +13 -0
  106. package/skills/bugteam/reference/obstacles/fix-publish-summary.md +13 -0
  107. package/skills/bugteam/reference/obstacles/fix-py-compile.md +13 -0
  108. package/skills/bugteam/reference/obstacles/fix-read-files.md +13 -0
  109. package/skills/bugteam/reference/obstacles/fix-resolve-thread.md +13 -0
  110. package/skills/bugteam/reference/obstacles/fix-test-suite.md +13 -0
  111. package/skills/bugteam/reference/obstacles/fix-violation-count.md +13 -0
  112. package/skills/bugteam/reference/obstacles/fix-write-xml.md +13 -0
  113. package/skills/bugteam/reference/team-setup.md +106 -9
  114. package/skills/bugteam/reference/teardown-publish-permissions.md +39 -8
  115. package/skills/bugteam/scripts/README.md +60 -0
  116. package/skills/bugteam/scripts/_claude_permissions_common.py +358 -0
  117. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +976 -0
  118. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +375 -0
  119. package/skills/bugteam/scripts/bugteam_preflight.py +294 -0
  120. package/skills/bugteam/scripts/config/bugteam_code_rules_gate_constants.py +25 -0
  121. package/skills/bugteam/scripts/config/bugteam_fix_hookspath_constants.py +26 -0
  122. package/skills/bugteam/scripts/config/bugteam_preflight_constants.py +35 -0
  123. package/skills/bugteam/scripts/config/claude_permissions_common_constants.py +20 -0
  124. package/skills/bugteam/scripts/config/probe_code_rules_enforcer_check_constants.py +12 -0
  125. package/skills/bugteam/scripts/config/windows_safe_rmtree_constants.py +7 -0
  126. package/skills/bugteam/scripts/grant_project_claude_permissions.py +175 -0
  127. package/skills/bugteam/scripts/probe_code_rules_enforcer_check.py +107 -0
  128. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +220 -0
  129. package/skills/bugteam/scripts/test__claude_permissions_common.py +112 -0
  130. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +400 -0
  131. package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +384 -0
  132. package/skills/bugteam/scripts/test_bugteam_preflight.py +268 -0
  133. package/skills/bugteam/scripts/test_claude_permissions_common.py +195 -0
  134. package/skills/bugteam/scripts/test_grant_project_claude_permissions.py +55 -0
  135. package/skills/bugteam/scripts/test_probe_code_rules_enforcer_check.py +76 -0
  136. package/skills/bugteam/scripts/test_revoke_project_claude_permissions.py +55 -0
  137. package/skills/bugteam/scripts/test_windows_safe_rmtree.py +108 -0
  138. package/skills/bugteam/scripts/windows_safe_rmtree.py +100 -0
  139. package/skills/bugteam/test_skill_additions.py +1 -11
  140. package/skills/code/SKILL.md +176 -0
  141. package/skills/doc-gist/SKILL.md +99 -0
  142. package/skills/doc-gist/references/examples/01-exploration-code-approaches.html +453 -0
  143. package/skills/doc-gist/references/examples/02-exploration-visual-designs.html +515 -0
  144. package/skills/doc-gist/references/examples/03-code-review-pr.html +638 -0
  145. package/skills/doc-gist/references/examples/04-code-understanding.html +491 -0
  146. package/skills/doc-gist/references/examples/05-design-system.html +629 -0
  147. package/skills/doc-gist/references/examples/06-component-variants.html +605 -0
  148. package/skills/doc-gist/references/examples/07-prototype-animation.html +455 -0
  149. package/skills/doc-gist/references/examples/08-prototype-interaction.html +396 -0
  150. package/skills/doc-gist/references/examples/09-slide-deck.html +592 -0
  151. package/skills/doc-gist/references/examples/10-svg-illustrations.html +492 -0
  152. package/skills/doc-gist/references/examples/11-status-report.html +528 -0
  153. package/skills/doc-gist/references/examples/12-incident-report.html +596 -0
  154. package/skills/doc-gist/references/examples/13-flowchart-diagram.html +395 -0
  155. package/skills/doc-gist/references/examples/14-research-feature-explainer.html +381 -0
  156. package/skills/doc-gist/references/examples/15-research-concept-explainer.html +368 -0
  157. package/skills/doc-gist/references/examples/16-implementation-plan.html +702 -0
  158. package/skills/doc-gist/references/examples/17-pr-writeup.html +595 -0
  159. package/skills/doc-gist/references/examples/18-editor-triage-board.html +573 -0
  160. package/skills/doc-gist/references/examples/19-editor-feature-flags.html +663 -0
  161. package/skills/doc-gist/references/examples/20-editor-prompt-tuner.html +722 -0
  162. package/skills/doc-gist/references/examples/README.md +5 -0
  163. package/skills/doc-gist/scripts/config/__init__.py +0 -0
  164. package/skills/doc-gist/scripts/config/gist_upload_constants.py +16 -0
  165. package/skills/doc-gist/scripts/gist_upload.py +177 -0
  166. package/skills/doc-gist/scripts/test_gist_upload.py +51 -0
  167. package/skills/findbugs/SKILL.md +68 -2
  168. package/skills/monitor-open-prs/SKILL.md +13 -32
  169. package/skills/monitor-open-prs/test_skill_contract.py +0 -11
  170. package/skills/pr-consistency-audit/SKILL.md +112 -0
  171. package/skills/pr-consistency-audit/reference/detection-rules.md +96 -0
  172. package/skills/pr-consistency-audit/reference/illustrations.md +78 -0
  173. package/skills/pr-converge/SKILL.md +227 -23
  174. package/skills/pr-converge/config/__init__.py +0 -0
  175. package/skills/pr-converge/config/constants.py +62 -0
  176. package/skills/pr-converge/reference/convergence-gates.md +138 -44
  177. package/skills/pr-converge/reference/examples.md +43 -11
  178. package/skills/pr-converge/reference/fix-protocol.md +6 -5
  179. package/skills/pr-converge/reference/ground-rules.md +5 -3
  180. package/skills/pr-converge/reference/multi-pr-orchestration.md +44 -19
  181. package/skills/pr-converge/reference/obstacles/fix-post-replies.md +13 -0
  182. package/skills/pr-converge/reference/obstacles/fix-publish-summary.md +13 -0
  183. package/skills/pr-converge/reference/obstacles/fix-push.md +13 -0
  184. package/skills/pr-converge/reference/obstacles/fix-read-filelines.md +13 -0
  185. package/skills/pr-converge/reference/obstacles/fix-reset-state.md +13 -0
  186. package/skills/pr-converge/reference/obstacles/fix-resolve-threads.md +13 -0
  187. package/skills/pr-converge/reference/obstacles/fix-spawn-clean-coder.md +13 -0
  188. package/skills/pr-converge/reference/obstacles/fix-stage-commit.md +13 -0
  189. package/skills/pr-converge/reference/obstacles/fix-trigger-bugbot.md +13 -0
  190. package/skills/pr-converge/reference/obstacles/fix-write-test.md +13 -0
  191. package/skills/pr-converge/reference/per-tick.md +90 -31
  192. package/skills/pr-converge/reference/state-schema.md +22 -1
  193. package/skills/pr-converge/reference/stop-conditions.md +9 -7
  194. package/skills/pr-converge/scripts/README.md +34 -46
  195. package/skills/pr-converge/scripts/check_bugbot_ci.py +174 -0
  196. package/skills/pr-converge/scripts/check_convergence.py +497 -0
  197. package/skills/pr-converge/scripts/check_pending_reviews.py +154 -0
  198. package/skills/pr-converge/scripts/config/pr_converge_constants.py +118 -0
  199. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +134 -0
  200. package/skills/pr-converge/scripts/post_fix_reply.py +168 -0
  201. package/skills/pr-converge/workflows/schedule-wakeup-loop.md +5 -12
  202. package/skills/qbug/SKILL.md +132 -27
  203. package/skills/session-log/SKILL.md +216 -114
  204. package/skills/session-tidy/SKILL.md +1 -1
  205. package/skills/skill-builder/SKILL.md +138 -56
  206. package/skills/skill-builder/references/delegation-map.md +72 -113
  207. package/skills/skill-builder/references/progressive-disclosure.md +122 -0
  208. package/skills/skill-builder/references/self-audit-checklist.md +92 -0
  209. package/skills/skill-builder/references/skill-types.md +228 -0
  210. package/skills/skill-builder/references/thariq-x-post-skills.json +33 -0
  211. package/skills/skill-builder/templates/gap-analysis.md +15 -8
  212. package/skills/skill-builder/workflows/improve-skill.md +86 -57
  213. package/skills/skill-builder/workflows/new-skill.md +80 -168
  214. package/skills/skill-builder/workflows/polish-skill.md +78 -54
  215. package/skills/structure-prompt/SKILL.md +50 -0
  216. package/skills/structure-prompt/reference/adversarial-tuning.md +62 -0
  217. package/skills/structure-prompt/reference/block-classification.md +27 -0
  218. package/skills/structure-prompt/reference/canonical-case.md +48 -0
  219. package/skills/structure-prompt/reference/citation-depth.md +70 -0
  220. package/skills/structure-prompt/reference/cleanup.md +33 -0
  221. package/skills/structure-prompt/reference/constraints.md +33 -0
  222. package/skills/structure-prompt/reference/directives.md +37 -0
  223. package/skills/structure-prompt/reference/examples.md +72 -0
  224. package/skills/structure-prompt/reference/instantiation.md +51 -0
  225. package/skills/structure-prompt/reference/output-contract.md +72 -0
  226. package/skills/structure-prompt/reference/per-category.md +23 -0
  227. package/skills/structure-prompt/reference/persona.md +38 -0
  228. package/skills/structure-prompt/reference/research.md +33 -0
  229. package/skills/structure-prompt/reference/structure.md +28 -0
  230. package/agents/code-standards-agent.md +0 -93
  231. package/agents/groq-coder.md +0 -113
  232. package/agents/plan-executor.md +0 -226
  233. package/agents/project-docs-analyzer.md +0 -53
  234. package/agents/project-structure-organizer-agent.md +0 -72
  235. package/agents/skill-to-agent-converter.md +0 -370
  236. package/agents/skill-writer-agent.md +0 -470
  237. package/agents/user-docs-writer.md +0 -67
  238. package/agents/workflow-visual-documenter.md +0 -82
  239. package/commands/readability-review.md +0 -20
  240. package/hooks/mypy.ini +0 -2
  241. package/hooks/notification/attention_needed_notify.py +0 -71
  242. package/hooks/notification/claude_notification_handler.py +0 -67
  243. package/hooks/notification/notification_utils.py +0 -267
  244. package/hooks/notification/subagent_complete_notify.py +0 -381
  245. package/hooks/notification/test_attention_needed_notify.py +0 -47
  246. package/hooks/notification/test_claude_notification_handler.py +0 -54
  247. package/hooks/notification/test_notification_utils.py +0 -91
  248. package/hooks/notification/test_subagent_complete_notify.py +0 -79
  249. package/scripts/config/groq_bugteam_config.py +0 -230
  250. package/scripts/config/test_groq_bugteam_config.py +0 -83
  251. package/scripts/config/test_spec_implementer_prompt.py +0 -32
  252. package/scripts/groq_bugteam.README.md +0 -131
  253. package/scripts/groq_bugteam.py +0 -647
  254. package/scripts/groq_bugteam_dotenv.py +0 -40
  255. package/scripts/groq_bugteam_spec.py +0 -226
  256. package/scripts/test_groq_bugteam.py +0 -529
  257. package/scripts/test_groq_bugteam_apply_fix_from_spec.py +0 -426
  258. package/scripts/test_groq_bugteam_dotenv.py +0 -66
  259. package/scripts/test_groq_bugteam_spec.py +0 -338
  260. package/skills/bugteam/SKILL_EVALS.md +0 -309
  261. package/skills/dream/SKILL.md +0 -118
  262. package/skills/ingest/SKILL.md +0 -40
  263. package/skills/npm-creator/SKILL.md +0 -187
  264. package/skills/readability-review/SKILL.md +0 -127
  265. package/skills/resume-review/SKILL.md +0 -261
  266. package/skills/rule-audit/SKILL.md +0 -307
  267. package/skills/rule-creator/SKILL.md +0 -150
  268. package/skills/searching-obsidian-vault/SKILL.md +0 -131
  269. package/skills/skill-writer/REFERENCE.md +0 -284
  270. package/skills/skill-writer/SKILL.md +0 -222
  271. package/skills/tdd-team/SKILL.md +0 -128
@@ -0,0 +1,497 @@
1
+ """Verify all convergence pre-conditions for a PR before marking ready.
2
+
3
+ Usage:
4
+ python scripts/check_convergence.py --owner <O> --repo <R> --pr-number <N>
5
+
6
+ Exit codes:
7
+ 0 — all seven pre-conditions met
8
+ 1 — one or more conditions not met (FAIL lines printed to stdout)
9
+ 2 — gh CLI error
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import re
17
+ import subprocess
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ _pr_converge_dir = Path(__file__).resolve().parent.parent
22
+ if str(_pr_converge_dir) not in sys.path:
23
+ sys.path.insert(0, str(_pr_converge_dir))
24
+
25
+ from config.constants import (
26
+ ALL_CLAUDE_DIRTY_REVIEW_STATES,
27
+ ALL_COPILOT_DIRTY_REVIEW_STATES,
28
+ ALL_BUGBOT_CHECK_RUN_COMPLETE_CONCLUSIONS,
29
+ BUGBOT_CHECK_RUN_NAME_SUBSTRING,
30
+ BUGBOT_DIRTY_BODY_REGEX,
31
+ CHECK_RUNS_PER_PAGE,
32
+ ALL_CLAUDE_CLEAN_REVIEW_STATES,
33
+ CLAUDE_LOGIN_FILTER_SUBSTRING,
34
+ ALL_COPILOT_CLEAN_REVIEW_STATES,
35
+ COPILOT_LOGIN_FILTER_SUBSTRING,
36
+ COPILOT_REVIEWER_LOGIN,
37
+ CURSOR_LOGIN_FILTER_SUBSTRING,
38
+ EXIT_CODE_GH_ERROR,
39
+ GH_CHECK_RUNS_PATH_TEMPLATE,
40
+ GH_PR_OBJECT_PATH_TEMPLATE,
41
+ GH_REQUESTED_REVIEWERS_PATH_TEMPLATE,
42
+ GH_REVIEWS_PATH_TEMPLATE,
43
+ GRAPHQL_REVIEW_THREADS_PAGE_SIZE,
44
+ REVIEWS_PER_PAGE,
45
+ UNRESOLVED_THREAD_DETAIL_MAX,
46
+ )
47
+
48
+
49
+ def _gh_api(endpoint_path: str) -> tuple[int, str]:
50
+ completed_process = subprocess.run(
51
+ ["gh", "api", endpoint_path],
52
+ capture_output=True,
53
+ text=True,
54
+ encoding="utf-8",
55
+ errors="replace",
56
+ check=False,
57
+ )
58
+ return completed_process.returncode, completed_process.stdout
59
+
60
+
61
+ def _gh_api_paginated(endpoint_path: str) -> tuple[int, str]:
62
+ completed_process = subprocess.run(
63
+ ["gh", "api", endpoint_path, "--paginate", "--slurp"],
64
+ capture_output=True,
65
+ text=True,
66
+ encoding="utf-8",
67
+ errors="replace",
68
+ check=False,
69
+ )
70
+ return completed_process.returncode, completed_process.stdout
71
+
72
+
73
+ def _get_pr_head_sha(*, owner: str, repo: str, number: int) -> str:
74
+ endpoint = GH_PR_OBJECT_PATH_TEMPLATE.format(owner=owner, repo=repo, number=number)
75
+ returncode, stdout = _gh_api(endpoint)
76
+ if returncode != 0:
77
+ print(f"gh api error fetching PR object: {stdout}", file=sys.stderr)
78
+ raise SystemExit(EXIT_CODE_GH_ERROR)
79
+ pr_object = json.loads(stdout)
80
+ head_sha: object = pr_object.get("head", {}).get("sha")
81
+ if not isinstance(head_sha, str):
82
+ raise SystemExit(EXIT_CODE_GH_ERROR)
83
+ return head_sha
84
+
85
+
86
+ def _get_mergeable(*, owner: str, repo: str, number: int) -> tuple[bool, str]:
87
+ endpoint = GH_PR_OBJECT_PATH_TEMPLATE.format(owner=owner, repo=repo, number=number)
88
+ returncode, stdout = _gh_api(endpoint)
89
+ if returncode != 0:
90
+ return False, f"gh api error: {stdout}"
91
+ pr_object = json.loads(stdout)
92
+ mergeable: object = pr_object.get("mergeable")
93
+ mergeable_state: object = pr_object.get("mergeable_state", "unknown")
94
+ state_str = str(mergeable_state)
95
+ if mergeable is True and state_str == "clean":
96
+ return True, "clean"
97
+ return False, state_str
98
+
99
+
100
+ def _check_bugbot(*, owner: str, repo: str, sha: str) -> tuple[bool, str]:
101
+ endpoint = GH_CHECK_RUNS_PATH_TEMPLATE.format(owner=owner, repo=repo, sha=sha)
102
+ returncode, stdout = _gh_api(f"{endpoint}?per_page={CHECK_RUNS_PER_PAGE}")
103
+ if returncode != 0:
104
+ return False, f"gh api error: {stdout}"
105
+ try:
106
+ response_body = json.loads(stdout)
107
+ except json.JSONDecodeError:
108
+ return False, "gh api response not valid JSON"
109
+ check_runs: list[dict[str, object]] = []
110
+ if isinstance(response_body, dict):
111
+ raw_runs = response_body.get("check_runs")
112
+ if isinstance(raw_runs, list):
113
+ check_runs = [r for r in raw_runs if isinstance(r, dict)]
114
+ for check_entry in check_runs:
115
+ each_name = check_entry.get("name", "")
116
+ if not isinstance(each_name, str):
117
+ continue
118
+ if BUGBOT_CHECK_RUN_NAME_SUBSTRING.lower() not in each_name.lower():
119
+ continue
120
+ conclusion = check_entry.get("conclusion", "")
121
+ if conclusion in ALL_BUGBOT_CHECK_RUN_COMPLETE_CONCLUSIONS:
122
+ check_id = check_entry.get("id", "?")
123
+ detail_url = check_entry.get("html_url", "")
124
+ details_suffix = f" ({detail_url})" if detail_url else ""
125
+ return True, f"check run #{check_id}, conclusion: {conclusion}{details_suffix}"
126
+ return False, f"check run conclusion is '{conclusion}', expected {ALL_BUGBOT_CHECK_RUN_COMPLETE_CONCLUSIONS}"
127
+ return False, "no bugbot check run found"
128
+
129
+
130
+ def _check_bugbot_not_dirty(*, owner: str, repo: str, number: int, head_sha: str) -> tuple[bool, str]:
131
+ endpoint = GH_REVIEWS_PATH_TEMPLATE.format(owner=owner, repo=repo, number=number)
132
+ returncode, stdout = _gh_api_paginated(f"{endpoint}?per_page={REVIEWS_PER_PAGE}")
133
+ if returncode != 0:
134
+ return True, "bugbot reviews unavailable (non-fatal)"
135
+ try:
136
+ raw_output = json.loads(stdout)
137
+ except json.JSONDecodeError:
138
+ return True, "bugbot reviews not valid JSON (non-fatal)"
139
+ if not isinstance(raw_output, list):
140
+ return True, "no reviews"
141
+ all_pages = [p for p in raw_output if isinstance(p, list)]
142
+ all_flat: list[dict[str, object]] = [
143
+ each_entry
144
+ for page in all_pages
145
+ for each_entry in page
146
+ if isinstance(each_entry, dict)
147
+ ]
148
+ all_flat.sort(
149
+ key=lambda each_review: str(each_review.get("submitted_at", "")),
150
+ reverse=True,
151
+ )
152
+ dirty_pattern = re.compile(BUGBOT_DIRTY_BODY_REGEX, re.IGNORECASE)
153
+ for each_review in all_flat:
154
+ user_obj = each_review.get("user")
155
+ if not isinstance(user_obj, dict):
156
+ continue
157
+ login = user_obj.get("login", "")
158
+ if not isinstance(login, str):
159
+ continue
160
+ if CURSOR_LOGIN_FILTER_SUBSTRING not in login.lower():
161
+ continue
162
+ commit_id = each_review.get("commit_id", "")
163
+ if not isinstance(commit_id, str) or not commit_id.startswith(head_sha):
164
+ continue
165
+ body = each_review.get("body", "")
166
+ if isinstance(body, str) and dirty_pattern.search(body):
167
+ return False, "bugbot review body reports findings"
168
+ return True, "clean"
169
+ return True, "no bugbot review at HEAD"
170
+
171
+
172
+ def _check_bot_review(
173
+ *,
174
+ owner: str,
175
+ repo: str,
176
+ number: int,
177
+ head_sha: str,
178
+ login_substring: str,
179
+ clean_states: tuple[str, ...],
180
+ dirty_states: tuple[str, ...],
181
+ label: str,
182
+ ) -> tuple[bool, str]:
183
+ endpoint = GH_REVIEWS_PATH_TEMPLATE.format(owner=owner, repo=repo, number=number)
184
+ returncode, stdout = _gh_api_paginated(f"{endpoint}?per_page={REVIEWS_PER_PAGE}")
185
+ if returncode != 0:
186
+ return False, f"gh api error: {stdout}"
187
+ try:
188
+ raw_output = json.loads(stdout)
189
+ except json.JSONDecodeError:
190
+ return False, "gh api response not valid JSON"
191
+ if not isinstance(raw_output, list):
192
+ return False, f"no {label} review found"
193
+ all_pages = [p for p in raw_output if isinstance(p, list)]
194
+ all_flat = [
195
+ each_entry
196
+ for page in all_pages
197
+ for each_entry in page
198
+ if isinstance(each_entry, dict)
199
+ ]
200
+ all_flat.sort(
201
+ key=lambda each_review: str(each_review.get("submitted_at", "")),
202
+ reverse=True,
203
+ )
204
+ for each_review in all_flat:
205
+ user_obj = each_review.get("user")
206
+ if not isinstance(user_obj, dict):
207
+ continue
208
+ login = user_obj.get("login", "")
209
+ if not isinstance(login, str):
210
+ continue
211
+ if login_substring not in login.lower():
212
+ continue
213
+ commit_id = each_review.get("commit_id", "")
214
+ review_state = each_review.get("state", "")
215
+ if not isinstance(commit_id, str) or not commit_id.startswith(head_sha):
216
+ continue
217
+ if review_state in clean_states:
218
+ review_id = each_review.get("id", "?")
219
+ return (
220
+ True,
221
+ f"review #{review_id}, state: {review_state}, commit: {commit_id[:7]}",
222
+ )
223
+ if review_state in dirty_states:
224
+ return (
225
+ False,
226
+ f"review state is '{review_state}' (dirty), commit: {commit_id[:7]}",
227
+ )
228
+ return False, f"review state is '{review_state}', commit: {commit_id[:7]}"
229
+ return False, f"no {label} review found on {head_sha[:7]}"
230
+
231
+
232
+ def _gh_graphql(query: str, variables: dict[str, object]) -> tuple[int, str]:
233
+ args: list[str] = ["gh", "api", "graphql", "-f", f"query={query}"]
234
+ for each_key, each_value in variables.items():
235
+ if each_value is None:
236
+ continue
237
+ if isinstance(each_value, int):
238
+ args.extend(["-F", f"{each_key}={each_value}"])
239
+ else:
240
+ args.extend(["-f", f"{each_key}={each_value}"])
241
+ completed_process = subprocess.run(
242
+ args,
243
+ capture_output=True,
244
+ text=True,
245
+ encoding="utf-8",
246
+ errors="replace",
247
+ check=False,
248
+ )
249
+ return completed_process.returncode, completed_process.stdout
250
+
251
+
252
+ def _count_unresolved_bot_threads(
253
+ *, owner: str, repo: str, number: int
254
+ ) -> tuple[bool, str]:
255
+ query = """
256
+ query($owner: String!, $repo: String!, $number: Int!, $first: Int!, $cursor: String) {
257
+ repository(owner: $owner, name: $repo) {
258
+ pullRequest(number: $number) {
259
+ reviewThreads(first: $first, after: $cursor) {
260
+ nodes {
261
+ isResolved
262
+ isOutdated
263
+ path
264
+ comments(first: 1) {
265
+ nodes {
266
+ author { login }
267
+ }
268
+ }
269
+ }
270
+ pageInfo {
271
+ hasNextPage
272
+ endCursor
273
+ }
274
+ }
275
+ }
276
+ }
277
+ }
278
+ """
279
+ bot_logins = (
280
+ CURSOR_LOGIN_FILTER_SUBSTRING,
281
+ CLAUDE_LOGIN_FILTER_SUBSTRING,
282
+ COPILOT_LOGIN_FILTER_SUBSTRING,
283
+ )
284
+ unresolved: list[dict[str, object]] = []
285
+ cursor: str | None = None
286
+
287
+ while True:
288
+ variables: dict[str, object] = {
289
+ "owner": owner,
290
+ "repo": repo,
291
+ "number": number,
292
+ "first": GRAPHQL_REVIEW_THREADS_PAGE_SIZE,
293
+ "cursor": cursor,
294
+ }
295
+ returncode, stdout = _gh_graphql(query, variables)
296
+ if returncode != 0:
297
+ return False, f"gh api graphql error: {stdout}"
298
+ try:
299
+ response_body = json.loads(stdout)
300
+ except json.JSONDecodeError:
301
+ return False, "gh api graphql response not valid JSON"
302
+ response_data = response_body.get("data", {})
303
+ repository = response_data.get("repository", {}) if isinstance(response_data, dict) else {}
304
+ pull_request = repository.get("pullRequest", {}) if isinstance(repository, dict) else {}
305
+ threads = pull_request.get("reviewThreads", {}) if isinstance(pull_request, dict) else {}
306
+ if not isinstance(threads, dict):
307
+ return False, "unexpected GraphQL response shape"
308
+ nodes = threads.get("nodes", [])
309
+ if isinstance(nodes, list):
310
+ for each_thread in nodes:
311
+ if not isinstance(each_thread, dict):
312
+ continue
313
+ if each_thread.get("isResolved") is True:
314
+ continue
315
+ if each_thread.get("isOutdated") is True:
316
+ continue
317
+ comments_wrapper = each_thread.get("comments", {})
318
+ if not isinstance(comments_wrapper, dict):
319
+ continue
320
+ comments_nodes = comments_wrapper.get("nodes", [])
321
+ if not isinstance(comments_nodes, list) or not comments_nodes:
322
+ continue
323
+ first_comment = comments_nodes[0]
324
+ if not isinstance(first_comment, dict):
325
+ continue
326
+ author_wrapper = first_comment.get("author")
327
+ if not isinstance(author_wrapper, dict):
328
+ continue
329
+ login = author_wrapper.get("login", "")
330
+ if not isinstance(login, str):
331
+ continue
332
+ is_bot = any(bot in login.lower() for bot in bot_logins)
333
+ if not is_bot:
334
+ continue
335
+ unresolved.append(each_thread)
336
+ page_info = threads.get("pageInfo", {})
337
+ if not isinstance(page_info, dict) or not page_info.get("hasNextPage"):
338
+ break
339
+ next_cursor = page_info.get("endCursor")
340
+ if isinstance(next_cursor, str):
341
+ cursor = next_cursor
342
+ else:
343
+ break
344
+
345
+ if not unresolved:
346
+ return True, "0 unresolved"
347
+ details_parts: list[str] = []
348
+ for each_thread in unresolved[:UNRESOLVED_THREAD_DETAIL_MAX]:
349
+ thread_path = each_thread.get("path", "?")
350
+ details_parts.append(str(thread_path))
351
+ detail_text = "; ".join(details_parts)
352
+ if len(unresolved) > UNRESOLVED_THREAD_DETAIL_MAX:
353
+ detail_text += f" ... and {len(unresolved) - UNRESOLVED_THREAD_DETAIL_MAX} more"
354
+ return False, f"{len(unresolved)} unresolved ({detail_text})"
355
+
356
+
357
+ def _check_no_pending_reviews(
358
+ *, owner: str, repo: str, number: int
359
+ ) -> tuple[bool, str]:
360
+ endpoint = GH_REQUESTED_REVIEWERS_PATH_TEMPLATE.format(
361
+ owner=owner, repo=repo, number=number
362
+ )
363
+ returncode, stdout = _gh_api(endpoint)
364
+ if returncode != 0:
365
+ return False, f"gh api error: {stdout}"
366
+ try:
367
+ response_body = json.loads(stdout)
368
+ except json.JSONDecodeError:
369
+ return True, "no pending (empty response)"
370
+ if isinstance(response_body, dict):
371
+ users = response_body.get("users", [])
372
+ elif isinstance(response_body, list):
373
+ users = response_body
374
+ else:
375
+ return True, "no pending (unexpected format)"
376
+ if not isinstance(users, list):
377
+ return True, "no pending"
378
+ copilot_pending = []
379
+ for each_user in users:
380
+ if not isinstance(each_user, dict):
381
+ continue
382
+ login = each_user.get("login", "")
383
+ if isinstance(login, str) and COPILOT_REVIEWER_LOGIN.lower() in login.lower():
384
+ copilot_pending.append(login)
385
+ if copilot_pending:
386
+ return False, f"pending: {', '.join(copilot_pending)}"
387
+ return True, "no pending reviewers"
388
+
389
+
390
+ def check_all(*, owner: str, repo: str, number: int) -> int:
391
+ head_sha = _get_pr_head_sha(owner=owner, repo=repo, number=number)
392
+ print(f"HEAD: {head_sha[:7]}\n")
393
+
394
+ conditions: list[tuple[str, tuple[bool, str]]] = []
395
+
396
+ conditions.append(
397
+ (
398
+ "bugbot_clean_at == current_head",
399
+ _check_bugbot(owner=owner, repo=repo, sha=head_sha),
400
+ )
401
+ )
402
+ if conditions[-1][1][0]:
403
+ conditions.append(
404
+ (
405
+ "bugbot review body clean",
406
+ _check_bugbot_not_dirty(owner=owner, repo=repo, number=number, head_sha=head_sha),
407
+ )
408
+ )
409
+
410
+ conditions.append(
411
+ (
412
+ "bugteam_clean_at == current_head",
413
+ _check_bot_review(
414
+ owner=owner,
415
+ repo=repo,
416
+ number=number,
417
+ head_sha=head_sha,
418
+ login_substring=CLAUDE_LOGIN_FILTER_SUBSTRING,
419
+ clean_states=ALL_CLAUDE_CLEAN_REVIEW_STATES,
420
+ dirty_states=ALL_CLAUDE_DIRTY_REVIEW_STATES,
421
+ label="claude[bot]",
422
+ ),
423
+ )
424
+ )
425
+
426
+ conditions.append(
427
+ (
428
+ "copilot_clean_at == current_head",
429
+ _check_bot_review(
430
+ owner=owner,
431
+ repo=repo,
432
+ number=number,
433
+ head_sha=head_sha,
434
+ login_substring=COPILOT_LOGIN_FILTER_SUBSTRING,
435
+ clean_states=ALL_COPILOT_CLEAN_REVIEW_STATES,
436
+ dirty_states=ALL_COPILOT_DIRTY_REVIEW_STATES,
437
+ label="copilot",
438
+ ),
439
+ )
440
+ )
441
+
442
+ conditions.append(
443
+ (
444
+ "zero unresolved bot threads",
445
+ _count_unresolved_bot_threads(owner=owner, repo=repo, number=number),
446
+ )
447
+ )
448
+
449
+ conditions.append(
450
+ ("PR is mergeable", _get_mergeable(owner=owner, repo=repo, number=number))
451
+ )
452
+
453
+ conditions.append(
454
+ (
455
+ "no pending requested reviews",
456
+ _check_no_pending_reviews(owner=owner, repo=repo, number=number),
457
+ )
458
+ )
459
+
460
+ is_all_passed = True
461
+ index = 1
462
+ for label, (passed, detail) in conditions:
463
+ status = "PASS" if passed else "FAIL"
464
+ print(f"{index}. {label}: {status} — {detail}")
465
+ if not passed:
466
+ is_all_passed = False
467
+ index += 1
468
+
469
+ print()
470
+ if is_all_passed:
471
+ print("All pre-conditions met — PR is ready to mark ready.")
472
+ else:
473
+ print("One or more pre-conditions not met — do not mark ready.")
474
+ return 0 if is_all_passed else 1
475
+
476
+
477
+ def parse_arguments(all_argv: list[str]) -> argparse.Namespace:
478
+ parser = argparse.ArgumentParser(description=__doc__)
479
+ parser.add_argument("--owner", required=True, help="GitHub repository owner")
480
+ parser.add_argument("--repo", required=True, help="GitHub repository name")
481
+ parser.add_argument(
482
+ "--pr-number", required=True, type=int, help="Pull request number"
483
+ )
484
+ return parser.parse_args(all_argv)
485
+
486
+
487
+ def main(all_arguments: list[str]) -> int:
488
+ arguments = parse_arguments(all_arguments)
489
+ return check_all(
490
+ owner=arguments.owner,
491
+ repo=arguments.repo,
492
+ number=getattr(arguments, "pr_number"),
493
+ )
494
+
495
+
496
+ if __name__ == "__main__":
497
+ raise SystemExit(main(sys.argv[1:]))
@@ -0,0 +1,154 @@
1
+ """Check for pending pull request reviews.
2
+
3
+ Usage:
4
+ python scripts/check_pending_reviews.py --owner <O> --repo <R> --pr-number <N> [--user <substring>]
5
+
6
+ Exit codes:
7
+ 0 — pending review(s) found (printed to stdout as JSON array)
8
+ 1 — no pending reviews found
9
+ EXIT_CODE_GH_ERROR — gh CLI error
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import subprocess
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ _pr_converge_dir = Path(__file__).resolve().parent.parent
21
+ if str(_pr_converge_dir) not in sys.path:
22
+ sys.path.insert(0, str(_pr_converge_dir))
23
+
24
+ from config.constants import (
25
+ EXIT_CODE_GH_ERROR,
26
+ GH_REVIEWS_PATH_TEMPLATE,
27
+ REVIEWS_PER_PAGE,
28
+ )
29
+
30
+
31
+ def fetch_pending_reviews(
32
+ *, owner: str, repo: str, number: int, user_filter: str | None = None
33
+ ) -> list[dict[str, object]]:
34
+ """Fetch pending reviews for a pull request.
35
+
36
+ Args:
37
+ owner: GitHub repository owner.
38
+ repo: GitHub repository name.
39
+ number: Pull request number.
40
+ user_filter: Optional case-insensitive substring to match against user login.
41
+
42
+ Returns:
43
+ List of pending review entries.
44
+
45
+ Raises:
46
+ SystemExit: When the gh CLI call fails.
47
+ """
48
+ endpoint_path = (
49
+ GH_REVIEWS_PATH_TEMPLATE.format(owner=owner, repo=repo, number=number)
50
+ + f"?per_page={REVIEWS_PER_PAGE}"
51
+ )
52
+ completed_process = subprocess.run(
53
+ ["gh", "api", endpoint_path, "--paginate", "--slurp"],
54
+ capture_output=True,
55
+ text=True,
56
+ encoding="utf-8",
57
+ errors="replace",
58
+ check=False,
59
+ )
60
+ if completed_process.returncode != 0:
61
+ print(f"gh api error: {completed_process.stderr}", file=sys.stderr)
62
+ raise SystemExit(EXIT_CODE_GH_ERROR)
63
+ raw_output: object = json.loads(completed_process.stdout)
64
+ if not isinstance(raw_output, list):
65
+ return []
66
+ all_pages: list[list[dict[str, object]]] = [
67
+ each_page for each_page in raw_output if isinstance(each_page, list)
68
+ ]
69
+ all_flat: list[dict[str, object]] = [
70
+ each_item for each_page in all_pages for each_item in each_page
71
+ ]
72
+ pending_reviews: list[dict[str, object]] = []
73
+ for each_review in all_flat:
74
+ if each_review.get("state") != "PENDING":
75
+ continue
76
+ user_object: object = each_review.get("user")
77
+ user_login: str = ""
78
+ if isinstance(user_object, dict):
79
+ raw_login: object = user_object.get("login")
80
+ if isinstance(raw_login, str):
81
+ user_login = raw_login
82
+ raw_submitted: object = each_review.get("submitted_at")
83
+ submitted_at: str = ""
84
+ if isinstance(raw_submitted, str):
85
+ submitted_at = raw_submitted
86
+ raw_commit: object = each_review.get("commit_id")
87
+ commit_short: str = ""
88
+ if isinstance(raw_commit, str):
89
+ commit_short = raw_commit[:7]
90
+ if user_filter is not None and user_filter.lower() not in user_login.lower():
91
+ continue
92
+ pending_reviews.append(
93
+ {
94
+ "user": user_login,
95
+ "submitted_at": submitted_at,
96
+ "commit_id": commit_short,
97
+ }
98
+ )
99
+ return pending_reviews
100
+
101
+
102
+ def parse_arguments(all_argv: list[str]) -> argparse.Namespace:
103
+ """Parse command-line arguments.
104
+
105
+ Args:
106
+ all_argv: Command-line argument list.
107
+
108
+ Returns:
109
+ Parsed namespace with owner, repo, and number.
110
+ """
111
+ parser = argparse.ArgumentParser(description=__doc__)
112
+ parser.add_argument("--owner", required=True, help="GitHub repository owner")
113
+ parser.add_argument("--repo", required=True, help="GitHub repository name")
114
+ parser.add_argument("--pr-number", required=True, type=int, help="Pull request number")
115
+ parser.add_argument(
116
+ "--user",
117
+ default=None,
118
+ help="Optional case-insensitive substring filter for user login",
119
+ )
120
+ return parser.parse_args(all_argv)
121
+
122
+
123
+ def main(
124
+ all_arguments: list[str], *, user_filter: str | None = None
125
+ ) -> int:
126
+ """Entry point for check_pending_reviews.
127
+
128
+ Args:
129
+ all_arguments: Command-line arguments.
130
+ user_filter: Override for user filter (default: from CLI).
131
+
132
+ Returns:
133
+ 0 when pending reviews are found, 1 when none, EXIT_CODE_GH_ERROR on error.
134
+ """
135
+ arguments = parse_arguments(all_arguments)
136
+ if arguments.owner is None:
137
+ return 1
138
+ pending = fetch_pending_reviews(
139
+ owner=arguments.owner,
140
+ repo=arguments.repo,
141
+ number=getattr(arguments, "pr_number"),
142
+ user_filter=arguments.user if user_filter is None else user_filter,
143
+ )
144
+ if pending:
145
+ json.dump(pending, sys.stdout)
146
+ sys.stdout.write("\n")
147
+ return 0
148
+ return 1
149
+
150
+
151
+ if __name__ == "__main__":
152
+ all_argv = sys.argv[1:]
153
+ arguments = parse_arguments(all_argv)
154
+ raise SystemExit(main(all_argv, user_filter=arguments.user))