
Fetch Ci Build
- 1 installs
- 1.5k repo stars
- Updated August 5, 2026
- dicklesworthstone/pi_agent_rust
Fetches CI build results, auto-detects the provider, diagnoses failures, and proposes fixes for GitHub Actions, Buildkite, and CircleCI.
About
Detects the CI provider from project files or a URL, fetches build results, reads failing source files, and presents proposed fixes. A developer uses it to diagnose and fix failing CI builds.
- Auto-detects GitHub Actions, Buildkite, or CircleCI from files or URLs
- Reads failing files, proposes fixes, and defers to systematic-debugging
Fetch Ci Build by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,172 of 1,435 DevOps & CI/CD skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/dicklesworthstone/pi_agent_rust --skill fetch-ci-buildAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 1.5k |
| Last updated | August 5, 2026 |
| Repository | dicklesworthstone/pi_agent_rust ↗ |
What it does
Fetches CI build results, auto-detects the provider, diagnoses failures, and proposes fixes for GitHub Actions, Buildkite, and CircleCI.
Files
Fetch CI Build
Overview
Fetch CI build results, diagnose failures, extract actionable error information, and suggest fixes. Supports multiple CI providers with automatic detection.
Supported Providers
| Provider | Detection | Tool |
|---|---|---|
| GitHub Actions | .github/workflows/ or github.com URL | gh CLI |
| Buildkite | .buildkite/ or buildkite.com URL | Python script |
| CircleCI | .circleci/ or circleci.com URL | Python script |
Auto-Detection
From URL
If the user provides a CI URL, detect provider:
github.com/.../actions/runs/...→ GitHub Actionsbuildkite.com/...→ Buildkiteapp.circleci.com/...orcircleci.com/...→ CircleCI
From Project Files
Check for CI configuration directories:
# GitHub Actions
test -d .github/workflows && echo "github"
# Buildkite
test -d .buildkite && echo "buildkite"
# CircleCI
test -d .circleci && echo "circleci"Workflow
digraph workflow {
rankdir=TB;
node [shape=box];
detect [label="1. Detect CI provider"];
load [label="2. Load provider reference"];
fetch [label="3. Fetch build results"];
check [label="4. Check for failures" shape=diamond];
passed [label="Report: Build passed!"];
read [label="5. Read failing source files"];
present [label="6. Present failures + proposed fixes"];
ask [label="7. Ask: Apply fix?" shape=diamond];
apply [label="Apply the fix"];
complex [label="Complex failure?" shape=diamond];
debug [label="Use systematic-debugging skill"];
next [label="Next failure?" shape=diamond];
done [label="Done"];
detect -> load;
load -> fetch;
fetch -> check;
check -> passed [label="passed"];
check -> read [label="failed"];
read -> present;
present -> ask;
ask -> apply [label="yes"];
ask -> complex [label="no"];
apply -> next;
complex -> debug [label="yes"];
complex -> next [label="no"];
debug -> next;
next -> read [label="yes"];
next -> done [label="no"];
}Step-by-Step Process
1. Detect CI Provider
First, check if the user provided a URL. If not, detect from project files.
2. Load Provider Reference
Read the appropriate reference file for provider-specific commands:
- GitHub: references/github.md
- Buildkite: references/buildkite.md
- CircleCI: references/circleci.md
3. Fetch Build Results
Use the provider-specific commands to fetch build information and failures.
4. For Each Failure
Read the relevant source file to understand context:
- For test failures: read the test file at the indicated line
- For lint errors: read the source file at the indicated line
- For TypeScript errors: read the file and understand the type issue
5. Present Findings and Propose Fix
Show the user:
- What failed (test name, file, line)
- Error message
- Proposed fix based on the error
6. Ask User What To Do
Ask how to proceed:
- "Apply the suggested fix"
- "Investigate further before fixing"
- "Skip this failure"
- "Use systematic-debugging for deeper investigation"
7. Complex Failures
If the failure requires deeper investigation (e.g., unclear root cause, flaky test, environmental issue), recommend the systematic-debugging skill.
Error Types Detected
| Type | Detection | Common Fixes |
|---|---|---|
| Test failure | Minitest/RSpec/Jest/pytest output | Fix assertion, update expected value, fix test setup |
| Lint error | Rubocop/ESLint/Biome violations | Auto-fix with linter's fix command |
| TypeScript | TSC compilation errors | Add types, fix type mismatches |
| Build error | Compilation failures | Fix syntax, missing dependencies |
Common Mistakes
| Mistake | Solution |
|---|---|
| Can't detect provider | Specify provider explicitly or provide CI URL |
| Missing credentials | Check provider reference for required env vars/auth |
| Build still running | Wait for completion or check partial results |
| Rate limiting | Wait and retry |
Integration with Other Skills
- systematic-debugging: Use for complex failures requiring root cause analysis
- test-driven-development: After fixing, ensure tests follow TDD principles
- verification-before-completion: Run tests locally before pushing fix
Buildkite Reference
Prerequisites
Environment variables must be set:
BUILDKITE_API_TOKEN- Buildkite API token with read accessBUILDKITE_ORGANIZATION_SLUG- Organization slug (e.g.,myorg)
Quick Reference
Fetch failures for current branch:
uv run {baseDir}/scripts/fetch_buildkite_failures.pyFetch specific build:
uv run {baseDir}/scripts/fetch_buildkite_failures.py --build 1723Fetch different branch:
uv run {baseDir}/scripts/fetch_buildkite_failures.py --branch mainFetch from specific pipeline:
uv run {baseDir}/scripts/fetch_buildkite_failures.py --pipeline my-pipelineScript Output
The script outputs JSON with:
- Build info (number, branch, state, URL)
- Failed jobs with extracted errors
- Summary counts by error type
Example output:
{
"build": {
"number": 1234,
"branch": "feature-branch",
"state": "failed",
"commit": "abc123",
"web_url": "https://buildkite.com/org/pipeline/builds/1234",
"message": "Fix the thing"
},
"failures": [
{
"job_name": "rspec tests",
"job_id": "job-uuid",
"web_url": "https://buildkite.com/...",
"errors": [
{
"test_name": "UserTest#test_validation",
"file": "test/models/user_test.rb",
"line": 42,
"message": "Expected true, got false",
"type": "test_failure"
}
]
}
],
"summary": {
"total_failed_jobs": 1,
"test_failures": 1,
"lint_errors": 0
}
}URL Patterns
Buildkite URLs follow this pattern:
https://buildkite.com/<org>/<pipeline>/builds/<build-number>
https://buildkite.com/<org>/<pipeline>/builds/<build-number>#<job-id>Extract build number from URL:
# From: https://buildkite.com/myorg/app/builds/1234
uv run {baseDir}/scripts/fetch_buildkite_failures.py --build 1234Error Types Detected
The script parses logs and extracts:
| Type | Detection Pattern |
|---|---|
test_failure | Minitest/RSpec failure output |
rubocop | Rubocop violation format |
lint | ESLint/Biome output |
typescript | TSC error format |
pytest | pytest FAILED lines |
ruff | Ruff linting output |
go_test | Go test failures |
go_compile | Go compilation errors |
docker | Docker errors |
permission | Permission denied errors |
API Direct Access
If you need to access the API directly:
List builds for a pipeline:
curl -s -H "Authorization: Bearer $BUILDKITE_API_TOKEN" \
"https://api.buildkite.com/v2/organizations/$BUILDKITE_ORGANIZATION_SLUG/pipelines/app/builds?branch=$(git branch --show-current)&per_page=1"Get specific build:
curl -s -H "Authorization: Bearer $BUILDKITE_API_TOKEN" \
"https://api.buildkite.com/v2/organizations/$BUILDKITE_ORGANIZATION_SLUG/pipelines/app/builds/1234"Troubleshooting
| Issue | Solution |
|---|---|
| "BUILDKITE_API_TOKEN not set" | Export the environment variable |
| "BUILDKITE_ORGANIZATION_SLUG not set" | Export the environment variable |
| "No builds found" | Check branch name, pipeline slug |
| "Invalid token" | Regenerate token in Buildkite settings |
| Rate limiting | Wait and retry (API limit: 200 req/min) |
CircleCI Reference
Prerequisites
Environment variables must be set:
CIRCLECI_TOKEN- CircleCI personal API token
The script auto-detects project slug from git remote URL.
Quick Reference
Fetch failures for current branch:
uv run {baseDir}/scripts/fetch_circleci_failures.pyFetch specific pipeline/workflow:
uv run {baseDir}/scripts/fetch_circleci_failures.py --pipeline <pipeline-id>Fetch different branch:
uv run {baseDir}/scripts/fetch_circleci_failures.py --branch mainSpecify project explicitly:
uv run {baseDir}/scripts/fetch_circleci_failures.py --project gh/owner/repoScript Output
The script outputs JSON with:
- Pipeline/workflow info
- Failed jobs with extracted errors
- Summary counts by error type
Example output:
{
"pipeline": {
"id": "pipeline-uuid",
"number": 456,
"branch": "feature-branch",
"state": "failed",
"web_url": "https://app.circleci.com/pipelines/github/owner/repo/456"
},
"failures": [
{
"job_name": "test",
"job_id": "job-uuid",
"web_url": "https://app.circleci.com/...",
"errors": [
{
"test_name": "test_user_creation",
"file": "tests/test_user.py",
"line": 25,
"message": "AssertionError: expected 1, got 0",
"type": "test_failure"
}
]
}
],
"summary": {
"total_failed_jobs": 1,
"test_failures": 1,
"lint_errors": 0
}
}URL Patterns
CircleCI URLs follow these patterns:
https://app.circleci.com/pipelines/github/<owner>/<repo>/<pipeline-number>
https://app.circleci.com/pipelines/github/<owner>/<repo>/<pipeline-number>/workflows/<workflow-id>
https://app.circleci.com/pipelines/github/<owner>/<repo>/<pipeline-number>/workflows/<workflow-id>/jobs/<job-number>Extract from URL:
# From: https://app.circleci.com/pipelines/github/owner/repo/456
uv run {baseDir}/scripts/fetch_circleci_failures.py --project gh/owner/repo --pipeline 456Error Types Detected
The script parses logs and extracts:
| Type | Detection Pattern |
|---|---|
test_failure | pytest/unittest/RSpec output |
lint | Linter output (file:line format) |
typescript | TSC error format |
build_error | Compilation failures |
exit_status | Non-zero exit codes |
API Direct Access
If you need to access the API directly:
Get project pipelines:
curl -s -H "Circle-Token: $CIRCLECI_TOKEN" \
"https://circleci.com/api/v2/project/gh/owner/repo/pipeline?branch=$(git branch --show-current)"Get pipeline workflows:
curl -s -H "Circle-Token: $CIRCLECI_TOKEN" \
"https://circleci.com/api/v2/pipeline/<pipeline-id>/workflow"Get workflow jobs:
curl -s -H "Circle-Token: $CIRCLECI_TOKEN" \
"https://circleci.com/api/v2/workflow/<workflow-id>/job"Get job details (includes output URL):
curl -s -H "Circle-Token: $CIRCLECI_TOKEN" \
"https://circleci.com/api/v2/project/gh/owner/repo/job/<job-number>"CircleCI CLI
The circleci CLI is primarily for config validation and local execution, not for fetching build results. Use the script or API for that.
Useful CLI commands:
# Validate config
circleci config validate
# Run job locally
circleci local execute --job <job-name>
# Open project in browser
circleci openTroubleshooting
| Issue | Solution |
|---|---|
| "CIRCLECI_TOKEN not set" | Export the environment variable |
| "Project not found" | Check project slug format (gh/owner/repo or bb/owner/repo) |
| "No pipelines found" | Check branch name, ensure CI has run |
| "Unauthorized" | Regenerate token in CircleCI user settings |
| Rate limiting | Wait and retry |
GitHub Actions Reference
Prerequisites
ghCLI installed and authenticated (gh auth login)- Repository access (push or collaborator permissions for private repos)
Quick Reference
List recent workflow runs:
gh run listList failed runs only:
gh run list --status failureList runs for current branch:
gh run list --branch "$(git branch --show-current)"View a specific run:
gh run view <run-id>View with job details:
gh run view <run-id> --verboseView logs for failed steps only:
gh run view <run-id> --log-failedView full log:
gh run view <run-id> --logView specific job:
gh run view --job <job-id>Get JSON output for parsing:
gh run view <run-id> --json jobs,conclusion,name,headBranchOpen in browser:
gh run view <run-id> --webURL Patterns
GitHub Actions URLs follow this pattern:
https://github.com/<owner>/<repo>/actions/runs/<run-id>
https://github.com/<owner>/<repo>/actions/runs/<run-id>/job/<job-id>Extract run ID from URL:
# From: https://github.com/owner/repo/actions/runs/12345678
run_id="12345678"
gh run view "$run_id" --log-failedCommon Workflows
Get latest failed run for current branch
# Get the run ID
run_id=$(gh run list --branch "$(git branch --show-current)" --status failure --limit 1 --json databaseId --jq '.[0].databaseId')
# View the failures
gh run view "$run_id" --log-failedGet all failed jobs from a run
gh run view <run-id> --json jobs --jq '.jobs[] | select(.conclusion == "failure") | {name: .name, steps: [.steps[] | select(.conclusion == "failure")]}'Re-run failed jobs
gh run rerun <run-id> --failedOutput Parsing Tips
The --log-failed output shows logs for failed steps. Common patterns:
Test failures - Look for:
FAILorFAILEDkeywords- Stack traces with file:line references
- Assertion errors
Build failures - Look for:
error:orError:prefixes- Exit code messages
- Missing dependency errors
Lint failures - Look for:
- File:line:column format
- Rule names/codes
warning:anderror:prefixes
Troubleshooting
| Issue | Solution |
|---|---|
| "not logged in" | Run gh auth login |
| "repository not found" | Check you have access, or use -R owner/repo |
| "run not found" | Verify run ID, check if run was deleted |
| Truncated logs | Use gh run view --log for full output, or download artifacts |
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""
Fetch and parse Buildkite CI failures for the current branch.
Usage:
uv run {baseDir}/scripts/fetch_buildkite_failures.py [options]
Options:
--branch BRANCH Git branch (default: current branch)
--build NUMBER Specific build number (default: latest for branch)
--pipeline SLUG Pipeline slug (default: app)
--help Show this help message
Environment variables required:
BUILDKITE_API_TOKEN
BUILDKITE_ORGANIZATION_SLUG
Output: JSON with build info, failures, and summary
"""
import argparse
import json
import os
import re
import subprocess
import sys
import urllib.request
import urllib.error
def get_current_branch():
"""Get the current git branch name."""
try:
result = subprocess.run(
["git", "branch", "--show-current"],
capture_output=True,
text=True,
check=True
)
return result.stdout.strip()
except subprocess.CalledProcessError:
return None
def api_request(url, token):
"""Make an authenticated request to the Buildkite API."""
req = urllib.request.Request(url)
req.add_header("Authorization", f"Bearer {token}")
try:
with urllib.request.urlopen(req) as response:
return json.loads(response.read().decode())
except urllib.error.HTTPError as e:
if e.code == 401:
raise SystemExit("Error: Invalid BUILDKITE_API_TOKEN")
elif e.code == 404:
return None
raise
def fetch_raw_log(url, token):
"""Fetch raw log content from Buildkite."""
req = urllib.request.Request(url)
req.add_header("Authorization", f"Bearer {token}")
try:
with urllib.request.urlopen(req) as response:
return response.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError:
return None
def strip_ansi_codes(text):
"""Remove ANSI escape codes from text."""
ansi_pattern = re.compile(r'\x1b\[[0-9;]*m')
return ansi_pattern.sub('', text)
def strip_buildkite_prefix(text):
"""Remove Buildkite timestamp prefixes."""
# Remove \e_bk;t=<timestamp>\a prefix
return re.sub(r'\x1b_bk;t=\d+\x07', '', text)
def normalize_log(text):
"""Clean up log text for parsing."""
text = strip_buildkite_prefix(text)
text = strip_ansi_codes(text)
text = text.replace('\r\n', '\n').replace('\r', '\n')
return text
def parse_test_failures(log_content, job_name):
"""Extract test failures from log content."""
errors = []
normalized = normalize_log(log_content)
# Rails/Minitest failure pattern
minitest_pattern = re.compile(
r'(?:Failure|Error):\s*\n'
r'(\S+#\S+)\s*\[([^\]]+):(\d+)\]:\s*\n'
r'((?:.*\n)*?)'
r'(?=\n\n|\nbin/rails|\Z)',
re.MULTILINE
)
for match in minitest_pattern.finditer(normalized):
test_name = match.group(1)
file_path = match.group(2)
line_num = int(match.group(3))
message = match.group(4).strip()
errors.append({
"test_name": test_name,
"file": file_path,
"line": line_num,
"message": message[:500], # Truncate long messages
"type": "test_failure"
})
# RSpec failure pattern
rspec_pattern = re.compile(
r'(\d+)\)\s+(.+?)\n'
r'\s+Failure/Error:.*?\n'
r'((?:.*\n)*?)'
r'\s+#\s+([^:]+):(\d+)',
re.MULTILINE
)
for match in rspec_pattern.finditer(normalized):
test_name = match.group(2).strip()
message = match.group(3).strip()
file_path = match.group(4)
line_num = int(match.group(5))
errors.append({
"test_name": test_name,
"file": file_path,
"line": line_num,
"message": message[:500],
"type": "test_failure"
})
# Generic error pattern (catches exceptions)
if not errors:
error_pattern = re.compile(
r'((?:ActiveRecord|NoMethodError|NameError|ArgumentError|RuntimeError|StandardError)'
r'[^\n]+)\n((?:\s+from [^\n]+\n)*)',
re.MULTILINE
)
for match in error_pattern.finditer(normalized):
message = match.group(1)
stack = match.group(2).strip()
# Try to extract file/line from stack trace
file_match = re.search(r'(?:app|test|spec)/[^:]+:(\d+)', stack)
errors.append({
"test_name": "Unknown",
"file": file_match.group(0).split(':')[0] if file_match else None,
"line": int(file_match.group(1)) if file_match else None,
"message": message[:500],
"type": "error"
})
return errors
def parse_lint_errors(log_content, job_name):
"""Extract linting errors from log content."""
errors = []
normalized = normalize_log(log_content)
# Rubocop pattern
rubocop_pattern = re.compile(
r'^([^:\s]+):(\d+):\d+:\s*([CWEF]):\s*([^:]+):\s*(.+)$',
re.MULTILINE
)
for match in rubocop_pattern.finditer(normalized):
errors.append({
"file": match.group(1),
"line": int(match.group(2)),
"severity": match.group(3),
"cop": match.group(4).strip(),
"message": match.group(5).strip(),
"type": "rubocop"
})
# Biome/ESLint pattern
biome_pattern = re.compile(
r'^([^:\s]+):(\d+)(?::\d+)?\s+(error|warning|info)\s+(.+)$',
re.MULTILINE
)
for match in biome_pattern.finditer(normalized):
errors.append({
"file": match.group(1),
"line": int(match.group(2)),
"severity": match.group(3),
"message": match.group(4).strip(),
"type": "lint"
})
return errors
def parse_typescript_errors(log_content, job_name):
"""Extract TypeScript compilation errors from log content."""
errors = []
normalized = normalize_log(log_content)
# TypeScript error pattern
ts_pattern = re.compile(
r'^([^:\s]+\.tsx?)\((\d+),\d+\):\s*error\s+TS(\d+):\s*(.+)$',
re.MULTILINE
)
for match in ts_pattern.finditer(normalized):
errors.append({
"file": match.group(1),
"line": int(match.group(2)),
"code": f"TS{match.group(3)}",
"message": match.group(4).strip(),
"type": "typescript"
})
return errors
def parse_python_errors(log_content, job_name):
"""Extract Python tool errors (uv, ruff, pytest, ty, pyproject-fmt)."""
errors = []
normalized = normalize_log(log_content)
# uv error pattern (e.g., "error: Failed to initialize cache at `/.cache/uv`")
uv_pattern = re.compile(
r'^error:\s*(.+?)$(?:\n\s+Caused by:\s*(.+?)$)?',
re.MULTILINE
)
for match in uv_pattern.finditer(normalized):
message = match.group(1).strip()
caused_by = match.group(2).strip() if match.group(2) else None
full_message = f"{message} - Caused by: {caused_by}" if caused_by else message
errors.append({
"message": full_message[:500],
"type": "uv"
})
# ruff error pattern (file:line:col: error code message)
ruff_pattern = re.compile(
r'^([^:\s]+\.py):(\d+):(\d+):\s*([A-Z]+\d+)\s+(.+)$',
re.MULTILINE
)
for match in ruff_pattern.finditer(normalized):
errors.append({
"file": match.group(1),
"line": int(match.group(2)),
"column": int(match.group(3)),
"code": match.group(4),
"message": match.group(5).strip(),
"type": "ruff"
})
# pytest failure pattern
pytest_pattern = re.compile(
r'FAILED\s+([^:\s]+)::(\S+)',
re.MULTILINE
)
for match in pytest_pattern.finditer(normalized):
errors.append({
"file": match.group(1),
"test_name": match.group(2),
"type": "pytest"
})
# ty (type checker) error pattern
ty_pattern = re.compile(
r'^([^:\s]+\.py):(\d+):(\d+):\s*error:\s*(.+)$',
re.MULTILINE
)
for match in ty_pattern.finditer(normalized):
errors.append({
"file": match.group(1),
"line": int(match.group(2)),
"column": int(match.group(3)),
"message": match.group(4).strip(),
"type": "ty"
})
return errors
def parse_go_errors(log_content, job_name):
"""Extract Go tool errors (gofmt, go test)."""
errors = []
normalized = normalize_log(log_content)
# gofmt outputs files that need formatting (one per line)
# If gofmt -l outputs anything, those files need formatting
gofmt_pattern = re.compile(
r'^([^:\s]+\.go)$',
re.MULTILINE
)
# Only capture if it looks like gofmt output (near "gofmt" in log)
if 'gofmt' in normalized:
for match in gofmt_pattern.finditer(normalized):
filepath = match.group(1)
if not filepath.startswith('/') and filepath.endswith('.go'):
errors.append({
"file": filepath,
"message": "File needs formatting (gofmt)",
"type": "gofmt"
})
# go test failure pattern
go_test_pattern = re.compile(
r'---\s*FAIL:\s*(\S+)\s*\(([^)]+)\)',
re.MULTILINE
)
for match in go_test_pattern.finditer(normalized):
errors.append({
"test_name": match.group(1),
"duration": match.group(2),
"type": "go_test"
})
# go build/compile errors
go_compile_pattern = re.compile(
r'^([^:\s]+\.go):(\d+):(\d+):\s*(.+)$',
re.MULTILINE
)
for match in go_compile_pattern.finditer(normalized):
errors.append({
"file": match.group(1),
"line": int(match.group(2)),
"column": int(match.group(3)),
"message": match.group(4).strip(),
"type": "go_compile"
})
return errors
def parse_docker_errors(log_content, job_name):
"""Extract Docker and permission errors."""
errors = []
normalized = normalize_log(log_content)
# Permission denied errors - capture the full context line
permission_pattern = re.compile(
r'([^\n]*(?:Permission denied|permission denied)[^\n]*)',
re.MULTILINE | re.IGNORECASE
)
for match in permission_pattern.finditer(normalized):
message = match.group(1).strip()
# Skip if it's just noise or already captured by another parser
if message and len(message) > 20 and '^^^' not in message:
errors.append({
"message": message[:500],
"type": "permission"
})
# Docker run failures
docker_error_pattern = re.compile(
r'docker:\s*Error[^:]*:\s*(.+?)(?=\n|$)',
re.MULTILINE | re.IGNORECASE
)
for match in docker_error_pattern.finditer(normalized):
errors.append({
"message": match.group(1).strip(),
"type": "docker"
})
# Generic "The command exited with status X"
exit_status_pattern = re.compile(
r'The command exited with status (\d+)',
re.MULTILINE
)
for match in exit_status_pattern.finditer(normalized):
status = int(match.group(1))
if status != 0 and not errors: # Only add if no other errors found
errors.append({
"message": f"Command exited with status {status}",
"exit_status": status,
"type": "exit_status"
})
return errors
def classify_job(job_name):
"""Classify a job by its name to determine parsing strategy."""
name_lower = job_name.lower()
if any(x in name_lower for x in ['rspec', 'rails', 'minitest']):
return 'ruby_test'
elif any(x in name_lower for x in ['python', 'pytest', 'agents', 'snake']):
return 'python'
elif any(x in name_lower for x in ['go ', 'golang', 'cli test']):
return 'go'
elif any(x in name_lower for x in ['lint', 'rubocop', 'biome', 'eslint']):
return 'lint'
elif any(x in name_lower for x in ['typescript', 'typecheck', 'tsc']):
return 'typescript'
elif 'test' in name_lower:
return 'test'
else:
return 'unknown'
def parse_job_log(log_content, job_name):
"""Parse a job's log and extract relevant errors."""
job_type = classify_job(job_name)
errors = []
# Always check for Docker/permission errors first
errors.extend(parse_docker_errors(log_content, job_name))
if job_type == 'ruby_test':
errors.extend(parse_test_failures(log_content, job_name))
elif job_type == 'python':
errors.extend(parse_python_errors(log_content, job_name))
elif job_type == 'go':
errors.extend(parse_go_errors(log_content, job_name))
elif job_type == 'lint':
errors.extend(parse_lint_errors(log_content, job_name))
elif job_type == 'typescript':
errors.extend(parse_typescript_errors(log_content, job_name))
elif job_type == 'test':
# Generic test - try multiple parsers
errors.extend(parse_test_failures(log_content, job_name))
errors.extend(parse_python_errors(log_content, job_name))
errors.extend(parse_go_errors(log_content, job_name))
else:
# Unknown - try all parsers
errors.extend(parse_test_failures(log_content, job_name))
errors.extend(parse_lint_errors(log_content, job_name))
errors.extend(parse_typescript_errors(log_content, job_name))
errors.extend(parse_python_errors(log_content, job_name))
errors.extend(parse_go_errors(log_content, job_name))
# Deduplicate errors by message
seen = set()
unique_errors = []
for error in errors:
key = error.get('message', '') or error.get('test_name', '') or error.get('file', '')
if key and key not in seen:
seen.add(key)
unique_errors.append(error)
elif not key:
unique_errors.append(error)
return unique_errors
def main():
parser = argparse.ArgumentParser(
description="Fetch and parse Buildkite CI failures"
)
parser.add_argument("--branch", help="Git branch (default: current)")
parser.add_argument("--build", type=int, help="Specific build number")
parser.add_argument("--pipeline", default="app", help="Pipeline slug (default: app)")
args = parser.parse_args()
# Check environment variables
token = os.environ.get("BUILDKITE_API_TOKEN")
org = os.environ.get("BUILDKITE_ORGANIZATION_SLUG")
if not token:
print(json.dumps({"error": "BUILDKITE_API_TOKEN environment variable not set"}))
sys.exit(1)
if not org:
print(json.dumps({"error": "BUILDKITE_ORGANIZATION_SLUG environment variable not set"}))
sys.exit(1)
# Determine branch
branch = args.branch or get_current_branch()
if not branch:
print(json.dumps({"error": "Could not determine git branch. Use --branch to specify."}))
sys.exit(1)
base_url = f"https://api.buildkite.com/v2/organizations/{org}/pipelines/{args.pipeline}"
# Fetch build
if args.build:
build_url = f"{base_url}/builds/{args.build}"
build = api_request(build_url, token)
else:
builds_url = f"{base_url}/builds?branch={branch}&per_page=1"
builds = api_request(builds_url, token)
if not builds:
print(json.dumps({
"error": f"No builds found for branch '{branch}' in pipeline '{args.pipeline}'"
}))
sys.exit(1)
build = builds[0]
if not build:
print(json.dumps({"error": f"Build not found"}))
sys.exit(1)
# Extract failed jobs
failed_jobs = [
job for job in build.get("jobs", [])
if job.get("state") == "failed" and job.get("type") == "script"
]
result = {
"build": {
"number": build.get("number"),
"branch": build.get("branch"),
"state": build.get("state"),
"commit": build.get("commit", "")[:8],
"web_url": build.get("web_url"),
"message": build.get("message", "")[:100]
},
"failures": [],
"summary": {
"total_failed_jobs": len(failed_jobs),
"test_failures": 0,
"lint_errors": 0,
"typescript_errors": 0,
"python_errors": 0,
"go_errors": 0,
"docker_errors": 0,
"other_errors": 0
}
}
# If build passed, report that
if build.get("state") == "passed":
result["message"] = "Build passed! No failures to diagnose."
print(json.dumps(result, indent=2))
return
if build.get("state") == "running":
result["message"] = "Build is still running."
result["running_jobs"] = [
job.get("name") for job in build.get("jobs", [])
if job.get("state") == "running"
]
# Process each failed job
for job in failed_jobs:
job_name = job.get("name", "Unknown")
raw_log_url = job.get("raw_log_url")
failure = {
"job_name": job_name,
"job_id": job.get("id"),
"web_url": job.get("web_url"),
"errors": []
}
if raw_log_url:
log_content = fetch_raw_log(raw_log_url, token)
if log_content:
errors = parse_job_log(log_content, job_name)
failure["errors"] = errors
# Update summary counts
for error in errors:
error_type = error.get("type", "other")
if error_type in ["test_failure", "error", "pytest"]:
result["summary"]["test_failures"] += 1
elif error_type in ["rubocop", "lint", "ruff"]:
result["summary"]["lint_errors"] += 1
elif error_type == "typescript":
result["summary"]["typescript_errors"] += 1
elif error_type in ["uv", "ty"]:
result["summary"]["python_errors"] += 1
elif error_type in ["go_test", "go_compile", "gofmt"]:
result["summary"]["go_errors"] += 1
elif error_type in ["docker", "permission", "exit_status"]:
result["summary"]["docker_errors"] += 1
else:
result["summary"]["other_errors"] += 1
result["failures"].append(failure)
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""
Fetch and parse CircleCI failures for the current branch.
Usage:
uv run {baseDir}/scripts/fetch_circleci_failures.py [options]
Options:
--branch BRANCH Git branch (default: current branch)
--pipeline NUMBER Specific pipeline number
--project SLUG Project slug (default: auto-detect from git remote)
Format: gh/owner/repo or bb/owner/repo
--help Show this help message
Environment variables required:
CIRCLECI_TOKEN Personal API token
Output: JSON with pipeline info, failures, and summary
"""
import argparse
import json
import os
import re
import subprocess
import sys
import urllib.request
import urllib.error
def get_current_branch():
"""Get the current git branch name."""
try:
result = subprocess.run(
["git", "branch", "--show-current"],
capture_output=True,
text=True,
check=True
)
return result.stdout.strip()
except subprocess.CalledProcessError:
return None
def get_project_slug():
"""Auto-detect project slug from git remote."""
try:
result = subprocess.run(
["git", "remote", "get-url", "origin"],
capture_output=True,
text=True,
check=True
)
url = result.stdout.strip()
# Parse GitHub SSH URL: git@github.com:owner/repo.git
match = re.match(r'git@github\.com:([^/]+)/(.+?)(?:\.git)?$', url)
if match:
return f"gh/{match.group(1)}/{match.group(2)}"
# Parse GitHub HTTPS URL: https://github.com/owner/repo.git
match = re.match(r'https://github\.com/([^/]+)/(.+?)(?:\.git)?$', url)
if match:
return f"gh/{match.group(1)}/{match.group(2)}"
# Parse Bitbucket SSH URL: git@bitbucket.org:owner/repo.git
match = re.match(r'git@bitbucket\.org:([^/]+)/(.+?)(?:\.git)?$', url)
if match:
return f"bb/{match.group(1)}/{match.group(2)}"
# Parse Bitbucket HTTPS URL: https://bitbucket.org/owner/repo.git
match = re.match(r'https://bitbucket\.org/([^/]+)/(.+?)(?:\.git)?$', url)
if match:
return f"bb/{match.group(1)}/{match.group(2)}"
return None
except subprocess.CalledProcessError:
return None
def api_request(url, token):
"""Make an authenticated request to the CircleCI API."""
req = urllib.request.Request(url)
req.add_header("Circle-Token", token)
req.add_header("Accept", "application/json")
try:
with urllib.request.urlopen(req) as response:
return json.loads(response.read().decode())
except urllib.error.HTTPError as e:
if e.code == 401:
raise SystemExit("Error: Invalid CIRCLECI_TOKEN")
elif e.code == 404:
return None
raise
def fetch_job_log(project_slug, job_number, token):
"""Fetch job output/log from CircleCI."""
# Get job details which includes step actions
url = f"https://circleci.com/api/v2/project/{project_slug}/job/{job_number}"
job_details = api_request(url, token)
if not job_details:
return None
# Collect output from all steps
log_content = []
# The v2 API doesn't directly expose logs, we need to use v1.1 API for step output
# v1.1 endpoint: GET /project/:vcs-type/:username/:project/:build_num
parts = project_slug.split('/')
if len(parts) != 3:
return None
vcs_type = "github" if parts[0] == "gh" else "bitbucket"
v1_url = f"https://circleci.com/api/v1.1/project/{vcs_type}/{parts[1]}/{parts[2]}/{job_number}"
req = urllib.request.Request(v1_url)
req.add_header("Circle-Token", token)
req.add_header("Accept", "application/json")
try:
with urllib.request.urlopen(req) as response:
build_details = json.loads(response.read().decode())
except urllib.error.HTTPError:
return None
# Extract step outputs
steps = build_details.get("steps", [])
for step in steps:
for action in step.get("actions", []):
if action.get("output_url"):
# Fetch the actual output
try:
output_req = urllib.request.Request(action["output_url"])
with urllib.request.urlopen(output_req) as resp:
output_data = json.loads(resp.read().decode())
for item in output_data:
if item.get("message"):
log_content.append(item["message"])
except (urllib.error.HTTPError, json.JSONDecodeError):
pass
return "\n".join(log_content) if log_content else None
def strip_ansi_codes(text):
"""Remove ANSI escape codes from text."""
ansi_pattern = re.compile(r'\x1b\[[0-9;]*m')
return ansi_pattern.sub('', text)
def normalize_log(text):
"""Clean up log text for parsing."""
text = strip_ansi_codes(text)
text = text.replace('\r\n', '\n').replace('\r', '\n')
return text
def parse_test_failures(log_content):
"""Extract test failures from log content."""
errors = []
normalized = normalize_log(log_content)
# pytest failure pattern
pytest_pattern = re.compile(
r'FAILED\s+([^:\s]+)::(\S+)',
re.MULTILINE
)
for match in pytest_pattern.finditer(normalized):
errors.append({
"file": match.group(1),
"test_name": match.group(2),
"type": "pytest"
})
# pytest short test summary with error details
pytest_error_pattern = re.compile(
r'FAILED\s+([^:\s]+)::(\S+)\s*-\s*(.+)$',
re.MULTILINE
)
for match in pytest_error_pattern.finditer(normalized):
errors.append({
"file": match.group(1),
"test_name": match.group(2),
"message": match.group(3).strip()[:500],
"type": "pytest"
})
# RSpec failure pattern
rspec_pattern = re.compile(
r'(\d+)\)\s+(.+?)\n'
r'\s+Failure/Error:.*?\n'
r'((?:.*\n)*?)'
r'\s+#\s+([^:]+):(\d+)',
re.MULTILINE
)
for match in rspec_pattern.finditer(normalized):
errors.append({
"test_name": match.group(2).strip(),
"file": match.group(4),
"line": int(match.group(5)),
"message": match.group(3).strip()[:500],
"type": "test_failure"
})
# Jest/Mocha failure pattern
jest_pattern = re.compile(
r'●\s+(.+?)\n\s*\n\s*(.+?)(?=\n\s*\n|\n\s*●|\Z)',
re.MULTILINE | re.DOTALL
)
for match in jest_pattern.finditer(normalized):
errors.append({
"test_name": match.group(1).strip(),
"message": match.group(2).strip()[:500],
"type": "jest"
})
# Generic assertion error
assertion_pattern = re.compile(
r'(AssertionError|assert\s+\w+.*?failed):\s*(.+?)(?=\n\n|\Z)',
re.MULTILINE | re.IGNORECASE
)
for match in assertion_pattern.finditer(normalized):
errors.append({
"message": match.group(2).strip()[:500],
"type": "assertion"
})
return errors
def parse_lint_errors(log_content):
"""Extract linting errors from log content."""
errors = []
normalized = normalize_log(log_content)
# Generic file:line:col pattern (eslint, ruff, rubocop, etc.)
lint_pattern = re.compile(
r'^([^:\s]+):(\d+):(\d+):\s*(?:(error|warning|Error|Warning|E|W|C|F))?\s*(.+)$',
re.MULTILINE
)
for match in lint_pattern.finditer(normalized):
errors.append({
"file": match.group(1),
"line": int(match.group(2)),
"column": int(match.group(3)),
"severity": match.group(4) or "error",
"message": match.group(5).strip()[:500],
"type": "lint"
})
return errors
def parse_typescript_errors(log_content):
"""Extract TypeScript compilation errors from log content."""
errors = []
normalized = normalize_log(log_content)
# TypeScript error pattern
ts_pattern = re.compile(
r'^([^:\s]+\.tsx?)\((\d+),\d+\):\s*error\s+TS(\d+):\s*(.+)$',
re.MULTILINE
)
for match in ts_pattern.finditer(normalized):
errors.append({
"file": match.group(1),
"line": int(match.group(2)),
"code": f"TS{match.group(3)}",
"message": match.group(4).strip(),
"type": "typescript"
})
return errors
def parse_build_errors(log_content):
"""Extract build/compilation errors."""
errors = []
normalized = normalize_log(log_content)
# Generic error pattern
error_pattern = re.compile(
r'^(error|Error|ERROR):\s*(.+)$',
re.MULTILINE
)
for match in error_pattern.finditer(normalized):
message = match.group(2).strip()
if message and len(message) > 10:
errors.append({
"message": message[:500],
"type": "build_error"
})
# Exit code pattern
exit_pattern = re.compile(
r'(?:exited with|exit code|returned)\s+(\d+)',
re.MULTILINE | re.IGNORECASE
)
for match in exit_pattern.finditer(normalized):
code = int(match.group(1))
if code != 0:
errors.append({
"message": f"Process exited with code {code}",
"exit_code": code,
"type": "exit_status"
})
return errors
def parse_job_log(log_content, job_name):
"""Parse a job's log and extract relevant errors."""
errors = []
# Try all parsers
errors.extend(parse_test_failures(log_content))
errors.extend(parse_lint_errors(log_content))
errors.extend(parse_typescript_errors(log_content))
errors.extend(parse_build_errors(log_content))
# Deduplicate errors by message
seen = set()
unique_errors = []
for error in errors:
key = error.get('message', '') or error.get('test_name', '') or error.get('file', '')
if key and key not in seen:
seen.add(key)
unique_errors.append(error)
elif not key:
unique_errors.append(error)
return unique_errors
def main():
parser = argparse.ArgumentParser(
description="Fetch and parse CircleCI failures"
)
parser.add_argument("--branch", help="Git branch (default: current)")
parser.add_argument("--pipeline", type=int, help="Specific pipeline number")
parser.add_argument("--project", help="Project slug (e.g., gh/owner/repo)")
args = parser.parse_args()
# Check environment variables
token = os.environ.get("CIRCLECI_TOKEN")
if not token:
print(json.dumps({"error": "CIRCLECI_TOKEN environment variable not set"}))
sys.exit(1)
# Determine project
project_slug = args.project or get_project_slug()
if not project_slug:
print(json.dumps({
"error": "Could not determine project. Use --project to specify (e.g., gh/owner/repo)"
}))
sys.exit(1)
# Determine branch
branch = args.branch or get_current_branch()
if not branch:
print(json.dumps({"error": "Could not determine git branch. Use --branch to specify."}))
sys.exit(1)
base_url = "https://circleci.com/api/v2"
# Fetch pipeline
if args.pipeline:
# Get pipelines and find the one with matching number
pipelines_url = f"{base_url}/project/{project_slug}/pipeline?branch={branch}"
response = api_request(pipelines_url, token)
pipelines = response.get("items", []) if response else []
pipeline = next((p for p in pipelines if p.get("number") == args.pipeline), None)
else:
# Get latest pipeline for branch
pipelines_url = f"{base_url}/project/{project_slug}/pipeline?branch={branch}"
response = api_request(pipelines_url, token)
pipelines = response.get("items", []) if response else []
pipeline = pipelines[0] if pipelines else None
if not pipeline:
print(json.dumps({
"error": f"No pipelines found for branch '{branch}' in project '{project_slug}'"
}))
sys.exit(1)
pipeline_id = pipeline.get("id")
# Get workflows for this pipeline
workflows_url = f"{base_url}/pipeline/{pipeline_id}/workflow"
workflows_response = api_request(workflows_url, token)
workflows = workflows_response.get("items", []) if workflows_response else []
result = {
"pipeline": {
"id": pipeline_id,
"number": pipeline.get("number"),
"branch": branch,
"state": pipeline.get("state"),
"web_url": f"https://app.circleci.com/pipelines/{project_slug}/{pipeline.get('number')}"
},
"failures": [],
"summary": {
"total_failed_jobs": 0,
"test_failures": 0,
"lint_errors": 0,
"typescript_errors": 0,
"build_errors": 0,
"other_errors": 0
}
}
# Check if all workflows passed
all_passed = all(w.get("status") == "success" for w in workflows)
if all_passed and workflows:
result["message"] = "All workflows passed! No failures to diagnose."
print(json.dumps(result, indent=2))
return
# Check if still running
any_running = any(w.get("status") == "running" for w in workflows)
if any_running:
result["message"] = "Some workflows are still running."
result["running_workflows"] = [
w.get("name") for w in workflows if w.get("status") == "running"
]
# Process each workflow
for workflow in workflows:
if workflow.get("status") not in ["failed", "error"]:
continue
workflow_id = workflow.get("id")
# Get jobs for this workflow
jobs_url = f"{base_url}/workflow/{workflow_id}/job"
jobs_response = api_request(jobs_url, token)
jobs = jobs_response.get("items", []) if jobs_response else []
# Filter to failed jobs
failed_jobs = [j for j in jobs if j.get("status") == "failed"]
result["summary"]["total_failed_jobs"] += len(failed_jobs)
for job in failed_jobs:
job_name = job.get("name", "Unknown")
job_number = job.get("job_number")
failure = {
"workflow": workflow.get("name"),
"job_name": job_name,
"job_number": job_number,
"web_url": f"https://app.circleci.com/pipelines/{project_slug}/{pipeline.get('number')}/workflows/{workflow_id}/jobs/{job_number}",
"errors": []
}
# Try to fetch and parse job logs
if job_number:
log_content = fetch_job_log(project_slug, job_number, token)
if log_content:
errors = parse_job_log(log_content, job_name)
failure["errors"] = errors
# Update summary counts
for error in errors:
error_type = error.get("type", "other")
if error_type in ["test_failure", "pytest", "jest", "assertion"]:
result["summary"]["test_failures"] += 1
elif error_type == "lint":
result["summary"]["lint_errors"] += 1
elif error_type == "typescript":
result["summary"]["typescript_errors"] += 1
elif error_type in ["build_error", "exit_status"]:
result["summary"]["build_errors"] += 1
else:
result["summary"]["other_errors"] += 1
result["failures"].append(failure)
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()