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.task_fingerprints import TaskFingerprint
|
||||||
from app.models.tasks import Task
|
from app.models.tasks import Task
|
||||||
from app.schemas.activity_events import ActivityEventRead
|
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.common import OkResponse
|
||||||
from app.schemas.errors import BlockedTaskError
|
from app.schemas.errors import BlockedTaskError
|
||||||
from app.schemas.pagination import DefaultLimitOffsetPage
|
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_comment_from_update(session, update=update)
|
||||||
await _record_task_update_activity(session, update=update)
|
await _record_task_update_activity(session, update=update)
|
||||||
await _notify_task_update_assignment_changes(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(
|
return await _task_read_response(
|
||||||
session,
|
session,
|
||||||
|
|
|
||||||
|
|
@ -50,5 +50,6 @@ class Board(TenantScoped, table=True):
|
||||||
default=None, foreign_key="agents.id", index=True
|
default=None, foreign_key="agents.id", index=True
|
||||||
)
|
)
|
||||||
error_auto_open_issue: bool = Field(default=False)
|
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)
|
created_at: datetime = Field(default_factory=utcnow)
|
||||||
updated_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_enabled: bool = False
|
||||||
error_escalation_agent_id: UUID | None = None
|
error_escalation_agent_id: UUID | None = None
|
||||||
error_auto_open_issue: bool = False
|
error_auto_open_issue: bool = False
|
||||||
|
auto_close_issue_on_task_done: bool = False
|
||||||
|
|
||||||
|
|
||||||
class BoardCreate(BoardBase):
|
class BoardCreate(BoardBase):
|
||||||
|
|
@ -86,6 +87,7 @@ class BoardUpdate(SQLModel):
|
||||||
error_escalation_enabled: bool | None = None
|
error_escalation_enabled: bool | None = None
|
||||||
error_escalation_agent_id: UUID | None = None
|
error_escalation_agent_id: UUID | None = None
|
||||||
error_auto_open_issue: bool | None = None
|
error_auto_open_issue: bool | None = None
|
||||||
|
auto_close_issue_on_task_done: bool | None = None
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_gateway_id(self) -> Self:
|
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 [errorEscalationEnabled, setErrorEscalationEnabled] = useState<boolean | undefined>(undefined);
|
||||||
const [errorEscalationAgentId, setErrorEscalationAgentId] = useState<string | null | undefined>(undefined);
|
const [errorEscalationAgentId, setErrorEscalationAgentId] = useState<string | null | undefined>(undefined);
|
||||||
const [errorAutoOpenIssue, setErrorAutoOpenIssue] = useState<boolean | undefined>(undefined);
|
const [errorAutoOpenIssue, setErrorAutoOpenIssue] = useState<boolean | undefined>(undefined);
|
||||||
|
const [autoCloseIssueOnTaskDone, setAutoCloseIssueOnTaskDone] = useState<boolean | undefined>(undefined);
|
||||||
const [successMetrics, setSuccessMetrics] = useState<string | undefined>(
|
const [successMetrics, setSuccessMetrics] = useState<string | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
@ -533,6 +534,8 @@ export default function EditBoardPage() {
|
||||||
: (baseboardExtended?.error_escalation_agent_id ?? null);
|
: (baseboardExtended?.error_escalation_agent_id ?? null);
|
||||||
const resolvedErrorAutoOpenIssue =
|
const resolvedErrorAutoOpenIssue =
|
||||||
errorAutoOpenIssue ?? baseboardExtended?.error_auto_open_issue ?? false;
|
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 =
|
const resolvedSuccessMetrics =
|
||||||
successMetrics ??
|
successMetrics ??
|
||||||
(baseBoard?.success_metrics
|
(baseBoard?.success_metrics
|
||||||
|
|
@ -624,6 +627,7 @@ export default function EditBoardPage() {
|
||||||
setErrorEscalationEnabled(ext.error_escalation_enabled ?? false);
|
setErrorEscalationEnabled(ext.error_escalation_enabled ?? false);
|
||||||
setErrorEscalationAgentId(ext.error_escalation_agent_id ?? null);
|
setErrorEscalationAgentId(ext.error_escalation_agent_id ?? null);
|
||||||
setErrorAutoOpenIssue(ext.error_auto_open_issue ?? false);
|
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(
|
setSuccessMetrics(
|
||||||
updated.success_metrics
|
updated.success_metrics
|
||||||
? JSON.stringify(updated.success_metrics, null, 2)
|
? JSON.stringify(updated.success_metrics, null, 2)
|
||||||
|
|
@ -696,6 +700,7 @@ export default function EditBoardPage() {
|
||||||
error_escalation_enabled: resolvedErrorEscalationEnabled,
|
error_escalation_enabled: resolvedErrorEscalationEnabled,
|
||||||
error_escalation_agent_id: resolvedErrorEscalationAgentId ?? undefined,
|
error_escalation_agent_id: resolvedErrorEscalationAgentId ?? undefined,
|
||||||
error_auto_open_issue: resolvedErrorAutoOpenIssue,
|
error_auto_open_issue: resolvedErrorAutoOpenIssue,
|
||||||
|
auto_close_issue_on_task_done: resolvedAutoCloseIssueOnTaskDone,
|
||||||
}) as Record<string, unknown>),
|
}) as Record<string, unknown>),
|
||||||
success_metrics: resolvedBoardType === "general" ? null : parsedMetrics,
|
success_metrics: resolvedBoardType === "general" ? null : parsedMetrics,
|
||||||
target_date:
|
target_date:
|
||||||
|
|
@ -1164,6 +1169,37 @@ export default function EditBoardPage() {
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<section className="space-y-3 border-t border-border pt-4">
|
<section className="space-y-3 border-t border-border pt-4">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue