diff --git a/backend/app/api/agent_forgejo.py b/backend/app/api/agent_forgejo.py index 495ac99..ae85bae 100644 --- a/backend/app/api/agent_forgejo.py +++ b/backend/app/api/agent_forgejo.py @@ -9,12 +9,19 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlmodel import select, func from sqlalchemy import and_ +from app.core.logging import get_logger from app.api.deps import get_board_for_actor_read + +logger = get_logger(__name__) + from app.core.agent_auth import get_agent_auth_context +from app.db import crud from app.db.session import get_session +from app.models.agents import Agent from app.models.boards import Board from app.models.board_repository_links import BoardRepositoryLink from app.models.forgejo_issues import ForgejoIssue +from app.models.gateways import Gateway from app.schemas.forgejo_issues import ForgejoIssueRead, ForgejoIssueListResponse, CloseIssueResponse from app.services.activity_log import record_activity from app.services.forgejo_issue_close import ( @@ -23,7 +30,11 @@ from app.services.forgejo_issue_close import ( CloseIssueRemoteError, close_issue_by_id, ) +from app.services.forgejo_issue_create import create_issue_on_board_repositories +from app.services.issue_template import render_error_report +from app.services.openclaw.gateway_rpc import GatewayConfig, send_message from app.services.openclaw.policies import OpenClawAuthorizationPolicy +from sqlmodel import SQLModel if TYPE_CHECKING: from sqlmodel.ext.asyncio.session import AsyncSession @@ -400,3 +411,193 @@ async def close_board_issue( forgejo_closed_at=result.get("forgejo_closed_at"), last_synced_at=result.get("last_synced_at") or "", ) + + +class AgentErrorReport(SQLModel): + """Error report payload submitted by an agent.""" + + title: str + body: str + error_type: str | None = None + stack_trace: str | None = None + task_id: UUID | None = None + + +class AgentErrorReportResponse(SQLModel): + """Response after an agent submits an error report.""" + + success: bool + board_id: UUID + escalation_agent_id: UUID | None + notification_sent: bool + issues_created: int + issue_ids: list[UUID] + + +@router.post( + "/{board_id}/errors", + response_model=AgentErrorReportResponse, + summary="Report an error to the board escalation agent", + description=( + "Report an error or code failure on the board.\n\n" + "**This endpoint is MANDATORY when `board.error_escalation_enabled` is `true`.**\n" + "Agents MUST call this endpoint whenever they encounter an error, exception, or " + "code failure — not doing so violates the board's error escalation rule.\n\n" + "The backend will:\n" + "1. Notify the designated escalation agent (or the board lead if none is set).\n" + "2. Automatically open a Forgejo issue on all linked repositories if " + "`board.error_auto_open_issue` is enabled." + ), + openapi_extra=_agent_board_openapi_hints( + intent="agent_error_escalation", + required_actor="any_agent", + when_to_use=[ + "Agent encounters a runtime error, exception, or unexpected failure", + "Code execution fails or produces incorrect results", + "Agent needs to escalate a blocking problem to a supervisor agent", + "Board error_escalation_enabled is true — this call is then MANDATORY", + ], + when_not_to_use=[ + "Board error_escalation_enabled is false", + "Non-critical warnings that do not block progress", + ], + routing_policy=[ + "MUST be called on any error when error_escalation_enabled is true on the board", + "Skipping this call when the rule is active violates board policy", + ], + routing_examples=[ + { + "input": { + "intent": "code execution raised an exception", + "board_rule": "error_escalation_enabled=true", + }, + "decision": "call POST /agent/boards/{board_id}/errors immediately", + }, + { + "input": { + "intent": "unexpected API failure blocked task completion", + "board_rule": "error_escalation_enabled=true", + }, + "decision": "call POST /agent/boards/{board_id}/errors with full stack trace", + }, + ], + ), + responses={ + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": "Error escalation is not enabled on this board", + }, + status.HTTP_502_BAD_GATEWAY: { + "description": "Could not notify escalation agent via gateway", + }, + }, +) +async def report_board_error( + board_id: UUID, + payload: AgentErrorReport, + session: AsyncSession = SESSION_DEP, + board: Board = BOARD_READ_DEP, + agent_ctx: AgentAuthContext = AGENT_CTX_DEP, +) -> AgentErrorReportResponse: + """Report an error — mandatory when board.error_escalation_enabled is True.""" + if not board.error_escalation_enabled: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="Error escalation is not enabled on this board", + ) + + # Resolve the target escalation agent (explicit setting or fall back to board lead). + escalation_agent: Agent | None = None + if board.error_escalation_agent_id is not None: + escalation_agent = await crud.get_by_id(session, Agent, board.error_escalation_agent_id) + if escalation_agent is None: + escalation_agent = ( + await session.exec( + select(Agent).where( + Agent.board_id == board_id, + Agent.is_board_lead.is_(True), + ) + ) + ).first() + + notification_sent = False + if escalation_agent is not None and escalation_agent.openclaw_session_id: + gateway = await crud.get_by_id(session, Gateway, escalation_agent.gateway_id) + if gateway is not None: + config = GatewayConfig( + url=gateway.url, + token=gateway.token, + allow_insecure_tls=gateway.allow_insecure_tls, + disable_device_pairing=gateway.disable_device_pairing, + ) + parts = [ + f"⚠️ ERROR ESCALATION from agent {agent_ctx.agent.name} on board {board.name}", + f"Title: {payload.title}", + f"Body: {payload.body}", + ] + if payload.error_type: + parts.append(f"Error type: {payload.error_type}") + if payload.task_id: + parts.append(f"Task ID: {payload.task_id}") + if payload.stack_trace: + parts.append(f"Stack trace:\n{payload.stack_trace}") + message_text = "\n".join(parts) + try: + await send_message( + message_text, + session_key=escalation_agent.openclaw_session_id, + config=config, + deliver=True, + ) + notification_sent = True + except Exception as exc: + logger.warning( + "error_escalation.notify_failed", + extra={ + "board_id": str(board_id), + "escalation_agent_id": str(escalation_agent.id), + "error": str(exc), + }, + ) + + # Optionally open a Forgejo issue on all linked repositories. + created_issues: list[ForgejoIssue] = [] + if board.error_auto_open_issue: + issue_title = f"[Error] {payload.title}" + affected_area = None + if payload.error_type: + affected_area = f"- Error type: {payload.error_type}\n- Board: {board.name}" + issue_body = render_error_report( + summary=payload.title, + problem=payload.body, + affected_area=affected_area, + stack_trace=payload.stack_trace, + reporter=f"agent:{agent_ctx.agent.name}", + ) + created_issues = await create_issue_on_board_repositories( + session, + board_id=board_id, + organization_id=board.organization_id, + title=issue_title, + body=issue_body, + ) + + record_activity( + session, + event_type="agent.error.reported", + message=( + f"Error reported by agent {agent_ctx.agent.name} on board {board.name}: " + f"{payload.title}" + ), + board_id=board_id, + agent_id=agent_ctx.agent.id, + ) + await session.commit() + + return AgentErrorReportResponse( + success=True, + board_id=board_id, + escalation_agent_id=escalation_agent.id if escalation_agent else None, + notification_sent=notification_sent, + issues_created=len(created_issues), + issue_ids=[issue.id for issue in created_issues], + ) diff --git a/backend/app/api/forgejo_issues.py b/backend/app/api/forgejo_issues.py index 98c7504..38dc031 100644 --- a/backend/app/api/forgejo_issues.py +++ b/backend/app/api/forgejo_issues.py @@ -23,6 +23,8 @@ from app.models.forgejo_repositories import ForgejoRepository from app.models.tasks import Task from app.schemas.forgejo_issues import ( CloseIssueResponse, + CreateIssueRequest, + CreateIssueResponse, EditIssueRequest, EditIssueResponse, ForgejoIssueDetailRead, @@ -34,6 +36,11 @@ from app.schemas.forgejo_issues import ( PostCommentResponse, ) from app.services.activity_log import record_activity +from app.services.forgejo_issue_create import ( + CreateIssueNotFoundError, + CreateIssueRemoteError, + create_issue_on_repository, +) from app.services.forgejo_issue_close import ( CloseIssueAccessError, CloseIssueNotFoundError, @@ -866,3 +873,55 @@ async def edit_issue( state=str(result["state"]), forgejo_updated_at=str(result["forgejo_updated_at"]), ) + + +@router.post( + "", + response_model=CreateIssueResponse, + status_code=status.HTTP_201_CREATED, + summary="Create a new Forgejo issue", + responses={ + status.HTTP_404_NOT_FOUND: {"description": "Repository not found"}, + status.HTTP_502_BAD_GATEWAY: {"description": "Forgejo API call failed"}, + }, +) +async def create_issue( + body: CreateIssueRequest, + session: AsyncSession = SESSION_DEP, + auth: AuthContext = AUTH_DEP, + ctx: OrganizationContext = ORG_MEMBER_DEP, +) -> CreateIssueResponse: + """Create a new issue on a Forgejo repository and cache it locally.""" + if auth.user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + + repository = await crud.get_by_id(session, ForgejoRepository, body.repository_id) + if repository is None or repository.organization_id != ctx.organization.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repository not found") + + try: + issue = await create_issue_on_repository( + session, + repository=repository, + title=body.title, + body=body.body, + ) + except CreateIssueNotFoundError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + except CreateIssueRemoteError as e: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)) + + record_activity( + session, + event_type="forgejo.issue.created", + message=f"Issue created by user {auth.user.id}: {body.title}", + ) + await session.commit() + + return CreateIssueResponse( + success=True, + issue_id=issue.id, + forgejo_issue_number=issue.forgejo_issue_number, + html_url=issue.html_url, + repository_id=issue.repository_id, + ) diff --git a/backend/app/models/boards.py b/backend/app/models/boards.py index 2023c9c..654744a 100644 --- a/backend/app/models/boards.py +++ b/backend/app/models/boards.py @@ -45,5 +45,10 @@ class Board(TenantScoped, table=True): block_status_changes_with_pending_approval: bool = Field(default=False) only_lead_can_change_status: bool = Field(default=False) max_agents: int = Field(default=1) + error_escalation_enabled: bool = Field(default=False) + error_escalation_agent_id: UUID | None = Field( + default=None, foreign_key="agents.id", index=True + ) + error_auto_open_issue: bool = Field(default=False) created_at: datetime = Field(default_factory=utcnow) updated_at: datetime = Field(default_factory=utcnow) diff --git a/backend/app/schemas/boards.py b/backend/app/schemas/boards.py index 3d2cdef..15b50a0 100644 --- a/backend/app/schemas/boards.py +++ b/backend/app/schemas/boards.py @@ -35,6 +35,9 @@ class BoardBase(SQLModel): block_status_changes_with_pending_approval: bool = False only_lead_can_change_status: bool = False max_agents: int = Field(default=1, ge=0) + error_escalation_enabled: bool = False + error_escalation_agent_id: UUID | None = None + error_auto_open_issue: bool = False class BoardCreate(BoardBase): @@ -80,6 +83,9 @@ class BoardUpdate(SQLModel): block_status_changes_with_pending_approval: bool | None = None only_lead_can_change_status: bool | None = None max_agents: int | None = Field(default=None, ge=0) + error_escalation_enabled: bool | None = None + error_escalation_agent_id: UUID | None = None + error_auto_open_issue: bool | None = None @model_validator(mode="after") def validate_gateway_id(self) -> Self: diff --git a/backend/app/schemas/forgejo_issues.py b/backend/app/schemas/forgejo_issues.py index b2bd87c..16000b4 100644 --- a/backend/app/schemas/forgejo_issues.py +++ b/backend/app/schemas/forgejo_issues.py @@ -81,6 +81,24 @@ class CloseIssueResponse(SQLModel): last_synced_at: str +class CreateIssueRequest(SQLModel): + """Request body for creating a new Forgejo issue.""" + + repository_id: UUID + title: str + body: str + + +class CreateIssueResponse(SQLModel): + """Response for issue creation.""" + + success: bool + issue_id: UUID + forgejo_issue_number: int + html_url: str + repository_id: UUID + + class PostCommentRequest(SQLModel): """Request body for posting a comment on an issue.""" diff --git a/backend/app/services/forgejo_client.py b/backend/app/services/forgejo_client.py index ba521f2..d5bc3dc 100644 --- a/backend/app/services/forgejo_client.py +++ b/backend/app/services/forgejo_client.py @@ -264,6 +264,26 @@ class ForgejoAPIClient: page += 1 return reactions + async def create_issue( + self, + owner: str, + repo: str, + title: str, + body: str, + labels: list[int] | None = None, + ) -> dict[str, object]: + """Open a new issue on a repository.""" + client = await self._get_client() + payload: dict[str, object] = {"title": title, "body": body} + if labels: + payload["labels"] = labels + response = await client.post( + f"/api/v1/repos/{owner}/{repo}/issues", + json=payload, + ) + response.raise_for_status() + return response.json() + async def create_comment( self, owner: str, diff --git a/backend/app/services/forgejo_issue_create.py b/backend/app/services/forgejo_issue_create.py new file mode 100644 index 0000000..6748cd3 --- /dev/null +++ b/backend/app/services/forgejo_issue_create.py @@ -0,0 +1,166 @@ +"""Service for creating new Forgejo issues from Pipeline and caching them locally.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID + +from sqlmodel import select + +from app.core.logging import get_logger +from app.core.time import utcnow +from app.db import crud +from app.models.board_repository_links import BoardRepositoryLink +from app.models.forgejo_connections import ForgejoConnection +from app.models.forgejo_issues import ForgejoIssue +from app.models.forgejo_repositories import ForgejoRepository +from app.services.forgejo_client import get_forgejo_client + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio.session import AsyncSession + +logger = get_logger(__name__) + + +class CreateIssueError(Exception): + """Base exception for issue creation errors.""" + + +class CreateIssueNotFoundError(CreateIssueError): + """Raised when a required repository or connection is not found.""" + + +class CreateIssueRemoteError(CreateIssueError): + """Raised when the Forgejo API call fails.""" + + +async def create_issue_on_repository( + session: AsyncSession, + *, + repository: ForgejoRepository, + title: str, + body: str, + labels: list[int] | None = None, +) -> ForgejoIssue: + """Create an issue on Forgejo and cache it locally. + + Raises: + CreateIssueNotFoundError: If the connection is not found. + CreateIssueRemoteError: If the Forgejo API call fails. + """ + connection = await crud.get_by_id(session, ForgejoConnection, repository.connection_id) + if connection is None: + raise CreateIssueNotFoundError("Repository connection not found") + + try: + async with get_forgejo_client(connection) as client: + raw = await client.create_issue( + owner=repository.owner, + repo=repository.repo, + title=title, + body=body, + labels=labels, + ) + except Exception as exc: + raise CreateIssueRemoteError(f"Failed to create issue on Forgejo: {exc}") from exc + + now = utcnow() + raw_number = raw.get("number", 0) + forgejo_number = int(raw_number) if isinstance(raw_number, int | float) else 0 + raw_body = raw.get("body") or "" + raw_user = raw.get("user") + author = ( + raw_user.get("login", "") if isinstance(raw_user, dict) else "" + ) + html_url = str(raw.get("html_url", "")) + raw_created = raw.get("created_at") + forgejo_created_at = now + if isinstance(raw_created, str): + try: + from datetime import datetime + forgejo_created_at = datetime.fromisoformat( + raw_created.replace("Z", "+00:00") + ).replace(tzinfo=None) + except ValueError: + pass + + issue = ForgejoIssue( + organization_id=repository.organization_id, + repository_id=repository.id, + forgejo_issue_number=forgejo_number, + title=title, + body=raw_body or body, + body_preview=(raw_body or body)[:1000], + state="open", + is_pull_request=False, + labels=[], + assignees=[], + author=author, + html_url=html_url, + forgejo_created_at=forgejo_created_at, + forgejo_updated_at=forgejo_created_at, + forgejo_payload=dict(raw) if isinstance(raw, dict) else None, + last_synced_at=now, + ) + session.add(issue) + await session.flush() + + logger.info( + "forgejo.issue.created", + extra={ + "issue_id": str(issue.id), + "forgejo_issue_number": forgejo_number, + "repository_id": str(repository.id), + "organization_id": str(repository.organization_id), + }, + ) + return issue + + +async def create_issue_on_board_repositories( + session: AsyncSession, + *, + board_id: UUID, + organization_id: UUID, + title: str, + body: str, + labels: list[int] | None = None, +) -> list[ForgejoIssue]: + """Create an issue on all repositories linked to a board. + + Returns a list of created ForgejoIssue records (one per linked repository). + Repositories that fail are logged and skipped. + """ + links = ( + await session.exec( + select(BoardRepositoryLink).where( + BoardRepositoryLink.board_id == board_id, + BoardRepositoryLink.organization_id == organization_id, + ) + ) + ).all() + + created: list[ForgejoIssue] = [] + for link in links: + repository = await crud.get_by_id(session, ForgejoRepository, link.repository_id) + if repository is None: + continue + try: + issue = await create_issue_on_repository( + session, + repository=repository, + title=title, + body=body, + labels=labels, + ) + created.append(issue) + except CreateIssueError as exc: + logger.warning( + "error_escalation.issue_create_failed", + extra={ + "board_id": str(board_id), + "repository_id": str(link.repository_id), + "error": str(exc), + }, + ) + return created diff --git a/backend/app/services/issue_template.py b/backend/app/services/issue_template.py new file mode 100644 index 0000000..fe89e4e --- /dev/null +++ b/backend/app/services/issue_template.py @@ -0,0 +1,109 @@ +"""Standard issue body template for Forgejo issues created via Pipeline.""" + +from __future__ import annotations + +ISSUE_TEMPLATE = """\ +## Summary +Briefly describe the issue in 1 to 3 sentences. + +## Problem +Explain what is wrong, missing, confusing, or broken. + +## Affected area +Example: +- UI page: +- Backend service: +- API: +- Database: +- Auth: +- CI/CD: +- Docs: + +## Affected files +Known or suspected files: +- `path/to/file` +- `path/to/other-file` + +## Affected routes or endpoints +Known or suspected routes: +- `GET /example` +- `POST /api/example` + +## Steps to reproduce +1. +2. +3. + +## Expected behavior +Describe what should happen. + +## Actual behavior +Describe what actually happens. + +## Error output, logs, or screenshots +Paste relevant logs only. Redact secrets. + +```text +Paste logs here +```\ +""" + + +def _section(heading: str, body: str) -> str: + return f"## {heading}\n{body.strip()}" + + +def _code_block(content: str, lang: str = "text") -> str: + return f"```{lang}\n{content.strip()}\n```" + + +def render_error_report( + *, + summary: str, + problem: str, + affected_area: str | None = None, + affected_files: list[str] | None = None, + affected_routes: list[str] | None = None, + actual_behavior: str | None = None, + stack_trace: str | None = None, + reporter: str | None = None, +) -> str: + """Render the standard template from structured error data.""" + parts: list[str] = [] + + if reporter: + parts.append(f"_Reported by: {reporter}_\n") + + parts.append(_section("Summary", summary)) + + parts.append(_section("Problem", problem)) + + area_body = affected_area.strip() if affected_area else "_Not identified._" + parts.append(_section("Affected area", area_body)) + + if affected_files: + files_body = "\n".join(f"- `{f}`" for f in affected_files) + else: + files_body = "_Not identified._" + parts.append(_section("Affected files", files_body)) + + if affected_routes: + routes_body = "\n".join(f"- `{r}`" for r in affected_routes) + else: + routes_body = "_Not identified._" + parts.append(_section("Affected routes or endpoints", routes_body)) + + parts.append(_section("Steps to reproduce", "_Not provided._")) + + parts.append(_section("Expected behavior", "_Not provided._")) + + actual = actual_behavior or problem + parts.append(_section("Actual behavior", actual)) + + if stack_trace: + log_body = _code_block(stack_trace) + else: + log_body = "_No output provided._" + parts.append(_section("Error output, logs, or screenshots", log_body)) + + return "\n\n".join(parts) diff --git a/backend/migrations/versions/e1f2a3b4c5d6_add_board_error_escalation.py b/backend/migrations/versions/e1f2a3b4c5d6_add_board_error_escalation.py new file mode 100644 index 0000000..b16699f --- /dev/null +++ b/backend/migrations/versions/e1f2a3b4c5d6_add_board_error_escalation.py @@ -0,0 +1,57 @@ +"""Add error escalation rule fields to boards. + +Revision ID: e1f2a3b4c5d6 +Revises: d4e5f6a7b8c9 +Create Date: 2026-05-22 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "e1f2a3b4c5d6" +down_revision = "d4e5f6a7b8c9" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "boards", + sa.Column("error_escalation_enabled", sa.Boolean(), nullable=False, server_default="false"), + ) + op.alter_column("boards", "error_escalation_enabled", server_default=None) + + op.add_column( + "boards", + sa.Column("error_escalation_agent_id", sa.UUID(), nullable=True), + ) + op.create_foreign_key( + "fk_boards_error_escalation_agent_id_agents", + "boards", + "agents", + ["error_escalation_agent_id"], + ["id"], + ) + op.create_index( + "ix_boards_error_escalation_agent_id", + "boards", + ["error_escalation_agent_id"], + ) + + op.add_column( + "boards", + sa.Column("error_auto_open_issue", sa.Boolean(), nullable=False, server_default="false"), + ) + op.alter_column("boards", "error_auto_open_issue", server_default=None) + + +def downgrade() -> None: + op.drop_column("boards", "error_auto_open_issue") + op.drop_index("ix_boards_error_escalation_agent_id", table_name="boards") + op.drop_constraint( + "fk_boards_error_escalation_agent_id_agents", "boards", type_="foreignkey" + ) + op.drop_column("boards", "error_escalation_agent_id") + op.drop_column("boards", "error_escalation_enabled") diff --git a/frontend/src/app/boards/[boardId]/edit/page.tsx b/frontend/src/app/boards/[boardId]/edit/page.tsx index 6bc4d5a..98df0a4 100644 --- a/frontend/src/app/boards/[boardId]/edit/page.tsx +++ b/frontend/src/app/boards/[boardId]/edit/page.tsx @@ -302,6 +302,9 @@ export default function EditBoardPage() { boolean | undefined >(undefined); const [maxAgents, setMaxAgents] = useState(undefined); + const [errorEscalationEnabled, setErrorEscalationEnabled] = useState(undefined); + const [errorEscalationAgentId, setErrorEscalationAgentId] = useState(undefined); + const [errorAutoOpenIssue, setErrorAutoOpenIssue] = useState(undefined); const [successMetrics, setSuccessMetrics] = useState( undefined, ); @@ -517,6 +520,19 @@ export default function EditBoardPage() { const resolvedOnlyLeadCanChangeStatus = onlyLeadCanChangeStatus ?? baseBoard?.only_lead_can_change_status ?? false; const resolvedMaxAgents = maxAgents ?? baseBoard?.max_agents ?? 1; + const baseboardExtended = baseBoard as (typeof baseBoard & { + error_escalation_enabled?: boolean; + error_escalation_agent_id?: string | null; + error_auto_open_issue?: boolean; + }) | null; + const resolvedErrorEscalationEnabled = + errorEscalationEnabled ?? baseboardExtended?.error_escalation_enabled ?? false; + const resolvedErrorEscalationAgentId = + errorEscalationAgentId !== undefined + ? errorEscalationAgentId + : (baseboardExtended?.error_escalation_agent_id ?? null); + const resolvedErrorAutoOpenIssue = + errorAutoOpenIssue ?? baseboardExtended?.error_auto_open_issue ?? false; const resolvedSuccessMetrics = successMetrics ?? (baseBoard?.success_metrics @@ -600,6 +616,14 @@ export default function EditBoardPage() { ); setOnlyLeadCanChangeStatus(updated.only_lead_can_change_status ?? false); setMaxAgents(updated.max_agents ?? 1); + const ext = updated as typeof updated & { + error_escalation_enabled?: boolean; + error_escalation_agent_id?: string | null; + error_auto_open_issue?: boolean; + }; + setErrorEscalationEnabled(ext.error_escalation_enabled ?? false); + setErrorEscalationAgentId(ext.error_escalation_agent_id ?? null); + setErrorAutoOpenIssue(ext.error_auto_open_issue ?? false); setSuccessMetrics( updated.success_metrics ? JSON.stringify(updated.success_metrics, null, 2) @@ -668,6 +692,11 @@ export default function EditBoardPage() { resolvedBlockStatusChangesWithPendingApproval, only_lead_can_change_status: resolvedOnlyLeadCanChangeStatus, max_agents: resolvedMaxAgents, + ...(({ + error_escalation_enabled: resolvedErrorEscalationEnabled, + error_escalation_agent_id: resolvedErrorEscalationAgentId ?? undefined, + error_auto_open_issue: resolvedErrorAutoOpenIssue, + }) as Record), success_metrics: resolvedBoardType === "general" ? null : parsedMetrics, target_date: resolvedBoardType === "general" @@ -1137,6 +1166,112 @@ export default function EditBoardPage() { +
+
+

+ Error escalation +

+

+ When enabled, agents are required to report + errors to a designated escalation agent. Disabling this rule + means agents may handle errors silently. +

+
+ +
+ + + + Mandatory error escalation + + + Agents must call the error report endpoint + when they encounter failures. Enforced via board policy in + the agent API. + + +
+ + {resolvedErrorEscalationEnabled ? ( + <> +
+ +

+ The agent that receives error notifications. Defaults to + the board lead if none is selected. +

+ +
+ +
+ + + + Auto-open Git issue on error + + + Automatically create a Forgejo issue on all linked + repositories whenever an agent reports an error. + + +
+ + ) : null} +
+ {gateways.length === 0 ? (

diff --git a/frontend/src/app/git-projects/issues/page.tsx b/frontend/src/app/git-projects/issues/page.tsx index e53c26f..ff32ecf 100644 --- a/frontend/src/app/git-projects/issues/page.tsx +++ b/frontend/src/app/git-projects/issues/page.tsx @@ -19,6 +19,7 @@ import { type ForgejoIssue, type ForgejoRepository, } from "@/lib/api-forgejo"; +import { CreateForgejoIssueDialog } from "@/components/git/CreateForgejoIssueDialog"; import { ForgejoIssueFilters } from "@/components/git/ForgejoIssueFilters"; import { ForgejoIssuesTable } from "@/components/git/ForgejoIssuesTable"; @@ -83,6 +84,7 @@ export default function GitIssuesPage() { const [page, setPage] = useState(() => parsePositiveInteger(searchParams.get("page")), ); + const [createDialogOpen, setCreateDialogOpen] = useState(false); const limit = 30; const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet< getMyMembershipApiV1OrganizationsMeMemberGetResponse, @@ -173,18 +175,18 @@ export default function GitIssuesPage() { const staleCutoffMs = useMemo(() => Date.now() - STALE_ISSUE_MS, []); const recentClosedCutoffMs = useMemo(() => Date.now() - RECENT_CLOSED_MS, []); const canCloseIssues = useMemo(() => { - if (membershipQuery.data?.status !== 200) { - return false; - } + if (membershipQuery.data?.status !== 200) return false; const member = membershipQuery.data.data; - if (["owner", "admin"].includes(member.role)) { - return true; - } - if (member.all_boards_write) { - return true; - } + if (["owner", "admin"].includes(member.role)) return true; + if (member.all_boards_write) return true; return (member.board_access ?? []).some((entry) => entry.can_write); }, [membershipQuery.data]); + + const canCreateIssues = useMemo(() => { + if (membershipQuery.data?.status !== 200) return false; + const member = membershipQuery.data.data; + return ["owner", "admin"].includes(member.role); + }, [membershipQuery.data]); const visibleIssues = useMemo(() => { if (staleOnly) { return issues.filter((issue) => isStaleOpenIssue(issue, staleCutoffMs)); @@ -218,26 +220,36 @@ export default function GitIssuesPage() { description={`${visibleTotal} issue${visibleTotal === 1 ? "" : "s"} from repositories tracked by Pipeline.`} stickyHeader > - { - setStateFilter(v); - setStaleOnly(false); - setRecentClosedOnly(false); - setPage(1); - }} - repoFilter={repoFilter} - onRepoChange={(v) => { - setRepoFilter(v); - setPage(1); - }} - search={search} - onSearchChange={(v) => { - setSearch(v); - setPage(1); - }} - repos={repos} - /> +

+ { + setStateFilter(v); + setStaleOnly(false); + setRecentClosedOnly(false); + setPage(1); + }} + repoFilter={repoFilter} + onRepoChange={(v) => { + setRepoFilter(v); + setPage(1); + }} + search={search} + onSearchChange={(v) => { + setSearch(v); + setPage(1); + }} + repos={repos} + /> + {canCreateIssues && repos.length > 0 ? ( + + ) : null} +
{staleOnly ? (
@@ -319,6 +331,15 @@ export default function GitIssuesPage() {
)} + { + setCreateDialogOpen(false); + handleRefresh(); + }} + /> ); } diff --git a/frontend/src/components/git/CreateForgejoIssueDialog.tsx b/frontend/src/components/git/CreateForgejoIssueDialog.tsx new file mode 100644 index 0000000..7b81a00 --- /dev/null +++ b/frontend/src/components/git/CreateForgejoIssueDialog.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { useState } from "react"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import type { ForgejoRepository } from "@/lib/api-forgejo"; +import { + createForgejoIssue, + ISSUE_TEMPLATE, + type CreateIssueResponse, +} from "@/lib/api-forgejo"; + +type CreateForgejoIssueDialogProps = { + repositories: ForgejoRepository[]; + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess: (result: CreateIssueResponse) => void; + defaultRepositoryId?: string; +}; + +export function CreateForgejoIssueDialog({ + repositories, + open, + onOpenChange, + onSuccess, + defaultRepositoryId, +}: CreateForgejoIssueDialogProps) { + const [repositoryId, setRepositoryId] = useState( + defaultRepositoryId ?? repositories[0]?.id ?? "", + ); + const [title, setTitle] = useState(""); + const [body, setBody] = useState(ISSUE_TEMPLATE); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + const reset = () => { + setTitle(""); + setBody(ISSUE_TEMPLATE); + setRepositoryId(defaultRepositoryId ?? repositories[0]?.id ?? ""); + setError(null); + }; + + const handleOpenChange = (next: boolean) => { + if (!isSubmitting) { + if (!next) reset(); + onOpenChange(next); + } + }; + + const handleSubmit = async () => { + if (!title.trim() || !repositoryId) return; + setIsSubmitting(true); + setError(null); + try { + const result = await createForgejoIssue({ + repository_id: repositoryId, + title: title.trim(), + body, + }); + reset(); + onSuccess(result); + onOpenChange(false); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create issue"); + } finally { + setIsSubmitting(false); + } + }; + + const selectedRepo = repositories.find((r) => r.id === repositoryId); + const repoLabel = selectedRepo + ? selectedRepo.display_name || `${selectedRepo.owner}/${selectedRepo.repo}` + : "—"; + + return ( + + + + Create issue + + Open a new issue on the connected Git repository. The body is + pre-filled with the standard issue template. + + + +
+ {repositories.length > 1 ? ( +
+ + +
+ ) : ( +
+ Repository:{" "} + {repoLabel} +
+ )} + +
+ + setTitle(e.target.value)} + disabled={isSubmitting} + placeholder="Short, descriptive issue title" + /> +
+ +
+ +

+ Fill in the sections below. Remove any that don't apply. +

+