#!/usr/bin/env bash set -euo pipefail # ----------------------- # Helpers: logging # ----------------------- log() { printf "[%s] [%s] %s\n" "$(date -Iseconds)" "$1" "$2"; } # ----------------------- # Read env / inputs # ----------------------- TOKEN="${TOKEN:?Missing TOKEN}" API_URL="${API_URL:?Missing API_URL}" REPO_OWNER="${REPO_OWNER:?Missing REPO_OWNER}" REPO_NAME="${REPO_NAME:?Missing REPO_NAME}" APPROVERS="${APPROVERS:?Missing APPROVERS}" APPROVAL_KEYWORDS="${APPROVAL_KEYWORDS:?Missing APPROVAL_KEYWORDS}" DENIAL_KEYWORDS="${DENIAL_KEYWORDS:?Missing DENIAL_KEYWORDS}" ENABLE_REACTIONS="${ENABLE_REACTIONS:-true}" POLL_INTERVAL="${POLL_INTERVAL:-10}" REMINDER_INTERVAL="${REMINDER_INTERVAL:-0}" APPRISE_API_URL="${APPRISE_API_URL:-}" INITIAL_COMMENT="${INITIAL_COMMENT:-}" AUTO_CLOSE="${AUTO_CLOSE:-true}" ISSUE_TITLE_INPUT="${ISSUE_TITLE:-}" REPO="${REPO_OWNER}/${REPO_NAME}" log INFO "Starting wait-for-approval" log INFO "Repo: $REPO" log INFO "API root: $API_URL" log INFO "Approvers: $APPROVERS" log INFO "Enable reactions: $ENABLE_REACTIONS" log INFO "Poll interval: ${POLL_INTERVAL}s, reminder interval: ${REMINDER_INTERVAL}s" log INFO "Auto-close: $AUTO_CLOSE" # ----------------------- # Utilities # ----------------------- escape_regex() { sed -e 's/[]\/$*.^|[]/\\&/g' <<< "$1" } # Convert CSV string to bash array (trim whitespace) csv_to_array() { local csv="$1" local -n out=$2 IFS=',' read -r -a tmp <<< "$csv" out=() for v in "${tmp[@]}"; do # trim spaces vv="$(echo "$v" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" out+=("$vv") done } # Helper to write to GITHUB_OUTPUT safely (composite actions set this env) write_output() { # $1 = key, $2 = value if [[ -n "${GITHUB_OUTPUT:-}" ]]; then echo "$1=$2" >> "$GITHUB_OUTPUT" else # fallback to printing for debugging echo "::notice::OUTPUT $1=$2" fi } # Apprise notify helper apprise_notify() { local msg="$1" log NOTICE "Notify: $msg" # call local notify helper if exists if [[ -x "$GITHUB_ACTION_PATH/notify" ]]; then bash "$GITHUB_ACTION_PATH/notify" "$msg" || log WARN "local notify script failed" fi # apprise CLI if available if command -v apprise >/dev/null 2>&1; then apprise -b "$msg" >/dev/null 2>&1 || log WARN "apprise CLI failed" fi # apprise-api if configured if [[ -n "$APPRISE_API_URL" ]]; then curl -fsS -X POST \ -H "Content-Type: application/json" \ -d "$(jq -n --arg b "$msg" '{body:$b}')" \ "$APPRISE_API_URL/notify" >/dev/null 2>&1 || log WARN "apprise API request failed" fi } # ----------------------- # Prepare arrays # ----------------------- csv_to_array "$APPROVERS" approver_list csv_to_array "$APPROVAL_KEYWORDS" approved_kws csv_to_array "$DENIAL_KEYWORDS" denied_kws # Lowercase keywords for matching for i in "${!approved_kws[@]}"; do approved_kws[$i]="$(tr '[:upper:]' '[:lower:]' <<<"${approved_kws[$i]}")"; done for i in "${!denied_kws[@]}"; do denied_kws[$i]="$(tr '[:upper:]' '[:lower:]' <<<"${denied_kws[$i]}")"; done # Build issue title if not provided if [[ -z "$ISSUE_TITLE_INPUT" ]]; then ISSUE_TITLE="Manual approval required: ${GITHUB_REPOSITORY:-$REPO} / run triggered" else ISSUE_TITLE="$ISSUE_TITLE_INPUT" fi log INFO "Issue title: $ISSUE_TITLE" # ----------------------- # 1) Find an existing open issue that looks like our approval issue # We search open issues for an exact title match. If present, reuse. # ----------------------- log INFO "Searching for existing open issues with the same title..." existing_api="${API_URL}/repos/${REPO}/issues?state=open&per_page=100" resp=$(curl -fsS -H "Authorization: token $TOKEN" "$existing_api") || { log WARN "Failed to fetch existing issues (curl failed)"; resp="[]"; } log DEBUG "Existing issues response: $(echo "$resp" | jq -c '.')" issue_number="" # select exact title match (works for GitHub & Gitea) issue_number=$(echo "$resp" | jq -r --arg t "$ISSUE_TITLE" '.[] | select(.title == $t) | .number // .index // .id' | head -n 1 || true) if [[ -n "$issue_number" && "$issue_number" != "null" ]]; then log INFO "Reusing existing open approval issue #$issue_number" else # Create issue log INFO "No existing issue found; creating a new approval issue" body="This workflow requires manual approval Approvers: ${APPROVERS} Reply with one of the approval keywords: ${APPROVAL_KEYWORDS} Or reply with one of the denial keywords: ${DENIAL_KEYWORDS} " # build assignees JSON assignees_json=$(jq -nc --arg csv "$APPROVERS" '$csv | split(",")') payload=$(jq -n --arg title "$ISSUE_TITLE" --arg body "$body" --argjson assignees "$assignees_json" \ '{title:$title, body:$body, assignees:$assignees}') log DEBUG "Create issue payload: $payload" create_resp=$(curl -fsS -X POST \ -H "Authorization: token $TOKEN" \ -H "Content-Type: application/json" \ -d "$payload" \ "${API_URL}/repos/${REPO}/issues") || { log ERROR "Issue creation failed"; echo "issue_creation_failed"; exit 1; } log DEBUG "Create issue response: $(echo "$create_resp" | jq -c '.')" issue_number=$(echo "$create_resp" | jq -r '.number // .index // .id') if [[ -z "$issue_number" || "$issue_number" == "null" ]]; then log ERROR "Failed to obtain issue number from create response: $create_resp" echo "issue_creation_failed" exit 1 fi log INFO "Created approval issue #$issue_number" fi issue_url="${API_URL%/}/repos/${REPO}/issues/${issue_number}" write_output issue_number "$issue_number" write_output issue_url "$issue_url" # ----------------------- # 1b) Post initial comment if provided # ----------------------- if [[ -n "$INITIAL_COMMENT" ]]; then log INFO "Posting initial comment to issue #$issue_number" comment_payload=$(jq -n --arg body "$INITIAL_COMMENT" '{body:$body}') curl -fsS -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ -d "$comment_payload" "${API_URL}/repos/${REPO}/issues/${issue_number}/comments" >/dev/null 2>&1 || log WARN "Posting initial comment failed" fi apprise_notify "Approval required on issue #${issue_number} — ${issue_url}" # ----------------------- # 2) Polling loop (comments + reactions) # ----------------------- last_reminder=$(date +%s) approval_status="denied" # default; will be set to 'approved' if approved # Prebuild lowercased regex patterns for keywords approved_patterns=() denied_patterns=() for k in "${approved_kws[@]}"; do k_lc="$(tr '[:upper:]' '[:lower:]' <<<"$k")" approved_patterns+=("$(escape_regex "$k_lc")") done for k in "${denied_kws[@]}"; do k_lc="$(tr '[:upper:]' '[:lower:]' <<<"$k")" denied_patterns+=("$(escape_regex "$k_lc")") done # reaction mapping: map common positive/negative reaction names # GitHub/Gitea reaction names can differ; we support common ones: # approve reactions: "+1", "thumbs_up", "heart" (optionally) # deny reactions: "-1", "thumbs_down" approve_reacts_regex="^\+1$|^\\+1$|thumbs_up|\\+1|\\u1F44D" deny_reacts_regex="^-1$|thumbs_down|\\-1|\\u1F44E" log INFO "Beginning polling loop for comments/reactions..." while true; do # fetch comments comments_api="${API_URL}/repos/${REPO}/issues/${issue_number}/comments?per_page=200" comments_json=$(curl -fsS -H "Authorization: token $TOKEN" "$comments_api") || { log WARN "Failed to fetch comments"; comments_json="[]"; } log DEBUG "Comments payload: $(echo "$comments_json" | jq -c '.')" # initialize state map declare -A state for u in "${approver_list[@]}"; do state["$u"]="pending"; done # check comments for keywords count=$(echo "$comments_json" | jq 'length' || echo 0) for ((i=0;i/dev/null 2>&1 || log WARN "Issue close failed" fi write_output approval_status "$approval_status" write_output issue_number "$issue_number" write_output issue_url "$issue_url" exit 0 fi done # ----------------------- # Check for full approval (all approvers approved) # ----------------------- all_ok=true for a in "${approver_list[@]}"; do # if state entry not set, treat as pending v="${state[$a]:-pending}" if [[ "$v" != "approved" ]]; then all_ok=false break fi done if $all_ok; then log INFO "All approvers approved — finalizing as approved" apprise_notify "All approvers approved issue #${issue_number} (${issue_url})" approval_status="approved" if [[ "${AUTO_CLOSE,,}" == "true" ]]; then log INFO "Auto-closing issue #$issue_number" close_payload=$(jq -n '{state:"closed"}') curl -fsS -X PATCH -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ -d "$close_payload" "${API_URL}/repos/${REPO}/issues/${issue_number}" >/dev/null 2>&1 || log WARN "Issue close failed" fi write_output approval_status "$approval_status" write_output issue_number "$issue_number" write_output issue_url "$issue_url" exit 0 fi # ----------------------- # Reminders # ----------------------- now=$(date +%s) if (( REMINDER_INTERVAL > 0 )) && (( now - last_reminder >= REMINDER_INTERVAL )); then pending_list="" for a in "${approver_list[@]}"; do v="${state[$a]:-pending}" if [[ "$v" == "pending" ]]; then pending_list+="$a "; fi done msg="Reminder: approval pending for [${pending_list}] on issue #${issue_number} (${issue_url})" apprise_notify "$msg" last_reminder=$now fi sleep "$POLL_INTERVAL" done