Automated Bug Fixer with Claude Code

Build a nightly pipeline that turns Linear bug tickets into draft PRs.

Introduction

30 Second Summary

Bug tickets pile up faster than your team can fix them. Meanwhile, your Mac Mini sits idle overnight, doing nothing productive.

In this project, you will build a TypeScript pipeline that pulls bug tickets from Linear, hands each one to Claude Code running in headless mode, and opens draft pull requests on GitHub with AI-generated fixes. You will then schedule the pipeline to run every night at 2 AM using launchd, turning your Mac Mini into an overnight bug-fixing machine.

What You'll Build

You open GitHub first thing in the morning and find draft pull requests waiting, each one a fix for a bug that was sitting in your Linear backlog the night before.

By the end of this project, you'll have:

  • Run one command to pull every bug labeled auto-fix from Linear and have Claude Code read your codebase, write a fix, and run the test suite for each one automatically.
  • See each fix arrive in its own git branch with a draft PR on GitHub and a comment posted back on the Linear ticket with a link to the PR.
  • Let a nightly launchd schedule run the entire pipeline at 2 AM while you sleep, with timestamped logs you can review in the morning.
  • Secret Mission: Add Slack notifications so you wake up to a morning summary of what was fixed overnight.

Are there any prerequisites?

This project assumes you already have Node.js 18+, the Claude Code CLI, and the GitHub CLI installed and authenticated. You also need a Linear workspace with bug tickets and a GitHub repository to run fixes against.

Step 1 walks you through verifying each tool before you start building.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up the Bug Fixer Project

Before the pipeline can query bug tickets or invoke Claude Code, it needs a home. A TypeScript project with the Linear SDK gives you typed access to Linear's API out of the box.

In this step, you'll scaffold a Node.js project, install dependencies, set up environment variables, and write a quick connection test to prove everything is wired together.

In this step, get ready to:
  • Initialize a TypeScript project with the Linear SDK installed.
  • Set up environment variables and verify Claude Code and GitHub CLI access.
  • Test the Linear API connection from your script.
Initialize the project and install dependencies
  • Move to your Desktop and create the project folder by running these commands:
cd ~/Desktop
mkdir linear-bug-fixer
cd linear-bug-fixer
  • Initialize the project and install all dependencies by running these commands:
npm init -y
npm install @linear/sdk@88.0.0
npm install -D typescript tsx @types/node

You should see output confirming each install, ending with something like added X packages.

What did those commands do?

  • npm init -y creates a default package.json with the project name, version, and a placeholder test script.
  • @linear/sdk@88.0.0 is Linear's official TypeScript SDK. It gives you typed access to queries and mutations on the Linear GraphQL API.
  • typescript, tsx, and @types/node are dev dependencies. tsx lets you run TypeScript files directly without a separate compile step.

Seeing command not found for npm?

Make sure Node.js is installed. Run node --version to check. If it's missing, install Node.js 18+ from the official site before continuing.

help me install Node.js on my Mac

The npm init command created a default package.json with placeholder values. You need to update two things: enable ES modules and add a script to run the pipeline.

  • Open package.json in your code editor.
  • Find the "version" line and the "scripts" block near the top of the file. They currently look like this:
"version": "1.0.0",
"main": "index.js",
"scripts": {
  "test": "echo \"Error: no test specified\" && exit 1"
},
  • Replace that section with the following. Remove the "main" line, add "type": "module", and swap the "test" script for a "fix" script:
"version": "1.0.0",
"type": "module",
"scripts": {
  "fix": "npx tsx src/index.ts"
},

What do these changes do?

"type": "module" tells Node.js to treat .js files as ES modules. This enables import/export syntax instead of the older require() style.

The "fix" script is a shortcut. Once the orchestrator is built, you can run the full pipeline with npm run fix.

  • Save package.json.

Next, TypeScript needs a configuration file so the compiler knows how to treat your code.

  • Create a new file called tsconfig.json in the root of your linear-bug-fixer folder and add the following:
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "resolveJsonModule": true
  },
  "include": ["src"]
}

What does this configure?

  • "target": "ES2022" compiles to modern JavaScript with top-level await support.
  • "strict": true enables all strict type-checking options, catching more errors at compile time.
  • "rootDir": "src" tells TypeScript to look for source files in the src/ folder.
  • Save tsconfig.json.
  • Verify your project structure by running:
ls

You should see node_modules, package.json, package-lock.json, and tsconfig.json listed.

Create your environment variables and verify tools

The pipeline needs three values from your environment: your Linear API key, your Anthropic API key, and the path to the git repo you want Claude to fix bugs in.

  • Create a new file called .env.local in the root of your linear-bug-fixer folder and add the following:
export LINEAR_API_KEY=your-linear-api-key-here
export ANTHROPIC_API_KEY=your-anthropic-api-key-here
export REPO_PATH=[[REPO_PATH="/absolute/path/to/your/repo"]]

Where do I find these values?

  • LINEAR_API_KEY: In your Linear workspace, go to Settings, then Security & access. Create a personal API key.
  • ANTHROPIC_API_KEY: Use the same API key you used to authenticate Claude Code CLI. You can find it in your Anthropic console.
  • REPO_PATH: The absolute path to the GitHub repository you want Claude to fix bugs in (e.g. /Users/you/Desktop/my-project).
  • Replace each placeholder with your actual values.
  • Save .env.local.
  • Load the environment variables into your terminal session by running:
source .env.local

Now verify that Claude Code works in headless mode. This is the mode the pipeline uses to generate fixes without any human interaction.

  • Test Claude Code headless by running:
claude -p "Say hello" --output-format json

You should see a JSON response with a "result" field containing Claude's reply. The response also includes a session_id and usage metrics.

Not getting a JSON response?

If you see an authentication error, make sure Claude Code CLI is authenticated. Run claude without any flags to open the interactive mode and complete the login flow.

If the command is not found, Claude Code CLI may not be installed or may not be on your PATH.

help me troubleshoot Claude Code CLI

  • Verify that the GitHub CLI is authenticated by running:
gh auth status

You should see a checkmark with Logged in to github.com and your GitHub username listed.

