189 lines
5.8 KiB
Python
189 lines
5.8 KiB
Python
"""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),
|
|
},
|
|
)
|