claude-dev-env 8.8.0 → 8.10.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/privacy-hygiene/SKILL.md +1 -0
- package/.agents/skills/pull-request/scripts/pull_request.py +1 -0
- package/.agents/skills/pull-request/scripts/test_pull_request.py +2 -0
- package/bin/install-constants.mjs +5 -0
- package/bin/install.cursor-rules.test.mjs +6 -0
- package/bin/install.mjs +2 -2
- package/docs/second-claude-account.md +8 -1
- package/hooks/hooks.json +37 -0
- package/hooks/hooks_constants/skill_loaded_reminder_constants.py +70 -0
- package/hooks/session/skill_loaded_reminder.py +214 -0
- package/hooks/session/test_skill_loaded_reminder.py +253 -0
- package/package.json +1 -1
- package/rules/durable-post-artifacts.md +26 -1
- package/rules/prefer-existing-tools.md +35 -0
- package/rules/review-closure-is-a-check.md +1 -1
- package/scripts/claude_account_choice.py +51 -6
- package/scripts/dev_env_scripts_constants/claude_account_constants.py +15 -0
- package/scripts/dev_env_scripts_constants/gh_artifact_upload_constants.py +27 -0
- package/scripts/dev_env_scripts_constants/private_term_constants.py +60 -0
- package/scripts/dev_env_scripts_constants/test_claude_account_constants.py +25 -0
- package/scripts/dev_env_scripts_constants/test_private_term_constants.py +29 -0
- package/scripts/durable_post_lint.py +47 -1
- package/scripts/durable_post_lint_config/config/constants.py +1 -0
- package/scripts/gh_artifact_upload.py +56 -3
- package/scripts/private_term_scan.py +135 -0
- package/scripts/private_terms.py +87 -0
- package/scripts/repository_checks/config/constants.py +3 -0
- package/scripts/repository_checks/runner.py +7 -0
- package/scripts/repository_checks/tracked_private_terms.py +57 -0
- package/scripts/test_claude_account_choice.py +28 -0
- package/scripts/test_durable_post_lint.py +90 -0
- package/scripts/tests/test_gh_artifact_upload.py +75 -0
- package/scripts/tests/test_match_exemptions.py +2 -2
- package/scripts/tests/test_private_term_scan.py +176 -0
- package/scripts/tests/test_private_terms.py +59 -0
- package/scripts/tests/test_repository_policy.py +2 -0
- package/scripts/tests/test_runner.py +20 -0
- package/scripts/tests/test_tracked_private_terms.py +71 -0
- package/scripts/tests/test_tracked_secrets.py +1 -1
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Fail a GitHub event whose text or commits name a private organization.
|
|
2
|
+
|
|
3
|
+
The workflow ``private-terms.yml`` runs this on pull request, issue, comment,
|
|
4
|
+
review, and release events. Each finding names where the text sits and its
|
|
5
|
+
line number, never the name itself, so the public log stays clean::
|
|
6
|
+
|
|
7
|
+
pull request body: Line 4 names a private organization. ...
|
|
8
|
+
commit 3f2a9c1b77de identity: Line 1 names a private organization. ...
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from dev_env_scripts_constants.private_term_constants import (
|
|
21
|
+
ALL_EVENT_TEXT_FIELDS,
|
|
22
|
+
ALL_PRIVATE_TERM_DIGESTS,
|
|
23
|
+
COMMIT_FIELD_SEPARATOR,
|
|
24
|
+
COMMIT_FIELD_SPLIT_LIMIT,
|
|
25
|
+
COMMIT_IDENTITY_PART,
|
|
26
|
+
COMMIT_LABEL_TEMPLATE,
|
|
27
|
+
COMMIT_LOG_FORMAT,
|
|
28
|
+
COMMIT_MESSAGE_PART,
|
|
29
|
+
COMMIT_RECORD_SEPARATOR,
|
|
30
|
+
FINDING_LINE_TEMPLATE,
|
|
31
|
+
PRIVATE_TERM_MESSAGE_TEMPLATE,
|
|
32
|
+
PRIVATE_TERM_TEXT_ENCODING,
|
|
33
|
+
SHORT_SHA_LENGTH,
|
|
34
|
+
)
|
|
35
|
+
from private_terms import private_term_line_numbers
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _findings_for(label: str, text: str) -> list[str]:
|
|
39
|
+
return [
|
|
40
|
+
FINDING_LINE_TEMPLATE.format(
|
|
41
|
+
label=label,
|
|
42
|
+
message=PRIVATE_TERM_MESSAGE_TEMPLATE.format(line_number=each_line_number),
|
|
43
|
+
)
|
|
44
|
+
for each_line_number in private_term_line_numbers(
|
|
45
|
+
text, ALL_PRIVATE_TERM_DIGESTS
|
|
46
|
+
)
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def scan_event(event_path: Path) -> list[str]:
|
|
51
|
+
"""Return findings for the texts a GitHub event payload carries.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
event_path: Path of the event JSON the runner writes.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
One finding per line that names a private organization.
|
|
58
|
+
"""
|
|
59
|
+
event = json.loads(event_path.read_text(encoding=PRIVATE_TERM_TEXT_ENCODING))
|
|
60
|
+
all_findings: list[str] = []
|
|
61
|
+
for each_object_key, each_field, each_label in ALL_EVENT_TEXT_FIELDS:
|
|
62
|
+
event_object = event.get(each_object_key)
|
|
63
|
+
if not isinstance(event_object, dict):
|
|
64
|
+
continue
|
|
65
|
+
text = event_object.get(each_field)
|
|
66
|
+
if isinstance(text, str):
|
|
67
|
+
all_findings.extend(_findings_for(each_label, text))
|
|
68
|
+
return all_findings
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _commit_findings(record: str) -> list[str]:
|
|
72
|
+
full_sha, identity, message = record.split(
|
|
73
|
+
COMMIT_FIELD_SEPARATOR, COMMIT_FIELD_SPLIT_LIMIT
|
|
74
|
+
)
|
|
75
|
+
short_sha = full_sha[:SHORT_SHA_LENGTH]
|
|
76
|
+
return [
|
|
77
|
+
*_findings_for(
|
|
78
|
+
COMMIT_LABEL_TEMPLATE.format(short_sha=short_sha, part=COMMIT_IDENTITY_PART),
|
|
79
|
+
identity,
|
|
80
|
+
),
|
|
81
|
+
*_findings_for(
|
|
82
|
+
COMMIT_LABEL_TEMPLATE.format(short_sha=short_sha, part=COMMIT_MESSAGE_PART),
|
|
83
|
+
message,
|
|
84
|
+
),
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def scan_commits(repository_root: Path, commit_range: str) -> list[str]:
|
|
89
|
+
"""Return findings for commit messages and identities in a range, oldest first.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
repository_root: Git checkout holding the range.
|
|
93
|
+
commit_range: A ``base..head`` revision range.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
One finding per commit identity or message line that names a private
|
|
97
|
+
organization.
|
|
98
|
+
"""
|
|
99
|
+
log_text = subprocess.run(
|
|
100
|
+
["git", "-C", str(repository_root), "log", "--reverse", COMMIT_LOG_FORMAT, commit_range],
|
|
101
|
+
check=True,
|
|
102
|
+
capture_output=True,
|
|
103
|
+
encoding=PRIVATE_TERM_TEXT_ENCODING,
|
|
104
|
+
).stdout
|
|
105
|
+
return [
|
|
106
|
+
each_finding
|
|
107
|
+
for each_record in log_text.split(COMMIT_RECORD_SEPARATOR)
|
|
108
|
+
if each_record.strip()
|
|
109
|
+
for each_finding in _commit_findings(each_record.strip())
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def main(all_arguments: Sequence[str]) -> int:
|
|
114
|
+
"""Print every finding and return 1 when any text names a private organization.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
all_arguments: Command-line arguments after the script name.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
0 for a clean event, 1 when any finding printed.
|
|
121
|
+
"""
|
|
122
|
+
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
|
|
123
|
+
parser.add_argument("--event-path", type=Path, required=True)
|
|
124
|
+
parser.add_argument("--commit-range")
|
|
125
|
+
arguments = parser.parse_args(list(all_arguments))
|
|
126
|
+
all_findings = scan_event(arguments.event_path)
|
|
127
|
+
if arguments.commit_range:
|
|
128
|
+
all_findings.extend(scan_commits(Path.cwd(), arguments.commit_range))
|
|
129
|
+
for each_finding in all_findings:
|
|
130
|
+
print(each_finding)
|
|
131
|
+
return 1 if all_findings else 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__":
|
|
135
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Find private organization names in text without holding the names.
|
|
2
|
+
|
|
3
|
+
``private_term_line_numbers`` reports where a name appears and never the
|
|
4
|
+
name, so a caller can print its result in a public log.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import bisect
|
|
10
|
+
import hashlib
|
|
11
|
+
from collections.abc import Collection
|
|
12
|
+
|
|
13
|
+
from dev_env_scripts_constants.private_term_constants import (
|
|
14
|
+
ALL_PRIVATE_TERM_DIGESTS,
|
|
15
|
+
PRIVATE_TERM_TEXT_ENCODING,
|
|
16
|
+
PrivateTermDigest,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _normalized_text_and_line_starts(text: str) -> tuple[str, list[int]]:
|
|
21
|
+
all_normalized_lines: list[str] = []
|
|
22
|
+
all_line_starts: list[int] = []
|
|
23
|
+
offset = 0
|
|
24
|
+
for each_line in text.split("\n"):
|
|
25
|
+
normalized_line = "".join(
|
|
26
|
+
each_character
|
|
27
|
+
for each_character in each_line.lower()
|
|
28
|
+
if each_character.isascii() and each_character.isalnum()
|
|
29
|
+
)
|
|
30
|
+
all_line_starts.append(offset)
|
|
31
|
+
all_normalized_lines.append(normalized_line)
|
|
32
|
+
offset += len(normalized_line)
|
|
33
|
+
return "".join(all_normalized_lines), all_line_starts
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _matching_window_starts(
|
|
37
|
+
normalized_text: str, length: int, all_wanted_digests: set[str]
|
|
38
|
+
) -> list[int]:
|
|
39
|
+
return [
|
|
40
|
+
each_start
|
|
41
|
+
for each_start in range(len(normalized_text) - length + 1)
|
|
42
|
+
if hashlib.sha256(
|
|
43
|
+
normalized_text[each_start : each_start + length].encode(
|
|
44
|
+
PRIVATE_TERM_TEXT_ENCODING
|
|
45
|
+
)
|
|
46
|
+
).hexdigest()
|
|
47
|
+
in all_wanted_digests
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def private_term_line_numbers(
|
|
52
|
+
text: str,
|
|
53
|
+
all_term_digests: Collection[PrivateTermDigest] = ALL_PRIVATE_TERM_DIGESTS,
|
|
54
|
+
) -> tuple[int, ...]:
|
|
55
|
+
"""Return the sorted one-based line numbers where a private name starts.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
text: Text to scan.
|
|
59
|
+
all_term_digests: Digests of the normalized names to find.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Each line number once. A name that wraps onto the next line reports
|
|
63
|
+
the line where it starts.
|
|
64
|
+
"""
|
|
65
|
+
normalized_text, all_line_starts = _normalized_text_and_line_starts(text)
|
|
66
|
+
all_found_line_numbers: set[int] = set()
|
|
67
|
+
for each_length in {each_digest.length for each_digest in all_term_digests}:
|
|
68
|
+
all_wanted_digests = {
|
|
69
|
+
each_digest.sha256
|
|
70
|
+
for each_digest in all_term_digests
|
|
71
|
+
if each_digest.length == each_length
|
|
72
|
+
}
|
|
73
|
+
all_found_line_numbers.update(
|
|
74
|
+
bisect.bisect_right(all_line_starts, each_start)
|
|
75
|
+
for each_start in _matching_window_starts(
|
|
76
|
+
normalized_text, each_length, all_wanted_digests
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
return tuple(sorted(all_found_line_numbers))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def names_private_term(
|
|
83
|
+
text: str,
|
|
84
|
+
all_term_digests: Collection[PrivateTermDigest] = ALL_PRIVATE_TERM_DIGESTS,
|
|
85
|
+
) -> bool:
|
|
86
|
+
"""Return whether the text names any private organization."""
|
|
87
|
+
return bool(private_term_line_numbers(text, all_term_digests))
|
|
@@ -12,6 +12,7 @@ CHECK_ID_ENV_VAR_DOCUMENTATION = "env-var-documentation"
|
|
|
12
12
|
CHECK_ID_PACKAGE_INVENTORY = "package-inventory"
|
|
13
13
|
CHECK_ID_PYTEST_TESTPATHS = "pytest-testpaths"
|
|
14
14
|
CHECK_ID_TRACKED_PERSONAL_DATA = "tracked-secrets"
|
|
15
|
+
CHECK_ID_TRACKED_PRIVATE_TERMS = "tracked-private-terms"
|
|
15
16
|
|
|
16
17
|
ALL_CHECK_IDS = (
|
|
17
18
|
CHECK_ID_CLAUDE_MD_ORPHANS,
|
|
@@ -19,6 +20,7 @@ ALL_CHECK_IDS = (
|
|
|
19
20
|
CHECK_ID_PACKAGE_INVENTORY,
|
|
20
21
|
CHECK_ID_PYTEST_TESTPATHS,
|
|
21
22
|
CHECK_ID_TRACKED_PERSONAL_DATA,
|
|
23
|
+
CHECK_ID_TRACKED_PRIVATE_TERMS,
|
|
22
24
|
)
|
|
23
25
|
|
|
24
26
|
SEVERITY_BREAKING = "breaking"
|
|
@@ -29,6 +31,7 @@ SEVERITY_BY_CHECK_ID: dict[str, str] = {
|
|
|
29
31
|
CHECK_ID_PACKAGE_INVENTORY: SEVERITY_SMELL,
|
|
30
32
|
CHECK_ID_PYTEST_TESTPATHS: SEVERITY_BREAKING,
|
|
31
33
|
CHECK_ID_TRACKED_PERSONAL_DATA: SEVERITY_BREAKING,
|
|
34
|
+
CHECK_ID_TRACKED_PRIVATE_TERMS: SEVERITY_BREAKING,
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
SUCCESS_EXIT_CODE = 0
|
|
@@ -14,6 +14,9 @@ from repository_checks.env_var_documentation import (
|
|
|
14
14
|
from repository_checks.models import RepositoryCheckReport, RepositoryFinding
|
|
15
15
|
from repository_checks.package_inventory import collect_package_inventory_findings
|
|
16
16
|
from repository_checks.pytest_testpaths import collect_pytest_testpath_findings
|
|
17
|
+
from repository_checks.tracked_private_terms import (
|
|
18
|
+
collect_tracked_private_term_findings,
|
|
19
|
+
)
|
|
17
20
|
from repository_checks.tracked_secrets import collect_tracked_secret_findings
|
|
18
21
|
|
|
19
22
|
RepositoryCollector = Callable[[Path, Sequence[str]], list[RepositoryFinding]]
|
|
@@ -90,4 +93,8 @@ def _all_collectors() -> tuple[tuple[str, RepositoryCollector], ...]:
|
|
|
90
93
|
repository_constants.CHECK_ID_TRACKED_PERSONAL_DATA,
|
|
91
94
|
collect_tracked_secret_findings,
|
|
92
95
|
),
|
|
96
|
+
(
|
|
97
|
+
repository_constants.CHECK_ID_TRACKED_PRIVATE_TERMS,
|
|
98
|
+
collect_tracked_private_term_findings,
|
|
99
|
+
),
|
|
93
100
|
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Tracked-file check for private organization names."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from dev_env_scripts_constants.private_term_constants import (
|
|
9
|
+
ALL_PRIVATE_TERM_DIGESTS,
|
|
10
|
+
PRIVATE_TERM_MESSAGE_TEMPLATE,
|
|
11
|
+
)
|
|
12
|
+
from policy_lint.config import constants as policy_constants
|
|
13
|
+
from private_terms import private_term_line_numbers
|
|
14
|
+
|
|
15
|
+
from repository_checks.config import constants as repository_constants
|
|
16
|
+
from repository_checks.models import RepositoryFinding
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _read_text_or_none(absolute_path: Path) -> str | None:
|
|
20
|
+
if not absolute_path.is_file():
|
|
21
|
+
return None
|
|
22
|
+
try:
|
|
23
|
+
return absolute_path.read_text(encoding=policy_constants.UTF8_ENCODING)
|
|
24
|
+
except UnicodeDecodeError:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def collect_tracked_private_term_findings(
|
|
29
|
+
repository_root: Path, all_tracked_paths: Sequence[str]
|
|
30
|
+
) -> list[RepositoryFinding]:
|
|
31
|
+
"""Return one finding for each tracked line that names a private organization.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
repository_root: Git repository root.
|
|
35
|
+
all_tracked_paths: Repository-relative tracked paths.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Findings that carry the path and line number, never the name.
|
|
39
|
+
"""
|
|
40
|
+
all_findings: list[RepositoryFinding] = []
|
|
41
|
+
for each_relative_path in all_tracked_paths:
|
|
42
|
+
if (content := _read_text_or_none(repository_root / each_relative_path)) is None:
|
|
43
|
+
continue
|
|
44
|
+
posix_relative_path = each_relative_path.replace(
|
|
45
|
+
repository_constants.WINDOWS_PATH_SEPARATOR, policy_constants.PATH_SEPARATOR
|
|
46
|
+
)
|
|
47
|
+
all_findings.extend(
|
|
48
|
+
RepositoryFinding(
|
|
49
|
+
repository_constants.CHECK_ID_TRACKED_PRIVATE_TERMS,
|
|
50
|
+
posix_relative_path,
|
|
51
|
+
PRIVATE_TERM_MESSAGE_TEMPLATE.format(line_number=each_line_number),
|
|
52
|
+
)
|
|
53
|
+
for each_line_number in private_term_line_numbers(
|
|
54
|
+
content, ALL_PRIVATE_TERM_DIGESTS
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
return all_findings
|
|
@@ -158,6 +158,32 @@ class TestDecisionPayload:
|
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
|
|
161
|
+
class TestMetersPayload:
|
|
162
|
+
def should_name_no_meters_for_an_unread_account(self) -> None:
|
|
163
|
+
assert choice.meters_payload(None) is None
|
|
164
|
+
|
|
165
|
+
def should_carry_each_used_percent_and_reset_time(self) -> None:
|
|
166
|
+
read_meters = meters(
|
|
167
|
+
session_used=12.0,
|
|
168
|
+
weekly_used=34.0,
|
|
169
|
+
weekly_resets_in=timedelta(days=3),
|
|
170
|
+
session_resets_in=timedelta(hours=1),
|
|
171
|
+
)
|
|
172
|
+
assert choice.meters_payload(read_meters) == {
|
|
173
|
+
"session_used_percent": 12.0,
|
|
174
|
+
"session_resets_at": "2026-09-22T22:00:00+00:00",
|
|
175
|
+
"weekly_used_percent": 34.0,
|
|
176
|
+
"weekly_resets_at": "2026-09-25T21:00:00+00:00",
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
def should_name_no_reset_time_the_meter_left_out(self) -> None:
|
|
180
|
+
read_meters = meters(session_used=1.0, weekly_used=None, weekly_resets_in=None)
|
|
181
|
+
payload = choice.meters_payload(read_meters)
|
|
182
|
+
assert payload is not None
|
|
183
|
+
assert payload["weekly_used_percent"] is None
|
|
184
|
+
assert payload["weekly_resets_at"] is None
|
|
185
|
+
|
|
186
|
+
|
|
161
187
|
class TestMain:
|
|
162
188
|
def should_print_the_choice_and_the_config_directory_of_the_chosen_account(
|
|
163
189
|
self,
|
|
@@ -190,6 +216,8 @@ class TestMain:
|
|
|
190
216
|
assert exit_code == 0
|
|
191
217
|
assert printed["account"] == choice.CHOICE_SECOND
|
|
192
218
|
assert printed["config_dir"] == str(second_home)
|
|
219
|
+
assert printed["meters"]["main"] is None
|
|
220
|
+
assert printed["meters"]["second"]["weekly_used_percent"] == 10.0
|
|
193
221
|
|
|
194
222
|
def should_print_no_config_directory_when_the_job_waits(
|
|
195
223
|
self,
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import hashlib
|
|
5
6
|
import subprocess
|
|
6
7
|
import sys
|
|
7
8
|
from pathlib import Path
|
|
@@ -13,6 +14,7 @@ if str(_SCRIPTS_DIRECTORY) not in sys.path:
|
|
|
13
14
|
sys.path.insert(0, str(_SCRIPTS_DIRECTORY))
|
|
14
15
|
|
|
15
16
|
import durable_post_lint
|
|
17
|
+
from dev_env_scripts_constants.private_term_constants import PrivateTermDigest
|
|
16
18
|
|
|
17
19
|
SCRIPT_PATH = _SCRIPTS_DIRECTORY / "durable_post_lint.py"
|
|
18
20
|
VALID_PR_BODY = """## Why
|
|
@@ -506,3 +508,91 @@ def test_a_release_body_carrying_a_contrast_stays_accepted() -> None:
|
|
|
506
508
|
)
|
|
507
509
|
|
|
508
510
|
assert all_findings == ()
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
_PRIVATE_FIXTURE_TERM = "acmewidget"
|
|
514
|
+
_ALL_PRIVATE_FIXTURE_DIGESTS = frozenset(
|
|
515
|
+
{
|
|
516
|
+
PrivateTermDigest(
|
|
517
|
+
length=len(_PRIVATE_FIXTURE_TERM),
|
|
518
|
+
sha256=hashlib.sha256(_PRIVATE_FIXTURE_TERM.encode("utf-8")).hexdigest(),
|
|
519
|
+
)
|
|
520
|
+
}
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
@pytest.fixture
|
|
525
|
+
def private_fixture_terms(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
526
|
+
monkeypatch.setattr(
|
|
527
|
+
durable_post_lint, "ALL_PRIVATE_TERM_DIGESTS", _ALL_PRIVATE_FIXTURE_DIGESTS
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
@pytest.mark.usefixtures("private_fixture_terms")
|
|
532
|
+
def test_a_body_naming_a_private_organization_reports_its_line() -> None:
|
|
533
|
+
all_findings = durable_post_lint.lint_durable_post(
|
|
534
|
+
action="pr-comment",
|
|
535
|
+
title=None,
|
|
536
|
+
body_text="Fixed.\nSee Acme-Widgets-Inc/tools#12.\n",
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
assert [each_finding.code for each_finding in all_findings] == ["private-term"]
|
|
540
|
+
assert "Line 2" in all_findings[0].message
|
|
541
|
+
assert "Acme" not in all_findings[0].message
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
@pytest.mark.usefixtures("private_fixture_terms")
|
|
545
|
+
def test_a_title_naming_a_private_organization_reports() -> None:
|
|
546
|
+
all_findings = durable_post_lint.lint_durable_post(
|
|
547
|
+
action="pr-create",
|
|
548
|
+
title="fix: handle the acme widgets upload",
|
|
549
|
+
body_text=VALID_PR_BODY,
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
assert [each_finding.code for each_finding in all_findings] == ["private-term"]
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
@pytest.mark.usefixtures("private_fixture_terms")
|
|
556
|
+
def test_a_post_inside_the_private_organization_may_name_it() -> None:
|
|
557
|
+
all_findings = durable_post_lint.lint_durable_post(
|
|
558
|
+
action="pr-comment",
|
|
559
|
+
title=None,
|
|
560
|
+
body_text="See Acme-Widgets-Inc/tools#12.\n",
|
|
561
|
+
repository="Acme-Widgets-Inc/tools",
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
assert all_findings == ()
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
@pytest.mark.usefixtures("private_fixture_terms")
|
|
568
|
+
def test_a_post_to_a_public_repository_may_not_name_it() -> None:
|
|
569
|
+
all_findings = durable_post_lint.lint_durable_post(
|
|
570
|
+
action="pr-comment",
|
|
571
|
+
title=None,
|
|
572
|
+
body_text="See Acme-Widgets-Inc/tools#12.\n",
|
|
573
|
+
repository="pat/public-tools",
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
assert [each_finding.code for each_finding in all_findings] == ["private-term"]
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def test_cli_accepts_a_repository(tmp_path: Path) -> None:
|
|
580
|
+
body_file = tmp_path / "body.md"
|
|
581
|
+
body_file.write_text("Comment body.\n", encoding="utf-8")
|
|
582
|
+
completed_process = subprocess.run(
|
|
583
|
+
[
|
|
584
|
+
sys.executable,
|
|
585
|
+
str(SCRIPT_PATH),
|
|
586
|
+
"--action",
|
|
587
|
+
"pr-comment",
|
|
588
|
+
"--repository",
|
|
589
|
+
"pat/public-tools",
|
|
590
|
+
"--body-file",
|
|
591
|
+
str(body_file),
|
|
592
|
+
],
|
|
593
|
+
check=False,
|
|
594
|
+
capture_output=True,
|
|
595
|
+
text=True,
|
|
596
|
+
)
|
|
597
|
+
assert completed_process.returncode == 0
|
|
598
|
+
assert completed_process.stderr == ""
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import shutil
|
|
6
7
|
import subprocess
|
|
7
8
|
import sys
|
|
8
9
|
import types
|
|
@@ -26,6 +27,15 @@ _DEFAULT_READBACK_ASSET = {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
|
|
30
|
+
_REAL_WHICH = shutil.which
|
|
31
|
+
_REAL_RUN = subprocess.run
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.fixture(autouse=True)
|
|
35
|
+
def oxipng_on_path(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
36
|
+
monkeypatch.setattr(shutil, "which", lambda _name: "oxipng")
|
|
37
|
+
|
|
38
|
+
|
|
29
39
|
def _make_gh_stub(
|
|
30
40
|
recorded_calls: list[list[str]],
|
|
31
41
|
view_return_code: int,
|
|
@@ -203,3 +213,68 @@ def test_upload_artifact_missing_file_raises(monkeypatch: pytest.MonkeyPatch) ->
|
|
|
203
213
|
monkeypatch.setattr(subprocess, "run", _make_gh_stub([], view_return_code=0))
|
|
204
214
|
with pytest.raises(mod.ArtifactUploadError):
|
|
205
215
|
mod.upload_artifact("does_not_exist_9f3a.png", "owner/repo")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_upload_artifact_sends_the_oxipng_shrunk_png(
|
|
219
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
220
|
+
) -> None:
|
|
221
|
+
image_module = pytest.importorskip("PIL.Image")
|
|
222
|
+
if _REAL_WHICH("oxipng") is None:
|
|
223
|
+
pytest.skip("oxipng is not on PATH")
|
|
224
|
+
monkeypatch.setattr(shutil, "which", _REAL_WHICH)
|
|
225
|
+
source_file = tmp_path / "contact_sheet.png"
|
|
226
|
+
image_module.new("RGB", (64, 64), (200, 40, 40)).save(
|
|
227
|
+
source_file, format="PNG", compress_level=0
|
|
228
|
+
)
|
|
229
|
+
source_bytes = source_file.read_bytes()
|
|
230
|
+
all_uploaded_sizes: list[int] = []
|
|
231
|
+
gh_stub = _make_gh_stub([], view_return_code=0)
|
|
232
|
+
|
|
233
|
+
def gh_stub_beside_live_oxipng(
|
|
234
|
+
all_command_arguments: list[str], **keyword_arguments: object
|
|
235
|
+
) -> object:
|
|
236
|
+
if all_command_arguments[0] != "gh":
|
|
237
|
+
return _REAL_RUN(all_command_arguments, **keyword_arguments)
|
|
238
|
+
if "upload" in all_command_arguments:
|
|
239
|
+
staged_path = Path(all_command_arguments[4])
|
|
240
|
+
all_uploaded_sizes.append(staged_path.stat().st_size)
|
|
241
|
+
with image_module.open(staged_path) as uploaded:
|
|
242
|
+
assert uploaded.convert("RGB").tobytes() == bytes((200, 40, 40)) * 4096
|
|
243
|
+
return gh_stub(all_command_arguments, **keyword_arguments)
|
|
244
|
+
|
|
245
|
+
monkeypatch.setattr(subprocess, "run", gh_stub_beside_live_oxipng)
|
|
246
|
+
|
|
247
|
+
mod.upload_artifact(str(source_file), "owner/repo")
|
|
248
|
+
|
|
249
|
+
assert all_uploaded_sizes and all_uploaded_sizes[0] < len(source_bytes)
|
|
250
|
+
assert source_file.read_bytes() == source_bytes
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def test_upload_artifact_refuses_a_png_when_oxipng_is_missing(
|
|
254
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
255
|
+
) -> None:
|
|
256
|
+
source_file = tmp_path / "out.png"
|
|
257
|
+
source_file.write_bytes(b"binary")
|
|
258
|
+
recorded_calls: list[list[str]] = []
|
|
259
|
+
monkeypatch.setattr(shutil, "which", lambda _name: None)
|
|
260
|
+
monkeypatch.setattr(subprocess, "run", _make_gh_stub(recorded_calls, view_return_code=0))
|
|
261
|
+
|
|
262
|
+
with pytest.raises(mod.ArtifactUploadError, match="oxipng"):
|
|
263
|
+
mod.upload_artifact(str(source_file), "owner/repo")
|
|
264
|
+
|
|
265
|
+
assert not any("upload" in call for call in recorded_calls)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def test_optimize_png_losslessly_leaves_other_formats_alone(
|
|
269
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
270
|
+
) -> None:
|
|
271
|
+
source_file = tmp_path / "trace.zip"
|
|
272
|
+
source_file.write_bytes(b"PK archive")
|
|
273
|
+
recorded_calls: list[list[str]] = []
|
|
274
|
+
monkeypatch.setattr(shutil, "which", lambda _name: None)
|
|
275
|
+
monkeypatch.setattr(subprocess, "run", _make_gh_stub(recorded_calls, view_return_code=0))
|
|
276
|
+
|
|
277
|
+
mod.optimize_png_losslessly(source_file)
|
|
278
|
+
|
|
279
|
+
assert source_file.read_bytes() == b"PK archive"
|
|
280
|
+
assert recorded_calls == []
|
|
@@ -21,8 +21,8 @@ from repository_checks.tracked_secrets import collect_tracked_secret_findings
|
|
|
21
21
|
CONTACT = "fixture-contact@company.io"
|
|
22
22
|
CHANGED_CONTACT = "changed-contact@company.io"
|
|
23
23
|
DIGEST = hashlib.sha256(CONTACT.encode()).hexdigest()
|
|
24
|
-
RUNNER_ADDRESS = "10.
|
|
25
|
-
CHANGED_RUNNER_ADDRESS = "10.
|
|
24
|
+
RUNNER_ADDRESS = "10.0.0.14"
|
|
25
|
+
CHANGED_RUNNER_ADDRESS = "10.0.0.15"
|
|
26
26
|
RUNNER_ADDRESS_DIGEST = hashlib.sha256(RUNNER_ADDRESS.encode()).hexdigest()
|
|
27
27
|
|
|
28
28
|
|