claude-dev-env 1.93.0 → 1.94.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/_shared/pr-loop/CLAUDE.md +1 -0
- package/_shared/pr-loop/audit-contract.md +1 -1
- package/_shared/pr-loop/gh-payloads.md +3 -3
- package/_shared/pr-loop/post-audit-thread-contract.md +51 -0
- package/_shared/pr-loop/scripts/README.md +2 -2
- package/_shared/pr-loop/scripts/code_rules_gate.py +127 -13
- package/_shared/pr-loop/scripts/fix_hookspath.py +47 -12
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +4 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +146 -69
- package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -374
- package/_shared/pr-loop/state-schema.md +3 -22
- package/bin/CLAUDE.md +1 -1
- package/bin/install.mjs +2 -7
- package/bin/install.test.mjs +10 -4
- package/hooks/blocking/CLAUDE.md +2 -0
- package/hooks/blocking/pii_commit_command.py +398 -0
- package/hooks/blocking/pii_payload_scan.py +133 -0
- package/hooks/blocking/pii_prevention_blocker.py +151 -449
- package/hooks/blocking/pii_scanner.py +26 -1
- package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +2 -2
- package/hooks/blocking/test_pii_prevention_blocker.py +299 -0
- package/hooks/blocking/test_pii_scanner.py +27 -2
- package/hooks/blocking/test_verdict_directory_write_blocker.py +42 -7
- package/hooks/hooks_constants/CLAUDE.md +2 -2
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +0 -2
- package/hooks/hooks_constants/local_identity.py +65 -15
- package/hooks/hooks_constants/pii_prevention_constants.py +20 -8
- package/hooks/hooks_constants/pr_description_enforcer_constants.py +1 -1
- package/hooks/hooks_constants/test_local_identity.py +68 -0
- package/package.json +1 -1
- package/scripts/CLAUDE.md +10 -1
- package/scripts/Get-SessionAccount.ps1 +210 -0
- package/scripts/tests/CLAUDE.md +15 -1
- package/scripts/tests/Get-SessionAccount.Tests.ps1 +350 -0
- package/skills/CLAUDE.md +3 -25
- package/skills/anthropic-plan/SKILL.md +1 -13
- package/skills/autoconverge/CLAUDE.md +5 -2
- package/skills/autoconverge/SKILL.md +112 -387
- package/skills/autoconverge/reference/CLAUDE.md +4 -1
- package/skills/autoconverge/reference/closing-report.md +9 -2
- package/skills/autoconverge/reference/convergence.md +26 -22
- package/skills/autoconverge/reference/headless-safety.md +44 -0
- package/skills/autoconverge/reference/multi-pr.md +88 -0
- package/skills/autoconverge/reference/self-closing-loop.md +84 -0
- package/skills/autoconverge/reference/stop-conditions.md +21 -13
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +87 -27
- package/skills/autoconverge/workflow/converge.contract.test.mjs +40 -10
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +179 -47
- package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
- package/skills/autoconverge/workflow/converge.mjs +284 -94
- package/skills/bugteam/CLAUDE.md +1 -1
- package/skills/bugteam/CONSTRAINTS.md +3 -3
- package/skills/bugteam/PROMPTS.md +8 -19
- package/skills/bugteam/SKILL.md +13 -14
- package/skills/bugteam/reference/CLAUDE.md +1 -1
- package/skills/bugteam/reference/audit-and-teammates.md +2 -2
- package/skills/bugteam/reference/copilot-gap-analysis.md +2 -0
- package/skills/bugteam/reference/github-pr-reviews.md +56 -38
- package/skills/bugteam/reference/team-setup.md +11 -11
- package/skills/bugteam/scripts/CLAUDE.md +6 -6
- package/skills/bugteam/scripts/README.md +20 -8
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +13 -1463
- package/skills/bugteam/scripts/bugteam_fix_hookspath.py +23 -358
- package/skills/bugteam/scripts/bugteam_preflight.py +13 -315
- package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +2 -3
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +34 -1053
- package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +30 -347
- package/skills/bugteam/scripts/test_bugteam_preflight.py +30 -305
- package/skills/copilot-finding-triage/SKILL.md +80 -28
- package/skills/copilot-finding-triage/reference/tier-rubric.md +70 -11
- package/skills/copilot-finding-triage/templates/notification.md +8 -3
- package/skills/everything-search/SKILL.md +1 -2
- package/skills/grokify/SKILL.md +58 -0
- package/skills/grokify/templates/handoff-template.md +92 -0
- package/skills/orchestrator/SKILL.md +12 -8
- package/skills/orchestrator-refresh/SKILL.md +3 -3
- package/skills/pr-converge/SKILL.md +132 -233
- package/skills/pr-converge/reference/convergence-gates.md +102 -66
- package/skills/pr-converge/reference/examples.md +37 -25
- package/skills/pr-converge/reference/fix-protocol.md +7 -7
- package/skills/pr-converge/reference/multi-pr-orchestration.md +10 -4
- package/skills/pr-converge/reference/per-tick.md +64 -41
- package/skills/pr-converge/reference/state-schema.md +27 -2
- package/skills/pr-converge/scripts/CLAUDE.md +8 -0
- package/skills/pr-converge/scripts/README.md +4 -3
- package/skills/pr-converge/scripts/_pr_converge_path_setup.py +23 -0
- package/skills/pr-converge/scripts/check_convergence.py +189 -645
- package/skills/pr-converge/scripts/check_convergence_gates.py +235 -0
- package/skills/pr-converge/scripts/check_convergence_thread_gates.py +219 -0
- package/skills/pr-converge/scripts/conftest.py +8 -0
- package/skills/pr-converge/scripts/pr_converge_scripts_constants/CLAUDE.md +1 -0
- package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -0
- package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +26 -0
- package/skills/pr-converge/scripts/test_check_convergence.py +73 -228
- package/skills/pr-converge/scripts/test_check_convergence_contract.py +321 -0
- package/skills/pr-converge/scripts/test_check_convergence_gates.py +68 -0
- package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +75 -0
- package/skills/privacy-hygiene/SKILL.md +8 -7
- package/skills/reviewer-gates/SKILL.md +26 -8
- package/skills/session-log/SKILL.md +11 -12
- package/skills/session-tidy/SKILL.md +40 -30
- package/skills/skill-builder/CLAUDE.md +13 -10
- package/skills/skill-builder/SKILL.md +31 -31
- package/skills/skill-builder/references/CLAUDE.md +10 -6
- package/skills/skill-builder/references/delegation-map.md +13 -4
- package/skills/skill-builder/references/description-field.md +113 -0
- package/skills/skill-builder/references/self-audit-checklist.md +21 -4
- package/skills/skill-builder/references/skill-modularity.md +113 -0
- package/skills/skill-builder/references/skill-types.md +4 -0
- package/skills/skill-builder/templates/CLAUDE.md +4 -4
- package/skills/skill-builder/templates/gap-analysis.md +25 -1
- package/skills/skill-builder/workflows/CLAUDE.md +6 -4
- package/skills/skill-builder/workflows/improve-skill.md +17 -16
- package/skills/skill-builder/workflows/new-skill.md +34 -10
- package/skills/skill-builder/workflows/polish-skill.md +59 -43
- package/skills/usage-pause/SKILL.md +10 -14
- package/skills/bdd-protocol/CLAUDE.md +0 -26
- package/skills/bdd-protocol/SKILL.md +0 -30
- package/skills/bdd-protocol/references/CLAUDE.md +0 -10
- package/skills/bdd-protocol/references/anti-patterns.md +0 -26
- package/skills/bdd-protocol/references/example-mapping.md +0 -23
- package/skills/bg-agent/CLAUDE.md +0 -17
- package/skills/bg-agent/SKILL.md +0 -69
- package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +0 -55
- package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_fix_hookspath_constants.py +0 -26
- package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_preflight_constants.py +0 -35
- package/skills/caveman/CLAUDE.md +0 -15
- package/skills/caveman/SKILL.md +0 -38
- package/skills/code/CLAUDE.md +0 -17
- package/skills/code/SKILL.md +0 -178
- package/skills/deep-research/CLAUDE.md +0 -17
- package/skills/deep-research/SKILL.md +0 -80
- package/skills/findbugs/CLAUDE.md +0 -20
- package/skills/findbugs/SKILL.md +0 -241
- package/skills/fixbugs/CLAUDE.md +0 -19
- package/skills/fixbugs/SKILL.md +0 -142
- package/skills/gh-paginate/CLAUDE.md +0 -18
- package/skills/gh-paginate/SKILL.md +0 -84
- package/skills/gotcha/CLAUDE.md +0 -33
- package/skills/gotcha/SKILL.md +0 -73
- package/skills/implement/CLAUDE.md +0 -27
- package/skills/implement/SKILL.md +0 -66
- package/skills/implement/scripts/CLAUDE.md +0 -22
- package/skills/implement/scripts/append_note.py +0 -133
- package/skills/implement/scripts/implement_scripts_constants/CLAUDE.md +0 -22
- package/skills/implement/scripts/implement_scripts_constants/__init__.py +0 -0
- package/skills/implement/scripts/implement_scripts_constants/notes_constants.py +0 -12
- package/skills/implement/scripts/test_append_note.py +0 -191
- package/skills/log-audit/CLAUDE.md +0 -20
- package/skills/log-audit/SKILL.md +0 -66
- package/skills/log-audit/reference/CLAUDE.md +0 -9
- package/skills/log-audit/reference/charter.md +0 -52
- package/skills/log-audit/scripts/CLAUDE.md +0 -27
- package/skills/log-audit/scripts/cluster_recurrences.py +0 -261
- package/skills/log-audit/scripts/collect_log_window.py +0 -199
- package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +0 -12
- package/skills/log-audit/scripts/log_audit_constants/__init__.py +0 -0
- package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +0 -23
- package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +0 -24
- package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +0 -49
- package/skills/log-audit/scripts/mine_copilot_findings.py +0 -302
- package/skills/log-audit/scripts/test_cluster_recurrences.py +0 -160
- package/skills/log-audit/scripts/test_collect_log_window.py +0 -111
- package/skills/log-audit/scripts/test_mine_copilot_findings.py +0 -126
- package/skills/logifix/CLAUDE.md +0 -36
- package/skills/logifix/SKILL.md +0 -69
- package/skills/logifix/scripts/CLAUDE.md +0 -16
- package/skills/logifix/scripts/logifix.ps1 +0 -205
- package/skills/monitor-open-prs/CLAUDE.md +0 -34
- package/skills/monitor-open-prs/SKILL.md +0 -88
- package/skills/monitor-open-prs/scripts/CLAUDE.md +0 -17
- package/skills/monitor-open-prs/scripts/discover_open_prs.py +0 -69
- package/skills/monitor-open-prs/scripts/test_discover_open_prs.py +0 -149
- package/skills/monitor-open-prs/test_skill_contract.py +0 -27
- package/skills/post-audit-findings/SKILL.md +0 -80
- package/skills/pr-consistency-audit/CLAUDE.md +0 -34
- package/skills/pr-consistency-audit/SKILL.md +0 -116
- package/skills/pr-consistency-audit/reference/CLAUDE.md +0 -16
- package/skills/pr-consistency-audit/reference/detection-rules.md +0 -96
- package/skills/pr-consistency-audit/reference/illustrations.md +0 -78
- package/skills/pr-fix-protocol/SKILL.md +0 -65
- package/skills/pr-review-responder/CLAUDE.md +0 -35
- package/skills/pr-review-responder/EXAMPLES.md +0 -590
- package/skills/pr-review-responder/PRINCIPLES.md +0 -533
- package/skills/pr-review-responder/README.md +0 -168
- package/skills/pr-review-responder/SKILL.md +0 -203
- package/skills/pr-review-responder/TESTING.md +0 -350
- package/skills/pr-scope-resolve/SKILL.md +0 -45
- package/skills/pre-compact/CLAUDE.md +0 -24
- package/skills/pre-compact/SKILL.md +0 -134
- package/skills/qbug/CLAUDE.md +0 -40
- package/skills/qbug/SKILL.md +0 -387
- package/skills/qbug/test_qbug_skill_audit_schema.py +0 -152
- package/skills/qbug/test_qbug_skill_post_fix_audit.py +0 -103
- package/skills/refine/CLAUDE.md +0 -44
- package/skills/refine/SKILL.md +0 -262
- package/skills/refine/templates/CLAUDE.md +0 -17
- package/skills/refine/templates/implementation-notes-template.html +0 -56
- package/skills/refine/templates/plan-template.md +0 -60
- package/skills/research-mode/CLAUDE.md +0 -35
- package/skills/research-mode/SKILL.md +0 -53
- package/skills/structure-prompt/CLAUDE.md +0 -42
- package/skills/structure-prompt/SKILL.md +0 -44
- package/skills/structure-prompt/reference/CLAUDE.md +0 -28
- package/skills/structure-prompt/reference/adversarial-tuning.md +0 -62
- package/skills/structure-prompt/reference/block-classification.md +0 -27
- package/skills/structure-prompt/reference/canonical-case.md +0 -48
- package/skills/structure-prompt/reference/citation-depth.md +0 -70
- package/skills/structure-prompt/reference/cleanup.md +0 -33
- package/skills/structure-prompt/reference/constraints.md +0 -33
- package/skills/structure-prompt/reference/directives.md +0 -37
- package/skills/structure-prompt/reference/examples.md +0 -72
- package/skills/structure-prompt/reference/instantiation.md +0 -51
- package/skills/structure-prompt/reference/output-contract.md +0 -72
- package/skills/structure-prompt/reference/per-category.md +0 -23
- package/skills/structure-prompt/reference/persona.md +0 -38
- package/skills/structure-prompt/reference/research.md +0 -33
- package/skills/structure-prompt/reference/structure.md +0 -28
- package/skills/verified-build/CLAUDE.md +0 -33
- package/skills/verified-build/SKILL.md +0 -33
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Review and Bugbot IO leaves for the convergence gate checks.
|
|
2
|
+
|
|
3
|
+
Each function issues one ``gh`` REST call and returns a ``(passed, detail)``
|
|
4
|
+
pair the convergence checker turns into a PASS/FAIL line, or a raw
|
|
5
|
+
``(returncode, stdout)`` pair for the low-level callers. The checker re-imports
|
|
6
|
+
these names, so a test patches them on the checker module.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
import _pr_converge_path_setup # noqa: F401
|
|
17
|
+
from pr_converge_skill_constants.constants import (
|
|
18
|
+
ALL_BUGBOT_CHECK_RUN_COMPLETE_CONCLUSIONS,
|
|
19
|
+
BUGBOT_CHECK_RUN_NAME_SUBSTRING,
|
|
20
|
+
BUGBOT_DIRTY_BODY_REGEX,
|
|
21
|
+
CHECK_RUNS_PER_PAGE,
|
|
22
|
+
CURSOR_LOGIN_FILTER_SUBSTRING,
|
|
23
|
+
EXIT_CODE_GH_ERROR,
|
|
24
|
+
GH_CHECK_RUNS_PATH_TEMPLATE,
|
|
25
|
+
GH_PR_OBJECT_PATH_TEMPLATE,
|
|
26
|
+
GH_REVIEWS_PATH_TEMPLATE,
|
|
27
|
+
REVIEWS_PER_PAGE,
|
|
28
|
+
)
|
|
29
|
+
from pr_converge_scripts_constants.convergence_gate_constants import SHORT_SHA_LENGTH
|
|
30
|
+
|
|
31
|
+
JsonObject = dict[str, object]
|
|
32
|
+
ReviewStateGroup = tuple[str, ...]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _run_gh_command(all_arguments: list[str]) -> tuple[int, str]:
|
|
36
|
+
completed_process = subprocess.run(
|
|
37
|
+
all_arguments,
|
|
38
|
+
capture_output=True,
|
|
39
|
+
text=True,
|
|
40
|
+
encoding="utf-8",
|
|
41
|
+
errors="replace",
|
|
42
|
+
check=False,
|
|
43
|
+
)
|
|
44
|
+
return completed_process.returncode, completed_process.stdout
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _gh_api(endpoint_path: str) -> tuple[int, str]:
|
|
48
|
+
return _run_gh_command(["gh", "api", endpoint_path])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _gh_api_paginated(endpoint_path: str) -> tuple[int, str]:
|
|
52
|
+
return _run_gh_command(["gh", "api", endpoint_path, "--paginate", "--slurp"])
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _short_sha(commit_id: str) -> str:
|
|
56
|
+
return commit_id[:SHORT_SHA_LENGTH]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _get_pr_head_sha(*, owner: str, repo: str, number: int) -> str:
|
|
60
|
+
"""Return the PR HEAD SHA, exiting with the gh-error code when the call fails."""
|
|
61
|
+
endpoint = GH_PR_OBJECT_PATH_TEMPLATE.format(owner=owner, repo=repo, number=number)
|
|
62
|
+
returncode, stdout = _gh_api(endpoint)
|
|
63
|
+
if returncode != 0:
|
|
64
|
+
sys.stderr.write(f"gh api error fetching PR object: {stdout}\n")
|
|
65
|
+
raise SystemExit(EXIT_CODE_GH_ERROR)
|
|
66
|
+
pr_object = json.loads(stdout)
|
|
67
|
+
head_sha: object = pr_object.get("head", {}).get("sha")
|
|
68
|
+
if not isinstance(head_sha, str):
|
|
69
|
+
raise SystemExit(EXIT_CODE_GH_ERROR)
|
|
70
|
+
return head_sha
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _get_mergeable(*, owner: str, repo: str, number: int) -> tuple[bool, str]:
|
|
74
|
+
"""Return whether the PR is cleanly mergeable, with the mergeable-state detail."""
|
|
75
|
+
endpoint = GH_PR_OBJECT_PATH_TEMPLATE.format(owner=owner, repo=repo, number=number)
|
|
76
|
+
returncode, stdout = _gh_api(endpoint)
|
|
77
|
+
if returncode != 0:
|
|
78
|
+
return False, f"gh api error: {stdout}"
|
|
79
|
+
pr_object = json.loads(stdout)
|
|
80
|
+
mergeable: object = pr_object.get("mergeable")
|
|
81
|
+
mergeable_state: object = pr_object.get("mergeable_state", "unknown")
|
|
82
|
+
state_text = str(mergeable_state)
|
|
83
|
+
if mergeable is True and state_text == "clean":
|
|
84
|
+
return True, "clean"
|
|
85
|
+
return False, state_text
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _bugbot_check_runs(
|
|
89
|
+
*, owner: str, repo: str, sha: str
|
|
90
|
+
) -> tuple[int, list[JsonObject]]:
|
|
91
|
+
"""Fetch the check runs for a SHA and return the returncode and run dicts."""
|
|
92
|
+
endpoint = GH_CHECK_RUNS_PATH_TEMPLATE.format(owner=owner, repo=repo, sha=sha)
|
|
93
|
+
returncode, stdout = _gh_api(f"{endpoint}?per_page={CHECK_RUNS_PER_PAGE}")
|
|
94
|
+
if returncode != 0:
|
|
95
|
+
return returncode, []
|
|
96
|
+
try:
|
|
97
|
+
payload = json.loads(stdout)
|
|
98
|
+
except json.JSONDecodeError:
|
|
99
|
+
return returncode, []
|
|
100
|
+
raw_runs = payload.get("check_runs") if isinstance(payload, dict) else None
|
|
101
|
+
if not isinstance(raw_runs, list):
|
|
102
|
+
return returncode, []
|
|
103
|
+
return returncode, [each_run for each_run in raw_runs if isinstance(each_run, dict)]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _bugbot_run_conclusion_detail(check_run: JsonObject) -> tuple[bool, str]:
|
|
107
|
+
"""Return the pass flag and detail for one bugbot check run."""
|
|
108
|
+
conclusion = check_run.get("conclusion", "")
|
|
109
|
+
if conclusion in ALL_BUGBOT_CHECK_RUN_COMPLETE_CONCLUSIONS:
|
|
110
|
+
check_id = check_run.get("id", "?")
|
|
111
|
+
detail_url = check_run.get("html_url", "")
|
|
112
|
+
details_suffix = f" ({detail_url})" if detail_url else ""
|
|
113
|
+
return True, f"check run {check_id}, conclusion: {conclusion}{details_suffix}"
|
|
114
|
+
return (
|
|
115
|
+
False,
|
|
116
|
+
f"check run conclusion is '{conclusion}', expected {ALL_BUGBOT_CHECK_RUN_COMPLETE_CONCLUSIONS}",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _check_bugbot(*, owner: str, repo: str, sha: str) -> tuple[bool, str]:
|
|
121
|
+
"""Return whether the Cursor Bugbot check run on a SHA completed successfully."""
|
|
122
|
+
returncode, check_runs = _bugbot_check_runs(owner=owner, repo=repo, sha=sha)
|
|
123
|
+
if returncode != 0:
|
|
124
|
+
return False, "gh api error fetching check runs"
|
|
125
|
+
for each_check_run in check_runs:
|
|
126
|
+
each_name = each_check_run.get("name", "")
|
|
127
|
+
if not isinstance(each_name, str):
|
|
128
|
+
continue
|
|
129
|
+
if BUGBOT_CHECK_RUN_NAME_SUBSTRING.lower() not in each_name.lower():
|
|
130
|
+
continue
|
|
131
|
+
return _bugbot_run_conclusion_detail(each_check_run)
|
|
132
|
+
return False, "no bugbot check run found"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _flatten_paginated_reviews(stdout: str) -> list[JsonObject] | None:
|
|
136
|
+
"""Flatten a slurped, paginated reviews payload into a newest-first list."""
|
|
137
|
+
try:
|
|
138
|
+
reviews_payload = json.loads(stdout)
|
|
139
|
+
except json.JSONDecodeError:
|
|
140
|
+
return None
|
|
141
|
+
if not isinstance(reviews_payload, list):
|
|
142
|
+
return None
|
|
143
|
+
all_flat: list[JsonObject] = [
|
|
144
|
+
each_entry
|
|
145
|
+
for each_page in reviews_payload
|
|
146
|
+
if isinstance(each_page, list)
|
|
147
|
+
for each_entry in each_page
|
|
148
|
+
if isinstance(each_entry, dict)
|
|
149
|
+
]
|
|
150
|
+
all_flat.sort(
|
|
151
|
+
key=lambda each_review: str(each_review.get("submitted_at", "")), reverse=True
|
|
152
|
+
)
|
|
153
|
+
return all_flat
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _review_matches_login(review: JsonObject, login_substring: str) -> bool:
|
|
157
|
+
user_object = review.get("user")
|
|
158
|
+
if not isinstance(user_object, dict):
|
|
159
|
+
return False
|
|
160
|
+
login = user_object.get("login", "")
|
|
161
|
+
return isinstance(login, str) and login_substring in login.lower()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _review_commit_starts_with(review: JsonObject, head_sha: str) -> bool:
|
|
165
|
+
commit_id = review.get("commit_id", "")
|
|
166
|
+
return isinstance(commit_id, str) and commit_id.startswith(head_sha)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _check_bugbot_not_dirty(
|
|
170
|
+
*, owner: str, repo: str, number: int, head_sha: str
|
|
171
|
+
) -> tuple[bool, str]:
|
|
172
|
+
"""Return whether the newest Cursor Bugbot review on HEAD reports no findings."""
|
|
173
|
+
endpoint = GH_REVIEWS_PATH_TEMPLATE.format(owner=owner, repo=repo, number=number)
|
|
174
|
+
returncode, stdout = _gh_api_paginated(f"{endpoint}?per_page={REVIEWS_PER_PAGE}")
|
|
175
|
+
if returncode != 0:
|
|
176
|
+
return True, "bugbot reviews unavailable (non-fatal)"
|
|
177
|
+
all_flat = _flatten_paginated_reviews(stdout)
|
|
178
|
+
if all_flat is None:
|
|
179
|
+
return True, "bugbot reviews not valid JSON (non-fatal)"
|
|
180
|
+
dirty_pattern = re.compile(BUGBOT_DIRTY_BODY_REGEX, re.IGNORECASE)
|
|
181
|
+
for each_review in all_flat:
|
|
182
|
+
if not _review_matches_login(each_review, CURSOR_LOGIN_FILTER_SUBSTRING):
|
|
183
|
+
continue
|
|
184
|
+
if not _review_commit_starts_with(each_review, head_sha):
|
|
185
|
+
continue
|
|
186
|
+
body = each_review.get("body", "")
|
|
187
|
+
if isinstance(body, str) and dirty_pattern.search(body):
|
|
188
|
+
return False, "bugbot review body reports findings"
|
|
189
|
+
return True, "clean"
|
|
190
|
+
return True, "no bugbot review at HEAD"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _bot_review_state_detail(
|
|
194
|
+
review: JsonObject,
|
|
195
|
+
clean_states: ReviewStateGroup,
|
|
196
|
+
dirty_states: ReviewStateGroup,
|
|
197
|
+
) -> tuple[bool, str]:
|
|
198
|
+
"""Return the pass flag and detail for one bot review's state."""
|
|
199
|
+
review_state = review.get("state", "")
|
|
200
|
+
commit_id = review.get("commit_id", "")
|
|
201
|
+
short_commit = _short_sha(commit_id) if isinstance(commit_id, str) else "?"
|
|
202
|
+
if review_state in clean_states:
|
|
203
|
+
review_id = review.get("id", "?")
|
|
204
|
+
return True, f"review {review_id}, state: {review_state}, commit {short_commit}"
|
|
205
|
+
if review_state in dirty_states:
|
|
206
|
+
return False, f"review state is '{review_state}' (dirty), commit {short_commit}"
|
|
207
|
+
return False, f"review state is '{review_state}', commit {short_commit}"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _check_bot_review(
|
|
211
|
+
*,
|
|
212
|
+
owner: str,
|
|
213
|
+
repo: str,
|
|
214
|
+
number: int,
|
|
215
|
+
head_sha: str,
|
|
216
|
+
login_substring: str,
|
|
217
|
+
clean_states: ReviewStateGroup,
|
|
218
|
+
dirty_states: ReviewStateGroup,
|
|
219
|
+
label: str,
|
|
220
|
+
) -> tuple[bool, str]:
|
|
221
|
+
"""Return whether the newest matching bot review on HEAD is in a clean state."""
|
|
222
|
+
endpoint = GH_REVIEWS_PATH_TEMPLATE.format(owner=owner, repo=repo, number=number)
|
|
223
|
+
returncode, stdout = _gh_api_paginated(f"{endpoint}?per_page={REVIEWS_PER_PAGE}")
|
|
224
|
+
if returncode != 0:
|
|
225
|
+
return False, f"gh api error: {stdout}"
|
|
226
|
+
all_flat = _flatten_paginated_reviews(stdout)
|
|
227
|
+
if all_flat is None:
|
|
228
|
+
return False, f"no {label} review found"
|
|
229
|
+
for each_review in all_flat:
|
|
230
|
+
if not _review_matches_login(each_review, login_substring):
|
|
231
|
+
continue
|
|
232
|
+
if not _review_commit_starts_with(each_review, head_sha):
|
|
233
|
+
continue
|
|
234
|
+
return _bot_review_state_detail(each_review, clean_states, dirty_states)
|
|
235
|
+
return False, f"no {label} review found on {_short_sha(head_sha)}"
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Review-thread and pending-reviewer IO leaves for the convergence gate checks.
|
|
2
|
+
|
|
3
|
+
Each function issues one ``gh`` call (GraphQL or REST) and returns a
|
|
4
|
+
``(passed, detail)`` pair the convergence checker turns into a PASS/FAIL line.
|
|
5
|
+
The checker re-imports these names, so a test patches them on the checker module.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
import _pr_converge_path_setup # noqa: F401
|
|
13
|
+
from check_convergence_gates import (
|
|
14
|
+
JsonObject,
|
|
15
|
+
ReviewStateGroup,
|
|
16
|
+
_gh_api,
|
|
17
|
+
_run_gh_command,
|
|
18
|
+
)
|
|
19
|
+
from pr_converge_skill_constants.constants import (
|
|
20
|
+
CLAUDE_LOGIN_FILTER_SUBSTRING,
|
|
21
|
+
COPILOT_LOGIN_FILTER_SUBSTRING,
|
|
22
|
+
COPILOT_REVIEWER_LOGIN,
|
|
23
|
+
CURSOR_LOGIN_FILTER_SUBSTRING,
|
|
24
|
+
GH_REQUESTED_REVIEWERS_PATH_TEMPLATE,
|
|
25
|
+
GRAPHQL_REVIEW_THREADS_PAGE_SIZE,
|
|
26
|
+
UNRESOLVED_THREAD_DETAIL_MAX,
|
|
27
|
+
)
|
|
28
|
+
from pr_converge_scripts_constants.convergence_gate_constants import (
|
|
29
|
+
GRAPHQL_REVIEW_THREADS_QUERY,
|
|
30
|
+
PENDING_REVIEWER_JOIN_SEPARATOR,
|
|
31
|
+
THREAD_PATH_JOIN_SEPARATOR,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _gh_graphql(query: str, variables: JsonObject) -> tuple[int, str]:
|
|
36
|
+
"""Run a ``gh api graphql`` query with variables and return returncode and stdout."""
|
|
37
|
+
all_arguments: list[str] = ["gh", "api", "graphql", "-f", f"query={query}"]
|
|
38
|
+
for each_key, each_variable in variables.items():
|
|
39
|
+
if each_variable is None:
|
|
40
|
+
continue
|
|
41
|
+
flag = "-F" if isinstance(each_variable, int) else "-f"
|
|
42
|
+
all_arguments.extend([flag, f"{each_key}={each_variable}"])
|
|
43
|
+
return _run_gh_command(all_arguments)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _thread_author_login(thread: JsonObject) -> str:
|
|
47
|
+
"""Return the login of a review thread's first comment author, or empty string."""
|
|
48
|
+
comments_wrapper = thread.get("comments", {})
|
|
49
|
+
if not isinstance(comments_wrapper, dict):
|
|
50
|
+
return ""
|
|
51
|
+
comments_nodes = comments_wrapper.get("nodes", [])
|
|
52
|
+
if not isinstance(comments_nodes, list) or not comments_nodes:
|
|
53
|
+
return ""
|
|
54
|
+
first_comment = comments_nodes[0]
|
|
55
|
+
author_wrapper = (
|
|
56
|
+
first_comment.get("author") if isinstance(first_comment, dict) else None
|
|
57
|
+
)
|
|
58
|
+
if not isinstance(author_wrapper, dict):
|
|
59
|
+
return ""
|
|
60
|
+
login = author_wrapper.get("login", "")
|
|
61
|
+
return login if isinstance(login, str) else ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _is_unresolved_bot_thread(thread: JsonObject, bot_logins: ReviewStateGroup) -> bool:
|
|
65
|
+
"""Return whether a review thread is unresolved, current, and bot-authored."""
|
|
66
|
+
if thread.get("isResolved") is True or thread.get("isOutdated") is True:
|
|
67
|
+
return False
|
|
68
|
+
login = _thread_author_login(thread).lower()
|
|
69
|
+
return any(each_bot in login for each_bot in bot_logins)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _unresolved_bot_threads_in_page(
|
|
73
|
+
nodes: object, bot_logins: ReviewStateGroup
|
|
74
|
+
) -> list[JsonObject]:
|
|
75
|
+
"""Return the unresolved bot-authored threads among one page of thread nodes."""
|
|
76
|
+
if not isinstance(nodes, list):
|
|
77
|
+
return []
|
|
78
|
+
return [
|
|
79
|
+
each_thread
|
|
80
|
+
for each_thread in nodes
|
|
81
|
+
if isinstance(each_thread, dict)
|
|
82
|
+
and _is_unresolved_bot_thread(each_thread, bot_logins)
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _review_threads_container(graphql_payload: object) -> JsonObject | None:
|
|
87
|
+
"""Return the ``reviewThreads`` object from a GraphQL payload, or None."""
|
|
88
|
+
if not isinstance(graphql_payload, dict):
|
|
89
|
+
return None
|
|
90
|
+
envelope = graphql_payload.get("data", {})
|
|
91
|
+
repository = envelope.get("repository", {}) if isinstance(envelope, dict) else {}
|
|
92
|
+
pull_request = (
|
|
93
|
+
repository.get("pullRequest", {}) if isinstance(repository, dict) else {}
|
|
94
|
+
)
|
|
95
|
+
threads = (
|
|
96
|
+
pull_request.get("reviewThreads", {}) if isinstance(pull_request, dict) else {}
|
|
97
|
+
)
|
|
98
|
+
return threads if isinstance(threads, dict) else None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _fetch_review_threads_page(
|
|
102
|
+
*, owner: str, repo: str, number: int, cursor: str | None
|
|
103
|
+
) -> tuple[str, object, object]:
|
|
104
|
+
"""Fetch one page of review threads and return an error text, nodes, and page info."""
|
|
105
|
+
variables: JsonObject = {
|
|
106
|
+
"owner": owner,
|
|
107
|
+
"repo": repo,
|
|
108
|
+
"number": number,
|
|
109
|
+
"first": GRAPHQL_REVIEW_THREADS_PAGE_SIZE,
|
|
110
|
+
"cursor": cursor,
|
|
111
|
+
}
|
|
112
|
+
returncode, stdout = _gh_graphql(GRAPHQL_REVIEW_THREADS_QUERY, variables)
|
|
113
|
+
if returncode != 0:
|
|
114
|
+
return f"gh api graphql error: {stdout}", [], {}
|
|
115
|
+
try:
|
|
116
|
+
graphql_payload = json.loads(stdout)
|
|
117
|
+
except json.JSONDecodeError:
|
|
118
|
+
return "gh api graphql response not valid JSON", [], {}
|
|
119
|
+
threads = _review_threads_container(graphql_payload)
|
|
120
|
+
if threads is None:
|
|
121
|
+
return "unexpected GraphQL response shape", [], {}
|
|
122
|
+
return "", threads.get("nodes", []), threads.get("pageInfo", {})
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _next_thread_cursor(page_info: object) -> str | None:
|
|
126
|
+
"""Return the next pagination cursor, or None when there is no next page."""
|
|
127
|
+
if not isinstance(page_info, dict):
|
|
128
|
+
return None
|
|
129
|
+
if not page_info.get("hasNextPage"):
|
|
130
|
+
return None
|
|
131
|
+
next_cursor = page_info.get("endCursor")
|
|
132
|
+
return next_cursor if isinstance(next_cursor, str) else None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _format_unresolved_detail(all_unresolved: list[JsonObject]) -> str:
|
|
136
|
+
"""Format the unresolved-thread count and a bounded list of their paths."""
|
|
137
|
+
detail_parts = [
|
|
138
|
+
str(each_thread.get("path", "?"))
|
|
139
|
+
for each_thread in all_unresolved[:UNRESOLVED_THREAD_DETAIL_MAX]
|
|
140
|
+
]
|
|
141
|
+
detail_text = THREAD_PATH_JOIN_SEPARATOR.join(detail_parts)
|
|
142
|
+
if len(all_unresolved) > UNRESOLVED_THREAD_DETAIL_MAX:
|
|
143
|
+
detail_text += (
|
|
144
|
+
f" ... and {len(all_unresolved) - UNRESOLVED_THREAD_DETAIL_MAX} more"
|
|
145
|
+
)
|
|
146
|
+
return f"{len(all_unresolved)} unresolved ({detail_text})"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _count_unresolved_bot_threads(
|
|
150
|
+
*, owner: str, repo: str, number: int
|
|
151
|
+
) -> tuple[bool, str]:
|
|
152
|
+
"""Return whether zero unresolved bot review threads remain on the PR."""
|
|
153
|
+
bot_logins = (
|
|
154
|
+
CURSOR_LOGIN_FILTER_SUBSTRING,
|
|
155
|
+
CLAUDE_LOGIN_FILTER_SUBSTRING,
|
|
156
|
+
COPILOT_LOGIN_FILTER_SUBSTRING,
|
|
157
|
+
)
|
|
158
|
+
all_unresolved: list[JsonObject] = []
|
|
159
|
+
cursor: str | None = None
|
|
160
|
+
while True:
|
|
161
|
+
error_text, nodes, page_info = _fetch_review_threads_page(
|
|
162
|
+
owner=owner, repo=repo, number=number, cursor=cursor
|
|
163
|
+
)
|
|
164
|
+
if error_text:
|
|
165
|
+
return False, error_text
|
|
166
|
+
all_unresolved.extend(_unresolved_bot_threads_in_page(nodes, bot_logins))
|
|
167
|
+
cursor = _next_thread_cursor(page_info)
|
|
168
|
+
if cursor is None:
|
|
169
|
+
break
|
|
170
|
+
if not all_unresolved:
|
|
171
|
+
return True, "0 unresolved"
|
|
172
|
+
return False, _format_unresolved_detail(all_unresolved)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _requested_reviewer_users(pending_payload: object) -> list[object]:
|
|
176
|
+
if isinstance(pending_payload, dict):
|
|
177
|
+
users = pending_payload.get("users", [])
|
|
178
|
+
elif isinstance(pending_payload, list):
|
|
179
|
+
users = pending_payload
|
|
180
|
+
else:
|
|
181
|
+
return []
|
|
182
|
+
return users if isinstance(users, list) else []
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _pending_copilot_logins(all_users: list[object]) -> list[str]:
|
|
186
|
+
"""Return the Copilot reviewer logins still pending among requested reviewers."""
|
|
187
|
+
pending: list[str] = []
|
|
188
|
+
for each_user in all_users:
|
|
189
|
+
if not isinstance(each_user, dict):
|
|
190
|
+
continue
|
|
191
|
+
login = each_user.get("login", "")
|
|
192
|
+
if isinstance(login, str) and COPILOT_REVIEWER_LOGIN.lower() in login.lower():
|
|
193
|
+
pending.append(login)
|
|
194
|
+
return pending
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _check_no_pending_reviews(
|
|
198
|
+
*, owner: str, repo: str, number: int
|
|
199
|
+
) -> tuple[bool, str]:
|
|
200
|
+
"""Return whether no Copilot review request is still pending on the PR."""
|
|
201
|
+
endpoint = GH_REQUESTED_REVIEWERS_PATH_TEMPLATE.format(
|
|
202
|
+
owner=owner, repo=repo, number=number
|
|
203
|
+
)
|
|
204
|
+
returncode, stdout = _gh_api(endpoint)
|
|
205
|
+
if returncode != 0:
|
|
206
|
+
return False, f"gh api error: {stdout}"
|
|
207
|
+
try:
|
|
208
|
+
pending_payload = json.loads(stdout)
|
|
209
|
+
except json.JSONDecodeError:
|
|
210
|
+
return True, "no pending (empty response)"
|
|
211
|
+
copilot_pending = _pending_copilot_logins(
|
|
212
|
+
_requested_reviewer_users(pending_payload)
|
|
213
|
+
)
|
|
214
|
+
if copilot_pending:
|
|
215
|
+
return (
|
|
216
|
+
False,
|
|
217
|
+
f"pending: {PENDING_REVIEWER_JOIN_SEPARATOR.join(copilot_pending)}",
|
|
218
|
+
)
|
|
219
|
+
return True, "no pending reviewers"
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Put the pr-converge scripts directory on sys.path for the test suite."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
_scripts_directory = Path(__file__).resolve().parent
|
|
7
|
+
if str(_scripts_directory) not in sys.path:
|
|
8
|
+
sys.path.insert(0, str(_scripts_directory))
|
|
@@ -9,6 +9,7 @@ Constants module for the `pr-converge` skill's Python scripts. Re-exports all ru
|
|
|
9
9
|
| `__init__.py` | Package marker |
|
|
10
10
|
| `pr_converge_constants.py` | Re-exports all constants from `pr_converge_skill_constants.constants` plus adds script-specific values |
|
|
11
11
|
| `reflow_skill_md_constants.py` | Constants for `reflow_skill_md.py`: line width limit, list item patterns, `TARGET_SKILL_PATH` |
|
|
12
|
+
| `convergence_gate_constants.py` | Constants for `check_convergence_gates.py`: short-SHA length, join separators, and the review-threads GraphQL query |
|
|
12
13
|
|
|
13
14
|
## Conventions
|
|
14
15
|
|
package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Named constants for the convergence gate IO leaves.
|
|
2
|
+
|
|
3
|
+
Holds the short-SHA length, the join separators for gate detail strings, and
|
|
4
|
+
the GraphQL query that lists a PR's review threads.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
SHORT_SHA_LENGTH: int = 7
|
|
8
|
+
|
|
9
|
+
THREAD_PATH_JOIN_SEPARATOR: str = "; "
|
|
10
|
+
|
|
11
|
+
PENDING_REVIEWER_JOIN_SEPARATOR: str = ", "
|
|
12
|
+
|
|
13
|
+
GRAPHQL_REVIEW_THREADS_QUERY: str = """
|
|
14
|
+
query($owner: String!, $repo: String!, $number: Int!, $first: Int!, $cursor: String) {
|
|
15
|
+
repository(owner: $owner, name: $repo) {
|
|
16
|
+
pullRequest(number: $number) {
|
|
17
|
+
reviewThreads(first: $first, after: $cursor) {
|
|
18
|
+
nodes {
|
|
19
|
+
isResolved
|
|
20
|
+
isOutdated
|
|
21
|
+
path
|
|
22
|
+
comments(first: 1) {
|
|
23
|
+
nodes {
|
|
24
|
+
author { login }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
pageInfo {
|
|
29
|
+
hasNextPage
|
|
30
|
+
endCursor
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
"""
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Importing _pr_converge_path_setup registers the skill and shared directories.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
import _pr_converge_path_setup
|
|
6
|
+
ok: str(skill_directory) in sys.path
|
|
7
|
+
ok: str(shared_pr_loop_scripts_directory) in sys.path
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import _pr_converge_path_setup # noqa: F401
|
|
16
|
+
|
|
17
|
+
_SCRIPTS_DIRECTORY = Path(__file__).absolute().parent
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_path_setup_registers_skill_and_shared_directories() -> None:
|
|
21
|
+
skill_directory = _SCRIPTS_DIRECTORY.parent
|
|
22
|
+
shared_directory = (
|
|
23
|
+
_SCRIPTS_DIRECTORY.parent.parent.parent / "_shared" / "pr-loop" / "scripts"
|
|
24
|
+
)
|
|
25
|
+
assert str(skill_directory) in sys.path
|
|
26
|
+
assert str(shared_directory) in sys.path
|