Skip to content
Closed
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
29 changes: 21 additions & 8 deletions apps/mcp/src/__tests__/api/views-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,18 +219,31 @@ describe("PacaAPIViewsClient", () => {
});
});

describe("bulkMoveTasks", () => {
it("calls PUT /api/v1/projects/:id/views/:viewId/task-positions/:taskId", async () => {
describe("moveTaskPosition", () => {
it("calls PUT /api/v1/projects/:id/views/:viewId/task-positions/:taskId with position and group_key", async () => {
const client = new PacaAPIViewsClient(CONFIG);
fetchMock.mockResolvedValue(okEnvelope(null));
await client.bulkMoveTasks("p1", "v1", {
task_id: "t1",
target_view_id: "v2",
target_status_id: null,
target_position: 0,
});
await client.moveTaskPosition("p1", "v1", "t1", 3, "group-1");
expect(fetchMock.mock.calls[0][0]).toContain("/task-positions/t1");
expect(fetchMock.mock.calls[0][1].method).toBe("PUT");
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body).toEqual({ position: 3, group_key: "group-1" });
});

it("omits group_key when not provided", async () => {
const client = new PacaAPIViewsClient(CONFIG);
fetchMock.mockResolvedValue(okEnvelope(null));
await client.moveTaskPosition("p1", "v1", "t1", 0);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body).toEqual({ position: 0 });
});

it("sends an explicit null group_key to clear the grouping", async () => {
const client = new PacaAPIViewsClient(CONFIG);
fetchMock.mockResolvedValue(okEnvelope(null));
await client.moveTaskPosition("p1", "v1", "t1", 1, null);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body).toEqual({ position: 1, group_key: null });
});
});

Expand Down
100 changes: 76 additions & 24 deletions apps/mcp/src/__tests__/tools/view-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ function makeClient(overrides: Record<string, any> = {}) {
listTaskPositions: vi
.fn()
.mockResolvedValue([{ task_id: "t1", position: 0 }]),
bulkMoveTasks: vi.fn().mockResolvedValue(undefined),
moveTaskPosition: vi.fn().mockResolvedValue(undefined),
bulkMoveViewTaskPositions: vi.fn().mockResolvedValue(undefined),
listCustomFieldDefinitions: vi.fn().mockResolvedValue([customField]),
createCustomFieldDefinition: vi.fn().mockResolvedValue(customField),
getCustomFieldDefinition: vi.fn().mockResolvedValue(customField),
Expand Down Expand Up @@ -485,28 +486,49 @@ describe("handleViewTool – list_task_positions", () => {
// ---------------------------------------------------------------------------

describe("handleViewTool – bulk_move_tasks", () => {
it("calls client.bulkMoveTasks with mapped input", async () => {
it("calls client.bulkMoveViewTaskPositions with mapped items", async () => {
const client = makeClient();
await handleViewTool(
"bulk_move_tasks",
{ projectId: "p1", viewId: "v1", taskId: "t1", targetViewId: "v2" },
{
projectId: "p1",
viewId: "v1",
items: [
{ taskId: "t1", position: 0 },
{ taskId: "t2", position: 1, groupKey: "group-1" },
],
},
client,
);
expect(client.bulkMoveTasks).toHaveBeenCalledWith("p1", "v1", {
task_id: "t1",
target_view_id: "v2",
target_status_id: null,
target_position: undefined,
});
expect(client.bulkMoveViewTaskPositions).toHaveBeenCalledWith("p1", "v1", [
{ task_id: "t1", position: 0, group_key: undefined },
{ task_id: "t2", position: 1, group_key: "group-1" },
]);
});

it("includes 'moved successfully' in response", async () => {
it("rejects an empty items array", async () => {
const client = makeClient();
await expect(
handleViewTool(
"bulk_move_tasks",
{ projectId: "p1", viewId: "v1", items: [] },
client,
),
).rejects.toThrow();
expect(client.bulkMoveViewTaskPositions).not.toHaveBeenCalled();
});

it("reports how many positions were updated", async () => {
const result = await handleViewTool(
"bulk_move_tasks",
{ projectId: "p1", viewId: "v1", taskId: "t1", targetViewId: "v2" },
{
projectId: "p1",
viewId: "v1",
items: [{ taskId: "t1", position: 0 }],
},
makeClient(),
);
expect(result.content[0].text).toContain("moved successfully");
expect(result.content[0].text).toContain("1 task position updated");
});
});

Expand All @@ -515,34 +537,64 @@ describe("handleViewTool – bulk_move_tasks", () => {
// ---------------------------------------------------------------------------

describe("handleViewTool – move_task", () => {
it("calls client.bulkMoveTasks (same underlying method as bulk_move_tasks)", async () => {
it("calls client.moveTaskPosition with view, task, position, and group key", async () => {
const client = makeClient();
await handleViewTool(
"move_task",
{
projectId: "p1",
viewId: "v1",
taskId: "t1",
targetViewId: "v2",
targetPosition: 3,
position: 3,
groupKey: "group-1",
},
client,
);
expect(client.bulkMoveTasks).toHaveBeenCalledWith("p1", "v1", {
task_id: "t1",
target_view_id: "v2",
target_status_id: null,
target_position: 3,
});
expect(client.moveTaskPosition).toHaveBeenCalledWith(
"p1",
"v1",
"t1",
3,
"group-1",
);
});

it("omits the group key argument when not provided", async () => {
const client = makeClient();
await handleViewTool(
"move_task",
{ projectId: "p1", viewId: "v1", taskId: "t1", position: 2 },
client,
);
expect(client.moveTaskPosition).toHaveBeenCalledWith(
"p1",
"v1",
"t1",
2,
undefined,
);
});

it("rejects a missing position", async () => {
const client = makeClient();
await expect(
handleViewTool(
"move_task",
{ projectId: "p1", viewId: "v1", taskId: "t1" },
client,
),
).rejects.toThrow();
expect(client.moveTaskPosition).not.toHaveBeenCalled();
});

it("includes 'moved successfully' in response", async () => {
it("reports the new position and that no cross-view move happened", async () => {
const result = await handleViewTool(
"move_task",
{ projectId: "p1", viewId: "v1", taskId: "t1", targetViewId: "v2" },
{ projectId: "p1", viewId: "v1", taskId: "t1", position: 3 },
makeClient(),
);
expect(result.content[0].text).toContain("moved successfully");
expect(result.content[0].text).toContain("position set to 3");
expect(result.content[0].text).toContain("reorder only");
});
});

Expand Down
23 changes: 14 additions & 9 deletions apps/mcp/src/api/views-client.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type {
Attachment,
BulkMoveTasksInput,
CreateCustomFieldInput,
CreateViewInput,
CustomFieldDefinition,
MoveTaskPositionItem,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MoveTaskPositionItem is imported here but never referenced anywhere in the file, so biome lint reports lint/correctness/noUnusedImports (it exits 0, so CI is not blocked, but the warning stands). It looks like the intent was to type bulkMoveViewTaskPositions's parameter: replace that method's inline Array<{ task_id; position; group_key? }> with MoveTaskPositionItem[] and the import is used; otherwise drop the import.

PacaConfig,
ReorderViewsInput,
SuccessEnvelope,
Expand Down Expand Up @@ -201,18 +201,23 @@ export class PacaAPIViewsClient {
);
}

async bulkMoveTasks(
async moveTaskPosition(
projectId: string,
viewId: string,
input: BulkMoveTasksInput,
taskId: string,
position: number,
groupKey?: string | null,
): Promise<void> {
// The API's MoveTaskRequest is an upsert of the task's order within this
// view only: it accepts { position, group_key? } and never moves a task
// across views, sprints, or statuses.
const body: { position: number; group_key?: string | null } = { position };
if (groupKey !== undefined) {
body.group_key = groupKey;
}
await this.put(
`/api/v1/projects/${projectId}/views/${viewId}/task-positions/${input.task_id}`,
{
target_view_id: input.target_view_id,
target_status_id: input.target_status_id,
target_position: input.target_position,
},
`/api/v1/projects/${projectId}/views/${viewId}/task-positions/${taskId}`,
body,
);
}

Expand Down
Loading