Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/notify_failure.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ on:
- "Check for Crowdin Updates"
- "Test Failure Notification"
- "Crowdin Multiple Translations Report"
- "Zendesk Ticket Triage"
types:
- completed

Expand Down
123 changes: 123 additions & 0 deletions .github/workflows/zendesk_triage.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
name: Zendesk Ticket Triage

# Runs daily over a 48h window (not 24h, so a failed run doesn't silently drop a
# day of tickets). The overlap does not produce duplicate Discord posts: a dedup
# state file records each reported ticket's Zendesk updated_at, so an unchanged
# ticket is skipped entirely on the next run, and a changed one is re-reported
# and flagged with 🔄.
#
# State lives in the Actions cache, which is best-effort — see the state note in
# the README. If it is ever missing the run degrades to re-reporting the window
# once, which is noisy but never wrong.
on:
schedule:
- cron: "0 7 * * *"
workflow_dispatch:
inputs:
query:
description: "Zendesk search query (overrides the rolling window entirely)"
required: false
window_hours:
description: "Analyze tickets created in the last N hours (default 48)"
required: false
max_tickets:
description: "Max tickets to analyze (default 1000, Zendesk's search cap)"
required: false
reset_state:
description: "Ignore saved state and re-report everything in the window"
type: boolean
default: false

# Two overlapping runs would race on the same state file, and the loser's
# reported tickets would be forgotten. Queue instead of cancelling, so a
# manual run never discards a scheduled run's state write.
concurrency:
group: zendesk-triage
cancel-in-progress: false

# The job only reads the repo; everything it writes goes to Zendesk/Discord over
# their own credentials. Nothing needs a writable GITHUB_TOKEN.
permissions:
contents: read

jobs:
triage:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7

- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: "3.12"

- name: Install dependencies
run: pip install -r zendesk_triage/requirements.txt

# Unique key so every run writes a fresh entry; the restore-keys prefix pulls
# in the most recent previous one. run_attempt is in the key because cache
# entries are immutable: a re-run reuses run_id, so without it attempt 2's save
# would collide with attempt 1's and silently write nothing. Attempt 2 then
# restores attempt 1's entry via the prefix, so tickets already delivered
# aren't reposted. Restore and save are split (rather than the combined
# actions/cache) so the save can run with if: always() — the script records
# partially-delivered tickets even when a later Discord POST fails, and the
# combined action would discard that on a failed job.
- name: Restore triage state
uses: actions/cache/restore@v4
with:
path: .triage-state
key: zendesk-triage-state-${{ github.run_id }}-${{ github.run_attempt }}
restore-keys: |
zendesk-triage-state-

# Inputs arrive via env, never interpolated straight into a shell command,
# and both outputs are stripped to digits so the run step below is safe.
- name: Resolve window and cap
id: cfg
env:
INPUT_WINDOW: ${{ github.event.inputs.window_hours }}
INPUT_MAX: ${{ github.event.inputs.max_tickets }}
RESET_STATE: ${{ github.event.inputs.reset_state }}
run: |
# The cap is a runaway guard, not a batch size: a 48h window is ~45
# tickets, and anything over --batch-size is split across requests
# rather than truncated. 1000 is Zendesk's own search result limit —
# asking for more just walks pagination into a 422, so don't.
window=$(printf '%s' "${INPUT_WINDOW:-48}" | tr -cd '0-9')
max=$(printf '%s' "${INPUT_MAX:-1000}" | tr -cd '0-9')
: "${window:=48}"
: "${max:=1000}"
echo "window=$window" >> "$GITHUB_OUTPUT"
echo "max=$max" >> "$GITHUB_OUTPUT"
if [ "$RESET_STATE" = "true" ]; then
rm -f .triage-state/seen.json
echo "State reset: every ticket in the window will be re-reported."
fi
echo "Window: ${window}h, max tickets: ${max}"

- name: Run triage
env:
ZENDESK_SUBDOMAIN: ${{ secrets.ZENDESK_SUBDOMAIN }}
ZENDESK_EMAIL: ${{ secrets.ZENDESK_EMAIL }}
ZENDESK_API_TOKEN: ${{ secrets.ZENDESK_API_TOKEN }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
ZENDESK_QUERY: ${{ github.event.inputs.query }}
ZENDESK_TRIAGE_MODEL: ${{ vars.ZENDESK_TRIAGE_MODEL }}
run: |
mkdir -p .triage-state
python zendesk_triage/triage.py \
--window-hours "${{ steps.cfg.outputs.window }}" \
--max-tickets "${{ steps.cfg.outputs.max }}" \
--state .triage-state/seen.json

# always(): the script writes state for tickets Discord accepted even when a
# later message fails, and that must survive the job's non-zero exit.
- name: Save triage state
if: always()
uses: actions/cache/save@v4
with:
path: .triage-state
key: zendesk-triage-state-${{ github.run_id }}-${{ github.run_attempt }}
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,11 @@ do_not_commit.sh

# Local Crowdin audit outputs (persist across sessions, not for commit)
.crowdin_audit/

# Local Zendesk triage debugging artifacts — these contain ticket content
zendesk_triage/*.json

# Zendesk triage dedup state (ticket ids + timestamps; restored from CI cache)
.triage-state/

.claude/
Loading