From f548fb241f972abc6221bcbc994605aad799fedf Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Sun, 14 Jun 2026 17:41:00 +0000 Subject: [PATCH 01/17] feat: add Linear + Sentry connectors, operational procedures, scheduler, and Hermes MCP server - Linear connector: GraphQL API for issues, teams, comments (read/write) - Sentry connector: issues, events, error counts for ops awareness - 4 operational procedures: weekly_progress_review, dependabot_triage, agent_pr_security_scan, deploy_readiness - ProcedureScheduler: in-process cron (5-field, minute granularity) - Hermes MCP server: stdio JSON-RPC server exposing agent-pm as MCP tools - Updated settings, connectors __init__, .env.example - Added tests for new connectors, scheduler, MCP server, procedures - 102 tests pass --- .env.example | 10 +- agent_pm/connectors/__init__.py | 8 +- agent_pm/connectors/linear.py | 188 ++++++++++++++ agent_pm/connectors/sentry.py | 155 ++++++++++++ agent_pm/mcp_server.py | 334 +++++++++++++++++++++++++ agent_pm/scheduler.py | 159 ++++++++++++ agent_pm/settings.py | 8 +- config/procedure_schedule.yaml | 19 ++ procedures/agent_pr_security_scan.yaml | 35 +++ procedures/dependabot_triage.yaml | 36 +++ procedures/deploy_readiness.yaml | 45 ++++ procedures/weekly_progress_review.yaml | 38 +++ tests/test_connectors.py | 117 +++++++++ 13 files changed, 1148 insertions(+), 4 deletions(-) create mode 100644 agent_pm/connectors/linear.py create mode 100644 agent_pm/connectors/sentry.py create mode 100644 agent_pm/mcp_server.py create mode 100644 agent_pm/scheduler.py create mode 100644 config/procedure_schedule.yaml create mode 100644 procedures/agent_pr_security_scan.yaml create mode 100644 procedures/dependabot_triage.yaml create mode 100644 procedures/deploy_readiness.yaml create mode 100644 procedures/weekly_progress_review.yaml diff --git a/.env.example b/.env.example index 3171c7d..892a4db 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,11 @@ OPENAI_API_KEY=sk-your-key GITHUB_TOKEN=ghp-your-token +GITHUB_REPOSITORIES=evalops/platform,evalops/deploy,evalops/maestro-internal,haasonsaas/homelab +LINEAR_API_KEY=lin_api_your-key +LINEAR_TEAM_IDS=TEAM_ID_1 +SENTRY_AUTH_TOKEN=sntrys_your-token +SENTRY_ORG_SLUG=evalops-inc +SENTRY_BASE_URL=https://sentry.io/api/0 JIRA_BASE_URL=https://your-domain.atlassian.net JIRA_API_TOKEN=your-jira-api-token JIRA_EMAIL=you@your-domain.com @@ -7,7 +13,7 @@ DRY_RUN=true APPROVAL_REQUIRED=true VECTOR_STORE_PATH=./data/vector_store.json TRACE_DIR=./data/traces -SLACK_BOT_TOKEN=xoxb-your-token +SLACK_BOT_TOKEN=*** SLACK_STATUS_CHANNEL=#status-updates CALENDAR_ID=primary GOOGLE_SERVICE_ACCOUNT_FILE=./config/google-service-account.json @@ -30,4 +36,4 @@ DATABASE_ECHO=false REDIS_URL=redis://localhost:6379 ENABLE_OPENTELEMETRY=false OTEL_SERVICE_NAME=agent-pm -OTEL_EXPORTER_ENDPOINT=http://localhost:4317 +OTEL_EXPORTER_ENDPOINT=http://localhost:4317 \ No newline at end of file diff --git a/agent_pm/connectors/__init__.py b/agent_pm/connectors/__init__.py index 113cd4c..8eff593 100644 --- a/agent_pm/connectors/__init__.py +++ b/agent_pm/connectors/__init__.py @@ -5,7 +5,9 @@ from .email import EmailConnector from .github import GitHubConnector from .google_drive import GoogleDriveConnector +from .linear import LinearConnector, linear_connector from .notion import NotionConnector +from .sentry import SentryConnector, sentry_connector from .slack import SlackConnector __all__ = [ @@ -14,6 +16,10 @@ "EmailConnector", "GitHubConnector", "GoogleDriveConnector", + "LinearConnector", + "linear_connector", "NotionConnector", + "SentryConnector", + "sentry_connector", "SlackConnector", -] +] \ No newline at end of file diff --git a/agent_pm/connectors/linear.py b/agent_pm/connectors/linear.py new file mode 100644 index 0000000..e3123f6 --- /dev/null +++ b/agent_pm/connectors/linear.py @@ -0,0 +1,188 @@ +"""Linear connector — GraphQL API for issue tracking and project management.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +import httpx + +from agent_pm.connectors.base import Connector +from agent_pm.settings import settings + +LINEAR_API_URL = "https://api.linear.app/graphql" + + +class LinearConnector(Connector): + def __init__(self) -> None: + super().__init__(name="linear") + self._api_key = settings.linear_api_key + self._team_ids = settings.linear_team_ids + + @property + def enabled(self) -> bool: + return bool(self._api_key) + + def _headers(self) -> dict[str, str]: + return { + "Authorization": self._api_key or "", + "Content-Type": "application/json", + } + + async def _graphql(self, query: str, variables: dict[str, Any] | None = None) -> dict[str, Any]: + if settings.dry_run or not self.enabled: + return {"dry_run": True, "query": query[:200], "variables": variables} + + async with httpx.AsyncClient() as client: + resp = await client.post( + LINEAR_API_URL, + headers=self._headers(), + json={"query": query, "variables": variables or {}}, + timeout=30, + ) + resp.raise_for_status() + data = resp.json() + if "errors" in data: + raise RuntimeError(f"Linear GraphQL errors: {data['errors']}") + return data.get("data", data) + + # ── read operations ────────────────────────────────────────── + + async def list_issues( + self, + *, + assignee_id: str | None = None, + team_id: str | None = None, + state: str | None = None, + order_by: str = "updatedAt", + limit: int = 50, + ) -> list[dict[str, Any]]: + filters: dict[str, Any] = {} + if assignee_id: + filters["assignee"] = {"id": {"eq": assignee_id}} + if team_id: + filters["team"] = {"id": {"eq": team_id}} + state_filter: dict[str, Any] | None = None + if state: + state_filter = {"name": {"eq": state}} + + query = """ + query($filter: IssueFilter, $stateFilter: WorkflowStateFilter, $orderBy: PaginationOrderBy, $first: Int!) { + issues(filter: $filter, state: $stateFilter, orderBy: $orderBy, first: $first) { + nodes { + id identifier title description state { name } priority + assignee { id name email } team { id name key } + dueDate createdAt updatedAt + labels { nodes { name } } + parent { id identifier } + } + } + } + """ + result = await self._graphql( + query, + { + "filter": filters if filters else None, + "stateFilter": state_filter, + "orderBy": order_by, + "first": limit, + }, + ) + return result.get("issues", {}).get("nodes", []) + + async def list_teams(self) -> list[dict[str, Any]]: + query = """ + query { + teams { nodes { id name key } } + } + """ + result = await self._graphql(query) + return result.get("teams", {}).get("nodes", []) + + async def get_issue_comments(self, issue_id: str, limit: int = 20) -> list[dict[str, Any]]: + query = """ + query($issueId: String!, $first: Int!) { + issue(id: $issueId) { + comments(first: $first) { nodes { id body createdAt user { name } } } + } + } + """ + result = await self._graphql(query, {"issueId": issue_id, "first": limit}) + return result.get("issue", {}).get("comments", {}).get("nodes", []) + + # ── write operations ───────────────────────────────────────── + + async def create_issue( + self, + *, + team_id: str, + title: str, + description: str = "", + assignee_id: str | None = None, + priority: int | None = None, + due_date: str | None = None, + ) -> dict[str, Any]: + create_input: dict[str, Any] = { + "teamId": team_id, + "title": title, + "description": description, + } + if assignee_id: + create_input["assigneeId"] = assignee_id + if priority is not None: + create_input["priority"] = priority + if due_date: + create_input["dueDate"] = due_date + + query = """ + mutation($input: IssueCreateInput!) { + issueCreate(input: $input) { + success + issue { id identifier title url } + } + } + """ + result = await self._graphql(query, {"input": create_input}) + return result.get("issueCreate", {}) + + async def update_issue(self, issue_id: str, **fields: Any) -> dict[str, Any]: + query = """ + mutation($id: String!, $input: IssueUpdateInput!) { + issueUpdate(id: $id, input: $input) { + success + issue { id identifier title } + } + } + """ + result = await self._graphql(query, {"id": issue_id, "input": fields}) + return result.get("issueUpdate", {}) + + async def add_comment(self, issue_id: str, body: str) -> dict[str, Any]: + query = """ + mutation($issueId: String!, $body: String!) { + commentCreate(input: {issueId: $issueId, body: $body}) { + success + comment { id body } + } + } + """ + result = await self._graphql(query, {"issueId": issue_id, "body": body}) + return result.get("commentCreate", {}) + + # ── connector protocol ─────────────────────────────────────── + + async def sync(self, *, since: datetime | None = None) -> list[dict[str, Any]]: + payloads: list[dict[str, Any]] = [] + team_ids = self._team_ids + if not team_ids: + teams = await self.list_teams() + team_ids = [t["id"] for t in teams] + for tid in team_ids: + issues = await self.list_issues(team_id=tid, limit=50) + payloads.append({"team_id": tid, "issues": issues}) + return payloads + + +linear_connector = LinearConnector() + +__all__ = ["LinearConnector", "linear_connector"] \ No newline at end of file diff --git a/agent_pm/connectors/sentry.py b/agent_pm/connectors/sentry.py new file mode 100644 index 0000000..6a33635 --- /dev/null +++ b/agent_pm/connectors/sentry.py @@ -0,0 +1,155 @@ +"""Sentry connector — issues, events, error rates for operations awareness.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +import httpx + +from agent_pm.connectors.base import Connector +from agent_pm.settings import settings + + +class SentryConnector(Connector): + def __init__(self) -> None: + super().__init__(name="sentry") + self._auth_token = settings.sentry_auth_token + self._org_slug = settings.sentry_org_slug + self._base_url = settings.sentry_base_url or "https://sentry.io/api/0" + + @property + def enabled(self) -> bool: + return bool(self._auth_token and self._org_slug) + + def _headers(self) -> dict[str, str]: + return { + "Authorization": f"Bearer {self._auth_token}", + "Accept": "application/json", + } + + async def _get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + if settings.dry_run or not self.enabled: + return {"dry_run": True, "path": path, "params": params} + + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{self._base_url}{path}", + headers=self._headers(), + params=params, + timeout=30, + ) + resp.raise_for_status() + return resp.json() + + # ── issues ─────────────────────────────────────────────────── + + async def list_issues( + self, + *, + query: str = "is:unresolved", + sort: str = "freq", + limit: int = 10, + stats_period: str = "14d", + ) -> list[dict[str, Any]]: + """Search unresolved Sentry issues.""" + result = await self._get( + f"/organizations/{self._org_slug}/issues/", + { + "query": query, + "sort": sort, + "limit": limit, + "statsPeriod": stats_period, + }, + ) + return result if isinstance(result, list) else [] + + async def get_issue(self, issue_id: str) -> dict[str, Any]: + return await self._get(f"/organizations/{self._org_slug}/issues/{issue_id}/") + + async def get_issue_events(self, issue_id: str, limit: int = 10) -> list[dict[str, Any]]: + return await self._get( + f"/organizations/{self._org_slug}/issues/{issue_id}/events/", + {"limit": limit}, + ) + + async def get_issue_tag_distribution(self, issue_id: str, tag_key: str) -> list[dict[str, Any]]: + return await self._get( + f"/organizations/{self._org_slug}/issues/{issue_id}/tags/{tag_key}/", + ) + + # ── events / error counts ──────────────────────────────────── + + async def search_events( + self, + *, + dataset: str = "errors", + fields: list[str] | None = None, + query: str = "", + sort: str = "-timestamp", + limit: int = 10, + stats_period: str = "7d", + ) -> list[dict[str, Any]]: + """Search Sentry events (errors, spans, logs).""" + params: dict[str, Any] = { + "dataset": dataset, + "sort": sort, + "per_page": min(limit, 100), + "statsPeriod": stats_period, + } + if fields: + params["field"] = fields + if query: + params["query"] = query + result = await self._get( + f"/organizations/{self._org_slug}/events/", + params, + ) + return result.get("data", []) if isinstance(result, dict) else [] + + async def error_counts( + self, + *, + stats_period: str = "7d", + project: str | None = None, + ) -> dict[str, Any]: + """Get aggregate error counts.""" + fields = ["count()", "project", "issue"] + params: dict[str, Any] = { + "dataset": "errors", + "field": fields, + "sort": "-count()", + "statsPeriod": stats_period, + "per_page": 25, + } + if project: + params["query"] = f"project:{project}" + result = await self._get( + f"/organizations/{self._org_slug}/events/", + params, + ) + return result if isinstance(result, dict) else {"data": []} + + # ── projects ───────────────────────────────────────────────── + + async def list_projects(self) -> list[dict[str, Any]]: + result = await self._get(f"/organizations/{self._org_slug}/projects/") + return result if isinstance(result, list) else [] + + # ── connector protocol ─────────────────────────────────────── + + async def sync(self, *, since: datetime | None = None) -> list[dict[str, Any]]: + issues = await self.list_issues(query="is:unresolved", limit=10) + error_data = await self.error_counts(stats_period="7d") + return [ + { + "issues": issues, + "error_counts": error_data, + "since": since.isoformat() if since else None, + } + ] + + +sentry_connector = SentryConnector() + +__all__ = ["SentryConnector", "sentry_connector"] \ No newline at end of file diff --git a/agent_pm/mcp_server.py b/agent_pm/mcp_server.py new file mode 100644 index 0000000..248de30 --- /dev/null +++ b/agent_pm/mcp_server.py @@ -0,0 +1,334 @@ +"""Hermes MCP server — exposes Agent PM as MCP tools for Hermes and other agents. + +Minimal stdio JSON-RPC MCP server. When Hermes connects via MCP, +it can call agent-pm procedures, scan Sentry, query Linear, etc. +as tools instead of hitting REST endpoints. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import sys +from typing import Any + +logger = logging.getLogger(__name__) + +# Tool definitions exposed to MCP clients +TOOL_DEFINITIONS = [ + { + "name": "agent_pm_run_procedure", + "description": "Run a named procedure (e.g. weekly_progress_review, dependabot_triage, agent_pr_security_scan, deploy_readiness).", + "inputSchema": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Procedure name (stem of YAML file in procedures/)."}, + "dry_run": {"type": "boolean", "default": False, "description": "If true, don't mutate external systems."}, + }, + "required": ["name"], + }, + }, + { + "name": "agent_pm_sentry_scan", + "description": "Scan Sentry for unresolved issues, error counts, or search events.", + "inputSchema": { + "type": "object", + "properties": { + "query": {"type": "string", "default": "is:unresolved", "description": "Sentry issue search query."}, + "stats_period": {"type": "string", "default": "14d", "description": "Time window (1h, 24h, 7d, 14d)."}, + "limit": {"type": "integer", "default": 10}, + }, + }, + }, + { + "name": "agent_pm_linear_scan", + "description": "Query Linear for issues — stale detection, status sweeps, team listing.", + "inputSchema": { + "type": "object", + "properties": { + "action": {"type": "string", "enum": ["list_issues", "list_teams", "stale_sweep"]}, + "team_id": {"type": "string", "description": "Linear team ID to scope to."}, + "state": {"type": "string", "description": "Workflow state name filter (e.g. 'Todo', 'In Progress')."}, + "limit": {"type": "integer", "default": 50}, + }, + "required": ["action"], + }, + }, + { + "name": "agent_pm_github_pr_scan", + "description": "Scan GitHub PRs across configured repos — CI status, mergeability, security bumps.", + "inputSchema": { + "type": "object", + "properties": { + "org": {"type": "string", "default": "evalops", "description": "GitHub org to scan."}, + "author": {"type": "string", "description": "Filter by PR author (e.g. 'dependabot')."}, + "state": {"type": "string", "default": "open"}, + "limit": {"type": "integer", "default": 20}, + }, + }, + }, + { + "name": "agent_pm_list_procedures", + "description": "List all available procedure definitions.", + "inputSchema": {"type": "object", "properties": {}}, + }, +] + + +async def _run_procedure(name: str, dry_run: bool) -> dict[str, Any]: + """Execute a procedure and return the result.""" + from agent_pm.procedures import loader + from agent_pm.planner import generate_plan + from agent_pm.models import Idea + from agent_pm.settings import settings + + procedures = loader.load() + if name not in procedures: + return {"error": f"Procedure '{name}' not found", "available": list(procedures.keys())} + + proc = procedures[name] + try: + prev_dry = settings.dry_run + if dry_run: + settings.dry_run = True + idea = Idea( + title=proc.get("name", name), + context=proc.get("description", f"Execute procedure: {name}"), + ) + result = await generate_plan(idea) + return {"procedure": name, "plan_id": result.get("plan_id"), "dry_run": dry_run} + except Exception as exc: + return {"error": str(exc), "procedure": name} + finally: + if dry_run: + settings.dry_run = prev_dry + + +async def _sentry_scan(query: str, stats_period: str, limit: int) -> dict[str, Any]: + """Run a Sentry issue scan.""" + from agent_pm.connectors.sentry import sentry_connector + + try: + issues = await sentry_connector.list_issues( + query=query, stats_period=stats_period, limit=limit + ) + return {"issues": issues, "count": len(issues), "query": query} + except Exception as exc: + return {"error": str(exc)} + + +async def _linear_scan(action: str, team_id: str | None, state: str | None, limit: int) -> dict[str, Any]: + """Query Linear.""" + from agent_pm.connectors.linear import linear_connector + + try: + if action == "list_teams": + teams = await linear_connector.list_teams() + return {"teams": teams} + elif action == "stale_sweep": + issues = await linear_connector.list_issues(order_by="updatedAt", limit=limit) + # Flag stale items + from datetime import datetime, timedelta, timezone + now = datetime.now(tz=timezone.utc) + stale = [] + for issue in issues: + updated = issue.get("updatedAt") + due = issue.get("dueDate") + flags = [] + if due and due < now.date().isoformat(): + flags.append("past_due") + if updated: + updated_dt = datetime.fromisoformat(updated.replace("Z", "+00:00")) + if (now - updated_dt) > timedelta(days=2): + flags.append("stale") + if flags: + stale.append({"id": issue["id"], "identifier": issue["identifier"], "title": issue["title"], "flags": flags}) + return {"total": len(issues), "stale": stale} + else: + issues = await linear_connector.list_issues( + team_id=team_id, state=state, limit=limit + ) + return {"issues": issues, "count": len(issues)} + except Exception as exc: + return {"error": str(exc)} + + +async def _github_pr_scan(org: str, author: str | None, state: str, limit: int) -> dict[str, Any]: + """Scan GitHub PRs.""" + from agent_pm.connectors.github import GitHubConnector + from agent_pm.settings import settings + + try: + # Use the existing GitHub connector + import httpx + headers = { + "Authorization": f"Bearer {settings.github_token}", + "Accept": "application/vnd.github+json", + } + params: dict[str, Any] = {"per_page": limit, "state": state} + query_parts = [f"org:{org}", f"is:pr", f"state:{state}"] + if author: + query_parts.append(f"author:{author}") + params["q"] = " ".join(query_parts) + async with httpx.AsyncClient() as client: + resp = await client.get( + "https://api.github.com/search/issues", + headers=headers, + params=params, + timeout=30, + ) + resp.raise_for_status() + data = resp.json() + return {"prs": data.get("items", []), "total": data.get("total_count", 0)} + else: + # Use connector for org repos + repos = settings.github_repositories or ["evalops/platform", "evalops/deploy", "evalops/maestro-internal"] + all_prs = [] + for repo in repos: + async with httpx.AsyncClient() as client: + resp = await client.get( + f"https://api.github.com/repos/{repo}/pulls", + headers=headers, + params={"state": state, "per_page": limit}, + timeout=30, + ) + if resp.status_code == 200: + all_prs.extend(resp.json()) + return {"prs": all_prs, "total": len(all_prs)} + except Exception as exc: + return {"error": str(exc)} + + +async def _list_procedures() -> dict[str, Any]: + from agent_pm.procedures import loader + procedures = loader.load() + summaries = {} + for name, proc in procedures.items(): + summaries[name] = { + "description": proc.get("description", ""), + "steps": len(proc.get("steps", [])), + "schedule": proc.get("schedule"), + } + return {"procedures": summaries} + + +# Tool dispatch +TOOL_HANDLERS = { + "agent_pm_run_procedure": lambda args: _run_procedure( + args.get("name", ""), args.get("dry_run", False) + ), + "agent_pm_sentry_scan": lambda args: _sentry_scan( + args.get("query", "is:unresolved"), + args.get("stats_period", "14d"), + args.get("limit", 10), + ), + "agent_pm_linear_scan": lambda args: _linear_scan( + args.get("action", "list_issues"), + args.get("team_id"), + args.get("state"), + args.get("limit", 50), + ), + "agent_pm_github_pr_scan": lambda args: _github_pr_scan( + args.get("org", "evalops"), + args.get("author"), + args.get("state", "open"), + args.get("limit", 20), + ), + "agent_pm_list_procedures": lambda args: _list_procedures(), +} + + +async def handle_request(request: dict[str, Any]) -> dict[str, Any]: + """Handle a JSON-RPC request.""" + req_id = request.get("id") + method = request.get("method", "") + + if method == "initialize": + return { + "jsonrpc": "2.0", + "id": req_id, + "result": { + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "agent-pm-mcp", "version": "0.1.0"}, + }, + } + + if method == "tools/list": + return { + "jsonrpc": "2.0", + "id": req_id, + "result": {"tools": TOOL_DEFINITIONS}, + } + + if method == "tools/call": + params = request.get("params", {}) + tool_name = params.get("name", "") + arguments = params.get("arguments", {}) + + handler = TOOL_HANDLERS.get(tool_name) + if not handler: + return { + "jsonrpc": "2.0", + "id": req_id, + "error": {"code": -32601, "message": f"Unknown tool: {tool_name}"}, + } + + try: + result = await handler(arguments) + return { + "jsonrpc": "2.0", + "id": req_id, + "result": {"content": [{"type": "text", "text": json.dumps(result)}]}, + } + except Exception as exc: + return { + "jsonrpc": "2.0", + "id": req_id, + "error": {"code": -32000, "message": str(exc)}, + } + + if method == "notifications/initialized": + return {} # No response for notifications + + return { + "jsonrpc": "2.0", + "id": req_id, + "error": {"code": -32601, "message": f"Method not found: {method}"}, + } + + +async def serve_stdio() -> None: + """Run the MCP server over stdio (stdin/stdout).""" + logger.info("Agent PM MCP server starting on stdio") + while True: + try: + line = sys.stdin.readline() + if not line: + break + line = line.strip() + if not line: + continue + request = json.loads(line) + response = await handle_request(request) + if response: + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + except json.JSONDecodeError: + continue + except EOFError: + break + except Exception: + logger.exception("MCP server error") + continue + + +def main() -> None: + asyncio.run(serve_stdio()) + + +if __name__ == "__main__": + main() + +__all__ = ["serve_stdio", "handle_request", "TOOL_DEFINITIONS", "main"] \ No newline at end of file diff --git a/agent_pm/scheduler.py b/agent_pm/scheduler.py new file mode 100644 index 0000000..e5749f4 --- /dev/null +++ b/agent_pm/scheduler.py @@ -0,0 +1,159 @@ +"""Cron-style scheduler for procedure execution.""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime, timezone + +import yaml + +from agent_pm.settings import settings + +logger = logging.getLogger(__name__) + +Schedule = dict[str, str] # e.g. {"cron": "0 9 * * 1", "description": "..."} + + +class ProcedureScheduler: + """Loads a schedule definition and runs procedures on cron expressions. + + A simple in-process scheduler — no external dependency. Reads + ``config/procedure_schedule.yaml`` which maps procedure names to + cron expressions (5-field, minute granularity). + + Example schedule YAML: + weekly_progress_review: + cron: "0 9 * * 1" # Monday 9am + dependabot_triage: + cron: "0 10 * * *" # Daily 10am + """ + + def __init__(self, schedule_path: str | None = None) -> None: + self._schedule_path = schedule_path or str( + settings.procedure_dir.parent / "config" / "procedure_schedule.yaml" + ) + self._schedules: dict[str, str] = {} + self._last_runs: dict[str, datetime] = {} + self._task: asyncio.Task[None] | None = None + self._running = False + + def load(self) -> dict[str, str]: + path = self._schedule_path + try: + with open(path) as f: + raw = yaml.safe_load(f) or {} + except FileNotFoundError: + logger.info("No procedure schedule found at %s — nothing scheduled", path) + return {} + self._schedules = {name: entry["cron"] for name, entry in raw.items()} + return self._schedules + + def _cron_matches(self, cron: str, dt: datetime) -> bool: + """Check if a 5-field cron expression matches *right now* (minute granularity).""" + parts = cron.strip().split() + if len(parts) != 5: + return False + + minute, hour, dom, month, dow = parts + + def _match(field: str, value: int) -> bool: + if field == "*": + return True + for alt in field.split(","): + alt = alt.strip() + if "-" in alt: + lo_s, hi_s = alt.split("-", 1) + if int(lo_s) <= value <= int(hi_s): + return True + elif alt == str(value): + return True + return False + + # Python weekday: 0=Mon, 6=Sun. Cron weekday: 0=Sun, 1=Mon, ..., 6=Sat. + cron_dow_map: dict[int, int] = {0: 1, 1: 2, 2: 3, 3: 4, 4: 5, 5: 6, 6: 0} + python_dow = dt.weekday() # 0=Monday + cron_dow_value = cron_dow_map.get(python_dow, python_dow) + + return ( + _match(minute, dt.minute) + and _match(hour, dt.hour) + and _match(dom, dt.day) + and _match(month, dt.month) + and _match(dow, cron_dow_value) + ) + + async def _run_procedure(self, name: str) -> None: + """Execute a named procedure.""" + from agent_pm.procedures import loader + + procedures = loader.load() + if name not in procedures: + logger.warning("Scheduled procedure '%s' not found in procedures/", name) + return + + logger.info("Running scheduled procedure: %s", name) + + try: + from agent_pm.planner import generate_plan + from agent_pm.models import Idea + + proc = procedures[name] + title = proc.get("name", name) + steps = len(proc.get("steps", [])) + idea = Idea( + title=title, + context=f"Scheduled execution of procedure with {steps} steps.", + ) + result = await generate_plan(idea) + logger.info("Procedure '%s' completed (plan_id=%s)", name, result.get("plan_id")) + except Exception: + logger.exception("Procedure '%s' failed", name) + + async def _tick(self) -> None: + """Single scheduler tick — check all scheduled procedures.""" + now = datetime.now(tz=timezone.utc) + for name, cron_expr in self._schedules.items(): + if not self._cron_matches(cron_expr, now): + continue + last = self._last_runs.get(name) + # Only run once per minute + if last and (now - last).total_seconds() < 120: + continue + self._last_runs[name] = now + await self._run_procedure(name) + + async def _loop(self) -> None: + """Main scheduler loop — checks every 60 seconds.""" + while self._running: + try: + await self._tick() + except Exception: + logger.exception("Scheduler tick error") + await asyncio.sleep(60) + + async def start(self) -> None: + self.load() + if not self._schedules: + logger.info("No scheduled procedures — scheduler idle") + return + self._running = True + self._task = asyncio.create_task(self._loop()) + logger.info( + "Procedure scheduler started (%d procedures)", len(self._schedules) + ) + + async def stop(self) -> None: + self._running = False + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + +scheduler = ProcedureScheduler() + +__all__ = ["ProcedureScheduler", "scheduler"] \ No newline at end of file diff --git a/agent_pm/settings.py b/agent_pm/settings.py index fa35306..0fbcf3d 100644 --- a/agent_pm/settings.py +++ b/agent_pm/settings.py @@ -21,6 +21,12 @@ class Settings(BaseSettings): description="Optional when DRY_RUN=true; required for live OpenAI access.", ) github_token: str | None = Field(None, alias="GITHUB_TOKEN") + github_repositories: list[str] = Field(default_factory=list, alias="GITHUB_REPOSITORIES") + linear_api_key: str | None = Field(None, alias="LINEAR_API_KEY") + linear_team_ids: list[str] = Field(default_factory=list, alias="LINEAR_TEAM_IDS") + sentry_auth_token: str | None = Field(None, alias="SENTRY_AUTH_TOKEN") + sentry_org_slug: str | None = Field(None, alias="SENTRY_ORG_SLUG") + sentry_base_url: str | None = Field(None, alias="SENTRY_BASE_URL") jira_base_url: str | None = Field(None, alias="JIRA_BASE_URL") jira_api_token: str | None = Field(None, alias="JIRA_API_TOKEN") jira_email: str | None = Field(None, alias="JIRA_EMAIL") @@ -54,7 +60,7 @@ def _parse_csv_list(value: str | list[str] | None) -> list[str]: return value @field_validator( - "github_repositories", "slack_sync_channels", "gmail_label_filter", "notion_database_ids", mode="before" + "github_repositories", "linear_team_ids", "slack_sync_channels", "gmail_label_filter", "notion_database_ids", mode="before" ) @classmethod def _parse_string_lists(cls, value): diff --git a/config/procedure_schedule.yaml b/config/procedure_schedule.yaml new file mode 100644 index 0000000..4389319 --- /dev/null +++ b/config/procedure_schedule.yaml @@ -0,0 +1,19 @@ +# Procedure schedules — 5-field cron (minute hour dom month dow) +# Runs inside the Agent PM process via ProcedureScheduler. +# Comment out or remove entries to disable. + +weekly_progress_review: + cron: "0 9 * * 1" # Monday 9am UTC + description: "Calendar → Sentry → Linear → GitHub sweep + Slack digest" + +dependabot_triage: + cron: "0 10 * * *" # Daily 10am UTC + description: "Scan all open Dependabot PRs, auto-merge green security bumps" + +agent_pr_security_scan: + cron: "0 */4 * * *" # Every 4 hours + description: "Scan recent agent PRs for leaked secrets, unsafe patterns, missing authz" + +deploy_readiness: + cron: "0 14 * * *" # Daily 2pm UTC + description: "Cross-reference platform PRs against deploy repo for monitoring/alerting gaps" \ No newline at end of file diff --git a/procedures/agent_pr_security_scan.yaml b/procedures/agent_pr_security_scan.yaml new file mode 100644 index 0000000..db880e3 --- /dev/null +++ b/procedures/agent_pr_security_scan.yaml @@ -0,0 +1,35 @@ +name: agent_pr_security_scan +description: "Scan recent agent-generated PRs for security issues — leaked secrets, unsafe patterns, missing validation." +schedule: "0 */4 * * *" # Every 4 hours +steps: + - id: fetch_recent_agent_prs + run: github_pr_scan + input: | + List PRs opened in the last 24 hours across evalops repos and haasonsaas/homelab. + Filter to PRs authored by bots or agents (dependabot, codex, cursor, maestro, claude). + For each PR, fetch the diff. + + - id: security_scan + run: model + input: | + For each PR diff, scan for: + - Secrets: API keys, tokens, passwords, private keys (AWS_*, GITHUB_TOKEN, etc.) + - Unsafe code: eval(), exec(), subprocess with shell=True, os.system() + - Missing authz: new endpoints without auth checks, unauthenticated handlers + - Dependency risk: new dependencies with known vulnerabilities + - Hardcoded credentials: connection strings, JWT secrets + Categorize findings by severity (critical/high/medium/low). + Return a structured list per PR. + + - id: compose_alerts + run: model + input: | + If any critical or high findings exist, compose an immediate Slack alert. + Otherwise, produce a "clean scan" summary. + Format: PR link, finding, severity, suggested fix. + + - id: deliver + run: publish_status_digest + with: + channel: "#engineering" + body_md: "{{alerts}}" \ No newline at end of file diff --git a/procedures/dependabot_triage.yaml b/procedures/dependabot_triage.yaml new file mode 100644 index 0000000..7e6ddae --- /dev/null +++ b/procedures/dependabot_triage.yaml @@ -0,0 +1,36 @@ +name: dependabot_triage +description: "Scan all open Dependabot PRs across repos — merge green security bumps, report skips." +schedule: "0 10 * * *" # Daily 10am +steps: + - id: scan_dependabot_prs + run: github_pr_scan + input: | + Search for open PRs across evalops repos and haasonsaas/homelab authored by dependabot. + For each PR: check CI status, mergeability, labels. + Flag security-relevant bumps: look for GHSA-* advisory references or CVEs in the body. + Return categorized list: + - MERGEABLE_SECURITY: green CI + security fix — auto-merge + - MERGEABLE_ROUTINE: green CI + no security impact — report for review + - BLOCKED: failing CI or conflicts — report for manual attention + + - id: merge_security_bumps + run: model + input: | + For each PR in MERGEABLE_SECURITY: + Determine if it can be auto-merged (squash merge). + List the PR number, repo, title, and the security advisory references. + Output the merge commands or note any that need manual intervention. + + - id: compose_report + run: model + input: | + Compose a terse Slack report: + - Merged: list PRs auto-merged with advisory refs + - Skipped: list BLOCKED or MERGEABLE_ROUTINE PRs with reason + Keep it dry and factual. No marketing. + + - id: deliver + run: publish_status_digest + with: + channel: "#engineering" + body_md: "{{report}}" \ No newline at end of file diff --git a/procedures/deploy_readiness.yaml b/procedures/deploy_readiness.yaml new file mode 100644 index 0000000..2f3d789 --- /dev/null +++ b/procedures/deploy_readiness.yaml @@ -0,0 +1,45 @@ +name: deploy_readiness +description: "Cross-reference platform PRs against deploy repo — flag gaps in alerting, observability, or deployment manifests." +schedule: "0 14 * * *" # Daily 2pm +steps: + - id: scan_platform_prs + run: github_pr_scan + input: | + List open PRs in evalops/platform. Note each PR's title, labels, changed files. + Identify PRs that introduce new endpoints, change configuration, or modify proto contracts. + + - id: scan_deploy_prs + run: github_pr_scan + input: | + List open PRs in evalops/deploy. Note each PR's title, labels, changed files. + Check for monitoring/alerting changes, Kyverno policies, new service deployments. + + - id: cross_reference + run: model + input: | + For each platform PR that introduces a new endpoint or config change: + - Does a corresponding deploy PR exist with updated monitoring/alerting? + - Are there Grafana dashboard updates, Prometheus alert rule changes, or SLO definitions? + - Is there a Kyverno policy update if security posture changed? + Flag gaps: platform changes with no deploy counterpart. + + - id: sentry_pulse + run: sentry_scan + input: | + Check Sentry for any new error patterns in the last 24h that correlate with recent deploys. + Match error timestamps against deploy timestamps from GitHub releases. + + - id: compose_report + run: model + input: | + Compose a terse report: + - Platform PRs needing deploy attention (missing alerting, missing policy) + - Sentry regressions (if any) + - Dependabot PRs in deploy that should be merged + Dry, factual, numbered. No fluff. + + - id: deliver + run: publish_status_digest + with: + channel: "#engineering" + body_md: "{{report}}" \ No newline at end of file diff --git a/procedures/weekly_progress_review.yaml b/procedures/weekly_progress_review.yaml new file mode 100644 index 0000000..2852598 --- /dev/null +++ b/procedures/weekly_progress_review.yaml @@ -0,0 +1,38 @@ +name: weekly_progress_review +description: "Multi-source progress sweep — Calendar → Sentry → Linear → GitHub → Slack digest." +schedule: "0 9 * * 1" # Monday 9am +steps: + - id: sentry_check + run: sentry_scan + input: | + List unresolved Sentry issues (is:unresolved, sort=freq, 14d). + Return a summary: count of unresolved, top issue titles, any regressions. + + - id: linear_stale_scan + run: linear_scan + input: | + Pull all issues assigned to me, sorted by updatedAt ascending. + Flag items: due date blown, last updated > 2 days ago, "In Progress" without recent comments. + Return a list of stale items with IDs and recommended actions. + + - id: github_pr_sweep + run: github_pr_scan + input: | + Scan open PRs across evalops repos and haasonsaas/homelab. + Check CI status, mergeability, review state. + Flag: green-but-unmerged, stale, security bumps (dependabot with GHSA refs). + Return PR summary with merge recommendations. + + - id: compose_digest + run: model + input: | + Compose a Slack digest from sentry_check, linear_stale_scan, and github_pr_sweep. + Format as bulleted sections: Sentry, Linear, GitHub PRs. + Keep it terse — no marketing language, no self-narration. + Include concrete next actions (merge PR #X, close issue #Y). + + - id: deliver + run: publish_status_digest + with: + channel: "#engineering" + body_md: "{{digest}}" \ No newline at end of file diff --git a/tests/test_connectors.py b/tests/test_connectors.py index c6b1337..8bd7650 100644 --- a/tests/test_connectors.py +++ b/tests/test_connectors.py @@ -7,7 +7,9 @@ EmailConnector, GitHubConnector, GoogleDriveConnector, + LinearConnector, NotionConnector, + SentryConnector, SlackConnector, ) from agent_pm.connectors.base import Connector @@ -143,3 +145,118 @@ async def test_periodic_sync_manager_executes_jobs(monkeypatch): records = await sync_storage.list_recent_syncs(limit=5) assert any(record["connector"] == "dummy" for record in records) + + +# ── Linear connector tests ──────────────────────────────────────── + +@pytest.mark.asyncio +async def test_linear_connector_disabled_without_key(monkeypatch): + monkeypatch.setattr(settings, "linear_api_key", None) + connector = LinearConnector() + assert connector.enabled is False + + +@pytest.mark.asyncio +async def test_linear_connector_dry_run(monkeypatch): + monkeypatch.setattr(settings, "linear_api_key", "lin-api-test") + monkeypatch.setattr(settings, "dry_run", True) + connector = LinearConnector() + payloads = await connector.sync() + assert len(payloads) >= 0 + # Dry run should return placeholder, not hit real API + if payloads: + assert payloads[0].get("dry_run") is True or "team_id" in payloads[0] + + +# ── Sentry connector tests ──────────────────────────────────────── + +@pytest.mark.asyncio +async def test_sentry_connector_disabled_without_credentials(monkeypatch): + monkeypatch.setattr(settings, "sentry_auth_token", None) + monkeypatch.setattr(settings, "sentry_org_slug", None) + connector = SentryConnector() + assert connector.enabled is False + + +@pytest.mark.asyncio +async def test_sentry_connector_dry_run(monkeypatch): + monkeypatch.setattr(settings, "sentry_auth_token", "sntrys-token") + monkeypatch.setattr(settings, "sentry_org_slug", "evalops-inc") + monkeypatch.setattr(settings, "dry_run", True) + connector = SentryConnector() + payloads = await connector.sync() + assert len(payloads) == 1 + assert payloads[0]["error_counts"].get("dry_run") is True + + +# ── Procedure loader tests ──────────────────────────────────────── + +def test_procedure_loader_discovers_yaml(tmp_path, monkeypatch): + from agent_pm.procedures import ProcedureLoader + + proc_dir = tmp_path / "procedures" + proc_dir.mkdir() + (proc_dir / "test_proc.yaml").write_text("name: test\ndescription: A test procedure\nsteps: []") + + monkeypatch.setattr(settings, "procedure_dir", proc_dir) + loader = ProcedureLoader(directory=proc_dir) + procs = loader.load() + assert "test_proc" in procs + assert procs["test_proc"]["name"] == "test" + + +# ── Scheduler tests ─────────────────────────────────────────────── + +def test_scheduler_cron_matching(): + from agent_pm.scheduler import ProcedureScheduler + from datetime import datetime, timezone + + s = ProcedureScheduler() + + # Match: Monday 9:00 UTC + dt_match = datetime(2026, 6, 15, 9, 0, tzinfo=timezone.utc) # Monday + assert s._cron_matches("0 9 * * 1", dt_match) is True + + # No match: wrong minute + dt_wrong_min = datetime(2026, 6, 15, 9, 1, tzinfo=timezone.utc) + assert s._cron_matches("0 9 * * 1", dt_wrong_min) is False + + # No match: wrong day + dt_wrong_day = datetime(2026, 6, 16, 9, 0, tzinfo=timezone.utc) # Tuesday + assert s._cron_matches("0 9 * * 1", dt_wrong_day) is False + + +# ── MCP server tests ────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_mcp_initialize(): + from agent_pm.mcp_server import handle_request + resp = await handle_request({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}) + assert resp["result"]["serverInfo"]["name"] == "agent-pm-mcp" + + +@pytest.mark.asyncio +async def test_mcp_list_tools(): + from agent_pm.mcp_server import handle_request + resp = await handle_request({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}) + tool_names = [t["name"] for t in resp["result"]["tools"]] + assert "agent_pm_run_procedure" in tool_names + assert "agent_pm_sentry_scan" in tool_names + assert "agent_pm_linear_scan" in tool_names + assert "agent_pm_github_pr_scan" in tool_names + assert "agent_pm_list_procedures" in tool_names + + +@pytest.mark.asyncio +async def test_mcp_list_procedures_tool(): + from agent_pm.mcp_server import handle_request + resp = await handle_request({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": {"name": "agent_pm_list_procedures", "arguments": {}}, + }) + text = resp["result"]["content"][0]["text"] + import json + data = json.loads(text) + assert "procedures" in data From 74942cb086a17ebed16c10d45cb3e5350f6ae1fb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 14 Jun 2026 17:50:16 +0000 Subject: [PATCH 02/17] Fix procedure planner calls and scheduler startup --- agent_pm/connectors/linear.py | 10 ++- agent_pm/mcp_server.py | 40 +++++------ agent_pm/planner.py | 63 +++++++++++++++++- agent_pm/scheduler.py | 23 +++---- app.py | 48 ++++---------- tests/test_app_lifespan.py | 59 +++++++++++++++++ tests/test_connectors.py | 121 +++++++++++++++++++++++++++++++--- 7 files changed, 277 insertions(+), 87 deletions(-) create mode 100644 tests/test_app_lifespan.py diff --git a/agent_pm/connectors/linear.py b/agent_pm/connectors/linear.py index e3123f6..9a13935 100644 --- a/agent_pm/connectors/linear.py +++ b/agent_pm/connectors/linear.py @@ -62,13 +62,12 @@ async def list_issues( filters["assignee"] = {"id": {"eq": assignee_id}} if team_id: filters["team"] = {"id": {"eq": team_id}} - state_filter: dict[str, Any] | None = None if state: - state_filter = {"name": {"eq": state}} + filters["state"] = {"name": {"eq": state}} query = """ - query($filter: IssueFilter, $stateFilter: WorkflowStateFilter, $orderBy: PaginationOrderBy, $first: Int!) { - issues(filter: $filter, state: $stateFilter, orderBy: $orderBy, first: $first) { + query($filter: IssueFilter, $orderBy: PaginationOrderBy, $first: Int!) { + issues(filter: $filter, orderBy: $orderBy, first: $first) { nodes { id identifier title description state { name } priority assignee { id name email } team { id name key } @@ -83,7 +82,6 @@ async def list_issues( query, { "filter": filters if filters else None, - "stateFilter": state_filter, "orderBy": order_by, "first": limit, }, @@ -185,4 +183,4 @@ async def sync(self, *, since: datetime | None = None) -> list[dict[str, Any]]: linear_connector = LinearConnector() -__all__ = ["LinearConnector", "linear_connector"] \ No newline at end of file +__all__ = ["LinearConnector", "linear_connector"] diff --git a/agent_pm/mcp_server.py b/agent_pm/mcp_server.py index 248de30..e27ebb7 100644 --- a/agent_pm/mcp_server.py +++ b/agent_pm/mcp_server.py @@ -24,7 +24,11 @@ "type": "object", "properties": { "name": {"type": "string", "description": "Procedure name (stem of YAML file in procedures/)."}, - "dry_run": {"type": "boolean", "default": False, "description": "If true, don't mutate external systems."}, + "dry_run": { + "type": "boolean", + "default": False, + "description": "If true, don't mutate external systems.", + }, }, "required": ["name"], }, @@ -78,9 +82,9 @@ async def _run_procedure(name: str, dry_run: bool) -> dict[str, Any]: """Execute a procedure and return the result.""" - from agent_pm.procedures import loader - from agent_pm.planner import generate_plan from agent_pm.models import Idea + from agent_pm.planner import generate_plan_for_idea + from agent_pm.procedures import loader from agent_pm.settings import settings procedures = loader.load() @@ -96,7 +100,7 @@ async def _run_procedure(name: str, dry_run: bool) -> dict[str, Any]: title=proc.get("name", name), context=proc.get("description", f"Execute procedure: {name}"), ) - result = await generate_plan(idea) + result = generate_plan_for_idea(idea) return {"procedure": name, "plan_id": result.get("plan_id"), "dry_run": dry_run} except Exception as exc: return {"error": str(exc), "procedure": name} @@ -110,9 +114,7 @@ async def _sentry_scan(query: str, stats_period: str, limit: int) -> dict[str, A from agent_pm.connectors.sentry import sentry_connector try: - issues = await sentry_connector.list_issues( - query=query, stats_period=stats_period, limit=limit - ) + issues = await sentry_connector.list_issues(query=query, stats_period=stats_period, limit=limit) return {"issues": issues, "count": len(issues), "query": query} except Exception as exc: return {"error": str(exc)} @@ -129,8 +131,9 @@ async def _linear_scan(action: str, team_id: str | None, state: str | None, limi elif action == "stale_sweep": issues = await linear_connector.list_issues(order_by="updatedAt", limit=limit) # Flag stale items - from datetime import datetime, timedelta, timezone - now = datetime.now(tz=timezone.utc) + from datetime import UTC, datetime, timedelta + + now = datetime.now(tz=UTC) stale = [] for issue in issues: updated = issue.get("updatedAt") @@ -143,12 +146,12 @@ async def _linear_scan(action: str, team_id: str | None, state: str | None, limi if (now - updated_dt) > timedelta(days=2): flags.append("stale") if flags: - stale.append({"id": issue["id"], "identifier": issue["identifier"], "title": issue["title"], "flags": flags}) + stale.append( + {"id": issue["id"], "identifier": issue["identifier"], "title": issue["title"], "flags": flags} + ) return {"total": len(issues), "stale": stale} else: - issues = await linear_connector.list_issues( - team_id=team_id, state=state, limit=limit - ) + issues = await linear_connector.list_issues(team_id=team_id, state=state, limit=limit) return {"issues": issues, "count": len(issues)} except Exception as exc: return {"error": str(exc)} @@ -156,18 +159,18 @@ async def _linear_scan(action: str, team_id: str | None, state: str | None, limi async def _github_pr_scan(org: str, author: str | None, state: str, limit: int) -> dict[str, Any]: """Scan GitHub PRs.""" - from agent_pm.connectors.github import GitHubConnector from agent_pm.settings import settings try: # Use the existing GitHub connector import httpx + headers = { "Authorization": f"Bearer {settings.github_token}", "Accept": "application/vnd.github+json", } params: dict[str, Any] = {"per_page": limit, "state": state} - query_parts = [f"org:{org}", f"is:pr", f"state:{state}"] + query_parts = [f"org:{org}", "is:pr", f"state:{state}"] if author: query_parts.append(f"author:{author}") params["q"] = " ".join(query_parts) @@ -202,6 +205,7 @@ async def _github_pr_scan(org: str, author: str | None, state: str, limit: int) async def _list_procedures() -> dict[str, Any]: from agent_pm.procedures import loader + procedures = loader.load() summaries = {} for name, proc in procedures.items(): @@ -215,9 +219,7 @@ async def _list_procedures() -> dict[str, Any]: # Tool dispatch TOOL_HANDLERS = { - "agent_pm_run_procedure": lambda args: _run_procedure( - args.get("name", ""), args.get("dry_run", False) - ), + "agent_pm_run_procedure": lambda args: _run_procedure(args.get("name", ""), args.get("dry_run", False)), "agent_pm_sentry_scan": lambda args: _sentry_scan( args.get("query", "is:unresolved"), args.get("stats_period", "14d"), @@ -331,4 +333,4 @@ def main() -> None: if __name__ == "__main__": main() -__all__ = ["serve_stdio", "handle_request", "TOOL_DEFINITIONS", "main"] \ No newline at end of file +__all__ = ["serve_stdio", "handle_request", "TOOL_DEFINITIONS", "main"] diff --git a/agent_pm/planner.py b/agent_pm/planner.py index cc92c09..6bc03d6 100644 --- a/agent_pm/planner.py +++ b/agent_pm/planner.py @@ -5,10 +5,17 @@ from collections import deque from . import embeddings -from .agent_sdk import CriticReview, PRDPlan, run_critic_agent, run_planner_agent +from .agent_sdk import ( + CriticReview, + PRDPlan, + planner_tools_default_enabled, + run_critic_agent, + run_planner_agent, +) from .alignment.log import record_alignment_event from .clients import openai_client, slack_client from .memory import TraceMemory, vector_memory +from .models import Idea from .observability.metrics import ( record_alignment_notification, record_dspy_guidance, @@ -316,6 +323,52 @@ def _maybe_get_dspy_guidance(title: str, context: str, constraints: list[str]) - return "" +def generate_plan_for_idea( + idea: Idea, + *, + trace: TraceMemory | None = None, +) -> dict[str, str]: + defaults = { + "requirements": [ + "Generate PRD using standard template", + "Create Jira epics and stories automatically", + "Publish Slack status digest", + ], + "acceptance": [ + "PRD includes context, goals, non-goals, ACs", + "Ticket plan generated with action items", + "Status digest ready for stakeholders", + ], + "goals": ["Ship MVP", "Lower time-to-spec", "Reduce PM toil"], + "nongoals": ["Rewrite infrastructure"], + "risks": ["Hallucinated scope", "Missed dependency"], + "users": "Engineers, PMs, stakeholders", + } + planner_trace = trace if trace is not None else TraceMemory() + default_tool_flag = settings.agent_tools_enabled or planner_tools_default_enabled() + enable_tools = default_tool_flag if idea.enable_tools is None else idea.enable_tools + if enable_tools: + from .tools import registry + + response_tools = registry.as_openai_tools() + else: + response_tools = [] + return generate_plan( + title=idea.title, + context=idea.context or "", + constraints=idea.constraints, + requirements=defaults["requirements"], + acceptance=defaults["acceptance"], + goals=defaults["goals"], + nongoals=defaults["nongoals"], + risks=defaults["risks"], + users=defaults["users"], + trace=planner_trace, + tools=response_tools, + enable_tools=bool(enable_tools), + ) + + def generate_plan( title: str, context: str, @@ -527,4 +580,10 @@ def _merge_list(default: list[str], candidate: list[str]) -> list[str]: return result -__all__ = ["generate_plan", "build_user_prompt", "SYSTEM_PROMPT", "build_status_digest"] +__all__ = [ + "generate_plan", + "generate_plan_for_idea", + "build_user_prompt", + "SYSTEM_PROMPT", + "build_status_digest", +] diff --git a/agent_pm/scheduler.py b/agent_pm/scheduler.py index e5749f4..7fe4c05 100644 --- a/agent_pm/scheduler.py +++ b/agent_pm/scheduler.py @@ -4,7 +4,8 @@ import asyncio import logging -from datetime import datetime, timezone +from contextlib import suppress +from datetime import UTC, datetime import yaml @@ -30,9 +31,7 @@ class ProcedureScheduler: """ def __init__(self, schedule_path: str | None = None) -> None: - self._schedule_path = schedule_path or str( - settings.procedure_dir.parent / "config" / "procedure_schedule.yaml" - ) + self._schedule_path = schedule_path or str(settings.procedure_dir.parent / "config" / "procedure_schedule.yaml") self._schedules: dict[str, str] = {} self._last_runs: dict[str, datetime] = {} self._task: asyncio.Task[None] | None = None @@ -95,8 +94,8 @@ async def _run_procedure(self, name: str) -> None: logger.info("Running scheduled procedure: %s", name) try: - from agent_pm.planner import generate_plan from agent_pm.models import Idea + from agent_pm.planner import generate_plan_for_idea proc = procedures[name] title = proc.get("name", name) @@ -105,14 +104,14 @@ async def _run_procedure(self, name: str) -> None: title=title, context=f"Scheduled execution of procedure with {steps} steps.", ) - result = await generate_plan(idea) + result = generate_plan_for_idea(idea) logger.info("Procedure '%s' completed (plan_id=%s)", name, result.get("plan_id")) except Exception: logger.exception("Procedure '%s' failed", name) async def _tick(self) -> None: """Single scheduler tick — check all scheduled procedures.""" - now = datetime.now(tz=timezone.utc) + now = datetime.now(tz=UTC) for name, cron_expr in self._schedules.items(): if not self._cron_matches(cron_expr, now): continue @@ -139,21 +138,17 @@ async def start(self) -> None: return self._running = True self._task = asyncio.create_task(self._loop()) - logger.info( - "Procedure scheduler started (%d procedures)", len(self._schedules) - ) + logger.info("Procedure scheduler started (%d procedures)", len(self._schedules)) async def stop(self) -> None: self._running = False if self._task: self._task.cancel() - try: + with suppress(asyncio.CancelledError): await self._task - except asyncio.CancelledError: - pass self._task = None scheduler = ProcedureScheduler() -__all__ = ["ProcedureScheduler", "scheduler"] \ No newline at end of file +__all__ = ["ProcedureScheduler", "scheduler"] diff --git a/app.py b/app.py index 6d55540..d83523c 100644 --- a/app.py +++ b/app.py @@ -14,7 +14,7 @@ from pydantic import BaseModel, model_validator from sqlalchemy.ext.asyncio import AsyncSession -from agent_pm.agent_sdk import planner_tools_default_enabled, reload_agent_profiles +from agent_pm.agent_sdk import reload_agent_profiles from agent_pm.alignment.log import ( fetch_alignment_events, get_alignment_summary, @@ -54,7 +54,7 @@ ) from agent_pm.observability.traces import list_traces as list_trace_files from agent_pm.observability.traces import persist_trace, summarize_trace -from agent_pm.planner import generate_plan +from agent_pm.planner import generate_plan_for_idea from agent_pm.plugins import plugin_registry from agent_pm.prd.changelog import generate_changelog from agent_pm.prd.versions import ( @@ -65,12 +65,12 @@ get_version_history, ) from agent_pm.procedures import loader as procedure_loader +from agent_pm.scheduler import scheduler from agent_pm.settings import settings from agent_pm.storage import syncs as sync_storage from agent_pm.storage.database import PRDVersion, get_db from agent_pm.storage.tasks import TaskStatus, get_task_queue from agent_pm.tasks.sync import PeriodicSyncManager, create_default_sync_manager -from agent_pm.tools import registry @asynccontextmanager @@ -84,10 +84,18 @@ async def lifespan(_app: FastAPI): except Exception: # pragma: no cover - defensive startup logging logger.exception("Failed to start periodic sync manager") _sync_manager = None + try: + await scheduler.start() + except Exception: # pragma: no cover - defensive startup logging + logger.exception("Failed to start procedure scheduler") logger.info("Agent PM service started") try: yield finally: + try: + await scheduler.stop() + except Exception: # pragma: no cover - defensive shutdown logging + logger.exception("Failed to stop procedure scheduler") if _sync_manager is not None: await _sync_manager.stop() _sync_manager = None @@ -324,41 +332,9 @@ async def install_plugin_endpoint(request: PluginInstallRequest, _admin_key: Adm async def _plan_impl(idea: Idea) -> dict[str, Any]: trace = TraceMemory() - defaults = { - "requirements": [ - "Generate PRD using standard template", - "Create Jira epics and stories automatically", - "Publish Slack status digest", - ], - "acceptance": [ - "PRD includes context, goals, non-goals, ACs", - "Ticket plan generated with action items", - "Status digest ready for stakeholders", - ], - "goals": ["Ship MVP", "Lower time-to-spec", "Reduce PM toil"], - "nongoals": ["Rewrite infrastructure"], - "risks": ["Hallucinated scope", "Missed dependency"], - "users": "Engineers, PMs, stakeholders", - } - default_tool_flag = settings.agent_tools_enabled or planner_tools_default_enabled() - enable_tools = default_tool_flag if idea.enable_tools is None else idea.enable_tools - response_tools = registry.as_openai_tools() if enable_tools else [] try: - result = generate_plan( - title=idea.title, - context=idea.context or "", - constraints=idea.constraints, - requirements=defaults["requirements"], - acceptance=defaults["acceptance"], - goals=defaults["goals"], - nongoals=defaults["nongoals"], - risks=defaults["risks"], - users=defaults["users"], - trace=trace, - tools=response_tools, - enable_tools=bool(enable_tools), - ) + result = generate_plan_for_idea(idea, trace=trace) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc trace_path = persist_trace(idea.title, trace) diff --git a/tests/test_app_lifespan.py b/tests/test_app_lifespan.py new file mode 100644 index 0000000..bfac5fe --- /dev/null +++ b/tests/test_app_lifespan.py @@ -0,0 +1,59 @@ +import pytest + +import app as app_module + + +@pytest.mark.asyncio +async def test_lifespan_starts_and_stops_procedure_scheduler(monkeypatch): + class StubQueue: + def __init__(self) -> None: + self.started = False + self.stopped = False + + async def start(self) -> None: + self.started = True + + async def stop(self) -> None: + self.stopped = True + + class StubManager: + def __init__(self) -> None: + self.started = False + self.stopped = False + + async def start(self) -> None: + self.started = True + + async def stop(self) -> None: + self.stopped = True + + class StubScheduler: + def __init__(self) -> None: + self.started = False + self.stopped = False + + async def start(self) -> None: + self.started = True + + async def stop(self) -> None: + self.stopped = True + + queue = StubQueue() + manager = StubManager() + scheduler = StubScheduler() + + async def fake_get_task_queue(): + return queue + + monkeypatch.setattr(app_module, "get_task_queue", fake_get_task_queue) + monkeypatch.setattr(app_module, "create_default_sync_manager", lambda: manager) + monkeypatch.setattr(app_module, "scheduler", scheduler) + + async with app_module.lifespan(app_module.app): + assert queue.started is True + assert manager.started is True + assert scheduler.started is True + + assert scheduler.stopped is True + assert manager.stopped is True + assert queue.stopped is True diff --git a/tests/test_connectors.py b/tests/test_connectors.py index 8bd7650..2feea74 100644 --- a/tests/test_connectors.py +++ b/tests/test_connectors.py @@ -1,4 +1,6 @@ import asyncio +from datetime import UTC, datetime +from typing import Any import pytest @@ -149,6 +151,7 @@ async def test_periodic_sync_manager_executes_jobs(monkeypatch): # ── Linear connector tests ──────────────────────────────────────── + @pytest.mark.asyncio async def test_linear_connector_disabled_without_key(monkeypatch): monkeypatch.setattr(settings, "linear_api_key", None) @@ -168,8 +171,33 @@ async def test_linear_connector_dry_run(monkeypatch): assert payloads[0].get("dry_run") is True or "team_id" in payloads[0] +@pytest.mark.asyncio +async def test_linear_connector_nests_state_filter(monkeypatch): + monkeypatch.setattr(settings, "linear_api_key", "lin-api-test") + monkeypatch.setattr(settings, "dry_run", False) + connector = LinearConnector() + captured: dict[str, Any] = {} + + async def fake_graphql(query: str, variables: dict[str, Any] | None = None) -> dict[str, Any]: + captured["query"] = query + captured["variables"] = variables or {} + return {"issues": {"nodes": []}} + + monkeypatch.setattr(connector, "_graphql", fake_graphql) + + await connector.list_issues(team_id="team-123", state="In Progress", limit=10) + + assert "stateFilter" not in captured["query"] + assert "issues(filter: $filter, orderBy: $orderBy, first: $first)" in captured["query"] + assert captured["variables"]["filter"] == { + "team": {"id": {"eq": "team-123"}}, + "state": {"name": {"eq": "In Progress"}}, + } + + # ── Sentry connector tests ──────────────────────────────────────── + @pytest.mark.asyncio async def test_sentry_connector_disabled_without_credentials(monkeypatch): monkeypatch.setattr(settings, "sentry_auth_token", None) @@ -191,6 +219,7 @@ async def test_sentry_connector_dry_run(monkeypatch): # ── Procedure loader tests ──────────────────────────────────────── + def test_procedure_loader_discovers_yaml(tmp_path, monkeypatch): from agent_pm.procedures import ProcedureLoader @@ -207,30 +236,63 @@ def test_procedure_loader_discovers_yaml(tmp_path, monkeypatch): # ── Scheduler tests ─────────────────────────────────────────────── + def test_scheduler_cron_matching(): from agent_pm.scheduler import ProcedureScheduler - from datetime import datetime, timezone s = ProcedureScheduler() # Match: Monday 9:00 UTC - dt_match = datetime(2026, 6, 15, 9, 0, tzinfo=timezone.utc) # Monday + dt_match = datetime(2026, 6, 15, 9, 0, tzinfo=UTC) # Monday assert s._cron_matches("0 9 * * 1", dt_match) is True # No match: wrong minute - dt_wrong_min = datetime(2026, 6, 15, 9, 1, tzinfo=timezone.utc) + dt_wrong_min = datetime(2026, 6, 15, 9, 1, tzinfo=UTC) assert s._cron_matches("0 9 * * 1", dt_wrong_min) is False # No match: wrong day - dt_wrong_day = datetime(2026, 6, 16, 9, 0, tzinfo=timezone.utc) # Tuesday + dt_wrong_day = datetime(2026, 6, 16, 9, 0, tzinfo=UTC) # Tuesday assert s._cron_matches("0 9 * * 1", dt_wrong_day) is False +@pytest.mark.asyncio +async def test_scheduler_run_procedure_uses_plan_helper(monkeypatch): + import agent_pm.planner as planner_module + from agent_pm.procedures import loader + from agent_pm.scheduler import ProcedureScheduler + + captured: dict[str, Any] = {} + + monkeypatch.setattr( + loader, + "load", + lambda: { + "weekly_progress_review": { + "name": "Weekly Progress Review", + "steps": [{"name": "check-status"}, {"name": "publish-digest"}], + } + }, + ) + + def fake_generate_plan_for_idea(idea): + captured["idea"] = idea.model_dump() + return {"plan_id": "plan-123"} + + monkeypatch.setattr(planner_module, "generate_plan_for_idea", fake_generate_plan_for_idea) + + await ProcedureScheduler()._run_procedure("weekly_progress_review") + + assert captured["idea"]["title"] == "Weekly Progress Review" + assert captured["idea"]["context"] == "Scheduled execution of procedure with 2 steps." + + # ── MCP server tests ────────────────────────────────────────────── + @pytest.mark.asyncio async def test_mcp_initialize(): from agent_pm.mcp_server import handle_request + resp = await handle_request({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}) assert resp["result"]["serverInfo"]["name"] == "agent-pm-mcp" @@ -238,6 +300,7 @@ async def test_mcp_initialize(): @pytest.mark.asyncio async def test_mcp_list_tools(): from agent_pm.mcp_server import handle_request + resp = await handle_request({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}) tool_names = [t["name"] for t in resp["result"]["tools"]] assert "agent_pm_run_procedure" in tool_names @@ -250,13 +313,51 @@ async def test_mcp_list_tools(): @pytest.mark.asyncio async def test_mcp_list_procedures_tool(): from agent_pm.mcp_server import handle_request - resp = await handle_request({ - "jsonrpc": "2.0", - "id": 3, - "method": "tools/call", - "params": {"name": "agent_pm_list_procedures", "arguments": {}}, - }) + + resp = await handle_request( + { + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": {"name": "agent_pm_list_procedures", "arguments": {}}, + } + ) text = resp["result"]["content"][0]["text"] import json + data = json.loads(text) assert "procedures" in data + + +@pytest.mark.asyncio +async def test_mcp_run_procedure_uses_plan_helper(monkeypatch): + import agent_pm.mcp_server as mcp_server + import agent_pm.planner as planner_module + from agent_pm.procedures import loader + + captured: dict[str, Any] = {} + monkeypatch.setattr(settings, "dry_run", False) + monkeypatch.setattr( + loader, + "load", + lambda: { + "deploy_readiness": { + "name": "Deploy Readiness", + "description": "Review deploy blockers.", + "steps": [], + } + }, + ) + + def fake_generate_plan_for_idea(idea): + captured["idea"] = idea.model_dump() + return {"plan_id": "plan-456"} + + monkeypatch.setattr(planner_module, "generate_plan_for_idea", fake_generate_plan_for_idea) + + result = await mcp_server._run_procedure("deploy_readiness", dry_run=True) + + assert result == {"procedure": "deploy_readiness", "plan_id": "plan-456", "dry_run": True} + assert captured["idea"]["title"] == "Deploy Readiness" + assert captured["idea"]["context"] == "Review deploy blockers." + assert settings.dry_run is False From ff1c05539ae93df1c198349ec4d41a9acbd063c1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 14 Jun 2026 17:57:35 +0000 Subject: [PATCH 03/17] Fix procedure scheduling and execution --- agent_pm/connectors/__init__.py | 2 +- agent_pm/connectors/sentry.py | 2 +- agent_pm/mcp_server.py | 18 +- agent_pm/planner.py | 3 + agent_pm/procedure_runner.py | 321 ++++++++++++++++++++++++++++++++ agent_pm/scheduler.py | 29 +-- agent_pm/settings.py | 27 ++- tests/test_connectors.py | 82 ++++++-- tests/test_planner.py | 3 +- 9 files changed, 436 insertions(+), 51 deletions(-) create mode 100644 agent_pm/procedure_runner.py diff --git a/agent_pm/connectors/__init__.py b/agent_pm/connectors/__init__.py index 8eff593..2b4bd98 100644 --- a/agent_pm/connectors/__init__.py +++ b/agent_pm/connectors/__init__.py @@ -22,4 +22,4 @@ "SentryConnector", "sentry_connector", "SlackConnector", -] \ No newline at end of file +] diff --git a/agent_pm/connectors/sentry.py b/agent_pm/connectors/sentry.py index 6a33635..06b069d 100644 --- a/agent_pm/connectors/sentry.py +++ b/agent_pm/connectors/sentry.py @@ -152,4 +152,4 @@ async def sync(self, *, since: datetime | None = None) -> list[dict[str, Any]]: sentry_connector = SentryConnector() -__all__ = ["SentryConnector", "sentry_connector"] \ No newline at end of file +__all__ = ["SentryConnector", "sentry_connector"] diff --git a/agent_pm/mcp_server.py b/agent_pm/mcp_server.py index e27ebb7..1302333 100644 --- a/agent_pm/mcp_server.py +++ b/agent_pm/mcp_server.py @@ -82,31 +82,17 @@ async def _run_procedure(name: str, dry_run: bool) -> dict[str, Any]: """Execute a procedure and return the result.""" - from agent_pm.models import Idea - from agent_pm.planner import generate_plan_for_idea + from agent_pm.procedure_runner import execute_procedure from agent_pm.procedures import loader - from agent_pm.settings import settings procedures = loader.load() if name not in procedures: return {"error": f"Procedure '{name}' not found", "available": list(procedures.keys())} - proc = procedures[name] try: - prev_dry = settings.dry_run - if dry_run: - settings.dry_run = True - idea = Idea( - title=proc.get("name", name), - context=proc.get("description", f"Execute procedure: {name}"), - ) - result = generate_plan_for_idea(idea) - return {"procedure": name, "plan_id": result.get("plan_id"), "dry_run": dry_run} + return await execute_procedure(name, dry_run=dry_run) except Exception as exc: return {"error": str(exc), "procedure": name} - finally: - if dry_run: - settings.dry_run = prev_dry async def _sentry_scan(query: str, stats_period: str, limit: int) -> dict[str, Any]: diff --git a/agent_pm/planner.py b/agent_pm/planner.py index 6bc03d6..7867814 100644 --- a/agent_pm/planner.py +++ b/agent_pm/planner.py @@ -3,6 +3,7 @@ import json import logging from collections import deque +from uuid import uuid4 from . import embeddings from .agent_sdk import ( @@ -383,6 +384,7 @@ def generate_plan( tools: list[dict[str, object]], enable_tools: bool, ) -> dict[str, str]: + plan_id = uuid4().hex constraint_list = constraints or [] user_prompt = build_user_prompt(title, context, constraint_list) agent_prompt = ( @@ -554,6 +556,7 @@ def _merge_list(default: list[str], candidate: list[str]) -> list[str]: } ) result = { + "plan_id": plan_id, "prd_markdown": prd, "raw_plan": text, "status_digest": digest, diff --git a/agent_pm/procedure_runner.py b/agent_pm/procedure_runner.py new file mode 100644 index 0000000..1838262 --- /dev/null +++ b/agent_pm/procedure_runner.py @@ -0,0 +1,321 @@ +"""Procedure execution engine for YAML-defined operational workflows.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import re +from contextlib import nullcontext +from datetime import datetime +from typing import Any +from uuid import uuid4 + +import httpx + +from agent_pm.clients.calendar_client import calendar_client +from agent_pm.clients.jira_client import jira_client +from agent_pm.clients.openai_client import openai_client +from agent_pm.clients.slack_client import slack_client +from agent_pm.connectors.linear import linear_connector +from agent_pm.connectors.sentry import sentry_connector +from agent_pm.models import Idea, JiraIssuePayload +from agent_pm.planner import generate_plan_for_idea +from agent_pm.procedures import loader +from agent_pm.settings import settings + +logger = logging.getLogger(__name__) + +_PLACEHOLDER_RE = re.compile(r"{{\s*([a-zA-Z0-9_]+)\s*}}") +_REPO_RE = re.compile(r"\b[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+\b") +_STATS_PERIOD_RE = re.compile(r"\b(\d+)([hdw])\b") +_LAST_HOURS_RE = re.compile(r"last\s+(\d+)\s+hours?", re.IGNORECASE) +_MODEL_STEP_SYSTEM_PROMPT = ( + "You are executing an operational procedure. Follow the instruction exactly, use the" + " provided step outputs as source material, and return concise markdown or plain text." +) + + +async def execute_procedure(name: str, *, dry_run: bool = False) -> dict[str, Any]: + """Run a named procedure definition and return an execution summary.""" + + procedures = loader.load() + if name not in procedures: + raise KeyError(name) + + proc = procedures[name] + title = proc.get("name", name) + plan_id = uuid4().hex + context: dict[str, Any] = { + "procedure": name, + "title": title, + "description": proc.get("description", ""), + "plan_id": plan_id, + } + + override = settings.override_dry_run(True) if dry_run else nullcontext() + with override: + steps = proc.get("steps") or [] + if not steps: + result = await _generate_plan_result(name, proc) + return {"procedure": name, "plan_id": result.get("plan_id", plan_id), "dry_run": dry_run} + + for step in steps: + step_id = step.get("id") or step.get("run") or f"step_{len(context)}" + result = await _execute_step(step, context) + context[step_id] = result + _store_step_aliases(step_id, result, context) + + return {"procedure": name, "plan_id": plan_id, "dry_run": dry_run} + + +async def _generate_plan_result(name: str, proc: dict[str, Any]) -> dict[str, Any]: + idea = Idea( + title=proc.get("name", name), + context=proc.get("description", f"Execute procedure: {name}"), + ) + return await asyncio.to_thread(generate_plan_for_idea, idea) + + +async def _execute_step(step: dict[str, Any], context: dict[str, Any]) -> Any: + items_expr = step.get("foreach") + if items_expr is None: + return await _execute_step_once(step, context) + + items = _render_value(items_expr, context) + if not isinstance(items, list): + raise TypeError( + f"Procedure step '{step.get('id', step.get('run', 'unknown'))}' expected foreach to resolve to a list" + ) + + results = [] + for item in items: + context["item"] = item + try: + results.append(await _execute_step_once(step, context)) + finally: + context.pop("item", None) + return results + + +async def _execute_step_once(step: dict[str, Any], context: dict[str, Any]) -> Any: + run_name = step.get("run") + if not run_name: + raise ValueError(f"Procedure step '{step.get('id', 'unknown')}' is missing a run target") + + if run_name == "sentry_scan": + return await _run_sentry_scan(_render_text(step.get("input", ""), context)) + if run_name == "linear_scan": + return await _run_linear_scan(_render_text(step.get("input", ""), context)) + if run_name == "github_pr_scan": + return await _run_github_pr_scan(_render_text(step.get("input", ""), context)) + if run_name == "model": + return await _run_model_step(_render_text(step.get("input", ""), context), context) + if run_name == "publish_status_digest": + params = _render_value(step.get("with", {}), context) + return await slack_client.post_digest(str(params.get("body_md", "")), params.get("channel")) + if run_name == "create_jira_issue": + params = _render_value(step.get("with", {}), context) + payload = JiraIssuePayload( + summary=str(params.get("summary", "")), + description=str(params.get("description", "")), + project_key=str(params.get("project_key", "")), + issue_type=str(params.get("issue_type", "Story")), + ) + return await jira_client.create_issue(payload.to_jira()) + if run_name == "schedule_review_event": + params = _render_value(step.get("with", {}), context) + attendees = params.get("attendees") or [] + if isinstance(attendees, str): + attendees = [email.strip() for email in attendees.split(",") if email.strip()] + start_time = _parse_datetime(str(params.get("start_time_iso", ""))) + return await calendar_client.schedule_review( + summary=str(params.get("summary", "")), + description=str(params.get("description", "")), + start_time=start_time, + duration_minutes=int(params.get("duration_minutes", 30)), + attendees=attendees, + ) + + raise ValueError(f"Unsupported procedure step run target: {run_name}") + + +async def _run_model_step(instruction: str, context: dict[str, Any]) -> str: + step_outputs = { + key: value + for key, value in context.items() + if key not in {"procedure", "title", "description", "plan_id"} and not key.startswith("_") + } + prompt = ( + f"Procedure: {context['title']}\n" + f"Description: {context['description']}\n\n" + f"Instruction:\n{instruction}\n\n" + "Available step outputs (JSON):\n" + f"{json.dumps(step_outputs, indent=2, default=str)}" + ) + return await asyncio.to_thread(openai_client.create_plan, _MODEL_STEP_SYSTEM_PROMPT, prompt, []) + + +async def _run_sentry_scan(instruction: str) -> dict[str, Any]: + stats_period = _extract_stats_period(instruction, default="14d") + query = "is:unresolved" if "is:unresolved" in instruction else "is:unresolved" + issues = await sentry_connector.list_issues(query=query, stats_period=stats_period, limit=10) + error_counts = await sentry_connector.error_counts(stats_period=stats_period) + return { + "issues": issues, + "count": len(issues), + "query": query, + "stats_period": stats_period, + "error_counts": error_counts, + } + + +async def _run_linear_scan(instruction: str) -> dict[str, Any]: + state = "In Progress" if "in progress" in instruction.lower() else None + issues = await linear_connector.list_issues(state=state, order_by="updatedAt", limit=50) + return {"issues": issues, "count": len(issues), "state": state} + + +async def _run_github_pr_scan(instruction: str) -> dict[str, Any]: + repos = ( + _extract_repositories(instruction) + or settings.github_repositories + or [ + "evalops/platform", + "evalops/deploy", + "evalops/maestro-internal", + ] + ) + author = "dependabot" if "dependabot" in instruction.lower() else None + hours = _extract_last_hours(instruction) + include_agent_authors = any( + token in instruction.lower() for token in ("bot", "agent", "codex", "cursor", "maestro", "claude") + ) + + if settings.dry_run or not settings.github_token or not repos: + return { + "dry_run": True, + "repositories": repos, + "author": author, + "last_hours": hours, + } + + headers = { + "Authorization": f"Bearer {settings.github_token}", + "Accept": "application/vnd.github+json", + } + pulls: list[dict[str, Any]] = [] + async with httpx.AsyncClient() as client: + for repo in repos: + response = await client.get( + f"https://api.github.com/repos/{repo}/pulls", + headers=headers, + params={"state": "open", "per_page": 20}, + timeout=30, + ) + response.raise_for_status() + for pr in response.json(): + if _include_pull_request( + pr, author=author, include_agent_authors=include_agent_authors, last_hours=hours + ): + pulls.append(pr) + + return {"prs": pulls, "count": len(pulls), "repositories": repos, "author": author, "last_hours": hours} + + +def _include_pull_request( + pr: dict[str, Any], + *, + author: str | None, + include_agent_authors: bool, + last_hours: int | None, +) -> bool: + login = str(pr.get("user", {}).get("login", "")).lower() + if author and author not in login: + return False + if include_agent_authors and not any( + token in login for token in ("bot", "agent", "codex", "cursor", "maestro", "claude") + ): + return False + if last_hours is None: + return True + + created_at = pr.get("created_at") + if not created_at: + return False + created = _parse_datetime(str(created_at)) + age = datetime.now(tz=created.tzinfo) - created + return age.total_seconds() <= last_hours * 3600 + + +def _parse_datetime(value: str) -> datetime: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def _extract_repositories(instruction: str) -> list[str]: + return list(dict.fromkeys(_REPO_RE.findall(instruction))) + + +def _extract_stats_period(instruction: str, *, default: str) -> str: + match = _STATS_PERIOD_RE.search(instruction) + if match: + return f"{match.group(1)}{match.group(2)}" + last_hours = _extract_last_hours(instruction) + if last_hours is not None: + return f"{last_hours}h" + return default + + +def _extract_last_hours(instruction: str) -> int | None: + match = _LAST_HOURS_RE.search(instruction) + if match: + return int(match.group(1)) + return None + + +def _store_step_aliases(step_id: str, result: Any, context: dict[str, Any]) -> None: + if not isinstance(result, str): + return + if "_" not in step_id: + return + + alias = step_id.split("_", 1)[1] + if alias and alias not in context: + context[alias] = result + if alias == "prd": + context.setdefault("prd_md", result) + + +def _render_text(value: str, context: dict[str, Any]) -> str: + rendered = _render_value(value, context) + return rendered if isinstance(rendered, str) else _stringify_value(rendered) + + +def _render_value(value: Any, context: dict[str, Any]) -> Any: + if isinstance(value, str): + match = _PLACEHOLDER_RE.fullmatch(value.strip()) + if match: + return context.get(match.group(1), "") + + def _replace(found: re.Match[str]) -> str: + return _stringify_value(context.get(found.group(1), "")) + + return _PLACEHOLDER_RE.sub(_replace, value) + if isinstance(value, list): + return [_render_value(item, context) for item in value] + if isinstance(value, dict): + return {key: _render_value(item, context) for key, item in value.items()} + return value + + +def _stringify_value(value: Any) -> str: + if value is None: + return "" + if isinstance(value, str): + return value + if isinstance(value, (int, float, bool)): + return str(value) + return json.dumps(value, default=str) + + +__all__ = ["execute_procedure"] diff --git a/agent_pm/scheduler.py b/agent_pm/scheduler.py index 7fe4c05..113e39e 100644 --- a/agent_pm/scheduler.py +++ b/agent_pm/scheduler.py @@ -61,6 +61,22 @@ def _match(field: str, value: int) -> bool: return True for alt in field.split(","): alt = alt.strip() + if "/" in alt: + base, step_s = alt.split("/", 1) + step = int(step_s) + if step <= 0: + continue + if base == "*": + if value % step == 0: + return True + continue + if "-" in base: + lo_s, hi_s = base.split("-", 1) + lo = int(lo_s) + hi = int(hi_s) + if lo <= value <= hi and (value - lo) % step == 0: + return True + continue if "-" in alt: lo_s, hi_s = alt.split("-", 1) if int(lo_s) <= value <= int(hi_s): @@ -84,6 +100,7 @@ def _match(field: str, value: int) -> bool: async def _run_procedure(self, name: str) -> None: """Execute a named procedure.""" + from agent_pm.procedure_runner import execute_procedure from agent_pm.procedures import loader procedures = loader.load() @@ -94,17 +111,7 @@ async def _run_procedure(self, name: str) -> None: logger.info("Running scheduled procedure: %s", name) try: - from agent_pm.models import Idea - from agent_pm.planner import generate_plan_for_idea - - proc = procedures[name] - title = proc.get("name", name) - steps = len(proc.get("steps", [])) - idea = Idea( - title=title, - context=f"Scheduled execution of procedure with {steps} steps.", - ) - result = generate_plan_for_idea(idea) + result = await execute_procedure(name) logger.info("Procedure '%s' completed (plan_id=%s)", name, result.get("plan_id")) except Exception: logger.exception("Procedure '%s' failed", name) diff --git a/agent_pm/settings.py b/agent_pm/settings.py index 0fbcf3d..30fa38a 100644 --- a/agent_pm/settings.py +++ b/agent_pm/settings.py @@ -1,5 +1,7 @@ """Application settings loaded from environment variables.""" +from contextlib import contextmanager +from contextvars import ContextVar from functools import lru_cache from pathlib import Path from typing import Literal @@ -7,6 +9,8 @@ from pydantic import Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict +_dry_run_override: ContextVar[bool | None] = ContextVar("agent_pm_dry_run_override", default=None) + class Settings(BaseSettings): model_config = SettingsConfigDict( @@ -40,6 +44,21 @@ class Settings(BaseSettings): procedure_dir: Path = Field(Path("./procedures"), alias="PROCEDURE_DIR") allowed_projects: list[str] = Field(default_factory=list, alias="ALLOWED_PROJECTS") + def __getattribute__(self, name: str): + if name == "dry_run": + override = _dry_run_override.get() + if override is not None: + return override + return super().__getattribute__(name) + + @contextmanager + def override_dry_run(self, value: bool): + token = _dry_run_override.set(value) + try: + yield + finally: + _dry_run_override.reset(token) + @field_validator("google_calendar_scopes", mode="before") @classmethod def _parse_google_scopes(cls, value): @@ -60,7 +79,12 @@ def _parse_csv_list(value: str | list[str] | None) -> list[str]: return value @field_validator( - "github_repositories", "linear_team_ids", "slack_sync_channels", "gmail_label_filter", "notion_database_ids", mode="before" + "github_repositories", + "linear_team_ids", + "slack_sync_channels", + "gmail_label_filter", + "notion_database_ids", + mode="before", ) @classmethod def _parse_string_lists(cls, value): @@ -136,7 +160,6 @@ def _parse_drive_scopes(cls, value): enable_opentelemetry: bool = Field(False, alias="ENABLE_OPENTELEMETRY") otel_service_name: str = Field("agent-pm", alias="OTEL_SERVICE_NAME") otel_exporter_endpoint: str | None = Field(None, alias="OTEL_EXPORTER_ENDPOINT") - github_repositories: list[str] = Field(default_factory=list, alias="GITHUB_REPOSITORIES") github_sync_interval_seconds: int = Field(900, alias="GITHUB_SYNC_INTERVAL_SECONDS") gmail_service_account_json: str | None = Field(None, alias="GMAIL_SERVICE_ACCOUNT_JSON") gmail_service_account_file: Path | None = Field(None, alias="GMAIL_SERVICE_ACCOUNT_FILE") diff --git a/tests/test_connectors.py b/tests/test_connectors.py index 2feea74..53186a1 100644 --- a/tests/test_connectors.py +++ b/tests/test_connectors.py @@ -254,10 +254,18 @@ def test_scheduler_cron_matching(): dt_wrong_day = datetime(2026, 6, 16, 9, 0, tzinfo=UTC) # Tuesday assert s._cron_matches("0 9 * * 1", dt_wrong_day) is False + # Match: every 4 hours + dt_every_four = datetime(2026, 6, 15, 8, 0, tzinfo=UTC) + assert s._cron_matches("0 */4 * * *", dt_every_four) is True + + # No match: not on a 4-hour boundary + dt_not_every_four = datetime(2026, 6, 15, 10, 0, tzinfo=UTC) + assert s._cron_matches("0 */4 * * *", dt_not_every_four) is False + @pytest.mark.asyncio -async def test_scheduler_run_procedure_uses_plan_helper(monkeypatch): - import agent_pm.planner as planner_module +async def test_scheduler_run_procedure_executes_yaml_steps(monkeypatch): + import agent_pm.procedure_runner as procedure_runner from agent_pm.procedures import loader from agent_pm.scheduler import ProcedureScheduler @@ -269,21 +277,37 @@ async def test_scheduler_run_procedure_uses_plan_helper(monkeypatch): lambda: { "weekly_progress_review": { "name": "Weekly Progress Review", - "steps": [{"name": "check-status"}, {"name": "publish-digest"}], + "steps": [ + { + "id": "sentry_check", + "run": "sentry_scan", + "input": "List unresolved Sentry issues (is:unresolved, sort=freq, 14d).", + } + ], } }, ) - def fake_generate_plan_for_idea(idea): - captured["idea"] = idea.model_dump() - return {"plan_id": "plan-123"} + async def fake_list_issues(*, query: str, sort: str = "freq", limit: int = 10, stats_period: str = "14d"): + captured["query"] = query + captured["stats_period"] = stats_period + captured["limit"] = limit + return [{"id": "issue-1"}] + + async def fake_error_counts(*, stats_period: str = "7d", project: str | None = None): + captured["error_counts_period"] = stats_period + captured["project"] = project + return {"data": []} - monkeypatch.setattr(planner_module, "generate_plan_for_idea", fake_generate_plan_for_idea) + monkeypatch.setattr(procedure_runner.sentry_connector, "list_issues", fake_list_issues) + monkeypatch.setattr(procedure_runner.sentry_connector, "error_counts", fake_error_counts) await ProcedureScheduler()._run_procedure("weekly_progress_review") - assert captured["idea"]["title"] == "Weekly Progress Review" - assert captured["idea"]["context"] == "Scheduled execution of procedure with 2 steps." + assert captured["query"] == "is:unresolved" + assert captured["stats_period"] == "14d" + assert captured["limit"] == 10 + assert captured["error_counts_period"] == "14d" # ── MCP server tests ────────────────────────────────────────────── @@ -330,9 +354,9 @@ async def test_mcp_list_procedures_tool(): @pytest.mark.asyncio -async def test_mcp_run_procedure_uses_plan_helper(monkeypatch): +async def test_mcp_run_procedure_executes_model_steps_without_mutating_global_dry_run(monkeypatch): import agent_pm.mcp_server as mcp_server - import agent_pm.planner as planner_module + import agent_pm.procedure_runner as procedure_runner from agent_pm.procedures import loader captured: dict[str, Any] = {} @@ -344,20 +368,40 @@ async def test_mcp_run_procedure_uses_plan_helper(monkeypatch): "deploy_readiness": { "name": "Deploy Readiness", "description": "Review deploy blockers.", - "steps": [], + "steps": [{"id": "compose_report", "run": "model", "input": "Compose a terse report."}], } }, ) - def fake_generate_plan_for_idea(idea): - captured["idea"] = idea.model_dump() - return {"plan_id": "plan-456"} + async def fake_to_thread(func, *args, **kwargs): + captured["dry_run_during_call"] = settings.dry_run + captured["function"] = func + captured["args"] = args + return "report body" - monkeypatch.setattr(planner_module, "generate_plan_for_idea", fake_generate_plan_for_idea) + monkeypatch.setattr(procedure_runner.asyncio, "to_thread", fake_to_thread) result = await mcp_server._run_procedure("deploy_readiness", dry_run=True) - assert result == {"procedure": "deploy_readiness", "plan_id": "plan-456", "dry_run": True} - assert captured["idea"]["title"] == "Deploy Readiness" - assert captured["idea"]["context"] == "Review deploy blockers." + assert result["procedure"] == "deploy_readiness" + assert result["dry_run"] is True + assert result["plan_id"] + assert captured["function"].__self__ is procedure_runner.openai_client + assert captured["function"].__name__ == "create_plan" + assert captured["dry_run_during_call"] is True + assert settings.dry_run is False + + +@pytest.mark.asyncio +async def test_settings_dry_run_override_is_task_local(monkeypatch): + monkeypatch.setattr(settings, "dry_run", False) + + async def observe(value: bool) -> bool: + with settings.override_dry_run(value): + await asyncio.sleep(0) + return settings.dry_run + + observed = await asyncio.gather(observe(True), observe(False)) + + assert observed == [True, False] assert settings.dry_run is False diff --git a/tests/test_planner.py b/tests/test_planner.py index e136f8f..1ac49f0 100644 --- a/tests/test_planner.py +++ b/tests/test_planner.py @@ -77,6 +77,7 @@ def test_generate_plan_produces_status_digest(monkeypatch): ) assert result["prd_markdown"].startswith("# PRD: Test Initiative") + assert result["plan_id"] assert "status_digest" in result assert "*Test Initiative*" in result["status_digest"] assert "stubbed plan" in result["raw_plan"] @@ -453,7 +454,7 @@ def test_goal_alignment_appends_note(monkeypatch, capture_alignment_events): monkeypatch.setattr( planner_module.embeddings, "generate_embedding_sync", - lambda text, model="text-embedding-3-small": ([1.0, 0.0] if "visibility" in text.lower() else [0.0, 1.0]), + lambda text, model="text-embedding-3-small": [1.0, 0.0] if "visibility" in text.lower() else [0.0, 1.0], ) monkeypatch.setattr(planner_module.embeddings, "cosine_similarity", lambda a, b: 0.95 if a == b else 0.1) From 32ac12c8177947b4977d93cf938fdff98bc87e66 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 14 Jun 2026 18:04:05 +0000 Subject: [PATCH 04/17] Fix procedure runner scan filters --- agent_pm/procedure_runner.py | 246 +++++++++++++++++++++-- procedures/weekly_progress_review.yaml | 10 +- tests/test_procedure_runner.py | 257 +++++++++++++++++++++++++ 3 files changed, 497 insertions(+), 16 deletions(-) create mode 100644 tests/test_procedure_runner.py diff --git a/agent_pm/procedure_runner.py b/agent_pm/procedure_runner.py index 1838262..e7669a6 100644 --- a/agent_pm/procedure_runner.py +++ b/agent_pm/procedure_runner.py @@ -7,7 +7,7 @@ import logging import re from contextlib import nullcontext -from datetime import datetime +from datetime import UTC, datetime, timedelta from typing import Any from uuid import uuid4 @@ -17,6 +17,7 @@ from agent_pm.clients.jira_client import jira_client from agent_pm.clients.openai_client import openai_client from agent_pm.clients.slack_client import slack_client +from agent_pm.connectors.calendar import CalendarConnector from agent_pm.connectors.linear import linear_connector from agent_pm.connectors.sentry import sentry_connector from agent_pm.models import Idea, JiraIssuePayload @@ -30,6 +31,13 @@ _REPO_RE = re.compile(r"\b[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+\b") _STATS_PERIOD_RE = re.compile(r"\b(\d+)([hdw])\b") _LAST_HOURS_RE = re.compile(r"last\s+(\d+)\s+hours?", re.IGNORECASE) +_NEXT_DAYS_RE = re.compile(r"next\s+(\d+)\s+days?", re.IGNORECASE) +_STALE_DAYS_RE = re.compile(r"last updated\s*>\s*(\d+)\s+days?\s+ago", re.IGNORECASE) +_EXPLICIT_PR_AUTHOR_RE = re.compile( + r"\b(?:authored|opened)\s+by\s+([a-zA-Z0-9_.-]+)\b|\bauthor\s*:\s*([a-zA-Z0-9_.-]+)\b", + re.IGNORECASE, +) +_KNOWN_AGENT_LOGINS = {"dependabot", "codex", "cursor", "maestro", "claude"} _MODEL_STEP_SYSTEM_PROMPT = ( "You are executing an operational procedure. Follow the instruction exactly, use the" " provided step outputs as source material, and return concise markdown or plain text." @@ -105,6 +113,8 @@ async def _execute_step_once(step: dict[str, Any], context: dict[str, Any]) -> A if run_name == "sentry_scan": return await _run_sentry_scan(_render_text(step.get("input", ""), context)) + if run_name == "calendar_scan": + return await _run_calendar_scan(_render_text(step.get("input", ""), context)) if run_name == "linear_scan": return await _run_linear_scan(_render_text(step.get("input", ""), context)) if run_name == "github_pr_scan": @@ -170,10 +180,46 @@ async def _run_sentry_scan(instruction: str) -> dict[str, Any]: } +async def _run_calendar_scan(instruction: str) -> dict[str, Any]: + window_days = _extract_calendar_window_days(instruction, default=7) + now = datetime.now(tz=UTC) + payloads = await CalendarConnector().sync(since=now) + + events: list[dict[str, Any]] + if payloads and isinstance(payloads[0], dict) and "items" in payloads[0]: + items = payloads[0].get("items") or [] + events = [event for event in items if _event_is_within_window(event, now, window_days)] + else: + events = payloads + + return { + "events": events, + "count": len(events), + "window_days": window_days, + "calendar_id": settings.calendar_id, + } + + async def _run_linear_scan(instruction: str) -> dict[str, Any]: - state = "In Progress" if "in progress" in instruction.lower() else None + state = _extract_linear_state(instruction) issues = await linear_connector.list_issues(state=state, order_by="updatedAt", limit=50) - return {"issues": issues, "count": len(issues), "state": state} + assignee_email = _extract_linear_assignee_email(instruction) + if assignee_email: + issues = [ + issue for issue in issues if str(issue.get("assignee", {}).get("email", "")).lower() == assignee_email + ] + + result: dict[str, Any] = { + "issues": issues, + "count": len(issues), + "state": state, + "assignee_email": assignee_email, + } + if _instruction_requests_linear_stale_scan(instruction): + stale_after_days = _extract_stale_days(instruction, default=2) + result["stale_after_days"] = stale_after_days + result["stale"] = await _collect_stale_linear_issues(issues, stale_after_days=stale_after_days) + return result async def _run_github_pr_scan(instruction: str) -> dict[str, Any]: @@ -186,18 +232,19 @@ async def _run_github_pr_scan(instruction: str) -> dict[str, Any]: "evalops/maestro-internal", ] ) - author = "dependabot" if "dependabot" in instruction.lower() else None + author = _extract_explicit_pr_author(instruction) hours = _extract_last_hours(instruction) - include_agent_authors = any( - token in instruction.lower() for token in ("bot", "agent", "codex", "cursor", "maestro", "claude") - ) + include_agent_authors = _instruction_requests_agent_authors(instruction) + fetch_diffs = _instruction_requests_pr_diffs(instruction) if settings.dry_run or not settings.github_token or not repos: return { "dry_run": True, "repositories": repos, "author": author, + "include_agent_authors": include_agent_authors, "last_hours": hours, + "fetch_diffs": fetch_diffs, } headers = { @@ -218,9 +265,77 @@ async def _run_github_pr_scan(instruction: str) -> dict[str, Any]: if _include_pull_request( pr, author=author, include_agent_authors=include_agent_authors, last_hours=hours ): - pulls.append(pr) + payload = dict(pr) + payload["repository"] = repo + if fetch_diffs: + payload["diff"] = await _fetch_pull_request_diff(client, repo, pr, headers) + pulls.append(payload) - return {"prs": pulls, "count": len(pulls), "repositories": repos, "author": author, "last_hours": hours} + return { + "prs": pulls, + "count": len(pulls), + "repositories": repos, + "author": author, + "include_agent_authors": include_agent_authors, + "last_hours": hours, + "fetch_diffs": fetch_diffs, + } + + +async def _collect_stale_linear_issues( + issues: list[dict[str, Any]], + *, + stale_after_days: int, +) -> list[dict[str, Any]]: + now = datetime.now(tz=UTC) + stale_items = [] + for issue in issues: + flags = [] + due = issue.get("dueDate") + updated_at = issue.get("updatedAt") + state_name = str(issue.get("state", {}).get("name", "")) + + if due and str(due) < now.date().isoformat(): + flags.append("past_due") + if updated_at: + updated_dt = _parse_datetime(str(updated_at)) + if (now - updated_dt) > timedelta(days=stale_after_days): + flags.append("stale") + if state_name == "In Progress": + comments = await linear_connector.get_issue_comments(str(issue.get("id", "")), limit=20) + if not _has_recent_comment(comments, now, stale_after_days): + flags.append("in_progress_no_recent_comments") + if flags: + stale_items.append( + { + "id": issue.get("id"), + "identifier": issue.get("identifier"), + "title": issue.get("title"), + "state": state_name, + "assignee": issue.get("assignee"), + "dueDate": due, + "updatedAt": updated_at, + "flags": flags, + "recommended_action": _recommend_linear_action(flags), + } + ) + return stale_items + + +async def _fetch_pull_request_diff( + client: httpx.AsyncClient, + repo: str, + pr: dict[str, Any], + headers: dict[str, str], +) -> str: + diff_url = str(pr.get("diff_url") or f"https://api.github.com/repos/{repo}/pulls/{pr.get('number')}") + response = await client.get( + diff_url, + headers={**headers, "Accept": "application/vnd.github.v3.diff"}, + timeout=30, + ) + response.raise_for_status() + return response.text def _include_pull_request( @@ -230,12 +345,11 @@ def _include_pull_request( include_agent_authors: bool, last_hours: int | None, ) -> bool: - login = str(pr.get("user", {}).get("login", "")).lower() - if author and author not in login: + login = str(pr.get("user", {}).get("login", "")) + normalized_login = _normalize_github_login(login) + if author and normalized_login != author: return False - if include_agent_authors and not any( - token in login for token in ("bot", "agent", "codex", "cursor", "maestro", "claude") - ): + if include_agent_authors and not _is_agent_login(normalized_login): return False if last_hours is None: return True @@ -273,6 +387,110 @@ def _extract_last_hours(instruction: str) -> int | None: return None +def _extract_calendar_window_days(instruction: str, *, default: int) -> int: + if "this week" in instruction.lower(): + return 7 + match = _NEXT_DAYS_RE.search(instruction) + if match: + return int(match.group(1)) + return default + + +def _event_is_within_window(event: dict[str, Any], now: datetime, window_days: int) -> bool: + start = event.get("start", {}) + start_value = str(start.get("dateTime") or start.get("date") or "") + if not start_value: + return False + if "T" in start_value: + start_at = _parse_datetime(start_value) + else: + start_at = datetime.fromisoformat(f"{start_value}T00:00:00+00:00") + return now <= start_at <= now + timedelta(days=window_days) + + +def _extract_linear_state(instruction: str) -> str | None: + lower_instruction = instruction.lower() + if re.search(r"\b(?:state|status)\s*(?:is|=|:)\s*\"?in progress\"?\b", lower_instruction): + return "In Progress" + if re.search(r"\b(?:list|scan|show|pull)\s+(?:all\s+)?in progress issues\b", lower_instruction): + return "In Progress" + if re.search(r"\b(?:only|just)\s+in progress\b", lower_instruction): + return "In Progress" + return None + + +def _extract_linear_assignee_email(instruction: str) -> str | None: + if "assigned to me" not in instruction.lower(): + return None + for email in (settings.jira_email, settings.google_calendar_delegated_user): + if email: + return email.lower() + return None + + +def _instruction_requests_linear_stale_scan(instruction: str) -> bool: + lower_instruction = instruction.lower() + return any( + token in lower_instruction + for token in ("stale", "due date", "last updated", "recent comments", "recommended actions") + ) + + +def _extract_stale_days(instruction: str, *, default: int) -> int: + match = _STALE_DAYS_RE.search(instruction) + if match: + return int(match.group(1)) + return default + + +def _has_recent_comment(comments: list[dict[str, Any]], now: datetime, stale_after_days: int) -> bool: + for comment in comments: + created_at = comment.get("createdAt") + if created_at and (now - _parse_datetime(str(created_at))) <= timedelta(days=stale_after_days): + return True + return False + + +def _recommend_linear_action(flags: list[str]) -> str: + if "past_due" in flags: + return "Update the due date or unblock the issue owner." + if "in_progress_no_recent_comments" in flags: + return "Request a status update or add a progress note." + return "Review the issue and decide whether to update or close it." + + +def _extract_explicit_pr_author(instruction: str) -> str | None: + match = _EXPLICIT_PR_AUTHOR_RE.search(instruction) + if not match: + return None + author = match.group(1) or match.group(2) + normalized = _normalize_github_login(author) + if normalized in {"bot", "bots", "agent", "agents"}: + return None + return normalized + + +def _instruction_requests_agent_authors(instruction: str) -> bool: + return bool( + re.search(r"\b(?:authored|opened)\s+by\s+(?:bots?|agents?)(?:\s+or\s+(?:bots?|agents?))?\b", instruction, re.I) + ) + + +def _instruction_requests_pr_diffs(instruction: str) -> bool: + return bool(re.search(r"\bfetch\b[^.\n]*\bdiffs?\b", instruction, re.I)) + + +def _normalize_github_login(login: str) -> str: + normalized = login.lower().strip() + if normalized.endswith("[bot]"): + normalized = normalized[:-5] + return normalized + + +def _is_agent_login(login: str) -> bool: + return login in _KNOWN_AGENT_LOGINS or login.endswith(("-bot", "_bot", "-agent", "_agent")) + + def _store_step_aliases(step_id: str, result: Any, context: dict[str, Any]) -> None: if not isinstance(result, str): return diff --git a/procedures/weekly_progress_review.yaml b/procedures/weekly_progress_review.yaml index 2852598..51c0cb2 100644 --- a/procedures/weekly_progress_review.yaml +++ b/procedures/weekly_progress_review.yaml @@ -2,6 +2,12 @@ name: weekly_progress_review description: "Multi-source progress sweep — Calendar → Sentry → Linear → GitHub → Slack digest." schedule: "0 9 * * 1" # Monday 9am steps: + - id: calendar_overview + run: calendar_scan + input: | + List upcoming calendar events for this week. + Flag reviews, launches, or conflicts that could change priority this week. + - id: sentry_check run: sentry_scan input: | @@ -26,8 +32,8 @@ steps: - id: compose_digest run: model input: | - Compose a Slack digest from sentry_check, linear_stale_scan, and github_pr_sweep. - Format as bulleted sections: Sentry, Linear, GitHub PRs. + Compose a Slack digest from calendar_overview, sentry_check, linear_stale_scan, and github_pr_sweep. + Format as bulleted sections: Calendar, Sentry, Linear, GitHub PRs. Keep it terse — no marketing language, no self-narration. Include concrete next actions (merge PR #X, close issue #Y). diff --git a/tests/test_procedure_runner.py b/tests/test_procedure_runner.py new file mode 100644 index 0000000..e13874e --- /dev/null +++ b/tests/test_procedure_runner.py @@ -0,0 +1,257 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import Any + +import pytest + +from agent_pm.procedures import loader +from agent_pm.settings import settings + + +class _FakeResponse: + def __init__(self, *, json_data: Any = None, text: str = "") -> None: + self._json_data = json_data + self.text = text + + def json(self) -> Any: + return self._json_data + + def raise_for_status(self) -> None: + return None + + +def _fake_github_client_factory( + *, + pulls: list[dict[str, Any]], + calls: list[dict[str, Any]], + diffs: dict[str, str] | None = None, +): + diff_payloads = diffs or {} + + class _FakeGitHubClient: + async def __aenter__(self) -> _FakeGitHubClient: + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + return None + + async def get( + self, + url: str, + *, + headers: dict[str, str] | None = None, + params: dict[str, Any] | None = None, + timeout: int | None = None, + ) -> _FakeResponse: + calls.append({"url": url, "headers": headers, "params": params, "timeout": timeout}) + if url.endswith("/pulls"): + return _FakeResponse(json_data=pulls) + if url in diff_payloads: + return _FakeResponse(text=diff_payloads[url]) + raise AssertionError(f"Unexpected URL: {url}") + + return _FakeGitHubClient + + +@pytest.mark.asyncio +async def test_github_pr_scan_does_not_narrow_dependabot_mentions(monkeypatch): + import agent_pm.procedure_runner as procedure_runner + + calls: list[dict[str, Any]] = [] + pulls = [ + { + "number": 1, + "title": "Regular cleanup", + "user": {"login": "alice"}, + "created_at": "2026-06-14T00:00:00Z", + "diff_url": "https://example.test/pr-1.diff", + }, + { + "number": 2, + "title": "Bump requests", + "user": {"login": "dependabot[bot]"}, + "created_at": "2026-06-14T01:00:00Z", + "diff_url": "https://example.test/pr-2.diff", + }, + ] + + monkeypatch.setattr(settings, "dry_run", False) + monkeypatch.setattr(settings, "github_token", "token") + monkeypatch.setattr(settings, "github_repositories", ["evalops/platform"]) + monkeypatch.setattr( + procedure_runner.httpx, + "AsyncClient", + _fake_github_client_factory(pulls=pulls, calls=calls), + ) + + result = await procedure_runner._run_github_pr_scan( + "Scan open PRs. Flag security bumps (dependabot with GHSA refs)." + ) + + assert result["author"] is None + assert result["include_agent_authors"] is False + assert {pr["number"] for pr in result["prs"]} == {1, 2} + assert calls[0]["url"].endswith("/pulls") + + +@pytest.mark.asyncio +async def test_github_pr_scan_fetches_diffs_for_agent_authors(monkeypatch): + import agent_pm.procedure_runner as procedure_runner + + now = datetime.now(tz=UTC) + recent = now.isoformat().replace("+00:00", "Z") + calls: list[dict[str, Any]] = [] + pulls = [ + { + "number": 1, + "title": "Security bump", + "user": {"login": "dependabot[bot]"}, + "created_at": recent, + "diff_url": "https://example.test/pr-1.diff", + }, + { + "number": 2, + "title": "Generated refactor", + "user": {"login": "codex"}, + "created_at": recent, + "diff_url": "https://example.test/pr-2.diff", + }, + { + "number": 3, + "title": "Manual change", + "user": {"login": "alice"}, + "created_at": recent, + "diff_url": "https://example.test/pr-3.diff", + }, + ] + diffs = { + "https://example.test/pr-1.diff": "diff --git a/a.py b/a.py\n+secret = 'nope'\n", + "https://example.test/pr-2.diff": "diff --git a/b.py b/b.py\n+print('hello')\n", + } + + monkeypatch.setattr(settings, "dry_run", False) + monkeypatch.setattr(settings, "github_token", "token") + monkeypatch.setattr(settings, "github_repositories", ["evalops/platform"]) + monkeypatch.setattr( + procedure_runner.httpx, + "AsyncClient", + _fake_github_client_factory(pulls=pulls, diffs=diffs, calls=calls), + ) + + result = await procedure_runner._run_github_pr_scan( + "List PRs opened in the last 24 hours. " + "Filter to PRs authored by bots or agents (dependabot, codex, cursor, maestro, claude). " + "For each PR, fetch the diff." + ) + + assert result["author"] is None + assert result["fetch_diffs"] is True + assert {pr["user"]["login"] for pr in result["prs"]} == {"dependabot[bot]", "codex"} + assert all(pr["diff"].startswith("diff --git") for pr in result["prs"]) + assert sum(1 for call in calls if call["headers"]["Accept"] == "application/vnd.github.v3.diff") == 2 + + +@pytest.mark.asyncio +async def test_linear_scan_respects_assignment_and_stale_rules(monkeypatch): + import agent_pm.procedure_runner as procedure_runner + + now = datetime.now(tz=UTC) + issues = [ + { + "id": "issue-1", + "identifier": "LIN-1", + "title": "Past due issue", + "state": {"name": "Todo"}, + "assignee": {"email": "me@example.com"}, + "dueDate": (now - timedelta(days=1)).date().isoformat(), + "updatedAt": (now - timedelta(days=3)).isoformat().replace("+00:00", "Z"), + }, + { + "id": "issue-2", + "identifier": "LIN-2", + "title": "Needs comment", + "state": {"name": "In Progress"}, + "assignee": {"email": "me@example.com"}, + "dueDate": None, + "updatedAt": (now - timedelta(days=1)).isoformat().replace("+00:00", "Z"), + }, + { + "id": "issue-3", + "identifier": "LIN-3", + "title": "Someone else's issue", + "state": {"name": "Todo"}, + "assignee": {"email": "other@example.com"}, + "dueDate": (now - timedelta(days=1)).date().isoformat(), + "updatedAt": (now - timedelta(days=4)).isoformat().replace("+00:00", "Z"), + }, + ] + captured: dict[str, Any] = {} + + async def fake_list_issues(*, state=None, order_by="updatedAt", limit=50): + captured["state"] = state + captured["order_by"] = order_by + captured["limit"] = limit + return issues + + async def fake_get_issue_comments(issue_id: str, limit: int = 20): + captured.setdefault("comment_ids", []).append(issue_id) + if issue_id == "issue-2": + return [] + return [{"createdAt": now.isoformat().replace("+00:00", "Z")}] + + monkeypatch.setattr(settings, "jira_email", "me@example.com") + monkeypatch.setattr(procedure_runner.linear_connector, "list_issues", fake_list_issues) + monkeypatch.setattr(procedure_runner.linear_connector, "get_issue_comments", fake_get_issue_comments) + + result = await procedure_runner._run_linear_scan( + "Pull all issues assigned to me, sorted by updatedAt ascending. " + 'Flag items: due date blown, last updated > 2 days ago, "In Progress" without recent comments. ' + "Return a list of stale items with IDs and recommended actions." + ) + + stale_by_id = {issue["identifier"]: issue for issue in result["stale"]} + assert captured["state"] is None + assert result["count"] == 2 + assert result["assignee_email"] == "me@example.com" + assert stale_by_id["LIN-1"]["flags"] == ["past_due", "stale"] + assert stale_by_id["LIN-2"]["flags"] == ["in_progress_no_recent_comments"] + assert captured["comment_ids"] == ["issue-2"] + + +@pytest.mark.asyncio +async def test_weekly_progress_review_uses_calendar_scan(monkeypatch): + import agent_pm.procedure_runner as procedure_runner + + now = datetime.now(tz=UTC) + captured: dict[str, Any] = {} + + class _FakeCalendarConnector: + async def sync(self, *, since=None): + captured["since"] = since + return [ + { + "items": [ + { + "id": "evt-1", + "summary": "Weekly review", + "start": {"dateTime": (now + timedelta(days=1)).isoformat()}, + }, + { + "id": "evt-2", + "summary": "Later event", + "start": {"dateTime": (now + timedelta(days=10)).isoformat()}, + }, + ] + } + ] + + monkeypatch.setattr(procedure_runner, "CalendarConnector", lambda: _FakeCalendarConnector()) + + result = await procedure_runner._run_calendar_scan("List upcoming calendar events for this week.") + procedure = loader.load()["weekly_progress_review"] + + assert captured["since"] is not None + assert result["count"] == 1 + assert procedure["steps"][0]["run"] == "calendar_scan" + assert "calendar_overview" in procedure["steps"][4]["input"] From b0b3810b642ae69c7054908043fde4018d67110a Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Sun, 14 Jun 2026 18:27:04 +0000 Subject: [PATCH 05/17] fix: address remaining Cursor Bugbot review issues - Fix dry_run not propagating to worker threads: check settings.dry_run before calling asyncio.to_thread in _run_model_step and _generate_plan_result - Fix calendar dry_run inflating event count to 1 instead of 0 - Fix scheduler drift: align sleep to minute boundary instead of fixed 60-second delay after each tick - Update test_mcp_run_procedure to reflect dry_run short-circuit behavior --- agent_pm/procedure_runner.py | 18 ++++++++++----- agent_pm/scheduler.py | 8 +++++-- tests/test_connectors.py | 45 ++++++++++++++++++++++++++++++++---- 3 files changed, 59 insertions(+), 12 deletions(-) diff --git a/agent_pm/procedure_runner.py b/agent_pm/procedure_runner.py index e7669a6..e25e255 100644 --- a/agent_pm/procedure_runner.py +++ b/agent_pm/procedure_runner.py @@ -82,6 +82,8 @@ async def _generate_plan_result(name: str, proc: dict[str, Any]) -> dict[str, An title=proc.get("name", name), context=proc.get("description", f"Execute procedure: {name}"), ) + if settings.dry_run: + return {"plan_id": uuid4().hex, "dry_run": True, "title": idea.title} return await asyncio.to_thread(generate_plan_for_idea, idea) @@ -163,6 +165,8 @@ async def _run_model_step(instruction: str, context: dict[str, Any]) -> str: "Available step outputs (JSON):\n" f"{json.dumps(step_outputs, indent=2, default=str)}" ) + if settings.dry_run: + return f"[dry_run] Would call model with prompt: {instruction[:200]}..." return await asyncio.to_thread(openai_client.create_plan, _MODEL_STEP_SYSTEM_PROMPT, prompt, []) @@ -185,12 +189,14 @@ async def _run_calendar_scan(instruction: str) -> dict[str, Any]: now = datetime.now(tz=UTC) payloads = await CalendarConnector().sync(since=now) - events: list[dict[str, Any]] - if payloads and isinstance(payloads[0], dict) and "items" in payloads[0]: - items = payloads[0].get("items") or [] - events = [event for event in items if _event_is_within_window(event, now, window_days)] - else: - events = payloads + events: list[dict[str, Any]] = [] + if payloads and isinstance(payloads[0], dict): + first = payloads[0] + if first.get("dry_run") is True: + events = [] + elif "items" in first: + items = first.get("items") or [] + events = [event for event in items if _event_is_within_window(event, now, window_days)] return { "events": events, diff --git a/agent_pm/scheduler.py b/agent_pm/scheduler.py index 113e39e..9e7a07a 100644 --- a/agent_pm/scheduler.py +++ b/agent_pm/scheduler.py @@ -130,13 +130,17 @@ async def _tick(self) -> None: await self._run_procedure(name) async def _loop(self) -> None: - """Main scheduler loop — checks every 60 seconds.""" + """Main scheduler loop — wakes at the top of each minute.""" while self._running: + now = datetime.now(tz=UTC) + seconds_to_next_minute = 60 - now.second try: + await asyncio.sleep(seconds_to_next_minute) + if not self._running: + break await self._tick() except Exception: logger.exception("Scheduler tick error") - await asyncio.sleep(60) async def start(self) -> None: self.load() diff --git a/tests/test_connectors.py b/tests/test_connectors.py index 53186a1..1896efc 100644 --- a/tests/test_connectors.py +++ b/tests/test_connectors.py @@ -354,7 +354,7 @@ async def test_mcp_list_procedures_tool(): @pytest.mark.asyncio -async def test_mcp_run_procedure_executes_model_steps_without_mutating_global_dry_run(monkeypatch): +async def test_mcp_run_procedure_dry_run_short_circuits_model_steps(monkeypatch): import agent_pm.mcp_server as mcp_server import agent_pm.procedure_runner as procedure_runner from agent_pm.procedures import loader @@ -374,9 +374,8 @@ async def test_mcp_run_procedure_executes_model_steps_without_mutating_global_dr ) async def fake_to_thread(func, *args, **kwargs): - captured["dry_run_during_call"] = settings.dry_run + captured["called"] = True captured["function"] = func - captured["args"] = args return "report body" monkeypatch.setattr(procedure_runner.asyncio, "to_thread", fake_to_thread) @@ -386,9 +385,47 @@ async def fake_to_thread(func, *args, **kwargs): assert result["procedure"] == "deploy_readiness" assert result["dry_run"] is True assert result["plan_id"] + # dry_run=True should short-circuit before calling to_thread + assert "called" not in captured + assert settings.dry_run is False + + +@pytest.mark.asyncio +async def test_mcp_run_procedure_live_executes_model_steps_in_thread(monkeypatch): + import agent_pm.mcp_server as mcp_server + import agent_pm.procedure_runner as procedure_runner + from agent_pm.procedures import loader + + captured: dict[str, Any] = {} + monkeypatch.setattr(settings, "dry_run", False) + monkeypatch.setattr( + loader, + "load", + lambda: { + "deploy_readiness": { + "name": "Deploy Readiness", + "description": "Review deploy blockers.", + "steps": [{"id": "compose_report", "run": "model", "input": "Compose a terse report."}], + } + }, + ) + + async def fake_to_thread(func, *args, **kwargs): + captured["dry_run_during_call"] = settings.dry_run + captured["function"] = func + captured["args"] = args + return "report body" + + monkeypatch.setattr(procedure_runner.asyncio, "to_thread", fake_to_thread) + + result = await mcp_server._run_procedure("deploy_readiness", dry_run=False) + + assert result["procedure"] == "deploy_readiness" + assert result["dry_run"] is False + assert result["plan_id"] assert captured["function"].__self__ is procedure_runner.openai_client assert captured["function"].__name__ == "create_plan" - assert captured["dry_run_during_call"] is True + assert captured["dry_run_during_call"] is False assert settings.dry_run is False From 09a8754d0ab21d93c96fbb5557e8b656ce61a78b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 14 Jun 2026 18:34:03 +0000 Subject: [PATCH 06/17] Fix Linear and MCP scan scoping --- agent_pm/connectors/linear.py | 54 +++++++++++++---- agent_pm/mcp_server.py | 16 ++++- agent_pm/procedure_runner.py | 49 +++++++++++++-- tests/test_connectors.py | 108 ++++++++++++++++++++++++++++++++- tests/test_procedure_runner.py | 38 +++++++++++- 5 files changed, 240 insertions(+), 25 deletions(-) diff --git a/agent_pm/connectors/linear.py b/agent_pm/connectors/linear.py index 9a13935..ae9d78b 100644 --- a/agent_pm/connectors/linear.py +++ b/agent_pm/connectors/linear.py @@ -52,13 +52,16 @@ async def list_issues( self, *, assignee_id: str | None = None, + assignee_email: str | None = None, team_id: str | None = None, state: str | None = None, order_by: str = "updatedAt", - limit: int = 50, + limit: int | None = 50, ) -> list[dict[str, Any]]: filters: dict[str, Any] = {} - if assignee_id: + if assignee_email: + filters["assignee"] = {"email": {"eq": assignee_email}} + elif assignee_id: filters["assignee"] = {"id": {"eq": assignee_id}} if team_id: filters["team"] = {"id": {"eq": team_id}} @@ -66,8 +69,8 @@ async def list_issues( filters["state"] = {"name": {"eq": state}} query = """ - query($filter: IssueFilter, $orderBy: PaginationOrderBy, $first: Int!) { - issues(filter: $filter, orderBy: $orderBy, first: $first) { + query($filter: IssueFilter, $orderBy: PaginationOrderBy, $first: Int!, $after: String) { + issues(filter: $filter, orderBy: $orderBy, first: $first, after: $after) { nodes { id identifier title description state { name } priority assignee { id name email } team { id name key } @@ -75,18 +78,43 @@ async def list_issues( labels { nodes { name } } parent { id identifier } } + pageInfo { hasNextPage endCursor } } } """ - result = await self._graphql( - query, - { - "filter": filters if filters else None, - "orderBy": order_by, - "first": limit, - }, - ) - return result.get("issues", {}).get("nodes", []) + issues: list[dict[str, Any]] = [] + after: str | None = None + remaining = limit + page_size = 50 if limit is None else limit + + while page_size > 0: + result = await self._graphql( + query, + { + "filter": filters if filters else None, + "orderBy": order_by, + "first": page_size, + "after": after, + }, + ) + connection = result.get("issues", {}) + nodes = connection.get("nodes", []) + issues.extend(nodes) + + if remaining is not None: + remaining -= len(nodes) + if remaining <= 0: + break + page_size = remaining + + page_info = connection.get("pageInfo", {}) + if not page_info.get("hasNextPage"): + break + after = page_info.get("endCursor") + if not after: + break + + return issues async def list_teams(self) -> list[dict[str, Any]]: query = """ diff --git a/agent_pm/mcp_server.py b/agent_pm/mcp_server.py index 1302333..7850d9d 100644 --- a/agent_pm/mcp_server.py +++ b/agent_pm/mcp_server.py @@ -115,7 +115,12 @@ async def _linear_scan(action: str, team_id: str | None, state: str | None, limi teams = await linear_connector.list_teams() return {"teams": teams} elif action == "stale_sweep": - issues = await linear_connector.list_issues(order_by="updatedAt", limit=limit) + issues = await linear_connector.list_issues( + team_id=team_id, + state=state, + order_by="updatedAt", + limit=limit, + ) # Flag stale items from datetime import UTC, datetime, timedelta @@ -155,9 +160,14 @@ async def _github_pr_scan(org: str, author: str | None, state: str, limit: int) "Authorization": f"Bearer {settings.github_token}", "Accept": "application/vnd.github+json", } + repos = settings.github_repositories params: dict[str, Any] = {"per_page": limit, "state": state} - query_parts = [f"org:{org}", "is:pr", f"state:{state}"] + query_parts = ["is:pr", f"state:{state}"] if author: + if repos: + query_parts.extend(f"repo:{repo}" for repo in repos) + else: + query_parts.append(f"org:{org}") query_parts.append(f"author:{author}") params["q"] = " ".join(query_parts) async with httpx.AsyncClient() as client: @@ -172,7 +182,7 @@ async def _github_pr_scan(org: str, author: str | None, state: str, limit: int) return {"prs": data.get("items", []), "total": data.get("total_count", 0)} else: # Use connector for org repos - repos = settings.github_repositories or ["evalops/platform", "evalops/deploy", "evalops/maestro-internal"] + repos = repos or ["evalops/platform", "evalops/deploy", "evalops/maestro-internal"] all_prs = [] for repo in repos: async with httpx.AsyncClient() as client: diff --git a/agent_pm/procedure_runner.py b/agent_pm/procedure_runner.py index e25e255..5890151 100644 --- a/agent_pm/procedure_runner.py +++ b/agent_pm/procedure_runner.py @@ -187,7 +187,8 @@ async def _run_sentry_scan(instruction: str) -> dict[str, Any]: async def _run_calendar_scan(instruction: str) -> dict[str, Any]: window_days = _extract_calendar_window_days(instruction, default=7) now = datetime.now(tz=UTC) - payloads = await CalendarConnector().sync(since=now) + window_start = _calendar_window_start(instruction, now) + payloads = await CalendarConnector().sync(since=window_start) events: list[dict[str, Any]] = [] if payloads and isinstance(payloads[0], dict): @@ -196,7 +197,7 @@ async def _run_calendar_scan(instruction: str) -> dict[str, Any]: events = [] elif "items" in first: items = first.get("items") or [] - events = [event for event in items if _event_is_within_window(event, now, window_days)] + events = [event for event in items if _event_is_within_window(event, window_start, window_days)] return { "events": events, @@ -208,12 +209,34 @@ async def _run_calendar_scan(instruction: str) -> dict[str, Any]: async def _run_linear_scan(instruction: str) -> dict[str, Any]: state = _extract_linear_state(instruction) - issues = await linear_connector.list_issues(state=state, order_by="updatedAt", limit=50) assignee_email = _extract_linear_assignee_email(instruction) + stale_scan = _instruction_requests_linear_stale_scan(instruction) + oldest_updates_first = _instruction_requests_oldest_linear_updates(instruction) or stale_scan + if _instruction_requests_assigned_to_me(instruction) and not assignee_email: + result: dict[str, Any] = { + "issues": [], + "count": 0, + "state": state, + "assignee_email": None, + "error": "Linear 'assigned to me' scans require JIRA_EMAIL or GOOGLE_CALENDAR_DELEGATED_USER.", + } + if stale_scan: + result["stale_after_days"] = _extract_stale_days(instruction, default=2) + result["stale"] = [] + return result + + issues = await linear_connector.list_issues( + assignee_email=assignee_email, + state=state, + order_by="updatedAt", + limit=None if oldest_updates_first else 50, + ) if assignee_email: issues = [ issue for issue in issues if str(issue.get("assignee", {}).get("email", "")).lower() == assignee_email ] + if oldest_updates_first: + issues = sorted(issues, key=lambda issue: str(issue.get("updatedAt") or "")) result: dict[str, Any] = { "issues": issues, @@ -221,7 +244,7 @@ async def _run_linear_scan(instruction: str) -> dict[str, Any]: "state": state, "assignee_email": assignee_email, } - if _instruction_requests_linear_stale_scan(instruction): + if stale_scan: stale_after_days = _extract_stale_days(instruction, default=2) result["stale_after_days"] = stale_after_days result["stale"] = await _collect_stale_linear_issues(issues, stale_after_days=stale_after_days) @@ -402,6 +425,12 @@ def _extract_calendar_window_days(instruction: str, *, default: int) -> int: return default +def _calendar_window_start(instruction: str, now: datetime) -> datetime: + if "this week" in instruction.lower(): + return now.replace(hour=0, minute=0, second=0, microsecond=0) + return now + + def _event_is_within_window(event: dict[str, Any], now: datetime, window_days: int) -> bool: start = event.get("start", {}) start_value = str(start.get("dateTime") or start.get("date") or "") @@ -426,7 +455,7 @@ def _extract_linear_state(instruction: str) -> str | None: def _extract_linear_assignee_email(instruction: str) -> str | None: - if "assigned to me" not in instruction.lower(): + if not _instruction_requests_assigned_to_me(instruction): return None for email in (settings.jira_email, settings.google_calendar_delegated_user): if email: @@ -434,6 +463,16 @@ def _extract_linear_assignee_email(instruction: str) -> str | None: return None +def _instruction_requests_assigned_to_me(instruction: str) -> bool: + return "assigned to me" in instruction.lower() + + +def _instruction_requests_oldest_linear_updates(instruction: str) -> bool: + lower_instruction = instruction.lower() + compact_instruction = re.sub(r"\s+", "", lower_instruction) + return "updatedatascending" in compact_instruction or "oldest update" in lower_instruction + + def _instruction_requests_linear_stale_scan(instruction: str) -> bool: lower_instruction = instruction.lower() return any( diff --git a/tests/test_connectors.py b/tests/test_connectors.py index 1896efc..78038b5 100644 --- a/tests/test_connectors.py +++ b/tests/test_connectors.py @@ -188,11 +188,47 @@ async def fake_graphql(query: str, variables: dict[str, Any] | None = None) -> d await connector.list_issues(team_id="team-123", state="In Progress", limit=10) assert "stateFilter" not in captured["query"] - assert "issues(filter: $filter, orderBy: $orderBy, first: $first)" in captured["query"] + assert "issues(filter: $filter, orderBy: $orderBy, first: $first, after: $after)" in captured["query"] assert captured["variables"]["filter"] == { "team": {"id": {"eq": "team-123"}}, "state": {"name": {"eq": "In Progress"}}, } + assert captured["variables"]["after"] is None + + +@pytest.mark.asyncio +async def test_linear_connector_uses_assignee_email_filter_and_paginates(monkeypatch): + monkeypatch.setattr(settings, "linear_api_key", "lin-api-test") + monkeypatch.setattr(settings, "dry_run", False) + connector = LinearConnector() + calls: list[dict[str, Any]] = [] + + async def fake_graphql(query: str, variables: dict[str, Any] | None = None) -> dict[str, Any]: + payload = variables or {} + calls.append(payload) + if len(calls) == 1: + return { + "issues": { + "nodes": [{"id": "issue-1"}], + "pageInfo": {"hasNextPage": True, "endCursor": "cursor-1"}, + } + } + return { + "issues": { + "nodes": [{"id": "issue-2"}], + "pageInfo": {"hasNextPage": False, "endCursor": None}, + } + } + + monkeypatch.setattr(connector, "_graphql", fake_graphql) + + issues = await connector.list_issues(assignee_email="me@example.com", limit=None) + + assert [issue["id"] for issue in issues] == ["issue-1", "issue-2"] + assert calls[0]["filter"] == {"assignee": {"email": {"eq": "me@example.com"}}} + assert calls[0]["first"] == 50 + assert calls[0]["after"] is None + assert calls[1]["after"] == "cursor-1" # ── Sentry connector tests ──────────────────────────────────────── @@ -353,6 +389,76 @@ async def test_mcp_list_procedures_tool(): assert "procedures" in data +@pytest.mark.asyncio +async def test_mcp_linear_stale_sweep_respects_team_and_state(monkeypatch): + import agent_pm.mcp_server as mcp_server + + captured: dict[str, Any] = {} + + async def fake_list_issues(*, team_id=None, state=None, order_by="updatedAt", limit=50): + captured["team_id"] = team_id + captured["state"] = state + captured["order_by"] = order_by + captured["limit"] = limit + return [] + + from agent_pm.connectors.linear import linear_connector + + monkeypatch.setattr(linear_connector, "list_issues", fake_list_issues) + + result = await mcp_server._linear_scan("stale_sweep", "team-123", "In Progress", 25) + + assert result == {"total": 0, "stale": []} + assert captured == { + "team_id": "team-123", + "state": "In Progress", + "order_by": "updatedAt", + "limit": 25, + } + + +@pytest.mark.asyncio +async def test_mcp_github_pr_scan_author_uses_configured_repos(monkeypatch): + import httpx + + import agent_pm.mcp_server as mcp_server + + calls: list[dict[str, Any]] = [] + + class _FakeResponse: + def __init__(self, *, payload: dict[str, Any]) -> None: + self._payload = payload + + def json(self) -> dict[str, Any]: + return self._payload + + def raise_for_status(self) -> None: + return None + + class _FakeGitHubClient: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def get(self, url: str, *, headers=None, params=None, timeout=None): + calls.append({"url": url, "headers": headers, "params": params, "timeout": timeout}) + return _FakeResponse(payload={"items": [], "total_count": 0}) + + monkeypatch.setattr(settings, "github_token", "token") + monkeypatch.setattr(settings, "github_repositories", ["evalops/platform", "haasonsaas/homelab"]) + monkeypatch.setattr(httpx, "AsyncClient", lambda: _FakeGitHubClient()) + + result = await mcp_server._github_pr_scan("evalops", "dependabot", "open", 20) + + assert result == {"prs": [], "total": 0} + assert calls[0]["url"] == "https://api.github.com/search/issues" + assert "repo:evalops/platform" in calls[0]["params"]["q"] + assert "repo:haasonsaas/homelab" in calls[0]["params"]["q"] + assert "org:evalops" not in calls[0]["params"]["q"] + + @pytest.mark.asyncio async def test_mcp_run_procedure_dry_run_short_circuits_model_steps(monkeypatch): import agent_pm.mcp_server as mcp_server diff --git a/tests/test_procedure_runner.py b/tests/test_procedure_runner.py index e13874e..032ffc4 100644 --- a/tests/test_procedure_runner.py +++ b/tests/test_procedure_runner.py @@ -188,7 +188,8 @@ async def test_linear_scan_respects_assignment_and_stale_rules(monkeypatch): ] captured: dict[str, Any] = {} - async def fake_list_issues(*, state=None, order_by="updatedAt", limit=50): + async def fake_list_issues(*, assignee_email=None, state=None, order_by="updatedAt", limit=50): + captured["assignee_email"] = assignee_email captured["state"] = state captured["order_by"] = order_by captured["limit"] = limit @@ -211,7 +212,9 @@ async def fake_get_issue_comments(issue_id: str, limit: int = 20): ) stale_by_id = {issue["identifier"]: issue for issue in result["stale"]} + assert captured["assignee_email"] == "me@example.com" assert captured["state"] is None + assert captured["limit"] is None assert result["count"] == 2 assert result["assignee_email"] == "me@example.com" assert stale_by_id["LIN-1"]["flags"] == ["past_due", "stale"] @@ -232,6 +235,11 @@ async def sync(self, *, since=None): return [ { "items": [ + { + "id": "evt-0", + "summary": "Earlier today", + "start": {"dateTime": (now - timedelta(hours=1)).isoformat()}, + }, { "id": "evt-1", "summary": "Weekly review", @@ -251,7 +259,31 @@ async def sync(self, *, since=None): result = await procedure_runner._run_calendar_scan("List upcoming calendar events for this week.") procedure = loader.load()["weekly_progress_review"] - assert captured["since"] is not None - assert result["count"] == 1 + assert captured["since"] == now.replace(hour=0, minute=0, second=0, microsecond=0) + assert result["count"] == 2 assert procedure["steps"][0]["run"] == "calendar_scan" assert "calendar_overview" in procedure["steps"][4]["input"] + + +@pytest.mark.asyncio +async def test_linear_scan_requires_configured_email_for_assigned_to_me(monkeypatch): + import agent_pm.procedure_runner as procedure_runner + + captured: dict[str, Any] = {} + + async def fake_list_issues(**kwargs): + captured["called"] = True + return [] + + monkeypatch.setattr(settings, "jira_email", None) + monkeypatch.setattr(settings, "google_calendar_delegated_user", None) + monkeypatch.setattr(procedure_runner.linear_connector, "list_issues", fake_list_issues) + + result = await procedure_runner._run_linear_scan( + "Pull all issues assigned to me, sorted by updatedAt ascending. Return stale items with recommended actions." + ) + + assert "called" not in captured + assert result["issues"] == [] + assert result["stale"] == [] + assert result["error"] == "Linear 'assigned to me' scans require JIRA_EMAIL or GOOGLE_CALENDAR_DELEGATED_USER." From 9e67ec17fb876c99b1166b054fee4bd32d149ce1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 14 Jun 2026 18:39:35 +0000 Subject: [PATCH 07/17] Fix GitHub and Linear scan regressions --- agent_pm/mcp_server.py | 7 ++-- agent_pm/procedure_runner.py | 7 +++- tests/test_connectors.py | 68 ++++++++++++++++++++++++++++++++-- tests/test_procedure_runner.py | 41 ++++++++++++++++++++ 4 files changed, 115 insertions(+), 8 deletions(-) diff --git a/agent_pm/mcp_server.py b/agent_pm/mcp_server.py index 7850d9d..3c4720c 100644 --- a/agent_pm/mcp_server.py +++ b/agent_pm/mcp_server.py @@ -119,8 +119,9 @@ async def _linear_scan(action: str, team_id: str | None, state: str | None, limi team_id=team_id, state=state, order_by="updatedAt", - limit=limit, + limit=None, ) + issues = sorted(issues, key=lambda issue: str(issue.get("updatedAt") or "")) # Flag stale items from datetime import UTC, datetime, timedelta @@ -192,8 +193,8 @@ async def _github_pr_scan(org: str, author: str | None, state: str, limit: int) params={"state": state, "per_page": limit}, timeout=30, ) - if resp.status_code == 200: - all_prs.extend(resp.json()) + resp.raise_for_status() + all_prs.extend(resp.json()) return {"prs": all_prs, "total": len(all_prs)} except Exception as exc: return {"error": str(exc)} diff --git a/agent_pm/procedure_runner.py b/agent_pm/procedure_runner.py index 5890151..41cfbb0 100644 --- a/agent_pm/procedure_runner.py +++ b/agent_pm/procedure_runner.py @@ -357,7 +357,12 @@ async def _fetch_pull_request_diff( pr: dict[str, Any], headers: dict[str, str], ) -> str: - diff_url = str(pr.get("diff_url") or f"https://api.github.com/repos/{repo}/pulls/{pr.get('number')}") + api_diff_url = str(pr.get("url") or f"https://api.github.com/repos/{repo}/pulls/{pr.get('number')}") + diff_url = str(pr.get("diff_url") or "") + if not diff_url or diff_url.startswith( + ("https://github.com/", "http://github.com/", "https://www.github.com/", "http://www.github.com/") + ): + diff_url = api_diff_url response = await client.get( diff_url, headers={**headers, "Accept": "application/vnd.github.v3.diff"}, diff --git a/tests/test_connectors.py b/tests/test_connectors.py index 78038b5..8113766 100644 --- a/tests/test_connectors.py +++ b/tests/test_connectors.py @@ -1,5 +1,5 @@ import asyncio -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from typing import Any import pytest @@ -394,13 +394,30 @@ async def test_mcp_linear_stale_sweep_respects_team_and_state(monkeypatch): import agent_pm.mcp_server as mcp_server captured: dict[str, Any] = {} + now = datetime.now(tz=UTC) + issues = [ + { + "id": "issue-newer", + "identifier": "LIN-2", + "title": "Newer stale issue", + "updatedAt": (now - timedelta(days=3)).isoformat().replace("+00:00", "Z"), + "dueDate": None, + }, + { + "id": "issue-older", + "identifier": "LIN-1", + "title": "Older stale issue", + "updatedAt": (now - timedelta(days=5)).isoformat().replace("+00:00", "Z"), + "dueDate": None, + }, + ] async def fake_list_issues(*, team_id=None, state=None, order_by="updatedAt", limit=50): captured["team_id"] = team_id captured["state"] = state captured["order_by"] = order_by captured["limit"] = limit - return [] + return issues from agent_pm.connectors.linear import linear_connector @@ -408,12 +425,13 @@ async def fake_list_issues(*, team_id=None, state=None, order_by="updatedAt", li result = await mcp_server._linear_scan("stale_sweep", "team-123", "In Progress", 25) - assert result == {"total": 0, "stale": []} + assert result["total"] == 2 + assert [issue["identifier"] for issue in result["stale"]] == ["LIN-1", "LIN-2"] assert captured == { "team_id": "team-123", "state": "In Progress", "order_by": "updatedAt", - "limit": 25, + "limit": None, } @@ -459,6 +477,48 @@ async def get(self, url: str, *, headers=None, params=None, timeout=None): assert "org:evalops" not in calls[0]["params"]["q"] +@pytest.mark.asyncio +async def test_mcp_github_pr_scan_without_author_surfaces_repo_errors(monkeypatch): + import httpx + + import agent_pm.mcp_server as mcp_server + + class _FakeResponse: + def __init__(self, *, status_code: int, payload: list[dict[str, Any]]) -> None: + self.status_code = status_code + self._payload = payload + self.request = httpx.Request("GET", "https://api.github.com/repos/evalops/platform/pulls") + + def json(self) -> list[dict[str, Any]]: + return self._payload + + def raise_for_status(self) -> None: + if self.status_code >= 400: + raise httpx.HTTPStatusError( + "Client error '403 Forbidden' for url", + request=self.request, + response=httpx.Response(self.status_code, request=self.request), + ) + + class _FakeGitHubClient: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def get(self, url: str, *, headers=None, params=None, timeout=None): + return _FakeResponse(status_code=403, payload=[]) + + monkeypatch.setattr(settings, "github_token", "token") + monkeypatch.setattr(settings, "github_repositories", ["evalops/platform"]) + monkeypatch.setattr(httpx, "AsyncClient", lambda: _FakeGitHubClient()) + + result = await mcp_server._github_pr_scan("evalops", None, "open", 20) + + assert "error" in result + + @pytest.mark.asyncio async def test_mcp_run_procedure_dry_run_short_circuits_model_steps(monkeypatch): import agent_pm.mcp_server as mcp_server diff --git a/tests/test_procedure_runner.py b/tests/test_procedure_runner.py index 032ffc4..80a95ea 100644 --- a/tests/test_procedure_runner.py +++ b/tests/test_procedure_runner.py @@ -152,6 +152,47 @@ async def test_github_pr_scan_fetches_diffs_for_agent_authors(monkeypatch): assert sum(1 for call in calls if call["headers"]["Accept"] == "application/vnd.github.v3.diff") == 2 +@pytest.mark.asyncio +async def test_github_pr_scan_uses_api_url_for_github_diff_endpoints(monkeypatch): + import agent_pm.procedure_runner as procedure_runner + + now = datetime.now(tz=UTC) + recent = now.isoformat().replace("+00:00", "Z") + calls: list[dict[str, Any]] = [] + pulls = [ + { + "number": 1, + "title": "Security bump", + "user": {"login": "dependabot[bot]"}, + "created_at": recent, + "url": "https://api.github.com/repos/evalops/platform/pulls/1", + "diff_url": "https://github.com/evalops/platform/pull/1.diff", + } + ] + diffs = { + "https://api.github.com/repos/evalops/platform/pulls/1": "diff --git a/a.py b/a.py\n+secret = 'nope'\n", + } + + monkeypatch.setattr(settings, "dry_run", False) + monkeypatch.setattr(settings, "github_token", "token") + monkeypatch.setattr(settings, "github_repositories", ["evalops/platform"]) + monkeypatch.setattr( + procedure_runner.httpx, + "AsyncClient", + _fake_github_client_factory(pulls=pulls, diffs=diffs, calls=calls), + ) + + result = await procedure_runner._run_github_pr_scan( + "List PRs opened in the last 24 hours. " + "Filter to PRs authored by bots or agents (dependabot, codex, cursor, maestro, claude). " + "For each PR, fetch the diff." + ) + + assert result["count"] == 1 + diff_call = next(call for call in calls if call["headers"]["Accept"] == "application/vnd.github.v3.diff") + assert diff_call["url"] == "https://api.github.com/repos/evalops/platform/pulls/1" + + @pytest.mark.asyncio async def test_linear_scan_respects_assignment_and_stale_rules(monkeypatch): import agent_pm.procedure_runner as procedure_runner From 051e4a24a31e2979f66546e5ef2ded9531c16640 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 14 Jun 2026 18:45:09 +0000 Subject: [PATCH 08/17] Fix scan scope and pagination bugs --- agent_pm/mcp_server.py | 69 +++++++++++----------- agent_pm/procedure_runner.py | 98 ++++++++++++++++++++++++------ tests/test_connectors.py | 105 ++++++++++++++++++++++++++++++++- tests/test_procedure_runner.py | 79 ++++++++++++++++++++++++- 4 files changed, 299 insertions(+), 52 deletions(-) diff --git a/agent_pm/mcp_server.py b/agent_pm/mcp_server.py index 3c4720c..267bbdc 100644 --- a/agent_pm/mcp_server.py +++ b/agent_pm/mcp_server.py @@ -52,6 +52,10 @@ "type": "object", "properties": { "action": {"type": "string", "enum": ["list_issues", "list_teams", "stale_sweep"]}, + "instruction": { + "type": "string", + "description": "Optional stale-scan instruction, e.g. 'last updated > 5 days ago'.", + }, "team_id": {"type": "string", "description": "Linear team ID to scope to."}, "state": {"type": "string", "description": "Workflow state name filter (e.g. 'Todo', 'In Progress')."}, "limit": {"type": "integer", "default": 50}, @@ -106,44 +110,39 @@ async def _sentry_scan(query: str, stats_period: str, limit: int) -> dict[str, A return {"error": str(exc)} -async def _linear_scan(action: str, team_id: str | None, state: str | None, limit: int) -> dict[str, Any]: +async def _linear_scan( + action: str, + team_id: str | None, + state: str | None, + limit: int, + instruction: str = "", +) -> dict[str, Any]: """Query Linear.""" - from agent_pm.connectors.linear import linear_connector + from agent_pm.procedure_runner import ( + _collect_stale_linear_issues, + _extract_stale_days, + _list_linear_issues_for_scan, + ) try: if action == "list_teams": + from agent_pm.connectors.linear import linear_connector + teams = await linear_connector.list_teams() return {"teams": teams} elif action == "stale_sweep": - issues = await linear_connector.list_issues( + issues = await _list_linear_issues_for_scan( team_id=team_id, state=state, order_by="updatedAt", limit=None, ) issues = sorted(issues, key=lambda issue: str(issue.get("updatedAt") or "")) - # Flag stale items - from datetime import UTC, datetime, timedelta - - now = datetime.now(tz=UTC) - stale = [] - for issue in issues: - updated = issue.get("updatedAt") - due = issue.get("dueDate") - flags = [] - if due and due < now.date().isoformat(): - flags.append("past_due") - if updated: - updated_dt = datetime.fromisoformat(updated.replace("Z", "+00:00")) - if (now - updated_dt) > timedelta(days=2): - flags.append("stale") - if flags: - stale.append( - {"id": issue["id"], "identifier": issue["identifier"], "title": issue["title"], "flags": flags} - ) - return {"total": len(issues), "stale": stale} + stale_after_days = _extract_stale_days(instruction, default=2) + stale = await _collect_stale_linear_issues(issues, stale_after_days=stale_after_days) + return {"total": len(issues), "stale_after_days": stale_after_days, "stale": stale} else: - issues = await linear_connector.list_issues(team_id=team_id, state=state, limit=limit) + issues = await _list_linear_issues_for_scan(team_id=team_id, state=state, order_by="updatedAt", limit=limit) return {"issues": issues, "count": len(issues)} except Exception as exc: return {"error": str(exc)} @@ -182,19 +181,22 @@ async def _github_pr_scan(org: str, author: str | None, state: str, limit: int) data = resp.json() return {"prs": data.get("items", []), "total": data.get("total_count", 0)} else: + from agent_pm.procedure_runner import _fetch_repository_pull_requests + # Use connector for org repos repos = repos or ["evalops/platform", "evalops/deploy", "evalops/maestro-internal"] all_prs = [] - for repo in repos: - async with httpx.AsyncClient() as client: - resp = await client.get( - f"https://api.github.com/repos/{repo}/pulls", - headers=headers, - params={"state": state, "per_page": limit}, - timeout=30, + async with httpx.AsyncClient() as client: + for repo in repos: + all_prs.extend( + await _fetch_repository_pull_requests( + client, + repo, + headers, + state=state, + per_page=limit, + ) ) - resp.raise_for_status() - all_prs.extend(resp.json()) return {"prs": all_prs, "total": len(all_prs)} except Exception as exc: return {"error": str(exc)} @@ -227,6 +229,7 @@ async def _list_procedures() -> dict[str, Any]: args.get("team_id"), args.get("state"), args.get("limit", 50), + args.get("instruction", ""), ), "agent_pm_github_pr_scan": lambda args: _github_pr_scan( args.get("org", "evalops"), diff --git a/agent_pm/procedure_runner.py b/agent_pm/procedure_runner.py index 41cfbb0..1e14ba3 100644 --- a/agent_pm/procedure_runner.py +++ b/agent_pm/procedure_runner.py @@ -38,6 +38,11 @@ re.IGNORECASE, ) _KNOWN_AGENT_LOGINS = {"dependabot", "codex", "cursor", "maestro", "claude"} +_DEFAULT_GITHUB_REPOSITORIES = [ + "evalops/platform", + "evalops/deploy", + "evalops/maestro-internal", +] _MODEL_STEP_SYSTEM_PROMPT = ( "You are executing an operational procedure. Follow the instruction exactly, use the" " provided step outputs as source material, and return concise markdown or plain text." @@ -225,7 +230,7 @@ async def _run_linear_scan(instruction: str) -> dict[str, Any]: result["stale"] = [] return result - issues = await linear_connector.list_issues( + issues = await _list_linear_issues_for_scan( assignee_email=assignee_email, state=state, order_by="updatedAt", @@ -252,15 +257,7 @@ async def _run_linear_scan(instruction: str) -> dict[str, Any]: async def _run_github_pr_scan(instruction: str) -> dict[str, Any]: - repos = ( - _extract_repositories(instruction) - or settings.github_repositories - or [ - "evalops/platform", - "evalops/deploy", - "evalops/maestro-internal", - ] - ) + repos = _resolve_github_pr_repositories(instruction) author = _extract_explicit_pr_author(instruction) hours = _extract_last_hours(instruction) include_agent_authors = _instruction_requests_agent_authors(instruction) @@ -283,14 +280,14 @@ async def _run_github_pr_scan(instruction: str) -> dict[str, Any]: pulls: list[dict[str, Any]] = [] async with httpx.AsyncClient() as client: for repo in repos: - response = await client.get( - f"https://api.github.com/repos/{repo}/pulls", - headers=headers, - params={"state": "open", "per_page": 20}, - timeout=30, + repo_pulls = await _fetch_repository_pull_requests( + client, + repo, + headers, + state="open", + per_page=20, ) - response.raise_for_status() - for pr in response.json(): + for pr in repo_pulls: if _include_pull_request( pr, author=author, include_agent_authors=include_agent_authors, last_hours=hours ): @@ -311,6 +308,34 @@ async def _run_github_pr_scan(instruction: str) -> dict[str, Any]: } +async def _list_linear_issues_for_scan( + *, + team_id: str | None = None, + assignee_email: str | None = None, + state: str | None = None, + order_by: str = "updatedAt", + limit: int | None = 50, +) -> list[dict[str, Any]]: + team_ids = [team_id] if team_id else settings.linear_team_ids or [None] + remaining = limit + issues: list[dict[str, Any]] = [] + for scan_team_id in team_ids: + team_limit = None if remaining is None else remaining + team_issues = await linear_connector.list_issues( + assignee_email=assignee_email, + team_id=scan_team_id, + state=state, + order_by=order_by, + limit=team_limit, + ) + issues.extend(team_issues) + if remaining is not None: + remaining -= len(team_issues) + if remaining <= 0: + break + return issues + + async def _collect_stale_linear_issues( issues: list[dict[str, Any]], *, @@ -372,6 +397,33 @@ async def _fetch_pull_request_diff( return response.text +async def _fetch_repository_pull_requests( + client: httpx.AsyncClient, + repo: str, + headers: dict[str, str], + *, + state: str, + per_page: int, +) -> list[dict[str, Any]]: + page = 1 + normalized_page_size = max(1, min(per_page, 100)) + pulls: list[dict[str, Any]] = [] + while True: + response = await client.get( + f"https://api.github.com/repos/{repo}/pulls", + headers=headers, + params={"state": state, "per_page": normalized_page_size, "page": page}, + timeout=30, + ) + response.raise_for_status() + page_pulls = response.json() + pulls.extend(page_pulls) + if len(page_pulls) < normalized_page_size: + break + page += 1 + return pulls + + def _include_pull_request( pr: dict[str, Any], *, @@ -404,6 +456,14 @@ def _extract_repositories(instruction: str) -> list[str]: return list(dict.fromkeys(_REPO_RE.findall(instruction))) +def _resolve_github_pr_repositories(instruction: str) -> list[str]: + explicit_repos = _extract_repositories(instruction) + default_repos = settings.github_repositories or _DEFAULT_GITHUB_REPOSITORIES + if explicit_repos and _instruction_requests_default_github_repositories(instruction): + return list(dict.fromkeys([*default_repos, *explicit_repos])) + return explicit_repos or default_repos + + def _extract_stats_period(instruction: str, *, default: str) -> str: match = _STATS_PERIOD_RE.search(instruction) if match: @@ -526,6 +586,10 @@ def _instruction_requests_agent_authors(instruction: str) -> bool: ) +def _instruction_requests_default_github_repositories(instruction: str) -> bool: + return bool(re.search(r"\bevalops\s+repos\b|\bconfigured\s+repos\b|\bdefault\s+repos\b", instruction, re.I)) + + def _instruction_requests_pr_diffs(instruction: str) -> bool: return bool(re.search(r"\bfetch\b[^.\n]*\bdiffs?\b", instruction, re.I)) diff --git a/tests/test_connectors.py b/tests/test_connectors.py index 8113766..bdf1a71 100644 --- a/tests/test_connectors.py +++ b/tests/test_connectors.py @@ -412,8 +412,9 @@ async def test_mcp_linear_stale_sweep_respects_team_and_state(monkeypatch): }, ] - async def fake_list_issues(*, team_id=None, state=None, order_by="updatedAt", limit=50): + async def fake_list_issues(*, team_id=None, assignee_email=None, state=None, order_by="updatedAt", limit=50): captured["team_id"] = team_id + captured["assignee_email"] = assignee_email captured["state"] = state captured["order_by"] = order_by captured["limit"] = limit @@ -429,12 +430,72 @@ async def fake_list_issues(*, team_id=None, state=None, order_by="updatedAt", li assert [issue["identifier"] for issue in result["stale"]] == ["LIN-1", "LIN-2"] assert captured == { "team_id": "team-123", + "assignee_email": None, "state": "In Progress", "order_by": "updatedAt", "limit": None, } +@pytest.mark.asyncio +async def test_mcp_linear_stale_sweep_uses_configured_teams_and_full_stale_rules(monkeypatch): + import agent_pm.mcp_server as mcp_server + + now = datetime.now(tz=UTC) + issues_by_team = { + "team-1": [ + { + "id": "issue-1", + "identifier": "LIN-1", + "title": "Needs status update", + "state": {"name": "In Progress"}, + "updatedAt": (now - timedelta(days=6)).isoformat().replace("+00:00", "Z"), + "dueDate": None, + } + ], + "team-2": [ + { + "id": "issue-2", + "identifier": "LIN-2", + "title": "Past due", + "state": {"name": "Todo"}, + "updatedAt": (now - timedelta(days=1)).isoformat().replace("+00:00", "Z"), + "dueDate": (now - timedelta(days=1)).date().isoformat(), + } + ], + } + captured_team_ids: list[str | None] = [] + + async def fake_list_issues(*, team_id=None, assignee_email=None, state=None, order_by="updatedAt", limit=50): + captured_team_ids.append(team_id) + return issues_by_team.get(team_id, []) + + async def fake_get_issue_comments(issue_id: str, limit: int = 20): + assert issue_id == "issue-1" + return [] + + from agent_pm.connectors.linear import linear_connector + + monkeypatch.setattr(settings, "linear_team_ids", ["team-1", "team-2"]) + monkeypatch.setattr(linear_connector, "list_issues", fake_list_issues) + monkeypatch.setattr(linear_connector, "get_issue_comments", fake_get_issue_comments) + + result = await mcp_server._linear_scan( + "stale_sweep", + None, + None, + 25, + 'Flag items: due date blown, last updated > 5 days ago, "In Progress" without recent comments.', + ) + + stale_by_id = {issue["identifier"]: issue for issue in result["stale"]} + assert captured_team_ids == ["team-1", "team-2"] + assert result["total"] == 2 + assert result["stale_after_days"] == 5 + assert stale_by_id["LIN-1"]["flags"] == ["stale", "in_progress_no_recent_comments"] + assert stale_by_id["LIN-2"]["flags"] == ["past_due"] + + @pytest.mark.asyncio async def test_mcp_github_pr_scan_author_uses_configured_repos(monkeypatch): import httpx @@ -519,6 +580,48 @@ async def get(self, url: str, *, headers=None, params=None, timeout=None): assert "error" in result +@pytest.mark.asyncio +async def test_mcp_github_pr_scan_without_author_paginates_all_repo_pages(monkeypatch): + import httpx + + import agent_pm.mcp_server as mcp_server + + calls: list[dict[str, Any]] = [] + + class _FakeResponse: + def __init__(self, *, payload: list[dict[str, Any]]) -> None: + self._payload = payload + self.request = httpx.Request("GET", "https://api.github.com/repos/evalops/platform/pulls") + + def json(self) -> list[dict[str, Any]]: + return self._payload + + def raise_for_status(self) -> None: + return None + + class _FakeGitHubClient: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def get(self, url: str, *, headers=None, params=None, timeout=None): + calls.append({"url": url, "headers": headers, "params": params, "timeout": timeout}) + page = int((params or {}).get("page", 1)) + payload = [{"number": idx} for idx in range(1, 21)] if page == 1 else [{"number": 21}] + return _FakeResponse(payload=payload) + + monkeypatch.setattr(settings, "github_token", "token") + monkeypatch.setattr(settings, "github_repositories", ["evalops/platform"]) + monkeypatch.setattr(httpx, "AsyncClient", lambda: _FakeGitHubClient()) + + result = await mcp_server._github_pr_scan("evalops", None, "open", 20) + + assert result["total"] == 21 + assert [call["params"]["page"] for call in calls] == [1, 2] + + @pytest.mark.asyncio async def test_mcp_run_procedure_dry_run_short_circuits_model_steps(monkeypatch): import agent_pm.mcp_server as mcp_server diff --git a/tests/test_procedure_runner.py b/tests/test_procedure_runner.py index 80a95ea..645bfa0 100644 --- a/tests/test_procedure_runner.py +++ b/tests/test_procedure_runner.py @@ -95,6 +95,62 @@ async def test_github_pr_scan_does_not_narrow_dependabot_mentions(monkeypatch): assert calls[0]["url"].endswith("/pulls") +@pytest.mark.asyncio +async def test_github_pr_scan_merges_evalops_defaults_with_explicit_repo(monkeypatch): + import agent_pm.procedure_runner as procedure_runner + + monkeypatch.setattr(settings, "dry_run", True) + monkeypatch.setattr(settings, "github_token", None) + monkeypatch.setattr(settings, "github_repositories", ["evalops/platform", "evalops/deploy"]) + + result = await procedure_runner._run_github_pr_scan("Scan open PRs across evalops repos and haasonsaas/homelab.") + + assert result["repositories"] == ["evalops/platform", "evalops/deploy", "haasonsaas/homelab"] + + +@pytest.mark.asyncio +async def test_github_pr_scan_paginates_all_open_pr_pages(monkeypatch): + import agent_pm.procedure_runner as procedure_runner + + pages = { + 1: [ + {"number": index, "user": {"login": "alice"}, "created_at": "2026-06-14T00:00:00Z"} + for index in range(1, 21) + ], + 2: [{"number": 21, "user": {"login": "alice"}, "created_at": "2026-06-14T00:00:00Z"}], + } + calls: list[dict[str, Any]] = [] + + class _FakeGitHubClient: + async def __aenter__(self) -> _FakeGitHubClient: + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + return None + + async def get( + self, + url: str, + *, + headers: dict[str, str] | None = None, + params: dict[str, Any] | None = None, + timeout: int | None = None, + ) -> _FakeResponse: + calls.append({"url": url, "headers": headers, "params": params, "timeout": timeout}) + page = int((params or {}).get("page", 1)) + return _FakeResponse(json_data=pages[page]) + + monkeypatch.setattr(settings, "dry_run", False) + monkeypatch.setattr(settings, "github_token", "token") + monkeypatch.setattr(settings, "github_repositories", ["evalops/platform"]) + monkeypatch.setattr(procedure_runner.httpx, "AsyncClient", lambda: _FakeGitHubClient()) + + result = await procedure_runner._run_github_pr_scan("Scan open PRs across evalops repos.") + + assert result["count"] == 21 + assert [call["params"]["page"] for call in calls] == [1, 2] + + @pytest.mark.asyncio async def test_github_pr_scan_fetches_diffs_for_agent_authors(monkeypatch): import agent_pm.procedure_runner as procedure_runner @@ -229,8 +285,9 @@ async def test_linear_scan_respects_assignment_and_stale_rules(monkeypatch): ] captured: dict[str, Any] = {} - async def fake_list_issues(*, assignee_email=None, state=None, order_by="updatedAt", limit=50): + async def fake_list_issues(*, assignee_email=None, team_id=None, state=None, order_by="updatedAt", limit=50): captured["assignee_email"] = assignee_email + captured["team_id"] = team_id captured["state"] = state captured["order_by"] = order_by captured["limit"] = limit @@ -263,6 +320,26 @@ async def fake_get_issue_comments(issue_id: str, limit: int = 20): assert captured["comment_ids"] == ["issue-2"] +@pytest.mark.asyncio +async def test_linear_scan_uses_configured_team_ids_when_unscoped(monkeypatch): + import agent_pm.procedure_runner as procedure_runner + + captured: list[str | None] = [] + + async def fake_list_issues(*, team_id=None, assignee_email=None, state=None, order_by="updatedAt", limit=50): + captured.append(team_id) + return [{"id": f"{team_id}-1", "identifier": f"{team_id}-1", "title": f"Issue for {team_id}"}] + + monkeypatch.setattr(settings, "linear_team_ids", ["team-1", "team-2"]) + monkeypatch.setattr(procedure_runner.linear_connector, "list_issues", fake_list_issues) + + result = await procedure_runner._run_linear_scan("List all issues sorted by updatedAt ascending.") + + assert captured == ["team-1", "team-2"] + assert result["count"] == 2 + assert {issue["identifier"] for issue in result["issues"]} == {"team-1-1", "team-2-1"} + + @pytest.mark.asyncio async def test_weekly_progress_review_uses_calendar_scan(monkeypatch): import agent_pm.procedure_runner as procedure_runner From 5216ab949b3c05f36f83ffea34db8a4e4d5246c7 Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Sun, 14 Jun 2026 19:38:24 +0000 Subject: [PATCH 09/17] fix: paginate MCP author search and fix Linear team limit skip - MCP _github_pr_scan with author filter now paginates through search results instead of returning only the first page - _list_linear_issues_for_scan no longer shares a global remaining counter across teams; each team gets its full limit --- agent_pm/mcp_server.py | 27 ++++++++++++++++++--------- agent_pm/procedure_runner.py | 8 +------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/agent_pm/mcp_server.py b/agent_pm/mcp_server.py index 267bbdc..c686cef 100644 --- a/agent_pm/mcp_server.py +++ b/agent_pm/mcp_server.py @@ -170,16 +170,25 @@ async def _github_pr_scan(org: str, author: str | None, state: str, limit: int) query_parts.append(f"org:{org}") query_parts.append(f"author:{author}") params["q"] = " ".join(query_parts) + all_items: list[dict[str, Any]] = [] + page = 1 async with httpx.AsyncClient() as client: - resp = await client.get( - "https://api.github.com/search/issues", - headers=headers, - params=params, - timeout=30, - ) - resp.raise_for_status() - data = resp.json() - return {"prs": data.get("items", []), "total": data.get("total_count", 0)} + while True: + page_params = {**params, "page": page} + resp = await client.get( + "https://api.github.com/search/issues", + headers=headers, + params=page_params, + timeout=30, + ) + resp.raise_for_status() + data = resp.json() + items = data.get("items", []) + all_items.extend(items) + if len(items) < params["per_page"] or len(all_items) >= limit: + break + page += 1 + return {"prs": all_items[:limit], "total": data.get("total_count", 0)} else: from agent_pm.procedure_runner import _fetch_repository_pull_requests diff --git a/agent_pm/procedure_runner.py b/agent_pm/procedure_runner.py index 1e14ba3..158fdb1 100644 --- a/agent_pm/procedure_runner.py +++ b/agent_pm/procedure_runner.py @@ -317,22 +317,16 @@ async def _list_linear_issues_for_scan( limit: int | None = 50, ) -> list[dict[str, Any]]: team_ids = [team_id] if team_id else settings.linear_team_ids or [None] - remaining = limit issues: list[dict[str, Any]] = [] for scan_team_id in team_ids: - team_limit = None if remaining is None else remaining team_issues = await linear_connector.list_issues( assignee_email=assignee_email, team_id=scan_team_id, state=state, order_by=order_by, - limit=team_limit, + limit=limit, ) issues.extend(team_issues) - if remaining is not None: - remaining -= len(team_issues) - if remaining <= 0: - break return issues From 0bbd066e039afda334062dcb47c54bf1832deec7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 14 Jun 2026 19:43:34 +0000 Subject: [PATCH 10/17] Fix scheduler and MCP GitHub scan guards --- agent_pm/mcp_server.py | 12 +++++++++ agent_pm/scheduler.py | 6 ++--- tests/test_connectors.py | 54 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/agent_pm/mcp_server.py b/agent_pm/mcp_server.py index c686cef..aa5c082 100644 --- a/agent_pm/mcp_server.py +++ b/agent_pm/mcp_server.py @@ -153,6 +153,18 @@ async def _github_pr_scan(org: str, author: str | None, state: str, limit: int) from agent_pm.settings import settings try: + if not settings.github_token: + return { + "dry_run": True, + "prs": [], + "total": 0, + "org": org, + "repositories": settings.github_repositories or [], + "author": author, + "state": state, + "limit": limit, + } + # Use the existing GitHub connector import httpx diff --git a/agent_pm/scheduler.py b/agent_pm/scheduler.py index 9e7a07a..4d31d2d 100644 --- a/agent_pm/scheduler.py +++ b/agent_pm/scheduler.py @@ -132,13 +132,13 @@ async def _tick(self) -> None: async def _loop(self) -> None: """Main scheduler loop — wakes at the top of each minute.""" while self._running: - now = datetime.now(tz=UTC) - seconds_to_next_minute = 60 - now.second try: - await asyncio.sleep(seconds_to_next_minute) if not self._running: break await self._tick() + now = datetime.now(tz=UTC) + seconds_to_next_minute = 60 - now.second - (now.microsecond / 1_000_000) + await asyncio.sleep(seconds_to_next_minute) except Exception: logger.exception("Scheduler tick error") diff --git a/tests/test_connectors.py b/tests/test_connectors.py index bdf1a71..fcf71d3 100644 --- a/tests/test_connectors.py +++ b/tests/test_connectors.py @@ -346,6 +346,35 @@ async def fake_error_counts(*, stats_period: str = "7d", project: str | None = N assert captured["error_counts_period"] == "14d" +@pytest.mark.asyncio +async def test_scheduler_loop_ticks_current_minute_before_sleep(monkeypatch): + import agent_pm.scheduler as scheduler_module + + scheduler = scheduler_module.ProcedureScheduler() + scheduler._running = True + events: list[str] = [] + + class _FrozenDatetime(datetime): + @classmethod + def now(cls, tz=None): + return datetime(2026, 6, 15, 9, 0, 30, 500000, tzinfo=UTC) + + async def fake_tick() -> None: + events.append("tick") + + async def fake_sleep(seconds: float) -> None: + events.append(f"sleep:{seconds}") + scheduler._running = False + + monkeypatch.setattr(scheduler_module, "datetime", _FrozenDatetime) + monkeypatch.setattr(scheduler, "_tick", fake_tick) + monkeypatch.setattr(scheduler_module.asyncio, "sleep", fake_sleep) + + await scheduler._loop() + + assert events == ["tick", "sleep:29.5"] + + # ── MCP server tests ────────────────────────────────────────────── @@ -538,6 +567,31 @@ async def get(self, url: str, *, headers=None, params=None, timeout=None): assert "org:evalops" not in calls[0]["params"]["q"] +@pytest.mark.asyncio +async def test_mcp_github_pr_scan_without_token_returns_empty_dry_run(monkeypatch): + import httpx + + import agent_pm.mcp_server as mcp_server + + monkeypatch.setattr(settings, "dry_run", False) + monkeypatch.setattr(settings, "github_token", None) + monkeypatch.setattr(settings, "github_repositories", ["evalops/platform"]) + monkeypatch.setattr(httpx, "AsyncClient", lambda: (_ for _ in ()).throw(AssertionError("network should not run"))) + + result = await mcp_server._github_pr_scan("evalops", "dependabot", "open", 20) + + assert result == { + "dry_run": True, + "prs": [], + "total": 0, + "org": "evalops", + "repositories": ["evalops/platform"], + "author": "dependabot", + "state": "open", + "limit": 20, + } + + @pytest.mark.asyncio async def test_mcp_github_pr_scan_without_author_surfaces_repo_errors(monkeypatch): import httpx From 188abc7e76725040ba14313bd4679277ef03852a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 14 Jun 2026 19:48:47 +0000 Subject: [PATCH 11/17] Fix MCP GitHub dry-run and scan limits --- agent_pm/mcp_server.py | 10 +++++++--- agent_pm/procedure_runner.py | 17 ++++++++++++++++- tests/test_connectors.py | 32 +++++++++++++++++++++++++++++--- tests/test_procedure_runner.py | 31 +++++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 7 deletions(-) diff --git a/agent_pm/mcp_server.py b/agent_pm/mcp_server.py index aa5c082..56f32f2 100644 --- a/agent_pm/mcp_server.py +++ b/agent_pm/mcp_server.py @@ -153,7 +153,7 @@ async def _github_pr_scan(org: str, author: str | None, state: str, limit: int) from agent_pm.settings import settings try: - if not settings.github_token: + if settings.dry_run or not settings.github_token: return { "dry_run": True, "prs": [], @@ -206,16 +206,20 @@ async def _github_pr_scan(org: str, author: str | None, state: str, limit: int) # Use connector for org repos repos = repos or ["evalops/platform", "evalops/deploy", "evalops/maestro-internal"] - all_prs = [] + all_prs: list[dict[str, Any]] = [] async with httpx.AsyncClient() as client: for repo in repos: + remaining = limit - len(all_prs) + if remaining <= 0: + break all_prs.extend( await _fetch_repository_pull_requests( client, repo, headers, state=state, - per_page=limit, + per_page=remaining, + max_results=remaining, ) ) return {"prs": all_prs, "total": len(all_prs)} diff --git a/agent_pm/procedure_runner.py b/agent_pm/procedure_runner.py index 158fdb1..64e3f06 100644 --- a/agent_pm/procedure_runner.py +++ b/agent_pm/procedure_runner.py @@ -29,6 +29,7 @@ _PLACEHOLDER_RE = re.compile(r"{{\s*([a-zA-Z0-9_]+)\s*}}") _REPO_RE = re.compile(r"\b[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+\b") +_SENTRY_QUERY_TOKEN_RE = re.compile(r"\b[a-zA-Z_][a-zA-Z0-9_.-]*:[^\s,()]+") _STATS_PERIOD_RE = re.compile(r"\b(\d+)([hdw])\b") _LAST_HOURS_RE = re.compile(r"last\s+(\d+)\s+hours?", re.IGNORECASE) _NEXT_DAYS_RE = re.compile(r"next\s+(\d+)\s+days?", re.IGNORECASE) @@ -177,7 +178,7 @@ async def _run_model_step(instruction: str, context: dict[str, Any]) -> str: async def _run_sentry_scan(instruction: str) -> dict[str, Any]: stats_period = _extract_stats_period(instruction, default="14d") - query = "is:unresolved" if "is:unresolved" in instruction else "is:unresolved" + query = _extract_sentry_query(instruction, default="is:unresolved") issues = await sentry_connector.list_issues(query=query, stats_period=stats_period, limit=10) error_counts = await sentry_connector.error_counts(stats_period=stats_period) return { @@ -398,6 +399,7 @@ async def _fetch_repository_pull_requests( *, state: str, per_page: int, + max_results: int | None = None, ) -> list[dict[str, Any]]: page = 1 normalized_page_size = max(1, min(per_page, 100)) @@ -412,6 +414,8 @@ async def _fetch_repository_pull_requests( response.raise_for_status() page_pulls = response.json() pulls.extend(page_pulls) + if max_results is not None and len(pulls) >= max_results: + return pulls[:max_results] if len(page_pulls) < normalized_page_size: break page += 1 @@ -468,6 +472,17 @@ def _extract_stats_period(instruction: str, *, default: str) -> str: return default +def _extract_sentry_query(instruction: str, *, default: str) -> str: + parenthetical_match = re.search(r"\(([^)]*)\)", instruction) + search_spaces = [parenthetical_match.group(1)] if parenthetical_match else [] + search_spaces.append(instruction) + for candidate in search_spaces: + tokens = _SENTRY_QUERY_TOKEN_RE.findall(candidate) + if tokens: + return " ".join(dict.fromkeys(tokens)) + return default + + def _extract_last_hours(instruction: str) -> int | None: match = _LAST_HOURS_RE.search(instruction) if match: diff --git a/tests/test_connectors.py b/tests/test_connectors.py index fcf71d3..6ed6e78 100644 --- a/tests/test_connectors.py +++ b/tests/test_connectors.py @@ -592,6 +592,31 @@ async def test_mcp_github_pr_scan_without_token_returns_empty_dry_run(monkeypatc } +@pytest.mark.asyncio +async def test_mcp_github_pr_scan_dry_run_with_token_skips_network(monkeypatch): + import httpx + + import agent_pm.mcp_server as mcp_server + + monkeypatch.setattr(settings, "dry_run", True) + monkeypatch.setattr(settings, "github_token", "token") + monkeypatch.setattr(settings, "github_repositories", ["evalops/platform"]) + monkeypatch.setattr(httpx, "AsyncClient", lambda: (_ for _ in ()).throw(AssertionError("network should not run"))) + + result = await mcp_server._github_pr_scan("evalops", "dependabot", "open", 20) + + assert result == { + "dry_run": True, + "prs": [], + "total": 0, + "org": "evalops", + "repositories": ["evalops/platform"], + "author": "dependabot", + "state": "open", + "limit": 20, + } + + @pytest.mark.asyncio async def test_mcp_github_pr_scan_without_author_surfaces_repo_errors(monkeypatch): import httpx @@ -635,7 +660,7 @@ async def get(self, url: str, *, headers=None, params=None, timeout=None): @pytest.mark.asyncio -async def test_mcp_github_pr_scan_without_author_paginates_all_repo_pages(monkeypatch): +async def test_mcp_github_pr_scan_without_author_respects_limit(monkeypatch): import httpx import agent_pm.mcp_server as mcp_server @@ -672,8 +697,9 @@ async def get(self, url: str, *, headers=None, params=None, timeout=None): result = await mcp_server._github_pr_scan("evalops", None, "open", 20) - assert result["total"] == 21 - assert [call["params"]["page"] for call in calls] == [1, 2] + assert result["total"] == 20 + assert [pr["number"] for pr in result["prs"]] == list(range(1, 21)) + assert [call["params"]["page"] for call in calls] == [1] @pytest.mark.asyncio diff --git a/tests/test_procedure_runner.py b/tests/test_procedure_runner.py index 645bfa0..1ad45e3 100644 --- a/tests/test_procedure_runner.py +++ b/tests/test_procedure_runner.py @@ -249,6 +249,37 @@ async def test_github_pr_scan_uses_api_url_for_github_diff_endpoints(monkeypatch assert diff_call["url"] == "https://api.github.com/repos/evalops/platform/pulls/1" +@pytest.mark.asyncio +async def test_run_sentry_scan_extracts_query_from_instruction(monkeypatch): + import agent_pm.procedure_runner as procedure_runner + + captured: dict[str, Any] = {} + + async def fake_list_issues(*, query: str, stats_period: str = "14d", limit: int = 10): + captured["query"] = query + captured["stats_period"] = stats_period + captured["limit"] = limit + return [{"id": "issue-1"}] + + async def fake_error_counts(*, stats_period: str = "7d", project: str | None = None): + captured["error_counts_period"] = stats_period + captured["project"] = project + return {"data": []} + + monkeypatch.setattr(procedure_runner.sentry_connector, "list_issues", fake_list_issues) + monkeypatch.setattr(procedure_runner.sentry_connector, "error_counts", fake_error_counts) + + result = await procedure_runner._run_sentry_scan( + "List resolved Sentry issues (is:resolved, environment:prod, 7d)." + ) + + assert captured["query"] == "is:resolved environment:prod" + assert captured["stats_period"] == "7d" + assert captured["limit"] == 10 + assert captured["error_counts_period"] == "7d" + assert result["query"] == "is:resolved environment:prod" + + @pytest.mark.asyncio async def test_linear_scan_respects_assignment_and_stale_rules(monkeypatch): import agent_pm.procedure_runner as procedure_runner From 29ead2e2cbd9f159fd120cd612bee319330666a4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 14 Jun 2026 19:50:48 +0000 Subject: [PATCH 12/17] Fix tests for MCP dry-run behavior --- tests/test_connectors.py | 3 +++ tests/test_procedure_runner.py | 4 +--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_connectors.py b/tests/test_connectors.py index 6ed6e78..6143e2a 100644 --- a/tests/test_connectors.py +++ b/tests/test_connectors.py @@ -554,6 +554,7 @@ async def get(self, url: str, *, headers=None, params=None, timeout=None): calls.append({"url": url, "headers": headers, "params": params, "timeout": timeout}) return _FakeResponse(payload={"items": [], "total_count": 0}) + monkeypatch.setattr(settings, "dry_run", False) monkeypatch.setattr(settings, "github_token", "token") monkeypatch.setattr(settings, "github_repositories", ["evalops/platform", "haasonsaas/homelab"]) monkeypatch.setattr(httpx, "AsyncClient", lambda: _FakeGitHubClient()) @@ -650,6 +651,7 @@ async def __aexit__(self, exc_type, exc, tb): async def get(self, url: str, *, headers=None, params=None, timeout=None): return _FakeResponse(status_code=403, payload=[]) + monkeypatch.setattr(settings, "dry_run", False) monkeypatch.setattr(settings, "github_token", "token") monkeypatch.setattr(settings, "github_repositories", ["evalops/platform"]) monkeypatch.setattr(httpx, "AsyncClient", lambda: _FakeGitHubClient()) @@ -691,6 +693,7 @@ async def get(self, url: str, *, headers=None, params=None, timeout=None): payload = [{"number": idx} for idx in range(1, 21)] if page == 1 else [{"number": 21}] return _FakeResponse(payload=payload) + monkeypatch.setattr(settings, "dry_run", False) monkeypatch.setattr(settings, "github_token", "token") monkeypatch.setattr(settings, "github_repositories", ["evalops/platform"]) monkeypatch.setattr(httpx, "AsyncClient", lambda: _FakeGitHubClient()) diff --git a/tests/test_procedure_runner.py b/tests/test_procedure_runner.py index 1ad45e3..2c9574b 100644 --- a/tests/test_procedure_runner.py +++ b/tests/test_procedure_runner.py @@ -269,9 +269,7 @@ async def fake_error_counts(*, stats_period: str = "7d", project: str | None = N monkeypatch.setattr(procedure_runner.sentry_connector, "list_issues", fake_list_issues) monkeypatch.setattr(procedure_runner.sentry_connector, "error_counts", fake_error_counts) - result = await procedure_runner._run_sentry_scan( - "List resolved Sentry issues (is:resolved, environment:prod, 7d)." - ) + result = await procedure_runner._run_sentry_scan("List resolved Sentry issues (is:resolved, environment:prod, 7d).") assert captured["query"] == "is:resolved environment:prod" assert captured["stats_period"] == "7d" From 6560a6f43bc9ba7b03abeca5c8d8ef86460d129f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 14 Jun 2026 19:55:49 +0000 Subject: [PATCH 13/17] Return procedure step outputs --- agent_pm/procedure_runner.py | 2 +- tests/test_procedure_runner.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/agent_pm/procedure_runner.py b/agent_pm/procedure_runner.py index 64e3f06..d229282 100644 --- a/agent_pm/procedure_runner.py +++ b/agent_pm/procedure_runner.py @@ -80,7 +80,7 @@ async def execute_procedure(name: str, *, dry_run: bool = False) -> dict[str, An context[step_id] = result _store_step_aliases(step_id, result, context) - return {"procedure": name, "plan_id": plan_id, "dry_run": dry_run} + return {**context, "dry_run": dry_run} async def _generate_plan_result(name: str, proc: dict[str, Any]) -> dict[str, Any]: diff --git a/tests/test_procedure_runner.py b/tests/test_procedure_runner.py index 2c9574b..14a9f76 100644 --- a/tests/test_procedure_runner.py +++ b/tests/test_procedure_runner.py @@ -412,6 +412,39 @@ async def sync(self, *, since=None): assert "calendar_overview" in procedure["steps"][4]["input"] +@pytest.mark.asyncio +async def test_execute_procedure_returns_step_outputs(monkeypatch): + import agent_pm.procedure_runner as procedure_runner + + procedure = { + "name": "weekly_progress_review", + "description": "Multi-source progress sweep.", + "steps": [ + {"id": "calendar_overview", "run": "calendar_scan"}, + {"id": "compose_digest", "run": "model"}, + ], + } + + async def fake_execute_step(step: dict[str, Any], context: dict[str, Any]) -> Any: + if step["id"] == "calendar_overview": + return {"count": 2, "events": [{"id": "evt-1"}]} + assert context["calendar_overview"] == {"count": 2, "events": [{"id": "evt-1"}]} + return "Digest body" + + monkeypatch.setattr(procedure_runner.loader, "load", lambda: {"weekly_progress_review": procedure}) + monkeypatch.setattr(procedure_runner, "_execute_step", fake_execute_step) + + result = await procedure_runner.execute_procedure("weekly_progress_review") + + assert result["procedure"] == "weekly_progress_review" + assert result["title"] == "weekly_progress_review" + assert result["description"] == "Multi-source progress sweep." + assert result["dry_run"] is False + assert result["calendar_overview"] == {"count": 2, "events": [{"id": "evt-1"}]} + assert result["compose_digest"] == "Digest body" + assert result["digest"] == "Digest body" + + @pytest.mark.asyncio async def test_linear_scan_requires_configured_email_for_assigned_to_me(monkeypatch): import agent_pm.procedure_runner as procedure_runner From ef10b5117b00acfd6cd30f4ac9d302cc8f9a4f00 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 20 Jun 2026 03:33:25 +0000 Subject: [PATCH 14/17] Fix procedure scan connector handling --- agent_pm/connectors/calendar.py | 10 ++- agent_pm/connectors/sentry.py | 10 +-- agent_pm/mcp_server.py | 33 ++++++++- agent_pm/procedure_runner.py | 44 ++++++++++-- tests/test_connectors.py | 116 ++++++++++++++++++++++++++++++++ tests/test_procedure_runner.py | 62 ++++++++++++++++- 6 files changed, 261 insertions(+), 14 deletions(-) diff --git a/agent_pm/connectors/calendar.py b/agent_pm/connectors/calendar.py index 30370d9..2e054f2 100644 --- a/agent_pm/connectors/calendar.py +++ b/agent_pm/connectors/calendar.py @@ -30,7 +30,12 @@ def __init__(self) -> None: def enabled(self) -> bool: return bool(settings.calendar_id and self._service_account_info) - async def sync(self, *, since: datetime | None = None) -> list[dict[str, Any]]: + async def sync( + self, + *, + since: datetime | None = None, + until: datetime | None = None, + ) -> list[dict[str, Any]]: calendar_id = settings.calendar_id window_days = settings.calendar_sync_window_days if settings.dry_run or not self.enabled: @@ -39,6 +44,7 @@ async def sync(self, *, since: datetime | None = None) -> list[dict[str, Any]]: "dry_run": True, "calendar_id": calendar_id, "since": since.isoformat() if since else None, + "until": until.isoformat() if until else None, "window_days": window_days, } ] @@ -47,7 +53,7 @@ async def sync(self, *, since: datetime | None = None) -> list[dict[str, Any]]: headers = {"Authorization": f"Bearer {token}"} lower_bound = datetime.now(tz=UTC) - timedelta(days=window_days) time_min = (since if since and since > lower_bound else lower_bound).isoformat() - time_max = (datetime.now(tz=UTC) + timedelta(days=window_days)).isoformat() + time_max = (until if until else datetime.now(tz=UTC) + timedelta(days=window_days)).isoformat() params = {"timeMin": time_min, "timeMax": time_max, "singleEvents": "true", "orderBy": "startTime"} async with httpx.AsyncClient() as client: response = await client.get( diff --git a/agent_pm/connectors/sentry.py b/agent_pm/connectors/sentry.py index 06b069d..8dcdffe 100644 --- a/agent_pm/connectors/sentry.py +++ b/agent_pm/connectors/sentry.py @@ -28,7 +28,7 @@ def _headers(self) -> dict[str, str]: "Accept": "application/json", } - async def _get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + async def _get(self, path: str, params: dict[str, Any] | None = None) -> Any: if settings.dry_run or not self.enabled: return {"dry_run": True, "path": path, "params": params} @@ -68,15 +68,17 @@ async def get_issue(self, issue_id: str) -> dict[str, Any]: return await self._get(f"/organizations/{self._org_slug}/issues/{issue_id}/") async def get_issue_events(self, issue_id: str, limit: int = 10) -> list[dict[str, Any]]: - return await self._get( + result = await self._get( f"/organizations/{self._org_slug}/issues/{issue_id}/events/", {"limit": limit}, ) + return result if isinstance(result, list) else result.get("data", []) if isinstance(result, dict) else [] async def get_issue_tag_distribution(self, issue_id: str, tag_key: str) -> list[dict[str, Any]]: - return await self._get( - f"/organizations/{self._org_slug}/issues/{issue_id}/tags/{tag_key}/", + result = await self._get( + f"/organizations/{self._org_slug}/issues/{issue_id}/tags/{tag_key}/values/", ) + return result if isinstance(result, list) else result.get("data", []) if isinstance(result, dict) else [] # ── events / error counts ──────────────────────────────────── diff --git a/agent_pm/mcp_server.py b/agent_pm/mcp_server.py index 56f32f2..ba59cfe 100644 --- a/agent_pm/mcp_server.py +++ b/agent_pm/mcp_server.py @@ -102,8 +102,19 @@ async def _run_procedure(name: str, dry_run: bool) -> dict[str, Any]: async def _sentry_scan(query: str, stats_period: str, limit: int) -> dict[str, Any]: """Run a Sentry issue scan.""" from agent_pm.connectors.sentry import sentry_connector + from agent_pm.settings import settings try: + if settings.dry_run: + return {"dry_run": True, "issues": [], "count": 0, "query": query, "stats_period": stats_period} + if not sentry_connector.enabled: + return { + "issues": [], + "count": 0, + "query": query, + "stats_period": stats_period, + "error": "Sentry connector is not configured.", + } issues = await sentry_connector.list_issues(query=query, stats_period=stats_period, limit=limit) return {"issues": issues, "count": len(issues), "query": query} except Exception as exc: @@ -118,18 +129,36 @@ async def _linear_scan( instruction: str = "", ) -> dict[str, Any]: """Query Linear.""" + from agent_pm.connectors.linear import linear_connector from agent_pm.procedure_runner import ( _collect_stale_linear_issues, _extract_stale_days, _list_linear_issues_for_scan, ) + from agent_pm.settings import settings try: if action == "list_teams": - from agent_pm.connectors.linear import linear_connector - teams = await linear_connector.list_teams() return {"teams": teams} + if settings.dry_run: + if action == "stale_sweep": + return { + "dry_run": True, + "total": 0, + "stale_after_days": _extract_stale_days(instruction, default=2), + "stale": [], + } + return {"dry_run": True, "issues": [], "count": 0} + if not linear_connector.enabled: + if action == "stale_sweep": + return { + "total": 0, + "stale_after_days": _extract_stale_days(instruction, default=2), + "stale": [], + "error": "Linear connector is not configured.", + } + return {"issues": [], "count": 0, "error": "Linear connector is not configured."} elif action == "stale_sweep": issues = await _list_linear_issues_for_scan( team_id=team_id, diff --git a/agent_pm/procedure_runner.py b/agent_pm/procedure_runner.py index d229282..bce122c 100644 --- a/agent_pm/procedure_runner.py +++ b/agent_pm/procedure_runner.py @@ -179,6 +179,18 @@ async def _run_model_step(instruction: str, context: dict[str, Any]) -> str: async def _run_sentry_scan(instruction: str) -> dict[str, Any]: stats_period = _extract_stats_period(instruction, default="14d") query = _extract_sentry_query(instruction, default="is:unresolved") + skipped_result = { + "issues": [], + "count": 0, + "query": query, + "stats_period": stats_period, + "error_counts": {"data": []}, + } + if settings.dry_run: + return {**skipped_result, "dry_run": True} + if not sentry_connector.enabled: + return {**skipped_result, "error": "Sentry connector is not configured."} + issues = await sentry_connector.list_issues(query=query, stats_period=stats_period, limit=10) error_counts = await sentry_connector.error_counts(stats_period=stats_period) return { @@ -194,7 +206,8 @@ async def _run_calendar_scan(instruction: str) -> dict[str, Any]: window_days = _extract_calendar_window_days(instruction, default=7) now = datetime.now(tz=UTC) window_start = _calendar_window_start(instruction, now) - payloads = await CalendarConnector().sync(since=window_start) + window_end = window_start + timedelta(days=window_days) + payloads = await CalendarConnector().sync(since=window_start, until=window_end) events: list[dict[str, Any]] = [] if payloads and isinstance(payloads[0], dict): @@ -218,6 +231,7 @@ async def _run_linear_scan(instruction: str) -> dict[str, Any]: assignee_email = _extract_linear_assignee_email(instruction) stale_scan = _instruction_requests_linear_stale_scan(instruction) oldest_updates_first = _instruction_requests_oldest_linear_updates(instruction) or stale_scan + stale_after_days = _extract_stale_days(instruction, default=2) if stale_scan else None if _instruction_requests_assigned_to_me(instruction) and not assignee_email: result: dict[str, Any] = { "issues": [], @@ -227,7 +241,22 @@ async def _run_linear_scan(instruction: str) -> dict[str, Any]: "error": "Linear 'assigned to me' scans require JIRA_EMAIL or GOOGLE_CALENDAR_DELEGATED_USER.", } if stale_scan: - result["stale_after_days"] = _extract_stale_days(instruction, default=2) + result["stale_after_days"] = stale_after_days + result["stale"] = [] + return result + if settings.dry_run or not linear_connector.enabled: + result = { + "issues": [], + "count": 0, + "state": state, + "assignee_email": assignee_email, + } + if settings.dry_run: + result["dry_run"] = True + else: + result["error"] = "Linear connector is not configured." + if stale_scan: + result["stale_after_days"] = stale_after_days result["stale"] = [] return result @@ -251,7 +280,6 @@ async def _run_linear_scan(instruction: str) -> dict[str, Any]: "assignee_email": assignee_email, } if stale_scan: - stale_after_days = _extract_stale_days(instruction, default=2) result["stale_after_days"] = stale_after_days result["stale"] = await _collect_stale_linear_issues(issues, stale_after_days=stale_after_days) return result @@ -319,15 +347,21 @@ async def _list_linear_issues_for_scan( ) -> list[dict[str, Any]]: team_ids = [team_id] if team_id else settings.linear_team_ids or [None] issues: list[dict[str, Any]] = [] + remaining = limit for scan_team_id in team_ids: + if remaining is not None and remaining <= 0: + break team_issues = await linear_connector.list_issues( assignee_email=assignee_email, team_id=scan_team_id, state=state, order_by=order_by, - limit=limit, + limit=remaining, ) - issues.extend(team_issues) + visible_issues = team_issues if remaining is None else team_issues[:remaining] + issues.extend(visible_issues) + if remaining is not None: + remaining -= len(visible_issues) return issues diff --git a/tests/test_connectors.py b/tests/test_connectors.py index 6143e2a..23da97b 100644 --- a/tests/test_connectors.py +++ b/tests/test_connectors.py @@ -79,6 +79,54 @@ async def test_calendar_connector_returns_time_window(monkeypatch): assert result["calendar_id"] == "calendar@example.com" +@pytest.mark.asyncio +async def test_calendar_connector_sync_uses_explicit_until(monkeypatch): + import agent_pm.connectors.calendar as calendar_module + + now = datetime.now(tz=UTC) + captured: dict[str, Any] = {} + + class _FakeResponse: + def json(self) -> dict[str, Any]: + return {"items": []} + + def raise_for_status(self) -> None: + return None + + class _FakeCalendarClient: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def get(self, url: str, *, headers=None, params=None, timeout=None): + captured["url"] = url + captured["headers"] = headers + captured["params"] = params + captured["timeout"] = timeout + return _FakeResponse() + + monkeypatch.setattr(settings, "calendar_id", "calendar@example.com") + monkeypatch.setattr(settings, "dry_run", False) + connector = CalendarConnector() + connector._service_account_info = {"client_email": "svc@example.com"} + + async def fake_get_token() -> str: + return "token" + + monkeypatch.setattr(connector, "_get_token", fake_get_token) + monkeypatch.setattr(calendar_module.httpx, "AsyncClient", lambda: _FakeCalendarClient()) + + since = now + until = now + timedelta(days=30) + payloads = await connector.sync(since=since, until=until) + + assert payloads == [{"items": []}] + assert captured["params"]["timeMin"] == since.isoformat() + assert captured["params"]["timeMax"] == until.isoformat() + + @pytest.mark.asyncio async def test_google_drive_connector_reports_query(monkeypatch): monkeypatch.setattr(settings, "google_service_account_json", None) @@ -253,6 +301,32 @@ async def test_sentry_connector_dry_run(monkeypatch): assert payloads[0]["error_counts"].get("dry_run") is True +@pytest.mark.asyncio +async def test_sentry_connector_issue_helpers_return_lists(monkeypatch): + connector = SentryConnector() + captured: list[tuple[str, dict[str, Any] | None]] = [] + + async def fake_get(path: str, params: dict[str, Any] | None = None) -> Any: + captured.append((path, params)) + if path.endswith("/events/"): + return {"data": [{"id": "event-1"}]} + if path.endswith("/values/"): + return [{"value": "prod"}] + raise AssertionError(f"Unexpected path: {path}") + + monkeypatch.setattr(connector, "_get", fake_get) + + events = await connector.get_issue_events("issue-1", limit=5) + tags = await connector.get_issue_tag_distribution("issue-1", "environment") + + assert events == [{"id": "event-1"}] + assert tags == [{"value": "prod"}] + assert captured == [ + (f"/organizations/{connector._org_slug}/issues/issue-1/events/", {"limit": 5}), + (f"/organizations/{connector._org_slug}/issues/issue-1/tags/environment/values/", None), + ] + + # ── Procedure loader tests ──────────────────────────────────────── @@ -306,6 +380,7 @@ async def test_scheduler_run_procedure_executes_yaml_steps(monkeypatch): from agent_pm.scheduler import ProcedureScheduler captured: dict[str, Any] = {} + monkeypatch.setattr(settings, "dry_run", False) monkeypatch.setattr( loader, @@ -335,6 +410,8 @@ async def fake_error_counts(*, stats_period: str = "7d", project: str | None = N captured["project"] = project return {"data": []} + monkeypatch.setattr(procedure_runner.sentry_connector, "_auth_token", "token") + monkeypatch.setattr(procedure_runner.sentry_connector, "_org_slug", "org") monkeypatch.setattr(procedure_runner.sentry_connector, "list_issues", fake_list_issues) monkeypatch.setattr(procedure_runner.sentry_connector, "error_counts", fake_error_counts) @@ -418,6 +495,41 @@ async def test_mcp_list_procedures_tool(): assert "procedures" in data +@pytest.mark.asyncio +async def test_mcp_sentry_scan_surfaces_dry_run_state(monkeypatch): + import agent_pm.mcp_server as mcp_server + from agent_pm.connectors.sentry import sentry_connector + + async def fail_list_issues(**kwargs): + raise AssertionError("scan should not run") + + monkeypatch.setattr(settings, "dry_run", True) + monkeypatch.setattr(sentry_connector, "list_issues", fail_list_issues) + + result = await mcp_server._sentry_scan("is:unresolved", "14d", 10) + + assert result == { + "dry_run": True, + "issues": [], + "count": 0, + "query": "is:unresolved", + "stats_period": "14d", + } + + +@pytest.mark.asyncio +async def test_mcp_linear_scan_surfaces_disabled_connector_state(monkeypatch): + import agent_pm.mcp_server as mcp_server + from agent_pm.connectors.linear import linear_connector + + monkeypatch.setattr(settings, "dry_run", False) + monkeypatch.setattr(linear_connector, "_api_key", None) + + result = await mcp_server._linear_scan("list_issues", None, None, 25) + + assert result == {"issues": [], "count": 0, "error": "Linear connector is not configured."} + + @pytest.mark.asyncio async def test_mcp_linear_stale_sweep_respects_team_and_state(monkeypatch): import agent_pm.mcp_server as mcp_server @@ -451,6 +563,8 @@ async def fake_list_issues(*, team_id=None, assignee_email=None, state=None, ord from agent_pm.connectors.linear import linear_connector + monkeypatch.setattr(settings, "dry_run", False) + monkeypatch.setattr(linear_connector, "_api_key", "token") monkeypatch.setattr(linear_connector, "list_issues", fake_list_issues) result = await mcp_server._linear_scan("stale_sweep", "team-123", "In Progress", 25) @@ -505,7 +619,9 @@ async def fake_get_issue_comments(issue_id: str, limit: int = 20): from agent_pm.connectors.linear import linear_connector + monkeypatch.setattr(settings, "dry_run", False) monkeypatch.setattr(settings, "linear_team_ids", ["team-1", "team-2"]) + monkeypatch.setattr(linear_connector, "_api_key", "token") monkeypatch.setattr(linear_connector, "list_issues", fake_list_issues) monkeypatch.setattr(linear_connector, "get_issue_comments", fake_get_issue_comments) diff --git a/tests/test_procedure_runner.py b/tests/test_procedure_runner.py index 14a9f76..385234f 100644 --- a/tests/test_procedure_runner.py +++ b/tests/test_procedure_runner.py @@ -266,6 +266,9 @@ async def fake_error_counts(*, stats_period: str = "7d", project: str | None = N captured["project"] = project return {"data": []} + monkeypatch.setattr(settings, "dry_run", False) + monkeypatch.setattr(procedure_runner.sentry_connector, "_auth_token", "token") + monkeypatch.setattr(procedure_runner.sentry_connector, "_org_slug", "org") monkeypatch.setattr(procedure_runner.sentry_connector, "list_issues", fake_list_issues) monkeypatch.setattr(procedure_runner.sentry_connector, "error_counts", fake_error_counts) @@ -278,6 +281,21 @@ async def fake_error_counts(*, stats_period: str = "7d", project: str | None = N assert result["query"] == "is:resolved environment:prod" +@pytest.mark.asyncio +async def test_run_sentry_scan_surfaces_skipped_connector_state(monkeypatch): + import agent_pm.procedure_runner as procedure_runner + + monkeypatch.setattr(settings, "dry_run", False) + monkeypatch.setattr(procedure_runner.sentry_connector, "_auth_token", None) + + result = await procedure_runner._run_sentry_scan("List unresolved Sentry issues.") + + assert result["issues"] == [] + assert result["count"] == 0 + assert result["error"] == "Sentry connector is not configured." + assert "dry_run" not in result + + @pytest.mark.asyncio async def test_linear_scan_respects_assignment_and_stale_rules(monkeypatch): import agent_pm.procedure_runner as procedure_runner @@ -328,7 +346,9 @@ async def fake_get_issue_comments(issue_id: str, limit: int = 20): return [] return [{"createdAt": now.isoformat().replace("+00:00", "Z")}] + monkeypatch.setattr(settings, "dry_run", False) monkeypatch.setattr(settings, "jira_email", "me@example.com") + monkeypatch.setattr(procedure_runner.linear_connector, "_api_key", "token") monkeypatch.setattr(procedure_runner.linear_connector, "list_issues", fake_list_issues) monkeypatch.setattr(procedure_runner.linear_connector, "get_issue_comments", fake_get_issue_comments) @@ -359,7 +379,9 @@ async def fake_list_issues(*, team_id=None, assignee_email=None, state=None, ord captured.append(team_id) return [{"id": f"{team_id}-1", "identifier": f"{team_id}-1", "title": f"Issue for {team_id}"}] + monkeypatch.setattr(settings, "dry_run", False) monkeypatch.setattr(settings, "linear_team_ids", ["team-1", "team-2"]) + monkeypatch.setattr(procedure_runner.linear_connector, "_api_key", "token") monkeypatch.setattr(procedure_runner.linear_connector, "list_issues", fake_list_issues) result = await procedure_runner._run_linear_scan("List all issues sorted by updatedAt ascending.") @@ -369,6 +391,28 @@ async def fake_list_issues(*, team_id=None, assignee_email=None, state=None, ord assert {issue["identifier"] for issue in result["issues"]} == {"team-1-1", "team-2-1"} +@pytest.mark.asyncio +async def test_list_linear_issues_for_scan_applies_limit_across_teams(monkeypatch): + import agent_pm.procedure_runner as procedure_runner + + captured_limits: list[int | None] = [] + + async def fake_list_issues(*, team_id=None, assignee_email=None, state=None, order_by="updatedAt", limit=50): + captured_limits.append(limit) + return [ + {"id": f"{team_id}-1", "identifier": f"{team_id}-1"}, + {"id": f"{team_id}-2", "identifier": f"{team_id}-2"}, + ] + + monkeypatch.setattr(settings, "linear_team_ids", ["team-1", "team-2"]) + monkeypatch.setattr(procedure_runner.linear_connector, "list_issues", fake_list_issues) + + issues = await procedure_runner._list_linear_issues_for_scan(limit=3) + + assert captured_limits == [3, 1] + assert [issue["identifier"] for issue in issues] == ["team-1-1", "team-1-2", "team-2-1"] + + @pytest.mark.asyncio async def test_weekly_progress_review_uses_calendar_scan(monkeypatch): import agent_pm.procedure_runner as procedure_runner @@ -377,8 +421,9 @@ async def test_weekly_progress_review_uses_calendar_scan(monkeypatch): captured: dict[str, Any] = {} class _FakeCalendarConnector: - async def sync(self, *, since=None): + async def sync(self, *, since=None, until=None): captured["since"] = since + captured["until"] = until return [ { "items": [ @@ -407,6 +452,7 @@ async def sync(self, *, since=None): procedure = loader.load()["weekly_progress_review"] assert captured["since"] == now.replace(hour=0, minute=0, second=0, microsecond=0) + assert captured["until"] == captured["since"] + timedelta(days=7) assert result["count"] == 2 assert procedure["steps"][0]["run"] == "calendar_scan" assert "calendar_overview" in procedure["steps"][4]["input"] @@ -467,3 +513,17 @@ async def fake_list_issues(**kwargs): assert result["issues"] == [] assert result["stale"] == [] assert result["error"] == "Linear 'assigned to me' scans require JIRA_EMAIL or GOOGLE_CALENDAR_DELEGATED_USER." + + +@pytest.mark.asyncio +async def test_linear_scan_surfaces_skipped_connector_state(monkeypatch): + import agent_pm.procedure_runner as procedure_runner + + monkeypatch.setattr(settings, "dry_run", False) + monkeypatch.setattr(procedure_runner.linear_connector, "_api_key", None) + + result = await procedure_runner._run_linear_scan("List all issues.") + + assert result["issues"] == [] + assert result["count"] == 0 + assert result["error"] == "Linear connector is not configured." From 1c5ae7773fc93684f8b7ff35fccb37b508431b38 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 20 Jun 2026 03:39:51 +0000 Subject: [PATCH 15/17] Fix procedure scan config errors --- agent_pm/procedure_runner.py | 41 +++++++++++++++++++++++----------- tests/test_procedure_runner.py | 37 ++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 13 deletions(-) diff --git a/agent_pm/procedure_runner.py b/agent_pm/procedure_runner.py index bce122c..25db07a 100644 --- a/agent_pm/procedure_runner.py +++ b/agent_pm/procedure_runner.py @@ -207,7 +207,20 @@ async def _run_calendar_scan(instruction: str) -> dict[str, Any]: now = datetime.now(tz=UTC) window_start = _calendar_window_start(instruction, now) window_end = window_start + timedelta(days=window_days) - payloads = await CalendarConnector().sync(since=window_start, until=window_end) + skipped_result = { + "events": [], + "count": 0, + "window_days": window_days, + "calendar_id": settings.calendar_id, + } + if settings.dry_run: + return skipped_result + + connector = CalendarConnector() + if not connector.enabled: + return {**skipped_result, "error": "Calendar connector is not configured."} + + payloads = await connector.sync(since=window_start, until=window_end) events: list[dict[str, Any]] = [] if payloads and isinstance(payloads[0], dict): @@ -218,12 +231,7 @@ async def _run_calendar_scan(instruction: str) -> dict[str, Any]: items = first.get("items") or [] events = [event for event in items if _event_is_within_window(event, window_start, window_days)] - return { - "events": events, - "count": len(events), - "window_days": window_days, - "calendar_id": settings.calendar_id, - } + return {**skipped_result, "events": events, "count": len(events)} async def _run_linear_scan(instruction: str) -> dict[str, Any]: @@ -292,15 +300,22 @@ async def _run_github_pr_scan(instruction: str) -> dict[str, Any]: include_agent_authors = _instruction_requests_agent_authors(instruction) fetch_diffs = _instruction_requests_pr_diffs(instruction) - if settings.dry_run or not settings.github_token or not repos: + skipped_result = { + "prs": [], + "count": 0, + "repositories": repos, + "author": author, + "include_agent_authors": include_agent_authors, + "last_hours": hours, + "fetch_diffs": fetch_diffs, + } + if settings.dry_run: return { "dry_run": True, - "repositories": repos, - "author": author, - "include_agent_authors": include_agent_authors, - "last_hours": hours, - "fetch_diffs": fetch_diffs, + **skipped_result, } + if not settings.github_token or not repos: + return {**skipped_result, "error": "GitHub PR scan is not configured."} headers = { "Authorization": f"Bearer {settings.github_token}", diff --git a/tests/test_procedure_runner.py b/tests/test_procedure_runner.py index 385234f..6b9f966 100644 --- a/tests/test_procedure_runner.py +++ b/tests/test_procedure_runner.py @@ -108,6 +108,22 @@ async def test_github_pr_scan_merges_evalops_defaults_with_explicit_repo(monkeyp assert result["repositories"] == ["evalops/platform", "evalops/deploy", "haasonsaas/homelab"] +@pytest.mark.asyncio +async def test_github_pr_scan_surfaces_missing_configuration(monkeypatch): + import agent_pm.procedure_runner as procedure_runner + + monkeypatch.setattr(settings, "dry_run", False) + monkeypatch.setattr(settings, "github_token", None) + monkeypatch.setattr(settings, "github_repositories", ["evalops/platform"]) + + result = await procedure_runner._run_github_pr_scan("Scan open PRs across evalops repos.") + + assert result["prs"] == [] + assert result["count"] == 0 + assert result["error"] == "GitHub PR scan is not configured." + assert "dry_run" not in result + + @pytest.mark.asyncio async def test_github_pr_scan_paginates_all_open_pr_pages(monkeypatch): import agent_pm.procedure_runner as procedure_runner @@ -421,6 +437,10 @@ async def test_weekly_progress_review_uses_calendar_scan(monkeypatch): captured: dict[str, Any] = {} class _FakeCalendarConnector: + @property + def enabled(self) -> bool: + return True + async def sync(self, *, since=None, until=None): captured["since"] = since captured["until"] = until @@ -458,6 +478,23 @@ async def sync(self, *, since=None, until=None): assert "calendar_overview" in procedure["steps"][4]["input"] +@pytest.mark.asyncio +async def test_run_calendar_scan_surfaces_missing_configuration(monkeypatch): + import agent_pm.procedure_runner as procedure_runner + + monkeypatch.setattr(settings, "dry_run", False) + monkeypatch.setattr(settings, "calendar_id", None) + monkeypatch.setattr(settings, "google_service_account_json", None) + monkeypatch.setattr(settings, "google_service_account_file", None) + + result = await procedure_runner._run_calendar_scan("List upcoming calendar events for this week.") + + assert result["events"] == [] + assert result["count"] == 0 + assert result["error"] == "Calendar connector is not configured." + assert "dry_run" not in result + + @pytest.mark.asyncio async def test_execute_procedure_returns_step_outputs(monkeypatch): import agent_pm.procedure_runner as procedure_runner From 64172d041e1663bc295c398be82330d56e148546 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 20 Jun 2026 03:41:11 +0000 Subject: [PATCH 16/17] Preserve calendar scan dry-run behavior --- agent_pm/procedure_runner.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/agent_pm/procedure_runner.py b/agent_pm/procedure_runner.py index 25db07a..025df22 100644 --- a/agent_pm/procedure_runner.py +++ b/agent_pm/procedure_runner.py @@ -213,11 +213,8 @@ async def _run_calendar_scan(instruction: str) -> dict[str, Any]: "window_days": window_days, "calendar_id": settings.calendar_id, } - if settings.dry_run: - return skipped_result - connector = CalendarConnector() - if not connector.enabled: + if not settings.dry_run and not connector.enabled: return {**skipped_result, "error": "Calendar connector is not configured."} payloads = await connector.sync(since=window_start, until=window_end) From 834ae83287577340f17038eecb143e0cad8ad235 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 20 Jun 2026 03:49:48 +0000 Subject: [PATCH 17/17] Fix procedure runner and GitHub scan safeguards --- agent_pm/mcp_server.py | 66 ++++++++++++++++++++++++---------- agent_pm/procedure_runner.py | 39 ++++++++++++++++++-- tests/test_connectors.py | 30 +++++++++++----- tests/test_procedure_runner.py | 54 ++++++++++++++++++++++++++-- 4 files changed, 157 insertions(+), 32 deletions(-) diff --git a/agent_pm/mcp_server.py b/agent_pm/mcp_server.py index ba59cfe..ce7276f 100644 --- a/agent_pm/mcp_server.py +++ b/agent_pm/mcp_server.py @@ -182,7 +182,7 @@ async def _github_pr_scan(org: str, author: str | None, state: str, limit: int) from agent_pm.settings import settings try: - if settings.dry_run or not settings.github_token: + if settings.dry_run: return { "dry_run": True, "prs": [], @@ -193,6 +193,17 @@ async def _github_pr_scan(org: str, author: str | None, state: str, limit: int) "state": state, "limit": limit, } + if not settings.github_token: + return { + "prs": [], + "total": 0, + "org": org, + "repositories": settings.github_repositories or [], + "author": author, + "state": state, + "limit": limit, + "error": "GitHub PR scan is not configured.", + } # Use the existing GitHub connector import httpx @@ -205,31 +216,48 @@ async def _github_pr_scan(org: str, author: str | None, state: str, limit: int) params: dict[str, Any] = {"per_page": limit, "state": state} query_parts = ["is:pr", f"state:{state}"] if author: + from agent_pm.procedure_runner import _github_author_search_candidates + if repos: query_parts.extend(f"repo:{repo}" for repo in repos) else: query_parts.append(f"org:{org}") - query_parts.append(f"author:{author}") - params["q"] = " ".join(query_parts) all_items: list[dict[str, Any]] = [] - page = 1 + total_count = 0 + seen_items: set[tuple[str, Any]] = set() async with httpx.AsyncClient() as client: - while True: - page_params = {**params, "page": page} - resp = await client.get( - "https://api.github.com/search/issues", - headers=headers, - params=page_params, - timeout=30, - ) - resp.raise_for_status() - data = resp.json() - items = data.get("items", []) - all_items.extend(items) - if len(items) < params["per_page"] or len(all_items) >= limit: + for author_candidate in _github_author_search_candidates(author): + page = 1 + while True: + page_params = { + **params, + "page": page, + "q": " ".join([*query_parts, f"author:{author_candidate}"]), + } + resp = await client.get( + "https://api.github.com/search/issues", + headers=headers, + params=page_params, + timeout=30, + ) + resp.raise_for_status() + data = resp.json() + items = data.get("items", []) + total_count += int(data.get("total_count", 0)) + for item in items: + item_key = (str(item.get("repository_url", "")), item.get("number")) + if item_key in seen_items: + continue + seen_items.add(item_key) + all_items.append(item) + if len(all_items) >= limit: + break + if len(items) < params["per_page"] or len(all_items) >= limit: + break + page += 1 + if len(all_items) >= limit: break - page += 1 - return {"prs": all_items[:limit], "total": data.get("total_count", 0)} + return {"prs": all_items[:limit], "total": total_count} else: from agent_pm.procedure_runner import _fetch_repository_pull_requests diff --git a/agent_pm/procedure_runner.py b/agent_pm/procedure_runner.py index 025df22..b5511ae 100644 --- a/agent_pm/procedure_runner.py +++ b/agent_pm/procedure_runner.py @@ -13,6 +13,7 @@ import httpx +from agent_pm.api.guardrails import guardrail_context from agent_pm.clients.calendar_client import calendar_client from agent_pm.clients.jira_client import jira_client from agent_pm.clients.openai_client import openai_client @@ -44,6 +45,7 @@ "evalops/deploy", "evalops/maestro-internal", ] +_DEFAULT_GITHUB_PR_SCAN_LIMIT = 20 _MODEL_STEP_SYSTEM_PROMPT = ( "You are executing an operational procedure. Follow the instruction exactly, use the" " provided step outputs as source material, and return concise markdown or plain text." @@ -76,7 +78,10 @@ async def execute_procedure(name: str, *, dry_run: bool = False) -> dict[str, An for step in steps: step_id = step.get("id") or step.get("run") or f"step_{len(context)}" - result = await _execute_step(step, context) + if _step_condition_satisfied(step): + result = await _execute_step(step, context) + else: + result = _skipped_step_result(step) context[step_id] = result _store_step_aliases(step_id, result, context) @@ -93,6 +98,25 @@ async def _generate_plan_result(name: str, proc: dict[str, Any]) -> dict[str, An return await asyncio.to_thread(generate_plan_for_idea, idea) +def _step_condition_satisfied(step: dict[str, Any]) -> bool: + when = step.get("when") + if when is None: + return True + if isinstance(when, bool): + return when + if isinstance(when, str) and when.strip().lower() == "approved": + return guardrail_context.approved + return bool(when) + + +def _skipped_step_result(step: dict[str, Any]) -> dict[str, Any]: + result = {"skipped": True} + when = step.get("when") + if when is not None: + result["when"] = when + return result + + async def _execute_step(step: dict[str, Any], context: dict[str, Any]) -> Any: items_expr = step.get("foreach") if items_expr is None: @@ -213,8 +237,10 @@ async def _run_calendar_scan(instruction: str) -> dict[str, Any]: "window_days": window_days, "calendar_id": settings.calendar_id, } + if settings.dry_run: + return {**skipped_result, "dry_run": True} connector = CalendarConnector() - if not settings.dry_run and not connector.enabled: + if not connector.enabled: return {**skipped_result, "error": "Calendar connector is not configured."} payloads = await connector.sync(since=window_start, until=window_end) @@ -327,6 +353,7 @@ async def _run_github_pr_scan(instruction: str) -> dict[str, Any]: headers, state="open", per_page=20, + max_results=_DEFAULT_GITHUB_PR_SCAN_LIMIT, ) for pr in repo_pulls: if _include_pull_request( @@ -656,6 +683,14 @@ def _normalize_github_login(login: str) -> str: return normalized +def _github_author_search_candidates(author: str) -> list[str]: + normalized = _normalize_github_login(author) + candidates = [str(author).strip(), normalized] + if _is_agent_login(normalized): + candidates.append(f"{normalized}[bot]") + return list(dict.fromkeys(candidate for candidate in candidates if candidate)) + + def _is_agent_login(login: str) -> bool: return login in _KNOWN_AGENT_LOGINS or login.endswith(("-bot", "_bot", "-agent", "_agent")) diff --git a/tests/test_connectors.py b/tests/test_connectors.py index 23da97b..50aadd1 100644 --- a/tests/test_connectors.py +++ b/tests/test_connectors.py @@ -642,7 +642,7 @@ async def fake_get_issue_comments(issue_id: str, limit: int = 20): @pytest.mark.asyncio -async def test_mcp_github_pr_scan_author_uses_configured_repos(monkeypatch): +async def test_mcp_github_pr_scan_author_expands_bot_login_for_configured_repos(monkeypatch): import httpx import agent_pm.mcp_server as mcp_server @@ -668,6 +668,18 @@ async def __aexit__(self, exc_type, exc, tb): async def get(self, url: str, *, headers=None, params=None, timeout=None): calls.append({"url": url, "headers": headers, "params": params, "timeout": timeout}) + if "author:dependabot[bot]" in params["q"]: + return _FakeResponse( + payload={ + "items": [ + { + "number": 17, + "repository_url": "https://api.github.com/repos/evalops/platform", + } + ], + "total_count": 1, + } + ) return _FakeResponse(payload={"items": [], "total_count": 0}) monkeypatch.setattr(settings, "dry_run", False) @@ -677,15 +689,17 @@ async def get(self, url: str, *, headers=None, params=None, timeout=None): result = await mcp_server._github_pr_scan("evalops", "dependabot", "open", 20) - assert result == {"prs": [], "total": 0} - assert calls[0]["url"] == "https://api.github.com/search/issues" - assert "repo:evalops/platform" in calls[0]["params"]["q"] - assert "repo:haasonsaas/homelab" in calls[0]["params"]["q"] - assert "org:evalops" not in calls[0]["params"]["q"] + assert result["prs"] == [{"number": 17, "repository_url": "https://api.github.com/repos/evalops/platform"}] + assert result["total"] == 1 + assert all(call["url"] == "https://api.github.com/search/issues" for call in calls) + assert all("repo:evalops/platform" in call["params"]["q"] for call in calls) + assert all("repo:haasonsaas/homelab" in call["params"]["q"] for call in calls) + assert all("org:evalops" not in call["params"]["q"] for call in calls) + assert any("author:dependabot[bot]" in call["params"]["q"] for call in calls) @pytest.mark.asyncio -async def test_mcp_github_pr_scan_without_token_returns_empty_dry_run(monkeypatch): +async def test_mcp_github_pr_scan_without_token_surfaces_configuration_error(monkeypatch): import httpx import agent_pm.mcp_server as mcp_server @@ -698,7 +712,6 @@ async def test_mcp_github_pr_scan_without_token_returns_empty_dry_run(monkeypatc result = await mcp_server._github_pr_scan("evalops", "dependabot", "open", 20) assert result == { - "dry_run": True, "prs": [], "total": 0, "org": "evalops", @@ -706,6 +719,7 @@ async def test_mcp_github_pr_scan_without_token_returns_empty_dry_run(monkeypatc "author": "dependabot", "state": "open", "limit": 20, + "error": "GitHub PR scan is not configured.", } diff --git a/tests/test_procedure_runner.py b/tests/test_procedure_runner.py index 6b9f966..6b79a79 100644 --- a/tests/test_procedure_runner.py +++ b/tests/test_procedure_runner.py @@ -125,7 +125,7 @@ async def test_github_pr_scan_surfaces_missing_configuration(monkeypatch): @pytest.mark.asyncio -async def test_github_pr_scan_paginates_all_open_pr_pages(monkeypatch): +async def test_github_pr_scan_limits_each_repository_fetch(monkeypatch): import agent_pm.procedure_runner as procedure_runner pages = { @@ -163,8 +163,9 @@ async def get( result = await procedure_runner._run_github_pr_scan("Scan open PRs across evalops repos.") - assert result["count"] == 21 - assert [call["params"]["page"] for call in calls] == [1, 2] + assert result["count"] == 20 + assert [pr["number"] for pr in result["prs"]] == list(range(1, 21)) + assert [call["params"]["page"] for call in calls] == [1] @pytest.mark.asyncio @@ -466,6 +467,7 @@ async def sync(self, *, since=None, until=None): } ] + monkeypatch.setattr(settings, "dry_run", False) monkeypatch.setattr(procedure_runner, "CalendarConnector", lambda: _FakeCalendarConnector()) result = await procedure_runner._run_calendar_scan("List upcoming calendar events for this week.") @@ -495,6 +497,21 @@ async def test_run_calendar_scan_surfaces_missing_configuration(monkeypatch): assert "dry_run" not in result +@pytest.mark.asyncio +async def test_run_calendar_scan_marks_dry_run(monkeypatch): + import agent_pm.procedure_runner as procedure_runner + + monkeypatch.setattr(settings, "dry_run", True) + monkeypatch.setattr(settings, "calendar_id", "calendar@example.com") + + result = await procedure_runner._run_calendar_scan("List upcoming calendar events for this week.") + + assert result["events"] == [] + assert result["count"] == 0 + assert result["dry_run"] is True + assert "error" not in result + + @pytest.mark.asyncio async def test_execute_procedure_returns_step_outputs(monkeypatch): import agent_pm.procedure_runner as procedure_runner @@ -528,6 +545,37 @@ async def fake_execute_step(step: dict[str, Any], context: dict[str, Any]) -> An assert result["digest"] == "Digest body" +@pytest.mark.asyncio +async def test_execute_procedure_skips_steps_that_require_approval(monkeypatch): + import agent_pm.procedure_runner as procedure_runner + + procedure = { + "name": "idea_to_prd_to_tickets", + "description": "Draft a PRD and wait for approval before notifying stakeholders.", + "steps": [ + {"id": "draft_prd", "run": "model"}, + {"id": "schedule_review", "run": "schedule_review_event", "when": "approved"}, + ], + } + executed_steps: list[str] = [] + + async def fake_execute_step(step: dict[str, Any], context: dict[str, Any]) -> Any: + executed_steps.append(step["id"]) + if step["id"] == "draft_prd": + return "Draft PRD" + raise AssertionError("Approved-only steps should be skipped without explicit approval") + + monkeypatch.setattr(settings, "dry_run", False) + monkeypatch.setattr(procedure_runner.loader, "load", lambda: {"idea_to_prd_to_tickets": procedure}) + monkeypatch.setattr(procedure_runner.guardrail_context, "approved", False) + monkeypatch.setattr(procedure_runner, "_execute_step", fake_execute_step) + + result = await procedure_runner.execute_procedure("idea_to_prd_to_tickets") + + assert executed_steps == ["draft_prd"] + assert result["schedule_review"] == {"skipped": True, "when": "approved"} + + @pytest.mark.asyncio async def test_linear_scan_requires_configured_email_for_assigned_to_me(monkeypatch): import agent_pm.procedure_runner as procedure_runner