feat(boards): add auto_close_issue_on_task_done flag and implement issue closure on task completion
This commit is contained in:
parent
ff545bff34
commit
a58bbe9f99
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
)
|
||||
|
|
@ -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")
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue