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/prsync.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Pull request awareness: link cards to PRs, poll state and checks through gh, close merged cards through the gates."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from . import pr as P
|
|
8
|
+
from .core import clock, model
|
|
9
|
+
from .core.errors import Precondition
|
|
10
|
+
from .runners import policy
|
|
11
|
+
|
|
12
|
+
FIELDS = "number,url,state,isDraft,mergedAt,headRefOid,headRefName,baseRefName,statusCheckRollup,reviewDecision"
|
|
13
|
+
RATE = re.compile(r"rate limit|abuse detection|HTTP 429", re.IGNORECASE)
|
|
14
|
+
PASS = {"SUCCESS"}
|
|
15
|
+
FAIL = {"FAILURE", "ERROR", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE", "STALE"}
|
|
16
|
+
STATES = ("OPEN", "CLOSED", "MERGED")
|
|
17
|
+
SHA = re.compile(r"^[0-9a-f]{40}([0-9a-f]{24})?$")
|
|
18
|
+
MAX_BACKOFF = 900
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RateLimited(Precondition):
|
|
22
|
+
kind = "rate_limited"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_ref(ref):
|
|
26
|
+
text = str(ref or "").strip().lstrip("#")
|
|
27
|
+
if text.isdigit() and 0 < int(text) < 10**9:
|
|
28
|
+
return {"number": int(text), "url": ""}
|
|
29
|
+
match = P.PR_URL.fullmatch(text)
|
|
30
|
+
if match and int(match.group(1)) > 0:
|
|
31
|
+
return {"number": int(match.group(1)), "url": text}
|
|
32
|
+
raise Precondition("a pull request is a number or a .../pull/<n> URL", ref=text[:200])
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def summarize(rollup):
|
|
36
|
+
"""Collapse gh's statusCheckRollup into counts and one state: success, failure, pending or none."""
|
|
37
|
+
passed = failed = pending = 0
|
|
38
|
+
failing = []
|
|
39
|
+
for item in rollup if isinstance(rollup, list) else []:
|
|
40
|
+
if not isinstance(item, dict):
|
|
41
|
+
continue
|
|
42
|
+
name = str(item.get("name") or item.get("context") or "check")[:100]
|
|
43
|
+
if item.get("__typename") == "StatusContext" or ("state" in item and "conclusion" not in item):
|
|
44
|
+
state = str(item.get("state", "")).upper()
|
|
45
|
+
if state == "SUCCESS":
|
|
46
|
+
passed += 1
|
|
47
|
+
elif state in ("FAILURE", "ERROR"):
|
|
48
|
+
failed += 1
|
|
49
|
+
failing.append(name)
|
|
50
|
+
else:
|
|
51
|
+
pending += 1
|
|
52
|
+
continue
|
|
53
|
+
if str(item.get("status", "")).upper() != "COMPLETED":
|
|
54
|
+
pending += 1
|
|
55
|
+
continue
|
|
56
|
+
conclusion = str(item.get("conclusion", "")).upper()
|
|
57
|
+
if conclusion in PASS:
|
|
58
|
+
passed += 1
|
|
59
|
+
elif conclusion in FAIL:
|
|
60
|
+
failed += 1
|
|
61
|
+
failing.append(name)
|
|
62
|
+
else:
|
|
63
|
+
pending += 1
|
|
64
|
+
total = passed + failed + pending
|
|
65
|
+
state = "failure" if failed else "pending" if pending else "success" if total else "none"
|
|
66
|
+
return {"state": state, "passed": passed, "failed": failed, "pending": pending, "total": total, "failing": failing}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _repo_path(board, card):
|
|
70
|
+
try:
|
|
71
|
+
return policy.target(board.workspace, board.dir, card["locks"])["path"]
|
|
72
|
+
except Precondition:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@P.protected
|
|
77
|
+
def fetch(board, card):
|
|
78
|
+
"""Current PR state from gh, validated. Raises RateLimited when GitHub throttles."""
|
|
79
|
+
pr = card["pr"]
|
|
80
|
+
proc = P.gh(
|
|
81
|
+
["pr", "view", pr.get("url") or str(pr["number"]), "--json", FIELDS], cwd=_repo_path(board, card), env=board.env
|
|
82
|
+
)
|
|
83
|
+
if proc is None:
|
|
84
|
+
raise Precondition("gh is not installed", hint="install the GitHub CLI and run gh auth login")
|
|
85
|
+
if proc.returncode != 0:
|
|
86
|
+
err = P.sanitize(proc.stderr or proc.stdout, board.env).strip()
|
|
87
|
+
if RATE.search(err):
|
|
88
|
+
raise RateLimited("GitHub rate limit reached; try again later", card=card["id"])
|
|
89
|
+
raise Precondition(f"gh pr view failed for {card['id']}: {err[:300]}", card=card["id"])
|
|
90
|
+
try:
|
|
91
|
+
data = json.loads(proc.stdout)
|
|
92
|
+
except ValueError:
|
|
93
|
+
raise Precondition(f"gh returned invalid JSON for {card['id']}", card=card["id"]) from None
|
|
94
|
+
if not isinstance(data, dict) or data.get("number") != pr["number"]:
|
|
95
|
+
raise Precondition(f"gh returned a different pull request for {card['id']}", card=card["id"])
|
|
96
|
+
state = str(data.get("state", "")).upper()
|
|
97
|
+
head = str(data.get("headRefOid") or "")
|
|
98
|
+
if state not in STATES or (head and not SHA.match(head)):
|
|
99
|
+
raise Precondition(f"gh returned an unexpected pull request state for {card['id']}", card=card["id"])
|
|
100
|
+
url = str(data.get("url") or pr.get("url") or "")
|
|
101
|
+
if pr.get("url") and url != pr["url"]:
|
|
102
|
+
raise Precondition(f"gh returned a different pull request for {card['id']}", card=card["id"])
|
|
103
|
+
return {
|
|
104
|
+
"state": state,
|
|
105
|
+
"draft": bool(data.get("isDraft")),
|
|
106
|
+
"merged_at": str(data.get("mergedAt") or ""),
|
|
107
|
+
"head": head,
|
|
108
|
+
"head_branch": str(data.get("headRefName") or "")[:200],
|
|
109
|
+
"base": str(data.get("baseRefName") or "")[:200],
|
|
110
|
+
"review_decision": str(data.get("reviewDecision") or ""),
|
|
111
|
+
"checks": summarize(data.get("statusCheckRollup")),
|
|
112
|
+
"url": url if P.PR_URL.fullmatch(url) else pr.get("url", ""),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def link(board, caller, card_id, ref):
|
|
117
|
+
"""Lead: link a card to an existing pull request."""
|
|
118
|
+
found = parse_ref(ref)
|
|
119
|
+
with board.lead_write(caller, "link a pull request"):
|
|
120
|
+
card = board.card(card_id)
|
|
121
|
+
previous = card.get("pr") or {}
|
|
122
|
+
card["pr"] = {
|
|
123
|
+
"number": found["number"],
|
|
124
|
+
"url": found["url"] or (previous.get("url") if previous.get("number") == found["number"] else ""),
|
|
125
|
+
"linked": clock.iso(),
|
|
126
|
+
"state": "",
|
|
127
|
+
"checks": summarize([]),
|
|
128
|
+
}
|
|
129
|
+
board._note(card, caller.name, f"linked pull request #{found['number']}")
|
|
130
|
+
board._save_card(card)
|
|
131
|
+
board._record(
|
|
132
|
+
"status",
|
|
133
|
+
caller.name,
|
|
134
|
+
f"{card_id} linked to pull request #{found['number']}",
|
|
135
|
+
task=card_id,
|
|
136
|
+
data={"event": "pr_linked", "pr": card["pr"]},
|
|
137
|
+
)
|
|
138
|
+
return {"card": card_id, "pr": card["pr"]}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _due(card, min_interval, force):
|
|
142
|
+
pr = card["pr"]
|
|
143
|
+
if force:
|
|
144
|
+
return True
|
|
145
|
+
if card["lane"] == "done" and pr.get("state") in ("MERGED", "CLOSED"):
|
|
146
|
+
return False
|
|
147
|
+
if pr.get("state") == "CLOSED":
|
|
148
|
+
return False
|
|
149
|
+
return not pr.get("synced") or clock.age_seconds(pr["synced"]) >= min_interval
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def hold_reason(board, card, info):
|
|
153
|
+
"""Why a merged PR does not close its card yet, or "" when the gates may run."""
|
|
154
|
+
if card["lane"] not in ("review", "blocked"):
|
|
155
|
+
return f"card is in {card['lane']}"
|
|
156
|
+
last = model.last_verification(card) or {}
|
|
157
|
+
if not last.get("sha"):
|
|
158
|
+
return "no verify receipt"
|
|
159
|
+
if info["head"] != last["sha"]:
|
|
160
|
+
return f"merged head {info['head'][:12]} is not the verified commit {last['sha'][:12]}"
|
|
161
|
+
if info["checks"]["state"] == "failure":
|
|
162
|
+
return "checks failed: " + ", ".join(info["checks"]["failing"][:5])
|
|
163
|
+
if info["checks"]["state"] != "success":
|
|
164
|
+
return "checks not successful: " + info["checks"]["state"]
|
|
165
|
+
return ""
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _update(board, caller, card_id, info):
|
|
169
|
+
with board.lead_write(caller, "sync a pull request"):
|
|
170
|
+
card = board.card(card_id)
|
|
171
|
+
pr = card.get("pr") or {}
|
|
172
|
+
before = (pr.get("state"), (pr.get("checks") or {}).get("state"))
|
|
173
|
+
pr.update(info, synced=clock.iso())
|
|
174
|
+
card["pr"] = pr
|
|
175
|
+
after = (info["state"], info["checks"]["state"])
|
|
176
|
+
if before != after:
|
|
177
|
+
board._note(card, caller.name, f"pull request #{pr['number']}: {after[0].lower()}, checks {after[1]}")
|
|
178
|
+
board._record(
|
|
179
|
+
"status",
|
|
180
|
+
caller.name,
|
|
181
|
+
f"pull request #{pr['number']} {after[0].lower()}, checks {after[1]}",
|
|
182
|
+
task=card_id,
|
|
183
|
+
data={"event": "pr_state", "state": after[0], "checks": info["checks"]},
|
|
184
|
+
)
|
|
185
|
+
board._save_card(card)
|
|
186
|
+
return card
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _hold(board, caller, card_id, reason):
|
|
190
|
+
with board.lead_write(caller, "hold a merged pull request"):
|
|
191
|
+
card = board.card(card_id)
|
|
192
|
+
if card["pr"].get("held") != reason:
|
|
193
|
+
card["pr"]["held"] = reason
|
|
194
|
+
board._note(card, caller.name, f"merged but not done: {reason}")
|
|
195
|
+
board._record(
|
|
196
|
+
"status",
|
|
197
|
+
caller.name,
|
|
198
|
+
f"{card_id} merged but not done: {reason}",
|
|
199
|
+
task=card_id,
|
|
200
|
+
data={"event": "pr_merge_held", "reason": reason},
|
|
201
|
+
)
|
|
202
|
+
board._save_card(card)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _advance(board, caller, card, info):
|
|
206
|
+
reason = hold_reason(board, card, info)
|
|
207
|
+
if not reason:
|
|
208
|
+
try:
|
|
209
|
+
board.move(card["id"], "done", caller, note=f"merged as pull request #{card['pr']['number']}")
|
|
210
|
+
from .runners.runner import prune_runs
|
|
211
|
+
|
|
212
|
+
prune_runs(board, card["id"])
|
|
213
|
+
return "done", ""
|
|
214
|
+
except Precondition as exc:
|
|
215
|
+
reason = exc.message
|
|
216
|
+
_hold(board, caller, card["id"], reason)
|
|
217
|
+
return "held", reason
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def sync(board, caller, ids=None, min_interval=60, force=False):
|
|
221
|
+
"""Lead: poll each linked PR once (respecting min_interval), record state and checks, and move merged cards to
|
|
222
|
+
done only through the done gate. Stops at the first rate limit."""
|
|
223
|
+
board._auth(caller)
|
|
224
|
+
board._require_lead(caller, "sync pull requests")
|
|
225
|
+
cards = board.cards()
|
|
226
|
+
if ids:
|
|
227
|
+
known = {c["id"]: c for c in cards}
|
|
228
|
+
for cid in ids:
|
|
229
|
+
if cid not in known:
|
|
230
|
+
raise Precondition(f"no card {cid}", card=cid)
|
|
231
|
+
if not known[cid].get("pr"):
|
|
232
|
+
raise Precondition(f"{cid} has no pull request", card=cid, hint=f"raidlock pr-link {cid} <number>")
|
|
233
|
+
cards = [known[cid] for cid in ids]
|
|
234
|
+
else:
|
|
235
|
+
cards = [c for c in cards if c.get("pr")]
|
|
236
|
+
out = {"synced": [], "skipped": [], "done": [], "held": [], "errors": [], "rate_limited": False}
|
|
237
|
+
for card in cards:
|
|
238
|
+
if not _due(card, min_interval, force):
|
|
239
|
+
out["skipped"].append(card["id"])
|
|
240
|
+
continue
|
|
241
|
+
try:
|
|
242
|
+
info = fetch(board, card)
|
|
243
|
+
except RateLimited:
|
|
244
|
+
out["rate_limited"] = True
|
|
245
|
+
break
|
|
246
|
+
except Precondition as exc:
|
|
247
|
+
out["errors"].append({"card": card["id"], "error": exc.message})
|
|
248
|
+
continue
|
|
249
|
+
with board.lead_write(caller, "sync a pull request"):
|
|
250
|
+
current = board.card(card["id"])
|
|
251
|
+
identity = ("number", "url", "linked", "opened", "repo")
|
|
252
|
+
if any((current.get("pr") or {}).get(k) != card["pr"].get(k) for k in identity):
|
|
253
|
+
out["errors"].append({"card": card["id"], "error": "pull request changed during sync"})
|
|
254
|
+
continue
|
|
255
|
+
card = _update(board, caller, card["id"], info)
|
|
256
|
+
out["synced"].append({"card": card["id"], "state": info["state"], "checks": info["checks"]["state"]})
|
|
257
|
+
if info["state"] == "MERGED" and card["lane"] != "done":
|
|
258
|
+
verdict, reason = _advance(board, caller, card, info)
|
|
259
|
+
if verdict == "done":
|
|
260
|
+
out["done"].append(card["id"])
|
|
261
|
+
else:
|
|
262
|
+
out["held"].append({"card": card["id"], "reason": reason})
|
|
263
|
+
return out
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def watch(board, caller, interval=60, max_polls=None, sleep=time.sleep, **kw):
|
|
267
|
+
"""Repeat sync; double the wait after a rate limit, up to MAX_BACKOFF, and reset after a clean poll."""
|
|
268
|
+
polls, wait, last = 0, interval, None
|
|
269
|
+
while max_polls is None or polls < max_polls:
|
|
270
|
+
last = sync(board, caller, **kw)
|
|
271
|
+
polls += 1
|
|
272
|
+
wait = min(MAX_BACKOFF, wait * 2) if last["rate_limited"] else interval
|
|
273
|
+
if max_polls is not None and polls >= max_polls:
|
|
274
|
+
break
|
|
275
|
+
sleep(wait)
|
|
276
|
+
return {**(last or {}), "polls": polls, "next_wait": wait}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def register_cli(add_command):
|
|
280
|
+
def link_setup(p):
|
|
281
|
+
p.add_argument("id", help="card id")
|
|
282
|
+
p.add_argument("ref", help="pull request number or URL")
|
|
283
|
+
|
|
284
|
+
def link_handler(ctx, a):
|
|
285
|
+
r = link(ctx.board, ctx.caller(), a.id, a.ref)
|
|
286
|
+
return r, f"Linked {a.id} to pull request #{r['pr']['number']}."
|
|
287
|
+
|
|
288
|
+
def sync_setup(p):
|
|
289
|
+
p.add_argument("ids", nargs="*", help="cards to sync (default: every card with a pull request)")
|
|
290
|
+
p.add_argument("--min-interval", type=float, default=60, help="seconds between polls of one PR")
|
|
291
|
+
p.add_argument("--force", action="store_true", help="poll even when recently synced or closed")
|
|
292
|
+
p.add_argument("--watch", type=float, default=None, metavar="SECONDS", help="keep polling at this interval")
|
|
293
|
+
p.add_argument("--max-polls", type=int, default=None)
|
|
294
|
+
|
|
295
|
+
def sync_handler(ctx, a):
|
|
296
|
+
kw = {"ids": a.ids, "min_interval": a.min_interval, "force": a.force}
|
|
297
|
+
if a.watch:
|
|
298
|
+
r = watch(ctx.board, ctx.caller(), interval=a.watch, max_polls=a.max_polls, **kw)
|
|
299
|
+
else:
|
|
300
|
+
r = sync(ctx.board, ctx.caller(), **kw)
|
|
301
|
+
lines = [f"{s['card']}: {s['state'].lower()}, checks {s['checks']}" for s in r["synced"]]
|
|
302
|
+
lines += [f"{cid}: merged, moved to done" for cid in r["done"]]
|
|
303
|
+
lines += [f"{h['card']}: merged, held: {h['reason']}" for h in r["held"]]
|
|
304
|
+
lines += [f"{e['card']}: {e['error']}" for e in r["errors"]]
|
|
305
|
+
if r["rate_limited"]:
|
|
306
|
+
lines.append("Stopped: GitHub rate limit reached; run again later.")
|
|
307
|
+
if not lines:
|
|
308
|
+
lines.append("Nothing to sync." + (f" Skipped: {', '.join(r['skipped'])}." if r["skipped"] else ""))
|
|
309
|
+
return {**r, "_exit": 2 if r["rate_limited"] or r["errors"] else 0}, "\n".join(lines)
|
|
310
|
+
|
|
311
|
+
add_command("pr-link", "lead: link a card to an existing pull request", link_setup, link_handler, True)
|
|
312
|
+
add_command(
|
|
313
|
+
"pr-sync",
|
|
314
|
+
"lead: poll linked pull requests; merged cards close only through the gates",
|
|
315
|
+
sync_setup,
|
|
316
|
+
sync_handler,
|
|
317
|
+
True,
|
|
318
|
+
)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Built-in recipes
|
|
2
|
+
|
|
3
|
+
The loader uses stdlib `importlib.resources` to read the packaged Markdown.
|
|
4
|
+
The existing wheel configuration includes this directory as package data.
|
|
5
|
+
|
|
6
|
+
The package exposes `read_plan(source, team=None, variables=None)`,
|
|
7
|
+
`parse_variables(assignments)` and `list_recipes()`. The core CLI owns
|
|
8
|
+
`plan import`, dispatches `recipe:` sources through `read_plan`, and retains
|
|
9
|
+
ordinary file imports and their mutating authorization. This package's
|
|
10
|
+
`register_cli(add_command)` registers only the read-only `recipes` listing.
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
raidlock recipes
|
|
14
|
+
raidlock --lead-secret /printed/path plan import recipe:upgrade-deps --var repo=acme-shop
|
|
15
|
+
raidlock --lead-secret /printed/path plan import recipe:security-audit --var repo=acme-shop
|
|
16
|
+
raidlock --lead-secret /printed/path plan import recipe:docs-refresh --var repo=pocket-notes
|
|
17
|
+
raidlock --lead-secret /printed/path plan import recipe:multi-repo-refactor --var repo=acme-shop --var other_repo=pocket-notes
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Recipe imports require repository variables; ordinary file imports reject
|
|
21
|
+
`--var`. Recipe IDs are stable, so importing the same recipe twice into a
|
|
22
|
+
board requires resolving its existing cards first.
|
|
23
|
+
|
|
24
|
+
Each checklist uses the existing `- [ ] Title | locks: repo/path, repo/path`
|
|
25
|
+
format. The loader reads each preceding `Quest:` JSON paragraph for its stable
|
|
26
|
+
ID, dependencies and scope. Ordinary Markdown import ignores those paragraphs;
|
|
27
|
+
use the recipe loader to retain dependencies and assignment instructions.
|
|
28
|
+
|
|
29
|
+
Variables are required repository names consisting of letters, digits, hyphens
|
|
30
|
+
or underscores. Paths, whitespace and Markdown syntax are rejected. Adjust
|
|
31
|
+
recipe lock paths to the target repository layout before assigning work.
|
|
32
|
+
Security review quests require a different provider from their build quest;
|
|
33
|
+
the lead must enforce the assignments described in scope. They produce separate
|
|
34
|
+
reports after builds finish and do not substitute for board approval gates.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Built-in plans and an optional CLI registration hook."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from importlib.resources import files
|
|
6
|
+
|
|
7
|
+
from raidlock.core import Precondition
|
|
8
|
+
from raidlock.core.paths import parse_list
|
|
9
|
+
|
|
10
|
+
_NAMES = ("docs-refresh", "multi-repo-refactor", "security-audit", "upgrade-deps")
|
|
11
|
+
_VARIABLE = re.compile(r"\{([a-z][a-z0-9_]*)\}")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def list_recipes():
|
|
15
|
+
return list(_NAMES)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_recipe(source, variables=None):
|
|
19
|
+
name = source.removeprefix("recipe:")
|
|
20
|
+
if name not in _NAMES:
|
|
21
|
+
raise Precondition(f"unknown recipe: {name}", hint="raidlock recipes")
|
|
22
|
+
values = dict(variables or {})
|
|
23
|
+
text = files(__package__).joinpath(name + ".md").read_text(encoding="utf-8")
|
|
24
|
+
required = set(_VARIABLE.findall(text))
|
|
25
|
+
missing = required - values.keys()
|
|
26
|
+
if missing:
|
|
27
|
+
raise Precondition("missing recipe variables: " + ", ".join(sorted(missing)))
|
|
28
|
+
for key in required:
|
|
29
|
+
value = values[key]
|
|
30
|
+
if not isinstance(value, str) or not re.fullmatch(r"[a-zA-Z0-9_-]+", value):
|
|
31
|
+
raise Precondition(f"invalid recipe variable {key}: use a repository name")
|
|
32
|
+
return _VARIABLE.sub(lambda match: values[match[1]], text)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def read_plan(source, team=None, variables=None):
|
|
36
|
+
from raidlock.cli import _md_item, _read_plan
|
|
37
|
+
|
|
38
|
+
if not source.startswith("recipe:"):
|
|
39
|
+
if variables:
|
|
40
|
+
raise Precondition("--var is supported only for recipes")
|
|
41
|
+
return _read_plan(source, team)
|
|
42
|
+
items = []
|
|
43
|
+
metadata = None
|
|
44
|
+
seen = set()
|
|
45
|
+
for line in load_recipe(source, variables).splitlines():
|
|
46
|
+
if line.startswith("Quest: "):
|
|
47
|
+
metadata = json.loads(line[7:])
|
|
48
|
+
elif line.startswith("- [ ] "):
|
|
49
|
+
if metadata is None:
|
|
50
|
+
raise Precondition("recipe quest is missing metadata")
|
|
51
|
+
item = {**_md_item(line), **metadata}
|
|
52
|
+
if item["id"] in seen or not set(item.get("depends", [])) <= seen:
|
|
53
|
+
raise Precondition("recipe dependencies must reference earlier unique quests")
|
|
54
|
+
seen.add(item["id"])
|
|
55
|
+
item["locks"] = parse_list(item["locks"])
|
|
56
|
+
if team:
|
|
57
|
+
item["team"] = team
|
|
58
|
+
items.append(item)
|
|
59
|
+
metadata = None
|
|
60
|
+
return items
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_variables(assignments):
|
|
64
|
+
variables = {}
|
|
65
|
+
for assignment in assignments or []:
|
|
66
|
+
key, separator, value = assignment.partition("=")
|
|
67
|
+
if not separator or not key or key in variables:
|
|
68
|
+
raise Precondition(f"invalid or duplicate --var: {assignment}")
|
|
69
|
+
variables[key] = value
|
|
70
|
+
return variables
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def setup_plan(parser):
|
|
74
|
+
parser.add_argument("action", choices=["import", "recipes"])
|
|
75
|
+
parser.add_argument("file", nargs="?")
|
|
76
|
+
parser.add_argument("--team", default=None)
|
|
77
|
+
parser.add_argument("--var", action="append", default=[])
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def handle_plan(ctx, args):
|
|
81
|
+
if args.action == "recipes":
|
|
82
|
+
if args.file or args.var or args.team:
|
|
83
|
+
raise Precondition("plan recipes takes no import arguments")
|
|
84
|
+
names = list_recipes()
|
|
85
|
+
return {"recipes": names}, "\n".join(names)
|
|
86
|
+
if not args.file:
|
|
87
|
+
raise Precondition("plan import requires a file or recipe:<name>")
|
|
88
|
+
variables = parse_variables(args.var)
|
|
89
|
+
items = read_plan(args.file, args.team, variables)
|
|
90
|
+
cards = ctx.board.import_plan(ctx.caller(), items)
|
|
91
|
+
ids = [card["id"] for card in cards]
|
|
92
|
+
return {"cards": ids}, f"Imported {len(ids)} card(s): {', '.join(ids)}.\nNext: raidlock show"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def setup_recipes(parser):
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def handle_recipes(ctx, args):
|
|
100
|
+
names = list_recipes()
|
|
101
|
+
return {"recipes": names}, "\n".join(names)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def register_cli(add_command):
|
|
105
|
+
add_command("recipes", "list built-in plan recipes", setup_recipes, handle_recipes)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# docs-refresh
|
|
2
|
+
|
|
3
|
+
Use when documentation has drifted from current behavior.
|
|
4
|
+
|
|
5
|
+
Quest: {"id": "refresh-guide", "depends": [], "scope": "Check documented commands against current behavior and update examples."}
|
|
6
|
+
|
|
7
|
+
- [ ] Refresh user guides for {repo} | locks: {repo}/docs
|
|
8
|
+
|
|
9
|
+
Quest: {"id": "refresh-readme", "depends": ["refresh-guide"], "scope": "Update setup instructions and links to the refreshed guides."}
|
|
10
|
+
|
|
11
|
+
- [ ] Refresh the README for {repo} | locks: {repo}/README.md
|
|
12
|
+
|
|
13
|
+
Quest: {"id": "verify-docs", "depends": ["refresh-readme"], "scope": "Run examples, check links and correct inconsistencies."}
|
|
14
|
+
|
|
15
|
+
- [ ] Verify documentation examples for {repo} | locks: {repo}/docs
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# multi-repo-refactor
|
|
2
|
+
|
|
3
|
+
Use for a shared contract change across two repositories. Keep the producer backward compatible until the consumer migrates.
|
|
4
|
+
|
|
5
|
+
Quest: {"id": "refactor-producer", "depends": [], "scope": "Introduce the new contract while preserving the old interface and test both."}
|
|
6
|
+
|
|
7
|
+
- [ ] Refactor the producer in {repo} | locks: {repo}/src, {repo}/tests
|
|
8
|
+
|
|
9
|
+
Quest: {"id": "refactor-consumer", "depends": ["refactor-producer"], "scope": "Adopt the new contract and test compatibility with the producer."}
|
|
10
|
+
|
|
11
|
+
- [ ] Migrate the consumer in {other_repo} | locks: {other_repo}/src, {other_repo}/tests
|
|
12
|
+
|
|
13
|
+
Quest: {"id": "verify-contract", "depends": ["refactor-consumer"], "scope": "Run integration tests across both repositories and document rollout and rollback steps."}
|
|
14
|
+
|
|
15
|
+
- [ ] Verify the contract across {repo} and {other_repo} | locks: {repo}/tests, {other_repo}/tests
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# security-audit
|
|
2
|
+
|
|
3
|
+
Use for a security audit covering dependencies, authentication and input handling. Each area has one build quest and one independent review quest. The lead must assign builds to openai and reviews to anthropic; provider assignment is an instruction, not an importer-enforced field. Review quests follow completed builds and do not replace board approval gates.
|
|
4
|
+
|
|
5
|
+
Quest: {"id": "build-dependencies", "depends": [], "scope": "Assign to provider openai. Audit dependencies and repair vulnerable versions with regression evidence."}
|
|
6
|
+
|
|
7
|
+
- [ ] Build security fixes for dependencies in {repo} | locks: {repo}/pyproject.toml, {repo}/requirements.txt
|
|
8
|
+
|
|
9
|
+
Quest: {"id": "review-dependencies", "depends": ["build-dependencies"], "scope": "Assign to provider anthropic, different from the build provider openai. Independently reproduce findings, inspect the patch and tests, and write a review report. Do not modify build files."}
|
|
10
|
+
|
|
11
|
+
- [ ] Independently review dependencies fixes in {repo} | locks: {repo}/audit-reviews/dependencies.md
|
|
12
|
+
|
|
13
|
+
Quest: {"id": "build-auth", "depends": [], "scope": "Assign to provider openai. Audit authentication and authorization boundaries and repair demonstrated flaws."}
|
|
14
|
+
|
|
15
|
+
- [ ] Build security fixes for auth in {repo} | locks: {repo}/src/auth, {repo}/tests/auth
|
|
16
|
+
|
|
17
|
+
Quest: {"id": "review-auth", "depends": ["build-auth"], "scope": "Assign to provider anthropic, different from the build provider openai. Independently reproduce findings, inspect the patch and tests, and write a review report. Do not modify build files."}
|
|
18
|
+
|
|
19
|
+
- [ ] Independently review auth fixes in {repo} | locks: {repo}/audit-reviews/auth.md
|
|
20
|
+
|
|
21
|
+
Quest: {"id": "build-inputs", "depends": [], "scope": "Assign to provider openai. Audit input validation and injection risks and add regression tests for fixes."}
|
|
22
|
+
|
|
23
|
+
- [ ] Build security fixes for inputs in {repo} | locks: {repo}/src/inputs, {repo}/tests/inputs
|
|
24
|
+
|
|
25
|
+
Quest: {"id": "review-inputs", "depends": ["build-inputs"], "scope": "Assign to provider anthropic, different from the build provider openai. Independently reproduce findings, inspect the patch and tests, and write a review report. Do not modify build files."}
|
|
26
|
+
|
|
27
|
+
- [ ] Independently review inputs fixes in {repo} | locks: {repo}/audit-reviews/inputs.md
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# upgrade-deps
|
|
2
|
+
|
|
3
|
+
Use for dependency upgrades that need compatibility checks before release.
|
|
4
|
+
|
|
5
|
+
Quest: {"id": "upgrade-runtime", "depends": [], "scope": "Update runtime constraints and lockfiles and record compatibility changes."}
|
|
6
|
+
|
|
7
|
+
- [ ] Upgrade runtime dependencies in {repo} | locks: {repo}/pyproject.toml, {repo}/requirements.txt
|
|
8
|
+
|
|
9
|
+
Quest: {"id": "upgrade-tooling", "depends": ["upgrade-runtime"], "scope": "Update development tools and regenerate affected lockfiles."}
|
|
10
|
+
|
|
11
|
+
- [ ] Upgrade development tools in {repo} | locks: {repo}/pyproject.toml, {repo}/requirements-dev.txt
|
|
12
|
+
|
|
13
|
+
Quest: {"id": "verify-upgrade", "depends": ["upgrade-tooling"], "scope": "Run the full suite and add regression tests for changed dependency behavior."}
|
|
14
|
+
|
|
15
|
+
- [ ] Verify upgraded dependencies in {repo} | locks: {repo}/tests
|