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.
# 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_urlcreate_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.
# 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%"))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.
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:
| Outcome | Meaning | What it tells you |
|---|---|---|
| Full success | Reproducing test passes, existing tests pass | The happy path |
| Wrong location | Edited the wrong file/function; never touched the bug | Exploration/retrieval is weak |
| Fix without test | Bug fixed but no reproducing test written | Repair loop isn't enforcing red-first |
| Regression introduced | New test passes but broke existing ones | Fix too broad; needs tighter, more localized edits |
| Exhausted retries | Hit the retry cap still failing | Issue beyond current scope — good limitations-doc material |
# 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))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.
Whiteboard drills
- ▸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.