Pipeline/backend/app/services/forgejo_issue_close_on_done.py

189 lines
5.8 KiB
Python
Raw Permalink Normal View History

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