hale-commenting-system 3.5.2 → 3.6.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;
@@ -41,12 +50,41 @@ type JiraStore = Record<string, JiraRecord>;
41
50
  const STORAGE_KEY = 'hale_commenting_jira_v1';
42
51
  const GH_JIRA_PATH = '.hale/jira.json';
43
52
 
53
+ function migrateRecord(record: LegacyJiraRecord): JiraRecord {
54
+ // If it's already in the new format, return as-is
55
+ if (Array.isArray(record.jiraKeys)) {
56
+ return record as JiraRecord;
57
+ }
58
+ // Migrate from old format (single jiraKey)
59
+ if (typeof record.jiraKey === 'string' && record.jiraKey.trim()) {
60
+ return {
61
+ jiraKeys: [record.jiraKey.trim()],
62
+ scope: record.scope,
63
+ anchorRoute: record.anchorRoute,
64
+ updatedAt: record.updatedAt,
65
+ };
66
+ }
67
+ // Empty record - return with empty array
68
+ return {
69
+ jiraKeys: [],
70
+ scope: record.scope,
71
+ anchorRoute: record.anchorRoute,
72
+ updatedAt: record.updatedAt,
73
+ };
74
+ }
75
+
44
76
  function safeParseStore(raw: string | null): JiraStore {
45
77
  if (!raw) return {};
46
78
  try {
47
79
  const parsed = JSON.parse(raw) as unknown;
48
80
  if (!parsed || typeof parsed !== 'object') return {};
49
- return parsed as JiraStore;
81
+ const store = parsed as Record<string, LegacyJiraRecord>;
82
+ // Migrate all records to new format
83
+ const migrated: JiraStore = {};
84
+ for (const [key, record] of Object.entries(store)) {
85
+ migrated[key] = migrateRecord(record);
86
+ }
87
+ return migrated;
50
88
  } catch {
51
89
  return {};
52
90
  }
@@ -86,22 +124,37 @@ function getSectionKey(sectionRoute: string): string {
86
124
  function loadForRoute(pathname: string): { record: JiraRecord | null; source: 'page' | 'section' | null } {
87
125
  const store = getStore();
88
126
  const pageKey = getPageKey(pathname);
89
- if (store[pageKey]) return { record: store[pageKey], source: 'page' };
127
+ if (store[pageKey] && store[pageKey].jiraKeys.length > 0) {
128
+ return { record: store[pageKey], source: 'page' };
129
+ }
90
130
 
91
131
  const sectionRoute = getSectionRoute(pathname);
92
132
  const sectionKey = getSectionKey(sectionRoute);
93
- if (store[sectionKey]) return { record: store[sectionKey], source: 'section' };
133
+ if (store[sectionKey] && store[sectionKey].jiraKeys.length > 0) {
134
+ return { record: store[sectionKey], source: 'section' };
135
+ }
94
136
 
95
137
  return { record: null, source: null };
96
138
  }
97
139
 
98
- const normalizeJiraKey = (input: string): string => {
140
+ const normalizeJiraKeys = (input: string): string[] => {
99
141
  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();
142
+ if (!raw) return [];
143
+
144
+ // Split by comma, newline, or whitespace, then normalize each
145
+ const keys = raw
146
+ .split(/[,\n]+/)
147
+ .map(k => k.trim())
148
+ .filter(Boolean)
149
+ .map(key => {
150
+ // Allow users to paste full URLs; extract trailing key-ish segment.
151
+ const m = key.match(/([A-Z][A-Z0-9]+-\d+)/i);
152
+ if (m?.[1]) return m[1].toUpperCase();
153
+ return key.toUpperCase();
154
+ });
155
+
156
+ // Remove duplicates and empty strings
157
+ return Array.from(new Set(keys.filter(Boolean)));
105
158
  };
106
159
 
107
160
  const stripHtmlTags = (input: string): string => {
@@ -237,9 +290,9 @@ export const JiraTab: React.FunctionComponent = () => {
237
290
  const [remoteError, setRemoteError] = React.useState<string | null>(null);
238
291
  const remoteShaRef = React.useRef<string | undefined>(undefined);
239
292
 
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);
293
+ const [isFetchingIssues, setIsFetchingIssues] = React.useState(false);
294
+ const [issues, setIssues] = React.useState<Record<string, JiraTicket>>({});
295
+ const [issueErrors, setIssueErrors] = React.useState<Record<string, string>>({});
243
296
 
244
297
  React.useEffect(() => {
245
298
  setResolved(loadForRoute(route));
@@ -286,40 +339,47 @@ export const JiraTab: React.FunctionComponent = () => {
286
339
  // eslint-disable-next-line react-hooks/exhaustive-deps
287
340
  }, []);
288
341
 
289
- // Fetch Jira issue details for the effective key.
342
+ // Fetch Jira issue details for all keys.
290
343
  React.useEffect(() => {
291
- const key = record?.jiraKey?.trim();
292
- if (!key) {
293
- setIssue(null);
294
- setIssueError(null);
344
+ const keys = record?.jiraKeys || [];
345
+ if (keys.length === 0) {
346
+ setIssues({});
347
+ setIssueErrors({});
295
348
  return;
296
349
  }
297
350
 
298
351
  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;
311
- }
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
- }
352
+ setIsFetchingIssues(true);
353
+ setIssueErrors({});
354
+ const results: Record<string, JiraTicket> = {};
355
+ const errors: Record<string, string> = {};
356
+
357
+ await Promise.all(
358
+ keys.map(async (key) => {
359
+ try {
360
+ const resp = await fetch(`/api/jira-issue?key=${encodeURIComponent(key)}`);
361
+ const payload = await resp.json().catch(() => ({}));
362
+ if (!resp.ok) {
363
+ const raw = String(payload?.message || `Failed to fetch Jira issue (${resp.status})`);
364
+ const sanitized = raw.trim().startsWith('<') ? 'Unauthorized or non-JSON response from Jira.' : raw;
365
+ const hint = payload?.hint ? ` ${String(payload.hint)}` : '';
366
+ errors[key] = `${sanitized}${hint}`;
367
+ return;
368
+ }
369
+ results[key] = payload as JiraTicket;
370
+ } catch (e: any) {
371
+ errors[key] = e?.message || 'Failed to fetch Jira issue';
372
+ }
373
+ })
374
+ );
375
+
376
+ setIssues(results);
377
+ setIssueErrors(errors);
378
+ setIsFetchingIssues(false);
319
379
  };
320
380
 
321
381
  void run();
322
- }, [record?.jiraKey]);
382
+ }, [record?.jiraKeys]);
323
383
 
324
384
  const startNew = () => {
325
385
  setDraftScope('section');
@@ -330,20 +390,23 @@ export const JiraTab: React.FunctionComponent = () => {
330
390
  const startEdit = (mode: 'edit-existing' | 'override-page') => {
331
391
  if (mode === 'override-page') {
332
392
  setDraftScope('page');
333
- setDraftKey(record?.jiraKey ?? '');
393
+ setDraftKey(record?.jiraKeys.join(', ') ?? '');
334
394
  setIsEditing(true);
335
395
  return;
336
396
  }
337
397
 
338
398
  const existingScope: JiraScope = record?.scope ?? 'section';
339
399
  setDraftScope(existingScope);
340
- setDraftKey(record?.jiraKey ?? '');
400
+ setDraftKey(record?.jiraKeys.join(', ') ?? '');
341
401
  setIsEditing(true);
342
402
  };
343
403
 
344
404
  const save = () => {
405
+ const normalizedKeys = normalizeJiraKeys(draftKey);
406
+ if (normalizedKeys.length === 0) return;
407
+
345
408
  const next: JiraRecord = {
346
- jiraKey: normalizeJiraKey(draftKey),
409
+ jiraKeys: normalizedKeys,
347
410
  scope: draftScope,
348
411
  anchorRoute: draftScope === 'section' ? sectionRoute : route,
349
412
  updatedAt: new Date().toISOString(),
@@ -450,8 +513,8 @@ export const JiraTab: React.FunctionComponent = () => {
450
513
  </Button>
451
514
  </div>
452
515
 
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>
516
+ <EmptyState icon={InfoCircleIcon} titleText="No Jira issues linked" headingLevel="h3">
517
+ <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
518
  </EmptyState>
456
519
  </div>
457
520
  );
@@ -492,23 +555,26 @@ export const JiraTab: React.FunctionComponent = () => {
492
555
  <Card>
493
556
  <CardBody>
494
557
  <Title headingLevel="h4" size="md" style={{ marginBottom: '1rem' }}>
495
- Jira issue
558
+ Jira issues
496
559
  </Title>
497
560
  <div style={{ display: 'grid', gap: '0.75rem' }}>
498
561
  <div>
499
562
  <div style={{ fontSize: '0.875rem', marginBottom: '0.25rem' }}>
500
- <b>Jira key or URL</b>
563
+ <b>Jira keys or URLs</b>
564
+ <div style={{ fontSize: '0.75rem', color: 'var(--pf-t--global--text--color--subtle)', marginTop: '0.25rem' }}>
565
+ Enter multiple keys separated by commas or new lines (e.g., ABC-123, DEF-456)
566
+ </div>
501
567
  </div>
502
568
  <TextArea
503
569
  value={draftKey}
504
570
  onChange={(_e, v) => setDraftKey(v)}
505
- aria-label="Jira key or URL"
506
- rows={1}
571
+ aria-label="Jira keys or URLs"
572
+ rows={3}
507
573
  />
508
574
  </div>
509
575
 
510
576
  <div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-start', marginTop: '0.5rem' }}>
511
- <Button variant="primary" onClick={save} isDisabled={!normalizeJiraKey(draftKey)}>
577
+ <Button variant="primary" onClick={save} isDisabled={normalizeJiraKeys(draftKey).length === 0}>
512
578
  Save
513
579
  </Button>
514
580
  <Button variant="link" onClick={() => setIsEditing(false)}>
@@ -529,25 +595,55 @@ export const JiraTab: React.FunctionComponent = () => {
529
595
 
530
596
  const scopeLabel =
531
597
  source === 'page' ? 'This page' : source === 'section' ? `Section (${sectionRoute}/*)` : null;
532
- const key = record.jiraKey || '';
598
+ const keys = record.jiraKeys || [];
533
599
  let jiraBaseUrl: string | undefined;
534
600
  try {
535
601
  jiraBaseUrl = typeof process !== 'undefined' && process.env ? process.env.VITE_JIRA_BASE_URL : undefined;
536
602
  } catch (e) {
537
603
  jiraBaseUrl = undefined;
538
604
  }
539
- const url = issue?.url || (jiraBaseUrl ? `${jiraBaseUrl}/browse/${key}` : '');
540
605
 
541
- const parsedSections = parseJiraTemplateSections(issue?.description || '');
542
- const byTitle = new Map(parsedSections.map((s) => [s.title, s.body]));
606
+ const removeKey = (keyToRemove: string) => {
607
+ const store = getStore();
608
+ const storeKey = source === 'page' ? getPageKey(route) : source === 'section' ? getSectionKey(sectionRoute) : null;
609
+ if (!storeKey || !record) return;
610
+
611
+ const updatedKeys = record.jiraKeys.filter(k => k !== keyToRemove);
612
+ if (updatedKeys.length === 0) {
613
+ // If no keys left, remove the entire record
614
+ const { [storeKey]: _removed, ...rest } = store;
615
+ setStore(rest);
616
+ setResolved(loadForRoute(route));
617
+ } else {
618
+ // Update with remaining keys
619
+ const updated: JiraRecord = {
620
+ ...record,
621
+ jiraKeys: updatedKeys,
622
+ updatedAt: new Date().toISOString(),
623
+ };
624
+ const nextStore = { ...store, [storeKey]: updated };
625
+ setStore(nextStore);
626
+ setResolved(loadForRoute(route));
627
+ }
543
628
 
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() : '';
629
+ if (isGitHubConfigured()) {
630
+ (async () => {
631
+ const finalStore = updatedKeys.length === 0
632
+ ? Object.fromEntries(Object.entries(store).filter(([k]) => k !== storeKey))
633
+ : { ...store, [storeKey]: { ...record, jiraKeys: updatedKeys, updatedAt: new Date().toISOString() } };
634
+ const text = JSON.stringify(finalStore, null, 2) + '\n';
635
+ const message = `chore(jira): remove ${keyToRemove} from ${storeKey}`;
636
+ const sha = remoteShaRef.current;
637
+ const write = await githubAdapter.putRepoFile({ path: GH_JIRA_PATH, text, message, sha });
638
+ if (write.success && write.data?.sha) {
639
+ remoteShaRef.current = write.data.sha;
640
+ setRemoteError(null);
641
+ } else {
642
+ setRemoteError(write.error || 'Failed to update Jira store in GitHub');
643
+ }
644
+ })();
645
+ }
646
+ };
551
647
 
552
648
  return (
553
649
  <div style={{ display: 'grid', gap: '1rem' }}>
@@ -582,119 +678,152 @@ export const JiraTab: React.FunctionComponent = () => {
582
678
  </div>
583
679
  </div>
584
680
 
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>
681
+ {/* Loading state */}
682
+ {isFetchingIssues && keys.length > 0 && (
683
+ <div style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem' }}>
684
+ <Spinner size="sm" /> <span>Fetching Jira details…</span>
685
+ </div>
686
+ )}
608
687
 
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>
688
+ {/* Display all issues */}
689
+ <div style={{ display: 'grid', gap: '1rem' }}>
690
+ {keys.map((key) => {
691
+ const issue = issues[key];
692
+ const error = issueErrors[key];
693
+ const url = issue?.url || (jiraBaseUrl ? `${jiraBaseUrl}/browse/${key}` : '');
694
+
695
+ const parsedSections = issue ? parseJiraTemplateSections(issue.description || '') : [];
696
+ const byTitle = new Map(parsedSections.map((s) => [s.title, s.body]));
697
+
698
+ const summary = issue?.summary || '';
699
+ const status = issue?.status || '';
700
+ const priority = issue?.priority || '';
701
+ const assignee = issue?.assignee || '';
702
+ const issueType = issue?.issueType || 'Issue';
703
+ const created = issue?.created ? new Date(issue.created).toLocaleString() : '';
704
+ const updated = issue?.updated ? new Date(issue.updated).toLocaleString() : '';
705
+
706
+ return (
707
+ <Card key={key}>
708
+ <CardBody>
709
+ <div style={{ display: 'grid', gap: '1rem' }}>
710
+ {/* Ticket header */}
711
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
712
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
713
+ <Label color="blue" isCompact>
714
+ {issueType || 'Issue'}
715
+ </Label>
716
+ {url ? (
717
+ <a
718
+ href={url}
719
+ target="_blank"
720
+ rel="noopener noreferrer"
721
+ style={{ display: 'inline-flex', alignItems: 'center', gap: '0.25rem', fontWeight: 600 }}
722
+ >
723
+ {key} <ExternalLinkAltIcon style={{ fontSize: '0.75rem' }} />
724
+ </a>
725
+ ) : (
726
+ <span style={{ fontWeight: 600 }}>{key}</span>
727
+ )}
728
+ </div>
729
+ {keys.length > 1 && (
730
+ <Button variant="link" isDanger onClick={() => removeKey(key)}>
731
+ Remove
732
+ </Button>
733
+ )}
734
+ </div>
635
735
 
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>
736
+ {/* Error state */}
737
+ {error && !isFetchingIssues && (
738
+ <div style={{ fontSize: '0.875rem', color: 'var(--pf-t--global--danger--color--100)' }}>{error}</div>
647
739
  )}
648
- </div>
649
-
650
- <div style={{ height: 1, background: 'var(--pf-t--global--border--color--default)', marginTop: '0.25rem' }} />
651
740
 
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
741
+ {/* Issue details */}
742
+ {!error && issue && (
743
+ <>
744
+ {/* Title */}
745
+ <Title headingLevel="h3" size="lg" style={{ marginTop: '0.25rem' }}>
746
+ {summary || '—'}
658
747
  </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>
748
+
749
+ {/* Chips row */}
750
+ <div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginTop: '0.25rem' }}>
751
+ <Label color="grey" isCompact>
752
+ Status: {status || '—'}
753
+ </Label>
754
+ <Label color="orange" isCompact>
755
+ Priority: {priority || '—'}
756
+ </Label>
757
+ <Label color="grey" isCompact>
758
+ Assignee: {assignee || '—'}
759
+ </Label>
760
+ </div>
761
+
762
+ {/* Dates */}
763
+ <div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', fontSize: '0.875rem', color: 'var(--pf-t--global--text--color--subtle)' }}>
764
+ {created && (
765
+ <span>
766
+ <b>Created:</b> {created}
767
+ </span>
768
+ )}
769
+ {updated && (
770
+ <span>
771
+ <b>Updated:</b> {updated}
772
+ </span>
773
+ )}
774
+ </div>
775
+
776
+ <div style={{ height: 1, background: 'var(--pf-t--global--border--color--default)', marginTop: '0.25rem' }} />
777
+
778
+ {/* Template sections (preferred) */}
779
+ {parsedSections.length > 0 ? (
780
+ <div style={{ display: 'grid', gap: '1rem' }}>
781
+ <div>
782
+ <Title headingLevel="h4" size="md" style={{ marginBottom: '0.5rem' }}>
783
+ Problem statement
784
+ </Title>
785
+ {renderBulletsOrText(byTitle.get('Problem statement') || '')}
786
+ </div>
787
+ <div>
788
+ <Title headingLevel="h4" size="md" style={{ marginBottom: '0.5rem' }}>
789
+ Objective
790
+ </Title>
791
+ {renderBulletsOrText(byTitle.get('Objective') || '')}
792
+ </div>
793
+ <div>
794
+ <Title headingLevel="h4" size="md" style={{ marginBottom: '0.5rem' }}>
795
+ Definition of Done
796
+ </Title>
797
+ {renderBulletsOrText(byTitle.get('Definition of Done') || '')}
798
+ </div>
799
+ </div>
800
+ ) : (
801
+ // Fallback: show the raw description if it doesn't follow the template.
802
+ <div>
803
+ <Title headingLevel="h4" size="md" style={{ marginBottom: '0.5rem' }}>
804
+ Description
805
+ </Title>
806
+ <div style={{ fontSize: '0.875rem', whiteSpace: 'pre-wrap' }}>
807
+ {issue?.description ? stripHtmlTags(issue.description) : (
808
+ <span style={{ color: 'var(--pf-t--global--text--color--subtle)' }}>No description</span>
809
+ )}
810
+ </div>
811
+ </div>
683
812
  )}
684
- </div>
685
- </div>
686
- )}
687
- </>
688
- )}
813
+ </>
814
+ )}
815
+ </div>
816
+ </CardBody>
817
+ </Card>
818
+ );
819
+ })}
820
+ </div>
689
821
 
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>
822
+ <div style={{ marginTop: '0.25rem' }}>
823
+ <Button variant="link" isDanger onClick={remove}>
824
+ Remove all Jira links
825
+ </Button>
826
+ </div>
698
827
  </div>
699
828
  );
700
829
  };