@infinisys/is-devops 0.0.0-stage → 0.1.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/README.md +75 -2
- package/client.js +1667 -0
- package/cordis.patch.yml +4 -0
- package/icon.svg +13 -0
- package/index.js +955 -0
- package/lib/bot-identity.js +115 -0
- package/lib/commit-scope.js +249 -0
- package/lib/git-exec.js +135 -0
- package/lib/git-parse.js +176 -0
- package/lib/git-repository.js +1364 -0
- package/lib/git-tool.js +223 -0
- package/lib/gitfox-api.js +600 -0
- package/lib/project-discovery.js +62 -0
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/package.json +48 -4
package/client.js
ADDED
|
@@ -0,0 +1,1667 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client half of the Infinity DevOps bundle.
|
|
3
|
+
*
|
|
4
|
+
* One global panel (`infinity-devops`) reads GitFox through the Host's
|
|
5
|
+
* authenticated proxy. The browser never receives the GitFox token, and this
|
|
6
|
+
* module stays independent of the settings page implementation: it only calls
|
|
7
|
+
* the stable `/infinity/devops/api/v1/*` surface.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
{
|
|
11
|
+
const PANEL_KEY = 'infinity-devops';
|
|
12
|
+
const STORAGE_KEY = 'infinity.devops.project';
|
|
13
|
+
const CSS_TAG = '@infinisys/is-devops/DevOpsPanel.css';
|
|
14
|
+
const PLUGIN_ID = '@infinisys/is-devops';
|
|
15
|
+
const PROXY = '/infinity/devops';
|
|
16
|
+
|
|
17
|
+
const CSS = `
|
|
18
|
+
.is-devops-root{height:100%;min-height:0;display:flex;flex-direction:column;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px;overflow:hidden}
|
|
19
|
+
.is-devops-top{flex:none;display:flex;flex-direction:column;gap:12px;padding:18px 24px 12px;border-bottom:1px solid var(--dsw-alias-border-l1)}
|
|
20
|
+
.is-devops-head{display:flex;align-items:flex-start;gap:16px;flex-wrap:wrap}
|
|
21
|
+
.is-devops-title{font-size:18px;font-weight:600;line-height:26px}
|
|
22
|
+
.is-devops-sub{font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary);margin-top:2px}
|
|
23
|
+
.is-devops-headTools{display:flex;align-items:center;gap:8px;margin-left:auto;flex-wrap:wrap}
|
|
24
|
+
.is-devops-select{box-sizing:border-box;height:32px;max-width:320px;border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;padding:0 8px;outline:none}
|
|
25
|
+
.is-devops-select:focus-visible{border-color:var(--dsw-alias-brand-primary-new-colorprimary-new-color,var(--dsw-alias-link))}
|
|
26
|
+
.is-devops-btn{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;gap:6px;height:30px;padding:0 12px;border-radius:var(--dsw-radius-sm);border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;font-weight:500;cursor:pointer;white-space:nowrap}
|
|
27
|
+
.is-devops-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
28
|
+
.is-devops-btn[disabled]{opacity:.55;cursor:default}
|
|
29
|
+
.is-devops-btn[data-variant=primary]{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary-foreground)}
|
|
30
|
+
.is-devops-btn[data-variant=primary]:hover{background:var(--dsw-alias-button-primary-hover)}
|
|
31
|
+
.is-devops-btn[data-variant=ghost]{border-color:transparent;background:transparent;color:var(--dsw-alias-label-secondary)}
|
|
32
|
+
.is-devops-btn[data-variant=danger]{border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 35%,transparent);background:transparent;color:var(--dsw-alias-state-error-primary)}
|
|
33
|
+
.is-devops-tabs{display:flex;align-items:center;gap:4px;flex-wrap:wrap}
|
|
34
|
+
.is-devops-tab{box-sizing:border-box;height:30px;border:none;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:13px;font-weight:500;padding:0 10px;cursor:pointer;display:inline-flex;align-items:center;gap:6px}
|
|
35
|
+
.is-devops-tab:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
36
|
+
.is-devops-tab[aria-selected=true],.is-devops-tab[data-active=true]{background:var(--dsw-alias-interactive-bg-active);color:var(--dsw-alias-label-primary)}
|
|
37
|
+
.is-devops-tabCount{font-size:11px;color:var(--dsw-alias-label-caption)}
|
|
38
|
+
.is-devops-body{flex:1;min-height:0;overflow:auto;padding:16px 24px 40px;display:flex;flex-direction:column;gap:14px}
|
|
39
|
+
.is-devops-filters{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
|
40
|
+
.is-devops-chip{height:26px;padding:0 10px;border-radius:999px;border:1px solid var(--dsw-alias-border-l1);background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;cursor:pointer}
|
|
41
|
+
.is-devops-chip[data-active=true]{background:var(--dsw-alias-interactive-bg-active);color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l2)}
|
|
42
|
+
.is-devops-list{display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l1);border-radius:var(--dsw-radius-md);overflow:hidden;background:var(--dsw-alias-bg-layer-1)}
|
|
43
|
+
.is-devops-row{display:flex;align-items:flex-start;gap:14px;padding:12px 14px;border-bottom:1px solid var(--dsw-alias-border-l1);min-width:0}
|
|
44
|
+
.is-devops-row:last-child{border-bottom:none}
|
|
45
|
+
.is-devops-row:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
46
|
+
.is-devops-rowMain{display:flex;flex-direction:column;gap:3px;min-width:0;flex:1}
|
|
47
|
+
.is-devops-titleBlock{display:flex;flex-direction:column;gap:2px;min-width:0}
|
|
48
|
+
.is-devops-titleRow{display:flex;align-items:baseline;gap:8px;min-width:0}
|
|
49
|
+
.is-devops-translate{flex:none;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:11px;line-height:16px;padding:0 8px;cursor:pointer}
|
|
50
|
+
.is-devops-translate:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-secondary)}
|
|
51
|
+
.is-devops-translation{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary);white-space:pre-wrap}
|
|
52
|
+
.is-devops-translation[data-tone=error]{color:var(--dsw-alias-state-error-primary)}
|
|
53
|
+
.is-devops-rowTitle{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
54
|
+
.is-devops-rowMeta{display:flex;align-items:center;gap:10px;flex-wrap:wrap;font-size:12px;color:var(--dsw-alias-label-secondary)}
|
|
55
|
+
.is-devops-rowActions{display:flex;align-items:center;gap:6px;flex:none;flex-wrap:wrap}
|
|
56
|
+
.is-devops-pill{display:inline-flex;align-items:center;gap:5px;height:20px;padding:0 8px;border-radius:999px;font-size:11px;font-weight:500;border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-secondary);white-space:nowrap}
|
|
57
|
+
.is-devops-pill[data-state=open]{color:var(--dsw-alias-state-success-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-success-primary) 35%,transparent)}
|
|
58
|
+
.is-devops-pill[data-state=closed],[data-state=merged]{color:var(--dsw-alias-state-business-primary,var(--dsw-alias-link));border-color:var(--dsw-alias-border-l2)}
|
|
59
|
+
.is-devops-pill[data-state=draft]{color:var(--dsw-alias-state-warn-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 35%,transparent)}
|
|
60
|
+
.is-devops-label{display:inline-flex;align-items:center;height:20px;padding:0 7px;border-radius:4px;background:var(--dsw-alias-markdown-tag);color:var(--dsw-alias-label-secondary);font-size:11px}
|
|
61
|
+
.is-devops-sha{font-family:var(--ds-font-family-code);font-size:12px;color:var(--dsw-alias-label-secondary)}
|
|
62
|
+
.is-devops-card{border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-1);padding:14px 16px;display:flex;flex-direction:column;gap:12px}
|
|
63
|
+
.is-devops-cardTitle{font-size:14px;font-weight:600}
|
|
64
|
+
.is-devops-formGrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px}
|
|
65
|
+
.is-devops-field{display:flex;flex-direction:column;gap:5px;min-width:0}
|
|
66
|
+
.is-devops-fieldWide{grid-column:1/-1}
|
|
67
|
+
.is-devops-labelText{font-size:12px;font-weight:500;color:var(--dsw-alias-label-secondary)}
|
|
68
|
+
.is-devops-input{box-sizing:border-box;width:100%;height:34px;border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;padding:0 9px;outline:none}
|
|
69
|
+
.is-devops-input:focus-visible{border-color:var(--dsw-alias-brand-primary-new-colorprimary-new-color,var(--dsw-alias-link));box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-brand-primary-new-colorprimary-new-color,var(--dsw-alias-link)) 18%,transparent)}
|
|
70
|
+
.is-devops-textarea{box-sizing:border-box;width:100%;min-height:72px;border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;padding:8px 9px;outline:none;resize:vertical}
|
|
71
|
+
.is-devops-textarea:focus-visible{border-color:var(--dsw-alias-brand-primary-new-colorprimary-new-color,var(--dsw-alias-link))}
|
|
72
|
+
.is-devops-formActions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
73
|
+
.is-devops-formActions .is-devops-spacer{flex:1}
|
|
74
|
+
.is-devops-message{border-radius:var(--dsw-radius-sm);border:1px solid var(--dsw-alias-border-l2);padding:9px 12px;font-size:13px;line-height:20px;background:var(--dsw-alias-bg-layer-2)}
|
|
75
|
+
.is-devops-message[data-tone=ok]{border-color:color-mix(in srgb,var(--dsw-alias-state-success-primary) 35%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 8%,transparent)}
|
|
76
|
+
.is-devops-message[data-tone=error]{border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 35%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 8%,transparent)}
|
|
77
|
+
.is-devops-empty{border:1px dashed var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);padding:24px;text-align:center;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px}
|
|
78
|
+
.is-devops-skeleton{height:14px;border-radius:4px;background:var(--dsw-alias-bg-skeleton);margin:8px 0;animation:is-devops-pulse 1.2s ease-in-out infinite}
|
|
79
|
+
@keyframes is-devops-pulse{50%{opacity:.55}}
|
|
80
|
+
@media (prefers-reduced-motion:reduce){.is-devops-skeleton{animation:none}}
|
|
81
|
+
.is-devops-required{color:var(--dsw-alias-state-error-primary)}
|
|
82
|
+
`;
|
|
83
|
+
|
|
84
|
+
const COPY = {
|
|
85
|
+
en: {
|
|
86
|
+
title: 'Infinity DevOps',
|
|
87
|
+
subtitle: 'Issues, merge requests, commits and branches from the connected GitFox project.',
|
|
88
|
+
project: 'Project',
|
|
89
|
+
noProject: 'Select a GitFox project to begin.',
|
|
90
|
+
noProjects: 'No GitFox projects are visible for this token.',
|
|
91
|
+
loadProjectsFailed: 'Cannot load projects: {message}',
|
|
92
|
+
loading: 'Loading…',
|
|
93
|
+
retry: 'Retry',
|
|
94
|
+
refresh: 'Refresh',
|
|
95
|
+
issues: 'Issues',
|
|
96
|
+
mergeRequests: 'Merge requests',
|
|
97
|
+
commits: 'Commits',
|
|
98
|
+
branches: 'Branches',
|
|
99
|
+
newIssue: 'New issue',
|
|
100
|
+
newMergeRequest: 'New merge request',
|
|
101
|
+
newBranch: 'New branch',
|
|
102
|
+
create: 'Create',
|
|
103
|
+
cancel: 'Cancel',
|
|
104
|
+
creating: 'Creating…',
|
|
105
|
+
titleLabel: 'Title',
|
|
106
|
+
description: 'Description',
|
|
107
|
+
source: 'Source branch',
|
|
108
|
+
target: 'Target branch',
|
|
109
|
+
branchName: 'Branch name',
|
|
110
|
+
fromRef: 'From ref',
|
|
111
|
+
draft: 'Draft',
|
|
112
|
+
squash: 'Squash commits',
|
|
113
|
+
deleteSource: 'Delete source branch after merge',
|
|
114
|
+
merge: 'Merge',
|
|
115
|
+
merging: 'Merging…',
|
|
116
|
+
closeIssue: 'Close',
|
|
117
|
+
reopenIssue: 'Reopen',
|
|
118
|
+
delete: 'Delete',
|
|
119
|
+
deleting: 'Deleting…',
|
|
120
|
+
empty: 'Nothing here yet.',
|
|
121
|
+
noBranches: 'This repository has no branches yet.',
|
|
122
|
+
state: 'State',
|
|
123
|
+
open: 'Open',
|
|
124
|
+
closed: 'Closed',
|
|
125
|
+
all: 'All',
|
|
126
|
+
merged: 'Merged',
|
|
127
|
+
author: 'Author',
|
|
128
|
+
comments: '{count} comments',
|
|
129
|
+
protected: 'Protected',
|
|
130
|
+
default: 'Default',
|
|
131
|
+
emptyMessage: 'No items for this filter.',
|
|
132
|
+
created: 'Created.',
|
|
133
|
+
mergedNotice: 'Merge request merged.',
|
|
134
|
+
deletedNotice: 'Branch deleted.',
|
|
135
|
+
requestFailed: 'Request failed: {message}',
|
|
136
|
+
required: 'This field is required.',
|
|
137
|
+
confirmDeleteBranch: 'Delete branch "{branch}"? This cannot be undone.',
|
|
138
|
+
commitRef: 'Revision',
|
|
139
|
+
defaultBranch: 'default',
|
|
140
|
+
settingsHint: 'Connect GitFox in Settings → Infinity System.',
|
|
141
|
+
notConfigured:
|
|
142
|
+
'GitFox is not connected. Open Settings → Infinity System and add your access token.',
|
|
143
|
+
translate: 'Translate',
|
|
144
|
+
translateFailed: 'Translation failed. Try again in a moment.',
|
|
145
|
+
},
|
|
146
|
+
zh: {
|
|
147
|
+
title: 'Infinity DevOps',
|
|
148
|
+
subtitle: '已连接 GitFox 项目的议题、合并请求、提交与分支。',
|
|
149
|
+
project: '项目',
|
|
150
|
+
noProject: '选择一个 GitFox 项目开始。',
|
|
151
|
+
noProjects: '当前令牌看不到任何 GitFox 项目。',
|
|
152
|
+
loadProjectsFailed: '无法加载项目:{message}',
|
|
153
|
+
loading: '加载中…',
|
|
154
|
+
retry: '重试',
|
|
155
|
+
refresh: '刷新',
|
|
156
|
+
issues: '议题',
|
|
157
|
+
mergeRequests: '合并请求',
|
|
158
|
+
commits: '提交',
|
|
159
|
+
branches: '分支',
|
|
160
|
+
newIssue: '新建议题',
|
|
161
|
+
newMergeRequest: '新建合并请求',
|
|
162
|
+
newBranch: '新建分支',
|
|
163
|
+
create: '创建',
|
|
164
|
+
cancel: '取消',
|
|
165
|
+
creating: '创建中…',
|
|
166
|
+
titleLabel: '标题',
|
|
167
|
+
description: '描述',
|
|
168
|
+
source: '源分支',
|
|
169
|
+
target: '目标分支',
|
|
170
|
+
branchName: '分支名',
|
|
171
|
+
fromRef: '起始引用',
|
|
172
|
+
draft: '草稿',
|
|
173
|
+
squash: '压缩提交',
|
|
174
|
+
deleteSource: '合并后删除源分支',
|
|
175
|
+
merge: '合并',
|
|
176
|
+
merging: '合并中…',
|
|
177
|
+
closeIssue: '关闭',
|
|
178
|
+
reopenIssue: '重新打开',
|
|
179
|
+
delete: '删除',
|
|
180
|
+
deleting: '删除中…',
|
|
181
|
+
empty: '这里还没有内容。',
|
|
182
|
+
noBranches: '仓库还没有分支。',
|
|
183
|
+
state: '状态',
|
|
184
|
+
open: '开放中',
|
|
185
|
+
closed: '已关闭',
|
|
186
|
+
all: '全部',
|
|
187
|
+
merged: '已合并',
|
|
188
|
+
author: '作者',
|
|
189
|
+
comments: '{count} 条评论',
|
|
190
|
+
protected: '受保护',
|
|
191
|
+
default: '默认',
|
|
192
|
+
emptyMessage: '当前筛选下没有内容。',
|
|
193
|
+
created: '已创建。',
|
|
194
|
+
mergedNotice: '合并请求已合并。',
|
|
195
|
+
deletedNotice: '分支已删除。',
|
|
196
|
+
requestFailed: '请求失败:{message}',
|
|
197
|
+
required: '此项为必填。',
|
|
198
|
+
confirmDeleteBranch: '删除分支“{branch}”?此操作不可撤销。',
|
|
199
|
+
commitRef: '版本',
|
|
200
|
+
defaultBranch: '默认',
|
|
201
|
+
settingsHint: '在“设置 → Infinity System”中连接 GitFox。',
|
|
202
|
+
notConfigured: 'GitFox 尚未连接。请打开“设置 → Infinity System”并填写访问令牌。',
|
|
203
|
+
translate: '翻译',
|
|
204
|
+
translateFailed: '翻译失败了,请稍后再试。',
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
let React = null;
|
|
209
|
+
let h = null;
|
|
210
|
+
let Fragment = null;
|
|
211
|
+
let useState = null;
|
|
212
|
+
let useEffect = null;
|
|
213
|
+
let useCallback = null;
|
|
214
|
+
let useMemo = null;
|
|
215
|
+
let panelCtx;
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* @param {'en'|'zh'} locale - active locale.
|
|
219
|
+
* @param {string} key - copy key.
|
|
220
|
+
* @returns {string} localized string.
|
|
221
|
+
*/
|
|
222
|
+
function text(locale, key) {
|
|
223
|
+
const table = locale.toLowerCase().startsWith('zh') ? COPY.zh : COPY.en;
|
|
224
|
+
return table[key] ?? COPY.en[key] ?? key;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* @param {object} ctx - client context.
|
|
229
|
+
* @returns {(key: string) => string} translation function.
|
|
230
|
+
*/
|
|
231
|
+
function useTranslation(ctx) {
|
|
232
|
+
const [revision, setRevision] = useState(() => ctx.locale.getSnapshot().revision);
|
|
233
|
+
useEffect(
|
|
234
|
+
() =>
|
|
235
|
+
ctx.locale.subscribe(() => {
|
|
236
|
+
setRevision(ctx.locale.getSnapshot().revision);
|
|
237
|
+
}),
|
|
238
|
+
[ctx],
|
|
239
|
+
);
|
|
240
|
+
const active = ctx.locale.getSnapshot().active;
|
|
241
|
+
return useCallback((key) => text(active, key), [active, revision]);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Call one proxied GitFox endpoint.
|
|
246
|
+
*
|
|
247
|
+
* @param {string} path - `/api/v1/...` path.
|
|
248
|
+
* @param {{ method?: string, body?: unknown }} [options] - request options.
|
|
249
|
+
* @returns {Promise<any>} parsed payload.
|
|
250
|
+
*/
|
|
251
|
+
async function apiRequest(path, options = {}) {
|
|
252
|
+
const hasBody = options.body !== undefined;
|
|
253
|
+
const headers = { accept: 'application/json', 'x-infinity-request': '1' };
|
|
254
|
+
if (hasBody) headers['content-type'] = 'application/json';
|
|
255
|
+
const response = await fetch(`${PROXY}${path}`, {
|
|
256
|
+
method: options.method ?? 'GET',
|
|
257
|
+
headers,
|
|
258
|
+
body: hasBody ? JSON.stringify(options.body) : undefined,
|
|
259
|
+
});
|
|
260
|
+
const raw = await response.text();
|
|
261
|
+
let payload;
|
|
262
|
+
try {
|
|
263
|
+
payload = raw === '' ? undefined : JSON.parse(raw);
|
|
264
|
+
} catch {
|
|
265
|
+
payload = undefined;
|
|
266
|
+
}
|
|
267
|
+
if (!response.ok) {
|
|
268
|
+
throw new Error(payload?.error?.message ?? payload?.message ?? `HTTP ${response.status}`);
|
|
269
|
+
}
|
|
270
|
+
return payload;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Translate one user-visible text into the user's language.
|
|
275
|
+
*
|
|
276
|
+
* @param {string} text - English (or mixed) source text.
|
|
277
|
+
* @param {string} target - active locale id.
|
|
278
|
+
* @returns {Promise<string>} translated text.
|
|
279
|
+
*/
|
|
280
|
+
async function translateText(source, target) {
|
|
281
|
+
const response = await fetch('/infinity/translate', {
|
|
282
|
+
method: 'POST',
|
|
283
|
+
headers: {
|
|
284
|
+
accept: 'application/json',
|
|
285
|
+
'content-type': 'application/json',
|
|
286
|
+
'x-infinity-request': '1',
|
|
287
|
+
},
|
|
288
|
+
body: JSON.stringify({ text: source, target }),
|
|
289
|
+
});
|
|
290
|
+
const payload = await response.json().catch(() => undefined);
|
|
291
|
+
if (!response.ok) throw translationFailure(payload, response.status);
|
|
292
|
+
return typeof payload?.text === 'string' ? payload.text : '';
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Build one readable translation failure.
|
|
297
|
+
*
|
|
298
|
+
* A desktop shell answers a slow Host with a 504 page of its own, and that page
|
|
299
|
+
* carries no JSON message. Both that page and a typed route failure become a
|
|
300
|
+
* sentence the user can act on; the raw detail goes to the console.
|
|
301
|
+
*
|
|
302
|
+
* @param {unknown} payload - parsed response body, when it had one.
|
|
303
|
+
* @param {number} status - HTTP status of the failed response.
|
|
304
|
+
* @returns {Error} failure carrying its code.
|
|
305
|
+
*/
|
|
306
|
+
function translationFailure(payload, status) {
|
|
307
|
+
const detail = typeof payload?.error?.message === 'string' ? payload.error.message : '';
|
|
308
|
+
const declared = typeof payload?.error?.code === 'string' ? payload.error.code : '';
|
|
309
|
+
const fallback = status === 504 ? 'translate-timeout' : 'translate-failed';
|
|
310
|
+
const error = new Error(detail === '' ? `HTTP ${status}` : detail);
|
|
311
|
+
error.code = declared === '' ? fallback : declared;
|
|
312
|
+
return error;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* A title line with an inline "translate" toggle.
|
|
317
|
+
*
|
|
318
|
+
* The English record stays visible; the translation is rendered underneath and
|
|
319
|
+
* can be collapsed again. The button disappears when the Host has no
|
|
320
|
+
* translation route.
|
|
321
|
+
*
|
|
322
|
+
* @param {{ text: string, target: string, t: Function }} props - title props.
|
|
323
|
+
* @returns {import('react').ReactElement} title block.
|
|
324
|
+
*/
|
|
325
|
+
function TranslatableTitle({ value, target, t }) {
|
|
326
|
+
const [state, setState] = useState({ status: 'idle', value: '' });
|
|
327
|
+
const toggle = () => {
|
|
328
|
+
if (state.status === 'loading') return;
|
|
329
|
+
if (state.status === 'done' || state.status === 'error') {
|
|
330
|
+
setState({ status: 'idle', value: '' });
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
setState({ status: 'loading', value: '' });
|
|
334
|
+
translateText(value, target)
|
|
335
|
+
.then((translated) => setState({ status: 'done', value: translated }))
|
|
336
|
+
.catch((error) => {
|
|
337
|
+
console.warn('[is-devops] translation failed:', error);
|
|
338
|
+
setState({ status: 'error', value: t('translateFailed') });
|
|
339
|
+
});
|
|
340
|
+
};
|
|
341
|
+
return h(
|
|
342
|
+
'div',
|
|
343
|
+
{ className: 'is-devops-titleBlock' },
|
|
344
|
+
h(
|
|
345
|
+
'div',
|
|
346
|
+
{ className: 'is-devops-titleRow' },
|
|
347
|
+
h('div', { className: 'is-devops-rowTitle' }, value),
|
|
348
|
+
h(
|
|
349
|
+
'button',
|
|
350
|
+
{ type: 'button', className: 'is-devops-translate', title: t('translate'), onClick: toggle },
|
|
351
|
+
state.status === 'loading' ? '…' : t('translate'),
|
|
352
|
+
),
|
|
353
|
+
),
|
|
354
|
+
state.status === 'done' || state.status === 'error'
|
|
355
|
+
? h(
|
|
356
|
+
'div',
|
|
357
|
+
{ className: 'is-devops-translation', 'data-tone': state.status === 'error' ? 'error' : 'ok' },
|
|
358
|
+
state.value,
|
|
359
|
+
)
|
|
360
|
+
: null,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* @param {unknown} data - project list payload.
|
|
366
|
+
* @returns {object[]} project rows.
|
|
367
|
+
*/
|
|
368
|
+
function projectRows(data) {
|
|
369
|
+
if (Array.isArray(data)) return data;
|
|
370
|
+
return Array.isArray(data?.items) ? data.items : [];
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* @param {object} project - GitFox project row.
|
|
375
|
+
* @returns {string} `namespace/name` key.
|
|
376
|
+
*/
|
|
377
|
+
function keyOfProject(project) {
|
|
378
|
+
return `${project.owner_name}/${project.name}`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* @param {object} project - GitFox project row.
|
|
383
|
+
* @returns {string} encoded API prefix for the project.
|
|
384
|
+
*/
|
|
385
|
+
function projectBase(project) {
|
|
386
|
+
return `/api/v1/projects/${encodeURIComponent(project.owner_name)}/${encodeURIComponent(project.name)}`;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* @param {unknown} value - ISO date, seconds, or milliseconds.
|
|
391
|
+
* @param {string} locale - active locale id.
|
|
392
|
+
* @returns {string} localized date.
|
|
393
|
+
*/
|
|
394
|
+
function formatDate(value, locale) {
|
|
395
|
+
if (value === undefined || value === null || value === '') return '';
|
|
396
|
+
let date;
|
|
397
|
+
if (typeof value === 'number') {
|
|
398
|
+
date = new Date(value > 1e12 ? value : value * 1000);
|
|
399
|
+
} else {
|
|
400
|
+
date = new Date(String(value));
|
|
401
|
+
}
|
|
402
|
+
if (Number.isNaN(date.getTime())) return String(value);
|
|
403
|
+
return date.toLocaleString(locale.toLowerCase().startsWith('zh') ? 'zh-CN' : 'en-US', {
|
|
404
|
+
dateStyle: 'medium',
|
|
405
|
+
timeStyle: 'short',
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* @param {unknown} message - commit message.
|
|
411
|
+
* @returns {string} first line.
|
|
412
|
+
*/
|
|
413
|
+
function firstLine(message) {
|
|
414
|
+
return String(message ?? '').split('\n')[0];
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* @param {unknown} sha - full commit id.
|
|
419
|
+
* @returns {string} short id.
|
|
420
|
+
*/
|
|
421
|
+
function shortSha(sha) {
|
|
422
|
+
return String(sha ?? '').slice(0, 8);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Install the panel stylesheet once per plugin lifetime.
|
|
427
|
+
*
|
|
428
|
+
* @returns {() => void} disposer.
|
|
429
|
+
*/
|
|
430
|
+
function installStyles() {
|
|
431
|
+
if (document.querySelector(`style[data-plugin-css="${CSS_TAG}"]`) !== null) return () => {};
|
|
432
|
+
const tag = document.createElement('style');
|
|
433
|
+
tag.dataset.plugin = PLUGIN_ID;
|
|
434
|
+
tag.dataset.pluginCss = CSS_TAG;
|
|
435
|
+
tag.textContent = CSS;
|
|
436
|
+
document.head.appendChild(tag);
|
|
437
|
+
return () => tag.remove();
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* @param {object} project - selected project.
|
|
442
|
+
* @returns {Promise<object[]>} branch rows.
|
|
443
|
+
*/
|
|
444
|
+
async function fetchBranches(project) {
|
|
445
|
+
const data = await apiRequest(`${projectBase(project)}/repository/branches`);
|
|
446
|
+
return Array.isArray(data) ? data : [];
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* @param {object[]} branches - branch rows.
|
|
451
|
+
* @param {string} current - current selection.
|
|
452
|
+
* @returns {string} selected branch name.
|
|
453
|
+
*/
|
|
454
|
+
function pickBranchName(branches, current) {
|
|
455
|
+
if (branches.some((branch) => branch.name === current)) return current;
|
|
456
|
+
return branches.find((branch) => branch.is_default)?.name ?? branches[0]?.name ?? current;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* @param {object[]} branches - branch rows.
|
|
461
|
+
* @param {string} current - current selection.
|
|
462
|
+
* @returns {string} source branch name.
|
|
463
|
+
*/
|
|
464
|
+
function pickSourceBranchName(branches, current) {
|
|
465
|
+
if (current !== '') return current;
|
|
466
|
+
return branches.find((branch) => !branch.is_default)?.name || branches[0]?.name || '';
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* @param {object[]} branches - branch rows.
|
|
471
|
+
* @returns {import('react').ReactElement[]} select options.
|
|
472
|
+
*/
|
|
473
|
+
function branchOptions(branches) {
|
|
474
|
+
return branches.map((branch) => h('option', { key: branch.name, value: branch.name }, branch.name));
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Build the API path of one tab view.
|
|
479
|
+
*
|
|
480
|
+
* @param {object} project - selected project.
|
|
481
|
+
* @param {string} tab - active tab id.
|
|
482
|
+
* @param {{ issueState: string, mrStatus: string, commitRef: string }} filters - active filters.
|
|
483
|
+
* @returns {string} API path.
|
|
484
|
+
*/
|
|
485
|
+
function listPath(project, tab, filters) {
|
|
486
|
+
const base = projectBase(project);
|
|
487
|
+
if (tab === 'issues') {
|
|
488
|
+
return `${base}/issues?state=${encodeURIComponent(filters.issueState)}&per_page=50`;
|
|
489
|
+
}
|
|
490
|
+
if (tab === 'merge_requests') {
|
|
491
|
+
return `${base}/merge_requests?status=${encodeURIComponent(filters.mrStatus)}&per_page=50`;
|
|
492
|
+
}
|
|
493
|
+
if (tab === 'commits') {
|
|
494
|
+
const query = filters.commitRef === '' ? '' : `&ref_name=${encodeURIComponent(filters.commitRef)}`;
|
|
495
|
+
return `${base}/repository/commits?per_page=50${query}`;
|
|
496
|
+
}
|
|
497
|
+
return `${base}/repository/branches`;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* @param {object} project - selected project.
|
|
502
|
+
* @param {string} tab - active tab id.
|
|
503
|
+
* @param {{ issueState: string, mrStatus: string, commitRef: string }} filters - active filters.
|
|
504
|
+
* @returns {Promise<object[]>} rows of the active tab.
|
|
505
|
+
*/
|
|
506
|
+
async function fetchTabItems(project, tab, filters) {
|
|
507
|
+
const data = await apiRequest(listPath(project, tab, filters));
|
|
508
|
+
if (tab !== 'issues') return Array.isArray(data) ? data : [];
|
|
509
|
+
return Array.isArray(data?.items) ? data.items : [];
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* @param {object} project - selected project.
|
|
514
|
+
* @param {string} tab - active tab id.
|
|
515
|
+
* @returns {boolean} whether the tab needs the branch list.
|
|
516
|
+
*/
|
|
517
|
+
function needsBranches(tab) {
|
|
518
|
+
return tab === 'merge_requests' || tab === 'branches' || tab === 'commits';
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* @param {object[]} projects - project rows.
|
|
523
|
+
* @param {string} projectKey - stored `namespace/name` key.
|
|
524
|
+
* @returns {object | undefined} selected project.
|
|
525
|
+
*/
|
|
526
|
+
function findProject(projects, projectKey) {
|
|
527
|
+
return (projects ?? []).find((candidate) => keyOfProject(candidate) === projectKey);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* @returns {string} project key restored from browser storage.
|
|
532
|
+
*/
|
|
533
|
+
function readStoredProjectKey() {
|
|
534
|
+
try {
|
|
535
|
+
return localStorage.getItem(STORAGE_KEY) ?? '';
|
|
536
|
+
} catch {
|
|
537
|
+
// Storage may be unavailable in hardened browser contexts.
|
|
538
|
+
return '';
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** @param {{ children?: any, onClick?: () => void, disabled?: boolean, variant?: string, title?: string }} props - button props. */
|
|
543
|
+
function Button({ children, onClick, disabled = false, variant = 'secondary', title }) {
|
|
544
|
+
return h('button', {
|
|
545
|
+
type: 'button',
|
|
546
|
+
className: 'is-devops-btn',
|
|
547
|
+
'data-variant': variant,
|
|
548
|
+
disabled,
|
|
549
|
+
title,
|
|
550
|
+
onClick,
|
|
551
|
+
}, children);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** @param {{ label: string, wide?: boolean, children?: any }} props - form field. */
|
|
555
|
+
function Field({ label, wide = false, children }) {
|
|
556
|
+
return h('div', { className: `is-devops-field${wide ? ' is-devops-fieldWide' : ''}` },
|
|
557
|
+
h('span', { className: 'is-devops-labelText' }, label),
|
|
558
|
+
children);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** @param {{ tone: string, children?: any }} props - inline message. */
|
|
562
|
+
function Message({ tone, children }) {
|
|
563
|
+
return h('div', { className: 'is-devops-message', 'data-tone': tone }, children);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** @param {{ label: string, wide?: boolean, value: string, disabled: boolean, placeholder?: string, onChange: (event: any) => void }} props - text field. */
|
|
567
|
+
function LabeledInput({ label, wide = false, value, disabled, placeholder, onChange }) {
|
|
568
|
+
return h(Field, { label, wide },
|
|
569
|
+
h('input', {
|
|
570
|
+
className: 'is-devops-input',
|
|
571
|
+
value,
|
|
572
|
+
disabled,
|
|
573
|
+
autoFocus: true,
|
|
574
|
+
onChange,
|
|
575
|
+
placeholder,
|
|
576
|
+
}));
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** @param {{ label: string, wide?: boolean, value: string, disabled: boolean, onChange: (event: any) => void }} props - textarea field. */
|
|
580
|
+
function LabeledTextArea({ label, wide = false, value, disabled, onChange }) {
|
|
581
|
+
return h(Field, { label, wide },
|
|
582
|
+
h('textarea', { className: 'is-devops-textarea', value, disabled, onChange }));
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** @param {{ label: string, wide?: boolean, value: string, disabled: boolean, onChange: (event: any) => void, children?: any }} props - select field. */
|
|
586
|
+
function LabeledSelect({ label, wide = false, value, disabled, onChange, children }) {
|
|
587
|
+
return h(Field, { label, wide },
|
|
588
|
+
h('select', { className: 'is-devops-select', value, disabled, onChange }, children));
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** @param {{ error: string | undefined }} props - form error notice. */
|
|
592
|
+
function FormError({ error }) {
|
|
593
|
+
if (error === undefined) return null;
|
|
594
|
+
return h(Message, { tone: 'error' }, error);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** @param {{ busy: boolean, onSubmit: () => void, onCancel: () => void, t: Function }} props - form actions. */
|
|
598
|
+
function FormActions({ busy, onSubmit, onCancel, t }) {
|
|
599
|
+
return h('div', { className: 'is-devops-formActions' },
|
|
600
|
+
h(Button, { variant: 'primary', onClick: onSubmit, disabled: busy }, busy ? t('creating') : t('create')),
|
|
601
|
+
h(Button, { variant: 'ghost', onClick: onCancel, disabled: busy }, t('cancel')));
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** @param {{ checked: boolean, disabled: boolean, onChange: (next: boolean) => void, label: string }} props - draft checkbox. */
|
|
605
|
+
function DraftToggle({ checked, disabled, onChange, label }) {
|
|
606
|
+
return h('label', { className: 'is-devops-labelText', style: { display: 'flex', alignItems: 'center', gap: '6px' } },
|
|
607
|
+
h('input', { type: 'checkbox', checked, disabled, onChange: (event) => onChange(event.target.checked) }),
|
|
608
|
+
label);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Issue creation form.
|
|
613
|
+
*
|
|
614
|
+
* @param {{ project: object, t: Function, onDone: (message: string) => void, onCancel: () => void }} props - composer wiring.
|
|
615
|
+
* @returns {import('react').ReactElement} form.
|
|
616
|
+
*/
|
|
617
|
+
function IssueComposer({ project, t, onDone, onCancel }) {
|
|
618
|
+
const [title, setTitle] = useState('');
|
|
619
|
+
const [description, setDescription] = useState('');
|
|
620
|
+
const [busy, setBusy] = useState(false);
|
|
621
|
+
const [error, setError] = useState(undefined);
|
|
622
|
+
|
|
623
|
+
const submit = async () => {
|
|
624
|
+
if (title.trim() === '') {
|
|
625
|
+
setError(t('required'));
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
setBusy(true);
|
|
629
|
+
setError(undefined);
|
|
630
|
+
try {
|
|
631
|
+
await apiRequest(`${projectBase(project)}/issues`, {
|
|
632
|
+
method: 'POST',
|
|
633
|
+
body: { title: title.trim(), description: description.trim() },
|
|
634
|
+
});
|
|
635
|
+
onDone(t('created'));
|
|
636
|
+
} catch (failure) {
|
|
637
|
+
setError(t('requestFailed').replace('{message}', failure.message));
|
|
638
|
+
} finally {
|
|
639
|
+
setBusy(false);
|
|
640
|
+
}
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
return h(
|
|
644
|
+
'section',
|
|
645
|
+
{ className: 'is-devops-card', 'aria-busy': busy },
|
|
646
|
+
h('div', { className: 'is-devops-cardTitle' }, t('newIssue')),
|
|
647
|
+
h(
|
|
648
|
+
'div',
|
|
649
|
+
{ className: 'is-devops-formGrid' },
|
|
650
|
+
h(LabeledInput, {
|
|
651
|
+
label: t('titleLabel'),
|
|
652
|
+
wide: true,
|
|
653
|
+
value: title,
|
|
654
|
+
disabled: busy,
|
|
655
|
+
placeholder: t('titleLabel'),
|
|
656
|
+
onChange: (event) => setTitle(event.target.value),
|
|
657
|
+
}),
|
|
658
|
+
h(LabeledTextArea, {
|
|
659
|
+
label: t('description'),
|
|
660
|
+
wide: true,
|
|
661
|
+
value: description,
|
|
662
|
+
disabled: busy,
|
|
663
|
+
onChange: (event) => setDescription(event.target.value),
|
|
664
|
+
}),
|
|
665
|
+
),
|
|
666
|
+
h(FormError, { error }),
|
|
667
|
+
h(FormActions, { busy, onSubmit: submit, onCancel, t }),
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Merge-request creation form.
|
|
673
|
+
*
|
|
674
|
+
* @param {{ project: object, t: Function, onDone: (message: string) => void, onCancel: () => void }} props - composer wiring.
|
|
675
|
+
* @returns {import('react').ReactElement} form.
|
|
676
|
+
*/
|
|
677
|
+
function MergeRequestComposer({ project, t, onDone, onCancel }) {
|
|
678
|
+
const [title, setTitle] = useState('');
|
|
679
|
+
const [description, setDescription] = useState('');
|
|
680
|
+
const [source, setSource] = useState('');
|
|
681
|
+
const [target, setTarget] = useState(project.default_branch ?? '');
|
|
682
|
+
const [draft, setDraft] = useState(false);
|
|
683
|
+
const [branches, setBranches] = useState([]);
|
|
684
|
+
const [busy, setBusy] = useState(false);
|
|
685
|
+
const [error, setError] = useState(undefined);
|
|
686
|
+
|
|
687
|
+
useEffect(() => {
|
|
688
|
+
let cancelled = false;
|
|
689
|
+
fetchBranches(project)
|
|
690
|
+
.then((rows) => {
|
|
691
|
+
if (cancelled) return;
|
|
692
|
+
setBranches(rows);
|
|
693
|
+
setSource((current) => pickSourceBranchName(rows, current));
|
|
694
|
+
setTarget((current) => pickBranchName(rows, current));
|
|
695
|
+
})
|
|
696
|
+
.catch(() => {});
|
|
697
|
+
return () => {
|
|
698
|
+
cancelled = true;
|
|
699
|
+
};
|
|
700
|
+
}, [project]);
|
|
701
|
+
|
|
702
|
+
const submit = async () => {
|
|
703
|
+
if (title.trim() === '' || source === '' || target === '') {
|
|
704
|
+
setError(t('required'));
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
setBusy(true);
|
|
708
|
+
setError(undefined);
|
|
709
|
+
try {
|
|
710
|
+
await apiRequest(`${projectBase(project)}/merge_requests`, {
|
|
711
|
+
method: 'POST',
|
|
712
|
+
body: {
|
|
713
|
+
title: title.trim(),
|
|
714
|
+
description: description.trim(),
|
|
715
|
+
source_branch: source,
|
|
716
|
+
target_branch: target,
|
|
717
|
+
is_draft: draft,
|
|
718
|
+
},
|
|
719
|
+
});
|
|
720
|
+
onDone(t('created'));
|
|
721
|
+
} catch (failure) {
|
|
722
|
+
setError(t('requestFailed').replace('{message}', failure.message));
|
|
723
|
+
} finally {
|
|
724
|
+
setBusy(false);
|
|
725
|
+
}
|
|
726
|
+
};
|
|
727
|
+
|
|
728
|
+
const options = branchOptions(branches);
|
|
729
|
+
|
|
730
|
+
return h(
|
|
731
|
+
'section',
|
|
732
|
+
{ className: 'is-devops-card', 'aria-busy': busy },
|
|
733
|
+
h('div', { className: 'is-devops-cardTitle' }, t('newMergeRequest')),
|
|
734
|
+
h(
|
|
735
|
+
'div',
|
|
736
|
+
{ className: 'is-devops-formGrid' },
|
|
737
|
+
h(LabeledInput, {
|
|
738
|
+
label: t('titleLabel'),
|
|
739
|
+
wide: true,
|
|
740
|
+
value: title,
|
|
741
|
+
disabled: busy,
|
|
742
|
+
onChange: (event) => setTitle(event.target.value),
|
|
743
|
+
}),
|
|
744
|
+
h(LabeledSelect, {
|
|
745
|
+
label: t('source'),
|
|
746
|
+
value: source,
|
|
747
|
+
disabled: busy,
|
|
748
|
+
onChange: (event) => setSource(event.target.value),
|
|
749
|
+
}, options),
|
|
750
|
+
h(LabeledSelect, {
|
|
751
|
+
label: t('target'),
|
|
752
|
+
value: target,
|
|
753
|
+
disabled: busy,
|
|
754
|
+
onChange: (event) => setTarget(event.target.value),
|
|
755
|
+
}, options),
|
|
756
|
+
h(LabeledTextArea, {
|
|
757
|
+
label: t('description'),
|
|
758
|
+
wide: true,
|
|
759
|
+
value: description,
|
|
760
|
+
disabled: busy,
|
|
761
|
+
onChange: (event) => setDescription(event.target.value),
|
|
762
|
+
}),
|
|
763
|
+
),
|
|
764
|
+
h(DraftToggle, { checked: draft, disabled: busy, onChange: setDraft, label: t('draft') }),
|
|
765
|
+
h(FormError, { error }),
|
|
766
|
+
h(FormActions, { busy, onSubmit: submit, onCancel, t }),
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Branch creation form.
|
|
772
|
+
*
|
|
773
|
+
* @param {{ project: object, t: Function, onDone: (message: string) => void, onCancel: () => void }} props - composer wiring.
|
|
774
|
+
* @returns {import('react').ReactElement} form.
|
|
775
|
+
*/
|
|
776
|
+
function BranchComposer({ project, t, onDone, onCancel }) {
|
|
777
|
+
const [name, setName] = useState('');
|
|
778
|
+
const [ref, setRef] = useState(project.default_branch ?? '');
|
|
779
|
+
const [branches, setBranches] = useState([]);
|
|
780
|
+
const [busy, setBusy] = useState(false);
|
|
781
|
+
const [error, setError] = useState(undefined);
|
|
782
|
+
|
|
783
|
+
useEffect(() => {
|
|
784
|
+
let cancelled = false;
|
|
785
|
+
fetchBranches(project)
|
|
786
|
+
.then((rows) => {
|
|
787
|
+
if (cancelled) return;
|
|
788
|
+
setBranches(rows);
|
|
789
|
+
setRef((current) => pickBranchName(rows, current));
|
|
790
|
+
})
|
|
791
|
+
.catch(() => {});
|
|
792
|
+
return () => {
|
|
793
|
+
cancelled = true;
|
|
794
|
+
};
|
|
795
|
+
}, [project]);
|
|
796
|
+
|
|
797
|
+
const submit = async () => {
|
|
798
|
+
if (name.trim() === '' || ref === '') {
|
|
799
|
+
setError(t('required'));
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
setBusy(true);
|
|
803
|
+
setError(undefined);
|
|
804
|
+
try {
|
|
805
|
+
await apiRequest(`${projectBase(project)}/repository/branches`, {
|
|
806
|
+
method: 'POST',
|
|
807
|
+
body: { name: name.trim(), ref_name: ref },
|
|
808
|
+
});
|
|
809
|
+
onDone(t('created'));
|
|
810
|
+
} catch (failure) {
|
|
811
|
+
setError(t('requestFailed').replace('{message}', failure.message));
|
|
812
|
+
} finally {
|
|
813
|
+
setBusy(false);
|
|
814
|
+
}
|
|
815
|
+
};
|
|
816
|
+
|
|
817
|
+
return h(
|
|
818
|
+
'section',
|
|
819
|
+
{ className: 'is-devops-card', 'aria-busy': busy },
|
|
820
|
+
h('div', { className: 'is-devops-cardTitle' }, t('newBranch')),
|
|
821
|
+
h(
|
|
822
|
+
'div',
|
|
823
|
+
{ className: 'is-devops-formGrid' },
|
|
824
|
+
h(LabeledInput, {
|
|
825
|
+
label: t('branchName'),
|
|
826
|
+
value: name,
|
|
827
|
+
disabled: busy,
|
|
828
|
+
placeholder: 'feature/…',
|
|
829
|
+
onChange: (event) => setName(event.target.value),
|
|
830
|
+
}),
|
|
831
|
+
h(LabeledSelect, {
|
|
832
|
+
label: t('fromRef'),
|
|
833
|
+
value: ref,
|
|
834
|
+
disabled: busy,
|
|
835
|
+
onChange: (event) => setRef(event.target.value),
|
|
836
|
+
}, branchOptions(branches)),
|
|
837
|
+
),
|
|
838
|
+
h(FormError, { error }),
|
|
839
|
+
h(FormActions, { busy, onSubmit: submit, onCancel, t }),
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Render the composer matching the active compose tab.
|
|
845
|
+
*
|
|
846
|
+
* @param {{ view: object, actions: object, t: Function }} props - panel state.
|
|
847
|
+
* @returns {import('react').ReactElement | null} composer.
|
|
848
|
+
*/
|
|
849
|
+
function ComposerSlot({ view, actions, t }) {
|
|
850
|
+
const composerProps = {
|
|
851
|
+
project: view.project,
|
|
852
|
+
t,
|
|
853
|
+
onDone: actions.finishCompose,
|
|
854
|
+
onCancel: actions.cancelCompose,
|
|
855
|
+
};
|
|
856
|
+
if (view.compose === 'issues') return h(IssueComposer, composerProps);
|
|
857
|
+
if (view.compose === 'merge_requests') return h(MergeRequestComposer, composerProps);
|
|
858
|
+
if (view.compose === 'branches') return h(BranchComposer, composerProps);
|
|
859
|
+
return null;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* @param {object | undefined} project - selected project.
|
|
864
|
+
* @param {Function} t - translation function.
|
|
865
|
+
* @returns {string} panel subtitle.
|
|
866
|
+
*/
|
|
867
|
+
function panelSubtitle(project, t) {
|
|
868
|
+
if (project === undefined) return t('subtitle');
|
|
869
|
+
return `${project.owner_name}/${project.name} · ${t('subtitle')}`;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Project picker shown in the panel header.
|
|
874
|
+
*
|
|
875
|
+
* @param {{ project: object | undefined, projects: object[] | undefined, projectKey: string, onChange: (key: string) => void, t: Function }} props - picker wiring.
|
|
876
|
+
* @returns {import('react').ReactElement | null} picker.
|
|
877
|
+
*/
|
|
878
|
+
function ProjectSelect({ project, projects, projectKey, onChange, t }) {
|
|
879
|
+
if (projects === undefined) return null;
|
|
880
|
+
const selectProps = {
|
|
881
|
+
className: 'is-devops-select',
|
|
882
|
+
'aria-label': t('project'),
|
|
883
|
+
value: project === undefined ? '' : projectKey,
|
|
884
|
+
onChange: (event) => onChange(event.target.value),
|
|
885
|
+
};
|
|
886
|
+
if (projects.length === 0) {
|
|
887
|
+
return h('select', selectProps, h('option', { value: '' }, t('noProjects')));
|
|
888
|
+
}
|
|
889
|
+
return h(
|
|
890
|
+
'select',
|
|
891
|
+
selectProps,
|
|
892
|
+
projects.map((candidate) =>
|
|
893
|
+
h('option', { key: keyOfProject(candidate), value: keyOfProject(candidate) }, keyOfProject(candidate)),
|
|
894
|
+
),
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* @param {{ t: Function }} props - tab labels.
|
|
900
|
+
* @returns {{ id: string, label: string }[]} tab definitions.
|
|
901
|
+
*/
|
|
902
|
+
function tabsFor(t) {
|
|
903
|
+
return [
|
|
904
|
+
{ id: 'issues', label: t('issues') },
|
|
905
|
+
{ id: 'merge_requests', label: t('mergeRequests') },
|
|
906
|
+
{ id: 'commits', label: t('commits') },
|
|
907
|
+
{ id: 'branches', label: t('branches') },
|
|
908
|
+
];
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* @param {{ tab: string, onSelect: (tab: string) => void, t: Function }} props - tab bar wiring.
|
|
913
|
+
* @returns {import('react').ReactElement} tab bar.
|
|
914
|
+
*/
|
|
915
|
+
function TabBar({ tab, onSelect, t }) {
|
|
916
|
+
return h(
|
|
917
|
+
'div',
|
|
918
|
+
{ className: 'is-devops-tabs', role: 'tablist' },
|
|
919
|
+
tabsFor(t).map((entry) =>
|
|
920
|
+
h(
|
|
921
|
+
'button',
|
|
922
|
+
{
|
|
923
|
+
key: entry.id,
|
|
924
|
+
type: 'button',
|
|
925
|
+
role: 'tab',
|
|
926
|
+
className: 'is-devops-tab',
|
|
927
|
+
'aria-selected': tab === entry.id,
|
|
928
|
+
'data-active': tab === entry.id,
|
|
929
|
+
onClick: () => onSelect(entry.id),
|
|
930
|
+
},
|
|
931
|
+
entry.label,
|
|
932
|
+
),
|
|
933
|
+
),
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* @param {{ view: object, actions: object, t: Function }} props - panel state.
|
|
939
|
+
* @returns {import('react').ReactElement} panel header.
|
|
940
|
+
*/
|
|
941
|
+
function PanelHeader({ view, actions, t }) {
|
|
942
|
+
return h(
|
|
943
|
+
'header',
|
|
944
|
+
{ className: 'is-devops-top' },
|
|
945
|
+
h(
|
|
946
|
+
'div',
|
|
947
|
+
{ className: 'is-devops-head' },
|
|
948
|
+
h(
|
|
949
|
+
'div',
|
|
950
|
+
{ style: { minWidth: 0 } },
|
|
951
|
+
h('div', { className: 'is-devops-title' }, t('title')),
|
|
952
|
+
h('div', { className: 'is-devops-sub' }, panelSubtitle(view.project, t)),
|
|
953
|
+
),
|
|
954
|
+
h(
|
|
955
|
+
'div',
|
|
956
|
+
{ className: 'is-devops-headTools' },
|
|
957
|
+
h(ProjectSelect, {
|
|
958
|
+
project: view.project,
|
|
959
|
+
projects: view.projects,
|
|
960
|
+
projectKey: view.projectKey,
|
|
961
|
+
onChange: actions.selectProject,
|
|
962
|
+
t,
|
|
963
|
+
}),
|
|
964
|
+
h(Button, { variant: 'ghost', onClick: actions.refresh, title: t('refresh') }, '↻'),
|
|
965
|
+
),
|
|
966
|
+
),
|
|
967
|
+
h(TabBar, { tab: view.tab, onSelect: actions.selectTab, t }),
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* @param {{ label: string, active: boolean, onSelect: () => void }} props - filter chip.
|
|
973
|
+
* @returns {import('react').ReactElement} chip button.
|
|
974
|
+
*/
|
|
975
|
+
function FilterChip({ label, active, onSelect }) {
|
|
976
|
+
return h(
|
|
977
|
+
'button',
|
|
978
|
+
{
|
|
979
|
+
type: 'button',
|
|
980
|
+
className: 'is-devops-chip',
|
|
981
|
+
'data-active': active,
|
|
982
|
+
onClick: onSelect,
|
|
983
|
+
},
|
|
984
|
+
label,
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* @param {{ project: object, branches: object[], commitRef: string, onChange: (ref: string) => void, t: Function }} props - revision picker.
|
|
990
|
+
* @returns {import('react').ReactElement} select.
|
|
991
|
+
*/
|
|
992
|
+
function CommitRefSelect({ project, branches, commitRef, onChange, t }) {
|
|
993
|
+
return h(
|
|
994
|
+
'select',
|
|
995
|
+
{
|
|
996
|
+
className: 'is-devops-select',
|
|
997
|
+
'aria-label': t('commitRef'),
|
|
998
|
+
value: commitRef,
|
|
999
|
+
onChange: (event) => onChange(event.target.value),
|
|
1000
|
+
},
|
|
1001
|
+
branches.length === 0
|
|
1002
|
+
? h('option', { value: project.default_branch ?? '' }, project.default_branch ?? '')
|
|
1003
|
+
: branchOptions(branches),
|
|
1004
|
+
);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* @param {string} tab - active tab id.
|
|
1009
|
+
* @param {string} issueState - issue filter.
|
|
1010
|
+
* @param {string} mrStatus - merge-request filter.
|
|
1011
|
+
* @returns {string} active filter id.
|
|
1012
|
+
*/
|
|
1013
|
+
function activeFilterId(tab, issueState, mrStatus) {
|
|
1014
|
+
return tab === 'issues' ? issueState : mrStatus;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* @param {string} tab - active tab id.
|
|
1019
|
+
* @param {object} actions - panel actions.
|
|
1020
|
+
* @param {string} id - selected filter id.
|
|
1021
|
+
* @returns {void}
|
|
1022
|
+
*/
|
|
1023
|
+
function selectFilter(tab, actions, id) {
|
|
1024
|
+
if (tab === 'issues') {
|
|
1025
|
+
actions.setIssueState(id);
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
actions.setMrStatus(id);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* @param {string} tab - active tab id.
|
|
1033
|
+
* @param {Function} t - translation function.
|
|
1034
|
+
* @returns {{ id: string, label: string }[]} filter definitions.
|
|
1035
|
+
*/
|
|
1036
|
+
function filtersForTab(tab, t) {
|
|
1037
|
+
if (tab === 'issues') {
|
|
1038
|
+
return [
|
|
1039
|
+
{ id: 'open', label: t('open') },
|
|
1040
|
+
{ id: 'closed', label: t('closed') },
|
|
1041
|
+
{ id: 'all', label: t('all') },
|
|
1042
|
+
];
|
|
1043
|
+
}
|
|
1044
|
+
if (tab === 'merge_requests') {
|
|
1045
|
+
return [
|
|
1046
|
+
{ id: 'open', label: t('open') },
|
|
1047
|
+
{ id: 'merged', label: t('merged') },
|
|
1048
|
+
{ id: 'closed', label: t('closed') },
|
|
1049
|
+
{ id: 'draft', label: t('draft') },
|
|
1050
|
+
];
|
|
1051
|
+
}
|
|
1052
|
+
return [];
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* @param {string} tab - active tab id.
|
|
1057
|
+
* @param {Function} t - translation function.
|
|
1058
|
+
* @returns {string | undefined} compose button label.
|
|
1059
|
+
*/
|
|
1060
|
+
function composeLabel(tab, t) {
|
|
1061
|
+
if (tab === 'issues') return t('newIssue');
|
|
1062
|
+
if (tab === 'merge_requests') return t('newMergeRequest');
|
|
1063
|
+
if (tab === 'branches') return t('newBranch');
|
|
1064
|
+
return undefined;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* @param {{ view: object, actions: object, t: Function }} props - panel state.
|
|
1069
|
+
* @returns {import('react').ReactElement} filter row.
|
|
1070
|
+
*/
|
|
1071
|
+
function PanelFilters({ view, actions, t }) {
|
|
1072
|
+
const active = activeFilterId(view.tab, view.issueState, view.mrStatus);
|
|
1073
|
+
return h(
|
|
1074
|
+
'div',
|
|
1075
|
+
{ className: 'is-devops-filters' },
|
|
1076
|
+
view.filters.map((filter) =>
|
|
1077
|
+
h(FilterChip, {
|
|
1078
|
+
key: filter.id,
|
|
1079
|
+
label: filter.label,
|
|
1080
|
+
active: active === filter.id,
|
|
1081
|
+
onSelect: () => selectFilter(view.tab, actions, filter.id),
|
|
1082
|
+
}),
|
|
1083
|
+
),
|
|
1084
|
+
view.tab === 'commits'
|
|
1085
|
+
? h(CommitRefSelect, {
|
|
1086
|
+
project: view.project,
|
|
1087
|
+
branches: view.branches,
|
|
1088
|
+
commitRef: view.commitRef,
|
|
1089
|
+
onChange: actions.setCommitRef,
|
|
1090
|
+
t,
|
|
1091
|
+
})
|
|
1092
|
+
: null,
|
|
1093
|
+
view.composeButton === undefined
|
|
1094
|
+
? null
|
|
1095
|
+
: h(
|
|
1096
|
+
'span',
|
|
1097
|
+
{ style: { marginLeft: 'auto' } },
|
|
1098
|
+
h(
|
|
1099
|
+
Button,
|
|
1100
|
+
{
|
|
1101
|
+
variant: 'primary',
|
|
1102
|
+
onClick: () => actions.setCompose(view.compose === view.tab ? undefined : view.tab),
|
|
1103
|
+
},
|
|
1104
|
+
view.composeButton,
|
|
1105
|
+
),
|
|
1106
|
+
),
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* @param {{ width: string }} props - skeleton row.
|
|
1112
|
+
* @returns {import('react').ReactElement} skeleton row.
|
|
1113
|
+
*/
|
|
1114
|
+
function LoadingRow({ width }) {
|
|
1115
|
+
return h(
|
|
1116
|
+
'div',
|
|
1117
|
+
{ className: 'is-devops-row' },
|
|
1118
|
+
h('div', { className: 'is-devops-skeleton', style: { width } }),
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/** @returns {import('react').ReactElement} loading list. */
|
|
1123
|
+
function LoadingRows() {
|
|
1124
|
+
return h(
|
|
1125
|
+
'div',
|
|
1126
|
+
{ className: 'is-devops-list' },
|
|
1127
|
+
h(LoadingRow, { width: '60%' }),
|
|
1128
|
+
h(LoadingRow, { width: '45%' }),
|
|
1129
|
+
h(LoadingRow, { width: '52%' }),
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* @param {{ item: object, t: Function, activeLocale: string, onToggle: (item: object, state: string) => void }} props - issue row.
|
|
1135
|
+
* @returns {import('react').ReactElement} row.
|
|
1136
|
+
*/
|
|
1137
|
+
function IssueRow({ item, t, activeLocale, onToggle }) {
|
|
1138
|
+
return h(
|
|
1139
|
+
'div',
|
|
1140
|
+
{ className: 'is-devops-row' },
|
|
1141
|
+
h(
|
|
1142
|
+
'div',
|
|
1143
|
+
{ className: 'is-devops-rowMain' },
|
|
1144
|
+
h(TranslatableTitle, { value: `#${item.iid} ${item.title}`, target: activeLocale, t }),
|
|
1145
|
+
h(
|
|
1146
|
+
'div',
|
|
1147
|
+
{ className: 'is-devops-rowMeta' },
|
|
1148
|
+
h('span', { className: 'is-devops-pill', 'data-state': item.state }, t(item.state)),
|
|
1149
|
+
h('span', null, `${t('author')}: ${item.author?.username ?? '—'}`),
|
|
1150
|
+
h('span', null, formatDate(item.updated_at, activeLocale)),
|
|
1151
|
+
item.comment_count > 0
|
|
1152
|
+
? h('span', null, t('comments').replace('{count}', String(item.comment_count)))
|
|
1153
|
+
: null,
|
|
1154
|
+
...(Array.isArray(item.labels) ? item.labels : []).map((label) =>
|
|
1155
|
+
h('span', { key: label, className: 'is-devops-label' }, label),
|
|
1156
|
+
),
|
|
1157
|
+
),
|
|
1158
|
+
),
|
|
1159
|
+
h(
|
|
1160
|
+
'div',
|
|
1161
|
+
{ className: 'is-devops-rowActions' },
|
|
1162
|
+
h(
|
|
1163
|
+
Button,
|
|
1164
|
+
{
|
|
1165
|
+
variant: 'ghost',
|
|
1166
|
+
onClick: () => onToggle(item, item.state === 'open' ? 'closed' : 'open'),
|
|
1167
|
+
},
|
|
1168
|
+
item.state === 'open' ? t('closeIssue') : t('reopenIssue'),
|
|
1169
|
+
),
|
|
1170
|
+
),
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* @param {{ item: object, t: Function, activeLocale: string, onMerge: (item: object) => void }} props - merge request row.
|
|
1176
|
+
* @returns {import('react').ReactElement} row.
|
|
1177
|
+
*/
|
|
1178
|
+
function MergeRequestRow({ item, t, activeLocale, onMerge }) {
|
|
1179
|
+
return h(
|
|
1180
|
+
'div',
|
|
1181
|
+
{ className: 'is-devops-row' },
|
|
1182
|
+
h(
|
|
1183
|
+
'div',
|
|
1184
|
+
{ className: 'is-devops-rowMain' },
|
|
1185
|
+
h(TranslatableTitle, { value: `!${item.iid} ${item.title}`, target: activeLocale, t }),
|
|
1186
|
+
h(
|
|
1187
|
+
'div',
|
|
1188
|
+
{ className: 'is-devops-rowMeta' },
|
|
1189
|
+
h('span', { className: 'is-devops-pill', 'data-state': item.status }, t(item.status)),
|
|
1190
|
+
h('span', null, `${item.source_branch} → ${item.target_branch}`),
|
|
1191
|
+
h('span', null, formatDate(item.updated_at, activeLocale)),
|
|
1192
|
+
),
|
|
1193
|
+
),
|
|
1194
|
+
h(
|
|
1195
|
+
'div',
|
|
1196
|
+
{ className: 'is-devops-rowActions' },
|
|
1197
|
+
item.status === 'open'
|
|
1198
|
+
? h(Button, { variant: 'primary', onClick: () => onMerge(item) }, t('merge'))
|
|
1199
|
+
: null,
|
|
1200
|
+
),
|
|
1201
|
+
);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/**
|
|
1205
|
+
* @param {{ item: object, activeLocale: string }} props - commit row.
|
|
1206
|
+
* @returns {import('react').ReactElement} row.
|
|
1207
|
+
*/
|
|
1208
|
+
function CommitRow({ item, activeLocale, t }) {
|
|
1209
|
+
return h(
|
|
1210
|
+
'div',
|
|
1211
|
+
{ className: 'is-devops-row' },
|
|
1212
|
+
h(
|
|
1213
|
+
'div',
|
|
1214
|
+
{ className: 'is-devops-rowMain' },
|
|
1215
|
+
h(TranslatableTitle, { value: firstLine(item.message), target: activeLocale, t }),
|
|
1216
|
+
h(
|
|
1217
|
+
'div',
|
|
1218
|
+
{ className: 'is-devops-rowMeta' },
|
|
1219
|
+
h('span', { className: 'is-devops-sha' }, shortSha(item.sha)),
|
|
1220
|
+
h('span', null, item.author_name ?? '—'),
|
|
1221
|
+
h('span', null, formatDate(item.authored_date ?? item.committed_date, activeLocale)),
|
|
1222
|
+
),
|
|
1223
|
+
),
|
|
1224
|
+
);
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
/**
|
|
1228
|
+
* @param {{ item: object, t: Function, onDelete: (item: object) => void }} props - branch row.
|
|
1229
|
+
* @returns {import('react').ReactElement} row.
|
|
1230
|
+
*/
|
|
1231
|
+
function BranchRow({ item, t, onDelete }) {
|
|
1232
|
+
return h(
|
|
1233
|
+
'div',
|
|
1234
|
+
{ className: 'is-devops-row' },
|
|
1235
|
+
h(
|
|
1236
|
+
'div',
|
|
1237
|
+
{ className: 'is-devops-rowMain' },
|
|
1238
|
+
h('div', { className: 'is-devops-rowTitle' }, h('span', { className: 'is-devops-sha' }, item.name)),
|
|
1239
|
+
h(
|
|
1240
|
+
'div',
|
|
1241
|
+
{ className: 'is-devops-rowMeta' },
|
|
1242
|
+
h('span', { className: 'is-devops-sha' }, shortSha(item.commit?.sha)),
|
|
1243
|
+
h('span', null, firstLine(item.commit?.message)),
|
|
1244
|
+
item.is_default
|
|
1245
|
+
? h('span', { className: 'is-devops-pill', 'data-state': 'open' }, t('default'))
|
|
1246
|
+
: null,
|
|
1247
|
+
item.is_protected ? h('span', { className: 'is-devops-pill' }, t('protected')) : null,
|
|
1248
|
+
),
|
|
1249
|
+
),
|
|
1250
|
+
h(
|
|
1251
|
+
'div',
|
|
1252
|
+
{ className: 'is-devops-rowActions' },
|
|
1253
|
+
item.is_default
|
|
1254
|
+
? null
|
|
1255
|
+
: h(Button, { variant: 'danger', onClick: () => onDelete(item) }, t('delete')),
|
|
1256
|
+
),
|
|
1257
|
+
);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/**
|
|
1261
|
+
* @param {object} item - tab row.
|
|
1262
|
+
* @param {string} tab - active tab id.
|
|
1263
|
+
* @returns {string | number | undefined} stable React key.
|
|
1264
|
+
*/
|
|
1265
|
+
function listRowKey(item, tab) {
|
|
1266
|
+
if (tab === 'commits') return item.sha;
|
|
1267
|
+
if (tab === 'branches') return item.name;
|
|
1268
|
+
return item.id;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/**
|
|
1272
|
+
* @param {{ item: object, tab: string, t: Function, activeLocale: string, actions: object }} props - row dispatcher.
|
|
1273
|
+
* @returns {import('react').ReactElement} row.
|
|
1274
|
+
*/
|
|
1275
|
+
function ListRow({ item, tab, t, activeLocale, actions }) {
|
|
1276
|
+
if (tab === 'issues') {
|
|
1277
|
+
return h(IssueRow, { item, t, activeLocale, onToggle: actions.updateIssue });
|
|
1278
|
+
}
|
|
1279
|
+
if (tab === 'merge_requests') {
|
|
1280
|
+
return h(MergeRequestRow, { item, t, activeLocale, onMerge: actions.mergeRequest });
|
|
1281
|
+
}
|
|
1282
|
+
if (tab === 'commits') return h(CommitRow, { item, activeLocale, t });
|
|
1283
|
+
return h(BranchRow, { item, t, onDelete: actions.deleteBranch });
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
/**
|
|
1287
|
+
* @param {{ view: object, actions: object, t: Function }} props - panel state.
|
|
1288
|
+
* @returns {import('react').ReactElement} list area.
|
|
1289
|
+
*/
|
|
1290
|
+
function ListSection({ view, actions, t }) {
|
|
1291
|
+
if (view.list.status === 'loading') return h(LoadingRows, null);
|
|
1292
|
+
if (view.list.status === 'error') {
|
|
1293
|
+
return h(
|
|
1294
|
+
Message,
|
|
1295
|
+
{ tone: 'error' },
|
|
1296
|
+
t('requestFailed').replace('{message}', view.list.error ?? ''),
|
|
1297
|
+
);
|
|
1298
|
+
}
|
|
1299
|
+
if (view.list.items.length === 0) {
|
|
1300
|
+
return h('div', { className: 'is-devops-empty' }, t('emptyMessage'));
|
|
1301
|
+
}
|
|
1302
|
+
return h(
|
|
1303
|
+
'div',
|
|
1304
|
+
{ className: 'is-devops-list' },
|
|
1305
|
+
view.list.items.map((item) =>
|
|
1306
|
+
h(ListRow, {
|
|
1307
|
+
key: listRowKey(item, view.tab),
|
|
1308
|
+
item,
|
|
1309
|
+
tab: view.tab,
|
|
1310
|
+
t,
|
|
1311
|
+
activeLocale: view.activeLocale,
|
|
1312
|
+
actions,
|
|
1313
|
+
}),
|
|
1314
|
+
),
|
|
1315
|
+
);
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
/**
|
|
1319
|
+
* @param {{ projects: object[] | undefined, projectsError: string | undefined, t: Function }} props - empty state wiring.
|
|
1320
|
+
* @returns {import('react').ReactElement} empty notice.
|
|
1321
|
+
*/
|
|
1322
|
+
function EmptyState({ projects, projectsError, t }) {
|
|
1323
|
+
return h('div', { className: 'is-devops-empty' }, t(emptyMessageKey(projects, projectsError)));
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
/**
|
|
1327
|
+
* @param {object[] | undefined} projects - project rows.
|
|
1328
|
+
* @param {string | undefined} projectsError - project load error.
|
|
1329
|
+
* @returns {string} copy key.
|
|
1330
|
+
*/
|
|
1331
|
+
function emptyMessageKey(projects, projectsError) {
|
|
1332
|
+
if (projects === undefined) return 'loading';
|
|
1333
|
+
if (projectsError === undefined) return 'noProject';
|
|
1334
|
+
return 'settingsHint';
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
/**
|
|
1338
|
+
* @param {{ view: object, actions: object, t: Function }} props - panel state.
|
|
1339
|
+
* @returns {import('react').ReactElement} selected-project body.
|
|
1340
|
+
*/
|
|
1341
|
+
function ProjectBody({ view, actions, t }) {
|
|
1342
|
+
return h(
|
|
1343
|
+
Fragment,
|
|
1344
|
+
null,
|
|
1345
|
+
h(PanelFilters, { view, actions, t }),
|
|
1346
|
+
h(ComposerSlot, { view, actions, t }),
|
|
1347
|
+
h(ListSection, { view, actions, t }),
|
|
1348
|
+
);
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
/**
|
|
1352
|
+
* @param {{ view: object, actions: object, t: Function }} props - panel state.
|
|
1353
|
+
* @returns {import('react').ReactElement} panel body.
|
|
1354
|
+
*/
|
|
1355
|
+
function PanelBody({ view, actions, t }) {
|
|
1356
|
+
return h(
|
|
1357
|
+
'div',
|
|
1358
|
+
{ className: 'is-devops-body' },
|
|
1359
|
+
view.notice === undefined ? null : h(Message, { tone: view.notice.tone }, view.notice.text),
|
|
1360
|
+
view.projectsError === undefined
|
|
1361
|
+
? null
|
|
1362
|
+
: h(
|
|
1363
|
+
Message,
|
|
1364
|
+
{ tone: 'error' },
|
|
1365
|
+
t('loadProjectsFailed').replace('{message}', view.projectsError),
|
|
1366
|
+
),
|
|
1367
|
+
view.project === undefined
|
|
1368
|
+
? h(EmptyState, { projects: view.projects, projectsError: view.projectsError, t })
|
|
1369
|
+
: h(ProjectBody, { view, actions, t }),
|
|
1370
|
+
);
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
/**
|
|
1374
|
+
* @param {number} refreshToken - refresh counter.
|
|
1375
|
+
* @returns {{ projects: object[] | undefined, projectsError: string | undefined }} project list state.
|
|
1376
|
+
*/
|
|
1377
|
+
function useProjects(refreshToken) {
|
|
1378
|
+
const [projects, setProjects] = useState(undefined);
|
|
1379
|
+
const [projectsError, setProjectsError] = useState(undefined);
|
|
1380
|
+
const loadProjects = useCallback(async () => {
|
|
1381
|
+
setProjectsError(undefined);
|
|
1382
|
+
try {
|
|
1383
|
+
const data = await apiRequest('/api/v1/projects?per_page=100');
|
|
1384
|
+
setProjects(projectRows(data));
|
|
1385
|
+
} catch (error) {
|
|
1386
|
+
setProjects([]);
|
|
1387
|
+
setProjectsError(error.message);
|
|
1388
|
+
}
|
|
1389
|
+
}, []);
|
|
1390
|
+
useEffect(() => {
|
|
1391
|
+
void loadProjects();
|
|
1392
|
+
}, [loadProjects, refreshToken]);
|
|
1393
|
+
return { projects, projectsError };
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
/**
|
|
1397
|
+
* @param {object[] | undefined} projects - project rows.
|
|
1398
|
+
* @returns {{ project: object | undefined, projectKey: string, setProjectKey: (key: string) => void }} selection state.
|
|
1399
|
+
*/
|
|
1400
|
+
function useSelectedProject(projects) {
|
|
1401
|
+
const [projectKey, setProjectKey] = useState(readStoredProjectKey);
|
|
1402
|
+
useEffect(() => {
|
|
1403
|
+
if (projects === undefined || projects.length === 0) return;
|
|
1404
|
+
if (!projects.some((candidate) => keyOfProject(candidate) === projectKey)) {
|
|
1405
|
+
setProjectKey(keyOfProject(projects[0]));
|
|
1406
|
+
}
|
|
1407
|
+
}, [projects, projectKey]);
|
|
1408
|
+
useEffect(() => {
|
|
1409
|
+
try {
|
|
1410
|
+
if (projectKey === '') localStorage.removeItem(STORAGE_KEY);
|
|
1411
|
+
else localStorage.setItem(STORAGE_KEY, projectKey);
|
|
1412
|
+
} catch {
|
|
1413
|
+
// Storage may be unavailable in hardened browser contexts.
|
|
1414
|
+
}
|
|
1415
|
+
}, [projectKey]);
|
|
1416
|
+
const project = useMemo(() => findProject(projects, projectKey), [projects, projectKey]);
|
|
1417
|
+
return { project, projectKey, setProjectKey };
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
/**
|
|
1421
|
+
* @param {object | undefined} project - selected project.
|
|
1422
|
+
* @param {string} tab - active tab id.
|
|
1423
|
+
* @param {number} refreshToken - refresh counter.
|
|
1424
|
+
* @returns {{ branches: object[], commitRef: string, setCommitRef: (ref: string) => void }} branch state.
|
|
1425
|
+
*/
|
|
1426
|
+
function useProjectBranches(project, tab, refreshToken) {
|
|
1427
|
+
const [branches, setBranches] = useState([]);
|
|
1428
|
+
const [commitRef, setCommitRef] = useState('');
|
|
1429
|
+
useEffect(() => {
|
|
1430
|
+
if (project === undefined || !needsBranches(tab)) return undefined;
|
|
1431
|
+
let cancelled = false;
|
|
1432
|
+
fetchBranches(project)
|
|
1433
|
+
.then((rows) => {
|
|
1434
|
+
if (cancelled) return;
|
|
1435
|
+
setBranches(rows);
|
|
1436
|
+
setCommitRef((current) => pickBranchName(rows, current));
|
|
1437
|
+
})
|
|
1438
|
+
.catch(() => {
|
|
1439
|
+
if (!cancelled) setBranches([]);
|
|
1440
|
+
});
|
|
1441
|
+
return () => {
|
|
1442
|
+
cancelled = true;
|
|
1443
|
+
};
|
|
1444
|
+
}, [project?.id, tab, refreshToken]);
|
|
1445
|
+
return { branches, commitRef, setCommitRef };
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
/**
|
|
1449
|
+
* @param {object | undefined} project - selected project.
|
|
1450
|
+
* @param {string} tab - active tab id.
|
|
1451
|
+
* @param {{ issueState: string, mrStatus: string, commitRef: string }} filters - active filters.
|
|
1452
|
+
* @param {number} refreshToken - refresh counter.
|
|
1453
|
+
* @returns {object} list state.
|
|
1454
|
+
*/
|
|
1455
|
+
function useTabItems(project, tab, filters, refreshToken) {
|
|
1456
|
+
const [list, setList] = useState({ status: 'idle', items: [] });
|
|
1457
|
+
useEffect(() => {
|
|
1458
|
+
if (project === undefined) return undefined;
|
|
1459
|
+
let cancelled = false;
|
|
1460
|
+
setList({ status: 'loading', items: [] });
|
|
1461
|
+
fetchTabItems(project, tab, filters)
|
|
1462
|
+
.then((items) => {
|
|
1463
|
+
if (!cancelled) setList({ status: 'ready', items });
|
|
1464
|
+
})
|
|
1465
|
+
.catch((error) => {
|
|
1466
|
+
if (!cancelled) setList({ status: 'error', items: [], error: error.message });
|
|
1467
|
+
});
|
|
1468
|
+
return () => {
|
|
1469
|
+
cancelled = true;
|
|
1470
|
+
};
|
|
1471
|
+
}, [project?.id, tab, filters.issueState, filters.mrStatus, filters.commitRef, refreshToken]);
|
|
1472
|
+
return list;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
/**
|
|
1476
|
+
* The DevOps panel.
|
|
1477
|
+
*
|
|
1478
|
+
* @returns {import('react').ReactElement} panel.
|
|
1479
|
+
*/
|
|
1480
|
+
function DevOpsPanel() {
|
|
1481
|
+
const ctx = panelCtx;
|
|
1482
|
+
const t = useTranslation(ctx);
|
|
1483
|
+
const activeLocale = ctx.locale.getSnapshot().active;
|
|
1484
|
+
const [refreshToken, setRefreshToken] = useState(0);
|
|
1485
|
+
const [tab, setTab] = useState('issues');
|
|
1486
|
+
const [issueState, setIssueState] = useState('open');
|
|
1487
|
+
const [mrStatus, setMrStatus] = useState('open');
|
|
1488
|
+
const [compose, setCompose] = useState(undefined);
|
|
1489
|
+
const [notice, setNotice] = useState(undefined);
|
|
1490
|
+
const { projects, projectsError } = useProjects(refreshToken);
|
|
1491
|
+
const { project, projectKey, setProjectKey } = useSelectedProject(projects);
|
|
1492
|
+
const { branches, commitRef, setCommitRef } = useProjectBranches(project, tab, refreshToken);
|
|
1493
|
+
const list = useTabItems(project, tab, { issueState, mrStatus, commitRef }, refreshToken);
|
|
1494
|
+
|
|
1495
|
+
const refresh = () => {
|
|
1496
|
+
setNotice(undefined);
|
|
1497
|
+
setRefreshToken((value) => value + 1);
|
|
1498
|
+
};
|
|
1499
|
+
const selectProject = (key) => {
|
|
1500
|
+
setProjectKey(key);
|
|
1501
|
+
setCompose(undefined);
|
|
1502
|
+
setNotice(undefined);
|
|
1503
|
+
};
|
|
1504
|
+
const selectTab = (nextTab) => {
|
|
1505
|
+
setTab(nextTab);
|
|
1506
|
+
setCompose(undefined);
|
|
1507
|
+
setNotice(undefined);
|
|
1508
|
+
};
|
|
1509
|
+
const finishCompose = (message) => {
|
|
1510
|
+
setCompose(undefined);
|
|
1511
|
+
setNotice({ tone: 'ok', text: message });
|
|
1512
|
+
setRefreshToken((value) => value + 1);
|
|
1513
|
+
};
|
|
1514
|
+
const cancelCompose = () => setCompose(undefined);
|
|
1515
|
+
|
|
1516
|
+
const updateIssue = async (issue, state) => {
|
|
1517
|
+
try {
|
|
1518
|
+
await apiRequest(`${projectBase(project)}/issues/${issue.iid}`, {
|
|
1519
|
+
method: 'PUT',
|
|
1520
|
+
body: { state },
|
|
1521
|
+
});
|
|
1522
|
+
setNotice({ tone: 'ok', text: t('created') });
|
|
1523
|
+
setRefreshToken((value) => value + 1);
|
|
1524
|
+
} catch (error) {
|
|
1525
|
+
setNotice({ tone: 'error', text: t('requestFailed').replace('{message}', error.message) });
|
|
1526
|
+
}
|
|
1527
|
+
};
|
|
1528
|
+
|
|
1529
|
+
const mergeRequest = async (mergeRequestRow) => {
|
|
1530
|
+
try {
|
|
1531
|
+
await apiRequest(`${projectBase(project)}/merge_requests/${mergeRequestRow.iid}/merge`, {
|
|
1532
|
+
method: 'PUT',
|
|
1533
|
+
body: {},
|
|
1534
|
+
});
|
|
1535
|
+
setNotice({ tone: 'ok', text: t('mergedNotice') });
|
|
1536
|
+
setRefreshToken((value) => value + 1);
|
|
1537
|
+
} catch (error) {
|
|
1538
|
+
setNotice({ tone: 'error', text: t('requestFailed').replace('{message}', error.message) });
|
|
1539
|
+
}
|
|
1540
|
+
};
|
|
1541
|
+
|
|
1542
|
+
const deleteBranch = async (branch) => {
|
|
1543
|
+
if (!window.confirm(t('confirmDeleteBranch').replace('{branch}', branch.name))) return;
|
|
1544
|
+
try {
|
|
1545
|
+
await apiRequest(`${projectBase(project)}/repository/branches/${encodeURIComponent(branch.name)}`, {
|
|
1546
|
+
method: 'DELETE',
|
|
1547
|
+
});
|
|
1548
|
+
setNotice({ tone: 'ok', text: t('deletedNotice') });
|
|
1549
|
+
setRefreshToken((value) => value + 1);
|
|
1550
|
+
} catch (error) {
|
|
1551
|
+
setNotice({ tone: 'error', text: t('requestFailed').replace('{message}', error.message) });
|
|
1552
|
+
}
|
|
1553
|
+
};
|
|
1554
|
+
|
|
1555
|
+
const view = {
|
|
1556
|
+
project,
|
|
1557
|
+
projects,
|
|
1558
|
+
projectsError,
|
|
1559
|
+
projectKey,
|
|
1560
|
+
notice,
|
|
1561
|
+
tab,
|
|
1562
|
+
issueState,
|
|
1563
|
+
mrStatus,
|
|
1564
|
+
commitRef,
|
|
1565
|
+
branches,
|
|
1566
|
+
compose,
|
|
1567
|
+
filters: filtersForTab(tab, t),
|
|
1568
|
+
composeButton: composeLabel(tab, t),
|
|
1569
|
+
list,
|
|
1570
|
+
activeLocale,
|
|
1571
|
+
};
|
|
1572
|
+
const actions = {
|
|
1573
|
+
refresh,
|
|
1574
|
+
selectProject,
|
|
1575
|
+
selectTab,
|
|
1576
|
+
setIssueState,
|
|
1577
|
+
setMrStatus,
|
|
1578
|
+
setCommitRef,
|
|
1579
|
+
setCompose,
|
|
1580
|
+
finishCompose,
|
|
1581
|
+
cancelCompose,
|
|
1582
|
+
updateIssue,
|
|
1583
|
+
mergeRequest,
|
|
1584
|
+
deleteBranch,
|
|
1585
|
+
};
|
|
1586
|
+
|
|
1587
|
+
return h(
|
|
1588
|
+
'div',
|
|
1589
|
+
{ className: 'is-devops-root' },
|
|
1590
|
+
h(PanelHeader, { view, actions, t }),
|
|
1591
|
+
h(PanelBody, { view, actions, t }),
|
|
1592
|
+
);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
/**
|
|
1596
|
+
* Sidebar icon. Stroke uses the current color so active state follows the host.
|
|
1597
|
+
*
|
|
1598
|
+
* @param {{ size?: number, active?: boolean }} props - owner props.
|
|
1599
|
+
* @returns {import('react').ReactElement} icon.
|
|
1600
|
+
*/
|
|
1601
|
+
function DevOpsIcon({ size = 24, active = false }) {
|
|
1602
|
+
return h(
|
|
1603
|
+
'svg',
|
|
1604
|
+
{
|
|
1605
|
+
width: size,
|
|
1606
|
+
height: size,
|
|
1607
|
+
viewBox: '0 0 24 24',
|
|
1608
|
+
fill: 'none',
|
|
1609
|
+
'aria-hidden': true,
|
|
1610
|
+
style: { color: active ? 'var(--dsw-alias-label-primary)' : 'inherit' },
|
|
1611
|
+
},
|
|
1612
|
+
h('circle', { cx: 7, cy: 5, r: 2.2, stroke: 'currentColor', strokeWidth: 1.6 }),
|
|
1613
|
+
h('circle', { cx: 7, cy: 19, r: 2.2, stroke: 'currentColor', strokeWidth: 1.6 }),
|
|
1614
|
+
h('circle', { cx: 17, cy: 8, r: 2.2, stroke: 'currentColor', strokeWidth: 1.6 }),
|
|
1615
|
+
h('path', {
|
|
1616
|
+
d: 'M7 7.2v9.6M17 10.2v1.6c0 2.2-2.2 3.2-4.6 3.2H9.2',
|
|
1617
|
+
stroke: 'currentColor',
|
|
1618
|
+
strokeWidth: 1.6,
|
|
1619
|
+
strokeLinecap: 'round',
|
|
1620
|
+
}),
|
|
1621
|
+
);
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
/** Services required by the Client half. */
|
|
1625
|
+
const inject = ['slots', 'locale'];
|
|
1626
|
+
|
|
1627
|
+
/**
|
|
1628
|
+
* Client plugin body.
|
|
1629
|
+
*
|
|
1630
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - Client context.
|
|
1631
|
+
* @returns {void}
|
|
1632
|
+
*/
|
|
1633
|
+
function apply(ctx) {
|
|
1634
|
+
panelCtx = ctx;
|
|
1635
|
+
ctx.effect(installStyles, 'is-devops: panel styles');
|
|
1636
|
+
|
|
1637
|
+
ctx.slots.inject('main', () =>
|
|
1638
|
+
ctx.slots.register({ name: 'main', key: PANEL_KEY }, DevOpsPanel),
|
|
1639
|
+
);
|
|
1640
|
+
|
|
1641
|
+
ctx.slots.inject('sidebar.panellist', () =>
|
|
1642
|
+
ctx.slots.register(
|
|
1643
|
+
{
|
|
1644
|
+
name: 'sidebar.panellist',
|
|
1645
|
+
id: PANEL_KEY,
|
|
1646
|
+
order: 20,
|
|
1647
|
+
label: 'DevOps',
|
|
1648
|
+
},
|
|
1649
|
+
DevOpsIcon,
|
|
1650
|
+
),
|
|
1651
|
+
);
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
window.__ModuleLoader__.load({
|
|
1655
|
+
id: '@infinisys/is-devops',
|
|
1656
|
+
factory(require) {
|
|
1657
|
+
React = require('react');
|
|
1658
|
+
h = React.createElement;
|
|
1659
|
+
Fragment = React.Fragment;
|
|
1660
|
+
useState = React.useState;
|
|
1661
|
+
useEffect = React.useEffect;
|
|
1662
|
+
useCallback = React.useCallback;
|
|
1663
|
+
useMemo = React.useMemo;
|
|
1664
|
+
return { inject, apply };
|
|
1665
|
+
},
|
|
1666
|
+
});
|
|
1667
|
+
}
|