Use Linear and GitHub to Ship AI Fixes

Turn a Linear incident into a tested AI-generated GitHub pull request.

Introduction

30 Second Summary

During an incident, pressure makes the fastest fix feel like the best fix. A reliable response still needs proof before any change reaches production.

In this project, you will extend your deployed Linear incident workflow with a Gemini-powered repair path. The finished workflow turns an approved checkout-api action into one tested GitHub pull request while Linear stays in Fixing for human review.

What You'll Build

An approved checkout-api action becomes one tested pull request while its Linear incident stays in Fixing until a person merges the change.

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

  • One-file AI repair that limits Gemini's proposal to one existing checkout-api service file.
  • Test-gated delivery that opens exactly one GitHub pull request only after the repository tests pass.
  • Human-controlled incident progress where Linear remains in Fixing for review. The incident shows one Fix PR opened: comment with no Runbook published comment.
  • Secret Mission: Add a read-only Linear MCP lookup that inserts an Earlier incidents in Linear: section into each routing brief.

Are there any prerequisites?

This project assumes you completed Connect the Triage Workflow to Linear and Deploy Linear Triage to Cloud Run.

You need the existing Google Cloud project, Cloud Run service, Cloud SQL instance, Linear workspace, Langfuse project, and ~/langgraph-triage-linear clone from those projects.

Before We Start

Before the hands-on work begins, this is your moment to commit to turning an approved incident into a reviewable pull request. You will also define why tests and human review must stand between an AI-generated patch and production.

Set Up the Fix Repository and GitHub Access

Your deployed triage workflow can analyze approved incidents in Google Cloud Run. It still has no learner-owned codebase where a fix can be proposed.

The repository in this step gives the workflow a controlled target. Its planted capacity bug lets pytest prove the failure before any fixer exists.

In this step, get ready to:
  • Create your own GitHub repository from the service template.
  • Create a fine-grained token with access to that repository.
  • Configure the local variables and cloud secret for the workflow.
Create and test your repository

The service template contains the code that the workflow eventually patches. You will create an independent copy so every automated write stays inside a repository you own.

  • Open the triage-services template repository.
  • Click Use this template above the file list.
  • Select Create a new repository.
  • Select your personal GitHub account from the Owner menu.
  • Enter triage-services in the Repository name field.
  • Leave Include all branches unselected.
  • Click Create repository from template.

Why Use a Template Copy?

A template copy starts a new repository with a single commit. Its history is unrelated to the source repository.

A fork retains the parent relationship plus the parent repository's history. The independent template copy keeps this automated repair project inside your own boundary.

  • Record your GitHub username for the clone command: your-github-user.
  • Switch back to Google Cloud Shell.
  • Clone your repository onto your Desktop by running these commands:
cd ~/Desktop
git clone https://github.com/[[GITHUB_USER="your-github-user"]]/triage-services
ls triage-services

What Do These Commands Do?

  • The first command moves your terminal into ~/Desktop.
  • The second command creates a separate local clone named triage-services.
  • The final command lists the cloned files so you can confirm the repository arrived.

Having Trouble Cloning?

Confirm that the URL contains your GitHub username. Check that your new repository is named triage-services.

Ask for help with the clone output: Help me diagnose why my learner-owned triage-services repository will not clone in Google Cloud Shell.

The planted bug is a small connection pool serving a larger checkout burst. Inspecting both values gives you a prediction before the test reveals the result.

  • Return to your learner-owned triage-services repository on GitHub.
  • Select the checkout_api folder.
  • Open config.py.
  • Confirm that the file contains maximum_pool_size = 5 on main.
  • Return to the repository file list.
  • Select the tests folder.
  • Open test_peak_load.py.
  • Confirm that the peak-load gate uses PEAK_CONCURRENT_CHECKOUTS = 41.

Before you run the test, do you expect a pool size of five to serve 41 concurrent checkouts?

  • Run the repository test suite from your separate clone with these commands:
cd ~/Desktop/triage-services
python3 -m pytest

What Should You See?

The suite runs against the unchanged main branch. The result names tests/test_peak_load.py::test_pool_serves_peak_checkout_traffic as a failed test.

That failure proves the planted checkout capacity bug is present. It becomes the deterministic gate for the future patch.

Good. The bug is now measurable before the fixer touches it.

Does the Test Pass or Fail Somewhere Else?

Confirm that your terminal path ends in Desktop/triage-services. Confirm that the checked-out branch is main.

Ask for help comparing the result: Help me understand why my triage-services baseline does not fail at the checkout peak-load test.

Create a repository-scoped token

A fine-grained personal access token gives the workflow permission to write a branch and open a pull request. Limiting it to one repository keeps that authority narrow.

Creating a credential is the sensitive part of this setup. Its repository scope limits what a leaked token could reach.

  • Click your profile picture in the upper-right corner of GitHub.
  • Click Settings.
  • Select Developer settings from the left sidebar.
  • Select Personal access tokens.
  • Select Fine-grained tokens.
  • Click Generate new token.
  • Enter triage-fixer in the Token name field.

