claude-dev-env 1.41.0 → 1.42.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 +8 -0
- package/_shared/pr-loop/scripts/_claude_permissions_common.py +231 -3
- package/_shared/pr-loop/scripts/config/claude_permissions_constants.py +56 -2
- package/_shared/pr-loop/scripts/config/claude_settings_keys_constants.py +2 -0
- package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +121 -4
- package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +73 -17
- package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +385 -0
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +33 -0
- package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +3 -1
- package/package.json +1 -1
- package/skills/bugteam/scripts/_claude_permissions_common.py +109 -0
- package/skills/bugteam/scripts/config/claude_permissions_common_constants.py +51 -2
- package/skills/bugteam/scripts/grant_project_claude_permissions.py +115 -4
- package/skills/bugteam/scripts/revoke_project_claude_permissions.py +69 -17
- package/skills/bugteam/scripts/test_agent_config_carveout.py +356 -0
- package/skills/implement/SKILL.md +66 -0
- package/skills/implement/scripts/append_note.py +133 -0
- package/skills/implement/scripts/config/__init__.py +0 -0
- package/skills/implement/scripts/config/notes_constants.py +12 -0
- package/skills/implement/scripts/test_append_note.py +191 -0
- package/skills/pr-converge/config/constants.py +5 -0
- package/skills/pr-converge/scripts/check_bugbot_ci.py +1 -1
- package/skills/pr-converge/scripts/check_convergence.py +167 -28
- package/skills/pr-converge/scripts/check_pending_reviews.py +1 -1
- package/skills/pr-converge/scripts/conftest.py +60 -0
- package/skills/pr-converge/scripts/fetch_copilot_reviews.py +1 -1
- package/skills/pr-converge/scripts/post_fix_reply.py +1 -1
- package/skills/pr-converge/scripts/test_check_bugbot_ci.py +1 -1
- package/skills/pr-converge/scripts/test_check_convergence.py +306 -0
- package/skills/pr-converge/scripts/test_evict_cached_config_modules.py +1 -1
- package/skills/refine/SKILL.md +257 -0
- package/skills/refine/templates/implementation-notes-template.html +56 -0
- package/skills/refine/templates/plan-template.md +60 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Tests for append_note.
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- _build_skeleton emits a four-section HTML document keyed by every slug
|
|
5
|
+
- _ensure_file creates a fresh file on first call and round-trips on subsequent calls
|
|
6
|
+
- _render_entry HTML-escapes the about label and the note body
|
|
7
|
+
- _insert_entry puts the first <li> on its own line and keeps a 6-space indent across entries
|
|
8
|
+
- _insert_entry raises a descriptive RuntimeError when the section block is missing
|
|
9
|
+
- _insert_entry raises a descriptive RuntimeError when the closing </ul> is missing
|
|
10
|
+
- main appends through the CLI surface against a real on-disk file
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import importlib.util
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from types import ModuleType
|
|
20
|
+
|
|
21
|
+
import pytest
|
|
22
|
+
|
|
23
|
+
_SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _load_module() -> ModuleType:
|
|
27
|
+
if str(_SCRIPTS_DIRECTORY) not in sys.path:
|
|
28
|
+
sys.path.insert(0, str(_SCRIPTS_DIRECTORY))
|
|
29
|
+
module_path = _SCRIPTS_DIRECTORY / "append_note.py"
|
|
30
|
+
spec = importlib.util.spec_from_file_location("append_note", module_path)
|
|
31
|
+
assert spec is not None
|
|
32
|
+
assert spec.loader is not None
|
|
33
|
+
module = importlib.util.module_from_spec(spec)
|
|
34
|
+
spec.loader.exec_module(module)
|
|
35
|
+
return module
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
append_note_module = _load_module()
|
|
39
|
+
HEADING_BY_SLUG = append_note_module.HEADING_BY_SLUG
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_should_build_skeleton_with_every_section_slug() -> None:
|
|
43
|
+
skeleton = append_note_module._build_skeleton()
|
|
44
|
+
|
|
45
|
+
for each_slug, each_heading in HEADING_BY_SLUG.items():
|
|
46
|
+
assert f'<section id="{each_slug}">' in skeleton
|
|
47
|
+
assert f"<h2>{each_heading}</h2>" in skeleton
|
|
48
|
+
assert skeleton.count("<ul></ul>") == len(HEADING_BY_SLUG)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_should_create_file_with_skeleton_on_first_ensure(tmp_path: Path) -> None:
|
|
52
|
+
target = tmp_path / "subdir" / "implementation-notes.html"
|
|
53
|
+
|
|
54
|
+
document = append_note_module._ensure_file(target)
|
|
55
|
+
|
|
56
|
+
assert target.exists()
|
|
57
|
+
assert document == target.read_text(encoding="utf-8")
|
|
58
|
+
assert '<section id="decisions">' in document
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_should_return_existing_content_on_subsequent_ensure(tmp_path: Path) -> None:
|
|
62
|
+
target = tmp_path / "notes.html"
|
|
63
|
+
custom_content = "<!doctype html><html><body>existing</body></html>\n"
|
|
64
|
+
target.write_text(custom_content, encoding="utf-8")
|
|
65
|
+
|
|
66
|
+
returned = append_note_module._ensure_file(target)
|
|
67
|
+
|
|
68
|
+
assert returned == custom_content
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_should_escape_html_metacharacters_in_about_and_note() -> None:
|
|
72
|
+
entry = append_note_module._render_entry("a<b & c>d", "<script>x</script>")
|
|
73
|
+
|
|
74
|
+
assert "<script>" not in entry
|
|
75
|
+
assert "<script>" in entry
|
|
76
|
+
assert "a<b & c>d" in entry
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_should_put_first_entry_on_its_own_line_inside_empty_ul() -> None:
|
|
80
|
+
skeleton = append_note_module._build_skeleton()
|
|
81
|
+
entry = append_note_module._render_entry("First", "alpha")
|
|
82
|
+
|
|
83
|
+
after_first = append_note_module._insert_entry(skeleton, "decisions", entry)
|
|
84
|
+
|
|
85
|
+
assert "<ul> <li>" not in after_first
|
|
86
|
+
assert "<ul>\n <li>" in after_first
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_should_keep_uniform_six_space_indent_across_multiple_entries() -> None:
|
|
90
|
+
skeleton = append_note_module._build_skeleton()
|
|
91
|
+
first_entry = append_note_module._render_entry("First", "alpha")
|
|
92
|
+
second_entry = append_note_module._render_entry("Second", "beta")
|
|
93
|
+
|
|
94
|
+
after_first = append_note_module._insert_entry(skeleton, "decisions", first_entry)
|
|
95
|
+
after_second = append_note_module._insert_entry(after_first, "decisions", second_entry)
|
|
96
|
+
|
|
97
|
+
decisions_section_start = after_second.index('<section id="decisions">')
|
|
98
|
+
decisions_section_end = after_second.index("</section>", decisions_section_start)
|
|
99
|
+
decisions_section = after_second[decisions_section_start:decisions_section_end]
|
|
100
|
+
|
|
101
|
+
assert " <li>" not in decisions_section
|
|
102
|
+
assert decisions_section.count("\n <li>") == 2
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_should_raise_when_requested_section_is_absent() -> None:
|
|
106
|
+
document_without_section = "<html><body></body></html>\n"
|
|
107
|
+
entry = append_note_module._render_entry("x", "y")
|
|
108
|
+
|
|
109
|
+
with pytest.raises(RuntimeError, match="section 'decisions' not found"):
|
|
110
|
+
append_note_module._insert_entry(document_without_section, "decisions", entry)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_should_raise_when_closing_ul_is_missing() -> None:
|
|
114
|
+
truncated_section = '<section id="decisions">\n <h2>Design decisions</h2>\n <ul>\n </section>\n'
|
|
115
|
+
entry = append_note_module._render_entry("x", "y")
|
|
116
|
+
|
|
117
|
+
with pytest.raises(RuntimeError, match="missing its closing </ul>"):
|
|
118
|
+
append_note_module._insert_entry(truncated_section, "decisions", entry)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_should_not_borrow_closing_ul_from_a_later_section() -> None:
|
|
122
|
+
malformed_first_with_intact_second = (
|
|
123
|
+
'<section id="decisions">\n'
|
|
124
|
+
' <h2>Design decisions</h2>\n'
|
|
125
|
+
' <ul>\n'
|
|
126
|
+
' </section>\n'
|
|
127
|
+
' <section id="deviations">\n'
|
|
128
|
+
' <h2>Deviations</h2>\n'
|
|
129
|
+
' <ul></ul>\n'
|
|
130
|
+
' </section>\n'
|
|
131
|
+
)
|
|
132
|
+
entry = append_note_module._render_entry("x", "y")
|
|
133
|
+
|
|
134
|
+
with pytest.raises(RuntimeError, match="missing its closing </ul>"):
|
|
135
|
+
append_note_module._insert_entry(malformed_first_with_intact_second, "decisions", entry)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_should_raise_when_closing_section_is_missing() -> None:
|
|
139
|
+
section_without_close = '<section id="decisions">\n <h2>Design decisions</h2>\n <ul></ul>\n'
|
|
140
|
+
entry = append_note_module._render_entry("x", "y")
|
|
141
|
+
|
|
142
|
+
with pytest.raises(RuntimeError, match="missing its closing </section>"):
|
|
143
|
+
append_note_module._insert_entry(section_without_close, "decisions", entry)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_should_append_through_cli_against_real_file(tmp_path: Path) -> None:
|
|
147
|
+
target = tmp_path / "notes.html"
|
|
148
|
+
script_path = _SCRIPTS_DIRECTORY / "append_note.py"
|
|
149
|
+
|
|
150
|
+
first_run = subprocess.run(
|
|
151
|
+
[
|
|
152
|
+
sys.executable,
|
|
153
|
+
str(script_path),
|
|
154
|
+
"--section",
|
|
155
|
+
"decisions",
|
|
156
|
+
"--about",
|
|
157
|
+
"First",
|
|
158
|
+
"--note",
|
|
159
|
+
"alpha",
|
|
160
|
+
"--file",
|
|
161
|
+
str(target),
|
|
162
|
+
],
|
|
163
|
+
cwd=str(_SCRIPTS_DIRECTORY),
|
|
164
|
+
capture_output=True,
|
|
165
|
+
text=True,
|
|
166
|
+
check=False,
|
|
167
|
+
)
|
|
168
|
+
second_run = subprocess.run(
|
|
169
|
+
[
|
|
170
|
+
sys.executable,
|
|
171
|
+
str(script_path),
|
|
172
|
+
"--section",
|
|
173
|
+
"questions",
|
|
174
|
+
"--about",
|
|
175
|
+
"Q1",
|
|
176
|
+
"--note",
|
|
177
|
+
"<beta & gamma>",
|
|
178
|
+
"--file",
|
|
179
|
+
str(target),
|
|
180
|
+
],
|
|
181
|
+
cwd=str(_SCRIPTS_DIRECTORY),
|
|
182
|
+
capture_output=True,
|
|
183
|
+
text=True,
|
|
184
|
+
check=False,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
assert first_run.returncode == 0, first_run.stderr
|
|
188
|
+
assert second_run.returncode == 0, second_run.stderr
|
|
189
|
+
output = target.read_text(encoding="utf-8")
|
|
190
|
+
assert "<li><strong>First:</strong> alpha</li>" in output
|
|
191
|
+
assert "<li><strong>Q1:</strong> <beta & gamma></li>" in output
|
|
@@ -33,6 +33,11 @@ ALL_BUGBOT_CHECK_RUN_COMPLETE_CONCLUSIONS = ("success", "neutral")
|
|
|
33
33
|
BUGBOT_RUN_TRIGGER_PHRASE = "bugbot run\n"
|
|
34
34
|
BUGBOT_RUN_TRIGGER_WAIT_SECONDS = 8
|
|
35
35
|
|
|
36
|
+
BUGTEAM_NEW_HEADER_PREFIX = "**Bugteam audit completed**"
|
|
37
|
+
BUGTEAM_LEGACY_HEADER_PREFIX = "## /bugteam loop "
|
|
38
|
+
BUGTEAM_NEW_CLEAN_LABEL = "Clean — no findings"
|
|
39
|
+
BUGTEAM_LEGACY_CLEAN_TOKEN = "→ clean"
|
|
40
|
+
|
|
36
41
|
GH_INLINE_COMMENTS_PATH_TEMPLATE = "repos/{owner}/{repo}/pulls/{number}/comments"
|
|
37
42
|
GH_REVIEW_COMMENTS_PATH_TEMPLATE = (
|
|
38
43
|
"repos/{owner}/{repo}/pulls/{number}/reviews/{review_id}/comments"
|
|
@@ -29,7 +29,7 @@ import subprocess
|
|
|
29
29
|
import sys
|
|
30
30
|
from pathlib import Path
|
|
31
31
|
|
|
32
|
-
_pr_converge_dir = Path(__file__).
|
|
32
|
+
_pr_converge_dir = Path(__file__).absolute().parent.parent
|
|
33
33
|
if str(_pr_converge_dir) not in sys.path:
|
|
34
34
|
sys.path.insert(0, str(_pr_converge_dir))
|
|
35
35
|
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
Usage:
|
|
4
4
|
python scripts/check_convergence.py --owner <O> --repo <R> --pr-number <N>
|
|
5
|
+
[--bugbot-down]
|
|
5
6
|
|
|
6
7
|
Exit codes:
|
|
7
|
-
0 — all
|
|
8
|
+
0 — all pre-conditions met
|
|
8
9
|
1 — one or more conditions not met (FAIL lines printed to stdout)
|
|
9
10
|
2 — gh CLI error
|
|
10
11
|
"""
|
|
@@ -18,18 +19,20 @@ import subprocess
|
|
|
18
19
|
import sys
|
|
19
20
|
from pathlib import Path
|
|
20
21
|
|
|
21
|
-
_pr_converge_dir = Path(__file__).
|
|
22
|
+
_pr_converge_dir = Path(__file__).absolute().parent.parent
|
|
22
23
|
if str(_pr_converge_dir) not in sys.path:
|
|
23
24
|
sys.path.insert(0, str(_pr_converge_dir))
|
|
24
25
|
|
|
25
26
|
from config.constants import (
|
|
26
|
-
ALL_CLAUDE_DIRTY_REVIEW_STATES,
|
|
27
27
|
ALL_COPILOT_DIRTY_REVIEW_STATES,
|
|
28
28
|
ALL_BUGBOT_CHECK_RUN_COMPLETE_CONCLUSIONS,
|
|
29
29
|
BUGBOT_CHECK_RUN_NAME_SUBSTRING,
|
|
30
30
|
BUGBOT_DIRTY_BODY_REGEX,
|
|
31
|
+
BUGTEAM_LEGACY_CLEAN_TOKEN,
|
|
32
|
+
BUGTEAM_LEGACY_HEADER_PREFIX,
|
|
33
|
+
BUGTEAM_NEW_CLEAN_LABEL,
|
|
34
|
+
BUGTEAM_NEW_HEADER_PREFIX,
|
|
31
35
|
CHECK_RUNS_PER_PAGE,
|
|
32
|
-
ALL_CLAUDE_CLEAN_REVIEW_STATES,
|
|
33
36
|
CLAUDE_LOGIN_FILTER_SUBSTRING,
|
|
34
37
|
ALL_COPILOT_CLEAN_REVIEW_STATES,
|
|
35
38
|
COPILOT_LOGIN_FILTER_SUBSTRING,
|
|
@@ -46,6 +49,93 @@ from config.constants import (
|
|
|
46
49
|
)
|
|
47
50
|
|
|
48
51
|
|
|
52
|
+
def _is_bugteam_review(review_body: str) -> bool:
|
|
53
|
+
"""Return True when a review body opens with a bugteam audit header.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
review_body: Full body text of a PR review.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
True when the body opens with either the new audit-template header
|
|
60
|
+
prefix or the legacy bugteam loop header prefix; False otherwise.
|
|
61
|
+
Used to identify bugteam audit reviews by body content rather than
|
|
62
|
+
by the posting user's GitHub login (the underlying ``gh`` token is
|
|
63
|
+
typically the PR-owner or reviewer identity, not ``claude[bot]``).
|
|
64
|
+
"""
|
|
65
|
+
return (
|
|
66
|
+
review_body.startswith(BUGTEAM_NEW_HEADER_PREFIX)
|
|
67
|
+
or review_body.startswith(BUGTEAM_LEGACY_HEADER_PREFIX)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _is_clean_bugteam_review(review_body: str) -> bool:
|
|
72
|
+
"""Return True when a bugteam audit review body declares a clean pass.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
review_body: Body text of a review that has already satisfied
|
|
76
|
+
:func:`_is_bugteam_review`.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
True when the new-shape body's first line carries the clean state
|
|
80
|
+
label, or the legacy-shape body ends with the legacy clean token.
|
|
81
|
+
False for any other shape, including dirty audit reviews and
|
|
82
|
+
bodies that do not match the bugteam header signature.
|
|
83
|
+
"""
|
|
84
|
+
if review_body.startswith(BUGTEAM_NEW_HEADER_PREFIX):
|
|
85
|
+
first_line = review_body.splitlines()[0]
|
|
86
|
+
return BUGTEAM_NEW_CLEAN_LABEL in first_line
|
|
87
|
+
if review_body.startswith(BUGTEAM_LEGACY_HEADER_PREFIX):
|
|
88
|
+
return review_body.rstrip().endswith(BUGTEAM_LEGACY_CLEAN_TOKEN)
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _check_bugteam_clean(
|
|
93
|
+
*, owner: str, repo: str, number: int, head_sha: str
|
|
94
|
+
) -> tuple[bool, str]:
|
|
95
|
+
endpoint = GH_REVIEWS_PATH_TEMPLATE.format(owner=owner, repo=repo, number=number)
|
|
96
|
+
returncode, stdout = _gh_api_paginated(f"{endpoint}?per_page={REVIEWS_PER_PAGE}")
|
|
97
|
+
if returncode != 0:
|
|
98
|
+
return False, f"gh api error: {stdout}"
|
|
99
|
+
try:
|
|
100
|
+
raw_output = json.loads(stdout)
|
|
101
|
+
except json.JSONDecodeError:
|
|
102
|
+
return False, "gh api response not valid JSON"
|
|
103
|
+
if not isinstance(raw_output, list):
|
|
104
|
+
return False, "unexpected gh api response shape (expected list)"
|
|
105
|
+
all_pages = [p for p in raw_output if isinstance(p, list)]
|
|
106
|
+
all_flat: list[dict[str, object]] = [
|
|
107
|
+
each_entry
|
|
108
|
+
for page in all_pages
|
|
109
|
+
for each_entry in page
|
|
110
|
+
if isinstance(each_entry, dict)
|
|
111
|
+
]
|
|
112
|
+
all_flat.sort(
|
|
113
|
+
key=lambda each_review: str(each_review.get("submitted_at", "")),
|
|
114
|
+
reverse=True,
|
|
115
|
+
)
|
|
116
|
+
for each_review in all_flat:
|
|
117
|
+
body = each_review.get("body", "")
|
|
118
|
+
if not isinstance(body, str):
|
|
119
|
+
continue
|
|
120
|
+
if not _is_bugteam_review(body):
|
|
121
|
+
continue
|
|
122
|
+
commit_id = each_review.get("commit_id", "")
|
|
123
|
+
if not isinstance(commit_id, str) or not commit_id.startswith(head_sha):
|
|
124
|
+
continue
|
|
125
|
+
review_id = each_review.get("id", "?")
|
|
126
|
+
short_commit = commit_id[:7]
|
|
127
|
+
if _is_clean_bugteam_review(body):
|
|
128
|
+
return (
|
|
129
|
+
True,
|
|
130
|
+
f"review #{review_id}, clean bugteam audit, commit: {short_commit}",
|
|
131
|
+
)
|
|
132
|
+
return (
|
|
133
|
+
False,
|
|
134
|
+
f"review #{review_id}, dirty bugteam audit, commit: {short_commit}",
|
|
135
|
+
)
|
|
136
|
+
return False, f"no bugteam review found on {head_sha[:7]}"
|
|
137
|
+
|
|
138
|
+
|
|
49
139
|
def _gh_api(endpoint_path: str) -> tuple[int, str]:
|
|
50
140
|
completed_process = subprocess.run(
|
|
51
141
|
["gh", "api", endpoint_path],
|
|
@@ -387,38 +477,60 @@ def _check_no_pending_reviews(
|
|
|
387
477
|
return True, "no pending reviewers"
|
|
388
478
|
|
|
389
479
|
|
|
390
|
-
def check_all(*, owner: str, repo: str, number: int) -> int:
|
|
480
|
+
def check_all(*, owner: str, repo: str, number: int, bugbot_down: bool) -> int:
|
|
481
|
+
"""Run every convergence gate and print one PASS/FAIL line per condition.
|
|
482
|
+
|
|
483
|
+
Args:
|
|
484
|
+
owner: GitHub repository owner login.
|
|
485
|
+
repo: GitHub repository name.
|
|
486
|
+
number: Pull request number to inspect.
|
|
487
|
+
bugbot_down: When True, bypass both the Cursor Bugbot check-run
|
|
488
|
+
presence gate and the bugbot review-body content gate. The
|
|
489
|
+
check-run gate appears in the condition list with a
|
|
490
|
+
``bypassed (bugbot_down)`` note; the review-body gate is
|
|
491
|
+
omitted entirely. Callers pass True when the lead has
|
|
492
|
+
declared Cursor Bugbot unreachable on the current HEAD so the
|
|
493
|
+
broader convergence gate can still close on the remaining
|
|
494
|
+
signals.
|
|
495
|
+
|
|
496
|
+
Returns:
|
|
497
|
+
``0`` when every gate reports PASS, ``1`` when at least one gate
|
|
498
|
+
reports FAIL. The function never raises for gate-level failures;
|
|
499
|
+
gh-api transport failures surface as gate FAILs in the printed
|
|
500
|
+
output and contribute to the ``1`` exit code.
|
|
501
|
+
"""
|
|
391
502
|
head_sha = _get_pr_head_sha(owner=owner, repo=repo, number=number)
|
|
392
503
|
print(f"HEAD: {head_sha[:7]}\n")
|
|
393
504
|
|
|
394
505
|
conditions: list[tuple[str, tuple[bool, str]]] = []
|
|
395
506
|
|
|
396
|
-
|
|
397
|
-
(
|
|
398
|
-
|
|
399
|
-
|
|
507
|
+
if bugbot_down:
|
|
508
|
+
conditions.append(
|
|
509
|
+
(
|
|
510
|
+
"bugbot_clean_at == current_head",
|
|
511
|
+
(True, "bypassed (bugbot_down)"),
|
|
512
|
+
)
|
|
400
513
|
)
|
|
401
|
-
|
|
402
|
-
if conditions[-1][1][0]:
|
|
514
|
+
else:
|
|
403
515
|
conditions.append(
|
|
404
516
|
(
|
|
405
|
-
"
|
|
406
|
-
|
|
517
|
+
"bugbot_clean_at == current_head",
|
|
518
|
+
_check_bugbot(owner=owner, repo=repo, sha=head_sha),
|
|
407
519
|
)
|
|
408
520
|
)
|
|
521
|
+
if conditions[-1][1][0]:
|
|
522
|
+
conditions.append(
|
|
523
|
+
(
|
|
524
|
+
"bugbot review body clean",
|
|
525
|
+
_check_bugbot_not_dirty(owner=owner, repo=repo, number=number, head_sha=head_sha),
|
|
526
|
+
)
|
|
527
|
+
)
|
|
409
528
|
|
|
410
529
|
conditions.append(
|
|
411
530
|
(
|
|
412
531
|
"bugteam_clean_at == current_head",
|
|
413
|
-
|
|
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]",
|
|
532
|
+
_check_bugteam_clean(
|
|
533
|
+
owner=owner, repo=repo, number=number, head_sha=head_sha
|
|
422
534
|
),
|
|
423
535
|
)
|
|
424
536
|
)
|
|
@@ -458,13 +570,11 @@ def check_all(*, owner: str, repo: str, number: int) -> int:
|
|
|
458
570
|
)
|
|
459
571
|
|
|
460
572
|
is_all_passed = True
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
if not passed:
|
|
573
|
+
for each_index, (each_label, (each_passed, each_detail)) in enumerate(conditions, start=1):
|
|
574
|
+
status = "PASS" if each_passed else "FAIL"
|
|
575
|
+
print(f"{each_index}. {each_label}: {status} — {each_detail}")
|
|
576
|
+
if not each_passed:
|
|
466
577
|
is_all_passed = False
|
|
467
|
-
index += 1
|
|
468
578
|
|
|
469
579
|
print()
|
|
470
580
|
if is_all_passed:
|
|
@@ -475,21 +585,50 @@ def check_all(*, owner: str, repo: str, number: int) -> int:
|
|
|
475
585
|
|
|
476
586
|
|
|
477
587
|
def parse_arguments(all_argv: list[str]) -> argparse.Namespace:
|
|
588
|
+
"""Parse command-line arguments for the convergence checker.
|
|
589
|
+
|
|
590
|
+
Args:
|
|
591
|
+
all_argv: Argument list excluding the program name, typically
|
|
592
|
+
``sys.argv[1:]``.
|
|
593
|
+
|
|
594
|
+
Returns:
|
|
595
|
+
Namespace exposing ``owner``, ``repo``, ``pr_number``, and
|
|
596
|
+
``bugbot_down`` attributes. ``bugbot_down`` defaults to False so
|
|
597
|
+
the unmodified hook contract (``--owner X --repo Y --pr-number N``)
|
|
598
|
+
still picks up the full gate set.
|
|
599
|
+
"""
|
|
478
600
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
479
601
|
parser.add_argument("--owner", required=True, help="GitHub repository owner")
|
|
480
602
|
parser.add_argument("--repo", required=True, help="GitHub repository name")
|
|
481
603
|
parser.add_argument(
|
|
482
604
|
"--pr-number", required=True, type=int, help="Pull request number"
|
|
483
605
|
)
|
|
606
|
+
parser.add_argument(
|
|
607
|
+
"--bugbot-down",
|
|
608
|
+
action="store_true",
|
|
609
|
+
help=(
|
|
610
|
+
"Bypass the bugbot check-run gate (gate 1) when the lead has "
|
|
611
|
+
"declared Cursor Bugbot unreachable on the current HEAD."
|
|
612
|
+
),
|
|
613
|
+
)
|
|
484
614
|
return parser.parse_args(all_argv)
|
|
485
615
|
|
|
486
616
|
|
|
487
617
|
def main(all_arguments: list[str]) -> int:
|
|
618
|
+
"""Run the script end-to-end against parsed CLI arguments.
|
|
619
|
+
|
|
620
|
+
Args:
|
|
621
|
+
all_arguments: Argument list excluding the program name.
|
|
622
|
+
|
|
623
|
+
Returns:
|
|
624
|
+
``0`` on full convergence, ``1`` on one or more gate failures.
|
|
625
|
+
"""
|
|
488
626
|
arguments = parse_arguments(all_arguments)
|
|
489
627
|
return check_all(
|
|
490
628
|
owner=arguments.owner,
|
|
491
629
|
repo=arguments.repo,
|
|
492
630
|
number=getattr(arguments, "pr_number"),
|
|
631
|
+
bugbot_down=arguments.bugbot_down,
|
|
493
632
|
)
|
|
494
633
|
|
|
495
634
|
|
|
@@ -17,7 +17,7 @@ import subprocess
|
|
|
17
17
|
import sys
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
|
|
20
|
-
_pr_converge_dir = Path(__file__).
|
|
20
|
+
_pr_converge_dir = Path(__file__).absolute().parent.parent
|
|
21
21
|
if str(_pr_converge_dir) not in sys.path:
|
|
22
22
|
sys.path.insert(0, str(_pr_converge_dir))
|
|
23
23
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Test fixtures for skills/pr-converge/scripts/.
|
|
2
|
+
|
|
3
|
+
Two unrelated Python packages live under the name ``config`` in this repo:
|
|
4
|
+
- ``skills/pr-converge/config/`` (constants for the pr-converge scripts)
|
|
5
|
+
- ``hooks/config/`` (constants for the code-rules enforcer and other hooks)
|
|
6
|
+
|
|
7
|
+
When tests under this directory exercise pr-converge scripts that load
|
|
8
|
+
``from config.constants import ...`` and other code paths in the same
|
|
9
|
+
pytest process also load a different ``config`` package,
|
|
10
|
+
``sys.modules['config']`` and ``sys.modules['config.<submodule>']`` cache
|
|
11
|
+
entries from one package leak into the other. The next
|
|
12
|
+
``from config.<submodule> import ...`` then fails with
|
|
13
|
+
``ModuleNotFoundError`` because the cached parent package does not
|
|
14
|
+
expose that submodule.
|
|
15
|
+
|
|
16
|
+
Independently, several scripts in this folder do
|
|
17
|
+
``Path(__file__).resolve()`` then prepend the resulting directory to
|
|
18
|
+
``sys.path``. On Windows when the working tree lives under a mapped drive
|
|
19
|
+
backed by a UNC share (``Y:`` -> ``\\\\server\\share\\...``), ``.resolve()``
|
|
20
|
+
returns the UNC form, and Python's import machinery on this host cannot
|
|
21
|
+
locate ``config`` packages from a UNC ``sys.path`` entry. The Y:-form entry
|
|
22
|
+
gets pushed to a later index by subsequent inserts, making
|
|
23
|
+
``from config.<submodule> import ...`` fail.
|
|
24
|
+
|
|
25
|
+
This autouse fixture restores both invariants once per pytest session,
|
|
26
|
+
immediately before the first test executes (after collection and module
|
|
27
|
+
imports have completed; session-scoped fixtures run after import, not
|
|
28
|
+
before, so test-module-level ``import`` of pr-converge scripts is
|
|
29
|
+
isolated by each module's own ``_load_module()`` helper rather than by
|
|
30
|
+
this fixture):
|
|
31
|
+
1. evict every ``config`` and ``config.*`` entry from ``sys.modules``
|
|
32
|
+
2. prepend the drive-letter (``.absolute()``) form of the pr-converge
|
|
33
|
+
directory to ``sys.path`` so package resolution always has a
|
|
34
|
+
non-UNC path to search first
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import sys
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
import pytest
|
|
43
|
+
|
|
44
|
+
PR_CONVERGE_DIRECTORY_DRIVE_LETTER_FORM = str(Path(__file__).absolute().parent.parent)
|
|
45
|
+
|
|
46
|
+
if PR_CONVERGE_DIRECTORY_DRIVE_LETTER_FORM not in sys.path:
|
|
47
|
+
sys.path.insert(0, PR_CONVERGE_DIRECTORY_DRIVE_LETTER_FORM)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@pytest.fixture(scope="session", autouse=True)
|
|
51
|
+
def _evict_config_namespace_at_session_start() -> None:
|
|
52
|
+
for each_module_name in [
|
|
53
|
+
each_key
|
|
54
|
+
for each_key in list(sys.modules)
|
|
55
|
+
if each_key == "config" or each_key.startswith("config.")
|
|
56
|
+
]:
|
|
57
|
+
sys.modules.pop(each_module_name, None)
|
|
58
|
+
if PR_CONVERGE_DIRECTORY_DRIVE_LETTER_FORM in sys.path:
|
|
59
|
+
sys.path.remove(PR_CONVERGE_DIRECTORY_DRIVE_LETTER_FORM)
|
|
60
|
+
sys.path.insert(0, PR_CONVERGE_DIRECTORY_DRIVE_LETTER_FORM)
|
|
@@ -15,7 +15,7 @@ import subprocess
|
|
|
15
15
|
import sys
|
|
16
16
|
from pathlib import Path
|
|
17
17
|
|
|
18
|
-
_pr_converge_dir = Path(__file__).
|
|
18
|
+
_pr_converge_dir = Path(__file__).absolute().parent.parent
|
|
19
19
|
if str(_pr_converge_dir) not in sys.path:
|
|
20
20
|
sys.path.insert(0, str(_pr_converge_dir))
|
|
21
21
|
|
|
@@ -20,7 +20,7 @@ import subprocess
|
|
|
20
20
|
import sys
|
|
21
21
|
from pathlib import Path
|
|
22
22
|
|
|
23
|
-
_pr_converge_dir = Path(__file__).
|
|
23
|
+
_pr_converge_dir = Path(__file__).absolute().parent.parent
|
|
24
24
|
if str(_pr_converge_dir) not in sys.path:
|
|
25
25
|
sys.path.insert(0, str(_pr_converge_dir))
|
|
26
26
|
|