@coderpp/yunxiao-cli 0.1.4 → 0.1.6

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 CHANGED
@@ -28,7 +28,7 @@ npx @coderpp/yunxiao-cli mr list --state opened
28
28
  也可以指定版本:
29
29
 
30
30
  ```bash
31
- npx @coderpp/yunxiao-cli@0.1.0 mr list --json
31
+ npx @coderpp/yunxiao-cli@0.1.4 mr list --output json
32
32
  ```
33
33
 
34
34
  包内的可执行命令名仍然是 `yunxiao`,全局安装后可这样使用:
@@ -97,7 +97,7 @@ node dist/src/index.js mr list --state opened --project-ids 2813489 --per-page 2
97
97
  输出 JSON:
98
98
 
99
99
  ```bash
100
- node dist/src/index.js mr list --state opened --json
100
+ node dist/src/index.js mr list --state opened --output json
101
101
  ```
102
102
 
103
103
  评审通过指定合并请求:
@@ -118,6 +118,14 @@ node dist/src/index.js mr merge 2813489 12 --yes
118
118
  node dist/src/index.js mr approve-and-merge 2813489 12 --yes --merge-type squash --remove-source-branch
119
119
  ```
120
120
 
121
+ 创建发布合并请求、自动评审通过并合并:
122
+
123
+ ```bash
124
+ node dist/src/index.js mr release yunxiao-cli release/1.2.3 master --yes
125
+ ```
126
+
127
+ 这个命令会先按仓库名称查询代码库 ID,再用当前 token 对应的用户作为评审人创建合并请求,随后评审通过并合并。合并时固定保留源分支,不会删除源分支。
128
+
121
129
  查询变更文件树:
122
130
 
123
131
  ```bash
@@ -132,6 +140,25 @@ node dist/src/index.js mr tree 2813489 12 \
132
140
  node dist/src/index.js mr patches 2813489 12
