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.
Files changed (88) hide show
  1. {overkill-0.7.0 → overkill-0.8.0}/.overkillrc.example +8 -0
  2. {overkill-0.7.0 → overkill-0.8.0}/PKG-INFO +7 -1
  3. {overkill-0.7.0 → overkill-0.8.0}/README.md +6 -0
  4. {overkill-0.7.0 → overkill-0.8.0}/pyproject.toml +1 -1
  5. {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/cli.py +30 -1
  6. {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/git_ops.py +43 -6
  7. {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/loop_engine.py +50 -1
  8. {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/models.py +9 -0
  9. {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/review_loop.py +13 -0
  10. {overkill-0.7.0 → overkill-0.8.0}/tests/test_cli.py +97 -0
  11. {overkill-0.7.0 → overkill-0.8.0}/tests/test_git_ops.py +75 -0
  12. {overkill-0.7.0 → overkill-0.8.0}/tests/test_loop_engine.py +120 -0
  13. overkill-0.8.0/tests/test_review_loop.py +225 -0
  14. {overkill-0.7.0 → overkill-0.8.0}/uv.lock +1 -1
  15. overkill-0.7.0/tests/test_review_loop.py +0 -66
  16. {overkill-0.7.0 → overkill-0.8.0}/.github/workflows/publish.yml +0 -0
  17. {overkill-0.7.0 → overkill-0.8.0}/.github/workflows/test.yml +0 -0
  18. {overkill-0.7.0 → overkill-0.8.0}/.gitignore +0 -0
  19. {overkill-0.7.0 → overkill-0.8.0}/.python-version +0 -0
  20. {overkill-0.7.0 → overkill-0.8.0}/.refactorsuggestrc.example +0 -0
  21. {overkill-0.7.0 → overkill-0.8.0}/AGENTS.md +0 -0
  22. {overkill-0.7.0 → overkill-0.8.0}/CLAUDE.md +0 -0
  23. {overkill-0.7.0 → overkill-0.8.0}/GEMINI.md +0 -0
  24. {overkill-0.7.0 → overkill-0.8.0}/LICENSE +0 -0
  25. {overkill-0.7.0 → overkill-0.8.0}/install.sh +0 -0
  26. {overkill-0.7.0 → overkill-0.8.0}/prompts/active/claude-fix-execute.prompt.md +0 -0
  27. {overkill-0.7.0 → overkill-0.8.0}/prompts/active/claude-fix.prompt.md +0 -0
  28. {overkill-0.7.0 → overkill-0.8.0}/prompts/active/claude-refactor-fix-execute.prompt.md +0 -0
  29. {overkill-0.7.0 → overkill-0.8.0}/prompts/active/claude-refactor-fix.prompt.md +0 -0
  30. {overkill-0.7.0 → overkill-0.8.0}/prompts/active/claude-refactor-full.prompt.md +0 -0
  31. {overkill-0.7.0 → overkill-0.8.0}/prompts/active/claude-refactor-layer.prompt.md +0 -0
  32. {overkill-0.7.0 → overkill-0.8.0}/prompts/active/claude-refactor-micro.prompt.md +0 -0
  33. {overkill-0.7.0 → overkill-0.8.0}/prompts/active/claude-refactor-module.prompt.md +0 -0
  34. {overkill-0.7.0 → overkill-0.8.0}/prompts/active/claude-review.prompt.md +0 -0
  35. {overkill-0.7.0 → overkill-0.8.0}/prompts/active/claude-self-review.prompt.md +0 -0
  36. {overkill-0.7.0 → overkill-0.8.0}/prompts/active/codex-refactor-full.prompt.md +0 -0
  37. {overkill-0.7.0 → overkill-0.8.0}/prompts/active/codex-refactor-layer.prompt.md +0 -0
  38. {overkill-0.7.0 → overkill-0.8.0}/prompts/active/codex-refactor-micro.prompt.md +0 -0
  39. {overkill-0.7.0 → overkill-0.8.0}/prompts/active/codex-refactor-module.prompt.md +0 -0
  40. {overkill-0.7.0 → overkill-0.8.0}/prompts/active/codex-review.prompt.md +0 -0
  41. {overkill-0.7.0 → overkill-0.8.0}/prompts/active/gemini-refactor-full.prompt.md +0 -0
  42. {overkill-0.7.0 → overkill-0.8.0}/prompts/active/gemini-refactor-layer.prompt.md +0 -0
  43. {overkill-0.7.0 → overkill-0.8.0}/prompts/active/gemini-refactor-micro.prompt.md +0 -0
  44. {overkill-0.7.0 → overkill-0.8.0}/prompts/active/gemini-refactor-module.prompt.md +0 -0
  45. {overkill-0.7.0 → overkill-0.8.0}/prompts/active/gemini-review.prompt.md +0 -0
  46. {overkill-0.7.0 → overkill-0.8.0}/prompts/reference/claude-code-review-plugin.md +0 -0
  47. {overkill-0.7.0 → overkill-0.8.0}/prompts/reference/claude-security-review.md +0 -0
  48. {overkill-0.7.0 → overkill-0.8.0}/prompts/reference/codex-review-original.md +0 -0
  49. {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/__init__.py +0 -0
  50. {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/__main__.py +0 -0
  51. {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/agents.py +0 -0
  52. {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/budget/__init__.py +0 -0
  53. {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/budget/claude.py +0 -0
  54. {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/budget/codex.py +0 -0
  55. {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/budget/gemini.py +0 -0
  56. {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/budget_report.py +0 -0
  57. {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/classify.py +0 -0
  58. {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/data/__init__.py +0 -0
  59. {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/data/review.schema.json +0 -0
  60. {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/init.py +0 -0
  61. {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/json_extract.py +0 -0
  62. {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/refactor_suggest.py +0 -0
  63. {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/reporting.py +0 -0
  64. {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/resume.py +0 -0
  65. {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/retry.py +0 -0
  66. {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/self_review.py +0 -0
  67. {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/time_utils.py +0 -0
  68. {overkill-0.7.0 → overkill-0.8.0}/src/mr_overkill/two_step_fix.py +0 -0
  69. {overkill-0.7.0 → overkill-0.8.0}/tests/__init__.py +0 -0
  70. {overkill-0.7.0 → overkill-0.8.0}/tests/conftest.py +0 -0
  71. {overkill-0.7.0 → overkill-0.8.0}/tests/test_agents.py +0 -0
  72. {overkill-0.7.0 → overkill-0.8.0}/tests/test_budget_claude.py +0 -0
  73. {overkill-0.7.0 → overkill-0.8.0}/tests/test_budget_codex.py +0 -0
  74. {overkill-0.7.0 → overkill-0.8.0}/tests/test_budget_gemini.py +0 -0
  75. {overkill-0.7.0 → overkill-0.8.0}/tests/test_budget_policy.py +0 -0
  76. {overkill-0.7.0 → overkill-0.8.0}/tests/test_budget_report.py +0 -0
  77. {overkill-0.7.0 → overkill-0.8.0}/tests/test_classify.py +0 -0
  78. {overkill-0.7.0 → overkill-0.8.0}/tests/test_init.py +0 -0
  79. {overkill-0.7.0 → overkill-0.8.0}/tests/test_integration.py +0 -0
  80. {overkill-0.7.0 → overkill-0.8.0}/tests/test_json_extract.py +0 -0
  81. {overkill-0.7.0 → overkill-0.8.0}/tests/test_refactor_suggest.py +0 -0
  82. {overkill-0.7.0 → overkill-0.8.0}/tests/test_reporting.py +0 -0
  83. {overkill-0.7.0 → overkill-0.8.0}/tests/test_resume.py +0 -0
  84. {overkill-0.7.0 → overkill-0.8.0}/tests/test_retry.py +0 -0
  85. {overkill-0.7.0 → overkill-0.8.0}/tests/test_self_review.py +0 -0
  86. {overkill-0.7.0 → overkill-0.8.0}/tests/test_time_utils.py +0 -0
  87. {overkill-0.7.0 → overkill-0.8.0}/tests/test_two_step_fix.py +0 -0
  88. {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.7.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "overkill"
3
- version = "0.7.0"
3
+ version = "0.8.0"
4
4
  description = "AI-powered code review loop — automates Codex/Gemini review + Claude fix cycles"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -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
- # Push if upstream exists
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
- elif branch:
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
- else:
257
- logger.info("No upstream/remote set — skipping push.")
258
- else:
259
- logger.info("No upstream set — skipping push.")
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"{commit_pattern} {i} fixes\n\n"
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"]