diff --git a/apps/api/internal/handler/issue_archive.go b/apps/api/internal/handler/issue_archive.go index 620aba7..eaea67c 100644 --- a/apps/api/internal/handler/issue_archive.go +++ b/apps/api/internal/handler/issue_archive.go @@ -112,6 +112,53 @@ func (h *IssueHandler) Convert(c *gin.Context) { c.JSON(http.StatusOK, issue) } +// Move rehomes a work item into another project in the same workspace. +// POST /api/workspaces/:slug/projects/:projectId/issues/:pk/move/ +func (h *IssueHandler) Move(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 + } + iid, ok := issueID(c) + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid issue ID"}) + return + } + var body struct { + TargetProjectID string `json:"target_project_id"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.TargetProjectID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "target_project_id is required"}) + return + } + targetID, err := uuid.Parse(body.TargetProjectID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid target project ID"}) + return + } + issue, err := h.Issue.Move(c.Request.Context(), slug, projectID, iid, user.ID, targetID) + if err != nil { + if err == service.ErrMoveSameProject { + c.JSON(http.StatusBadRequest, gin.H{"error": "Work item is already in that project."}) + return + } + if issueAccessNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "Not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to move work item"}) + return + } + c.JSON(http.StatusOK, issue) +} + // ListArchived returns archived work items for a project. // GET /api/workspaces/:slug/projects/:projectId/archived-issues/ func (h *IssueHandler) ListArchived(c *gin.Context) { diff --git a/apps/api/internal/handler/issue_move_test.go b/apps/api/internal/handler/issue_move_test.go new file mode 100644 index 0000000..8bc6364 --- /dev/null +++ b/apps/api/internal/handler/issue_move_test.go @@ -0,0 +1,89 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/Devlaner/devlane/api/internal/model" + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/stretchr/testify/require" +) + +func TestIssue_MoveToProject(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + target := testutil.CreateProject(t, ts.DB, w.Workspace.ID, w.User.ID) + + // An issue in the source project with a state and a label. + issue := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + state := testutil.CreateState(t, ts.DB, w.Project.ID, w.Workspace.ID) + require.NoError(t, ts.DB.Model(issue).Update("state_id", state.ID).Error) + label := testutil.CreateLabel(t, ts.DB, w.Project.ID, w.Workspace.ID) + require.NoError(t, ts.DB.Create(&model.IssueLabel{ + IssueID: issue.ID, LabelID: label.ID, ProjectID: w.Project.ID, WorkspaceID: w.Workspace.ID, + }).Error) + + // A child work item parented to the issue; it must be detached on move so the + // same-project parent/child invariant holds. + child := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + require.NoError(t, ts.DB.Model(child).Update("parent_id", issue.ID).Error) + + // Pre-seed the target with an issue so the moved item gets a fresh sequence. + testutil.CreateIssue(t, ts.DB, target.ID, w.Workspace.ID, w.User.ID) + + base := "/api/workspaces/" + w.Workspace.Slug + "/projects/" + w.Project.ID.String() + "/issues/" + rr := ts.POST(base+issue.ID.String()+"/move/", map[string]any{"target_project_id": target.ID.String()}, w.Session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + body := testutil.MustJSONMap(t, rr) + require.Equal(t, target.ID.String(), body["project_id"]) + require.Nil(t, body["state_id"], "state should reset on move") + + // Project-scoped associations are dropped. + var labels int64 + require.NoError(t, ts.DB.Model(&model.IssueLabel{}).Where("issue_id = ?", issue.ID).Count(&labels).Error) + require.Zero(t, labels, "labels should be cleared on move") + + // The row really lives in the target project now. + var moved model.Issue + require.NoError(t, ts.DB.First(&moved, "id = ?", issue.ID).Error) + require.Equal(t, target.ID, moved.ProjectID) + require.Nil(t, moved.StateID) + + // The child stays in the source project but is detached from the moved parent. + var movedChild model.Issue + require.NoError(t, ts.DB.First(&movedChild, "id = ?", child.ID).Error) + require.Equal(t, w.Project.ID, movedChild.ProjectID, "child stays in source project") + require.Nil(t, movedChild.ParentID, "child should be detached from a cross-project parent") +} + +func TestIssue_MoveToSameProjectRejected(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + issue := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + base := "/api/workspaces/" + w.Workspace.Slug + "/projects/" + w.Project.ID.String() + "/issues/" + rr := ts.POST(base+issue.ID.String()+"/move/", map[string]any{"target_project_id": w.Project.ID.String()}, w.Session) + require.Equal(t, http.StatusBadRequest, rr.Code, "body=%s", rr.Body.String()) +} + +func TestIssue_MoveRequiresTarget(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + issue := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + base := "/api/workspaces/" + w.Workspace.Slug + "/projects/" + w.Project.ID.String() + "/issues/" + rr := ts.POST(base+issue.ID.String()+"/move/", map[string]any{}, w.Session) + require.Equal(t, http.StatusBadRequest, rr.Code, "body=%s", rr.Body.String()) +} + +func TestIssue_MoveCrossWorkspaceRejected(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + // A project that belongs to a different workspace. + otherUser := testutil.CreateUser(t, ts.DB) + otherWS := testutil.CreateWorkspace(t, ts.DB, otherUser.ID) + foreign := testutil.CreateProject(t, ts.DB, otherWS.ID, otherUser.ID) + + issue := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + base := "/api/workspaces/" + w.Workspace.Slug + "/projects/" + w.Project.ID.String() + "/issues/" + rr := ts.POST(base+issue.ID.String()+"/move/", map[string]any{"target_project_id": foreign.ID.String()}, w.Session) + require.Equal(t, http.StatusNotFound, rr.Code, "body=%s", rr.Body.String()) +} diff --git a/apps/api/internal/router/router.go b/apps/api/internal/router/router.go index 113f326..1a3e2fa 100644 --- a/apps/api/internal/router/router.go +++ b/apps/api/internal/router/router.go @@ -344,6 +344,7 @@ func New(cfg Config) *gin.Engine { api.POST("/workspaces/:slug/projects/:projectId/issues/:pk/archive/", issueHandler.Archive) api.DELETE("/workspaces/:slug/projects/:projectId/issues/:pk/archive/", issueHandler.Restore) api.POST("/workspaces/:slug/projects/:projectId/issues/:pk/convert/", issueHandler.Convert) + api.POST("/workspaces/:slug/projects/:projectId/issues/:pk/move/", issueHandler.Move) api.GET("/workspaces/:slug/projects/:projectId/archived-issues/", issueHandler.ListArchived) api.POST("/workspaces/:slug/projects/:projectId/issues-bulk/update/", issueHandler.BulkUpdate) api.POST("/workspaces/:slug/projects/:projectId/issues-bulk/archive/", issueHandler.BulkArchive) diff --git a/apps/api/internal/service/issue.go b/apps/api/internal/service/issue.go index f1d14f8..0823f1d 100644 --- a/apps/api/internal/service/issue.go +++ b/apps/api/internal/service/issue.go @@ -23,6 +23,8 @@ var ( ErrInvalidState = errors.New("invalid state for project") // ErrEpicHasChildren blocks demoting an epic that still has child work items. ErrEpicHasChildren = errors.New("epic has child work items") + // ErrMoveSameProject is returned when a move targets the issue's current project. + ErrMoveSameProject = errors.New("issue already in target project") ) // validPriorities is the accepted set of work-item priority values. @@ -255,6 +257,35 @@ func boolStr(b bool) string { return "false" } +// Move rehomes an issue into another project in the same workspace. The target +// must differ from the current project and be reachable by the caller. +// Project-scoped fields (state, parent, estimate) and associations (labels, +// cycle/module membership, relations) are reset because they don't carry across +// projects; the issue gets a fresh per-project sequence id. +func (s *IssueService) Move(ctx context.Context, workspaceSlug string, projectID, issueID, userID, targetProjectID uuid.UUID) (*model.Issue, error) { + issue, err := s.issueForAccess(ctx, workspaceSlug, projectID, issueID, userID) + if err != nil { + return nil, err + } + if targetProjectID == projectID { + return nil, ErrMoveSameProject + } + // The target must be a project the caller can access in the same workspace; + // ensureProjectAccess returns ErrProjectNotFound for projects outside it. + if err := s.ensureProjectAccess(ctx, workspaceSlug, targetProjectID, userID); err != nil { + return nil, err + } + if _, err := s.is.MoveToProject(ctx, issue.ID, targetProjectID, userID); err != nil { + return nil, err + } + updated, err := s.is.GetByID(ctx, issue.ID) + if err != nil { + return nil, err + } + s.recordActivity(ctx, updated, userID, "project_id", projectID.String(), targetProjectID.String()) + return updated, nil +} + // ListArchived returns archived issues for a project. func (s *IssueService) ListArchived(ctx context.Context, workspaceSlug string, projectID, userID uuid.UUID, limit, offset int) ([]model.Issue, error) { if err := s.ensureProjectAccess(ctx, workspaceSlug, projectID, userID); err != nil { diff --git a/apps/api/internal/store/issue.go b/apps/api/internal/store/issue.go index f6c387d..4227db9 100644 --- a/apps/api/internal/store/issue.go +++ b/apps/api/internal/store/issue.go @@ -172,6 +172,70 @@ func (s *IssueStore) BulkUpdateFields(ctx context.Context, projectID uuid.UUID, return res.RowsAffected, res.Error } +// MoveToProject rehomes an issue into another project atomically: it allocates a +// fresh per-project sequence, repoints project_id, resets project-scoped fields +// (state, parent, estimate) and drops project-scoped associations (labels, +// cycle/module memberships, relations), and repoints links/attachments. Returns +// the new sequence id. +func (s *IssueStore) MoveToProject(ctx context.Context, issueID, targetProjectID, userID uuid.UUID) (int, error) { + var newSeq int + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + n, err := s.NextSequenceID(ctx, tx, targetProjectID) + if err != nil { + return err + } + newSeq = n + if err := tx.Model(&model.Issue{}).Where("id = ?", issueID).Updates(map[string]any{ + "project_id": targetProjectID, + "sequence_id": newSeq, + "state_id": nil, + "parent_id": nil, + "estimate_point_id": nil, + "updated_by_id": userID, + }).Error; err != nil { + return err + } + // Detach any children: they stay in the source project, so keeping them + // parented to the moved issue would break the same-project hierarchy + // invariant (a child pointing at a parent in another project). + if err := tx.Model(&model.Issue{}).Where("parent_id = ?", issueID). + Updates(map[string]any{"parent_id": nil, "updated_by_id": userID}).Error; err != nil { + return err + } + // Drop associations that belong to the source project. + if err := tx.Where("issue_id = ?", issueID).Delete(&model.IssueLabel{}).Error; err != nil { + return err + } + if err := tx.Where("issue_id = ?", issueID).Delete(&model.CycleIssue{}).Error; err != nil { + return err + } + if err := tx.Where("issue_id = ?", issueID).Delete(&model.ModuleIssue{}).Error; err != nil { + return err + } + if err := tx.Where("issue_id = ? OR related_issue_id = ?", issueID, issueID). + Delete(&model.IssueRelation{}).Error; err != nil { + return err + } + // Links and attachments travel with the issue; repoint their project_id. + if err := tx.Model(&model.IssueLink{}).Where("issue_id = ?", issueID). + Update("project_id", targetProjectID).Error; err != nil { + return err + } + if err := tx.Model(&model.IssueAttachment{}).Where("issue_id = ?", issueID). + Update("project_id", targetProjectID).Error; err != nil { + return err + } + // GitHub PR↔issue sync rows are filtered by project_id in project summary + // queries, so repoint them too or the moved issue's PRs vanish there. + if err := tx.Model(&model.GithubIssueSync{}).Where("issue_id = ?", issueID). + Update("project_id", targetProjectID).Error; err != nil { + return err + } + return nil + }) + return newSeq, err +} + // SetIsEpic flips an issue's is_epic flag. Promotion also clears parent_id. // Demotion is guarded atomically: the UPDATE only matches when the issue has no // child work items, so a concurrent "add child" can't leave orphaned children diff --git a/apps/web/src/pages/IssueDetailPage.tsx b/apps/web/src/pages/IssueDetailPage.tsx index d78b039..73ad841 100644 --- a/apps/web/src/pages/IssueDetailPage.tsx +++ b/apps/web/src/pages/IssueDetailPage.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { Link, useParams, useNavigate } from 'react-router-dom'; -import { Archive, ArchiveRestore, Layers } from 'lucide-react'; -import { Button, Card, CardContent, CardHeader, Avatar } from '../components/ui'; +import { Archive, ArchiveRestore, Layers, FolderInput } from 'lucide-react'; +import { Button, Card, CardContent, CardHeader, Avatar, Modal } from '../components/ui'; import { useAuth } from '../contexts/AuthContext'; import { Dropdown, DatePickerTrigger, CommentEditor } from '../components/work-item'; import { sanitizeHtml, safeUrl } from '../lib/sanitize'; @@ -339,6 +339,12 @@ export function IssueDetailPage() { // Attachments const [attachments, setAttachments] = useState([]); const [uploadingAttachment, setUploadingAttachment] = useState(false); + // Move to another project + const [moveOpen, setMoveOpen] = useState(false); + const [moveProjects, setMoveProjects] = useState([]); + const [moveLoading, setMoveLoading] = useState(false); + const [moveSubmitting, setMoveSubmitting] = useState(false); + const [moveError, setMoveError] = useState(null); useDocumentTitle(loading ? 'Work item' : (issue?.name ?? 'Work item')); @@ -517,6 +523,40 @@ export function IssueDetailPage() { setErrorMessage('Failed to convert to epic.'); } }; + + const openMove = async () => { + if (!workspaceSlug) return; + setMoveOpen(true); + setMoveError(null); + setMoveLoading(true); + try { + const list = await projectService.list(workspaceSlug); + setMoveProjects(list.filter((p) => p.id !== project.id)); + } catch { + setMoveError('Failed to load projects.'); + } finally { + setMoveLoading(false); + } + }; + + const handleMove = async (targetProjectId: string) => { + if (!workspaceSlug || !projectId || !issueId) return; + setMoveSubmitting(true); + setMoveError(null); + try { + await issueService.move(workspaceSlug, projectId, issueId, targetProjectId); + setMoveOpen(false); + navigate(`/${workspace.slug}/projects/${targetProjectId}/issues/${issueId}`); + } catch (err) { + const apiError = + err && typeof err === 'object' && 'response' in err + ? (err as { response?: { data?: { error?: string } } }).response?.data?.error + : null; + setMoveError(apiError ?? 'Failed to move work item.'); + } finally { + setMoveSubmitting(false); + } + }; const descriptionHtml = issue.description_html && typeof issue.description_html === 'string' ? issue.description_html @@ -707,6 +747,16 @@ export function IssueDetailPage() { Convert to epic ) : null} + {!issue.is_epic && !issue.archived_at ? ( + + ) : null} {issue.archived_at ? ( + + ))} + + )} + + + { diff --git a/apps/web/src/services/issueService.ts b/apps/web/src/services/issueService.ts index 3a90b95..ad4246c 100644 --- a/apps/web/src/services/issueService.ts +++ b/apps/web/src/services/issueService.ts @@ -337,6 +337,19 @@ export const issueService = { return data; }, + async move( + workspaceSlug: string, + projectId: string, + issueId: string, + targetProjectId: string, + ): Promise { + const { data } = await apiClient.post( + `${base(workspaceSlug, projectId, issueId)}/move/`, + { target_project_id: targetProjectId }, + ); + return data; + }, + async listArchived( workspaceSlug: string, projectId: string,