fix(scripts): issues

This commit is contained in:
null 2026-05-22 01:44:39 -05:00
parent 585fc52cd2
commit 5f8078399c
13 changed files with 1066 additions and 29 deletions

View File

@ -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],
)

View File

@ -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,
)

View File

@ -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)

View File

@ -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:

View File

@ -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."""

View File

@ -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,

View File

@ -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

View File

@ -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)

View File

@ -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")

View File

@ -302,6 +302,9 @@ export default function EditBoardPage() {
boolean | undefined
>(undefined);
const [maxAgents, setMaxAgents] = useState<number | undefined>(undefined);
const [errorEscalationEnabled, setErrorEscalationEnabled] = useState<boolean | undefined>(undefined);
const [errorEscalationAgentId, setErrorEscalationAgentId] = useState<string | null | undefined>(undefined);
const [errorAutoOpenIssue, setErrorAutoOpenIssue] = useState<boolean | undefined>(undefined);
const [successMetrics, setSuccessMetrics] = useState<string | undefined>(
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<string, unknown>),
success_metrics: resolvedBoardType === "general" ? null : parsedMetrics,
target_date:
resolvedBoardType === "general"
@ -1137,6 +1166,112 @@ export default function EditBoardPage() {
</div>
</section>
<section className="space-y-3 border-t border-border pt-4">
<div>
<h2 className="text-base font-semibold text-foreground">
Error escalation
</h2>
<p className="text-xs text-muted-foreground">
When enabled, agents are <strong>required</strong> to report
errors to a designated escalation agent. Disabling this rule
means agents may handle errors silently.
</p>
</div>
<div className="flex items-start gap-3 rounded-lg border border-border px-3 py-3">
<button
type="button"
role="switch"
aria-checked={resolvedErrorEscalationEnabled}
aria-label="Enable error escalation"
onClick={() => setErrorEscalationEnabled(!resolvedErrorEscalationEnabled)}
disabled={isLoading}
className={`mt-0.5 inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition ${
resolvedErrorEscalationEnabled
? "border-emerald-600 bg-emerald-600"
: "border-input bg-border"
} ${isLoading ? "cursor-not-allowed opacity-60" : "cursor-pointer"}`}
>
<span
className={`inline-block h-5 w-5 rounded-full bg-card shadow-sm transition ${
resolvedErrorEscalationEnabled ? "translate-x-5" : "translate-x-0.5"
}`}
/>
</button>
<span className="space-y-1">
<span className="block text-sm font-medium text-foreground">
Mandatory error escalation
</span>
<span className="block text-xs text-muted-foreground">
Agents <strong>must</strong> call the error report endpoint
when they encounter failures. Enforced via board policy in
the agent API.
</span>
</span>
</div>
{resolvedErrorEscalationEnabled ? (
<>
<div className="space-y-1.5 rounded-lg border border-border px-3 py-3">
<label className="block text-sm font-medium text-foreground">
Escalation agent
</label>
<p className="text-xs text-muted-foreground">
The agent that receives error notifications. Defaults to
the board lead if none is selected.
</p>
<select
value={resolvedErrorEscalationAgentId ?? ""}
onChange={(e) =>
setErrorEscalationAgentId(e.target.value || null)
}
disabled={isLoading}
className="mt-1 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">Board lead (default)</option>
{webhookAgents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name}
{agent.is_board_lead ? " (lead)" : ""}
</option>
))}
</select>
</div>
<div className="flex items-start gap-3 rounded-lg border border-border px-3 py-3">
<button
type="button"
role="switch"
aria-checked={resolvedErrorAutoOpenIssue}
aria-label="Auto-open Forgejo issue on error"
onClick={() => setErrorAutoOpenIssue(!resolvedErrorAutoOpenIssue)}
disabled={isLoading}
className={`mt-0.5 inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition ${
resolvedErrorAutoOpenIssue
? "border-emerald-600 bg-emerald-600"
: "border-input bg-border"
} ${isLoading ? "cursor-not-allowed opacity-60" : "cursor-pointer"}`}
>
<span
className={`inline-block h-5 w-5 rounded-full bg-card shadow-sm transition ${
resolvedErrorAutoOpenIssue ? "translate-x-5" : "translate-x-0.5"
}`}
/>
</button>
<span className="space-y-1">
<span className="block text-sm font-medium text-foreground">
Auto-open Git issue on error
</span>
<span className="block text-xs text-muted-foreground">
Automatically create a Forgejo issue on all linked
repositories whenever an agent reports an error.
</span>
</span>
</div>
</>
) : null}
</section>
{gateways.length === 0 ? (
<div className="rounded-lg border border-border bg-muted px-4 py-3 text-sm text-muted-foreground">
<p>

View File

@ -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
>
<ForgejoIssueFilters
stateFilter={stateFilter}
onStateChange={(v) => {
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}
/>
<div className="flex flex-wrap items-start justify-between gap-3">
<ForgejoIssueFilters
stateFilter={stateFilter}
onStateChange={(v) => {
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 ? (
<Button
size="sm"
onClick={() => setCreateDialogOpen(true)}
>
Create Issue
</Button>
) : null}
</div>
{staleOnly ? (
<div className="mb-4 flex flex-col gap-3 rounded-xl border border-[color:var(--warning)]/35 bg-[color:rgba(251,191,36,0.12)] p-3 text-sm text-[color:var(--warning)] sm:flex-row sm:items-center sm:justify-between">
@ -319,6 +331,15 @@ export default function GitIssuesPage() {
</div>
</div>
)}
<CreateForgejoIssueDialog
repositories={repos}
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onSuccess={() => {
setCreateDialogOpen(false);
handleRefresh();
}}
/>
</DashboardPageLayout>
);
}

View File

@ -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<string | null>(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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Create issue</DialogTitle>
<DialogDescription>
Open a new issue on the connected Git repository. The body is
pre-filled with the standard issue template.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{repositories.length > 1 ? (
<div className="space-y-1.5">
<label className="text-sm font-medium text-strong">
Repository
</label>
<select
value={repositoryId}
onChange={(e) => setRepositoryId(e.target.value)}
disabled={isSubmitting}
className="w-full rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 text-sm text-strong focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)]"
>
{repositories.map((repo) => (
<option key={repo.id} value={repo.id}>
{repo.display_name || `${repo.owner}/${repo.repo}`}
</option>
))}
</select>
</div>
) : (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-3 py-2 text-sm text-muted">
Repository:{" "}
<span className="font-medium text-strong">{repoLabel}</span>
</div>
)}
<div className="space-y-1.5">
<label className="text-sm font-medium text-strong">
Title <span className="text-[color:var(--danger)]">*</span>
</label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={isSubmitting}
placeholder="Short, descriptive issue title"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-strong">Body</label>
<p className="text-xs text-muted">
Fill in the sections below. Remove any that don&apos;t apply.
</p>
<Textarea
value={body}
onChange={(e) => setBody(e.target.value)}
rows={22}
disabled={isSubmitting}
className="resize-y font-mono text-xs"
/>
</div>
</div>
{error ? (
<div className="rounded-lg border border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] p-3 text-xs text-[color:var(--danger)]">
{error}
</div>
) : null}
<DialogFooter>
<Button
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={isSubmitting || !title.trim() || !repositoryId}
>
{isSubmitting ? "Creating…" : "Create Issue"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -371,6 +371,73 @@ export async function getForgejoIssue(
return fetchJson<ForgejoIssueDetail>(`/api/v1/forgejo/issues/${issueId}`);
}
export const 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
\`\`\``;
export interface CreateIssueRequest {
repository_id: string;
title: string;
body: string;
}
export interface CreateIssueResponse {
success: boolean;
issue_id: string;
forgejo_issue_number: number;
html_url: string;
repository_id: string;
}
export async function createForgejoIssue(
data: CreateIssueRequest,
): Promise<CreateIssueResponse> {
return fetchJson<CreateIssueResponse>("/api/v1/forgejo/issues", {
method: "POST",
body: JSON.stringify(data),
});
}
export async function closeForgejoIssue(
issueId: string,
): Promise<ForgejoIssue> {