133
141
  ```
134
142
 
143
+ ## 输出格式
144
+
145
+ 所有会输出 API 结果的命令都支持 `--output`:
146
+
147
+ ```bash
148
+ node dist/src/index.js mr list --output table
149
+ node dist/src/index.js mr list --output json
150
+ node dist/src/index.js mr review 2813489 12 --output table
151
+ node dist/src/index.js mr release yunxiao-cli release/1.2.3 master --yes --output json
152
+ node dist/src/index.js mr patches 2813489 12 --output json
153
+ ```
154
+
155
+ 支持的格式:
156
+
157
+ - `table`:普通表格文本,默认值。
158
+ - `json`:格式化 JSON。
159
+
160
+ `--json` 仍然可用,等价于 `--output json`。
161
+
135
162
  ## 参数补充
136
163
 
137
164
  `repositoryId` 可以是代码库 ID,也可以是仓库完整路径,工具会自动做 URL 编码。
@@ -140,6 +167,7 @@ node dist/src/index.js mr patches 2813489 12
140
167
 
141
168
  - `mr merge`
142
169
  - `mr approve-and-merge`
170
+ - `mr release`
143
171
 
144
172
  这是为了避免误合并。
145
173
 
package/dist/src/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { YunxiaoClient } from "./client.js";
2
2
  const mergeTypes = new Set(["ff-only", "no-fast-forward", "squash", "rebase"]);
3
+ const outputFormats = new Set(["table", "json"]);
3
4
  export async function runCli(args = process.argv.slice(2), env = process.env, io = {}) {
4
5
  const stdout = io.stdout ?? ((text) => process.stdout.write(text));
5
6
  const stderr = io.stderr ?? ((text) => process.stderr.write(text));
@@ -13,16 +14,30 @@ export async function runCli(args = process.argv.slice(2), env = process.env, io
13
14
  if (scope !== "mr" || !command) {
14
15
  throw new Error(`Unknown command: ${parsed.positionals.join(" ")}`);
15
16
  }
17
+ const outputFormat = outputFormatFromFlags(parsed.flags);
16
18
  const client = createClient(parsed.flags, env, io.fetcher);
17
19
  let result;
18
20
  if (command === "list") {
19
21
  result = await client.listChangeRequests(listOptions(parsed.flags));
20
- printResult(result, Boolean(parsed.flags.json), stdout);
22
+ printResult(result, outputFormat, stdout);
21
23
  return 0;
22
24
  }
23
25
  if (!repositoryId || !localIdRaw) {
24
26
  throw new Error(`Command mr ${command} requires <repositoryId> and <localId>.`);
25
27
  }
28
+ if (command === "release") {
29
+ requireConfirmation(parsed.flags);
30
+ result = await client.releaseByRepositoryName({
31
+ repositoryName: repositoryId,
32
+ sourceBranch: localIdRaw,
33
+ targetBranch: requiredPositional(parsed.positionals[4], "targetBranch"),
34
+ title: optionalString(parsed.flags.title),
35
+ description: optionalString(parsed.flags.description),
36
+ mergeType: mergeOptions(parsed.flags).mergeType
37
+ });
38
+ printResult(result, outputFormat, stdout);
39
+ return 0;
40
+ }
26
41
  const localId = parseLocalId(localIdRaw);
27
42
  switch (command) {
28
43
  case "review":
@@ -53,7 +68,7 @@ export async function runCli(args = process.argv.slice(2), env = process.env, io
53
68
  default:
54
69
  throw new Error(`Unknown mr command: ${command}`);
55
70
  }
56
- printResult(result, Boolean(parsed.flags.json), stdout);
71
+ printResult(result, outputFormat, stdout);
57
72
  return 0;
58
73
  }
59
74
  catch (error) {
@@ -126,8 +141,19 @@ function requireConfirmation(flags) {
126
141
  throw new Error("This command changes repository state. Re-run with --yes after you have reviewed the merge request.");
127
142
  }
128
143
  }
129
- function printResult(result, asJson, stdout) {
130
- if (asJson) {
144
+ function outputFormatFromFlags(flags) {
145
+ const requestedOutput = optionalString(flags.output);
146
+ if (flags.json && requestedOutput && requestedOutput !== "json") {
147
+ throw new Error("--json cannot be combined with --output table.");
148
+ }
149
+ const output = flags.json ? "json" : requestedOutput ?? "table";
150
+ if (!outputFormats.has(output)) {
151
+ throw new Error(`Invalid --output: ${output}. Supported formats: table, json.`);
152
+ }
153
+ return output;
154
+ }
155
+ function printResult(result, outputFormat, stdout) {
156
+ if (outputFormat === "json") {
131
157
  stdout(`${JSON.stringify(result, null, 2)}\n`);
132
158
  return;
133
159
  }
@@ -135,22 +161,50 @@ function printResult(result, asJson, stdout) {
135
161
  stdout(formatTable(result));
136
162
  return;
137
163
  }
138
- stdout(`${JSON.stringify(result, null, 2)}\n`);
164
+ if (isRecord(result)) {
165
+ stdout(formatKeyValueTable(result));
166
+ return;
167
+ }
168
+ if (result === null || result === undefined) {
169
+ stdout("No result.\n");
170
+ return;
171
+ }
172
+ stdout(`${String(result)}\n`);
139
173
  }
140
174
  function formatTable(rows) {
141
175
  if (rows.length === 0) {
142
176
  return "No records found.\n";
143
177
  }
144
178
  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)));
179
+ const preferredKeys = ["localId", "projectId", "title", "state", "sourceBranch", "targetBranch", "detailUrl"].filter((key) => normalizedRows.some((row) => row[key] !== undefined));
180
+ const keys = preferredKeys.length > 0 ? preferredKeys : uniqueKeys(normalizedRows);
181
+ const widths = keys.map((key) => Math.max(key.length, ...normalizedRows.map((row) => formatCellValue(row[key]).length)));
147
182
  const line = (values) => values.map((value, index) => value.padEnd(widths[index])).join(" ");
148
183
  return [
149
184
  line(keys),
150
185
  line(keys.map((_, index) => "-".repeat(widths[index]))),
151
- ...normalizedRows.map((row) => line(keys.map((key) => String(row[key] ?? ""))))
186
+ ...normalizedRows.map((row) => line(keys.map((key) => formatCellValue(row[key]))))
152
187
  ].join("\n") + "\n";
153
188
  }
189
+ function uniqueKeys(rows) {
190
+ return Array.from(new Set(rows.flatMap((row) => Object.keys(row))));
191
+ }
192
+ function formatKeyValueTable(record) {
193
+ const rows = Object.entries(record).map(([key, value]) => ({
194
+ key,
195
+ value: formatCellValue(value)
196
+ }));
197
+ return formatTable(rows);
198
+ }
199
+ function formatCellValue(value) {
200
+ if (value === null || value === undefined) {
201
+ return "";
202
+ }
203
+ if (typeof value === "object") {
204
+ return JSON.stringify(value);
205
+ }
206
+ return String(value);
207
+ }
154
208
  function parseLocalId(value) {
155
209
  const localId = Number(value);
156
210
  if (!Number.isInteger(localId) || localId <= 0) {
@@ -158,6 +212,12 @@ function parseLocalId(value) {
158
212
  }
159
213
  return localId;
160
214
  }
215
+ function requiredPositional(value, name) {
216
+ if (!value) {
217
+ throw new Error(`Missing required positional argument <${name}>.`);
218
+ }
219
+ return value;
220
+ }
161
221
  function requiredFlag(flags, name) {
162
222
  const value = optionalString(flags[name]);
163
223
  if (!value) {
@@ -188,12 +248,18 @@ function helpText() {
188
248
  return `yunxiao - Yunxiao Codeup merge request helper
