overkill 0.7.0__tar.gz → 0.8.0__tar.gz
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.
- {overkill-0.7.0 → overkill-0.8.0}/.overkillrc.example +8 -0
- {overkill-0.7.0 → overkill-0.8.0}/PKG-INFO +7 -1
- {overkill-0.7.0 → overkill-0.8.0}/README.md +6 -0
- {overkill-0.7.0 → overkill-0.8.0}/pyproject.toml +1 -1
- {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/cli.py +30 -1
- {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/git_ops.py +43 -6
- {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/loop_engine.py +50 -1
- {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/models.py +9 -0
- {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/review_loop.py +13 -0
- {overkill-0.7.0 → overkill-0.8.0}/tests/test_cli.py +97 -0
- {overkill-0.7.0 → overkill-0.8.0}/tests/test_git_ops.py +75 -0
- {overkill-0.7.0 → overkill-0.8.0}/tests/test_loop_engine.py +120 -0
- overkill-0.8.0/tests/test_review_loop.py +225 -0
- {overkill-0.7.0 → overkill-0.8.0}/uv.lock +1 -1
- overkill-0.7.0/tests/test_review_loop.py +0 -66
- {overkill-0.7.0 → overkill-0.8.0}/.github/workflows/publish.yml +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/.github/workflows/test.yml +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/.gitignore +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/.python-version +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/.refactorsuggestrc.example +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/AGENTS.md +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/CLAUDE.md +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/GEMINI.md +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/LICENSE +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/install.sh +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/prompts/active/claude-fix-execute.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/prompts/active/claude-fix.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/prompts/active/claude-refactor-fix-execute.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/prompts/active/claude-refactor-fix.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/prompts/active/claude-refactor-full.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/prompts/active/claude-refactor-layer.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/prompts/active/claude-refactor-micro.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/prompts/active/claude-refactor-module.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/prompts/active/claude-review.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/prompts/active/claude-self-review.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/prompts/active/codex-refactor-full.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/prompts/active/codex-refactor-layer.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/prompts/active/codex-refactor-micro.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/prompts/active/codex-refactor-module.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/prompts/active/codex-review.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/prompts/active/gemini-refactor-full.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/prompts/active/gemini-refactor-layer.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/prompts/active/gemini-refactor-micro.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/prompts/active/gemini-refactor-module.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/prompts/active/gemini-review.prompt.md +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/prompts/reference/claude-code-review-plugin.md +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/prompts/reference/claude-security-review.md +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/prompts/reference/codex-review-original.md +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/__init__.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/__main__.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/agents.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/budget/__init__.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/budget/claude.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/budget/codex.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/budget/gemini.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/budget_report.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/classify.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/data/__init__.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/data/review.schema.json +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/init.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/json_extract.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/refactor_suggest.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/reporting.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/resume.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/retry.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/self_review.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/time_utils.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/two_step_fix.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/tests/__init__.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/tests/conftest.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/tests/test_agents.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/tests/test_budget_claude.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/tests/test_budget_codex.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/tests/test_budget_gemini.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/tests/test_budget_policy.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/tests/test_budget_report.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/tests/test_classify.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/tests/test_init.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/tests/test_integration.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/tests/test_json_extract.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/tests/test_refactor_suggest.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/tests/test_reporting.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/tests/test_resume.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/tests/test_retry.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/tests/test_self_review.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/tests/test_time_utils.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/tests/test_two_step_fix.py +0 -0
- {overkill-0.7.0 → overkill-0.8.0}/uninstall.sh +0 -0
|
@@ -17,6 +17,14 @@
|
|
|
17
17
|
# Enable auto-commit of fixes (default: true)
|
|
18
18
|
# AUTO_COMMIT=true
|
|
19
19
|
|
|
20
|
+
# CI trigger policy for iteration commits (default: last-only)
|
|
21
|
+
# last-only — append "[skip ci]" to each iteration commit; push a single
|
|
22
|
+
# empty "chore: trigger CI" commit only when the loop ends
|
|
23
|
+
# with all_clear (saves CI cost on long iteration runs)
|
|
24
|
+
# every — each iteration commit triggers CI (pre-0.3 behaviour)
|
|
25
|
+
# none — append "[skip ci]"; no trigger commit (forks / CI-less repos)
|
|
26
|
+
# CI_TRIGGER_MODE="last-only"
|
|
27
|
+
|
|
20
28
|
# Path to active prompt templates (default: <script_dir>/../prompts/active)
|
|
21
29
|
# PROMPTS_DIR="./custom-prompts"
|
|
22
30
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: overkill
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: AI-powered code review loop — automates Codex/Gemini review + Claude fix cycles
|
|
5
5
|
Project-URL: Repository, https://github.com/modocai/mr-overkill
|
|
6
6
|
Project-URL: Issues, https://github.com/modocai/mr-overkill/issues
|
|
@@ -125,6 +125,11 @@ Options:
|
|
|
125
125
|
--no-auto-commit Fix but do not commit/push (single iteration)
|
|
126
126
|
--resume Resume from a previously interrupted run (reuses existing logs)
|
|
127
127
|
--reviewer-backend <be> Reviewer backend: claude|codex (default: codex)
|
|
128
|
+
--ci-trigger-mode <m> CI trigger policy: every|last-only|none (default: last-only).
|
|
129
|
+
'last-only' tags each iteration commit with [skip ci]
|
|
130
|
+
and pushes a single empty trigger commit on PASS —
|
|
131
|
+
CI runs once instead of once per iteration.
|
|
132
|
+
Use 'every' to restore pre-0.3 per-commit CI.
|
|
128
133
|
--diagnostic-log Save full Claude event stream to sidecar files
|
|
129
134
|
|
|
130
135
|
Examples:
|
|
@@ -134,6 +139,7 @@ Examples:
|
|
|
134
139
|
overkill review-loop -n 3 --no-self-review # disable self-review sub-loop
|
|
135
140
|
overkill review-loop --resume # resume an interrupted run
|
|
136
141
|
overkill review-loop -n 2 --reviewer-backend claude # use Claude as reviewer
|
|
142
|
+
overkill review-loop -n 10 --ci-trigger-mode last-only # CI fires once on PASS
|
|
137
143
|
```
|
|
138
144
|
|
|
139
145
|
## Usage: overkill refactor-suggest
|
|
@@ -102,6 +102,11 @@ Options:
|
|
|
102
102
|
--no-auto-commit Fix but do not commit/push (single iteration)
|
|
103
103
|
--resume Resume from a previously interrupted run (reuses existing logs)
|
|
104
104
|
--reviewer-backend <be> Reviewer backend: claude|codex (default: codex)
|
|
105
|
+
--ci-trigger-mode <m> CI trigger policy: every|last-only|none (default: last-only).
|
|
106
|
+
'last-only' tags each iteration commit with [skip ci]
|
|
107
|
+
and pushes a single empty trigger commit on PASS —
|
|
108
|
+
CI runs once instead of once per iteration.
|
|
109
|
+
Use 'every' to restore pre-0.3 per-commit CI.
|
|
105
110
|
--diagnostic-log Save full Claude event stream to sidecar files
|
|
106
111
|
|
|
107
112
|
Examples:
|
|
@@ -111,6 +116,7 @@ Examples:
|
|
|
111
116
|
overkill review-loop -n 3 --no-self-review # disable self-review sub-loop
|
|
112
117
|
overkill review-loop --resume # resume an interrupted run
|
|
113
118
|
overkill review-loop -n 2 --reviewer-backend claude # use Claude as reviewer
|
|
119
|
+
overkill review-loop -n 10 --ci-trigger-mode last-only # CI fires once on PASS
|
|
114
120
|
```
|
|
115
121
|
|
|
116
122
|
## Usage: overkill refactor-suggest
|
|
@@ -108,7 +108,7 @@ def _load_rc_file(rc_name: str) -> dict[str, str]:
|
|
|
108
108
|
"RETRY_INITIAL_WAIT", "BUDGET_SCOPE", "DIAGNOSTIC_LOG",
|
|
109
109
|
"SCOPE", "AUTO_APPROVE", "CREATE_PR", "WITH_REVIEW",
|
|
110
110
|
"REVIEW_LOOPS", "FIX_NITS", "REVIEWER_BACKEND",
|
|
111
|
-
"REVIEWER_CONTEXT",
|
|
111
|
+
"REVIEWER_CONTEXT", "CI_TRIGGER_MODE",
|
|
112
112
|
}
|
|
113
113
|
boolean_keys = {
|
|
114
114
|
"DRY_RUN", "AUTO_COMMIT", "DIAGNOSTIC_LOG",
|
|
@@ -306,6 +306,18 @@ def parse_review_loop_args(
|
|
|
306
306
|
default=None,
|
|
307
307
|
help="Additional context for the reviewer (e.g. design intent, constraints)",
|
|
308
308
|
)
|
|
309
|
+
parser.add_argument(
|
|
310
|
+
"--ci-trigger-mode",
|
|
311
|
+
default=None,
|
|
312
|
+
choices=["every", "last-only", "none"],
|
|
313
|
+
help=(
|
|
314
|
+
"CI trigger policy for iteration commits. "
|
|
315
|
+
"'last-only' (default): append [skip ci] to iteration commits and "
|
|
316
|
+
"push a single empty 'chore: trigger CI' commit only on PASS. "
|
|
317
|
+
"'every': every commit triggers CI. "
|
|
318
|
+
"'none': append [skip ci] with no trigger commit."
|
|
319
|
+
),
|
|
320
|
+
)
|
|
309
321
|
|
|
310
322
|
args = parser.parse_args(argv)
|
|
311
323
|
|
|
@@ -405,6 +417,10 @@ def parse_review_loop_args(
|
|
|
405
417
|
saved = log_dir / "reviewer-context.txt"
|
|
406
418
|
if saved.is_file():
|
|
407
419
|
args.context = saved.read_text().strip()
|
|
420
|
+
if args.ci_trigger_mode is None:
|
|
421
|
+
saved = log_dir / "ci-trigger-mode.txt"
|
|
422
|
+
if saved.is_file():
|
|
423
|
+
args.ci_trigger_mode = saved.read_text().strip()
|
|
408
424
|
|
|
409
425
|
if max_loop is not None and max_loop < 1:
|
|
410
426
|
parser.error("--max-loop must be a positive integer")
|
|
@@ -420,6 +436,17 @@ def parse_review_loop_args(
|
|
|
420
436
|
args.context if args.context is not None else rc.get("REVIEWER_CONTEXT", "")
|
|
421
437
|
)
|
|
422
438
|
|
|
439
|
+
ci_trigger_mode = (
|
|
440
|
+
args.ci_trigger_mode
|
|
441
|
+
if args.ci_trigger_mode is not None
|
|
442
|
+
else rc.get("CI_TRIGGER_MODE", "last-only")
|
|
443
|
+
)
|
|
444
|
+
if ci_trigger_mode not in ("every", "last-only", "none"):
|
|
445
|
+
parser.error(
|
|
446
|
+
f"CI_TRIGGER_MODE must be 'every', 'last-only', or 'none',"
|
|
447
|
+
f" got {ci_trigger_mode!r}"
|
|
448
|
+
)
|
|
449
|
+
|
|
423
450
|
return LoopConfig(
|
|
424
451
|
current_branch=current_branch,
|
|
425
452
|
target_branch=target,
|
|
@@ -441,6 +468,7 @@ def parse_review_loop_args(
|
|
|
441
468
|
pr_number=pr_number,
|
|
442
469
|
reviewer_backend=reviewer_backend,
|
|
443
470
|
reviewer_context=reviewer_context,
|
|
471
|
+
ci_trigger_mode=ci_trigger_mode,
|
|
444
472
|
)
|
|
445
473
|
|
|
446
474
|
|
|
@@ -713,6 +741,7 @@ def parse_refactor_suggest_args(
|
|
|
713
741
|
prompts_dir=prompts_dir,
|
|
714
742
|
scope=scope,
|
|
715
743
|
reviewer_backend=reviewer_backend,
|
|
744
|
+
ci_trigger_mode="every",
|
|
716
745
|
)
|
|
717
746
|
|
|
718
747
|
extra = _RefactorExtra(
|
|
@@ -234,7 +234,12 @@ def commit_and_push(
|
|
|
234
234
|
|
|
235
235
|
logger.info("Committed.")
|
|
236
236
|
|
|
237
|
-
|
|
237
|
+
_push_current_branch(branch=branch, cwd=cwd)
|
|
238
|
+
return True
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _push_current_branch(branch: str = "", cwd: Path | None = None) -> None:
|
|
242
|
+
"""Push the current branch, setting upstream on first push if needed."""
|
|
238
243
|
upstream_check = _run(
|
|
239
244
|
["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
|
|
240
245
|
cwd=cwd,
|
|
@@ -244,7 +249,8 @@ def commit_and_push(
|
|
|
244
249
|
if push_result.returncode != 0:
|
|
245
250
|
raise RuntimeError(f"git push failed: {push_result.stderr.strip()}")
|
|
246
251
|
logger.info("Pushed.")
|
|
247
|
-
|
|
252
|
+
return
|
|
253
|
+
if branch:
|
|
248
254
|
remote_check = _run(["git", "remote"], cwd=cwd)
|
|
249
255
|
remotes = remote_check.stdout.strip().splitlines()
|
|
250
256
|
remote = "origin" if "origin" in remotes else (remotes[0] if remotes else "")
|
|
@@ -253,9 +259,40 @@ def commit_and_push(
|
|
|
253
259
|
if push_result.returncode != 0:
|
|
254
260
|
raise RuntimeError(f"git push failed: {push_result.stderr.strip()}")
|
|
255
261
|
logger.info("Pushed (upstream set).")
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
262
|
+
return
|
|
263
|
+
logger.info("No upstream/remote set — skipping push.")
|
|
264
|
+
return
|
|
265
|
+
logger.info("No upstream set — skipping push.")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def push_trigger_commit(branch: str = "", cwd: Path | None = None) -> bool:
|
|
269
|
+
"""Push an empty ``chore: trigger CI`` commit.
|
|
270
|
+
|
|
271
|
+
Used by ``--ci-trigger-mode last-only`` to fire CI once after a run of
|
|
272
|
+
iteration commits that were each tagged with ``[skip ci]``. Returns
|
|
273
|
+
``True`` on success, ``False`` if the empty commit could not be created
|
|
274
|
+
(e.g. repo has no HEAD yet). Raises ``RuntimeError`` if ``git push`` fails.
|
|
275
|
+
|
|
276
|
+
Idempotent: if HEAD already lacks ``[skip ci]`` (a prior call succeeded
|
|
277
|
+
locally but the push may not have reached the remote), skip creating
|
|
278
|
+
another empty commit and just retry the push.
|
|
279
|
+
"""
|
|
280
|
+
head_msg = _run(["git", "log", "-1", "--pretty=%B"], cwd=cwd).stdout
|
|
281
|
+
if head_msg and "[skip ci]" not in head_msg:
|
|
282
|
+
logger.info("HEAD already triggers CI — retrying push only.")
|
|
283
|
+
_push_current_branch(branch=branch, cwd=cwd)
|
|
284
|
+
return True
|
|
260
285
|
|
|
286
|
+
result = _run(
|
|
287
|
+
["git", "commit", "--allow-empty", "-m", "chore: trigger CI"],
|
|
288
|
+
cwd=cwd,
|
|
289
|
+
)
|
|
290
|
+
if result.returncode != 0:
|
|
291
|
+
logger.warning(
|
|
292
|
+
"trigger commit failed: %s",
|
|
293
|
+
result.stderr.strip() or result.stdout.strip(),
|
|
294
|
+
)
|
|
295
|
+
return False
|
|
296
|
+
logger.info("Created CI trigger commit.")
|
|
297
|
+
_push_current_branch(branch=branch, cwd=cwd)
|
|
261
298
|
return True
|
|
@@ -218,10 +218,20 @@ def review_fix_loop(
|
|
|
218
218
|
# Already-completed runs can short-circuit after branch validation
|
|
219
219
|
if state.status == "completed":
|
|
220
220
|
resolved_status = FinalStatus(state.prev_status or "max_iterations_reached")
|
|
221
|
+
# Re-attempt the CI trigger push: a prior run may have written
|
|
222
|
+
# the terminal summary but failed (or been interrupted) before
|
|
223
|
+
# pushing the trigger commit, leaving the remote on a [skip ci]
|
|
224
|
+
# commit. push_trigger_commit is idempotent.
|
|
225
|
+
needs_trigger = (
|
|
226
|
+
resolved_status == FinalStatus.ALL_CLEAR
|
|
227
|
+
and config.ci_trigger_mode in ("last-only", "none")
|
|
228
|
+
and _has_skipped_fix_commit(commit_pattern, log_dir, cwd)
|
|
229
|
+
)
|
|
221
230
|
return LoopResult(
|
|
222
231
|
final_status=resolved_status,
|
|
223
232
|
iterations_run=0,
|
|
224
233
|
summary_path=_generate_summary_safe(config, resolved_status),
|
|
234
|
+
made_skipped_fix_commit=needs_trigger,
|
|
225
235
|
)
|
|
226
236
|
|
|
227
237
|
resume_from = state.resume_from
|
|
@@ -285,6 +295,14 @@ def review_fix_loop(
|
|
|
285
295
|
allowed_stashed = False
|
|
286
296
|
had_findings = False
|
|
287
297
|
fix_committed = False
|
|
298
|
+
# On resume, prior iteration commits already exist; in last-only/none
|
|
299
|
+
# modes those carry [skip ci], so a final trigger commit is still needed
|
|
300
|
+
# even if no new fix commit is made in this process.
|
|
301
|
+
made_skipped_fix_commit = (
|
|
302
|
+
config.resume
|
|
303
|
+
and resume_from > 1
|
|
304
|
+
and config.ci_trigger_mode in ("last-only", "none")
|
|
305
|
+
)
|
|
288
306
|
|
|
289
307
|
for i in range(1, config.max_loop + 1):
|
|
290
308
|
logger.info("── Iteration %d / %d ──", i, config.max_loop)
|
|
@@ -489,8 +507,11 @@ def review_fix_loop(
|
|
|
489
507
|
|
|
490
508
|
# j. Commit & push
|
|
491
509
|
if config.auto_commit:
|
|
510
|
+
subject = f"{commit_pattern} {i} fixes"
|
|
511
|
+
if config.ci_trigger_mode in ("last-only", "none"):
|
|
512
|
+
subject += " [skip ci]"
|
|
492
513
|
commit_msg = (
|
|
493
|
-
f"{
|
|
514
|
+
f"{subject}\n\n"
|
|
494
515
|
f"Auto-generated by review loop (iteration {i}/{config.max_loop})"
|
|
495
516
|
)
|
|
496
517
|
if self_review_summary:
|
|
@@ -509,6 +530,8 @@ def review_fix_loop(
|
|
|
509
530
|
final_status = FinalStatus.COMMIT_PUSH_ERROR
|
|
510
531
|
iterations_run = i
|
|
511
532
|
break
|
|
533
|
+
if fix_committed and config.ci_trigger_mode in ("last-only", "none"):
|
|
534
|
+
made_skipped_fix_commit = True
|
|
512
535
|
else:
|
|
513
536
|
logger.info("AUTO_COMMIT is disabled — skipping commit and push.")
|
|
514
537
|
finally:
|
|
@@ -552,12 +575,37 @@ def review_fix_loop(
|
|
|
552
575
|
final_status=final_status,
|
|
553
576
|
iterations_run=iterations_run,
|
|
554
577
|
summary_path=summary_path,
|
|
578
|
+
made_skipped_fix_commit=made_skipped_fix_commit,
|
|
555
579
|
)
|
|
556
580
|
|
|
557
581
|
|
|
558
582
|
# ── Private helpers ──────────────────────────────────────────────────
|
|
559
583
|
|
|
560
584
|
|
|
585
|
+
def _has_skipped_fix_commit(
|
|
586
|
+
commit_pattern: str, log_dir: Path, cwd: Path | None
|
|
587
|
+
) -> bool:
|
|
588
|
+
"""Return True if any [skip ci] fix commit exists since the run started."""
|
|
589
|
+
start_file = log_dir / "start-commit.txt"
|
|
590
|
+
if not start_file.is_file():
|
|
591
|
+
return False
|
|
592
|
+
start = start_file.read_text().strip()
|
|
593
|
+
if not start:
|
|
594
|
+
return False
|
|
595
|
+
result = subprocess.run(
|
|
596
|
+
[
|
|
597
|
+
"git", "log", "--fixed-strings",
|
|
598
|
+
f"--grep={commit_pattern}", "--grep=[skip ci]", "--all-match",
|
|
599
|
+
"--oneline", f"{start}..HEAD",
|
|
600
|
+
],
|
|
601
|
+
cwd=cwd,
|
|
602
|
+
capture_output=True,
|
|
603
|
+
text=True,
|
|
604
|
+
check=False,
|
|
605
|
+
)
|
|
606
|
+
return result.returncode == 0 and bool(result.stdout.strip())
|
|
607
|
+
|
|
608
|
+
|
|
561
609
|
def _validate_target_branch(target: str, cwd: Path | None) -> bool:
|
|
562
610
|
"""Return True if *target* is a valid git ref."""
|
|
563
611
|
result = subprocess.run(
|
|
@@ -636,6 +684,7 @@ def _save_metadata(config: LoopConfig, cwd: Path | None) -> None:
|
|
|
636
684
|
(log_dir / "max-loop.txt").write_text(str(config.max_loop))
|
|
637
685
|
(log_dir / "reviewer-backend.txt").write_text(config.reviewer_backend)
|
|
638
686
|
(log_dir / "reviewer-context.txt").write_text(config.reviewer_context)
|
|
687
|
+
(log_dir / "ci-trigger-mode.txt").write_text(config.ci_trigger_mode)
|
|
639
688
|
if config.scope:
|
|
640
689
|
(log_dir / "scope.txt").write_text(config.scope)
|
|
641
690
|
|
|
@@ -162,6 +162,14 @@ class LoopConfig:
|
|
|
162
162
|
resume: bool = False
|
|
163
163
|
auto_approve: bool = False
|
|
164
164
|
|
|
165
|
+
# CI trigger policy for iteration commits:
|
|
166
|
+
# "last-only" — default: append "[skip ci]" to iteration commits; push a
|
|
167
|
+
# single empty "chore: trigger CI" commit only on ALL_CLEAR.
|
|
168
|
+
# "every" — each commit triggers CI (pre-0.3 behaviour).
|
|
169
|
+
# "none" — append "[skip ci]" to iteration commits; never emit a
|
|
170
|
+
# trigger commit (forks / CI-less repos).
|
|
171
|
+
ci_trigger_mode: str = "last-only"
|
|
172
|
+
|
|
165
173
|
# Retry / budget
|
|
166
174
|
retry_max_wait: int = 7200
|
|
167
175
|
retry_initial_wait: int = 30
|
|
@@ -193,6 +201,7 @@ class LoopResult:
|
|
|
193
201
|
final_status: FinalStatus
|
|
194
202
|
iterations_run: int
|
|
195
203
|
summary_path: Path | None = None
|
|
204
|
+
made_skipped_fix_commit: bool = False
|
|
196
205
|
|
|
197
206
|
|
|
198
207
|
# ── Protocols (DI contracts) ─────────────────────────────────────────
|
|
@@ -9,6 +9,7 @@ from mr_overkill.agents import (
|
|
|
9
9
|
create_review_agent,
|
|
10
10
|
create_self_review_agent,
|
|
11
11
|
)
|
|
12
|
+
from mr_overkill.git_ops import push_trigger_commit
|
|
12
13
|
from mr_overkill.loop_engine import review_fix_loop
|
|
13
14
|
from mr_overkill.models import (
|
|
14
15
|
FinalStatus,
|
|
@@ -42,6 +43,18 @@ def run(config: LoopConfig) -> int:
|
|
|
42
43
|
if result.summary_path:
|
|
43
44
|
logger.info("Summary: %s", result.summary_path)
|
|
44
45
|
|
|
46
|
+
if (
|
|
47
|
+
result.final_status == FinalStatus.ALL_CLEAR
|
|
48
|
+
and config.ci_trigger_mode == "last-only"
|
|
49
|
+
and config.auto_commit
|
|
50
|
+
and not config.dry_run
|
|
51
|
+
and result.made_skipped_fix_commit
|
|
52
|
+
):
|
|
53
|
+
try:
|
|
54
|
+
push_trigger_commit(branch=config.current_branch)
|
|
55
|
+
except RuntimeError as exc:
|
|
56
|
+
logger.warning("Could not push CI trigger commit: %s", exc)
|
|
57
|
+
|
|
45
58
|
success_statuses = {
|
|
46
59
|
FinalStatus.ALL_CLEAR,
|
|
47
60
|
FinalStatus.DRY_RUN,
|
|
@@ -316,6 +316,100 @@ class TestParseReviewLoopArgs:
|
|
|
316
316
|
])
|
|
317
317
|
assert config.reviewer_backend == "gemini"
|
|
318
318
|
|
|
319
|
+
@patch("mr_overkill.cli._detect_pr_number", return_value=None)
|
|
320
|
+
@patch("mr_overkill.cli._detect_current_branch", return_value="feat/x")
|
|
321
|
+
@patch("mr_overkill.cli._load_rc_file", return_value={})
|
|
322
|
+
@patch("mr_overkill.cli.subprocess.run")
|
|
323
|
+
def test_ci_trigger_mode_default(
|
|
324
|
+
self,
|
|
325
|
+
mock_run: MagicMock,
|
|
326
|
+
mock_rc: MagicMock,
|
|
327
|
+
mock_branch: MagicMock,
|
|
328
|
+
mock_pr: MagicMock,
|
|
329
|
+
) -> None:
|
|
330
|
+
mock_run.return_value = MagicMock(returncode=0, stdout="/tmp/repo")
|
|
331
|
+
config = parse_review_loop_args(["-n", "1"])
|
|
332
|
+
assert config.ci_trigger_mode == "last-only"
|
|
333
|
+
|
|
334
|
+
@patch("mr_overkill.cli._detect_pr_number", return_value=None)
|
|
335
|
+
@patch("mr_overkill.cli._detect_current_branch", return_value="feat/x")
|
|
336
|
+
@patch("mr_overkill.cli._load_rc_file", return_value={})
|
|
337
|
+
@patch("mr_overkill.cli.subprocess.run")
|
|
338
|
+
def test_ci_trigger_mode_cli(
|
|
339
|
+
self,
|
|
340
|
+
mock_run: MagicMock,
|
|
341
|
+
mock_rc: MagicMock,
|
|
342
|
+
mock_branch: MagicMock,
|
|
343
|
+
mock_pr: MagicMock,
|
|
344
|
+
) -> None:
|
|
345
|
+
mock_run.return_value = MagicMock(returncode=0, stdout="/tmp/repo")
|
|
346
|
+
config = parse_review_loop_args([
|
|
347
|
+
"-n", "1", "--ci-trigger-mode", "last-only",
|
|
348
|
+
])
|
|
349
|
+
assert config.ci_trigger_mode == "last-only"
|
|
350
|
+
|
|
351
|
+
@patch("mr_overkill.cli._detect_pr_number", return_value=None)
|
|
352
|
+
@patch("mr_overkill.cli._detect_current_branch", return_value="feat/x")
|
|
353
|
+
@patch(
|
|
354
|
+
"mr_overkill.cli._load_rc_file",
|
|
355
|
+
return_value={"CI_TRIGGER_MODE": "none"},
|
|
356
|
+
)
|
|
357
|
+
@patch("mr_overkill.cli.subprocess.run")
|
|
358
|
+
def test_ci_trigger_mode_rc(
|
|
359
|
+
self,
|
|
360
|
+
mock_run: MagicMock,
|
|
361
|
+
mock_rc: MagicMock,
|
|
362
|
+
mock_branch: MagicMock,
|
|
363
|
+
mock_pr: MagicMock,
|
|
364
|
+
) -> None:
|
|
365
|
+
mock_run.return_value = MagicMock(returncode=0, stdout="/tmp/repo")
|
|
366
|
+
config = parse_review_loop_args(["-n", "1"])
|
|
367
|
+
assert config.ci_trigger_mode == "none"
|
|
368
|
+
|
|
369
|
+
@patch("mr_overkill.cli._detect_pr_number", return_value=None)
|
|
370
|
+
@patch("mr_overkill.cli._detect_current_branch", return_value="feat/x")
|
|
371
|
+
@patch(
|
|
372
|
+
"mr_overkill.cli._load_rc_file",
|
|
373
|
+
return_value={"CI_TRIGGER_MODE": "none"},
|
|
374
|
+
)
|
|
375
|
+
@patch("mr_overkill.cli.subprocess.run")
|
|
376
|
+
def test_ci_trigger_mode_cli_overrides_rc(
|
|
377
|
+
self,
|
|
378
|
+
mock_run: MagicMock,
|
|
379
|
+
mock_rc: MagicMock,
|
|
380
|
+
mock_branch: MagicMock,
|
|
381
|
+
mock_pr: MagicMock,
|
|
382
|
+
) -> None:
|
|
383
|
+
mock_run.return_value = MagicMock(returncode=0, stdout="/tmp/repo")
|
|
384
|
+
config = parse_review_loop_args([
|
|
385
|
+
"-n", "1", "--ci-trigger-mode", "every",
|
|
386
|
+
])
|
|
387
|
+
assert config.ci_trigger_mode == "every"
|
|
388
|
+
|
|
389
|
+
@patch("mr_overkill.cli._detect_pr_number", return_value=None)
|
|
390
|
+
@patch("mr_overkill.cli._detect_current_branch", return_value="feat/x")
|
|
391
|
+
@patch(
|
|
392
|
+
"mr_overkill.cli._load_rc_file",
|
|
393
|
+
return_value={"CI_TRIGGER_MODE": "bogus"},
|
|
394
|
+
)
|
|
395
|
+
@patch("mr_overkill.cli.subprocess.run")
|
|
396
|
+
def test_ci_trigger_mode_invalid_rc(
|
|
397
|
+
self,
|
|
398
|
+
mock_run: MagicMock,
|
|
399
|
+
mock_rc: MagicMock,
|
|
400
|
+
mock_branch: MagicMock,
|
|
401
|
+
mock_pr: MagicMock,
|
|
402
|
+
) -> None:
|
|
403
|
+
mock_run.return_value = MagicMock(returncode=0, stdout="/tmp/repo")
|
|
404
|
+
with pytest.raises(SystemExit):
|
|
405
|
+
parse_review_loop_args(["-n", "1"])
|
|
406
|
+
|
|
407
|
+
def test_ci_trigger_mode_invalid_cli(self) -> None:
|
|
408
|
+
with pytest.raises(SystemExit):
|
|
409
|
+
parse_review_loop_args([
|
|
410
|
+
"-n", "1", "--ci-trigger-mode", "bogus",
|
|
411
|
+
])
|
|
412
|
+
|
|
319
413
|
|
|
320
414
|
class TestParseRefactorSuggestArgs:
|
|
321
415
|
@patch("mr_overkill.cli._detect_current_branch", return_value="feat/x")
|
|
@@ -336,6 +430,9 @@ class TestParseRefactorSuggestArgs:
|
|
|
336
430
|
assert config.max_loop == 1
|
|
337
431
|
assert extra.create_pr is False
|
|
338
432
|
assert extra.with_review is False
|
|
433
|
+
# refactor-suggest does not push a trigger commit, so iteration
|
|
434
|
+
# commits must trigger CI directly — never inherit "last-only".
|
|
435
|
+
assert config.ci_trigger_mode == "every"
|
|
339
436
|
|
|
340
437
|
@patch("mr_overkill.cli._detect_current_branch", return_value="feat/x")
|
|
341
438
|
@patch("mr_overkill.cli._load_rc_file", return_value={})
|
|
@@ -4,12 +4,14 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import subprocess
|
|
6
6
|
from pathlib import Path
|
|
7
|
+
from unittest.mock import MagicMock, patch
|
|
7
8
|
|
|
8
9
|
from mr_overkill.git_ops import (
|
|
9
10
|
changed_files_since_snapshot,
|
|
10
11
|
diff_hash,
|
|
11
12
|
gen_uuid,
|
|
12
13
|
git_all_dirty,
|
|
14
|
+
push_trigger_commit,
|
|
13
15
|
sha256,
|
|
14
16
|
snapshot_worktree,
|
|
15
17
|
)
|
|
@@ -126,3 +128,76 @@ class TestChangedFilesSinceSnapshot:
|
|
|
126
128
|
)
|
|
127
129
|
assert "src.txt" in changed
|
|
128
130
|
assert not any(f.startswith("logs/") for f in changed)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class TestPushTriggerCommit:
|
|
134
|
+
"""Empty trigger commit used by ``--ci-trigger-mode last-only``."""
|
|
135
|
+
|
|
136
|
+
@patch("mr_overkill.git_ops._run")
|
|
137
|
+
def test_creates_empty_commit_and_pushes(self, mock_run: MagicMock) -> None:
|
|
138
|
+
# 1) HEAD message has [skip ci], 2) commit succeeds,
|
|
139
|
+
# 3) upstream check succeeds, 4) push succeeds
|
|
140
|
+
mock_run.side_effect = [
|
|
141
|
+
MagicMock(returncode=0, stdout="fix: something [skip ci]\n", stderr=""),
|
|
142
|
+
MagicMock(returncode=0, stdout="", stderr=""),
|
|
143
|
+
MagicMock(returncode=0, stdout="origin/feat/x", stderr=""),
|
|
144
|
+
MagicMock(returncode=0, stdout="", stderr=""),
|
|
145
|
+
]
|
|
146
|
+
assert push_trigger_commit() is True
|
|
147
|
+
|
|
148
|
+
commit_call = mock_run.call_args_list[1]
|
|
149
|
+
assert commit_call.args[0] == [
|
|
150
|
+
"git", "commit", "--allow-empty", "-m", "chore: trigger CI",
|
|
151
|
+
]
|
|
152
|
+
push_call = mock_run.call_args_list[3]
|
|
153
|
+
assert push_call.args[0] == ["git", "push"]
|
|
154
|
+
|
|
155
|
+
@patch("mr_overkill.git_ops._run")
|
|
156
|
+
def test_commit_failure_returns_false_without_push(
|
|
157
|
+
self, mock_run: MagicMock,
|
|
158
|
+
) -> None:
|
|
159
|
+
# HEAD has [skip ci]; then commit attempt fails — no push should follow.
|
|
160
|
+
mock_run.side_effect = [
|
|
161
|
+
MagicMock(returncode=0, stdout="fix: something [skip ci]\n", stderr=""),
|
|
162
|
+
MagicMock(returncode=1, stdout="", stderr="nothing to commit"),
|
|
163
|
+
]
|
|
164
|
+
assert push_trigger_commit() is False
|
|
165
|
+
assert mock_run.call_count == 2
|
|
166
|
+
|
|
167
|
+
@patch("mr_overkill.git_ops._run")
|
|
168
|
+
def test_sets_upstream_when_no_upstream_yet(
|
|
169
|
+
self, mock_run: MagicMock,
|
|
170
|
+
) -> None:
|
|
171
|
+
# HEAD has [skip ci], commit ok, upstream check fails,
|
|
172
|
+
# remote check returns 'origin', push -u ok
|
|
173
|
+
mock_run.side_effect = [
|
|
174
|
+
MagicMock(returncode=0, stdout="fix: x [skip ci]\n", stderr=""),
|
|
175
|
+
MagicMock(returncode=0, stdout="", stderr=""),
|
|
176
|
+
MagicMock(returncode=128, stdout="", stderr="no upstream"),
|
|
177
|
+
MagicMock(returncode=0, stdout="origin\n", stderr=""),
|
|
178
|
+
MagicMock(returncode=0, stdout="", stderr=""),
|
|
179
|
+
]
|
|
180
|
+
assert push_trigger_commit(branch="feat/x") is True
|
|
181
|
+
push_call = mock_run.call_args_list[4]
|
|
182
|
+
assert push_call.args[0] == [
|
|
183
|
+
"git", "push", "-u", "origin", "feat/x",
|
|
184
|
+
]
|
|
185
|
+
|
|
186
|
+
@patch("mr_overkill.git_ops._run")
|
|
187
|
+
def test_skips_extra_commit_when_head_already_triggers_ci(
|
|
188
|
+
self, mock_run: MagicMock,
|
|
189
|
+
) -> None:
|
|
190
|
+
# HEAD lacks [skip ci] (trigger commit already exists from a prior
|
|
191
|
+
# run that failed before pushing); only the push should be retried.
|
|
192
|
+
mock_run.side_effect = [
|
|
193
|
+
MagicMock(returncode=0, stdout="chore: trigger CI\n", stderr=""),
|
|
194
|
+
MagicMock(returncode=0, stdout="origin/feat/x", stderr=""),
|
|
195
|
+
MagicMock(returncode=0, stdout="", stderr=""),
|
|
196
|
+
]
|
|
197
|
+
assert push_trigger_commit() is True
|
|
198
|
+
# No new commit attempted; HEAD check + upstream check + push only.
|
|
199
|
+
assert mock_run.call_count == 3
|
|
200
|
+
assert mock_run.call_args_list[1].args[0] == [
|
|
201
|
+
"git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}",
|
|
202
|
+
]
|
|
203
|
+
assert mock_run.call_args_list[2].args[0] == ["git", "push"]
|