@levr-one/cli 0.10.19 → 0.10.21
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/{addHandler-r0Yw1vu-.js → addHandler-DUulKXOX.js} +68 -68
- package/dist/appendHandler-DkbMvFOn.js +80 -0
- package/dist/artifact-upload-BnqeVY_b.js +296 -0
- package/dist/attachHandler-BOxIqxAw.js +151 -0
- package/dist/cli.js +237 -17
- package/dist/connect-CPybjrXT.js +36 -0
- package/dist/{currentHandler-BBHqC26D.js → currentHandler-B_sWXNeW.js} +1 -1
- package/dist/{importHandler-DptPgXYU.js → importHandler-oENkrali.js} +8 -8
- package/dist/{listHandler-Ct_-wWi2.js → listHandler-E666F9uV.js} +5 -5
- package/dist/{loginHandler-CVxKJV6A.js → loginHandler-DS29PzLz.js} +5 -10
- package/dist/{logoutHandler-BFutKash.js → logoutHandler-TSQlZGpU.js} +2 -2
- package/dist/{pushHandler-MQdf0TVK.js → pushHandler-CR5vu3ci.js} +142 -9
- package/dist/{resolve-token-DbQsmn03.js → resolve-token-7xb7kG7h.js} +2 -2
- package/dist/{resolve-workspace-BrmiCTJO.js → resolve-workspace-Dsm8Xzut.js} +2 -2
- package/dist/{runHandler-BTCT0Ugi.js → runHandler-B2DlFleh.js} +8 -8
- package/dist/{sdk-client-SXw7oTbQ.js → sdk-client-B_urshDp.js} +334 -73
- package/dist/{selectHandler-Co3MDgXz.js → selectHandler-Cv5fJl-p.js} +5 -5
- package/dist/sleep-BpJ39Ypm.js +7 -0
- package/dist/{statusHandler-Cu4DULGh.js → statusHandler-CwO5kl3m.js} +3 -3
- package/dist/{token-refresh-Cu5RpkLJ.js → token-refresh-DeusLK8H.js} +1 -1
- package/package.json +1 -1
- /package/dist/{env-CHeKHu5S.js → env-CdwyPHGV.js} +0 -0
- /package/dist/{workspace-store-DDOxnut1.js → workspace-store-CnrxyYRB.js} +0 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { uploadAttachment } from "./sdk-client-B_urshDp.js";
|
|
2
|
+
import { sleep } from "./sleep-BpJ39Ypm.js";
|
|
3
|
+
import { constants, promises, readlinkSync, realpathSync, statSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
|
|
7
|
+
//#region src/utils/artifact-upload.ts
|
|
8
|
+
/** The per-file ceiling the server enforces (DD5): bigger files are skipped. */
|
|
9
|
+
const MAX_ARTIFACT_BYTES = 50 * 1024 * 1024;
|
|
10
|
+
/** Retries after the first attempt, for failures that are safe to repeat. */
|
|
11
|
+
const MAX_UPLOAD_RETRIES = 2;
|
|
12
|
+
/** Related types with an attachment table (the upload route accepts these). */
|
|
13
|
+
const TYPED_TARGET_TYPES = [
|
|
14
|
+
"issue",
|
|
15
|
+
"test",
|
|
16
|
+
"run",
|
|
17
|
+
"run_result",
|
|
18
|
+
"run_result_variant",
|
|
19
|
+
"import_job",
|
|
20
|
+
"project",
|
|
21
|
+
"project_update",
|
|
22
|
+
"automation_run_result"
|
|
23
|
+
];
|
|
24
|
+
/** A target string the CLI cannot interpret; a usage error, exit 1. */
|
|
25
|
+
var TargetParseError = class extends Error {
|
|
26
|
+
constructor(input) {
|
|
27
|
+
super(`Invalid target "${input}". Use an identifier (ENG-42, TC-5, TR-3) or <related_type>:<uuid|identifier> with related_type one of ${TYPED_TARGET_TYPES.join(", ")}.`);
|
|
28
|
+
this.name = "TargetParseError";
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
32
|
+
const IDENTIFIER_RE = /^([A-Z][A-Z0-9]*)(?:-[A-Z0-9]{2,10})?-(\d+)$/;
|
|
33
|
+
/**
|
|
34
|
+
* `ENG-42` → issue, `TC-5` → test, `TR-3` → run (the server resolves the
|
|
35
|
+
* identifier); `<related_type>:<uuid>` for any typed target.
|
|
36
|
+
*/
|
|
37
|
+
function parseTarget(input) {
|
|
38
|
+
const trimmed = input.trim();
|
|
39
|
+
const ident = IDENTIFIER_RE.exec(trimmed);
|
|
40
|
+
if (ident) {
|
|
41
|
+
const prefix = ident[1];
|
|
42
|
+
return {
|
|
43
|
+
relatedType: prefix === "TC" ? "test" : prefix === "TR" ? "run" : "issue",
|
|
44
|
+
relatedId: trimmed
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const sep = trimmed.indexOf(":");
|
|
48
|
+
if (sep > 0) {
|
|
49
|
+
const type = trimmed.slice(0, sep);
|
|
50
|
+
const id = trimmed.slice(sep + 1);
|
|
51
|
+
if (TYPED_TARGET_TYPES.includes(type) && (UUID_RE.test(id) || IDENTIFIER_RE.test(id))) return {
|
|
52
|
+
relatedType: type,
|
|
53
|
+
relatedId: id
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
throw new TargetParseError(input);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Resolve a path a REPORT supplied (untrusted: a fork PR controls its own
|
|
60
|
+
* JUnit) against `baseDir`, confined to `root` (audit P1). An absolute path
|
|
61
|
+
* is taken as-is and accepted when it lies inside `root` (R1-23).
|
|
62
|
+
*
|
|
63
|
+
* fail_closed: returns null — never read, counted as rejected — when the
|
|
64
|
+
* real path (symlinks followed) is outside `root`. A path that does not
|
|
65
|
+
* exist inside `root` is `{ status: 'missing' }`, never a throw.
|
|
66
|
+
*/
|
|
67
|
+
function resolveArtifactPath(ref, baseDir, root = process.cwd()) {
|
|
68
|
+
const realRoot = safeRealpath(root) ?? path.resolve(root);
|
|
69
|
+
const candidate = path.isAbsolute(ref) ? ref : path.resolve(baseDir, ref);
|
|
70
|
+
const real = safeRealpath(candidate);
|
|
71
|
+
if (real === void 0) return isInside(realRoot, realpathOfNearest(path.resolve(candidate))) ? { status: "missing" } : null;
|
|
72
|
+
if (!isInside(realRoot, real)) return null;
|
|
73
|
+
let st;
|
|
74
|
+
try {
|
|
75
|
+
st = statSync(real);
|
|
76
|
+
} catch {
|
|
77
|
+
return { status: "missing" };
|
|
78
|
+
}
|
|
79
|
+
if (!st.isFile()) return { status: "missing" };
|
|
80
|
+
return {
|
|
81
|
+
status: "ok",
|
|
82
|
+
path: real,
|
|
83
|
+
dev: st.dev,
|
|
84
|
+
ino: st.ino,
|
|
85
|
+
root: realRoot
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function safeRealpath(p) {
|
|
89
|
+
try {
|
|
90
|
+
return realpathSync(p);
|
|
91
|
+
} catch {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** `p` with its longest existing prefix replaced by that prefix's real path. */
|
|
96
|
+
function realpathOfNearest(p) {
|
|
97
|
+
const missing = [];
|
|
98
|
+
let current = p;
|
|
99
|
+
for (;;) {
|
|
100
|
+
const real = safeRealpath(current);
|
|
101
|
+
if (real !== void 0) return path.join(real, ...missing.reverse());
|
|
102
|
+
const parent = path.dirname(current);
|
|
103
|
+
if (parent === current) return p;
|
|
104
|
+
missing.push(path.basename(current));
|
|
105
|
+
current = parent;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function isInside(root, p) {
|
|
109
|
+
const rel = path.relative(root, p);
|
|
110
|
+
return rel === "" || rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Upload one file. Never throws: every outcome is a status.
|
|
114
|
+
*
|
|
115
|
+
* - The file is opened ONCE, with O_NOFOLLOW (omitted on win32, where the
|
|
116
|
+
* dev/inode comparison alone guards the read), and the open handle's
|
|
117
|
+
* dev/inode must match the validated path's; otherwise the file was
|
|
118
|
+
* swapped after validation and the result is `rejected` (R1-07). The size
|
|
119
|
+
* check and the bytes sent come from that same handle.
|
|
120
|
+
* - ENOENT/ENOTDIR → `missing`; over 50 MiB → `skipped` (DD5).
|
|
121
|
+
* - Fields go before the file part; kind/attempt_index only for an
|
|
122
|
+
* automation_run_result target (the server rejects them elsewhere).
|
|
123
|
+
* - Retries split by idempotency (R1-06), fail_retry(2) with backoff: a
|
|
124
|
+
* failure the server cannot have received (429, connection refused, DNS)
|
|
125
|
+
* is retried for any target; an ambiguous one (timeout, reset, 5xx) only
|
|
126
|
+
* for automation_run_result, whose uploads the server deduplicates. Any
|
|
127
|
+
* other target gets `failed` ("outcome unknown") instead of a duplicate.
|
|
128
|
+
*/
|
|
129
|
+
async function uploadArtifactFile(options) {
|
|
130
|
+
const { target, filePath } = options;
|
|
131
|
+
const sleep$1 = options.sleep ?? sleep;
|
|
132
|
+
const isAutomation = target.relatedType === "automation_run_result";
|
|
133
|
+
let validated = options.validated;
|
|
134
|
+
if (!validated) {
|
|
135
|
+
const real = safeRealpath(filePath);
|
|
136
|
+
if (real === void 0) return { status: "missing" };
|
|
137
|
+
try {
|
|
138
|
+
const st = statSync(real);
|
|
139
|
+
if (!st.isFile()) return { status: "missing" };
|
|
140
|
+
validated = {
|
|
141
|
+
path: real,
|
|
142
|
+
dev: st.dev,
|
|
143
|
+
ino: st.ino
|
|
144
|
+
};
|
|
145
|
+
} catch {
|
|
146
|
+
return { status: "missing" };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const flags = process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW;
|
|
150
|
+
let handle;
|
|
151
|
+
try {
|
|
152
|
+
handle = await promises.open(validated.path, flags);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
const code = err.code;
|
|
155
|
+
if (code === "ENOENT" || code === "ENOTDIR") return { status: "missing" };
|
|
156
|
+
if (code === "ELOOP") return {
|
|
157
|
+
status: "rejected",
|
|
158
|
+
message: "the path became a symlink"
|
|
159
|
+
};
|
|
160
|
+
return {
|
|
161
|
+
status: "failed",
|
|
162
|
+
message: err.message
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
let bytes;
|
|
166
|
+
try {
|
|
167
|
+
const st = await handle.stat();
|
|
168
|
+
if (st.dev !== validated.dev || st.ino !== validated.ino) return {
|
|
169
|
+
status: "rejected",
|
|
170
|
+
message: "the file changed after it was validated"
|
|
171
|
+
};
|
|
172
|
+
if (validated.root && !openedInside(handle.fd, validated, validated.root)) return {
|
|
173
|
+
status: "rejected",
|
|
174
|
+
message: "the opened file is outside the working directory"
|
|
175
|
+
};
|
|
176
|
+
if (st.size > MAX_ARTIFACT_BYTES) return {
|
|
177
|
+
status: "skipped",
|
|
178
|
+
reason: "too_large",
|
|
179
|
+
size: st.size
|
|
180
|
+
};
|
|
181
|
+
bytes = await handle.readFile();
|
|
182
|
+
} catch (err) {
|
|
183
|
+
return {
|
|
184
|
+
status: "failed",
|
|
185
|
+
message: err.message
|
|
186
|
+
};
|
|
187
|
+
} finally {
|
|
188
|
+
await handle.close().catch(() => void 0);
|
|
189
|
+
}
|
|
190
|
+
const name = options.name ?? path.basename(filePath);
|
|
191
|
+
const body = {
|
|
192
|
+
related_type: target.relatedType,
|
|
193
|
+
related_id: target.relatedId
|
|
194
|
+
};
|
|
195
|
+
if (isAutomation) {
|
|
196
|
+
if (options.kind) body.kind = options.kind;
|
|
197
|
+
if (options.attemptIndex !== void 0 && options.attemptIndex !== null) body.attempt_index = options.attemptIndex;
|
|
198
|
+
}
|
|
199
|
+
if (options.isPublic !== void 0) body.is_public = options.isPublic;
|
|
200
|
+
body.file = new File([new Uint8Array(bytes)], name);
|
|
201
|
+
for (let attempt = 0;; attempt++) {
|
|
202
|
+
let outcome;
|
|
203
|
+
let thrown;
|
|
204
|
+
try {
|
|
205
|
+
outcome = await uploadAttachment(body);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
thrown = err;
|
|
208
|
+
}
|
|
209
|
+
if (outcome && outcome.status >= 200 && outcome.status < 300) {
|
|
210
|
+
const data = outcome.data;
|
|
211
|
+
if (!data?.id) return {
|
|
212
|
+
status: "failed",
|
|
213
|
+
message: "upload returned no attachment"
|
|
214
|
+
};
|
|
215
|
+
return {
|
|
216
|
+
status: data.deduplicated || outcome.status === 200 ? "deduplicated" : "uploaded",
|
|
217
|
+
id: data.id,
|
|
218
|
+
url: data.url ?? null,
|
|
219
|
+
target_id: data.target_id ?? null
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
const kind = classifyFailure(outcome, thrown);
|
|
223
|
+
if ((kind === "not_received" || kind === "ambiguous" && isAutomation) && attempt < MAX_UPLOAD_RETRIES) {
|
|
224
|
+
await sleep$1(500 * 2 ** attempt);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (kind === "ambiguous" && !isAutomation) return {
|
|
228
|
+
status: "failed",
|
|
229
|
+
message: "outcome unknown, not retried to avoid a duplicate attachment"
|
|
230
|
+
};
|
|
231
|
+
return {
|
|
232
|
+
status: "failed",
|
|
233
|
+
message: describeFailure(outcome, thrown)
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Where the OPEN descriptor really lives (ENG-6165 code review F-005).
|
|
239
|
+
* O_NOFOLLOW guards only the last component, and the dev/inode recorded at
|
|
240
|
+
* validation was itself read through a path whose parent could have been
|
|
241
|
+
* swapped for a symlink in between. On Linux `/proc/self/fd/<fd>` names the
|
|
242
|
+
* opened file authoritatively. Elsewhere Node has no fd → path call, so the
|
|
243
|
+
* path is resolved again and must still be the validated, in-root path
|
|
244
|
+
* holding the same file: this narrows the window to the open itself rather
|
|
245
|
+
* than closing it.
|
|
246
|
+
*/
|
|
247
|
+
function openedInside(fd, validated, root) {
|
|
248
|
+
if (process.platform === "linux") try {
|
|
249
|
+
return isInside(root, readlinkSync(`/proc/self/fd/${fd}`));
|
|
250
|
+
} catch {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
const again = safeRealpath(validated.path);
|
|
254
|
+
if (again !== validated.path || !isInside(root, again)) return false;
|
|
255
|
+
try {
|
|
256
|
+
const st = statSync(again);
|
|
257
|
+
return st.dev === validated.dev && st.ino === validated.ino;
|
|
258
|
+
} catch {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* not_received: the server cannot have acted (429, connection refused, DNS).
|
|
264
|
+
* ambiguous: the request may have landed (timeout, reset, 5xx, unknown).
|
|
265
|
+
* rejected: a definite 4xx answer — never retried.
|
|
266
|
+
*/
|
|
267
|
+
function classifyFailure(outcome, thrown) {
|
|
268
|
+
if (outcome) {
|
|
269
|
+
if (outcome.status === 429) return "not_received";
|
|
270
|
+
if (outcome.status >= 500 || outcome.status === 0) return "ambiguous";
|
|
271
|
+
return "rejected";
|
|
272
|
+
}
|
|
273
|
+
const code = errorCode(thrown);
|
|
274
|
+
if (code === "ECONNREFUSED" || code === "ENOTFOUND" || code === "EAI_AGAIN") return "not_received";
|
|
275
|
+
return "ambiguous";
|
|
276
|
+
}
|
|
277
|
+
function errorCode(err) {
|
|
278
|
+
const e = err;
|
|
279
|
+
const code = e?.code ?? e?.cause?.code;
|
|
280
|
+
return typeof code === "string" ? code : void 0;
|
|
281
|
+
}
|
|
282
|
+
function describeFailure(outcome, thrown) {
|
|
283
|
+
if (outcome) {
|
|
284
|
+
const msg = outcome.error?.message;
|
|
285
|
+
const text = Array.isArray(msg) ? msg.join("; ") : typeof msg === "string" ? msg : JSON.stringify(outcome.error ?? {});
|
|
286
|
+
return `HTTP ${outcome.status}: ${text}`;
|
|
287
|
+
}
|
|
288
|
+
return thrown instanceof Error ? thrown.message : String(thrown);
|
|
289
|
+
}
|
|
290
|
+
/** Markdown that shows an uploaded file: an image inline, anything else as a link. */
|
|
291
|
+
function embedMarkdown(name, url) {
|
|
292
|
+
return /\.(png|jpe?g|gif|webp)$/i.test(name) ? `` : `[${name}](${url})`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
//#endregion
|
|
296
|
+
export { TargetParseError, embedMarkdown, parseTarget, resolveArtifactPath, uploadArtifactFile };
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { appendActualResult, createComment } from "./sdk-client-B_urshDp.js";
|
|
2
|
+
import "./env-CdwyPHGV.js";
|
|
3
|
+
import "./token-refresh-DeusLK8H.js";
|
|
4
|
+
import "./resolve-token-7xb7kG7h.js";
|
|
5
|
+
import "./workspace-store-CnrxyYRB.js";
|
|
6
|
+
import "./resolve-workspace-Dsm8Xzut.js";
|
|
7
|
+
import { connect } from "./connect-CPybjrXT.js";
|
|
8
|
+
import "./sleep-BpJ39Ypm.js";
|
|
9
|
+
import { TargetParseError, embedMarkdown, parseTarget, uploadArtifactFile } from "./artifact-upload-BnqeVY_b.js";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { basename } from "node:path";
|
|
12
|
+
|
|
13
|
+
//#region src/commands/attachHandler.ts
|
|
14
|
+
/**
|
|
15
|
+
* `levr attach` (ENG-6164 D4). fail_closed: any file that fails or is
|
|
16
|
+
* skipped, and any failed embed write, exits 1 — an explicit attach is a
|
|
17
|
+
* request, unlike `levr push --artifacts`.
|
|
18
|
+
*
|
|
19
|
+
* Visibility: every upload is private, --embed included (ENG-6165 code
|
|
20
|
+
* review F-006). Levr serves a private attachment's url to any signed-in
|
|
21
|
+
* workspace member, so the embedded link renders in Levr without making the
|
|
22
|
+
* file readable by anyone holding the link. --embed writes
|
|
23
|
+
* append-only — a NEW comment on an issue, test or run, or a server-side
|
|
24
|
+
* `append_actual_result` on an execution result — never a rewrite of
|
|
25
|
+
* entity text (audit A3).
|
|
26
|
+
*/
|
|
27
|
+
async function attachHandler(flags, ...args) {
|
|
28
|
+
if (flags.verbose) this.logger.setVerbose(true);
|
|
29
|
+
let jobs;
|
|
30
|
+
try {
|
|
31
|
+
jobs = flags.manifest ? readManifest(flags.manifest) : [singleJob(args, flags)];
|
|
32
|
+
} catch (err) {
|
|
33
|
+
this.logger.error(err instanceof Error ? err.message : String(err));
|
|
34
|
+
this.process.exitCode = 1;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (flags.embed) {
|
|
38
|
+
for (const job of jobs) if (!embedWriter(job.target)) {
|
|
39
|
+
this.logger.error(`--embed is not supported for ${job.target.relatedType} targets (issue, test, run or run_result_variant only).`);
|
|
40
|
+
this.process.exitCode = 1;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (!await connect(this, flags["workspace-id"])) return;
|
|
45
|
+
let failed = false;
|
|
46
|
+
for (const job of jobs) {
|
|
47
|
+
const uploaded = [];
|
|
48
|
+
for (const file of job.files) {
|
|
49
|
+
const result = await uploadArtifactFile({
|
|
50
|
+
target: job.target,
|
|
51
|
+
filePath: file,
|
|
52
|
+
kind: job.kind,
|
|
53
|
+
attemptIndex: job.attempt
|
|
54
|
+
});
|
|
55
|
+
report(this, file, result);
|
|
56
|
+
if (result.status === "uploaded" || result.status === "deduplicated") {
|
|
57
|
+
if (result.url && result.target_id) uploaded.push({
|
|
58
|
+
name: basename(file),
|
|
59
|
+
url: result.url,
|
|
60
|
+
targetId: result.target_id
|
|
61
|
+
});
|
|
62
|
+
} else failed = true;
|
|
63
|
+
}
|
|
64
|
+
const first = uploaded[0];
|
|
65
|
+
if (flags.embed && first) {
|
|
66
|
+
const markdown = uploaded.map((u) => embedMarkdown(u.name, u.url)).join("\n");
|
|
67
|
+
try {
|
|
68
|
+
await writeEmbed(job.target, first.targetId, markdown);
|
|
69
|
+
this.process.stdout.write(` embedded ${uploaded.length} file(s) on ${job.target.relatedId}\n`);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
this.logger.error(`Embed failed for ${job.target.relatedId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
72
|
+
failed = true;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (failed) this.process.exitCode = 1;
|
|
77
|
+
}
|
|
78
|
+
function singleJob(args, flags) {
|
|
79
|
+
const [targetArg, ...files] = args;
|
|
80
|
+
if (!targetArg || files.length === 0) throw new Error("Usage: levr attach <target> <files...> (or levr attach --manifest <json>)");
|
|
81
|
+
return {
|
|
82
|
+
target: parseTarget(targetArg),
|
|
83
|
+
files,
|
|
84
|
+
kind: flags.kind,
|
|
85
|
+
attempt: flags.attempt
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** `[{ target, files[], kind?, attempt? }]` — validated before any upload. */
|
|
89
|
+
function readManifest(file) {
|
|
90
|
+
let raw;
|
|
91
|
+
try {
|
|
92
|
+
raw = JSON.parse(readFileSync(file, "utf8"));
|
|
93
|
+
} catch (err) {
|
|
94
|
+
throw new Error(`Cannot read manifest ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
95
|
+
}
|
|
96
|
+
if (!Array.isArray(raw)) throw new Error(`Manifest ${file} must be a JSON array`);
|
|
97
|
+
return raw.map((entry, i) => {
|
|
98
|
+
const e = entry;
|
|
99
|
+
if (typeof e?.target !== "string") throw new Error(`Manifest entry ${i}: "target" must be a string`);
|
|
100
|
+
if (!Array.isArray(e.files) || e.files.length === 0 || !e.files.every((f) => typeof f === "string")) throw new Error(`Manifest entry ${i}: "files" must be a string array`);
|
|
101
|
+
if (e.attempt !== void 0 && !(Number.isInteger(e.attempt) && e.attempt >= 0)) throw new Error(`Manifest entry ${i}: "attempt" must be an integer >= 0`);
|
|
102
|
+
try {
|
|
103
|
+
return {
|
|
104
|
+
target: parseTarget(e.target),
|
|
105
|
+
files: e.files,
|
|
106
|
+
kind: typeof e.kind === "string" ? e.kind : void 0,
|
|
107
|
+
attempt: e.attempt
|
|
108
|
+
};
|
|
109
|
+
} catch (err) {
|
|
110
|
+
if (err instanceof TargetParseError) throw new Error(`Manifest entry ${i}: ${err.message}`);
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
function embedWriter(target) {
|
|
116
|
+
if (target.relatedType === "run_result_variant") return "actual_result";
|
|
117
|
+
if (target.relatedType === "issue" || target.relatedType === "test" || target.relatedType === "run") return "comment";
|
|
118
|
+
}
|
|
119
|
+
/** Append-only: a new comment, or the server-side actual-result append. */
|
|
120
|
+
async function writeEmbed(target, targetId, markdown) {
|
|
121
|
+
if (embedWriter(target) === "actual_result") {
|
|
122
|
+
await appendActualResult(targetId, markdown);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
await createComment(target.relatedType, targetId, markdown);
|
|
126
|
+
}
|
|
127
|
+
function report(ctx, file, result) {
|
|
128
|
+
switch (result.status) {
|
|
129
|
+
case "uploaded":
|
|
130
|
+
ctx.process.stdout.write(` attached ${file} (${result.id})\n`);
|
|
131
|
+
return;
|
|
132
|
+
case "deduplicated":
|
|
133
|
+
ctx.process.stdout.write(` already attached ${file} (${result.id})\n`);
|
|
134
|
+
return;
|
|
135
|
+
case "skipped":
|
|
136
|
+
ctx.logger.warning(`skipped ${file}: larger than 50 MiB`);
|
|
137
|
+
return;
|
|
138
|
+
case "missing":
|
|
139
|
+
ctx.logger.error(`not found: ${file}`);
|
|
140
|
+
return;
|
|
141
|
+
case "rejected":
|
|
142
|
+
ctx.logger.error(`refused ${file}: ${result.message}`);
|
|
143
|
+
return;
|
|
144
|
+
case "failed":
|
|
145
|
+
ctx.logger.error(`failed ${file}: ${result.message}`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
//#endregion
|
|
151
|
+
export { attachHandler };
|