Not logged in to GitHub CLI?

Run gh auth login and follow the prompts to authenticate with your GitHub account. Choose HTTPS as the protocol when asked.

help me authenticate the GitHub CLI

Write a Linear connection test

With the project scaffolded and all three tools verified, it's time to write the first real code. A quick connection test proves your Linear API key is valid and the SDK is working.

  • Create the src folder by running:
mkdir src
  • Create a new file called linear.ts inside the src folder and add the following code:
import { LinearClient } from "@linear/sdk";

// Create a Linear client using the API key from your environment
const client = new LinearClient({
  apiKey: process.env.LINEAR_API_KEY!,
});

// Only run the test when this file is executed directly
if (process.argv[1]?.endsWith("linear.ts")) {
  const me = await client.viewer;
  console.log(`Connected to Linear as: ${me.name}`);
}

What does this code do?

  • LinearClient is the SDK's main entry point. It takes your API key and gives you typed methods for every Linear API operation.
  • process.env.LINEAR_API_KEY! reads the key from your environment. The ! tells TypeScript you guarantee the value exists.
  • client.viewer fetches the authenticated user's profile. If this call succeeds, your API key and SDK are both working.
  • The process.argv[1] guard ensures the test only runs when you execute linear.ts directly. Later, other files will import from this module without triggering the test.
  • Save src/linear.ts.

✔️ Awesome, I've got everything!

Great. Double check you've saved src/linear.ts and move on to the connection test below.

ⓧ I'd like to double check the full code

Here is the complete src/linear.ts file. Make sure yours matches:

import { LinearClient } from "@linear/sdk";

const client = new LinearClient({
  apiKey: process.env.LINEAR_API_KEY!,
});

if (process.argv[1]?.endsWith("linear.ts")) {
  const me = await client.viewer;
  console.log(`Connected to Linear as: ${me.name}`);
}

Before you run this, what do you think the output will be? Think about what client.viewer returns.

  • Run the connection test by executing:
npx tsx src/linear.ts

You should see output like Connected to Linear as: Your Name, showing your Linear account name. That confirms the SDK, your API key, and the TypeScript toolchain are all working together.

Seeing an authentication or module error?

  • If you see an authentication error, check that your LINEAR_API_KEY is correct in .env.local and that you ran source .env.local in this terminal session.
  • If you see a module resolution error, confirm that "type": "module" is set in package.json and that tsconfig.json has "module": "ESNext".
  • If the command hangs, the Linear API might be unreachable. Check your internet connection and try again.

help me debug my Linear connection test

Your project is scaffolded and talking to Linear. Next up, you'll expand this script to pull bug tickets labeled for auto-fixing and iterate through them.

Pull Bug Tickets from Linear

Your script can talk to Linear, but it does not know which bugs to fix yet.

You need a controlled queue of work that you opt bugs into explicitly. Querying Linear for issues with a specific label gives you exactly that.

In this step, get ready to:
  • Add a fetchBugsToFix function that queries Linear for labeled bugs.
  • Add an addComment function and a test runner to verify the query.
  • Run the script and confirm it fetches your labeled bugs.
Add a fetchBugsToFix function

The pipeline needs a function that queries Linear's API for issues carrying a specific label. It should also filter out any tickets that are already completed or canceled, so Claude only works on active bugs.

  • In src/linear.ts, find the closing }); of the new LinearClient({...}) block.
  • Add the following code below it, before the if block:
// Shape of a bug ticket from Linear
export interface Bug {
  id: string;
  identifier: string;
  title: string;
  description: string | undefined;
  url: string;
}

// Query Linear for issues with a specific label, excluding done/canceled
export async function fetchBugsToFix(labelName: string): Promise<Bug[]> {
  const issues = await client.issues({
    filter: {
      labels: { name: { eq: labelName } },
      state: { type: { nin: ["completed", "canceled"] } },
    },
  });

  return issues.nodes.map((issue) => ({
    id: issue.id,
    identifier: issue.identifier,
    title: issue.title,
    description: issue.description,
    url: issue.url,
  }));
}

What does this code do?

The Bug interface defines the shape of a bug ticket your pipeline cares about: its ID, a human-readable identifier like BUG-123, its title, description, and URL.

The fetchBugsToFix function calls client.issues() with a filter that matches issues by label name and excludes any in a completed or canceled state. This means you control exactly which bugs enter the pipeline by adding or removing the label in Linear.

  • Save src/linear.ts.
  • Confirm the new code compiles by running this command:
npx tsx src/linear.ts

You should see the same Connected to Linear as: [your name] output as before. This confirms your new code compiles without errors.

Seeing a runtime error?

  • Check that the new code sits between the new LinearClient({...}) block and the if block. Placing it inside either block causes syntax errors.
  • Make sure the export keyword is on both the interface and the function. Later files will import them.
  • help me debug a runtime error in my linear.ts file
Add an addComment function and update the test runner

When the pipeline fixes a bug later, it needs a way to post a comment on the Linear ticket linking to the fix. You also need to update the test runner at the bottom of the file so it actually calls fetchBugsToFix and prints the results.

  • Still in src/linear.ts, add the following function below fetchBugsToFix, before the if block:
// Post a comment on a Linear issue
export async function addComment(
  issueId: string,
  body: string
): Promise<void> {
  await client.createComment({ issueId, body });
}

What does addComment do?

This function wraps the Linear SDK's createComment method. You will use it in a later step to post a link to the draft PR directly on the bug ticket, closing the feedback loop between Linear and GitHub.

Now update the if block at the bottom of the file so it calls fetchBugsToFix and prints each bug.

  • Inside the if block, find this line:
  console.log(`Connected to Linear as: ${me.name}`);
  • Add the following code below it, before the closing } of the if block:
  // Fetch bugs labeled "auto-fix" and print them
  const bugs = await fetchBugsToFix("auto-fix");
  console.log(`\nFound ${bugs.length} bug(s) labeled "auto-fix":\n`);
  bugs.forEach((bug, i) => {
    console.log(`  ${i + 1}. ${bug.identifier}: ${bug.title}`);
  });

What does the updated test runner do?

