@coderpp/yunxiao-cli 0.1.11 → 0.2.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/dist/src/mr.js ADDED
@@ -0,0 +1,202 @@
1
+ import { CliError } from "./errors.js";
2
+ export function record(v) {
3
+ if (!v || typeof v !== "object" || Array.isArray(v))
4
+ throw new CliError("INVALID_RESPONSE", "Expected API object.", 1);
5
+ return v;
6
+ }
7
+ export function records(v) {
8
+ if (!Array.isArray(v))
9
+ throw new CliError("INVALID_RESPONSE", "Expected API array.", 1);
10
+ return v.map(record);
11
+ }
12
+ export function required(f, key) {
13
+ if (typeof f[key] !== "string" || !String(f[key]).trim())
14
+ throw new CliError("INVALID_ARGUMENT", `--${key} is required.`);
15
+ return String(f[key]);
16
+ }
17
+ export function confirmed(f) {
18
+ if (f.yes !== true)
19
+ throw new CliError("CONFIRMATION_REQUIRED", "This command changes state. Re-run with --yes after reviewing the operation.", 2);
20
+ }
21
+ export function latestPatch(patches, type) {
22
+ const list = patches
23
+ .filter((p) => p.relatedMergeItemType === type)
24
+ .sort((a, b) => Number(b.versionNo) - Number(a.versionNo));
25
+ if (!list.length)
26
+ throw new CliError("PATCH_NOT_FOUND", `No ${type} patch found; specify patch IDs.`, 5);
27
+ return list[0];
28
+ }
29
+ export async function patchPair(client, repo, id, f) {
30
+ const patches = records(await client.listChangeRequestPatchSets(repo, id));
31
+ function select(key, type) {
32
+ const p = f[key] === undefined
33
+ ? latestPatch(patches, type)
34
+ : patches.find((p) => p.patchSetBizId === f[key]);
35
+ if (!p ||
36
+ typeof p.patchSetBizId !== "string" ||
37
+ typeof p.commitId !== "string")
38
+ throw new CliError("PATCH_NOT_FOUND", `Invalid ${key} for this MR.`, 5);
39
+ return p;
40
+ }
41
+ return {
42
+ from: select("from-patch-set-id", "MERGE_TARGET"),
43
+ to: select("to-patch-set-id", "MERGE_SOURCE"),
44
+ };
45
+ }
46
+ export async function mrDiff(client, repo, id, f) {
47
+ const pair = await patchPair(client, repo, id, f);
48
+ const tree = record(await client.getChangeRequestTree(repo, id, {
49
+ fromPatchSetId: String(pair.from.patchSetBizId),
50
+ toPatchSetId: String(pair.to.patchSetBizId),
51
+ }));
52
+ const items = records(tree.changedTreeItems);
53
+ const comparison = record(await client.compare(repo, String(pair.from.commitId), String(pair.to.commitId), pair.from.relatedMergeItemType === "MERGE_SOURCE"));
54
+ const diffs = records(comparison.diffs);
55
+ const files = items.map((item) => {
56
+ const d = diffs.find((d) => d.newPath === item.newPath && d.oldPath === item.oldPath);
57
+ const text = typeof d?.diff === "string" ? d.diff : "";
58
+ let inHunk = false, added = 0, deleted = 0;
59
+ for (const line of text.split("\n")) {
60
+ if (line.startsWith("@@ ")) {
61
+ inHunk = true;
62
+ continue;
63
+ }
64
+ if (inHunk && line.startsWith("+"))
65
+ added++;
66
+ if (inHunk && line.startsWith("-"))
67
+ deleted++;
68
+ }
69
+ const binary = item.isBinary === true || d?.isBinary === true;
70
+ const matches = d &&
71
+ (!item.newObjectId || item.newObjectId === d.newId) &&
72
+ (!item.oldObjectId || item.oldObjectId === d.oldId);
73
+ const complete = !binary &&
74
+ !!matches &&
75
+ d?.truncated !== true &&
76
+ d?.tooLarge !== true &&
77
+ Number(item.addLines) === added &&
78
+ Number(item.delLines) === deleted;
79
+ return {
80
+ ...item,
81
+ diff: text,
82
+ complete,
83
+ reason: binary
84
+ ? "binary"
85
+ : complete
86
+ ? undefined
87
+ : "missing, truncated, or inconsistent comparison",
88
+ };
89
+ });
90
+ if (f.file &&
91
+ !files.some((x) => x.newPath === f.file || x.oldPath === f.file))
92
+ throw new CliError("FILE_NOT_FOUND", "File is not in this MR diff.", 4);
93
+ const selected = f.file
94
+ ? files.filter((x) => x.newPath === f.file || x.oldPath === f.file)
95
+ : files;
96
+ return {
97
+ fromPatchSetId: pair.from.patchSetBizId,
98
+ toPatchSetId: pair.to.patchSetBizId,
99
+ fromCommit: pair.from.commitId,
100
+ toCommit: pair.to.commitId,
101
+ complete: Number(tree.count) === items.length && selected.every((f) => f.complete),
102
+ files: selected,
103
+ };
104
+ }
105
+ export async function runExtendedMr(command, p, f, c) {
106
+ const repo = p[2];
107
+ const id = Number(p[3]);
108
+ if (command === "create") {
109
+ confirmed(f);
110
+ const source = required(f, "source"), target = required(f, "target"), title = required(f, "title");
111
+ const targetRepo = record(await c.getRepository(repo));
112
+ const sourceRepo = f["source-project-id"]
113
+ ? record(await c.getRepository(String(f["source-project-id"])))
114
+ : targetRepo;
115
+ if (targetRepo.id === undefined || sourceRepo.id === undefined)
116
+ throw new CliError("INVALID_RESPONSE", "Repository ID missing.", 1);
117
+ return c.createChangeRequest(repo, {
118
+ sourceBranch: source,
119
+ targetBranch: target,
120
+ title,
121
+ description: f.description,
122
+ sourceProjectId: String(sourceRepo.id),
123
+ targetProjectId: String(targetRepo.id),
124
+ reviewerUserIds: f.reviewers
125
+ ? String(f.reviewers).split(",").filter(Boolean)
126
+ : undefined,
127
+ });
128
+ }
129
+ if (!Number.isSafeInteger(id) || id < 1)
130
+ throw new CliError("INVALID_ARGUMENT", "MR ID must be a positive integer.");
131
+ if (command === "get")
132
+ return c.getChangeRequest(repo, id);
133
+ if (command === "diff")
134
+ return mrDiff(c, repo, id, f);
135
+ if (command === "comments")
136
+ return c.listChangeRequestComments(repo, id, {
137
+ file_path: f.file,
138
+ comment_type: f["comment-type"],
139
+ resolved: f.resolved,
140
+ patchset_biz_id_list: f["patch-set-ids"]
141
+ ? String(f["patch-set-ids"]).split(",")
142
+ : undefined,
143
+ });
144
+ if (command === "update") {
145
+ confirmed(f);
146
+ const body = { title: f.title, description: f.description };
147
+ if (body.title === undefined && body.description === undefined)
148
+ throw new CliError("INVALID_ARGUMENT", "Provide --title or --description.");
149
+ return c.updateChangeRequest(repo, id, body);
150
+ }
151
+ if (command === "close") {
152
+ confirmed(f);
153
+ return c.closeChangeRequest(repo, id);
154
+ }
155
+ if (command === "comment") {
156
+ confirmed(f);
157
+ const content = required(f, "content");
158
+ if (content.length > 65535)
159
+ throw new CliError("INVALID_ARGUMENT", "Comment exceeds 65535 characters.");
160
+ const inline = f.file !== undefined;
161
+ if (!inline &&
162
+ ["line", "side", "from-patch-set-id"].some((k) => f[k] !== undefined))
163
+ throw new CliError("INVALID_ARGUMENT", "Line comment options require --file.");
164
+ const body = {
165
+ comment_type: inline ? "INLINE_COMMENT" : "GLOBAL_COMMENT",
166
+ content,
167
+ draft: false,
168
+ resolved: false,
169
+ parent_comment_biz_id: f["parent-id"],
170
+ };
171
+ if (inline) {
172
+ required(f, "line");
173
+ required(f, "side");
174
+ required(f, "to-patch-set-id");
175
+ const pair = await patchPair(c, repo, id, f);
176
+ const tree = record(await c.getChangeRequestTree(repo, id, {
177
+ fromPatchSetId: String(pair.from.patchSetBizId),
178
+ toPatchSetId: String(pair.to.patchSetBizId),
179
+ }));
180
+ if (!records(tree.changedTreeItems).some((item) => item[f.side === "old" ? "oldPath" : "newPath"] === f.file))
181
+ throw new CliError("FILE_NOT_FOUND", "File is not present on the selected side of the MR diff.", 4);
182
+ Object.assign(body, {
183
+ file_path: f.file,
184
+ line_number: Number(f.line),
185
+ from_patchset_biz_id: pair.from.patchSetBizId,
186
+ to_patchset_biz_id: pair.to.patchSetBizId,
187
+ patchset_biz_id: f.side === "old" ? pair.from.patchSetBizId : pair.to.patchSetBizId,
188
+ });
189
+ }
190
+ else {
191
+ const patches = records(await c.listChangeRequestPatchSets(repo, id));
192
+ const patch = f["to-patch-set-id"]
193
+ ? patches.find((p) => p.patchSetBizId === f["to-patch-set-id"])
194
+ : latestPatch(patches, "MERGE_SOURCE");
195
+ if (!patch?.patchSetBizId)
196
+ throw new CliError("PATCH_NOT_FOUND", "Comment patch not found.", 5);
197
+ body.patchset_biz_id = patch.patchSetBizId;
198
+ }
199
+ return c.createChangeRequestComment(repo, id, body);
200
+ }
201
+ throw new CliError("INVALID_ARGUMENT", `Unknown MR command: ${command}`);
202
+ }
@@ -0,0 +1,10 @@
1
+ export declare const pageMetadata: WeakMap<object, {
2
+ next?: number | null;
3
+ perPage?: number;
4
+ }>;
5
+ export declare function paginate(fetchPage: (page: number, perPage: number) => Promise<unknown>, options?: {
6
+ page?: number;
7
+ perPage?: number;
8
+ allPages?: boolean;
9
+ warn?: (s: string) => void;
10
+ }): Promise<unknown[]>;
@@ -0,0 +1,35 @@
1
+ import { CliError } from "./errors.js";
2
+ export const pageMetadata = new WeakMap();
3
+ export async function paginate(fetchPage, options = {}) {
4
+ const result = [];
5
+ const seen = new Set();
6
+ const perPage = options.perPage ?? 20;
7
+ let page = options.page ?? 1;
8
+ for (let count = 0; count < 10000; count++) {
9
+ const response = await fetchPage(page, perPage);
10
+ if (!Array.isArray(response))
11
+ throw new CliError("INVALID_RESPONSE", "Expected a paginated array response.", 1);
12
+ const fingerprint = JSON.stringify(response);
13
+ if (response.length && seen.has(fingerprint))
14
+ throw new CliError("REPEATED_PAGE", `Server repeated page ${page}; full results unavailable.`, 6);
15
+ seen.add(fingerprint);
16
+ result.push(...response);
17
+ const meta = pageMetadata.get(response);
18
+ const next = meta?.next !== undefined
19
+ ? meta.next
20
+ : response.length >= (meta?.perPage ?? perPage)
21
+ ? page + 1
22
+ : null;
23
+ if (!options.allPages) {
24
+ if (next)
25
+ options.warn?.("More results may exist; use --all-pages for complete results.\n");
26
+ return result;
27
+ }
28
+ if (!next)
29
+ return result;
30
+ if (next <= page)
31
+ throw new CliError("INVALID_PAGE", "Server returned a non-advancing page.", 6);
32
+ page = next;
33
+ }
34
+ throw new CliError("PAGE_LIMIT", "Pagination exceeded 10000 pages; results are incomplete.", 6);
35
+ }
@@ -0,0 +1,3 @@
1
+ import type { YunxiaoClient } from "./client.js";
2
+ import type { Flags } from "./arguments.js";
3
+ export declare function runExtendedPipeline(command: string, p: string[], f: Flags, c: YunxiaoClient): Promise<unknown>;
@@ -0,0 +1,50 @@
1
+ import { confirmed, record, records } from "./mr.js";
2
+ import { CliError } from "./errors.js";
3
+ export async function runExtendedPipeline(command, p, f, c) {
4
+ const [, , pipelineId, runId, jobId] = p;
5
+ if (command === "logs")
6
+ return c.getPipelineJobLog(pipelineId, runId, jobId);
7
+ if (command === "jobs") {
8
+ const run = record(await c.getPipelineRun(pipelineId, runId));
9
+ return records(run.stages ?? []).flatMap((stage) => {
10
+ const info = record(stage.stageInfo ?? stage);
11
+ return records(info.jobs ?? []).map((job) => ({
12
+ ...job,
13
+ stageId: stage.id,
14
+ stageName: stage.name ?? info.name,
15
+ }));
16
+ });
17
+ }
18
+ if (command === "start") {
19
+ confirmed(f);
20
+ let params;
21
+ if (f.params !== undefined) {
22
+ try {
23
+ params = record(JSON.parse(String(f.params)));
24
+ }
25
+ catch {
26
+ throw new CliError("INVALID_ARGUMENT", "--params must be a JSON object.");
27
+ }
28
+ }
29
+ return {
30
+ pipelineId,
31
+ pipelineRunId: await c.startPipeline(pipelineId, params),
32
+ };
33
+ }
34
+ if (command === "cancel") {
35
+ confirmed(f);
36
+ const accepted = await c.cancelPipeline(pipelineId, runId);
37
+ try {
38
+ return {
39
+ pipelineId,
40
+ pipelineRunId: runId,
41
+ accepted,
42
+ run: await c.getPipelineRun(pipelineId, runId),
43
+ };
44
+ }
45
+ catch {
46
+ throw new CliError("PARTIAL_OPERATION", "Cancel accepted, but reading the final state failed.", 7, { pipelineId, pipelineRunId: runId, accepted });
47
+ }
48
+ }
49
+ throw new CliError("INVALID_ARGUMENT", `Unknown pipeline command: ${command}`);
50
+ }
@@ -0,0 +1,2 @@
1
+ import type { YunxiaoClient, ReleaseByRepositoryNameOptions, ReleaseResult } from "./client.js";
2
+ export declare function release(c: YunxiaoClient, o: ReleaseByRepositoryNameOptions): Promise<ReleaseResult>;
@@ -0,0 +1,203 @@
1
+ import { YunxiaoApiError } from "./transport.js";
2
+ import { CliError, errorInfo } from "./errors.js";
3
+ import { paginate } from "./pagination.js";
4
+ import { record, records, latestPatch } from "./mr.js";
5
+ export async function release(c, o) {
6
+ const repositories = records(await paginate((page, perPage) => c.listRepositories({
7
+ page,
8
+ perPage,
9
+ search: o.repositoryName,
10
+ archived: false,
11
+ }), { allPages: true }));
12
+ const normalize = (s) => String(s ?? "")
13
+ .replace(/\s*\/\s*/g, "/")
14
+ .toLowerCase();
15
+ const matches = repositories.filter((r) => [r.name, r.path, r.nameWithNamespace, r.pathWithNamespace].some((v) => v && normalize(v) === normalize(o.repositoryName)));
16
+ if (matches.length !== 1)
17
+ throw new CliError(matches.length ? "AMBIGUOUS_REPOSITORY" : "NOT_FOUND", matches.length
18
+ ? "Multiple repositories match; use the full namespace path."
19
+ : `Repository not found by name: ${o.repositoryName}`, matches.length ? 5 : 4);
20
+ const repository = matches[0];
21
+ const repo = String(repository.id);
22
+ const matching = async () => records(await paginate((page, perPage) => c.listChangeRequests({
23
+ page,
24
+ perPage,
25
+ projectIds: repo,
26
+ state: "opened",
27
+ }), { allPages: true })).filter((m) => m.sourceBranch === o.sourceBranch &&
28
+ m.targetBranch === o.targetBranch &&
29
+ String(m.sourceProjectId) === repo &&
30
+ String(m.targetProjectId ?? m.projectId) === repo);
31
+ let mr;
32
+ let review = null;
33
+ let merge = null;
34
+ let stage = "lookup";
35
+ const completed = [];
36
+ const recovery = () => ({
37
+ repository,
38
+ changeRequest: mr,
39
+ completed,
40
+ failedStage: stage,
41
+ localId: mr?.localId,
42
+ url: mr?.detailUrl ?? mr?.webUrl,
43
+ resumeArgs: mr && Number.isSafeInteger(Number(mr.localId)) && Number(mr.localId) > 0
44
+ ? [
45
+ "mr",
46
+ "release",
47
+ o.repositoryName,
48
+ o.sourceBranch,
49
+ o.targetBranch,
50
+ "--resume",
51
+ String(mr.localId),
52
+ "--yes",
53
+ ...(o.createOnly ? ["--create-only"] : []),
54
+ ...(o.mergeType ? ["--merge-type", o.mergeType] : []),
55
+ ]
56
+ : undefined,
57
+ });
58
+ try {
59
+ if (o.resume) {
60
+ mr = record(await c.getChangeRequest(repo, o.resume));
61
+ if (mr.sourceBranch !== o.sourceBranch ||
62
+ mr.targetBranch !== o.targetBranch ||
63
+ String(mr.sourceProjectId) !== repo ||
64
+ String(mr.targetProjectId) !== repo)
65
+ throw new CliError("RESUME_MISMATCH", "MR does not match the requested repositories and branches.", 5);
66
+ if (String(mr.status ?? mr.state).toUpperCase() === "MERGED")
67
+ return {
68
+ repository,
69
+ changeRequest: mr,
70
+ review,
71
+ merge,
72
+ status: "already-merged",
73
+ };
74
+ if (String(mr.status ?? mr.state).toUpperCase() === "CLOSED")
75
+ throw new CliError("MR_CLOSED", "Cannot resume a closed MR.", 5);
76
+ }
77
+ else {
78
+ const existing = await matching();
79
+ if (existing.length) {
80
+ mr = existing.length === 1 ? existing[0] : undefined;
81
+ throw new CliError("EXISTING_MR", "Matching open MR exists; explicitly select --resume <mrId>.", 5, { ...recovery(), matches: existing });
82
+ }
83
+ stage = "create";
84
+ const user = await c.getCurrentUser();
85
+ try {
86
+ const created = await c.createChangeRequest(repo, {
87
+ sourceBranch: o.sourceBranch,
88
+ targetBranch: o.targetBranch,
89
+ sourceProjectId: repo,
90
+ targetProjectId: repo,
91
+ title: o.title ?? `Release ${o.sourceBranch} into ${o.targetBranch}`,
92
+ description: o.description,
93
+ reviewerUserIds: [user.id],
94
+ });
95
+ if (!created ||
96
+ typeof created !== "object" ||
97
+ Array.isArray(created) ||
98
+ !Number.isSafeInteger(Number(created.localId)) ||
99
+ Number(created.localId) < 1) {
100
+ throw new CliError("WRITE_RESULT_UNKNOWN", "Create response did not identify the MR; verify server state before retrying.", 7);
101
+ }
102
+ mr = created;
103
+ }
104
+ catch (e) {
105
+ // Validation errors other than this exact no-change condition must remain failures.
106
+ if (e instanceof YunxiaoApiError &&
107
+ /源分支相对目标分支没有改动,不能新建代码评审/.test(e.responseBody))
108
+ return {
109
+ repository,
110
+ changeRequest: null,
111
+ review,
112
+ merge,
113
+ status: "skipped",
114
+ };
115
+ if (e instanceof CliError && e.exitCode === 7) {
116
+ try {
117
+ const found = await matching();
118
+ if (found.length === 1)
119
+ mr = found[0];
120
+ }
121
+ catch { }
122
+ throw new CliError("WRITE_RESULT_UNKNOWN", "Create result was uncertain; inspect the matching MR before resuming.", 7, recovery());
123
+ }
124
+ throw e;
125
+ }
126
+ completed.push("create");
127
+ }
128
+ if (!Number.isSafeInteger(Number(mr.localId)) || Number(mr.localId) < 1)
129
+ throw new CliError("INVALID_RESPONSE", "Created MR response has no valid localId.", 7);
130
+ const id = Number(mr.localId);
131
+ if (o.createOnly)
132
+ return {
133
+ repository,
134
+ changeRequest: mr,
135
+ review,
136
+ merge,
137
+ status: "created",
138
+ };
139
+ stage = "review";
140
+ const before = latestPatch(records(await c.listChangeRequestPatchSets(repo, id)), "MERGE_SOURCE");
141
+ let alreadyReviewed = false;
142
+ if (o.resume) {
143
+ const user = await c.getCurrentUser();
144
+ const current = record(await c.getChangeRequest(repo, id));
145
+ const created = Date.parse(String(before.createTime));
146
+ alreadyReviewed =
147
+ Number.isFinite(created) &&
148
+ records(current.reviewers ?? []).some((r) => r.userId === user.id &&
149
+ r.reviewOpinionStatus === "PASS" &&
150
+ Date.parse(String(r.reviewTime)) >= created);
151
+ }
152
+ if (!alreadyReviewed)
153
+ review = await c.reviewChangeRequest(repo, id, {
154
+ reviewComment: "Approved by yunxiao-cli release workflow",
155
+ });
156
+ completed.push("review");
157
+ const after = latestPatch(records(await c.listChangeRequestPatchSets(repo, id)), "MERGE_SOURCE");
158
+ if (!before.patchSetBizId ||
159
+ !before.commitId ||
160
+ before.patchSetBizId !== after.patchSetBizId ||
161
+ before.commitId !== after.commitId)
162
+ throw new CliError("VERSION_CHANGED", "Source version changed during review; inspect and resume.", 5);
163
+ stage = "merge";
164
+ try {
165
+ merge = await c.mergeChangeRequest(repo, id, {
166
+ mergeType: o.mergeType,
167
+ removeSourceBranch: false,
168
+ });
169
+ }
170
+ catch (e) {
171
+ if (e instanceof CliError && e.exitCode === 7) {
172
+ try {
173
+ const current = record(await c.getChangeRequest(repo, id));
174
+ if (String(current.status ?? current.state).toUpperCase() === "MERGED")
175
+ return {
176
+ repository,
177
+ changeRequest: current,
178
+ review,
179
+ merge: null,
180
+ status: "merged",
181
+ };
182
+ }
183
+ catch { }
184
+ }
185
+ throw e;
186
+ }
187
+ completed.push("merge");
188
+ return { repository, changeRequest: mr, review, merge, status: "merged" };
189
+ }
190
+ catch (e) {
191
+ if (e instanceof CliError &&
192
+ ["EXISTING_MR", "RESUME_MISMATCH", "MR_CLOSED"].includes(e.code))
193
+ throw e;
194
+ if (e instanceof CliError && e.code === "WRITE_RESULT_UNKNOWN")
195
+ throw new CliError(e.code, e.message, 7, {
196
+ ...recovery(),
197
+ cause: e.details,
198
+ });
199
+ if (mr || completed.length)
200
+ throw new CliError("PARTIAL_OPERATION", errorInfo(e).message, 7, recovery());
201
+ throw e;
202
+ }
203
+ }
@@ -0,0 +1,16 @@
1
+ import type { Fetcher } from "./client.js";
2
+ export declare class YunxiaoApiError extends Error {
3
+ readonly status: number;
4
+ readonly responseBody: string;
5
+ readonly method: string;
6
+ readonly url: string;
7
+ readonly requestId?: string | undefined;
8
+ readonly apiCode?: string;
9
+ constructor(message: string, status: number, responseBody: string, method: string, url: string, requestId?: string | undefined);
10
+ }
11
+ export declare function requestJson(fetcher: Fetcher, url: URL, init: RequestInit, options: {
12
+ timeoutMs: number;
13
+ retries: number;
14
+ token: string;
15
+ readOnly: boolean;
16
+ }): Promise<unknown>;
@@ -0,0 +1,119 @@
1
+ import { CliError, redact } from "./errors.js";
2
+ import { pageMetadata } from "./pagination.js";
3
+ export class YunxiaoApiError extends Error {
4
+ status;
5
+ responseBody;
6
+ method;
7
+ url;
8
+ requestId;
9
+ apiCode;
10
+ constructor(message, status, responseBody, method, url, requestId) {
11
+ super(message);
12
+ this.status = status;
13
+ this.responseBody = responseBody;
14
+ this.method = method;
15
+ this.url = url;
16
+ this.requestId = requestId;
17
+ this.name = "YunxiaoApiError";
18
+ try {
19
+ const body = JSON.parse(responseBody);
20
+ this.apiCode = body.code ?? body.errorCode;
21
+ }
22
+ catch { }
23
+ }
24
+ }
25
+ export async function requestJson(fetcher, url, init, options) {
26
+ const method = init.method ?? "GET";
27
+ for (let attempt = 0;; attempt++) {
28
+ const controller = new AbortController();
29
+ let timer;
30
+ let retryAfter = 0;
31
+ try {
32
+ const operation = (async () => {
33
+ const response = await fetcher(url.toString(), {
34
+ ...init,
35
+ signal: controller.signal,
36
+ redirect: "error",
37
+ });
38
+ const body = await response.text();
39
+ const raw = response.headers.get("retry-after");
40
+ if (raw)
41
+ retryAfter = /^\d+(\.\d+)?$/.test(raw)
42
+ ? Number(raw) * 1000
43
+ : Math.max(0, Date.parse(raw) - Date.now());
44
+ if (!Number.isFinite(retryAfter))
45
+ retryAfter = 0;
46
+ if (!response.ok) {
47
+ const safe = redact(body, [options.token]);
48
+ throw new YunxiaoApiError(`Yunxiao API request failed with HTTP ${response.status}: ${method} ${url.origin}${url.pathname}${safe ? "\n" + safe : ""}`, response.status, safe, method, url.origin + url.pathname, response.headers.get("x-request-id") ?? undefined);
49
+ }
50
+ if (!body)
51
+ return null;
52
+ let value;
53
+ try {
54
+ value = JSON.parse(body);
55
+ }
56
+ catch {
57
+ throw new CliError("INVALID_RESPONSE", "API returned invalid JSON.", options.readOnly ? 1 : 7);
58
+ }
59
+ if (value === false ||
60
+ (value &&
61
+ typeof value === "object" &&
62
+ !Array.isArray(value) &&
63
+ (value.success === false ||
64
+ value.result === false)))
65
+ throw new CliError("API_REJECTED", "API reported an unsuccessful operation.", 5);
66
+ if (Array.isArray(value)) {
67
+ const next = response.headers.get("x-next-page");
68
+ const per = response.headers.get("x-per-page");
69
+ if (next !== null &&
70
+ next !== "" &&
71
+ (!/^\d+$/.test(next) || Number(next) < 1))
72
+ throw new CliError("INVALID_RESPONSE", "Invalid pagination response header.", 1);
73
+ pageMetadata.set(value, {
74
+ next: next === null ? undefined : next === "" ? null : Number(next),
75
+ perPage: per && Number(per) > 0 ? Number(per) : undefined,
76
+ });
77
+ }
78
+ return value;
79
+ })();
80
+ return await Promise.race([
81
+ operation,
82
+ new Promise((_, reject) => {
83
+ timer = setTimeout(() => {
84
+ controller.abort();
85
+ reject(new CliError(options.readOnly ? "TIMEOUT" : "WRITE_RESULT_UNKNOWN", "Request timed out; " +
86
+ (options.readOnly
87
+ ? "read can be retried."
88
+ : "verify server state before retrying."), options.readOnly ? 6 : 7));
89
+ }, options.timeoutMs);
90
+ }),
91
+ ]);
92
+ }
93
+ catch (error) {
94
+ const transient = error instanceof YunxiaoApiError
95
+ ? [429, 502, 503, 504].includes(error.status)
96
+ : !(error instanceof CliError) || error.code === "TIMEOUT";
97
+ if (!options.readOnly &&
98
+ error instanceof YunxiaoApiError &&
99
+ error.status >= 500)
100
+ throw new CliError("WRITE_RESULT_UNKNOWN", "Server error during write; verify server state before retrying.", 7, { requestId: error.requestId, status: error.status });
101
+ if (!options.readOnly &&
102
+ !(error instanceof YunxiaoApiError) &&
103
+ !(error instanceof CliError))
104
+ throw new CliError("WRITE_RESULT_UNKNOWN", "Network error; verify server state before retrying this write.", 7);
105
+ if (!options.readOnly || !transient || attempt >= options.retries) {
106
+ if (!(error instanceof YunxiaoApiError) && !(error instanceof CliError))
107
+ throw new CliError("NETWORK_ERROR", "Network request failed.", 6);
108
+ throw error;
109
+ }
110
+ if (retryAfter > 60000)
111
+ throw new CliError("RATE_LIMITED", "Server requests a retry delay longer than 60 seconds; retry later.", 6);
112
+ }
113
+ finally {
114
+ if (timer)
115
+ clearTimeout(timer);
116
+ }
117
+ await new Promise((resolve) => setTimeout(resolve, Math.max(retryAfter, Math.min(500 * 2 ** attempt, 5000) + Math.random() * 100)));
118
+ }
119
+ }
@@ -9,6 +9,7 @@ export type ContentOptions = {
9
9
  dir: string;
10
10
  downloadAttachments?: boolean;
11
11
  fetcher?: Fetcher;
12
+ timeoutMs?: number;
12
13
  };
13
14
  export declare function parseWorkitemUrl(link: string): {
14
15
  projectId: string;