"""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), }, )