PR Etiquette, HITL Gating & Evaluating a Coding Agent

A green test suite isn't a merge. The agent produces a well-formed PR gated on human approval, then you measure whether the whole thing actually works — a small SWE-bench-style eval set you assemble yourself, with a partial-success taxonomy and cost/time per issue.

Passing tests earns the right to propose a change, not to merge it. The delivery stage produces a proper pull request — a clear description linking the issue, the diff, and the test evidence — and puts it behind a human approval gate. Then comes the part that makes this a portfolio piece rather than a toy: you evaluate the agent honestly across many issues and report real numbers.

PR etiquette

  • A description a reviewer can act on: what the bug was, the root cause, what changed and why, and the test that now proves it — linking the original issue.
  • Open as a draft, not ready-to-merge — the agent proposes; a human decides.
  • Show the evidence: the diff, the before/after test results (red → green), and the cost of producing the fix.
  • Small, focused diffs: one issue, one logical change — the search/replace default pays off here.
The PR is an irreversible action — gate it
Opening a PR against a real repo is exactly the kind of consequential, hard-to-undo action Module 7's HITL pattern exists for. The gate shows the approver the diff, test results, and cost so they can decide in seconds; nothing hits GitHub without an explicit yes; the decision is logged; a timeout defaults to reject. This is also your defense if a malicious issue tries to inject instructions — a human sees the diff before anything ships.
producing the PR behind an HITL gate (PyGithub)
# Reference implementation — needs 'pip install PyGithub', a GITHUB_TOKEN,
# a real repo/branch, and the audit() logger from Module 7. It opens a real
# draft PR, so it's here to read and adapt, not to run in Colab.
from github import Github        # PyGithub
import os

def open_pr_if_approved(repo_full: str, branch: str, base: str,
                        title: str, body: str, diff: str,
                        cost_usd: float) -> str:
    # 1) Surface everything the human needs to approve in seconds.
    print("=== PROPOSED PR ===")
    print(title)
    print(body)
    print(f"cost to produce: $" + f"{cost_usd:.3f}")
    print(diff[:4000])            # trimmed diff preview
    decision = input("open this draft PR? [y/N] ").strip().lower()

    audit({"event": "pr_decision", "repo": repo_full, "branch": branch,
           "approved": decision == "y", "cost_usd": cost_usd})
    if decision != "y":
        return "rejected by human — nothing pushed"

    # 2) Only on explicit yes do we touch GitHub, and only as a DRAFT.
    gh = Github(os.environ["GITHUB_TOKEN"])
    repo = gh.get_repo(repo_full)
    pr = repo.create_pull(title=title, body=body, head=branch,
                          base=base, draft=True)
    audit({"event": "pr_opened", "url": pr.html_url})
    return pr.html_url
The gate is the whole point: the human sees title, body, cost, and a trimmed diff, and only an explicit 'y' reaches create_pull — opened as a draft. Every path writes to the audit log, so there's a durable record of what the agent proposed and what a human decided. In a team setting the input() becomes a web approval UI, but the contract is identical: propose with full context, execute only on approval.

Why small, reviewable diffs beat big ones

The 'small, focused diffs' bullet above is more than style — it's what makes the entire gate structure work. Reviewer trust: a human approver skimming a 15-line diff can genuinely verify it inside the seconds an HITL gate is designed to take; a 400-line diff gets rubber-stamped under the same time pressure, which defeats the gate's entire purpose — the approval becomes theater instead of a real check. Bisectability: git bisect and git blame stay useful only if each commit does one thing; a diff that fixes the bug and reformats three files and renames a variable makes future debugging archaeology harder, including your own agent's future regression-hunting. The PR as the unit of accountability: one issue, one fix, one clear description creates a clean audit trail — this PR closes this issue, this test proves it. A bundled diff obscures which change caused which effect, which matters enormously when a later PR from the same agent introduces a regression and you need to know which prior change is implicated. This is why Lesson 2's 'default to search/replace' choice isn't just an implementation detail — it's the thing that makes the diffs small enough for this gate to mean anything.

Layered gates: from fast fail to human

