#!/usr/bin/env bash # # auto-issue.sh — drive Claude Code tasks from GitHub issues. # # Poll a GitHub repo for issues carrying a trigger label, propose a work plan # with `claude -p`, and once you approve it (via a label) build the change, # open a PR, merge it, and close the issue. State lives in GitHub labels so it # works from anywhere you have GitHub access. # # Run `auto-issue setup` once, then `auto-issue register` inside each repo you # want to enable. A single global loop / systemd service handles all repos. set -uo pipefail # --------------------------------------------------------------------------- # Resolve our own location (used for the systemd unit's ExecStart). # --------------------------------------------------------------------------- SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")" # --------------------------------------------------------------------------- # Configuration (all overridable via env or ~/.auto-issue.env). # --------------------------------------------------------------------------- ENV_FILE="${AUTO_ISSUE_ENV_FILE:-$HOME/.auto-issue.env}" # Load the env file early so the values below can come from it. if [[ -f "$ENV_FILE" ]]; then set -a # shellcheck disable=SC1090 . "$ENV_FILE" set +a fi # Trigger: only issues with this label are processed. Empty => all open issues. BOT_LABEL="${BOT_LABEL-bot}" # State labels (the workflow state machine). LABEL_PLAN="${LABEL_PLAN:-plan-proposed}" LABEL_APPROVED="${LABEL_APPROVED:-approved}" LABEL_HALTED="${LABEL_HALTED:-halted}" LABEL_DONE="${LABEL_DONE:-done}" # Model selection. Default model for all work; if an issue carries # MODEL_OPUS_LABEL, that issue uses MODEL_OPUS instead. MODEL_DEFAULT="${MODEL_DEFAULT:-sonnet}" MODEL_OPUS="${MODEL_OPUS:-opus}" MODEL_OPUS_LABEL="${MODEL_OPUS_LABEL:-opus}" # Loop pacing. INTERVAL_MIN="${INTERVAL_MIN:-1}" # minutes — sleep after doing work (fast lane) INTERVAL_MAX="${INTERVAL_MAX:-20}" # minutes — backoff ceiling when idle BACKOFF_FACTOR="${BACKOFF_FACTOR:-2}" # multiply sleep by this each idle round MAX_PER_CYCLE="${MAX_PER_CYCLE:-10}" # max Claude actions per cycle COOLDOWN="${COOLDOWN:-20}" # seconds between Claude invocations CLAUDE_MAX_TURNS="${CLAUDE_MAX_TURNS:-40}" # Git / PR behaviour. WORK_BRANCH_PREFIX="${WORK_BRANCH_PREFIX:-auto-issue/}" MERGE_METHOD="${MERGE_METHOD:-squash}" # squash | merge | rebase TARGET_BRANCH="${TARGET_BRANCH:-}" # empty => repo default branch # Behaviour switches. DRY_RUN="${DRY_RUN:-0}" # 1 => log actions, never spawn Claude or mutate # Marker that tags every comment we author, so we can tell our own comments # apart from human instructions regardless of which account posts them. BOT_MARKER="" # --------------------------------------------------------------------------- # Global config dir (user-space, not per-repo). # --------------------------------------------------------------------------- GLOBAL_DIR="$HOME/.auto-issue" REGISTRY="$GLOBAL_DIR/repos" # Populated at runtime by detect_repo. REPO="" DEFAULT_BRANCH="" REPO_ROOT="" STATE_DIR="" # set per-repo inside run_cycle_for_repo # --------------------------------------------------------------------------- # Output helpers. # --------------------------------------------------------------------------- c_reset=$'\e[0m'; c_bold=$'\e[1m'; c_dim=$'\e[2m' c_red=$'\e[31m'; c_grn=$'\e[32m'; c_ylw=$'\e[33m'; c_blu=$'\e[34m'; c_cyn=$'\e[36m' _ts() { date '+%Y-%m-%d %H:%M:%S'; } log() { printf '%s %s\n' "$(_ts)" "$*"; } info() { printf '%s %sℹ%s %s\n' "$(_ts)" "$c_cyn" "$c_reset" "$*"; } ok() { printf '%s %s✓%s %s\n' "$(_ts)" "$c_grn" "$c_reset" "$*"; } warn() { printf '%s %s!%s %s\n' "$(_ts)" "$c_ylw" "$c_reset" "$*" >&2; } err() { printf '%s %s✗%s %s\n' "$(_ts)" "$c_red" "$c_reset" "$*" >&2; } die() { err "$*"; exit 1; } # --------------------------------------------------------------------------- # Repo detection & GitHub plumbing. # --------------------------------------------------------------------------- # Make every gh call use the write-capable token, if provided. setup_gh_auth() { if [[ -n "${AUTO_ISSUE_GH_TOKEN:-}" ]]; then export GH_TOKEN="$AUTO_ISSUE_GH_TOKEN" fi } # Confirm a path is inside a git repo that gh recognises as a GitHub repo. # Accepts an optional path argument (default: $PWD). # Sets REPO, DEFAULT_BRANCH, REPO_ROOT. Returns non-zero otherwise. detect_repo() { local path="${1:-$PWD}" REPO_ROOT="$(git -C "$path" rev-parse --show-toplevel 2>/dev/null)" || return 1 local json json="$(cd "$REPO_ROOT" && gh repo view --json nameWithOwner,defaultBranchRef 2>/dev/null)" || return 1 REPO="$(jq -r '.nameWithOwner // empty' <<<"$json")" DEFAULT_BRANCH="$(jq -r '.defaultBranchRef.name // empty' <<<"$json")" [[ -n "$REPO" ]] || return 1 [[ -n "$TARGET_BRANCH" ]] || TARGET_BRANCH="$DEFAULT_BRANCH" return 0 } require_repo() { setup_gh_auth detect_repo || die "Not a GitHub repository (gh can't resolve a repo here). Nothing to do." } # Confirm the active token can actually write (push/triage). token_can_write() { local perms perms="$(gh api "repos/$REPO" -q '.permissions.push' 2>/dev/null)" [[ "$perms" == "true" ]] } # --------------------------------------------------------------------------- # Global state dir helpers (replaces per-repo .auto-issue/). # --------------------------------------------------------------------------- # Create the global config dir and state subdir. ensure_global_dir() { mkdir -p "$GLOBAL_DIR/state" } # Return the per-repo state dir for the given REPO_ROOT path. global_state_dir() { local rroot="$1" local slug slug="$(printf '%s' "$rroot" | tr '/' '_')" printf '%s/state/%s' "$GLOBAL_DIR" "$slug" } # --------------------------------------------------------------------------- # Labels. # --------------------------------------------------------------------------- # label name|color|description managed_labels() { cat </dev/null 2>&1 \ && info "label ok: $name" || warn "could not ensure label: $name" done < <(managed_labels) } # --------------------------------------------------------------------------- # Claude prompts (edit these to tune behaviour). # --------------------------------------------------------------------------- plan_prompt() { local title="$1" body="$2" cat < Keep the bar HIGH — reject only when clearly warranted. --- ISSUE: ${title} --- ${body} EOF } rework_prompt() { local title="$1" body="$2" prev_plan="$3" instructions="$4" cat <&2 echo "[dry-run plan placeholder]" return 0 fi local out errfile rc errfile="$(mktemp)" out="$(printf '%s' "$prompt" | claude -p \ --model "$model" \ --permission-mode bypassPermissions \ --max-turns "$CLAUDE_MAX_TURNS" \ --output-format json 2>"$errfile")" rc=$? if (( rc != 0 )); then err "claude invocation failed (exit $rc): $(tr '\n' ' ' <"$errfile" | head -c 500)" rm -f "$errfile" return 1 fi rm -f "$errfile" if [[ "$(jq -r '.is_error // false' <<<"$out" 2>/dev/null)" == "true" ]]; then err "claude reported an error: $(jq -r '.result // .error // "unknown"' <<<"$out")" return 1 fi jq -r '.result // empty' <<<"$out" 2>/dev/null || printf '%s' "$out" } # Resolve which model an issue should use, based on its labels. model_for_labels() { local labels_csv="$1" if grep -qiw "$MODEL_OPUS_LABEL" <<<"$labels_csv"; then echo "$MODEL_OPUS" else echo "$MODEL_DEFAULT" fi } # --------------------------------------------------------------------------- # Issue helpers. # --------------------------------------------------------------------------- issue_has_label() { grep -qiw -- "$2" <<<"$1"; } post_comment() { local num="$1" body="$2" local full="${BOT_MARKER} ${body}" if [[ "$DRY_RUN" == "1" ]]; then warn "[dry-run] would comment on #$num"; return 0; fi gh issue comment "$num" --body "$full" >/dev/null } add_label() { [[ "$DRY_RUN" == "1" ]] && { warn "[dry-run] +label $2 on #$1"; return 0; }; gh issue edit "$1" --add-label "$2" >/dev/null; } remove_label() { [[ "$DRY_RUN" == "1" ]] && { warn "[dry-run] -label $2 on #$1"; return 0; }; gh issue edit "$1" --remove-label "$2" >/dev/null 2>&1; } # Most recent human (unmarked) comment newer than our last marked comment. # Echoes the concatenated instruction text (empty if none). new_instructions_since_plan() { local num="$1" json json="$(gh issue view "$num" --json comments 2>/dev/null)" || return 0 jq -r --arg marker "$BOT_MARKER" ' (.comments // []) as $c | ($c | map(select(.body | contains($marker))) | max_by(.createdAt) | .createdAt) as $lastbot | $c | map(select((.body | contains($marker)) | not)) | map(select($lastbot == null or (.createdAt > $lastbot))) | map(.body) | join("\n\n---\n\n") ' <<<"$json" } # Latest plan we proposed (marker stripped). last_plan_body() { local num="$1" json json="$(gh issue view "$num" --json comments 2>/dev/null)" || return 0 jq -r --arg marker "$BOT_MARKER" ' (.comments // []) | map(select(.body | contains($marker))) | max_by(.createdAt) | .body // "" ' <<<"$json" | sed "s|${BOT_MARKER}||" } # --------------------------------------------------------------------------- # Actions. # --------------------------------------------------------------------------- action_propose_plan() { local num="$1" title="$2" body="$3" model="$4" info "#$num: proposing plan (model=$model)" local plan plan="$(run_claude "$model" "$(plan_prompt "$title" "$body")")" || { warn "#$num: plan generation failed"; return 1; } [[ -n "$plan" ]] || { warn "#$num: empty plan, skipping"; return 1; } # Check for rejection sentinel (first line only, for unambiguous matching). local firstline firstline="$(head -n1 <<<"$plan")" if [[ "$firstline" == REJECT:* ]]; then local reason="${firstline#REJECT:}" reason="${reason#"${reason%%[! ]*}"}" # ltrim whitespace post_comment "$num" "## 🚫 Issue not actioned **Reason:** ${reason} Add \`${LABEL_APPROVED}\` to override and force a work plan, or remove \`${BOT_LABEL}\` and \`${LABEL_PLAN}\` labels to fully reset." add_label "$num" "$LABEL_HALTED" ok "#$num: rejected — '$reason'; labelled '$LABEL_HALTED'" return 0 fi post_comment "$num" "## 🤖 Proposed work plan ${plan} --- **How to proceed:** add the \`${LABEL_APPROVED}\` label to build this, comment with changes to revise it, or add \`${LABEL_HALTED}\` to stop." add_label "$num" "$LABEL_PLAN" ok "#$num: plan posted, labelled '$LABEL_PLAN'" } action_rework_plan() { local num="$1" title="$2" body="$3" model="$4" instructions="$5" info "#$num: reworking plan from new feedback (model=$model)" local prev plan prev="$(last_plan_body "$num")" plan="$(run_claude "$model" "$(rework_prompt "$title" "$body" "$prev" "$instructions")")" || { warn "#$num: rework failed"; return 1; } [[ -n "$plan" ]] || { warn "#$num: empty revised plan"; return 1; } post_comment "$num" "## 🤖 Revised work plan ${plan} --- **How to proceed:** add the \`${LABEL_APPROVED}\` label to build this, comment with more changes to revise again, or add \`${LABEL_HALTED}\` to stop." ok "#$num: revised plan posted" } action_build() { local num="$1" title="$2" body="$3" model="$4" info "#$num: building approved plan (model=$model)" local plan branch plan="$(last_plan_body "$num")" [[ -n "$plan" ]] || { warn "#$num: no plan found to build; skipping"; return 1; } branch="${WORK_BRANCH_PREFIX}${num}" if [[ "$DRY_RUN" == "1" ]]; then warn "[dry-run] would: branch $branch from origin/$TARGET_BRANCH, run claude build, push, PR, merge, close #$num" return 0 fi # Fresh branch from the latest target, so re-runs never carry stale state. git fetch origin "$TARGET_BRANCH" >/dev/null 2>&1 || { err "#$num: git fetch failed"; return 1; } git checkout -B "$branch" "origin/$TARGET_BRANCH" >/dev/null 2>&1 || { err "#$num: cannot create branch $branch"; return 1; } local base_sha; base_sha="$(git rev-parse HEAD)" if ! run_claude "$model" "$(build_prompt "$num" "$title" "$body" "$plan")" >/dev/null; then err "#$num: build run failed; leaving branch $branch for inspection" git checkout "$TARGET_BRANCH" >/dev/null 2>&1 return 1 fi if [[ "$(git rev-parse HEAD)" == "$base_sha" ]]; then warn "#$num: Claude produced no commits; nothing to push" git checkout "$TARGET_BRANCH" >/dev/null 2>&1 return 1 fi git push -u origin "$branch" --force-with-lease >/dev/null 2>&1 || { err "#$num: push failed"; return 1; } # Reuse an existing PR for this branch if present, else create one. local pr_url pr_url="$(gh pr view "$branch" --json url -q '.url' 2>/dev/null)" if [[ -z "$pr_url" ]]; then pr_url="$(gh pr create --base "$TARGET_BRANCH" --head "$branch" \ --title "$title (#$num)" \ --body "Automated implementation for #$num. Closes #$num" 2>/dev/null)" || { err "#$num: PR create failed"; return 1; } fi if gh pr merge "$branch" "--$MERGE_METHOD" --delete-branch >/dev/null 2>&1 \ || gh pr merge "$branch" "--$MERGE_METHOD" --delete-branch --admin >/dev/null 2>&1; then ok "#$num: PR merged ($pr_url)" else err "#$num: merge failed; PR left open: $pr_url" git checkout "$TARGET_BRANCH" >/dev/null 2>&1 return 1 fi # Return to and refresh the target branch. git checkout "$TARGET_BRANCH" >/dev/null 2>&1 git pull --ff-only origin "$TARGET_BRANCH" >/dev/null 2>&1 # Finish: done label + summary + close. remove_label "$num" "$LABEL_APPROVED" remove_label "$num" "$LABEL_PLAN" add_label "$num" "$LABEL_DONE" local sha; sha="$(git rev-parse --short HEAD)" post_comment "$num" "## ✅ Done Implemented and merged into \`${TARGET_BRANCH}\` (\`${sha}\`). PR: ${pr_url}" gh issue close "$num" --reason completed >/dev/null 2>&1 ok "#$num: completed and closed" } # --------------------------------------------------------------------------- # One poll cycle (runs in the CWD repo context set by run_cycle_for_repo). # --------------------------------------------------------------------------- run_cycle() { local label_args=() [[ -n "$BOT_LABEL" ]] && label_args=(--label "$BOT_LABEL") local issues issues="$(gh issue list --state open --limit 100 "${label_args[@]}" \ --json number,title,body,labels 2>/dev/null)" || { warn "cycle: gh issue list failed"; return 0; } local count; count="$(jq 'length' <<<"$issues")" info "cycle: $count candidate issue(s)" local actions=0 i num title body labels_csv model instr for ((i=0; i= MAX_PER_CYCLE )) && { warn "MAX_PER_CYCLE=$MAX_PER_CYCLE reached; remaining issues wait for next cycle"; break; } num="$(jq -r ".[$i].number" <<<"$issues")" title="$(jq -r ".[$i].title" <<<"$issues")" body="$(jq -r ".[$i].body // \"\"" <<<"$issues")" labels_csv="$(jq -r ".[$i].labels | map(.name) | join(\",\")" <<<"$issues")" model="$(model_for_labels "$labels_csv")" if issue_has_label "$labels_csv" "$LABEL_HALTED"; then info "#$num: halted — skipping"; continue fi if issue_has_label "$labels_csv" "$LABEL_DONE"; then continue fi local did=0 if issue_has_label "$labels_csv" "$LABEL_APPROVED"; then action_build "$num" "$title" "$body" "$model" && did=1 || did=1 elif issue_has_label "$labels_csv" "$LABEL_PLAN"; then instr="$(new_instructions_since_plan "$num")" if [[ -n "${instr// /}" ]]; then action_rework_plan "$num" "$title" "$body" "$model" "$instr" && did=1 || did=1 else info "#$num: awaiting approval or feedback" fi else action_propose_plan "$num" "$title" "$body" "$model" && did=1 || did=1 fi if (( did )); then actions=$((actions+1)) (( actions < MAX_PER_CYCLE )) && { info "cooldown ${COOLDOWN}s"; sleep "$COOLDOWN"; } fi done date -u '+%Y-%m-%dT%H:%M:%SZ' >"$STATE_DIR/last-check" echo "$actions" >"$STATE_DIR/last-actions" info "cycle complete: $actions action(s) taken" } # --------------------------------------------------------------------------- # Run one poll cycle for a given repo path. # Runs in a subshell so CWD changes and variable mutations don't leak. # --------------------------------------------------------------------------- run_cycle_for_repo() { local rpath="$1" ( cd "$rpath" || { warn "cannot cd to '$rpath'"; return 1; } detect_repo || { warn "cannot detect repo at '$rpath'; is it a GitHub repository?"; return 1; } STATE_DIR="$(global_state_dir "$REPO_ROOT")" mkdir -p "$STATE_DIR" ensure_labels info "==> repo: ${c_bold}$REPO${c_reset} ($REPO_ROOT)" token_can_write || warn "active token cannot push to $REPO — builds/labels will fail. Fix AUTO_ISSUE_GH_TOKEN." run_cycle ) } # --------------------------------------------------------------------------- # Quick activity probe — queries GitHub updated_at for each registered repo. # Returns 0 (activity found in ≥1 repo) or 1 (nothing new anywhere). # Fails open: returns 0 on missing last-check or API error. # --------------------------------------------------------------------------- quick_check_any_activity() { local any=1 # start assuming no activity (exit 1) while IFS= read -r rpath; do [[ -n "$rpath" ]] || continue [[ -d "$rpath" ]] || continue local rroot rroot="$(git -C "$rpath" rev-parse --show-toplevel 2>/dev/null)" || { any=0; break; } local sdir; sdir="$(global_state_dir "$rroot")" if [[ ! -f "$sdir/last-check" ]]; then any=0; break # no checkpoint → fail-open, run a full cycle fi local last_iso; last_iso="$(cat "$sdir/last-check")" [[ -z "$last_iso" ]] && { any=0; break; } local repo_name repo_name="$(cd "$rroot" && gh repo view --json nameWithOwner -q '.nameWithOwner' 2>/dev/null)" \ || { any=0; break; } local result cnt result="$(gh api "/repos/${repo_name}/issues?state=open&since=${last_iso}&per_page=1" 2>/dev/null)" \ || { any=0; break; } cnt="$(jq 'length' <<<"$result" 2>/dev/null)" || { any=0; break; } if [[ "${cnt:-0}" -gt 0 ]]; then any=0; break # activity in this repo fi done < "$REGISTRY" return "$any" } # --------------------------------------------------------------------------- # Loop runner (foreground; also the systemd ExecStart target). # --------------------------------------------------------------------------- cmd_loop() { setup_gh_auth ensure_global_dir [[ -s "$REGISTRY" ]] || die "No repos registered. Run: auto-issue register" info "auto-issue global loop started — min ${INTERVAL_MIN}m / max ${INTERVAL_MAX}m / backoff ×${BACKOFF_FACTOR}" while IFS= read -r rpath; do [[ -n "$rpath" ]] && info " registered: $rpath" done < "$REGISTRY" local _total_file; _total_file="$GLOBAL_DIR/last-cycle-total" local current_sleep=$(( INTERVAL_MIN * 60 )) local interval_max_s=$(( INTERVAL_MAX * 60 )) trap 'echo; warn "stopping auto-issue loop"; exit 0' INT TERM while true; do if ! quick_check_any_activity; then local new_sleep=$(( current_sleep * BACKOFF_FACTOR )) (( new_sleep > interval_max_s )) && new_sleep=$interval_max_s current_sleep=$new_sleep info "quick-check: no new activity — sleeping $(( current_sleep / 60 ))m" sleep "$current_sleep" continue fi echo 0 > "$_total_file" ( flock -n 9 || { warn "another auto-issue runner holds the lock; skipping cycle"; exit 0; } local _total=0 while IFS= read -r rpath; do [[ -n "$rpath" ]] || continue [[ -d "$rpath" ]] || { warn "registered path missing: $rpath"; continue; } run_cycle_for_repo "$rpath" local rroot; rroot="$(git -C "$rpath" rev-parse --show-toplevel 2>/dev/null)" || continue local sdir; sdir="$(global_state_dir "$rroot")" local cnt=0 [[ -f "$sdir/last-actions" ]] && cnt="$(cat "$sdir/last-actions")" _total=$(( _total + cnt )) done < "$REGISTRY" echo "$_total" > "$_total_file" ) 9>"$GLOBAL_DIR/lock" local cycle_actions=0 [[ -f "$_total_file" ]] && cycle_actions="$(cat "$_total_file")" if (( cycle_actions > 0 )); then current_sleep=$(( INTERVAL_MIN * 60 )) info "cycle did work — resetting sleep to ${INTERVAL_MIN}m" else local new_sleep=$(( current_sleep * BACKOFF_FACTOR )) (( new_sleep > interval_max_s )) && new_sleep=$interval_max_s current_sleep=$new_sleep info "cycle: no actionable issues — sleeping $(( current_sleep / 60 ))m" fi sleep "$current_sleep" done } cmd_once() { setup_gh_auth ensure_global_dir [[ -s "$REGISTRY" ]] || die "No repos registered. Run: auto-issue register" info "auto-issue single cycle${DRY_RUN:+ (DRY_RUN=$DRY_RUN)}" ( flock -n 9 || die "another auto-issue runner holds the lock" while IFS= read -r rpath; do [[ -n "$rpath" ]] || continue [[ -d "$rpath" ]] || { warn "registered path missing: $rpath"; continue; } run_cycle_for_repo "$rpath" done < "$REGISTRY" ) 9>"$GLOBAL_DIR/lock" } # --------------------------------------------------------------------------- # Registry management. # --------------------------------------------------------------------------- cmd_register() { local path="${1:-$PWD}" [[ -n "$path" ]] || path="$PWD" path="$(readlink -f "$path")" setup_gh_auth detect_repo "$path" || die "Not a GitHub repository at '$path'" ensure_global_dir # Check if already registered. if [[ -f "$REGISTRY" ]] && grep -qxF "$REPO_ROOT" "$REGISTRY"; then ok "$REPO ($REPO_ROOT) is already registered" return 0 fi printf '%s\n' "$REPO_ROOT" >> "$REGISTRY" ok "registered $REPO ($REPO_ROOT)" if prompt_yn "Create/refresh workflow labels in $REPO now?" y; then ensure_labels fi # Migration: warn about old per-repo state dir. if [[ -d "$REPO_ROOT/.auto-issue" ]]; then warn "Found old per-repo state dir: $REPO_ROOT/.auto-issue" warn "You can safely remove it: rm -rf $REPO_ROOT/.auto-issue" fi # Migration: warn about old per-repo systemd service. local old_svc; old_svc="auto-issue-$(printf '%s' "$(basename "$REPO_ROOT")" | tr -c 'A-Za-z0-9_.-' '-')" if systemctl --user is-enabled "${old_svc}.service" >/dev/null 2>&1 \ || systemctl --user is-active "${old_svc}.service" >/dev/null 2>&1; then warn "Old per-repo service '${old_svc}' is still installed." warn "Disable it to avoid double-processing:" warn " systemctl --user disable --now ${old_svc}.service" fi } cmd_unregister() { local path="${1:-$PWD}" [[ -n "$path" ]] || path="$PWD" path="$(readlink -f "$path")" # Resolve to git root if possible, fall back to the given path. local rroot rroot="$(git -C "$path" rev-parse --show-toplevel 2>/dev/null)" || rroot="$path" if [[ ! -f "$REGISTRY" ]] || ! grep -qxF "$rroot" "$REGISTRY"; then warn "'$rroot' is not in the registry" return 1 fi local tmp; tmp="$(mktemp)" grep -vxF "$rroot" "$REGISTRY" > "$tmp" && mv "$tmp" "$REGISTRY" ok "unregistered $rroot" } cmd_repos() { local svc_state="stopped" service_running && svc_state="${c_grn}running${c_reset}" echo "${c_bold}Global service:${c_reset} $(service_name) — $svc_state" echo if [[ ! -f "$REGISTRY" ]] || [[ ! -s "$REGISTRY" ]]; then info "No repos registered. Run: auto-issue register" return 0 fi echo "${c_bold}Registered repos:${c_reset}" while IFS= read -r rpath; do [[ -n "$rpath" ]] || continue if [[ -d "$rpath" ]]; then printf ' %s\n' "$rpath" else printf ' %s %s\n' "$rpath" "${c_ylw}(directory not found)${c_reset}" fi done < "$REGISTRY" } # --------------------------------------------------------------------------- # systemd user service management (single global service). # --------------------------------------------------------------------------- service_name() { echo "auto-issue"; } service_unit() { echo "$HOME/.config/systemd/user/auto-issue.service"; } service_running() { systemctl --user is-active --quiet "$(service_name).service" 2>/dev/null; } # systemd user services start with a minimal PATH (no ~/.local/bin, no nvm), # so `claude` and friends aren't found at runtime. Build a PATH from the # install-time locations of the tools we shell out to, then the system dirs. service_path() { local t d p seen="" out="" for p in "$(command -v claude)" "$(command -v gh)" "$(command -v git)" \ "$(command -v jq)" "$(command -v flock)" "$(command -v node)"; do [[ -n "$p" ]] || continue d="$(dirname "$p")" case ":$seen:" in *":$d:"*) continue ;; esac seen="${seen:+$seen:}$d"; out="${out:+$out:}$d" done out="${out:+$out:}/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" printf '%s' "$out" } write_unit() { local unit; unit="$(service_unit)" mkdir -p "$(dirname "$unit")" cat >"$unit" <" local name; name="$(service_name)" if service_running; then info "replacing running service '$name' (config refresh)" systemctl --user stop "$name.service" >/dev/null 2>&1 fi write_unit systemctl --user daemon-reload systemctl --user enable "$name.service" >/dev/null 2>&1 systemctl --user restart "$name.service" if [[ "$(loginctl show-user "$USER" -p Linger --value 2>/dev/null)" != "yes" ]]; then loginctl enable-linger "$USER" >/dev/null 2>&1 \ && info "enabled linger (service will start on boot)" \ || warn "could not enable linger; service won't auto-start on boot. Run: loginctl enable-linger $USER" fi ok "service '$name' started. Logs: ${c_bold}auto-issue logs${c_reset}" } cmd_stop_service() { local name; name="$(service_name)" systemctl --user stop "$name.service" >/dev/null 2>&1 && ok "stopped $name" || warn "$name was not running" } cmd_disable_service() { local name unit; name="$(service_name)"; unit="$(service_unit)" systemctl --user stop "$name.service" >/dev/null 2>&1 systemctl --user disable "$name.service" >/dev/null 2>&1 rm -f "$unit" systemctl --user daemon-reload ok "removed service '$name'" } cmd_status_service() { systemctl --user status "$(service_name).service" --no-pager 2>&1 | head -20 || true } cmd_logs() { journalctl --user -u "$(service_name).service" -f --no-hostname } # Show the global service state and all registered repos. cmd_list_services() { local name; name="$(service_name)" local state; state="$(systemctl --user is-active "$name.service" 2>/dev/null)" local color case "$state" in active) color="$c_grn" ;; failed) color="$c_red" ;; *) color="$c_dim" ;; esac printf '%s%-10s%s (%s)\n' "$color" "$state" "$c_reset" "$name" echo cmd_repos } # --------------------------------------------------------------------------- # setup: guided one-time setup so `auto-issue` works from any GitHub folder. # 1. check dependencies 2. token + env file 3. install the command # 4. register a repo # --------------------------------------------------------------------------- # Prompt yes/no with a default; auto-answers the default if not interactive. prompt_yn() { local q="$1" def="${2:-y}" ans if [[ ! -t 0 ]]; then [[ "$def" == "y" ]]; return; fi local hint="[Y/n]"; [[ "$def" == "n" ]] && hint="[y/N]" printf '%s %s ' "$q" "$hint"; read -r ans ans="${ans:-$def}" [[ "$ans" =~ ^[Yy] ]] } setup_check_deps() { echo "${c_bold}1. Dependencies${c_reset}" local missing=0 tool for tool in git gh claude jq flock; do if command -v "$tool" >/dev/null 2>&1; then ok "$tool found" else err "$tool NOT found"; missing=1 fi done (( missing == 0 )) || warn "install the missing tools above before running the bot" echo } setup_token() { echo "${c_bold}2. GitHub token${c_reset}" setup_gh_auth # If we already have a working write token, we're done. if [[ -n "${AUTO_ISSUE_GH_TOKEN:-}" ]] && detect_repo && token_can_write; then ok "AUTO_ISSUE_GH_TOKEN works and can write to $REPO" echo; return fi if [[ -f "$ENV_FILE" ]] && grep -q '^AUTO_ISSUE_GH_TOKEN=' "$ENV_FILE"; then ok "env file already defines AUTO_ISSUE_GH_TOKEN: $ENV_FILE" [[ -n "$REPO" ]] && ! token_can_write && \ warn "but it can't write to $REPO — check the token's repo scope & permissions" echo; return fi cat <"$ENV_FILE" ) chmod 600 "$ENV_FILE" export AUTO_ISSUE_GH_TOKEN="$tok" GH_TOKEN="$tok" ok "wrote $ENV_FILE (chmod 600)" if detect_repo; then token_can_write && ok "verified: token can write to $REPO" \ || err "token still cannot write to $REPO — check scope/permissions" fi else warn "no token entered; skipping" fi else warn "skipped — create $ENV_FILE later with: AUTO_ISSUE_GH_TOKEN=github_pat_..." fi echo } setup_install_command() { echo "${c_bold}3. Install the 'auto-issue' command${c_reset}" local bindir="$HOME/.local/bin" link="$HOME/.local/bin/auto-issue" if ! prompt_yn "Install 'auto-issue' into $bindir so it runs from anywhere?" y; then warn "skipped — invoke it directly via $SCRIPT_PATH" echo; return fi mkdir -p "$bindir" ln -sf "$SCRIPT_PATH" "$link" ok "installed: $link -> $SCRIPT_PATH" if echo ":$PATH:" | grep -q ":$bindir:"; then ok "$bindir is on PATH" else warn "$bindir is not on PATH." if prompt_yn "Append 'export PATH=\$HOME/.local/bin:\$PATH' to ~/.bashrc?" y; then printf '\n# added by auto-issue setup\nexport PATH="$HOME/.local/bin:$PATH"\n' >>"$HOME/.bashrc" ok "added to ~/.bashrc — run 'source ~/.bashrc' or open a new shell" fi fi echo } cmd_setup() { echo "${c_bold}── auto-issue setup ──${c_reset}" echo setup_check_deps setup_token setup_install_command ensure_global_dir echo "${c_bold}4. Register a repo${c_reset}" setup_gh_auth if detect_repo; then if prompt_yn "Register the current repo ($REPO) now?" y; then cmd_register "$PWD" fi else info "Not in a GitHub repo — run '${c_bold}auto-issue register${c_reset}' inside a repo to register it" fi echo ok "Setup complete. Run: ${c_bold}auto-issue repos${c_reset} to see registered repos" } # --------------------------------------------------------------------------- # info: show resolved configuration (read-only). # --------------------------------------------------------------------------- cmd_info() { setup_gh_auth echo "${c_bold}auto-issue configuration${c_reset}" echo " env file $([[ -f "$ENV_FILE" ]] && echo "$ENV_FILE" || echo "${c_ylw}missing${c_reset}")" echo " global dir $GLOBAL_DIR" echo " registry $REGISTRY" echo " dry run $DRY_RUN" echo " interval min ${INTERVAL_MIN}m / max ${INTERVAL_MAX}m / backoff ×${BACKOFF_FACTOR}, max ${MAX_PER_CYCLE}/cycle, cooldown ${COOLDOWN}s" echo " model $MODEL_DEFAULT (label '$MODEL_OPUS_LABEL' ⇒ $MODEL_OPUS)" echo " merge method $MERGE_METHOD, branch prefix '$WORK_BRANCH_PREFIX'" local svc="not installed" service_running && svc="${c_grn}running${c_reset}" \ || { [[ -f "$(service_unit)" ]] && svc="installed (stopped)"; } echo " service $svc ($(service_name))" echo if [[ -f "$REGISTRY" ]] && [[ -s "$REGISTRY" ]]; then echo "${c_bold}Registered repos:${c_reset}" while IFS= read -r rpath; do [[ -n "$rpath" ]] || continue if [[ -d "$rpath" ]]; then printf ' %s\n' "$rpath" else printf ' %s %s\n' "$rpath" "${c_ylw}(missing)${c_reset}" fi done < "$REGISTRY" else info "No repos registered. Run: auto-issue register" fi echo # If we happen to be in a repo, show per-repo details too. if detect_repo; then local writeable="no"; token_can_write && writeable="yes" echo "${c_bold}Current repo:${c_reset} $REPO" echo " repo root $REPO_ROOT" echo " target branch $TARGET_BRANCH (default: $DEFAULT_BRANCH)" echo " trigger label ${BOT_LABEL:-}" echo " state labels $LABEL_PLAN → $LABEL_APPROVED / $LABEL_HALTED → $LABEL_DONE" echo " token write? $([[ "$writeable" == yes ]] && echo "${c_grn}yes${c_reset}" || echo "${c_red}no — fix AUTO_ISSUE_GH_TOKEN${c_reset}")" fi } # --------------------------------------------------------------------------- # Interactive launcher (default when run with no subcommand). # --------------------------------------------------------------------------- cmd_interactive() { setup_gh_auth echo cmd_info echo # If no repos registered yet, offer to register the current one. if [[ ! -s "$REGISTRY" ]]; then if detect_repo; then if prompt_yn "Register $REPO for auto-issue now?" y; then cmd_register "$PWD" else info "Run 'auto-issue register' inside any GitHub repo to get started" exit 0 fi else die "No repos registered and not in a GitHub repo. cd into a repo and run: auto-issue register" fi else # We have registered repos; offer to also register current repo if not already in. if detect_repo && ! grep -qxF "$REPO_ROOT" "$REGISTRY" 2>/dev/null; then if prompt_yn "Register current repo ($REPO) too?" n; then cmd_register "$PWD" fi fi fi local running_note="" service_running && running_note=" ${c_ylw}(background service already running)${c_reset}" echo "${c_bold}How do you want to run it?${c_reset}$running_note" echo " ${c_grn}1${c_reset}) Foreground ${c_dim}— runs here, Ctrl-C to stop. ${c_bold}Recommended for the first try.${c_reset}" echo " ${c_blu}2${c_reset}) Background ${c_dim}— systemd service, auto-restart$( [[ -n "$running_note" ]] && echo ", replaces the running one" ).${c_reset}" echo " 3) Cancel" printf 'Choice [1]: ' local choice; read -r choice choice="${choice:-1}" case "$choice" in 1) cmd_loop ;; 2) cmd_start_service ;; *) info "cancelled"; exit 0 ;; esac } # --------------------------------------------------------------------------- # Usage. # --------------------------------------------------------------------------- usage() { cat <