@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,430 @@
|
|
|
1
|
+
import {
|
|
2
|
+
INTAKE_HTML,
|
|
3
|
+
MINE_HTML,
|
|
4
|
+
REQ_STATUS_LABEL,
|
|
5
|
+
STATUS_HTML,
|
|
6
|
+
cleanId,
|
|
7
|
+
sanitizeRequirementPool,
|
|
8
|
+
text,
|
|
9
|
+
} from '../domain.js'
|
|
10
|
+
import {
|
|
11
|
+
ensureReqFolder,
|
|
12
|
+
mutateReqDoc,
|
|
13
|
+
pullFeishuDoc,
|
|
14
|
+
requirementGate,
|
|
15
|
+
requirementMatrix,
|
|
16
|
+
requirementProgress,
|
|
17
|
+
requirementRollup,
|
|
18
|
+
saveRequirement,
|
|
19
|
+
syncRequirementArtifacts,
|
|
20
|
+
taskExitGate,
|
|
21
|
+
} from '../executor.js'
|
|
22
|
+
import { randomBytes } from 'node:crypto'
|
|
23
|
+
const INJECTED = ["createTask","publicState","readJsonBody","sendJson"]
|
|
24
|
+
let host = {
|
|
25
|
+
createTask: () => {},
|
|
26
|
+
publicState: () => {},
|
|
27
|
+
readJsonBody: () => {},
|
|
28
|
+
sendJson: () => {},
|
|
29
|
+
}
|
|
30
|
+
/** 需求(含交付物、文档、飞书同步)相关路由。宿主能力由 configureRequirementRoutes() 注入。 */
|
|
31
|
+
export function configureRequirementRoutes(services) {
|
|
32
|
+
host = { ...host, ...(services ?? {}) }
|
|
33
|
+
}
|
|
34
|
+
export async function handleRequirementRoute({ method, route, req, res, store, url }) {
|
|
35
|
+
if (method === 'GET' && route === '/requirements') {
|
|
36
|
+
const state = store.snapshot()
|
|
37
|
+
const requirements = state.requirements.map(item => {
|
|
38
|
+
const progress = requirementProgress(state, item)
|
|
39
|
+
return {
|
|
40
|
+
...item,
|
|
41
|
+
counts: {
|
|
42
|
+
tasks: state.tasks.filter(task => task.requirementId === item.id).length,
|
|
43
|
+
done: state.tasks.filter(task => task.requirementId === item.id && task.status === 'done').length,
|
|
44
|
+
bugs: state.tasks.filter(task => task.requirementId === item.id && task.type === 'bug' && task.status !== 'done').length,
|
|
45
|
+
},
|
|
46
|
+
progress,
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
requirements.sort((a, b) => (Number(a.order) || 0) - (Number(b.order) || 0) || String(a.code).localeCompare(String(b.code)))
|
|
50
|
+
host.sendJson(res, 200, { ok: true, requirements })
|
|
51
|
+
return true
|
|
52
|
+
}
|
|
53
|
+
if (method === 'POST' && route === '/requirement/get') {
|
|
54
|
+
const body = await host.readJsonBody(req)
|
|
55
|
+
const rollup = requirementRollup(store.snapshot(), String(body.id ?? ''))
|
|
56
|
+
if (rollup === undefined) throw new Error('需求不存在')
|
|
57
|
+
const matrix = requirementMatrix(store.snapshot(), rollup.requirement)
|
|
58
|
+
const progress = requirementProgress(store.snapshot(), rollup.requirement)
|
|
59
|
+
host.sendJson(res, 200, { ok: true, ...rollup, matrix, progress })
|
|
60
|
+
return true
|
|
61
|
+
}
|
|
62
|
+
if (method === 'POST' && route === '/requirement/matrix') {
|
|
63
|
+
const body = await host.readJsonBody(req)
|
|
64
|
+
const requirement = store.snapshot().requirements.find(item => item.id === String(body.id ?? ''))
|
|
65
|
+
if (requirement === undefined) throw new Error('需求不存在')
|
|
66
|
+
host.sendJson(res, 200, { ok: true, requirement: { id: requirement.id, code: requirement.code, title: requirement.title }, matrix: requirementMatrix(store.snapshot(), requirement) })
|
|
67
|
+
return true
|
|
68
|
+
}
|
|
69
|
+
if (method === 'POST' && route === '/requirement/signoff') {
|
|
70
|
+
const body = await host.readJsonBody(req)
|
|
71
|
+
const id = String(body.id ?? '')
|
|
72
|
+
const action = body.action === 'clear' ? 'clear' : 'sign'
|
|
73
|
+
const note = String(body.note ?? '').trim().slice(0, 600)
|
|
74
|
+
const by = String(body.by ?? req.headers['x-studio-user'] ?? '').trim().slice(0, 60) || '人'
|
|
75
|
+
if (action === 'sign' && note === '') throw new Error('需求验收签字要写一句结论:交付了什么、遗留什么')
|
|
76
|
+
const snapshot = await store.mutate(state => {
|
|
77
|
+
const target = state.requirements.find(item => item.id === id)
|
|
78
|
+
if (target === undefined) throw new Error('需求不存在')
|
|
79
|
+
if (action === 'sign') {
|
|
80
|
+
target.signoff = { by, at: Date.now(), note }
|
|
81
|
+
const open = state.tasks.filter(task => task.requirementId === id && task.status !== 'done').length
|
|
82
|
+
const total = state.tasks.filter(task => task.requirementId === id).length
|
|
83
|
+
// 签完字且卡全完成,需求自动落到「已交付」;否则留在进行中,等卡收口。
|
|
84
|
+
if (open === 0 && total > 0) target.status = 'delivered'
|
|
85
|
+
} else {
|
|
86
|
+
target.signoff = null
|
|
87
|
+
if (target.status === 'delivered') target.status = 'active'
|
|
88
|
+
}
|
|
89
|
+
target.updatedAt = Date.now()
|
|
90
|
+
})
|
|
91
|
+
const requirement = snapshot.requirements.find(item => item.id === id)
|
|
92
|
+
host.sendJson(res, 200, {
|
|
93
|
+
ok: true,
|
|
94
|
+
requirement,
|
|
95
|
+
progress: requirementProgress(snapshot, requirement),
|
|
96
|
+
signoff: requirement.signoff ?? null,
|
|
97
|
+
state: host.publicState(store),
|
|
98
|
+
})
|
|
99
|
+
return true
|
|
100
|
+
}
|
|
101
|
+
if (method === 'POST' && route === '/task/gate') {
|
|
102
|
+
const body = await host.readJsonBody(req)
|
|
103
|
+
const task = store.snapshot().tasks.find(entry => entry.id === String(body.id ?? ''))
|
|
104
|
+
if (task === undefined) throw new Error('任务不存在')
|
|
105
|
+
host.sendJson(res, 200, { ok: true, gate: taskExitGate(store.snapshot(), task), signoff: task.signoff ?? null })
|
|
106
|
+
return true
|
|
107
|
+
}
|
|
108
|
+
if (method === 'POST' && route === '/task/signoff') {
|
|
109
|
+
const body = await host.readJsonBody(req)
|
|
110
|
+
const id = String(body.id ?? '')
|
|
111
|
+
const action = String(body.action ?? 'sign')
|
|
112
|
+
const by = text(body.by, 40) ?? '人'
|
|
113
|
+
const note = text(body.note, 600) ?? ''
|
|
114
|
+
const task = await store.mutate(state => {
|
|
115
|
+
const entry = state.tasks.find(item => item.id === id)
|
|
116
|
+
if (entry === undefined) throw new Error('任务不存在')
|
|
117
|
+
if (action === 'clear') {
|
|
118
|
+
entry.signoff = null
|
|
119
|
+
entry.history.push({ at: Date.now(), by, status: entry.status, note: 'signoff-cleared' })
|
|
120
|
+
} else {
|
|
121
|
+
if (note.length === 0) throw new Error('验收签字要写一句结论:验了什么、遗留什么')
|
|
122
|
+
entry.signoff = { by, at: Date.now(), note }
|
|
123
|
+
entry.history.push({ at: Date.now(), by, status: entry.status, note: `signoff:${note.slice(0, 80)}` })
|
|
124
|
+
}
|
|
125
|
+
entry.updatedAt = Date.now()
|
|
126
|
+
})
|
|
127
|
+
void task
|
|
128
|
+
const signed = store.snapshot().tasks.find(item => item.id === id)
|
|
129
|
+
host.sendJson(res, 200, { ok: true, task: signed, gate: taskExitGate(store.snapshot(), signed), state: host.publicState(store) })
|
|
130
|
+
return true
|
|
131
|
+
}
|
|
132
|
+
if (method === 'POST' && route === '/requirement/save') {
|
|
133
|
+
const body = await host.readJsonBody(req)
|
|
134
|
+
const incoming = body.requirement ?? body
|
|
135
|
+
const beforeRecord = typeof incoming.id === 'string' ? store.snapshot().requirements.find(item => item.id === incoming.id) : undefined
|
|
136
|
+
const watch = ['title', 'description', 'priority', 'status', 'owner', 'project', 'iteration']
|
|
137
|
+
const before = beforeRecord === undefined ? null : Object.fromEntries(watch.map(key => [key, String(beforeRecord[key] ?? '')]))
|
|
138
|
+
const requirement = await saveRequirement(store, incoming)
|
|
139
|
+
if (before !== null) {
|
|
140
|
+
const diffs = watch
|
|
141
|
+
.map(key => ({ field: key, from: before[key], to: String(requirement[key] ?? '') }))
|
|
142
|
+
.filter(item => item.from !== item.to)
|
|
143
|
+
if (diffs.length > 0) {
|
|
144
|
+
const by = String(incoming.by ?? incoming.operator ?? '人').slice(0, 40)
|
|
145
|
+
await store.mutate(state => {
|
|
146
|
+
const target = state.requirements.find(item => item.id === requirement.id)
|
|
147
|
+
if (target === undefined) return
|
|
148
|
+
const clipped = value => value.length > 300 ? `${value.slice(0, 300)}…` : value
|
|
149
|
+
target.history = [...(target.history ?? []), ...diffs.map(item => ({ at: Date.now(), by, field: item.field, from: clipped(item.from), to: clipped(item.to) }))].slice(-100)
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const snapshot = store.snapshot()
|
|
154
|
+
const saved = snapshot.requirements.find(item => item.id === requirement.id) ?? requirement
|
|
155
|
+
host.sendJson(res, 200, { ok: true, requirement, progress: requirementProgress(snapshot, saved), state: host.publicState(store) })
|
|
156
|
+
return true
|
|
157
|
+
}
|
|
158
|
+
if (method === 'POST' && route === '/requirement/review') {
|
|
159
|
+
const body = await host.readJsonBody(req)
|
|
160
|
+
const id = String(body.id ?? '')
|
|
161
|
+
const decision = String(body.decision ?? 'approve')
|
|
162
|
+
if (!['submit', 'approve', 'reject'].includes(decision)) throw new Error('未知的评审动作:submit / approve / reject')
|
|
163
|
+
const by = String(body.by ?? '').trim().slice(0, 60) || '人'
|
|
164
|
+
const note = String(body.note ?? '').slice(0, 600)
|
|
165
|
+
if (decision === 'reject' && note.trim() === '') throw new Error('打回要说清楚哪里不行,写一句评审意见')
|
|
166
|
+
let saved
|
|
167
|
+
await store.mutate(state => {
|
|
168
|
+
const found = state.requirements.find(item => item.id === id)
|
|
169
|
+
if (found === undefined) throw new Error(`no requirement ${id}`)
|
|
170
|
+
const now = Date.now()
|
|
171
|
+
if (decision === 'submit') {
|
|
172
|
+
found.status = 'review'
|
|
173
|
+
found.review = { status: 'pending', by, at: now, note }
|
|
174
|
+
} else if (decision === 'approve') {
|
|
175
|
+
found.status = found.status === 'closed' ? 'closed' : 'active'
|
|
176
|
+
found.review = { status: 'approved', by, at: now, note }
|
|
177
|
+
} else {
|
|
178
|
+
found.status = 'draft'
|
|
179
|
+
found.review = { status: 'changes_requested', by, at: now, note }
|
|
180
|
+
}
|
|
181
|
+
found.updatedAt = now
|
|
182
|
+
saved = found
|
|
183
|
+
})
|
|
184
|
+
const snapshot = store.snapshot()
|
|
185
|
+
const requirement = snapshot.requirements.find(item => item.id === id) ?? saved
|
|
186
|
+
host.sendJson(res, 200, { ok: true, requirement, review: requirement.review ?? null, progress: requirementProgress(snapshot, requirement), state: host.publicState(store) })
|
|
187
|
+
return true
|
|
188
|
+
}
|
|
189
|
+
if (method === 'POST' && route === '/requirement/split') {
|
|
190
|
+
const body = await host.readJsonBody(req)
|
|
191
|
+
const id = String(body.id ?? '')
|
|
192
|
+
const requested = (Array.isArray(body.pointIds) ? body.pointIds : []).map(item => String(item))
|
|
193
|
+
const column = String(body.status ?? 'clarify')
|
|
194
|
+
const before = store.snapshot()
|
|
195
|
+
const requirement = before.requirements.find(item => item.id === id)
|
|
196
|
+
if (requirement === undefined) throw new Error(`no requirement ${id}`)
|
|
197
|
+
const approved = (requirement.review ?? null)?.status === 'approved'
|
|
198
|
+
const started = ['active', 'delivered', 'closed'].includes(requirement.status)
|
|
199
|
+
if (!approved && !started) throw new Error('需求还没评审通过,先过评审再拆卡')
|
|
200
|
+
const points = (requirement.points ?? []).filter(point => requested.length === 0 || requested.includes(point.id))
|
|
201
|
+
const uncovered = points.filter(point => !before.tasks.some(task => task.requirementId === id && task.pointId === point.id))
|
|
202
|
+
if (uncovered.length === 0) throw new Error('这些功能点都已经有卡了')
|
|
203
|
+
const created = []
|
|
204
|
+
for (const point of uncovered) {
|
|
205
|
+
const task = await host.createTask(store, {
|
|
206
|
+
title: `${point.id} ${point.title}`.slice(0, 200),
|
|
207
|
+
type: 'requirement',
|
|
208
|
+
requirementId: id,
|
|
209
|
+
pointId: point.id,
|
|
210
|
+
project: requirement.project,
|
|
211
|
+
iteration: requirement.iteration,
|
|
212
|
+
priority: requirement.priority,
|
|
213
|
+
status: column,
|
|
214
|
+
requirement: point.description ?? point.title,
|
|
215
|
+
})
|
|
216
|
+
created.push(task)
|
|
217
|
+
}
|
|
218
|
+
host.sendJson(res, 200, { ok: true, created, state: host.publicState(store) })
|
|
219
|
+
return true
|
|
220
|
+
}
|
|
221
|
+
if (method === 'POST' && route === '/requirement/pool') {
|
|
222
|
+
const body = await host.readJsonBody(req)
|
|
223
|
+
const id = cleanId(body.id, 'req')
|
|
224
|
+
const pool = sanitizeRequirementPool(body.pool ?? body)
|
|
225
|
+
let next = null
|
|
226
|
+
await store.mutate(state => {
|
|
227
|
+
const target = state.requirements.find(item => item.id === id)
|
|
228
|
+
if (target !== undefined) { target.pool = pool; target.updatedAt = Date.now(); next = target }
|
|
229
|
+
})
|
|
230
|
+
if (next === null) throw new Error('没有这条需求')
|
|
231
|
+
host.sendJson(res, 200, { ok: true, requirement: next, gate: requirementGate(store.snapshot(), next), state: host.publicState(store) })
|
|
232
|
+
return true
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (method === 'GET' && route === '/intake') {
|
|
236
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
|
|
237
|
+
res.end(INTAKE_HTML)
|
|
238
|
+
return true
|
|
239
|
+
}
|
|
240
|
+
if (method === 'POST' && route === '/intake/requirement') {
|
|
241
|
+
const body = await host.readJsonBody(req)
|
|
242
|
+
const title = String(body.title ?? '').trim().slice(0, 200)
|
|
243
|
+
const description = String(body.description ?? '').trim().slice(0, 20000)
|
|
244
|
+
const requester = String(body.requester ?? '').trim().slice(0, 40)
|
|
245
|
+
if (title.length === 0) throw new Error('请填写需求标题')
|
|
246
|
+
if (requester.length === 0) throw new Error('请填写您的姓名')
|
|
247
|
+
if (description.length < 10) throw new Error('请把需求描述写得具体一些(至少 10 个字)')
|
|
248
|
+
const contactKey = String(body.contact ?? '').trim()
|
|
249
|
+
if (contactKey.length > 0) {
|
|
250
|
+
const since = Date.now() - 10 * 60 * 1000
|
|
251
|
+
const recent = (store.snapshot().requirements ?? []).filter(item => item.source === 'business'
|
|
252
|
+
&& String(item.requester?.contact ?? '').trim() === contactKey
|
|
253
|
+
&& Number(item.requester?.at ?? 0) > since)
|
|
254
|
+
if (recent.length >= 3) throw new Error('同一联系方式 10 分钟内最多提交 3 条,请稍后再试')
|
|
255
|
+
}
|
|
256
|
+
const requirement = await saveRequirement(store, {
|
|
257
|
+
title,
|
|
258
|
+
description,
|
|
259
|
+
status: 'draft',
|
|
260
|
+
priority: String(body.priority ?? 'normal'),
|
|
261
|
+
project: String(body.project ?? ''),
|
|
262
|
+
iteration: String(body.iteration ?? ''),
|
|
263
|
+
links: { prd: String(body.links?.prd ?? body.prd ?? ''), proto: String(body.links?.proto ?? body.proto ?? ''), acceptance: String(body.links?.acceptance ?? body.acceptance ?? '') },
|
|
264
|
+
source: 'business',
|
|
265
|
+
requester: { name: requester, dept: String(body.dept ?? '').slice(0, 40), contact: String(body.contact ?? '').slice(0, 80), at: Date.now() },
|
|
266
|
+
})
|
|
267
|
+
host.sendJson(res, 200, { ok: true, code: requirement.code, id: requirement.id })
|
|
268
|
+
return true
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (method === 'GET' && route === '/intake/mine') {
|
|
272
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
|
|
273
|
+
res.end(MINE_HTML)
|
|
274
|
+
return true
|
|
275
|
+
}
|
|
276
|
+
if (method === 'POST' && route === '/intake/mine/query') {
|
|
277
|
+
const body = await host.readJsonBody(req)
|
|
278
|
+
const contact = String(body.contact ?? '').trim()
|
|
279
|
+
if (contact.length < 4) throw new Error('请填写提交时留的联系方式(至少 4 位)')
|
|
280
|
+
const rows = (store.snapshot().requirements ?? [])
|
|
281
|
+
.filter(item => item.source === 'business' && String(item.requester?.contact ?? '').trim() === contact)
|
|
282
|
+
.sort((left, right) => (Number(right.requester?.at ?? 0) - Number(left.requester?.at ?? 0)) || (Number(right.updatedAt ?? 0) - Number(left.updatedAt ?? 0)))
|
|
283
|
+
.slice(0, 50)
|
|
284
|
+
.map(item => {
|
|
285
|
+
const cards = (store.snapshot().tasks ?? []).filter(task => task.requirementId === item.id)
|
|
286
|
+
return {
|
|
287
|
+
code: item.code,
|
|
288
|
+
title: item.title,
|
|
289
|
+
status: item.status,
|
|
290
|
+
statusLabel: REQ_STATUS_LABEL[item.status] ?? item.status,
|
|
291
|
+
updatedAt: item.updatedAt,
|
|
292
|
+
openQuestions: (item.questions ?? []).filter(question => question.status === 'open').length,
|
|
293
|
+
cards: cards.length,
|
|
294
|
+
doneCards: cards.filter(task => task.status === 'done').length,
|
|
295
|
+
}
|
|
296
|
+
})
|
|
297
|
+
host.sendJson(res, 200, { ok: true, rows })
|
|
298
|
+
return true
|
|
299
|
+
}
|
|
300
|
+
if (method === 'GET' && route === '/intake/status') {
|
|
301
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
|
|
302
|
+
res.end(STATUS_HTML)
|
|
303
|
+
return true
|
|
304
|
+
}
|
|
305
|
+
if (method === 'POST' && route === '/intake/query') {
|
|
306
|
+
const body = await host.readJsonBody(req)
|
|
307
|
+
const code = String(body.code ?? '').trim().toUpperCase()
|
|
308
|
+
const tail = String(body.contact ?? '').trim().replace(/\s+/g, '')
|
|
309
|
+
if (code.length === 0 || tail.length < 4) throw new Error('请填写需求编号,以及联系方式的后四位')
|
|
310
|
+
const hit = (store.snapshot().requirements ?? []).find(item => String(item.code ?? '').toUpperCase() === code)
|
|
311
|
+
const contact = String(hit?.requester?.contact ?? '').replace(/\s+/g, '')
|
|
312
|
+
if (hit === undefined || contact.length === 0 || !contact.endsWith(tail)) throw new Error('没查到:编号或联系方式对不上')
|
|
313
|
+
const cards = (store.snapshot().tasks ?? []).filter(item => item.requirementId === hit.id)
|
|
314
|
+
const open = (hit.questions ?? []).filter(item => item.status === 'open').length
|
|
315
|
+
host.sendJson(res, 200, {
|
|
316
|
+
ok: true,
|
|
317
|
+
code: hit.code,
|
|
318
|
+
title: hit.title,
|
|
319
|
+
status: hit.status,
|
|
320
|
+
statusLabel: REQ_STATUS_LABEL[hit.status] ?? hit.status,
|
|
321
|
+
reviewStatus: hit.review?.status ?? '',
|
|
322
|
+
updatedAt: hit.updatedAt,
|
|
323
|
+
openQuestions: open,
|
|
324
|
+
cards: cards.length,
|
|
325
|
+
doneCards: cards.filter(item => item.status === 'done').length,
|
|
326
|
+
})
|
|
327
|
+
return true
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (method === 'POST' && route === '/requirement/request-prd') {
|
|
331
|
+
const body = await host.readJsonBody(req)
|
|
332
|
+
const id = String(body.id ?? '').trim()
|
|
333
|
+
const by = String(body.by ?? body.operator ?? '').trim() || '人'
|
|
334
|
+
if (id.length === 0) throw new Error('缺少需求 id')
|
|
335
|
+
const now = Date.now()
|
|
336
|
+
await store.mutate(state => {
|
|
337
|
+
const item = state.requirements.find(entry => entry.id === id)
|
|
338
|
+
if (item === undefined) throw new Error('没有这条需求')
|
|
339
|
+
item.review = { status: 'pending', by, at: now, note: String(body.note ?? '').trim().slice(0, 600) || '业务提单:等产品补 PRD' }
|
|
340
|
+
if (item.status === 'draft') item.status = 'review'
|
|
341
|
+
item.history = [...(item.history ?? []), { at: now, by, field: 'review', from: '', to: '申请产品补 PRD(待评审)' }].slice(-100)
|
|
342
|
+
item.updatedAt = now
|
|
343
|
+
})
|
|
344
|
+
const fresh = store.snapshot()
|
|
345
|
+
const requirement = (fresh.requirements ?? []).find(entry => entry.id === id)
|
|
346
|
+
host.sendJson(res, 200, { ok: true, requirement, progress: requirementProgress(fresh, requirement), state: host.publicState(store) })
|
|
347
|
+
return true
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (method === 'POST' && route === '/requirement/questions') {
|
|
351
|
+
const body = await host.readJsonBody(req)
|
|
352
|
+
const id = cleanId(body.id, 'req')
|
|
353
|
+
const action = String(body.action ?? 'add')
|
|
354
|
+
const requirement = store.snapshot().requirements.find(item => item.id === id)
|
|
355
|
+
if (requirement === undefined) throw new Error('没有这条需求')
|
|
356
|
+
let questions = Array.isArray(requirement.questions) ? requirement.questions.slice() : []
|
|
357
|
+
const now = Date.now()
|
|
358
|
+
if (action === 'add') {
|
|
359
|
+
const text = String(body.text ?? '').trim().slice(0, 300)
|
|
360
|
+
if (text.length === 0) throw new Error('问题不能为空')
|
|
361
|
+
questions.push({ id: `q-${randomBytes(3).toString('hex')}`, text, status: 'open', owner: String(body.owner ?? '').trim().slice(0, 40), answer: '', at: now, answeredAt: 0 })
|
|
362
|
+
} else if (action === 'answer') {
|
|
363
|
+
const target = questions.find(item => item.id === String(body.questionId ?? ''))
|
|
364
|
+
if (target === undefined) throw new Error('没有这个问题')
|
|
365
|
+
const answer = String(body.answer ?? '').trim().slice(0, 1000)
|
|
366
|
+
if (answer.length === 0) throw new Error('回答不能为空')
|
|
367
|
+
target.answer = answer
|
|
368
|
+
target.status = String(body.status ?? 'answered') === 'open' ? 'open' : 'answered'
|
|
369
|
+
target.answeredAt = target.status === 'answered' ? now : 0
|
|
370
|
+
if (body.by !== undefined) target.answeredBy = String(body.by).slice(0, 40)
|
|
371
|
+
} else if (action === 'delete') {
|
|
372
|
+
questions = questions.filter(item => item.id !== String(body.questionId ?? ''))
|
|
373
|
+
} else {
|
|
374
|
+
throw new Error(`unknown action ${action}`)
|
|
375
|
+
}
|
|
376
|
+
await store.mutate(state => {
|
|
377
|
+
const target = state.requirements.find(item => item.id === id)
|
|
378
|
+
if (target !== undefined) { target.questions = questions; target.updatedAt = now }
|
|
379
|
+
})
|
|
380
|
+
const next = store.snapshot().requirements.find(item => item.id === id)
|
|
381
|
+
host.sendJson(res, 200, { ok: true, requirement: next, gate: requirementGate(store.snapshot(), next), state: host.publicState(store) })
|
|
382
|
+
return true
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (method === 'POST' && route === '/requirement/delete') {
|
|
386
|
+
const body = await host.readJsonBody(req)
|
|
387
|
+
const id = String(body.id ?? '')
|
|
388
|
+
await store.mutate(state => {
|
|
389
|
+
state.requirements = state.requirements.filter(item => item.id !== id)
|
|
390
|
+
// 卡不删,只是脱离需求;人可以看到它们变成「未归属」。
|
|
391
|
+
for (const task of state.tasks) {
|
|
392
|
+
if (task.requirementId === id) {
|
|
393
|
+
task.requirementId = ''
|
|
394
|
+
task.code = ''
|
|
395
|
+
task.updatedAt = Date.now()
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
})
|
|
399
|
+
host.sendJson(res, 200, { ok: true, state: host.publicState(store) })
|
|
400
|
+
return true
|
|
401
|
+
}
|
|
402
|
+
if (method === 'POST' && route === '/requirement/doc') {
|
|
403
|
+
const body = await host.readJsonBody(req)
|
|
404
|
+
const result = await mutateReqDoc(store, body)
|
|
405
|
+
host.sendJson(res, 200, { ok: true, requirement: result, state: host.publicState(store) })
|
|
406
|
+
return true
|
|
407
|
+
}
|
|
408
|
+
if (method === 'POST' && route === '/requirement/pull') {
|
|
409
|
+
const body = await host.readJsonBody(req)
|
|
410
|
+
const result = await pullFeishuDoc(body.url ?? body.docUrl ?? '')
|
|
411
|
+
host.sendJson(res, 200, { ok: true, ...result })
|
|
412
|
+
return true
|
|
413
|
+
}
|
|
414
|
+
if (method === 'POST' && route === '/requirement/folder') {
|
|
415
|
+
const body = await host.readJsonBody(req)
|
|
416
|
+
const id = String(body.id ?? '')
|
|
417
|
+
const folder = await ensureReqFolder(store, id)
|
|
418
|
+
const requirement = store.snapshot().requirements.find(item => item.id === id)
|
|
419
|
+
host.sendJson(res, 200, { ok: String(folder.token ?? '').length > 0, folder, requirement, error: folder.error ?? '', state: host.publicState(store) })
|
|
420
|
+
return true
|
|
421
|
+
}
|
|
422
|
+
if (method === 'POST' && route === '/requirement/sync') {
|
|
423
|
+
const body = await host.readJsonBody(req)
|
|
424
|
+
const id = String(body.id ?? '')
|
|
425
|
+
const report = await syncRequirementArtifacts(store, id)
|
|
426
|
+
host.sendJson(res, 200, { ok: report.failed === 0, report, state: host.publicState(store) })
|
|
427
|
+
return true
|
|
428
|
+
}
|
|
429
|
+
return false
|
|
430
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
const INJECTED = ["isSkillName","readJsonBody","sendJson","skillFromMarkdown","skillFromZip","skillsRoot","getSkills"]
|
|
2
|
+
let host = {
|
|
3
|
+
isSkillName: () => {},
|
|
4
|
+
readJsonBody: () => {},
|
|
5
|
+
sendJson: () => {},
|
|
6
|
+
skillFromMarkdown: () => {},
|
|
7
|
+
skillFromZip: () => {},
|
|
8
|
+
skillsRoot: () => {},
|
|
9
|
+
getSkills: () => undefined,
|
|
10
|
+
}
|
|
11
|
+
/** 技能库的读写与上传路由。宿主能力由 configureSkillsRoutes() 注入。 */
|
|
12
|
+
export function configureSkillsRoutes(services) {
|
|
13
|
+
host = { ...host, ...(services ?? {}) }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function handleSkillsRoute({ method, route, req, res, store, url }) {
|
|
17
|
+
if (route.startsWith('/skills')) {
|
|
18
|
+
if (host.getSkills() === undefined) throw new Error('技能库尚未就绪')
|
|
19
|
+
if (method === 'GET' && route === '/skills') {
|
|
20
|
+
host.sendJson(res, 200, { ok: true, root: host.skillsRoot(), skills: host.getSkills().list(), onDisk: await host.getSkills().visibleRoots() })
|
|
21
|
+
return true
|
|
22
|
+
}
|
|
23
|
+
if (method === 'POST' && route === '/skills/save') {
|
|
24
|
+
const body = await host.readJsonBody(req)
|
|
25
|
+
const { skill, file, warning } = await host.getSkills().save(body.skill ?? body)
|
|
26
|
+
host.sendJson(res, 200, { ok: true, skill, file, warning, skills: host.getSkills().list() })
|
|
27
|
+
return true
|
|
28
|
+
}
|
|
29
|
+
if (method === 'POST' && route === '/skills/delete') {
|
|
30
|
+
const body = await host.readJsonBody(req)
|
|
31
|
+
await host.getSkills().remove(String(body.name ?? ''))
|
|
32
|
+
host.sendJson(res, 200, { ok: true, skills: host.getSkills().list() })
|
|
33
|
+
return true
|
|
34
|
+
}
|
|
35
|
+
if (method === 'POST' && route === '/skills/sync') {
|
|
36
|
+
const { written } = await host.getSkills().syncAll()
|
|
37
|
+
host.sendJson(res, 200, { ok: true, written, skills: host.getSkills().list() })
|
|
38
|
+
return true
|
|
39
|
+
}
|
|
40
|
+
if (method === 'POST' && route === '/skills/upload') {
|
|
41
|
+
const body = await host.readJsonBody(req)
|
|
42
|
+
const fileName = String(body.fileName ?? body.name ?? '').trim()
|
|
43
|
+
const encoded = String(body.dataBase64 ?? '')
|
|
44
|
+
const plain = typeof body.text === 'string' ? body.text : ''
|
|
45
|
+
let text = plain
|
|
46
|
+
let sourceName = fileName
|
|
47
|
+
if (encoded !== '') {
|
|
48
|
+
const buffer = Buffer.from(encoded, 'base64')
|
|
49
|
+
if (buffer.length === 0) throw new Error('上传的文件是空的')
|
|
50
|
+
if (buffer.length > 1_400_000) throw new Error('文件太大:技能包请控制在 1.4MB 以内(正文本身上限 6 万字)')
|
|
51
|
+
if (buffer.subarray(0, 2).toString('latin1') === 'PK') {
|
|
52
|
+
const unzipped = await host.skillFromZip(buffer)
|
|
53
|
+
if (!unzipped.ok) throw new Error(unzipped.error)
|
|
54
|
+
text = unzipped.text
|
|
55
|
+
sourceName = unzipped.fileName
|
|
56
|
+
} else {
|
|
57
|
+
text = buffer.toString('utf8')
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (String(text).trim() === '') throw new Error('没读到内容:请上传 SKILL.md,或包含 SKILL.md 的 .zip')
|
|
61
|
+
const draft = host.skillFromMarkdown(sourceName, text)
|
|
62
|
+
if (!host.isSkillName(draft.name)) {
|
|
63
|
+
throw new Error('从文件里推不出合法的技能名(kebab-case):在 frontmatter 里加一行 name: my-skill 再传')
|
|
64
|
+
}
|
|
65
|
+
const existing = host.getSkills().get(draft.name)
|
|
66
|
+
if (existing !== undefined && body.overwrite !== true) {
|
|
67
|
+
throw new Error(`技能「${draft.name}」已经在库里了:换个文件名,或勾上「覆盖同名技能」再传。`)
|
|
68
|
+
}
|
|
69
|
+
const { skill, file, warning } = await host.getSkills().save(draft)
|
|
70
|
+
host.sendJson(res, 200, { ok: true, skill, file, warning, overwritten: existing !== undefined, skills: host.getSkills().list() })
|
|
71
|
+
return true
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return false
|
|
75
|
+
}
|