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/snapshot.js
CHANGED
|
@@ -1,149 +1,149 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Snapshot storage.
|
|
3
|
-
*
|
|
4
|
-
* A snapshot is a directory that becomes visible only when its `manifest.json`
|
|
5
|
-
* exists: resource files are written first and the manifest last, so an
|
|
6
|
-
* interrupted sync leaves an orphan directory that no reader ever picks up.
|
|
7
|
-
*
|
|
8
|
-
* The manifest is also what makes a snapshot trustworthy: it records, per
|
|
9
|
-
* resource, whether the pull was complete, truncated by the row cap, or failed.
|
|
10
|
-
* Without that, "the environment does not have this" and "we never fetched it"
|
|
11
|
-
* would look identical in a diff.
|
|
12
|
-
*/
|
|
13
|
-
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
14
|
-
import { join, resolve, sep } from 'node:path'
|
|
15
|
-
import { dataDirectory } from './config.js'
|
|
16
|
-
|
|
17
|
-
/** Root directory holding snapshots, diffs and query artifacts. */
|
|
18
|
-
export function dataRoot(config) {
|
|
19
|
-
return join(dataDirectory(config), '.d365')
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function snapshotsRoot(config) {
|
|
23
|
-
return join(dataRoot(config), 'snapshots')
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function diffsRoot(config) {
|
|
27
|
-
return join(dataRoot(config), 'diffs')
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** Sortable, filename-safe snapshot id: `20261102T071005Z`. */
|
|
31
|
-
export function snapshotId(date = new Date()) {
|
|
32
|
-
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function envSnapshotDir(config, envKey, id) {
|
|
36
|
-
return join(snapshotsRoot(config), envKey, id)
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Ensure the data root exists and carries a `.gitignore`, so a metadata dump
|
|
41
|
-
* of a customer org never gets committed by accident.
|
|
42
|
-
*/
|
|
43
|
-
export async function ensureDataRoot(config) {
|
|
44
|
-
const root = dataRoot(config)
|
|
45
|
-
await mkdir(root, { recursive: true })
|
|
46
|
-
await writeFile(
|
|
47
|
-
join(root, '.gitignore'),
|
|
48
|
-
'# D365 snapshots and query artifacts may contain customer metadata.\n*\n',
|
|
49
|
-
{ flag: 'wx' },
|
|
50
|
-
).catch(() => {})
|
|
51
|
-
return root
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Write one snapshot: resource files first, manifest last.
|
|
56
|
-
* @param files - array of `{ resource, payload }`.
|
|
57
|
-
* @returns the snapshot directory.
|
|
58
|
-
*/
|
|
59
|
-
export async function writeSnapshot(config, envKey, id, manifest, files) {
|
|
60
|
-
const dir = envSnapshotDir(config, envKey, id)
|
|
61
|
-
await mkdir(dir, { recursive: true })
|
|
62
|
-
for (const file of files) {
|
|
63
|
-
// Compact JSON: a 200k-row resource is ~35% smaller without indentation,
|
|
64
|
-
// and nothing reads these files by eye.
|
|
65
|
-
await writeFile(join(dir, `${file.resource}.json`), `${JSON.stringify(file.payload)}\n`, 'utf8')
|
|
66
|
-
}
|
|
67
|
-
await writeFile(join(dir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
|
|
68
|
-
return dir
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* List snapshots of one environment, newest first. Only directories with a
|
|
73
|
-
* manifest count — the rest are interrupted writes.
|
|
74
|
-
* @returns `[{ id, dir, manifest }]`.
|
|
75
|
-
*/
|
|
76
|
-
export async function listSnapshots(config, envKey) {
|
|
77
|
-
const dir = join(snapshotsRoot(config), envKey)
|
|
78
|
-
let names
|
|
79
|
-
try {
|
|
80
|
-
names = await readdir(dir, { withFileTypes: true })
|
|
81
|
-
} catch {
|
|
82
|
-
return []
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const snapshots = []
|
|
86
|
-
for (const entry of names) {
|
|
87
|
-
if (!entry.isDirectory()) continue
|
|
88
|
-
const manifestPath = join(dir, entry.name, 'manifest.json')
|
|
89
|
-
try {
|
|
90
|
-
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
91
|
-
snapshots.push({ id: entry.name, dir: join(dir, entry.name), manifest })
|
|
92
|
-
} catch {
|
|
93
|
-
continue
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
return snapshots.sort((a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0))
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** Read a manifest, or null when the snapshot does not exist. */
|
|
100
|
-
export async function readManifest(config, envKey, id) {
|
|
101
|
-
try {
|
|
102
|
-
const text = await readFile(join(envSnapshotDir(config, envKey, id), 'manifest.json'), 'utf8')
|
|
103
|
-
return JSON.parse(text)
|
|
104
|
-
} catch {
|
|
105
|
-
return null
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** Read one resource's rows from a snapshot, or null when it was not synced. */
|
|
110
|
-
export async function readResource(config, envKey, id, resource) {
|
|
111
|
-
try {
|
|
112
|
-
const text = await readFile(join(envSnapshotDir(config, envKey, id), `${resource}.json`), 'utf8')
|
|
113
|
-
return JSON.parse(text)
|
|
114
|
-
} catch {
|
|
115
|
-
return null
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Delete the oldest snapshots beyond `keep`, inside one environment only.
|
|
121
|
-
* Refuses any path that resolves outside the environment's snapshot directory.
|
|
122
|
-
* @returns the deleted ids.
|
|
123
|
-
*/
|
|
124
|
-
export async function pruneSnapshots(config, envKey, keep) {
|
|
125
|
-
if (!Number.isFinite(keep) || keep < 1) return []
|
|
126
|
-
const snapshots = await listSnapshots(config, envKey)
|
|
127
|
-
if (snapshots.length <= keep) return []
|
|
128
|
-
|
|
129
|
-
const base = resolve(join(snapshotsRoot(config), envKey))
|
|
130
|
-
const deleted = []
|
|
131
|
-
for (const snapshot of snapshots.slice(keep)) {
|
|
132
|
-
const target = resolve(snapshot.dir)
|
|
133
|
-
if (target !== base && target.startsWith(base + sep)) {
|
|
134
|
-
await rm(target, { recursive: true, force: true })
|
|
135
|
-
deleted.push(snapshot.id)
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
return deleted
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** Write a diff report and return its workspace-relative path. */
|
|
142
|
-
export async function writeDiffReport(config, name, markdown) {
|
|
143
|
-
const root = diffsRoot(config)
|
|
144
|
-
await mkdir(root, { recursive: true })
|
|
145
|
-
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
146
|
-
const file = join(root, `${stamp}-${name.replace(/[^a-zA-Z0-9._-]+/g, '-')}.md`)
|
|
147
|
-
await writeFile(file, `${markdown}\n`, 'utf8')
|
|
148
|
-
return file.replace(/\\/g, '/')
|
|
149
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot storage.
|
|
3
|
+
*
|
|
4
|
+
* A snapshot is a directory that becomes visible only when its `manifest.json`
|
|
5
|
+
* exists: resource files are written first and the manifest last, so an
|
|
6
|
+
* interrupted sync leaves an orphan directory that no reader ever picks up.
|
|
7
|
+
*
|
|
8
|
+
* The manifest is also what makes a snapshot trustworthy: it records, per
|
|
9
|
+
* resource, whether the pull was complete, truncated by the row cap, or failed.
|
|
10
|
+
* Without that, "the environment does not have this" and "we never fetched it"
|
|
11
|
+
* would look identical in a diff.
|
|
12
|
+
*/
|
|
13
|
+
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
14
|
+
import { join, resolve, sep } from 'node:path'
|
|
15
|
+
import { dataDirectory } from './config.js'
|
|
16
|
+
|
|
17
|
+
/** Root directory holding snapshots, diffs and query artifacts. */
|
|
18
|
+
export function dataRoot(config) {
|
|
19
|
+
return join(dataDirectory(config), '.d365')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function snapshotsRoot(config) {
|
|
23
|
+
return join(dataRoot(config), 'snapshots')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function diffsRoot(config) {
|
|
27
|
+
return join(dataRoot(config), 'diffs')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Sortable, filename-safe snapshot id: `20261102T071005Z`. */
|
|
31
|
+
export function snapshotId(date = new Date()) {
|
|
32
|
+
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function envSnapshotDir(config, envKey, id) {
|
|
36
|
+
return join(snapshotsRoot(config), envKey, id)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Ensure the data root exists and carries a `.gitignore`, so a metadata dump
|
|
41
|
+
* of a customer org never gets committed by accident.
|
|
42
|
+
*/
|
|
43
|
+
export async function ensureDataRoot(config) {
|
|
44
|
+
const root = dataRoot(config)
|
|
45
|
+
await mkdir(root, { recursive: true })
|
|
46
|
+
await writeFile(
|
|
47
|
+
join(root, '.gitignore'),
|
|
48
|
+
'# D365 snapshots and query artifacts may contain customer metadata.\n*\n',
|
|
49
|
+
{ flag: 'wx' },
|
|
50
|
+
).catch(() => {})
|
|
51
|
+
return root
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Write one snapshot: resource files first, manifest last.
|
|
56
|
+
* @param files - array of `{ resource, payload }`.
|
|
57
|
+
* @returns the snapshot directory.
|
|
58
|
+
*/
|
|
59
|
+
export async function writeSnapshot(config, envKey, id, manifest, files) {
|
|
60
|
+
const dir = envSnapshotDir(config, envKey, id)
|
|
61
|
+
await mkdir(dir, { recursive: true })
|
|
62
|
+
for (const file of files) {
|
|
63
|
+
// Compact JSON: a 200k-row resource is ~35% smaller without indentation,
|
|
64
|
+
// and nothing reads these files by eye.
|
|
65
|
+
await writeFile(join(dir, `${file.resource}.json`), `${JSON.stringify(file.payload)}\n`, 'utf8')
|
|
66
|
+
}
|
|
67
|
+
await writeFile(join(dir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
|
|
68
|
+
return dir
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* List snapshots of one environment, newest first. Only directories with a
|
|
73
|
+
* manifest count — the rest are interrupted writes.
|
|
74
|
+
* @returns `[{ id, dir, manifest }]`.
|
|
75
|
+
*/
|
|
76
|
+
export async function listSnapshots(config, envKey) {
|
|
77
|
+
const dir = join(snapshotsRoot(config), envKey)
|
|
78
|
+
let names
|
|
79
|
+
try {
|
|
80
|
+
names = await readdir(dir, { withFileTypes: true })
|
|
81
|
+
} catch {
|
|
82
|
+
return []
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const snapshots = []
|
|
86
|
+
for (const entry of names) {
|
|
87
|
+
if (!entry.isDirectory()) continue
|
|
88
|
+
const manifestPath = join(dir, entry.name, 'manifest.json')
|
|
89
|
+
try {
|
|
90
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
91
|
+
snapshots.push({ id: entry.name, dir: join(dir, entry.name), manifest })
|
|
92
|
+
} catch {
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return snapshots.sort((a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Read a manifest, or null when the snapshot does not exist. */
|
|
100
|
+
export async function readManifest(config, envKey, id) {
|
|
101
|
+
try {
|
|
102
|
+
const text = await readFile(join(envSnapshotDir(config, envKey, id), 'manifest.json'), 'utf8')
|
|
103
|
+
return JSON.parse(text)
|
|
104
|
+
} catch {
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Read one resource's rows from a snapshot, or null when it was not synced. */
|
|
110
|
+
export async function readResource(config, envKey, id, resource) {
|
|
111
|
+
try {
|
|
112
|
+
const text = await readFile(join(envSnapshotDir(config, envKey, id), `${resource}.json`), 'utf8')
|
|
113
|
+
return JSON.parse(text)
|
|
114
|
+
} catch {
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Delete the oldest snapshots beyond `keep`, inside one environment only.
|
|
121
|
+
* Refuses any path that resolves outside the environment's snapshot directory.
|
|
122
|
+
* @returns the deleted ids.
|
|
123
|
+
*/
|
|
124
|
+
export async function pruneSnapshots(config, envKey, keep) {
|
|
125
|
+
if (!Number.isFinite(keep) || keep < 1) return []
|
|
126
|
+
const snapshots = await listSnapshots(config, envKey)
|
|
127
|
+
if (snapshots.length <= keep) return []
|
|
128
|
+
|
|
129
|
+
const base = resolve(join(snapshotsRoot(config), envKey))
|
|
130
|
+
const deleted = []
|
|
131
|
+
for (const snapshot of snapshots.slice(keep)) {
|
|
132
|
+
const target = resolve(snapshot.dir)
|
|
133
|
+
if (target !== base && target.startsWith(base + sep)) {
|
|
134
|
+
await rm(target, { recursive: true, force: true })
|
|
135
|
+
deleted.push(snapshot.id)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return deleted
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Write a diff report and return its workspace-relative path. */
|
|
142
|
+
export async function writeDiffReport(config, name, markdown) {
|
|
143
|
+
const root = diffsRoot(config)
|
|
144
|
+
await mkdir(root, { recursive: true })
|
|
145
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
146
|
+
const file = join(root, `${stamp}-${name.replace(/[^a-zA-Z0-9._-]+/g, '-')}.md`)
|
|
147
|
+
await writeFile(file, `${markdown}\n`, 'utf8')
|
|
148
|
+
return file.replace(/\\/g, '/')
|
|
149
|
+
}
|
package/lib/tool-code-check.js
CHANGED
|
@@ -1,133 +1,133 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `d365_code_check` — what does this merge request break in a target environment?
|
|
3
|
-
*
|
|
4
|
-
* Only the files the MR touches are analyzed, and only their added/removed
|
|
5
|
-
* lines: the question is "will this change run elsewhere", not "is this
|
|
6
|
-
* repository clean".
|
|
7
|
-
*/
|
|
8
|
-
import { loadConfig } from './config.js'
|
|
9
|
-
import { runCodeCheck } from './code-check.js'
|
|
10
|
-
|
|
11
|
-
export const definition = {
|
|
12
|
-
name: 'd365_code_check',
|
|
13
|
-
description:
|
|
14
|
-
'检查一次 GitLab MR 的改动在目标环境会不会跑不起来:JS/TS 映射到 Web 资源与表单,核对改动行读取的字段/实体是否还在表单上;'
|
|
15
|
-
+ 'C# 映射到插件程序集,核对程序集版本、插件类型、步骤、step 镜像与过滤属性。无法静态判定的进人工确认清单。只读。',
|
|
16
|
-
parameters: {
|
|
17
|
-
type: 'object',
|
|
18
|
-
additionalProperties: false,
|
|
19
|
-
required: ['mergeRequest'],
|
|
20
|
-
properties: {
|
|
21
|
-
mergeRequest: {
|
|
22
|
-
type: 'string',
|
|
23
|
-
description: 'MR 链接(https://gitlab…/group/project/-/merge_requests/42),或 IID(42 / !42)。',
|
|
24
|
-
},
|
|
25
|
-
project: {
|
|
26
|
-
type: 'string',
|
|
27
|
-
description: 'GitLab 项目路径 group/project;省略时用 MR 链接里的项目,再省略用设置页的默认项目。',
|
|
28
|
-
},
|
|
29
|
-
targets: {
|
|
30
|
-
type: 'array',
|
|
31
|
-
items: { type: 'string' },
|
|
32
|
-
description: '要对照检查的环境 key 列表;省略则全部环境。',
|
|
33
|
-
},
|
|
34
|
-
},
|
|
35
|
-
},
|
|
36
|
-
output: {
|
|
37
|
-
schema: {
|
|
38
|
-
type: 'object',
|
|
39
|
-
additionalProperties: false,
|
|
40
|
-
required: ['text'],
|
|
41
|
-
properties: {
|
|
42
|
-
text: { type: 'string' },
|
|
43
|
-
},
|
|
44
|
-
},
|
|
45
|
-
render(_args, value) {
|
|
46
|
-
return [{ type: 'text', text: value?.text ?? '' }]
|
|
47
|
-
},
|
|
48
|
-
},
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function renderReport(report, reportPath) {
|
|
52
|
-
const lines = []
|
|
53
|
-
lines.push(`代码改动检查:MR !${report.iid}${report.title ? ` · ${report.title}` : ''}`)
|
|
54
|
-
lines.push(`项目:${report.project}`)
|
|
55
|
-
if (report.sourceBranch) lines.push(`分支:${report.sourceBranch} → ${report.targetBranch}`)
|
|
56
|
-
lines.push(`目标环境:${report.targets.join('、')}`)
|
|
57
|
-
lines.push('')
|
|
58
|
-
|
|
59
|
-
lines.push('| 改动文件 | 类型 | 增/删行 | 检查到的引用 |')
|
|
60
|
-
lines.push('| --- | --- | --- | --- |')
|
|
61
|
-
for (const file of report.files) {
|
|
62
|
-
const refs = file.references
|
|
63
|
-
? `字段 ${file.references.attributes.length} · 实体 ${file.references.entities.length} · GUID ${file.references.guids.length}`
|
|
64
|
-
: file.facts
|
|
65
|
-
? `类 ${file.facts.classes.length} · 镜像 ${file.facts.images.length} · 字面量 ${file.facts.literals.length}`
|
|
66
|
-
: '—'
|
|
67
|
-
lines.push(`| ${file.path} | ${file.kind} | +${file.addedLines ?? 0}/-${file.removedLines ?? 0} | ${refs} |`)
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
for (const file of report.files) {
|
|
71
|
-
if (!file.targets?.length) continue
|
|
72
|
-
lines.push('')
|
|
73
|
-
lines.push(`### ${file.path}`)
|
|
74
|
-
for (const target of file.targets) {
|
|
75
|
-
if (file.kind === 'csharp') {
|
|
76
|
-
const types = target.types?.length ?? 0
|
|
77
|
-
const steps = (target.types ?? []).reduce((sum, item) => sum + (item.steps?.length ?? 0), 0)
|
|
78
|
-
const mapped = target.assembly
|
|
79
|
-
? `程序集 ${target.assembly}(${target.version ?? '版本未知'}${target.certain ? '' : ',推断待确认'}),类型 ${types},步骤 ${steps}`
|
|
80
|
-
: '未匹配到程序集'
|
|
81
|
-
lines.push(`- ${target.target}:${mapped}`)
|
|
82
|
-
continue
|
|
83
|
-
}
|
|
84
|
-
const forms = target.forms?.length ?? 0
|
|
85
|
-
const mapped = target.resourceName
|
|
86
|
-
? `→ ${target.resourceName}${target.certain ? '' : '(推断,待确认)'},${forms} 张表单引用`
|
|
87
|
-
: '→ 未找到对应 Web 资源'
|
|
88
|
-
lines.push(`- ${target.target}:${mapped}`)
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const blockers = report.findings.filter((item) => item.severity === 'blocker')
|
|
93
|
-
const warnings = report.findings.filter((item) => item.severity === 'warning')
|
|
94
|
-
|
|
95
|
-
lines.push('')
|
|
96
|
-
lines.push(`### 阻断项 ${blockers.length}`)
|
|
97
|
-
if (!blockers.length) lines.push('没有发现必然失败的改动。')
|
|
98
|
-
for (const item of blockers) {
|
|
99
|
-
lines.push(`- [${item.target}] ${item.file}${item.line ? `:${item.line}` : ''} — ${item.message}`)
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
lines.push('')
|
|
103
|
-
lines.push(`### 警告 ${warnings.length}`)
|
|
104
|
-
if (!warnings.length) lines.push('没有警告。')
|
|
105
|
-
for (const item of warnings) {
|
|
106
|
-
lines.push(`- [${item.target}] ${item.file}${item.line ? `:${item.line}` : ''} — ${item.message}`)
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
lines.push('')
|
|
110
|
-
lines.push(`### 人工确认 ${report.human.length}`)
|
|
111
|
-
if (!report.human.length) lines.push('没有需要人工确认的条目。')
|
|
112
|
-
for (const item of report.human) {
|
|
113
|
-
lines.push(`- ${item.id} ${item.object}:${item.question}`)
|
|
114
|
-
lines.push(` 证据:${item.evidence}`)
|
|
115
|
-
lines.push(` 确认:${item.howToCheck}`)
|
|
116
|
-
lines.push(` 影响:${item.impact}`)
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
lines.push('')
|
|
120
|
-
lines.push(`报告:${reportPath}`)
|
|
121
|
-
return lines.join('\n')
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export function register(ctx, state) {
|
|
125
|
-
return ctx.tools.register({
|
|
126
|
-
...definition,
|
|
127
|
-
async execute(args, exec) {
|
|
128
|
-
const config = await loadConfig(state.rawConfig)
|
|
129
|
-
const { report, reportPath } = await runCodeCheck(config, args ?? {}, exec?.signal)
|
|
130
|
-
return { text: renderReport(report, reportPath) }
|
|
131
|
-
},
|
|
132
|
-
})
|
|
133
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* `d365_code_check` — what does this merge request break in a target environment?
|
|
3
|
+
*
|
|
4
|
+
* Only the files the MR touches are analyzed, and only their added/removed
|
|
5
|
+
* lines: the question is "will this change run elsewhere", not "is this
|
|
6
|
+
* repository clean".
|
|
7
|
+
*/
|
|
8
|
+
import { loadConfig } from './config.js'
|
|
9
|
+
import { runCodeCheck } from './code-check.js'
|
|
10
|
+
|
|
11
|
+
export const definition = {
|
|
12
|
+
name: 'd365_code_check',
|
|
13
|
+
description:
|
|
14
|
+
'检查一次 GitLab MR 的改动在目标环境会不会跑不起来:JS/TS 映射到 Web 资源与表单,核对改动行读取的字段/实体是否还在表单上;'
|
|
15
|
+
+ 'C# 映射到插件程序集,核对程序集版本、插件类型、步骤、step 镜像与过滤属性。无法静态判定的进人工确认清单。只读。',
|
|
16
|
+
parameters: {
|
|
17
|
+
type: 'object',
|
|
18
|
+
additionalProperties: false,
|
|
19
|
+
required: ['mergeRequest'],
|
|
20
|
+
properties: {
|
|
21
|
+
mergeRequest: {
|
|
22
|
+
type: 'string',
|
|
23
|
+
description: 'MR 链接(https://gitlab…/group/project/-/merge_requests/42),或 IID(42 / !42)。',
|
|
24
|
+
},
|
|
25
|
+
project: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
description: 'GitLab 项目路径 group/project;省略时用 MR 链接里的项目,再省略用设置页的默认项目。',
|
|
28
|
+
},
|
|
29
|
+
targets: {
|
|
30
|
+
type: 'array',
|
|
31
|
+
items: { type: 'string' },
|
|
32
|
+
description: '要对照检查的环境 key 列表;省略则全部环境。',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
output: {
|
|
37
|
+
schema: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
additionalProperties: false,
|
|
40
|
+
required: ['text'],
|
|
41
|
+
properties: {
|
|
42
|
+
text: { type: 'string' },
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
render(_args, value) {
|
|
46
|
+
return [{ type: 'text', text: value?.text ?? '' }]
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function renderReport(report, reportPath) {
|
|
52
|
+
const lines = []
|
|
53
|
+
lines.push(`代码改动检查:MR !${report.iid}${report.title ? ` · ${report.title}` : ''}`)
|
|
54
|
+
lines.push(`项目:${report.project}`)
|
|
55
|
+
if (report.sourceBranch) lines.push(`分支:${report.sourceBranch} → ${report.targetBranch}`)
|
|
56
|
+
lines.push(`目标环境:${report.targets.join('、')}`)
|
|
57
|
+
lines.push('')
|
|
58
|
+
|
|
59
|
+
lines.push('| 改动文件 | 类型 | 增/删行 | 检查到的引用 |')
|
|
60
|
+
lines.push('| --- | --- | --- | --- |')
|
|
61
|
+
for (const file of report.files) {
|
|
62
|
+
const refs = file.references
|
|
63
|
+
? `字段 ${file.references.attributes.length} · 实体 ${file.references.entities.length} · GUID ${file.references.guids.length}`
|
|
64
|
+
: file.facts
|
|
65
|
+
? `类 ${file.facts.classes.length} · 镜像 ${file.facts.images.length} · 字面量 ${file.facts.literals.length}`
|
|
66
|
+
: '—'
|
|
67
|
+
lines.push(`| ${file.path} | ${file.kind} | +${file.addedLines ?? 0}/-${file.removedLines ?? 0} | ${refs} |`)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (const file of report.files) {
|
|
71
|
+
if (!file.targets?.length) continue
|
|
72
|
+
lines.push('')
|
|
73
|
+
lines.push(`### ${file.path}`)
|
|
74
|
+
for (const target of file.targets) {
|
|
75
|
+
if (file.kind === 'csharp') {
|
|
76
|
+
const types = target.types?.length ?? 0
|
|
77
|
+
const steps = (target.types ?? []).reduce((sum, item) => sum + (item.steps?.length ?? 0), 0)
|
|
78
|
+
const mapped = target.assembly
|
|
79
|
+
? `程序集 ${target.assembly}(${target.version ?? '版本未知'}${target.certain ? '' : ',推断待确认'}),类型 ${types},步骤 ${steps}`
|
|
80
|
+
: '未匹配到程序集'
|
|
81
|
+
lines.push(`- ${target.target}:${mapped}`)
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
const forms = target.forms?.length ?? 0
|
|
85
|
+
const mapped = target.resourceName
|
|
86
|
+
? `→ ${target.resourceName}${target.certain ? '' : '(推断,待确认)'},${forms} 张表单引用`
|
|
87
|
+
: '→ 未找到对应 Web 资源'
|
|
88
|
+
lines.push(`- ${target.target}:${mapped}`)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const blockers = report.findings.filter((item) => item.severity === 'blocker')
|
|
93
|
+
const warnings = report.findings.filter((item) => item.severity === 'warning')
|
|
94
|
+
|
|
95
|
+
lines.push('')
|
|
96
|
+
lines.push(`### 阻断项 ${blockers.length}`)
|
|
97
|
+
if (!blockers.length) lines.push('没有发现必然失败的改动。')
|
|
98
|
+
for (const item of blockers) {
|
|
99
|
+
lines.push(`- [${item.target}] ${item.file}${item.line ? `:${item.line}` : ''} — ${item.message}`)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
lines.push('')
|
|
103
|
+
lines.push(`### 警告 ${warnings.length}`)
|
|
104
|
+
if (!warnings.length) lines.push('没有警告。')
|
|
105
|
+
for (const item of warnings) {
|
|
106
|
+
lines.push(`- [${item.target}] ${item.file}${item.line ? `:${item.line}` : ''} — ${item.message}`)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
lines.push('')
|
|
110
|
+
lines.push(`### 人工确认 ${report.human.length}`)
|
|
111
|
+
if (!report.human.length) lines.push('没有需要人工确认的条目。')
|
|
112
|
+
for (const item of report.human) {
|
|
113
|
+
lines.push(`- ${item.id} ${item.object}:${item.question}`)
|
|
114
|
+
lines.push(` 证据:${item.evidence}`)
|
|
115
|
+
lines.push(` 确认:${item.howToCheck}`)
|
|
116
|
+
lines.push(` 影响:${item.impact}`)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
lines.push('')
|
|
120
|
+
lines.push(`报告:${reportPath}`)
|
|
121
|
+
return lines.join('\n')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function register(ctx, state) {
|
|
125
|
+
return ctx.tools.register({
|
|
126
|
+
...definition,
|
|
127
|
+
async execute(args, exec) {
|
|
128
|
+
const config = await loadConfig(state.rawConfig)
|
|
129
|
+
const { report, reportPath } = await runCodeCheck(config, args ?? {}, exec?.signal)
|
|
130
|
+
return { text: renderReport(report, reportPath) }
|
|
131
|
+
},
|
|
132
|
+
})
|
|
133
|
+
}
|