@gitpm/cli 0.1.0
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/index.js +801 -0
- package/package.json +20 -0
- package/src/__tests__/import.test.ts +220 -0
- package/src/__tests__/init.test.ts +112 -0
- package/src/__tests__/pull.test.ts +225 -0
- package/src/__tests__/push.test.ts +242 -0
- package/src/__tests__/sync.test.ts +264 -0
- package/src/__tests__/validate.test.ts +178 -0
- package/src/commands/import.ts +184 -0
- package/src/commands/init.ts +62 -0
- package/src/commands/pull.ts +145 -0
- package/src/commands/push.ts +224 -0
- package/src/commands/sync.ts +238 -0
- package/src/commands/validate.ts +79 -0
- package/src/index.ts +33 -0
- package/src/utils/auth.ts +28 -0
- package/src/utils/config.ts +5 -0
- package/src/utils/conflict-ui.ts +45 -0
- package/src/utils/output.ts +23 -0
- package/tsconfig.json +8 -0
- package/tsup.config.ts +11 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
// --- Mocks ---
|
|
5
|
+
|
|
6
|
+
const mockLoadGitHubConfig = vi.fn();
|
|
7
|
+
const mockExportToGitHub = vi.fn();
|
|
8
|
+
const mockLoadGitLabConfig = vi.fn();
|
|
9
|
+
const mockExportToGitLab = vi.fn();
|
|
10
|
+
const mockResolveToken = vi.fn();
|
|
11
|
+
const mockConfirm = vi.fn();
|
|
12
|
+
|
|
13
|
+
vi.mock('@gitpm/sync-github', () => ({
|
|
14
|
+
loadConfig: (...args: unknown[]) => mockLoadGitHubConfig(...args),
|
|
15
|
+
exportToGitHub: (...args: unknown[]) => mockExportToGitHub(...args),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock('@gitpm/sync-gitlab', () => ({
|
|
19
|
+
loadConfig: (...args: unknown[]) => mockLoadGitLabConfig(...args),
|
|
20
|
+
exportToGitLab: (...args: unknown[]) => mockExportToGitLab(...args),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
vi.mock('../utils/auth.js', () => ({
|
|
24
|
+
resolveToken: (...args: unknown[]) => mockResolveToken(...args),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
vi.mock('@inquirer/prompts', () => ({
|
|
28
|
+
confirm: (...args: unknown[]) => mockConfirm(...args),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
vi.mock('ora', () => ({
|
|
32
|
+
default: () => ({
|
|
33
|
+
start: vi.fn().mockReturnThis(),
|
|
34
|
+
succeed: vi.fn().mockReturnThis(),
|
|
35
|
+
fail: vi.fn().mockReturnThis(),
|
|
36
|
+
stop: vi.fn().mockReturnThis(),
|
|
37
|
+
}),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
let logSpy: ReturnType<typeof vi.spyOn>;
|
|
41
|
+
let errorSpy: ReturnType<typeof vi.spyOn>;
|
|
42
|
+
let exitSpy: ReturnType<typeof vi.spyOn>;
|
|
43
|
+
|
|
44
|
+
async function run(...args: string[]) {
|
|
45
|
+
const { pushCommand } = await import('../commands/push.js');
|
|
46
|
+
const program = new Command();
|
|
47
|
+
program.option('--meta-dir <path>', 'Path to .meta directory', '.meta');
|
|
48
|
+
program.addCommand(pushCommand);
|
|
49
|
+
await program.parseAsync(['node', 'gitpm', 'push', ...args]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const exportResult = {
|
|
53
|
+
created: { milestones: 1, issues: 2 },
|
|
54
|
+
updated: { milestones: 0, issues: 1 },
|
|
55
|
+
totalChanges: 4,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const noChangesResult = {
|
|
59
|
+
created: { milestones: 0, issues: 0 },
|
|
60
|
+
updated: { milestones: 0, issues: 0 },
|
|
61
|
+
totalChanges: 0,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// --- Tests ---
|
|
65
|
+
|
|
66
|
+
describe('gitpm push', () => {
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
vi.resetModules();
|
|
69
|
+
vi.resetAllMocks();
|
|
70
|
+
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
71
|
+
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
72
|
+
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
|
|
73
|
+
throw new Error('process.exit');
|
|
74
|
+
}) as never);
|
|
75
|
+
mockResolveToken.mockResolvedValue('mock-token');
|
|
76
|
+
// Default: GitHub config found, GitLab not
|
|
77
|
+
mockLoadGitHubConfig.mockResolvedValue({
|
|
78
|
+
ok: true,
|
|
79
|
+
value: { repo: 'owner/repo' },
|
|
80
|
+
});
|
|
81
|
+
mockLoadGitLabConfig.mockResolvedValue({
|
|
82
|
+
ok: false,
|
|
83
|
+
error: { message: 'not found' },
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
afterEach(() => {
|
|
88
|
+
vi.restoreAllMocks();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('prints preview in dry-run mode', async () => {
|
|
92
|
+
mockExportToGitHub.mockResolvedValue({ ok: true, value: exportResult });
|
|
93
|
+
|
|
94
|
+
await run('--dry-run', '--meta-dir', '/tmp/meta');
|
|
95
|
+
|
|
96
|
+
expect(mockExportToGitHub).toHaveBeenCalledWith(
|
|
97
|
+
expect.objectContaining({ dryRun: true }),
|
|
98
|
+
);
|
|
99
|
+
expect(mockConfirm).not.toHaveBeenCalled();
|
|
100
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
101
|
+
const allOutput = logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
102
|
+
expect(allOutput).toContain('New issues: 2');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('pushes when user confirms', async () => {
|
|
106
|
+
mockExportToGitHub
|
|
107
|
+
.mockResolvedValueOnce({ ok: true, value: exportResult }) // preview
|
|
108
|
+
.mockResolvedValueOnce({ ok: true, value: exportResult }); // actual push
|
|
109
|
+
mockConfirm.mockResolvedValue(true);
|
|
110
|
+
|
|
111
|
+
await run('--meta-dir', '/tmp/meta');
|
|
112
|
+
|
|
113
|
+
expect(mockExportToGitHub).toHaveBeenCalledTimes(2);
|
|
114
|
+
expect(mockConfirm).toHaveBeenCalled();
|
|
115
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('cancels push when user declines', async () => {
|
|
119
|
+
mockExportToGitHub.mockResolvedValue({ ok: true, value: exportResult });
|
|
120
|
+
mockConfirm.mockResolvedValue(false);
|
|
121
|
+
|
|
122
|
+
await run('--meta-dir', '/tmp/meta');
|
|
123
|
+
|
|
124
|
+
// Only called once for preview, not for actual push
|
|
125
|
+
expect(mockExportToGitHub).toHaveBeenCalledTimes(1);
|
|
126
|
+
const allOutput = logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
127
|
+
expect(allOutput).toContain('Push cancelled');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('skips confirmation with --yes', async () => {
|
|
131
|
+
mockExportToGitHub
|
|
132
|
+
.mockResolvedValueOnce({ ok: true, value: exportResult })
|
|
133
|
+
.mockResolvedValueOnce({ ok: true, value: exportResult });
|
|
134
|
+
|
|
135
|
+
await run('--yes', '--meta-dir', '/tmp/meta');
|
|
136
|
+
|
|
137
|
+
expect(mockConfirm).not.toHaveBeenCalled();
|
|
138
|
+
expect(mockExportToGitHub).toHaveBeenCalledTimes(2);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('prints nothing-to-push when totalChanges is 0', async () => {
|
|
142
|
+
mockExportToGitHub.mockResolvedValue({ ok: true, value: noChangesResult });
|
|
143
|
+
|
|
144
|
+
await run('--meta-dir', '/tmp/meta');
|
|
145
|
+
|
|
146
|
+
expect(mockConfirm).not.toHaveBeenCalled();
|
|
147
|
+
const allOutput = logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
148
|
+
expect(allOutput).toContain('Nothing to push');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('exits with code 1 when push fails', async () => {
|
|
152
|
+
mockExportToGitHub
|
|
153
|
+
.mockResolvedValueOnce({ ok: true, value: exportResult }) // preview ok
|
|
154
|
+
.mockResolvedValueOnce({ ok: false, error: { message: 'push error' } });
|
|
155
|
+
mockConfirm.mockResolvedValue(true);
|
|
156
|
+
|
|
157
|
+
await expect(run('--meta-dir', '/tmp/meta')).rejects.toThrow(
|
|
158
|
+
'process.exit',
|
|
159
|
+
);
|
|
160
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('exits with code 1 when no sync config found', async () => {
|
|
164
|
+
mockLoadGitHubConfig.mockResolvedValue({
|
|
165
|
+
ok: false,
|
|
166
|
+
error: { message: 'not found' },
|
|
167
|
+
});
|
|
168
|
+
mockLoadGitLabConfig.mockResolvedValue({
|
|
169
|
+
ok: false,
|
|
170
|
+
error: { message: 'not found' },
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
await expect(run('--meta-dir', '/tmp/meta')).rejects.toThrow(
|
|
174
|
+
'process.exit',
|
|
175
|
+
);
|
|
176
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
177
|
+
const errOutput = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
178
|
+
expect(errOutput).toContain('No sync config found');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('exits with code 1 when token resolution fails', async () => {
|
|
182
|
+
mockResolveToken.mockRejectedValue(new Error('No GitHub token found'));
|
|
183
|
+
|
|
184
|
+
await expect(run('--meta-dir', '/tmp/meta')).rejects.toThrow(
|
|
185
|
+
'process.exit',
|
|
186
|
+
);
|
|
187
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('pushes to GitLab when GitLab config is found', async () => {
|
|
191
|
+
mockLoadGitHubConfig.mockResolvedValue({
|
|
192
|
+
ok: false,
|
|
193
|
+
error: { message: 'not found' },
|
|
194
|
+
});
|
|
195
|
+
mockLoadGitLabConfig.mockResolvedValue({
|
|
196
|
+
ok: true,
|
|
197
|
+
value: {
|
|
198
|
+
project: 'group/proj',
|
|
199
|
+
project_id: 42,
|
|
200
|
+
base_url: 'https://gitlab.com',
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
mockExportToGitLab.mockResolvedValue({ ok: true, value: exportResult });
|
|
204
|
+
|
|
205
|
+
await run('--dry-run', '--token', 'gl-tok', '--meta-dir', '/tmp/meta');
|
|
206
|
+
|
|
207
|
+
expect(mockExportToGitLab).toHaveBeenCalledWith(
|
|
208
|
+
expect.objectContaining({
|
|
209
|
+
token: 'gl-tok',
|
|
210
|
+
project: 'group/proj',
|
|
211
|
+
dryRun: true,
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
214
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('exits with code 1 when GitLab token is missing', async () => {
|
|
218
|
+
const originalEnv = process.env.GITLAB_TOKEN;
|
|
219
|
+
// biome-ignore lint/performance/noDelete: must remove env var, not set to "undefined" string
|
|
220
|
+
delete process.env.GITLAB_TOKEN;
|
|
221
|
+
|
|
222
|
+
mockLoadGitHubConfig.mockResolvedValue({
|
|
223
|
+
ok: false,
|
|
224
|
+
error: { message: 'not found' },
|
|
225
|
+
});
|
|
226
|
+
mockLoadGitLabConfig.mockResolvedValue({
|
|
227
|
+
ok: true,
|
|
228
|
+
value: {
|
|
229
|
+
project: 'group/proj',
|
|
230
|
+
project_id: 42,
|
|
231
|
+
base_url: 'https://gitlab.com',
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
await expect(run('--meta-dir', '/tmp/meta')).rejects.toThrow(
|
|
236
|
+
'process.exit',
|
|
237
|
+
);
|
|
238
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
239
|
+
|
|
240
|
+
process.env.GITLAB_TOKEN = originalEnv;
|
|
241
|
+
});
|
|
242
|
+
});
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
// --- Mocks ---
|
|
5
|
+
|
|
6
|
+
const mockLoadGitHubConfig = vi.fn();
|
|
7
|
+
const mockSyncWithGitHub = vi.fn();
|
|
8
|
+
const mockLoadGitLabConfig = vi.fn();
|
|
9
|
+
const mockSyncWithGitLab = vi.fn();
|
|
10
|
+
const mockResolveToken = vi.fn();
|
|
11
|
+
const mockConfirm = vi.fn();
|
|
12
|
+
const mockPromptConflictResolution = vi.fn();
|
|
13
|
+
|
|
14
|
+
vi.mock('@gitpm/sync-github', () => ({
|
|
15
|
+
loadConfig: (...args: unknown[]) => mockLoadGitHubConfig(...args),
|
|
16
|
+
syncWithGitHub: (...args: unknown[]) => mockSyncWithGitHub(...args),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
vi.mock('@gitpm/sync-gitlab', () => ({
|
|
20
|
+
loadConfig: (...args: unknown[]) => mockLoadGitLabConfig(...args),
|
|
21
|
+
syncWithGitLab: (...args: unknown[]) => mockSyncWithGitLab(...args),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
vi.mock('../utils/auth.js', () => ({
|
|
25
|
+
resolveToken: (...args: unknown[]) => mockResolveToken(...args),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
vi.mock('../utils/conflict-ui.js', () => ({
|
|
29
|
+
promptConflictResolution: (...args: unknown[]) =>
|
|
30
|
+
mockPromptConflictResolution(...args),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
vi.mock('@inquirer/prompts', () => ({
|
|
34
|
+
confirm: (...args: unknown[]) => mockConfirm(...args),
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
vi.mock('ora', () => ({
|
|
38
|
+
default: () => ({
|
|
39
|
+
start: vi.fn().mockReturnThis(),
|
|
40
|
+
succeed: vi.fn().mockReturnThis(),
|
|
41
|
+
fail: vi.fn().mockReturnThis(),
|
|
42
|
+
stop: vi.fn().mockReturnThis(),
|
|
43
|
+
}),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
let logSpy: ReturnType<typeof vi.spyOn>;
|
|
47
|
+
let errorSpy: ReturnType<typeof vi.spyOn>;
|
|
48
|
+
let exitSpy: ReturnType<typeof vi.spyOn>;
|
|
49
|
+
|
|
50
|
+
async function run(...args: string[]) {
|
|
51
|
+
const { syncCommand } = await import('../commands/sync.js');
|
|
52
|
+
const program = new Command();
|
|
53
|
+
program.option('--meta-dir <path>', 'Path to .meta directory', '.meta');
|
|
54
|
+
program.addCommand(syncCommand);
|
|
55
|
+
await program.parseAsync(['node', 'gitpm', 'sync', ...args]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const syncResult = {
|
|
59
|
+
pushed: { milestones: 1, issues: 2 },
|
|
60
|
+
pulled: { milestones: 0, issues: 3 },
|
|
61
|
+
conflicts: [],
|
|
62
|
+
resolved: 0,
|
|
63
|
+
skipped: 0,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// --- Tests ---
|
|
67
|
+
|
|
68
|
+
describe('gitpm sync', () => {
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
vi.resetModules();
|
|
71
|
+
vi.resetAllMocks();
|
|
72
|
+
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
73
|
+
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
74
|
+
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
|
|
75
|
+
throw new Error('process.exit');
|
|
76
|
+
}) as never);
|
|
77
|
+
mockResolveToken.mockResolvedValue('mock-token');
|
|
78
|
+
mockLoadGitHubConfig.mockResolvedValue({
|
|
79
|
+
ok: true,
|
|
80
|
+
value: { repo: 'owner/repo' },
|
|
81
|
+
});
|
|
82
|
+
mockLoadGitLabConfig.mockResolvedValue({
|
|
83
|
+
ok: false,
|
|
84
|
+
error: { message: 'not found' },
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
afterEach(() => {
|
|
89
|
+
vi.restoreAllMocks();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('prints summary in dry-run mode', async () => {
|
|
93
|
+
mockSyncWithGitHub.mockResolvedValue({ ok: true, value: syncResult });
|
|
94
|
+
|
|
95
|
+
await run('--dry-run', '--meta-dir', '/tmp/meta');
|
|
96
|
+
|
|
97
|
+
expect(mockSyncWithGitHub).toHaveBeenCalledWith(
|
|
98
|
+
expect.objectContaining({ dryRun: true }),
|
|
99
|
+
);
|
|
100
|
+
expect(mockConfirm).not.toHaveBeenCalled();
|
|
101
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
102
|
+
const allOutput = logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
103
|
+
expect(allOutput).toContain('Sync Complete');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('syncs when user confirms', async () => {
|
|
107
|
+
mockSyncWithGitHub.mockResolvedValue({ ok: true, value: syncResult });
|
|
108
|
+
mockConfirm.mockResolvedValue(true);
|
|
109
|
+
|
|
110
|
+
await run('--meta-dir', '/tmp/meta');
|
|
111
|
+
|
|
112
|
+
expect(mockConfirm).toHaveBeenCalled();
|
|
113
|
+
expect(mockSyncWithGitHub).toHaveBeenCalled();
|
|
114
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('cancels sync when user declines', async () => {
|
|
118
|
+
mockConfirm.mockResolvedValue(false);
|
|
119
|
+
|
|
120
|
+
await run('--meta-dir', '/tmp/meta');
|
|
121
|
+
|
|
122
|
+
expect(mockSyncWithGitHub).not.toHaveBeenCalled();
|
|
123
|
+
const allOutput = logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
124
|
+
expect(allOutput).toContain('Sync cancelled');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('skips confirmation with --yes', async () => {
|
|
128
|
+
mockSyncWithGitHub.mockResolvedValue({ ok: true, value: syncResult });
|
|
129
|
+
|
|
130
|
+
await run('--yes', '--meta-dir', '/tmp/meta');
|
|
131
|
+
|
|
132
|
+
expect(mockConfirm).not.toHaveBeenCalled();
|
|
133
|
+
expect(mockSyncWithGitHub).toHaveBeenCalled();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('prompts for conflict resolution with ask strategy', async () => {
|
|
137
|
+
const conflicts = [
|
|
138
|
+
{
|
|
139
|
+
entityId: 'e1',
|
|
140
|
+
entityTitle: 'Story 1',
|
|
141
|
+
entityType: 'story',
|
|
142
|
+
field: 'status',
|
|
143
|
+
localValue: 'in_progress',
|
|
144
|
+
remoteValue: 'done',
|
|
145
|
+
},
|
|
146
|
+
];
|
|
147
|
+
mockSyncWithGitHub.mockResolvedValue({
|
|
148
|
+
ok: true,
|
|
149
|
+
value: { ...syncResult, conflicts, resolved: 0 },
|
|
150
|
+
});
|
|
151
|
+
mockConfirm.mockResolvedValue(true);
|
|
152
|
+
mockPromptConflictResolution.mockResolvedValue([
|
|
153
|
+
{ entityId: 'e1', field: 'status', pick: 'remote' },
|
|
154
|
+
]);
|
|
155
|
+
|
|
156
|
+
await run('--strategy', 'ask', '--meta-dir', '/tmp/meta');
|
|
157
|
+
|
|
158
|
+
expect(mockPromptConflictResolution).toHaveBeenCalledWith(conflicts);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('does not prompt for conflicts with local-wins', async () => {
|
|
162
|
+
mockSyncWithGitHub.mockResolvedValue({ ok: true, value: syncResult });
|
|
163
|
+
mockConfirm.mockResolvedValue(true);
|
|
164
|
+
|
|
165
|
+
await run('--strategy', 'local-wins', '--meta-dir', '/tmp/meta');
|
|
166
|
+
|
|
167
|
+
expect(mockPromptConflictResolution).not.toHaveBeenCalled();
|
|
168
|
+
expect(mockSyncWithGitHub).toHaveBeenCalledWith(
|
|
169
|
+
expect.objectContaining({ strategy: 'local-wins' }),
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('does not prompt for conflicts with remote-wins', async () => {
|
|
174
|
+
mockSyncWithGitHub.mockResolvedValue({ ok: true, value: syncResult });
|
|
175
|
+
mockConfirm.mockResolvedValue(true);
|
|
176
|
+
|
|
177
|
+
await run('--strategy', 'remote-wins', '--meta-dir', '/tmp/meta');
|
|
178
|
+
|
|
179
|
+
expect(mockPromptConflictResolution).not.toHaveBeenCalled();
|
|
180
|
+
expect(mockSyncWithGitHub).toHaveBeenCalledWith(
|
|
181
|
+
expect.objectContaining({ strategy: 'remote-wins' }),
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('exits with code 1 when sync fails', async () => {
|
|
186
|
+
mockSyncWithGitHub.mockResolvedValue({
|
|
187
|
+
ok: false,
|
|
188
|
+
error: { message: 'sync failed' },
|
|
189
|
+
});
|
|
190
|
+
mockConfirm.mockResolvedValue(true);
|
|
191
|
+
|
|
192
|
+
await expect(run('--meta-dir', '/tmp/meta')).rejects.toThrow(
|
|
193
|
+
'process.exit',
|
|
194
|
+
);
|
|
195
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('exits with code 1 when no sync config found', async () => {
|
|
199
|
+
mockLoadGitHubConfig.mockResolvedValue({
|
|
200
|
+
ok: false,
|
|
201
|
+
error: { message: 'not found' },
|
|
202
|
+
});
|
|
203
|
+
mockLoadGitLabConfig.mockResolvedValue({
|
|
204
|
+
ok: false,
|
|
205
|
+
error: { message: 'not found' },
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
await expect(run('--meta-dir', '/tmp/meta')).rejects.toThrow(
|
|
209
|
+
'process.exit',
|
|
210
|
+
);
|
|
211
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
212
|
+
const errOutput = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
213
|
+
expect(errOutput).toContain('No sync config found');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('syncs with GitLab in dry-run mode', async () => {
|
|
217
|
+
mockLoadGitHubConfig.mockResolvedValue({
|
|
218
|
+
ok: false,
|
|
219
|
+
error: { message: 'not found' },
|
|
220
|
+
});
|
|
221
|
+
mockLoadGitLabConfig.mockResolvedValue({
|
|
222
|
+
ok: true,
|
|
223
|
+
value: {
|
|
224
|
+
project: 'group/proj',
|
|
225
|
+
project_id: 42,
|
|
226
|
+
base_url: 'https://gitlab.com',
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
mockSyncWithGitLab.mockResolvedValue({ ok: true, value: syncResult });
|
|
230
|
+
|
|
231
|
+
await run('--dry-run', '--token', 'gl-tok', '--meta-dir', '/tmp/meta');
|
|
232
|
+
|
|
233
|
+
expect(mockSyncWithGitLab).toHaveBeenCalledWith(
|
|
234
|
+
expect.objectContaining({ dryRun: true, token: 'gl-tok' }),
|
|
235
|
+
);
|
|
236
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('exits with code 1 when GitLab token is missing', async () => {
|
|
240
|
+
const originalEnv = process.env.GITLAB_TOKEN;
|
|
241
|
+
// biome-ignore lint/performance/noDelete: must remove env var, not set to "undefined" string
|
|
242
|
+
delete process.env.GITLAB_TOKEN;
|
|
243
|
+
|
|
244
|
+
mockLoadGitHubConfig.mockResolvedValue({
|
|
245
|
+
ok: false,
|
|
246
|
+
error: { message: 'not found' },
|
|
247
|
+
});
|
|
248
|
+
mockLoadGitLabConfig.mockResolvedValue({
|
|
249
|
+
ok: true,
|
|
250
|
+
value: {
|
|
251
|
+
project: 'group/proj',
|
|
252
|
+
project_id: 42,
|
|
253
|
+
base_url: 'https://gitlab.com',
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
await expect(run('--meta-dir', '/tmp/meta')).rejects.toThrow(
|
|
258
|
+
'process.exit',
|
|
259
|
+
);
|
|
260
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
261
|
+
|
|
262
|
+
process.env.GITLAB_TOKEN = originalEnv;
|
|
263
|
+
});
|
|
264
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
// --- Mocks ---
|
|
5
|
+
|
|
6
|
+
const mockParseTree = vi.fn();
|
|
7
|
+
const mockResolveRefs = vi.fn();
|
|
8
|
+
const mockValidateTree = vi.fn();
|
|
9
|
+
|
|
10
|
+
vi.mock('@gitpm/core', () => ({
|
|
11
|
+
parseTree: (...args: unknown[]) => mockParseTree(...args),
|
|
12
|
+
resolveRefs: (...args: unknown[]) => mockResolveRefs(...args),
|
|
13
|
+
validateTree: (...args: unknown[]) => mockValidateTree(...args),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
let logSpy: ReturnType<typeof vi.spyOn>;
|
|
17
|
+
let errorSpy: ReturnType<typeof vi.spyOn>;
|
|
18
|
+
let warnSpy: ReturnType<typeof vi.spyOn>;
|
|
19
|
+
let exitSpy: ReturnType<typeof vi.spyOn>;
|
|
20
|
+
|
|
21
|
+
async function run(...args: string[]) {
|
|
22
|
+
const { validateCommand } = await import('../commands/validate.js');
|
|
23
|
+
const program = new Command();
|
|
24
|
+
program.option('--meta-dir <path>', 'Path to .meta directory', '.meta');
|
|
25
|
+
program.addCommand(validateCommand);
|
|
26
|
+
await program.parseAsync(['node', 'gitpm', 'validate', ...args]);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// --- Helpers ---
|
|
30
|
+
|
|
31
|
+
function makeTree(
|
|
32
|
+
overrides?: Partial<{
|
|
33
|
+
stories: unknown[];
|
|
34
|
+
epics: unknown[];
|
|
35
|
+
milestones: unknown[];
|
|
36
|
+
roadmaps: unknown[];
|
|
37
|
+
prds: unknown[];
|
|
38
|
+
errors: unknown[];
|
|
39
|
+
}>,
|
|
40
|
+
) {
|
|
41
|
+
return {
|
|
42
|
+
stories: [],
|
|
43
|
+
epics: [],
|
|
44
|
+
milestones: [],
|
|
45
|
+
roadmaps: [],
|
|
46
|
+
prds: [],
|
|
47
|
+
errors: [],
|
|
48
|
+
...overrides,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// --- Tests ---
|
|
53
|
+
|
|
54
|
+
describe('gitpm validate', () => {
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
vi.resetModules();
|
|
57
|
+
vi.resetAllMocks();
|
|
58
|
+
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
59
|
+
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
60
|
+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
61
|
+
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
|
|
62
|
+
throw new Error('process.exit');
|
|
63
|
+
}) as never);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
vi.restoreAllMocks();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('prints success for a valid tree with no warnings', async () => {
|
|
71
|
+
const tree = makeTree({
|
|
72
|
+
stories: [{ id: 's1' }, { id: 's2' }],
|
|
73
|
+
epics: [{ id: 'e1' }],
|
|
74
|
+
});
|
|
75
|
+
mockParseTree.mockResolvedValue({ ok: true, value: tree });
|
|
76
|
+
mockResolveRefs.mockReturnValue({ ok: true, value: { resolved: true } });
|
|
77
|
+
mockValidateTree.mockReturnValue({ valid: true, errors: [], warnings: [] });
|
|
78
|
+
|
|
79
|
+
await run('--meta-dir', '/tmp/test-meta');
|
|
80
|
+
|
|
81
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
82
|
+
const allOutput = logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
83
|
+
expect(allOutput).toContain('3 entities');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('prints warnings but does not exit for a valid tree with warnings', async () => {
|
|
87
|
+
const tree = makeTree({ stories: [{ id: 's1' }] });
|
|
88
|
+
mockParseTree.mockResolvedValue({ ok: true, value: tree });
|
|
89
|
+
mockResolveRefs.mockReturnValue({ ok: true, value: {} });
|
|
90
|
+
mockValidateTree.mockReturnValue({
|
|
91
|
+
valid: true,
|
|
92
|
+
errors: [],
|
|
93
|
+
warnings: [
|
|
94
|
+
{
|
|
95
|
+
filePath: '/tmp/test.md',
|
|
96
|
+
entityId: 'w1',
|
|
97
|
+
code: 'WARN_01',
|
|
98
|
+
message: 'something fishy',
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
await run('--meta-dir', '/tmp/test-meta');
|
|
104
|
+
|
|
105
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
106
|
+
const warnOutput = warnSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
107
|
+
expect(warnOutput).toContain('1 warning(s)');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('exits with code 1 for an invalid tree', async () => {
|
|
111
|
+
const tree = makeTree();
|
|
112
|
+
mockParseTree.mockResolvedValue({ ok: true, value: tree });
|
|
113
|
+
mockResolveRefs.mockReturnValue({ ok: true, value: {} });
|
|
114
|
+
mockValidateTree.mockReturnValue({
|
|
115
|
+
valid: false,
|
|
116
|
+
errors: [
|
|
117
|
+
{
|
|
118
|
+
filePath: '/tmp/bad.md',
|
|
119
|
+
entityId: 'e1',
|
|
120
|
+
code: 'ERR_01',
|
|
121
|
+
message: 'bad entity',
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
warnings: [],
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
await expect(run('--meta-dir', '/tmp/test-meta')).rejects.toThrow(
|
|
128
|
+
'process.exit',
|
|
129
|
+
);
|
|
130
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('exits with code 1 when parseTree fails', async () => {
|
|
134
|
+
mockParseTree.mockResolvedValue({
|
|
135
|
+
ok: false,
|
|
136
|
+
error: { message: 'cannot read .meta' },
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
await expect(run('--meta-dir', '/tmp/test-meta')).rejects.toThrow(
|
|
140
|
+
'process.exit',
|
|
141
|
+
);
|
|
142
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
143
|
+
const errOutput = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
144
|
+
expect(errOutput).toContain('cannot read .meta');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('exits with code 1 when resolveRefs fails', async () => {
|
|
148
|
+
const tree = makeTree();
|
|
149
|
+
mockParseTree.mockResolvedValue({ ok: true, value: tree });
|
|
150
|
+
mockResolveRefs.mockReturnValue({
|
|
151
|
+
ok: false,
|
|
152
|
+
error: { message: 'broken ref' },
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
await expect(run('--meta-dir', '/tmp/test-meta')).rejects.toThrow(
|
|
156
|
+
'process.exit',
|
|
157
|
+
);
|
|
158
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
159
|
+
const errOutput = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
160
|
+
expect(errOutput).toContain('broken ref');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('prints parse errors from the tree', async () => {
|
|
164
|
+
const tree = makeTree({
|
|
165
|
+
errors: [
|
|
166
|
+
{ filePath: '/tmp/bad-story.md', message: 'invalid frontmatter' },
|
|
167
|
+
],
|
|
168
|
+
});
|
|
169
|
+
mockParseTree.mockResolvedValue({ ok: true, value: tree });
|
|
170
|
+
mockResolveRefs.mockReturnValue({ ok: true, value: {} });
|
|
171
|
+
mockValidateTree.mockReturnValue({ valid: true, errors: [], warnings: [] });
|
|
172
|
+
|
|
173
|
+
await run('--meta-dir', '/tmp/test-meta');
|
|
174
|
+
|
|
175
|
+
const allOutput = logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
176
|
+
expect(allOutput).toContain('invalid frontmatter');
|
|
177
|
+
});
|
|
178
|
+
});
|