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
53 changes: 53 additions & 0 deletions apps/api/internal/handler/cycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
111 changes: 111 additions & 0 deletions apps/api/internal/handler/cycle_complete_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
4 changes: 4 additions & 0 deletions apps/api/internal/model/cycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
1 change: 1 addition & 0 deletions apps/api/internal/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
85 changes: 81 additions & 4 deletions apps/api/internal/service/cycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

Expand All @@ -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]
}
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
Expand Down
56 changes: 56 additions & 0 deletions apps/api/internal/store/cycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading