← Abdelrahman Saed · All articles

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

A pull request auto-filling its own context beside a quality gate


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

Flow: a PR passing a JSON check and the analyzer, then merge-allowed or blocked with a self-deleting comment

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

Tags: GitHub Actions · Developer Experience · CI/CD · Code Review