From 695f170466d68d1b16f4fbb7a460b43450c3d0d3 Mon Sep 17 00:00:00 2001 From: jpelletier1 <44589723+jpelletier1@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:13:56 -0400 Subject: [PATCH] Initial commit This adds a skill for creating an automation that monitors a Slack channel for messages that contains a '#bug' hashtag and then automatically creates a bug ticket in Jira Cloud. --- skills/auto-triage-bugs-from-slack/SKILL.md | 177 ++++++++++ .../references/setup-guide.md | 235 ++++++++++++++ .../scripts/main.py | 307 ++++++++++++++++++ 3 files changed, 719 insertions(+) create mode 100644 skills/auto-triage-bugs-from-slack/SKILL.md create mode 100644 skills/auto-triage-bugs-from-slack/references/setup-guide.md create mode 100644 skills/auto-triage-bugs-from-slack/scripts/main.py diff --git a/skills/auto-triage-bugs-from-slack/SKILL.md b/skills/auto-triage-bugs-from-slack/SKILL.md new file mode 100644 index 00000000..8b04ec41 --- /dev/null +++ b/skills/auto-triage-bugs-from-slack/SKILL.md @@ -0,0 +1,177 @@ +--- +name: auto-triage-bugs-from-slack +description: This skill should be used when the user asks to "monitor a Slack channel and create Jira issues", "automatically file Jira tickets from Slack messages", "create a Jira bug from Slack", "watch a channel for bugs and open tickets", "set up a Slack to Jira automation", or "turn Slack messages into Jira issues". Deploys a cron-based polling automation that watches a Slack channel for messages containing a trigger phrase, creates a Jira issue for each match, posts the Jira ticket link as a thread reply, and adds a ✅ reaction to the original message. +--- + +# Slack → Jira Monitor Automation + +Deploys a cron-based automation that polls a Slack channel on a schedule. For every new parent message containing a configurable trigger phrase, it: + +1. Creates a Jira issue (Task or other type) in the specified project +2. Replies to the Slack thread with a link to the new Jira ticket +3. Adds a ✅ (`white_check_mark`) reaction to the original message + +State is tracked in the automation KV store so messages are never double-processed. + +--- + +## Required Secrets + +Two secrets must be registered in the agent server before deploying: + +| Secret name | What it is | +|---|---| +| `SLACK_BOT_TOKEN` | Slack bot OAuth token (`xoxb-…`). Bot must have scopes: `channels:history`, `channels:join`, `chat:write`, `reactions:write`. | +| `JIRA_CLOUD_KEY` | Atlassian API token (generate at https://id.atlassian.com/manage-api-tokens). Used with Basic auth as `email:token`. | + +Verify both secrets are accessible before proceeding: + +```bash +SESSION_KEY=$(cat ~/.openhands/agent-canvas/api-key.txt) +curl -s http://localhost:18000/api/settings/secrets/SLACK_BOT_TOKEN -H "X-Session-API-Key: $SESSION_KEY" +curl -s http://localhost:18000/api/settings/secrets/JIRA_CLOUD_KEY -H "X-Session-API-Key: $SESSION_KEY" +``` + +--- + +## Information to Gather from the User + +Before generating the automation, collect these five values: + +| Parameter | Example | Notes | +|---|---|---| +| Slack channel name or ID | `#bugs` / `C0BFL769U6Q` | Name is resolved to ID automatically | +| Trigger phrase | `#bug` | Case-insensitive substring match | +| Jira site URL | `https://acme.atlassian.net` | No trailing slash | +| Jira account email | `alice@example.com` | Must match the API token owner | +| Jira project key | `KAN` | The short key shown in project settings | + +The cron schedule defaults to `*/5 * * * *` (every 5 minutes). Ask the user if they want a different interval. + +--- + +## Setup Workflow + +### 1 — Resolve the Slack channel ID + +If the user provides a channel name, convert it to an ID: + +```bash +curl -s "https://slack.com/api/conversations.list?types=public_channel&limit=200" \ + -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ + | python3 -c " +import json,sys +for c in json.load(sys.stdin).get('channels',[]): + if c['name'] == 'bugs': # replace with target channel name + print(c['id'], c['name']) +" +``` + +### 2 — Discover valid Jira issue types + +Not all projects have a "Bug" type. Fetch the list and choose the most appropriate: + +```bash +AUTH=$(python3 -c "import base64; print('Basic '+base64.b64encode(b'EMAIL:TOKEN').decode())") +curl -s "https://SITE/rest/api/3/project/PROJECT_KEY" \ + -H "Authorization: $AUTH" -H "Accept: application/json" \ + | python3 -c "import json,sys; print([t['name'] for t in json.load(sys.stdin).get('issueTypes',[])])" +``` + +Use `"Bug"` if available; otherwise use `"Task"` or the closest available type. + +### 3 — Customize and package `scripts/main.py` + +Copy `scripts/main.py` and fill in the `── CONFIGURATION ──` block at the top: + +```python +SLACK_CHANNEL_ID = "C0BFL769U6Q" # resolved in step 1 +TRIGGER_PHRASE = "#bug" +KV_KEY = "slack_jira_monitor_bugs" # unique key per channel +JIRA_BASE_URL = "https://acme.atlassian.net" +JIRA_EMAIL = "alice@example.com" +JIRA_PROJECT = "KAN" +JIRA_ISSUE_TYPE = "Task" # from step 2 +CRON_SCHEDULE = "*/5 * * * *" +``` + +### 4 — Build and upload the tarball + +```bash +mkdir -p /tmp/skill-deploy +cp scripts/main.py /tmp/skill-deploy/ +cd /tmp && tar -czf automation.tar.gz -C skill-deploy main.py + +UPLOAD=$(curl -s -X POST \ + "http://localhost:18001/api/automation/v1/uploads?name=slack-jira-monitor" \ + -H "Authorization: Bearer $OPENHANDS_AUTOMATION_API_KEY" \ + -H "Content-Type: application/gzip" \ + --data-binary @automation.tar.gz) + +TARBALL_PATH=$(echo $UPLOAD | python3 -c "import json,sys; print(json.load(sys.stdin)['tarball_path'])") +echo "Uploaded: $TARBALL_PATH" +``` + +### 5 — Create the automation + +```bash +curl -s -X POST "http://localhost:18001/api/automation/v1" \ + -H "Authorization: Bearer $OPENHANDS_AUTOMATION_API_KEY" \ + -H "Content-Type: application/json" \ + -d "{ + \"name\": \"Slack → Jira: #bugs\", + \"trigger\": {\"type\": \"cron\", \"schedule\": \"*/5 * * * *\", \"timezone\": \"UTC\"}, + \"tarball_path\": \"$TARBALL_PATH\", + \"entrypoint\": \"python3 main.py\" + }" | python3 -m json.tool +``` + +### 6 — Verify with a test dispatch + +```bash +AUTOMATION_ID="" + +curl -s -X POST "http://localhost:18001/api/automation/v1/$AUTOMATION_ID/dispatch" \ + -H "Authorization: Bearer $OPENHANDS_AUTOMATION_API_KEY" + +# Check result after ~15 seconds +sleep 15 && curl -s "http://localhost:18001/api/automation/v1/$AUTOMATION_ID/runs?limit=1" \ + -H "Authorization: Bearer $OPENHANDS_AUTOMATION_API_KEY" \ + | python3 -c "import json,sys; r=json.load(sys.stdin)['runs'][0]; print(r['status'], r.get('error_detail') or '')" +``` + +--- + +## How the Automation Works + +- **Polling**: Fetches messages from the channel using `conversations.history` with `oldest=`. Only parent messages (not thread replies) that are not system events are considered. +- **State**: The high-water timestamp of the last processed message is stored in the KV store under `KV_KEY`. On the first run, it defaults to `time.time() - 300` (5 minutes ago). +- **Jira auth**: Basic authentication with `base64(email:api_token)` against the Jira REST API v3 endpoint. +- **Issue format**: Summary is extracted from the message (trigger phrase stripped, first sentence taken, max 120 chars). Description includes reporter user ID, date, and the full original message in ADF format. +- **Slack reply**: Posted as a threaded message with a clickable Jira link in Slack mrkdwn format: `**`. +- **Reaction**: `white_check_mark` is added to the parent message. Duplicate reactions (`already_reacted`) are silently ignored. +- **Idempotency**: The `conversations.join` call runs on every execution so the bot self-heals if removed from the channel. + +--- + +## Updating a Deployed Automation + +To update the script after deployment: + +```bash +# 1. Upload the new tarball (same as step 4) +NEW_TARBALL="oh-internal://uploads/" + +# 2. Patch the automation +curl -s -X PATCH "http://localhost:18001/api/automation/v1/$AUTOMATION_ID" \ + -H "Authorization: Bearer $OPENHANDS_AUTOMATION_API_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"tarball_path\": \"$NEW_TARBALL\"}" +``` + +--- + +## Additional Resources + +- **`scripts/main.py`** — Fully parameterized automation script ready to customize and deploy +- **`references/setup-guide.md`** — Detailed reference: secret scopes, Jira token setup, KV store behavior, troubleshooting diff --git a/skills/auto-triage-bugs-from-slack/references/setup-guide.md b/skills/auto-triage-bugs-from-slack/references/setup-guide.md new file mode 100644 index 00000000..05e36eb7 --- /dev/null +++ b/skills/auto-triage-bugs-from-slack/references/setup-guide.md @@ -0,0 +1,235 @@ +# Slack → Jira Monitor: Setup Reference + +Detailed reference for configuring and deploying the automation. Consult this +alongside SKILL.md when something isn't obvious from the overview. + +--- + +## Secrets Setup + +### SLACK_BOT_TOKEN + +Required Slack bot OAuth scopes: + +| Scope | Used for | +|---|---| +| `channels:history` | Read message history from public channels | +| `channels:join` | Bot auto-joins the monitored channel on each run | +| `channels:read` | List channels to resolve name → ID | +| `chat:write` | Post thread replies | +| `reactions:write` | Add ✅ reaction to messages | +| `groups:history` | Read message history from private channels (if needed) | + +To create the bot: +1. Go to https://api.slack.com/apps → **Create New App** → **From scratch** +2. Under **OAuth & Permissions** → **Bot Token Scopes**, add the scopes above +3. **Install to Workspace** and copy the `xoxb-…` token +4. Register it as a secret: `SLACK_BOT_TOKEN` + +### JIRA_CLOUD_KEY + +This is an Atlassian API token, **not** the account password. + +To generate: +1. Visit https://id.atlassian.com/manage-api-tokens +2. Click **Create API token**, give it a label (e.g. `openhands-automation`) +3. Copy the token value immediately (it is shown only once) +4. Register it as a secret: `JIRA_CLOUD_KEY` + +Authentication in the script uses HTTP Basic auth: +``` +Authorization: Basic base64(your-email@example.com:ATATT3x…) +``` + +--- + +## Finding a Slack Channel ID + +Channel IDs look like `C0BFL769U6Q` and are stable even if the channel is renamed. + +**Option A — API call (recommended for automation)** +```bash +curl -s "https://slack.com/api/conversations.list?types=public_channel&limit=200" \ + -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ + | python3 -c " +import json, sys +for c in json.load(sys.stdin).get('channels', []): + print(c['id'], c['name']) +" | grep bugs # replace 'bugs' with target channel name +``` + +**Option B — Slack UI** +Open the channel in Slack → right-click the channel name → **Copy link**. The URL ends with the channel ID: `https://app.slack.com/client/T.../C0BFL769U6Q`. + +**Option C — Private channels** +Change `types=public_channel` to `types=private_channel` and ensure the bot token has the `groups:history` scope. + +--- + +## Discovering Jira Issue Types + +Every Jira project has its own issue type configuration. Fetch the list before setting `JIRA_ISSUE_TYPE`: + +```bash +export JIRA_SITE="https://acme.atlassian.net" +export JIRA_EMAIL="you@example.com" +export JIRA_TOKEN="" +export JIRA_PROJECT="KAN" + +AUTH=$(python3 -c " +import base64, os +creds = f\"{os.environ['JIRA_EMAIL']}:{os.environ['JIRA_TOKEN']}\" +print('Basic ' + base64.b64encode(creds.encode()).decode()) +") + +curl -s "$JIRA_SITE/rest/api/3/project/$JIRA_PROJECT" \ + -H "Authorization: $AUTH" \ + -H "Accept: application/json" \ + | python3 -c " +import json, sys +d = json.load(sys.stdin) +print('Project:', d.get('name')) +print('Issue types:', [t['name'] for t in d.get('issueTypes', [])]) +" +``` + +Common types by project style: + +| Project style | Typical types | +|---|---| +| Scrum | Epic, Story, Task, Bug, Subtask | +| Kanban | Epic, Feature, Task, Subtask | +| Bug tracker | Bug, Improvement, Task | +| Custom | Varies — always check | + +--- + +## KV Store Behavior + +The automation uses the automation service's built-in KV store to persist the last-processed Slack message timestamp across runs. + +**Key name**: Set via `KV_KEY` in `main.py`. Use a unique name per deployment (e.g. `slack_jira_monitor_bugs`, `slack_jira_monitor_security`) to avoid collisions if multiple automations monitor different channels. + +**First-run default**: When no KV entry exists, `last_ts` defaults to `time.time() - 300` (5 minutes ago). This avoids flooding Jira with historical messages on the first run while still catching messages that arrived just before deployment. To process older messages on first run, change `- 300` to a larger value (e.g. `- 86400` for the past 24 hours) or set `"0"` to process all history. + +**Token lifetime**: `AUTOMATION_KV_TOKEN` is a short-lived JWT scoped to a single run. It is injected automatically by the automation service and should never be hardcoded or reused across runs. + +**Resetting state**: To force reprocessing of a time window, update the KV value directly using the management API key: + +```bash +curl -s -X PUT "http://localhost:18001/api/automation/v1/kv/slack_jira_monitor_bugs" \ + -H "Authorization: Bearer $OPENHANDS_AUTOMATION_API_KEY" \ + -H "Content-Type: application/json" \ + -d '"1783360000"' # Unix timestamp to reset to +``` + +--- + +## Automation Management API + +### Check automation status +```bash +curl -s "http://localhost:18001/api/automation/v1/$AUTOMATION_ID" \ + -H "Authorization: Bearer $OPENHANDS_AUTOMATION_API_KEY" | python3 -m json.tool +``` + +### List recent runs +```bash +curl -s "http://localhost:18001/api/automation/v1/$AUTOMATION_ID/runs?limit=10" \ + -H "Authorization: Bearer $OPENHANDS_AUTOMATION_API_KEY" \ + | python3 -c " +import json, sys +for r in json.load(sys.stdin).get('runs', []): + print(r['id'][:8], r['status'], r.get('error_detail') or '') +" +``` + +### Pause / resume +```bash +# Pause +curl -s -X PATCH "http://localhost:18001/api/automation/v1/$AUTOMATION_ID" \ + -H "Authorization: Bearer $OPENHANDS_AUTOMATION_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"enabled": false}' + +# Resume +curl -s -X PATCH "http://localhost:18001/api/automation/v1/$AUTOMATION_ID" \ + -H "Authorization: Bearer $OPENHANDS_AUTOMATION_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"enabled": true}' +``` + +### Delete +```bash +curl -s -X DELETE "http://localhost:18001/api/automation/v1/$AUTOMATION_ID" \ + -H "Authorization: Bearer $OPENHANDS_AUTOMATION_API_KEY" +``` + +--- + +## Cron Schedule Reference + +| Schedule | Meaning | +|---|---| +| `*/5 * * * *` | Every 5 minutes (default) | +| `*/10 * * * *` | Every 10 minutes | +| `0 * * * *` | Every hour on the hour | +| `0 9-17 * * 1-5` | Every hour, 9am–5pm, weekdays only | +| `0 9 * * 1-5` | Once at 9am on weekdays | + +All times are UTC unless `timezone` is set in the trigger config. + +--- + +## Troubleshooting + +### `not_in_channel` from Slack API +The bot has not joined the channel. The script calls `conversations.join` automatically on each run, but this requires the `channels:join` scope. Verify the scope is granted and re-install the app if you added it after the initial install. + +### `Specify a valid issue type` from Jira +`JIRA_ISSUE_TYPE` does not exist in the project. Run the issue-type discovery command above and update `main.py`. + +### `401 Unauthorized` from Jira +The `JIRA_CLOUD_KEY` secret or `JIRA_EMAIL` is incorrect. Verify the email exactly matches the Atlassian account that owns the API token. + +### Run shows `COMPLETED` instantly with no messages processed +The KV `last_ts` is set to a time after all existing messages. The automation is working correctly and will pick up the next new message. To test immediately, post a message with the trigger phrase and dispatch a manual run. + +### Run shows `FAILED` with a Python traceback +Retrieve the bash command output for the failing run: +```bash +SESSION_KEY=$(cat ~/.openhands/agent-canvas/api-key.txt) +RUN_BASH_ID=$(curl -s "http://localhost:18001/api/automation/v1/$AUTOMATION_ID/runs?limit=1" \ + -H "Authorization: Bearer $OPENHANDS_AUTOMATION_API_KEY" \ + | python3 -c "import json,sys; print(json.load(sys.stdin)['runs'][0]['bash_command_id'])") + +curl -s "http://localhost:18000/api/bash/bash_events/$RUN_BASH_ID" \ + -H "X-Session-API-Key: $SESSION_KEY" \ + | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('command','')[-300:])" +``` + +--- + +## Extending the Automation + +### Monitor multiple channels +Deploy separate automations with different `SLACK_CHANNEL_ID`, `KV_KEY`, and automation names. Each automation runs independently. + +### Different trigger phrases +Change `TRIGGER_PHRASE`. Examples: `"!ticket"`, `"#feature"`, `"ALERT:"`, `"[bug]"`. The match is case-insensitive substring. + +### Add Jira labels or priority +Extend the `create_jira_issue` payload's `"fields"` dict: +```python +"fields": { + ... + "labels": ["slack-import", "needs-triage"], + "priority": {"name": "High"}, +} +``` + +### Notify a different Slack channel +Replace the `chat.postMessage` call's `"channel"` with a different channel ID to route replies elsewhere (e.g. a `#jira-tickets` digest channel). + +### Custom reply format +Edit the `text` field in the `chat.postMessage` call. Slack mrkdwn reference: `*bold*`, `_italic_`, ``, `\n` for newlines. diff --git a/skills/auto-triage-bugs-from-slack/scripts/main.py b/skills/auto-triage-bugs-from-slack/scripts/main.py new file mode 100644 index 00000000..d345cfdd --- /dev/null +++ b/skills/auto-triage-bugs-from-slack/scripts/main.py @@ -0,0 +1,307 @@ +""" +Slack → Jira Monitor Automation +================================ +Polls a Slack channel every N minutes. For each new parent message that +contains TRIGGER_PHRASE, creates a Jira issue, replies to the thread with +the Jira ticket link, and adds a ✅ reaction to the original message. + +Customize the CONFIGURATION block below, package as a tarball, and deploy +via the OpenHands automation API. See SKILL.md for the full setup workflow. +""" + +import base64 +import json +import os +import time +import urllib.error +import urllib.request +from urllib.parse import urlparse + +# ── CONFIGURATION ──────────────────────────────────────────────────────────── +# All values here must be set before deploying. + +SLACK_CHANNEL_ID = "C0BFL769U6Q" # Slack channel ID to monitor +TRIGGER_PHRASE = "#bug" # Case-insensitive trigger (e.g. "#bug", "!ticket") +KV_KEY = "slack_jira_monitor_bugs" # Unique KV key — change if running multiple instances + +JIRA_BASE_URL = "https://acme.atlassian.net" # Your Jira Cloud URL (no trailing slash) +JIRA_EMAIL = "you@example.com" # Email tied to the JIRA_CLOUD_KEY secret +JIRA_PROJECT = "KAN" # Jira project key +JIRA_ISSUE_TYPE = "Task" # Issue type (Bug, Task, Story — must exist in project) + +# ── END CONFIGURATION ───────────────────────────────────────────────────────── + + +# --------------------------------------------------------------------------- +# Automation boilerplate helpers (do not modify) +# --------------------------------------------------------------------------- + +def get_secret(name): + """Fetch a named secret from the agent server.""" + url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/") + key = os.environ.get("SESSION_API_KEY") or os.environ.get("OH_SESSION_API_KEYS_0", "") + req = urllib.request.Request( + f"{url}/api/settings/secrets/{name}", + headers={"X-Session-API-Key": key}, + ) + with urllib.request.urlopen(req) as r: + return r.read().decode().strip() + + +def fire_callback(status="COMPLETED", error=None): + """Signal run completion to the automation service.""" + url = os.environ.get("AUTOMATION_CALLBACK_URL", "") + if not url: + return + body = {"status": status, "run_id": os.environ.get("AUTOMATION_RUN_ID", "")} + if error: + body["error"] = error + try: + urllib.request.urlopen( + urllib.request.Request( + url, + data=json.dumps(body).encode(), + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {os.environ.get('AUTOMATION_CALLBACK_API_KEY', '')}", + }, + ) + ) + except Exception as exc: + print(f"Callback error: {exc}") + + +# --------------------------------------------------------------------------- +# KV store helpers +# --------------------------------------------------------------------------- + +def _kv_base_url(): + callback = os.environ.get("AUTOMATION_CALLBACK_URL", "") + if callback: + parsed = urlparse(callback) + return f"{parsed.scheme}://{parsed.netloc}" + return "http://localhost:18001" + + +def kv_get(key, default=None): + token = os.environ.get("AUTOMATION_KV_TOKEN", "") + base = _kv_base_url() + try: + req = urllib.request.Request( + f"{base}/api/automation/v1/kv/{key}", + headers={"Authorization": f"Bearer {token}"}, + ) + with urllib.request.urlopen(req) as r: + return json.loads(r.read().decode()).get("value", default) + except urllib.error.HTTPError as exc: + if exc.code == 404: + return default + raise + except Exception as exc: + print(f"KV get error for '{key}': {exc}") + return default + + +def kv_set(key, value): + token = os.environ.get("AUTOMATION_KV_TOKEN", "") + base = _kv_base_url() + req = urllib.request.Request( + f"{base}/api/automation/v1/kv/{key}", + data=json.dumps(value).encode(), + method="PUT", + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {token}", + }, + ) + with urllib.request.urlopen(req) as r: + return json.loads(r.read().decode()) + + +# --------------------------------------------------------------------------- +# Slack helpers +# --------------------------------------------------------------------------- + +def slack_get(endpoint, token, params=""): + sep = "&" if "?" in endpoint else "?" + url = f"https://slack.com/api/{endpoint}{sep}{params}" if params else f"https://slack.com/api/{endpoint}" + req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}) + with urllib.request.urlopen(req) as r: + return json.loads(r.read().decode()) + + +def slack_post(method, token, payload): + req = urllib.request.Request( + f"https://slack.com/api/{method}", + data=json.dumps(payload).encode(), + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + ) + with urllib.request.urlopen(req) as r: + return json.loads(r.read().decode()) + + +# --------------------------------------------------------------------------- +# Jira helpers +# --------------------------------------------------------------------------- + +def _jira_auth_header(api_token): + credentials = f"{JIRA_EMAIL}:{api_token}" + return "Basic " + base64.b64encode(credentials.encode()).decode() + + +def _jira_adf_doc(text): + """Wrap plain text in Atlassian Document Format (ADF) for Jira REST API v3.""" + paragraphs = [p.strip() for p in text.split("\n") if p.strip()] + content = [ + {"type": "paragraph", "content": [{"type": "text", "text": para}]} + for para in paragraphs + ] or [{"type": "paragraph", "content": [{"type": "text", "text": text}]}] + return {"type": "doc", "version": 1, "content": content} + + +def create_jira_issue(summary, description_text, api_token): + """Create a Jira issue and return (issue_key, browse_url).""" + payload = { + "fields": { + "project": {"key": JIRA_PROJECT}, + "summary": summary, + "description": _jira_adf_doc(description_text), + "issuetype": {"name": JIRA_ISSUE_TYPE}, + } + } + req = urllib.request.Request( + f"{JIRA_BASE_URL}/rest/api/3/issue", + data=json.dumps(payload).encode(), + headers={ + "Authorization": _jira_auth_header(api_token), + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + try: + with urllib.request.urlopen(req) as r: + data = json.loads(r.read().decode()) + except urllib.error.HTTPError as exc: + raise RuntimeError(f"Jira API error {exc.code}: {exc.read().decode()}") from exc + key = data["key"] + return key, f"{JIRA_BASE_URL}/browse/{key}" + + +# --------------------------------------------------------------------------- +# Message helpers +# --------------------------------------------------------------------------- + +def extract_summary(message_text): + """Derive a short Jira summary from the Slack message.""" + text = message_text + for phrase in (TRIGGER_PHRASE, TRIGGER_PHRASE.upper(), TRIGGER_PHRASE.title()): + text = text.replace(phrase, "").strip() + for sep in (".", "!", "?", "\n"): + idx = text.find(sep) + if idx > 10: + text = text[:idx] + break + return text[:120].strip() or f"Issue reported via Slack {SLACK_CHANNEL_ID}" + + +def build_description(message_text, user_id, ts): + report_date = time.strftime("%Y-%m-%d", time.gmtime(float(ts))) + return ( + f"Reported by: {user_id}\n" + f"Date: {report_date}\n" + f"Source: Slack channel {SLACK_CHANNEL_ID}\n\n" + f"Original message:\n{message_text}" + ) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main(): + slack_token = get_secret("SLACK_BOT_TOKEN") + jira_token = get_secret("JIRA_CLOUD_KEY") + + # Ensure the bot is a member of the monitored channel (idempotent) + slack_post("conversations.join", slack_token, {"channel": SLACK_CHANNEL_ID}) + + # Load or initialise the high-water timestamp + last_ts = kv_get(KV_KEY) + if last_ts is None: + last_ts = str(time.time() - 300) # default: start 5 minutes ago on first run + print(f"No prior state — starting from {last_ts}") + + print(f"Polling {SLACK_CHANNEL_ID} for messages since {last_ts}") + resp = slack_get( + "conversations.history", + slack_token, + f"channel={SLACK_CHANNEL_ID}&oldest={last_ts}&limit=100&inclusive=false", + ) + if not resp.get("ok"): + raise RuntimeError(f"conversations.history error: {resp.get('error')}") + + messages = resp.get("messages", []) + print(f"Fetched {len(messages)} message(s)") + + # Keep only new parent messages (not replies, not system events) with the trigger + triggered = [ + m for m in messages + if TRIGGER_PHRASE.lower() in m.get("text", "").lower() + and m.get("type") == "message" + and not m.get("subtype") + and not (m.get("thread_ts") and m["thread_ts"] != m["ts"]) + ] + print(f"Found {len(triggered)} triggered message(s)") + + new_last_ts = last_ts + for msg in reversed(triggered): # oldest first + ts = msg["ts"] + text = msg.get("text", "") + user = msg.get("user", "unknown") + print(f" Processing {ts}: {text[:80]!r}") + + summary = extract_summary(text) + description = build_description(text, user, ts) + + issue_key, issue_url = create_jira_issue(summary, description, jira_token) + print(f" Created {issue_key}: {issue_url}") + + # Thread reply with Jira link + reply = slack_post("chat.postMessage", slack_token, { + "channel": SLACK_CHANNEL_ID, + "thread_ts": ts, + "text": f"🐛 Jira issue filed: *<{issue_url}|{issue_key}>* — {summary}", + }) + if not reply.get("ok"): + print(f" Warning — thread reply failed: {reply.get('error')}") + + # ✅ reaction on the parent message + reaction = slack_post("reactions.add", slack_token, { + "channel": SLACK_CHANNEL_ID, + "timestamp": ts, + "name": "white_check_mark", + }) + if not reaction.get("ok") and reaction.get("error") != "already_reacted": + print(f" Warning — reaction failed: {reaction.get('error')}") + + if float(ts) > float(new_last_ts): + new_last_ts = ts + + if new_last_ts != last_ts: + kv_set(KV_KEY, new_last_ts) + print(f"State saved — last_ts={new_last_ts}") + + print("Done.") + fire_callback("COMPLETED") + + +if __name__ == "__main__": + try: + main() + except Exception as exc: + print(f"Fatal: {exc}") + fire_callback("FAILED", str(exc)) + raise