diff --git a/apps/api/internal/handler/cycle.go b/apps/api/internal/handler/cycle.go index f2b857bf..0132419e 100644 --- a/apps/api/internal/handler/cycle.go +++ b/apps/api/internal/handler/cycle.go @@ -318,6 +318,59 @@ func (h *CycleHandler) Progress(c *gin.Context) { c.JSON(http.StatusOK, snap) } +// CompleteCycle marks a cycle completed and optionally transfers its incomplete +// work items to another cycle. +// POST /api/workspaces/:slug/projects/:projectId/cycles/:cycleId/transfer-issues/ +func (h *CycleHandler) CompleteCycle(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + slug := c.Param("slug") + projectID, err := uuid.Parse(c.Param("projectId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"}) + return + } + cycleID, err := uuid.Parse(c.Param("cycleId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid cycle ID"}) + return + } + var body struct { + TargetCycleID string `json:"target_cycle_id"` + } + // An empty body is allowed: complete without transferring. + if err := c.ShouldBindJSON(&body); err != nil && !errors.Is(err, io.EOF) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()}) + return + } + var targetCycleID *uuid.UUID + if body.TargetCycleID != "" { + tid, err := uuid.Parse(body.TargetCycleID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid target_cycle_id"}) + return + } + targetCycleID = &tid + } + cy, moved, err := h.Cycle.CompleteCycle(c.Request.Context(), slug, projectID, cycleID, targetCycleID, user.ID) + if err != nil { + if err == service.ErrCycleNotFound || err == service.ErrProjectForbidden || err == service.ErrProjectNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Not found"}) + return + } + if err == service.ErrInvalidTargetCycle { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid target cycle"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to complete cycle"}) + return + } + c.JSON(http.StatusOK, gin.H{"cycle": cy, "transferred_count": moved}) +} + // Analytics returns the distribution analytics for the cycle. // GET /api/workspaces/:slug/projects/:projectId/cycles/:cycleId/analytics func (h *CycleHandler) Analytics(c *gin.Context) { diff --git a/apps/api/internal/handler/cycle_complete_test.go b/apps/api/internal/handler/cycle_complete_test.go new file mode 100644 index 00000000..a190fde5 --- /dev/null +++ b/apps/api/internal/handler/cycle_complete_test.go @@ -0,0 +1,111 @@ +package handler_test + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/Devlaner/devlane/api/internal/model" + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/stretchr/testify/require" +) + +func cycleIssueIDs(t *testing.T, ts *testutil.TestServer, url, session string) []string { + t.Helper() + rr := ts.GET(url, session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + var ids []string + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &ids)) + return ids +} + +// Completing a cycle snapshots its distribution, marks it completed (which sticks +// on subsequent reads), and transfers only the incomplete work items to the +// chosen target cycle. Covers #184. +func TestCycle_CompleteAndTransfer(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + base := "/api/workspaces/" + w.Workspace.Slug + "/projects/" + w.Project.ID.String() + "/cycles/" + + source := testutil.CreateCycle(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + target := testutil.CreateCycle(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + + // A state in the "completed" group; issues in it should not be transferred. + 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) + + // Two incomplete issues (no state -> backlog) and one completed issue. + inc1 := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + inc2 := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + done := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + require.NoError(t, ts.DB.Model(done).Updates(map[string]any{"state_id": doneState.ID}).Error) + + for _, iss := range []*model.Issue{inc1, inc2, done} { + ci := &model.CycleIssue{ + CycleID: source.ID, + IssueID: iss.ID, + ProjectID: w.Project.ID, + WorkspaceID: w.Workspace.ID, + } + require.NoError(t, ts.DB.Create(ci).Error) + } + + // Complete the source cycle, transferring incomplete work to the target. + rr := ts.POST(base+source.ID.String()+"/transfer-issues/", + map[string]any{"target_cycle_id": target.ID.String()}, w.Session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + var resp struct { + Cycle struct { + Status string `json:"status"` + ProgressSnapshot map[string]any `json:"progress_snapshot"` + } `json:"cycle"` + TransferredCount int `json:"transferred_count"` + } + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) + require.Equal(t, 2, resp.TransferredCount) + require.Equal(t, "completed", resp.Cycle.Status) + require.EqualValues(t, 3, resp.Cycle.ProgressSnapshot["total"]) + require.EqualValues(t, 1, resp.Cycle.ProgressSnapshot["completed"]) + + // The two incomplete issues moved to the target; the completed one stayed. + require.ElementsMatch(t, []string{inc1.ID.String(), inc2.ID.String()}, + cycleIssueIDs(t, ts, base+target.ID.String()+"/issues/", w.Session)) + require.Equal(t, []string{done.ID.String()}, + cycleIssueIDs(t, ts, base+source.ID.String()+"/issues/", w.Session)) + + // The completed status persists on a later read (via the snapshot marker). + getRR := ts.GET(base+source.ID.String()+"/", w.Session) + require.Equal(t, http.StatusOK, getRR.Code) + require.Equal(t, "completed", testutil.MustJSONMap(t, getRR)["status"]) + + // Targeting the cycle itself is rejected. + require.Equal(t, http.StatusBadRequest, + ts.POST(base+source.ID.String()+"/transfer-issues/", + map[string]any{"target_cycle_id": source.ID.String()}, w.Session).Code) +} + +// Completing a cycle with no target just snapshots and marks it completed, +// leaving its work items in place. +func TestCycle_CompleteWithoutTransfer(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + base := "/api/workspaces/" + w.Workspace.Slug + "/projects/" + w.Project.ID.String() + "/cycles/" + + cy := testutil.CreateCycle(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + iss := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + require.NoError(t, ts.DB.Create(&model.CycleIssue{ + CycleID: cy.ID, IssueID: iss.ID, ProjectID: w.Project.ID, WorkspaceID: w.Workspace.ID, + }).Error) + + rr := ts.POST(base+cy.ID.String()+"/transfer-issues/", map[string]any{}, w.Session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + var resp struct { + TransferredCount int `json:"transferred_count"` + } + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) + require.Equal(t, 0, resp.TransferredCount) + + // The issue stays in the completed cycle. + require.Equal(t, []string{iss.ID.String()}, + cycleIssueIDs(t, ts, base+cy.ID.String()+"/issues/", w.Session)) +} diff --git a/apps/api/internal/model/cycle.go b/apps/api/internal/model/cycle.go index 6290b059..67eeb94a 100644 --- a/apps/api/internal/model/cycle.go +++ b/apps/api/internal/model/cycle.go @@ -28,6 +28,10 @@ type Cycle struct { ArchivedAt *time.Time `gorm:"type:timestamptz" json:"archived_at,omitempty"` Timezone string `gorm:"default:UTC" json:"timezone"` Version int `gorm:"default:1" json:"version"` + // ProgressSnapshot captures the cycle's state-group distribution at the moment + // it was completed, so a completed cycle's numbers stay fixed even after its + // incomplete work is transferred out. Empty until the cycle is completed. + ProgressSnapshot JSONMap `gorm:"column:progress_snapshot;type:jsonb" json:"progress_snapshot,omitempty"` } func (Cycle) TableName() string { return "cycles" } diff --git a/apps/api/internal/router/router.go b/apps/api/internal/router/router.go index 81e55e4a..49d0e124 100644 --- a/apps/api/internal/router/router.go +++ b/apps/api/internal/router/router.go @@ -380,6 +380,7 @@ func New(cfg Config) *gin.Engine { api.GET("/workspaces/:slug/projects/:projectId/cycles/:cycleId/issues/", cycleHandler.ListIssues) api.POST("/workspaces/:slug/projects/:projectId/cycles/:cycleId/issues/", cycleHandler.AddIssue) api.DELETE("/workspaces/:slug/projects/:projectId/cycles/:cycleId/issues/:issueId/", cycleHandler.RemoveIssue) + api.POST("/workspaces/:slug/projects/:projectId/cycles/:cycleId/transfer-issues/", cycleHandler.CompleteCycle) api.GET("/workspaces/:slug/projects/:projectId/cycles/:cycleId/progress/", cycleHandler.Progress) api.GET("/workspaces/:slug/projects/:projectId/cycles/:cycleId/cycle-progress/", cycleHandler.Progress) api.GET("/workspaces/:slug/projects/:projectId/cycles/:cycleId/analytics", cycleHandler.Analytics) diff --git a/apps/api/internal/service/cycle.go b/apps/api/internal/service/cycle.go index e3ccb1fe..1b510b78 100644 --- a/apps/api/internal/service/cycle.go +++ b/apps/api/internal/service/cycle.go @@ -39,6 +39,21 @@ func computeCycleStatus(start, end *time.Time) string { var ErrCycleNotFound = errors.New("cycle not found") +// ErrInvalidTargetCycle is returned when a complete-cycle transfer names a target +// that is missing, in another project, or the cycle being completed itself. +var ErrInvalidTargetCycle = errors.New("invalid target cycle") + +// effectiveCycleStatus reports "completed" for a cycle that was explicitly +// completed (it carries a progress snapshot), and otherwise falls back to the +// date-derived status. This keeps a user-completed cycle completed even when its +// end date is still in the future. +func effectiveCycleStatus(cy *model.Cycle) string { + if len(cy.ProgressSnapshot) > 0 { + return "completed" + } + return computeCycleStatus(cy.StartDate, cy.EndDate) +} + // ErrInvalidCycleDates is returned when a cycle's start date falls after its // end date. var ErrInvalidCycleDates = errors.New("cycle start date must be on or before the end date") @@ -96,7 +111,7 @@ func (s *CycleService) List(ctx context.Context, workspaceSlug string, projectID ids := make([]uuid.UUID, 0, len(list)) for i := range list { ids = append(ids, list[i].ID) - list[i].Status = computeCycleStatus(list[i].StartDate, list[i].EndDate) + list[i].Status = effectiveCycleStatus(&list[i]) } counts, err := s.cs.CountIssuesByCycleIDs(ctx, ids) if err == nil { @@ -130,7 +145,7 @@ func (s *CycleService) Create(ctx context.Context, workspaceSlug string, project if err := s.cs.Create(ctx, cy); err != nil { return nil, err } - cy.Status = computeCycleStatus(cy.StartDate, cy.EndDate) + cy.Status = effectiveCycleStatus(cy) return cy, nil } @@ -145,7 +160,7 @@ func (s *CycleService) Get(ctx context.Context, workspaceSlug string, projectID, if cy.ProjectID != projectID { return nil, ErrCycleNotFound } - cy.Status = computeCycleStatus(cy.StartDate, cy.EndDate) + cy.Status = effectiveCycleStatus(cy) if counts, err := s.cs.CountIssuesByCycleIDs(ctx, []uuid.UUID{cy.ID}); err == nil { cy.IssueCount = counts[cy.ID] } @@ -175,7 +190,7 @@ func (s *CycleService) Update(ctx context.Context, workspaceSlug string, project if err := s.cs.Update(ctx, cy); err != nil { return nil, err } - cy.Status = computeCycleStatus(cy.StartDate, cy.EndDate) + cy.Status = effectiveCycleStatus(cy) return cy, nil } @@ -247,6 +262,68 @@ type CycleDistribution struct { Labels []interface{} `json:"labels"` } +// CompleteCycle marks a cycle completed and, when a target cycle is given, +// transfers its incomplete work items (backlog/unstarted/started) into that +// target. The cycle's state-group distribution is snapshotted first so its +// completion numbers stay fixed even after the transfer. Returns the updated +// cycle and how many work items were moved. +func (s *CycleService) CompleteCycle(ctx context.Context, workspaceSlug string, projectID, cycleID uuid.UUID, targetCycleID *uuid.UUID, userID uuid.UUID) (*model.Cycle, int, error) { + cy, err := s.Get(ctx, workspaceSlug, projectID, cycleID, userID) + if err != nil { + return nil, 0, err + } + + var target *model.Cycle + if targetCycleID != nil { + if *targetCycleID == cycleID { + return nil, 0, ErrInvalidTargetCycle + } + t, err := s.cs.GetByID(ctx, *targetCycleID) + if err != nil || t.ProjectID != projectID { + return nil, 0, ErrInvalidTargetCycle + } + target = t + } + + // Snapshot the distribution before any transfer so a completed cycle keeps the + // numbers it had at completion. + dist, err := s.cs.CycleStateDistribution(ctx, cycleID) + if err != nil { + return nil, 0, err + } + total := 0 + for _, v := range dist { + total += v + } + cy.ProgressSnapshot = model.JSONMap{ + "total": total, + "backlog": dist["backlog"], + "unstarted": dist["unstarted"], + "started": dist["started"], + "completed": dist["completed"], + "cancelled": dist["cancelled"], + "completed_at": time.Now().UTC().Format(time.RFC3339), + } + cy.Status = "completed" + if err := s.cs.Update(ctx, cy); err != nil { + return nil, 0, err + } + + moved := 0 + if target != nil { + moved, err = s.cs.TransferIncompleteIssues(ctx, cycleID, target, userID) + if err != nil { + return nil, 0, err + } + } + + cy.Status = effectiveCycleStatus(cy) + if counts, err := s.cs.CountIssuesByCycleIDs(ctx, []uuid.UUID{cy.ID}); err == nil { + cy.IssueCount = counts[cy.ID] + } + return cy, moved, nil +} + // GetProgress computes a TProgressSnapshot-compatible response for the cycle. func (s *CycleService) GetProgress(ctx context.Context, workspaceSlug string, projectID, cycleID uuid.UUID, userID uuid.UUID) (*CycleProgressSnapshot, error) { cy, err := s.Get(ctx, workspaceSlug, projectID, cycleID, userID) diff --git a/apps/api/internal/store/cycle.go b/apps/api/internal/store/cycle.go index 7000fd17..835ba680 100644 --- a/apps/api/internal/store/cycle.go +++ b/apps/api/internal/store/cycle.go @@ -66,6 +66,62 @@ func (s *CycleStore) ListCycleIssueIDs(ctx context.Context, cycleID uuid.UUID) ( return ids, nil } +// TransferIncompleteIssues moves every incomplete work item (state group +// backlog/unstarted/started, or no state) from the source cycle to the target +// cycle in a single transaction, and returns how many were moved. Items already +// in the target keep a single active link; the source link is soft-deleted. +func (s *CycleStore) TransferIncompleteIssues(ctx context.Context, sourceCycleID uuid.UUID, target *model.Cycle, userID uuid.UUID) (int, error) { + var rows []struct { + IssueID uuid.UUID `gorm:"column:issue_id"` + } + err := s.db.WithContext(ctx).Raw(` + SELECT ci.issue_id + FROM cycle_issues ci + JOIN issues i ON i.id = ci.issue_id AND i.deleted_at IS NULL + LEFT JOIN states st ON st.id = i.state_id + WHERE ci.cycle_id = ? AND ci.deleted_at IS NULL + AND COALESCE(st.group, 'backlog') IN ('backlog', 'unstarted', 'started') + `, sourceCycleID).Scan(&rows).Error + if err != nil { + return 0, err + } + + moved := 0 + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + for _, r := range rows { + if err := tx.Where("cycle_id = ? AND issue_id = ?", sourceCycleID, r.IssueID). + Delete(&model.CycleIssue{}).Error; err != nil { + return err + } + var existing int64 + if err := tx.Model(&model.CycleIssue{}). + Where("cycle_id = ? AND issue_id = ? AND deleted_at IS NULL", target.ID, r.IssueID). + Count(&existing).Error; err != nil { + return err + } + if existing == 0 { + cb := userID + ci := &model.CycleIssue{ + CycleID: target.ID, + IssueID: r.IssueID, + ProjectID: target.ProjectID, + WorkspaceID: target.WorkspaceID, + CreatedByID: &cb, + } + if err := tx.Create(ci).Error; err != nil { + return err + } + } + moved++ + } + return nil + }) + if err != nil { + return 0, err + } + return moved, nil +} + func (s *CycleStore) CountIssuesByCycleIDs(ctx context.Context, cycleIDs []uuid.UUID) (map[uuid.UUID]int, error) { out := make(map[uuid.UUID]int) if len(cycleIDs) == 0 { diff --git a/apps/web/src/pages/CycleDetailPage.tsx b/apps/web/src/pages/CycleDetailPage.tsx index fb38f47f..824eeadc 100644 --- a/apps/web/src/pages/CycleDetailPage.tsx +++ b/apps/web/src/pages/CycleDetailPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { Link, useParams } from 'react-router-dom'; -import { Badge } from '../components/ui'; +import { Badge, Button, Modal } from '../components/ui'; import { CycleBurndownChart } from '../components/cycles/CycleBurndownChart'; import { workspaceService } from '../services/workspaceService'; import { projectService } from '../services/projectService'; @@ -59,9 +59,14 @@ export function CycleDetailPage() { const [workspace, setWorkspace] = useState(null); const [project, setProject] = useState(null); const [cycle, setCycle] = useState(null); + const [allCycles, setAllCycles] = useState([]); const [issues, setIssues] = useState([]); const [states, setStates] = useState([]); const [progress, setProgress] = useState(null); + const [completeModalOpen, setCompleteModalOpen] = useState(false); + const [completing, setCompleting] = useState(false); + const [completeError, setCompleteError] = useState(null); + const [transferTargetId, setTransferTargetId] = useState(''); useDocumentTitle(loading ? 'Cycle' : (cycle?.name ?? 'Cycle')); @@ -87,6 +92,7 @@ export function CycleDetailPage() { setProject(p ?? null); const found = (cycles ?? []).find((c) => cycleMatchesPathSegment(c, cycleId)) ?? null; setCycle(found); + setAllCycles(cycles ?? []); setIssues(allIssues ?? []); setStates(st ?? []); // Fetch progress separately so it doesn't block the main render. @@ -124,6 +130,37 @@ export function CycleDetailPage() { const stateName = (stateId: string | null | undefined) => stateId ? (states.find((s) => s.id === stateId)?.name ?? '—') : '—'; + // Other cycles this one's incomplete work can be transferred into on completion. + const transferTargets = allCycles.filter((c) => c.id !== cycle?.id && c.status !== 'completed'); + + const handleComplete = async () => { + if (!workspaceSlug || !projectId || !cycle) return; + setCompleting(true); + setCompleteError(null); + try { + const res = await cycleService.completeCycle( + workspaceSlug, + projectId, + cycle.id, + transferTargetId || undefined, + ); + setCycle(res.cycle); + // Some work items may have moved out; refresh the list and the snapshot. + const [allIssues, snap] = await Promise.all([ + issueService.list(workspaceSlug, projectId, { limit: 500 }), + cycleService.getProgress(workspaceSlug, projectId, cycle.id), + ]); + setIssues(allIssues ?? []); + setProgress(snap); + setCompleteModalOpen(false); + setTransferTargetId(''); + } catch { + setCompleteError('Could not complete the cycle. Please try again.'); + } finally { + setCompleting(false); + } + }; + if (loading) return
Loading cycle…
; if (!workspace || !project || !cycle) return
Cycle not found.
; @@ -135,19 +172,81 @@ export function CycleDetailPage() { return (
-
- - ← Back to cycles - -

{cycle.name}

-

- {formatDate(cycle.start_date)} — {formatDate(cycle.end_date)} · {total} work items -

+
+
+ + ← Back to cycles + +

{cycle.name}

+

+ {formatDate(cycle.start_date)} — {formatDate(cycle.end_date)} · {total} work items +

+
+
+ {cycle.status === 'completed' ? ( + Completed + ) : ( + + )} +
+ !completing && setCompleteModalOpen(false)} + title="Complete cycle" + > +
+

+ Completing {cycle.name}{' '} + records its progress. Optionally move any incomplete work items into another cycle. +

+
+ + +
+ {completeError &&

{completeError}

} +
+ + +
+
+
+ {/* ── Progress stats ── */} {progress && (
diff --git a/apps/web/src/services/cycleService.ts b/apps/web/src/services/cycleService.ts index 405b4a1a..ccd34021 100644 --- a/apps/web/src/services/cycleService.ts +++ b/apps/web/src/services/cycleService.ts @@ -95,6 +95,23 @@ export const cycleService = { ); return data; }, + + /** + * Complete a cycle: snapshots its progress and marks it completed. When + * targetCycleId is given, incomplete work items are moved into that cycle. + */ + async completeCycle( + workspaceSlug: string, + projectId: string, + cycleId: string, + targetCycleId?: string | null, + ): Promise<{ cycle: CycleApiResponse; transferred_count: number }> { + const { data } = await apiClient.post<{ cycle: CycleApiResponse; transferred_count: number }>( + `/api/workspaces/${encodeURIComponent(workspaceSlug)}/projects/${encodeURIComponent(projectId)}/cycles/${encodeURIComponent(cycleId)}/transfer-issues/`, + targetCycleId ? { target_cycle_id: targetCycleId } : {}, + ); + return data; + }, }; export interface CycleProgressResponse {