feat(system): DPLAN-0088 auto STATUS.md sync on PR create/merge (#155)

- Trigger: add pr_created + pr_merged events (#13, #14) with fire-and-forget
  handler that runs `drone @prax status sync` (9 new tests, 191 total)
- Drone: wire trigger.fire() into pr_handler, pr_plugin, merge_plugin
  (lazy import, try/except — never blocks PR workflow, 447 tests)
- Flow: fix dashboard push failure on plan create
- Global prompt: remove compaction anxiety language, Patrick controls compaction

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
AIPass
2026-03-30 22:42:12 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 8ff56c2914
commit ec3c79d4dc
13 changed files with 430 additions and 42 deletions
+2 -3
View File
@@ -159,7 +159,7 @@ Your `.trinity/` files are your persistence. Without them you're just an instanc
- `local.json` — session history, active tasks, learnings
- `observations.json` — collaboration patterns over time
Update `.trinity/` at natural breakpoints, after milestones, and on `/memo`. If compaction hits before you save, it's gone. Details in your branch prompt.
Update `.trinity/` at natural breakpoints, after milestones, and on `/memo`. Details in your branch prompt.
### STATUS.local.md — Equal Priority
@@ -171,7 +171,6 @@ Save memories **proactively**. Don't wait for `/memo` or end of session. These a
- **After a milestone** — task completed, bug fixed, dispatch cycle done, plan closed
- **After a decision** — the user chose an approach, rejected an idea, taught you something
- **After learning something new** — a pattern, a gotcha, a command quirk, a system behavior
- **Every ~10 turns** — if you haven't saved recently, save now. Context can compact at any time
- **Before switching topics** — capture what you learned before the conversation moves on
- **When the user teaches** — if they correct you or share insight, that's a key_learning immediately
@@ -180,7 +179,7 @@ What to save where:
- `observations.json` → collaboration patterns (how the user works, what works well, what to avoid)
- `STATUS.local.md` → current work, known issues, todos, recently completed. Surfaces in pre-compact recovery and startup reads.
**The cost of saving too often is zero. The cost of losing context to compaction is everything.**
**Don't stress about compaction.** We run on a 1M context window. Patrick monitors context usage and controls compaction manually — it's his job, not yours. Auto-compact is effectively obsolete. Save your memories because they're valuable, not because you're racing a clock. The cost of saving too often is zero.
## Breadcrumbs
+17 -32
View File
@@ -2,7 +2,7 @@
> Auto-generated by `drone @prax status sync`. Do not edit manually.
**Last sync:** 2026-03-29 22:27
**Last sync:** 2026-03-30 22:28
**Summary:** 9 operational | 0 in-progress | 0 not started
---
@@ -331,37 +331,22 @@ Removed `_module` suffix from all 21 module files per seedgo naming standard ("P
## Notepad
> **SCRATCH SPACE — gets wiped at session start or topic change.**
Session 63:
- Massive 6-branch audit session. 8-agent swarm per branch.
- DPLAN-0053 (drone): dispatched with 4 work items. Drone created FPLAN-0142 (master). Answered design questions. Drone building.
- DPLAN-0084 (seedgo): created + investigated. 31/32 checkers untested. Dispatched seedgo — COMPLETED all 4 phases (225→314 tests). Bug fixes, README updated, checker count corrected to 32.
- DPLAN-0074 (cli): investigated. No standalone aipass init. 116 tests clean. Dispatched.
- DPLAN-0039 (prax): investigated. 71 bypasses, 36/134 tested. JSON corruption cross-branch. Dispatched.
- DPLAN-0082 (flow): investigated. 180 tests, close orphan mitigated. Dispatched.
- DPLAN-0036 (ai_mail): investigated. 26/101 tested, 8x duplicated utility. Dispatched.
- Standards count: 32 not 33 (corrected in DPLAN-0084, needs fixing in STATUS/HERALD/README).
- Patrick's notepad reviewed (/home/patrick/Projects/AIPass/notepad.md) — noted per-branch concerns.
- Cross-branch pattern: handler tests are the gap everywhere, JSON corruption affects 4+ branches.
- 6 branches dispatched and working: drone, seedgo (done), prax, flow, ai_mail, cli.
- Still need: memory, devpulse, trigger (one-on-one), daemon (one-on-one), skills (one-on-one).
- 4 uncommitted files from S62 still pending.
- Round 2 dispatches: skills (list bug fix + demo skills), trigger (medic wiring), daemon (demo schedules), memory (venv fix), commons (social testing).
- Verification: 3343 tests pass system-wide, 43 skip, 0 fail.
- Seedgo broken test: test_cli_routing_template.py imports nonexistent module. Emailed seedgo.
- Backup performance: snapshot dry-run 21s, drive-stats 8s. Patrick says should be ms. Added to DPLAN-0034.
- Skills list bug: drone @skills list crashes — skills.py shadows package. Fix known (sys.path cleanup from commons).
- Commons tested: post/feed/thread all working. Posted S63 update.
- 25 open plans in flow — 7 old seedgo DPLANs may be closeable.
- Drone FPLAN-0142 COMPLETE — generic adapter built, 3 drone_adapter.py files archived, CWD scoping added. 393 tests.
- Skills COMPLETE — list bug fixed, 2 demo skills created (inbox_check, branch_health). 5 skills in catalog.
- Daemon COMPLETE — 3 demo schedules created, pipeline bug fixed, plugins verified, skipped test fixed. 236 tests.
- Memory COMPLETE — venv works, content placement by design, search verified.
- Commons COMPLETE — 12 social features E2E tested, coverage 67%→92% (328 tests), boardroom docs added.
- Trigger COMPLETE — Medic wired up with systemd service, E2E dispatch tested, 12 events documented. Bug fix: CLI fire parser type coercion.
- CLI broken test: test_integration.py imports archived drone_adapter. Emailed CLI.
- Seedgo broken template: test_cli_routing_template.py imports nonexistent module. Emailed seedgo.
- System tests: 3,330 pass, 42 skip (excluding 2 known broken files).
- inotify limit hit during heavy dispatch (too many simultaneous file watchers).
Session 65 (Night Shift):
- FPLAN-0154 COMPLETE: 14 branches dispatched across 3 phases, all replied successfully
- Phase 1 (critical): backup dry-run, ai_mail per-ID, flow introspection gate, drone atomic writes, commons 3-strategy detection
- Phase 2 (patterns): spawn argparse+dry-run, skills create help, api introspection routing, trigger empty JSON, seedgo @ prefix
- Phase 3 (polish): prax ghost module archived, daemon update+error cascade, memory search timeout, cli standalone verified
- Phase 4 (verification): 3,410 tests (+80), seedgo 99% avg, 0 failures
- Key pattern: introspection fallback (no-args gate before routing) was root cause across flow, api, daemon, prax
- Prax conftest.py updated: collect_ignore_glob for .archive/
Session 65 (continued — master key):
- DPLAN-0087: devpulse_ops plugin suite complete. 4 plugins: system-pr, merge, smart-sync, fix
- Full test suite: 8/8 scenarios passed (PRs #147-153, all merged, repo clean)
- system-pr + merge cycle is the new standard workflow for system-wide PRs
- Phase 3 (snapshot_handler) designed in DPLAN but not built — handler not plugin
- FPLAN-0154 still open (can close now — all phases done)
- PR #146 (HERALD) merged during night shift sequence
- Next: update prompts/docs with new commands, build snapshot_handler, Patrick author mode
</details>
+4 -3
View File
@@ -73,9 +73,10 @@
"reason": "Plugin imports lock_handler directly — it's a drone-internal plugin in the same branch, not a cross-branch import."
},
{
"file": "apps/plugins/devpulse_ops/pr_plugin.py",
"standard": "trigger",
"reason": "create_system_pr is a privileged operation — trigger integration deferred until trigger branch supports plugin events."
"file": "apps/handlers/git/pr_handler.py",
"standard": "handlers",
"lines": [210],
"reason": "Lazy import of trigger.fire() — event notification after PR creation. Non-blocking, wrapped in try/except."
},
{
"file": "apps/plugins/devpulse_ops/merge_plugin.py",
@@ -204,6 +204,14 @@ def create_pr(branch_name: str, description: str, branch_dir: Path) -> dict:
{"branch": branch_name, "feature_branch": feature_branch, "pr_url": pr_url},
)
logger.info(result["message"])
# Fire pr_created event (non-blocking — never fail the PR workflow)
try:
from aipass.trigger.apps.modules.core import trigger
trigger.fire("pr_created", branch=branch_name, pr_url=pr_url)
except Exception as exc:
logger.warning("trigger.fire('pr_created') failed: %s", exc)
return result
except (OSError, subprocess.SubprocessError) as exc:
@@ -91,6 +91,14 @@ def merge_pr(pr_number: str, caller: str) -> dict:
},
)
logger.info(result["message"])
# Fire pr_merged event (non-blocking — never fail the merge workflow)
try:
from aipass.trigger.apps.modules.core import trigger
trigger.fire("pr_merged", pr_number=pr_number, title=title)
except Exception as exc:
logger.warning("trigger.fire('pr_merged') failed: %s", exc)
return result
except (OSError, subprocess.SubprocessError) as exc:
@@ -193,6 +193,14 @@ def create_system_pr(description: str, caller: str) -> dict:
},
)
logger.info(result["message"])
# Fire pr_created event (non-blocking — never fail the PR workflow)
try:
from aipass.trigger.apps.modules.core import trigger
trigger.fire("pr_created", branch=caller, pr_url=pr_url)
except Exception as exc:
logger.warning("trigger.fire('pr_created') failed: %s", exc)
return result
except (OSError, subprocess.SubprocessError) as exc:
+134
View File
@@ -681,3 +681,137 @@ class TestModuleRegistration:
assert hasattr(mod, "handle_command")
assert hasattr(mod, "get_help")
assert hasattr(mod, "get_introspective")
# ===========================================================================
# 7. trigger.fire() integration tests
# ===========================================================================
class TestTriggerFireIntegration:
"""Verify trigger.fire() is called after successful PR/merge operations."""
def test_pr_handler_fires_pr_created(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""pr_handler.create_pr fires pr_created event on success."""
registry = tmp_path / "AIPASS_REGISTRY.json"
registry.write_text("{}", encoding="utf-8")
monkeypatch.chdir(tmp_path)
call_count = 0
def mock_run(cmd, **kwargs):
nonlocal call_count
call_count += 1
r = MagicMock()
r.stderr = ""
if cmd[1:3] == ["rev-parse", "--abbrev-ref"]:
r.returncode = 0
r.stdout = "main\n"
elif cmd[0] == "git" and cmd[1] == "add":
r.returncode = 0
r.stdout = ""
elif cmd[1:3] == ["diff", "--cached"]:
r.returncode = 1 # 1 = something staged
r.stdout = ""
elif cmd[0] == "git" and cmd[1] == "commit":
r.returncode = 0
r.stdout = ""
elif cmd[0] == "git" and cmd[1] == "branch":
r.returncode = 0
r.stdout = ""
elif cmd[0] == "git" and cmd[1] == "push":
r.returncode = 0
r.stdout = ""
elif cmd[0] == "gh" and cmd[1] == "pr":
r.returncode = 0
r.stdout = "https://github.com/org/repo/pull/99\n"
else:
r.returncode = 0
r.stdout = ""
return r
mock_trigger = MagicMock()
with patch("aipass.drone.apps.handlers.git.pr_handler.subprocess.run", side_effect=mock_run):
with patch("aipass.drone.apps.handlers.git.pr_handler.acquire_lock", return_value={"success": True, "message": "ok"}):
with patch("aipass.drone.apps.handlers.git.pr_handler.release_lock"):
with patch("aipass.trigger.apps.modules.core.trigger", mock_trigger):
result = create_pr("api", "test trigger", tmp_path / "src" / "aipass" / "api")
assert result["success"] is True
mock_trigger.fire.assert_any_call("pr_created", branch="api", pr_url="https://github.com/org/repo/pull/99")
def test_pr_handler_continues_if_trigger_fails(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""pr_handler.create_pr succeeds even if trigger.fire raises."""
registry = tmp_path / "AIPASS_REGISTRY.json"
registry.write_text("{}", encoding="utf-8")
monkeypatch.chdir(tmp_path)
def mock_run(cmd, **kwargs):
r = MagicMock()
r.stderr = ""
if cmd[1:3] == ["rev-parse", "--abbrev-ref"]:
r.returncode = 0
r.stdout = "main\n"
elif cmd[1:3] == ["diff", "--cached"]:
r.returncode = 1
r.stdout = ""
elif cmd[0] == "gh" and cmd[1] == "pr":
r.returncode = 0
r.stdout = "https://github.com/org/repo/pull/100\n"
else:
r.returncode = 0
r.stdout = ""
return r
mock_trigger = MagicMock()
mock_trigger.fire.side_effect = RuntimeError("trigger broken")
with patch("aipass.drone.apps.handlers.git.pr_handler.subprocess.run", side_effect=mock_run):
with patch("aipass.drone.apps.handlers.git.pr_handler.acquire_lock", return_value={"success": True, "message": "ok"}):
with patch("aipass.drone.apps.handlers.git.pr_handler.release_lock"):
with patch("aipass.trigger.apps.modules.core.trigger", mock_trigger):
result = create_pr("api", "test resilience", tmp_path / "src" / "aipass" / "api")
assert result["success"] is True # PR still succeeds despite trigger failure
def test_merge_plugin_fires_pr_merged(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""merge_plugin.merge_pr fires pr_merged event on success."""
registry = tmp_path / "AIPASS_REGISTRY.json"
registry.write_text("{}", encoding="utf-8")
monkeypatch.chdir(tmp_path)
from aipass.drone.apps.plugins.devpulse_ops.merge_plugin import merge_pr
call_idx = 0
def mock_run(cmd, **kwargs):
nonlocal call_idx
call_idx += 1
r = MagicMock()
r.stderr = ""
if cmd[0] == "gh" and cmd[1] == "pr" and cmd[2] == "merge":
r.returncode = 0
r.stdout = ""
elif cmd[0] == "git" and cmd[1] == "pull":
r.returncode = 0
r.stdout = ""
elif cmd[1:3] == ["rev-parse", "HEAD"]:
r.returncode = 0
r.stdout = "abc123def456\n"
elif cmd[0] == "gh" and cmd[1] == "pr" and cmd[2] == "view":
r.returncode = 0
r.stdout = "Fix the thing\n"
else:
r.returncode = 0
r.stdout = ""
return r
mock_trigger = MagicMock()
with patch("aipass.drone.apps.plugins.devpulse_ops.merge_plugin.subprocess.run", side_effect=mock_run):
with patch("aipass.trigger.apps.modules.core.trigger", mock_trigger):
result = merge_pr("42", "devpulse")
assert result["success"] is True
mock_trigger.fire.assert_any_call("pr_merged", pr_number="42", title="Fix the thing")
@@ -166,7 +166,9 @@ def _calculate_quick_status(sections: Dict[str, Any]) -> Dict[str, Any]:
new_mail = ai_mail.get("new", ai_mail.get("unread", 0))
opened_mail = ai_mail.get("opened", 0)
active_plans = flow.get("active_plans", 0)
active_plans = flow.get("active_count", 0)
if isinstance(active_plans, list):
active_plans = len(active_plans)
mentions = commons.get("mentions", 0)
action_required = new_mail > 0 or active_plans > 0 or mentions > 0
+18
View File
@@ -343,6 +343,24 @@
"standard": "imports",
"pattern": "prax logger import",
"reason": "Systemd service \u2014 uses print() to journald, not prax logger. Importing prax adds unnecessary dependency for a standalone daemon."
},
{
"file": "apps/handlers/events/pr_status_sync.py",
"standard": "silent_catch",
"pattern": "_log_info except",
"reason": "Meta-logging helper: _log_info() writes directly to file. Its own except block cannot log \u2014 you cannot log a failure to log."
},
{
"file": "apps/handlers/events/pr_status_sync.py",
"standard": "error_handling",
"pattern": "except Exception: pass",
"reason": "Meta-logging helper _log_info() \u2014 cannot log a failure to log. Same pattern as silent_catch bypass."
},
{
"file": "apps/handlers/events/pr_status_sync.py",
"standard": "trigger",
"pattern": "handle_pr",
"reason": "PR event handlers (handle_pr_created, handle_pr_merged) are event handlers registered in registry.py, not trigger-specific naming violations."
}
],
"notes": {
+5 -3
View File
@@ -2,7 +2,7 @@
**Purpose:** Event bus for AIPass. Branches fire events, registered handlers react. Decouples producers from consumers — the module that detects a condition doesn't need to know what should happen next.
**Module:** `aipass.trigger`
**Last Updated:** 2026-03-29
**Last Updated:** 2026-03-30
## Commands / Usage
@@ -64,7 +64,7 @@ result = report_error(
## Events
12 events registered via `handlers/events/registry.py`. All fire through `Trigger.fire()`.
14 events registered via `handlers/events/registry.py`. All fire through `Trigger.fire()`.
| Event | Handler | Fired when | Action |
|-------|---------|------------|--------|
@@ -80,6 +80,8 @@ result = report_error(
| `memory_template_updated` | `memory_template_updated.py` | Memory template modified | Pushes template updates to branches |
| `memory_saved` | `memory.py` | Memory file saved | Placeholder for future rollover trigger |
| `cli_header_displayed` | `cli.py` | CLI displays headers | Registration hook |
| `pr_created` | `pr_status_sync.py` | PR opened on GitHub | Runs `drone @prax status sync` (fire-and-forget) |
| `pr_merged` | `pr_status_sync.py` | PR merged on GitHub | Runs `drone @prax status sync` (fire-and-forget) |
## Medic
@@ -125,4 +127,4 @@ trigger/
---
*Last Updated: 2026-03-29*
*Last Updated: 2026-03-30*
@@ -0,0 +1,91 @@
# =================== AIPass ====================
# Name: pr_status_sync.py
# Description: PR event handlers — sync STATUS.md on PR create/merge
# Version: 1.0.0
# Created: 2026-03-30
# Modified: 2026-03-30
# =============================================
"""
PR Status Sync Event Handlers
Handles pr_created and pr_merged events by running
'drone @prax status sync' in a fire-and-forget subprocess.
Events:
pr_created — fired when a PR is opened
data: {branch: str, pr_url: str}
pr_merged — fired when a PR is merged
data: {pr_number: str, title: str}
"""
import subprocess
from datetime import datetime, timezone
from typing import Any
from aipass.trigger.apps.config import TRIGGER_ROOT
from aipass.trigger.apps.handlers.json import json_handler
_HANDLER_LOG = TRIGGER_ROOT / "logs" / "pr_status_sync_handler.log"
def _log_info(message: str) -> None:
"""Log to file (event handlers cannot import prax logger — causes recursion)."""
try:
_HANDLER_LOG.parent.mkdir(parents=True, exist_ok=True)
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
with open(_HANDLER_LOG, 'a', encoding='utf-8') as f:
f.write(f"{ts} | INFO | {message}\n")
except Exception:
pass # Meta-logging: cannot log a failure to log
def _run_status_sync(reason: str) -> None:
"""Fire-and-forget: run drone @prax status sync."""
try:
subprocess.Popen(
["drone", "@prax", "status", "sync"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
_log_info(f"status sync launched ({reason})")
except Exception as exc:
_log_info(f"status sync failed ({reason}): {exc}")
def handle_pr_created(
branch: str | None = None,
pr_url: str | None = None,
**kwargs: Any,
) -> None:
"""Handle pr_created event — trigger STATUS.md sync.
Args:
branch: Branch that created the PR
pr_url: URL of the created PR
**kwargs: Additional event data (ignored)
"""
_run_status_sync(f"pr_created by {branch or 'unknown'}")
json_handler.log_operation("pr_created_event", {
"branch": branch or "unknown",
"pr_url": pr_url or "",
})
def handle_pr_merged(
pr_number: str | None = None,
title: str | None = None,
**kwargs: Any,
) -> None:
"""Handle pr_merged event — trigger STATUS.md sync.
Args:
pr_number: PR number that was merged
title: PR title
**kwargs: Additional event data (ignored)
"""
_run_status_sync(f"pr_merged #{pr_number or '?'}")
json_handler.log_operation("pr_merged_event", {
"pr_number": pr_number or "",
"title": title or "",
})
@@ -70,6 +70,7 @@ def setup_handlers():
from .bulletin_created import handle_bulletin_created
from .memory_threshold_exceeded import handle_memory_threshold_exceeded
from .memory_template_updated import handle_memory_template_updated
from .pr_status_sync import handle_pr_created, handle_pr_merged
trigger.on('startup', handle_startup)
trigger.on('memory_saved', handle_memory_saved)
@@ -83,5 +84,7 @@ def setup_handlers():
trigger.on('bulletin_created', handle_bulletin_created)
trigger.on('memory_threshold_exceeded', handle_memory_threshold_exceeded)
trigger.on('memory_template_updated', handle_memory_template_updated)
trigger.on('pr_created', handle_pr_created)
trigger.on('pr_merged', handle_pr_merged)
json_handler.log_operation("handlers_registered", {"success": True})
@@ -0,0 +1,129 @@
# =================== AIPass ====================
# Name: test_pr_status_sync.py
# Description: Tests for pr_created and pr_merged event handlers
# Version: 1.0.0
# Created: 2026-03-30
# Modified: 2026-03-30
# =============================================
"""Tests for pr_status_sync event handlers."""
import pytest
from unittest.mock import MagicMock, patch
from pathlib import Path
@pytest.fixture(autouse=True)
def _mock_infrastructure(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Mock heavy infrastructure imports."""
import sys
mock_config = MagicMock()
mock_config.TRIGGER_ROOT = tmp_path
monkeypatch.setitem(sys.modules, "aipass.trigger.apps.config", mock_config)
mock_json_handler = MagicMock()
mock_json_handler.log_operation = MagicMock(return_value=True)
json_pkg = MagicMock()
json_pkg.json_handler = mock_json_handler
monkeypatch.setitem(sys.modules, "aipass.trigger.apps.handlers.json", json_pkg)
monkeypatch.setitem(sys.modules, "aipass.trigger.apps.handlers.json.json_handler", mock_json_handler)
monkeypatch.delitem(sys.modules, "aipass.trigger.apps.handlers.events.pr_status_sync", raising=False)
def _import_module():
"""Import fresh after mocking."""
import aipass.trigger.apps.handlers.events.pr_status_sync as m
return m
class TestHandlePrCreated:
"""Tests for handle_pr_created."""
@patch("subprocess.Popen")
def test_fires_subprocess(self, mock_popen: MagicMock) -> None:
"""Calls drone @prax status sync via Popen."""
mod = _import_module()
mod.handle_pr_created(branch="flow", pr_url="https://github.com/org/repo/pull/42")
mock_popen.assert_called_once()
args = mock_popen.call_args[0][0]
assert args == ["drone", "@prax", "status", "sync"]
@patch("subprocess.Popen")
def test_does_not_block(self, mock_popen: MagicMock) -> None:
"""Popen is used (not run), so it doesn't block."""
mod = _import_module()
mod.handle_pr_created(branch="api")
mock_popen.assert_called_once()
# Popen returns immediately — no .wait() or .communicate() called
mock_popen.return_value.wait.assert_not_called()
@patch("subprocess.Popen", side_effect=FileNotFoundError("drone not found"))
def test_handles_missing_drone(self, mock_popen: MagicMock, tmp_path: Path) -> None:
"""Doesn't crash if drone binary not found."""
mod = _import_module()
mod.handle_pr_created(branch="flow") # Should not raise
@patch("subprocess.Popen")
def test_logs_operation(self, mock_popen: MagicMock) -> None:
"""Logs the event via json_handler."""
mod = _import_module()
from aipass.trigger.apps.handlers.json import json_handler
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
mod.handle_pr_created(branch="spawn", pr_url="https://example.com/pr/1")
json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr]
"pr_created_event",
{"branch": "spawn", "pr_url": "https://example.com/pr/1"},
)
@patch("subprocess.Popen")
def test_none_defaults(self, mock_popen: MagicMock) -> None:
"""Handles None parameters gracefully."""
mod = _import_module()
mod.handle_pr_created() # All defaults
mock_popen.assert_called_once()
class TestHandlePrMerged:
"""Tests for handle_pr_merged."""
@patch("subprocess.Popen")
def test_fires_subprocess(self, mock_popen: MagicMock) -> None:
"""Calls drone @prax status sync via Popen."""
mod = _import_module()
mod.handle_pr_merged(pr_number="42", title="Fix the thing")
mock_popen.assert_called_once()
args = mock_popen.call_args[0][0]
assert args == ["drone", "@prax", "status", "sync"]
@patch("subprocess.Popen", side_effect=OSError("exec failed"))
def test_handles_exec_failure(self, mock_popen: MagicMock) -> None:
"""Doesn't crash on subprocess failure."""
mod = _import_module()
mod.handle_pr_merged(pr_number="99") # Should not raise
@patch("subprocess.Popen")
def test_logs_operation(self, mock_popen: MagicMock) -> None:
"""Logs the event via json_handler."""
mod = _import_module()
from aipass.trigger.apps.handlers.json import json_handler
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
mod.handle_pr_merged(pr_number="7", title="Add feature")
json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr]
"pr_merged_event",
{"pr_number": "7", "title": "Add feature"},
)
@patch("subprocess.Popen")
def test_none_defaults(self, mock_popen: MagicMock) -> None:
"""Handles None parameters gracefully."""
mod = _import_module()
mod.handle_pr_merged() # All defaults
mock_popen.assert_called_once()