From 305dd49dde7c8e347d4dc0e42c3428f2d55402c1 Mon Sep 17 00:00:00 2001 From: "Trez.One" Date: Tue, 25 Nov 2025 10:51:51 -0500 Subject: [PATCH] Revamp. --- action.yml | 58 ++++-- notify | 15 +- wait-for-approval.sh | 459 +++++++++++++++++++++++++++++++------------ 3 files changed, 389 insertions(+), 143 deletions(-) diff --git a/action.yml b/action.yml index b60222d..14bdddf 100644 --- a/action.yml +++ b/action.yml @@ -1,13 +1,16 @@ name: "Wait For Manual Approval (Gitea/GitHub + Apprise)" -description: "Pauses workflow until designated approvers comment with approval or denial keywords." +description: > + Pauses workflow until designated approvers comment (or react) with approval or denial keywords, + optionally posts an initial comment, auto-closes the issue, and provides outputs: + approval_status, issue_number, issue_url. inputs: token: - description: "API token for GitHub or Gitea" + description: "API token for GitHub or Gitea (GITHUB_TOKEN or a personal token)" required: true api_url: - description: "Root API URL (e.g., https://git.example.com/api/v1)" + description: "Root API URL (e.g., https://git.example.com/api/v1 or https://api.github.com)" required: true repo_owner: @@ -19,7 +22,7 @@ inputs: required: true approvers: - description: "Comma-separated list of approver usernames" + description: "Comma-separated list of approver usernames (e.g. ops-team,john.doe)" required: true approval_keywords: @@ -30,8 +33,12 @@ inputs: description: "Comma-separated keywords meaning DENIED" default: "deny,denied,no" + enable_reactions: + description: "Enable reaction-based approvals (:+1: / -1). Default true" + default: "true" + poll_interval: - description: "Seconds between each comment check" + description: "Seconds between each comment/reaction check" default: "10" reminder_interval: @@ -39,28 +46,47 @@ inputs: default: "0" apprise_api_url: - description: "URL to Apprise API (optional)" + description: "URL to Apprise API (optional; e.g. https://apprise.example.com)" required: false + default: "" initial_comment: - description: "Optional comment to post immediately on the newly created issue" + description: "Optional comment to post immediately on the newly created/reused issue (supports markdown)" + required: false + default: "" + + auto_close: + description: "Auto-close the issue once a final decision has been reached (true/false)" + default: "true" + + issue_title: + description: "Optional issue title. If empty the action generates a default title." required: false default: "" outputs: approval_status: description: "Final approval status: approved or denied" - value: ${{ steps.wait-for-manual-approval.outputs.approval_status }} + value: ${{ steps.wait.outputs.approval_status }} + + issue_number: + description: "The issue number that was used" + value: ${{ steps.wait.outputs.issue_number }} + + issue_url: + description: "A URL to the approval issue" + value: ${{ steps.wait.outputs.issue_url }} runs: using: "composite" steps: - - name: Make scripts executable + - name: Make helper scripts executable shell: bash - run: chmod +x "$GITHUB_ACTION_PATH/wait-for-approval.sh" "$GITHUB_ACTION_PATH/notify" + run: | + chmod +x "$GITHUB_ACTION_PATH/wait-for-approval.sh" "$GITHUB_ACTION_PATH/notify" - - name: Run wait-for-approval - id: wait-for-manual-approval + - name: Wait for manual approval + id: wait shell: bash env: TOKEN: ${{ inputs.token }} @@ -70,10 +96,14 @@ runs: APPROVERS: ${{ inputs.approvers }} APPROVAL_KEYWORDS: ${{ inputs.approval_keywords }} DENIAL_KEYWORDS: ${{ inputs.denial_keywords }} + ENABLE_REACTIONS: ${{ inputs.enable_reactions }} POLL_INTERVAL: ${{ inputs.poll_interval }} REMINDER_INTERVAL: ${{ inputs.reminder_interval }} APPRISE_API_URL: ${{ inputs.apprise_api_url }} INITIAL_COMMENT: ${{ inputs.initial_comment }} + AUTO_CLOSE: ${{ inputs.auto_close }} + ISSUE_TITLE: ${{ inputs.issue_title }} run: | - status=$(bash "$GITHUB_ACTION_PATH/wait-for-approval.sh") - echo "approval_status=$status" >> "$GITHUB_OUTPUT" + # The script will write outputs directly to $GITHUB_OUTPUT: + # approval_status, issue_number, issue_url + bash "$GITHUB_ACTION_PATH/wait-for-approval.sh" diff --git a/notify b/notify index 7a8d152..af0ce80 100755 --- a/notify +++ b/notify @@ -2,17 +2,16 @@ set -euo pipefail msg="$1" +APPRISE_API_URL="${APPRISE_API_URL:-}" -# Apprise CLI +# Local Apprise CLI if available if command -v apprise >/dev/null 2>&1; then - apprise -b "$msg" >/dev/null 2>&1 || true + apprise -b "$msg" >/dev/null 2>&1 || true fi # Apprise API -if [[ -n "${APPRISE_API_URL:-}" ]]; then - curl -s -X POST \ - -H "Content-Type: application/json" \ - -F "{\"body\":\"$msg\"}" \ - -F "tags=all" \ - "$APPRISE_API_URL" >/dev/null 2>&1 || true +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 || true fi diff --git a/wait-for-approval.sh b/wait-for-approval.sh index 431dfe1..60b3dcb 100755 --- a/wait-for-approval.sh +++ b/wait-for-approval.sh @@ -1,6 +1,14 @@ #!/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}" @@ -8,152 +16,361 @@ 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:-}" -IFS=',' read -r -a approver_list <<< "$APPROVERS" -IFS=',' read -r -a approved_kws <<< "$APPROVAL_KEYWORDS" -IFS=',' read -r -a denied_kws <<< "$DENIAL_KEYWORDS" +REPO="${REPO_OWNER}/${REPO_NAME}" -last_reminder=$(date +%s) +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" + sed -e 's/[]\/$*.^|[]/\\&/g' <<< "$1" } -notify_func() { - local msg="$1" - echo "::notice::$msg" - - if [[ -x "$GITHUB_ACTION_PATH/notify" ]]; then - bash "$GITHUB_ACTION_PATH/notify" "$msg" || true - fi - - if command -v apprise >/dev/null 2>&1; then - apprise -b "$msg" >/dev/null 2>&1 || true - fi - - if [[ -n "${APPRISE_API_URL:-}" ]]; then - curl -s -X POST \ - -H "Content-Type: application/json" \ - -F "{\"body\":\"$msg\"}" \ - -F "tags=all" \ - "$APPRISE_API_URL" >/dev/null 2>&1 || true - fi +# 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 } -# ----------------------------- -# Step 1: Create the issue -# ----------------------------- -title="Manual approval required" -body="Workflow requires manual approval. +# 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: -• ${APPROVAL_KEYWORDS} -• ${DENIAL_KEYWORDS}" +Reply with one of the approval keywords: +${APPROVAL_KEYWORDS} -assignees_json=$(jq -nc --arg csv "$APPROVERS" '$csv|split(",")') +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" -json=$(jq -n \ - --arg title "$title" \ - --arg body "$body" \ - --argjson assignees "$assignees_json" \ - '{title:$title, body:$body, assignees:$assignees}') + 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; } -resp=$(curl -s -X POST \ - -H "Authorization: token $TOKEN" \ - -H "Content-Type: application/json" \ - -d "$json" \ - "$API_URL/repos/$REPO_OWNER/$REPO_NAME/issues") + log DEBUG "Create issue response: $(echo "$create_resp" | jq -c '.')" -ISSUE=$(echo "$resp" | jq -r '.number // .index // .id') -if [[ -z "$ISSUE" || "$ISSUE" == "null" ]]; then - echo "❌ Failed to create issue: $resp" + 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 -notify_func "Approval required on issue #$ISSUE" +issue_url="${API_URL%/}/repos/${REPO}/issues/${issue_number}" +write_output issue_number "$issue_number" +write_output issue_url "$issue_url" -# ----------------------------- -# Step 1b: Post initial comment if provided -# ----------------------------- +# ----------------------- +# 1b) Post initial comment if provided +# ----------------------- if [[ -n "$INITIAL_COMMENT" ]]; then - comment_json=$(jq -n --arg body "$INITIAL_COMMENT" '{body:$body}') - curl -s -X POST \ - -H "Authorization: token $TOKEN" \ - -H "Content-Type: application/json" \ - -d "$comment_json" \ - "$API_URL/repos/$REPO_OWNER/$REPO_NAME/issues/$ISSUE/comments" >/dev/null + 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 -# ----------------------------- -# Step 2: Poll for comments -# ----------------------------- -while true; do - comments=$(curl -s \ - -H "Authorization: token $TOKEN" \ - "$API_URL/repos/$REPO_OWNER/$REPO_NAME/issues/$ISSUE/comments") +apprise_notify "Approval required on issue #${issue_number} — ${issue_url}" - declare -A state - for u in "${approver_list[@]}"; do - state["$u"]="pending" - done +# ----------------------- +# 2) Polling loop (comments + reactions) +# ----------------------- +last_reminder=$(date +%s) +approval_status="denied" # default; will be set to 'approved' if approved - count=$(echo "$comments" | jq 'length') - - for ((i=0; i> "$GITHUB_OUTPUT" - exit 1 - fi - done - - # Full approval check - all_ok=true - for u in "${!state[@]}"; do - [[ "${state[$u]}" != "approved" ]] && all_ok=false - done - - if $all_ok; then - notify_func "All approvers approved issue #$ISSUE" - echo "approved=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Send reminders - if (( REMINDER_INTERVAL > 0 )); then - now=$(date +%s) - if (( now - last_reminder >= REMINDER_INTERVAL )); then - pending="" - for u in "${!state[@]}"; do - [[ "${state[$u]}" == "pending" ]] && pending+="$u " - done - notify_func "Reminder: approval pending for [${pending}] on issue #$ISSUE" - last_reminder=$now - fi - fi - - sleep "$POLL_INTERVAL" +# 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