@agile-team/wl-skills-gate 0.13.1 → 0.13.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 CHANGED
@@ -1,5 +1,20 @@
1
1
  # 变更记录
2
2
 
3
+ ## 0.13.3
4
+
5
+ - 兼容 Jenkins 配置回读中的 UTF-8 XML 1.1 声明,保留 DTD、实体、控制字符及任务契约的严格校验。
6
+ - 增加 Jenkins 创建任务后规范化 XML 的往返回归,避免仅回显请求内容的模拟遗漏。
7
+ - 复用公共任务时同时核验串行执行配置,拒绝丢失并发保护的漂移。
8
+
9
+ ## 0.13.2
10
+
11
+ - Jenkins运维巡检不依赖项目登记和中央配置即可发现Job、检查接入缺项并发送流水线机器人提醒。
12
+ - 节点安装自动生成统一巡检配置;doctor支持每5分钟复查、缺项去重、恢复与复发记录,真实飞书ID核验后才@。
13
+ - 干净检出的待安装依赖交由固定CHECK准备;动态仓库/分支需确认,未受控旧部署任务明确提示。
14
+ - 已核验项目承接固定SHA的受控CHECK,持久记录启动与实际运行编号;未知结果先对账,不盲目重发或续建。
15
+ - 九维机器取证、问题清零、本人Review授权及独立签发继续复用现有Gate执行链;运维巡检不授予构建权限。
16
+
17
+
3
18
  ## [0.13.1] — 2026-10-22
4
19
 
5
20
  - Jenkins安装自动记录实际Node目录;服务器命令别名使用固定Node绝对路径,公共入口及各角色任务自动设置Node/npm的PATH。各节点可用受控WL_NODE_BIN_DIR覆盖本机差异。
package/README.md CHANGED
@@ -49,7 +49,7 @@ Gate 是质量执行与裁决层,复用项目已有检查器和适用的 WL
49
49
  wl-quality init
50
50
  ```
51
51
 
52
- 本轮统一使用`0.13.1`固定版本;交付包与批准安装源的版本和字节摘要须一致,安装脚本自动核验。
52
+ 本轮统一使用`0.13.3`固定版本;交付包与批准安装源的版本和字节摘要须一致,安装脚本自动核验。
53
53
 
54
54
  | 项目 | 依赖装在哪里 | 业务构建 |
55
55
  | --- | --- | --- |
package/bin/wl-quality.js CHANGED
@@ -23,6 +23,7 @@ try {
23
23
  if(o['--confirm'])result=applyJenkinsInstallation({...input,confirm:true,planHash:o['--plan-hash']});
24
24
  else if(!o['--preview']&&process.stdin.isTTY&&process.stdout.isTTY&&!o['--json']){const {createInterface}=await import('node:readline/promises');console.log(JSON.stringify(plan,null,2));const prompt=createInterface({input:process.stdin,output:process.stdout});try{if((await prompt.question('输入1确认准备公共入口,Enter取消:')).trim()==='1')result=applyJenkinsInstallation({...input,confirm:true,planHash:plan.planHash});}finally{prompt.close();}}
25
25
  console.log(JSON.stringify(result,null,2));
26
- }else if(command==='jenkins'&&args[0]==='bootstrap'){args.shift();const {runBootstrapCli}=await import('../lib/jenkins-node-bootstrap.js');process.exitCode=await runBootstrapCli(args);}
26
+ }else if(command==='jenkins'&&args[0]==='ops'){args.shift();const {runOpsCli}=await import('../lib/jenkins-ops.js');process.exitCode=await runOpsCli(args);}
27
+ else if(command==='jenkins'&&args[0]==='bootstrap'){args.shift();const {runBootstrapCli}=await import('../lib/jenkins-node-bootstrap.js');process.exitCode=await runBootstrapCli(args);}
27
28
  else throw Error('用法:wl-quality init;wl-quality jenkins context|install|bootstrap。');
28
29
  }catch(error){console.error('[wl-quality] '+error.message);process.exitCode=2;}
@@ -27,7 +27,7 @@ wl-quality init
27
27
 
28
28
  ```bash
29
29
  npm exec --yes --ignore-scripts --registry=https://registry.npmjs.org/ \
