claude-dev-env 1.38.1 → 1.40.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.
- package/CLAUDE.md +10 -36
- package/_shared/pr-loop/audit-reply-template.md +147 -0
- package/_shared/pr-loop/fix-protocol.md +25 -4
- package/_shared/pr-loop/gh-payloads.md +37 -50
- package/_shared/pr-loop/scripts/code_rules_gate.py +0 -60
- package/_shared/pr-loop/scripts/config/post_audit_thread_constants.py +199 -0
- package/_shared/pr-loop/scripts/config/reviews_disabled_constants.py +8 -0
- package/_shared/pr-loop/scripts/post_audit_thread.py +1242 -0
- package/_shared/pr-loop/scripts/preflight.py +129 -2
- package/_shared/pr-loop/scripts/reviews_disabled.py +59 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +0 -19
- package/_shared/pr-loop/scripts/tests/test_post_audit_thread.py +1116 -0
- package/_shared/pr-loop/scripts/tests/test_post_audit_thread_constants.py +127 -0
- package/_shared/pr-loop/scripts/tests/test_preflight.py +41 -0
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +36 -0
- package/_shared/pr-loop/state-schema.md +1 -1
- package/agents/clean-coder.md +2 -2
- package/agents/pr-description-writer.md +150 -52
- package/bin/install.mjs +6 -7
- package/bin/install.test.mjs +8 -0
- package/commands/doc-gist.md +16 -0
- package/commands/plan.md +0 -2
- package/commands/review-plan.md +1 -1
- package/docs/CODE_RULES.md +122 -2
- package/docs/PR_DESCRIPTION_GUIDE.md +127 -64
- package/hooks/blocking/bot_mention_comment_blocker.py +75 -0
- package/hooks/blocking/code_rules_enforcer.py +1143 -129
- package/hooks/blocking/convergence_gate_blocker.py +130 -0
- package/hooks/blocking/destructive_command_blocker.py +74 -0
- package/hooks/blocking/gh_body_arg_blocker.py +30 -0
- package/hooks/blocking/md_to_html_blocker.py +119 -0
- package/hooks/blocking/pr_description_enforcer.py +57 -22
- package/hooks/blocking/test_bot_mention_comment_blocker.py +131 -0
- package/hooks/blocking/test_code_rules_enforcer.py +21 -0
- package/hooks/blocking/test_code_rules_enforcer_any_exempt_files.py +70 -0
- package/hooks/blocking/test_code_rules_enforcer_any_imports_and_cast.py +92 -0
- package/hooks/blocking/test_code_rules_enforcer_banned_import_alias.py +143 -0
- package/hooks/blocking/test_code_rules_enforcer_banned_prefixes.py +152 -0
- package/hooks/blocking/test_code_rules_enforcer_bare_except.py +120 -0
- package/hooks/blocking/test_code_rules_enforcer_boundary_types.py +175 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -1
- package/hooks/blocking/test_code_rules_enforcer_collection_prefix.py +50 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_format.py +255 -0
- package/hooks/blocking/test_code_rules_enforcer_inline_tuple_string_magic.py +130 -0
- package/hooks/blocking/test_code_rules_enforcer_stub_implementations.py +141 -0
- package/hooks/blocking/test_code_rules_enforcer_test_branching.py +143 -0
- package/hooks/blocking/test_code_rules_enforcer_thin_wrapper_files.py +169 -0
- package/hooks/blocking/test_code_rules_enforcer_todo_markers.py +99 -0
- package/hooks/blocking/test_code_rules_enforcer_typed_dict_pairs.py +141 -0
- package/hooks/blocking/test_convergence_gate_blocker.py +63 -0
- package/hooks/blocking/test_destructive_command_blocker.py +146 -0
- package/hooks/blocking/test_destructive_command_blocker_no_verify.py +102 -0
- package/hooks/blocking/test_gh_body_arg_blocker.py +45 -0
- package/hooks/blocking/test_md_to_html_blocker.py +317 -0
- package/hooks/blocking/test_pr_description_enforcer.py +69 -8
- package/hooks/config/any_type_config.py +7 -0
- package/hooks/config/banned_identifiers_constants.py +11 -0
- package/hooks/config/blocking_check_limits.py +38 -0
- package/hooks/config/bot_mention_comment_blocker_constants.py +20 -0
- package/hooks/config/code_rules_enforcer_constants.py +53 -0
- package/hooks/config/convergence_branch_constants.py +9 -0
- package/hooks/config/doc_gist_auto_publish_constants.py +18 -0
- package/hooks/config/html_companion_constants.py +20 -0
- package/hooks/config/inline_tuple_string_magic_constants.py +22 -0
- package/hooks/config/pr_description_enforcer_constants.py +14 -0
- package/hooks/config/test_banned_identifiers_constants.py +17 -0
- package/hooks/hooks.json +28 -20
- package/hooks/pyproject.toml +69 -0
- package/hooks/validators/mypy_integration.py +47 -1
- package/hooks/validators/run_all_validators.py +3 -3
- package/hooks/validators/test_mypy_integration.py +50 -1
- package/hooks/workflow/doc_gist_auto_publish.py +144 -0
- package/hooks/workflow/md_to_html_companion.py +365 -0
- package/hooks/workflow/test_doc_gist_auto_publish.py +117 -0
- package/hooks/workflow/test_md_to_html_companion.py +452 -0
- package/package.json +1 -1
- package/rules/gh-body-file.md +2 -0
- package/scripts/Install-SweepEmptyDirs.ps1 +111 -0
- package/scripts/check.ps1 +106 -0
- package/scripts/config/timing.py +11 -0
- package/scripts/sweep_empty_dirs.py +138 -0
- package/scripts/sync_to_cursor/rules.py +1 -1
- package/scripts/test_sweep_empty_dirs.py +183 -0
- package/skills/_shared/pr-loop/prompts/pr-consistency-audit.xml +323 -0
- package/skills/_shared/pr-loop/scripts/_cli_utils.py +22 -0
- package/skills/_shared/pr-loop/scripts/_path_resolver.py +165 -0
- package/skills/_shared/pr-loop/scripts/_xml_utils.py +20 -0
- package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +182 -0
- package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +185 -0
- package/skills/_shared/pr-loop/scripts/config/__init__.py +0 -0
- package/skills/_shared/pr-loop/scripts/config/path_resolver_constants.py +78 -0
- package/skills/_shared/pr-loop/scripts/init_loop_state.py +135 -0
- package/skills/_shared/pr-loop/scripts/teardown_worktrees.py +175 -0
- package/skills/_shared/pr-loop/scripts/write_audit_outcomes.py +182 -0
- package/skills/_shared/pr-loop/scripts/write_fix_outcomes.py +206 -0
- package/skills/bugteam/CONSTRAINTS.md +21 -22
- package/skills/bugteam/EXAMPLES.md +3 -3
- package/skills/bugteam/PROMPTS.md +227 -67
- package/skills/bugteam/SKILL.md +132 -455
- package/skills/bugteam/reference/README.md +1 -1
- package/skills/bugteam/reference/audit-and-teammates.md +112 -39
- package/skills/bugteam/reference/audit-contract.md +4 -22
- package/skills/bugteam/reference/copilot-gap-analysis.md +8 -5
- package/skills/bugteam/reference/design-rationale.md +2 -2
- package/skills/bugteam/reference/github-pr-reviews.md +50 -57
- package/skills/bugteam/reference/obstacles/audit-assign-ids.md +13 -0
- package/skills/bugteam/reference/obstacles/audit-capture-excerpts.md +13 -0
- package/skills/bugteam/reference/obstacles/audit-walk-categories.md +13 -0
- package/skills/bugteam/reference/obstacles/audit-write-xml.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-append-summary.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-apply-fixes.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-git-add-commit.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-git-push.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-post-reply.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-publish-summary.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-py-compile.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-read-files.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-resolve-thread.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-test-suite.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-violation-count.md +13 -0
- package/skills/bugteam/reference/obstacles/fix-write-xml.md +13 -0
- package/skills/bugteam/reference/team-setup.md +111 -9
- package/skills/bugteam/reference/teardown-publish-permissions.md +39 -8
- package/skills/bugteam/scripts/README.md +60 -0
- package/skills/bugteam/scripts/_claude_permissions_common.py +358 -0
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +976 -0
- package/skills/bugteam/scripts/bugteam_fix_hookspath.py +375 -0
- package/skills/bugteam/scripts/bugteam_preflight.py +328 -0
- package/skills/bugteam/scripts/config/bugteam_code_rules_gate_constants.py +25 -0
- package/skills/bugteam/scripts/config/bugteam_fix_hookspath_constants.py +26 -0
- package/skills/bugteam/scripts/config/bugteam_preflight_constants.py +35 -0
- package/skills/bugteam/scripts/config/claude_permissions_common_constants.py +20 -0
- package/skills/bugteam/scripts/config/probe_code_rules_enforcer_check_constants.py +12 -0
- package/skills/bugteam/scripts/config/windows_safe_rmtree_constants.py +7 -0
- package/skills/bugteam/scripts/grant_project_claude_permissions.py +175 -0
- package/skills/bugteam/scripts/probe_code_rules_enforcer_check.py +107 -0
- package/skills/bugteam/scripts/revoke_project_claude_permissions.py +220 -0
- package/skills/bugteam/scripts/test__claude_permissions_common.py +112 -0
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +400 -0
- package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +384 -0
- package/skills/bugteam/scripts/test_bugteam_preflight.py +309 -0
- package/skills/bugteam/scripts/test_claude_permissions_common.py +195 -0
- package/skills/bugteam/scripts/test_grant_project_claude_permissions.py +55 -0
- package/skills/bugteam/scripts/test_probe_code_rules_enforcer_check.py +76 -0
- package/skills/bugteam/scripts/test_revoke_project_claude_permissions.py +55 -0
- package/skills/bugteam/scripts/test_windows_safe_rmtree.py +108 -0
- package/skills/bugteam/scripts/windows_safe_rmtree.py +100 -0
- package/skills/bugteam/test_skill_additions.py +1 -11
- package/skills/code/SKILL.md +176 -0
- package/skills/copilot-review/SKILL.md +16 -0
- package/skills/doc-gist/SKILL.md +99 -0
- package/skills/doc-gist/references/examples/01-exploration-code-approaches.html +453 -0
- package/skills/doc-gist/references/examples/02-exploration-visual-designs.html +515 -0
- package/skills/doc-gist/references/examples/03-code-review-pr.html +638 -0
- package/skills/doc-gist/references/examples/04-code-understanding.html +491 -0
- package/skills/doc-gist/references/examples/05-design-system.html +629 -0
- package/skills/doc-gist/references/examples/06-component-variants.html +605 -0
- package/skills/doc-gist/references/examples/07-prototype-animation.html +455 -0
- package/skills/doc-gist/references/examples/08-prototype-interaction.html +396 -0
- package/skills/doc-gist/references/examples/09-slide-deck.html +592 -0
- package/skills/doc-gist/references/examples/10-svg-illustrations.html +492 -0
- package/skills/doc-gist/references/examples/11-status-report.html +528 -0
- package/skills/doc-gist/references/examples/12-incident-report.html +596 -0
- package/skills/doc-gist/references/examples/13-flowchart-diagram.html +395 -0
- package/skills/doc-gist/references/examples/14-research-feature-explainer.html +381 -0
- package/skills/doc-gist/references/examples/15-research-concept-explainer.html +368 -0
- package/skills/doc-gist/references/examples/16-implementation-plan.html +702 -0
- package/skills/doc-gist/references/examples/17-pr-writeup.html +595 -0
- package/skills/doc-gist/references/examples/18-editor-triage-board.html +573 -0
- package/skills/doc-gist/references/examples/19-editor-feature-flags.html +663 -0
- package/skills/doc-gist/references/examples/20-editor-prompt-tuner.html +722 -0
- package/skills/doc-gist/references/examples/README.md +5 -0
- package/skills/doc-gist/scripts/config/__init__.py +0 -0
- package/skills/doc-gist/scripts/config/gist_upload_constants.py +16 -0
- package/skills/doc-gist/scripts/gist_upload.py +177 -0
- package/skills/doc-gist/scripts/test_gist_upload.py +51 -0
- package/skills/findbugs/SKILL.md +96 -2
- package/skills/monitor-open-prs/SKILL.md +14 -32
- package/skills/monitor-open-prs/test_skill_contract.py +0 -11
- package/skills/pr-consistency-audit/SKILL.md +112 -0
- package/skills/pr-consistency-audit/reference/detection-rules.md +96 -0
- package/skills/pr-consistency-audit/reference/illustrations.md +78 -0
- package/skills/pr-converge/SKILL.md +229 -23
- package/skills/pr-converge/config/__init__.py +0 -0
- package/skills/pr-converge/config/constants.py +63 -0
- package/skills/pr-converge/reference/convergence-gates.md +138 -44
- package/skills/pr-converge/reference/examples.md +43 -11
- package/skills/pr-converge/reference/fix-protocol.md +6 -5
- package/skills/pr-converge/reference/ground-rules.md +5 -3
- package/skills/pr-converge/reference/multi-pr-orchestration.md +44 -19
- package/skills/pr-converge/reference/obstacles/fix-post-replies.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-publish-summary.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-push.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-read-filelines.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-reset-state.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-resolve-threads.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-spawn-clean-coder.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-stage-commit.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-trigger-bugbot.md +13 -0
- package/skills/pr-converge/reference/obstacles/fix-write-test.md +13 -0
- package/skills/pr-converge/reference/per-tick.md +107 -31
- package/skills/pr-converge/reference/state-schema.md +22 -1
- package/skills/pr-converge/reference/stop-conditions.md +9 -7
- package/skills/pr-converge/scripts/README.md +34 -46
- package/skills/pr-converge/scripts/check_bugbot_ci.py +279 -0
- package/skills/pr-converge/scripts/check_convergence.py +497 -0
- package/skills/pr-converge/scripts/check_pending_reviews.py +154 -0
- package/skills/pr-converge/scripts/config/pr_converge_constants.py +118 -0
- package/skills/pr-converge/scripts/fetch_copilot_reviews.py +134 -0
- package/skills/pr-converge/scripts/post_fix_reply.py +168 -0
- package/skills/pr-converge/scripts/test_check_bugbot_ci.py +312 -0
- package/skills/pr-converge/workflows/schedule-wakeup-loop.md +5 -12
- package/skills/qbug/SKILL.md +157 -27
- package/skills/session-log/SKILL.md +216 -114
- package/skills/session-tidy/SKILL.md +1 -1
- package/skills/skill-builder/SKILL.md +138 -56
- package/skills/skill-builder/references/delegation-map.md +72 -113
- package/skills/skill-builder/references/progressive-disclosure.md +122 -0
- package/skills/skill-builder/references/self-audit-checklist.md +92 -0
- package/skills/skill-builder/references/skill-types.md +228 -0
- package/skills/skill-builder/references/thariq-x-post-skills.json +33 -0
- package/skills/skill-builder/templates/gap-analysis.md +15 -8
- package/skills/skill-builder/workflows/improve-skill.md +86 -57
- package/skills/skill-builder/workflows/new-skill.md +80 -168
- package/skills/skill-builder/workflows/polish-skill.md +78 -54
- package/skills/structure-prompt/SKILL.md +50 -0
- package/skills/structure-prompt/reference/adversarial-tuning.md +62 -0
- package/skills/structure-prompt/reference/block-classification.md +27 -0
- package/skills/structure-prompt/reference/canonical-case.md +48 -0
- package/skills/structure-prompt/reference/citation-depth.md +70 -0
- package/skills/structure-prompt/reference/cleanup.md +33 -0
- package/skills/structure-prompt/reference/constraints.md +33 -0
- package/skills/structure-prompt/reference/directives.md +37 -0
- package/skills/structure-prompt/reference/examples.md +72 -0
- package/skills/structure-prompt/reference/instantiation.md +51 -0
- package/skills/structure-prompt/reference/output-contract.md +72 -0
- package/skills/structure-prompt/reference/per-category.md +23 -0
- package/skills/structure-prompt/reference/persona.md +38 -0
- package/skills/structure-prompt/reference/research.md +33 -0
- package/skills/structure-prompt/reference/structure.md +28 -0
- package/agents/code-standards-agent.md +0 -93
- package/agents/groq-coder.md +0 -113
- package/agents/plan-executor.md +0 -226
- package/agents/project-docs-analyzer.md +0 -53
- package/agents/project-structure-organizer-agent.md +0 -72
- package/agents/skill-to-agent-converter.md +0 -370
- package/agents/skill-writer-agent.md +0 -470
- package/agents/user-docs-writer.md +0 -67
- package/agents/workflow-visual-documenter.md +0 -82
- package/commands/readability-review.md +0 -20
- package/hooks/mypy.ini +0 -2
- package/hooks/notification/attention_needed_notify.py +0 -71
- package/hooks/notification/claude_notification_handler.py +0 -67
- package/hooks/notification/notification_utils.py +0 -267
- package/hooks/notification/subagent_complete_notify.py +0 -381
- package/hooks/notification/test_attention_needed_notify.py +0 -47
- package/hooks/notification/test_claude_notification_handler.py +0 -54
- package/hooks/notification/test_notification_utils.py +0 -91
- package/hooks/notification/test_subagent_complete_notify.py +0 -79
- package/scripts/config/groq_bugteam_config.py +0 -230
- package/scripts/config/test_groq_bugteam_config.py +0 -83
- package/scripts/config/test_spec_implementer_prompt.py +0 -32
- package/scripts/groq_bugteam.README.md +0 -131
- package/scripts/groq_bugteam.py +0 -647
- package/scripts/groq_bugteam_dotenv.py +0 -40
- package/scripts/groq_bugteam_spec.py +0 -226
- package/scripts/test_groq_bugteam.py +0 -529
- package/scripts/test_groq_bugteam_apply_fix_from_spec.py +0 -426
- package/scripts/test_groq_bugteam_dotenv.py +0 -66
- package/scripts/test_groq_bugteam_spec.py +0 -338
- package/skills/bugteam/SKILL_EVALS.md +0 -309
- package/skills/dream/SKILL.md +0 -118
- package/skills/ingest/SKILL.md +0 -40
- package/skills/npm-creator/SKILL.md +0 -187
- package/skills/readability-review/SKILL.md +0 -127
- package/skills/resume-review/SKILL.md +0 -261
- package/skills/rule-audit/SKILL.md +0 -307
- package/skills/rule-creator/SKILL.md +0 -150
- package/skills/searching-obsidian-vault/SKILL.md +0 -131
- package/skills/skill-writer/REFERENCE.md +0 -284
- package/skills/skill-writer/SKILL.md +0 -222
- 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))
|