@caddis/cli 0.4.55 → 0.4.57
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.
- package/bundles/antigravity-plugin/caddis_statusline.py +7 -3
- package/bundles/antigravity-plugin/guard_agy.py +1056 -83
- package/bundles/antigravity-plugin/hooks.json +2 -1
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/repo_freshness.py +182 -4
- package/bundles/antigravity-plugin/scripts/caddis_adhoc.py +20 -12
- package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +63 -12
- package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +14 -5
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +27 -1
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +93 -6
- package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +67 -22
- package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +8 -2
- package/bundles/antigravity-plugin/scripts/caddis_review.py +290 -176
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +346 -50
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +82 -8
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +89 -24
- package/bundles/antigravity-plugin/scripts/caddis_statusline.py +7 -3
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +10 -6
- package/bundles/antigravity-plugin/scripts/caddis_todo.py +7 -1
- package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +12 -5
- package/bundles/antigravity-plugin/scripts/caddis_worktree_safety.py +182 -0
- package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +2 -0
- package/bundles/antigravity-plugin/scripts/claudster_doctor.py +6 -2
- package/bundles/antigravity-plugin/scripts/no_window.py +49 -0
- package/bundles/antigravity-plugin/scripts/oss_review.py +303 -79
- package/bundles/antigravity-plugin/scripts/secret_filter.py +190 -22
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +6 -1
- package/bundles/antigravity-plugin/session_end_agy.py +27 -11
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +55 -9
- package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +11 -8
- package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +10 -6
- package/bundles/antigravity-plugin/skills/git-commit/SKILL.md +2 -3
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +31 -17
- package/bundles/antigravity-plugin/skills/mmr/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +45 -5
- package/bundles/antigravity-plugin/skills/refactoring/SKILL.md +4 -2
- package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +13 -4
- package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +28 -6
- package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +16 -13
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +32 -10
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +32 -1
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +44 -6
- package/bundles/antigravity-plugin/skills/writing-plans/SKILL.md +1 -1
- package/bundles/antigravity-plugin/warm_start_agy.py +40 -16
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
- package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/deploy-local/SKILL.md +2 -1
- package/bundles/antigravity-plugin-extras/skills/update-readme/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/refactoring/SKILL.md +4 -2
- package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +10 -6
- package/bundles/codex/.codex/skills/devops/git-commit/SKILL.md +2 -3
- package/bundles/codex/.codex/skills/docs/writing-plans/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +28 -6
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +16 -13
- package/bundles/manifest.json +3 -3
- package/package.json +1 -1
|
@@ -33,9 +33,11 @@ except ModuleNotFoundError as exc:
|
|
|
33
33
|
import caddis_exit
|
|
34
34
|
from caddis_heavy_slot import HeavySlotBusy, heavy_slot
|
|
35
35
|
|
|
36
|
+
import caddis_checkout_guard
|
|
36
37
|
import caddis_routing
|
|
37
38
|
import caddis_redact
|
|
38
39
|
import caddis_spend
|
|
40
|
+
import caddis_worktree_safety
|
|
39
41
|
|
|
40
42
|
if hasattr(sys.stdout, "reconfigure"):
|
|
41
43
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
@@ -265,7 +267,14 @@ def remove_phase_worktree(
|
|
|
265
267
|
if delete_branch and not wt.branch.startswith("lane/"):
|
|
266
268
|
raise LaneConfigError(f"refusing to delete non-lane branch: {wt.branch}")
|
|
267
269
|
|
|
268
|
-
|
|
270
|
+
try:
|
|
271
|
+
problem = caddis_worktree_safety.safe_remove_worktree(repo_root, resolved)
|
|
272
|
+
except OSError as exc:
|
|
273
|
+
raise LaneConfigError(f"worktree remove failed: {exc}") from exc
|
|
274
|
+
if not problem and os.path.lexists(resolved):
|
|
275
|
+
problem = f"worktree remove left {resolved} on disk"
|
|
276
|
+
if problem:
|
|
277
|
+
raise LaneConfigError(problem)
|
|
269
278
|
_git_output(repo_root, "worktree", "prune")
|
|
270
279
|
if delete_branch:
|
|
271
280
|
_git_output(repo_root, "branch", "-D", "--", wt.branch)
|
|
@@ -1244,8 +1253,19 @@ def validate_codex_argv(argv: list[str]) -> None:
|
|
|
1244
1253
|
)
|
|
1245
1254
|
|
|
1246
1255
|
|
|
1256
|
+
# Robustness #4: a lane agent can be prompt-injected through a plan or a diff, so it gets no publish token and no
|
|
1257
|
+
# credential prompt, and cannot switch the guard off. The same list as caddis_roadmap_run.SESSION_SECRET_VARS. The
|
|
1258
|
+
# gh keyring login stays on the machine; the guard denies push, merge and publish when CADDIS_HEADLESS=1.
|
|
1259
|
+
LANE_SECRET_VARS = ("GH_TOKEN", "GITHUB_TOKEN", "GH_ENTERPRISE_TOKEN", "NPM_TOKEN", "NODE_AUTH_TOKEN",
|
|
1260
|
+
"CADDIS_ALLOW_PUBLISH", "CADDIS_GUARD_DISABLED")
|
|
1261
|
+
LANE_NO_PROMPT_ENV = {"GH_PROMPT_DISABLED": "1", "GIT_TERMINAL_PROMPT": "0"}
|
|
1262
|
+
|
|
1263
|
+
|
|
1247
1264
|
def child_env(base: dict, lane: str, model: str) -> dict:
|
|
1248
1265
|
env = base.copy()
|
|
1266
|
+
for name in LANE_SECRET_VARS:
|
|
1267
|
+
env.pop(name, None)
|
|
1268
|
+
env.update(LANE_NO_PROMPT_ENV)
|
|
1249
1269
|
env["CADDIS_HEADLESS"] = "1"
|
|
1250
1270
|
if lane == "glm":
|
|
1251
1271
|
env["OSS_MODEL"] = model
|
|
@@ -2132,6 +2152,42 @@ def _check_command(
|
|
|
2132
2152
|
return exit_code
|
|
2133
2153
|
|
|
2134
2154
|
|
|
2155
|
+
_TERMINAL_ESCAPES = re.compile(
|
|
2156
|
+
r"\x1b\[[0-?]*[ -/]*[@-~]" # CSI (colours, cursor)
|
|
2157
|
+
r"|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?" # OSC (titles, links)
|
|
2158
|
+
r"|\x1b[@-Z\\-_]" # other two-byte escapes
|
|
2159
|
+
)
|
|
2160
|
+
_CONTROL_CHARS = re.compile(r"[\x00-\x09\x0b-\x1f\x7f]")
|
|
2161
|
+
|
|
2162
|
+
|
|
2163
|
+
def _printable(value: object) -> str:
|
|
2164
|
+
"""Text safe to print: no ANSI escapes and no C0 control characters except newline."""
|
|
2165
|
+
text = _TERMINAL_ESCAPES.sub("", str(value if value is not None else ""))
|
|
2166
|
+
return _CONTROL_CHARS.sub("", text)
|
|
2167
|
+
|
|
2168
|
+
|
|
2169
|
+
# Accept fast-forwards the SESSION checkout. In a checkout a dev service serves from, or one that holds a peer's
|
|
2170
|
+
# uncommitted edits, that moves files under them (robustness #11). The lane's own scaffolding is not a peer's change.
|
|
2171
|
+
_LANE_OWN_PREFIX = ".caddis/orchestrator/"
|
|
2172
|
+
|
|
2173
|
+
|
|
2174
|
+
def shared_checkout_reason(repo: Path) -> str:
|
|
2175
|
+
"""Why accept must not fast-forward `repo`, or ''. Shared (AGENTS.md) and/or foreign changes (git status):
|
|
2176
|
+
every reason that holds, joined with "; ", so an --allow-shared audit records all of them."""
|
|
2177
|
+
reasons = []
|
|
2178
|
+
if caddis_checkout_guard.checkout_is_shared(repo):
|
|
2179
|
+
reasons.append("AGENTS.md says Shared checkout: yes")
|
|
2180
|
+
try:
|
|
2181
|
+
dirty = caddis_checkout_guard.foreign_changes(repo)
|
|
2182
|
+
except caddis_checkout_guard.CheckoutStatusError:
|
|
2183
|
+
reasons.append("git status failed, so changes by other sessions cannot be ruled out")
|
|
2184
|
+
dirty = []
|
|
2185
|
+
foreign = [path for path in dirty if not path.replace("\\", "/").startswith(_LANE_OWN_PREFIX)]
|
|
2186
|
+
if foreign:
|
|
2187
|
+
reasons.append("changes not made by this lane: " + ", ".join(foreign[:3]))
|
|
2188
|
+
return "; ".join(reasons)
|
|
2189
|
+
|
|
2190
|
+
|
|
2135
2191
|
def _accept_command(
|
|
2136
2192
|
args,
|
|
2137
2193
|
repo: Path,
|
|
@@ -2151,12 +2207,26 @@ def _accept_command(
|
|
|
2151
2207
|
if packet.get("next") != "review":
|
|
2152
2208
|
print(f"refusing: packet next={packet.get('next')}")
|
|
2153
2209
|
return EXIT_USAGE
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2210
|
+
# Fail closed: only an exact CLEAN verdict merges without an owner override.
|
|
2211
|
+
review = packet.get("review") or {}
|
|
2212
|
+
if not isinstance(review, dict):
|
|
2213
|
+
review = {}
|
|
2214
|
+
review_status = review.get("status")
|
|
2215
|
+
override_reason = (getattr(args, "override_review", None) or "").strip()
|
|
2216
|
+
if review_status != "CLEAN" and not override_reason:
|
|
2217
|
+
if review_status == "BLOCKING":
|
|
2218
|
+
print("refusing: review BLOCKING; pass --override-review with a reason")
|
|
2219
|
+
else:
|
|
2220
|
+
# MISSING = no verdict (provider down, 429, secret-filter refusal). Anything else
|
|
2221
|
+
# that is not CLEAN (absent, null, unknown, lowercase) is also no valid verdict.
|
|
2222
|
+
cause = " ".join(_printable(review.get("tail")).split())[-300:] or "no reason recorded"
|
|
2223
|
+
label = (
|
|
2224
|
+
"MISSING" if review_status == "MISSING"
|
|
2225
|
+
else f"status {_printable(review_status)!r}"
|
|
2226
|
+
)
|
|
2227
|
+
print(f"refusing: review {label} (no verdict: {cause}); re-run the review, "
|
|
2228
|
+
"or pass --override-review with a reason")
|
|
2157
2229
|
return EXIT_USAGE
|
|
2158
|
-
if review_status == "MISSING":
|
|
2159
|
-
print("WARNING: review MISSING")
|
|
2160
2230
|
if _git_output(repo, "rev-parse", "HEAD") != packet.get("base_sha"):
|
|
2161
2231
|
print("refusing: programme branch moved since the attempt started")
|
|
2162
2232
|
return EXIT_FAIL
|
|
@@ -2174,10 +2244,25 @@ def _accept_command(
|
|
|
2174
2244
|
or packet["worktree_digest"] != worktree_digest(wt.path, files)):
|
|
2175
2245
|
print("refusing: worktree changed since check")
|
|
2176
2246
|
return EXIT_FAIL
|
|
2247
|
+
unsafe = shared_checkout_reason(repo)
|
|
2248
|
+
if unsafe and not getattr(args, "allow_shared", False):
|
|
2249
|
+
print(f"refusing: the session checkout is not safe to fast-forward ({_printable(unsafe)}). "
|
|
2250
|
+
"Run the programme from a worktree of origin/<default> under %TEMP% (git worktree add), "
|
|
2251
|
+
"or pass --allow-shared to fast-forward here anyway")
|
|
2252
|
+
return EXIT_USAGE
|
|
2253
|
+
if unsafe:
|
|
2254
|
+
packet["allow_shared"] = unsafe
|
|
2255
|
+
write_packet(packet_path, packet)
|
|
2256
|
+
print(f"ALLOW SHARED: {_printable(unsafe)}")
|
|
2177
2257
|
if args.override_review is not None:
|
|
2178
2258
|
packet["override_review"] = args.override_review
|
|
2179
2259
|
write_packet(packet_path, packet)
|
|
2180
2260
|
print(f"OVERRIDE REVIEW: {args.override_review}")
|
|
2261
|
+
if review_status != "CLEAN":
|
|
2262
|
+
print(
|
|
2263
|
+
f"WARNING: merged without a vendor review (status {_printable(review_status)}): "
|
|
2264
|
+
f"{args.override_review}"
|
|
2265
|
+
)
|
|
2181
2266
|
_git_output(wt.path, "add", "--", *(str(path) for path in files))
|
|
2182
2267
|
_git_output(wt.path, "commit", "-m", args.message)
|
|
2183
2268
|
accepted_sha = _git_output(wt.path, "rev-parse", "HEAD")
|
|
@@ -2406,6 +2491,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
2406
2491
|
_add_phase_arguments(accept)
|
|
2407
2492
|
accept.add_argument("--message", required=True)
|
|
2408
2493
|
accept.add_argument("--override-review")
|
|
2494
|
+
accept.add_argument("--allow-shared", action="store_true",
|
|
2495
|
+
help="fast-forward a shared checkout or one with foreign changes anyway (recorded)")
|
|
2409
2496
|
abandon = subparsers.add_parser("abandon")
|
|
2410
2497
|
_add_phase_arguments(abandon)
|
|
2411
2498
|
finish = subparsers.add_parser("finish")
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
"""Merge pull requests one at a time, from green CI to a verified deploy, and stop the line at the first problem.
|
|
2
2
|
|
|
3
3
|
For each PR (design section 11): when BEHIND, update the branch and cancel the old head's runs (force-cancel after
|
|
4
|
-
2 minutes); a cancelled run on the head is re-run, never merged on; "
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
2 minutes); a cancelled run on the head is re-run, never merged on; "checks green" is ci-watch's verdict on the
|
|
5
|
+
`statusCheckRollup` of the PR's `headRefOid` (never the `gh pr checks` table, which can show an older head); "no
|
|
6
|
+
checks reported" waits, then stops; every check skipped stops (no job ran), both unless --allow-no-checks; a failed
|
|
7
|
+
check stops; no merge while a push run is in progress on the default branch; squash merge with --match-head-commit;
|
|
8
|
+
then the merge SHA must get a push run, and `caddis_prod_check.py` must pass before the next PR starts. A STOP file is
|
|
9
|
+
checked before each update-branch and each merge. --dry-run makes read-only `gh` calls and prints what it would do.
|
|
8
10
|
|
|
9
11
|
Exit 0 when every PR merged and verified; 1 at the first stop; 3 when the profile cannot be read.
|
|
10
12
|
|
|
11
|
-
Usage: caddis_merge_chain.py [--repo PATH] [--gh-repo OWNER/NAME] [--docs-only] [--dry-run]
|
|
13
|
+
Usage: caddis_merge_chain.py [--repo PATH] [--gh-repo OWNER/NAME] [--docs-only] [--dry-run] [--allow-no-checks]
|
|
12
14
|
[--stop-file PATH ...] <pr> [<pr> ...]
|
|
13
15
|
"""
|
|
14
16
|
|
|
@@ -21,13 +23,15 @@ from pathlib import Path
|
|
|
21
23
|
|
|
22
24
|
try:
|
|
23
25
|
import caddis_exit
|
|
26
|
+
import caddis_lanes
|
|
24
27
|
import caddis_prod_check as prod
|
|
25
28
|
except ModuleNotFoundError as exc:
|
|
26
|
-
if exc.name not in {"caddis_exit", "caddis_prod_check"}:
|
|
29
|
+
if exc.name not in {"caddis_exit", "caddis_lanes", "caddis_prod_check", "caddis_heavy_slot", "caddis_routing"}:
|
|
27
30
|
raise
|
|
28
31
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
29
32
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
|
|
30
33
|
import caddis_exit
|
|
34
|
+
import caddis_lanes
|
|
31
35
|
import caddis_prod_check as prod
|
|
32
36
|
|
|
33
37
|
MAX_ATTEMPTS = 8
|
|
@@ -45,6 +49,7 @@ class Stop(Exception):
|
|
|
45
49
|
class ChainContext(prod.Context):
|
|
46
50
|
stop_files: list[Path] = field(default_factory=list)
|
|
47
51
|
docs_only: bool = False
|
|
52
|
+
allow_no_checks: bool = False
|
|
48
53
|
|
|
49
54
|
|
|
50
55
|
def _stop_check(ctx: ChainContext, before: str) -> None:
|
|
@@ -90,26 +95,63 @@ def _head_run(ctx: ChainContext, branch: str, head: str) -> dict | None:
|
|
|
90
95
|
return next((r for r in runs if r.get("headSha") == head), None) # type: ignore[union-attr]
|
|
91
96
|
|
|
92
97
|
|
|
93
|
-
def
|
|
94
|
-
"""
|
|
98
|
+
def _rollup_state(rollup: object, allow_no_checks: bool) -> tuple[str, str]:
|
|
99
|
+
"""(state, detail) from the head's `statusCheckRollup`, by ci-watch's own verdict (`caddis_lanes._rollup_verdict`).
|
|
100
|
+
|
|
101
|
+
state is 'pass', 'fail', 'cancel', 'pending', 'none' (no check reported) or 'skipped' (every check skipped).
|
|
102
|
+
A check that only SKIPPED or NEUTRAL ran no job, so a rollup with no SUCCESS proves nothing: 'skipped', unless
|
|
103
|
+
the owner passed --allow-no-checks for a repo that has no CI at all.
|
|
104
|
+
"""
|
|
105
|
+
verdict = caddis_lanes._rollup_verdict(rollup)
|
|
106
|
+
if verdict is None:
|
|
107
|
+
return ("pass", "no checks reported (--allow-no-checks)") if allow_no_checks else ("none", "")
|
|
108
|
+
_green, failed, unfinished = verdict
|
|
109
|
+
if failed:
|
|
110
|
+
cancelled = {str(i.get("name") or i.get("context") or "?") for i in rollup # type: ignore[union-attr]
|
|
111
|
+
if isinstance(i, dict) and str(i.get("conclusion") or "").upper() == "CANCELLED"}
|
|
112
|
+
return ("cancel" if set(failed) <= cancelled else "fail"), ", ".join(failed)
|
|
113
|
+
if unfinished:
|
|
114
|
+
return "pending", ", ".join(unfinished)
|
|
115
|
+
ran = sum(1 for i in rollup if isinstance(i, dict) # type: ignore[union-attr]
|
|
116
|
+
and str(i.get("conclusion") or i.get("state") or "").upper() == "SUCCESS")
|
|
117
|
+
if not ran:
|
|
118
|
+
return ("pass", "every check skipped (--allow-no-checks)") if allow_no_checks else ("skipped", "")
|
|
119
|
+
return "pass", ""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _wait_checks(ctx: ChainContext, pr: int, head: str) -> str:
|
|
123
|
+
"""Return 'pass', 'fail', 'cancel' or 'moved' for the commit `head`. Raise Stop when no proof comes in time.
|
|
124
|
+
|
|
125
|
+
The verdict is ci-watch's: the `statusCheckRollup` of the PR's `headRefOid`, read in one `gh pr view`. The
|
|
126
|
+
`gh pr checks` table is never read: it can still show an older head's checks.
|
|
127
|
+
"""
|
|
95
128
|
deadline = ctx.clock.now() + CHECKS_WAIT_MINUTES * 60
|
|
96
129
|
while True:
|
|
97
|
-
proc = ctx.gh("pr", "
|
|
98
|
-
if proc.rc != 0
|
|
130
|
+
proc = ctx.gh("pr", "view", "--json", "headRefOid,statusCheckRollup", str(pr))
|
|
131
|
+
if proc.rc != 0:
|
|
99
132
|
# Auth or network failure: say so now instead of waiting 30 minutes as if no check had started.
|
|
100
|
-
raise Stop(f"#{pr}: gh pr
|
|
101
|
-
buckets: list[str] = []
|
|
133
|
+
raise Stop(f"#{pr}: gh pr view failed: {(proc.err or proc.out).strip()[:300]}")
|
|
102
134
|
try:
|
|
103
|
-
|
|
135
|
+
data = prod.json.loads(proc.out) if proc.out.strip() else {}
|
|
104
136
|
except ValueError:
|
|
105
|
-
|
|
106
|
-
if
|
|
107
|
-
|
|
108
|
-
if "
|
|
109
|
-
|
|
110
|
-
|
|
137
|
+
data = {}
|
|
138
|
+
if not isinstance(data, dict):
|
|
139
|
+
data = {}
|
|
140
|
+
if data.get("headRefOid") != head:
|
|
141
|
+
ctx.out(f" #{pr}: the head is now {str(data.get('headRefOid'))[:12]}, not {head[:12]}; reading again")
|
|
142
|
+
return "moved"
|
|
143
|
+
state, detail = _rollup_state(data.get("statusCheckRollup"), ctx.allow_no_checks)
|
|
144
|
+
if state == "pass":
|
|
145
|
+
if detail:
|
|
146
|
+
ctx.out(f" #{pr}: WARNING: {detail} on head {head[:12]}")
|
|
111
147
|
return "pass"
|
|
112
|
-
|
|
148
|
+
if state in ("fail", "cancel"):
|
|
149
|
+
return state
|
|
150
|
+
if state == "skipped":
|
|
151
|
+
raise Stop(f"#{pr}: every check on head {head[:12]} was skipped: no job ran, so this is not a pass. "
|
|
152
|
+
"If this repo has no CI at all, run again with --allow-no-checks")
|
|
153
|
+
waiting = (f"checks pending ({detail})" if state == "pending" else
|
|
154
|
+
"no checks reported yet (if this repo has no CI at all, run again with --allow-no-checks)")
|
|
113
155
|
if ctx.clock.now() >= deadline:
|
|
114
156
|
raise Stop(f"#{pr}: {waiting} after {CHECKS_WAIT_MINUTES} min")
|
|
115
157
|
if ctx.dry_run:
|
|
@@ -159,10 +201,10 @@ def merge_one(ctx: ChainContext, pr: int) -> str | None:
|
|
|
159
201
|
ctx.out(f" WARNING: re-run of {run['databaseId']} refused: {(rerun.err or rerun.out).strip()[:200]}")
|
|
160
202
|
ctx.clock.sleep(60)
|
|
161
203
|
continue
|
|
162
|
-
result = _wait_checks(ctx, pr)
|
|
204
|
+
result = _wait_checks(ctx, pr, head)
|
|
163
205
|
if result == "fail":
|
|
164
206
|
raise Stop(f"#{pr}: CI failed on head {head[:12]}")
|
|
165
|
-
if result
|
|
207
|
+
if result in ("cancel", "moved"):
|
|
166
208
|
continue
|
|
167
209
|
if ctx.dry_run:
|
|
168
210
|
ctx.out(f"#{pr} ({merge_state}): checks green; would wait for idle push runs, then squash-merge {head[:12]}")
|
|
@@ -219,6 +261,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
219
261
|
parser.add_argument("--profile", type=Path, help="default: <repo>/.caddis/prod-check.toml")
|
|
220
262
|
parser.add_argument("--docs-only", action="store_true", help="the PRs change docs only: expect no deploy")
|
|
221
263
|
parser.add_argument("--dry-run", action="store_true", help="read-only gh calls; print the steps")
|
|
264
|
+
parser.add_argument("--allow-no-checks", action="store_true",
|
|
265
|
+
help="the repo has no CI: accept a head with no check, or with every check skipped")
|
|
222
266
|
parser.add_argument("--stop-file", type=Path, action="append", default=[],
|
|
223
267
|
help="extra STOP file (~/.caddis/STOP is always checked)")
|
|
224
268
|
args = parser.parse_args(argv)
|
|
@@ -229,6 +273,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
229
273
|
return caddis_exit.NOT_RUN
|
|
230
274
|
ctx = ChainContext(run=prod.real_runner(args.repo), clock=prod.Clock(), profile=profile, repo=args.repo,
|
|
231
275
|
gh_repo=args.gh_repo, dry_run=args.dry_run, docs_only=args.docs_only,
|
|
276
|
+
allow_no_checks=args.allow_no_checks,
|
|
232
277
|
stop_files=[Path.home() / ".caddis" / "STOP", *args.stop_file])
|
|
233
278
|
return merge_chain(ctx, args.prs)
|
|
234
279
|
|
|
@@ -20,13 +20,18 @@ except ModuleNotFoundError as exc:
|
|
|
20
20
|
import secret_filter
|
|
21
21
|
|
|
22
22
|
|
|
23
|
+
# No child process opens a console window on Windows (this runs under hooks and status lines that
|
|
24
|
+
# have no console). Inline, not scripts/no_window.py: in the source tree this file does not sit beside it.
|
|
25
|
+
_NO_WINDOW = 0x08000000 if os.name == "nt" else 0
|
|
26
|
+
|
|
23
27
|
MAX_NOTE_CHARS = 4000
|
|
24
28
|
|
|
25
29
|
|
|
26
30
|
def _git(repo: Path, *args: str, input_data: bytes | None = None,
|
|
27
31
|
env: dict[str, str] | None = None) -> bytes:
|
|
28
32
|
result = subprocess.run(["git", *args], cwd=repo, env=env, input=input_data,
|
|
29
|
-
stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False
|
|
33
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False,
|
|
34
|
+
creationflags=_NO_WINDOW)
|
|
30
35
|
if result.returncode:
|
|
31
36
|
# Name the subcommand, not a leading "-c key=value" option.
|
|
32
37
|
command = next((arg for i, arg in enumerate(args) if not arg.startswith("-") and (i == 0 or args[i - 1] != "-c")), "?")
|
|
@@ -53,7 +58,8 @@ def _pre_send(note: str) -> None:
|
|
|
53
58
|
"--- /dev/null\n+++ b/relay.md\n"
|
|
54
59
|
f"@@ -0,0 +1,{len(lines)} @@\n" +
|
|
55
60
|
"".join("+" + line + "\n" for line in lines))
|
|
56
|
-
result = secret_filter.filter_diff(diff, list(secret_filter.DEFAULT_DENY_GLOBS), []
|
|
61
|
+
result = secret_filter.filter_diff(diff, list(secret_filter.DEFAULT_DENY_GLOBS), [],
|
|
62
|
+
env_assignment_everywhere=True)
|
|
57
63
|
if result.hits or result.dropped_paths or not result.kept_diff:
|
|
58
64
|
raise ValueError("relay refused by pre-send filter")
|
|
59
65
|
|