The if block now does two things. First, it still prints the connected user to confirm the API key works. Then it fetches all bugs labeled auto-fix and prints a numbered list of their identifiers and titles.

Before you run the script, you need at least one bug ticket in Linear with the right label.

  • Open your Linear workspace in your browser.
  • Find one or two bug tickets in your project.
  • Add the label auto-fix to each one. If the label does not exist yet, Linear lets you create it inline when you type the name.
  • Make sure the tickets are not in a Done or Canceled state. The filter excludes completed and canceled issues.
  • Save src/linear.ts.
Run the script and verify bugs are fetched

Before you run the next command, think about what you expect to see. You labeled one or two tickets in Linear. What should the script print?

  • Run the script by running this command:
npx tsx src/linear.ts

You should see your connected user name, followed by a numbered list of the bugs you labeled. Each line shows the ticket identifier and title.

What should I see?

Your output looks something like this (with your own ticket IDs and titles):

Connected to Linear as: Your Name

Found 2 bug(s) labeled "auto-fix":

  1. BUG-42: Fix login redirect loop
  2. BUG-57: Handle empty API response

No bugs showing up?

  • Check that the label name in Linear is exactly auto-fix (lowercase, with a hyphen). Label matching is case-sensitive.
  • Verify the issues are not in a completed or canceled state. The filter uses nin: ["completed", "canceled"], so done tickets are excluded.
  • Make sure you assigned the label to the issue itself, not to the project. Labels on the project level do not automatically apply to issues.
  • help me debug why fetchBugsToFix returns no bugs

✔️ Awesome, I've got everything!

Great. Double check you have saved src/linear.ts.

ⓧ I'd like to double check the full code

Scroll up and confirm your src/linear.ts has each of these sections in order:

  • The import and LinearClient initialization (from the previous step).
  • The exported Bug interface with five fields.
  • The exported fetchBugsToFix function with the label and state filter.
  • The exported addComment function calling client.createComment().
  • The if block that prints the user name, fetches bugs, and prints a numbered list.

Your script now has a queue of bugs to work through. Next up, you will hand each bug to Claude Code and let it generate a fix.

Generate a Fix with Claude Code

Your script can now pull bug tickets from Linear. The next question is: who actually fixes them?

Claude Code in headless mode is a full AI coding agent you can invoke from the command line. It reads your codebase, understands the structure, edits files, and runs tests. All of this happens in a single CLI call with no human interaction.

In this step, get ready to:
  • Write a fixBug function that invokes Claude Code headless against your target repo.
  • Parse Claude's output and verify it actually changed files.
  • Run the fixer against a real bug and observe where the workflow falls short.
Create src/fix-bug.ts with a fixBug function

The fix-bug module is responsible for taking a single bug, building a prompt from it, and handing that prompt to Claude Code. You need two type definitions to describe the shapes of data flowing through this module.

  • Create a new file src/fix-bug.ts and add the imports and type definitions by pasting the following code:
import { execFileSync } from "child_process";
import type { Bug } from "./linear.js";
import { fetchBugsToFix } from "./linear.js";

// Describes the outcome of a fix attempt
export interface FixResult {
  success: boolean;
  prUrl?: string;
  reason?: string;
}

// Shape of Claude Code's JSON response
interface ClaudeOutput {
  result: string;
  session_id: string;
}

What does this code do?

FixResult is the return type for every fix attempt. It tracks whether the fix succeeded, an optional PR URL (used in a later step), and a reason string for failures or summaries.

ClaudeOutput matches the JSON structure that Claude Code returns when you pass --output-format json. The result field contains Claude's text summary of what it did.

Now you need the function that builds a prompt from a bug's details and sends it to Claude. The prompt gives Claude the bug's ID, title, and description, plus explicit instructions to fix it, run tests, and retry if tests fail.

  • Below the ClaudeOutput interface, add the fixBug function with its prompt builder by pasting this code:
export async function fixBug(
  bug: Bug,
  repoPath: string
): Promise<FixResult> {
  // Prepare a branch name for future isolation
  const branchName = `auto-fix/${bug.identifier.toLowerCase()}`;

  try {
    // Build a structured prompt from the bug details
    const prompt = [
      "Fix the following bug in this codebase.",
      "",
      `Bug ID: ${bug.identifier}`,
      `Title: ${bug.title}`,
      bug.description ? `Description: ${bug.description}` : "",
      "",
      "Instructions:",
      "1. Find the relevant code related to this bug.",
      "2. Implement a minimal, focused fix.",
      "3. Run the existing test suite to verify your fix does not break anything.",
      "4. If tests fail after your fix, revert and try a different approach.",
    ]
      .filter(Boolean)
      .join("\n");

What does this code do?

The prompt is assembled as an array of strings, then joined with newlines. The .filter(Boolean) call strips out any empty strings (like when a bug has no description).

The instructions tell Claude to find the bug, implement a minimal fix, run the test suite, and try again if tests break. This gives Claude a clear, repeatable workflow.

  • Save src/fix-bug.ts.
Invoke Claude Code and check for changes

The prompt is ready. Now you need to actually call Claude Code with it, then inspect whether Claude changed any files.

  • Directly below the .join("\n"); line, add the Claude Code invocation by pasting this code:
    // Invoke Claude Code headless in the target repo
    const rawOutput = execFileSync(
      "claude",
      [
        "-p",
        prompt,
        "--output-format",
        "json",
        "--allowedTools",
        "Bash,Read,Edit",
      ],
      {
        cwd: repoPath,
        encoding: "utf-8",
        maxBuffer: 50 * 1024 * 1024,
        timeout: 10 * 60 * 1000,
      }
    );

What does this code do?

execFileSync runs the claude CLI as a child process. The -p flag tells Claude Code to run non-interactively with the given prompt.

--allowedTools Bash,Read,Edit lets Claude use bash commands, read files, and edit files without prompting for permission. The 10-minute timeout and 50 MB buffer give Claude enough room to work through complex fixes.

Why execFileSync instead of execSync?

execFileSync does not pass arguments through a shell. This avoids shell injection from bug titles or descriptions that might contain special characters like backticks or semicolons.

After Claude finishes, you need to parse its JSON output and check whether it actually modified any files. If the diff is empty, Claude could not fix the bug.

  • Directly below the Claude invocation's closing );, add the parsing logic, error handling, and a cleanup helper by pasting this code:
    // Parse Claude's structured JSON response
    const output: ClaudeOutput = JSON.parse(rawOutput);

    // Check if Claude actually modified any files
    const diff = execFileSync("git", ["diff", "--stat"], {
      cwd: repoPath,
      encoding: "utf-8",
    });

    if (!diff.trim()) {
      return { success: false, reason: "Claude made no code changes" };
    }

    return { success: true, reason: output.result };
  } catch (error) {
    const message =
      error instanceof Error ? error.message : String(error);
    return { success: false, reason: message };
  }
}

