@useconductor/conductor 1.0.0 → 1.0.1

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.
Files changed (145) hide show
  1. package/.github/README.md +374 -7
  2. package/.github/workflows/ci.yml +3 -1
  3. package/.github/workflows/claude-code-review.yml +1 -15
  4. package/.github/workflows/publish.yml +43 -0
  5. package/README.md +290 -121
  6. package/dist/cli/commands/audit.d.ts +40 -0
  7. package/dist/cli/commands/audit.d.ts.map +1 -0
  8. package/dist/cli/commands/audit.js +272 -0
  9. package/dist/cli/commands/audit.js.map +1 -0
  10. package/dist/cli/commands/circuit.d.ts +13 -0
  11. package/dist/cli/commands/circuit.d.ts.map +1 -0
  12. package/dist/cli/commands/circuit.js +53 -0
  13. package/dist/cli/commands/circuit.js.map +1 -0
  14. package/dist/cli/commands/config.d.ts +31 -0
  15. package/dist/cli/commands/config.d.ts.map +1 -0
  16. package/dist/cli/commands/config.js +152 -0
  17. package/dist/cli/commands/config.js.map +1 -0
  18. package/dist/cli/commands/init.d.ts +5 -8
  19. package/dist/cli/commands/init.d.ts.map +1 -1
  20. package/dist/cli/commands/init.js +86 -123
  21. package/dist/cli/commands/init.js.map +1 -1
  22. package/dist/cli/commands/marketplace.js +1 -1
  23. package/dist/cli/commands/onboard.d.ts.map +1 -1
  24. package/dist/cli/commands/onboard.js +33 -11
  25. package/dist/cli/commands/onboard.js.map +1 -1
  26. package/dist/cli/commands/release.d.ts.map +1 -1
  27. package/dist/cli/commands/release.js +1 -1
  28. package/dist/cli/commands/release.js.map +1 -1
  29. package/dist/cli/index.js +146 -10
  30. package/dist/cli/index.js.map +1 -1
  31. package/dist/core/audit.d.ts.map +1 -1
  32. package/dist/core/audit.js +5 -2
  33. package/dist/core/audit.js.map +1 -1
  34. package/dist/core/conductor.d.ts.map +1 -1
  35. package/dist/core/conductor.js +12 -0
  36. package/dist/core/conductor.js.map +1 -1
  37. package/dist/core/config.d.ts +3 -0
  38. package/dist/core/config.d.ts.map +1 -1
  39. package/dist/core/config.js +46 -2
  40. package/dist/core/config.js.map +1 -1
  41. package/dist/core/database.d.ts +3 -0
  42. package/dist/core/database.d.ts.map +1 -1
  43. package/dist/core/database.js +26 -0
  44. package/dist/core/database.js.map +1 -1
  45. package/dist/core/encryption.d.ts +34 -0
  46. package/dist/core/encryption.d.ts.map +1 -0
  47. package/dist/core/encryption.js +96 -0
  48. package/dist/core/encryption.js.map +1 -0
  49. package/dist/core/zero-config.d.ts.map +1 -1
  50. package/dist/core/zero-config.js +1 -4
  51. package/dist/core/zero-config.js.map +1 -1
  52. package/dist/dashboard/server.d.ts.map +1 -1
  53. package/dist/dashboard/server.js +112 -16
  54. package/dist/dashboard/server.js.map +1 -1
  55. package/dist/mcp/server.d.ts.map +1 -1
  56. package/dist/mcp/server.js +30 -2
  57. package/dist/mcp/server.js.map +1 -1
  58. package/dist/plugins/builtin/aws.d.ts +31 -0
  59. package/dist/plugins/builtin/aws.d.ts.map +1 -0
  60. package/dist/plugins/builtin/aws.js +149 -0
  61. package/dist/plugins/builtin/aws.js.map +1 -0
  62. package/dist/plugins/builtin/database.d.ts +1 -0
  63. package/dist/plugins/builtin/database.d.ts.map +1 -1
  64. package/dist/plugins/builtin/database.js +26 -1
  65. package/dist/plugins/builtin/database.js.map +1 -1
  66. package/dist/plugins/builtin/docker.d.ts +4 -0
  67. package/dist/plugins/builtin/docker.d.ts.map +1 -1
  68. package/dist/plugins/builtin/docker.js +20 -1
  69. package/dist/plugins/builtin/docker.js.map +1 -1
  70. package/dist/plugins/builtin/gcp.d.ts +28 -0
  71. package/dist/plugins/builtin/gcp.d.ts.map +1 -0
  72. package/dist/plugins/builtin/gcp.js +135 -0
  73. package/dist/plugins/builtin/gcp.js.map +1 -0
  74. package/dist/plugins/builtin/index.d.ts.map +1 -1
  75. package/dist/plugins/builtin/index.js +4 -0
  76. package/dist/plugins/builtin/index.js.map +1 -1
  77. package/dist/plugins/builtin/jira.d.ts.map +1 -1
  78. package/dist/plugins/builtin/jira.js +4 -2
  79. package/dist/plugins/builtin/jira.js.map +1 -1
  80. package/dist/plugins/builtin/linear.js +1 -1
  81. package/dist/plugins/builtin/linear.js.map +1 -1
  82. package/dist/plugins/builtin/shell.js +1 -1
  83. package/dist/plugins/builtin/shell.js.map +1 -1
  84. package/dist/plugins/builtin/slack.d.ts +1 -0
  85. package/dist/plugins/builtin/slack.d.ts.map +1 -1
  86. package/dist/plugins/builtin/slack.js +9 -1
  87. package/dist/plugins/builtin/slack.js.map +1 -1
  88. package/dist/plugins/builtin/spotify.js +1 -1
  89. package/dist/plugins/builtin/spotify.js.map +1 -1
  90. package/dist/plugins/builtin/vercel.d.ts.map +1 -1
  91. package/dist/plugins/builtin/vercel.js +3 -1
  92. package/dist/plugins/builtin/vercel.js.map +1 -1
  93. package/dist/security/sso.d.ts +37 -0
  94. package/dist/security/sso.d.ts.map +1 -0
  95. package/dist/security/sso.js +92 -0
  96. package/dist/security/sso.js.map +1 -0
  97. package/docs/deployment.md +201 -0
  98. package/docs/plugin-sdk.md +212 -0
  99. package/package.json +11 -8
  100. package/src/cli/commands/audit.ts +318 -0
  101. package/src/cli/commands/circuit.ts +63 -0
  102. package/src/cli/commands/config.ts +176 -0
  103. package/src/cli/commands/init.ts +87 -145
  104. package/src/cli/commands/marketplace.ts +1 -1
  105. package/src/cli/commands/onboard.ts +33 -11
  106. package/src/cli/commands/release.ts +13 -6
  107. package/src/cli/index.ts +165 -11
  108. package/src/core/audit.ts +5 -2
  109. package/src/core/conductor.ts +11 -0
  110. package/src/core/config.ts +47 -2
  111. package/src/core/database.ts +32 -0
  112. package/src/core/encryption.ts +110 -0
  113. package/src/core/zero-config.ts +1 -5
  114. package/src/dashboard/server.ts +135 -16
  115. package/src/mcp/server.ts +40 -2
  116. package/src/plugins/builtin/aws.ts +162 -0
  117. package/src/plugins/builtin/database.ts +19 -1
  118. package/src/plugins/builtin/docker.ts +17 -1
  119. package/src/plugins/builtin/gcp.ts +145 -0
  120. package/src/plugins/builtin/index.ts +4 -0
  121. package/src/plugins/builtin/jira.ts +23 -19
  122. package/src/plugins/builtin/linear.ts +1 -1
  123. package/src/plugins/builtin/shell.ts +1 -1
  124. package/src/plugins/builtin/slack.ts +6 -1
  125. package/src/plugins/builtin/spotify.ts +1 -1
  126. package/src/plugins/builtin/vercel.ts +3 -1
  127. package/src/security/sso.ts +124 -0
  128. package/tests/audit.test.ts +185 -0
  129. package/tests/circuit-breaker.test.ts +125 -0
  130. package/tests/docker.test.ts +244 -39
  131. package/tests/errors.test.ts +122 -0
  132. package/tests/github.test.ts.skip +392 -0
  133. package/tests/jira.test.ts +310 -0
  134. package/tests/linear.test.ts +366 -0
  135. package/tests/mcp.test.ts.skip +243 -0
  136. package/tests/notion.test.ts +257 -0
  137. package/tests/retry.test.ts +104 -0
  138. package/tests/shell.test.ts +262 -30
  139. package/tests/slack.test.ts +250 -0
  140. package/tests/stripe.test.ts +272 -0
  141. package/tests/validation.test.ts +173 -0
  142. package/tests/vercel.test.ts +368 -0
  143. package/tests/zero-config.test.ts +566 -0
  144. package/C.png +0 -0
  145. package/tests/mcp.test.ts +0 -14
