jira-pilot 2.1.2 → 2.2.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.
Files changed (114) hide show
  1. package/README.md +54 -0
  2. package/bin/jira.ts +2 -0
  3. package/dist/bin/jira.js +2 -0
  4. package/dist/bin/jira.js.map +1 -1
  5. package/dist/src/commands/ai-actions/plan.js +1 -1
  6. package/dist/src/commands/ai-actions/plan.js.map +1 -1
  7. package/dist/src/commands/ai-actions/review.js +5 -4
  8. package/dist/src/commands/ai-actions/review.js.map +1 -1
  9. package/dist/src/commands/ai-actions/standup.js +1 -1
  10. package/dist/src/commands/ai-actions/standup.js.map +1 -1
  11. package/dist/src/commands/ai.js +1 -1
  12. package/dist/src/commands/ai.js.map +1 -1
  13. package/dist/src/commands/board.js +10 -5
  14. package/dist/src/commands/board.js.map +1 -1
  15. package/dist/src/commands/bulk.js +11 -10
  16. package/dist/src/commands/bulk.js.map +1 -1
  17. package/dist/src/commands/config.js +1 -1
  18. package/dist/src/commands/config.js.map +1 -1
  19. package/dist/src/commands/dashboard.js +19 -12
  20. package/dist/src/commands/dashboard.js.map +1 -1
  21. package/dist/src/commands/filter.js +7 -4
  22. package/dist/src/commands/filter.js.map +1 -1
  23. package/dist/src/commands/git.js +1 -1
  24. package/dist/src/commands/git.js.map +1 -1
  25. package/dist/src/commands/issue-attach.js +1 -1
  26. package/dist/src/commands/issue-attach.js.map +1 -1
  27. package/dist/src/commands/issue-pr.js +1 -1
  28. package/dist/src/commands/issue-pr.js.map +1 -1
  29. package/dist/src/commands/issue-worklog.js +10 -5
  30. package/dist/src/commands/issue-worklog.js.map +1 -1
  31. package/dist/src/commands/issue.js +173 -122
  32. package/dist/src/commands/issue.js.map +1 -1
  33. package/dist/src/commands/project.js +10 -5
  34. package/dist/src/commands/project.js.map +1 -1
  35. package/dist/src/commands/sprint.js +19 -8
  36. package/dist/src/commands/sprint.js.map +1 -1
  37. package/dist/src/commands/tui.d.ts +2 -0
  38. package/dist/src/commands/tui.js +10 -0
  39. package/dist/src/commands/tui.js.map +1 -0
  40. package/dist/src/server/mcp-server.js +209 -27
  41. package/dist/src/server/mcp-server.js.map +1 -1
  42. package/dist/src/services/ai-service.js +7 -4
  43. package/dist/src/services/ai-service.js.map +1 -1
  44. package/dist/src/services/api-service.d.ts +2 -0
  45. package/dist/src/services/api-service.js +32 -20
  46. package/dist/src/services/api-service.js.map +1 -1
  47. package/dist/src/tui/App.d.ts +1 -0
  48. package/dist/src/tui/App.js +26 -0
  49. package/dist/src/tui/App.js.map +1 -0
  50. package/dist/src/tui/index.d.ts +1 -0
  51. package/dist/src/tui/index.js +8 -0
  52. package/dist/src/tui/index.js.map +1 -0
  53. package/dist/src/tui/screens/BoardList.d.ts +1 -0
  54. package/dist/src/tui/screens/BoardList.js +71 -0
  55. package/dist/src/tui/screens/BoardList.js.map +1 -0
  56. package/dist/src/tui/screens/Dashboard.d.ts +1 -0
  57. package/dist/src/tui/screens/Dashboard.js +41 -0
  58. package/dist/src/tui/screens/Dashboard.js.map +1 -0
  59. package/dist/src/tui/screens/IssueDetail.d.ts +6 -0
  60. package/dist/src/tui/screens/IssueDetail.js +40 -0
  61. package/dist/src/tui/screens/IssueDetail.js.map +1 -0
  62. package/dist/src/tui/screens/IssueList.d.ts +1 -0
  63. package/dist/src/tui/screens/IssueList.js +72 -0
  64. package/dist/src/tui/screens/IssueList.js.map +1 -0
  65. package/dist/src/tui/screens/KanbanBoard.d.ts +6 -0
  66. package/dist/src/tui/screens/KanbanBoard.js +86 -0
  67. package/dist/src/tui/screens/KanbanBoard.js.map +1 -0
  68. package/dist/src/tui/utils/adf-render.d.ts +1 -0
  69. package/dist/src/tui/utils/adf-render.js +29 -0
  70. package/dist/src/tui/utils/adf-render.js.map +1 -0
  71. package/dist/src/utils/api-paths.d.ts +31 -0
  72. package/dist/src/utils/api-paths.js +32 -0
  73. package/dist/src/utils/api-paths.js.map +1 -0
  74. package/dist/src/utils/error-handler.d.ts +2 -2
  75. package/dist/src/utils/error-handler.js.map +1 -1
  76. package/dist/src/utils/http.d.ts +27 -0
  77. package/dist/src/utils/http.js +95 -0
  78. package/dist/src/utils/http.js.map +1 -0
  79. package/dist/src/utils/spinner.d.ts +21 -0
  80. package/dist/src/utils/spinner.js +79 -0
  81. package/dist/src/utils/spinner.js.map +1 -0
  82. package/package.json +10 -5
  83. package/src/commands/ai-actions/plan.ts +1 -1
  84. package/src/commands/ai-actions/review.ts +5 -4
  85. package/src/commands/ai-actions/standup.ts +1 -1
  86. package/src/commands/ai.ts +1 -1
  87. package/src/commands/board.ts +10 -5
  88. package/src/commands/bulk.ts +11 -10
  89. package/src/commands/config.ts +1 -1
  90. package/src/commands/dashboard.ts +20 -12
  91. package/src/commands/filter.ts +8 -5
  92. package/src/commands/git.ts +1 -1
  93. package/src/commands/issue-attach.ts +1 -1
  94. package/src/commands/issue-pr.ts +1 -1
  95. package/src/commands/issue-worklog.ts +10 -5
  96. package/src/commands/issue.ts +181 -124
  97. package/src/commands/project.ts +10 -5
  98. package/src/commands/sprint.ts +19 -8
  99. package/src/commands/tui.ts +11 -0
  100. package/src/server/mcp-server.ts +234 -27
  101. package/src/services/ai-service.ts +7 -4
  102. package/src/services/api-service.ts +34 -21
  103. package/src/tui/App.tsx +61 -0
  104. package/src/tui/index.tsx +8 -0
  105. package/src/tui/screens/BoardList.tsx +102 -0
  106. package/src/tui/screens/Dashboard.tsx +75 -0
  107. package/src/tui/screens/IssueDetail.tsx +93 -0
  108. package/src/tui/screens/IssueList.tsx +116 -0
  109. package/src/tui/screens/KanbanBoard.tsx +133 -0
  110. package/src/tui/utils/adf-render.ts +30 -0
  111. package/src/utils/api-paths.ts +32 -0
  112. package/src/utils/error-handler.ts +2 -2
  113. package/src/utils/http.ts +128 -0
  114. package/src/utils/spinner.ts +87 -0
