raidlock 0.1.0__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.
- raidlock/__init__.py +11 -0
- raidlock/__main__.py +5 -0
- raidlock/_python.py +58 -0
- raidlock/achievements.py +156 -0
- raidlock/approvals.py +237 -0
- raidlock/bench.py +286 -0
- raidlock/bridge/__init__.py +186 -0
- raidlock/budgets.py +178 -0
- raidlock/cli.py +2153 -0
- raidlock/commands_install.py +114 -0
- raidlock/core/__init__.py +26 -0
- raidlock/core/advisories.py +16 -0
- raidlock/core/billing.py +54 -0
- raidlock/core/board.py +2251 -0
- raidlock/core/brief.py +111 -0
- raidlock/core/clock.py +40 -0
- raidlock/core/cost.py +198 -0
- raidlock/core/db.py +608 -0
- raidlock/core/errors.py +52 -0
- raidlock/core/exe.py +6 -0
- raidlock/core/hints.py +32 -0
- raidlock/core/identity.py +285 -0
- raidlock/core/lead.py +758 -0
- raidlock/core/lead_env.py +23 -0
- raidlock/core/lead_hook.py +42 -0
- raidlock/core/leases.py +19 -0
- raidlock/core/lock.py +99 -0
- raidlock/core/messages.py +98 -0
- raidlock/core/migrate.py +579 -0
- raidlock/core/model.py +126 -0
- raidlock/core/models.py +109 -0
- raidlock/core/paths.py +61 -0
- raidlock/core/portable.py +53 -0
- raidlock/core/receipts.py +73 -0
- raidlock/core/registry.py +39 -0
- raidlock/core/repos.py +250 -0
- raidlock/core/safeio.py +201 -0
- raidlock/core/status.py +52 -0
- raidlock/core/store.py +107 -0
- raidlock/core/tokens.py +77 -0
- raidlock/core/transcript.py +208 -0
- raidlock/duel.py +371 -0
- raidlock/foresight.py +191 -0
- raidlock/frames.py +162 -0
- raidlock/gitguard.py +753 -0
- raidlock/guard.py +239 -0
- raidlock/hooks.py +1526 -0
- raidlock/infer.py +205 -0
- raidlock/limits.py +685 -0
- raidlock/lore/__init__.py +22 -0
- raidlock/lore/approval.py +145 -0
- raidlock/lore/capture.py +146 -0
- raidlock/lore/cli.py +109 -0
- raidlock/lore/distill.py +17 -0
- raidlock/lore/frontmatter.py +56 -0
- raidlock/lore/inject.py +127 -0
- raidlock/lore/vault.py +264 -0
- raidlock/mcp/__init__.py +34 -0
- raidlock/mcp/resources.py +72 -0
- raidlock/mcp/server.py +184 -0
- raidlock/mcp/tools.py +178 -0
- raidlock/notify.py +144 -0
- raidlock/party.py +239 -0
- raidlock/pr.py +575 -0
- raidlock/prsync.py +318 -0
- raidlock/recipes/README.md +34 -0
- raidlock/recipes/__init__.py +105 -0
- raidlock/recipes/docs-refresh.md +15 -0
- raidlock/recipes/multi-repo-refactor.md +15 -0
- raidlock/recipes/security-audit.md +27 -0
- raidlock/recipes/upgrade-deps.md +15 -0
- raidlock/reputation.py +194 -0
- raidlock/resume.py +329 -0
- raidlock/resurrect.py +419 -0
- raidlock/routing.py +419 -0
- raidlock/runners/__init__.py +16 -0
- raidlock/runners/base.py +341 -0
- raidlock/runners/brief.py +176 -0
- raidlock/runners/claude_code.py +121 -0
- raidlock/runners/cli.py +110 -0
- raidlock/runners/codex.py +114 -0
- raidlock/runners/gemini.py +64 -0
- raidlock/runners/isolation.py +391 -0
- raidlock/runners/policy.py +123 -0
- raidlock/runners/runner.py +921 -0
- raidlock/runners/shell.py +35 -0
- raidlock/runners/verifycache.py +93 -0
- raidlock/runners/workspace.py +376 -0
- raidlock/scan.py +357 -0
- raidlock/snapshot.py +151 -0
- raidlock/treasury.py +210 -0
- raidlock/tui.py +223 -0
- raidlock/web/__init__.py +5 -0
- raidlock/web/achievements/view.css +19 -0
- raidlock/web/achievements/view.js +58 -0
- raidlock/web/bosses/view.css +19 -0
- raidlock/web/bosses/view.js +46 -0
- raidlock/web/cli.py +90 -0
- raidlock/web/data.py +687 -0
- raidlock/web/export.py +153 -0
- raidlock/web/frames/view.css +25 -0
- raidlock/web/frames/view.js +63 -0
- raidlock/web/graph/__init__.py +142 -0
- raidlock/web/graph/view.css +33 -0
- raidlock/web/graph/view.js +136 -0
- raidlock/web/raidmap/__init__.py +205 -0
- raidlock/web/raidmap/view.css +15 -0
- raidlock/web/raidmap/view.js +47 -0
- raidlock/web/replay/__init__.py +258 -0
- raidlock/web/replay/view.css +13 -0
- raidlock/web/replay/view.js +127 -0
- raidlock/web/server.py +292 -0
- raidlock/web/static/app.css +851 -0
- raidlock/web/static/app.js +2102 -0
- raidlock/web/static/fonts/OFL.txt +93 -0
- raidlock/web/static/fonts/cinzel-600.woff2 +0 -0
- raidlock/web/static/fonts/cinzel-700.woff2 +0 -0
- raidlock/web/static/icon-32.png +0 -0
- raidlock/web/static/icon-raid.svg +1 -0
- raidlock/web/static/icon.svg +1 -0
- raidlock/web/static/index.html +122 -0
- raidlock-0.1.0.dist-info/METADATA +186 -0
- raidlock-0.1.0.dist-info/RECORD +126 -0
- raidlock-0.1.0.dist-info/WHEEL +4 -0
- raidlock-0.1.0.dist-info/entry_points.txt +34 -0
- raidlock-0.1.0.dist-info/licenses/LICENSE +21 -0
raidlock/pr.py
ADDED
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
"""Open a pull request from a card's verified commit: receipt gate, lock check, patch scan, branch push, gh."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
import tempfile
|
|
7
|
+
import unicodedata
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from contextvars import ContextVar
|
|
10
|
+
from functools import wraps
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .core import clock, exe, model, receipts, repos
|
|
14
|
+
from .core import transcript as T
|
|
15
|
+
from .core.errors import Precondition
|
|
16
|
+
from .runners import policy
|
|
17
|
+
from .runners import workspace as W
|
|
18
|
+
from .runners.base import SECRET_NAME
|
|
19
|
+
from .scan import scan_patch
|
|
20
|
+
|
|
21
|
+
REDACT_KINDS = ("agent", "model", "provider")
|
|
22
|
+
REDACTED = "[redacted]"
|
|
23
|
+
REF_NAME = re.compile(r"^(?![-./])(?!.*\.\.)(?!.*//)(?!.*@\{)[A-Za-z0-9._/-]{1,120}(?<![./])$")
|
|
24
|
+
REMOTE_NAME = re.compile(r"^(?![-.])[A-Za-z0-9._-]{1,60}$")
|
|
25
|
+
TERM = re.compile(r"^[^\x00-\x1f\x7f,]{2,80}$")
|
|
26
|
+
VENDOR = (
|
|
27
|
+
r"claude[\w.-]*|anthropic|codex[\w.-]*|openai|chatgpt|gpt[\w.-]*|o[1-9](?:-[\w.]+)?|gemini[\w.-]*|"
|
|
28
|
+
r"opus[\w.-]*|sonnet[\w.-]*|haiku[\w.-]*|copilot|llama[\w.-]*|mistral[\w.-]*|deepseek[\w.-]*|qwen[\w.-]*"
|
|
29
|
+
)
|
|
30
|
+
PR_URL = re.compile(r"https?://[^\s]+/pull/(\d+)")
|
|
31
|
+
MAX_FILES = 60
|
|
32
|
+
GH_TIMEOUT = 120
|
|
33
|
+
GIT_TIMEOUT = 300
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_SECRET_VALUES = ContextVar("pr_secret_values", default=())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _normalize(text):
|
|
40
|
+
return "".join(c for c in unicodedata.normalize("NFKC", str(text)) if unicodedata.category(c) != "Cf")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def sanitize(text, env=None):
|
|
44
|
+
text = _normalize(text or "")
|
|
45
|
+
values = list(_SECRET_VALUES.get())
|
|
46
|
+
for source in (os.environ, env or {}):
|
|
47
|
+
values.extend(v for k, v in source.items() if SECRET_NAME.search(k) and v)
|
|
48
|
+
for value in sorted({_normalize(v) for v in values if v}, key=len, reverse=True):
|
|
49
|
+
if value:
|
|
50
|
+
text = text.replace(value, T.REDACTED)
|
|
51
|
+
return T.redact(text)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def protected(fn):
|
|
55
|
+
@wraps(fn)
|
|
56
|
+
def call(board, *args, **kwargs):
|
|
57
|
+
values = list(_SECRET_VALUES.get())
|
|
58
|
+
values.extend(v for k, v in board.env.items() if SECRET_NAME.search(k) and v)
|
|
59
|
+
for arg in args:
|
|
60
|
+
values.extend(getattr(arg, field, "") for field in ("lead_secret", "token"))
|
|
61
|
+
for path in Path(board.home, "tokens", board.uid).glob("*.token"):
|
|
62
|
+
try:
|
|
63
|
+
values.append(path.read_text(encoding="utf-8").strip())
|
|
64
|
+
except OSError:
|
|
65
|
+
continue
|
|
66
|
+
token = _SECRET_VALUES.set(tuple(values))
|
|
67
|
+
try:
|
|
68
|
+
return fn(board, *args, **kwargs)
|
|
69
|
+
except Precondition as exc:
|
|
70
|
+
exc.message = sanitize(exc.message)
|
|
71
|
+
exc.args = (exc.message,)
|
|
72
|
+
raise
|
|
73
|
+
finally:
|
|
74
|
+
_SECRET_VALUES.reset(token)
|
|
75
|
+
|
|
76
|
+
return call
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def local_git(cwd, *args, binary=False, **kwargs):
|
|
80
|
+
proc = remote_git(cwd, *args, env=W.git_env(), binary=binary, **kwargs)
|
|
81
|
+
return proc.stdout
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _words(value):
|
|
85
|
+
items = value if isinstance(value, (list, tuple)) else str(value or "").split(",")
|
|
86
|
+
return [str(w).strip() for w in items if str(w).strip()]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def setting_value(key, value):
|
|
90
|
+
"""Validate a `pr.*` board setting; returns the stored value ("" or [] clears it)."""
|
|
91
|
+
if key == "pr.redact":
|
|
92
|
+
words = [w.lower() for w in _words(value)]
|
|
93
|
+
if words in (["none"], ["off"], []):
|
|
94
|
+
return []
|
|
95
|
+
if "all" in words:
|
|
96
|
+
return list(REDACT_KINDS)
|
|
97
|
+
bad = [w for w in words if w not in REDACT_KINDS]
|
|
98
|
+
if bad:
|
|
99
|
+
raise Precondition(f"pr.redact takes {', '.join(REDACT_KINDS)}, all or none", value=",".join(bad))
|
|
100
|
+
return sorted(set(words))
|
|
101
|
+
if key == "pr.terms":
|
|
102
|
+
words = _words(value)
|
|
103
|
+
if len(words) > 50:
|
|
104
|
+
raise Precondition("pr.terms takes at most 50 terms")
|
|
105
|
+
for w in words:
|
|
106
|
+
if not TERM.match(w):
|
|
107
|
+
raise Precondition("each pr.terms entry is 2-80 printable characters", term=w[:80])
|
|
108
|
+
return sorted(set(words), key=str.lower)
|
|
109
|
+
text = str(value or "").strip()
|
|
110
|
+
if key == "pr.base":
|
|
111
|
+
if text and not REF_NAME.match(text):
|
|
112
|
+
raise Precondition("pr.base must be a plain branch name", value=text[:120])
|
|
113
|
+
return text
|
|
114
|
+
if key == "pr.remote":
|
|
115
|
+
if text and not REMOTE_NAME.match(text):
|
|
116
|
+
raise Precondition("pr.remote must be a plain remote name", value=text[:60])
|
|
117
|
+
return text
|
|
118
|
+
raise Precondition(f"unknown setting {key}", key=key)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def config(board, redact=None, terms=None, base=None, remote=None):
|
|
122
|
+
"""Board `pr.*` settings merged with flags. Flags add redaction, they never remove what the board requires."""
|
|
123
|
+
s = board.settings
|
|
124
|
+
kinds = set(s.get("pr.redact") or [])
|
|
125
|
+
if redact:
|
|
126
|
+
kinds |= set(setting_value("pr.redact", redact))
|
|
127
|
+
extra = list(s.get("pr.terms") or [])
|
|
128
|
+
if terms:
|
|
129
|
+
extra += setting_value("pr.terms", terms)
|
|
130
|
+
return {
|
|
131
|
+
"redact": sorted(kinds),
|
|
132
|
+
"terms": sorted(set(extra), key=str.lower),
|
|
133
|
+
"base": setting_value("pr.base", base) if base else s.get("pr.base", ""),
|
|
134
|
+
"remote": setting_value("pr.remote", remote) if remote else (s.get("pr.remote") or "origin"),
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def gh_env(env=None):
|
|
139
|
+
env = dict(os.environ if env is None else env)
|
|
140
|
+
return {k: v for k, v in env.items() if not k.startswith("RAIDLOCK_")}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def gh(args, cwd=None, env=None, timeout=GH_TIMEOUT):
|
|
144
|
+
"""Run gh with an argv list; None when gh is not installed."""
|
|
145
|
+
try:
|
|
146
|
+
return subprocess.run(
|
|
147
|
+
[exe.find("gh"), *args],
|
|
148
|
+
cwd=cwd,
|
|
149
|
+
env=gh_env(env),
|
|
150
|
+
capture_output=True,
|
|
151
|
+
text=True,
|
|
152
|
+
encoding="utf-8",
|
|
153
|
+
errors="replace",
|
|
154
|
+
timeout=timeout,
|
|
155
|
+
)
|
|
156
|
+
except (FileNotFoundError, PermissionError, NotADirectoryError):
|
|
157
|
+
return None
|
|
158
|
+
except subprocess.TimeoutExpired:
|
|
159
|
+
raise Precondition("gh command timed out") from None
|
|
160
|
+
except OSError as exc:
|
|
161
|
+
raise Precondition(f"gh command failed: {sanitize(str(exc), env)[:300]}") from None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def gh_ready(env=None):
|
|
165
|
+
proc = gh(["auth", "status"], env=env, timeout=30)
|
|
166
|
+
return bool(proc) and proc.returncode == 0
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def remote_git(cwd, *args, env=None, check=True, binary=False):
|
|
170
|
+
"""git with the user's credentials (pushes and fetches need them) but no hooks, fsmonitor or prompts."""
|
|
171
|
+
full = gh_env(env)
|
|
172
|
+
full["GIT_TERMINAL_PROMPT"] = "0"
|
|
173
|
+
try:
|
|
174
|
+
proc = W.git_run(cwd, *args, env=full, binary=binary, timeout=GIT_TIMEOUT)
|
|
175
|
+
except subprocess.TimeoutExpired:
|
|
176
|
+
raise Precondition("git command timed out") from None
|
|
177
|
+
except OSError as exc:
|
|
178
|
+
raise Precondition(f"git command failed: {sanitize(str(exc), env)[:300]}") from None
|
|
179
|
+
if check and proc.returncode != 0:
|
|
180
|
+
err = proc.stderr.decode("utf-8", "replace") if binary else proc.stderr
|
|
181
|
+
raise Precondition(sanitize(f"git {args[0]} failed: {err.strip()}", env)[:300])
|
|
182
|
+
return proc
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def verified(board, card):
|
|
186
|
+
"""The card's passing, signed verify receipt and the checkout it covers. Same gate as `done`."""
|
|
187
|
+
cid = card["id"]
|
|
188
|
+
if card["lane"] not in ("review", "done"):
|
|
189
|
+
raise Precondition(f"{cid} is in {card['lane']}; a pull request opens from review or done", card=cid)
|
|
190
|
+
if not card.get("verify_cmd"):
|
|
191
|
+
raise Precondition(
|
|
192
|
+
f"{cid} has no verify command; a pull request needs a passing verify receipt",
|
|
193
|
+
card=cid,
|
|
194
|
+
hint="the lead sets one with raidlock new --verify-cmd, then runs raidlock verify",
|
|
195
|
+
)
|
|
196
|
+
last = model.last_verification(card)
|
|
197
|
+
if not last or not receipts.valid(board, card, last):
|
|
198
|
+
raise Precondition(f"{cid} needs a valid verification receipt", card=cid, hint=f"raidlock verify {cid}")
|
|
199
|
+
if not last.get("passed") or last.get("exit") != 0:
|
|
200
|
+
raise Precondition(f"the last verify run of {cid} failed", card=cid, hint=f"raidlock verify {cid}")
|
|
201
|
+
source = board._verification_source(card)
|
|
202
|
+
if last.get("repo") != board._verification_repo(card, source):
|
|
203
|
+
raise Precondition(f"{cid} verification receipt repo does not match the card")
|
|
204
|
+
if last.get("sha") != local_git(source, "rev-parse", "HEAD").strip():
|
|
205
|
+
raise Precondition(
|
|
206
|
+
f"{cid} verification receipt SHA does not match current HEAD", card=cid, hint=f"raidlock verify {cid}"
|
|
207
|
+
)
|
|
208
|
+
return last, source
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _redactor(board, card, cfg, run_info):
|
|
212
|
+
terms = set(cfg["terms"])
|
|
213
|
+
agents = board.agents()
|
|
214
|
+
if "agent" in cfg["redact"]:
|
|
215
|
+
terms |= set(agents)
|
|
216
|
+
terms |= {card.get("owner") or ""}
|
|
217
|
+
terms |= set(card.get("reviewers") or [])
|
|
218
|
+
terms |= {r.get("by", "") for r in card.get("reviews", [])}
|
|
219
|
+
terms |= {v.get("by", "") for v in card.get("verifications", [])}
|
|
220
|
+
terms |= {run_info.get("agent", "")}
|
|
221
|
+
if "model" in cfg["redact"]:
|
|
222
|
+
terms |= {a.get("model", "") for a in agents.values()}
|
|
223
|
+
terms |= {r.get("model", "") for r in card.get("reviews", [])}
|
|
224
|
+
terms |= {run_info.get("model", "")}
|
|
225
|
+
if "provider" in cfg["redact"]:
|
|
226
|
+
terms |= {a.get(k, "") for a in agents.values() for k in ("provider", "runtime")}
|
|
227
|
+
terms |= {r.get("provider", "") for r in card.get("reviews", [])}
|
|
228
|
+
terms |= {run_info.get(k, "") for k in ("provider", "runtime")}
|
|
229
|
+
terms = sorted({_normalize(t) for t in terms if t and _normalize(t)}, key=lambda t: (-len(t), t))
|
|
230
|
+
parts = [re.escape(t) for t in terms]
|
|
231
|
+
if {"model", "provider"} & set(cfg["redact"]):
|
|
232
|
+
parts.append(VENDOR)
|
|
233
|
+
if not parts:
|
|
234
|
+
return None
|
|
235
|
+
return re.compile("(?:" + "|".join(parts) + ")", re.IGNORECASE)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _scrub(pattern, text):
|
|
239
|
+
return pattern.sub(REDACTED, _normalize(text)) if pattern else _normalize(text)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _run_info(board, card_id):
|
|
243
|
+
import json
|
|
244
|
+
|
|
245
|
+
from .runners.runner import latest_patch
|
|
246
|
+
|
|
247
|
+
folder, _ = latest_patch(board, card_id)
|
|
248
|
+
if not folder:
|
|
249
|
+
return {}
|
|
250
|
+
try:
|
|
251
|
+
with open(os.path.join(folder, "run.json"), encoding="utf-8") as f:
|
|
252
|
+
info = json.load(f)
|
|
253
|
+
except (OSError, ValueError):
|
|
254
|
+
return {}
|
|
255
|
+
return info if isinstance(info, dict) else {}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _md(text, limit=300):
|
|
259
|
+
text = sanitize(str(text or "")).replace("\r", " ").replace("\n", " ").strip()
|
|
260
|
+
text = text.replace("`", "'")
|
|
261
|
+
return text[:limit]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@protected
|
|
265
|
+
def render(board, card, receipt, check, cfg, run_info):
|
|
266
|
+
"""(title, body) for the pull request. Redaction drops whole fields and scrubs every remaining line."""
|
|
267
|
+
kinds = set(cfg["redact"])
|
|
268
|
+
agents = board.agents()
|
|
269
|
+
owner = card.get("owner") or ""
|
|
270
|
+
rec = agents.get(owner, {})
|
|
271
|
+
model_id = rec.get("model") or run_info.get("model") or ""
|
|
272
|
+
need = board.settings["require_review"]
|
|
273
|
+
approvals = board.approvals(card)
|
|
274
|
+
lines = [f"Card `{card['id']}`: {_md(card['title'])}", ""]
|
|
275
|
+
if card.get("scope"):
|
|
276
|
+
lines += [_md(card["scope"], 600), ""]
|
|
277
|
+
who = []
|
|
278
|
+
if "agent" not in kinds:
|
|
279
|
+
who.append(f"- Agent: {owner or '-'}")
|
|
280
|
+
if "provider" not in kinds:
|
|
281
|
+
who.append(f"- Runtime: {rec.get('runtime') or run_info.get('runtime') or '-'}")
|
|
282
|
+
who.append(f"- Provider: {rec.get('provider') or run_info.get('provider') or '-'}")
|
|
283
|
+
if "model" not in kinds:
|
|
284
|
+
who.append(f"- Model: {model_id or '-'}")
|
|
285
|
+
if who:
|
|
286
|
+
lines += ["### Agent", *who, ""]
|
|
287
|
+
files = check["changed"]
|
|
288
|
+
lines += [
|
|
289
|
+
"### Lock check",
|
|
290
|
+
f"- Locks: {', '.join(f'`{_md(x, 200)}`' for x in card.get('locks', [])) or '-'}",
|
|
291
|
+
f"- Changed files ({len(files)}): "
|
|
292
|
+
+ ", ".join(f"`{_md(p, 200)}`" for p in files[:MAX_FILES])
|
|
293
|
+
+ (f" and {len(files) - MAX_FILES} more" if len(files) > MAX_FILES else ""),
|
|
294
|
+
f"- Outside locks: {len(check['outside'])}",
|
|
295
|
+
f"- Denied paths: {len(check['denied'])}",
|
|
296
|
+
f"- Patch scan findings: {len(check['findings'])}",
|
|
297
|
+
"",
|
|
298
|
+
"### Verify receipt",
|
|
299
|
+
f"- Command: `{_md(receipt.get('cmd'), 300)}`",
|
|
300
|
+
f"- Commit: `{receipt.get('sha', '')}`",
|
|
301
|
+
f"- Result: exit {receipt.get('exit')} in {receipt.get('duration_ms', 0)} ms, isolation "
|
|
302
|
+
f"{_md(receipt.get('isolation'), 80) or '-'}",
|
|
303
|
+
f"- Recorded: {receipt.get('ts', '')}, signature valid, command hash `{receipt.get('command_hash', '')[:16]}`",
|
|
304
|
+
"",
|
|
305
|
+
"### Review",
|
|
306
|
+
f"- Required approvals: {need['count']}" + (" from another provider" if need["other_provider"] else ""),
|
|
307
|
+
]
|
|
308
|
+
if "agent" in kinds:
|
|
309
|
+
lines.append(f"- Approvals: {len(approvals)}")
|
|
310
|
+
else:
|
|
311
|
+
lines.append(f"- Approvals: {len(approvals)}" + (f" ({', '.join(approvals)})" if approvals else ""))
|
|
312
|
+
current = [r for r in card.get("reviews", []) if r.get("rev") == card.get("rev", 0)]
|
|
313
|
+
for r in current[-10:]:
|
|
314
|
+
by = "" if "agent" in kinds else f" by {r.get('by', '')}"
|
|
315
|
+
note = _md(r.get("note"), 200)
|
|
316
|
+
lines.append(f"- {r.get('verdict')}{by}" + (f": {note}" if note else ""))
|
|
317
|
+
lines += ["", f"Opened with raidlock from card `{card['id']}` (rev {card.get('rev', 0)})."]
|
|
318
|
+
pattern = _redactor(board, card, cfg, run_info)
|
|
319
|
+
title = _scrub(pattern, _md(card["title"], 200)) or card["id"]
|
|
320
|
+
body = _scrub(pattern, sanitize("\n".join(lines))) + "\n"
|
|
321
|
+
return sanitize(title), sanitize(body), pattern
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@contextmanager
|
|
325
|
+
def _changes(board, card, source, sha, url, base):
|
|
326
|
+
"""Fetch the verified commit and the base into a private store; yield (store, lock check and commit text)."""
|
|
327
|
+
target = policy.target(board.workspace, board.dir, card["locks"])
|
|
328
|
+
root = os.path.join(board.home, "pr")
|
|
329
|
+
os.makedirs(root, mode=0o700, exist_ok=True)
|
|
330
|
+
with tempfile.TemporaryDirectory(dir=root) as tmp:
|
|
331
|
+
store = os.path.join(tmp, "store.git")
|
|
332
|
+
local_git(tmp, "init", "--quiet", "--bare", store)
|
|
333
|
+
local_git(
|
|
334
|
+
store,
|
|
335
|
+
"-c",
|
|
336
|
+
"uploadpack.allowAnySHA1InWant=true",
|
|
337
|
+
"fetch",
|
|
338
|
+
"--quiet",
|
|
339
|
+
"--no-tags",
|
|
340
|
+
os.path.realpath(source),
|
|
341
|
+
sha,
|
|
342
|
+
)
|
|
343
|
+
remote_git(store, "fetch", "--quiet", "--no-tags", url, f"+refs/heads/{base}:refs/raid/base")
|
|
344
|
+
mb = local_git(store, "merge-base", sha, "refs/raid/base", check=False).strip()
|
|
345
|
+
if not mb:
|
|
346
|
+
raise Precondition(f"the verified commit shares no history with {base}", base=base)
|
|
347
|
+
changed = set()
|
|
348
|
+
findings = []
|
|
349
|
+
commits = local_git(store, "rev-list", sha, "^refs/raid/base").splitlines()
|
|
350
|
+
for commit in commits:
|
|
351
|
+
parents = local_git(store, "rev-list", "--parents", "-n", "1", commit).split()[1:]
|
|
352
|
+
for parent in parents or [None]:
|
|
353
|
+
args = ["diff", parent, commit] if parent else ["diff-tree", "--root", "--no-commit-id", "-r", commit]
|
|
354
|
+
paths = local_git(store, *args, "--name-only", "--no-renames", "-z").split("\0")
|
|
355
|
+
changed.update(p for p in paths if p)
|
|
356
|
+
patch = local_git(
|
|
357
|
+
store, *args, "-p", "--binary", "--no-renames", "--no-ext-diff", "--no-textconv", binary=True
|
|
358
|
+
)
|
|
359
|
+
try:
|
|
360
|
+
findings.extend(scan_patch(patch, rels=target["rels"], ci=target["ci"]))
|
|
361
|
+
from .scan import patch_links
|
|
362
|
+
|
|
363
|
+
for path in policy.check_links(patch_links(patch.decode()), target["rels"], target["ci"]):
|
|
364
|
+
findings.append({"kind": "path", "rule": "unsafe-symlink", "path": path, "line": None})
|
|
365
|
+
except Precondition as exc:
|
|
366
|
+
findings.append({"kind": "scan", "rule": exc.message, "path": "", "line": None})
|
|
367
|
+
text = _normalize(patch.decode("utf-8", "replace"))
|
|
368
|
+
if sanitize(text) != text:
|
|
369
|
+
findings.append({"kind": "secret", "rule": "secret", "path": "", "line": None})
|
|
370
|
+
changed = sorted(changed)
|
|
371
|
+
if not changed:
|
|
372
|
+
raise Precondition(f"the verified commit has no changes against {base}", base=base)
|
|
373
|
+
found = policy.check(changed, target["rels"], target["ci"])
|
|
374
|
+
links = {}
|
|
375
|
+
for entry in local_git(store, "ls-tree", "-r", "-z", sha).split("\0"):
|
|
376
|
+
if entry.startswith("120000 "):
|
|
377
|
+
metadata, path = entry.split("\t", 1)
|
|
378
|
+
links[path] = local_git(store, "cat-file", "blob", metadata.split()[2])
|
|
379
|
+
found["denied"] = sorted(
|
|
380
|
+
set(found["denied"]) | set(policy.check_links(links, target["rels"], target["ci"], paths=changed))
|
|
381
|
+
)
|
|
382
|
+
meta = local_git(store, "log", "--format=%an%n%ae%n%cn%n%ce%n%B", sha, "^refs/raid/base")
|
|
383
|
+
yield (
|
|
384
|
+
store,
|
|
385
|
+
{
|
|
386
|
+
"repo": target["repo"],
|
|
387
|
+
"repo_path": target["path"],
|
|
388
|
+
"merge_base": mb,
|
|
389
|
+
"changed": changed,
|
|
390
|
+
"outside": found["outside"],
|
|
391
|
+
"denied": found["denied"],
|
|
392
|
+
"findings": [{k: f.get(k) for k in ("kind", "rule", "path", "line")} for f in findings],
|
|
393
|
+
"commit_text": meta,
|
|
394
|
+
},
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _remote_url(repo_path, remote):
|
|
399
|
+
gdir = repos.git_dir(repo_path)
|
|
400
|
+
url = repos._read_remotes(os.path.join(repos.common_dir(gdir), "config")).get(remote) if gdir else None
|
|
401
|
+
if not url:
|
|
402
|
+
raise Precondition(f"the repo has no remote {remote}", hint="raidlock config set pr.remote <name>")
|
|
403
|
+
return url
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _default_base(repo_path, url):
|
|
407
|
+
proc = remote_git(repo_path, "ls-remote", "--symref", url, "HEAD", check=False)
|
|
408
|
+
for line in (proc.stdout or "").splitlines():
|
|
409
|
+
if line.startswith("ref: refs/heads/") and line.endswith("\tHEAD"):
|
|
410
|
+
name = line[len("ref: refs/heads/") : -len("\tHEAD")]
|
|
411
|
+
if REF_NAME.match(name):
|
|
412
|
+
return name
|
|
413
|
+
return "main"
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _gh_repo(url):
|
|
417
|
+
from .core.repos import normalize_remote
|
|
418
|
+
|
|
419
|
+
norm = normalize_remote(url)
|
|
420
|
+
return "" if norm.startswith("path:") or norm.count("/") != 2 else norm
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
@protected
|
|
424
|
+
def open_pr(board, caller, card_id, dry_run=None, branch=None, draft=False, **flags):
|
|
425
|
+
"""Open (or plan, when dry) a pull request from the card's verified commit. Lead-only unless dry."""
|
|
426
|
+
board._auth(caller)
|
|
427
|
+
card = board.card(card_id)
|
|
428
|
+
receipt, source = verified(board, card)
|
|
429
|
+
cfg = config(board, **flags)
|
|
430
|
+
branch = branch or f"raid/{card['id']}"
|
|
431
|
+
if not REF_NAME.match(branch):
|
|
432
|
+
raise Precondition("branch must be a plain branch name", branch=branch[:120])
|
|
433
|
+
ready = gh_ready(board.env)
|
|
434
|
+
dry = (not ready) if dry_run is None else (bool(dry_run) or not ready)
|
|
435
|
+
if not dry:
|
|
436
|
+
board._require_lead(caller, "open a pull request")
|
|
437
|
+
target = policy.target(board.workspace, board.dir, card["locks"])
|
|
438
|
+
url = _remote_url(target["path"], cfg["remote"])
|
|
439
|
+
base = cfg["base"] or _default_base(target["path"], url)
|
|
440
|
+
sha = receipt["sha"]
|
|
441
|
+
with _changes(board, card, source, sha, url, base) as (store, check):
|
|
442
|
+
run_info = _run_info(board, card["id"])
|
|
443
|
+
title, body, pattern = render(board, card, receipt, check, cfg, run_info)
|
|
444
|
+
problems = []
|
|
445
|
+
if check["outside"]:
|
|
446
|
+
problems.append("changes outside the card's locks: " + ", ".join(check["outside"][:20]))
|
|
447
|
+
if check["denied"]:
|
|
448
|
+
problems.append("denied paths: " + ", ".join(check["denied"][:20]))
|
|
449
|
+
if check["findings"]:
|
|
450
|
+
problems.append("patch safety findings: " + ", ".join(sorted({f["rule"] for f in check["findings"]})))
|
|
451
|
+
if pattern and pattern.search(_normalize(branch)):
|
|
452
|
+
problems.append("the branch name contains a redacted term")
|
|
453
|
+
if pattern and pattern.search(_normalize(check["commit_text"])):
|
|
454
|
+
problems.append("commit messages or authors contain a redacted term")
|
|
455
|
+
if sanitize(check["commit_text"]) != _normalize(check["commit_text"]):
|
|
456
|
+
problems.append("commit metadata contains a secret")
|
|
457
|
+
result = {
|
|
458
|
+
"card": card["id"],
|
|
459
|
+
"dry_run": dry,
|
|
460
|
+
"gh_ready": ready,
|
|
461
|
+
"branch": branch,
|
|
462
|
+
"base": base,
|
|
463
|
+
"sha": sha,
|
|
464
|
+
"repo": check["repo"],
|
|
465
|
+
"title": title,
|
|
466
|
+
"body": body,
|
|
467
|
+
"redact": cfg["redact"],
|
|
468
|
+
"lock_check": {k: check[k] for k in ("changed", "outside", "denied", "findings")},
|
|
469
|
+
"problems": problems,
|
|
470
|
+
}
|
|
471
|
+
if problems:
|
|
472
|
+
raise Precondition(f"{card['id']} cannot be proposed: " + "; ".join(problems), card=card["id"])
|
|
473
|
+
if dry:
|
|
474
|
+
return result
|
|
475
|
+
with board.lead_write(caller, "open a pull request"):
|
|
476
|
+
current = board.card(card["id"])
|
|
477
|
+
current_receipt, current_source = verified(board, current)
|
|
478
|
+
if (
|
|
479
|
+
current != card
|
|
480
|
+
or current_receipt != receipt
|
|
481
|
+
or current_source != source
|
|
482
|
+
or config(board, **flags) != cfg
|
|
483
|
+
):
|
|
484
|
+
raise Precondition("card or verification changed while preparing the pull request")
|
|
485
|
+
proc = remote_git(
|
|
486
|
+
store, "push", "--no-verify", "--porcelain", url, f"{sha}:refs/heads/{branch}", check=False
|
|
487
|
+
)
|
|
488
|
+
if proc.returncode != 0:
|
|
489
|
+
raise Precondition(
|
|
490
|
+
f"push to {branch} was refused: {sanitize(proc.stderr).strip()[:200]}",
|
|
491
|
+
branch=branch,
|
|
492
|
+
hint="pick another branch with --branch",
|
|
493
|
+
)
|
|
494
|
+
pr = _create(check["repo_path"], url, branch, base, title, body, draft, board.env)
|
|
495
|
+
record = {
|
|
496
|
+
"number": pr["number"],
|
|
497
|
+
"url": pr["url"],
|
|
498
|
+
"branch": branch,
|
|
499
|
+
"base": base,
|
|
500
|
+
"sha": sha,
|
|
501
|
+
"repo": check["repo"],
|
|
502
|
+
"opened": clock.iso(),
|
|
503
|
+
"state": "OPEN",
|
|
504
|
+
"checks": {"state": "none", "passed": 0, "failed": 0, "pending": 0, "total": 0},
|
|
505
|
+
"existing": pr["existing"],
|
|
506
|
+
}
|
|
507
|
+
current["pr"] = record
|
|
508
|
+
board._note(current, caller.name, f"pull request #{record['number']} {record['url']} ({branch} -> {base})")
|
|
509
|
+
board._save_card(current)
|
|
510
|
+
board._record(
|
|
511
|
+
"status",
|
|
512
|
+
caller.name,
|
|
513
|
+
f"pull request #{record['number']} for {card['id']}",
|
|
514
|
+
task=card["id"],
|
|
515
|
+
data={"event": "pr_opened", "pr": record},
|
|
516
|
+
)
|
|
517
|
+
return {**result, "pr": record}
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _create(repo_path, url, branch, base, title, body, draft, env):
|
|
521
|
+
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".md", delete=False) as f:
|
|
522
|
+
f.write(body)
|
|
523
|
+
body_file = f.name
|
|
524
|
+
try:
|
|
525
|
+
args = ["pr", "create", "--head", branch, "--base", base, "--title", title, "--body-file", body_file]
|
|
526
|
+
repo = _gh_repo(url)
|
|
527
|
+
if repo:
|
|
528
|
+
args += ["--repo", repo]
|
|
529
|
+
if draft:
|
|
530
|
+
args.append("--draft")
|
|
531
|
+
proc = gh(args, cwd=repo_path, env=env)
|
|
532
|
+
finally:
|
|
533
|
+
os.unlink(body_file)
|
|
534
|
+
if proc is None:
|
|
535
|
+
raise Precondition("gh is not installed", hint="install the GitHub CLI and run gh auth login")
|
|
536
|
+
out = (proc.stdout or "") + "\n" + (proc.stderr or "")
|
|
537
|
+
match = PR_URL.search(proc.stdout or "") or (PR_URL.search(proc.stderr or "") if "already exists" in out else None)
|
|
538
|
+
if not match:
|
|
539
|
+
raise Precondition(f"gh pr create failed: {sanitize(proc.stderr or proc.stdout, env).strip()[:300]}")
|
|
540
|
+
return {"number": int(match.group(1)), "url": match.group(0), "existing": proc.returncode != 0}
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def register_cli(add_command):
|
|
544
|
+
def setup(p):
|
|
545
|
+
p.add_argument("id", help="card id")
|
|
546
|
+
p.add_argument("--dry-run", action="store_true", default=None, help="check and print, push nothing")
|
|
547
|
+
p.add_argument("--base", default=None, help="base branch (default: pr.base or the remote's HEAD)")
|
|
548
|
+
p.add_argument("--remote", default=None, help="remote in the card's repo (default: pr.remote or origin)")
|
|
549
|
+
p.add_argument("--branch", default=None, help="head branch (default: raid/<card>)")
|
|
550
|
+
p.add_argument("--redact", default=None, help="drop names from the PR: agent,model,provider or all")
|
|
551
|
+
p.add_argument("--redact-terms", default=None, help="extra comma-separated words to scrub")
|
|
552
|
+
p.add_argument("--draft", action="store_true")
|
|
553
|
+
|
|
554
|
+
def handler(ctx, a):
|
|
555
|
+
r = open_pr(
|
|
556
|
+
ctx.board,
|
|
557
|
+
ctx.caller(),
|
|
558
|
+
a.id,
|
|
559
|
+
dry_run=a.dry_run,
|
|
560
|
+
branch=a.branch,
|
|
561
|
+
draft=a.draft,
|
|
562
|
+
redact=a.redact,
|
|
563
|
+
terms=a.redact_terms,
|
|
564
|
+
base=a.base,
|
|
565
|
+
remote=a.remote,
|
|
566
|
+
)
|
|
567
|
+
if r["dry_run"]:
|
|
568
|
+
why = "" if r["gh_ready"] else " (gh is not authenticated)"
|
|
569
|
+
head = f"Dry run{why}: would push {r['sha'][:12]} to {r['branch']} and open a PR into {r['base']}."
|
|
570
|
+
return r, f"{head}\nTitle: {r['title']}\n\n{r['body']}".rstrip()
|
|
571
|
+
pr = r["pr"]
|
|
572
|
+
verb = "Linked existing" if pr["existing"] else "Opened"
|
|
573
|
+
return r, f"{verb} pull request #{pr['number']}: {pr['url']}"
|
|
574
|
+
|
|
575
|
+
add_command("pr", "lead: push a card's verified commit to a branch and open a pull request", setup, handler, True)
|