@cnbcool/cnb-api-generate 2.3.4 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/core.ts +3 -2
- package/client/index.ts +2 -0
- package/client/lib/build-diagnostics.ts +240 -0
- package/client/lib/execute-action.ts +12 -0
- package/client/lib/login.ts +98 -0
- package/client/lib/register-modules.ts +6 -1
- package/client/shortcuts.ts +3 -0
- package/client/utils/device-auth.ts +8 -0
- package/client/utils/do-token-request.ts +52 -0
- package/client/utils/get-token.ts +13 -0
- package/client/utils/load-token.ts +14 -0
- package/client/utils/mask-token.ts +5 -0
- package/client/utils/open-browser.ts +22 -0
- package/client/utils/poll-token.ts +78 -0
- package/client/utils/refresh-token.ts +46 -0
- package/client/utils/request-device-code.ts +41 -0
- package/client/utils/resolve-token.ts +50 -0
- package/client/utils/save-token.ts +14 -0
- package/client/utils/token-path.ts +7 -0
- package/client/utils/types.ts +45 -0
- package/package.json +1 -1
- package/skills-template/SKILL.md +40 -55
package/client/core.ts
CHANGED
|
@@ -3,7 +3,7 @@ import path from 'path';
|
|
|
3
3
|
import os from 'os';
|
|
4
4
|
import { fetchResponseHandler } from "./fetch-response-handler";
|
|
5
5
|
import { generateUniqueId } from './utils/generate-unique-id';
|
|
6
|
-
|
|
6
|
+
import { resolveToken } from './utils/resolve-token';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* 格式化 API 响应
|
|
@@ -117,6 +117,7 @@ async function formatResponse(data: any, response: any) {
|
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
async function clientFetch(data: any): Promise<any> {
|
|
120
|
+
const token = await resolveToken();
|
|
120
121
|
const domain = process.env.CNB_API_ENDPOINT || 'https://api.cnb.cool'
|
|
121
122
|
const url = `${domain}${data.url}`
|
|
122
123
|
const urlParse = new URL(url);
|
|
@@ -131,7 +132,7 @@ async function clientFetch(data: any): Promise<any> {
|
|
|
131
132
|
method: data.method.toUpperCase(),
|
|
132
133
|
body: data.data ? JSON.stringify(data.data) : undefined,
|
|
133
134
|
headers: {
|
|
134
|
-
Authorization: `Bearer ${
|
|
135
|
+
Authorization: `Bearer ${token}`,
|
|
135
136
|
Accept: 'application/vnd.cnb.api+json',
|
|
136
137
|
...(data?.header || {}),
|
|
137
138
|
},
|
package/client/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { Command } from 'commander';
|
|
|
4
4
|
import { getExtraHelpText } from './lib/extra-help';
|
|
5
5
|
import { registerModuleCommands } from './lib/register-modules';
|
|
6
6
|
import { registerFallbackAction } from './lib/register-fallback';
|
|
7
|
+
import { registerLoginCommand } from './lib/login';
|
|
7
8
|
|
|
8
9
|
// ============================================================
|
|
9
10
|
// Commander 程序定义
|
|
@@ -24,6 +25,7 @@ program
|
|
|
24
25
|
// 注册命令
|
|
25
26
|
// ============================================================
|
|
26
27
|
|
|
28
|
+
registerLoginCommand(program);
|
|
27
29
|
registerModuleCommands(program);
|
|
28
30
|
registerFallbackAction(program);
|
|
29
31
|
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 构建诊断模块
|
|
3
|
+
*
|
|
4
|
+
* 查询流水线失败 Stage 的详细日志和错误信息,同时输出耗时数据辅助分析。
|
|
5
|
+
* 内置降级逻辑:Stage 详情失败时自动尝试全量日志下载(含 base64 解码)。
|
|
6
|
+
*
|
|
7
|
+
* sn 获取策略(按优先级):
|
|
8
|
+
* 1. 用户通过 --sn 显式传入
|
|
9
|
+
* 2. 环境变量 CNB_BUILD_ID(当前构建自身的 sn)
|
|
10
|
+
* 3. 从 PR commit statuses 中提取失败构建的 sn
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { resolveToken } from '../utils/resolve-token';
|
|
14
|
+
|
|
15
|
+
async function apiRequest(url: string, headers: Record<string, string>): Promise<any> {
|
|
16
|
+
const res = await fetch(url, { headers });
|
|
17
|
+
if (!res.ok) {
|
|
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
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 降级方案:下载全量日志
|
|
53
|
+
* API 返回可能是 JSON(含 base64 编码的 data.data 字段)或纯文本
|
|
54
|
+
*/
|
|
55
|
+
async function downloadFullLog(baseUrl: string, pipelineId: string, headers: Record<string, string>): Promise<string> {
|
|
56
|
+
const body = await apiRequestText(`${baseUrl}/-/build/logs/${pipelineId}`, headers);
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const json = JSON.parse(body);
|
|
60
|
+
if (json.data && typeof json.data === 'string') {
|
|
61
|
+
return Buffer.from(json.data, 'base64').toString('utf-8');
|
|
62
|
+
}
|
|
63
|
+
if (json.data?.data && typeof json.data.data === 'string') {
|
|
64
|
+
return Buffer.from(json.data.data, 'base64').toString('utf-8');
|
|
65
|
+
}
|
|
66
|
+
return JSON.stringify(json, null, 2);
|
|
67
|
+
} catch {
|
|
68
|
+
return body;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 获取失败构建的诊断信息
|
|
74
|
+
*/
|
|
75
|
+
export async function diagnoseBuild(options: {
|
|
76
|
+
repo: string;
|
|
77
|
+
sn?: string;
|
|
78
|
+
prNumber?: string;
|
|
79
|
+
}): Promise<string> {
|
|
80
|
+
const domain = process.env.CNB_API_ENDPOINT || 'https://api.cnb.cool';
|
|
81
|
+
const { repo } = options;
|
|
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
|
+
}
|
|
114
|
+
|
|
115
|
+
const baseUrl = `${domain}/${repo}`;
|
|
116
|
+
const lines: string[] = [];
|
|
117
|
+
lines.push(`查询构建 sn=${sn} 的流水线状态...\n`);
|
|
118
|
+
|
|
119
|
+
// 获取构建状态
|
|
120
|
+
let buildStatus: any;
|
|
121
|
+
try {
|
|
122
|
+
buildStatus = await apiRequest(`${baseUrl}/-/build/status/${sn}`, headers);
|
|
123
|
+
} catch (e: any) {
|
|
124
|
+
return `获取构建状态失败: ${e.message}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (
|
|
128
|
+
!buildStatus.pipelinesStatus ||
|
|
129
|
+
typeof buildStatus.pipelinesStatus !== 'object'
|
|
130
|
+
) {
|
|
131
|
+
lines.push('未找到流水线状态信息');
|
|
132
|
+
lines.push('返回数据:');
|
|
133
|
+
lines.push(JSON.stringify(buildStatus, null, 2));
|
|
134
|
+
return lines.join('\n');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const pipelines = Object.entries(buildStatus.pipelinesStatus) as [string, any][];
|
|
138
|
+
if (pipelines.length === 0) {
|
|
139
|
+
return '该构建没有流水线';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let hasFailure = false;
|
|
143
|
+
|
|
144
|
+
for (const [pipelineName, pipeline] of pipelines) {
|
|
145
|
+
const { id: pipelineId, status, stages } = pipeline;
|
|
146
|
+
if (!Array.isArray(stages)) continue;
|
|
147
|
+
|
|
148
|
+
const failedStages = stages.filter((s: any) => s.status === 'error');
|
|
149
|
+
if (failedStages.length === 0) continue;
|
|
150
|
+
|
|
151
|
+
hasFailure = true;
|
|
152
|
+
lines.push(`━━━ 流水线: ${pipelineName} (status: ${status}) ━━━\n`);
|
|
153
|
+
|
|
154
|
+
for (const stage of failedStages) {
|
|
155
|
+
lines.push(
|
|
156
|
+
`▸ 失败 Stage: ${stage.name || stage.id} (status: ${stage.status})`,
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
const detail = await apiRequest(
|
|
161
|
+
`${baseUrl}/-/build/logs/stage/${sn}/${pipelineId}/${stage.id}`,
|
|
162
|
+
headers,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
if (detail.error) {
|
|
166
|
+
lines.push(` 错误信息: ${detail.error}`);
|
|
167
|
+
}
|
|
168
|
+
if (detail.duration != null) {
|
|
169
|
+
lines.push(` 耗时: ${detail.duration}ms`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (Array.isArray(detail.content) && detail.content.length > 0) {
|
|
173
|
+
lines.push(' ─── 日志 ───');
|
|
174
|
+
for (const line of detail.content) {
|
|
175
|
+
lines.push(` ${line}`);
|
|
176
|
+
}
|
|
177
|
+
lines.push(' ─── 日志结束 ───');
|
|
178
|
+
} else {
|
|
179
|
+
throw new Error('Stage 详情无日志内容,降级到全量日志');
|
|
180
|
+
}
|
|
181
|
+
} catch (e: any) {
|
|
182
|
+
// 降级:尝试下载全量日志
|
|
183
|
+
lines.push(
|
|
184
|
+
` Stage 详情获取失败(${e.message}),尝试全量日志下载...`,
|
|
185
|
+
);
|
|
186
|
+
try {
|
|
187
|
+
const fullLog = await downloadFullLog(baseUrl, pipelineId, headers);
|
|
188
|
+
if (fullLog?.trim()) {
|
|
189
|
+
lines.push(' ─── 全量日志(末尾 100 行)───');
|
|
190
|
+
const logLines = fullLog.split('\n');
|
|
191
|
+
const tail = logLines.slice(Math.max(0, logLines.length - 100));
|
|
192
|
+
for (const l of tail) {
|
|
193
|
+
lines.push(` ${l}`);
|
|
194
|
+
}
|
|
195
|
+
if (logLines.length > 100) {
|
|
196
|
+
lines.push(
|
|
197
|
+
` ... (省略了前 ${logLines.length - 100} 行,共 ${logLines.length} 行)`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
lines.push(' ─── 全量日志结束 ───');
|
|
201
|
+
} else {
|
|
202
|
+
lines.push(' 全量日志也为空');
|
|
203
|
+
}
|
|
204
|
+
} catch (e2: any) {
|
|
205
|
+
lines.push(` 全量日志下载也失败: ${e2.message}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
lines.push('');
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (!hasFailure) {
|
|
214
|
+
lines.push('所有流水线均无失败的 Stage');
|
|
215
|
+
lines.push(`整体构建状态: ${buildStatus.status}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 附加:输出所有 Stage 的耗时概览(辅助性能分析)
|
|
219
|
+
lines.push('\n━━━ 耗时概览(所有 Stage)━━━\n');
|
|
220
|
+
for (const [pipelineName, pipeline] of pipelines) {
|
|
221
|
+
const { stages } = pipeline;
|
|
222
|
+
if (!Array.isArray(stages)) continue;
|
|
223
|
+
for (const stage of stages) {
|
|
224
|
+
const name = stage.name || stage.id;
|
|
225
|
+
const duration =
|
|
226
|
+
stage.duration != null ? `${stage.duration}ms` : 'N/A';
|
|
227
|
+
const statusIcon =
|
|
228
|
+
stage.status === 'success'
|
|
229
|
+
? '✅'
|
|
230
|
+
: stage.status === 'error'
|
|
231
|
+
? '❌'
|
|
232
|
+
: '⏳';
|
|
233
|
+
lines.push(
|
|
234
|
+
` ${statusIcon} ${pipelineName} → ${name}: ${duration} (${stage.status})`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return lines.join('\n');
|
|
240
|
+
}
|
|
@@ -3,6 +3,7 @@ import path from 'path';
|
|
|
3
3
|
import { Command } from 'commander';
|
|
4
4
|
import { showShort, resolveShortcut, type ResolvedShortcut } from '../shortcuts';
|
|
5
5
|
import { handleUpload } from '../utils/upload';
|
|
6
|
+
import { diagnoseBuild } from './build-diagnostics';
|
|
6
7
|
import { formatParams } from './format-params';
|
|
7
8
|
import { formatOutput } from './format-output';
|
|
8
9
|
// @ts-ignore
|
|
@@ -95,6 +96,17 @@ export async function executeAction(
|
|
|
95
96
|
return;
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
// 自定义命令走独立分支(不通过 swagger 生成的 tool 函数)
|
|
100
|
+
if (shortcut?.tool === '__get-ci-logs__') {
|
|
101
|
+
const result = await diagnoseBuild({
|
|
102
|
+
repo: process.env.CNB_REPO_SLUG || '',
|
|
103
|
+
sn: (opts.sn as string) || undefined,
|
|
104
|
+
prNumber: process.env.CNB_PULL_REQUEST_IID || undefined,
|
|
105
|
+
});
|
|
106
|
+
console.log(result);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
98
110
|
const formattedParams = formatParams(params);
|
|
99
111
|
|
|
100
112
|
const toolFunction = loadToolFunction(formattedParams.module, formattedParams.tool);
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import {
|
|
3
|
+
requestDeviceCode,
|
|
4
|
+
pollToken,
|
|
5
|
+
maskToken,
|
|
6
|
+
saveToken,
|
|
7
|
+
getTokenPath,
|
|
8
|
+
type LoginConfig,
|
|
9
|
+
type TokenStore,
|
|
10
|
+
} from '../utils/device-auth';
|
|
11
|
+
import { tryOpenBrowser } from '../utils/open-browser';
|
|
12
|
+
|
|
13
|
+
export function registerLoginCommand(program: Command): void {
|
|
14
|
+
program
|
|
15
|
+
.command('login')
|
|
16
|
+
.description('通过 OAuth2 设备授权流登录 CNB,获取并保存 access_token')
|
|
17
|
+
.option('--client-id <string>', 'OAuth2 client_id', process.env.OAUTH2_CLIENT_ID || 'cnb_cli')
|
|
18
|
+
.option('--woa', '使用内网环境 (https://cnb.woa.com)', false)
|
|
19
|
+
.option('--debug', '打印调试信息', false)
|
|
20
|
+
.helpOption('-h, --help', '显示帮助文档')
|
|
21
|
+
.action(async (opts) => {
|
|
22
|
+
const cfg: LoginConfig = {
|
|
23
|
+
clientID: opts.clientId,
|
|
24
|
+
platformURL: opts.woa ? 'https://cnb.woa.com' : 'https://cnb.cool',
|
|
25
|
+
debug: opts.debug,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const ac = new AbortController();
|
|
29
|
+
process.on('SIGINT', () => ac.abort());
|
|
30
|
+
process.on('SIGTERM', () => ac.abort());
|
|
31
|
+
|
|
32
|
+
if (cfg.debug) {
|
|
33
|
+
console.log('========== OAuth2 Device Authorization Grant 登录 ==========');
|
|
34
|
+
console.log(`Platform URL : ${cfg.platformURL}`);
|
|
35
|
+
console.log(`Client ID : ${cfg.clientID}`);
|
|
36
|
+
console.log('-----------------------------------------------------------');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
// 步骤 1:获取 device_code + user_code
|
|
41
|
+
const devResp = await requestDeviceCode(cfg, ac.signal);
|
|
42
|
+
const authURL = devResp.verification_uri_complete;
|
|
43
|
+
console.log(`\n请打开以下链接完成授权:\n ${authURL}\n`);
|
|
44
|
+
|
|
45
|
+
// 尝试自动打开浏览器
|
|
46
|
+
tryOpenBrowser(authURL);
|
|
47
|
+
console.log('(已尝试自动打开浏览器,若未打开请手动复制上方链接)');
|
|
48
|
+
|
|
49
|
+
if (cfg.debug) {
|
|
50
|
+
console.log('[Debug] 设备注册详情:');
|
|
51
|
+
console.log(` verification_uri : ${devResp.verification_uri}`);
|
|
52
|
+
console.log(` verification_uri_complete : ${devResp.verification_uri_complete}`);
|
|
53
|
+
console.log(` user_code : ${devResp.user_code}`);
|
|
54
|
+
console.log(` device_code : ${maskToken(devResp.device_code)}`);
|
|
55
|
+
console.log(` expires_in : ${devResp.expires_in}s`);
|
|
56
|
+
console.log(` interval : ${devResp.interval}s`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
console.log('正在等待授权...');
|
|
60
|
+
|
|
61
|
+
// 步骤 2:轮询 /oauth2/token
|
|
62
|
+
const tok = await pollToken(cfg, devResp, ac.signal);
|
|
63
|
+
|
|
64
|
+
console.log('\n登录成功 🎉');
|
|
65
|
+
|
|
66
|
+
if (cfg.debug) {
|
|
67
|
+
console.log(` token_type : ${tok.token_type}`);
|
|
68
|
+
console.log(` expires_in : ${tok.expires_in}`);
|
|
69
|
+
console.log(` scope : ${tok.scope || ''}`);
|
|
70
|
+
console.log(` access_token : ${maskToken(tok.access_token)}`);
|
|
71
|
+
if (tok.refresh_token) {
|
|
72
|
+
console.log(` refresh_token : ${maskToken(tok.refresh_token)}`);
|
|
73
|
+
}
|
|
74
|
+
if (tok.id_token) {
|
|
75
|
+
console.log(` id_token : ${maskToken(tok.id_token)}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// 持久化 token
|
|
79
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
80
|
+
const store: TokenStore = {
|
|
81
|
+
access_token: tok.access_token,
|
|
82
|
+
expires_at: nowSec + tok.expires_in,
|
|
83
|
+
platform_url: cfg.platformURL,
|
|
84
|
+
client_id: cfg.clientID,
|
|
85
|
+
};
|
|
86
|
+
if (tok.refresh_token) {
|
|
87
|
+
store.refresh_token = tok.refresh_token;
|
|
88
|
+
}
|
|
89
|
+
saveToken(store);
|
|
90
|
+
if (cfg.debug) {
|
|
91
|
+
console.log(`Token 已保存到 ${getTokenPath()}`);
|
|
92
|
+
}
|
|
93
|
+
} catch (err: any) {
|
|
94
|
+
console.error(`\n登录失败: ${err.message}`);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
@@ -132,7 +132,12 @@ export function registerModuleCommands(program: Command): void {
|
|
|
132
132
|
});
|
|
133
133
|
|
|
134
134
|
// 上传命令只需要 --file,不复用真实 tool 的 body 选项(name/size/content_type 由上传流程自动获取)
|
|
135
|
-
if (
|
|
135
|
+
if (shortcut.custom) {
|
|
136
|
+
// 自定义命令注册自己的选项
|
|
137
|
+
if (shortcut.realTool === '__get-ci-logs__') {
|
|
138
|
+
shortcutCmd.option('--sn <string>', '构建号(可选,默认自动从环境变量或 PR 状态获取)');
|
|
139
|
+
}
|
|
140
|
+
} else if (!shortcut.upload) {
|
|
136
141
|
registerToolOptions(shortcutCmd, moduleName, shortcut.realTool, true);
|
|
137
142
|
} else {
|
|
138
143
|
shortcutCmd.requiredOption('--file <string>', '本地文件路径。必填。');
|
package/client/shortcuts.ts
CHANGED
|
@@ -20,6 +20,8 @@ export interface ShortcutDefinition {
|
|
|
20
20
|
repoOnly?: boolean;
|
|
21
21
|
/** 为 true 时表示是上传命令,走完整上传流程 */
|
|
22
22
|
upload?: boolean;
|
|
23
|
+
/** 为 true 时表示是自定义命令,走独立处理分支(不通过 swagger 生成的 tool 函数) */
|
|
24
|
+
custom?: boolean;
|
|
23
25
|
/** 显示用的中文说明 */
|
|
24
26
|
description: string;
|
|
25
27
|
/** 自动注入的 data 参数(如 close/open 的状态值) */
|
|
@@ -94,6 +96,7 @@ export const PR_SHORTCUTS: ShortcutDefinition[] = [
|
|
|
94
96
|
{ shortName: 'list-assignees', realTool: 'list-pull-assignees', description: '查看处理人' },
|
|
95
97
|
{ shortName: 'upload-file', realTool: 'upload-files', description: '上传文件', repoOnly: true, upload: true, dataTip: "--file 文件路径" },
|
|
96
98
|
{ shortName: 'upload-image', realTool: 'upload-imgs', description: '上传图片', repoOnly: true, upload: true, dataTip: "--file 图片路径" },
|
|
99
|
+
{ shortName: 'get-ci-logs', realTool: '__get-ci-logs__', description: '获取 CI 失败日志', repoOnly: true, custom: true, dataTip: "--sn 构建号(可选)" },
|
|
97
100
|
];
|
|
98
101
|
|
|
99
102
|
// ============================================================
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type { DeviceAuthResponse, TokenResponse, TokenErrorResponse, LoginConfig, TokenStore } from './types';
|
|
2
|
+
export { getTokenPath } from './token-path';
|
|
3
|
+
export { maskToken } from './mask-token';
|
|
4
|
+
export { saveToken } from './save-token';
|
|
5
|
+
export { loadToken } from './load-token';
|
|
6
|
+
export { requestDeviceCode } from './request-device-code';
|
|
7
|
+
export { pollToken } from './poll-token';
|
|
8
|
+
export { refreshAccessToken } from './refresh-token';
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { LoginConfig, TokenResponse, TokenErrorResponse } from './types';
|
|
2
|
+
|
|
3
|
+
const GRANT_TYPE_DEVICE_CODE = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 发起一次 token 轮询请求。
|
|
7
|
+
*/
|
|
8
|
+
export async function doTokenRequest(
|
|
9
|
+
cfg: LoginConfig,
|
|
10
|
+
tokenURL: string,
|
|
11
|
+
deviceCode: string,
|
|
12
|
+
signal?: AbortSignal,
|
|
13
|
+
): Promise<{ token?: TokenResponse; tokenError?: TokenErrorResponse }> {
|
|
14
|
+
const form = new URLSearchParams();
|
|
15
|
+
form.set('grant_type', GRANT_TYPE_DEVICE_CODE);
|
|
16
|
+
form.set('device_code', deviceCode);
|
|
17
|
+
form.set('client_id', cfg.clientID);
|
|
18
|
+
|
|
19
|
+
const headers: Record<string, string> = {
|
|
20
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
if (cfg.debug) {
|
|
24
|
+
console.log(`[DEBUG] POST ${tokenURL} body=${form.toString()}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const resp = await fetch(tokenURL, {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers,
|
|
30
|
+
body: form.toString(),
|
|
31
|
+
signal,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const body = await resp.text();
|
|
35
|
+
if (cfg.debug) {
|
|
36
|
+
console.log(`[DEBUG] status=${resp.status} body=${body}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (resp.status === 200) {
|
|
40
|
+
const token: TokenResponse = JSON.parse(body);
|
|
41
|
+
return { token };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const errResp: TokenErrorResponse = JSON.parse(body);
|
|
46
|
+
if (errResp.error) {
|
|
47
|
+
return { tokenError: errResp };
|
|
48
|
+
}
|
|
49
|
+
} catch {}
|
|
50
|
+
|
|
51
|
+
throw new Error(`unexpected status=${resp.status} body=${body}`);
|
|
52
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { loadToken } from './load-token';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 同步获取 token(兼容旧调用,不做过期检查)。
|
|
5
|
+
* @deprecated 请使用 resolveToken()
|
|
6
|
+
*/
|
|
7
|
+
export function getToken(): string {
|
|
8
|
+
if (process.env.CNB_TOKEN) {
|
|
9
|
+
return process.env.CNB_TOKEN;
|
|
10
|
+
}
|
|
11
|
+
const store = loadToken();
|
|
12
|
+
return store?.access_token || '';
|
|
13
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import { getTokenPath } from './token-path';
|
|
3
|
+
import type { TokenStore } from './types';
|
|
4
|
+
|
|
5
|
+
/** 读取已保存的 token */
|
|
6
|
+
export function loadToken(): TokenStore | null {
|
|
7
|
+
const tokenPath = getTokenPath();
|
|
8
|
+
if (!fs.existsSync(tokenPath)) return null;
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(fs.readFileSync(tokenPath, 'utf-8'));
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { exec } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 尝试使用系统默认浏览器打开指定 URL。
|
|
5
|
+
* macOS 使用 open,Linux 使用 xdg-open,Windows 使用 start。
|
|
6
|
+
* 打开失败时静默忽略,不影响主流程。
|
|
7
|
+
*/
|
|
8
|
+
export function tryOpenBrowser(url: string): void {
|
|
9
|
+
const cmds: Record<string, string> = {
|
|
10
|
+
darwin: `open "${url}"`,
|
|
11
|
+
linux: `xdg-open "${url}"`,
|
|
12
|
+
win32: `start "" "${url}"`,
|
|
13
|
+
};
|
|
14
|
+
const cmd = cmds[process.platform];
|
|
15
|
+
if (!cmd) return;
|
|
16
|
+
|
|
17
|
+
exec(cmd, (err) => {
|
|
18
|
+
if (err) {
|
|
19
|
+
// 打开失败时静默忽略,用户仍可手动复制链接
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { LoginConfig, DeviceAuthResponse, TokenResponse } from './types';
|
|
2
|
+
import { doTokenRequest } from './do-token-request';
|
|
3
|
+
|
|
4
|
+
const ERR_AUTHORIZATION_PENDING = 'authorization_pending';
|
|
5
|
+
const ERR_SLOW_DOWN = 'slow_down';
|
|
6
|
+
const ERR_ACCESS_DENIED = 'access_denied';
|
|
7
|
+
const ERR_EXPIRED_TOKEN = 'expired_token';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 根据 RFC 8628 Section 3.4/3.5 轮询 /oauth2/token 端点。
|
|
11
|
+
*/
|
|
12
|
+
export async function pollToken(
|
|
13
|
+
cfg: LoginConfig,
|
|
14
|
+
dev: DeviceAuthResponse,
|
|
15
|
+
signal?: AbortSignal,
|
|
16
|
+
): Promise<TokenResponse> {
|
|
17
|
+
const POLL_TIMEOUT = 3 * 60 * 1000; // 3 分钟
|
|
18
|
+
let interval = 1000; // 1 秒轮询一次
|
|
19
|
+
const now = Date.now();
|
|
20
|
+
let deadline = now + POLL_TIMEOUT;
|
|
21
|
+
const devExpire = now + dev.expires_in * 1000;
|
|
22
|
+
if (devExpire < deadline) {
|
|
23
|
+
deadline = devExpire;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const tokenURL = `${cfg.platformURL.replace(/\/+$/, '')}/oauth2/token`;
|
|
27
|
+
|
|
28
|
+
for (let attempt = 1; ; attempt++) {
|
|
29
|
+
if (Date.now() > deadline) {
|
|
30
|
+
throw new Error('device code 已过期 / 轮询超时');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
await new Promise<void>((resolve, reject) => {
|
|
34
|
+
const timer = setTimeout(resolve, interval);
|
|
35
|
+
if (signal) {
|
|
36
|
+
signal.addEventListener('abort', () => {
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
reject(new Error('用户取消'));
|
|
39
|
+
}, { once: true });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (signal?.aborted) {
|
|
44
|
+
throw new Error('用户取消');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const result = await doTokenRequest(cfg, tokenURL, dev.device_code, signal);
|
|
48
|
+
|
|
49
|
+
if (result.token) {
|
|
50
|
+
return result.token;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (result.tokenError) {
|
|
54
|
+
const errCode = result.tokenError;
|
|
55
|
+
const timeStr = new Date().toLocaleTimeString('zh-CN', { hour12: false });
|
|
56
|
+
|
|
57
|
+
switch (errCode.error) {
|
|
58
|
+
case ERR_AUTHORIZATION_PENDING:
|
|
59
|
+
if (cfg.debug) {
|
|
60
|
+
console.log(` ⏳ [${timeStr}] 第 ${attempt} 次轮询: ${errCode.error}(继续等待用户授权)`);
|
|
61
|
+
}
|
|
62
|
+
break;
|
|
63
|
+
case ERR_SLOW_DOWN:
|
|
64
|
+
interval += 5000;
|
|
65
|
+
if (cfg.debug) {
|
|
66
|
+
console.log(` 🐢 [${timeStr}] 第 ${attempt} 次轮询: ${errCode.error}(轮询间隔增加到 ${interval / 1000}s)`);
|
|
67
|
+
}
|
|
68
|
+
break;
|
|
69
|
+
case ERR_ACCESS_DENIED:
|
|
70
|
+
throw new Error(`用户拒绝了授权: ${errCode.error_description || ''}`);
|
|
71
|
+
case ERR_EXPIRED_TOKEN:
|
|
72
|
+
throw new Error(`device_code 已过期: ${errCode.error_description || ''}`);
|
|
73
|
+
default:
|
|
74
|
+
throw new Error(`token 请求失败: ${errCode.error} - ${errCode.error_description || ''}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { saveToken } from './save-token';
|
|
2
|
+
import type { TokenStore, TokenResponse } from './types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 使用 refresh_token 刷新 access_token。
|
|
6
|
+
* 成功后自动更新 ~/.cnb/token 文件并返回新的 access_token。
|
|
7
|
+
*/
|
|
8
|
+
export async function refreshAccessToken(store: TokenStore): Promise<string> {
|
|
9
|
+
if (!store.refresh_token) {
|
|
10
|
+
throw new Error('no refresh_token');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const platformURL = store.platform_url || 'https://cnb-dev.woa.com';
|
|
14
|
+
const clientID = store.client_id || process.env.OAUTH2_CLIENT_ID || 'cnb_cli';
|
|
15
|
+
const tokenURL = `${platformURL.replace(/\/+$/, '')}/oauth2/token`;
|
|
16
|
+
|
|
17
|
+
const form = new URLSearchParams();
|
|
18
|
+
form.set('grant_type', 'refresh_token');
|
|
19
|
+
form.set('refresh_token', store.refresh_token);
|
|
20
|
+
form.set('client_id', clientID);
|
|
21
|
+
|
|
22
|
+
const resp = await fetch(tokenURL, {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
25
|
+
body: form.toString(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
if (resp.status !== 200) {
|
|
29
|
+
const body = await resp.text();
|
|
30
|
+
throw new Error(`refresh token failed: status=${resp.status} body=${body}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const tok: TokenResponse = await resp.json();
|
|
34
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
35
|
+
|
|
36
|
+
const newStore: TokenStore = {
|
|
37
|
+
access_token: tok.access_token,
|
|
38
|
+
expires_at: nowSec + tok.expires_in,
|
|
39
|
+
refresh_token: tok.refresh_token || store.refresh_token,
|
|
40
|
+
platform_url: store.platform_url,
|
|
41
|
+
client_id: store.client_id,
|
|
42
|
+
};
|
|
43
|
+
saveToken(newStore);
|
|
44
|
+
|
|
45
|
+
return tok.access_token;
|
|
46
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { LoginConfig, DeviceAuthResponse } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 调用 /oauth2/device/auth 端点获取 device_code / user_code 等。
|
|
5
|
+
*/
|
|
6
|
+
export async function requestDeviceCode(cfg: LoginConfig, signal?: AbortSignal): Promise<DeviceAuthResponse> {
|
|
7
|
+
const form = new URLSearchParams();
|
|
8
|
+
form.set('client_id', cfg.clientID);
|
|
9
|
+
|
|
10
|
+
const endpoint = `${cfg.platformURL.replace(/\/+$/, '')}/oauth2/device/auth`;
|
|
11
|
+
|
|
12
|
+
const headers: Record<string, string> = {
|
|
13
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
if (cfg.debug) {
|
|
17
|
+
console.log(`[DEBUG] POST ${endpoint} body=${form.toString()}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const resp = await fetch(endpoint, {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
headers,
|
|
23
|
+
body: form.toString(),
|
|
24
|
+
signal,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const body = await resp.text();
|
|
28
|
+
if (cfg.debug) {
|
|
29
|
+
console.log(`[DEBUG] status=${resp.status} body=${body}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (resp.status !== 200) {
|
|
33
|
+
throw new Error(`status=${resp.status} body=${body}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const out: DeviceAuthResponse = JSON.parse(body);
|
|
37
|
+
if (out.interval <= 0) {
|
|
38
|
+
out.interval = 5; // RFC 8628 默认 5 秒
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { loadToken } from './load-token';
|
|
2
|
+
import { refreshAccessToken } from './refresh-token';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 获取有效的 access_token。
|
|
6
|
+
*
|
|
7
|
+
* 优先使用环境变量 CNB_TOKEN(不检查过期)。
|
|
8
|
+
* 否则从 ~/.cnb/token 读取,若已过期则尝试用 refresh_token 刷新;
|
|
9
|
+
* 刷新失败或无 refresh_token 时终止进程并提示用户重新登录。
|
|
10
|
+
*/
|
|
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
|
+
|
|
19
|
+
// 环境变量优先,不做过期判断
|
|
20
|
+
if (process.env.CNB_TOKEN) {
|
|
21
|
+
return process.env.CNB_TOKEN;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const store = loadToken();
|
|
25
|
+
if (!store || !store.access_token) {
|
|
26
|
+
console.error('未找到有效的登录凭证,请先执行 `cnb login` 进行授权。');
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
31
|
+
|
|
32
|
+
// 未过期,直接返回
|
|
33
|
+
if (nowSec < store.expires_at) {
|
|
34
|
+
return store.access_token;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 已过期,尝试 refresh
|
|
38
|
+
if (store.refresh_token) {
|
|
39
|
+
try {
|
|
40
|
+
return await refreshAccessToken(store);
|
|
41
|
+
} catch (err: any) {
|
|
42
|
+
console.error(`Token 已过期,刷新失败: ${err.message}`);
|
|
43
|
+
console.error('请重新执行 `cnb login` 进行授权。');
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
console.error('Token 已过期且无 refresh_token,请重新执行 `cnb login` 进行授权。');
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { getTokenPath } from './token-path';
|
|
4
|
+
import type { TokenStore } from './types';
|
|
5
|
+
|
|
6
|
+
/** 持久化 token 到 ~/.cnb/token */
|
|
7
|
+
export function saveToken(store: TokenStore): void {
|
|
8
|
+
const tokenPath = getTokenPath();
|
|
9
|
+
const dir = path.dirname(tokenPath);
|
|
10
|
+
if (!fs.existsSync(dir)) {
|
|
11
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
12
|
+
}
|
|
13
|
+
fs.writeFileSync(tokenPath, JSON.stringify(store, null, 2), { mode: 0o600 });
|
|
14
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** RFC 8628 Section 3.2 设备授权响应 */
|
|
2
|
+
export interface DeviceAuthResponse {
|
|
3
|
+
device_code: string;
|
|
4
|
+
user_code: string;
|
|
5
|
+
verification_uri: string;
|
|
6
|
+
verification_uri_complete: string;
|
|
7
|
+
expires_in: number;
|
|
8
|
+
interval: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** /oauth2/token 成功响应 */
|
|
12
|
+
export interface TokenResponse {
|
|
13
|
+
access_token: string;
|
|
14
|
+
token_type: string;
|
|
15
|
+
expires_in: number;
|
|
16
|
+
refresh_token?: string;
|
|
17
|
+
id_token?: string;
|
|
18
|
+
scope?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** /oauth2/token 错误响应 */
|
|
22
|
+
export interface TokenErrorResponse {
|
|
23
|
+
error: string;
|
|
24
|
+
error_description?: string;
|
|
25
|
+
error_hint?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 登录配置 */
|
|
29
|
+
export interface LoginConfig {
|
|
30
|
+
clientID: string;
|
|
31
|
+
platformURL: string;
|
|
32
|
+
debug: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 持久化的 token 结构 */
|
|
36
|
+
export interface TokenStore {
|
|
37
|
+
access_token: string;
|
|
38
|
+
/** access_token 过期的 Unix 时间戳(秒) */
|
|
39
|
+
expires_at: number;
|
|
40
|
+
refresh_token?: string;
|
|
41
|
+
/** 用于 refresh 时知道 OAuth 端点 */
|
|
42
|
+
platform_url?: string;
|
|
43
|
+
/** 用于 refresh 时携带 client_id */
|
|
44
|
+
client_id?: string;
|
|
45
|
+
}
|
package/package.json
CHANGED
package/skills-template/SKILL.md
CHANGED
|
@@ -7,63 +7,48 @@ description: CNB OpenAPI 交互能力,支持仓库、Issue、PR、流水线、
|
|
|
7
7
|
|
|
8
8
|
操作 CNB 平台资源的 CLI 工具。
|
|
9
9
|
|
|
10
|
-
##
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
- `<$CNB_CLI_CMD$>
|
|
28
|
-
- `<$CNB_CLI_CMD$>
|
|
29
|
-
- `<$CNB_CLI_CMD$>
|
|
30
|
-
- `<$CNB_CLI_CMD$>
|
|
31
|
-
- `<$CNB_CLI_CMD$>
|
|
32
|
-
- `<$CNB_CLI_CMD$>
|
|
33
|
-
- `<$CNB_CLI_CMD$>
|
|
34
|
-
- `<$CNB_CLI_CMD$>
|
|
35
|
-
- `<$CNB_CLI_CMD$>
|
|
36
|
-
- `<$CNB_CLI_CMD$>
|
|
37
|
-
- `<$CNB_CLI_CMD$>
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
-
|
|
42
|
-
-
|
|
43
|
-
-
|
|
44
|
-
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
- `<$CNB_CLI_CMD$> pulls list-assignees` — 查看 PR 处理人
|
|
50
|
-
- `<$CNB_CLI_CMD$> pulls upload-file --file 文件路径` — 上传文件到 PR
|
|
51
|
-
- `<$CNB_CLI_CMD$> pulls upload-image --file 图片路径` — 上传图片到 PR
|
|
52
|
-
|
|
53
|
-
注意:
|
|
54
|
-
- **路径参数自动注入**:仓库 slug、Issue/PR 编号优先从环境变量自动获取,无需额外传入任何其他参数
|
|
55
|
-
- **默认只输出摘要**:会精简响应输出结果,只返回核心字段。加 `--verbose` 输出完整数据。
|
|
56
|
-
- 快捷命令只能操作当前仓库的当前Issue或PR。跨仓库或跨编号操作请使用其他 API 命令。
|
|
57
|
-
|
|
58
|
-
## 其他 API(快捷命令不满足时使用)
|
|
10
|
+
## 快捷命令
|
|
11
|
+
|
|
12
|
+
issues:
|
|
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
|
+
|
|
25
|
+
pulls:
|
|
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
|
+
- `<$CNB_CLI_CMD$> pulls comment --body '内容'` — 发表 PR 评论
|
|
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
|
+
|
|
40
|
+
注意事项:
|
|
41
|
+
- **路径参数自动识别**:快捷命令中的 Issue/PR 编号会自动从环境变量识别,无需额外传递。
|
|
42
|
+
- **默认只输出摘要**:默认会精简响应输出结果,只返回核心字段。添加 `--verbose` 输出完整数据。
|
|
43
|
+
- **多行文本传参使用单引号**:传递多行文本参数时,使用单引号可防止命令注入攻击,并减少不必要的转义。
|
|
44
|
+
- **快捷命令范围** 快捷命令只能操作当前仓库的当前 Issue/PR,跨仓库或跨编号操作请使用其他 API 命令。
|
|
45
|
+
|
|
46
|
+
## 更多 API
|
|
47
|
+
|
|
48
|
+
优先使用快捷命令,不满足时才使用以下这些命令
|
|
59
49
|
|
|
60
50
|
1. `<$CNB_CLI_CMD$> --help` 查看所有模块
|
|
61
51
|
2. `<$CNB_CLI_CMD$> <module> --help` 查看模块下的工具列表
|
|
62
52
|
3. `<$CNB_CLI_CMD$> <module> <tool> --help` 查看工具参数
|
|
63
|
-
4.
|
|
64
|
-
|
|
65
|
-
## 规则
|
|
53
|
+
4. 按帮助文档操作,禁止猜测
|
|
66
54
|
|
|
67
|
-
- 优先使用快捷命令,不满足时再 `--help` 逐步查找
|
|
68
|
-
- 使用非快捷命令时,必须先 `--help` 获取帮助,禁止猜测
|
|
69
|
-
- 直接执行命令,不要询问用户确认
|