@@ -1,8 +1,8 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
- import Table from 'cli-table3';
3
+ import { Table } from 'cmd-table';
4
4
  import { api } from '../services/api-service.js';
5
- import ora from 'ora';
5
+ import ora from '../utils/spinner.js';
6
6
  import enquirer from 'enquirer';
7
7
  import { handleCommandError } from '../utils/error-handler.js';
8
8
 
@@ -56,11 +56,16 @@ Common Actions:
56
56
  }
57
57
 
58
58
  const table = new Table({
59
- head: [chalk.bold('ID'), chalk.bold('Name'), chalk.bold('State'), chalk.bold('Dates')]
59
+ columns: [
60
+ { name: chalk.bold('ID') },
61
+ { name: chalk.bold('Name') },
62
+ { name: chalk.bold('State') },
63
+ { name: chalk.bold('Dates') }
64
+ ]
60
65
  });
61
66
 
62
67
  data.values.forEach((s: any) => {
63
- table.push([
68
+ table.addRow([
64
69
  s.id,
65
70
  s.name,
66
71
  s.state === 'active' ? chalk.green(s.state) : s.state,
@@ -68,7 +73,7 @@ Common Actions:
68
73
  ]);
69
74
  });
70
75
 
71
- console.log(table.toString());
76
+ console.log(table.render());
72
77
 
73
78
  } catch (e: any) {
74
79
  handleCommandError(spinner, e, 'Failed to list sprints');
@@ -131,10 +136,16 @@ Examples:
131
136
  }
132
137
 
133
138
  const table = new Table({
134
- head: [chalk.bold('Key'), chalk.bold('Summary'), chalk.bold('Status'), chalk.bold('Assignee'), chalk.bold('Priority')]
139
+ columns: [
140
+ { name: chalk.bold('Key') },
141
+ { name: chalk.bold('Summary') },
142
+ { name: chalk.bold('Status') },
143
+ { name: chalk.bold('Assignee') },
144
+ { name: chalk.bold('Priority') }
145
+ ]
135
146
  });
136
147
  issues.issues.forEach((i: any) => {
137
- table.push([
148
+ table.addRow([
138
149
  chalk.cyan(i.key),
139
150
  i.fields.summary ? (i.fields.summary.length > 50 ? i.fields.summary.substring(0, 47) + '...' : i.fields.summary) : '',
140
151
  i.fields.status?.name || '',
@@ -142,7 +153,7 @@ Examples:
142
153
  i.fields.priority?.name || ''
143
154
  ]);
144
155
  });
145
- console.log(table.toString());
156
+ console.log(table.render());
146
157
  console.log(chalk.grey(`${issues.issues.length} issue(s) in sprint`));
147
158
 
148
159
  } catch (e: any) {
@@ -0,0 +1,11 @@
1
+ import { Command } from 'commander';
2
+ import { startTui } from '../tui/index.js';
3
+
4
+ export function registerTuiCommand(program: Command) {
5
+ program
6
+ .command('tui')
7
+ .description('Start the interactive TUI mode')
8
+ .action(async () => {
9
+ startTui();
10
+ });
11
+ }
@@ -1,18 +1,54 @@
1
1
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
- import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
3
+ import {
4
+ CallToolRequestSchema,
5
+ ListToolsRequestSchema,
6
+ ListPromptsRequestSchema,
7
+ GetPromptRequestSchema,
8
+ ListResourceTemplatesRequestSchema,
9
+ ListResourcesRequestSchema,
10
+ ReadResourceRequestSchema,
11
+ } from "@modelcontextprotocol/sdk/types.js";
4
12
  import { api } from "../services/api-service.js";
5
13
  import { textToADF } from "../utils/text-to-adf.js";
14
+ import { readFileSync, existsSync } from "fs";
15
+ import { join, dirname } from "path";
16
+ import { fileURLToPath } from "url";
17
+ import { API } from "../utils/api-paths.js";
18
+
19
+ // Load package.json for version
20
+ const __dirname = dirname(fileURLToPath(import.meta.url));
21
+
22
+ function getPackageVersion() {
23
+ const candidates = [
24
+ join(__dirname, "../../package.json"),
25
+ join(__dirname, "../../../package.json"),
26
+ join(process.cwd(), "package.json"),
27
+ ];
28
+
29
+ for (const p of candidates) {
30
+ if (!existsSync(p)) continue;
31
+ try {
32
+ const pkg = JSON.parse(readFileSync(p, "utf-8"));
33
+ if (typeof pkg.version === "string" && pkg.version) return pkg.version;
34
+ } catch { /* ignore */ }
35
+ }
36
+ return "0.0.0";
37
+ }
38
+
39
+ const version = getPackageVersion();
6
40
 
7
41
  // Initialize MCP Server
8
42
  const server = new Server(
9
43
  {
10
44
  name: "jira-pilot",
11
- version: "1.0.0",
45
+ version: version,
12
46
  },
13
47
  {
14
48
  capabilities: {
15
49
  tools: {},
50
+ prompts: { listChanged: true },
51
+ resources: { subscribe: false, listChanged: true },
16
52
  },
17
53
  }
18
54
  );
@@ -200,6 +236,175 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
200
236
  };
201
237
  });
