@coderpp/yunxiao-cli 0.1.10 → 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.
@@ -0,0 +1,28 @@
1
+ import { YunxiaoClient, type Fetcher } from "./client.js";
2
+ type RecordData = Record<string, unknown>;
3
+ type Image = {
4
+ fileId: string;
5
+ path: string;
6
+ source: string;
7
+ };
8
+ export type ContentOptions = {
9
+ dir: string;
10
+ downloadAttachments?: boolean;
11
+ fetcher?: Fetcher;
12
+ timeoutMs?: number;
13
+ };
14
+ export declare function parseWorkitemUrl(link: string): {
15
+ projectId: string;
16
+ workitemId: string;
17
+ };
18
+ export declare function downloadWorkitemAttachments(client: YunxiaoClient, id: string, options: ContentOptions): Promise<RecordData[]>;
19
+ export declare function readWorkitemContent(client: YunxiaoClient, link: string, options: ContentOptions): Promise<{
20
+ url: string;
21
+ workitem: RecordData;
22
+ descriptionHtml: string;
23
+ comments: RecordData[];
24
+ images: Image[];
25
+ attachments: RecordData[];
26
+ }>;
27
+ export declare function downloadWorkitemImages(client: YunxiaoClient, id: string, options: ContentOptions): Promise<Image[]>;
28
+ export {};
@@ -0,0 +1,201 @@
1
+ import { mkdir, open, rm } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { parseFragment, serialize } from "parse5";
4
+ import MarkdownIt from "markdown-it";
5
+ export function parseWorkitemUrl(link) {
6
+ const u = new URL(link);
7
+ const parts = u.pathname.replace(/\/$/, "").split("/");
8
+ if (u.protocol !== "https:" ||
9
+ u.host !== "devops.aliyun.com" ||
10
+ u.username ||
11
+ u.password ||
12
+ parts.length !== 6 ||
13
+ parts[1] !== "projex" ||
14
+ parts[2] !== "project" ||
15
+ !parts[3] ||
16
+ !parts[4] ||
17
+ !parts[5]) {
18
+ throw new Error("Expected a Yunxiao workitem URL: https://devops.aliyun.com/projex/project/<projectId>/<type>/<workitemId>");
19
+ }
20
+ return { projectId: parts[3], workitemId: parts[5] };
21
+ }
22
+ function record(value) {
23
+ if (!value || typeof value !== "object" || Array.isArray(value))
24
+ throw new Error("Unexpected workitem API response.");
25
+ return value;
26
+ }
27
+ function records(value) {
28
+ if (!Array.isArray(value))
29
+ throw new Error("Expected an API array.");
30
+ return value.map(record);
31
+ }
32
+ function str(value) {
33
+ return typeof value === "string" ? value : "";
34
+ }
35
+ function markup(content, format) {
36
+ const text = str(content);
37
+ if (format === "MARKDOWN")
38
+ return new MarkdownIt({ html: true }).render(text);
39
+ if (text.trimStart().startsWith("{")) {
40
+ const rich = record(JSON.parse(text));
41
+ if (typeof rich.htmlValue !== "string")
42
+ throw new Error("Rich text is missing htmlValue.");
43
+ return rich.htmlValue;
44
+ }
45
+ return text;
46
+ }
47
+ function imageExtension(head) {
48
+ if (head.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])))
49
+ return ".png";
50
+ if (head[0] === 255 && head[1] === 216 && head[2] === 255)
51
+ return ".jpg";
52
+ if (/^GIF8[79]a/.test(head.toString("ascii", 0, 6)))
53
+ return ".gif";
54
+ if (head.toString("ascii", 0, 4) === "RIFF" &&
55
+ head.toString("ascii", 8, 12) === "WEBP")
56
+ return ".webp";
57
+ if (head.toString("ascii", 0, 2) === "BM")
58
+ return ".bmp";
59
+ if (head.subarray(0, 4).equals(Buffer.from([0, 0, 1, 0])))
60
+ return ".ico";
61
+ throw new Error("Download is not a supported image (PNG/JPEG/GIF/WebP/BMP/ICO).");
62
+ }
63
+ async function download(url, target, image, fetcher, timeoutMs = 30000) {
64
+ let response;
65
+ try {
66
+ response = await fetcher(url, { signal: AbortSignal.timeout(timeoutMs) });
67
+ }
68
+ catch {
69
+ throw new Error("File download failed (network error or timeout).");
70
+ }
71
+ if (!response.ok || !response.body) {
72
+ await response.body?.cancel();
73
+ throw new Error(`File download failed: HTTP ${response.status}.`);
74
+ }
75
+ const reader = response.body.getReader();
76
+ let file;
77
+ let destination = target;
78
+ let complete = false;
79
+ try {
80
+ const chunks = [];
81
+ let size = 0;
82
+ while (size < 16) {
83
+ const chunk = await reader.read();
84
+ if (chunk.done)
85
+ break;
86
+ chunks.push(Buffer.from(chunk.value));
87
+ size += chunk.value.length;
88
+ }
89
+ const head = Buffer.concat(chunks);
90
+ if (image)
91
+ destination += imageExtension(head);
92
+ await mkdir(path.dirname(destination), { recursive: true, mode: 0o700 });
93
+ file = await open(destination, "wx", 0o600);
94
+ await file.writeFile(head);
95
+ for (;;) {
96
+ const chunk = await reader.read();
97
+ if (chunk.done)
98
+ break;
99
+ await file.writeFile(chunk.value);
100
+ }
101
+ await file.close();
102
+ complete = true;
103
+ return destination;
104
+ }
105
+ catch (error) {
106
+ if (error.code === "EEXIST")
107
+ throw new Error(`File already exists; choose a new directory: ${destination}`);
108
+ // Do not leak signed storage URLs from fetch errors.
109
+ throw new Error(`Unable to save file ${destination}: download or filesystem error.`);
110
+ }
111
+ finally {
112
+ await reader.cancel().catch(() => undefined);
113
+ if (file && !complete) {
114
+ await file.close();
115
+ await rm(destination, { force: true });
116
+ }
117
+ }
118
+ }
119
+ async function localize(client, id, content, format, source, dir, cache, images, fetcher, timeoutMs) {
120
+ const tree = parseFragment(markup(content, format));
121
+ async function visit(node) {
122
+ if ("tagName" in node && node.tagName === "img") {
123
+ const src = node.attrs.find((a) => a.name === "src");
124
+ const fileId = src &&
125
+ new URL(src.value, "https://devops.aliyun.com").searchParams.get("fileIdentifier");
126
+ if (!src || !fileId)
127
+ throw new Error(`${source}: only Yunxiao images with fileIdentifier are supported.`);
128
+ let local = cache.get(fileId);
129
+ if (!local) {
130
+ const file = record(await client.getWorkitemFile(id, fileId));
131
+ if (!str(file.url))
132
+ throw new Error(`Missing download URL for image ${fileId}.`);
133
+ local = await download(str(file.url), path.resolve(dir, String(cache.size + 1).padStart(2, "0")), true, fetcher, timeoutMs);
134
+ cache.set(fileId, local);
135
+ }
136
+ src.value = local;
137
+ if (!images.some((i) => i.fileId === fileId && i.source === source))
138
+ images.push({ fileId, path: local, source });
139
+ }
140
+ if ("childNodes" in node)
141
+ for (const child of node.childNodes)
142
+ await visit(child);
143
+ }
144
+ await visit(tree);
145
+ return serialize(tree);
146
+ }
147
+ async function attachments(client, id, options) {
148
+ const files = records(await client.listWorkitemAttachments(id));
149
+ for (const [index, file] of files.entries()) {
150
+ const url = str(file.url);
151
+ delete file.url;
152
+ if (!options.downloadAttachments)
153
+ continue;
154
+ if (!url)
155
+ throw new Error(`Missing download URL for attachment ${index + 1}.`);
156
+ let name = path.posix.basename(str(file.fileName).replaceAll("\\", "/"));
157
+ if (!name || name === "." || name === "..")
158
+ name = "attachment";
159
+ file.path = await download(url, path.resolve(options.dir, `${String(index + 1).padStart(2, "0")}-${name}`), false, options.fetcher ?? fetch, options.timeoutMs);
160
+ }
161
+ return files;
162
+ }
163
+ export async function downloadWorkitemAttachments(client, id, options) {
164
+ return attachments(client, id, { ...options, downloadAttachments: true });
165
+ }
166
+ export async function readWorkitemContent(client, link, options) {
167
+ const { projectId, workitemId: id } = parseWorkitemUrl(link);
168
+ const workitem = record(await client.getWorkitem(id));
169
+ if (workitem.id !== id || record(workitem.space).id !== projectId)
170
+ throw new Error("Workitem/project does not match the link.");
171
+ const comments = records(await client.listWorkitemComments(id));
172
+ const images = [];
173
+ const cache = new Map();
174
+ const dir = path.join(options.dir, "images");
175
+ const fetcher = options.fetcher ?? fetch;
176
+ const descriptionHtml = await localize(client, id, workitem.description, workitem.formatType, "description", dir, cache, images, fetcher, options.timeoutMs);
177
+ for (const c of comments)
178
+ c.contentHtml = await localize(client, id, c.content, c.contentFormat, `comment:${c.id}`, dir, cache, images, fetcher, options.timeoutMs);
179
+ return {
180
+ url: link,
181
+ workitem,
182
+ descriptionHtml,
183
+ comments,
184
+ images,
185
+ attachments: await attachments(client, id, {
186
+ ...options,
187
+ dir: path.join(options.dir, "attachments"),
188
+ }),
189
+ };
190
+ }
191
+ export async function downloadWorkitemImages(client, id, options) {
192
+ const workitem = record(await client.getWorkitem(id));
193
+ const comments = records(await client.listWorkitemComments(id));
194
+ const images = [];
195
+ const cache = new Map();
196
+ const fetcher = options.fetcher ?? fetch;
197
+ await localize(client, id, workitem.description, workitem.formatType, "description", options.dir, cache, images, fetcher, options.timeoutMs);
198
+ for (const c of comments)
199
+ await localize(client, id, c.content, c.contentFormat, `comment:${c.id}`, options.dir, cache, images, fetcher, options.timeoutMs);
200
+ return images;
201
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@coderpp/yunxiao-cli",
3
- "version": "0.1.10",
4
- "description": "A CLI for Yunxiao merge request, pipeline retry, project member, and workitem workflows.",
3
+ "version": "0.2.0",
4
+ "description": "A CLI for Yunxiao merge requests, reviews, releases, pipelines, project members, and workitems.",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
@@ -29,6 +29,7 @@
29
29
  "access": "public"
30
30
  },
31
31
  "devDependencies": {
32
+ "@types/markdown-it": "^14.2.0",
32
33
  "@types/node": "^20.12.12",
33
34
  "typescript": "^5.4.5"
34
35
  },
@@ -38,5 +39,9 @@
38
39
  "files": [
39
40
  "dist/src",
40
41
  "README.md"
41
- ]
42
+ ],
43
+ "dependencies": {
44
+ "markdown-it": "^14.3.3",
45
+ "parse5": "^7.3.0"
46
+ }
42
47
  }