@@ -0,0 +1,392 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { GitHubPlugin } from '../src/plugins/builtin/github.js';
3
+ import { Keychain } from '../src/security/keychain.js';
4
+
5
+ // Helper: get a tool's handler by name
6
+ function tool(plugin: GitHubPlugin, name: string) {
7
+ const t = plugin.getTools().find((t) => t.name === name);
8
+ if (!t) throw new Error(`Tool not found: ${name}`);
9
+ return t.handler as (args: Record<string, unknown>) => Promise<unknown>;
10
+ }
11
+
12
+ // Minimal conductor mock
13
+ function makeConductor(configDir = '/tmp/conductor-test-github') {
14
+ return {
15
+ getConfig: () => ({
16
+ getConfigDir: () => configDir,
17
+ }),
18
+ } as any;
19
+ }
20
+
21
+ let plugin: GitHubPlugin;
22
+
23
+ beforeEach(async () => {
24
+ plugin = new GitHubPlugin();
25
+ await plugin.initialize(makeConductor());
26
+ vi.clearAllMocks();
27
+ });
28
+
29
+ afterEach(() => {
30
+ vi.restoreAllMocks();
31
+ });
32
+
33
+ // ── Structure ────────────────────────────────────────────────────────────────
34
+
35
+ describe('GitHubPlugin structure', () => {
36
+ it('has correct name and version', () => {
37
+ expect(plugin.name).toBe('github');
38
+ expect(plugin.version).toBeTruthy();
39
+ });
40
+
41
+ it('isConfigured() always returns true (public APIs work without token)', () => {
42
+ expect(plugin.isConfigured()).toBe(true);
43
+ });
44
+
45
+ it('registers expected tools', () => {
46
+ const names = plugin.getTools().map((t) => t.name);
47
+ expect(names).toContain('github_user');
48
+ expect(names).toContain('github_repo');
49
+ expect(names).toContain('github_repos');
50
+ expect(names).toContain('github_trending');
51
+ expect(names).toContain('github_issues');
52
+ expect(names).toContain('github_issue');
53
+ expect(names).toContain('github_create_issue');
54
+ expect(names).toContain('github_prs');
55
+ expect(names).toContain('github_pr');
56
+ expect(names).toContain('github_create_pr');
57
+ expect(names).toContain('github_commits');
58
+ expect(names).toContain('github_releases');
59
+ expect(names).toContain('github_search_code');
60
+ expect(names).toContain('github_file');
61
+ });
62
+
63
+ it('marks write operations as requiresApproval', () => {
64
+ const approvalTools = ['github_create_issue', 'github_close_issue', 'github_comment_issue',
65
+ 'github_create_pr', 'github_merge_pr', 'github_create_release', 'github_star', 'github_fork'];
66
+ for (const name of approvalTools) {
67
+ const t = plugin.getTools().find((t) => t.name === name);
68
+ if (t) expect(t.requiresApproval).toBe(true);
69
+ }
70
+ });
71
+ });
72
+
73
+ // ── Public tools (no token needed) ───────────────────────────────────────────
74
+
75
+ describe('GitHub tools — public (no token)', () => {
76
+ beforeEach(() => {
77
+ vi.spyOn(Keychain.prototype, 'get').mockResolvedValue(null);
78
+ });
79
+
80
+ it('github_user returns user profile data', async () => {
81
+ const mockUser = {
82
+ login: 'octocat',
83
+ name: 'The Octocat',
84
+ bio: 'A cat from GitHub',
85
+ public_repos: 42,
86
+ followers: 10000,
87
+ following: 5,
88
+ created_at: '2011-01-25T18:44:36Z',
89
+ html_url: 'https://github.com/octocat',
90
+ avatar_url: 'https://github.com/images/error/octocat_happy.gif',
91
+ };
92
+
93
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
94
+ ok: true,
95
+ status: 200,
96
+ json: () => Promise.resolve(mockUser),
97
+ }));
98
+
99
+ const result = await tool(plugin, 'github_user')({ username: 'octocat' }) as any;
100
+ expect(result.login).toBe('octocat');
101
+ expect(result.name).toBe('The Octocat');
102
+ expect(result.public_repos).toBe(42);
103
+ expect(result.followers).toBe(10000);
104
+ expect(result.url).toBe('https://github.com/octocat');
105
+ });
106
+
107
+ it('github_repo returns repository details', async () => {
108
+ const mockRepo = {
109
+ full_name: 'octocat/hello-world',
110
+ description: 'A Hello World repo',
111
+ language: 'JavaScript',
112
+ stargazers_count: 500,
113
+ forks_count: 100,
114
+ open_issues_count: 10,
115
+ license: { spdx_id: 'MIT' },
116
+ created_at: '2020-01-01T00:00:00Z',
117
+ updated_at: '2024-01-01T00:00:00Z',
118
+ default_branch: 'main',
119
+ html_url: 'https://github.com/octocat/hello-world',
120
+ topics: ['javascript', 'hello-world'],
121
+ private: false,
122
+ };
123
+
124
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
125
+ ok: true,
126
+ status: 200,
127
+ json: () => Promise.resolve(mockRepo),
128
+ }));
129
+
130
+ const result = await tool(plugin, 'github_repo')({ owner: 'octocat', repo: 'hello-world' }) as any;
131
+ expect(result.name).toBe('octocat/hello-world');
132
+ expect(result.language).toBe('JavaScript');
133
+ expect(result.stars).toBe(500);
134
+ expect(result.license).toBe('MIT');
135
+ expect(result.default_branch).toBe('main');
136
+ expect(result.private).toBe(false);
137
+ });
138
+
139
+ it('github_trending returns repositories matching query', async () => {
140
+ const mockData = {
141
+ items: [
142
+ {
143
+ full_name: 'trending/repo1',
144
+ description: 'Trending repo 1',
145
+ language: 'TypeScript',
146
+ stargazers_count: 1000,
147
+ forks_count: 200,
148
+ html_url: 'https://github.com/trending/repo1',
149
+ },
150
+ {
151
+ full_name: 'trending/repo2',
152
+ description: 'Trending repo 2',
153
+ language: 'Python',
154
+ stargazers_count: 800,
155
+ forks_count: 150,
156
+ html_url: 'https://github.com/trending/repo2',
157
+ },
158
+ ],
159
+ };
160
+
161
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
162
+ ok: true,
163
+ status: 200,
164
+ json: () => Promise.resolve(mockData),
165
+ }));
166
+
167
+ const result = await tool(plugin, 'github_trending')({ query: 'typescript cli' }) as any;
168
+ expect(Array.isArray(result)).toBe(true);
169
+ expect(result).toHaveLength(2);
170
+ expect(result[0].name).toBe('trending/repo1');
171
+ expect(result[0].stars).toBe(1000);
172
+ });
173
+
174
+ it('github_issues returns issue list', async () => {
175
+ const mockIssues = [
176
+ {
177
+ number: 1,
178
+ title: 'Test issue',
179
+ state: 'open',
180
+ user: { login: 'alice' },
181
+ assignees: [{ login: 'bob' }],
182
+ labels: [{ name: 'bug' }],
183
+ comments: 3,
184
+ created_at: '2024-01-01T00:00:00Z',
185
+ updated_at: '2024-06-01T00:00:00Z',
186
+ html_url: 'https://github.com/owner/repo/issues/1',
187
+ // no pull_request field = it's an issue
188
+ },
189
+ ];
190
+
191
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
192
+ ok: true,
193
+ status: 200,
194
+ json: () => Promise.resolve(mockIssues),
195
+ }));
196
+
197
+ const result = await tool(plugin, 'github_issues')({ owner: 'owner', repo: 'repo' }) as any;
198
+ expect(result).toHaveLength(1);
199
+ expect(result[0].number).toBe(1);
200
+ expect(result[0].title).toBe('Test issue');
201
+ expect(result[0].labels).toEqual(['bug']);
202
+ });
203
+
204
+ it('github_commits returns commit list', async () => {
205
+ const mockCommits = [
206
+ {
207
+ sha: 'abc123def456',
208
+ commit: {
209
+ message: 'fix: resolve bug\n\nMore details here',
210
+ author: { name: 'Alice', date: '2024-06-01T00:00:00Z' },
211
+ },
212
+ html_url: 'https://github.com/owner/repo/commit/abc123',
213
+ },
214
+ ];
215
+
216
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
217
+ ok: true,
218
+ status: 200,
219
+ json: () => Promise.resolve(mockCommits),
220
+ }));
221
+
222
+ const result = await tool(plugin, 'github_commits')({ owner: 'owner', repo: 'repo' }) as any;
223
+ expect(result).toHaveLength(1);
224
+ expect(result[0].sha).toBe('abc123d');
225
+ expect(result[0].message).toBe('fix: resolve bug');
226
+ expect(result[0].author).toBe('Alice');
227
+ });
228
+
229
+ it('github_releases returns release list', async () => {
230
+ const mockReleases = [
231
+ {
232
+ id: 1001,
233
+ tag_name: 'v1.0.0',
234
+ name: 'Release 1.0.0',
235
+ prerelease: false,
236
+ draft: false,
237
+ body: 'Initial release',
238
+ author: { login: 'alice' },
239
+ created_at: '2024-01-01T00:00:00Z',
240
+ published_at: '2024-01-02T00:00:00Z',
241
+ html_url: 'https://github.com/owner/repo/releases/tag/v1.0.0',
242
+ assets: [],
243
+ },
244
+ ];
245
+
246
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
247
+ ok: true,
248
+ status: 200,
249
+ json: () => Promise.resolve(mockReleases),
250
+ }));
251
+
252
+ const result = await tool(plugin, 'github_releases')({ owner: 'owner', repo: 'repo' }) as any;
253
+ expect(result).toHaveLength(1);
254
+ expect(result[0].tag).toBe('v1.0.0');
255
+ expect(result[0].name).toBe('Release 1.0.0');
256
+ expect(result[0].prerelease).toBe(false);
257
+ });
258
+ });
259
+
260
+ // ── Private tools (token required) ───────────────────────────────────────────
261
+
262
+ describe('GitHub tools — private (token required)', () => {
263
+ beforeEach(async () => {
264
+ vi.spyOn(Keychain.prototype, 'get').mockResolvedValue('ghp_fake_token_abcdefghij');
265
+ plugin = new GitHubPlugin();
266
+ await plugin.initialize(makeConductor());
267
+ });
268
+
269
+ it('github_create_issue creates an issue with token', async () => {
270
+ const mockIssue = {
271
+ number: 42,
272
+ html_url: 'https://github.com/owner/repo/issues/42',
273
+ title: 'New bug report',
274
+ };
275
+
276
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
277
+ ok: true,
278
+ status: 201,
279
+ json: () => Promise.resolve(mockIssue),
280
+ }));
281
+
282
+ const result = await tool(plugin, 'github_create_issue')({
283
+ owner: 'owner',
284
+ repo: 'repo',
285
+ title: 'New bug report',
286
+ body: 'Something is broken',
287
+ labels: ['bug'],
288
+ }) as any;
289
+
290
+ expect(result.number).toBe(42);
291
+ expect(result.url).toBe('https://github.com/owner/repo/issues/42');
292
+ expect(result.title).toBe('New bug report');
293
+ });
294
+
295
+ it('github_create_issue sends Authorization header', async () => {
296
+ const mockIssue = { number: 1, html_url: 'https://github.com/o/r/issues/1', title: 'test' };
297
+ const fetchMock = vi.fn().mockResolvedValue({
298
+ ok: true,
299
+ status: 201,
300
+ json: () => Promise.resolve(mockIssue),
301
+ });
302
+ vi.stubGlobal('fetch', fetchMock);
303
+
304
+ await tool(plugin, 'github_create_issue')({
305
+ owner: 'owner',
306
+ repo: 'repo',
307
+ title: 'test',
308
+ });
309
+
310
+ const callInit = fetchMock.mock.calls[0][1] as RequestInit;
311
+ const headers = callInit.headers as Record<string, string>;
312
+ expect(headers['Authorization']).toBe('Bearer ghp_fake_token_abcdefghij');
313
+ });
314
+
315
+ it('github_create_issue throws without token', async () => {
316
+ vi.spyOn(Keychain.prototype, 'get').mockResolvedValue(null);
317
+ plugin = new GitHubPlugin();
318
+ await plugin.initialize(makeConductor());
319
+
320
+ await expect(
321
+ tool(plugin, 'github_create_issue')({ owner: 'owner', repo: 'repo', title: 'Test' }),
322
+ ).rejects.toThrow(/token/i);
323
+ });
324
+
325
+ it('github_my_repos throws without token', async () => {
326
+ vi.spyOn(Keychain.prototype, 'get').mockResolvedValue(null);
327
+ plugin = new GitHubPlugin();
328
+ await plugin.initialize(makeConductor());
329
+
330
+ await expect(tool(plugin, 'github_my_repos')({})).rejects.toThrow(/token/i);
331
+ });
332
+
333
+ it('github_notifications throws without token', async () => {
334
+ vi.spyOn(Keychain.prototype, 'get').mockResolvedValue(null);
335
+ plugin = new GitHubPlugin();
336
+ await plugin.initialize(makeConductor());
337
+
338
+ await expect(tool(plugin, 'github_notifications')({})).rejects.toThrow(/token/i);
339
+ });
340
+
341
+ it('github_repos returns user repos with token', async () => {
342
+ const mockRepos = [
343
+ {
344
+ name: 'my-repo',
345
+ description: 'A repo',
346
+ language: 'TypeScript',
347
+ stargazers_count: 10,
348
+ forks_count: 2,
349
+ updated_at: '2024-06-01T00:00:00Z',
350
+ html_url: 'https://github.com/alice/my-repo',
351
+ private: false,
352
+ },
353
+ ];
354
+
355
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
356
+ ok: true,
357
+ status: 200,
358
+ json: () => Promise.resolve(mockRepos),
359
+ }));
360
+
361
+ const result = await tool(plugin, 'github_repos')({ username: 'alice' }) as any;
362
+ expect(result).toHaveLength(1);
363
+ expect(result[0].name).toBe('my-repo');
364
+ expect(result[0].stars).toBe(10);
365
+ });
366
+ });
367
+
368
+ // ── Error handling ────────────────────────────────────────────────────────────
369
+
370
+ describe('GitHub tools — error handling', () => {
371
+ it('github_user throws on 404', async () => {
372
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
373
+ ok: false,
374
+ status: 404,
375
+ text: () => Promise.resolve('Not Found'),
376
+ }));
377
+
378
+ await expect(tool(plugin, 'github_user')({ username: 'nonexistent-xyz' })).rejects.toThrow(/404/);
379
+ });
380
+
381
+ it('github_repo throws on API error', async () => {
382
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
383
+ ok: false,
384
+ status: 403,
385
+ text: () => Promise.resolve('API rate limit exceeded'),
386
+ }));
387
+
388
+ await expect(
389
+ tool(plugin, 'github_repo')({ owner: 'owner', repo: 'repo' }),
390
+ ).rejects.toThrow(/403/);
391
+ });
392
+ });
@@ -0,0 +1,310 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { JiraPlugin } from '../src/plugins/builtin/jira.js';
3
+ import { Keychain } from '../src/security/keychain.js';
4
+
5
+ // Helper: get a tool's handler by name
6
+ function tool(plugin: JiraPlugin, name: string) {
7
+ const t = plugin.getTools().find((t) => t.name === name);
8
+ if (!t) throw new Error(`Tool not found: ${name}`);
9
+ return t.handler as (args: Record<string, unknown>) => Promise<unknown>;
10
+ }
11
+
12
+ // Minimal conductor mock — with config support for domain/email
13
+ function makeConductor(opts: {
14
+ configDir?: string;
15
+ domain?: string;
16
+ email?: string;
17
+ } = {}) {
18
+ const { configDir = '/tmp/conductor-test-jira', domain, email } = opts;
19
+ return {
20
+ getConfig: () => ({
21
+ getConfigDir: () => configDir,
22
+ get: (key: string) => {
23
+ if (key === 'plugins.jira.domain') return domain ?? null;
24
+ if (key === 'plugins.jira.email') return email ?? null;
25
+ return null;
26
+ },
27
+ }),
28
+ } as any;
29
+ }
30
+
31
+ let plugin: JiraPlugin;
32
+
33
+ beforeEach(async () => {
34
+ plugin = new JiraPlugin();
35
+ await plugin.initialize(makeConductor());
36
+ vi.clearAllMocks();
37
+ });
38
+
39
+ afterEach(() => {
40
+ vi.restoreAllMocks();
41
+ });
42
+
43
+ // ── Structure ────────────────────────────────────────────────────────────────
44
+
45
+ describe('JiraPlugin structure', () => {
46
+ it('has correct name and version', () => {
47
+ expect(plugin.name).toBe('jira');
48
+ expect(plugin.version).toBeTruthy();
49
+ });
50
+
51
+ it('registers expected tools', () => {
52
+ const names = plugin.getTools().map((t) => t.name);
53
+ expect(names).toContain('jira_issues');
54
+ expect(names).toContain('jira_my_issues');
55
+ expect(names).toContain('jira_issue');
56
+ expect(names).toContain('jira_create_issue');
57
+ expect(names).toContain('jira_update_issue');
58
+ expect(names).toContain('jira_comment');
59
+ expect(names).toContain('jira_projects');
60
+ expect(names).toContain('jira_transitions');
61
+ expect(names).toContain('jira_transition_issue');
62
+ });
63
+
64
+ it('marks write operations as requiresApproval', () => {
65
+ const writeTools = ['jira_create_issue', 'jira_update_issue', 'jira_comment', 'jira_transition_issue'];
66
+ for (const name of writeTools) {
67
+ const t = plugin.getTools().find((t) => t.name === name);
68
+ expect(t?.requiresApproval).toBe(true);
69
+ }
70
+ });
71
+ });
72
+
73
+ // ── isConfigured ─────────────────────────────────────────────────────────────
74
+
75
+ // Note: isConfigured() returns true by design - real check at tool invocation
76
+
77
+ // ── Unconfigured error messages ───────────────────────────────────────────────
78
+
79
+ describe('Jira tools — unconfigured', () => {
80
+ beforeEach(async () => {
81
+ // No token, no domain, no email
82
+ vi.spyOn(Keychain.prototype, 'get').mockResolvedValue(null);
83
+ plugin = new JiraPlugin();
84
+ await plugin.initialize(makeConductor());
85
+ });
86
+
87
+ it('jira_issues throws with actionable message when not configured', async () => {
88
+ await expect(
89
+ tool(plugin, 'jira_issues')({ jql: 'project = ENG' }),
90
+ ).rejects.toThrow(/jira/i);
91
+ });
92
+
93
+ it('jira_projects throws with actionable message when not configured', async () => {
94
+ await expect(tool(plugin, 'jira_projects')({})).rejects.toThrow(/jira/i);
95
+ });
96
+
97
+ it('jira_my_issues throws with actionable message when not configured', async () => {
98
+ await expect(tool(plugin, 'jira_my_issues')({})).rejects.toThrow(/jira/i);
99
+ });
100
+ });
101
+
102
+ // ── Configured — mocked fetch calls ──────────────────────────────────────────
103
+
104
+ describe('Jira tools — configured', () => {
105
+ beforeEach(async () => {
106
+ vi.spyOn(Keychain.prototype, 'get').mockResolvedValue('fake_jira_api_token');
107
+ plugin = new JiraPlugin();
108
+ await plugin.initialize(makeConductor({
109
+ domain: 'mycompany',
110
+ email: 'user@mycompany.com',
111
+ }));
112
+ });
113
+
114
+ it('jira_issues returns search results', async () => {
115
+ const mockResponse = {
116
+ total: 2,
117
+ issues: [
118
+ {
119
+ id: '10001',
120
+ key: 'ENG-1',
121
+ self: 'https://mycompany.atlassian.net/rest/api/3/issue/10001',
122
+ fields: {
123
+ summary: 'Fix the login bug',
124
+ status: { name: 'In Progress', statusCategory: { name: 'In Progress' } },
125
+ priority: { name: 'High' },
126
+ assignee: { displayName: 'Alice Smith' },
127
+ reporter: { displayName: 'Bob Jones' },
128
+ issuetype: { name: 'Bug' },
129
+ project: { key: 'ENG' },
130
+ created: '2024-01-01T00:00:00Z',
131
+ updated: '2024-06-01T00:00:00Z',
132
+ labels: ['frontend'],
133
+ },
134
+ },
135
+ {
136
+ id: '10002',
137
+ key: 'ENG-2',
138
+ self: 'https://mycompany.atlassian.net/rest/api/3/issue/10002',
139
+ fields: {
140
+ summary: 'Add dark mode',
141
+ status: { name: 'Todo', statusCategory: { name: 'To Do' } },
142
+ priority: { name: 'Medium' },
143
+ assignee: null,
144
+ reporter: { displayName: 'Bob Jones' },
145
+ issuetype: { name: 'Story' },
146
+ project: { key: 'ENG' },
147
+ created: '2024-02-01T00:00:00Z',
148
+ updated: '2024-06-01T00:00:00Z',
149
+ labels: [],
150
+ },
151
+ },
152
+ ],
153
+ };
154
+
155
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
156
+ ok: true,
157
+ status: 200,
158
+ json: () => Promise.resolve(mockResponse),
159
+ }));
160
+
161
+ const result = await tool(plugin, 'jira_issues')({ jql: 'project = ENG' }) as any;
162
+ expect(result.total).toBe(2);
163
+ expect(result.count).toBe(2);
164
+ expect(result.issues[0].key).toBe('ENG-1');
165
+ expect(result.issues[0].summary).toBe('Fix the login bug');
166
+ expect(result.issues[0].status).toBe('In Progress');
167
+ expect(result.issues[0].assignee).toBe('Alice Smith');
168
+ expect(result.issues[1].key).toBe('ENG-2');
169
+ expect(result.issues[1].assignee).toBeNull();
170
+ });
171
+
172
+ it('jira_issue fetches a single issue by key', async () => {
173
+ const mockIssue = {
174
+ id: '10001',
175
+ key: 'ENG-42',
176
+ self: 'https://mycompany.atlassian.net/rest/api/3/issue/10001',
177
+ fields: {
178
+ summary: 'Critical auth bug',
179
+ status: { name: 'In Progress', statusCategory: { name: 'In Progress' } },
180
+ priority: { name: 'Highest' },
181
+ assignee: { displayName: 'Charlie Dev' },
182
+ reporter: { displayName: 'Manager' },
183
+ issuetype: { name: 'Bug' },
184
+ project: { key: 'ENG' },
185
+ created: '2024-01-01T00:00:00Z',
186
+ updated: '2024-06-01T00:00:00Z',
187
+ labels: ['critical'],
188
+ comment: {
189
+ comments: [
190
+ {
191
+ author: { displayName: 'Alice' },
192
+ created: '2024-06-01T00:00:00Z',
193
+ body: {
194
+ content: [{ content: [{ text: 'Working on it' }] }],
195
+ },
196
+ },
197
+ ],
198
+ },
199
+ },
200
+ };
201
+
202
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
203
+ ok: true,
204
+ status: 200,
205
+ json: () => Promise.resolve(mockIssue),
206
+ }));
207
+
208
+ const result = await tool(plugin, 'jira_issue')({ key: 'ENG-42' }) as any;
209
+ expect(result.key).toBe('ENG-42');
210
+ expect(result.summary).toBe('Critical auth bug');
211
+ expect(result.priority).toBe('Highest');
212
+ expect(result.comments).toHaveLength(1);
213
+ expect(result.comments[0].author).toBe('Alice');
214
+ expect(result.comments[0].body).toBe('Working on it');
215
+ });
216
+
217
+ it('jira_projects returns project list', async () => {
218
+ const mockResponse = {
219
+ values: [
220
+ { id: '1', key: 'ENG', name: 'Engineering', projectTypeKey: 'software', lead: { displayName: 'Alice' } },
221
+ { id: '2', key: 'MKTG', name: 'Marketing', projectTypeKey: 'business', lead: null },
222
+ ],
223
+ };
224
+
225
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
226
+ ok: true,
227
+ status: 200,
228
+ json: () => Promise.resolve(mockResponse),
229
+ }));
230
+
231
+ const result = await tool(plugin, 'jira_projects')({}) as any;
232
+ expect(result.count).toBe(2);
233
+ expect(result.projects[0].key).toBe('ENG');
234
+ expect(result.projects[0].name).toBe('Engineering');
235
+ expect(result.projects[1].key).toBe('MKTG');
236
+ });
237
+
238
+ it('jira_create_issue creates an issue and returns key', async () => {
239
+ const mockResponse = {
240
+ id: '10099',
241
+ key: 'ENG-99',
242
+ self: 'https://mycompany.atlassian.net/rest/api/3/issue/10099',
243
+ };
244
+
245
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
246
+ ok: true,
247
+ status: 201,
248
+ json: () => Promise.resolve(mockResponse),
249
+ }));
250
+
251
+ const result = await tool(plugin, 'jira_create_issue')({
252
+ project: 'ENG',
253
+ summary: 'New task',
254
+ description: 'Do the thing',
255
+ issue_type: 'Task',
256
+ }) as any;
257
+
258
+ expect(result.key).toBe('ENG-99');
259
+ expect(result.id).toBe('10099');
260
+ });
261
+
262
+ it('jira uses Basic auth header', async () => {
263
+ const mockResponse = { values: [] };
264
+ const fetchMock = vi.fn().mockResolvedValue({
265
+ ok: true,
266
+ status: 200,
267
+ json: () => Promise.resolve(mockResponse),
268
+ });
269
+ vi.stubGlobal('fetch', fetchMock);
270
+
271
+ await tool(plugin, 'jira_projects')({});
272
+
273
+ const callInit = fetchMock.mock.calls[0][1] as RequestInit;
274
+ const headers = callInit.headers as Record<string, string>;
275
+ const expectedAuth = Buffer.from('user@mycompany.com:fake_jira_api_token').toString('base64');
276
+ expect(headers['Authorization']).toBe(`Basic ${expectedAuth}`);
277
+ });
278
+
279
+ it('jira_transitions returns available transitions', async () => {
280
+ const mockResponse = {
281
+ transitions: [
282
+ { id: '11', name: 'To Do', to: { name: 'To Do', statusCategory: { name: 'To Do' } } },
283
+ { id: '21', name: 'In Progress', to: { name: 'In Progress', statusCategory: { name: 'In Progress' } } },
284
+ { id: '31', name: 'Done', to: { name: 'Done', statusCategory: { name: 'Done' } } },
285
+ ],
286
+ };
287
+
288
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
289
+ ok: true,
290
+ status: 200,
291
+ json: () => Promise.resolve(mockResponse),
292
+ }));
293
+
294
+ const result = await tool(plugin, 'jira_transitions')({ key: 'ENG-1' }) as any;
295
+ expect(result.issue).toBe('ENG-1');
296
+ expect(result.transitions).toHaveLength(3);
297
+ expect(result.transitions[1].name).toBe('In Progress');
298
+ expect(result.transitions[1].id).toBe('21');
299
+ });
300
+
301
+ it('jira throws when API returns error', async () => {
302
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
303
+ ok: false,
304
+ status: 401,
305
+ text: () => Promise.resolve('Unauthorized'),
306
+ }));
307
+
308
+ await expect(tool(plugin, 'jira_projects')({})).rejects.toThrow(/401/);
309
+ });
310
+ });