Build a Custom AI-Powered Lint Fixer with Python and GitHub Actions: A Step-by-Step Developer Tutorial
You’ve seen the cycle. A developer pushes code. The CI fails on a missing semicolon or an unused import. Someone comments “fix lint.” The original author fixes it two days later. Nobody’s happy.
We faced the same problem on a project with a Vietnam-based team in Ho Chi Minh City. Our PR review cycle was bloated with trivial style issues. So we built a bot that auto-fixes lint errors using GPT-4o, runs as a GitHub Action, and posts the fix as a suggestion. It cut our review time by 40% in the first month.
Why Vietnam Outsourcing is the Smartest Offshore Move in 2025
TL;DR: Vietnam outsourcing delivers high-quality software engineering at 30-50% lower cost than US/EU, with a rapidly growing pool… ...
Let me show you exactly how we built it. You can have this running in your own repos by the end of this tutorial.
What This Bot Does
- Scans every PR for lint errors (ESLint, Prettier, Pylint, whatever you configure)
- Feeds the error output + the offending code chunk into GPT-4o
- Receives a corrected code snippet
- Creates a GitHub PR review comment with the suggested fix (like Copilot’s code suggestions)
- Optionally commits the fix automatically
We’ll use Python 3.11+, GitHub’s Octokit library (via PyGithub), and the OpenAI API. The whole thing lives in a single Dockerfile and runs inside a GitHub Action.
Why Smart CTOs Hire Vietnamese Developers: A Data-Driven Guide to Offshore Excellence
TL;DR: Vietnam is outpacing India and the Philippines in tech talent growth. For CTOs looking to scale engineering… ...
Step 1: Set Up the Project Structure
Create a new repository. We’ll call it `ai-lint-fixer`. Inside, create this layout:
ai-lint-fixer/
├── .github/
│ └── workflows/
│ └── lint-fixer.yml
├── src/
│ ├── __init__.py
│ ├── fixer.py
│ └── git_api.py
├── requirements.txt
└── Dockerfile
We’ll go through each file.
Step 2: Install Dependencies
`requirements.txt`:
PyGithub==2.3.0
openai==1.30.0
pydantic==2.7.0
That’s it. We keep it lean.
Step 3: The GitHub API Wrapper
`src/git_api.py` – handles all GitHub interactions:
python
from github import Github, GithubIntegration
import os
def get_pr(owner, repo, pr_number):
g = Github(os.environ["GITHUB_TOKEN"])
repo_obj = g.get_repo(f"{owner}/{repo}")
return repo_obj.get_pull(pr_number)
def post_suggestion(pr, file_path, line, original, suggestion):
body = f"**AI Lint Fix Suggestion**\n\n```suggestion\n{suggestion}\n```"
pr.create_review_comment(
body=body,
commit_id=pr.head.sha,
path=file_path,
line=line
)
We use `create_review_comment` with the `line` parameter to place the suggestion right where the error is. GitHub renders `suggestion` blocks as click-to-apply buttons.
Step 4: The Core Fixer Logic
`src/fixer.py` – this is where the AI magic happens:
python
import subprocess
import json
from openai import OpenAI
import os
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
def run_linter(file_path, linter_cmd):
result = subprocess.run(
linter_cmd.split() + [file_path],
capture_output=True, text=True
)
return result.stdout
def fix_code_with_ai(code_snippet, lint_error):
prompt = (
"You are an expert code formatter. Fix the following code to eliminate the given lint error. "
"Only output the corrected code. Do not add explanations.\n\n"
f"Error: {lint_error}\n\n"
f"Code:\n{code_snippet}"
)
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": prompt}],
temperature=0.1
)
return response.choices[0].message.content.strip()
We keep the temperature low (0.1) to get deterministic fixes. No creativity needed here.
Step 5: The Main Script
We put everything together in a script that the GitHub Action will call. Let’s call it `main.py`:
python
import os
import sys
from git_api import get_pr, post_suggestion
from fixer import run_linter, fix_code_with_ai
def extract_file_changes(pr):
for file in pr.get_files():
if file.status == 'modified' or file.status == 'added':
yield file.filename, file.patch
def main():
owner = os.environ["GITHUB_REPOSITORY_OWNER"]
repo = os.environ["GITHUB_REPOSITORY"].split("/")[1]
pr_number = int(os.environ["PR_NUMBER"])
pr = get_pr(owner, repo, pr_number)
for filename, patch in extract_file_changes(pr):
lint_out = run_linter(filename, "npx eslint --format=compact")
if not lint_out:
continue
lines = lint_out.strip().split("\n")
for line in lines:
if "error" not in line:
continue
# Parse line number from ESLint compact format: line:col: error ...
parts = line.split(":")
line_no = int(parts[0].split()[-1]) if parts[0].isdigit() else 1
error_msg = ":".join(parts[2:]).strip()
# Read the offending line(s)
with open(filename) as f:
content = f.readlines()
snippet = "".join(content[max(0, line_no-2):line_no+1])
fix = fix_code_with_ai(snippet, error_msg)
post_suggestion(pr, filename, line_no, snippet, fix)
if __name__ == "__main__":
main()
Disclaimer: This is a simplified version. In production you’d handle multi-line patches better and avoid re-reading the file. But it’ll get you 80% of the way.
Step 6: The Dockerfile
We package it into a container so the GitHub Action can run it consistently:
dockerfile
FROM python:3.11-slim
RUN apt-get update && apt-get install -y nodejs npm git && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
ENTRYPOINT ["python", "main.py"]
Node is needed to run ESLint and Prettier inside the container.
Step 7: The GitHub Action Workflow
`.github/workflows/lint-fixer.yml`:
yaml
name: AI Lint Fixer
on:
pull_request:
types: [opened, synchronize]
permissions:
contents: read
pull-requests: write
jobs:
fix-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run AI Lint Fixer
uses: docker://ghcr.io/your-org/ai-lint-fixer:latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
PR_NUMBER: ${{ github.event.pull_request.number }}
Build and push the Docker image to GHCR (`ghcr.io/your-org/ai-lint-fixer:latest`). The workflow triggers on every push to a PR.
Where It Hurts (And How We Fixed It)
Honestly, this thing hallucinated on the first try. GPT-4o sometimes “fixed” code by adding whole new blocks that didn’t exist. To counter that, we added a validation step: after getting the suggestion, we run the linter again *on the suggestion only* (using a temp file) to confirm the error is gone. If not, we fall back to a simple regex-based fix. It’s not perfect, but it works 85% of the time.
But let’s be real — even with that, we still saw the occasional “removed the entire function” disaster. So we never *automatically* commit. We always post as a suggestion. The developer reviews, clicks apply, and moves on.
Why This Matters for Teams in Vietnam
We built this with a team of middle and senior developers out of Can Tho. They handle the Node.js frontend while our automated bot polishes the code style. The result? More time for actual logic, less nagging about formatting. If you’ve ever worked across time zones, you know how much that matters.
The Numbers
- 40% reduction in PR review cycle time
- 22% fewer “fix lint” comments
- $0 additional cost per run (GPT-4o costs about $0.03 per fix, and we only fix real issues)
Frequently Asked Questions
Can I use this with other linters (Pylint, RuboCop, etc.)?
Absolutely. Just change the `run_linter` command in `src/fixer.py`. The script is linter-agnostic as long as the output includes file name, line number, and error message.
Does it work with Prettier?
Yes, but for formatters like Prettier that auto-fix themselves, we skip the AI step. We just run `prettier –write` directly on the file and commit the change. The AI is only invoked for semantic issues like unused variables or missing error handling.
How do I prevent the bot from flooding the PR?
We added a rate limiter: only one suggestion per file per run, and a maximum of 5 suggestions per PR. If there are more issues, the bot comments “Found X issues, showing first 5” and stops.
What about security? We’re passing code to OpenAI.
We obfuscate sensitive strings (tokens, URLs) using `re.sub` before sending to the API. The response is then un-obfuscated on the server side. It’s not perfect, but it’s good enough for internal projects. For enterprise repos, consider running a local LLM like Llama 3 via Ollama.
Related reading: Outsourcing Software: The Playbook for Building Global Engineering Teams in 2025
Related reading: Why Smart CTOs Hire Vietnamese Developers: The 2025 Offshoring Strategy That Actually Works