// Resets the repo to main and deletes a failed branch
function cleanup(branchName: string, repoPath: string): void {
  try {
    execFileSync("git", ["checkout", "main"], { cwd: repoPath });
    execFileSync("git", ["branch", "-D", branchName], {
      cwd: repoPath,
    });
  } catch {
    // Ignore cleanup errors
  }
}

What does this code do?

  • git diff --stat checks whether Claude actually changed files. If the diff is empty, the function returns a failure with the reason "Claude made no code changes".
  • The catch block wraps the entire attempt so a crash mid-fix returns a clean error instead of blowing up the pipeline.
  • The cleanup helper resets the repo to main and deletes a failed branch. You will wire it into the function in a later step when you add branch isolation.
  • Save src/fix-bug.ts.
  • Verify the file compiles by running:
npx tsc --noEmit

No output means no type errors. If you see errors, double-check that your import paths end in .js and that the interfaces match the code above exactly.

Seeing type errors?

  • Make sure you import from ./linear.js (not ./linear.ts). ES module resolution requires the .js extension even when the source file is .ts.
  • Check that the Bug interface is exported from src/linear.ts with the export keyword.

Why is my TypeScript file not compiling?

Add a test harness and run it against a real bug

The function is ready, but you need a way to actually invoke it. A test harness at the bottom of the file will grab the first bug from Linear and feed it to fixBug.

  • At the very bottom of src/fix-bug.ts, below the cleanup function, add the test harness by pasting this code:
if (process.argv[1]?.endsWith("fix-bug.ts")) {
  const repoPath = process.env.REPO_PATH!;
  const bugs = await fetchBugsToFix("auto-fix");

  if (bugs.length === 0) {
    console.log("No bugs to fix.");
    process.exit(0);
  }

  console.log(`Testing fix on: ${bugs[0].identifier} - ${bugs[0].title}`);
  const result = await fixBug(bugs[0], repoPath);
  console.log("Result:", result);
}

What does this code do?

The process.argv[1]?.endsWith("fix-bug.ts") guard ensures this block only runs when you execute the file directly. It fetches the first "auto-fix" bug and passes it to fixBug along with the repo path from your environment.

  • Save src/fix-bug.ts.

✔️ Awesome, I've got everything!

Great. Double check you have saved src/fix-bug.ts.

ⓧ I'd like to double check the full code

Your src/fix-bug.ts file is built from five sections. Scroll up and confirm each one is present in this order:

  • Imports from child_process and ./linear.js.
  • FixResult and ClaudeOutput interfaces.
  • fixBug function with prompt builder, Claude invocation, diff check, and error handling.
  • cleanup helper function.
  • Test harness if (process.argv[1]?.endsWith("fix-bug.ts")) block at the bottom.

Time to let Claude loose on a real bug. Before you run this, think about what you expect to happen. Claude will read your entire repo, try to fix the bug, and possibly edit files. What do you think the result will look like?

  • Run the fixer against your first bug by running this command:
npx tsx src/fix-bug.ts

What should I see?

Claude Code will take anywhere from 30 seconds to several minutes depending on your repo's size. You will see a line like Testing fix on: BUG-123 - Some bug title followed by a Result: object showing whether the fix succeeded.

If the result shows success: true, Claude changed files in your repo. Confirm this by checking the diff.

  • See which files Claude modified by running this command:
cd $REPO_PATH && git diff --stat

You should see a list of changed files with insertion and deletion counts.

Now look at where those changes landed. They are sitting as unstaged modifications on whatever branch you have checked out. There is no isolation, no PR, no review gate. If you are on main, these unreviewed AI-generated changes are one git add away from production.

Why is this a problem?

An automated fixer that dumps changes directly onto main is dangerous. You need each fix isolated in its own branch with a draft PR so a human reviews before anything merges. That is exactly what the next step adds.

  • Reset the working tree to discard Claude's changes by running this command:
git checkout -- .
  • Confirm the reset worked by running:
git diff --stat

The output should be empty, meaning all of Claude's modifications are gone.

Claude returned success: false?

  • If the reason is "Claude made no code changes", the bug description may be too vague for Claude to act on. Try updating the bug's description in Linear with more detail about where the issue is and what the expected behavior should be.
  • If you see a timeout error, your repo may be very large. The 10-minute timeout is set in the execFileSync options. Ensure your REPO_PATH points to the correct repository.
  • If you see a "command not found" error for claude, make sure the Claude Code CLI is installed and available in your terminal's PATH.

Why is Claude Code not fixing my bug?

Claude can now read your bugs and generate fixes. But those fixes land as loose, unreviewed changes on your current branch. Next up, you will isolate each fix in its own branch and open a draft PR on GitHub so a human always reviews before anything merges.

Add Branch Isolation and Draft PRs

In the previous step, Claude Code generated a fix and you saw modified files appear in git diff --stat. But those changes were sitting as loose, unstaged modifications on whatever branch you happened to be on.

That is one git add away from landing unreviewed code on main. Each fix needs its own branch, a commit, a push, and a draft PR on GitHub so a human reviews before anything merges. You also need an orchestrator that loops through every bug and ties the whole pipeline together.

In this step, get ready to:
  • Wrap each fix in its own git branch with commit-and-push logic.
  • Create draft PRs via the GitHub CLI and post PR links back to Linear.
  • Build an orchestrator that processes all labeled bugs in a single run.
Add branch creation and cleanup to the fix function

