@marsken/dsh-studio 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.
Files changed (42) hide show
  1. package/AGENTS.md +148 -0
  2. package/LICENSE +202 -0
  3. package/README.md +1367 -3
  4. package/client.js +952 -0
  5. package/client.src.js +2466 -0
  6. package/cordis.patch.yml +9 -0
  7. package/dist/client.js +952 -0
  8. package/docs/compatibility.md +101 -0
  9. package/docs/images/board.png +0 -0
  10. package/docs/images/cases.png +0 -0
  11. package/docs/images/quality.png +0 -0
  12. package/docs/publish.md +123 -0
  13. package/docs/quickstart.md +53 -0
  14. package/icon.svg +6 -0
  15. package/index.js +2674 -0
  16. package/locale/en.json +11 -0
  17. package/locale/zh.json +11 -0
  18. package/package.json +64 -4
  19. package/scripts/smoke-store.mjs +76 -0
  20. package/src/host/cases.js +135 -0
  21. package/src/host/design.js +87 -0
  22. package/src/host/dispatcher.js +936 -0
  23. package/src/host/domain.js +1966 -0
  24. package/src/host/executor.js +1396 -0
  25. package/src/host/gitlab-facts.js +22 -0
  26. package/src/host/impact.js +32 -0
  27. package/src/host/integrations.js +587 -0
  28. package/src/host/quality.js +106 -0
  29. package/src/host/routes/admin.js +100 -0
  30. package/src/host/routes/agents.js +71 -0
  31. package/src/host/routes/auth.js +155 -0
  32. package/src/host/routes/coding.js +203 -0
  33. package/src/host/routes/connect.js +166 -0
  34. package/src/host/routes/exports.js +122 -0
  35. package/src/host/routes/insights.js +97 -0
  36. package/src/host/routes/releases.js +335 -0
  37. package/src/host/routes/requirements.js +430 -0
  38. package/src/host/routes/skills.js +75 -0
  39. package/src/host/routes/tasks.js +573 -0
  40. package/src/host/store.js +522 -0
  41. package/src/host/team-memory.js +46 -0
  42. package/src/host/tools.js +1211 -0
