devsurface 0.2.0 → 0.3.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/CHANGELOG.md +15 -0
- package/README.md +48 -2
- package/action/dist/index.js +644 -0
- package/action.yml +39 -0
- package/dist/cli/index.js +1169 -224
- package/dist/cli/index.js.map +1 -1
- package/package.json +8 -5
- package/src/web/dist/assets/index-7njY8n4D.js +10 -0
- package/src/web/dist/assets/index-DvunFIw4.css +1 -0
- package/src/web/dist/index.html +2 -2
- package/src/web/dist/assets/index-CdzG3b92.js +0 -10
- package/src/web/dist/assets/index-l7i8vzTo.css +0 -1
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
// src/action/runtime.ts
|
|
2
|
+
import { promises as fs5 } from "fs";
|
|
3
|
+
import path4 from "path";
|
|
4
|
+
|
|
5
|
+
// src/core/check/index.ts
|
|
6
|
+
import { promises as fs3 } from "fs";
|
|
7
|
+
import path3 from "path";
|
|
8
|
+
|
|
9
|
+
// src/core/config/load.ts
|
|
10
|
+
import { promises as fs } from "fs";
|
|
11
|
+
import path from "path";
|
|
12
|
+
|
|
13
|
+
// src/core/security/url.ts
|
|
14
|
+
function isSafeHttpUrl(value) {
|
|
15
|
+
try {
|
|
16
|
+
const url = new URL(value);
|
|
17
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
18
|
+
} catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// src/core/config/defaults.ts
|
|
24
|
+
var CONFIG_FILE_NAME = "devsurface.config.json";
|
|
25
|
+
|
|
26
|
+
// src/core/config/load.ts
|
|
27
|
+
var MAX_CONFIGURED_PORTS = 32;
|
|
28
|
+
function isWithinRoot(root, target) {
|
|
29
|
+
const relative = path.relative(root, target);
|
|
30
|
+
return relative === "" || !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
31
|
+
}
|
|
32
|
+
function isRecord(value) {
|
|
33
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
function toStringRecord(value, warnings, label) {
|
|
36
|
+
if (value === void 0) {
|
|
37
|
+
return void 0;
|
|
38
|
+
}
|
|
39
|
+
if (!isRecord(value)) {
|
|
40
|
+
warnings.push(`${label} must be an object.`);
|
|
41
|
+
return void 0;
|
|
42
|
+
}
|
|
43
|
+
const record = {};
|
|
44
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
45
|
+
if (typeof raw === "string") {
|
|
46
|
+
record[key] = raw;
|
|
47
|
+
} else {
|
|
48
|
+
warnings.push(`${label}.${key} must be a string.`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return record;
|
|
52
|
+
}
|
|
53
|
+
function toGroups(value, warnings) {
|
|
54
|
+
if (value === void 0) {
|
|
55
|
+
return void 0;
|
|
56
|
+
}
|
|
57
|
+
if (!isRecord(value)) {
|
|
58
|
+
warnings.push("groups must be an object.");
|
|
59
|
+
return void 0;
|
|
60
|
+
}
|
|
61
|
+
const groups = {};
|
|
62
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
63
|
+
if (Array.isArray(raw) && raw.every((entry) => typeof entry === "string")) {
|
|
64
|
+
groups[key] = raw;
|
|
65
|
+
} else {
|
|
66
|
+
warnings.push(`groups.${key} must be an array of command names.`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return groups;
|
|
70
|
+
}
|
|
71
|
+
function toPorts(value, warnings) {
|
|
72
|
+
if (value === void 0) {
|
|
73
|
+
return void 0;
|
|
74
|
+
}
|
|
75
|
+
if (!Array.isArray(value)) {
|
|
76
|
+
warnings.push("ports must be an array of numbers.");
|
|
77
|
+
return void 0;
|
|
78
|
+
}
|
|
79
|
+
const ports = value.filter(
|
|
80
|
+
(port) => Number.isInteger(port) && port > 0 && port < 65536
|
|
81
|
+
);
|
|
82
|
+
if (ports.length !== value.length) {
|
|
83
|
+
warnings.push("ports may only contain integers between 1 and 65535.");
|
|
84
|
+
}
|
|
85
|
+
if (ports.length > MAX_CONFIGURED_PORTS) {
|
|
86
|
+
warnings.push(`ports may contain at most ${MAX_CONFIGURED_PORTS} entries.`);
|
|
87
|
+
}
|
|
88
|
+
return ports.slice(0, MAX_CONFIGURED_PORTS);
|
|
89
|
+
}
|
|
90
|
+
function validateConfig(raw) {
|
|
91
|
+
const warnings = [];
|
|
92
|
+
if (!isRecord(raw)) {
|
|
93
|
+
return { config: {}, warnings: ["devsurface.config.json must contain a JSON object."] };
|
|
94
|
+
}
|
|
95
|
+
const env = isRecord(raw.env) ? {
|
|
96
|
+
example: typeof raw.env.example === "string" ? raw.env.example : void 0,
|
|
97
|
+
local: typeof raw.env.local === "string" ? raw.env.local : void 0
|
|
98
|
+
} : void 0;
|
|
99
|
+
if (raw.env !== void 0 && !isRecord(raw.env)) {
|
|
100
|
+
warnings.push("env must be an object.");
|
|
101
|
+
}
|
|
102
|
+
const services = isRecord(raw.services) ? {
|
|
103
|
+
docker: typeof raw.services.docker === "boolean" ? raw.services.docker : void 0
|
|
104
|
+
} : void 0;
|
|
105
|
+
if (raw.services !== void 0 && !isRecord(raw.services)) {
|
|
106
|
+
warnings.push("services must be an object.");
|
|
107
|
+
}
|
|
108
|
+
let docs;
|
|
109
|
+
if (typeof raw.docs === "string" && raw.docs.length > 0) {
|
|
110
|
+
if (isSafeHttpUrl(raw.docs)) {
|
|
111
|
+
docs = raw.docs;
|
|
112
|
+
} else {
|
|
113
|
+
warnings.push("docs must be an http or https URL.");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
config: {
|
|
118
|
+
name: typeof raw.name === "string" ? raw.name : void 0,
|
|
119
|
+
description: typeof raw.description === "string" ? raw.description : void 0,
|
|
120
|
+
commands: toStringRecord(raw.commands, warnings, "commands"),
|
|
121
|
+
groups: toGroups(raw.groups, warnings),
|
|
122
|
+
ports: toPorts(raw.ports, warnings),
|
|
123
|
+
env,
|
|
124
|
+
services,
|
|
125
|
+
docs
|
|
126
|
+
},
|
|
127
|
+
warnings
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
async function loadConfig(root) {
|
|
131
|
+
const configPath = path.join(root, CONFIG_FILE_NAME);
|
|
132
|
+
try {
|
|
133
|
+
const [realRoot, realConfigPath] = await Promise.all([
|
|
134
|
+
fs.realpath(root),
|
|
135
|
+
fs.realpath(configPath)
|
|
136
|
+
]);
|
|
137
|
+
if (!isWithinRoot(realRoot, realConfigPath)) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
const content = await fs.readFile(realConfigPath, "utf8");
|
|
141
|
+
const parsed = JSON.parse(content);
|
|
142
|
+
const { config, warnings } = validateConfig(parsed);
|
|
143
|
+
return { path: realConfigPath, config, warnings };
|
|
144
|
+
} catch (error) {
|
|
145
|
+
const code = typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
|
|
146
|
+
if (code === "ENOENT") {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
if (error instanceof SyntaxError) {
|
|
150
|
+
return {
|
|
151
|
+
path: configPath,
|
|
152
|
+
config: {},
|
|
153
|
+
warnings: [`${CONFIG_FILE_NAME} contains invalid JSON.`]
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// src/core/documentation.ts
|
|
161
|
+
function extractScriptReferences(content) {
|
|
162
|
+
const references = /* @__PURE__ */ new Set();
|
|
163
|
+
const commandRegexes = [
|
|
164
|
+
/\bnpm\s+run\s+([A-Za-z0-9:_-]+)/g,
|
|
165
|
+
/\bpnpm\s+run\s+([A-Za-z0-9:_-]+)/g,
|
|
166
|
+
/\bbun\s+run\s+([A-Za-z0-9:_-]+)/g,
|
|
167
|
+
/\byarn\s+run\s+([A-Za-z0-9:_-]+)/g,
|
|
168
|
+
/\bnpm\s+(test|start|build)\b/g,
|
|
169
|
+
/\bpnpm\s+(test|start|build)\b/g,
|
|
170
|
+
/\byarn\s+(test|start|build)\b/g,
|
|
171
|
+
/\bbun\s+(test|start|build)\b/g
|
|
172
|
+
];
|
|
173
|
+
for (const regex of commandRegexes) {
|
|
174
|
+
for (const match of content.matchAll(regex)) {
|
|
175
|
+
references.add(match[1]);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return Array.from(references);
|
|
179
|
+
}
|
|
180
|
+
function documentsEnvironmentSetup(content) {
|
|
181
|
+
return /(?:\.env(?:\.example)?|environment\s+variables?)/i.test(content);
|
|
182
|
+
}
|
|
183
|
+
function undocumentedPorts(content, ports) {
|
|
184
|
+
return ports.filter((port) => !new RegExp(`\\b${port}\\b`).test(content));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// src/core/scanner/ports.ts
|
|
188
|
+
import net from "net";
|
|
189
|
+
function uniquePorts(ports) {
|
|
190
|
+
return Array.from(
|
|
191
|
+
new Set(ports.filter((port) => Number.isInteger(port) && port > 0 && port < 65536))
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
function inferPortsFromScripts(scripts) {
|
|
195
|
+
const ports = [];
|
|
196
|
+
for (const command of Object.values(scripts)) {
|
|
197
|
+
const patterns = [
|
|
198
|
+
/(?:--port|-p)\s+(\d{2,5})/g,
|
|
199
|
+
/\bPORT=(\d{2,5})\b/g,
|
|
200
|
+
/localhost:(\d{2,5})/g,
|
|
201
|
+
/127\.0\.0\.1:(\d{2,5})/g
|
|
202
|
+
];
|
|
203
|
+
for (const pattern of patterns) {
|
|
204
|
+
for (const match of command.matchAll(pattern)) {
|
|
205
|
+
ports.push(Number(match[1]));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return uniquePorts(ports);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// src/core/scanner/packageJson.ts
|
|
213
|
+
import { promises as fs2 } from "fs";
|
|
214
|
+
import path2 from "path";
|
|
215
|
+
function isWithinRoot2(root, target) {
|
|
216
|
+
const relative = path2.relative(root, target);
|
|
217
|
+
return relative === "" || !relative.startsWith("..") && !path2.isAbsolute(relative);
|
|
218
|
+
}
|
|
219
|
+
async function readPackageJson(root) {
|
|
220
|
+
const packageJsonPath = path2.join(root, "package.json");
|
|
221
|
+
try {
|
|
222
|
+
const [realRoot, realPackageJsonPath] = await Promise.all([
|
|
223
|
+
fs2.realpath(root),
|
|
224
|
+
fs2.realpath(packageJsonPath)
|
|
225
|
+
]);
|
|
226
|
+
if (!isWithinRoot2(realRoot, realPackageJsonPath)) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
const content = await fs2.readFile(realPackageJsonPath, "utf8");
|
|
230
|
+
const data = JSON.parse(content);
|
|
231
|
+
return { path: realPackageJsonPath, data };
|
|
232
|
+
} catch {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// src/core/scanner/scripts.ts
|
|
238
|
+
function extractScripts(packageJson) {
|
|
239
|
+
if (!packageJson?.data.scripts || typeof packageJson.data.scripts !== "object" || Array.isArray(packageJson.data.scripts)) {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
return Object.fromEntries(
|
|
243
|
+
Object.entries(packageJson.data.scripts).filter((entry) => {
|
|
244
|
+
const [, command] = entry;
|
|
245
|
+
return typeof command === "string";
|
|
246
|
+
})
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// src/core/check/index.ts
|
|
251
|
+
function isWithinRoot3(root, target) {
|
|
252
|
+
const relative = path3.relative(root, target);
|
|
253
|
+
return relative === "" || !relative.startsWith("..") && !path3.isAbsolute(relative);
|
|
254
|
+
}
|
|
255
|
+
async function readFileInsideRoot(root, relativePath) {
|
|
256
|
+
const candidate = path3.resolve(root, relativePath);
|
|
257
|
+
if (!isWithinRoot3(root, candidate)) {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
const [realRoot, realCandidate] = await Promise.all([
|
|
262
|
+
fs3.realpath(root),
|
|
263
|
+
fs3.realpath(candidate)
|
|
264
|
+
]);
|
|
265
|
+
if (!isWithinRoot3(realRoot, realCandidate)) {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
return await fs3.readFile(realCandidate, "utf8");
|
|
269
|
+
} catch {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
async function readFirstDocumentationFile(root, candidates) {
|
|
274
|
+
for (const candidate of candidates) {
|
|
275
|
+
const content = await readFileInsideRoot(root, candidate);
|
|
276
|
+
if (content !== null) {
|
|
277
|
+
return { path: candidate, content };
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
async function fileExistsInsideRoot(root, relativePath) {
|
|
283
|
+
return await readFileInsideRoot(root, relativePath) !== null;
|
|
284
|
+
}
|
|
285
|
+
function check(id, severity, title, message, target) {
|
|
286
|
+
return { id, severity, title, message, target };
|
|
287
|
+
}
|
|
288
|
+
async function runRepositoryChecks(requestedRoot = process.cwd()) {
|
|
289
|
+
const root = await fs3.realpath(path3.resolve(requestedRoot));
|
|
290
|
+
const [packageJson, config, readme, contributing] = await Promise.all([
|
|
291
|
+
readPackageJson(root),
|
|
292
|
+
loadConfig(root),
|
|
293
|
+
readFirstDocumentationFile(root, ["README.md", "README"]),
|
|
294
|
+
readFirstDocumentationFile(root, ["CONTRIBUTING.md", "CONTRIBUTING"])
|
|
295
|
+
]);
|
|
296
|
+
const checks = [];
|
|
297
|
+
const scripts = extractScripts(packageJson) ?? {};
|
|
298
|
+
const projectName = config?.config.name ?? packageJson?.data.name ?? path3.basename(root);
|
|
299
|
+
for (const configWarning of config?.warnings ?? []) {
|
|
300
|
+
checks.push(
|
|
301
|
+
check(
|
|
302
|
+
"config-warning",
|
|
303
|
+
"warning",
|
|
304
|
+
"Invalid DevSurface configuration",
|
|
305
|
+
configWarning,
|
|
306
|
+
"devsurface.config.json"
|
|
307
|
+
)
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
if (packageJson === null) {
|
|
311
|
+
checks.push(
|
|
312
|
+
check(
|
|
313
|
+
"missing-package-json",
|
|
314
|
+
"error",
|
|
315
|
+
"No package.json",
|
|
316
|
+
"DevSurface checks require a Node.js project with a package.json.",
|
|
317
|
+
"package.json"
|
|
318
|
+
)
|
|
319
|
+
);
|
|
320
|
+
} else {
|
|
321
|
+
if (scripts.test === void 0) {
|
|
322
|
+
checks.push(
|
|
323
|
+
check(
|
|
324
|
+
"missing-test-script",
|
|
325
|
+
"warning",
|
|
326
|
+
"No test script",
|
|
327
|
+
"package.json does not define a test script.",
|
|
328
|
+
"package.json"
|
|
329
|
+
)
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
if (scripts.build === void 0) {
|
|
333
|
+
checks.push(
|
|
334
|
+
check(
|
|
335
|
+
"missing-build-script",
|
|
336
|
+
"warning",
|
|
337
|
+
"No build script",
|
|
338
|
+
"package.json does not define a build script.",
|
|
339
|
+
"package.json"
|
|
340
|
+
)
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (readme === null) {
|
|
345
|
+
checks.push(
|
|
346
|
+
check("missing-readme", "warning", "No README", "No README.md or README file was found.")
|
|
347
|
+
);
|
|
348
|
+
} else {
|
|
349
|
+
const missingScripts = extractScriptReferences(readme.content).filter(
|
|
350
|
+
(script) => scripts[script] === void 0
|
|
351
|
+
);
|
|
352
|
+
if (missingScripts.length > 0) {
|
|
353
|
+
checks.push(
|
|
354
|
+
check(
|
|
355
|
+
"readme-script-mismatch",
|
|
356
|
+
"warning",
|
|
357
|
+
"README references missing scripts",
|
|
358
|
+
`README mentions scripts not present in package.json: ${missingScripts.join(", ")}.`,
|
|
359
|
+
readme.path
|
|
360
|
+
)
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (contributing === null) {
|
|
365
|
+
checks.push(
|
|
366
|
+
check(
|
|
367
|
+
"missing-contributing",
|
|
368
|
+
"warning",
|
|
369
|
+
"No CONTRIBUTING guide",
|
|
370
|
+
"No CONTRIBUTING.md or CONTRIBUTING file was found."
|
|
371
|
+
)
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
const documentation = [readme?.content, contributing?.content].filter(Boolean).join("\n");
|
|
375
|
+
const envExample = config?.config.env?.example ?? ".env.example";
|
|
376
|
+
if (await fileExistsInsideRoot(root, envExample) && !documentsEnvironmentSetup(documentation)) {
|
|
377
|
+
checks.push(
|
|
378
|
+
check(
|
|
379
|
+
"undocumented-env",
|
|
380
|
+
"warning",
|
|
381
|
+
"Environment setup is undocumented",
|
|
382
|
+
`${envExample} exists, but README or CONTRIBUTING does not explain environment setup.`,
|
|
383
|
+
envExample
|
|
384
|
+
)
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
const ports = Array.from(
|
|
388
|
+
/* @__PURE__ */ new Set([...config?.config.ports ?? [], ...inferPortsFromScripts(scripts)])
|
|
389
|
+
);
|
|
390
|
+
const missingPortDocs = undocumentedPorts(documentation, ports);
|
|
391
|
+
if (missingPortDocs.length > 0) {
|
|
392
|
+
checks.push(
|
|
393
|
+
check(
|
|
394
|
+
"undocumented-ports",
|
|
395
|
+
"info",
|
|
396
|
+
"Detected ports are undocumented",
|
|
397
|
+
`README or CONTRIBUTING does not mention: ${missingPortDocs.join(", ")}.`
|
|
398
|
+
)
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
return { root, projectName, checks };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// src/action/github.ts
|
|
405
|
+
import { promises as fs4 } from "fs";
|
|
406
|
+
var COMMENT_MARKER = "<!-- devsurface-health-check -->";
|
|
407
|
+
async function readPullRequestNumber(eventPath) {
|
|
408
|
+
if (!eventPath) {
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
try {
|
|
412
|
+
const event = JSON.parse(await fs4.readFile(eventPath, "utf8"));
|
|
413
|
+
return typeof event.pull_request?.number === "number" ? event.pull_request.number : null;
|
|
414
|
+
} catch {
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
async function githubRequest(url, token, init, fetchImpl) {
|
|
419
|
+
return await fetchImpl(url, {
|
|
420
|
+
...init,
|
|
421
|
+
headers: {
|
|
422
|
+
Accept: "application/vnd.github+json",
|
|
423
|
+
Authorization: `Bearer ${token}`,
|
|
424
|
+
"Content-Type": "application/json",
|
|
425
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
426
|
+
...init.headers
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
async function upsertPullRequestComment(options, fetchImpl = fetch) {
|
|
431
|
+
if (!options.token || !options.repository || !options.eventPath) {
|
|
432
|
+
return "skipped";
|
|
433
|
+
}
|
|
434
|
+
const pullRequestNumber = await readPullRequestNumber(options.eventPath);
|
|
435
|
+
if (pullRequestNumber === null) {
|
|
436
|
+
return "skipped";
|
|
437
|
+
}
|
|
438
|
+
const baseUrl = `https://api.github.com/repos/${options.repository}`;
|
|
439
|
+
const listResponse = await githubRequest(
|
|
440
|
+
`${baseUrl}/issues/${pullRequestNumber}/comments?per_page=100`,
|
|
441
|
+
options.token,
|
|
442
|
+
{ method: "GET" },
|
|
443
|
+
fetchImpl
|
|
444
|
+
);
|
|
445
|
+
if (listResponse.status === 403) {
|
|
446
|
+
return "forbidden";
|
|
447
|
+
}
|
|
448
|
+
if (!listResponse.ok) {
|
|
449
|
+
throw new Error(`GitHub comment lookup failed with status ${listResponse.status}.`);
|
|
450
|
+
}
|
|
451
|
+
const comments = await listResponse.json();
|
|
452
|
+
const existing = comments.find((comment) => comment.body?.includes(COMMENT_MARKER));
|
|
453
|
+
const response = existing === void 0 ? await githubRequest(
|
|
454
|
+
`${baseUrl}/issues/${pullRequestNumber}/comments`,
|
|
455
|
+
options.token,
|
|
456
|
+
{ method: "POST", body: JSON.stringify({ body: options.body }) },
|
|
457
|
+
fetchImpl
|
|
458
|
+
) : await githubRequest(
|
|
459
|
+
`${baseUrl}/issues/comments/${existing.id}`,
|
|
460
|
+
options.token,
|
|
461
|
+
{ method: "PATCH", body: JSON.stringify({ body: options.body }) },
|
|
462
|
+
fetchImpl
|
|
463
|
+
);
|
|
464
|
+
if (response.status === 403) {
|
|
465
|
+
return "forbidden";
|
|
466
|
+
}
|
|
467
|
+
if (!response.ok) {
|
|
468
|
+
throw new Error(`GitHub comment update failed with status ${response.status}.`);
|
|
469
|
+
}
|
|
470
|
+
return existing === void 0 ? "created" : "updated";
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// src/action/report.ts
|
|
474
|
+
var SEVERITY_ORDER = ["error", "warning", "info"];
|
|
475
|
+
function stripControlCharacters(value) {
|
|
476
|
+
let result = "";
|
|
477
|
+
for (const character of value) {
|
|
478
|
+
const code = character.charCodeAt(0);
|
|
479
|
+
if (code > 31 && code < 127 || code > 159) {
|
|
480
|
+
result += character;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return result;
|
|
484
|
+
}
|
|
485
|
+
function countChecks(checks) {
|
|
486
|
+
return {
|
|
487
|
+
error: checks.filter((item) => item.severity === "error").length,
|
|
488
|
+
warning: checks.filter((item) => item.severity === "warning").length,
|
|
489
|
+
info: checks.filter((item) => item.severity === "info").length
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
function escapeMarkdown(value) {
|
|
493
|
+
return stripControlCharacters(value).replaceAll("\\", "\\\\").replace(/([`*_[\]{}()#+!|<>])/g, "\\$1").replaceAll("\r", "").replaceAll("\n", " ");
|
|
494
|
+
}
|
|
495
|
+
function renderReport(projectName, checks) {
|
|
496
|
+
const counts = countChecks(checks);
|
|
497
|
+
const lines = [
|
|
498
|
+
"<!-- devsurface-health-check -->",
|
|
499
|
+
`## DevSurface Health Check: ${escapeMarkdown(projectName)}`,
|
|
500
|
+
"",
|
|
501
|
+
`Errors: **${counts.error}** | Warnings: **${counts.warning}** | Info: **${counts.info}**`,
|
|
502
|
+
""
|
|
503
|
+
];
|
|
504
|
+
if (checks.length === 0) {
|
|
505
|
+
lines.push("No repository health issues found.");
|
|
506
|
+
return `${lines.join("\n")}
|
|
507
|
+
`;
|
|
508
|
+
}
|
|
509
|
+
lines.push("| Severity | Check | Details |", "| --- | --- | --- |");
|
|
510
|
+
for (const severity of SEVERITY_ORDER) {
|
|
511
|
+
for (const item of checks.filter((candidate) => candidate.severity === severity)) {
|
|
512
|
+
lines.push(
|
|
513
|
+
`| ${severity} | ${escapeMarkdown(item.title)} | ${escapeMarkdown(item.message)} |`
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return `${lines.join("\n")}
|
|
518
|
+
`;
|
|
519
|
+
}
|
|
520
|
+
function parseFailureThreshold(value) {
|
|
521
|
+
const normalized = value?.trim().toLowerCase() || "error";
|
|
522
|
+
if (normalized === "error" || normalized === "warning" || normalized === "never") {
|
|
523
|
+
return normalized;
|
|
524
|
+
}
|
|
525
|
+
throw new Error(`fail-on must be one of: error, warning, never. Received: ${value}`);
|
|
526
|
+
}
|
|
527
|
+
function shouldFail(checks, threshold) {
|
|
528
|
+
if (threshold === "never") {
|
|
529
|
+
return false;
|
|
530
|
+
}
|
|
531
|
+
if (threshold === "warning") {
|
|
532
|
+
return checks.some((item) => item.severity === "error" || item.severity === "warning");
|
|
533
|
+
}
|
|
534
|
+
return checks.some((item) => item.severity === "error");
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// src/action/runtime.ts
|
|
538
|
+
function input(name, fallback = "") {
|
|
539
|
+
return process.env[`INPUT_${name.toUpperCase().replaceAll("-", "_")}`]?.trim() || fallback;
|
|
540
|
+
}
|
|
541
|
+
function booleanInput(name, fallback) {
|
|
542
|
+
const value = input(name);
|
|
543
|
+
if (!value) {
|
|
544
|
+
return fallback;
|
|
545
|
+
}
|
|
546
|
+
if (value === "true") {
|
|
547
|
+
return true;
|
|
548
|
+
}
|
|
549
|
+
if (value === "false") {
|
|
550
|
+
return false;
|
|
551
|
+
}
|
|
552
|
+
throw new Error(`${name} must be true or false.`);
|
|
553
|
+
}
|
|
554
|
+
function isWithinRoot4(root, target) {
|
|
555
|
+
const relative = path4.relative(root, target);
|
|
556
|
+
return relative === "" || !relative.startsWith("..") && !path4.isAbsolute(relative);
|
|
557
|
+
}
|
|
558
|
+
async function resolveActionRoot(workspace, requestedPath) {
|
|
559
|
+
const resolvedWorkspace = path4.resolve(workspace);
|
|
560
|
+
const resolvedRoot = path4.resolve(resolvedWorkspace, requestedPath);
|
|
561
|
+
if (!isWithinRoot4(resolvedWorkspace, resolvedRoot)) {
|
|
562
|
+
throw new Error("path must resolve inside GITHUB_WORKSPACE.");
|
|
563
|
+
}
|
|
564
|
+
const [realWorkspace, realRoot] = await Promise.all([
|
|
565
|
+
fs5.realpath(resolvedWorkspace),
|
|
566
|
+
fs5.realpath(resolvedRoot)
|
|
567
|
+
]);
|
|
568
|
+
if (!isWithinRoot4(realWorkspace, realRoot)) {
|
|
569
|
+
throw new Error("path must resolve inside GITHUB_WORKSPACE.");
|
|
570
|
+
}
|
|
571
|
+
return realRoot;
|
|
572
|
+
}
|
|
573
|
+
function escapeWorkflowValue(value) {
|
|
574
|
+
return value.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
|
|
575
|
+
}
|
|
576
|
+
function escapeWorkflowProperty(value) {
|
|
577
|
+
return escapeWorkflowValue(value).replaceAll(":", "%3A").replaceAll(",", "%2C");
|
|
578
|
+
}
|
|
579
|
+
function emitAnnotations(checks) {
|
|
580
|
+
for (const item of checks) {
|
|
581
|
+
const command = item.severity === "info" ? "notice" : item.severity;
|
|
582
|
+
const properties = [
|
|
583
|
+
item.target ? `file=${escapeWorkflowProperty(item.target)}` : null,
|
|
584
|
+
`title=${escapeWorkflowProperty(item.title)}`
|
|
585
|
+
].filter(Boolean);
|
|
586
|
+
console.log(`::${command} ${properties.join(",")}::${escapeWorkflowValue(item.message)}`);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
async function appendFileIfConfigured(filePath, content) {
|
|
590
|
+
if (filePath) {
|
|
591
|
+
await fs5.appendFile(filePath, content, "utf8");
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
async function writeOutput(name, value) {
|
|
595
|
+
const outputPath = process.env.GITHUB_OUTPUT;
|
|
596
|
+
if (outputPath) {
|
|
597
|
+
await fs5.appendFile(outputPath, `${name}=${value}
|
|
598
|
+
`, "utf8");
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
async function runAction() {
|
|
602
|
+
const workspace = process.env.GITHUB_WORKSPACE ?? process.cwd();
|
|
603
|
+
const requestedPath = input("path", ".");
|
|
604
|
+
const root = await resolveActionRoot(workspace, requestedPath);
|
|
605
|
+
const threshold = parseFailureThreshold(input("fail-on", "error"));
|
|
606
|
+
const comment = booleanInput("comment", true);
|
|
607
|
+
const result = await runRepositoryChecks(root);
|
|
608
|
+
const report = renderReport(result.projectName, result.checks);
|
|
609
|
+
const counts = countChecks(result.checks);
|
|
610
|
+
emitAnnotations(result.checks);
|
|
611
|
+
await appendFileIfConfigured(process.env.GITHUB_STEP_SUMMARY, report);
|
|
612
|
+
await writeOutput("errors", String(counts.error));
|
|
613
|
+
await writeOutput("warnings", String(counts.warning));
|
|
614
|
+
await writeOutput("info", String(counts.info));
|
|
615
|
+
await writeOutput("outcome", result.checks.length === 0 ? "healthy" : "issues-found");
|
|
616
|
+
if (comment) {
|
|
617
|
+
const commentResult = await upsertPullRequestComment({
|
|
618
|
+
token: input("github-token"),
|
|
619
|
+
repository: process.env.GITHUB_REPOSITORY ?? "",
|
|
620
|
+
eventPath: process.env.GITHUB_EVENT_PATH ?? "",
|
|
621
|
+
body: report
|
|
622
|
+
}).catch((error) => {
|
|
623
|
+
console.log(
|
|
624
|
+
`DevSurface could not update the pull request comment: ${error instanceof Error ? error.message : String(error)}`
|
|
625
|
+
);
|
|
626
|
+
return "skipped";
|
|
627
|
+
});
|
|
628
|
+
if (commentResult === "forbidden") {
|
|
629
|
+
console.log(
|
|
630
|
+
"DevSurface could not comment because this workflow has a read-only token. Annotations and the job summary are still available."
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
if (shouldFail(result.checks, threshold)) {
|
|
635
|
+
process.exitCode = 1;
|
|
636
|
+
console.error(`DevSurface repository checks failed at the ${threshold} threshold.`);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// src/action/index.ts
|
|
641
|
+
runAction().catch((error) => {
|
|
642
|
+
console.error(error instanceof Error ? error.stack ?? error.message : String(error));
|
|
643
|
+
process.exitCode = 1;
|
|
644
|
+
});
|
package/action.yml
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
name: DevSurface Health Check
|
|
2
|
+
description: Check Node.js repository onboarding health without running project code.
|
|
3
|
+
author: mrfandu1
|
|
4
|
+
|
|
5
|
+
branding:
|
|
6
|
+
icon: activity
|
|
7
|
+
color: blue
|
|
8
|
+
|
|
9
|
+
inputs:
|
|
10
|
+
path:
|
|
11
|
+
description: Repository-relative directory to check.
|
|
12
|
+
required: false
|
|
13
|
+
default: .
|
|
14
|
+
fail-on:
|
|
15
|
+
description: 'Minimum severity that fails the action: error, warning, or never.'
|
|
16
|
+
required: false
|
|
17
|
+
default: error
|
|
18
|
+
comment:
|
|
19
|
+
description: Create or update a pull request comment when permissions allow.
|
|
20
|
+
required: false
|
|
21
|
+
default: 'true'
|
|
22
|
+
github-token:
|
|
23
|
+
description: Token used for pull request comments.
|
|
24
|
+
required: false
|
|
25
|
+
default: '${{ github.token }}'
|
|
26
|
+
|
|
27
|
+
outputs:
|
|
28
|
+
errors:
|
|
29
|
+
description: Number of error-level checks.
|
|
30
|
+
warnings:
|
|
31
|
+
description: Number of warning-level checks.
|
|
32
|
+
info:
|
|
33
|
+
description: Number of informational checks.
|
|
34
|
+
outcome:
|
|
35
|
+
description: healthy or issues-found.
|
|
36
|
+
|
|
37
|
+
runs:
|
|
38
|
+
using: node20
|
|
39
|
+
main: action/dist/index.js
|