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.
@@ -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
- try:
793
- require(info["mergeCommitAllowed"], "repository disallows merge commits")
794
- gh(receipt, "pr", "merge", str(pr["number"]), "--auto", "--merge", "--match-head-commit", receipt["publish_sha"])
795
- except CompletionError as error:
796
- refused = str(error)
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
- print(json.dumps(prs[int(a[2])-1]))
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
- if not d.get('auto_allowed',True) and d.get('pending'):
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('pending'):
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("disallows auto merge", result["merge_refused"])
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
- `gh pr merge --auto --merge --match-head-commit <accepted SHA>`. Repository
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.21",
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": {