@cleocode/core 2026.3.44 → 2026.3.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin/import-tasks.d.ts +10 -2
- package/dist/admin/import-tasks.d.ts.map +1 -1
- package/dist/index.js +415 -132
- package/dist/index.js.map +4 -4
- package/dist/internal.d.ts +3 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/nexus/index.d.ts +2 -0
- package/dist/nexus/index.d.ts.map +1 -1
- package/dist/nexus/transfer-types.d.ts +123 -0
- package/dist/nexus/transfer-types.d.ts.map +1 -0
- package/dist/nexus/transfer.d.ts +31 -0
- package/dist/nexus/transfer.d.ts.map +1 -0
- package/dist/store/brain-sqlite.d.ts +4 -1
- package/dist/store/brain-sqlite.d.ts.map +1 -1
- package/dist/store/nexus-sqlite.d.ts +4 -1
- package/dist/store/nexus-sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.d.ts +4 -1
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/tasks-schema.d.ts +3 -3
- package/dist/store/tasks-schema.d.ts.map +1 -1
- package/dist/store/validation-schemas.d.ts +5 -4
- package/dist/store/validation-schemas.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/admin/import-tasks.ts +53 -29
- package/src/internal.ts +7 -1
- package/src/nexus/__tests__/transfer.test.ts +446 -0
- package/src/nexus/index.ts +14 -0
- package/src/nexus/transfer-types.ts +129 -0
- package/src/nexus/transfer.ts +314 -0
- package/src/store/brain-sqlite.ts +7 -3
- package/src/store/nexus-sqlite.ts +7 -3
- package/src/store/sqlite.ts +9 -3
- package/src/store/tasks-schema.ts +1 -1
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Extracted from CLI import-tasks command for dispatch layer access.
|
|
5
5
|
*
|
|
6
|
-
* @task T5323, T5328
|
|
6
|
+
* @task T5323, T5328, T046
|
|
7
7
|
* @epic T4545
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { constants as fsConstants } from 'node:fs';
|
|
11
11
|
import { access, readFile } from 'node:fs/promises';
|
|
12
12
|
import type { Task, TaskStatus } from '@cleocode/contracts';
|
|
13
|
+
import type { ImportFromPackageOptions, ImportFromPackageResult } from '../nexus/transfer-types.js';
|
|
13
14
|
import { getAccessor } from '../store/data-accessor.js';
|
|
14
15
|
import type { ExportPackage } from '../store/export.js';
|
|
15
16
|
import {
|
|
@@ -74,25 +75,13 @@ export interface ImportTasksResult {
|
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
/**
|
|
77
|
-
* Import tasks from
|
|
78
|
+
* Import tasks from an in-memory ExportPackage with ID remapping.
|
|
79
|
+
* Core logic extracted from importTasksPackage for reuse by transfer engine.
|
|
78
80
|
*/
|
|
79
|
-
export async function
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
await access(file, fsConstants.R_OK);
|
|
84
|
-
} catch {
|
|
85
|
-
throw new Error(`Export file not found: ${file}`);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const content = await readFile(file, 'utf-8');
|
|
89
|
-
let exportPkg: ExportPackage;
|
|
90
|
-
try {
|
|
91
|
-
exportPkg = JSON.parse(content) as ExportPackage;
|
|
92
|
-
} catch {
|
|
93
|
-
throw new Error(`Invalid JSON in: ${file}`);
|
|
94
|
-
}
|
|
95
|
-
|
|
81
|
+
export async function importFromPackage(
|
|
82
|
+
exportPkg: ExportPackage,
|
|
83
|
+
options: ImportFromPackageOptions = {},
|
|
84
|
+
): Promise<ImportFromPackageResult> {
|
|
96
85
|
if (exportPkg._meta?.format !== 'cleo-export') {
|
|
97
86
|
throw new Error(
|
|
98
87
|
`Invalid export format (expected 'cleo-export', got '${exportPkg._meta?.format}')`,
|
|
@@ -103,17 +92,17 @@ export async function importTasksPackage(params: ImportTasksParams): Promise<Imp
|
|
|
103
92
|
throw new Error('Export package contains no tasks');
|
|
104
93
|
}
|
|
105
94
|
|
|
106
|
-
const accessor = await getAccessor(
|
|
95
|
+
const accessor = await getAccessor(options.cwd);
|
|
107
96
|
const { tasks: existingTasks } = await accessor.queryTasks({});
|
|
108
97
|
|
|
109
|
-
const onConflict: OnConflict =
|
|
110
|
-
const onMissingDep: OnMissingDep =
|
|
111
|
-
const force =
|
|
112
|
-
const parentId =
|
|
113
|
-
const phaseOverride =
|
|
114
|
-
const addLabel =
|
|
115
|
-
const resetStatus =
|
|
116
|
-
const addProvenance =
|
|
98
|
+
const onConflict: OnConflict = options.onConflict ?? 'fail';
|
|
99
|
+
const onMissingDep: OnMissingDep = options.onMissingDep === 'fail' ? 'fail' : 'strip';
|
|
100
|
+
const force = options.force ?? false;
|
|
101
|
+
const parentId = options.parent;
|
|
102
|
+
const phaseOverride = options.phase;
|
|
103
|
+
const addLabel = options.addLabel;
|
|
104
|
+
const resetStatus = options.resetStatus;
|
|
105
|
+
const addProvenance = options.provenance !== false;
|
|
117
106
|
|
|
118
107
|
if (parentId) {
|
|
119
108
|
const parentExists = existingTasks.some((t) => t.id === parentId);
|
|
@@ -195,7 +184,7 @@ export async function importTasksPackage(params: ImportTasksParams): Promise<Imp
|
|
|
195
184
|
existingIds.add(remapped.id);
|
|
196
185
|
}
|
|
197
186
|
|
|
198
|
-
if (
|
|
187
|
+
if (options.dryRun) {
|
|
199
188
|
return {
|
|
200
189
|
imported: transformed.length,
|
|
201
190
|
skipped: skipped.length,
|
|
@@ -217,3 +206,38 @@ export async function importTasksPackage(params: ImportTasksParams): Promise<Imp
|
|
|
217
206
|
idRemap: idRemapJson,
|
|
218
207
|
};
|
|
219
208
|
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Import tasks from a cross-project export package file with ID remapping.
|
|
212
|
+
* Thin wrapper around importFromPackage that handles file I/O.
|
|
213
|
+
*/
|
|
214
|
+
export async function importTasksPackage(params: ImportTasksParams): Promise<ImportTasksResult> {
|
|
215
|
+
const { file } = params;
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
await access(file, fsConstants.R_OK);
|
|
219
|
+
} catch {
|
|
220
|
+
throw new Error(`Export file not found: ${file}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const content = await readFile(file, 'utf-8');
|
|
224
|
+
let exportPkg: ExportPackage;
|
|
225
|
+
try {
|
|
226
|
+
exportPkg = JSON.parse(content) as ExportPackage;
|
|
227
|
+
} catch {
|
|
228
|
+
throw new Error(`Invalid JSON in: ${file}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return importFromPackage(exportPkg, {
|
|
232
|
+
cwd: params.cwd,
|
|
233
|
+
dryRun: params.dryRun,
|
|
234
|
+
parent: params.parent,
|
|
235
|
+
phase: params.phase,
|
|
236
|
+
addLabel: params.addLabel,
|
|
237
|
+
provenance: params.provenance,
|
|
238
|
+
resetStatus: params.resetStatus,
|
|
239
|
+
onConflict: params.onConflict,
|
|
240
|
+
onMissingDep: params.onMissingDep === 'placeholder' ? 'strip' : params.onMissingDep,
|
|
241
|
+
force: params.force,
|
|
242
|
+
});
|
|
243
|
+
}
|
package/src/internal.ts
CHANGED
|
@@ -27,7 +27,7 @@ export { exportTasksPackage } from './admin/export-tasks.js';
|
|
|
27
27
|
// Admin
|
|
28
28
|
export { computeHelp } from './admin/help.js';
|
|
29
29
|
export { importTasks } from './admin/import.js';
|
|
30
|
-
export { importTasksPackage } from './admin/import-tasks.js';
|
|
30
|
+
export { importFromPackage, importTasksPackage } from './admin/import-tasks.js';
|
|
31
31
|
// ADRs
|
|
32
32
|
export { findAdrs } from './adrs/find.js';
|
|
33
33
|
export { listAdrs, showAdr, syncAdrsToDb, validateAllAdrs } from './adrs/index.js';
|
|
@@ -668,6 +668,12 @@ export {
|
|
|
668
668
|
} from './nexus/discover.js';
|
|
669
669
|
// Nexus — readRegistry (exported as nexusReadRegistry to avoid name clash with skills readRegistry)
|
|
670
670
|
export { readRegistry as nexusReadRegistry } from './nexus/registry.js';
|
|
671
|
+
// Nexus — transfer
|
|
672
|
+
export { executeTransfer, previewTransfer } from './nexus/transfer.js';
|
|
673
|
+
export type {
|
|
674
|
+
TransferParams,
|
|
675
|
+
TransferResult,
|
|
676
|
+
} from './nexus/transfer-types.js';
|
|
671
677
|
export type { DependencyAnalysis } from './orchestration/analyze.js';
|
|
672
678
|
export { analyzeDependencies as orchestrationAnalyzeDependencies } from './orchestration/analyze.js';
|
|
673
679
|
export { getCriticalPath as orchestrationGetCriticalPath } from './orchestration/critical-path.js';
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for NEXUS cross-project task transfer.
|
|
3
|
+
*
|
|
4
|
+
* @task T046, T055
|
|
5
|
+
* @epic T4540
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import type { Task } from '@cleocode/contracts';
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
13
|
+
import { getLinksByTaskId } from '../../reconciliation/link-store.js';
|
|
14
|
+
import { seedTasks } from '../../store/__tests__/test-db-helper.js';
|
|
15
|
+
import { resetDbState } from '../../store/sqlite.js';
|
|
16
|
+
import { createSqliteDataAccessor } from '../../store/sqlite-data-accessor.js';
|
|
17
|
+
import { nexusInit, nexusRegister, resetNexusDbState } from '../registry.js';
|
|
18
|
+
import { executeTransfer, previewTransfer } from '../transfer.js';
|
|
19
|
+
|
|
20
|
+
/** Create a test project with tasks in SQLite (tasks.db). */
|
|
21
|
+
async function createTestProjectDb(
|
|
22
|
+
dir: string,
|
|
23
|
+
tasks: Array<Partial<Task> & { id: string }>,
|
|
24
|
+
): Promise<void> {
|
|
25
|
+
await mkdir(join(dir, '.cleo'), { recursive: true });
|
|
26
|
+
resetDbState();
|
|
27
|
+
const accessor = await createSqliteDataAccessor(dir);
|
|
28
|
+
await seedTasks(accessor, tasks);
|
|
29
|
+
await accessor.close();
|
|
30
|
+
resetDbState();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let testDir: string;
|
|
34
|
+
let registryDir: string;
|
|
35
|
+
let sourceDir: string;
|
|
36
|
+
let targetDir: string;
|
|
37
|
+
|
|
38
|
+
beforeEach(async () => {
|
|
39
|
+
testDir = await mkdtemp(join(tmpdir(), 'nexus-transfer-test-'));
|
|
40
|
+
registryDir = join(testDir, 'cleo-home');
|
|
41
|
+
sourceDir = join(testDir, 'source-project');
|
|
42
|
+
targetDir = join(testDir, 'target-project');
|
|
43
|
+
|
|
44
|
+
await mkdir(registryDir, { recursive: true });
|
|
45
|
+
|
|
46
|
+
// Create source project with a task hierarchy
|
|
47
|
+
await createTestProjectDb(sourceDir, [
|
|
48
|
+
{ id: 'T001', title: 'Epic: Auth', type: 'epic', status: 'in-progress' },
|
|
49
|
+
{
|
|
50
|
+
id: 'T002',
|
|
51
|
+
title: 'Login form',
|
|
52
|
+
parentId: 'T001',
|
|
53
|
+
status: 'pending',
|
|
54
|
+
description: 'Build login',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: 'T003',
|
|
58
|
+
title: 'JWT tokens',
|
|
59
|
+
parentId: 'T001',
|
|
60
|
+
depends: ['T002'],
|
|
61
|
+
status: 'pending',
|
|
62
|
+
},
|
|
63
|
+
{ id: 'T004', title: 'Unrelated task', status: 'done' },
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
// Create empty target project
|
|
67
|
+
await createTestProjectDb(targetDir, []);
|
|
68
|
+
|
|
69
|
+
// Point env vars to test dirs
|
|
70
|
+
process.env['CLEO_HOME'] = registryDir;
|
|
71
|
+
process.env['NEXUS_HOME'] = join(registryDir, 'nexus');
|
|
72
|
+
process.env['NEXUS_CACHE_DIR'] = join(registryDir, 'nexus', 'cache');
|
|
73
|
+
process.env['NEXUS_SKIP_PERMISSION_CHECK'] = 'true';
|
|
74
|
+
|
|
75
|
+
resetNexusDbState();
|
|
76
|
+
|
|
77
|
+
// Register both projects
|
|
78
|
+
await nexusInit();
|
|
79
|
+
await nexusRegister(sourceDir, 'source-project', 'read');
|
|
80
|
+
resetDbState();
|
|
81
|
+
await nexusRegister(targetDir, 'target-project', 'write');
|
|
82
|
+
resetDbState();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterEach(async () => {
|
|
86
|
+
delete process.env['CLEO_HOME'];
|
|
87
|
+
delete process.env['NEXUS_HOME'];
|
|
88
|
+
delete process.env['NEXUS_CACHE_DIR'];
|
|
89
|
+
delete process.env['NEXUS_SKIP_PERMISSION_CHECK'];
|
|
90
|
+
resetNexusDbState();
|
|
91
|
+
resetDbState();
|
|
92
|
+
await rm(testDir, { recursive: true, force: true });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('previewTransfer', () => {
|
|
96
|
+
it('returns a dry-run result without writing', async () => {
|
|
97
|
+
const result = await previewTransfer({
|
|
98
|
+
taskIds: ['T001'],
|
|
99
|
+
sourceProject: 'source-project',
|
|
100
|
+
targetProject: 'target-project',
|
|
101
|
+
scope: 'subtree',
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(result.dryRun).toBe(true);
|
|
105
|
+
expect(result.transferred).toBe(3); // T001 + T002 + T003
|
|
106
|
+
expect(result.manifest.sourceProject).toBe('source-project');
|
|
107
|
+
expect(result.manifest.targetProject).toBe('target-project');
|
|
108
|
+
expect(result.manifest.entries).toHaveLength(3);
|
|
109
|
+
|
|
110
|
+
// Verify nothing was written to target
|
|
111
|
+
resetDbState();
|
|
112
|
+
const accessor = await createSqliteDataAccessor(targetDir);
|
|
113
|
+
const { tasks } = await accessor.queryTasks({});
|
|
114
|
+
expect(tasks).toHaveLength(0);
|
|
115
|
+
await accessor.close();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('returns single-task preview when scope is single', async () => {
|
|
119
|
+
const result = await previewTransfer({
|
|
120
|
+
taskIds: ['T001'],
|
|
121
|
+
sourceProject: 'source-project',
|
|
122
|
+
targetProject: 'target-project',
|
|
123
|
+
scope: 'single',
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(result.dryRun).toBe(true);
|
|
127
|
+
expect(result.transferred).toBe(1);
|
|
128
|
+
expect(result.manifest.entries).toHaveLength(1);
|
|
129
|
+
expect(result.manifest.entries[0]!.sourceId).toBe('T001');
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('executeTransfer - copy mode', () => {
|
|
134
|
+
it('copies a subtree to the target project', async () => {
|
|
135
|
+
const result = await executeTransfer({
|
|
136
|
+
taskIds: ['T001'],
|
|
137
|
+
sourceProject: 'source-project',
|
|
138
|
+
targetProject: 'target-project',
|
|
139
|
+
mode: 'copy',
|
|
140
|
+
scope: 'subtree',
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(result.dryRun).toBe(false);
|
|
144
|
+
expect(result.transferred).toBe(3);
|
|
145
|
+
expect(result.skipped).toBe(0);
|
|
146
|
+
expect(result.archived).toBe(0);
|
|
147
|
+
expect(result.manifest.mode).toBe('copy');
|
|
148
|
+
|
|
149
|
+
// Verify tasks exist in target
|
|
150
|
+
resetDbState();
|
|
151
|
+
const targetAccessor = await createSqliteDataAccessor(targetDir);
|
|
152
|
+
const { tasks: targetTasks } = await targetAccessor.queryTasks({});
|
|
153
|
+
expect(targetTasks).toHaveLength(3);
|
|
154
|
+
await targetAccessor.close();
|
|
155
|
+
|
|
156
|
+
// Verify source tasks still exist (not archived)
|
|
157
|
+
resetDbState();
|
|
158
|
+
const sourceAccessor = await createSqliteDataAccessor(sourceDir);
|
|
159
|
+
const { tasks: sourceTasks } = await sourceAccessor.queryTasks({});
|
|
160
|
+
expect(sourceTasks).toHaveLength(4); // all 4 original tasks
|
|
161
|
+
await sourceAccessor.close();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('remaps task IDs in the target', async () => {
|
|
165
|
+
// Seed target with a task so IDs don't accidentally collide
|
|
166
|
+
resetDbState();
|
|
167
|
+
const accessor = await createSqliteDataAccessor(targetDir);
|
|
168
|
+
await seedTasks(accessor, [{ id: 'T010', title: 'Existing target task', status: 'pending' }]);
|
|
169
|
+
await accessor.close();
|
|
170
|
+
resetDbState();
|
|
171
|
+
|
|
172
|
+
const result = await executeTransfer({
|
|
173
|
+
taskIds: ['T001'],
|
|
174
|
+
sourceProject: 'source-project',
|
|
175
|
+
targetProject: 'target-project',
|
|
176
|
+
scope: 'subtree',
|
|
177
|
+
onConflict: 'rename',
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
expect(Object.keys(result.manifest.idRemap).length).toBeGreaterThan(0);
|
|
181
|
+
|
|
182
|
+
// All target IDs should be different from source IDs since target has T010
|
|
183
|
+
for (const entry of result.manifest.entries) {
|
|
184
|
+
expect(entry.targetId).not.toBe(entry.sourceId);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('preserves parent-child hierarchy in target', async () => {
|
|
189
|
+
const result = await executeTransfer({
|
|
190
|
+
taskIds: ['T001'],
|
|
191
|
+
sourceProject: 'source-project',
|
|
192
|
+
targetProject: 'target-project',
|
|
193
|
+
scope: 'subtree',
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
resetDbState();
|
|
197
|
+
const accessor = await createSqliteDataAccessor(targetDir);
|
|
198
|
+
const { tasks } = await accessor.queryTasks({});
|
|
199
|
+
|
|
200
|
+
const epicId = result.manifest.idRemap['T001']!;
|
|
201
|
+
const loginId = result.manifest.idRemap['T002']!;
|
|
202
|
+
const jwtId = result.manifest.idRemap['T003']!;
|
|
203
|
+
|
|
204
|
+
const loginTask = tasks.find((t) => t.id === loginId);
|
|
205
|
+
const jwtTask = tasks.find((t) => t.id === jwtId);
|
|
206
|
+
|
|
207
|
+
expect(loginTask?.parentId).toBe(epicId);
|
|
208
|
+
expect(jwtTask?.parentId).toBe(epicId);
|
|
209
|
+
await accessor.close();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('preserves dependencies in target', async () => {
|
|
213
|
+
const result = await executeTransfer({
|
|
214
|
+
taskIds: ['T001'],
|
|
215
|
+
sourceProject: 'source-project',
|
|
216
|
+
targetProject: 'target-project',
|
|
217
|
+
scope: 'subtree',
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
resetDbState();
|
|
221
|
+
const accessor = await createSqliteDataAccessor(targetDir);
|
|
222
|
+
const { tasks } = await accessor.queryTasks({});
|
|
223
|
+
|
|
224
|
+
const loginId = result.manifest.idRemap['T002']!;
|
|
225
|
+
const jwtId = result.manifest.idRemap['T003']!;
|
|
226
|
+
const jwtTask = tasks.find((t) => t.id === jwtId);
|
|
227
|
+
|
|
228
|
+
expect(jwtTask?.depends).toContain(loginId);
|
|
229
|
+
await accessor.close();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('adds provenance notes by default', async () => {
|
|
233
|
+
const result = await executeTransfer({
|
|
234
|
+
taskIds: ['T001'],
|
|
235
|
+
sourceProject: 'source-project',
|
|
236
|
+
targetProject: 'target-project',
|
|
237
|
+
scope: 'single',
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
resetDbState();
|
|
241
|
+
const accessor = await createSqliteDataAccessor(targetDir);
|
|
242
|
+
const { tasks } = await accessor.queryTasks({});
|
|
243
|
+
|
|
244
|
+
const epicId = result.manifest.idRemap['T001']!;
|
|
245
|
+
const task = tasks.find((t) => t.id === epicId);
|
|
246
|
+
expect(task?.notes?.some((n) => n.includes('Imported from source-project'))).toBe(true);
|
|
247
|
+
await accessor.close();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('creates bidirectional external links', async () => {
|
|
251
|
+
const result = await executeTransfer({
|
|
252
|
+
taskIds: ['T004'],
|
|
253
|
+
sourceProject: 'source-project',
|
|
254
|
+
targetProject: 'target-project',
|
|
255
|
+
scope: 'single',
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
expect(result.linksCreated).toBe(2); // one in each project
|
|
259
|
+
|
|
260
|
+
// Check target link
|
|
261
|
+
resetDbState();
|
|
262
|
+
const targetId = result.manifest.idRemap['T004']!;
|
|
263
|
+
const targetLinks = await getLinksByTaskId(targetId, targetDir);
|
|
264
|
+
expect(targetLinks).toHaveLength(1);
|
|
265
|
+
expect(targetLinks[0]!.providerId).toBe('nexus:source-project');
|
|
266
|
+
expect(targetLinks[0]!.externalId).toBe('T004');
|
|
267
|
+
expect(targetLinks[0]!.linkType).toBe('transferred');
|
|
268
|
+
expect(targetLinks[0]!.syncDirection).toBe('inbound');
|
|
269
|
+
|
|
270
|
+
// Check source link
|
|
271
|
+
resetDbState();
|
|
272
|
+
const sourceLinks = await getLinksByTaskId('T004', sourceDir);
|
|
273
|
+
expect(sourceLinks).toHaveLength(1);
|
|
274
|
+
expect(sourceLinks[0]!.providerId).toBe('nexus:target-project');
|
|
275
|
+
expect(sourceLinks[0]!.externalId).toBe(targetId);
|
|
276
|
+
expect(sourceLinks[0]!.linkType).toBe('transferred');
|
|
277
|
+
expect(sourceLinks[0]!.syncDirection).toBe('outbound');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('transfers a single task without descendants', async () => {
|
|
281
|
+
const result = await executeTransfer({
|
|
282
|
+
taskIds: ['T004'],
|
|
283
|
+
sourceProject: 'source-project',
|
|
284
|
+
targetProject: 'target-project',
|
|
285
|
+
scope: 'single',
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
expect(result.transferred).toBe(1);
|
|
289
|
+
expect(result.manifest.entries).toHaveLength(1);
|
|
290
|
+
expect(result.manifest.entries[0]!.sourceId).toBe('T004');
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
describe('executeTransfer - move mode', () => {
|
|
295
|
+
it('archives source tasks after transfer', async () => {
|
|
296
|
+
const result = await executeTransfer({
|
|
297
|
+
taskIds: ['T004'],
|
|
298
|
+
sourceProject: 'source-project',
|
|
299
|
+
targetProject: 'target-project',
|
|
300
|
+
mode: 'move',
|
|
301
|
+
scope: 'single',
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
expect(result.transferred).toBe(1);
|
|
305
|
+
expect(result.archived).toBe(1);
|
|
306
|
+
expect(result.manifest.mode).toBe('move');
|
|
307
|
+
|
|
308
|
+
// Source task should be archived (queryTasks excludes archived by default)
|
|
309
|
+
resetDbState();
|
|
310
|
+
const sourceAccessor = await createSqliteDataAccessor(sourceDir);
|
|
311
|
+
const { tasks: sourceTasks } = await sourceAccessor.queryTasks({
|
|
312
|
+
status: 'archived',
|
|
313
|
+
});
|
|
314
|
+
const archivedTask = sourceTasks.find((t) => t.id === 'T004');
|
|
315
|
+
expect(archivedTask?.status).toBe('archived');
|
|
316
|
+
await sourceAccessor.close();
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
describe('executeTransfer - error handling', () => {
|
|
321
|
+
it('throws when source project not found', async () => {
|
|
322
|
+
await expect(
|
|
323
|
+
executeTransfer({
|
|
324
|
+
taskIds: ['T001'],
|
|
325
|
+
sourceProject: 'nonexistent',
|
|
326
|
+
targetProject: 'target-project',
|
|
327
|
+
}),
|
|
328
|
+
).rejects.toThrow('Source project not found');
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('throws when target project not found', async () => {
|
|
332
|
+
await expect(
|
|
333
|
+
executeTransfer({
|
|
334
|
+
taskIds: ['T001'],
|
|
335
|
+
sourceProject: 'source-project',
|
|
336
|
+
targetProject: 'nonexistent',
|
|
337
|
+
}),
|
|
338
|
+
).rejects.toThrow('Target project not found');
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('throws when source and target are the same', async () => {
|
|
342
|
+
await expect(
|
|
343
|
+
executeTransfer({
|
|
344
|
+
taskIds: ['T001'],
|
|
345
|
+
sourceProject: 'source-project',
|
|
346
|
+
targetProject: 'source-project',
|
|
347
|
+
}),
|
|
348
|
+
).rejects.toThrow('Source and target projects must be different');
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('throws when task not found in source', async () => {
|
|
352
|
+
await expect(
|
|
353
|
+
executeTransfer({
|
|
354
|
+
taskIds: ['T999'],
|
|
355
|
+
sourceProject: 'source-project',
|
|
356
|
+
targetProject: 'target-project',
|
|
357
|
+
}),
|
|
358
|
+
).rejects.toThrow('Task not found in source project: T999');
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('throws when no task IDs specified', async () => {
|
|
362
|
+
await expect(
|
|
363
|
+
executeTransfer({
|
|
364
|
+
taskIds: [],
|
|
365
|
+
sourceProject: 'source-project',
|
|
366
|
+
targetProject: 'target-project',
|
|
367
|
+
}),
|
|
368
|
+
).rejects.toThrow('No task IDs specified');
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
describe('executeTransfer - conflict resolution', () => {
|
|
373
|
+
it('renames tasks with duplicate titles by default', async () => {
|
|
374
|
+
// Create a task in target with the same title as source
|
|
375
|
+
resetDbState();
|
|
376
|
+
const accessor = await createSqliteDataAccessor(targetDir);
|
|
377
|
+
await seedTasks(accessor, [{ id: 'T001', title: 'Unrelated task', status: 'pending' }]);
|
|
378
|
+
await accessor.close();
|
|
379
|
+
resetDbState();
|
|
380
|
+
|
|
381
|
+
const result = await executeTransfer({
|
|
382
|
+
taskIds: ['T004'],
|
|
383
|
+
sourceProject: 'source-project',
|
|
384
|
+
targetProject: 'target-project',
|
|
385
|
+
scope: 'single',
|
|
386
|
+
onConflict: 'rename',
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
expect(result.transferred).toBe(1);
|
|
390
|
+
|
|
391
|
+
resetDbState();
|
|
392
|
+
const targetAccessor = await createSqliteDataAccessor(targetDir);
|
|
393
|
+
const { tasks: targetTasks } = await targetAccessor.queryTasks({});
|
|
394
|
+
const transferredTask = targetTasks.find((t) => t.id === result.manifest.idRemap['T004']);
|
|
395
|
+
expect(transferredTask?.title).toContain('imported');
|
|
396
|
+
await targetAccessor.close();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('skips tasks with duplicate titles when onConflict=skip', async () => {
|
|
400
|
+
resetDbState();
|
|
401
|
+
const accessor = await createSqliteDataAccessor(targetDir);
|
|
402
|
+
await seedTasks(accessor, [{ id: 'T001', title: 'Unrelated task', status: 'pending' }]);
|
|
403
|
+
await accessor.close();
|
|
404
|
+
resetDbState();
|
|
405
|
+
|
|
406
|
+
const result = await executeTransfer({
|
|
407
|
+
taskIds: ['T004'],
|
|
408
|
+
sourceProject: 'source-project',
|
|
409
|
+
targetProject: 'target-project',
|
|
410
|
+
scope: 'single',
|
|
411
|
+
onConflict: 'skip',
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
expect(result.skipped).toBe(1);
|
|
415
|
+
expect(result.transferred).toBe(0);
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
describe('executeTransfer - multiple tasks', () => {
|
|
420
|
+
it('transfers multiple independent tasks', async () => {
|
|
421
|
+
const result = await executeTransfer({
|
|
422
|
+
taskIds: ['T001', 'T004'],
|
|
423
|
+
sourceProject: 'source-project',
|
|
424
|
+
targetProject: 'target-project',
|
|
425
|
+
scope: 'single',
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
expect(result.transferred).toBe(2);
|
|
429
|
+
expect(result.manifest.entries).toHaveLength(2);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('deduplicates tasks when subtree overlaps', async () => {
|
|
433
|
+
// T001 subtree includes T002 and T003
|
|
434
|
+
// Requesting T001 and T002 should not duplicate T002
|
|
435
|
+
const result = await executeTransfer({
|
|
436
|
+
taskIds: ['T001', 'T002'],
|
|
437
|
+
sourceProject: 'source-project',
|
|
438
|
+
targetProject: 'target-project',
|
|
439
|
+
scope: 'subtree',
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// T001 subtree is T001, T002, T003. T002 subtree is just T002.
|
|
443
|
+
// Merged = T001, T002, T003 (deduped)
|
|
444
|
+
expect(result.transferred).toBe(3);
|
|
445
|
+
});
|
|
446
|
+
});
|
package/src/nexus/index.ts
CHANGED
|
@@ -98,3 +98,17 @@ export {
|
|
|
98
98
|
type SharingStatus,
|
|
99
99
|
syncGitignore,
|
|
100
100
|
} from './sharing/index.js';
|
|
101
|
+
// Transfer - cross-project task transfer
|
|
102
|
+
export { executeTransfer, previewTransfer } from './transfer.js';
|
|
103
|
+
export type {
|
|
104
|
+
ImportFromPackageOptions,
|
|
105
|
+
ImportFromPackageResult,
|
|
106
|
+
TransferManifest,
|
|
107
|
+
TransferManifestEntry,
|
|
108
|
+
TransferMode,
|
|
109
|
+
TransferOnConflict,
|
|
110
|
+
TransferOnMissingDep,
|
|
111
|
+
TransferParams,
|
|
112
|
+
TransferResult,
|
|
113
|
+
TransferScope,
|
|
114
|
+
} from './transfer-types.js';
|