dsh-d365-kit 0.34.1 → 0.34.3
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/CHANGELOG.md +318 -308
- package/CHANGELOG.zh-CN.md +317 -307
- package/INSTALL.md +94 -94
- package/INSTALL.zh-CN.md +93 -93
- package/LICENSE +21 -21
- package/README.md +336 -327
- package/README.zh-CN.md +336 -327
- package/client.js +1818 -1818
- package/cordis.patch.yml +44 -44
- package/docs/en/architecture.md +172 -172
- package/docs/en/checks.md +184 -184
- package/docs/en/installation.md +151 -151
- package/docs/en/permissions.md +161 -161
- package/docs/en/scenarios.md +202 -202
- package/docs/en/tools.md +665 -665
- package/docs/en/troubleshooting.md +131 -131
- package/docs/zh/architecture.md +172 -172
- package/docs/zh/checks.md +184 -184
- package/docs/zh/installation.md +151 -151
- package/docs/zh/permissions.md +161 -161
- package/docs/zh/scenarios.md +202 -202
- package/docs/zh/tools.md +665 -665
- package/docs/zh/troubleshooting.md +131 -131
- package/examples/prompts.en.md +206 -206
- package/examples/prompts.zh-CN.md +206 -206
- package/examples/waivers.example.json +20 -20
- package/icon.svg +6 -6
- package/index.js +139 -139
- package/lib/auth.js +181 -181
- package/lib/client.js +496 -496
- package/lib/code-check.js +549 -549
- package/lib/config.js +323 -323
- package/lib/contract-history.js +49 -49
- package/lib/contract.js +583 -583
- package/lib/csharp-check.js +493 -493
- package/lib/data-migrate.js +1318 -1318
- package/lib/data-policy.js +78 -78
- package/lib/error-codes.js +80 -80
- package/lib/gitlab.js +161 -161
- package/lib/health.js +205 -205
- package/lib/migrate.js +732 -732
- package/lib/preflight.js +116 -116
- package/lib/refs.js +191 -191
- package/lib/resources.js +320 -320
- package/lib/retention-defaults.js +17 -17
- package/lib/retention.js +384 -384
- package/lib/runtime.js +232 -232
- package/lib/settings-http.js +512 -512
- package/lib/settings-store.js +371 -371
- package/lib/snapshot.js +149 -149
- package/lib/tool-code-check.js +133 -133
- package/lib/tool-configure.js +169 -169
- package/lib/tool-contract.js +376 -376
- package/lib/tool-data-sync.js +559 -559
- package/lib/tool-data-write.js +203 -203
- package/lib/tool-data.js +282 -282
- package/lib/tool-diff.js +484 -484
- package/lib/tool-environments.js +106 -106
- package/lib/tool-flows.js +540 -540
- package/lib/tool-health.js +127 -127
- package/lib/tool-layers.js +497 -497
- package/lib/tool-metadata.js +417 -417
- package/lib/tool-migrate.js +102 -102
- package/lib/tool-refs.js +206 -206
- package/lib/tool-reports.js +299 -299
- package/lib/tool-sync.js +430 -430
- package/lib/tool-write.js +563 -563
- package/lib/waivers.js +83 -83
- package/lib/write-actions.js +1374 -1374
- package/lib/write-policy.js +199 -199
- package/lib/write-store.js +167 -167
- package/locale/en.json +68 -68
- package/locale/zh.json +68 -68
- package/package.json +2 -2
- package/skills/d365-troubleshooting/SKILL.md +115 -115
package/lib/tool-diff.js
CHANGED
|
@@ -1,484 +1,484 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `d365_diff` — compare two metadata snapshots.
|
|
3
|
-
*
|
|
4
|
-
* Two shapes matter in practice: the same environment against its previous
|
|
5
|
-
* snapshot ("what changed since last week") and one environment against another
|
|
6
|
-
* ("what is in dev but not in sit"). Both are the same operation over two
|
|
7
|
-
* directories, so this tool takes two snapshot references.
|
|
8
|
-
*
|
|
9
|
-
* Honesty about evidence is the whole point: when a resource was truncated or
|
|
10
|
-
* failed to sync on one side, a "removed" row may simply never have been
|
|
11
|
-
* fetched. Those resources are flagged rather than reported as removals.
|
|
12
|
-
*/
|
|
13
|
-
import { loadConfig } from './config.js'
|
|
14
|
-
import { KEY_FIELDS, DIFF_KEY_FIELDS, HIGHLIGHT_FIELDS, rowKey } from './resources.js'
|
|
15
|
-
import { D365Error } from './errors.js'
|
|
16
|
-
import { listSnapshots, readResource, writeDiffReport } from './snapshot.js'
|
|
17
|
-
import { enforceRetention } from './retention.js'
|
|
18
|
-
|
|
19
|
-
/** Display order for resources, most operationally interesting first. */
|
|
20
|
-
const RESOURCE_ORDER = [
|
|
21
|
-
'organization',
|
|
22
|
-
'solutions',
|
|
23
|
-
'entities',
|
|
24
|
-
'attributes',
|
|
25
|
-
'forms',
|
|
26
|
-
'views',
|
|
27
|
-
'workflows',
|
|
28
|
-
'plugin_steps',
|
|
29
|
-
'plugin_assemblies',
|
|
30
|
-
'plugin_types',
|
|
31
|
-
'web_resources',
|
|
32
|
-
'custom_apis',
|
|
33
|
-
'solution_components',
|
|
34
|
-
'sitemaps',
|
|
35
|
-
'option_sets',
|
|
36
|
-
]
|
|
37
|
-
|
|
38
|
-
export const definition = {
|
|
39
|
-
name: 'd365_diff',
|
|
40
|
-
description:
|
|
41
|
-
'比较两份 D365 元数据快照,给出新增 / 删除 / 修改(字段级)。'
|
|
42
|
-
+ '可用于"同一环境与上一份快照对比"或"dev 与 sit 对比"。'
|
|
43
|
-
+ '参数省略时先列出各环境已有快照,便于先 d365_sync 再对比。',
|
|
44
|
-
parameters: {
|
|
45
|
-
type: 'object',
|
|
46
|
-
additionalProperties: false,
|
|
47
|
-
properties: {
|
|
48
|
-
base: {
|
|
49
|
-
type: 'string',
|
|
50
|
-
description: '基准快照:"dev" 表示 dev 最新,"dev@prev" 表示上一份,"dev@20261102T071005Z" 指定某份。省略时取 head 环境的上一份快照。',
|
|
51
|
-
},
|
|
52
|
-
head: {
|
|
53
|
-
type: 'string',
|
|
54
|
-
description: '对照快照,写法同 base。省略时取默认环境的最新快照。',
|
|
55
|
-
},
|
|
56
|
-
resources: {
|
|
57
|
-
type: 'array',
|
|
58
|
-
items: { type: 'string' },
|
|
59
|
-
description: '只比较这些资源,例如 ["entities","attributes"]。默认比较两边都采集过的全部资源。',
|
|
60
|
-
},
|
|
61
|
-
maxDetails: {
|
|
62
|
-
type: 'integer',
|
|
63
|
-
description: '每个资源在回复里最多列多少条变化,默认 30;完整清单写入报告文件。',
|
|
64
|
-
},
|
|
65
|
-
},
|
|
66
|
-
},
|
|
67
|
-
output: {
|
|
68
|
-
schema: {
|
|
69
|
-
type: 'object',
|
|
70
|
-
additionalProperties: false,
|
|
71
|
-
required: ['text'],
|
|
72
|
-
properties: {
|
|
73
|
-
text: { type: 'string' },
|
|
74
|
-
},
|
|
75
|
-
},
|
|
76
|
-
render(_args, value) {
|
|
77
|
-
return [{ type: 'text', text: value?.text ?? '' }]
|
|
78
|
-
},
|
|
79
|
-
},
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Parse `env`, `env@id`, `env@prev` or `env/id`. */
|
|
83
|
-
function parseRef(text) {
|
|
84
|
-
const trimmed = String(text ?? '').trim()
|
|
85
|
-
const match = /^([^@/]+)(?:[@/](.+))?$/.exec(trimmed)
|
|
86
|
-
if (!match) throw new D365Error(`无法解析快照引用:${trimmed}`, { code: 'bad-ref' })
|
|
87
|
-
return { envKey: match[1].trim(), id: match[2]?.trim() }
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** Resolve one reference to `{ env, snapshot }`. */
|
|
91
|
-
async function resolveRef(config, text, role) {
|
|
92
|
-
const { envKey, id } = parseRef(text)
|
|
93
|
-
const env = config.environments[envKey]
|
|
94
|
-
if (!env) {
|
|
95
|
-
throw new D365Error(`${role} 引用了未知环境:${envKey}`, {
|
|
96
|
-
code: 'unknown-environment',
|
|
97
|
-
hint: `可用环境:${Object.keys(config.environments).join(', ')}`,
|
|
98
|
-
})
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const snapshots = await listSnapshots(config, envKey)
|
|
102
|
-
if (!snapshots.length) {
|
|
103
|
-
throw new D365Error(`环境 ${envKey} 还没有任何快照`, {
|
|
104
|
-
code: 'no-snapshot',
|
|
105
|
-
hint: `先运行 d365_sync(environments: ["${envKey}"])。`,
|
|
106
|
-
})
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
if (!id || id === 'latest') return { env, snapshot: snapshots[0] }
|
|
110
|
-
if (id === 'prev') {
|
|
111
|
-
if (snapshots.length < 2) {
|
|
112
|
-
throw new D365Error(`环境 ${envKey} 只有一份快照,没有"上一份"可对比`, {
|
|
113
|
-
code: 'no-previous',
|
|
114
|
-
hint: '再跑一次 d365_sync,或显式指定另一份快照 ID。',
|
|
115
|
-
})
|
|
116
|
-
}
|
|
117
|
-
return { env, snapshot: snapshots[1] }
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const exact = snapshots.find((item) => item.id === id)
|
|
121
|
-
?? snapshots.find((item) => item.id.startsWith(id))
|
|
122
|
-
// A time suffix is how people read these ids, so `@082604` should find
|
|
123
|
-
// `20261102T082604Z`.
|
|
124
|
-
?? snapshots.find((item) => item.id.includes(id))
|
|
125
|
-
if (!exact) {
|
|
126
|
-
throw new D365Error(`找不到快照 ${envKey}@${id}`, {
|
|
127
|
-
code: 'unknown-snapshot',
|
|
128
|
-
hint: `该环境现有:${snapshots.slice(0, 8).map((item) => item.id).join(', ')}`,
|
|
129
|
-
})
|
|
130
|
-
}
|
|
131
|
-
return { env, snapshot: exact }
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** Normalize a cell for comparison. */
|
|
135
|
-
function normalize(value) {
|
|
136
|
-
if (value === undefined || value === null) return ''
|
|
137
|
-
if (typeof value === 'object') return JSON.stringify(value)
|
|
138
|
-
return String(value)
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Fields that differ between any two environments for reasons that are not
|
|
143
|
-
* drift: record GUIDs are minted per organization, metadata ids change on
|
|
144
|
-
* import, and timestamps record when the row was touched. Comparing them turned
|
|
145
|
-
* a real cross-environment report into "66051 web resources modified", which
|
|
146
|
-
* buries the changes that matter.
|
|
147
|
-
*/
|
|
148
|
-
const NOISE_FIELDS = new Set([
|
|
149
|
-
'metadataid',
|
|
150
|
-
'objecttypecode',
|
|
151
|
-
'modifiedon',
|
|
152
|
-
'createdon',
|
|
153
|
-
'installedon',
|
|
154
|
-
'overwritetime',
|
|
155
|
-
'msdyn_overwritetime',
|
|
156
|
-
'modifiedby',
|
|
157
|
-
'createdby',
|
|
158
|
-
'overriddencreatedon',
|
|
159
|
-
'timezoneruleversionnumber',
|
|
160
|
-
'utcconversiontimezonecode',
|
|
161
|
-
'versionnumber',
|
|
162
|
-
'importsequencenumber',
|
|
163
|
-
])
|
|
164
|
-
|
|
165
|
-
function isNoiseField(field) {
|
|
166
|
-
const lower = field.toLowerCase()
|
|
167
|
-
if (NOISE_FIELDS.has(lower)) return true
|
|
168
|
-
// Any `*id` column is a per-organization identifier; the row key already
|
|
169
|
-
// carries identity, so its value never describes a difference in behaviour.
|
|
170
|
-
return lower.endsWith('id')
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/** Fields of a row worth comparing; `@odata` annotations are noise. */
|
|
174
|
-
function comparableFields(row) {
|
|
175
|
-
return Object.keys(row ?? {}).filter(
|
|
176
|
-
(key) => !key.startsWith('@') && !key.endsWith('@odata.etag') && !isNoiseField(key),
|
|
177
|
-
)
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Resources whose rows cannot be aligned across organizations. `solution_components`
|
|
182
|
-
* identifies a component by `objectid`, a GUID that is minted per environment, so
|
|
183
|
-
* keying on it reports every component as added *and* removed. Counting per
|
|
184
|
-
* `componenttype` is the honest question: "does the target have as many entities,
|
|
185
|
-
* attributes, forms ... as the source".
|
|
186
|
-
*/
|
|
187
|
-
const HISTOGRAM_RESOURCES = new Set(['solution_components'])
|
|
188
|
-
|
|
189
|
-
/** Count rows per `componenttype` and diff the counts. */
|
|
190
|
-
function histogramDiff(baseRows, headRows) {
|
|
191
|
-
const tally = (rows) => {
|
|
192
|
-
const counts = new Map()
|
|
193
|
-
for (const row of rows ?? []) {
|
|
194
|
-
const key = String(row.componenttype ?? '?')
|
|
195
|
-
counts.set(key, (counts.get(key) ?? 0) + 1)
|
|
196
|
-
}
|
|
197
|
-
return counts
|
|
198
|
-
}
|
|
199
|
-
const baseCounts = tally(baseRows)
|
|
200
|
-
const headCounts = tally(headRows)
|
|
201
|
-
const changed = []
|
|
202
|
-
for (const key of new Set([...baseCounts.keys(), ...headCounts.keys()])) {
|
|
203
|
-
const from = baseCounts.get(key) ?? 0
|
|
204
|
-
const to = headCounts.get(key) ?? 0
|
|
205
|
-
if (from === to) continue
|
|
206
|
-
changed.push({ key: `componenttype ${key}`, fields: [{ field: '数量', from: String(from), to: String(to) }] })
|
|
207
|
-
}
|
|
208
|
-
changed.sort((a, b) => a.key.localeCompare(b.key))
|
|
209
|
-
return { keyFields: ['componenttype'], added: [], removed: [], changed, histogram: true }
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* Compare one resource's rows.
|
|
214
|
-
* @returns `{ added, removed, changed }` with keys, plus the key fields used.
|
|
215
|
-
*/
|
|
216
|
-
function diffRows(resource, baseRows, headRows) {
|
|
217
|
-
if (HISTOGRAM_RESOURCES.has(resource)) return histogramDiff(baseRows, headRows)
|
|
218
|
-
|
|
219
|
-
const keyFields = DIFF_KEY_FIELDS[resource] ?? KEY_FIELDS[resource]
|
|
220
|
-
const keyOf = (row) => rowKey(resource, row, keyFields)
|
|
221
|
-
|
|
222
|
-
const baseMap = new Map()
|
|
223
|
-
for (const row of baseRows) baseMap.set(keyOf(row), row)
|
|
224
|
-
const headMap = new Map()
|
|
225
|
-
for (const row of headRows) headMap.set(keyOf(row), row)
|
|
226
|
-
|
|
227
|
-
const added = []
|
|
228
|
-
const removed = []
|
|
229
|
-
const changed = []
|
|
230
|
-
|
|
231
|
-
for (const [key, headRow] of headMap) {
|
|
232
|
-
const baseRow = baseMap.get(key)
|
|
233
|
-
if (!baseRow) {
|
|
234
|
-
added.push({ key })
|
|
235
|
-
continue
|
|
236
|
-
}
|
|
237
|
-
const fields = []
|
|
238
|
-
const names = new Set([...comparableFields(baseRow), ...comparableFields(headRow)])
|
|
239
|
-
for (const field of names) {
|
|
240
|
-
const from = normalize(baseRow[field])
|
|
241
|
-
const to = normalize(headRow[field])
|
|
242
|
-
if (from !== to) fields.push({ field, from, to })
|
|
243
|
-
}
|
|
244
|
-
if (fields.length) changed.push({ key, fields })
|
|
245
|
-
}
|
|
246
|
-
for (const key of baseMap.keys()) {
|
|
247
|
-
if (!headMap.has(key)) removed.push({ key })
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
const sortByKey = (a, b) => a.key.localeCompare(b.key)
|
|
251
|
-
return {
|
|
252
|
-
keyFields,
|
|
253
|
-
added: added.sort(sortByKey),
|
|
254
|
-
removed: removed.sort(sortByKey),
|
|
255
|
-
changed: changed.sort(sortByKey),
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/** Order changed fields so the meaningful ones come first. */
|
|
260
|
-
function orderFields(resource, fields) {
|
|
261
|
-
const highlight = HIGHLIGHT_FIELDS[resource] ?? []
|
|
262
|
-
return [...fields].sort((a, b) => {
|
|
263
|
-
const ai = highlight.indexOf(a.field)
|
|
264
|
-
const bi = highlight.indexOf(b.field)
|
|
265
|
-
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi) || a.field.localeCompare(b.field)
|
|
266
|
-
})
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/** A one-line description of one change. */
|
|
270
|
-
function describeChange(resource, change, maxFields = 6) {
|
|
271
|
-
const fields = orderFields(resource, change.fields)
|
|
272
|
-
const shown = fields.slice(0, maxFields).map((item) => `${item.field} ${item.from || '∅'}→${item.to || '∅'}`)
|
|
273
|
-
if (fields.length > maxFields) shown.push(`… 另有 ${fields.length - maxFields} 个字段`)
|
|
274
|
-
return `~ ${change.key}:${shown.join(';')}`
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/** Render the human-facing report, shared by the reply and the markdown file. */
|
|
278
|
-
function renderReport(context) {
|
|
279
|
-
const { base, head, results, skipped, evidence, maxDetails } = context
|
|
280
|
-
const lines = []
|
|
281
|
-
const detailed = new Set()
|
|
282
|
-
|
|
283
|
-
lines.push(`比较:${base.env.key}@${base.snapshot.id} → ${head.env.key}@${head.snapshot.id}`)
|
|
284
|
-
lines.push(
|
|
285
|
-
`基准:${base.env.key}(${base.snapshot.manifest.finishedAt ?? '?'},${base.snapshot.manifest.complete ? '完整' : '部分'})`
|
|
286
|
-
+ `;对照:${head.env.key}(${head.snapshot.manifest.finishedAt ?? '?'},${head.snapshot.manifest.complete ? '完整' : '部分'})`,
|
|
287
|
-
)
|
|
288
|
-
|
|
289
|
-
const staleHours = (manifest) => {
|
|
290
|
-
const at = Date.parse(manifest?.finishedAt ?? '')
|
|
291
|
-
if (!Number.isFinite(at)) return null
|
|
292
|
-
return (Date.now() - at) / 3600000
|
|
293
|
-
}
|
|
294
|
-
const stale = [
|
|
295
|
-
['基准', base.env.key, staleHours(base.snapshot.manifest)],
|
|
296
|
-
['对照', head.env.key, staleHours(head.snapshot.manifest)],
|
|
297
|
-
].filter(([, , hours]) => hours !== null && hours > 24)
|
|
298
|
-
if (stale.length) {
|
|
299
|
-
lines.push('')
|
|
300
|
-
lines.push(`提示:${stale.map(([role, env, hours]) => `${role}快照(${env})已是 ${hours.toFixed(0)} 小时前`).join(',')},元数据可能已经变了;建议先 \`d365_sync\` 再比较。`)
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
const totals = { added: 0, removed: 0, changed: 0 }
|
|
304
|
-
for (const result of results) {
|
|
305
|
-
totals.added += result.diff.added.length
|
|
306
|
-
totals.removed += result.diff.removed.length
|
|
307
|
-
totals.changed += result.diff.changed.length
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
if (!totals.added && !totals.removed && !totals.changed) {
|
|
311
|
-
lines.push('', '两份快照在已采集的资源上没有差异。')
|
|
312
|
-
} else {
|
|
313
|
-
lines.push('')
|
|
314
|
-
lines.push('| 资源 | 新增 | 删除 | 修改 |')
|
|
315
|
-
lines.push('| --- | --- | --- | --- |')
|
|
316
|
-
for (const result of results) {
|
|
317
|
-
const { added, removed, changed } = result.diff
|
|
318
|
-
if (!added.length && !removed.length && !changed.length) continue
|
|
319
|
-
lines.push(`| ${result.resource} | ${added.length} | ${removed.length} | ${changed.length} |`)
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
for (const result of results) {
|
|
324
|
-
const { added, removed, changed } = result.diff
|
|
325
|
-
if (!added.length && !removed.length && !changed.length) continue
|
|
326
|
-
lines.push('')
|
|
327
|
-
const caveat = evidence.get(result.resource)?.caveat
|
|
328
|
-
detailed.add(result.resource)
|
|
329
|
-
lines.push(`### ${result.resource}(新增 ${added.length} / 删除 ${removed.length} / 修改 ${changed.length})`)
|
|
330
|
-
if (caveat) lines.push(`> 注意:${caveat}`)
|
|
331
|
-
|
|
332
|
-
for (const item of added.slice(0, maxDetails)) lines.push(`+ ${item.key}`)
|
|
333
|
-
if (added.length > maxDetails) lines.push(`+ … 其余 ${added.length - maxDetails} 条见报告文件`)
|
|
334
|
-
|
|
335
|
-
for (const item of removed.slice(0, maxDetails)) lines.push(`- ${item.key}`)
|
|
336
|
-
if (removed.length > maxDetails) lines.push(`- … 其余 ${removed.length - maxDetails} 条见报告文件`)
|
|
337
|
-
|
|
338
|
-
for (const item of changed.slice(0, maxDetails)) lines.push(describeChange(result.resource, item))
|
|
339
|
-
if (changed.length > maxDetails) lines.push(`~ … 其余 ${changed.length - maxDetails} 条见报告文件`)
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
if (skipped.length) {
|
|
343
|
-
lines.push('')
|
|
344
|
-
lines.push('未参与比较的资源(有一侧没采集):')
|
|
345
|
-
for (const item of skipped) lines.push(`- ${item.resource}:${item.reason}`)
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
// Resources with no visible diff but incomplete data need their own warning,
|
|
349
|
-
// otherwise a partial snapshot reads as "nothing changed".
|
|
350
|
-
const suspect = [...evidence.values()].filter((item) => item.caveat && !detailed.has(item.resource))
|
|
351
|
-
if (suspect.length) {
|
|
352
|
-
lines.push('')
|
|
353
|
-
lines.push('其余资源数据不完整,未发现差异不代表没有变化:')
|
|
354
|
-
for (const item of suspect) lines.push(`- ${item.resource}:${item.caveat}`)
|
|
355
|
-
}
|
|
356
|
-
return lines
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
/** Explain why a resource's comparison may be misleading. */
|
|
360
|
-
function evidenceFor(resource, baseManifest, headManifest) {
|
|
361
|
-
const baseDetail = baseManifest.resources?.[resource]
|
|
362
|
-
const headDetail = headManifest.resources?.[resource]
|
|
363
|
-
const parts = []
|
|
364
|
-
if (baseDetail?.truncated) parts.push('基准侧被行数上限截断')
|
|
365
|
-
if (headDetail?.truncated) parts.push('对照侧被行数上限截断')
|
|
366
|
-
if (baseDetail?.error) parts.push(`基准侧采集失败(${String(baseDetail.error).slice(0, 80)})`)
|
|
367
|
-
if (headDetail?.error) parts.push(`对照侧采集失败(${String(headDetail.error).slice(0, 80)})`)
|
|
368
|
-
return parts.length ? { resource, caveat: parts.join(';') } : null
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
/** List available snapshots, newest first. */
|
|
372
|
-
async function describeSnapshots(config) {
|
|
373
|
-
const lines = ['当前已有快照(最新在前):']
|
|
374
|
-
let total = 0
|
|
375
|
-
for (const envKey of Object.keys(config.environments)) {
|
|
376
|
-
const snapshots = await listSnapshots(config, envKey)
|
|
377
|
-
if (!snapshots.length) {
|
|
378
|
-
lines.push(`- ${envKey}:无(先运行 d365_sync)`)
|
|
379
|
-
continue
|
|
380
|
-
}
|
|
381
|
-
total += snapshots.length
|
|
382
|
-
for (const snapshot of snapshots.slice(0, 5)) {
|
|
383
|
-
const manifest = snapshot.manifest
|
|
384
|
-
const orgName = manifest.organization?.name ?? '?'
|
|
385
|
-
const state = manifest.complete ? '完整' : '部分'
|
|
386
|
-
const resources = Object.keys(manifest.resources ?? {}).length
|
|
387
|
-
lines.push(`- ${envKey}@${snapshot.id}:${orgName},${state},${resources} 个资源,${manifest.finishedAt ?? ''}`)
|
|
388
|
-
}
|
|
389
|
-
if (snapshots.length > 5) lines.push(`- ${envKey}:其余 ${snapshots.length - 5} 份略`)
|
|
390
|
-
}
|
|
391
|
-
if (total) {
|
|
392
|
-
lines.push('')
|
|
393
|
-
lines.push('对比用法:base="dev@prev" head="dev"(同一环境与上一份),或 base="dev" head="sit"(跨环境)。')
|
|
394
|
-
}
|
|
395
|
-
return lines.join('\n')
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
export function register(ctx, state) {
|
|
399
|
-
return ctx.tools.register({
|
|
400
|
-
...definition,
|
|
401
|
-
async execute(args = {}) {
|
|
402
|
-
const config = await loadConfig(state.rawConfig)
|
|
403
|
-
|
|
404
|
-
if (!args.base && !args.head) {
|
|
405
|
-
return { text: await describeSnapshots(config) }
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
const head = await resolveRef(config, args.head ?? config.defaultEnvironment, 'head')
|
|
409
|
-
const base = await resolveRef(config, args.base ?? `${head.env.key}@prev`, 'base')
|
|
410
|
-
|
|
411
|
-
const baseResources = new Set(Object.keys(base.snapshot.manifest.resources ?? {}))
|
|
412
|
-
const headResources = new Set(Object.keys(head.snapshot.manifest.resources ?? {}))
|
|
413
|
-
const requested = Array.isArray(args.resources) && args.resources.length
|
|
414
|
-
? args.resources
|
|
415
|
-
// `organization` is the environment's own identity: two environments are
|
|
416
|
-
// always "different" there, and that says nothing about drift.
|
|
417
|
-
: RESOURCE_ORDER.filter((resource) => resource !== 'organization'
|
|
418
|
-
&& baseResources.has(resource) && headResources.has(resource))
|
|
419
|
-
|
|
420
|
-
const ordered = [...requested].sort(
|
|
421
|
-
(a, b) => (RESOURCE_ORDER.indexOf(a) === -1 ? 99 : RESOURCE_ORDER.indexOf(a))
|
|
422
|
-
- (RESOURCE_ORDER.indexOf(b) === -1 ? 99 : RESOURCE_ORDER.indexOf(b)),
|
|
423
|
-
)
|
|
424
|
-
|
|
425
|
-
const results = []
|
|
426
|
-
const skipped = []
|
|
427
|
-
const evidence = new Map()
|
|
428
|
-
|
|
429
|
-
for (const resource of ordered) {
|
|
430
|
-
const inBase = baseResources.has(resource)
|
|
431
|
-
const inHead = headResources.has(resource)
|
|
432
|
-
if (!inBase || !inHead) {
|
|
433
|
-
skipped.push({
|
|
434
|
-
resource,
|
|
435
|
-
reason: !inBase && !inHead ? '两侧都未采集' : inBase ? '对照侧未采集' : '基准侧未采集',
|
|
436
|
-
})
|
|
437
|
-
continue
|
|
438
|
-
}
|
|
439
|
-
const basePayload = await readResource(config, base.env.key, base.snapshot.id, resource)
|
|
440
|
-
const headPayload = await readResource(config, head.env.key, head.snapshot.id, resource)
|
|
441
|
-
if (!basePayload || !headPayload) {
|
|
442
|
-
skipped.push({ resource, reason: '快照文件缺失' })
|
|
443
|
-
continue
|
|
444
|
-
}
|
|
445
|
-
const diff = diffRows(resource, basePayload.rows ?? [], headPayload.rows ?? [])
|
|
446
|
-
results.push({ resource, diff })
|
|
447
|
-
const flag = evidenceFor(resource, base.snapshot.manifest, head.snapshot.manifest) ?? {}
|
|
448
|
-
if (diff.histogram) {
|
|
449
|
-
flag.caveat = [flag.caveat, '按 componenttype 计数比较(组件的 objectid 是按组织分配的 GUID,逐行对齐会得到全部新增+全部删除)']
|
|
450
|
-
.filter(Boolean).join(';')
|
|
451
|
-
}
|
|
452
|
-
if (flag.caveat) evidence.set(resource, { resource, caveat: flag.caveat })
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
const maxDetails = Number.isFinite(args.maxDetails) && args.maxDetails > 0
|
|
456
|
-
? Math.min(200, Math.trunc(args.maxDetails))
|
|
457
|
-
: 30
|
|
458
|
-
|
|
459
|
-
const lines = renderReport({ base, head, ordered, results, skipped, evidence, maxDetails })
|
|
460
|
-
|
|
461
|
-
const changedResources = results.filter(
|
|
462
|
-
(result) => result.diff.added.length || result.diff.removed.length || result.diff.changed.length,
|
|
463
|
-
)
|
|
464
|
-
if (changedResources.length || skipped.length) {
|
|
465
|
-
const markdown = renderReport({ base, head, ordered, results, skipped, evidence, maxDetails: 500 }).join('\n')
|
|
466
|
-
const file = await writeDiffReport(
|
|
467
|
-
config,
|
|
468
|
-
`${base.env.key}-${base.snapshot.id}-vs-${head.env.key}-${head.snapshot.id}`,
|
|
469
|
-
`# D365 元数据差异\n\n${markdown}\n`,
|
|
470
|
-
)
|
|
471
|
-
lines.push('', `完整报告:${file}`)
|
|
472
|
-
await enforceRetention(config, { target: 'diffs' })
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
return { text: lines.join('\n') }
|
|
476
|
-
},
|
|
477
|
-
presentCall(args = {}) {
|
|
478
|
-
const title = args.base || args.head
|
|
479
|
-
? `比较快照 ${args.base ?? '(上一份)'} → ${args.head ?? '(最新)'}`
|
|
480
|
-
: '列出已有 D365 快照'
|
|
481
|
-
return { card: 'generic', title, kind: 'other', rawInput: args }
|
|
482
|
-
},
|
|
483
|
-
})
|
|
484
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* `d365_diff` — compare two metadata snapshots.
|
|
3
|
+
*
|
|
4
|
+
* Two shapes matter in practice: the same environment against its previous
|
|
5
|
+
* snapshot ("what changed since last week") and one environment against another
|
|
6
|
+
* ("what is in dev but not in sit"). Both are the same operation over two
|
|
7
|
+
* directories, so this tool takes two snapshot references.
|
|
8
|
+
*
|
|
9
|
+
* Honesty about evidence is the whole point: when a resource was truncated or
|
|
10
|
+
* failed to sync on one side, a "removed" row may simply never have been
|
|
11
|
+
* fetched. Those resources are flagged rather than reported as removals.
|
|
12
|
+
*/
|
|
13
|
+
import { loadConfig } from './config.js'
|
|
14
|
+
import { KEY_FIELDS, DIFF_KEY_FIELDS, HIGHLIGHT_FIELDS, rowKey } from './resources.js'
|
|
15
|
+
import { D365Error } from './errors.js'
|
|
16
|
+
import { listSnapshots, readResource, writeDiffReport } from './snapshot.js'
|
|
17
|
+
import { enforceRetention } from './retention.js'
|
|
18
|
+
|
|
19
|
+
/** Display order for resources, most operationally interesting first. */
|
|
20
|
+
const RESOURCE_ORDER = [
|
|
21
|
+
'organization',
|
|
22
|
+
'solutions',
|
|
23
|
+
'entities',
|
|
24
|
+
'attributes',
|
|
25
|
+
'forms',
|
|
26
|
+
'views',
|
|
27
|
+
'workflows',
|
|
28
|
+
'plugin_steps',
|
|
29
|
+
'plugin_assemblies',
|
|
30
|
+
'plugin_types',
|
|
31
|
+
'web_resources',
|
|
32
|
+
'custom_apis',
|
|
33
|
+
'solution_components',
|
|
34
|
+
'sitemaps',
|
|
35
|
+
'option_sets',
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
export const definition = {
|
|
39
|
+
name: 'd365_diff',
|
|
40
|
+
description:
|
|
41
|
+
'比较两份 D365 元数据快照,给出新增 / 删除 / 修改(字段级)。'
|
|
42
|
+
+ '可用于"同一环境与上一份快照对比"或"dev 与 sit 对比"。'
|
|
43
|
+
+ '参数省略时先列出各环境已有快照,便于先 d365_sync 再对比。',
|
|
44
|
+
parameters: {
|
|
45
|
+
type: 'object',
|
|
46
|
+
additionalProperties: false,
|
|
47
|
+
properties: {
|
|
48
|
+
base: {
|
|
49
|
+
type: 'string',
|
|
50
|
+
description: '基准快照:"dev" 表示 dev 最新,"dev@prev" 表示上一份,"dev@20261102T071005Z" 指定某份。省略时取 head 环境的上一份快照。',
|
|
51
|
+
},
|
|
52
|
+
head: {
|
|
53
|
+
type: 'string',
|
|
54
|
+
description: '对照快照,写法同 base。省略时取默认环境的最新快照。',
|
|
55
|
+
},
|
|
56
|
+
resources: {
|
|
57
|
+
type: 'array',
|
|
58
|
+
items: { type: 'string' },
|
|
59
|
+
description: '只比较这些资源,例如 ["entities","attributes"]。默认比较两边都采集过的全部资源。',
|
|
60
|
+
},
|
|
61
|
+
maxDetails: {
|
|
62
|
+
type: 'integer',
|
|
63
|
+
description: '每个资源在回复里最多列多少条变化,默认 30;完整清单写入报告文件。',
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
output: {
|
|
68
|
+
schema: {
|
|
69
|
+
type: 'object',
|
|
70
|
+
additionalProperties: false,
|
|
71
|
+
required: ['text'],
|
|
72
|
+
properties: {
|
|
73
|
+
text: { type: 'string' },
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
render(_args, value) {
|
|
77
|
+
return [{ type: 'text', text: value?.text ?? '' }]
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Parse `env`, `env@id`, `env@prev` or `env/id`. */
|
|
83
|
+
function parseRef(text) {
|
|
84
|
+
const trimmed = String(text ?? '').trim()
|
|
85
|
+
const match = /^([^@/]+)(?:[@/](.+))?$/.exec(trimmed)
|
|
86
|
+
if (!match) throw new D365Error(`无法解析快照引用:${trimmed}`, { code: 'bad-ref' })
|
|
87
|
+
return { envKey: match[1].trim(), id: match[2]?.trim() }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Resolve one reference to `{ env, snapshot }`. */
|
|
91
|
+
async function resolveRef(config, text, role) {
|
|
92
|
+
const { envKey, id } = parseRef(text)
|
|
93
|
+
const env = config.environments[envKey]
|
|
94
|
+
if (!env) {
|
|
95
|
+
throw new D365Error(`${role} 引用了未知环境:${envKey}`, {
|
|
96
|
+
code: 'unknown-environment',
|
|
97
|
+
hint: `可用环境:${Object.keys(config.environments).join(', ')}`,
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const snapshots = await listSnapshots(config, envKey)
|
|
102
|
+
if (!snapshots.length) {
|
|
103
|
+
throw new D365Error(`环境 ${envKey} 还没有任何快照`, {
|
|
104
|
+
code: 'no-snapshot',
|
|
105
|
+
hint: `先运行 d365_sync(environments: ["${envKey}"])。`,
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!id || id === 'latest') return { env, snapshot: snapshots[0] }
|
|
110
|
+
if (id === 'prev') {
|
|
111
|
+
if (snapshots.length < 2) {
|
|
112
|
+
throw new D365Error(`环境 ${envKey} 只有一份快照,没有"上一份"可对比`, {
|
|
113
|
+
code: 'no-previous',
|
|
114
|
+
hint: '再跑一次 d365_sync,或显式指定另一份快照 ID。',
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
return { env, snapshot: snapshots[1] }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const exact = snapshots.find((item) => item.id === id)
|
|
121
|
+
?? snapshots.find((item) => item.id.startsWith(id))
|
|
122
|
+
// A time suffix is how people read these ids, so `@082604` should find
|
|
123
|
+
// `20261102T082604Z`.
|
|
124
|
+
?? snapshots.find((item) => item.id.includes(id))
|
|
125
|
+
if (!exact) {
|
|
126
|
+
throw new D365Error(`找不到快照 ${envKey}@${id}`, {
|
|
127
|
+
code: 'unknown-snapshot',
|
|
128
|
+
hint: `该环境现有:${snapshots.slice(0, 8).map((item) => item.id).join(', ')}`,
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
return { env, snapshot: exact }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Normalize a cell for comparison. */
|
|
135
|
+
function normalize(value) {
|
|
136
|
+
if (value === undefined || value === null) return ''
|
|
137
|
+
if (typeof value === 'object') return JSON.stringify(value)
|
|
138
|
+
return String(value)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Fields that differ between any two environments for reasons that are not
|
|
143
|
+
* drift: record GUIDs are minted per organization, metadata ids change on
|
|
144
|
+
* import, and timestamps record when the row was touched. Comparing them turned
|
|
145
|
+
* a real cross-environment report into "66051 web resources modified", which
|
|
146
|
+
* buries the changes that matter.
|
|
147
|
+
*/
|
|
148
|
+
const NOISE_FIELDS = new Set([
|
|
149
|
+
'metadataid',
|
|
150
|
+
'objecttypecode',
|
|
151
|
+
'modifiedon',
|
|
152
|
+
'createdon',
|
|
153
|
+
'installedon',
|
|
154
|
+
'overwritetime',
|
|
155
|
+
'msdyn_overwritetime',
|
|
156
|
+
'modifiedby',
|
|
157
|
+
'createdby',
|
|
158
|
+
'overriddencreatedon',
|
|
159
|
+
'timezoneruleversionnumber',
|
|
160
|
+
'utcconversiontimezonecode',
|
|
161
|
+
'versionnumber',
|
|
162
|
+
'importsequencenumber',
|
|
163
|
+
])
|
|
164
|
+
|
|
165
|
+
function isNoiseField(field) {
|
|
166
|
+
const lower = field.toLowerCase()
|
|
167
|
+
if (NOISE_FIELDS.has(lower)) return true
|
|
168
|
+
// Any `*id` column is a per-organization identifier; the row key already
|
|
169
|
+
// carries identity, so its value never describes a difference in behaviour.
|
|
170
|
+
return lower.endsWith('id')
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Fields of a row worth comparing; `@odata` annotations are noise. */
|
|
174
|
+
function comparableFields(row) {
|
|
175
|
+
return Object.keys(row ?? {}).filter(
|
|
176
|
+
(key) => !key.startsWith('@') && !key.endsWith('@odata.etag') && !isNoiseField(key),
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Resources whose rows cannot be aligned across organizations. `solution_components`
|
|
182
|
+
* identifies a component by `objectid`, a GUID that is minted per environment, so
|
|
183
|
+
* keying on it reports every component as added *and* removed. Counting per
|
|
184
|
+
* `componenttype` is the honest question: "does the target have as many entities,
|
|
185
|
+
* attributes, forms ... as the source".
|
|
186
|
+
*/
|
|
187
|
+
const HISTOGRAM_RESOURCES = new Set(['solution_components'])
|
|
188
|
+
|
|
189
|
+
/** Count rows per `componenttype` and diff the counts. */
|
|
190
|
+
function histogramDiff(baseRows, headRows) {
|
|
191
|
+
const tally = (rows) => {
|
|
192
|
+
const counts = new Map()
|
|
193
|
+
for (const row of rows ?? []) {
|
|
194
|
+
const key = String(row.componenttype ?? '?')
|
|
195
|
+
counts.set(key, (counts.get(key) ?? 0) + 1)
|
|
196
|
+
}
|
|
197
|
+
return counts
|
|
198
|
+
}
|
|
199
|
+
const baseCounts = tally(baseRows)
|
|
200
|
+
const headCounts = tally(headRows)
|
|
201
|
+
const changed = []
|
|
202
|
+
for (const key of new Set([...baseCounts.keys(), ...headCounts.keys()])) {
|
|
203
|
+
const from = baseCounts.get(key) ?? 0
|
|
204
|
+
const to = headCounts.get(key) ?? 0
|
|
205
|
+
if (from === to) continue
|
|
206
|
+
changed.push({ key: `componenttype ${key}`, fields: [{ field: '数量', from: String(from), to: String(to) }] })
|
|
207
|
+
}
|
|
208
|
+
changed.sort((a, b) => a.key.localeCompare(b.key))
|
|
209
|
+
return { keyFields: ['componenttype'], added: [], removed: [], changed, histogram: true }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Compare one resource's rows.
|
|
214
|
+
* @returns `{ added, removed, changed }` with keys, plus the key fields used.
|
|
215
|
+
*/
|
|
216
|
+
function diffRows(resource, baseRows, headRows) {
|
|
217
|
+
if (HISTOGRAM_RESOURCES.has(resource)) return histogramDiff(baseRows, headRows)
|
|
218
|
+
|
|
219
|
+
const keyFields = DIFF_KEY_FIELDS[resource] ?? KEY_FIELDS[resource]
|
|
220
|
+
const keyOf = (row) => rowKey(resource, row, keyFields)
|
|
221
|
+
|
|
222
|
+
const baseMap = new Map()
|
|
223
|
+
for (const row of baseRows) baseMap.set(keyOf(row), row)
|
|
224
|
+
const headMap = new Map()
|
|
225
|
+
for (const row of headRows) headMap.set(keyOf(row), row)
|
|
226
|
+
|
|
227
|
+
const added = []
|
|
228
|
+
const removed = []
|
|
229
|
+
const changed = []
|
|
230
|
+
|
|
231
|
+
for (const [key, headRow] of headMap) {
|
|
232
|
+
const baseRow = baseMap.get(key)
|
|
233
|
+
if (!baseRow) {
|
|
234
|
+
added.push({ key })
|
|
235
|
+
continue
|
|
236
|
+
}
|
|
237
|
+
const fields = []
|
|
238
|
+
const names = new Set([...comparableFields(baseRow), ...comparableFields(headRow)])
|
|
239
|
+
for (const field of names) {
|
|
240
|
+
const from = normalize(baseRow[field])
|
|
241
|
+
const to = normalize(headRow[field])
|
|
242
|
+
if (from !== to) fields.push({ field, from, to })
|
|
243
|
+
}
|
|
244
|
+
if (fields.length) changed.push({ key, fields })
|
|
245
|
+
}
|
|
246
|
+
for (const key of baseMap.keys()) {
|
|
247
|
+
if (!headMap.has(key)) removed.push({ key })
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const sortByKey = (a, b) => a.key.localeCompare(b.key)
|
|
251
|
+
return {
|
|
252
|
+
keyFields,
|
|
253
|
+
added: added.sort(sortByKey),
|
|
254
|
+
removed: removed.sort(sortByKey),
|
|
255
|
+
changed: changed.sort(sortByKey),
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Order changed fields so the meaningful ones come first. */
|
|
260
|
+
function orderFields(resource, fields) {
|
|
261
|
+
const highlight = HIGHLIGHT_FIELDS[resource] ?? []
|
|
262
|
+
return [...fields].sort((a, b) => {
|
|
263
|
+
const ai = highlight.indexOf(a.field)
|
|
264
|
+
const bi = highlight.indexOf(b.field)
|
|
265
|
+
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi) || a.field.localeCompare(b.field)
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** A one-line description of one change. */
|
|
270
|
+
function describeChange(resource, change, maxFields = 6) {
|
|
271
|
+
const fields = orderFields(resource, change.fields)
|
|
272
|
+
const shown = fields.slice(0, maxFields).map((item) => `${item.field} ${item.from || '∅'}→${item.to || '∅'}`)
|
|
273
|
+
if (fields.length > maxFields) shown.push(`… 另有 ${fields.length - maxFields} 个字段`)
|
|
274
|
+
return `~ ${change.key}:${shown.join(';')}`
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Render the human-facing report, shared by the reply and the markdown file. */
|
|
278
|
+
function renderReport(context) {
|
|
279
|
+
const { base, head, results, skipped, evidence, maxDetails } = context
|
|
280
|
+
const lines = []
|
|
281
|
+
const detailed = new Set()
|
|
282
|
+
|
|
283
|
+
lines.push(`比较:${base.env.key}@${base.snapshot.id} → ${head.env.key}@${head.snapshot.id}`)
|
|
284
|
+
lines.push(
|
|
285
|
+
`基准:${base.env.key}(${base.snapshot.manifest.finishedAt ?? '?'},${base.snapshot.manifest.complete ? '完整' : '部分'})`
|
|
286
|
+
+ `;对照:${head.env.key}(${head.snapshot.manifest.finishedAt ?? '?'},${head.snapshot.manifest.complete ? '完整' : '部分'})`,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
const staleHours = (manifest) => {
|
|
290
|
+
const at = Date.parse(manifest?.finishedAt ?? '')
|
|
291
|
+
if (!Number.isFinite(at)) return null
|
|
292
|
+
return (Date.now() - at) / 3600000
|
|
293
|
+
}
|
|
294
|
+
const stale = [
|
|
295
|
+
['基准', base.env.key, staleHours(base.snapshot.manifest)],
|
|
296
|
+
['对照', head.env.key, staleHours(head.snapshot.manifest)],
|
|
297
|
+
].filter(([, , hours]) => hours !== null && hours > 24)
|
|
298
|
+
if (stale.length) {
|
|
299
|
+
lines.push('')
|
|
300
|
+
lines.push(`提示:${stale.map(([role, env, hours]) => `${role}快照(${env})已是 ${hours.toFixed(0)} 小时前`).join(',')},元数据可能已经变了;建议先 \`d365_sync\` 再比较。`)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const totals = { added: 0, removed: 0, changed: 0 }
|
|
304
|
+
for (const result of results) {
|
|
305
|
+
totals.added += result.diff.added.length
|
|
306
|
+
totals.removed += result.diff.removed.length
|
|
307
|
+
totals.changed += result.diff.changed.length
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (!totals.added && !totals.removed && !totals.changed) {
|
|
311
|
+
lines.push('', '两份快照在已采集的资源上没有差异。')
|
|
312
|
+
} else {
|
|
313
|
+
lines.push('')
|
|
314
|
+
lines.push('| 资源 | 新增 | 删除 | 修改 |')
|
|
315
|
+
lines.push('| --- | --- | --- | --- |')
|
|
316
|
+
for (const result of results) {
|
|
317
|
+
const { added, removed, changed } = result.diff
|
|
318
|
+
if (!added.length && !removed.length && !changed.length) continue
|
|
319
|
+
lines.push(`| ${result.resource} | ${added.length} | ${removed.length} | ${changed.length} |`)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
for (const result of results) {
|
|
324
|
+
const { added, removed, changed } = result.diff
|
|
325
|
+
if (!added.length && !removed.length && !changed.length) continue
|
|
326
|
+
lines.push('')
|
|
327
|
+
const caveat = evidence.get(result.resource)?.caveat
|
|
328
|
+
detailed.add(result.resource)
|
|
329
|
+
lines.push(`### ${result.resource}(新增 ${added.length} / 删除 ${removed.length} / 修改 ${changed.length})`)
|
|
330
|
+
if (caveat) lines.push(`> 注意:${caveat}`)
|
|
331
|
+
|
|
332
|
+
for (const item of added.slice(0, maxDetails)) lines.push(`+ ${item.key}`)
|
|
333
|
+
if (added.length > maxDetails) lines.push(`+ … 其余 ${added.length - maxDetails} 条见报告文件`)
|
|
334
|
+
|
|
335
|
+
for (const item of removed.slice(0, maxDetails)) lines.push(`- ${item.key}`)
|
|
336
|
+
if (removed.length > maxDetails) lines.push(`- … 其余 ${removed.length - maxDetails} 条见报告文件`)
|
|
337
|
+
|
|
338
|
+
for (const item of changed.slice(0, maxDetails)) lines.push(describeChange(result.resource, item))
|
|
339
|
+
if (changed.length > maxDetails) lines.push(`~ … 其余 ${changed.length - maxDetails} 条见报告文件`)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (skipped.length) {
|
|
343
|
+
lines.push('')
|
|
344
|
+
lines.push('未参与比较的资源(有一侧没采集):')
|
|
345
|
+
for (const item of skipped) lines.push(`- ${item.resource}:${item.reason}`)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Resources with no visible diff but incomplete data need their own warning,
|
|
349
|
+
// otherwise a partial snapshot reads as "nothing changed".
|
|
350
|
+
const suspect = [...evidence.values()].filter((item) => item.caveat && !detailed.has(item.resource))
|
|
351
|
+
if (suspect.length) {
|
|
352
|
+
lines.push('')
|
|
353
|
+
lines.push('其余资源数据不完整,未发现差异不代表没有变化:')
|
|
354
|
+
for (const item of suspect) lines.push(`- ${item.resource}:${item.caveat}`)
|
|
355
|
+
}
|
|
356
|
+
return lines
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Explain why a resource's comparison may be misleading. */
|
|
360
|
+
function evidenceFor(resource, baseManifest, headManifest) {
|
|
361
|
+
const baseDetail = baseManifest.resources?.[resource]
|
|
362
|
+
const headDetail = headManifest.resources?.[resource]
|
|
363
|
+
const parts = []
|
|
364
|
+
if (baseDetail?.truncated) parts.push('基准侧被行数上限截断')
|
|
365
|
+
if (headDetail?.truncated) parts.push('对照侧被行数上限截断')
|
|
366
|
+
if (baseDetail?.error) parts.push(`基准侧采集失败(${String(baseDetail.error).slice(0, 80)})`)
|
|
367
|
+
if (headDetail?.error) parts.push(`对照侧采集失败(${String(headDetail.error).slice(0, 80)})`)
|
|
368
|
+
return parts.length ? { resource, caveat: parts.join(';') } : null
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** List available snapshots, newest first. */
|
|
372
|
+
async function describeSnapshots(config) {
|
|
373
|
+
const lines = ['当前已有快照(最新在前):']
|
|
374
|
+
let total = 0
|
|
375
|
+
for (const envKey of Object.keys(config.environments)) {
|
|
376
|
+
const snapshots = await listSnapshots(config, envKey)
|
|
377
|
+
if (!snapshots.length) {
|
|
378
|
+
lines.push(`- ${envKey}:无(先运行 d365_sync)`)
|
|
379
|
+
continue
|
|
380
|
+
}
|
|
381
|
+
total += snapshots.length
|
|
382
|
+
for (const snapshot of snapshots.slice(0, 5)) {
|
|
383
|
+
const manifest = snapshot.manifest
|
|
384
|
+
const orgName = manifest.organization?.name ?? '?'
|
|
385
|
+
const state = manifest.complete ? '完整' : '部分'
|
|
386
|
+
const resources = Object.keys(manifest.resources ?? {}).length
|
|
387
|
+
lines.push(`- ${envKey}@${snapshot.id}:${orgName},${state},${resources} 个资源,${manifest.finishedAt ?? ''}`)
|
|
388
|
+
}
|
|
389
|
+
if (snapshots.length > 5) lines.push(`- ${envKey}:其余 ${snapshots.length - 5} 份略`)
|
|
390
|
+
}
|
|
391
|
+
if (total) {
|
|
392
|
+
lines.push('')
|
|
393
|
+
lines.push('对比用法:base="dev@prev" head="dev"(同一环境与上一份),或 base="dev" head="sit"(跨环境)。')
|
|
394
|
+
}
|
|
395
|
+
return lines.join('\n')
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function register(ctx, state) {
|
|
399
|
+
return ctx.tools.register({
|
|
400
|
+
...definition,
|
|
401
|
+
async execute(args = {}) {
|
|
402
|
+
const config = await loadConfig(state.rawConfig)
|
|
403
|
+
|
|
404
|
+
if (!args.base && !args.head) {
|
|
405
|
+
return { text: await describeSnapshots(config) }
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const head = await resolveRef(config, args.head ?? config.defaultEnvironment, 'head')
|
|
409
|
+
const base = await resolveRef(config, args.base ?? `${head.env.key}@prev`, 'base')
|
|
410
|
+
|
|
411
|
+
const baseResources = new Set(Object.keys(base.snapshot.manifest.resources ?? {}))
|
|
412
|
+
const headResources = new Set(Object.keys(head.snapshot.manifest.resources ?? {}))
|
|
413
|
+
const requested = Array.isArray(args.resources) && args.resources.length
|
|
414
|
+
? args.resources
|
|
415
|
+
// `organization` is the environment's own identity: two environments are
|
|
416
|
+
// always "different" there, and that says nothing about drift.
|
|
417
|
+
: RESOURCE_ORDER.filter((resource) => resource !== 'organization'
|
|
418
|
+
&& baseResources.has(resource) && headResources.has(resource))
|
|
419
|
+
|
|
420
|
+
const ordered = [...requested].sort(
|
|
421
|
+
(a, b) => (RESOURCE_ORDER.indexOf(a) === -1 ? 99 : RESOURCE_ORDER.indexOf(a))
|
|
422
|
+
- (RESOURCE_ORDER.indexOf(b) === -1 ? 99 : RESOURCE_ORDER.indexOf(b)),
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
const results = []
|
|
426
|
+
const skipped = []
|
|
427
|
+
const evidence = new Map()
|
|
428
|
+
|
|
429
|
+
for (const resource of ordered) {
|
|
430
|
+
const inBase = baseResources.has(resource)
|
|
431
|
+
const inHead = headResources.has(resource)
|
|
432
|
+
if (!inBase || !inHead) {
|
|
433
|
+
skipped.push({
|
|
434
|
+
resource,
|
|
435
|
+
reason: !inBase && !inHead ? '两侧都未采集' : inBase ? '对照侧未采集' : '基准侧未采集',
|
|
436
|
+
})
|
|
437
|
+
continue
|
|
438
|
+
}
|
|
439
|
+
const basePayload = await readResource(config, base.env.key, base.snapshot.id, resource)
|
|
440
|
+
const headPayload = await readResource(config, head.env.key, head.snapshot.id, resource)
|
|
441
|
+
if (!basePayload || !headPayload) {
|
|
442
|
+
skipped.push({ resource, reason: '快照文件缺失' })
|
|
443
|
+
continue
|
|
444
|
+
}
|
|
445
|
+
const diff = diffRows(resource, basePayload.rows ?? [], headPayload.rows ?? [])
|
|
446
|
+
results.push({ resource, diff })
|
|
447
|
+
const flag = evidenceFor(resource, base.snapshot.manifest, head.snapshot.manifest) ?? {}
|
|
448
|
+
if (diff.histogram) {
|
|
449
|
+
flag.caveat = [flag.caveat, '按 componenttype 计数比较(组件的 objectid 是按组织分配的 GUID,逐行对齐会得到全部新增+全部删除)']
|
|
450
|
+
.filter(Boolean).join(';')
|
|
451
|
+
}
|
|
452
|
+
if (flag.caveat) evidence.set(resource, { resource, caveat: flag.caveat })
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const maxDetails = Number.isFinite(args.maxDetails) && args.maxDetails > 0
|
|
456
|
+
? Math.min(200, Math.trunc(args.maxDetails))
|
|
457
|
+
: 30
|
|
458
|
+
|
|
459
|
+
const lines = renderReport({ base, head, ordered, results, skipped, evidence, maxDetails })
|
|
460
|
+
|
|
461
|
+
const changedResources = results.filter(
|
|
462
|
+
(result) => result.diff.added.length || result.diff.removed.length || result.diff.changed.length,
|
|
463
|
+
)
|
|
464
|
+
if (changedResources.length || skipped.length) {
|
|
465
|
+
const markdown = renderReport({ base, head, ordered, results, skipped, evidence, maxDetails: 500 }).join('\n')
|
|
466
|
+
const file = await writeDiffReport(
|
|
467
|
+
config,
|
|
468
|
+
`${base.env.key}-${base.snapshot.id}-vs-${head.env.key}-${head.snapshot.id}`,
|
|
469
|
+
`# D365 元数据差异\n\n${markdown}\n`,
|
|
470
|
+
)
|
|
471
|
+
lines.push('', `完整报告:${file}`)
|
|
472
|
+
await enforceRetention(config, { target: 'diffs' })
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return { text: lines.join('\n') }
|
|
476
|
+
},
|
|
477
|
+
presentCall(args = {}) {
|
|
478
|
+
const title = args.base || args.head
|
|
479
|
+
? `比较快照 ${args.base ?? '(上一份)'} → ${args.head ?? '(最新)'}`
|
|
480
|
+
: '列出已有 D365 快照'
|
|
481
|
+
return { card: 'generic', title, kind: 'other', rawInput: args }
|
|
482
|
+
},
|
|
483
|
+
})
|
|
484
|
+
}
|