#!/usr/bin/env bash # Org-wide discovery of actionable PRs/issues for the bot. # Supports FORGE_BACKEND=github (default) or forgejo. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" # shellcheck source=lib/common.sh source "${SCRIPT_DIR}/lib/common.sh" FORMAT="${FORMAT:-json}" # json | summary LIMIT="${LIMIT:-50}" discover_github() { require_cmd gh jq local tmp tmp="$(mktemp -d)" # shellcheck disable=SC2064 trap "rm -rf '${tmp}'" EXIT gh search prs --owner "${ORG}" --review-requested=@me --state open \ --json number,title,url,updatedAt,author,assignees,repository --limit "${LIMIT}" \ > "${tmp}/pr_rr.json" 2>/dev/null || echo '[]' > "${tmp}/pr_rr.json" gh search prs --owner "${ORG}" --assignee=@me --state open \ --json number,title,url,updatedAt,author,assignees,repository --limit "${LIMIT}" \ > "${tmp}/pr_as.json" 2>/dev/null || echo '[]' > "${tmp}/pr_as.json" gh search issues --owner "${ORG}" --assignee=@me --state open \ --json number,title,url,updatedAt,author,assignees,repository --limit "${LIMIT}" \ > "${tmp}/issues.json" 2>/dev/null || echo '[]' > "${tmp}/issues.json" # Normalize empty/null files for f in pr_rr pr_as issues; do if [[ ! -s "${tmp}/${f}.json" ]] || ! jq empty "${tmp}/${f}.json" 2>/dev/null; then echo '[]' > "${tmp}/${f}.json" fi done jq -n \ --arg org "${ORG}" \ --arg bot "${BOT_LOGIN}" \ --slurpfile rr "${tmp}/pr_rr.json" \ --slurpfile as "${tmp}/pr_as.json" \ --slurpfile iss "${tmp}/issues.json" \ ' def repo_name: .repository.nameWithOwner // .repository.full_name // "unknown/unknown"; def pr_key: (repo_name) + "#pr:" + (.number|tostring); ($rr[0] // []) as $rr | ($as[0] // []) as $as | ($iss[0] // []) as $iss | # set of assignee PR keys ([$as[] | pr_key] | unique) as $as_keys | ([$rr[] | pr_key] | unique) as $rr_keys | ( [ $rr[] | . + { kind: "pr", role: (if ([pr_key] | inside($as_keys)) then "reviewer+assignee" else "reviewer" end) } ] + [ $as[] | select(([pr_key] | inside($rr_keys)) | not) | . + { kind: "pr", role: "assignee" } ] ) as $prs | ( [ $iss[] | . + { kind: "issue", role: "assignee" } ] ) as $issues | { backend: "github", org: $org, bot: $bot, generated_at: (now | strftime("%Y-%m-%dT%H:%M:%SZ")), counts: { prs: ($prs | length), issues: ($issues | length), total: (($prs | length) + ($issues | length)) }, prs: $prs, issues: $issues } ' } discover_forgejo() { require_cmd jq curl local cfg token url tmp page batch n owner repo full cfg="${STOKE_CONFIG_FILE:-${XDG_CONFIG_HOME:-$HOME/.config}/stoke/config.json}" if [[ ! -f "${cfg}" ]]; then echo "error: stoke config not found at ${cfg} (run: stoke auth login)" >&2 return 1 fi token="$(jq -r '.token // empty' "${cfg}")" url="$(jq -r '.url // empty' "${cfg}")" url="${url:-$FORGEJO_URL}" url="${url%/}" if [[ -z "${token}" ]]; then echo "error: no token in ${cfg}" >&2 return 1 fi tmp="$(mktemp -d)" # shellcheck disable=SC2064 trap "rm -rf '${tmp}'" EXIT api() { curl -fsS -H "Authorization: token ${token}" -H "Accept: application/json" "$@" } : > "${tmp}/repos.txt" page=1 while (( page <= 40 )); do batch="$(api "${url}/api/v1/orgs/${ORG}/repos?limit=50&page=${page}" 2>/dev/null || echo '[]')" n="$(echo "${batch}" | jq 'length')" (( n == 0 )) && break echo "${batch}" | jq -r '.[].full_name // empty' >> "${tmp}/repos.txt" (( n < 50 )) && break page=$((page + 1)) done : > "${tmp}/prs.ndjson" : > "${tmp}/issues.ndjson" while read -r full; do [[ -z "${full}" ]] && continue owner="${full%%/*}" repo="${full#*/}" api "${url}/api/v1/repos/${owner}/${repo}/pulls?state=open&limit=50" 2>/dev/null \ | jq -c --arg full "${full}" --arg bot "${BOT_LOGIN}" ' .[]? | ( ((.requested_reviewers // []) | map(.login) | index($bot) != null) ) as $is_rev | ( (.assignee.login == $bot) or (((.assignees // []) | map(.login) | index($bot)) != null) ) as $is_as | select($is_rev or $is_as) | { kind: "pr", number, title, url: .html_url, updatedAt: .updated_at, author: (.user.login // null), head_sha: (.head.sha // null), repository: { nameWithOwner: $full }, role: ( if $is_rev and $is_as then "reviewer+assignee" elif $is_rev then "reviewer" else "assignee" end ) } ' >> "${tmp}/prs.ndjson" 2>/dev/null || true api "${url}/api/v1/repos/${owner}/${repo}/issues?state=open&type=issues&limit=50" 2>/dev/null \ | jq -c --arg full "${full}" --arg bot "${BOT_LOGIN}" ' .[]? | select(.pull_request == null) | select( (.assignee.login == $bot) or (((.assignees // []) | map(.login) | index($bot)) != null) ) | { kind: "issue", number, title, url: .html_url, updatedAt: .updated_at, author: (.user.login // null), repository: { nameWithOwner: $full }, role: "assignee" } ' >> "${tmp}/issues.ndjson" 2>/dev/null || true done < "${tmp}/repos.txt" local prs_json issues_json if [[ -s "${tmp}/prs.ndjson" ]]; then prs_json="$(jq -s '.' "${tmp}/prs.ndjson")" else prs_json='[]' fi if [[ -s "${tmp}/issues.ndjson" ]]; then issues_json="$(jq -s '.' "${tmp}/issues.ndjson")" else issues_json='[]' fi jq -n \ --arg org "${ORG}" \ --arg bot "${BOT_LOGIN}" \ --arg url "${url}" \ --argjson prs "${prs_json}" \ --argjson issues "${issues_json}" \ '{ backend: "forgejo", forge_url: $url, org: $org, bot: $bot, generated_at: (now | strftime("%Y-%m-%dT%H:%M:%SZ")), counts: { prs: ($prs | length), issues: ($issues | length), total: (($prs | length) + ($issues | length)) }, prs: $prs, issues: $issues }' } print_summary() { jq -r ' "backend=\(.backend) org=\(.org) bot=\(.bot)", "counts: prs=\(.counts.prs) issues=\(.counts.issues) total=\(.counts.total)", "", (if (.prs|length)>0 then "PRs:" else empty end), (.prs[]? | " [\(.role)] \(.repository.nameWithOwner // "unknown")!\(.number) — \(.title)"), (if (.issues|length)>0 then "Issues:" else empty end), (.issues[]? | " [\(.role)] \(.repository.nameWithOwner // "unknown")#\(.number) — \(.title)"), (if .counts.total == 0 then "(no actionable items)" else empty end) ' } main() { local payload case "${FORGE_BACKEND}" in github) payload="$(discover_github)" ;; forgejo) payload="$(discover_forgejo)" ;; *) echo "error: unknown FORGE_BACKEND=${FORGE_BACKEND} (use github or forgejo)" >&2 exit 2 ;; esac case "${FORMAT}" in json) echo "${payload}" | jq . ;; summary) echo "${payload}" | print_summary ;; *) echo "error: FORMAT must be json or summary" >&2; exit 2 ;; esac } main "$@"