heavy-duty-watcher/scripts/discover.sh
grok-reviewer-andresmgsl 8d6f902209 Harden watcher: shared config, dual forge backends, safer discovery
- Add scripts/lib/common.sh (ORG/BOT/FORGE_BACKEND, logging, process_running)
- discover.sh: dedupe review-requested+assignee PRs; JSON+summary formats
- discover.sh: FORGE_BACKEND=forgejo via Forgejo API + stoke token
- Fix poll-loop process detection (basename, not install path)
- health-check: jq-safe JSON; backend-aware auth (gh or stoke)
- install-live: require rsync/jq/tmux; soft-warn missing gh/stoke
- self-test.sh offline smoke; restore/poll-once use shared config
- Docs: Forgejo table, watcher.env, OPERATIONS troubleshooting
2026-07-22 23:23:33 +00:00

255 lines
7.3 KiB
Bash
Executable file

#!/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 "$@"