30
- --package=@agile-team/wl-skills-gate@0.13.1 -- wl-quality init
30
+ --package=@agile-team/wl-skills-gate@0.13.3 -- wl-quality init
31
31
  ```
32
32
 
33
33
  向导展示差异与实际安装计划,确认后执行;检查异常不能当成零问题。接入后日常由Hook自动检查;正式Review、远端强制检查、群消息与报告权限仍按现场清单验收。
@@ -63,7 +63,7 @@ node('wl-quality-router') { load('/opt/wl-quality/gate-validation/jenkins/entry.
63
63
 
64
64
  ## 当前交付边界
65
65
 
66
- 本轮以0.13.1固定正式版本交付。包发布和隔离测试不代表两业务仓库或远端已接通。正式Review后的自动续建、验证构建结果/制品采集、紧凑检查卡片可靠投递已通过隔离贯通测试;真实项目配置、中央服务、TEST任务和飞书身份仍需现场交接/验收。
66
+ 本轮以0.13.3固定正式版本交付。包发布和隔离测试不代表两业务仓库或远端已接通。正式Review后的自动续建、验证构建结果/制品采集、紧凑检查卡片可靠投递已通过隔离贯通测试;真实项目配置、中央服务、TEST任务和飞书身份仍需现场交接/验收。
67
67
 
68
68
  团队操作以交付包根目录的完整或运维接入操作说明为准,本文供维护者排查底层契约。
69
69
 
@@ -157,3 +157,16 @@ node('wl-quality-router') { load('/opt/wl-quality/gate-validation/jenkins/entry.
157
157
  ## 验收
158
158
 
159
159
  至少演练:正常完整组合、缺/旧 Review、当前 CI 失败或重试、错 SHA、替换制品/配置、错环境、无授权/重放、冒烟失败与显式恢复。成功与失败都保留原始报告和作业 ID;真实 Jenkins 权限、实际部署/冒烟与群投递另留现场证据。
160
+
161
+
162
+ ## 运维巡检:项目尚未接入也能先运行
163
+
164
+ 服务器统一入口安装后,先创建`wl-quality-fleet`运行“准备公共任务”,再运行`wl-quality-doctor`。它独立读取Jenkins任务、隔离检出明确的目标分支、检查Gate及项目质量配置,并用流水线机器人发送缺项提醒。无需先手工创建空的bindings/fleet/automation文件。
165
+
166
+ `jenkins-ops.json`由安装器首次生成;默认盘点可读任务中的dev、sit、uat、pre、prd、main及test。只读凭据及CI机器人的两项凭据在Jenkins配置一次。动态分支或无法唯一定位的内联脚本,在该文件的`jobMappings`一次确认,不执行项目Jenkinsfile来猜。责任人和已核验飞书身份也在此登记;缺映射会明确提示,仍发送缺项记录。
167
+
168
+ 在doctor勾选`ENABLE_MONITOR`运行一次后,每5分钟自动复查。同一缺项不重复刷屏,变化、恢复及复发留存新记录。`RETRY_NOTIFICATION`只用于核对群记录后的明确补发;`RETRY_UNCONFIRMED_CHECK`只用于核对实际队列后的重试,两项均默认关闭,可能重复。
169
+
170
+ 项目声明/锁/规则/证据计划未就绪时只告警,不把缺项计为代码风险,也不标记质量通过。已批准且锁定但尚未安装的依赖由CHECK自动准备。项目与登记、实际公共入口、目标分支触发及中央前置配置通过后,巡检可承接该项目固定SHA的CHECK;原有目标分支Hook继续负责后续变更触发。九维证据、实名Review授权、独立签发和BUILD准入仍由各自受控任务执行。未迁移的旧部署/回滚Job保持“未受控”,不能据此声称已经拦截;其Configure权限须限制在批准维护者。
171
+
172
+ 本轮JSON及HTML在doctor构建归档可访问,持久记录保存在中央配置目录的`ops-state/`。普通群成员仍需Jenkins报告只读权限;通知送达、真实@、CPS与插件接线均需现场验证。
package/docs/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # wl-skills-gate 技术手册索引
2
2
 
3
- 当前正式版本0.13.1。团队操作以交付包根目录主说明为准,见[16 项目与运维入口](16-project-ci-integration.md);本索引提供工具契约与参数参考。
3
+ 当前正式版本0.13.3。团队操作以交付包根目录主说明为准,见[16 项目与运维入口](16-project-ci-integration.md);本索引提供工具契约与参数参考。
4
4
 
5
5
  授权声明见 [LICENSE](../LICENSE);公开获取不授予使用、复制、修改或再分发授权,申请入口为 [仓库 Issues](https://github.com/ChenyCHENYU/wl-skills-gate/issues)。
6
6
 
@@ -6,6 +6,7 @@ import {controlledPath} from './control-store.js';
6
6
  import {stableStringify} from './util.js';
7
7
  import {nativePipelineEntry} from './jenkins-entry-contract.js';
8
8
  import {jenkinsBootstrapHelper} from './jenkins-bootstrap.js';
9
+ import {defaultOpsConfiguration} from './jenkins-ops.js';
9
10
  const assert=(ok,m)=>{if(!ok)throw Error(m);};
10
11
  const quote=v=>"'"+v.replaceAll("'","'\\''")+"'";
11
12
  export function planJenkinsInstallation(options={}){
@@ -19,7 +20,8 @@ export function planJenkinsInstallation(options={}){
19
20
  const loader=jenkinsBootstrapHelper()+readFileSync(path.join(GATE_ROOT,'templates/ci/jenkins-loader.groovy'),'utf8').replaceAll('__WL_GATE_ROOT__',GATE_ROOT).replaceAll('__WL_CLUSTER_FILE__',clusterFile).replaceAll('__WL_RUNTIME_HASH__',gateRuntimeHash()).replaceAll('__WL_NODE_BIN_DIR__',nodeBinDir);
20
21
  const nativeEntry={loaderFile,loaderHash:digest(loader),runtimeHash:gateRuntimeHash()};
21
22
  const c={schemaVersion:1,kind:'wl-quality-jenkins-cluster',jenkinsOrigin:u.origin+'/',registryFiles,configDir,nodeBinDir,agents:{central:centralAgentLabel,review:reviewSignerAgentLabel},nativeEntry};
22
- const files=[{file:loaderFile,text:loader,mode:0o644},{file:aliasFile,text:'#!/bin/sh\nset -eu\nPATH='+quote(nodeBinDir)+':"$PATH"\nexport PATH\nexec '+quote(process.execPath)+' '+quote(path.join(GATE_ROOT,'bin/wl-quality.js'))+' "$@"\n',mode:0o755},{file:clusterFile,text:JSON.stringify(c,null,2)+'\n',mode:0o600}].map(f=>{controlledPath(f.file);const before=existsSync(f.file)?readFileSync(f.file,'utf8'):null;return {...f,before,action:before===f.text&&(lstatSync(f.file).mode&0o777)===f.mode?'unchanged':before===null?'create':'update'};});
23
+ const opsFile=path.join(configDir,'jenkins-ops.json');
24
+ const files=[{file:loaderFile,text:loader,mode:0o644},{file:aliasFile,text:'#!/bin/sh\nset -eu\nPATH='+quote(nodeBinDir)+':"$PATH"\nexport PATH\nexec '+quote(process.execPath)+' '+quote(path.join(GATE_ROOT,'bin/wl-quality.js'))+' "$@"\n',mode:0o755},{file:clusterFile,text:JSON.stringify(c,null,2)+'\n',mode:0o600},{file:opsFile,text:existsSync(opsFile)?readFileSync(controlledPath(opsFile),'utf8'):JSON.stringify(defaultOpsConfiguration(),null,2)+'\n',mode:0o600}].map(f=>{controlledPath(f.file);const before=existsSync(f.file)?readFileSync(f.file,'utf8'):null;return {...f,before,action:before===f.text&&(lstatSync(f.file).mode&0o777)===f.mode?'unchanged':before===null?'create':'update'};});
23
25
  const plan={kind:'wl-quality-installation-plan',prefix,configDir,runtimeHash:gateRuntimeHash(),files,nativeEntry,pipelineScript:nativePipelineEntry(nativeEntry),nodeEnvironment:{'PATH+WLQUALITY':path.join(prefix,'bin'),WL_NODE_BIN_DIR:nodeBinDir,WL_QUALITY_CLUSTER_CONFIG:clusterFile},routerLabel:'wl-quality-router'};return {...plan,planHash:digest(stableStringify(plan)),status:'preview'};
24
26
  }
25
27
  export function applyJenkinsInstallation(options={}){
@@ -122,6 +122,7 @@ export function assertBoundCheckout(context, root = process.cwd(), env = process
122
122
  assert(!source.dirty && sha === git(['rev-parse', `refs/remotes/origin/${b.branch}`], root), '源码未提交、分支错误或本轮checkout已变化');
123
123
  assert(git(['remote','get-url','origin'], root) === b.repository, '实际检出仓库与受控登记不一致');
124
124
  if (context.mode === 'check') {
125
+ assert(!env.WL_GATE_DISPATCH_ID || env.WL_REVIEW_SHA === sha, '运维承接候选与本轮检出SHA不一致;分支已前进,重新巡检,不检查旧版本冒充当前版本');
125
126
  assert(!env.gitlabActionType || env.gitlabAfter, '自动Push事件缺少实际目标SHA');
126
127
  assert(!env.gitlabAfter || env.gitlabAfter === sha, '排队期间分支已变化;旧事件不能冒充当前版本');
127
128
  } else assert(env.WL_REVIEW_SHA === sha, '受控续建获批SHA与当前目标分支不一致');
@@ -0,0 +1,162 @@
1
+ import {createHash} from 'node:crypto';
2
+ import {fleetClient,jobPath,fleetAssert as assert} from './jenkins-fleet-client.js';
3
+ import {parseXml} from './xml.js';
4
+ import {isManagedJob,xmlDescendants} from './jenkins-job-config.js';
5
+
6
+ const hash=value=>createHash('sha256').update(value).digest('hex');
7
+ const gap=(code,message)=>({code,message});
8
+ const nodes=(node,name)=>xmlDescendants(node).filter(v=>v.name===name);
9
+ const child=(node,name)=>{const values=node.children.filter(v=>v.name===name);return values.length===1&&!values[0].children.length?values[0].text.trim():null;};
10
+ const identifier=value=>typeof value==='string'&&/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(value);
11
+ const safeBranch=value=>typeof value==='string'&&value.length>0&&value.length<=250&&!/[\s\u0000-\u001f\u007f$\\~^:?\[\]]/.test(value)&&!value.includes('..')&&!value.includes('@{');
12
+ const safeCredential=value=>typeof value==='string'&&value.length<=250&&!/[\u0000-\u001f\u007f${}]/.test(value);
13
+ function repository(value){
14
+ if(typeof value!=='string'||!value||value.length>2048||/[\s\u0000-\u001f\u007f${}]/.test(value))return null;
15
+ if(/^git@[A-Za-z0-9.-]+:[A-Za-z0-9_./-]+$/.test(value))return value;
16
+ try{const u=new URL(value);if(!['https:','ssh:'].includes(u.protocol)||!u.hostname||u.password||u.search||u.hash||u.pathname==='/'||!u.pathname)return null;if(u.username&&!(u.protocol==='ssh:'&&u.username==='git'))return null;return value;}catch{return null;}
17
+ }
18
+ const dynamic=value=>{const m=/^(?:\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*))$/.exec(value||'');return m?.[1]||m?.[2]||null;};
19
+ function branches(values,gaps){
20
+ const names=[],variables=new Set();
21
+ for(const value of values){const key=dynamic(value);if(key){variables.add(key);continue;}if(safeBranch(value))names.push(value);else gaps.push(gap('unsupported-branch','分支表达式不是可确认的静态引用。'));}
22
+ if(variables.size>1||variables.size&&names.length){gaps.push(gap('ambiguous-branch','同一SCM存在多个分支来源,须明确项目分支映射。'));return {branches:[]};}
23
+ if(variables.size){gaps.push(gap('dynamic-branch','分支取自运行参数;参数默认值不是本轮实际分支。'));return {branches:[],dynamicBranch:[...variables][0]};}
24
+ return {branches:[...new Set(names)]};
25
+ }
26
+
27
+ // A deliberately small lexical reader, never a Groovy interpreter. Strings are
28
+ // opaque, comments are discarded, and only direct environment literals and
29
+ // named git arguments are considered. Unsupported expressions stay unknown.
30
+ function tokens(script){
31
+ const out=[];let i=0;
32
+ while(i<script.length){
33
+ const c=script[i];
34
+ if(c==='\r'||c==='\n'){out.push({type:'break',value:'\n'});i++;continue;}
35
+ if(/\s/.test(c)){i++;continue;}
36
+ if(script.startsWith('//',i)){const end=script.indexOf('\n',i+2);i=end<0?script.length:end;continue;}
37
+ if(script.startsWith('/*',i)){const end=script.indexOf('*/',i+2);if(end<0)return null;i=end+2;continue;}
38
+ if(script.startsWith('$/',i)){const end=script.indexOf('/$',i+2);if(end<0)return null;out.push({type:'opaque',value:''});i=end+2;continue;}
39
+ if(c==='"'||c==="'"){
40
+ const triple=script.startsWith(c.repeat(3),i),delimiter=triple?c.repeat(3):c,start=i+delimiter.length;let j=start,escaped=false;
41
+ while(j<script.length){if(script[j]==='\\'){escaped=true;j+=2;continue;}if(script.startsWith(delimiter,j))break;j++;}
42
+ if(j>=script.length)return null;
43
+ const value=script.slice(start,j);out.push({type:triple||escaped||value.includes('$')?'opaque':'string',value:triple||escaped||value.includes('$')?'':value});i=j+delimiter.length;continue;
44
+ }
45
+ // Slashy Groovy strings may contain fake calls. Treat the entire literal as
46
+ // opaque rather than accidentally discovering its text as executable code.
47
+ if(c==='/'&&['=','(',':',',','['].includes(out.at(-1)?.value)){
48
+ let j=i+1;while(j<script.length){if(script[j]==='\\'){j+=2;continue;}if(script[j]==='/')break;j++;}if(j>=script.length)return null;out.push({type:'opaque',value:''});i=j+1;continue;
49
+ }
50
+ const word=/^[A-Za-z_][A-Za-z0-9_]*/.exec(script.slice(i));if(word){out.push({type:'word',value:word[0]});i+=word[0].length;continue;}
51
+ out.push({type:'punct',value:c});i++;
52
+ }
53
+ return out;
54
+ }
55
+ function inlineScm(script,gaps){
56
+ const t=tokens(script);if(!t){gaps.push(gap('unsupported-inline-script','内联脚本语法不能安全静态识别,未执行脚本。'));return [];}
57
+ const literal=new Map(),assigned=new Set();let depth=0,environmentDepth=null;
58
+ for(let i=0;i<t.length;i++){
59
+ if(t[i].value==='{'&&t[i-1]?.value==='environment')environmentDepth=depth+1;
60
+ if(t[i].value==='{')depth++;
61
+ if(environmentDepth===depth&&['GIT_URL','GIT_CREDENTIAL_ID'].includes(t[i].value)&&t[i+1]?.value==='='){
62
+ const key=t[i].value,end=t[i+3],valid=t[i+2]?.type==='string'&&(!end||end.type==='break'||[';','}'].includes(end.value));
63
+ if(assigned.has(key)||!valid)literal.set(key,null);else literal.set(key,t[i+2].value);assigned.add(key);
64
+ }
65
+ if(t[i].value==='}'){if(environmentDepth===depth)environmentDepth=null;depth--;}
66
+ }
67
+ const candidates=[];
68
+ for(let i=0;i<t.length;i++){
69
+ if(t[i].type!=='word'||t[i].value!=='git'||['.','def'].includes(t[i-1]?.value))continue;
70
+ let j=i+1,paren=false;if(t[j]?.value==='('){paren=true;j++;}if(t[j]?.type!=='word'||t[j+1]?.value!==':')continue;
71
+ const args=new Map();let valid=true;
72
+ while(j<t.length){
73
+ while(t[j]?.type==='break')j++;
74
+ if(t[j]?.type!=='word'||t[j+1]?.value!==':'){valid=false;break;}
75
+ const key=t[j].value;j+=2;let value;
76
+ if(t[j]?.type==='string'){value={literal:t[j].value};j++;}
77
+ else if(t[j]?.value==='env'&&t[j+1]?.value==='.'&&t[j+2]?.type==='word'){value={env:t[j+2].value};j+=3;}
78
+ else{valid=false;break;}
79
+ if(args.has(key)){valid=false;break;}args.set(key,value);
80
+ if(t[j]?.value===','){j++;continue;}
81
+ if(paren){while(t[j]?.type==='break')j++;if(t[j]?.value!==')')valid=false;}
82
+ else if(t[j]&&t[j].type!=='break'&&![';','}'].includes(t[j].value))valid=false;
83
+ break;
84
+ }
85
+ const url=args.get('url'),credential=args.get('credentialsId'),branch=args.get('branch');
86
+ const resolve=(v,key)=>v?.literal??(v?.env===key?literal.get(key):undefined);
87
+ const repo=repository(resolve(url,'GIT_URL')),cred=resolve(credential,'GIT_CREDENTIAL_ID');
88
+ if(!valid||!repo||credential&&!safeCredential(cred)||!branch||branch.env&&!identifier(branch.env)||branch.literal!==undefined&&!safeBranch(branch.literal)){
89
+ gaps.push(gap('unsupported-inline-checkout','内联git检出不是唯一完整的字面参数,须明确仓库、凭据及分支映射。'));continue;
90
+ }
91
+ const b=branch.env?{branches:[],dynamicBranch:branch.env}:{branches:[branch.literal]};
92
+ candidates.push({repository:repo,credentialId:credential?cred:'',...b});
93
+ if(branch.env)gaps.push(gap('dynamic-branch','分支取自运行参数;参数默认值不是本轮实际分支。'));
94
+ }
95
+ if(!candidates.length)gaps.push(gap('scm-unknown','未发现可安全识别的SCM配置,未执行内联Groovy。'));
96
+ else gaps.push(gap('inline-candidate-unapproved','内联脚本仅静态提取候选,实际运行及接入范围仍须确认。'));
97
+ return candidates;
98
+ }
99
+ function scm(tree,gaps){
100
+ const candidates=[],gitScms=xmlDescendants(tree).filter(n=>n.attrs.class==='hudson.plugins.git.GitSCM'||n.name==='hudson.plugins.git.GitSCM');
101
+ for(const node of gitScms){
102
+ const remotes=nodes(node,'hudson.plugins.git.UserRemoteConfig');
103
+ if(remotes.length!==1){gaps.push(gap('ambiguous-repository','一个SCM包含多个或缺失仓库,未自动选择。'));continue;}
104
+ const remote=remotes[0],repo=repository(child(remote,'url')),credentialId=child(remote,'credentialsId')??'';
105
+ if(!repo||!safeCredential(credentialId)){gaps.push(gap('invalid-repository','仓库地址包含凭据、动态表达式或不支持的格式,未保留地址。'));continue;}
106
+ const specs=nodes(node,'hudson.plugins.git.BranchSpec').map(n=>child(n,'name'));
107
+ if(!specs.length)gaps.push(gap('branch-unknown','SCM未明确声明分支,未使用默认分支推断。'));
108
+ candidates.push({repository:repo,credentialId,...branches(specs,gaps)});
109
+ }
110
+ if(!gitScms.length&&tree.name==='flow-definition'){
111
+ const definitions=tree.children.filter(n=>n.name==='definition');
112
+ if(definitions.length===1&&definitions[0].attrs.class==='org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition'){
113
+ const scripts=definitions[0].children.filter(n=>n.name==='script');
114
+ if(scripts.length===1&&!scripts[0].children.length)candidates.push(...inlineScm(scripts[0].text,gaps));
115
+ }
116
+ }
117
+ const unique=[...new Map(candidates.map(v=>[JSON.stringify(v),v])).values()];
118
+ if(new Set(unique.map(v=>v.repository)).size>1){gaps.push(gap('ambiguous-repository','任务含多个仓库候选,须明确业务仓库映射,未自动选择。'));return [];}
119
+ if(!unique.length&&!gaps.length)gaps.push(gap('scm-unknown','未发现可安全识别的Git SCM配置。'));
120
+ return unique;
121
+ }
122
+ function parameterDefaults(tree,candidates,gaps){
123
+ const names=new Set(candidates.map(v=>v.dynamicBranch).filter(Boolean)),values=[];
124
+ for(const name of names){
125
+ const matches=xmlDescendants(tree).filter(n=>/ParameterDefinition$/.test(n.name)&&child(n,'name')===name);
126
+ if(matches.length===1){const value=child(matches[0],'defaultValue');if(value&&safeBranch(value))values.push({name,defaultValue:value});}
127
+ else if(matches.length>1)gaps.push(gap('ambiguous-parameter','分支参数重复声明,未采纳参数默认值。'));
128
+ }
129
+ return values;
130
+ }
131
+
132
+ // First-use read-only discovery intentionally has no dependency on project
133
+ // bindings, fleet approval files, Gate installation or central automation.
134
+ export async function discoverJenkinsJobs({jenkinsOrigin,inventory={},env=process.env,fetchImpl=fetch}={}){
135
+ assert(inventory&&typeof inventory==='object'&&!Array.isArray(inventory)&&Object.keys(inventory).every(k=>['roots','maxJobs','maxDepth'].includes(k)),'盘点参数无效');
136
+ const options={roots:[''],maxJobs:1000,maxDepth:8,...inventory};
137
+ assert(Array.isArray(options.roots)&&options.roots.length>0&&options.roots.length<=100&&options.roots.every(r=>typeof r==='string'),'需要明确的盘点根目录');
138
+ assert(Number.isInteger(options.maxJobs)&&options.maxJobs>0&&options.maxJobs<=2000&&Number.isInteger(options.maxDepth)&&options.maxDepth>0&&options.maxDepth<=12,'盘点数量或深度预算无效');
139
+ for(const root of options.roots)if(root)jobPath(root);
140
+ const client=fleetClient({origin:jenkinsOrigin,env,fetchImpl}),queue=[...new Set(options.roots)].map(root=>({root,depth:0})),seen=new Set(),visited=new Set(),jobs=[],errors=[];let budget=false;
141
+ while(queue.length&&!budget){
142
+ const {root,depth}=queue.shift();if(visited.has(root))continue;visited.add(root);
143
+ try{
144
+ const data=await client.json((root?jobPath(root):'')+'api/json?tree=jobs[name,_class]');assert(Array.isArray(data.jobs),'盘点接口未返回任务列表');
145
+ for(const item of data.jobs){
146
+ if(!item||typeof item.name!=='string'||item.name.includes('/')){errors.push({root,code:'invalid-job',reason:'盘点返回无效任务名称,未继续该项。'});continue;}
147
+ const job=root?root+'/'+item.name:item.name;try{jobPath(job);}catch{errors.push({root,code:'invalid-job',reason:'盘点返回不支持的任务名称,未继续该项。'});continue;}
148
+ if(seen.has(job))continue;if(seen.size>=options.maxJobs){errors.push({root,code:'job-budget-exceeded',reason:'任务数超过盘点预算,结果不完整。'});budget=true;break;}seen.add(job);
149
+ const type=typeof item._class==='string'&&/^[A-Za-z_$][A-Za-z0-9_.$]{0,249}$/.test(item._class)?item._class:'unknown';
150
+ if(/(?:^|\.)(?:Folder|OrganizationFolder|WorkflowMultiBranchProject)$/.test(type)){
151
+ if(depth>=options.maxDepth)errors.push({job,code:'depth-budget-exceeded',reason:'目录超过盘点深度,结果不完整。'});else queue.push({root:job,depth:depth+1});continue;
152
+ }
153
+ const record={job,type,status:'unreadable',managed:false,configHash:null,scmCandidates:[],discoveryGaps:[]};
154
+ try{
155
+ const xml=await client.config(job);assert(xml!==null,'盘点期间任务已消失:HTTP 404');record.configHash=hash(xml);const tree=parseXml(xml);record.managed=isManagedJob(xml);record.scmCandidates=scm(tree,record.discoveryGaps);const defaults=parameterDefaults(tree,record.scmCandidates,record.discoveryGaps);if(defaults.length)record.parameterDefaults=defaults;record.status=record.discoveryGaps.length?'attention':'read';
156
+ }catch(error){record.discoveryGaps.push(gap('job-unreadable',String(error.message).replace(/https?:\/\/[^\s]+/g,'[URL omitted]').slice(0,300)));}
157
+ record.discoveryGaps=[...new Map(record.discoveryGaps.map(v=>[v.code,v])).values()];jobs.push(record);
158
+ }
159
+ }catch(error){errors.push({root,code:'root-unreadable',reason:String(error.message).replace(/https?:\/\/[^\s]+/g,'[URL omitted]').slice(0,300)});}
160
+ }
161
+ return {kind:'jenkins-ops-inventory',status:errors.length||jobs.some(v=>v.status!=='read')?'attention':'inventoried',jobs,errors,remoteModified:false};
162
+ }
@@ -14,7 +14,7 @@ export async function planJenkinsBootstrap({configFile,env=process.env,fetchImpl
14
14
  const entries=[];
15
15
  for(const [role,job] of Object.entries(MAINTENANCE_JOBS)){
16
16
  const before=await client.config(job);let action='create',reason='';
17
- if(before!==null){try{const v=jobContract(before);const ownedSchedule=['automation','review'].includes(role)&&v.triggers.length===1&&v.triggers[0].type==='hudson.triggers.TimerTrigger'&&v.triggers[0].fields.spec==='* * * * *';assert(v.script===script.trim()&&v.sandbox==='true'&&v.disabled==='false'&&(!v.triggers.length||ownedSchedule),'同名任务存在自定义脚本/触发器或已禁用');action='unchanged';}catch(e){action='blocked';reason=e.message;}}
17
+ if(before!==null){try{const v=jobContract(before);const schedule=role==='node'?'H/5 * * * *':'* * * * *';const ownedSchedule=['node','automation','review'].includes(role)&&v.triggers.length===1&&v.triggers[0].type==='hudson.triggers.TimerTrigger'&&v.triggers[0].fields.spec===schedule;assert(v.script===script.trim()&&v.sandbox==='true'&&v.disabled==='false'&&v.concurrent&&(!v.triggers.length||ownedSchedule),'同名任务存在自定义脚本/触发器或已禁用');action='unchanged';}catch(e){action='blocked';reason=e.message;}}
18
18
  // The operator creates this single initial job; never mutate a running self.
19
19
  if(role==='fleet'&&before===null){action='blocked';reason='先创建wl-quality-fleet并填入生成的一行入口';}
20
20
  entries.push({role,job,action,...(reason?{reason}:{}),beforeHash:before===null?null:digest(before)});
@@ -0,0 +1,158 @@
1
+ import path from 'node:path';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { readControlledJson, lockedJson } from './control-store.js';
4
+ import { digest } from './jenkins-integration.js';
5
+ import { stableStringify } from './util.js';
6
+ import { inspectJenkinsReadiness } from './jenkins-readiness.js';
7
+ import { nativePipelineEntry } from './jenkins-entry-contract.js';
8
+ import { jobContract } from './jenkins-job-config.js';
9
+ import { fleetClient, jobPath } from './jenkins-fleet-client.js';
10
+
11
+ const assert = (ok, message) => { if (!ok) throw new Error(message); };
12
+ const hash = value => digest(stableStringify(value));
13
+ const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
14
+ const SHA = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
15
+ const MAX_ATTEMPTS = 3, REPORT_AGE = 30 * 60 * 1000, NEW_CLAIM_GRACE = 60 * 1000;
16
+ const params = run => {
17
+ const rows = (Array.isArray(run?.actions) ? run.actions : []).flatMap(a => Array.isArray(a?.parameters) ? a.parameters : []);
18
+ const values = name => rows.filter(p => p?.name === name).map(p => p.value);
19
+ const dispatch = values('WL_GATE_DISPATCH_ID'), sha = values('WL_REVIEW_SHA');
20
+ return dispatch.length === 1 && sha.length === 1 ? { dispatchId: dispatch[0], sha: sha[0] } : null;
21
+ };
22
+ const matches = (run, record) => { const p = params(run); return p?.dispatchId === record.dispatchId && p.sha === record.sha; };
23
+ const context = async clusterFile => {
24
+ // The main operations module re-exports this module. Resolve context only
25
+ // inside calls, after module initialization, to avoid a cyclic startup read.
26
+ const { opsContext } = await import('./jenkins-ops.js');
27
+ assert(typeof opsContext === 'function', '运维上下文入口未准备');
28
+ return opsContext(clusterFile);
29
+ };
30
+ const publicRecord = (record, status, extra = {}) => ({ status, job: record.job, sha: record.sha, dispatchId: record.dispatchId, attempt: record.attempt,
31
+ ...(record.buildNumber ? { buildNumber: record.buildNumber } : {}), ...extra });
32
+
33
+ function assertState(state) {
34
+ assert(state?.schemaVersion === 1 && state.claims && typeof state.claims === 'object' && !Array.isArray(state.claims), 'CHECK承接台账损坏;先恢复真实台账,不能忽略历史重新触发');
35
+ }
36
+ function assertRecord(record) {
37
+ assert(record && ['claimed', 'started'].includes(record.status) && UUID.test(record.dispatchId || '') && UUID.test(record.runId || '')
38
+ && /^[a-f0-9]{24}$/.test(record.key || '') && SHA.test(record.sha || '') && Number.isInteger(record.attempt) && record.attempt >= 1 && record.attempt <= MAX_ATTEMPTS
39
+ && Number.isFinite(Date.parse(record.claimedAt)) && Array.isArray(record.history), '已有CHECK启动记录不完整;需人工核对,不能盲目重新触发');
40
+ }
41
+
42
+ async function currentJob(client, c, project, binding) {
43
+ const xml = await client.config(project.checkJob);
44
+ assert(xml && digest(xml) === project.configHash, 'CHECK实际Job配置已漂移或无法读取,重新巡检后再承接');
45
+ const actual = jobContract(xml);
46
+ const triggers = actual.triggers.filter(v => v.type === 'com.dabsquared.gitlabjenkins.GitLabPushTrigger');
47
+ assert(actual.script === nativePipelineEntry(c.nativeEntry).trim() && actual.sandbox === 'true' && actual.disabled === 'false'
48
+ && triggers.length === 1 && triggers[0].fields.includeBranchesSpec === binding.branch && triggers[0].fields.triggerOnPush === 'true'
49
+ && triggers[0].fields.branchFilterType === 'NameBasedFilter', 'CHECK实际受控入口、运行状态或目标分支触发已变化,重新巡检');
50
+ for (const name of ['WL_REVIEW_SHA', 'WL_GATE_DISPATCH_ID']) assert(actual.parameters.filter(p => p.name === name && p.type === 'hudson.model.StringParameterDefinition').length === 1, 'CHECK缺少唯一受控候选SHA或启动追踪参数,不能承接');
51
+ }
52
+
53
+ async function fetchRun(client, job, number) {
54
+ const response = await client.request('GET', `${jobPath(job)}${number}/api/json?tree=number,building,result,actions[parameters[name,value]],artifacts[fileName,relativePath]`);
55
+ if (response.status !== 200) return null;
56
+ try { const run = JSON.parse(response.body); return String(run.number) === String(number) ? run : null; } catch { return null; }
57
+ }
58
+ async function reconcileUnknown(client, record) {
59
+ try {
60
+ const data = await client.json(`${jobPath(record.job)}api/json?tree=builds[number,building,result,actions[parameters[name,value]],artifacts[fileName,relativePath]]{0,25}`);
61
+ if (!Array.isArray(data.builds) || data.builds.length > 25) return { status: 'unknown', reason: '未取得完整的近期CHECK记录;不自动重复启动' };
62
+ const found = data.builds.filter(run => matches(run, record));
63
+ if (found.length !== 1 || !/^[1-9][0-9]*$/.test(String(found[0]?.number || ''))) return { status: 'unknown', reason: found.length > 1 ? '同一追踪ID匹配多次运行,需人工核对;不自动重发' : '近期运行尚未找到唯一追踪ID与候选SHA;不自动重发', notFound: found.length === 0 };
64
+ record.status = 'started'; record.buildNumber = String(found[0].number); record.reconciledAt = new Date().toISOString();
65
+ return { status: 'found', run: found[0] };
66
+ } catch { return { status: 'unknown', reason: 'Jenkins运行对账未完成;保留本轮启动记录,不自动重发' }; }
67
+ }
68
+ async function failureKind(client, record, run, project) {
69
+ // Only explicit artifact metadata can establish absence. A broken/read-denied
70
+ // JSON response is not evidence that no quality check ran.
71
+ if (!Array.isArray(run.artifacts)) return 'unverified';
72
+ const reports = run.artifacts.filter(a => a.relativePath === 'quality-reports/quality-summary.json');
73
+ if (!reports.length) return 'execution';
74
+ if (reports.length !== 1) return 'unverified';
75
+ try {
76
+ const response = await client.request('GET', `${jobPath(record.job)}${record.buildNumber}/artifact/quality-reports/quality-summary.json`);
77
+ if (response.status !== 200) return 'unverified';
78
+ const summary = JSON.parse(response.body);
79
+ if (summary?.run?.commitSha !== record.sha || summary.run.project !== project.project || summary.run.stage !== 'ci' || !Array.isArray(summary.findings) || !Array.isArray(summary.checks)) return 'unverified';
80
+ if (summary.findings.length) return 'quality';
81
+ return summary.checks.length && summary.checks.every(check => check.status === 'passed') ? 'execution' : 'unverified';
82
+ } catch { return 'unverified'; }
83
+ }
84
+
85
+ export async function claimOpsCheck({ clusterFile, runId, key, env = process.env, fetchImpl = fetch, now = Date.now(), retryUnknown = false, retryKnownFailure = false }) {
86
+ const { c, bindings } = await context(clusterFile);
87
+ assert(UUID.test(runId || '') && /^[a-f0-9]{24}$/.test(key || ''), '需要本轮巡检身份');
88
+ const instant = typeof now === 'function' ? now() : now;
89
+ assert(Number.isFinite(instant), 'CHECK承接时间无效');
90
+ const report = readControlledJson(path.join(c.configDir, 'ops-state/runs', runId, 'report.json'));
91
+ const age = instant - Date.parse(report.checkedAt);
92
+ assert(report.kind === 'jenkins-ops-report' && report.schemaVersion === 1 && report.runId === runId && report.clusterHash === c.configHash
93
+ && report.jenkinsOrigin === c.jenkinsOrigin && Number.isFinite(age) && age >= 0 && age <= REPORT_AGE, '巡检报告不属于当前集群或已超过30分钟,重新巡检后再承接');
94
+ const projects = Array.isArray(report.projects) ? report.projects.filter(p => p.key === key) : [], p = projects.length === 1 ? projects[0] : null;
95
+ const bound = bindings.find(b => b.binding.id === p?.bindingId && b.registryFile === p?.registryFile);
96
+ const awaitingReconcile = p?.status === 'attention' && p.gaps?.length === 1 && p.gaps[0].id === 'CHECK-DISPATCH-UNKNOWN';
97
+ assert(Array.isArray(report.globalGaps) && report.globalGaps.length === 0 && p && Array.isArray(p.gaps) && (p.status === 'ready' && !p.gaps.length || awaitingReconcile)
98
+ && p.pinsVerified && p.entryVerified && bound && p.checkJob === bound.binding.jobs.check && p.job === p.checkJob && hash(bound.binding) === p.bindingHash
99
+ && SHA.test(p.sha || '') && /^[a-f0-9]{64}$/.test(p.configHash || ''), '仅本轮实际核验且已登记的CHECK可承接;不会启动旧部署或BUILD');
100
+ assert(inspectJenkinsReadiness({ clusterFile: c.file }).status === 'handover-files-ready', '承接前工程配置变化,重新巡检');
101
+ const client = fleetClient({ origin: c.jenkinsOrigin, env, fetchImpl });
102
+ await currentJob(client, c, p, bound.binding);
103
+ return lockedJson(path.join(c.configDir, 'ops-state/checks.json'), { schemaVersion: 1, kind: 'jenkins-ops-checks', claims: {} }, async state => {
104
+ assertState(state);
105
+ const id = hash({ job: p.checkJob, sha: p.sha, binding: p.bindingHash }), old = state.claims[id];
106
+ let canRetry = false;
107
+ if (old) {
108
+ assertRecord(old);
109
+ assert(old.job === p.checkJob && old.sha === p.sha && old.bindingHash === p.bindingHash, 'CHECK历史记录与当前批准范围不一致');
110
+ let run;
111
+ if (old.status === 'claimed') {
112
+ const reconciliation = await reconcileUnknown(client, old);
113
+ if (reconciliation.status === 'found') run = reconciliation.run;
114
+ else {
115
+ const claimAge = instant - Date.parse(old.claimedAt);
116
+ if (!(retryUnknown === true && reconciliation.notFound && claimAge >= NEW_CLAIM_GRACE)) return publicRecord(old, 'unknown', { reason: retryUnknown && claimAge < NEW_CLAIM_GRACE ? '新启动仍在一分钟观察窗口内,先等待并核对队列;不重复启动' : reconciliation.reason });
117
+ canRetry = true;
118
+ }
119
+ } else {
120
+ try { run = await fetchRun(client, old.job, old.buildNumber); } catch {}
121
+ if (!run || !matches(run, old)) return publicRecord(old, 'unknown', { reason: '实际CHECK运行未唯一核验追踪ID与候选SHA;不自动重复启动' });
122
+ }
123
+ if (run) {
124
+ if (run.building === true || run.result === 'SUCCESS') return publicRecord(old, 'already-started', { building: run.building === true, result: run.result || null });
125
+ if (run.building !== false || !['FAILURE', 'ABORTED'].includes(run.result)) return publicRecord(old, 'unknown', { reason: '实际运行尚未取得明确的终止结果;不自动重复启动' });
126
+ const kind = await failureKind(client, old, run, p);
127
+ old.lastResult = run.result; old.failureKind = kind;
128
+ if (kind === 'quality') return publicRecord(old, 'quality-failed', { reason: '本候选已有真实代码问题;修复并提交新版本后再检查,不重复刷同一候选' });
129
+ if (awaitingReconcile && retryUnknown !== true) return publicRecord(old, 'failed-needs-confirmation', { reason: '已核对到终止失败;本轮仅允许对账,确认修复执行环境后显式重试' });
130
+ if (kind !== 'execution' && retryKnownFailure !== true) return publicRecord(old, 'failed-needs-confirmation', { reason: '已确认运行失败,但未能排除质量失败;查看原件后显式重试' });
131
+ canRetry = true;
132
+ }
133
+ if (canRetry && old.attempt >= MAX_ATTEMPTS) return publicRecord(old, 'retry-limit', { reason: '同一候选已达到三次启动上限;查看实际CHECK原件,修复后由责任人明确处理' });
134
+ } else assert(!awaitingReconcile, '本轮只有待对账缺项,却没有实际启动台账;恢复台账后再处理');
135
+ const record = { status: 'claimed', runId, key, job: p.checkJob, sha: p.sha, bindingHash: p.bindingHash, dispatchId: randomUUID(), claimedAt: new Date(instant).toISOString(), attempt: old ? old.attempt + 1 : 1,
136
+ history: old ? [...old.history, Object.fromEntries(Object.entries(old).filter(([k]) => k !== 'history'))] : [] };
137
+ state.claims[id] = record;
138
+ // lockedJson fsyncs this record before this function returns. The caller
139
+ // may submit exactly this dispatchId/SHA, then acknowledge the actual run.
140
+ return publicRecord(record, 'claimed');
141
+ });
142
+ }
143
+
144
+ export async function acknowledgeOpsCheck({ clusterFile, runId, key, buildNumber, dispatchId }) {
145
+ const { c } = await context(clusterFile);
146
+ assert(UUID.test(runId || '') && /^[a-f0-9]{24}$/.test(key || ''), '需要本轮巡检身份');
147
+ assert(/^[1-9][0-9]*$/.test(String(buildNumber || '')), 'CHECK启动须记录实际Jenkins运行编号');
148
+ return lockedJson(path.join(c.configDir, 'ops-state/checks.json'), { schemaVersion: 1, kind: 'jenkins-ops-checks', claims: {} }, state => {
149
+ assertState(state);
150
+ const rows = Object.values(state.claims).filter(v => v.runId === runId && v.key === key && ['claimed', 'started'].includes(v.status));
151
+ assert(rows.length === 1, '不存在唯一待确认的本轮CHECK启动记录');
152
+ const record = rows[0]; assertRecord(record);
153
+ if (dispatchId !== undefined) assert(UUID.test(dispatchId || '') && dispatchId === record.dispatchId, '迟到或其他dispatchId不能确认当前CHECK启动;先对账实际运行');
154
+ assert(record.status !== 'started' || record.buildNumber === String(buildNumber), '本轮已确认其他实际运行编号,不能覆盖');
155
+ record.status = 'started'; record.buildNumber = String(buildNumber);
156
+ return publicRecord(record, 'started');
157
+ });
158
+ }
@@ -0,0 +1,125 @@
1
+ import { createHmac } from 'node:crypto';
2
+ import { notificationText } from './notification-style.js';
3
+ import { formatNotificationTime } from './notification-time.js';
4
+
5
+ // Operations readiness is independent from quality/admission. Missing project
6
+ // configuration must still produce an actionable CI notice, without requiring
7
+ // that project's governance config or any source-controlled notification code.
8
+ const clean = (value, max = 180) => String(value ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ')
9
+ .replace(/https?:\/\/[^\s<>)]+/g, raw => { try { const u = new URL(raw); return `${u.origin}${u.pathname}`; } catch { return '[地址已隐藏]'; } })
10
+ .replace(/\b(token|password|secret|authorization)\s*[:=]\s*\S+/gi, '$1=[已隐藏]').slice(0, max);
11
+ const md = value => clean(value).replace(/[\\`*_~\[\]<>]/g, '\\$&');
12
+ const safeId = id => typeof id === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(id) && id !== 'all';
13
+ const verifiedPerson = p => !!(p?.name && ['open_id', 'user_id'].includes(p.feishu?.idType) && safeId(p.feishu.id)
14
+ && (p.feishu.idType !== 'open_id' || p.feishu.id.startsWith('ou_'))
15
+ && (p.feishu.verified === true || p.feishu.identityVerified === true));
16
+ const category = id => /gate|package|runtime|node|python|java|tool|engine/i.test(id) ? 'Gate/执行环境'
17
+ : /owner|people|person|actor|mention|manager/i.test(id) ? '责任人/提醒'
18
+ : /credential|token|secret|auth|permission/i.test(id) ? '凭据/访问权限'
19
+ : /review|trust|sign/i.test(id) ? 'Review/准入'
20
+ : /hook|trigger|branch|event/i.test(id) ? '触发/分支接线'
21
+ : /registry|binding|config|policy|fleet|source|automation|project|check/i.test(id) ? '项目/检查配置' : '其他接入缺项';
22
+ const httpsUrl = value => { try { const u = new URL(value); return u.protocol === 'https:' && u.hostname && !u.username && !u.password && !u.hash ? u : null; } catch { return null; } };
23
+
24
+ function recipients(project, config) {
25
+ let owner = config.owners?.[project.key] ?? config.owners?.[project.project] ?? {};
26
+ if (typeof owner === 'string') owner = { technicalManager: owner };
27
+ const manager = project.technicalManager || owner.technicalManager;
28
+ const executor = project.executor || owner.executor;
29
+ const same = !!manager && !!executor && (manager === executor || (verifiedPerson(config.people?.[manager]) && verifiedPerson(config.people?.[executor]) && config.people[manager].feishu.id === config.people[executor].feishu.id));
30
+ const observer = same ? project.managerSubmissionNotify || owner.managerSubmissionNotify : null;
31
+ const actors = [...new Set([executor, manager, observer, ...(Array.isArray(owner.recipients) ? owner.recipients : [])].filter(v => typeof v === 'string' && v))];
32
+ return { manager, executor, observer, actors };
33
+ }
34
+
35
+ export function opsNotification(report, { people = {}, owners = {}, fallbackRecipients = [] } = {}) {
36
+ if (report?.kind !== 'jenkins-ops-report' || !Array.isArray(report.projects)
37
+ || report.projects.some(p => !p || !['attention', 'ready', 'unverified'].includes(p.status) || !Array.isArray(p.gaps))
38
+ || (report.globalGaps !== undefined && !Array.isArray(report.globalGaps))) throw new Error('需要真实运维接入检查报告');
39
+ const config = { people, owners }, projects = report.projects;
40
+ const globalGaps = report.globalGaps || [], gaps = [...globalGaps, ...projects.flatMap(p => p.gaps)];
41
+ const pending = projects.filter(p => p.status !== 'ready');
42
+ const unresolved = globalGaps.length > 0 || pending.length > 0 || gaps.length > 0 || report.status === 'attention';
43
+ const recovered = report.recovered === true && !unresolved;
44
+ const mentioned = new Set(), used = new Set(), missing = new Set();
45
+ const person = actor => {
46
+ if (!actor) { missing.add('责任人'); return '责任人待登记'; }
47
+ const p = people[actor];
48
+ if (verifiedPerson(p)) {
49
+ mentioned.add(p.feishu.id);
50
+ if (!used.has(p.feishu.id)) { used.add(p.feishu.id); return `<at id="${p.feishu.id}"></at>`; }
51
+ } else missing.add(actor);
52
+ return md(p?.name || actor);
53
+ };
54
+ const summaryCounts = { attention: projects.filter(p => p.status === 'attention').length, ready: projects.filter(p => p.status === 'ready').length, unverified: projects.filter(p => p.status === 'unverified').length };
55
+ const title = recovered ? '接入缺项已消除|转入九维检查' : unresolved ? '流水线接入检查|需处理' : '流水线接入检查|前置就绪';
56
+ const verdict = recovered ? '接入缺项已消除|转入现有九维检查与Review准入' : unresolved ? '暂停质量放行|先补齐接入缺项,修复后自动复检' : '接入前置就绪|进入现有九维检查与Review准入';
57
+ const elements = [notificationText(verdict, recovered ? 'green' : unresolved ? 'red' : 'blue'),
58
+ { tag: 'markdown', text_size: 'normal', content: `**检查时间**:${formatNotificationTime(report.checkedAt)}\n**项目**:${projects.length} | **待补齐**:${summaryCounts.attention} | **待验证**:${summaryCounts.unverified} | **前置就绪**:${summaryCounts.ready}` }];
59
+ if (gaps.length) {
60
+ const groups = new Map();
61
+ for (const gap of gaps) { const key = category(String(gap.id || '')); groups.set(key, (groups.get(key) || 0) + 1); }
62
+ elements.push(notificationText(`接入缺项 ${gaps.length} 项:${[...groups].map(([name, count]) => `${name} ${count}`).join(' · ')}`, 'orange'));
63
+ }
64
+ if (globalGaps.length) elements.push({ tag: 'markdown', text_size: 'normal', content: `**公共配置**\n${globalGaps.slice(0, 2).map(g => `• ${md(g.message || g.id)}\n 处理:${md(g.action || '按本次归档补齐配置,再重跑检查')}`).join('\n')}${globalGaps.length > 2 ? `\n另有 ${globalGaps.length - 2} 项,详见本次记录。` : ''}` });
65
+ const ordered = [...projects].sort((a, b) => (a.status === 'ready') - (b.status === 'ready'));
66
+ const visible = ordered.slice(0, 3);
67
+ for (const p of visible) {
68
+ const role = recipients(p, config);
69
+ const who = [role.executor ? `执行人 ${person(role.executor)}` : '', `技术经理 ${person(role.manager)}`, role.observer ? `同步 ${person(role.observer)}` : ''].filter(Boolean).join(' | ');
70
+ const label = p.status === 'ready' ? '前置就绪' : p.status === 'unverified' ? '待验证' : '待补齐';
71
+ const body = p.gaps.length ? p.gaps.slice(0, 2).map(g => `• ${md(g.message || g.id)}\n 处理:${md(g.action || '按本次归档补齐配置,再重跑检查')}`).join('\n') + (p.gaps.length > 2 ? `\n另有 ${p.gaps.length - 2} 项,详见本次记录。` : '') : '接入前置已核对;九维质量、Review授权和构建准入仍须按实际检查执行。';
72
+ elements.push({ tag: 'markdown', text_size: 'normal', content: `**${md(p.project || p.key)} · ${md(p.branch || '分支待识别')} · ${label}**\n${who}\n${body}` });
73
+ }
74
+ // Resolve every affected project's recipients even when the visual preview is
75
+ // capped. Hidden projects must not silently lose their required reminder.
76
+ const reminderActors = [...new Set(projects.flatMap(p => recipients(p, config).actors).concat(fallbackRecipients.filter(v => typeof v === 'string' && v)))];
77
+ const extraReminders = reminderActors.filter(actor => {
78
+ const p = people[actor];
79
+ return !verifiedPerson(p) || !used.has(p.feishu.id);
80
+ }).map(person);
81
+ if (projects.length > visible.length) elements.push(notificationText(`仅展示 ${visible.length}/${projects.length} 个项目;其余 ${projects.length - visible.length} 个项目及完整缺项见本次记录。`, 'grey'));
82
+ const run = httpsUrl(report.runUrl), origin = httpsUrl(report.jenkinsOrigin);
83
+ if (run && origin && run.origin === origin.origin && !run.search) elements.push({ tag: 'button', type: 'primary', text: { tag: 'plain_text', content: '查看本次接入检查与完整缺项' }, behaviors: [{ type: 'open_url', default_url: run.href }] });
84
+ else elements.push(notificationText('详情见本次 Jenkins 归档;尚未提供可验证的 HTTPS 记录地址。', 'grey', 'notation'));
85
+ if (!reminderActors.length && !visible.some(p => recipients(p, config).manager)) missing.add('责任人');
86
+ if (extraReminders.length) elements.push({ tag: 'markdown', text_size: 'normal', content: `**请关注并完成闭环**:${extraReminders.join('、')}\n请补齐接入缺项,完成复检和九维检查,跟进修复与回归结果。` });
87
+ else elements.push(notificationText('请责任人补齐缺项并复检,跟进九维检查、修复与回归闭环。', 'blue'));
88
+ if (missing.size) elements.push(notificationText('责任人或真实 @ 尚未配置;姓名/工号文字不代表已触达,请工程维护者补齐已验证的人员映射。', 'orange', 'notation'));
89
+ elements.push(notificationText('未接入不等于扫描通过。前置就绪仅表示可继续检查;本通知不授予构建、部署或Review签发权限。', 'grey', 'notation'));
90
+ const payload = { msg_type: 'interactive', card: { schema: '2.0', header: { template: recovered ? 'green' : unresolved ? 'orange' : 'blue', padding: '10px 16px 10px 16px', title: { tag: 'plain_text', content: title } }, body: { padding: '10px 16px 12px 16px', vertical_spacing: '6px', elements } } };
91
+ if (Buffer.byteLength(JSON.stringify(payload)) > 20000) throw new Error('运维通知超过飞书卡片预算,请缩小盘点批次');
92
+ return { payload, mentions: [...mentioned], mentionMappingComplete: missing.size === 0 };
93
+ }
94
+
95
+ export async function sendOpsNotification(report, config = {}, { env = process.env, fetchImpl = fetch, now = Date.now } = {}) {
96
+ const notification = opsNotification(report, config);
97
+ const receipt = (status, reason) => ({ status, reason, mentions: notification.mentions, mentionMappingComplete: notification.mentionMappingComplete, qualityPassed: false, buildAuthorized: false });
98
+ const webhook = env.WL_FEISHU_CI_WEBHOOK, secret = env.WL_FEISHU_CI_SECRET;
99
+ const url = httpsUrl(webhook);
100
+ if (!url || url.hostname !== 'open.feishu.cn' || url.port || url.search || url.hash || !/^\/open-apis\/bot\/v2\/hook\/[A-Za-z0-9-]+$/.test(url.pathname) || typeof secret !== 'string' || !secret) return receipt('not-sent', '缺少批准的流水线机器人地址或签名凭据;请配置CI机器人,不能使用项目机器人替代');
101
+ const instant = typeof now === 'function' ? now() : now;
102
+ if (!Number.isFinite(instant)) throw new Error('通知时间无效');
103
+ const timestamp = String(Math.floor(instant / 1000));
104
+ const body = { ...notification.payload, timestamp, sign: createHmac('sha256', `${timestamp}\n${secret}`).update('').digest('base64') };
105
+ let response;
106
+ try { response = await fetchImpl(url.href, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), redirect: 'error', signal: AbortSignal.timeout(10000) }); }
107
+ catch { return receipt('unknown', '网络或超时,送达状态未知;先核对群记录,不自动重发'); }
108
+ if (response.status !== 200) return receipt('rejected', '飞书返回HTTP错误;本次未确认送达');
109
+ try {
110
+ const length = Number(response.headers?.get?.('content-length'));
111
+ if (Number.isFinite(length) && length > 65536) return receipt('unknown', '响应过大,无法确认送达;先核对群记录');
112
+ // Read a bounded stream when available. Test/provider adapters may only
113
+ // implement text(); their result is still checked before parsing.
114
+ let raw = '';
115
+ if (response.body?.[Symbol.asyncIterator]) {
116
+ const chunks = []; let bytes = 0;
117
+ for await (const chunk of response.body) { bytes += Buffer.byteLength(chunk); if (bytes > 65536) return receipt('unknown', '响应过大,无法确认送达;先核对群记录'); chunks.push(Buffer.from(chunk)); }
118
+ raw = Buffer.concat(chunks).toString('utf8');
119
+ } else if (typeof response.text === 'function') raw = await response.text();
120
+ else if (typeof response.json === 'function') raw = JSON.stringify(await response.json());
121
+ if (Buffer.byteLength(raw) > 65536) return receipt('unknown', '响应过大,无法确认送达;先核对群记录');
122
+ const result = JSON.parse(raw), codes = ['code', 'StatusCode'].filter(k => Object.hasOwn(result || {}, k)).map(k => result[k]);
123
+ return codes.length && codes.every(code => Number.isInteger(code) && code === 0) ? receipt('sent', '飞书已确认接收') : receipt('rejected', '飞书未确认接收;检查CI机器人安全设置');
124
+ } catch { return receipt('unknown', '飞书响应无法校验,送达状态未知;先核对群记录,不自动重发'); }
125
+ }