hale-commenting-system 2.2.0 → 2.2.1
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/.claude/settings.local.json +7 -0
- package/.editorconfig +17 -0
- package/.eslintrc.js +75 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +23 -0
- package/.github/workflows/ci.yaml +51 -0
- package/.prettierignore +1 -0
- package/.prettierrc +4 -0
- package/GITHUB_OAUTH_ENV_TEMPLATE.md +53 -0
- package/LICENSE +21 -0
- package/README.md +92 -21
- package/package.json +74 -50
- package/scripts/README.md +42 -0
- package/scripts/integrate.js +440 -0
- package/src/app/AppLayout/AppLayout.tsx +248 -0
- package/src/app/Comments/Comments.tsx +273 -0
- package/src/app/Dashboard/Dashboard.tsx +10 -0
- package/src/app/NotFound/NotFound.tsx +35 -0
- package/src/app/Settings/General/GeneralSettings.tsx +16 -0
- package/src/app/Settings/Profile/ProfileSettings.tsx +18 -0
- package/src/app/Support/Support.tsx +50 -0
- package/src/app/__snapshots__/app.test.tsx.snap +524 -0
- package/src/app/app.css +11 -0
- package/src/app/app.test.tsx +55 -0
- package/src/app/bgimages/Patternfly-Logo.svg +28 -0
- package/src/app/commenting-system/components/CommentOverlay.tsx +93 -0
- package/src/app/commenting-system/components/CommentPanel.tsx +534 -0
- package/src/app/commenting-system/components/CommentPin.tsx +60 -0
- package/src/app/commenting-system/components/DetailsTab.tsx +516 -0
- package/src/app/commenting-system/components/FloatingWidget.tsx +130 -0
- package/src/app/commenting-system/components/JiraTab.tsx +696 -0
- package/src/app/commenting-system/contexts/CommentContext.tsx +1033 -0
- package/src/app/commenting-system/contexts/GitHubAuthContext.tsx +84 -0
- package/{dist/index.d.ts → src/app/commenting-system/index.ts} +5 -4
- package/src/app/commenting-system/services/githubAdapter.ts +359 -0
- package/src/app/commenting-system/types/index.ts +27 -0
- package/src/app/commenting-system/utils/version.ts +19 -0
- package/src/app/index.tsx +22 -0
- package/src/app/routes.tsx +81 -0
- package/src/app/utils/useDocumentTitle.ts +13 -0
- package/src/favicon.png +0 -0
- package/src/index.html +18 -0
- package/src/index.tsx +25 -0
- package/src/test/setup.ts +33 -0
- package/src/typings.d.ts +12 -0
- package/stylePaths.js +14 -0
- package/tsconfig.json +34 -0
- package/vitest.config.ts +19 -0
- package/webpack.common.js +139 -0
- package/webpack.dev.js +318 -0
- package/webpack.prod.js +38 -0
- package/bin/detect.d.ts +0 -10
- package/bin/detect.js +0 -134
- package/bin/generators.d.ts +0 -20
- package/bin/generators.js +0 -272
- package/bin/hale-commenting.js +0 -4
- package/bin/index.d.ts +0 -2
- package/bin/index.js +0 -61
- package/bin/onboarding.d.ts +0 -1
- package/bin/onboarding.js +0 -395
- package/bin/postinstall.d.ts +0 -2
- package/bin/postinstall.js +0 -65
- package/bin/validators.d.ts +0 -2
- package/bin/validators.js +0 -66
- package/dist/cli/detect.d.ts +0 -10
- package/dist/cli/detect.js +0 -134
- package/dist/cli/generators.d.ts +0 -20
- package/dist/cli/generators.js +0 -272
- package/dist/cli/index.d.ts +0 -2
- package/dist/cli/index.js +0 -61
- package/dist/cli/onboarding.d.ts +0 -1
- package/dist/cli/onboarding.js +0 -395
- package/dist/cli/postinstall.d.ts +0 -2
- package/dist/cli/postinstall.js +0 -65
- package/dist/cli/validators.d.ts +0 -2
- package/dist/cli/validators.js +0 -66
- package/dist/components/CommentOverlay.d.ts +0 -2
- package/dist/components/CommentOverlay.js +0 -101
- package/dist/components/CommentPanel.d.ts +0 -6
- package/dist/components/CommentPanel.js +0 -334
- package/dist/components/CommentPin.d.ts +0 -11
- package/dist/components/CommentPin.js +0 -64
- package/dist/components/DetailsTab.d.ts +0 -2
- package/dist/components/DetailsTab.js +0 -380
- package/dist/components/FloatingWidget.d.ts +0 -8
- package/dist/components/FloatingWidget.js +0 -128
- package/dist/components/JiraTab.d.ts +0 -2
- package/dist/components/JiraTab.js +0 -507
- package/dist/contexts/CommentContext.d.ts +0 -30
- package/dist/contexts/CommentContext.js +0 -891
- package/dist/contexts/GitHubAuthContext.d.ts +0 -13
- package/dist/contexts/GitHubAuthContext.js +0 -96
- package/dist/index.js +0 -27
- package/dist/services/githubAdapter.d.ts +0 -56
- package/dist/services/githubAdapter.js +0 -321
- package/dist/types/index.d.ts +0 -25
- package/dist/types/index.js +0 -2
- package/dist/utils/version.d.ts +0 -1
- package/dist/utils/version.js +0 -23
- 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
|
+
|