Why Use Least Privilege?

The workflow needs to read code and write one fix branch. It also needs permission to open a pull request.

Repository-specific access keeps unrelated private repositories outside the token's reach.

  • Choose an Expiration that covers the project.
  • Select your personal GitHub account as the Resource owner.
  • Choose Only select repositories under Repository access.
  • Select only triage-services from Selected repositories.
  • Set Contents to Read and write.
  • Set Pull requests to Read and write.
  • Click Generate token.
  • Copy the generated token into a secure temporary location.

Skip the Public Repository Test

Fine-grained tokens always include read-only access to public repositories. Reading another public repository would not prove that your selected repository scope can write code or open pull requests.

Configure the workflow credentials

The local workflow reads its repository settings from environment variables. Google Secret Manager holds the token for the deployed service.

  • Return to the ~/langgraph-triage-linear clone from the prerequisite project.
  • Open the existing .env file in Cloud Shell Editor.
  • Add the GitHub settings at the bottom by pasting this block:
GITHUB_TOKEN=your-fine-grained-pat-here
GITHUB_REPO=[[GITHUB_USER="your-github-user"]]/triage-services

What Do These Settings Control?

  • The GITHUB_TOKEN value authorizes repository writes and pull request creation.
  • The GITHUB_REPO value limits the workflow to your learner-owned triage-services repository.
  • Replace your-fine-grained-pat-here with the token you copied.
  • Save .env.
  • Confirm that GITHUB_REPO ends with /triage-services.

Do the Local Values Look Wrong?

Check that each setting occupies one line. Remove spaces around both equals signs.

Ask for help without sharing the token: Help me check the format of my GitHub environment variables without asking me to reveal the token.

The next action stores the credential in your cloud project. The secret value stays protected as long as you avoid screenshots of the creation form.

  • Open Secret Manager in the Google Cloud console.
  • Click Create secret.
  • Enter github-token in the Name field.

The Name field now identifies the exact secret that the deployment uses later.

  • Paste your fine-grained GitHub token into the Secret value field.
  • Click Create secret.
  • Confirm that the secret details show github-token with version 1 enabled.

The credential is now stored as secret version 1. The deployed workflow still needs permission to read it.

  • Grant the existing Cloud Run service account access to only this secret by running:
SERVICE_ACCOUNT=$(gcloud run services describe triage-receiver --region us-central1 --format="value(spec.template.spec.serviceAccountName)")
gcloud secrets add-iam-policy-binding github-token \
  --member="serviceAccount:$SERVICE_ACCOUNT" \
  --role="roles/secretmanager.secretAccessor"

What Does This Permission Do?

The first command reads the service account already attached to triage-receiver. It does not create or replace an identity.

The second command grants that identity the Secret Manager Secret Accessor role on github-token only. The command output shows the updated member and role binding.

Could Not Grant Secret Access?

Confirm that triage-receiver still exists in us-central1. Confirm that the new secret is named github-token.

Ask for help with the policy output: Help me diagnose why my existing Cloud Run service account cannot access github-token.

Before this final check, do you expect the new credentials to change the untouched bug on main?

  • Verify the original failing baseline again by running:
cd ~/Desktop/triage-services
python3 -m pytest

What Does the Final Check Prove?

You will still see tests/test_peak_load.py::test_pool_serves_peak_checkout_traffic fail on main.

The credentials are ready while the planted bug remains unchanged. A future patch must turn this same gate green before a pull request can open.

Your repository now exposes a measurable bug through its test. Next, you will connect the scoped GitHub access to Linear's incident lifecycle.

Configure the Linear Pull Request Lifecycle

Your learner-owned triage-services repository now exposes the checkout bug through a failing test.

The next goal is to connect its GitHub activity to Linear. The GitHub integration turns repository activity into team status changes.

A linked pull request needs to keep the incident in Fixing while review is pending. The merge should move that incident to Done.

In this step, get ready to:
  • Connect Linear only to your learner-owned GitHub repository.
  • Map opened pull requests to Fixing.
  • Map merged pull requests to Done without adding review automation.
Connect the repository to Linear

Repository-level access keeps this connection focused on the code used by the fixer. The integration does not need access to any other repository.

  • Return to Linear.
  • Open Settings from your workspace menu.
  • Select Features in the settings sidebar.
  • Select Integrations.
  • Select GitHub.
  • Click Enable.
  • Select the GitHub account that owns your-github-user/triage-services.
  • Choose Only select repositories.

Repository permissions deserve a careful pause. GitHub may show an authorization page before it returns you to Linear.

  • Select your-github-user/triage-services in the repository picker.
  • Click Install.
  • Approve the GitHub authorization prompt if it appears.
  • Wait for the GitHub integration settings to load in Linear.
  • Confirm the connected repositories list contains only your-github-user/triage-services.
  • Confirm the GitHub Issues Sync section has no configured repository connection.

Why leave GitHub Issues Sync off?

