hale-commenting-system 3.5.2 → 3.7.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 +187 -8
- package/package.json +1 -1
- package/src/app/commenting-system/components/CommentOverlay.tsx +282 -14
- package/src/app/commenting-system/components/CommentPanel.tsx +67 -12
- package/src/app/commenting-system/components/CommentPin.tsx +87 -5
- package/src/app/commenting-system/components/FloatingWidget.tsx +79 -9
- package/src/app/commenting-system/components/JiraTab.tsx +379 -164
- package/src/app/commenting-system/contexts/CommentContext.tsx +77 -33
- package/src/app/commenting-system/index.ts +4 -1
- package/src/app/commenting-system/services/githubAdapter.ts +9 -2
- package/src/app/commenting-system/types/index.ts +14 -2
- package/src/app/commenting-system/utils/componentUtils.ts +242 -0
- package/src/app/commenting-system/utils/selectorUtils.ts +155 -0
|
@@ -30,7 +30,16 @@ type JiraTicket = {
|
|
|
30
30
|
};
|
|
31
31
|
|
|
32
32
|
interface JiraRecord {
|
|
33
|
-
|
|
33
|
+
jiraKeys: string[];
|
|
34
|
+
scope: JiraScope;
|
|
35
|
+
anchorRoute: string;
|
|
36
|
+
updatedAt: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Legacy format for backward compatibility
|
|
40
|
+
interface LegacyJiraRecord {
|
|
41
|
+
jiraKey?: string;
|
|
42
|
+
jiraKeys?: string[];
|
|
34
43
|
scope: JiraScope;
|
|
35
44
|
anchorRoute: string;
|
|
36
45
|
updatedAt: string;
|
|
@@ -38,15 +47,53 @@ interface JiraRecord {
|
|
|
38
47
|
|
|
39
48
|
type JiraStore = Record<string, JiraRecord>;
|
|
40
49
|
|
|
50
|
+
// Jira Issue Cache
|
|
51
|
+
type CachedJiraIssue = {
|
|
52
|
+
ticket: JiraTicket;
|
|
53
|
+
fetchedAt: number; // timestamp
|
|
54
|
+
};
|
|
55
|
+
type JiraIssueCache = Record<string, CachedJiraIssue>;
|
|
56
|
+
|
|
41
57
|
const STORAGE_KEY = 'hale_commenting_jira_v1';
|
|
58
|
+
const CACHE_STORAGE_KEY = 'hale_commenting_jira_cache_v1';
|
|
42
59
|
const GH_JIRA_PATH = '.hale/jira.json';
|
|
60
|
+
const CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes
|
|
61
|
+
|
|
62
|
+
function migrateRecord(record: LegacyJiraRecord): JiraRecord {
|
|
63
|
+
// If it's already in the new format, return as-is
|
|
64
|
+
if (Array.isArray(record.jiraKeys)) {
|
|
65
|
+
return record as JiraRecord;
|
|
66
|
+
}
|
|
67
|
+
// Migrate from old format (single jiraKey)
|
|
68
|
+
if (typeof record.jiraKey === 'string' && record.jiraKey.trim()) {
|
|
69
|
+
return {
|
|
70
|
+
jiraKeys: [record.jiraKey.trim()],
|
|
71
|
+
scope: record.scope,
|
|
72
|
+
anchorRoute: record.anchorRoute,
|
|
73
|
+
updatedAt: record.updatedAt,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
// Empty record - return with empty array
|
|
77
|
+
return {
|
|
78
|
+
jiraKeys: [],
|
|
79
|
+
scope: record.scope,
|
|
80
|
+
anchorRoute: record.anchorRoute,
|
|
81
|
+
updatedAt: record.updatedAt,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
43
84
|
|
|
44
85
|
function safeParseStore(raw: string | null): JiraStore {
|
|
45
86
|
if (!raw) return {};
|
|
46
87
|
try {
|
|
47
88
|
const parsed = JSON.parse(raw) as unknown;
|
|
48
89
|
if (!parsed || typeof parsed !== 'object') return {};
|
|
49
|
-
|
|
90
|
+
const store = parsed as Record<string, LegacyJiraRecord>;
|
|
91
|
+
// Migrate all records to new format
|
|
92
|
+
const migrated: JiraStore = {};
|
|
93
|
+
for (const [key, record] of Object.entries(store)) {
|
|
94
|
+
migrated[key] = migrateRecord(record);
|
|
95
|
+
}
|
|
96
|
+
return migrated;
|
|
50
97
|
} catch {
|
|
51
98
|
return {};
|
|
52
99
|
}
|
|
@@ -62,6 +109,50 @@ function setStore(next: JiraStore) {
|
|
|
62
109
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
|
63
110
|
}
|
|
64
111
|
|
|
112
|
+
function getIssueCache(): JiraIssueCache {
|
|
113
|
+
if (typeof window === 'undefined') return {};
|
|
114
|
+
try {
|
|
115
|
+
const raw = window.localStorage.getItem(CACHE_STORAGE_KEY);
|
|
116
|
+
if (!raw) return {};
|
|
117
|
+
return JSON.parse(raw) as JiraIssueCache;
|
|
118
|
+
} catch {
|
|
119
|
+
return {};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function setIssueCache(cache: JiraIssueCache) {
|
|
124
|
+
if (typeof window === 'undefined') return;
|
|
125
|
+
window.localStorage.setItem(CACHE_STORAGE_KEY, JSON.stringify(cache));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function getCachedIssue(key: string): JiraTicket | null {
|
|
129
|
+
const cache = getIssueCache();
|
|
130
|
+
const cached = cache[key];
|
|
131
|
+
if (!cached) return null;
|
|
132
|
+
|
|
133
|
+
const now = Date.now();
|
|
134
|
+
const age = now - cached.fetchedAt;
|
|
135
|
+
|
|
136
|
+
// Return cached data if it's still fresh
|
|
137
|
+
if (age < CACHE_TTL_MS) {
|
|
138
|
+
return cached.ticket;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Expired - remove it
|
|
142
|
+
delete cache[key];
|
|
143
|
+
setIssueCache(cache);
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function setCachedIssue(key: string, ticket: JiraTicket) {
|
|
148
|
+
const cache = getIssueCache();
|
|
149
|
+
cache[key] = {
|
|
150
|
+
ticket,
|
|
151
|
+
fetchedAt: Date.now(),
|
|
152
|
+
};
|
|
153
|
+
setIssueCache(cache);
|
|
154
|
+
}
|
|
155
|
+
|
|
65
156
|
function normalizePathname(pathname: string): string {
|
|
66
157
|
if (!pathname) return '/';
|
|
67
158
|
const cleaned = pathname.split('?')[0].split('#')[0];
|
|
@@ -86,22 +177,37 @@ function getSectionKey(sectionRoute: string): string {
|
|
|
86
177
|
function loadForRoute(pathname: string): { record: JiraRecord | null; source: 'page' | 'section' | null } {
|
|
87
178
|
const store = getStore();
|
|
88
179
|
const pageKey = getPageKey(pathname);
|
|
89
|
-
if (store[pageKey]
|
|
180
|
+
if (store[pageKey] && store[pageKey].jiraKeys.length > 0) {
|
|
181
|
+
return { record: store[pageKey], source: 'page' };
|
|
182
|
+
}
|
|
90
183
|
|
|
91
184
|
const sectionRoute = getSectionRoute(pathname);
|
|
92
185
|
const sectionKey = getSectionKey(sectionRoute);
|
|
93
|
-
if (store[sectionKey]
|
|
186
|
+
if (store[sectionKey] && store[sectionKey].jiraKeys.length > 0) {
|
|
187
|
+
return { record: store[sectionKey], source: 'section' };
|
|
188
|
+
}
|
|
94
189
|
|
|
95
190
|
return { record: null, source: null };
|
|
96
191
|
}
|
|
97
192
|
|
|
98
|
-
const
|
|
193
|
+
const normalizeJiraKeys = (input: string): string[] => {
|
|
99
194
|
const raw = input.trim();
|
|
100
|
-
if (!raw) return
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
195
|
+
if (!raw) return [];
|
|
196
|
+
|
|
197
|
+
// Split by comma, newline, or whitespace, then normalize each
|
|
198
|
+
const keys = raw
|
|
199
|
+
.split(/[,\n]+/)
|
|
200
|
+
.map(k => k.trim())
|
|
201
|
+
.filter(Boolean)
|
|
202
|
+
.map(key => {
|
|
203
|
+
// Allow users to paste full URLs; extract trailing key-ish segment.
|
|
204
|
+
const m = key.match(/([A-Z][A-Z0-9]+-\d+)/i);
|
|
205
|
+
if (m?.[1]) return m[1].toUpperCase();
|
|
206
|
+
return key.toUpperCase();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Remove duplicates and empty strings
|
|
210
|
+
return Array.from(new Set(keys.filter(Boolean)));
|
|
105
211
|
};
|
|
106
212
|
|
|
107
213
|
const stripHtmlTags = (input: string): string => {
|
|
@@ -237,9 +343,9 @@ export const JiraTab: React.FunctionComponent = () => {
|
|
|
237
343
|
const [remoteError, setRemoteError] = React.useState<string | null>(null);
|
|
238
344
|
const remoteShaRef = React.useRef<string | undefined>(undefined);
|
|
239
345
|
|
|
240
|
-
const [
|
|
241
|
-
const [
|
|
242
|
-
const [
|
|
346
|
+
const [isFetchingIssues, setIsFetchingIssues] = React.useState(false);
|
|
347
|
+
const [issues, setIssues] = React.useState<Record<string, JiraTicket>>({});
|
|
348
|
+
const [issueErrors, setIssueErrors] = React.useState<Record<string, string>>({});
|
|
243
349
|
|
|
244
350
|
React.useEffect(() => {
|
|
245
351
|
setResolved(loadForRoute(route));
|
|
@@ -286,40 +392,73 @@ export const JiraTab: React.FunctionComponent = () => {
|
|
|
286
392
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
287
393
|
}, []);
|
|
288
394
|
|
|
289
|
-
// Fetch Jira issue details for
|
|
395
|
+
// Fetch Jira issue details for all keys (with caching).
|
|
290
396
|
React.useEffect(() => {
|
|
291
|
-
const
|
|
292
|
-
if (
|
|
293
|
-
|
|
294
|
-
|
|
397
|
+
const keys = record?.jiraKeys || [];
|
|
398
|
+
if (keys.length === 0) {
|
|
399
|
+
setIssues({});
|
|
400
|
+
setIssueErrors({});
|
|
295
401
|
return;
|
|
296
402
|
}
|
|
297
403
|
|
|
298
404
|
const run = async () => {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
405
|
+
setIsFetchingIssues(true);
|
|
406
|
+
setIssueErrors({});
|
|
407
|
+
const results: Record<string, JiraTicket> = {};
|
|
408
|
+
const errors: Record<string, string> = {};
|
|
409
|
+
|
|
410
|
+
// Check cache first
|
|
411
|
+
const keysToFetch: string[] = [];
|
|
412
|
+
for (const key of keys) {
|
|
413
|
+
const cached = getCachedIssue(key);
|
|
414
|
+
if (cached) {
|
|
415
|
+
results[key] = cached;
|
|
416
|
+
} else {
|
|
417
|
+
keysToFetch.push(key);
|
|
311
418
|
}
|
|
312
|
-
setIssue(payload as JiraTicket);
|
|
313
|
-
} catch (e: any) {
|
|
314
|
-
setIssue(null);
|
|
315
|
-
setIssueError(e?.message || 'Failed to fetch Jira issue');
|
|
316
|
-
} finally {
|
|
317
|
-
setIsFetchingIssue(false);
|
|
318
419
|
}
|
|
420
|
+
|
|
421
|
+
// Fetch uncached issues
|
|
422
|
+
if (keysToFetch.length > 0) {
|
|
423
|
+
await Promise.all(
|
|
424
|
+
keysToFetch.map(async (key) => {
|
|
425
|
+
try {
|
|
426
|
+
const resp = await fetch(`/api/jira-issue?key=${encodeURIComponent(key)}`);
|
|
427
|
+
const payload = await resp.json().catch(() => ({}));
|
|
428
|
+
|
|
429
|
+
if (!resp.ok) {
|
|
430
|
+
// Handle rate limiting specially
|
|
431
|
+
if (resp.status === 429) {
|
|
432
|
+
errors[key] = 'Rate limit exceeded. Please wait a few minutes and refresh the page.';
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const raw = String(payload?.message || `Failed to fetch Jira issue (${resp.status})`);
|
|
437
|
+
const sanitized = raw.trim().startsWith('<') ? 'Unauthorized or non-JSON response from Jira.' : raw;
|
|
438
|
+
const hint = payload?.hint ? ` ${String(payload.hint)}` : '';
|
|
439
|
+
errors[key] = `${sanitized}${hint}`;
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const ticket = payload as JiraTicket;
|
|
444
|
+
results[key] = ticket;
|
|
445
|
+
|
|
446
|
+
// Cache the successful result
|
|
447
|
+
setCachedIssue(key, ticket);
|
|
448
|
+
} catch (e: any) {
|
|
449
|
+
errors[key] = e?.message || 'Failed to fetch Jira issue';
|
|
450
|
+
}
|
|
451
|
+
})
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
setIssues(results);
|
|
456
|
+
setIssueErrors(errors);
|
|
457
|
+
setIsFetchingIssues(false);
|
|
319
458
|
};
|
|
320
459
|
|
|
321
460
|
void run();
|
|
322
|
-
}, [record?.
|
|
461
|
+
}, [record?.jiraKeys]);
|
|
323
462
|
|
|
324
463
|
const startNew = () => {
|
|
325
464
|
setDraftScope('section');
|
|
@@ -330,20 +469,23 @@ export const JiraTab: React.FunctionComponent = () => {
|
|
|
330
469
|
const startEdit = (mode: 'edit-existing' | 'override-page') => {
|
|
331
470
|
if (mode === 'override-page') {
|
|
332
471
|
setDraftScope('page');
|
|
333
|
-
setDraftKey(record?.
|
|
472
|
+
setDraftKey(record?.jiraKeys.join(', ') ?? '');
|
|
334
473
|
setIsEditing(true);
|
|
335
474
|
return;
|
|
336
475
|
}
|
|
337
476
|
|
|
338
477
|
const existingScope: JiraScope = record?.scope ?? 'section';
|
|
339
478
|
setDraftScope(existingScope);
|
|
340
|
-
setDraftKey(record?.
|
|
479
|
+
setDraftKey(record?.jiraKeys.join(', ') ?? '');
|
|
341
480
|
setIsEditing(true);
|
|
342
481
|
};
|
|
343
482
|
|
|
344
483
|
const save = () => {
|
|
484
|
+
const normalizedKeys = normalizeJiraKeys(draftKey);
|
|
485
|
+
if (normalizedKeys.length === 0) return;
|
|
486
|
+
|
|
345
487
|
const next: JiraRecord = {
|
|
346
|
-
|
|
488
|
+
jiraKeys: normalizedKeys,
|
|
347
489
|
scope: draftScope,
|
|
348
490
|
anchorRoute: draftScope === 'section' ? sectionRoute : route,
|
|
349
491
|
updatedAt: new Date().toISOString(),
|
|
@@ -450,8 +592,8 @@ export const JiraTab: React.FunctionComponent = () => {
|
|
|
450
592
|
</Button>
|
|
451
593
|
</div>
|
|
452
594
|
|
|
453
|
-
<EmptyState icon={InfoCircleIcon} titleText="No Jira
|
|
454
|
-
<EmptyStateBody>Add
|
|
595
|
+
<EmptyState icon={InfoCircleIcon} titleText="No Jira issues linked" headingLevel="h3">
|
|
596
|
+
<EmptyStateBody>Add Jira keys like <b>ABC-123</b> (or paste Jira URLs). You can add multiple issues separated by commas or new lines.</EmptyStateBody>
|
|
455
597
|
</EmptyState>
|
|
456
598
|
</div>
|
|
457
599
|
);
|
|
@@ -492,23 +634,26 @@ export const JiraTab: React.FunctionComponent = () => {
|
|
|
492
634
|
<Card>
|
|
493
635
|
<CardBody>
|
|
494
636
|
<Title headingLevel="h4" size="md" style={{ marginBottom: '1rem' }}>
|
|
495
|
-
Jira
|
|
637
|
+
Jira issues
|
|
496
638
|
</Title>
|
|
497
639
|
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
|
498
640
|
<div>
|
|
499
641
|
<div style={{ fontSize: '0.875rem', marginBottom: '0.25rem' }}>
|
|
500
|
-
<b>Jira
|
|
642
|
+
<b>Jira keys or URLs</b>
|
|
643
|
+
<div style={{ fontSize: '0.75rem', color: 'var(--pf-t--global--text--color--subtle)', marginTop: '0.25rem' }}>
|
|
644
|
+
Enter multiple keys separated by commas or new lines (e.g., ABC-123, DEF-456)
|
|
645
|
+
</div>
|
|
501
646
|
</div>
|
|
502
647
|
<TextArea
|
|
503
648
|
value={draftKey}
|
|
504
649
|
onChange={(_e, v) => setDraftKey(v)}
|
|
505
|
-
aria-label="Jira
|
|
506
|
-
rows={
|
|
650
|
+
aria-label="Jira keys or URLs"
|
|
651
|
+
rows={3}
|
|
507
652
|
/>
|
|
508
653
|
</div>
|
|
509
654
|
|
|
510
655
|
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-start', marginTop: '0.5rem' }}>
|
|
511
|
-
<Button variant="primary" onClick={save} isDisabled={
|
|
656
|
+
<Button variant="primary" onClick={save} isDisabled={normalizeJiraKeys(draftKey).length === 0}>
|
|
512
657
|
Save
|
|
513
658
|
</Button>
|
|
514
659
|
<Button variant="link" onClick={() => setIsEditing(false)}>
|
|
@@ -529,25 +674,55 @@ export const JiraTab: React.FunctionComponent = () => {
|
|
|
529
674
|
|
|
530
675
|
const scopeLabel =
|
|
531
676
|
source === 'page' ? 'This page' : source === 'section' ? `Section (${sectionRoute}/*)` : null;
|
|
532
|
-
const
|
|
677
|
+
const keys = record.jiraKeys || [];
|
|
533
678
|
let jiraBaseUrl: string | undefined;
|
|
534
679
|
try {
|
|
535
680
|
jiraBaseUrl = typeof process !== 'undefined' && process.env ? process.env.VITE_JIRA_BASE_URL : undefined;
|
|
536
681
|
} catch (e) {
|
|
537
682
|
jiraBaseUrl = undefined;
|
|
538
683
|
}
|
|
539
|
-
const url = issue?.url || (jiraBaseUrl ? `${jiraBaseUrl}/browse/${key}` : '');
|
|
540
684
|
|
|
541
|
-
const
|
|
542
|
-
|
|
685
|
+
const removeKey = (keyToRemove: string) => {
|
|
686
|
+
const store = getStore();
|
|
687
|
+
const storeKey = source === 'page' ? getPageKey(route) : source === 'section' ? getSectionKey(sectionRoute) : null;
|
|
688
|
+
if (!storeKey || !record) return;
|
|
689
|
+
|
|
690
|
+
const updatedKeys = record.jiraKeys.filter(k => k !== keyToRemove);
|
|
691
|
+
if (updatedKeys.length === 0) {
|
|
692
|
+
// If no keys left, remove the entire record
|
|
693
|
+
const { [storeKey]: _removed, ...rest } = store;
|
|
694
|
+
setStore(rest);
|
|
695
|
+
setResolved(loadForRoute(route));
|
|
696
|
+
} else {
|
|
697
|
+
// Update with remaining keys
|
|
698
|
+
const updated: JiraRecord = {
|
|
699
|
+
...record,
|
|
700
|
+
jiraKeys: updatedKeys,
|
|
701
|
+
updatedAt: new Date().toISOString(),
|
|
702
|
+
};
|
|
703
|
+
const nextStore = { ...store, [storeKey]: updated };
|
|
704
|
+
setStore(nextStore);
|
|
705
|
+
setResolved(loadForRoute(route));
|
|
706
|
+
}
|
|
543
707
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
708
|
+
if (isGitHubConfigured()) {
|
|
709
|
+
(async () => {
|
|
710
|
+
const finalStore = updatedKeys.length === 0
|
|
711
|
+
? Object.fromEntries(Object.entries(store).filter(([k]) => k !== storeKey))
|
|
712
|
+
: { ...store, [storeKey]: { ...record, jiraKeys: updatedKeys, updatedAt: new Date().toISOString() } };
|
|
713
|
+
const text = JSON.stringify(finalStore, null, 2) + '\n';
|
|
714
|
+
const message = `chore(jira): remove ${keyToRemove} from ${storeKey}`;
|
|
715
|
+
const sha = remoteShaRef.current;
|
|
716
|
+
const write = await githubAdapter.putRepoFile({ path: GH_JIRA_PATH, text, message, sha });
|
|
717
|
+
if (write.success && write.data?.sha) {
|
|
718
|
+
remoteShaRef.current = write.data.sha;
|
|
719
|
+
setRemoteError(null);
|
|
720
|
+
} else {
|
|
721
|
+
setRemoteError(write.error || 'Failed to update Jira store in GitHub');
|
|
722
|
+
}
|
|
723
|
+
})();
|
|
724
|
+
}
|
|
725
|
+
};
|
|
551
726
|
|
|
552
727
|
return (
|
|
553
728
|
<div style={{ display: 'grid', gap: '1rem' }}>
|
|
@@ -582,119 +757,159 @@ export const JiraTab: React.FunctionComponent = () => {
|
|
|
582
757
|
</div>
|
|
583
758
|
</div>
|
|
584
759
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
<Label color="blue" isCompact>
|
|
592
|
-
{issueType || 'Issue'}
|
|
593
|
-
</Label>
|
|
594
|
-
{url ? (
|
|
595
|
-
<a
|
|
596
|
-
href={url}
|
|
597
|
-
target="_blank"
|
|
598
|
-
rel="noopener noreferrer"
|
|
599
|
-
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.25rem', fontWeight: 600 }}
|
|
600
|
-
>
|
|
601
|
-
{key} <ExternalLinkAltIcon style={{ fontSize: '0.75rem' }} />
|
|
602
|
-
</a>
|
|
603
|
-
) : (
|
|
604
|
-
<span style={{ fontWeight: 600 }}>{key}</span>
|
|
605
|
-
)}
|
|
606
|
-
</div>
|
|
607
|
-
</div>
|
|
760
|
+
{/* Loading state */}
|
|
761
|
+
{isFetchingIssues && keys.length > 0 && (
|
|
762
|
+
<div style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem' }}>
|
|
763
|
+
<Spinner size="sm" /> <span>Fetching Jira details…</span>
|
|
764
|
+
</div>
|
|
765
|
+
)}
|
|
608
766
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
767
|
+
{/* Display all issues */}
|
|
768
|
+
<div style={{ display: 'grid', gap: '1rem' }}>
|
|
769
|
+
{keys.map((key) => {
|
|
770
|
+
const issue = issues[key];
|
|
771
|
+
const error = issueErrors[key];
|
|
772
|
+
const url = issue?.url || (jiraBaseUrl ? `${jiraBaseUrl}/browse/${key}` : '');
|
|
773
|
+
|
|
774
|
+
const parsedSections = issue ? parseJiraTemplateSections(issue.description || '') : [];
|
|
775
|
+
const byTitle = new Map(parsedSections.map((s) => [s.title, s.body]));
|
|
776
|
+
|
|
777
|
+
const summary = issue?.summary || '';
|
|
778
|
+
const status = issue?.status || '';
|
|
779
|
+
const priority = issue?.priority || '';
|
|
780
|
+
const assignee = issue?.assignee || '';
|
|
781
|
+
const issueType = issue?.issueType || 'Issue';
|
|
782
|
+
const created = issue?.created ? new Date(issue.created).toLocaleString() : '';
|
|
783
|
+
const updated = issue?.updated ? new Date(issue.updated).toLocaleString() : '';
|
|
784
|
+
|
|
785
|
+
return (
|
|
786
|
+
<Card key={key}>
|
|
787
|
+
<CardBody>
|
|
788
|
+
<div style={{ display: 'grid', gap: '1rem' }}>
|
|
789
|
+
{/* Ticket header */}
|
|
790
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
|
|
791
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
|
792
|
+
<Label color="blue" isCompact>
|
|
793
|
+
{issueType || 'Issue'}
|
|
794
|
+
</Label>
|
|
795
|
+
{url ? (
|
|
796
|
+
<a
|
|
797
|
+
href={url}
|
|
798
|
+
target="_blank"
|
|
799
|
+
rel="noopener noreferrer"
|
|
800
|
+
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.25rem', fontWeight: 600 }}
|
|
801
|
+
>
|
|
802
|
+
{key} <ExternalLinkAltIcon style={{ fontSize: '0.75rem' }} />
|
|
803
|
+
</a>
|
|
804
|
+
) : (
|
|
805
|
+
<span style={{ fontWeight: 600 }}>{key}</span>
|
|
806
|
+
)}
|
|
807
|
+
</div>
|
|
808
|
+
{keys.length > 1 && (
|
|
809
|
+
<Button variant="link" isDanger onClick={() => removeKey(key)}>
|
|
810
|
+
Remove
|
|
811
|
+
</Button>
|
|
812
|
+
)}
|
|
813
|
+
</div>
|
|
635
814
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
</span>
|
|
815
|
+
{/* Error state */}
|
|
816
|
+
{error && !isFetchingIssues && (
|
|
817
|
+
<div style={{ display: 'grid', gap: '0.5rem' }}>
|
|
818
|
+
<div style={{ fontSize: '0.875rem', color: 'var(--pf-t--global--danger--color--100)' }}>{error}</div>
|
|
819
|
+
{error.includes('Rate limit') && (
|
|
820
|
+
<div style={{ fontSize: '0.75rem', color: 'var(--pf-t--global--text--color--subtle)' }}>
|
|
821
|
+
Tip: Jira data is cached for 15 minutes to reduce API calls. Refreshing the page will retry.
|
|
822
|
+
</div>
|
|
823
|
+
)}
|
|
824
|
+
</div>
|
|
647
825
|
)}
|
|
648
|
-
</div>
|
|
649
|
-
|
|
650
|
-
<div style={{ height: 1, background: 'var(--pf-t--global--border--color--default)', marginTop: '0.25rem' }} />
|
|
651
826
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
<Title headingLevel="
|
|
657
|
-
|
|
827
|
+
{/* Issue details */}
|
|
828
|
+
{!error && issue && (
|
|
829
|
+
<>
|
|
830
|
+
{/* Title */}
|
|
831
|
+
<Title headingLevel="h3" size="lg" style={{ marginTop: '0.25rem' }}>
|
|
832
|
+
{summary || '—'}
|
|
658
833
|
</Title>
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
834
|
+
|
|
835
|
+
{/* Chips row */}
|
|
836
|
+
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginTop: '0.25rem' }}>
|
|
837
|
+
<Label color="grey" isCompact>
|
|
838
|
+
Status: {status || '—'}
|
|
839
|
+
</Label>
|
|
840
|
+
<Label color="orange" isCompact>
|
|
841
|
+
Priority: {priority || '—'}
|
|
842
|
+
</Label>
|
|
843
|
+
<Label color="grey" isCompact>
|
|
844
|
+
Assignee: {assignee || '—'}
|
|
845
|
+
</Label>
|
|
846
|
+
</div>
|
|
847
|
+
|
|
848
|
+
{/* Dates */}
|
|
849
|
+
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', fontSize: '0.875rem', color: 'var(--pf-t--global--text--color--subtle)' }}>
|
|
850
|
+
{created && (
|
|
851
|
+
<span>
|
|
852
|
+
<b>Created:</b> {created}
|
|
853
|
+
</span>
|
|
854
|
+
)}
|
|
855
|
+
{updated && (
|
|
856
|
+
<span>
|
|
857
|
+
<b>Updated:</b> {updated}
|
|
858
|
+
</span>
|
|
859
|
+
)}
|
|
860
|
+
</div>
|
|
861
|
+
|
|
862
|
+
<div style={{ height: 1, background: 'var(--pf-t--global--border--color--default)', marginTop: '0.25rem' }} />
|
|
863
|
+
|
|
864
|
+
{/* Template sections (preferred) */}
|
|
865
|
+
{parsedSections.length > 0 ? (
|
|
866
|
+
<div style={{ display: 'grid', gap: '1rem' }}>
|
|
867
|
+
<div>
|
|
868
|
+
<Title headingLevel="h4" size="md" style={{ marginBottom: '0.5rem' }}>
|
|
869
|
+
Problem statement
|
|
870
|
+
</Title>
|
|
871
|
+
{renderBulletsOrText(byTitle.get('Problem statement') || '')}
|
|
872
|
+
</div>
|
|
873
|
+
<div>
|
|
874
|
+
<Title headingLevel="h4" size="md" style={{ marginBottom: '0.5rem' }}>
|
|
875
|
+
Objective
|
|
876
|
+
</Title>
|
|
877
|
+
{renderBulletsOrText(byTitle.get('Objective') || '')}
|
|
878
|
+
</div>
|
|
879
|
+
<div>
|
|
880
|
+
<Title headingLevel="h4" size="md" style={{ marginBottom: '0.5rem' }}>
|
|
881
|
+
Definition of Done
|
|
882
|
+
</Title>
|
|
883
|
+
{renderBulletsOrText(byTitle.get('Definition of Done') || '')}
|
|
884
|
+
</div>
|
|
885
|
+
</div>
|
|
886
|
+
) : (
|
|
887
|
+
// Fallback: show the raw description if it doesn't follow the template.
|
|
888
|
+
<div>
|
|
889
|
+
<Title headingLevel="h4" size="md" style={{ marginBottom: '0.5rem' }}>
|
|
890
|
+
Description
|
|
891
|
+
</Title>
|
|
892
|
+
<div style={{ fontSize: '0.875rem', whiteSpace: 'pre-wrap' }}>
|
|
893
|
+
{issue?.description ? stripHtmlTags(issue.description) : (
|
|
894
|
+
<span style={{ color: 'var(--pf-t--global--text--color--subtle)' }}>No description</span>
|
|
895
|
+
)}
|
|
896
|
+
</div>
|
|
897
|
+
</div>
|
|
683
898
|
)}
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
899
|
+
</>
|
|
900
|
+
)}
|
|
901
|
+
</div>
|
|
902
|
+
</CardBody>
|
|
903
|
+
</Card>
|
|
904
|
+
);
|
|
905
|
+
})}
|
|
906
|
+
</div>
|
|
689
907
|
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
</div>
|
|
696
|
-
</CardBody>
|
|
697
|
-
</Card>
|
|
908
|
+
<div style={{ marginTop: '0.25rem' }}>
|
|
909
|
+
<Button variant="link" isDanger onClick={remove}>
|
|
910
|
+
Remove all Jira links
|
|
911
|
+
</Button>
|
|
912
|
+
</div>
|
|
698
913
|
</div>
|
|
699
914
|
);
|
|
700
915
|
};
|