claude-dev-env 8.46.31 → 8.47.1
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/hooks/blocking/followup_pr_dedupe.py +202 -0
- package/hooks/blocking/pr_lifecycle_skill_gate.py +15 -5
- package/hooks/blocking/test_followup_pr_dedupe.py +86 -0
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +25 -0
- package/hooks/hooks_constants/followup_pr_dedupe_constants.py +19 -0
- package/hooks/hooks_constants/test_followup_pr_dedupe_constants.py +12 -0
- package/package.json +1 -1
- package/rules/flag-non-breaking-findings.md +1 -1
- package/scripts/dev_env_scripts_constants/followup_pr_uniqueness_constants.py +14 -0
- package/scripts/followup_pr_uniqueness.py +132 -0
- package/scripts/policy_lint/adapter_configuration.py +3 -1
- package/scripts/tests/test_followup_pr_uniqueness.py +76 -0
- package/scripts/tests/test_pr_verification_lock.py +29 -4
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Find an open follow-up pull request that already holds a new one's parent findings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import urllib.request
|
|
10
|
+
from collections.abc import Callable, Mapping
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
15
|
+
if hooks_directory not in sys.path:
|
|
16
|
+
sys.path.insert(0, hooks_directory)
|
|
17
|
+
|
|
18
|
+
from hooks_constants.followup_pr_dedupe_constants import (
|
|
19
|
+
ALL_BODY_FILE_OPTIONS,
|
|
20
|
+
ALL_BODY_OPTIONS,
|
|
21
|
+
ALL_REPOSITORY_OPTIONS,
|
|
22
|
+
CREATE_PULL_REQUEST_TOOL_SUFFIX,
|
|
23
|
+
DUPLICATE_FOLLOWUP_REASON_TEMPLATE,
|
|
24
|
+
FOLLOWUP_PARENT_PATTERN,
|
|
25
|
+
ALL_GH_CREATE_WORDS,
|
|
26
|
+
ALL_GIT_REMOTE_COMMAND_WORDS,
|
|
27
|
+
GITHUB_API_ROOT,
|
|
28
|
+
GITHUB_REMOTE_PATTERN,
|
|
29
|
+
OPEN_PULL_REQUESTS_PATH_TEMPLATE,
|
|
30
|
+
READ_TIMEOUT_SECONDS,
|
|
31
|
+
STANDARD_INPUT_PATH,
|
|
32
|
+
)
|
|
33
|
+
from hooks_constants.pr_lifecycle_skill_gate_constants import SHELL_TOOL_NAMES
|
|
34
|
+
from hooks_constants.pytest_invocation import unquoted_token
|
|
35
|
+
from hooks_constants.setup_project_paths_constants import DECODE_ERRORS_POLICY, UTF8_ENCODING
|
|
36
|
+
from hooks_constants.shell_command_pipeline import pipeline_segments_for_command
|
|
37
|
+
from hooks_constants.shell_command_wrappers import (
|
|
38
|
+
all_wrapped_command_texts,
|
|
39
|
+
segment_program_and_arguments,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class PullRequestDraft:
|
|
45
|
+
"""The repository and body of a pull request about to be opened."""
|
|
46
|
+
|
|
47
|
+
owner: str
|
|
48
|
+
repo: str
|
|
49
|
+
body: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
OpenPullRequestReader = Callable[[str, str], list[Mapping[str, object]]]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def followup_parent_number(body: str) -> int | None:
|
|
56
|
+
"""Return the parent number a "Follow-up to #N" line names, or None."""
|
|
57
|
+
match = re.search(FOLLOWUP_PARENT_PATTERN, body, re.IGNORECASE)
|
|
58
|
+
return int(match.group(1)) if match else None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def read_open_pull_requests(owner: str, repo: str) -> list[Mapping[str, object]]:
|
|
62
|
+
"""Read one page of a repository's open pull requests from the GitHub API."""
|
|
63
|
+
url = GITHUB_API_ROOT + OPEN_PULL_REQUESTS_PATH_TEMPLATE.format(owner=owner, repo=repo)
|
|
64
|
+
request = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json"})
|
|
65
|
+
with urllib.request.urlopen(request, timeout=READ_TIMEOUT_SECONDS) as reply:
|
|
66
|
+
answer = json.loads(reply.read())
|
|
67
|
+
return [each_entry for each_entry in answer if isinstance(each_entry, dict)]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _option_value(all_arguments: list[str], all_option_names: frozenset[str]) -> str | None:
|
|
71
|
+
for each_index, each_argument in enumerate(all_arguments):
|
|
72
|
+
option_name, separator, inline_value = each_argument.partition("=")
|
|
73
|
+
if option_name not in all_option_names:
|
|
74
|
+
continue
|
|
75
|
+
if separator:
|
|
76
|
+
return unquoted_token(inline_value)
|
|
77
|
+
if each_index + 1 < len(all_arguments):
|
|
78
|
+
return unquoted_token(all_arguments[each_index + 1])
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _body_from_arguments(all_arguments: list[str], working_directory: str) -> str | None:
|
|
83
|
+
inline_body = _option_value(all_arguments, ALL_BODY_OPTIONS)
|
|
84
|
+
if inline_body is not None:
|
|
85
|
+
return inline_body
|
|
86
|
+
body_path = _option_value(all_arguments, ALL_BODY_FILE_OPTIONS)
|
|
87
|
+
if body_path is None or body_path == STANDARD_INPUT_PATH:
|
|
88
|
+
return None
|
|
89
|
+
try:
|
|
90
|
+
return (Path(working_directory) / body_path).read_text(
|
|
91
|
+
encoding=UTF8_ENCODING, errors=DECODE_ERRORS_POLICY
|
|
92
|
+
)
|
|
93
|
+
except OSError:
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _origin_repository(working_directory: str) -> tuple[str, str] | None:
|
|
98
|
+
try:
|
|
99
|
+
completed = subprocess.run(
|
|
100
|
+
ALL_GIT_REMOTE_COMMAND_WORDS,
|
|
101
|
+
cwd=working_directory or None,
|
|
102
|
+
capture_output=True,
|
|
103
|
+
text=True,
|
|
104
|
+
timeout=READ_TIMEOUT_SECONDS,
|
|
105
|
+
check=False,
|
|
106
|
+
)
|
|
107
|
+
except (OSError, subprocess.SubprocessError):
|
|
108
|
+
return None
|
|
109
|
+
match = re.search(GITHUB_REMOTE_PATTERN, completed.stdout.strip())
|
|
110
|
+
return (match.group(1), match.group(2)) if match else None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _repository_from_arguments(
|
|
114
|
+
all_arguments: list[str], working_directory: str
|
|
115
|
+
) -> tuple[str, str] | None:
|
|
116
|
+
named_repository = _option_value(all_arguments, ALL_REPOSITORY_OPTIONS)
|
|
117
|
+
if named_repository is None:
|
|
118
|
+
return _origin_repository(working_directory)
|
|
119
|
+
owner, separator, repo = named_repository.rpartition("/")
|
|
120
|
+
owner = owner.rpartition("/")[2]
|
|
121
|
+
return (owner, repo) if separator and owner and repo else None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _segment_draft(all_segment_tokens: list[str], working_directory: str) -> PullRequestDraft | None:
|
|
125
|
+
program, all_arguments = segment_program_and_arguments(all_segment_tokens)
|
|
126
|
+
if program != "gh" or all_arguments[: len(ALL_GH_CREATE_WORDS)] != ALL_GH_CREATE_WORDS:
|
|
127
|
+
return None
|
|
128
|
+
body = _body_from_arguments(all_arguments, working_directory)
|
|
129
|
+
repository = _repository_from_arguments(all_arguments, working_directory)
|
|
130
|
+
if body is None or repository is None:
|
|
131
|
+
return None
|
|
132
|
+
return PullRequestDraft(repository[0], repository[1], body)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _shell_draft(command: str, working_directory: str) -> PullRequestDraft | None:
|
|
136
|
+
all_drafts = (
|
|
137
|
+
_segment_draft(each_segment, working_directory)
|
|
138
|
+
for each_text in all_wrapped_command_texts(command)
|
|
139
|
+
for each_segment, _each_following_operator in pipeline_segments_for_command(each_text)
|
|
140
|
+
)
|
|
141
|
+
return next((each_draft for each_draft in all_drafts if each_draft is not None), None)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def pull_request_draft(all_payload_fields: Mapping[str, object]) -> PullRequestDraft | None:
|
|
145
|
+
"""Return the pull request a PreToolUse payload is about to open, or None."""
|
|
146
|
+
tool_name = all_payload_fields.get("tool_name")
|
|
147
|
+
tool_input = all_payload_fields.get("tool_input")
|
|
148
|
+
if not isinstance(tool_name, str) or not isinstance(tool_input, dict):
|
|
149
|
+
return None
|
|
150
|
+
if tool_name.endswith(CREATE_PULL_REQUEST_TOOL_SUFFIX):
|
|
151
|
+
owner, repo, body = (tool_input.get(each_key) for each_key in ("owner", "repo", "body"))
|
|
152
|
+
if isinstance(owner, str) and isinstance(repo, str) and isinstance(body, str):
|
|
153
|
+
return PullRequestDraft(owner, repo, body)
|
|
154
|
+
return None
|
|
155
|
+
command = tool_input.get("command")
|
|
156
|
+
if tool_name not in SHELL_TOOL_NAMES or not isinstance(command, str):
|
|
157
|
+
return None
|
|
158
|
+
working_directory = all_payload_fields.get("cwd")
|
|
159
|
+
return _shell_draft(command, working_directory if isinstance(working_directory, str) else "")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _reason_for_parent(
|
|
163
|
+
parent_number: int, all_open_pull_requests: list[Mapping[str, object]]
|
|
164
|
+
) -> str | None:
|
|
165
|
+
all_same_parent = [
|
|
166
|
+
each_pull_request
|
|
167
|
+
for each_pull_request in all_open_pull_requests
|
|
168
|
+
if followup_parent_number(str(each_pull_request.get("body") or "")) == parent_number
|
|
169
|
+
]
|
|
170
|
+
if not all_same_parent:
|
|
171
|
+
return None
|
|
172
|
+
return DUPLICATE_FOLLOWUP_REASON_TEMPLATE.format(
|
|
173
|
+
open_number=all_same_parent[0].get("number"),
|
|
174
|
+
parent_number=parent_number,
|
|
175
|
+
open_url=all_same_parent[0].get("html_url"),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def duplicate_followup_reason(
|
|
180
|
+
all_payload_fields: Mapping[str, object],
|
|
181
|
+
read_open: OpenPullRequestReader | None = None,
|
|
182
|
+
) -> str | None:
|
|
183
|
+
"""Return a deny reason when an open pull request already follows up the same parent.
|
|
184
|
+
|
|
185
|
+
Any read failure returns None, so the pull request opens.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
all_payload_fields: The parsed PreToolUse input.
|
|
189
|
+
read_open: Reads the open pull requests of an owner and repository;
|
|
190
|
+
None reads them from the GitHub API.
|
|
191
|
+
"""
|
|
192
|
+
draft = pull_request_draft(all_payload_fields)
|
|
193
|
+
if draft is None:
|
|
194
|
+
return None
|
|
195
|
+
parent_number = followup_parent_number(draft.body)
|
|
196
|
+
if parent_number is None:
|
|
197
|
+
return None
|
|
198
|
+
try:
|
|
199
|
+
all_open_pull_requests = (read_open or read_open_pull_requests)(draft.owner, draft.repo)
|
|
200
|
+
except Exception:
|
|
201
|
+
return None
|
|
202
|
+
return _reason_for_parent(parent_number, all_open_pull_requests)
|
|
@@ -50,6 +50,7 @@ from hooks_constants.pr_lifecycle_skill_gate_constants import (
|
|
|
50
50
|
)
|
|
51
51
|
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
52
52
|
from hooks_constants.setup_project_paths_constants import DECODE_ERRORS_POLICY, UTF8_ENCODING
|
|
53
|
+
from blocking.followup_pr_dedupe import duplicate_followup_reason
|
|
53
54
|
from transcript_skill_scan import is_skill_loaded_after_last_compaction
|
|
54
55
|
|
|
55
56
|
|
|
@@ -195,26 +196,35 @@ def _subagent_transcript_path(all_payload_fields: dict[str, object]) -> str | No
|
|
|
195
196
|
|
|
196
197
|
|
|
197
198
|
def decision_for(all_payload_fields: dict[str, object]) -> dict[str, object] | None:
|
|
198
|
-
"""Return a deny for a
|
|
199
|
+
"""Return a deny for an unloaded skill or a second follow-up pull request for one parent.
|
|
199
200
|
|
|
200
201
|
Args:
|
|
201
202
|
all_payload_fields: The parsed PreToolUse input.
|
|
202
203
|
"""
|
|
203
204
|
if not _is_governed_action(all_payload_fields):
|
|
204
205
|
return None
|
|
206
|
+
if _is_skill_unloaded(all_payload_fields):
|
|
207
|
+
return _deny(DENY_REASON)
|
|
208
|
+
duplicate_reason = duplicate_followup_reason(all_payload_fields)
|
|
209
|
+
return None if duplicate_reason is None else _deny(duplicate_reason)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _is_skill_unloaded(all_payload_fields: dict[str, object]) -> bool:
|
|
205
213
|
paths = [all_payload_fields.get(field) for field in ALL_TRANSCRIPT_PATH_FIELDS]
|
|
206
214
|
paths.append(_subagent_transcript_path(all_payload_fields))
|
|
207
215
|
readable_paths = list(dict.fromkeys(path for path in paths if isinstance(path, str) and path))
|
|
208
216
|
if not readable_paths:
|
|
209
|
-
return
|
|
217
|
+
return False
|
|
210
218
|
statuses = [_transcript_load_status(path) for path in readable_paths]
|
|
211
|
-
|
|
212
|
-
|
|
219
|
+
return not any(status is None or status for status in statuses)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _deny(reason: str) -> dict[str, object]:
|
|
213
223
|
return {
|
|
214
224
|
HOOK_SPECIFIC_OUTPUT_KEY: {
|
|
215
225
|
"hookEventName": HOOK_EVENT_NAME,
|
|
216
226
|
PERMISSION_DECISION_KEY: DENY_DECISION,
|
|
217
|
-
PERMISSION_DECISION_REASON_KEY:
|
|
227
|
+
PERMISSION_DECISION_REASON_KEY: reason,
|
|
218
228
|
}
|
|
219
229
|
}
|
|
220
230
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Behavior tests for the one-follow-up-pull-request-per-parent check."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
HOOKS_DIRECTORY = Path(__file__).resolve().parent.parent
|
|
8
|
+
if str(HOOKS_DIRECTORY) not in sys.path:
|
|
9
|
+
sys.path.insert(0, str(HOOKS_DIRECTORY))
|
|
10
|
+
|
|
11
|
+
from blocking import followup_pr_dedupe as dedupe
|
|
12
|
+
|
|
13
|
+
OPEN_FOLLOWUP = {
|
|
14
|
+
"number": 1769,
|
|
15
|
+
"html_url": "https://github.com/jl-cmd/claude-dev-env/pull/1769",
|
|
16
|
+
"body": "Follow-up to #1731. Fixes one smell.",
|
|
17
|
+
}
|
|
18
|
+
OTHER_FOLLOWUP = {
|
|
19
|
+
"number": 1777,
|
|
20
|
+
"html_url": "https://github.com/jl-cmd/claude-dev-env/pull/1777",
|
|
21
|
+
"body": "follow-up to #17",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _reader(all_open: list[Mapping[str, object]]) -> dedupe.OpenPullRequestReader:
|
|
26
|
+
def read(owner: str, repo: str) -> list[Mapping[str, object]]:
|
|
27
|
+
assert (owner, repo) == ("jl-cmd", "claude-dev-env")
|
|
28
|
+
return all_open
|
|
29
|
+
|
|
30
|
+
return read
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _failing_reader(*_all_reader_arguments: str) -> list[Mapping[str, object]]:
|
|
34
|
+
raise OSError("network down")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _mcp_payload(body: str) -> dict[str, object]:
|
|
38
|
+
return {
|
|
39
|
+
"tool_name": "mcp__github__create_pull_request",
|
|
40
|
+
"tool_input": {"owner": "jl-cmd", "repo": "claude-dev-env", "body": body},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_second_followup_for_one_parent_is_denied() -> None:
|
|
45
|
+
reason = dedupe.duplicate_followup_reason(
|
|
46
|
+
_mcp_payload("FOLLOW-UP TO #1731: another smell"), _reader([OTHER_FOLLOWUP, OPEN_FOLLOWUP])
|
|
47
|
+
)
|
|
48
|
+
assert reason is not None
|
|
49
|
+
assert "#1769" in reason
|
|
50
|
+
assert "Add this finding to the open follow-up pull request #1769" in reason
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_first_followup_passes() -> None:
|
|
54
|
+
assert dedupe.duplicate_followup_reason(_mcp_payload("Follow-up to #1731"), _reader([OTHER_FOLLOWUP])) is None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_parent_prefix_number_does_not_match_longer_parent() -> None:
|
|
58
|
+
assert dedupe.duplicate_followup_reason(_mcp_payload("Follow-up to #173"), _reader([OPEN_FOLLOWUP])) is None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_non_followup_pull_request_skips_the_read() -> None:
|
|
62
|
+
assert dedupe.duplicate_followup_reason(_mcp_payload("Adds a hook."), _failing_reader) is None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_read_failure_fails_open() -> None:
|
|
66
|
+
assert dedupe.duplicate_followup_reason(_mcp_payload("Follow-up to #1731"), _failing_reader) is None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_gh_pr_create_inline_body_is_denied() -> None:
|
|
70
|
+
payload = {
|
|
71
|
+
"tool_name": "Bash",
|
|
72
|
+
"tool_input": {"command": "gh pr create -R jl-cmd/claude-dev-env --title t --body 'Follow-up to #1731'"},
|
|
73
|
+
}
|
|
74
|
+
reason = dedupe.duplicate_followup_reason(payload, _reader([OPEN_FOLLOWUP]))
|
|
75
|
+
assert reason is not None and "pull request #1769" in reason
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_powershell_body_file_is_denied(tmp_path: Path) -> None:
|
|
79
|
+
(tmp_path / "body.md").write_text("Follow-up to #1731\n", encoding="utf-8")
|
|
80
|
+
payload = {
|
|
81
|
+
"tool_name": "PowerShell",
|
|
82
|
+
"cwd": str(tmp_path),
|
|
83
|
+
"tool_input": {"command": "gh pr create --repo jl-cmd/claude-dev-env --body-file body.md"},
|
|
84
|
+
}
|
|
85
|
+
reason = dedupe.duplicate_followup_reason(payload, _reader([OPEN_FOLLOWUP]))
|
|
86
|
+
assert reason is not None and "pull request #1769" in reason
|
|
@@ -10,6 +10,7 @@ HOOKS_DIRECTORY = Path(__file__).resolve().parent.parent
|
|
|
10
10
|
if str(HOOKS_DIRECTORY) not in sys.path:
|
|
11
11
|
sys.path.insert(0, str(HOOKS_DIRECTORY))
|
|
12
12
|
|
|
13
|
+
from blocking import followup_pr_dedupe as gate_dedupe
|
|
13
14
|
from blocking import pr_lifecycle_skill_gate as gate
|
|
14
15
|
from hooks_constants.pr_lifecycle_skill_gate_constants import DENY_REASON
|
|
15
16
|
|
|
@@ -231,3 +232,27 @@ def test_hook_has_its_own_pre_tool_use_registration() -> None:
|
|
|
231
232
|
],
|
|
232
233
|
}
|
|
233
234
|
]
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def test_loaded_skill_still_denies_a_second_followup_for_one_parent(tmp_path: Path) -> None:
|
|
238
|
+
payload = {
|
|
239
|
+
"tool_name": "mcp__github__create_pull_request",
|
|
240
|
+
"tool_input": {"owner": "jl-cmd", "repo": "claude-dev-env", "body": "Follow-up to #1731"},
|
|
241
|
+
"transcript_path": str(_transcript(tmp_path, "pr-lifecycle")),
|
|
242
|
+
}
|
|
243
|
+
open_followup = {"number": 1769, "html_url": "https://github.com/jl-cmd/claude-dev-env/pull/1769", "body": "Follow-up to #1731"}
|
|
244
|
+
with patch.object(gate_dedupe, "read_open_pull_requests", return_value=[open_followup]):
|
|
245
|
+
decision = gate.decision_for(payload)
|
|
246
|
+
assert decision is not None
|
|
247
|
+
assert decision["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
248
|
+
assert "open follow-up pull request #1769" in decision["hookSpecificOutput"]["permissionDecisionReason"]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def test_loaded_skill_allows_a_followup_when_the_read_fails(tmp_path: Path) -> None:
|
|
252
|
+
payload = {
|
|
253
|
+
"tool_name": "mcp__github__create_pull_request",
|
|
254
|
+
"tool_input": {"owner": "jl-cmd", "repo": "claude-dev-env", "body": "Follow-up to #1731"},
|
|
255
|
+
"transcript_path": str(_transcript(tmp_path, "pr-lifecycle")),
|
|
256
|
+
}
|
|
257
|
+
with patch.object(gate_dedupe, "read_open_pull_requests", side_effect=OSError("offline")):
|
|
258
|
+
assert gate.decision_for(payload) is None
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Constants for the one-follow-up-pull-request-per-parent check."""
|
|
2
|
+
|
|
3
|
+
FOLLOWUP_PARENT_PATTERN = r"follow[- ]?up to #(\d+)(?!\d)"
|
|
4
|
+
GITHUB_API_ROOT = "https://api.github.com"
|
|
5
|
+
OPEN_PULL_REQUESTS_PATH_TEMPLATE = "/repos/{owner}/{repo}/pulls?state=open&per_page=100"
|
|
6
|
+
READ_TIMEOUT_SECONDS = 4
|
|
7
|
+
ALL_GH_CREATE_WORDS = ["pr", "create"]
|
|
8
|
+
ALL_BODY_OPTIONS = frozenset({"-b", "--body"})
|
|
9
|
+
ALL_BODY_FILE_OPTIONS = frozenset({"-F", "--body-file"})
|
|
10
|
+
ALL_REPOSITORY_OPTIONS = frozenset({"-R", "--repo"})
|
|
11
|
+
STANDARD_INPUT_PATH = "-"
|
|
12
|
+
CREATE_PULL_REQUEST_TOOL_SUFFIX = "__create_pull_request"
|
|
13
|
+
GITHUB_REMOTE_PATTERN = r"github\.com[:/]([^/\s]+)/([^/\s]+?)(?:\.git)?/?$"
|
|
14
|
+
ALL_GIT_REMOTE_COMMAND_WORDS = ("git", "remote", "get-url", "origin")
|
|
15
|
+
DUPLICATE_FOLLOWUP_REASON_TEMPLATE = (
|
|
16
|
+
"Pull request #{open_number} already holds the follow-up findings for parent #{parent_number}"
|
|
17
|
+
" ({open_url}). Add this finding to the open follow-up pull request #{open_number}."
|
|
18
|
+
" One combined follow-up pull request per parent pull request."
|
|
19
|
+
)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from hooks_constants import followup_pr_dedupe_constants as constants
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_parent_pattern_reads_both_spellings_and_stops_at_the_number() -> None:
|
|
7
|
+
assert re.search(constants.FOLLOWUP_PARENT_PATTERN, "followup to #12", re.IGNORECASE)
|
|
8
|
+
assert re.search(constants.FOLLOWUP_PARENT_PATTERN, "Follow-up to #1731", re.IGNORECASE).group(1) == "1731"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_read_timeout_fits_inside_the_hook_timeout() -> None:
|
|
12
|
+
assert constants.READ_TIMEOUT_SECONDS * 2 < 10
|
package/package.json
CHANGED
|
@@ -13,7 +13,7 @@ paths:
|
|
|
13
13
|
|
|
14
14
|
**When:** Writing a local gate or changing a check's severity.
|
|
15
15
|
|
|
16
|
-
Give every check ID a breaking or smell severity row; style and structure findings are smells. Fail the gate and stop for a bug, secret, broken test, syntax error, or instruction file that cannot load. Record a smell in `.claude/followups/smells.jsonl`, proceed, and fix it in
|
|
16
|
+
Give every check ID a breaking or smell severity row; style and structure findings are smells. Fail the gate and stop for a bug, secret, broken test, syntax error, or instruction file that cannot load. Record a smell in `.claude/followups/smells.jsonl`, proceed, and fix it later in one combined follow-up pull request per parent pull request, whose body names the parent as `Follow-up to #N`; ledger failure leaves the gate decision unchanged. Treat an unclassified check as breaking.
|
|
17
17
|
|
|
18
18
|
**Enforcement:** none, the agent applies it.
|
|
19
19
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Constants for the one-follow-up-pull-request-per-parent CI check."""
|
|
2
|
+
|
|
3
|
+
FOLLOWUP_PARENT_PATTERN = r"follow[- ]?up to #(\d+)(?!\d)"
|
|
4
|
+
RULE_START_TIMESTAMP = "2026-10-04T12:00:00Z"
|
|
5
|
+
OPEN_PULL_REQUESTS_ENDPOINT_TEMPLATE = "{api_root}/repos/{slug}/pulls?state=open&per_page=100"
|
|
6
|
+
PASS_EXIT_CODE = 0
|
|
7
|
+
DUPLICATE_EXIT_CODE = 1
|
|
8
|
+
ERROR_EXIT_CODE = 2
|
|
9
|
+
PASS_MESSAGE = "No earlier open follow-up pull request names this parent."
|
|
10
|
+
DUPLICATE_FOLLOWUP_MESSAGE_TEMPLATE = (
|
|
11
|
+
"Pull request #{open_number} already holds the follow-up findings for parent #{parent_number}"
|
|
12
|
+
" ({open_url}). Add this finding to the open follow-up pull request #{open_number}."
|
|
13
|
+
" One combined follow-up pull request per parent pull request."
|
|
14
|
+
)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Fail a follow-up pull request when an earlier open one already names its parent.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
python followup_pr_uniqueness.py OWNER/REPO NUMBER
|
|
6
|
+
|
|
7
|
+
Only pull requests opened at or after the rule start take part, so follow-up
|
|
8
|
+
groups opened before the rule existed stay green.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
from collections.abc import Mapping, Sequence
|
|
17
|
+
|
|
18
|
+
from dev_env_scripts_constants.followup_pr_uniqueness_constants import (
|
|
19
|
+
DUPLICATE_EXIT_CODE,
|
|
20
|
+
DUPLICATE_FOLLOWUP_MESSAGE_TEMPLATE,
|
|
21
|
+
ERROR_EXIT_CODE,
|
|
22
|
+
FOLLOWUP_PARENT_PATTERN,
|
|
23
|
+
OPEN_PULL_REQUESTS_ENDPOINT_TEMPLATE,
|
|
24
|
+
PASS_EXIT_CODE,
|
|
25
|
+
PASS_MESSAGE,
|
|
26
|
+
RULE_START_TIMESTAMP,
|
|
27
|
+
)
|
|
28
|
+
from dev_env_scripts_constants.review_closure_constants import GET_METHOD, GITHUB_API_ROOT
|
|
29
|
+
from pr_verification.github_parsing import GitHubError
|
|
30
|
+
from review_closure_github import github_token, read_pull_request, request_json
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def followup_parent_number(body: object) -> int | None:
|
|
34
|
+
"""Read the parent number a "Follow-up to #N" line names.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
body: The pull request body, or any other value GitHub sent.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
The parent number, or None when the body names no parent.
|
|
41
|
+
"""
|
|
42
|
+
if not isinstance(body, str):
|
|
43
|
+
return None
|
|
44
|
+
match = re.search(FOLLOWUP_PARENT_PATTERN, body, re.IGNORECASE)
|
|
45
|
+
return int(match.group(1)) if match else None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _is_under_rule(all_pull_request_fields: Mapping[str, object]) -> bool:
|
|
49
|
+
created_at = all_pull_request_fields.get("created_at")
|
|
50
|
+
return isinstance(created_at, str) and created_at >= RULE_START_TIMESTAMP
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _earlier_followups(
|
|
54
|
+
own_number: int, parent_number: int, all_open_pull_requests: Sequence[Mapping[str, object]]
|
|
55
|
+
) -> list[Mapping[str, object]]:
|
|
56
|
+
return [
|
|
57
|
+
each_open
|
|
58
|
+
for each_open in all_open_pull_requests
|
|
59
|
+
if isinstance(each_open.get("number"), int)
|
|
60
|
+
and each_open["number"] < own_number
|
|
61
|
+
and _is_under_rule(each_open)
|
|
62
|
+
and followup_parent_number(each_open.get("body")) == parent_number
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def duplicate_message(
|
|
67
|
+
all_pull_request_fields: Mapping[str, object],
|
|
68
|
+
all_open_pull_requests: Sequence[Mapping[str, object]],
|
|
69
|
+
) -> str | None:
|
|
70
|
+
"""Return the failure message when an earlier open follow-up names the same parent.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
all_pull_request_fields: The pull request under test.
|
|
74
|
+
all_open_pull_requests: The repository's open pull requests.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
The failure message, or None when this pull request may stay open.
|
|
78
|
+
"""
|
|
79
|
+
parent_number = followup_parent_number(all_pull_request_fields.get("body"))
|
|
80
|
+
own_number = all_pull_request_fields.get("number")
|
|
81
|
+
if parent_number is None or not isinstance(own_number, int) or not _is_under_rule(all_pull_request_fields):
|
|
82
|
+
return None
|
|
83
|
+
all_earlier_followups = _earlier_followups(own_number, parent_number, all_open_pull_requests)
|
|
84
|
+
if not all_earlier_followups:
|
|
85
|
+
return None
|
|
86
|
+
first_followup = min(all_earlier_followups, key=lambda entry: entry["number"])
|
|
87
|
+
return DUPLICATE_FOLLOWUP_MESSAGE_TEMPLATE.format(
|
|
88
|
+
open_number=first_followup["number"],
|
|
89
|
+
parent_number=parent_number,
|
|
90
|
+
open_url=first_followup.get("html_url"),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _open_pull_requests(slug: str, token: str) -> list[Mapping[str, object]]:
|
|
95
|
+
document = request_json(
|
|
96
|
+
GET_METHOD,
|
|
97
|
+
OPEN_PULL_REQUESTS_ENDPOINT_TEMPLATE.format(api_root=GITHUB_API_ROOT, slug=slug),
|
|
98
|
+
token,
|
|
99
|
+
None,
|
|
100
|
+
)
|
|
101
|
+
if not isinstance(document, list):
|
|
102
|
+
raise GitHubError(str(document))
|
|
103
|
+
return [each_entry for each_entry in document if isinstance(each_entry, Mapping)]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def main(all_arguments: Sequence[str]) -> int:
|
|
107
|
+
"""Print the verdict for one pull request and return its exit code.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
all_arguments: The command line, without the program name.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Zero when no earlier follow-up names the parent, one on a duplicate,
|
|
114
|
+
and two when GitHub could not be read.
|
|
115
|
+
"""
|
|
116
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
117
|
+
parser.add_argument("slug")
|
|
118
|
+
parser.add_argument("number", type=int)
|
|
119
|
+
parsed = parser.parse_args(all_arguments)
|
|
120
|
+
try:
|
|
121
|
+
token = github_token()
|
|
122
|
+
pull_request = read_pull_request(parsed.slug, parsed.number, token)
|
|
123
|
+
message = duplicate_message(pull_request, _open_pull_requests(parsed.slug, token))
|
|
124
|
+
except GitHubError as failure:
|
|
125
|
+
print(failure, file=sys.stderr)
|
|
126
|
+
return ERROR_EXIT_CODE
|
|
127
|
+
print(message or PASS_MESSAGE)
|
|
128
|
+
return PASS_EXIT_CODE if message is None else DUPLICATE_EXIT_CODE
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
if __name__ == "__main__":
|
|
132
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -140,7 +140,9 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
|
|
|
140
140
|
|
|
141
141
|
The pull request lifecycle gate denies a commit, push, pull request, or
|
|
142
142
|
merge call once, until the session loads the ``pr-lifecycle`` skill. It
|
|
143
|
-
decides when a rule set loads and no code or safety policy.
|
|
143
|
+
decides when a rule set loads and no code or safety policy. It also denies
|
|
144
|
+
a second open ``Follow-up to #N`` pull request for one parent, a delivery
|
|
145
|
+
rule that names the open follow-up to extend.
|
|
144
146
|
|
|
145
147
|
The step-note gate allows every call until the user runs ``/step-notes on``.
|
|
146
148
|
It asks for a readable status line and decides no code or safety policy.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
SCRIPTS_DIRECTORY = Path(__file__).resolve().parents[1]
|
|
9
|
+
if str(SCRIPTS_DIRECTORY) not in sys.path:
|
|
10
|
+
sys.path.insert(0, str(SCRIPTS_DIRECTORY))
|
|
11
|
+
|
|
12
|
+
import followup_pr_uniqueness as command
|
|
13
|
+
from pr_verification.github_parsing import GitHubError
|
|
14
|
+
|
|
15
|
+
AFTER_RULE = "2026-10-05T00:00:00Z"
|
|
16
|
+
BEFORE_RULE = "2026-10-04T03:00:00Z"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def pull_request(number: int, body: str, created_at: str = AFTER_RULE) -> dict[str, object]:
|
|
20
|
+
return {
|
|
21
|
+
"number": number,
|
|
22
|
+
"body": body,
|
|
23
|
+
"created_at": created_at,
|
|
24
|
+
"html_url": f"https://github.com/jl-cmd/claude-dev-env/pull/{number}",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_second_followup_fails_naming_the_first() -> None:
|
|
29
|
+
first = pull_request(1900, "Follow-up to #1731")
|
|
30
|
+
second = pull_request(1901, "follow-up to #1731: one more")
|
|
31
|
+
message = command.duplicate_message(second, [first, second])
|
|
32
|
+
assert message is not None
|
|
33
|
+
assert "Add this finding to the open follow-up pull request #1900" in message
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_first_followup_stays_green_after_a_duplicate_opens() -> None:
|
|
37
|
+
first = pull_request(1900, "Follow-up to #1731")
|
|
38
|
+
second = pull_request(1901, "Follow-up to #1731")
|
|
39
|
+
assert command.duplicate_message(first, [first, second]) is None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_followups_opened_before_the_rule_do_not_count() -> None:
|
|
43
|
+
legacy = pull_request(1769, "Follow-up to #1731", BEFORE_RULE)
|
|
44
|
+
assert command.duplicate_message(pull_request(1900, "Follow-up to #1731"), [legacy]) is None
|
|
45
|
+
later_legacy = pull_request(1770, "Follow-up to #1731", BEFORE_RULE)
|
|
46
|
+
assert command.duplicate_message(later_legacy, [legacy, later_legacy]) is None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_other_parent_and_non_followup_pass() -> None:
|
|
50
|
+
first = pull_request(1900, "Follow-up to #173")
|
|
51
|
+
assert command.duplicate_message(pull_request(1901, "Follow-up to #1731"), [first]) is None
|
|
52
|
+
assert command.duplicate_message(pull_request(1901, "Adds a hook."), [first]) is None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_main_exits_one_on_duplicate_and_two_on_read_failure(
|
|
56
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
57
|
+
) -> None:
|
|
58
|
+
first = pull_request(1900, "Follow-up to #1731")
|
|
59
|
+
second = pull_request(1901, "Follow-up to #1731")
|
|
60
|
+
monkeypatch.setattr(command, "github_token", lambda: "token")
|
|
61
|
+
monkeypatch.setattr(command, "read_pull_request", lambda slug, number, token: second)
|
|
62
|
+
monkeypatch.setattr(command, "request_json", lambda method, url, token, body: [first, second])
|
|
63
|
+
assert command.main(["jl-cmd/claude-dev-env", "1901"]) == 1
|
|
64
|
+
assert "#1900" in capsys.readouterr().out
|
|
65
|
+
|
|
66
|
+
def fail(slug: str, number: int, token: str) -> dict[str, object]:
|
|
67
|
+
raise GitHubError("offline")
|
|
68
|
+
|
|
69
|
+
monkeypatch.setattr(command, "read_pull_request", fail)
|
|
70
|
+
assert command.main(["jl-cmd/claude-dev-env", "1901"]) == 2
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_parent_number_reads_both_spellings_and_ignores_other_values() -> None:
|
|
74
|
+
assert command.followup_parent_number("FOLLOWUP to #1731.") == 1731
|
|
75
|
+
assert command.followup_parent_number("Follow-up to #17310") == 17310
|
|
76
|
+
assert command.followup_parent_number(None) is None
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import subprocess
|
|
4
4
|
import sys
|
|
5
|
+
import threading
|
|
5
6
|
import time
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
from typing import cast
|
|
@@ -50,6 +51,7 @@ RESULT_WAIT_SECONDS = 5
|
|
|
50
51
|
PROCESS_WAIT_SECONDS = 10
|
|
51
52
|
POLL_INTERVAL_SECONDS = 0.01
|
|
52
53
|
LEGACY_LOCK_CONTENT = "legacy owner metadata"
|
|
54
|
+
DELAYED_OUTCOME_WRITE_SECONDS = 0.2
|
|
53
55
|
BASE_PYTHON_EXECUTABLE = cast(str, getattr(sys, "_base_executable", sys.executable))
|
|
54
56
|
|
|
55
57
|
|
|
@@ -72,13 +74,20 @@ def _start_lock_contender(
|
|
|
72
74
|
)
|
|
73
75
|
|
|
74
76
|
|
|
77
|
+
def _read_reported_outcome(result_path: Path) -> str:
|
|
78
|
+
if not result_path.is_file():
|
|
79
|
+
return ""
|
|
80
|
+
return result_path.read_text(encoding="utf-8")
|
|
81
|
+
|
|
82
|
+
|
|
75
83
|
def _wait_for_lock_outcomes(all_result_paths: list[Path]) -> list[str]:
|
|
76
84
|
deadline = time.monotonic() + RESULT_WAIT_SECONDS
|
|
77
85
|
while time.monotonic() < deadline:
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
86
|
+
all_outcomes = [
|
|
87
|
+
_read_reported_outcome(each_path) for each_path in all_result_paths
|
|
88
|
+
]
|
|
89
|
+
if all(all_outcomes):
|
|
90
|
+
return all_outcomes
|
|
82
91
|
time.sleep(POLL_INTERVAL_SECONDS)
|
|
83
92
|
raise AssertionError("lock contenders did not report within five seconds")
|
|
84
93
|
|
|
@@ -146,6 +155,22 @@ def test_process_lock_survives_contention_and_owner_termination(
|
|
|
146
155
|
_terminate_owned_child(each_child_process)
|
|
147
156
|
|
|
148
157
|
|
|
158
|
+
def test_wait_for_lock_outcomes_waits_for_written_content(tmp_path: Path) -> None:
|
|
159
|
+
result_path = tmp_path / "delayed.result"
|
|
160
|
+
result_path.touch()
|
|
161
|
+
delayed_writer = threading.Timer(
|
|
162
|
+
DELAYED_OUTCOME_WRITE_SECONDS,
|
|
163
|
+
result_path.write_text,
|
|
164
|
+
args=("acquired",),
|
|
165
|
+
kwargs={"encoding": "utf-8"},
|
|
166
|
+
)
|
|
167
|
+
delayed_writer.start()
|
|
168
|
+
try:
|
|
169
|
+
assert _wait_for_lock_outcomes([result_path]) == ["acquired"]
|
|
170
|
+
finally:
|
|
171
|
+
delayed_writer.join()
|
|
172
|
+
|
|
173
|
+
|
|
149
174
|
def test_lock_io_error_is_presented_as_supervisor_lock_error(
|
|
150
175
|
tmp_path: Path,
|
|
151
176
|
) -> None:
|