DEV Community

Quinn Zhu
Quinn Zhu

Posted on

Day-One PR: Reject Any Diff Outside a Ticket Card

Your first AI patch will invent extra work.
Freeze the ticket before you open a chat.
A small JSON card is the only gate you need.

Why the first PR goes wide

You joined a messy repo on day one.
The assigned ticket looks small and already scoped.
The assistant then edits packages you never opened.

That extra code is not a free gift.
Reviewers then reject scope they never asked for.
You then spend the afternoon reverting confident surprises.

Agent loops still assume the missing product rules.
They fill silence with a plausible extra design.
Your freeze file makes that silence illegal today.

What you will ship by lunch

You will write one JSON freeze card first.
You will run one local checker script next.
You will open a PR only after exit code zero.

The freeze card answers only a single question.
Did this generated patch stay inside the ticket?
If the script says no, the branch is not a PR.

Step 1: Capture the ticket without a tracker dump

Do not paste the whole tracker page into chat.
Copy the id, the title, and one acceptance line.
Anything else is noise the model will overfit.

git checkout -b first-pr/TICKET-1042
mkdir -p .ticket scripts
git ls-files "src/users/*"
git log --oneline -n 8 -- src/users/show.py
Enter fullscreen mode Exit fullscreen mode

Read those eight commits on your own screen.
Do not ask a model to summarize history.
Invented history is how the scope quietly grows.

Step 2: Write the freeze card

Save the freeze file with this JSON shape.
Keep allowed_paths at two files when possible.
If you cannot name them, you cannot prompt yet.

{
  "id": "TICKET-1042",
  "title": "Return 404 when the user slug is unknown",
  "allowed_paths": [
    "src/users/show.py",
    "src/users/test_show.py"
  ],
  "forbidden_path_prefixes": [
    "migrations/",
    ".github/",
    "src/users/__init__.py"
  ],
  "test_command": ["python", "-m", "pytest", "src/users/test_show.py", "-q"],
  "must_include": ["status_code == 404"],
  "must_not_include": ["TODO", "pass  # implement"],
  "max_changed_lines": 80,
  "commit_ticket": "TICKET-1042"
}
Enter fullscreen mode Exit fullscreen mode

Name files from git ls-files, not from memory.
Put generated migrations on the forbidden prefix list.
Put workflow files on that forbidden list too.

Step 3: Write the red test by hand

The assistant does not write this first test.
You write it from the acceptance line only.
Run it once so you see the failure yourself.

# Proposed example: src/users/test_show.py
from users.show import handle_show


def test_unknown_slug_returns_404():
    response = handle_show(slug="missing-user")
    assert response.status_code == 404
Enter fullscreen mode Exit fullscreen mode
python -m pytest src/users/test_show.py -q
Enter fullscreen mode Exit fullscreen mode

If this test is already green, stop now.
The ticket is wrong, or it is already done.
Ask a human before any model sees the tree.

Fixtures belong in allowed_paths if tests need them.
Do not let the model create a new fixture package.
Reuse the test module you already have open.

Step 4: Add the proposed freeze checker

Save this proposed script as scripts/check_ticket_freeze.py.
It uses only the Python 3 standard library.
Run it on your machine after every generated draft.

#!/usr/bin/env python3
"""Proposed local gate: reject diffs that escape a ticket card."""
from __future__ import annotations

import json
import subprocess
import sys
from pathlib import Path

ALWAYS_ALLOW_PREFIXES = (".ticket/", "scripts/check_ticket_freeze.py")


def git_output(args: list[str]) -> str:
    result = subprocess.run(
        ["git", *args],
        check=True,
        capture_output=True,
        text=True,
    )
    return result.stdout


def changed_files() -> list[str]:
    names = git_output(["diff", "--name-only", "HEAD"]).splitlines()
    extra = git_output(["ls-files", "--others", "--exclude-standard"]).splitlines()
    return sorted({name for name in names + extra if name})


def changed_line_count() -> int:
    diff = git_output(["diff", "HEAD"])
    total = 0
    for line in diff.splitlines():
        if line.startswith("+++") or line.startswith("---"):
            continue
        if line.startswith("+") or line.startswith("-"):
            total += 1
    return total


