jira-pilot 2.1.2 → 2.1.3

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 (107) hide show
  1. package/README.md +23 -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 +1 -1
  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 +11 -5
  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 +142 -87
  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/services/ai-service.js +7 -4
  41. package/dist/src/services/ai-service.js.map +1 -1
  42. package/dist/src/services/api-service.d.ts +2 -0
  43. package/dist/src/services/api-service.js +29 -20
  44. package/dist/src/services/api-service.js.map +1 -1
  45. package/dist/src/tui/App.d.ts +1 -0
  46. package/dist/src/tui/App.js +26 -0
  47. package/dist/src/tui/App.js.map +1 -0
  48. package/dist/src/tui/index.d.ts +1 -0
  49. package/dist/src/tui/index.js +8 -0
  50. package/dist/src/tui/index.js.map +1 -0
  51. package/dist/src/tui/screens/BoardList.d.ts +1 -0
  52. package/dist/src/tui/screens/BoardList.js +71 -0
  53. package/dist/src/tui/screens/BoardList.js.map +1 -0
  54. package/dist/src/tui/screens/Dashboard.d.ts +1 -0
  55. package/dist/src/tui/screens/Dashboard.js +41 -0
  56. package/dist/src/tui/screens/Dashboard.js.map +1 -0
  57. package/dist/src/tui/screens/IssueDetail.d.ts +6 -0
  58. package/dist/src/tui/screens/IssueDetail.js +40 -0
  59. package/dist/src/tui/screens/IssueDetail.js.map +1 -0
  60. package/dist/src/tui/screens/IssueList.d.ts +1 -0
  61. package/dist/src/tui/screens/IssueList.js +72 -0
  62. package/dist/src/tui/screens/IssueList.js.map +1 -0
  63. package/dist/src/tui/screens/KanbanBoard.d.ts +6 -0
  64. package/dist/src/tui/screens/KanbanBoard.js +86 -0
  65. package/dist/src/tui/screens/KanbanBoard.js.map +1 -0
  66. package/dist/src/tui/utils/adf-render.d.ts +1 -0
  67. package/dist/src/tui/utils/adf-render.js +29 -0
  68. package/dist/src/tui/utils/adf-render.js.map +1 -0
  69. package/dist/src/utils/error-handler.d.ts +2 -2
  70. package/dist/src/utils/error-handler.js.map +1 -1
  71. package/dist/src/utils/http.d.ts +27 -0
  72. package/dist/src/utils/http.js +95 -0
  73. package/dist/src/utils/http.js.map +1 -0
  74. package/dist/src/utils/spinner.d.ts +21 -0
  75. package/dist/src/utils/spinner.js +79 -0
  76. package/dist/src/utils/spinner.js.map +1 -0
  77. package/package.json +10 -5
  78. package/src/commands/ai-actions/plan.ts +1 -1
  79. package/src/commands/ai-actions/review.ts +5 -4
  80. package/src/commands/ai-actions/standup.ts +1 -1
  81. package/src/commands/ai.ts +1 -1
  82. package/src/commands/board.ts +10 -5
  83. package/src/commands/bulk.ts +1 -1
  84. package/src/commands/config.ts +1 -1
  85. package/src/commands/dashboard.ts +11 -5
  86. package/src/commands/filter.ts +8 -5
  87. package/src/commands/git.ts +1 -1
  88. package/src/commands/issue-attach.ts +1 -1
  89. package/src/commands/issue-pr.ts +1 -1
  90. package/src/commands/issue-worklog.ts +10 -5
  91. package/src/commands/issue.ts +149 -89
  92. package/src/commands/project.ts +10 -5
  93. package/src/commands/sprint.ts +19 -8
  94. package/src/commands/tui.ts +11 -0
  95. package/src/services/ai-service.ts +7 -4
  96. package/src/services/api-service.ts +29 -21
  97. package/src/tui/App.tsx +61 -0
  98. package/src/tui/index.tsx +8 -0
  99. package/src/tui/screens/BoardList.tsx +102 -0
  100. package/src/tui/screens/Dashboard.tsx +75 -0
  101. package/src/tui/screens/IssueDetail.tsx +93 -0
  102. package/src/tui/screens/IssueList.tsx +116 -0
  103. package/src/tui/screens/KanbanBoard.tsx +133 -0
  104. package/src/tui/utils/adf-render.ts +30 -0
  105. package/src/utils/error-handler.ts +2 -2
  106. package/src/utils/http.ts +128 -0
  107. 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,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,4 +1,4 @@
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
4
 
