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
This commit is contained in:
parent
7b9b696874
commit
8d6f902209
14 changed files with 675 additions and 124 deletions
|
|
@ -173,6 +173,22 @@ discover → read all context → decide
|
||||||
|
|
||||||
## Identity
|
## Identity
|
||||||
|
|
||||||
- Bot: `grok-bot-andresmgsl`
|
- Bot: `grok-bot-andresmgsl` (override with `BOT_LOGIN` in `config/watcher.env`)
|
||||||
- Org: `heavy-duty`
|
- Org: `heavy-duty` (override with `ORG`)
|
||||||
- Role: assigned reviewer / assignee agent — **not** an unsolicited drive-by bot
|
- Role: assigned reviewer / assignee agent — **not** an unsolicited drive-by bot
|
||||||
|
|
||||||
|
## Forgejo backend (optional)
|
||||||
|
|
||||||
|
Set `FORGE_BACKEND=forgejo` in `config/watcher.env` when the heavy-duty forge is
|
||||||
|
[Forgejo](https://forgejo.heavyduty.builders) instead of GitHub.com.
|
||||||
|
|
||||||
|
| Task | GitHub | Forgejo |
|
||||||
|
|------|--------|---------|
|
||||||
|
| Auth | `gh auth status` | `stoke auth status` / `stoke auth login` |
|
||||||
|
| Discover | `gh search …` | `FORGE_BACKEND=forgejo ./scripts/discover.sh` |
|
||||||
|
| PR review | `gh pr review` | `stoke pr review -o OWNER -r REPO -n N --event …` |
|
||||||
|
| PR comment | `gh issue comment` | `stoke pr comment -o OWNER -r REPO -n N -b …` |
|
||||||
|
| PR show | `gh pr view` | `stoke pr show -o OWNER -r REPO -n N` |
|
||||||
|
|
||||||
|
Discovery still only returns items where this bot is **assignee** or **requested reviewer**.
|
||||||
|
The same Verdict / de-dupe / no-drive-by rules apply. State keys stay `owner/repo#pr:N`.
|
||||||
|
|
|
||||||
66
README.md
66
README.md
|
|
@ -1,6 +1,7 @@
|
||||||
# heavy-duty-watcher
|
# heavy-duty-watcher
|
||||||
|
|
||||||
**Org-wide GitHub review agent** for the [heavy-duty](https://github.com/heavy-duty) organization.
|
**Org-wide review agent** for the [heavy-duty](https://github.com/heavy-duty) organization
|
||||||
|
(GitHub by default; optional [Forgejo](https://forgejo.heavyduty.builders) backend via [stoke](https://forgejo.heavyduty.builders/heavy-duty/stoke)).
|
||||||
|
|
||||||
Runs as bot **`grok-bot-andresmgsl`**. Every 15 minutes it discovers open Issues/PRs where this bot is **assignee** or **requested reviewer** across **all** `heavy-duty/*` repos, reads the full discussion, and posts structured reviews until it **agrees** (Approve) or still has blockers (Request changes).
|
Runs as bot **`grok-bot-andresmgsl`**. Every 15 minutes it discovers open Issues/PRs where this bot is **assignee** or **requested reviewer** across **all** `heavy-duty/*` repos, reads the full discussion, and posts structured reviews until it **agrees** (Approve) or still has blockers (Request changes).
|
||||||
|
|
||||||
|
|
@ -11,11 +12,14 @@ Runs as bot **`grok-bot-andresmgsl`**. Every 15 minutes it discovers open Issues
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Org-wide scope** — one watcher for every repo under `heavy-duty`
|
- **Org-wide scope** — one watcher for every repo under `heavy-duty`
|
||||||
|
- **Dual forge backends** — `FORGE_BACKEND=github` (default) or `forgejo`
|
||||||
- **Strict collaboration rules** — clear Verdict/Status, specific, non-redundant, blockers vs nits
|
- **Strict collaboration rules** — clear Verdict/Status, specific, non-redundant, blockers vs nits
|
||||||
- **Stateful de-dupe** — tracks per-item head SHA and last action (`owner/repo#pr:N`)
|
- **Stateful de-dupe** — tracks per-item head SHA and last action (`owner/repo#pr:N`)
|
||||||
|
- **Deduped discovery** — PRs that are both review-requested and assigned appear once
|
||||||
- **Dual durability** — Grok durable schedulers **and** local tmux loops
|
- **Dual durability** — Grok durable schedulers **and** local tmux loops
|
||||||
- **Reboot recovery** — `scripts/restore.sh` rebuilds tmux + resumes the agent
|
- **Reboot recovery** — `scripts/restore.sh` rebuilds tmux + resumes the agent
|
||||||
- **Hourly health watchdog** — verifies poll loop, gh auth, freshness; self-heals
|
- **Hourly health watchdog** — verifies poll loop, auth, freshness; self-heals
|
||||||
|
- **Offline self-test** — `scripts/self-test.sh` (`bash -n` + structure checks)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -23,12 +27,23 @@ Runs as bot **`grok-bot-andresmgsl`**. Every 15 minutes it discovers open Issues
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/grok-bot-andresmgsl/heavy-duty-watcher.git
|
git clone https://github.com/grok-bot-andresmgsl/heavy-duty-watcher.git
|
||||||
|
# or from Forgejo: https://forgejo.heavyduty.builders/grok-reviewer-andresmgsl/heavy-duty-watcher.git
|
||||||
cd heavy-duty-watcher
|
cd heavy-duty-watcher
|
||||||
gh auth status # bot account with org access
|
gh auth status # bot account with org access (GitHub backend)
|
||||||
|
./scripts/self-test.sh # optional offline smoke
|
||||||
./scripts/install-live.sh
|
./scripts/install-live.sh
|
||||||
./scripts/restore.sh # after reboot: always this
|
./scripts/restore.sh # after reboot: always this
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Forgejo backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp config/watcher.env.example config/watcher.env
|
||||||
|
# set FORGE_BACKEND=forgejo and BOT_LOGIN=<forgejo-bot>
|
||||||
|
stoke auth login
|
||||||
|
FORGE_BACKEND=forgejo ./scripts/discover.sh
|
||||||
|
```
|
||||||
|
|
||||||
Arm durable schedulers from a live Grok session using prompts in [`docs/SCHEDULER_PROMPTS.md`](docs/SCHEDULER_PROMPTS.md).
|
Arm durable schedulers from a live Grok session using prompts in [`docs/SCHEDULER_PROMPTS.md`](docs/SCHEDULER_PROMPTS.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -37,22 +52,25 @@ Arm durable schedulers from a live Grok session using prompts in [`docs/SCHEDULE
|
||||||
|
|
||||||
```text
|
```text
|
||||||
heavy-duty-watcher/
|
heavy-duty-watcher/
|
||||||
├── README.md ← you are here
|
├── README.md
|
||||||
├── POLL_INSTRUCTIONS.md ← authoritative review rules (read every cycle)
|
├── POLL_INSTRUCTIONS.md ← authoritative review rules (read every cycle)
|
||||||
├── config/
|
├── config/
|
||||||
│ ├── state.template.json
|
│ ├── state.template.json
|
||||||
│ └── watcher.env.example
|
│ └── watcher.env.example
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ ├── discover.sh ← org-wide actionable list
|
│ ├── lib/common.sh ← shared env + helpers
|
||||||
|
│ ├── discover.sh ← org-wide actionable list (github|forgejo)
|
||||||
│ ├── poll-once.sh ← one headless poll (Grok)
|
│ ├── poll-once.sh ← one headless poll (Grok)
|
||||||
│ ├── poll-loop.sh ← every 15m
|
│ ├── poll-loop.sh ← every 15m
|
||||||
│ ├── health-check.sh
|
│ ├── health-check.sh
|
||||||
│ ├── health-loop.sh ← every 1h + self-heal
|
│ ├── health-loop.sh ← every 1h + self-heal
|
||||||
│ ├── restore.sh ← reboot recovery (tmux)
|
│ ├── restore.sh ← reboot recovery (tmux)
|
||||||
│ └── install-live.sh ← deploy under ~/heavy-duty-watcher
|
│ ├── install-live.sh ← deploy under ~/heavy-duty-watcher
|
||||||
|
│ └── self-test.sh ← offline smoke
|
||||||
├── docs/
|
├── docs/
|
||||||
│ ├── ARCHITECTURE.md
|
│ ├── ARCHITECTURE.md
|
||||||
│ ├── OPERATIONS.md
|
│ ├── OPERATIONS.md
|
||||||
|
│ ├── ORG_SCOPE.md
|
||||||
│ ├── SCHEDULER_PROMPTS.md
|
│ ├── SCHEDULER_PROMPTS.md
|
||||||
│ └── REVIEW_PLAYBOOK.md
|
│ └── REVIEW_PLAYBOOK.md
|
||||||
└── logs/ ← runtime (gitignored)
|
└── logs/ ← runtime (gitignored)
|
||||||
|
|
@ -63,7 +81,7 @@ heavy-duty-watcher/
|
||||||
## How a poll works
|
## How a poll works
|
||||||
|
|
||||||
```text
|
```text
|
||||||
discover (org search)
|
discover (org search / forgejo API)
|
||||||
→ for each match
|
→ for each match
|
||||||
read ALL comments + reviews + diff + checks
|
read ALL comments + reviews + diff + checks
|
||||||
compare head SHA / prior bot comments (state.json)
|
compare head SHA / prior bot comments (state.json)
|
||||||
|
|
@ -78,6 +96,35 @@ Full rules: [`POLL_INSTRUCTIONS.md`](POLL_INSTRUCTIONS.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Copy `config/watcher.env.example` → `config/watcher.env` (gitignored):
|
||||||
|
|
||||||
|
| Variable | Default | Meaning |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| `ORG` | `heavy-duty` | GitHub/Forgejo org |
|
||||||
|
| `BOT_LOGIN` | `grok-bot-andresmgsl` | Bot account login |
|
||||||
|
| `FORGE_BACKEND` | `github` | `github` or `forgejo` |
|
||||||
|
| `FORGEJO_URL` | `https://forgejo.heavyduty.builders` | Forge base URL |
|
||||||
|
| `POLL_INTERVAL_SEC` | `900` | tmux poll-loop sleep |
|
||||||
|
| `HEALTH_INTERVAL_SEC` | `3600` | health-loop sleep |
|
||||||
|
| `STALE_POLL_MINUTES` | `45` | health WARN threshold |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Day-to-day commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/discover.sh # JSON (default)
|
||||||
|
FORMAT=summary ./scripts/discover.sh # human table
|
||||||
|
./scripts/health-check.sh
|
||||||
|
./scripts/self-test.sh
|
||||||
|
tail -f logs/poll.log logs/health.log
|
||||||
|
tmux attach -t heavy-duty-watcher
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Documentation map
|
## Documentation map
|
||||||
|
|
||||||
| Doc | Audience |
|
| Doc | Audience |
|
||||||
|
|
@ -87,6 +134,7 @@ Full rules: [`POLL_INSTRUCTIONS.md`](POLL_INSTRUCTIONS.md).
|
||||||
| [docs/OPERATIONS.md](docs/OPERATIONS.md) | Humans operating the watcher |
|
| [docs/OPERATIONS.md](docs/OPERATIONS.md) | Humans operating the watcher |
|
||||||
| [docs/SCHEDULER_PROMPTS.md](docs/SCHEDULER_PROMPTS.md) | Copy-paste durable task prompts |
|
| [docs/SCHEDULER_PROMPTS.md](docs/SCHEDULER_PROMPTS.md) | Copy-paste durable task prompts |
|
||||||
| [docs/REVIEW_PLAYBOOK.md](docs/REVIEW_PLAYBOOK.md) | Review style examples |
|
| [docs/REVIEW_PLAYBOOK.md](docs/REVIEW_PLAYBOOK.md) | Review style examples |
|
||||||
|
| [docs/ORG_SCOPE.md](docs/ORG_SCOPE.md) | Org repo notes |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -96,8 +144,6 @@ Full rules: [`POLL_INSTRUCTIONS.md`](POLL_INSTRUCTIONS.md).
|
||||||
~/heavy-duty-watcher/scripts/restore.sh
|
~/heavy-duty-watcher/scripts/restore.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Creates/attaches tmux session `heavy-duty-watcher` with:
|
|
||||||
|
|
||||||
| Window | Role |
|
| Window | Role |
|
||||||
|--------|------|
|
|--------|------|
|
||||||
| `grok` | Interactive agent (resume session when possible) |
|
| `grok` | Interactive agent (resume session when possible) |
|
||||||
|
|
@ -111,7 +157,7 @@ Creates/attaches tmux session `heavy-duty-watcher` with:
|
||||||
|
|
||||||
- Only comments when **assigned** or **review-requested**
|
- Only comments when **assigned** or **review-requested**
|
||||||
- Never merges, closes, force-pushes, or reassigns by default
|
- Never merges, closes, force-pushes, or reassigns by default
|
||||||
- Secrets are **not** stored in this repo — use `gh auth` on the host
|
- Secrets are **not** stored in this repo — use `gh auth` / `stoke auth login` on the host
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,17 @@
|
||||||
# Copy to watcher.env (gitignored) for local overrides.
|
# Copy to config/watcher.env (gitignored) for local overrides.
|
||||||
WATCHER_DIR="${WATCHER_DIR:-$HOME/heavy-duty-watcher}"
|
# Values below are defaults applied by scripts/lib/common.sh when unset.
|
||||||
ORG="${ORG:-heavy-duty}"
|
|
||||||
BOT_LOGIN="${BOT_LOGIN:-grok-bot-andresmgsl}"
|
# WATCHER_DIR=$HOME/heavy-duty-watcher
|
||||||
POLL_INTERVAL_SEC="${POLL_INTERVAL_SEC:-900}"
|
ORG=heavy-duty
|
||||||
HEALTH_INTERVAL_SEC="${HEALTH_INTERVAL_SEC:-3600}"
|
BOT_LOGIN=grok-bot-andresmgsl
|
||||||
STALE_POLL_MINUTES="${STALE_POLL_MINUTES:-45}"
|
|
||||||
TMUX_SESSION="${TMUX_SESSION:-heavy-duty-watcher}"
|
# github (default) | forgejo
|
||||||
|
FORGE_BACKEND=github
|
||||||
|
FORGEJO_URL=https://forgejo.heavyduty.builders
|
||||||
|
|
||||||
|
POLL_INTERVAL_SEC=900
|
||||||
|
HEALTH_INTERVAL_SEC=3600
|
||||||
|
STALE_POLL_MINUTES=45
|
||||||
|
TMUX_SESSION=heavy-duty-watcher
|
||||||
|
# LEGACY_TMUX=rig-watcher
|
||||||
|
# LIMIT=50
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,17 @@ It polls GitHub for Issues/PRs where bot **`grok-bot-andresmgsl`** is:
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
GitHub org:heavy-duty
|
GitHub org:heavy-duty
|
||||||
|
or Forgejo org (FORGE_BACKEND=forgejo)
|
||||||
state.json + logs/
|
state.json + logs/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Forge backends
|
||||||
|
|
||||||
|
| `FORGE_BACKEND` | Auth | Discovery | Review CLI |
|
||||||
|
|-----------------|------|-----------|------------|
|
||||||
|
| `github` (default) | `gh` | `gh search` | `gh pr review` |
|
||||||
|
| `forgejo` | `stoke auth` | Forgejo API via stoke token | `stoke pr review` |
|
||||||
|
|
||||||
### Why two poll paths?
|
### Why two poll paths?
|
||||||
|
|
||||||
| Path | Survives |
|
| Path | Survives |
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,25 @@
|
||||||
```bash
|
```bash
|
||||||
# 1. Clone
|
# 1. Clone
|
||||||
git clone https://github.com/grok-bot-andresmgsl/heavy-duty-watcher.git
|
git clone https://github.com/grok-bot-andresmgsl/heavy-duty-watcher.git
|
||||||
|
# Forgejo mirror (if present):
|
||||||
|
# git clone https://forgejo.heavyduty.builders/grok-reviewer-andresmgsl/heavy-duty-watcher.git
|
||||||
cd heavy-duty-watcher
|
cd heavy-duty-watcher
|
||||||
|
|
||||||
# 2. Auth
|
# 2. Auth (pick backend)
|
||||||
gh auth status # must be grok-bot-andresmgsl (or your reviewer bot)
|
gh auth status # FORGE_BACKEND=github (default)
|
||||||
|
# stoke auth login # FORGE_BACKEND=forgejo
|
||||||
|
|
||||||
# 3. Install live + start tmux loops
|
# 3. Optional overrides
|
||||||
|
cp config/watcher.env.example config/watcher.env
|
||||||
|
# edit ORG / BOT_LOGIN / FORGE_BACKEND as needed
|
||||||
|
|
||||||
|
# 4. Offline smoke
|
||||||
|
./scripts/self-test.sh
|
||||||
|
|
||||||
|
# 5. Install live + start tmux loops
|
||||||
./scripts/install-live.sh
|
./scripts/install-live.sh
|
||||||
|
|
||||||
# 4. In a Grok session: arm durable schedulers (15m poll + 1h health)
|
# 6. In a Grok session: arm durable schedulers (15m poll + 1h health)
|
||||||
# Or restore interactive session:
|
# Or restore interactive session:
|
||||||
./scripts/restore.sh
|
./scripts/restore.sh
|
||||||
```
|
```
|
||||||
|
|
@ -62,8 +72,10 @@ Item keys changed from `pr:N` to `heavy-duty/rig#pr:N`.
|
||||||
|
|
||||||
| Symptom | Check |
|
| Symptom | Check |
|
||||||
|---------|--------|
|
|---------|--------|
|
||||||
| No reviews happening | `./scripts/discover.sh` empty? Not requested. |
|
| No reviews happening | `FORMAT=summary ./scripts/discover.sh` empty? Not requested. |
|
||||||
| Stale last_poll | `health-check.sh` WARN; is poll-loop alive? |
|
| Stale last_poll | `health-check.sh` WARN; is poll-loop alive? |
|
||||||
| gh 401 | `gh auth login` |
|
| gh 401 | `gh auth login` |
|
||||||
|
| stoke 401 | `stoke auth login` (Forgejo backend) |
|
||||||
| Duplicate spam | Inspect `state.json` `last_head_sha` / `last_action` |
|
| Duplicate spam | Inspect `state.json` `last_head_sha` / `last_action` |
|
||||||
| Scheduler missing | Re-arm via Grok; see `docs/SCHEDULER_PROMPTS.md` |
|
| Scheduler missing | Re-arm via Grok; see `docs/SCHEDULER_PROMPTS.md` |
|
||||||
|
| `install-live` fails | need `rsync`, `jq`, `tmux` |
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,255 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Org-wide discovery of actionable PRs/issues for the bot.
|
# Org-wide discovery of actionable PRs/issues for the bot.
|
||||||
|
# Supports FORGE_BACKEND=github (default) or forgejo.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
ORG="${ORG:-heavy-duty}"
|
|
||||||
|
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}"
|
LIMIT="${LIMIT:-50}"
|
||||||
|
|
||||||
echo "=== review-requested PRs (org:${ORG}) ==="
|
discover_github() {
|
||||||
gh search prs --owner "${ORG}" --review-requested=@me --state open \
|
require_cmd gh jq
|
||||||
--json number,title,url,updatedAt,author,assignees,repository --limit "${LIMIT}"
|
|
||||||
|
|
||||||
echo "=== assignee PRs (org:${ORG}) ==="
|
local tmp
|
||||||
gh search prs --owner "${ORG}" --assignee=@me --state open \
|
tmp="$(mktemp -d)"
|
||||||
--json number,title,url,updatedAt,author,assignees,repository --limit "${LIMIT}"
|
# shellcheck disable=SC2064
|
||||||
|
trap "rm -rf '${tmp}'" EXIT
|
||||||
|
|
||||||
echo "=== assignee issues (org:${ORG}) ==="
|
gh search prs --owner "${ORG}" --review-requested=@me --state open \
|
||||||
gh search issues --owner "${ORG}" --assignee=@me --state open \
|
--json number,title,url,updatedAt,author,assignees,repository --limit "${LIMIT}" \
|
||||||
--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 "$@"
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,15 @@
|
||||||
# Health check for the heavy-duty org watcher. Exit 0=ok, 1=degraded, 2=down.
|
# Health check for the heavy-duty org watcher. Exit 0=ok, 1=degraded, 2=down.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
WATCHER_DIR="${WATCHER_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
STATE_FILE="${WATCHER_DIR}/state.json"
|
# shellcheck source=lib/common.sh
|
||||||
LOG_DIR="${WATCHER_DIR}/logs"
|
source "${SCRIPT_DIR}/lib/common.sh"
|
||||||
|
|
||||||
HEALTH_LOG="${LOG_DIR}/health.log"
|
HEALTH_LOG="${LOG_DIR}/health.log"
|
||||||
TMUX_SESSION="${TMUX_SESSION:-heavy-duty-watcher}"
|
|
||||||
# Accept legacy session name during migration
|
|
||||||
LEGACY_TMUX="${LEGACY_TMUX:-rig-watcher}"
|
|
||||||
STALE_POLL_MINUTES="${STALE_POLL_MINUTES:-45}"
|
|
||||||
mkdir -p "${LOG_DIR}"
|
mkdir -p "${LOG_DIR}"
|
||||||
|
|
||||||
NOW_EPOCH="$(date -u +%s)"
|
NOW_EPOCH="$(date -u +%s)"
|
||||||
NOW_ISO="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
NOW_ISO="$(utc_now)"
|
||||||
STATUS="ok"
|
STATUS="ok"
|
||||||
ISSUES=()
|
ISSUES=()
|
||||||
|
|
||||||
|
|
@ -21,19 +18,40 @@ ok() { ISSUES+=("OK: $1"); }
|
||||||
warn() { STATUS="degraded"; ISSUES+=("WARN: $1"); }
|
warn() { STATUS="degraded"; ISSUES+=("WARN: $1"); }
|
||||||
fail() { STATUS="down"; ISSUES+=("FAIL: $1"); }
|
fail() { STATUS="down"; ISSUES+=("FAIL: $1"); }
|
||||||
|
|
||||||
if gh api user --jq .login >/dev/null 2>&1; then
|
# --- auth ---
|
||||||
LOGIN="$(gh api user --jq .login 2>/dev/null || echo unknown)"
|
case "${FORGE_BACKEND}" in
|
||||||
ok "gh authenticated as ${LOGIN}"
|
github)
|
||||||
else
|
if command -v gh >/dev/null 2>&1 && gh api user --jq .login >/dev/null 2>&1; then
|
||||||
fail "gh not authenticated"
|
LOGIN="$(gh api user --jq .login 2>/dev/null || echo unknown)"
|
||||||
fi
|
ok "gh authenticated as ${LOGIN}"
|
||||||
|
if [[ -n "${BOT_LOGIN}" && "${LOGIN}" != "${BOT_LOGIN}" ]]; then
|
||||||
|
warn "gh login ${LOGIN} != expected BOT_LOGIN ${BOT_LOGIN}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
fail "gh not authenticated"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
forgejo)
|
||||||
|
if command -v stoke >/dev/null 2>&1 && stoke auth status 2>/dev/null | grep -q 'Login:'; then
|
||||||
|
LOGIN="$(stoke auth status 2>/dev/null | awk '/^Login:/{print $2; exit}')"
|
||||||
|
ok "stoke authenticated as ${LOGIN:-unknown}"
|
||||||
|
else
|
||||||
|
fail "stoke not authenticated (FORGE_BACKEND=forgejo)"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# --- tmux ---
|
||||||
ACTIVE_TMUX=""
|
ACTIVE_TMUX=""
|
||||||
if tmux has-session -t "${TMUX_SESSION}" 2>/dev/null; then
|
if command -v tmux >/dev/null 2>&1; then
|
||||||
ACTIVE_TMUX="${TMUX_SESSION}"
|
if tmux has-session -t "${TMUX_SESSION}" 2>/dev/null; then
|
||||||
elif tmux has-session -t "${LEGACY_TMUX}" 2>/dev/null; then
|
ACTIVE_TMUX="${TMUX_SESSION}"
|
||||||
ACTIVE_TMUX="${LEGACY_TMUX}"
|
elif tmux has-session -t "${LEGACY_TMUX}" 2>/dev/null; then
|
||||||
warn "using legacy tmux session ${LEGACY_TMUX} (prefer ${TMUX_SESSION})"
|
ACTIVE_TMUX="${LEGACY_TMUX}"
|
||||||
|
warn "using legacy tmux session ${LEGACY_TMUX} (prefer ${TMUX_SESSION})"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "tmux not installed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -n "${ACTIVE_TMUX}" ]]; then
|
if [[ -n "${ACTIVE_TMUX}" ]]; then
|
||||||
|
|
@ -47,56 +65,64 @@ else
|
||||||
fail "tmux session missing (run scripts/restore.sh)"
|
fail "tmux session missing (run scripts/restore.sh)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ps -eo args | awk '/poll-loop\.sh/ && !/awk/ {found=1} END{exit !found}'; then
|
if process_running 'poll-loop\.sh'; then
|
||||||
ok "poll-loop.sh process running"
|
ok "poll-loop.sh process running"
|
||||||
else
|
else
|
||||||
fail "poll-loop.sh process not running"
|
fail "poll-loop.sh process not running"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --- state ---
|
||||||
if [[ -f "${STATE_FILE}" ]]; then
|
if [[ -f "${STATE_FILE}" ]]; then
|
||||||
LAST_POLL="$(jq -r '.last_poll_at // empty' "${STATE_FILE}" 2>/dev/null || true)"
|
if command -v jq >/dev/null 2>&1; then
|
||||||
SCOPE="$(jq -r '.scope // .repo // "unknown"' "${STATE_FILE}" 2>/dev/null || echo unknown)"
|
LAST_POLL="$(jq -r '.last_poll_at // empty' "${STATE_FILE}" 2>/dev/null || true)"
|
||||||
ok "scope: ${SCOPE}"
|
SCOPE="$(jq -r '.scope // .repo // "unknown"' "${STATE_FILE}" 2>/dev/null || echo unknown)"
|
||||||
if [[ -n "${LAST_POLL}" ]]; then
|
ok "scope: ${SCOPE}"
|
||||||
if LAST_EPOCH="$(date -u -d "${LAST_POLL}" +%s 2>/dev/null)"; then
|
if [[ -n "${LAST_POLL}" && "${LAST_POLL}" != "null" ]]; then
|
||||||
AGE_MIN=$(( (NOW_EPOCH - LAST_EPOCH) / 60 ))
|
if LAST_EPOCH="$(date -u -d "${LAST_POLL}" +%s 2>/dev/null)"; then
|
||||||
if (( AGE_MIN > STALE_POLL_MINUTES )); then
|
AGE_MIN=$(( (NOW_EPOCH - LAST_EPOCH) / 60 ))
|
||||||
warn "last_poll_at is ${AGE_MIN}m old (${LAST_POLL})"
|
if (( AGE_MIN > STALE_POLL_MINUTES )); then
|
||||||
|
warn "last_poll_at is ${AGE_MIN}m old (${LAST_POLL})"
|
||||||
|
else
|
||||||
|
ok "last_poll_at fresh (${AGE_MIN}m ago: ${LAST_POLL})"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
ok "last_poll_at fresh (${AGE_MIN}m ago: ${LAST_POLL})"
|
ok "last_poll_at present (${LAST_POLL})"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
ok "last_poll_at present (${LAST_POLL})"
|
warn "state.json has no last_poll_at"
|
||||||
fi
|
fi
|
||||||
|
SUMMARY="$(jq -r '.last_poll_summary // "n/a"' "${STATE_FILE}" 2>/dev/null || echo n/a)"
|
||||||
|
ok "last_poll_summary: ${SUMMARY}"
|
||||||
else
|
else
|
||||||
warn "state.json has no last_poll_at"
|
warn "jq missing; cannot parse state.json details"
|
||||||
fi
|
fi
|
||||||
SUMMARY="$(jq -r '.last_poll_summary // "n/a"' "${STATE_FILE}" 2>/dev/null || echo n/a)"
|
|
||||||
ok "last_poll_summary: ${SUMMARY}"
|
|
||||||
else
|
else
|
||||||
fail "state.json missing"
|
fail "state.json missing"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
[[ -f "${WATCHER_DIR}/POLL_INSTRUCTIONS.md" ]] && ok "POLL_INSTRUCTIONS.md present" || fail "POLL_INSTRUCTIONS.md missing"
|
[[ -f "${WATCHER_DIR}/POLL_INSTRUCTIONS.md" ]] && ok "POLL_INSTRUCTIONS.md present" || fail "POLL_INSTRUCTIONS.md missing"
|
||||||
|
|
||||||
|
# --- write health JSON via jq (safe escaping) ---
|
||||||
RESULT_JSON="${LOG_DIR}/health-latest.json"
|
RESULT_JSON="${LOG_DIR}/health-latest.json"
|
||||||
{
|
if command -v jq >/dev/null 2>&1; then
|
||||||
echo "{"
|
jq -n \
|
||||||
echo " \"checked_at\": \"${NOW_ISO}\","
|
--arg t "${NOW_ISO}" \
|
||||||
echo " \"status\": \"${STATUS}\","
|
--arg s "${STATUS}" \
|
||||||
echo " \"issues\": ["
|
--arg backend "${FORGE_BACKEND}" \
|
||||||
first=1
|
--arg org "${ORG}" \
|
||||||
for line in "${ISSUES[@]}"; do
|
--args \
|
||||||
esc="${line//\"/\\\"}"
|
'{
|
||||||
if (( first )); then first=0; else echo ","; fi
|
checked_at: $t,
|
||||||
printf ' "%s"' "${esc}"
|
status: $s,
|
||||||
done
|
backend: $backend,
|
||||||
echo
|
org: $org,
|
||||||
echo " ]"
|
issues: $ARGS.positional
|
||||||
echo "}"
|
}' -- "${ISSUES[@]}" > "${RESULT_JSON}"
|
||||||
} > "${RESULT_JSON}"
|
else
|
||||||
|
echo "{\"checked_at\":\"${NOW_ISO}\",\"status\":\"${STATUS}\",\"issues\":[]}" > "${RESULT_JSON}"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "[${NOW_ISO}] status=${STATUS} | $(IFS=' ; '; echo "${ISSUES[*]}")" >> "${HEALTH_LOG}"
|
watcher_log "${HEALTH_LOG}" "status=${STATUS} | $(IFS=' ; '; echo "${ISSUES[*]}")"
|
||||||
|
|
||||||
if command -v jq >/dev/null 2>&1 && [[ -f "${STATE_FILE}" ]]; then
|
if command -v jq >/dev/null 2>&1 && [[ -f "${STATE_FILE}" ]]; then
|
||||||
tmp="$(mktemp)"
|
tmp="$(mktemp)"
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,14 @@
|
||||||
# Hourly health check + self-heal for poll-loop.
|
# Hourly health check + self-heal for poll-loop.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
WATCHER_DIR="${WATCHER_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
LOG_DIR="${WATCHER_DIR}/logs"
|
# shellcheck source=lib/common.sh
|
||||||
INTERVAL_SEC="${HEALTH_INTERVAL_SEC:-3600}"
|
source "${SCRIPT_DIR}/lib/common.sh"
|
||||||
TMUX_SESSION="${TMUX_SESSION:-heavy-duty-watcher}"
|
|
||||||
LEGACY_TMUX="${LEGACY_TMUX:-rig-watcher}"
|
INTERVAL_SEC="${HEALTH_INTERVAL_SEC}"
|
||||||
mkdir -p "${LOG_DIR}"
|
mkdir -p "${LOG_DIR}"
|
||||||
|
|
||||||
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] health-loop started interval=${INTERVAL_SEC}s" \
|
watcher_log "${LOG_DIR}/health.log" "health-loop started interval=${INTERVAL_SEC}s"
|
||||||
>> "${LOG_DIR}/health.log"
|
|
||||||
|
|
||||||
"${WATCHER_DIR}/scripts/health-check.sh" || true
|
"${WATCHER_DIR}/scripts/health-check.sh" || true
|
||||||
|
|
||||||
|
|
@ -31,9 +30,8 @@ while true; do
|
||||||
rc=$?
|
rc=$?
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
if ! ps -eo args | awk '/poll-loop\.sh/ && !/awk/ {found=1} END{exit !found}'; then
|
if ! process_running 'poll-loop\.sh'; then
|
||||||
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] health-loop: restarting poll-loop" \
|
watcher_log "${LOG_DIR}/health.log" "health-loop: restarting poll-loop"
|
||||||
>> "${LOG_DIR}/health.log"
|
|
||||||
sess="$(session_name)"
|
sess="$(session_name)"
|
||||||
if [[ -n "${sess}" ]]; then
|
if [[ -n "${sess}" ]]; then
|
||||||
tmux list-windows -t "${sess}" -F '#{window_index} #{window_name}' \
|
tmux list-windows -t "${sess}" -F '#{window_index} #{window_name}' \
|
||||||
|
|
@ -50,7 +48,6 @@ while true; do
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if (( rc != 0 )); then
|
if (( rc != 0 )); then
|
||||||
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] health-loop: status non-ok rc=${rc}" \
|
watcher_log "${LOG_DIR}/health.log" "health-loop: status non-ok rc=${rc}"
|
||||||
>> "${LOG_DIR}/health.log"
|
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,24 @@ SRC="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
DEST="${DEST:-$HOME/heavy-duty-watcher}"
|
DEST="${DEST:-$HOME/heavy-duty-watcher}"
|
||||||
TMUX_SESSION="${TMUX_SESSION:-heavy-duty-watcher}"
|
TMUX_SESSION="${TMUX_SESSION:-heavy-duty-watcher}"
|
||||||
|
|
||||||
|
missing=()
|
||||||
|
for c in bash rsync jq tmux; do
|
||||||
|
command -v "${c}" >/dev/null 2>&1 || missing+=("${c}")
|
||||||
|
done
|
||||||
|
if ((${#missing[@]})); then
|
||||||
|
echo "error: missing required tools: ${missing[*]}" >&2
|
||||||
|
echo "install them and re-run (e.g. apt-get install rsync jq tmux)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Soft deps depending on backend
|
||||||
|
if [[ "${FORGE_BACKEND:-github}" == "github" ]] && ! command -v gh >/dev/null 2>&1; then
|
||||||
|
echo "warn: gh not found (required for FORGE_BACKEND=github)" >&2
|
||||||
|
fi
|
||||||
|
if [[ "${FORGE_BACKEND:-github}" == "forgejo" ]] && ! command -v stoke >/dev/null 2>&1; then
|
||||||
|
echo "warn: stoke not found (required for FORGE_BACKEND=forgejo)" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Installing watcher: ${SRC} -> ${DEST}"
|
echo "Installing watcher: ${SRC} -> ${DEST}"
|
||||||
mkdir -p "${DEST}"
|
mkdir -p "${DEST}"
|
||||||
# Copy tree but preserve local state/logs if DEST already has them
|
# Copy tree but preserve local state/logs if DEST already has them
|
||||||
|
|
@ -21,6 +39,8 @@ if [[ ! -f "${DEST}/state.json" ]]; then
|
||||||
fi
|
fi
|
||||||
|
|
||||||
chmod +x "${DEST}/scripts/"*.sh
|
chmod +x "${DEST}/scripts/"*.sh
|
||||||
|
# lib is sourced, not executed
|
||||||
|
chmod +x "${DEST}/scripts/lib/"*.sh 2>/dev/null || true
|
||||||
|
|
||||||
# Compatibility symlink for old path
|
# Compatibility symlink for old path
|
||||||
if [[ ! -e "$HOME/rig-watcher" ]]; then
|
if [[ ! -e "$HOME/rig-watcher" ]]; then
|
||||||
|
|
|
||||||
65
scripts/lib/common.sh
Executable file
65
scripts/lib/common.sh
Executable file
|
|
@ -0,0 +1,65 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Shared helpers for heavy-duty-watcher scripts.
|
||||||
|
# shellcheck shell=bash
|
||||||
|
# Usage: source "$(dirname "$0")/lib/common.sh" (from scripts/*.sh)
|
||||||
|
|
||||||
|
# Resolve repo root from the calling script when WATCHER_DIR is unset.
|
||||||
|
if [[ -z "${WATCHER_DIR:-}" ]]; then
|
||||||
|
_COMMON_HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WATCHER_DIR="$(cd "${_COMMON_HERE}/../.." && pwd)"
|
||||||
|
unset _COMMON_HERE
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Optional local overrides (never committed — see .gitignore).
|
||||||
|
if [[ -f "${WATCHER_DIR}/config/watcher.env" ]]; then
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
set -a
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "${WATCHER_DIR}/config/watcher.env"
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
ORG="${ORG:-heavy-duty}"
|
||||||
|
BOT_LOGIN="${BOT_LOGIN:-grok-bot-andresmgsl}"
|
||||||
|
# github | forgejo — discovery/posting backend
|
||||||
|
FORGE_BACKEND="${FORGE_BACKEND:-github}"
|
||||||
|
FORGEJO_URL="${FORGEJO_URL:-https://forgejo.heavyduty.builders}"
|
||||||
|
POLL_INTERVAL_SEC="${POLL_INTERVAL_SEC:-900}"
|
||||||
|
HEALTH_INTERVAL_SEC="${HEALTH_INTERVAL_SEC:-3600}"
|
||||||
|
STALE_POLL_MINUTES="${STALE_POLL_MINUTES:-45}"
|
||||||
|
TMUX_SESSION="${TMUX_SESSION:-heavy-duty-watcher}"
|
||||||
|
LEGACY_TMUX="${LEGACY_TMUX:-rig-watcher}"
|
||||||
|
LIMIT="${LIMIT:-50}"
|
||||||
|
|
||||||
|
LOG_DIR="${LOG_DIR:-${WATCHER_DIR}/logs}"
|
||||||
|
STATE_FILE="${STATE_FILE:-${WATCHER_DIR}/state.json}"
|
||||||
|
|
||||||
|
watcher_log() {
|
||||||
|
local file="$1"
|
||||||
|
shift
|
||||||
|
mkdir -p "$(dirname "${file}")"
|
||||||
|
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" >> "${file}"
|
||||||
|
}
|
||||||
|
|
||||||
|
utc_now() {
|
||||||
|
date -u +%Y-%m-%dT%H:%M:%SZ
|
||||||
|
}
|
||||||
|
|
||||||
|
require_cmd() {
|
||||||
|
local c
|
||||||
|
for c in "$@"; do
|
||||||
|
if ! command -v "${c}" >/dev/null 2>&1; then
|
||||||
|
echo "error: required command not found: ${c}" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# True if a process whose args contain the given regex is running (excludes awk).
|
||||||
|
process_running() {
|
||||||
|
local pattern="$1"
|
||||||
|
ps -eo args 2>/dev/null | awk -v p="${pattern}" '
|
||||||
|
$0 ~ p && $0 !~ /awk/ { found=1 }
|
||||||
|
END { exit !found }
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
@ -2,13 +2,15 @@
|
||||||
# Long-running poll loop (default 15 minutes) for tmux.
|
# Long-running poll loop (default 15 minutes) for tmux.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
WATCHER_DIR="${WATCHER_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
LOG_DIR="${WATCHER_DIR}/logs"
|
# shellcheck source=lib/common.sh
|
||||||
INTERVAL_SEC="${POLL_INTERVAL_SEC:-900}"
|
source "${SCRIPT_DIR}/lib/common.sh"
|
||||||
|
|
||||||
|
INTERVAL_SEC="${POLL_INTERVAL_SEC}"
|
||||||
mkdir -p "${LOG_DIR}"
|
mkdir -p "${LOG_DIR}"
|
||||||
|
|
||||||
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] poll-loop started interval=${INTERVAL_SEC}s scope=org:heavy-duty (sleep-first)" \
|
watcher_log "${LOG_DIR}/poll.log" \
|
||||||
>> "${LOG_DIR}/poll.log"
|
"poll-loop started interval=${INTERVAL_SEC}s scope=org:${ORG} backend=${FORGE_BACKEND} (sleep-first)"
|
||||||
|
|
||||||
if [[ "${POLL_NOW:-0}" == "1" ]]; then
|
if [[ "${POLL_NOW:-0}" == "1" ]]; then
|
||||||
"${WATCHER_DIR}/scripts/poll-once.sh" || true
|
"${WATCHER_DIR}/scripts/poll-once.sh" || true
|
||||||
|
|
@ -16,8 +18,9 @@ fi
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
sleep "${INTERVAL_SEC}"
|
sleep "${INTERVAL_SEC}"
|
||||||
if ps -eo args | awk '/heavy-duty-watcher\/scripts\/poll-once\.sh/ && !/awk/ {found=1} END{exit !found}'; then
|
# Match by script basename so WATCHER_DIR reinstalls / path moves still work.
|
||||||
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] poll-loop: previous poll still running, skipping" >> "${LOG_DIR}/poll.log"
|
if process_running 'poll-once\.sh'; then
|
||||||
|
watcher_log "${LOG_DIR}/poll.log" "poll-loop: previous poll still running, skipping"
|
||||||
else
|
else
|
||||||
"${WATCHER_DIR}/scripts/poll-once.sh" || true
|
"${WATCHER_DIR}/scripts/poll-once.sh" || true
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -2,37 +2,61 @@
|
||||||
# Single headless poll via Grok for the heavy-duty org watcher.
|
# Single headless poll via Grok for the heavy-duty org watcher.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
WATCHER_DIR="${WATCHER_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
LOG_DIR="${WATCHER_DIR}/logs"
|
# shellcheck source=lib/common.sh
|
||||||
|
source "${SCRIPT_DIR}/lib/common.sh"
|
||||||
|
|
||||||
mkdir -p "${LOG_DIR}"
|
mkdir -p "${LOG_DIR}"
|
||||||
TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
TS="$(utc_now)"
|
||||||
PROMPT_FILE="${WATCHER_DIR}/.poll-prompt.txt"
|
PROMPT_FILE="${WATCHER_DIR}/.poll-prompt.txt"
|
||||||
|
|
||||||
|
case "${FORGE_BACKEND}" in
|
||||||
|
github)
|
||||||
|
DISCOVERY_HINT="gh search prs/issues --owner ${ORG} (review-requested=@me / assignee=@me)"
|
||||||
|
POST_HINT="Prefer gh pr review / gh issue comment"
|
||||||
|
BACKEND_LABEL="GitHub"
|
||||||
|
;;
|
||||||
|
forgejo)
|
||||||
|
DISCOVERY_HINT="bash ${WATCHER_DIR}/scripts/discover.sh # FORGE_BACKEND=forgejo"
|
||||||
|
POST_HINT="Prefer stoke pr review / stoke pr comment (Forgejo at ${FORGEJO_URL})"
|
||||||
|
BACKEND_LABEL="Forgejo"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "error: unknown FORGE_BACKEND=${FORGE_BACKEND}" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
cat > "${PROMPT_FILE}" <<EOF
|
cat > "${PROMPT_FILE}" <<EOF
|
||||||
Execute ONE full poll cycle for the heavy-duty org GitHub watcher.
|
Execute ONE full poll cycle for the heavy-duty org ${BACKEND_LABEL} watcher.
|
||||||
|
|
||||||
1. Read ${WATCHER_DIR}/POLL_INSTRUCTIONS.md and ${WATCHER_DIR}/state.json
|
1. Read ${WATCHER_DIR}/POLL_INSTRUCTIONS.md and ${WATCHER_DIR}/state.json
|
||||||
2. Bot: grok-bot-andresmgsl. Scope: ALL repos under org heavy-duty (not a single repo).
|
2. Bot: ${BOT_LOGIN}. Scope: ALL repos under org ${ORG} (not a single repo). Backend: ${FORGE_BACKEND}.
|
||||||
3. Discover open Issues/PRs where bot is assignee OR review-requested:
|
3. Discover open Issues/PRs where bot is assignee OR review-requested:
|
||||||
gh search prs --owner heavy-duty --review-requested=@me --state open --json number,title,url,updatedAt,author,assignees,repository --limit 50
|
${DISCOVERY_HINT}
|
||||||
gh search prs --owner heavy-duty --assignee=@me --state open --json number,title,url,updatedAt,author,assignees,repository --limit 50
|
Or run: FORGE_BACKEND=${FORGE_BACKEND} bash ${WATCHER_DIR}/scripts/discover.sh
|
||||||
gh search issues --owner heavy-duty --assignee=@me --state open --json number,title,url,updatedAt,author,assignees,repository --limit 50
|
4. For each match: read ALL comments/reviews/inline threads/diff/checks first; never repeat prior ${BOT_LOGIN} comments.
|
||||||
4. For each match: read ALL comments/reviews/inline threads/diff/checks first; never repeat prior grok-bot-andresmgsl comments.
|
|
||||||
5. Comment/review only if first assignment, new commits (head SHA change vs state), new human comments needing reply, material CI change, or explicit re-request. Else silent.
|
5. Comment/review only if first assignment, new commits (head SHA change vs state), new human comments needing reply, material CI change, or explicit re-request. Else silent.
|
||||||
6. Style: clear Verdict (Approve/Comment/Request changes) or Status (Aligned/Feedback/Need info). Specific, non-redundant, actionable. Blockers vs nits. Prefer gh pr review. Keep reviewing across updates until you fully agree (then Approve).
|
6. Style: clear Verdict (Approve/Comment/Request changes) or Status (Aligned/Feedback/Need info). Specific, non-redundant, actionable. Blockers vs nits. ${POST_HINT}. Keep reviewing across updates until you fully agree (then Approve).
|
||||||
7. Never merge/close/reassign/drive-by on unassigned items.
|
7. Never merge/close/reassign/drive-by on unassigned items.
|
||||||
8. Update ${WATCHER_DIR}/state.json (keys like heavy-duty/box#pr:79) + append one line to ${WATCHER_DIR}/logs/poll.log
|
8. Update ${WATCHER_DIR}/state.json (keys like ${ORG}/repo#pr:N) + append one line to ${WATCHER_DIR}/logs/poll.log
|
||||||
9. Brief report only. If zero assignments, log and exit.
|
9. Brief report only. If zero assignments, log and exit.
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
echo "[${TS}] poll-once.sh starting headless grok (org:heavy-duty)" >> "${LOG_DIR}/poll.log"
|
watcher_log "${LOG_DIR}/poll.log" "poll-once.sh starting headless grok (org:${ORG} backend:${FORGE_BACKEND})"
|
||||||
|
|
||||||
|
if ! command -v grok >/dev/null 2>&1; then
|
||||||
|
watcher_log "${LOG_DIR}/poll.log" "poll-once.sh failed: grok CLI not found"
|
||||||
|
echo "error: grok CLI not found on PATH" >&2
|
||||||
|
exit 127
|
||||||
|
fi
|
||||||
|
|
||||||
if grok --always-approve --cwd "${HOME}" --max-turns 80 \
|
if grok --always-approve --cwd "${HOME}" --max-turns 80 \
|
||||||
--single "$(cat "${PROMPT_FILE}")" \
|
--single "$(cat "${PROMPT_FILE}")" \
|
||||||
>> "${LOG_DIR}/poll-headless.log" 2>&1; then
|
>> "${LOG_DIR}/poll-headless.log" 2>&1; then
|
||||||
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] poll-once.sh finished ok" >> "${LOG_DIR}/poll.log"
|
watcher_log "${LOG_DIR}/poll.log" "poll-once.sh finished ok"
|
||||||
else
|
else
|
||||||
rc=$?
|
rc=$?
|
||||||
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] poll-once.sh failed rc=${rc}" >> "${LOG_DIR}/poll.log"
|
watcher_log "${LOG_DIR}/poll.log" "poll-once.sh failed rc=${rc}"
|
||||||
exit "${rc}"
|
exit "${rc}"
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,10 @@
|
||||||
# ./scripts/restore.sh --status # health snapshot
|
# ./scripts/restore.sh --status # health snapshot
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
WATCHER_DIR="${WATCHER_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
STATE_FILE="${WATCHER_DIR}/state.json"
|
# shellcheck source=lib/common.sh
|
||||||
TMUX_SESSION="${TMUX_SESSION:-heavy-duty-watcher}"
|
source "${SCRIPT_DIR}/lib/common.sh"
|
||||||
LOG_DIR="${WATCHER_DIR}/logs"
|
|
||||||
mkdir -p "${LOG_DIR}"
|
mkdir -p "${LOG_DIR}"
|
||||||
|
|
||||||
session_id() {
|
session_id() {
|
||||||
|
|
@ -27,6 +27,7 @@ session_id() {
|
||||||
status() {
|
status() {
|
||||||
echo "=== heavy-duty-watcher status ==="
|
echo "=== heavy-duty-watcher status ==="
|
||||||
echo "dir: ${WATCHER_DIR}"
|
echo "dir: ${WATCHER_DIR}"
|
||||||
|
echo "backend: ${FORGE_BACKEND} org: ${ORG} bot: ${BOT_LOGIN}"
|
||||||
if [[ -f "${STATE_FILE}" ]]; then
|
if [[ -f "${STATE_FILE}" ]]; then
|
||||||
jq '{scope, org, bot_login, session_id, last_poll_at, last_poll_summary, health, items: (.items|keys)}' \
|
jq '{scope, org, bot_login, session_id, last_poll_at, last_poll_summary, health, items: (.items|keys)}' \
|
||||||
"${STATE_FILE}" 2>/dev/null || cat "${STATE_FILE}"
|
"${STATE_FILE}" 2>/dev/null || cat "${STATE_FILE}"
|
||||||
|
|
@ -36,7 +37,14 @@ status() {
|
||||||
echo
|
echo
|
||||||
tmux ls 2>/dev/null || echo "(no tmux sessions)"
|
tmux ls 2>/dev/null || echo "(no tmux sessions)"
|
||||||
echo
|
echo
|
||||||
echo "gh user: $(gh api user --jq .login 2>/dev/null || echo 'not authenticated')"
|
case "${FORGE_BACKEND}" in
|
||||||
|
github)
|
||||||
|
echo "gh user: $(gh api user --jq .login 2>/dev/null || echo 'not authenticated')"
|
||||||
|
;;
|
||||||
|
forgejo)
|
||||||
|
echo "stoke: $(stoke auth status 2>/dev/null | head -n 4 | tr '\n' ' ' || echo 'not authenticated')"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
echo "session_id: $(session_id || true)"
|
echo "session_id: $(session_id || true)"
|
||||||
[[ -f "${LOG_DIR}/poll.log" ]] && { echo; echo "last poll log:"; tail -n 5 "${LOG_DIR}/poll.log"; }
|
[[ -f "${LOG_DIR}/poll.log" ]] && { echo; echo "last poll log:"; tail -n 5 "${LOG_DIR}/poll.log"; }
|
||||||
[[ -f "${LOG_DIR}/health.log" ]] && { echo; echo "last health log:"; tail -n 5 "${LOG_DIR}/health.log"; }
|
[[ -f "${LOG_DIR}/health.log" ]] && { echo; echo "last health log:"; tail -n 5 "${LOG_DIR}/health.log"; }
|
||||||
|
|
@ -55,7 +63,7 @@ start_or_attach() {
|
||||||
local sid resume_prompt
|
local sid resume_prompt
|
||||||
sid="$(session_id)"
|
sid="$(session_id)"
|
||||||
resume_prompt="$(cat <<EOF
|
resume_prompt="$(cat <<EOF
|
||||||
Resume the heavy-duty org GitHub watcher (ALL heavy-duty/* repos).
|
Resume the heavy-duty org watcher (ALL ${ORG}/* repos, backend=${FORGE_BACKEND}).
|
||||||
|
|
||||||
1. Read ${WATCHER_DIR}/POLL_INSTRUCTIONS.md and ${WATCHER_DIR}/state.json
|
1. Read ${WATCHER_DIR}/POLL_INSTRUCTIONS.md and ${WATCHER_DIR}/state.json
|
||||||
2. Run one full org-wide poll cycle immediately (discover assigned/review-requested across org, read all comments, review until you agree)
|
2. Run one full org-wide poll cycle immediately (discover assigned/review-requested across org, read all comments, review until you agree)
|
||||||
|
|
@ -103,7 +111,7 @@ EOF
|
||||||
"echo 'tools: ./scripts/discover.sh | ./scripts/health-check.sh | tail -f logs/poll.log'; exec bash"
|
"echo 'tools: ./scripts/discover.sh | ./scripts/health-check.sh | tail -f logs/poll.log'; exec bash"
|
||||||
|
|
||||||
tmux select-window -t "${TMUX_SESSION}:grok"
|
tmux select-window -t "${TMUX_SESSION}:grok"
|
||||||
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) restore.sh started ${TMUX_SESSION}" >> "${LOG_DIR}/poll.log"
|
watcher_log "${LOG_DIR}/poll.log" "restore.sh started ${TMUX_SESSION}"
|
||||||
exec tmux attach -t "${TMUX_SESSION}"
|
exec tmux attach -t "${TMUX_SESSION}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
79
scripts/self-test.sh
Executable file
79
scripts/self-test.sh
Executable file
|
|
@ -0,0 +1,79 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Offline smoke checks for the watcher tree (no network required for syntax).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "${ROOT}"
|
||||||
|
fail=0
|
||||||
|
|
||||||
|
echo "== bash -n =="
|
||||||
|
while IFS= read -r -d '' f; do
|
||||||
|
if bash -n "${f}"; then
|
||||||
|
echo " ok ${f}"
|
||||||
|
else
|
||||||
|
echo " FAIL ${f}"
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
done < <(find scripts -name '*.sh' -print0 | sort -z)
|
||||||
|
|
||||||
|
echo "== required files =="
|
||||||
|
for f in \
|
||||||
|
POLL_INSTRUCTIONS.md \
|
||||||
|
README.md \
|
||||||
|
config/state.template.json \
|
||||||
|
config/watcher.env.example \
|
||||||
|
scripts/lib/common.sh \
|
||||||
|
scripts/discover.sh \
|
||||||
|
scripts/poll-once.sh \
|
||||||
|
scripts/poll-loop.sh \
|
||||||
|
scripts/health-check.sh \
|
||||||
|
scripts/health-loop.sh \
|
||||||
|
scripts/restore.sh \
|
||||||
|
scripts/install-live.sh
|
||||||
|
do
|
||||||
|
if [[ -f "${f}" ]]; then
|
||||||
|
echo " ok ${f}"
|
||||||
|
else
|
||||||
|
echo " FAIL missing ${f}"
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "== source common.sh =="
|
||||||
|
# shellcheck source=lib/common.sh
|
||||||
|
if WATCHER_DIR="${ROOT}" source "${ROOT}/scripts/lib/common.sh" \
|
||||||
|
&& [[ -n "${ORG:-}" && -n "${BOT_LOGIN:-}" ]]; then
|
||||||
|
echo " ok ORG=${ORG} BOT_LOGIN=${BOT_LOGIN} FORGE_BACKEND=${FORGE_BACKEND}"
|
||||||
|
else
|
||||||
|
echo " FAIL sourcing common.sh"
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== state template is valid JSON =="
|
||||||
|
if command -v jq >/dev/null 2>&1; then
|
||||||
|
if jq empty config/state.template.json; then
|
||||||
|
echo " ok state.template.json"
|
||||||
|
else
|
||||||
|
echo " FAIL state.template.json"
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " skip jq not installed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== discover --help-ish (dry: unknown backend should fail cleanly) =="
|
||||||
|
if FORGE_BACKEND=not-a-backend FORMAT=json bash scripts/discover.sh 2>/dev/null; then
|
||||||
|
echo " FAIL expected non-zero for bad backend"
|
||||||
|
fail=1
|
||||||
|
else
|
||||||
|
echo " ok bad backend rejected"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (( fail == 0 )); then
|
||||||
|
echo
|
||||||
|
echo "self-test: PASS"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
echo "self-test: FAIL"
|
||||||
|
exit 1
|
||||||
Loading…
Reference in a new issue