claude-dev-env 8.19.2 → 8.20.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/bin/install.mjs CHANGED
@@ -25,6 +25,10 @@ import {
25
25
  pruneManagedPermissionsFromSettings,
26
26
  retiredManagedPermissions,
27
27
  } from './merge_managed_permissions.mjs';
28
+ import {
29
+ mergeMissingSettingsDefaults,
30
+ settingsDefaultsFromPackageSettings,
31
+ } from './merge_settings_defaults.mjs';
28
32
  import {
29
33
  SKIPPED_SOURCE_ENTRY_NAMES,
30
34
  SKIPPED_SOURCE_FILE_EXTENSIONS,
@@ -2176,6 +2180,26 @@ function mergeManagedPermissions() {
2176
2180
  return { ...mergeOutcome, removedCount: pruneOutcome.removedCount };
2177
2181
  }
2178
2182
 
2183
+ /**
2184
+ * Add each package setting default that ~/.claude/settings.json lacks.
2185
+ *
2186
+ * @returns {{addedKeys: string[]}}
2187
+ */
2188
+ function mergePackageSettingsDefaults() {
2189
+ const packageSettingsPath = join(PACKAGE_ROOT, SETTINGS_FILE_NAME);
2190
+ if (!existsSync(packageSettingsPath)) return { addedKeys: [] };
2191
+ const settingsDefaults = settingsDefaultsFromPackageSettings(
2192
+ JSON.parse(readFileSync(packageSettingsPath, 'utf8')),
2193
+ );
2194
+ const settingsPath = join(CLAUDE_HOME, SETTINGS_FILE_NAME);
2195
+ const settings = loadClaudeSettingsObject(settingsPath);
2196
+ const mergeOutcome = mergeMissingSettingsDefaults(settings, settingsDefaults);
2197
+ if (mergeOutcome.addedKeys.length > 0) {
2198
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 4) + '\n');
2199
+ }
2200
+ return mergeOutcome;
2201
+ }
2202
+
2179
2203
  /**
2180
2204
  * Merge two path lists into one, keyed on `comparisonKeyForPath`, with the
2181
2205
  * current run's spelling winning a collision.
@@ -2864,6 +2888,11 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2864
2888
  );
2865
2889
  }
2866
2890
 
2891
+ const settingsDefaultsMerge = mergePackageSettingsDefaults();
2892
+ if (settingsDefaultsMerge.addedKeys.length > 0) {
2893
+ console.log(` Settings: added ${settingsDefaultsMerge.addedKeys.join(', ')}`);
2894
+ }
2895
+
2867
2896
  const agentsHubSource = join(PACKAGE_ROOT, 'AGENTS.md');
2868
2897
  if (existsSync(agentsHubSource)) {
2869
2898
  const agentsHubDest = join(AGENTS_HOME, 'AGENTS.md');
@@ -21,6 +21,10 @@ import {
21
21
  mergeManagedPermissionsIntoSettings,
22
22
  pruneManagedPermissionsFromSettings,
23
23
  } from './merge_managed_permissions.mjs';
24
+ import {
25
+ mergeMissingSettingsDefaults,
26
+ settingsDefaultsFromPackageSettings,
27
+ } from './merge_settings_defaults.mjs';
24
28
 
25
29
  const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
26
30
  const PACKAGE_SETTINGS_PATH = join(PACKAGE_ROOT, 'settings.json');
@@ -251,3 +255,52 @@ test('sandbox uninstall removes only package-owned permission entries and keeps
251
255
  rmSync(sandboxHome, { recursive: true, force: true });
252
256
  }
253
257
  });
258
+
259
+ test('package settings.json publishes the advisor model default', () => {
260
+ const packageSettings = JSON.parse(readFileSync(PACKAGE_SETTINGS_PATH, 'utf8'));
261
+ assert.deepEqual(settingsDefaultsFromPackageSettings(packageSettings), { advisorModel: 'opus' });
262
+ });
263
+
264
+ test('a missing default is added and a user-set value is kept', () => {
265
+ const emptySettings = {};
266
+ assert.deepEqual(mergeMissingSettingsDefaults(emptySettings, { advisorModel: 'opus' }), {
267
+ addedKeys: ['advisorModel'],
268
+ });
269
+ assert.equal(emptySettings.advisorModel, 'opus');
270
+ const userSettings = { advisorModel: 'sonnet' };
271
+ assert.deepEqual(mergeMissingSettingsDefaults(userSettings, { advisorModel: 'opus' }), {
272
+ addedKeys: [],
273
+ });
274
+ assert.equal(userSettings.advisorModel, 'sonnet');
275
+ });
276
+
277
+ test('a Fable main model gets no advisor default', () => {
278
+ for (const fableModel of ['fable', 'claude-fable-5-1']) {
279
+ const fableSettings = { model: fableModel };
280
+ assert.deepEqual(mergeMissingSettingsDefaults(fableSettings, { advisorModel: 'opus' }), {
281
+ addedKeys: [],
282
+ });
283
+ assert.equal(Object.hasOwn(fableSettings, 'advisorModel'), false);
284
+ }
285
+ const opusSettings = { model: 'opus' };
286
+ mergeMissingSettingsDefaults(opusSettings, { advisorModel: 'opus' });
287
+ assert.equal(opusSettings.advisorModel, 'opus');
288
+ });
289
+
290
+ test('install adds advisorModel when missing and keeps a user choice on rerun', () => {
291
+ const sandboxHome = mkdtempSync(join(tmpdir(), 'cde-settings-defaults-'));
292
+ try {
293
+ const firstRun = runInstallerInSandbox(sandboxHome);
294
+ assert.equal(firstRun.status, 0, firstRun.stderr);
295
+ const settingsPath = join(sandboxHome, '.claude', 'settings.json');
296
+ assert.equal(JSON.parse(readFileSync(settingsPath, 'utf8')).advisorModel, 'opus');
297
+ const userChoice = JSON.parse(readFileSync(settingsPath, 'utf8'));
298
+ userChoice.advisorModel = 'sonnet';
299
+ writeFileSync(settingsPath, JSON.stringify(userChoice, null, 4) + '\n');
300
+ const secondRun = runInstallerInSandbox(sandboxHome);
301
+ assert.equal(secondRun.status, 0, secondRun.stderr);
302
+ assert.equal(JSON.parse(readFileSync(settingsPath, 'utf8')).advisorModel, 'sonnet');
303
+ } finally {
304
+ rmSync(sandboxHome, { recursive: true, force: true });
305
+ }
306
+ });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Merge package-owned scalar setting defaults into a settings object.
3
+ *
4
+ * Every top-level key in the package settings.json other than the managed
5
+ * lists (`permissions`) is a default. Install adds a default only when the
6
+ * user settings lack that key, so a value the user already set stays as is.
7
+ * A Fable main model accepts only a Fable advisor, so a user whose settings
8
+ * name a Fable model gets no package advisor default.
9
+ */
10
+
11
+ export const MANAGED_LIST_SETTING_KEYS = Object.freeze(['permissions']);
12
+ export const ADVISOR_MODEL_SETTING_KEY = 'advisorModel';
13
+ const FABLE_MODEL_PATTERN = /fable/i;
14
+
15
+ /**
16
+ * Read the setting defaults from a package settings source object.
17
+ *
18
+ * @param {Record<string, unknown> | null | undefined} packageSettings
19
+ * @returns {Record<string, unknown>}
20
+ */
21
+ export function settingsDefaultsFromPackageSettings(packageSettings) {
22
+ if (!packageSettings || typeof packageSettings !== 'object' || Array.isArray(packageSettings)) {
23
+ return {};
24
+ }
25
+ return Object.fromEntries(
26
+ Object.entries(packageSettings).filter(
27
+ ([settingKey]) => !MANAGED_LIST_SETTING_KEYS.includes(settingKey),
28
+ ),
29
+ );
30
+ }
31
+
32
+ /**
33
+ * Add each default whose key the target settings lack.
34
+ *
35
+ * @param {Record<string, unknown>} targetSettings Mutated in place.
36
+ * @param {Record<string, unknown>} settingsDefaults
37
+ * @returns {{addedKeys: string[]}}
38
+ */
39
+ export function mergeMissingSettingsDefaults(targetSettings, settingsDefaults) {
40
+ const addedKeys = [];
41
+ const mainModel = targetSettings.model;
42
+ const usesFableMainModel = typeof mainModel === 'string' && FABLE_MODEL_PATTERN.test(mainModel);
43
+ for (const [settingKey, defaultValue] of Object.entries(settingsDefaults)) {
44
+ if (Object.hasOwn(targetSettings, settingKey)) continue;
45
+ if (settingKey === ADVISOR_MODEL_SETTING_KEY && usesFableMainModel) continue;
46
+ targetSettings[settingKey] = defaultValue;
47
+ addedKeys.push(settingKey);
48
+ }
49
+ return { addedKeys };
50
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.19.2",
3
+ "version": "8.20.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,7 +24,7 @@ One command prints the verdict:
24
24
  python packages/claude-dev-env/scripts/agent_merge_check.py <owner>/<name> <number>
25
25
  ```
26
26
 
27
- It prints `MERGE` and exits 0 when the pull request is ready. It prints `HOLD` with the reason and exits 1 for a draft, for a head behind or conflicting with the base, for a required check that is not passing, for a check still running or red, and for an open review thread. It exits 2 when the state could not be read.
27
+ It prints `MERGE` and exits 0 when the pull request is ready. It prints `HOLD` with the reason and exits 1 for a draft, for a head behind or conflicting with the base, for a required check that is not passing, for a check still running or red, for a head the merge queue ejected for failed checks, and for an open review thread. It exits 2 when the state could not be read.
28
28
 
29
29
  Each hold reason names its own repair, and each repair belongs to the agent:
30
30
 
@@ -35,6 +35,7 @@ Each hold reason names its own repair, and each repair belongs to the agent:
35
35
  | A required check is red | Read the failing check, fix it, push |
36
36
  | A check is red or still running | Fix it, or wait for it, then read the verdict again |
37
37
  | A review thread is open | Answer it, push the fix, resolve the thread |
38
+ | Ejected from the merge queue for failed checks on this head | Read the merge_group run, fix the failure, push, then read the verdict again |
38
39
  | The pull request is a draft | Mark it ready once the checks pass |
39
40
 
40
41
  ## When a gate elsewhere holds the merge command
@@ -26,6 +26,8 @@ It prints `CLOSED` and exits 0 when every finding on the head is answered. It pr
26
26
  | A blocking `Claude Approvals` row | A push, which moves the head the check reports on |
27
27
  | A top-level comment on the pull request | A later top-level comment from the driving account |
28
28
 
29
+ A review bot rewrites its summary comment on each pass. Its edit leaves an answered summary closed, because each new finding it has arrives as a review thread or a new comment. An edit from a person reopens the comment.
30
+
29
31
  A red circle marks a finding a review states as blocking, so resolution in silence leaves it open. The reply says what changed or why the finding stands, and the reviewer reads it beside the diff.
30
32
 
31
33
  The driving account is the one that opened the pull request. Where the agent comments under a second login, `--driver-login <login>` names it, repeatably.
@@ -32,7 +32,9 @@ from dataclasses import dataclass
32
32
 
33
33
  from dev_env_scripts_constants.agent_merge_check_constants import (
34
34
  ACCEPT_HEADER,
35
+ ALL_HEAD_COMMIT_NODE_KEYS,
35
36
  ALL_HOLD_REASONS_BY_STATE,
37
+ ALL_MERGE_QUEUE_REMOVAL_NODE_KEYS,
36
38
  ALL_PASSING_CHECK_CONCLUSIONS,
37
39
  ALL_OUTDATED_KEYS,
38
40
  ALL_RESOLVED_KEYS,
@@ -58,12 +60,16 @@ from dev_env_scripts_constants.agent_merge_check_constants import (
58
60
  CHECK_RUNS_KEY,
59
61
  COMBINED_STATUS_ENDPOINT_TEMPLATE,
60
62
  COMMAND_DESCRIPTION,
63
+ COMMIT_KEY,
64
+ COMMITTED_DATE_KEY,
61
65
  COMPARE_ENDPOINT_TEMPLATE,
62
66
  CONTENT_TYPE_HEADER,
63
67
  CONTEXT_KEY,
68
+ CREATED_AT_KEY,
64
69
  DRAFT_HOLD_REASON,
65
70
  DRAFT_KEY,
66
71
  ERROR_EXIT_CODE,
72
+ FAILED_CHECKS_REMOVAL_REASON,
67
73
  FAILING_REQUIRED_CHECKS_HOLD_TEMPLATE,
68
74
  GITHUB_ACCEPT_TYPE,
69
75
  GITHUB_API_ROOT,
@@ -74,6 +80,9 @@ from dev_env_scripts_constants.agent_merge_check_constants import (
74
80
  INTEGRATION_ID_KEY,
75
81
  JSON_CONTENT_TYPE,
76
82
  MERGE_EXIT_CODE,
83
+ MERGE_QUEUE_EJECTION_HOLD_TEMPLATE,
84
+ MERGE_QUEUE_REMOVAL_PAGE_SIZE,
85
+ MERGE_QUEUE_REMOVAL_QUERY,
77
86
  MERGE_QUEUE_RULE_TYPE,
78
87
  MERGE_VERDICT_LABEL,
79
88
  MERGEABLE_STATE_BLOCKED,
@@ -95,6 +104,7 @@ from dev_env_scripts_constants.agent_merge_check_constants import (
95
104
  QUERY_KEY,
96
105
  READY_DETAIL,
97
106
  REF_KEY,
107
+ REMOVAL_REASON_KEY,
98
108
  REQUIRED_CHECK_SEPARATOR,
99
109
  REQUIRED_CHECK_STATE_TEMPLATE,
100
110
  REQUIRED_STATUS_CHECKS_KEY,
@@ -181,6 +191,7 @@ def hold_reason(
181
191
  all_pull_request_fields: Mapping[str, object],
182
192
  unresolved_thread_count: int,
183
193
  blocked_evidence: BlockedEvidence | None = None,
194
+ is_ejected_from_merge_queue: bool = False,
184
195
  ) -> str | None:
185
196
  """Return why this pull request stays open, or None when it may merge.
186
197
 
@@ -190,11 +201,14 @@ def hold_reason(
190
201
  unresolved_thread_count: How many review threads are open on it.
191
202
  blocked_evidence: What was read behind a ``blocked`` merge state, or
192
203
  None when nothing was read.
204
+ is_ejected_from_merge_queue: Whether the merge queue ejected the
205
+ current head for failed checks.
193
206
 
194
207
  Returns:
195
- The reason text for a draft, for a merge state other than clean, and
196
- for an open review thread. None when the pull request is ready for
197
- the agent that drives it to merge it.
208
+ The reason text for a draft, for a merge state other than clean, for
209
+ a head the merge queue ejected, and for an open review thread. None
210
+ when the pull request is ready for the agent that drives it to merge
211
+ it.
198
212
  """
199
213
  if all_pull_request_fields.get(DRAFT_KEY):
200
214
  return DRAFT_HOLD_REASON
@@ -206,11 +220,27 @@ def hold_reason(
206
220
  merge_state,
207
221
  UNKNOWN_STATE_HOLD_TEMPLATE.format(state=merge_state),
208
222
  )
223
+ if is_ejected_from_merge_queue:
224
+ return MERGE_QUEUE_EJECTION_HOLD_TEMPLATE.format(
225
+ base=_optional_nested_field(all_pull_request_fields, BASE_KEY, REF_KEY),
226
+ number=all_pull_request_fields.get(NUMBER_KEY),
227
+ )
209
228
  if unresolved_thread_count > 0:
210
229
  return UNRESOLVED_THREADS_HOLD_TEMPLATE.format(count=unresolved_thread_count)
211
230
  return None
212
231
 
213
232
 
233
+ def _optional_nested_field(
234
+ all_fields: Mapping[str, object],
235
+ outer_key: str,
236
+ inner_key: str,
237
+ ) -> object:
238
+ all_inner_fields = all_fields.get(outer_key)
239
+ if not isinstance(all_inner_fields, Mapping):
240
+ return None
241
+ return all_inner_fields.get(inner_key)
242
+
243
+
214
244
  def required_contexts(all_rules: Sequence[object]) -> tuple[RequiredContext, ...]:
215
245
  """Collect each status check the branch rules require, once, in order.
216
246
 
@@ -406,6 +436,27 @@ def count_unresolved_threads(all_thread_records: Sequence[object]) -> int:
406
436
  )
407
437
 
408
438
 
439
+ def _is_ejected_on_head(
440
+ all_removal_records: Sequence[object],
441
+ head_committed_at: str,
442
+ ) -> bool:
443
+ """Report whether a ``failed_checks`` removal is newer than the head.
444
+
445
+ A commit that lands after the merge queue ejected the pull request
446
+ carries a fix the queue has not seen yet, so an older removal holds
447
+ nothing back.
448
+ """
449
+ all_failed_check_times = [
450
+ str(each_removal.get(CREATED_AT_KEY))
451
+ for each_removal in all_removal_records
452
+ if isinstance(each_removal, Mapping)
453
+ and each_removal.get(REMOVAL_REASON_KEY) == FAILED_CHECKS_REMOVAL_REASON
454
+ ]
455
+ return (
456
+ bool(all_failed_check_times) and max(all_failed_check_times) > head_committed_at
457
+ )
458
+
459
+
409
460
  def _any_flag(all_thread_fields: Mapping[str, object], all_keys: Sequence[str]) -> bool:
410
461
  return any(all_thread_fields.get(each_key) for each_key in all_keys)
411
462
 
@@ -648,14 +699,20 @@ def _thread_records_over_rest(
648
699
  return document if isinstance(document, list) else None
649
700
 
650
701
 
651
- def _thread_query_payload(owner: str, name: str, number: int) -> dict[str, object]:
702
+ def _pull_request_query_payload(
703
+ query: str,
704
+ page_size: int,
705
+ slug: str,
706
+ number: int,
707
+ ) -> dict[str, object]:
708
+ owner, _, name = slug.partition(SLUG_SEPARATOR)
652
709
  return {
653
- QUERY_KEY: UNRESOLVED_THREAD_QUERY,
710
+ QUERY_KEY: query,
654
711
  VARIABLES_KEY: {
655
712
  OWNER_VARIABLE: owner,
656
713
  NAME_VARIABLE: name,
657
714
  NUMBER_VARIABLE: number,
658
- PAGE_SIZE_VARIABLE: REVIEW_THREAD_PAGE_SIZE,
715
+ PAGE_SIZE_VARIABLE: page_size,
659
716
  },
660
717
  }
661
718
 
@@ -665,11 +722,12 @@ def _read_unresolved_thread_count_over_graphql(
665
722
  number: int,
666
723
  token: str,
667
724
  ) -> int:
668
- owner, _, name = slug.partition(SLUG_SEPARATOR)
669
725
  document = _request_json(
670
726
  GITHUB_GRAPHQL_ENDPOINT,
671
727
  token,
672
- _thread_query_payload(owner, name, number),
728
+ _pull_request_query_payload(
729
+ UNRESOLVED_THREAD_QUERY, REVIEW_THREAD_PAGE_SIZE, slug, number
730
+ ),
673
731
  )
674
732
  all_thread_records = functools.reduce(
675
733
  functools.partial(_field_at, document),
@@ -687,6 +745,59 @@ def _field_at(document: object, all_fields: object, key: str) -> object:
687
745
  return all_fields.get(key)
688
746
 
689
747
 
748
+ def read_merge_queue_ejection(slug: str, number: int, token: str) -> bool:
749
+ """Read whether the merge queue ejected the pull request's current head.
750
+
751
+ Only GraphQL reports why a pull request left the merge queue. A
752
+ repository with no merge queue reports no removal events.
753
+
754
+ Args:
755
+ slug: The repository as ``owner/name``.
756
+ number: The pull request number.
757
+ token: The GitHub token the request authenticates with.
758
+
759
+ Returns:
760
+ True when a ``failed_checks`` removal is newer than the head commit.
761
+
762
+ Raises:
763
+ MergeCheckError: The query failed, or answered with another shape.
764
+ """
765
+ document = _request_json(
766
+ GITHUB_GRAPHQL_ENDPOINT,
767
+ token,
768
+ _pull_request_query_payload(
769
+ MERGE_QUEUE_REMOVAL_QUERY, MERGE_QUEUE_REMOVAL_PAGE_SIZE, slug, number
770
+ ),
771
+ )
772
+ return _is_ejected_on_head(
773
+ _list_along(document, ALL_MERGE_QUEUE_REMOVAL_NODE_KEYS),
774
+ _head_committed_at(document),
775
+ )
776
+
777
+
778
+ def _list_along(document: object, all_keys: Sequence[str]) -> list[object]:
779
+ all_records = functools.reduce(
780
+ functools.partial(_field_at, document), all_keys, document
781
+ )
782
+ if not isinstance(all_records, list):
783
+ raise MergeCheckError(str(document))
784
+ return all_records
785
+
786
+
787
+ def _head_committed_at(document: object) -> str:
788
+ all_head_commits = _list_along(document, ALL_HEAD_COMMIT_NODE_KEYS)
789
+ if not all_head_commits:
790
+ raise MergeCheckError(str(document))
791
+ head_committed_at = functools.reduce(
792
+ functools.partial(_field_at, document),
793
+ (COMMIT_KEY, COMMITTED_DATE_KEY),
794
+ all_head_commits[-1],
795
+ )
796
+ if not isinstance(head_committed_at, str):
797
+ raise MergeCheckError(str(document))
798
+ return head_committed_at
799
+
800
+
690
801
  def read_settled_pull_request(
691
802
  slug: str,
692
803
  number: int,
@@ -741,6 +852,9 @@ def main(all_arguments: Sequence[str]) -> int:
741
852
  unresolved_thread_count = read_unresolved_thread_count(
742
853
  parsed.slug, parsed.number, token
743
854
  )
855
+ is_ejected_from_merge_queue = read_merge_queue_ejection(
856
+ parsed.slug, parsed.number, token
857
+ )
744
858
  except MergeCheckError as failure:
745
859
  print(failure, file=sys.stderr)
746
860
  return ERROR_EXIT_CODE
@@ -753,7 +867,10 @@ def main(all_arguments: Sequence[str]) -> int:
753
867
  except MergeCheckError as failure:
754
868
  print(failure, file=sys.stderr)
755
869
  reason = hold_reason(
756
- all_pull_request_fields, unresolved_thread_count, blocked_evidence
870
+ all_pull_request_fields,
871
+ unresolved_thread_count,
872
+ blocked_evidence,
873
+ is_ejected_from_merge_queue,
757
874
  )
758
875
  print(verdict_line(parsed.slug, all_pull_request_fields, reason))
759
876
  return HOLD_EXIT_CODE if reason else MERGE_EXIT_CODE
@@ -117,6 +117,13 @@ UNKNOWN_STATE_HOLD_TEMPLATE = (
117
117
  UNRESOLVED_THREADS_HOLD_TEMPLATE = (
118
118
  "{count} review thread(s) are open. Answer each one and resolve it."
119
119
  )
120
+ MERGE_QUEUE_EJECTION_HOLD_TEMPLATE = (
121
+ "The merge queue ejected this head for failed checks, and no commit has "
122
+ "landed since. Read the merge_group run for the "
123
+ "gh-readonly-queue/{base}/pr-{number}-<sha> branch, fix the failure, and "
124
+ "push before you enqueue it again."
125
+ )
126
+ FAILED_CHECKS_REMOVAL_REASON = "failed_checks"
120
127
 
121
128
  MERGE_VERDICT_LABEL = "MERGE"
122
129
  HOLD_VERDICT_LABEL = "HOLD"
@@ -148,6 +155,19 @@ query($owner: String!, $name: String!, $number: Int!, $pageSize: Int!) {
148
155
  }
149
156
  }
150
157
  """
158
+ MERGE_QUEUE_REMOVAL_PAGE_SIZE = 100
159
+ MERGE_QUEUE_REMOVAL_QUERY = """
160
+ query($owner: String!, $name: String!, $number: Int!, $pageSize: Int!) {
161
+ repository(owner: $owner, name: $name) {
162
+ pullRequest(number: $number) {
163
+ commits(last: 1) { nodes { commit { committedDate } } }
164
+ timelineItems(last: $pageSize, itemTypes: [REMOVED_FROM_MERGE_QUEUE_EVENT]) {
165
+ nodes { ... on RemovedFromMergeQueueEvent { createdAt reason } }
166
+ }
167
+ }
168
+ }
169
+ }
170
+ """
151
171
  QUERY_KEY = "query"
152
172
  VARIABLES_KEY = "variables"
153
173
  OWNER_VARIABLE = "owner"
@@ -166,5 +186,25 @@ ALL_THREAD_NODE_KEYS = (
166
186
  REVIEW_THREADS_KEY,
167
187
  NODES_KEY,
168
188
  )
189
+ COMMITS_KEY = "commits"
190
+ COMMIT_KEY = "commit"
191
+ COMMITTED_DATE_KEY = "committedDate"
192
+ TIMELINE_ITEMS_KEY = "timelineItems"
193
+ CREATED_AT_KEY = "createdAt"
194
+ REMOVAL_REASON_KEY = "reason"
195
+ ALL_MERGE_QUEUE_REMOVAL_NODE_KEYS = (
196
+ DATA_KEY,
197
+ REPOSITORY_KEY,
198
+ PULL_REQUEST_KEY,
199
+ TIMELINE_ITEMS_KEY,
200
+ NODES_KEY,
201
+ )
202
+ ALL_HEAD_COMMIT_NODE_KEYS = (
203
+ DATA_KEY,
204
+ REPOSITORY_KEY,
205
+ PULL_REQUEST_KEY,
206
+ COMMITS_KEY,
207
+ NODES_KEY,
208
+ )
169
209
  ALL_RESOLVED_KEYS = ("isResolved", "is_resolved", "resolved")
170
210
  ALL_OUTDATED_KEYS = ("isOutdated", "is_outdated", "outdated")
@@ -131,6 +131,12 @@ NUMBER_KEY: str = "number"
131
131
  USER_KEY: str = "user"
132
132
  """Field carrying the account that opened a pull request."""
133
133
 
134
+ USER_TYPE_KEY: str = "type"
135
+ """Field on a REST user naming whether the account is a person or a bot."""
136
+
137
+ BOT_USER_TYPE: str = "Bot"
138
+ """The user type GitHub reports for an app account such as a review bot."""
139
+
134
140
  SHORT_SHA_LENGTH: int = 7
135
141
  """How much of a commit identifier the verdict line prints."""
136
142
 
@@ -13,10 +13,14 @@ and the approvals conclusion, and name what waits.
13
13
  thread: a red circle, resolved in silence -> open
14
14
  top-level: a bot comment, no agent comment -> open
15
15
  top-level: a bot comment, then an agent one -> closed
16
- top-level: the bot edits after that reply -> open
16
+ top-level: the bot edits after that reply -> closed
17
+ top-level: a person edits after that reply -> open
17
18
 
18
19
  A red circle marks a finding a review states as blocking, so resolution alone
19
20
  leaves it open. The reply says what changed, or why the finding stands.
21
+ A review bot rewrites its summary comment on each pass, and any new finding it
22
+ has arrives as a review thread or a new comment, so its edit alone reopens
23
+ nothing.
20
24
  """
21
25
 
22
26
  from __future__ import annotations
@@ -34,6 +38,7 @@ from dev_env_scripts_constants.review_closure_constants import (
34
38
  APPROVALS_CHECK_NAME,
35
39
  APPROVALS_OPEN_REASON,
36
40
  AUTHOR_LOGIN_KEY,
41
+ BOT_USER_TYPE,
37
42
  CHECK_RUN_CONCLUSION_KEY,
38
43
  CHECK_RUN_NAME_KEY,
39
44
  CLOSED_DETAIL,
@@ -58,6 +63,7 @@ from dev_env_scripts_constants.review_closure_constants import (
58
63
  UNNAMED_THREAD_SUBJECT,
59
64
  UPDATED_AT_KEY,
60
65
  USER_KEY,
66
+ USER_TYPE_KEY,
61
67
  VERDICT_LINE_TEMPLATE,
62
68
  )
63
69
 
@@ -86,6 +92,7 @@ class TopLevelComment:
86
92
 
87
93
  identifier: object
88
94
  author_login: str
95
+ is_bot: bool
89
96
  created_at: datetime
90
97
  updated_at: datetime
91
98
  url: str
@@ -212,7 +219,8 @@ def top_level_finding(
212
219
  """
213
220
  if comment.author_login in all_driver_logins:
214
221
  return None
215
- if latest_driver_time is not None and comment.updated_at <= latest_driver_time:
222
+ last_counted_at = comment.created_at if comment.is_bot else comment.updated_at
223
+ if latest_driver_time is not None and last_counted_at <= latest_driver_time:
216
224
  return None
217
225
  return OpenFinding(
218
226
  subject=comment.url,
@@ -228,15 +236,18 @@ def top_level_findings(
228
236
 
229
237
  The driving agent answers a top-level comment by posting a top-level
230
238
  comment of its own. Its latest one answers every comment last touched
231
- before it, and a comment edited after it waits again.
239
+ before it. A person's comment edited after it waits again. A bot's edit
240
+ leaves its comment answered, since a bot posts each new finding as a
241
+ review thread or a new comment.
232
242
 
233
243
  Args:
234
244
  all_comments: The top-level comments on the pull request.
235
245
  all_driver_logins: The logins whose comments count as the answer.
236
246
 
237
247
  Returns:
238
- One finding per comment from another account that was posted or
239
- edited after the driving agent's latest top-level comment.
248
+ One finding per comment from another account posted after the
249
+ driving agent's latest top-level comment, and one per person's
250
+ comment edited after it.
240
251
  """
241
252
  all_listed_comments = tuple(all_comments)
242
253
  latest_driver_time = latest_driver_comment_time(
@@ -424,6 +435,7 @@ def parse_top_level_comment(
424
435
  return TopLevelComment(
425
436
  identifier=all_comment_fields.get(COMMENT_IDENTIFIER_KEY),
426
437
  author_login=_comment_author_login(all_comment_fields),
438
+ is_bot=_is_bot_author(all_comment_fields),
427
439
  created_at=datetime.fromisoformat(str(all_comment_fields.get(CREATED_AT_KEY))),
428
440
  updated_at=datetime.fromisoformat(str(all_comment_fields.get(UPDATED_AT_KEY))),
429
441
  url=str(all_comment_fields.get(HTML_URL_KEY) or ""),
@@ -477,6 +489,14 @@ def _parse_comment(all_comment_fields: Mapping[str, object]) -> ReviewComment:
477
489
  )
478
490
 
479
491
 
492
+ def _is_bot_author(all_comment_fields: Mapping[str, object]) -> bool:
493
+ all_author_fields = all_comment_fields.get(USER_KEY)
494
+ return (
495
+ isinstance(all_author_fields, Mapping)
496
+ and all_author_fields.get(USER_TYPE_KEY) == BOT_USER_TYPE
497
+ )
498
+
499
+
480
500
  def _comment_author_login(all_comment_fields: Mapping[str, object]) -> str:
481
501
  for each_key in ALL_COMMENT_AUTHOR_KEYS:
482
502
  all_author_fields = all_comment_fields.get(each_key)
@@ -290,6 +290,11 @@ def test_a_ready_pull_request_exits_zero(monkeypatch: pytest.MonkeyPatch) -> Non
290
290
  "read_unresolved_thread_count",
291
291
  lambda slug, number, token: 0,
292
292
  )
293
+ monkeypatch.setattr(
294
+ agent_merge_check,
295
+ "read_merge_queue_ejection",
296
+ lambda slug, number, token: False,
297
+ )
293
298
  assert agent_merge_check.main(["jl-cmd/claude-dev-env", "1442"]) == 0
294
299
 
295
300
 
@@ -305,6 +310,11 @@ def test_a_held_pull_request_exits_one(monkeypatch: pytest.MonkeyPatch) -> None:
305
310
  "read_unresolved_thread_count",
306
311
  lambda slug, number, token: 0,
307
312
  )
313
+ monkeypatch.setattr(
314
+ agent_merge_check,
315
+ "read_merge_queue_ejection",
316
+ lambda slug, number, token: False,
317
+ )
308
318
  assert agent_merge_check.main(["jl-cmd/claude-dev-env", "1442"]) == 1
309
319
 
310
320
 
@@ -319,6 +329,7 @@ def test_an_unreadable_pull_request_exits_two(
319
329
  assert agent_merge_check.main(["jl-cmd/claude-dev-env", "1442"]) == 2
320
330
 
321
331
 
332
+ HEAD_COMMITTED_AT = "2026-09-26T16:00:00Z"
322
333
  PULL_REQUEST_4922_HEAD_SHA = "06bc18a1a8761a16e92c1419693a42f22dd4ae7c"
323
334
  REQUIRED_CHECK_APP_ID = 15368
324
335
  PULL_REQUEST_4922 = {
@@ -386,11 +397,28 @@ def _github_answers(
386
397
  return {"statuses": all_statuses or []}
387
398
  if "/compare/" in parsed.path:
388
399
  return {"behind_by": behind_by, "ahead_by": 1}
400
+ if parsed.path.endswith("/graphql"):
401
+ return _merge_queue_document([])
389
402
  raise AssertionError(url)
390
403
 
391
404
  return _answer
392
405
 
393
406
 
407
+ def _merge_queue_document(all_removals: list[dict[str, object]]) -> object:
408
+ return {
409
+ "data": {
410
+ "repository": {
411
+ "pullRequest": {
412
+ "commits": {
413
+ "nodes": [{"commit": {"committedDate": HEAD_COMMITTED_AT}}]
414
+ },
415
+ "timelineItems": {"nodes": all_removals},
416
+ }
417
+ }
418
+ }
419
+ }
420
+
421
+
394
422
  def _run_main(
395
423
  monkeypatch: pytest.MonkeyPatch,
396
424
  capsys: pytest.CaptureFixture[str],
@@ -610,3 +638,147 @@ def test_blocked_hold_reason_names_every_unmet_required_check() -> None:
610
638
  )
611
639
  assert "Ruff (failure), Tip Local green (missing)" in reason
612
640
  assert "behind" not in reason
641
+
642
+
643
+ QUEUED_PULL_REQUEST = {
644
+ "number": 1442,
645
+ "draft": False,
646
+ "mergeable_state": "clean",
647
+ "base": {"ref": "main"},
648
+ "head": {"sha": "ab845eb6945650055ebe852312a9057b9f067d6a"},
649
+ }
650
+
651
+
652
+ def _removal(created_at: str, reason: str) -> dict[str, object]:
653
+ return {"createdAt": created_at, "reason": reason}
654
+
655
+
656
+ def _merge_queue_answers(
657
+ all_removals: list[dict[str, object]],
658
+ ) -> object:
659
+ def _answer(url: str, token: str, all_payload_fields: object) -> object:
660
+ if url.endswith("/pulls/1442"):
661
+ return QUEUED_PULL_REQUEST
662
+ if url.endswith("/ccr/review_threads"):
663
+ return []
664
+ if url.endswith("/graphql"):
665
+ return _merge_queue_document(all_removals)
666
+ raise AssertionError(url)
667
+
668
+ return _answer
669
+
670
+
671
+ def _run_queued_main(
672
+ monkeypatch: pytest.MonkeyPatch,
673
+ capsys: pytest.CaptureFixture[str],
674
+ all_removals: list[dict[str, object]],
675
+ ) -> tuple[int, str]:
676
+ monkeypatch.setenv("GH_TOKEN", "token")
677
+ monkeypatch.setattr(
678
+ agent_merge_check, "_request_json", _merge_queue_answers(all_removals)
679
+ )
680
+ exit_code = agent_merge_check.main(["jl-cmd/claude-dev-env", "1442"])
681
+ return exit_code, capsys.readouterr().out
682
+
683
+
684
+ def test_a_head_ejected_from_the_merge_queue_for_failed_checks_holds(
685
+ monkeypatch: pytest.MonkeyPatch,
686
+ capsys: pytest.CaptureFixture[str],
687
+ ) -> None:
688
+ exit_code, line = _run_queued_main(
689
+ monkeypatch,
690
+ capsys,
691
+ [
692
+ _removal("2026-09-26T15:09:20Z", "failed_checks"),
693
+ _removal("2026-09-26T17:11:35Z", "failed_checks"),
694
+ ],
695
+ )
696
+ assert exit_code == 1
697
+ assert line.startswith(HOLD_VERDICT_LABEL)
698
+ assert "merge queue" in line
699
+ assert "failed checks" in line
700
+ assert "merge_group" in line
701
+ assert "gh-readonly-queue/main/pr-1442-<sha>" in line
702
+ assert "before you enqueue it again" in line
703
+
704
+
705
+ def test_a_fix_pushed_after_the_merge_queue_ejection_clears_the_hold(
706
+ monkeypatch: pytest.MonkeyPatch,
707
+ capsys: pytest.CaptureFixture[str],
708
+ ) -> None:
709
+ exit_code, line = _run_queued_main(
710
+ monkeypatch,
711
+ capsys,
712
+ [_removal("2026-09-26T15:09:20Z", "failed_checks")],
713
+ )
714
+ assert exit_code == 0
715
+ assert line.startswith(MERGE_VERDICT_LABEL)
716
+
717
+
718
+ def test_a_manual_merge_queue_removal_does_not_hold(
719
+ monkeypatch: pytest.MonkeyPatch,
720
+ capsys: pytest.CaptureFixture[str],
721
+ ) -> None:
722
+ exit_code, line = _run_queued_main(
723
+ monkeypatch,
724
+ capsys,
725
+ [
726
+ _removal("2026-09-26T15:09:20Z", "failed_checks"),
727
+ _removal("2026-09-26T17:58:17Z", "manual"),
728
+ ],
729
+ )
730
+ assert exit_code == 0
731
+ assert line.startswith(MERGE_VERDICT_LABEL)
732
+
733
+
734
+ def test_a_repository_with_no_merge_queue_removal_may_merge(
735
+ monkeypatch: pytest.MonkeyPatch,
736
+ capsys: pytest.CaptureFixture[str],
737
+ ) -> None:
738
+ exit_code, line = _run_queued_main(monkeypatch, capsys, [])
739
+ assert exit_code == 0
740
+ assert line.startswith(MERGE_VERDICT_LABEL)
741
+
742
+
743
+ def test_a_merge_queue_answer_of_another_shape_exits_two(
744
+ monkeypatch: pytest.MonkeyPatch,
745
+ capsys: pytest.CaptureFixture[str],
746
+ ) -> None:
747
+ routed_answer = _merge_queue_answers([])
748
+
749
+ def _answer(url: str, token: str, all_payload_fields: object) -> object:
750
+ if url.endswith("/graphql"):
751
+ return {"errors": [{"message": "Bad credentials"}]}
752
+ return routed_answer(url, token, all_payload_fields)
753
+
754
+ monkeypatch.setenv("GH_TOKEN", "token")
755
+ monkeypatch.setattr(agent_merge_check, "_request_json", _answer)
756
+ assert agent_merge_check.main(["jl-cmd/claude-dev-env", "1442"]) == 2
757
+ assert capsys.readouterr().out == ""
758
+
759
+
760
+ @pytest.mark.parametrize(
761
+ ("all_removals", "expected_ejection"),
762
+ [
763
+ ([_removal("2026-09-26T17:11:35Z", "failed_checks")], True),
764
+ ([_removal("2026-09-26T15:09:20Z", "failed_checks")], False),
765
+ ([_removal("2026-09-26T17:58:17Z", "manual")], False),
766
+ ([], False),
767
+ ],
768
+ )
769
+ def test_read_merge_queue_ejection_compares_failed_checks_removals_to_the_head(
770
+ monkeypatch: pytest.MonkeyPatch,
771
+ all_removals: list[dict[str, object]],
772
+ expected_ejection: bool,
773
+ ) -> None:
774
+ monkeypatch.setattr(
775
+ agent_merge_check,
776
+ "_request_json",
777
+ lambda url, token, all_payload_fields: _merge_queue_document(all_removals),
778
+ )
779
+ assert (
780
+ agent_merge_check.read_merge_queue_ejection(
781
+ "jl-cmd/claude-dev-env", 1442, "token"
782
+ )
783
+ is expected_ejection
784
+ )
@@ -139,6 +139,7 @@ def should_print_the_url_of_a_waiting_top_level_comment(
139
139
  bot_summary = model.TopLevelComment(
140
140
  identifier=1,
141
141
  author_login="qodo-merge-pro[bot]",
142
+ is_bot=True,
142
143
  created_at=posted,
143
144
  updated_at=posted,
144
145
  url=summary_url,
@@ -21,6 +21,7 @@ from dev_env_scripts_constants.review_closure_constants import (
21
21
 
22
22
  DRIVING_AGENT = frozenset({"claude[bot]"})
23
23
  REVIEW_BOT = "qodo-merge-pro[bot]"
24
+ REVIEWER = "maintainer"
24
25
 
25
26
 
26
27
  def thread(
@@ -313,10 +314,12 @@ def top_level_comment(
313
314
  posted_minute: int,
314
315
  edited_minute: int | None = None,
315
316
  url: str = BOT_SUMMARY_URL,
317
+ is_bot: bool = True,
316
318
  ) -> model.TopLevelComment:
317
319
  return model.TopLevelComment(
318
320
  identifier=posted_minute,
319
321
  author_login=author_login,
322
+ is_bot=is_bot,
320
323
  created_at=at_minute(posted_minute),
321
324
  updated_at=at_minute(posted_minute if edited_minute is None else edited_minute),
322
325
  url=url,
@@ -355,9 +358,9 @@ def should_close_every_earlier_comment_with_one_driver_comment() -> None:
355
358
  assert model.top_level_findings(all_comments, DRIVING_AGENT) == ()
356
359
 
357
360
 
358
- def should_reopen_a_bot_comment_edited_after_the_driver_reply() -> None:
361
+ def should_reopen_a_person_comment_edited_after_the_driver_reply() -> None:
359
362
  all_comments = (
360
- top_level_comment(REVIEW_BOT, 1, edited_minute=5),
363
+ top_level_comment(REVIEWER, 1, edited_minute=5, is_bot=False),
361
364
  top_level_comment("claude[bot]", 2),
362
365
  )
363
366
 
@@ -366,6 +369,15 @@ def should_reopen_a_bot_comment_edited_after_the_driver_reply() -> None:
366
369
  assert [each.subject for each in all_findings] == [BOT_SUMMARY_URL]
367
370
 
368
371
 
372
+ def should_keep_a_bot_summary_closed_when_edited_after_the_driver_reply() -> None:
373
+ all_comments = (
374
+ top_level_comment(REVIEW_BOT, 1, edited_minute=5),
375
+ top_level_comment("claude[bot]", 2),
376
+ )
377
+
378
+ assert model.top_level_findings(all_comments, DRIVING_AGENT) == ()
379
+
380
+
369
381
  def should_leave_a_bot_comment_posted_after_the_driver_open() -> None:
370
382
  all_comments = (
371
383
  top_level_comment("claude[bot]", 1),
@@ -403,9 +415,28 @@ def should_close_a_bot_comment_last_edited_at_the_driver_time() -> None:
403
415
  assert model.top_level_finding(comment, DRIVING_AGENT, at_minute(4)) is None
404
416
 
405
417
 
406
- def should_open_a_bot_comment_edited_after_the_driver_time() -> None:
418
+ def should_open_a_person_comment_edited_after_the_driver_time() -> None:
419
+ comment = top_level_comment(REVIEWER, 1, edited_minute=5, is_bot=False)
420
+
421
+ assert model.top_level_finding(
422
+ comment, DRIVING_AGENT, at_minute(4)
423
+ ) == model.OpenFinding(
424
+ subject=BOT_SUMMARY_URL,
425
+ reason=TOP_LEVEL_OPEN_REASON_TEMPLATE.format(author=REVIEWER),
426
+ )
427
+
428
+
429
+ def should_close_a_bot_comment_posted_before_and_edited_after_the_driver_time() -> (
430
+ None
431
+ ):
407
432
  comment = top_level_comment(REVIEW_BOT, 1, edited_minute=5)
408
433
 
434
+ assert model.top_level_finding(comment, DRIVING_AGENT, at_minute(4)) is None
435
+
436
+
437
+ def should_open_a_bot_comment_posted_after_the_driver_time() -> None:
438
+ comment = top_level_comment(REVIEW_BOT, 5)
439
+
409
440
  assert model.top_level_finding(
410
441
  comment, DRIVING_AGENT, at_minute(4)
411
442
  ) == model.OpenFinding(
@@ -426,7 +457,7 @@ def should_read_a_top_level_comment_record() -> None:
426
457
  comment = model.parse_top_level_comment(
427
458
  {
428
459
  "id": 1,
429
- "user": {"login": REVIEW_BOT},
460
+ "user": {"login": REVIEW_BOT, "type": "Bot"},
430
461
  "created_at": "2026-09-26T12:01:00Z",
431
462
  "updated_at": "2026-09-26T12:05:00Z",
432
463
  "html_url": BOT_SUMMARY_URL,
@@ -434,3 +465,17 @@ def should_read_a_top_level_comment_record() -> None:
434
465
  )
435
466
 
436
467
  assert comment == top_level_comment(REVIEW_BOT, 1, edited_minute=5)
468
+
469
+
470
+ def should_read_a_person_top_level_comment_record() -> None:
471
+ comment = model.parse_top_level_comment(
472
+ {
473
+ "id": 1,
474
+ "user": {"login": REVIEWER, "type": "User"},
475
+ "created_at": "2026-09-26T12:01:00Z",
476
+ "updated_at": "2026-09-26T12:05:00Z",
477
+ "html_url": BOT_SUMMARY_URL,
478
+ }
479
+ )
480
+
481
+ assert comment == top_level_comment(REVIEWER, 1, edited_minute=5, is_bot=False)
package/settings.json CHANGED
@@ -3,5 +3,6 @@
3
3
  "allow": [
4
4
  "WebFetch(domain:docs.github.com)"
5
5
  ]
6
- }
6
+ },
7
+ "advisorModel": "opus"
7
8
  }