377 lines
14 KiB
Bash
Executable File
377 lines
14 KiB
Bash
Executable File
#!/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<count;i++)); do
|
|
user=$(echo "$comments_json" | jq -r ".[$i].user.login")
|
|
body=$(echo "$comments_json" | jq -r ".[$i].body" | tr '[:upper:]' '[:lower:]')
|
|
|
|
# normalize username as provided in approvers (do not strip dots etc.)
|
|
clean_user="$user"
|
|
|
|
log DEBUG "Comment by $clean_user: $body"
|
|
|
|
# check approval keywords (exact single-word match + optional . or !)
|
|
for pat in "${approved_patterns[@]}"; do
|
|
if [[ "$body" =~ ^${pat}[.!]?$ ]]; then
|
|
state["$clean_user"]="approved"
|
|
log INFO "Detected approval keyword '${pat}' from user '$clean_user'"
|
|
fi
|
|
done
|
|
# check denial
|
|
for pat in "${denied_patterns[@]}"; do
|
|
if [[ "$body" =~ ^${pat}[.!]?$ ]]; then
|
|
state["$clean_user"]="denied"
|
|
log INFO "Detected denial keyword '${pat}' from user '$clean_user'"
|
|
fi
|
|
done
|
|
done
|
|
|
|
# optionally check reactions on the issue (summary) and on individual comments
|
|
if [[ "${ENABLE_REACTIONS,,}" == "true" ]]; then
|
|
# reactions on the issue itself (if API exposes them)
|
|
# Fetch single-issue details if available (some APIs include reactions)
|
|
issue_api="${API_URL}/repos/${REPO}/issues/${issue_number}"
|
|
issue_details=$(curl -fsS -H "Authorization: token $TOKEN" "$issue_api") || issue_details="{}"
|
|
log DEBUG "Issue details: $(echo "$issue_details" | jq -c '.')"
|
|
|
|
# Check comment-level reactions: iterate comments and request reactions for each
|
|
for ((i=0;i<count;i++)); do
|
|
comment_id=$(echo "$comments_json" | jq -r ".[$i].id")
|
|
comment_user=$(echo "$comments_json" | jq -r ".[$i].user.login")
|
|
# many APIs provide a reactions_url or /comments/{id}/reactions endpoint
|
|
# Try the common endpoint:
|
|
reactions_api="${API_URL}/repos/${REPO}/issues/comments/${comment_id}/reactions"
|
|
reactions_json=$(curl -fsS -H "Authorization: token $TOKEN" -H "Accept: application/vnd.github.squirrel-girl-preview+json" "$reactions_api" || echo "[]")
|
|
# iterate reactions
|
|
rcount=$(echo "$reactions_json" | jq 'length' || echo 0)
|
|
for ((r=0;r<rcount;r++)); do
|
|
react_content=$(echo "$reactions_json" | jq -r ".[$r].content // .reaction_type // .type // \"\"")
|
|
reactor=$(echo "$reactions_json" | jq -r ".[$r].user.login // .user // \"\"")
|
|
# normalize
|
|
react_lc="$(tr '[:upper:]' '[:lower:]' <<<"$react_content")"
|
|
if [[ "$react_lc" =~ ($approve_reacts_regex) ]]; then
|
|
state["$comment_user"]="approved"
|
|
log INFO "Detected approve reaction '$react_content' on comment $comment_id by $reactor (affects $comment_user)"
|
|
fi
|
|
if [[ "$react_lc" =~ ($deny_reacts_regex) ]]; then
|
|
state["$comment_user"]="denied"
|
|
log INFO "Detected deny reaction '$react_content' on comment $comment_id by $reactor (affects $comment_user)"
|
|
fi
|
|
done
|
|
done
|
|
|
|
# Also check reactions on the issue itself (aggregate reactions)
|
|
# Some APIs return a 'reactions' object with counts; not all provide per-user reaction lists for the issue root.
|
|
# We'll attempt to fetch reactions on the issue via /issues/{num}/reactions
|
|
issue_reactions_api="${API_URL}/repos/${REPO}/issues/${issue_number}/reactions"
|
|
issue_reactions_json=$(curl -fsS -H "Authorization: token $TOKEN" -H "Accept: application/vnd.github.squirrel-girl-preview+json" "$issue_reactions_api" || echo "[]")
|
|
ircount=$(echo "$issue_reactions_json" | jq 'length' || echo 0)
|
|
for ((r=0;r<ircount;r++)); do
|
|
react_content=$(echo "$issue_reactions_json" | jq -r ".[$r].content // .reaction_type // .type // \"\"")
|
|
react_user=$(echo "$issue_reactions_json" | jq -r ".[$r].user.login // .user // \"\"")
|
|
react_lc="$(tr '[:upper:]' '[:lower:]' <<<"$react_content")"
|
|
if [[ "$react_lc" =~ ($approve_reacts_regex) ]]; then
|
|
# treat reactor as approval actor — if the reactor is in approvers, mark them approved
|
|
for a in "${approver_list[@]}"; do
|
|
if [[ "$a" == "$react_user" ]]; then
|
|
state["$react_user"]="approved"
|
|
log INFO "Detected approve reaction by approver $react_user on the issue"
|
|
fi
|
|
done
|
|
fi
|
|
if [[ "$react_lc" =~ ($deny_reacts_regex) ]]; then
|
|
for a in "${approver_list[@]}"; do
|
|
if [[ "$a" == "$react_user" ]]; then
|
|
state["$react_user"]="denied"
|
|
log INFO "Detected deny reaction by approver $react_user on the issue"
|
|
fi
|
|
done
|
|
fi
|
|
done
|
|
fi
|
|
|
|
# -----------------------
|
|
# Check for denial
|
|
# -----------------------
|
|
for u in "${!state[@]}"; do
|
|
if [[ "${state[$u]}" == "denied" ]]; then
|
|
log INFO "Detected denial by $u — finalizing as denied"
|
|
apprise_notify "Approval denied by ${u} for issue #${issue_number} (${issue_url})"
|
|
approval_status="denied"
|
|
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
|
|
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
|