hale-commenting-system 2.2.0 → 2.2.2

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 (99) hide show
  1. package/.claude/settings.local.json +7 -0
  2. package/.editorconfig +17 -0
  3. package/.eslintrc.js +75 -0
  4. package/.github/ISSUE_TEMPLATE/bug_report.md +23 -0
  5. package/.github/workflows/ci.yaml +51 -0
  6. package/.prettierignore +1 -0
  7. package/.prettierrc +4 -0
  8. package/GITHUB_OAUTH_ENV_TEMPLATE.md +53 -0
  9. package/LICENSE +21 -0
  10. package/README.md +92 -21
  11. package/package.json +74 -50
  12. package/scripts/README.md +42 -0
  13. package/scripts/integrate.js +472 -0
  14. package/src/app/AppLayout/AppLayout.tsx +248 -0
  15. package/src/app/Comments/Comments.tsx +273 -0
  16. package/src/app/Dashboard/Dashboard.tsx +10 -0
  17. package/src/app/NotFound/NotFound.tsx +35 -0
  18. package/src/app/Settings/General/GeneralSettings.tsx +16 -0
  19. package/src/app/Settings/Profile/ProfileSettings.tsx +18 -0
  20. package/src/app/Support/Support.tsx +50 -0
  21. package/src/app/__snapshots__/app.test.tsx.snap +524 -0
  22. package/src/app/app.css +11 -0
  23. package/src/app/app.test.tsx +55 -0
  24. package/src/app/bgimages/Patternfly-Logo.svg +28 -0
  25. package/src/app/commenting-system/components/CommentOverlay.tsx +93 -0
  26. package/src/app/commenting-system/components/CommentPanel.tsx +534 -0
  27. package/src/app/commenting-system/components/CommentPin.tsx +60 -0
  28. package/src/app/commenting-system/components/DetailsTab.tsx +516 -0
  29. package/src/app/commenting-system/components/FloatingWidget.tsx +130 -0
  30. package/src/app/commenting-system/components/JiraTab.tsx +696 -0
  31. package/src/app/commenting-system/contexts/CommentContext.tsx +1033 -0
  32. package/src/app/commenting-system/contexts/GitHubAuthContext.tsx +84 -0
  33. package/{dist/index.d.ts → src/app/commenting-system/index.ts} +5 -4
  34. package/src/app/commenting-system/services/githubAdapter.ts +359 -0
  35. package/src/app/commenting-system/types/index.ts +27 -0
  36. package/src/app/commenting-system/utils/version.ts +19 -0
  37. package/src/app/index.tsx +22 -0
  38. package/src/app/routes.tsx +81 -0
  39. package/src/app/utils/useDocumentTitle.ts +13 -0
  40. package/src/favicon.png +0 -0
  41. package/src/index.html +18 -0
  42. package/src/index.tsx +25 -0
  43. package/src/test/setup.ts +33 -0
  44. package/src/typings.d.ts +12 -0
  45. package/stylePaths.js +14 -0
  46. package/tsconfig.json +34 -0
  47. package/vitest.config.ts +19 -0
  48. package/webpack.common.js +139 -0
  49. package/webpack.dev.js +318 -0
  50. package/webpack.prod.js +38 -0
  51. package/bin/detect.d.ts +0 -10
  52. package/bin/detect.js +0 -134
  53. package/bin/generators.d.ts +0 -20
  54. package/bin/generators.js +0 -272
  55. package/bin/hale-commenting.js +0 -4
  56. package/bin/index.d.ts +0 -2
  57. package/bin/index.js +0 -61
  58. package/bin/onboarding.d.ts +0 -1
  59. package/bin/onboarding.js +0 -395
  60. package/bin/postinstall.d.ts +0 -2
  61. package/bin/postinstall.js +0 -65
  62. package/bin/validators.d.ts +0 -2
  63. package/bin/validators.js +0 -66
  64. package/dist/cli/detect.d.ts +0 -10
  65. package/dist/cli/detect.js +0 -134
  66. package/dist/cli/generators.d.ts +0 -20
  67. package/dist/cli/generators.js +0 -272
  68. package/dist/cli/index.d.ts +0 -2
  69. package/dist/cli/index.js +0 -61
  70. package/dist/cli/onboarding.d.ts +0 -1
  71. package/dist/cli/onboarding.js +0 -395
  72. package/dist/cli/postinstall.d.ts +0 -2
  73. package/dist/cli/postinstall.js +0 -65
  74. package/dist/cli/validators.d.ts +0 -2
  75. package/dist/cli/validators.js +0 -66
  76. package/dist/components/CommentOverlay.d.ts +0 -2
  77. package/dist/components/CommentOverlay.js +0 -101
  78. package/dist/components/CommentPanel.d.ts +0 -6
  79. package/dist/components/CommentPanel.js +0 -334
  80. package/dist/components/CommentPin.d.ts +0 -11
  81. package/dist/components/CommentPin.js +0 -64
  82. package/dist/components/DetailsTab.d.ts +0 -2
  83. package/dist/components/DetailsTab.js +0 -380
  84. package/dist/components/FloatingWidget.d.ts +0 -8
  85. package/dist/components/FloatingWidget.js +0 -128
  86. package/dist/components/JiraTab.d.ts +0 -2
  87. package/dist/components/JiraTab.js +0 -507
  88. package/dist/contexts/CommentContext.d.ts +0 -30
  89. package/dist/contexts/CommentContext.js +0 -891
  90. package/dist/contexts/GitHubAuthContext.d.ts +0 -13
  91. package/dist/contexts/GitHubAuthContext.js +0 -96
  92. package/dist/index.js +0 -27
  93. package/dist/services/githubAdapter.d.ts +0 -56
  94. package/dist/services/githubAdapter.js +0 -321
  95. package/dist/types/index.d.ts +0 -25
  96. package/dist/types/index.js +0 -2
  97. package/dist/utils/version.d.ts +0 -1
  98. package/dist/utils/version.js +0 -23
  99. package/templates/webpack-middleware.js +0 -226