def main() -> int:
    if len(sys.argv) != 2:
        print("usage: check_ticket_freeze.py .ticket/TICKET-id.json")
        return 2

    freeze_path = Path(sys.argv[1])
    freeze = json.loads(freeze_path.read_text(encoding="utf-8"))
    allowed = set(freeze["allowed_paths"])
    forbidden = tuple(freeze["forbidden_path_prefixes"])
    files = changed_files()
    errors: list[str] = []

    if not files:
        errors.append("no changes; the ticket is not started")

    for name in files:
        if name.startswith(ALWAYS_ALLOW_PREFIXES):
            continue
        if name.startswith(forbidden):
            errors.append(f"forbidden path: {name}")
        elif name not in allowed:
            errors.append(f"path not allowed: {name}")

    line_count = changed_line_count()
    limit = int(freeze["max_changed_lines"])
    if line_count > limit:
        errors.append(f"diff has {line_count} lines; max is {limit}")

    diff_text = git_output(["diff", "HEAD"])
    for needle in freeze["must_include"]:
        if needle not in diff_text:
            errors.append(f"missing required snippet: {needle}")
    for needle in freeze["must_not_include"]:
        if needle in diff_text:
            errors.append(f"forbidden snippet: {needle}")

    test = subprocess.run(freeze["test_command"])
    if test.returncode != 0:
        errors.append(f"test command failed: {freeze['test_command']}")

    if errors:
        print("FREEZE FAIL")
        for item in errors:
            print(f"- {item}")
        return 1

    print("FREEZE OK")
    print("files: " + ", ".join(files))
    print(f"changed_lines: {line_count}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode

Stage the allowed files before you run it.
git diff HEAD then includes the new test.
Unstaged untracked files can still hide from git.

Step 5: Prompt against the card, not the repo

Feed the freeze JSON, not the whole checkout.
Ask for a patch that cannot leave allowed_paths.

Read .ticket/TICKET-1042.json first.
Change only allowed_paths.
Do not touch forbidden_path_prefixes.
Keep the diff under max_changed_lines.
Stop if another file seems required.
Do not invent helpers, TODOs, or extra refactors.
Enter fullscreen mode Exit fullscreen mode

Paste nothing else on that first attempt.
If the model asks for more files, answer no.
Update the freeze file only after a human agrees.

Where a free local loop fits

You can iterate those drafts on a local loop.
MonkeyCode offers free model access and a free server option.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.

Point that loop at the freeze file only.
The checker still owns the whole merge decision.
A draft that fails the script is not a PR.

If you try the free server, keep the JSON in the branch.

Step 6: Run the gate, then commit

Stage nothing from those forbidden path prefixes.

git add src/users/show.py src/users/test_show.py .ticket/TICKET-1042.json
python3 scripts/check_ticket_freeze.py .ticket/TICKET-1042.json
echo $?
Enter fullscreen mode Exit fullscreen mode

Restore files the model should not have touched.

git restore --staged --worktree migrations .github
python3 scripts/check_ticket_freeze.py .ticket/TICKET-1042.json
Enter fullscreen mode Exit fullscreen mode

Commit only when the script prints FREEZE OK.

git commit -m "fix(users): return 404 for unknown slug TICKET-1042"
Enter fullscreen mode Exit fullscreen mode

Leave the freeze file in the pull request.
Reviewers then rerun the same gate you ran.

Decision table for hour one

Signal you see What you do next
Allowed paths are still unknown Run git ls-files. Do not prompt.
Hand-written test is already green Stop. Ask a human.
Model wants a third production file Refuse. Split the ticket.
Diff exceeds max_changed_lines Reset and narrow the prompt.
Forbidden prefix appears in git diff Restore that path, then rerun.
Checker prints FREEZE OK Open the pull request.

Pin this table next to the active ticket.
Do not keep the rules only in chat history.
Hour one is for gates, not clever prompts.

When the model begs for one more file

Refuse the extra file on the first pass.
A helper in another module is out of scope.
Wrap the new behavior inside show.py instead.

If a true dependency blocks the ticket, stop.
Do not widen JSON to make the model happy.
Open a second ticket after a human agrees.

A sample failure you should want

The script should fail loud on extra files.

FREEZE FAIL
- path not allowed: src/users/helpers.py
- missing required snippet: status_code == 404
Enter fullscreen mode Exit fullscreen mode

That noisy failure is the whole teaching point.
Fix the diff and do not edit the freeze file.
Widening the card stays a human-only decision.

What the checker cannot catch

A legal diff can still be completely wrong.
Wrong error text inside show.py still passes.
max_changed_lines is only a blunt line heuristic.

The freeze file is plain JSON on disk.
A reckless agent can edit the card too.
Never list the .ticket/ folder inside allowed_paths.

Existing TODO lines can still trip must_not_include.
Move those needles to added-line checks later if needed.
This first checker version stays small on purpose.

This flow needs one failing test you trust.
It needs two file paths you can defend.
It needs a stable ticket id in the commit.

Who should not use this

Do not use this on a production incident.
Do not use this flow for schema migrations.
Do not use this flow for lockfile-only upgrades.

Skip this flow on a wide exploratory spike.
Skip it if you will not write the test.
Skip a change that must touch twenty packages.

Existing CODEOWNERS rules still apply on the PR.
This script does not replace a human reviewer.
It only stops the first-day scope leak.

Close

Your first PR is a contract, not a tour.
Freeze the ticket and write the red test.
Let the checker reject every extra generous patch.

Top comments (0)