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 && (
+
+ )}