diff --git a/docs/superpowers/plans/v02-execution-plan.md b/docs/superpowers/plans/v02-execution-plan.md new file mode 100644 index 00000000..f5bc5ccc --- /dev/null +++ b/docs/superpowers/plans/v02-execution-plan.md @@ -0,0 +1,20 @@ +# v0.2 Execution Plan + +## Status + +| Feature | Status | Notes | +|---|---|---| +| F1: Per-task budget | ✅ DONE | BudgetGuard already in runtime.py line 121 | +| F2: Config approval gate | TODO | New middleware | +| F3: Audit CLI | TODO | New CLI commands | +| F4: LinearAdapter | TODO | New channel adapter | +| F5: Plugin system | TODO | New directory + CI | +| F6: Issue dedup | TODO | pgvector + embedding | + +## Execution Order + +1. F3: Audit CLI (reads existing data, no schema changes) +2. F2: Config approval gate (new middleware, small scope) +3. F5: Plugin system (scaffolding + 3 builtin plugins) +4. F4: LinearAdapter (new channel, webhook + write-back) +5. F6: Issue dedup (pgvector migration + embedding pipeline) diff --git a/docs/superpowers/plans/v02-f1-per-task-budget.md b/docs/superpowers/plans/v02-f1-per-task-budget.md new file mode 100644 index 00000000..d22b003a --- /dev/null +++ b/docs/superpowers/plans/v02-f1-per-task-budget.md @@ -0,0 +1,41 @@ +# F1: Per-Task Budget Fix + +> Sprint 1, Task T1. Fix: per-task budget only checked at preflight (input-side), not during agent loop execution. + +## Problem + +`BudgetEnforcement` middleware checks monthly/global caps before the task starts, but the per-task cap ($3.00 for fix) is never enforced during the agent loop. A runaway agent can exceed its budget. + +## Solution + +Add `BudgetStepGuard` — a middleware that checks cumulative cost before each agent step. + +### File: `openbot/infrastructure/agents/_budget_middleware.py` + +```python +class BudgetStepGuard: + """Check per-task cost before each agent step. + + Queries cost_meter for cumulative spend on this task_id. + If exceeds per_task_cap_usd → raise BudgetExceeded. + """ + + def __init__(self, task_id: str, cap_usd: float, session_factory): + ... + + async def before_step(self, state) -> None: + total = await self._query_cost() + if total >= self.cap_usd: + raise BudgetExceeded(f"Hit per-task budget (${self.cap_usd:.2f})") +``` + +### Integration Point + +Wire into DeepAgents runtime middleware stack in `openbot/infrastructure/agents/runtime.py`. + +### Tests + +- `tests/unit/infrastructure/agents/test_budget_step_guard.py` + - Under budget → continues + - At/over budget → raises BudgetExceeded + - cost_meter query failure → degrades gracefully (log warning, continue) diff --git a/docs/superpowers/specs/v02-overall-spec.md b/docs/superpowers/specs/v02-overall-spec.md new file mode 100644 index 00000000..b2adeb7a --- /dev/null +++ b/docs/superpowers/specs/v02-overall-spec.md @@ -0,0 +1,26 @@ +# v0.2 Overall Spec + +> 6 features, 3 sprints, 6 weeks. PRD: `docs/prd/openbot-v02-prd.md` + +## Feature List + +| # | Feature | Sprint | Effort | Files | +|---|---|---|---|---| +| F1 | Per-task budget fix | 1 | 2d | `_budget_middleware.py`, tests | +| F2 | Config approval gate | 1 | 1d | `config_approval.py`, tests | +| F3 | Audit CLI | 1 | 2d | `openbot/cli/audit.py`, tests | +| F4 | LinearAdapter | 2 | 5d | `linear.py`, `channel_credentials.py`, webhook route, tests | +| F5 | Plugin system | 2 | 3d | `openbot_plugins/`, CI gate, CONTRIBUTING.md | +| F6 | Issue dedup | 3 | 4d | `dedup.py`, pgvector migration, embedding + rerank, tests | + +## Execution Order + +F1 → F2 → F3 → F4 → F5 → F6 + +## Shared Constraints + +- `make check` must pass after each feature +- No hardcoded values — env vars via `Settings` +- Immutable data patterns (frozen dataclasses) +- Hexagonal architecture — domain ← application ← infrastructure +- Egress scanning on all bot-authored output diff --git a/openbot/application/middleware/config_approval.py b/openbot/application/middleware/config_approval.py new file mode 100644 index 00000000..44905b96 --- /dev/null +++ b/openbot/application/middleware/config_approval.py @@ -0,0 +1,147 @@ +"""Config approval gate — PRD v0.2 §3.2. + +When a PR actually modifies `.openbot/config.yaml`, the bot checks for a +`config-approved` label. Without it, the bot comments and blocks. Detection +is based on the PR **diff** (the real changed files), not on free-text +mentions in the title/body — otherwise the gate is trivially bypassed by +simply not naming the file. + +When the touched lines include high-risk keys (budget / safety / channels / +cancel / rate_limit / egress caps), they're surfaced in the block comment so +the reviewer knows why approval is required. + +This is a preflight-level check — it runs before the workflow starts, +same as budget/rate-limit/cancel gates. +""" + +from __future__ import annotations + +import logging + +from openbot.application.middleware.preflight import ( + MiddlewareDecision, + MiddlewareResult, + PreflightContext, + WorkflowPhase, +) + +_logger = logging.getLogger(__name__) + +# High-risk YAML keys. When a changed line in the config diff starts with one +# of these keys, the change is flagged in the block comment. The gate blocks +# on ANY config.yaml change regardless (fail closed); this list only enriches +# the reason surfaced to the reviewer. +_HIGH_RISK_KEYS = ( + "budget", + "safety", + "channels", + "cancel", + "rate_limit", + "egress_action", + "global_hard_kill_usd", + "monthly_soft_cap_usd", + "per_task", +) + +_APPROVAL_LABEL = "config-approved" +_CONFIG_PATH = ".openbot/config.yaml" + + +def _diff_touches_config(diff: str) -> bool: + """True if the unified diff modifies ``.openbot/config.yaml``.""" + for line in diff.splitlines(): + if line.startswith(("diff --git", "+++ ", "--- ")) and _CONFIG_PATH in line: + return True + return False + + +def _changed_high_risk_keys(diff: str) -> list[str]: + """Return high-risk YAML keys appearing on added/removed lines inside the + ``.openbot/config.yaml`` section of the diff. + + Best-effort: scans the file section between its ``diff --git`` header and + the next file header, inspecting ``+``/``-`` content lines (ignoring the + ``+++``/``---`` file markers) for a leading ``key:`` token. + """ + found: set[str] = set() + in_config = False + for line in diff.splitlines(): + if line.startswith("diff --git"): + in_config = _CONFIG_PATH in line + continue + if not in_config: + continue + if line.startswith(("+++", "---")): + continue + if not line.startswith(("+", "-")): + continue + stripped = line[1:].strip() + key = stripped.split(":", 1)[0].strip() + if key in _HIGH_RISK_KEYS: + found.add(key) + return sorted(found) + + +class ConfigApprovalGate: + """Block config-change PRs that lack the approval label.""" + + name = "config_approval" + + async def __call__(self, ctx: PreflightContext) -> MiddlewareDecision: + """Block PRs that modify .openbot/config.yaml without the approval label.""" + event = ctx.event + + # Only applies to PRs. + if not event.pr_number: + return MiddlewareDecision.proceed() + + # Inspect the actual diff to decide whether config.yaml is touched. + # Fail open on fetch errors (same posture as other soft gates) — a + # transient API failure shouldn't wedge every PR. + try: + diff = await ctx.adapter.get_pr_diff(event, event.pr_number) + except Exception: + _logger.warning( + "config_approval_diff_unavailable", + extra={"pr_number": event.pr_number}, + ) + return MiddlewareDecision.proceed() + + if not _diff_touches_config(diff): + return MiddlewareDecision.proceed() + + # Approval label present → allow. + labels = event.raw.get("pull_request", {}).get("labels", []) + label_names = {lbl.get("name", "") for lbl in labels} + if _APPROVAL_LABEL in label_names: + _logger.info( + "config_approval_granted", + extra={"pr_number": event.pr_number, "labels": sorted(label_names)}, + ) + return MiddlewareDecision.proceed() + + # Config change without approval — block. + high_risk = _changed_high_risk_keys(diff) + _logger.warning( + "config_approval_blocked", + extra={ + "pr_number": event.pr_number, + "labels": sorted(label_names), + "high_risk_keys": high_risk, + }, + ) + risk_note = ( + f" High-risk fields changed: {', '.join(high_risk)}." + if high_risk + else " (budget, safety, channels, cancel)." + ) + return MiddlewareDecision( + result=MiddlewareResult.BLOCKED, + reason="config_approval_required", + comment=( + f"Config change requires the `{_APPROVAL_LABEL}` label. " + f"This PR modifies `{_CONFIG_PATH}`.{risk_note} " + f"Add the `{_APPROVAL_LABEL}` label to proceed." + ), + audit_phase=WorkflowPhase.SKIPPED, + ) diff --git a/openbot/application/use_cases/dedup.py b/openbot/application/use_cases/dedup.py new file mode 100644 index 00000000..1d72ebb4 --- /dev/null +++ b/openbot/application/use_cases/dedup.py @@ -0,0 +1,143 @@ +"""Issue deduplication — PRD v0.2 §2.3. + +Pipeline: + 1. Embed new issue title + body + 2. pgvector ANN search (top-K by cosine similarity) + 3. LLM rerank → top-3 candidates + 4. Bot comment with candidates (never auto-close) + +Dependencies: pgvector extension on Postgres, embedding provider (Voyage/OpenAI). +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from openbot.application.ports.channel_adapter import ChannelAdapterPort + from openbot.domain.events import UnifiedEvent + +_logger = logging.getLogger(__name__) + + +@dataclass(frozen=True, slots=True) +class DedupCandidate: + """A potential duplicate issue.""" + + issue_number: int + title: str + similarity: float + + +@dataclass(frozen=True, slots=True) +class DedupResult: + """Result of dedup analysis.""" + + candidates: list[DedupCandidate] + comment: str + + +def render_dedup_comment(candidates: list[DedupCandidate]) -> str: + """Render a user-facing comment listing potential duplicates.""" + if not candidates: + return "" + + parts: list[str] = [ + ":mag: **Potential duplicates found:**\n", + ] + for c in candidates: + pct = f"{c.similarity * 100:.0f}%" + parts.append(f"- #{c.issue_number} — {c.title} ({pct} similar)") + + parts.append("") + parts.append( + "_Please check if this issue is a duplicate. " + "If so, close this one and continue the discussion in the existing issue._" + ) + return "\n".join(parts) + + +async def check_duplicates( + event: UnifiedEvent, + adapter: ChannelAdapterPort, + *, + issue_title: str, + issue_body: str, + embedding_func: Any | None = None, + search_func: Any | None = None, + rerank_func: Any | None = None, + min_similarity: float = 0.75, + top_k: int = 10, + rerank_top: int = 3, +) -> DedupResult: + """Check for duplicate issues and return candidates. + + This is the use-case entry point. It orchestrates: + 1. Embed the issue + 2. Search for similar issues + 3. Rerank with LLM + 4. Render the comment + + When embedding/search/rerank functions are None (not configured), + returns an empty result gracefully. + """ + if embedding_func is None or search_func is None: + _logger.info("dedup_skipped_not_configured") + return DedupResult(candidates=[], comment="") + + try: + # Step 1: Embed + text = f"{issue_title}\n\n{issue_body}"[:4000] # truncate for embedding + embedding = await embedding_func(text) + + # Step 2: Search + similar = await search_func( + embedding=embedding, + repo=event.repo, + top_k=top_k, + min_similarity=min_similarity, + ) + + if not similar: + return DedupResult(candidates=[], comment="") + + # Step 3: Rerank (optional). When a rerank_func is supplied, let it + # reorder/trim the raw ANN hits (LLM rerank, PRD v0.2 §2.3); otherwise + # fall back to similarity order. Either way, keep the top `rerank_top`. + if rerank_func is not None: + reranked = await rerank_func( + issue_title=issue_title, + issue_body=issue_body, + candidates=similar, + ) + else: + reranked = similar + + candidates = [ + DedupCandidate( + issue_number=s["issue_number"], + title=s["title"], + similarity=s["similarity"], + ) + for s in reranked[:rerank_top] + ] + + # Step 4: Render comment + comment = render_dedup_comment(candidates) + + _logger.info( + "dedup_completed", + extra={ + "repo": event.repo, + "candidates": len(candidates), + "max_similarity": max(c.similarity for c in candidates) if candidates else 0, + }, + ) + + return DedupResult(candidates=candidates, comment=comment) + + except Exception: + _logger.exception("dedup_failed") + return DedupResult(candidates=[], comment="") diff --git a/openbot/core/settings.py b/openbot/core/settings.py index 81e6f25c..c981ba22 100644 --- a/openbot/core/settings.py +++ b/openbot/core/settings.py @@ -75,6 +75,26 @@ class Settings(BaseSettings): ), ) + # ─── Linear channel (PRD v0.2 §2.1) ─── + # Optional second ingress. When the webhook secret is set, the + # /webhook/linear route is mounted and the LinearAdapter is wired + # onto app.state; otherwise the route 503s (same pattern as GitHub). + linear_webhook_secret: SecretStr | None = Field( + default=None, + description=( + "HMAC-SHA-256 signing secret from the Linear webhook settings. " + "If unset, /webhook/linear responds 503 — the Linear channel is " + "off until this is provided." + ), + ) + linear_oauth_token: SecretStr | None = Field( + default=None, + description=( + "Linear OAuth access token used for GraphQL write-back " + "(comments / labels). Receive-only deployments may leave it unset." + ), + ) + # ─── Persistence ─── # If unset, webhook dedup falls open (logs WARNING). Production MUST set it. redis_url: str | None = Field( diff --git a/openbot/entrypoints/api/app.py b/openbot/entrypoints/api/app.py index 36aa4a8c..1a37f4c5 100644 --- a/openbot/entrypoints/api/app.py +++ b/openbot/entrypoints/api/app.py @@ -36,8 +36,10 @@ from openbot.core.settings import Settings, get_settings from openbot.entrypoints.api.routes.github_webhook import router as _webhook_router from openbot.entrypoints.api.routes.health import router as _health_router +from openbot.entrypoints.api.routes.linear_webhook import router as _linear_webhook_router from openbot.infrastructure.adapters.github import GitHubAdapter from openbot.infrastructure.adapters.github_auth import GitHubAppAuth +from openbot.infrastructure.adapters.linear import LinearAdapter from openbot.infrastructure.config_loader import YamlConfigLoader from openbot.infrastructure.observability import init_langfuse, init_langsmith, init_sentry from openbot.infrastructure.persistence import ( @@ -178,12 +180,31 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: ) app.state.github_adapter = github_adapter app.state.raw_github_adapter = raw_github_adapter + + # Linear channel (PRD v0.2 §2.1) — wired only when the webhook secret is + # set. Without it, /webhook/linear 503s via verified_linear_event, mirroring + # the GitHub receive-only fallback. + linear_adapter: LinearAdapter | None = ( + LinearAdapter( + webhook_secret=settings.linear_webhook_secret.get_secret_value(), + oauth_token=( + settings.linear_oauth_token.get_secret_value() + if settings.linear_oauth_token is not None + else None + ), + ) + if settings.linear_webhook_secret is not None + else None + ) + app.state.linear_adapter = linear_adapter + _logger.info( "openbot_startup", extra={ "version": __version__, "webhook_configured": settings.github_webhook_secret is not None, "write_back_configured": auth is not None, + "linear_configured": linear_adapter is not None, "redis_configured": redis_client is not None, "postgres_configured": db_engine is not None, }, @@ -200,6 +221,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: adapter: GitHubAdapter | None = app.state.raw_github_adapter if adapter is not None: await adapter.aclose() + if linear_adapter is not None: + await linear_adapter.aclose() if auth is not None: await auth.aclose() if redis_client is not None: @@ -323,3 +346,4 @@ def _attach_prometheus_metrics(app: FastAPI) -> None: app.include_router(_health_router) app.include_router(_webhook_router) +app.include_router(_linear_webhook_router) diff --git a/openbot/entrypoints/api/deps.py b/openbot/entrypoints/api/deps.py index 1329e428..9fb647e3 100644 --- a/openbot/entrypoints/api/deps.py +++ b/openbot/entrypoints/api/deps.py @@ -1,9 +1,11 @@ """FastAPI dependencies for the API entrypoint. -At the moment this module only exposes the GitHub webhook ingress boundary: -read raw bytes, verify the signature, parse the event, and surface a single -``UnifiedEvent`` to the route handler. Keeping this as a route-local -dependency avoids pushing GitHub-specific trust checks into global middleware. +Exposes webhook ingress boundaries for each channel: +- ``verified_github_event`` — GitHub webhook HMAC verification +- ``verified_linear_event`` — Linear webhook HMAC verification + +Each is route-local to avoid pushing channel-specific trust checks into +global middleware. """ from __future__ import annotations @@ -15,6 +17,7 @@ from openbot.infrastructure.adapters.base import SignatureError from openbot.infrastructure.adapters.github import GitHubAdapter +from openbot.infrastructure.adapters.linear import LinearAdapter if TYPE_CHECKING: from openbot.domain.events import UnifiedEvent @@ -67,3 +70,38 @@ async def verified_github_event(request: Request) -> UnifiedEvent: request.state.github_event = event return event + + +async def verified_linear_event(request: Request) -> UnifiedEvent: + """Return a parsed Linear event after verifying the webhook signature.""" + adapter: LinearAdapter | None = getattr(request.app.state, "linear_adapter", None) + if adapter is None: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "OPENBOT_LINEAR_WEBHOOK_SECRET is not set", + ) + + body = await request.body() + headers = {k.lower(): v for k, v in request.headers.items()} + request.state.webhook_body = body + + try: + adapter.verify_signature(body, headers) + except SignatureError as exc: + _logger.warning( + "linear_webhook_signature_rejected", + extra={"reason": str(exc)}, + ) + raise HTTPException(status.HTTP_401_UNAUTHORIZED, str(exc)) from exc + + try: + event = adapter.parse_event(body, headers) + except Exception as exc: + _logger.warning( + "linear_webhook_payload_unparseable", + extra={"reason": str(exc)}, + ) + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc + + request.state.linear_event = event + return event diff --git a/openbot/entrypoints/api/routes/linear_webhook.py b/openbot/entrypoints/api/routes/linear_webhook.py new file mode 100644 index 00000000..f44ebfdd --- /dev/null +++ b/openbot/entrypoints/api/routes/linear_webhook.py @@ -0,0 +1,47 @@ +"""POST /webhook/linear — signed webhook ingestion for Linear. + +Same pattern as GitHub webhook: verify signature → parse → ingest → 202. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from fastapi import APIRouter, Depends, Request, status + +from openbot.application.use_cases.ingest_webhook import ingest_webhook +from openbot.entrypoints.api.deps import verified_linear_event +from openbot.infrastructure.adapters.linear import LinearAdapter + +if TYPE_CHECKING: + from openbot.domain.events import UnifiedEvent + +router = APIRouter() +_verified_linear_event = Depends(verified_linear_event) + + +@router.post("/webhook/linear", status_code=status.HTTP_202_ACCEPTED) +async def linear_webhook( + request: Request, + event: UnifiedEvent = _verified_linear_event, +) -> dict[str, str | int | bool | None]: + """Receive a Linear webhook. + + Order: verify signature → parse → ingest_webhook → 202. + """ + adapter: LinearAdapter = request.app.state.linear_adapter + + state = request.app.state + result = await ingest_webhook( + event, + adapter, + dedup=state.dedup, + runs_repo=getattr(state, "runs_repo", None), + resource_lock=getattr(state, "resource_lock", None), + cancellation=getattr(state, "cancellation", None), + queue=getattr(state, "queue", None), + github_auth_configured=getattr(state, "github_auth", None) is not None, + redis_client=getattr(state, "redis", None), + ) + + return result.to_response() diff --git a/openbot/entrypoints/cli/audit.py b/openbot/entrypoints/cli/audit.py index 344b2b36..7514f899 100644 --- a/openbot/entrypoints/cli/audit.py +++ b/openbot/entrypoints/cli/audit.py @@ -3,11 +3,13 @@ Read-only operator tool. Closes the v0.2-deferred item from the archived harness spec §1.2 / §8. -Two subcommands: +Subcommands: - python -m openbot.entrypoints.cli.audit cost [--repo R] [--since 30] [--json] - python -m openbot.entrypoints.cli.audit log [--repo R] [--since 7] [--limit 50] [--json] - [--phase started|completed|failed|...] + python -m openbot.entrypoints.cli.audit cost [--repo R] [--since 30] [--json] + python -m openbot.entrypoints.cli.audit log [--repo R] [--since 7] [--limit 50] [--json] + python -m openbot.entrypoints.cli.audit show [--json] + python -m openbot.entrypoints.cli.audit export [--since 30] [--format csv|json] + python -m openbot.entrypoints.cli.audit budget reset [--repo R] [--global] Defaults aim at "show me what just happened" — `cost` summarizes the last 30 days, `log` lists the most recent 50 entries from the last 7 days. @@ -21,6 +23,8 @@ import argparse import asyncio +import csv +import io import json import logging import sys @@ -31,7 +35,7 @@ from decimal import Decimal from typing import Any -from sqlalchemy import case, desc, func, select +from sqlalchemy import case, delete, desc, func, select from sqlalchemy.ext.asyncio import AsyncEngine from openbot.core.settings import get_settings @@ -100,6 +104,24 @@ def to_json(self) -> dict[str, Any]: } +@dataclass(frozen=True, slots=True) +class TaskDetail: + """Full detail view for a single task_id (audit_log + cost_meter rows).""" + + task_id: str + audit_entries: list[dict[str, Any]] + cost_entries: list[dict[str, Any]] + total_cost_usd: Decimal + + def to_json(self) -> dict[str, Any]: + return { + "task_id": self.task_id, + "audit_entries": self.audit_entries, + "cost_entries": self.cost_entries, + "total_cost_usd": str(self.total_cost_usd), + } + + # ────────────────────────────────────────────────────────────────────────── # Queries # ────────────────────────────────────────────────────────────────────────── @@ -206,6 +228,111 @@ async def fetch_audit_rows( ] +async def fetch_task_detail( + engine: AsyncEngine, + task_id: str, +) -> TaskDetail: + """Full detail for a single task_id: audit_log + cost_meter rows.""" + factory = make_session_factory(engine) + async with session_scope(factory) as session: + # Audit entries for this delivery_id + audit_stmt = ( + select(AuditLog).where(AuditLog.delivery_id == task_id).order_by(AuditLog.created_at) + ) + audit_result = await session.execute(audit_stmt) + audit_entries: list[dict[str, Any]] = [] + for r in audit_result.scalars().all(): + entry: dict[str, Any] = { + "created_at": r.created_at.isoformat(), + "phase": str(r.phase), + "workflow": str(r.workflow) if r.workflow else None, + "repo": r.repo, + "actor": r.actor, + "outcome": r.outcome, + } + if r.details: + entry["details"] = r.details + audit_entries.append(entry) + + # Cost entries for this task_id + cost_stmt = ( + select(CostMeter).where(CostMeter.task_id == task_id).order_by(CostMeter.created_at) + ) + cost_result = await session.execute(cost_stmt) + cost_entries: list[dict[str, Any]] = [] + total = Decimal("0") + for r in cost_result.scalars().all(): + cost_entries.append( + { + "created_at": r.created_at.isoformat(), + "feature": str(r.feature), + "model": r.model, + "cost_usd": str(r.cost_usd), + "cost_status": str(r.cost_status), + "prompt_tokens": r.prompt_tokens, + "completion_tokens": r.completion_tokens, + } + ) + if r.cost_status == CostStatus.RECORDED: + total += r.cost_usd + + return TaskDetail( + task_id=task_id, + audit_entries=audit_entries, + cost_entries=cost_entries, + total_cost_usd=total, + ) + + +async def export_cost_rows( + engine: AsyncEngine, + *, + since: datetime, +) -> list[dict[str, Any]]: + """Export all cost_meter rows since `since` as flat dicts for CSV/JSON.""" + factory = make_session_factory(engine) + async with session_scope(factory) as session: + stmt = select(CostMeter).where(CostMeter.created_at >= since).order_by(CostMeter.created_at) + result = await session.execute(stmt) + rows: list[dict[str, Any]] = [] + for r in result.scalars().all(): + rows.append( + { + "created_at": r.created_at.isoformat(), + "task_id": r.task_id, + "repo": r.repo, + "feature": str(r.feature), + "model": r.model, + "cost_usd": str(r.cost_usd), + "cost_status": str(r.cost_status), + "prompt_tokens": r.prompt_tokens, + "completion_tokens": r.completion_tokens, + } + ) + return rows + + +async def reset_budget( + engine: AsyncEngine, + *, + repo: str | None, + global_reset: bool, +) -> int: + """Delete cost_meter rows for reset. + + If global_reset: delete all rows (emergency reset). + If repo: delete rows for that repo only. + Returns count of deleted rows. + """ + factory = make_session_factory(engine) + async with session_scope(factory) as session: + if global_reset: + result = await session.execute(delete(CostMeter)) + else: + result = await session.execute(delete(CostMeter).where(CostMeter.repo == repo)) + return result.rowcount + + # ────────────────────────────────────────────────────────────────────────── # Rendering # ────────────────────────────────────────────────────────────────────────── @@ -268,6 +395,53 @@ def render_audit_markdown(rows: Sequence[AuditRow], *, since: datetime) -> str: return "\n".join(out) +def render_task_detail_markdown(detail: TaskDetail) -> str: + """Markdown view of a single task's audit trail + cost breakdown.""" + out: list[str] = [] + out.append(f"## Task `{detail.task_id}`") + out.append("") + out.append(f"**Total cost (RECORDED):** ${detail.total_cost_usd:.4f}") + out.append("") + + if detail.audit_entries: + out.append("### Audit trail") + out.append("") + out.append("| time (UTC) | phase | workflow | outcome |") + out.append("|------------|-------|----------|---------|") + for e in detail.audit_entries: + out.append( + f"| {e['created_at']} | {e['phase']} | " + f"{e.get('workflow') or '—'} | {(e.get('outcome') or '')[:60]} |" + ) + out.append("") + + if detail.cost_entries: + out.append("### Cost breakdown") + out.append("") + out.append("| time (UTC) | feature | model | cost USD | status | tokens (p/c) |") + out.append("|------------|---------|-------|----------|--------|--------------|") + for e in detail.cost_entries: + out.append( + f"| {e['created_at']} | {e['feature']} | {e['model']} | " + f"{e['cost_usd']} | {e['cost_status']} | " + f"{e['prompt_tokens']}/{e['completion_tokens']} |" + ) + out.append("") + + return "\n".join(out) + + +def export_csv(rows: Sequence[dict[str, Any]]) -> str: + """Convert flat dicts to CSV string.""" + if not rows: + return "" + buf = io.StringIO() + writer = csv.DictWriter(buf, fieldnames=list(rows[0].keys())) + writer.writeheader() + writer.writerows(rows) + return buf.getvalue() + + # ────────────────────────────────────────────────────────────────────────── # CLI plumbing # ────────────────────────────────────────────────────────────────────────── @@ -280,36 +454,42 @@ def _build_parser() -> argparse.ArgumentParser: ) sub = p.add_subparsers(dest="cmd", required=True) + # cost cost = sub.add_parser("cost", help="Cost summary grouped by (repo, feature).") cost.add_argument("--repo", help="Filter to a single repo (owner/name).") - cost.add_argument( - "--since", - type=int, - default=30, - help="How many days back (default 30).", - ) + cost.add_argument("--since", type=int, default=30, help="How many days back (default 30).") cost.add_argument("--json", action="store_true", help="Emit JSON instead of markdown.") + # log log = sub.add_parser("log", help="Recent audit_log entries.") log.add_argument("--repo", help="Filter to a single repo.") log.add_argument( - "--phase", - help="Filter to a single phase (e.g. started / completed / failed / rejected).", - ) - log.add_argument( - "--since", - type=int, - default=7, - help="How many days back (default 7).", - ) - log.add_argument( - "--limit", - type=int, - default=50, - help="Max rows to show (default 50).", + "--phase", help="Filter to a single phase (e.g. started / completed / failed / rejected)." ) + log.add_argument("--since", type=int, default=7, help="How many days back (default 7).") + log.add_argument("--limit", type=int, default=50, help="Max rows to show (default 50).") log.add_argument("--json", action="store_true", help="Emit JSON instead of markdown.") + # show + show = sub.add_parser("show", help="Full detail for a single task.") + show.add_argument("task_id", help="The delivery_id / task_id to inspect.") + show.add_argument("--json", action="store_true", help="Emit JSON instead of markdown.") + + # export + export = sub.add_parser("export", help="Export cost_meter rows.") + export.add_argument("--since", type=int, default=30, help="How many days back (default 30).") + export.add_argument("--format", choices=["csv", "json"], default="csv", help="Output format.") + + # budget reset + budget = sub.add_parser("budget", help="Budget management.") + budget_sub = budget.add_subparsers(dest="budget_cmd", required=True) + reset = budget_sub.add_parser("reset", help="Delete cost_meter rows (emergency reset).") + reset.add_argument("--repo", help="Reset for a specific repo only.") + reset.add_argument( + "--global", action="store_true", dest="global_reset", help="Reset ALL cost rows." + ) + reset.add_argument("--yes", action="store_true", help="Skip confirmation prompt.") + return p @@ -321,17 +501,19 @@ async def _run(argv: list[str]) -> int: print("OPENBOT_POSTGRES_URL is not set.", file=sys.stderr) return 2 - since = datetime.now(UTC) - timedelta(days=args.since) engine = make_engine(settings.postgres_url, echo=settings.debug) try: if args.cmd == "cost": + since = datetime.now(UTC) - timedelta(days=args.since) rows = await fetch_cost_rows(engine, repo=args.repo, since=since) if args.json: print(json.dumps([r.to_json() for r in rows], ensure_ascii=False, indent=2)) else: print(render_cost_markdown(rows, since=since)) return 0 + if args.cmd == "log": + since = datetime.now(UTC) - timedelta(days=args.since) rows = await fetch_audit_rows( engine, repo=args.repo, @@ -344,9 +526,40 @@ async def _run(argv: list[str]) -> int: else: print(render_audit_markdown(rows, since=since)) return 0 + + if args.cmd == "show": + detail = await fetch_task_detail(engine, args.task_id) + if args.json: + print(json.dumps(detail.to_json(), ensure_ascii=False, indent=2)) + else: + print(render_task_detail_markdown(detail)) + return 0 + + if args.cmd == "export": + since = datetime.now(UTC) - timedelta(days=args.since) + rows = await export_cost_rows(engine, since=since) + if args.format == "csv": + print(export_csv(rows)) + else: + print(json.dumps(rows, ensure_ascii=False, indent=2)) + return 0 + + if args.cmd == "budget" and args.budget_cmd == "reset": + if not args.repo and not args.global_reset: + print("Specify --repo or --global", file=sys.stderr) + return 2 + if not args.yes: + scope = "ALL repos (GLOBAL)" if args.global_reset else f"repo={args.repo}" + confirm = input(f"Delete all cost_meter rows for {scope}? [y/N] ") + if confirm.lower() != "y": + print("Aborted.") + return 1 + count = await reset_budget(engine, repo=args.repo, global_reset=args.global_reset) + print(f"Deleted {count} cost_meter rows.") + return 0 finally: await engine.dispose() - return 1 # unreachable; argparse already enforced `required=True` + return 1 def main() -> int: diff --git a/openbot/infrastructure/adapters/linear.py b/openbot/infrastructure/adapters/linear.py new file mode 100644 index 00000000..d8966931 --- /dev/null +++ b/openbot/infrastructure/adapters/linear.py @@ -0,0 +1,328 @@ +"""LinearAdapter — verify Linear webhooks + write back via GraphQL API. + +Linear webhook reference: https://developers.linear.app/docs/webhooks +Linear GraphQL reference: https://developers.linear.app/docs/graphql + +PRD v0.2 §2.1: LinearAdapter implements ChannelAdapter so Linear issues +can trigger fix workflows that open PRs on GitHub and write back to Linear. + +Webhook signature: HMAC-SHA256 of the raw body using the webhook signing key. +Header: `linear-signature`. + +Event mapping: + - Issue.create → ISSUE_OPENED + - Issue.update → ISSUE_EDITED (if description changed) + - IssueComment.create → ISSUE_COMMENT_CREATED +""" + +from __future__ import annotations + +import contextlib +import hashlib +import hmac +import json +import logging +from collections.abc import Mapping +from typing import Any, Final + +import httpx + +from openbot.domain.events import EventKind, UnifiedEvent +from openbot.infrastructure.adapters.base import ChannelAdapter, SignatureError + +_logger = logging.getLogger(__name__) + +_LINEAR_SIGNATURE_HEADER: Final = "linear-signature" + +# Linear webhook action → EventKind +_EVENT_TABLE: Final[dict[str, EventKind]] = { + "Issue.create": EventKind.ISSUE_OPENED, + "Issue.update": EventKind.ISSUE_EDITED, + "IssueComment.create": EventKind.ISSUE_COMMENT_CREATED, +} + +_GRAPHQL_URL: Final = "https://api.linear.app/graphql" + + +class LinearAdapter(ChannelAdapter): + """Linear channel adapter — webhook receive + GraphQL write-back.""" + + name = "linear" + + def __init__( + self, + *, + webhook_secret: str, + oauth_token: str | None = None, + ) -> None: + self._webhook_secret = webhook_secret.encode() + self._oauth_token = oauth_token + self._http: httpx.AsyncClient | None = None + + def _get_http(self) -> httpx.AsyncClient: + if self._http is None: + headers = {"Content-Type": "application/json"} + if self._oauth_token: + headers["Authorization"] = self._oauth_token + self._http = httpx.AsyncClient( + base_url=_GRAPHQL_URL, + headers=headers, + timeout=10.0, + ) + return self._http + + async def aclose(self) -> None: + """Close the cached GraphQL client. Called on app shutdown.""" + if self._http is not None: + await self._http.aclose() + self._http = None + + def verify_signature(self, body: bytes, headers: Mapping[str, str]) -> None: + """Verify Linear webhook HMAC-SHA256 signature. + + Linear signs the raw body with the webhook signing key and sends + the hex digest in the `linear-signature` header. + """ + signature = headers.get(_LINEAR_SIGNATURE_HEADER, "") + if not signature: + raise SignatureError("Missing linear-signature header") + + expected = hmac.new(self._webhook_secret, body, hashlib.sha256).hexdigest() + if not hmac.compare_digest(signature, expected): + raise SignatureError("Invalid Linear webhook signature") + + def parse_event(self, body: bytes, headers: Mapping[str, str]) -> UnifiedEvent: + """Parse Linear webhook payload into UnifiedEvent. + + Linear webhook payload shape: + { + "action": "Issue.create" | "Issue.update" | ..., + "data": { ... }, + "url": "https://...", + "type": "Issue" | "IssueComment" | ... + } + """ + payload = json.loads(body) + action = payload.get("action", "") + data = payload.get("data", {}) + + kind = _EVENT_TABLE.get(action, EventKind.UNKNOWN) + + # Extract issue number from Linear's `identifier` field (e.g., "ENG-123") + issue_identifier = data.get("identifier", "") + issue_number = None + if issue_identifier and "-" in issue_identifier: + with contextlib.suppress(ValueError): + issue_number = int(issue_identifier.split("-")[-1]) + + # Extract repo from data or url + repo = data.get("team", {}).get("key", "linear") + actor = data.get("creator", {}).get("name", "unknown") + + # Comment body for chat events + comment_body = None + if "IssueComment" in action: + comment_body = data.get("body", "") + + return UnifiedEvent( + channel="linear", + delivery_id=payload.get("webhookId", ""), + kind=kind, + repo=repo, + actor=actor, + issue_number=issue_number, + comment_body=comment_body, + raw=payload, + ) + + async def reply(self, event: UnifiedEvent, message: str) -> dict[str, Any]: + """Post a comment on the Linear issue via GraphQL.""" + issue_id = event.raw.get("data", {}).get("id") + if not issue_id: + _logger.warning("linear_reply_no_issue_id", extra={"delivery_id": event.delivery_id}) + return {} + + mutation = """ + mutation CommentCreate($input: CommentCreateInput!) { + commentCreate(input: $input) { + success + comment { id body } + } + } + """ + variables = { + "input": { + "issueId": issue_id, + "body": message, + } + } + + try: + http = self._get_http() + resp = await http.post("", json={"query": mutation, "variables": variables}) + resp.raise_for_status() + result = resp.json() + if result.get("errors"): + _logger.error("linear_graphql_error", extra={"errors": result["errors"]}) + return result.get("data", {}).get("commentCreate", {}) + except httpx.HTTPError: + _logger.exception("linear_reply_failed", extra={"issue_id": issue_id}) + return {} + + async def update_comment( + self, + event: UnifiedEvent, + comment_id: int, + message: str, + ) -> dict[str, Any]: + """Update an existing Linear comment via GraphQL.""" + mutation = """ + mutation CommentUpdate($id: String!, $input: CommentUpdateInput!) { + commentUpdate(id: $id, input: $input) { + success + comment { id body } + } + } + """ + variables = { + "id": str(comment_id), + "input": {"body": message}, + } + + try: + http = self._get_http() + resp = await http.post("", json={"query": mutation, "variables": variables}) + resp.raise_for_status() + return resp.json().get("data", {}).get("commentUpdate", {}) + except httpx.HTTPError: + _logger.exception("linear_update_comment_failed") + return {} + + async def get_issue_labels(self, event: UnifiedEvent, number: int) -> frozenset[str]: + """Return labels on a Linear issue. Stubs as empty for now.""" + return frozenset() + + async def get_pr_comments(self, event: UnifiedEvent, pr_number: int) -> list[dict[str, Any]]: + """Linear doesn't have PRs in the same sense. Return empty.""" + return [] + + async def get_actor_role(self, event: UnifiedEvent, login: str | None = None) -> str: + """Return actor role. Linear uses different permission model.""" + return "write" + + async def update_check_run( + self, event: UnifiedEvent, check_run_id: int, **kwargs: Any + ) -> dict[str, Any]: + """Linear doesn't have check runs. No-op.""" + return {} + + async def fetch_repo_file(self, event: UnifiedEvent, path: str) -> bytes | None: + """Linear doesn't expose repo files. Return None.""" + return None + + async def get_pr_diff(self, event: UnifiedEvent, pr_number: int) -> str: + """Linear doesn't have PRs. Return empty.""" + return "" + + async def read_file(self, event: UnifiedEvent, path: str) -> str: + """Linear doesn't expose repo files. Return empty.""" + return "" + + async def grep_repo( + self, + event: UnifiedEvent, + *, + pattern: str, + path_glob: str | None = None, + max_matches: int = 20, + ) -> list[str]: + """Linear doesn't expose code search. Return empty.""" + return [] + + async def add_label(self, event: UnifiedEvent, *labels: str) -> list[dict[str, Any]]: + """Add labels to a Linear issue via GraphQL.""" + issue_id = event.raw.get("data", {}).get("id") + if not issue_id: + return [] + + # Linear labels need to be created/found first, then added to the issue. + # Simplified: just log the intent. + _logger.info("linear_add_label_stub", extra={"issue_id": issue_id, "labels": labels}) + return [] + + async def create_pr_review( + self, event: UnifiedEvent, pr_number: int, **kwargs: Any + ) -> dict[str, Any]: + """Linear doesn't have PR reviews. No-op.""" + return {} + + async def get_issue(self, event: UnifiedEvent, issue_number: int) -> dict[str, Any]: + """Fetch Linear issue details via GraphQL.""" + query = """ + query Issue($id: String!) { + issue(id: $id) { + title description + comments { nodes { body user { name } } } + team { key } + } + } + """ + issue_id = event.raw.get("data", {}).get("id") + if not issue_id: + return { + "title": "", + "body": "", + "comments": [], + "base_sha": "", + "default_branch": "", + "clone_url": "", + } + + try: + http = self._get_http() + resp = await http.post("", json={"query": query, "variables": {"id": issue_id}}) + resp.raise_for_status() + data = resp.json().get("data", {}).get("issue", {}) + return { + "title": data.get("title", ""), + "body": data.get("description", ""), + "comments": [ + {"author": c.get("user", {}).get("name", ""), "body": c.get("body", "")} + for c in data.get("comments", {}).get("nodes", []) + ], + "base_sha": "", + "default_branch": "", + "clone_url": "", + } + except httpx.HTTPError: + _logger.exception("linear_get_issue_failed") + return { + "title": "", + "body": "", + "comments": [], + "base_sha": "", + "default_branch": "", + "clone_url": "", + } + + async def create_branch(self, event: UnifiedEvent, branch_ref: str, from_sha: str) -> None: + """Linear doesn't manage git branches. No-op.""" + pass + + async def open_pull_request(self, event: UnifiedEvent, **kwargs: Any) -> dict[str, Any]: + """Linear doesn't have PRs. The fix workflow opens PRs on GitHub instead.""" + return {} + + async def get_pull_request(self, event: UnifiedEvent, pr_number: int) -> dict[str, Any]: + """Linear doesn't have PRs. Return empty.""" + return {} + + async def get_default_branch_sha(self, event: UnifiedEvent) -> str: + """Linear doesn't expose git refs. Return empty.""" + return "" + + async def get_installation_token(self, event: UnifiedEvent) -> str: + """Linear uses OAuth tokens, not installation tokens.""" + if not self._oauth_token: + raise RuntimeError("LinearAdapter constructed without OAuth token") + return self._oauth_token diff --git a/openbot/infrastructure/llm/embedding.py b/openbot/infrastructure/llm/embedding.py new file mode 100644 index 00000000..69a18e8f --- /dev/null +++ b/openbot/infrastructure/llm/embedding.py @@ -0,0 +1,149 @@ +"""Embedding service for issue dedup — PRD v0.2 §2.3. + +Supports Voyage-3-large (primary) and OpenAI text-embedding-3-large (fallback). +Provider is selected via OPENBOT_EMBEDDING_PROVIDER env var. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +import httpx + +from openbot.core.settings import get_settings + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncEngine + +_logger = logging.getLogger(__name__) + +_VOYAGE_URL = "https://api.voyageai.com/v1/embeddings" +_OPENAI_URL = "https://api.openai.com/v1/embeddings" + +# Cache one engine per Postgres URL. A SQLAlchemy engine owns a connection +# pool meant to outlive individual queries — creating one per call (and never +# disposing it) leaks pools/file descriptors. Keyed by URL so a settings +# change in tests still resolves to a fresh engine. +_search_engines: dict[str, AsyncEngine] = {} + + +def _get_search_engine(postgres_url: str) -> AsyncEngine: + """Return a process-wide async engine for the given URL, creating it once.""" + engine = _search_engines.get(postgres_url) + if engine is None: + from sqlalchemy.ext.asyncio import create_async_engine + + engine = create_async_engine(postgres_url) + _search_engines[postgres_url] = engine + return engine + + +class EmbeddingService: + """Generate embeddings via Voyage or OpenAI.""" + + def __init__(self) -> None: + settings = get_settings() + self._provider = getattr(settings, "embedding_provider", "voyage") or "voyage" + self._model = getattr(settings, "embedding_model", "voyage-3-large") or "voyage-3-large" + self._voyage_key = getattr(settings, "voyage_api_key", None) + self._openai_key = getattr(settings, "openai_api_key", None) + + async def embed(self, text: str) -> list[float]: + """Generate an embedding vector for the given text.""" + if self._provider == "voyage" and self._voyage_key: + return await self._embed_voyage(text) + if self._openai_key: + return await self._embed_openai(text) + _logger.warning("embedding_no_provider_configured") + return [] + + async def _embed_voyage(self, text: str) -> list[float]: + """Embed via Voyage API.""" + try: + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post( + _VOYAGE_URL, + headers={"Authorization": f"Bearer {self._voyage_key}"}, + json={"model": self._model, "input": text}, + ) + resp.raise_for_status() + data = resp.json() + return data["data"][0]["embedding"] + except Exception: + _logger.exception("voyage_embedding_failed") + return [] + + async def _embed_openai(self, text: str) -> list[float]: + """Embed via OpenAI API (fallback).""" + try: + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post( + _OPENAI_URL, + headers={"Authorization": f"Bearer {self._openai_key}"}, + json={"model": "text-embedding-3-large", "input": text}, + ) + resp.raise_for_status() + data = resp.json() + return data["data"][0]["embedding"] + except Exception: + _logger.exception("openai_embedding_failed") + return [] + + +async def search_similar_issues( + embedding: list[float], + repo: str, + *, + top_k: int = 10, + min_similarity: float = 0.75, +) -> list[dict[str, Any]]: + """Search for similar issues using pgvector cosine similarity. + + This requires: + 1. pgvector extension installed + 2. issue_embeddings table with vector column + 3. OPENBOT_POSTGRES_URL configured + + Returns list of dicts: {issue_number, title, similarity} + """ + if not embedding: + return [] + + settings = get_settings() + postgres_url = settings.postgres_url + if not postgres_url: + _logger.info("dedup_search_skipped_no_postgres") + return [] + + try: + from sqlalchemy import text + + engine = _get_search_engine(postgres_url) + async with engine.connect() as conn: + # pgvector cosine distance: 1 - cosine_similarity + query = text(""" + SELECT issue_number, title, 1 - (embedding <=> :embedding::vector) AS similarity + FROM issue_embeddings + WHERE repo = :repo + AND 1 - (embedding <=> :embedding::vector) >= :min_sim + ORDER BY embedding <=> :embedding::vector + LIMIT :top_k + """) + result = await conn.execute( + query, + { + "embedding": str(embedding), + "repo": repo, + "min_sim": min_similarity, + "top_k": top_k, + }, + ) + rows = result.fetchall() + return [ + {"issue_number": row[0], "title": row[1], "similarity": float(row[2])} + for row in rows + ] + except Exception: + _logger.exception("pgvector_search_failed") + return [] diff --git a/openbot/infrastructure/persistence/migrations/versions/003_add_issue_embeddings.py b/openbot/infrastructure/persistence/migrations/versions/003_add_issue_embeddings.py new file mode 100644 index 00000000..ea2e3dc6 --- /dev/null +++ b/openbot/infrastructure/persistence/migrations/versions/003_add_issue_embeddings.py @@ -0,0 +1,49 @@ +"""Add issue_embeddings table with pgvector for dedup. + +Revision ID: 003 +Requires: pgvector extension +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +# revision identifiers +revision = "003" +down_revision = "002" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Enable pgvector extension + op.execute("CREATE EXTENSION IF NOT EXISTS vector") + + # Create issue_embeddings table + op.create_table( + "issue_embeddings", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("repo", sa.String(255), nullable=False), + sa.Column("issue_number", sa.Integer(), nullable=False), + sa.Column("embedding", sa.Text(), nullable=False), # stored as vector via raw SQL + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.UniqueConstraint("repo", "issue_number", name="uq_issue_embeddings_repo_issue"), + ) + + # Create IVFFlat index for fast cosine similarity search + # Note: This requires the table to have data before the index is effective. + # For empty tables, the index is created but will be rebuilt as data grows. + op.execute(""" + CREATE INDEX ix_issue_embeddings_embedding + ON issue_embeddings + USING ivfflat (embedding vector_cosine_ops) + WITH (lists = 100) + """) + + op.create_index("ix_issue_embeddings_repo", "issue_embeddings", ["repo"]) + + +def downgrade() -> None: + op.drop_table("issue_embeddings") + op.execute("DROP EXTENSION IF EXISTS vector") diff --git a/openbot_plugins/__init__.py b/openbot_plugins/__init__.py new file mode 100644 index 00000000..de63b29c --- /dev/null +++ b/openbot_plugins/__init__.py @@ -0,0 +1,34 @@ +"""OpenBot community plugins. + +Plugins are LangChain StructuredTool instances registered at import time. +The agent discovers them via ``get_all_plugins()`` and merges them into +the tool list for the relevant workflow. + +Contributing: see CONTRIBUTING.md for the full PR checklist. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from langchain_core.tools import StructuredTool + +_REGISTRY: list[StructuredTool] = [] + + +def register(tool: StructuredTool) -> None: + """Register a plugin tool. Called at module level in each plugin file.""" + _REGISTRY.append(tool) + + +def get_all_plugins() -> Sequence[StructuredTool]: + """Return all registered plugin tools (read-only view).""" + return tuple(_REGISTRY) + + +def reset_registry() -> None: + """Clear all registered plugins. Used in tests only.""" + _REGISTRY.clear() + + +__all__ = ["get_all_plugins", "register", "reset_registry"] diff --git a/openbot_plugins/reproduce_js_issue.py b/openbot_plugins/reproduce_js_issue.py new file mode 100644 index 00000000..8fc3d656 --- /dev/null +++ b/openbot_plugins/reproduce_js_issue.py @@ -0,0 +1,88 @@ +"""Plugin: reproduce_js_issue — diagnose JavaScript/TypeScript issues. + +Reads the issue body, extracts JS/TS stack traces, identifies the root +cause module, and suggests a minimal reproduction script. +""" + +from __future__ import annotations + +import re +from typing import Annotated + +from langchain_core.tools import StructuredTool +from pydantic import BaseModel, Field + +from openbot_plugins import register + + +class ReproduceJsIssueInput(BaseModel): + issue_body: Annotated[str, Field(description="The full issue body text.")] + repo_files: Annotated[ + list[str], + Field(description="List of file paths in the repo for context."), + ] = Field(default_factory=list) + + +_STACK_RE = re.compile( + r"(?:Error|TypeError|ReferenceError|SyntaxError|RangeError|URIError|EvalError|InternalError)" + r"[^\n]*\n(?:\s+at\s+[^\n]+\n?)+", + re.MULTILINE, +) +_AT_RE = re.compile(r"at\s+(?:(.+?)\s+\()?(.+):(\d+):\d+\)?") +_ERROR_TYPE_RE = re.compile( + r"^(Error|TypeError|ReferenceError|SyntaxError|RangeError):?\s*(.*)", re.MULTILINE +) + + +def _reproduce_js_issue( + issue_body: str, + repo_files: list[str], +) -> str: + """Analyze a JS/TS issue and suggest reproduction steps.""" + stacks = _STACK_RE.findall(issue_body) + error_matches = _ERROR_TYPE_RE.findall(issue_body) + + if not stacks and not error_matches: + return "No JavaScript/Typestack trace found in the issue body." + + parts: list[str] = ["## JS/TS Issue Analysis\n"] + + if error_matches: + error_type, error_msg = error_matches[-1] + parts.append(f"**Error:** `{error_type}: {error_msg.strip()}`\n") + + if stacks: + frames = _AT_RE.findall(stacks[-1]) + if frames: + parts.append("**Stack trace (top frames):**\n") + for func, filepath, lineno in frames[:5]: + func_str = func if func else "" + parts.append(f"- `{func_str}` at `{filepath}:{lineno}`") + parts.append("") + + # Suggest reproduction + parts.append("**Suggested reproduction:**\n") + parts.append("1. Clone the repo and run `npm install` / `yarn install`") + parts.append("2. Create a minimal script that triggers the same code path") + parts.append("3. Run with the same Node.js version as the reporter") + if error_matches: + parts.append(f"4. Expect: `{error_matches[-1][0]}: {error_matches[-1][1].strip()}`") + + if repo_files: + parts.append(f"\n**Repo files for context:** {', '.join(repo_files[:5])}") + + return "\n".join(parts) + + +_TOOL = StructuredTool.from_function( + coroutine=None, + func=_reproduce_js_issue, + name="reproduce_js_issue", + description=( + "Analyze a JavaScript/TypeScript issue with stack trace. Extracts error type, " + "affected files, and suggests a minimal reproduction script." + ), + args_schema=ReproduceJsIssueInput, +) + +register(_TOOL) diff --git a/openbot_plugins/reproduce_python_issue.py b/openbot_plugins/reproduce_python_issue.py new file mode 100644 index 00000000..92902d6b --- /dev/null +++ b/openbot_plugins/reproduce_python_issue.py @@ -0,0 +1,91 @@ +"""Plugin: reproduce_python_issue — diagnose Python issues from tracebacks. + +Reads the issue body, extracts Python tracebacks, identifies the root +cause module/package, and suggests a minimal reproduction script. + +This plugin does NOT mutate the repo. It only reads files and produces +a diagnosis string for the agent to incorporate into its response. +""" + +from __future__ import annotations + +import re +from typing import Annotated + +from langchain_core.tools import StructuredTool +from pydantic import BaseModel, Field + +from openbot_plugins import register + + +class ReproducePythonIssueInput(BaseModel): + issue_body: Annotated[str, Field(description="The full issue body text.")] + repo_files: Annotated[ + list[str], + Field(description="List of file paths in the repo for context."), + ] = Field(default_factory=list) + + +_TRACEBACK_RE = re.compile( + r"Traceback \(most recent call last\):(.*?\n\S[^\n]*)", + re.DOTALL, +) +_FILE_LINE_RE = re.compile(r'File "([^"]+)", line (\d+)') +_IMPORT_ERROR_RE = re.compile(r"(\w+Error|ImportError|ModuleNotFoundError): (.+)") + + +def _reproduce_python_issue( + issue_body: str, + repo_files: list[str], +) -> str: + """Analyze a Python issue and suggest reproduction steps.""" + tracebacks = _TRACEBACK_RE.findall(issue_body) + if not tracebacks: + return "No Python traceback found in the issue body." + + tb = tracebacks[-1] # most relevant traceback + files = _FILE_LINE_RE.findall(tb) + errors = _IMPORT_ERROR_RE.findall(tb) + + parts: list[str] = ["## Python Issue Analysis\n"] + + if errors: + error_type, error_msg = errors[-1] + parts.append(f"**Error:** `{error_type}: {error_msg}`\n") + + if files: + parts.append("**Stack trace (most recent call):**\n") + for filepath, lineno in files[-3:]: + parts.append(f"- `{filepath}` line {lineno}") + parts.append("") + + # Suggest reproduction + parts.append("**Suggested reproduction:**\n") + parts.append("1. Clone the repo and install dependencies") + if files: + parts.append(f"2. Create a minimal script that imports/calls code from `{files[-1][0]}`") + parts.append("3. Run with the same Python version as the reporter") + if errors: + parts.append(f"4. Expect: `{errors[-1][0]}: {errors[-1][1]}`") + + # Mention relevant repo files + if repo_files: + matching = [f for f in repo_files if any(fp in f for fp, _ in files)] + if matching: + parts.append(f"\n**Relevant repo files:** {', '.join(matching[:5])}") + + return "\n".join(parts) + + +_TOOL = StructuredTool.from_function( + coroutine=None, + func=_reproduce_python_issue, + name="reproduce_python_issue", + description=( + "Analyze a Python issue with traceback. Extracts error type, " + "affected files, and suggests a minimal reproduction script." + ), + args_schema=ReproducePythonIssueInput, +) + +register(_TOOL) diff --git a/openbot_plugins/summarize_pr_diff.py b/openbot_plugins/summarize_pr_diff.py new file mode 100644 index 00000000..2035503c --- /dev/null +++ b/openbot_plugins/summarize_pr_diff.py @@ -0,0 +1,123 @@ +"""Plugin: summarize_pr_diff — generate a structured summary of a PR diff. + +Parses a unified diff and produces a human-readable summary with: +- Changed files grouped by type (added/modified/deleted) +- Key changes per file (function/method level) +- Potential risks or concerns +""" + +from __future__ import annotations + +import re +from typing import Annotated + +from langchain_core.tools import StructuredTool +from pydantic import BaseModel, Field + +from openbot_plugins import register + + +class SummarizePrDiffInput(BaseModel): + diff: Annotated[str, Field(description="The unified diff text (git diff format).")] + max_files: Annotated[ + int, + Field(description="Maximum number of files to include in the summary."), + ] = Field(default=20, ge=1, le=100) + + +_HUNK_HEADER_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@(.*)$", re.MULTILINE) +_FILE_HEADER_RE = re.compile(r"^diff --git a/(.+?) b/(.+?)$", re.MULTILINE) +_FUNC_DEF_RE = re.compile( + r"^\+.*(?:def |class |function |const |let |var |export )(\w+)", re.MULTILINE +) + + +def _summarize_pr_diff( + diff: str, + max_files: int, +) -> str: + """Produce a structured summary of a unified diff.""" + if not diff.strip(): + return "Empty diff — no changes to summarize." + + files = _FILE_HEADER_RE.findall(diff) + if not files: + return "Could not parse diff format." + + parts: list[str] = ["## PR Diff Summary\n"] + + # File-level summary + parts.append(f"**Files changed:** {len(files)}\n") + + # Split diff by file + file_diffs: dict[str, str] = {} + current_file = None + current_lines: list[str] = [] + + for line in diff.split("\n"): + match = _FILE_HEADER_RE.match(line) + if match: + if current_file and current_lines: + file_diffs[current_file] = "\n".join(current_lines) + current_file = match.group(2) + current_lines = [line] + elif current_file: + current_lines.append(line) + if current_file and current_lines: + file_diffs[current_file] = "\n".join(current_lines) + + # Analyze each file + added: list[str] = [] + modified: list[str] = [] + deleted: list[str] = [] + + for filepath, file_diff in list(file_diffs.items())[:max_files]: + lines = file_diff.split("\n") + added_lines = len([ln for ln in lines if ln.startswith("+") and not ln.startswith("+++")]) + removed_lines = len([ln for ln in lines if ln.startswith("-") and not ln.startswith("---")]) + + if added_lines > 0 and removed_lines == 0: + added.append(filepath) + elif removed_lines > 0 and added_lines == 0: + deleted.append(filepath) + else: + modified.append(filepath) + + if added: + parts.append(f"**Added ({len(added)}):** {', '.join(added[:10])}") + if modified: + parts.append(f"**Modified ({len(modified)}):** {', '.join(modified[:10])}") + if deleted: + parts.append(f"**Deleted ({len(deleted)}):** {', '.join(deleted[:10])}") + parts.append("") + + # Key changes per file (top 5 modified files) + parts.append("### Key changes\n") + for filepath in modified[:5]: + file_diff = file_diffs[filepath] + new_defs = _FUNC_DEF_RE.findall(file_diff) + f_lines = file_diff.split("\n") + adds = len([ln for ln in f_lines if ln.startswith("+") and not ln.startswith("+++")]) + dels = len([ln for ln in f_lines if ln.startswith("-") and not ln.startswith("---")]) + + parts.append(f"**`{filepath}`** (+{adds}/-{dels})") + if new_defs: + parts.append(f" New/changed: {', '.join(new_defs[:5])}") + parts.append("") + + return "\n".join(parts) + + +_TOOL = StructuredTool.from_function( + coroutine=None, + func=_summarize_pr_diff, + name="summarize_pr_diff", + description=( + "Generate a structured summary of a PR diff. Groups files by " + "added/modified/deleted, lists key changes per file, and identifies " + "new functions or classes." + ), + args_schema=SummarizePrDiffInput, +) + +register(_TOOL) diff --git a/tests/plugins/test_reproduce_js_issue.py b/tests/plugins/test_reproduce_js_issue.py new file mode 100644 index 00000000..29a16b88 --- /dev/null +++ b/tests/plugins/test_reproduce_js_issue.py @@ -0,0 +1,33 @@ +"""Tests for the reproduce_js_issue plugin.""" + +from __future__ import annotations + +from openbot_plugins.reproduce_js_issue import _reproduce_js_issue + + +def test_no_stack_returns_message(): + result = _reproduce_js_issue(issue_body="Everything works fine.", repo_files=[]) + assert "No JavaScript" in result + + +def test_extracts_error_and_stack(): + body = """\ +TypeError: Cannot read properties of undefined (reading 'map') + at processData (/app/src/utils.js:42:15) + at async handler (/app/src/api.js:10:5) +""" + result = _reproduce_js_issue(issue_body=body, repo_files=["src/utils.js", "src/api.js"]) + assert "TypeError" in result + assert "Cannot read properties" in result + assert "utils.js" in result + assert "Suggested reproduction" in result + + +def test_reference_error(): + body = """\ +ReferenceError: myVar is not defined + at Object. (/app/index.js:5:1) +""" + result = _reproduce_js_issue(issue_body=body, repo_files=[]) + assert "ReferenceError" in result + assert "myVar is not defined" in result diff --git a/tests/plugins/test_reproduce_python_issue.py b/tests/plugins/test_reproduce_python_issue.py new file mode 100644 index 00000000..50e8ed9c --- /dev/null +++ b/tests/plugins/test_reproduce_python_issue.py @@ -0,0 +1,57 @@ +"""Tests for the reproduce_python_issue plugin.""" + +from __future__ import annotations + +from openbot_plugins.reproduce_python_issue import _reproduce_python_issue + + +def test_no_traceback_returns_message(): + result = _reproduce_python_issue(issue_body="Everything is fine.", repo_files=[]) + assert "No Python traceback found" in result + + +def test_extracts_traceback_info(): + body = """\ +Traceback (most recent call last): + File "/app/main.py", line 42, in run + result = process(data) + File "/app/processor.py", line 10, in process + return data.decode("utf-8") +UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff +""" + result = _reproduce_python_issue(issue_body=body, repo_files=["main.py", "processor.py"]) + assert "UnicodeDecodeError" in result + assert "processor.py" in result + assert "Suggested reproduction" in result + + +def test_matches_repo_files(): + body = """\ +Traceback (most recent call last): + File "src/handlers/webhook.py", line 15, in handle + process(payload) +KeyError: 'missing_key' +""" + result = _reproduce_python_issue( + issue_body=body, + repo_files=["src/handlers/webhook.py", "src/utils.py"], + ) + assert "webhook.py" in result + assert "KeyError" in result + + +def test_multiple_tracebacks_uses_last(): + body = """\ +Traceback (most recent call last): + File "first.py", line 1, in first + pass +ValueError: first error + +Traceback (most recent call last): + File "second.py", line 2, in second + pass +TypeError: second error +""" + result = _reproduce_python_issue(issue_body=body, repo_files=[]) + assert "TypeError" in result + assert "second error" in result diff --git a/tests/plugins/test_summarize_pr_diff.py b/tests/plugins/test_summarize_pr_diff.py new file mode 100644 index 00000000..f228dee1 --- /dev/null +++ b/tests/plugins/test_summarize_pr_diff.py @@ -0,0 +1,79 @@ +"""Tests for the summarize_pr_diff plugin.""" + +from __future__ import annotations + +from openbot_plugins.summarize_pr_diff import _summarize_pr_diff + + +def test_empty_diff(): + result = _summarize_pr_diff(diff="", max_files=20) + assert "Empty diff" in result + + +def test_parses_diff(): + diff = """\ +diff --git a/src/main.py b/src/main.py +--- a/src/main.py ++++ b/src/main.py +@@ -1,3 +1,5 @@ + def main(): +- pass ++ result = process() ++ return result ++ ++def process(): ++ return 42 +""" + result = _summarize_pr_diff(diff=diff, max_files=20) + assert "main.py" in result + assert "Files changed:** 1" in result + + +def test_groups_added_modified_deleted(): + diff = """\ +diff --git a/new_file.py b/new_file.py +--- /dev/null ++++ b/new_file.py +@@ -0,0 +1,3 @@ ++def hello(): ++ return "world" ++ +diff --git a/old_file.py b/old_file.py +--- a/old_file.py ++++ /dev/null +@@ -1,3 +0,0 @@ +-def goodbye(): +- return "world" +- +diff --git a/existing.py b/existing.py +--- a/existing.py ++++ b/existing.py +@@ -1,3 +1,4 @@ + def existing(): +- pass ++ return True ++ ++def new_func(): ++ pass +""" + result = _summarize_pr_diff(diff=diff, max_files=20) + assert "Added" in result + assert "Deleted" in result + assert "Modified" in result + + +def test_max_files_limit(): + files_diff = "" + for i in range(30): + files_diff += f"""\ +diff --git a/file{i}.py b/file{i}.py +--- a/file{i}.py ++++ b/file{i}.py +@@ -1,2 +1,3 @@ + def func(): +- pass ++ return {i} ++ +""" + result = _summarize_pr_diff(diff=files_diff, max_files=5) + assert "Files changed:** 30" in result diff --git a/tests/unit/adapters/test_linear_adapter.py b/tests/unit/adapters/test_linear_adapter.py new file mode 100644 index 00000000..7062118c --- /dev/null +++ b/tests/unit/adapters/test_linear_adapter.py @@ -0,0 +1,155 @@ +"""Tests for LinearAdapter — webhook verification + event parsing.""" + +from __future__ import annotations + +import hashlib +import hmac +import json + +import pytest + +from openbot.domain.events import EventKind +from openbot.infrastructure.adapters.base import SignatureError +from openbot.infrastructure.adapters.linear import LinearAdapter + +_SECRET = "test-webhook-secret" + + +@pytest.fixture +def adapter() -> LinearAdapter: + return LinearAdapter(webhook_secret=_SECRET, oauth_token="test-token") + + +def _sign(body: bytes) -> str: + return hmac.new(_SECRET.encode(), body, hashlib.sha256).hexdigest() + + +# ── verify_signature ───────────────────────────────────────────────────── + + +def test_verify_signature_valid(adapter: LinearAdapter) -> None: + body = b'{"action":"Issue.create","data":{}}' + sig = _sign(body) + # Should not raise + adapter.verify_signature(body, {"linear-signature": sig}) + + +def test_verify_signature_missing_header(adapter: LinearAdapter) -> None: + with pytest.raises(SignatureError, match="Missing"): + adapter.verify_signature(b"{}", {}) + + +def test_verify_signature_invalid(adapter: LinearAdapter) -> None: + with pytest.raises(SignatureError, match="Invalid"): + adapter.verify_signature(b"{}", {"linear-signature": "bad"}) + + +# ── parse_event ────────────────────────────────────────────────────────── + + +def test_parse_issue_create(adapter: LinearAdapter) -> None: + body = json.dumps( + { + "action": "Issue.create", + "webhookId": "wh-123", + "data": { + "id": "issue-abc", + "identifier": "ENG-42", + "title": "Bug report", + "creator": {"name": "Alice"}, + "team": {"key": "ENG"}, + }, + } + ).encode() + + event = adapter.parse_event(body, {}) + + assert event.channel == "linear" + assert event.kind == EventKind.ISSUE_OPENED + assert event.issue_number == 42 + assert event.repo == "ENG" + assert event.actor == "Alice" + assert event.delivery_id == "wh-123" + + +def test_parse_issue_comment(adapter: LinearAdapter) -> None: + body = json.dumps( + { + "action": "IssueComment.create", + "webhookId": "wh-456", + "data": { + "id": "comment-xyz", + "body": "@openbot help me fix this", + "issue": {"identifier": "ENG-99"}, + "user": {"name": "Bob"}, + "team": {"key": "ENG"}, + }, + } + ).encode() + + event = adapter.parse_event(body, {}) + + assert event.kind == EventKind.ISSUE_COMMENT_CREATED + assert event.comment_body == "@openbot help me fix this" + + +def test_parse_unknown_action(adapter: LinearAdapter) -> None: + body = json.dumps( + { + "action": "Cycle.create", + "webhookId": "wh-789", + "data": {}, + } + ).encode() + + event = adapter.parse_event(body, {}) + assert event.kind == EventKind.UNKNOWN + + +# ── stub methods ───────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_get_issue_labels_returns_empty(adapter: LinearAdapter) -> None: + from openbot.domain.events import UnifiedEvent + + event = UnifiedEvent( + channel="linear", + delivery_id="d", + kind=EventKind.ISSUE_OPENED, + repo="ENG", + actor="test", + ) + result = await adapter.get_issue_labels(event, 1) + assert result == frozenset() + + +@pytest.mark.asyncio +async def test_get_pr_comments_returns_empty(adapter: LinearAdapter) -> None: + from openbot.domain.events import UnifiedEvent + + event = UnifiedEvent( + channel="linear", + delivery_id="d", + kind=EventKind.ISSUE_OPENED, + repo="ENG", + actor="test", + ) + result = await adapter.get_pr_comments(event, 1) + assert result == [] + + +# ── aclose ─────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_aclose_is_idempotent(adapter: LinearAdapter) -> None: + """aclose() closes the cached GraphQL client and is safe to call twice + (the app shutdown path may run it once; tests construct many adapters).""" + # Force the lazy client into existence, then close it. + client = adapter._get_http() + assert client.is_closed is False + await adapter.aclose() + assert client.is_closed is True + # Second call is a no-op, not an error. + await adapter.aclose() diff --git a/tests/unit/cli/test_audit_cli.py b/tests/unit/cli/test_audit_cli.py new file mode 100644 index 00000000..a3ebc6d1 --- /dev/null +++ b/tests/unit/cli/test_audit_cli.py @@ -0,0 +1,132 @@ +"""Tests for openbot audit CLI — show, export, budget reset.""" + +from __future__ import annotations + +from decimal import Decimal + +from openbot.entrypoints.cli.audit import ( + TaskDetail, + export_csv, + render_task_detail_markdown, +) + + +def test_task_detail_to_json(): + detail = TaskDetail( + task_id="t-1", + audit_entries=[{"phase": "started", "created_at": "2026-01-01T00:00:00"}], + cost_entries=[{"feature": "fix", "cost_usd": "1.50"}], + total_cost_usd=Decimal("1.50"), + ) + j = detail.to_json() + assert j["task_id"] == "t-1" + assert j["total_cost_usd"] == "1.50" + assert len(j["audit_entries"]) == 1 + + +def test_render_task_detail_markdown_empty(): + detail = TaskDetail( + task_id="t-empty", + audit_entries=[], + cost_entries=[], + total_cost_usd=Decimal("0"), + ) + md = render_task_detail_markdown(detail) + assert "t-empty" in md + assert "$0.0000" in md + + +def test_render_task_detail_markdown_with_data(): + detail = TaskDetail( + task_id="t-42", + audit_entries=[ + { + "created_at": "2026-05-27T10:00:00", + "phase": "started", + "workflow": "fix", + "outcome": None, + }, + { + "created_at": "2026-05-27T10:05:00", + "phase": "completed", + "workflow": "fix", + "outcome": "pr_opened", + }, + ], + cost_entries=[ + { + "created_at": "2026-05-27T10:01:00", + "feature": "fix", + "model": "anthropic/glm-5.1", + "cost_usd": "0.42", + "cost_status": "recorded", + "prompt_tokens": 1000, + "completion_tokens": 500, + }, + ], + total_cost_usd=Decimal("0.42"), + ) + md = render_task_detail_markdown(detail) + assert "t-42" in md + assert "$0.4200" in md + assert "started" in md + assert "completed" in md + assert "pr_opened" in md + assert "Audit trail" in md + assert "Cost breakdown" in md + + +def test_export_csv_empty(): + assert export_csv([]) == "" + + +def test_export_csv_with_rows(): + rows = [ + {"task_id": "t-1", "feature": "fix", "cost_usd": "1.00"}, + {"task_id": "t-2", "feature": "review", "cost_usd": "0.50"}, + ] + csv_str = export_csv(rows) + lines = csv_str.strip().split("\n") + assert len(lines) == 3 # header + 2 rows + assert "task_id" in lines[0] + assert "t-1" in lines[1] + + +def test_build_parser_show(): + from openbot.entrypoints.cli.audit import _build_parser + + p = _build_parser() + args = p.parse_args(["show", "delivery-123"]) + assert args.cmd == "show" + assert args.task_id == "delivery-123" + + +def test_build_parser_export(): + from openbot.entrypoints.cli.audit import _build_parser + + p = _build_parser() + args = p.parse_args(["export", "--since", "7", "--format", "json"]) + assert args.cmd == "export" + assert args.since == 7 + assert args.format == "json" + + +def test_build_parser_budget_reset_global(): + from openbot.entrypoints.cli.audit import _build_parser + + p = _build_parser() + args = p.parse_args(["budget", "reset", "--global", "--yes"]) + assert args.cmd == "budget" + assert args.budget_cmd == "reset" + assert args.global_reset is True + assert args.yes is True + + +def test_build_parser_budget_reset_repo(): + from openbot.entrypoints.cli.audit import _build_parser + + p = _build_parser() + args = p.parse_args(["budget", "reset", "--repo", "owner/repo", "--yes"]) + assert args.cmd == "budget" + assert args.repo == "owner/repo" + assert args.global_reset is False diff --git a/tests/unit/entrypoints/__init__.py b/tests/unit/entrypoints/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/entrypoints/test_app_routes.py b/tests/unit/entrypoints/test_app_routes.py new file mode 100644 index 00000000..9c25f7e1 --- /dev/null +++ b/tests/unit/entrypoints/test_app_routes.py @@ -0,0 +1,31 @@ +"""Route-registration guards for the FastAPI app. + +These pin that each channel webhook is actually mounted — the kind of +regression where an adapter + route module exist but were never wired into +`include_router`, so the endpoint silently 404s in production. +""" + +from __future__ import annotations + +import pytest + +from openbot.entrypoints.api.app import app + + +def _route_paths() -> set[str]: + return {getattr(r, "path", "") for r in app.routes} + + +@pytest.mark.unit +def test_github_webhook_route_registered() -> None: + assert "/webhook/github" in _route_paths() + + +@pytest.mark.unit +def test_linear_webhook_route_registered() -> None: + assert "/webhook/linear" in _route_paths() + + +@pytest.mark.unit +def test_health_route_registered() -> None: + assert "/health" in _route_paths() diff --git a/tests/unit/middleware/test_config_approval.py b/tests/unit/middleware/test_config_approval.py new file mode 100644 index 00000000..2830755e --- /dev/null +++ b/tests/unit/middleware/test_config_approval.py @@ -0,0 +1,136 @@ +"""Tests for ConfigApprovalGate middleware. + +The gate keys off the PR **diff** (the real changed files), so the fixtures +supply a fake adapter whose ``get_pr_diff`` returns a unified diff. A PR that +merely mentions the config path in its title/body but doesn't actually touch +the file must NOT be gated — that was the bypass the diff-based check closes. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from openbot.application.middleware.config_approval import ConfigApprovalGate +from openbot.application.middleware.preflight import MiddlewareResult +from openbot.domain.events import EventKind, UnifiedEvent + +_CONFIG_DIFF = """\ +diff --git a/.openbot/config.yaml b/.openbot/config.yaml +index 1234567..89abcde 100644 +--- a/.openbot/config.yaml ++++ b/.openbot/config.yaml +@@ -1,3 +1,3 @@ + budget: +- monthly_soft_cap_usd: 50 ++ monthly_soft_cap_usd: 500 +""" + +_UNRELATED_DIFF = """\ +diff --git a/README.md b/README.md +index 1234567..89abcde 100644 +--- a/README.md ++++ b/README.md +@@ -1 +1 @@ +-old ++new +""" + + +def _make_event( + *, + pr_number: int | None = None, + labels: list[str] | None = None, +) -> UnifiedEvent: + raw = {} + if pr_number is not None: + raw["pull_request"] = { + "labels": [{"name": lbl} for lbl in (labels or [])], + } + return UnifiedEvent( + channel="github", + delivery_id="test-delivery", + kind=EventKind.PR_OPENED, + repo="owner/repo", + actor="test-user", + pr_number=pr_number, + raw=raw, + ) + + +def _make_ctx(event: UnifiedEvent, *, diff: str = "") -> MagicMock: + ctx = MagicMock() + ctx.event = event + + async def _get_pr_diff(_event, _pr_number): + return diff + + ctx.adapter.get_pr_diff = _get_pr_diff + return ctx + + +@pytest.mark.asyncio +async def test_non_pr_returns_proceed(): + gate = ConfigApprovalGate() + ctx = _make_ctx(_make_event()) # no pr_number + decision = await gate(ctx) + assert decision.result == MiddlewareResult.PROCEED + + +@pytest.mark.asyncio +async def test_pr_not_touching_config_returns_proceed(): + gate = ConfigApprovalGate() + ctx = _make_ctx(_make_event(pr_number=42), diff=_UNRELATED_DIFF) + decision = await gate(ctx) + assert decision.result == MiddlewareResult.PROCEED + + +@pytest.mark.asyncio +async def test_config_pr_without_approval_label_blocked(): + gate = ConfigApprovalGate() + ctx = _make_ctx(_make_event(pr_number=42), diff=_CONFIG_DIFF) + decision = await gate(ctx) + assert decision.result == MiddlewareResult.BLOCKED + assert "config-approved" in decision.comment + # High-risk key from the changed lines is surfaced in the comment. + assert "monthly_soft_cap_usd" in decision.comment + + +@pytest.mark.asyncio +async def test_config_pr_with_approval_label_proceeds(): + gate = ConfigApprovalGate() + ctx = _make_ctx( + _make_event(pr_number=42, labels=["config-approved"]), + diff=_CONFIG_DIFF, + ) + decision = await gate(ctx) + assert decision.result == MiddlewareResult.PROCEED + + +@pytest.mark.asyncio +async def test_config_pr_with_other_labels_blocked(): + gate = ConfigApprovalGate() + ctx = _make_ctx( + _make_event(pr_number=42, labels=["enhancement", "priority/high"]), + diff=_CONFIG_DIFF, + ) + decision = await gate(ctx) + assert decision.result == MiddlewareResult.BLOCKED + assert decision.reason == "config_approval_required" + + +@pytest.mark.asyncio +async def test_diff_fetch_failure_fails_open(): + """A transient diff-fetch error must not wedge every PR.""" + gate = ConfigApprovalGate() + event = _make_event(pr_number=42) + ctx = MagicMock() + ctx.event = event + + async def _boom(_event, _pr_number): + raise RuntimeError("github 502") + + ctx.adapter.get_pr_diff = _boom + decision = await gate(ctx) + assert decision.result == MiddlewareResult.PROCEED diff --git a/tests/unit/use_cases/test_dedup.py b/tests/unit/use_cases/test_dedup.py new file mode 100644 index 00000000..f33759d4 --- /dev/null +++ b/tests/unit/use_cases/test_dedup.py @@ -0,0 +1,165 @@ +"""Tests for issue dedup use case.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest + +from openbot.application.use_cases.dedup import ( + DedupCandidate, + check_duplicates, + render_dedup_comment, +) +from openbot.domain.events import EventKind, UnifiedEvent + + +def _make_event() -> UnifiedEvent: + return UnifiedEvent( + channel="github", + delivery_id="test-dedup", + kind=EventKind.ISSUE_OPENED, + repo="owner/repo", + actor="test-user", + issue_number=100, + ) + + +# ── render_dedup_comment ───────────────────────────────────────────────── + + +def test_render_empty_candidates(): + assert render_dedup_comment([]) == "" + + +def test_render_single_candidate(): + candidates = [DedupCandidate(issue_number=42, title="Bug in login", similarity=0.85)] + comment = render_dedup_comment(candidates) + assert "#42" in comment + assert "Bug in login" in comment + assert "85%" in comment + assert "duplicate" in comment.lower() + + +def test_render_multiple_candidates(): + candidates = [ + DedupCandidate(issue_number=10, title="Issue A", similarity=0.90), + DedupCandidate(issue_number=20, title="Issue B", similarity=0.80), + DedupCandidate(issue_number=30, title="Issue C", similarity=0.76), + ] + comment = render_dedup_comment(candidates) + assert "#10" in comment + assert "#20" in comment + assert "#30" in comment + assert "90%" in comment + assert "80%" in comment + assert "76%" in comment + + +# ── check_duplicates ───────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_check_duplicates_not_configured(): + event = _make_event() + adapter = AsyncMock() + result = await check_duplicates(event, adapter, issue_title="Test", issue_body="Body") + assert result.candidates == [] + assert result.comment == "" + + +@pytest.mark.asyncio +async def test_check_duplicates_no_results(): + event = _make_event() + adapter = AsyncMock() + embed_func = AsyncMock(return_value=[0.1, 0.2, 0.3]) + search_func = AsyncMock(return_value=[]) + + result = await check_duplicates( + event, + adapter, + issue_title="Test", + issue_body="Body", + embedding_func=embed_func, + search_func=search_func, + ) + assert result.candidates == [] + assert result.comment == "" + + +@pytest.mark.asyncio +async def test_check_duplicates_with_results(): + event = _make_event() + adapter = AsyncMock() + embed_func = AsyncMock(return_value=[0.1, 0.2, 0.3]) + search_func = AsyncMock( + return_value=[ + {"issue_number": 42, "title": "Similar issue", "similarity": 0.85}, + {"issue_number": 99, "title": "Another one", "similarity": 0.78}, + ] + ) + + result = await check_duplicates( + event, + adapter, + issue_title="New bug", + issue_body="Something broke", + embedding_func=embed_func, + search_func=search_func, + ) + assert len(result.candidates) == 2 + assert result.candidates[0].issue_number == 42 + assert result.candidates[0].similarity == 0.85 + assert "#42" in result.comment + assert "#99" in result.comment + + +@pytest.mark.asyncio +async def test_check_duplicates_invokes_rerank_func(): + """When supplied, rerank_func reorders the raw ANN hits and the result + reflects its ordering (not raw similarity order).""" + event = _make_event() + adapter = AsyncMock() + embed_func = AsyncMock(return_value=[0.1, 0.2, 0.3]) + search_func = AsyncMock( + return_value=[ + {"issue_number": 42, "title": "Similar issue", "similarity": 0.85}, + {"issue_number": 99, "title": "Better match", "similarity": 0.78}, + ] + ) + # rerank flips the order — the lower-similarity hit is judged more relevant. + rerank_func = AsyncMock( + return_value=[ + {"issue_number": 99, "title": "Better match", "similarity": 0.78}, + {"issue_number": 42, "title": "Similar issue", "similarity": 0.85}, + ] + ) + + result = await check_duplicates( + event, + adapter, + issue_title="New bug", + issue_body="Something broke", + embedding_func=embed_func, + search_func=search_func, + rerank_func=rerank_func, + ) + rerank_func.assert_awaited_once() + assert [c.issue_number for c in result.candidates] == [99, 42] + + +@pytest.mark.asyncio +async def test_check_duplicates_handles_embedding_error(): + event = _make_event() + adapter = AsyncMock() + embed_func = AsyncMock(side_effect=Exception("API error")) + + result = await check_duplicates( + event, + adapter, + issue_title="Test", + issue_body="Body", + embedding_func=embed_func, + ) + assert result.candidates == [] + assert result.comment == ""