@gitpm/cli 0.1.0 → 0.1.16

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/package.json CHANGED
@@ -1,20 +1,73 @@
1
1
  {
2
2
  "name": "@gitpm/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.16",
4
+ "license": "MIT",
5
+ "description": "Git-native project management CLI — manage roadmaps, epics, and stories from a .meta/ tree synced with GitHub, GitLab, Jira, Linear, and Bitbucket",
6
+ "keywords": [
7
+ "gitpm",
8
+ "project-management",
9
+ "github",
10
+ "gitlab",
11
+ "jira",
12
+ "linear",
13
+ "bitbucket",
14
+ "issues",
15
+ "roadmap",
16
+ "epics",
17
+ "cli",
18
+ "developer-tools"
19
+ ],
20
+ "homepage": "https://github.com/yevheniidehtiar/gitpm#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/yevheniidehtiar/gitpm/issues"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/yevheniidehtiar/gitpm.git",
27
+ "directory": "packages/cli"
28
+ },
4
29
  "type": "module",
30
+ "engines": {
31
+ "node": ">=22.12.0"
32
+ },
5
33
  "bin": {
6
- "gitpm": "dist/index.js"
34
+ "gitpm": "./dist/index.js"
7
35
  },
36
+ "files": [
37
+ "dist"
38
+ ],
8
39
  "scripts": {
9
40
  "build": "tsup"
10
41
  },
11
42
  "dependencies": {
12
- "@gitpm/core": "workspace:*",
13
- "@gitpm/sync-github": "workspace:*",
14
- "@gitpm/sync-gitlab": "workspace:*",
15
- "@inquirer/prompts": "^7.0.0",
16
- "chalk": "^5.3.0",
17
- "commander": "^12.1.0",
18
- "ora": "^8.1.0"
43
+ "@gitpm/core": "0.1.16",
44
+ "@inquirer/prompts": "^8.7.5",
45
+ "chalk": "^6.0.1",
46
+ "commander": "^15.0.0",
47
+ "ora": "^10.0.0"
48
+ },
49
+ "peerDependencies": {
50
+ "@gitpm/sync-github": "^0.1.14",
51
+ "@gitpm/sync-gitlab": "^0.1.11",
52
+ "@gitpm/sync-jira": "^0.1.10",
53
+ "@gitpm/sync-linear": "^0.1.2",
54
+ "@gitpm/sync-bitbucket": "^1.1.1"
55
+ },
56
+ "peerDependenciesMeta": {
57
+ "@gitpm/sync-github": {
58
+ "optional": true
59
+ },
60
+ "@gitpm/sync-gitlab": {
61
+ "optional": true
62
+ },
63
+ "@gitpm/sync-jira": {
64
+ "optional": true
65
+ },
66
+ "@gitpm/sync-linear": {
67
+ "optional": true
68
+ },
69
+ "@gitpm/sync-bitbucket": {
70
+ "optional": true
71
+ }
19
72
  }
20
73
  }
