Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/oz/config.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
version: 1
self_improvement:
base_branch: auto
triage:
bot_author_allowlist:
- warp-dev-github-integration[bot]
60 changes: 58 additions & 2 deletions api/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
import os
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler
from typing import Any, Callable, Mapping
from typing import Any, Callable, Iterable, Mapping

from core.dispatch import (
DispatchRequest,
Expand All @@ -41,6 +41,7 @@
RouteDecision,
WORKFLOW_ANNOUNCE_READY_ISSUE,
WORKFLOW_PLAN_APPROVED,
needs_triage_bot_author_allowlist,
route_event,
)
from core.signatures import (
Expand All @@ -59,6 +60,8 @@
_DELIVERY_HEADER = "x-github-delivery"




@dataclass(frozen=True)
class WebhookResponse:
"""Structured response surfaced by :func:`process_webhook_request`."""
Expand Down Expand Up @@ -130,6 +133,8 @@ def process_webhook_request(
store: StateStore | None = None,
sync_plan_approved: Callable[[Mapping[str, Any]], dict[str, Any] | None] | None = None,
sync_announce_ready_issue: Callable[[Mapping[str, Any]], dict[str, Any]] | None = None,
triage_bot_author_allowlist: Iterable[str] | None = None,
triage_bot_author_allowlist_loader: Callable[[Mapping[str, Any]], Iterable[str]] | None = None,
) -> WebhookResponse:
"""Validate a webhook delivery and dispatch the cloud agent run.

Expand Down Expand Up @@ -169,7 +174,35 @@ def process_webhook_request(
body={"error": "webhook payload must be a JSON object"},
)

decision: RouteDecision = route_event(event, payload)
route_triage_bot_author_allowlist: frozenset[str] = frozenset(
triage_bot_author_allowlist or ()
)
if (
triage_bot_author_allowlist_loader is not None
and needs_triage_bot_author_allowlist(event, payload)
):
try:
route_triage_bot_author_allowlist = frozenset(
triage_bot_author_allowlist_loader(payload)
)
except Exception as exc:
logger.exception("Failed to load triage bot author allowlist")
return WebhookResponse(
status=500,
body={
"event": event,
"workflow": None,
"reason": "failed to load triage bot author allowlist",
"delivery": delivery_id or "",
"error": f"route config failed: {exc}",
},
)

decision: RouteDecision = route_event(
event,
payload,
triage_bot_author_allowlist=route_triage_bot_author_allowlist,
)
base_body: dict[str, Any] = {
"event": event,
"workflow": decision.workflow,
Expand Down Expand Up @@ -324,6 +357,9 @@ def do_POST(self) -> None: # noqa: N802 - signature comes from BaseHTTPRequestH
store=wiring["store"],
sync_plan_approved=wiring["sync_plan_approved"],
sync_announce_ready_issue=wiring["sync_announce_ready_issue"],
triage_bot_author_allowlist_loader=wiring[
"triage_bot_author_allowlist_loader"
],
)
self._respond(response.status, response.body)

Expand Down Expand Up @@ -358,6 +394,9 @@ def _build_runtime_wiring(*, body: bytes) -> dict[str, Any]:
from oz.oz_client import ( # type: ignore[import-not-found]
build_agent_config,
)
from oz.workflow_config import ( # type: ignore[import-not-found]
load_triage_bot_author_allowlist,
)
from workflows.announce_ready_issue import ( # type: ignore[import-not-found]
apply_announce_ready_issue_sync,
)
Expand Down Expand Up @@ -490,13 +529,30 @@ def sync_announce_ready_issue(
repo_handle, payload=payload
)

def triage_bot_author_allowlist_loader(payload: Mapping[str, Any]) -> frozenset[str]:
full_name = str(
(payload.get("repository") or {}).get("full_name") or ""
)
if "/" not in full_name:
raise RuntimeError(
"webhook payload is missing repository.full_name; "
"cannot load triage bot author allowlist"
)
repo_handle = _client_for_payload().get_repo(full_name)
workflow_root = _Path(__file__).resolve().parents[1]
return load_triage_bot_author_allowlist(
repo_handle,
fallback_workspace=workflow_root,
)

return {
"builder_registry": builder_registry,
"runner": runner,
"config_factory": config_factory,
"store": build_state_store(),
"sync_plan_approved": sync_plan_approved,
"sync_announce_ready_issue": sync_announce_ready_issue,
"triage_bot_author_allowlist_loader": triage_bot_author_allowlist_loader,
}


Expand Down
55 changes: 50 additions & 5 deletions core/routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@
the normal bot-author drop. Other opened issues route to
``triage-new-issues`` regardless of existing lifecycle labels
(``ready-to-spec`` / ``ready-to-implement`` issues still get a
triage pass).
triage pass). Automation-authored opened issues are skipped unless
the author is present in the configured triage bot author allowlist.
- ``assigned`` routes to ``create-spec-from-issue`` or
``create-implementation-from-issue`` when the assignee being
added is ``oz-agent`` and the issue carries the matching
Expand Down Expand Up @@ -76,7 +77,7 @@

import re
from dataclasses import dataclass
from typing import Any
from typing import Any, Mapping

# Workflow identifiers the dispatcher knows how to handle. These strings
# are used as state-store keys and as ``RouteDecision.workflow`` values
Expand Down Expand Up @@ -170,6 +171,29 @@ def _is_bot(actor: Any) -> bool:
return bool(login) and login.endswith("[bot]")


def _is_issue_triage_allowlisted_bot(
actor: Any,
bot_author_allowlist: frozenset[str],
) -> bool:
"""Return True when *actor* is an automation account allowed to trigger issue triage."""
return (
_is_bot(actor)
and _login(actor).lower() in bot_author_allowlist
)


def needs_triage_bot_author_allowlist(event: str, payload: Mapping[str, Any]) -> bool:
"""Return True when *event*/*payload* is a bot-authored ``issues.opened``."""
if event != "issues":
return False
if str(payload.get("action") or "").strip() != "opened":
return False
issue = payload.get("issue") or {}
if not isinstance(issue, dict) or issue.get("pull_request"):
return False
return _is_bot(issue.get("user"))


def _route_issue_comment(payload: dict[str, Any]) -> RouteDecision:
action = str(payload.get("action") or "").strip()
if action != "created":
Expand Down Expand Up @@ -252,7 +276,11 @@ def _route_plain_issue_comment(
)


def _route_issues(payload: dict[str, Any]) -> RouteDecision:
def _route_issues(
payload: dict[str, Any],
*,
bot_author_allowlist: frozenset[str] = frozenset(),
) -> RouteDecision:
"""Route an ``issues`` webhook event.

Three actions are routed:
Expand All @@ -267,6 +295,8 @@ def _route_issues(payload: dict[str, Any]) -> RouteDecision:
imported from another repo or re-opened — still get a triage
pass so the bot can post a fresh progress comment and pick up
any state changes that landed while the issue was closed.
Configured bot authors are allowlisted through the bot check
because some generated issues still need triage labels.
- ``assigned`` triggers ``create-spec-from-issue`` or
``create-implementation-from-issue`` when the assignee being
added is ``oz-agent`` itself and the issue carries the
Expand Down Expand Up @@ -301,7 +331,11 @@ def _route_issues(payload: dict[str, Any]) -> RouteDecision:
WORKFLOW_CREATE_IMPLEMENTATION_FROM_ISSUE,
"auto-implement label on newly opened issue",
)
if _is_bot(issue.get("user")):
issue_author = issue.get("user")
if _is_bot(issue_author) and not _is_issue_triage_allowlisted_bot(
issue_author,
bot_author_allowlist,
):
return RouteDecision(None, "issue authored by automation user")
return RouteDecision(
WORKFLOW_TRIAGE_NEW_ISSUES, "issues.opened triggers triage"
Expand Down Expand Up @@ -463,7 +497,12 @@ def _route_pull_request_review(payload: dict[str, Any]) -> RouteDecision:
}


def route_event(event: str, payload: dict[str, Any]) -> RouteDecision:
def route_event(
event: str,
payload: dict[str, Any],
*,
triage_bot_author_allowlist: frozenset[str] | None = None,
) -> RouteDecision:
"""Decide which workflow (if any) handles *event* + *payload*.

The router never raises on unknown events or malformed payloads; it
Expand All @@ -472,6 +511,11 @@ def route_event(event: str, payload: dict[str, Any]) -> RouteDecision:
"""
if not isinstance(payload, dict):
return RouteDecision(None, "non-object webhook payload")
if event == "issues":
return _route_issues(
payload,
bot_author_allowlist=triage_bot_author_allowlist or frozenset(),
)
handler = _EVENT_HANDLERS.get(event)
if handler is None:
return RouteDecision(None, f"event {event!r} not handled")
Expand Down Expand Up @@ -502,5 +546,6 @@ def route_event(event: str, payload: dict[str, Any]) -> RouteDecision:
"WORKFLOW_TRIAGE_NEW_ISSUES",
"WORKFLOW_VERIFY_PR_COMMENT",
"has_oz_review_command",
"needs_triage_bot_author_allowlist",
"route_event",
]
6 changes: 6 additions & 0 deletions docs/onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,14 @@ self_improvement:
triage:
prior_triage_labels:
- triaged
bot_author_allowlist:
- trusted-intake[bot]
```

`triage.bot_author_allowlist` lets a repository opt specific automation
accounts back into `issues.opened` triage. Other bot-authored issues are
skipped by default.

## 4. Bootstrap triage configuration (optional)

Run the [`bootstrap-issue-config`](../.agents/skills/bootstrap-issue-config/SKILL.md) skill against your repository to seed `.github/issue-triage/config.json` and `.github/STAKEHOLDERS` with sensible defaults derived from your existing labels and CODEOWNERS.
Loading
Loading