@agile-team/wl-skills-gate 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/README.md +6 -6
- package/bin/wl-quality-setup.js +3 -3
- package/bin/wl-quality.js +28 -0
- package/bin/wl-skills-gate.js +27 -2
- package/docs/01-quick-start.md +13 -6
- package/docs/07-jenkins.md +95 -3
- package/docs/16-project-ci-integration.md +8 -8
- package/docs/17-onboarding.md +37 -7
- package/docs/README.md +4 -4
- package/lib/automation.js +40 -12
- package/lib/build-artifacts.js +42 -0
- package/lib/central-check-notification.js +33 -0
- package/lib/ci-discovery.js +9 -3
- package/lib/ci-source.js +25 -4
- package/lib/feishu-members.js +43 -0
- package/lib/governance-notifications.js +50 -20
- package/lib/governance.js +43 -10
- package/lib/jenkins-bootstrap.js +83 -0
- package/lib/jenkins-cluster.js +30 -0
- package/lib/jenkins-dispatch.js +110 -0
- package/lib/jenkins-entry-contract.js +16 -0
- package/lib/jenkins-fleet-client.js +17 -0
- package/lib/jenkins-fleet.js +190 -0
- package/lib/jenkins-installation.js +28 -0
- package/lib/jenkins-integration.js +251 -0
- package/lib/jenkins-job-config.js +80 -0
- package/lib/jenkins-node-bootstrap.js +39 -0
- package/lib/jenkins-readiness.js +21 -0
- package/lib/jenkins-runtime.js +123 -0
- package/lib/lifecycle-source.js +23 -0
- package/lib/notification-people.js +25 -0
- package/lib/notification-style.js +39 -0
- package/lib/notification-time.js +15 -0
- package/lib/onboard.js +1 -1
- package/lib/pipeline-notification.js +72 -0
- package/lib/project-actions.js +305 -0
- package/lib/project-notification-scope.js +18 -0
- package/lib/project-notification.js +70 -0
- package/lib/project-review-report.js +88 -0
- package/lib/release-review.js +13 -1
- package/lib/report-publication.js +93 -0
- package/lib/review-auto.js +7 -1
- package/lib/setup-central.js +12 -2
- package/lib/setup-modes.js +19 -0
- package/lib/setup.js +8 -4
- package/npm-shrinkwrap.json +4 -3
- package/package.json +4 -2
- package/scripts/feishu-sync-members.mjs +22 -0
- package/scripts/lib-tests/automation.test.mjs +11 -0
- package/scripts/lib-tests/build-findings-admission.test.mjs +44 -0
- package/scripts/lib-tests/ci-discovery.test.mjs +16 -0
- package/scripts/lib-tests/ci-ops-entry.test.mjs +32 -0
- package/scripts/lib-tests/ci-source.test.mjs +25 -2
- package/scripts/lib-tests/feishu-members.test.mjs +31 -0
- package/scripts/lib-tests/governance-lifecycle.test.mjs +2 -2
- package/scripts/lib-tests/governance.test.mjs +11 -1
- package/scripts/lib-tests/jenkins-bootstrap.test.mjs +84 -0
- package/scripts/lib-tests/jenkins-fleet.test.mjs +118 -0
- package/scripts/lib-tests/jenkins-full-chain.test.mjs +182 -0
- package/scripts/lib-tests/jenkins-integration.test.mjs +104 -0
- package/scripts/lib-tests/jenkins-native.test.mjs +50 -0
- package/scripts/lib-tests/onboard.test.mjs +1 -1
- package/scripts/lib-tests/pipeline-notification.test.mjs +80 -0
- package/scripts/lib-tests/prevention-governance-chain.test.mjs +3 -3
- package/scripts/lib-tests/project-notification.test.mjs +187 -0
- package/scripts/lib-tests/report-notification-v12.test.mjs +72 -8
- package/scripts/lib-tests/report-publication.test.mjs +50 -0
- package/scripts/lib-tests/review-auto.test.mjs +11 -0
- package/scripts/lib-tests/setup-central.test.mjs +11 -1
- package/scripts/test-support/control-review-fixture.mjs +6 -1
- package/scripts/test-support/jenkins-fixture.mjs +48 -0
- package/scripts/test-support/review-collect-fixture.mjs +4 -3
- package/scripts/verify-quality-chain.mjs +34 -0
- package/templates/ci/Jenkinsfile +15 -2
- package/templates/ci/Jenkinsfile.automation +32 -13
- package/templates/ci/Jenkinsfile.fleet +72 -0
- package/templates/ci/Jenkinsfile.gate +29 -0
- package/templates/ci/Jenkinsfile.node-preflight +23 -0
- package/templates/ci/Jenkinsfile.quality-build +75 -0
- package/templates/ci/Jenkinsfile.quality-check +52 -0
- package/templates/ci/Jenkinsfile.review-sign +23 -18
- package/templates/ci/Jenkinsfile.verify-jobs +24 -0
- package/templates/ci/assert-build-admission.mjs +14 -0
- package/templates/ci/check-project.config.json +12 -0
- package/templates/ci/check-project.mjs +65 -0
- package/templates/ci/gitlab-backend.yml +7 -3
- package/templates/ci/gitlab-frontend.yml +7 -3
- package/templates/ci/jenkins-bootstrap.py +196 -0
- package/templates/ci/jenkins-gate.groovy +76 -0
- package/templates/ci/jenkins-loader.groovy +23 -0
- package/templates/ci/jenkins-native.groovy +145 -0
- package/templates/ci/jenkins-quality-report.groovy +47 -0
- package/templates/ci/jenkins.seed.json +22 -0
- package/templates/ci/run-approved-build.mjs +20 -0
- package/templates/ci/wl-quality-automation.service +1 -1
- package/templates/onboarding/automation/project-actions.mjs +17 -298
- package/templates/onboarding/backend/quality-tools.package.json +1 -1
- package/templates/platform/README.md +7 -5
- package/templates/platform/automation-config.jenkins-test.json +48 -0
- package/templates/platform/ci-source.jenkins-build.json +30 -0
- package/templates/platform/ci-source.jenkins-test.json +37 -0
- package/templates/platform/ci-source.jenkins.json +7 -1
- package/templates/platform/governance-config.json +34 -10
- package/templates/platform/jenkins-cluster.json +9 -0
- package/templates/platform/jenkins-fleet.json +16 -0
- package/templates/platform/release-review-trust.json +4 -2
- package/templates/quality/rules.lock.json +1 -1
- package/templates/scripts-quality/run.mjs +1 -1
package/lib/automation.js
CHANGED
|
@@ -4,19 +4,21 @@ import { createHash, createPrivateKey, createPublicKey, randomUUID, verify } fro
|
|
|
4
4
|
import { loadCiSourceConfig, collectCiSource } from "./ci-source.js";
|
|
5
5
|
import { discoverCiRuns } from "./ci-discovery.js";
|
|
6
6
|
import { loadGovernanceConfig, signPlatformReceipt, ingestGovernance, ingestLifecycleGovernance, reconcileGovernance, dispatchGovernance, readGovernanceState } from "./governance.js";
|
|
7
|
-
import { createBuildLifecycle, createReviewLifecycle } from "./lifecycle-source.js";
|
|
7
|
+
import { createBuildLifecycle, createValidationBuildLifecycle, createReviewLifecycle } from "./lifecycle-source.js";
|
|
8
|
+
import {BUILD_RESULT_PATH,BUILD_ARCHIVE_PATH} from './build-artifacts.js';
|
|
8
9
|
import { controlledPath, readControlledJson, writeExclusiveJson, lockedJson } from "./control-store.js";
|
|
9
10
|
import { stableStringify } from "./util.js";
|
|
10
11
|
import { createCiReview } from "./review-ci.js";
|
|
11
12
|
import { ingestReviewPublications } from "./review-publication.js";
|
|
12
13
|
import { ingestDeploymentDeliveries } from "./deployment-delivery.js";
|
|
14
|
+
import {loadBuildDispatch,dispatchApprovedBuilds} from './jenkins-dispatch.js';
|
|
13
15
|
const assert = (value, message) => { if (!value) throw new Error(message); };
|
|
14
16
|
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
|
15
17
|
const safeId = (value) => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/.test(value) && !["__proto__", "constructor", "prototype"].includes(value);
|
|
16
18
|
|
|
17
19
|
export function loadAutomationConfig(file) {
|
|
18
20
|
const c = readControlledJson(file), base = path.dirname(path.resolve(file));
|
|
19
|
-
assert(c.schemaVersion === 1 && Array.isArray(c.sources) && c.sources.length > 0 && c.sources.length <=
|
|
21
|
+
assert(c.schemaVersion === 1 && Array.isArray(c.sources) && c.sources.length > 0 && c.sources.length <= 1000, "自动执行器必须登记固定CI来源");
|
|
20
22
|
for (const field of ["governanceConfigFile", "stateFile", "dataDir", "receiptPrivateKeyFile"]) { assert(c[field], `自动执行器缺少${field}`); c[field] = controlledPath(path.resolve(base, c[field])); }
|
|
21
23
|
const g = loadGovernanceConfig(c.governanceConfigFile), key = createPrivateKey(readFileSync(c.receiptPrivateKeyFile));
|
|
22
24
|
assert(key.asymmetricKeyType === "ed25519" && createPublicKey(key).export({ type: "spki", format: "der" }).equals(createPublicKey(readFileSync(g.publicKeyFile)).export({ type: "spki", format: "der" })), "自动执行器事件密钥未绑定治理公钥");
|
|
@@ -24,12 +26,18 @@ export function loadAutomationConfig(file) {
|
|
|
24
26
|
for (const source of c.sources) {
|
|
25
27
|
assert(safeId(source.id) && source.configFile && source.actors && Object.keys(source.actors).length > 0 && Object.values(source.actors).every(safeId), "CI来源编号/已认证用户映射缺失");
|
|
26
28
|
source.configFile = controlledPath(path.resolve(base, source.configFile)); source.config = loadCiSourceConfig(source.configFile);
|
|
27
|
-
const scopeKey = source.config.project + ":" + source.config.scope;
|
|
28
|
-
assert(!seen.has(source.id) && !seen.has(scopeKey), "
|
|
29
|
+
const scopeKey = source.config.project + ":" + source.config.scope + (source.buildResultPath?':build':':quality');
|
|
30
|
+
assert(!seen.has(source.id) && !seen.has(scopeKey), "一个项目范围的同类检查/构建只能绑定一个CI来源,避免运行顺序串线"); seen.add(source.id); seen.add(scopeKey);
|
|
29
31
|
assert(g.projects[source.config.project]?.scopes[source.config.scope] && g.allowedLinkOrigins.includes(source.config.baseUrl), "CI来源项目/范围/链接未在治理批准");
|
|
30
32
|
assert(typeof source.since === "string" && Number.isFinite(Date.parse(source.since)), "首次发现窗口必须明确,禁止默认只看最新一次");
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
if(source.buildResultPath!==undefined){
|
|
34
|
+
assert(source.buildResultPath===BUILD_RESULT_PATH && source.manifestPath===undefined && source.environment===undefined && source.config.system==='jenkins' && !source.config.qualitySummary,'验证构建来源需独立BUILD Job与固定结果,不声明部署环境');
|
|
35
|
+
assert([BUILD_RESULT_PATH,BUILD_ARCHIVE_PATH].every(p=>source.config.artifacts.paths.includes(p)),'验证构建来源缺少固定归档');
|
|
36
|
+
}
|
|
37
|
+
if (source.manifestPath !== undefined) {
|
|
38
|
+
assert(["dev", "sit", "uat", "pre", "prd"].includes(source.environment), "必须固定构建目标环境");
|
|
39
|
+
assert(source.config.artifacts.paths.includes(source.manifestPath), "构建清单必须登记在固定来源工件内");
|
|
40
|
+
} else assert(source.environment === undefined || source.environment === null || ["dev", "sit", "uat", "pre", "prd"].includes(source.environment), "纯检查来源不需要构建环境;不能用test冒充正式发布环境");
|
|
33
41
|
if (source.review !== undefined) {
|
|
34
42
|
assert(source.review && typeof source.review.configFile === "string" && typeof source.review.outRoot === "string", "自动Review必须固定接线配置和中央证据根目录");
|
|
35
43
|
source.review.configFile = controlledPath(path.resolve(base, source.review.configFile)); source.review.outRoot = controlledPath(path.resolve(base, source.review.outRoot));
|
|
@@ -38,6 +46,17 @@ export function loadAutomationConfig(file) {
|
|
|
38
46
|
assert(source.review.publication.expectAuthorizationIssuer && /^[a-f0-9]{64}$/.test(source.review.publication.expectReviewPolicyHash || ""), "正式Review订阅身份/策略摘要无效");
|
|
39
47
|
}
|
|
40
48
|
}
|
|
49
|
+
if(source.buildDispatch){
|
|
50
|
+
assert(source.review?.publication,'自动续建须先登记正式Review交付');
|
|
51
|
+
source.buildDispatch.registryFile=controlledPath(path.resolve(base,source.buildDispatch.registryFile));
|
|
52
|
+
loadBuildDispatch({config:source.buildDispatch,ciConfigFile:source.configFile});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
for(const source of c.sources)if(source.buildResultPath){
|
|
56
|
+
const companion=c.sources.filter(other=>other!==source && other.config.qualitySummary && other.config.project===source.config.project && other.config.scope===source.config.scope && other.config.ref.replace(/^(?:origin\/|refs\/heads\/)/,'')===source.config.ref.replace(/^(?:origin\/|refs\/heads\/)/,''));
|
|
57
|
+
assert(companion.length===1 && companion[0].buildDispatch,'验证构建须绑定同范围CHECK与正式Review续建,不能独立冒充完整九维');
|
|
58
|
+
const dispatch=loadBuildDispatch({config:companion[0].buildDispatch,ciConfigFile:companion[0].configFile});
|
|
59
|
+
assert(dispatch.b.jobs.build===source.config.jobPath && dispatch.registry.jenkinsOrigin===source.config.baseUrl+'/','验证构建采集必须对应受控续建的实际BUILD任务');
|
|
41
60
|
}
|
|
42
61
|
if (c.deployments !== undefined) {
|
|
43
62
|
assert(c.deployments && typeof c.deployments.deliveryDir === "string" && c.deployments.configs && typeof c.deployments.configs === "object" && !Array.isArray(c.deployments.configs) && Object.keys(c.deployments.configs).length > 0, "部署回流必须登记固定交付目录和配置映射");
|
|
@@ -57,9 +76,11 @@ export async function automationTick({ configFile, send = false, env = process.e
|
|
|
57
76
|
const monotonicStart = performance.now(), currentInstant = () => now + Math.ceil(performance.now() - monotonicStart);
|
|
58
77
|
const c = loadAutomationConfig(configFile), executionId = `tick-${randomUUID()}`, tickDir = path.join(c.dataDir, executionId);
|
|
59
78
|
const binding = hash(stableStringify({ ...c, governance: c.governance }));
|
|
79
|
+
assert(!existsSync(path.join(c.dataDir, "fleet-maintenance.json")), "批量接入维护中;完成原批次续跑或回退,不删除业务台账");
|
|
60
80
|
// Acquire the scheduler lock across discovery, receipt ingestion and outbox;
|
|
61
81
|
// governance has a separate lock so authenticated actions remain serializable.
|
|
62
82
|
return lockedJson(path.join(c.dataDir, "automation-state.json"), { schemaVersion: 1, binding, runs: {}, history: [], health: {}, discovery: {}, reviewPublications: {} }, async (state, checkpoint) => {
|
|
83
|
+
assert(!existsSync(path.join(c.dataDir, "fleet-maintenance.json")), "批量接入维护中,稍后再采集");
|
|
63
84
|
assert(state.schemaVersion === 1 && state.binding === binding && state.runs && Array.isArray(state.history) && state.health && typeof state.health === "object" && !Array.isArray(state.health), "自动执行器配置身份变化或状态损坏,不能静默重置");
|
|
64
85
|
state.discovery ??= {}; state.reviewPublications ??= {};
|
|
65
86
|
// Fail before querying/signing anything if the ledger itself cannot be read.
|
|
@@ -119,7 +140,7 @@ export async function automationTick({ configFile, send = false, env = process.e
|
|
|
119
140
|
assert(Number.isSafeInteger(Number(run.runId)) && Number(run.runId) > 0 && Number(run.runId) <= Math.floor((Number.MAX_SAFE_INTEGER - 9999) / 10000) && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(run.sha), "发现的流水线身份超出顺序预算");
|
|
120
141
|
assert(new URL(run.pipelineUrl).origin === source.config.baseUrl && run.ref === source.config.ref && Number.isFinite(Date.parse(run.dueAt)), "发现的流水线范围/截止时间无效");
|
|
121
142
|
if (record) assert(record.sha === run.sha && record.project === source.config.project && record.scope === source.config.scope, "同runId源码身份发生变化,禁止覆盖原证据");
|
|
122
|
-
else record = state.runs[runKey] = { project: source.config.project, scope: source.config.scope, sha: run.sha, pipelineId: String(run.runId), pipelineUrl: run.pipelineUrl, dueAt: run.dueAt, generation: 0, revisions: {} };
|
|
143
|
+
else record = state.runs[runKey] = { project: source.config.project, scope: source.config.scope, qualitySource:!!source.config.qualitySummary,sha: run.sha, pipelineId: String(run.runId), pipelineUrl: run.pipelineUrl, dueAt: run.dueAt, generation: 0, revisions: {} };
|
|
123
144
|
assert(record.revisions && typeof record.revisions === "object" && Number.isSafeInteger(record.generation), "运行进度损坏,不能重置");
|
|
124
145
|
const revision = run.observedRevision || hash(stableStringify({ status: run.status, missingJobs: run.missingJobs, actorSource: run.actorSource, sha: run.sha }));
|
|
125
146
|
assert(/^[a-f0-9]{64}$/.test(revision), "发现快照摘要无效");
|
|
@@ -128,7 +149,7 @@ export async function automationTick({ configFile, send = false, env = process.e
|
|
|
128
149
|
if (!progress.qualityDone && alreadyIngested(progress.qualityReceiptFile, progress.collection.sourceFile, progress.collection.summaryFile)) progress.qualityDone = true;
|
|
129
150
|
if (!progress.buildDone && alreadyIngested(progress.buildReceiptFile, progress.build?.sourceFile)) progress.buildDone = true;
|
|
130
151
|
if (!progress.reviewDone && alreadyIngested(progress.reviewReceiptFile, progress.reviewLifecycle?.sourceFile)) progress.reviewDone = true;
|
|
131
|
-
if ((!progress.collection.eventFile || progress.qualityDone) && (!source.manifestPath || progress.buildDone) && (!source.review || progress.review && progress.reviewDone)) progress.done = true;
|
|
152
|
+
if ((!progress.collection.eventFile || progress.qualityDone) && (!(source.manifestPath || source.buildResultPath) || progress.buildDone) && (!source.review || progress.review && progress.reviewDone)) progress.done = true;
|
|
132
153
|
checkpoint();
|
|
133
154
|
}
|
|
134
155
|
if (progress?.collection && !progress.done) {
|
|
@@ -173,9 +194,10 @@ export async function automationTick({ configFile, send = false, env = process.e
|
|
|
173
194
|
await afterStage("quality-ingested", { source: source.id, runId: run.runId, revision });
|
|
174
195
|
progress.qualityDone = true; checkpoint();
|
|
175
196
|
}
|
|
176
|
-
if (source.manifestPath && !progress.buildDone) {
|
|
197
|
+
if ((source.manifestPath || source.buildResultPath) && !progress.buildDone) {
|
|
177
198
|
if (!progress.build) {
|
|
178
|
-
|
|
199
|
+
const create=source.buildResultPath?createValidationBuildLifecycle:createBuildLifecycle;
|
|
200
|
+
progress.build = create({ configFile: source.configFile, sourceFile: collection.sourceFile, manifestPath: source.manifestPath, resultPath:source.buildResultPath,env: source.environment, sha: run.sha, outDir: path.join(tickDir, source.id, `build-${run.runId}`), now });
|
|
179
201
|
progress.buildReceiptFile = sign(readControlledJson(progress.build.eventFile), `${source.id}/build-${run.runId}`); checkpoint();
|
|
180
202
|
}
|
|
181
203
|
progress.buildReceiptFile = refreshReceipt(progress.buildReceiptFile, `${source.id}/build-${run.runId}`); checkpoint();
|
|
@@ -209,7 +231,7 @@ export async function automationTick({ configFile, send = false, env = process.e
|
|
|
209
231
|
window.pendingRunIds = window.pendingRunIds.filter((id) => { const run = state.runs[`${source.id}:${id}`]; return failedRunIds.has(id) || !run?.terminal || !run.revisions?.[run.lastObservation]?.done; }); checkpoint();
|
|
210
232
|
// Include all remembered runs, even when an API lookback no longer
|
|
211
233
|
// returns an old missing/canceled job. A later healthy run cannot erase it.
|
|
212
|
-
const expectedRuns = Object.entries(state.runs).filter(([key]) => key.slice(0, key.lastIndexOf(":")) === source.id).flatMap(([, r]) => Object.values(r.revisions).map((p) => ({ project: r.project, scope: r.scope, sha: r.sha, pipelineId: r.pipelineId, sequence: p.sequence, pipelineUrl: r.pipelineUrl, dueAt: r.dueAt })));
|
|
234
|
+
const expectedRuns = Object.entries(state.runs).filter(([key,r]) => r.qualitySource!==false && key.slice(0, key.lastIndexOf(":")) === source.id).flatMap(([, r]) => Object.values(r.revisions).map((p) => ({ project: r.project, scope: r.scope, sha: r.sha, pipelineId: r.pipelineId, sequence: p.sequence, pipelineUrl: r.pipelineUrl, dueAt: r.dueAt })));
|
|
213
235
|
item.reconciliation = reconcileGovernance({ configFile: c.governanceConfigFile, stateFile: c.stateFile, expectationsFile: sign({ schemaVersion: 1, kind: "pipeline-expectations", eventId: `query-${source.id}-${randomUUID()}`, expectedRuns }, `${source.id}/expectations`), now });
|
|
214
236
|
} catch (error) { item.errors.push({ stage: "source", reason: error.message }); }
|
|
215
237
|
if (source.review?.publication) {
|
|
@@ -219,6 +241,12 @@ export async function automationTick({ configFile, send = false, env = process.e
|
|
|
219
241
|
item.errors.push(...item.formalReviews.errors.map((e) => ({ stage: "formal-review", ...e })));
|
|
220
242
|
} catch (error) { item.errors.push({ stage: "formal-review", reason: error.message }); }
|
|
221
243
|
}
|
|
244
|
+
if(source.buildDispatch){
|
|
245
|
+
try{
|
|
246
|
+
item.buildDispatch=await dispatchApprovedBuilds({config:source.buildDispatch,ciConfigFile:source.configFile,dataDir:path.join(c.dataDir,'build-dispatch',source.id),send,env,fetchImpl,now:currentInstant(),afterStage});
|
|
247
|
+
item.errors.push(...item.buildDispatch.errors.map(e=>({stage:'build-dispatch',...e})),...item.buildDispatch.unknown.map(id=>({stage:'build-dispatch',reason:'续建结果待自动对账,不盲目重复触发',id})));
|
|
248
|
+
}catch(error){item.errors.push({stage:'build-dispatch',reason:error.message});}
|
|
249
|
+
}
|
|
222
250
|
}
|
|
223
251
|
const deploymentErrors = [];
|
|
224
252
|
if (c.deployments) {
|
|
@@ -286,7 +314,7 @@ export async function automationTick({ configFile, send = false, env = process.e
|
|
|
286
314
|
// A source outage still leaves a durable health result and allows retries /
|
|
287
315
|
// SLA escalation / other projects' pending notifications to make progress.
|
|
288
316
|
try {
|
|
289
|
-
reconcileGovernance({ configFile: c.governanceConfigFile, stateFile: c.stateFile, expectationsFile: sign({ schemaVersion: 1, kind: "pipeline-expectations", eventId: `sla-${randomUUID()}`, expectedRuns: Object.values(state.runs).flatMap((r) => Object.values(r.revisions || {}).map((p) => ({ project: r.project, scope: r.scope, sha: r.sha, pipelineId: r.pipelineId, sequence: p.sequence, pipelineUrl: r.pipelineUrl, dueAt: r.dueAt }))) }, "all-expectations"), now });
|
|
317
|
+
reconcileGovernance({ configFile: c.governanceConfigFile, stateFile: c.stateFile, expectationsFile: sign({ schemaVersion: 1, kind: "pipeline-expectations", eventId: `sla-${randomUUID()}`, expectedRuns: Object.values(state.runs).filter(r=>r.qualitySource!==false).flatMap((r) => Object.values(r.revisions || {}).map((p) => ({ project: r.project, scope: r.scope, sha: r.sha, pipelineId: r.pipelineId, sequence: p.sequence, pipelineUrl: r.pipelineUrl, dueAt: r.dueAt }))) }, "all-expectations"), now });
|
|
290
318
|
result.delivery = await dispatchGovernance({ configFile: c.governanceConfigFile, stateFile: c.stateFile, includeDigest: true, send, env, fetchImpl, now: currentInstant() });
|
|
291
319
|
if (result.delivery.results?.some((r) => r.status !== "delivered") || result.delivery.deadLetters?.length || result.delivery.unknownDeliveries?.length) result.errors.push({ stage: "delivery", reason: "消息存在待恢复投递;查看认证操作入口" });
|
|
292
320
|
} catch (error) { result.errors.push({ stage: "outbox", reason: error.message }); }
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import {globSync,lstatSync,readFileSync,mkdirSync,writeFileSync} from 'node:fs';
|
|
3
|
+
import {safePath} from './source-state.js';
|
|
4
|
+
import {createHash} from 'node:crypto';
|
|
5
|
+
const sha256=value=>createHash('sha256').update(value).digest('hex');
|
|
6
|
+
|
|
7
|
+
export const BUILD_RESULT_PATH='quality-reports/ci-integration/build-result.json';
|
|
8
|
+
export const BUILD_ARCHIVE_PATH='quality-reports/ci-integration/business-artifacts.zip';
|
|
9
|
+
const MAX_BYTES=256*1024*1024,MAX_FILES=5000;
|
|
10
|
+
const assert=(ok,message)=>{if(!ok)throw Error(message);};
|
|
11
|
+
const table=Array.from({length:256},(_,i)=>{let c=i;for(let n=0;n<8;n++)c=c&1?0xedb88320^(c>>>1):c>>>1;return c>>>0;});
|
|
12
|
+
const crc32=bytes=>{let c=0xffffffff;for(const b of bytes)c=table[(c^b)&255]^(c>>>8);return (c^0xffffffff)>>>0;};
|
|
13
|
+
|
|
14
|
+
// A deterministic ZIP with ordinary stored files; no shell, package download,
|
|
15
|
+
// platform-specific archiver or executable metadata. The collector downloads
|
|
16
|
+
// exactly this fixed artifact, rather than trusting a workspace glob remotely.
|
|
17
|
+
export function archiveBuildArtifacts({root,artifactGlob}) {
|
|
18
|
+
const names=new Set();let matched=0;
|
|
19
|
+
for(const pattern of artifactGlob.split(',').map(v=>v.trim()))for(const file of globSync(pattern,{cwd:root})) {
|
|
20
|
+
assert(++matched<=MAX_FILES*4,'制品匹配超过预算,请收紧批准范围');
|
|
21
|
+
const relative=file.split(path.sep).join('/');
|
|
22
|
+
assert(!relative.split('/').some(p=>['.git','node_modules','quality-reports'].includes(p)),'制品范围不能夹带Git、依赖或门禁记录');
|
|
23
|
+
const stat=lstatSync(safePath(root,relative));assert(!stat.isSymbolicLink(),'制品不能含符号链接');
|
|
24
|
+
if(stat.isFile())names.add(relative);else assert(stat.isDirectory(),'制品包含特殊文件');
|
|
25
|
+
}
|
|
26
|
+
assert(names.size && names.size<=MAX_FILES,'批准范围没有实际制品或超过5000文件预算');
|
|
27
|
+
const local=[],central=[],entries=[];let offset=0,total=0,centralSize=0;
|
|
28
|
+
for(const name of [...names].sort()) {
|
|
29
|
+
const stat=lstatSync(safePath(root,name));assert(stat.size<=MAX_BYTES-total,'制品超过256MiB预算,请拆分批准构建范围');
|
|
30
|
+
const bytes=readFileSync(safePath(root,name));total+=bytes.length;
|
|
31
|
+
assert(bytes.length===stat.size && total<=MAX_BYTES,'制品读取期间变化或超过预算');
|
|
32
|
+
const filename=Buffer.from(name),crc=crc32(bytes);assert(filename.length<=65535,'制品文件名过长');
|
|
33
|
+
const header=Buffer.alloc(30);header.writeUInt32LE(0x04034b50);header.writeUInt16LE(20,4);header.writeUInt16LE(0x800,6);header.writeUInt16LE(0x21,12);header.writeUInt32LE(crc,14);header.writeUInt32LE(bytes.length,18);header.writeUInt32LE(bytes.length,22);header.writeUInt16LE(filename.length,26);
|
|
34
|
+
const index=Buffer.alloc(46);index.writeUInt32LE(0x02014b50);index.writeUInt16LE(20,4);index.writeUInt16LE(20,6);index.writeUInt16LE(0x800,8);index.writeUInt16LE(0x21,14);index.writeUInt32LE(crc,16);index.writeUInt32LE(bytes.length,20);index.writeUInt32LE(bytes.length,24);index.writeUInt16LE(filename.length,28);index.writeUInt32LE(offset,42);
|
|
35
|
+
local.push(header,filename,bytes);central.push(index,filename);centralSize+=46+filename.length;offset+=30+filename.length+bytes.length;
|
|
36
|
+
entries.push({path:name,bytes:bytes.length,sha256:sha256(bytes)});
|
|
37
|
+
}
|
|
38
|
+
const end=Buffer.alloc(22);end.writeUInt32LE(0x06054b50);end.writeUInt16LE(entries.length,8);end.writeUInt16LE(entries.length,10);end.writeUInt32LE(centralSize,12);end.writeUInt32LE(offset,16);
|
|
39
|
+
assert(offset+centralSize+22<=MAX_BYTES,'制品ZIP超过256MiB采集预算');
|
|
40
|
+
const archive=Buffer.concat([...local,...central,end]),file=safePath(root,BUILD_ARCHIVE_PATH);mkdirSync(path.dirname(file),{recursive:true});writeFileSync(file,archive,{flag:'wx',mode:0o600});
|
|
41
|
+
return {path:BUILD_ARCHIVE_PATH,sha256:sha256(archive),bytes:archive.length,entries};
|
|
42
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import {pipelineCheckNotification} from './pipeline-notification.js';
|
|
2
|
+
import {findingPreview} from './notification-style.js';
|
|
3
|
+
import {sha256,stableStringify} from './util.js';
|
|
4
|
+
|
|
5
|
+
export const scopeNotification=(config,project,scope)=>config.projects[project].scopes[scope].notification;
|
|
6
|
+
export const scopeChannel=(config,project,scope)=>scopeNotification(config,project,scope)?.channel || config.projects[project].channel;
|
|
7
|
+
const branch=ref=>ref?.replace(/^(?:refs\/remotes\/origin\/|refs\/heads\/|origin\/)/,'');
|
|
8
|
+
const short=(value,max=500)=>typeof value==='string'?[...value].slice(0,max).join(''):value;
|
|
9
|
+
|
|
10
|
+
// Immutable, bounded view of an already validated full source report. The full
|
|
11
|
+
// nine-dimensional report stays archived; one outbox row per run, not per risk.
|
|
12
|
+
export function checkSnapshot(report,event,notification){
|
|
13
|
+
const findings=report.decision.status==='invalid'?[{severity:'error',ruleId:'GOV/CHECK-INVALID',file:'quality/policy',message:'检查执行失效,修复接线后重跑',dimension:'scan'}]:report.findings;
|
|
14
|
+
const totals=Object.fromEntries(['blocker','error','warning','info'].map(level=>[level,findings.filter(f=>f.severity===level).length]));
|
|
15
|
+
const preview=findingPreview(findings).visible.map(f=>Object.fromEntries(['severity','ruleId','file','line','endLine','message','suggestion','dimension'].filter(k=>f[k]!==undefined).map(k=>[k,short(f[k])])));
|
|
16
|
+
const actualBranch=branch(event.ref);
|
|
17
|
+
if(actualBranch!==notification.branch)throw Error('质量事件实际分支与通知批准分支不一致');
|
|
18
|
+
const commit=report.run.trace?.commits?.[0];
|
|
19
|
+
return {schemaVersion:1,kind:'quality-check-notification',eventId:event.eventId,project:event.project,scope:event.scope,sha:event.sha,authorId:event.authorId,reportHash:event.reportHash,
|
|
20
|
+
reportUrl:event.reportUrl,pipelineUrl:event.pipelineUrl,totals,
|
|
21
|
+
summary:{run:{project:event.project,commitSha:event.sha,stage:'ci',eventId:event.reportEventId || event.eventId,startedAt:report.run.startedAt,...(report.run.finishedAt?{finishedAt:report.run.finishedAt}:{}),
|
|
22
|
+
trace:{branch:actualBranch,...(commit?{commits:[{subject:short(commit.subject,250)}]}:{})}},decision:{status:report.decision.status},findings:preview},
|
|
23
|
+
presentation:{submitterId:event.authorId,reportUrl:event.reportUrl,pipelineLabel:`${event.jobId} · 运行 ${event.pipelineId}`,surface:notification.surface,approvedBranch:notification.branch,validationBranch:notification.validationBranch===true,requireResolvedFindings:notification.requireResolvedFindings===true,
|
|
24
|
+
executionStates:[{name:'完整代码检查',status:report.decision.status==='passed'?'passed':'blocked',result:report.decision.status==='passed'?'机器结果已记录':'未通过,修复后复扫'},{name:'正式Review / 业务构建',status:'pending',result:'本条仅为检查结果;以独立正式记录为准'}]}};
|
|
25
|
+
}
|
|
26
|
+
export function checkSnapshotHash(snapshot){return sha256(stableStringify(snapshot));}
|
|
27
|
+
export function centralCheckNotification(snapshot,config){
|
|
28
|
+
const project=config.projects[snapshot.project],n=scopeNotification(config,snapshot.project,snapshot.scope);
|
|
29
|
+
const viewConfig={...config,projects:{...config.projects,[snapshot.project]:{...project,...(n.technicalManager?{technicalManager:n.technicalManager}:{})}}};
|
|
30
|
+
const result=pipelineCheckNotification({summary:snapshot.summary,notificationTotals:snapshot.totals,presentation:snapshot.presentation},viewConfig);
|
|
31
|
+
if(!result.payload)throw Error('批准检查消息被错误跳过');
|
|
32
|
+
return result;
|
|
33
|
+
}
|
package/lib/ci-discovery.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { jenkinsJobConfigHash } from './jenkins-job-config.js';
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
2
3
|
import { loadCiSourceConfig } from "./ci-source.js";
|
|
3
4
|
import { stableStringify } from "./util.js";
|
|
@@ -170,7 +171,7 @@ let pipelineUrl;
|
|
|
170
171
|
} else {
|
|
171
172
|
const jobPath = config.jobPath.split("/").map((part) => `job/${encodeURIComponent(part)}`).join("/");
|
|
172
173
|
const xml = await get(`/${jobPath}/config.xml`, { xml: true });
|
|
173
|
-
assert(
|
|
174
|
+
assert(jenkinsJobConfigHash(xml.body, config.jobConfigHashScheme) === config.jobConfigHash, "Jenkins发现作业配置与批准摘要不一致");
|
|
174
175
|
const processed = new Set();
|
|
175
176
|
const inspect = async (runId, listedTimestamp, recheck = false) => {
|
|
176
177
|
if(processed.has(runId)) return; requireTime(); processed.add(runId); highWatermarkRunId=Math.max(highWatermarkRunId,runId);
|
|
@@ -188,11 +189,16 @@ let pipelineUrl;
|
|
|
188
189
|
const status = build.building ? "running" : states[build.result]; assert(status, "Jenkins发现构建状态未知");
|
|
189
190
|
const causes = (build.actions || []).flatMap((action) => action.causes || []).filter((cause) => cause._class === "hudson.model.Cause$UserIdCause" && typeof cause.userId === "string" && cause.userId);
|
|
190
191
|
const users = [...new Set(causes.map((cause) => cause.userId))], userId = users.length === 1 ? users[0] : null;
|
|
191
|
-
|
|
192
|
+
// A person clicking Build can differ from the candidate author. When
|
|
193
|
+
// an approved upstream source exists, always verify that association;
|
|
194
|
+
// manual retries must not silently replace the author with the clicker.
|
|
195
|
+
const trigger = owner(userId, userId !== null);
|
|
196
|
+
const actor = actorSource ? await mergedCommitOwner(sha) : trigger;
|
|
192
197
|
const final = (await get(`${buildPath}api/json`)).value;
|
|
193
198
|
const facts = (value) => ({ number: value.number, timestamp: value.timestamp, building: value.building, result: value.result, actions: value.actions, artifacts: value.artifacts, url: value.url ?? null });
|
|
194
199
|
assert(digest(facts(build)) === digest(facts(final)), "Jenkins发现期间构建事实改变,等待下次稳定发现");
|
|
195
200
|
push({ runId, sha, ref: config.ref, occurredAt, pipelineUrl, ...actor,
|
|
201
|
+
...(actorSource ? { triggerSource: { ...trigger.actorSource, employeeId: trigger.authorId, role: "build-trigger" } } : {}),
|
|
196
202
|
terminal: !build.building, missingJobs: [], status, sourceStatus: status, requiredJobs: [{ id: runId, name: config.jobPath, status, trusted: true }], issues: actor.authorId === null ? [{ reason: actor.actorSource.reason || "no-unique-authenticated-author" }] : [],
|
|
197
203
|
observedRevision: digest({ build: facts(final), configHash: config.jobConfigHash, actorSource: actor.actorSource }) });
|
|
198
204
|
} catch (error) { if (error.code === "CI_DISCOVERY_TIME_BUDGET") throw error; discoveryErrors.push({ runId, ...(pipelineUrl ? { pipelineUrl } : {}), reason: error.message }); }
|
|
@@ -215,7 +221,7 @@ let pipelineUrl;
|
|
|
215
221
|
}
|
|
216
222
|
jenkinsFinalCheck = true;
|
|
217
223
|
const final = await get(`/${jobPath}/config.xml`, { xml: true });
|
|
218
|
-
assert(
|
|
224
|
+
assert(jenkinsJobConfigHash(final.body, config.jobConfigHashScheme) === config.jobConfigHash, "Jenkins发现期间作业配置变化");
|
|
219
225
|
}
|
|
220
226
|
} catch (error) {
|
|
221
227
|
if (error.code !== "CI_DISCOVERY_TIME_BUDGET") throw error;
|
package/lib/ci-source.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {ENTRY_CONTEXT_PATH,validateNativeSource,assertNativeProof} from './jenkins-entry-contract.js';
|
|
2
|
+
import { jenkinsJobConfigHash } from './jenkins-job-config.js';
|
|
1
3
|
import { readFileSync, mkdirSync, writeFileSync, lstatSync, existsSync, rmSync } from "node:fs";
|
|
2
4
|
import path from "node:path";
|
|
3
5
|
import { createHash } from "node:crypto";
|
|
@@ -25,6 +27,7 @@ export function loadCiSourceConfig(file) {
|
|
|
25
27
|
assert(typeof config.ref === "string" && config.ref && !/[\u0000-\u001f]/.test(config.ref), "必须固定实际分支/候选ref");
|
|
26
28
|
assert(config.artifacts && config.requiredJobs.includes(config.artifacts.job) && Array.isArray(config.artifacts.paths) && config.artifacts.paths.length && config.artifacts.paths.every(relative) && new Set(config.artifacts.paths).size === config.artifacts.paths.length, "必须固定证据作业和工件路径,不能使用glob/越界路径");
|
|
27
29
|
if (config.qualitySummary !== undefined) assert(config.artifacts.paths.includes(config.qualitySummary), "qualitySummary未登记在固定工件清单");
|
|
30
|
+
if (config.reportPublication !== undefined) assert(config.system === "jenkins" && config.qualitySummary && relative(config.reportPublication.manifestPath) && config.artifacts.paths.includes(config.reportPublication.manifestPath) && !config.artifacts.optionalPaths?.includes(config.reportPublication.manifestPath), "在线报告需绑定Jenkins和固定必需归档清单");
|
|
28
31
|
if (config.artifacts.optionalPaths !== undefined) assert(Array.isArray(config.artifacts.optionalPaths) && new Set(config.artifacts.optionalPaths).size === config.artifacts.optionalPaths.length && config.artifacts.optionalPaths.every((p) => config.artifacts.paths.includes(p) && p !== config.qualitySummary), "可选Review工件必须在固定清单内且不能包含原始Gate报告");
|
|
29
32
|
if (config.system === "gitlab") {
|
|
30
33
|
assert(positive(config.projectId), "GitLab必须固定数字projectId");
|
|
@@ -34,7 +37,9 @@ export function loadCiSourceConfig(file) {
|
|
|
34
37
|
assert(relative(config.jobPath) && config.requiredJobs.length === 1 && config.requiredJobs[0] === config.jobPath, "Jenkins必须固定唯一jobPath;多条作业分别采集并由受控发布作业汇总");
|
|
35
38
|
assert(/^[A-Z][A-Z0-9_]+$/.test(config.userEnv || ""), "Jenkins用户必须用环境变量引用");
|
|
36
39
|
assert(/^[a-f0-9]{64}$/.test(config.jobConfigHash || ""), "Jenkins必须固定批准job config.xml摘要");
|
|
40
|
+
assert(config.jobConfigHashScheme === undefined || ["raw", "stable-v1"].includes(config.jobConfigHashScheme), "未知Jenkins作业摘要协议");
|
|
37
41
|
}
|
|
42
|
+
if(config.nativeEntry)validateNativeSource(config);
|
|
38
43
|
config.maxArtifactBytes ??= 64 * 1024 * 1024;
|
|
39
44
|
assert(Number.isSafeInteger(config.maxArtifactBytes) && config.maxArtifactBytes > 0 && config.maxArtifactBytes <= 256 * 1024 * 1024, "工件预算无效");
|
|
40
45
|
return config;
|
|
@@ -61,7 +66,7 @@ export async function collectCiSource({ configFile, requestFile, outDir, env = p
|
|
|
61
66
|
const headers = config.system === "gitlab" ? { "PRIVATE-TOKEN": token } : { Authorization: `Basic ${Buffer.from(`${env[config.userEnv] || ""}:${token}`).toString("base64")}` };
|
|
62
67
|
if (config.system === "jenkins") assert(env[config.userEnv] && !/[:\r\n]/.test(env[config.userEnv]), "缺少有效Jenkins只读用户");
|
|
63
68
|
const responses = [], artifacts = [], missingArtifacts = [];
|
|
64
|
-
const get = async (route, binary = false, snapshot) => {
|
|
69
|
+
const get = async (route, binary = false, snapshot, html = false) => {
|
|
65
70
|
const url = new URL(route, config.baseUrl);
|
|
66
71
|
assert(url.origin === config.baseUrl && !url.username && !url.password, "CI来源越出固定origin");
|
|
67
72
|
let response;
|
|
@@ -69,6 +74,7 @@ export async function collectCiSource({ configFile, requestFile, outDir, env = p
|
|
|
69
74
|
catch { throw new Error("CI只读查询连接失败;没有接受webhook自报结果"); }
|
|
70
75
|
assert(!response.url || new URL(response.url).origin === config.baseUrl, "CI响应来源发生跳转");
|
|
71
76
|
if (!response.ok) { const error = new Error(`CI只读查询失败: HTTP ${response.status}`); error.ciStatus = response.status; throw error; }
|
|
77
|
+
if (html) assert(response.status === 200 && /^text\/html(?:;|$)/i.test(response.headers.get("content-type") || ""), "在线报告没有返回HTML,拒绝发送详情按钮");
|
|
72
78
|
const body = await bytes(response, binary ? config.maxArtifactBytes : 10 * 1024 * 1024);
|
|
73
79
|
if (binary) return body;
|
|
74
80
|
const raw = body.toString("utf8"); let parsed;
|
|
@@ -119,7 +125,7 @@ export async function collectCiSource({ configFile, requestFile, outDir, env = p
|
|
|
119
125
|
} else {
|
|
120
126
|
const jobPath = config.jobPath.split("/").map((v) => `job/${encodeURIComponent(v)}`).join("/");
|
|
121
127
|
const configXml = await get(`/${jobPath}/config.xml`, true);
|
|
122
|
-
assert(
|
|
128
|
+
assert(jenkinsJobConfigHash(configXml, config.jobConfigHashScheme) === config.jobConfigHash, "Jenkins作业配置与批准摘要不一致");
|
|
123
129
|
// Job XML can contain legacy inline secrets. Keep its verified digest,
|
|
124
130
|
// never copy configuration text into the report/chat evidence bundle.
|
|
125
131
|
responses.push({ url: `${config.baseUrl}/${jobPath}/config.xml`, sha256: digest(configXml), rawOmitted: "controlled-job-configuration" });
|
|
@@ -147,6 +153,7 @@ export async function collectCiSource({ configFile, requestFile, outDir, env = p
|
|
|
147
153
|
try { const body = await get(artifactRoute(file), true); artifacts.push({ path: file, sha256: digest(body), bytes: body.byteLength, body }); }
|
|
148
154
|
catch (error) { if (optional && [404, 410].includes(error.ciStatus)) missingArtifacts.push({ path: file, reason: "optional-unavailable" }); else { if (jobStatus === "success") throw error; missingArtifacts.push({ path: file, reason: "download-unavailable" }); } }
|
|
149
155
|
}
|
|
156
|
+
if(config.nativeEntry&&terminal){const proof=artifacts.find(a=>a.path===ENTRY_CONTEXT_PATH);assert(proof,'当前作业缺少公共入口原始执行证据,不能按成功或质量结果采集');let value;try{value=JSON.parse(proof.body);}catch{throw Error('公共入口执行证据不是JSON');}assertNativeProof(value,config,request);}
|
|
150
157
|
// A concurrent retry, erase, SCM update or result change invalidates the
|
|
151
158
|
// collection instead of mixing one run's status with another run's bytes.
|
|
152
159
|
const final = (await get(recheckRoute, false, config.system === "gitlab" ? "final-artifact-job" : "final-build")).parsed;
|
|
@@ -154,7 +161,7 @@ export async function collectCiSource({ configFile, requestFile, outDir, env = p
|
|
|
154
161
|
else {
|
|
155
162
|
assert(String(final.number) === String(request.runId) && final.building === job.building && final.result === job.result && stableStringify(final.actions) === stableStringify(job.actions) && stableStringify(final.artifacts) === stableStringify(job.artifacts), "工件采集后Jenkins构建改变,重新采集");
|
|
156
163
|
const jobPath = config.jobPath.split("/").map((v) => `job/${encodeURIComponent(v)}`).join("/");
|
|
157
|
-
assert(
|
|
164
|
+
assert(jenkinsJobConfigHash(await get(`/${jobPath}/config.xml`, true), config.jobConfigHashScheme) === config.jobConfigHash, "采集期间Jenkins作业配置变化,重新采集");
|
|
158
165
|
}
|
|
159
166
|
if (recheckJobs) await recheckJobs();
|
|
160
167
|
const passed = jobs.every((v) => v.status === "success");
|
|
@@ -163,7 +170,7 @@ export async function collectCiSource({ configFile, requestFile, outDir, env = p
|
|
|
163
170
|
status: passed ? "success" : "not-passed", pipelineUrl, jobUrl, jobId: String(job.id ?? job.number), jobStatus,
|
|
164
171
|
configHash: digest(stableStringify(config)), requiredJobsSnapshot: config.system === "gitlab" ? "final-required-jobs" : "final-build", requiredJobs: jobs.map((v) => ({ id: String(v.id ?? v.number), name: v.name, status: v.status })),
|
|
165
172
|
responses, artifacts: artifacts.map(({ body, ...v }) => v), missingArtifacts };
|
|
166
|
-
let event;
|
|
173
|
+
let event, publishedHtml;
|
|
167
174
|
if (config.qualitySummary && terminal) {
|
|
168
175
|
const content = artifacts.find((v) => v.path === config.qualitySummary)?.body;
|
|
169
176
|
let summary, reason;
|
|
@@ -184,6 +191,19 @@ export async function collectCiSource({ configFile, requestFile, outDir, env = p
|
|
|
184
191
|
reportHash: digest(content), reportUrl: new URL(artifactRoute(config.qualitySummary), config.baseUrl).href, pipelineUrl,
|
|
185
192
|
occurredAt: new Date(now).toISOString(),ref:config.ref };
|
|
186
193
|
}
|
|
194
|
+
if (event && config.reportPublication) {
|
|
195
|
+
const body = artifacts.find((a) => a.path === config.reportPublication.manifestPath)?.body;
|
|
196
|
+
assert(body, "本轮缺少报告发布归档,不能生成详情按钮");
|
|
197
|
+
const p = JSON.parse(body), report = JSON.parse(artifacts.find((a) => a.path === config.qualitySummary).body);
|
|
198
|
+
assert(p.kind === "quality-report-archive" && p.schemaVersion === 1 && p.project === config.project && p.commitSha === request.sha && p.eventId === report.run.eventId && p.stage === report.run.stage && ["local-hook", "ci"].includes(p.stage) && /^[a-f0-9]{64}$/.test(p.archiveId || "") && /^[a-f0-9]{64}$/.test(p.htmlSha256 || ""), "在线报告没有绑定本项目、本轮SHA和检查阶段");
|
|
199
|
+
assert(p.summarySha256 === digest(json(report)) && p.buildUrl === jobUrl && p.relativeDirectory === `${config.project}/${p.stage}/${p.archiveId}`, "在线报告归档与当前作业或原始报告不一致");
|
|
200
|
+
const url = new URL(`wl-quality/${p.relativeDirectory}/index.html`, jobUrl);
|
|
201
|
+
assert(p.reportUrl === url.href, "在线报告链接不是本次构建的固定归档");
|
|
202
|
+
publishedHtml = await get(url.pathname, true, undefined, true);
|
|
203
|
+
assert(digest(publishedHtml) === p.htmlSha256, "在线报告是登录页、旧报告或已被修改;禁止发送失效详情按钮");
|
|
204
|
+
observation.publishedReport = { url: url.href, htmlSha256: p.htmlSha256, archiveId: p.archiveId, verifiedAt: new Date(now).toISOString() };
|
|
205
|
+
event.reportUrl = url.href;
|
|
206
|
+
}
|
|
187
207
|
const sourceText = json(observation);
|
|
188
208
|
if (event) event.source = { system: config.system, recordId: String(request.runId), recordHash: digest(sourceText), observedAt: new Date(now).toISOString(), url: jobUrl };
|
|
189
209
|
// Persist only after the final source identity/state checks. A failed job may
|
|
@@ -195,6 +215,7 @@ export async function collectCiSource({ configFile, requestFile, outDir, env = p
|
|
|
195
215
|
mkdirSync(output, { mode: 0o700 });
|
|
196
216
|
try {
|
|
197
217
|
writeFileSync(path.join(output, "source.json"), sourceText, { flag: "wx", mode: 0o600 });
|
|
218
|
+
if (publishedHtml) writeFileSync(path.join(output, "published-report.html"), publishedHtml, { flag: "wx", mode: 0o600 });
|
|
198
219
|
for (const item of artifacts) { const dest = path.join(output, "artifacts", item.path); mkdirSync(path.dirname(dest), { recursive: true }); writeFileSync(dest, item.body, { flag: "wx", mode: 0o600 }); }
|
|
199
220
|
if (event) writeFileSync(path.join(output, "quality-event.json"), json(event), { flag: "wx", mode: 0o600 });
|
|
200
221
|
} catch (error) { rmSync(output, { recursive: true, force: true }); throw error; }
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Read one approved group. No message sending, membership mutation or config writes.
|
|
2
|
+
export async function fetchFeishuMembers({ appId, appSecret, chatId, fetchImpl = fetch }) {
|
|
3
|
+
if (!/^cli_[A-Za-z0-9]+$/.test(appId || "") || !appSecret || !/^oc_[A-Za-z0-9]+$/.test(chatId || "")) throw new Error("需要应用App ID、App Secret及oc_开头的群ID");
|
|
4
|
+
async function request(url, options) {
|
|
5
|
+
const response = await fetchImpl(url, { ...options, redirect: "error", signal: AbortSignal.timeout(15000) });
|
|
6
|
+
if (!response.ok) throw new Error(`飞书接口HTTP ${response.status},未生成成员清单`);
|
|
7
|
+
const result = await response.json();
|
|
8
|
+
if (result.code !== 0) throw new Error(`飞书接口拒绝(code=${Number.isSafeInteger(result.code) ? result.code : "invalid"});核对应用发布、只读权限与入群状态`);
|
|
9
|
+
return result;
|
|
10
|
+
}
|
|
11
|
+
const auth = await request("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", {
|
|
12
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
|
|
13
|
+
});
|
|
14
|
+
if (typeof auth.tenant_access_token !== "string" || !auth.tenant_access_token) throw new Error("飞书未返回有效应用令牌");
|
|
15
|
+
const members = new Map(), visited = new Set();
|
|
16
|
+
let pageToken = "";
|
|
17
|
+
for (let page = 0; page < 1000; page++) {
|
|
18
|
+
const url = new URL(`https://open.feishu.cn/open-apis/im/v1/chats/${encodeURIComponent(chatId)}/members`);
|
|
19
|
+
url.searchParams.set("member_id_type", "open_id"); url.searchParams.set("page_size", "100");
|
|
20
|
+
if (pageToken) url.searchParams.set("page_token", pageToken);
|
|
21
|
+
const result = await request(url.href, { method: "GET", headers: { Authorization: `Bearer ${auth.tenant_access_token}` } });
|
|
22
|
+
const data = result.data;
|
|
23
|
+
if (!data || !Array.isArray(data.items) || typeof data.has_more !== "boolean") throw new Error("群成员响应不完整,禁止将半份名单作为完整结果");
|
|
24
|
+
for (const member of data.items) {
|
|
25
|
+
if (member.member_id_type !== "open_id" || !/^ou_[A-Za-z0-9_-]+$/.test(member.member_id || "") || typeof member.name !== "string" || !member.name.trim()) throw new Error("群成员姓名/ID类型无效,拒绝猜测身份");
|
|
26
|
+
const entry = { name: member.name.trim(), idType: "open_id", id: member.member_id };
|
|
27
|
+
if (members.has(entry.id) && members.get(entry.id).name !== entry.name) throw new Error("群成员在分页期间发生身份信息变化,请重新读取");
|
|
28
|
+
members.set(entry.id, entry);
|
|
29
|
+
}
|
|
30
|
+
if (!data.has_more) return { schemaVersion: 1, kind: "feishu-group-members", appId, chatId, collectedAt: new Date().toISOString(), complete: true, count: members.size, members: [...members.values()].sort((a, b) => a.name.localeCompare(b.name, "zh-CN")) };
|
|
31
|
+
if (typeof data.page_token !== "string" || !data.page_token || visited.has(data.page_token)) throw new Error("群成员分页无有效进展,未导出残缺名单");
|
|
32
|
+
visited.add(data.page_token); pageToken = data.page_token;
|
|
33
|
+
}
|
|
34
|
+
throw new Error("群成员分页超出预算,未导出残缺名单");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function matchFeishuNames(names, directory) {
|
|
38
|
+
if (directory?.complete !== true || !Array.isArray(directory.members)) throw new Error("只允许使用完整群成员清单匹配");
|
|
39
|
+
return [...new Set(names)].map((name) => {
|
|
40
|
+
const matches = directory.members.filter((member) => member.name === name);
|
|
41
|
+
return { name, status: matches.length === 1 ? "matched" : matches.length > 1 ? "ambiguous" : "missing", matches };
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
// Pure presentation: no credentials, network, approval, or issue-state mutation.
|
|
2
|
+
import { formatNotificationTime } from "./notification-time.js";
|
|
3
|
+
import { notificationText, riskCountsElement } from "./notification-style.js";
|
|
4
|
+
import { notificationActors, responsibilityReminder } from "./notification-people.js";
|
|
2
5
|
const clean = (value, max = 200) => String(value ?? "").replace(/[\u0000-\u001f\u007f\u202a-\u202e\u2066-\u2069]/g, " ").slice(0, max);
|
|
3
6
|
const md = (value, max) => clean(value, max).replace(/[\\`*_~\[\]<>]/g, "\\$&");
|
|
4
7
|
const button = (label, url) => ({ tag: "button", type: "default", text: { tag: "plain_text", content: label }, behaviors: [{ type: "open_url", default_url: url }] });
|
|
5
|
-
const card = (title, template, content, links) =>
|
|
8
|
+
const card = (title, template, content, links) => {
|
|
9
|
+
const [conclusion, next, ...metadata] = content.split("\n");
|
|
10
|
+
return { msg_type: "interactive", card: { schema: "2.0", header: { template, title: { tag: "plain_text", content: clean(title, 160) } }, body: { elements: [
|
|
11
|
+
notificationText(conclusion.replace(/^\*\*结论\*\*:/, ""), template, "heading-4"),
|
|
12
|
+
{ tag: "markdown", text_size: "normal", content: metadata.join("\n") },
|
|
13
|
+
notificationText(next.replace(/^\*\*下一步\*\*:/, "下一步:"), "blue"),
|
|
14
|
+
...links.map(([label, url]) => button(label, url)),
|
|
15
|
+
] } } };
|
|
16
|
+
};
|
|
6
17
|
function bounded(message, detailsUrl) {
|
|
7
18
|
if (Buffer.byteLength(message.text, "utf8") <= 1800) return message;
|
|
8
19
|
const suffix = `\n…内容已精简,查看完整详情:${detailsUrl}`;
|
|
@@ -24,52 +35,71 @@ function renderIssueNotification(items, config) {
|
|
|
24
35
|
const label = category === "reminder" ? "处理提醒" : "代码质量";
|
|
25
36
|
const status = issueStatus[issue.status];
|
|
26
37
|
const next = issue.status === "closed" ? "查看独立复验记录;复发会重新打开" : issue.status === "awaitingVerification" ? "独立非作者复核人查看当前修复版本和实际业务复验,确认后关闭" : "主备查看定位与建议,确认修复方案后由脚本执行并复扫";
|
|
27
|
-
const links = [["查看报告", evidence.reportUrl], ["查看流水线", evidence.pipelineUrl], ["登录处理", `${project.issueBaseUrl.replace(/\/$/, "")}/${issue.id}`]];
|
|
28
|
-
const text = `${label} ${issue.severity} / ${item.kind}\n项目/范围:${issue.project}/${issue.scope}\n规则:${clean(issue.ruleId, 100)}\n位置:${clean(issue.file)}${issue.line ? ":" + issue.line : ""}\n状态/版本:${status}/${issue.revision}\n主责/备份/升级:${issue.owner}/${issue.backup}/${issue.escalationOwner}\n处理期限:${issue.resolveDueAt}\nSHA:${evidence.sha}\n${links.map(([name, url]) => `${name}:${url}`).join("\n")}`;
|
|
38
|
+
const links = [["查看报告", evidence.reportUrl], ...(evidence.stage === "local-hook" ? [] : [["查看流水线", evidence.pipelineUrl]]), ["登录处理", `${project.issueBaseUrl.replace(/\/$/, "")}/${issue.id}`]];
|
|
39
|
+
const text = `${label} ${issue.severity} / ${item.kind}\n项目/范围:${issue.project}/${issue.scope}\n规则:${clean(issue.ruleId, 100)}\n位置:${clean(issue.file)}${issue.line ? ":" + issue.line : ""}\n状态/版本:${status}/${issue.revision}\n主责/备份/升级:${issue.owner}/${issue.backup}/${issue.escalationOwner}\n处理期限:${formatNotificationTime(issue.resolveDueAt)}\nSHA:${evidence.sha}\n${links.map(([name, url]) => `${name}:${url}`).join("\n")}`;
|
|
29
40
|
const template = issue.status === "closed" ? "green" : issue.status === "awaitingVerification" || issue.severity === "warning" ? "orange" : "red";
|
|
30
|
-
return bounded({ category, text, payload: card(`[${label}] ${issue.project} · ${status}`, template, `**结论**:${md(status)}\n**下一步**:${next}\n**项目/范围**:${md(issue.project)}/${md(issue.scope)}\n**规则/位置**:${md(issue.ruleId, 100)} · ${md(issue.file, 160)}${issue.line ? ":" + issue.line : ""}\n**主责/备份/升级**:${md(issue.owner)}/${md(issue.backup)}/${md(issue.escalationOwner)}\n**期限**:${
|
|
41
|
+
return bounded({ category, text, payload: card(`[${label}] ${issue.project} · ${status}`, template, `**结论**:${md(status)}\n**下一步**:${next}\n**项目/范围**:${md(issue.project)}/${md(issue.scope)}\n**规则/位置**:${md(issue.ruleId, 100)} · ${md(issue.file, 160)}${issue.line ? ":" + issue.line : ""}\n**主责/备份/升级**:${md(issue.owner)}/${md(issue.backup)}/${md(issue.escalationOwner)}\n**期限**:${formatNotificationTime(issue.resolveDueAt)}\n**SHA**:${md(evidence.sha)}\n**事件/版本**:${md(item.kind)}/${issue.revision}`, links) }, project.issueBaseUrl);
|
|
31
42
|
}
|
|
32
43
|
|
|
33
44
|
export function issueNotification(items, config) {
|
|
45
|
+
const subject=items[0].snapshot,override=config.projects[subject.project].scopes?.[subject.scope]?.notification?.technicalManager;
|
|
46
|
+
if(override)config={...config,projects:{...config.projects,[subject.project]:{...config.projects[subject.project],technicalManager:override}}};
|
|
34
47
|
const message=renderIssueNotification(items,config), issue=items[0].snapshot, project=config.projects[issue.project];
|
|
35
48
|
const evidence=issue.pendingEvidence || issue.latestEvidence, executor=evidence.authorId;
|
|
36
|
-
const manager=project.technicalManager, actors=
|
|
49
|
+
const manager=project.technicalManager, {actors,observer}=notificationActors(executor,project,config.people);
|
|
37
50
|
const people=actors.map(actor=>({actor,...config.people?.[actor]}));
|
|
38
51
|
const high=items.some(item=>["blocker","error"].includes(item.snapshot.severity));
|
|
39
52
|
const mapped=people.length===actors.length && actors.length>0 && people.every(person=>person.name && person.feishu?.id && person.feishu.id!=="all" && /^[A-Za-z0-9_-]{1,128}$/.test(person.feishu.id));
|
|
40
|
-
if(high && project.requireRiskMentions===true && (!executor || !manager || !mapped)) throw new Error("
|
|
41
|
-
const
|
|
42
|
-
const display=actor=>config.people?.[actor]?.name ?
|
|
53
|
+
if((high || observer) && project.requireRiskMentions===true && (!executor || !manager || !mapped)) throw new Error("报告缺执行人/技术经理/同步人实名@映射;禁止伪造@或@全体,需走渠道故障兜底");
|
|
54
|
+
const mentionIds=mapped ? [...new Set(people.filter(person=>high || person.actor===observer).map(person=>person.feishu.id))] : [];
|
|
55
|
+
const display=actor=>config.people?.[actor]?.name ? md(config.people[actor].name) : md(actor || "未核验");
|
|
43
56
|
const counts=evidence.riskCounts;
|
|
44
57
|
const summary=[`**项目领域**:【${md(project.domain || "未登记")}】 ${md(issue.project)}`,
|
|
45
58
|
`**检查阶段**:${evidence.stage==="local-hook"?"第一道 · 项目Hook":evidence.stage==="ci"?"第二道 · CI合并/构建":"阶段待核验"}`,
|
|
46
|
-
|
|
47
|
-
|
|
59
|
+
`**提交人**:${display(executor)} | **技术经理**:${display(manager)}${observer ? ` | **同步**:${display(observer)}` : ""}`,
|
|
60
|
+
`**发生时间**:${formatNotificationTime(evidence.occurredAt || issue.firstSourceOccurredAt)}`,
|
|
61
|
+
`**通知时间**:${formatNotificationTime(items[0].createdAt || issue.openedAt)}`,
|
|
48
62
|
...(evidence.trace ? [`**分支**:${md(evidence.trace.sourceBranch || "未提供")} → ${md(evidence.trace.targetBranch || evidence.trace.branch || "未提供")}`,
|
|
49
63
|
`**提交**:${md(evidence.trace.commits?.[0]?.subject || "无提交说明",160)}`] : []),
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
64
|
+
].join("\n");
|
|
65
|
+
const elements=message.payload.card.body.elements, metadata=elements.find((element)=>element.tag==="markdown");
|
|
66
|
+
metadata.content=summary+"\n\n"+metadata.content;
|
|
67
|
+
if(counts) elements.splice(2,0,riskCountsElement(Object.fromEntries(["blocker","error","warning","info"].map((level)=>[level,counts[level]??0]))));
|
|
68
|
+
elements.push({tag:"markdown",text_size:"normal",content:responsibilityReminder({executor,manager,observer,people:config.people,mentions:mentionIds,closed:issue.status==="closed"})});
|
|
69
|
+
return {...message,mentions:mentionIds,mentionMappingComplete:mapped};
|
|
54
70
|
}
|
|
55
71
|
|
|
56
72
|
const stages = {
|
|
57
|
-
review: { label: "
|
|
73
|
+
review: { label: "代码审核", status: { draft: "待技术经理审核", "changes-requested": "审核未通过 · 修复后再审核", approved: "审核已确认", rejected: "审核未通过", canceled: "审核已取消 · 不作为通过记录" } },
|
|
58
74
|
build: { label: "构建", status: { running: "正在构建", success: "构建成功 · 待部署验证", failed: "构建失败", canceled: "已取消 · 无有效构建结果", "not-run": "未运行 · 无有效构建结果" } },
|
|
59
75
|
deploy: { label: "发布验证", status: { running: "正在部署", success: "已部署 · 验证待完成", failed: "部署或验证失败", canceled: "已取消 · 无有效部署结果", "not-run": "未运行 · 无有效部署结果", verified: "环境验证通过 · 问题待独立关闭" } },
|
|
60
76
|
};
|
|
61
|
-
|
|
77
|
+
function renderLifecycleNotification(fact, config) {
|
|
62
78
|
if (fact.category === "collection") {
|
|
63
79
|
const project = config.projects[fact.project], recovered = fact.status === "recovered", status = recovered ? "采集已恢复" : "采集执行异常";
|
|
64
80
|
const next = recovered ? "中央周期已恢复查询;项目质量和问题关闭仍以真实报告及独立复验为准" : "运维检查只读凭据、网络、必需作业和中央执行记录;恢复后自动补采,不将失联当作通过";
|
|
65
81
|
const links = [["治理入口", fact.reportUrl], ["CI来源", fact.source.url]];
|
|
66
|
-
const text = `执行提醒:${status}\n下一步:${next}\n项目/范围/来源:${fact.project}/${fact.scope}/${fact.sourceId}\n异常阶段/数量:${fact.errorStages.join(",") || "无"}/${fact.errorCount}\n主责/备份/升级:${project.owner}/${project.backup}/${project.escalationOwner}\n首次异常/当前观察:${fact.firstFailureAt}
|
|
67
|
-
return bounded({ category: "reminder", text, payload: card(`[执行提醒] ${fact.project} · ${status}`, recovered ? "blue" : "red", `**结论**:${status}\n**下一步**:${next}\n**项目/范围/来源**:${md(fact.project)}/${md(fact.scope)}/${md(fact.sourceId)}\n**异常阶段/数量**:${md(fact.errorStages.join(",") || "无")}/${fact.errorCount}\n**主责/备份/升级**:${md(project.owner)}/${md(project.backup)}/${md(project.escalationOwner)}\n**首次异常/当前观察**:${
|
|
82
|
+
const text = `执行提醒:${status}\n下一步:${next}\n项目/范围/来源:${fact.project}/${fact.scope}/${fact.sourceId}\n异常阶段/数量:${fact.errorStages.join(",") || "无"}/${fact.errorCount}\n主责/备份/升级:${project.owner}/${project.backup}/${project.escalationOwner}\n首次异常/当前观察:${formatNotificationTime(fact.firstFailureAt)} / ${formatNotificationTime(fact.source.observedAt)}\n执行记录:${fact.executionId}\n${links.map(([name, url]) => `${name}:${url}`).join("\n")}`;
|
|
83
|
+
return bounded({ category: "reminder", text, payload: card(`[执行提醒] ${fact.project} · ${status}`, recovered ? "blue" : "red", `**结论**:${status}\n**下一步**:${next}\n**项目/范围/来源**:${md(fact.project)}/${md(fact.scope)}/${md(fact.sourceId)}\n**异常阶段/数量**:${md(fact.errorStages.join(",") || "无")}/${fact.errorCount}\n**主责/备份/升级**:${md(project.owner)}/${md(project.backup)}/${md(project.escalationOwner)}\n**首次异常/当前观察**:${formatNotificationTime(fact.firstFailureAt)} / ${formatNotificationTime(fact.source.observedAt)}\n**执行记录**:${md(fact.executionId)}`, links) }, project.issueBaseUrl);
|
|
68
84
|
}
|
|
69
85
|
const stage = stages[fact.category], label = stage.label, status = stage.status[fact.status], project = config.projects[fact.project];
|
|
70
86
|
const next = fact.category === "review" ? fact.status === "draft" ? "非作者复核人核对九维证据、修前修后差异,再明确授权正式记录" : fact.status === "approved" ? "按当前候选版本进入受控构建;发布仍需独立授权" : "查看当前Review记录和要求" : fact.category === "build" ? fact.status === "success" ? "核对制品摘要并进入授权部署与环境验证" : "查看当前作业和失败原因;重新运行生成新事实" : fact.status === "verified" ? "独立复核当前修复问题;环境验证成功不自动关闭台账" : fact.status === "success" ? "执行实际环境验证,保留验证证据" : "核对部署结果与恢复记录";
|
|
71
87
|
const links = [["查看记录", fact.reportUrl], ["查看流水线", fact.pipelineUrl], ["登录处理", project.issueBaseUrl]];
|
|
72
|
-
const text = `${label}:${status}\n下一步:${next}\n项目/范围/环境:${fact.project}/${fact.scope}/${fact.environment || "无"}\nSHA:${fact.sha}\n主责/备份:${project.owner}/${project.backup}\n来源核验:${fact.source.system}/${fact.source.recordId}\n发生/采集:${fact.occurredAt}
|
|
88
|
+
const text = `${label}:${status}\n下一步:${next}\n项目/范围/环境:${fact.project}/${fact.scope}/${fact.environment || "无"}\nSHA:${fact.sha}\n主责/备份:${project.owner}/${project.backup}\n来源核验:${fact.source.system}/${fact.source.recordId}\n发生/采集:${formatNotificationTime(fact.occurredAt)} / ${formatNotificationTime(fact.source.observedAt)}\n${links.map(([name, url]) => `${name}:${url}`).join("\n")}`;
|
|
73
89
|
const template = ["failed", "rejected", "changes-requested"].includes(fact.status) ? "red" : ["canceled", "not-run", "draft"].includes(fact.status) ? "orange" : "blue";
|
|
74
|
-
return bounded({ category: fact.category, text, payload: card(`[${label}] ${fact.project} · ${status}`, template, `**结论**:${md(status)}\n**下一步**:${next}\n**项目/范围/环境**:${md(fact.project)}/${md(fact.scope)}/${md(fact.environment || "无")}\n**SHA**:${md(fact.sha)}\n**主责/备份**:${md(project.owner)}/${md(project.backup)}\n**来源核验**:${md(fact.source.system)}/${md(fact.source.recordId)}\n**发生/采集**:${
|
|
90
|
+
return bounded({ category: fact.category, text, payload: card(`[${label}] ${fact.project} · ${status}`, template, `**结论**:${md(status)}\n**下一步**:${next}\n**项目/范围/环境**:${md(fact.project)}/${md(fact.scope)}/${md(fact.environment || "无")}\n**SHA**:${md(fact.sha)}\n**主责/备份**:${md(project.owner)}/${md(project.backup)}\n**来源核验**:${md(fact.source.system)}/${md(fact.source.recordId)}\n**发生/采集**:${formatNotificationTime(fact.occurredAt)} / ${formatNotificationTime(fact.source.observedAt)}${fact.artifactHash ? `\n**制品摘要**:${md(fact.artifactHash)}` : ""}`, links) }, project.issueBaseUrl);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function lifecycleNotification(fact,config){
|
|
94
|
+
const override=config.projects[fact.project].scopes?.[fact.scope]?.notification?.technicalManager;
|
|
95
|
+
if(override)config={...config,projects:{...config.projects,[fact.project]:{...config.projects[fact.project],technicalManager:override}}};
|
|
96
|
+
const message=renderLifecycleNotification(fact,config);
|
|
97
|
+
if(!fact.authorId)return message;
|
|
98
|
+
const project=config.projects[fact.project],executor=fact.authorId,manager=project.technicalManager,{actors,observer}=notificationActors(executor,project,config.people);
|
|
99
|
+
const high=['failed','rejected','changes-requested'].includes(fact.status),people=actors.map(actor=>({actor,...config.people?.[actor]}));
|
|
100
|
+
const mapped=!!manager && !!executor && people.every(p=>p.name && ['open_id','user_id'].includes(p.feishu?.idType) && /^[A-Za-z0-9_-]{1,128}$/.test(p.feishu.id || '') && p.feishu.id!=='all');
|
|
101
|
+
if((high || observer) && project.requireRiskMentions===true && !mapped)throw Error('运行结果缺执行人/技术经理/同步人真实飞书ID');
|
|
102
|
+
const mentions=mapped?[...new Set(people.filter(p=>high || p.actor===observer).map(p=>p.feishu.id))]:[];
|
|
103
|
+
message.payload.card.body.elements.push({tag:'markdown',text_size:'normal',content:responsibilityReminder({executor,manager,observer,people:config.people,mentions,hasFindings:high})});
|
|
104
|
+
return {...message,mentions,mentionMappingComplete:mapped};
|
|
75
105
|
}
|