Right now fixBug runs Claude on whatever branch you happen to be on. You need it to start from a fresh main, create an isolated branch for the fix, and clean up if anything goes wrong.

  • In src/fix-bug.ts, find the const branchName line inside fixBug.
  • Add the following three lines directly after it, before the try { block:
  execFileSync("git", ["checkout", "main"], { cwd: repoPath });
  execFileSync("git", ["pull", "origin", "main"], { cwd: repoPath });
  execFileSync("git", ["checkout", "-b", branchName], { cwd: repoPath });

What does this code do?

These three commands ensure every fix starts from the latest main. They check out main, pull the latest changes from the remote, and create a new branch named after the bug identifier (e.g. auto-fix/bug-123).

Running these before the try block means the branch always gets created. If Claude fails inside the try, the catch can clean up the branch.

Next, update the cleanup calls so the branch gets deleted when it is no longer needed.

  • Find the if (!diff.trim()) block inside the try. It currently looks like this:
    if (!diff.trim()) {
      return { success: false, reason: "Claude made no code changes" };
    }
  • Add a cleanup(branchName, repoPath) call before the return, so the block now looks like this:
    if (!diff.trim()) {
      cleanup(branchName, repoPath);
      return { success: false, reason: "Claude made no code changes" };
    }
  • Now find the catch (error) block at the bottom of fixBug. It currently looks like this:
  } catch (error) {
    const message =
      error instanceof Error ? error.message : String(error);
    return { success: false, reason: message };
  }
  • Add cleanup(branchName, repoPath) as the first line inside the catch, so it now looks like this:
  } catch (error) {
    cleanup(branchName, repoPath);
    const message =
      error instanceof Error ? error.message : String(error);
    return { success: false, reason: message };
  }

Why cleanup in both places?

If Claude made no file changes, the branch is empty and useless. Calling cleanup deletes it and returns to main.

If Claude throws an error (timeout, JSON parse failure), the same cleanup runs in the catch block. Either way, the repo is left on main with no stale branches.

Add commit, push, and draft PR creation

When Claude does modify files, you need to stage, commit, push, and open a draft PR. The GitHub CLI can create pull requests directly from the terminal.

  • In src/fix-bug.ts, find the line return { success: true, reason: output.result }; inside the try block (after the if (!diff.trim()) check).
  • Delete that line and replace it with the following code that stages, commits, and pushes the fix:
    // Stage all changes Claude made and commit them
    execFileSync("git", ["add", "-A"], { cwd: repoPath });
    execFileSync(
      "git",
      ["commit", "-m", `fix: ${bug.title}`],
      { cwd: repoPath }
    );
    execFileSync("git", ["push", "origin", branchName], {
      cwd: repoPath,
    });

What does this code do?

  • git add -A stages every file Claude touched, including new files.
  • The commit message follows the fix: <title> convention so reviewers can immediately see the intent.
  • git push origin sends the branch to GitHub, where the draft PR will reference it.
  • Immediately after the push lines you just pasted, add the following code to create a draft PR and return to main:
    // Build the PR body with Claude's summary
    const prBody = [
      `Automated fix for [${bug.identifier}](${bug.url})`,
      "",
      "## What Claude did",
      output.result,
      "",
      "---",
      "*This PR was generated automatically. Please review carefully before merging.*",
    ].join("\n");

    // Open a draft PR via the GitHub CLI
    const prUrl = execFileSync(
      "gh",
      [
        "pr",
        "create",
        "--draft",
        "--title",
        `fix: ${bug.title}`,
        "--body",
        prBody,
        "--base",
        "main",
      ],
      { cwd: repoPath, encoding: "utf-8" }
    ).trim();

    // Return to main so the next bug starts clean
    execFileSync("git", ["checkout", "main"], { cwd: repoPath });

    return { success: true, prUrl };

What does this code do?

  • prBody assembles a Markdown PR description that links back to the Linear ticket and includes Claude's summary of what it changed.
  • gh pr create --draft creates the pull request as a draft. Draft PRs prevent accidental merges. The --base main flag tells GitHub which branch to merge into. On success, gh prints the new PR's URL to stdout.
  • After PR creation, the function checks out main again so the next bug in the loop starts from a clean state.

Why draft PRs?

This pipeline generates code with no human oversight. A draft PR forces someone to review before merging. Using the --draft flag is a single flag that enforces this safety gate.

  • Save src/fix-bug.ts.

Before you run this, think about what you expect to happen. Will Claude create a branch, fix the bug, push it, and open a real PR?

  • Test the updated function by running:
npx tsx src/fix-bug.ts

Claude will run headless, modify files, and you should see a PR URL printed in the result output. Check your GitHub repository. You should see a new draft PR with the fix.

Not seeing a draft PR?

  • Make sure gh auth status shows you authenticated. The gh pr create command needs an active gh session.
  • Check that your REPO_PATH points to a repo that has a remote on GitHub. Run git remote -v inside that repo to confirm.
  • If you see a branch name conflict, your previous test from the last step may have left a branch behind. Delete it with git branch -D auto-fix/<identifier> and try again.

If you are still stuck, help me debug why my draft PR is not being created.

  • Clean up the test PR so the next run does not hit a branch conflict. Close the PR and delete the remote branch by running these commands from inside your target repo:
gh pr close --delete-branch "$(gh pr list --draft --json url --jq '.[0].url')"
  • Return to your linear-bug-fixer project directory.

✔️ Awesome, I've got everything!

Great. Double check you have saved src/fix-bug.ts and your draft PR test was successful.

ⓧ I'd like to double check the full code

Since src/fix-bug.ts is a long file, scroll through it and confirm these sections appear in order:

  • Imports from child_process and ./linear.js.
  • FixResult and ClaudeOutput interfaces.
  • fixBug function opening with branchName, then three execFileSync git calls (checkout main, pull, checkout -b).
  • try block with the Claude prompt, execFileSync("claude", ...), JSON parse, and git diff --stat.
  • No-diff check with cleanup call before the return.
  • git add -A, git commit, git push sequence.
  • prBody construction and gh pr create --draft call.
  • Checkout main and return { success: true, prUrl }.
  • catch block with cleanup call as the first line.
  • cleanup helper function.
  • Test harness if (process.argv[1]?.endsWith("fix-bug.ts")) block at the bottom.
