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.
- 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/everything-search/SKILL.md +18 -3
- package/.agents/skills/everything-search/scripts/everything_search.py +204 -0
- package/.agents/skills/everything-search/scripts/everything_search_command_constants/__init__.py +1 -0
- package/.agents/skills/everything-search/scripts/everything_search_command_constants/config/__init__.py +1 -0
- package/.agents/skills/everything-search/scripts/everything_search_command_constants/config/constants.py +11 -0
- package/.agents/skills/everything-search/scripts/test_everything_search.py +172 -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 +149 -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 +5 -16
- 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 +7 -20
- 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/filesystem-search.md +5 -5
- 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/es_exe_path_rewriter.py +0 -159
- 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_es_exe_path_rewriter.py +0 -447
- 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/path_rewriter_constants.py +0 -13
- package/hooks/hooks_constants/pr_description_writer_gate_constants.py +0 -73
- package/hooks/hooks_constants/test_path_rewriter_constants.py +0 -57
- 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,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
|