Python CI Status Bot
Build a Python-based CI status bot
Focus: build a python-based ci status bot
You've just pushed a commit, and now you're refreshing the CI page over and over, hoping the build goes green. That's a waste of your attention and a perfect automation target. In this lesson, you'll learn how to build a Python-based CI status bot that polls your CI system's API and posts the result to a Slack channel — turning a manual ritual into a hands-off notification.
The problem this lesson solves
Modern teams rely on continuous integration (CI) to validate every commit, but the feedback loop is broken: you have to manually check the pipeline status. This wastes developer time, delays bug fixes, and breaks flow. A CI status bot automates the polling and notification, so you get the result where you already work — Slack, Discord, or email. Instead of refreshing a dashboard, you get a message that says "Build #42: ✅ passed" the moment it happens.
Beyond convenience, the bot improves team transparency and accountability. When the bot posts failures publicly, everyone sees the broken build and can react immediately. It also gives you a single source of truth for multiple repositories — you can watch all your services in one Slack channel.
Core concept / mental model
A CI status bot is a polling wrapper around the CI provider's REST API. Think of it as a friendly assistant that:
- Asks the CI API "what happened?" every few seconds.
- Compares the answer with the last known state.
- Yells (posts a message) only when the state changes.
This simple loop is the heart of the bot. The state is the build number + status (e.g., #123: pending, #123: success). You store it locally (a file, a database) or in memory.
Definitions
- Build — An execution of your CI pipeline for a specific commit.
- Status — The lifecycle state:
pending,running,success,failed,canceled. - Polling — Repeatedly requesting the API at a fixed interval.
- Webhook — The opposite: the CI server calls your URL on an event. (We'll compare later.)
How it works step by step
Let's break the bot into a logical sequence:
- Authenticate — Obtain an API token from your CI provider (GitHub Actions, GitLab CI, CircleCI, Jenkins). Store it as an environment variable.
- Fetch latest build — Make an HTTP GET request to the CI API endpoint. The response contains the latest build's status and commit info.
- Compare state — Check if the build number changed from what you have stored. If it's the same build, you can ignore it unless the status changed (e.g., from
pendingtofailed). - Detect transition — If the status changed for the same build, or a new build appeared, you have an event.
- Format message — Build a human-readable string with status emoji, build number, commit SHA, and repo name.
- Post to chat — Send the message to your Slack webhook URL (or Discord channel) via a simple POST request.
- Sleep — Wait a few seconds, then repeat from step 2.
This loop is idempotent — running it twice won't send duplicates because you only post on state changes.
Hands-on walkthrough
We'll build a bot that watches a GitHub Actions workflow and posts to Slack. The bot uses the GitHub REST API (no extra SDK needed) and a Slack Incoming Webhook.
Prerequisites
- Python 3.10+
- A public GitHub repository with Actions enabled
- A Slack workspace (you can create a test one) and an Incoming Webhook URL
requestslibrary (pip install requests)
Step 1: Environment setup
Save your secrets in a .env file and load them with python-dotenv:
pip install requests python-dotenv
GITHUB_API_TOKEN=ghp_xxxx
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/xxx/yyy/zzz
REPO=owner/repo
BRANCH=main
Step 2: The core bot
Now the full script:
import os
import time
import requests
from dotenv import load_dotenv
load_dotenv()
GITHUB_API = "https://api.github.com"
TOKEN = os.getenv("GITHUB_API_TOKEN")
REPO = os.getenv("REPO")
BRANCH = os.getenv("BRANCH")
SLACK_URL = os.getenv("SLACK_WEBHOOK_URL")
HEADERS = {
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {TOKEN}",
"X-GitHub-Api-Version": "2022-11-28"
}
def get_latest_build():
"""Fetch the latest workflow run for the given branch."""
url = f"{GITHUB_API}/repos/{REPO}/actions/runs"
params = {"branch": BRANCH, "per_page": 1}
resp = requests.get(url, headers=HEADERS, params=params)
resp.raise_for_status()
run = resp.json()["workflow_runs"][0]
return run["id"], run["status"], run["conclusion"], run["head_sha"]
def status_message(build_id, status, conclusion, sha):
if status == "completed":
if conclusion == "success":
emoji = "✅"
text = "passed"
else:
emoji = "❌"
text = f"failed on {conclusion}"
else:
emoji = "⏳"
text = status
return f"{emoji} Build #{build_id} {text}\nCommit: `{sha[:7]}` ({REPO}@{BRANCH})"
def post_to_slack(message):
payload = {"text": message}
resp = requests.post(SLACK_URL, json=payload)
resp.raise_for_status()
def main():
last_id, last_status, last_conclusion = None, None, None
while True:
try:
build_id, status, conclusion, sha = get_latest_build()
# Only post if the build is new or its status changed
if (build_id != last_id) or (last_id is not None and status != last_status):
msg = status_message(build_id, status, conclusion, sha)
post_to_slack(msg)
last_id, last_status, last_conclusion = build_id, status, conclusion
print(f"Posted: {msg}")
except Exception as e:
print(f"Error: {e}")
time.sleep(15) # poll every 15 seconds
if __name__ == "__main__":
main()
Expected output
When you run the script and push a new commit, you'll see in your terminal:
Posted: ⏳ Build #123 pending
Commit: `9f2a1b` (owner/repo@main)
...later...
Posted: ✅ Build #123 passed
Commit: `9f2a1b` (owner/repo@main)
And the same messages land in your Slack channel.
Pro tip: Run this script inside a Docker container or as a cron job so it restarts automatically if it crashes. For a production bot, consider using
tenacityfor retries andlogginginstead of
Compare options / when to choose what
Your bot can talk to different CI providers and different chat platforms. Here’s a quick comparison:
CI Providers
| Provider | API | Auth | Notes |
|---|---|---|---|
| GitHub Actions | REST + GraphQL | Personal Access Token | Easy, rich data |
| GitLab CI | REST | Personal Access Token | Pipeline & job status |
| CircleCI | REST | API Token | Requires project slug |
| Jenkins | REST | API Token | Often behind VPN, JSON/XML |
Notification Channels
| Channel | Method | Setup | Cost |
|---|---|---|---|
| Slack | Incoming Webhook | 2 minutes | Free tier |
| Discord | Webhook | 2 minutes | Free |
| SMTP | More complex | Depends on provider | |
| Custom dashboard | REST push | High effort | N/A |
Polling vs. Webhooks
| Aspect | Polling (our approach) | Webhooks |
|---|---|---|
| Latency | Up to poll interval | Instant |
| Complexity | Simple, works anywhere | Need exposed endpoint |
| Reliability | Need to handle failures | Server must be up |
| Use case | Learning, internal tools | Production, minimal delay |
Troubleshooting & edge cases
1. Rate limiting
GitHub’s API limits unauthenticated requests to 60/hour. With a token, you get 5000/hour. Our bot polls every 15 seconds, which is ~240 requests/hour — within limits for one repo, but if you monitor many repos, you may hit the limit. Fix: Increase the poll interval to 60 seconds, use the If-None-Match header with ETags, or use a webhook instead.
2. The bot sends duplicates
Our condition (build_id != last_id) only catches new builds. If the same build changes from in_progress to completed, the second condition fires. But if you restart the bot, it sends the current status again — that’s a false duplicate. Fix: Persist last_id in a file (see variations).
3. Network errors
requests.get can fail if your machine goes offline. Our try/except swallows the error and retries forever, but that could mask real issues. Fix: Log exceptions and alert on persistent failures.
4. Slack webhook returns 404
If your webhook URL is wrong or revoked, post_to_slack raises. Fix: Validate the URL, and consider using Slack’s chat.postMessage API with a bot token for more control.
5. Empty workflow runs
If no workflow has ever run, workflow_runs[0] will raise IndexError. Fix: Check the list is non-empty before indexing.
What you learned & what's next
You’ve learned how to build a Python-based CI status bot that polls a CI API, detects state changes, and sends the result to Slack. You now understand the core polling loop, state tracking, and how to handle provider-specific differences. This is a foundational pattern for many DevOps automations — from deployment watches to infrastructure health checks.
Next in this track, you’ll explore how to create a Slack bot that responds to commands — turning your one-way notifications into a two-way conversation. That will let you trigger builds, get logs, and manage infrastructure directly from your chat.
Keep building — your teams will thank you!
Practice recap
Modify the script to monitor two different repositories by reading a list of repos from a config file. Then extend it to post a failure message with the commit message (you'll need to fetch the commit info from the API). Finally, add retry logic with tenacity and test what happens when the network is cut off.
Common mistakes
- Hardcoding the API token in the script instead of using environment variables — you risk leaking it in source control.
- Posting a message on every poll, even when the build status hasn't changed — this spams the channel.
- Not handling API rate limits — your bot fails silently after hitting the quota.
- Assuming the first workflow run always exists — the script crashes if no builds have run yet.
- Using polling for real-time critical alerts when a webhook would be more appropriate.
Variations
- Use GitLab CI API instead of GitHub, with similar endpoints for pipeline status.
- Post to Discord instead of Slack, using a Discord webhook URL.
- Persist the last build state in a JSON file so the bot survives restarts without resending the last status.
Real-world use cases
- Watch multiple GitHub repositories and post build results to a #ci channel using the same polling loop with a small config file.
- Combine with a deployment trigger — when CI passes on the main branch, automatically call an AWS Lambda to deploy a new container version.
- Create a dashboard bot that reports nightly test results and failed builds to a team chat, reducing the need to check CI pages.
Key takeaways
- The core loop is: poll API → compare state → send notification only on change.
- Using polling is simple and works anywhere, but webhooks are more efficient for high-frequency events.
- Always store API tokens and webhook URLs as environment variables, never in code.
- Persisting the last known build state avoids duplicate notifications after a restart.
- Know your provider's rate limits and adjust polling interval accordingly.
- The same pattern extends to other CI providers and chat platforms with minimal changes.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.