@@ -0,0 +1,696 @@
1
+ import * as React from 'react';
2
+ import { useLocation } from 'react-router-dom';
3
+ import {
4
+ Button,
5
+ Card,
6
+ CardBody,
7
+ EmptyState,
8
+ EmptyStateBody,
9
+ Label,
10
+ Spinner,
11
+ TextArea,
12
+ Title,
13
+ } from '@patternfly/react-core';
14
+ import { ExternalLinkAltIcon, InfoCircleIcon } from '@patternfly/react-icons';
15
+ import { githubAdapter, isGitHubConfigured } from '../services/githubAdapter';
16
+
17
+ type JiraScope = 'page' | 'section';
18
+
19
+ type JiraTicket = {
20
+ key: string;
21
+ url: string;
22
+ summary: string;
23
+ status: string;
24
+ assignee: string;
25
+ issueType: string;
26
+ priority: string;
27
+ created?: string;
28
+ updated?: string;
29
+ description?: string;
30
+ };
31
+
32
+ interface JiraRecord {
33
+ jiraKey: string;
34
+ scope: JiraScope;
35
+ anchorRoute: string;
36
+ updatedAt: string;
37
+ }
38
+
39
+ type JiraStore = Record<string, JiraRecord>;
40
+
41
+ const STORAGE_KEY = 'hale_commenting_jira_v1';
42
+ const GH_JIRA_PATH = '.hale/jira.json';
43
+
44
+ function safeParseStore(raw: string | null): JiraStore {
45
+ if (!raw) return {};
46
+ try {
47
+ const parsed = JSON.parse(raw) as unknown;
48
+ if (!parsed || typeof parsed !== 'object') return {};
49
+ return parsed as JiraStore;
50
+ } catch {
51
+ return {};
52
+ }
53
+ }
54
+
55
+ function getStore(): JiraStore {
56
+ if (typeof window === 'undefined') return {};
57
+ return safeParseStore(window.localStorage.getItem(STORAGE_KEY));
58
+ }
59
+
60
+ function setStore(next: JiraStore) {
61
+ if (typeof window === 'undefined') return;
62
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
63
+ }
64
+
65
+ function normalizePathname(pathname: string): string {
66
+ if (!pathname) return '/';
67
+ const cleaned = pathname.split('?')[0].split('#')[0];
68
+ return cleaned === '' ? '/' : cleaned;
69
+ }
70
+
71
+ function getSectionRoute(pathname: string): string {
72
+ const normalized = normalizePathname(pathname);
73
+ const parts = normalized.split('/').filter(Boolean);
74
+ if (parts.length === 0) return '/';
75
+ return `/${parts[0]}`;
76
+ }
77
+
78
+ function getPageKey(pathname: string): string {
79
+ return `page:${normalizePathname(pathname)}`;
80
+ }
81
+
82
+ function getSectionKey(sectionRoute: string): string {
83
+ return `section:${normalizePathname(sectionRoute)}/*`;
84
+ }
85
+
86
+ function loadForRoute(pathname: string): { record: JiraRecord | null; source: 'page' | 'section' | null } {
87
+ const store = getStore();
88
+ const pageKey = getPageKey(pathname);
89
+ if (store[pageKey]) return { record: store[pageKey], source: 'page' };
90
+
91
+ const sectionRoute = getSectionRoute(pathname);
92
+ const sectionKey = getSectionKey(sectionRoute);
93
+ if (store[sectionKey]) return { record: store[sectionKey], source: 'section' };
94
+
95
+ return { record: null, source: null };
96
+ }
97
+
98
+ const normalizeJiraKey = (input: string): string => {
99
+ 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();
105
+ };
106
+
107
+ const stripHtmlTags = (input: string): string => {
108
+ // Jira sometimes returns HTML-ish strings (or users paste HTML). For our UI,
109
+ // show readable plain text.
110
+ return input.replace(/<[^>]*>/g, '').replace(/\r\n/g, '\n').trim();
111
+ };
112
+
113
+ type ParsedSection = { title: string; body: string };
114
+
115
+ const canonicalizeSectionTitle = (raw: string): string => {
116
+ const t = raw.trim().replace(/\s+/g, ' ');
117
+ const lower = t.toLowerCase();
118
+ if (lower === 'problem statement') return 'Problem statement';
119
+ if (lower === 'objective') return 'Objective';
120
+ if (lower === 'definition of done') return 'Definition of Done';
121
+ if (lower === 'job stories') return 'Job Stories';
122
+ if (lower === 'stakeholders') return 'Stakeholders';
123
+ return t;
124
+ };
125
+
126
+ const parseJiraTemplateSections = (rawText: string): ParsedSection[] => {
127
+ const text = stripHtmlTags(rawText || '');
128
+ if (!text) return [];
129
+
130
+ const lines = text.split('\n');
131
+ const sections: ParsedSection[] = [];
132
+
133
+ let currentTitle: string | null = null;
134
+ let currentBody: string[] = [];
135
+
136
+ const flush = () => {
137
+ if (!currentTitle) return;
138
+ const body = currentBody.join('\n').trim();
139
+ sections.push({ title: canonicalizeSectionTitle(currentTitle), body });
140
+ currentTitle = null;
141
+ currentBody = [];
142
+ };
143
+
144
+ const isKnownHeading = (t: string) => {
145
+ const lower = t.trim().toLowerCase();
146
+ return (
147
+ lower === 'problem statement' ||
148
+ lower === 'objective' ||
149
+ lower === 'job stories' ||
150
+ lower === 'stakeholders' ||
151
+ lower === 'definition of done'
152
+ );
153
+ };
154
+
155
+ for (const rawLine of lines) {
156
+ const line = rawLine.trimRight();
157
+ const trimmed = line.trim();
158
+
159
+ // Jira wiki-style headings often come through as: "h3. Problem statement"
160
+ const m = trimmed.match(/^h3\.\s*(.+)$/i);
161
+ if (m?.[1]) {
162
+ flush();
163
+ currentTitle = m[1].trim();
164
+ continue;
165
+ }
166
+
167
+ // After HTML stripping, rendered headings may be left as plain lines like "Problem statement"
168
+ // Treat them as headings when they match a known template title.
169
+ if (isKnownHeading(trimmed)) {
170
+ flush();
171
+ currentTitle = trimmed;
172
+ continue;
173
+ }
174
+
175
+ if (!currentTitle) {
176
+ // ignore leading preamble until we hit the first known heading
177
+ continue;
178
+ }
179
+
180
+ currentBody.push(line);
181
+ }
182
+
183
+ flush();
184
+ return sections;
185
+ };
186
+
187
+ const renderBulletsOrText = (text: string) => {
188
+ const cleaned = stripHtmlTags(text || '');
189
+ if (!cleaned) return (
190
+ <div style={{ fontSize: '0.875rem', color: 'var(--pf-t--global--text--color--subtle)' }}>—</div>
191
+ );
192
+
193
+ const lines = cleaned
194
+ .split('\n')
195
+ .map((l) => l.trim())
196
+ .filter(Boolean);
197
+
198
+ if (lines.length === 0) {
199
+ return <div style={{ fontSize: '0.875rem', whiteSpace: 'pre-wrap' }}>{cleaned}</div>;
200
+ }
201
+
202
+ const bulletLines = lines.filter((l) => /^(\*{1,2}\s+|[-•]\s+)/.test(l));
203
+ const hasBullets = bulletLines.length > 0;
204
+
205
+ if (!hasBullets) {
206
+ return <div style={{ fontSize: '0.875rem', whiteSpace: 'pre-wrap' }}>{cleaned}</div>;
207
+ }
208
+
209
+ const items = lines
210
+ .map((l) => l.replace(/^(\*{1,2}\s+|[-•]\s+)/, '').trim())
211
+ .filter(Boolean);
212
+
213
+ return (
214
+ <ul style={{ margin: 0, paddingLeft: '1.25rem', display: 'grid', gap: '0.5rem' }}>
215
+ {items.map((item, idx) => (
216
+ // eslint-disable-next-line react/no-array-index-key
217
+ <li key={idx} style={{ fontSize: '0.875rem' }}>
218
+ {item}
219
+ </li>
220
+ ))}
221
+ </ul>
222
+ );
223
+ };
224
+
225
+ export const JiraTab: React.FunctionComponent = () => {
226
+ const location = useLocation();
227
+ const route = normalizePathname(location.pathname);
228
+ const sectionRoute = getSectionRoute(route);
229
+
230
+ const [{ record, source }, setResolved] = React.useState(() => loadForRoute(route));
231
+ const [isEditing, setIsEditing] = React.useState(false);
232
+
233
+ const [draftScope, setDraftScope] = React.useState<JiraScope>('section');
234
+ const [draftKey, setDraftKey] = React.useState('');
235
+
236
+ const [isLoadingRemote, setIsLoadingRemote] = React.useState(false);
237
+ const [remoteError, setRemoteError] = React.useState<string | null>(null);
238
+ const remoteShaRef = React.useRef<string | undefined>(undefined);
239
+
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);
243
+
244
+ React.useEffect(() => {
245
+ setResolved(loadForRoute(route));
246
+ setIsEditing(false);
247
+ }, [route]);
248
+
249
+ // Load Jira store from GitHub if configured.
250
+ React.useEffect(() => {
251
+ const load = async () => {
252
+ if (!isGitHubConfigured()) return;
253
+ setIsLoadingRemote(true);
254
+ setRemoteError(null);
255
+ try {
256
+ const local = getStore();
257
+ const res = await githubAdapter.getRepoFile(GH_JIRA_PATH);
258
+ if (!res.success) {
259
+ setRemoteError(res.error || 'Failed to load Jira store from GitHub');
260
+ return;
261
+ }
262
+
263
+ if (!res.data) {
264
+ // No remote file yet: initialize from local if we have anything.
265
+ if (Object.keys(local).length > 0) {
266
+ const created = await githubAdapter.putRepoFile({
267
+ path: GH_JIRA_PATH,
268
+ text: JSON.stringify(local, null, 2) + '\n',
269
+ message: 'chore(jira): initialize jira store',
270
+ });
271
+ if (created.success) remoteShaRef.current = created.data?.sha;
272
+ }
273
+ return;
274
+ }
275
+
276
+ remoteShaRef.current = res.data.sha;
277
+ const parsed = safeParseStore(res.data.text);
278
+ setStore(parsed);
279
+ setResolved(loadForRoute(route));
280
+ } finally {
281
+ setIsLoadingRemote(false);
282
+ }
283
+ };
284
+
285
+ void load();
286
+ // eslint-disable-next-line react-hooks/exhaustive-deps
287
+ }, []);
288
+
289
+ // Fetch Jira issue details for the effective key.
290
+ React.useEffect(() => {
291
+ const key = record?.jiraKey?.trim();
292
+ if (!key) {
293
+ setIssue(null);
294
+ setIssueError(null);
295
+ return;
296
+ }
297
+
298
+ 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
+ }
319
+ };
320
+
321
+ void run();
322
+ }, [record?.jiraKey]);
323
+
324
+ const startNew = () => {
325
+ setDraftScope('section');
326
+ setDraftKey('');
327
+ setIsEditing(true);
328
+ };
329
+
330
+ const startEdit = (mode: 'edit-existing' | 'override-page') => {
331
+ if (mode === 'override-page') {
332
+ setDraftScope('page');
333
+ setDraftKey(record?.jiraKey ?? '');
334
+ setIsEditing(true);
335
+ return;
336
+ }
337
+
338
+ const existingScope: JiraScope = record?.scope ?? 'section';
339
+ setDraftScope(existingScope);
340
+ setDraftKey(record?.jiraKey ?? '');
341
+ setIsEditing(true);
342
+ };
343
+
344
+ const save = () => {
345
+ const next: JiraRecord = {
346
+ jiraKey: normalizeJiraKey(draftKey),
347
+ scope: draftScope,
348
+ anchorRoute: draftScope === 'section' ? sectionRoute : route,
349
+ updatedAt: new Date().toISOString(),
350
+ };
351
+
352
+ const store = getStore();
353
+ const key = draftScope === 'section' ? getSectionKey(sectionRoute) : getPageKey(route);
354
+ const nextStore = { ...store, [key]: next };
355
+ setStore(nextStore);
356
+
357
+ setResolved(loadForRoute(route));
358
+ setIsEditing(false);
359
+
360
+ if (isGitHubConfigured()) {
361
+ (async () => {
362
+ const text = JSON.stringify(nextStore, null, 2) + '\n';
363
+ const message = `chore(jira): update ${key}`;
364
+ const sha = remoteShaRef.current;
365
+
366
+ const write = await githubAdapter.putRepoFile({ path: GH_JIRA_PATH, text, message, sha });
367
+ if (write.success && write.data?.sha) {
368
+ remoteShaRef.current = write.data.sha;
369
+ setRemoteError(null);
370
+ return;
371
+ }
372
+
373
+ const refreshed = await githubAdapter.getRepoFile(GH_JIRA_PATH);
374
+ if (refreshed.success && refreshed.data?.sha) {
375
+ remoteShaRef.current = refreshed.data.sha;
376
+ const retry = await githubAdapter.putRepoFile({
377
+ path: GH_JIRA_PATH,
378
+ text,
379
+ message,
380
+ sha: refreshed.data.sha,
381
+ });
382
+ if (retry.success && retry.data?.sha) {
383
+ remoteShaRef.current = retry.data.sha;
384
+ setRemoteError(null);
385
+ return;
386
+ }
387
+ }
388
+
389
+ setRemoteError(write.error || 'Failed to save Jira store to GitHub');
390
+ })();
391
+ }
392
+ };
393
+
394
+ const remove = () => {
395
+ const store = getStore();
396
+ const keyToRemove =
397
+ source === 'page' ? getPageKey(route) : source === 'section' ? getSectionKey(sectionRoute) : null;
398
+ if (!keyToRemove) return;
399
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
400
+ const { [keyToRemove]: _removed, ...rest } = store;
401
+ setStore(rest);
402
+ setResolved(loadForRoute(route));
403
+ setIsEditing(false);
404
+
405
+ if (isGitHubConfigured()) {
406
+ (async () => {
407
+ const text = JSON.stringify(rest, null, 2) + '\n';
408
+ const message = `chore(jira): remove ${keyToRemove}`;
409
+ const sha = remoteShaRef.current;
410
+ const write = await githubAdapter.putRepoFile({ path: GH_JIRA_PATH, text, message, sha });
411
+ if (write.success && write.data?.sha) {
412
+ remoteShaRef.current = write.data.sha;
413
+ setRemoteError(null);
414
+ return;
415
+ }
416
+ setRemoteError(write.error || 'Failed to update Jira store in GitHub');
417
+ })();
418
+ }
419
+ };
420
+
421
+ const remoteStatusLine = isGitHubConfigured()
422
+ ? isLoadingRemote
423
+ ? 'Loading Jira store from GitHub…'
424
+ : remoteError
425
+ ? `GitHub sync: ${remoteError}`
426
+ : 'GitHub sync enabled'
427
+ : null;
428
+
429
+ const isInherited = source === 'section' && record?.anchorRoute !== route;
430
+
431
+ if (!record && !isEditing) {
432
+ return (
433
+ <div style={{ display: 'grid', gap: '1rem' }}>
434
+ <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: '1rem' }}>
435
+ <div>
436
+ <Title headingLevel="h3" size="lg">
437
+ Jira
438
+ </Title>
439
+ <div style={{ fontSize: '0.875rem', color: 'var(--pf-t--global--text--color--subtle)' }}>
440
+ No Jira issue set for <b>{route}</b>.
441
+ </div>
442
+ {remoteStatusLine && (
443
+ <div style={{ fontSize: '0.75rem', color: 'var(--pf-t--global--text--color--subtle)', marginTop: '0.25rem' }}>
444
+ {remoteStatusLine}
445
+ </div>
446
+ )}
447
+ </div>
448
+ <Button variant="primary" onClick={startNew}>
449
+ Add Jira issue
450
+ </Button>
451
+ </div>
452
+
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>
455
+ </EmptyState>
456
+ </div>
457
+ );
458
+ }
459
+
460
+ if (isEditing) {
461
+ const effectiveAnchor = draftScope === 'section' ? `${sectionRoute}/*` : route;
462
+ return (
463
+ <div style={{ display: 'grid', gap: '1rem' }}>
464
+ <div style={{ display: 'grid', gap: '0.5rem' }}>
465
+ <div>
466
+ <Title headingLevel="h3" size="lg">
467
+ Edit Jira
468
+ </Title>
469
+ <div style={{ fontSize: '0.875rem', color: 'var(--pf-t--global--text--color--subtle)' }}>
470
+ Applies to: <b>{effectiveAnchor}</b>
471
+ </div>
472
+ {remoteStatusLine && (
473
+ <div style={{ fontSize: '0.75rem', color: 'var(--pf-t--global--text--color--subtle)', marginTop: '0.25rem' }}>
474
+ {remoteStatusLine}
475
+ </div>
476
+ )}
477
+ </div>
478
+
479
+ <div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
480
+ <Button variant={draftScope === 'page' ? 'primary' : 'secondary'} onClick={() => setDraftScope('page')}>
481
+ This page only
482
+ </Button>
483
+ <Button
484
+ variant={draftScope === 'section' ? 'primary' : 'secondary'}
485
+ onClick={() => setDraftScope('section')}
486
+ >
487
+ This section
488
+ </Button>
489
+ </div>
490
+ </div>
491
+
492
+ <Card>
493
+ <CardBody>
494
+ <Title headingLevel="h4" size="md" style={{ marginBottom: '1rem' }}>
495
+ Jira issue
496
+ </Title>
497
+ <div style={{ display: 'grid', gap: '0.75rem' }}>
498
+ <div>
499
+ <div style={{ fontSize: '0.875rem', marginBottom: '0.25rem' }}>
500
+ <b>Jira key or URL</b>
501
+ </div>
502
+ <TextArea
503
+ value={draftKey}
504
+ onChange={(_e, v) => setDraftKey(v)}
505
+ aria-label="Jira key or URL"
506
+ rows={1}
507
+ />
508
+ </div>
509
+
510
+ <div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-start', marginTop: '0.5rem' }}>
511
+ <Button variant="primary" onClick={save} isDisabled={!normalizeJiraKey(draftKey)}>
512
+ Save
513
+ </Button>
514
+ <Button variant="link" onClick={() => setIsEditing(false)}>
515
+ Cancel
516
+ </Button>
517
+ </div>
518
+ </div>
519
+ </CardBody>
520
+ </Card>
521
+ </div>
522
+ );
523
+ }
524
+
525
+ // View mode
526
+ if (!record) {
527
+ return null;
528
+ }
529
+
530
+ const scopeLabel =
531
+ source === 'page' ? 'This page' : source === 'section' ? `Section (${sectionRoute}/*)` : null;
532
+ const key = record.jiraKey || '';
533
+ const url = issue?.url || (process.env.VITE_JIRA_BASE_URL ? `${process.env.VITE_JIRA_BASE_URL}/browse/${key}` : '');
534
+
535
+ const parsedSections = parseJiraTemplateSections(issue?.description || '');
536
+ const byTitle = new Map(parsedSections.map((s) => [s.title, s.body]));
537
+
538
+ const summary = issue?.summary || '';
539
+ const status = issue?.status || '';
540
+ const priority = issue?.priority || '';
541
+ const assignee = issue?.assignee || '';
542
+ const issueType = issue?.issueType || 'Issue';
543
+ const created = issue?.created ? new Date(issue.created).toLocaleString() : '';
544
+ const updated = issue?.updated ? new Date(issue.updated).toLocaleString() : '';
545
+
546
+ return (
547
+ <div style={{ display: 'grid', gap: '1rem' }}>
548
+ <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: '1rem' }}>
549
+ <div>
550
+ <Title headingLevel="h3" size="lg">
551
+ Jira
552
+ </Title>
553
+ {scopeLabel && (
554
+ <div style={{ fontSize: '0.875rem', color: 'var(--pf-t--global--text--color--subtle)' }}>
555
+ Scope: <b>{scopeLabel}</b>
556
+ {source === 'section' ? ` (applies to ${record.anchorRoute}/*)` : ''}
557
+ {isInherited ? ` (inherited)` : ''}
558
+ </div>
559
+ )}
560
+ {remoteStatusLine && (
561
+ <div style={{ fontSize: '0.75rem', color: 'var(--pf-t--global--text--color--subtle)', marginTop: '0.25rem' }}>
562
+ {remoteStatusLine}
563
+ </div>
564
+ )}
565
+ </div>
566
+
567
+ <div style={{ display: 'flex', gap: '8px' }}>
568
+ {isInherited && (
569
+ <Button variant="secondary" onClick={() => startEdit('override-page')}>
570
+ Override for this page
571
+ </Button>
572
+ )}
573
+ <Button variant="secondary" onClick={() => startEdit('edit-existing')}>
574
+ Edit
575
+ </Button>
576
+ </div>
577
+ </div>
578
+
579
+ <Card>
580
+ <CardBody>
581
+ <div style={{ display: 'grid', gap: '1rem' }}>
582
+ {/* Ticket header */}
583
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
584
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
585
+ <Label color="blue" isCompact>
586
+ {issueType || 'Issue'}
587
+ </Label>
588
+ {url ? (
589
+ <a
590
+ href={url}
591
+ target="_blank"
592
+ rel="noopener noreferrer"
593
+ style={{ display: 'inline-flex', alignItems: 'center', gap: '0.25rem', fontWeight: 600 }}
594
+ >
595
+ {key} <ExternalLinkAltIcon style={{ fontSize: '0.75rem' }} />
596
+ </a>
597
+ ) : (
598
+ <span style={{ fontWeight: 600 }}>{key}</span>
599
+ )}
600
+ </div>
601
+ </div>
602
+
603
+ {/* Loading / error */}
604
+ {isFetchingIssue ? (
605
+ <div style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem' }}>
606
+ <Spinner size="sm" /> <span>Fetching Jira details…</span>
607
+ </div>
608
+ ) : issueError ? (
609
+ <div style={{ fontSize: '0.875rem', color: 'var(--pf-t--global--danger--color--100)' }}>{issueError}</div>
610
+ ) : (
611
+ <>
612
+ {/* Title */}
613
+ <Title headingLevel="h3" size="lg" style={{ marginTop: '0.25rem' }}>
614
+ {summary || '—'}
615
+ </Title>
616
+
617
+ {/* Chips row */}
618
+ <div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginTop: '0.25rem' }}>
619
+ <Label color="grey" isCompact>
620
+ Status: {status || '—'}
621
+ </Label>
622
+ <Label color="orange" isCompact>
623
+ Priority: {priority || '—'}
624
+ </Label>
625
+ <Label color="grey" isCompact>
626
+ Assignee: {assignee || '—'}
627
+ </Label>
628
+ </div>
629
+
630
+ {/* Dates */}
631
+ <div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', fontSize: '0.875rem', color: 'var(--pf-t--global--text--color--subtle)' }}>
632
+ {created && (
633
+ <span>
634
+ <b>Created:</b> {created}
635
+ </span>
636
+ )}
637
+ {updated && (
638
+ <span>
639
+ <b>Updated:</b> {updated}
640
+ </span>
641
+ )}
642
+ </div>
643
+
644
+ <div style={{ height: 1, background: 'var(--pf-t--global--border--color--default)', marginTop: '0.25rem' }} />
645
+
646
+ {/* Template sections (preferred) */}
647
+ {parsedSections.length > 0 ? (
648
+ <div style={{ display: 'grid', gap: '1rem' }}>
649
+ <div>
650
+ <Title headingLevel="h4" size="md" style={{ marginBottom: '0.5rem' }}>
651
+ Problem statement
652
+ </Title>
653
+ {renderBulletsOrText(byTitle.get('Problem statement') || '')}
654
+ </div>
655
+ <div>
656
+ <Title headingLevel="h4" size="md" style={{ marginBottom: '0.5rem' }}>
657
+ Objective
658
+ </Title>
659
+ {renderBulletsOrText(byTitle.get('Objective') || '')}
660
+ </div>
661
+ <div>
662
+ <Title headingLevel="h4" size="md" style={{ marginBottom: '0.5rem' }}>
663
+ Definition of Done
664
+ </Title>
665
+ {renderBulletsOrText(byTitle.get('Definition of Done') || '')}
666
+ </div>
667
+ </div>
668
+ ) : (
669
+ // Fallback: show the raw description if it doesn't follow the template.
670
+ <div>
671
+ <Title headingLevel="h4" size="md" style={{ marginBottom: '0.5rem' }}>
672
+ Description
673
+ </Title>
674
+ <div style={{ fontSize: '0.875rem', whiteSpace: 'pre-wrap' }}>
675
+ {issue?.description ? stripHtmlTags(issue.description) : (
676
+ <span style={{ color: 'var(--pf-t--global--text--color--subtle)' }}>No description</span>
677
+ )}
678
+ </div>
679
+ </div>
680
+ )}
681
+ </>
682
+ )}
683
+
684
+ <div style={{ marginTop: '0.25rem' }}>
685
+ <Button variant="link" isDanger onClick={remove}>
686
+ Remove Jira link
687
+ </Button>
688
+ </div>
689
+ </div>
690
+ </CardBody>
691
+ </Card>
692
+ </div>
693
+ );
694
+ };
695
+
696
+