@@ -1,220 +0,0 @@
1
- import { Command } from 'commander';
2
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
-
4
- // --- Mocks ---
5
-
6
- const mockImportFromGitHub = vi.fn();
7
- const mockImportFromGitLab = vi.fn();
8
- const mockResolveToken = vi.fn();
9
-
10
- vi.mock('@gitpm/sync-github', () => ({
11
- importFromGitHub: (...args: unknown[]) => mockImportFromGitHub(...args),
12
- }));
13
-
14
- vi.mock('@gitpm/sync-gitlab', () => ({
15
- importFromGitLab: (...args: unknown[]) => mockImportFromGitLab(...args),
16
- }));
17
-
18
- vi.mock('../utils/auth.js', () => ({
19
- resolveToken: (...args: unknown[]) => mockResolveToken(...args),
20
- }));
21
-
22
- vi.mock('ora', () => ({
23
- default: () => ({
24
- start: vi.fn().mockReturnThis(),
25
- succeed: vi.fn().mockReturnThis(),
26
- fail: vi.fn().mockReturnThis(),
27
- stop: vi.fn().mockReturnThis(),
28
- }),
29
- }));
30
-
31
- let logSpy: ReturnType<typeof vi.spyOn>;
32
- let errorSpy: ReturnType<typeof vi.spyOn>;
33
- let exitSpy: ReturnType<typeof vi.spyOn>;
34
-
35
- async function run(...args: string[]) {
36
- const { importCommand } = await import('../commands/import.js');
37
- const program = new Command();
38
- program.option('--meta-dir <path>', 'Path to .meta directory', '.meta');
39
- program.addCommand(importCommand);
40
- await program.parseAsync(['node', 'gitpm', 'import', ...args]);
41
- }
42
-
43
- const importSummary = {
44
- milestones: 2,
45
- epics: 3,
46
- stories: 5,
47
- totalFiles: 10,
48
- };
49
-
50
- // --- Tests ---
51
-
52
- describe('gitpm import', () => {
53
- beforeEach(() => {
54
- vi.resetModules();
55
- vi.resetAllMocks();
56
- logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
57
- errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
58
- exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
59
- throw new Error('process.exit');
60
- }) as never);
61
- mockResolveToken.mockResolvedValue('mock-token');
62
- });
63
-
64
- afterEach(() => {
65
- vi.restoreAllMocks();
66
- });
67
-
68
- // --- GitHub ---
69
-
70
- it('imports from GitHub successfully', async () => {
71
- mockImportFromGitHub.mockResolvedValue({ ok: true, value: importSummary });
72
-
73
- await run('--repo', 'owner/repo', '--meta-dir', '/tmp/meta');
74
-
75
- expect(mockImportFromGitHub).toHaveBeenCalledWith(
76
- expect.objectContaining({
77
- token: 'mock-token',
78
- repo: 'owner/repo',
79
- metaDir: '/tmp/meta',
80
- }),
81
- );
82
- expect(exitSpy).not.toHaveBeenCalled();
83
- const allOutput = logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
84
- expect(allOutput).toContain('Milestones: 2');
85
- expect(allOutput).toContain('Epics: 3');
86
- expect(allOutput).toContain('Stories: 5');
87
- });
88
-
89
- it('exits with code 1 when --repo is missing', async () => {
90
- await expect(run('--meta-dir', '/tmp/meta')).rejects.toThrow(
91
- 'process.exit',
92
- );
93
- expect(exitSpy).toHaveBeenCalledWith(1);
94
- const errOutput = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n');
95
- expect(errOutput).toContain('--repo is required');
96
- });
97
-
98
- it('exits with code 1 when --repo format is invalid', async () => {
99
- await expect(
100
- run('--repo', 'badformat', '--meta-dir', '/tmp/meta'),
101
- ).rejects.toThrow('process.exit');
102
- expect(exitSpy).toHaveBeenCalledWith(1);
103
- });
104
-
105
- it('passes --link-strategy to importFromGitHub', async () => {
106
- mockImportFromGitHub.mockResolvedValue({ ok: true, value: importSummary });
107
-
108
- await run(
109
- '--repo',
110
- 'owner/repo',
111
- '--link-strategy',
112
- 'labels',
113
- '--meta-dir',
114
- '/tmp/meta',
115
- );
116
-
117
- expect(mockImportFromGitHub).toHaveBeenCalledWith(
118
- expect.objectContaining({ linkStrategy: 'labels' }),
119
- );
120
- });
121
-
122
- it('exits with code 1 for invalid link strategy', async () => {
123
- await expect(
124
- run(
125
- '--repo',
126
- 'owner/repo',
127
- '--link-strategy',
128
- 'invalid-strat',
129
- '--meta-dir',
130
- '/tmp/meta',
131
- ),
132
- ).rejects.toThrow('process.exit');
133
- expect(exitSpy).toHaveBeenCalledWith(1);
134
- const errOutput = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n');
135
- expect(errOutput).toContain('Invalid link strategy');
136
- });
137
-
138
- it('exits with code 1 when import fails', async () => {
139
- mockImportFromGitHub.mockResolvedValue({
140
- ok: false,
141
- error: { message: 'API rate limit exceeded' },
142
- });
143
-
144
- await expect(
145
- run('--repo', 'owner/repo', '--meta-dir', '/tmp/meta'),
146
- ).rejects.toThrow('process.exit');
147
- expect(exitSpy).toHaveBeenCalledWith(1);
148
- const errOutput = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n');
149
- expect(errOutput).toContain('API rate limit exceeded');
150
- });
151
-
152
- it('exits with code 1 when token resolution fails', async () => {
153
- mockResolveToken.mockRejectedValue(new Error('No GitHub token found'));
154
-
155
- await expect(
156
- run('--repo', 'owner/repo', '--meta-dir', '/tmp/meta'),
157
- ).rejects.toThrow('process.exit');
158
- expect(exitSpy).toHaveBeenCalledWith(1);
159
- const errOutput = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n');
160
- expect(errOutput).toContain('No GitHub token found');
161
- });
162
-
163
- // --- GitLab ---
164
-
165
- it('imports from GitLab successfully', async () => {
166
- mockImportFromGitLab.mockResolvedValue({ ok: true, value: importSummary });
167
-
168
- await run(
169
- '--source',
170
- 'gitlab',
171
- '--project',
172
- 'group/proj',
173
- '--token',
174
- 'gl-tok',
175
- '--meta-dir',
176
- '/tmp/meta',
177
- );
178
-
179
- expect(mockImportFromGitLab).toHaveBeenCalledWith(
180
- expect.objectContaining({ token: 'gl-tok', project: 'group/proj' }),
181
- );
182
- expect(exitSpy).not.toHaveBeenCalled();
183
- });
184
-
185
- it('exits with code 1 when GitLab project is missing', async () => {
186
- await expect(
187
- run('--source', 'gitlab', '--token', 'gl-tok', '--meta-dir', '/tmp/meta'),
188
- ).rejects.toThrow('process.exit');
189
- expect(exitSpy).toHaveBeenCalledWith(1);
190
- });
191
-
192
- it('exits with code 1 when GitLab token is missing', async () => {
193
- const originalEnv = process.env.GITLAB_TOKEN;
194
- // biome-ignore lint/performance/noDelete: must remove env var, not set to "undefined" string
195
- delete process.env.GITLAB_TOKEN;
196
-
197
- await expect(
198
- run(
199
- '--source',
200
- 'gitlab',
201
- '--project',
202
- 'group/proj',
203
- '--meta-dir',
204
- '/tmp/meta',
205
- ),
206
- ).rejects.toThrow('process.exit');
207
- expect(exitSpy).toHaveBeenCalledWith(1);
208
-
209
- process.env.GITLAB_TOKEN = originalEnv;
210
- });
211
-
212
- it('exits with code 1 for unknown source', async () => {
213
- await expect(
214
- run('--source', 'bitbucket', '--meta-dir', '/tmp/meta'),
215
- ).rejects.toThrow('process.exit');
216
- expect(exitSpy).toHaveBeenCalledWith(1);
217
- const errOutput = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n');
218
- expect(errOutput).toContain('Unknown source');
219
- });
220
- });
@@ -1,112 +0,0 @@
1
- import { mkdtemp, rm } from 'node:fs/promises';
2
- import { tmpdir } from 'node:os';
3
- import { join } from 'node:path';
4
- import { Command } from 'commander';
5
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
-
7
- // --- Mocks ---
8
-
9
- const mockScaffoldMeta = vi.fn();
10
- const mockInput = vi.fn();
11
-
12
- vi.mock('@gitpm/core', () => ({
13
- scaffoldMeta: (...args: unknown[]) => mockScaffoldMeta(...args),
14
- }));
15
-
16
- vi.mock('@inquirer/prompts', () => ({
17
- input: (...args: unknown[]) => mockInput(...args),
18
- }));
19
-
20
- let logSpy: ReturnType<typeof vi.spyOn>;
21
- let errorSpy: ReturnType<typeof vi.spyOn>;
22
- let exitSpy: ReturnType<typeof vi.spyOn>;
23
-
24
- async function run(...args: string[]) {
25
- const { initCommand } = await import('../commands/init.js');
26
- const program = new Command();
27
- program.option('--meta-dir <path>', 'Path to .meta directory', '.meta');
28
- program.addCommand(initCommand);
29
- await program.parseAsync(['node', 'gitpm', 'init', ...args]);
30
- }
31
-
32
- // --- Tests ---
33
-
34
- describe('gitpm init', () => {
35
- let tmpDir: string;
36
-
37
- beforeEach(async () => {
38
- vi.resetModules();
39
- vi.resetAllMocks();
40
- tmpDir = await mkdtemp(join(tmpdir(), 'gitpm-cli-init-'));
41
- logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
42
- errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
43
- exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
44
- throw new Error('process.exit');
45
- }) as never);
46
- });
47
-
48
- afterEach(async () => {
49
- vi.restoreAllMocks();
50
- await rm(tmpDir, { recursive: true, force: true });
51
- });
52
-
53
- it('creates .meta structure with project name argument', async () => {
54
- const metaDir = join(tmpDir, '.meta');
55
- mockScaffoldMeta.mockResolvedValue({ ok: true, value: undefined });
56
-
57
- // scaffoldMeta is mocked, so collectFiles will read from an empty dir.
58
- // We need to create at least one file so the readdir doesn't fail.
59
- const { mkdir, writeFile } = await import('node:fs/promises');
60
- await mkdir(metaDir, { recursive: true });
61
- await writeFile(join(metaDir, 'roadmap.yaml'), '');
62
-
63
- await run('my-project', '--meta-dir', metaDir);
64
-
65
- expect(mockScaffoldMeta).toHaveBeenCalledWith(metaDir, 'my-project');
66
- expect(mockInput).not.toHaveBeenCalled();
67
- expect(exitSpy).not.toHaveBeenCalled();
68
- });
69
-
70
- it('prompts for project name when not provided', async () => {
71
- const metaDir = join(tmpDir, '.meta');
72
- mockInput.mockResolvedValue('prompted-name');
73
- mockScaffoldMeta.mockResolvedValue({ ok: true, value: undefined });
74
-
75
- const { mkdir, writeFile } = await import('node:fs/promises');
76
- await mkdir(metaDir, { recursive: true });
77
- await writeFile(join(metaDir, 'roadmap.yaml'), '');
78
-
79
- await run('--meta-dir', metaDir);
80
-
81
- expect(mockInput).toHaveBeenCalled();
82
- expect(mockScaffoldMeta).toHaveBeenCalledWith(metaDir, 'prompted-name');
83
- });
84
-
85
- it('exits with code 1 when scaffoldMeta fails', async () => {
86
- const metaDir = join(tmpDir, '.meta');
87
- mockScaffoldMeta.mockResolvedValue({
88
- ok: false,
89
- error: { message: 'directory already exists' },
90
- });
91
-
92
- await expect(run('my-project', '--meta-dir', metaDir)).rejects.toThrow(
93
- 'process.exit',
94
- );
95
- expect(exitSpy).toHaveBeenCalledWith(1);
96
- const errOutput = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n');
97
- expect(errOutput).toContain('directory already exists');
98
- });
99
-
100
- it('respects --meta-dir option', async () => {
101
- const customDir = join(tmpDir, 'custom-meta');
102
- mockScaffoldMeta.mockResolvedValue({ ok: true, value: undefined });
103
-
104
- const { mkdir, writeFile } = await import('node:fs/promises');
105
- await mkdir(customDir, { recursive: true });
106
- await writeFile(join(customDir, 'roadmap.yaml'), '');
107
-
108
- await run('test-proj', '--meta-dir', customDir);
109
-
110
- expect(mockScaffoldMeta).toHaveBeenCalledWith(customDir, 'test-proj');
111
- });
112
- });
@@ -1,225 +0,0 @@
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 mockPromptConflictResolution = vi.fn();
12
-
13
- vi.mock('@gitpm/sync-github', () => ({
14
- loadConfig: (...args: unknown[]) => mockLoadGitHubConfig(...args),
15
- syncWithGitHub: (...args: unknown[]) => mockSyncWithGitHub(...args),
16
- }));
17
-
18
- vi.mock('@gitpm/sync-gitlab', () => ({
19
- loadConfig: (...args: unknown[]) => mockLoadGitLabConfig(...args),
20
- syncWithGitLab: (...args: unknown[]) => mockSyncWithGitLab(...args),
21
- }));
22
-
23
- vi.mock('../utils/auth.js', () => ({
24
- resolveToken: (...args: unknown[]) => mockResolveToken(...args),
25
- }));
26
-
27
- vi.mock('../utils/conflict-ui.js', () => ({
28
- promptConflictResolution: (...args: unknown[]) =>
29
- mockPromptConflictResolution(...args),
30
- }));
31
-
32
- vi.mock('ora', () => ({
33
- default: () => ({
34
- start: vi.fn().mockReturnThis(),
35
- succeed: vi.fn().mockReturnThis(),
36
- fail: vi.fn().mockReturnThis(),
37
- stop: vi.fn().mockReturnThis(),
38
- }),
39
- }));
40
-
41
- let logSpy: ReturnType<typeof vi.spyOn>;
42
- let errorSpy: ReturnType<typeof vi.spyOn>;
43
- let exitSpy: ReturnType<typeof vi.spyOn>;
44
-
45
- async function run(...args: string[]) {
46
- const { pullCommand } = await import('../commands/pull.js');
47
- const program = new Command();
48
- program.option('--meta-dir <path>', 'Path to .meta directory', '.meta');
49
- program.addCommand(pullCommand);
50
- await program.parseAsync(['node', 'gitpm', 'pull', ...args]);
51
- }
52
-
53
- const pullResult = {
54
- pulled: { milestones: 1, issues: 3 },
55
- conflicts: [],
56
- resolved: 0,
57
- skipped: 0,
58
- };
59
-
60
- // --- Tests ---
61
-
62
- describe('gitpm pull', () => {
63
- beforeEach(() => {
64
- vi.resetModules();
65
- vi.resetAllMocks();
66
- logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
67
- errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
68
- exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
69
- throw new Error('process.exit');
70
- }) as never);
71
- mockResolveToken.mockResolvedValue('mock-token');
72
- mockLoadGitHubConfig.mockResolvedValue({
73
- ok: true,
74
- value: { repo: 'owner/repo' },
75
- });
76
- mockLoadGitLabConfig.mockResolvedValue({
77
- ok: false,
78
- error: { message: 'not found' },
79
- });
80
- });
81
-
82
- afterEach(() => {
83
- vi.restoreAllMocks();
84
- });
85
-
86
- it('pulls from GitHub successfully with remote-wins', async () => {
87
- mockSyncWithGitHub.mockResolvedValue({ ok: true, value: pullResult });
88
-
89
- await run('--meta-dir', '/tmp/meta');
90
-
91
- expect(mockSyncWithGitHub).toHaveBeenCalledWith(
92
- expect.objectContaining({ strategy: 'remote-wins' }),
93
- );
94
- expect(exitSpy).not.toHaveBeenCalled();
95
- const allOutput = logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
96
- expect(allOutput).toContain('Pull Summary');
97
- expect(allOutput).toContain('4 changes');
98
- });
99
-
100
- it('prompts for conflict resolution with ask strategy', async () => {
101
- const conflicts = [
102
- {
103
- entityId: 'e1',
104
- entityTitle: 'Story 1',
105
- entityType: 'story',
106
- field: 'title',
107
- localValue: 'Local Title',
108
- remoteValue: 'Remote Title',
109
- },
110
- ];
111
- mockSyncWithGitHub.mockResolvedValue({
112
- ok: true,
113
- value: { ...pullResult, conflicts, resolved: 0 },
114
- });
115
- mockPromptConflictResolution.mockResolvedValue([
116
- { entityId: 'e1', field: 'title', pick: 'local' },
117
- ]);
118
-
119
- await run('--strategy', 'ask', '--meta-dir', '/tmp/meta');
120
-
121
- expect(mockPromptConflictResolution).toHaveBeenCalledWith(conflicts);
122
- expect(exitSpy).not.toHaveBeenCalled();
123
- });
124
-
125
- it('does not prompt for conflicts with local-wins', async () => {
126
- mockSyncWithGitHub.mockResolvedValue({ ok: true, value: pullResult });
127
-
128
- await run('--strategy', 'local-wins', '--meta-dir', '/tmp/meta');
129
-
130
- expect(mockPromptConflictResolution).not.toHaveBeenCalled();
131
- expect(mockSyncWithGitHub).toHaveBeenCalledWith(
132
- expect.objectContaining({ strategy: 'local-wins' }),
133
- );
134
- });
135
-
136
- it('exits with code 1 when pull fails', async () => {
137
- mockSyncWithGitHub.mockResolvedValue({
138
- ok: false,
139
- error: { message: 'sync error' },
140
- });
141
-
142
- await expect(run('--meta-dir', '/tmp/meta')).rejects.toThrow(
143
- 'process.exit',
144
- );
145
- expect(exitSpy).toHaveBeenCalledWith(1);
146
- const errOutput = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n');
147
- expect(errOutput).toContain('sync error');
148
- });
149
-
150
- it('exits with code 1 when no sync config found', async () => {
151
- mockLoadGitHubConfig.mockResolvedValue({
152
- ok: false,
153
- error: { message: 'not found' },
154
- });
155
- mockLoadGitLabConfig.mockResolvedValue({
156
- ok: false,
157
- error: { message: 'not found' },
158
- });
159
-
160
- await expect(run('--meta-dir', '/tmp/meta')).rejects.toThrow(
161
- 'process.exit',
162
- );
163
- expect(exitSpy).toHaveBeenCalledWith(1);
164
- const errOutput = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n');
165
- expect(errOutput).toContain('No sync config found');
166
- });
167
-
168
- it('exits with code 1 when token resolution fails', async () => {
169
- mockResolveToken.mockRejectedValue(new Error('No GitHub token found'));
170
-
171
- await expect(run('--meta-dir', '/tmp/meta')).rejects.toThrow(
172
- 'process.exit',
173
- );
174
- expect(exitSpy).toHaveBeenCalledWith(1);
175
- });
176
-
177
- it('pulls from GitLab when GitLab config is found', async () => {
178
- mockLoadGitHubConfig.mockResolvedValue({
179
- ok: false,
180
- error: { message: 'not found' },
181
- });
182
- mockLoadGitLabConfig.mockResolvedValue({
183
- ok: true,
184
- value: {
185
- project: 'group/proj',
186
- project_id: 42,
187
- base_url: 'https://gitlab.com',
188
- },
189
- });
190
- mockSyncWithGitLab.mockResolvedValue({ ok: true, value: pullResult });
191
-
192
- await run('--token', 'gl-tok', '--meta-dir', '/tmp/meta');
193
-
194
- expect(mockSyncWithGitLab).toHaveBeenCalledWith(
195
- expect.objectContaining({ token: 'gl-tok', project: 'group/proj' }),
196
- );
197
- expect(exitSpy).not.toHaveBeenCalled();
198
- });
199
-
200
- it('exits with code 1 when GitLab token is missing', async () => {
201
- const originalEnv = process.env.GITLAB_TOKEN;
202
- // biome-ignore lint/performance/noDelete: must remove env var, not set to "undefined" string
203
- delete process.env.GITLAB_TOKEN;
204
-
205
- mockLoadGitHubConfig.mockResolvedValue({
206
- ok: false,
207
- error: { message: 'not found' },
208
- });
209
- mockLoadGitLabConfig.mockResolvedValue({
210
- ok: true,
211
- value: {
212
- project: 'group/proj',
213
- project_id: 42,
214
- base_url: 'https://gitlab.com',
215
- },
216
- });
217
-
218
- await expect(run('--meta-dir', '/tmp/meta')).rejects.toThrow(
219
- 'process.exit',
220
- );
221
- expect(exitSpy).toHaveBeenCalledWith(1);
222
-
223
- process.env.GITLAB_TOKEN = originalEnv;
224
- });
225
- });