jira-pat 1.0.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 (95) hide show
  1. package/AGENTS.md +218 -0
  2. package/README.md +64 -0
  3. package/backend/.env.example +1 -0
  4. package/backend/__tests__/getJiraClient.test.js +57 -0
  5. package/backend/__tests__/issues.test.js +565 -0
  6. package/backend/__tests__/jiraService.test.js +1127 -0
  7. package/backend/__tests__/projects.test.js +256 -0
  8. package/backend/coverage/clover.xml +426 -0
  9. package/backend/coverage/coverage-final.json +4 -0
  10. package/backend/coverage/lcov-report/base.css +224 -0
  11. package/backend/coverage/lcov-report/block-navigation.js +87 -0
  12. package/backend/coverage/lcov-report/favicon.png +0 -0
  13. package/backend/coverage/lcov-report/index.html +131 -0
  14. package/backend/coverage/lcov-report/prettify.css +1 -0
  15. package/backend/coverage/lcov-report/prettify.js +2 -0
  16. package/backend/coverage/lcov-report/routes/index.html +131 -0
  17. package/backend/coverage/lcov-report/routes/issues.js.html +823 -0
  18. package/backend/coverage/lcov-report/routes/projects.js.html +190 -0
  19. package/backend/coverage/lcov-report/service/index.html +116 -0
  20. package/backend/coverage/lcov-report/service/jiraService.js.html +1663 -0
  21. package/backend/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  22. package/backend/coverage/lcov-report/sorter.js +210 -0
  23. package/backend/coverage/lcov.info +707 -0
  24. package/backend/index.js +38 -0
  25. package/backend/jest.config.js +11 -0
  26. package/backend/package-lock.json +5636 -0
  27. package/backend/package.json +28 -0
  28. package/backend/routes/issues.js +246 -0
  29. package/backend/routes/projects.js +35 -0
  30. package/backend/service/jiraService.js +526 -0
  31. package/bin/jira.js +92 -0
  32. package/frontend/.env.example +1 -0
  33. package/frontend/coverage/base.css +224 -0
  34. package/frontend/coverage/block-navigation.js +87 -0
  35. package/frontend/coverage/clover.xml +559 -0
  36. package/frontend/coverage/components/CreateIssueModal.jsx.html +592 -0
  37. package/frontend/coverage/components/IssueDetailPanel.jsx.html +1633 -0
  38. package/frontend/coverage/components/IssueDrawer.jsx.html +550 -0
  39. package/frontend/coverage/components/IssueTable.jsx.html +571 -0
  40. package/frontend/coverage/components/SkeletonComponents.jsx.html +223 -0
  41. package/frontend/coverage/components/ToastContainer.jsx.html +142 -0
  42. package/frontend/coverage/components/index.html +191 -0
  43. package/frontend/coverage/coverage-final.json +14 -0
  44. package/frontend/coverage/favicon.png +0 -0
  45. package/frontend/coverage/hooks/index.html +161 -0
  46. package/frontend/coverage/hooks/useFocusTrap.js.html +262 -0
  47. package/frontend/coverage/hooks/useIssueDrawer.js.html +1000 -0
  48. package/frontend/coverage/hooks/useIssuesList.js.html +175 -0
  49. package/frontend/coverage/hooks/useToasts.js.html +142 -0
  50. package/frontend/coverage/index.html +161 -0
  51. package/frontend/coverage/prettify.css +1 -0
  52. package/frontend/coverage/prettify.js +2 -0
  53. package/frontend/coverage/services/api.js.html +547 -0
  54. package/frontend/coverage/services/index.html +116 -0
  55. package/frontend/coverage/sort-arrow-sprite.png +0 -0
  56. package/frontend/coverage/sorter.js +210 -0
  57. package/frontend/coverage/utils/index.html +131 -0
  58. package/frontend/coverage/utils/issueHelpers.jsx.html +334 -0
  59. package/frontend/coverage/utils/sanitize.js.html +166 -0
  60. package/frontend/index.html +13 -0
  61. package/frontend/package-lock.json +3436 -0
  62. package/frontend/package.json +30 -0
  63. package/frontend/src/App.jsx +447 -0
  64. package/frontend/src/__tests__/components/CreateIssueModal.test.jsx +375 -0
  65. package/frontend/src/__tests__/components/IssueDetailPanel.test.jsx +962 -0
  66. package/frontend/src/__tests__/components/IssueDrawer.test.jsx +240 -0
  67. package/frontend/src/__tests__/components/IssueTable.test.jsx +423 -0
  68. package/frontend/src/__tests__/components/ToastContainer.test.jsx +196 -0
  69. package/frontend/src/__tests__/hooks/useFocusTrap.test.js +197 -0
  70. package/frontend/src/__tests__/hooks/useIssueDrawer.test.js +1053 -0
  71. package/frontend/src/__tests__/hooks/useIssuesList.test.js +175 -0
  72. package/frontend/src/__tests__/hooks/useToasts.test.js +110 -0
  73. package/frontend/src/__tests__/services/api.test.js +568 -0
  74. package/frontend/src/__tests__/setup.js +54 -0
  75. package/frontend/src/__tests__/utils/issueHelpers.test.jsx +336 -0
  76. package/frontend/src/__tests__/utils/sanitize.test.js +238 -0
  77. package/frontend/src/components/CreateIssueModal.jsx +169 -0
  78. package/frontend/src/components/ErrorBoundary.jsx +52 -0
  79. package/frontend/src/components/IssueDetailPanel.jsx +517 -0
  80. package/frontend/src/components/IssueDrawer.jsx +155 -0
  81. package/frontend/src/components/IssueTable.jsx +162 -0
  82. package/frontend/src/components/SkeletonComponents.jsx +46 -0
  83. package/frontend/src/components/StandaloneIssuePage.jsx +176 -0
  84. package/frontend/src/components/ToastContainer.jsx +19 -0
  85. package/frontend/src/hooks/useFocusTrap.js +59 -0
  86. package/frontend/src/hooks/useIssueDrawer.js +305 -0
  87. package/frontend/src/hooks/useIssuesList.js +30 -0
  88. package/frontend/src/hooks/useToasts.js +19 -0
  89. package/frontend/src/index.css +2070 -0
  90. package/frontend/src/main.jsx +13 -0
  91. package/frontend/src/services/api.js +154 -0
  92. package/frontend/src/utils/issueHelpers.jsx +84 -0
  93. package/frontend/src/utils/sanitize.js +27 -0
  94. package/frontend/vite.config.js +15 -0
  95. package/package.json +19 -0