Build the orchestrator in src/index.ts

The fixBug function handles a single bug. You need an orchestrator that fetches every labeled bug, runs fixBug on each one, posts a comment on the Linear ticket with the result, and prints a summary at the end.

  • Create a new file src/index.ts and add the following imports, constants, and main function header:
import { fetchBugsToFix, addComment } from "./linear.js";
import { fixBug } from "./fix-bug.js";

// Read label from env, default to "auto-fix"
const LABEL = process.env.BUGFIX_LABEL ?? "auto-fix";
const REPO_PATH = process.env.REPO_PATH!;

// Prefix every log line with an ISO timestamp
function log(message: string): void {
  const timestamp = new Date().toISOString();
  console.log(`[${timestamp}] ${message}`);
}

async function main(): Promise<void> {
  log("Starting nightly bug fixer");
  log(`Looking for bugs labeled "${LABEL}"`);

  const bugs = await fetchBugsToFix(LABEL);
  log(`Found ${bugs.length} bug(s) to process`);

  if (bugs.length === 0) {
    log("Nothing to do. Exiting.");
    return;
  }

  // Collect results for the end-of-run summary
  const results: {
    identifier: string;
    success: boolean;
    prUrl?: string;
    reason?: string;
  }[] = [];

What does this code do?

  • The LABEL constant reads from the BUGFIX_LABEL environment variable, defaulting to auto-fix. This lets you change the target label without editing code.
  • The log function prepends an ISO timestamp to every message. When this runs at 2 AM unattended, timestamps in the log file tell you exactly when each bug was processed.
  • The results array accumulates outcomes so the summary at the end can report which bugs were fixed and which failed.
  • Add the main processing loop directly after the results array declaration:
  // Process each bug: fix it, then comment on the Linear ticket
  for (const bug of bugs) {
    log(`\nProcessing ${bug.identifier}: ${bug.title}`);
    try {
      const result = await fixBug(bug, REPO_PATH);
      results.push({ identifier: bug.identifier, ...result });

      if (result.success && result.prUrl) {
        // Post a comment on the Linear ticket linking to the PR
        await addComment(
          bug.id,
          `Automated fix submitted: ${result.prUrl}`
        );
        log(`Created PR for ${bug.identifier}: ${result.prUrl}`);
      } else {
        await addComment(
          bug.id,
          `Auto-fix attempted but could not resolve: ${result.reason}`
        );
        log(`Could not fix ${bug.identifier}: ${result.reason}`);
      }
    } catch (error) {
      const message =
        error instanceof Error ? error.message : String(error);
      log(`Error processing ${bug.identifier}: ${message}`);
      results.push({
        identifier: bug.identifier,
        success: false,
        reason: message,
      });
    }
  }

What does this code do?

  • The loop calls fixBug for each bug. If the fix succeeds and a PR URL exists, it posts a comment on the Linear ticket with the link.
  • If Claude could not fix the bug (no changes or an error), it still comments on the ticket explaining what happened. This keeps the Linear ticket updated regardless of outcome.
  • The outer try/catch ensures that a crash on one bug does not stop the rest of the queue from being processed.
  • Add the summary output and the main() call at the bottom of the file, after the closing } of the for loop:
  // Print a summary of what happened
  log("\n--- Summary ---");
  const fixed = results.filter((r) => r.success);
  const failed = results.filter((r) => !r.success);
  log(`Fixed: ${fixed.length} | Failed: ${failed.length}`);
  for (const r of fixed) {
    log(`  [OK] ${r.identifier} -> ${r.prUrl}`);
  }
  for (const r of failed) {
    log(`  [FAIL] ${r.identifier} -> ${r.reason}`);
  }
}

main().catch((error) => {
  log(`Fatal error: ${error}`);
  process.exit(1);
});

What does this code do?

  • The summary splits results into fixed and failed arrays and prints a count of each. For fixed bugs, it shows the PR URL. For failed bugs, it shows the reason.
  • The main().catch(...) call at the bottom kicks off the orchestrator. If main throws an unhandled error, it logs the failure and exits with a non-zero code so the calling shell script knows something went wrong.
  • Save src/index.ts.

✔️ Awesome, I've got everything!

Great. Double check you have saved src/index.ts before running it.

ⓧ I'd like to double check the full code

Since src/index.ts is a long file, scroll through it and confirm these sections appear in order:

  • Imports from ./linear.js (fetchBugsToFix and addComment) and from ./fix-bug.js (fixBug).
  • LABEL and REPO_PATH constants.
  • log function with ISO timestamp.
  • main function: fetch bugs, early return if empty, results array.
  • for loop calling fixBug, then addComment for both success and failure cases.
  • Summary section printing fixed and failed counts.
  • main().catch(...) at the bottom.

Before you run the full pipeline, think about what you expect. Will the orchestrator pick up your labeled bugs, create branches, open draft PRs, and post comments on Linear?

  • Make sure you have at least one bug ticket labeled "auto-fix" in your Linear workspace that is not in a completed or canceled state.
  • Run the full pipeline:
npx tsx src/index.ts

You should see timestamped log output showing each bug being processed. For each successful fix, the log prints a GitHub PR URL. The summary at the end reports how many bugs were fixed and how many failed.

  • Open your GitHub repository in a browser. You should see a new draft PR for each bug that Claude successfully fixed.
  • Open the corresponding bug ticket in Linear. You should see a new comment with a link to the draft PR.

Seeing a branch name conflict?

If you tested fix-bug.ts earlier and did not clean up the test PR and branch, the same bug's branch still exists. Delete the local branch with git branch -D auto-fix/<identifier> from inside your target repo. Then delete the remote branch with git push origin --delete auto-fix/<identifier>. Run npx tsx src/index.ts again.

If you are still stuck, help me debug why the orchestrator is failing.

Each fix is now isolated in its own branch with a draft PR and a review gate. Next up, you will schedule this entire pipeline to run automatically at 2 AM every night.

Schedule the Nightly Run with launchd

