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
47 changes: 47 additions & 0 deletions apps/api/internal/handler/issue_archive.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
89 changes: 89 additions & 0 deletions apps/api/internal/handler/issue_move_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
1 change: 1 addition & 0 deletions apps/api/internal/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
31 changes: 31 additions & 0 deletions apps/api/internal/service/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
64 changes: 64 additions & 0 deletions apps/api/internal/store/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
Expand Down
93 changes: 91 additions & 2 deletions apps/web/src/pages/IssueDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -339,6 +339,12 @@ export function IssueDetailPage() {
// Attachments
const [attachments, setAttachments] = useState<IssueAttachmentApiResponse[]>([]);
const [uploadingAttachment, setUploadingAttachment] = useState(false);
// Move to another project
const [moveOpen, setMoveOpen] = useState(false);
const [moveProjects, setMoveProjects] = useState<ProjectApiResponse[]>([]);
const [moveLoading, setMoveLoading] = useState(false);
const [moveSubmitting, setMoveSubmitting] = useState(false);
const [moveError, setMoveError] = useState<string | null>(null);

useDocumentTitle(loading ? 'Work item' : (issue?.name ?? 'Work item'));

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -707,6 +747,16 @@ export function IssueDetailPage() {
Convert to epic
</button>
) : null}
{!issue.is_epic && !issue.archived_at ? (
<button
type="button"
onClick={() => void openMove()}
className="inline-flex shrink-0 items-center gap-1 rounded-(--radius-md) border border-(--border-subtle) px-2 py-1 text-xs text-(--txt-secondary) transition-colors hover:bg-(--bg-layer-1-hover)"
>
<FolderInput className="h-3.5 w-3.5" />
Move
</button>
) : null}
{issue.archived_at ? (
<button
type="button"
Expand Down Expand Up @@ -1920,6 +1970,45 @@ export function IssueDetailPage() {
);
})()}

<Modal open={moveOpen} onClose={() => setMoveOpen(false)} title="Move work item to project">
<div className="space-y-3">
<p className="text-sm text-(--txt-tertiary)">
Choose a destination project. The work item gets a new ID there; its state, parent,
labels, and cycle/module links are cleared, and any sub-items are detached.
</p>
{moveError ? (
<p className="rounded-(--radius-md) bg-(--bg-danger-subtle) px-3 py-2 text-sm text-(--txt-danger)">
{moveError}
</p>
) : null}
{moveLoading ? (
<p className="py-6 text-center text-sm text-(--txt-tertiary)">Loading projects…</p>
) : moveProjects.length === 0 ? (
<p className="py-6 text-center text-sm text-(--txt-tertiary)">
No other projects available.
</p>
) : (
<ul className="max-h-72 space-y-1 overflow-y-auto">
{moveProjects.map((p) => (
<li key={p.id}>
<button
type="button"
disabled={moveSubmitting}
onClick={() => void handleMove(p.id)}
className="flex w-full items-center gap-2 rounded-(--radius-md) px-3 py-2 text-left text-sm text-(--txt-primary) transition-colors hover:bg-(--bg-layer-1-hover) disabled:opacity-50"
>
<span className="shrink-0 rounded-(--radius-sm) bg-(--bg-layer-2) px-1.5 py-0.5 text-xs font-medium text-(--txt-secondary)">
{p.identifier ?? p.id.slice(0, 8)}
</span>
<span className="truncate">{p.name}</span>
</button>
</li>
))}
</ul>
)}
</div>
</Modal>

<CreateWorkItemModal
open={subCreateOpen}
onClose={() => {
Expand Down
Loading
Loading