@@ -0,0 +1,162 @@
1
+ import React, { Inbox } from 'react';
2
+ import { getIssueTypeIcon, getStatusClass, getSeverityIcon, formatSeverity, getInitials, SEVERITY_FIELD } from '../utils/issueHelpers.jsx';
3
+
4
+ function LoadingSkeleton() {
5
+ return (
6
+ <div className="table-container">
7
+ <table className="issue-table">
8
+ <thead>
9
+ <tr>
10
+ <th>Key</th>
11
+ <th>Summary</th>
12
+ <th>Status</th>
13
+ <th>Assignee</th>
14
+ <th>Severity</th>
15
+ </tr>
16
+ </thead>
17
+ <tbody>
18
+ {[...Array(5)].map((_, i) => (
19
+ <tr key={i} className="skeleton-row">
20
+ <td><div className="skeleton skeleton-cell" style={{ maxWidth: '100px' }}></div></td>
21
+ <td><div className="skeleton skeleton-cell"></div></td>
22
+ <td><div className="skeleton skeleton-cell" style={{ maxWidth: '100px' }}></div></td>
23
+ <td><div className="skeleton skeleton-avatar"></div></td>
24
+ <td><div className="skeleton skeleton-cell" style={{ maxWidth: '80px' }}></div></td>
25
+ </tr>
26
+ ))}
27
+ </tbody>
28
+ </table>
29
+ </div>
30
+ );
31
+ }
32
+
33
+ function EmptyState() {
34
+ return (
35
+ <div className="empty-state">
36
+ <Inbox size={48} className="empty-state-icon" />
37
+ <h3>No issues found</h3>
38
+ <p>Try adjusting your filters or create a new issue</p>
39
+ </div>
40
+ );
41
+ }
42
+
43
+ export default React.memo(function IssueTable({ issues, loading, onRowClick, currentPage = 0, pageSize = 25, total, onPageChange }) {
44
+ const startItem = total > 0 ? currentPage * pageSize + 1 : 0;
45
+ const endItem = Math.min((currentPage + 1) * pageSize, total);
46
+
47
+ if (loading) {
48
+ return <LoadingSkeleton />;
49
+ }
50
+
51
+ if (!loading && issues.length === 0) {
52
+ return <EmptyState />;
53
+ }
54
+
55
+ return (
56
+ <div className="table-container">
57
+ <table className="issue-table" role="grid">
58
+ <thead>
59
+ <tr>
60
+ <th>Key</th>
61
+ <th>Summary</th>
62
+ <th>Status</th>
63
+ <th>Assignee</th>
64
+ <th>Severity</th>
65
+ </tr>
66
+ </thead>
67
+ <tbody>
68
+ {[...issues]
69
+ .sort((a, b) => {
70
+ const aStatus = a.fields.status?.name?.toLowerCase() || '';
71
+ const bStatus = b.fields.status?.name?.toLowerCase() || '';
72
+ if (aStatus === 'backlog' && bStatus !== 'backlog') return 1;
73
+ if (aStatus !== 'backlog' && bStatus === 'backlog') return -1;
74
+ return 0;
75
+ })
76
+ .map((issue) => (
77
+ <tr
78
+ key={issue.id}
79
+ className="clickable-row"
80
+ onClick={() => onRowClick(issue.key)}
81
+ role="row"
82
+ tabIndex={0}
83
+ onKeyDown={(e) => e.key === 'Enter' && onRowClick(issue.key)}
84
+ >
85
+ <td>
86
+ <span className="issue-key">
87
+ {getIssueTypeIcon(issue.fields.issuetype?.name)}
88
+ {issue.key}
89
+ </span>
90
+ </td>
91
+ <td>
92
+ <span className="issue-summary" title={issue.fields.summary}>
93
+ {issue.fields.summary}
94
+ </span>
95
+ </td>
96
+ <td>
97
+ <span className={`status-badge ${getStatusClass(issue.fields.status?.name)}`}>
98
+ {issue.fields.status?.name || 'Unknown'}
99
+ </span>
100
+ </td>
101
+ <td>
102
+ <div className="assignee-cell">
103
+ {issue.fields.assignee ? (
104
+ <>
105
+ <span className="avatar">
106
+ {getInitials(issue.fields.assignee.displayName)}
107
+ </span>
108
+ <span>{issue.fields.assignee.displayName}</span>
109
+ </>
110
+ ) : (
111
+ <>
112
+ <span className="avatar placeholder">
113
+ {getInitials('Unassigned')}
114
+ </span>
115
+ <span>Unassigned</span>
116
+ </>
117
+ )}
118
+ </div>
119
+ </td>
120
+ <td>
121
+ {issue.fields[SEVERITY_FIELD] ? (
122
+ <span className={`severity-badge ${(issue.fields[SEVERITY_FIELD].value || issue.fields[SEVERITY_FIELD].name || '').toLowerCase().replace(/\s+/g, '-')}`}>
123
+ {getSeverityIcon(issue.fields[SEVERITY_FIELD].value || issue.fields[SEVERITY_FIELD].name)}
124
+ {formatSeverity(issue.fields[SEVERITY_FIELD].value || issue.fields[SEVERITY_FIELD].name)}
125
+ </span>
126
+ ) : (
127
+ <span style={{ color: 'var(--text-muted)', fontSize: '13px' }}>—</span>
128
+ )}
129
+ </td>
130
+ </tr>
131
+ ))}
132
+ </tbody>
133
+ </table>
134
+ {total !== undefined && total > 0 && (
135
+ <div className="pagination-bar">
136
+ <span className="pagination-info">
137
+ Showing {startItem}–{endItem} of {total}
138
+ </span>
139
+ <div className="pagination-controls">
140
+ <button
141
+ className="pagination-btn"
142
+ onClick={() => onPageChange(currentPage - 1)}
143
+ disabled={currentPage === 0}
144
+ >
145
+ Previous
146
+ </button>
147
+ <span className="pagination-page">Page {currentPage + 1}</span>
148
+ <button
149
+ className="pagination-btn"
150
+ onClick={() => onPageChange(currentPage + 1)}
151
+ disabled={endItem >= total}
152
+ >
153
+ Next
154
+ </button>
155
+ </div>
156
+ </div>
157
+ )}
158
+ </div>
159
+ );
160
+ });
161
+
162
+ export { getIssueTypeIcon, getStatusClass, getSeverityIcon, formatSeverity, getInitials, SEVERITY_FIELD } from '../utils/issueHelpers.jsx';
@@ -0,0 +1,46 @@
1
+ import React from 'react';
2
+
3
+ export function SkeletonTitle({ width = '60%' }) {
4
+ return <div className="skeleton skeleton-title" style={{ width }} />;
5
+ }
6
+
7
+ export function SkeletonText({ lines = 3, lastLineWidth = '60%' }) {
8
+ return (
9
+ <>
10
+ {Array.from({ length: lines }).map((_, i) => (
11
+ <div
12
+ key={i}
13
+ className="skeleton skeleton-line"
14
+ style={{ width: i === lines - 1 ? lastLineWidth : '100%' }}
15
+ />
16
+ ))}
17
+ </>
18
+ );
19
+ }
20
+
21
+ export function SkeletonAvatar({ size = 32 }) {
22
+ return (
23
+ <div
24
+ className="skeleton skeleton-avatar"
25
+ style={{ width: size, height: size }}
26
+ />
27
+ );
28
+ }
29
+
30
+ export function SkeletonChip({ width = 80 }) {
31
+ return (
32
+ <div
33
+ className="skeleton skeleton-chip"
34
+ style={{ width }}
35
+ />
36
+ );
37
+ }
38
+
39
+ export function SkeletonSidebarItem() {
40
+ return (
41
+ <div className="skeleton-sidebar-item">
42
+ <div className="skeleton" style={{ height: 14, width: '30%', marginBottom: 4 }} />
43
+ <div className="skeleton" style={{ height: 20, width: '50%' }} />
44
+ </div>
45
+ );
46
+ }
@@ -0,0 +1,176 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { useIssueDrawer } from '../hooks/useIssueDrawer';
3
+ import IssueDetailPanel from './IssueDetailPanel';
4
+ import { useToasts } from '../hooks/useToasts';
5
+ import {
6
+ X, Loader2, AlertCircle, Link2, Calendar, Package,
7
+ PlusCircle, ArrowLeft
8
+ } from 'lucide-react';
9
+
10
+ export default function StandaloneIssuePage() {
11
+ const [issueKey, setIssueKey] = useState(null);
12
+ const { toasts, addToast, removeToast } = useToasts();
13
+ const {
14
+ issueDetail,
15
+ comments,
16
+ transitions,
17
+ assignableUsers,
18
+ drawerLoading,
19
+ drawerError,
20
+ newCommentText,
21
+ setNewCommentText,
22
+ isPostingComment,
23
+ targetTransitionId,
24
+ setTargetTransitionId,
25
+ isTransitioning,
26
+ assigneeSearch,
27
+ setAssigneeSearch,
28
+ targetAccountId,
29
+ setTargetAccountId,
30
+ isAssigning,
31
+ projectVersions,
32
+ isUpdatingVersions,
33
+ newLabelText,
34
+ setNewLabelText,
35
+ labelSuggestions,
36
+ showLabelSuggestions,
37
+ setShowLabelSuggestions,
38
+ isUpdatingLabels,
39
+ isUploading,
40
+ uploadMessage,
41
+ handlePostComment,
42
+ handleAssignUser,
43
+ handleFileUpload,
44
+ handleUpdateVersion,
45
+ handleAddLabel,
46
+ handleRemoveLabel,
47
+ handleUpdateStatus
48
+ } = useIssueDrawer(issueKey, addToast);
49
+
50
+ useEffect(() => {
51
+ const loadFromHash = () => {
52
+ const hash = window.location.hash;
53
+ if (hash.startsWith('#/view/')) {
54
+ const key = hash.replace('#/view/', '');
55
+ if (key && key !== issueKey) {
56
+ setIssueKey(key);
57
+ }
58
+ }
59
+ };
60
+
61
+ loadFromHash();
62
+ window.addEventListener('hashchange', loadFromHash);
63
+ window.addEventListener('popstate', loadFromHash);
64
+
65
+ return () => {
66
+ window.removeEventListener('hashchange', loadFromHash);
67
+ window.removeEventListener('popstate', loadFromHash);
68
+ };
69
+ }, [issueKey]);
70
+
71
+ if (drawerLoading) {
72
+ return (
73
+ <div className="standalone-page">
74
+ <div className="standalone-issue-container">
75
+ <header className="standalone-header">
76
+ <a href="/" className="standalone-back-btn">
77
+ <ArrowLeft size={18} />
78
+ Back to Dashboard
79
+ </a>
80
+ </header>
81
+ <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
82
+ <Loader2 size={32} style={{ animation: 'spin 1s linear infinite' }} />
83
+ <span style={{ marginLeft: '12px', fontSize: '16px', color: '#666' }}>Loading issue...</span>
84
+ </div>
85
+ </div>
86
+ </div>
87
+ );
88
+ }
89
+
90
+ if (drawerError || !issueDetail) {
91
+ return (
92
+ <div className="standalone-page">
93
+ <div className="standalone-issue-container">
94
+ <header className="standalone-header">
95
+ <a href="/" className="standalone-back-btn">
96
+ <ArrowLeft size={18} />
97
+ Back to Dashboard
98
+ </a>
99
+ </header>
100
+ <div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '16px' }}>
101
+ <AlertCircle size={48} style={{ color: '#de350b' }} />
102
+ <h2 style={{ margin: 0, color: 'var(--text-main)' }}>{drawerError || 'Issue not found'}</h2>
103
+ <a href="/" className="btn-primary" style={{ textDecoration: 'none', padding: '12px 24px' }}>
104
+ Back to Dashboard
105
+ </a>
106
+ </div>
107
+ </div>
108
+ </div>
109
+ );
110
+ }
111
+
112
+ return (
113
+ <div className="standalone-page">
114
+ <div className="standalone-issue-container">
115
+ <header className="standalone-header">
116
+ <a href="/" className="standalone-back-btn">
117
+ <ArrowLeft size={18} />
118
+ Back to Dashboard
119
+ </a>
120
+ <div className="drawer-header-actions">
121
+ <button
122
+ className="action-btn"
123
+ onClick={() => {
124
+ navigator.clipboard.writeText(window.location.href);
125
+ addToast('Link copied to clipboard', 'success');
126
+ }}
127
+ aria-label="Copy link"
128
+ >
129
+ <Link2 size={18} />
130
+ </button>
131
+ </div>
132
+ </header>
133
+
134
+ <div className="drawer-body">
135
+ <IssueDetailPanel
136
+ mode="standalone"
137
+ issueDetail={issueDetail}
138
+ comments={comments}
139
+ transitions={transitions}
140
+ assignableUsers={assignableUsers}
141
+ projectVersions={projectVersions}
142
+ drawerLoading={drawerLoading}
143
+ drawerError={drawerError}
144
+ newCommentText={newCommentText}
145
+ setNewCommentText={setNewCommentText}
146
+ isPostingComment={isPostingComment}
147
+ targetTransitionId={targetTransitionId}
148
+ setTargetTransitionId={setTargetTransitionId}
149
+ isTransitioning={isTransitioning}
150
+ assigneeSearch={assigneeSearch}
151
+ setAssigneeSearch={setAssigneeSearch}
152
+ targetAccountId={targetAccountId}
153
+ setTargetAccountId={setTargetAccountId}
154
+ isAssigning={isAssigning}
155
+ isUpdatingVersions={isUpdatingVersions}
156
+ newLabelText={newLabelText}
157
+ setNewLabelText={setNewLabelText}
158
+ labelSuggestions={labelSuggestions}
159
+ showLabelSuggestions={showLabelSuggestions}
160
+ setShowLabelSuggestions={setShowLabelSuggestions}
161
+ isUpdatingLabels={isUpdatingLabels}
162
+ isUploading={isUploading}
163
+ uploadMessage={uploadMessage}
164
+ handlePostComment={handlePostComment}
165
+ handleAssignUser={handleAssignUser}
166
+ handleFileUpload={handleFileUpload}
167
+ handleUpdateVersion={handleUpdateVersion}
168
+ handleAddLabel={handleAddLabel}
169
+ handleRemoveLabel={handleRemoveLabel}
170
+ handleUpdateStatus={handleUpdateStatus}
171
+ />
172
+ </div>
173
+ </div>
174
+ </div>
175
+ );
176
+ }
@@ -0,0 +1,19 @@
1
+ import { CheckCircle, AlertCircle, Info, X } from 'lucide-react';
2
+
3
+ export default function ToastContainer({ toasts, onRemove }) {
4
+ return (
5
+ <div className="toast-container" aria-live="polite">
6
+ {toasts.map(toast => (
7
+ <div key={toast.id} className={`toast ${toast.type}`} role="alert">
8
+ {toast.type === 'success' && <CheckCircle size={18} />}
9
+ {toast.type === 'error' && <AlertCircle size={18} />}
10
+ {toast.type === 'info' && <Info size={18} />}
11
+ <span>{toast.message}</span>
12
+ <button className="toast-close" onClick={() => onRemove(toast.id)} aria-label="Dismiss">
13
+ <X size={16} />
14
+ </button>
15
+ </div>
16
+ ))}
17
+ </div>
18
+ );
19
+ }
@@ -0,0 +1,59 @@
1
+ import { useEffect, useRef } from 'react';
2
+
3
+ const FOCUSABLE_SELECTORS = [
4
+ 'a[href]',
5
+ 'button:not([disabled])',
6
+ 'textarea:not([disabled])',
7
+ 'input:not([disabled])',
8
+ 'select:not([disabled])',
9
+ '[tabindex]:not([tabindex="-1"])',
10
+ ].join(', ');
11
+
12
+ export function useFocusTrap(containerRef, isActive) {
13
+ const previousActiveElement = useRef(null);
14
+
15
+ useEffect(() => {
16
+ if (!isActive || !containerRef.current) return;
17
+
18
+ const container = containerRef.current;
19
+ previousActiveElement.current = document.activeElement;
20
+
21
+ const focusableElements = container.querySelectorAll(FOCUSABLE_SELECTORS);
22
+ const firstElement = focusableElements[0];
23
+
24
+ if (firstElement) {
25
+ firstElement.focus();
26
+ }
27
+
28
+ const handleKeyDown = (e) => {
29
+ if (e.key !== 'Tab') return;
30
+
31
+ const focusableInTrap = container.querySelectorAll(FOCUSABLE_SELECTORS);
32
+ if (focusableInTrap.length === 0) return;
33
+
34
+ const first = focusableInTrap[0];
35
+ const last = focusableInTrap[focusableInTrap.length - 1];
36
+
37
+ if (e.shiftKey) {
38
+ if (document.activeElement === first) {
39
+ e.preventDefault();
40
+ last.focus();
41
+ }
42
+ } else {
43
+ if (document.activeElement === last) {
44
+ e.preventDefault();
45
+ first.focus();
46
+ }
47
+ }
48
+ };
49
+
50
+ container.addEventListener('keydown', handleKeyDown);
51
+
52
+ return () => {
53
+ container.removeEventListener('keydown', handleKeyDown);
54
+ if (previousActiveElement.current && previousActiveElement.current.focus) {
55
+ previousActiveElement.current.focus();
56
+ }
57
+ };
58
+ }, [isActive, containerRef]);
59
+ }