Python CI Status Bot

Build a Python-based CI status bot

Focus: build a python-based ci status bot

Sponsored

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:

  1. Asks the CI API "what happened?" every few seconds.
  2. Compares the answer with the last known state.
  3. 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:

  1. Authenticate — Obtain an API token from your CI provider (GitHub Actions, GitLab CI, CircleCI, Jenkins). Store it as an environment variable.
  2. Fetch latest build — Make an HTTP GET request to the CI API endpoint. The response contains the latest build's status and commit info.
  3. 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 pending to failed).
  4. Detect transition — If the status changed for the same build, or a new build appeared, you have an event.
  5. Format message — Build a human-readable string with status emoji, build number, commit SHA, and repo name.
  6. Post to chat — Send the message to your Slack webhook URL (or Discord channel) via a simple POST request.
  7. 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
  • requests library (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 tenacity for retries and logging instead of print.

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
Email 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

  1. Use GitLab CI API instead of GitHub, with similar endpoints for pipeline status.
  2. Post to Discord instead of Slack, using a Discord webhook URL.
  3. 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.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.