@@ -28,27 +28,28 @@ export class ApiService {
28
28
  const authHeader = `Basic ${Buffer.from(`${email}:${apiToken}`).toString('base64')}`;
29
29
 
30
30
  // Standard REST API v3 client
31
- this.client = axios.create({
31
+ this.client = new HttpClient({
32
32
  baseURL: `${this._domain}/rest/api/3`,
33
33
  headers: {
34
34
  'Authorization': authHeader,
35
- 'Accept': 'application/json',
36
- 'Content-Type': 'application/json'
35
+ 'Accept': 'application/json'
37
36
  }
38
37
  });
39
38
 
40
39
  // Agile REST API v1 client (for boards, sprints, etc.)
41
- this.agileClient = axios.create({
40
+ this.agileClient = new HttpClient({
42
41
  baseURL: `${this._domain}/rest/agile/1.0`,
43
42
  headers: {
44
43
  'Authorization': authHeader,
45
- 'Accept': 'application/json',
46
- 'Content-Type': 'application/json'
44
+ 'Accept': 'application/json'
47
45
  }
48
46
  });
47
+ }
49
48
 
50
- // Shared response interceptor
51
- const errorInterceptor = (error: any) => {
49
+ private async handleRequest(request: Promise<any>) {
50
+ try {
51
+ return await request;
52
+ } catch (error: any) {
52
53
  if (error.response) {
53
54
  if (error.response.status === 401) {
54
55
  console.error(chalk.red('Authentication failed. Please check your credentials using "jira config".'));
@@ -56,11 +57,8 @@ export class ApiService {
56
57
  console.error(chalk.red('Access denied. You may not have permission for this resource.'));
57
58
  }
58
59
  }
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);
60
+ throw error;
61
+ }
64
62
  }
65
63
 
66
64
  /** @returns {string} The Jira domain URL */
@@ -81,28 +79,38 @@ export class ApiService {
81
79
 
82
80
  async get(url: string, config: any = {}) {
83
81
  this.ensureClient();
84
- const response = await this.client.get(url, config);
82
+ const response = await this.handleRequest(this.client.get(url, config));
85
83
  return response.data;
86
84
  }
87
85
 
88
86
  async post(url: string, data: any, config: any = {}) {
89
87
  this.ensureClient();
90
- const response = await this.client.post(url, data, config);
88
+ const response = await this.handleRequest(this.client.post(url, data, config));
91
89
  return response.data;
92
90
  }
93
91
 
94
92
  async put(url: string, data: any, config: any = {}) {
95
93
  this.ensureClient();
96
- const response = await this.client.put(url, data, config);
94
+ const response = await this.handleRequest(this.client.put(url, data, config));
97
95
  return response.data;
98
96
  }
99
97
 
100
98
  async delete(url: string, config: any = {}) {
101
99
  this.ensureClient();
102
- const response = await this.client.delete(url, config);
100
+ const response = await this.handleRequest(this.client.delete(url, config));
103
101
  return response.data;
104
102
  }
105
103
 
104
+ async search(jql: string, startAt: number = 0, maxResults: number = 50) {
105
+ return this.post('/search/jql', {
106
+ jql,
107
+ startAt,
108
+ maxResults,
109
+ fields: ['summary', 'status', 'assignee', 'priority', 'issuetype', 'created', 'updated', 'project'],
110
+ validation: 'warn'
111
+ });
112
+ }
113
+
106
114
  async upload(url: string, formData: any) {
107
115
  this.ensureClient();
108
116
  // Jira requires this header for attachments
@@ -117,7 +125,7 @@ export class ApiService {
117
125
  }
118
126
 
119
127
  const config = { headers };
120
- const response = await this.client.post(url, formData, config);
128
+ const response = await this.handleRequest(this.client.post(url, formData, config));
121
129
  return response.data;
122
130
  }
123
131
 
@@ -125,13 +133,13 @@ export class ApiService {
125
133
 
126
134
  async agileGet(url: string, config: any = {}) {
127
135
  this.ensureClient();
128
- const response = await this.agileClient.get(url, config);
136
+ const response = await this.handleRequest(this.agileClient.get(url, config));
129
137
  return response.data;
130
138
  }
131
139
 
132
140
  async agilePost(url: string, data: any, config: any = {}) {
133
141
  this.ensureClient();
134
- const response = await this.agileClient.post(url, data, config);
142
+ const response = await this.handleRequest(this.agileClient.post(url, data, config));
135
143
  return response.data;
136
144
  }
137
145
  }
@@ -0,0 +1,61 @@
1
+ import React, { useState } from 'react';
2
+ import { Box, Text, useInput } from 'ink';
3
+ import Dashboard from './screens/Dashboard.js';
4
+ import IssueList from './screens/IssueList.js';
5
+ import BoardList from './screens/BoardList.js';
6
+
7
+ type View = 'dashboard' | 'issues' | 'boards';
8
+
9
+ export default function App() {
10
+ const [view, setView] = useState<View>('dashboard');
11
+ const [activeTab, setActiveTab] = useState(0);
12
+ const tabs: View[] = ['dashboard', 'issues', 'boards'];
13
+
14
+ useInput((input, key) => {
15
+ if (key.leftArrow) {
16
+ setActiveTab(prev => Math.max(0, prev - 1));
17
+ setView(tabs[Math.max(0, activeTab - 1)]);
18
+ }
19
+ if (key.rightArrow) {
20
+ setActiveTab(prev => Math.min(tabs.length - 1, prev + 1));
21
+ setView(tabs[Math.min(tabs.length - 1, activeTab + 1)]);
22
+ }
23
+ if (input === 'q') {
24
+ process.exit(0);
25
+ }
26
+ });
27
+
28
+ return (
29
+ <Box flexDirection="column" height="100%">
30
+ {/* Header */}
31
+ <Box borderStyle="classic" borderColor="blue" paddingX={1}>
32
+ <Text bold color="blue">Jira Pilot</Text>
33
+ <Box marginLeft={2} flexDirection="column" justifyContent="center">
34
+ <Text>Use <Text color="green">←/→</Text> to navigate tabs. Press <Text color="red">q</Text> to quit.</Text>
35
+ </Box>
36
+ </Box>
37
+
38
+ {/* Tabs */}
39
+ <Box paddingX={1} marginBottom={1}>
40
+ {tabs.map((tab, index) => (
41
+ <Box key={tab} marginRight={2}>
42
+ <Text
43
+ color={activeTab === index ? 'green' : 'white'}
44
+ bold={activeTab === index}
45
+ underline={activeTab === index}
46
+ >
47
+ {tab.charAt(0).toUpperCase() + tab.slice(1)}
48
+ </Text>
49
+ </Box>
50
+ ))}
51
+ </Box>
52
+
53
+ {/* Content Area */}
54
+ <Box flexGrow={1} borderStyle="round" padding={1} borderColor="gray">
55
+ {view === 'dashboard' && <Dashboard />}
56
+ {view === 'issues' && <IssueList />}
57
+ {view === 'boards' && <BoardList />}
58
+ </Box>
59
+ </Box>
60
+ );
61
+ }
@@ -0,0 +1,8 @@
1
+ import React from 'react';
2
+ import { render } from 'ink';
3
+ import App from './App.js';
4
+
5
+ export function startTui() {
6
+ console.clear();
7
+ render(<App />);
8
+ }
@@ -0,0 +1,102 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { Box, Text, useInput } from 'ink';
3
+ import SpinnerOriginal from 'ink-spinner';
4
+ const Spinner = SpinnerOriginal as any;
5
+ import { api } from '../../services/api-service.js';
6
+ import KanbanBoard from './KanbanBoard.js';
7
+
8
+ export default function BoardList() {
9
+ const [loading, setLoading] = useState(true);
10
+ const [boards, setBoards] = useState<any[]>([]);
11
+ const [selectedIndex, setSelectedIndex] = useState(0);
12
+ const [selectedBoardId, setSelectedBoardId] = useState<string | null>(null);
13
+ const [error, setError] = useState<string | null>(null);
14
+
15
+ useEffect(() => {
16
+ const fetchBoards = async () => {
17
+ try {
18
+ const results = await api.agileGet('/board');
19
+ setBoards(results.values);
20
+ setLoading(false);
21
+ } catch (err: any) {
22
+ setError(err.message);
23
+ setLoading(false);
24
+ }
25
+ };
26
+ fetchBoards();
27
+ }, []);
28
+
29
+ useInput((input, key) => {
30
+ if (selectedBoardId) return;
31
+
32
+ if (key.upArrow) {
33
+ setSelectedIndex((prev: number) => Math.max(0, prev - 1));
34
+ }
35
+ if (key.downArrow) {
36
+ setSelectedIndex((prev: number) => Math.min(boards.length - 1, prev + 1));
37
+ }
38
+ if (key.return) {
39
+ if (boards[selectedIndex]) {
40
+ setSelectedBoardId(boards[selectedIndex].id);
41
+ }
42
+ }
43
+ });
44
+
45
+ if (selectedBoardId) {
46
+ return <KanbanBoard boardId={selectedBoardId} onBack={() => setSelectedBoardId(null)} />;
47
+ }
48
+
49
+ if (loading) {
50
+ return (
51
+ <Box>
52
+ <Text color="green"><Spinner type="dots" /></Text>
53
+ <Text> Loading Boards...</Text>
54
+ </Box>
55
+ );
56
+ }
57
+
58
+ if (error) {
59
+ return <Text color="red">Error: {error}</Text>;
60
+ }
61
+
62
+ if (boards.length === 0) {
63
+ return <Text>No boards found.</Text>;
64
+ }
65
+
66
+ const windowSize = 15;
67
+ const halfWindow = Math.floor(windowSize / 2);
68
+
69
+ // Ensure the selected item is always visible
70
+ let start = 0;
71
+ if (selectedIndex > halfWindow) {
72
+ start = Math.min(selectedIndex - halfWindow, boards.length - windowSize);
73
+ }
74
+ start = Math.max(0, start);
75
+ const end = Math.min(boards.length, start + windowSize);
76
+
77
+ const visibleBoards = boards.slice(start, end);
78
+
79
+ return (
80
+ <Box flexDirection="column">
81
+ <Box marginBottom={1}>
82
+ <Text bold underline>Select a Board ({selectedIndex + 1}/{boards.length})</Text>
83
+ </Box>
84
+
85
+ {visibleBoards.map((board: any, index: number) => {
86
+ const globalIndex = start + index;
87
+ const isSelected = globalIndex === selectedIndex;
88
+
89
+ return (
90
+ <Box key={board.id}>
91
+ <Text color={isSelected ? 'green' : 'white'} bold={isSelected}>
92
+ {isSelected ? '> ' : ' '}
93
+ </Text>
94
+ <Text color={isSelected ? 'green' : 'white'}>
95
+ {board.name} ({board.type})
96
+ </Text>
97
+ </Box>
98
+ );
99
+ })}
100
+ </Box>
101
+ );
102
+ }
@@ -0,0 +1,75 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import SpinnerOriginal from 'ink-spinner';
4
+ const Spinner = SpinnerOriginal as any;
5
+ import { api } from '../../services/api-service.js';
6
+
7
+ export default function Dashboard() {
8
+ const [loading, setLoading] = useState(true);
9
+ const [user, setUser] = useState<any>(null);
10
+ const [issueCount, setIssueCount] = useState<number>(0);
11
+ const [error, setError] = useState<string | null>(null);
12
+
13
+ useEffect(() => {
14
+ const fetchData = async () => {
15
+ try {
16
+ // Fetch issue count (maxResults must be >= 1)
17
+ const searchResults = await api.search(
18
+ `assignee = currentUser() AND resolution = Unresolved`,
19
+ 0,
20
+ 1
21
+ );
22
+ const myself = await api.get('/myself');
23
+
24
+ setUser(myself);
25
+ setIssueCount(searchResults.total);
26
+ setLoading(false);
27
+ } catch (err: any) {
28
+ // detailed error inspection
29
+ const msg = err.response
30
+ ? `API Error ${err.response.status}: ${JSON.stringify(err.response.data)}`
31
+ : err.message || JSON.stringify(err);
32
+ setError(msg);
33
+ setLoading(false);
34
+ }
35
+ };
36
+
37
+ fetchData();
38
+ }, []);
39
+
40
+ if (loading) {
41
+ return (
42
+ <Box>
43
+ <Text color="green"><Spinner type="dots" /></Text>
44
+ <Text> Loading Dashboard...</Text>
45
+ </Box>
46
+ );
47
+ }
48
+
49
+ if (error) {
50
+ return (
51
+ <Box flexDirection="column" borderColor="red" borderStyle="round" padding={1}>
52
+ <Text color="red" bold>Error Loading Dashboard:</Text>
53
+ <Text color="red">{error}</Text>
54
+ <Box marginTop={1}>
55
+ <Text color="gray">
56
+ Tip: Check your internet connection and run `jira config` to verify credentials.
57
+ </Text>
58
+ </Box>
59
+ </Box>
60
+ );
61
+ }
62
+
63
+ return (
64
+ <Box flexDirection="column">
65
+ <Text bold>Welcome back, {user?.displayName}!</Text>
66
+ <Box marginTop={1} borderStyle="single" padding={1} borderColor="yellow">
67
+ <Text>You have <Text bold color="red">{issueCount}</Text> unresolved issues assigned to you.</Text>
68
+ </Box>
69
+
70
+ <Box marginTop={1}>
71
+ <Text color="gray">Press ←/→ to navigate. Try the 'Issues' tab to see details.</Text>
72
+ </Box>
73
+ </Box>
74
+ );
75
+ }
@@ -0,0 +1,93 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { Box, Text, useInput } from 'ink';
3
+ import SpinnerOriginal from 'ink-spinner';
4
+ const Spinner = SpinnerOriginal as any;
5
+ import { api } from '../../services/api-service.js';
6
+ import { renderADF } from '../utils/adf-render.js';
7
+
8
+ interface IssueDetailProps {
9
+ issueKey: string;
10
+ onBack: () => void;
11
+ }
12
+
13
+ export default function IssueDetail({ issueKey, onBack }: IssueDetailProps) {
14
+ const [loading, setLoading] = useState(true);
15
+ const [issue, setIssue] = useState<any>(null);
16
+ const [error, setError] = useState<string | null>(null);
17
+
18
+ useEffect(() => {
19
+ const fetchIssue = async () => {
20
+ try {
21
+ const results = await api.get(`/issue/${issueKey}`);
22
+ setIssue(results);
23
+ setLoading(false);
24
+ } catch (err: any) {
25
+ setError(err.message);
26
+ setLoading(false);
27
+ }
28
+ };
29
+ fetchIssue();
30
+ }, [issueKey]);
31
+
32
+ useInput((input, key) => {
33
+ if (key.escape || input === 'b') {
34
+ onBack();
35
+ }
36
+ });
37
+
38
+ if (loading) {
39
+ return (
40
+ <Box>
41
+ <Text color="green"><Spinner type="dots" /></Text>
42
+ <Text> Loading Issue {issueKey}...</Text>
43
+ </Box>
44
+ );
45
+ }
46
+
47
+ if (error) {
48
+ return <Text color="red">Error: {error}</Text>;
49
+ }
50
+
51
+ const { summary, description, status, priority, assignee, reporter, comment } = issue.fields;
52
+
53
+ return (
54
+ <Box flexDirection="column" padding={1} borderStyle="single">
55
+ <Box marginBottom={1}>
56
+ <Text bold color="cyan">{issue.key}: {summary}</Text>
57
+ </Box>
58
+
59
+ <Box flexDirection="row" marginBottom={1}>
60
+ <Box width="50%">
61
+ <Text><Text bold>Status:</Text> {status.name}</Text>
62
+ <Text><Text bold>Priority:</Text> {priority.name}</Text>
63
+ </Box>
64
+ <Box width="50%">
65
+ <Text><Text bold>Assignee:</Text> {assignee ? assignee.displayName : 'Unassigned'}</Text>
66
+ <Text><Text bold>Reporter:</Text> {reporter ? reporter.displayName : 'Unknown'}</Text>
67
+ </Box>
68
+ </Box>
69
+
70
+
71
+ <Box marginBottom={1}>
72
+ <Text bold underline>Description:</Text>
73
+ <Text>{renderADF(description) || 'No description provided.'}</Text>
74
+ </Box>
75
+
76
+ {comment && comment.comments && comment.comments.length > 0 && (
77
+ <Box flexDirection="column" marginTop={1}>
78
+ <Text bold underline>Comments ({comment.total}):</Text>
79
+ {comment.comments.slice(-3).map((c: any) => (
80
+ <Box key={c.id} borderStyle="round" borderColor="gray" padding={1} marginTop={1}>
81
+ <Text bold color="blue">{c.author.displayName}:</Text>
82
+ <Text>{renderADF(c.body)}</Text>
83
+ </Box>
84
+ ))}
85
+ </Box>
86
+ )}
87
+
88
+ <Box marginTop={1}>
89
+ <Text color="gray">Press <Text bold>Esc</Text> or <Text bold>b</Text> to go back.</Text>
90
+ </Box>
91
+ </Box>
92
+ );
93
+ }