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/data-policy.js
CHANGED
|
@@ -1,78 +1,78 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Decide whether one environment may touch business records.
|
|
3
|
-
*
|
|
4
|
-
* Metadata is readable by default; business tables need `data.read`, and
|
|
5
|
-
* writing business records needs `data.write`. Both can be revoked per
|
|
6
|
-
* environment. The answer is cached for a moment because a single tool call can
|
|
7
|
-
* issue hundreds of requests, and the settings page may change the policy at
|
|
8
|
-
* any time.
|
|
9
|
-
*/
|
|
10
|
-
import { loadConfig } from './config.js'
|
|
11
|
-
import { allows, readPolicy } from './write-policy.js'
|
|
12
|
-
|
|
13
|
-
const TTL_MS = 2_000
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Build a resolver for one capability.
|
|
17
|
-
*
|
|
18
|
-
* @param getRawConfig - returns the plugin's raw config (so the config file can move).
|
|
19
|
-
* @param capability - policy key, e.g. `data.read`.
|
|
20
|
-
* @returns async function (envKey) => boolean.
|
|
21
|
-
*/
|
|
22
|
-
export function makeCapabilityResolver(getRawConfig, capability) {
|
|
23
|
-
let cache = { path: undefined, at: 0, policy: null }
|
|
24
|
-
|
|
25
|
-
return async function allowsCapability(envKey) {
|
|
26
|
-
try {
|
|
27
|
-
const config = await loadConfig(getRawConfig() ?? {})
|
|
28
|
-
const path = config.configFile ?? null
|
|
29
|
-
const now = Date.now()
|
|
30
|
-
if (cache.path !== path || now - cache.at > TTL_MS) {
|
|
31
|
-
cache = { path, at: now, policy: await readPolicy(path) }
|
|
32
|
-
}
|
|
33
|
-
return allows(cache.policy, envKey, capability)
|
|
34
|
-
} catch {
|
|
35
|
-
return false
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** @returns async function (envKey) => boolean for `data.read`. */
|
|
41
|
-
export function makeDataReadResolver(getRawConfig) {
|
|
42
|
-
return makeCapabilityResolver(getRawConfig, 'data.read')
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** @returns async function (envKey) => boolean for `data.write`. */
|
|
46
|
-
export function makeDataWriteResolver(getRawConfig) {
|
|
47
|
-
return makeCapabilityResolver(getRawConfig, 'data.write')
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** @returns async function (envKey) => boolean for `metadata.read`. */
|
|
51
|
-
export function makeMetadataReadResolver(getRawConfig) {
|
|
52
|
-
return makeCapabilityResolver(getRawConfig, 'metadata.read')
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Resolver for tools that name their capability on the request itself.
|
|
57
|
-
*
|
|
58
|
-
* `d365_invoke` passes `message.invoke` on its own request; instead of adding a
|
|
59
|
-
* fourth resolver per capability, resolve any key from the same policy file.
|
|
60
|
-
* @returns async function (envKey, capability) => boolean.
|
|
61
|
-
*/
|
|
62
|
-
export function makeAnyCapabilityResolver(getRawConfig) {
|
|
63
|
-
let cache = { path: undefined, at: 0, policy: null }
|
|
64
|
-
|
|
65
|
-
return async function allowsAnyCapability(envKey, capability) {
|
|
66
|
-
try {
|
|
67
|
-
const config = await loadConfig(getRawConfig() ?? {})
|
|
68
|
-
const path = config.configFile ?? null
|
|
69
|
-
const now = Date.now()
|
|
70
|
-
if (cache.path !== path || now - cache.at > TTL_MS) {
|
|
71
|
-
cache = { path, at: now, policy: await readPolicy(path) }
|
|
72
|
-
}
|
|
73
|
-
return allows(cache.policy, envKey, capability)
|
|
74
|
-
} catch {
|
|
75
|
-
return false
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Decide whether one environment may touch business records.
|
|
3
|
+
*
|
|
4
|
+
* Metadata is readable by default; business tables need `data.read`, and
|
|
5
|
+
* writing business records needs `data.write`. Both can be revoked per
|
|
6
|
+
* environment. The answer is cached for a moment because a single tool call can
|
|
7
|
+
* issue hundreds of requests, and the settings page may change the policy at
|
|
8
|
+
* any time.
|
|
9
|
+
*/
|
|
10
|
+
import { loadConfig } from './config.js'
|
|
11
|
+
import { allows, readPolicy } from './write-policy.js'
|
|
12
|
+
|
|
13
|
+
const TTL_MS = 2_000
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Build a resolver for one capability.
|
|
17
|
+
*
|
|
18
|
+
* @param getRawConfig - returns the plugin's raw config (so the config file can move).
|
|
19
|
+
* @param capability - policy key, e.g. `data.read`.
|
|
20
|
+
* @returns async function (envKey) => boolean.
|
|
21
|
+
*/
|
|
22
|
+
export function makeCapabilityResolver(getRawConfig, capability) {
|
|
23
|
+
let cache = { path: undefined, at: 0, policy: null }
|
|
24
|
+
|
|
25
|
+
return async function allowsCapability(envKey) {
|
|
26
|
+
try {
|
|
27
|
+
const config = await loadConfig(getRawConfig() ?? {})
|
|
28
|
+
const path = config.configFile ?? null
|
|
29
|
+
const now = Date.now()
|
|
30
|
+
if (cache.path !== path || now - cache.at > TTL_MS) {
|
|
31
|
+
cache = { path, at: now, policy: await readPolicy(path) }
|
|
32
|
+
}
|
|
33
|
+
return allows(cache.policy, envKey, capability)
|
|
34
|
+
} catch {
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @returns async function (envKey) => boolean for `data.read`. */
|
|
41
|
+
export function makeDataReadResolver(getRawConfig) {
|
|
42
|
+
return makeCapabilityResolver(getRawConfig, 'data.read')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @returns async function (envKey) => boolean for `data.write`. */
|
|
46
|
+
export function makeDataWriteResolver(getRawConfig) {
|
|
47
|
+
return makeCapabilityResolver(getRawConfig, 'data.write')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @returns async function (envKey) => boolean for `metadata.read`. */
|
|
51
|
+
export function makeMetadataReadResolver(getRawConfig) {
|
|
52
|
+
return makeCapabilityResolver(getRawConfig, 'metadata.read')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolver for tools that name their capability on the request itself.
|
|
57
|
+
*
|
|
58
|
+
* `d365_invoke` passes `message.invoke` on its own request; instead of adding a
|
|
59
|
+
* fourth resolver per capability, resolve any key from the same policy file.
|
|
60
|
+
* @returns async function (envKey, capability) => boolean.
|
|
61
|
+
*/
|
|
62
|
+
export function makeAnyCapabilityResolver(getRawConfig) {
|
|
63
|
+
let cache = { path: undefined, at: 0, policy: null }
|
|
64
|
+
|
|
65
|
+
return async function allowsAnyCapability(envKey, capability) {
|
|
66
|
+
try {
|
|
67
|
+
const config = await loadConfig(getRawConfig() ?? {})
|
|
68
|
+
const path = config.configFile ?? null
|
|
69
|
+
const now = Date.now()
|
|
70
|
+
if (cache.path !== path || now - cache.at > TTL_MS) {
|
|
71
|
+
cache = { path, at: now, policy: await readPolicy(path) }
|
|
72
|
+
}
|
|
73
|
+
return allows(cache.policy, envKey, capability)
|
|
74
|
+
} catch {
|
|
75
|
+
return false
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
package/lib/error-codes.js
CHANGED
|
@@ -1,80 +1,80 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Dataverse error codes that are worth explaining on the spot.
|
|
3
|
-
*
|
|
4
|
-
* A `0x80048d19` with no interpretation costs a person an hour: the message
|
|
5
|
-
* says the *shape* of one property is wrong, not which rule was broken. This
|
|
6
|
-
* table turns the code into the next thing to check. It is deliberately small:
|
|
7
|
-
* only codes whose cause is unambiguous get an entry, because a wrong hint is
|
|
8
|
-
* worse than none.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
export const ERROR_CODES = {
|
|
12
|
-
'0x80048d19': {
|
|
13
|
-
summary: '载荷里某个属性的形状不对:服务端在读一个标量值,报文里却是注解、对象或 null 以外的结构(常见于 lookup)',
|
|
14
|
-
hint: 'lookup 要写成 "<导航属性名>@odata.bind": "/<集合名>(<guid>)",不是字段逻辑名、也不是裸 GUID;选项集写数值、多选写数组、布尔写 true/false。导航属性名与字段逻辑名可能只是大小写不同(contoso_approverid → contoso_ApproverId),用 d365_metadata resource=one_to_many_relationships 看 ReferencingEntityNavigationPropertyName。',
|
|
15
|
-
},
|
|
16
|
-
'0x80060888': {
|
|
17
|
-
summary: '请求里有一个这个端点不认识的属性名或查询参数',
|
|
18
|
-
hint: '元数据端点(EntityDefinitions / GlobalOptionSetDefinitions 等)不接受 $top、$orderby、$filter 的部分组合;派生属性(如 DefaultValue、Targets、Options)要先用 cast 到具体类型才能 select。工具已经会逐列剔除并重试,仍失败说明名字本身写错了。',
|
|
19
|
-
},
|
|
20
|
-
'0x80040216': {
|
|
21
|
-
summary: '对象不存在:按这个名字或 id 找不到记录',
|
|
22
|
-
hint: '先确认环境对不对(不同环境的自定义表可用性不同),再用不带 filter 的查询确认记录真的存在。',
|
|
23
|
-
},
|
|
24
|
-
'0x80040217': {
|
|
25
|
-
summary: '对象不存在:按这个名字或 id 找不到记录',
|
|
26
|
-
hint: '先确认环境对不对(不同环境的自定义表可用性不同),再用不带 filter 的查询确认记录真的存在。',
|
|
27
|
-
},
|
|
28
|
-
'0x80040333': {
|
|
29
|
-
summary: '重复检测规则拦截了这次创建:目标里已有匹配的记录',
|
|
30
|
-
hint: '先查目标环境里是否已有同名/同键记录,按需要改成 update 或对这条记录开启 MSCRM.SuppressDuplicateDetection。',
|
|
31
|
-
},
|
|
32
|
-
'0x80044150': {
|
|
33
|
-
summary: '记录在读取之后被别人改过(并发冲突 / 状态已变化)',
|
|
34
|
-
hint: '重新读一次当前行再决定怎么写;如果是状态字段,确认它现在是不是已经是你想要的值。',
|
|
35
|
-
},
|
|
36
|
-
'0x80048423': {
|
|
37
|
-
summary: '组件属于某个受管解决方案,不能在这里直接改',
|
|
38
|
-
hint: '受管组件只能在源环境改,再作为新版本导入;要在当前环境改就先把它变成非受管(或建一个新的非受管组件)。',
|
|
39
|
-
},
|
|
40
|
-
'0x8004a0f6': {
|
|
41
|
-
summary: '操作违反了当前记录状态:状态机不允许这一步',
|
|
42
|
-
hint: '先看 statecode/statuscode 与业务流程阶段,撤销/激活类操作只能在特定状态下执行。',
|
|
43
|
-
},
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Pull the first `0x........` token out of a string. */
|
|
47
|
-
export function errorCodeFromText(text) {
|
|
48
|
-
const match = /0x[0-9a-fA-F]{8}/.exec(String(text ?? ''))
|
|
49
|
-
return match ? match[0].toLowerCase() : undefined
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Pull the Dataverse error code out of a decoded response body.
|
|
54
|
-
* Dataverse puts it in `error.code`, but some endpoints put it in the message.
|
|
55
|
-
*/
|
|
56
|
-
export function errorCodeFromBody(body) {
|
|
57
|
-
if (!body) return undefined
|
|
58
|
-
if (typeof body === 'string') return errorCodeFromText(body)
|
|
59
|
-
const root = body.error ?? body
|
|
60
|
-
const code = root?.code ?? root?.Code ?? root?.errorcode
|
|
61
|
-
if (typeof code === 'number') return `0x${code.toString(16).padStart(8, '0')}`
|
|
62
|
-
if (code) {
|
|
63
|
-
const text = errorCodeFromText(code)
|
|
64
|
-
if (text) return text
|
|
65
|
-
}
|
|
66
|
-
return errorCodeFromText(root?.message ?? root?.Message)
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** @returns the explanation for a known code, or undefined. */
|
|
70
|
-
export function explainErrorCode(code) {
|
|
71
|
-
const key = String(code ?? '').toLowerCase()
|
|
72
|
-
return ERROR_CODES[key]
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** @returns a one-line hint for a known code, or undefined. */
|
|
76
|
-
export function hintForErrorCode(code) {
|
|
77
|
-
const entry = explainErrorCode(code)
|
|
78
|
-
if (!entry) return undefined
|
|
79
|
-
return `${entry.summary}。${entry.hint}`
|
|
80
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Dataverse error codes that are worth explaining on the spot.
|
|
3
|
+
*
|
|
4
|
+
* A `0x80048d19` with no interpretation costs a person an hour: the message
|
|
5
|
+
* says the *shape* of one property is wrong, not which rule was broken. This
|
|
6
|
+
* table turns the code into the next thing to check. It is deliberately small:
|
|
7
|
+
* only codes whose cause is unambiguous get an entry, because a wrong hint is
|
|
8
|
+
* worse than none.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const ERROR_CODES = {
|
|
12
|
+
'0x80048d19': {
|
|
13
|
+
summary: '载荷里某个属性的形状不对:服务端在读一个标量值,报文里却是注解、对象或 null 以外的结构(常见于 lookup)',
|
|
14
|
+
hint: 'lookup 要写成 "<导航属性名>@odata.bind": "/<集合名>(<guid>)",不是字段逻辑名、也不是裸 GUID;选项集写数值、多选写数组、布尔写 true/false。导航属性名与字段逻辑名可能只是大小写不同(contoso_approverid → contoso_ApproverId),用 d365_metadata resource=one_to_many_relationships 看 ReferencingEntityNavigationPropertyName。',
|
|
15
|
+
},
|
|
16
|
+
'0x80060888': {
|
|
17
|
+
summary: '请求里有一个这个端点不认识的属性名或查询参数',
|
|
18
|
+
hint: '元数据端点(EntityDefinitions / GlobalOptionSetDefinitions 等)不接受 $top、$orderby、$filter 的部分组合;派生属性(如 DefaultValue、Targets、Options)要先用 cast 到具体类型才能 select。工具已经会逐列剔除并重试,仍失败说明名字本身写错了。',
|
|
19
|
+
},
|
|
20
|
+
'0x80040216': {
|
|
21
|
+
summary: '对象不存在:按这个名字或 id 找不到记录',
|
|
22
|
+
hint: '先确认环境对不对(不同环境的自定义表可用性不同),再用不带 filter 的查询确认记录真的存在。',
|
|
23
|
+
},
|
|
24
|
+
'0x80040217': {
|
|
25
|
+
summary: '对象不存在:按这个名字或 id 找不到记录',
|
|
26
|
+
hint: '先确认环境对不对(不同环境的自定义表可用性不同),再用不带 filter 的查询确认记录真的存在。',
|
|
27
|
+
},
|
|
28
|
+
'0x80040333': {
|
|
29
|
+
summary: '重复检测规则拦截了这次创建:目标里已有匹配的记录',
|
|
30
|
+
hint: '先查目标环境里是否已有同名/同键记录,按需要改成 update 或对这条记录开启 MSCRM.SuppressDuplicateDetection。',
|
|
31
|
+
},
|
|
32
|
+
'0x80044150': {
|
|
33
|
+
summary: '记录在读取之后被别人改过(并发冲突 / 状态已变化)',
|
|
34
|
+
hint: '重新读一次当前行再决定怎么写;如果是状态字段,确认它现在是不是已经是你想要的值。',
|
|
35
|
+
},
|
|
36
|
+
'0x80048423': {
|
|
37
|
+
summary: '组件属于某个受管解决方案,不能在这里直接改',
|
|
38
|
+
hint: '受管组件只能在源环境改,再作为新版本导入;要在当前环境改就先把它变成非受管(或建一个新的非受管组件)。',
|
|
39
|
+
},
|
|
40
|
+
'0x8004a0f6': {
|
|
41
|
+
summary: '操作违反了当前记录状态:状态机不允许这一步',
|
|
42
|
+
hint: '先看 statecode/statuscode 与业务流程阶段,撤销/激活类操作只能在特定状态下执行。',
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Pull the first `0x........` token out of a string. */
|
|
47
|
+
export function errorCodeFromText(text) {
|
|
48
|
+
const match = /0x[0-9a-fA-F]{8}/.exec(String(text ?? ''))
|
|
49
|
+
return match ? match[0].toLowerCase() : undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Pull the Dataverse error code out of a decoded response body.
|
|
54
|
+
* Dataverse puts it in `error.code`, but some endpoints put it in the message.
|
|
55
|
+
*/
|
|
56
|
+
export function errorCodeFromBody(body) {
|
|
57
|
+
if (!body) return undefined
|
|
58
|
+
if (typeof body === 'string') return errorCodeFromText(body)
|
|
59
|
+
const root = body.error ?? body
|
|
60
|
+
const code = root?.code ?? root?.Code ?? root?.errorcode
|
|
61
|
+
if (typeof code === 'number') return `0x${code.toString(16).padStart(8, '0')}`
|
|
62
|
+
if (code) {
|
|
63
|
+
const text = errorCodeFromText(code)
|
|
64
|
+
if (text) return text
|
|
65
|
+
}
|
|
66
|
+
return errorCodeFromText(root?.message ?? root?.Message)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** @returns the explanation for a known code, or undefined. */
|
|
70
|
+
export function explainErrorCode(code) {
|
|
71
|
+
const key = String(code ?? '').toLowerCase()
|
|
72
|
+
return ERROR_CODES[key]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** @returns a one-line hint for a known code, or undefined. */
|
|
76
|
+
export function hintForErrorCode(code) {
|
|
77
|
+
const entry = explainErrorCode(code)
|
|
78
|
+
if (!entry) return undefined
|
|
79
|
+
return `${entry.summary}。${entry.hint}`
|
|
80
|
+
}
|
package/lib/gitlab.js
CHANGED
|
@@ -1,161 +1,161 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Read-only GitLab access for the diff-driven code check.
|
|
3
|
-
*
|
|
4
|
-
* Only one thing is needed: the list of files a merge request touches, with
|
|
5
|
-
* their unified diffs. Everything else (mapping paths to Dataverse components,
|
|
6
|
-
* comparing against an environment) lives in `code-check.js`.
|
|
7
|
-
*
|
|
8
|
-
* The token comes from `D365_GITLAB_TOKEN` / `GITLAB_TOKEN`, else from the
|
|
9
|
-
* secrets file the settings page writes. It never travels to the browser.
|
|
10
|
-
*/
|
|
11
|
-
import { readGitlabToken } from './config.js'
|
|
12
|
-
import { D365Error } from './errors.js'
|
|
13
|
-
|
|
14
|
-
const TIMEOUT_MS = 30_000
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Parse a GitLab merge-request URL.
|
|
18
|
-
* @param value - e.g. `https://gitlab.example.com/g/p/-/merge_requests/42/diffs`
|
|
19
|
-
* @returns `{ base, project, iid }` or null when the string is not a MR URL.
|
|
20
|
-
*/
|
|
21
|
-
export function parseMergeRequestUrl(value) {
|
|
22
|
-
const text = String(value ?? '').trim()
|
|
23
|
-
const match = text.match(/^(https?:\/\/[^/]+)\/(.+?)\/-\/merge_requests\/(\d+)/i)
|
|
24
|
-
if (!match) return null
|
|
25
|
-
return { base: match[1], project: match[2], iid: match[3] }
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** `https://host` → `https://host/api/v4`, tolerating a trailing slash. */
|
|
29
|
-
export function apiBase(url) {
|
|
30
|
-
return `${String(url ?? '').trim().replace(/\/+$/, '')}/api/v4`
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function describeHttp(status, body) {
|
|
34
|
-
const message = String(body ?? '').slice(0, 300).replace(/\s+/g, ' ')
|
|
35
|
-
if (status === 401) return 'GitLab 令牌无效或已过期(HTTP 401)'
|
|
36
|
-
if (status === 403) {
|
|
37
|
-
return 'GitLab 拒绝了访问(HTTP 403):令牌 scope 不足,或该用户在项目里的角色低于 Reporter'
|
|
38
|
-
+ '(项目访问令牌的角色若是 Guest,读不到 MR 与仓库)。把角色改成 Reporter 以上,或改用个人访问令牌'
|
|
39
|
-
}
|
|
40
|
-
if (status === 404) return 'GitLab 找不到项目或 MR(HTTP 404),检查项目路径与 IID'
|
|
41
|
-
return `GitLab 返回 HTTP ${status}:${message}`
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Call one GitLab API path and parse JSON.
|
|
46
|
-
* @param config - loaded plugin config (supplies `gitlab.url` and the secrets path).
|
|
47
|
-
* @param path - path beginning with `/`, e.g. `/projects/g%2Fp`.
|
|
48
|
-
*/
|
|
49
|
-
export async function gitlabGet(config, path, signal) {
|
|
50
|
-
const url = config.gitlab?.url
|
|
51
|
-
const token = await readGitlabToken(config)
|
|
52
|
-
if (!url) {
|
|
53
|
-
throw new D365Error('没有配置 GitLab 地址', {
|
|
54
|
-
code: 'gitlab-not-configured',
|
|
55
|
-
hint: '在设置 →「D365 环境」→ GitLab 里填仓库地址与访问令牌。',
|
|
56
|
-
})
|
|
57
|
-
}
|
|
58
|
-
if (!token.ok) {
|
|
59
|
-
throw new D365Error('没有配置 GitLab 访问令牌', {
|
|
60
|
-
code: 'gitlab-no-token',
|
|
61
|
-
hint: '在设置页填只读令牌(read_api),或设置环境变量 D365_GITLAB_TOKEN。',
|
|
62
|
-
})
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const controller = new AbortController()
|
|
66
|
-
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
|
|
67
|
-
const onAbort = () => controller.abort()
|
|
68
|
-
signal?.addEventListener?.('abort', onAbort, { once: true })
|
|
69
|
-
try {
|
|
70
|
-
const response = await fetch(`${apiBase(url)}${path}`, {
|
|
71
|
-
headers: { 'PRIVATE-TOKEN': token.value, accept: 'application/json' },
|
|
72
|
-
signal: controller.signal,
|
|
73
|
-
})
|
|
74
|
-
const body = await response.text()
|
|
75
|
-
if (!response.ok) {
|
|
76
|
-
throw new D365Error(describeHttp(response.status, body), { code: 'gitlab-http' })
|
|
77
|
-
}
|
|
78
|
-
return body ? JSON.parse(body) : null
|
|
79
|
-
} catch (error) {
|
|
80
|
-
if (error?.name === 'AbortError') {
|
|
81
|
-
throw new D365Error(`请求 GitLab 超时(${TIMEOUT_MS}ms)`, { code: 'gitlab-timeout' })
|
|
82
|
-
}
|
|
83
|
-
throw error
|
|
84
|
-
} finally {
|
|
85
|
-
clearTimeout(timer)
|
|
86
|
-
signal?.removeEventListener?.('abort', onAbort)
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Fetch one merge request's changes.
|
|
92
|
-
* @param config - loaded plugin config.
|
|
93
|
-
* @param request - `{ mergeRequest, project? }`; mergeRequest may be a URL, an
|
|
94
|
-
* IID, or `!123`.
|
|
95
|
-
* @returns `{ project, iid, title, webUrl, sourceBranch, targetBranch, changes }`
|
|
96
|
-
* where each change is `{ oldPath, newPath, newFile, deletedFile, renamedFile, diff }`.
|
|
97
|
-
*/
|
|
98
|
-
export async function fetchMergeRequest(config, request, signal) {
|
|
99
|
-
const raw = String(request?.mergeRequest ?? '').trim()
|
|
100
|
-
if (!raw) {
|
|
101
|
-
throw new D365Error('缺少 mergeRequest', {
|
|
102
|
-
code: 'missing-merge-request',
|
|
103
|
-
hint: '传 MR 链接,或项目路径 + IID。',
|
|
104
|
-
})
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const parsed = parseMergeRequestUrl(raw)
|
|
108
|
-
const projectPath = parsed?.project
|
|
109
|
-
?? String(request?.project ?? config.gitlab?.defaultProject ?? '').trim()
|
|
110
|
-
const iid = parsed?.iid ?? raw.replace(/^!/, '')
|
|
111
|
-
|
|
112
|
-
if (!projectPath) {
|
|
113
|
-
throw new D365Error('无法确定 GitLab 项目', {
|
|
114
|
-
code: 'gitlab-no-project',
|
|
115
|
-
hint: '传完整的 MR 链接,或在设置页填默认项目(group/project)。',
|
|
116
|
-
})
|
|
117
|
-
}
|
|
118
|
-
if (!/^\d+$/.test(iid)) {
|
|
119
|
-
throw new D365Error(`MR IID 不是数字:${iid}`, {
|
|
120
|
-
code: 'invalid-merge-request',
|
|
121
|
-
hint: '例如 https://gitlab.example.com/group/project/-/merge_requests/42',
|
|
122
|
-
})
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const data = await gitlabGet(
|
|
126
|
-
config,
|
|
127
|
-
`/projects/${encodeURIComponent(projectPath)}/merge_requests/${iid}/changes`,
|
|
128
|
-
signal,
|
|
129
|
-
)
|
|
130
|
-
|
|
131
|
-
return {
|
|
132
|
-
project: data?.project_id ? String(projectPath) : projectPath,
|
|
133
|
-
iid,
|
|
134
|
-
title: data?.title ?? '',
|
|
135
|
-
webUrl: data?.web_url ?? (parsed ? raw.split('/diffs')[0] : ''),
|
|
136
|
-
sourceBranch: data?.source_branch ?? '',
|
|
137
|
-
targetBranch: data?.target_branch ?? '',
|
|
138
|
-
changes: (data?.changes ?? []).map((change) => ({
|
|
139
|
-
oldPath: change.old_path ?? change.new_path,
|
|
140
|
-
newPath: change.new_path ?? change.old_path,
|
|
141
|
-
newFile: change.new_file === true,
|
|
142
|
-
deletedFile: change.deleted_file === true,
|
|
143
|
-
renamedFile: change.renamed_file === true,
|
|
144
|
-
diff: change.diff ?? '',
|
|
145
|
-
})),
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* Prove the configured token can reach the configured host.
|
|
151
|
-
* @returns `{ ok, user?, error? }` — never throws, so the settings page can
|
|
152
|
-
* show the result next to the form.
|
|
153
|
-
*/
|
|
154
|
-
export async function checkGitlab(config) {
|
|
155
|
-
try {
|
|
156
|
-
const user = await gitlabGet(config, '/user')
|
|
157
|
-
return { ok: true, user: user?.username ?? '', name: user?.name ?? '' }
|
|
158
|
-
} catch (error) {
|
|
159
|
-
return { ok: false, error: error.message, hint: error.hint }
|
|
160
|
-
}
|
|
161
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Read-only GitLab access for the diff-driven code check.
|
|
3
|
+
*
|
|
4
|
+
* Only one thing is needed: the list of files a merge request touches, with
|
|
5
|
+
* their unified diffs. Everything else (mapping paths to Dataverse components,
|
|
6
|
+
* comparing against an environment) lives in `code-check.js`.
|
|
7
|
+
*
|
|
8
|
+
* The token comes from `D365_GITLAB_TOKEN` / `GITLAB_TOKEN`, else from the
|
|
9
|
+
* secrets file the settings page writes. It never travels to the browser.
|
|
10
|
+
*/
|
|
11
|
+
import { readGitlabToken } from './config.js'
|
|
12
|
+
import { D365Error } from './errors.js'
|
|
13
|
+
|
|
14
|
+
const TIMEOUT_MS = 30_000
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Parse a GitLab merge-request URL.
|
|
18
|
+
* @param value - e.g. `https://gitlab.example.com/g/p/-/merge_requests/42/diffs`
|
|
19
|
+
* @returns `{ base, project, iid }` or null when the string is not a MR URL.
|
|
20
|
+
*/
|
|
21
|
+
export function parseMergeRequestUrl(value) {
|
|
22
|
+
const text = String(value ?? '').trim()
|
|
23
|
+
const match = text.match(/^(https?:\/\/[^/]+)\/(.+?)\/-\/merge_requests\/(\d+)/i)
|
|
24
|
+
if (!match) return null
|
|
25
|
+
return { base: match[1], project: match[2], iid: match[3] }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** `https://host` → `https://host/api/v4`, tolerating a trailing slash. */
|
|
29
|
+
export function apiBase(url) {
|
|
30
|
+
return `${String(url ?? '').trim().replace(/\/+$/, '')}/api/v4`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function describeHttp(status, body) {
|
|
34
|
+
const message = String(body ?? '').slice(0, 300).replace(/\s+/g, ' ')
|
|
35
|
+
if (status === 401) return 'GitLab 令牌无效或已过期(HTTP 401)'
|
|
36
|
+
if (status === 403) {
|
|
37
|
+
return 'GitLab 拒绝了访问(HTTP 403):令牌 scope 不足,或该用户在项目里的角色低于 Reporter'
|
|
38
|
+
+ '(项目访问令牌的角色若是 Guest,读不到 MR 与仓库)。把角色改成 Reporter 以上,或改用个人访问令牌'
|
|
39
|
+
}
|
|
40
|
+
if (status === 404) return 'GitLab 找不到项目或 MR(HTTP 404),检查项目路径与 IID'
|
|
41
|
+
return `GitLab 返回 HTTP ${status}:${message}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Call one GitLab API path and parse JSON.
|
|
46
|
+
* @param config - loaded plugin config (supplies `gitlab.url` and the secrets path).
|
|
47
|
+
* @param path - path beginning with `/`, e.g. `/projects/g%2Fp`.
|
|
48
|
+
*/
|
|
49
|
+
export async function gitlabGet(config, path, signal) {
|
|
50
|
+
const url = config.gitlab?.url
|
|
51
|
+
const token = await readGitlabToken(config)
|
|
52
|
+
if (!url) {
|
|
53
|
+
throw new D365Error('没有配置 GitLab 地址', {
|
|
54
|
+
code: 'gitlab-not-configured',
|
|
55
|
+
hint: '在设置 →「D365 环境」→ GitLab 里填仓库地址与访问令牌。',
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
if (!token.ok) {
|
|
59
|
+
throw new D365Error('没有配置 GitLab 访问令牌', {
|
|
60
|
+
code: 'gitlab-no-token',
|
|
61
|
+
hint: '在设置页填只读令牌(read_api),或设置环境变量 D365_GITLAB_TOKEN。',
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const controller = new AbortController()
|
|
66
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
|
|
67
|
+
const onAbort = () => controller.abort()
|
|
68
|
+
signal?.addEventListener?.('abort', onAbort, { once: true })
|
|
69
|
+
try {
|
|
70
|
+
const response = await fetch(`${apiBase(url)}${path}`, {
|
|
71
|
+
headers: { 'PRIVATE-TOKEN': token.value, accept: 'application/json' },
|
|
72
|
+
signal: controller.signal,
|
|
73
|
+
})
|
|
74
|
+
const body = await response.text()
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
throw new D365Error(describeHttp(response.status, body), { code: 'gitlab-http' })
|
|
77
|
+
}
|
|
78
|
+
return body ? JSON.parse(body) : null
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (error?.name === 'AbortError') {
|
|
81
|
+
throw new D365Error(`请求 GitLab 超时(${TIMEOUT_MS}ms)`, { code: 'gitlab-timeout' })
|
|
82
|
+
}
|
|
83
|
+
throw error
|
|
84
|
+
} finally {
|
|
85
|
+
clearTimeout(timer)
|
|
86
|
+
signal?.removeEventListener?.('abort', onAbort)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Fetch one merge request's changes.
|
|
92
|
+
* @param config - loaded plugin config.
|
|
93
|
+
* @param request - `{ mergeRequest, project? }`; mergeRequest may be a URL, an
|
|
94
|
+
* IID, or `!123`.
|
|
95
|
+
* @returns `{ project, iid, title, webUrl, sourceBranch, targetBranch, changes }`
|
|
96
|
+
* where each change is `{ oldPath, newPath, newFile, deletedFile, renamedFile, diff }`.
|
|
97
|
+
*/
|
|
98
|
+
export async function fetchMergeRequest(config, request, signal) {
|
|
99
|
+
const raw = String(request?.mergeRequest ?? '').trim()
|
|
100
|
+
if (!raw) {
|
|
101
|
+
throw new D365Error('缺少 mergeRequest', {
|
|
102
|
+
code: 'missing-merge-request',
|
|
103
|
+
hint: '传 MR 链接,或项目路径 + IID。',
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const parsed = parseMergeRequestUrl(raw)
|
|
108
|
+
const projectPath = parsed?.project
|
|
109
|
+
?? String(request?.project ?? config.gitlab?.defaultProject ?? '').trim()
|
|
110
|
+
const iid = parsed?.iid ?? raw.replace(/^!/, '')
|
|
111
|
+
|
|
112
|
+
if (!projectPath) {
|
|
113
|
+
throw new D365Error('无法确定 GitLab 项目', {
|
|
114
|
+
code: 'gitlab-no-project',
|
|
115
|
+
hint: '传完整的 MR 链接,或在设置页填默认项目(group/project)。',
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
if (!/^\d+$/.test(iid)) {
|
|
119
|
+
throw new D365Error(`MR IID 不是数字:${iid}`, {
|
|
120
|
+
code: 'invalid-merge-request',
|
|
121
|
+
hint: '例如 https://gitlab.example.com/group/project/-/merge_requests/42',
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const data = await gitlabGet(
|
|
126
|
+
config,
|
|
127
|
+
`/projects/${encodeURIComponent(projectPath)}/merge_requests/${iid}/changes`,
|
|
128
|
+
signal,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
project: data?.project_id ? String(projectPath) : projectPath,
|
|
133
|
+
iid,
|
|
134
|
+
title: data?.title ?? '',
|
|
135
|
+
webUrl: data?.web_url ?? (parsed ? raw.split('/diffs')[0] : ''),
|
|
136
|
+
sourceBranch: data?.source_branch ?? '',
|
|
137
|
+
targetBranch: data?.target_branch ?? '',
|
|
138
|
+
changes: (data?.changes ?? []).map((change) => ({
|
|
139
|
+
oldPath: change.old_path ?? change.new_path,
|
|
140
|
+
newPath: change.new_path ?? change.old_path,
|
|
141
|
+
newFile: change.new_file === true,
|
|
142
|
+
deletedFile: change.deleted_file === true,
|
|
143
|
+
renamedFile: change.renamed_file === true,
|
|
144
|
+
diff: change.diff ?? '',
|
|
145
|
+
})),
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Prove the configured token can reach the configured host.
|
|
151
|
+
* @returns `{ ok, user?, error? }` — never throws, so the settings page can
|
|
152
|
+
* show the result next to the form.
|
|
153
|
+
*/
|
|
154
|
+
export async function checkGitlab(config) {
|
|
155
|
+
try {
|
|
156
|
+
const user = await gitlabGet(config, '/user')
|
|
157
|
+
return { ok: true, user: user?.username ?? '', name: user?.name ?? '' }
|
|
158
|
+
} catch (error) {
|
|
159
|
+
return { ok: false, error: error.message, hint: error.hint }
|
|
160
|
+
}
|
|
161
|
+
}
|