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.
@@ -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 governed action with readable, unloaded transcripts.
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 None
217
+ return False
210
218
  statuses = [_transcript_load_status(path) for path in readable_paths]
211
- if any(status is None or status for status in statuses):
212
- return None
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: DENY_REASON,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.46.31",
3
+ "version": "8.47.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 a later pull request; ledger failure leaves the gate decision unchanged. Treat an unclassified check as breaking.
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
- if all(each_path.is_file() for each_path in all_result_paths):
79
- return [
80
- each_path.read_text(encoding="utf-8") for each_path in all_result_paths
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: