#!/usr/bin/env bash # oopsie — command-line client for the Oopsie exception tracker set -euo pipefail VERSION="0.4.0" CONFIG_DIR="${OOPSIE_CONFIG_DIR:-$HOME/.oopsie}" CONFIG_FILE="$CONFIG_DIR/config.json" # ── Helpers ───────────────────────────────────────────────────────────── die() { echo "Error: $*" >&2; exit 1; } need() { command -v "$1" >/dev/null 2>&1 || die "'$1' is required but not installed."; } need curl need jq ensure_config() { if [ ! -f "$CONFIG_FILE" ]; then mkdir -p "$CONFIG_DIR" && chmod 700 "$CONFIG_DIR" echo '{"connections":{},"default":null}' > "$CONFIG_FILE" chmod 600 "$CONFIG_FILE" return fi # Migrate legacy .projects → .connections (one-time, silent) if jq -e '(.projects | type == "object") and (.connections | not)' "$CONFIG_FILE" >/dev/null 2>&1; then local tmp="$CONFIG_FILE.tmp" jq '.connections = .projects | del(.projects)' "$CONFIG_FILE" > "$tmp" \ && chmod 600 "$tmp" && mv "$tmp" "$CONFIG_FILE" fi } read_config() { jq -r "$@" "$CONFIG_FILE"; } write_config() { local tmp="$CONFIG_FILE.tmp" jq "$@" "$CONFIG_FILE" > "$tmp" && chmod 600 "$tmp" && mv "$tmp" "$CONFIG_FILE" } # Resolve which connection to use: --connection flag > default # Sets CONNECTION_NAME, CONNECTION_SERVER, CONNECTION_KEY, # CONNECTION_PROJECT_ID, CONNECTION_PROJECT_NAME # The pinned project is stored as {id, name} in new configs; legacy string is # treated as name-only (id left empty, resolved at use-time). resolve_connection() { local name="${CONNECTION_FLAG:-$(read_config '.default // empty')}" [ -z "$name" ] && die "No connection configured. Run 'oopsie config add' to get started." local server key server=$(read_config --arg n "$name" '.connections[$n].server // empty') key=$(read_config --arg n "$name" '.connections[$n].key // empty') [ -z "$server" ] && die "Connection '$name' not found. Run 'oopsie config list'." CONNECTION_NAME="$name" CONNECTION_SERVER="${server%/}" CONNECTION_KEY="$key" CONNECTION_PROJECT_ID=$(read_config --arg n "$name" ' .connections[$n].project | if type == "object" then .id // empty elif type == "string" and test("^[0-9]+$") then . else empty end' ) CONNECTION_PROJECT_NAME=$(read_config --arg n "$name" ' .connections[$n].project | if type == "object" then .name // empty elif type == "string" and (test("^[0-9]+$") | not) then . else empty end' ) } # api METHOD PATH [query=val ...] # Sends X-Project-Id header when SCOPED_PROJECT_ID is set. api() { local method="$1" path="$2" shift 2 local url="${CONNECTION_SERVER}${path}" if [ $# -gt 0 ]; then url="${url}?$(printf '%s&' "$@")" url="${url%&}" fi local args=( -s -w "\n%{http_code}" -X "$method" -H "Authorization: Bearer ${CONNECTION_KEY}" -H "Accept: application/json" -H "Content-Type: application/json" -H "X-Oopsie-Client: cli/oopsie ${VERSION}" --connect-timeout 10 --max-time 30 ) if [ -n "${SCOPED_PROJECT_ID:-}" ]; then args+=(-H "X-Project-Id: ${SCOPED_PROJECT_ID}") fi local has_body="${API_REQUEST_BODY_SET:-0}" local request_body="${API_REQUEST_BODY:-}" API_REQUEST_BODY_SET=0 API_REQUEST_BODY="" if [ "$has_body" = "1" ]; then args+=(-d "$request_body") fi local body http_code err body=$(curl "${args[@]}" "$url" 2>/dev/null) || die "Cannot connect to ${CONNECTION_SERVER}. Is the server running?" http_code=$(echo "$body" | tail -1) body=$(echo "$body" | sed '$d') case "$http_code" in 2*) echo "$body" ;; 400) err=$(echo "$body" | jq -r '.error // "Bad request"' 2>/dev/null || echo "Bad request") if [[ "$err" == *"Project context required"* ]]; then die "No project scoped. Use '--project ' or 'oopsie config set-project '." fi die "$err" ;; 401) die "Invalid API key. Run 'oopsie whoami' to check." ;; 403) die "Forbidden. Your key doesn't have access to that project." ;; 404) die "Not found." ;; 429) die "Rate limit exceeded. Try again in a minute." ;; *) die "Server returned HTTP $http_code: $body" ;; esac } api_json() { local method="$1" path="$2" body="$3" shift 3 API_REQUEST_BODY="$body" API_REQUEST_BODY_SET=1 api "$method" "$path" "$@" } # Fetch accessible projects as TSV (id, name, unresolved, total, created). # Temporarily clears X-Project-Id to get the full list. fetch_projects_tsv() { local saved="${SCOPED_PROJECT_ID:-}" SCOPED_PROJECT_ID="" local data data=$(api GET /api/v1/project) SCOPED_PROJECT_ID="$saved" if echo "$data" | jq -e '.projects' >/dev/null 2>&1; then echo "$data" | jq -r '.projects[] | [.id, .name, .unresolved_count, .error_groups_count, .created_at] | @tsv' else echo "$data" | jq -r '[.id, .name, .unresolved_count, .error_groups_count, .created_at] | @tsv' fi } # Resolve a --project argument (numeric id OR name) to a numeric id. resolve_project_id_or_die() { local input="$1" if [[ "$input" =~ ^[0-9]+$ ]]; then echo "$input"; return fi local id id=$(fetch_projects_tsv | awk -F'\t' -v n="$input" '$2==n {print $1; exit}') [ -z "$id" ] && die "Project '$input' not accessible on ${CONNECTION_SERVER}. Run 'oopsie projects'." echo "$id" } # Determine project scope for this command: # 1. --project flag (name or id) # 2. connection's pinned project id (no network round-trip) # 3. connection's pinned project name (legacy, resolves via network) # 4. unset (server decides: project-key works, user-key errors) scope_project() { local flag="${PROJECT_ARG:-}" if [ -n "$flag" ]; then SCOPED_PROJECT_ID=$(resolve_project_id_or_die "$flag") return fi if [ -n "${CONNECTION_PROJECT_ID:-}" ]; then SCOPED_PROJECT_ID="$CONNECTION_PROJECT_ID" return fi if [ -n "${CONNECTION_PROJECT_NAME:-}" ]; then SCOPED_PROJECT_ID=$(resolve_project_id_or_die "$CONNECTION_PROJECT_NAME") return fi SCOPED_PROJECT_ID="" } # ── Display helpers ───────────────────────────────────────────────────── relative_time() { local iso="$1" local ts ts=$(date -d "$iso" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%S" "${iso%%.*}" +%s 2>/dev/null || echo "") if [ -z "$ts" ]; then echo "$iso"; return; fi local now diff now=$(date +%s) diff=$((now - ts)) if [ $diff -lt 60 ]; then echo "just now" elif [ $diff -lt 3600 ]; then echo "$((diff / 60))m ago" elif [ $diff -lt 86400 ]; then echo "$((diff / 3600))h ago" elif [ $diff -lt 604800 ]; then echo "$((diff / 86400))d ago" else echo "${iso:0:10}" fi } shorten() { local str="$1" max="${2:-80}" if [ ${#str} -gt "$max" ]; then echo "${str:0:$((max-3))}..." else echo "$str"; fi } status_badge() { case "$1" in unresolved) echo "[OPEN]" ;; resolved) echo "[RESOLVED]" ;; ignored) echo "[IGNORED]" ;; *) echo "[$(echo "$1" | tr '[:lower:]' '[:upper:]')]" ;; esac } workflow_badge() { case "$1" in untriaged) echo "[UNTRIAGED]" ;; looking) echo "[LOOKING]" ;; in_progress) echo "[IN PROGRESS]" ;; blocked) echo "[BLOCKED]" ;; ready_to_resolve) echo "[READY TO RESOLVE]" ;; *) echo "[$(echo "$1" | tr '[:lower:]' '[:upper:]')]" ;; esac } TEXT_VALUE="" TEXT_SOURCE_COUNT=0 reset_text_source() { TEXT_VALUE="" TEXT_SOURCE_COUNT=0 } set_text_source() { local value="$1" flag="$2" TEXT_SOURCE_COUNT=$((TEXT_SOURCE_COUNT + 1)) [ "$TEXT_SOURCE_COUNT" -gt 1 ] && die "Use only one text source flag." TEXT_VALUE="$value" if [ -z "$TEXT_VALUE" ]; then die "$flag cannot be blank." fi } set_text_source_from_file() { local file="$1" flag="$2" [ -r "$file" ] || die "Cannot read file for $flag: $file" set_text_source "$(cat "$file")" "$flag" } set_text_source_from_stdin() { local flag="$1" set_text_source "$(cat)" "$flag" } parse_text_options() { local label="$1" shift reset_text_source while [ $# -gt 0 ]; do case "$1" in --${label}) [ $# -ge 2 ] || die "Missing value for --${label}." set_text_source "$2" "--${label}" shift 2 ;; --${label}-stdin) set_text_source_from_stdin "--${label}-stdin" shift ;; --${label}-file) [ $# -ge 2 ] || die "Missing file path for --${label}-file." set_text_source_from_file "$2" "--${label}-file" shift 2 ;; -*) die "Unknown flag: $1. Run 'oopsie help'." ;; *) die "Unexpected argument: $1. Run 'oopsie help'." ;; esac done return 0 } require_text_source() { local usage="$1" if [ "$TEXT_SOURCE_COUNT" -eq 0 ]; then die "$usage" fi } canonical_event_cli() { case "$1" in new_error|error.created) echo "new_error" ;; regression|error.reopened|error.regressed) echo "regression" ;; *) return 1 ;; esac } events_json_or_die() { local raw="${1:-new_error,regression}" local -a parts events=() local part event seen="," IFS=',' read -r -a parts <<< "$raw" for part in "${parts[@]}"; do part="${part#"${part%%[![:space:]]*}"}" part="${part%"${part##*[![:space:]]}"}" [ -z "$part" ] && continue event=$(canonical_event_cli "$part") || die "Invalid event '$part'. Supported: new_error, regression, error.created, error.reopened." if [[ "$seen" != *",$event,"* ]]; then events+=("$event") seen="${seen}${event}," fi done [ "${#events[@]}" -eq 0 ] && die "Missing --events value." printf '%s\n' "${events[@]}" | jq -R . | jq -s . } validate_webhook_url() { local url="$1" [[ "$url" =~ ^https?://[^[:space:]]+$ ]] || die "Missing or invalid URL. Use an HTTP or HTTPS URL." } # ── Config commands ───────────────────────────────────────────────────── cmd_config_add() { local name="" server="" key="" project_arg="" [ $# -ge 1 ] && { name="$1"; shift; } while [ $# -gt 0 ]; do case "$1" in --server) server="$2"; shift 2 ;; --key) key="$2"; shift 2 ;; --project|--pin) project_arg="$2"; shift 2 ;; -*) die "Unknown flag: $1" ;; *) shift ;; esac done [ -z "$name" ] && die "Usage: oopsie config add --server --key [--project ]" [ -z "$server" ] && die "Missing --server" [ -z "$key" ] && die "Missing --key" ensure_config local obj obj=$(jq -n --arg s "$server" --arg k "$key" '{server:$s, key:$k}') write_config --arg n "$name" --argjson o "$obj" \ '.connections[$n] = $o | if .default == null then .default = $n else . end' echo "Added connection '$name' → ${server}" local default default=$(read_config '.default') [ "$default" = "$name" ] && echo " Set as default." if [ -n "$project_arg" ]; then CONNECTION_FLAG="$name" resolve_connection pin_project_on_connection "$project_arg" fi } cmd_config_list() { ensure_config local count count=$(read_config '.connections | length') if [ "$count" -eq 0 ]; then echo "No connections. Run 'oopsie config add' to get started." return fi local default default=$(read_config '.default // ""') echo "CONNECTIONS ($count)" echo "" read_config '.connections | to_entries[] | [.key, .value.server, .value.key, (if .value.project | type == "object" then "\(.value.project.name) (id \(.value.project.id))" elif .value.project then .value.project else "" end)] | @tsv' | \ while IFS=$'\t' read -r name server key project; do local marker="" [ "$name" = "$default" ] && marker=" (default)" echo " ${name}${marker}" echo " ${server}" if [ -n "$project" ]; then echo " key ${key:0:8}... · project: ${project}" else echo " key ${key:0:8}..." fi echo "" done } cmd_config_remove() { local name="${1:-}" [ -z "$name" ] && die "Usage: oopsie config remove " ensure_config local exists exists=$(read_config --arg n "$name" '.connections[$n] // empty') [ -z "$exists" ] && die "Connection '$name' not found." write_config --arg n "$name" \ 'del(.connections[$n]) | if .default == $n then .default = (.connections | keys | first // null) else . end' echo "Removed connection '$name'." } cmd_config_use() { local name="${1:-}" [ -z "$name" ] && die "Usage: oopsie config use " ensure_config local exists exists=$(read_config --arg n "$name" '.connections[$n] // empty') [ -z "$exists" ] && die "Connection '$name' not found." write_config --arg n "$name" '.default = $n' echo "Default connection set to '$name'." } # Resolve a project (by name or id), fetch the canonical name, and pin {id,name} # on the current CONNECTION_NAME. A rename on the server will break the pin # (bad match on id) rather than silently retarget a different project. pin_project_on_connection() { local input="$1" local id name id=$(resolve_project_id_or_die "$input") # Read the canonical name for this id from the projects list. name=$(fetch_projects_tsv | awk -F'\t' -v i="$id" '$1==i {print $2; exit}') [ -z "$name" ] && die "Could not resolve canonical name for project id '$id'." write_config --arg n "$CONNECTION_NAME" --argjson p "{\"id\":$id,\"name\":$(jq -Rn --arg s "$name" '$s')}" \ '.connections[$n].project = $p' echo "Pinned project '$name' (id $id) on connection '$CONNECTION_NAME'." } cmd_config_set_project() { ensure_config resolve_connection local proj="${1:-}" if [ -z "$proj" ]; then die "Usage: oopsie config set-project | --clear" fi if [ "$proj" = "--clear" ]; then write_config --arg n "$CONNECTION_NAME" 'del(.connections[$n].project)' echo "Cleared pinned project on connection '$CONNECTION_NAME'." return fi pin_project_on_connection "$proj" } cmd_config() { local sub="${1:-help}"; shift 2>/dev/null || true case "$sub" in add) cmd_config_add "$@" ;; list) cmd_config_list ;; remove) cmd_config_remove "$@" ;; use) cmd_config_use "$@" ;; set-project) cmd_config_set_project "$@" ;; *) cat < --server --key [--project ] oopsie config list oopsie config remove oopsie config use Set default connection oopsie config set-project | --clear Pin a default project on current connection EOF ;; esac } # ── whoami / projects ─────────────────────────────────────────────────── cmd_whoami() { resolve_connection SCOPED_PROJECT_ID="" local data data=$(api GET /api/v1/project) local auth_type count if echo "$data" | jq -e '.projects' >/dev/null 2>&1; then auth_type="User API key (cross-project)" count=$(echo "$data" | jq '.projects | length') else auth_type="Project API key" count=1 fi echo "Connection: $CONNECTION_NAME" echo " Server: $CONNECTION_SERVER" echo " Auth: $auth_type" echo " Projects: $count accessible" if [ -n "${CONNECTION_PROJECT_NAME:-}" ]; then if [ -n "${CONNECTION_PROJECT_ID:-}" ]; then echo " Project: $CONNECTION_PROJECT_NAME (id $CONNECTION_PROJECT_ID, pinned)" else echo " Project: $CONNECTION_PROJECT_NAME (pinned, legacy name-only)" fi elif [ -n "${CONNECTION_PROJECT_ID:-}" ]; then echo " Project: id $CONNECTION_PROJECT_ID (pinned)" fi } cmd_projects() { resolve_connection local rows rows=$(fetch_projects_tsv) local n n=$(printf '%s\n' "$rows" | grep -c . || true) if [ "$n" -eq 0 ]; then echo "No projects accessible." return fi echo "PROJECTS ($n)" echo "" printf " %-25s %10s %7s %5s\n" "NAME" "UNRESOLVED" "TOTAL" "ID" local had_marker=0 while IFS=$'\t' read -r id name unresolved total created; do local marker="" if [ -n "${CONNECTION_PROJECT_ID:-}" ] && [ "$id" = "$CONNECTION_PROJECT_ID" ]; then marker=" *"; had_marker=1 elif [ -z "${CONNECTION_PROJECT_ID:-}" ] && [ -n "${CONNECTION_PROJECT_NAME:-}" ] && [ "$name" = "$CONNECTION_PROJECT_NAME" ]; then marker=" *"; had_marker=1 fi printf " %-25s %10s %7s %5s%s\n" "$name" "$unresolved" "$total" "$id" "$marker" done <<< "$rows" if [ "$had_marker" = "1" ]; then echo "" echo " * pinned default" fi } # ── Project / errors commands ─────────────────────────────────────────── cmd_project() { resolve_connection SCOPED_PROJECT_ID="" local data data=$(api GET /api/v1/project) # Pick what to display: --project flag > pinned id > pinned name local want="${PROJECT_ARG:-}" if [ -z "$want" ]; then if [ -n "${CONNECTION_PROJECT_ID:-}" ]; then want="$CONNECTION_PROJECT_ID" elif [ -n "${CONNECTION_PROJECT_NAME:-}" ]; then want="$CONNECTION_PROJECT_NAME" fi fi local proj_json if echo "$data" | jq -e '.projects' >/dev/null 2>&1; then if [ -z "$want" ]; then die "User API key in use. Specify a project: '--project ' or 'oopsie config set-project '." fi # Match by exact id first (unambiguous), then by name (may match multiple). proj_json=$(echo "$data" | jq --arg n "$want" 'first(.projects[] | select((.id | tostring) == $n))') [ -z "$proj_json" ] || [ "$proj_json" = "null" ] && \ proj_json=$(echo "$data" | jq --arg n "$want" 'first(.projects[] | select(.name == $n))') [ -z "$proj_json" ] || [ "$proj_json" = "null" ] && \ die "Project '$want' not accessible. Run 'oopsie projects'." else proj_json="$data" fi echo "Project: $(echo "$proj_json" | jq -r '.name')" echo " Error groups: $(echo "$proj_json" | jq -r '.error_groups_count')" echo " Unresolved: $(echo "$proj_json" | jq -r '.unresolved_count')" echo " Created: $(echo "$proj_json" | jq -r '.created_at')" } cmd_errors() { local params=() while [ $# -gt 0 ]; do case "$1" in --status) params+=("status=$2"); shift 2 ;; --workflow-state) params+=("workflow_state=$2"); shift 2 ;; --limit) params+=("limit=$2"); shift 2 ;; --offset) params+=("offset=$2"); shift 2 ;; -*) die "Unknown flag: $1. Run 'oopsie help'." ;; *) die "Unexpected argument: $1. Run 'oopsie help'." ;; esac done resolve_connection scope_project local data data=$(api GET /api/v1/error_groups "${params[@]+"${params[@]}"}") local count total count=$(echo "$data" | jq '.error_groups | length') total=$(echo "$data" | jq '.total') if [ "$count" -eq 0 ]; then echo "No error groups found." return fi echo "Error groups ($count of $total):" echo "" echo "$data" | jq -c '.error_groups[]' | while read -r group; do local id status workflow_state error_class message occ_count last_seen id=$(echo "$group" | jq -r '.id') status=$(echo "$group" | jq -r '.status') workflow_state=$(echo "$group" | jq -r '.workflow_state // "untriaged"') error_class=$(echo "$group" | jq -r '.error_class') message=$(echo "$group" | jq -r '.message // ""') occ_count=$(echo "$group" | jq -r '.occurrences_count') last_seen=$(echo "$group" | jq -r '.last_seen_at') echo " #${id} $(status_badge "$status") $(workflow_badge "$workflow_state") ${error_class}" echo " $(shorten "$message" 80)" echo " ${occ_count} occurrences · last seen $(relative_time "$last_seen")" echo "" done } cmd_show() { local id="${1:-}" [ -z "$id" ] && die "Usage: oopsie show " resolve_connection scope_project local data data=$(api GET "/api/v1/error_groups/$id") local g g=$(echo "$data" | jq '.error_group') echo "Error group #$(echo "$g" | jq -r '.id')" echo " Class: $(echo "$g" | jq -r '.error_class')" echo " Status: $(status_badge "$(echo "$g" | jq -r '.status')")" echo " Workflow: $(workflow_badge "$(echo "$g" | jq -r '.workflow_state // "untriaged"')")" echo " Message: $(echo "$g" | jq -r '.message // ""')" echo " Occurrences: $(echo "$g" | jq -r '.occurrences_count')" echo " First seen: $(echo "$g" | jq -r '.first_seen_at')" echo " Last seen: $(echo "$g" | jq -r '.last_seen_at')" local activity_count activity_count=$(echo "$data" | jq '(.activity // []) | length') if [ "$activity_count" -gt 0 ]; then echo "" echo " Recent activity:" echo "" echo "$data" | jq -c '(.activity // [])[]' | while read -r item; do local kind actor source created_at from_value to_value body kind=$(echo "$item" | jq -r '.kind') actor=$(echo "$item" | jq -r '.actor_label // "system"') source=$(echo "$item" | jq -r '.source // "unknown"') created_at=$(echo "$item" | jq -r '.created_at') from_value=$(echo "$item" | jq -r '.from_value // empty') to_value=$(echo "$item" | jq -r '.to_value // empty') body=$(echo "$item" | jq -r '.body // empty') echo " ${created_at} ${kind} by ${actor} via ${source}" if [ -n "$from_value" ] || [ -n "$to_value" ]; then echo " ${from_value:-none} → ${to_value:-none}" fi [ -n "$body" ] && echo " $(shorten "$body" 96)" echo "" done fi local occ_count occ_count=$(echo "$data" | jq '.occurrences | length') if [ "$occ_count" -gt 0 ]; then echo "" echo " Recent occurrences:" echo "" echo "$data" | jq -c '.occurrences[]' | while read -r occ; do local oid occurred_at env msg oid=$(echo "$occ" | jq -r '.id') occurred_at=$(echo "$occ" | jq -r '.occurred_at') env=$(echo "$occ" | jq -r '.environment // "unknown"') msg=$(echo "$occ" | jq -r '.message // ""') echo " #${oid} ${occurred_at} [${env}]" echo " $(shorten "$msg" 76)" local file line method file=$(echo "$occ" | jq -r '.first_line.file // empty') if [ -n "$file" ]; then line=$(echo "$occ" | jq -r '.first_line.line // ""') method=$(echo "$occ" | jq -r '.first_line.method // ""') echo " at ${file}:${line} in ${method}" fi local ctx ctx=$(echo "$occ" | jq -r 'if .context != null and .context != {} then .context | tostring else empty end') [ -n "$ctx" ] && echo " context: ${ctx}" echo "" done fi } cmd_resolve() { local id="${1:-}"; [ -z "$id" ] && die "Usage: oopsie resolve " shift || true parse_text_options note "$@" resolve_connection scope_project if [ "$TEXT_SOURCE_COUNT" -gt 0 ]; then local body body=$(jq -n --arg note "$TEXT_VALUE" '{note: $note}') api_json PATCH "/api/v1/error_groups/$id/resolve" "$body" >/dev/null else api PATCH "/api/v1/error_groups/$id/resolve" >/dev/null fi echo "Resolved error group #${id}." } cmd_ignore() { local id="${1:-}"; [ -z "$id" ] && die "Usage: oopsie ignore " shift || true parse_text_options note "$@" resolve_connection scope_project if [ "$TEXT_SOURCE_COUNT" -gt 0 ]; then local body body=$(jq -n --arg note "$TEXT_VALUE" '{note: $note}') api_json PATCH "/api/v1/error_groups/$id/ignore" "$body" >/dev/null else api PATCH "/api/v1/error_groups/$id/ignore" >/dev/null fi echo "Ignored error group #${id}." } cmd_reopen() { local id="${1:-}"; [ -z "$id" ] && die "Usage: oopsie reopen " shift || true parse_text_options note "$@" resolve_connection scope_project if [ "$TEXT_SOURCE_COUNT" -gt 0 ]; then local body body=$(jq -n --arg note "$TEXT_VALUE" '{note: $note}') api_json PATCH "/api/v1/error_groups/$id/unresolve" "$body" >/dev/null else api PATCH "/api/v1/error_groups/$id/unresolve" >/dev/null fi echo "Reopened error group #${id}." } cmd_state() { local id="${1:-}" state="${2:-}" if [ -z "$id" ] || [ -z "$state" ]; then die "Usage: oopsie state [--note |--note-stdin|--note-file ]" fi shift 2 parse_text_options note "$@" local body if [ "$TEXT_SOURCE_COUNT" -gt 0 ]; then body=$(jq -n --arg workflow_state "$state" --arg note "$TEXT_VALUE" '{workflow_state: $workflow_state, note: $note}') else body=$(jq -n --arg workflow_state "$state" '{workflow_state: $workflow_state}') fi resolve_connection scope_project api_json PATCH "/api/v1/error_groups/$id/workflow_state" "$body" >/dev/null echo "Set error group #${id} workflow state to ${state}." } cmd_note() { local id="${1:-}" [ -z "$id" ] && die "Usage: oopsie note --body |--body-stdin|--body-file " shift || true parse_text_options body "$@" require_text_source "Usage: oopsie note --body |--body-stdin|--body-file " local body body=$(jq -n --arg body "$TEXT_VALUE" '{body: $body}') resolve_connection scope_project api_json POST "/api/v1/error_groups/$id/notes" "$body" >/dev/null echo "Added note to error group #${id}." } # ── Notification commands ─────────────────────────────────────────────── cmd_notifications() { [ "${1:-}" = "list" ] && shift local kind="" while [ $# -gt 0 ]; do case "$1" in --kind|--channel) kind="$2"; shift 2 ;; -*) die "Unknown flag: $1. Run 'oopsie help'." ;; *) die "Unexpected argument: $1. Run 'oopsie help'." ;; esac done if [ -n "$kind" ] && [ "$kind" != "email" ] && [ "$kind" != "webhook" ]; then die "Invalid kind '$kind'. Use 'email' or 'webhook'." fi resolve_connection scope_project local data rules count label data=$(api GET /api/v1/notification_rules) rules=$(echo "$data" | jq -c --arg kind "$kind" '.notification_rules[] | select($kind == "" or .channel == $kind)') count=$(printf '%s\n' "$rules" | grep -c . || true) if [ "$count" -eq 0 ]; then echo "No notification rules found." return fi label="NOTIFICATIONS" [ -n "$kind" ] && label="$(echo "$kind" | tr '[:lower:]' '[:upper:]') NOTIFICATIONS" echo "$label ($count)" echo "" printf " %-5s %-8s %-8s %-22s %s\n" "ID" "CHANNEL" "ENABLED" "EVENTS" "DESTINATION" while read -r rule; do local id channel enabled events destination id=$(echo "$rule" | jq -r '.id') channel=$(echo "$rule" | jq -r '.channel') enabled=$(echo "$rule" | jq -r '.enabled') events=$(echo "$rule" | jq -r '.events | join(",")') destination=$(echo "$rule" | jq -r '.destination_masked // "[masked]"') printf " %-5s %-8s %-8s %-22s %s\n" "$id" "$channel" "$enabled" "$events" "$destination" done <<< "$rules" } cmd_notification_create() { local kind="webhook" url="" events_arg="" url_sources=0 enabled=true while [ $# -gt 0 ]; do case "$1" in --kind|--channel) kind="$2"; shift 2 ;; --url|--destination) url="$2"; url_sources=$((url_sources + 1)); shift 2 ;; --url-stdin) url=$(tr -d '\r\n'); url_sources=$((url_sources + 1)); shift ;; --url-file) [ -r "$2" ] || die "Cannot read URL file: $2" url=$(tr -d '\r\n' < "$2") url_sources=$((url_sources + 1)) shift 2 ;; --events|--event) events_arg="$2"; shift 2 ;; --enabled) enabled=true; shift ;; --disabled) enabled=false; shift ;; -*) die "Unknown flag: $1. Run 'oopsie help'." ;; *) die "Unexpected argument: $1. Run 'oopsie help'." ;; esac done [ "$kind" = "webhook" ] || die "The CLI currently supports creating webhook notifications only." [ "$url_sources" -eq 0 ] && die "Missing URL. Use --url, --url-stdin, or --url-file." [ "$url_sources" -gt 1 ] && die "Use only one of --url, --url-stdin, or --url-file." validate_webhook_url "$url" local events_json body data rule events_json=$(events_json_or_die "$events_arg") body=$(jq -n \ --arg channel "$kind" \ --arg destination "$url" \ --argjson events "$events_json" \ --argjson enabled "$enabled" \ '{notification_rule: {channel: $channel, destination: $destination, events: $events, enabled: $enabled}}') resolve_connection scope_project data=$(api_json POST /api/v1/notification_rules "$body") rule=$(echo "$data" | jq '.notification_rule') echo "Created notification rule #$(echo "$rule" | jq -r '.id')." echo " Channel: $(echo "$rule" | jq -r '.channel')" echo " Events: $(echo "$rule" | jq -r '.events | join(",")')" echo " Enabled: $(echo "$rule" | jq -r '.enabled')" echo " Destination: $(echo "$rule" | jq -r '.destination_masked // "[masked]"')" } cmd_notification() { local sub="${1:-help}" shift 2>/dev/null || true case "$sub" in create) cmd_notification_create "$@" ;; list|ls) cmd_notifications "$@" ;; *) cat < [--events new_error,regression] printf '%s' "\$OOPSIE_WEBHOOK_URL" | oopsie notification create --kind webhook --url-stdin --events error.created,error.reopened EOF ;; esac } cmd_webhooks() { [ "${1:-}" = "list" ] && shift cmd_notifications --kind webhook "$@" } cmd_webhook() { local sub="${1:-list}" shift 2>/dev/null || true case "$sub" in create) cmd_notification_create --kind webhook "$@" ;; list|ls) cmd_webhooks "$@" ;; *) die "Unknown webhook command: $sub. Run 'oopsie help' for usage." ;; esac } # ── Help ──────────────────────────────────────────────────────────────── cmd_help() { cat < --server --key [--project ] oopsie config list oopsie config use Set the default connection oopsie config set-project |--clear Pin a default project on current connection oopsie config remove Info: oopsie whoami Current auth + project access oopsie projects List projects you can access oopsie project Summary of the active project Errors: oopsie errors [--status unresolved|resolved|ignored] [--workflow-state untriaged|looking|in_progress|blocked|ready_to_resolve] [--limit N] [--offset N] oopsie show Full details + recent occurrences oopsie state [--note TEXT|--note-stdin|--note-file PATH] Set agent workflow state without changing lifecycle status oopsie note --body TEXT|--body-stdin|--body-file PATH Add an investigation note oopsie resolve [--note TEXT] Mark resolved oopsie ignore [--note TEXT] Archive oopsie reopen [--note TEXT] Reopen Notifications: oopsie notifications List notification rules oopsie notifications --kind webhook List webhook notification rules oopsie notification create --kind webhook --url [--events new_error,regression] oopsie webhook create --url [--events error.created,error.reopened] printf '%s' "\$OOPSIE_WEBHOOK_URL" | oopsie notification create --kind webhook --url-stdin Global flags: -p, --project Scope to a specific remote project -c, --connection Use a non-default connection -h, --help Show this help -v, --version Show version Auth modes: Project key — scoped to one project. No --project needed. User key — cross-project. Pass --project or pin one with 'config set-project'. Config: ${CONFIG_DIR}/config.json (override with \$OOPSIE_CONFIG_DIR) EOF } # ── Main ──────────────────────────────────────────────────────────────── CONNECTION_FLAG="" PROJECT_ARG="" args=() # `config add` owns its own --project flag (pins a project on the new connection). # For every other command, --project/-p at any position sets the scope for this call. is_config_add=0 if [ "${1:-}" = "config" ] && [ "${2:-}" = "add" ]; then is_config_add=1 fi while [ $# -gt 0 ]; do case "$1" in -c|--connection) CONNECTION_FLAG="$2"; shift 2 ;; -p|--project) if [ "$is_config_add" = "1" ]; then args+=("$1"); shift else PROJECT_ARG="$2"; shift 2 fi ;; *) args+=("$1"); shift ;; esac done set -- "${args[@]+"${args[@]}"}" command="${1:-help}"; shift 2>/dev/null || true case "$command" in config) cmd_config "$@" ;; whoami) cmd_whoami ;; projects) cmd_projects ;; project) cmd_project ;; errors) cmd_errors "$@" ;; show) cmd_show "$@" ;; state) cmd_state "$@" ;; note) cmd_note "$@" ;; resolve) cmd_resolve "$@" ;; ignore) cmd_ignore "$@" ;; reopen) cmd_reopen "$@" ;; notifications) cmd_notifications "$@" ;; notification) cmd_notification "$@" ;; webhooks) cmd_webhooks "$@" ;; webhook) cmd_webhook "$@" ;; help|--help|-h) cmd_help ;; version|--version|-v) echo "oopsie ${VERSION}" ;; *) die "Unknown command: $command. Run 'oopsie help' for usage." ;; esac