Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions apps/api/cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}
}
}
}()
Expand Down
6 changes: 4 additions & 2 deletions apps/api/internal/handler/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()})
Expand Down Expand Up @@ -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
}
Expand Down
3 changes: 3 additions & 0 deletions apps/api/internal/model/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
26 changes: 26 additions & 0 deletions apps/api/internal/service/automation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
101 changes: 101 additions & 0 deletions apps/api/internal/service/automation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
9 changes: 8 additions & 1 deletion apps/api/internal/service/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
33 changes: 33 additions & 0 deletions apps/api/internal/store/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package store
import (
"context"
"encoding/binary"
"errors"
"time"

"github.com/Devlaner/devlane/api/internal/model"
Expand Down Expand Up @@ -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(
Expand Down
10 changes: 10 additions & 0 deletions apps/api/internal/store/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}).
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
50 changes: 48 additions & 2 deletions apps/web/src/pages/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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<string | null>(null);
const pendingInviteMenuRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -2656,14 +2684,32 @@ export function SettingsPage() {
Devlane will automatically close work items that haven&apos;t been completed
or canceled.
</p>
{autoClose && (
<label className="mt-2 flex items-center gap-2 text-sm text-(--txt-secondary)">
Close after
<select
value={autoCloseMonths}
disabled={autoCloseSaving}
onChange={(e) => persistAutoClose(true, Number(e.target.value))}
className="rounded-(--radius-md) border border-(--border-subtle) bg-(--bg-surface-1) px-2 py-1 text-sm text-(--txt-primary) focus:outline-none focus:border-(--border-strong)"
>
<option value={1}>1 month</option>
<option value={3}>3 months</option>
<option value={6}>6 months</option>
<option value={12}>12 months</option>
</select>
of inactivity
</label>
)}
</div>
</div>
<button
type="button"
role="switch"
aria-checked={autoClose}
aria-labelledby={`auto-close-toggle-${selectedProjectId ?? 'project'}`}
onClick={() => setAutoClose(!autoClose)}
disabled={autoCloseSaving}
onClick={() => persistAutoClose(!autoClose, autoCloseMonths)}
className={`relative h-6 w-10 shrink-0 rounded-full transition-colors ${autoClose ? 'bg-(--brand-default)' : 'bg-(--neutral-400)'}`}
>
<span
Expand Down
Loading
Loading