gitpro 0.3.1__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.
- gitpro/__init__.py +4 -0
- gitpro/__main__.py +5 -0
- gitpro/agents.py +1928 -0
- gitpro/approval_policy.py +151 -0
- gitpro/args.py +82 -0
- gitpro/audit.py +800 -0
- gitpro/change_contract.py +117 -0
- gitpro/changes.py +853 -0
- gitpro/cli.py +499 -0
- gitpro/clone.py +505 -0
- gitpro/completion.py +407 -0
- gitpro/config.py +517 -0
- gitpro/devcontainer.py +442 -0
- gitpro/discover.py +1579 -0
- gitpro/doctor.py +572 -0
- gitpro/envcmd.py +370 -0
- gitpro/example.toml +102 -0
- gitpro/files.py +139 -0
- gitpro/forge.py +1244 -0
- gitpro/guard.py +1121 -0
- gitpro/health.py +1241 -0
- gitpro/hot.py +38 -0
- gitpro/jsonfile.py +195 -0
- gitpro/jsonout.py +26 -0
- gitpro/keys.py +59 -0
- gitpro/lint.py +50 -0
- gitpro/lost.py +874 -0
- gitpro/mcp.py +1055 -0
- gitpro/operation_contract.py +178 -0
- gitpro/operation_io.py +256 -0
- gitpro/operation_reconcile.py +191 -0
- gitpro/operations.py +841 -0
- gitpro/pin.py +89 -0
- gitpro/plan.py +199 -0
- gitpro/policy.py +1808 -0
- gitpro/prompt.py +304 -0
- gitpro/render.py +474 -0
- gitpro/resolve.py +898 -0
- gitpro/scan.py +358 -0
- gitpro/scanroots.py +146 -0
- gitpro/setup.py +260 -0
- gitpro/trailers.py +218 -0
- gitpro/tui.py +178 -0
- gitpro/undo.py +63 -0
- gitpro/view.py +155 -0
- gitpro-0.3.1.dist-info/METADATA +103 -0
- gitpro-0.3.1.dist-info/RECORD +50 -0
- gitpro-0.3.1.dist-info/WHEEL +4 -0
- gitpro-0.3.1.dist-info/entry_points.txt +2 -0
- gitpro-0.3.1.dist-info/licenses/LICENSE +21 -0
gitpro/operations.py
ADDED
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
"""Agent preflight and explicitly authorized, identity-scoped commit/push/PR execution.
|
|
2
|
+
|
|
3
|
+
Existing human commands and fail-open hooks retain their contracts. These operation endpoints
|
|
4
|
+
fail closed and never edit identity configuration or switch a globally active forge account.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import contextlib
|
|
10
|
+
import dataclasses
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import shlex
|
|
15
|
+
import sqlite3
|
|
16
|
+
import sys
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Dict, List, Optional, Sequence
|
|
20
|
+
from urllib.parse import quote, urlsplit
|
|
21
|
+
|
|
22
|
+
from . import config, forge, jsonout, policy, resolve, trailers
|
|
23
|
+
from .config import Config, ConfigError
|
|
24
|
+
from .operation_contract import (MAX_REQUEST, REQUEST_SCHEMA, RESULT_SCHEMA, VERSION,
|
|
25
|
+
OperationError, Request, decode, digest, failure, request_key)
|
|
26
|
+
from .operation_io import (Ledger, Process, configuration_lock, file_hash, locked_index, process,
|
|
27
|
+
repository_lock, state_dir, termination_signals)
|
|
28
|
+
|
|
29
|
+
OID = re.compile(r"[0-9a-f]{40}(?:[0-9a-f]{24})?")
|
|
30
|
+
MAX_COMMITS = 1000
|
|
31
|
+
TOKENS = (*forge._GH_TOKEN_VARS, *forge._GLAB_TOKEN_VARS)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def child_environment() -> Dict[str, str]:
|
|
35
|
+
drop = {*resolve._REDIRECT_VARS, *TOKENS, "GIT_SSH_COMMAND", "GIT_SSH", "GIT_SSH_VARIANT",
|
|
36
|
+
"GITPRO_SKIP", "GH_HOST", "GH_REPO", "GITLAB_HOST", "GIT_ASKPASS", "SSH_ASKPASS"}
|
|
37
|
+
env = {k: v for k, v in os.environ.items()
|
|
38
|
+
if k not in drop and not k.startswith(("GIT_AUTHOR_", "GIT_COMMITTER_", "GIT_TRACE"))}
|
|
39
|
+
# Keep inherited Git safety/config settings: removing all GIT_CONFIG_* would also remove policy.
|
|
40
|
+
env.update(GIT_TERMINAL_PROMPT="0", GIT_OPTIONAL_LOCKS="0", GCM_INTERACTIVE="never",
|
|
41
|
+
GH_PROMPT_DISABLED="1", GIT_EDITOR="true", GIT_SEQUENCE_EDITOR="true",
|
|
42
|
+
SSH_ASKPASS_REQUIRE="never", GIT_NO_LAZY_FETCH="1")
|
|
43
|
+
return env
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _checked(result: Process, label: str, codes: Sequence[int] = (0,)) -> str:
|
|
47
|
+
if result.stopped:
|
|
48
|
+
raise OperationError(result.stopped, f"{label} did not complete.")
|
|
49
|
+
if result.code not in codes:
|
|
50
|
+
# Child output may contain arbitrary credentials from helpers/hooks. Do not echo it.
|
|
51
|
+
raise OperationError("check_failed", f"{label} failed (exit {result.code}).")
|
|
52
|
+
return result.out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def git(repo: Path, env: Dict[str, str], *args: str, codes: Sequence[int] = (0,),
|
|
56
|
+
text: Optional[str] = None) -> str:
|
|
57
|
+
return _checked(process(["git", *args], repo, env, text=text), "git " + args[0], codes)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _config(repo: Path, env: Dict[str, str], key: str) -> str:
|
|
61
|
+
return git(repo, env, "config", "--get", key, codes=(0, 1)).strip()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _oid(value: str) -> str:
|
|
65
|
+
if not OID.fullmatch(value):
|
|
66
|
+
raise OperationError("invalid_response", "Git/forge returned an invalid object ID.")
|
|
67
|
+
return value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _ref(repo: Path, env: Dict[str, str], value: str) -> None:
|
|
71
|
+
result = process(["git", "check-ref-format", "refs/heads/" + value], repo, env)
|
|
72
|
+
if result.code or result.stopped:
|
|
73
|
+
raise OperationError("invalid_ref", "Invalid branch name.", "needs_input")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class Plan:
|
|
78
|
+
request: Request
|
|
79
|
+
cfg: Config
|
|
80
|
+
env: Dict[str, str]
|
|
81
|
+
report: resolve.Report
|
|
82
|
+
common: Path
|
|
83
|
+
index: Path
|
|
84
|
+
snapshot: dict
|
|
85
|
+
destination: Optional[str] = None
|
|
86
|
+
account: Optional[forge.Account] = None
|
|
87
|
+
project: Optional[str] = None
|
|
88
|
+
checks: List[dict] = field(default_factory=list)
|
|
89
|
+
problems: List[dict] = field(default_factory=list)
|
|
90
|
+
policies: list = field(default_factory=list)
|
|
91
|
+
|
|
92
|
+
def problem(self, code: str, message: str, decision: str = "blocked",
|
|
93
|
+
argv: Optional[Sequence[str]] = None) -> None:
|
|
94
|
+
self.problems.append({"code": code, "message": forge.redact(message), "decision": decision,
|
|
95
|
+
"action": {"kind": "request_approval" if decision == "needs_approval" else "inspect",
|
|
96
|
+
"argv": list(argv) if argv else None}})
|
|
97
|
+
|
|
98
|
+
def check(self, code: str, message: str, status: str = "pass") -> None:
|
|
99
|
+
self.checks.append({"code": code, "status": status, "message": message})
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def decision(self) -> str:
|
|
103
|
+
for decision in ("blocked", "needs_approval", "needs_input", "indeterminate"):
|
|
104
|
+
if any(p["decision"] == decision for p in self.problems):
|
|
105
|
+
return decision
|
|
106
|
+
return "ready"
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def token(self) -> str:
|
|
110
|
+
return digest({"request": self.request.values, "snapshot": self.snapshot,
|
|
111
|
+
"identity": dataclasses.asdict(self.report.expected)})
|
|
112
|
+
|
|
113
|
+
def result(self) -> dict:
|
|
114
|
+
exp = self.report.expected
|
|
115
|
+
return {"version": VERSION, "command": "preflight", "ok": self.decision == "ready",
|
|
116
|
+
"decision": self.decision, "operation": self.request.operation, "repo": str(self.request.repo),
|
|
117
|
+
"problems": self.problems, "checks": self.checks,
|
|
118
|
+
"identity": {"id": exp.id or "default", "name": exp.name, "email": exp.email,
|
|
119
|
+
"sign": exp.sign, "ssh_key": exp.ssh_key,
|
|
120
|
+
"account": dataclasses.asdict(self.account) if self.account else None},
|
|
121
|
+
"destination": resolve.redact_url(self.destination),
|
|
122
|
+
"head": self.snapshot["head"], "plan_token": self.token if self.decision == "ready" else None,
|
|
123
|
+
"effects": {"persistent_configuration": False, "network": self.request.operation != "commit",
|
|
124
|
+
"child_identity_isolated": True},
|
|
125
|
+
"authorization": "A ready result is not permission to execute; retain host/user approval."}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _destination(req: Request, env: Dict[str, str]) -> Optional[str]:
|
|
129
|
+
remote = req.values.get("remote")
|
|
130
|
+
if remote is None:
|
|
131
|
+
return None
|
|
132
|
+
if remote not in git(req.repo, env, "remote").splitlines():
|
|
133
|
+
raise OperationError("unknown_remote", "Supply a configured remote name.", "needs_input")
|
|
134
|
+
args = ["remote", "get-url", "--all"]
|
|
135
|
+
if req.operation == "push":
|
|
136
|
+
args.append("--push")
|
|
137
|
+
urls = git(req.repo, env, *args, remote).splitlines()
|
|
138
|
+
if len(urls) != 1:
|
|
139
|
+
raise OperationError("ambiguous_destination", "Exactly one destination URL is required.", "needs_input")
|
|
140
|
+
url = urls[0]
|
|
141
|
+
if resolve.redact_url(url) != url or "?" in url or "\n" in url:
|
|
142
|
+
raise OperationError("credential_url", "Use a remote without embedded credentials or query parameters.",
|
|
143
|
+
"needs_approval")
|
|
144
|
+
if git(req.repo, env, "ls-remote", "--get-url", url).strip() != url:
|
|
145
|
+
raise OperationError("chained_rewrite", "The resolved destination would be rewritten again; review remote URLs.",
|
|
146
|
+
"needs_approval")
|
|
147
|
+
return url
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _snapshot(req: Request, cfg: Config, env: Dict[str, str], index: Path) -> dict:
|
|
151
|
+
head = git(req.repo, env, "rev-parse", "--verify", "--quiet", "HEAD", codes=(0, 1)).strip()
|
|
152
|
+
branch = git(req.repo, env, "symbolic-ref", "--quiet", "HEAD", codes=(0, 1)).strip()
|
|
153
|
+
if index.is_symlink():
|
|
154
|
+
raise OperationError("symlink_index", "The staged index must be a regular file.", "blocked")
|
|
155
|
+
trust = {}
|
|
156
|
+
for key in ("gpg.ssh.allowedSignersFile", "gpg.ssh.revocationFile"):
|
|
157
|
+
name = _config(req.repo, env, key)
|
|
158
|
+
if name:
|
|
159
|
+
path = Path(name).expanduser()
|
|
160
|
+
path = path if path.is_absolute() else req.repo / path
|
|
161
|
+
trust[str(path)] = file_hash(path)
|
|
162
|
+
return {"head": _oid(head) if head else None, "branch": branch, "index": file_hash(index),
|
|
163
|
+
"git_config": digest(git(req.repo, env, "config", "--null", "--list", "--show-origin")),
|
|
164
|
+
"config": digest(dataclasses.asdict(cfg)), "config_file": file_hash(cfg.path), "git_trust": trust}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _policies(plan: Plan) -> None:
|
|
168
|
+
exp = plan.report.expected
|
|
169
|
+
srcs = policy.sources(plan.cfg, exp.match.identity)
|
|
170
|
+
local = plan.request.repo / policy.REPO_POLICY
|
|
171
|
+
if local.exists():
|
|
172
|
+
srcs.append((str(local), "repository"))
|
|
173
|
+
loaded = [policy.load(p, check_perms=who != "repository",
|
|
174
|
+
inside=plan.request.repo if who == "repository" else None) for p, who in srcs]
|
|
175
|
+
urls = [plan.destination] if plan.destination else list(plan.report.effective.remotes.values())
|
|
176
|
+
plan.policies = policy.match(loaded, urls, plan.cfg)
|
|
177
|
+
plan.snapshot["policies"] = {str(p.path): file_hash(Path(p.path)) for p in loaded}
|
|
178
|
+
plan.snapshot["signers"] = {p.allowed_signers: file_hash(Path(p.allowed_signers))
|
|
179
|
+
for p in loaded if p.allowed_signers}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _policy_base(plan: Plan, base: Optional[str]) -> None:
|
|
183
|
+
"""Never let a checkout's modified/deleted policy weaken the policy already in its base."""
|
|
184
|
+
if not base:
|
|
185
|
+
return
|
|
186
|
+
paths = {str(plan.request.repo / policy.REPO_POLICY), *plan.snapshot["policies"], *plan.snapshot["signers"]}
|
|
187
|
+
for raw in sorted(paths):
|
|
188
|
+
path = Path(os.path.abspath(os.path.expanduser(raw)))
|
|
189
|
+
try:
|
|
190
|
+
rel = path.relative_to(plan.request.repo).as_posix()
|
|
191
|
+
except ValueError:
|
|
192
|
+
continue
|
|
193
|
+
rows = git(plan.request.repo, plan.env, "ls-tree", "-z", base, "--", rel).split("\0")
|
|
194
|
+
entry = next((row for row in rows if row.partition("\t")[2] == rel), None)
|
|
195
|
+
if entry is None:
|
|
196
|
+
continue
|
|
197
|
+
mode = entry.split(" ", 1)[0]
|
|
198
|
+
if mode not in ("100644", "100755"):
|
|
199
|
+
raise OperationError("policy_base_invalid", "A policy/trust file in the base is not a regular file.", "blocked")
|
|
200
|
+
base_text = git(plan.request.repo, plan.env, "cat-file", "blob", base + ":" + rel)
|
|
201
|
+
if not path.is_file() or path.is_symlink() or path.read_text(encoding="utf-8") != base_text:
|
|
202
|
+
plan.problem("policy_changed", "A repository policy/trust file differs from the committed base. "
|
|
203
|
+
"Review that change separately; it cannot authorize its own operation.", "needs_approval")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _message_problems(plan: Plan, message: str, author: str, committer: str, *, signed: bool,
|
|
207
|
+
signing_format: Optional[str], own_commit: bool = True, merge: bool = False) -> None:
|
|
208
|
+
exp = plan.report.expected
|
|
209
|
+
found = trailers.split_lines(git(plan.request.repo, plan.env, "interpret-trailers", "--parse",
|
|
210
|
+
"--no-divider", text=message))
|
|
211
|
+
tp = trailers.policy_for(exp.match.identity, plan.cfg.default)
|
|
212
|
+
if merge and tp.co_authored_by == "require":
|
|
213
|
+
tp = dataclasses.replace(tp, co_authored_by="allow")
|
|
214
|
+
if own_commit:
|
|
215
|
+
for msg in trailers.check(found, tp):
|
|
216
|
+
plan.problem("trailer_policy", msg)
|
|
217
|
+
for pol, rules in plan.policies:
|
|
218
|
+
for email in (author, committer):
|
|
219
|
+
violation = policy.email_problem(rules, email)
|
|
220
|
+
if violation:
|
|
221
|
+
plan.problem("policy_" + violation[0], violation[1])
|
|
222
|
+
for code, msg in policy.trailer_problems(pol, rules, found, merge):
|
|
223
|
+
plan.problem("policy_" + code, msg)
|
|
224
|
+
if any(r.require_signing for r in rules) and not signed:
|
|
225
|
+
plan.problem("policy_signing", "The applicable policy requires a signed commit.")
|
|
226
|
+
if signed and any(r.signing_formats and signing_format not in r.signing_formats for r in rules):
|
|
227
|
+
plan.problem("policy_signing_format", "The signature format is not permitted by policy.")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _account(plan: Plan, required: bool = True) -> None:
|
|
231
|
+
exp = plan.report.expected
|
|
232
|
+
identity = exp.match.identity
|
|
233
|
+
host, owner, name = resolve.parse_url(plan.destination or exp.match.url or "")
|
|
234
|
+
if identity:
|
|
235
|
+
host = identity.host
|
|
236
|
+
login = identity.gh_account
|
|
237
|
+
else:
|
|
238
|
+
login = plan.cfg.default.gh_account if host == forge.GITHUB else None
|
|
239
|
+
if not login or not host or not owner or not name:
|
|
240
|
+
if required:
|
|
241
|
+
raise OperationError("missing_account", "Configure a GitHub account for the destination identity.",
|
|
242
|
+
"needs_input")
|
|
243
|
+
return
|
|
244
|
+
if not re.fullmatch(r"[A-Za-z0-9.-]+", host) or not re.fullmatch(r"[A-Za-z0-9-]+", login):
|
|
245
|
+
raise OperationError("invalid_account", "Invalid GitHub host/account.", "needs_input")
|
|
246
|
+
if "/" in owner or not re.fullmatch(r"[A-Za-z0-9_.-]+", owner) or not re.fullmatch(r"[A-Za-z0-9_.-]+", name):
|
|
247
|
+
raise OperationError("unsupported_forge", "This operation supports GitHub repositories only.", "needs_input")
|
|
248
|
+
plan.account = forge.Account(host, "gh", login)
|
|
249
|
+
plan.project = owner + "/" + name.removesuffix(".git")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _authenticate(plan: Plan) -> None:
|
|
253
|
+
if plan.account is None:
|
|
254
|
+
raise OperationError("missing_account", "A configured GitHub account is required.", "needs_input")
|
|
255
|
+
# Reuse the existing credential resolver; take only its selected credential, never its ambient env.
|
|
256
|
+
scoped = forge.gh_env(plan.account.host, plan.account.login, base_env=plan.env)
|
|
257
|
+
for key in ("GH_HOST", "GH_TOKEN", "GH_ENTERPRISE_TOKEN"):
|
|
258
|
+
if key in scoped:
|
|
259
|
+
plan.env[key] = scoped[key]
|
|
260
|
+
login = _checked(process(["gh", "api", "user", "--jq", ".login"], plan.request.repo, plan.env),
|
|
261
|
+
"GitHub account verification").strip()
|
|
262
|
+
if login.casefold() != plan.account.login.casefold():
|
|
263
|
+
raise OperationError("account_mismatch", "The credential does not belong to the configured account.", "blocked")
|
|
264
|
+
plan.check("forge_account", "The selected credential belongs to the configured GitHub account.")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _api(plan: Plan, endpoint: str, *, params: Optional[dict] = None) -> object:
|
|
268
|
+
argv = ["gh", "api", "--method", "GET", endpoint]
|
|
269
|
+
for key, value in (params or {}).items():
|
|
270
|
+
argv += ["-f", f"{key}={value}"]
|
|
271
|
+
text = _checked(process(argv, plan.request.repo, plan.env), "GitHub read")
|
|
272
|
+
try:
|
|
273
|
+
return json.loads(text)
|
|
274
|
+
except ValueError:
|
|
275
|
+
raise OperationError("invalid_response", "GitHub returned an invalid JSON response.") from None
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _transport(plan: Plan, online: bool) -> None:
|
|
279
|
+
url = plan.destination or ""
|
|
280
|
+
host, _, _ = resolve.parse_url(url)
|
|
281
|
+
if url.startswith("https://"):
|
|
282
|
+
_account(plan)
|
|
283
|
+
headers = git(plan.request.repo, plan.env, "config", "--get-regexp",
|
|
284
|
+
r"^http(\..*)?\.extraheader$", codes=(0, 1))
|
|
285
|
+
if headers:
|
|
286
|
+
raise OperationError("http_header_override", "Configured HTTP headers could override the selected account. "
|
|
287
|
+
"Review/remove them before scoped HTTPS execution.", "needs_approval")
|
|
288
|
+
if online:
|
|
289
|
+
_authenticate(plan)
|
|
290
|
+
# Empty helper clears helpers that could borrow another identity.
|
|
291
|
+
plan.env.update(GIT_CONFIG_COUNT=str(int(plan.env.get("GIT_CONFIG_COUNT", "0")) + 2))
|
|
292
|
+
n = int(plan.env["GIT_CONFIG_COUNT"]) - 2
|
|
293
|
+
plan.env.update({f"GIT_CONFIG_KEY_{n}": "credential.helper", f"GIT_CONFIG_VALUE_{n}": "",
|
|
294
|
+
f"GIT_CONFIG_KEY_{n + 1}": "credential.helper",
|
|
295
|
+
f"GIT_CONFIG_VALUE_{n + 1}": "!gh auth git-credential"})
|
|
296
|
+
elif host and (url.startswith("ssh://") or ("://" not in url and ":" in url)):
|
|
297
|
+
key = plan.report.expected.ssh_key
|
|
298
|
+
if not key or not Path(key).is_file():
|
|
299
|
+
raise OperationError("missing_ssh_key", "The destination identity needs an existing SSH key.", "needs_input")
|
|
300
|
+
plan.env["GIT_SSH_COMMAND"] = (f"ssh -F /dev/null -i {shlex.quote(key)} -o IdentitiesOnly=yes "
|
|
301
|
+
"-o BatchMode=yes -o StrictHostKeyChecking=yes -o UpdateHostKeys=no")
|
|
302
|
+
identity = plan.report.expected.match.identity
|
|
303
|
+
if identity and identity.ssh_alias and host == identity.ssh_alias:
|
|
304
|
+
plan.env["GIT_SSH_COMMAND"] += (" -o " + shlex.quote("HostName=" + identity.host) +
|
|
305
|
+
" -p " + str(identity.ssh_port))
|
|
306
|
+
plan.snapshot["ssh_key_stat"] = _key_stat(Path(key))
|
|
307
|
+
if online:
|
|
308
|
+
_ssh_account(plan, host)
|
|
309
|
+
elif url.startswith("file://") or Path(url).is_absolute():
|
|
310
|
+
pass
|
|
311
|
+
else:
|
|
312
|
+
raise OperationError("unsupported_transport", "Use SSH, GitHub HTTPS, or an absolute local remote.",
|
|
313
|
+
"needs_input")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _ssh_account(plan: Plan, host: str) -> None:
|
|
317
|
+
from .health import parse_greeting
|
|
318
|
+
identity = plan.report.expected.match.identity
|
|
319
|
+
person = identity or plan.cfg.default
|
|
320
|
+
gh_account = person.gh_account
|
|
321
|
+
glab_account = person.glab_account
|
|
322
|
+
if not gh_account and not glab_account:
|
|
323
|
+
plan.check("ssh_account", "No forge login configured; only the configured SSH key and destination access are checked.",
|
|
324
|
+
"skip")
|
|
325
|
+
return
|
|
326
|
+
url = plan.destination or ""
|
|
327
|
+
user, port = "git", None
|
|
328
|
+
if url.startswith("ssh://"):
|
|
329
|
+
parsed = urlsplit(url)
|
|
330
|
+
user, port = parsed.username or "git", parsed.port
|
|
331
|
+
else:
|
|
332
|
+
match = resolve._SCP_RE.match(url)
|
|
333
|
+
if match:
|
|
334
|
+
user = match[1] or "git"
|
|
335
|
+
argv = shlex.split(plan.env["GIT_SSH_COMMAND"]) + ["-T"]
|
|
336
|
+
if port:
|
|
337
|
+
argv += ["-p", str(port)]
|
|
338
|
+
result = process([*argv, user + "@" + host], plan.request.repo, plan.env)
|
|
339
|
+
if result.stopped or result.code not in (0, 1):
|
|
340
|
+
raise OperationError("ssh_account_unknown", "The selected SSH key's forge account could not be checked.")
|
|
341
|
+
provider, login = parse_greeting(result.out + result.err)
|
|
342
|
+
expected = gh_account if provider == "github" else glab_account if provider == "gitlab" else None
|
|
343
|
+
if not login or not expected:
|
|
344
|
+
raise OperationError("ssh_account_unknown", "The SSH server did not identify a configured forge account.")
|
|
345
|
+
if login.casefold() != expected.casefold():
|
|
346
|
+
raise OperationError("ssh_account_mismatch", "The selected key authenticates as another forge account.", "blocked")
|
|
347
|
+
plan.check("ssh_account", "The selected SSH key authenticates as the configured forge account.")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _key_stat(path: Path) -> list:
|
|
351
|
+
st = path.stat()
|
|
352
|
+
return [str(path), st.st_dev, st.st_ino, st.st_mtime_ns, st.st_size, st.st_mode]
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _verify_signature(plan: Plan, oid: str, fmt: Optional[str], committer: str) -> None:
|
|
356
|
+
tmp: List[str] = []
|
|
357
|
+
try:
|
|
358
|
+
pre, ships = policy._signers_args(plan.policies, tmp)
|
|
359
|
+
text = _checked(process(["git", *pre, "show", "-s", "--format=%G?%x00%GK%x00%GS", oid],
|
|
360
|
+
plan.request.repo, plan.env), "Signature verification")
|
|
361
|
+
fields = text.rstrip("\n").split("\0")
|
|
362
|
+
if len(fields) != 3:
|
|
363
|
+
raise OperationError("invalid_response", "Could not read the verified signature.")
|
|
364
|
+
status, key, signer = fields
|
|
365
|
+
status = policy.classify(status, fmt, signer)
|
|
366
|
+
if status not in ("G", "U"):
|
|
367
|
+
raise OperationError("signature_unverified", "The signature could not be verified as valid.")
|
|
368
|
+
signers = [p.allowed_signers for p, _ in plan.policies if p.allowed_signers]
|
|
369
|
+
for path in signers:
|
|
370
|
+
Path(path).read_text(encoding="utf-8") # A missing/unreadable trust file is not an empty policy.
|
|
371
|
+
principals = policy.signer_principals(signers).get(key)
|
|
372
|
+
for _, rules in plan.policies:
|
|
373
|
+
for code, msg in policy.signing_problems(rules, status, fmt, ships, signer=signer,
|
|
374
|
+
committer=committer, principals=principals):
|
|
375
|
+
plan.problem("policy_" + code, msg)
|
|
376
|
+
finally:
|
|
377
|
+
for path in tmp:
|
|
378
|
+
Path(path).unlink()
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _history(plan: Plan, base: Optional[str], head: str) -> None:
|
|
382
|
+
repo, env = plan.request.repo, plan.env
|
|
383
|
+
if base:
|
|
384
|
+
available = process(["git", "cat-file", "-e", base + "^{commit}"], repo, env)
|
|
385
|
+
if available.code or available.stopped:
|
|
386
|
+
raise OperationError("missing_history", "Fetch the destination history before retrying preflight.",
|
|
387
|
+
"needs_input")
|
|
388
|
+
ancestor = process(["git", "merge-base", "--is-ancestor", base, head], repo, env)
|
|
389
|
+
if ancestor.stopped or ancestor.code not in (0, 1):
|
|
390
|
+
raise OperationError("check_failed", "Could not compare destination history.")
|
|
391
|
+
if ancestor.code:
|
|
392
|
+
plan.problem("non_fast_forward", "The destination is not an ancestor of the proposed head.")
|
|
393
|
+
return
|
|
394
|
+
args = [head] + ([f"^{base}"] if base else [])
|
|
395
|
+
commits = git(repo, env, "rev-list", f"--max-count={MAX_COMMITS + 1}", *args, "--").splitlines()
|
|
396
|
+
if len(commits) > MAX_COMMITS:
|
|
397
|
+
raise OperationError("history_limit", "More than 1000 outgoing commits; review/push a smaller range.",
|
|
398
|
+
"needs_input")
|
|
399
|
+
own = {e.casefold() for e in resolve.own_emails(plan.cfg)}
|
|
400
|
+
expected = (plan.report.expected.email or "").casefold()
|
|
401
|
+
signing_policy = any(r.signing() for _, rules in plan.policies for r in rules)
|
|
402
|
+
for oid in commits:
|
|
403
|
+
_oid(oid)
|
|
404
|
+
raw = git(repo, env, "show", "-s", "--format=%ce%x00%ae%x00%B", oid)
|
|
405
|
+
parts = raw.split("\0", 2)
|
|
406
|
+
if len(parts) != 3 or "\0" in parts[-1]:
|
|
407
|
+
raise OperationError("invalid_response", "Could not read an outgoing commit.")
|
|
408
|
+
committer, author, message = parts
|
|
409
|
+
if any(e.casefold() in own and e.casefold() != expected for e in (committer, author)):
|
|
410
|
+
plan.problem("outgoing_identity", f"Commit {oid} uses another configured identity.")
|
|
411
|
+
body = git(repo, env, "cat-file", "commit", oid).encode()
|
|
412
|
+
fmt = policy._sig_format(body)
|
|
413
|
+
merge = sum(line.startswith(b"parent ") for line in body.split(b"\n\n", 1)[0].splitlines()) > 1
|
|
414
|
+
_message_problems(plan, message, author, committer, signed=fmt is not None, signing_format=fmt,
|
|
415
|
+
own_commit=committer.casefold() in own, merge=merge)
|
|
416
|
+
if signing_policy and fmt is not None:
|
|
417
|
+
_verify_signature(plan, oid, fmt, committer)
|
|
418
|
+
plan.check("outgoing_history", f"Checked {len(commits)} outgoing commits against the destination identity/policy.")
|
|
419
|
+
_policy_base(plan, base)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _remote_head(plan: Plan) -> Optional[str]:
|
|
423
|
+
ref = "refs/heads/" + plan.request.values["branch"]
|
|
424
|
+
result = process(["git", "ls-remote", "--exit-code", str(plan.destination), ref],
|
|
425
|
+
plan.request.repo, plan.env)
|
|
426
|
+
text = _checked(result, "Destination ref lookup", (0, 2))
|
|
427
|
+
if result.code == 2 and not text.strip():
|
|
428
|
+
return None
|
|
429
|
+
rows = [line.split("\t") for line in text.splitlines()]
|
|
430
|
+
if len(rows) != 1 or rows[0][1:] != [ref]:
|
|
431
|
+
raise OperationError("invalid_response", "Could not resolve exactly one destination ref.")
|
|
432
|
+
return _oid(rows[0][0])
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _commit_checks(plan: Plan) -> None:
|
|
436
|
+
req, exp, env = plan.request, plan.report.expected, plan.env
|
|
437
|
+
_policy_base(plan, plan.snapshot["head"])
|
|
438
|
+
for p in plan.report.problems:
|
|
439
|
+
if p.code != "key_mismatch":
|
|
440
|
+
plan.problem(p.code, p.message, "needs_input" if p.code == "no_identity" else "needs_approval",
|
|
441
|
+
p.fix_argv)
|
|
442
|
+
if not exp.name or not exp.email:
|
|
443
|
+
plan.problem("missing_identity", "Configure a name and email before executing.", "needs_input")
|
|
444
|
+
author = req.values.get("author_email", exp.email or "")
|
|
445
|
+
_, bad_author = resolve.identity_conflicts(exp.email, resolve.own_emails(plan.cfg), author, exp.email)
|
|
446
|
+
if bad_author:
|
|
447
|
+
plan.problem("author_identity", "The proposed author is another of your configured identities.")
|
|
448
|
+
for marker in ("MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "rebase-merge", "rebase-apply"):
|
|
449
|
+
path = git(req.repo, env, "rev-parse", "--path-format=absolute", "--git-path", marker).strip()
|
|
450
|
+
if Path(path).exists():
|
|
451
|
+
plan.problem("operation_in_progress", "Finish the existing Git operation first.")
|
|
452
|
+
changes = process(["git", "diff", "--cached", "--quiet", "--exit-code", "--no-ext-diff"], req.repo, env)
|
|
453
|
+
if changes.stopped or changes.code not in (0, 1):
|
|
454
|
+
raise OperationError("check_failed", "Could not inspect staged changes.")
|
|
455
|
+
if changes.code == 0:
|
|
456
|
+
plan.problem("nothing_staged", "Stage the intended changes before preflight.", "needs_input")
|
|
457
|
+
if git(req.repo, env, "ls-files", "--unmerged"):
|
|
458
|
+
plan.problem("unmerged_index", "Resolve staged conflicts first.")
|
|
459
|
+
fmt = _config(req.repo, env, "gpg.format") or "openpgp"
|
|
460
|
+
fmt = "gpg" if fmt == "openpgp" else fmt
|
|
461
|
+
if exp.sign:
|
|
462
|
+
if not exp.signingkey:
|
|
463
|
+
plan.problem("missing_signing_key", "The configured signing key is missing.", "needs_input")
|
|
464
|
+
elif fmt == "ssh" and Path(exp.signingkey).is_file():
|
|
465
|
+
plan.snapshot["signing_key_stat"] = _key_stat(Path(exp.signingkey))
|
|
466
|
+
signers = [p.allowed_signers for p, _ in plan.policies if p.allowed_signers]
|
|
467
|
+
if signers:
|
|
468
|
+
public = Path(exp.signingkey)
|
|
469
|
+
if public.suffix != ".pub":
|
|
470
|
+
public = public.with_name(public.name + ".pub")
|
|
471
|
+
if not public.is_file():
|
|
472
|
+
plan.problem("missing_public_key", "A public signing key is needed to check policy principals.",
|
|
473
|
+
"needs_input")
|
|
474
|
+
else:
|
|
475
|
+
parts = public.read_text(encoding="utf-8").split()
|
|
476
|
+
fingerprint = policy.ssh_fingerprint(parts[1]) if len(parts) >= 2 else None
|
|
477
|
+
principals = policy.signer_principals(signers).get(fingerprint or "")
|
|
478
|
+
if not principals:
|
|
479
|
+
plan.problem("signer_unknown", "The public key has no directly verifiable policy principal.",
|
|
480
|
+
"indeterminate")
|
|
481
|
+
elif not policy.principal_ok(principals, exp.email):
|
|
482
|
+
plan.problem("policy_allowed_signers", "The policy signing key does not permit this committer email.")
|
|
483
|
+
plan.snapshot["public_signing_key"] = file_hash(public)
|
|
484
|
+
_message_problems(plan, req.values["message"], author, exp.email or "", signed=exp.sign, signing_format=fmt)
|
|
485
|
+
plan.check("staged_commit", "Only the reviewed staged index is committed; existing Git hooks still run.")
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _pr_checks(plan: Plan) -> None:
|
|
489
|
+
req = plan.request
|
|
490
|
+
_account(plan)
|
|
491
|
+
_authenticate(plan)
|
|
492
|
+
prefix = "repos/" + str(plan.project)
|
|
493
|
+
head, base = req.values["head"], req.values["base"]
|
|
494
|
+
if head == base or plan.snapshot["branch"] != "refs/heads/" + head:
|
|
495
|
+
raise OperationError("branch_mismatch", "head must be this worktree's branch and differ from base.", "blocked")
|
|
496
|
+
refs = {}
|
|
497
|
+
for name in (base, head):
|
|
498
|
+
data = _api(plan, prefix + "/git/ref/heads/" + quote(name, safe=""))
|
|
499
|
+
if not isinstance(data, dict) or not isinstance(data.get("object"), dict):
|
|
500
|
+
raise OperationError("invalid_response", "GitHub did not return the requested branch.")
|
|
501
|
+
refs[name] = _oid(str(data["object"].get("sha", "")))
|
|
502
|
+
if refs[head] != plan.snapshot["head"]:
|
|
503
|
+
raise OperationError("head_not_pushed", "Push the current head before creating the PR.", "needs_input")
|
|
504
|
+
prs = _api(plan, prefix + "/pulls", params={"state": "open", "head": plan.project.split("/")[0] + ":" + head,
|
|
505
|
+
"base": base, "per_page": "100"})
|
|
506
|
+
if not isinstance(prs, list) or len(prs) > 1 or (prs and not isinstance(prs[0], dict)):
|
|
507
|
+
raise OperationError("invalid_response", "Could not identify a unique existing PR.")
|
|
508
|
+
plan.snapshot.update(remote_refs=refs, existing_pr=_pr_url(plan, prs[0]) if prs else None)
|
|
509
|
+
# GitHub allows a base that has advanced independently; inspect commits unique to head.
|
|
510
|
+
base_oid = refs[base]
|
|
511
|
+
merge = git(req.repo, plan.env, "merge-base", base_oid, refs[head]).strip()
|
|
512
|
+
_history(plan, _oid(merge), refs[head])
|
|
513
|
+
_policy_base(plan, base_oid)
|
|
514
|
+
plan.check("pr_destination", "The configured GitHub account, pushed head, base and existing PR were checked.")
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _pr_url(plan: Plan, data: object) -> str:
|
|
518
|
+
if not isinstance(data, dict) or not plan.account:
|
|
519
|
+
raise OperationError("invalid_response", "GitHub did not return a pull request.")
|
|
520
|
+
url = data.get("html_url")
|
|
521
|
+
prefix = "https://" + plan.account.host + "/" + str(plan.project) + "/pull/"
|
|
522
|
+
if not isinstance(url, str) or not url.startswith(prefix) or not url[len(prefix):].isdigit():
|
|
523
|
+
raise OperationError("invalid_response", "GitHub did not return a valid pull request URL.")
|
|
524
|
+
return url
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def build(req: Request, cfg_path: Optional[Path] = None, *, online: bool = False) -> Plan:
|
|
528
|
+
state_dir()
|
|
529
|
+
env = child_environment()
|
|
530
|
+
resolve.require_git()
|
|
531
|
+
if not req.repo.is_dir():
|
|
532
|
+
raise OperationError("not_repository", "repo must be an existing worktree.", "needs_input")
|
|
533
|
+
top = git(req.repo, env, "rev-parse", "--show-toplevel").strip()
|
|
534
|
+
if Path(top).resolve() != req.repo:
|
|
535
|
+
raise OperationError("not_worktree_root", "Supply the canonical worktree root.", "needs_input")
|
|
536
|
+
path, how = config.find(cfg_path, Path.home())
|
|
537
|
+
cfg = config.load_all(path, how)
|
|
538
|
+
env["GITPRO_CONFIG"] = str(cfg.path)
|
|
539
|
+
common = Path(git(req.repo, env, "rev-parse", "--path-format=absolute", "--git-common-dir").strip())
|
|
540
|
+
index = Path(git(req.repo, env, "rev-parse", "--path-format=absolute", "--git-path", "index").strip())
|
|
541
|
+
destination = _destination(req, env)
|
|
542
|
+
rep = resolve.compare(req.repo, cfg, remote=req.values.get("remote"), remote_url=destination,
|
|
543
|
+
env=env, git_env=env, extra_checks=False)
|
|
544
|
+
snapshot = _snapshot(req, cfg, env, index)
|
|
545
|
+
plan = Plan(req, cfg, env, rep, common, index, snapshot, destination)
|
|
546
|
+
for key in ("branch", "base", "head"):
|
|
547
|
+
if key in req.values:
|
|
548
|
+
_ref(req.repo, env, req.values[key])
|
|
549
|
+
_policies(plan)
|
|
550
|
+
if req.operation == "commit":
|
|
551
|
+
_commit_checks(plan)
|
|
552
|
+
else:
|
|
553
|
+
if not snapshot["head"]:
|
|
554
|
+
plan.problem("missing_head", "Create a commit first.", "needs_input")
|
|
555
|
+
if not rep.expected.email:
|
|
556
|
+
plan.problem("missing_identity", "Configure an identity for the destination.", "needs_input")
|
|
557
|
+
if req.operation == "push":
|
|
558
|
+
_transport(plan, online)
|
|
559
|
+
else:
|
|
560
|
+
_account(plan)
|
|
561
|
+
if not online:
|
|
562
|
+
plan.problem("network_approval", "Destination checks and execution require explicit --online.",
|
|
563
|
+
"needs_approval")
|
|
564
|
+
plan.check("destination", "Not checked offline.", "unknown")
|
|
565
|
+
elif not plan.problems:
|
|
566
|
+
if req.operation == "push":
|
|
567
|
+
remote_head = _remote_head(plan)
|
|
568
|
+
plan.snapshot["remote_head"] = remote_head
|
|
569
|
+
_history(plan, remote_head, snapshot["head"])
|
|
570
|
+
plan.check("destination", "The exact destination ref was read; execution uses a compare-and-swap lease.")
|
|
571
|
+
else:
|
|
572
|
+
_pr_checks(plan)
|
|
573
|
+
if any(r.require_verified_email for _, rules in plan.policies for r in rules):
|
|
574
|
+
if not online:
|
|
575
|
+
plan.problem("verified_email_unknown", "Email verification requires explicit --online.", "needs_approval")
|
|
576
|
+
else:
|
|
577
|
+
if plan.account is None:
|
|
578
|
+
_account(plan)
|
|
579
|
+
_authenticate(plan)
|
|
580
|
+
elif not any(c["code"] == "forge_account" for c in plan.checks):
|
|
581
|
+
_authenticate(plan)
|
|
582
|
+
emails = _api(plan, "user/emails")
|
|
583
|
+
if not isinstance(emails, list):
|
|
584
|
+
raise OperationError("invalid_response", "Could not check verified email.")
|
|
585
|
+
if not any(isinstance(e, dict) and e.get("verified") is True and
|
|
586
|
+
str(e.get("email", "")).casefold() == (rep.expected.email or "").casefold() for e in emails):
|
|
587
|
+
plan.problem("email_not_verified", "The configured email is not verified on this account.")
|
|
588
|
+
# Detect changes even during preflight; don't mint a token for a mixed snapshot.
|
|
589
|
+
current = _snapshot(req, cfg, child_environment(), index)
|
|
590
|
+
if any(current[k] != snapshot[k] for k in current):
|
|
591
|
+
raise OperationError("state_changed", "Repository/configuration changed during preflight; inspect again.")
|
|
592
|
+
plan.check("policy", "Applicable local policies were evaluated explicitly; checker errors stop execution.")
|
|
593
|
+
return plan
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def preflight(raw: object, cfg_path: Optional[Path] = None, *, online: bool = False) -> dict:
|
|
597
|
+
req = None
|
|
598
|
+
try:
|
|
599
|
+
req = Request.parse(raw)
|
|
600
|
+
return build(req, cfg_path, online=online).result()
|
|
601
|
+
except OperationError as exc:
|
|
602
|
+
return failure("preflight", exc, req)
|
|
603
|
+
except ConfigError as exc:
|
|
604
|
+
return failure("preflight", OperationError("configuration_error", forge.redact(str(exc)), "needs_input"), req)
|
|
605
|
+
except (OSError, ValueError) as exc:
|
|
606
|
+
return failure("preflight", OperationError("check_failed", f"Preflight failed ({type(exc).__name__})."), req)
|
|
607
|
+
except KeyboardInterrupt:
|
|
608
|
+
return failure("preflight", OperationError("cancelled", "Preflight cancelled; no operation was started."), req)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _commit(plan: Plan) -> dict:
|
|
612
|
+
req, exp = plan.request, plan.report.expected
|
|
613
|
+
if exp.name is None or exp.email is None:
|
|
614
|
+
raise OperationError("missing_identity", "A configured committer name and email are required.", "needs_input")
|
|
615
|
+
env = dict(plan.env)
|
|
616
|
+
env.update(GIT_AUTHOR_NAME=req.values.get("author_name", exp.name),
|
|
617
|
+
GIT_AUTHOR_EMAIL=req.values.get("author_email", exp.email),
|
|
618
|
+
GIT_COMMITTER_NAME=exp.name, GIT_COMMITTER_EMAIL=exp.email)
|
|
619
|
+
# Do not attach forge tokens to local commits or their hooks.
|
|
620
|
+
for key in TOKENS:
|
|
621
|
+
env.pop(key, None)
|
|
622
|
+
with locked_index(plan.index, plan.snapshot["index"]) as private:
|
|
623
|
+
env["GIT_INDEX_FILE"] = str(private)
|
|
624
|
+
tree = git(req.repo, env, "write-tree").strip()
|
|
625
|
+
signing = ["-c", f"commit.gpgsign={'true' if exp.sign else 'false'}"]
|
|
626
|
+
if exp.sign and exp.signingkey:
|
|
627
|
+
signing += ["-c", "user.signingkey=" + exp.signingkey]
|
|
628
|
+
result = process(["git", *signing, "commit", "--cleanup=verbatim", "--file=-"], req.repo, env,
|
|
629
|
+
text=req.values["message"])
|
|
630
|
+
head = git(req.repo, env, "rev-parse", "--verify", "HEAD").strip()
|
|
631
|
+
if result.stopped or result.code:
|
|
632
|
+
if head != plan.snapshot["head"] or result.stopped:
|
|
633
|
+
raise OperationError("outcome_unknown", "Commit execution was interrupted or changed HEAD. Inspect history.")
|
|
634
|
+
raise OperationError("execution_failed", "Git refused the commit; HEAD is unchanged.", "blocked")
|
|
635
|
+
meta = git(req.repo, env, "show", "-s", "--format=%T%x00%P%x00%cn%x00%ce%x00%an%x00%ae", head).strip().split("\0")
|
|
636
|
+
expected = [tree, plan.snapshot["head"] or "", exp.name, exp.email,
|
|
637
|
+
env["GIT_AUTHOR_NAME"], env["GIT_AUTHOR_EMAIL"]]
|
|
638
|
+
if meta != expected or file_hash(plan.index) != plan.snapshot["index"]:
|
|
639
|
+
raise OperationError("outcome_unknown", "The resulting commit/index differs from the reviewed operation.")
|
|
640
|
+
message = git(req.repo, env, "show", "-s", "--format=%B", head)
|
|
641
|
+
body = git(req.repo, env, "cat-file", "commit", head).encode()
|
|
642
|
+
committed_message = body.split(b"\n\n", 1)[1].decode("utf-8")
|
|
643
|
+
if committed_message.rstrip("\n") != req.values["message"].rstrip("\n"):
|
|
644
|
+
raise OperationError("outcome_unknown", "A hook changed the reviewed commit message; inspect the new commit.")
|
|
645
|
+
fmt = policy._sig_format(body)
|
|
646
|
+
_message_problems(plan, message, env["GIT_AUTHOR_EMAIL"], exp.email,
|
|
647
|
+
signed=fmt is not None, signing_format=fmt)
|
|
648
|
+
if exp.sign:
|
|
649
|
+
_verify_signature(plan, head, fmt, exp.email)
|
|
650
|
+
if plan.problems or (fmt is not None) != exp.sign:
|
|
651
|
+
raise OperationError("outcome_unknown", "A hook changed the resulting commit's policy compliance; inspect history.")
|
|
652
|
+
return {"commit": _oid(head), "tree": _oid(tree)}
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _push(plan: Plan) -> dict:
|
|
656
|
+
req = plan.request
|
|
657
|
+
old, head = plan.snapshot["remote_head"], plan.snapshot["head"]
|
|
658
|
+
if old == head:
|
|
659
|
+
return {"remote_oid": head, "already_present": True}
|
|
660
|
+
ref = "refs/heads/" + req.values["branch"]
|
|
661
|
+
# Fast-forward ancestry was checked above; the explicit lease prevents races, not history rewriting.
|
|
662
|
+
argv = ["git", "-c", f"remote.{req.values['remote']}.mirror=false", "push", "--porcelain", "--atomic",
|
|
663
|
+
"--no-follow-tags", "--recurse-submodules=no", f"--force-with-lease={ref}:{old or ''}",
|
|
664
|
+
req.values["remote"], f"{head}:{ref}"]
|
|
665
|
+
result = process(argv, req.repo, plan.env)
|
|
666
|
+
current = _remote_head(plan)
|
|
667
|
+
if current == head:
|
|
668
|
+
return {"remote_oid": head, "already_present": False}
|
|
669
|
+
if result.stopped or result.code == 0 or current != old:
|
|
670
|
+
raise OperationError("outcome_unknown", "The push outcome could not be verified. Inspect the remote ref.")
|
|
671
|
+
raise OperationError("execution_failed", "The remote rejected the push and its ref is unchanged.", "blocked")
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _create_pr(plan: Plan) -> dict:
|
|
675
|
+
if plan.snapshot.get("existing_pr"):
|
|
676
|
+
return {"pr_url": plan.snapshot["existing_pr"], "already_present": True}
|
|
677
|
+
req = plan.request
|
|
678
|
+
body = {k: req.values[k] for k in ("title", "body", "base", "head", "draft")}
|
|
679
|
+
result = process(["gh", "api", "--method", "POST", "repos/" + str(plan.project) + "/pulls", "--input", "-"],
|
|
680
|
+
req.repo, plan.env, text=json.dumps(body))
|
|
681
|
+
if result.stopped or result.code:
|
|
682
|
+
raise OperationError("outcome_unknown", "PR creation did not return a confirmed result. Inspect GitHub before retrying.")
|
|
683
|
+
try:
|
|
684
|
+
data = json.loads(result.out)
|
|
685
|
+
except ValueError:
|
|
686
|
+
raise OperationError("outcome_unknown", "PR creation returned an unreadable result; inspect GitHub.") from None
|
|
687
|
+
return {"pr_url": _pr_url(plan, data), "already_present": False}
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def execute(raw: object, token: str, key: str, cfg_path: Optional[Path] = None, *,
|
|
691
|
+
online: bool = False, authorized: bool = False) -> dict:
|
|
692
|
+
req = None
|
|
693
|
+
claimed = False
|
|
694
|
+
ledger = None
|
|
695
|
+
result: dict
|
|
696
|
+
try:
|
|
697
|
+
req = Request.parse(raw)
|
|
698
|
+
request_key(key)
|
|
699
|
+
if not authorized:
|
|
700
|
+
raise OperationError("approval_required", "Execution requires explicit host/user authorization.",
|
|
701
|
+
"needs_approval")
|
|
702
|
+
if not isinstance(token, str) or not re.fullmatch(r"[0-9a-f]{64}", token):
|
|
703
|
+
raise OperationError("invalid_plan", "Supply the ready preflight's plan_token.", "needs_input")
|
|
704
|
+
fingerprint = digest({"request": req.fingerprint, "plan": token})
|
|
705
|
+
ledger = Ledger()
|
|
706
|
+
previous = ledger.get(key, fingerprint)
|
|
707
|
+
if previous is not None:
|
|
708
|
+
ledger.close()
|
|
709
|
+
return previous
|
|
710
|
+
with contextlib.ExitStack() as locks:
|
|
711
|
+
locks.enter_context(configuration_lock())
|
|
712
|
+
plan = build(req, cfg_path, online=online)
|
|
713
|
+
locks.enter_context(repository_lock(plan.common))
|
|
714
|
+
previous = ledger.get(key, fingerprint)
|
|
715
|
+
if previous is not None:
|
|
716
|
+
ledger.close()
|
|
717
|
+
return previous
|
|
718
|
+
if plan.decision != "ready":
|
|
719
|
+
result = dict(plan.result(), command="run", status="blocked")
|
|
720
|
+
else:
|
|
721
|
+
fresh = build(req, cfg_path, online=online)
|
|
722
|
+
if fresh.decision != "ready" or fresh.token != token:
|
|
723
|
+
raise OperationError("stale_plan", "The reviewed state changed. Run preflight and review again.", "blocked")
|
|
724
|
+
from .operation_reconcile import capture
|
|
725
|
+
ledger.claim(key, fingerprint, intent=capture(fresh))
|
|
726
|
+
claimed = True
|
|
727
|
+
outcome = {"commit": _commit, "push": _push, "pr-create": _create_pr}[req.operation](fresh)
|
|
728
|
+
result = {"version": VERSION, "command": "run", "ok": True, "decision": "ready",
|
|
729
|
+
"status": "succeeded", "operation": req.operation, "repo": str(req.repo),
|
|
730
|
+
"problems": [], "outcome": outcome}
|
|
731
|
+
except OperationError as exc:
|
|
732
|
+
result = failure("run", exc, req)
|
|
733
|
+
if claimed:
|
|
734
|
+
result["status"] = "failed" if exc.code == "execution_failed" else "indeterminate"
|
|
735
|
+
if result["status"] == "indeterminate":
|
|
736
|
+
result["decision"] = "indeterminate"
|
|
737
|
+
except (OSError, ConfigError, ValueError, sqlite3.Error) as exc:
|
|
738
|
+
result = failure("run", OperationError("operation_failed", f"Operation failed ({type(exc).__name__}); "
|
|
739
|
+
"inspect status and effects before retrying."), req)
|
|
740
|
+
result["status"] = "indeterminate" if claimed else "blocked"
|
|
741
|
+
except KeyboardInterrupt:
|
|
742
|
+
result = failure("run", OperationError("cancelled", "Operation cancelled. Inspect its effects before retrying."), req)
|
|
743
|
+
result["status"] = "indeterminate" if claimed else "blocked"
|
|
744
|
+
result.update(idempotency_key=key, replayed=False)
|
|
745
|
+
if ledger is not None:
|
|
746
|
+
try:
|
|
747
|
+
if claimed:
|
|
748
|
+
ledger.finish(key, result)
|
|
749
|
+
except (OSError, sqlite3.Error):
|
|
750
|
+
result = failure("run", OperationError("journal_failed", "Could not persist the outcome. "
|
|
751
|
+
"The operation may have succeeded; do not repeat it."), req)
|
|
752
|
+
result.update(status="indeterminate", idempotency_key=key, replayed=False)
|
|
753
|
+
finally:
|
|
754
|
+
ledger.close()
|
|
755
|
+
return result
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def status(key: str) -> dict:
|
|
759
|
+
try:
|
|
760
|
+
request_key(key)
|
|
761
|
+
with contextlib.closing(Ledger(readonly=True)) as ledger:
|
|
762
|
+
result = ledger.get(key)
|
|
763
|
+
if result is None:
|
|
764
|
+
raise OperationError("unknown_request", "No operation has this key.", "needs_input")
|
|
765
|
+
return dict(result, command="operation-status")
|
|
766
|
+
except OperationError as exc:
|
|
767
|
+
return failure("operation-status", exc)
|
|
768
|
+
except (OSError, ValueError, sqlite3.Error):
|
|
769
|
+
return failure("operation-status", OperationError("journal_failed", "Could not read the operation ledger."))
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def _read_request(path: str) -> object:
|
|
773
|
+
if path == "-":
|
|
774
|
+
return decode(sys.stdin.read(MAX_REQUEST + 1))
|
|
775
|
+
with open(path, encoding="utf-8") as stream:
|
|
776
|
+
return decode(stream.read(MAX_REQUEST + 1))
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def _emit(result: dict) -> int:
|
|
780
|
+
jsonout.emit(result["command"], result)
|
|
781
|
+
return 0 if result["ok"] else (3 if result["decision"] == "blocked" else 2)
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _command(args: argparse.Namespace) -> int:
|
|
785
|
+
if args.command == "operation-reconcile":
|
|
786
|
+
from .operation_reconcile import SCHEMA, reconcile
|
|
787
|
+
if args.schema:
|
|
788
|
+
jsonout.emit("operation-reconcile-schema", {"version": VERSION, "result": SCHEMA})
|
|
789
|
+
return 0
|
|
790
|
+
return _emit(reconcile(args.idempotency_key, online=args.online))
|
|
791
|
+
if getattr(args, "schema", False):
|
|
792
|
+
jsonout.emit("preflight-schema", {"version": VERSION, "request": REQUEST_SCHEMA, "result": RESULT_SCHEMA})
|
|
793
|
+
return 0
|
|
794
|
+
if args.command == "operation-status":
|
|
795
|
+
return _emit(status(args.idempotency_key))
|
|
796
|
+
try:
|
|
797
|
+
if not args.request:
|
|
798
|
+
raise OperationError("missing_input", "Supply --request FILE (or - for stdin).", "needs_input")
|
|
799
|
+
raw = _read_request(args.request)
|
|
800
|
+
except (OSError, UnicodeError):
|
|
801
|
+
return _emit(failure(args.command, OperationError("invalid_request", "Could not read the request.", "needs_input")))
|
|
802
|
+
except OperationError as exc:
|
|
803
|
+
return _emit(failure(args.command, exc))
|
|
804
|
+
if args.command == "preflight" or not (args.yes or args.write):
|
|
805
|
+
return _emit(preflight(raw, args.config, online=args.online))
|
|
806
|
+
return _emit(execute(raw, args.plan_token, args.idempotency_key, args.config,
|
|
807
|
+
online=args.online, authorized=args.yes or args.write))
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def command(args: argparse.Namespace) -> int:
|
|
811
|
+
with termination_signals():
|
|
812
|
+
return _command(args)
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def register(sub: argparse._SubParsersAction, common: argparse.ArgumentParser,
|
|
816
|
+
confirm: argparse.ArgumentParser) -> None:
|
|
817
|
+
from .args import capability
|
|
818
|
+
for name in ("preflight", "run"):
|
|
819
|
+
p = sub.add_parser(name, parents=[common, confirm] if name == "run" else [common],
|
|
820
|
+
help="structured agent " + ("operation checks" if name == "preflight" else "execution"))
|
|
821
|
+
p.add_argument("--request", metavar="FILE", help="versioned JSON request (- reads stdin)")
|
|
822
|
+
p.add_argument("--online", action="store_true", help="authorize destination/account network checks")
|
|
823
|
+
p.add_argument("--json", action="store_true", help="JSON is always emitted")
|
|
824
|
+
if name == "preflight":
|
|
825
|
+
p.add_argument("--schema", action="store_true", help="print the request and result JSON Schemas")
|
|
826
|
+
else:
|
|
827
|
+
p.add_argument("--plan-token", default="", help="token from the reviewed, ready preflight")
|
|
828
|
+
p.add_argument("--idempotency-key", default="", help="stable key for this operation and its retries")
|
|
829
|
+
p.set_defaults(func=command)
|
|
830
|
+
p = sub.add_parser("operation-status", help="read a durable operation result without executing")
|
|
831
|
+
p.add_argument("--idempotency-key", required=True)
|
|
832
|
+
p.add_argument("--json", action="store_true")
|
|
833
|
+
p.set_defaults(func=command)
|
|
834
|
+
p = sub.add_parser("operation-reconcile", help="observe recorded intent without repeating a write")
|
|
835
|
+
p.add_argument("--idempotency-key", default="")
|
|
836
|
+
p.add_argument("--online", action="store_true", help="authorize remote/account reads")
|
|
837
|
+
p.add_argument("--schema", action="store_true", help="print the observation result JSON Schema")
|
|
838
|
+
p.add_argument("--json", action="store_true")
|
|
839
|
+
p.set_defaults(func=command)
|
|
840
|
+
capability("preflight.v1", "run.v1", "operation-status.v1")
|
|
841
|
+
capability("operation-reconcile.v1")
|