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
21 changes: 21 additions & 0 deletions apps/api/cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/Devlaner/devlane/api/internal/rabbitmq"
"github.com/Devlaner/devlane/api/internal/redis"
"github.com/Devlaner/devlane/api/internal/router"
"github.com/Devlaner/devlane/api/internal/service"
"github.com/Devlaner/devlane/api/internal/store"
)

Expand Down Expand Up @@ -124,6 +125,26 @@ func main() {
}
}

// Periodically auto-archive settled work items for projects that opt in
// (archive_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)
defer ticker.Stop()
for {
select {
case <-consumerCtx.Done():
return
case <-ticker.C:
if n, err := automationSvc.RunAutoArchive(consumerCtx); err != nil {
log.Warn("auto-archive", "error", err)
} else if n > 0 {
log.Info("auto-archive", "archived", n)
}
}
}
}()

addr := ":" + cfg.ServerPort
srv := &http.Server{
Addr: addr,
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 @@ -223,6 +223,7 @@ func (h *ProjectHandler) Create(c *gin.Context) {
body.PageView,
body.IntakeView,
body.IsTimeTrackingEnabled,
nil, // archive_in: set via project settings, not on create
)
if err != nil {
if err == service.ErrInvalidNetwork {
Expand Down Expand Up @@ -271,6 +272,7 @@ func (h *ProjectHandler) Update(c *gin.Context) {
PageView *bool `json:"page_view"`
IntakeView *bool `json:"intake_view"`
IsTimeTrackingEnabled *bool `json:"is_time_tracking_enabled"`
ArchiveIn *int `json:"archive_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 @@ -328,13 +330,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)
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)
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 {
if err == service.ErrProjectIdentifierTooLong || err == service.ErrInvalidNetwork || err == service.ErrInvalidArchiveIn {
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 @@ -76,6 +76,9 @@ type Project struct {
GuestViewAllFeatures bool `gorm:"column:guest_view_all_features;default:false" json:"guest_view_all_features"`
CoverImage string `gorm:"column:cover_image;type:text" json:"cover_image,omitempty"`
Timezone string `gorm:"default:UTC" json:"timezone"`
// 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"`
}

func (Project) TableName() string { return "projects" }
Expand Down
43 changes: 43 additions & 0 deletions apps/api/internal/service/automation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package service

import (
"context"
"time"

"github.com/Devlaner/devlane/api/internal/store"
)

// AutomationService runs project-level background automations.
type AutomationService struct {
projects *store.ProjectStore
issues *store.IssueStore
}

func NewAutomationService(projects *store.ProjectStore, issues *store.IssueStore) *AutomationService {
return &AutomationService{projects: projects, issues: issues}
}

// RunAutoArchive archives settled (completed/cancelled) work items in every
// project that has auto-archive enabled, once they have been untouched for the
// project's configured number of months. Returns the total archived. Safe to run
// repeatedly: already-archived items are skipped.
func (s *AutomationService) RunAutoArchive(ctx context.Context) (int64, error) {
projects, err := s.projects.ListWithAutoArchive(ctx)
if err != nil {
return 0, err
}
var total int64
for i := range projects {
p := &projects[i]
if p.ArchiveIn <= 0 {
continue
}
cutoff := time.Now().AddDate(0, -p.ArchiveIn, 0)
n, err := s.issues.ArchiveSettledBefore(ctx, p.ID, cutoff)
if err != nil {
return total, err
}
total += n
}
return total, nil
}
89 changes: 89 additions & 0 deletions apps/api/internal/service/automation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package service_test

import (
"context"
"testing"
"time"

"github.com/Devlaner/devlane/api/internal/model"
"github.com/Devlaner/devlane/api/internal/service"
"github.com/Devlaner/devlane/api/internal/store"
"github.com/Devlaner/devlane/api/internal/testutil"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)

func isArchived(t *testing.T, db *gorm.DB, id uuid.UUID) bool {
t.Helper()
var iss model.Issue
require.NoError(t, db.First(&iss, "id = ?", id).Error)
return iss.ArchivedAt != nil
}

// Auto-archive only touches work items that are both settled (completed/cancelled
// state) and untouched for longer than the project's archive_in months; active or
// recently-settled items are left alone. Covers #194.
func TestAutoArchive_ArchivesOnlySettledStaleIssues(t *testing.T) {
ts := testutil.NewTestServer(t)
w := testutil.SeedWorld(t, ts.DB)

// Enable auto-archive after 1 month.
require.NoError(t, ts.DB.Model(&model.Project{}).Where("id = ?", w.Project.ID).
UpdateColumn("archive_in", 1).Error)

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()

// settled + stale -> should archive.
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)

// settled + recent -> should not archive.
settledRecent := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID)
require.NoError(t, ts.DB.Model(&model.Issue{}).Where("id = ?", settledRecent.ID).
UpdateColumns(map[string]any{"state_id": doneState.ID, "updated_at": now}).Error)

// active (no state -> backlog) + stale -> should not archive.
activeOld := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID)
require.NoError(t, ts.DB.Model(&model.Issue{}).Where("id = ?", activeOld.ID).
UpdateColumn("updated_at", old).Error)

svc := service.NewAutomationService(store.NewProjectStore(ts.DB), store.NewIssueStore(ts.DB))
n, err := svc.RunAutoArchive(context.Background())
require.NoError(t, err)
require.EqualValues(t, 1, n, "only the stale settled issue should be archived")

require.True(t, isArchived(t, ts.DB, settledOld.ID))
require.False(t, isArchived(t, ts.DB, settledRecent.ID))
require.False(t, isArchived(t, ts.DB, activeOld.ID))

// Running again is a no-op (already archived).
n2, err := svc.RunAutoArchive(context.Background())
require.NoError(t, err)
require.EqualValues(t, 0, n2)
}

