@cnbcool/cnb-api-generate 2.3.5 → 2.4.1
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/client/lib/build-diagnostics.ts +10 -75
- package/client/lib/build-helpers.ts +180 -0
- package/client/lib/build-timing.ts +256 -0
- package/client/lib/execute-action.ts +11 -0
- package/client/lib/register-modules.ts +4 -1
- package/client/lib/summary-extractors.ts +2 -1
- package/client/shortcuts.ts +1 -0
- package/client/utils/resolve-token.ts +7 -0
- package/package.json +1 -1
- package/skills-template/SKILL.md +29 -34
|
@@ -1,52 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 构建失败诊断模块
|
|
3
3
|
*
|
|
4
4
|
* 查询流水线失败 Stage 的详细日志和错误信息,同时输出耗时数据辅助分析。
|
|
5
5
|
* 内置降级逻辑:Stage 详情失败时自动尝试全量日志下载(含 base64 解码)。
|
|
6
6
|
*
|
|
7
7
|
* sn 获取策略(按优先级):
|
|
8
8
|
* 1. 用户通过 --sn 显式传入
|
|
9
|
-
* 2.
|
|
10
|
-
* 3. 从 PR commit statuses 中提取失败构建的 sn
|
|
9
|
+
* 2. 有 PR 上下文时,从 PR commit statuses 中提取失败构建的 sn
|
|
11
10
|
*/
|
|
12
11
|
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
throw new Error(`请求失败: ${res.status} ${res.statusText} — ${url}`);
|
|
19
|
-
}
|
|
20
|
-
return res.json();
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
async function apiRequestText(url: string, headers: Record<string, string>): Promise<string> {
|
|
24
|
-
const res = await fetch(url, { headers });
|
|
25
|
-
if (!res.ok) {
|
|
26
|
-
throw new Error(`请求失败: ${res.status} ${res.statusText} — ${url}`);
|
|
27
|
-
}
|
|
28
|
-
return res.text();
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* 从 PR 的 commit statuses 中提取失败构建的 sn
|
|
33
|
-
*/
|
|
34
|
-
function extractSnFromStatuses(statusData: any): string | null {
|
|
35
|
-
const statuses = statusData?.statuses || statusData;
|
|
36
|
-
if (!Array.isArray(statuses) || statuses.length === 0) return null;
|
|
37
|
-
|
|
38
|
-
// 优先找 error/failure 状态的构建
|
|
39
|
-
const failed = statuses.find(
|
|
40
|
-
(s: any) => s.state === 'error' || s.state === 'failure',
|
|
41
|
-
);
|
|
42
|
-
if (!failed) return null;
|
|
43
|
-
|
|
44
|
-
// 从 target_url 中解析 sn
|
|
45
|
-
// 格式通常为: https://cnb.cool/{repo}/-/build/{sn} 或含 /pipelines 路径
|
|
46
|
-
const url = failed.target_url || '';
|
|
47
|
-
const match = url.match(/\/-\/build\/([^/?#]+)/);
|
|
48
|
-
return match ? match[1] : null;
|
|
49
|
-
}
|
|
12
|
+
import {
|
|
13
|
+
apiRequest,
|
|
14
|
+
apiRequestText,
|
|
15
|
+
resolveBuildContext,
|
|
16
|
+
} from './build-helpers';
|
|
50
17
|
|
|
51
18
|
/**
|
|
52
19
|
* 降级方案:下载全量日志
|
|
@@ -77,42 +44,10 @@ export async function diagnoseBuild(options: {
|
|
|
77
44
|
sn?: string;
|
|
78
45
|
prNumber?: string;
|
|
79
46
|
}): Promise<string> {
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
let sn = options.sn;
|
|
83
|
-
|
|
84
|
-
// 一次性 resolve token,后续所有请求复用
|
|
85
|
-
const token = await resolveToken();
|
|
86
|
-
const headers: Record<string, string> = {
|
|
87
|
-
Accept: 'application/vnd.cnb.api+json',
|
|
88
|
-
Authorization: `Bearer ${token}`,
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
// 策略 1: 显式传入的 sn(最高优先级,已在外层赋值)
|
|
92
|
-
// 策略 2: 环境变量 CNB_BUILD_ID
|
|
93
|
-
if (!sn && process.env.CNB_BUILD_ID) {
|
|
94
|
-
sn = process.env.CNB_BUILD_ID;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// 策略 3: 从 PR commit statuses 中获取失败构建的 sn
|
|
98
|
-
if (!sn && options.prNumber) {
|
|
99
|
-
try {
|
|
100
|
-
const statusesUrl = `${domain}/${repo}/-/pulls/${options.prNumber}/statuses`;
|
|
101
|
-
const statusData = await apiRequest(statusesUrl, headers);
|
|
102
|
-
sn = extractSnFromStatuses(statusData);
|
|
103
|
-
if (!sn) {
|
|
104
|
-
return '未从 PR CI 状态中找到失败的构建,所有构建均为成功状态。';
|
|
105
|
-
}
|
|
106
|
-
} catch (e: any) {
|
|
107
|
-
return `获取 PR CI 状态失败: ${e.message}`;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
if (!sn) {
|
|
112
|
-
return '缺少构建号(sn):未传入 --sn、未设置 CNB_BUILD_ID、且未检测到 PR 上下文。';
|
|
113
|
-
}
|
|
47
|
+
const resolved = await resolveBuildContext(options);
|
|
48
|
+
if (!resolved.ok) return resolved.message;
|
|
114
49
|
|
|
115
|
-
const baseUrl =
|
|
50
|
+
const { sn, baseUrl, headers } = resolved.ctx;
|
|
116
51
|
const lines: string[] = [];
|
|
117
52
|
lines.push(`查询构建 sn=${sn} 的流水线状态...\n`);
|
|
118
53
|
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 构建诊断 / 性能分析的共用 helper
|
|
3
|
+
*
|
|
4
|
+
* 两个对外自定义命令共享:
|
|
5
|
+
* - cnb pulls get-ci-logs → build-diagnostics.ts:diagnoseBuild
|
|
6
|
+
* - cnb pulls get-ci-timing → build-timing.ts:analyzeBuildTiming
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { resolveToken } from '../utils/resolve-token';
|
|
10
|
+
|
|
11
|
+
/** CNB 构建号格式:cnb-xxx-xxxxxxxxx(字母/数字组成的两段) */
|
|
12
|
+
export const SN_PATTERN = /^cnb-[a-z0-9]+-[a-z0-9]+$/i;
|
|
13
|
+
|
|
14
|
+
export function invalidSnMessage(sn: string): string {
|
|
15
|
+
return [
|
|
16
|
+
`参数 --sn 的值不是合法的构建号: "${sn}"`,
|
|
17
|
+
'',
|
|
18
|
+
'构建号(sn)格式应为 cnb-xxx-xxxxxxxxx,例如 cnb-9p9-1jnfh8rv5。',
|
|
19
|
+
'常见错误:把 `cnb pulls check-status` 返回的 context 字段(形如',
|
|
20
|
+
'"cnb/pull_request/common-lint")当成 sn 传入,这是 CI 状态的检查项名,',
|
|
21
|
+
'不是构建号。',
|
|
22
|
+
'',
|
|
23
|
+
'请去掉 --sn 参数重试,工具会自动从当前 PR 的 CI 状态中解析失败构建的 sn。',
|
|
24
|
+
].join('\n');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function apiRequest(url: string, headers: Record<string, string>): Promise<any> {
|
|
28
|
+
const res = await fetch(url, { headers });
|
|
29
|
+
if (!res.ok) {
|
|
30
|
+
// 注意:错误消息中暴露 URL 是有意的,便于调试。
|
|
31
|
+
// 本项目所有 API 凭证通过 Authorization header 传递,不走 query string,
|
|
32
|
+
// 因此 URL 中不含敏感信息。未来如需在 URL 拼接 query 参数,请同步审视这里。
|
|
33
|
+
throw new Error(`请求失败: ${res.status} ${res.statusText} — ${url}`);
|
|
34
|
+
}
|
|
35
|
+
return res.json();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function apiRequestText(url: string, headers: Record<string, string>): Promise<string> {
|
|
39
|
+
const res = await fetch(url, { headers });
|
|
40
|
+
if (!res.ok) {
|
|
41
|
+
// 见 apiRequest 注释
|
|
42
|
+
throw new Error(`请求失败: ${res.status} ${res.statusText} — ${url}`);
|
|
43
|
+
}
|
|
44
|
+
return res.text();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 从 PR 的 commit statuses 中提取失败构建的 sn
|
|
49
|
+
* 仅选 state=error/failure 的检查项,从 target_url 中解析构建 sn
|
|
50
|
+
* target_url 通常形如 https://cnb.cool/{repo}/-/build/logs/{sn}
|
|
51
|
+
*/
|
|
52
|
+
export function extractSnFromStatuses(statusData: any): string | null {
|
|
53
|
+
const statuses = statusData?.statuses || statusData;
|
|
54
|
+
if (!Array.isArray(statuses) || statuses.length === 0) return null;
|
|
55
|
+
|
|
56
|
+
const failed = statuses.find(
|
|
57
|
+
(s: any) => s.state === 'error' || s.state === 'failure',
|
|
58
|
+
);
|
|
59
|
+
if (!failed) return null;
|
|
60
|
+
|
|
61
|
+
return extractSnFromUrl(failed.target_url || '');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 从构建链接 URL 中提取 sn
|
|
66
|
+
* 支持两种格式:
|
|
67
|
+
* .../-/build/logs/{sn} ← check-status 的 target_url
|
|
68
|
+
* .../-/build/{sn} ← 兼容老路径
|
|
69
|
+
*/
|
|
70
|
+
export function extractSnFromUrl(url: string): string | null {
|
|
71
|
+
if (!url) return null;
|
|
72
|
+
// 优先匹配 /-/build/logs/{sn}
|
|
73
|
+
const logsMatch = url.match(/\/-\/build\/logs\/([^/?#]+)/);
|
|
74
|
+
if (logsMatch && SN_PATTERN.test(logsMatch[1])) {
|
|
75
|
+
return logsMatch[1];
|
|
76
|
+
}
|
|
77
|
+
// 回退匹配 /-/build/{sn}
|
|
78
|
+
const buildMatch = url.match(/\/-\/build\/([^/?#]+)/);
|
|
79
|
+
if (buildMatch && SN_PATTERN.test(buildMatch[1])) {
|
|
80
|
+
return buildMatch[1];
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface ResolveSnOptions {
|
|
86
|
+
repo: string;
|
|
87
|
+
sn?: string;
|
|
88
|
+
prNumber?: string;
|
|
89
|
+
/** 当未找到失败构建、但想回退到"最近一个构建"时使用(例如 timing 分析场景) */
|
|
90
|
+
fallbackToLatestStatus?: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ResolvedContext {
|
|
94
|
+
/** 校验/解析出来的最终 sn;若为空字符串则表示应立即返回 errorMessage */
|
|
95
|
+
sn: string;
|
|
96
|
+
domain: string;
|
|
97
|
+
baseUrl: string;
|
|
98
|
+
headers: Record<string, string>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 统一解析 sn + token + baseUrl
|
|
103
|
+
*
|
|
104
|
+
* 返回格式:
|
|
105
|
+
* - { ok: true, ctx } 成功解析
|
|
106
|
+
* - { ok: false, message } 解析失败,调用方应直接把 message 返回给用户
|
|
107
|
+
*/
|
|
108
|
+
export async function resolveBuildContext(
|
|
109
|
+
options: ResolveSnOptions,
|
|
110
|
+
): Promise<
|
|
111
|
+
| { ok: true; ctx: ResolvedContext }
|
|
112
|
+
| { ok: false; message: string }
|
|
113
|
+
> {
|
|
114
|
+
const domain = process.env.CNB_API_ENDPOINT || 'https://api.cnb.cool';
|
|
115
|
+
const { repo } = options;
|
|
116
|
+
let sn = options.sn;
|
|
117
|
+
|
|
118
|
+
// 策略 1: 显式传入的 sn — 需先校验格式,尽早失败
|
|
119
|
+
if (sn && !SN_PATTERN.test(sn)) {
|
|
120
|
+
return { ok: false, message: invalidSnMessage(sn) };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const token = await resolveToken();
|
|
124
|
+
const headers: Record<string, string> = {
|
|
125
|
+
Accept: 'application/vnd.cnb.api+json',
|
|
126
|
+
Authorization: `Bearer ${token}`,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// 策略 2: 有 PR 上下文时,从 PR commit statuses 中取失败(或最近)构建 sn。
|
|
130
|
+
if (!sn && options.prNumber) {
|
|
131
|
+
try {
|
|
132
|
+
// 对应 swagger: GET /{repo}/-/pulls/{number}/commit-statuses
|
|
133
|
+
const statusesUrl = `${domain}/${repo}/-/pulls/${options.prNumber}/commit-statuses`;
|
|
134
|
+
const statusData = await apiRequest(statusesUrl, headers);
|
|
135
|
+
sn = extractSnFromStatuses(statusData) ?? undefined;
|
|
136
|
+
|
|
137
|
+
if (!sn && options.fallbackToLatestStatus) {
|
|
138
|
+
// timing 场景:没失败也允许分析,回退到第一个能解析出合法 sn 的 status
|
|
139
|
+
const statuses = statusData?.statuses || statusData;
|
|
140
|
+
if (Array.isArray(statuses)) {
|
|
141
|
+
for (const s of statuses) {
|
|
142
|
+
const parsed = extractSnFromUrl(s?.target_url || '');
|
|
143
|
+
if (parsed) {
|
|
144
|
+
sn = parsed;
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} catch (e: any) {
|
|
151
|
+
return { ok: false, message: `获取 PR CI 状态失败: ${e.message}` };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!sn) {
|
|
156
|
+
if (options.prNumber) {
|
|
157
|
+
return {
|
|
158
|
+
ok: false,
|
|
159
|
+
message: options.fallbackToLatestStatus
|
|
160
|
+
? '未从 PR CI 状态中找到任何可分析的构建。'
|
|
161
|
+
: '未从 PR CI 状态中找到失败的构建,所有构建均为成功状态。',
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
message:
|
|
167
|
+
'缺少构建号(sn):未传入 --sn 且未检测到 PR 上下文(CNB_PULL_REQUEST_IID)。',
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
ok: true,
|
|
173
|
+
ctx: {
|
|
174
|
+
sn,
|
|
175
|
+
domain,
|
|
176
|
+
baseUrl: `${domain}/${repo}`,
|
|
177
|
+
headers,
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 构建耗时分析模块
|
|
3
|
+
*
|
|
4
|
+
* 查询流水线所有 Stage 的耗时数据,分析瓶颈并给出优化提示。
|
|
5
|
+
*
|
|
6
|
+
* sn 获取策略(按优先级):
|
|
7
|
+
* 1. 用户通过 --sn 显式传入
|
|
8
|
+
* 2. 有 PR 上下文时,从 PR commit statuses 中提取失败构建的 sn;
|
|
9
|
+
* 若无失败则回退到最近一条可解析的构建
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { apiRequest, resolveBuildContext } from './build-helpers';
|
|
13
|
+
|
|
14
|
+
function formatDuration(ms: number | null | undefined): string {
|
|
15
|
+
if (ms == null) return 'N/A';
|
|
16
|
+
if (ms < 1000) return `${ms}ms`;
|
|
17
|
+
const seconds = ms / 1000;
|
|
18
|
+
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
|
19
|
+
const minutes = Math.floor(seconds / 60);
|
|
20
|
+
const remainSec = (seconds % 60).toFixed(0);
|
|
21
|
+
return `${minutes}m${remainSec}s`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function progressBar(ratio: number, width = 20): string {
|
|
25
|
+
const filled = Math.round(ratio * width);
|
|
26
|
+
const empty = width - filled;
|
|
27
|
+
return '█'.repeat(filled) + '░'.repeat(empty);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface StageTiming {
|
|
31
|
+
pipelineName: string;
|
|
32
|
+
pipelineId: string;
|
|
33
|
+
stageId: string;
|
|
34
|
+
stageName: string;
|
|
35
|
+
status: string;
|
|
36
|
+
duration: number;
|
|
37
|
+
needs: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 分析构建耗时瓶颈
|
|
42
|
+
*/
|
|
43
|
+
export async function analyzeBuildTiming(options: {
|
|
44
|
+
repo: string;
|
|
45
|
+
sn?: string;
|
|
46
|
+
prNumber?: string;
|
|
47
|
+
}): Promise<string> {
|
|
48
|
+
const resolved = await resolveBuildContext({
|
|
49
|
+
...options,
|
|
50
|
+
// 耗时分析场景下,即使没有失败构建也允许分析成功的构建
|
|
51
|
+
fallbackToLatestStatus: true,
|
|
52
|
+
});
|
|
53
|
+
if (!resolved.ok) return resolved.message;
|
|
54
|
+
|
|
55
|
+
const { sn, baseUrl, headers } = resolved.ctx;
|
|
56
|
+
const lines: string[] = [];
|
|
57
|
+
lines.push(`查询构建 sn=${sn} 的流水线耗时数据...\n`);
|
|
58
|
+
|
|
59
|
+
// 获取构建状态
|
|
60
|
+
let buildStatus: any;
|
|
61
|
+
try {
|
|
62
|
+
buildStatus = await apiRequest(`${baseUrl}/-/build/status/${sn}`, headers);
|
|
63
|
+
} catch (e: any) {
|
|
64
|
+
return `获取构建状态失败: ${e.message}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (
|
|
68
|
+
!buildStatus.pipelinesStatus ||
|
|
69
|
+
typeof buildStatus.pipelinesStatus !== 'object'
|
|
70
|
+
) {
|
|
71
|
+
lines.push('未找到流水线状态信息');
|
|
72
|
+
lines.push('返回数据:');
|
|
73
|
+
lines.push(JSON.stringify(buildStatus, null, 2));
|
|
74
|
+
return lines.join('\n');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const pipelines = Object.entries(buildStatus.pipelinesStatus) as [string, any][];
|
|
78
|
+
if (pipelines.length === 0) {
|
|
79
|
+
return '该构建没有流水线';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const allStages: StageTiming[] = [];
|
|
83
|
+
let totalBuildDuration = 0;
|
|
84
|
+
|
|
85
|
+
lines.push('═══════════════════════════════════════════════════════');
|
|
86
|
+
lines.push(' CI 流水线耗时分析');
|
|
87
|
+
lines.push('═══════════════════════════════════════════════════════\n');
|
|
88
|
+
|
|
89
|
+
for (const [pipelineName, pipeline] of pipelines) {
|
|
90
|
+
const { id: pipelineId, status, stages } = pipeline;
|
|
91
|
+
if (!Array.isArray(stages) || stages.length === 0) continue;
|
|
92
|
+
|
|
93
|
+
let pipelineDuration = 0;
|
|
94
|
+
const stageTimings: StageTiming[] = [];
|
|
95
|
+
|
|
96
|
+
for (const stage of stages) {
|
|
97
|
+
const duration = stage.duration || 0;
|
|
98
|
+
pipelineDuration += duration;
|
|
99
|
+
stageTimings.push({
|
|
100
|
+
pipelineName,
|
|
101
|
+
pipelineId,
|
|
102
|
+
stageId: stage.id,
|
|
103
|
+
stageName: stage.name || stage.id,
|
|
104
|
+
status: stage.status,
|
|
105
|
+
duration,
|
|
106
|
+
needs: stage.needs || [],
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
totalBuildDuration += pipelineDuration;
|
|
111
|
+
|
|
112
|
+
lines.push(
|
|
113
|
+
`━━━ Pipeline: ${pipelineName} (status: ${status}, 总耗时: ${formatDuration(pipelineDuration)}) ━━━\n`,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// 按耗时从大到小排序
|
|
117
|
+
const sorted = [...stageTimings].sort((a, b) => b.duration - a.duration);
|
|
118
|
+
|
|
119
|
+
for (const s of sorted) {
|
|
120
|
+
const ratio = pipelineDuration > 0 ? s.duration / pipelineDuration : 0;
|
|
121
|
+
const pct = (ratio * 100).toFixed(1);
|
|
122
|
+
const bar = progressBar(ratio);
|
|
123
|
+
const statusIcon = s.status === 'success' ? '✅' : s.status === 'error' ? '❌' : '⏳';
|
|
124
|
+
lines.push(` ${statusIcon} ${s.stageName}`);
|
|
125
|
+
lines.push(` 耗时: ${formatDuration(s.duration)} (${pct}%) ${bar}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
lines.push('');
|
|
129
|
+
allStages.push(...stageTimings);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 全局 Stage 耗时排行
|
|
133
|
+
lines.push('═══════════════════════════════════════════════════════');
|
|
134
|
+
lines.push(' 全局 Stage 耗时排行 (Top 10)');
|
|
135
|
+
lines.push('═══════════════════════════════════════════════════════\n');
|
|
136
|
+
|
|
137
|
+
const globalSorted = [...allStages].sort((a, b) => b.duration - a.duration);
|
|
138
|
+
const top10 = globalSorted.slice(0, 10);
|
|
139
|
+
|
|
140
|
+
for (let i = 0; i < top10.length; i++) {
|
|
141
|
+
const s = top10[i];
|
|
142
|
+
const ratio = totalBuildDuration > 0 ? s.duration / totalBuildDuration : 0;
|
|
143
|
+
const pct = (ratio * 100).toFixed(1);
|
|
144
|
+
lines.push(` #${i + 1} ${s.pipelineName} → ${s.stageName}`);
|
|
145
|
+
lines.push(` 耗时: ${formatDuration(s.duration)} (占总耗时 ${pct}%)`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
lines.push(`\n 总耗时: ${formatDuration(totalBuildDuration)} (所有 Stage 耗时之和)`);
|
|
149
|
+
|
|
150
|
+
// 获取耗时最长 Stage 的详情日志用于分析
|
|
151
|
+
lines.push('\n═══════════════════════════════════════════════════════');
|
|
152
|
+
lines.push(' 耗时最长 Stage 的详情日志(用于分析优化点)');
|
|
153
|
+
lines.push('═══════════════════════════════════════════════════════\n');
|
|
154
|
+
|
|
155
|
+
const topStages = globalSorted.slice(0, 3);
|
|
156
|
+
for (const s of topStages) {
|
|
157
|
+
lines.push(`▸ ${s.pipelineName} → ${s.stageName} (${formatDuration(s.duration)})`);
|
|
158
|
+
try {
|
|
159
|
+
const detail = await apiRequest(
|
|
160
|
+
`${baseUrl}/-/build/logs/stage/${sn}/${s.pipelineId}/${s.stageId}`,
|
|
161
|
+
headers,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
if (detail.duration != null) {
|
|
165
|
+
lines.push(` Stage 实际耗时: ${formatDuration(detail.duration)}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (Array.isArray(detail.content) && detail.content.length > 0) {
|
|
169
|
+
lines.push(' ─── 日志(末尾 50 行)───');
|
|
170
|
+
const logLines = detail.content;
|
|
171
|
+
const tail = logLines.slice(Math.max(0, logLines.length - 50));
|
|
172
|
+
for (const line of tail) {
|
|
173
|
+
lines.push(` ${line}`);
|
|
174
|
+
}
|
|
175
|
+
if (logLines.length > 50) {
|
|
176
|
+
lines.push(` ... (省略了前 ${logLines.length - 50} 行,共 ${logLines.length} 行)`);
|
|
177
|
+
}
|
|
178
|
+
lines.push(' ─── 日志结束 ───');
|
|
179
|
+
} else {
|
|
180
|
+
lines.push(' (无日志内容)');
|
|
181
|
+
}
|
|
182
|
+
} catch (e: any) {
|
|
183
|
+
lines.push(` 查询 Stage 详情失败: ${e.message}`);
|
|
184
|
+
}
|
|
185
|
+
lines.push('');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 统计摘要
|
|
189
|
+
lines.push('═══════════════════════════════════════════════════════');
|
|
190
|
+
lines.push(' 统计摘要');
|
|
191
|
+
lines.push('═══════════════════════════════════════════════════════\n');
|
|
192
|
+
|
|
193
|
+
lines.push(` Pipeline 数量: ${pipelines.length}`);
|
|
194
|
+
lines.push(` Stage 总数: ${allStages.length}`);
|
|
195
|
+
lines.push(` 成功 Stage: ${allStages.filter((s) => s.status === 'success').length}`);
|
|
196
|
+
lines.push(` 失败 Stage: ${allStages.filter((s) => s.status === 'error').length}`);
|
|
197
|
+
lines.push(` 总耗时: ${formatDuration(totalBuildDuration)}`);
|
|
198
|
+
|
|
199
|
+
if (globalSorted.length > 0) {
|
|
200
|
+
const longest = globalSorted[0];
|
|
201
|
+
lines.push(
|
|
202
|
+
` 最慢 Stage: ${longest.pipelineName} → ${longest.stageName} (${formatDuration(longest.duration)})`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 检测潜在优化点
|
|
207
|
+
lines.push('\n═══════════════════════════════════════════════════════');
|
|
208
|
+
lines.push(' 潜在优化点提示');
|
|
209
|
+
lines.push('═══════════════════════════════════════════════════════\n');
|
|
210
|
+
|
|
211
|
+
const hints: string[] = [];
|
|
212
|
+
|
|
213
|
+
// 检测: 耗时超过 5 分钟的 Stage
|
|
214
|
+
const slowStages = allStages.filter((s) => s.duration > 300000);
|
|
215
|
+
if (slowStages.length > 0) {
|
|
216
|
+
hints.push(`⚠️ 有 ${slowStages.length} 个 Stage 耗时超过 5 分钟,建议重点优化`);
|
|
217
|
+
for (const s of slowStages) {
|
|
218
|
+
hints.push(` - ${s.pipelineName} → ${s.stageName}: ${formatDuration(s.duration)}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 检测: 耗时占比超过 50% 的单个 Stage
|
|
223
|
+
const dominantStages = allStages.filter(
|
|
224
|
+
(s) => totalBuildDuration > 0 && s.duration / totalBuildDuration > 0.5,
|
|
225
|
+
);
|
|
226
|
+
if (dominantStages.length > 0) {
|
|
227
|
+
for (const s of dominantStages) {
|
|
228
|
+
const pct = ((s.duration / totalBuildDuration) * 100).toFixed(1);
|
|
229
|
+
hints.push(`⚠️ Stage "${s.stageName}" 占总耗时 ${pct}%,是主要瓶颈`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// 检测: 单个 Pipeline 内 Stage 数很多但都串行
|
|
234
|
+
for (const [pipelineName, pipeline] of pipelines) {
|
|
235
|
+
if (Array.isArray(pipeline.stages) && pipeline.stages.length > 5) {
|
|
236
|
+
const noDeps = pipeline.stages.filter(
|
|
237
|
+
(s: any) => !s.needs || s.needs.length === 0,
|
|
238
|
+
);
|
|
239
|
+
if (noDeps.length > 3) {
|
|
240
|
+
hints.push(
|
|
241
|
+
`💡 Pipeline "${pipelineName}" 有 ${noDeps.length} 个无依赖的 Stage,可考虑并行执行`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (hints.length === 0) {
|
|
248
|
+
lines.push(' ✅ 未检测到明显的耗时异常');
|
|
249
|
+
} else {
|
|
250
|
+
for (const hint of hints) {
|
|
251
|
+
lines.push(` ${hint}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return lines.join('\n');
|
|
256
|
+
}
|
|
@@ -4,6 +4,7 @@ import { Command } from 'commander';
|
|
|
4
4
|
import { showShort, resolveShortcut, type ResolvedShortcut } from '../shortcuts';
|
|
5
5
|
import { handleUpload } from '../utils/upload';
|
|
6
6
|
import { diagnoseBuild } from './build-diagnostics';
|
|
7
|
+
import { analyzeBuildTiming } from './build-timing';
|
|
7
8
|
import { formatParams } from './format-params';
|
|
8
9
|
import { formatOutput } from './format-output';
|
|
9
10
|
// @ts-ignore
|
|
@@ -107,6 +108,16 @@ export async function executeAction(
|
|
|
107
108
|
return;
|
|
108
109
|
}
|
|
109
110
|
|
|
111
|
+
if (shortcut?.tool === '__get-ci-timing__') {
|
|
112
|
+
const result = await analyzeBuildTiming({
|
|
113
|
+
repo: process.env.CNB_REPO_SLUG || '',
|
|
114
|
+
sn: (opts.sn as string) || undefined,
|
|
115
|
+
prNumber: process.env.CNB_PULL_REQUEST_IID || undefined,
|
|
116
|
+
});
|
|
117
|
+
console.log(result);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
110
121
|
const formattedParams = formatParams(params);
|
|
111
122
|
|
|
112
123
|
const toolFunction = loadToolFunction(formattedParams.module, formattedParams.tool);
|
|
@@ -134,7 +134,10 @@ export function registerModuleCommands(program: Command): void {
|
|
|
134
134
|
// 上传命令只需要 --file,不复用真实 tool 的 body 选项(name/size/content_type 由上传流程自动获取)
|
|
135
135
|
if (shortcut.custom) {
|
|
136
136
|
// 自定义命令注册自己的选项
|
|
137
|
-
if (
|
|
137
|
+
if (
|
|
138
|
+
shortcut.realTool === '__get-ci-logs__' ||
|
|
139
|
+
shortcut.realTool === '__get-ci-timing__'
|
|
140
|
+
) {
|
|
138
141
|
shortcutCmd.option('--sn <string>', '构建号(可选,默认自动从环境变量或 PR 状态获取)');
|
|
139
142
|
}
|
|
140
143
|
} else if (!shortcut.upload) {
|
|
@@ -136,7 +136,7 @@ const SUMMARY_EXTRACTORS: Record<string, SummaryExtractor> = {
|
|
|
136
136
|
}),
|
|
137
137
|
|
|
138
138
|
// PR CI 状态摘要:保留整体状态和各检查项的核心信息(sha 截断为前 8 位)
|
|
139
|
-
//
|
|
139
|
+
// 保留 target_url(用于从中提取构建 sn);过滤 created_at/updated_at
|
|
140
140
|
'pulls/list-pull-commit-statuses': (item) => ({
|
|
141
141
|
sha: typeof item.sha === 'string' ? item.sha.substring(0, 8) : item.sha,
|
|
142
142
|
state: item.state,
|
|
@@ -144,6 +144,7 @@ const SUMMARY_EXTRACTORS: Record<string, SummaryExtractor> = {
|
|
|
144
144
|
context: s.context,
|
|
145
145
|
state: s.state,
|
|
146
146
|
description: s.description,
|
|
147
|
+
target_url: s.target_url,
|
|
147
148
|
})) || [],
|
|
148
149
|
}),
|
|
149
150
|
|
package/client/shortcuts.ts
CHANGED
|
@@ -97,6 +97,7 @@ export const PR_SHORTCUTS: ShortcutDefinition[] = [
|
|
|
97
97
|
{ shortName: 'upload-file', realTool: 'upload-files', description: '上传文件', repoOnly: true, upload: true, dataTip: "--file 文件路径" },
|
|
98
98
|
{ shortName: 'upload-image', realTool: 'upload-imgs', description: '上传图片', repoOnly: true, upload: true, dataTip: "--file 图片路径" },
|
|
99
99
|
{ shortName: 'get-ci-logs', realTool: '__get-ci-logs__', description: '获取 CI 失败日志', repoOnly: true, custom: true, dataTip: "--sn 构建号(可选)" },
|
|
100
|
+
{ shortName: 'get-ci-timing', realTool: '__get-ci-timing__', description: '分析 CI 耗时瓶颈', repoOnly: true, custom: true, dataTip: "--sn 构建号(可选)" },
|
|
100
101
|
];
|
|
101
102
|
|
|
102
103
|
// ============================================================
|
|
@@ -9,6 +9,13 @@ import { refreshAccessToken } from './refresh-token';
|
|
|
9
9
|
* 刷新失败或无 refresh_token 时终止进程并提示用户重新登录。
|
|
10
10
|
*/
|
|
11
11
|
export async function resolveToken(): Promise<string> {
|
|
12
|
+
// 环境变量优先 CodeBuddy
|
|
13
|
+
if (process.env.CNB_TOKEN_FOR_CODEBUDDY) {
|
|
14
|
+
if (!process.env.CNB_NPC_SLUG && process.env.CNB_NPC_NAME === 'CodeBuddy') {
|
|
15
|
+
return process.env.CNB_TOKEN_FOR_CODEBUDDY;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
12
19
|
// 环境变量优先,不做过期判断
|
|
13
20
|
if (process.env.CNB_TOKEN) {
|
|
14
21
|
return process.env.CNB_TOKEN;
|
package/package.json
CHANGED
package/skills-template/SKILL.md
CHANGED
|
@@ -10,49 +10,44 @@ description: CNB OpenAPI 交互能力,支持仓库、Issue、PR、流水线、
|
|
|
10
10
|
## 快捷命令
|
|
11
11
|
|
|
12
12
|
issues:
|
|
13
|
-
- `<$CNB_CLI_CMD$> issues get` —
|
|
14
|
-
- `<$CNB_CLI_CMD$> issues list-comments` —
|
|
15
|
-
- `<$CNB_CLI_CMD$> issues comment --body '内容'` —
|
|
16
|
-
- `<$CNB_CLI_CMD$> issues close` —
|
|
17
|
-
- `<$CNB_CLI_CMD$> issues open` —
|
|
18
|
-
- `<$CNB_CLI_CMD$> issues list-labels` —
|
|
19
|
-
- `<$CNB_CLI_CMD$> issues add-labels --labels bug --labels feature` —
|
|
20
|
-
- `<$CNB_CLI_CMD$> issues list-assignees` —
|
|
21
|
-
- `<$CNB_CLI_CMD$> issues add-assignees --assignees username` —
|
|
22
|
-
- `<$CNB_CLI_CMD$> issues upload-file --file 文件路径` —
|
|
23
|
-
- `<$CNB_CLI_CMD$> issues upload-image --file 图片路径` —
|
|
13
|
+
- `<$CNB_CLI_CMD$> issues get` — 获取当前 Issue 详情
|
|
14
|
+
- `<$CNB_CLI_CMD$> issues list-comments` — 列出当前 Issue 评论
|
|
15
|
+
- `<$CNB_CLI_CMD$> issues comment --body '内容'` — 发表评论到当前 Issue
|
|
16
|
+
- `<$CNB_CLI_CMD$> issues close` — 关闭当前 Issue
|
|
17
|
+
- `<$CNB_CLI_CMD$> issues open` — 打开当前 Issue
|
|
18
|
+
- `<$CNB_CLI_CMD$> issues list-labels` — 列出当前 Issue 标签
|
|
19
|
+
- `<$CNB_CLI_CMD$> issues add-labels --labels bug --labels feature` — 添加标签到当前 Issue
|
|
20
|
+
- `<$CNB_CLI_CMD$> issues list-assignees` — 查看当前 Issue 处理人
|
|
21
|
+
- `<$CNB_CLI_CMD$> issues add-assignees --assignees username` — 添加处理人到当前 Issue
|
|
22
|
+
- `<$CNB_CLI_CMD$> issues upload-file --file 文件路径` — 上传文件到当前 Issue
|
|
23
|
+
- `<$CNB_CLI_CMD$> issues upload-image --file 图片路径` — 上传图片到当前 Issue
|
|
24
24
|
|
|
25
25
|
pulls:
|
|
26
|
-
- `<$CNB_CLI_CMD$> pulls get` —
|
|
27
|
-
- `<$CNB_CLI_CMD$> pulls list-files` —
|
|
28
|
-
- `<$CNB_CLI_CMD$> pulls list-commits` —
|
|
29
|
-
- `<$CNB_CLI_CMD$> pulls list-comments` —
|
|
26
|
+
- `<$CNB_CLI_CMD$> pulls get` — 获取当前 PR 详情
|
|
27
|
+
- `<$CNB_CLI_CMD$> pulls list-files` — 列出当前 PR 变更文件
|
|
28
|
+
- `<$CNB_CLI_CMD$> pulls list-commits` — 列出当前 PR 提交记录
|
|
29
|
+
- `<$CNB_CLI_CMD$> pulls list-comments` — 列出当前 PR 评论
|
|
30
30
|
- `<$CNB_CLI_CMD$> pulls comment --body '内容'` — 发表 PR 评论
|
|
31
|
-
- `<$CNB_CLI_CMD$> pulls list-labels` —
|
|
32
|
-
- `<$CNB_CLI_CMD$> pulls add-labels --labels ready --labels approved` —
|
|
33
|
-
- `<$CNB_CLI_CMD$> pulls check-status` —
|
|
34
|
-
- `<$CNB_CLI_CMD$> pulls
|
|
35
|
-
- `<$CNB_CLI_CMD$> pulls list-
|
|
36
|
-
- `<$CNB_CLI_CMD$> pulls
|
|
37
|
-
- `<$CNB_CLI_CMD$> pulls upload-
|
|
38
|
-
- `<$CNB_CLI_CMD$> pulls
|
|
31
|
+
- `<$CNB_CLI_CMD$> pulls list-labels` — 列出当前 PR 标签
|
|
32
|
+
- `<$CNB_CLI_CMD$> pulls add-labels --labels ready --labels approved` — 添加标签到当前 PR
|
|
33
|
+
- `<$CNB_CLI_CMD$> pulls check-status` — 获取当前 PR 的 CI 状态
|
|
34
|
+
- `<$CNB_CLI_CMD$> pulls get-ci-logs` — 获取当前 PR 的 CI 构建日志
|
|
35
|
+
- `<$CNB_CLI_CMD$> pulls list-reviews` — 查看当前当前的评审列表
|
|
36
|
+
- `<$CNB_CLI_CMD$> pulls list-assignees` — 查看当前 PR 处理人
|
|
37
|
+
- `<$CNB_CLI_CMD$> pulls upload-file --file 文件路径` — 上传文件到当前 PR
|
|
38
|
+
- `<$CNB_CLI_CMD$> pulls upload-image --file 图片路径` — 上传图片到当前 PR
|
|
39
39
|
|
|
40
40
|
注意事项:
|
|
41
|
-
-
|
|
42
|
-
-
|
|
41
|
+
- **路径参数自动识别**:快捷命令中的 Issue/PR 编号会自动从环境变量识别,无需额外传递。
|
|
42
|
+
- **默认只输出摘要**:默认会精简响应输出结果,只返回核心字段。添加 `--verbose` 输出完整数据。
|
|
43
43
|
- **多行文本传参使用单引号**:传递多行文本参数时,使用单引号可防止命令注入攻击,并减少不必要的转义。
|
|
44
|
-
- **快捷命令范围** 快捷命令只能操作当前仓库的当前Issue
|
|
44
|
+
- **快捷命令范围** 快捷命令只能操作当前仓库的当前 Issue/PR,跨仓库或跨编号操作请使用其他 API 命令。
|
|
45
45
|
|
|
46
|
-
##
|
|
46
|
+
## 更多 API
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
优先使用快捷命令,不满足时才使用以下这些命令
|
|
49
49
|
|
|
50
50
|
1. `<$CNB_CLI_CMD$> --help` 查看所有模块
|
|
51
51
|
2. `<$CNB_CLI_CMD$> <module> --help` 查看模块下的工具列表
|
|
52
52
|
3. `<$CNB_CLI_CMD$> <module> <tool> --help` 查看工具参数
|
|
53
|
-
4.
|
|
54
|
-
|
|
55
|
-
## 规则
|
|
56
|
-
|
|
57
|
-
- 优先使用快捷命令,不满足时再 `--help` 逐步查找
|
|
58
|
-
- 使用非快捷命令时,必须先 `--help` 获取帮助,禁止猜测
|
|
53
|
+
4. 按帮助文档操作,禁止猜测
|