git-daemon 0.1.8 → 0.1.10
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 +38 -1
- package/config.schema.json +19 -0
- package/design.md +160 -2
- package/dist/app.js +221 -4
- package/dist/approvals.js +4 -1
- package/dist/config.js +11 -0
- package/dist/context.js +3 -0
- package/dist/git.js +117 -8
- package/dist/healthchecks.js +425 -0
- package/dist/jobs.js +1 -0
- package/dist/validation.js +20 -1
- package/healthchecks/example-suite/README.md +10 -0
- package/healthchecks/example-suite/about-description/healthcheck.json +17 -0
- package/healthchecks/example-suite/about-description/run.js +241 -0
- package/healthchecks/example-suite/package-manager/healthcheck.json +17 -0
- package/healthchecks/example-suite/package-manager/run.js +106 -0
- package/healthchecks/example-suite/suite.json +6 -0
- package/openapi.yaml +347 -0
- package/package.json +1 -1
- package/src/app.ts +150 -0
- package/src/config.ts +11 -0
- package/src/context.ts +3 -0
- package/src/git.ts +104 -8
- package/src/healthchecks.ts +620 -0
- package/src/jobs.ts +2 -0
- package/src/types.ts +5 -1
- package/src/validation.ts +18 -0
- package/tests/app.test.ts +223 -16
package/dist/git.js
CHANGED
|
@@ -36,7 +36,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
36
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
-
exports.resolveRepoPath = exports.getRepoStatus = exports.fetchRepo = exports.cloneRepo = exports.RepoNotFoundError = void 0;
|
|
39
|
+
exports.resolveRepoPath = exports.listRepoBranches = exports.getRepoSummary = exports.getRepoStatus = exports.fetchRepo = exports.cloneRepo = exports.RepoNotFoundError = void 0;
|
|
40
40
|
const path_1 = __importDefault(require("path"));
|
|
41
41
|
const fs_1 = require("fs");
|
|
42
42
|
const process_1 = require("./process");
|
|
@@ -79,6 +79,56 @@ const getRepoStatus = async (workspaceRoot, repoPath) => {
|
|
|
79
79
|
return parseStatus(result.stdout);
|
|
80
80
|
};
|
|
81
81
|
exports.getRepoStatus = getRepoStatus;
|
|
82
|
+
const getRepoSummary = async (workspaceRoot, repoPath) => {
|
|
83
|
+
const resolved = await (0, exports.resolveRepoPath)(workspaceRoot, repoPath);
|
|
84
|
+
const { execa } = await Promise.resolve().then(() => __importStar(require("execa")));
|
|
85
|
+
const result = await execa("git", [
|
|
86
|
+
"-C",
|
|
87
|
+
resolved,
|
|
88
|
+
"status",
|
|
89
|
+
"--porcelain=2",
|
|
90
|
+
"-b",
|
|
91
|
+
]);
|
|
92
|
+
const info = parseStatusInfo(result.stdout);
|
|
93
|
+
const dirty = info.stagedCount > 0 ||
|
|
94
|
+
info.unstagedCount > 0 ||
|
|
95
|
+
info.untrackedCount > 0 ||
|
|
96
|
+
info.conflictsCount > 0;
|
|
97
|
+
const upstream = info.detached ? null : info.upstream;
|
|
98
|
+
return {
|
|
99
|
+
repoPath,
|
|
100
|
+
exists: true,
|
|
101
|
+
branch: info.branch,
|
|
102
|
+
upstream,
|
|
103
|
+
ahead: upstream ? info.ahead : 0,
|
|
104
|
+
behind: upstream ? info.behind : 0,
|
|
105
|
+
dirty,
|
|
106
|
+
staged: info.stagedCount,
|
|
107
|
+
unstaged: info.unstagedCount,
|
|
108
|
+
untracked: info.untrackedCount,
|
|
109
|
+
conflicts: info.conflictsCount,
|
|
110
|
+
detached: info.detached,
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
exports.getRepoSummary = getRepoSummary;
|
|
114
|
+
const listRepoBranches = async (workspaceRoot, repoPath, options) => {
|
|
115
|
+
const resolved = await (0, exports.resolveRepoPath)(workspaceRoot, repoPath);
|
|
116
|
+
const includeRemote = options?.includeRemote ?? true;
|
|
117
|
+
const { execa } = await Promise.resolve().then(() => __importStar(require("execa")));
|
|
118
|
+
const refs = ["refs/heads"];
|
|
119
|
+
if (includeRemote) {
|
|
120
|
+
refs.push("refs/remotes");
|
|
121
|
+
}
|
|
122
|
+
const result = await execa("git", [
|
|
123
|
+
"-C",
|
|
124
|
+
resolved,
|
|
125
|
+
"for-each-ref",
|
|
126
|
+
"--format=%(refname)\t%(refname:short)\t%(objectname)\t%(HEAD)\t%(upstream:short)",
|
|
127
|
+
...refs,
|
|
128
|
+
]);
|
|
129
|
+
return parseBranches(result.stdout);
|
|
130
|
+
};
|
|
131
|
+
exports.listRepoBranches = listRepoBranches;
|
|
82
132
|
const resolveRepoPath = async (workspaceRoot, repoPath) => {
|
|
83
133
|
const resolved = await (0, workspace_1.resolveInsideWorkspace)(workspaceRoot, repoPath);
|
|
84
134
|
await assertRepoExists(resolved);
|
|
@@ -99,7 +149,26 @@ const assertRepoExists = async (repoPath) => {
|
|
|
99
149
|
}
|
|
100
150
|
};
|
|
101
151
|
const parseStatus = (output) => {
|
|
102
|
-
|
|
152
|
+
const info = parseStatusInfo(output);
|
|
153
|
+
const clean = info.stagedCount === 0 &&
|
|
154
|
+
info.unstagedCount === 0 &&
|
|
155
|
+
info.untrackedCount === 0 &&
|
|
156
|
+
info.conflictsCount === 0;
|
|
157
|
+
return {
|
|
158
|
+
branch: info.branch,
|
|
159
|
+
ahead: info.ahead,
|
|
160
|
+
behind: info.behind,
|
|
161
|
+
stagedCount: info.stagedCount,
|
|
162
|
+
unstagedCount: info.unstagedCount,
|
|
163
|
+
untrackedCount: info.untrackedCount,
|
|
164
|
+
conflictsCount: info.conflictsCount,
|
|
165
|
+
clean,
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
const parseStatusInfo = (output) => {
|
|
169
|
+
let head = "";
|
|
170
|
+
let oid = "";
|
|
171
|
+
let upstream = null;
|
|
103
172
|
let ahead = 0;
|
|
104
173
|
let behind = 0;
|
|
105
174
|
let stagedCount = 0;
|
|
@@ -109,7 +178,16 @@ const parseStatus = (output) => {
|
|
|
109
178
|
const lines = output.split(/\r?\n/);
|
|
110
179
|
for (const line of lines) {
|
|
111
180
|
if (line.startsWith("# branch.head")) {
|
|
112
|
-
|
|
181
|
+
head = line.split(" ").slice(2).join(" ").trim();
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (line.startsWith("# branch.oid")) {
|
|
185
|
+
oid = line.split(" ").slice(2).join(" ").trim();
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (line.startsWith("# branch.upstream")) {
|
|
189
|
+
const value = line.split(" ").slice(2).join(" ").trim();
|
|
190
|
+
upstream = value.length > 0 ? value : null;
|
|
113
191
|
continue;
|
|
114
192
|
}
|
|
115
193
|
if (line.startsWith("# branch.ab")) {
|
|
@@ -139,18 +217,49 @@ const parseStatus = (output) => {
|
|
|
139
217
|
}
|
|
140
218
|
}
|
|
141
219
|
}
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
220
|
+
const detached = head === "(detached)";
|
|
221
|
+
let branch = head;
|
|
222
|
+
if (detached) {
|
|
223
|
+
branch = oid || head;
|
|
224
|
+
upstream = null;
|
|
225
|
+
}
|
|
146
226
|
return {
|
|
147
227
|
branch,
|
|
228
|
+
upstream,
|
|
148
229
|
ahead,
|
|
149
230
|
behind,
|
|
150
231
|
stagedCount,
|
|
151
232
|
unstagedCount,
|
|
152
233
|
untrackedCount,
|
|
153
234
|
conflictsCount,
|
|
154
|
-
|
|
235
|
+
detached,
|
|
155
236
|
};
|
|
156
237
|
};
|
|
238
|
+
const parseBranches = (output) => {
|
|
239
|
+
const branches = [];
|
|
240
|
+
const lines = output.split(/\r?\n/);
|
|
241
|
+
for (const line of lines) {
|
|
242
|
+
if (!line) {
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const [fullName, shortName, , headFlag, upstream] = line.split("\t");
|
|
246
|
+
if (!fullName || !shortName) {
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (fullName.startsWith("refs/remotes/") && fullName.endsWith("/HEAD")) {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const type = fullName.startsWith("refs/heads/") ? "local" : "remote";
|
|
253
|
+
const branch = {
|
|
254
|
+
name: shortName,
|
|
255
|
+
fullName,
|
|
256
|
+
type,
|
|
257
|
+
current: headFlag === "*",
|
|
258
|
+
};
|
|
259
|
+
if (upstream) {
|
|
260
|
+
branch.upstream = upstream;
|
|
261
|
+
}
|
|
262
|
+
branches.push(branch);
|
|
263
|
+
}
|
|
264
|
+
return branches;
|
|
265
|
+
};
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.HealthcheckResultStore = exports.runHealthchecks = exports.resolveSelections = exports.listHealthcheckSuites = exports.loadHealthcheckSuites = void 0;
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
const fs_1 = require("fs");
|
|
9
|
+
const execa_1 = require("execa");
|
|
10
|
+
const tree_kill_1 = __importDefault(require("tree-kill"));
|
|
11
|
+
const STATUS_ORDER = {
|
|
12
|
+
failed: 3,
|
|
13
|
+
"pass-partial": 2,
|
|
14
|
+
"pass-full": 1,
|
|
15
|
+
na: 0,
|
|
16
|
+
};
|
|
17
|
+
const IGNORED_DIRS = new Set(["node_modules", ".git", "dist", "logs"]);
|
|
18
|
+
const readJsonFile = async (filePath) => {
|
|
19
|
+
const raw = await fs_1.promises.readFile(filePath, "utf8");
|
|
20
|
+
return JSON.parse(raw);
|
|
21
|
+
};
|
|
22
|
+
const getString = (value) => typeof value === "string" && value.trim() ? value.trim() : null;
|
|
23
|
+
const getNumber = (value) => typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
24
|
+
const getBoolean = (value) => typeof value === "boolean" ? value : null;
|
|
25
|
+
const normalizeSuiteMeta = async (root) => {
|
|
26
|
+
const suitePath = path_1.default.join(root, "suite.json");
|
|
27
|
+
try {
|
|
28
|
+
const parsed = await readJsonFile(suitePath);
|
|
29
|
+
const id = getString(parsed.id) ?? path_1.default.basename(root);
|
|
30
|
+
const name = getString(parsed.name) ?? id;
|
|
31
|
+
return {
|
|
32
|
+
id,
|
|
33
|
+
name,
|
|
34
|
+
version: getString(parsed.version) ?? undefined,
|
|
35
|
+
description: getString(parsed.description) ?? undefined,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
const id = path_1.default.basename(root);
|
|
40
|
+
return { id, name: id, version: undefined, description: undefined };
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const collectManifests = async (root) => {
|
|
44
|
+
const results = [];
|
|
45
|
+
const walk = async (dir) => {
|
|
46
|
+
const entries = await fs_1.promises.readdir(dir, { withFileTypes: true });
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
if (entry.name.startsWith(".") && entry.isDirectory()) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const entryPath = path_1.default.join(dir, entry.name);
|
|
52
|
+
if (entry.isDirectory()) {
|
|
53
|
+
if (IGNORED_DIRS.has(entry.name)) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
await walk(entryPath);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (entry.isFile() && entry.name === "healthcheck.json") {
|
|
60
|
+
results.push(entryPath);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
await walk(root);
|
|
65
|
+
return results;
|
|
66
|
+
};
|
|
67
|
+
const parseManifest = async (manifestPath) => {
|
|
68
|
+
let parsed;
|
|
69
|
+
try {
|
|
70
|
+
parsed = await readJsonFile(manifestPath);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const id = getString(parsed.id);
|
|
76
|
+
const name = getString(parsed.name) ?? id;
|
|
77
|
+
const version = getString(parsed.version);
|
|
78
|
+
const description = getString(parsed.description) ?? "";
|
|
79
|
+
const entrypoint = getString(parsed.entrypoint);
|
|
80
|
+
if (!id || !name || !version || !entrypoint) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const root = path_1.default.dirname(manifestPath);
|
|
84
|
+
const entrypointPath = path_1.default.resolve(root, entrypoint);
|
|
85
|
+
try {
|
|
86
|
+
await fs_1.promises.access(entrypointPath);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const argsValue = Array.isArray(parsed.args)
|
|
92
|
+
? parsed.args.filter((item) => typeof item === "string")
|
|
93
|
+
: [];
|
|
94
|
+
const timeoutSeconds = getNumber(parsed.timeoutSeconds) ?? 60;
|
|
95
|
+
const cacheable = getBoolean(parsed.cacheable) ?? false;
|
|
96
|
+
const cacheMaxAgeSeconds = getNumber(parsed.cacheMaxAgeSeconds) ?? undefined;
|
|
97
|
+
return {
|
|
98
|
+
id,
|
|
99
|
+
name,
|
|
100
|
+
version,
|
|
101
|
+
description,
|
|
102
|
+
entrypointPath,
|
|
103
|
+
args: argsValue,
|
|
104
|
+
timeoutSeconds,
|
|
105
|
+
cacheable,
|
|
106
|
+
cacheMaxAgeSeconds,
|
|
107
|
+
permissions: parsed.permissions,
|
|
108
|
+
configSchema: parsed.configSchema,
|
|
109
|
+
outputSchemaVersion: getNumber(parsed.outputSchemaVersion) ?? undefined,
|
|
110
|
+
root,
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
const loadHealthcheckSuites = async (configDir, suitePaths) => {
|
|
114
|
+
const suites = [];
|
|
115
|
+
for (const suitePath of suitePaths) {
|
|
116
|
+
const resolved = path_1.default.isAbsolute(suitePath)
|
|
117
|
+
? suitePath
|
|
118
|
+
: path_1.default.resolve(configDir, suitePath);
|
|
119
|
+
let stats;
|
|
120
|
+
try {
|
|
121
|
+
stats = await fs_1.promises.stat(resolved);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (!stats.isDirectory()) {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const meta = await normalizeSuiteMeta(resolved);
|
|
130
|
+
const manifestPaths = await collectManifests(resolved);
|
|
131
|
+
const checks = [];
|
|
132
|
+
for (const manifestPath of manifestPaths) {
|
|
133
|
+
const manifest = await parseManifest(manifestPath);
|
|
134
|
+
if (manifest) {
|
|
135
|
+
checks.push(manifest);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
suites.push({
|
|
139
|
+
...meta,
|
|
140
|
+
root: resolved,
|
|
141
|
+
checks,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return suites;
|
|
145
|
+
};
|
|
146
|
+
exports.loadHealthcheckSuites = loadHealthcheckSuites;
|
|
147
|
+
const listHealthcheckSuites = (suites) => suites.map((suite) => ({
|
|
148
|
+
id: suite.id,
|
|
149
|
+
name: suite.name,
|
|
150
|
+
version: suite.version,
|
|
151
|
+
description: suite.description,
|
|
152
|
+
checks: suite.checks.map((check) => ({
|
|
153
|
+
id: check.id,
|
|
154
|
+
name: check.name,
|
|
155
|
+
version: check.version,
|
|
156
|
+
description: check.description,
|
|
157
|
+
timeoutSeconds: check.timeoutSeconds,
|
|
158
|
+
cacheable: check.cacheable,
|
|
159
|
+
cacheMaxAgeSeconds: check.cacheMaxAgeSeconds,
|
|
160
|
+
permissions: check.permissions,
|
|
161
|
+
configSchema: check.configSchema,
|
|
162
|
+
outputSchemaVersion: check.outputSchemaVersion,
|
|
163
|
+
})),
|
|
164
|
+
}));
|
|
165
|
+
exports.listHealthcheckSuites = listHealthcheckSuites;
|
|
166
|
+
const resolveSelections = (suites, request, defaultSuiteId) => {
|
|
167
|
+
if (suites.length === 0) {
|
|
168
|
+
throw new Error("No healthcheck suites configured.");
|
|
169
|
+
}
|
|
170
|
+
const suiteId = request.suiteId ??
|
|
171
|
+
defaultSuiteId ??
|
|
172
|
+
(suites.length === 1 ? suites[0].id : null);
|
|
173
|
+
const suiteById = new Map(suites.map((suite) => [suite.id, suite]));
|
|
174
|
+
const selections = [];
|
|
175
|
+
if (request.checks && request.checks.length > 0) {
|
|
176
|
+
for (const selection of request.checks) {
|
|
177
|
+
const targetSuiteId = selection.suiteId ?? suiteId;
|
|
178
|
+
if (!targetSuiteId) {
|
|
179
|
+
throw new Error("Suite id is required to select healthchecks.");
|
|
180
|
+
}
|
|
181
|
+
const suite = suiteById.get(targetSuiteId);
|
|
182
|
+
if (!suite) {
|
|
183
|
+
throw new Error(`Healthcheck suite not found: ${targetSuiteId}.`);
|
|
184
|
+
}
|
|
185
|
+
const check = suite.checks.find((item) => item.id === selection.checkId);
|
|
186
|
+
if (!check) {
|
|
187
|
+
throw new Error(`Healthcheck not found: ${targetSuiteId}/${selection.checkId}.`);
|
|
188
|
+
}
|
|
189
|
+
selections.push({
|
|
190
|
+
suiteId: targetSuiteId,
|
|
191
|
+
check,
|
|
192
|
+
config: selection.config,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
if (!suiteId) {
|
|
198
|
+
throw new Error("Suite id is required to run healthchecks.");
|
|
199
|
+
}
|
|
200
|
+
const suite = suiteById.get(suiteId);
|
|
201
|
+
if (!suite) {
|
|
202
|
+
throw new Error(`Healthcheck suite not found: ${suiteId}.`);
|
|
203
|
+
}
|
|
204
|
+
for (const check of suite.checks) {
|
|
205
|
+
selections.push({ suiteId, check });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return selections;
|
|
209
|
+
};
|
|
210
|
+
exports.resolveSelections = resolveSelections;
|
|
211
|
+
const attachLineReader = (stream, onLine) => {
|
|
212
|
+
if (!stream) {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
let buffer = "";
|
|
216
|
+
stream.on("data", (chunk) => {
|
|
217
|
+
buffer += chunk.toString();
|
|
218
|
+
const lines = buffer.split(/\r?\n/);
|
|
219
|
+
buffer = lines.pop() ?? "";
|
|
220
|
+
for (const line of lines) {
|
|
221
|
+
if (line.length > 0) {
|
|
222
|
+
onLine(line);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
stream.on("end", () => {
|
|
227
|
+
if (buffer.length > 0) {
|
|
228
|
+
onLine(buffer);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
};
|
|
232
|
+
const parseOutput = (stdout) => {
|
|
233
|
+
const trimmed = stdout.trim();
|
|
234
|
+
if (!trimmed) {
|
|
235
|
+
return { error: "No output provided." };
|
|
236
|
+
}
|
|
237
|
+
const attempt = (value) => {
|
|
238
|
+
try {
|
|
239
|
+
return { value: JSON.parse(value) };
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
return {
|
|
243
|
+
error: err instanceof Error ? err.message : "Invalid JSON output.",
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
const first = attempt(trimmed);
|
|
248
|
+
if (!first.error) {
|
|
249
|
+
return first;
|
|
250
|
+
}
|
|
251
|
+
const lines = trimmed.split(/\r?\n/).filter(Boolean);
|
|
252
|
+
if (lines.length > 1) {
|
|
253
|
+
return attempt(lines[lines.length - 1]);
|
|
254
|
+
}
|
|
255
|
+
return first;
|
|
256
|
+
};
|
|
257
|
+
const normalizeStatus = (value) => {
|
|
258
|
+
if (value === "na" ||
|
|
259
|
+
value === "failed" ||
|
|
260
|
+
value === "pass-partial" ||
|
|
261
|
+
value === "pass-full") {
|
|
262
|
+
return value;
|
|
263
|
+
}
|
|
264
|
+
return null;
|
|
265
|
+
};
|
|
266
|
+
const buildFailureResult = (suiteId, checkId, summary, explanation, details) => ({
|
|
267
|
+
suiteId,
|
|
268
|
+
checkId,
|
|
269
|
+
status: "failed",
|
|
270
|
+
summary,
|
|
271
|
+
explanation,
|
|
272
|
+
details,
|
|
273
|
+
cached: false,
|
|
274
|
+
});
|
|
275
|
+
const runCheck = async (ctx, selection, repoPath, repoInfo) => {
|
|
276
|
+
const { check, suiteId } = selection;
|
|
277
|
+
const start = Date.now();
|
|
278
|
+
ctx.progress({
|
|
279
|
+
kind: "healthcheck",
|
|
280
|
+
detail: `${check.id}: running`,
|
|
281
|
+
});
|
|
282
|
+
const env = {
|
|
283
|
+
...process.env,
|
|
284
|
+
REPO_PATH: repoPath,
|
|
285
|
+
};
|
|
286
|
+
if (repoInfo) {
|
|
287
|
+
env.REPO_INFO = JSON.stringify(repoInfo);
|
|
288
|
+
}
|
|
289
|
+
if (selection.config !== undefined) {
|
|
290
|
+
env.CHECK_CONFIG = JSON.stringify(selection.config);
|
|
291
|
+
}
|
|
292
|
+
const subprocess = (0, execa_1.execa)(check.entrypointPath, check.args, {
|
|
293
|
+
cwd: repoPath,
|
|
294
|
+
env,
|
|
295
|
+
timeout: check.timeoutSeconds * 1000,
|
|
296
|
+
stdout: "pipe",
|
|
297
|
+
stderr: "pipe",
|
|
298
|
+
});
|
|
299
|
+
const pid = subprocess.pid;
|
|
300
|
+
if (pid) {
|
|
301
|
+
ctx.setCancel(() => new Promise((resolve) => {
|
|
302
|
+
(0, tree_kill_1.default)(pid, "SIGTERM", () => resolve());
|
|
303
|
+
}));
|
|
304
|
+
}
|
|
305
|
+
attachLineReader(subprocess.stderr, ctx.logStderr);
|
|
306
|
+
let stdout = "";
|
|
307
|
+
if (subprocess.stdout) {
|
|
308
|
+
subprocess.stdout.on("data", (chunk) => {
|
|
309
|
+
stdout += chunk.toString();
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
await subprocess;
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
const message = err instanceof Error ? err.message : "Healthcheck failed.";
|
|
317
|
+
if (err.stdout) {
|
|
318
|
+
stdout += String(err.stdout);
|
|
319
|
+
}
|
|
320
|
+
return buildFailureResult(suiteId, check.id, "Healthcheck failed to run", message);
|
|
321
|
+
}
|
|
322
|
+
const parsed = parseOutput(stdout);
|
|
323
|
+
if (parsed.error || !parsed.value) {
|
|
324
|
+
return buildFailureResult(suiteId, check.id, "Invalid healthcheck output", parsed.error ?? "Unable to parse healthcheck output.");
|
|
325
|
+
}
|
|
326
|
+
const status = normalizeStatus(parsed.value.status);
|
|
327
|
+
const summary = getString(parsed.value.summary);
|
|
328
|
+
const explanation = getString(parsed.value.explanation);
|
|
329
|
+
if (!status || !summary || !explanation) {
|
|
330
|
+
return buildFailureResult(suiteId, check.id, "Invalid healthcheck output", "Healthcheck output is missing required fields.");
|
|
331
|
+
}
|
|
332
|
+
const details = Array.isArray(parsed.value.details)
|
|
333
|
+
? parsed.value.details
|
|
334
|
+
: undefined;
|
|
335
|
+
const metrics = parsed.value.metrics && typeof parsed.value.metrics === "object"
|
|
336
|
+
? parsed.value.metrics
|
|
337
|
+
: undefined;
|
|
338
|
+
const artifacts = Array.isArray(parsed.value.artifacts)
|
|
339
|
+
? parsed.value.artifacts
|
|
340
|
+
: undefined;
|
|
341
|
+
const durationMs = getNumber(parsed.value.durationMs) ?? Math.max(0, Date.now() - start);
|
|
342
|
+
return {
|
|
343
|
+
suiteId,
|
|
344
|
+
checkId: check.id,
|
|
345
|
+
status,
|
|
346
|
+
summary,
|
|
347
|
+
explanation,
|
|
348
|
+
details,
|
|
349
|
+
metrics,
|
|
350
|
+
artifacts,
|
|
351
|
+
durationMs,
|
|
352
|
+
cached: false,
|
|
353
|
+
};
|
|
354
|
+
};
|
|
355
|
+
const summarize = (checks) => {
|
|
356
|
+
const counts = {
|
|
357
|
+
failed: 0,
|
|
358
|
+
"pass-partial": 0,
|
|
359
|
+
"pass-full": 0,
|
|
360
|
+
na: 0,
|
|
361
|
+
};
|
|
362
|
+
for (const check of checks) {
|
|
363
|
+
counts[check.status] += 1;
|
|
364
|
+
}
|
|
365
|
+
return `failed ${counts.failed}, partial ${counts["pass-partial"]}, full ${counts["pass-full"]}, na ${counts.na}`;
|
|
366
|
+
};
|
|
367
|
+
const aggregateStatus = (checks) => {
|
|
368
|
+
let winner = "na";
|
|
369
|
+
for (const check of checks) {
|
|
370
|
+
if (STATUS_ORDER[check.status] > STATUS_ORDER[winner]) {
|
|
371
|
+
winner = check.status;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return winner;
|
|
375
|
+
};
|
|
376
|
+
const runHealthchecks = async (ctx, jobId, repoPath, selections, repoInfo) => {
|
|
377
|
+
const startedAt = new Date().toISOString();
|
|
378
|
+
const results = [];
|
|
379
|
+
for (const selection of selections) {
|
|
380
|
+
if (ctx.isCancelled()) {
|
|
381
|
+
break;
|
|
382
|
+
}
|
|
383
|
+
const result = await runCheck(ctx, selection, repoPath, repoInfo);
|
|
384
|
+
results.push(result);
|
|
385
|
+
ctx.progress({
|
|
386
|
+
kind: "healthcheck",
|
|
387
|
+
detail: `${selection.check.id}: ${result.status}`,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
const finishedAt = new Date().toISOString();
|
|
391
|
+
const status = aggregateStatus(results);
|
|
392
|
+
return {
|
|
393
|
+
jobId,
|
|
394
|
+
repoPath,
|
|
395
|
+
status,
|
|
396
|
+
summary: summarize(results),
|
|
397
|
+
checks: results,
|
|
398
|
+
startedAt,
|
|
399
|
+
finishedAt,
|
|
400
|
+
};
|
|
401
|
+
};
|
|
402
|
+
exports.runHealthchecks = runHealthchecks;
|
|
403
|
+
class HealthcheckResultStore {
|
|
404
|
+
constructor(maxEntries = 200) {
|
|
405
|
+
this.results = new Map();
|
|
406
|
+
this.order = [];
|
|
407
|
+
this.maxEntries = maxEntries;
|
|
408
|
+
}
|
|
409
|
+
get(jobId) {
|
|
410
|
+
return this.results.get(jobId);
|
|
411
|
+
}
|
|
412
|
+
set(jobId, result) {
|
|
413
|
+
if (!this.results.has(jobId)) {
|
|
414
|
+
this.order.push(jobId);
|
|
415
|
+
}
|
|
416
|
+
this.results.set(jobId, result);
|
|
417
|
+
while (this.order.length > this.maxEntries) {
|
|
418
|
+
const oldest = this.order.shift();
|
|
419
|
+
if (oldest) {
|
|
420
|
+
this.results.delete(oldest);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
exports.HealthcheckResultStore = HealthcheckResultStore;
|
package/dist/jobs.js
CHANGED
|
@@ -124,6 +124,7 @@ class JobManager {
|
|
|
124
124
|
}, this.timeoutMs);
|
|
125
125
|
}
|
|
126
126
|
const ctx = {
|
|
127
|
+
jobId: job.id,
|
|
127
128
|
logStdout: (line) => job.emit({ type: "log", stream: "stdout", line }),
|
|
128
129
|
logStderr: (line) => job.emit({ type: "log", stream: "stderr", line }),
|
|
129
130
|
progress: (event) => job.emit({ type: "progress", ...event }),
|
package/dist/validation.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.depsInstallRequestSchema = exports.osOpenRequestSchema = exports.gitStatusQuerySchema = exports.gitFetchRequestSchema = exports.gitCloneRequestSchema = exports.pairRequestSchema = void 0;
|
|
3
|
+
exports.healthcheckRunRequestSchema = exports.depsInstallRequestSchema = exports.osOpenRequestSchema = exports.gitBranchesQuerySchema = exports.gitSummaryQuerySchema = exports.gitStatusQuerySchema = exports.gitFetchRequestSchema = exports.gitCloneRequestSchema = exports.pairRequestSchema = void 0;
|
|
4
4
|
const zod_1 = require("zod");
|
|
5
5
|
const MAX_PATH_LENGTH = 4096;
|
|
6
6
|
const isValidRepoUrl = (value) => {
|
|
@@ -50,6 +50,13 @@ exports.gitFetchRequestSchema = zod_1.z.object({
|
|
|
50
50
|
exports.gitStatusQuerySchema = zod_1.z.object({
|
|
51
51
|
repoPath: zod_1.z.string().min(1).max(MAX_PATH_LENGTH),
|
|
52
52
|
});
|
|
53
|
+
exports.gitSummaryQuerySchema = zod_1.z.object({
|
|
54
|
+
repoPath: zod_1.z.string().min(1).max(MAX_PATH_LENGTH),
|
|
55
|
+
});
|
|
56
|
+
exports.gitBranchesQuerySchema = zod_1.z.object({
|
|
57
|
+
repoPath: zod_1.z.string().min(1).max(MAX_PATH_LENGTH),
|
|
58
|
+
includeRemote: zod_1.z.enum(["true", "false"]).optional(),
|
|
59
|
+
});
|
|
53
60
|
exports.osOpenRequestSchema = zod_1.z.object({
|
|
54
61
|
target: zod_1.z.enum(["folder", "terminal", "vscode"]),
|
|
55
62
|
path: zod_1.z.string().min(1).max(MAX_PATH_LENGTH),
|
|
@@ -60,3 +67,15 @@ exports.depsInstallRequestSchema = zod_1.z.object({
|
|
|
60
67
|
mode: zod_1.z.enum(["auto", "ci", "install"]).optional(),
|
|
61
68
|
safer: zod_1.z.boolean().optional(),
|
|
62
69
|
});
|
|
70
|
+
const healthcheckSelectionSchema = zod_1.z.object({
|
|
71
|
+
suiteId: zod_1.z.string().min(1).optional(),
|
|
72
|
+
checkId: zod_1.z.string().min(1),
|
|
73
|
+
config: zod_1.z.record(zod_1.z.unknown()).optional(),
|
|
74
|
+
cacheMode: zod_1.z.enum(["prefer", "refresh", "bypass"]).optional(),
|
|
75
|
+
});
|
|
76
|
+
exports.healthcheckRunRequestSchema = zod_1.z.object({
|
|
77
|
+
repoPath: zod_1.z.string().min(1).max(MAX_PATH_LENGTH),
|
|
78
|
+
repoInfo: zod_1.z.record(zod_1.z.unknown()).optional(),
|
|
79
|
+
suiteId: zod_1.z.string().min(1).optional(),
|
|
80
|
+
checks: zod_1.z.array(healthcheckSelectionSchema).optional(),
|
|
81
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Example Healthchecks
|
|
2
|
+
|
|
3
|
+
This suite includes two sample checks:
|
|
4
|
+
|
|
5
|
+
- `about-description`: Verify the GitHub repo description matches the first
|
|
6
|
+
sentence of the README (ignores headings and badges).
|
|
7
|
+
- `package-manager`: If a repo has `package.json`, require a `packageManager`
|
|
8
|
+
field.
|
|
9
|
+
|
|
10
|
+
Each check is defined by a `healthcheck.json` manifest and a `run.js` entrypoint.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "about-description",
|
|
3
|
+
"name": "About description matches README",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "Checks that the GitHub description matches the first README sentence.",
|
|
6
|
+
"entrypoint": "./run.js",
|
|
7
|
+
"args": [],
|
|
8
|
+
"timeoutSeconds": 30,
|
|
9
|
+
"permissions": {
|
|
10
|
+
"repoRead": true,
|
|
11
|
+
"network": false,
|
|
12
|
+
"secrets": []
|
|
13
|
+
},
|
|
14
|
+
"cacheable": true,
|
|
15
|
+
"cacheMaxAgeSeconds": 3600,
|
|
16
|
+
"outputSchemaVersion": 1
|
|
17
|
+
}
|