// A project with auto-archive disabled (archive_in = 0) is never touched.
func TestAutoArchive_DisabledProjectUntouched(t *testing.T) {
ts := testutil.NewTestServer(t)
w := testutil.SeedWorld(t, ts.DB) // archive_in defaults to 0

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, -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).
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.RunAutoArchive(context.Background())
require.NoError(t, err)
require.EqualValues(t, 0, n)
require.False(t, isArchived(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 @@ -19,6 +19,7 @@ var (
ErrProjectForbidden = errors.New("no access to this project")
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")
)

// ProjectService handles project business logic.
Expand Down Expand Up @@ -134,7 +135,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) (*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) (*model.Project, error) {
p, err := s.GetByID(ctx, workspaceSlug, projectID, userID)
if err != nil {
return nil, err
Expand Down Expand Up @@ -202,6 +203,12 @@ func (s *ProjectService) Update(ctx context.Context, workspaceSlug string, proje
if isTimeTrackingEnabled != nil {
p.IsTimeTrackingEnabled = *isTimeTrackingEnabled
}
if archiveIn != nil {
if *archiveIn < 0 {
return nil, ErrInvalidArchiveIn
}
p.ArchiveIn = *archiveIn
}
if err := s.ps.Update(ctx, p); err != nil {
return nil, err
}
Expand Down
17 changes: 17 additions & 0 deletions apps/api/internal/store/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,23 @@ func (s *IssueStore) SetArchived(ctx context.Context, id uuid.UUID, archived boo
Updates(updates).Error
}

// ArchiveSettledBefore archives (sets archived_at) the project's non-archived,
// non-draft work items that sit in a completed/cancelled state and were last
// touched before cutoff. Returns how many were archived. Used by auto-archive.
func (s *IssueStore) ArchiveSettledBefore(ctx context.Context, projectID uuid.UUID, cutoff time.Time) (int64, error) {
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 IN (SELECT id FROM states WHERE project_id = ? AND "group" IN ('completed','cancelled'))`,
projectID, cutoff, projectID).
Update("archived_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 @@ -92,6 +92,16 @@ func (s *ProjectStore) GetByWorkspaceAndIdentifier(ctx context.Context, workspac
}

// IsInWorkspace checks that the project belongs to the workspace.
// ListWithAutoArchive returns projects that have auto-archive enabled (archive_in > 0).
func (s *ProjectStore) ListWithAutoArchive(ctx context.Context) ([]model.Project, error) {
var list []model.Project
err := s.db.WithContext(ctx).Where("archive_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
1 change: 1 addition & 0 deletions apps/api/migrations/000007_project_archive_in.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE projects DROP COLUMN IF EXISTS archive_in;
3 changes: 3 additions & 0 deletions apps/api/migrations/000007_project_archive_in.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- Auto-archive: number of months after which settled (completed/cancelled) work
-- items are archived. 0 disables the automation for the project.
ALTER TABLE projects ADD COLUMN IF NOT EXISTS archive_in INT NOT NULL DEFAULT 0;
2 changes: 2 additions & 0 deletions apps/web/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ export interface ProjectApiResponse {
page_view?: boolean;
intake_view?: boolean;
is_time_tracking_enabled?: boolean;
/** Auto-archive: months of inactivity after which settled items archive (0 = off). */
archive_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 @@ -232,6 +232,9 @@ export function SettingsPage() {
setFeaturePages(selectedProject.page_view ?? true);
setFeatureIntake(selectedProject.intake_view ?? false);
setFeatureTimeTracking(selectedProject.is_time_tracking_enabled ?? false);
const months = selectedProject.archive_in ?? 0;
setAutoArchive(months > 0);
if (months > 0) setAutoArchiveMonths(months);
}
}, [
selectedProject,
Expand All @@ -249,8 +252,31 @@ export function SettingsPage() {
selectedProject?.page_view,
selectedProject?.intake_view,
selectedProject?.is_time_tracking_enabled,
selectedProject?.archive_in,
]);

// Persist the auto-archive automation: archive_in is the number of months (0
// disables it). Optimistically flips the toggle, then saves.
const persistAutoArchive = async (enabled: boolean, months: number) => {
if (!workspaceSlug || !selectedProjectId) return;
setAutoArchive(enabled);
setAutoArchiveMonths(months);
setAutoArchiveSaving(true);
try {
const updated = await projectService.update(workspaceSlug, selectedProjectId, {
archive_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?.archive_in ?? 0;
setAutoArchive(persisted > 0);
if (persisted > 0) setAutoArchiveMonths(persisted);
} finally {
setAutoArchiveSaving(false);
}
};

const [workspaceName, setWorkspaceName] = useState('');
const [companySize, setCompanySize] = useState('51-200');
const [generalUpdateLoading, setGeneralUpdateLoading] = useState(false);
Expand Down Expand Up @@ -358,7 +384,9 @@ export function SettingsPage() {
const [featurePages, setFeaturePages] = useState(true);
const [featureIntake, setFeatureIntake] = useState(false);
const [featureTimeTracking, setFeatureTimeTracking] = useState(false);
const [autoArchive, setAutoArchive] = useState(true);
const [autoArchive, setAutoArchive] = useState(false);
const [autoArchiveMonths, setAutoArchiveMonths] = useState(3);
const [autoArchiveSaving, setAutoArchiveSaving] = useState(false);
const [autoClose, setAutoClose] = useState(true);
const [pendingInvitesExpanded, setPendingInvitesExpanded] = useState(true);
const [pendingInviteMenuId, setPendingInviteMenuId] = useState<string | null>(null);
Expand Down Expand Up @@ -2578,14 +2606,32 @@ export function SettingsPage() {
<p className="mt-0.5 text-sm text-(--txt-secondary)">
Devlane will auto archive work items that have been completed or canceled.
</p>
{autoArchive && (
<label className="mt-2 flex items-center gap-2 text-sm text-(--txt-secondary)">
Archive after
<select
value={autoArchiveMonths}
disabled={autoArchiveSaving}
onChange={(e) => persistAutoArchive(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={autoArchive}
aria-labelledby={`auto-archive-toggle-${selectedProjectId ?? 'project'}`}
onClick={() => setAutoArchive(!autoArchive)}
disabled={autoArchiveSaving}
onClick={() => persistAutoArchive(!autoArchive, autoArchiveMonths)}
className={`relative h-6 w-10 shrink-0 rounded-full transition-colors ${autoArchive ? 'bg-(--brand-default)' : 'bg-(--neutral-400)'}`}
>
<span
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/services/projectService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ export const projectService = {
page_view?: boolean;
intake_view?: boolean;
is_time_tracking_enabled?: boolean;
/** Auto-archive: months of inactivity after which settled items archive (0 = off). */
archive_in?: number;
},
): Promise<ProjectApiResponse> {
const { data } = await apiClient.patch<ProjectApiResponse>(
Expand Down
Loading