189
249
 
190
250
  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>
251
+ yunxiao mr list [--state opened] [--project-ids 2813489] [--output table|json]
252
+ yunxiao mr review <repositoryId> <localId> [--comment "LGTM"] [--output table|json]
253
+ yunxiao mr merge <repositoryId> <localId> --yes [--merge-type no-fast-forward] [--output table|json]
254
+ yunxiao mr approve-and-merge <repositoryId> <localId> --yes [--merge-type no-fast-forward] [--output table|json]
255
+ yunxiao mr release <repositoryName> <sourceBranch> <targetBranch> --yes [--title "Release"] [--output table|json]
256
+ yunxiao mr tree <repositoryId> <localId> --from-patch-set-id <id> --to-patch-set-id <id> [--output table|json]
257
+ yunxiao mr patches <repositoryId> <localId> [--output table|json]
258
+
259
+ Output:
260
+ --output table Plain text table output, default
261
+ --output json Pretty JSON output
262
+ --json Alias for --output json
197
263
 
198
264
  Config:
199
265
  YUNXIAO_TOKEN Personal access token
@@ -34,12 +34,61 @@ export interface ChangeRequestTreeOptions {
34
34
  fromPatchSetId: string;
35
35
  toPatchSetId: string;
36
36
  }
37
+ export interface ListRepositoriesOptions {
38
+ page?: number;
39
+ perPage?: number;
40
+ orderBy?: "created_at" | "updated_at" | "last_activity_at" | string;
41
+ sort?: "asc" | "desc" | string;
42
+ search?: string;
43
+ archived?: boolean;
44
+ }
45
+ export interface RepositorySummary {
46
+ id: number | string;
47
+ name?: string;
48
+ path?: string;
49
+ nameWithNamespace?: string;
50
+ pathWithNamespace?: string;
51
+ }
52
+ export interface CurrentUser {
53
+ id: string;
54
+ name?: string;
55
+ username?: string;
56
+ email?: string;
57
+ }
58
+ export interface CreateChangeRequestOptions {
59
+ sourceBranch: string;
60
+ sourceProjectId: number | string;
61
+ targetBranch: string;
62
+ targetProjectId: number | string;
63
+ title: string;
64
+ createFrom?: "WEB" | "COMMAND_LINE";
65
+ description?: string;
66
+ reviewerUserIds?: string[];
67
+ triggerAIReviewRun?: boolean;
68
+ workItemIds?: string;
69
+ }
70
+ export interface ReleaseByRepositoryNameOptions {
71
+ repositoryName: string;
72
+ sourceBranch: string;
73
+ targetBranch: string;
74
+ title?: string;
75
+ description?: string;
76
+ mergeType?: MergeChangeRequestOptions["mergeType"];
77
+ }
78
+ export interface ReleaseResult {
79
+ repository: RepositorySummary;
80
+ changeRequest: unknown;
81
+ review: unknown;
82
+ merge: unknown;
83
+ }
37
84
  export interface ApproveAndMergeOptions extends ReviewChangeRequestOptions, MergeChangeRequestOptions {
38
85
  }
39
86
  export declare class YunxiaoApiError extends Error {
40
87
  readonly status: number;
41
88
  readonly responseBody: string;
42
- constructor(message: string, status: number, responseBody: string);
89
+ readonly method: string;
90
+ readonly url: string;
91
+ constructor(message: string, status: number, responseBody: string, method: string, url: string);
43
92
  }
