#!/bin/sh # inv: small helper for agents (Drover) that work on Inventory todos. # Needs only POSIX sh, curl and jq. See `inv help`. # # Configuration (environment wins over the vars file): # INVENTORY_API_BASE e.g. https://inventory.metastic.ch/api/v1 # INVENTORY_API_TOKEN agent or write token (inv_…); INVENTORY_TOKEN is accepted as an alias # INV_VARS_FILE vars file to read missing values from # (default ~/.drover/secrets/passive-income.vars.env) # INV_ASSIGNEE assignee name used by next/claim/mine (default drover) # # The token is only ever passed to curl through a private temp config file # (never on the command line, never printed). Exit status: 0 ok, 1 API/usage error, # 2 claim conflict (someone else holds the todo). set -eu PROG=inv # INV_VERSION is bumped with every change of this file; /docs/drover shows it with the sha256. INV_VERSION=1.1.0 die() { printf '%s: %s\n' "$PROG" "$*" >&2; exit 1; } command -v curl >/dev/null 2>&1 || die "curl is not installed" command -v jq >/dev/null 2>&1 || die "jq is not installed" # --- configuration ------------------------------------------------------------- # varsfile_get KEY: value of KEY=... from the vars file (no shell evaluation of the file). varsfile_get() { [ -r "$VARS_FILE" ] || return 0 sed -n "s/^[[:space:]]*\(export[[:space:]]\{1,\}\)\{0,1\}$1=//p" "$VARS_FILE" | tail -n 1 | sed -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'\$/\1/" } VARS_FILE=${INV_VARS_FILE:-${HOME:-}/.drover/secrets/passive-income.vars.env} BASE=${INVENTORY_API_BASE:-$(varsfile_get INVENTORY_API_BASE)} TOKEN=${INVENTORY_API_TOKEN:-${INVENTORY_TOKEN:-}} [ -n "$TOKEN" ] || TOKEN=$(varsfile_get INVENTORY_API_TOKEN) [ -n "$TOKEN" ] || TOKEN=$(varsfile_get INVENTORY_TOKEN) ASSIGNEE=${INV_ASSIGNEE:-drover} BASE=${BASE%/} TMP=$(mktemp -d "${TMPDIR:-/tmp}/inv.XXXXXX") trap 'rm -rf "$TMP"' EXIT HUP INT TERM chmod 700 "$TMP" need_config() { [ -n "$BASE" ] || die "INVENTORY_API_BASE is not set (env or $VARS_FILE)" [ -n "$TOKEN" ] || die "INVENTORY_API_TOKEN is not set (env or $VARS_FILE)" case $BASE in https://* | http://localhost* | http://127.0.0.1*) ;; *) die "INVENTORY_API_BASE must be an https URL (got: $BASE)" ;; esac case $TOKEN in *[!A-Za-z0-9_-]*) die "INVENTORY_API_TOKEN has unexpected characters" ;; esac # curl reads the header from a 0600 file, so the token never appears in `ps`. (umask 077 && printf 'header = "Authorization: Bearer %s"\n' "$TOKEN" >"$TMP/auth") } # --- HTTP ---------------------------------------------------------------------- # api METHOD PATH [JSON]: body in $TMP/body, status in $CODE. Dies on transport errors only. api() { need_config _m=$1 _p=$2 [ -z "${3+x}" ] || printf '%s' "$3" >"$TMP/req" set -- -sS -o "$TMP/body" -w '%{http_code}' --connect-timeout 10 --max-time 60 \ -K "$TMP/auth" -H 'Accept: application/json' -X "$_m" ${3+"--data-binary"} ${3+"@$TMP/req"} \ ${3+"-H"} ${3+"Content-Type: application/json"} # Only reads are retried: a retried POST claim could answer 409 to its own first attempt. [ "$_m" != GET ] || set -- "$@" --retry 2 --retry-connrefused CODE=$(curl "$@" "$BASE$_p" 2>"$TMP/err") || die "request failed: $_m $_p: $(tr '\n' ' ' <"$TMP/err")" } # call METHOD PATH [JSON]: like api, but dies with the API's error message on non-2xx. call() { api "$@" case $CODE in 2??) return 0 ;; esac fail_http "$1 $2" } fail_http() { _msg=$(jq -r '.error // empty' "$TMP/body" 2>/dev/null || true) [ -n "$_msg" ] || _msg=$(head -c 300 "$TMP/body" | tr '\n' ' ') case $CODE in 401) _hint=" (token missing, wrong, revoked or expired)" ;; 403) _hint=" (token scope too small? inv needs scope agent or write)" ;; 404) _hint=" (no such id in this organization)" ;; 429) _hint=" (rate limited; try again in a minute)" ;; *) _hint="" ;; esac die "$1: HTTP $CODE: $_msg$_hint" } check_id() { # check_id PREFIX VALUE case $2 in "$1"_*) ;; *) die "expected a $1_… id, got: $2" ;; esac case ${2#"$1"_} in *[!0-9a-z]* | "") die "malformed id: $2" ;; esac } # todo_id ARG: accepts todo_… or a todo URL (…/todos/todo_…). todo_id() { _t=${1##*/} check_id todo "$_t" printf '%s' "$_t" } # read_note FILE|-: markdown from a file or stdin, must not be empty, must not contain the token. read_note() { if [ "$1" = - ]; then cat >"$TMP/note"; else [ -r "$1" ] || die "cannot read note file: $1" cat -- "$1" >"$TMP/note" fi [ -n "$(tr -d ' \t\r\n' <"$TMP/note")" ] || die "note is empty" no_token_in "$TMP/note" } no_token_in() { if [ -n "$TOKEN" ] && grep -qF -- "$TOKEN" "$1"; then die "refusing to send text that contains the API token"; fi if grep -qE 'inv_[A-Za-z0-9_-]{20,}' "$1"; then die "refusing to send text that looks like an API token (inv_…)"; fi } uri() { printf '%s' "$1" | jq -sRr @uri; } todo_line='"\(.id)\t\(.priority)\t\(.status)\t\(.app_name)\t\(.title)\(if .link_url != "" then "\t" + .link_url else "" end)"' # --- commands ------------------------------------------------------------------ usage() { cat < [args] next open todos assigned to $ASSIGNEE, highest priority first mine todos $ASSIGNEE is working on (in progress), with PR links show todo, its notes and the app/repo context (repo, monorepo path) claim [-m note] atomically take an open todo (exit 2 if someone else holds it) note append a markdown note (from a file or stdin) pr attach a PR/issue URL to the todo done [-m note] mark the todo done release [-m note] [--to email] give it back: status open, unassigned (or assigned to --to) from-finding todo from a finding (dep:npm/lodash, issue:), assigned to $ASSIGNEE me token name, scope and organization version version of this CLI help this text is a todo id (todo_…) or its URL. Config: INVENTORY_API_BASE, INVENTORY_API_TOKEN (environment, else ${VARS_FILE}). EOF } # parse_opts "$@": parses -m NOTE / --to EMAIL into NOTE / TO. parse_opts() { NOTE="" TO="" while [ $# -gt 0 ]; do case $1 in -m | --note) [ $# -ge 2 ] || die "$1 needs a value"; NOTE=$2; shift 2 ;; --to) [ $# -ge 2 ] || die "--to needs a value"; TO=$2; shift 2 ;; *) die "unknown option: $1" ;; esac done if [ -n "$NOTE" ]; then printf '%s' "$NOTE" >"$TMP/note" && no_token_in "$TMP/note"; fi } cmd_next() { call GET "/todos?assignee=$(uri "$ASSIGNEE")&status=open" jq -r ' def rank: {"urgent":0,"high":1,"normal":2,"low":3}[.priority] // 4; .todos | sort_by(rank, .created_at) | .[] | '"$todo_line" "$TMP/body" } cmd_mine() { call GET "/todos?assignee=$(uri "$ASSIGNEE")&status=in_progress" jq -r ".todos[] | $todo_line" "$TMP/body" } cmd_show() { t=$(todo_id "$1") call GET "/todos/$t" cp "$TMP/body" "$TMP/todo" app=$(jq -r .app "$TMP/todo") check_id app "$app" call GET "/apps/$app" cp "$TMP/body" "$TMP/app" call GET "/apps/$app/repos" jq -r --slurpfile app "$TMP/app" --slurpfile repos "$TMP/body" -f /dev/stdin "$TMP/todo" <<'JQ' ($app[0]) as $a | ($repos[0].repos) as $r | "# \(.title)", "", "id: \(.id)", "status: \(.status) priority: \(.priority) assignee: \(if .assignee == "" then "-" else .assignee end)", "url: \(.url)", (if .link_url != "" then "link: \(.link_url)" else empty end), (if .source_ref != "" then "source: \(.source_ref)" else empty end), "created: \(.created_at) by \(.created_by)", "", "## App: \(.app_name) (\(.app))", (if $a.repo_url != "" then "repo_url: \($a.repo_url)" else empty end), (if $a.app_url != "" then "app_url: \($a.app_url)" else empty end), (if ($a.tags | length) > 0 then "tags: \($a.tags | join(" "))" else empty end), ($r[] | "repo: \(.url) (\(.kind))\(if .path != "" then " monorepo path: \(.path)" else "" end)\(if .label != "" then " label: \(.label)" else "" end)"), (($a.deployments // [])[] | "deployed: \(.environment) on \(.server_name) (\(.hostname))"), (if $a.description != "" then "", $a.description else empty end), "", "## Notes", (.notes[] | "", "### \(.created_at) \(.author)\(if .kind == "event" then " (event)" else "" end)", .body) JQ } cmd_claim() { t=$(todo_id "$1") shift parse_opts "$@" if [ -n "$NOTE" ]; then body=$(jq -n --arg a "$ASSIGNEE" --rawfile n "$TMP/note" '{assignee: $a, note: $n}') else body=$(jq -n --arg a "$ASSIGNEE" '{assignee: $a}') fi api POST "/todos/$t/claim" "$body" case $CODE in 200) jq -r '"claimed \(.id): \(.title)\n\(.url)"' "$TMP/body" ;; 409) jq -r '"inv: \(.error // "todo is not open"); assignee: \(if (.assignee // "") == "" then "-" else .assignee end). Someone else holds it: do not work on it." ' "$TMP/body" >&2 exit 2 ;; *) fail_http "claim $t" ;; esac } cmd_note() { [ $# -eq 2 ] || die "usage: inv note " t=$(todo_id "$1") read_note "$2" call POST "/todos/$t/notes" "$(jq -n --rawfile b "$TMP/note" '{body: $b}')" jq -r '"note \(.id) added"' "$TMP/body" } cmd_pr() { [ $# -eq 2 ] || die "usage: inv pr " t=$(todo_id "$1") case $2 in https://* | http://*) ;; *) die "not an http(s) URL: $2" ;; esac call PUT "/todos/$t/link" "$(jq -n --arg u "$2" '{url: $u}')" jq -r '"linked \(.id) → \(.link_url)"' "$TMP/body" } cmd_done() { t=$(todo_id "$1") shift parse_opts "$@" if [ -n "$NOTE" ]; then body=$(jq -n --rawfile n "$TMP/note" '{status: "done", note: $n}') else body='{"status":"done"}' fi call PATCH "/todos/$t" "$body" jq -r '"\(.id) is \(.status)"' "$TMP/body" } cmd_release() { t=$(todo_id "$1") shift parse_opts "$@" # Unassign by default, so the worker does not pick the same todo up again on its next poll. body=$(jq -n --arg to "$TO" '{status: "open", assignee: $to}') if [ -n "$NOTE" ]; then body=$(printf '%s' "$body" | jq --rawfile n "$TMP/note" '. + {note: $n}'); fi call PATCH "/todos/$t" "$body" jq -r '"\(.id) released: \(.status), assignee \(if .assignee == "" then "-" else .assignee end)"' "$TMP/body" } cmd_from_finding() { [ $# -eq 2 ] || die "usage: inv from-finding " app=${1##*/} check_id app "$app" case $2 in dep:?*/?* | issue:http*) ;; *) die "source must be dep:/ or issue:" ;; esac call POST "/apps/$app/todos/from-finding" "$(jq -n --arg s "$2" --arg a "$ASSIGNEE" '{source: $s, assignee: $a}')" if [ "$CODE" = 201 ]; then w=created; else w=existing; fi jq -r --arg w "$w" '"\($w) \(.id) [\(.priority)] \(.title)\n\(.url)"' "$TMP/body" } cmd_me() { call GET /me jq -r '"token \(.token) (\(.scope)) in org \(.org.name) (\(.org.id))"' "$TMP/body" } [ $# -ge 1 ] || { usage >&2; exit 1; } cmd=$1 shift case $cmd in next | mine | me | version) [ $# -eq 0 ] || die "$cmd takes no arguments" ;; show) [ $# -eq 1 ] || die "usage: inv show " ;; claim | done | release) [ $# -ge 1 ] || die "usage: inv $cmd [-m note]" ;; esac case $cmd in next) cmd_next ;; mine) cmd_mine ;; show) cmd_show "$@" ;; claim) cmd_claim "$@" ;; note) cmd_note "$@" ;; pr) cmd_pr "$@" ;; done) cmd_done "$@" ;; release) cmd_release "$@" ;; from-finding) cmd_from_finding "$@" ;; me) cmd_me ;; version | --version) printf 'inv %s\n' "$INV_VERSION" ;; help | -h | --help) usage ;; *) die "unknown command: $cmd (try: inv help)" ;; esac