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.
Files changed (126) hide show
  1. raidlock/__init__.py +11 -0
  2. raidlock/__main__.py +5 -0
  3. raidlock/_python.py +58 -0
  4. raidlock/achievements.py +156 -0
  5. raidlock/approvals.py +237 -0
  6. raidlock/bench.py +286 -0
  7. raidlock/bridge/__init__.py +186 -0
  8. raidlock/budgets.py +178 -0
  9. raidlock/cli.py +2153 -0
  10. raidlock/commands_install.py +114 -0
  11. raidlock/core/__init__.py +26 -0
  12. raidlock/core/advisories.py +16 -0
  13. raidlock/core/billing.py +54 -0
  14. raidlock/core/board.py +2251 -0
  15. raidlock/core/brief.py +111 -0
  16. raidlock/core/clock.py +40 -0
  17. raidlock/core/cost.py +198 -0
  18. raidlock/core/db.py +608 -0
  19. raidlock/core/errors.py +52 -0
  20. raidlock/core/exe.py +6 -0
  21. raidlock/core/hints.py +32 -0
  22. raidlock/core/identity.py +285 -0
  23. raidlock/core/lead.py +758 -0
  24. raidlock/core/lead_env.py +23 -0
  25. raidlock/core/lead_hook.py +42 -0
  26. raidlock/core/leases.py +19 -0
  27. raidlock/core/lock.py +99 -0
  28. raidlock/core/messages.py +98 -0
  29. raidlock/core/migrate.py +579 -0
  30. raidlock/core/model.py +126 -0
  31. raidlock/core/models.py +109 -0
  32. raidlock/core/paths.py +61 -0
  33. raidlock/core/portable.py +53 -0
  34. raidlock/core/receipts.py +73 -0
  35. raidlock/core/registry.py +39 -0
  36. raidlock/core/repos.py +250 -0
  37. raidlock/core/safeio.py +201 -0
  38. raidlock/core/status.py +52 -0
  39. raidlock/core/store.py +107 -0
  40. raidlock/core/tokens.py +77 -0
  41. raidlock/core/transcript.py +208 -0
  42. raidlock/duel.py +371 -0
  43. raidlock/foresight.py +191 -0
  44. raidlock/frames.py +162 -0
  45. raidlock/gitguard.py +753 -0
  46. raidlock/guard.py +239 -0
  47. raidlock/hooks.py +1526 -0
  48. raidlock/infer.py +205 -0
  49. raidlock/limits.py +685 -0
  50. raidlock/lore/__init__.py +22 -0
  51. raidlock/lore/approval.py +145 -0
  52. raidlock/lore/capture.py +146 -0
  53. raidlock/lore/cli.py +109 -0
  54. raidlock/lore/distill.py +17 -0
  55. raidlock/lore/frontmatter.py +56 -0
  56. raidlock/lore/inject.py +127 -0
  57. raidlock/lore/vault.py +264 -0
  58. raidlock/mcp/__init__.py +34 -0
  59. raidlock/mcp/resources.py +72 -0
  60. raidlock/mcp/server.py +184 -0
  61. raidlock/mcp/tools.py +178 -0
  62. raidlock/notify.py +144 -0
  63. raidlock/party.py +239 -0
  64. raidlock/pr.py +575 -0
  65. raidlock/prsync.py +318 -0
  66. raidlock/recipes/README.md +34 -0
  67. raidlock/recipes/__init__.py +105 -0
  68. raidlock/recipes/docs-refresh.md +15 -0
  69. raidlock/recipes/multi-repo-refactor.md +15 -0
  70. raidlock/recipes/security-audit.md +27 -0
  71. raidlock/recipes/upgrade-deps.md +15 -0
  72. raidlock/reputation.py +194 -0
  73. raidlock/resume.py +329 -0
  74. raidlock/resurrect.py +419 -0
  75. raidlock/routing.py +419 -0
  76. raidlock/runners/__init__.py +16 -0
  77. raidlock/runners/base.py +341 -0
  78. raidlock/runners/brief.py +176 -0
  79. raidlock/runners/claude_code.py +121 -0
  80. raidlock/runners/cli.py +110 -0
  81. raidlock/runners/codex.py +114 -0
  82. raidlock/runners/gemini.py +64 -0
  83. raidlock/runners/isolation.py +391 -0
  84. raidlock/runners/policy.py +123 -0
  85. raidlock/runners/runner.py +921 -0
  86. raidlock/runners/shell.py +35 -0
  87. raidlock/runners/verifycache.py +93 -0
  88. raidlock/runners/workspace.py +376 -0
  89. raidlock/scan.py +357 -0
  90. raidlock/snapshot.py +151 -0
  91. raidlock/treasury.py +210 -0
  92. raidlock/tui.py +223 -0
  93. raidlock/web/__init__.py +5 -0
  94. raidlock/web/achievements/view.css +19 -0
  95. raidlock/web/achievements/view.js +58 -0
  96. raidlock/web/bosses/view.css +19 -0
  97. raidlock/web/bosses/view.js +46 -0
  98. raidlock/web/cli.py +90 -0
  99. raidlock/web/data.py +687 -0
  100. raidlock/web/export.py +153 -0
  101. raidlock/web/frames/view.css +25 -0
  102. raidlock/web/frames/view.js +63 -0
  103. raidlock/web/graph/__init__.py +142 -0
  104. raidlock/web/graph/view.css +33 -0
  105. raidlock/web/graph/view.js +136 -0
  106. raidlock/web/raidmap/__init__.py +205 -0
  107. raidlock/web/raidmap/view.css +15 -0
  108. raidlock/web/raidmap/view.js +47 -0
  109. raidlock/web/replay/__init__.py +258 -0
  110. raidlock/web/replay/view.css +13 -0
  111. raidlock/web/replay/view.js +127 -0
  112. raidlock/web/server.py +292 -0
  113. raidlock/web/static/app.css +851 -0
  114. raidlock/web/static/app.js +2102 -0
  115. raidlock/web/static/fonts/OFL.txt +93 -0
  116. raidlock/web/static/fonts/cinzel-600.woff2 +0 -0
  117. raidlock/web/static/fonts/cinzel-700.woff2 +0 -0
  118. raidlock/web/static/icon-32.png +0 -0
  119. raidlock/web/static/icon-raid.svg +1 -0
  120. raidlock/web/static/icon.svg +1 -0
  121. raidlock/web/static/index.html +122 -0
  122. raidlock-0.1.0.dist-info/METADATA +186 -0
  123. raidlock-0.1.0.dist-info/RECORD +126 -0
  124. raidlock-0.1.0.dist-info/WHEEL +4 -0
  125. raidlock-0.1.0.dist-info/entry_points.txt +34 -0
  126. 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)