Your pipeline creates draft PRs on GitHub and comments on Linear tickets. Right now you have to run it manually every time.

The real payoff is waking up to a batch of draft PRs already waiting for review. In this step, you will wrap the pipeline in a shell script and schedule it to run every night at 2 AM using launchd, the native macOS job scheduler.

In this step, get ready to:
  • Create a shell wrapper script that sets up the environment and logs output to disk.
  • Write a launchd plist that fires the pipeline nightly at 2 AM.
  • Load and manually trigger the job to confirm everything works end to end.
Create the shell wrapper script

launchd runs commands in a minimal environment without your shell profile loaded. That means tools like node, claude, and gh are invisible unless you explicitly set the PATH.

  • Create a new file called run-fixer.sh in your linear-bug-fixer directory and paste in the following code:
#!/bin/bash

# Make Node, Claude, and gh visible to launchd
export PATH="/usr/local/bin:/opt/homebrew/bin:$PATH"

# Set your secrets and config
export LINEAR_API_KEY="your-linear-api-key-here"
export ANTHROPIC_API_KEY="your-anthropic-api-key-here"
export REPO_PATH="[[REPO_PATH=\"/absolute/path/to/your/repo\"]]"
export BUGFIX_LABEL="auto-fix"

# Create a date-stamped log file
LOG_DIR="$HOME/logs/bug-fixer"
mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/$(date +%Y-%m-%d).log"

# Navigate to the project directory
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"

# Run the pipeline and capture all output
echo "=== Bug Fixer Run: $(date) ===" >> "$LOG_FILE"
npx tsx src/index.ts >> "$LOG_FILE" 2>&1
echo "=== Run Complete: $(date) ===" >> "$LOG_FILE"

What does this script do?

The PATH export on line 4 adds Homebrew's directories so launchd can find node, claude, and gh. Without this, every command would fail with "command not found."

The SCRIPT_DIR block resolves the directory the script lives in and cds into it. This guarantees npx tsx src/index.ts runs from the project root, no matter where launchd invokes the script from.

All output goes into a date-stamped log file under ~/logs/bug-fixer/ so you can review each night's results.

  • Replace your-linear-api-key-here with your actual Linear API key.
  • Replace your-anthropic-api-key-here with your actual Anthropic API key.
  • Make the script executable by running this command:
chmod +x run-fixer.sh
  • Confirm the file is executable by running:
ls -l run-fixer.sh

You should see -rwxr-xr-x (or similar) at the start of the line. The x flags mean the file is executable.

Not seeing the x flags?

Make sure you ran chmod +x run-fixer.sh from inside the linear-bug-fixer directory. If you are in a different directory, chmod won't find the file.

Help me make my run-fixer.sh script executable.

✔️ Awesome, I've got everything!

Great. Double check you have saved run-fixer.sh.

ⓧ I'd like to double check the full code

#!/bin/bash

export PATH="/usr/local/bin:/opt/homebrew/bin:$PATH"

export LINEAR_API_KEY="your-linear-api-key-here"
export ANTHROPIC_API_KEY="your-anthropic-api-key-here"
export REPO_PATH="/absolute/path/to/your/repo"
export BUGFIX_LABEL="auto-fix"

LOG_DIR="$HOME/logs/bug-fixer"
mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/$(date +%Y-%m-%d).log"

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"

echo "=== Bug Fixer Run: $(date) ===" >> "$LOG_FILE"
npx tsx src/index.ts >> "$LOG_FILE" 2>&1
echo "=== Run Complete: $(date) ===" >> "$LOG_FILE"
Create the launchd plist

launchd reads job definitions from .plist files. Per-user jobs live in ~/Library/LaunchAgents/.

Why launchd instead of cron?

Apple recommends launchd as the only supported way to schedule jobs on macOS. cron is deprecated and can be silently disabled by macOS system settings.

launchd also handles sleep correctly. If your Mac Mini's display is asleep at 2 AM, launchd still fires the job. If the Mac was powered off, the job runs at the next scheduled time after it boots.

  • Make sure the LaunchAgents directory exists by running:
mkdir -p ~/Library/LaunchAgents
  • Create a new file at ~/Library/LaunchAgents/com.bugfixer.nightly.plist and paste in the following XML:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.bugfixer.nightly</string>
    <key>ProgramArguments</key>
    <array>
        <string>/bin/bash</string>
        <string>[[FIXER_SCRIPT_PATH="absolute path to your run-fixer.sh"]]
        </string>
    </array>
    <key>StartCalendarInterval</key>
    <dict>
        <key>Hour</key>
        <integer>2</integer>
        <key>Minute</key>
        <integer>0</integer>
    </dict>
    <key>StandardOutPath</key>
    <string>/tmp/bugfixer-stdout.log</string>
    <key>StandardErrorPath</key>
    <string>/tmp/bugfixer-stderr.log</string>
</dict>
</plist>

What do these plist keys mean?

  • Label is the unique identifier launchd uses to track the job.
  • ProgramArguments tells launchd to run /bin/bash with your run-fixer.sh script as the argument.
  • StartCalendarInterval with Hour: 2 and Minute: 0 fires the job daily at 2:00 AM. Omitting the day-of-week key means every day.
  • StandardOutPath and StandardErrorPath capture any output launchd itself produces, separate from the log your script writes.

Find your absolute script path

To get the absolute path for your run-fixer.sh, run pwd from the linear-bug-fixer directory and append /run-fixer.sh to the output. For example: /Users/yourname/Desktop/linear-bug-fixer/run-fixer.sh.

  • Confirm the plist file exists by running:
ls ~/Library/LaunchAgents/com.bugfixer.nightly.plist

You should see the full file path printed back to you.

File not found?

Double check that the filename is exactly com.bugfixer.nightly.plist and that it lives inside ~/Library/LaunchAgents/. A typo in either the directory or filename will prevent launchd from finding it.

Help me place my plist file in the right location.

✔️ Awesome, I've got everything!

Great. Double check you have saved the plist at ~/Library/LaunchAgents/com.bugfixer.nightly.plist.