The pull request integration links repository activity to existing Linear incidents. That link provides the lifecycle signals this project needs.

GitHub Issues Sync creates or synchronizes issue records between the two products. This project keeps Linear as the incident source of truth.

Cannot see your repository?

  • Confirm the selected GitHub account owns your-github-user/triage-services.
  • Confirm the Linear GitHub App has access to your-github-user/triage-services.
  • Help me troubleshoot why Linear cannot see my learner-owned GitHub repository.
Configure the Incidents automations

Pull request automations are configured per team. Selecting Incidents prevents this lifecycle from changing how other Linear teams handle their work.

  • Return to Settings in Linear.
  • Select Incidents under Teams.
  • Select Workflows & automations.
  • Select Pull request and commit automations.
  • Set the opened pull request destination to Fixing.
  • Set the merged pull request destination to Done.
  • Set the review request or activity event to No action.
Verify the lifecycle rules

The future PR body links the incident record plus its approved action. Each reference has a different effect on the Linear lifecycle.

How the two references behave

The first line uses Fixes TRI-n. This closing reference lets the incident follow the pull request automation through Fixing to Done.

The next line uses Refs APR-n. This non-closing reference links the approval issue without applying the merge-to-Done transition.

Before you check, predict whether review activity should move the incident to another status.

  • Confirm the opened pull request event targets Fixing.
  • Confirm the merged pull request event targets Done.
  • Confirm the review request or activity event has no status destination.
  • Return to the GitHub integration settings.
  • Confirm your-github-user/triage-services is the only connected repository.
  • Confirm GitHub Issues Sync remains unconfigured.

What should I see?

  • Opened linked pull requests target Fixing.
  • Merged linked pull requests target Done.
  • Review activity has no status automation.
  • Only your-github-user/triage-services is connected.
  • GitHub Issues Sync has no repository-to-team connection.

That is the review boundary locked in. Next, you will build the test-gated path that earns the right to open the pull request.

Build the Test-Gated Fix Path

Your Linear pull request lifecycle is ready. A linked pull request can now keep an incident in Fixing until a person merges it.

The current approval path still continues toward publish_runbook. That side effect could move the incident to Done before anyone reviews the patch.

You will insert a LangGraph branch that gives Gemini a narrow proposal role. The repository's pytest suite decides whether GitHub receives a pull request.

In this step, get ready to:
  • Build a fixer that permits one existing service file to change.
  • Insert a deferred fix node after the approval gate.
  • Prove the complete workflow passes its local test suite.
Trace the current approval gap

An approved action currently enters a path with a terminal publishing side effect. Seeing that path first makes the new branch's purpose concrete.

Before you inspect the graph, where do you think an approved action goes after the approval gate?

  • In Google Cloud Shell, use the editor to open ~/langgraph-triage-linear/app.py.
  • Search build_graph for approval_gate.
  • Follow the outgoing edge from approval_gate to identify its next node.

You should find that the current path continues toward publish_runbook. This is the intended shortfall because the incident can reach Done before pull request review.

Why add a graph branch?

A conditional graph branch can stop execution when a fix result exists. The existing publish_runbook node keeps its original responsibility.

This isolates the new delivery lifecycle. Human merge authority remains outside the model.

Build the constrained fixer

The fixer separates model judgment from deterministic side effects. Its schema limits the response shape before Python checks the proposed path.

  • Use the editor's new-file control to create fixer.py inside ~/langgraph-triage-linear.
  • Add the imports, patch schema, configuration check, and package-name conversion by pasting this first chunk:
import os
import subprocess
import sys
import tempfile
from pathlib import Path

from github_api import GitHub

TEST_TIMEOUT_S = 120
PATCH_SCHEMA = {
    "title": "CodeFix",
    "type": "object",
    "properties": {
        "path": {"type": "string", "description": "The one file to replace, relative to the repo root."},
        "content": {"type": "string", "description": "The whole new file."},
        "summary": {"type": "string", "description": "One sentence for the PR title, under 70 characters."},
    },
    "required": ["path", "content", "summary"],
}

def configured() -> bool:
    return bool(os.environ.get("GITHUB_TOKEN") and os.environ.get("GITHUB_REPO"))

def package_for(service: str) -> str:
    return service.replace("-", "_")

What does this code do?

  • PATCH_SCHEMA requires a path, a complete replacement file, and a short summary.
  • configured() confirms that both GitHub settings exist before the workflow attempts repository access.
  • package_for() maps a service such as checkout-api to its Python package name.
  • Save fixer.py.
  • Confirm the file begins with the imports and ends with package_for().

Does the schema look incomplete?

Check that the closing braces for properties and PATCH_SCHEMA line up. Preserve the commas after each property.

Ask for help with checking this schema.

The next boundary comes from the downloaded repository itself. Only Python files already present inside the approved service package become valid patch targets.

  • Add the source-file inventory and patch checks below package_for() by pasting:
def source_files(root: Path, package: str) -> dict[str, str]:
    return {str(path.relative_to(root)): path.read_text(encoding="utf-8") for path in sorted((root / package).rglob("*.py"))}

