@cnbcool/cnb-api-generate 2.4.0 → 2.4.2
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/index.ts +4 -0
- 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/login.ts +2 -1
- package/client/lib/logout.ts +35 -0
- package/client/lib/register-modules.ts +4 -1
- package/client/lib/status.ts +59 -0
- package/client/lib/summary-extractors.ts +2 -1
- package/client/shortcuts.ts +1 -0
- package/package.json +1 -1
- package/skills-template/SKILL.md +6 -9
package/client/index.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { getExtraHelpText } from './lib/extra-help';
|
|
|
5
5
|
import { registerModuleCommands } from './lib/register-modules';
|
|
6
6
|
import { registerFallbackAction } from './lib/register-fallback';
|
|
7
7
|
import { registerLoginCommand } from './lib/login';
|
|
8
|
+
import { registerLogoutCommand } from './lib/logout';
|
|
9
|
+
import { registerStatusCommand } from './lib/status';
|
|
8
10
|
|
|
9
11
|
// ============================================================
|
|
10
12
|
// Commander 程序定义
|
|
@@ -26,6 +28,8 @@ program
|
|
|
26
28
|
// ============================================================
|
|
27
29
|
|
|
28
30
|
registerLoginCommand(program);
|
|
31
|
+
registerLogoutCommand(program);
|
|
32
|
+
registerStatusCommand(program);
|
|
29
33
|
registerModuleCommands(program);
|
|
30
34
|
registerFallbackAction(program);
|
|
31
35
|
|
|
@@ -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);
|
package/client/lib/login.ts
CHANGED
|
@@ -16,12 +16,13 @@ export function registerLoginCommand(program: Command): void {
|
|
|
16
16
|
.description('通过 OAuth2 设备授权流登录 CNB,获取并保存 access_token')
|
|
17
17
|
.option('--client-id <string>', 'OAuth2 client_id', process.env.OAUTH2_CLIENT_ID || 'cnb_cli')
|
|
18
18
|
.option('--woa', '使用内网环境 (https://cnb.woa.com)', false)
|
|
19
|
+
.option('--host <string>', '指定自定义域名 (优先级高于 --woa)')
|
|
19
20
|
.option('--debug', '打印调试信息', false)
|
|
20
21
|
.helpOption('-h, --help', '显示帮助文档')
|
|
21
22
|
.action(async (opts) => {
|
|
22
23
|
const cfg: LoginConfig = {
|
|
23
24
|
clientID: opts.clientId,
|
|
24
|
-
platformURL: opts.woa ? 'https://cnb.woa.com' : 'https://cnb.cool',
|
|
25
|
+
platformURL: opts.host ? opts.host : opts.woa ? 'https://cnb.woa.com' : 'https://cnb.cool',
|
|
25
26
|
debug: opts.debug,
|
|
26
27
|
};
|
|
27
28
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import { getTokenPath } from '../utils/token-path';
|
|
4
|
+
import { loadToken } from '../utils/load-token';
|
|
5
|
+
|
|
6
|
+
export function registerLogoutCommand(program: Command): void {
|
|
7
|
+
program
|
|
8
|
+
.command('logout')
|
|
9
|
+
.description('退出登录,删除本地保存的 access_token')
|
|
10
|
+
.option('--debug', '打印调试信息', false)
|
|
11
|
+
.helpOption('-h, --help', '显示帮助文档')
|
|
12
|
+
.action(async (opts) => {
|
|
13
|
+
const tokenPath = getTokenPath();
|
|
14
|
+
const token = loadToken();
|
|
15
|
+
|
|
16
|
+
if (!token) {
|
|
17
|
+
console.log('当前未登录,无需退出。');
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (opts.debug) {
|
|
22
|
+
console.log(`Token 文件路径: ${tokenPath}`);
|
|
23
|
+
console.log(`Platform URL : ${token.platform_url}`);
|
|
24
|
+
console.log(`Client ID : ${token.client_id}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
fs.unlinkSync(tokenPath);
|
|
29
|
+
console.log('已成功退出登录。');
|
|
30
|
+
} catch (err: any) {
|
|
31
|
+
console.error(`退出登录失败: ${err.message}`);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -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) {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { getTokenPath } from '../utils/token-path';
|
|
3
|
+
import { loadToken } from '../utils/load-token';
|
|
4
|
+
import { refreshAccessToken } from '../utils/refresh-token';
|
|
5
|
+
|
|
6
|
+
export function registerStatusCommand(program: Command): void {
|
|
7
|
+
program
|
|
8
|
+
.command('status')
|
|
9
|
+
.description('检查当前登录状态(token 是否存在、是否过期)')
|
|
10
|
+
.option('--debug', '打印调试信息', false)
|
|
11
|
+
.helpOption('-h, --help', '显示帮助文档')
|
|
12
|
+
.action(async (opts) => {
|
|
13
|
+
const tokenPath = getTokenPath();
|
|
14
|
+
const token = loadToken();
|
|
15
|
+
|
|
16
|
+
if (!token) {
|
|
17
|
+
console.log('⚠️ 未登录,请执行 `cnb login` 进行授权。');
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (opts.debug) {
|
|
22
|
+
console.log(`Token 文件路径 : ${tokenPath}`);
|
|
23
|
+
console.log(`Platform URL : ${token.platform_url || '未知'}`);
|
|
24
|
+
console.log(`Client ID : ${token.client_id || '未知'}`);
|
|
25
|
+
console.log(`Expires At : ${new Date(token.expires_at * 1000).toLocaleString()}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
29
|
+
const isExpired = nowSec >= token.expires_at;
|
|
30
|
+
|
|
31
|
+
if (isExpired) {
|
|
32
|
+
// 尝试使用 refresh_token 刷新
|
|
33
|
+
if (token.refresh_token) {
|
|
34
|
+
if (opts.debug) {
|
|
35
|
+
console.log('Token 已过期,尝试使用 refresh_token 刷新...');
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
await refreshAccessToken(token);
|
|
39
|
+
const refreshedToken = loadToken();
|
|
40
|
+
if (refreshedToken) {
|
|
41
|
+
console.log('✅ 已登录。');
|
|
42
|
+
if (opts.debug) {
|
|
43
|
+
console.log(' (Token 已自动刷新)');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
} catch (err: any) {
|
|
47
|
+
if (opts.debug) {
|
|
48
|
+
console.log(`刷新失败: ${err.message}`);
|
|
49
|
+
}
|
|
50
|
+
console.log('⚠️ 未登录,请执行 `cnb login` 重新授权。');
|
|
51
|
+
}
|
|
52
|
+
} else {
|
|
53
|
+
console.log('⚠️ 未登录,请执行 `cnb login` 重新授权。');
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
console.log('✅ 已登录。');
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -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
|
// ============================================================
|
package/package.json
CHANGED
package/skills-template/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: cnb-api
|
|
3
|
-
description: CNB
|
|
3
|
+
description: CNB 平台交互命令,支持仓库、Issue、PR、流水线、制品库等操作。
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# cnb-api
|
|
@@ -38,17 +38,14 @@ pulls:
|
|
|
38
38
|
- `<$CNB_CLI_CMD$> pulls upload-image --file 图片路径` — 上传图片到当前 PR
|
|
39
39
|
|
|
40
40
|
注意事项:
|
|
41
|
-
-
|
|
42
|
-
-
|
|
43
|
-
-
|
|
44
|
-
-
|
|
41
|
+
- **参数自动识别**:快捷命令中的 Issue/PR 编号会自动从环境变量识别,无需额外传递。
|
|
42
|
+
- **默认仅需摘要**:默认会精简响应输出结果,只返回核心字段。添加 `--verbose` 输出完整数据。
|
|
43
|
+
- **单引号传参**:传递多行文本参数时,使用单引号可防止命令注入攻击,并减少不必要的转义。
|
|
44
|
+
- **快捷命令适用范围**: 快捷命令只能操作当前仓库的当前 Issue/PR,跨仓库或跨编号操作请参考 `更多 API`。
|
|
45
|
+
- **npc提及和召唤的区别**: 评论中直接 @npc 会召唤 npc 干活,如果只提及不召唤,应该去掉 `@` 符号,或使用反引号包裹 `@npc`。
|
|
45
46
|
|
|
46
47
|
## 更多 API
|
|
47
48
|
|
|
48
|
-
优先使用快捷命令,不满足时才使用以下这些命令
|
|
49
|
-
|
|
50
49
|
1. `<$CNB_CLI_CMD$> --help` 查看所有模块
|
|
51
50
|
2. `<$CNB_CLI_CMD$> <module> --help` 查看模块下的工具列表
|
|
52
51
|
3. `<$CNB_CLI_CMD$> <module> <tool> --help` 查看工具参数
|
|
53
|
-
4. 按帮助文档操作,禁止猜测
|
|
54
|
-
|