From 11a892df3db38922b988cd17ca7e69c3dd9b9fe9 Mon Sep 17 00:00:00 2001 From: cavidelizade Date: Sat, 11 Jul 2026 00:21:08 +0400 Subject: [PATCH] feat(projects): add auto-close automation for inactive work items The "Auto-close work items" project setting was a non-functional, local-state-only toggle. It now persists and drives a real automation, mirroring the existing auto-archive feature. Backend: Project.CloseIn (months; 0 = off, column already in the schema); IssueStore.CloseInactiveBefore moves non-archived, non-draft items that are not already in a completed/cancelled state and have been untouched past the cutoff into the project's cancelled ("closed") state, skipping projects with no cancelled state; AutomationService.RunAutoClose sweeps every opted-in project; the existing 6h in-process ticker runs it alongside auto-archive. close_in is threaded through project Update. Frontend: the settings toggle + a months selector read and persist close_in via projectService.update. Closes #193 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/cmd/api/main.go | 10 +- apps/api/internal/handler/project.go | 6 +- apps/api/internal/model/project.go | 3 + apps/api/internal/service/automation.go | 26 +++++ apps/api/internal/service/automation_test.go | 101 +++++++++++++++++++ apps/api/internal/service/project.go | 9 +- apps/api/internal/store/issue.go | 33 ++++++ apps/api/internal/store/project.go | 10 ++ apps/web/src/api/types.ts | 2 + apps/web/src/pages/SettingsPage.tsx | 50 ++++++++- apps/web/src/services/projectService.ts | 2 + 11 files changed, 245 insertions(+), 7 deletions(-) diff --git a/apps/api/cmd/api/main.go b/apps/api/cmd/api/main.go index 4f1d3680..d5793ef1 100644 --- a/apps/api/cmd/api/main.go +++ b/apps/api/cmd/api/main.go @@ -125,8 +125,9 @@ func main() { } } - // Periodically auto-archive settled work items for projects that opt in - // (archive_in > 0). Runs in-process; stops on shutdown via consumerCtx. + // Periodically run project automations for projects that opt in: auto-archive + // settled items (archive_in > 0) and auto-close inactive items (close_in > 0). + // Runs in-process; stops on shutdown via consumerCtx. automationSvc := service.NewAutomationService(store.NewProjectStore(db), store.NewIssueStore(db)) go func() { ticker := time.NewTicker(6 * time.Hour) @@ -141,6 +142,11 @@ func main() { } else if n > 0 { log.Info("auto-archive", "archived", n) } + if n, err := automationSvc.RunAutoClose(consumerCtx); err != nil { + log.Warn("auto-close", "error", err) + } else if n > 0 { + log.Info("auto-close", "closed", n) + } } } }() diff --git a/apps/api/internal/handler/project.go b/apps/api/internal/handler/project.go index a912182c..c53bfa6a 100644 --- a/apps/api/internal/handler/project.go +++ b/apps/api/internal/handler/project.go @@ -224,6 +224,7 @@ func (h *ProjectHandler) Create(c *gin.Context) { body.IntakeView, body.IsTimeTrackingEnabled, nil, // archive_in: set via project settings, not on create + nil, // close_in: set via project settings, not on create ) if err != nil { if err == service.ErrInvalidNetwork { @@ -273,6 +274,7 @@ func (h *ProjectHandler) Update(c *gin.Context) { IntakeView *bool `json:"intake_view"` IsTimeTrackingEnabled *bool `json:"is_time_tracking_enabled"` ArchiveIn *int `json:"archive_in"` + CloseIn *int `json:"close_in"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "detail": err.Error()}) @@ -330,13 +332,13 @@ func (h *ProjectHandler) Update(c *gin.Context) { defaultAssigneeIDPtr = &id } } - p, err := h.Project.Update(c.Request.Context(), slug, projectID, user.ID, name, identifier, description, timezone, coverImage, body.Emoji, iconProp, body.ProjectLeadID != nil, projectLeadIDPtr, body.DefaultAssigneeID != nil, defaultAssigneeIDPtr, body.GuestViewAllFeatures, body.Network, body.ModuleView, body.CycleView, body.IssueViewsView, body.PageView, body.IntakeView, body.IsTimeTrackingEnabled, body.ArchiveIn) + p, err := h.Project.Update(c.Request.Context(), slug, projectID, user.ID, name, identifier, description, timezone, coverImage, body.Emoji, iconProp, body.ProjectLeadID != nil, projectLeadIDPtr, body.DefaultAssigneeID != nil, defaultAssigneeIDPtr, body.GuestViewAllFeatures, body.Network, body.ModuleView, body.CycleView, body.IssueViewsView, body.PageView, body.IntakeView, body.IsTimeTrackingEnabled, body.ArchiveIn, body.CloseIn) if err != nil { if err == service.ErrProjectNotFound || err == service.ErrProjectForbidden { c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) return } - if err == service.ErrProjectIdentifierTooLong || err == service.ErrInvalidNetwork || err == service.ErrInvalidArchiveIn { + if err == service.ErrProjectIdentifierTooLong || err == service.ErrInvalidNetwork || err == service.ErrInvalidArchiveIn || err == service.ErrInvalidCloseIn { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } diff --git a/apps/api/internal/model/project.go b/apps/api/internal/model/project.go index cb48512d..d93896c7 100644 --- a/apps/api/internal/model/project.go +++ b/apps/api/internal/model/project.go @@ -79,6 +79,9 @@ type Project struct { // ArchiveIn is the number of months after which settled (completed/cancelled) // work items are auto-archived. 0 disables the automation. ArchiveIn int `gorm:"column:archive_in;default:0" json:"archive_in"` + // CloseIn is the number of months after which inactive (non-terminal) work + // items are auto-closed into the project's cancelled state. 0 disables it. + CloseIn int `gorm:"column:close_in;default:0" json:"close_in"` } func (Project) TableName() string { return "projects" } diff --git a/apps/api/internal/service/automation.go b/apps/api/internal/service/automation.go index 49a86d0a..f3b355c4 100644 --- a/apps/api/internal/service/automation.go +++ b/apps/api/internal/service/automation.go @@ -41,3 +41,29 @@ func (s *AutomationService) RunAutoArchive(ctx context.Context) (int64, error) { } return total, nil } + +// RunAutoClose moves inactive (non-terminal) work items into the closed state in +// every project that has auto-close enabled, once they have been untouched for +// the project's configured number of months. Returns the total closed. Safe to +// run repeatedly: already-closed items sit in the cancelled group and are +// skipped, and a project without a cancelled state is left untouched. +func (s *AutomationService) RunAutoClose(ctx context.Context) (int64, error) { + projects, err := s.projects.ListWithAutoClose(ctx) + if err != nil { + return 0, err + } + var total int64 + for i := range projects { + p := &projects[i] + if p.CloseIn <= 0 { + continue + } + cutoff := time.Now().AddDate(0, -p.CloseIn, 0) + n, err := s.issues.CloseInactiveBefore(ctx, p.ID, cutoff) + if err != nil { + return total, err + } + total += n + } + return total, nil +} diff --git a/apps/api/internal/service/automation_test.go b/apps/api/internal/service/automation_test.go index 73901292..2fcd5feb 100644 --- a/apps/api/internal/service/automation_test.go +++ b/apps/api/internal/service/automation_test.go @@ -87,3 +87,104 @@ func TestAutoArchive_DisabledProjectUntouched(t *testing.T) { require.EqualValues(t, 0, n) require.False(t, isArchived(t, ts.DB, iss.ID)) } + +func issueStateID(t *testing.T, db *gorm.DB, id uuid.UUID) *uuid.UUID { + t.Helper() + var iss model.Issue + require.NoError(t, db.First(&iss, "id = ?", id).Error) + return iss.StateID +} + +// Auto-close moves only inactive (non-terminal) work items that were untouched +// past the project's close_in months into the project's cancelled state; +// recently-touched items and items already in a completed/cancelled state are +// left alone. Covers #193. +func TestAutoClose_ClosesOnlyInactiveStaleIssues(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + + // Enable auto-close after 1 month. + require.NoError(t, ts.DB.Model(&model.Project{}).Where("id = ?", w.Project.ID). + UpdateColumn("close_in", 1).Error) + + // The close target: a state in the cancelled group. + cancelledState := testutil.CreateState(t, ts.DB, w.Project.ID, w.Workspace.ID) + require.NoError(t, ts.DB.Model(cancelledState).Updates(map[string]any{"group": "cancelled"}).Error) + // A completed state, to prove terminal items are left alone. + doneState := testutil.CreateState(t, ts.DB, w.Project.ID, w.Workspace.ID) + require.NoError(t, ts.DB.Model(doneState).Updates(map[string]any{"group": "completed"}).Error) + + old := time.Now().AddDate(0, -2, 0) // two months ago + now := time.Now() + + // active (no state -> backlog) + stale -> should close. + inactiveOld := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + require.NoError(t, ts.DB.Model(&model.Issue{}).Where("id = ?", inactiveOld.ID). + UpdateColumn("updated_at", old).Error) + + // active + recent -> should not close. + inactiveRecent := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + require.NoError(t, ts.DB.Model(&model.Issue{}).Where("id = ?", inactiveRecent.ID). + UpdateColumn("updated_at", now).Error) + + // already completed + stale -> should not close (already terminal). + settledOld := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + require.NoError(t, ts.DB.Model(&model.Issue{}).Where("id = ?", settledOld.ID). + UpdateColumns(map[string]any{"state_id": doneState.ID, "updated_at": old}).Error) + + svc := service.NewAutomationService(store.NewProjectStore(ts.DB), store.NewIssueStore(ts.DB)) + n, err := svc.RunAutoClose(context.Background()) + require.NoError(t, err) + require.EqualValues(t, 1, n, "only the stale active issue should be closed") + + closed := issueStateID(t, ts.DB, inactiveOld.ID) + require.NotNil(t, closed) + require.Equal(t, cancelledState.ID, *closed) + require.Nil(t, issueStateID(t, ts.DB, inactiveRecent.ID)) + require.Equal(t, doneState.ID, *issueStateID(t, ts.DB, settledOld.ID)) + + // Running again is a no-op (the closed item now sits in the cancelled group). + n2, err := svc.RunAutoClose(context.Background()) + require.NoError(t, err) + require.EqualValues(t, 0, n2) +} + +// A project with auto-close disabled (close_in = 0) is never touched. +func TestAutoClose_DisabledProjectUntouched(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) // close_in defaults to 0 + + cancelledState := testutil.CreateState(t, ts.DB, w.Project.ID, w.Workspace.ID) + require.NoError(t, ts.DB.Model(cancelledState).Updates(map[string]any{"group": "cancelled"}).Error) + + old := time.Now().AddDate(0, -12, 0) + iss := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + require.NoError(t, ts.DB.Model(&model.Issue{}).Where("id = ?", iss.ID). + UpdateColumn("updated_at", old).Error) + + svc := service.NewAutomationService(store.NewProjectStore(ts.DB), store.NewIssueStore(ts.DB)) + n, err := svc.RunAutoClose(context.Background()) + require.NoError(t, err) + require.EqualValues(t, 0, n) + require.Nil(t, issueStateID(t, ts.DB, iss.ID)) +} + +// A project with auto-close enabled but no cancelled state has nowhere to close +// to, so inactive items are left untouched. +func TestAutoClose_NoCancelledStateSkips(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + require.NoError(t, ts.DB.Model(&model.Project{}).Where("id = ?", w.Project.ID). + UpdateColumn("close_in", 1).Error) + + old := time.Now().AddDate(0, -6, 0) + iss := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + require.NoError(t, ts.DB.Model(&model.Issue{}).Where("id = ?", iss.ID). + UpdateColumn("updated_at", old).Error) + + svc := service.NewAutomationService(store.NewProjectStore(ts.DB), store.NewIssueStore(ts.DB)) + n, err := svc.RunAutoClose(context.Background()) + require.NoError(t, err) + require.EqualValues(t, 0, n) + require.Nil(t, issueStateID(t, ts.DB, iss.ID)) +} diff --git a/apps/api/internal/service/project.go b/apps/api/internal/service/project.go index 4fb16056..76e0fdaa 100644 --- a/apps/api/internal/service/project.go +++ b/apps/api/internal/service/project.go @@ -20,6 +20,7 @@ var ( ErrProjectIdentifierTooLong = errors.New("project identifier must be at most 7 characters") ErrInvalidNetwork = errors.New("network must be public or secret") ErrInvalidArchiveIn = errors.New("archive_in must be zero or a positive number of months") + ErrInvalidCloseIn = errors.New("close_in must be zero or a positive number of months") ) // ProjectService handles project business logic. @@ -135,7 +136,7 @@ func (s *ProjectService) Create(ctx context.Context, workspaceSlug, name, identi return p, nil } -func (s *ProjectService) Update(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID, name, identifier, description, timezone, coverImage *string, emoji *string, iconProp *model.JSONMap, projectLeadIDSet bool, projectLeadID *uuid.UUID, defaultAssigneeIDSet bool, defaultAssigneeID *uuid.UUID, guestViewAllFeatures *bool, network *int16, moduleView, cycleView, issueViewsView, pageView, intakeView, isTimeTrackingEnabled *bool, archiveIn *int) (*model.Project, error) { +func (s *ProjectService) Update(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID, name, identifier, description, timezone, coverImage *string, emoji *string, iconProp *model.JSONMap, projectLeadIDSet bool, projectLeadID *uuid.UUID, defaultAssigneeIDSet bool, defaultAssigneeID *uuid.UUID, guestViewAllFeatures *bool, network *int16, moduleView, cycleView, issueViewsView, pageView, intakeView, isTimeTrackingEnabled *bool, archiveIn *int, closeIn *int) (*model.Project, error) { p, err := s.GetByID(ctx, workspaceSlug, projectID, userID) if err != nil { return nil, err @@ -209,6 +210,12 @@ func (s *ProjectService) Update(ctx context.Context, workspaceSlug string, proje } p.ArchiveIn = *archiveIn } + if closeIn != nil { + if *closeIn < 0 { + return nil, ErrInvalidCloseIn + } + p.CloseIn = *closeIn + } if err := s.ps.Update(ctx, p); err != nil { return nil, err } diff --git a/apps/api/internal/store/issue.go b/apps/api/internal/store/issue.go index 63307db6..23c250c9 100644 --- a/apps/api/internal/store/issue.go +++ b/apps/api/internal/store/issue.go @@ -3,6 +3,7 @@ package store import ( "context" "encoding/binary" + "errors" "time" "github.com/Devlaner/devlane/api/internal/model" @@ -154,6 +155,38 @@ func (s *IssueStore) ArchiveSettledBefore(ctx context.Context, projectID uuid.UU return res.RowsAffected, nil } +// CloseInactiveBefore moves the project's non-archived, non-draft work items +// that are still active (not already in a completed/cancelled state, including +// items with no state) and were last touched before cutoff into the project's +// "closed" state: the lowest-sequence state in the cancelled group. Projects +// with no cancelled state are skipped (returns 0). Bumping updated_at gives a +// freshly-closed item its own clock before it can become auto-archive-eligible. +// Returns how many were closed. Used by auto-close. +func (s *IssueStore) CloseInactiveBefore(ctx context.Context, projectID uuid.UUID, cutoff time.Time) (int64, error) { + var closeState model.State + err := s.db.WithContext(ctx). + Where(`project_id = ? AND deleted_at IS NULL AND "group" = 'cancelled'`, projectID). + Order("sequence ASC, created_at ASC"). + First(&closeState).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, nil + } + if err != nil { + return 0, err + } + res := s.db.WithContext(ctx). + Model(&model.Issue{}). + Where(`project_id = ? AND deleted_at IS NULL AND archived_at IS NULL AND is_draft IS NOT TRUE + AND updated_at < ? + AND (state_id IS NULL OR state_id NOT IN (SELECT id FROM states WHERE project_id = ? AND "group" IN ('completed','cancelled')))`, + projectID, cutoff, projectID). + Updates(map[string]any{"state_id": closeState.ID, "updated_at": time.Now()}) + if res.Error != nil { + return 0, res.Error + } + return res.RowsAffected, nil +} + func (s *IssueStore) ListDraftsByWorkspaceID(ctx context.Context, workspaceID uuid.UUID, limit, offset int) ([]model.Issue, error) { var list []model.Issue q := s.db.WithContext(ctx).Where( diff --git a/apps/api/internal/store/project.go b/apps/api/internal/store/project.go index 804e8f6a..f8717cf5 100644 --- a/apps/api/internal/store/project.go +++ b/apps/api/internal/store/project.go @@ -102,6 +102,16 @@ func (s *ProjectStore) ListWithAutoArchive(ctx context.Context) ([]model.Project return list, nil } +// ListWithAutoClose returns projects that have auto-close enabled (close_in > 0). +func (s *ProjectStore) ListWithAutoClose(ctx context.Context) ([]model.Project, error) { + var list []model.Project + err := s.db.WithContext(ctx).Where("close_in > 0 AND deleted_at IS NULL").Find(&list).Error + if err != nil { + return nil, err + } + return list, nil +} + func (s *ProjectStore) IsInWorkspace(ctx context.Context, projectID, workspaceID uuid.UUID) (bool, error) { var count int64 err := s.db.WithContext(ctx).Model(&model.Project{}). diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index ad378df3..c8e630fa 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -108,6 +108,8 @@ export interface ProjectApiResponse { is_time_tracking_enabled?: boolean; /** Auto-archive: months of inactivity after which settled items archive (0 = off). */ archive_in?: number; + /** Auto-close: months of inactivity after which active items are closed (0 = off). */ + close_in?: number; created_at?: string; updated_at?: string; } diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx index 4c31b892..e7ac339e 100644 --- a/apps/web/src/pages/SettingsPage.tsx +++ b/apps/web/src/pages/SettingsPage.tsx @@ -236,6 +236,9 @@ export function SettingsPage() { const months = selectedProject.archive_in ?? 0; setAutoArchive(months > 0); if (months > 0) setAutoArchiveMonths(months); + const closeMonths = selectedProject.close_in ?? 0; + setAutoClose(closeMonths > 0); + if (closeMonths > 0) setAutoCloseMonths(closeMonths); } }, [ selectedProject, @@ -254,6 +257,7 @@ export function SettingsPage() { selectedProject?.intake_view, selectedProject?.is_time_tracking_enabled, selectedProject?.archive_in, + selectedProject?.close_in, ]); // Persist the auto-archive automation: archive_in is the number of months (0 @@ -278,6 +282,28 @@ export function SettingsPage() { } }; + // Persist the auto-close automation: close_in is the number of months (0 + // disables it). Optimistically flips the toggle, then saves. + const persistAutoClose = async (enabled: boolean, months: number) => { + if (!workspaceSlug || !selectedProjectId) return; + setAutoClose(enabled); + setAutoCloseMonths(months); + setAutoCloseSaving(true); + try { + const updated = await projectService.update(workspaceSlug, selectedProjectId, { + close_in: enabled ? months : 0, + }); + setProjects((prev) => prev.map((p) => (p.id === updated.id ? updated : p))); + } catch { + // Revert the toggle to the persisted value on failure. + const persisted = selectedProject?.close_in ?? 0; + setAutoClose(persisted > 0); + if (persisted > 0) setAutoCloseMonths(persisted); + } finally { + setAutoCloseSaving(false); + } + }; + const [workspaceName, setWorkspaceName] = useState(''); const [companySize, setCompanySize] = useState('51-200'); const [generalUpdateLoading, setGeneralUpdateLoading] = useState(false); @@ -388,7 +414,9 @@ export function SettingsPage() { const [autoArchive, setAutoArchive] = useState(false); const [autoArchiveMonths, setAutoArchiveMonths] = useState(3); const [autoArchiveSaving, setAutoArchiveSaving] = useState(false); - const [autoClose, setAutoClose] = useState(true); + const [autoClose, setAutoClose] = useState(false); + const [autoCloseMonths, setAutoCloseMonths] = useState(3); + const [autoCloseSaving, setAutoCloseSaving] = useState(false); const [pendingInvitesExpanded, setPendingInvitesExpanded] = useState(true); const [pendingInviteMenuId, setPendingInviteMenuId] = useState(null); const pendingInviteMenuRef = useRef(null); @@ -2656,6 +2684,23 @@ export function SettingsPage() { Devlane will automatically close work items that haven't been completed or canceled.

+ {autoClose && ( + + )}