From 80c9545f2437ced0fb29c76b33455cdb408b2d59 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Wed, 8 Jul 2026 16:50:28 -0400 Subject: [PATCH] feat(plan): notify Slack on plan approval Re-land upstream #1632 onto the modular structure. When a plan is approved via the dashboard approve endpoint, post a thread reply to the originating Slack thread noting the comment count and approver, after the follow-up run is dispatched. Slack post failures never break approval. Adapted to the fork's approve_plan (no plan_markdown read). Refs #80, upstream #1632 --- agent/dashboard/plan_api.py | 46 +++++++++++++++++++++++++++++- tests/test_plan_review.py | 57 +++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/agent/dashboard/plan_api.py b/agent/dashboard/plan_api.py index 28eeac04..491f819e 100644 --- a/agent/dashboard/plan_api.py +++ b/agent/dashboard/plan_api.py @@ -22,6 +22,7 @@ from langgraph_sdk import get_client from pydantic import BaseModel from ..dispatch import dispatch_agent_run +from ..utils.slack import post_slack_thread_reply from .oauth import require_same_origin_for_mutations, require_session from .plan_store import ( PLAN_STATUS_APPROVED, @@ -139,7 +140,8 @@ async def approve_plan(thread_id: str, session: dict[str, Any] = _SESSION_DEP) - raise HTTPException(403, "only the plan owner can approve") # Read comments BEFORE mutating state: a store failure here aborts the # decision (500) rather than dispatching the run without the feedback. - feedback = _format_comments(await list_plan_comments(thread_id, raise_on_error=True)) + comments = await list_plan_comments(thread_id, raise_on_error=True) + feedback = _format_comments(comments) await set_plan_status(thread_id, PLAN_STATUS_APPROVED, plan_mode=False) if feedback: text = ( @@ -149,6 +151,9 @@ async def approve_plan(thread_id: str, session: dict[str, Any] = _SESSION_DEP) - else: text = "The plan has been approved. Implement it now as described in the plan." await _dispatch_followup(thread_id, metadata, text, plan_mode=False) + await _maybe_post_plan_approved_to_slack( + metadata, comment_count=len(comments), actor=_approval_actor_name(session) + ) return {"status": PLAN_STATUS_APPROVED} @@ -181,6 +186,45 @@ def _format_comments(comments: list[dict[str, Any]]) -> str: return "\n".join(lines) +def _approval_actor_name(session: dict[str, Any]) -> str: + return str(session.get("name") or session.get("sub") or "User").strip() or "User" + + +def _slack_thread_from_metadata(metadata: dict[str, Any]) -> tuple[str, str] | None: + source_context = metadata.get("source_context") + if not isinstance(source_context, dict): + return None + slack_thread = source_context.get("slack_thread") + if not isinstance(slack_thread, dict): + return None + channel_id = slack_thread.get("channel_id") + thread_ts = slack_thread.get("thread_ts") + if isinstance(channel_id, str) and channel_id and isinstance(thread_ts, str) and thread_ts: + return channel_id, thread_ts + return None + + +def _plan_approved_slack_text(comment_count: int, actor: str) -> str: + return f"Plan approved with {comment_count} comments by {actor}\nbeginning implementation" + + +async def _maybe_post_plan_approved_to_slack( + metadata: dict[str, Any], *, comment_count: int, actor: str +) -> None: + slack_thread = _slack_thread_from_metadata(metadata) + if slack_thread is None: + return + channel_id, thread_ts = slack_thread + text = _plan_approved_slack_text(comment_count, actor) + try: + posted = await post_slack_thread_reply(channel_id, thread_ts, text) + except Exception: # noqa: BLE001 + logger.warning("Failed to post plan-approval notice to Slack", exc_info=True) + return + if not posted: + logger.warning("Plan-approval Slack notice was not posted (channel=%s)", channel_id) + + async def _dispatch_followup( thread_id: str, metadata: dict[str, Any], text: str, *, plan_mode: bool ) -> None: diff --git a/tests/test_plan_review.py b/tests/test_plan_review.py index 74894594..7d0e5348 100644 --- a/tests/test_plan_review.py +++ b/tests/test_plan_review.py @@ -182,3 +182,60 @@ def test_plan_mode_middleware_self_activation_via_state() -> None: # After enter_plan_mode sets state: the next request is filtered. on = _FakeReq([{"name": "read_file"}, {"name": "write_file"}], {"plan_mode": True}) assert _names(mw._filter(on)) == {"read_file"} + + +def test_plan_approved_slack_text_mentions_comments_actor_and_start() -> None: + from agent.dashboard.plan_api import _plan_approved_slack_text + + text = _plan_approved_slack_text(3, "Alice") + assert text == "Plan approved with 3 comments by Alice\nbeginning implementation" + + +async def test_approve_plan_posts_slack_approval_notice(monkeypatch: pytest.MonkeyPatch) -> None: + from agent.dashboard import plan_api + + metadata = { + "github_login": "alice", + "source_context": {"slack_thread": {"channel_id": "C1", "thread_ts": "1700000000.0001"}}, + } + posted: dict[str, Any] = {} + + async def fake_thread_metadata(thread_id: str) -> dict[str, Any]: + return metadata + + def fake_user_owns_thread(md: dict, sub: str, email: str | None) -> bool: + return True + + async def fake_list_plan_comments( + thread_id: str, *, raise_on_error: bool = False + ) -> list[dict]: + return [{"author": "bob", "body": "tweak this"}] + + async def fake_set_plan_status(thread_id: str, status: str, *, plan_mode: bool) -> None: + posted["status"] = {"status": status, "plan_mode": plan_mode} + + async def fake_dispatch_followup( + thread_id: str, md: dict, text: str, *, plan_mode: bool + ) -> None: + posted["dispatch"] = {"text": text, "plan_mode": plan_mode} + + async def fake_post_slack_thread_reply(channel_id: str, thread_ts: str, text: str) -> bool: + posted["slack"] = {"channel_id": channel_id, "thread_ts": thread_ts, "text": text} + return True + + monkeypatch.setattr(plan_api, "_thread_metadata", fake_thread_metadata) + monkeypatch.setattr(plan_api, "_user_owns_thread", fake_user_owns_thread) + monkeypatch.setattr(plan_api, "list_plan_comments", fake_list_plan_comments) + monkeypatch.setattr(plan_api, "set_plan_status", fake_set_plan_status) + monkeypatch.setattr(plan_api, "_dispatch_followup", fake_dispatch_followup) + monkeypatch.setattr(plan_api, "post_slack_thread_reply", fake_post_slack_thread_reply) + + result = await plan_api.approve_plan("tid", session={"sub": "u1", "name": "Alice"}) + + assert result == {"status": plan_api.PLAN_STATUS_APPROVED} + assert posted["dispatch"]["plan_mode"] is False + assert posted["slack"] == { + "channel_id": "C1", + "thread_ts": "1700000000.0001", + "text": "Plan approved with 1 comments by Alice\nbeginning implementation", + }