ⓧ I'd like to double check the full code

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.bugfixer.nightly</string>
    <key>ProgramArguments</key>
    <array>
        <string>/bin/bash</string>
        <string>/absolute/path/to/linear-bug-fixer/run-fixer.sh</string>
    </array>
    <key>StartCalendarInterval</key>
    <dict>
        <key>Hour</key>
        <integer>2</integer>
        <key>Minute</key>
        <integer>0</integer>
    </dict>
    <key>StandardOutPath</key>
    <string>/tmp/bugfixer-stdout.log</string>
    <key>StandardErrorPath</key>
    <string>/tmp/bugfixer-stderr.log</string>
</dict>
</plist>
Load the job and verify

The plist is in place but launchd does not know about it yet. You need to load it so launchd starts watching the schedule.

  • Load the job by running this command:
launchctl load ~/Library/LaunchAgents/com.bugfixer.nightly.plist

Before you verify, what do you think you will see if the job loaded successfully?

  • Check that launchd registered the job by running:
launchctl list | grep bugfixer

You should see a line containing com.bugfixer.nightly. The first column shows the PID (a - means the job is not currently running, which is expected since it only fires at 2 AM).

No output from grep?

If nothing prints, the plist did not load. Check for XML errors by running:

plutil -lint ~/Library/LaunchAgents/com.bugfixer.nightly.plist

This command validates the XML structure and reports the exact line with an error. Fix any issues, then run the launchctl load command again.

Help me debug why my launchd job did not load.

Now trigger the job manually to confirm the full pipeline runs and output is captured.

  • Manually fire the job by running:
launchctl start com.bugfixer.nightly

This triggers the job immediately instead of waiting until 2 AM. The pipeline takes a minute or more depending on how many bugs are labeled.

  • After about a minute, check the log file by running:
cat ~/logs/bug-fixer/$(date +%Y-%m-%d).log

You should see the familiar timestamped output from your pipeline, including the bug processing log and the summary of fixed vs. failed tickets.

Log file is empty or missing?

Check /tmp/bugfixer-stderr.log for error output from launchd itself. A common cause is the ProgramArguments path in the plist not matching the actual location of run-fixer.sh.

Also verify that run-fixer.sh has the correct REPO_PATH and that your API keys are filled in (not the placeholder text).

Help me figure out why my nightly fixer log file is empty.

What about sleep and power-off?

Since this is a Mac Mini with no lid, launchd fires the job even if the display is asleep. If the Mac was powered off at 2 AM, the job does not execute retroactively. It runs at the next scheduled 2 AM after the Mac boots back up.

Your overnight bug-fixing machine is fully wired. Every night at 2 AM, your Mac Mini pulls bugs from Linear, hands them to Claude Code, and opens draft PRs for you to review in the morning. Next up, a secret mission to make it even better.

Secret mission

Add Slack Notifications

Your pipeline runs at 2 AM, but you still have to check log files to find out what happened. What if a Slack message was waiting for you when you woke up? In this mission, you will wire up a Slack Incoming Webhook so the pipeline posts a formatted summary of every nightly run to a channel of your choice.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. The launchd job itself costs nothing, but each nightly run consumes Claude API tokens proportional to the number of bugs and the size of your codebase.

Cost warning

While the launchd job is loaded, it fires every night at 2 AM. Each run calls Claude Code headless, which uses your Anthropic API credits. If you are not actively using the pipeline, unload the job (see the Pause tab below) to avoid unexpected charges.

Resources you used:

  • A com.bugfixer.nightly launchd job scheduled to run nightly at 2 AM.
  • The linear-bug-fixer project directory on your Desktop.
  • Log files stored in ~/logs/bug-fixer/.
  • Draft pull requests created on your GitHub repository.
  • Comments posted on Linear tickets linking to fix PRs.

Keep everything running

No action needed. Choose this if you are still actively using the pipeline and want it to keep running nightly.

  • The launchd job continues firing at 2 AM each night.
  • Check ~/logs/bug-fixer/ each morning for the latest run log.
  • Review any new draft PRs on GitHub before merging.
  • Monitor your Anthropic API usage to track token spend.

Pause - I'll come back to this later

Shut down the scheduled job to stop nightly runs and API charges. Your project files stay on disk so you can reload the job later.

  • Unload the launchd job by running this command:
launchctl unload ~/Library/LaunchAgents/com.bugfixer.nightly.plist
  • When you are ready to resume, reload the job by running:
launchctl load ~/Library/LaunchAgents/com.bugfixer.nightly.plist

Delete - I don't want to use this again

Remove all project resources and start fresh. This stops the nightly job, removes local files, and cleans up generated PRs.

  • Unload the launchd job by running:
launchctl unload ~/Library/LaunchAgents/com.bugfixer.nightly.plist
  • Delete the plist file by running:
rm ~/Library/LaunchAgents/com.bugfixer.nightly.plist
  • Delete the project directory by running:
rm -rf ~/Desktop/linear-bug-fixer
  • Delete the log files by running:
rm -rf ~/logs/bug-fixer
  • Go to your repository on GitHub and click the Pull requests tab.
  • Filter by Draft status to find the auto-generated PRs.
  • Close each draft PR that was created by the pipeline.

Secrets in run-fixer.sh

The run-fixer.sh script contained your LINEAR_API_KEY and ANTHROPIC_API_KEY in plain text. Deleting the project directory removes it, but if you committed it to version control, rotate those keys now.

Nice Work!

Nice Work!

You just built a fully automated overnight bug-fixing machine. Your Mac Mini now pulls bug tickets from Linear, hands them to Claude Code running in headless mode, and opens draft PRs on GitHub ready for your morning review.

You've learned how to:

  • Build a TypeScript pipeline that queries Linear for bug tickets labeled auto-fix and feeds each one to Claude Code in headless mode to generate fixes.
  • Wire up git branch isolation and the GitHub CLI to create draft PRs with AI-generated fixes, and establish a feedback loop where Linear tickets automatically receive comments linking to their fix PRs.
  • Schedule the entire pipeline as a launchd nightly job on your Mac Mini, firing at 2 AM with logs written to disk.
  • Secret Mission: Added Slack notifications so you wake up to a formatted summary of what was fixed overnight.

Ready to quiz yourself?