The HITL approval isn't the only gate — it's the last and most expensive one in a sequence that should fail fast and cheap before it fails slow and human. Lint/typecheck first: seconds, catches syntax and type errors before you ever burn a full test-suite run. The test suite next: the red→green proof from Lesson 2. Agent self-review vs. an independent review model: never let the model that wrote the fix be its only reviewer — the same self-preference bias Module 7 names for LLM judges applies here (a model rates its own family's output more favorably), so route the diff through a different model, or at minimum a fresh context with a narrow rubric, before a human ever sees it. Human approval is the final gate precisely because it's the only one that's adversarially robust — every earlier layer is itself a model or a script that can, in principle, be fooled by the same class of failure the pipeline exists to catch.

an independent review pass before the human ever sees the diff
# Colab cell — run once. Set your key in the 🔑 panel (name it
# ANTHROPIC_API_KEY) or just paste it when prompted.
!pip install -q anthropic

import os
try:
    from google.colab import userdata
    os.environ["ANTHROPIC_API_KEY"] = userdata.get("ANTHROPIC_API_KEY")
except Exception:
    from getpass import getpass
    os.environ.setdefault("ANTHROPIC_API_KEY", getpass("Anthropic API key: "))

import json

import anthropic

reviewer_client = anthropic.Anthropic()   # if the fixer is an OpenAI model,
                                          # this IS the cross-family reviewer

REVIEW_SCHEMA = {
    "type": "object",
    "properties": {
        "touches_tests": {"type": "boolean"},
        "scope_concern": {"type": "boolean"},
        "verdict": {"type": "string", "enum": ["pass", "flag"]},
    },
    "required": ["touches_tests", "scope_concern", "verdict"],
    "additionalProperties": False,
}

def independent_review(diff: str, issue_text: str) -> dict:
    """A DIFFERENT model/context reviews the diff before a human does.
    Never let the model that wrote the fix be the only reviewer of it."""
    resp = reviewer_client.messages.create(
        model="claude-sonnet-5", max_tokens=1024,
        system=(
            "You are reviewing a code change, not the engineer who wrote it. "
            "Check: does the diff plausibly fix the described issue? Does it "
            "touch any file under tests/ or named test_*/*_test? Is the diff "
            "larger than the issue seems to warrant?"
        ),
        messages=[{"role": "user", "content":
            f"Issue:\n{issue_text}\n\nDiff:\n{diff}"}],
        # Structured output: the verdict is machine-routed, so don't parse prose.
        output_config={"format": {"type": "json_schema", "schema": REVIEW_SCHEMA}},
    )
    text = next(b.text for b in resp.content if b.type == "text")
    return json.loads(text)


# a diff that weakens a test instead of fixing code -> should be flagged:
gamed_diff = (
    "--- a/tests/test_pricing.py\n+++ b/tests/test_pricing.py\n"
    "@@\n-    assert result <= 100\n+    assert result <= 200\n")
print(independent_review(gamed_diff,
                         "calculate_discount returns discounts over 100%"))
The rubric is deliberately narrow and mechanical (touches_tests, scope_concern, verdict) rather than an open 'is this good?' — narrow, structured checks resist the position and self-preference biases Module 7 covers better than holistic judgments do. Because a gate routes on this verdict, the JSON shape is enforced via structured outputs rather than requested in the prompt. touches_tests is exactly the guardrail Lesson 2 promised against test-gaming: any repair that edits a test file gets flagged for elevated scrutiny automatically, before the human's limited attention is spent on it. This call is cheap triage, not certification — it exists so the HITL gate only ever sees diffs that already cleared automated review, not to replace the human's judgment. One deliberate difference from Module 7's judge, which forced a tool call for the same enforce-the-shape job: both are Module 1's structured-output patterns, and since nothing here needs executing, the native output_config format is the lighter fit — pick either, just pick consciously.

Evaluating a coding agent

Now the honest question: how often does it actually work? You assemble your own small SWE-bench-style eval set — real issues from small OSS repos plus bugs you seed yourself (introduce a bug, write the issue, keep the known-good fix). The README target is ≥10 issues. For each, you know ground truth, so you can score automatically: does the agent's fix make the reproducing test pass without breaking existing tests? Report success rate, and don't stop there.

Changeprompt / model / toolEval suiteN cases, fixedJudge + assertsLLM judge · unit checksship only if score holds — regressions block the mergetargeteval score per iteration →
Run the agent across your issue set, score each (success / partial / failure), aggregate rate, cost, and time.

The partial-success taxonomy

Binary pass/fail hides everything interesting. Coding agents fail in structured ways, and naming those categories is what turns raw numbers into insight — and into interview vocabulary. Track where each run landed:

OutcomeMeaningWhat it tells you
Full successReproducing test passes, existing tests passThe happy path
Wrong locationEdited the wrong file/function; never touched the bugExploration/retrieval is weak
Fix without testBug fixed but no reproducing test writtenRepair loop isn't enforcing red-first
Regression introducedNew test passes but broke existing onesFix too broad; needs tighter, more localized edits
Exhausted retriesHit the retry cap still failingIssue beyond current scope — good limitations-doc material
scoring the eval set with a partial-success taxonomy
# Colab cell — pure Python, no key needed; run it as-is.
from dataclasses import dataclass, field

@dataclass
class IssueResult:
    issue_id: str
    outcome: str          # one of the taxonomy categories
    cost_usd: float
    seconds: float

def score_issue(run) -> str:
    if run.status == "exhausted":
        return "exhausted_retries"
    before, after = run.tests_before, run.tests_after
    if not run.wrote_reproducing_test:
        return "fix_without_test"
    if after.existing_failed:                 # broke something that passed
        return "regression_introduced"
    if not after.repro_test_passed:
        return "wrong_location"               # never actually fixed it
    return "full_success"

def report(results: list[IssueResult]) -> dict:
    n = len(results)
    from statistics import median
    succ = sum(r.outcome == "full_success" for r in results)
    by_outcome: dict[str, int] = {}
    for r in results:
        by_outcome[r.outcome] = by_outcome.get(r.outcome, 0) + 1
    return {
        "n_issues": n,
        "success_rate": round(succ / n, 3),
        "taxonomy": by_outcome,
        "median_cost_usd": round(median(r.cost_usd for r in results), 3),
        "median_seconds": round(median(r.seconds for r in results), 1),
    }


# fake runs, one per taxonomy category, to see the report populate:
from types import SimpleNamespace as NS
def make_run(status, wrote_test, existing_failed, repro_passed):
    return NS(status=status, wrote_reproducing_test=wrote_test,
              tests_before=NS(),
              tests_after=NS(existing_failed=existing_failed,
                             repro_test_passed=repro_passed))

runs = [
    make_run("done", True, False, True),         # full_success
    make_run("done", False, False, True),        # fix_without_test
    make_run("done", True, True, True),          # regression_introduced
    make_run("done", True, False, False),        # wrong_location
    make_run("exhausted", False, False, False),  # exhausted_retries
]
results = [IssueResult(f"issue-{i}", score_issue(r), cost_usd=0.05 * (i + 1),
                       seconds=10 * (i + 1)) for i, r in enumerate(runs)]
print(report(results))
The scoring function encodes the taxonomy as a decision tree over ground truth you control: did it run out of retries, skip the reproducing test, break existing tests, or fail to fix the target? Reporting the outcome histogram alongside the headline success rate is what a senior reviewer wants — it shows you know how your agent fails, not just how often. Median cost and time per issue are the operational numbers every hiring conversation asks for. The demo builds one run per category and prints the full report — success rate, the outcome histogram, and the medians — so you can see exactly the shape a reviewer wants.
Cost analysis that reads as senior
Report median (and worst-case) cost per issue and per outcome — successes are often cheaper than exhausted runs that retried five times. Break cost down by stage (exploration vs. repair) using your traces from Module 7; usually exploration or a runaway repair loop dominates. 'Median $X per successful fix, exhausted runs cost ~3x' is a sentence that lands in an interview.

What SWE-bench-style numbers do and don't tell you

Your own eval set borrows SWE-bench's shape, so it's worth knowing exactly where that shape misleads. Pass@1 vs. pass@k: pass@1 asks whether a single attempt succeeds; pass@k asks whether at least one of k independent attempts succeeds — pass@k is mechanically ≥ pass@1 for k>1, because more independent chances can only help. A number reported as 'resolved' without specifying which one is comparing apples to a fruit basket; production is almost always pass@1, since a user's PR gets opened from exactly one attempt. Env-setup brittleness: a meaningful share of historical SWE-bench failures turned out to be the harness failing to reproduce a repo's environment, not the model failing to fix the bug — a low score can mean 'our setup is fragile,' not 'the model is weak,' and conflating the two misdirects your debugging effort. Benchmark contamination: frontier models may have seen SWE-bench instances or their fixes during training, inflating published scores in ways that don't transfer to your own private, freshly-seeded issue set — which is exactly why you built one instead of just quoting the public number. The gap to real repo work: benchmarks select for issues with a clean reproducible test and unambiguous ground truth; real repos have flaky CI, tribal knowledge nowhere in the docs, and issues that are underspecified until someone asks a clarifying question. A benchmark number is a ceiling estimate on real work, not a guarantee of it.

Spot the bug
A teammate's slide says: 'Our agent matches published SWE-bench-verified numbers: 65% resolved.' The published figure is pass@1 on the full SWE-bench-verified set. Your team's number comes from running your own agent 5 times per issue on your own 10-issue set and counting an issue as resolved if ANY of the 5 attempts passed. What's wrong with the comparison, and what should the slide say instead?

Whiteboard drills

Check yourself
Drill: "Walk me through every gate a change goes through between your agent finishing a fix and that fix landing on main — and tell me which ones a determined agent could talk its way past."
Check yourself
Drill: "You add a second LLM as an independent reviewer before human approval. It's the same model family as the one that wrote the fix. Good enough? What would you change?"
Key takeaways
  • Green tests earn a PR proposal, not a merge; open a draft with a reviewer-ready description linking the issue.
  • Opening a PR is an irreversible action — gate it with HITL showing diff, test results, and cost; log the decision; fail closed on timeout.
  • Small diffs aren't just style: they're what makes reviewer trust, bisectability, and PR-as-accountability-unit actually work.
  • Layer the gates cheap-to-expensive: lint/typecheck → tests → independent review (different model family, narrow rubric — beware self-preference bias) → human approval as the only adversarially robust layer.
  • Build your own small SWE-bench-style set (≥10 issues, real + seeded) with known ground truth to score automatically.
  • Report success rate AND a partial-success taxonomy (wrong location, fix-without-test, regression, exhausted).
  • Report median/worst cost and time per issue, broken down by stage using your traces.
  • Know SWE-bench's limits: pass@1 vs pass@k are not interchangeable, env-setup brittleness and contamination inflate published numbers, and a benchmark score is a ceiling estimate on real repo work, not a guarantee.
  • Naming failure categories turns numbers into insight and into interview vocabulary.