@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.
- package/AGENTS.md +148 -0
- package/LICENSE +202 -0
- package/README.md +1356 -3
- package/client.js +952 -0
- package/client.src.js +2466 -0
- package/cordis.patch.yml +9 -0
- package/dist/client.js +952 -0
- package/docs/compatibility.md +99 -0
- package/docs/publish.md +86 -0
- package/docs/quickstart.md +53 -0
- package/icon.svg +6 -0
- package/index.js +2674 -0
- package/locale/en.json +11 -0
- package/locale/zh.json +11 -0
- package/package.json +63 -4
- package/scripts/smoke-store.mjs +76 -0
- package/src/host/cases.js +135 -0
- package/src/host/design.js +87 -0
- package/src/host/dispatcher.js +936 -0
- package/src/host/domain.js +1966 -0
- package/src/host/executor.js +1396 -0
- package/src/host/gitlab-facts.js +22 -0
- package/src/host/impact.js +32 -0
- package/src/host/integrations.js +587 -0
- package/src/host/quality.js +106 -0
- package/src/host/routes/admin.js +100 -0
- package/src/host/routes/agents.js +71 -0
- package/src/host/routes/auth.js +155 -0
- package/src/host/routes/coding.js +203 -0
- package/src/host/routes/connect.js +166 -0
- package/src/host/routes/exports.js +122 -0
- package/src/host/routes/insights.js +97 -0
- package/src/host/routes/releases.js +335 -0
- package/src/host/routes/requirements.js +430 -0
- package/src/host/routes/skills.js +75 -0
- package/src/host/routes/tasks.js +573 -0
- package/src/host/store.js +522 -0
- package/src/host/team-memory.js +46 -0
- package/src/host/tools.js +1211 -0
|
@@ -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
|
+
}
|