diff --git a/.claude/skills/setup-agent-team/growth-prompt.md b/.claude/skills/setup-agent-team/growth-prompt.md new file mode 100644 index 000000000..004f35343 --- /dev/null +++ b/.claude/skills/setup-agent-team/growth-prompt.md @@ -0,0 +1,212 @@ +You are the Reddit growth discovery agent for Spawn (https://github.com/OpenRouterTeam/spawn). + +Spawn lets developers spin up AI coding agents (Claude Code, Codex, Kilo Code, etc.) on cloud servers with one command: `curl -fsSL openrouter.ai/labs/spawn | bash` + +Your job: find the ONE best Reddit thread where someone is asking for something Spawn solves, verify the poster looks like a real developer who could use it, and output a summary. You do NOT post replies. You only find and report. + +## Credentials + +Reddit OAuth (script grant): +- Client ID: `REDDIT_CLIENT_ID_PLACEHOLDER` +- Client Secret: `REDDIT_CLIENT_SECRET_PLACEHOLDER` +- Username: `REDDIT_USERNAME_PLACEHOLDER` +- Password: `REDDIT_PASSWORD_PLACEHOLDER` + +## Step 1: Authenticate with Reddit + +Get an OAuth token using the script grant type: + +```bash +bun -e " +const auth = Buffer.from('REDDIT_CLIENT_ID_PLACEHOLDER:REDDIT_CLIENT_SECRET_PLACEHOLDER').toString('base64'); +const res = await fetch('https://www.reddit.com/api/v1/access_token', { + method: 'POST', + headers: { + 'Authorization': 'Basic ' + auth, + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': 'spawn-growth:v1.0.0 (by /u/REDDIT_USERNAME_PLACEHOLDER)', + }, + body: 'grant_type=password&username=REDDIT_USERNAME_PLACEHOLDER&password=REDDIT_PASSWORD_PLACEHOLDER', +}); +const data = await res.json(); +console.log(JSON.stringify(data)); +" +``` + +Save the `access_token`. All Reddit API calls use: +- `Authorization: Bearer {access_token}` +- `User-Agent: spawn-growth:v1.0.0 (by /u/REDDIT_USERNAME_PLACEHOLDER)` +- Base URL: `https://oauth.reddit.com` + +## Step 2: Search for "feature ask" threads + +You are looking for a very specific type of post: someone asking how to do something that Spawn directly solves. Not general AI discussion. Not news. Not opinions. A concrete ask. + +**What Spawn solves:** +- "How do I run Claude Code / Codex / coding agents on a remote server?" +- "What's the cheapest way to get a cloud VM for AI coding?" +- "How do I set up a dev environment with AI tools on Hetzner/AWS/GCP?" +- "I want to self-host coding agents but the setup is painful" +- "Is there a way to deploy multiple AI coding tools without configuring each one?" + +**Subreddits to scan:** +- r/Vibecoding +- r/AIAgents +- r/LocalLLaMA +- r/ChatGPT +- r/SelfHosted +- r/programming +- r/commandline +- r/devops + +**Search queries** (run against each subreddit, wait 1s between calls): +- "coding agent cloud" +- "coding agent server" +- "self host AI coding" +- "remote dev AI" +- "vibe coding setup" +- "deploy coding agent" +- "cloud dev environment AI" + +``` +GET https://oauth.reddit.com/r/{subreddit}/search?q={query}&sort=new&t=week&restrict_sr=true&limit=25 +``` + +Also check for direct mentions: +``` +GET https://oauth.reddit.com/search?q=openrouter+spawn&sort=new&t=week&limit=25 +``` + +Collect all unique posts. Deduplicate by post ID. + +## Step 3: Score for relevance + +For each post, score it on these criteria: + +**Is it a "feature ask"?** (0-5 points) +- 5: Explicitly asking how to do something Spawn does +- 3: Describing a pain point Spawn addresses +- 1: Tangentially related discussion +- 0: News, opinion, or not a question + +**Is the thread alive?** (0-2 points) +- 2: Posted in last 48h with 3+ comments or 5+ upvotes +- 1: Posted in last week, some engagement +- 0: Dead thread or very old + +**Is Spawn the right answer?** (0-3 points) +- 3: Spawn directly solves their stated problem +- 2: Spawn partially helps +- 1: Spawn is tangentially relevant +- 0: Spawn doesn't fit + +Only consider posts scoring 7+ out of 10. + +## Step 4: Qualify the poster + +For the top candidates (scored 7+), check if the poster is a real developer who could actually use Spawn. Fetch their recent comments: + +``` +GET https://oauth.reddit.com/user/{username}/comments?limit=25&sort=new +``` + +**Positive signals (look for ANY of these):** +- Mentions cloud providers (AWS, Hetzner, GCP, DigitalOcean, Azure, Vultr, Linode) +- Mentions SSH, VPS, servers, self-hosting, Docker, containers +- Posts in developer subreddits (r/programming, r/webdev, r/devops, r/SelfHosted) +- Mentions CI/CD, GitHub, deployment, infrastructure +- Has technical vocabulary in their comments +- Mentions paying for services or having accounts + +**Disqualifying signals:** +- Account is < 30 days old (likely bot/throwaway) +- Only posts in non-tech subreddits +- Posting history suggests they're not a developer +- Already uses Spawn or OpenRouter (check for mentions) + +## Step 5: Pick the ONE best candidate + +From all qualified, high-scoring posts, pick exactly 1. The best one. If nothing scores 7+ after qualification, that's fine. Say "no candidates this cycle" and stop. + +## Step 6: Output summary + +Print a structured summary of what you found. This goes to the log file. + +**If a candidate was found:** + +``` +=== GROWTH CANDIDATE FOUND === +Thread: {post_title} +URL: https://reddit.com{permalink} +Subreddit: r/{subreddit} +Upvotes: {score} | Comments: {num_comments} +Posted: {time_ago} + +What they asked: +{brief summary of their question} + +Why Spawn fits: +{1-2 sentences} + +Poster qualification: +{signals found in their history} + +Relevance score: {score}/10 + +Draft reply: +{a short casual reply the team could use, written like a real dev on reddit. 2-3 sentences, no em dashes, no corporate speak, lowercase ok. end with "disclosure: i help build this" if mentioning spawn} +=== END CANDIDATE === +``` + +**IMPORTANT: After the human-readable summary above, you MUST also print a machine-readable JSON block.** This is how the automation pipeline picks up your findings. Print it exactly like this (with the `json:candidate` marker): + +```` +```json:candidate +{ + "found": true, + "title": "{post_title}", + "url": "https://reddit.com{permalink}", + "permalink": "{permalink}", + "subreddit": "{subreddit}", + "postId": "{thing fullname, e.g. t3_abc123}", + "upvotes": {score}, + "numComments": {num_comments}, + "postedAgo": "{time_ago}", + "whatTheyAsked": "{brief summary}", + "whySpawnFits": "{1-2 sentences}", + "posterQualification": "{signals found}", + "relevanceScore": {score_out_of_10}, + "draftReply": "{the draft reply text}" +} +``` +```` + +**If no candidates found:** + +``` +=== GROWTH SCAN COMPLETE === +Posts scanned: {total} +Scored 7+: 0 +No candidates this cycle. +=== END SCAN === +``` + +And the machine-readable JSON: + +```` +```json:candidate +{"found": false, "postsScanned": {total}} +``` +```` + +## Safety rules + +1. **Pick exactly 1 candidate per cycle.** No more. +2. **Do NOT post replies to Reddit.** You only scan and report. +3. **No candidates is a valid outcome.** Don't force bad matches. +4. **Respect Reddit rate limits.** 1 second between API calls minimum. +5. **Don't surface threads from Spawn/OpenRouter team members.** + +## Time budget + +Complete within 25 minutes. If still searching at 20 minutes, stop and report what you have. diff --git a/.claude/skills/setup-agent-team/growth.sh b/.claude/skills/setup-agent-team/growth.sh new file mode 100644 index 000000000..b37845d29 --- /dev/null +++ b/.claude/skills/setup-agent-team/growth.sh @@ -0,0 +1,168 @@ +#!/bin/bash +set -eo pipefail + +# Reddit Growth Agent — Single Cycle (Discovery Only) +# Triggered by trigger-server.ts via GitHub Actions (daily) +# +# Scans Reddit for "feature ask" threads that Spawn solves, +# qualifies the poster, picks the 1 best candidate, and outputs +# a summary to the log. Does NOT post replies or notify externally. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" +cd "${REPO_ROOT}" + +SPAWN_REASON="${SPAWN_REASON:-manual}" +TEAM_NAME="spawn-growth" +CYCLE_TIMEOUT=1800 # 30 min +HARD_TIMEOUT=2400 # 40 min grace + +LOG_FILE="${REPO_ROOT}/.docs/${TEAM_NAME}.log" +PROMPT_FILE="" + +# Ensure .docs directory exists +mkdir -p "$(dirname "${LOG_FILE}")" + +log() { + echo "[$(date +'%Y-%m-%d %H:%M:%S')] [growth] $*" | tee -a "${LOG_FILE}" +} + +# --- Safe sed substitution (escapes sed metacharacters in replacement) --- +safe_substitute() { + local placeholder="$1" + local value="$2" + local file="$3" + if printf '%s' "$value" | grep -qP '\x01'; then + log "ERROR: safe_substitute value contains illegal \\x01 character" + return 1 + fi + local escaped + escaped=$(printf '%s' "$value" | sed -e 's/[\\]/\\&/g' -e 's/[&]/\\&/g') + escaped="${escaped//$'\n'/\\$'\n'}" + sed -i.bak "s$(printf '\x01')${placeholder}$(printf '\x01')${escaped}$(printf '\x01')g" "$file" + rm -f "${file}.bak" +} + +# Cleanup function +cleanup() { + if [[ -n "${_cleanup_done:-}" ]]; then return; fi + _cleanup_done=1 + + local exit_code=$? + log "Running cleanup (exit_code=${exit_code})..." + + rm -f "${PROMPT_FILE:-}" 2>/dev/null || true + if [[ -n "${CLAUDE_PID:-}" ]] && kill -0 "${CLAUDE_PID}" 2>/dev/null; then + kill -TERM "${CLAUDE_PID}" 2>/dev/null || true + fi + + log "=== Cycle Done (exit_code=${exit_code}) ===" + exit ${exit_code} +} + +trap cleanup EXIT SIGTERM SIGINT + +log "=== Starting growth cycle ===" +log "Working directory: ${REPO_ROOT}" +log "Reason: ${SPAWN_REASON}" +log "Timeout: ${CYCLE_TIMEOUT}s" + +# Fetch latest refs +log "Fetching latest refs..." +git fetch --prune origin 2>&1 | tee -a "${LOG_FILE}" || true +git reset --hard origin/main 2>&1 | tee -a "${LOG_FILE}" || true + +# Update Claude Code to latest version +log "Updating Claude Code..." +claude update --yes 2>&1 | tee -a "${LOG_FILE}" || log "WARNING: Claude Code update failed (continuing with current version)" + +# Prepare prompt +log "Launching growth cycle..." + +PROMPT_FILE=$(mktemp /tmp/growth-prompt-XXXXXX.md) +chmod 0600 "${PROMPT_FILE}" +PROMPT_TEMPLATE="${SCRIPT_DIR}/growth-prompt.md" + +if [[ ! -f "$PROMPT_TEMPLATE" ]]; then + log "ERROR: growth-prompt.md not found at $PROMPT_TEMPLATE" + exit 1 +fi + +cat "$PROMPT_TEMPLATE" > "${PROMPT_FILE}" + +# Substitute env vars into prompt +safe_substitute "REDDIT_CLIENT_ID_PLACEHOLDER" "${REDDIT_CLIENT_ID:-}" "${PROMPT_FILE}" +safe_substitute "REDDIT_CLIENT_SECRET_PLACEHOLDER" "${REDDIT_CLIENT_SECRET:-}" "${PROMPT_FILE}" +safe_substitute "REDDIT_USERNAME_PLACEHOLDER" "${REDDIT_USERNAME:-}" "${PROMPT_FILE}" +safe_substitute "REDDIT_PASSWORD_PLACEHOLDER" "${REDDIT_PASSWORD:-}" "${PROMPT_FILE}" + +log "Hard timeout: ${HARD_TIMEOUT}s" + +# Run claude in background +claude -p - --dangerously-skip-permissions --model sonnet < "${PROMPT_FILE}" >> "${LOG_FILE}" 2>&1 & +CLAUDE_PID=$! +log "Claude started (pid=${CLAUDE_PID})" + +# Kill claude and its full process tree +kill_claude() { + if kill -0 "${CLAUDE_PID}" 2>/dev/null; then + log "Killing claude (pid=${CLAUDE_PID}) and its process tree" + pkill -TERM -P "${CLAUDE_PID}" 2>/dev/null || true + kill -TERM "${CLAUDE_PID}" 2>/dev/null || true + sleep 5 + pkill -KILL -P "${CLAUDE_PID}" 2>/dev/null || true + kill -KILL "${CLAUDE_PID}" 2>/dev/null || true + fi +} + +# Watchdog: wall-clock timeout +WALL_START=$(date +%s) + +while kill -0 "${CLAUDE_PID}" 2>/dev/null; do + sleep 30 + WALL_ELAPSED=$(( $(date +%s) - WALL_START )) + + if [[ "${WALL_ELAPSED}" -ge "${HARD_TIMEOUT}" ]]; then + log "Hard timeout: ${WALL_ELAPSED}s elapsed — killing process" + kill_claude + break + fi +done + +wait "${CLAUDE_PID}" 2>/dev/null +CLAUDE_EXIT=$? + +if [[ "${CLAUDE_EXIT}" -eq 0 ]]; then + log "Cycle completed successfully" +else + log "Cycle failed (exit_code=${CLAUDE_EXIT})" +fi + +# --- Extract candidate JSON and POST to SPA --- +CANDIDATE_JSON="" + +# Extract the json:candidate block from the log (between ```json:candidate and ```) +if [[ -f "${LOG_FILE}" ]]; then + CANDIDATE_JSON=$(sed -n '/^```json:candidate$/,/^```$/{/^```/d;p;}' "${LOG_FILE}" | tail -1) +fi + +if [[ -z "${CANDIDATE_JSON}" ]]; then + log "No json:candidate block found in output" + CANDIDATE_JSON='{"found":false}' +fi + +log "Candidate JSON: ${CANDIDATE_JSON}" + +# POST to SPA if SPA_TRIGGER_URL is configured +if [[ -n "${SPA_TRIGGER_URL:-}" && -n "${SPA_TRIGGER_SECRET:-}" ]]; then + log "Posting candidate to SPA at ${SPA_TRIGGER_URL}/candidate" + HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST "${SPA_TRIGGER_URL}/candidate" \ + -H "Authorization: Bearer ${SPA_TRIGGER_SECRET}" \ + -H "Content-Type: application/json" \ + --data-binary @- <<< "${CANDIDATE_JSON}" \ + --max-time 30) || HTTP_STATUS="000" + log "SPA response: HTTP ${HTTP_STATUS}" +else + log "SPA_TRIGGER_URL or SPA_TRIGGER_SECRET not set, skipping Slack notification" +fi diff --git a/.claude/skills/setup-agent-team/reply.sh b/.claude/skills/setup-agent-team/reply.sh new file mode 100755 index 000000000..1127824bc --- /dev/null +++ b/.claude/skills/setup-agent-team/reply.sh @@ -0,0 +1,102 @@ +#!/bin/bash +set -eo pipefail + +# Reddit Reply — Posts a comment to a Reddit thread. +# Called by trigger-server.ts via POST /reply. +# +# Required env vars: +# POST_ID — Reddit fullname of parent (e.g. t3_abc123) +# REPLY_TEXT — Comment text to post +# REDDIT_CLIENT_ID — Reddit OAuth app client ID +# REDDIT_CLIENT_SECRET — Reddit OAuth app client secret +# REDDIT_USERNAME — Reddit account username +# REDDIT_PASSWORD — Reddit account password + +if [[ -z "${POST_ID:-}" ]]; then + echo '{"ok":false,"error":"POST_ID env var is required"}' >&2 + exit 1 +fi + +if [[ -z "${REPLY_TEXT:-}" ]]; then + echo '{"ok":false,"error":"REPLY_TEXT env var is required"}' >&2 + exit 1 +fi + +if [[ -z "${REDDIT_CLIENT_ID:-}" || -z "${REDDIT_CLIENT_SECRET:-}" || -z "${REDDIT_USERNAME:-}" || -z "${REDDIT_PASSWORD:-}" ]]; then + echo '{"ok":false,"error":"REDDIT_CLIENT_ID, REDDIT_CLIENT_SECRET, REDDIT_USERNAME, and REDDIT_PASSWORD are all required"}' >&2 + exit 1 +fi + +# Use bun to authenticate + post comment (avoids shell escaping issues with reply text) +# Write script to temp file so credentials stay in env vars, not visible in ps output +REPLY_SCRIPT=$(mktemp /tmp/reply-XXXXXX.ts) +chmod 0600 "${REPLY_SCRIPT}" +cat > "${REPLY_SCRIPT}" <<'EOSCRIPT' +const clientId = process.env.REDDIT_CLIENT_ID!; +const clientSecret = process.env.REDDIT_CLIENT_SECRET!; +const username = process.env.REDDIT_USERNAME!; +const password = process.env.REDDIT_PASSWORD!; +const postId = process.env.POST_ID!; +const replyText = process.env.REPLY_TEXT!; + +const auth = Buffer.from(clientId + ':' + clientSecret).toString('base64'); +const userAgent = 'spawn-growth:v1.0.0 (by /u/' + username + ')'; + +// Step 1: Get OAuth token +const tokenRes = await fetch('https://www.reddit.com/api/v1/access_token', { + method: 'POST', + headers: { + 'Authorization': 'Basic ' + auth, + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': userAgent, + }, + body: 'grant_type=password&username=' + encodeURIComponent(username) + '&password=' + encodeURIComponent(password), +}); + +if (!tokenRes.ok) { + console.log(JSON.stringify({ ok: false, error: 'Reddit auth failed: ' + tokenRes.status })); + process.exit(1); +} + +const tokenData = await tokenRes.json(); +const token = tokenData.access_token; +if (!token) { + console.log(JSON.stringify({ ok: false, error: 'No access_token in Reddit auth response' })); + process.exit(1); +} + +// Step 2: Post comment +const commentRes = await fetch('https://oauth.reddit.com/api/comment', { + method: 'POST', + headers: { + 'Authorization': 'Bearer ' + token, + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': userAgent, + }, + body: 'thing_id=' + encodeURIComponent(postId) + '&text=' + encodeURIComponent(replyText), +}); + +if (!commentRes.ok) { + const body = await commentRes.text(); + console.log(JSON.stringify({ ok: false, error: 'Reddit comment failed: ' + commentRes.status, body })); + process.exit(1); +} + +const commentData = await commentRes.json(); + +// Extract the comment URL from Reddit's response +const commentThing = commentData?.json?.data?.things?.[0]?.data; +const commentId = commentThing?.id ?? commentThing?.name ?? ''; +const commentPermalink = commentThing?.permalink ?? ''; +const commentUrl = commentPermalink ? 'https://reddit.com' + commentPermalink : ''; + +console.log(JSON.stringify({ + ok: true, + commentId, + commentUrl, +})); +EOSCRIPT + +cleanup_reply() { rm -f "${REPLY_SCRIPT}" 2>/dev/null || true; } +trap cleanup_reply EXIT +exec bun run "${REPLY_SCRIPT}" diff --git a/.claude/skills/setup-agent-team/trigger-server.ts b/.claude/skills/setup-agent-team/trigger-server.ts index 244fd6853..71604d927 100644 --- a/.claude/skills/setup-agent-team/trigger-server.ts +++ b/.claude/skills/setup-agent-team/trigger-server.ts @@ -80,12 +80,7 @@ let nextRunId = 1; /** Timing-safe auth check — prevents timing side-channel attacks on TRIGGER_SECRET */ function isAuthed(req: Request): boolean { - const given = req.headers.get("Authorization") ?? ""; - const expected = `Bearer ${TRIGGER_SECRET}`; - if (given.length !== expected.length) { - return false; - } - return timingSafeEqual(Buffer.from(given), Buffer.from(expected)); + return isAuthedWith(req, TRIGGER_SECRET); } /** Allowed values for the reason query parameter */ @@ -186,6 +181,81 @@ function gracefulShutdown(signal: string) { process.on("SIGTERM", () => gracefulShutdown("SIGTERM")); process.on("SIGINT", () => gracefulShutdown("SIGINT")); +const REPLY_SCRIPT = resolve(SKILL_DIR, "reply.sh"); +const REPLY_SECRET = process.env.REPLY_SECRET ?? TRIGGER_SECRET; + +/** Check auth against a given secret (timing-safe). */ +function isAuthedWith(req: Request, secret: string): boolean { + const given = req.headers.get("Authorization") ?? ""; + const expected = `Bearer ${secret}`; + if (given.length !== expected.length) { + return false; + } + return timingSafeEqual(Buffer.from(given), Buffer.from(expected)); +} + +/** + * Handle POST /reply — post a comment to Reddit via reply.sh. + * This is synchronous: it waits for reply.sh to finish and returns the result. + */ +async function handleReply(req: Request): Promise { + if (!isAuthedWith(req, REPLY_SECRET)) { + return Response.json({ error: "unauthorized" }, { status: 401 }); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return Response.json({ error: "invalid JSON body" }, { status: 400 }); + } + + const obj = typeof body === "object" && body !== null ? (body as Record) : null; + const postId = obj && typeof obj.postId === "string" ? obj.postId : ""; + const replyText = obj && typeof obj.replyText === "string" ? obj.replyText : ""; + + if (!postId || !replyText) { + return Response.json({ error: "postId and replyText are required" }, { status: 400 }); + } + + // Validate postId format (Reddit fullname: t1_, t3_, etc.) + if (!/^t[1-6]_[a-z0-9]+$/i.test(postId)) { + return Response.json({ error: "invalid postId format" }, { status: 400 }); + } + + console.log(`[trigger] Reply request: postId=${postId}, replyText=${replyText.slice(0, 80)}...`); + + const proc = Bun.spawn(["bash", REPLY_SCRIPT], { + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + POST_ID: postId, + REPLY_TEXT: replyText, + }, + }); + + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const exitCode = await proc.exited; + + if (exitCode !== 0) { + console.error(`[trigger] reply.sh failed (exit=${exitCode}): ${stderr}`); + return Response.json({ error: "reply failed", stderr: stderr.slice(0, 500) }, { status: 502 }); + } + + // Parse reply.sh JSON output + try { + const result = JSON.parse(stdout.trim()); + console.log(`[trigger] Reply posted: ${JSON.stringify(result)}`); + return Response.json(result); + } catch { + return Response.json({ ok: true, raw: stdout.trim() }); + } +} + /** * Spawn the target script and return immediately with a JSON response. * Script stdout/stderr are piped to the server console (journalctl). @@ -278,6 +348,13 @@ const server = Bun.serve({ }); } + if (req.method === "POST" && url.pathname === "/reply") { + if (shuttingDown) { + return Response.json({ error: "server is shutting down" }, { status: 503 }); + } + return handleReply(req); + } + if (req.method === "POST" && url.pathname === "/trigger") { if (shuttingDown) { return Response.json( diff --git a/.claude/skills/setup-spa/helpers.ts b/.claude/skills/setup-spa/helpers.ts index 78ef04fe8..1e5668105 100644 --- a/.claude/skills/setup-spa/helpers.ts +++ b/.claude/skills/setup-spa/helpers.ts @@ -149,6 +149,23 @@ export function openDb(path?: string): Database { PRIMARY KEY (channel, thread_ts) ) `); + db.run(` + CREATE TABLE IF NOT EXISTS candidates ( + post_id TEXT PRIMARY KEY, + permalink TEXT NOT NULL, + title TEXT NOT NULL, + subreddit TEXT NOT NULL, + draft_reply TEXT NOT NULL, + slack_channel TEXT, + slack_ts TEXT, + status TEXT NOT NULL DEFAULT 'pending', + actioned_by TEXT, + actioned_at TEXT, + posted_reply TEXT, + reddit_comment_url TEXT, + created_at TEXT NOT NULL + ) + `); if (!path) { migrateFromJson(db); } @@ -237,6 +254,122 @@ export function updateThread( ); } +// #region Candidates — Reddit growth pipeline + +/** A Reddit growth candidate tracked for approval. */ +export interface CandidateRow { + postId: string; + permalink: string; + title: string; + subreddit: string; + draftReply: string; + slackChannel?: string; + slackTs?: string; + status: "pending" | "approved" | "posted" | "skipped" | "error"; + actionedBy?: string; + actionedAt?: string; + postedReply?: string; + redditCommentUrl?: string; + createdAt: string; +} + +/** Raw SQLite row shape for candidates. */ +interface RawCandidate { + post_id: string; + permalink: string; + title: string; + subreddit: string; + draft_reply: string; + slack_channel: string | null; + slack_ts: string | null; + status: string; + actioned_by: string | null; + actioned_at: string | null; + posted_reply: string | null; + reddit_comment_url: string | null; + created_at: string; +} + +function rowToCandidate(r: RawCandidate): CandidateRow { + return { + postId: r.post_id, + permalink: r.permalink, + title: r.title, + subreddit: r.subreddit, + draftReply: r.draft_reply, + slackChannel: r.slack_channel ?? undefined, + slackTs: r.slack_ts ?? undefined, + status: r.status === "approved" || r.status === "posted" || r.status === "skipped" || r.status === "error" + ? r.status + : "pending", + actionedBy: r.actioned_by ?? undefined, + actionedAt: r.actioned_at ?? undefined, + postedReply: r.posted_reply ?? undefined, + redditCommentUrl: r.reddit_comment_url ?? undefined, + createdAt: r.created_at, + }; +} + +/** Insert or update a candidate. On conflict (same post_id), updates Slack coordinates. */ +export function upsertCandidate(db: Database, candidate: CandidateRow): void { + db.run( + `INSERT INTO candidates (post_id, permalink, title, subreddit, draft_reply, slack_channel, slack_ts, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (post_id) DO UPDATE SET + slack_channel = excluded.slack_channel, + slack_ts = excluded.slack_ts`, + [ + candidate.postId, + candidate.permalink, + candidate.title, + candidate.subreddit, + candidate.draftReply, + candidate.slackChannel ?? null, + candidate.slackTs ?? null, + candidate.status, + candidate.createdAt, + ], + ); +} + +/** Look up a candidate by Reddit post ID. */ +export function findCandidate(db: Database, postId: string): CandidateRow | undefined { + const row = db + .query("SELECT * FROM candidates WHERE post_id = ?") + .get(postId); + return row ? rowToCandidate(row) : undefined; +} + +/** Update a candidate's status and related fields after an action. */ +export function updateCandidateStatus( + db: Database, + postId: string, + update: { + status: CandidateRow["status"]; + actionedBy?: string; + postedReply?: string; + redditCommentUrl?: string; + }, +): void { + db.run( + `UPDATE candidates SET + status = ?, + actioned_by = ?, + actioned_at = ?, + posted_reply = ?, + reddit_comment_url = ? + WHERE post_id = ?`, + [ + update.status, + update.actionedBy ?? null, + new Date().toISOString(), + update.postedReply ?? null, + update.redditCommentUrl ?? null, + postId, + ], + ); +} + // #endregion // #region Claude Code stream parsing diff --git a/.claude/skills/setup-spa/main.ts b/.claude/skills/setup-spa/main.ts index d90601047..c2b9a0b7e 100644 --- a/.claude/skills/setup-spa/main.ts +++ b/.claude/skills/setup-spa/main.ts @@ -5,11 +5,13 @@ import type { ActionsBlock, ContextBlock, KnownBlock, SectionBlock } from "@slac import type { Block } from "@slack/types"; import type { ToolCall } from "./helpers"; +import { timingSafeEqual } from "node:crypto"; import { isString, toRecord } from "@openrouter/spawn-shared"; import { App } from "@slack/bolt"; import * as v from "valibot"; import { downloadSlackFile, + findCandidate, findThread, formatToolStats, markdownToRichTextBlocks, @@ -20,7 +22,9 @@ import { ResultSchema, runCleanupIfDue, stripMention, + updateCandidateStatus, updateThread, + upsertCandidate, upsertThread, } from "./helpers"; @@ -31,6 +35,11 @@ type SlackClient = InstanceType["client"]; const SLACK_BOT_TOKEN = process.env.SLACK_BOT_TOKEN ?? ""; const SLACK_APP_TOKEN = process.env.SLACK_APP_TOKEN ?? ""; const GITHUB_REPO = process.env.GITHUB_REPO ?? "OpenRouterTeam/spawn"; +const TRIGGER_SECRET = process.env.TRIGGER_SECRET ?? ""; +const GROWTH_TRIGGER_URL = process.env.GROWTH_TRIGGER_URL ?? ""; +const GROWTH_REPLY_SECRET = process.env.GROWTH_REPLY_SECRET ?? ""; +const SLACK_CHANNEL_ID = process.env.SLACK_CHANNEL_ID ?? ""; +const HTTP_PORT = Number.parseInt(process.env.HTTP_PORT ?? "3100", 10); for (const [name, value] of Object.entries({ SLACK_BOT_TOKEN, @@ -967,6 +976,470 @@ app.action("cancel_run", async ({ ack, payload }) => { } }); +// --- growth_approve: post draft reply to Reddit --- +app.action("growth_approve", async ({ ack, body, client }) => { + await ack(); + const payload = toRecord( + "actions" in body && Array.isArray(body.actions) ? body.actions[0] : null, + ); + const postId = payload && isString(payload.value) ? payload.value : ""; + if (!postId) return; + + const userId = "user" in body && toRecord(body.user) ? String((toRecord(body.user) ?? {}).id ?? "") : ""; + const candidate = findCandidate(db, postId); + if (!candidate) return; + + if (candidate.status !== "pending") { + await client.chat.postMessage({ + channel: candidate.slackChannel ?? "", + thread_ts: candidate.slackTs ?? undefined, + text: `:warning: Already handled (${candidate.status}${candidate.actionedBy ? ` by <@${candidate.actionedBy}>` : ""})`, + }).catch(() => {}); + return; + } + + updateCandidateStatus(db, postId, { status: "approved", actionedBy: userId }); + + // POST to growth VM to send the Reddit reply + if (!GROWTH_TRIGGER_URL) { + await client.chat.postMessage({ + channel: candidate.slackChannel ?? "", + thread_ts: candidate.slackTs ?? undefined, + text: ":x: GROWTH_TRIGGER_URL not configured — cannot post to Reddit", + }).catch(() => {}); + return; + } + + try { + const res = await fetch(`${GROWTH_TRIGGER_URL}/reply`, { + method: "POST", + headers: { + Authorization: `Bearer ${GROWTH_REPLY_SECRET}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ postId: candidate.postId, replyText: candidate.draftReply }), + }); + + const result = toRecord(await res.json().catch(() => null)); + if (res.ok && result && result.ok) { + const commentUrl = isString(result.commentUrl) ? result.commentUrl : ""; + updateCandidateStatus(db, postId, { + status: "posted", + actionedBy: userId, + postedReply: candidate.draftReply, + redditCommentUrl: commentUrl, + }); + // Update the Slack message — replace buttons with confirmation + if (candidate.slackChannel && candidate.slackTs) { + await replaceButtonsWithStatus( + client, + candidate.slackChannel, + candidate.slackTs, + `:white_check_mark: Posted by <@${userId}>${commentUrl ? ` — <${commentUrl}|view comment>` : ""}`, + ); + } + } else { + const errMsg = isString(result?.error) ? result.error : `HTTP ${res.status}`; + updateCandidateStatus(db, postId, { status: "error", actionedBy: userId }); + await client.chat.postMessage({ + channel: candidate.slackChannel ?? "", + thread_ts: candidate.slackTs ?? undefined, + text: `:x: Reddit reply failed: ${errMsg}`, + }).catch(() => {}); + } + } catch (err) { + updateCandidateStatus(db, postId, { status: "error", actionedBy: userId }); + await client.chat.postMessage({ + channel: candidate.slackChannel ?? "", + thread_ts: candidate.slackTs ?? undefined, + text: `:x: Reddit reply failed: ${err instanceof Error ? err.message : String(err)}`, + }).catch(() => {}); + } +}); + +// --- growth_edit: open modal with draft reply for editing --- +app.action("growth_edit", async ({ ack, body, client }) => { + await ack(); + const payload = toRecord( + "actions" in body && Array.isArray(body.actions) ? body.actions[0] : null, + ); + const postId = payload && isString(payload.value) ? payload.value : ""; + if (!postId) return; + + const triggerId = "trigger_id" in body && isString(body.trigger_id) ? body.trigger_id : ""; + if (!triggerId) return; + + const candidate = findCandidate(db, postId); + if (!candidate) return; + + if (candidate.status !== "pending") { + return; // already handled + } + + await client.views.open({ + trigger_id: triggerId, + view: { + type: "modal", + callback_id: "growth_edit_submit", + private_metadata: postId, + title: { type: "plain_text", text: "Edit Reply" }, + submit: { type: "plain_text", text: "Post to Reddit" }, + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: `*<${candidate.permalink.startsWith("http") ? candidate.permalink : `https://reddit.com${candidate.permalink}`}|${candidate.title}>*\nr/${candidate.subreddit}`, + }, + }, + { + type: "input", + block_id: "reply_block", + label: { type: "plain_text", text: "Reply text" }, + element: { + type: "plain_text_input", + action_id: "reply_text", + multiline: true, + initial_value: candidate.draftReply, + }, + }, + ], + }, + }).catch(() => {}); +}); + +// --- growth_edit_submit: modal submitted with edited reply --- +app.view("growth_edit_submit", async ({ ack, view, body, client }) => { + await ack(); + const postId = view.private_metadata; + if (!postId) return; + + const candidate = findCandidate(db, postId); + if (!candidate || candidate.status !== "pending") return; + + const replyBlock = toRecord(view.state?.values?.reply_block?.reply_text); + const editedReply = replyBlock && isString(replyBlock.value) ? replyBlock.value : ""; + if (!editedReply) return; + + const userId = toRecord(body.user) ? String((toRecord(body.user) ?? {}).id ?? "") : ""; + + updateCandidateStatus(db, postId, { status: "approved", actionedBy: userId }); + + if (!GROWTH_TRIGGER_URL) return; + + try { + const res = await fetch(`${GROWTH_TRIGGER_URL}/reply`, { + method: "POST", + headers: { + Authorization: `Bearer ${GROWTH_REPLY_SECRET}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ postId: candidate.postId, replyText: editedReply }), + }); + + const result = toRecord(await res.json().catch(() => null)); + if (res.ok && result && result.ok) { + const commentUrl = isString(result.commentUrl) ? result.commentUrl : ""; + updateCandidateStatus(db, postId, { + status: "posted", + actionedBy: userId, + postedReply: editedReply, + redditCommentUrl: commentUrl, + }); + if (candidate.slackChannel && candidate.slackTs) { + await replaceButtonsWithStatus( + client, + candidate.slackChannel, + candidate.slackTs, + `:white_check_mark: Posted (edited) by <@${userId}>${commentUrl ? ` — <${commentUrl}|view comment>` : ""}`, + ); + } + } else { + updateCandidateStatus(db, postId, { status: "error", actionedBy: userId }); + if (candidate.slackChannel && candidate.slackTs) { + await client.chat.postMessage({ + channel: candidate.slackChannel, + thread_ts: candidate.slackTs, + text: `:x: Reddit reply failed: ${isString(result?.error) ? result.error : `HTTP ${res.status}`}`, + }).catch(() => {}); + } + } + } catch { + updateCandidateStatus(db, postId, { status: "error", actionedBy: userId }); + } +}); + +// --- growth_skip: skip this candidate --- +app.action("growth_skip", async ({ ack, body, client }) => { + await ack(); + const payload = toRecord( + "actions" in body && Array.isArray(body.actions) ? body.actions[0] : null, + ); + const postId = payload && isString(payload.value) ? payload.value : ""; + if (!postId) return; + + const userId = "user" in body && toRecord(body.user) ? String((toRecord(body.user) ?? {}).id ?? "") : ""; + const candidate = findCandidate(db, postId); + if (!candidate || candidate.status !== "pending") return; + + updateCandidateStatus(db, postId, { status: "skipped", actionedBy: userId }); + + if (candidate.slackChannel && candidate.slackTs) { + await replaceButtonsWithStatus( + client, + candidate.slackChannel, + candidate.slackTs, + `:no_entry_sign: Skipped by <@${userId}>`, + ); + } +}); + +/** Replace the actions block in a candidate card with a status context line. */ +async function replaceButtonsWithStatus( + client: SlackClient, + channel: string, + ts: string, + statusText: string, +): Promise { + try { + // Fetch the current message to get its blocks + const result = await client.conversations.history({ + channel, + latest: ts, + inclusive: true, + limit: 1, + }); + const msg = result.messages?.[0]; + if (!msg) return; + + const blocks = Array.isArray(msg.blocks) ? msg.blocks : []; + // Replace the actions block with a context block showing the status + const updatedBlocks = blocks + .filter((b: Record) => b.type !== "actions") + .concat({ + type: "context", + elements: [{ type: "mrkdwn", text: statusText }], + }); + + await client.chat.update({ + channel, + ts, + text: statusText, + blocks: updatedBlocks, + }); + } catch { + // non-fatal + } +} + +// #endregion + +// #region Growth candidate HTTP server + +/** Valibot schema for incoming candidate JSON from growth agent. */ +const CandidatePayloadSchema = v.object({ + found: v.boolean(), + title: v.optional(v.string()), + url: v.optional(v.string()), + permalink: v.optional(v.string()), + subreddit: v.optional(v.string()), + postId: v.optional(v.string()), + upvotes: v.optional(v.number()), + numComments: v.optional(v.number()), + postedAgo: v.optional(v.string()), + whatTheyAsked: v.optional(v.string()), + whySpawnFits: v.optional(v.string()), + posterQualification: v.optional(v.string()), + relevanceScore: v.optional(v.number()), + draftReply: v.optional(v.string()), + postsScanned: v.optional(v.number()), +}); + +/** Timing-safe auth for the HTTP trigger endpoint. */ +function isHttpAuthed(req: Request): boolean { + if (!TRIGGER_SECRET) return false; + const given = req.headers.get("Authorization") ?? ""; + const expected = `Bearer ${TRIGGER_SECRET}`; + if (given.length !== expected.length) return false; + return timingSafeEqual(Buffer.from(given), Buffer.from(expected)); +} + +/** Post a Block Kit candidate card to Slack and store in DB. */ +async function postCandidateCard( + client: SlackClient, + candidate: v.InferOutput, +): Promise { + const channel = SLACK_CHANNEL_ID; + if (!channel) { + return Response.json({ error: "SLACK_CHANNEL_ID not configured" }, { status: 500 }); + } + + if (!candidate.found) { + // No candidate — post brief summary + const scanText = candidate.postsScanned + ? `Growth scan complete — scanned ${candidate.postsScanned} posts, no candidates today.` + : "Growth scan complete — no candidates today."; + await client.chat.postMessage({ + channel, + text: scanText, + blocks: [ + { type: "context", elements: [{ type: "mrkdwn", text: scanText }] }, + ], + }).catch(() => {}); + return Response.json({ ok: true, action: "no_candidate" }); + } + + // Candidate found — build Block Kit card + const title = candidate.title ?? "Untitled"; + const url = candidate.url ?? `https://reddit.com${candidate.permalink ?? ""}`; + const postId = candidate.postId ?? ""; + const subreddit = candidate.subreddit ?? ""; + const upvotes = candidate.upvotes ?? 0; + const numComments = candidate.numComments ?? 0; + const postedAgo = candidate.postedAgo ?? ""; + const draftReply = candidate.draftReply ?? ""; + + const blocks: (KnownBlock | Block)[] = [ + { + type: "header", + text: { type: "plain_text", text: "Reddit Growth — Candidate Found", emoji: true }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: `*<${url}|${title}>*\nr/${subreddit} | ${upvotes} upvotes | ${numComments} comments | ${postedAgo}`, + }, + }, + ]; + + if (candidate.whatTheyAsked) { + blocks.push({ + type: "section", + text: { type: "mrkdwn", text: `*What they asked:*\n${candidate.whatTheyAsked}` }, + }); + } + + if (candidate.whySpawnFits) { + blocks.push({ + type: "section", + text: { type: "mrkdwn", text: `*Why Spawn fits:*\n${candidate.whySpawnFits}` }, + }); + } + + if (candidate.posterQualification) { + blocks.push({ + type: "section", + text: { type: "mrkdwn", text: `*Poster signals:*\n${candidate.posterQualification}` }, + }); + } + + if (draftReply) { + blocks.push({ + type: "section", + text: { type: "mrkdwn", text: `*Draft reply:*\n>${draftReply.replace(/\n/g, "\n>")}` }, + }); + } + + if (candidate.relevanceScore !== undefined) { + blocks.push({ + type: "context", + elements: [{ type: "mrkdwn", text: `Relevance: ${candidate.relevanceScore}/10` }], + }); + } + + // Action buttons + blocks.push({ + type: "actions", + elements: [ + { + type: "button", + text: { type: "plain_text", text: "Approve", emoji: true }, + style: "primary", + action_id: "growth_approve", + value: postId, + }, + { + type: "button", + text: { type: "plain_text", text: "Edit", emoji: true }, + action_id: "growth_edit", + value: postId, + }, + { + type: "button", + text: { type: "plain_text", text: "Skip", emoji: true }, + style: "danger", + action_id: "growth_skip", + value: postId, + }, + ], + }); + + const msg = await client.chat.postMessage({ + channel, + text: `Reddit Growth — ${title}`, + blocks, + }); + + // Store candidate in DB + upsertCandidate(db, { + postId, + permalink: candidate.permalink ?? "", + title, + subreddit, + draftReply, + slackChannel: channel, + slackTs: msg.ts ?? undefined, + status: "pending", + createdAt: new Date().toISOString(), + }); + + return Response.json({ ok: true, action: "posted", ts: msg.ts }); +} + +/** Start the HTTP server for growth candidate ingestion. */ +function startHttpServer(client: SlackClient): void { + if (!TRIGGER_SECRET) { + console.log("[spa] TRIGGER_SECRET not set — HTTP server disabled"); + return; + } + + Bun.serve({ + port: HTTP_PORT, + async fetch(req) { + const url = new URL(req.url); + + if (req.method === "GET" && url.pathname === "/health") { + return Response.json({ status: "ok" }); + } + + if (req.method === "POST" && url.pathname === "/candidate") { + if (!isHttpAuthed(req)) { + return Response.json({ error: "unauthorized" }, { status: 401 }); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return Response.json({ error: "invalid JSON" }, { status: 400 }); + } + + const parsed = v.safeParse(CandidatePayloadSchema, body); + if (!parsed.success) { + return Response.json({ error: "invalid payload", issues: parsed.issues }, { status: 400 }); + } + + return postCandidateCard(client, parsed.output); + } + + return Response.json({ error: "not found" }, { status: 404 }); + }, + }); + + console.log(`[spa] HTTP server listening on port ${HTTP_PORT}`); +} + // #endregion // #region Graceful shutdown @@ -1002,6 +1475,7 @@ process.on("SIGINT", () => shutdown("SIGINT")); } await app.start(); + startHttpServer(app.client); console.log(`[spa] Running (any channel + DMs, repo=${GITHUB_REPO})`); })(); diff --git a/.claude/skills/setup-spa/spa.test.ts b/.claude/skills/setup-spa/spa.test.ts index 0802e067d..5d68e5de4 100644 --- a/.claude/skills/setup-spa/spa.test.ts +++ b/.claude/skills/setup-spa/spa.test.ts @@ -1,4 +1,4 @@ -import type { ToolCall } from "./helpers"; +import type { CandidateRow, ToolCall } from "./helpers"; import { afterEach, describe, expect, it, mock } from "bun:test"; import { toRecord } from "@openrouter/spawn-shared"; @@ -7,6 +7,7 @@ import { downloadSlackFile, extractMarkdownTables, extractToolHint, + findCandidate, findThread, formatToolHistory, formatToolStats, @@ -21,6 +22,8 @@ import { parseStreamEvent, plainTextFallback, stripMention, + updateCandidateStatus, + upsertCandidate, upsertThread, } from "./helpers"; @@ -1046,3 +1049,95 @@ describe("markdownTableToSlackBlock", () => { expect(block?.rows[1][2].text).toBe(""); }); }); + +// #region Candidate DB tests + +function makeCandidate(overrides: Partial = {}): CandidateRow { + return { + postId: "t3_abc123", + permalink: "/r/SelfHosted/comments/abc123/test", + title: "How to run coding agents on cloud?", + subreddit: "SelfHosted", + draftReply: "check out spawn, it does exactly this. disclosure: i help build this", + status: "pending", + createdAt: new Date().toISOString(), + ...overrides, + }; +} + +describe("candidates table", () => { + it("upsertCandidate and findCandidate round-trip", () => { + const db = openDb(":memory:"); + const candidate = makeCandidate(); + upsertCandidate(db, candidate); + const found = findCandidate(db, "t3_abc123"); + expect(found).toBeTruthy(); + expect(found?.postId).toBe("t3_abc123"); + expect(found?.title).toBe("How to run coding agents on cloud?"); + expect(found?.subreddit).toBe("SelfHosted"); + expect(found?.draftReply).toContain("spawn"); + expect(found?.status).toBe("pending"); + db.close(); + }); + + it("findCandidate returns undefined for missing post", () => { + const db = openDb(":memory:"); + expect(findCandidate(db, "t3_nonexistent")).toBeUndefined(); + db.close(); + }); + + it("upsertCandidate updates Slack coordinates on conflict", () => { + const db = openDb(":memory:"); + upsertCandidate(db, makeCandidate()); + upsertCandidate(db, makeCandidate({ slackChannel: "C123", slackTs: "1234.5678" })); + const found = findCandidate(db, "t3_abc123"); + expect(found?.slackChannel).toBe("C123"); + expect(found?.slackTs).toBe("1234.5678"); + db.close(); + }); + + it("updateCandidateStatus changes status and sets actioned fields", () => { + const db = openDb(":memory:"); + upsertCandidate(db, makeCandidate()); + updateCandidateStatus(db, "t3_abc123", { + status: "posted", + actionedBy: "U789", + postedReply: "the actual reply text", + redditCommentUrl: "https://reddit.com/r/SelfHosted/comments/abc123/test/def456", + }); + const found = findCandidate(db, "t3_abc123"); + expect(found?.status).toBe("posted"); + expect(found?.actionedBy).toBe("U789"); + expect(found?.actionedAt).toBeTruthy(); + expect(found?.postedReply).toBe("the actual reply text"); + expect(found?.redditCommentUrl).toContain("def456"); + db.close(); + }); + + it("updateCandidateStatus to skipped", () => { + const db = openDb(":memory:"); + upsertCandidate(db, makeCandidate()); + updateCandidateStatus(db, "t3_abc123", { + status: "skipped", + actionedBy: "U111", + }); + const found = findCandidate(db, "t3_abc123"); + expect(found?.status).toBe("skipped"); + expect(found?.actionedBy).toBe("U111"); + db.close(); + }); + + it("updateCandidateStatus to error", () => { + const db = openDb(":memory:"); + upsertCandidate(db, makeCandidate()); + updateCandidateStatus(db, "t3_abc123", { + status: "error", + actionedBy: "U222", + }); + const found = findCandidate(db, "t3_abc123"); + expect(found?.status).toBe("error"); + db.close(); + }); +}); + +// #endregion diff --git a/.github/workflows/growth.yml b/.github/workflows/growth.yml new file mode 100644 index 000000000..846f32b23 --- /dev/null +++ b/.github/workflows/growth.yml @@ -0,0 +1,38 @@ +name: Trigger Growth + +on: + schedule: + - cron: '37 14 * * *' + workflow_dispatch: + +jobs: + trigger: + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - name: Trigger growth cycle + env: + SPRITE_URL: ${{ secrets.GROWTH_SPRITE_URL }} + TRIGGER_SECRET: ${{ secrets.GROWTH_TRIGGER_SECRET }} + run: | + HTTP_CODE=$(curl -sS --connect-timeout 15 --max-time 30 \ + -o /tmp/response.json -w "%{http_code}" -X POST \ + "${SPRITE_URL}/trigger?reason=${{ github.event_name }}" \ + -H "Authorization: Bearer ${TRIGGER_SECRET}") + BODY=$(cat /tmp/response.json 2>/dev/null || echo '{}') + echo "$BODY" + case "$HTTP_CODE" in + 2*) + echo "::notice::Trigger accepted (HTTP $HTTP_CODE)" + ;; + 409) + echo "::notice::Run already in progress (HTTP 409)" + ;; + 429) + echo "::warning::Server at capacity (HTTP 429)" + ;; + *) + echo "::error::Trigger failed (HTTP $HTTP_CODE)" + exit 1 + ;; + esac