- Moved all agent loop scripts into source control (bin/) - claude-loop.sh, gemini-loop.sh, timmy-orchestrator.sh - workforce-manager.py, agent-dispatch.sh, nexus-merge-bot.sh - ops dashboard scripts (ops-panel, ops-helpers, ops-gitea) - monitoring scripts (timmy-status, timmy-loopstat) - deploy.sh: one-command overlay onto ~/.hermes/ - Updated README with sidecar architecture docs - All loops now target the-nexus + autolora only
217 lines
6.4 KiB
Bash
Executable File
217 lines
6.4 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# nexus-merge-bot.sh — Auto-review and auto-merge for the-nexus
|
|
# Polls open PRs. For each: clone, validate (HTML/JS/JSON/size), merge if clean.
|
|
# Runs as a loop. Squash-only. Linear history.
|
|
#
|
|
# Pattern: matches Timmy-time-dashboard merge policy.
|
|
# Pre-commit hooks + this bot are the gates. If gates pass, auto-merge.
|
|
|
|
set -uo pipefail
|
|
|
|
LOG_DIR="$HOME/.hermes/logs"
|
|
LOG="$LOG_DIR/nexus-merge-bot.log"
|
|
PIDFILE="$LOG_DIR/nexus-merge-bot.pid"
|
|
GITEA_URL="http://143.198.27.163:3000"
|
|
GITEA_TOKEN=$(cat "$HOME/.hermes/gitea_token_vps" 2>/dev/null)
|
|
REPO="Timmy_Foundation/the-nexus"
|
|
CHECK_INTERVAL=60 # 2 minutes
|
|
|
|
mkdir -p "$LOG_DIR"
|
|
|
|
# Single instance guard
|
|
if [ -f "$PIDFILE" ]; then
|
|
old_pid=$(cat "$PIDFILE")
|
|
if kill -0 "$old_pid" 2>/dev/null; then
|
|
echo "Merge bot already running (PID $old_pid)" >&2
|
|
exit 0
|
|
fi
|
|
fi
|
|
echo $$ > "$PIDFILE"
|
|
trap 'rm -f "$PIDFILE"' EXIT
|
|
|
|
log() {
|
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] MERGE-BOT: $*" >> "$LOG"
|
|
}
|
|
|
|
validate_pr() {
|
|
local pr_num="$1"
|
|
local work_dir="/tmp/nexus-validate-$$"
|
|
rm -rf "$work_dir"
|
|
|
|
# Get PR head branch
|
|
local pr_info
|
|
pr_info=$(curl -s --max-time 10 -H "Authorization: token ${GITEA_TOKEN}" \
|
|
"${GITEA_URL}/api/v1/repos/${REPO}/pulls/${pr_num}")
|
|
|
|
local head_ref
|
|
head_ref=$(echo "$pr_info" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['head']['ref'])" 2>/dev/null)
|
|
local mergeable
|
|
mergeable=$(echo "$pr_info" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('mergeable', False))" 2>/dev/null)
|
|
|
|
if [ "$mergeable" != "True" ]; then
|
|
log "PR #${pr_num}: not mergeable (conflicts), skipping"
|
|
echo "CONFLICT"
|
|
return 1
|
|
fi
|
|
|
|
# Clone and checkout the PR branch
|
|
git clone -q --depth 20 \
|
|
"http://Timmy:${GITEA_TOKEN}@143.198.27.163:3000/${REPO}.git" "$work_dir" 2>&1 | tail -5 >> "$LOG"
|
|
|
|
if [ ! -d "$work_dir/.git" ]; then
|
|
log "PR #${pr_num}: clone failed"
|
|
echo "CLONE_FAIL"
|
|
return 1
|
|
fi
|
|
|
|
cd "$work_dir" || return 1
|
|
|
|
# Fetch and checkout the PR branch
|
|
git fetch origin "$head_ref" 2>/dev/null && git checkout "$head_ref" 2>/dev/null
|
|
if [ $? -ne 0 ]; then
|
|
# Try fetching the PR ref directly
|
|
git fetch origin "pull/${pr_num}/head:pr-${pr_num}" 2>/dev/null && git checkout "pr-${pr_num}" 2>/dev/null
|
|
fi
|
|
local FAIL=0
|
|
|
|
# 1. HTML validation
|
|
if [ -f index.html ]; then
|
|
python3 -c "
|
|
import html.parser
|
|
class V(html.parser.HTMLParser):
|
|
pass
|
|
v = V()
|
|
v.feed(open('index.html').read())
|
|
" 2>/dev/null || { log "PR #${pr_num}: HTML validation failed"; FAIL=1; }
|
|
fi
|
|
|
|
# 2. JS syntax check (node --check)
|
|
for f in $(find . -name '*.js' -not -path './node_modules/*' 2>/dev/null); do
|
|
if command -v node >/dev/null 2>&1; then
|
|
if ! node --check "$f" 2>/dev/null; then
|
|
log "PR #${pr_num}: JS syntax error in $f"
|
|
FAIL=1
|
|
fi
|
|
fi
|
|
done
|
|
|
|
# 3. JSON validation
|
|
for f in $(find . -name '*.json' -not -path './node_modules/*' 2>/dev/null); do
|
|
if ! python3 -c "import json; json.load(open('$f'))" 2>/dev/null; then
|
|
log "PR #${pr_num}: invalid JSON in $f"
|
|
FAIL=1
|
|
fi
|
|
done
|
|
|
|
# 4. File size budget (500KB per JS file)
|
|
for f in $(find . -name '*.js' -not -path './node_modules/*' 2>/dev/null); do
|
|
local size
|
|
size=$(wc -c < "$f")
|
|
if [ "$size" -gt 512000 ]; then
|
|
log "PR #${pr_num}: $f exceeds 500KB budget (${size} bytes)"
|
|
FAIL=1
|
|
fi
|
|
done
|
|
|
|
# Cleanup
|
|
rm -rf "$work_dir"
|
|
|
|
if [ $FAIL -eq 0 ]; then
|
|
echo "PASS"
|
|
return 0
|
|
else
|
|
echo "FAIL"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
merge_pr() {
|
|
local pr_num="$1"
|
|
local result
|
|
result=$(curl -s --max-time 30 -X POST \
|
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"Do":"squash","delete_branch_after_merge":true}' \
|
|
"${GITEA_URL}/api/v1/repos/${REPO}/pulls/${pr_num}/merge")
|
|
|
|
if echo "$result" | grep -q '"sha"'; then
|
|
log "PR #${pr_num}: MERGED (squash)"
|
|
return 0
|
|
elif echo "$result" | grep -q '"message"'; then
|
|
local msg
|
|
msg=$(echo "$result" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('message','unknown'))" 2>/dev/null)
|
|
log "PR #${pr_num}: merge failed: $msg"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
comment_pr() {
|
|
local pr_num="$1"
|
|
local body="$2"
|
|
curl -s --max-time 10 -X POST \
|
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
-H "Content-Type: application/json" \
|
|
-d "{\"body\": \"$body\"}" \
|
|
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${pr_num}/comments" >/dev/null
|
|
}
|
|
|
|
log "Starting nexus merge bot (PID $$)"
|
|
|
|
while true; do
|
|
# Get open PRs
|
|
prs=$(curl -s --max-time 15 -H "Authorization: token ${GITEA_TOKEN}" \
|
|
"${GITEA_URL}/api/v1/repos/${REPO}/pulls?state=open&sort=newest&limit=20")
|
|
|
|
pr_count=$(echo "$prs" | python3 -c "import sys,json; print(len(json.loads(sys.stdin.buffer.read())))" 2>/dev/null || echo "0")
|
|
|
|
if [ "$pr_count" = "0" ] || [ -z "$pr_count" ]; then
|
|
log "No open PRs. Sleeping ${CHECK_INTERVAL}s"
|
|
sleep "$CHECK_INTERVAL"
|
|
continue
|
|
fi
|
|
|
|
log "Found ${pr_count} open PRs, validating..."
|
|
|
|
# Process PRs one at a time, oldest first (sequential merge)
|
|
pr_nums=$(echo "$prs" | python3 -c "
|
|
import sys, json
|
|
prs = json.loads(sys.stdin.buffer.read())
|
|
for p in prs:
|
|
print(p['number'])
|
|
" 2>/dev/null)
|
|
|
|
for pr_num in $pr_nums; do
|
|
log "Validating PR #${pr_num}..."
|
|
result=$(validate_pr "$pr_num")
|
|
|
|
case "$result" in
|
|
PASS)
|
|
log "PR #${pr_num}: validation passed, merging..."
|
|
comment_pr "$pr_num" "🤖 **Merge Bot**: CI validation passed (HTML, JS syntax, JSON, size budget). Auto-merging."
|
|
merge_pr "$pr_num"
|
|
# Wait a beat for Gitea to process
|
|
sleep 5
|
|
;;
|
|
CONFLICT)
|
|
# Auto-close stale conflicting PRs — don't let them pile up
|
|
log "PR #${pr_num}: conflicts, closing"
|
|
comment_pr "$pr_num" "🤖 **Merge Bot**: Merge conflicts with main. Closing. The issue remains open — next agent cycle will pick it up fresh."
|
|
curl -s --max-time 5 -X PATCH \
|
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"state":"closed"}' \
|
|
"${GITEA_URL}/api/v1/repos/${REPO}/pulls/${pr_num}" >/dev/null 2>&1
|
|
;;
|
|
FAIL)
|
|
comment_pr "$pr_num" "🤖 **Merge Bot**: CI validation failed. Check the merge-bot log for details."
|
|
;;
|
|
*)
|
|
log "PR #${pr_num}: unknown result: $result"
|
|
;;
|
|
esac
|
|
done
|
|
|
|
log "Cycle complete. Sleeping ${CHECK_INTERVAL}s"
|
|
sleep "$CHECK_INTERVAL"
|
|
done
|