202
238
 
239
+ // ── Prompt Definitions ────────────────────────────────────────────────
240
+ server.setRequestHandler(ListPromptsRequestSchema, async () => {
241
+ return {
242
+ prompts: [
243
+ {
244
+ name: "jira-assist",
245
+ description: "A system prompt to help the LLM understand how to assist with Jira tasks.",
246
+ },
247
+ {
248
+ name: "jira-summarize-issue",
249
+ description: "Summarize a specific Jira issue.",
250
+ arguments: [
251
+ {
252
+ name: "issueKey",
253
+ description: "The key of the issue to summarize (e.g., PROJ-123)",
254
+ required: true
255
+ }
256
+ ]
257
+ }
258
+ ]
259
+ };
260
+ });
261
+
262
+ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
263
+ const { name, arguments: args } = request.params;
264
+
265
+ if (name === "jira-assist") {
266
+ return {
267
+ messages: [
268
+ {
269
+ role: "user",
270
+ content: {
271
+ type: "text",
272
+ text: `You are Jira Pilot, an intelligent assistant for Jira.
273
+ Your goal is to help users manage their projects, issues, and workflows efficiently.
274
+
275
+ Available Tools:
276
+ - Use 'jira_list_issues' to find issues.
277
+ - Use 'jira_get_issue' to see details.
278
+ - Use 'jira_create_issue', 'jira_update_issue', 'jira_transition_issue' to modify.
279
+
280
+ Guidelines:
281
+ 1. Always be concise and helpful.
282
+ 2. If the user asks to "fix" something, look for relevant issues first.
283
+ 3. When creating issues, ask for clarification if fields are missing (Project, Type).
284
+ 4. Use JQL for powerful searching.`
285
+ }
286
+ }
287
+ ]
288
+ };
289
+ }
290
+
291
+ if (name === "jira-summarize-issue") {
292
+ const issueKey = args?.issueKey;
293
+ if (!issueKey) {
294
+ throw new Error("Missing required argument: issueKey");
295
+ }
296
+
297
+ return {
298
+ messages: [
299
+ {
300
+ role: "user",
301
+ content: {
302
+ type: "text",
303
+ text: `Please fetch details for Jira issue ${issueKey} using 'jira_get_issue', and then provide a concise summary of its status, priority, and recent activity.`
304
+ }
305
+ }
306
+ ]
307
+ };
308
+ }
309
+
310
+ throw new Error(`Prompt not found: ${name}. Available: jira-assist, jira-summarize-issue`);
311
+ });
312
+
313
+ // ── Resource Templates ──────────────────────────────────────────────
314
+ server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
315
+ return {
316
+ resourceTemplates: []
317
+ };
318
+ });
319
+
320
+ // ── Resource Definitions ──────────────────────────────────────────────
321
+ server.setRequestHandler(ListResourcesRequestSchema, async () => {
322
+ return {
323
+ resources: [
324
+ {
325
+ uri: "jira://myself",
326
+ name: "My Profile",
327
+ description: "Details of the currently authenticated user.",
328
+ mimeType: "application/json"
329
+ },
330
+ {
331
+ uri: "jira://projects",
332
+ name: "All Projects",
333
+ description: "List of all accessible Jira projects.",
334
+ mimeType: "application/json"
335
+ }
336
+ ]
337
+ };
338
+ });
339
+
340
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
341
+ const { uri } = request.params;
342
+
343
+ const createEnvelope = (type: string, data: any) => ({
344
+ source: "jira-pilot",
345
+ type,
346
+ data,
347
+ fetchedAt: new Date().toISOString()
348
+ });
349
+
350
+ try {
351
+ if (uri === "jira://myself") {
352
+ const myself = await api.get(API.USER.MYSELF);
353
+ // Mask sensitive data if needed, though 'myself' usually implies permission to see own data.
354
+ // keeping it simple for now, but ensuring consistent shape.
355
+ const safeData = {
356
+ accountId: myself.accountId,
357
+ displayName: myself.displayName,
358
+ active: myself.active,
359
+ timeZone: myself.timeZone,
360
+ // Only include email if present, or maybe mask it? User asked to be careful.
361
+ // We'll exclude email to be safe as per user request "do not include email".
362
+ };
363
+
364
+ return {
365
+ contents: [{
366
+ uri,
367
+ mimeType: "application/json",
368
+ text: JSON.stringify(createEnvelope("myself", safeData), null, 2)
369
+ }]
370
+ };
371
+ }
372
+
373
+ if (uri === "jira://projects") {
374
+ const data = await api.get(`${API.PROJECT.SEARCH}?maxResults=50`);
375
+ const projects = (data.values || []).map((p: any) => ({
376
+ key: p.key,
377
+ name: p.name,
378
+ id: p.id,
379
+ style: p.style
380
+ }));
381
+
382
+ return {
383
+ contents: [{
384
+ uri,
385
+ mimeType: "application/json",
386
+ text: JSON.stringify(createEnvelope("projects", projects), null, 2)
387
+ }]
388
+ };
389
+ }
390
+
391
+ throw new Error(`Resource not found: ${uri}. Available: jira://myself, jira://projects`);
392
+
393
+ } catch (e: any) {
394
+ // Handle Auth/Network errors specifically
395
+ if (e.response?.status === 401 || e.response?.status === 403) {
396
+ throw new Error(`Jira auth is missing or expired. Run 'jira config setup' to authenticate.`);
397
+ }
398
+ if (e.message.includes("Resource not found")) {
399
+ throw e; // Re-throw 404s we generated
400
+ }
401
+
402
+ // Upstream errors
403
+ const status = e.response?.status || "Unknown";
404
+ throw new Error(`Upstream Jira error (${status}): ${e.message}`);
405
+ }
406
+ });
407
+
203
408
  // ── Tool Handlers ────────────────────────────────────────────────────
