autoforge-cli 0.1.0a0__py3-none-any.whl
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.
- autoforge/__init__.py +5 -0
- autoforge/agent_worktree.py +653 -0
- autoforge/certificate.py +495 -0
- autoforge/child_env.py +871 -0
- autoforge/claims.py +726 -0
- autoforge/claude_stream.py +501 -0
- autoforge/cli.py +2884 -0
- autoforge/config.py +2178 -0
- autoforge/config_fingerprint.py +374 -0
- autoforge/doctor.py +1551 -0
- autoforge/doctor_sandbox.py +691 -0
- autoforge/effect_ops.py +1272 -0
- autoforge/effects.py +3210 -0
- autoforge/engine.py +16637 -0
- autoforge/epic_commands.py +1486 -0
- autoforge/epic_graph.py +852 -0
- autoforge/errors.py +536 -0
- autoforge/evidence.py +1722 -0
- autoforge/executor.py +1601 -0
- autoforge/executor_duplex.py +985 -0
- autoforge/executor_supervised.py +144 -0
- autoforge/git_transport.py +970 -0
- autoforge/github.py +3402 -0
- autoforge/interaction.py +1027 -0
- autoforge/local_workspace.py +1253 -0
- autoforge/locking.py +1050 -0
- autoforge/loop_detect.py +660 -0
- autoforge/loop_guard.py +404 -0
- autoforge/materialize.py +722 -0
- autoforge/ownership.py +240 -0
- autoforge/pi_rpc.py +713 -0
- autoforge/premerge.py +181 -0
- autoforge/profiles.py +106 -0
- autoforge/progress.py +392 -0
- autoforge/prompts/__init__.py +173 -0
- autoforge/prompts/acceptance_actions.md +39 -0
- autoforge/prompts/analyze_execute.md +128 -0
- autoforge/prompts/common.md +132 -0
- autoforge/prompts/correction.md +12 -0
- autoforge/prompts/fix.md +252 -0
- autoforge/prompts/local_analyze_execute.md +69 -0
- autoforge/prompts/local_common.md +149 -0
- autoforge/prompts/local_fix.md +96 -0
- autoforge/prompts/local_review.md +103 -0
- autoforge/prompts/operator_acceptance.md +61 -0
- autoforge/prompts/replan_reexecute.md +991 -0
- autoforge/prompts/review.md +312 -0
- autoforge/prompts/update_epic.md +120 -0
- autoforge/prompts/update_epic_rerequest.md +85 -0
- autoforge/prompts/validation_evidence.md +26 -0
- autoforge/prompts/validation_failure.md +22 -0
- autoforge/prompts/worktree_state.md +45 -0
- autoforge/providers.py +2893 -0
- autoforge/read_context.py +389 -0
- autoforge/redaction.py +408 -0
- autoforge/replan.py +208 -0
- autoforge/replan_txn.py +1930 -0
- autoforge/result_parser.py +2234 -0
- autoforge/roadmap.py +151 -0
- autoforge/run_contract.py +408 -0
- autoforge/run_namespace.py +839 -0
- autoforge/runlog.py +764 -0
- autoforge/safefs.py +1717 -0
- autoforge/sandbox.py +1313 -0
- autoforge/sandbox_bwrap.py +815 -0
- autoforge/sandbox_pin.py +170 -0
- autoforge/sandbox_policy.py +1107 -0
- autoforge/scheduler.py +1143 -0
- autoforge/scheduler_state.py +2316 -0
- autoforge/scheduler_stop.py +421 -0
- autoforge/sdk_policy.py +988 -0
- autoforge/state.py +2417 -0
- autoforge/transitions.py +388 -0
- autoforge/validation.py +324 -0
- autoforge/worker.py +568 -0
- autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
- autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
- autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
- autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
- autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
- autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
autoforge/doctor.py
ADDED
|
@@ -0,0 +1,1551 @@
|
|
|
1
|
+
"""`autoforge doctor`: read-only environment checks.
|
|
2
|
+
|
|
3
|
+
Every check is non-destructive: version queries, `gh auth status`,
|
|
4
|
+
`git rev-parse`, `pi auth check --no-refresh`, reading the config, and
|
|
5
|
+
creating/removing one temp file in the state directory to prove it is
|
|
6
|
+
writable.
|
|
7
|
+
|
|
8
|
+
The agent CLIs checked are the ones the run can reach: each distinct
|
|
9
|
+
command named by a reachable profile, once, labelled with those profiles.
|
|
10
|
+
A Pi profile adds a version floor and a read-only credential probe, run
|
|
11
|
+
under the same environment allow-list as the agent launch (never the
|
|
12
|
+
operator's whole environment) and reported without Pi's raw output, and a
|
|
13
|
+
warning naming the instruction files Pi would load from outside the
|
|
14
|
+
checkout (docs/pi-policy.md §4-5); those are found by path, never read.
|
|
15
|
+
It also warns where the controller cannot contain Pi's detached tool
|
|
16
|
+
processes (it cannot make itself a child subreaper: not Linux, or a
|
|
17
|
+
security policy denies it), where unattended Pi use is not supported
|
|
18
|
+
(docs/pi.md, #135). A Claude Code command must list the flags every
|
|
19
|
+
launch keeps the operator's and the repository's integrations off with in
|
|
20
|
+
its ``--help`` (#165) and fails while its settings or global
|
|
21
|
+
configuration set a variable over a launch's isolation, and an OpenCode
|
|
22
|
+
profile fails while OpenCode would load an organization's configuration;
|
|
23
|
+
a launch refuses either (#165,
|
|
24
|
+
:meth:`autoforge.providers.ClaudeCodeProvider.integration_preflight`,
|
|
25
|
+
:meth:`autoforge.providers.OpenCodeProvider.integration_preflight`).
|
|
26
|
+
|
|
27
|
+
Both report the environment an agent and a repository-defined command are
|
|
28
|
+
launched with, by name only (never a value), and run the launch's own
|
|
29
|
+
credential preflight (#165, :func:`autoforge.child_env.preflight`): one
|
|
30
|
+
local ``git config`` read, ``~/.netrc`` and one login shell per shell a
|
|
31
|
+
launch may start; a credential helper in the repository's configuration, a
|
|
32
|
+
URL with an embedded credential, an HTTP header for git, a GitHub entry in
|
|
33
|
+
``~/.netrc`` or a shell startup file that changes a launch's variables
|
|
34
|
+
fails ``doctor`` exactly as it would refuse the launch. ``~/.curlrc``,
|
|
35
|
+
wget's configuration and zsh's startup files need no check: a launch's
|
|
36
|
+
curl, wget and zsh read the controller's empty ones instead. The
|
|
37
|
+
``origin`` URL is shown redacted.
|
|
38
|
+
|
|
39
|
+
A REMOTE run's doctor also makes the merge gate's checks read on one open
|
|
40
|
+
pull request whose head has check results (#291): GitHub refuses a
|
|
41
|
+
fine-grained personal access token the check runs of a private repository,
|
|
42
|
+
which no permission it can be given changes. PASS needs a check run read;
|
|
43
|
+
no such pull request is UNKNOWN, never a PASS.
|
|
44
|
+
|
|
45
|
+
Every run shows the parallel EPIC scheduler's settings (``scheduler.*``,
|
|
46
|
+
bounded by the configuration loader). ``doctor --epic <url>`` adds the
|
|
47
|
+
scheduler's own checks (ADR 0008, #183): the coordination root (the git
|
|
48
|
+
common dir) is on a filesystem whose ``flock`` is a local kernel lock
|
|
49
|
+
(:func:`autoforge.locking.check_local_lock_filesystem`); the scheduler root
|
|
50
|
+
is absent or a real directory owned by this user and not world-writable
|
|
51
|
+
(``lstat`` only); and ``gh`` can read the EPIC's sub-issues and
|
|
52
|
+
dependencies (#179's snapshot, its four reads). They create nothing.
|
|
53
|
+
|
|
54
|
+
``autoforge local doctor`` runs the LOCAL subset: no `gh`, no `gh auth
|
|
55
|
+
status`, no `origin` remote. A machine with no GitHub CLI and no GitHub
|
|
56
|
+
credentials must pass it, so the checks it omits are omitted entirely rather
|
|
57
|
+
than reported as warnings.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
from __future__ import annotations
|
|
61
|
+
|
|
62
|
+
import os
|
|
63
|
+
import re
|
|
64
|
+
import secrets
|
|
65
|
+
import stat
|
|
66
|
+
from collections.abc import Callable
|
|
67
|
+
from dataclasses import dataclass
|
|
68
|
+
from pathlib import Path
|
|
69
|
+
|
|
70
|
+
from .child_env import (
|
|
71
|
+
LaunchIdentity,
|
|
72
|
+
child_shell,
|
|
73
|
+
describe_overrides,
|
|
74
|
+
forbidden_env_entry,
|
|
75
|
+
isolated_overrides,
|
|
76
|
+
preflight,
|
|
77
|
+
)
|
|
78
|
+
from .config import (
|
|
79
|
+
MAX_PARALLEL_BOUNDS,
|
|
80
|
+
POLL_INTERVAL_BOUNDS,
|
|
81
|
+
STOP_GRACE_BOUNDS,
|
|
82
|
+
AutoForgeConfig,
|
|
83
|
+
ProfileConfig,
|
|
84
|
+
load_config_file,
|
|
85
|
+
validate_required_profiles,
|
|
86
|
+
)
|
|
87
|
+
from .config_fingerprint import fingerprint_routing
|
|
88
|
+
from .epic_graph import build_epic_snapshot, evaluate, ready_set
|
|
89
|
+
from .errors import (
|
|
90
|
+
ConfigurationError,
|
|
91
|
+
ExecutionError,
|
|
92
|
+
GitHubChecksAccessError,
|
|
93
|
+
GitHubError,
|
|
94
|
+
GitHubNotFoundError,
|
|
95
|
+
GitHubPlanLimitedError,
|
|
96
|
+
GitHubUnavailableError,
|
|
97
|
+
LockError,
|
|
98
|
+
SnapshotRefused,
|
|
99
|
+
StateError,
|
|
100
|
+
)
|
|
101
|
+
from .executor import (
|
|
102
|
+
ExecutionRequest,
|
|
103
|
+
ExecutionResult,
|
|
104
|
+
execute,
|
|
105
|
+
orphan_containment_available,
|
|
106
|
+
)
|
|
107
|
+
from .github import (
|
|
108
|
+
GH_MIN_VERSION,
|
|
109
|
+
GitHubClient,
|
|
110
|
+
checks_access_remedy,
|
|
111
|
+
gh_request,
|
|
112
|
+
gh_workdir,
|
|
113
|
+
is_access_denied_gh_failure,
|
|
114
|
+
parse_gh_version,
|
|
115
|
+
)
|
|
116
|
+
from .local_workspace import DEFAULT_MAX_BYTES, DEFAULT_MAX_ENTRIES
|
|
117
|
+
from .locking import check_local_lock_filesystem, repository_lock_path
|
|
118
|
+
from .profiles import REQUIRED_PROFILES, local_required_profiles
|
|
119
|
+
from .providers import (
|
|
120
|
+
CLAUDE_ISOLATION_FLAGS,
|
|
121
|
+
OPENCODE_MIN_VERSION,
|
|
122
|
+
PI_CONTEXT_FILES,
|
|
123
|
+
PI_DEFAULT_AGENT_DIR,
|
|
124
|
+
PI_MIN_VERSION,
|
|
125
|
+
PI_SYSTEM_PROMPT_FILES,
|
|
126
|
+
ClaudeCodeProvider,
|
|
127
|
+
OpenCodeProvider,
|
|
128
|
+
PiProvider,
|
|
129
|
+
parse_opencode_version,
|
|
130
|
+
parse_pi_version,
|
|
131
|
+
provider_for,
|
|
132
|
+
)
|
|
133
|
+
from .redaction import redact, redact_argv
|
|
134
|
+
from .run_namespace import AUTOFORGE_DIRNAME, SCHEDULER_DIRNAME
|
|
135
|
+
from .safefs import STATE_DIR_NEEDS_HARD_LINKS, HardLinksUnavailable, SafeRoot
|
|
136
|
+
from .sdk_policy import SDK_PROVIDERS
|
|
137
|
+
from .validation import parse_issue_url, parse_remote_repository
|
|
138
|
+
|
|
139
|
+
Runner = Callable[[ExecutionRequest], ExecutionResult]
|
|
140
|
+
# A fixed list of required profile names, or a function of the loaded config.
|
|
141
|
+
RequiredProfiles = list[str] | Callable[[AutoForgeConfig], list[str]]
|
|
142
|
+
|
|
143
|
+
# Fallback executable for a provider whose profile does not set `command`,
|
|
144
|
+
# matching the provider adapters' own defaults.
|
|
145
|
+
DEFAULT_AGENT_COMMANDS = {"claude": "claude", "opencode": "opencode", "pi": "pi"}
|
|
146
|
+
|
|
147
|
+
# The profile a REMOTE run reaches only after a merge, on top of
|
|
148
|
+
# `REQUIRED_PROFILES`; checked when it is configured.
|
|
149
|
+
REMOTE_POST_MERGE_PROFILES = ["update_epic"]
|
|
150
|
+
|
|
151
|
+
# Name of the `doctor` row that verifies the default branch's required checks.
|
|
152
|
+
REQUIRED_CHECKS_ROW = "default branch requires checks"
|
|
153
|
+
# Name of the `doctor` row that tries the merge gate's checks read (#291).
|
|
154
|
+
CHECKS_READ_ROW = "pull request check runs readable"
|
|
155
|
+
# How many candidate PRs that row reads before it gives up on finding a check
|
|
156
|
+
# run: each is one `gh pr view`, and a candidate whose results are commit
|
|
157
|
+
# statuses only proves nothing about check runs.
|
|
158
|
+
CHECKS_READ_TRIES = 3
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass
|
|
162
|
+
class CheckResult:
|
|
163
|
+
name: str
|
|
164
|
+
ok: bool
|
|
165
|
+
detail: str = ""
|
|
166
|
+
required: bool = True
|
|
167
|
+
# The check could not be performed (a read this token is not allowed to
|
|
168
|
+
# make, a transient GitHub failure, an earlier check it depends on
|
|
169
|
+
# failed). Neither a pass nor a failure: it never fails `doctor`, and it
|
|
170
|
+
# never claims the property holds.
|
|
171
|
+
skipped: bool = False
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def label(self) -> str:
|
|
175
|
+
if self.skipped:
|
|
176
|
+
return "SKIP"
|
|
177
|
+
if self.ok:
|
|
178
|
+
return "OK "
|
|
179
|
+
return "FAIL" if self.required else "WARN"
|
|
180
|
+
|
|
181
|
+
@classmethod
|
|
182
|
+
def skip(cls, name: str, detail: str) -> CheckResult:
|
|
183
|
+
return cls(name, False, detail, required=False, skipped=True)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class Doctor:
|
|
187
|
+
def __init__(
|
|
188
|
+
self,
|
|
189
|
+
config_path: str | None = None,
|
|
190
|
+
state_dir: str | None = None,
|
|
191
|
+
cwd: str | None = None,
|
|
192
|
+
runner: Runner | None = None,
|
|
193
|
+
timeout_seconds: int = 30,
|
|
194
|
+
epic_url: str | None = None,
|
|
195
|
+
) -> None:
|
|
196
|
+
self.config_path = config_path
|
|
197
|
+
# ``doctor --epic``: the EPIC whose graph the scheduler rows read.
|
|
198
|
+
self.epic_url = epic_url
|
|
199
|
+
self.state_dir = state_dir
|
|
200
|
+
self.cwd = cwd or os.getcwd()
|
|
201
|
+
self._runner: Runner = runner or execute
|
|
202
|
+
self.timeout = timeout_seconds
|
|
203
|
+
self.config: AutoForgeConfig | None = None
|
|
204
|
+
# 'owner/repo' of the `origin` remote, set exactly when `check_remote` passes.
|
|
205
|
+
self.repo: str | None = None
|
|
206
|
+
# Where the TCP listeners are read from (ADR 0005 D9.3); a test points
|
|
207
|
+
# it at its own files.
|
|
208
|
+
self.proc_net = "/proc/net"
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def runner(self) -> Runner:
|
|
212
|
+
"""What every check's process runs through (a test's fake, or the executor)."""
|
|
213
|
+
return self._runner
|
|
214
|
+
|
|
215
|
+
# -- helpers -----------------------------------------------------------------
|
|
216
|
+
def _run(self, argv: list[str]) -> tuple[bool, str]:
|
|
217
|
+
return self._run_request(
|
|
218
|
+
ExecutionRequest(command=argv, cwd=self.cwd, timeout_seconds=self.timeout)
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def _run_gh(self, argv: list[str]) -> tuple[bool, str]:
|
|
222
|
+
""":meth:`_run` for a `gh` argv: in an empty directory of its own, never the checkout.
|
|
223
|
+
|
|
224
|
+
The controller runs every `gh` that way (#283), and so does doctor.
|
|
225
|
+
"""
|
|
226
|
+
try:
|
|
227
|
+
with gh_workdir() as workdir:
|
|
228
|
+
return self._run_request(gh_request(argv, workdir, timeout_seconds=self.timeout))
|
|
229
|
+
except GitHubError as exc: # no empty directory could be made
|
|
230
|
+
return False, str(exc)
|
|
231
|
+
|
|
232
|
+
def _run_request(self, request: ExecutionRequest) -> tuple[bool, str]:
|
|
233
|
+
try:
|
|
234
|
+
res = self._runner(request)
|
|
235
|
+
except Exception as exc: # spawn failure / missing binary
|
|
236
|
+
return False, f"{type(exc).__name__}: {exc}"
|
|
237
|
+
text = (res.stdout or "").strip() or (res.stderr or "").strip()
|
|
238
|
+
first = text.splitlines()[0] if text else ""
|
|
239
|
+
if res.timed_out:
|
|
240
|
+
return False, "timed out"
|
|
241
|
+
return res.exit_code == 0, first if res.exit_code == 0 else (text[-400:] or "non-zero exit")
|
|
242
|
+
|
|
243
|
+
def _version_check(self, name: str, argv: list[str]) -> CheckResult:
|
|
244
|
+
ok, detail = self._run(argv)
|
|
245
|
+
return CheckResult(name, ok, detail)
|
|
246
|
+
|
|
247
|
+
# -- checks --------------------------------------------------------------------
|
|
248
|
+
def check_config(self, required_profiles: RequiredProfiles | None = None) -> CheckResult:
|
|
249
|
+
"""Load the config and require its profiles.
|
|
250
|
+
|
|
251
|
+
``required_profiles`` may be a fixed list or a callable, because a
|
|
252
|
+
LOCAL run's required reviewer profiles are derived from the config that
|
|
253
|
+
is only loaded here (see :func:`autoforge.profiles.local_required_profiles`).
|
|
254
|
+
|
|
255
|
+
``self.config`` is set exactly when this check passes, and to what
|
|
256
|
+
*this* attempt accepted. Every later check reads it, so it must not
|
|
257
|
+
describe a config a run would refuse: not a previous file (a `Doctor`
|
|
258
|
+
reused after its config turned invalid), and not one that parsed but
|
|
259
|
+
failed profile validation -- a run does not start on such a config
|
|
260
|
+
(`ControllerEngine.validate_config`), so the merge gate must not claim
|
|
261
|
+
what a run "WILL" do with it.
|
|
262
|
+
"""
|
|
263
|
+
self.config = None
|
|
264
|
+
try:
|
|
265
|
+
cfg = load_config_file(self.config_path)
|
|
266
|
+
required = required_profiles or REQUIRED_PROFILES
|
|
267
|
+
names = required(cfg) if callable(required) else required
|
|
268
|
+
validate_required_profiles(cfg, names)
|
|
269
|
+
if required_profiles is None:
|
|
270
|
+
# A REMOTE run: its routing is recorded in its state file (#219).
|
|
271
|
+
fingerprint_routing(cfg)
|
|
272
|
+
except ConfigurationError as exc:
|
|
273
|
+
return CheckResult("config", False, str(exc))
|
|
274
|
+
# #280: a file an enforced launch could have rewritten is refused
|
|
275
|
+
# here as every command refuses it, and since ``self.config`` stays
|
|
276
|
+
# unset, no check runs a command it names (a profile's ``command``,
|
|
277
|
+
# ``github.command``).
|
|
278
|
+
from .engine import config_file_placement_problem
|
|
279
|
+
|
|
280
|
+
problem = config_file_placement_problem(cfg, self.config_path or "", self.cwd, self.runner)
|
|
281
|
+
if problem:
|
|
282
|
+
return CheckResult(
|
|
283
|
+
"config", False, f"{problem}; doctor runs no command this file names"
|
|
284
|
+
)
|
|
285
|
+
self.config = cfg
|
|
286
|
+
src = self.config_path or "(built-in defaults)"
|
|
287
|
+
return CheckResult("config", True, f"{src}; profiles: {', '.join(sorted(cfg.profiles))}")
|
|
288
|
+
|
|
289
|
+
def check_merge_gate(self) -> CheckResult:
|
|
290
|
+
"""Report the effective merge gate and the file/key that set it (informational).
|
|
291
|
+
|
|
292
|
+
The gate has two halves -- `safety.allow_merge` in config AND
|
|
293
|
+
`--allow-merge` on the CLI -- and `doctor` cannot see the flag, so it
|
|
294
|
+
states what the flag *would* do with this config. The source is named
|
|
295
|
+
so an operator who believes the gate is closed can check the key that
|
|
296
|
+
decides it rather than the one they last edited.
|
|
297
|
+
"""
|
|
298
|
+
name = "merge gate"
|
|
299
|
+
cfg = self.config
|
|
300
|
+
if cfg is None:
|
|
301
|
+
return CheckResult(name, False, "(config check failed)", required=False)
|
|
302
|
+
source = cfg.safety.allow_merge_source
|
|
303
|
+
if cfg.merge_allowed_by_config:
|
|
304
|
+
detail = (
|
|
305
|
+
f"config half OPEN: safety.allow_merge=true ({source}); "
|
|
306
|
+
"a run with --allow-merge WILL merge PRs itself"
|
|
307
|
+
)
|
|
308
|
+
else:
|
|
309
|
+
detail = (
|
|
310
|
+
f"CLOSED: safety.allow_merge=false ({source}); --allow-merge alone cannot merge"
|
|
311
|
+
)
|
|
312
|
+
return CheckResult(name, True, detail, required=False)
|
|
313
|
+
|
|
314
|
+
def check_premerge_verification(self) -> CheckResult:
|
|
315
|
+
"""Report the controller's own pre-merge evidence (informational; runs nothing).
|
|
316
|
+
|
|
317
|
+
A green required check proves the PR's workflow ran, not that the
|
|
318
|
+
base branch's definition ran (``safety.verify_check_definition``)
|
|
319
|
+
and not what the tests assert (``merge.verification_commands``).
|
|
320
|
+
With the config half of the merge gate open and neither in place,
|
|
321
|
+
the hosted check alone decides what gets merged unattended, which
|
|
322
|
+
is worth stating next to the gate rather than discovering later.
|
|
323
|
+
"""
|
|
324
|
+
name = "pre-merge verification"
|
|
325
|
+
cfg = self.config
|
|
326
|
+
if cfg is None:
|
|
327
|
+
return CheckResult(name, False, "(config check failed)", required=False)
|
|
328
|
+
definition = bool(cfg.safety.verify_check_definition and cfg.safety.required_checks)
|
|
329
|
+
commands = cfg.merge.verification_commands
|
|
330
|
+
parts = [
|
|
331
|
+
(
|
|
332
|
+
"check definition compared to the base branch's run "
|
|
333
|
+
f"({', '.join(cfg.safety.required_checks)})"
|
|
334
|
+
if definition
|
|
335
|
+
else "check definition NOT verified (safety.verify_check_definition)"
|
|
336
|
+
),
|
|
337
|
+
(
|
|
338
|
+
"local commands: " + "; ".join(" ".join(argv) for argv in commands)
|
|
339
|
+
if commands
|
|
340
|
+
else "no merge.verification_commands: nothing runs locally before a merge"
|
|
341
|
+
),
|
|
342
|
+
]
|
|
343
|
+
detail = "; ".join(parts)
|
|
344
|
+
if cfg.merge_allowed_by_config and not commands:
|
|
345
|
+
detail += (
|
|
346
|
+
" -- with safety.allow_merge=true a green check is the only evidence about "
|
|
347
|
+
"what the PR's tests assert; consider merge.verification_commands"
|
|
348
|
+
)
|
|
349
|
+
return CheckResult(name, True, detail, required=False)
|
|
350
|
+
|
|
351
|
+
def check_candidate_validation(self) -> CheckResult:
|
|
352
|
+
"""List ``validation.commands`` (informational; runs nothing, ADR 0006 D3.1).
|
|
353
|
+
|
|
354
|
+
The definitions REMOTE candidate validation runs in a candidate's
|
|
355
|
+
byte-exact export, redacted, and the mode their records are made
|
|
356
|
+
under; none is launched here.
|
|
357
|
+
"""
|
|
358
|
+
name = "candidate validation (validation.commands)"
|
|
359
|
+
cfg = self.config
|
|
360
|
+
if cfg is None:
|
|
361
|
+
return CheckResult(name, False, "(config check failed)", required=False)
|
|
362
|
+
commands = cfg.validation.commands
|
|
363
|
+
mode = f"records made under sandbox.mode {cfg.sandbox.mode}"
|
|
364
|
+
if not cfg.sandbox.enabled:
|
|
365
|
+
mode += " (validators run unsandboxed as the operator's UID: unenforced evidence)"
|
|
366
|
+
if not commands:
|
|
367
|
+
return CheckResult(
|
|
368
|
+
name, True, f"(none configured: an empty set); {mode}", required=False
|
|
369
|
+
)
|
|
370
|
+
listed = "; ".join(" ".join(redact_argv(list(argv))) for argv in commands)
|
|
371
|
+
return CheckResult(
|
|
372
|
+
name, True, f"{len(commands)} definition(s): {listed}; {mode}", required=False
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
def check_state_dir(self) -> CheckResult:
|
|
376
|
+
d = self.state_dir or (self.config.state_dir if self.config else ".autoforge")
|
|
377
|
+
path = Path(self.cwd) / d if not Path(d).is_absolute() else Path(d)
|
|
378
|
+
problem = self._writable(lambda: SafeRoot.open(path, create=True))
|
|
379
|
+
if problem is not None:
|
|
380
|
+
return CheckResult("state dir writable", False, f"{path}: {problem}")
|
|
381
|
+
return CheckResult("state dir writable", True, str(path))
|
|
382
|
+
|
|
383
|
+
def check_git_repo(self) -> CheckResult:
|
|
384
|
+
ok, detail = self._run(["git", "rev-parse", "--show-toplevel"])
|
|
385
|
+
if not ok:
|
|
386
|
+
return CheckResult("cwd is a git repository", False, detail)
|
|
387
|
+
return CheckResult("cwd is a git repository", True, detail)
|
|
388
|
+
|
|
389
|
+
def check_remote(self) -> CheckResult:
|
|
390
|
+
self.repo = None
|
|
391
|
+
ok, detail = self._run(["git", "remote", "get-url", "origin"])
|
|
392
|
+
if not ok:
|
|
393
|
+
return CheckResult("GitHub remote", False, f"no 'origin' remote: {redact(detail)}")
|
|
394
|
+
# The URL is shown redacted: one carrying a token is refused by the
|
|
395
|
+
# credential row, and never printed.
|
|
396
|
+
try:
|
|
397
|
+
repo = parse_remote_repository(detail)
|
|
398
|
+
except Exception as exc:
|
|
399
|
+
return CheckResult("GitHub remote", False, redact(f"{detail!r}: {exc}"))
|
|
400
|
+
self.repo = repo
|
|
401
|
+
return CheckResult("GitHub remote", True, f"{repo} ({redact(detail)})")
|
|
402
|
+
|
|
403
|
+
def check_agent_environment(self) -> CheckResult:
|
|
404
|
+
"""What an agent and a repository-defined command inherit, by name only.
|
|
405
|
+
|
|
406
|
+
Informational: the configured allow-list (each provider adds its own
|
|
407
|
+
names on top), the names the controller sets for every launch, and
|
|
408
|
+
the credential-route names this shell holds that no launch receives.
|
|
409
|
+
"""
|
|
410
|
+
cfg = self.config if self.config is not None else AutoForgeConfig()
|
|
411
|
+
names = cfg.execution.environment_names()
|
|
412
|
+
withheld = sorted(name for name in os.environ if forbidden_env_entry(name))
|
|
413
|
+
detail = (
|
|
414
|
+
f"allow-listed: {', '.join(names)} (plus each provider's own names); "
|
|
415
|
+
f"set by the controller: {describe_overrides()}; "
|
|
416
|
+
f"withheld from this shell: {', '.join(withheld) or '(none present)'}"
|
|
417
|
+
)
|
|
418
|
+
return CheckResult("launch environment", True, detail, required=False)
|
|
419
|
+
|
|
420
|
+
def check_launch_credentials(self) -> CheckResult:
|
|
421
|
+
"""The launch preflight, run here: no GitHub or git credential reaches a child."""
|
|
422
|
+
name = "no credential reaches a launch"
|
|
423
|
+
cfg = self.config if self.config is not None else AutoForgeConfig()
|
|
424
|
+
try:
|
|
425
|
+
identity = preflight(
|
|
426
|
+
self.cwd, self._runner, env_allowlist=cfg.execution.environment_names()
|
|
427
|
+
)
|
|
428
|
+
except ConfigurationError as exc:
|
|
429
|
+
return CheckResult(name, False, redact(str(exc)))
|
|
430
|
+
return CheckResult(
|
|
431
|
+
name,
|
|
432
|
+
True,
|
|
433
|
+
"no credential helper, credential-bearing URL or HTTP header in the repository's "
|
|
434
|
+
"git configuration, and no GitHub entry in ~/.netrc; a launch's curl and wget "
|
|
435
|
+
"read an empty configuration of the controller's, never ~/.curlrc or ~/.wgetrc; "
|
|
436
|
+
f"a launch's shell is {child_shell()} and no login shell's startup files change "
|
|
437
|
+
"its variables, zsh reading none of yours; "
|
|
438
|
+
f"author identity supplied: {identity.describe()}",
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
def check_required_checks(self, gh: str) -> CheckResult:
|
|
442
|
+
"""Whether the default branch really *requires* `safety.required_checks`.
|
|
443
|
+
|
|
444
|
+
The merge gate verifies that every check on the PR succeeded, which
|
|
445
|
+
is a statement about check results, not about whether any check had
|
|
446
|
+
to exist: a branch that requires nothing makes a PR with no check
|
|
447
|
+
runs vacuously green. The requirement lives in repository settings,
|
|
448
|
+
outside version control, so this is the only place drift is caught.
|
|
449
|
+
|
|
450
|
+
Read-only. When the branch rules cannot be verified -- GitHub's
|
|
451
|
+
plan offers none on this repository (#289), the token may not read
|
|
452
|
+
them, GitHub reports the repository not found, or GitHub could not
|
|
453
|
+
be read at all (transient) -- nothing shows that the merge gate's
|
|
454
|
+
"every check succeeded" has a required check behind it. With the
|
|
455
|
+
configuration's half of the merge gate open (``safety.allow_merge:
|
|
456
|
+
true``) that is a FAIL; with it closed a SKIP that says why. A plan
|
|
457
|
+
limitation is named as one and never sent to the token's
|
|
458
|
+
permissions: no permission changes it. The same holds for a rule
|
|
459
|
+
whose bypass actors GitHub withholds (it returns them only with
|
|
460
|
+
write access to the ruleset) when it also does not say whether this
|
|
461
|
+
token may bypass it: whether the controller's own login can skip the
|
|
462
|
+
rule is then unverified. When GitHub does say that this token may
|
|
463
|
+
never bypass it, the hidden list is a SKIP either way: the required
|
|
464
|
+
checks were read and the controller cannot bypass them. A problem
|
|
465
|
+
that *is* visible (a missing context, a non-active ruleset, a bypass
|
|
466
|
+
actor) is a FAIL regardless of what stayed hidden. Never an
|
|
467
|
+
unverified OK.
|
|
468
|
+
"""
|
|
469
|
+
name = REQUIRED_CHECKS_ROW
|
|
470
|
+
cfg = self.config
|
|
471
|
+
if cfg is None:
|
|
472
|
+
return CheckResult.skip(name, "(config check failed)")
|
|
473
|
+
if self.repo is None:
|
|
474
|
+
return CheckResult.skip(name, "(GitHub remote check failed)")
|
|
475
|
+
client = GitHubClient(gh_command=gh, timeout_seconds=self.timeout, runner=self._runner)
|
|
476
|
+
try:
|
|
477
|
+
return self._required_checks(client, self.repo, cfg.safety.required_checks)
|
|
478
|
+
except GitHubUnavailableError as exc:
|
|
479
|
+
return self._required_checks_unverifiable(
|
|
480
|
+
f"GitHub could not be read (transient; run doctor again): {exc}",
|
|
481
|
+
"run doctor again",
|
|
482
|
+
)
|
|
483
|
+
except GitHubPlanLimitedError as exc:
|
|
484
|
+
return self._required_checks_unverifiable(
|
|
485
|
+
_plan_limited_text(self.repo, exc), _PLAN_REMEDY
|
|
486
|
+
)
|
|
487
|
+
except GitHubError as exc:
|
|
488
|
+
if isinstance(exc, GitHubNotFoundError) or is_access_denied_gh_failure(str(exc)):
|
|
489
|
+
return self._required_checks_unverifiable(
|
|
490
|
+
f"this token cannot read the branch rules of {self.repo}: {exc}",
|
|
491
|
+
_READ_RULES_REMEDY,
|
|
492
|
+
)
|
|
493
|
+
return CheckResult(name, False, str(exc))
|
|
494
|
+
|
|
495
|
+
def _required_checks_unverifiable(self, why: str, remedy: str) -> CheckResult:
|
|
496
|
+
"""No verified answer about the default branch's required checks (#289).
|
|
497
|
+
|
|
498
|
+
A FAIL with the gate open, a SKIP that says why with it closed. The
|
|
499
|
+
merge gate's own rollup check refuses a rollup in which nothing
|
|
500
|
+
succeeded, but only a branch rule makes a check *required*: with
|
|
501
|
+
``safety.allow_merge: true`` and nothing doctor can verify requiring
|
|
502
|
+
one, the unattended merge rests on a property no one verified.
|
|
503
|
+
``remedy`` is the way out for this cause, never a permission for a
|
|
504
|
+
plan limitation.
|
|
505
|
+
"""
|
|
506
|
+
name = REQUIRED_CHECKS_ROW
|
|
507
|
+
if self.config is not None and self.config.merge_allowed_by_config:
|
|
508
|
+
return CheckResult(
|
|
509
|
+
name,
|
|
510
|
+
False,
|
|
511
|
+
redact(
|
|
512
|
+
f"{why}; with safety.allow_merge=true the merge gate would merge without "
|
|
513
|
+
f"a branch rule doctor could verify requires a check: {remedy}, or close "
|
|
514
|
+
"the gate (safety.allow_merge: false)"
|
|
515
|
+
),
|
|
516
|
+
)
|
|
517
|
+
return CheckResult.skip(
|
|
518
|
+
name,
|
|
519
|
+
redact(f"{why} (the merge gate is closed, so no unattended merge relies on it)"),
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
def _required_checks(self, client: GitHubClient, repo: str, expected: list[str]) -> CheckResult:
|
|
523
|
+
name = REQUIRED_CHECKS_ROW
|
|
524
|
+
branch = client.get_repo(repo).default_branch
|
|
525
|
+
if not branch:
|
|
526
|
+
return CheckResult(name, False, f"{repo}: GitHub reports no default branch")
|
|
527
|
+
rules = client.get_required_status_check_rules(repo, branch)
|
|
528
|
+
if not rules:
|
|
529
|
+
return self._required_checks_without_ruleset(client, repo, branch, expected)
|
|
530
|
+
contexts = sorted({context for rule in rules for context in rule.contexts})
|
|
531
|
+
problems = _missing_contexts(contexts, expected)
|
|
532
|
+
# The effective-rules read lists active rulesets only, but the
|
|
533
|
+
# ruleset itself says who may bypass it, and that is read separately.
|
|
534
|
+
# GitHub withholds `bypass_actors` from a token without write access
|
|
535
|
+
# to the ruleset (the read still succeeds), so "no bypass actors" is
|
|
536
|
+
# established only by an explicitly empty list; `current_user_can_
|
|
537
|
+
# bypass` is returned to every caller and a token that may bypass the
|
|
538
|
+
# rule is a bypass actor whether or not the list is visible.
|
|
539
|
+
unseen: list[str] = [] # list hidden; GitHub says this token may never bypass
|
|
540
|
+
unverified: list[str] = [] # list hidden, and nothing said about this token
|
|
541
|
+
for ruleset_id in sorted({rule.ruleset_id for rule in rules}):
|
|
542
|
+
ruleset = client.get_ruleset(repo, ruleset_id)
|
|
543
|
+
label = f"ruleset '{ruleset.name}' (#{ruleset.id})"
|
|
544
|
+
can_bypass = ruleset.current_user_can_bypass
|
|
545
|
+
if not ruleset.is_active:
|
|
546
|
+
problems.append(f"{label} enforcement is '{ruleset.enforcement}', not 'active'")
|
|
547
|
+
if ruleset.bypass_actors:
|
|
548
|
+
problems.append(
|
|
549
|
+
f"{label} can be bypassed by: {', '.join(ruleset.bypass_actors)}"
|
|
550
|
+
+ (f" (this token: {can_bypass})" if can_bypass else "")
|
|
551
|
+
)
|
|
552
|
+
elif can_bypass not in ("", "never"):
|
|
553
|
+
problems.append(f"{label} can be bypassed by this token ({can_bypass})")
|
|
554
|
+
elif ruleset.bypass_actors is None:
|
|
555
|
+
(unverified if can_bypass == "" else unseen).append(label)
|
|
556
|
+
sources = ", ".join(
|
|
557
|
+
f"ruleset #{rule.ruleset_id} ({rule.ruleset_source_type or '?'} {rule.ruleset_source})"
|
|
558
|
+
for rule in rules
|
|
559
|
+
)
|
|
560
|
+
summary = f"'{branch}' requires: {', '.join(contexts) or '(no context)'} via {sources}"
|
|
561
|
+
hidden = (
|
|
562
|
+
[
|
|
563
|
+
f"bypass actors of {', '.join(unseen)} are not visible to this token (GitHub "
|
|
564
|
+
"returns them only with write access to the ruleset), so 'no bypass actors' "
|
|
565
|
+
"is unverified"
|
|
566
|
+
]
|
|
567
|
+
if unseen
|
|
568
|
+
else []
|
|
569
|
+
)
|
|
570
|
+
if unverified:
|
|
571
|
+
hidden.append(
|
|
572
|
+
f"bypass actors of {', '.join(unverified)} are not visible to this token, and "
|
|
573
|
+
"GitHub did not say whether this token may bypass the rule, so whether the "
|
|
574
|
+
"controller's own login can skip the required checks is unverified"
|
|
575
|
+
)
|
|
576
|
+
if problems:
|
|
577
|
+
# A visible problem is conclusive whatever else stayed hidden;
|
|
578
|
+
# the hidden part is still named so the remedy is known to be partial.
|
|
579
|
+
return CheckResult(name, False, "; ".join([summary, *problems, *hidden]))
|
|
580
|
+
if unverified:
|
|
581
|
+
return self._required_checks_unverifiable(
|
|
582
|
+
"; ".join([summary, *hidden]),
|
|
583
|
+
"run doctor with a login that has write access to the ruleset",
|
|
584
|
+
)
|
|
585
|
+
if hidden:
|
|
586
|
+
return CheckResult.skip(name, f"{summary}; {hidden[0]}")
|
|
587
|
+
return CheckResult(name, True, summary)
|
|
588
|
+
|
|
589
|
+
def _required_checks_without_ruleset(
|
|
590
|
+
self, client: GitHubClient, repo: str, branch: str, expected: list[str]
|
|
591
|
+
) -> CheckResult:
|
|
592
|
+
"""No active ruleset requires a check: classic branch protection is the last resort."""
|
|
593
|
+
name = REQUIRED_CHECKS_ROW
|
|
594
|
+
remedy = (
|
|
595
|
+
f"add a ruleset at https://github.com/{repo}/settings/rules targeting '{branch}' "
|
|
596
|
+
f"with a 'Require status checks to pass' rule naming "
|
|
597
|
+
f"{', '.join(expected) or 'the CI check'}, enforcement 'active' and no bypass actors"
|
|
598
|
+
)
|
|
599
|
+
try:
|
|
600
|
+
classic = client.get_branch_protection(repo, branch)
|
|
601
|
+
except GitHubUnavailableError:
|
|
602
|
+
raise
|
|
603
|
+
except GitHubPlanLimitedError as exc:
|
|
604
|
+
return self._required_checks_unverifiable(
|
|
605
|
+
f"no active ruleset requires a status check on '{branch}', and "
|
|
606
|
+
+ _plan_limited_text(repo, exc),
|
|
607
|
+
_PLAN_REMEDY,
|
|
608
|
+
)
|
|
609
|
+
except GitHubError as exc:
|
|
610
|
+
if not is_access_denied_gh_failure(str(exc)):
|
|
611
|
+
raise
|
|
612
|
+
# Rulesets require nothing, and the only other mechanism cannot
|
|
613
|
+
# be read: no conclusion either way.
|
|
614
|
+
return self._required_checks_unverifiable(
|
|
615
|
+
f"no active ruleset requires a status check on '{branch}', and this token "
|
|
616
|
+
f"cannot read its classic branch protection (Administration: read): {exc}",
|
|
617
|
+
_READ_RULES_REMEDY,
|
|
618
|
+
)
|
|
619
|
+
if classic is not None and classic.required_status_checks:
|
|
620
|
+
contexts = sorted(classic.required_contexts)
|
|
621
|
+
problems = _missing_contexts(contexts, expected)
|
|
622
|
+
if not classic.enforce_admins:
|
|
623
|
+
problems.append("'Do not allow bypassing the above settings' is off for admins")
|
|
624
|
+
summary = (
|
|
625
|
+
f"'{branch}' requires: {', '.join(contexts) or '(no context)'} "
|
|
626
|
+
"via classic branch protection"
|
|
627
|
+
)
|
|
628
|
+
if problems:
|
|
629
|
+
return CheckResult(name, False, f"{summary}; {'; '.join(problems)}")
|
|
630
|
+
return CheckResult(name, True, summary)
|
|
631
|
+
# A disabled ruleset is invisible to the effective-rules read, so the
|
|
632
|
+
# listing is consulted to make the remedy concrete. It is a hint only:
|
|
633
|
+
# a listing this token may not read changes nothing about the answer.
|
|
634
|
+
try:
|
|
635
|
+
rulesets = client.list_rulesets(repo)
|
|
636
|
+
except GitHubUnavailableError:
|
|
637
|
+
raise
|
|
638
|
+
except GitHubError as exc:
|
|
639
|
+
if not (
|
|
640
|
+
isinstance(exc, GitHubPlanLimitedError) or is_access_denied_gh_failure(str(exc))
|
|
641
|
+
):
|
|
642
|
+
raise
|
|
643
|
+
rulesets = []
|
|
644
|
+
hints = [
|
|
645
|
+
f"ruleset '{r.name}' (#{r.id}) exists but its enforcement is '{r.enforcement}'"
|
|
646
|
+
for r in rulesets
|
|
647
|
+
if r.target == "branch" and not r.is_active
|
|
648
|
+
]
|
|
649
|
+
detail = f"no rule requires a status check on '{branch}'"
|
|
650
|
+
if classic is not None:
|
|
651
|
+
detail += " (classic branch protection exists but requires no status check)"
|
|
652
|
+
return CheckResult(name, False, "; ".join([detail, *hints, remedy]))
|
|
653
|
+
|
|
654
|
+
def check_checks_read(self, gh: str) -> CheckResult:
|
|
655
|
+
"""Whether this `gh` login may read a pull request's check runs (#291).
|
|
656
|
+
|
|
657
|
+
The merge gate reads a PR's check results with
|
|
658
|
+
:meth:`GitHubClient.get_pr_with_checks`, and GitHub refuses a
|
|
659
|
+
fine-grained personal access token the check runs of a private
|
|
660
|
+
repository's PR: no permission it can be given grants them. This row
|
|
661
|
+
makes that same read, with the same `gh` command and login, on an
|
|
662
|
+
open PR whose head has at least one check result (selected by count,
|
|
663
|
+
:meth:`GitHubClient.list_checks_probe_candidates`), up to
|
|
664
|
+
:data:`CHECKS_READ_TRIES` of them. Read-only; it never reads, prints
|
|
665
|
+
or stores a token.
|
|
666
|
+
|
|
667
|
+
PASS only when the read returned at least one check *run*, the
|
|
668
|
+
result GitHub was seen to refuse (#257). Whether it refuses a PR
|
|
669
|
+
whose results are commit statuses only was not tried, so reading
|
|
670
|
+
one proves nothing about check runs: the next candidate is read,
|
|
671
|
+
and none with a check run is UNKNOWN. DENIED when GitHub refused the
|
|
672
|
+
read (whatever the PR's results), a FAIL while the
|
|
673
|
+
configuration's half of the merge gate is open (every merge would be
|
|
674
|
+
BLOCKED) and a WARN otherwise. UNKNOWN (a SKIP, never a PASS) when no
|
|
675
|
+
PR could be tried or the answer was a plan limitation, a not-found or
|
|
676
|
+
a transient failure: none of those says what this login may read.
|
|
677
|
+
"""
|
|
678
|
+
name = CHECKS_READ_ROW
|
|
679
|
+
cfg = self.config
|
|
680
|
+
if cfg is None:
|
|
681
|
+
return CheckResult.skip(name, "(config check failed)")
|
|
682
|
+
if self.repo is None:
|
|
683
|
+
return CheckResult.skip(name, "(GitHub remote check failed)")
|
|
684
|
+
repo = self.repo
|
|
685
|
+
client = GitHubClient(gh_command=gh, timeout_seconds=self.timeout, runner=self._runner)
|
|
686
|
+
try:
|
|
687
|
+
candidates = client.list_checks_probe_candidates(repo)
|
|
688
|
+
except GitHubChecksAccessError as exc:
|
|
689
|
+
# Even the count was refused: that is the refusal itself.
|
|
690
|
+
return self._checks_read_denied(
|
|
691
|
+
f"GitHub refused this gh login the check results of {repo}'s open pull "
|
|
692
|
+
f"requests: {checks_access_remedy(exc.credential)}",
|
|
693
|
+
exc,
|
|
694
|
+
)
|
|
695
|
+
except GitHubError as exc:
|
|
696
|
+
return self._checks_read_unknown(
|
|
697
|
+
f"the open pull requests of {repo} could not be listed to pick one to read", exc
|
|
698
|
+
)
|
|
699
|
+
if not candidates:
|
|
700
|
+
return CheckResult.skip(
|
|
701
|
+
name,
|
|
702
|
+
f"UNKNOWN: no recently updated open pull request of {repo} has a check result "
|
|
703
|
+
"on its head, so the checks read the merge gate makes was not tried; run doctor "
|
|
704
|
+
"again while one does",
|
|
705
|
+
)
|
|
706
|
+
tried: list[str] = []
|
|
707
|
+
for url, _count in candidates[:CHECKS_READ_TRIES]:
|
|
708
|
+
try:
|
|
709
|
+
pr = client.get_pr_with_checks(url)
|
|
710
|
+
except GitHubChecksAccessError as exc:
|
|
711
|
+
return self._checks_read_denied(
|
|
712
|
+
f"GitHub refused this gh login the check results of {url}: "
|
|
713
|
+
f"{checks_access_remedy(exc.credential)}",
|
|
714
|
+
exc,
|
|
715
|
+
)
|
|
716
|
+
except GitHubError as exc:
|
|
717
|
+
if _pr_read_refused(exc):
|
|
718
|
+
return self._checks_read_denied(
|
|
719
|
+
f"GitHub refused this gh login the pull request {url} itself (see the "
|
|
720
|
+
"'gh authenticated' row)",
|
|
721
|
+
exc,
|
|
722
|
+
)
|
|
723
|
+
return self._checks_read_unknown(f"the checks read of {url} failed", exc)
|
|
724
|
+
runs = [check for check in pr.checks or [] if check.kind == "CheckRun"]
|
|
725
|
+
if runs:
|
|
726
|
+
return CheckResult(
|
|
727
|
+
name,
|
|
728
|
+
True,
|
|
729
|
+
f"PASS: this gh login read {len(pr.checks or [])} check result(s) of {url}, "
|
|
730
|
+
f"{len(runs)} of them check runs, with the merge gate's own read",
|
|
731
|
+
)
|
|
732
|
+
tried.append(url)
|
|
733
|
+
return CheckResult.skip(
|
|
734
|
+
name,
|
|
735
|
+
f"UNKNOWN: the check results of {', '.join(tried)} hold no check run (commit "
|
|
736
|
+
"statuses only), which proves nothing about the check runs GitHub may refuse",
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
def _checks_read_denied(self, what: str, exc: GitHubError) -> CheckResult:
|
|
740
|
+
gate_open = self.config is not None and self.config.merge_allowed_by_config
|
|
741
|
+
consequence = (
|
|
742
|
+
"with safety.allow_merge=true every merge would be BLOCKED at READY_FOR_MERGE"
|
|
743
|
+
if gate_open
|
|
744
|
+
else "a run with the merge gate open would be BLOCKED at READY_FOR_MERGE; "
|
|
745
|
+
"REVIEW and FIX proceed without the check results"
|
|
746
|
+
)
|
|
747
|
+
return CheckResult(
|
|
748
|
+
CHECKS_READ_ROW,
|
|
749
|
+
False,
|
|
750
|
+
redact(f"DENIED: {what}; {consequence}. GitHub's answer: {exc}"),
|
|
751
|
+
required=gate_open,
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
@staticmethod
|
|
755
|
+
def _checks_read_unknown(what: str, exc: GitHubError) -> CheckResult:
|
|
756
|
+
# The client's typed answers first; only a plain GitHubError is
|
|
757
|
+
# classified by its text.
|
|
758
|
+
if isinstance(exc, GitHubUnavailableError):
|
|
759
|
+
why = "a transient GitHub failure; run doctor again"
|
|
760
|
+
elif isinstance(exc, GitHubNotFoundError):
|
|
761
|
+
why = "the repository or pull request was not found by this login"
|
|
762
|
+
elif isinstance(exc, GitHubPlanLimitedError):
|
|
763
|
+
why = "a GitHub plan limitation, not this login's permissions"
|
|
764
|
+
elif type(exc) is GitHubError and is_access_denied_gh_failure(str(exc)):
|
|
765
|
+
why = "this login may not make that read (see the 'gh authenticated' row)"
|
|
766
|
+
else:
|
|
767
|
+
why = "an answer this check cannot classify"
|
|
768
|
+
return CheckResult.skip(CHECKS_READ_ROW, redact(f"UNKNOWN: {what} ({why}): {exc}"))
|
|
769
|
+
|
|
770
|
+
def check_gh_version(self, gh: str) -> CheckResult:
|
|
771
|
+
"""`gh` runs and is at least :data:`GH_MIN_VERSION`.
|
|
772
|
+
|
|
773
|
+
The client's paginated reads use `gh api --paginate --slurp`, which an
|
|
774
|
+
older `gh` rejects. That would surface only as a conclusive read
|
|
775
|
+
failure at the merge gate (BLOCKED) or as a SKIP of the branch-rule
|
|
776
|
+
check, so the version is refused here, up front, with the minimum
|
|
777
|
+
named. A `--version` line the check cannot parse is a FAIL too: an
|
|
778
|
+
unknown version is not a known-good one.
|
|
779
|
+
"""
|
|
780
|
+
name = "gh available"
|
|
781
|
+
ok, detail = self._run_gh([gh, "--version"])
|
|
782
|
+
if not ok:
|
|
783
|
+
return CheckResult(name, False, detail)
|
|
784
|
+
minimum = ".".join(str(part) for part in GH_MIN_VERSION)
|
|
785
|
+
found = parse_gh_version(detail)
|
|
786
|
+
if found is None:
|
|
787
|
+
return CheckResult(
|
|
788
|
+
name, False, f"cannot read the gh version from {detail!r} (need >= {minimum})"
|
|
789
|
+
)
|
|
790
|
+
if found < GH_MIN_VERSION:
|
|
791
|
+
return CheckResult(
|
|
792
|
+
name,
|
|
793
|
+
False,
|
|
794
|
+
f"{detail}: gh >= {minimum} is required "
|
|
795
|
+
"(`gh api --paginate --slurp`, used to list branch rules and a PR's changed files)",
|
|
796
|
+
)
|
|
797
|
+
return CheckResult(name, True, detail)
|
|
798
|
+
|
|
799
|
+
def check_gh_auth(self, gh: str) -> CheckResult:
|
|
800
|
+
ok, detail = self._run_gh([gh, "auth", "status"])
|
|
801
|
+
return CheckResult("gh authenticated", ok, detail)
|
|
802
|
+
|
|
803
|
+
def check_feature_spec(self, spec_path: str) -> CheckResult:
|
|
804
|
+
"""Validate a feature specification path the way `local run` would."""
|
|
805
|
+
from .local_workspace import read_feature_spec
|
|
806
|
+
|
|
807
|
+
name = "feature specification"
|
|
808
|
+
ws = self._workspace()
|
|
809
|
+
try:
|
|
810
|
+
spec = read_feature_spec(ws, spec_path)
|
|
811
|
+
except Exception as exc:
|
|
812
|
+
return CheckResult(name, False, f"{spec_path}: {exc}")
|
|
813
|
+
return CheckResult(name, True, f"{spec.relative_path} (sha256 {spec.sha256[:16]}...)")
|
|
814
|
+
|
|
815
|
+
def check_validation_commands(self) -> CheckResult:
|
|
816
|
+
"""Report the configured local validation commands (never runs them)."""
|
|
817
|
+
cfg = self.config
|
|
818
|
+
cmds = cfg.local.validation_commands if cfg else []
|
|
819
|
+
name = "local validation commands"
|
|
820
|
+
if not cmds:
|
|
821
|
+
return CheckResult(name, True, "(none configured)", required=False)
|
|
822
|
+
return CheckResult(name, True, "; ".join(" ".join(argv) for argv in cmds))
|
|
823
|
+
|
|
824
|
+
def run_local(self, feature_spec_path: str | None = None) -> list[CheckResult]:
|
|
825
|
+
"""Checks a LOCAL run needs — and nothing that touches GitHub.
|
|
826
|
+
|
|
827
|
+
No `gh` binary, no `gh auth status`, no `origin` remote: a local run
|
|
828
|
+
makes zero GitHub calls, so requiring any of them here would be a
|
|
829
|
+
false failure on exactly the machine local mode exists for.
|
|
830
|
+
"""
|
|
831
|
+
# Which reviewer profiles a local run needs depends on its configured
|
|
832
|
+
# review bound, so the requirement is derived from the loaded config
|
|
833
|
+
# rather than from a fixed list (`local_required_profiles`). `doctor`
|
|
834
|
+
# therefore fails on a missing `review_round_6_plus` exactly when a run
|
|
835
|
+
# with this `local.max_fix_rounds` could actually ask for it.
|
|
836
|
+
results = [self.check_config(local_required_profiles)]
|
|
837
|
+
cfg = self.config
|
|
838
|
+
results.append(self._version_check("git available", ["git", "--version"]))
|
|
839
|
+
results.append(self.check_git_repo())
|
|
840
|
+
results.append(self.check_agent_environment())
|
|
841
|
+
results.append(self.check_launch_credentials())
|
|
842
|
+
results.extend(self._local_agent_checks(cfg))
|
|
843
|
+
results.append(self.check_local_state_dir())
|
|
844
|
+
results.append(self.check_validation_commands())
|
|
845
|
+
if feature_spec_path:
|
|
846
|
+
results.append(self.check_feature_spec(feature_spec_path))
|
|
847
|
+
results.extend(self.sandbox_checks(local=True))
|
|
848
|
+
return results
|
|
849
|
+
|
|
850
|
+
def sandbox_checks(self, local: bool) -> list[CheckResult]:
|
|
851
|
+
"""The outer sandbox's rows (ADR 0005 D12.4, D14.1; :mod:`autoforge.doctor_sandbox`)."""
|
|
852
|
+
from .doctor_sandbox import sandbox_checks
|
|
853
|
+
|
|
854
|
+
return sandbox_checks(self, local)
|
|
855
|
+
|
|
856
|
+
def _local_agent_checks(self, cfg: AutoForgeConfig | None) -> list[CheckResult]:
|
|
857
|
+
"""The agent CLIs a LOCAL run can actually reach.
|
|
858
|
+
|
|
859
|
+
Derived from `local_required_profiles`, not from every configured
|
|
860
|
+
profile: a machine only needs the binaries the *reachable* local
|
|
861
|
+
profiles name. A local configuration that routes everything through
|
|
862
|
+
OpenCode must not fail because an unrelated remote profile mentions
|
|
863
|
+
`claude`, and one that uses only `scripted` profiles must not require
|
|
864
|
+
an external CLI at all.
|
|
865
|
+
"""
|
|
866
|
+
if cfg is None:
|
|
867
|
+
return []
|
|
868
|
+
try:
|
|
869
|
+
reachable = local_required_profiles(cfg)
|
|
870
|
+
except ConfigurationError:
|
|
871
|
+
return []
|
|
872
|
+
return [
|
|
873
|
+
*self._agent_checks(cfg, reachable, "this local configuration"),
|
|
874
|
+
self.check_agent_limits(cfg, reachable),
|
|
875
|
+
self.check_loop_detection(cfg),
|
|
876
|
+
]
|
|
877
|
+
|
|
878
|
+
def check_agent_limits(self, cfg: AutoForgeConfig, reachable: list[str]) -> CheckResult:
|
|
879
|
+
"""Report the limits each reachable profile's agent runs under (#193).
|
|
880
|
+
|
|
881
|
+
Informational: an agent is killed after writing nothing for its idle
|
|
882
|
+
timeout, and once it has run for its max runtime when one is set.
|
|
883
|
+
Fails only for a profile a run would refuse to launch (a CLI that
|
|
884
|
+
reports no progress and has no wall-clock limit), which the config
|
|
885
|
+
check does not already cover for a profile reachable only after a
|
|
886
|
+
merge.
|
|
887
|
+
"""
|
|
888
|
+
name = "profile limits"
|
|
889
|
+
shown: list[str] = []
|
|
890
|
+
for profile_name in reachable:
|
|
891
|
+
profile = cfg.profiles.get(profile_name)
|
|
892
|
+
if profile is None:
|
|
893
|
+
continue
|
|
894
|
+
try:
|
|
895
|
+
limits = cfg.agent_limits(profile)
|
|
896
|
+
except ConfigurationError as exc:
|
|
897
|
+
return CheckResult(name, False, str(exc))
|
|
898
|
+
shown.append(f"{profile_name}: {limits.describe()}")
|
|
899
|
+
return CheckResult(name, True, "; ".join(shown) or "(no profile)", required=False)
|
|
900
|
+
|
|
901
|
+
@staticmethod
|
|
902
|
+
def check_loop_detection(cfg: AutoForgeConfig) -> CheckResult:
|
|
903
|
+
"""Report how every agent is watched for a loop (#194). Informational."""
|
|
904
|
+
return CheckResult(
|
|
905
|
+
"loop detection", True, cfg.execution.loop_detection.describe(), required=False
|
|
906
|
+
)
|
|
907
|
+
|
|
908
|
+
def _agent_checks(
|
|
909
|
+
self, cfg: AutoForgeConfig, reachable: list[str], scope: str
|
|
910
|
+
) -> list[CheckResult]:
|
|
911
|
+
"""One availability check per external CLI the ``reachable`` profiles name.
|
|
912
|
+
|
|
913
|
+
Each distinct command is checked once per provider that names it,
|
|
914
|
+
labelled with that provider's profiles: what a command must be
|
|
915
|
+
depends on the adapter that launches it, so a command that profiles of
|
|
916
|
+
two providers share (a wrapper) must pass both providers' checks.
|
|
917
|
+
`scripted` spawns the configured argv directly rather than an agent
|
|
918
|
+
CLI, so there is no version to query. A command a Pi profile names
|
|
919
|
+
gets Pi's version floor instead of a bare `--version`, and each Pi
|
|
920
|
+
profile a read-only credential probe; one an OpenCode profile names
|
|
921
|
+
gets OpenCode's floor.
|
|
922
|
+
"""
|
|
923
|
+
groups: dict[tuple[str, str], list[ProfileConfig]] = {}
|
|
924
|
+
for name in reachable:
|
|
925
|
+
profile = cfg.profiles.get(name)
|
|
926
|
+
if profile is None or profile.provider == "scripted":
|
|
927
|
+
continue
|
|
928
|
+
command = profile.command or DEFAULT_AGENT_COMMANDS.get(profile.provider, "")
|
|
929
|
+
if not command:
|
|
930
|
+
continue
|
|
931
|
+
groups.setdefault((command, profile.provider), []).append(profile)
|
|
932
|
+
if not groups:
|
|
933
|
+
return [
|
|
934
|
+
CheckResult(
|
|
935
|
+
"agent CLI available",
|
|
936
|
+
True,
|
|
937
|
+
f"(no external agent CLI is reachable for {scope})",
|
|
938
|
+
required=False,
|
|
939
|
+
)
|
|
940
|
+
]
|
|
941
|
+
results: list[CheckResult] = []
|
|
942
|
+
for (command, provider), profiles in groups.items():
|
|
943
|
+
label = f"agent '{command}' available ({', '.join(p.name for p in profiles)})"
|
|
944
|
+
if provider == "pi":
|
|
945
|
+
available = self.check_pi_version(label, profiles[0])
|
|
946
|
+
results.append(available)
|
|
947
|
+
results.extend(self.check_pi_auth(command, profiles, available.ok))
|
|
948
|
+
elif provider == "opencode":
|
|
949
|
+
results.append(self.check_opencode_version(label, command))
|
|
950
|
+
results.append(self.check_opencode_isolation(cfg, command, profiles))
|
|
951
|
+
elif provider in SDK_PROVIDERS:
|
|
952
|
+
results.append(self._version_check(label, [command, "--version"]))
|
|
953
|
+
results.append(self.check_sdk_launch(provider, profiles))
|
|
954
|
+
else:
|
|
955
|
+
results.append(self._version_check(label, [command, "--version"]))
|
|
956
|
+
if provider == "claude":
|
|
957
|
+
results.append(self.check_claude_isolation(command, profiles))
|
|
958
|
+
results.append(self.check_claude_configuration(cfg, command, profiles))
|
|
959
|
+
pi_profiles = [p for (_, provider), ps in groups.items() if provider == "pi" for p in ps]
|
|
960
|
+
if pi_profiles:
|
|
961
|
+
results.append(self.check_pi_outside_instructions(pi_profiles))
|
|
962
|
+
results.append(self.check_pi_orphan_containment(pi_profiles))
|
|
963
|
+
return results
|
|
964
|
+
|
|
965
|
+
def check_sdk_launch(self, provider: str, profiles: list[ProfileConfig]) -> CheckResult:
|
|
966
|
+
"""FAIL for an SDK provider's profiles: their policy validates (#149),
|
|
967
|
+
but the helper that would launch them has not landed (#151, #152), so
|
|
968
|
+
every launch is refused before it starts."""
|
|
969
|
+
name = f"provider {provider} can launch ({', '.join(p.name for p in profiles)})"
|
|
970
|
+
try:
|
|
971
|
+
provider_for(profiles[0]).integration_preflight(None)
|
|
972
|
+
except ConfigurationError as exc:
|
|
973
|
+
return CheckResult(name, False, redact(str(exc)))
|
|
974
|
+
return CheckResult(name, True, "its helper is available")
|
|
975
|
+
|
|
976
|
+
def check_pi_outside_instructions(self, profiles: list[ProfileConfig]) -> CheckResult:
|
|
977
|
+
"""WARN when Pi would load instruction files from outside the checkout.
|
|
978
|
+
|
|
979
|
+
Pi always appends its agent directory's ``SYSTEM.md`` /
|
|
980
|
+
``APPEND_SYSTEM.md`` (no flag turns them off), and, unless every Pi
|
|
981
|
+
profile sets ``context_files: false``, the agent directory's context
|
|
982
|
+
file and one from each directory above the checkout. None of them is
|
|
983
|
+
project data under review, and none is visible in the PR. Only
|
|
984
|
+
whether each path exists is checked; no file is read.
|
|
985
|
+
"""
|
|
986
|
+
names = ", ".join(p.name for p in profiles)
|
|
987
|
+
name = f"pi loads no instructions from outside the checkout ({names})"
|
|
988
|
+
env = os.environ.get("PI_CODING_AGENT_DIR", "") if self._pi_passes_agent_dir() else ""
|
|
989
|
+
agent_dir = Path(env or PI_DEFAULT_AGENT_DIR).expanduser()
|
|
990
|
+
found = [agent_dir / f for f in PI_SYSTEM_PROMPT_FILES if (agent_dir / f).is_file()]
|
|
991
|
+
if any(PiProvider.context_files(p) for p in profiles):
|
|
992
|
+
for directory in [agent_dir, *Path(self.cwd).resolve().parents]:
|
|
993
|
+
first = next((f for f in PI_CONTEXT_FILES if (directory / f).is_file()), None)
|
|
994
|
+
if first is not None:
|
|
995
|
+
found.append(directory / first)
|
|
996
|
+
if not found:
|
|
997
|
+
return CheckResult(name, True, "(none found)", required=False)
|
|
998
|
+
return CheckResult(
|
|
999
|
+
name,
|
|
1000
|
+
False,
|
|
1001
|
+
f"every Pi run will also follow {', '.join(str(p) for p in found)}; remove "
|
|
1002
|
+
"or move them, or set options.context_files: false for context files "
|
|
1003
|
+
"(docs/pi-policy.md)",
|
|
1004
|
+
required=False,
|
|
1005
|
+
)
|
|
1006
|
+
|
|
1007
|
+
def check_pi_orphan_containment(self, profiles: list[ProfileConfig]) -> CheckResult:
|
|
1008
|
+
"""WARN where Pi's detached tool processes cannot be contained.
|
|
1009
|
+
|
|
1010
|
+
Pi's `bash` tool starts every command detached, so what a command
|
|
1011
|
+
backgrounds outlives Pi; only a child subreaper (Linux) lets the
|
|
1012
|
+
executor find and kill it (ADR 0002 §4b). The row passes only where
|
|
1013
|
+
the controller can make itself one, as each contained launch does,
|
|
1014
|
+
not merely read the setting. Without one, unattended Pi use is not
|
|
1015
|
+
supported (docs/pi.md#unattended-use, #135). Never a FAIL: an
|
|
1016
|
+
attended run that reads each result's leftovers sentence is still
|
|
1017
|
+
possible.
|
|
1018
|
+
"""
|
|
1019
|
+
name = f"pi's detached tool processes are contained ({', '.join(p.name for p in profiles)})"
|
|
1020
|
+
if orphan_containment_available():
|
|
1021
|
+
return CheckResult(
|
|
1022
|
+
name,
|
|
1023
|
+
True,
|
|
1024
|
+
"a process a Pi tool command leaves running is killed when the launch ends "
|
|
1025
|
+
"(Linux child subreaper, ADR 0002 §4b)",
|
|
1026
|
+
required=False,
|
|
1027
|
+
)
|
|
1028
|
+
return CheckResult(
|
|
1029
|
+
name,
|
|
1030
|
+
False,
|
|
1031
|
+
"the controller cannot make itself a child subreaper here (not Linux, or a "
|
|
1032
|
+
"security policy denies it): a process a Pi tool command leaves "
|
|
1033
|
+
"running can outlive the launch, and each result says so (orphans_unchecked); "
|
|
1034
|
+
"do not run Pi unattended here (docs/pi.md#unattended-use)",
|
|
1035
|
+
required=False,
|
|
1036
|
+
)
|
|
1037
|
+
|
|
1038
|
+
def _pi_passes_agent_dir(self) -> bool:
|
|
1039
|
+
"""Whether the launch allow-list hands ``PI_CODING_AGENT_DIR`` to Pi."""
|
|
1040
|
+
return "PI_CODING_AGENT_DIR" in self._pi_allowlist()
|
|
1041
|
+
|
|
1042
|
+
def _pi_allowlist(self) -> tuple[str, ...]:
|
|
1043
|
+
"""The environment a Pi agent is launched with, so the probes see what it would."""
|
|
1044
|
+
cfg = self.config if self.config is not None else AutoForgeConfig()
|
|
1045
|
+
return PiProvider().launch_allowlist(cfg.execution.environment_names())
|
|
1046
|
+
|
|
1047
|
+
def _pi_probe(self, argv: list[str]) -> ExecutionResult | str:
|
|
1048
|
+
"""Run one Pi probe as the launch would (its allow-list and the variables
|
|
1049
|
+
the controller sets, #165); the result, or why it did not run."""
|
|
1050
|
+
try:
|
|
1051
|
+
with isolated_overrides(LaunchIdentity()) as overrides:
|
|
1052
|
+
res = self._runner(
|
|
1053
|
+
ExecutionRequest(
|
|
1054
|
+
command=argv,
|
|
1055
|
+
cwd=self.cwd,
|
|
1056
|
+
timeout_seconds=self.timeout,
|
|
1057
|
+
env_allowlist=self._pi_allowlist(),
|
|
1058
|
+
env=overrides,
|
|
1059
|
+
)
|
|
1060
|
+
)
|
|
1061
|
+
except Exception as exc: # spawn failure / missing binary
|
|
1062
|
+
return f"{type(exc).__name__}: {exc}"
|
|
1063
|
+
return "timed out" if res.timed_out else res
|
|
1064
|
+
|
|
1065
|
+
def check_pi_version(self, name: str, profile: ProfileConfig) -> CheckResult:
|
|
1066
|
+
"""`pi` runs and is at least :data:`PI_MIN_VERSION` (ADR 0003 §2.8).
|
|
1067
|
+
|
|
1068
|
+
A version line the check cannot parse is a FAIL, as for `gh`: an
|
|
1069
|
+
unknown version is not a known-good one. Pi's output is never quoted:
|
|
1070
|
+
a detail names only the parsed release number, so whatever a wrapper
|
|
1071
|
+
prints there (or appends as a pre-release suffix) stays out of it.
|
|
1072
|
+
"""
|
|
1073
|
+
argv = PiProvider().version_command(profile)
|
|
1074
|
+
res = self._pi_probe(argv)
|
|
1075
|
+
if isinstance(res, str):
|
|
1076
|
+
return CheckResult(name, False, res)
|
|
1077
|
+
if res.exit_code != 0:
|
|
1078
|
+
return CheckResult(
|
|
1079
|
+
name,
|
|
1080
|
+
False,
|
|
1081
|
+
f"`{' '.join(argv)}` exited {res.exit_code} (output not shown; run it yourself)",
|
|
1082
|
+
)
|
|
1083
|
+
minimum = ".".join(str(part) for part in PI_MIN_VERSION)
|
|
1084
|
+
text = (res.stdout or "").strip()
|
|
1085
|
+
found = parse_pi_version(text)
|
|
1086
|
+
if found is None:
|
|
1087
|
+
return CheckResult(
|
|
1088
|
+
name,
|
|
1089
|
+
False,
|
|
1090
|
+
f"cannot read the pi version from `{' '.join(argv)}` (output not shown; "
|
|
1091
|
+
f"need >= {minimum})",
|
|
1092
|
+
)
|
|
1093
|
+
release, _, suffix = text.partition("-")
|
|
1094
|
+
shown = f"pi {release} (pre-release)" if suffix else f"pi {release}"
|
|
1095
|
+
if found < PI_MIN_VERSION:
|
|
1096
|
+
return CheckResult(
|
|
1097
|
+
name,
|
|
1098
|
+
False,
|
|
1099
|
+
f"{shown}: pi >= {minimum} is required (the RPC and auth contract "
|
|
1100
|
+
"AutoForge relies on, ADR 0003)",
|
|
1101
|
+
)
|
|
1102
|
+
return CheckResult(name, True, shown)
|
|
1103
|
+
|
|
1104
|
+
def check_claude_isolation(self, command: str, profiles: list[ProfileConfig]) -> CheckResult:
|
|
1105
|
+
"""`claude --help` lists every flag a launch keeps integrations off with (#165).
|
|
1106
|
+
|
|
1107
|
+
Every Claude Code launch passes :data:`CLAUDE_ISOLATION_FLAGS` so that
|
|
1108
|
+
no hook, plugin or MCP server of the operator's or the repository's
|
|
1109
|
+
configuration runs and no project or local settings apply; a CLI
|
|
1110
|
+
without one would refuse every launch. The help text is never quoted.
|
|
1111
|
+
"""
|
|
1112
|
+
name = f"agent '{command}' keeps integrations off ({', '.join(p.name for p in profiles)})"
|
|
1113
|
+
argv = [command, "--help"]
|
|
1114
|
+
try:
|
|
1115
|
+
res = self._runner(
|
|
1116
|
+
ExecutionRequest(command=argv, cwd=self.cwd, timeout_seconds=self.timeout)
|
|
1117
|
+
)
|
|
1118
|
+
except Exception as exc: # spawn failure / missing binary
|
|
1119
|
+
return CheckResult(name, False, f"{type(exc).__name__}: {exc}")
|
|
1120
|
+
if res.timed_out:
|
|
1121
|
+
return CheckResult(name, False, "timed out")
|
|
1122
|
+
if res.exit_code != 0:
|
|
1123
|
+
return CheckResult(
|
|
1124
|
+
name,
|
|
1125
|
+
False,
|
|
1126
|
+
f"`{' '.join(argv)}` exited {res.exit_code} (output not shown; run it yourself)",
|
|
1127
|
+
)
|
|
1128
|
+
listed = set(re.findall(r"(?<![\w-])--[a-z][a-z-]*", res.stdout or ""))
|
|
1129
|
+
# A flag is offered by its name; its value (``=user``) is the launch's.
|
|
1130
|
+
flags = [flag.partition("=")[0] for flag in CLAUDE_ISOLATION_FLAGS]
|
|
1131
|
+
missing = [flag for flag in flags if flag not in listed]
|
|
1132
|
+
if missing:
|
|
1133
|
+
return CheckResult(
|
|
1134
|
+
name,
|
|
1135
|
+
False,
|
|
1136
|
+
f"`{' '.join(argv)}` does not list {', '.join(missing)}, which every launch "
|
|
1137
|
+
"passes so that no hook, plugin or MCP server of the operator's or the "
|
|
1138
|
+
"repository's configuration runs and no setting of the repository's "
|
|
1139
|
+
"applies; update Claude Code",
|
|
1140
|
+
)
|
|
1141
|
+
return CheckResult(
|
|
1142
|
+
name,
|
|
1143
|
+
True,
|
|
1144
|
+
f"launched with {' '.join(CLAUDE_ISOLATION_FLAGS)}: no hook, plugin or MCP server "
|
|
1145
|
+
"of the operator's or the repository's configuration runs, and no setting of "
|
|
1146
|
+
"the repository's applies",
|
|
1147
|
+
)
|
|
1148
|
+
|
|
1149
|
+
def check_claude_configuration(
|
|
1150
|
+
self, cfg: AutoForgeConfig, command: str, profiles: list[ProfileConfig]
|
|
1151
|
+
) -> CheckResult:
|
|
1152
|
+
"""The launch's own refusal of Claude Code configuration that sets a
|
|
1153
|
+
variable over its isolation (#165).
|
|
1154
|
+
|
|
1155
|
+
Run under the agent launch's allow-list, so the configuration files
|
|
1156
|
+
looked at are the launch's; only paths and variable names are
|
|
1157
|
+
reported, never a value.
|
|
1158
|
+
"""
|
|
1159
|
+
name = (
|
|
1160
|
+
f"agent '{command}' configuration keeps the launch environment "
|
|
1161
|
+
f"({', '.join(p.name for p in profiles)})"
|
|
1162
|
+
)
|
|
1163
|
+
try:
|
|
1164
|
+
ClaudeCodeProvider().integration_preflight(cfg.execution.environment_names())
|
|
1165
|
+
except (ConfigurationError, ExecutionError) as exc:
|
|
1166
|
+
return CheckResult(name, False, redact(str(exc)))
|
|
1167
|
+
return CheckResult(
|
|
1168
|
+
name,
|
|
1169
|
+
True,
|
|
1170
|
+
"no variable of Claude Code's settings or global configuration replaces one "
|
|
1171
|
+
"AutoForge sets; its Bash tool runs no shell startup file's function or alias",
|
|
1172
|
+
)
|
|
1173
|
+
|
|
1174
|
+
def check_opencode_isolation(
|
|
1175
|
+
self, cfg: AutoForgeConfig, command: str, profiles: list[ProfileConfig]
|
|
1176
|
+
) -> CheckResult:
|
|
1177
|
+
"""The launch's own refusal of an organization OpenCode would load (#165).
|
|
1178
|
+
|
|
1179
|
+
Run under the agent launch's allow-list, so the data directory and
|
|
1180
|
+
database looked at are the launch's; only paths and counts are
|
|
1181
|
+
reported, never an origin or a credential.
|
|
1182
|
+
"""
|
|
1183
|
+
name = (
|
|
1184
|
+
f"agent '{command}' loads no organization configuration "
|
|
1185
|
+
f"({', '.join(p.name for p in profiles)})"
|
|
1186
|
+
)
|
|
1187
|
+
provider = OpenCodeProvider()
|
|
1188
|
+
allowlist = cfg.execution.environment_names()
|
|
1189
|
+
try:
|
|
1190
|
+
provider.integration_preflight(allowlist)
|
|
1191
|
+
data_dir = provider.data_dir(allowlist)
|
|
1192
|
+
except (ConfigurationError, ExecutionError) as exc:
|
|
1193
|
+
return CheckResult(name, False, redact(str(exc)))
|
|
1194
|
+
return CheckResult(
|
|
1195
|
+
name,
|
|
1196
|
+
True,
|
|
1197
|
+
f"no organization connected to OpenCode (data directory {data_dir}); a launch "
|
|
1198
|
+
"reads no configuration file of the operator's or the repository's",
|
|
1199
|
+
)
|
|
1200
|
+
|
|
1201
|
+
def check_opencode_version(self, name: str, command: str) -> CheckResult:
|
|
1202
|
+
"""`opencode` runs and is at least :data:`OPENCODE_MIN_VERSION` (#186).
|
|
1203
|
+
|
|
1204
|
+
The adapter speaks v2's argv (``--standalone``, ``-m model#variant``)
|
|
1205
|
+
and hands the prompt over on stdin; 1.x rejects that argv. As for Pi,
|
|
1206
|
+
an unreadable version is a FAIL and the CLI's output is never quoted.
|
|
1207
|
+
"""
|
|
1208
|
+
argv = [command, "--version"]
|
|
1209
|
+
try:
|
|
1210
|
+
res = self._runner(
|
|
1211
|
+
ExecutionRequest(command=argv, cwd=self.cwd, timeout_seconds=self.timeout)
|
|
1212
|
+
)
|
|
1213
|
+
except Exception as exc: # spawn failure / missing binary
|
|
1214
|
+
return CheckResult(name, False, f"{type(exc).__name__}: {exc}")
|
|
1215
|
+
if res.timed_out:
|
|
1216
|
+
return CheckResult(name, False, "timed out")
|
|
1217
|
+
if res.exit_code != 0:
|
|
1218
|
+
return CheckResult(
|
|
1219
|
+
name,
|
|
1220
|
+
False,
|
|
1221
|
+
f"`{' '.join(argv)}` exited {res.exit_code} (output not shown; run it yourself)",
|
|
1222
|
+
)
|
|
1223
|
+
minimum = ".".join(str(part) for part in OPENCODE_MIN_VERSION)
|
|
1224
|
+
text = (res.stdout or "").strip()
|
|
1225
|
+
found = parse_opencode_version(text)
|
|
1226
|
+
if found is None:
|
|
1227
|
+
return CheckResult(
|
|
1228
|
+
name,
|
|
1229
|
+
False,
|
|
1230
|
+
f"cannot read the opencode version from `{' '.join(argv)}` (output not "
|
|
1231
|
+
f"shown; need >= {minimum})",
|
|
1232
|
+
)
|
|
1233
|
+
# The text matched the version pattern whole, so its only `-` opens a
|
|
1234
|
+
# pre-release suffix.
|
|
1235
|
+
head, _, suffix = text.partition("-")
|
|
1236
|
+
release = head.removeprefix("opencode ").removeprefix("v")
|
|
1237
|
+
shown = f"opencode {release} (pre-release)" if suffix else f"opencode {release}"
|
|
1238
|
+
if found < OPENCODE_MIN_VERSION:
|
|
1239
|
+
return CheckResult(
|
|
1240
|
+
name,
|
|
1241
|
+
False,
|
|
1242
|
+
f"{shown}: opencode >= {minimum} is required (the argv and stdin "
|
|
1243
|
+
"delivery the adapter speaks, #186)",
|
|
1244
|
+
)
|
|
1245
|
+
return CheckResult(name, True, shown)
|
|
1246
|
+
|
|
1247
|
+
def check_pi_auth(
|
|
1248
|
+
self, command: str, profiles: list[ProfileConfig], available: bool
|
|
1249
|
+
) -> list[CheckResult]:
|
|
1250
|
+
"""``pi auth check --no-refresh`` for each distinct (model, OAuth policy) pair.
|
|
1251
|
+
|
|
1252
|
+
Run under the agent launch's allow-list, so a key only the
|
|
1253
|
+
operator's shell holds (an `OPENAI_API_KEY` the launch would not
|
|
1254
|
+
see) cannot make it report "ready". Pi's stdout and stderr are never
|
|
1255
|
+
quoted: the verdict names the four fields of its JSON result only.
|
|
1256
|
+
"""
|
|
1257
|
+
provider = PiProvider()
|
|
1258
|
+
groups: dict[tuple[str, bool], list[ProfileConfig]] = {}
|
|
1259
|
+
for profile in profiles:
|
|
1260
|
+
groups.setdefault((profile.model, provider.require_oauth(profile)), []).append(profile)
|
|
1261
|
+
results: list[CheckResult] = []
|
|
1262
|
+
for members in groups.values():
|
|
1263
|
+
profile = members[0]
|
|
1264
|
+
name = f"agent '{command}' auth ready ({', '.join(p.name for p in members)})"
|
|
1265
|
+
if not available:
|
|
1266
|
+
results.append(CheckResult.skip(name, f"(agent '{command}' is not available)"))
|
|
1267
|
+
continue
|
|
1268
|
+
results.append(self._pi_auth(name, provider, profile))
|
|
1269
|
+
return results
|
|
1270
|
+
|
|
1271
|
+
def _pi_auth(self, name: str, provider: PiProvider, profile: ProfileConfig) -> CheckResult:
|
|
1272
|
+
refused = provider.api_key_problem(profile, self._pi_allowlist())
|
|
1273
|
+
if refused:
|
|
1274
|
+
return CheckResult(name, False, refused)
|
|
1275
|
+
res = self._pi_probe(provider.auth_check_command(profile))
|
|
1276
|
+
if isinstance(res, str):
|
|
1277
|
+
return CheckResult(name, False, res)
|
|
1278
|
+
ok, detail = provider.auth_verdict(profile, res.exit_code, res.stdout or "")
|
|
1279
|
+
return CheckResult(name, ok, f"{profile.model}: {detail}")
|
|
1280
|
+
|
|
1281
|
+
def _workspace(self):
|
|
1282
|
+
"""A LocalWorkspace over the doctor's cwd, configured like a real run."""
|
|
1283
|
+
from .local_workspace import LocalWorkspace
|
|
1284
|
+
|
|
1285
|
+
cfg = self.config
|
|
1286
|
+
local = cfg.local if cfg else None
|
|
1287
|
+
return LocalWorkspace(
|
|
1288
|
+
workdir=self.cwd,
|
|
1289
|
+
runner=self._runner,
|
|
1290
|
+
exclude=local.exclude if local else (),
|
|
1291
|
+
max_entries=local.max_workspace_entries if local else DEFAULT_MAX_ENTRIES,
|
|
1292
|
+
max_bytes=local.max_workspace_bytes if local else DEFAULT_MAX_BYTES,
|
|
1293
|
+
)
|
|
1294
|
+
|
|
1295
|
+
def check_local_state_dir(self) -> CheckResult:
|
|
1296
|
+
"""Where this run would keep its state — outside the reviewed tree, writable.
|
|
1297
|
+
|
|
1298
|
+
This replaces the generic `check_state_dir` for local mode rather than
|
|
1299
|
+
joining it: the generic check would create `.autoforge/` inside the
|
|
1300
|
+
working tree, and in LOCAL mode that directory is part of what gets
|
|
1301
|
+
fingerprinted. A read-only diagnostic must not change what a run would
|
|
1302
|
+
review.
|
|
1303
|
+
"""
|
|
1304
|
+
from .engine import local_state_paths
|
|
1305
|
+
|
|
1306
|
+
cfg = self.config
|
|
1307
|
+
name = "state dir outside the reviewed working tree"
|
|
1308
|
+
ws = self._workspace()
|
|
1309
|
+
try:
|
|
1310
|
+
paths = local_state_paths(
|
|
1311
|
+
ws, explicit=self.state_dir, configured=cfg.state_dir if cfg else None
|
|
1312
|
+
)
|
|
1313
|
+
ws.check_state_dir_location(paths.state_dir)
|
|
1314
|
+
except Exception as exc:
|
|
1315
|
+
return CheckResult(name, False, str(exc))
|
|
1316
|
+
# The probe goes through the same capability a run would hold
|
|
1317
|
+
# (``StatePaths.open_root``: descriptor-relative from the git dir,
|
|
1318
|
+
# refusing a symbolic link at any component), so the doctor cannot be
|
|
1319
|
+
# made to write where the run would refuse to -- a symlinked
|
|
1320
|
+
# ``<git dir>/autoforge`` fails this check instead of being followed.
|
|
1321
|
+
writable = self._writable(lambda: paths.open_root(create=True))
|
|
1322
|
+
if writable is not None:
|
|
1323
|
+
return CheckResult(name, False, f"{paths.state_dir}: {writable}")
|
|
1324
|
+
return CheckResult(name, True, str(paths.state_dir))
|
|
1325
|
+
|
|
1326
|
+
@staticmethod
|
|
1327
|
+
def _writable(open_root: Callable[[], SafeRoot]) -> str | None:
|
|
1328
|
+
"""None when a probe file can be created through ``open_root()``, else why not.
|
|
1329
|
+
|
|
1330
|
+
The probe is a controller write like any other: it is created and
|
|
1331
|
+
removed through the opened root, never by pathname, so the check
|
|
1332
|
+
exercises exactly the boundary a run's writes would go through. A
|
|
1333
|
+
filesystem without hard links fails it as it fails every run's
|
|
1334
|
+
run-log probe, and the reason names that requirement (#120).
|
|
1335
|
+
"""
|
|
1336
|
+
probe = f".doctor-probe-{secrets.token_hex(8)}"
|
|
1337
|
+
try:
|
|
1338
|
+
with open_root() as root:
|
|
1339
|
+
root.create_exclusive(probe, b"")
|
|
1340
|
+
root.unlink(probe)
|
|
1341
|
+
except HardLinksUnavailable as exc:
|
|
1342
|
+
return f"{exc}. {STATE_DIR_NEEDS_HARD_LINKS}"
|
|
1343
|
+
except (OSError, StateError) as exc:
|
|
1344
|
+
return str(exc)
|
|
1345
|
+
return None
|
|
1346
|
+
|
|
1347
|
+
def _remote_agent_checks(self, cfg: AutoForgeConfig | None) -> list[CheckResult]:
|
|
1348
|
+
"""The agent CLIs a REMOTE run can reach: `REQUIRED_PROFILES`, plus
|
|
1349
|
+
`update_epic` (reachable after a merge) when it is configured.
|
|
1350
|
+
|
|
1351
|
+
Nothing is checked when the config failed: that row already fails
|
|
1352
|
+
`doctor`, and a guess at the routing would check the wrong binaries.
|
|
1353
|
+
"""
|
|
1354
|
+
if cfg is None:
|
|
1355
|
+
return []
|
|
1356
|
+
reachable = REQUIRED_PROFILES + [
|
|
1357
|
+
name for name in REMOTE_POST_MERGE_PROFILES if name in cfg.profiles
|
|
1358
|
+
]
|
|
1359
|
+
return [
|
|
1360
|
+
*self._agent_checks(cfg, reachable, "this configuration"),
|
|
1361
|
+
self.check_agent_limits(cfg, reachable),
|
|
1362
|
+
self.check_loop_detection(cfg),
|
|
1363
|
+
]
|
|
1364
|
+
|
|
1365
|
+
# -- the parallel EPIC scheduler (ADR 0008, #183) -------------------------------
|
|
1366
|
+
def check_scheduler_settings(self) -> CheckResult:
|
|
1367
|
+
"""``scheduler.*`` as a scheduler would run with them; the loader enforces the bounds."""
|
|
1368
|
+
name = "scheduler settings"
|
|
1369
|
+
cfg = self.config
|
|
1370
|
+
if cfg is None:
|
|
1371
|
+
return CheckResult.skip(name, "(config check failed)")
|
|
1372
|
+
settings = cfg.scheduler
|
|
1373
|
+
values = (
|
|
1374
|
+
("max_parallel", settings.max_parallel, MAX_PARALLEL_BOUNDS),
|
|
1375
|
+
("poll_interval_seconds", settings.poll_interval_seconds, POLL_INTERVAL_BOUNDS),
|
|
1376
|
+
("stop_grace_seconds", settings.stop_grace_seconds, STOP_GRACE_BOUNDS),
|
|
1377
|
+
)
|
|
1378
|
+
outside = [
|
|
1379
|
+
f"{key} {value}" for key, value, (low, high) in values if not low <= value <= high
|
|
1380
|
+
]
|
|
1381
|
+
detail = ", ".join(f"{key} {value} ({low}-{high})" for key, value, (low, high) in values)
|
|
1382
|
+
if outside:
|
|
1383
|
+
return CheckResult(name, False, f"out of bounds: {', '.join(outside)}; {detail}")
|
|
1384
|
+
return CheckResult(name, True, detail)
|
|
1385
|
+
|
|
1386
|
+
def check_scheduler_root(self) -> CheckResult:
|
|
1387
|
+
"""The coordination root's filesystem and the scheduler root's entry; read-only."""
|
|
1388
|
+
name = "scheduler coordination root"
|
|
1389
|
+
try:
|
|
1390
|
+
root = repository_lock_path(self.cwd, runner=self._runner).parent.parent
|
|
1391
|
+
check_local_lock_filesystem(root, what=f"the coordination root {root}")
|
|
1392
|
+
except LockError as exc:
|
|
1393
|
+
return CheckResult(name, False, str(exc))
|
|
1394
|
+
problems = []
|
|
1395
|
+
path = Path(root)
|
|
1396
|
+
for component in (AUTOFORGE_DIRNAME, SCHEDULER_DIRNAME):
|
|
1397
|
+
path = path / component
|
|
1398
|
+
try:
|
|
1399
|
+
st = os.lstat(path)
|
|
1400
|
+
except FileNotFoundError:
|
|
1401
|
+
break
|
|
1402
|
+
except OSError as exc:
|
|
1403
|
+
problems.append(f"{path} cannot be read ({exc})")
|
|
1404
|
+
break
|
|
1405
|
+
if stat.S_ISLNK(st.st_mode) or not stat.S_ISDIR(st.st_mode):
|
|
1406
|
+
problems.append(f"{path} is not a directory (a link or another entry)")
|
|
1407
|
+
break
|
|
1408
|
+
if st.st_uid != os.getuid():
|
|
1409
|
+
problems.append(f"{path} is owned by uid {st.st_uid}, not this user")
|
|
1410
|
+
if st.st_mode & stat.S_IWOTH:
|
|
1411
|
+
problems.append(f"{path} is world-writable")
|
|
1412
|
+
if problems:
|
|
1413
|
+
return CheckResult(name, False, "; ".join(problems))
|
|
1414
|
+
scheduler = Path(root) / AUTOFORGE_DIRNAME / SCHEDULER_DIRNAME
|
|
1415
|
+
found = (
|
|
1416
|
+
"exists" if os.path.lexists(scheduler) else "is absent (the first 'epic run' makes it)"
|
|
1417
|
+
)
|
|
1418
|
+
return CheckResult(
|
|
1419
|
+
name, True, f"{root}: local flock; the scheduler root {scheduler} {found}"
|
|
1420
|
+
)
|
|
1421
|
+
|
|
1422
|
+
def check_epic_graph(self, gh: str) -> CheckResult:
|
|
1423
|
+
"""Whether ``gh`` reads the EPIC's graph: #179's snapshot, read-only."""
|
|
1424
|
+
name = "EPIC sub-issues and dependencies readable"
|
|
1425
|
+
if self.epic_url is None:
|
|
1426
|
+
return CheckResult.skip(name, "(no --epic)")
|
|
1427
|
+
try:
|
|
1428
|
+
epic = parse_issue_url(self.epic_url)
|
|
1429
|
+
except ConfigurationError as exc:
|
|
1430
|
+
return CheckResult(name, False, str(exc))
|
|
1431
|
+
if self.repo is not None and self.repo.lower() != epic.repository.lower():
|
|
1432
|
+
return CheckResult(
|
|
1433
|
+
name,
|
|
1434
|
+
False,
|
|
1435
|
+
f"the EPIC is in {epic.repository}, and this checkout's origin is {self.repo}",
|
|
1436
|
+
)
|
|
1437
|
+
client = GitHubClient(gh_command=gh, timeout_seconds=self.timeout, runner=self._runner)
|
|
1438
|
+
try:
|
|
1439
|
+
repository_id = client.get_repository_id(epic.repository)
|
|
1440
|
+
snapshot = build_epic_snapshot(client, epic.canonical, repository_id=repository_id)
|
|
1441
|
+
except GitHubUnavailableError as exc:
|
|
1442
|
+
return CheckResult.skip(name, f"GitHub could not be read (transient): {exc}")
|
|
1443
|
+
except SnapshotRefused as exc:
|
|
1444
|
+
if exc.transient:
|
|
1445
|
+
return CheckResult.skip(name, f"GitHub could not be read (transient): {exc}")
|
|
1446
|
+
return CheckResult(name, False, str(exc))
|
|
1447
|
+
except GitHubError as exc:
|
|
1448
|
+
return CheckResult(name, False, str(exc))
|
|
1449
|
+
ready = ready_set(evaluate(snapshot))
|
|
1450
|
+
return CheckResult(
|
|
1451
|
+
name,
|
|
1452
|
+
True,
|
|
1453
|
+
f"EPIC #{epic.number}: {len(snapshot.children)} sub-issues and "
|
|
1454
|
+
f"{len(snapshot.edges)} blocked-by links read; {len(ready)} ready by the graph "
|
|
1455
|
+
f"alone (snapshot {snapshot.digest[:12]})",
|
|
1456
|
+
)
|
|
1457
|
+
|
|
1458
|
+
def scheduler_checks(self, gh: str) -> list[CheckResult]:
|
|
1459
|
+
"""The scheduler rows: its settings always, the rest for ``doctor --epic``."""
|
|
1460
|
+
results = [self.check_scheduler_settings()]
|
|
1461
|
+
if self.epic_url is not None:
|
|
1462
|
+
results.append(self.check_scheduler_root())
|
|
1463
|
+
results.append(self.check_epic_graph(gh))
|
|
1464
|
+
return results
|
|
1465
|
+
|
|
1466
|
+
def run_all(self) -> list[CheckResult]:
|
|
1467
|
+
results = [
|
|
1468
|
+
self.check_config(),
|
|
1469
|
+
self.check_merge_gate(),
|
|
1470
|
+
self.check_premerge_verification(),
|
|
1471
|
+
self.check_candidate_validation(),
|
|
1472
|
+
]
|
|
1473
|
+
cfg = self.config
|
|
1474
|
+
gh = cfg.github.command if cfg else "gh"
|
|
1475
|
+
results.append(self._version_check("git available", ["git", "--version"]))
|
|
1476
|
+
results.append(self.check_gh_version(gh))
|
|
1477
|
+
results.append(self.check_gh_auth(gh))
|
|
1478
|
+
results.extend(self._remote_agent_checks(cfg))
|
|
1479
|
+
results.append(self.check_git_repo())
|
|
1480
|
+
results.append(self.check_remote())
|
|
1481
|
+
results.append(self.check_agent_environment())
|
|
1482
|
+
results.append(self.check_launch_credentials())
|
|
1483
|
+
results.append(self.check_required_checks(gh))
|
|
1484
|
+
results.append(self.check_checks_read(gh))
|
|
1485
|
+
results.append(self.check_state_dir())
|
|
1486
|
+
results.extend(self.scheduler_checks(gh))
|
|
1487
|
+
results.extend(self.sandbox_checks(local=False))
|
|
1488
|
+
return results
|
|
1489
|
+
|
|
1490
|
+
|
|
1491
|
+
# The way out of an unverifiable required-check row, by cause (#289). A plan
|
|
1492
|
+
# limitation is never sent to the token's permissions.
|
|
1493
|
+
_PLAN_REMEDY = "make the repository public or move it to a plan that offers rules"
|
|
1494
|
+
_READ_RULES_REMEDY = "run doctor with a login that may read the branch rules"
|
|
1495
|
+
|
|
1496
|
+
|
|
1497
|
+
def _plan_limited_text(repo: str, exc: GitHubError) -> str:
|
|
1498
|
+
"""What doctor says about GitHub's plan-entitlement 403 (#289): the plan, never the token."""
|
|
1499
|
+
return (
|
|
1500
|
+
f"GitHub's plan for {repo} does not offer rulesets or branch protection on this "
|
|
1501
|
+
"repository (GitHub Free offers neither on a private repository), so nothing can "
|
|
1502
|
+
"require a check on its default branch; this is the plan, not the token, and no "
|
|
1503
|
+
"token permission changes it: make the repository public or move it to a plan "
|
|
1504
|
+
f"that offers them (GitHub Pro, Team or Enterprise). GitHub's answer: {exc}"
|
|
1505
|
+
)
|
|
1506
|
+
|
|
1507
|
+
|
|
1508
|
+
def _pr_read_refused(exc: GitHubError) -> bool:
|
|
1509
|
+
"""Whether GitHub refused this login the pull request itself (#291).
|
|
1510
|
+
|
|
1511
|
+
Classified by the client's types: only a plain conclusive
|
|
1512
|
+
:class:`GitHubError` can be such a refusal. Every typed answer says
|
|
1513
|
+
something else (transient, not found, a refused checks read, another
|
|
1514
|
+
PR, a plan limitation, which is about the plan, not the login).
|
|
1515
|
+
"""
|
|
1516
|
+
return type(exc) is GitHubError and is_access_denied_gh_failure(str(exc))
|
|
1517
|
+
|
|
1518
|
+
|
|
1519
|
+
def _missing_contexts(contexts: list[str], expected: list[str]) -> list[str]:
|
|
1520
|
+
"""The problems an expected-context list has with the required contexts (often none)."""
|
|
1521
|
+
if not contexts:
|
|
1522
|
+
return ["the rule names no status check context at all"]
|
|
1523
|
+
missing = [context for context in expected if context not in contexts]
|
|
1524
|
+
if missing:
|
|
1525
|
+
return [f"required contexts do not include {', '.join(repr(m) for m in missing)}"]
|
|
1526
|
+
return []
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
def run_doctor(
|
|
1530
|
+
config_path: str | None = None,
|
|
1531
|
+
state_dir: str | None = None,
|
|
1532
|
+
cwd: str | None = None,
|
|
1533
|
+
runner: Runner | None = None,
|
|
1534
|
+
epic_url: str | None = None,
|
|
1535
|
+
) -> list[CheckResult]:
|
|
1536
|
+
return Doctor(
|
|
1537
|
+
config_path=config_path, state_dir=state_dir, cwd=cwd, runner=runner, epic_url=epic_url
|
|
1538
|
+
).run_all()
|
|
1539
|
+
|
|
1540
|
+
|
|
1541
|
+
def run_local_doctor(
|
|
1542
|
+
config_path: str | None = None,
|
|
1543
|
+
state_dir: str | None = None,
|
|
1544
|
+
cwd: str | None = None,
|
|
1545
|
+
runner: Runner | None = None,
|
|
1546
|
+
feature_spec_path: str | None = None,
|
|
1547
|
+
) -> list[CheckResult]:
|
|
1548
|
+
"""`autoforge local doctor`: the LOCAL checks only (never touches GitHub)."""
|
|
1549
|
+
return Doctor(config_path=config_path, state_dir=state_dir, cwd=cwd, runner=runner).run_local(
|
|
1550
|
+
feature_spec_path
|
|
1551
|
+
)
|