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.
- package/README.md +54 -0
- package/bin/jira.ts +2 -0
- package/dist/bin/jira.js +2 -0
- package/dist/bin/jira.js.map +1 -1
- package/dist/src/commands/ai-actions/plan.js +1 -1
- package/dist/src/commands/ai-actions/plan.js.map +1 -1
- package/dist/src/commands/ai-actions/review.js +5 -4
- package/dist/src/commands/ai-actions/review.js.map +1 -1
- package/dist/src/commands/ai-actions/standup.js +1 -1
- package/dist/src/commands/ai-actions/standup.js.map +1 -1
- package/dist/src/commands/ai.js +1 -1
- package/dist/src/commands/ai.js.map +1 -1
- package/dist/src/commands/board.js +10 -5
- package/dist/src/commands/board.js.map +1 -1
- package/dist/src/commands/bulk.js +11 -10
- package/dist/src/commands/bulk.js.map +1 -1
- package/dist/src/commands/config.js +1 -1
- package/dist/src/commands/config.js.map +1 -1
- package/dist/src/commands/dashboard.js +19 -12
- package/dist/src/commands/dashboard.js.map +1 -1
- package/dist/src/commands/filter.js +7 -4
- package/dist/src/commands/filter.js.map +1 -1
- package/dist/src/commands/git.js +1 -1
- package/dist/src/commands/git.js.map +1 -1
- package/dist/src/commands/issue-attach.js +1 -1
- package/dist/src/commands/issue-attach.js.map +1 -1
- package/dist/src/commands/issue-pr.js +1 -1
- package/dist/src/commands/issue-pr.js.map +1 -1
- package/dist/src/commands/issue-worklog.js +10 -5
- package/dist/src/commands/issue-worklog.js.map +1 -1
- package/dist/src/commands/issue.js +173 -122
- package/dist/src/commands/issue.js.map +1 -1
- package/dist/src/commands/project.js +10 -5
- package/dist/src/commands/project.js.map +1 -1
- package/dist/src/commands/sprint.js +19 -8
- package/dist/src/commands/sprint.js.map +1 -1
- package/dist/src/commands/tui.d.ts +2 -0
- package/dist/src/commands/tui.js +10 -0
- package/dist/src/commands/tui.js.map +1 -0
- package/dist/src/server/mcp-server.js +209 -27
- package/dist/src/server/mcp-server.js.map +1 -1
- package/dist/src/services/ai-service.js +7 -4
- package/dist/src/services/ai-service.js.map +1 -1
- package/dist/src/services/api-service.d.ts +2 -0
- package/dist/src/services/api-service.js +32 -20
- package/dist/src/services/api-service.js.map +1 -1
- package/dist/src/tui/App.d.ts +1 -0
- package/dist/src/tui/App.js +26 -0
- package/dist/src/tui/App.js.map +1 -0
- package/dist/src/tui/index.d.ts +1 -0
- package/dist/src/tui/index.js +8 -0
- package/dist/src/tui/index.js.map +1 -0
- package/dist/src/tui/screens/BoardList.d.ts +1 -0
- package/dist/src/tui/screens/BoardList.js +71 -0
- package/dist/src/tui/screens/BoardList.js.map +1 -0
- package/dist/src/tui/screens/Dashboard.d.ts +1 -0
- package/dist/src/tui/screens/Dashboard.js +41 -0
- package/dist/src/tui/screens/Dashboard.js.map +1 -0
- package/dist/src/tui/screens/IssueDetail.d.ts +6 -0
- package/dist/src/tui/screens/IssueDetail.js +40 -0
- package/dist/src/tui/screens/IssueDetail.js.map +1 -0
- package/dist/src/tui/screens/IssueList.d.ts +1 -0
- package/dist/src/tui/screens/IssueList.js +72 -0
- package/dist/src/tui/screens/IssueList.js.map +1 -0
- package/dist/src/tui/screens/KanbanBoard.d.ts +6 -0
- package/dist/src/tui/screens/KanbanBoard.js +86 -0
- package/dist/src/tui/screens/KanbanBoard.js.map +1 -0
- package/dist/src/tui/utils/adf-render.d.ts +1 -0
- package/dist/src/tui/utils/adf-render.js +29 -0
- package/dist/src/tui/utils/adf-render.js.map +1 -0
- package/dist/src/utils/api-paths.d.ts +31 -0
- package/dist/src/utils/api-paths.js +32 -0
- package/dist/src/utils/api-paths.js.map +1 -0
- package/dist/src/utils/error-handler.d.ts +2 -2
- package/dist/src/utils/error-handler.js.map +1 -1
- package/dist/src/utils/http.d.ts +27 -0
- package/dist/src/utils/http.js +95 -0
- package/dist/src/utils/http.js.map +1 -0
- package/dist/src/utils/spinner.d.ts +21 -0
- package/dist/src/utils/spinner.js +79 -0
- package/dist/src/utils/spinner.js.map +1 -0
- package/package.json +10 -5
- package/src/commands/ai-actions/plan.ts +1 -1
- package/src/commands/ai-actions/review.ts +5 -4
- package/src/commands/ai-actions/standup.ts +1 -1
- package/src/commands/ai.ts +1 -1
- package/src/commands/board.ts +10 -5
- package/src/commands/bulk.ts +11 -10
- package/src/commands/config.ts +1 -1
- package/src/commands/dashboard.ts +20 -12
- package/src/commands/filter.ts +8 -5
- package/src/commands/git.ts +1 -1
- package/src/commands/issue-attach.ts +1 -1
- package/src/commands/issue-pr.ts +1 -1
- package/src/commands/issue-worklog.ts +10 -5
- package/src/commands/issue.ts +181 -124
- package/src/commands/project.ts +10 -5
- package/src/commands/sprint.ts +19 -8
- package/src/commands/tui.ts +11 -0
- package/src/server/mcp-server.ts +234 -27
- package/src/services/ai-service.ts +7 -4
- package/src/services/api-service.ts +34 -21
- package/src/tui/App.tsx +61 -0
- package/src/tui/index.tsx +8 -0
- package/src/tui/screens/BoardList.tsx +102 -0
- package/src/tui/screens/Dashboard.tsx +75 -0
- package/src/tui/screens/IssueDetail.tsx +93 -0
- package/src/tui/screens/IssueList.tsx +116 -0
- package/src/tui/screens/KanbanBoard.tsx +133 -0
- package/src/tui/utils/adf-render.ts +30 -0
- package/src/utils/api-paths.ts +32 -0
- package/src/utils/error-handler.ts +2 -2
- package/src/utils/http.ts +128 -0
- package/src/utils/spinner.ts +87 -0
package/src/tui/App.tsx
ADDED
|
@@ -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,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
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
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 IssueDetail from './IssueDetail.js';
|
|
7
|
+
|
|
8
|
+
export default function IssueList() {
|
|
9
|
+
const [loading, setLoading] = useState(true);
|
|
10
|
+
const [issues, setIssues] = useState<any[]>([]);
|
|
11
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
12
|
+
const [selectedIssueKey, setSelectedIssueKey] = useState<string | null>(null);
|
|
13
|
+
const [error, setError] = useState<string | null>(null);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
const fetchIssues = async () => {
|
|
17
|
+
try {
|
|
18
|
+
// Default search: Assigned to me or reported by me, unresolved
|
|
19
|
+
const results = await api.search('assignee = currentUser() OR reporter = currentUser() AND resolution = Unresolved order by updated DESC', 0, 20);
|
|
20
|
+
setIssues(results.issues);
|
|
21
|
+
setLoading(false);
|
|
22
|
+
} catch (err: any) {
|
|
23
|
+
setError(err.message);
|
|
24
|
+
setLoading(false);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
fetchIssues();
|
|
28
|
+
}, []);
|
|
29
|
+
|
|
30
|
+
useInput((input, key) => {
|
|
31
|
+
if (selectedIssueKey) return; // Let Detail view handle input if active
|
|
32
|
+
|
|
33
|
+
if (key.upArrow) {
|
|
34
|
+
setSelectedIndex((prev: number) => Math.max(0, prev - 1));
|
|
35
|
+
}
|
|
36
|
+
if (key.downArrow) {
|
|
37
|
+
setSelectedIndex((prev: number) => Math.min(issues.length - 1, prev + 1));
|
|
38
|
+
}
|
|
39
|
+
if (key.return) {
|
|
40
|
+
if (issues[selectedIndex]) {
|
|
41
|
+
setSelectedIssueKey(issues[selectedIndex].key);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
if (selectedIssueKey) {
|
|
47
|
+
return <IssueDetail issueKey={selectedIssueKey} onBack={() => setSelectedIssueKey(null)} />;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (loading) {
|
|
51
|
+
return (
|
|
52
|
+
<Box>
|
|
53
|
+
<Text color="green"><Spinner type="dots" /></Text>
|
|
54
|
+
<Text> Loading Issues...</Text>
|
|
55
|
+
</Box>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
// ... rest of the file ...
|
|
59
|
+
|
|
60
|
+
if (error) {
|
|
61
|
+
return <Text color="red">Error: {error}</Text>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (issues.length === 0) {
|
|
65
|
+
return <Text>No issues found.</Text>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Pagination/Windowing logic could go here, for now just slice a window around selected
|
|
69
|
+
const windowSize = 10;
|
|
70
|
+
const start = Math.max(0, selectedIndex - Math.floor(windowSize / 2));
|
|
71
|
+
const end = Math.min(issues.length, start + windowSize);
|
|
72
|
+
const visibleIssues = issues.slice(start, end);
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<Box flexDirection="column">
|
|
76
|
+
<Box marginBottom={1}>
|
|
77
|
+
<Text bold underline>Issue Navigator ({issues.length})</Text>
|
|
78
|
+
</Box>
|
|
79
|
+
|
|
80
|
+
{visibleIssues.map((issue: any, index: number) => {
|
|
81
|
+
const globalIndex = start + index;
|
|
82
|
+
const isSelected = globalIndex === selectedIndex;
|
|
83
|
+
const key = issue.key;
|
|
84
|
+
const summary = issue.fields.summary;
|
|
85
|
+
const status = issue.fields.status.name;
|
|
86
|
+
const priority = issue.fields.priority.name;
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<Box key={issue.id}>
|
|
90
|
+
<Text color={isSelected ? 'green' : 'white'} bold={isSelected}>
|
|
91
|
+
{isSelected ? '> ' : ' '}
|
|
92
|
+
</Text>
|
|
93
|
+
<Box width={12}>
|
|
94
|
+
<Text color="cyan">{key}</Text>
|
|
95
|
+
</Box>
|
|
96
|
+
<Box width={12}>
|
|
97
|
+
<Text color="yellow">{status}</Text>
|
|
98
|
+
</Box>
|
|
99
|
+
<Box width={10}>
|
|
100
|
+
<Text color="magenta">{priority}</Text>
|
|
101
|
+
</Box>
|
|
102
|
+
<Box flexGrow={1}>
|
|
103
|
+
<Text wrap="truncate-end">{summary}</Text>
|
|
104
|
+
</Box>
|
|
105
|
+
</Box>
|
|
106
|
+
);
|
|
107
|
+
})}
|
|
108
|
+
|
|
109
|
+
<Box marginTop={1} borderStyle="single" borderColor="gray">
|
|
110
|
+
<Text color="gray">
|
|
111
|
+
Selected: {issues[selectedIndex]?.key} - {issues[selectedIndex]?.fields?.summary}
|
|
112
|
+
</Text>
|
|
113
|
+
</Box>
|
|
114
|
+
</Box>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
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
|
+
|
|
7
|
+
interface KanbanBoardProps {
|
|
8
|
+
boardId: string;
|
|
9
|
+
onBack: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default function KanbanBoard({ boardId, onBack }: KanbanBoardProps) {
|
|
13
|
+
const [loading, setLoading] = useState(true);
|
|
14
|
+
const [columns, setColumns] = useState<any[]>([]);
|
|
15
|
+
const [issues, setIssues] = useState<Record<string, any[]>>({});
|
|
16
|
+
const [activeColumnIndex, setActiveColumnIndex] = useState(0);
|
|
17
|
+
const [error, setError] = useState<string | null>(null);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const fetchData = async () => {
|
|
21
|
+
try {
|
|
22
|
+
// parallel fetch
|
|
23
|
+
const [config, boardIssues] = await Promise.all([
|
|
24
|
+
api.agileGet(`/board/${boardId}/configuration`),
|
|
25
|
+
api.agileGet(`/board/${boardId}/issue`)
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const cols = config.columnConfig.columns;
|
|
29
|
+
setColumns(cols);
|
|
30
|
+
|
|
31
|
+
// Group issues by status/column
|
|
32
|
+
// This is simplified mapping logic. Jira statuses map to columns.
|
|
33
|
+
const issuesByStatus: Record<string, any[]> = {};
|
|
34
|
+
cols.forEach((col: any) => {
|
|
35
|
+
issuesByStatus[col.name] = [];
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
boardIssues.issues.forEach((issue: any) => {
|
|
39
|
+
const statusName = issue.fields.status.name;
|
|
40
|
+
// Find which column this status belongs to involves checking config.columnConfig.columns[i].statuses
|
|
41
|
+
// For now, let's just try to match column name or push to 'Other'
|
|
42
|
+
let placed = false;
|
|
43
|
+
for (const col of cols) {
|
|
44
|
+
// Check if status in column statuses
|
|
45
|
+
// config.columnConfig.columns structure: { name: 'To Do', statuses: [ { id: '10000', self: '...' } ] }
|
|
46
|
+
if (col.statuses && col.statuses.some((s: any) => s.id === issue.fields.status.id)) {
|
|
47
|
+
if (!issuesByStatus[col.name]) issuesByStatus[col.name] = [];
|
|
48
|
+
issuesByStatus[col.name].push(issue);
|
|
49
|
+
placed = true;
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (!placed) {
|
|
54
|
+
// fallback: try direct name match
|
|
55
|
+
if (issuesByStatus[statusName]) {
|
|
56
|
+
issuesByStatus[statusName].push(issue);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
setIssues(issuesByStatus);
|
|
62
|
+
setLoading(false);
|
|
63
|
+
} catch (err: any) {
|
|
64
|
+
setError(err.message);
|
|
65
|
+
setLoading(false);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
fetchData();
|
|
69
|
+
}, [boardId]);
|
|
70
|
+
|
|
71
|
+
useInput((input, key) => {
|
|
72
|
+
if (key.escape || input === 'b') {
|
|
73
|
+
onBack();
|
|
74
|
+
}
|
|
75
|
+
if (key.leftArrow) {
|
|
76
|
+
setActiveColumnIndex((prev: number) => Math.max(0, prev - 1));
|
|
77
|
+
}
|
|
78
|
+
if (key.rightArrow) {
|
|
79
|
+
setActiveColumnIndex((prev: number) => Math.min(columns.length - 1, prev + 1));
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
if (loading) {
|
|
84
|
+
return (
|
|
85
|
+
<Box>
|
|
86
|
+
<Text color="green"><Spinner type="dots" /></Text>
|
|
87
|
+
<Text> Loading Board...</Text>
|
|
88
|
+
</Box>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (error) {
|
|
93
|
+
return <Text color="red">Error: {error}</Text>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<Box flexDirection="column" height="100%">
|
|
98
|
+
<Box marginBottom={1}>
|
|
99
|
+
<Text>Board View (Use ←/→ to switch columns, Esc to back)</Text>
|
|
100
|
+
</Box>
|
|
101
|
+
|
|
102
|
+
<Box flexDirection="row" flexGrow={1}>
|
|
103
|
+
{columns.map((col: any, index: number) => {
|
|
104
|
+
const isActive = index === activeColumnIndex;
|
|
105
|
+
const colIssues = issues[col.name] || [];
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<Box
|
|
109
|
+
key={col.name}
|
|
110
|
+
flexDirection="column"
|
|
111
|
+
width={30}
|
|
112
|
+
borderStyle={isActive ? 'double' : 'single'}
|
|
113
|
+
borderColor={isActive ? 'green' : 'gray'}
|
|
114
|
+
marginRight={1}
|
|
115
|
+
paddingX={1}
|
|
116
|
+
>
|
|
117
|
+
<Box marginBottom={1} borderStyle="single" borderBottom={false} borderLeft={false} borderRight={false} borderTop={false}>
|
|
118
|
+
<Text bold underline color={isActive ? 'green' : 'white'}>{col.name} ({colIssues.length})</Text>
|
|
119
|
+
</Box>
|
|
120
|
+
|
|
121
|
+
{colIssues.slice(0, 10).map((issue: any) => ( // Limit simplified list
|
|
122
|
+
<Box key={issue.id} marginBottom={1}>
|
|
123
|
+
<Text color="cyan">{issue.key}</Text>
|
|
124
|
+
<Text wrap="truncate">{issue.fields.summary}</Text>
|
|
125
|
+
</Box>
|
|
126
|
+
))}
|
|
127
|
+
</Box>
|
|
128
|
+
);
|
|
129
|
+
})}
|
|
130
|
+
</Box>
|
|
131
|
+
</Box>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
|
|
2
|
+
export function renderADF(node: any): string {
|
|
3
|
+
if (!node) return '';
|
|
4
|
+
if (typeof node === 'string') return node;
|
|
5
|
+
|
|
6
|
+
// Handle text nodes
|
|
7
|
+
if (node.type === 'text') {
|
|
8
|
+
return node.text || '';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Handle content arrays
|
|
12
|
+
if (node.content && Array.isArray(node.content)) {
|
|
13
|
+
return node.content.map(renderADF).join('');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Handle specific block types (optional formatting)
|
|
17
|
+
switch (node.type) {
|
|
18
|
+
case 'paragraph':
|
|
19
|
+
return renderADF({ content: node.content }) + '\n';
|
|
20
|
+
case 'bulletList':
|
|
21
|
+
case 'orderedList':
|
|
22
|
+
return renderADF({ content: node.content });
|
|
23
|
+
case 'listItem':
|
|
24
|
+
return '• ' + renderADF({ content: node.content });
|
|
25
|
+
case 'hardBreak':
|
|
26
|
+
return '\n';
|
|
27
|
+
default:
|
|
28
|
+
return '';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
|
|
2
|
+
export const API = {
|
|
3
|
+
SEARCH: {
|
|
4
|
+
JQL: '/search/jql',
|
|
5
|
+
},
|
|
6
|
+
ISSUE: {
|
|
7
|
+
BASE: '/issue',
|
|
8
|
+
GET: (key: string) => `/issue/${key}`,
|
|
9
|
+
TRANSITIONS: (key: string) => `/issue/${key}/transitions`,
|
|
10
|
+
COMMENT: (key: string) => `/issue/${key}/comment`,
|
|
11
|
+
ASSIGNEE: (key: string) => `/issue/${key}/assignee`,
|
|
12
|
+
WORKLOG: (key: string) => `/issue/${key}/worklog`,
|
|
13
|
+
ATTACHMENTS: (key: string) => `/issue/${key}/attachments`,
|
|
14
|
+
CREATEMETA: (projectKey: string) => `/issue/createmeta/${projectKey}/issuetypes`,
|
|
15
|
+
},
|
|
16
|
+
PROJECT: {
|
|
17
|
+
SEARCH: '/project/search',
|
|
18
|
+
GET: (key: string) => `/project/${key}`,
|
|
19
|
+
COMPONENTS: (key: string) => `/project/${key}/components`,
|
|
20
|
+
VERSIONS: (key: string) => `/project/${key}/versions`,
|
|
21
|
+
},
|
|
22
|
+
USER: {
|
|
23
|
+
MYSELF: '/myself',
|
|
24
|
+
SEARCH: '/user/search',
|
|
25
|
+
},
|
|
26
|
+
PRIORITY: {
|
|
27
|
+
ALL: '/priority',
|
|
28
|
+
},
|
|
29
|
+
SERVER: {
|
|
30
|
+
INFO: '/serverInfo',
|
|
31
|
+
}
|
|
32
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import {
|
|
2
|
+
import { Spinner } from './spinner.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Standardized error handler for CLI commands.
|
|
@@ -9,7 +9,7 @@ import { Ora } from 'ora';
|
|
|
9
9
|
* @param {Error} error - The error object
|
|
10
10
|
* @param {string} [context] - Optional context (e.g., "Failed to list issues")
|
|
11
11
|
*/
|
|
12
|
-
export function handleCommandError(spinner:
|
|
12
|
+
export function handleCommandError(spinner: Spinner | null, error: any, context = 'Operation failed') {
|
|
13
13
|
// Handle user cancellation (Ctrl+C in enquirer)
|
|
14
14
|
if (error === '' || (error && error.message === '')) {
|
|
15
15
|
if (spinner) spinner.stop();
|