@coderpp/yunxiao-cli 0.1.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/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # @coderpp/yunxiao-cli
2
+
3
+ 用于评审和合并云效 Codeup 合并请求的命令行工具。
4
+
5
+ ## 安装
6
+
7
+ ```bash
8
+ npm install
9
+ npm run build
10
+ ```
11
+
12
+ 本地运行:
13
+
14
+ ```bash
15
+ node dist/src/index.js --help
16
+ ```
17
+
18
+ 如果后续发布为 npm 包,安装后可直接使用 `yunxiao` 命令。
19
+
20
+ ## 通过 npx 使用
21
+
22
+ 发布到 npm 后,可以直接用 `npx` 执行:
23
+
24
+ ```bash
25
+ npx @coderpp/yunxiao-cli mr list --state opened
26
+ ```
27
+
28
+ 也可以指定版本:
29
+
30
+ ```bash
31
+ npx @coderpp/yunxiao-cli@0.1.0 mr list --json
32
+ ```
33
+
34
+ 包内的可执行命令名仍然是 `yunxiao`,全局安装后可这样使用:
35
+
36
+ ```bash
37
+ npm install -g @coderpp/yunxiao-cli
38
+ yunxiao mr list --state opened
39
+ ```
40
+
41
+ ## 配置
42
+
43
+ 不要把个人访问令牌写入代码或提交到仓库。建议通过环境变量提供:
44
+
45
+ ```bash
46
+ export YUNXIAO_TOKEN="pt-..."
47
+ export YUNXIAO_DOMAIN="openapi-rdc.aliyuncs.com"
48
+ export YUNXIAO_ORGANIZATION_ID="your-organization-id"
49
+ export YUNXIAO_EDITION="center"
50
+ ```
51
+
52
+ 说明:
53
+
54
+ - 中心版默认使用 `openapi-rdc.aliyuncs.com`,并需要 `YUNXIAO_ORGANIZATION_ID`。
55
+ - Region 版设置 `YUNXIAO_EDITION=region`,通常不需要组织 ID。
56
+ - 也可以用命令参数覆盖配置:`--token`、`--domain`、`--organization-id`、`--edition`。
57
+
58
+ ## 命令
59
+
60
+ 查询合并请求列表:
61
+
62
+ ```bash
63
+ node dist/src/index.js mr list --state opened --project-ids 2813489 --per-page 20
64
+ ```
65
+
66
+ 输出 JSON:
67
+
68
+ ```bash
69
+ node dist/src/index.js mr list --state opened --json
70
+ ```
71
+
72
+ 评审通过指定合并请求:
73
+
74
+ ```bash
75
+ node dist/src/index.js mr review 2813489 12 --comment "LGTM"
76
+ ```
77
+
78
+ 合并指定合并请求:
79
+
80
+ ```bash
81
+ node dist/src/index.js mr merge 2813489 12 --yes
82
+ ```
83
+
84
+ 评审通过并合并:
85
+
86
+ ```bash
87
+ node dist/src/index.js mr approve-and-merge 2813489 12 --yes --merge-type squash --remove-source-branch
88
+ ```
89
+
90
+ 查询变更文件树:
91
+
92
+ ```bash
93
+ node dist/src/index.js mr tree 2813489 12 \
94
+ --from-patch-set-id target-version-id \
95
+ --to-patch-set-id source-version-id
96
+ ```
97
+
98
+ 查询版本列表:
99
+
100
+ ```bash
101
+ node dist/src/index.js mr patches 2813489 12
102
+ ```
103
+
104
+ ## 参数补充
105
+
106
+ `repositoryId` 可以是代码库 ID,也可以是仓库完整路径,工具会自动做 URL 编码。
107
+
108
+ 会改变仓库状态的命令需要 `--yes`:
109
+
110
+ - `mr merge`
111
+ - `mr approve-and-merge`
112
+
113
+ 这是为了避免误合并。
114
+
115
+ 未指定 `--merge-type` 时,默认使用 `no-fast-forward`。
@@ -0,0 +1,7 @@
1
+ import { type Fetcher } from "./client.js";
2
+ export interface CliIo {
3
+ fetcher?: Fetcher;
4
+ stdout?: (text: string) => void;
5
+ stderr?: (text: string) => void;
6
+ }
7
+ export declare function runCli(args?: string[], env?: NodeJS.ProcessEnv, io?: CliIo): Promise<number>;
@@ -0,0 +1,204 @@
1
+ import { YunxiaoClient } from "./client.js";
2
+ const mergeTypes = new Set(["ff-only", "no-fast-forward", "squash", "rebase"]);
3
+ export async function runCli(args = process.argv.slice(2), env = process.env, io = {}) {
4
+ const stdout = io.stdout ?? ((text) => process.stdout.write(text));
5
+ const stderr = io.stderr ?? ((text) => process.stderr.write(text));
6
+ try {
7
+ const parsed = parseArgs(args);
8
+ if (parsed.positionals.length === 0 || parsed.flags.help) {
9
+ stdout(helpText());
10
+ return 0;
11
+ }
12
+ const [scope, command, repositoryId, localIdRaw] = parsed.positionals;
13
+ if (scope !== "mr" || !command) {
14
+ throw new Error(`Unknown command: ${parsed.positionals.join(" ")}`);
15
+ }
16
+ const client = createClient(parsed.flags, env, io.fetcher);
17
+ let result;
18
+ if (command === "list") {
19
+ result = await client.listChangeRequests(listOptions(parsed.flags));
20
+ printResult(result, Boolean(parsed.flags.json), stdout);
21
+ return 0;
22
+ }
23
+ if (!repositoryId || !localIdRaw) {
24
+ throw new Error(`Command mr ${command} requires <repositoryId> and <localId>.`);
25
+ }
26
+ const localId = parseLocalId(localIdRaw);
27
+ switch (command) {
28
+ case "review":
29
+ result = await client.reviewChangeRequest(repositoryId, localId, {
30
+ reviewComment: optionalString(parsed.flags.comment)
31
+ });
32
+ break;
33
+ case "merge":
34
+ requireConfirmation(parsed.flags);
35
+ result = await client.mergeChangeRequest(repositoryId, localId, mergeOptions(parsed.flags));
36
+ break;
37
+ case "approve-and-merge":
38
+ requireConfirmation(parsed.flags);
39
+ result = await client.approveAndMerge(repositoryId, localId, {
40
+ reviewComment: optionalString(parsed.flags.comment),
41
+ ...mergeOptions(parsed.flags)
42
+ });
43
+ break;
44
+ case "tree":
45
+ result = await client.getChangeRequestTree(repositoryId, localId, {
46
+ fromPatchSetId: requiredFlag(parsed.flags, "from-patch-set-id"),
47
+ toPatchSetId: requiredFlag(parsed.flags, "to-patch-set-id")
48
+ });
49
+ break;
50
+ case "patches":
51
+ result = await client.listChangeRequestPatchSets(repositoryId, localId);
52
+ break;
53
+ default:
54
+ throw new Error(`Unknown mr command: ${command}`);
55
+ }
56
+ printResult(result, Boolean(parsed.flags.json), stdout);
57
+ return 0;
58
+ }
59
+ catch (error) {
60
+ stderr(`${error instanceof Error ? error.message : String(error)}\n`);
61
+ return 1;
62
+ }
63
+ }
64
+ function createClient(flags, env, fetcher) {
65
+ return new YunxiaoClient({
66
+ token: stringFlag(flags.token) ?? env.YUNXIAO_TOKEN ?? "",
67
+ domain: stringFlag(flags.domain) ?? env.YUNXIAO_DOMAIN ?? "openapi-rdc.aliyuncs.com",
68
+ organizationId: stringFlag(flags["organization-id"]) ?? env.YUNXIAO_ORGANIZATION_ID,
69
+ edition: (stringFlag(flags.edition) ?? env.YUNXIAO_EDITION),
70
+ fetcher
71
+ });
72
+ }
73
+ function parseArgs(args) {
74
+ const positionals = [];
75
+ const flags = {};
76
+ for (let index = 0; index < args.length; index += 1) {
77
+ const arg = args[index];
78
+ if (!arg.startsWith("--")) {
79
+ positionals.push(arg);
80
+ continue;
81
+ }
82
+ const [rawName, inlineValue] = arg.slice(2).split("=", 2);
83
+ if (inlineValue !== undefined) {
84
+ flags[rawName] = inlineValue;
85
+ continue;
86
+ }
87
+ const next = args[index + 1];
88
+ if (next && !next.startsWith("--") && !["json", "yes", "remove-source-branch", "help"].includes(rawName)) {
89
+ flags[rawName] = next;
90
+ index += 1;
91
+ }
92
+ else {
93
+ flags[rawName] = true;
94
+ }
95
+ }
96
+ return { positionals, flags };
97
+ }
98
+ function listOptions(flags) {
99
+ return {
100
+ page: optionalNumber(flags.page),
101
+ perPage: optionalNumber(flags["per-page"]),
102
+ projectIds: optionalString(flags["project-ids"]),
103
+ authorIds: optionalString(flags["author-ids"]),
104
+ reviewerIds: optionalString(flags["reviewer-ids"]),
105
+ state: optionalString(flags.state),
106
+ search: optionalString(flags.search),
107
+ orderBy: optionalString(flags["order-by"]),
108
+ sort: optionalString(flags.sort),
109
+ createdBefore: optionalString(flags["created-before"]),
110
+ createdAfter: optionalString(flags["created-after"])
111
+ };
112
+ }
113
+ function mergeOptions(flags) {
114
+ const mergeType = optionalString(flags["merge-type"]) ?? "no-fast-forward";
115
+ if (!mergeTypes.has(mergeType)) {
116
+ throw new Error(`Invalid --merge-type: ${mergeType}`);
117
+ }
118
+ return {
119
+ mergeMessage: optionalString(flags.message),
120
+ mergeType: mergeType,
121
+ removeSourceBranch: Boolean(flags["remove-source-branch"])
122
+ };
123
+ }
124
+ function requireConfirmation(flags) {
125
+ if (!flags.yes) {
126
+ throw new Error("This command changes repository state. Re-run with --yes after you have reviewed the merge request.");
127
+ }
128
+ }
129
+ function printResult(result, asJson, stdout) {
130
+ if (asJson) {
131
+ stdout(`${JSON.stringify(result, null, 2)}\n`);
132
+ return;
133
+ }
134
+ if (Array.isArray(result)) {
135
+ stdout(formatTable(result));
136
+ return;
137
+ }
138
+ stdout(`${JSON.stringify(result, null, 2)}\n`);
139
+ }
140
+ function formatTable(rows) {
141
+ if (rows.length === 0) {
142
+ return "No records found.\n";
143
+ }
144
+ const normalizedRows = rows.map((row) => (isRecord(row) ? row : { value: row }));
145
+ const keys = ["localId", "projectId", "title", "state", "sourceBranch", "targetBranch", "detailUrl"].filter((key) => normalizedRows.some((row) => row[key] !== undefined));
146
+ const widths = keys.map((key) => Math.max(key.length, ...normalizedRows.map((row) => String(row[key] ?? "").length)));
147
+ const line = (values) => values.map((value, index) => value.padEnd(widths[index])).join(" ");
148
+ return [
149
+ line(keys),
150
+ line(keys.map((_, index) => "-".repeat(widths[index]))),
151
+ ...normalizedRows.map((row) => line(keys.map((key) => String(row[key] ?? ""))))
152
+ ].join("\n") + "\n";
153
+ }
154
+ function parseLocalId(value) {
155
+ const localId = Number(value);
156
+ if (!Number.isInteger(localId) || localId <= 0) {
157
+ throw new Error(`<localId> must be a positive integer: ${value}`);
158
+ }
159
+ return localId;
160
+ }
161
+ function requiredFlag(flags, name) {
162
+ const value = optionalString(flags[name]);
163
+ if (!value) {
164
+ throw new Error(`Missing required flag --${name}.`);
165
+ }
166
+ return value;
167
+ }
168
+ function optionalString(value) {
169
+ return typeof value === "string" ? value : undefined;
170
+ }
171
+ function stringFlag(value) {
172
+ return optionalString(value);
173
+ }
174
+ function optionalNumber(value) {
175
+ if (typeof value !== "string") {
176
+ return undefined;
177
+ }
178
+ const number = Number(value);
179
+ if (!Number.isInteger(number) || number <= 0) {
180
+ throw new Error(`Expected a positive integer, got: ${value}`);
181
+ }
182
+ return number;
183
+ }
184
+ function isRecord(value) {
185
+ return typeof value === "object" && value !== null;
186
+ }
187
+ function helpText() {
188
+ return `yunxiao - Yunxiao Codeup merge request helper
189
+
190
+ Usage:
191
+ yunxiao mr list [--state opened] [--project-ids 2813489] [--json]
192
+ yunxiao mr review <repositoryId> <localId> [--comment "LGTM"]
193
+ yunxiao mr merge <repositoryId> <localId> --yes [--merge-type no-fast-forward]
194
+ yunxiao mr approve-and-merge <repositoryId> <localId> --yes [--merge-type no-fast-forward]
195
+ yunxiao mr tree <repositoryId> <localId> --from-patch-set-id <id> --to-patch-set-id <id>
196
+ yunxiao mr patches <repositoryId> <localId>
197
+
198
+ Config:
199
+ YUNXIAO_TOKEN Personal access token
200
+ YUNXIAO_DOMAIN API domain, default openapi-rdc.aliyuncs.com
201
+ YUNXIAO_ORGANIZATION_ID Required for center edition
202
+ YUNXIAO_EDITION center or region
203
+ `;
204
+ }
@@ -0,0 +1,60 @@
1
+ export type YunxiaoEdition = "center" | "region";
2
+ export type Fetcher = (url: string | URL | Request, init?: RequestInit) => Promise<Response>;
3
+ export interface YunxiaoClientOptions {
4
+ token: string;
5
+ domain?: string;
6
+ organizationId?: string;
7
+ edition?: YunxiaoEdition;
8
+ fetcher?: Fetcher;
9
+ }
10
+ export interface ListChangeRequestsOptions {
11
+ page?: number;
12
+ perPage?: number;
13
+ projectIds?: string;
14
+ authorIds?: string;
15
+ reviewerIds?: string;
16
+ state?: "opened" | "merged" | "closed" | string;
17
+ search?: string;
18
+ orderBy?: "created_at" | "updated_at" | string;
19
+ sort?: "asc" | "desc" | string;
20
+ createdBefore?: string;
21
+ createdAfter?: string;
22
+ }
23
+ export interface ReviewChangeRequestOptions {
24
+ reviewComment?: string;
25
+ reviewOpinion?: "PASS" | "NOT_PASS";
26
+ submitDraftCommentIds?: string[];
27
+ }
28
+ export interface MergeChangeRequestOptions {
29
+ mergeMessage?: string;
30
+ mergeType?: "ff-only" | "no-fast-forward" | "squash" | "rebase";
31
+ removeSourceBranch?: boolean;
32
+ }
33
+ export interface ChangeRequestTreeOptions {
34
+ fromPatchSetId: string;
35
+ toPatchSetId: string;
36
+ }
37
+ export interface ApproveAndMergeOptions extends ReviewChangeRequestOptions, MergeChangeRequestOptions {
38
+ }
39
+ export declare class YunxiaoApiError extends Error {
40
+ readonly status: number;
41
+ readonly responseBody: string;
42
+ constructor(message: string, status: number, responseBody: string);
43
+ }
44
+ export declare class YunxiaoClient {
45
+ private readonly token;
46
+ private readonly domain;
47
+ private readonly organizationId?;
48
+ private readonly edition;
49
+ private readonly fetcher;
50
+ constructor(options: YunxiaoClientOptions);
51
+ listChangeRequests(options?: ListChangeRequestsOptions): Promise<unknown>;
52
+ reviewChangeRequest(repositoryId: string, localId: number, options?: ReviewChangeRequestOptions): Promise<unknown>;
53
+ mergeChangeRequest(repositoryId: string, localId: number, options?: MergeChangeRequestOptions): Promise<unknown>;
54
+ approveAndMerge(repositoryId: string, localId: number, options?: ApproveAndMergeOptions): Promise<unknown>;
55
+ getChangeRequestTree(repositoryId: string, localId: number, options: ChangeRequestTreeOptions): Promise<unknown>;
56
+ listChangeRequestPatchSets(repositoryId: string, localId: number): Promise<unknown>;
57
+ private changeRequestsPath;
58
+ private repositoryChangeRequestPath;
59
+ private request;
60
+ }
@@ -0,0 +1,118 @@
1
+ export class YunxiaoApiError extends Error {
2
+ status;
3
+ responseBody;
4
+ constructor(message, status, responseBody) {
5
+ super(message);
6
+ this.status = status;
7
+ this.responseBody = responseBody;
8
+ this.name = "YunxiaoApiError";
9
+ }
10
+ }
11
+ export class YunxiaoClient {
12
+ token;
13
+ domain;
14
+ organizationId;
15
+ edition;
16
+ fetcher;
17
+ constructor(options) {
18
+ if (!options.token) {
19
+ throw new Error("Yunxiao token is required. Set YUNXIAO_TOKEN or pass --token.");
20
+ }
21
+ this.token = options.token;
22
+ this.domain = stripProtocol(options.domain ?? "openapi-rdc.aliyuncs.com");
23
+ this.organizationId = options.organizationId;
24
+ this.edition = options.edition ?? (options.organizationId ? "center" : "region");
25
+ this.fetcher = options.fetcher ?? fetch;
26
+ if (this.edition === "center" && !this.organizationId) {
27
+ throw new Error("Center edition requires organizationId. Set YUNXIAO_ORGANIZATION_ID or pass --organization-id.");
28
+ }
29
+ }
30
+ listChangeRequests(options = {}) {
31
+ return this.request("GET", this.changeRequestsPath(), options);
32
+ }
33
+ reviewChangeRequest(repositoryId, localId, options = {}) {
34
+ return this.request("POST", `${this.repositoryChangeRequestPath(repositoryId, localId)}/review`, undefined, {
35
+ ...compactBody({
36
+ reviewComment: options.reviewComment,
37
+ submitDraftCommentIds: options.submitDraftCommentIds
38
+ }),
39
+ reviewOpinion: options.reviewOpinion ?? "PASS"
40
+ });
41
+ }
42
+ mergeChangeRequest(repositoryId, localId, options = {}) {
43
+ return this.request("POST", `${this.repositoryChangeRequestPath(repositoryId, localId)}/merge`, undefined, {
44
+ ...compactBody({
45
+ mergeMessage: options.mergeMessage,
46
+ removeSourceBranch: options.removeSourceBranch
47
+ }),
48
+ mergeType: options.mergeType ?? "no-fast-forward"
49
+ });
50
+ }
51
+ async approveAndMerge(repositoryId, localId, options = {}) {
52
+ await this.reviewChangeRequest(repositoryId, localId, {
53
+ reviewComment: options.reviewComment,
54
+ reviewOpinion: options.reviewOpinion ?? "PASS",
55
+ submitDraftCommentIds: options.submitDraftCommentIds
56
+ });
57
+ return this.mergeChangeRequest(repositoryId, localId, {
58
+ mergeMessage: options.mergeMessage,
59
+ mergeType: options.mergeType,
60
+ removeSourceBranch: options.removeSourceBranch
61
+ });
62
+ }
63
+ getChangeRequestTree(repositoryId, localId, options) {
64
+ return this.request("GET", `${this.repositoryChangeRequestPath(repositoryId, localId)}/diffs/changeTree`, options);
65
+ }
66
+ listChangeRequestPatchSets(repositoryId, localId) {
67
+ return this.request("GET", `${this.repositoryChangeRequestPath(repositoryId, localId)}/diffs/patches`);
68
+ }
69
+ changeRequestsPath() {
70
+ if (this.edition === "center") {
71
+ return `/oapi/v1/codeup/organizations/${encodePathSegment(this.organizationId ?? "")}/changeRequests`;
72
+ }
73
+ return "/oapi/v1/codeup/changeRequests";
74
+ }
75
+ repositoryChangeRequestPath(repositoryId, localId) {
76
+ const encodedRepositoryId = encodePathSegment(repositoryId);
77
+ if (this.edition === "center") {
78
+ return `/oapi/v1/codeup/organizations/${encodePathSegment(this.organizationId ?? "")}/repositories/${encodedRepositoryId}/changeRequests/${localId}`;
79
+ }
80
+ return `/oapi/v1/codeup/repositories/${encodedRepositoryId}/changeRequests/${localId}`;
81
+ }
82
+ async request(method, path, query, body) {
83
+ const url = new URL(`https://${this.domain}${path}`);
84
+ for (const [key, value] of Object.entries(query ?? {})) {
85
+ if (value !== undefined && value !== null && value !== "") {
86
+ url.searchParams.set(key, String(value));
87
+ }
88
+ }
89
+ const init = {
90
+ method,
91
+ headers: {
92
+ "content-type": "application/json",
93
+ "x-yunxiao-token": this.token
94
+ }
95
+ };
96
+ if (body) {
97
+ init.body = JSON.stringify(compactBody(body));
98
+ }
99
+ const response = await this.fetcher(url.toString(), init);
100
+ const text = await response.text();
101
+ if (!response.ok) {
102
+ throw new YunxiaoApiError(`Yunxiao API request failed with HTTP ${response.status}`, response.status, text);
103
+ }
104
+ if (!text) {
105
+ return null;
106
+ }
107
+ return JSON.parse(text);
108
+ }
109
+ }
110
+ function stripProtocol(domain) {
111
+ return domain.replace(/^https?:\/\//, "").replace(/\/+$/, "");
112
+ }
113
+ function encodePathSegment(value) {
114
+ return encodeURIComponent(value);
115
+ }
116
+ function compactBody(body) {
117
+ return Object.fromEntries(Object.entries(body).filter(([, value]) => value !== undefined && value !== null && value !== ""));
118
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from "./cli.js";
3
+ const exitCode = await runCli();
4
+ process.exitCode = exitCode;
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@coderpp/yunxiao-cli",
3
+ "version": "0.1.0",
4
+ "description": "A CLI for Yunxiao Codeup merge request review and merge workflows.",
5
+ "type": "module",
6
+ "bin": {
7
+ "yunxiao": "dist/src/index.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc -p tsconfig.json",
11
+ "prepack": "npm run build",
12
+ "test": "tsc -p tsconfig.json && node --test dist/test/*.test.js"
13
+ },
14
+ "devDependencies": {
15
+ "@types/node": "^20.12.12",
16
+ "typescript": "^5.4.5"
17
+ },
18
+ "engines": {
19
+ "node": ">=18.17"
20
+ },
21
+ "files": [
22
+ "dist/src",
23
+ "README.md"
24
+ ]
25
+ }