Find Dead Code Automatically
Automatically find dead code and create GitHub Issues with cleanup suggestions
Introduction
This project has been archived
We're no longer updating this project - we recommend checking out our active projects instead!
If you've already completed this project, don't worry! Your documentation is still available for you to check out and share.
If you have any questions, leave a question in our Discord and we'll get back to you ASAP!
⚡️ 30 Second Summary
Tech debt accumulates silently - unused functions, dead imports, unreachable code. By the time you notice, cleanup becomes a project of its own. Regular audits catch it early, but who has time for manual code reviews every week?
In this project, you'll build a GitHub Actions workflow that automatically scans your codebase for dead code using Gemini, creating GitHub Issues with findings every week.
What you'll build
You'll build a Python script that scans your repository for stale code and uses Gemini to identify tech debt. The workflow runs on a schedule and creates issues automatically.
By the end of this project, you'll have:
- 🔍 A Python script that scans code and sends it to the Gemini API for dead code analysis.
- ⏰ A scheduled workflow that runs weekly using cron syntax.
- 🎫 Automatic Issue creation with structured findings for your team to review.
- 📊 Categorized findings grouped by type (unused functions, dead imports, unreachable code).
- 💎 Secret Mission: Time estimates for cleanup prioritization.
Are there any prerequisites?
This project is Part 4 of the CI/CD GitHub Workflow series.
While we encourage you to do the other projects in this series, it's not required.
Not sure if this project is right for you? Check if it matches your goals
If you're up for a bit of a challenge, quiz yourself on the key concepts up ahead in this project.
- Part 1: Your First GitHub Actions AI workflow
- Part 2: Review GitHub Pull Requests with Gemini
- Part 3: Increase Test Coverage with AI
- Part 4: You are here!
Find all Python files
In this step, you'll create a Python script that scans your codebase for Python files and reads them.
In this step, you're going to:
- Build a function to find Python files in a repository
- Build a function to read all the files you found.
This project continues from the same repo you set up in Part 3: AI Test Generation Bot. Completing other projects in the series is not required. Keep going!
✅ I completed Part 3
Make sure you have:
- Your forked ai-cicd-github repository on GitHub
- The project cloned locally in Cursor
- Your GEMINI_API_KEY secret configured in GitHub
Open the project in Cursor and continue below.
🆕 I'm starting from scratch
No worries! Follow these steps to get set up with the same repo from Parts 1-3.
Setup the starter repo
You'll need a GitHub account to fork the starter repository.
✅ I have a GitHub account
You're all set! Continue below.
🆕 I need to create one
- Head to github.com and click Sign up.
- Sign up with Google, Apple, or fill in the form with your email, password, and username.
- Click Create account and verify your email address.
Great, now you have a GitHub account! Let's fork the starter repo below.
- Head to the starter repository https://github.com/itsnextwork/ai-cicd-github.
- Click the Fork button in the top right.
- Click the green Create fork button.
- Click the green <> Code button → copy the URL.
✅ I have Cursor
Open Cursor from your Applications (Mac) or Start menu (Windows).
🆕 I need to install it
- Download from cursor.com/downloads.
- macOS: Open the .dmg and drag Cursor to Applications.
- Windows: Run the .exe installer and click through the setup wizard.
- Open Cursor.
- Press Ctrl/Cmd + Shift + P → Select Git: Clone
- Paste the forked repository URL and press Enter.
- Select a folder to save your project (e.g. Desktop) → Click Select as Repository Destination.
- Click Open when Cursor asks if you'd like to open the cloned repository.
✅ It's working
Your repository is cloned and ready.
❌ Git not found
That's totally okay! This just means you don't have Git installed on your computer.
- Head over to git-scm.com/downloads.
- Download and run the installer for your operating system.
- Once installed, restart your IDE and try again.
Still having issues? Troubleshoot here or share your error with the NextWork community!
Create a Python venv
- Open the integrated Cursor terminal (Ctrl/Cmd + `)
- Enter:
python -m venv venv
✅ It's working
Let's activate your virtual environment.
❌ Python not found
No worries, let's troubleshoot!
- "python not found": Download from python.org and check "Add to PATH" during installation. Then restart Cursor.
- "python3" works but "python" doesn't: Try python3 -m venv venv instead.
Get help with Python setup or share your error with the NextWork community!
- Activate the virtual environment:
🍎 macOS/Linux
source venv/bin/activate
🖼️ Windows
venv\Scripts\activate
"Running scripts is disabled" error?
Open PowerShell as Administrator and run: Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser. Then try again.
You should see (venv) appear at the beginning of your terminal prompt.
Nearly there! Now we need an API key to use Google's Gemini AI.
Get Your Google AI API Key
- Go to https://aistudio.google.com/ and sign in.
- In the left sidebar, click Get API key.
- Click Create API key in the top right.
- Create a new project and click Create key.
- Copy your API key.
Keep your API key secure!
Don't share your API key publicly or commit it to version control.
Add the Gemini dependency
- Open requirements.txt and add:
google-genai>=1.0.0
- Install dependencies, including the new Gemini dependency:
pip install -r requirements.txt
In order to use our API key securely, we need to store it as a GitHub secret.
Add your API key as a GitHub secret
- Go to your forked repository on GitHub → Settings → Secrets and variables → Actions.
- Click New repository secret.
- Set Name to: GEMINI_API_KEY
- Set Secret to: your API key
- Click Add secret.
Well done traveller, we're now ready for the next part!
Understanding Dead Code
Before we start building, let's understand what we're looking for. "Dead code" refers to code that exists in your codebase but never executes. Common types that we'll be looking for include:
- Unused functions: Functions that are defined but never called
- Dead imports: Modules that are imported but never used
- Unreachable code: Code that can never execute due to logic flow (e.g., code after a return statement)
Why does removing dead code matter?
Dead code is a maintenance burden. Every line of unused code is code that developers must read, understand, and work around. It clutters code reviews, confuses new team members, and can even hide bugs. Regular, automated audits catch dead code early, before it accumulates and causes issues.
Create the Stale Code Detector Script
- Open Cursor
- Make sure your forked repo is loaded already.
- In your file directory on the left, right-click scripts
- In the menu, select New File...
- Name your new file scripts/find_stale.py
Build the Script
Now let's write the script that will scan your codebase and use Gemini to identify dead code. We'll build it up function by function.
Add the imports and Gemini client
Let's start by adding the imports.
- Copy and paste the following into your empty file:
import os
import json
import time
from pathlib import Path
from google import genai
What are these imports for?
- os: Let's Python find and read things on your computer OS. We'll use it to access our Gemini API Key.
- json: Essential for parsing Gemini's JSON responses
- time: Literally time. We'll use it to add delays between API calls to avoid rate limits
- pathlib.Path: Let's Python find and read files in our repo.
- google.genai: Handles all the interactions with Gemini - we just pass it our API Key.
- Now let's create the Gemini client. This is what will allow us to call Gemini later on.
- Try filling in the blanks... Do you know how to call our Gemini API Key from our OS?
# Create a Gemini client, passing in the API key from environment variables
client = genai.Client(api_key=[[CLIENT_ENV="how do you get GEMINI_API_KEY?"]])
- Did you get it?
✔️ I gave it a go!
Well done!
Check the other tab to compare your answer.
ⓧ Give me the answer
# Create a Gemini client, passing in the API key from environment variables
client = genai.Client(api_key=os.environ.get("GEMINI_API_KEY"))
- Paste this code into scripts/find_stale.py.
- Save the file. (Ctrl/Cmd + S)
How does this code block work?
- os.environ.get("GEMINI_API_KEY"): This looks up the value of your Gemini API Key in your system's environment variables.
- What are environment variables? Environment variables are like global settings that any program can access - they're commonly used to store sensitive data like API keys so you don't have to hardcode them in your source code.
- Fun Fact! Did you know we use .get() instead of [] because .get() returns null if the key doesn't exist, rather than crashing with an error. Errors are much better than null because we can react to them more specifically. This helps makes your code more resilient.
Add the file discovery function
We want to scan our repo for Python files so we can look for dead code.
Let's write the function that does that:
- Starting with the bare bones:
def get_python_files(repo_path: str) -> list[str]:
# Get all python files
# Ignore files in repo like venv, .git, etc
return python_files
Note
We've created an empty function called get_python_files
The goal is to take in a GitHub repo folder path, then return all the python files in that repo.
- Now, let's add an empty list to our function.
- This will store our python files.
def get_python_files(repo_path: str) -> list[str]:
# Get all python files
python_files = []
# Ignore files in repo like venv, .git, etc
return python_files
- Finally, we need to look over all the files in our repo and add them to our list.
- We're excluding any file that we don't need! Look closely...can you see which ones we exclude?
def get_python_files(repo_path: str) -> list[str]:
# Get all python files
python_files = []
# Ignore files in repo like venv, .git, etc
for path in Path(repo_path).rglob("*.py"):
# Skip virtual environments and common directories to ignore
if any(part in path.parts for part in ["venv", ".venv", "node_modules", "__pycache__", ".git"]):
continue
python_files.append(str(path))
return python_files
What's that funky rglob command?
rglob("*.py") is a recursive function (the "r" stands for "recursive"). Unlike regular glob() which only searches one directory, rglob() searches the current directory AND all subdirectories automatically. The *.py pattern matches any file ending in .py.
- Paste this code into scripts/find_stale.py below your existing code.
- Save the file. (Ctrl/Cmd + S)
Add the file reader function
So we have our files. But we can't read them!
Let's write a function that opens our files and reads them.
- Copy the following code block
def read_file_content(file_path: str) -> str:
"""Read and return the content of a file."""
try:
with open(file_path, "r", encoding="utf-8") as f:
return f.read()
except Exception as e:
return f"Error reading file: {e}"
- Paste it into your file below our existing code
How does it read?
f.read() is the magic command! Can you see where we open() a file first, name it as f, and then call .read() on it? It returns the entire file contents as a single string.
We've also added that if anything goes wrong (file doesn't exist, permission denied, encoding error), it catches the exception and returns an error message instead of crashing the entire script. Look for Exception as e.
- Paste this code into scripts/find_stale.py.
- Save the file. (Ctrl/Cmd + S)
Add Gemini Analysis
In this step, you're going to:
- Work out how to send prompts to Gemini
- Clean up any response from Gemini (Gemini often adds annoying markdown)
- Parse the cleaned response into a Python dictionary
- Write a detailed prompt with instructions for Gemini on finding dead code
Note
We'll be building this Gemini Analysis code bit by bit. You can either add to your find_stale.py file as you go, or wait till the end and past in the final function.
Skeleton code
- Read the following code block.
- This is skeleton code; placeholders that help us understand the high level tasks.
- If you'd like, paste this code below the last function in your find_stale.py file (or wait till the end of this step for the full code block).
def analyze_code_with_gemini(code_content: str, file_path: str) -> dict:
# TODO: Write the prompt
# TODO: Send to Gemini with the files
# TODO: Get response and clean it up
# TODO: Parse and return
How to send things to Gemini
- We're going to ignore the prompt itself for now. How would we send any prompt to Gemini?
- Can you spot the # NEW CONTENT HERE: in the code block?
def analyze_code_with_gemini(code_content: str, file_path: str) -> dict:
prompt = # TODO: Write the prompt
# NEW CONTENT HERE:
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=prompt
)
return response.text
Remember the Gemini client?
Can you spot the use of client.models.?
Do you remember in Step 1, we created client = genai.Client...?. We're using this same client to send our prompt to Gemini for analysis.
- This code will return a response from Gemini in plain text (.text).
- It's a good start, but not very useful for our purposes - we need the response in a structured data format so we can seperate it into nicely formatted issues.
Clean up Gemini responses
- The first problem with Gemini responding in plain text, is that Gemini will often wrap responses in markdown code blocks.
- This will break our future parsing, and stop us completely from putting our responses in a data structure (GASP!)
- Let's make sure we don't have any funky markdown formatting.
def analyze_code_with_gemini(code_content: str, file_path: str) -> dict:
prompt = # TODO: Write the prompt
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=prompt
)
# NEW CONTENT HERE:
# Remove markdown code blocks if Gemini added them
response_text = response.text.strip()
if response_text.startswith("```"):
response_text = response_text.split("\n", 1)[1] # Remove ```json
response_text = response_text.rsplit("```", 1)[0] # Remove closing ```
return response_text
How the heck does this cleanup work?
- .split("\n", 1) This says "split the string", "at the newline character", "one time".
- rsplit... The second split says "split the string from the right side", "at the last three back ticks", "one time".
- Awesome. We will now have a clean text response returned from Gemini.
Make sure we return a Python dictionary
The problem with returning plain text is that we can't easily group information in the response into things like type or line_number - we'll need this to write our issues!
A Python dictionary is one possible solution. It lets us access data with simple syntax.
- Let's take our cleaned up responses and put them in a Python dictionary
- Look for # NEW CONTENT HERE: in the code block:
def analyze_code_with_gemini(code_content: str, file_path: str) -> dict:
prompt = # TODO: Write the prompt
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=prompt
)
# Remove markdown code blocks if Gemini added them
response_text = response.text.strip()
if response_text.startswith("```"):
response_text = response_text.split("\n", 1)[1] # Remove ```json
response_text = response_text.rsplit("```", 1)[0] # Remove closing ```
# NEW CONTENT HERE:
return json.loads(response_text) # Convert JSON string → Python dict
What's json.loads()?
json.loads() converts any JSON string into a Python dictionary.
This means that we can take Gemini's response, clean it up, and then store it in a Python Dictionary...as long as Gemini's response is in the format of a JSON file!
Write the actual prompt!
- Now we can write the actual prompt.
- Add the f""" syntax after the prompt variable, so we can add our prompt as text - it's not python!
- This is where we will put our prompt.
def analyze_code_with_gemini(code_content: str, file_path: str) -> dict:
prompt = f""" ....PROMPT GOES HERE....
Why f""" syntax?
The f makes it a formatted string (f-string), letting us insert variables like {file_path} directly into the text. The triple quotes """ allow multi-line strings without needing escape characters for each new line.
We'll build our prompt bit by bit.
Feel free to add your prompt into your code file as we go, or wait till the end to get the fully combined version.
- The first task of our prompt is to ask Gemini to look for dead code.
- Remember those three types of dead code we're looking for? We're literally telling Gemini to look for those.
Analyze the following Python code and identify any dead code.
Look for:
1. Unused functions (defined but never called within this file)
2. Dead imports (imported but never used)
3. Unreachable code (code after return statements, etc.)
File: {file_path}
Code:
{code_content}
- Now we make sure Gemini responds in a JSON format
Respond in JSON format with this structure:
{{
"findings": [
{{
"type": "unused_function" | "dead_import" | "unreachable_code",
"name": "name of the function/import/code",
"line": line_number,
"description": "brief description of why this is dead code"
}}
]
}}
- Lastly, we need to explain to Gemini what to do if there is no dead code to be found. Should it return an error? NO!
- We ask Gemini to return an empty response, with no additional text.
If no dead code is found, return: {{"findings": []}}
Only return the JSON, no additional text."""
- Putting it all together in our function...
def analyze_code_with_gemini(code_content: str, file_path: str) -> dict:
"""Send code to Gemini for dead code analysis."""
prompt = f"""Analyze the following Python code and identify any dead code.
Look for:
1. Unused functions (defined but never called within this file)
2. Dead imports (imported but never used)
3. Unreachable code (code after return statements, etc.)
File: {file_path}
Code:
{code_content}
Respond in JSON format with this structure:
{{
"findings": [
{{
"type": "unused_function" | "dead_import" | "unreachable_code",
"name": "name of the function/import/code",
"line": line_number,
"description": "brief description of why this is dead code"
}}
]
}}
If no dead code is found, return: {{"findings": []}}
Only return the JSON, no additional text."""
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=prompt
)
# Remove markdown code blocks if Gemini added them
response_text = response.text.strip()
if response_text.startswith("```"):
response_text = response_text.split("\n", 1)[1] # Remove ```json
response_text = response_text.rsplit("```", 1)[0] # Remove closing ```
return json.loads(response_text) # Convert JSON string → Python dict
- Done! Right?
- Nope - we can't forget about error handling!
Error Handling
- We'll put our calls to Gemini in a beautiful try, except to catch any errors.
- Here is the final code: can you spot the error handling?
def analyze_code_with_gemini(code_content: str, file_path: str) -> dict:
"""Send code to Gemini for dead code analysis."""
prompt = f"""Analyze the following Python code and identify any dead code.
Look for:
1. Unused functions (defined but never called within this file)
2. Dead imports (imported but never used)
3. Unreachable code (code after return statements, etc.)
File: {file_path}
Code:
{code_content}
Respond in JSON format with this structure:
{{
"findings": [
{{
"type": "unused_function" | "dead_import" | "unreachable_code",
"name": "name of the function/import/code",
"line": line_number,
"description": "brief description of why this is dead code"
}}
]
}}
If no dead code is found, return: {{"findings": []}}
Only return the JSON, no additional text."""
try:
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=prompt
)
time.sleep(18) # Stay under Gemini free tier rate limit (5 requests/minute)
response_text = response.text.strip()
if response_text.startswith("`" * 3):
response_text = response_text.split("\n", 1)[1]
response_text = response_text.rsplit("`" * 3, 1)[0]
return json.loads(response_text)
except Exception as e:
return {"error": str(e), "findings": []}
- Paste this code into scripts/find_stale.py.
- Save the file. (Ctrl/Cmd + S)
Build the Scheduled Workflow
Now let's complete the script and automate the stale code detection with a GitHub Actions workflow that runs weekly and creates Issues with findings.
In this step, you're going to:
- Add the markdown formatter function
- Add the main function to run the detector
- Create a GitHub Actions workflow file
- Set up automatic Issue creation
Add the markdown formatter
This function converts the findings into a readable markdown report for GitHub Issues.
Note
We'll be building this function bit by bit. You can either add to your find_stale.py file as you go, or wait till the end and paste in the final function.
Skeleton code
- Read the following code block.
- This is skeleton code; placeholders that help us understand the high level tasks.
def format_findings_as_markdown(all_findings: list[dict]) -> str:
"""Format all findings as markdown for a GitHub Issue."""
# TODO: Handle empty findings case
# TODO: Create markdown header with count
# TODO: Group findings by type
# TODO: Generate markdown for each group
return markdown
What are the high-level tasks?
We need to: check if there are no findings, count the total, group them by type (unused functions, dead imports, etc.), then build a markdown string for each group.
Handle empty findings and create header
- Can you spot the new code we're adding? Look for the comments that match our TODOs.
def format_findings_as_markdown(all_findings: list[dict]) -> str:
"""Format all findings as markdown for a GitHub Issue."""
# Handle empty findings case
if not all_findings:
return "## Stale Code Report\n\nNo dead code detected in this scan."
# Create markdown header with count
markdown = "## Stale Code Report\n\n"
markdown += f"Found {len(all_findings)} potential issues:\n\n"
# TODO: Group findings by type
# TODO: Generate markdown for each group
return markdown
New Python concept
len(list): Returns the number of items in a list. For example, len([1, 2, 3]) returns 3.
- Great! Now we have the header sorted. But we need to organize our findings...
Group findings by type
The problem with dumping all findings into one list is that it's hard to read. Let's group them by type so unused functions, dead imports, and unreachable code each get their own section.
# Group findings by type
by_type = {}
for finding in all_findings:
finding_type = finding.get("type", "unknown")
if finding_type not in by_type:
by_type[finding_type] = []
by_type[finding_type].append(finding)
New Python concept
dict.get("key", default): Safely retrieves a value from a dictionary. If the key doesn't exist, returns the default value instead of crashing.
- Awesome. Now our findings are grouped by type. Time to turn them into readable markdown!
Generate markdown for each group
Finally, we loop through each type and build the markdown output.
type_labels = {
"unused_function": "Unused Functions",
"dead_import": "Dead Imports",
"unreachable_code": "Unreachable Code"
}
for finding_type, findings in by_type.items():
label = type_labels.get(finding_type, finding_type)
markdown += f"### {label}\n\n"
for f in findings:
markdown += f"- **{f.get('name', 'Unknown')}** ({f.get('file', 'unknown')}:{f.get('line', '?')})\n"
markdown += f" - {f.get('description', 'No description')}\n\n"
return markdown
Using .get() for labels
Notice type_labels.get(finding_type, finding_type)? If the type isn't in our labels dictionary, we just use the raw type name as the fallback.
Complete function
- Putting it all together in our function...
def format_findings_as_markdown(all_findings: list[dict]) -> str:
"""Format all findings as markdown for a GitHub Issue."""
if not all_findings:
return "## Stale Code Report\n\nNo dead code detected in this scan."
markdown = "## Stale Code Report\n\n"
markdown += f"Found {len(all_findings)} potential issues:\n\n"
by_type = {}
for finding in all_findings:
finding_type = finding.get("type", "unknown")
if finding_type not in by_type:
by_type[finding_type] = []
by_type[finding_type].append(finding)
type_labels = {
"unused_function": "Unused Functions",
"dead_import": "Dead Imports",
"unreachable_code": "Unreachable Code"
}
for finding_type, findings in by_type.items():
label = type_labels.get(finding_type, finding_type)
markdown += f"### {label}\n\n"
for f in findings:
markdown += f"- **{f.get('name', 'Unknown')}** ({f.get('file', 'unknown')}:{f.get('line', '?')})\n"
markdown += f" - {f.get('description', 'No description')}\n\n"
return markdown
- Paste this code into scripts/find_stale.py.
- Save the file. (Ctrl/Cmd + S)
Add the main function
Finally, add the main function that orchestrates the entire scanning process.
Note
Same as before - we'll build this function bit by bit. Add as you go, or wait till the end for the complete version.
Skeleton code
- Read the following code block.
- This is skeleton code; placeholders that help us understand the high level tasks.
def main():
"""Main function to scan repository and report findings."""
# TODO: Get repository path
# TODO: Find all Python files
# TODO: Loop through files and analyze each one
# TODO: Generate and save report
return all_findings
What does main() need to do?
This function ties everything together: it finds files, analyzes them, and creates the report. It's the "conductor" of our script!
Find all Python files
def main():
"""Main function to scan repository and report findings."""
repo_path = os.environ.get("GITHUB_WORKSPACE", ".")
print(f"Scanning repository: {repo_path}")
python_files = get_python_files(repo_path)
print(f"Found {len(python_files)} Python files")
# TODO: Loop through files and analyze each one
# TODO: Generate and save report
Calling your own functions
See how we call get_python_files(repo_path)? Once you define a function, you can call it from anywhere else in your code. Just use the function name with parentheses and pass any required arguments.
- Great! We've got our list of Python files. Now we need to analyze each one...
Loop through files and analyze
This is where the magic happens - we loop through each file and send it to Gemini for analysis.
all_findings = []
for i, file_path in enumerate(python_files):
print(f"Analyzing: {file_path}")
content = read_file_content(file_path)
if content.startswith("Error"):
print(f" Skipping due to read error")
continue
result = analyze_code_with_gemini(content, file_path)
if "error" in result:
print(f" Analysis error: {result['error']}")
continue
for finding in result.get("findings", []):
finding["file"] = file_path
all_findings.append(finding)
Reusing your functions
See analyze_code_with_gemini(content, file_path)? That's the function you built in Step 2! We pass it the file content and path, and it returns JSON findings from Gemini.
- Nearly there! We've collected all our findings. Now let's generate the report and save it.
Generate and save the report
markdown_report = format_findings_as_markdown(all_findings)
print("\n" + "=" * 50)
print(markdown_report)
with open("stale_code_report.md", "w") as f:
f.write(markdown_report)
print(f"\nReport saved to stale_code_report.md")
return all_findings
if __name__ == "__main__":
findings = main()
exit(1 if findings else 0)
What's if __name__ == "__main__"?
This is a Python pattern that means "only run this code if the script is executed directly" (not imported as a module). It calls main() and exits with code 1 if findings exist (indicating issues found) or 0 if clean.
- Done! Right? Actually yes - the main() function is complete!
Complete function
- Putting it all together in our function...
def main():
"""Main function to scan repository and report findings."""
repo_path = os.environ.get("GITHUB_WORKSPACE", ".")
print(f"Scanning repository: {repo_path}")
python_files = get_python_files(repo_path)
print(f"Found {len(python_files)} Python files")
all_findings = []
for i, file_path in enumerate(python_files):
print(f"Analyzing: {file_path}")
content = read_file_content(file_path)
if content.startswith("Error"):
print(f" Skipping due to read error")
continue
result = analyze_code_with_gemini(content, file_path)
if "error" in result:
print(f" Analysis error: {result['error']}")
continue
for finding in result.get("findings", []):
finding["file"] = file_path
all_findings.append(finding)
markdown_report = format_findings_as_markdown(all_findings)
print("\n" + "=" * 50)
print(markdown_report)
with open("stale_code_report.md", "w") as f:
f.write(markdown_report)
print(f"\nReport saved to stale_code_report.md")
return all_findings
if __name__ == "__main__":
findings = main()
exit(1 if findings else 0)
- Paste this code into scripts/find_stale.py.
- Save the file. (Ctrl/Cmd + S)
Create the Workflow File
Now for the exciting part - let's automate the stale code detection with a GitHub Actions workflow that runs every week!
- Open the .github/workflows folder
- Right-click → New file... → Name it stale-code.yml
- Enter the workflow configuration:
name: Stale Code Detector
on:
# Run every Monday at 9:00 AM UTC
schedule:
- cron: '0 9 * * 1'
# Allow manual triggering for testing
workflow_dispatch:
permissions:
contents: read
issues: write
jobs:
detect-stale-code:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install google-genai
- name: Run stale code detector
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
run: |
python scripts/find_stale.py || true
- name: Create GitHub Issue
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Check if report exists and has findings
if [ -f stale_code_report.md ] && grep -q "Found .* potential issues" stale_code_report.md; then
gh issue create \
--title "Weekly Stale Code Report - $(date +%Y-%m-%d)" \
--body-file stale_code_report.md
else
echo "No stale code findings to report"
fi
Understanding the workflow
Cron syntax 0 9 * * 1 means: - 0 - Minute (run at minute 0) - 9 - Hour (9 AM UTC) - * - Any day of month - * - Any month - 1 - Monday (0=Sunday, 1=Monday)
Triggers: - schedule: Runs automatically every Monday at 9 AM UTC - workflow_dispatch: Allows you to manually trigger from GitHub's Actions UI
Permissions: - contents: read: Needed to checkout the repository - issues: write: Needed to create GitHub Issues
- Save the file. (Ctrl/Cmd + S)
- Great! The workflow is ready. But we need to create some labels to organize our Issues...
Create the Tech Debt Labels
Labels help you filter and organize Issues. Let's create two labels so our automated reports are easy to find.
- Go to your repository on GitHub
- Click the Issues tab
- Click Labels on the right side
- Click New label
- Enter tech-debt as the label name and pick a color
- Click Create label
- Create another label named automated
- Perfect! Labels are ready. Time to push everything to GitHub and see it in action!
Commit and Push
- Open the integrated Cursor terminal (Ctrl/Cmd + `)
- Commit and push your changes:
git add .
git commit -m "Add stale code detector with weekly workflow"
git push origin main
WHOA! What a journey. You've finished the script and created the automation.
Here's what you have now:
- Markdown formatter: Converts findings into a readable report grouped by type
- Main function: Orchestrates the scanning process and saves the report
- GitHub Actions workflow: Runs weekly on a cron schedule and creates Issues automatically
- Labels: tech-debt and automated for organizing your Issues
In the next step, you'll test everything by triggering the workflow manually.
Verify the Workflow
Let's test the workflow by triggering it manually and verifying the Issue gets created.
In this step, you're going to:
- Manually trigger the workflow
- Verify an Issue is created with findings
- Understand the workflow output
Trigger the Workflow Manually
Instead of waiting for Monday, let's trigger the workflow now.
- Navigate to github.com
- Click your profile icon in the top right corner
- Select Your repositories from the dropdown menu
- Find and click on your ai-cicd-github repository
- Click the Actions tab in the top navbar
- Select Stale Code Detector from the left sidebar
- Click Run workflow → select the main branch → click Run workflow again
Watch the Workflow Execute
- Click on the running workflow to see its progress in real-time
- Watch each step complete: Checkout repository → Set up Python → Install dependencies → Run stale code detector → Create GitHub Issue
✅ Workflow completed
All steps should show green checkmarks. Continue to check the created Issue.
❌ Workflow failed
- Click on the failed step to see the error message
Here are common fixes:
"Run stale code detector" failed: The GEMINI_API_KEY secret is likely missing or incorrect. Go to Settings > Secrets and variables > Actions and verify the secret exists with your valid API key.
"Create GitHub Issue" failed: The labels might not exist yet - go to Issues > Labels and create tech-debt and automated labels. Also check the workflow has issues: write permission in the YAML.
Workflow not visible in Actions: Make sure you pushed the workflow file to GitHub and it's at .github/workflows/stale-code.yml. Refresh the page and wait a few seconds.
- Re-run the workflow after fixing
❌ 429 Rate limit error
No worries - let's figure this out together!
429 RESOURCE_EXHAUSTED?
Our code already includes time.sleep(18) to handle the per-minute limit (5 requests/minute).
If you're still getting this error, you've likely hit the daily limit (20 requests/day).
Fixes:
- Wait and retry: Daily limits reset at midnight Pacific Time
- Create a new API key: Go to Google AI Studio, create a new key:
Then update your GitHub secret at Settings → Secrets and variables → Actions → Edit GEMINI_API_KEY:
- Still not working? Try creating an API key on a separate Google account at Google AI Studio.
🙋♀️ 503 UNAVAILABLE or "The model is overloaded"?
Gemini's servers are temporarily busy - this happens during peak usage times. Wait 2-3 minutes, then go to the Actions tab → click your failed workflow run → Re-run all jobs.
Still stuck? Ask for help. You can also ask in the NextWork Community.
❌ No Issue created
No worries - let's figure this out together!
Using hashFiles() in an older workflow version?
If you copied an older version of the workflow that uses if: hashFiles('stale_code_report.md') != '', this won't work correctly.
- Why? hashFiles() evaluates at workflow parse time (before any steps run), not at runtime. So it checks if the file exists before the Python script creates it - which means it's always empty!
- Fix: Check your workflow uses this pattern (no if: line on the step):
- name: Create GitHub Issue
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [ -f stale_code_report.md ] && grep -q "Found .* potential issues" stale_code_report.md; then
Still stuck? Ask for help. You can also ask in the NextWork Community.
Check the Created Issue
- Click on the workflow run to see the details once the workflow completes
- Expand the logs to see the "Create GitHub Issue" step - you should see confirmation that the Issue was created
- Go to the Issues tab in your repository
- Open the new Issue titled "Weekly Stale Code Report - [today's date]" to see the findings
The Issue should contain:
- A summary of how many issues were found
- Sections for unused functions, dead imports, and unreachable code
- File paths and line numbers for each finding
Understanding The Results
You'll likely see findings for functions in app.py like add, multiply, and factorial. These functions are used in tests, so why are they showing up as dead code?
Tests exist to verify that our functions work correctly, but they don't count as production usage. If a function is only ever called by tests and never by actual application code, then the function serves no real purpose, and neither do its tests!
This is a common source of dead code that often goes undetected.
Scheduled Runs
Your workflow will run automatically every Monday at 9:00 AM UTC. You can check the workflow history in the Actions tab to see past and scheduled runs.
Epic work.
You've verified your stale code detector is working. Here's what you confirmed:
- Manual triggering: You can run the workflow anytime from the Actions tab
- Issue creation: The workflow creates GitHub Issues with structured findings
- False positives: You understand why single-file analysis flags some code incorrectly
Your automated tech debt detection system is now live and running weekly!
Secret mission
How do engineering managers at companies like Stripe, Shopify, and Airbnb prioritize which tech debt to tackle first? They estimate the effort required for each item and calculate the ROI of cleanup.
By now, you've built a stale code detector that scans your codebase and creates GitHub Issues. But your current report just lists findings - it doesn't help prioritize what to fix first. In this secret mission, you'll enhance your detector to estimate cleanup time for each finding, so your team can make data-driven decisions about tech debt.
Estimate Cleanup Time for Tech Debt
Clean Up Your Resources
Clean Up Your Resources
This project uses minimal cloud resources, but let's clean up what we created.
Keep everything running
If you want to keep using the stale code detector, no action is needed:
- The workflow will continue running every Monday at 9:00 AM UTC
- New Issues will be created automatically when dead code is detected
- You can manually trigger the workflow anytime from the Actions tab
Disable scheduled runs
If you want to stop the weekly scans without deleting the workflow:
- Edit .github/workflows/stale-code.yml
- Comment out the schedule section:
on:
# schedule:
# - cron: '0 9 * * 1'
workflow_dispatch:
- Commit and push the change:
git add .github/workflows/stale-code.yml
git commit -m "Disable scheduled stale code scans"
git push origin main
The workflow will still be available for manual triggering but won't run automatically.
Remove everything
If you want to completely clean up:
Remove the workflow file:
macOS/Linux
rm .github/workflows/stale-code.yml
git add .github/workflows/stale-code.yml
git commit -m "Remove stale code workflow"
git push origin main
Windows
Remove-Item .github\workflows\stale-code.yml
git add .github/workflows/stale-code.yml
git commit -m "Remove stale code workflow"
git push origin main
Remove the detector script:
rm scripts/find_stale.py
rm stale_code_report.md
git add -A
git commit -m "Remove stale code detector files"
git push origin main
Remove the GitHub secret:
- Go to Settings > Secrets and variables > Actions
- Find GEMINI_API_KEY and click the trash icon to delete it
Gemini API costs:
The Gemini API has a generous free tier. For a typical repository, weekly scans should stay well within free limits. Check your usage at Google AI Studio if concerned.
That's a wrap!
That's a wrap!
You did it! You've built an automated tech debt detector that scans your codebase weekly and reports findings as GitHub Issues.
You have built an incredible amount. To summarise...
- A Python script that uses Gemini to identify dead code
- A scheduled GitHub Actions workflow running weekly
- Automatic GitHub Issue creation with structured findings
Ready to quiz yourself? 💪
You're an absolute champion. Incredible work - here's to many more GitHub Workflows to come!