███████╗██╗ ██╗██████╗ ██████╗ ████████╗
╚══███╔╝██║ ██╔╝██╔══██╗██╔═══██╗╚══██╔══╝
███╔╝ █████╔╝ ██████╔╝██║ ██║ ██║
███╔╝ ██╔═██╗ ██╔══██╗██║ ██║ ██║
███████╗██║ ██╗██████╔╝╚██████╔╝ ██║
╚══════╝╚═╝ ╚═╝╚═════╝ ╚═════╝ ╚═╝
ZKBot is an automated triage assistant built for the Zookeeper team. Every weekday morning at 7:30 AM CDT, it silently wakes up, scans the PB Jira board, and delivers a clean Slack report of every ticket that needs human attention — before anyone has had their first coffee.
No dashboards to check. No filters to remember. No tickets falling through the cracks. Just a friendly nudge, right where the team already lives.
"A good bot should feel like a great teammate — one that handles the boring stuff so you can focus on the interesting stuff."
flowchart TD
A([⏰ 7:30 AM CDT\nGitHub Actions Cron]) --> B[Checkout Repo]
B --> C[Install Dependencies]
C --> D[Run main.py]
D --> E{Authenticate\nwith Jira API}
E --> F1[Query Filter 18934\nBacklog, No Team]
E --> F2[Query Filter 18935\nReviewed, No Team, Stale]
E --> F3[Query Filter 18936\nAll PB, No Team]
E --> F4[Query Filter 20496\nStale Backlog, Team Assigned]
F1 --> G1{No linked\nissues?}
F2 --> G2{No linked\nissues?}
F3 --> G3{Has linked\nissues?}
F4 --> G4{Linked work item\nstatuses?}
G1 -- Yes --> S1[Needs Attention]
G2 -- Yes --> S2[Reviewed, No Movement]
G3 -- Yes --> S3[Warning]
G4 -- None --> S4[Warning: No Linked Work]
G4 -- Any Backlog/To Do --> S5[Warning: Pending Work]
G4 -- All Done --> S6[Info: Work Complete]
S1 --> M1[Build Triage Report]
S2 --> M1
S3 --> M1
S4 --> M2[Build Backlog Report]
S5 --> M2
S6 --> M2
M1 --> N{Any sections\nnon-empty?}
M2 --> N
N -- Yes --> O[POST to Slack\nWorkflow Webhook]
N -- No --> P[POST friendly\nall-clear message]
O --> Q([📬 Zookeeper team\nreceives Slack message])
P --> Q
Triage criteria live entirely in Jira saved filters — no hardcoded JQL in the code. Each filter is managed directly in Jira and Python does a secondary pass to check for linked issues.
| Filter | Link | What it captures | Python keeps |
|---|---|---|---|
| 18934 — ZKBot - Backlogged items | Open in Jira | Backlog status, no tech team | tickets with no linked issues |
| 18935 — ZKBot - Reviewed/More Info Needed | Open in Jira | Reviewed or More Info Needed, no tech team, not updated in 2+ days | tickets with no linked issues |
| 18936 — ZKBot - No Tech Team | Open in Jira | All PB board items, no tech team | tickets WITH linked issues |
| 20496 — ZKBot - Stale Backlog, Tech Team Assigned | Open in Jira | Backlog status, tech team assigned | split three ways by linked work item status |
Why linked issues in Python and not in the filter? Standard Jira Cloud JQL has no operator for filtering by whether linked issues exist. If ScriptRunner is ever added to the instance,
issueFunction in issuesWithoutLinks()could move this check into the filter itself.
Filter 20496 JQL: project in ("PB") AND status in (Backlog) AND "Technical Team[Dropdown]" IS NOT EMPTY ORDER BY created DESC
Tickets from this filter are further split in Python by the status of their linked work items:
| Case | Condition | Bucket |
|---|---|---|
| A | No linked work items | |
| B | Has linked work items, all have status name exactly Done |
ℹ️ Informational — still open but work is complete |
| C | Has linked work items, any has status Backlog or To Do |
|
| Neither (e.g. only "In Progress" linked items) | — | Omitted — not reported |
C is checked before B, since a ticket can't simultaneously have a pending linked item and have all linked items done.
ZKBot posts via a Slack Workflow Builder webhook. The workflow receives a single text variable — zkbotpayload — and routes it to the Zookeeper team's channel.
When tickets need attention:
Triage Report
:red_circle: 2 ticket(s) need attention — Backlog && No Tickets && No Team
- PB-2300 (5d) — Some unassigned backlog ticket
https://pagerinc.atlassian.net/browse/PB-2300
:hourglass_flowing_sand: 1 ticket(s) need attention - Reviewed | More Information Needed - No Recent Updates && No Tickets && No Team
- PB-2150 (3d) — Stale reviewed ticket
https://pagerinc.atlassian.net/browse/PB-2150
:warning: 2 ticket(s) have linked tickets but No Tech Team Assigned
- PB-2090 — [INT] Some linked but unassigned ticket
https://pagerinc.atlassian.net/browse/PB-2090
Backlog Report
:warning: 1 ticket(s) — No Linked Work Items
- CHA PB-2200 (56d) — Some stale backlog ticket, team assigned, nothing linked
https://pagerinc.atlassian.net/browse/PB-2200
:hourglass_flowing_sand: 1 ticket(s) — Pending Work Items (Backlog/To Do)
- MIR PB-2210 (19d) — Some stale backlog ticket with a linked item still in To Do
https://pagerinc.atlassian.net/browse/PB-2210
:information_source: 1 ticket(s) — Still open but all Linked Work Items are Done
- MIR PB-2220 (54d) — Some backlog ticket whose linked work is all Done
https://pagerinc.atlassian.net/browse/PB-2220
Each group header (Triage Report, Backlog Report) only appears if that group has at least one non-empty section — a quiet week for one group doesn't print an empty header.
Tech Team tag: When a ticket has a Technical Team assigned (custom field
customfield_10199), the abbreviation before the colon (e.g.MIRfromMIR: Member Intake & Recommendations) is shown alongside the ticket key — in Backlog Report sections it's shown before the key (e.g.MIR PB-2210).
When everything is triaged:
:white_check_mark: All tickets are in good shape — nothing to triage!
ZKBot/
├── .github/
│ └── workflows/
│ └── zkbot.yml # GitHub Actions cron schedule & job
├── main.py # Entry point — orchestrates the run
├── jira_client.py # Jira authentication, querying & serialization
├── slack_notifier.py # Message formatting & Slack webhook POST
├── find_field.py # Utility: discover custom Jira field IDs
├── requirements.txt # Python dependencies
├── .env.example # Environment variable reference
└── .gitignore # Keeps secrets out of version control
All secrets are stored as GitHub Actions Secrets — never committed to the repository.
| Secret | Description |
|---|---|
JIRA_URL |
Your Atlassian base URL, e.g. https://yourcompany.atlassian.net |
JIRA_EMAIL |
Email address associated with your Jira account |
JIRA_API_TOKEN |
API token from id.atlassian.com |
SLACK_WEBHOOK_URL |
Workflow Builder webhook URL |
The Jira filter IDs (18934, 18935, 18936, 20496) are not secrets — they are hardcoded constants in main.py.
# Clone the repo
git clone https://github.com/TimVallier-Pager/ZKBot.git
cd ZKBot
# Set up environment
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
# Copy and fill in credentials
cp .env.example .env
# edit .env with your values
# Dry run — prints the Slack message without posting it
DRY_RUN=1 .venv/bin/python main.py
# Live run
.venv/bin/python main.pyZKBot runs automatically via GitHub Actions cron:
- cron: "30 12 * * 1-5" # 7:30 AM CDT (UTC-5), Monday–Friday| Day | Time |
|---|---|
| Monday–Friday | 7:30 AM CDT |
| Saturday–Sunday | 💤 resting |
Daylight Saving Note: The cron runs at
12:30 UTCyear-round. During CST (UTC-6, November–March) this shifts to 6:30 AM local time. Adjust the cron to30 13 * * 1-5during those months if you want a strict 7:30 AM.
You can also trigger the bot manually at any time:
GitHub → Actions → ZKBot Triage Report → Run workflow
Python dependencies
| Package | Purpose |
|---|---|
jira |
Official Jira Python SDK — handles auth, pagination, and field deserialization |
requests |
HTTP client for posting to the Slack webhook |
python-dotenv |
Loads .env file for local development |
Infrastructure
| Service | Role |
|---|---|
| GitHub Actions | Hosts and schedules the bot — no server required |
| GitHub Secrets | Secure storage for API credentials |
| Jira Cloud REST API | Source of truth for ticket data |
| Slack Workflow Builder | Receives the webhook payload and routes it to the team channel |
Why not a Slack bot token instead of a webhook?
Slack offers two integration paths: incoming webhooks and full bot tokens. A bot token is more powerful — it can read channels, react to messages, and post anywhere — but it also requires creating a Slack App, managing OAuth scopes, and maintaining a token with broad permissions.
For ZKBot's use case (one-way, one-channel notifications), a Workflow Builder webhook is the perfect fit. It's scoped to exactly one action in exactly one place, with zero ongoing maintenance.
Why does markdown not render in the Slack message?
Slack's mrkdwn formatting (*bold*, <url|label>) only renders in messages sent via the Chat API or Block Kit. When a message arrives through a Workflow Builder text variable, Slack treats it as plain text and doesn't apply mrkdwn processing — with one exception: bare URLs are automatically hyperlinked.
This is actually fine for ZKBot. The plain text format is clean, scannable, and renders consistently across all Slack clients.
How does Jira pagination work here?
The Jira Cloud REST API caps results at 50 issues per request by default. ZKBot handles this with a simple while loop:
while True:
batch = client.search_issues(jql, startAt=start, maxResults=50)
# process batch...
start += len(batch)
if start >= batch.total:
breakThis ensures ZKBot never misses a ticket, even on boards with hundreds of open issues.
Why GitHub Actions instead of a local cron job?
A local cron job requires the machine to be on and awake at the scheduled time. GitHub Actions runs in the cloud, is free for public repos and generously free for private ones, produces a full audit log of every run, and can be triggered manually from anywhere. It's a strictly better fit for a lightweight scheduled task like this.
This project was designed and built with Claude Code (Anthropic's AI coding assistant) in a single session — from blank directory to deployed production bot. The original implementation, including Jira integration, Slack formatting iteration, GitHub Actions setup, secret configuration, and this README, was produced collaboratively without writing a single line of code by hand.
Built with care for the Zookeeper team