diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 739f46f..faf34e2 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -42,6 +42,7 @@ from app.models.task_dependencies import TaskDependency from app.models.task_fingerprints import TaskFingerprint from app.models.tasks import Task from app.schemas.activity_events import ActivityEventRead +from app.services.forgejo_issue_close_on_done import close_linked_issue_on_done from app.schemas.common import OkResponse from app.schemas.errors import BlockedTaskError from app.schemas.pagination import DefaultLimitOffsetPage @@ -2739,6 +2740,11 @@ async def _finalize_updated_task( await _record_task_comment_from_update(session, update=update) await _record_task_update_activity(session, update=update) await _notify_task_update_assignment_changes(session, update=update) + await close_linked_issue_on_done( + session, + task=update.task, + previous_status=update.previous_status, + ) return await _task_read_response( session, diff --git a/backend/app/models/boards.py b/backend/app/models/boards.py index 654744a..bed9e6b 100644 --- a/backend/app/models/boards.py +++ b/backend/app/models/boards.py @@ -50,5 +50,6 @@ class Board(TenantScoped, table=True): default=None, foreign_key="agents.id", index=True ) error_auto_open_issue: bool = Field(default=False) + auto_close_issue_on_task_done: 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 15b50a0..deb6c86 100644 --- a/backend/app/schemas/boards.py +++ b/backend/app/schemas/boards.py @@ -38,6 +38,7 @@ class BoardBase(SQLModel): error_escalation_enabled: bool = False error_escalation_agent_id: UUID | None = None error_auto_open_issue: bool = False + auto_close_issue_on_task_done: bool = False class BoardCreate(BoardBase): @@ -86,6 +87,7 @@ class BoardUpdate(SQLModel): error_escalation_enabled: bool | None = None error_escalation_agent_id: UUID | None = None error_auto_open_issue: bool | None = None + auto_close_issue_on_task_done: bool | None = None @model_validator(mode="after") def validate_gateway_id(self) -> Self: diff --git a/backend/app/services/forgejo_issue_close_on_done.py b/backend/app/services/forgejo_issue_close_on_done.py new file mode 100644 index 0000000..bb866fc --- /dev/null +++ b/backend/app/services/forgejo_issue_close_on_done.py @@ -0,0 +1,188 @@ +"""Auto-close a linked Forgejo issue when its Pipeline task is marked done. + +Called from the task update path after the task commit. Looks up the +ForgejoIssueTaskLink for the completed task, derives a conventional-commit +closing comment, posts it, then closes the issue on Forgejo. + +Conventional-commit type is inferred from the issue's labels: + fix — bug / defect / hotfix / error + docs — doc / documentation / readme + test — test / testing / spec + refactor — refactor / cleanup + chore — chore / ci / cd / maintenance / deps + style — style / css / design + feat — everything else (default) +""" + +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.db import crud +from app.models.agents import Agent +from app.models.boards import Board +from app.models.forgejo_issue_task_links import ForgejoIssueTaskLink +from app.models.forgejo_issues import ForgejoIssue +from app.models.forgejo_repositories import ForgejoRepository +from app.models.tasks import Task +from app.services.forgejo_issue_close import CloseIssueError, close_cached_issue +from app.services.forgejo_issue_comment import PostCommentError, post_comment_on_issue + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio.session import AsyncSession + +logger = get_logger(__name__) + +# ── conventional-commit helpers ─────────────────────────────────────────────── + +_TYPE_RULES: list[tuple[tuple[str, ...], str]] = [ + (("bug", "fix", "defect", "error", "hotfix", "regression"), "fix"), + (("doc", "docs", "documentation", "readme"), "docs"), + (("test", "testing", "spec", "coverage"), "test"), + (("refactor", "cleanup", "clean-up", "restructure"), "refactor"), + (("chore", "ci", "cd", "maintenance", "deps", "dependency", "dependencies"), "chore"), + (("style", "css", "design", "ui", "ux", "visual"), "style"), + (("feat", "feature", "enhancement", "new", "add"), "feat"), +] + + +def _commit_type(labels: list[dict]) -> str: + joined = " ".join( + str(lbl.get("name", "")).lower() + for lbl in labels + if isinstance(lbl, dict) + ) + for keywords, commit_type in _TYPE_RULES: + if any(kw in joined for kw in keywords): + return commit_type + return "feat" + + +def _commit_scope(repository: ForgejoRepository) -> str: + return repository.repo.lower() + + +def _closing_comment( + *, + issue: ForgejoIssue, + repository: ForgejoRepository, + task: Task, + board: Board, + agent: Agent | None, +) -> str: + commit_type = _commit_type(list(issue.labels or [])) + scope = _commit_scope(repository) + description = issue.title.strip() + commit_line = f"{commit_type}({scope}): {description}" + + lines = [ + "Closed automatically by Pipeline — task marked done.", + "", + f"```", + commit_line, + f"```", + "", + f"**Board:** {board.name}", + f"**Task:** {task.title}", + ] + if agent: + lines.append(f"**Completed by:** {agent.name}") + + return "\n".join(lines) + + +# ── main entry point ────────────────────────────────────────────────────────── + +async def close_linked_issue_on_done( + session: AsyncSession, + *, + task: Task, + previous_status: str, +) -> None: + """Close the Forgejo issue linked to `task` if the board rule is enabled. + + No-ops silently when: + - The task didn't just transition to done + - The board has auto_close_issue_on_task_done disabled + - No ForgejoIssueTaskLink exists for this task + - The issue is already closed + """ + if task.status != "done" or previous_status == "done": + return + if not task.board_id: + return + + board = await crud.get_by_id(session, Board, task.board_id) + if board is None or not board.auto_close_issue_on_task_done: + return + + link = ( + await session.exec( + select(ForgejoIssueTaskLink).where( + ForgejoIssueTaskLink.task_id == task.id, + ) + ) + ).first() + if link is None: + return + + issue = await crud.get_by_id(session, ForgejoIssue, link.issue_id) + if issue is None or issue.state == "closed": + return + + repository = await crud.get_by_id(session, ForgejoRepository, issue.repository_id) + if repository is None: + return + + agent: Agent | None = None + if task.assigned_agent_id: + agent = await crud.get_by_id(session, Agent, task.assigned_agent_id) + + comment_body = _closing_comment( + issue=issue, + repository=repository, + task=task, + board=board, + agent=agent, + ) + + try: + await post_comment_on_issue(session, issue=issue, body=comment_body) + except PostCommentError as exc: + logger.warning( + "auto_close_issue.comment_failed", + extra={ + "task_id": str(task.id), + "issue_id": str(issue.id), + "error": str(exc), + }, + ) + + try: + await close_cached_issue( + session, + issue=issue, + actor_agent_id=task.assigned_agent_id, + ) + logger.info( + "auto_close_issue.closed", + extra={ + "task_id": str(task.id), + "issue_id": str(issue.id), + "forgejo_issue_number": issue.forgejo_issue_number, + "repository": f"{repository.owner}/{repository.repo}", + }, + ) + except CloseIssueError as exc: + logger.warning( + "auto_close_issue.close_failed", + extra={ + "task_id": str(task.id), + "issue_id": str(issue.id), + "error": str(exc), + }, + ) diff --git a/backend/migrations/versions/f2a3b4c5d6e7_add_board_auto_close_issue_on_task_done.py b/backend/migrations/versions/f2a3b4c5d6e7_add_board_auto_close_issue_on_task_done.py new file mode 100644 index 0000000..cd73839 --- /dev/null +++ b/backend/migrations/versions/f2a3b4c5d6e7_add_board_auto_close_issue_on_task_done.py @@ -0,0 +1,33 @@ +"""Add auto_close_issue_on_task_done to boards. + +Revision ID: f2a3b4c5d6e7 +Revises: e1f2a3b4c5d6 +Create Date: 2026-05-22 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "f2a3b4c5d6e7" +down_revision = "e1f2a3b4c5d6" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "boards", + sa.Column( + "auto_close_issue_on_task_done", + sa.Boolean(), + nullable=False, + server_default="false", + ), + ) + op.alter_column("boards", "auto_close_issue_on_task_done", server_default=None) + + +def downgrade() -> None: + op.drop_column("boards", "auto_close_issue_on_task_done") diff --git a/frontend/src/app/boards/[boardId]/edit/page.tsx b/frontend/src/app/boards/[boardId]/edit/page.tsx index 98df0a4..055b6c0 100644 --- a/frontend/src/app/boards/[boardId]/edit/page.tsx +++ b/frontend/src/app/boards/[boardId]/edit/page.tsx @@ -305,6 +305,7 @@ export default function EditBoardPage() { const [errorEscalationEnabled, setErrorEscalationEnabled] = useState(undefined); const [errorEscalationAgentId, setErrorEscalationAgentId] = useState(undefined); const [errorAutoOpenIssue, setErrorAutoOpenIssue] = useState(undefined); + const [autoCloseIssueOnTaskDone, setAutoCloseIssueOnTaskDone] = useState(undefined); const [successMetrics, setSuccessMetrics] = useState( undefined, ); @@ -533,6 +534,8 @@ export default function EditBoardPage() { : (baseboardExtended?.error_escalation_agent_id ?? null); const resolvedErrorAutoOpenIssue = errorAutoOpenIssue ?? baseboardExtended?.error_auto_open_issue ?? false; + const resolvedAutoCloseIssueOnTaskDone = + autoCloseIssueOnTaskDone ?? (baseboardExtended as typeof baseboardExtended & { auto_close_issue_on_task_done?: boolean } | null)?.auto_close_issue_on_task_done ?? false; const resolvedSuccessMetrics = successMetrics ?? (baseBoard?.success_metrics @@ -624,6 +627,7 @@ export default function EditBoardPage() { setErrorEscalationEnabled(ext.error_escalation_enabled ?? false); setErrorEscalationAgentId(ext.error_escalation_agent_id ?? null); setErrorAutoOpenIssue(ext.error_auto_open_issue ?? false); + setAutoCloseIssueOnTaskDone((ext as typeof ext & { auto_close_issue_on_task_done?: boolean }).auto_close_issue_on_task_done ?? false); setSuccessMetrics( updated.success_metrics ? JSON.stringify(updated.success_metrics, null, 2) @@ -696,6 +700,7 @@ export default function EditBoardPage() { error_escalation_enabled: resolvedErrorEscalationEnabled, error_escalation_agent_id: resolvedErrorEscalationAgentId ?? undefined, error_auto_open_issue: resolvedErrorAutoOpenIssue, + auto_close_issue_on_task_done: resolvedAutoCloseIssueOnTaskDone, }) as Record), success_metrics: resolvedBoardType === "general" ? null : parsedMetrics, target_date: @@ -1164,6 +1169,37 @@ export default function EditBoardPage() { +
+ + + + Auto-close Git issue when task is done + + + When a task linked to a Forgejo issue is marked{" "} + done, Pipeline posts a closing comment in + conventional-commit format and closes the issue on Forgejo. + + +