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.
@@ -30,7 +30,16 @@ type JiraTicket = {
30
30
  };
31
31
 
32
32
  interface JiraRecord {
33
- jiraKey: string;
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
- return parsed as JiraStore;
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]) return { record: store[pageKey], source: 'page' };
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]) return { record: store[sectionKey], source: 'section' };
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 normalizeJiraKey = (input: string): string => {
193
+ const normalizeJiraKeys = (input: string): string[] => {
99
194
  const raw = input.trim();
100
- if (!raw) return '';
101
- // Allow users to paste full URLs; extract trailing key-ish segment.
102
- const m = raw.match(/([A-Z][A-Z0-9]+-\d+)/i);
103
- if (m?.[1]) return m[1].toUpperCase();
104
- return raw.toUpperCase();
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 [isFetchingIssue, setIsFetchingIssue] = React.useState(false);
241
- const [issueError, setIssueError] = React.useState<string | null>(null);
242
- const [issue, setIssue] = React.useState<JiraTicket | null>(null);
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 the effective key.
395
+ // Fetch Jira issue details for all keys (with caching).
290
396
  React.useEffect(() => {
291
- const key = record?.jiraKey?.trim();
292
- if (!key) {
293
- setIssue(null);
294
- setIssueError(null);
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
- setIsFetchingIssue(true);
300
- setIssueError(null);
301
- try {
302
- const resp = await fetch(`/api/jira-issue?key=${encodeURIComponent(key)}`);
303
- const payload = await resp.json().catch(() => ({}));
304
- if (!resp.ok) {
305
- setIssue(null);
306
- const raw = String(payload?.message || `Failed to fetch Jira issue (${resp.status})`);
307
- const sanitized = raw.trim().startsWith('<') ? 'Unauthorized or non-JSON response from Jira.' : raw;
308
- const hint = payload?.hint ? ` ${String(payload.hint)}` : '';
309
- setIssueError(`${sanitized}${hint}`);
310
- return;
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?.jiraKey]);
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?.jiraKey ?? '');
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?.jiraKey ?? '');
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
- jiraKey: normalizeJiraKey(draftKey),
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 issue linked" headingLevel="h3">
454
- <EmptyStateBody>Add a Jira key like <b>ABC-123</b> (or paste a Jira URL).</EmptyStateBody>
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 issue
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 key or URL</b>
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 key or URL"
506
- rows={1}
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={!normalizeJiraKey(draftKey)}>
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 key = record.jiraKey || '';
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 parsedSections = parseJiraTemplateSections(issue?.description || '');
542
- const byTitle = new Map(parsedSections.map((s) => [s.title, s.body]));
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
- const summary = issue?.summary || '';
545
- const status = issue?.status || '';
546
- const priority = issue?.priority || '';
547
- const assignee = issue?.assignee || '';
548
- const issueType = issue?.issueType || 'Issue';
549
- const created = issue?.created ? new Date(issue.created).toLocaleString() : '';
550
- const updated = issue?.updated ? new Date(issue.updated).toLocaleString() : '';
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
- <Card>
586
- <CardBody>
587
- <div style={{ display: 'grid', gap: '1rem' }}>
588
- {/* Ticket header */}
589
- <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
590
- <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
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
- {/* Loading / error */}
610
- {isFetchingIssue ? (
611
- <div style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem' }}>
612
- <Spinner size="sm" /> <span>Fetching Jira details…</span>
613
- </div>
614
- ) : issueError ? (
615
- <div style={{ fontSize: '0.875rem', color: 'var(--pf-t--global--danger--color--100)' }}>{issueError}</div>
616
- ) : (
617
- <>
618
- {/* Title */}
619
- <Title headingLevel="h3" size="lg" style={{ marginTop: '0.25rem' }}>
620
- {summary || ''}
621
- </Title>
622
-
623
- {/* Chips row */}
624
- <div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginTop: '0.25rem' }}>
625
- <Label color="grey" isCompact>
626
- Status: {status || '—'}
627
- </Label>
628
- <Label color="orange" isCompact>
629
- Priority: {priority || '—'}
630
- </Label>
631
- <Label color="grey" isCompact>
632
- Assignee: {assignee || ''}
633
- </Label>
634
- </div>
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
- {/* Dates */}
637
- <div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', fontSize: '0.875rem', color: 'var(--pf-t--global--text--color--subtle)' }}>
638
- {created && (
639
- <span>
640
- <b>Created:</b> {created}
641
- </span>
642
- )}
643
- {updated && (
644
- <span>
645
- <b>Updated:</b> {updated}
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
- {/* Template sections (preferred) */}
653
- {parsedSections.length > 0 ? (
654
- <div style={{ display: 'grid', gap: '1rem' }}>
655
- <div>
656
- <Title headingLevel="h4" size="md" style={{ marginBottom: '0.5rem' }}>
657
- Problem statement
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
- {renderBulletsOrText(byTitle.get('Problem statement') || '')}
660
- </div>
661
- <div>
662
- <Title headingLevel="h4" size="md" style={{ marginBottom: '0.5rem' }}>
663
- Objective
664
- </Title>
665
- {renderBulletsOrText(byTitle.get('Objective') || '')}
666
- </div>
667
- <div>
668
- <Title headingLevel="h4" size="md" style={{ marginBottom: '0.5rem' }}>
669
- Definition of Done
670
- </Title>
671
- {renderBulletsOrText(byTitle.get('Definition of Done') || '')}
672
- </div>
673
- </div>
674
- ) : (
675
- // Fallback: show the raw description if it doesn't follow the template.
676
- <div>
677
- <Title headingLevel="h4" size="md" style={{ marginBottom: '0.5rem' }}>
678
- Description
679
- </Title>
680
- <div style={{ fontSize: '0.875rem', whiteSpace: 'pre-wrap' }}>
681
- {issue?.description ? stripHtmlTags(issue.description) : (
682
- <span style={{ color: 'var(--pf-t--global--text--color--subtle)' }}>No description</span>
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
- </div>
685
- </div>
686
- )}
687
- </>
688
- )}
899
+ </>
900
+ )}
901
+ </div>
902
+ </CardBody>
903
+ </Card>
904
+ );
905
+ })}
906
+ </div>
689
907
 
690
- <div style={{ marginTop: '0.25rem' }}>
691
- <Button variant="link" isDanger onClick={remove}>
692
- Remove Jira link
693
- </Button>
694
- </div>
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
  };