claude-dev-env 8.19.2 → 8.21.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 +29 -0
- package/bin/install.settings-defaults.test.mjs +40 -0
- package/bin/merge_settings_defaults.mjs +43 -0
- package/package.json +1 -1
- package/rules/agent-merges-its-own-green-pull-request.md +2 -1
- package/rules/review-closure-is-a-check.md +2 -0
- package/scripts/agent_merge_check.py +126 -9
- package/scripts/dev_env_scripts_constants/agent_merge_check_constants.py +40 -0
- package/scripts/dev_env_scripts_constants/review_closure_constants.py +6 -0
- package/scripts/review_closure_model.py +25 -5
- package/scripts/test_agent_merge_check.py +172 -0
- package/scripts/tests/test_review_closure.py +1 -0
- package/scripts/tests/test_review_closure_model.py +49 -4
- package/settings.json +2 -1
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,39 @@ 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: 'fable' });
|
|
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: 'fable' }), {
|
|
267
|
+
addedKeys: ['advisorModel'],
|
|
268
|
+
});
|
|
269
|
+
assert.equal(emptySettings.advisorModel, 'fable');
|
|
270
|
+
const userSettings = { advisorModel: 'sonnet' };
|
|
271
|
+
assert.deepEqual(mergeMissingSettingsDefaults(userSettings, { advisorModel: 'fable' }), {
|
|
272
|
+
addedKeys: [],
|
|
273
|
+
});
|
|
274
|
+
assert.equal(userSettings.advisorModel, 'sonnet');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('install adds advisorModel when missing and keeps a user choice on rerun', () => {
|
|
278
|
+
const sandboxHome = mkdtempSync(join(tmpdir(), 'cde-settings-defaults-'));
|
|
279
|
+
try {
|
|
280
|
+
const firstRun = runInstallerInSandbox(sandboxHome);
|
|
281
|
+
assert.equal(firstRun.status, 0, firstRun.stderr);
|
|
282
|
+
const settingsPath = join(sandboxHome, '.claude', 'settings.json');
|
|
283
|
+
assert.equal(JSON.parse(readFileSync(settingsPath, 'utf8')).advisorModel, 'fable');
|
|
284
|
+
const userChoice = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
285
|
+
userChoice.advisorModel = 'sonnet';
|
|
286
|
+
writeFileSync(settingsPath, JSON.stringify(userChoice, null, 4) + '\n');
|
|
287
|
+
const secondRun = runInstallerInSandbox(sandboxHome);
|
|
288
|
+
assert.equal(secondRun.status, 0, secondRun.stderr);
|
|
289
|
+
assert.equal(JSON.parse(readFileSync(settingsPath, 'utf8')).advisorModel, 'sonnet');
|
|
290
|
+
} finally {
|
|
291
|
+
rmSync(sandboxHome, { recursive: true, force: true });
|
|
292
|
+
}
|
|
293
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const MANAGED_LIST_SETTING_KEYS = Object.freeze(['permissions']);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Read the setting defaults from a package settings source object.
|
|
13
|
+
*
|
|
14
|
+
* @param {Record<string, unknown> | null | undefined} packageSettings
|
|
15
|
+
* @returns {Record<string, unknown>}
|
|
16
|
+
*/
|
|
17
|
+
export function settingsDefaultsFromPackageSettings(packageSettings) {
|
|
18
|
+
if (!packageSettings || typeof packageSettings !== 'object' || Array.isArray(packageSettings)) {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
return Object.fromEntries(
|
|
22
|
+
Object.entries(packageSettings).filter(
|
|
23
|
+
([settingKey]) => !MANAGED_LIST_SETTING_KEYS.includes(settingKey),
|
|
24
|
+
),
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Add each default whose key the target settings lack.
|
|
30
|
+
*
|
|
31
|
+
* @param {Record<string, unknown>} targetSettings Mutated in place.
|
|
32
|
+
* @param {Record<string, unknown>} settingsDefaults
|
|
33
|
+
* @returns {{addedKeys: string[]}}
|
|
34
|
+
*/
|
|
35
|
+
export function mergeMissingSettingsDefaults(targetSettings, settingsDefaults) {
|
|
36
|
+
const addedKeys = [];
|
|
37
|
+
for (const [settingKey, defaultValue] of Object.entries(settingsDefaults)) {
|
|
38
|
+
if (Object.hasOwn(targetSettings, settingKey)) continue;
|
|
39
|
+
targetSettings[settingKey] = defaultValue;
|
|
40
|
+
addedKeys.push(settingKey);
|
|
41
|
+
}
|
|
42
|
+
return { addedKeys };
|
|
43
|
+
}
|
package/package.json
CHANGED
|
@@ -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,
|
|
196
|
-
for an open review thread. None
|
|
197
|
-
the agent that drives it to merge
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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 ->
|
|
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
|
-
|
|
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
|
|
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
|
|
239
|
-
|
|
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
|
+
)
|
|
@@ -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
|
|
361
|
+
def should_reopen_a_person_comment_edited_after_the_driver_reply() -> None:
|
|
359
362
|
all_comments = (
|
|
360
|
-
top_level_comment(
|
|
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
|
|
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)
|