claude-dev-env 4.1.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.
Files changed (89) hide show
  1. package/.agents/skills/_shared/pr-loop/scripts/durable_post_lint.py +242 -0
  2. package/.agents/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/durable_post_lint_constants.py +106 -0
  3. package/.agents/skills/_shared/pr-loop/scripts/test_durable_post_lint.py +332 -0
  4. package/.agents/skills/_shared/pr-loop/scripts/test_durable_post_lint_constants.py +25 -0
  5. package/.agents/skills/everything-search/SKILL.md +18 -3
  6. package/.agents/skills/everything-search/scripts/everything_search.py +204 -0
  7. package/.agents/skills/everything-search/scripts/everything_search_command_constants/__init__.py +1 -0
  8. package/.agents/skills/everything-search/scripts/everything_search_command_constants/config/__init__.py +1 -0
  9. package/.agents/skills/everything-search/scripts/everything_search_command_constants/config/constants.py +11 -0
  10. package/.agents/skills/everything-search/scripts/test_everything_search.py +172 -0
  11. package/.agents/skills/issue-tracker/SKILL.md +6 -0
  12. package/.agents/skills/pull-request/SKILL.md +218 -0
  13. package/.agents/skills/pull-request/reference/publication-tasks.md +18 -0
  14. package/.agents/skills/pull-request/scripts/github_pr_command_constants/__init__.py +1 -0
  15. package/.agents/skills/pull-request/scripts/github_pr_command_constants/config/__init__.py +1 -0
  16. package/.agents/skills/pull-request/scripts/github_pr_command_constants/config/constants.py +61 -0
  17. package/.agents/skills/pull-request/scripts/pull_request.py +276 -0
  18. package/.agents/skills/pull-request/scripts/recover_legacy_author.py +251 -0
  19. package/.agents/skills/pull-request/scripts/test_github_pr_command_constants.py +49 -0
  20. package/.agents/skills/pull-request/scripts/test_pull_request.py +398 -0
  21. package/.agents/skills/pull-request/scripts/test_recover_legacy_author.py +221 -0
  22. package/.agents/skills/test-runner/SKILL.md +69 -0
  23. package/.agents/skills/test-runner/scripts/preflight_checks.py +365 -0
  24. package/.agents/skills/test-runner/scripts/run_tests.py +316 -0
  25. package/.agents/skills/test-runner/scripts/test_preflight_checks.py +185 -0
  26. package/.agents/skills/test-runner/scripts/test_run_tests.py +281 -0
  27. package/.agents/skills/test-runner/scripts/test_runner_constants/__init__.py +1 -0
  28. package/.agents/skills/test-runner/scripts/test_runner_constants/config/__init__.py +1 -0
  29. package/.agents/skills/test-runner/scripts/test_runner_constants/config/constants.py +96 -0
  30. package/.agents/skills-archived/pr-loop-cloud-transport/reference/identity-and-hooks.md +1 -1
  31. package/bin/ever-shipped-skills.mjs +2 -0
  32. package/bin/ever-shipped-skills.test.mjs +8 -0
  33. package/bin/install.mjs +9 -1
  34. package/bin/install.prune.test.mjs +149 -0
  35. package/bin/install.test.mjs +24 -7
  36. package/hooks/blocking/pii_prevention_blocker.py +32 -51
  37. package/hooks/blocking/test_bash_post_call_dispatcher.py +5 -28
  38. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -4
  39. package/hooks/blocking/test_code_rules_command_dispatch.py +2 -2
  40. package/hooks/blocking/test_pii_prevention_blocker.py +37 -89
  41. package/hooks/gh_post_body_texts.py +195 -0
  42. package/hooks/hooks.json +0 -15
  43. package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +0 -1
  44. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +5 -16
  45. package/hooks/hooks_constants/gh_post_body_texts_constants.py +12 -0
  46. package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +0 -7
  47. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +7 -20
  48. package/hooks/hooks_constants/test_failure_recorder_constants.py +3 -3
  49. package/hooks/hooks_constants/test_gh_post_body_texts_constants.py +19 -0
  50. package/hooks/observability/test_failure_recorder.py +1 -1
  51. package/hooks/pyproject.toml +1 -1
  52. package/hooks/test_gh_post_body_texts.py +79 -0
  53. package/package.json +1 -1
  54. package/rules/durable-post-artifacts.md +31 -20
  55. package/rules/filesystem-search.md +5 -5
  56. package/rules/gh-cli-conventions.md +23 -13
  57. package/rules/git-workflow.md +21 -8
  58. package/scripts/policy_lint/adapter_configuration.py +53 -12
  59. package/scripts/repository_checks/config/constants.py +0 -5
  60. package/scripts/tests/test_adapter_configuration.py +54 -0
  61. package/hooks/_gh_pr_author_swap_utils.py +0 -1225
  62. package/hooks/blocking/conventional_pr_title_gate.py +0 -443
  63. package/hooks/blocking/es_exe_path_rewriter.py +0 -159
  64. package/hooks/blocking/gh_body_arg_blocker.py +0 -176
  65. package/hooks/blocking/gh_pr_author_enforcer.py +0 -487
  66. package/hooks/blocking/gh_pr_author_restore.py +0 -105
  67. package/hooks/blocking/pr_description_writer_gate.py +0 -256
  68. package/hooks/blocking/test_conventional_pr_title_gate.py +0 -640
  69. package/hooks/blocking/test_es_exe_path_rewriter.py +0 -447
  70. package/hooks/blocking/test_gh_body_arg_blocker.py +0 -441
  71. package/hooks/blocking/test_gh_pr_author_enforcer.py +0 -1166
  72. package/hooks/blocking/test_gh_pr_author_restore.py +0 -513
  73. package/hooks/blocking/test_gh_pr_author_swap_utils.py +0 -910
  74. package/hooks/blocking/test_pr_description_writer_gate.py +0 -234
  75. package/hooks/blocking/test_preflight_check.py +0 -343
  76. package/hooks/blocking/test_test_preflight_check.py +0 -80
  77. package/hooks/blocking/test_volatile_path_in_post_blocker.py +0 -322
  78. package/hooks/blocking/volatile_path_in_post_blocker.py +0 -461
  79. package/hooks/hooks_constants/conventional_pr_title_gate_constants.py +0 -58
  80. package/hooks/hooks_constants/gh_pr_author_swap_constants.py +0 -76
  81. package/hooks/hooks_constants/path_rewriter_constants.py +0 -13
  82. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +0 -73
  83. package/hooks/hooks_constants/test_path_rewriter_constants.py +0 -57
  84. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +0 -55
  85. package/hooks/observability/pr_description_writer_spawn_tracker.py +0 -113
  86. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +0 -150
  87. package/hooks/session/gh_pr_author_session_cleanup.py +0 -175
  88. package/hooks/session/test_gh_pr_author_session_cleanup.py +0 -576
  89. package/hooks/test__gh_pr_author_swap_utils.py +0 -333
