You can let a free AI model suggest pull-request fixes in CI, but only if the pipeline is more paranoid than the model is clever. The model proposes a patch; a committed guard script, a clean git apply --check, a second test run, and a human review dispose of anything that breaks the rules.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The idea still sounds reckless. An AI editing my PRs, automatically, from a free server? I thought the same. Then I realized the problem was control, not capability. MonkeyCode's free tier made the experiment cheap: free model access, a free server option, and no credit card. I could test the loop without a budget meeting. What I would not skip is the bouncer at the door.
Propose, never dispose: four rules the model cannot vote on
I treat the model as a guest and the pipeline as a bouncer. The guest can bring a patch. The bouncer checks ID before anyone enters the club. Four rules are non-negotiable:
-
Source files only. Allowed prefixes are
src/andlib/. Tests, configs, and lockfiles are off-limits. The test suite is the contract; the model does not rewrite the contract. -
Small diffs only. A change that touches 300 lines is a rewrite, not a fix. My hard cap is 200 changed lines, counting only
+and-hunk lines, not the+++/---headers. -
Clean apply, no force, no fuzz. If
git apply --checkfails, the patch is dead. I never pass--rejector--3wayon this path. - Human authority at the end. The workflow comments a candidate. It never auto-merges. If you want auto-merge, you are building something else, and something riskier.
Compare that with an unguarded "just run the model and commit" job. Unguarded CI will happily rewrite tests/, bump requirements.txt, or drop a 2,000-line "refactor" on a failing assertion. The four rules make that failure mode boring: the job prints REJECTED and stops.
A concrete contrast: a patch that fixes an off-by-one in src/parser.py (12 lines) and also "cleans up" tests/test_parser.py fails the bouncer. The same 12-line patch that only touches src/parser.py and applies cleanly proceeds to pytest. That difference is the whole design.
Commit the guard script before you invite the model
CI cannot protect what it cannot see. I save the bouncer as guard_patch.py, commit it, and run it on every candidate patch. The script inspects the unified diff, rejects forbidden paths, rejects oversized diffs, then asks Git whether the patch applies.
#!/usr/bin/env python3
"""Guardrail: inspect a model patch before it touches your repo."""
import subprocess
import sys
from pathlib import Path
ALLOWED_PATHS = {"src/", "lib/"}
MAX_LINES = 200
FORBIDDEN = {"test_", "tests/", "setup.py", "requirements.txt"}
def patch_stats(patch_file: Path) -> tuple[int, set[str]]:
text = patch_file.read_text()
lines = sum(1 for line in text.splitlines()
if line.startswith(("+", "-")) and not line.startswith(("+++", "---")))
touched = set()
for line in text.splitlines():
if line.startswith("+++ b/"):
touched.add(line[6:])
return lines, touched
def main() -> int:
patch_file = Path(sys.argv[1])
if not patch_file.exists():
print(f"REJECTED: {patch_file} not found")
return 1
lines, touched = patch_stats(patch_file)
for path in touched:
if any(path.startswith(prefix) for prefix in FORBIDDEN):
print(f"REJECTED: {path} is off-limits")
return 1
if not any(path.startswith(prefix) for prefix in ALLOWED_PATHS):
print(f"REJECTED: {path} is outside allowed paths")
return 1
if lines > MAX_LINES:
print(f"REJECTED: patch touches {lines} lines, limit is {MAX_LINES}")
return 1
result = subprocess.run(["git", "apply", "--check", str(patch_file)])
if result.returncode != 0:
print("REJECTED: patch does not apply cleanly")
return 1
print(f"APPROVED: {len(touched)} file(s), {lines} changed lines")
return 0
if __name__ == "__main__":
sys.exit(main())
How I test it from the repo root, before I ever wire GitHub Actions:
- Craft a
bad-tests.patchthat touchestests/test_foo.py. ExpectREJECTEDfor an off-limits path. - Craft a
bad-size.patchwith more than 200 changed lines. Expect a line-limit rejection. - Craft a
bad-apply.patchthat does not match current HEAD. Expect a clean-apply rejection. - Craft a
good.patchundersrc/within the line cap. ExpectAPPROVED.
python guard_patch.py bad.patch
# expect REJECTED
python guard_patch.py good.patch
# expect APPROVED
If your application code lives in app/ instead of src/ or lib/, change ALLOWED_PATHS and commit that change with a failing fixture patch so the next reviewer can see the rule in git history. Do not keep the allowlist only in a wiki. Forbidden names in my script include test_, tests/, setup.py, and requirements.txt. That is a starting pair, not a universal policy. A monorepo that stores production code next to generated lockfiles needs a tighter prefix list, not a looser one.
Wire a GitHub Actions pipeline that stops on the first broken stage
The workflow lives at .github/workflows/ai-fix.yml. It runs on opened and synchronize pull requests. I follow GitHub Actions workflow syntax and keep every model step gated on a real baseline failure.
name: ai-fix-suggestion
on:
pull_request:
types: [opened, synchronize]
jobs:
suggest-fix:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: pip install -r requirements.txt
# Add a step here to install the MonkeyCode CLI per the project README.
- name: Confirm the failure is real
id: baseline
run: python -m pytest -q
continue-on-error: true
- name: Generate a fix candidate
if: steps.baseline.outcome == 'failure'
run: |
monkeycode --server free \
--prompt "Fix the failing tests. Change only source files." \
--output fix.patch
- name: Guard the patch
if: steps.baseline.outcome == 'failure'
run: python guard_patch.py fix.patch
- name: Apply and verify
if: steps.baseline.outcome == 'failure'
run: |
git apply fix.patch
python -m pytest -q
- name: Comment with the candidate
if: steps.baseline.outcome == 'failure'
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'AI fix candidate attached. Review before merging.'
})
A few details that bite people:
- Checkout the PR head with
ref: ${{ github.head_ref }}sogit applytargets the branch under test, not a detached merge commit you cannot reason about cleanly. - The baseline pytest step uses
continue-on-error: trueso a red suite is a signal, not a cancelled job. If the suite is already green, later steps are skipped. There is nothing to fix. - Install the MonkeyCode CLI per the project README. I am not hard-coding an install one-liner here because it can change. Run
monkeycode --helpfirst and match what it prints. The flags in the snippet (--server free,--prompt,--output) are the ones I used; if help output differs, follow help output. - The comment step does not push a commit. A human still has to read the candidate.
After the guard prints APPROVED, the job applies the patch and runs pytest again. That second run is the only evidence I trust. A patch that applies and still fails tests is a wrong fix, not a missing guardrail.
Verify four stages, then decide in review
I walk every PR through the same four gates. Missing any one of them turns the experiment into noise.
-
Baseline must fail. If
python -m pytest -qis already green, the model has no job. Stop. This also filters flaky suites: if you cannot trust pytest onubuntu-latest, do not invite a model to interpret the redness. -
Guard must print
APPROVED.REJECTEDmeans a rule broke. Do not apply. Do not "just this once" comment the raw diff as if it were safe. - Tests must pass after apply. If they fail, the comment can still post, but I read it as a warning, not a fix. The YAML snippet posts a generic line: "AI fix candidate attached. Review before merging." When the second pytest is red, that comment is "here is a failed attempt," not a green light.
- A human reviews. This is the only stage with merge authority. The model suggests. I decide.
What the free tier is good for, compared with a paid or self-hosted setup: it is a cheap experiment. A bad patch costs a few tokens and a red job, not an invoice. I use it on small, well-tested repos where the tests are honest. If tests are flaky, the whole pipeline is noise.
Limitations I will not paper over. The free server is not a production SLA. Do not point it at proprietary code without reading the docs. Know where requests go. Know what is logged. Teams with compliance requirements, huge monorepos, or diffs that cost money when they are wrong should skip this path and look at a paid, self-hosted setup instead.
Build the guardrails first, then start with one issue
The model is the cheap part. The guardrails are the expensive part. Build the guardrails first, then let the model loose.
Start this week with one small repo and one failing test:
- Commit
guard_patch.pyand the bad/good fixture patches. Prove locally that a test-file touch, a 500-line rewrite, and a dirty apply all printREJECTED. - Add
.github/workflows/ai-fix.yml, install the CLI per the README, and confirmmonkeycode --helpmatches the flags you pass. - Open a PR that fails on purpose in
src/. Confirm the job skips when tests are green, rejects an off-limits path, and only comments when a small source patch applies. - Read the comment yourself. Merge or close. Do not add auto-merge.
You will learn more from one rejected patch than from a month of model announcements. If you want to try the same cheap loop I used, start with MonkeyCode's free tier, keep the bouncer in git, and leave merge authority with a human.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Top comments (0)