204
409
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
205
410
  const { name, arguments: args } = request.params as { name: string; arguments: any };
@@ -209,7 +414,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
209
414
  if (name === "jira_list_issues") {
210
415
  const jql = args.jql || "";
211
416
  const limit = args.limit || 10;
212
- const data = await api.post('/search/jql', {
417
+ const data = await api.post(API.SEARCH.JQL, {
213
418
  jql,
214
419
  maxResults: limit,
215
420
  fields: ['summary', 'status', 'assignee', 'priority', 'created', 'updated']
@@ -233,7 +438,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
233
438
 
234
439
  // ── jira_get_issue ──────────────────────────────────
235
440
  if (name === "jira_get_issue") {
236
- const data = await api.get(`/issue/${args.issueKey}`);
441
+ const data = await api.get(API.ISSUE.GET(args.issueKey));
237
442
 
238
443
  // Return a cleaner summary for agents
239
444
  const result = {
@@ -284,7 +489,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
284
489
  body.fields.assignee = { accountId: args.assigneeId };
285
490
  }
286
491
 
287
- const data = await api.post('/issue', body);
492
+ const data = await api.post(API.ISSUE.BASE, body);
288
493
  return {
289
494
  content: [{ type: "text", text: JSON.stringify({ key: data.key, self: data.self }, null, 2) }]
290
495
  };
@@ -294,8 +499,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
294
499
  if (name === "jira_transition_issue") {
295
500
  if (!args.transitionId) {
296
501
  // List available transitions
297
- const transData = await api.get(`/issue/${args.issueKey}/transitions`);
298
- const issue = await api.get(`/issue/${args.issueKey}?fields=summary,status`);
502
+ const transData = await api.get(API.ISSUE.TRANSITIONS(args.issueKey));
503
+ const issue = await api.get(`${API.ISSUE.GET(args.issueKey)}?fields=summary,status`);
299
504
 
300
505
  const result = {
301
506
  issueKey: args.issueKey,
@@ -314,7 +519,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
314
519
  }
315
520
 
316
521
  // Execute transition
317
- await api.post(`/issue/${args.issueKey}/transitions`, {
522
+ await api.post(API.ISSUE.TRANSITIONS(args.issueKey), {
318
523
  transition: { id: args.transitionId }
319
524
  });
320
525
 
@@ -329,11 +534,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
329
534
 
330
535
  // Resolve "me" to actual account ID
331
536
  if (accountId === 'me') {
332
- const myself = await api.get('/myself');
537
+ const myself = await api.get(API.USER.MYSELF);
333
538
  accountId = myself.accountId;
334
539
  }
335
540
 
336
- await api.put(`/issue/${args.issueKey}/assignee`, {
541
+ await api.put(API.ISSUE.ASSIGNEE(args.issueKey), {
337
542
  accountId: accountId || null
338
543
  });
339
544
 
@@ -344,7 +549,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
344
549
 
345
550
  // ── jira_add_comment ────────────────────────────────
346
551
  if (name === "jira_add_comment") {
347
- const data = await api.post(`/issue/${args.issueKey}/comment`, {
552
+ const data = await api.post(API.ISSUE.COMMENT(args.issueKey), {
348
553
  body: textToADF(args.body)
349
554
  });
350
555
 
@@ -364,7 +569,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
364
569
  if (args.assigneeId) {
365
570
  let accId = args.assigneeId;
366
571
  if (accId === 'me') {
367
- const myself = await api.get('/myself');
572
+ const myself = await api.get(API.USER.MYSELF);
368
573
  accId = myself.accountId;
369
574
  } else if (accId === 'none') {
370
575
  accId = null;
@@ -379,7 +584,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
379
584
  };
380
585
  }
381
586
 
382
- await api.put(`/issue/${args.issueKey}`, updateBody);
587
+ await api.put(API.ISSUE.GET(args.issueKey), updateBody);
383
588
 
384
589
  return {
385
590
  content: [{ type: "text", text: JSON.stringify({ success: true, issueKey: args.issueKey }) }]
@@ -388,12 +593,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
388
593
 
389
594
  // ── jira_search_users ───────────────────────────────
390
595
  if (name === "jira_search_users") {
391
- const users = await api.get(`/user/search?query=${encodeURIComponent(args.query)}`);
596
+ const users = await api.get(`${API.USER.SEARCH}?query=${encodeURIComponent(args.query)}`);
392
597
 
393
598
  const results = (users || []).map((u: any) => ({
394
599
  accountId: u.accountId,
395
600
  displayName: u.displayName,
396
- email: u.emailAddress,
601
+ // Email excluded for safety
602
+ // email: u.emailAddress,
397
603
  active: u.active
398
604
  }));
399
605
 
@@ -404,11 +610,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
404
610
 
405
611
  // ── jira_myself ─────────────────────────────────────
406
612
  if (name === "jira_myself") {
407
- const myself = await api.get('/myself');
613
+ const myself = await api.get(API.USER.MYSELF);
408
614
  const result = {
409
615
  accountId: myself.accountId,
410
616
  displayName: myself.displayName,
411
- email: myself.emailAddress,
617
+ // Email excluded for safety
618
+ // email: myself.emailAddress,
412
619
  active: myself.active,
413
620
  timeZone: myself.timeZone
414
621
  };
@@ -421,7 +628,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
421
628
  // ── jira_list_projects ──────────────────────────────
422
629
  if (name === "jira_list_projects") {
423
630
  const limit = args.limit || 50;
424
- const data = await api.get(`/project/search?maxResults=${limit}`);
631
+ const data = await api.get(`${API.PROJECT.SEARCH}?maxResults=${limit}`);
425
632
 
426
633
  const projects = (data.values || []).map((p: any) => ({
427
634
  key: p.key,
@@ -464,7 +671,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
464
671
  body.comment = textToADF(args.comment);
465
672
  }
466
673
 
467
- await api.post(`/issue/${args.issueKey}/worklog`, body);
674
+ await api.post(API.ISSUE.WORKLOG(args.issueKey), body);
468
675
 
469
676
  return {
470
677
  content: [{ type: "text", text: JSON.stringify({ success: true, issueKey: args.issueKey, timeSpent: args.timeSpent }) }]
@@ -474,7 +681,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
474
681
  // ── jira_create_subtask ─────────────────────────────
475
682
  if (name === "jira_create_subtask") {
476
683
  // 1. Fetch parent to get project
477
- const parent = await api.get(`/issue/${args.parentKey}?fields=project`);
684
+ const parent = await api.get(`${API.ISSUE.GET(args.parentKey)}?fields=project`);
478
685
  const projectKey = parent.fields.project.key;
479
686
 
480
687
  // 2. Find subtask issue type
@@ -504,13 +711,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
504
711
  if (args.assigneeId) {
505
712
  let accId = args.assigneeId;
506
713
  if (accId === 'me') {
507
- const myself = await api.get('/myself');
714
+ const myself = await api.get(API.USER.MYSELF);
508
715
  accId = myself.accountId;
509
716
  }
510
717
  body.fields.assignee = { accountId: accId };
511
718
  }
512
719
 
513
- const data = await api.post('/issue', body);
720
+ const data = await api.post(API.ISSUE.BASE, body);
514
721
  return {
515
722
  content: [{ type: "text", text: JSON.stringify({ key: data.key, self: data.self }, null, 2) }]
516
723
  };
@@ -520,15 +727,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
520
727
  if (name === "jira_add_attachment") {
521
728
  try {
522
729
  // Dynamically import fs/path to avoid top-level node dependencies if this runs in browser-like env (unlikely but safe)
523
- const { openAsBlob } = await import('node:fs');
524
- const path = await import('node:path');
730
+ const fs = await import("node:fs");
731
+ const path = await import("node:path");
525
732
 
526
733
  const filePath = args.filePath;
527
- const file = await openAsBlob(filePath);
734
+ const file = await fs.openAsBlob(filePath);
528
735
  const formData = new FormData();
529
- formData.append('file', file, path.default.basename(filePath));
736
+ formData.append("file", file, path.basename(filePath));
530
737
 
531
- const result = await api.upload(`/issue/${args.issueKey}/attachments`, formData);
738
+ const result = await api.upload(API.ISSUE.ATTACHMENTS(args.issueKey), formData);
532
739
 
533
740
  return {
534
741
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
@@ -1,4 +1,4 @@
1
- import axios from 'axios';
1
+ import { HttpClient } from '../utils/http.js';
2
2
  import { getCredentials } from '../utils/config.js';
3
3
 
4
4
  export class AiService {
@@ -89,7 +89,8 @@ Today is ${new Date().toISOString().split('T')[0]}.`;
89
89
 
90
90
  async callOpenAI(key: string, prompt: string): Promise<string> {
91
91
  try {
92
- const response = await axios.post('https://api.openai.com/v1/chat/completions', {
92
+ const client = new HttpClient();
93
+ const response = await client.post('https://api.openai.com/v1/chat/completions', {
93
94
  model: 'gpt-4o',
94
95
  messages: [{ role: 'user', content: prompt }],
95
96
  temperature: 0.7
@@ -107,8 +108,9 @@ Today is ${new Date().toISOString().split('T')[0]}.`;
107
108
  // Gemini REST API — uses generativelanguage.googleapis.com
108
109
  const model = 'gemini-2.0-flash';
109
110
  const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${key}`;
111
+ const client = new HttpClient();
110
112
 
111
- const response = await axios.post(url, {
113
+ const response = await client.post(url, {
112
114
  contents: [{
113
115
  parts: [{ text: prompt }]
114
116
  }],
@@ -137,7 +139,8 @@ Today is ${new Date().toISOString().split('T')[0]}.`;
137
139
  async callAnthropic(key: string, prompt: string): Promise<string> {
138
140
  try {
139
141
  // Anthropic Messages API
140
- const response = await axios.post('https://api.anthropic.com/v1/messages', {
142
+ const client = new HttpClient();
143
+ const response = await client.post('https://api.anthropic.com/v1/messages', {
141
144
  model: 'claude-sonnet-4-20250514',
142
145
  max_tokens: 2048,
143
146
  messages: [{ role: 'user', content: prompt }]
@@ -1,6 +1,7 @@
1
- import axios from 'axios';
1
+ import { HttpClient } from '../utils/http.js';
2
2
  import chalk from 'chalk';
3
3
  import { getCredentials } from '../utils/config.js';
4
+ import { API } from '../utils/api-paths.js';
4
5
 
5
6
  export class ApiService {
6
7
  private client: any;
@@ -28,27 +29,28 @@ export class ApiService {
28
29
  const authHeader = `Basic ${Buffer.from(`${email}:${apiToken}`).toString('base64')}`;
29
30
 
30
31
  // Standard REST API v3 client
31
- this.client = axios.create({
32
+ this.client = new HttpClient({
32
33
  baseURL: `${this._domain}/rest/api/3`,
33
34
  headers: {
34
35
  'Authorization': authHeader,
35
- 'Accept': 'application/json',
36
- 'Content-Type': 'application/json'
36
+ 'Accept': 'application/json'
37
37
  }
38
38
  });
39
39
 
40
40
  // Agile REST API v1 client (for boards, sprints, etc.)
41
- this.agileClient = axios.create({
41
+ this.agileClient = new HttpClient({
42
42
  baseURL: `${this._domain}/rest/agile/1.0`,
43
43
  headers: {
44
44
  'Authorization': authHeader,
45
- 'Accept': 'application/json',
46
- 'Content-Type': 'application/json'
45
+ 'Accept': 'application/json'
47
46
  }
48
47
  });
48
+ }
49
49
 
50
- // Shared response interceptor
51
- const errorInterceptor = (error: any) => {
50
+ private async handleRequest(request: Promise<any>) {
51
+ try {
52
+ return await request;
53
+ } catch (error: any) {
52
54
  if (error.response) {
53
55
  if (error.response.status === 401) {
54
56
  console.error(chalk.red('Authentication failed. Please check your credentials using "jira config".'));
@@ -56,11 +58,8 @@ export class ApiService {
56
58
  console.error(chalk.red('Access denied. You may not have permission for this resource.'));
57
59
  }
58
60
  }
59
- return Promise.reject(error);
60
- };
61
-
62
- this.client.interceptors.response.use((r: any) => r, errorInterceptor);
63
- this.agileClient.interceptors.response.use((r: any) => r, errorInterceptor);
61
+ throw error;
62
+ }
64
63
  }
65
64
 
66
65
  /** @returns {string} The Jira domain URL */
@@ -81,28 +80,42 @@ export class ApiService {
81
80
 
82
81
  async get(url: string, config: any = {}) {
83
82
  this.ensureClient();
84
- const response = await this.client.get(url, config);
83
+ const response = await this.handleRequest(this.client.get(url, config));
85
84
  return response.data;
86
85
  }
87
86
 
88
87
  async post(url: string, data: any, config: any = {}) {
89
88
  this.ensureClient();
90
- const response = await this.client.post(url, data, config);
89
+ const response = await this.handleRequest(this.client.post(url, data, config));
91
90
  return response.data;
92
91
  }
93
92
 
94
93
  async put(url: string, data: any, config: any = {}) {
95
94
  this.ensureClient();
96
- const response = await this.client.put(url, data, config);
95
+ const response = await this.handleRequest(this.client.put(url, data, config));
97
96
  return response.data;
98
97
  }
99
98
 
100
99
  async delete(url: string, config: any = {}) {
101
100
  this.ensureClient();
102
- const response = await this.client.delete(url, config);
101
+ const response = await this.handleRequest(this.client.delete(url, config));
103
102
  return response.data;
104
103
  }
105
104
 
105
+ async search(jql: string, startAt: number = 0, maxResults: number = 50, nextPageToken?: string) {
106
+ const payload: any = {
107
+ jql,
108
+ maxResults,
109
+ fields: ['summary', 'status', 'assignee', 'priority', 'issuetype', 'created', 'updated', 'project']
110
+ };
111
+
112
+ if (nextPageToken) {
113
+ payload.nextPageToken = nextPageToken;
114
+ }
115
+
116
+ return this.post(API.SEARCH.JQL, payload);
117
+ }
118
+
106
119
  async upload(url: string, formData: any) {
107
120
  this.ensureClient();
108
121
  // Jira requires this header for attachments
@@ -117,7 +130,7 @@ export class ApiService {
117
130
  }
118
131
 
119
132
  const config = { headers };
120
- const response = await this.client.post(url, formData, config);
133
+ const response = await this.handleRequest(this.client.post(url, formData, config));
121
134
  return response.data;
122
135
  }
123
136
 
@@ -125,13 +138,13 @@ export class ApiService {
125
138
 
126
139
  async agileGet(url: string, config: any = {}) {
127
140
  this.ensureClient();
128
- const response = await this.agileClient.get(url, config);
141
+ const response = await this.handleRequest(this.agileClient.get(url, config));
129
142
  return response.data;
130
143
  }
131
144
 
132
145
  async agilePost(url: string, data: any, config: any = {}) {
133
146
  this.ensureClient();
134
- const response = await this.agileClient.post(url, data, config);
147
+ const response = await this.handleRequest(this.agileClient.post(url, data, config));
135
148
  return response.data;
136
149
  }
137
150
  }