From 7df183e28f5911dc767a15959d8afacba2c07bd2 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Mon, 13 Apr 2026 18:02:20 +0200 Subject: [PATCH] Keep Codex reviewing PRs continuously from your active local branch Added a reusable review-bot watch loop that polls open PRs for a base branch and dispatches one Codex-agent review/merge task per new head SHA. This keeps local codex-auth driven automation running without requiring API-key based cloud workflows. Constraint: Must use local Codex CLI + gh auth, not GitHub Action API key flow Rejected: One-shot helper only | user asked for continuous monitoring Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep template and workspace scripts mirrored for review-bot-watch behavior Tested: npm test Tested: node --check bin/multiagent-safety.js Tested: npm pack --dry-run --- README.md | 19 ++ bin/multiagent-safety.js | 4 + scripts/review-bot-watch.sh | 326 ++++++++++++++++++++++++++ templates/scripts/review-bot-watch.sh | 326 ++++++++++++++++++++++++++ test/install.test.js | 14 ++ 5 files changed, 689 insertions(+) create mode 100755 scripts/review-bot-watch.sh create mode 100755 templates/scripts/review-bot-watch.sh diff --git a/README.md b/README.md index 6eb65f8..9134872 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,9 @@ gx protect remove release gx sync --check gx sync +# continuously monitor open PRs targeting current branch and dispatch codex-agent review/merge tasks +bash scripts/review-bot-watch.sh --interval 30 + # cleanup merged agent branches/worktrees gx cleanup @@ -115,6 +118,21 @@ gx scan gx report scorecard --repo github.com/recodeecom/multiagent-safety ``` +### Continuous Codex PR monitor (local codex-auth session) + +Run this in your local shell to keep watching PRs targeting the current branch (or `--base `): + +```sh +bash scripts/review-bot-watch.sh --interval 30 +``` + +Useful flags: + +- `--base main` watch a specific base branch +- `--only-pr 123` process only one PR +- `--once` run one polling cycle and exit +- `--retry-failed` retry failed PRs without waiting for a new head SHA + ## Important behavior defaults - No command defaults to `gx status`. @@ -187,6 +205,7 @@ codex-auth current scripts/agent-branch-start.sh scripts/agent-branch-finish.sh scripts/codex-agent.sh +scripts/review-bot-watch.sh scripts/agent-worktree-prune.sh scripts/agent-file-locks.py scripts/install-agent-git-hooks.sh diff --git a/bin/multiagent-safety.js b/bin/multiagent-safety.js index aff2ae7..ab76f43 100755 --- a/bin/multiagent-safety.js +++ b/bin/multiagent-safety.js @@ -42,6 +42,7 @@ const TEMPLATE_FILES = [ 'scripts/agent-branch-start.sh', 'scripts/agent-branch-finish.sh', 'scripts/codex-agent.sh', + 'scripts/review-bot-watch.sh', 'scripts/agent-worktree-prune.sh', 'scripts/agent-file-locks.py', 'scripts/install-agent-git-hooks.sh', @@ -56,6 +57,7 @@ const EXECUTABLE_RELATIVE_PATHS = new Set([ 'scripts/agent-branch-start.sh', 'scripts/agent-branch-finish.sh', 'scripts/codex-agent.sh', + 'scripts/review-bot-watch.sh', 'scripts/agent-worktree-prune.sh', 'scripts/agent-file-locks.py', 'scripts/install-agent-git-hooks.sh', @@ -83,6 +85,7 @@ const MANAGED_GITIGNORE_PATHS = [ 'scripts/agent-branch-start.sh', 'scripts/agent-branch-finish.sh', 'scripts/codex-agent.sh', + 'scripts/review-bot-watch.sh', 'scripts/agent-worktree-prune.sh', 'scripts/agent-file-locks.py', 'scripts/install-agent-git-hooks.sh', @@ -532,6 +535,7 @@ function ensurePackageScripts(repoRoot, dryRun) { const wantedScripts = { 'agent:codex': 'bash ./scripts/codex-agent.sh', + 'agent:review:watch': 'bash ./scripts/review-bot-watch.sh', 'agent:branch:start': 'bash ./scripts/agent-branch-start.sh', 'agent:branch:finish': 'bash ./scripts/agent-branch-finish.sh', 'agent:cleanup': `${SHORT_TOOL_NAME} cleanup`, diff --git a/scripts/review-bot-watch.sh b/scripts/review-bot-watch.sh new file mode 100755 index 0000000..a41cefd --- /dev/null +++ b/scripts/review-bot-watch.sh @@ -0,0 +1,326 @@ +#!/usr/bin/env bash +set -euo pipefail + +INTERVAL_SECONDS="${MUSAFETY_REVIEW_BOT_INTERVAL_SECONDS:-30}" +AGENT_NAME="${MUSAFETY_REVIEW_BOT_AGENT_NAME:-guardex-review-bot}" +TASK_PREFIX="${MUSAFETY_REVIEW_BOT_TASK_PREFIX:-review-merge}" +STATE_FILE="${MUSAFETY_REVIEW_BOT_STATE_FILE:-}" +BASE_BRANCH="${MUSAFETY_REVIEW_BOT_BASE_BRANCH:-}" +ONLY_PR="${MUSAFETY_REVIEW_BOT_ONLY_PR:-}" +RETRY_FAILED_RAW="${MUSAFETY_REVIEW_BOT_RETRY_FAILED:-false}" +INCLUDE_DRAFT_RAW="${MUSAFETY_REVIEW_BOT_INCLUDE_DRAFT:-false}" + +usage() { + cat <<'USAGE' +Usage: bash scripts/review-bot-watch.sh [options] + +Continuously monitor GitHub pull requests targeting a base branch and dispatch +one Codex-agent task per newly opened/updated PR. + +Options: + --base Base branch to watch (default: current branch) + --interval Poll interval (default: 30) + --agent Agent name for codex-agent (default: guardex-review-bot) + --task-prefix Task prefix for codex-agent branches (default: review-merge) + --state-file State file path (default: .omx/state/review-bot-watch-.tsv) + --only-pr Watch only one PR number + --include-draft Include draft PRs + --retry-failed Retry PRs that previously failed even when SHA is unchanged + --once Run one poll cycle and exit + -h, --help Show this help + +Environment overrides: + MUSAFETY_REVIEW_BOT_PROMPT_APPEND Additional instructions appended to each Codex prompt +USAGE +} + +normalize_bool() { + local raw="${1:-}" + local fallback="${2:-0}" + case "$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')" in + 1|true|yes|on) printf '1' ;; + 0|false|no|off) printf '0' ;; + '') printf '%s' "$fallback" ;; + *) printf '%s' "$fallback" ;; + esac +} + +ONCE=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --base) + BASE_BRANCH="${2:-}" + shift 2 + ;; + --interval) + INTERVAL_SECONDS="${2:-}" + shift 2 + ;; + --agent) + AGENT_NAME="${2:-}" + shift 2 + ;; + --task-prefix) + TASK_PREFIX="${2:-}" + shift 2 + ;; + --state-file) + STATE_FILE="${2:-}" + shift 2 + ;; + --only-pr) + ONLY_PR="${2:-}" + shift 2 + ;; + --retry-failed) + RETRY_FAILED_RAW="true" + shift + ;; + --include-draft) + INCLUDE_DRAFT_RAW="true" + shift + ;; + --once) + ONCE=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "[review-bot-watch] Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +RETRY_FAILED="$(normalize_bool "$RETRY_FAILED_RAW" "0")" +INCLUDE_DRAFT="$(normalize_bool "$INCLUDE_DRAFT_RAW" "0")" + +if [[ ! "$INTERVAL_SECONDS" =~ ^[0-9]+$ ]] || [[ "$INTERVAL_SECONDS" -lt 5 ]]; then + echo "[review-bot-watch] --interval must be an integer >= 5 seconds." >&2 + exit 1 +fi + +if [[ -n "$ONLY_PR" ]] && [[ ! "$ONLY_PR" =~ ^[0-9]+$ ]]; then + echo "[review-bot-watch] --only-pr must be a numeric PR id." >&2 + exit 1 +fi + +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "[review-bot-watch] Not inside a git repository." >&2 + exit 1 +fi +repo_root="$(git rev-parse --show-toplevel)" + +if [[ -z "$BASE_BRANCH" ]]; then + BASE_BRANCH="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" +fi +if [[ -z "$BASE_BRANCH" || "$BASE_BRANCH" == "HEAD" ]]; then + BASE_BRANCH="main" +fi + +if ! command -v gh >/dev/null 2>&1; then + echo "[review-bot-watch] Missing GitHub CLI (gh)." >&2 + echo "[review-bot-watch] Install gh and run: gh auth login" >&2 + exit 127 +fi + +if ! command -v codex >/dev/null 2>&1; then + echo "[review-bot-watch] Missing Codex CLI command: codex" >&2 + exit 127 +fi + +if [[ ! -x "$repo_root/scripts/codex-agent.sh" ]]; then + echo "[review-bot-watch] Missing scripts/codex-agent.sh. Run: gx setup" >&2 + exit 1 +fi + +if ! gh auth status >/dev/null 2>&1; then + echo "[review-bot-watch] gh is not authenticated. Run: gh auth login" >&2 + exit 1 +fi + +sanitize_slug() { + local raw="$1" + local fallback="$2" + local slug + slug="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-{2,}/-/g')" + if [[ -z "$slug" ]]; then + slug="$fallback" + fi + printf '%s' "$slug" +} + +base_slug="$(sanitize_slug "$BASE_BRANCH" "base")" +if [[ -z "$STATE_FILE" ]]; then + STATE_FILE="$repo_root/.omx/state/review-bot-watch-${base_slug}.tsv" +fi +mkdir -p "$(dirname "$STATE_FILE")" + +declare -A LAST_SHA + +declare -A LAST_STATUS + +load_state() { + if [[ ! -f "$STATE_FILE" ]]; then + return 0 + fi + while IFS=$'\t' read -r pr sha status updated_at; do + if [[ -z "${pr:-}" ]] || [[ "${pr:0:1}" == "#" ]]; then + continue + fi + LAST_SHA["$pr"]="$sha" + LAST_STATUS["$pr"]="$status" + done < "$STATE_FILE" +} + +save_state() { + { + echo "# pr\thead_sha\tstatus\tupdated_at" + for pr in "${!LAST_SHA[@]}"; do + printf '%s\t%s\t%s\t%s\n' "${pr}" "${LAST_SHA[$pr]}" "${LAST_STATUS[$pr]:-unknown}" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + done | sort -n + } > "$STATE_FILE" +} + +build_prompt() { + local pr="$1" + local head_branch="$2" + local head_sha="$3" + local pr_title="$4" + local pr_url="$5" + + cat <&2 + fi + + save_state +} + +load_state + +echo "[review-bot-watch] Starting monitor" +echo "[review-bot-watch] Base branch : ${BASE_BRANCH}" +echo "[review-bot-watch] Interval : ${INTERVAL_SECONDS}s" +echo "[review-bot-watch] State file : ${STATE_FILE}" +if [[ -n "$ONLY_PR" ]]; then + echo "[review-bot-watch] Only PR : #${ONLY_PR}" +fi + +trap 'echo "[review-bot-watch] Stopped."; exit 0' INT TERM + +while true; do + found=0 + while IFS=$'\t' read -r pr head_branch sha is_draft title url; do + if [[ -z "${pr:-}" ]]; then + continue + fi + + found=1 + + if [[ -n "$ONLY_PR" && "$pr" != "$ONLY_PR" ]]; then + continue + fi + + if [[ "$INCLUDE_DRAFT" != "1" && "$is_draft" == "true" ]]; then + continue + fi + + if ! should_process_pr "$pr" "$sha"; then + continue + fi + + process_one_pr "$pr" "$head_branch" "$sha" "$title" "$url" + done < <(list_open_prs || true) + + if [[ "$found" -eq 0 ]]; then + echo "[review-bot-watch] No open PRs for base '${BASE_BRANCH}'." + fi + + if [[ "$ONCE" -eq 1 ]]; then + break + fi + + sleep "$INTERVAL_SECONDS" +done diff --git a/templates/scripts/review-bot-watch.sh b/templates/scripts/review-bot-watch.sh new file mode 100755 index 0000000..a41cefd --- /dev/null +++ b/templates/scripts/review-bot-watch.sh @@ -0,0 +1,326 @@ +#!/usr/bin/env bash +set -euo pipefail + +INTERVAL_SECONDS="${MUSAFETY_REVIEW_BOT_INTERVAL_SECONDS:-30}" +AGENT_NAME="${MUSAFETY_REVIEW_BOT_AGENT_NAME:-guardex-review-bot}" +TASK_PREFIX="${MUSAFETY_REVIEW_BOT_TASK_PREFIX:-review-merge}" +STATE_FILE="${MUSAFETY_REVIEW_BOT_STATE_FILE:-}" +BASE_BRANCH="${MUSAFETY_REVIEW_BOT_BASE_BRANCH:-}" +ONLY_PR="${MUSAFETY_REVIEW_BOT_ONLY_PR:-}" +RETRY_FAILED_RAW="${MUSAFETY_REVIEW_BOT_RETRY_FAILED:-false}" +INCLUDE_DRAFT_RAW="${MUSAFETY_REVIEW_BOT_INCLUDE_DRAFT:-false}" + +usage() { + cat <<'USAGE' +Usage: bash scripts/review-bot-watch.sh [options] + +Continuously monitor GitHub pull requests targeting a base branch and dispatch +one Codex-agent task per newly opened/updated PR. + +Options: + --base Base branch to watch (default: current branch) + --interval Poll interval (default: 30) + --agent Agent name for codex-agent (default: guardex-review-bot) + --task-prefix Task prefix for codex-agent branches (default: review-merge) + --state-file State file path (default: .omx/state/review-bot-watch-.tsv) + --only-pr Watch only one PR number + --include-draft Include draft PRs + --retry-failed Retry PRs that previously failed even when SHA is unchanged + --once Run one poll cycle and exit + -h, --help Show this help + +Environment overrides: + MUSAFETY_REVIEW_BOT_PROMPT_APPEND Additional instructions appended to each Codex prompt +USAGE +} + +normalize_bool() { + local raw="${1:-}" + local fallback="${2:-0}" + case "$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')" in + 1|true|yes|on) printf '1' ;; + 0|false|no|off) printf '0' ;; + '') printf '%s' "$fallback" ;; + *) printf '%s' "$fallback" ;; + esac +} + +ONCE=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --base) + BASE_BRANCH="${2:-}" + shift 2 + ;; + --interval) + INTERVAL_SECONDS="${2:-}" + shift 2 + ;; + --agent) + AGENT_NAME="${2:-}" + shift 2 + ;; + --task-prefix) + TASK_PREFIX="${2:-}" + shift 2 + ;; + --state-file) + STATE_FILE="${2:-}" + shift 2 + ;; + --only-pr) + ONLY_PR="${2:-}" + shift 2 + ;; + --retry-failed) + RETRY_FAILED_RAW="true" + shift + ;; + --include-draft) + INCLUDE_DRAFT_RAW="true" + shift + ;; + --once) + ONCE=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "[review-bot-watch] Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +RETRY_FAILED="$(normalize_bool "$RETRY_FAILED_RAW" "0")" +INCLUDE_DRAFT="$(normalize_bool "$INCLUDE_DRAFT_RAW" "0")" + +if [[ ! "$INTERVAL_SECONDS" =~ ^[0-9]+$ ]] || [[ "$INTERVAL_SECONDS" -lt 5 ]]; then + echo "[review-bot-watch] --interval must be an integer >= 5 seconds." >&2 + exit 1 +fi + +if [[ -n "$ONLY_PR" ]] && [[ ! "$ONLY_PR" =~ ^[0-9]+$ ]]; then + echo "[review-bot-watch] --only-pr must be a numeric PR id." >&2 + exit 1 +fi + +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "[review-bot-watch] Not inside a git repository." >&2 + exit 1 +fi +repo_root="$(git rev-parse --show-toplevel)" + +if [[ -z "$BASE_BRANCH" ]]; then + BASE_BRANCH="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" +fi +if [[ -z "$BASE_BRANCH" || "$BASE_BRANCH" == "HEAD" ]]; then + BASE_BRANCH="main" +fi + +if ! command -v gh >/dev/null 2>&1; then + echo "[review-bot-watch] Missing GitHub CLI (gh)." >&2 + echo "[review-bot-watch] Install gh and run: gh auth login" >&2 + exit 127 +fi + +if ! command -v codex >/dev/null 2>&1; then + echo "[review-bot-watch] Missing Codex CLI command: codex" >&2 + exit 127 +fi + +if [[ ! -x "$repo_root/scripts/codex-agent.sh" ]]; then + echo "[review-bot-watch] Missing scripts/codex-agent.sh. Run: gx setup" >&2 + exit 1 +fi + +if ! gh auth status >/dev/null 2>&1; then + echo "[review-bot-watch] gh is not authenticated. Run: gh auth login" >&2 + exit 1 +fi + +sanitize_slug() { + local raw="$1" + local fallback="$2" + local slug + slug="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-{2,}/-/g')" + if [[ -z "$slug" ]]; then + slug="$fallback" + fi + printf '%s' "$slug" +} + +base_slug="$(sanitize_slug "$BASE_BRANCH" "base")" +if [[ -z "$STATE_FILE" ]]; then + STATE_FILE="$repo_root/.omx/state/review-bot-watch-${base_slug}.tsv" +fi +mkdir -p "$(dirname "$STATE_FILE")" + +declare -A LAST_SHA + +declare -A LAST_STATUS + +load_state() { + if [[ ! -f "$STATE_FILE" ]]; then + return 0 + fi + while IFS=$'\t' read -r pr sha status updated_at; do + if [[ -z "${pr:-}" ]] || [[ "${pr:0:1}" == "#" ]]; then + continue + fi + LAST_SHA["$pr"]="$sha" + LAST_STATUS["$pr"]="$status" + done < "$STATE_FILE" +} + +save_state() { + { + echo "# pr\thead_sha\tstatus\tupdated_at" + for pr in "${!LAST_SHA[@]}"; do + printf '%s\t%s\t%s\t%s\n' "${pr}" "${LAST_SHA[$pr]}" "${LAST_STATUS[$pr]:-unknown}" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + done | sort -n + } > "$STATE_FILE" +} + +build_prompt() { + local pr="$1" + local head_branch="$2" + local head_sha="$3" + local pr_title="$4" + local pr_url="$5" + + cat <&2 + fi + + save_state +} + +load_state + +echo "[review-bot-watch] Starting monitor" +echo "[review-bot-watch] Base branch : ${BASE_BRANCH}" +echo "[review-bot-watch] Interval : ${INTERVAL_SECONDS}s" +echo "[review-bot-watch] State file : ${STATE_FILE}" +if [[ -n "$ONLY_PR" ]]; then + echo "[review-bot-watch] Only PR : #${ONLY_PR}" +fi + +trap 'echo "[review-bot-watch] Stopped."; exit 0' INT TERM + +while true; do + found=0 + while IFS=$'\t' read -r pr head_branch sha is_draft title url; do + if [[ -z "${pr:-}" ]]; then + continue + fi + + found=1 + + if [[ -n "$ONLY_PR" && "$pr" != "$ONLY_PR" ]]; then + continue + fi + + if [[ "$INCLUDE_DRAFT" != "1" && "$is_draft" == "true" ]]; then + continue + fi + + if ! should_process_pr "$pr" "$sha"; then + continue + fi + + process_one_pr "$pr" "$head_branch" "$sha" "$title" "$url" + done < <(list_open_prs || true) + + if [[ "$found" -eq 0 ]]; then + echo "[review-bot-watch] No open PRs for base '${BASE_BRANCH}'." + fi + + if [[ "$ONCE" -eq 1 ]]; then + break + fi + + sleep "$INTERVAL_SECONDS" +done diff --git a/test/install.test.js b/test/install.test.js index 42cdcd5..96807de 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -216,6 +216,7 @@ test('setup provisions workflow files and repo config', () => { 'scripts/agent-branch-start.sh', 'scripts/agent-branch-finish.sh', 'scripts/codex-agent.sh', + 'scripts/review-bot-watch.sh', 'scripts/agent-worktree-prune.sh', 'scripts/agent-file-locks.py', 'scripts/install-agent-git-hooks.sh', @@ -235,6 +236,7 @@ test('setup provisions workflow files and repo config', () => { const packageJson = JSON.parse(fs.readFileSync(path.join(repoDir, 'package.json'), 'utf8')); assert.equal(packageJson.scripts['agent:codex'], 'bash ./scripts/codex-agent.sh'); + assert.equal(packageJson.scripts['agent:review:watch'], 'bash ./scripts/review-bot-watch.sh'); assert.equal(packageJson.scripts['agent:branch:start'], 'bash ./scripts/agent-branch-start.sh'); assert.equal(packageJson.scripts['agent:plan:init'], 'bash ./scripts/openspec/init-plan-workspace.sh'); assert.equal(packageJson.scripts['agent:protect:list'], 'gx protect list'); @@ -251,6 +253,7 @@ test('setup provisions workflow files and repo config', () => { assert.match(gitignoreContent, /# multiagent-safety:START/); assert.match(gitignoreContent, /scripts\/agent-branch-start\.sh/); assert.match(gitignoreContent, /scripts\/codex-agent\.sh/); + assert.match(gitignoreContent, /scripts\/review-bot-watch\.sh/); assert.match(gitignoreContent, /scripts\/agent-file-locks\.py/); assert.match(gitignoreContent, /\.githooks\/pre-commit/); assert.match(gitignoreContent, /\.githooks\/pre-push/); @@ -279,6 +282,17 @@ test('init aliases setup and provisions workflow files', () => { assert.equal(fs.existsSync(path.join(repoDir, 'AGENTS.md')), true); }); +test('review-bot-watch script prints help after setup', () => { + const repoDir = initRepo(); + + const setupResult = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(setupResult.status, 0, setupResult.stderr || setupResult.stdout); + + const helpResult = runCmd('bash', ['scripts/review-bot-watch.sh', '--help'], repoDir); + assert.equal(helpResult.status, 0, helpResult.stderr || helpResult.stdout); + assert.match(helpResult.stdout, /Continuously monitor GitHub pull requests targeting a base branch/); +}); + test('setup blocks in-place maintenance writes on protected main after initialization', () => { const repoDir = initRepoOnBranch('main');