@@ -0,0 +1,166 @@
1
+ import { INTEGRATION, feishuPush, gitlabReady, gitlabRequest, gitlabSync, healthReport, projectReady } from '../integrations.js'
2
+ import { cleanId } from '../domain.js'
3
+ const INJECTED = ["applyConfig","installedPackages","publicState","readJsonBody","resolvePackageName","runPluginCli","sendJson","getSyncTimer","upsertById"]
4
+ let host = {
5
+ applyConfig: () => {},
6
+ installedPackages: () => {},
7
+ publicState: () => {},
8
+ readJsonBody: () => {},
9
+ resolvePackageName: () => {},
10
+ runPluginCli: () => {},
11
+ sendJson: () => {},
12
+ getSyncTimer: () => undefined,
13
+ upsertById: () => {},
14
+ }
15
+ /** 市场、集成健康、GitLab 同步、仓库与飞书机器人。宿主能力由 configureConnectRoutes() 注入。 */
16
+ export function configureConnectRoutes(services) {
17
+ host = { ...host, ...(services ?? {}) }
18
+ }
19
+
20
+ export async function handleConnectRoute({ method, route, req, res, store, url }) {
21
+ if (method === 'GET' && route === '/market') {
22
+ host.sendJson(res, 200, {
23
+ ok: true,
24
+ title: store.snapshot().config.market.title,
25
+ items: store.snapshot().config.market.items,
26
+ installed: await host.installedPackages(),
27
+ })
28
+ return true
29
+ }
30
+ if (method === 'POST' && route === '/market/install') {
31
+ const body = await host.readJsonBody(req)
32
+ const item = store.snapshot().config.market.items.find(entry => entry.id === String(body.id ?? ''))
33
+ if (item === undefined) throw new Error('unknown market item')
34
+ const result = await host.runPluginCli(['add', item.spec])
35
+ host.sendJson(res, 200, { ok: result.ok, result, installed: await host.installedPackages(), restartRequired: result.ok })
36
+ return true
37
+ }
38
+ if (method === 'POST' && route === '/market/uninstall') {
39
+ const body = await host.readJsonBody(req)
40
+ const item = store.snapshot().config.market.items.find(entry => entry.id === String(body.id ?? ''))
41
+ const requested = String(body.pkg ?? '').trim()
42
+ const pkg = requested.length > 0 ? requested : (item === undefined ? undefined : await host.resolvePackageName(item.spec))
43
+ if (pkg === undefined) throw new Error('unknown market item')
44
+ const result = await host.runPluginCli(['remove', pkg])
45
+ host.sendJson(res, 200, { ok: result.ok, pkg, result, installed: await host.installedPackages(), restartRequired: result.ok })
46
+ return true
47
+ }
48
+ if (method === 'GET' && route === '/integrations') {
49
+ const config = store.snapshot().config
50
+ host.sendJson(res, 200, {
51
+ ok: true,
52
+ gitlab: {
53
+ ready: gitlabReady(config),
54
+ syncing: host.getSyncTimer() !== undefined,
55
+ projects: (config.gitlab.projects ?? []).map(project => ({
56
+ id: project.id,
57
+ ready: projectReady(config, project),
58
+ lastSyncAt: project.lastSyncAt,
59
+ lastSyncError: project.lastSyncError,
60
+ })),
61
+ },
62
+ feishu: {
63
+ ready: (config.feishu.bots ?? []).some(bot => bot.enabled && bot.webhook.length > 0),
64
+ lastError: INTEGRATION.lastPushError,
65
+ },
66
+ health: healthReport(),
67
+ })
68
+ return true
69
+ }
70
+ if (method === 'GET' && route === '/integrations/health') {
71
+ host.sendJson(res, 200, { ok: true, ...healthReport() })
72
+ return true
73
+ }
74
+ if (method === 'POST' && route === '/gitlab/sync') {
75
+ const body = await host.readJsonBody(req).catch(() => ({}))
76
+ const summary = await gitlabSync(store, body.project === undefined ? undefined : String(body.project))
77
+ host.sendJson(res, 200, { ok: true, summary, state: host.publicState(store) })
78
+ return true
79
+ }
80
+ if (method === 'POST' && route === '/repos/save') {
81
+ const body = await host.readJsonBody(req)
82
+ // 老前端发 {project},新脚本发 {repo},两个都认。
83
+ const incoming = body.repo ?? body.project ?? body
84
+ if (String(incoming.name ?? incoming.project ?? '').trim() === '') {
85
+ throw new Error('仓库至少要填一个名字(或者 GitLab 项目路径),否则存下来是一条空记录')
86
+ }
87
+ const existing = store.snapshot().config.gitlab.projects
88
+ const wantedId = cleanId(incoming.id ?? incoming.name ?? incoming.project, 'repo')
89
+ const saved = existing.find(item => item.id === wantedId)
90
+ const merged = {
91
+ ...incoming,
92
+ id: wantedId,
93
+ // An empty token field means "keep the one already stored".
94
+ token: String(incoming.token ?? '').trim() || (saved?.token ?? ''),
95
+ }
96
+ await host.applyConfig(store, { gitlab: { projects: host.upsertById(existing, merged) } })
97
+ // 只回公开视图:token 不能出现在响应里。
98
+ const state = host.publicState(store)
99
+ host.sendJson(res, 200, { ok: true, repos: state.config.gitlab.projects, state })
100
+ return true
101
+ }
102
+ if (method === 'POST' && route === '/repos/delete') {
103
+ const body = await host.readJsonBody(req)
104
+ const id = cleanId(body.id, 'repo')
105
+ await host.applyConfig(store, { gitlab: { projects: store.snapshot().config.gitlab.projects.filter(entry => entry.id !== id) } })
106
+ host.sendJson(res, 200, { ok: true, state: host.publicState(store) })
107
+ return true
108
+ }
109
+ if (method === 'POST' && route === '/repos/test') {
110
+ const body = await host.readJsonBody(req)
111
+ const config = store.snapshot().config
112
+ const project = config.gitlab.projects.find(entry => entry.id === cleanId(body.id, 'repo'))
113
+ if (project === undefined) throw new Error('unknown project')
114
+ if (!projectReady(config, project)) throw new Error('这个仓库还没填全:地址 / 项目路径 / 访问令牌')
115
+ const info = await gitlabRequest(config, project, '/issues?per_page=1')
116
+ host.sendJson(res, 200, { ok: true, reachable: true, sample: Array.isArray(info) ? info.length : 0 })
117
+ return true
118
+ }
119
+ if (method === 'POST' && route === '/bots/save') {
120
+ const body = await host.readJsonBody(req)
121
+ const incoming = body.bot ?? body
122
+ const existing = store.snapshot().config.feishu.bots
123
+ const wantedId = cleanId(incoming.id ?? incoming.name, 'bot')
124
+ const saved = existing.find(item => item.id === wantedId)
125
+ const merged = {
126
+ ...incoming,
127
+ id: wantedId,
128
+ // An empty webhook field means "keep the one already stored".
129
+ webhook: String(incoming.webhook ?? '').trim() || (saved?.webhook ?? ''),
130
+ }
131
+ await host.applyConfig(store, { feishu: { bots: host.upsertById(existing, merged) } })
132
+ const state = host.publicState(store)
133
+ host.sendJson(res, 200, { ok: true, bots: state.config.feishu.bots, state })
134
+ return true
135
+ }
136
+ if (method === 'POST' && route === '/bots/delete') {
137
+ const body = await host.readJsonBody(req)
138
+ const id = cleanId(body.id, 'bot')
139
+ await host.applyConfig(store, { feishu: { bots: store.snapshot().config.feishu.bots.filter(entry => entry.id !== id) } })
140
+ host.sendJson(res, 200, { ok: true, state: host.publicState(store) })
141
+ return true
142
+ }
143
+ if (method === 'POST' && route === '/feishu/test') {
144
+ const body = await host.readJsonBody(req).catch(() => ({}))
145
+ const config = store.snapshot().config
146
+ const wanted = body.id === undefined ? '' : cleanId(body.id, 'bot')
147
+ const bots = (config.feishu.bots ?? []).filter(bot => wanted.length === 0 || bot.id === wanted)
148
+ const results = []
149
+ for (const bot of bots) {
150
+ try {
151
+ const result = await feishuPush(bot.webhook, '【需求&任务工作空间】这是一条测试消息,收到即说明群通知已接通。')
152
+ results.push({ bot: bot.id, name: bot.name, ...result })
153
+ } catch (error) {
154
+ results.push({ bot: bot.id, name: bot.name, ok: false, error: String(error?.message ?? error).slice(0, 200) })
155
+ }
156
+ }
157
+ host.sendJson(res, 200, { ok: results.some(entry => entry.ok === true), results })
158
+ return true
159
+ }
160
+ if (method === 'POST' && route === '/reset') {
161
+ await store.mutate(state => { state.tasks = [] })
162
+ host.sendJson(res, 200, { ok: true, state: host.publicState(store) })
163
+ return true
164
+ }
165
+ return false
166
+ }
@@ -0,0 +1,122 @@
1
+ import { REQ_STATUS_LABEL, STATUSES } from '../domain.js'
2
+
3
+ let host = { sendCsv: () => {}, requirementProgress: () => null, sessions: new Map() }
4
+
5
+ /** 导出类路由(CSV)。宿主能力由 configureExportRoutes() 注入,避免反向 import index.js。 */
6
+ export function configureExportRoutes(services) {
7
+ host = { ...host, ...(services ?? {}) }
8
+ }
9
+
10
+ export function handleExportRoute({ method, route, url, res, store, sendJson }) {
11
+ if (method === 'GET' && route === '/export/tasks.csv') {
12
+ const snapshot = store.snapshot()
13
+ const rows = [['编号', '标题', '类型', '状态', '负责人', '需求', '项目', '迭代', '版本', '阻塞', '截止', '用例通过', '用例总数', '交付物', '更新时间']]
14
+ for (const task of snapshot.tasks) {
15
+ const requirement = snapshot.requirements.find(item => item.id === task.requirementId)
16
+ const project = snapshot.config.projects.find(item => item.id === task.project)
17
+ const iteration = project?.iterations.find(item => item.id === task.iteration)
18
+ const release = snapshot.releases.find(item => item.id === task.releaseId)
19
+ const cases = task.cases ?? []
20
+ rows.push([
21
+ task.code || task.id,
22
+ task.title,
23
+ task.type,
24
+ STATUSES.find(item => item.id === task.status)?.label ?? task.status,
25
+ task.owner ?? '',
26
+ requirement === undefined ? '' : `${requirement.code} ${requirement.title}`.trim(),
27
+ project?.name ?? '',
28
+ iteration?.name ?? '',
29
+ release?.version ?? '',
30
+ task.blocked === null || task.blocked === undefined ? '' : `是:${String(task.blocked.reason ?? '').slice(0, 60)}`,
31
+ task.dueAt > 0 ? new Date(task.dueAt).toISOString().slice(0, 10) : '',
32
+ cases.filter(item => item.result === 'pass').length,
33
+ cases.length,
34
+ (task.artifacts ?? []).length,
35
+ new Date(task.updatedAt).toISOString().slice(0, 19).replace('T', ' '),
36
+ ])
37
+ }
38
+ host.sendCsv(res, rows, 'tasks.csv')
39
+ return true
40
+ }
41
+ if (method === 'GET' && route === '/export/requirements.csv') {
42
+ const snapshot = store.snapshot()
43
+ const rows = [['需求编号', '标题', '状态', '负责人', '项目', '功能点', '已覆盖', '卡数', '已完成', '用例通过', '用例总数', '交付物', '签字', '更新时间']]
44
+ for (const requirement of snapshot.requirements) {
45
+ const progress = host.requirementProgress(snapshot, requirement)
46
+ rows.push([
47
+ requirement.code,
48
+ requirement.title,
49
+ REQ_STATUS_LABEL[requirement.status] ?? requirement.status,
50
+ requirement.owner ?? '',
51
+ snapshot.config.projects.find(item => item.id === requirement.project)?.name ?? '',
52
+ progress.gate.stats.points,
53
+ progress.gate.stats.coveredPoints,
54
+ progress.cards.total,
55
+ progress.cards.done,
56
+ progress.gate.stats.pass,
57
+ progress.gate.stats.cases,
58
+ progress.gate.stats.artifacts,
59
+ requirement.signoff === null ? '' : `${requirement.signoff.by} ${new Date(requirement.signoff.at).toISOString().slice(0, 10)}`,
60
+ new Date(requirement.updatedAt).toISOString().slice(0, 19).replace('T', ' '),
61
+ ])
62
+ }
63
+ host.sendCsv(res, rows, 'requirements.csv')
64
+ return true
65
+ }
66
+ if (method === 'GET' && route === '/audit/export.csv') {
67
+ const snapshot = store.snapshot()
68
+ const cfg = snapshot.config
69
+ if (cfg?.ldap?.enabled === true && cfg?.ldap?.requireLogin === true) {
70
+ const token = String(url.searchParams.get('token') ?? '')
71
+ const session = token === '' ? null : host.sessions.get(token)
72
+ if (session === null || (Number(session.expiresAt) || 0) <= Date.now()) {
73
+ sendJson(res, 401, { ok: false, error: '导出审计日志要先登录' })
74
+ return
75
+ }
76
+ }
77
+ const by = String(url.searchParams.get('by') ?? '').trim()
78
+ const keyword = String(url.searchParams.get('q') ?? '').trim().toLowerCase()
79
+ const onlyFailed = url.searchParams.get('failed') === '1'
80
+ const list = (snapshot.audit ?? []).filter(item => (by === '' || item.by === by)
81
+ && (onlyFailed === false || item.ok === false)
82
+ && (keyword === '' || `${item.route} ${item.by} ${item.summary}`.toLowerCase().includes(keyword)))
83
+ const csvRows = [['时间', '操作人', '动作', '详情', '结果']]
84
+ for (const item of list.slice(0, 5000)) {
85
+ csvRows.push([
86
+ new Date(item.at).toISOString().slice(0, 19).replace('T', ' '),
87
+ item.by,
88
+ item.route,
89
+ item.summary,
90
+ item.ok ? '成功' : `失败 ${item.status}`,
91
+ ])
92
+ }
93
+ host.sendCsv(res, csvRows, `audit-${new Date().toISOString().slice(0, 10)}.csv`)
94
+ return true
95
+ }
96
+ if (method === 'GET' && route === '/export/dispatch.csv') {
97
+ const snapshot = store.snapshot()
98
+ const cfg = snapshot.config
99
+ if (cfg?.ldap?.enabled === true && cfg?.ldap?.requireLogin === true) {
100
+ const token = String(url.searchParams.get('token') ?? '')
101
+ const session = token === '' ? null : host.sessions.get(token)
102
+ if (session === null || (Number(session.expiresAt) || 0) <= Date.now()) {
103
+ sendJson(res, 401, { ok: false, error: '导出派单日志要先登录' })
104
+ return
105
+ }
106
+ }
107
+ const keyword = String(url.searchParams.get('q') ?? '').trim().toLowerCase()
108
+ const onlyFailed = url.searchParams.get('failed') === '1'
109
+ const list = (snapshot.agents?.log ?? []).filter(entry => {
110
+ const line = String(entry?.line ?? '')
111
+ if (onlyFailed && !/失败|错误|异常|拦|拒绝/.test(line)) return false
112
+ return keyword === '' || line.toLowerCase().includes(keyword)
113
+ })
114
+ const csvRows = [['时间', '内容']]
115
+ for (const entry of list.slice(0, 2000)) {
116
+ csvRows.push([new Date(Number(entry.at) || 0).toISOString().slice(0, 19).replace('T', ' '), String(entry.line ?? '')])
117
+ }
118
+ host.sendCsv(res, csvRows, `dispatch-${new Date().toISOString().slice(0, 10)}.csv`)
119
+ return true
120
+ }
121
+ return false
122
+ }
@@ -0,0 +1,97 @@
1
+ import {
2
+ buildDashboard,
3
+ } from '../executor.js'
4
+ import { runSummary } from '../dispatcher.js'
5
+ const INJECTED = ['getDispatcher', "buildAnalytics","buildMetrics","burndownReport","sendJson","workloadReport"]
6
+ let host = {
7
+ getDispatcher: () => undefined,
8
+ buildAnalytics: () => {},
9
+ buildMetrics: () => {},
10
+ burndownReport: () => {},
11
+ sendJson: () => {},
12
+ workloadReport: () => {},
13
+ }
14
+ /** 度量与工作台(指标、分析、工作量、燃尽、成本)相关路由。宿主能力由 configureInsightsRoutes() 注入。 */
15
+ import { buildQuality } from '../quality.js'
16
+
17
+ export function configureInsightsRoutes(services) {
18
+ host = { ...host, ...(services ?? {}) }
19
+ }
20
+ export async function handleInsightsRoute({ method, route, req, res, store, url }) {
21
+ if (method === 'GET' && route === '/metrics') {
22
+ host.sendJson(res, 200, { ok: true, metrics: host.buildMetrics(store.snapshot()) })
23
+ return true
24
+ }
25
+ if (method === 'GET' && route === '/analytics') {
26
+ const snapshot = store.snapshot()
27
+ host.sendJson(res, 200, {
28
+ ok: true,
29
+ analytics: host.buildAnalytics(snapshot, {
30
+ days: url.searchParams.get('days') ?? '',
31
+ project: url.searchParams.get('project') ?? '',
32
+ }),
33
+ })
34
+ return true
35
+ }
36
+ if (method === 'GET' && route === '/dashboard') {
37
+ // 最近一次回归(定时或人跑的)也一起给工作台,早上打开就知道昨晚跑成什么样。
38
+ const regressions = await store.readSide('regressions').catch(() => null)
39
+ host.sendJson(res, 200, {
40
+ ok: true,
41
+ dashboard: buildDashboard(store.snapshot()),
42
+ regression: Array.isArray(regressions) && regressions.length > 0 ? regressions[0] : null,
43
+ })
44
+ return true
45
+ }
46
+ if (method === 'GET' && route === '/quality') {
47
+ // AI 质量:派单量 / 一次通过率 / 返工率 / 平均耗时。成本未接入(见 README 口径)。
48
+ host.sendJson(res, 200, { ok: true, quality: buildQuality(store.snapshot(), runSummary()) })
49
+ return true
50
+ }
51
+ if (method === 'GET' && route === '/workload') {
52
+ const dispatcher = host.getDispatcher()
53
+ const running = dispatcher === undefined ? [] : [...dispatcher.running.values()]
54
+ host.sendJson(res, 200, { ok: true, workload: host.workloadReport(store.snapshot(), running) })
55
+ return true
56
+ }
57
+ if (method === 'GET' && route === '/burndown') {
58
+ host.sendJson(res, 200, {
59
+ ok: true,
60
+ burndown: host.burndownReport(store.snapshot(), {
61
+ days: url.searchParams.get('days') ?? '',
62
+ project: url.searchParams.get('project') ?? '',
63
+ }),
64
+ })
65
+ return true
66
+ }
67
+ if (method === 'GET' && route === '/costs') {
68
+ const summary = runSummary()
69
+ const budget = store.snapshot().config.costs
70
+ const runs = summary.total
71
+ const hours = Math.round(((summary.avgMs * summary.finished) / 3600_000) * 10) / 10
72
+ const runRate = budget.budgetRuns > 0 ? Math.min(100, Math.round((runs / budget.budgetRuns) * 100)) : 0
73
+ const hourRate = budget.budgetHours > 0 ? Math.min(100, Math.round((hours / budget.budgetHours) * 100)) : 0
74
+ const level = Math.max(runRate, hourRate) >= 100
75
+ ? 'over'
76
+ : Math.max(runRate, hourRate) >= budget.warnAt ? 'warn' : 'ok'
77
+ host.sendJson(res, 200, {
78
+ ok: true,
79
+ costs: summary,
80
+ budget: {
81
+ ...budget,
82
+ runs,
83
+ hours,
84
+ runRate,
85
+ hourRate,
86
+ level,
87
+ hint: level === 'over'
88
+ ? '已经超出预算:要么调高预算,要么停下来看看是不是有卡在空转'
89
+ : level === 'warn'
90
+ ? `已用到 ${Math.max(runRate, hourRate)}%,超过 ${budget.warnAt}% 会在这里标黄`
91
+ : '预算内',
92
+ },
93
+ })
94
+ return true
95
+ }
96
+ return false
97
+ }