devlyn-cli 4.3.31 → 4.3.32
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/README.md +3 -1
- package/bin/devlyn.js +33 -8
- package/bin/harness-activation.js +4 -1
- package/bin/harness-status.js +15 -8
- package/bin/skill-history.json +7 -0
- package/config/skills/_shared/bring-in.py +10 -0
- package/config/skills/_shared/task-complete.py +319 -54
- package/config/skills/_shared/task-completion.md +5 -5
- package/config/skills/_shared/warm-build-dirs.py +13 -0
- package/config/skills/devlyn-ideate/references/loop.md +1 -1
- package/config/skills/devlyn-ideate/scripts/acceptance.py +11 -0
- package/config/skills/devlyn-ideate/scripts/queue.py +17 -1
- package/native/claude/.claude-plugin/plugin.json +1 -1
- package/native/claude/skills/_shared/bring-in.py +10 -0
- package/native/claude/skills/_shared/task-complete.py +319 -54
- package/native/claude/skills/_shared/task-completion.md +5 -5
- package/native/claude/skills/_shared/warm-build-dirs.py +13 -0
- package/native/claude/skills/devlyn-ideate/references/loop.md +1 -1
- package/native/claude/skills/devlyn-ideate/scripts/acceptance.py +11 -0
- package/native/claude/skills/devlyn-ideate/scripts/queue.py +17 -1
- package/native/codex/.agents/plugins/marketplace.json +1 -1
- package/native/codex/plugins/devlyn/.codex-plugin/plugin.json +1 -1
- package/native/codex/plugins/devlyn/skills/_shared/bring-in.py +10 -0
- package/native/codex/plugins/devlyn/skills/_shared/task-complete.py +319 -54
- package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +5 -5
- package/native/codex/plugins/devlyn/skills/_shared/warm-build-dirs.py +13 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/loop.md +1 -1
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/acceptance.py +11 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py +17 -1
- package/package.json +1 -1
- package/release.json +32 -32
|
@@ -1480,17 +1480,24 @@ def cleanup_workspace(receipt, path, *, warnings):
|
|
|
1480
1480
|
sync_surviving_directory(Path(receipt['common_gitdir']) / 'worktrees')
|
|
1481
1481
|
|
|
1482
1482
|
|
|
1483
|
-
def
|
|
1484
|
-
|
|
1483
|
+
def verify_delivery(receipt, path, pr):
|
|
1484
|
+
"""Evidence that the accepted source is delivered, checked before anything is removed."""
|
|
1485
1485
|
if pr is not None:
|
|
1486
1486
|
require(pr["state"] == "MERGED" and pr.get("mergedAt") and pr.get("mergeCommit", {}).get("oid"), "actual matching merge evidence is required")
|
|
1487
1487
|
sha = receipt["publish_sha"]
|
|
1488
|
-
branch_ref = "refs/heads/" + receipt["branch"]
|
|
1489
1488
|
# The recovery ref keeps the accepted commit reachable, so squash and
|
|
1490
1489
|
# rebase merges need only the merge commit on base.
|
|
1491
1490
|
verify_custody(receipt, path)
|
|
1492
1491
|
gref(receipt, "merge-base", "--is-ancestor", pr["mergeCommit"]["oid"] if pr else sha, remote_base(receipt))
|
|
1493
1492
|
gref(receipt, "merge-base", "--is-ancestor", receipt["source_sha"], sha)
|
|
1493
|
+
# The base was fetched through the remote's name, so that name must still bind the receipt's repository.
|
|
1494
|
+
require(remote_url(receipt) == receipt["remote_url"], "remote changed during the delivery checks; retain resources")
|
|
1495
|
+
|
|
1496
|
+
|
|
1497
|
+
def remove_delivered(receipt, path, *, warnings=None):
|
|
1498
|
+
warnings = [] if warnings is None else warnings
|
|
1499
|
+
sha = receipt["publish_sha"]
|
|
1500
|
+
branch_ref = "refs/heads/" + receipt["branch"]
|
|
1494
1501
|
cleanup_workspace(receipt, path, warnings=warnings)
|
|
1495
1502
|
checked_out = [p for p, row in registrations(receipt).items() if row.get("branch") == branch_ref]
|
|
1496
1503
|
require(not checked_out, "task branch is still checked out at " + ", ".join(checked_out) + "; switch it off the task branch before cleanup")
|
|
@@ -1511,6 +1518,11 @@ def cleanup(receipt, path, pr, *, warnings=None):
|
|
|
1511
1518
|
atomic_json(path, receipt)
|
|
1512
1519
|
|
|
1513
1520
|
|
|
1521
|
+
def cleanup(receipt, path, pr, *, warnings=None):
|
|
1522
|
+
verify_delivery(receipt, path, pr)
|
|
1523
|
+
remove_delivered(receipt, path, warnings=warnings)
|
|
1524
|
+
|
|
1525
|
+
|
|
1514
1526
|
def resolve_receipt(path, *, operation_id=None):
|
|
1515
1527
|
"""Resolve a lost retirement reply without guessing between operations."""
|
|
1516
1528
|
require(path.name == 'receipt.json' and path == path.resolve(), 'receipt locator must be nonsymlink and absolute')
|
|
@@ -1630,13 +1642,17 @@ def completion_result(receipt, path, status, *, warnings=None):
|
|
|
1630
1642
|
|
|
1631
1643
|
|
|
1632
1644
|
def cleanup_delivered(receipt, path, pr, *, warnings=None):
|
|
1645
|
+
# Missing evidence leaves delivery unrecorded; once it holds, a refused removal retains what it guards.
|
|
1646
|
+
verify_delivery(receipt, path, pr)
|
|
1633
1647
|
try:
|
|
1634
|
-
|
|
1648
|
+
remove_delivered(receipt, path, warnings=warnings)
|
|
1635
1649
|
except (CompletionError, OSError) as error:
|
|
1636
|
-
|
|
1637
|
-
|
|
1650
|
+
if receipt.get('status') == 'COMPLETE':
|
|
1651
|
+
# Every workspace and ref removal finished and only the final record write failed: the resume ends COMPLETE,
|
|
1652
|
+
# or CLEANUP_PENDING while a scratch cleanup retained on its own remains.
|
|
1638
1653
|
raise
|
|
1639
|
-
|
|
1654
|
+
managed = storage_call('managed_receipt', receipt)
|
|
1655
|
+
reason = manual_removal(receipt, path, error) if managed and isinstance(error, WritersUnobservable) else str(error)
|
|
1640
1656
|
receipt['workspace_cleanup'] = {'status': 'RETAINED', 'reason': reason, 'resume': receipt_resume('complete', path, receipt)}
|
|
1641
1657
|
atomic_json(path, receipt)
|
|
1642
1658
|
|
|
@@ -1681,7 +1697,14 @@ def reconcile(common, allocated, anchor, *, exclude_receipts=(), warnings=None):
|
|
|
1681
1697
|
if storage_call("managed_receipt", receipt):
|
|
1682
1698
|
cleanup_delivered(receipt, path, pr, warnings=warnings)
|
|
1683
1699
|
else:
|
|
1684
|
-
|
|
1700
|
+
try:
|
|
1701
|
+
cleanup(receipt, path, pr, warnings=warnings)
|
|
1702
|
+
except (CompletionError, OSError) as error:
|
|
1703
|
+
if "workspace_cleanup" in receipt:
|
|
1704
|
+
# A retained cleanup names its latest refusal, which may guard less than the first.
|
|
1705
|
+
receipt["workspace_cleanup"]["reason"] = str(error)
|
|
1706
|
+
atomic_json(path, receipt)
|
|
1707
|
+
raise
|
|
1685
1708
|
results.append(completion_result(receipt, path, "COMPLETE", warnings=warnings))
|
|
1686
1709
|
except (Exception, SystemExit) as error:
|
|
1687
1710
|
results.append({"receipt": str(path), "status": "RETAINED", "reason": str(error)})
|
|
@@ -1794,7 +1817,7 @@ def complete(args, *, warnings=None):
|
|
|
1794
1817
|
inspect_workspace(receipt)
|
|
1795
1818
|
verify_files(Path(receipt["worktree"]), receipt["files"])
|
|
1796
1819
|
require(remote_head(receipt, receipt["branch"]) == receipt["publish_sha"], "remote head race before merge")
|
|
1797
|
-
refused = None
|
|
1820
|
+
refused = waits = None
|
|
1798
1821
|
if not pr.get("autoMergeRequest"):
|
|
1799
1822
|
if not info["mergeCommitAllowed"]:
|
|
1800
1823
|
refused = "repository disallows merge commits"
|
|
@@ -1818,9 +1841,11 @@ def complete(args, *, warnings=None):
|
|
|
1818
1841
|
break
|
|
1819
1842
|
if attempt < 9:
|
|
1820
1843
|
time.sleep(2)
|
|
1821
|
-
if state
|
|
1844
|
+
if state in {"BLOCKED", "UNKNOWN"}:
|
|
1845
|
+
waits = state
|
|
1846
|
+
elif state not in {"CLEAN", "HAS_HOOKS", "UNSTABLE"}:
|
|
1822
1847
|
refused = "pull request is not mergeable: " + state
|
|
1823
|
-
if not refused:
|
|
1848
|
+
if not refused and not waits:
|
|
1824
1849
|
try:
|
|
1825
1850
|
gh(receipt, "pr", "merge", str(pr["number"]), *auto, "--merge", "--match-head-commit", receipt["publish_sha"])
|
|
1826
1851
|
except CompletionError as error:
|
|
@@ -1828,8 +1853,10 @@ def complete(args, *, warnings=None):
|
|
|
1828
1853
|
pr = json.loads(gh(receipt, "pr", "view", str(pr["number"]), "--json", PR_FIELDS))
|
|
1829
1854
|
validate_pr(receipt, pr)
|
|
1830
1855
|
if pr["state"] != "MERGED":
|
|
1831
|
-
#
|
|
1832
|
-
|
|
1856
|
+
# Only a refusal leaves the PR for a person; merge_waits: the resume merges once the PR is mergeable.
|
|
1857
|
+
if refused and not pr.get("autoMergeRequest"):
|
|
1858
|
+
return dict(result("PR"), merge_refused=refused)
|
|
1859
|
+
return dict(result("PENDING"), **({"merge_waits": waits} if waits else {}))
|
|
1833
1860
|
if pr:
|
|
1834
1861
|
receipt["merge"] = pr
|
|
1835
1862
|
atomic_json(path, receipt)
|
|
@@ -2089,6 +2116,9 @@ class CompletionTests(unittest.TestCase):
|
|
|
2089
2116
|
self.bin.mkdir()
|
|
2090
2117
|
self.data = self.root / "gh.json"
|
|
2091
2118
|
self.data.write_text(json.dumps({"bare": str(self.bare)}), encoding="utf-8")
|
|
2119
|
+
# A commit's detached automatic maintenance would still write into .git while a test reads the tree. A file, not
|
|
2120
|
+
# GIT_CONFIG_COUNT: the storage suite reuses this fixture with a native helper that refuses that variable.
|
|
2121
|
+
(self.root / "gitconfig").write_text("[maintenance]\n\tauto = false\n", encoding="utf-8")
|
|
2092
2122
|
self.env = {**os.environ, "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": str(self.root / "gitconfig"),
|
|
2093
2123
|
"GIT_AUTHOR_NAME": "Fixture", "GIT_AUTHOR_EMAIL": "fixture@example.invalid",
|
|
2094
2124
|
"GIT_COMMITTER_NAME": "Fixture", "GIT_COMMITTER_EMAIL": "fixture@example.invalid",
|
|
@@ -2144,6 +2174,14 @@ class CompletionTests(unittest.TestCase):
|
|
|
2144
2174
|
repository="test/project", base="main", remote="origin", worktree=str(worktree),
|
|
2145
2175
|
local_base=None, from_receipt=None, start=None)
|
|
2146
2176
|
|
|
2177
|
+
def test_fixture_commit_starts_no_automatic_maintenance(self):
|
|
2178
|
+
# Its detached child would still write into .git while a test reads the tree or cleanup removes it.
|
|
2179
|
+
trace = self.root / "trace.json"
|
|
2180
|
+
self.env["GIT_TRACE2_EVENT"] = str(trace)
|
|
2181
|
+
self.g("commit", "-q", "--allow-empty", "-m", "traced")
|
|
2182
|
+
children = [line for line in trace.read_text(encoding="utf-8").splitlines() if '"event":"child_start"' in line]
|
|
2183
|
+
self.assertEqual(children, [])
|
|
2184
|
+
|
|
2147
2185
|
def test_allocation_marks_exclusively_created_empty_target_before_checkout(self):
|
|
2148
2186
|
# Prediction: persisted intent precedes exclusive creation and marking; real Git then populates that directory.
|
|
2149
2187
|
from unittest.mock import patch
|
|
@@ -3118,7 +3156,7 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
3118
3156
|
error.storage_error = {'code': 'storage_unavailable'}
|
|
3119
3157
|
raise error
|
|
3120
3158
|
args = argparse.Namespace(receipt=str(self.receipt), acceptance=None, mode=None, local_only=False, writers_stopped=True)
|
|
3121
|
-
with patch.dict(os.environ, self.env), patch.dict(complete.__globals__, managed_registration=lambda receipt, **kwargs: {},
|
|
3159
|
+
with patch.dict(os.environ, self.env), patch.dict(complete.__globals__, managed_registration=lambda receipt, **kwargs: {}, remove_delivered=outage):
|
|
3122
3160
|
result = complete(args)
|
|
3123
3161
|
self.assertEqual((result['status'], result['delivery_status']), ('CLEANUP_PENDING', 'COMPLETE'))
|
|
3124
3162
|
self.assertIn('complete --receipt', result['resume'])
|
|
@@ -3133,13 +3171,17 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
3133
3171
|
receipt.update(mode='managed', allocation='managed_owned', delivery='COMPLETE', writers_released=True,
|
|
3134
3172
|
storage={'decision': {'registration': {'fixture': True}}, 'request': {'operation_id': 'saved'}, 'result': {'closure_result': {'established_by': 'never_admitted', 'operation_id': 'saved'}}})
|
|
3135
3173
|
atomic_json(self.receipt, receipt)
|
|
3174
|
+
calls = []
|
|
3136
3175
|
def outage(*args, **kwargs):
|
|
3176
|
+
calls.append(args)
|
|
3137
3177
|
error = CompletionError('authority offline during reconciliation')
|
|
3138
3178
|
error.storage_error = {'code': 'storage_unavailable'}
|
|
3139
3179
|
raise error
|
|
3140
|
-
with patch.dict(os.environ, self.env), patch.dict(reconcile.__globals__, managed_registration=lambda receipt, **kwargs: {},
|
|
3180
|
+
with patch.dict(os.environ, self.env), patch.dict(reconcile.__globals__, managed_registration=lambda receipt, **kwargs: {}, remove_delivered=outage):
|
|
3141
3181
|
[result] = reconcile(Path(receipt['common_gitdir']), self.receipt.with_name('other.json'), self.work)
|
|
3182
|
+
self.assertEqual(len(calls), 1)
|
|
3142
3183
|
self.assertEqual((result['status'], result['delivery_status']), ('CLEANUP_PENDING', 'COMPLETE'))
|
|
3184
|
+
self.assertEqual(result['workspace_cleanup']['reason'], 'authority offline during reconciliation')
|
|
3143
3185
|
self.assertIn('complete --receipt', result['resume'])
|
|
3144
3186
|
self.assertTrue(self.task.exists())
|
|
3145
3187
|
|
|
@@ -3164,6 +3206,42 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
3164
3206
|
d = read_json(self.data)
|
|
3165
3207
|
self.assertEqual((d["pushs"], d.get("creates", 0)), (0, 0))
|
|
3166
3208
|
|
|
3209
|
+
def test_unchanged_failed_final_receipt_write_raises_and_resumes_to_complete(self):
|
|
3210
|
+
# Prediction: every removal finished and only the final status-COMPLETE record write fails, so complete raises
|
|
3211
|
+
# that error and the printed resume ends COMPLETE. Before: retained as CLEANUP_PENDING on a COMPLETE receipt,
|
|
3212
|
+
# which every resume reported again.
|
|
3213
|
+
from unittest.mock import patch
|
|
3214
|
+
self.allocate(); self.accept(unchanged=True)
|
|
3215
|
+
self.cli("accept", "--receipt", self.receipt, "--acceptance", self.acceptance)
|
|
3216
|
+
real, failed = atomic_json, []
|
|
3217
|
+
def final_write_fails(path, value):
|
|
3218
|
+
real(path, value)
|
|
3219
|
+
if value.get("status") == "COMPLETE" and "workspace_cleanup" not in value and not failed:
|
|
3220
|
+
failed.append(path)
|
|
3221
|
+
raise OSError(5, "fixture final receipt write failure")
|
|
3222
|
+
args = argparse.Namespace(receipt=str(self.receipt), acceptance=None, mode=None, local_only=False, writers_stopped=True)
|
|
3223
|
+
with patch.dict(os.environ, self.env), patch.dict(complete.__globals__, atomic_json=final_write_fails):
|
|
3224
|
+
with self.assertRaises(OSError):
|
|
3225
|
+
complete(args)
|
|
3226
|
+
self.assertFalse(self.task.exists())
|
|
3227
|
+
result, _ = self.complete("--writers-stopped", acceptance=False)
|
|
3228
|
+
self.assertEqual((result["status"], result["workspace_cleanup"]), ("COMPLETE", None))
|
|
3229
|
+
|
|
3230
|
+
def test_unchanged_removal_os_error_is_retained_with_delivery_complete(self):
|
|
3231
|
+
# Prediction: an OSError raised while the workspace is removed (the records copy) is retained like a refusal:
|
|
3232
|
+
# delivery COMPLETE, CLEANUP_PENDING naming the error, tree and task ref kept. Fails if only CompletionError is retained.
|
|
3233
|
+
from unittest.mock import patch
|
|
3234
|
+
self.allocate(); self.accept(unchanged=True)
|
|
3235
|
+
args = argparse.Namespace(receipt=str(self.receipt), acceptance=str(self.acceptance), mode=None, local_only=False, writers_stopped=True)
|
|
3236
|
+
with patch.dict(os.environ, self.env), \
|
|
3237
|
+
patch.object(shutil, "copytree", side_effect=OSError(errno.ENOSPC, "fixture records copy failure")):
|
|
3238
|
+
result = complete(args)
|
|
3239
|
+
self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
|
|
3240
|
+
self.assertIn("fixture records copy failure", result["workspace_cleanup"]["reason"])
|
|
3241
|
+
self.assertEqual(read_json(self.receipt)["delivery"], "COMPLETE")
|
|
3242
|
+
self.assertTrue(self.task.exists())
|
|
3243
|
+
self.assertEqual(self.g("rev-parse", "task/fixture"), self.sha)
|
|
3244
|
+
|
|
3167
3245
|
def test_unchanged_writer_retention_reconciles_on_allocate(self):
|
|
3168
3246
|
# Prediction: the next allocation retries on-base delivery once writer observation succeeds.
|
|
3169
3247
|
from unittest.mock import Mock, patch
|
|
@@ -3232,7 +3310,8 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
3232
3310
|
self.assertEqual(self.complete(acceptance=False)[0]["status"], "COMPLETE")
|
|
3233
3311
|
|
|
3234
3312
|
def test_merged_foreign_remote_head_records_merge_and_retains_ref(self):
|
|
3235
|
-
# Prediction: merged PRs record merge evidence and reach cleanup's remote-ref retention guard
|
|
3313
|
+
# Prediction: merged PRs record merge evidence and reach cleanup's remote-ref retention guard, which keeps both
|
|
3314
|
+
# task refs and leaves the merged delivery COMPLETE (CLEANUP_PENDING). Before: BLOCKED, delivery unrecorded.
|
|
3236
3315
|
self.allocate(); self.accept()
|
|
3237
3316
|
self.complete("--mode", "pr")
|
|
3238
3317
|
self.run_cmd(["gh", "pr", "merge", "1", "--merge", "--match-head-commit", self.sha,
|
|
@@ -3240,11 +3319,113 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
3240
3319
|
self.g("commit", "--allow-empty", "-m", "foreign commit")
|
|
3241
3320
|
foreign = self.g("rev-parse", "HEAD")
|
|
3242
3321
|
self.g("push", "origin", "+" + foreign + ":refs/heads/task/fixture")
|
|
3243
|
-
result, _ = self.complete("--writers-stopped"
|
|
3244
|
-
self.assertEqual(result["status"], "
|
|
3245
|
-
self.assertIn("remote branch changed after merge; retain ref", result["reason"])
|
|
3322
|
+
result, _ = self.complete("--writers-stopped")
|
|
3323
|
+
self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
|
|
3324
|
+
self.assertIn("remote branch changed after merge; retain ref", result["workspace_cleanup"]["reason"])
|
|
3246
3325
|
self.assertEqual(read_json(self.receipt)["merge"], read_json(self.data)["prs"][0])
|
|
3247
3326
|
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], foreign)
|
|
3327
|
+
self.assertEqual(self.g("rev-parse", "task/fixture"), self.sha)
|
|
3328
|
+
|
|
3329
|
+
def test_merged_task_branch_checked_out_elsewhere_keeps_both_task_refs(self):
|
|
3330
|
+
# Prediction: with the task branch also checked out in another worktree, the merged delivery is COMPLETE and
|
|
3331
|
+
# reports CLEANUP_PENDING naming that worktree; neither task ref is deleted. Once that worktree leaves the branch,
|
|
3332
|
+
# the printed resume deletes both. Before: BLOCKED, delivery unrecorded.
|
|
3333
|
+
self.allocate(); self.accept()
|
|
3334
|
+
self.complete("--mode", "pr", "--writers-stopped")
|
|
3335
|
+
self.merge_pr()
|
|
3336
|
+
other = self.root / "other"
|
|
3337
|
+
self.g("worktree", "add", "--force", str(other), "task/fixture")
|
|
3338
|
+
result, _ = self.complete()
|
|
3339
|
+
self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
|
|
3340
|
+
self.assertIn("task branch is still checked out at " + str(other), result["workspace_cleanup"]["reason"])
|
|
3341
|
+
self.assertEqual(read_json(self.receipt)["delivery"], "COMPLETE")
|
|
3342
|
+
self.assertEqual(self.g("rev-parse", "task/fixture"), self.sha)
|
|
3343
|
+
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
|
|
3344
|
+
self.g("switch", "--detach", work=other)
|
|
3345
|
+
result, _ = self.cli(*shlex.split(result["resume"])[2:])
|
|
3346
|
+
self.assertEqual((result["status"], result["workspace_cleanup"]), ("COMPLETE", None))
|
|
3347
|
+
self.assertFalse(self.task.exists())
|
|
3348
|
+
self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
3349
|
+
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
|
|
3350
|
+
|
|
3351
|
+
def test_delivery_evidence_failure_blocks_with_delivery_unrecorded(self):
|
|
3352
|
+
# Prediction: evidence is checked before any removal, so a PR view that is not merge evidence, or a changed
|
|
3353
|
+
# custody manifest, stops completion BLOCKED with delivery left at PR and nothing removed. Unchanged on origin/main;
|
|
3354
|
+
# fails if the evidence checks move inside the retention boundary.
|
|
3355
|
+
self.allocate(); self.accept()
|
|
3356
|
+
self.assertEqual(self.complete("--mode", "pr", "--writers-stopped")[0]["status"], "PR")
|
|
3357
|
+
pr = read_json(self.data)["prs"][0]
|
|
3358
|
+
manifest = self.receipt.parent / "manifest.json"
|
|
3359
|
+
for case, reason in (("merge evidence", "actual matching merge evidence is required"),
|
|
3360
|
+
("custody manifest", "custody manifest changed")):
|
|
3361
|
+
with self.subTest(case=case):
|
|
3362
|
+
if case == "merge evidence":
|
|
3363
|
+
self.configure(prs=[dict(pr, state="MERGED", mergedAt=None, mergeCommit=None)])
|
|
3364
|
+
else:
|
|
3365
|
+
self.configure(prs=[pr])
|
|
3366
|
+
self.merge_pr()
|
|
3367
|
+
manifest.write_text("{}\n", encoding="utf-8")
|
|
3368
|
+
result, r = self.complete(success=False)
|
|
3369
|
+
self.assertEqual((r.returncode, result["status"]), (1, "BLOCKED"))
|
|
3370
|
+
self.assertIn(reason, result["reason"])
|
|
3371
|
+
receipt = read_json(self.receipt)
|
|
3372
|
+
self.assertEqual((receipt["delivery"], receipt.get("status")), ("PR", None))
|
|
3373
|
+
self.assertNotIn("workspace_cleanup", receipt)
|
|
3374
|
+
self.assertTrue(self.task.exists())
|
|
3375
|
+
self.assertEqual(self.g("rev-parse", "task/fixture"), self.sha)
|
|
3376
|
+
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
|
|
3377
|
+
|
|
3378
|
+
def test_managed_delivery_evidence_failure_raises_with_delivery_unrecorded(self):
|
|
3379
|
+
# Prediction: a managed receipt's changed custody manifest also raises before any removal, delivery unrecorded.
|
|
3380
|
+
# Before: retained as CLEANUP_PENDING with delivery COMPLETE.
|
|
3381
|
+
from unittest.mock import patch
|
|
3382
|
+
self.allocate(); self.accept(unchanged=True)
|
|
3383
|
+
self.cli('accept', '--receipt', self.receipt, '--acceptance', self.acceptance)
|
|
3384
|
+
receipt = read_json(self.receipt)
|
|
3385
|
+
receipt.update(mode='managed', allocation='managed_owned', storage={'decision': {'registration': {'fixture': True}}, 'request': {'operation_id': 'saved'}, 'result': {
|
|
3386
|
+
'closure_result': {'established_by': 'never_admitted', 'operation_id': 'saved'}}})
|
|
3387
|
+
atomic_json(self.receipt, receipt)
|
|
3388
|
+
(self.receipt.parent / 'manifest.json').write_text('{}\n', encoding='utf-8')
|
|
3389
|
+
args = argparse.Namespace(receipt=str(self.receipt), acceptance=None, mode=None, local_only=False, writers_stopped=True)
|
|
3390
|
+
with patch.dict(os.environ, self.env), patch.dict(complete.__globals__, managed_registration=lambda receipt, **kwargs: {}):
|
|
3391
|
+
with self.assertRaisesRegex(CompletionError, 'custody manifest changed'):
|
|
3392
|
+
complete(args)
|
|
3393
|
+
receipt = read_json(self.receipt)
|
|
3394
|
+
self.assertEqual((receipt.get('delivery'), receipt.get('status')), (None, None))
|
|
3395
|
+
self.assertNotIn('workspace_cleanup', receipt)
|
|
3396
|
+
self.assertTrue(self.task.exists())
|
|
3397
|
+
|
|
3398
|
+
def test_remote_retargeted_during_delivery_checks_blocks_with_delivery_unrecorded(self):
|
|
3399
|
+
# Prediction: origin is retargeted, after the first URL check, to another repository whose base holds the accepted
|
|
3400
|
+
# commit, so both ancestry checks pass against it; the URL recheck after them stops completion BLOCKED with delivery
|
|
3401
|
+
# unrecorded and nothing removed. Before: the tree was removed, the deletion-time URL refusal was retained and
|
|
3402
|
+
# delivery was recorded COMPLETE for the receipt's repository, whose base lacks the commit.
|
|
3403
|
+
from unittest.mock import patch
|
|
3404
|
+
self.allocate(); self.accept()
|
|
3405
|
+
fork = self.root / "fork.git"
|
|
3406
|
+
self.run_cmd(["git", "clone", "--bare", "--quiet", str(self.bare), str(fork)])
|
|
3407
|
+
self.g("push", str(fork), self.sha + ":refs/heads/main", work=self.task)
|
|
3408
|
+
bare = ["git", "--git-dir", str(self.bare)]
|
|
3409
|
+
base = self.run_cmd([*bare, "rev-parse", "refs/heads/main"]).stdout.strip()
|
|
3410
|
+
real, retargeted = repo_policy, []
|
|
3411
|
+
def retarget(receipt, **kwargs):
|
|
3412
|
+
if not retargeted:
|
|
3413
|
+
self.g("remote", "set-url", "origin", str(fork))
|
|
3414
|
+
retargeted.append(str(fork))
|
|
3415
|
+
return real(receipt, **kwargs)
|
|
3416
|
+
args = argparse.Namespace(receipt=str(self.receipt), acceptance=str(self.acceptance), mode=None, local_only=False, writers_stopped=True)
|
|
3417
|
+
with patch.dict(os.environ, self.env), patch.dict(complete.__globals__, repo_policy=retarget):
|
|
3418
|
+
with self.assertRaisesRegex(CompletionError, "remote must unambiguously identify one GitHub repository"):
|
|
3419
|
+
complete(args)
|
|
3420
|
+
self.assertEqual(retargeted, [str(fork)])
|
|
3421
|
+
receipt = read_json(self.receipt)
|
|
3422
|
+
self.assertEqual((receipt.get("delivery"), receipt.get("status")), (None, None))
|
|
3423
|
+
self.assertNotIn("workspace_cleanup", receipt)
|
|
3424
|
+
self.assertTrue(self.task.exists())
|
|
3425
|
+
self.assertEqual(self.g("rev-parse", "task/fixture"), self.sha)
|
|
3426
|
+
self.assertEqual(self.g("rev-parse", receipt["recovery_ref"]), self.sha)
|
|
3427
|
+
self.assertEqual(self.run_cmd([*bare, "rev-parse", "refs/heads/main"]).stdout.strip(), base)
|
|
3428
|
+
self.assertEqual(self.run_cmd([*bare, "for-each-ref", "refs/heads/task/"]).stdout, "")
|
|
3248
3429
|
|
|
3249
3430
|
def test_unchanged_foreign_remote_head_retains_worktree(self):
|
|
3250
3431
|
# Prediction: a foreign remote task head refuses completion before worktree removal.
|
|
@@ -3416,6 +3597,9 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
3416
3597
|
|
|
3417
3598
|
@unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
|
|
3418
3599
|
def test_nested_repository_or_worktree_retains_tree(self):
|
|
3600
|
+
# Prediction: the first call merges; a nested worktree, then a nested repository, under an ignored path leaves the
|
|
3601
|
+
# merged delivery COMPLETE (CLEANUP_PENDING) naming each, with the tree, the nested work and both task refs kept;
|
|
3602
|
+
# once both are gone the printed resume removes everything. Before: BLOCKED, delivery unrecorded.
|
|
3419
3603
|
self.allocate(); self.accept()
|
|
3420
3604
|
_, r = self.cli("allocate", "--repo", self.work, "--task", "inner", "--branch", "task/inner", "--repository", "test/project",
|
|
3421
3605
|
"--base", "main", "--worktree", self.task / "ignored" / "inner", success=False)
|
|
@@ -3425,17 +3609,25 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
3425
3609
|
(nested / "product").write_text("unsaved nested work\n", encoding="utf-8")
|
|
3426
3610
|
clone = self.task / "ignored" / "clone"
|
|
3427
3611
|
self.run_cmd(["git", "init", "--initial-branch=main", str(clone)])
|
|
3428
|
-
for remove in (lambda: self.g("worktree", "remove", "--force", str(nested)),
|
|
3429
|
-
|
|
3430
|
-
self.
|
|
3612
|
+
for name, remove in (("ignored/nested/", lambda: self.g("worktree", "remove", "--force", str(nested))),
|
|
3613
|
+
("ignored/clone/", lambda: shutil.rmtree(clone))):
|
|
3614
|
+
result, _ = self.complete("--mode", "auto", "--writers-stopped")
|
|
3615
|
+
self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
|
|
3616
|
+
self.assertIn("nested repository or worktree inside the task tree: ", result["workspace_cleanup"]["reason"])
|
|
3617
|
+
self.assertIn(name, result["workspace_cleanup"]["reason"])
|
|
3618
|
+
self.assertEqual(read_json(self.receipt)["delivery"], "COMPLETE")
|
|
3431
3619
|
self.assertTrue(self.task.exists())
|
|
3432
|
-
self.
|
|
3620
|
+
self.assertEqual(self.g("rev-parse", "task/fixture"), self.sha)
|
|
3621
|
+
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
|
|
3433
3622
|
if nested.exists():
|
|
3434
3623
|
self.assertEqual((nested / "product").read_text(encoding="utf-8"), "unsaved nested work\n")
|
|
3435
3624
|
remove()
|
|
3436
|
-
result, _ = self.
|
|
3437
|
-
self.assertEqual(result["status"], "COMPLETE")
|
|
3625
|
+
result, _ = self.cli(*shlex.split(result["resume"])[2:])
|
|
3626
|
+
self.assertEqual((result["status"], result["workspace_cleanup"]), ("COMPLETE", None))
|
|
3438
3627
|
self.assertFalse(self.task.exists())
|
|
3628
|
+
self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
3629
|
+
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
|
|
3630
|
+
self.assertEqual(read_json(self.data)["merges"], 1)
|
|
3439
3631
|
|
|
3440
3632
|
@unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
|
|
3441
3633
|
def test_default_mode_merges_when_possible_and_cleans(self):
|
|
@@ -3577,6 +3769,44 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
3577
3769
|
self.assertEqual(self.g("rev-parse", "task/fixture"), self.sha)
|
|
3578
3770
|
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
|
|
3579
3771
|
|
|
3772
|
+
def test_reconcile_checks_delivery_evidence_before_cleanup(self):
|
|
3773
|
+
# Prediction: a merged standalone receipt whose recovery ref moved gives a RETAINED reconcile row naming it, with
|
|
3774
|
+
# the tree and both task refs kept. Fails if reconcile's standalone cleanup skips verify_delivery.
|
|
3775
|
+
self.allocate(); self.accept()
|
|
3776
|
+
self.complete("--mode", "pr", "--writers-stopped")
|
|
3777
|
+
self.merge_pr()
|
|
3778
|
+
receipt, task = self.receipt, self.task
|
|
3779
|
+
saved = read_json(receipt)
|
|
3780
|
+
self.g("update-ref", saved["recovery_ref"], saved["baseline"])
|
|
3781
|
+
result = self.allocate("next")
|
|
3782
|
+
self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "RETAINED")])
|
|
3783
|
+
self.assertIn("recovery reachability changed", result["reconciled"][0]["reason"])
|
|
3784
|
+
self.assertTrue(task.exists())
|
|
3785
|
+
self.assertEqual(self.g("rev-parse", "task/fixture"), self.sha)
|
|
3786
|
+
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
|
|
3787
|
+
|
|
3788
|
+
def test_reconcile_replaces_a_retained_reason_with_its_latest_refusal(self):
|
|
3789
|
+
# Prediction: complete retains a merged delivery for a nested repository; once that is gone and the remote task
|
|
3790
|
+
# branch has moved, the next allocation's reconcile removes the tree, keeps the task ref and replaces the receipt's
|
|
3791
|
+
# reason with the remote-branch refusal. Before: the receipt still named the nested repository.
|
|
3792
|
+
self.allocate(); self.accept()
|
|
3793
|
+
clone = self.task / "ignored" / "clone"
|
|
3794
|
+
self.run_cmd(["git", "init", "--initial-branch=main", str(clone)])
|
|
3795
|
+
result, _ = self.complete("--mode", "auto", "--writers-stopped")
|
|
3796
|
+
self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
|
|
3797
|
+
self.assertIn("ignored/clone/", result["workspace_cleanup"]["reason"])
|
|
3798
|
+
shutil.rmtree(clone)
|
|
3799
|
+
self.g("commit", "--allow-empty", "-m", "foreign commit")
|
|
3800
|
+
self.g("push", "origin", "+" + self.g("rev-parse", "HEAD") + ":refs/heads/task/fixture")
|
|
3801
|
+
receipt, task = self.receipt, self.task
|
|
3802
|
+
[row] = self.allocate("next")["reconciled"]
|
|
3803
|
+
self.assertEqual((row["receipt"], row["status"]), (str(receipt), "RETAINED"))
|
|
3804
|
+
self.assertIn("remote branch changed after merge; retain ref", row["reason"])
|
|
3805
|
+
self.assertFalse(task.exists())
|
|
3806
|
+
saved = read_json(receipt)["workspace_cleanup"]
|
|
3807
|
+
self.assertEqual((saved["status"], saved["reason"]), ("RETAINED", row["reason"]))
|
|
3808
|
+
self.assertEqual(self.g("rev-parse", "task/fixture"), self.sha)
|
|
3809
|
+
|
|
3580
3810
|
def test_reconcile_skips_open_and_unaccepted_and_retains_in_use_tree(self):
|
|
3581
3811
|
self.allocate(); self.accept()
|
|
3582
3812
|
self.complete("--mode", "pr", "--writers-stopped")
|
|
@@ -3741,16 +3971,17 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
3741
3971
|
other.write_bytes(b"owner data")
|
|
3742
3972
|
link = self.task / ".devlyn/link"
|
|
3743
3973
|
link.symlink_to(other)
|
|
3744
|
-
result, _ = self.complete(
|
|
3745
|
-
self.
|
|
3974
|
+
result, _ = self.complete()
|
|
3975
|
+
self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
|
|
3976
|
+
self.assertIn("path escapes custody", result["workspace_cleanup"]["reason"])
|
|
3746
3977
|
self.assertTrue(self.task.exists())
|
|
3747
3978
|
self.assertTrue(link.is_symlink())
|
|
3748
3979
|
self.assertEqual(other.read_bytes(), b"owner data")
|
|
3749
3980
|
link.unlink()
|
|
3750
3981
|
if hasattr(os, "mkfifo"):
|
|
3751
3982
|
os.mkfifo(link)
|
|
3752
|
-
result, _ = self.complete(
|
|
3753
|
-
self.assertIn("regular file", result["reason"])
|
|
3983
|
+
result, _ = self.complete()
|
|
3984
|
+
self.assertIn("regular file", result["workspace_cleanup"]["reason"])
|
|
3754
3985
|
self.assertTrue(self.task.exists())
|
|
3755
3986
|
link.unlink()
|
|
3756
3987
|
saved = self.receipt.parent / "records/.devlyn"
|
|
@@ -3817,8 +4048,9 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
3817
4048
|
extra = generation / ".devlyn/unexpected"
|
|
3818
4049
|
extra.write_bytes(b"keep this copy\n")
|
|
3819
4050
|
mismatch = records_manifest(generation)
|
|
3820
|
-
result, _ = self.complete(
|
|
3821
|
-
self.
|
|
4051
|
+
result, _ = self.complete()
|
|
4052
|
+
self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
|
|
4053
|
+
self.assertIn(generation.name, result["workspace_cleanup"]["reason"])
|
|
3822
4054
|
self.assertTrue(self.task.exists())
|
|
3823
4055
|
self.assertEqual(records_manifest(generation), mismatch)
|
|
3824
4056
|
self.assertEqual(records_manifest(saved), original)
|
|
@@ -3845,8 +4077,9 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
3845
4077
|
(work / ".devlyn/storage-cleanup.json").write_bytes(b"{}\n")
|
|
3846
4078
|
args = argparse.Namespace(receipt=str(self.receipt), acceptance=None, mode=None, local_only=False, writers_stopped=False)
|
|
3847
4079
|
with patch.dict(os.environ, self.env), patch.dict(cleanup.__globals__, {"removable": changed}):
|
|
3848
|
-
|
|
3849
|
-
|
|
4080
|
+
result = complete(args)
|
|
4081
|
+
self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
|
|
4082
|
+
self.assertIn("changed after it was copied; stop its writer and resume", result["workspace_cleanup"]["reason"])
|
|
3850
4083
|
self.assertTrue(self.task.exists())
|
|
3851
4084
|
self.assertEqual(records_manifest(saved), original)
|
|
3852
4085
|
final = records_manifest(self.task)
|
|
@@ -3868,8 +4101,9 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
3868
4101
|
return result
|
|
3869
4102
|
args = argparse.Namespace(receipt=str(self.receipt), acceptance=None, mode=None, local_only=False, writers_stopped=False)
|
|
3870
4103
|
with patch.dict(os.environ, self.env), patch.object(shutil, "copytree", changed):
|
|
3871
|
-
|
|
3872
|
-
|
|
4104
|
+
result = complete(args)
|
|
4105
|
+
self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
|
|
4106
|
+
self.assertIn("changed while it was copied; stop its writer and resume", result["workspace_cleanup"]["reason"])
|
|
3873
4107
|
self.assertTrue(self.task.exists())
|
|
3874
4108
|
self.assertFalse((self.receipt.parent / "records").exists())
|
|
3875
4109
|
self.assertEqual(list(self.receipt.parent.glob(".records-*")), [])
|
|
@@ -3877,8 +4111,8 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
3877
4111
|
def test_linked_custody_and_interrupted_removal(self):
|
|
3878
4112
|
self.allocate(); self.accept()
|
|
3879
4113
|
self.configure(interrupt_remove=True)
|
|
3880
|
-
result,
|
|
3881
|
-
self.
|
|
4114
|
+
result, _ = self.complete("--writers-stopped", "--mode", "auto")
|
|
4115
|
+
self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
|
|
3882
4116
|
self.assertFalse(self.task.exists())
|
|
3883
4117
|
saved = json.loads(self.receipt.read_text(encoding="utf-8"))
|
|
3884
4118
|
self.assertNotIn("cleanup_started", saved)
|
|
@@ -3923,15 +4157,24 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
3923
4157
|
self.assertEqual((d["pushs"], d["creates"], d["merges"]), (1, 1, 1))
|
|
3924
4158
|
|
|
3925
4159
|
def test_retains_dirty_locked_and_active_tree(self):
|
|
4160
|
+
# Prediction: the first call merges; each refused removal keeps the tree and task ref and reports CLEANUP_PENDING
|
|
4161
|
+
# with delivery COMPLETE, while a caller inside the tree is refused (BLOCKED) before anything. Before: all BLOCKED.
|
|
3926
4162
|
self.allocate(); self.accept()
|
|
4163
|
+
reasons = {"no-yield": "resume with --writers-stopped", "dirty": "dirty/untracked workspace",
|
|
4164
|
+
"untracked": "dirty/untracked workspace", "locked": "detached or locked"}
|
|
3927
4165
|
for case in ("no-yield", "dirty", "untracked", "locked", "cwd"):
|
|
3928
4166
|
path = self.task / ("product" if case == "dirty" else "unknown")
|
|
3929
4167
|
if case in {"dirty", "untracked"}:
|
|
3930
4168
|
path.parent.mkdir(exist_ok=True); path.write_text("do not delete", encoding="utf-8")
|
|
3931
4169
|
if case == "locked": self.g("worktree", "lock", str(self.task))
|
|
3932
4170
|
with self.subTest(case=case):
|
|
3933
|
-
|
|
3934
|
-
|
|
4171
|
+
result, r = self.complete("--mode", "auto", *([] if case == "no-yield" else ["--writers-stopped"]), cwd=self.task if case == "cwd" else None, success=False)
|
|
4172
|
+
if case == "cwd":
|
|
4173
|
+
self.assertEqual((r.returncode, result["status"]), (1, "BLOCKED"))
|
|
4174
|
+
self.assertIn("caller cwd is inside", result["reason"])
|
|
4175
|
+
else:
|
|
4176
|
+
self.assertEqual((r.returncode, result["status"], result["delivery_status"]), (0, "CLEANUP_PENDING", "COMPLETE"))
|
|
4177
|
+
self.assertIn(reasons[case], result["workspace_cleanup"]["reason"])
|
|
3935
4178
|
self.assertTrue(self.task.exists())
|
|
3936
4179
|
self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
3937
4180
|
if case == "untracked": path.unlink()
|
|
@@ -4004,10 +4247,11 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
4004
4247
|
self.assertTrue(self.task.exists())
|
|
4005
4248
|
finally:
|
|
4006
4249
|
child.terminate(); child.wait(timeout=5)
|
|
4007
|
-
# A foreign branch in the same registered tree never inherits ownership.
|
|
4250
|
+
# A foreign branch in the same registered tree never inherits ownership; the merged delivery stays COMPLETE.
|
|
4008
4251
|
self.g("switch", "-c", "foreign", work=self.task)
|
|
4009
|
-
|
|
4010
|
-
self.
|
|
4252
|
+
result, _ = self.complete("--writers-stopped")
|
|
4253
|
+
self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
|
|
4254
|
+
self.assertIn("foreign worktree branch", result["workspace_cleanup"]["reason"])
|
|
4011
4255
|
self.assertTrue(self.task.exists())
|
|
4012
4256
|
self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
4013
4257
|
|
|
@@ -4015,8 +4259,8 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
4015
4259
|
self.allocate(); self.accept()
|
|
4016
4260
|
race = self.g("rev-parse", "main")
|
|
4017
4261
|
self.configure(remote_delete_race=True, race_sha=race)
|
|
4018
|
-
|
|
4019
|
-
self.
|
|
4262
|
+
result, _ = self.complete("--writers-stopped", "--mode", "auto")
|
|
4263
|
+
self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
|
|
4020
4264
|
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], race)
|
|
4021
4265
|
self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
4022
4266
|
|
|
@@ -4032,16 +4276,37 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
4032
4276
|
self.assertEqual(d["prs"][0]["state"], "MERGED")
|
|
4033
4277
|
self.assertIsNone(d["prs"][0]["autoMergeRequest"])
|
|
4034
4278
|
|
|
4035
|
-
def
|
|
4279
|
+
def test_auto_disallowed_blocked_waits_and_its_resume_merges(self):
|
|
4280
|
+
# Prediction (B3): with auto-merge off, required checks or reviews not passing yet (BLOCKED) make no merge call
|
|
4281
|
+
# and report PENDING with merge_waits; once the PR is CLEAN the printed resume merges it directly and cleans.
|
|
4282
|
+
# Before: PR with merge_refused "not mergeable: BLOCKED", a refusal left for a person.
|
|
4283
|
+
self.allocate(); self.accept()
|
|
4284
|
+
self.configure(auto_allowed=False, merge_state="BLOCKED")
|
|
4285
|
+
result, _ = self.complete("--mode", "auto", "--writers-stopped")
|
|
4286
|
+
self.assertEqual((result["status"], result.get("merge_waits")), ("PENDING", "BLOCKED"))
|
|
4287
|
+
self.assertNotIn("merge_refused", result)
|
|
4288
|
+
d = json.loads(self.data.read_text(encoding="utf-8"))
|
|
4289
|
+
self.assertEqual((d.get("merge_calls", []), d["prs"][0]["state"]), ([], "OPEN"))
|
|
4290
|
+
self.assertEqual(read_json(self.receipt)["delivery"], "PENDING")
|
|
4291
|
+
self.configure(merge_state="CLEAN")
|
|
4292
|
+
result, _ = self.cli(*shlex.split(result["resume"])[2:])
|
|
4293
|
+
self.assertEqual(result["status"], "COMPLETE")
|
|
4294
|
+
d = json.loads(self.data.read_text(encoding="utf-8"))
|
|
4295
|
+
self.assertEqual(d["merge_calls"], [["pr", "merge", "1", "--merge", "--match-head-commit", self.sha,
|
|
4296
|
+
"--repo", "github.com/test/project"]])
|
|
4297
|
+
self.assertEqual(d["prs"][0]["state"], "MERGED")
|
|
4298
|
+
self.assertFalse(self.task.exists())
|
|
4299
|
+
|
|
4300
|
+
def test_auto_disallowed_unmergeable_states_refuse_without_merge(self):
|
|
4036
4301
|
self.allocate(); self.accept()
|
|
4037
|
-
for state in ("
|
|
4302
|
+
for state in ("BEHIND", "DIRTY", "DRAFT", "NEW_STATE"):
|
|
4038
4303
|
with self.subTest(state=state):
|
|
4039
4304
|
self.configure(auto_allowed=False, merge_state=state)
|
|
4040
4305
|
result, _ = self.complete("--mode", "auto")
|
|
4041
4306
|
self.assertEqual(result["status"], "PR")
|
|
4042
4307
|
self.assertIn("not mergeable: " + state, result["merge_refused"])
|
|
4043
4308
|
d = json.loads(self.data.read_text(encoding="utf-8"))
|
|
4044
|
-
self.assertEqual(d["merge_state_views"], ["
|
|
4309
|
+
self.assertEqual(d["merge_state_views"], ["BEHIND", "DIRTY", "DRAFT", "NEW_STATE"])
|
|
4045
4310
|
self.assertEqual(d.get("merge_calls", []), [])
|
|
4046
4311
|
self.assertEqual(d["prs"][0]["state"], "OPEN")
|
|
4047
4312
|
self.assertIsNone(d["prs"][0]["autoMergeRequest"])
|
|
@@ -4065,12 +4330,12 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
4065
4330
|
self.assertEqual(result["status"], "PENDING")
|
|
4066
4331
|
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8"))["auto_setting_reads"], 1)
|
|
4067
4332
|
|
|
4068
|
-
def
|
|
4333
|
+
def test_auto_disallowed_unknown_bound_waits_without_merge(self):
|
|
4069
4334
|
self.allocate(); self.accept()
|
|
4070
4335
|
self.configure(auto_allowed=False, merge_state="UNKNOWN")
|
|
4071
4336
|
result, _ = self.complete("--mode", "auto")
|
|
4072
|
-
self.assertEqual(result["status"], "
|
|
4073
|
-
self.
|
|
4337
|
+
self.assertEqual((result["status"], result.get("merge_waits")), ("PENDING", "UNKNOWN"))
|
|
4338
|
+
self.assertNotIn("merge_refused", result)
|
|
4074
4339
|
d = json.loads(self.data.read_text(encoding="utf-8"))
|
|
4075
4340
|
self.assertEqual(d["merge_state_views"], ["UNKNOWN"] * 10)
|
|
4076
4341
|
self.assertEqual(d.get("merge_calls", []), [])
|
|
@@ -4154,8 +4419,8 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
4154
4419
|
self.assertNotIn("merge_refused", result)
|
|
4155
4420
|
self.configure(merge_allowed=True, auto_allowed=False, pending=True)
|
|
4156
4421
|
result, _ = self.complete("--mode", "auto")
|
|
4157
|
-
self.assertEqual(result["status"], "
|
|
4158
|
-
self.
|
|
4422
|
+
self.assertEqual((result["status"], result.get("merge_waits")), ("PENDING", "BLOCKED"))
|
|
4423
|
+
self.assertNotIn("merge_refused", result)
|
|
4159
4424
|
self.assertTrue(self.task.exists())
|
|
4160
4425
|
# Local-only cannot rewrite the delivery of a pushed PR; a local allocation keeps it (test_local_chain_*).
|
|
4161
4426
|
result, _ = self.complete("--no-push", success=False)
|
|
@@ -4214,8 +4479,8 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
4214
4479
|
user_file.write_text("retained user data", encoding="utf-8")
|
|
4215
4480
|
before = self.anchor_state()
|
|
4216
4481
|
self.configure(interrupt_delete=True)
|
|
4217
|
-
|
|
4218
|
-
self.
|
|
4482
|
+
result, _ = self.complete(acceptance=False)
|
|
4483
|
+
self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
|
|
4219
4484
|
self.assertEqual(self.anchor_state(), before)
|
|
4220
4485
|
other = self.root / "advance"
|
|
4221
4486
|
self.run_cmd(["git", "clone", str(self.bare), str(other)])
|