devlyn-cli 4.2.21 → 4.2.22
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/bin/skill-history.json
CHANGED
|
@@ -100,6 +100,7 @@
|
|
|
100
100
|
"978e4210e49b42827c98cf6e40c868c87dc6e3533a2b779ca2fa05abfc16ad0f",
|
|
101
101
|
"9815b6e6963e068872a6dbeae24068143bf92042bb035f67bafd1a224d65bcb0",
|
|
102
102
|
"998343c148618b937556b58b83749c05d5aa3283280d751c118da6a542fb87f2",
|
|
103
|
+
"9dd3ec225818efe0ad3726192db94ff129154e995617a9d3c150fc58ad0f9f2c",
|
|
103
104
|
"9f14ca629e41730b1a9fb5d0ad1a39cff558fe34758d6ceccbb9de498f9f384e",
|
|
104
105
|
"a11950a5880346f03257c688fd8bc6f8c29fc0b7c05c90e8f5211d5fb7665774",
|
|
105
106
|
"a405a99b988f6edd2eabb0504b36309bb4cf0c3b97eab26bb7ef257e4515dbf3",
|
|
@@ -21,6 +21,7 @@ import stat
|
|
|
21
21
|
import subprocess
|
|
22
22
|
import sys
|
|
23
23
|
import tempfile
|
|
24
|
+
import time
|
|
24
25
|
import unittest
|
|
25
26
|
import uuid
|
|
26
27
|
|
|
@@ -789,11 +790,35 @@ def complete(args):
|
|
|
789
790
|
require(remote_head(receipt, receipt["branch"]) == receipt["publish_sha"], "remote head race before merge")
|
|
790
791
|
refused = None
|
|
791
792
|
if not pr.get("autoMergeRequest"):
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
793
|
+
if not info["mergeCommitAllowed"]:
|
|
794
|
+
refused = "repository disallows merge commits"
|
|
795
|
+
else:
|
|
796
|
+
owner, name = receipt["repository"].split("/")
|
|
797
|
+
auto_allowed = json.loads(command([
|
|
798
|
+
"gh", "api", "graphql", "--hostname", "github.com", "-f",
|
|
799
|
+
"query=query($owner:String!,$name:String!){repository(owner:$owner,name:$name){autoMergeAllowed}}",
|
|
800
|
+
"-f", "owner="+owner, "-f", "name="+name,
|
|
801
|
+
"--jq", ".data.repository.autoMergeAllowed | tojson",
|
|
802
|
+
]))
|
|
803
|
+
require(isinstance(auto_allowed, bool), "repository autoMergeAllowed must be a JSON boolean")
|
|
804
|
+
auto = ["--auto"] if auto_allowed else []
|
|
805
|
+
if not auto:
|
|
806
|
+
# UNKNOWN can reach gh's direct merge mutation; wait for a
|
|
807
|
+
# resolved state and permit only gh's immediate-merge set.
|
|
808
|
+
for attempt in range(10):
|
|
809
|
+
state = json.loads(gh(receipt, "pr", "view", str(pr["number"]), "--json", "mergeStateStatus"))["mergeStateStatus"]
|
|
810
|
+
require(isinstance(state, str) and state, "invalid mergeStateStatus")
|
|
811
|
+
if state != "UNKNOWN":
|
|
812
|
+
break
|
|
813
|
+
if attempt < 9:
|
|
814
|
+
time.sleep(2)
|
|
815
|
+
if state not in {"CLEAN", "HAS_HOOKS", "UNSTABLE"}:
|
|
816
|
+
refused = "pull request is not mergeable: " + state
|
|
817
|
+
if not refused:
|
|
818
|
+
try:
|
|
819
|
+
gh(receipt, "pr", "merge", str(pr["number"]), *auto, "--merge", "--match-head-commit", receipt["publish_sha"])
|
|
820
|
+
except CompletionError as error:
|
|
821
|
+
refused = str(error)
|
|
797
822
|
pr = json.loads(gh(receipt, "pr", "view", str(pr["number"]), "--json", PR_FIELDS))
|
|
798
823
|
validate_pr(receipt, pr)
|
|
799
824
|
if pr["state"] != "MERGED":
|
|
@@ -885,6 +910,16 @@ def remote(ref):
|
|
|
885
910
|
if a[:2] == ['repo', 'view']:
|
|
886
911
|
assert a[2:] == ['github.com/test/project', '--json', 'nameWithOwner,url,defaultBranchRef,mergeCommitAllowed'], 'unsupported gh repo view arguments: '+str(a)
|
|
887
912
|
print(json.dumps({'nameWithOwner':'test/project', 'url':'https://github.com/test/project', 'defaultBranchRef':{'name':d.get('default_branch','main')}, 'mergeCommitAllowed':d.get('merge_allowed',True)}))
|
|
913
|
+
elif a[:1] == ['api']:
|
|
914
|
+
assert a == ['api', 'graphql', '--hostname', 'github.com', '-f',
|
|
915
|
+
'query=query($owner:String!,$name:String!){repository(owner:$owner,name:$name){autoMergeAllowed}}',
|
|
916
|
+
'-f', 'owner=test', '-f', 'name=project',
|
|
917
|
+
'--jq', '.data.repository.autoMergeAllowed | tojson'], 'unsupported gh api arguments: '+str(a)
|
|
918
|
+
d['auto_setting_reads'] = d.get('auto_setting_reads',0)+1
|
|
919
|
+
save()
|
|
920
|
+
if d.get('auto_setting_error'):
|
|
921
|
+
print('auto-merge setting read failed',file=sys.stderr); sys.exit(1)
|
|
922
|
+
print(d.get('auto_setting_output', json.dumps(d.get('auto_allowed',True))))
|
|
888
923
|
elif a[:2] == ['pr', 'list']:
|
|
889
924
|
assert '--repo' in a and a[a.index('--repo')+1] == 'github.com/test/project'
|
|
890
925
|
print(json.dumps([pr for pr in prs if pr['headRefName'] == a[a.index('--head')+1]]))
|
|
@@ -898,21 +933,42 @@ elif a[:2] == ['pr', 'create']:
|
|
|
898
933
|
print(url)
|
|
899
934
|
if d.pop('interrupt_create',False): save(); sys.exit(1)
|
|
900
935
|
elif a[:2] == ['pr', 'view']:
|
|
901
|
-
|
|
936
|
+
pr = prs[int(a[2])-1]
|
|
937
|
+
fields = a[4]
|
|
938
|
+
assert a[3:] == ['--json', fields, '--repo', 'github.com/test/project']
|
|
939
|
+
assert fields in ('mergeStateStatus', 'number,url,headRefName,baseRefName,headRefOid,headRepository,headRepositoryOwner,isCrossRepository,state,mergedAt,mergeCommit,autoMergeRequest')
|
|
940
|
+
if fields == 'mergeStateStatus':
|
|
941
|
+
if d.get('merge_state_error'):
|
|
942
|
+
print('merge state read failed',file=sys.stderr); sys.exit(1)
|
|
943
|
+
if d.get('merge_states'):
|
|
944
|
+
d['merge_state'] = d['merge_states'].pop(0)
|
|
945
|
+
state = d.get('merge_state', 'BLOCKED' if d.get('pending') else 'CLEAN')
|
|
946
|
+
d.setdefault('merge_state_views', []).append(state)
|
|
947
|
+
save()
|
|
948
|
+
print(json.dumps({'mergeStateStatus': state}))
|
|
949
|
+
else:
|
|
950
|
+
print(json.dumps(pr))
|
|
902
951
|
elif a[:2] == ['pr', 'merge'] and '--disable-auto' in a:
|
|
903
952
|
d['disables'] = d.get('disables',0)+1
|
|
904
953
|
prs[int(a[2])-1]['autoMergeRequest'] = None
|
|
905
954
|
save()
|
|
906
955
|
elif a[:2] == ['pr', 'merge']:
|
|
907
|
-
assert '--admin' not in a and '--delete-branch' not in a
|
|
908
|
-
assert '--auto' in a and '--merge' in a and '--match-head-commit' in a
|
|
909
956
|
pr = prs[int(a[2])-1]
|
|
910
957
|
sha = a[a.index('--match-head-commit')+1]
|
|
958
|
+
auto = ['--auto'] if '--auto' in a else []
|
|
959
|
+
assert a[3:] == auto + ['--merge', '--match-head-commit', sha, '--repo', 'github.com/test/project']
|
|
911
960
|
assert sha == remote('refs/heads/'+pr['headRefName'])
|
|
912
961
|
d['merges'] = d.get('merges',0)+1
|
|
913
|
-
|
|
962
|
+
d.setdefault('merge_calls', []).append(a)
|
|
963
|
+
state = d.get('merge_state', 'BLOCKED' if d.get('pending') else 'CLEAN')
|
|
964
|
+
immediately_mergeable = state in {'CLEAN', 'HAS_HOOKS', 'UNSTABLE'}
|
|
965
|
+
if auto and not immediately_mergeable and not d.get('auto_allowed',True):
|
|
914
966
|
save(); print('repository disallows auto merge',file=sys.stderr); sys.exit(1)
|
|
915
|
-
if d.get('
|
|
967
|
+
if d.get('merge_error'):
|
|
968
|
+
save(); print('merge attempt refused',file=sys.stderr); sys.exit(1)
|
|
969
|
+
if not auto and not immediately_mergeable and state != 'UNKNOWN':
|
|
970
|
+
save(); print('pull request is not mergeable: '+state,file=sys.stderr); sys.exit(1)
|
|
971
|
+
if auto and not immediately_mergeable:
|
|
916
972
|
pr['autoMergeRequest'] = {'enabledAt':'now'}
|
|
917
973
|
else:
|
|
918
974
|
g = [os.environ['REAL_GIT'],'--git-dir',d['bare']]
|
|
@@ -1042,7 +1098,7 @@ class CompletionTests(unittest.TestCase):
|
|
|
1042
1098
|
|
|
1043
1099
|
def merge_pr(self):
|
|
1044
1100
|
self.configure(pending=False)
|
|
1045
|
-
self.run_cmd(["gh", "pr", "merge", "1", "--auto", "--merge", "--match-head-commit", self.sha])
|
|
1101
|
+
self.run_cmd(["gh", "pr", "merge", "1", "--auto", "--merge", "--match-head-commit", self.sha, "--repo", "github.com/test/project"])
|
|
1046
1102
|
|
|
1047
1103
|
def anchor_state(self, work=None):
|
|
1048
1104
|
work = work or self.work
|
|
@@ -2119,6 +2175,127 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
2119
2175
|
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], race)
|
|
2120
2176
|
self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
2121
2177
|
|
|
2178
|
+
def test_auto_disallowed_unknown_then_clean_merges_directly(self):
|
|
2179
|
+
self.allocate(); self.accept()
|
|
2180
|
+
self.configure(auto_allowed=False, merge_states=["UNKNOWN", "UNKNOWN", "CLEAN"])
|
|
2181
|
+
result, _ = self.complete("--mode", "auto", "--writers-stopped")
|
|
2182
|
+
self.assertEqual(result["status"], "COMPLETE")
|
|
2183
|
+
d = json.loads(self.data.read_text(encoding="utf-8"))
|
|
2184
|
+
self.assertEqual(d["merge_state_views"], ["UNKNOWN", "UNKNOWN", "CLEAN"])
|
|
2185
|
+
self.assertEqual(d["merge_calls"], [["pr", "merge", "1", "--merge", "--match-head-commit", self.sha,
|
|
2186
|
+
"--repo", "github.com/test/project"]])
|
|
2187
|
+
self.assertEqual(d["prs"][0]["state"], "MERGED")
|
|
2188
|
+
self.assertIsNone(d["prs"][0]["autoMergeRequest"])
|
|
2189
|
+
|
|
2190
|
+
def test_auto_disallowed_blocked_refuses_without_merge(self):
|
|
2191
|
+
self.allocate(); self.accept()
|
|
2192
|
+
for state in ("BLOCKED", "BEHIND", "DIRTY", "DRAFT", "NEW_STATE"):
|
|
2193
|
+
with self.subTest(state=state):
|
|
2194
|
+
self.configure(auto_allowed=False, merge_state=state)
|
|
2195
|
+
result, _ = self.complete("--mode", "auto")
|
|
2196
|
+
self.assertEqual(result["status"], "PR")
|
|
2197
|
+
self.assertIn("not mergeable: " + state, result["merge_refused"])
|
|
2198
|
+
d = json.loads(self.data.read_text(encoding="utf-8"))
|
|
2199
|
+
self.assertEqual(d["merge_state_views"], ["BLOCKED", "BEHIND", "DIRTY", "DRAFT", "NEW_STATE"])
|
|
2200
|
+
self.assertEqual(d.get("merge_calls", []), [])
|
|
2201
|
+
self.assertEqual(d["prs"][0]["state"], "OPEN")
|
|
2202
|
+
self.assertIsNone(d["prs"][0]["autoMergeRequest"])
|
|
2203
|
+
self.assertTrue(self.task.exists())
|
|
2204
|
+
|
|
2205
|
+
def test_auto_allowed_unknown_keeps_auto_pending(self):
|
|
2206
|
+
self.allocate(); self.accept()
|
|
2207
|
+
self.configure(auto_allowed=True, merge_state="UNKNOWN")
|
|
2208
|
+
result, _ = self.complete("--mode", "auto")
|
|
2209
|
+
self.assertEqual(result["status"], "PENDING")
|
|
2210
|
+
self.assertNotIn("merge_refused", result)
|
|
2211
|
+
d = json.loads(self.data.read_text(encoding="utf-8"))
|
|
2212
|
+
self.assertEqual(d.get("merge_state_views", []), [])
|
|
2213
|
+
self.assertEqual(d["merge_calls"], [["pr", "merge", "1", "--auto", "--merge", "--match-head-commit", self.sha,
|
|
2214
|
+
"--repo", "github.com/test/project"]])
|
|
2215
|
+
self.assertEqual(d["prs"][0]["state"], "OPEN")
|
|
2216
|
+
self.assertTrue(d["prs"][0]["autoMergeRequest"])
|
|
2217
|
+
self.assertTrue(self.task.exists())
|
|
2218
|
+
self.configure(auto_setting_error=True)
|
|
2219
|
+
result, _ = self.complete()
|
|
2220
|
+
self.assertEqual(result["status"], "PENDING")
|
|
2221
|
+
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8"))["auto_setting_reads"], 1)
|
|
2222
|
+
|
|
2223
|
+
def test_auto_disallowed_unknown_bound_refuses_without_merge(self):
|
|
2224
|
+
self.allocate(); self.accept()
|
|
2225
|
+
self.configure(auto_allowed=False, merge_state="UNKNOWN")
|
|
2226
|
+
result, _ = self.complete("--mode", "auto")
|
|
2227
|
+
self.assertEqual(result["status"], "PR")
|
|
2228
|
+
self.assertIn("not mergeable: UNKNOWN", result["merge_refused"])
|
|
2229
|
+
d = json.loads(self.data.read_text(encoding="utf-8"))
|
|
2230
|
+
self.assertEqual(d["merge_state_views"], ["UNKNOWN"] * 10)
|
|
2231
|
+
self.assertEqual(d.get("merge_calls", []), [])
|
|
2232
|
+
self.assertEqual(d["prs"][0]["state"], "OPEN")
|
|
2233
|
+
self.assertIsNone(d["prs"][0]["autoMergeRequest"])
|
|
2234
|
+
self.assertTrue(self.task.exists())
|
|
2235
|
+
|
|
2236
|
+
def test_auto_disallowed_other_immediate_states_attempt_direct_merge(self):
|
|
2237
|
+
self.allocate(); self.accept()
|
|
2238
|
+
for state in ("HAS_HOOKS", "UNSTABLE"):
|
|
2239
|
+
with self.subTest(state=state):
|
|
2240
|
+
self.configure(auto_allowed=False, merge_state=state, merge_error=True, merge_calls=[])
|
|
2241
|
+
result, _ = self.complete("--mode", "auto")
|
|
2242
|
+
self.assertEqual(result["status"], "PR")
|
|
2243
|
+
self.assertIn("merge attempt refused", result["merge_refused"])
|
|
2244
|
+
d = json.loads(self.data.read_text(encoding="utf-8"))
|
|
2245
|
+
self.assertEqual(d["merge_calls"], [["pr", "merge", "1", "--merge",
|
|
2246
|
+
"--match-head-commit", self.sha, "--repo", "github.com/test/project"]])
|
|
2247
|
+
|
|
2248
|
+
def test_auto_setting_read_failure_or_invalid_value_blocks_with_resume(self):
|
|
2249
|
+
self.allocate(); self.accept()
|
|
2250
|
+
for values in ({"auto_setting_error": True}, *({"auto_setting_error": False, "auto_setting_output": output}
|
|
2251
|
+
for output in ('null', '"true"', '"false"', '0', '1', '{}', '[]', '', 'invalid'))):
|
|
2252
|
+
with self.subTest(values=values):
|
|
2253
|
+
self.configure(**values)
|
|
2254
|
+
result, process = self.complete("--mode", "auto", "--writers-stopped", success=False)
|
|
2255
|
+
self.assertNotEqual(process.returncode, 0)
|
|
2256
|
+
self.assertEqual(result["status"], "BLOCKED")
|
|
2257
|
+
if values.get("auto_setting_output") == 'null':
|
|
2258
|
+
self.assertEqual(result["reason"], "repository autoMergeAllowed must be a JSON boolean")
|
|
2259
|
+
self.assertIn(str(self.receipt), result["resume"])
|
|
2260
|
+
self.assertNotIn("merge_refused", result)
|
|
2261
|
+
d = json.loads(self.data.read_text(encoding="utf-8"))
|
|
2262
|
+
self.assertEqual(d.get("merge_calls", []), [])
|
|
2263
|
+
self.assertEqual(d["prs"][0]["state"], "OPEN")
|
|
2264
|
+
self.configure(auto_setting_output='false')
|
|
2265
|
+
result, _ = self.cli(*shlex.split(result["resume"])[2:])
|
|
2266
|
+
self.assertEqual(result["status"], "COMPLETE")
|
|
2267
|
+
|
|
2268
|
+
def test_auto_disallowed_status_poll_failure_blocks_with_resume(self):
|
|
2269
|
+
self.allocate(); self.accept()
|
|
2270
|
+
self.configure(auto_allowed=False, merge_state_error=True)
|
|
2271
|
+
result, process = self.complete("--mode", "auto", "--writers-stopped", success=False)
|
|
2272
|
+
self.assertNotEqual(process.returncode, 0)
|
|
2273
|
+
self.assertEqual(result["status"], "BLOCKED")
|
|
2274
|
+
self.assertIn("merge state read failed", result["reason"])
|
|
2275
|
+
self.assertIn(str(self.receipt), result["resume"])
|
|
2276
|
+
self.assertNotIn("merge_refused", result)
|
|
2277
|
+
d = json.loads(self.data.read_text(encoding="utf-8"))
|
|
2278
|
+
self.assertEqual(d.get("merge_calls", []), [])
|
|
2279
|
+
self.assertEqual(d["prs"][0]["state"], "OPEN")
|
|
2280
|
+
self.configure(merge_state_error=False)
|
|
2281
|
+
result, _ = self.cli(*shlex.split(result["resume"])[2:])
|
|
2282
|
+
self.assertEqual(result["status"], "COMPLETE")
|
|
2283
|
+
|
|
2284
|
+
def test_merge_attempt_refusal_keeps_pr(self):
|
|
2285
|
+
self.allocate(); self.accept()
|
|
2286
|
+
self.configure(auto_allowed=False, merge_error=True)
|
|
2287
|
+
result, _ = self.complete("--mode", "auto")
|
|
2288
|
+
self.assertEqual(result["status"], "PR")
|
|
2289
|
+
self.assertIn("merge attempt refused", result["merge_refused"])
|
|
2290
|
+
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8"))["merges"], 1)
|
|
2291
|
+
|
|
2292
|
+
def test_pr_mode_does_not_read_auto_setting(self):
|
|
2293
|
+
self.allocate(); self.accept()
|
|
2294
|
+
self.configure(auto_setting_error=True)
|
|
2295
|
+
result, _ = self.complete("--mode", "pr")
|
|
2296
|
+
self.assertEqual(result["status"], "PR")
|
|
2297
|
+
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("auto_setting_reads", 0), 0)
|
|
2298
|
+
|
|
2122
2299
|
def test_policy_and_local_only_persist(self):
|
|
2123
2300
|
self.allocate(); self.accept()
|
|
2124
2301
|
self.configure(merge_allowed=False)
|
|
@@ -2133,7 +2310,7 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
2133
2310
|
self.configure(merge_allowed=True, auto_allowed=False, pending=True)
|
|
2134
2311
|
result, _ = self.complete("--mode", "auto")
|
|
2135
2312
|
self.assertEqual(result["status"], "PR")
|
|
2136
|
-
self.assertIn("
|
|
2313
|
+
self.assertIn("not mergeable: BLOCKED", result["merge_refused"])
|
|
2137
2314
|
self.assertTrue(self.task.exists())
|
|
2138
2315
|
# Local-only cannot rewrite the delivery of a pushed PR; a local allocation keeps it (test_local_chain_*).
|
|
2139
2316
|
result, _ = self.complete("--no-push", success=False)
|
|
@@ -182,7 +182,7 @@ the user's delivery restriction. These per-task choices persist in the receipt;
|
|
|
182
182
|
head and base PR. While OPEN, it cancels owned auto-merge, reports `PR` with its URL
|
|
183
183
|
and retains the checkout. Once MERGED by any method, either mode cleans released
|
|
184
184
|
resources in that call, on resume or at the next allocation. Default `auto` also requests
|
|
185
|
-
|
|
185
|
+
a `--merge --match-head-commit <accepted SHA>` merge. Repository
|
|
186
186
|
checks/reviews and merge policy remain authoritative: no admin bypass, strategy
|
|
187
187
|
fallback or repository setting changes. When the repository disallows merge
|
|
188
188
|
commits or refuses the request, delivery reports `PR` with `merge_refused` and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "devlyn-cli",
|
|
3
|
-
"version": "4.2.
|
|
3
|
+
"version": "4.2.22",
|
|
4
4
|
"description": "Loop & harness engineering for AI coding agents (Claude Code, Codex, and any AGENTS.md-driven agent) — core engineering principles for CLAUDE.md/AGENTS.md and a loop designer that splits one intent into tasks agents drain hands-free",
|
|
5
5
|
"homepage": "https://github.com/fysoul17/devlyn-cli#readme",
|
|
6
6
|
"bin": {
|