feat(boards): add auto_close_issue_on_task_done flag and implement issue closure on task completion

This commit is contained in:
null 2026-05-22 04:06:57 -05:00
parent ff545bff34
commit a58bbe9f99
6 changed files with 266 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -305,6 +305,7 @@ export default function EditBoardPage() {
const [errorEscalationEnabled, setErrorEscalationEnabled] = useState<boolean | undefined>(undefined);
const [errorEscalationAgentId, setErrorEscalationAgentId] = useState<string | null | undefined>(undefined);
const [errorAutoOpenIssue, setErrorAutoOpenIssue] = useState<boolean | undefined>(undefined);
const [autoCloseIssueOnTaskDone, setAutoCloseIssueOnTaskDone] = useState<boolean | undefined>(undefined);
const [successMetrics, setSuccessMetrics] = useState<string | undefined>(
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<string, unknown>),
success_metrics: resolvedBoardType === "general" ? null : parsedMetrics,
target_date:
@ -1164,6 +1169,37 @@ export default function EditBoardPage() {
</span>
</span>
</div>
<div className="flex items-start gap-3 rounded-lg border border-border px-3 py-3">
<button
type="button"
role="switch"
aria-checked={resolvedAutoCloseIssueOnTaskDone}
aria-label="Auto-close linked Git issue when task is done"
onClick={() => setAutoCloseIssueOnTaskDone(!resolvedAutoCloseIssueOnTaskDone)}
disabled={isLoading}
className={`mt-0.5 inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition ${
resolvedAutoCloseIssueOnTaskDone
? "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 ${
resolvedAutoCloseIssueOnTaskDone ? "translate-x-5" : "translate-x-0.5"
}`}
/>
</button>
<span className="space-y-1">
<span className="block text-sm font-medium text-foreground">
Auto-close Git issue when task is done
</span>
<span className="block text-xs text-muted-foreground">
When a task linked to a Forgejo issue is marked{" "}
<code>done</code>, Pipeline posts a closing comment in
conventional-commit format and closes the issue on Forgejo.
</span>
</span>
</div>
</section>
<section className="space-y-3 border-t border-border pt-4">