def check_patch(patch: dict, files: dict[str, str], package: str) -> str | None:
    path = patch["path"].strip().lstrip("./")
    if path not in files:
        return f"{path} is not an existing file in {package}/."
    if patch["content"] == files[path]:
        return f"The patch doesn't change {path}."
    return None

How is the file boundary enforced?

source_files() builds an allowlist from existing .py files. Sorting makes the prompt input stable across runs.

check_patch() rejects paths outside that allowlist. It also rejects an unchanged replacement.

  • Save fixer.py.
  • Confirm check_patch() contains both rejection paths.

Is the path check behaving strangely?

Check that lstrip("./") uses the exact punctuation shown above. Confirm that path not in files remains the first rejection check.

Ask for help with reviewing the allowlist logic.

A valid path still needs a repository-owned quality gate. The test subprocess receives a deliberately small environment before the model prompt is assembled.

  • Add the test runner and patch messages below check_patch() by pasting:
def run_tests(root: Path) -> tuple[bool, str]:
    try:
        result = subprocess.run(
            [sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider"],
            cwd=root,
            env={"PATH": os.defpath, "PYTHONDONTWRITEBYTECODE": "1"},
            capture_output=True,
            text=True,
            timeout=TEST_TIMEOUT_S,
        )
    except subprocess.TimeoutExpired:
        return False, f"The tests took longer than {TEST_TIMEOUT_S} s."
    output = (result.stdout + result.stderr).strip()
    return result.returncode == 0, "\n".join(output.splitlines()[-15:])

def patch_messages(analysis: dict, action: dict, files: dict[str, str]) -> list:
    listing = "\n\n".join(f"### {path}\n```python\n{text}\n```" for path, text in files.items())
    return [
        ("system", "You fix production incidents with the smallest safe code change. Replace exactly one of the files below. Return the whole new file, keep its comments up to date, and change nothing unrelated."),
        ("human", f"Likely cause: {analysis['likely_cause']}\n\nApproved action for {action['service']}: {action['action']}\n\nFiles in {package_for(action['service'])}/:\n\n{listing}"),
    ]

What protects the test gate?

run_tests() supplies only PATH and PYTHONDONTWRITEBYTECODE to the subprocess. This prevents an inherited GITHUB_TOKEN from reaching repository tests.

This environment boundary is not a sandbox. The tests still run as the current process user with network access.

patch_messages() gives the model the incident cause, approved action, and allowed file contents. The model receives no direct write tool.

  • Save fixer.py.
  • Confirm the subprocess environment contains exactly two keys.

Seeing an indentation problem?

Keep every argument to subprocess.run() inside its parentheses. Align the except block with try.

Ask for help with checking the function indentation.

The final function owns the ordered side effects. It looks for an existing branch first so a resumed node can reuse the pull request without calling the model again.

  • Add the fix proposal flow below patch_messages() by pasting:
def propose_fix(build_patcher, incident: str, analysis: dict, action: dict, approval_issue: str) -> dict:
    github = GitHub()
    package = package_for(action["service"])
    branch = f"{incident.lower()}-fix-{action['service']}"
    if pr := github.find_pr(branch):
        return {"status": "opened", "url": pr["html_url"], "branch": branch, "path": "", "output": ""}
    with tempfile.TemporaryDirectory() as tmp:
        root = github.download("main", Path(tmp))
        files = source_files(root, package)
        patch = build_patcher().invoke(patch_messages(analysis, action, files))
        path = patch["path"].strip().lstrip("./")
        if problem := check_patch(patch, files, package):
            return {"status": "blocked", "reason": problem, "output": ""}
        (root / path).write_text(patch["content"], encoding="utf-8")
        passed, output = run_tests(root)
        if not passed:
            return {"status": "blocked", "reason": "The repo's tests failed on the patch.", "output": output}
        refs = f"\nRefs {approval_issue}" if approval_issue else ""
        pr = github.open_pr(
            branch, path, patch["content"], title=f"{incident}: {patch['summary']}",
            body=(f"Fixes {incident}{refs}\n\n**Approved action:** {action['action']}\n\n**Likely cause:** {analysis['likely_cause']}\n\nWritten by the triage workflow's fixer. The repo's tests passed on this patch:\n\n```\n{output}\n```\n\nReview it before you merge."),
        )
        return {"status": "opened", "url": pr["html_url"], "branch": branch, "path": path, "output": output}

How are the side effects ordered?

  • find_pr() runs before the model call. A retry returns the existing pull request.
  • download() retrieves main into a temporary directory.
  • check_patch() enforces the existing-file boundary before anything is written.
  • open_pr() runs only after the repository tests pass.
  • Save fixer.py.
  • Confirm github.find_pr(branch) appears before build_patcher().invoke(...).

Is the pull request body malformed?

Keep Fixes {incident} at the start of the body. Preserve the conditional newline before Refs {approval_issue}.

Ask for help with checking the PR construction.

Compare your completed fixer against the reference if you want a file-level check.

✔️ Awesome, I've got everything!

Your saved fixer.py now owns the one-file boundary, test gate, and idempotent pull request flow.

ⓧ I'd like to double check the full code

import os
import subprocess
import sys
import tempfile
from pathlib import Path

from github_api import GitHub

TEST_TIMEOUT_S = 120
PATCH_SCHEMA = {
    "title": "CodeFix",
    "type": "object",
    "properties": {
        "path": {"type": "string", "description": "The one file to replace, relative to the repo root."},
        "content": {"type": "string", "description": "The whole new file."},
        "summary": {"type": "string", "description": "One sentence for the PR title, under 70 characters."},
    },
    "required": ["path", "content", "summary"],
}

def configured() -> bool:
    return bool(os.environ.get("GITHUB_TOKEN") and os.environ.get("GITHUB_REPO"))

def package_for(service: str) -> str:
    return service.replace("-", "_")

def source_files(root: Path, package: str) -> dict[str, str]:
    return {str(path.relative_to(root)): path.read_text(encoding="utf-8") for path in sorted((root / package).rglob("*.py"))}

def check_patch(patch: dict, files: dict[str, str], package: str) -> str | None:
    path = patch["path"].strip().lstrip("./")
    if path not in files:
        return f"{path} is not an existing file in {package}/."
    if patch["content"] == files[path]:
        return f"The patch doesn't change {path}."
    return None

def run_tests(root: Path) -> tuple[bool, str]:
    try:
        result = subprocess.run(
            [sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider"],
            cwd=root,
            env={"PATH": os.defpath, "PYTHONDONTWRITEBYTECODE": "1"},
            capture_output=True,
            text=True,
            timeout=TEST_TIMEOUT_S,
        )
    except subprocess.TimeoutExpired:
        return False, f"The tests took longer than {TEST_TIMEOUT_S} s."
    output = (result.stdout + result.stderr).strip()
    return result.returncode == 0, "\n".join(output.splitlines()[-15:])

def patch_messages(analysis: dict, action: dict, files: dict[str, str]) -> list:
    listing = "\n\n".join(f"### {path}\n```python\n{text}\n```" for path, text in files.items())
    return [
        ("system", "You fix production incidents with the smallest safe code change. Replace exactly one of the files below. Return the whole new file, keep its comments up to date, and change nothing unrelated."),
        ("human", f"Likely cause: {analysis['likely_cause']}\n\nApproved action for {action['service']}: {action['action']}\n\nFiles in {package_for(action['service'])}/:\n\n{listing}"),
    ]

def propose_fix(build_patcher, incident: str, analysis: dict, action: dict, approval_issue: str) -> dict:
    github = GitHub()
    package = package_for(action["service"])
    branch = f"{incident.lower()}-fix-{action['service']}"
    if pr := github.find_pr(branch):
        return {"status": "opened", "url": pr["html_url"], "branch": branch, "path": "", "output": ""}
    with tempfile.TemporaryDirectory() as tmp:
        root = github.download("main", Path(tmp))
        files = source_files(root, package)
        patch = build_patcher().invoke(patch_messages(analysis, action, files))
        path = patch["path"].strip().lstrip("./")
        if problem := check_patch(patch, files, package):
            return {"status": "blocked", "reason": problem, "output": ""}
        (root / path).write_text(patch["content"], encoding="utf-8")
        passed, output = run_tests(root)
        if not passed:
            return {"status": "blocked", "reason": "The repo's tests failed on the patch.", "output": output}
        refs = f"\nRefs {approval_issue}" if approval_issue else ""
        pr = github.open_pr(
            branch, path, patch["content"], title=f"{incident}: {patch['summary']}",
            body=(f"Fixes {incident}{refs}\n\n**Approved action:** {action['action']}\n\n**Likely cause:** {analysis['likely_cause']}\n\nWritten by the triage workflow's fixer. The repo's tests passed on this patch:\n\n```\n{output}\n```\n\nReview it before you merge."),
        )
        return {"status": "opened", "url": pr["html_url"], "branch": branch, "path": path, "output": output}

What should match?

The lookup precedes the model invocation. The test result precedes the pull request creation.

The subprocess environment contains only PATH and PYTHONDONTWRITEBYTECODE.

Wire the fix path into the graph

The application node turns the fixer's result into a lifecycle decision. Its last external write is the Linear comment so a resumed node repeats the safer lookup before writing again.

  • Add the fixer import to the existing import group in app.py.
  • Add the fixable service allowlist beside the existing constants using this reference:
import fixer
FIXABLE_SERVICES = {"checkout-api"}

What does this allowlist control?

FIXABLE_SERVICES limits the new branch to approved checkout-api actions. Other approved services retain the existing runbook path.

  • Save app.py.
  • Confirm the file now imports fixer and defines FIXABLE_SERVICES.
  • Add a fix field with type dict inside the existing IncidentState definition.

The state can now remember that the fix node already produced an opened or blocked result.

  • Add the structured patcher and fix node near the existing model helpers and graph nodes by pasting:
def build_patcher():
    return build_model().with_structured_output(schema=fixer.PATCH_SCHEMA, method="json_schema")

def open_fix_pr(state: IncidentState):
    if not state.get("issue_id") or not fixer.configured() or state.get("fix"):
        return {}
    approved = [a for a in state.get("approvals", []) if a["decision"] == "approve" and a["service"] in FIXABLE_SERVICES]
    if not approved:
        return {}
    action = approved[0]
    fix = fixer.propose_fix(build_patcher, state["incident_id"], state["analysis"], action, action.get("approval_issue", ""))
    linear = linear_client()
    if fix["status"] == "opened":
        body = f"**Fix PR opened:** {fix['url']}\n\nThe repo's tests passed on the patch. Review it before you merge."
    else:
        linear.move(state["issue_id"], INCIDENTS_TEAM, "Triage")
        body = f"**Fix blocked:** {fix['reason']} No pull request was opened." + (f"\n\n```\n{fix['output']}\n```" if fix["output"] else "")
    linear.comment(state["issue_id"], body)
    return {"fix": fix, "status": f"fix_{fix['status']}", "audit_log": state["audit_log"] + [f"Fixer for {action['service']}: {fix['status']}."]}

How does the node preserve the lifecycle?

build_patcher() binds Gemini to PATCH_SCHEMA. The node selects only an approved action from the allowlist.

An opened fix produces fix_opened. A blocked fix moves the incident to Triage before producing fix_blocked.

linear.comment() is the node's final external write. Completion can still read final_state['status'].

  • Save app.py.
  • Confirm the editor now lists build_patcher() and open_fix_pr() as functions.

Does the fix node report the wrong status?

Check that the return value uses f"fix_{fix['status']}". Confirm that the blocked branch moves the incident before the comment.

Ask for help with reviewing the node's write order.

The routing function ends the graph when a fix result exists. An empty result preserves the unchanged runbook path for incidents without an eligible fix.

  • Add fix_route() near the existing routing helpers.
  • Register open_fix_pr with deferred execution inside build_graph.
  • Replace the outgoing edge after approval_gate with the registrations shown below:
def fix_route(state: IncidentState):
    return END if state.get("fix") else "publish_runbook"

# build_graph additions/replacement:
builder.add_node("open_fix_pr", open_fix_pr, defer=True)
builder.add_edge("approval_gate", "open_fix_pr")
builder.add_conditional_edges("open_fix_pr", fix_route, ["publish_runbook", END])

What changes in the graph?

defer=True schedules the fix node for deferred execution. The approval gate now points to open_fix_pr.

fix_route() returns END when the state contains a fix. Otherwise it returns publish_runbook.

  • Leave the existing publish_runbook node unchanged.
  • Save app.py.
  • Confirm approval_gate now points to open_fix_pr.

Does the graph still bypass the fixer?

Remove the old outgoing edge from approval_gate. Keep the new edge and conditional route inside build_graph.

Ask for help with checking the graph registrations.

Use this additions-only reference to compare the new app.py surface.

✔️ Awesome, I've got everything!

Your application now records fix state and routes approved checkout fixes through the deferred node.

ⓧ I'd like to double check the full code

import fixer
FIXABLE_SERVICES = {"checkout-api"}
# IncidentState addition: fix: dict

def build_patcher():
    return build_model().with_structured_output(schema=fixer.PATCH_SCHEMA, method="json_schema")

def open_fix_pr(state: IncidentState):
    if not state.get("issue_id") or not fixer.configured() or state.get("fix"):
        return {}
    approved = [a for a in state.get("approvals", []) if a["decision"] == "approve" and a["service"] in FIXABLE_SERVICES]
    if not approved:
        return {}
    action = approved[0]
    fix = fixer.propose_fix(build_patcher, state["incident_id"], state["analysis"], action, action.get("approval_issue", ""))
    linear = linear_client()
    if fix["status"] == "opened":
        body = f"**Fix PR opened:** {fix['url']}\n\nThe repo's tests passed on the patch. Review it before you merge."
    else:
        linear.move(state["issue_id"], INCIDENTS_TEAM, "Triage")
        body = f"**Fix blocked:** {fix['reason']} No pull request was opened." + (f"\n\n```\n{fix['output']}\n```" if fix["output"] else "")
    linear.comment(state["issue_id"], body)
    return {"fix": fix, "status": f"fix_{fix['status']}", "audit_log": state["audit_log"] + [f"Fixer for {action['service']}: {fix['status']}."]}

def fix_route(state: IncidentState):
    return END if state.get("fix") else "publish_runbook"

# build_graph additions/replacement:
builder.add_node("open_fix_pr", open_fix_pr, defer=True)
builder.add_edge("approval_gate", "open_fix_pr")
builder.add_conditional_edges("open_fix_pr", fix_route, ["publish_runbook", END])

What should match?

The fix node returns a status in every opened or blocked result. The conditional edge can end the graph before publish_runbook runs.

The blocked path moves the incident before writing its final comment.

The final local test checks the complete code path. Before you run it, do you expect the new graph branch to preserve the passing suite?

  • Use the editor's terminal control for the ~/langgraph-triage-linear folder.
  • Run the complete local test suite with this command:
python3 -m pytest

What should I see?

You should see the full suite pass. The graph now includes approval_gate to open_fix_pr.

You may also see Returning fallback prompt for 'triage-analyst-label:production' due to fetch error. The fallback keeps the run moving.

  • Inspect app.py to confirm builder.add_edge("approval_gate", "open_fix_pr") remains present.
  • Inspect app.py to confirm builder.add_conditional_edges("open_fix_pr", fix_route, ["publish_runbook", END]) remains present.

That's the gate working: model output can reach GitHub only through the one-file check and the repository's passing tests.

Is the test suite failing?

Start with the first traceback. Check whether it points to fixer.py or one of the new app.py functions.

Confirm the old edge after approval_gate was replaced. Keep publish_runbook itself unchanged.

Ask for help with debugging the failing test.

Your local workflow now blocks unsafe patches and ends opened fixes before runbook publication. Next, you will deploy this branch and open the tested pull request.

Redeploy and Open the Fix Pull Request

Your test-gated fix path now passes its local suite. The graph also stops an opened fix before the unchanged runbook node.

The deployed workflow still runs the older source. The current Cloud Run revision cannot open the fix.

You'll publish a new Cloud Run revision with update-only flags. The approved action should stop at one tested pull request for human review.

In this step, get ready to:
  • Redeploy the triage-receiver service without replacing its existing configuration.
  • Approve the displayed checkout-api action for INC-2041 once in Linear.
  • Verify one tested pull request in GitHub for human review.
Publish the new revision safely

The --update-env-vars flag changes the named repository value. The --update-secrets flag updates only the named secret mapping.

These update-only flags preserve the service account. They also preserve the Cloud SQL attachment and the remaining service configuration.

  • Return to the Google Cloud Shell terminal from earlier that is already in ~/langgraph-triage-linear.

The source build can take a few minutes. A quiet stretch during the build is normal.

  • Publish the new revision with its repository setting plus secret mapping by running:
gcloud run deploy triage-receiver \
  --source . \
  --region=us-central1 \
  --update-env-vars=GITHUB_REPO="$GITHUB_REPO" \
  --update-secrets=GITHUB_TOKEN=github-token:1

What does this deployment preserve?

  • The --source . argument packages the current folder as the source for a new revision.
  • The --region=us-central1 argument targets the existing service region.
  • The --update-env-vars=GITHUB_REPO="$GITHUB_REPO" argument changes only the repository setting.
  • The --update-secrets=GITHUB_TOKEN=github-token:1 argument maps the token to version 1 of the Google Secret Manager secret.
  • Wait for the deployment command to finish.

You'll see the deployment finish for triage-receiver in us-central1. The new revision uses the updated GitHub values.

That deployment risk is handled. Your new source is live while the existing service configuration stays in place.

Deployment did not finish?

  • Confirm the terminal is using the ~/langgraph-triage-linear folder.
  • Confirm GITHUB_REPO still contains your learner-owned repository.
  • Confirm the existing github-token secret contains version 1.

Help me trace why my triage-receiver source deployment failed.

Seed and approve the incident once

The model can produce one approval gate or two. The valid trigger for this demo is the displayed checkout-api action.

This incident should run only once during the demo. A run after merging the fix would start from a main branch where the planted bug no longer exists.

  • Use the incident-seeding flow from the prerequisite deployment project to seed INC-2041 once.
  • Switch back to the INC-2041 incident in Linear.
  • Locate the displayed action for checkout-api.
  • Approve that action by clicking Approve.

The fixer now downloads the repository and asks Gemini for a one-file replacement. It runs the repository tests before it can open the pull request.

This run can take a few minutes while the model proposes the patch. A quiet gap here is expected.

  • Wait for the workflow run to finish.

A fallback log is safe here

You may see a log line beginning Returning fallback prompt for 'triage-analyst-label:production' due to fetch error. The workflow continues with its fallback prompt.

Verify the human review boundary

Before you inspect the result, do you expect the approved action to stop at a pull request or continue into the runbook path? Hold that prediction in mind.

  • Switch back to your learner-owned triage-services repository in GitHub.
  • Select the Pull requests tab.
  • Open the pull request whose source branch matches the tri-n-fix-checkout-api pattern.
  • Inspect the first lines of the pull request body.
  • Review the pull request diff to identify the changed file.
  • Read the test output quoted in the pull request body.

What should the pull request prove?

  • Exactly one pull request exists for the fix branch.
  • The pull request remains unmerged for human review.
  • The body starts with Fixes TRI-n. It includes Refs APR-n when an approval issue exists.
  • The diff changes exactly one existing Python file inside checkout_api. The quoted test output shows that the repository tests passed.

The exact patch can vary. Gemini may replace all of checkout_api/config.py or make a smaller valid change.

  • Switch back to the INC-2041 incident in Linear.
  • Read the incident status.
  • Count the comments headed Fix PR opened:.
  • Search the incident discussion for Runbook published.

You should see the incident in Fixing. You should see exactly one Fix PR opened: comment.

You should find no Runbook published comment. This proves the new branch ended before publish_runbook.

Missing a pull request or Linear update?

  • Refresh Linear after its GitHub link has had time to update.
  • Confirm the approved action names checkout-api.
  • Confirm the pull request body begins with the incident's Fixes reference.

Help me trace where this fix workflow stopped.

  • Leave the pull request unmerged for human review.

Why retries stay idempotent

The fixer calls find_pr before invoking the model. A resumed run can return the existing pull request instead of opening another one.

You have the review boundary working. An approved incident can now produce one tested fix without granting the model merge authority.

Secret mission

Secret Mission: Recall Earlier Incidents Through Linear MCP

Give the router up to three earlier Linear incidents before it analyzes a fresh alert. The model receives read-only context while every write remains inside the existing deterministic workflow.

Clean Up Your Resources

Clean Up Your Resources

Your GitHub fix workflow extends an existing Google Cloud deployment whose Cloud SQL instance can keep costing money. Choose Keep, Pause, or Delete before more Gemini usage is generated.

Cost warning

The db-f1-micro Cloud SQL tier costs $0.0105 per running hour. Storage is charged separately.

Gemini requests are usage-based. Pause or Delete is the safer choice when you do not plan to run more incidents.

Resources you used:

  • Your learner-owned triage-services repository.
  • The tri-n-fix-checkout-api branch.
  • The unmerged tested pull request linked to INC-2041.
  • Your fine-grained GitHub PAT with Contents: Read and write plus Pull requests: Read and write.
  • The github-token secret in Google Secret Manager.
  • The new revision of the triage-receiver service on Google Cloud Run.
  • The existing Cloud SQL instance from Deploy Linear Triage to Cloud Run.

Keep everything running

No action is needed. Choose this option if you plan to keep testing the human-gated workflow.

  • Keep the INC-2041 pull request unmerged while you want Linear to show Fixing.
  • Retain the triage-services repository for follow-on work.
  • Retain the fine-grained PAT until its planned expiry.
  • Retain github-token while triage-receiver still uses it.
  • Leave the Cloud Run service deployed.
  • Leave Cloud SQL running only while you need a working demo.

Pause - I'll come back to this later

Pausing preserves your repository plus deployment settings. Stopping Cloud SQL reduces compute charges while storage charges may continue.

  • Return to the Cloud SQL stop option from Deploy Linear Triage to Cloud Run to stop the existing instance.
  • Leave triage-receiver deployed.
  • Keep the github-token secret.
  • Keep the fine-grained PAT.
  • Keep the INC-2041 pull request unmerged.
  • Avoid starting fresh Linear-backed runs while Cloud SQL is stopped.

The workflow cannot complete while its checkpoint database is stopped.

Delete - I don't want to use this again

Deleting the stack is permanent. Save any review evidence you want to keep before removing the resources.

  • Save any useful pull request diff from the unmerged fix.
  • Return to the cleanup path from Deploy Linear Triage to Cloud Run to delete the triage-receiver Cloud Run service.
  • Use that prerequisite cleanup path to delete the existing Cloud SQL instance.
  • Open Secret Manager in the Google Cloud console.
  • Select the github-token secret.
  • Use the secret's deletion control to delete it.

The remaining cleanup removes the workflow's GitHub access plus its learner-owned repository.

GitHub deletion is permanent

Deleting triage-services also removes the unmerged pull request plus the tri-n-fix-checkout-api branch.

The Linear incident may retain links to GitHub resources that no longer open.

  • Open GitHub's fine-grained personal access token settings page.
  • Select the PAT scoped to your-github-user/triage-services.
  • Revoke the selected PAT.
  • Return to your learner-owned triage-services repository.
  • Use the repository's permanent deletion control in its settings area to delete it.

Nice Work!

Nice Work!

You did it! You built a human-gated repair workflow that turns an approved Linear incident into one tested GitHub pull request.

Your Google Cloud Run service now keeps the model inside a one-file patch boundary. Tests and human review remain between its proposed fix and production.

You've learned how to:

  • Turn an approved incident into exactly one tested pull request. Keep Linear in Fixing until a person merges it.
  • Enforce a one-file patch boundary that restricts Gemini to one existing service file. Run pytest with a minimal environment that leaves GITHUB_TOKEN out of the test subprocess.
  • Make LangGraph retries idempotent by checking for an existing pull request before model invocation. Keep the Linear comment as the node's last write. Route completed fixes around publish_runbook so the human review gate stays intact.
  • Secret Mission: Add read-only Linear history through MCP before routing. Give the router up to three earlier incident outcomes without granting model write access.

Ready to quiz yourself?