2026-03-12 12:44:15 +00:00
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
# review-poll.sh — Poll open PRs and review those with green CI
|
|
|
|
|
#
|
2026-03-14 13:49:09 +01:00
|
|
|
# Peek while running: cat /tmp/<project>-review-status
|
|
|
|
|
# Full log: tail -f <factory-root>/review/review.log
|
2026-03-12 12:44:15 +00:00
|
|
|
|
|
|
|
|
set -euo pipefail
|
|
|
|
|
|
refactor: split supervisor into infra + per-project, make poll scripts config-driven
Supervisor split (#26):
- Layer 1 (infra): P0 memory, P1 disk, P4 housekeeping — runs once, project-agnostic
- Layer 2 (per-project): P2 CI/dev-agent, P3 PRs/deps — iterates projects/*.toml
- Adding a new project requires only a new TOML file, no code changes
Poll scripts accept project TOML arg (#27):
- dev-poll.sh, review-poll.sh, gardener-poll.sh accept optional project TOML as $1
- env.sh loads PROJECT_TOML if set, overriding .env defaults
- Cron: `dev-poll.sh projects/versi.toml` targets that project
New files:
- lib/load-project.sh: TOML to env var loader (Python tomllib)
- projects/versi.toml: current project config extracted from .env
Backwards compatible: scripts without a TOML arg fall back to .env config.
Closes #26, Closes #27
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 08:57:18 +01:00
|
|
|
# Load shared environment (with optional project TOML override)
|
|
|
|
|
# Usage: review-poll.sh [projects/harb.toml]
|
|
|
|
|
export PROJECT_TOML="${1:-}"
|
2026-03-12 12:44:15 +00:00
|
|
|
source "$(dirname "$0")/../lib/env.sh"
|
2026-03-18 02:05:54 +00:00
|
|
|
source "$(dirname "$0")/../lib/ci-helpers.sh"
|
2026-03-12 12:44:15 +00:00
|
|
|
|
2026-03-18 01:53:02 +00:00
|
|
|
# shellcheck disable=SC2034
|
2026-03-17 23:56:04 +00:00
|
|
|
REPO_ROOT="${PROJECT_REPO_ROOT}"
|
2026-03-12 12:44:15 +00:00
|
|
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
|
|
|
|
|
|
|
|
API_BASE="${CODEBERG_API}"
|
|
|
|
|
LOGFILE="$SCRIPT_DIR/review.log"
|
|
|
|
|
MAX_REVIEWS=3
|
2026-03-17 23:56:04 +00:00
|
|
|
REVIEW_IDLE_TIMEOUT=14400 # 4h: kill review session if idle
|
2026-03-12 12:44:15 +00:00
|
|
|
|
|
|
|
|
log() {
|
|
|
|
|
printf '[%s] %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Log rotation
|
|
|
|
|
if [ -f "$LOGFILE" ]; then
|
|
|
|
|
LOGSIZE=$(stat -c%s "$LOGFILE" 2>/dev/null || echo 0)
|
|
|
|
|
if [ "$LOGSIZE" -gt 102400 ]; then
|
|
|
|
|
mv "$LOGFILE" "$LOGFILE.old"
|
|
|
|
|
log "Log rotated"
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
log "--- Poll start ---"
|
|
|
|
|
|
2026-03-17 23:56:04 +00:00
|
|
|
# --- Clean up stale review sessions ---
|
|
|
|
|
# Kill sessions for merged/closed PRs or idle > 4h
|
|
|
|
|
REVIEW_SESSIONS=$(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep "^review-${PROJECT_NAME}-" || true)
|
|
|
|
|
if [ -n "$REVIEW_SESSIONS" ]; then
|
|
|
|
|
while IFS= read -r session; do
|
|
|
|
|
pr_num="${session#review-"${PROJECT_NAME}"-}"
|
|
|
|
|
phase_file="/tmp/review-session-${PROJECT_NAME}-${pr_num}.phase"
|
|
|
|
|
|
|
|
|
|
# Check if PR is still open
|
|
|
|
|
pr_state=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
|
|
|
"${API_BASE}/pulls/${pr_num}" | jq -r '.state // "unknown"' 2>/dev/null) || true
|
|
|
|
|
|
|
|
|
|
if [ "$pr_state" != "open" ]; then
|
|
|
|
|
log "cleanup: killing session ${session} (PR #${pr_num} state=${pr_state})"
|
|
|
|
|
tmux kill-session -t "$session" 2>/dev/null || true
|
2026-03-18 09:29:41 +00:00
|
|
|
rm -f "$phase_file" "/tmp/${PROJECT_NAME}-review-output-${pr_num}.json" \
|
|
|
|
|
"/tmp/review-injected-${PROJECT_NAME}-${pr_num}"
|
2026-03-20 18:21:42 +00:00
|
|
|
# Prune thread-map entries for this PR
|
2026-03-21 07:38:39 +00:00
|
|
|
sed -i "/\treview\t[^\t]*\t${pr_num}\t/d" "${MATRIX_THREAD_MAP:-/tmp/matrix-thread-map}" 2>/dev/null || true
|
2026-03-17 23:56:04 +00:00
|
|
|
cd "$REPO_ROOT"
|
|
|
|
|
git worktree remove "/tmp/${PROJECT_NAME}-review-${pr_num}" --force 2>/dev/null || true
|
|
|
|
|
rm -rf "/tmp/${PROJECT_NAME}-review-${pr_num}" 2>/dev/null || true
|
|
|
|
|
continue
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
# Check idle timeout (4h)
|
|
|
|
|
phase_mtime=$(stat -c %Y "$phase_file" 2>/dev/null || echo 0)
|
|
|
|
|
now=$(date +%s)
|
|
|
|
|
if [ "$phase_mtime" -gt 0 ] && [ $(( now - phase_mtime )) -gt "$REVIEW_IDLE_TIMEOUT" ]; then
|
|
|
|
|
log "cleanup: killing session ${session} (idle > 4h)"
|
|
|
|
|
tmux kill-session -t "$session" 2>/dev/null || true
|
2026-03-18 09:29:41 +00:00
|
|
|
rm -f "$phase_file" "/tmp/${PROJECT_NAME}-review-output-${pr_num}.json" \
|
|
|
|
|
"/tmp/review-injected-${PROJECT_NAME}-${pr_num}"
|
2026-03-20 18:21:42 +00:00
|
|
|
# Prune thread-map entries for this PR
|
2026-03-21 07:38:39 +00:00
|
|
|
sed -i "/\treview\t[^\t]*\t${pr_num}\t/d" "${MATRIX_THREAD_MAP:-/tmp/matrix-thread-map}" 2>/dev/null || true
|
2026-03-18 00:04:30 +00:00
|
|
|
cd "$REPO_ROOT"
|
|
|
|
|
git worktree remove "/tmp/${PROJECT_NAME}-review-${pr_num}" --force 2>/dev/null || true
|
|
|
|
|
rm -rf "/tmp/${PROJECT_NAME}-review-${pr_num}" 2>/dev/null || true
|
2026-03-17 23:56:04 +00:00
|
|
|
continue
|
|
|
|
|
fi
|
2026-03-19 20:30:27 +00:00
|
|
|
|
|
|
|
|
# Safety net: clean up sessions in terminal phases (review already posted)
|
|
|
|
|
current_phase=$(head -1 "$phase_file" 2>/dev/null | tr -d '[:space:]' || true)
|
|
|
|
|
if [ "$current_phase" = "PHASE:review_complete" ]; then
|
|
|
|
|
log "cleanup: killing session ${session} (terminal phase: review_complete)"
|
|
|
|
|
tmux kill-session -t "$session" 2>/dev/null || true
|
|
|
|
|
rm -f "$phase_file" "/tmp/${PROJECT_NAME}-review-output-${pr_num}.json" \
|
|
|
|
|
"/tmp/review-injected-${PROJECT_NAME}-${pr_num}"
|
2026-03-21 07:38:39 +00:00
|
|
|
sed -i "/\treview\t[^\t]*\t${pr_num}\t/d" "${MATRIX_THREAD_MAP:-/tmp/matrix-thread-map}" 2>/dev/null || true
|
2026-03-19 20:30:27 +00:00
|
|
|
cd "$REPO_ROOT"
|
|
|
|
|
git worktree remove "/tmp/${PROJECT_NAME}-review-${pr_num}" --force 2>/dev/null || true
|
|
|
|
|
rm -rf "/tmp/${PROJECT_NAME}-review-${pr_num}" 2>/dev/null || true
|
|
|
|
|
continue
|
|
|
|
|
fi
|
2026-03-17 23:56:04 +00:00
|
|
|
done <<< "$REVIEW_SESSIONS"
|
|
|
|
|
fi
|
|
|
|
|
|
2026-03-12 12:44:15 +00:00
|
|
|
PRS=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
|
|
|
"${API_BASE}/pulls?state=open&limit=20" | \
|
2026-03-17 20:52:51 +00:00
|
|
|
jq -r --arg branch "${PRIMARY_BRANCH}" '.[] | select(.base.ref == $branch) | select(.draft != true) | select(.title | test("^\\[?WIP[\\]:]"; "i") | not) | "\(.number) \(.head.sha) \(.head.ref)"')
|
2026-03-12 12:44:15 +00:00
|
|
|
|
|
|
|
|
if [ -z "$PRS" ]; then
|
2026-03-14 13:49:09 +01:00
|
|
|
log "No open PRs targeting ${PRIMARY_BRANCH}"
|
2026-03-12 12:44:15 +00:00
|
|
|
exit 0
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
TOTAL=$(echo "$PRS" | wc -l)
|
|
|
|
|
log "Found ${TOTAL} open PRs"
|
|
|
|
|
|
|
|
|
|
REVIEWED=0
|
|
|
|
|
SKIPPED=0
|
|
|
|
|
|
2026-03-17 20:52:51 +00:00
|
|
|
inject_review_into_dev_session() {
|
|
|
|
|
local pr_num="$1" pr_sha="$2" pr_branch="$3"
|
|
|
|
|
|
|
|
|
|
local issue_num
|
|
|
|
|
issue_num=$(printf '%s' "$pr_branch" | grep -oP 'issue-\K[0-9]+' || true)
|
|
|
|
|
[ -z "$issue_num" ] && return 0
|
|
|
|
|
|
|
|
|
|
local session="dev-${PROJECT_NAME}-${issue_num}"
|
|
|
|
|
local phase_file="/tmp/dev-session-${PROJECT_NAME}-${issue_num}.phase"
|
|
|
|
|
|
|
|
|
|
tmux has-session -t "${session}" 2>/dev/null || return 0
|
|
|
|
|
|
|
|
|
|
local current_phase
|
|
|
|
|
current_phase=$(head -1 "${phase_file}" 2>/dev/null | tr -d '[:space:]' || true)
|
|
|
|
|
[ "${current_phase}" = "PHASE:awaiting_review" ] || return 0
|
|
|
|
|
|
|
|
|
|
local review_comment
|
2026-03-18 08:13:43 +00:00
|
|
|
review_comment=$(codeberg_api_all "/issues/${pr_num}/comments" | \
|
2026-03-17 20:52:51 +00:00
|
|
|
jq -r --arg sha "${pr_sha}" \
|
|
|
|
|
'[.[] | select(.body | contains("<!-- reviewed: " + $sha))] | last // empty') || true
|
|
|
|
|
if [ -z "${review_comment}" ] || [ "${review_comment}" = "null" ]; then
|
|
|
|
|
return 0
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
local review_text verdict
|
|
|
|
|
review_text=$(printf '%s' "${review_comment}" | jq -r '.body')
|
|
|
|
|
verdict=$(printf '%s' "${review_text}" | grep -oP '\*\*(APPROVE|REQUEST_CHANGES|DISCUSS)\*\*' | head -1 | tr -d '*' || true)
|
|
|
|
|
|
|
|
|
|
local inject_msg=""
|
|
|
|
|
if [ "${verdict}" = "APPROVE" ]; then
|
|
|
|
|
inject_msg="Approved! PR #${pr_num} has been approved by the reviewer.
|
2026-03-18 18:10:30 +00:00
|
|
|
Merge the PR and close the issue, then signal done:
|
|
|
|
|
|
|
|
|
|
curl -sf -X POST \\
|
|
|
|
|
-H \"Authorization: token \$CODEBERG_TOKEN\" \\
|
|
|
|
|
-H 'Content-Type: application/json' \\
|
|
|
|
|
\"${API_BASE}/pulls/${pr_num}/merge\" \\
|
|
|
|
|
-d '{\"Do\":\"merge\",\"delete_branch_after_merge\":true}'
|
|
|
|
|
|
|
|
|
|
curl -sf -X PATCH \\
|
|
|
|
|
-H \"Authorization: token \$CODEBERG_TOKEN\" \\
|
|
|
|
|
-H 'Content-Type: application/json' \\
|
|
|
|
|
\"${API_BASE}/issues/${issue_num}\" \\
|
|
|
|
|
-d '{\"state\":\"closed\"}'
|
|
|
|
|
|
|
|
|
|
echo \"PHASE:done\" > \"${phase_file}\"
|
|
|
|
|
|
|
|
|
|
If merge fails due to conflicts, rebase first then retry.
|
2026-03-21 19:39:04 +00:00
|
|
|
If merge repeatedly fails, write PHASE:escalate with a reason."
|
2026-03-17 20:52:51 +00:00
|
|
|
elif [ "${verdict}" = "REQUEST_CHANGES" ] || [ "${verdict}" = "DISCUSS" ]; then
|
|
|
|
|
inject_msg="Review: ${verdict} on PR #${pr_num}:
|
|
|
|
|
|
|
|
|
|
${review_text}
|
|
|
|
|
|
|
|
|
|
Instructions:
|
|
|
|
|
1. Address each piece of feedback carefully.
|
|
|
|
|
2. Run lint and tests when done.
|
|
|
|
|
3. Commit your changes and push: git push origin ${pr_branch}
|
|
|
|
|
4. Write: echo \"PHASE:awaiting_ci\" > \"${phase_file}\"
|
|
|
|
|
5. Stop and wait for the next CI result."
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
[ -z "${inject_msg}" ] && return 0
|
|
|
|
|
|
|
|
|
|
local inject_tmp
|
|
|
|
|
inject_tmp=$(mktemp /tmp/review-inject-XXXXXX)
|
|
|
|
|
printf '%s' "${inject_msg}" > "${inject_tmp}"
|
2026-03-17 21:19:47 +00:00
|
|
|
# All tmux calls guarded with || true: the dev session is external and may die
|
|
|
|
|
# between the has-session check above and here; a non-zero exit must not abort
|
|
|
|
|
# the outer poll loop under set -euo pipefail.
|
|
|
|
|
tmux load-buffer -b "review-inject-${pr_num}" "${inject_tmp}" || true
|
|
|
|
|
tmux paste-buffer -t "${session}" -b "review-inject-${pr_num}" || true
|
2026-03-17 20:52:51 +00:00
|
|
|
sleep 0.5
|
2026-03-17 21:19:47 +00:00
|
|
|
tmux send-keys -t "${session}" "" Enter || true
|
|
|
|
|
tmux delete-buffer -b "review-inject-${pr_num}" 2>/dev/null || true
|
2026-03-17 20:52:51 +00:00
|
|
|
rm -f "${inject_tmp}"
|
|
|
|
|
log " #${pr_num} review (${verdict}) injected into session ${session}"
|
2026-03-18 09:01:50 +00:00
|
|
|
# Write sentinel so dev-agent.sh awaiting_review loop skips its own injection
|
|
|
|
|
touch "/tmp/review-injected-${PROJECT_NAME}-${pr_num}"
|
2026-03-17 20:52:51 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-19 20:09:22 +00:00
|
|
|
# --- Re-review: trigger review for awaiting_changes sessions with new commits ---
|
|
|
|
|
if [ -n "${REVIEW_SESSIONS:-}" ]; then
|
|
|
|
|
while IFS= read -r session; do
|
|
|
|
|
pr_num="${session#review-"${PROJECT_NAME}"-}"
|
|
|
|
|
phase_file="/tmp/review-session-${PROJECT_NAME}-${pr_num}.phase"
|
|
|
|
|
|
|
|
|
|
current_phase=$(head -1 "$phase_file" 2>/dev/null | tr -d '[:space:]' || true)
|
|
|
|
|
[ "$current_phase" = "PHASE:awaiting_changes" ] || continue
|
|
|
|
|
|
|
|
|
|
reviewed_sha=$(sed -n 's/^SHA://p' "$phase_file" 2>/dev/null | tr -d '[:space:]' || true)
|
|
|
|
|
[ -n "$reviewed_sha" ] || continue
|
|
|
|
|
|
|
|
|
|
pr_json=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
|
|
|
"${API_BASE}/pulls/${pr_num}" 2>/dev/null || true)
|
|
|
|
|
[ -n "$pr_json" ] || continue
|
|
|
|
|
|
|
|
|
|
pr_state=$(printf '%s' "$pr_json" | jq -r '.state // "unknown"')
|
|
|
|
|
[ "$pr_state" = "open" ] || continue
|
|
|
|
|
|
|
|
|
|
current_sha=$(printf '%s' "$pr_json" | jq -r '.head.sha // ""')
|
|
|
|
|
pr_branch=$(printf '%s' "$pr_json" | jq -r '.head.ref // ""')
|
|
|
|
|
if [ -z "$current_sha" ] || [ "$current_sha" = "$reviewed_sha" ]; then continue; fi
|
|
|
|
|
|
|
|
|
|
ci_state=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
|
|
|
"${API_BASE}/commits/${current_sha}/status" | jq -r '.state // "unknown"')
|
|
|
|
|
|
|
|
|
|
if ! ci_passed "$ci_state"; then
|
|
|
|
|
if ci_required_for_pr "$pr_num"; then
|
|
|
|
|
log " #${pr_num} awaiting_changes: new SHA ${current_sha:0:7} CI=${ci_state}, waiting"
|
|
|
|
|
continue
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
log " #${pr_num} re-review: new commits (${reviewed_sha:0:7}→${current_sha:0:7})"
|
|
|
|
|
|
|
|
|
|
if "${SCRIPT_DIR}/review-pr.sh" "$pr_num" 2>&1; then
|
|
|
|
|
REVIEWED=$((REVIEWED + 1))
|
|
|
|
|
FRESH_SHA=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
|
|
|
"${API_BASE}/pulls/${pr_num}" | jq -r '.head.sha // ""') || true
|
|
|
|
|
inject_review_into_dev_session "$pr_num" "${FRESH_SHA:-$current_sha}" "$pr_branch"
|
|
|
|
|
else
|
|
|
|
|
log " #${pr_num} re-review failed"
|
|
|
|
|
matrix_send "review" "❌ PR #${pr_num} re-review failed" 2>/dev/null || true
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
[ "$REVIEWED" -lt "$MAX_REVIEWS" ] || break
|
|
|
|
|
done <<< "$REVIEW_SESSIONS"
|
|
|
|
|
fi
|
|
|
|
|
|
2026-03-12 12:44:15 +00:00
|
|
|
while IFS= read -r line; do
|
|
|
|
|
PR_NUM=$(echo "$line" | awk '{print $1}')
|
|
|
|
|
PR_SHA=$(echo "$line" | awk '{print $2}')
|
2026-03-17 20:52:51 +00:00
|
|
|
PR_BRANCH=$(echo "$line" | awk '{print $3}')
|
2026-03-12 12:44:15 +00:00
|
|
|
|
|
|
|
|
CI_STATE=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
|
|
|
"${API_BASE}/commits/${PR_SHA}/status" | jq -r '.state // "unknown"')
|
|
|
|
|
|
2026-03-19 13:48:00 +00:00
|
|
|
# Skip if CI is running/failed. Allow "success", no CI configured, or non-code PRs
|
2026-03-18 02:05:54 +00:00
|
|
|
if ! ci_passed "$CI_STATE"; then
|
2026-03-19 13:48:00 +00:00
|
|
|
if ci_required_for_pr "$PR_NUM"; then
|
|
|
|
|
log " #${PR_NUM} CI=${CI_STATE}, skip"
|
|
|
|
|
SKIPPED=$((SKIPPED + 1))
|
|
|
|
|
continue
|
|
|
|
|
fi
|
|
|
|
|
log " #${PR_NUM} CI=${CI_STATE} but no code files — proceeding"
|
2026-03-12 12:44:15 +00:00
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
# Check formal Codeberg reviews (not comment markers)
|
|
|
|
|
HAS_REVIEW=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
|
|
|
"${API_BASE}/pulls/${PR_NUM}/reviews" | \
|
|
|
|
|
jq -r --arg sha "$PR_SHA" \
|
|
|
|
|
'[.[] | select(.commit_id == $sha) | select(.state != "COMMENT")] | length')
|
|
|
|
|
|
|
|
|
|
if [ "${HAS_REVIEW:-0}" -gt "0" ]; then
|
|
|
|
|
log " #${PR_NUM} formal review exists for ${PR_SHA:0:7}, skip"
|
|
|
|
|
SKIPPED=$((SKIPPED + 1))
|
|
|
|
|
continue
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
log " #${PR_NUM} needs review (CI=success, SHA=${PR_SHA:0:7})"
|
|
|
|
|
|
|
|
|
|
if "${SCRIPT_DIR}/review-pr.sh" "$PR_NUM" 2>&1; then
|
|
|
|
|
REVIEWED=$((REVIEWED + 1))
|
2026-03-17 21:19:47 +00:00
|
|
|
# Re-fetch current SHA: review-pr.sh fetches the PR independently and tags its
|
|
|
|
|
# comment with whatever SHA it saw. If a commit arrived while review-pr.sh was
|
|
|
|
|
# running those two SHA captures diverge and we would miss the comment.
|
|
|
|
|
FRESH_SHA=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
|
|
|
"${API_BASE}/pulls/${PR_NUM}" | jq -r '.head.sha // ""') || true
|
|
|
|
|
inject_review_into_dev_session "$PR_NUM" "${FRESH_SHA:-$PR_SHA}" "$PR_BRANCH"
|
2026-03-12 12:44:15 +00:00
|
|
|
else
|
|
|
|
|
log " #${PR_NUM} review failed"
|
2026-03-15 10:27:23 +00:00
|
|
|
matrix_send "review" "❌ PR #${PR_NUM} review failed" 2>/dev/null || true
|
2026-03-12 12:44:15 +00:00
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
if [ "$REVIEWED" -ge "$MAX_REVIEWS" ]; then
|
|
|
|
|
log "Hit max reviews (${MAX_REVIEWS}), stopping"
|
|
|
|
|
break
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
sleep 2
|
|
|
|
|
|
|
|
|
|
done <<< "$PRS"
|
|
|
|
|
|
|
|
|
|
log "--- Poll done: ${REVIEWED} reviewed, ${SKIPPED} skipped ---"
|