agents: add continuous Dependabot PR monitor to process-dependabot-prs (#4352) Previously, `process-dependabot-prs` only evaluated open Dependabot PRs when manually invoked, requiring manual re-runs whenever new PRs opened or existing PRs were recreated after another merge. Add `scripts/monitor_dependabot_prs.py` to poll GitHub in the background once every 5 hours (or immediately on `SIGUSR1`) and wake the agent conversation via `agentapi send-message` only when new or updated open Dependabot PRs appear, when an open PR becomes conflicting, or when a poll error occurs. Update `.agents/skills/process-dependabot-prs/SKILL.md` to launch the background daemon and trigger `SIGUSR1` after merging PRs.
diff --git a/.agents/skills/process-dependabot-prs/SKILL.md b/.agents/skills/process-dependabot-prs/SKILL.md index 0c51cb5..1368211 100644 --- a/.agents/skills/process-dependabot-prs/SKILL.md +++ b/.agents/skills/process-dependabot-prs/SKILL.md
@@ -1,28 +1,72 @@ --- name: process-dependabot-prs -description: Evaluate open Dependabot PRs against approval criteria, report - conclusions for user confirmation, fix trivial failures, and merge approved - PRs using the merge-pr skill +description: Continuously monitor for open Dependabot PRs, evaluate them against + approval criteria, report conclusions for user confirmation, fix trivial + failures, and merge approved PRs using the merge-pr skill --- @.agents/rules/lockfiles.md -When asked to process, review, or triage Dependabot pull requests: +When asked to process, review, triage, or monitor Dependabot pull requests: -## 1. Discover and Inspect Open Dependabot PRs +## 1. Start Continuous Background Monitor + +1. Check Existing Monitor Process: Check if `monitor_dependabot_prs.py` is + already running using `pgrep -af "[m]onitor_dependabot_prs.py"` as a + standalone command (do NOT chain it with `|| ... monitor_dependabot_prs.py` + in a single shell invocation, as `pgrep -af` will match the parent `bash -c` + command string). +2. Launch Monitor Daemon: If no monitor process is active, start it via + `run_command` with `IsDaemon: true` and `WaitMsBeforeAsync: 500` (do NOT + append a trailing `&`, which causes the shell to exit immediately and kills + the background process): + ```bash + ./.agents/skills/process-dependabot-prs/scripts/monitor_dependabot_prs.py \ + "<your_conversation_id>" + ``` +3. Continuous Wakeup Loop: + - `monitor_dependabot_prs.py` polls GitHub once every 5 hours (`--interval`) + and tracks each open Dependabot PR's `(headRefOid, conflict_status)` in + `scratch/monitored_dependabot_prs.json` under the skill directory. + - When no new or updated open Dependabot PRs exist, the daemon sleeps without + waking the agent. + - Whenever a new Dependabot PR opens, an existing PR is rebased/recreated + with a new commit SHA (`headRefOid`), or an open PR becomes `CONFLICTING` / + `DIRTY` after another PR merges, the daemon sends a notification via + `agentapi send-message` to wake the conversation. + - Upon starting the daemon—and on every wakeup notification from the + daemon—execute Steps 2–5 below for any newly discovered or updated open + Dependabot PRs. +4. Immediate Poll Trigger (`SIGUSR1`): + - Trigger an immediate poll cycle at any time (for example, right after a + `Merge PR Shepherd` finishes merging an approved Dependabot PR or after + refreshing credentials): + ```bash + pkill -USR1 -f "monitor_dependabot_prs.py" + ``` +5. Authentication Failures: + - When `gh` fails due to invalid or expired credentials, keep the background + monitor process running so it automatically resumes if credentials are + refreshed incidentally. + - Prompt the user via `ask_question` so a UI popup alerts them to + re-authenticate. + - After the user responds, trigger an immediate poll cycle via `pkill -USR1 + -f "monitor_dependabot_prs.py"`. + +## 2. Discover and Inspect Open Dependabot PRs 1. Query open Dependabot PRs: ```bash gh pr list --repo bazel-contrib/rules_python --author "app/dependabot" \ - --state open --json number,title,url,headRefName,mergeable \ + --state open --json number,title,url,headRefName,headRefOid,mergeable \ --json mergeStateStatus,statusCheckRollup,files,labels,body,comments \ --json reviews ``` -2. For each PR, inspect the diff (`gh pr diff <number> --repo - bazel-contrib/rules_python`) and check statuses (Buildkite, ReadTheDocs, and - GitHub Actions). +2. For each new or updated open PR, inspect the diff (`gh pr diff <number> + --repo bazel-contrib/rules_python`) and check statuses (Buildkite, + ReadTheDocs, and GitHub Actions). -## 2. Evaluate Against Criteria (OK vs. Problematic) +## 3. Evaluate Against Criteria (OK vs. Problematic) Classify each PR as **OK**, **OK (with trivial fixes)**, or **Problematic**: @@ -36,7 +80,7 @@ leaf tooling dependencies where release notes show no breaking changes to repository usage and CI passes. * **Trivial Synchronization Failures Only**: CI fails solely due to stale - derived files or hardcoded version assertions covered in Step 4. + derived files or hardcoded version assertions covered in Step 5. ### Problematic (Flag for User Attention) * **Duplicate or Superseded PRs**: Overlapping `pip` and `uv` group PRs @@ -55,7 +99,7 @@ * **Suspicious Releases**: Upstream repository URL changes, yanked versions, or release notes indicating known regressions. -## 3. Notify User and Request Confirmation +## 4. Notify User and Request Confirmation 1. **Create Per-PR Analysis Artifacts**: For each evaluated PR, create a markdown artifact `dependabot-pr-<number>.md` in the conversation artifact @@ -78,7 +122,7 @@ - `Manually review` 3. Wait for user confirmation before mutating any PR. -## 4. Fix Trivial Failures and Merge Approved PRs +## 5. Fix Trivial Failures and Merge Approved PRs Once the user confirms: @@ -131,3 +175,7 @@ <pr_number> --repo bazel-contrib/rules_python --auto --squash`) and launch a `Merge PR Shepherd` subagent to monitor CI, retry flakes, and track the merge queue to completion. + - When a `Merge PR Shepherd` reports that a Dependabot PR has merged, trigger + an immediate poll (`pkill -USR1 -f "monitor_dependabot_prs.py"`) so any + remaining open Dependabot PRs that became conflicting are detected and + recreated right away.
diff --git a/.agents/skills/process-dependabot-prs/scripts/monitor_dependabot_prs.py b/.agents/skills/process-dependabot-prs/scripts/monitor_dependabot_prs.py new file mode 100755 index 0000000..2207bbb --- /dev/null +++ b/.agents/skills/process-dependabot-prs/scripts/monitor_dependabot_prs.py
@@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +"""Continuously polls GitHub for new or updated open Dependabot pull requests. + +State file format: + A JSON object mapping open PR number strings (e.g. `"4301"`) to their last + notified state token (`"<headRefOid>:<CLEAN|CONFLICTING>"`). + +Usage: + ./.agents/skills/process-dependabot-prs/scripts/monitor_dependabot_prs.py \\ + "<conversation_id>" + +Triggering an immediate poll cycle: + kill -USR1 <PID> +""" + +from __future__ import annotations + +import argparse +import fcntl +import json +import os +import pathlib +import signal +import subprocess +import sys +import threading +import traceback +from collections.abc import Mapping, Sequence +from datetime import datetime + +_POLL_TRIGGER = threading.Event() +_REPO = "bazel-contrib/rules_python" +_DEPENDABOT_AUTHOR = "app/dependabot" +# Poll once every 5 hours (18,000 seconds) by default; immediate checks after a +# PR merges can be triggered via SIGUSR1. +_DEFAULT_INTERVAL_SECONDS = 5 * 60 * 60 +_SKILL_DIR = pathlib.Path(__file__).resolve().parent.parent +_SCRATCH_DIR = _SKILL_DIR / "scratch" +_STATE_FILE = _SCRATCH_DIR / "monitored_dependabot_prs.json" +_LOCK_FILE = _SCRATCH_DIR / ".monitor_dependabot_prs.lock" + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + """Parses command-line arguments.""" + parser = argparse.ArgumentParser( + description=( + "Continuously monitor open Dependabot PRs and notify an agent " + "conversation when new or updated PRs appear." + ), + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument( + "conv_id", + help="Conversation ID to notify via agentapi when PRs need processing.", + ) + parser.add_argument( + "--interval", + type=int, + default=_DEFAULT_INTERVAL_SECONDS, + help="Polling interval in seconds.", + ) + return parser.parse_args(argv) + + +def handle_wakeup_signal(signum: int, unused_frame: object) -> None: + """Triggers an immediate polling iteration upon receiving SIGUSR1.""" + sig_name = signal.Signals(signum).name + print( + f"\n[Signal] Received {sig_name} ({signum}), triggering immediate poll...", + flush=True, + ) + _POLL_TRIGGER.set() + + +def register_signal_handlers() -> None: + """Registers SIGUSR1 for asynchronous poll wakeups.""" + signal.signal(signal.SIGUSR1, handle_wakeup_signal) + + +def format_subprocess_output(stdout: str, stderr: str) -> str: + """Formats captured subprocess stdout and stderr with standard delimiters.""" + return ( + "==================== STDOUT BEGIN ====================\n" + f"{stdout}\n" + "==================== STDOUT END ====================\n" + "==================== STDERR BEGIN ====================\n" + f"{stderr}\n" + "==================== STDERR END ====================" + ) + + +def fetch_open_dependabot_prs() -> list[dict[str, object]]: + """Queries GitHub for currently open Dependabot pull requests.""" + cmd = [ + "gh", + "pr", + "list", + "--repo", + _REPO, + "--author", + _DEPENDABOT_AUTHOR, + "--state", + "open", + "--json", + "number,title,url,headRefName,headRefOid,mergeable,mergeStateStatus", + ] + try: + res = subprocess.run(cmd, capture_output=True, text=True, check=True) + except subprocess.CalledProcessError as e: + e.add_note(format_subprocess_output(e.stdout or "", e.stderr or "")) + raise + return json.loads(res.stdout) + + +def load_seen_state() -> dict[str, str]: + """Loads the mapping of PR number strings to state tokens from disk.""" + if not _STATE_FILE.exists(): + return {} + with _STATE_FILE.open(encoding="utf-8") as f: + data = json.load(f) + return {str(k): str(v) for k, v in data.items()} + + +def save_seen_state(seen: Mapping[str, str]) -> None: + """Writes the mapping of PR number strings to state tokens to disk.""" + _STATE_FILE.parent.mkdir(parents=True, exist_ok=True) + with _STATE_FILE.open("w", encoding="utf-8") as f: + json.dump(dict(sorted(seen.items())), f, indent=2) + f.write("\n") + + +def compute_pr_state_token(pr: Mapping[str, object]) -> str: + """Computes a state token capturing the PR's head SHA and conflict status.""" + head_oid = str(pr["headRefOid"]) + is_conflicting = ( + pr.get("mergeable") == "CONFLICTING" or pr.get("mergeStateStatus") == "DIRTY" + ) + conflict_status = "CONFLICTING" if is_conflicting else "CLEAN" + return f"{head_oid}:{conflict_status}" + + +def select_actionable_prs( + open_prs: Sequence[Mapping[str, object]], + seen: Mapping[str, str], +) -> tuple[list[Mapping[str, object]], dict[str, str]]: + """Identifies new or updated PRs and returns the updated state dictionary.""" + actionable: list[Mapping[str, object]] = [] + updated_seen: dict[str, str] = {} + for pr in open_prs: + pr_num = str(pr["number"]) + token = compute_pr_state_token(pr) + updated_seen[pr_num] = token + if seen.get(pr_num) != token: + actionable.append(pr) + return actionable, updated_seen + + +def notify_conversation(conv_id: str, title: str, message: str) -> None: + """Sends a high-priority notification message to the target conversation.""" + cmd = ["agentapi", "send-message", f"--title={title}", conv_id, message] + try: + subprocess.run(cmd, capture_output=True, text=True, check=True) + except subprocess.CalledProcessError as e: + e.add_note(format_subprocess_output(e.stdout or "", e.stderr or "")) + raise + + +def format_notification_message(prs: Sequence[Mapping[str, object]]) -> str: + """Builds the notification message listing new or updated Dependabot PRs.""" + lines = [ + f"đ Detected {len(prs)} new or updated open Dependabot PR(s):", + "", + ] + for pr in prs: + number = pr["number"] + title = pr["title"] + url = pr["url"] + head_oid = str(pr["headRefOid"])[:8] + merge_state = pr.get("mergeStateStatus", "UNKNOWN") + lines.append( + f"- #{number}: {title} ({url}) [SHA: `{head_oid}`, mergeStateStatus: `{merge_state}`]" + ) + lines.extend( + [ + "", + "Action required: Run the `process-dependabot-prs` workflow on the " + "above PR(s).", + ] + ) + return "\n".join(lines) + + +def poll_once(conv_id: str) -> int: + """Executes one polling cycle and returns the count of newly notified PRs.""" + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + open_prs = fetch_open_dependabot_prs() + seen = load_seen_state() + actionable, updated_seen = select_actionable_prs(open_prs, seen) + + if not actionable: + save_seen_state(updated_seen) + print( + f"[{timestamp}] {len(open_prs)} open Dependabot PR(s); 0 new or updated.", + flush=True, + ) + return 0 + + msg = format_notification_message(actionable) + notify_conversation(conv_id, "Dependabot PRs Ready", msg) + save_seen_state(updated_seen) + print( + f"[{timestamp}] Notified conversation {conv_id} about {len(actionable)} " + "Dependabot PR(s).", + flush=True, + ) + return len(actionable) + + +def main(argv: Sequence[str] | None = None) -> None: + """Main entrypoint for the continuous Dependabot PR monitor daemon.""" + args = parse_args(argv) + _SCRATCH_DIR.mkdir(parents=True, exist_ok=True) + + with _LOCK_FILE.open("a+", encoding="utf-8") as lock_file: + try: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + print( + "âšī¸ Continuous Dependabot PR monitor is already running in " + "another process. Exiting.", + flush=True, + ) + sys.exit(0) + + lock_file.seek(0) + lock_file.truncate(0) + lock_file.write( + json.dumps({"pid": os.getpid(), "notify_conv_id": args.conv_id}) + "\n" + ) + lock_file.flush() + + register_signal_handlers() + pid = os.getpid() + print( + f"đ Starting continuous Dependabot PR monitor (PID: {pid}) for " + f"conversation {args.conv_id} every {args.interval}s...", + flush=True, + ) + print(f"To trigger an immediate poll: kill -USR1 {pid}", flush=True) + + error_notified = False + while True: + woke_by_signal = _POLL_TRIGGER.is_set() + _POLL_TRIGGER.clear() + if woke_by_signal: + error_notified = False + try: + poll_once(args.conv_id) + error_notified = False + except ( + subprocess.CalledProcessError, + OSError, + json.JSONDecodeError, + ) as e: + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + details = "".join(traceback.format_exception_only(e)).strip() + print( + f"[{timestamp}] Warning: Dependabot poll failed: {details}", + file=sys.stderr, + flush=True, + ) + if not error_notified: + try: + notify_conversation( + args.conv_id, + "Dependabot Monitor Error", + "â ī¸ Dependabot PR monitor poll failed. If this is an " + "authentication error, prompt the user via " + "`ask_question` to re-authenticate:\n\n" + f"```\n{details}\n```", + ) + error_notified = True + except (subprocess.CalledProcessError, OSError) as notify_err: + print( + f"[{timestamp}] Warning: Failed to notify " + f"conversation: {notify_err}", + file=sys.stderr, + flush=True, + ) + _POLL_TRIGGER.wait(timeout=args.interval) + + +if __name__ == "__main__": + main()