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:
co-authored by
Claude Opus 4.6
parent
8ff56c2914
commit
ec3c79d4dc
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user