@marsken/dsh-studio 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ // AI Coding 执行记录「自报值 vs 仓库事实」的对账。纯函数,方便单测;
2
+ // 取数(MR / changes / commits)在 integrations 里,这里只管比。
3
+
4
+ /** 把自报的文件数、提交数和 MR 里的事实比一遍,返回可以直接写进 card.coding.gitlab 的结果。 */
5
+ export function compareCodingFacts(coding, mr, detail, now = Date.now()) {
6
+ const declaredFiles = Array.isArray(coding?.files) ? coding.files.length : 0
7
+ const declaredCommits = Array.isArray(coding?.commits) ? coding.commits.length : 0
8
+ const actualFiles = Array.isArray(detail?.changes?.files) ? detail.changes.files.length : 0
9
+ const actualCommits = Number(detail?.changes?.commits) || 0
10
+ const mismatches = []
11
+ if (declaredFiles !== actualFiles) mismatches.push(`自报 ${declaredFiles} 个文件,MR 里是 ${actualFiles} 个`)
12
+ if (declaredCommits !== actualCommits) mismatches.push(`自报 ${declaredCommits} 个提交,MR 里是 ${actualCommits} 个`)
13
+ return {
14
+ at: now,
15
+ state: String(mr?.state ?? ''),
16
+ title: String(mr?.title ?? '').slice(0, 200),
17
+ pipeline: detail?.pipeline === null || detail?.pipeline === undefined ? null : String(detail.pipeline.status ?? ''),
18
+ files: { declared: declaredFiles, actual: actualFiles },
19
+ commits: { declared: declaredCommits, actual: actualCommits },
20
+ mismatches: mismatches.slice(0, 6),
21
+ }
22
+ }
@@ -0,0 +1,32 @@
1
+ // 需求改版的影响面:哪些卡已经产出过东西、哪些用例要重跑。纯函数,方便单测。
2
+
3
+ /** 把受影响的卡上挂着的用例摊平,给需求变更记录用。 */
4
+ export function casesOfTasks(tasks, limit = 40) {
5
+ const out = []
6
+ for (const task of Array.isArray(tasks) ? tasks : []) {
7
+ if (task === null || task === undefined) continue
8
+ for (const item of Array.isArray(task.cases) ? task.cases : []) {
9
+ if (out.length >= limit) return out
10
+ out.push({
11
+ taskId: String(task.id ?? ''),
12
+ taskCode: String(task.code ?? ''),
13
+ key: `${task.id}:${item.id}`,
14
+ title: String(item.title ?? '').slice(0, 160),
15
+ result: String(item.result ?? 'pending'),
16
+ http: item.http === true,
17
+ })
18
+ }
19
+ }
20
+ return out
21
+ }
22
+
23
+ /** 影响面小结:多少张卡、多少条用例、其中几条能自动跑。 */
24
+ export function impactSummary(tasks) {
25
+ const cases = casesOfTasks(tasks, 500)
26
+ return {
27
+ cards: (Array.isArray(tasks) ? tasks : []).length,
28
+ cases: cases.length,
29
+ runnable: cases.filter(item => item.http).length,
30
+ failing: cases.filter(item => item.result === 'fail').length,
31
+ }
32
+ }
@@ -0,0 +1,587 @@
1
+ // dsh-studio · 集成层:GitLab / 飞书 / 平台健康
2
+ // 从 index.js 拆出(P1 第三步)。本模块不 import index.js,宿主级能力由 configureIntegrations() 注入。
3
+
4
+ import { randomBytes } from 'node:crypto'
5
+ import { STATUSES, mergeCoding } from './domain.js'
6
+
7
+ let host = { newTaskId: () => '', serviceOf: () => null, getStore: () => null }
8
+
9
+ /** 由 index.js 在 mount 时注入宿主能力(不反向 import,避免成环)。 */
10
+ export function configureIntegrations(services) {
11
+ host = { ...host, ...(services ?? {}) }
12
+ }
13
+
14
+
15
+ /** Last outcome of the GitLab / Feishu side calls, for the panel. */
16
+ export const INTEGRATION = { lastError: '', lastAt: 0, lastSyncError: '', lastPushError: '' }
17
+
18
+ /**
19
+ * 外部集成的健康度。以前这些失败只写日志,人看不到;现在每条通道都记
20
+ * 「最近一次成功 / 失败 / 连续失败次数」,面板上直接亮红。
21
+ */
22
+ const CHANNELS = {
23
+ feishu: { label: '飞书群通知', hint: '卡片状态变化往群里发消息的 webhook' },
24
+ gitlab: { label: 'GitLab', hint: '拉 Issue、回写评论、对 MR' },
25
+ drive: { label: '飞书云盘归档', hint: '需求文件夹与产物文档(lark-cli)' },
26
+ worktree: { label: '代码隔离', hint: 'git worktree 的创建与回收' },
27
+ }
28
+ const HEALTH = new Map()
29
+ export function healthState(channel) {
30
+ if (!HEALTH.has(channel)) {
31
+ HEALTH.set(channel, {
32
+ channel,
33
+ label: CHANNELS[channel]?.label ?? channel,
34
+ hint: CHANNELS[channel]?.hint ?? '',
35
+ ok: null,
36
+ at: 0,
37
+ lastOkAt: 0,
38
+ lastErrorAt: 0,
39
+ lastError: '',
40
+ failures: 0,
41
+ attempts: 0,
42
+ skipped: 0,
43
+ })
44
+ }
45
+ return HEALTH.get(channel)
46
+ }
47
+
48
+ /**
49
+ * 集成健康不是看板数据:放 meta 的 side 键,重启后还在,但既不动 revision 也不追加快照。
50
+ * 每几秒变一次的计数要是进 state,客户端会以为业务数据变了。
51
+ */
52
+ const HEALTH_KEY = 'integration-health'
53
+ let flushTimer = null
54
+
55
+ /** 把内存里的健康表写回库;同一批变化只落一次盘。 */
56
+ function flushHealth() {
57
+ const store = host.getStore?.()
58
+ if (store === undefined || store === null || typeof store.writeSide !== 'function') return
59
+ const saved = {}
60
+ for (const [channel, entry] of HEALTH) saved[channel] = { ...entry }
61
+ void store.writeSide(HEALTH_KEY, saved)
62
+ }
63
+
64
+ function scheduleHealthFlush() {
65
+ if (flushTimer !== null) return
66
+ flushTimer = setTimeout(() => { flushTimer = null; flushHealth() }, 2000)
67
+ flushTimer.unref?.()
68
+ }
69
+
70
+ /** 启动时读回上一次的健康表:重启不该让四条通道全部回到「还没用过」。 */
71
+ export function restoreHealth() {
72
+ const store = host.getStore?.()
73
+ if (store === undefined || store === null || typeof store.readSide !== 'function') return
74
+ void Promise.resolve(store.readSide(HEALTH_KEY)).then(saved => {
75
+ if (saved === null || typeof saved !== 'object') return
76
+ for (const [channel, entry] of Object.entries(saved)) {
77
+ if (entry === null || typeof entry !== 'object') continue
78
+ HEALTH.set(channel, { ...healthState(channel), ...entry, channel })
79
+ }
80
+ }).catch(() => { /* 读不回来就按全新启动 */ })
81
+ }
82
+
83
+ /** Record one integration attempt. Never throws: health tracking must not break the caller. */
84
+ export function markHealth(channel, outcome, detail = '') {
85
+ try {
86
+ const entry = healthState(channel)
87
+ const at = Date.now()
88
+ entry.at = at
89
+ if (outcome === 'skip') {
90
+ entry.skipped += 1
91
+ return
92
+ }
93
+ entry.attempts += 1
94
+ if (outcome === 'ok') {
95
+ entry.ok = true
96
+ entry.lastOkAt = at
97
+ entry.failures = 0
98
+ entry.lastError = ''
99
+ } else {
100
+ entry.ok = false
101
+ entry.failures += 1
102
+ entry.lastErrorAt = at
103
+ entry.lastError = String(detail ?? '').slice(0, 300)
104
+ escalateHealth(channel, entry)
105
+ }
106
+ scheduleHealthFlush()
107
+ } catch {
108
+ /* ignore */
109
+ }
110
+ }
111
+
112
+ /**
113
+ * 连着坏到阈值就升级到另一个群(或同群的机器人):只在跨过阈值的那一次发,避免刷屏。
114
+ * 用独立的 escalateWebhook,和日常通知分开,坏消息不会淹在状态流里。
115
+ */
116
+ export function escalateHealth(channel, entry) {
117
+ try {
118
+ const state = host.getStore()?.snapshot()
119
+ if (state === undefined) return
120
+ const hook = String(state.config.feishu.escalateWebhook ?? '').trim()
121
+ const after = Math.max(1, Number(state.config.feishu.escalateAfter) || 3)
122
+ if (hook === '' || entry.failures !== after) return
123
+ const label = CHANNELS[channel]?.label ?? channel
124
+ void feishuPush(hook, `【集成异常升级】${label} 已连续失败 ${entry.failures} 次\n最近错误:${entry.lastError}\n最近时间:${new Date(entry.lastErrorAt).toLocaleString('zh-CN')}\n去「设置 → 集成健康」看一眼,或人工确认是否可以继续。`)
125
+ } catch {
126
+ /* 升级失败只能算了,不能反过来污染健康表 */
127
+ }
128
+ }
129
+
130
+ /** Snapshot for the panel: only the channels that were actually used. */
131
+ export function healthReport() {
132
+ const channels = []
133
+ for (const [channel, meta] of Object.entries(CHANNELS)) {
134
+ const entry = HEALTH.get(channel) ?? {
135
+ channel,
136
+ label: meta.label,
137
+ hint: meta.hint,
138
+ ok: null,
139
+ at: 0,
140
+ lastOkAt: 0,
141
+ lastErrorAt: 0,
142
+ lastError: '',
143
+ failures: 0,
144
+ attempts: 0,
145
+ skipped: 0,
146
+ }
147
+ channels.push({
148
+ channel,
149
+ label: entry.label,
150
+ hint: entry.hint,
151
+ ok: entry.ok,
152
+ at: entry.at,
153
+ lastOkAt: entry.lastOkAt,
154
+ lastErrorAt: entry.lastErrorAt,
155
+ lastError: entry.lastError,
156
+ failures: entry.failures,
157
+ attempts: entry.attempts,
158
+ skipped: entry.skipped,
159
+ state: entry.ok === null ? 'idle' : entry.ok ? 'ok' : 'error',
160
+ })
161
+ }
162
+ const broken = channels.filter(item => item.state === 'error')
163
+ return { channels, broken: broken.length, checkedAt: Date.now() }
164
+ }
165
+
166
+
167
+ /** Auto-sync timer for GitLab; created in apply(), read by the status route. */
168
+
169
+ /** The live dispatcher, so tools can fan a mention out to an expert. */
170
+ /** 健康表在 emit 时需要读配置(升级 webhook 与阈值),这里留一个可空引用。 */
171
+
172
+ export function projectReady(config, project) {
173
+ if (project === undefined) return false
174
+ const url = project.url || config.gitlab.url
175
+ const token = project.token || config.gitlab.token
176
+ return url.length > 0 && token.length > 0 && project.project.length > 0
177
+ }
178
+
179
+ export function gitlabReady(config) {
180
+ return (config.gitlab.projects ?? []).some(project => projectReady(config, project))
181
+ }
182
+
183
+ /** One GitLab REST call against one configured repository. */
184
+ export async function gitlabRequest(config, project, path, init) {
185
+ const base = (project.url || config.gitlab.url).replace(/\/+$/, '')
186
+ const token = project.token || config.gitlab.token
187
+ const slug = encodeURIComponent(project.project)
188
+ const headers = { 'PRIVATE-TOKEN': token }
189
+ if (init?.body !== undefined) headers['content-type'] = 'application/json'
190
+ let response
191
+ try {
192
+ response = await fetch(`${base}/api/v4/projects/${slug}${path}`, { ...init, headers })
193
+ } catch (error) {
194
+ markHealth('gitlab', 'fail', `连不上 GitLab:${error?.message ?? error}`)
195
+ throw error
196
+ }
197
+ if (!response.ok) {
198
+ const detail = (await response.text()).slice(0, 300)
199
+ markHealth('gitlab', 'fail', `HTTP ${response.status} ${detail}`)
200
+ throw new Error(`GitLab ${response.status}: ${detail}`)
201
+ }
202
+ markHealth('gitlab', 'ok')
203
+ if (response.status === 204) return null
204
+ return await response.json()
205
+ }
206
+
207
+ /**
208
+ * Pull open issues in as cards, one repository at a time. A card keeps the
209
+ * project it came from (`gitlabProject`) and is matched on (project, iid) so
210
+ * two repositories can both have issue #12.
211
+ */
212
+ /** 一条 MR 的流水线状态与真实改动(拿不到就当没有,不算失败)。 */
213
+ export async function gitlabMergeRequestDetail(config, project, iid) {
214
+ const detail = { pipeline: null, changes: null }
215
+ try {
216
+ const pipelines = await gitlabRequest(config, project, `/merge_requests/${iid}/pipelines`)
217
+ const latest = Array.isArray(pipelines) ? pipelines[0] : undefined
218
+ if (latest !== undefined) {
219
+ detail.pipeline = {
220
+ id: Number(latest.id) || 0,
221
+ status: String(latest.status ?? ''),
222
+ ref: String(latest.ref ?? ''),
223
+ url: String(latest.web_url ?? ''),
224
+ at: Date.now(),
225
+ }
226
+ }
227
+ } catch { /* 没开 CI 或没权限,不算错 */ }
228
+ try {
229
+ const payload = await gitlabRequest(config, project, `/merge_requests/${iid}/changes`)
230
+ const list = Array.isArray(payload?.changes) ? payload.changes : []
231
+ detail.changes = {
232
+ files: list.slice(0, 300).map((entry, index) => ({
233
+ id: `f${index + 1}`,
234
+ path: String(entry.new_path ?? entry.old_path ?? '').slice(0, 300),
235
+ change: entry.new_file === true ? 'added' : entry.deleted_file === true ? 'deleted' : 'modified',
236
+ additions: 0,
237
+ deletions: 0,
238
+ })),
239
+ commits: Number(payload?.commits_count) || 0,
240
+ }
241
+ } catch { /* 同上 */ }
242
+ return detail
243
+ }
244
+
245
+ /**
246
+ * 把 MR 和卡对上:先按「分支名里带卡号」认(一卡一 worktree 时分支就是 task/DEV-001),
247
+ * 再按 issue 的 related_merge_requests 兜一次。对上之后记流水线状态与真实改动文件,
248
+ * 开发中的卡一旦有打开的 MR 就自动进「待评审」——评审不是靠人盯着看。
249
+ */
250
+ export async function gitlabSyncMergeRequests(store, config, project) {
251
+ const state = store.snapshot()
252
+ const byCode = new Map()
253
+ for (const task of state.tasks) {
254
+ const code = String(task.code ?? '').toLowerCase()
255
+ if (code.length > 0) byCode.set(code, task)
256
+ }
257
+ const seen = new Map()
258
+ let listed = []
259
+ try {
260
+ listed = await gitlabRequest(config, project, '/merge_requests?state=opened&per_page=50&order_by=updated_at')
261
+ } catch { listed = [] }
262
+ for (const mr of Array.isArray(listed) ? listed : []) {
263
+ const iid = Number(mr.iid) || 0
264
+ if (iid === 0) continue
265
+ const branch = String(mr.source_branch ?? '').toLowerCase()
266
+ const direct = state.tasks.find(task => String(task.gitlabProject ?? '') === project.id && (task.gitlabMR?.iid ?? 0) === iid)
267
+ const hit = direct ?? [...byCode.entries()].find(([code]) => branch.includes(code))?.[1]
268
+ if (hit === undefined) continue
269
+ seen.set(hit.id, mr)
270
+ }
271
+ const candidates = state.tasks
272
+ .filter(task => String(task.gitlabProject ?? '') === project.id)
273
+ .filter(task => Number.isFinite(Number(task.gitlabIid)) && Number(task.gitlabIid) > 0)
274
+ .filter(task => !seen.has(task.id))
275
+ .filter(task => task.status === 'dev' || task.status === 'review' || (task.gitlabMR?.iid ?? 0) === 0)
276
+ .slice(0, 12)
277
+ for (const task of candidates) {
278
+ const list = await gitlabRequest(config, project, `/issues/${Number(task.gitlabIid)}/related_merge_requests`).catch(() => [])
279
+ if (!Array.isArray(list) || list.length === 0) continue
280
+ const mr = list.find(item => item.state === 'opened') ?? list[0]
281
+ if (Number(mr?.iid) > 0) seen.set(task.id, mr)
282
+ }
283
+ let matched = 0
284
+ for (const [taskId, mr] of seen) {
285
+ const task = state.tasks.find(entry => entry.id === taskId)
286
+ if (task === undefined) continue
287
+ const iid = Number(mr.iid) || 0
288
+ const next = {
289
+ iid,
290
+ url: String(mr.web_url ?? ''),
291
+ title: String(mr.title ?? '').slice(0, 200),
292
+ state: String(mr.state ?? ''),
293
+ sourceBranch: String(mr.source_branch ?? ''),
294
+ targetBranch: String(mr.target_branch ?? ''),
295
+ updatedAt: Date.now(),
296
+ }
297
+ const detail = await gitlabMergeRequestDetail(config, project, iid)
298
+ if (detail.pipeline !== null) next.pipeline = detail.pipeline
299
+ const same = (task.gitlabMR?.iid ?? 0) === iid
300
+ && (task.gitlabMR?.state ?? '') === next.state
301
+ && (task.gitlabMR?.pipeline?.id ?? 0) === (next.pipeline?.id ?? 0)
302
+ && (task.gitlabMR?.pipeline?.status ?? '') === (next.pipeline?.status ?? '')
303
+ if (same) continue
304
+ await store.mutate(draft => {
305
+ const target = draft.tasks.find(entry => entry.id === taskId)
306
+ if (target === undefined) return
307
+ target.gitlabMR = { ...(target.gitlabMR ?? { iid: 0, state: '' }), ...next }
308
+ target.updatedAt = Date.now()
309
+ // MR 里改了哪些文件 = 研发自报之外的真实证据;研发没登记就自动补上。
310
+ if (detail.changes !== null && ((target.coding?.files ?? []).length === 0)) {
311
+ target.coding = mergeCoding(target.coding ?? null, {
312
+ branch: next.sourceBranch,
313
+ files: detail.changes.files,
314
+ commits: Array.from({ length: Math.min(detail.changes.commits, 20) }, (_, index) => ({
315
+ id: `g${index + 1}`,
316
+ sha: '',
317
+ message: `MR !${iid} 的提交 ${index + 1}`,
318
+ at: 0,
319
+ files: 0,
320
+ additions: 0,
321
+ deletions: 0,
322
+ })),
323
+ })
324
+ }
325
+ if (next.state === 'opened' && target.status === 'dev') {
326
+ const from = target.status
327
+ target.status = 'review'
328
+ target.role = 'architect'
329
+ target.expert = ''
330
+ target.history = [...(target.history ?? []), { at: Date.now(), by: 'gitlab', status: 'review', note: `MR !${iid} 已提交,转待评审` }]
331
+ target.comments = [...(target.comments ?? []), {
332
+ id: `${Date.now()}-${randomBytes(3).toString('hex')}`,
333
+ at: Date.now(),
334
+ author: 'GitLab',
335
+ kind: 'agent',
336
+ body: `MR !${iid} 关联到这张卡:${next.title}\n${next.url}\n\n开发阶段结束,已自动推进到「待评审」(原列:${from})。${next.pipeline === undefined ? '' : `\n流水线:${next.pipeline.status}`}`,
337
+ }]
338
+ } else if (next.state === 'merged' && (target.status === 'dev' || target.status === 'review')) {
339
+ const from = target.status
340
+ target.status = 'test'
341
+ target.role = 'qa'
342
+ target.expert = ''
343
+ target.history = [...(target.history ?? []), { at: Date.now(), by: 'gitlab', status: 'test', note: `MR !${iid} 已合并,转待测试` }]
344
+ target.comments = [...(target.comments ?? []), {
345
+ id: `${Date.now()}-${randomBytes(3).toString('hex')}`,
346
+ at: Date.now(),
347
+ author: 'GitLab',
348
+ kind: 'agent',
349
+ body: `MR !${iid} 已合并(原列:${from}),自动推进到「待测试」:现在应该在测试环境跑用例,通过后再收口。`,
350
+ }]
351
+ }
352
+ if (next.pipeline?.status === 'failed') {
353
+ target.comments = [...(target.comments ?? []), {
354
+ id: `${Date.now()}-${randomBytes(3).toString('hex')}`,
355
+ at: Date.now(),
356
+ author: 'GitLab',
357
+ kind: 'agent',
358
+ body: `流水线失败:pipeline #${next.pipeline.id}(${next.pipeline.ref})\n${next.pipeline.url}`,
359
+ }]
360
+ }
361
+ })
362
+ matched += 1
363
+ }
364
+ return { matched }
365
+ }
366
+
367
+ export async function gitlabSync(store, projectId) {
368
+ const config = store.snapshot().config
369
+ const projects = (config.gitlab.projects ?? []).filter(project => projectId === undefined || project.id === projectId)
370
+ const ready = projects.filter(project => projectReady(config, project))
371
+ if (ready.length === 0) throw new Error('GitLab 未配置:请先在设置里添加至少一个项目(地址 / 项目路径 / 访问令牌)')
372
+ const summary = []
373
+ for (const project of ready) {
374
+ const params = new URLSearchParams({ state: 'opened', per_page: '50', order_by: 'updated_at' })
375
+ if (project.syncLabel.length > 0) params.set('labels', project.syncLabel)
376
+ try {
377
+ const issues = await gitlabRequest(config, project, `/issues?${params.toString()}`)
378
+ const created = []
379
+ await store.mutate(state => {
380
+ const known = new Set(
381
+ state.tasks
382
+ .filter(task => String(task.gitlabProject ?? '') === project.id)
383
+ .map(task => Number(task.gitlabIid))
384
+ .filter(Number.isFinite),
385
+ )
386
+ for (const issue of issues) {
387
+ if (known.has(Number(issue.iid))) continue
388
+ const now = Date.now()
389
+ const labels = Array.isArray(issue.labels) ? issue.labels.map(String) : []
390
+ // 带 bug 标签的 Issue 直接建成缺陷卡,P0~P3 标签顺便当严重级别。
391
+ const isBug = labels.some(label => /^(bug|defect|缺陷|故障)$/i.test(label.trim()))
392
+ const severityLabel = labels.map(label => label.trim().toLowerCase()).find(label => /^p[0-3]$/.test(label))
393
+ const entryStatus = isBug ? 'dev_ready' : 'clarify'
394
+ // 从 GitLab 拉进来的卡也照模板铺验收清单,不然它一进流程就是个没有「做完」定义的空卡。
395
+ const template = state.config.templates[isBug ? 'bug' : 'requirement'] ?? []
396
+ state.tasks.unshift({
397
+ id: host.newTaskId(),
398
+ title: `#${issue.iid} ${issue.title}`.slice(0, 200),
399
+ type: isBug ? 'bug' : 'requirement',
400
+ status: entryStatus,
401
+ role: isBug ? 'dev' : 'product',
402
+ severity: isBug ? (severityLabel ?? 'p2') : '',
403
+ expert: isBug ? 'dev' : 'product',
404
+ priority: isBug && severityLabel === 'p0' ? 'urgent' : 'normal',
405
+ requirement: String(issue.description ?? '').slice(0, 20000),
406
+ gitlabIssue: String(issue.web_url ?? ''),
407
+ gitlabIid: Number(issue.iid),
408
+ gitlabProject: project.id,
409
+ gitlabMR: { iid: 0, url: '', title: '', state: '' },
410
+ feishuThread: '',
411
+ owner: '',
412
+ project: '',
413
+ iteration: '',
414
+ parent: '',
415
+ dueAt: 0,
416
+ blocked: null,
417
+ blockedReason: '',
418
+ artifacts: [],
419
+ checklist: template.map((text, index) => ({ id: `c${index + 1}`, text, done: false, by: '', at: 0, note: '' })),
420
+ cases: [],
421
+ tags: labels.slice(0, 12),
422
+ createdAt: now,
423
+ updatedAt: now,
424
+ comments: [],
425
+ history: [{ at: now, by: 'gitlab', status: entryStatus }],
426
+ })
427
+ created.push(project.id)
428
+ }
429
+ const record = state.config.gitlab.projects.find(entry => entry.id === project.id)
430
+ if (record !== undefined) {
431
+ record.lastSyncAt = Date.now()
432
+ record.lastSyncError = ''
433
+ }
434
+ })
435
+ summary.push({ project: project.id, name: project.name, fetched: issues.length, created: created.length })
436
+ const mrs = await gitlabSyncMergeRequests(store, config, project).catch(error => ({ matched: 0, error: String(error?.message ?? error).slice(0, 200) }))
437
+ if (mrs.matched > 0 || mrs.error !== undefined) summary.push({ project: project.id, mergeRequests: mrs })
438
+ } catch (error) {
439
+ const message = String(error?.message ?? error).slice(0, 300)
440
+ await store.mutate(state => {
441
+ const record = state.config.gitlab.projects.find(entry => entry.id === project.id)
442
+ if (record !== undefined) record.lastSyncError = message
443
+ }).catch(() => {})
444
+ summary.push({ project: project.id, name: project.name, error: message })
445
+ }
446
+ }
447
+ const firstError = summary.find(entry => entry.error !== undefined)
448
+ INTEGRATION.lastSyncError = firstError?.error ?? ''
449
+ return { projects: summary }
450
+ }
451
+
452
+ /** Post one text message through one Feishu custom-bot webhook. */
453
+ /** 通知正文:字符串直接用,对象取 text/content。以前两种写法混用,导致群里收到「[object Object]」。 */
454
+ export function feishuText(body) {
455
+ if (typeof body === 'string') return body.slice(0, 4000)
456
+ const value = body !== null && typeof body === 'object' ? body.text ?? body.content ?? '' : ''
457
+ return String(value).slice(0, 4000)
458
+ }
459
+
460
+ export async function feishuPush(webhook, body) {
461
+ if (typeof webhook !== 'string' || webhook.length === 0) {
462
+ markHealth('feishu', 'skip')
463
+ return { ok: false, skipped: true }
464
+ }
465
+ const text = feishuText(body)
466
+ if (text.trim() === '') return { ok: false, skipped: true, error: '通知正文是空的' }
467
+ let lastError = null
468
+ // 群里掉一条消息没人会发现,所以发两次;仍失败才算真的失败。
469
+ for (let attempt = 0; attempt < 2; attempt += 1) {
470
+ try {
471
+ const response = await fetch(webhook, {
472
+ method: 'POST',
473
+ headers: { 'content-type': 'application/json' },
474
+ body: JSON.stringify({ msg_type: 'text', content: { text } }),
475
+ })
476
+ const payload = (await response.text()).slice(0, 300)
477
+ if (!response.ok) throw new Error(`飞书 ${response.status}: ${payload}`)
478
+ // 飞书 webhook 失败时也可能回 200,真正的结果在 body 里。
479
+ try {
480
+ const parsed = JSON.parse(payload)
481
+ const code = Number(parsed?.code ?? parsed?.StatusCode ?? 0)
482
+ if (code !== 0) throw new Error(`飞书返回 code=${code} ${String(parsed?.msg ?? '').slice(0, 120)}`)
483
+ } catch (error) {
484
+ if (error instanceof SyntaxError) {
485
+ /* 不是 JSON,就按 HTTP 状态算成功 */
486
+ } else {
487
+ throw error
488
+ }
489
+ }
490
+ markHealth('feishu', 'ok')
491
+ return { ok: true, payload }
492
+ } catch (error) {
493
+ lastError = error
494
+ if (attempt === 0) await new Promise(resolve => setTimeout(resolve, 400))
495
+ }
496
+ }
497
+ markHealth('feishu', 'fail', lastError?.message ?? String(lastError))
498
+ throw lastError ?? new Error('飞书推送失败')
499
+ }
500
+
501
+ /** 每个群想收哪些事件由 bot.events 决定:change / sla / bug / release。 */
502
+ export function botWants(bot, kind) {
503
+ if (bot === null || typeof bot !== 'object') return false
504
+ if (bot.enabled === false) return false
505
+ if (String(bot.webhook ?? '') === '') return false
506
+ const events = bot.events !== null && typeof bot.events === 'object' ? bot.events : null
507
+ if (events === null) return kind === 'change' ? bot.notifyOnChange !== false : true
508
+ if (kind === 'change' && bot.notifyOnChange === false) return false
509
+ return events[kind] !== false
510
+ }
511
+
512
+ /** Send one message to every enabled bot that subscribes to this event kind. */
513
+ export async function feishuBroadcast(config, body, kind = 'change') {
514
+ const bots = (config.feishu.bots ?? []).filter(bot => botWants(bot, kind))
515
+ const results = []
516
+ for (const bot of bots) {
517
+ try {
518
+ const result = await feishuPush(bot.webhook, body)
519
+ results.push({ bot: bot.id, ok: result.ok })
520
+ } catch (error) {
521
+ const message = String(error?.message ?? error).slice(0, 300)
522
+ INTEGRATION.lastPushError = message
523
+ results.push({ bot: bot.id, ok: false, error: message })
524
+ }
525
+ }
526
+ return results
527
+ }
528
+
529
+ /** The repository a card came from, when it came from one. */
530
+ export function projectOf(config, task) {
531
+ const id = String(task.gitlabProject ?? '')
532
+ if (id.length > 0) {
533
+ const found = (config.gitlab.projects ?? []).find(project => project.id === id)
534
+ if (found !== undefined) return found
535
+ }
536
+ // 卡上没记来源仓时,用「服务 → 关联仓库」兜一次(一个项目多个服务时按服务走)。
537
+ const service = host.serviceOf(config, task)
538
+ if (service !== undefined && String(service.repoId ?? '').length > 0) {
539
+ return (config.gitlab.projects ?? []).find(project => project.id === service.repoId)
540
+ }
541
+ return undefined
542
+ }
543
+
544
+ /**
545
+ * Fan one board change out to the outside world: a message to every configured
546
+ * Feishu group and, for a card that came from GitLab, a note on its issue. Both
547
+ * are best effort — a failure is recorded instead of failing the board write.
548
+ */
549
+ export function broadcastChange(store, task, from, to, by, kind) {
550
+ const config = store.snapshot().config
551
+ INTEGRATION.lastAt = Date.now()
552
+ const fromLabel = STATUSES.find(entry => entry.id === from)?.label ?? from
553
+ const toLabel = STATUSES.find(entry => entry.id === to)?.label ?? to
554
+ const headline = kind === 'blocked'
555
+ ? `【阻塞】${task.title}\n停在「${toLabel}」,原因:${task.blocked?.reason || '未填写'}\n操作人:${by}`
556
+ : kind === 'unblocked'
557
+ ? `【解除阻塞】${task.title}\n继续在「${toLabel}」\n操作人:${by}`
558
+ : `【需求&任务工作空间】${task.title}\n${fromLabel} → ${toLabel}\n操作人:${by}`
559
+ const line = kind === 'blocked' || kind === 'unblocked' ? headline : headline
560
+ feishuBroadcast(config, line).catch(error => {
561
+ INTEGRATION.lastPushError = String(error?.message ?? error).slice(0, 300)
562
+ })
563
+ const project = projectOf(config, task)
564
+ if (project !== undefined && projectReady(config, project) && Number.isFinite(Number(task.gitlabIid))) {
565
+ const note = kind === 'blocked'
566
+ ? `阻塞:停在「${toLabel}」,原因:${task.blocked?.reason || '未填写'}(${by})`
567
+ : kind === 'unblocked'
568
+ ? `解除阻塞:继续在「${toLabel}」(${by})`
569
+ : `需求&任务工作空间:${fromLabel} → ${toLabel}(${by})`
570
+ gitlabRequest(config, project, `/issues/${Number(task.gitlabIid)}/notes`, {
571
+ method: 'POST',
572
+ body: JSON.stringify({ body: note }),
573
+ }).catch(error => { INTEGRATION.lastPushError = String(error?.message ?? error).slice(0, 300) })
574
+ }
575
+ // 有 MR 的卡,状态变化也写回 MR 讨论区——评审的人在 MR 里就能看到卡走到哪了。
576
+ if (project !== undefined && projectReady(config, project) && Number(task.gitlabMR?.iid ?? 0) > 0) {
577
+ const mrNote = kind === 'blocked'
578
+ ? `看板:这张 MR 对应的卡被标阻塞,停在「${toLabel}」,原因:${task.blocked?.reason || '未填写'}(${by})`
579
+ : kind === 'unblocked'
580
+ ? `看板:阻塞已解除,继续在「${toLabel}」(${by})`
581
+ : `看板:${fromLabel} → ${toLabel}(${by})`
582
+ gitlabRequest(config, project, `/merge_requests/${Number(task.gitlabMR.iid)}/notes`, {
583
+ method: 'POST',
584
+ body: JSON.stringify({ body: mrNote }),
585
+ }).catch(error => { INTEGRATION.lastPushError = String(error?.message ?? error).slice(0, 300) })
586
+ }
587
+ }