@@ -0,0 +1,18 @@
1
+ # Pull request publication tasks
2
+
3
+ Register each line as one session task before starting the process. Complete
4
+ each task with `PASS`, `FAIL` plus file and line evidence, or `N/A` plus a
5
+ reason when the task is conditional. This file is a task seed catalog, not a
6
+ markdown progress board.
7
+
8
+ 1. Resolve one repository, action, and selected author. For create, resolve the source branch, base, and head. For other actions, resolve one existing pull request target. Create actions publish drafts.
9
+ 2. Invoke the required `pr-description-writer` for create or full body rewrite, then record its title and body file. `pr-title-description` may assist. Mark `N/A` for comment or review bodies that do not rewrite the pull request description.
10
+ 3. Validate every title, body, artifact, and local path input. Reject inline body input.
11
+ 4. Run `_shared/pr-loop/scripts/durable_post_lint.py` for the matching action and record exit code `0` before credential lookup or network work.
12
+ 5. Run `privacy-hygiene` before the GitHub post and record its clean result.
13
+ 6. Upload binary evidence through `scripts/gh_artifact_upload.py`, replace local references with permanent URLs, and rerun the shared body linter. Mark `N/A` when no binary evidence exists.
14
+ 7. Resolve the selected author in the child process environment and prove that the parent environment and global `gh` account remain unchanged.
15
+ 8. Run `.agents/skills/pull-request/scripts/pull_request.py` for exactly one action: `create`, `edit`, `comment`, or `review`.
16
+ 9. When the user selects a legacy state file, run `recover_legacy_author.py <exact-state-file> --confirm-inactive` for exactly that record. Preserve every other record and delete the selected record only after successful restore. Mark `N/A` otherwise.
17
+ 10. Read back the remote pull request title, body, head SHA, draft state, and requested comment or review, then record the URL and number without credentials.
18
+ 11. Mark every session task complete with evidence and report the action, remote URL, readback result, and any remaining blocker.
@@ -0,0 +1 @@
1
+ """Constants for the explicit GitHub pull request command."""
@@ -0,0 +1 @@
1
+ """Configuration constants for the explicit GitHub pull request command."""
@@ -0,0 +1,61 @@
1
+ """Configuration constants for process-local GitHub pull request actions."""
2
+
3
+ import re
4
+
5
+ ACTION_COMMENT = "comment"
6
+ ACTION_CREATE = "create"
7
+ ACTION_EDIT = "edit"
8
+ ACTION_REVIEW = "review"
9
+
10
+ LINTER_ACTION_COMMENT = "pr-comment"
11
+ LINTER_ACTION_CREATE = "pr-create"
12
+ LINTER_ACTION_EDIT = "pr-edit"
13
+ LINTER_ACTION_REVIEW = "pr-review"
14
+
15
+ REVIEW_EVENT_APPROVE = "approve"
16
+ REVIEW_EVENT_COMMENT = "comment"
17
+ REVIEW_EVENT_REQUEST_CHANGES = "request-changes"
18
+ ALL_REVIEW_FLAGS_BY_EVENT: dict[str, str] = {
19
+ REVIEW_EVENT_APPROVE: "--approve",
20
+ REVIEW_EVENT_COMMENT: "--comment",
21
+ REVIEW_EVENT_REQUEST_CHANGES: "--request-changes",
22
+ }
23
+
24
+ ALL_LINTER_ACTIONS_BY_COMMAND: dict[str, str] = {
25
+ ACTION_COMMENT: LINTER_ACTION_COMMENT,
26
+ ACTION_CREATE: LINTER_ACTION_CREATE,
27
+ ACTION_EDIT: LINTER_ACTION_EDIT,
28
+ ACTION_REVIEW: LINTER_ACTION_REVIEW,
29
+ }
30
+
31
+ SELECTED_ACCOUNT_ENVIRONMENT_KEY = "GITHUB_DEFAULT_ACCOUNT"
32
+
33
+ ACCOUNT_LOOKUP_FAILED_MESSAGE = "error: GitHub account lookup failed\n"
34
+ ACCOUNT_LOOKUP_EMPTY_MESSAGE = "error: GitHub account lookup returned no value\n"
35
+ ACTION_FAILED_MESSAGE = "error: GitHub action failed\n"
36
+ ACCOUNT_LOOKUP_FAILURE_EXIT_CODE = 1
37
+ PACKAGE_ROOT_PARENT_INDEX = 4
38
+ SKILLS_DIRECTORY_PARENT_INDEX = 2
39
+
40
+ ALL_GH_AUTH_SWITCH_COMMAND_HEAD: tuple[str, ...] = (
41
+ "gh",
42
+ "auth",
43
+ "switch",
44
+ "--user",
45
+ )
46
+ LEGACY_RECORD_ACTIVE_MESSAGE = "error: selected legacy state record is active\n"
47
+ LEGACY_RECORD_CHANGED_MESSAGE = "error: selected legacy state record changed\n"
48
+ LEGACY_RECORD_CONFIRMATION_MESSAGE = "error: confirm inactive legacy state\n"
49
+ LEGACY_RECORD_REJECTED_MESSAGE = "error: selected legacy state record is invalid\n"
50
+ LEGACY_RESTORE_FAILED_MESSAGE = "error: GitHub account restore failed\n"
51
+ LEGACY_RECORD_INACTIVE_CONFIRMATION_FLAG = "--confirm-inactive"
52
+ LEGACY_RECORD_ORIGINAL_ACCOUNT_KEY = "original_account"
53
+ LEGACY_RECORD_PERMISSION_MODE = 0o600
54
+ LEGACY_RECORD_STALE_AGE_SECONDS = 1800
55
+ LEGACY_RECORD_NAME_PATTERN: re.Pattern[str] = re.compile(
56
+ r"^gh_pr_author_swap_[A-Za-z0-9_-]+\.json$"
57
+ )
58
+ RECOVERY_CLEAN_EXIT_CODE = 0
59
+ RECOVERY_FAILED_EXIT_CODE = 1
60
+ RECOVERY_REJECTED_EXIT_CODE = 2
61
+ RECOVERY_UNRESOLVED_EXIT_CODE = 3
@@ -0,0 +1,276 @@
1
+ """Run validated GitHub pull request actions with process-local account selection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import importlib
7
+ import os
8
+ import subprocess
9
+ import sys
10
+ from collections.abc import Callable, Mapping, Sequence
11
+ from pathlib import Path
12
+
13
+ from github_pr_command_constants.config.constants import (
14
+ ACCOUNT_LOOKUP_EMPTY_MESSAGE,
15
+ ACCOUNT_LOOKUP_FAILED_MESSAGE,
16
+ ACCOUNT_LOOKUP_FAILURE_EXIT_CODE,
17
+ ACTION_COMMENT,
18
+ ACTION_CREATE,
19
+ ACTION_EDIT,
20
+ ACTION_FAILED_MESSAGE,
21
+ ACTION_REVIEW,
22
+ ALL_LINTER_ACTIONS_BY_COMMAND,
23
+ ALL_REVIEW_FLAGS_BY_EVENT,
24
+ PACKAGE_ROOT_PARENT_INDEX,
25
+ REVIEW_EVENT_COMMENT,
26
+ SELECTED_ACCOUNT_ENVIRONMENT_KEY,
27
+ SKILLS_DIRECTORY_PARENT_INDEX,
28
+ )
29
+
30
+ CommandRunner = Callable[..., subprocess.CompletedProcess[str]]
31
+
32
+
33
+ def _build_parser() -> argparse.ArgumentParser:
34
+ parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
35
+ all_actions = parser.add_subparsers(dest="action", required=True)
36
+ create = all_actions.add_parser(ACTION_CREATE, allow_abbrev=False)
37
+ create.add_argument("--repo", required=True)
38
+ create.add_argument("--base", required=True)
39
+ create.add_argument("--head", required=True)
40
+ create.add_argument("--title", required=True)
41
+ create.add_argument("--body-file", required=True, type=Path)
42
+ edit = all_actions.add_parser(ACTION_EDIT, allow_abbrev=False)
43
+ edit.add_argument("--repo", required=True)
44
+ edit.add_argument("--number", required=True)
45
+ edit.add_argument("--title")
46
+ edit.add_argument("--body-file", type=Path)
47
+ comment = all_actions.add_parser(ACTION_COMMENT, allow_abbrev=False)
48
+ comment.add_argument("--repo", required=True)
49
+ comment.add_argument("--number", required=True)
50
+ comment.add_argument("--body-file", required=True, type=Path)
51
+ review = all_actions.add_parser(ACTION_REVIEW, allow_abbrev=False)
52
+ review.add_argument("--repo", required=True)
53
+ review.add_argument("--number", required=True)
54
+ review.add_argument("--body-file", required=True, type=Path)
55
+ review.add_argument(
56
+ "--event",
57
+ choices=tuple(ALL_REVIEW_FLAGS_BY_EVENT),
58
+ default=REVIEW_EVENT_COMMENT,
59
+ )
60
+ return parser
61
+
62
+
63
+ def _linter_path() -> Path:
64
+ return (
65
+ Path(__file__).resolve().parents[SKILLS_DIRECTORY_PARENT_INDEX]
66
+ / "_shared/pr-loop/scripts/durable_post_lint.py"
67
+ )
68
+
69
+
70
+ def _linter_arguments(arguments: argparse.Namespace) -> list[str]:
71
+ all_arguments = [
72
+ sys.executable,
73
+ str(_linter_path()),
74
+ "--action",
75
+ ALL_LINTER_ACTIONS_BY_COMMAND[arguments.action],
76
+ ]
77
+ if getattr(arguments, "title", None) is not None:
78
+ all_arguments.extend(["--title", arguments.title])
79
+ if getattr(arguments, "body_file", None) is not None:
80
+ all_arguments.extend(["--body-file", str(arguments.body_file)])
81
+ return all_arguments
82
+
83
+
84
+ def _create_arguments(arguments: argparse.Namespace) -> list[str]:
85
+ return [
86
+ "gh",
87
+ "pr",
88
+ "create",
89
+ "--draft",
90
+ "--repo",
91
+ arguments.repo,
92
+ "--base",
93
+ arguments.base,
94
+ "--head",
95
+ arguments.head,
96
+ "--title",
97
+ arguments.title,
98
+ "--body-file",
99
+ str(arguments.body_file),
100
+ ]
101
+
102
+
103
+ def _edit_arguments(arguments: argparse.Namespace) -> list[str]:
104
+ all_arguments = ["gh", "pr", "edit", arguments.number, "--repo", arguments.repo]
105
+ if arguments.title is not None:
106
+ all_arguments.extend(["--title", arguments.title])
107
+ if arguments.body_file is not None:
108
+ all_arguments.extend(["--body-file", str(arguments.body_file)])
109
+ return all_arguments
110
+
111
+
112
+ def _post_arguments(arguments: argparse.Namespace) -> list[str]:
113
+ if arguments.action == ACTION_CREATE:
114
+ return _create_arguments(arguments)
115
+ if arguments.action == ACTION_EDIT:
116
+ return _edit_arguments(arguments)
117
+ if arguments.action == ACTION_COMMENT:
118
+ return [
119
+ "gh",
120
+ "pr",
121
+ "comment",
122
+ arguments.number,
123
+ "--repo",
124
+ arguments.repo,
125
+ "--body-file",
126
+ str(arguments.body_file),
127
+ ]
128
+ return [
129
+ "gh",
130
+ "pr",
131
+ "review",
132
+ arguments.number,
133
+ "--repo",
134
+ arguments.repo,
135
+ ALL_REVIEW_FLAGS_BY_EVENT[arguments.event],
136
+ "--body-file",
137
+ str(arguments.body_file),
138
+ ]
139
+
140
+
141
+ def _shared_environment_key_names() -> tuple[str, str, tuple[str, ...]]:
142
+ shared_directory = (
143
+ Path(__file__).resolve().parents[PACKAGE_ROOT_PARENT_INDEX]
144
+ / "_shared/pr-loop/scripts"
145
+ )
146
+ shared_directory_text = str(shared_directory)
147
+ if shared_directory_text not in sys.path:
148
+ sys.path.insert(0, shared_directory_text)
149
+ constants_module = importlib.import_module(
150
+ "pr_loop_shared_constants.post_audit_thread_constants"
151
+ )
152
+ return (
153
+ constants_module.GITHUB_TOKEN_ENV_VAR_NAME,
154
+ constants_module.GH_TOKEN_ENV_VAR_NAME,
155
+ constants_module.ALL_GH_TOKEN_ENV_VAR_NAMES,
156
+ )
157
+
158
+
159
+ def _lookup_environment(all_environment: Mapping[str, str]) -> dict[str, str]:
160
+ lookup_environment = dict(all_environment)
161
+ _, _, all_authorization_names = _shared_environment_key_names()
162
+ for each_name in all_authorization_names:
163
+ lookup_environment.pop(each_name, None)
164
+ return lookup_environment
165
+
166
+
167
+ def _lookup_account_authorization(
168
+ selected_account: str,
169
+ all_environment: Mapping[str, str],
170
+ command_runner: CommandRunner,
171
+ ) -> str | None:
172
+ try:
173
+ completed_lookup = command_runner(
174
+ ["gh", "auth", "token", "--user", selected_account],
175
+ capture_output=True,
176
+ check=False,
177
+ env=_lookup_environment(all_environment),
178
+ shell=False,
179
+ text=True,
180
+ )
181
+ except OSError:
182
+ sys.stderr.write(ACCOUNT_LOOKUP_FAILED_MESSAGE)
183
+ return None
184
+ if completed_lookup.returncode != 0:
185
+ sys.stderr.write(ACCOUNT_LOOKUP_FAILED_MESSAGE)
186
+ return None
187
+ account_authorization = completed_lookup.stdout.strip()
188
+ if not account_authorization:
189
+ sys.stderr.write(ACCOUNT_LOOKUP_EMPTY_MESSAGE)
190
+ return None
191
+ return account_authorization
192
+
193
+
194
+ def _action_environment(
195
+ all_environment: Mapping[str, str],
196
+ command_runner: CommandRunner,
197
+ ) -> dict[str, str] | None:
198
+ selected_account = all_environment.get(SELECTED_ACCOUNT_ENVIRONMENT_KEY)
199
+ if not selected_account:
200
+ return dict(all_environment)
201
+ account_authorization = _lookup_account_authorization(
202
+ selected_account, all_environment, command_runner
203
+ )
204
+ if account_authorization is None:
205
+ return None
206
+ parent_authorization_name, child_authorization_name, _ = (
207
+ _shared_environment_key_names()
208
+ )
209
+ action_environment = dict(all_environment)
210
+ action_environment.pop(parent_authorization_name, None)
211
+ action_environment[child_authorization_name] = account_authorization
212
+ return action_environment
213
+
214
+
215
+ def _run_post_action(
216
+ arguments: argparse.Namespace,
217
+ all_action_environment: Mapping[str, str],
218
+ command_runner: CommandRunner,
219
+ ) -> int:
220
+ try:
221
+ completed_action = command_runner(
222
+ _post_arguments(arguments),
223
+ check=False,
224
+ env=all_action_environment,
225
+ shell=False,
226
+ )
227
+ except OSError:
228
+ sys.stderr.write(ACTION_FAILED_MESSAGE)
229
+ return ACCOUNT_LOOKUP_FAILURE_EXIT_CODE
230
+ return completed_action.returncode
231
+
232
+
233
+ def _run_action(
234
+ arguments: argparse.Namespace,
235
+ all_environment: Mapping[str, str],
236
+ command_runner: CommandRunner,
237
+ ) -> int:
238
+ action_environment = _action_environment(all_environment, command_runner)
239
+ if action_environment is None:
240
+ return ACCOUNT_LOOKUP_FAILURE_EXIT_CODE
241
+ return _run_post_action(arguments, action_environment, command_runner)
242
+
243
+
244
+ def main(
245
+ all_arguments: Sequence[str],
246
+ *,
247
+ all_environment: Mapping[str, str],
248
+ command_runner: CommandRunner,
249
+ ) -> int:
250
+ """Run one validated pull request action.
251
+
252
+ Args:
253
+ all_arguments: Command arguments after the script name.
254
+ all_environment: Parent environment copied for child processes.
255
+ command_runner: Process runner used by tests and production.
256
+
257
+ Returns:
258
+ The linter, account lookup, or GitHub action exit status.
259
+ """
260
+ arguments = _build_parser().parse_args(list(all_arguments))
261
+ completed_lint = command_runner(
262
+ _linter_arguments(arguments), check=False, shell=False
263
+ )
264
+ if completed_lint.returncode != 0:
265
+ return completed_lint.returncode
266
+ return _run_action(arguments, all_environment, command_runner)
267
+
268
+
269
+ if __name__ == "__main__":
270
+ raise SystemExit(
271
+ main(
272
+ sys.argv[1:],
273
+ all_environment=os.environ,
274
+ command_runner=subprocess.run,
275
+ )
276
+ )
@@ -0,0 +1,251 @@
1
+ """Restore one explicitly selected legacy GitHub author-swap record."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import stat
9
+ import subprocess
10
+ import sys
11
+ import time
12
+ from collections.abc import Callable, Sequence
13
+ from pathlib import Path
14
+
15
+ from github_pr_command_constants.config.constants import (
16
+ ALL_GH_AUTH_SWITCH_COMMAND_HEAD,
17
+ LEGACY_RECORD_ACTIVE_MESSAGE,
18
+ LEGACY_RECORD_CHANGED_MESSAGE,
19
+ LEGACY_RECORD_CONFIRMATION_MESSAGE,
20
+ LEGACY_RECORD_INACTIVE_CONFIRMATION_FLAG,
21
+ LEGACY_RECORD_NAME_PATTERN,
22
+ LEGACY_RECORD_ORIGINAL_ACCOUNT_KEY,
23
+ LEGACY_RECORD_PERMISSION_MODE,
24
+ LEGACY_RECORD_REJECTED_MESSAGE,
25
+ LEGACY_RECORD_STALE_AGE_SECONDS,
26
+ LEGACY_RESTORE_FAILED_MESSAGE,
27
+ RECOVERY_CLEAN_EXIT_CODE,
28
+ RECOVERY_FAILED_EXIT_CODE,
29
+ RECOVERY_REJECTED_EXIT_CODE,
30
+ RECOVERY_UNRESOLVED_EXIT_CODE,
31
+ )
32
+
33
+ CommandRunner = Callable[..., subprocess.CompletedProcess[str]]
34
+ FileFingerprint = tuple[int, int, int, int, int]
35
+ VerifiedRecord = tuple[str, FileFingerprint]
36
+
37
+
38
+ class LegacyRecordRejected(ValueError):
39
+ """Report an invalid legacy state record."""
40
+
41
+
42
+ class LegacyRecordActive(ValueError):
43
+ """Report a legacy state record that is not old enough for recovery."""
44
+
45
+
46
+ class LegacyRecordChanged(ValueError):
47
+ """Report a legacy state record that changed during recovery."""
48
+
49
+
50
+ def _build_parser() -> argparse.ArgumentParser:
51
+ parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
52
+ parser.add_argument("state_file", type=Path)
53
+ parser.add_argument(LEGACY_RECORD_INACTIVE_CONFIRMATION_FLAG, action="store_true")
54
+ return parser
55
+
56
+
57
+ def _posix_metadata_is_secure(
58
+ file_mode: int,
59
+ file_owner_id: int,
60
+ current_user_id: int | None,
61
+ ) -> bool:
62
+ if not stat.S_ISREG(file_mode):
63
+ return False
64
+ if current_user_id is None:
65
+ return True
66
+ return (
67
+ stat.S_IMODE(file_mode) == LEGACY_RECORD_PERMISSION_MODE
68
+ and file_owner_id == current_user_id
69
+ )
70
+
71
+
72
+ def _secure_metadata(
73
+ state_file: Path,
74
+ current_user_id: int | None,
75
+ ) -> os.stat_result:
76
+ try:
77
+ file_metadata = state_file.lstat()
78
+ except OSError as error:
79
+ raise LegacyRecordRejected from error
80
+ if not _posix_metadata_is_secure(
81
+ file_metadata.st_mode,
82
+ file_metadata.st_uid,
83
+ current_user_id,
84
+ ):
85
+ raise LegacyRecordRejected
86
+ return file_metadata
87
+
88
+
89
+ def _fingerprint(file_metadata: os.stat_result) -> FileFingerprint:
90
+ return (
91
+ file_metadata.st_dev,
92
+ file_metadata.st_ino,
93
+ file_metadata.st_mode,
94
+ file_metadata.st_size,
95
+ file_metadata.st_mtime_ns,
96
+ )
97
+
98
+
99
+ def _parse_original_account(record_text: str) -> str:
100
+ try:
101
+ decoded_record = json.loads(record_text)
102
+ except json.JSONDecodeError as error:
103
+ raise LegacyRecordRejected from error
104
+ if not isinstance(decoded_record, dict):
105
+ raise LegacyRecordRejected
106
+ if set(decoded_record) != {LEGACY_RECORD_ORIGINAL_ACCOUNT_KEY}:
107
+ raise LegacyRecordRejected
108
+ original_account = decoded_record[LEGACY_RECORD_ORIGINAL_ACCOUNT_KEY]
109
+ if not isinstance(original_account, str) or not original_account.strip():
110
+ raise LegacyRecordRejected
111
+ return original_account.strip()
112
+
113
+
114
+ def _read_verified_record(
115
+ state_file: Path,
116
+ now_seconds: float,
117
+ current_user_id: int | None,
118
+ ) -> VerifiedRecord:
119
+ if LEGACY_RECORD_NAME_PATTERN.fullmatch(state_file.name) is None:
120
+ raise LegacyRecordRejected
121
+ initial_metadata = _secure_metadata(state_file, current_user_id)
122
+ if now_seconds - initial_metadata.st_mtime < LEGACY_RECORD_STALE_AGE_SECONDS:
123
+ raise LegacyRecordActive
124
+ try:
125
+ record_text = state_file.read_text(encoding="utf-8")
126
+ except (OSError, UnicodeError) as error:
127
+ raise LegacyRecordRejected from error
128
+ original_account = _parse_original_account(record_text)
129
+ initial_fingerprint = _fingerprint(initial_metadata)
130
+ verified_metadata = _secure_metadata(state_file, current_user_id)
131
+ if _fingerprint(verified_metadata) != initial_fingerprint:
132
+ raise LegacyRecordChanged
133
+ return original_account, initial_fingerprint
134
+
135
+
136
+ def _delete_unchanged_record(
137
+ state_file: Path,
138
+ expected_fingerprint: FileFingerprint,
139
+ current_user_id: int | None,
140
+ ) -> None:
141
+ current_metadata = _secure_metadata(state_file, current_user_id)
142
+ if _fingerprint(current_metadata) != expected_fingerprint:
143
+ raise LegacyRecordChanged
144
+ try:
145
+ state_file.unlink()
146
+ except OSError as error:
147
+ raise LegacyRecordChanged from error
148
+
149
+
150
+ def _load_record_with_diagnostics(
151
+ state_file: Path,
152
+ now_seconds: float,
153
+ current_user_id: int | None,
154
+ ) -> tuple[VerifiedRecord | None, int]:
155
+ try:
156
+ return _read_verified_record(state_file, now_seconds, current_user_id), 0
157
+ except LegacyRecordActive:
158
+ sys.stderr.write(LEGACY_RECORD_ACTIVE_MESSAGE)
159
+ return None, RECOVERY_REJECTED_EXIT_CODE
160
+ except LegacyRecordChanged:
161
+ sys.stderr.write(LEGACY_RECORD_CHANGED_MESSAGE)
162
+ return None, RECOVERY_FAILED_EXIT_CODE
163
+ except LegacyRecordRejected:
164
+ sys.stderr.write(LEGACY_RECORD_REJECTED_MESSAGE)
165
+ return None, RECOVERY_REJECTED_EXIT_CODE
166
+
167
+
168
+ def _restore_and_delete(
169
+ state_file: Path,
170
+ verified_record: VerifiedRecord,
171
+ current_user_id: int | None,
172
+ command_runner: CommandRunner,
173
+ ) -> int:
174
+ original_account, expected_fingerprint = verified_record
175
+ try:
176
+ completed_restore = command_runner(
177
+ [*ALL_GH_AUTH_SWITCH_COMMAND_HEAD, original_account],
178
+ capture_output=True,
179
+ check=False,
180
+ shell=False,
181
+ text=True,
182
+ )
183
+ except OSError:
184
+ sys.stderr.write(LEGACY_RESTORE_FAILED_MESSAGE)
185
+ return RECOVERY_FAILED_EXIT_CODE
186
+ if completed_restore.returncode != 0:
187
+ sys.stderr.write(LEGACY_RESTORE_FAILED_MESSAGE)
188
+ return RECOVERY_FAILED_EXIT_CODE
189
+ try:
190
+ _delete_unchanged_record(state_file, expected_fingerprint, current_user_id)
191
+ except (LegacyRecordChanged, LegacyRecordRejected):
192
+ sys.stderr.write(LEGACY_RECORD_CHANGED_MESSAGE)
193
+ return RECOVERY_FAILED_EXIT_CODE
194
+ return RECOVERY_CLEAN_EXIT_CODE
195
+
196
+
197
+ def _recover_selected_record(
198
+ state_file: Path,
199
+ now_seconds: float,
200
+ current_user_id: int | None,
201
+ command_runner: CommandRunner,
202
+ ) -> int:
203
+ verified_record, exit_code = _load_record_with_diagnostics(
204
+ state_file, now_seconds, current_user_id
205
+ )
206
+ if verified_record is None:
207
+ return exit_code
208
+ return _restore_and_delete(
209
+ state_file, verified_record, current_user_id, command_runner
210
+ )
211
+
212
+
213
+ def main(
214
+ all_arguments: Sequence[str],
215
+ *,
216
+ now_seconds: float,
217
+ current_user_id: int | None,
218
+ command_runner: CommandRunner,
219
+ ) -> int:
220
+ """Recover one confirmed-inactive legacy state record.
221
+
222
+ Args:
223
+ all_arguments: Command arguments after the script name.
224
+ now_seconds: Current wall-clock time in seconds.
225
+ current_user_id: Current POSIX user identifier, or None on Windows.
226
+ command_runner: Process runner used by tests and production.
227
+
228
+ Returns:
229
+ Zero on success, one on failure, two on rejection, or three when unresolved.
230
+ """
231
+ arguments = _build_parser().parse_args(list(all_arguments))
232
+ if not arguments.confirm_inactive:
233
+ sys.stderr.write(LEGACY_RECORD_CONFIRMATION_MESSAGE)
234
+ return RECOVERY_UNRESOLVED_EXIT_CODE
235
+ return _recover_selected_record(
236
+ arguments.state_file,
237
+ now_seconds,
238
+ current_user_id,
239
+ command_runner,
240
+ )
241
+
242
+
243
+ if __name__ == "__main__":
244
+ raise SystemExit(
245
+ main(
246
+ sys.argv[1:],
247
+ now_seconds=time.time(),
248
+ current_user_id=os.getuid() if hasattr(os, "getuid") else None,
249
+ command_runner=subprocess.run,
250
+ )
251
+ )
@@ -0,0 +1,49 @@
1
+ """Tests for explicit GitHub pull request command constants."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ _SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
7
+ if str(_SCRIPTS_DIRECTORY) not in sys.path:
8
+ sys.path.insert(0, str(_SCRIPTS_DIRECTORY))
9
+
10
+ from github_pr_command_constants.config import constants
11
+
12
+
13
+ def test_action_and_linter_mappings_are_complete() -> None:
14
+ assert constants.ALL_LINTER_ACTIONS_BY_COMMAND == {
15
+ constants.ACTION_COMMENT: constants.LINTER_ACTION_COMMENT,
16
+ constants.ACTION_CREATE: constants.LINTER_ACTION_CREATE,
17
+ constants.ACTION_EDIT: constants.LINTER_ACTION_EDIT,
18
+ constants.ACTION_REVIEW: constants.LINTER_ACTION_REVIEW,
19
+ }
20
+
21
+
22
+ def test_review_event_flags_are_complete() -> None:
23
+ assert constants.ALL_REVIEW_FLAGS_BY_EVENT == {
24
+ constants.REVIEW_EVENT_APPROVE: "--approve",
25
+ constants.REVIEW_EVENT_COMMENT: "--comment",
26
+ constants.REVIEW_EVENT_REQUEST_CHANGES: "--request-changes",
27
+ }
28
+
29
+
30
+ def test_account_boundary_constants_are_safe_and_stable() -> None:
31
+ assert constants.SELECTED_ACCOUNT_ENVIRONMENT_KEY == "GITHUB_DEFAULT_ACCOUNT"
32
+ assert constants.ACCOUNT_LOOKUP_FAILURE_EXIT_CODE == 1
33
+ assert constants.ACCOUNT_LOOKUP_FAILED_MESSAGE.endswith("lookup failed\n")
34
+ assert constants.ACCOUNT_LOOKUP_EMPTY_MESSAGE.endswith("returned no value\n")
35
+
36
+
37
+ def test_legacy_recovery_constants_define_one_record_boundary() -> None:
38
+ assert constants.ALL_GH_AUTH_SWITCH_COMMAND_HEAD == (
39
+ "gh",
40
+ "auth",
41
+ "switch",
42
+ "--user",
43
+ )
44
+ assert constants.LEGACY_RECORD_NAME_PATTERN.fullmatch(
45
+ "gh_pr_author_swap_session-a.json"
46
+ )
47
+ assert not constants.LEGACY_RECORD_NAME_PATTERN.fullmatch("other.json")
48
+ assert constants.LEGACY_RECORD_STALE_AGE_SECONDS == 1800
49
+ assert constants.RECOVERY_UNRESOLVED_EXIT_CODE == 3