@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,335 @@
1
+ import {
2
+ RELEASE_STATUSES,
3
+ sanitizeRelease,
4
+ } from '../domain.js'
5
+ import { feishuBroadcast } from '../integrations.js'
6
+ const INJECTED = ["publicState","readJsonBody","releaseDeploys","releaseGate","releaseNotes","releaseRequirements","releaseRuleViolations","sendJson"]
7
+ let host = {
8
+ publicState: () => {},
9
+ readJsonBody: () => {},
10
+ releaseDeploys: () => {},
11
+ releaseGate: () => {},
12
+ releaseNotes: () => {},
13
+ releaseRequirements: () => {},
14
+ releaseRuleViolations: () => {},
15
+ sendJson: () => {},
16
+ }
17
+ /** 发布(版本、审批、灰度、部署、回滚)相关路由。宿主能力由 configureReleasesRoutes() 注入。 */
18
+ export function configureReleasesRoutes(services) {
19
+ host = { ...host, ...(services ?? {}) }
20
+ }
21
+ export async function handleReleasesRoute({ method, route, req, res, store, url }) {
22
+ if (method === 'GET' && route === '/releases') {
23
+ const state = store.snapshot()
24
+ const releases = state.releases.map(item => {
25
+ const gate = host.releaseGate(state, item)
26
+ return { ...item, gate, counts: gate.stats }
27
+ })
28
+ host.sendJson(res, 200, { ok: true, releases, statuses: RELEASE_STATUSES })
29
+ return true
30
+ }
31
+ if (method === 'POST' && route === '/release/get') {
32
+ const body = await host.readJsonBody(req)
33
+ const state = store.snapshot()
34
+ const release = state.releases.find(item => item.id === String(body.id ?? ''))
35
+ if (release === undefined) throw new Error('版本不存在')
36
+ const tasks = state.tasks.filter(task => task.releaseId === release.id)
37
+ host.sendJson(res, 200, {
38
+ ok: true,
39
+ release: { ...release, requirements: host.releaseRequirements(state, release), deploys: host.releaseDeploys(state, release) },
40
+ gate: host.releaseGate(state, release),
41
+ tasks,
42
+ })
43
+ return true
44
+ }
45
+ if (method === 'POST' && route === '/release/save') {
46
+ const body = await host.readJsonBody(req)
47
+ const raw = body.release ?? body
48
+ // 部分更新(只带 id + 某几个字段)时,先把已有版本垫在下面,避免「只改 tag」被判成没填版本号。
49
+ const known = raw.id === undefined
50
+ ? undefined
51
+ : store.snapshot().releases.find(item => item.id === String(raw.id))
52
+ const incoming = sanitizeRelease(known === undefined ? raw : { ...known, ...raw })
53
+ if (incoming === null) throw new Error('版本至少要填一个版本号或名字')
54
+ let saved = null
55
+ await store.mutate(state => {
56
+ const existing = state.releases.find(item => item.id === incoming.id)
57
+ if (existing === undefined) {
58
+ saved = { ...incoming, createdAt: Date.now(), updatedAt: Date.now() }
59
+ state.releases.push(saved)
60
+ return true
61
+ }
62
+ // 已发布/已回滚的版本不允许改版本号,避免「同一个 tag 两套内容」。
63
+ const locked = existing.status === 'released' || existing.status === 'rolled_back'
64
+ saved = {
65
+ ...existing,
66
+ name: incoming.name,
67
+ project: incoming.project,
68
+ owner: incoming.owner,
69
+ status: locked ? existing.status : incoming.status,
70
+ plannedAt: incoming.plannedAt,
71
+ notes: incoming.notes !== '' ? incoming.notes : existing.notes,
72
+ tagName: incoming.tagName !== '' ? incoming.tagName : existing.tagName,
73
+ commitSha: incoming.commitSha !== '' ? incoming.commitSha : existing.commitSha,
74
+ smoke: incoming.smoke.length > 0 ? incoming.smoke : existing.smoke,
75
+ updatedAt: Date.now(),
76
+ }
77
+ state.releases[state.releases.indexOf(existing)] = saved
78
+ })
79
+ host.sendJson(res, 200, { ok: true, release: saved, state: host.publicState(store) })
80
+ return true
81
+ }
82
+ if (method === 'POST' && route === '/release/delete') {
83
+ const body = await host.readJsonBody(req)
84
+ const id = String(body.id ?? '')
85
+ await store.mutate(state => {
86
+ state.releases = state.releases.filter(item => item.id !== id)
87
+ for (const task of state.tasks) {
88
+ if (task.releaseId === id) {
89
+ task.releaseId = ''
90
+ task.updatedAt = Date.now()
91
+ }
92
+ }
93
+ })
94
+ host.sendJson(res, 200, { ok: true, state: host.publicState(store) })
95
+ return true
96
+ }
97
+ if (method === 'POST' && route === '/release/link') {
98
+ const body = await host.readJsonBody(req)
99
+ const id = String(body.id ?? '')
100
+ const action = String(body.action ?? 'add')
101
+ const taskIds = Array.isArray(body.taskIds)
102
+ ? body.taskIds.map(value => String(value))
103
+ : [String(body.taskId ?? '')]
104
+ await store.mutate(state => {
105
+ if (!state.releases.some(item => item.id === id)) throw new Error('版本不存在')
106
+ for (const task of state.tasks) {
107
+ if (!taskIds.includes(task.id)) continue
108
+ task.releaseId = action === 'remove' ? '' : id
109
+ task.updatedAt = Date.now()
110
+ }
111
+ })
112
+ host.sendJson(res, 200, { ok: true, state: host.publicState(store) })
113
+ return true
114
+ }
115
+ if (method === 'POST' && route === '/release/smoke') {
116
+ const body = await host.readJsonBody(req)
117
+ const id = String(body.id ?? '')
118
+ const action = String(body.action ?? 'add')
119
+ const by = String(body.by ?? '人').slice(0, 60)
120
+ let release = null
121
+ await store.mutate(state => {
122
+ const found = state.releases.find(item => item.id === id)
123
+ if (found === undefined) throw new Error('版本不存在')
124
+ if (action === 'add') {
125
+ const label = String(body.text ?? '').trim().slice(0, 200)
126
+ if (label === '') throw new Error('冒烟项要有内容:写清楚发布前必须验证什么')
127
+ found.smoke.push({ id: `s-${Math.random().toString(36).slice(2, 7)}`, text: label, done: false, by: '', at: 0 })
128
+ } else if (action === 'toggle') {
129
+ const item = found.smoke.find(entry => entry.id === String(body.itemId ?? ''))
130
+ if (item === undefined) throw new Error('冒烟项不存在')
131
+ item.done = body.done === undefined ? item.done !== true : body.done === true
132
+ item.by = item.done ? by : ''
133
+ item.at = item.done ? Date.now() : 0
134
+ } else if (action === 'remove') {
135
+ found.smoke = found.smoke.filter(entry => entry.id !== String(body.itemId ?? ''))
136
+ }
137
+ found.updatedAt = Date.now()
138
+ release = { ...found }
139
+ })
140
+ host.sendJson(res, 200, { ok: true, release, gate: host.releaseGate(store.snapshot(), release), state: host.publicState(store) })
141
+ return true
142
+ }
143
+ if (method === 'POST' && route === '/release/notes') {
144
+ const body = await host.readJsonBody(req)
145
+ const id = String(body.id ?? '')
146
+ let release = null
147
+ await store.mutate(state => {
148
+ const found = state.releases.find(item => item.id === id)
149
+ if (found === undefined) throw new Error('版本不存在')
150
+ found.notes = host.releaseNotes(state, found)
151
+ found.updatedAt = Date.now()
152
+ release = { ...found }
153
+ })
154
+ host.sendJson(res, 200, { ok: true, release, state: host.publicState(store) })
155
+ return true
156
+ }
157
+ if (method === 'POST' && route === '/release/approve') {
158
+ const body = await host.readJsonBody(req)
159
+ const id = String(body.id ?? '')
160
+ const by = String(body.by ?? '人').slice(0, 120)
161
+ let release = null
162
+ let gate = null
163
+ await store.mutate(state => {
164
+ const found = state.releases.find(item => item.id === id)
165
+ if (found === undefined) throw new Error('版本不存在')
166
+ gate = host.releaseGate(state, found)
167
+ if (!gate.ready) throw new Error(`门禁没过:${gate.blockers.join(';')}`)
168
+ found.status = 'approved'
169
+ found.approveBy = by
170
+ found.approveAt = Date.now()
171
+ found.approvals = [...(found.approvals ?? []), {
172
+ id: `ap-${Date.now().toString(36)}`,
173
+ by,
174
+ role: 'release',
175
+ at: Date.now(),
176
+ note: String(body.note ?? '').slice(0, 600),
177
+ }].slice(-20)
178
+ found.deployLog = [...(found.deployLog ?? []), {
179
+ at: Date.now(), by, action: 'approve', note: String(body.note ?? '').slice(0, 600),
180
+ }].slice(-200)
181
+ found.updatedAt = Date.now()
182
+ release = { ...found }
183
+ })
184
+ host.sendJson(res, 200, { ok: true, release, gate, state: host.publicState(store) })
185
+ return true
186
+ }
187
+ if (method === 'POST' && route === '/release/publish') {
188
+ const body = await host.readJsonBody(req)
189
+ const id = String(body.id ?? '')
190
+ const by = String(body.by ?? '人').slice(0, 120)
191
+ let release = null
192
+ let forcedNote = ''
193
+ await store.mutate(state => {
194
+ const found = state.releases.find(item => item.id === id)
195
+ if (found === undefined) throw new Error('版本不存在')
196
+ if (found.status === 'released') throw new Error('这个版本已经发布过了')
197
+ const gate = host.releaseGate(state, found)
198
+ if (!gate.ready) throw new Error(`门禁没过:${gate.blockers.join(';')}`)
199
+ // 发布准入(可配):没满足就走强制发布,但必须写原因,留在部署日志里。
200
+ const violations = host.releaseRuleViolations(state, found)
201
+ const forced = violations.length > 0
202
+ const forceNote = String(body.forceNote ?? '').slice(0, 300)
203
+ if (forced && body.force !== true) throw new Error(`发布准入没过:${violations.join(';')}。确实要发就勾上「强制发布」并写原因`)
204
+ if (forced && forceNote.trim() === '') throw new Error('强制发布必须写清楚原因(会记进部署日志)')
205
+ found.status = 'released'
206
+ found.releasedAt = Date.now()
207
+ found.approveBy = found.approveBy === '' ? by : found.approveBy
208
+ found.approveAt = found.approveAt === 0 ? Date.now() : found.approveAt
209
+ found.notes = found.notes === '' ? host.releaseNotes(state, found) : found.notes
210
+ found.deployLog = [...(found.deployLog ?? []), {
211
+ at: Date.now(),
212
+ by,
213
+ action: 'publish',
214
+ note: `tag ${found.tagName || '—'}${found.commitSha === '' ? '' : ` · ${String(found.commitSha).slice(0, 8)}`}${forced ? ` · 强制发布(${violations.join(';')})原因:${forceNote}` : ''}`,
215
+ }].slice(-200)
216
+ found.updatedAt = Date.now()
217
+ release = { ...found }
218
+ })
219
+ if (release !== null) {
220
+ const count = (release.taskIds ?? []).length
221
+ feishuBroadcast(
222
+ store.snapshot().config,
223
+ `【版本发布】${release.name} ${release.tagName} · ${count} 张卡${forcedNote === '' ? '' : `\n强制发布(${forcedNote})`}`,
224
+ 'release',
225
+ ).catch(() => undefined)
226
+ }
227
+ host.sendJson(res, 200, { ok: true, release, state: host.publicState(store) })
228
+ return true
229
+ }
230
+ if (method === 'POST' && route === '/release/gray') {
231
+ const body = await host.readJsonBody(req)
232
+ const id = String(body.id ?? '')
233
+ const action = String(body.action ?? 'add')
234
+ const by = String(body.by ?? '人').slice(0, 120)
235
+ const note = String(body.note ?? '').slice(0, 600)
236
+ let release = null
237
+ let gate = null
238
+ await store.mutate(state => {
239
+ const found = state.releases.find(item => item.id === id)
240
+ if (found === undefined) throw new Error('版本不存在')
241
+ const batches = found.grayBatches ?? []
242
+ if (action === 'add') {
243
+ if (batches.length >= 20) throw new Error('灰度批次最多 20 批')
244
+ const percent = Math.max(0, Math.min(100, Number(body.percent) || 0))
245
+ if (percent <= 0) throw new Error('灰度比例要大于 0,例如 10')
246
+ batches.push({
247
+ id: `g-${Date.now().toString(36)}`,
248
+ name: String(body.name ?? '').slice(0, 120) || `第 ${batches.length + 1} 批 ${percent}%`,
249
+ percent,
250
+ status: 'pending',
251
+ by,
252
+ at: Date.now(),
253
+ note,
254
+ })
255
+ } else {
256
+ const target = batches.find(item => item.id === String(body.batchId ?? ''))
257
+ if (target === undefined) throw new Error('找不到这一批灰度')
258
+ if (action === 'advance') {
259
+ if (target.status === 'passed') throw new Error('这一批已经走完了')
260
+ target.status = 'passed'
261
+ target.at = Date.now()
262
+ target.note = note || target.note
263
+ } else if (action === 'start') {
264
+ target.status = 'running'
265
+ target.at = Date.now()
266
+ } else if (action === 'rollback') {
267
+ if (note === '') throw new Error('灰度回滚要写原因')
268
+ target.status = 'rolled_back'
269
+ target.at = Date.now()
270
+ target.note = note
271
+ found.status = 'rolled_back'
272
+ found.rollbackReason = `灰度「${target.name}」回滚:${note}`
273
+ } else {
274
+ throw new Error(`unknown gray action: ${action}`)
275
+ }
276
+ target.by = by
277
+ }
278
+ found.grayBatches = batches
279
+ found.deployLog = [...(found.deployLog ?? []), {
280
+ at: Date.now(), by, action: `gray-${action}`, note: note || `${batches.length} 批`,
281
+ }].slice(-200)
282
+ found.updatedAt = Date.now()
283
+ release = { ...found }
284
+ gate = host.releaseGate(state, found)
285
+ })
286
+ host.sendJson(res, 200, { ok: true, release, gate, state: host.publicState(store) })
287
+ return true
288
+ }
289
+ if (method === 'POST' && route === '/release/deploy') {
290
+ const body = await host.readJsonBody(req)
291
+ const id = String(body.id ?? '')
292
+ const envId = String(body.env ?? '')
293
+ const by = String(body.by ?? '人').slice(0, 120)
294
+ const note = String(body.note ?? '').trim().slice(0, 600)
295
+ let release = null
296
+ let deploys = []
297
+ await store.mutate(state => {
298
+ const found = state.releases.find(item => item.id === id)
299
+ if (found === undefined) throw new Error('版本不存在')
300
+ const env = (state.config.environments ?? []).find(item => item.id === envId)
301
+ if (env === undefined) throw new Error('先选一个部署环境(设置 → 环境里可以加)')
302
+ // 只写一条记录,不碰任何部署系统:平台不触发 CD。
303
+ found.deployLog = [...(found.deployLog ?? []), {
304
+ at: Date.now(), by, action: 'deploy', env: env.id, note: note || `登记部署到「${env.name}」`,
305
+ }].slice(-200)
306
+ found.updatedAt = Date.now()
307
+ deploys = host.releaseDeploys(state, found)
308
+ release = { ...found, deploys }
309
+ })
310
+ host.sendJson(res, 200, { ok: true, release, deploys, mode: 'record-only', state: host.publicState(store) })
311
+ return true
312
+ }
313
+ if (method === 'POST' && route === '/release/rollback') {
314
+ const body = await host.readJsonBody(req)
315
+ const id = String(body.id ?? '')
316
+ const reason = String(body.reason ?? '').trim().slice(0, 600)
317
+ if (reason === '') throw new Error('回滚要写清楚原因,之后复盘要用')
318
+ let release = null
319
+ await store.mutate(state => {
320
+ const found = state.releases.find(item => item.id === id)
321
+ if (found === undefined) throw new Error('版本不存在')
322
+ if (found.status !== 'released') throw new Error('只有已发布的版本才能回滚')
323
+ found.status = 'rolled_back'
324
+ found.rollbackReason = reason
325
+ found.deployLog = [...(found.deployLog ?? []), {
326
+ at: Date.now(), by: String(body.by ?? '人').slice(0, 120), action: 'rollback', note: reason,
327
+ }].slice(-200)
328
+ found.updatedAt = Date.now()
329
+ release = { ...found }
330
+ })
331
+ host.sendJson(res, 200, { ok: true, release, state: host.publicState(store) })
332
+ return true
333
+ }
334
+ return false
335
+ }