@ikon85/agent-workflow-kit 0.36.4 → 0.37.0
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/.agents/skills/orchestrate-wave/SKILL.md +15 -7
- package/.agents/skills/setup-workflow/SKILL.md +16 -2
- package/.agents/skills/setup-workflow/orchestrate-wave-seed.md +3 -2
- package/.agents/skills/setup-workflow/worktree-lifecycle.md +11 -0
- package/.agents/skills/wrapup/SKILL.md +20 -9
- package/.claude/hooks/migration-snapshot-reminder.py +1 -1
- package/.claude/skills/orchestrate-wave/SKILL.md +6 -6
- package/.claude/skills/setup-workflow/SKILL.md +16 -2
- package/.claude/skills/setup-workflow/orchestrate-wave-seed.md +3 -2
- package/.claude/skills/setup-workflow/worktree-lifecycle.md +11 -0
- package/.claude/skills/skill-manifest.json +1 -1
- package/.claude/skills/wrapup/SKILL.md +11 -10
- package/README.md +59 -1
- package/agent-workflow-kit.package.json +26 -26
- package/docs/agents/workflow-capabilities.json +1 -0
- package/package.json +1 -1
- package/scripts/anchor_table.py +14 -8
- package/scripts/project-skill-extension.mjs +21 -2
- package/scripts/readiness.mjs +32 -4
- package/scripts/release-state.mjs +19 -9
- package/scripts/release-state.test.mjs +71 -8
- package/scripts/test_anchor_table.py +69 -0
- package/scripts/test_board_sync_create_idempotency.py +15 -2
- package/scripts/test_board_sync_wave_title.py +14 -2
- package/scripts/test_census_backstop.py +33 -0
- package/scripts/test_orchestrate_wave_contract.py +35 -0
- package/scripts/test_retro_wrapup_contract.py +19 -2
- package/scripts/test_wrapup_land.py +428 -0
- package/scripts/workflow-advisories/core.py +44 -2
- package/scripts/worktree-lifecycle/README.md +18 -4
- package/scripts/worktree-lifecycle/cleanup.py +173 -6
- package/scripts/worktree-lifecycle/core.py +331 -18
- package/scripts/worktree-lifecycle/profile.py +2 -0
- package/scripts/wrapup-land.py +336 -15
- package/src/cli.mjs +60 -27
- package/src/lib/manifest.mjs +173 -3
- package/src/lib/projectSkillExtension.mjs +78 -1
- package/src/lib/updateCandidate.mjs +11 -1
- package/src/lib/updateDecisions.mjs +2 -2
- package/src/lib/updateReconcile.mjs +6 -0
- package/src/lib/verifyUpdateCandidate.mjs +6 -3
- package/src/lib/verifyUpdateCandidateProtocol.mjs +15 -0
- package/src/lib/verifyUpdateCandidateTransaction.mjs +23 -1
|
@@ -34,6 +34,7 @@ PROFILE = {
|
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
def load_board_sync():
|
|
37
|
+
previous_profile = os.environ.get("BOARD_SYNC_PROFILE")
|
|
37
38
|
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False) as profile:
|
|
38
39
|
profile.write("<!-- board-sync:profile -->\n```json\n")
|
|
39
40
|
json.dump(PROFILE, profile)
|
|
@@ -45,11 +46,18 @@ def load_board_sync():
|
|
|
45
46
|
)
|
|
46
47
|
module = importlib.util.module_from_spec(spec)
|
|
47
48
|
assert spec.loader is not None
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
try:
|
|
50
|
+
spec.loader.exec_module(module)
|
|
51
|
+
finally:
|
|
52
|
+
Path(profile_path).unlink()
|
|
53
|
+
if previous_profile is None:
|
|
54
|
+
os.environ.pop("BOARD_SYNC_PROFILE", None)
|
|
55
|
+
else:
|
|
56
|
+
os.environ["BOARD_SYNC_PROFILE"] = previous_profile
|
|
50
57
|
return module
|
|
51
58
|
|
|
52
59
|
|
|
60
|
+
PROFILE_BEFORE_LOAD = os.environ.get("BOARD_SYNC_PROFILE")
|
|
53
61
|
bs = load_board_sync()
|
|
54
62
|
|
|
55
63
|
|
|
@@ -80,6 +88,11 @@ class FakeGh:
|
|
|
80
88
|
return ""
|
|
81
89
|
|
|
82
90
|
|
|
91
|
+
class BoardSyncImportIsolationTest(unittest.TestCase):
|
|
92
|
+
def test_loading_board_sync_restores_the_caller_profile(self):
|
|
93
|
+
self.assertEqual(os.environ.get("BOARD_SYNC_PROFILE"), PROFILE_BEFORE_LOAD)
|
|
94
|
+
|
|
95
|
+
|
|
83
96
|
class MarkerAwareCreateTest(unittest.TestCase):
|
|
84
97
|
def test_identical_program_leaf_retry_reuses_existing_issue(self):
|
|
85
98
|
marker = "<!-- program-leaf-source: program-x/1a -->"
|
|
@@ -37,6 +37,7 @@ BASE_PROFILE = {
|
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
def load_board_sync(profile_dict: dict, module_name: str):
|
|
40
|
+
previous_profile = os.environ.get("BOARD_SYNC_PROFILE")
|
|
40
41
|
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False) as profile:
|
|
41
42
|
profile.write("<!-- board-sync:profile -->\n```json\n")
|
|
42
43
|
json.dump(profile_dict, profile)
|
|
@@ -48,8 +49,14 @@ def load_board_sync(profile_dict: dict, module_name: str):
|
|
|
48
49
|
)
|
|
49
50
|
module = importlib.util.module_from_spec(spec)
|
|
50
51
|
assert spec.loader is not None
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
try:
|
|
53
|
+
spec.loader.exec_module(module)
|
|
54
|
+
finally:
|
|
55
|
+
Path(profile_path).unlink()
|
|
56
|
+
if previous_profile is None:
|
|
57
|
+
os.environ.pop("BOARD_SYNC_PROFILE", None)
|
|
58
|
+
else:
|
|
59
|
+
os.environ["BOARD_SYNC_PROFILE"] = previous_profile
|
|
53
60
|
return module
|
|
54
61
|
|
|
55
62
|
|
|
@@ -75,6 +82,11 @@ class WaveTitleDefaultPrefix(unittest.TestCase):
|
|
|
75
82
|
"Welle 29 — Auth hardening",
|
|
76
83
|
)
|
|
77
84
|
|
|
85
|
+
def test_loading_board_sync_restores_the_caller_profile(self):
|
|
86
|
+
before = os.environ.get("BOARD_SYNC_PROFILE")
|
|
87
|
+
load_board_sync(copy.deepcopy(BASE_PROFILE), "board_sync_wave_isolation")
|
|
88
|
+
self.assertEqual(os.environ.get("BOARD_SYNC_PROFILE"), before)
|
|
89
|
+
|
|
78
90
|
|
|
79
91
|
class WaveTitleProfilePrefix(unittest.TestCase):
|
|
80
92
|
"""A profile with `titles.wavePrefix` drives the anchor title language."""
|
|
@@ -40,6 +40,39 @@ class CensusBackstopTest(unittest.TestCase):
|
|
|
40
40
|
"overrides": overrides or [],
|
|
41
41
|
}) + "\n", encoding="utf-8")
|
|
42
42
|
|
|
43
|
+
def test_execute_ready_missing_profile_failure_is_captured_and_exact(self):
|
|
44
|
+
missing_profile = ROOT / "missing-board-profile.md"
|
|
45
|
+
environment = os.environ.copy()
|
|
46
|
+
environment["BOARD_SYNC_PROFILE"] = str(missing_profile)
|
|
47
|
+
|
|
48
|
+
completed = subprocess.run(
|
|
49
|
+
[
|
|
50
|
+
sys.executable,
|
|
51
|
+
str(ROOT / "scripts" / "execute-ready-check.py"),
|
|
52
|
+
"--issue",
|
|
53
|
+
"52",
|
|
54
|
+
"--mode",
|
|
55
|
+
"handoff",
|
|
56
|
+
],
|
|
57
|
+
cwd=ROOT,
|
|
58
|
+
env=environment,
|
|
59
|
+
capture_output=True,
|
|
60
|
+
text=True,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
expected_stderr = (
|
|
64
|
+
"[FAIL] execute-ready-check: Board-Profil nicht verfügbar — "
|
|
65
|
+
f"board profile not found at {missing_profile} — run /setup-workflow "
|
|
66
|
+
"to seed docs/agents/board-sync.md.\n"
|
|
67
|
+
)
|
|
68
|
+
diagnostics = (
|
|
69
|
+
f"stdout:\n{completed.stdout}\n"
|
|
70
|
+
f"stderr:\n{completed.stderr}"
|
|
71
|
+
)
|
|
72
|
+
self.assertEqual(completed.returncode, 1, diagnostics)
|
|
73
|
+
self.assertEqual(completed.stdout, "", diagnostics)
|
|
74
|
+
self.assertEqual(completed.stderr, expected_stderr, diagnostics)
|
|
75
|
+
|
|
43
76
|
def activate_current(self, root):
|
|
44
77
|
fresh = DRIFT_GUARD.scan_census_status(root)["fresh"]
|
|
45
78
|
(root / ".census" / "active.json").write_text(
|
|
@@ -210,6 +210,41 @@ class OrchestrateWaveContract(unittest.TestCase):
|
|
|
210
210
|
):
|
|
211
211
|
self.assertIn(fragment, done)
|
|
212
212
|
|
|
213
|
+
def test_phase_one_orders_producers_before_surface_measurers(self):
|
|
214
|
+
prose = " ".join(self.skill.split())
|
|
215
|
+
phase_one = prose.split("## Phase 1", 1)[1].split("## Phase 2", 1)[0]
|
|
216
|
+
for fragment in (
|
|
217
|
+
"Producer/measurer coupling",
|
|
218
|
+
"semantic dependency",
|
|
219
|
+
"measurer strictly after the producer",
|
|
220
|
+
"baseline reconciliation",
|
|
221
|
+
"drop producer-resolved entries",
|
|
222
|
+
):
|
|
223
|
+
self.assertIn(fragment, phase_one)
|
|
224
|
+
|
|
225
|
+
done = phase_one.split("**Done when:**", 1)[1]
|
|
226
|
+
for fragment in (
|
|
227
|
+
"producer/measurer pair",
|
|
228
|
+
"explicit edge/order",
|
|
229
|
+
"reconciliation owner",
|
|
230
|
+
):
|
|
231
|
+
self.assertIn(fragment, done)
|
|
232
|
+
|
|
233
|
+
def test_phase_four_centrally_runs_builder_browser_specs_before_ci(self):
|
|
234
|
+
prose = " ".join(self.skill.split())
|
|
235
|
+
phase_four = prose.split("## Phase 4", 1)[1].split("## Phase 5", 1)[0]
|
|
236
|
+
browser_contract = "builders authored or changed browser specs"
|
|
237
|
+
central_gate = "Re-run your project's full CI/verify gate CENTRALLY yourself"
|
|
238
|
+
for fragment in (
|
|
239
|
+
browser_contract,
|
|
240
|
+
"unverified artifact",
|
|
241
|
+
"ONE central green run",
|
|
242
|
+
"if `§Verify Recipe` names a local e2e runner",
|
|
243
|
+
"Never invent a command under generic fallback",
|
|
244
|
+
):
|
|
245
|
+
self.assertIn(fragment, phase_four)
|
|
246
|
+
self.assertLess(phase_four.index(browser_contract), phase_four.index(central_gate))
|
|
247
|
+
|
|
213
248
|
def test_phase_two_keeps_per_slice_routing_decisions(self):
|
|
214
249
|
prose = " ".join(self.skill.split())
|
|
215
250
|
self.assertIn("(a) inline vs delegate", prose)
|
|
@@ -88,7 +88,7 @@ class WrapupChainingContract(unittest.TestCase):
|
|
|
88
88
|
required = (
|
|
89
89
|
"invoke the `retro` skill immediately in this run",
|
|
90
90
|
"land nothing in this run",
|
|
91
|
-
"fresh explicit `/wrapup` invocation",
|
|
91
|
+
"fresh explicit `$wrapup` or `/wrapup` invocation",
|
|
92
92
|
)
|
|
93
93
|
for surface in SURFACES:
|
|
94
94
|
with self.subTest(surface=surface):
|
|
@@ -120,10 +120,27 @@ class WrapupChainingContract(unittest.TestCase):
|
|
|
120
120
|
with self.subTest(surface=surface):
|
|
121
121
|
text = skill(surface, "wrapup")
|
|
122
122
|
self.assertIn("disable-model-invocation: true", text)
|
|
123
|
-
self.assertIn(
|
|
123
|
+
self.assertIn(
|
|
124
|
+
"user's direct `$wrapup` or `/wrapup` input IS the explicit",
|
|
125
|
+
text,
|
|
126
|
+
)
|
|
124
127
|
self.assertIn("program-sync", text)
|
|
125
128
|
self.assertIn("Phasen-Gates", text)
|
|
126
129
|
|
|
130
|
+
def test_wrapup_accepts_only_direct_user_dollar_or_slash_invocations(self):
|
|
131
|
+
required = (
|
|
132
|
+
"direct `$wrapup` or `/wrapup`",
|
|
133
|
+
"Natural-language requests",
|
|
134
|
+
"indirect skill chaining",
|
|
135
|
+
"autonomous invocation",
|
|
136
|
+
"fresh explicit `$wrapup` or `/wrapup` invocation",
|
|
137
|
+
)
|
|
138
|
+
for surface in SURFACES:
|
|
139
|
+
with self.subTest(surface=surface):
|
|
140
|
+
text = contract_text(surface, "wrapup")
|
|
141
|
+
for phrase in required:
|
|
142
|
+
self.assertIn(phrase, text)
|
|
143
|
+
|
|
127
144
|
def test_new_contract_is_reference_free(self):
|
|
128
145
|
for surface in SURFACES:
|
|
129
146
|
with self.subTest(surface=surface):
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Focused contracts for wrapup's bounded PR-check merge gate."""
|
|
3
|
+
|
|
4
|
+
import importlib.util
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import subprocess
|
|
8
|
+
import unittest
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
REPO = Path(__file__).resolve().parent.parent
|
|
12
|
+
WRAPUP = REPO / "scripts/wrapup-land.py"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_wrapup():
|
|
16
|
+
spec = importlib.util.spec_from_file_location("wrapup_land_check_gate", WRAPUP)
|
|
17
|
+
module = importlib.util.module_from_spec(spec)
|
|
18
|
+
spec.loader.exec_module(module)
|
|
19
|
+
return module
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def completed(payload, returncode=0, stderr=""):
|
|
23
|
+
return subprocess.CompletedProcess([], returncode, json.dumps(payload), stderr)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def pr_snapshot(**overrides):
|
|
27
|
+
snapshot = {
|
|
28
|
+
"state": "OPEN",
|
|
29
|
+
"mergeable": "MERGEABLE",
|
|
30
|
+
"mergeStateStatus": "CLEAN",
|
|
31
|
+
"statusCheckRollup": [],
|
|
32
|
+
"baseRefName": "main",
|
|
33
|
+
}
|
|
34
|
+
snapshot.update(overrides)
|
|
35
|
+
return snapshot
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def gate_runner(snapshots, required_checks):
|
|
39
|
+
snapshots = iter(snapshots)
|
|
40
|
+
required_checks = iter(required_checks)
|
|
41
|
+
|
|
42
|
+
def runner(cmd, **_kwargs):
|
|
43
|
+
if cmd[:3] == ["gh", "pr", "view"]:
|
|
44
|
+
return completed(next(snapshots))
|
|
45
|
+
if cmd[:3] == ["gh", "pr", "checks"]:
|
|
46
|
+
checks = next(required_checks)
|
|
47
|
+
return completed(checks, returncode=1 if any(
|
|
48
|
+
check.get("state") == "FAILURE" for check in checks
|
|
49
|
+
) else 0)
|
|
50
|
+
raise AssertionError(f"unexpected command: {cmd}")
|
|
51
|
+
|
|
52
|
+
return runner
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class FakeClock:
|
|
56
|
+
def __init__(self):
|
|
57
|
+
self.now = 0.0
|
|
58
|
+
|
|
59
|
+
def monotonic(self):
|
|
60
|
+
return self.now
|
|
61
|
+
|
|
62
|
+
def sleep(self, seconds):
|
|
63
|
+
self.now += seconds
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class WrapupCheckGateContract(unittest.TestCase):
|
|
67
|
+
def setUp(self):
|
|
68
|
+
self.wrapup = load_wrapup()
|
|
69
|
+
|
|
70
|
+
def test_required_check_names_come_from_active_base_branch_rules(self):
|
|
71
|
+
commands = []
|
|
72
|
+
|
|
73
|
+
def runner(cmd, **_kwargs):
|
|
74
|
+
commands.append(cmd)
|
|
75
|
+
if cmd[:3] == ["gh", "repo", "view"]:
|
|
76
|
+
return completed({"nameWithOwner": "acme/repo"})
|
|
77
|
+
if cmd[:2] == ["gh", "api"]:
|
|
78
|
+
return completed([
|
|
79
|
+
{"type": "pull_request"},
|
|
80
|
+
{
|
|
81
|
+
"type": "required_status_checks",
|
|
82
|
+
"parameters": {
|
|
83
|
+
"required_status_checks": [
|
|
84
|
+
{"context": "test", "integration_id": 1},
|
|
85
|
+
{"context": "lint", "integration_id": 1},
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
])
|
|
90
|
+
raise AssertionError(f"unexpected command: {cmd}")
|
|
91
|
+
|
|
92
|
+
self.assertEqual(
|
|
93
|
+
self.wrapup._configured_required_check_names(
|
|
94
|
+
pr_snapshot(baseRefName="release/next"), runner
|
|
95
|
+
),
|
|
96
|
+
{"test", "lint"},
|
|
97
|
+
)
|
|
98
|
+
self.assertIn(
|
|
99
|
+
["gh", "api", "repos/acme/repo/rules/branches/release%2Fnext"],
|
|
100
|
+
commands,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def test_pending_checks_are_visible_then_green_checks_proceed(self):
|
|
104
|
+
snapshots = iter([
|
|
105
|
+
pr_snapshot(mergeable="UNKNOWN", mergeStateStatus="BLOCKED"),
|
|
106
|
+
pr_snapshot(),
|
|
107
|
+
])
|
|
108
|
+
checks = iter([
|
|
109
|
+
[{"name": "test", "state": "PENDING", "link": "https://example.test"}],
|
|
110
|
+
[{"name": "test", "state": "SUCCESS", "link": "https://example.test"}],
|
|
111
|
+
])
|
|
112
|
+
calls = []
|
|
113
|
+
|
|
114
|
+
def runner(cmd, **_kwargs):
|
|
115
|
+
calls.append(cmd)
|
|
116
|
+
if cmd[:3] == ["gh", "pr", "view"]:
|
|
117
|
+
return completed(next(snapshots))
|
|
118
|
+
if cmd[:3] == ["gh", "pr", "checks"]:
|
|
119
|
+
return completed(next(checks))
|
|
120
|
+
raise AssertionError(f"unexpected command: {cmd}")
|
|
121
|
+
|
|
122
|
+
clock = FakeClock()
|
|
123
|
+
progress = io.StringIO()
|
|
124
|
+
already_merged = self.wrapup.wait_for_merge_gate(
|
|
125
|
+
"42",
|
|
126
|
+
timeout_seconds=30,
|
|
127
|
+
poll_interval=5,
|
|
128
|
+
command_runner=runner,
|
|
129
|
+
clock=clock.monotonic,
|
|
130
|
+
sleeper=clock.sleep,
|
|
131
|
+
progress_stream=progress,
|
|
132
|
+
configured_required_names={"test"},
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
self.assertFalse(already_merged)
|
|
136
|
+
self.assertEqual(len(calls), 4)
|
|
137
|
+
self.assertIn("waiting for PR #42 checks", progress.getvalue())
|
|
138
|
+
self.assertIn("test", progress.getvalue())
|
|
139
|
+
|
|
140
|
+
def test_explicit_null_conclusion_waits_even_with_green_legacy_state(self):
|
|
141
|
+
checks = [{
|
|
142
|
+
"name": "test",
|
|
143
|
+
"status": "COMPLETED",
|
|
144
|
+
"conclusion": None,
|
|
145
|
+
"state": "SUCCESS",
|
|
146
|
+
}]
|
|
147
|
+
|
|
148
|
+
self.assertEqual(self.wrapup.pending_checks(checks), checks)
|
|
149
|
+
|
|
150
|
+
def test_terminal_red_check_stops_before_merge_and_names_check(self):
|
|
151
|
+
snapshot = pr_snapshot(mergeStateStatus="BLOCKED")
|
|
152
|
+
checks = [[{
|
|
153
|
+
"name": "test",
|
|
154
|
+
"state": "FAILURE",
|
|
155
|
+
"link": "https://example.test",
|
|
156
|
+
}]]
|
|
157
|
+
|
|
158
|
+
with self.assertRaises(self.wrapup.Stop) as stopped:
|
|
159
|
+
self.wrapup.wait_for_merge_gate(
|
|
160
|
+
"42",
|
|
161
|
+
command_runner=gate_runner([snapshot], checks),
|
|
162
|
+
configured_required_names={"test"},
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
self.assertEqual(stopped.exception.step, "0c merge-gate")
|
|
166
|
+
self.assertIn("test", stopped.exception.detail)
|
|
167
|
+
self.assertNotIn("infrastructure failure", stopped.exception.detail)
|
|
168
|
+
|
|
169
|
+
def test_timeout_names_pending_checks_and_elapsed_time(self):
|
|
170
|
+
snapshot = pr_snapshot(mergeable="UNKNOWN", mergeStateStatus="BLOCKED")
|
|
171
|
+
checks = [
|
|
172
|
+
{"name": "test", "state": "QUEUED"},
|
|
173
|
+
{"name": "lint", "state": "PENDING"},
|
|
174
|
+
]
|
|
175
|
+
clock = FakeClock()
|
|
176
|
+
|
|
177
|
+
with self.assertRaises(self.wrapup.Stop) as stopped:
|
|
178
|
+
self.wrapup.wait_for_merge_gate(
|
|
179
|
+
"42",
|
|
180
|
+
timeout_seconds=10,
|
|
181
|
+
poll_interval=5,
|
|
182
|
+
command_runner=gate_runner(
|
|
183
|
+
[snapshot, snapshot, snapshot],
|
|
184
|
+
[checks, checks, checks],
|
|
185
|
+
),
|
|
186
|
+
clock=clock.monotonic,
|
|
187
|
+
sleeper=clock.sleep,
|
|
188
|
+
progress_stream=io.StringIO(),
|
|
189
|
+
configured_required_names={"test", "lint"},
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
self.assertIn("wait budget exceeded", stopped.exception.reason)
|
|
193
|
+
self.assertIn("elapsed=10.0s", stopped.exception.detail)
|
|
194
|
+
self.assertIn("test", stopped.exception.detail)
|
|
195
|
+
self.assertIn("lint", stopped.exception.detail)
|
|
196
|
+
|
|
197
|
+
def test_already_merged_pr_bypasses_check_wait(self):
|
|
198
|
+
snapshot = pr_snapshot(
|
|
199
|
+
state="MERGED", mergeable="UNKNOWN", mergeStateStatus="UNKNOWN"
|
|
200
|
+
)
|
|
201
|
+
clock = FakeClock()
|
|
202
|
+
commands = []
|
|
203
|
+
|
|
204
|
+
def runner(cmd, **_kwargs):
|
|
205
|
+
commands.append(cmd)
|
|
206
|
+
return completed(snapshot)
|
|
207
|
+
|
|
208
|
+
already_merged = self.wrapup.wait_for_merge_gate(
|
|
209
|
+
"42",
|
|
210
|
+
command_runner=runner,
|
|
211
|
+
clock=clock.monotonic,
|
|
212
|
+
sleeper=clock.sleep,
|
|
213
|
+
progress_stream=io.StringIO(),
|
|
214
|
+
configured_required_names={"test"},
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
self.assertTrue(already_merged)
|
|
218
|
+
self.assertEqual(clock.now, 0)
|
|
219
|
+
self.assertEqual(len(commands), 1)
|
|
220
|
+
|
|
221
|
+
def test_optional_red_check_does_not_block_when_required_check_is_green(self):
|
|
222
|
+
snapshot = pr_snapshot(
|
|
223
|
+
mergeStateStatus="BLOCKED",
|
|
224
|
+
statusCheckRollup=[{
|
|
225
|
+
"name": "advisory-browser",
|
|
226
|
+
"status": "COMPLETED",
|
|
227
|
+
"conclusion": "FAILURE",
|
|
228
|
+
}],
|
|
229
|
+
)
|
|
230
|
+
runner = gate_runner([snapshot], [[{
|
|
231
|
+
"name": "test",
|
|
232
|
+
"state": "SUCCESS",
|
|
233
|
+
"link": "https://example.test",
|
|
234
|
+
}]])
|
|
235
|
+
|
|
236
|
+
self.assertFalse(self.wrapup.wait_for_merge_gate(
|
|
237
|
+
"42", command_runner=runner, configured_required_names={"test"}
|
|
238
|
+
))
|
|
239
|
+
|
|
240
|
+
def test_fresh_pr_waits_until_required_checks_become_visible(self):
|
|
241
|
+
snapshots = [
|
|
242
|
+
pr_snapshot(mergeable="UNKNOWN", mergeStateStatus="BLOCKED"),
|
|
243
|
+
pr_snapshot(mergeable="UNKNOWN", mergeStateStatus="BLOCKED"),
|
|
244
|
+
pr_snapshot(),
|
|
245
|
+
]
|
|
246
|
+
required = [
|
|
247
|
+
[],
|
|
248
|
+
[{"name": "test", "state": "PENDING"}],
|
|
249
|
+
[{"name": "test", "state": "SUCCESS"}],
|
|
250
|
+
]
|
|
251
|
+
clock = FakeClock()
|
|
252
|
+
progress = io.StringIO()
|
|
253
|
+
|
|
254
|
+
self.assertFalse(self.wrapup.wait_for_merge_gate(
|
|
255
|
+
"42",
|
|
256
|
+
timeout_seconds=30,
|
|
257
|
+
poll_interval=5,
|
|
258
|
+
command_runner=gate_runner(snapshots, required),
|
|
259
|
+
clock=clock.monotonic,
|
|
260
|
+
sleeper=clock.sleep,
|
|
261
|
+
progress_stream=progress,
|
|
262
|
+
configured_required_names={"test"},
|
|
263
|
+
))
|
|
264
|
+
self.assertIn("test (awaiting discovery)", progress.getvalue())
|
|
265
|
+
self.assertIn("test", progress.getvalue())
|
|
266
|
+
|
|
267
|
+
def test_visible_optional_check_cannot_mask_required_discovery(self):
|
|
268
|
+
snapshot = pr_snapshot(
|
|
269
|
+
mergeStateStatus="BLOCKED",
|
|
270
|
+
statusCheckRollup=[{
|
|
271
|
+
"name": "advisory-browser",
|
|
272
|
+
"status": "COMPLETED",
|
|
273
|
+
"conclusion": "FAILURE",
|
|
274
|
+
}],
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
clock = FakeClock()
|
|
278
|
+
with self.assertRaises(self.wrapup.Stop) as stopped:
|
|
279
|
+
self.wrapup.wait_for_merge_gate(
|
|
280
|
+
"42",
|
|
281
|
+
timeout_seconds=0,
|
|
282
|
+
command_runner=gate_runner([snapshot], [[]]),
|
|
283
|
+
clock=clock.monotonic,
|
|
284
|
+
sleeper=clock.sleep,
|
|
285
|
+
progress_stream=io.StringIO(),
|
|
286
|
+
configured_required_names={"test"},
|
|
287
|
+
)
|
|
288
|
+
self.assertIn("test (awaiting discovery)", stopped.exception.detail)
|
|
289
|
+
|
|
290
|
+
def test_zero_step_failed_job_is_named_as_infrastructure_failure(self):
|
|
291
|
+
snapshot = pr_snapshot(mergeStateStatus="BLOCKED")
|
|
292
|
+
check = {
|
|
293
|
+
"name": "test",
|
|
294
|
+
"state": "FAILURE",
|
|
295
|
+
"link": "https://github.com/acme/repo/actions/runs/123/job/456",
|
|
296
|
+
}
|
|
297
|
+
commands = []
|
|
298
|
+
|
|
299
|
+
def runner(cmd, **_kwargs):
|
|
300
|
+
commands.append(cmd)
|
|
301
|
+
if cmd[:3] == ["gh", "pr", "view"]:
|
|
302
|
+
return completed(snapshot)
|
|
303
|
+
if cmd[:3] == ["gh", "pr", "checks"]:
|
|
304
|
+
return completed([check], returncode=1)
|
|
305
|
+
if "--json" in cmd:
|
|
306
|
+
return completed({
|
|
307
|
+
"jobs": [{
|
|
308
|
+
"databaseId": 456,
|
|
309
|
+
"name": "test",
|
|
310
|
+
"conclusion": "failure",
|
|
311
|
+
"steps": [],
|
|
312
|
+
}],
|
|
313
|
+
})
|
|
314
|
+
return subprocess.CompletedProcess(
|
|
315
|
+
cmd, 1, "", "log unavailable"
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
with self.assertRaises(self.wrapup.Stop) as stopped:
|
|
319
|
+
self.wrapup.wait_for_merge_gate(
|
|
320
|
+
"42", command_runner=runner, configured_required_names={"test"}
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
self.assertIn("test", stopped.exception.detail)
|
|
324
|
+
self.assertIn("infrastructure failure", stopped.exception.detail)
|
|
325
|
+
self.assertIn(["gh", "run", "view", "123", "--json", "jobs"], commands)
|
|
326
|
+
self.assertEqual(
|
|
327
|
+
[cmd[:3] for cmd in commands].count(["gh", "run", "view"]), 1
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
def test_billing_annotation_is_sanitized_and_bounded(self):
|
|
331
|
+
noisy = (
|
|
332
|
+
"The job was not started because recent account payments have failed. "
|
|
333
|
+
+ "TOKEN=secret "
|
|
334
|
+
+ "x" * 1000
|
|
335
|
+
)
|
|
336
|
+
check = {
|
|
337
|
+
"name": "test",
|
|
338
|
+
"state": "FAILURE",
|
|
339
|
+
"link": "https://github.com/acme/repo/actions/runs/123/job/456",
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
def runner(cmd, **_kwargs):
|
|
343
|
+
if "--json" in cmd:
|
|
344
|
+
return completed({"jobs": [{
|
|
345
|
+
"databaseId": 456,
|
|
346
|
+
"name": "test",
|
|
347
|
+
"conclusion": "failure",
|
|
348
|
+
"steps": [{"name": "x"}],
|
|
349
|
+
}]})
|
|
350
|
+
self.assertIn("--job", cmd)
|
|
351
|
+
self.assertIn("456", cmd)
|
|
352
|
+
return subprocess.CompletedProcess(cmd, 0, noisy, "")
|
|
353
|
+
|
|
354
|
+
diagnosis = self.wrapup.infrastructure_failure_diagnosis(
|
|
355
|
+
check, command_runner=runner
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
self.assertIn("infrastructure failure", diagnosis)
|
|
359
|
+
self.assertLessEqual(len(diagnosis), self.wrapup.MAX_EXTERNAL_DETAIL)
|
|
360
|
+
self.assertNotIn("\n", diagnosis)
|
|
361
|
+
|
|
362
|
+
def test_mixed_run_does_not_misclassify_real_test_failure_as_infra(self):
|
|
363
|
+
check = {
|
|
364
|
+
"name": "test",
|
|
365
|
+
"state": "FAILURE",
|
|
366
|
+
"link": "https://github.com/acme/repo/actions/runs/123/job/456",
|
|
367
|
+
}
|
|
368
|
+
commands = []
|
|
369
|
+
|
|
370
|
+
def runner(cmd, **_kwargs):
|
|
371
|
+
commands.append(cmd)
|
|
372
|
+
if "--json" in cmd:
|
|
373
|
+
return completed({"jobs": [
|
|
374
|
+
{
|
|
375
|
+
"databaseId": 111,
|
|
376
|
+
"name": "setup",
|
|
377
|
+
"conclusion": "startup_failure",
|
|
378
|
+
"steps": [],
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
"databaseId": 456,
|
|
382
|
+
"name": "test",
|
|
383
|
+
"conclusion": "failure",
|
|
384
|
+
"steps": [{"name": "Run tests", "conclusion": "failure"}],
|
|
385
|
+
},
|
|
386
|
+
]})
|
|
387
|
+
return subprocess.CompletedProcess(
|
|
388
|
+
cmd, 0, "AssertionError: expected green", ""
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
diagnosis = self.wrapup.infrastructure_failure_diagnosis(
|
|
392
|
+
check, command_runner=runner
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
self.assertEqual(diagnosis, "")
|
|
396
|
+
self.assertIn(
|
|
397
|
+
["gh", "run", "view", "123", "--job", "456", "--log-failed"],
|
|
398
|
+
commands,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
def test_check_without_unique_job_match_is_not_classified_from_run_logs(self):
|
|
402
|
+
check = {
|
|
403
|
+
"name": "test",
|
|
404
|
+
"state": "FAILURE",
|
|
405
|
+
"link": "https://github.com/acme/repo/actions/runs/123",
|
|
406
|
+
}
|
|
407
|
+
commands = []
|
|
408
|
+
|
|
409
|
+
def runner(cmd, **_kwargs):
|
|
410
|
+
commands.append(cmd)
|
|
411
|
+
if "--json" in cmd:
|
|
412
|
+
return completed({"jobs": [
|
|
413
|
+
{"name": "test", "conclusion": "failure", "steps": []},
|
|
414
|
+
{"name": "test", "conclusion": "failure", "steps": []},
|
|
415
|
+
]})
|
|
416
|
+
raise AssertionError("aggregate run logs must not be inspected")
|
|
417
|
+
|
|
418
|
+
self.assertEqual(
|
|
419
|
+
self.wrapup.infrastructure_failure_diagnosis(
|
|
420
|
+
check, command_runner=runner
|
|
421
|
+
),
|
|
422
|
+
"",
|
|
423
|
+
)
|
|
424
|
+
self.assertEqual(len(commands), 1)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
if __name__ == "__main__":
|
|
428
|
+
unittest.main()
|
|
@@ -261,11 +261,47 @@ def convention_freshness_decision(profile: dict, root: Path) -> Decision:
|
|
|
261
261
|
)
|
|
262
262
|
|
|
263
263
|
|
|
264
|
-
def
|
|
264
|
+
def _migration_session_marker(profile: dict, payload: dict, root: Path) -> Path | None:
|
|
265
|
+
session_id = payload.get("session_id")
|
|
266
|
+
if not isinstance(session_id, str) or not session_id:
|
|
267
|
+
return None
|
|
268
|
+
state_dir = root / profile.get("baseline", {}).get(
|
|
269
|
+
"stateDir", ".claude/logs/advisory-state",
|
|
270
|
+
)
|
|
271
|
+
safe_session = re.sub(r"[^A-Za-z0-9._-]", "-", session_id)
|
|
272
|
+
return state_dir / f"{safe_session}.migration.hinted"
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _unquoted_shell_text(command: str) -> str:
|
|
276
|
+
visible: list[str] = []
|
|
277
|
+
quote: str | None = None
|
|
278
|
+
escaped = False
|
|
279
|
+
for character in command:
|
|
280
|
+
if escaped:
|
|
281
|
+
visible.append(" " if quote else character)
|
|
282
|
+
escaped = False
|
|
283
|
+
elif character == "\\" and quote != "'":
|
|
284
|
+
visible.append(" ")
|
|
285
|
+
escaped = True
|
|
286
|
+
elif quote:
|
|
287
|
+
visible.append(" ")
|
|
288
|
+
if character == quote:
|
|
289
|
+
quote = None
|
|
290
|
+
elif character in {"'", '"', "`"}:
|
|
291
|
+
visible.append(" ")
|
|
292
|
+
quote = character
|
|
293
|
+
else:
|
|
294
|
+
visible.append(character)
|
|
295
|
+
return "".join(visible)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def migration_reminder_decision(
|
|
299
|
+
profile: dict, payload: dict, root: Path,
|
|
300
|
+
) -> Decision:
|
|
265
301
|
config = profile.get("migration", {})
|
|
266
302
|
if payload.get("tool_name") != "Bash":
|
|
267
303
|
return Decision(None, "PostToolUse")
|
|
268
|
-
command = payload.get("tool_input", {}).get("command", "")
|
|
304
|
+
command = _unquoted_shell_text(payload.get("tool_input", {}).get("command", ""))
|
|
269
305
|
if not any(
|
|
270
306
|
re.search(pattern, command)
|
|
271
307
|
for pattern in config.get("commandMatchers", [])
|
|
@@ -275,6 +311,12 @@ def migration_reminder_decision(profile: dict, payload: dict) -> Decision:
|
|
|
275
311
|
refresh = config.get("refreshCommand", [])
|
|
276
312
|
if not artifact or not refresh:
|
|
277
313
|
return Decision(None, "PostToolUse")
|
|
314
|
+
marker = _migration_session_marker(profile, payload, root)
|
|
315
|
+
if marker and marker.exists():
|
|
316
|
+
return Decision(None, "PostToolUse")
|
|
317
|
+
if marker:
|
|
318
|
+
marker.parent.mkdir(parents=True, exist_ok=True)
|
|
319
|
+
marker.write_text(f"{payload['session_id']}\n", encoding="utf-8")
|
|
278
320
|
message = (
|
|
279
321
|
f"Migration advisory: refresh {artifact} with `{' '.join(refresh)}` "
|
|
280
322
|
"before treating the migration result as complete."
|