claude-dev-env 4.2.0 → 4.3.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/.agents/skills/_shared/pr-loop/scripts/durable_post_lint.py +242 -0
- package/.agents/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/durable_post_lint_constants.py +106 -0
- package/.agents/skills/_shared/pr-loop/scripts/test_durable_post_lint.py +332 -0
- package/.agents/skills/_shared/pr-loop/scripts/test_durable_post_lint_constants.py +25 -0
- package/.agents/skills/issue-tracker/SKILL.md +6 -0
- package/.agents/skills/pull-request/SKILL.md +218 -0
- package/.agents/skills/pull-request/reference/publication-tasks.md +18 -0
- package/.agents/skills/pull-request/scripts/github_pr_command_constants/__init__.py +1 -0
- package/.agents/skills/pull-request/scripts/github_pr_command_constants/config/__init__.py +1 -0
- package/.agents/skills/pull-request/scripts/github_pr_command_constants/config/constants.py +61 -0
- package/.agents/skills/pull-request/scripts/pull_request.py +276 -0
- package/.agents/skills/pull-request/scripts/recover_legacy_author.py +251 -0
- package/.agents/skills/pull-request/scripts/test_github_pr_command_constants.py +49 -0
- package/.agents/skills/pull-request/scripts/test_pull_request.py +398 -0
- package/.agents/skills/pull-request/scripts/test_recover_legacy_author.py +221 -0
- package/.agents/skills/test-runner/SKILL.md +69 -0
- package/.agents/skills/test-runner/scripts/preflight_checks.py +365 -0
- package/.agents/skills/test-runner/scripts/run_tests.py +316 -0
- package/.agents/skills/test-runner/scripts/test_preflight_checks.py +185 -0
- package/.agents/skills/test-runner/scripts/test_run_tests.py +281 -0
- package/.agents/skills/test-runner/scripts/test_runner_constants/__init__.py +1 -0
- package/.agents/skills/test-runner/scripts/test_runner_constants/config/__init__.py +1 -0
- package/.agents/skills/test-runner/scripts/test_runner_constants/config/constants.py +96 -0
- package/.agents/skills-archived/pr-loop-cloud-transport/reference/identity-and-hooks.md +1 -1
- package/bin/ever-shipped-skills.mjs +2 -0
- package/bin/ever-shipped-skills.test.mjs +8 -0
- package/bin/install.mjs +9 -1
- package/bin/install.prune.test.mjs +90 -0
- package/bin/install.test.mjs +24 -7
- package/hooks/blocking/pii_prevention_blocker.py +32 -51
- package/hooks/blocking/test_bash_post_call_dispatcher.py +5 -28
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -4
- package/hooks/blocking/test_code_rules_command_dispatch.py +2 -2
- package/hooks/blocking/test_pii_prevention_blocker.py +37 -89
- package/hooks/gh_post_body_texts.py +195 -0
- package/hooks/hooks.json +0 -15
- package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +0 -1
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +0 -8
- package/hooks/hooks_constants/gh_post_body_texts_constants.py +12 -0
- package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +0 -7
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +0 -7
- package/hooks/hooks_constants/test_failure_recorder_constants.py +3 -3
- package/hooks/hooks_constants/test_gh_post_body_texts_constants.py +19 -0
- package/hooks/observability/test_failure_recorder.py +1 -1
- package/hooks/pyproject.toml +1 -1
- package/hooks/test_gh_post_body_texts.py +79 -0
- package/package.json +1 -1
- package/rules/durable-post-artifacts.md +31 -20
- package/rules/gh-cli-conventions.md +23 -13
- package/rules/git-workflow.md +21 -8
- package/scripts/policy_lint/adapter_configuration.py +53 -12
- package/scripts/repository_checks/config/constants.py +0 -5
- package/scripts/tests/test_adapter_configuration.py +54 -0
- package/hooks/_gh_pr_author_swap_utils.py +0 -1225
- package/hooks/blocking/conventional_pr_title_gate.py +0 -443
- package/hooks/blocking/gh_body_arg_blocker.py +0 -176
- package/hooks/blocking/gh_pr_author_enforcer.py +0 -487
- package/hooks/blocking/gh_pr_author_restore.py +0 -105
- package/hooks/blocking/pr_description_writer_gate.py +0 -256
- package/hooks/blocking/test_conventional_pr_title_gate.py +0 -640
- package/hooks/blocking/test_gh_body_arg_blocker.py +0 -441
- package/hooks/blocking/test_gh_pr_author_enforcer.py +0 -1166
- package/hooks/blocking/test_gh_pr_author_restore.py +0 -513
- package/hooks/blocking/test_gh_pr_author_swap_utils.py +0 -910
- package/hooks/blocking/test_pr_description_writer_gate.py +0 -234
- package/hooks/blocking/test_preflight_check.py +0 -343
- package/hooks/blocking/test_test_preflight_check.py +0 -80
- package/hooks/blocking/test_volatile_path_in_post_blocker.py +0 -322
- package/hooks/blocking/volatile_path_in_post_blocker.py +0 -461
- package/hooks/hooks_constants/conventional_pr_title_gate_constants.py +0 -58
- package/hooks/hooks_constants/gh_pr_author_swap_constants.py +0 -76
- package/hooks/hooks_constants/pr_description_writer_gate_constants.py +0 -73
- package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +0 -55
- package/hooks/observability/pr_description_writer_spawn_tracker.py +0 -113
- package/hooks/observability/test_pr_description_writer_spawn_tracker.py +0 -150
- package/hooks/session/gh_pr_author_session_cleanup.py +0 -175
- package/hooks/session/test_gh_pr_author_session_cleanup.py +0 -576
- package/hooks/test__gh_pr_author_swap_utils.py +0 -333
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Validate durable GitHub post inputs before publication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from skills_pr_loop_constants.durable_post_lint_constants import (
|
|
12
|
+
ACTION_PR_CREATE,
|
|
13
|
+
ACTION_PR_EDIT,
|
|
14
|
+
ALL_BARE_VOLATILE_PATH_MARKERS,
|
|
15
|
+
ALL_BODY_REQUIRED_ACTIONS,
|
|
16
|
+
ALL_PATH_ANCHORED_VOLATILE_PATH_MARKERS,
|
|
17
|
+
ALL_POST_ACTIONS,
|
|
18
|
+
ALL_PR_DESCRIPTION_ACTIONS,
|
|
19
|
+
ALL_REQUIRED_PR_DESCRIPTION_HEADINGS,
|
|
20
|
+
ALL_TITLE_ACTIONS,
|
|
21
|
+
BODY_FILE_ENCODING,
|
|
22
|
+
BODY_FILE_UNREADABLE_MESSAGE,
|
|
23
|
+
BODY_MUST_NOT_BE_EMPTY_MESSAGE,
|
|
24
|
+
BODY_REQUIRED_MESSAGE,
|
|
25
|
+
CLEAN_EXIT_CODE,
|
|
26
|
+
CONTENT_FINDING_EXIT_CODE,
|
|
27
|
+
CONVENTIONAL_TITLE_PATTERN,
|
|
28
|
+
EDIT_INPUT_REQUIRED_MESSAGE,
|
|
29
|
+
EMPTY_BODY_CODE,
|
|
30
|
+
INPUT_ERROR_EXIT_CODE,
|
|
31
|
+
INVALID_ACTION_MESSAGE,
|
|
32
|
+
INVALID_TITLE_CODE,
|
|
33
|
+
INVALID_TITLE_MESSAGE,
|
|
34
|
+
MISSING_HEADING_CODE,
|
|
35
|
+
MISSING_HEADING_MESSAGE_TEMPLATE,
|
|
36
|
+
PATH_ANCHOR_CHARACTER,
|
|
37
|
+
PATH_SEGMENT_START_CHARACTERS,
|
|
38
|
+
TITLE_NOT_ALLOWED_MESSAGE,
|
|
39
|
+
TITLE_REQUIRED_MESSAGE,
|
|
40
|
+
VOLATILE_PATH_CODE,
|
|
41
|
+
VOLATILE_PATH_MESSAGE,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DurablePostUsageError(ValueError):
|
|
46
|
+
"""Report an invalid GitHub post request shape."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class DurablePostInputError(OSError):
|
|
50
|
+
"""Report an unreadable GitHub post input file."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class DurablePostFinding:
|
|
55
|
+
"""One GitHub post content finding."""
|
|
56
|
+
|
|
57
|
+
code: str
|
|
58
|
+
message: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _character_starts_path_segment(character: str) -> bool:
|
|
62
|
+
return bool(character) and (
|
|
63
|
+
character.isalnum() or character in PATH_SEGMENT_START_CHARACTERS
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _text_has_anchored_marker(normalized_text: str, marker: str) -> bool:
|
|
68
|
+
search_start = 0
|
|
69
|
+
while True:
|
|
70
|
+
found_index = normalized_text.find(marker, search_start)
|
|
71
|
+
if found_index < 0:
|
|
72
|
+
return False
|
|
73
|
+
preceding_character = normalized_text[found_index - 1 : found_index]
|
|
74
|
+
following_index = found_index + len(marker)
|
|
75
|
+
following_character = normalized_text[following_index : following_index + 1]
|
|
76
|
+
if (
|
|
77
|
+
preceding_character == PATH_ANCHOR_CHARACTER
|
|
78
|
+
or _character_starts_path_segment(following_character)
|
|
79
|
+
):
|
|
80
|
+
return True
|
|
81
|
+
search_start = found_index + 1
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def find_volatile_path_marker(body_text: str) -> str | None:
|
|
85
|
+
"""Return the first volatile path marker in a GitHub post body.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
body_text: Post body text to scan.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
The matched marker, or None when the body names no volatile path.
|
|
92
|
+
"""
|
|
93
|
+
normalized_text = body_text.replace("\\", PATH_ANCHOR_CHARACTER).lower()
|
|
94
|
+
for each_marker in ALL_PATH_ANCHORED_VOLATILE_PATH_MARKERS:
|
|
95
|
+
if _text_has_anchored_marker(normalized_text, each_marker):
|
|
96
|
+
return each_marker
|
|
97
|
+
for each_marker in ALL_BARE_VOLATILE_PATH_MARKERS:
|
|
98
|
+
if each_marker in normalized_text:
|
|
99
|
+
return each_marker
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def read_body_file(body_file: Path) -> str:
|
|
104
|
+
"""Read one UTF-8 body file without exposing its contents on failure.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
body_file: Local post body file.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Decoded body text.
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
DurablePostInputError: The file is missing, unreadable, or invalid UTF-8.
|
|
114
|
+
"""
|
|
115
|
+
try:
|
|
116
|
+
return body_file.read_text(encoding=BODY_FILE_ENCODING)
|
|
117
|
+
except (OSError, UnicodeError) as error:
|
|
118
|
+
raise DurablePostInputError(BODY_FILE_UNREADABLE_MESSAGE) from error
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _validate_request_shape(
|
|
122
|
+
action: str,
|
|
123
|
+
title: str | None,
|
|
124
|
+
body_text: str | None,
|
|
125
|
+
) -> None:
|
|
126
|
+
if action not in ALL_POST_ACTIONS:
|
|
127
|
+
raise DurablePostUsageError(INVALID_ACTION_MESSAGE)
|
|
128
|
+
if action in ALL_BODY_REQUIRED_ACTIONS and body_text is None:
|
|
129
|
+
raise DurablePostUsageError(BODY_REQUIRED_MESSAGE)
|
|
130
|
+
if action == ACTION_PR_CREATE and not title:
|
|
131
|
+
raise DurablePostUsageError(TITLE_REQUIRED_MESSAGE)
|
|
132
|
+
if action == ACTION_PR_EDIT and title is None and body_text is None:
|
|
133
|
+
raise DurablePostUsageError(EDIT_INPUT_REQUIRED_MESSAGE)
|
|
134
|
+
if title is not None and action not in ALL_TITLE_ACTIONS:
|
|
135
|
+
raise DurablePostUsageError(TITLE_NOT_ALLOWED_MESSAGE)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _description_heading_findings(body_text: str) -> list[DurablePostFinding]:
|
|
139
|
+
all_body_lines = {each_line.strip() for each_line in body_text.splitlines()}
|
|
140
|
+
return [
|
|
141
|
+
DurablePostFinding(
|
|
142
|
+
code=MISSING_HEADING_CODE,
|
|
143
|
+
message=MISSING_HEADING_MESSAGE_TEMPLATE.format(heading=each_heading),
|
|
144
|
+
)
|
|
145
|
+
for each_heading in ALL_REQUIRED_PR_DESCRIPTION_HEADINGS
|
|
146
|
+
if f"## {each_heading}" not in all_body_lines
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _title_findings(title: str | None) -> list[DurablePostFinding]:
|
|
151
|
+
if title is None or CONVENTIONAL_TITLE_PATTERN.fullmatch(title) is not None:
|
|
152
|
+
return []
|
|
153
|
+
return [DurablePostFinding(code=INVALID_TITLE_CODE, message=INVALID_TITLE_MESSAGE)]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _body_findings(
|
|
157
|
+
action: str,
|
|
158
|
+
body_text: str | None,
|
|
159
|
+
) -> list[DurablePostFinding]:
|
|
160
|
+
if body_text is None:
|
|
161
|
+
return []
|
|
162
|
+
all_findings: list[DurablePostFinding] = []
|
|
163
|
+
if not body_text.strip():
|
|
164
|
+
all_findings.append(
|
|
165
|
+
DurablePostFinding(
|
|
166
|
+
code=EMPTY_BODY_CODE,
|
|
167
|
+
message=BODY_MUST_NOT_BE_EMPTY_MESSAGE,
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
if action in ALL_PR_DESCRIPTION_ACTIONS:
|
|
171
|
+
all_findings.extend(_description_heading_findings(body_text))
|
|
172
|
+
if find_volatile_path_marker(body_text) is not None:
|
|
173
|
+
all_findings.append(
|
|
174
|
+
DurablePostFinding(
|
|
175
|
+
code=VOLATILE_PATH_CODE,
|
|
176
|
+
message=VOLATILE_PATH_MESSAGE,
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
return all_findings
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def lint_durable_post(
|
|
183
|
+
action: str,
|
|
184
|
+
title: str | None,
|
|
185
|
+
body_text: str | None,
|
|
186
|
+
) -> tuple[DurablePostFinding, ...]:
|
|
187
|
+
"""Return content findings for one locally valid GitHub post request.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
action: GitHub post action name.
|
|
191
|
+
title: Pull request title when the action accepts one.
|
|
192
|
+
body_text: Decoded body text when the action accepts a body.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
Content findings in stable rule order.
|
|
196
|
+
|
|
197
|
+
Raises:
|
|
198
|
+
DurablePostUsageError: The action and supplied fields are not a valid request.
|
|
199
|
+
"""
|
|
200
|
+
_validate_request_shape(action, title, body_text)
|
|
201
|
+
return (*_title_findings(title), *_body_findings(action, body_text))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _parse_arguments(all_arguments: Sequence[str]) -> argparse.Namespace:
|
|
205
|
+
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
|
|
206
|
+
parser.add_argument("--action", required=True)
|
|
207
|
+
parser.add_argument("--title")
|
|
208
|
+
parser.add_argument("--body-file", type=Path)
|
|
209
|
+
return parser.parse_args(list(all_arguments))
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def main(all_arguments: Sequence[str]) -> int:
|
|
213
|
+
"""Validate one GitHub post and return its result status.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
all_arguments: Command arguments after the script name.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
Zero when clean, one for content findings, or two for invalid input.
|
|
220
|
+
"""
|
|
221
|
+
arguments = _parse_arguments(all_arguments)
|
|
222
|
+
try:
|
|
223
|
+
body_text = (
|
|
224
|
+
read_body_file(arguments.body_file)
|
|
225
|
+
if arguments.body_file is not None
|
|
226
|
+
else None
|
|
227
|
+
)
|
|
228
|
+
all_findings = lint_durable_post(
|
|
229
|
+
action=arguments.action,
|
|
230
|
+
title=arguments.title,
|
|
231
|
+
body_text=body_text,
|
|
232
|
+
)
|
|
233
|
+
except (DurablePostInputError, DurablePostUsageError) as error:
|
|
234
|
+
sys.stderr.write(f"{error}\n")
|
|
235
|
+
return INPUT_ERROR_EXIT_CODE
|
|
236
|
+
for each_finding in all_findings:
|
|
237
|
+
sys.stderr.write(f"{each_finding.message}\n")
|
|
238
|
+
return CONTENT_FINDING_EXIT_CODE if all_findings else CLEAN_EXIT_CODE
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
if __name__ == "__main__":
|
|
242
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Configuration constants for durable GitHub post validation."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
ACTION_GITHUB_MCP_POST = "github-mcp-post"
|
|
6
|
+
ACTION_ISSUE_COMMENT = "issue-comment"
|
|
7
|
+
ACTION_ISSUE_CREATE = "issue-create"
|
|
8
|
+
ACTION_ISSUE_EDIT = "issue-edit"
|
|
9
|
+
ACTION_PR_COMMENT = "pr-comment"
|
|
10
|
+
ACTION_PR_CREATE = "pr-create"
|
|
11
|
+
ACTION_PR_EDIT = "pr-edit"
|
|
12
|
+
ACTION_PR_REVIEW = "pr-review"
|
|
13
|
+
|
|
14
|
+
ALL_POST_ACTIONS: frozenset[str] = frozenset(
|
|
15
|
+
{
|
|
16
|
+
ACTION_GITHUB_MCP_POST,
|
|
17
|
+
ACTION_ISSUE_COMMENT,
|
|
18
|
+
ACTION_ISSUE_CREATE,
|
|
19
|
+
ACTION_ISSUE_EDIT,
|
|
20
|
+
ACTION_PR_COMMENT,
|
|
21
|
+
ACTION_PR_CREATE,
|
|
22
|
+
ACTION_PR_EDIT,
|
|
23
|
+
ACTION_PR_REVIEW,
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
ALL_BODY_REQUIRED_ACTIONS: frozenset[str] = frozenset(
|
|
28
|
+
{
|
|
29
|
+
ACTION_GITHUB_MCP_POST,
|
|
30
|
+
ACTION_ISSUE_COMMENT,
|
|
31
|
+
ACTION_ISSUE_CREATE,
|
|
32
|
+
ACTION_ISSUE_EDIT,
|
|
33
|
+
ACTION_PR_COMMENT,
|
|
34
|
+
ACTION_PR_CREATE,
|
|
35
|
+
ACTION_PR_REVIEW,
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
ALL_PR_DESCRIPTION_ACTIONS: frozenset[str] = frozenset(
|
|
40
|
+
{ACTION_PR_CREATE, ACTION_PR_EDIT}
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
ALL_TITLE_ACTIONS: frozenset[str] = frozenset({ACTION_PR_CREATE, ACTION_PR_EDIT})
|
|
44
|
+
|
|
45
|
+
ALL_CONVENTIONAL_TITLE_TYPES: tuple[str, ...] = (
|
|
46
|
+
"build",
|
|
47
|
+
"chore",
|
|
48
|
+
"ci",
|
|
49
|
+
"docs",
|
|
50
|
+
"feat",
|
|
51
|
+
"fix",
|
|
52
|
+
"perf",
|
|
53
|
+
"refactor",
|
|
54
|
+
"revert",
|
|
55
|
+
"style",
|
|
56
|
+
"test",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
CONVENTIONAL_TITLE_PATTERN: re.Pattern[str] = re.compile(
|
|
60
|
+
rf"^(?:{'|'.join(ALL_CONVENTIONAL_TITLE_TYPES)})(?:\([^)\r\n]+\))?!?: [^\s].*$"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
ALL_REQUIRED_PR_DESCRIPTION_HEADINGS: tuple[str, ...] = (
|
|
64
|
+
"Summary",
|
|
65
|
+
"Description",
|
|
66
|
+
"Why",
|
|
67
|
+
"How",
|
|
68
|
+
"Verification",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
ALL_PATH_ANCHORED_VOLATILE_PATH_MARKERS: tuple[str, ...] = (
|
|
72
|
+
".claude-profile-a/jobs/",
|
|
73
|
+
".claude/worktrees/",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
ALL_BARE_VOLATILE_PATH_MARKERS: tuple[str, ...] = (
|
|
77
|
+
"appdata/local/temp",
|
|
78
|
+
"/tmp/",
|
|
79
|
+
"%temp%",
|
|
80
|
+
"$env:temp",
|
|
81
|
+
"$claude_job_dir",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
BODY_FILE_ENCODING = "utf-8"
|
|
85
|
+
CLEAN_EXIT_CODE = 0
|
|
86
|
+
CONTENT_FINDING_EXIT_CODE = 1
|
|
87
|
+
EMPTY_BODY_CODE = "empty-body"
|
|
88
|
+
INPUT_ERROR_EXIT_CODE = 2
|
|
89
|
+
PATH_ANCHOR_CHARACTER = "/"
|
|
90
|
+
PATH_SEGMENT_START_CHARACTERS = "_-"
|
|
91
|
+
|
|
92
|
+
BODY_FILE_UNREADABLE_MESSAGE = "body file is not readable UTF-8 text"
|
|
93
|
+
BODY_REQUIRED_MESSAGE = "this action requires a body file"
|
|
94
|
+
BODY_MUST_NOT_BE_EMPTY_MESSAGE = "body file must contain text"
|
|
95
|
+
EDIT_INPUT_REQUIRED_MESSAGE = "pr-edit requires a title or body file"
|
|
96
|
+
INVALID_ACTION_MESSAGE = "unsupported durable post action"
|
|
97
|
+
INVALID_TITLE_CODE = "invalid-pr-title"
|
|
98
|
+
INVALID_TITLE_MESSAGE = (
|
|
99
|
+
"pull request title does not use the repository Conventional Commit form"
|
|
100
|
+
)
|
|
101
|
+
MISSING_HEADING_CODE = "missing-pr-description-heading"
|
|
102
|
+
MISSING_HEADING_MESSAGE_TEMPLATE = "body is missing required heading: {heading}"
|
|
103
|
+
TITLE_NOT_ALLOWED_MESSAGE = "this action does not accept a title"
|
|
104
|
+
TITLE_REQUIRED_MESSAGE = "pr-create requires a title"
|
|
105
|
+
VOLATILE_PATH_CODE = "volatile-local-path"
|
|
106
|
+
VOLATILE_PATH_MESSAGE = "body contains a volatile local artifact path"
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""Tests for action-aware durable GitHub post linting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
_SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
|
|
12
|
+
if str(_SCRIPTS_DIRECTORY) not in sys.path:
|
|
13
|
+
sys.path.insert(0, str(_SCRIPTS_DIRECTORY))
|
|
14
|
+
|
|
15
|
+
import durable_post_lint
|
|
16
|
+
|
|
17
|
+
SCRIPT_PATH = _SCRIPTS_DIRECTORY / "durable_post_lint.py"
|
|
18
|
+
VALID_PR_BODY = """## Summary
|
|
19
|
+
|
|
20
|
+
Summary text.
|
|
21
|
+
|
|
22
|
+
## Description
|
|
23
|
+
|
|
24
|
+
Description text.
|
|
25
|
+
|
|
26
|
+
## Why
|
|
27
|
+
|
|
28
|
+
Why text.
|
|
29
|
+
|
|
30
|
+
## How
|
|
31
|
+
|
|
32
|
+
How text.
|
|
33
|
+
|
|
34
|
+
## Verification
|
|
35
|
+
|
|
36
|
+
Verification text.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.mark.parametrize(
|
|
41
|
+
("action", "title", "body_text"),
|
|
42
|
+
[
|
|
43
|
+
("pr-create", "feat: add durable post linting", VALID_PR_BODY),
|
|
44
|
+
("pr-edit", "fix(cli): validate post input", None),
|
|
45
|
+
("pr-comment", None, "Comment body."),
|
|
46
|
+
("pr-review", None, "Review body."),
|
|
47
|
+
("issue-create", None, "Issue body."),
|
|
48
|
+
("issue-edit", None, "Updated issue body."),
|
|
49
|
+
("issue-comment", None, "Issue comment."),
|
|
50
|
+
("github-mcp-post", None, "MCP body."),
|
|
51
|
+
],
|
|
52
|
+
)
|
|
53
|
+
def test_all_actions_accept_valid_inputs(
|
|
54
|
+
action: str, title: str | None, body_text: str | None
|
|
55
|
+
) -> None:
|
|
56
|
+
all_findings = durable_post_lint.lint_durable_post(
|
|
57
|
+
action=action,
|
|
58
|
+
title=title,
|
|
59
|
+
body_text=body_text,
|
|
60
|
+
)
|
|
61
|
+
assert all_findings == ()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_unknown_action_raises_usage_error() -> None:
|
|
65
|
+
with pytest.raises(durable_post_lint.DurablePostUsageError):
|
|
66
|
+
durable_post_lint.lint_durable_post(
|
|
67
|
+
action="release-create",
|
|
68
|
+
title=None,
|
|
69
|
+
body_text="Release body.",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@pytest.mark.parametrize(
|
|
74
|
+
("title", "body_text"),
|
|
75
|
+
[
|
|
76
|
+
(None, VALID_PR_BODY),
|
|
77
|
+
("feat: add durable post linting", None),
|
|
78
|
+
],
|
|
79
|
+
)
|
|
80
|
+
def test_pr_create_requires_title_and_body(
|
|
81
|
+
title: str | None, body_text: str | None
|
|
82
|
+
) -> None:
|
|
83
|
+
with pytest.raises(durable_post_lint.DurablePostUsageError):
|
|
84
|
+
durable_post_lint.lint_durable_post(
|
|
85
|
+
action="pr-create",
|
|
86
|
+
title=title,
|
|
87
|
+
body_text=body_text,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_pr_edit_requires_one_edit_field() -> None:
|
|
92
|
+
with pytest.raises(durable_post_lint.DurablePostUsageError):
|
|
93
|
+
durable_post_lint.lint_durable_post(
|
|
94
|
+
action="pr-edit",
|
|
95
|
+
title=None,
|
|
96
|
+
body_text=None,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@pytest.mark.parametrize(
|
|
101
|
+
"action",
|
|
102
|
+
[
|
|
103
|
+
"pr-comment",
|
|
104
|
+
"pr-review",
|
|
105
|
+
"issue-create",
|
|
106
|
+
"issue-edit",
|
|
107
|
+
"issue-comment",
|
|
108
|
+
"github-mcp-post",
|
|
109
|
+
],
|
|
110
|
+
)
|
|
111
|
+
def test_body_actions_require_body(action: str) -> None:
|
|
112
|
+
with pytest.raises(durable_post_lint.DurablePostUsageError):
|
|
113
|
+
durable_post_lint.lint_durable_post(
|
|
114
|
+
action=action,
|
|
115
|
+
title=None,
|
|
116
|
+
body_text=None,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@pytest.mark.parametrize(
|
|
121
|
+
"title",
|
|
122
|
+
[
|
|
123
|
+
"feat: add durable post linting",
|
|
124
|
+
"fix(cli): validate body files",
|
|
125
|
+
"refactor!: replace post validation",
|
|
126
|
+
"docs(skill)!: explain durable posts",
|
|
127
|
+
],
|
|
128
|
+
)
|
|
129
|
+
def test_pr_titles_accept_repository_conventional_forms(title: str) -> None:
|
|
130
|
+
all_findings = durable_post_lint.lint_durable_post(
|
|
131
|
+
action="pr-edit",
|
|
132
|
+
title=title,
|
|
133
|
+
body_text=None,
|
|
134
|
+
)
|
|
135
|
+
assert all_findings == ()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@pytest.mark.parametrize(
|
|
139
|
+
"title",
|
|
140
|
+
[
|
|
141
|
+
"Add durable post linting",
|
|
142
|
+
"feature: add durable post linting",
|
|
143
|
+
"fix(): add durable post linting",
|
|
144
|
+
"fix:add durable post linting",
|
|
145
|
+
"fix: ",
|
|
146
|
+
],
|
|
147
|
+
)
|
|
148
|
+
def test_pr_titles_reject_nonconventional_forms(title: str) -> None:
|
|
149
|
+
all_findings = durable_post_lint.lint_durable_post(
|
|
150
|
+
action="pr-edit",
|
|
151
|
+
title=title,
|
|
152
|
+
body_text=None,
|
|
153
|
+
)
|
|
154
|
+
assert [each_finding.code for each_finding in all_findings] == ["invalid-pr-title"]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@pytest.mark.parametrize(
|
|
158
|
+
"missing_heading",
|
|
159
|
+
["Summary", "Description", "Why", "How", "Verification"],
|
|
160
|
+
)
|
|
161
|
+
def test_pr_body_reports_each_missing_heading(missing_heading: str) -> None:
|
|
162
|
+
body_text = VALID_PR_BODY.replace(f"## {missing_heading}\n", "")
|
|
163
|
+
all_findings = durable_post_lint.lint_durable_post(
|
|
164
|
+
action="pr-create",
|
|
165
|
+
title="feat: add durable post linting",
|
|
166
|
+
body_text=body_text,
|
|
167
|
+
)
|
|
168
|
+
assert [each_finding.message for each_finding in all_findings] == [
|
|
169
|
+
f"body is missing required heading: {missing_heading}"
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_non_pr_body_needs_no_pr_headings() -> None:
|
|
174
|
+
all_findings = durable_post_lint.lint_durable_post(
|
|
175
|
+
action="issue-comment",
|
|
176
|
+
title=None,
|
|
177
|
+
body_text="A concise issue comment.",
|
|
178
|
+
)
|
|
179
|
+
assert all_findings == ()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@pytest.mark.parametrize(
|
|
183
|
+
("body_text", "expected_marker"),
|
|
184
|
+
[
|
|
185
|
+
(
|
|
186
|
+
r"See C:\Users\example\.claude-profile-a\jobs\job-1\artifact.png",
|
|
187
|
+
".claude-profile-a/jobs/",
|
|
188
|
+
),
|
|
189
|
+
(
|
|
190
|
+
r"Edited C:\Users\example\.claude\worktrees\feature\file.py",
|
|
191
|
+
".claude/worktrees/",
|
|
192
|
+
),
|
|
193
|
+
(
|
|
194
|
+
r"Saved in C:\Users\example\AppData\Local\Temp\report.txt",
|
|
195
|
+
"appdata/local/temp",
|
|
196
|
+
),
|
|
197
|
+
("Saved in /tmp/report.txt", "/tmp/"),
|
|
198
|
+
(r"Saved in %TEMP%\report.txt", "%temp%"),
|
|
199
|
+
(r"Saved in $env:TEMP\report.txt", "$env:temp"),
|
|
200
|
+
("Saved in $CLAUDE_JOB_DIR/report.txt", "$claude_job_dir"),
|
|
201
|
+
],
|
|
202
|
+
)
|
|
203
|
+
def test_volatile_path_markers_match_hook_behavior(
|
|
204
|
+
body_text: str, expected_marker: str
|
|
205
|
+
) -> None:
|
|
206
|
+
assert durable_post_lint.find_volatile_path_marker(body_text) == expected_marker
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@pytest.mark.parametrize(
|
|
210
|
+
"body_text",
|
|
211
|
+
[
|
|
212
|
+
"the `.claude/worktrees/` prefix is a manifest key",
|
|
213
|
+
".claude/worktrees/ entries are skipped by the manifest",
|
|
214
|
+
"(.claude-profile-a/jobs/) is a directory name",
|
|
215
|
+
"worktrees live under `.claude/worktrees/<name>`",
|
|
216
|
+
"the constant is `.claude/worktrees/`. Next.",
|
|
217
|
+
],
|
|
218
|
+
)
|
|
219
|
+
def test_anchored_marker_near_misses_remain_allowed(body_text: str) -> None:
|
|
220
|
+
assert durable_post_lint.find_volatile_path_marker(body_text) is None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_volatile_body_returns_content_finding() -> None:
|
|
224
|
+
all_findings = durable_post_lint.lint_durable_post(
|
|
225
|
+
action="issue-comment",
|
|
226
|
+
title=None,
|
|
227
|
+
body_text="Saved in /tmp/report.txt",
|
|
228
|
+
)
|
|
229
|
+
assert [each_finding.code for each_finding in all_findings] == [
|
|
230
|
+
"volatile-local-path"
|
|
231
|
+
]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def test_read_body_file_accepts_utf8(tmp_path: Path) -> None:
|
|
235
|
+
body_file = tmp_path / "body.md"
|
|
236
|
+
body_file.write_text("Café", encoding="utf-8")
|
|
237
|
+
assert durable_post_lint.read_body_file(body_file) == "Café"
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def test_read_body_file_rejects_missing_file(tmp_path: Path) -> None:
|
|
241
|
+
missing_file = tmp_path / "missing.md"
|
|
242
|
+
with pytest.raises(durable_post_lint.DurablePostInputError):
|
|
243
|
+
durable_post_lint.read_body_file(missing_file)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_read_body_file_rejects_invalid_utf8(tmp_path: Path) -> None:
|
|
247
|
+
body_file = tmp_path / "body.md"
|
|
248
|
+
body_file.write_bytes(b"\xff\xfe")
|
|
249
|
+
with pytest.raises(durable_post_lint.DurablePostInputError):
|
|
250
|
+
durable_post_lint.read_body_file(body_file)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def test_cli_returns_clean_exit_for_valid_pr_create(tmp_path: Path) -> None:
|
|
254
|
+
body_file = tmp_path / "body.md"
|
|
255
|
+
body_file.write_text(VALID_PR_BODY, encoding="utf-8")
|
|
256
|
+
completed_process = subprocess.run(
|
|
257
|
+
[
|
|
258
|
+
sys.executable,
|
|
259
|
+
str(SCRIPT_PATH),
|
|
260
|
+
"--action",
|
|
261
|
+
"pr-create",
|
|
262
|
+
"--title",
|
|
263
|
+
"feat: add durable post linting",
|
|
264
|
+
"--body-file",
|
|
265
|
+
str(body_file),
|
|
266
|
+
],
|
|
267
|
+
check=False,
|
|
268
|
+
capture_output=True,
|
|
269
|
+
text=True,
|
|
270
|
+
)
|
|
271
|
+
assert completed_process.returncode == 0
|
|
272
|
+
assert completed_process.stdout == ""
|
|
273
|
+
assert completed_process.stderr == ""
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def test_cli_returns_content_exit_without_echoing_body(tmp_path: Path) -> None:
|
|
277
|
+
body_text = "private body words saved in /tmp/report.txt"
|
|
278
|
+
body_file = tmp_path / "body.md"
|
|
279
|
+
body_file.write_text(body_text, encoding="utf-8")
|
|
280
|
+
completed_process = subprocess.run(
|
|
281
|
+
[
|
|
282
|
+
sys.executable,
|
|
283
|
+
str(SCRIPT_PATH),
|
|
284
|
+
"--action",
|
|
285
|
+
"issue-comment",
|
|
286
|
+
"--body-file",
|
|
287
|
+
str(body_file),
|
|
288
|
+
],
|
|
289
|
+
check=False,
|
|
290
|
+
capture_output=True,
|
|
291
|
+
text=True,
|
|
292
|
+
)
|
|
293
|
+
assert completed_process.returncode == 1
|
|
294
|
+
assert "volatile local artifact path" in completed_process.stderr
|
|
295
|
+
assert body_text not in completed_process.stderr
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def test_cli_returns_usage_exit_for_missing_body_file(tmp_path: Path) -> None:
|
|
299
|
+
missing_file = tmp_path / "missing.md"
|
|
300
|
+
completed_process = subprocess.run(
|
|
301
|
+
[
|
|
302
|
+
sys.executable,
|
|
303
|
+
str(SCRIPT_PATH),
|
|
304
|
+
"--action",
|
|
305
|
+
"issue-comment",
|
|
306
|
+
"--body-file",
|
|
307
|
+
str(missing_file),
|
|
308
|
+
],
|
|
309
|
+
check=False,
|
|
310
|
+
capture_output=True,
|
|
311
|
+
text=True,
|
|
312
|
+
)
|
|
313
|
+
assert completed_process.returncode == 2
|
|
314
|
+
assert "body file is not readable" in completed_process.stderr
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def test_cli_has_no_inline_body_flag() -> None:
|
|
318
|
+
completed_process = subprocess.run(
|
|
319
|
+
[
|
|
320
|
+
sys.executable,
|
|
321
|
+
str(SCRIPT_PATH),
|
|
322
|
+
"--action",
|
|
323
|
+
"issue-comment",
|
|
324
|
+
"--body",
|
|
325
|
+
"Inline body.",
|
|
326
|
+
],
|
|
327
|
+
check=False,
|
|
328
|
+
capture_output=True,
|
|
329
|
+
text=True,
|
|
330
|
+
)
|
|
331
|
+
assert completed_process.returncode == 2
|
|
332
|
+
assert "unrecognized arguments" in completed_process.stderr
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Tests for durable GitHub post linter constants."""
|
|
2
|
+
|
|
3
|
+
from skills_pr_loop_constants.durable_post_lint_constants import (
|
|
4
|
+
ALL_CONVENTIONAL_TITLE_TYPES,
|
|
5
|
+
ALL_POST_ACTIONS,
|
|
6
|
+
CONVENTIONAL_TITLE_PATTERN,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_action_inventory_covers_pull_request_issue_and_tool_posts() -> None:
|
|
11
|
+
assert ALL_POST_ACTIONS == {
|
|
12
|
+
"github-mcp-post",
|
|
13
|
+
"issue-comment",
|
|
14
|
+
"issue-create",
|
|
15
|
+
"issue-edit",
|
|
16
|
+
"pr-comment",
|
|
17
|
+
"pr-create",
|
|
18
|
+
"pr-edit",
|
|
19
|
+
"pr-review",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_title_pattern_uses_every_configured_type() -> None:
|
|
24
|
+
for each_title_type in ALL_CONVENTIONAL_TITLE_TYPES:
|
|
25
|
+
assert CONVENTIONAL_TITLE_PATTERN.fullmatch(f"{each_title_type}: one change")
|