PRs that fill in their own paperwork (and a gate that won't let bad code merge)
Subtitle: Half of code review is chores — linking the ticket, assigning yourself, checking the analyzer. So I automated the chores and kept humans for the judgment.
The Self-Driving Repo · Part 2 — Developer Experience

Open a pull request on most teams and you're greeted by a checklist: link the Jira ticket, assign yourself, confirm the analyzer passes, tick the boxes. None of it is hard. All of it is friction. And friction at the start of review is the worst kind, because it delays the part that actually matters — a human reading your code.
So I split PR review into two buckets. Chores (mechanical, rule-based, boring) get automated. Judgment (is this the right change?) stays with people. Two workflows handle the chores: one enriches every new PR with context, the other runs a real quality gate and manages its own labels and comments.
The problem
The opening minutes of every PR were spent on the same low-value motions:
- Copy the ticket ID from the branch name, build the Jira URL, paste it in.
- Assign yourself (or get pinged because you forgot).
- Tick the template checkboxes.
- And on the reviewer's side: pull the branch, run the analyzer, discover the translation file has a trailing-comma syntax error that breaks the build, leave a comment, wait.
Every one of those is deterministic. A human doing them is a human not reading code.
The idea
Two PR-triggered workflows:
1. PR automation — on opened, derive context from the branch and write it into the PR: the ticket link, the assignee, a wip label. 2. The analysis gate — on opened and every push, validate translation JSON, run the analyzer, then label, comment, and block on failure — and remove the label and comment when the next push fixes it.
The theme that makes them feel good to use: idempotency and self-cleanup. They never double-post, never leave stale state, and converge to "correct" no matter how many times you push.
How it works

Auto-context on open
The ticket ID already lives in the branch name — feature/PROJ-123-add-audio-seek. A regex lifts it out, no external lookup needed:
const match = branch.match(/([A-Z]+-\d+)/);
if (match) {
const jiraId = match[1]; // PROJ-123
const url = `https://your-tracker/browse/${jiraId}`;
// write the link into the PR body, replacing the template placeholder
}
The same step ticks the "I added the Jira link" checkbox in the template, assigns the author if the PR has no assignee yet, and adds a wip label. Each action guards itself first — does it already have an assignee? does the label exist? — so re-runs are no-ops:
// Create the label only if it's missing (404 = doesn't exist yet)
try {
await github.rest.issues.getLabel({ owner, repo, name: 'wip' });
} catch (e) {
if (e.status === 404) {
await github.rest.issues.createLabel({ owner, repo, name: 'wip', color: 'fbca04' });
}
}
That try/get-create-on-404 pattern shows up in every one of these workflows. GitHub has no "ensure label exists" call, so you build it yourself — and once you have it, label management stops being something humans think about.
A gate that validates more than Dart
The analysis workflow runs on open and on every push, scoped to the files that matter:
on:
pull_request:
types: [opened, synchronize]
paths: ['**.dart', 'pubspec.yaml', 'assets/translations/**.json']
concurrency:
group: dart-analysis-${{ github.event.pull_request.number }}
cancel-in-progress: true
That concurrency block matters more than it looks. Push three times in a minute and you don't want three analyzer runs racing to comment on the same PR — you want the latest one to win and the rest to cancel. cancel-in-progress: true gives you exactly that.
First it validates translation files, because a malformed localization JSON breaks the build in a way the Dart analyzer won't catch:
for f in assets/translations/*.json; do
python3 -c "import json; json.load(open('$f'))" || { echo "Invalid JSON: $f"; exit 1; }
done
Then the analyzer, captured rather than fail-fast so we can format the result ourselves:
flutter analyze > analysis-output.txt 2>&1 || true
grep "error •" analysis-output.txt && echo "has_errors=true" >> "$GITHUB_OUTPUT"
The part I'm proud of: bidirectional state
Most "lint bot" workflows only know how to complain. This one also knows how to take it back. On failure, it adds an analysis-failed label and posts one comment with the errors tucked inside a collapsible <details> block (so the PR stays skimmable). On the next push, if analysis passes, it removes the label and deletes its own comment:
// On pass: find the bot's previous failure comment and remove it
const comments = await github.rest.issues.listComments({ owner, repo, issue_number });
const stale = comments.data.find(c =>
c.user.type === 'Bot' && c.body.includes('Dart Analysis Failed'));
if (stale) await github.rest.issues.deleteComment({ owner, repo, comment_id: stale.id });
The c.user.type === 'Bot' filter is the safety latch: the workflow only ever deletes comments it wrote, never a human's. The result is a PR timeline that reflects the current state, not a graveyard of "❌ failed / ✅ fixed / ❌ failed again" noise. When the gate is green, it's silent.
And when it's red, it actually blocks — the job exits non-zero so the merge is gated, not just decorated:
[ "$has_errors" = "true" ] && exit 1
What it bought us
- Reviewers start on the code, not the chores. Context is already there when they open the PR.
- A whole category of broken builds never reaches
master— malformed translations and analyzer errors are caught at the door. - The PR timeline stays clean. Self-deleting comments mean no stale failure noise to scroll past.
- Around 5 fewer "please link the ticket / please rebase / CI is red because of a comma" round-trips per week.
Gotchas & trade-offs
- Editing the PR body is brittle. Auto-ticking checkboxes is naive string replacement — if someone reworded the template, the match silently fails. Keep the template text stable, or match loosely.
paths:is a double-edged filter. Scope it too tightly and a relevant change skips the gate; too loosely and you run the analyzer on doc-only PRs. Revisit it as the repo grows.- Bot-comment deletion depends on the bot identity. If you switch the token the comments are posted under, the
user.type === 'Bot'filter can stop matching your own history. Test after any auth change. - Automating chores can hide them. New teammates never learn why the ticket link matters because they never do it manually. Worth a sentence in onboarding so the automation is understood, not just trusted.
Takeaway
Separate the chores of review from the judgment of review, and automate only the chores — idempotently, with self-cleanup, so the bot's output always reflects current reality. The win isn't "look, a bot." It's that every human minute on a PR now goes to the one thing humans are uniquely good at: deciding whether the change is right.
Next: making two sources of truth — Jira and GitHub — actually agree with each other.
The complete workflow
Here is the full, genericized workflow — drop it into .github/workflows/ and replace the placeholders (your-org, the PROJ project key, <@DISCORD_USER_ID>, the example team, and the secret names) with your own.
.github/workflows/pr-automation.yml
name: PR Automation
on:
pull_request:
types: [opened]
permissions:
pull-requests: write
issues: write
jobs:
automate-pr:
runs-on: ubuntu-slim
steps:
- name: Add Jira Link
uses: actions/github-script@v7
with:
script: |
const branchName = context.payload.pull_request.head.ref;
const jiraIdMatch = branchName.match(/([A-Z]+-\d+)/);
if (!jiraIdMatch) {
console.log('No Jira ID found in branch name');
return;
}
const jiraId = jiraIdMatch[1];
const jiraUrl = `https://your-org.atlassian.net/browse/${jiraId}`;
let prBody = context.payload.pull_request.body || '';
console.log(`Found Jira ID: ${jiraId}`);
if (prBody.includes(jiraUrl)) {
console.log('Jira URL already exists in PR description');
return;
}
const placeholder = '<!---add your Jira link-->';
const jiraLink = `**Jira Ticket:** ${jiraUrl}`;
if (prBody.includes(placeholder)) {
prBody = prBody.replace(placeholder, jiraLink);
console.log('Replaced placeholder with Jira link');
} else {
prBody = `${jiraLink}\n\n${prBody}`;
console.log('Prepended Jira link to PR body');
}
// Check the Jira link checkbox
prBody = prBody.replace(
'- [ ] I added the Jira link',
'- [x] I added the Jira link'
);
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
body: prBody
});
console.log('✅ Added Jira link to PR');
- name: Auto-assign PR creator
uses: actions/github-script@v7
with:
script: |
const assignees = context.payload.pull_request.assignees || [];
if (assignees.length > 0) {
console.log(`PR already has assignees: ${assignees.map(a => a.login).join(', ')}`);
return;
}
const creator = context.payload.pull_request.user.login;
const prNumber = context.payload.pull_request.number;
await github.rest.issues.addAssignees({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
assignees: [creator]
});
console.log(`✅ Assigned ${creator} to PR #${prNumber}`);
// Check the assignee checkbox in PR body
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});
let prBody = pr.body || '';
const unchecked = '- [ ] The pull request has an assignee (assign yourself)';
if (prBody.includes(unchecked)) {
prBody = prBody.replace(unchecked, '- [x] The pull request has an assignee (assign yourself)');
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
body: prBody
});
console.log('Checked the assignee checkbox');
}
- name: Add WIP label
uses: actions/github-script@v7
with:
script: |
const labels = context.payload.pull_request.labels || [];
if (labels.length > 0) {
console.log(`PR already has labels: ${labels.map(l => l.name).join(', ')}`);
return;
}
const owner = context.repo.owner;
const repo = context.repo.repo;
const labelName = 'wip';
// Ensure wip label exists
try {
await github.rest.issues.getLabel({ owner, repo, name: labelName });
} catch (e) {
if (e.status === 404) {
await github.rest.issues.createLabel({
owner,
repo,
name: labelName,
color: 'fbca04',
description: 'Work in progress'
});
console.log(`Created '${labelName}' label`);
} else {
throw e;
}
}
await github.rest.issues.addLabels({
owner,
repo,
issue_number: context.payload.pull_request.number,
labels: [labelName]
});
console.log(`✅ Added '${labelName}' label`);
.github/workflows/pr-analysis-label.yml
name: Dart Analysis
on:
pull_request:
types: [opened, synchronize]
paths:
- '**.dart'
- 'pubspec.yaml'
- 'pubspec.lock'
- 'analysis_options.yaml'
- 'assets/translations/**.json'
workflow_dispatch:
concurrency:
group: dart-analysis-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
pull-requests: write
issues: write
contents: read
jobs:
dart-analysis:
runs-on: ubuntu-slim
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Validate translation JSON files
id: validate_json
run: |
echo "Validating translation files..."
FAILED=false
for file in assets/translations/*.json; do
if [ -f "$file" ]; then
echo -n "Checking $file... "
if python3 -c "import json; json.load(open('$file'))" 2>/dev/null; then
echo "✓ Valid"
else
echo "✗ Invalid JSON"
python3 -c "import json; json.load(open('$file'))" 2>&1 || true
FAILED=true
fi
fi
done
if [ "$FAILED" = true ]; then
echo ""
echo "has_errors=true" >> $GITHUB_OUTPUT
echo "::error::One or more translation files contain invalid JSON"
exit 1
fi
echo "has_errors=false" >> $GITHUB_OUTPUT
echo ""
echo "All translation files are valid JSON"
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: 'stable'
flutter-version: '3.44.0'
cache: true
cache-key: "flutter-:os:-:channel:-:version:-:arch:"
cache-path: "${{ runner.tool_cache }}/flutter/:channel:-:version:-:arch:"
- name: Get dependencies
run: flutter pub get
- name: Run Dart analyze
id: analyze
run: |
flutter analyze > analysis-output.txt 2>&1 || true
if grep -q "error •" analysis-output.txt; then
echo "has_errors=true" >> $GITHUB_OUTPUT
echo "error_details<<EOF" >> $GITHUB_OUTPUT
grep "error •" analysis-output.txt >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
else
echo "has_errors=false" >> $GITHUB_OUTPUT
fi
- name: Add failure label if analysis has errors
if: steps.analyze.outputs.has_errors == 'true'
uses: actions/github-script@v7
env:
ERROR_DETAILS: ${{ steps.analyze.outputs.error_details }}
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const labelName = 'analysis-failed';
const prNumber = context.payload.pull_request.number;
// Ensure label exists
async function ensureLabel(name, color, description) {
try {
await github.rest.issues.getLabel({ owner, repo, name });
} catch (e) {
if (e.status === 404) {
await github.rest.issues.createLabel({ owner, repo, name, color, description });
console.log(`Created label: ${name}`);
} else {
throw e;
}
}
}
await ensureLabel(labelName, 'd93f0b', 'Dart analysis found issues');
// Add label
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels: [labelName]
});
console.log(`✅ Added '${labelName}' label to PR #${prNumber}`);
// Post comment
const errors = process.env.ERROR_DETAILS;
const body = `## ❌ Dart Analysis Failed\n\nPlease fix the analysis issues before merging.\n\n<details>\n<summary>Errors found</summary>\n\n\`\`\`\n${errors}\n\`\`\`\n\n</details>\n\n@${context.payload.pull_request.user.login} \n <img src="https://your-cdn.example.com/dart-analysis-failed.png" width="66px"/>`;
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: body
});
console.log('✅ Added comment to PR');
- name: Remove failure label if analysis passed
if: steps.analyze.outputs.has_errors == 'false'
uses: actions/github-script@v7
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const labelName = 'analysis-failed';
const prNumber = context.payload.pull_request.number;
// Remove label if it exists
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: prNumber,
name: labelName
});
console.log(`✅ Removed '${labelName}' label from PR #${prNumber}`);
} catch (e) {
if (e.status === 404) {
console.log(`Label '${labelName}' not found on PR, skipping removal`);
} else {
throw e;
}
}
// Find and remove analysis-failed comment
const comments = await github.rest.issues.listComments({
owner,
repo,
issue_number: prNumber
});
for (const comment of comments.data) {
if (comment.body.includes('## ❌ Dart Analysis Failed') && comment.user.type === 'Bot') {
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: comment.id
});
console.log(`✅ Removed analysis-failed comment from PR #${prNumber}`);
}
}
- name: Fail job if analysis has errors
if: steps.analyze.outputs.has_errors == 'true'
run: exit 1