44
93
  export declare class YunxiaoClient {
45
94
  private readonly token;
@@ -49,12 +98,18 @@ export declare class YunxiaoClient {
49
98
  private readonly fetcher;
50
99
  constructor(options: YunxiaoClientOptions);
51
100
  listChangeRequests(options?: ListChangeRequestsOptions): Promise<unknown>;
101
+ getCurrentUser(): Promise<CurrentUser>;
102
+ listRepositories(options?: ListRepositoriesOptions): Promise<RepositorySummary[]>;
103
+ createChangeRequest(repositoryId: string, options: CreateChangeRequestOptions): Promise<unknown>;
52
104
  reviewChangeRequest(repositoryId: string, localId: number, options?: ReviewChangeRequestOptions): Promise<unknown>;
53
105
  mergeChangeRequest(repositoryId: string, localId: number, options?: MergeChangeRequestOptions): Promise<unknown>;
54
106
  approveAndMerge(repositoryId: string, localId: number, options?: ApproveAndMergeOptions): Promise<unknown>;
55
107
  getChangeRequestTree(repositoryId: string, localId: number, options: ChangeRequestTreeOptions): Promise<unknown>;
56
108
  listChangeRequestPatchSets(repositoryId: string, localId: number): Promise<unknown>;
109
+ releaseByRepositoryName(options: ReleaseByRepositoryNameOptions): Promise<ReleaseResult>;
57
110
  private changeRequestsPath;
111
+ private repositoriesPath;
112
+ private repositoryPath;
58
113
  private repositoryChangeRequestPath;
59
114
  private request;
60
115
  }
@@ -1,10 +1,14 @@
1
1
  export class YunxiaoApiError extends Error {
2
2
  status;
3
3
  responseBody;
4
- constructor(message, status, responseBody) {
4
+ method;
5
+ url;
6
+ constructor(message, status, responseBody, method, url) {
5
7
  super(message);
6
8
  this.status = status;
7
9
  this.responseBody = responseBody;
10
+ this.method = method;
11
+ this.url = url;
8
12
  this.name = "YunxiaoApiError";
9
13
  }
10
14
  }
@@ -30,6 +34,26 @@ export class YunxiaoClient {
30
34
  listChangeRequests(options = {}) {
31
35
  return this.request("GET", this.changeRequestsPath(), options);
32
36
  }
37
+ getCurrentUser() {
38
+ return this.request("GET", "/oapi/v1/platform/user");
39
+ }
40
+ listRepositories(options = {}) {
41
+ return this.request("GET", this.repositoriesPath(), options);
42
+ }
43
+ createChangeRequest(repositoryId, options) {
44
+ return this.request("POST", `${this.repositoryPath(repositoryId)}/changeRequests`, undefined, {
45
+ createFrom: options.createFrom ?? "COMMAND_LINE",
46
+ description: options.description,
47
+ reviewerUserIds: options.reviewerUserIds,
48
+ sourceBranch: options.sourceBranch,
49
+ sourceProjectId: normalizeProjectId(options.sourceProjectId),
50
+ targetBranch: options.targetBranch,
51
+ targetProjectId: normalizeProjectId(options.targetProjectId),
52
+ title: options.title,
53
+ triggerAIReviewRun: options.triggerAIReviewRun ?? false,
54
+ workItemIds: options.workItemIds
55
+ });
56
+ }
33
57
  reviewChangeRequest(repositoryId, localId, options = {}) {
34
58
  return this.request("POST", `${this.repositoryChangeRequestPath(repositoryId, localId)}/review`, undefined, {
35
59
  ...compactBody({
@@ -66,18 +90,67 @@ export class YunxiaoClient {
66
90
  listChangeRequestPatchSets(repositoryId, localId) {
67
91
  return this.request("GET", `${this.repositoryChangeRequestPath(repositoryId, localId)}/diffs/patches`);
68
92
  }
93
+ async releaseByRepositoryName(options) {
94
+ const [currentUser, repositories] = await Promise.all([
95
+ this.getCurrentUser(),
96
+ this.listRepositories({
97
+ page: 1,
98
+ perPage: 20,
99
+ search: options.repositoryName,
100
+ archived: false
101
+ })
102
+ ]);
103
+ const repository = findRepositoryByName(repositories, options.repositoryName);
104
+ if (!repository) {
105
+ throw new Error(`Repository not found by name: ${options.repositoryName}`);
106
+ }
107
+ const repositoryId = String(repository.id);
108
+ const title = options.title ?? `Release ${options.sourceBranch} into ${options.targetBranch}`;
109
+ const changeRequest = await this.createChangeRequest(repositoryId, {
110
+ sourceBranch: options.sourceBranch,
111
+ targetBranch: options.targetBranch,
112
+ sourceProjectId: repository.id,
113
+ targetProjectId: repository.id,
114
+ reviewerUserIds: [currentUser.id],
115
+ title,
116
+ description: options.description
117
+ });
118
+ const localId = localIdFromChangeRequest(changeRequest);
119
+ const review = await this.reviewChangeRequest(repositoryId, localId, {
120
+ reviewComment: "Approved by yunxiao-cli release workflow"
121
+ });
122
+ const merge = await this.mergeChangeRequest(repositoryId, localId, {
123
+ mergeType: options.mergeType,
124
+ removeSourceBranch: false
125
+ });
126
+ return {
127
+ repository,
128
+ changeRequest,
129
+ review,
130
+ merge
131
+ };
132
+ }
69
133
  changeRequestsPath() {
70
134
  if (this.edition === "center") {
71
135
  return `/oapi/v1/codeup/organizations/${encodePathSegment(this.organizationId ?? "")}/changeRequests`;
72
136
  }
73
137
  return "/oapi/v1/codeup/changeRequests";
74
138
  }
75
- repositoryChangeRequestPath(repositoryId, localId) {
139
+ repositoriesPath() {
140
+ if (this.edition === "center") {
141
+ return `/oapi/v1/codeup/organizations/${encodePathSegment(this.organizationId ?? "")}/repositories`;
142
+ }
143
+ return "/oapi/v1/codeup/repositories";
144
+ }
145
+ repositoryPath(repositoryId) {
76
146
  const encodedRepositoryId = encodePathSegment(repositoryId);
77
147
  if (this.edition === "center") {
78
- return `/oapi/v1/codeup/organizations/${encodePathSegment(this.organizationId ?? "")}/repositories/${encodedRepositoryId}/changeRequests/${localId}`;
148
+ return `/oapi/v1/codeup/organizations/${encodePathSegment(this.organizationId ?? "")}/repositories/${encodedRepositoryId}`;
79
149
  }
80
- return `/oapi/v1/codeup/repositories/${encodedRepositoryId}/changeRequests/${localId}`;
150
+ return `/oapi/v1/codeup/repositories/${encodedRepositoryId}`;
151
+ }
152
+ repositoryChangeRequestPath(repositoryId, localId) {
153
+ return `${this.repositoryPath(repositoryId)}/changeRequests/${localId}`;
81
154
  }
82
155
  async request(method, path, query, body) {
83
156
  const url = new URL(`https://${this.domain}${path}`);
@@ -99,7 +172,7 @@ export class YunxiaoClient {
99
172
  const response = await this.fetcher(url.toString(), init);
100
173
  const text = await response.text();
101
174
  if (!response.ok) {
102
- throw new YunxiaoApiError(`Yunxiao API request failed with HTTP ${response.status}`, response.status, text);
175
+ throw new YunxiaoApiError(`Yunxiao API request failed with HTTP ${response.status}: ${method} ${url.toString()}${text ? `\n${text}` : ""}`, response.status, text, method, url.toString());
103
176
  }
104
177
  if (!text) {
105
178
  return null;
@@ -116,3 +189,29 @@ function encodePathSegment(value) {
116
189
  function compactBody(body) {
117
190
  return Object.fromEntries(Object.entries(body).filter(([, value]) => value !== undefined && value !== null && value !== ""));
118
191
  }
192
+ function normalizeProjectId(value) {
193
+ const numberValue = typeof value === "string" ? Number(value) : value;
194
+ return Number.isInteger(numberValue) ? numberValue : value;
195
+ }
196
+ function findRepositoryByName(repositories, repositoryName) {
197
+ const normalizedName = normalizeRepositoryName(repositoryName);
198
+ const matches = repositories.filter((repository) => [repository.name, repository.path, repository.nameWithNamespace, repository.pathWithNamespace]
199
+ .filter((value) => typeof value === "string")
200
+ .some((value) => normalizeRepositoryName(value) === normalizedName));
201
+ if (matches.length > 1) {
202
+ throw new Error(`Multiple repositories matched name: ${repositoryName}. Use a more specific path.`);
203
+ }
204
+ return matches[0];
205
+ }
206
+ function normalizeRepositoryName(value) {
207
+ return value.replace(/\s+\/\s+/g, "/").trim().toLowerCase();
208
+ }
209
+ function localIdFromChangeRequest(changeRequest) {
210
+ if (typeof changeRequest === "object" && changeRequest !== null && "localId" in changeRequest) {
211
+ const localId = Number(changeRequest.localId);
212
+ if (Number.isInteger(localId) && localId > 0) {
213
+ return localId;
214
+ }
215
+ }
216
+ throw new Error("Create change request response did not include a valid localId.");
217
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderpp/yunxiao-cli",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "A CLI for Yunxiao Codeup merge request review and merge workflows.",
5
5
  "type": "module",
6
6
  "repository": {