@magland/mochi 0.3.1
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/LICENSE +201 -0
- package/README.md +108 -0
- package/dist/ansi.js +174 -0
- package/dist/api/admin.js +416 -0
- package/dist/api/auth.js +166 -0
- package/dist/api/backup.js +598 -0
- package/dist/api/ci.js +336 -0
- package/dist/api/contents.js +339 -0
- package/dist/api/issues.js +165 -0
- package/dist/api/pulls.js +244 -0
- package/dist/api/releases.js +83 -0
- package/dist/api/repos.js +156 -0
- package/dist/api/write.js +518 -0
- package/dist/api.js +326 -0
- package/dist/assets.js +29 -0
- package/dist/atom.js +32 -0
- package/dist/atomic.js +171 -0
- package/dist/avatar.js +81 -0
- package/dist/browse.js +630 -0
- package/dist/build-info.json +4 -0
- package/dist/ci/actionref.js +86 -0
- package/dist/ci/api.js +829 -0
- package/dist/ci/artifacts.js +201 -0
- package/dist/ci/dispatch.js +30 -0
- package/dist/ci/engine.js +1321 -0
- package/dist/ci/expr.js +526 -0
- package/dist/ci/manual.js +199 -0
- package/dist/ci/present.js +82 -0
- package/dist/ci/protocol.js +6 -0
- package/dist/ci/runners.js +256 -0
- package/dist/ci/runs.js +208 -0
- package/dist/ci/trigger.js +28 -0
- package/dist/ci/views.js +441 -0
- package/dist/ci/wake.js +194 -0
- package/dist/ci/web.js +617 -0
- package/dist/ci/workflow.js +436 -0
- package/dist/cli/admin-cmd.js +324 -0
- package/dist/cli/api-cmd.js +128 -0
- package/dist/cli/backup-cmd.js +1500 -0
- package/dist/cli/exit.js +69 -0
- package/dist/cli/input.js +64 -0
- package/dist/cli/issue-cmd.js +243 -0
- package/dist/cli/output.js +93 -0
- package/dist/cli/parse.js +317 -0
- package/dist/cli/pr-cmd.js +289 -0
- package/dist/cli/release-cmd.js +171 -0
- package/dist/cli/repo-cmd.js +763 -0
- package/dist/cli/repo.js +101 -0
- package/dist/cli/run-cmd.js +438 -0
- package/dist/cli/target.js +54 -0
- package/dist/cli-api.js +84 -0
- package/dist/compare.js +111 -0
- package/dist/config.js +212 -0
- package/dist/credentials.js +235 -0
- package/dist/deploy-cli.js +859 -0
- package/dist/deploy-runner-cli.js +592 -0
- package/dist/diff.js +171 -0
- package/dist/discussion.js +253 -0
- package/dist/egress.js +559 -0
- package/dist/filecache.js +68 -0
- package/dist/find.js +162 -0
- package/dist/forms.js +737 -0
- package/dist/git.js +547 -0
- package/dist/githttp.js +428 -0
- package/dist/html.js +87 -0
- package/dist/icons.js +101 -0
- package/dist/import-cli.js +316 -0
- package/dist/index.js +752 -0
- package/dist/issues.js +308 -0
- package/dist/issueweb.js +447 -0
- package/dist/job-cli.js +197 -0
- package/dist/jobtoken.js +96 -0
- package/dist/languages.js +383 -0
- package/dist/layout.js +100 -0
- package/dist/lfs.js +438 -0
- package/dist/lfsstore.js +425 -0
- package/dist/limit.js +259 -0
- package/dist/logo.js +61 -0
- package/dist/markdown.js +382 -0
- package/dist/migrate.js +334 -0
- package/dist/multipart.js +90 -0
- package/dist/ops.js +869 -0
- package/dist/pagescript.js +465 -0
- package/dist/perms.js +370 -0
- package/dist/pointer.js +55 -0
- package/dist/profile.js +106 -0
- package/dist/pulls.js +320 -0
- package/dist/pullweb.js +461 -0
- package/dist/redirects.js +455 -0
- package/dist/releases.js +435 -0
- package/dist/render.js +233 -0
- package/dist/runner/actions.js +448 -0
- package/dist/runner/client.js +428 -0
- package/dist/runner/context.js +247 -0
- package/dist/runner/docker.js +197 -0
- package/dist/runner/externals.js +175 -0
- package/dist/runner/job.js +290 -0
- package/dist/runner/manual-run.js +272 -0
- package/dist/runner/overrides.js +554 -0
- package/dist/runner/steps.js +571 -0
- package/dist/runner/wake.js +84 -0
- package/dist/runner-cli.js +405 -0
- package/dist/scan.js +231 -0
- package/dist/server.js +424 -0
- package/dist/session.js +267 -0
- package/dist/site.js +259 -0
- package/dist/siteshost.js +94 -0
- package/dist/source.js +90 -0
- package/dist/style.js +1295 -0
- package/dist/themes.js +369 -0
- package/dist/vault.js +442 -0
- package/dist/version.js +88 -0
- package/dist/views.js +1007 -0
- package/dist/web.js +182 -0
- package/dist/webops.js +1402 -0
- package/package.json +71 -0
package/dist/api/ci.js
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.registerCiRunApi = registerCiRunApi;
|
|
37
|
+
const fs = __importStar(require("fs"));
|
|
38
|
+
const path = __importStar(require("path"));
|
|
39
|
+
const artifacts_1 = require("../ci/artifacts");
|
|
40
|
+
const dispatch_1 = require("../ci/dispatch");
|
|
41
|
+
const engine_1 = require("../ci/engine");
|
|
42
|
+
const manual_1 = require("../ci/manual");
|
|
43
|
+
const runs_1 = require("../ci/runs");
|
|
44
|
+
const git_1 = require("../git");
|
|
45
|
+
const version_1 = require("../version");
|
|
46
|
+
const web_1 = require("../web");
|
|
47
|
+
const auth_1 = require("./auth");
|
|
48
|
+
// Workflows and their runs.
|
|
49
|
+
//
|
|
50
|
+
// The one thing here that is not a thin transport is the log: a job log can be
|
|
51
|
+
// large and is capped by the server, and an agent diagnosing a failure wants the
|
|
52
|
+
// end of it. Handing it the whole thing wastes its context, so ?tail= defaults to
|
|
53
|
+
// the last two hundred lines and the response says when it truncated something.
|
|
54
|
+
const MAX_LISTED_RUNS = 200;
|
|
55
|
+
const DEFAULT_TAIL = 200;
|
|
56
|
+
function runNumber(raw) {
|
|
57
|
+
return /^[1-9][0-9]*$/.test(raw ?? '') ? parseInt(raw, 10) : null;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* A job log, as lines. It is stored as NDJSON, one object per line, which is what
|
|
61
|
+
* the runner posts and what the tailer reads; a caller asking for text gets the
|
|
62
|
+
* text out of it.
|
|
63
|
+
*/
|
|
64
|
+
function readLog(file) {
|
|
65
|
+
let content = '';
|
|
66
|
+
try {
|
|
67
|
+
content = fs.readFileSync(file, 'utf8');
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return { lines: [], raw: [] };
|
|
71
|
+
}
|
|
72
|
+
const raw = content.split('\n').filter((l) => l !== '');
|
|
73
|
+
const lines = [];
|
|
74
|
+
for (const line of raw) {
|
|
75
|
+
try {
|
|
76
|
+
const parsed = JSON.parse(line);
|
|
77
|
+
lines.push(typeof parsed.text === 'string' ? parsed.text : line);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
lines.push(line);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { lines, raw };
|
|
84
|
+
}
|
|
85
|
+
function registerCiRunApi(app, root, limiter, engine) {
|
|
86
|
+
// The workflows at a ref, with the inputs each dispatch takes. A caller that
|
|
87
|
+
// means to run one needs both.
|
|
88
|
+
app.get('/api/repos/:collection/:repo/workflows', async (req, res) => {
|
|
89
|
+
const found = (0, auth_1.requireRepo)(root, limiter, req, res);
|
|
90
|
+
if (!found)
|
|
91
|
+
return;
|
|
92
|
+
const asked = String(req.query.ref ?? '');
|
|
93
|
+
if (asked !== '' && !(0, git_1.isValidRefName)(asked)) {
|
|
94
|
+
(0, auth_1.apiError)(res, 400, 'ref is not a usable ref name');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const branches = await found.repo.listRefs('heads');
|
|
98
|
+
const ref = asked || (await found.repo.defaultBranch(branches)) || '';
|
|
99
|
+
const sha = ref === '' ? null : await found.repo.resolve(ref);
|
|
100
|
+
if (!sha) {
|
|
101
|
+
res.json({ ref, workflows: [] });
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
res.json({ ref, sha, workflows: await (0, engine_1.listWorkflowsAt)(found.repo, sha) });
|
|
105
|
+
});
|
|
106
|
+
app.get('/api/repos/:collection/:repo/runs', (req, res) => {
|
|
107
|
+
const found = (0, auth_1.requireRepo)(root, limiter, req, res);
|
|
108
|
+
if (!found)
|
|
109
|
+
return;
|
|
110
|
+
const status = String(req.query.status ?? '');
|
|
111
|
+
if (status !== '' && !['queued', 'running', 'completed'].includes(status)) {
|
|
112
|
+
(0, auth_1.apiError)(res, 400, 'status must be queued, running, or completed');
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const all = (0, runs_1.listRuns)(root, found.repo.collection, found.repo.name);
|
|
116
|
+
const selected = status === '' ? all : all.filter((r) => r.status === status);
|
|
117
|
+
const limit = (0, auth_1.limitParam)(req.query.limit, 30, MAX_LISTED_RUNS);
|
|
118
|
+
res.json({ runs: selected.slice(0, limit), total: selected.length });
|
|
119
|
+
});
|
|
120
|
+
app.get('/api/repos/:collection/:repo/runs/:n', (req, res) => {
|
|
121
|
+
const found = (0, auth_1.requireRepo)(root, limiter, req, res);
|
|
122
|
+
if (!found)
|
|
123
|
+
return;
|
|
124
|
+
const n = runNumber(req.params.n);
|
|
125
|
+
const run = n === null ? null : (0, runs_1.readRun)(root, found.repo.collection, found.repo.name, n);
|
|
126
|
+
if (!run) {
|
|
127
|
+
(0, auth_1.apiError)(res, 404, `no run #${req.params.n} in ${found.repo.collection}/${found.repo.name}`);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
// The jobs with their step states, because "which step failed" is the next
|
|
131
|
+
// question after "did it fail" and a second request per job to answer it
|
|
132
|
+
// would be a poor trade.
|
|
133
|
+
const jobs = run.jobs
|
|
134
|
+
.map((id) => (0, runs_1.readJob)(root, found.repo.collection, found.repo.name, n, id))
|
|
135
|
+
.filter((j) => j !== null);
|
|
136
|
+
res.json({ ...run, jobs });
|
|
137
|
+
});
|
|
138
|
+
app.get('/api/repos/:collection/:repo/runs/:n/jobs/:job', (req, res) => {
|
|
139
|
+
const found = (0, auth_1.requireRepo)(root, limiter, req, res);
|
|
140
|
+
if (!found)
|
|
141
|
+
return;
|
|
142
|
+
const n = runNumber(req.params.n);
|
|
143
|
+
if (n === null || !(0, runs_1.isValidJobId)(req.params.job)) {
|
|
144
|
+
(0, auth_1.apiError)(res, 404, 'no such job');
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const job = (0, runs_1.readJob)(root, found.repo.collection, found.repo.name, n, req.params.job);
|
|
148
|
+
if (!job) {
|
|
149
|
+
(0, auth_1.apiError)(res, 404, `no job ${req.params.job} in run #${n}`);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
res.json(job);
|
|
153
|
+
});
|
|
154
|
+
// ?tail=<n> for the last n lines, ?tail=0 for all of it, ?format=ndjson for
|
|
155
|
+
// the records as the runner posted them.
|
|
156
|
+
app.get('/api/repos/:collection/:repo/runs/:n/jobs/:job/log', (req, res) => {
|
|
157
|
+
const found = (0, auth_1.requireRepo)(root, limiter, req, res);
|
|
158
|
+
if (!found)
|
|
159
|
+
return;
|
|
160
|
+
const n = runNumber(req.params.n);
|
|
161
|
+
if (n === null || !(0, runs_1.isValidJobId)(req.params.job)) {
|
|
162
|
+
(0, auth_1.apiError)(res, 404, 'no such job');
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const job = (0, runs_1.readJob)(root, found.repo.collection, found.repo.name, n, req.params.job);
|
|
166
|
+
if (!job) {
|
|
167
|
+
(0, auth_1.apiError)(res, 404, `no job ${req.params.job} in run #${n}`);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const format = String(req.query.format ?? 'text');
|
|
171
|
+
if (format !== 'text' && format !== 'ndjson') {
|
|
172
|
+
(0, auth_1.apiError)(res, 400, 'format must be text or ndjson');
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const asked = req.query.tail;
|
|
176
|
+
const tail = asked === undefined ? DEFAULT_TAIL : Number(asked);
|
|
177
|
+
if (!Number.isInteger(tail) || tail < 0) {
|
|
178
|
+
(0, auth_1.apiError)(res, 400, 'tail must be a whole number; 0 means the whole log');
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const { lines, raw } = readLog((0, runs_1.jobLogPath)(root, found.repo.collection, found.repo.name, n, req.params.job));
|
|
182
|
+
const source = format === 'ndjson' ? raw : lines;
|
|
183
|
+
const kept = tail === 0 ? source : source.slice(-tail);
|
|
184
|
+
res.json({
|
|
185
|
+
job: job.id,
|
|
186
|
+
status: job.status,
|
|
187
|
+
conclusion: job.conclusion ?? null,
|
|
188
|
+
// Two kinds of truncation, and they are not the same thing: the server
|
|
189
|
+
// caps a log as it is written, and tail keeps only the end of what is
|
|
190
|
+
// there. A caller diagnosing a failure should be told which happened.
|
|
191
|
+
lines: kept,
|
|
192
|
+
truncated: kept.length < source.length,
|
|
193
|
+
capped: job.logCapped === true,
|
|
194
|
+
total: source.length,
|
|
195
|
+
// A job that failed in the planner has no log at all, only a reason.
|
|
196
|
+
error: job.error ?? null,
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
app.get('/api/repos/:collection/:repo/runs/:n/artifacts', (req, res) => {
|
|
200
|
+
const found = (0, auth_1.requireRepo)(root, limiter, req, res);
|
|
201
|
+
if (!found)
|
|
202
|
+
return;
|
|
203
|
+
const n = runNumber(req.params.n);
|
|
204
|
+
if (n === null) {
|
|
205
|
+
(0, auth_1.apiError)(res, 404, 'no such run');
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
res.json({ artifacts: (0, artifacts_1.listArtifacts)(root, found.repo.collection, found.repo.name, n) });
|
|
209
|
+
});
|
|
210
|
+
app.get('/api/repos/:collection/:repo/runs/:n/artifacts/:name', (req, res) => {
|
|
211
|
+
const found = (0, auth_1.requireRepo)(root, limiter, req, res);
|
|
212
|
+
if (!found)
|
|
213
|
+
return;
|
|
214
|
+
const n = runNumber(req.params.n);
|
|
215
|
+
if (n === null || !(0, artifacts_1.isValidArtifactName)(req.params.name)) {
|
|
216
|
+
(0, auth_1.apiError)(res, 404, 'no such artifact');
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const file = (0, artifacts_1.artifactPath)(root, found.repo.collection, found.repo.name, n, req.params.name);
|
|
220
|
+
if (!file || !fs.existsSync(file)) {
|
|
221
|
+
(0, auth_1.apiError)(res, 404, `no artifact named ${req.params.name} in run #${n}`);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
res
|
|
225
|
+
.type('application/x-tar')
|
|
226
|
+
.set('Content-Disposition', `attachment; filename="${req.params.name}.tar"`)
|
|
227
|
+
.sendFile(path.resolve(file));
|
|
228
|
+
});
|
|
229
|
+
// Cancelling, re-running, and dispatching write, so they take the write role on
|
|
230
|
+
// the repository, which is what the web requires of them too.
|
|
231
|
+
app.post('/api/repos/:collection/:repo/runs/:n/cancel', (req, res) => {
|
|
232
|
+
const ctx = (0, auth_1.requirePush)(root, limiter, req, res);
|
|
233
|
+
if (!ctx)
|
|
234
|
+
return;
|
|
235
|
+
const n = runNumber(req.params.n);
|
|
236
|
+
if (n === null || !(0, runs_1.readRun)(root, ctx.repo.collection, ctx.repo.name, n)) {
|
|
237
|
+
(0, auth_1.apiError)(res, 404, `no run #${req.params.n}`);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
// A run that is already over is not cancellable, which is a state refusal
|
|
241
|
+
// rather than a missing run.
|
|
242
|
+
const cancelled = engine.cancelRun(ctx.repo.collection, ctx.repo.name, n);
|
|
243
|
+
if (!cancelled) {
|
|
244
|
+
(0, auth_1.apiError)(res, 409, `run #${n} is not in progress`);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
res.json({ cancelled: n });
|
|
248
|
+
});
|
|
249
|
+
app.post('/api/repos/:collection/:repo/runs/:n/rerun', async (req, res) => {
|
|
250
|
+
const ctx = (0, auth_1.requirePush)(root, limiter, req, res);
|
|
251
|
+
if (!ctx)
|
|
252
|
+
return;
|
|
253
|
+
const n = runNumber(req.params.n);
|
|
254
|
+
if (n === null) {
|
|
255
|
+
(0, auth_1.apiError)(res, 404, 'no such run');
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
const fresh = await engine.rerun(ctx.repo.collection, ctx.repo.name, n);
|
|
260
|
+
if (!fresh) {
|
|
261
|
+
(0, auth_1.apiError)(res, 404, `no run #${n}`);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
res.json({ number: fresh.number, workflowName: fresh.workflowName, status: fresh.status });
|
|
265
|
+
}
|
|
266
|
+
catch (e) {
|
|
267
|
+
(0, auth_1.apiError)(res, 400, e instanceof Error ? e.message : String(e));
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
/**
|
|
271
|
+
* Mint the command that runs this run's manual jobs on a machine of the
|
|
272
|
+
* caller's. Takes the write role, the same standing that dispatches and
|
|
273
|
+
* re-runs workflows: minting decides that repository code will execute
|
|
274
|
+
* somewhere, which is exactly what those decide.
|
|
275
|
+
*
|
|
276
|
+
* The token is in the response once and only its hash is kept, as with
|
|
277
|
+
* every credential a vault issues. It must be redeemed within fifteen
|
|
278
|
+
* minutes and is spent by redemption; the npx version is pinned to this
|
|
279
|
+
* vault's own, so the pasted command runs the client this server was
|
|
280
|
+
* released with.
|
|
281
|
+
*/
|
|
282
|
+
app.post('/api/repos/:collection/:repo/runs/:n/exec-command', (req, res) => {
|
|
283
|
+
const ctx = (0, auth_1.requirePush)(root, limiter, req, res);
|
|
284
|
+
if (!ctx)
|
|
285
|
+
return;
|
|
286
|
+
const n = runNumber(req.params.n);
|
|
287
|
+
if (n === null || !(0, runs_1.readRun)(root, ctx.repo.collection, ctx.repo.name, n)) {
|
|
288
|
+
(0, auth_1.apiError)(res, 404, `no run #${req.params.n}`);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const minted = engine.mintManual(ctx.repo.collection, ctx.repo.name, n, ctx.actor.username);
|
|
292
|
+
if ('error' in minted) {
|
|
293
|
+
if (minted.error === 'finished')
|
|
294
|
+
(0, auth_1.apiError)(res, 409, `run #${n} has finished; there is nothing left to run`);
|
|
295
|
+
else
|
|
296
|
+
(0, auth_1.apiError)(res, 400, `run #${n} has no manual jobs (none of its jobs has 'manual' in runs-on)`);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const url = (0, web_1.baseUrlOf)(req);
|
|
300
|
+
res.status(201).json({
|
|
301
|
+
run: n,
|
|
302
|
+
token: minted.token,
|
|
303
|
+
expiresAt: minted.grant.expiresAt,
|
|
304
|
+
expiresInMinutes: Math.round(manual_1.MINT_TTL_MS / 60000),
|
|
305
|
+
command: `npx @magland/mochi@${(0, version_1.packageVersion)()} job run ${url} ${minted.token}`,
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
app.post('/api/repos/:collection/:repo/dispatches', async (req, res) => {
|
|
309
|
+
const ctx = (0, auth_1.requirePush)(root, limiter, req, res);
|
|
310
|
+
if (!ctx)
|
|
311
|
+
return;
|
|
312
|
+
const body = (0, auth_1.bodyOf)(req);
|
|
313
|
+
const workflow = (0, auth_1.stringField)(body, 'workflow');
|
|
314
|
+
if (workflow === null) {
|
|
315
|
+
(0, auth_1.apiError)(res, 400, 'a "workflow" is required; list them at /workflows');
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const rawInputs = body.inputs;
|
|
319
|
+
if (rawInputs !== undefined && (typeof rawInputs !== 'object' || rawInputs === null || Array.isArray(rawInputs))) {
|
|
320
|
+
(0, auth_1.apiError)(res, 400, '"inputs" must be an object');
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const branches = await ctx.repo.listRefs('heads');
|
|
324
|
+
try {
|
|
325
|
+
const run = await (0, dispatch_1.dispatchWorkflow)(engine, ctx.repo, branches, await ctx.repo.defaultBranch(branches), ctx.actor.username, {
|
|
326
|
+
workflow,
|
|
327
|
+
ref: (0, auth_1.stringField)(body, 'ref') ?? '',
|
|
328
|
+
inputs: (rawInputs ?? {}),
|
|
329
|
+
});
|
|
330
|
+
res.status(201).json({ number: run.number, workflowName: run.workflowName, status: run.status, sha: run.sha });
|
|
331
|
+
}
|
|
332
|
+
catch (e) {
|
|
333
|
+
(0, auth_1.sendOpError)(res, e, 'could not dispatch the workflow');
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerContentsApi = registerContentsApi;
|
|
4
|
+
const git_1 = require("../git");
|
|
5
|
+
const languages_1 = require("../languages");
|
|
6
|
+
const limit_1 = require("../limit");
|
|
7
|
+
const pointer_1 = require("../pointer");
|
|
8
|
+
const render_1 = require("../render");
|
|
9
|
+
const auth_1 = require("./auth");
|
|
10
|
+
// Reading a repository: the tree, a file, history, a diff, blame, search.
|
|
11
|
+
//
|
|
12
|
+
// These are the routes that make an agent able to work without a clone. They are
|
|
13
|
+
// deliberately not the HTML routes answering JSON: the HTML routes carry
|
|
14
|
+
// UI-shaped parameters and costs (a tree listing with the last commit per entry
|
|
15
|
+
// is one `git log` per entry) that an API caller usually does not want, and
|
|
16
|
+
// sometimes wants more than.
|
|
17
|
+
/** The wildcard tail of a route path, as express leaves it. */
|
|
18
|
+
function tail(req) {
|
|
19
|
+
return req.params['0'] ?? '';
|
|
20
|
+
}
|
|
21
|
+
/** A ref from ?ref=, falling back to the default branch. Never an arbitrary revision expression. */
|
|
22
|
+
async function refOf(req, repo) {
|
|
23
|
+
const asked = String(req.query.ref ?? '');
|
|
24
|
+
if (asked === '')
|
|
25
|
+
return await repo.defaultBranch(await repo.listRefs('heads'));
|
|
26
|
+
return (0, git_1.isValidRefName)(asked) ? asked : null;
|
|
27
|
+
}
|
|
28
|
+
// How a gated route refuses. 503 rather than 429: this is server capacity rather
|
|
29
|
+
// than a client quota, and nothing about the request was wrong.
|
|
30
|
+
function sendBusy(res) {
|
|
31
|
+
res.setHeader('Retry-After', String(limit_1.BUSY_RETRY_SECONDS));
|
|
32
|
+
(0, auth_1.apiError)(res, 503, 'the server is busy with other git work; try again in a moment');
|
|
33
|
+
}
|
|
34
|
+
const MAX_COMMITS = 250;
|
|
35
|
+
const MAX_LISTED_PATHS = 20000;
|
|
36
|
+
/** The same ceiling the editor applies, so that the two interfaces agree on what is too big. */
|
|
37
|
+
const MAX_FILE_BYTES = 1024 * 1024;
|
|
38
|
+
function registerContentsApi(app, root, limiter, gates) {
|
|
39
|
+
// A directory listing. ?commits=1 adds the last commit per entry, which costs
|
|
40
|
+
// one git log per entry; it is off by default because an API caller asking for
|
|
41
|
+
// a listing usually wants names.
|
|
42
|
+
const treeHandler = async (req, res) => {
|
|
43
|
+
const found = (0, auth_1.requireRepo)(root, limiter, req, res);
|
|
44
|
+
if (!found)
|
|
45
|
+
return;
|
|
46
|
+
const ref = await refOf(req, found.repo);
|
|
47
|
+
if (!ref) {
|
|
48
|
+
(0, auth_1.apiError)(res, 400, 'ref is not a usable ref name');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const dir = tail(req);
|
|
52
|
+
if (!(0, git_1.isValidRepoPath)(dir)) {
|
|
53
|
+
(0, auth_1.apiError)(res, 400, 'that is not a usable path');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
let entries;
|
|
57
|
+
try {
|
|
58
|
+
entries = await found.repo.listTree(ref, dir);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
(0, auth_1.apiError)(res, 404, `nothing at ${dir || '/'} in ${ref}`);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (String(req.query.commits ?? '') === '1') {
|
|
65
|
+
const paths = entries.map((e) => (dir ? `${dir}/${e.name}` : e.name));
|
|
66
|
+
const last = await found.repo.lastCommits(ref, paths);
|
|
67
|
+
res.json({
|
|
68
|
+
ref,
|
|
69
|
+
path: dir,
|
|
70
|
+
entries: entries.map((e, i) => ({ ...e, lastCommit: last.get(paths[i]) ?? null })),
|
|
71
|
+
});
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
res.json({ ref, path: dir, entries });
|
|
75
|
+
};
|
|
76
|
+
app.get('/api/repos/:collection/:repo/tree/*', treeHandler);
|
|
77
|
+
app.get('/api/repos/:collection/:repo/tree', treeHandler);
|
|
78
|
+
// One file, with its content. Text comes back as text; anything binary comes
|
|
79
|
+
// back base64, and an LFS pointer is reported as one rather than handed over as
|
|
80
|
+
// if it were the file, since the bytes are somewhere else.
|
|
81
|
+
app.get('/api/repos/:collection/:repo/contents/*', async (req, res) => {
|
|
82
|
+
const found = (0, auth_1.requireRepo)(root, limiter, req, res);
|
|
83
|
+
if (!found)
|
|
84
|
+
return;
|
|
85
|
+
const ref = await refOf(req, found.repo);
|
|
86
|
+
if (!ref) {
|
|
87
|
+
(0, auth_1.apiError)(res, 400, 'ref is not a usable ref name');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const filePath = tail(req);
|
|
91
|
+
if (filePath === '' || !(0, git_1.isValidRepoPath)(filePath)) {
|
|
92
|
+
(0, auth_1.apiError)(res, 400, 'that is not a usable path');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
let buf;
|
|
96
|
+
try {
|
|
97
|
+
if ((await found.repo.entryType(ref, filePath)) !== 'blob') {
|
|
98
|
+
(0, auth_1.apiError)(res, 404, `no file at ${filePath} in ${ref}`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
buf = await found.repo.catBlob(ref, filePath);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
(0, auth_1.apiError)(res, 404, `no file at ${filePath} in ${ref}`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const pointer = (0, pointer_1.parsePointer)(buf);
|
|
108
|
+
// The commit the ref is at, so that a caller which reads, thinks, and then
|
|
109
|
+
// writes can hand it back as expectedSha and be told if the branch moved
|
|
110
|
+
// underneath it.
|
|
111
|
+
const base = { ref, path: filePath, size: buf.length, commit: await found.repo.resolve(ref) };
|
|
112
|
+
if (pointer) {
|
|
113
|
+
res.json({
|
|
114
|
+
...base,
|
|
115
|
+
encoding: 'lfs-pointer',
|
|
116
|
+
lfs: { oid: pointer.oid, size: pointer.size },
|
|
117
|
+
content: null,
|
|
118
|
+
note: 'this path is a Git LFS pointer; fetch the object through the LFS endpoints or a clone with git-lfs',
|
|
119
|
+
});
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (buf.length > MAX_FILE_BYTES) {
|
|
123
|
+
(0, auth_1.apiError)(res, 413, `${filePath} is larger than ${MAX_FILE_BYTES} bytes; fetch it from the raw route or a clone`);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if ((0, render_1.isBinary)(buf)) {
|
|
127
|
+
res.json({ ...base, encoding: 'base64', content: buf.toString('base64') });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
res.json({ ...base, encoding: 'utf-8', content: buf.toString('utf8') });
|
|
131
|
+
});
|
|
132
|
+
// The bytes, with no envelope. The sandbox headers are the same ones the web
|
|
133
|
+
// raw route sets, because repository content must never be able to inject HTML
|
|
134
|
+
// into this origin whichever door it came out of.
|
|
135
|
+
app.get('/api/repos/:collection/:repo/raw/*', async (req, res) => {
|
|
136
|
+
const found = (0, auth_1.requireRepo)(root, limiter, req, res);
|
|
137
|
+
if (!found)
|
|
138
|
+
return;
|
|
139
|
+
const ref = await refOf(req, found.repo);
|
|
140
|
+
const filePath = tail(req);
|
|
141
|
+
if (!ref || filePath === '' || !(0, git_1.isValidRepoPath)(filePath)) {
|
|
142
|
+
(0, auth_1.apiError)(res, 400, 'ask for a file as /raw/<path>?ref=<ref>');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
let buf;
|
|
146
|
+
try {
|
|
147
|
+
if ((await found.repo.entryType(ref, filePath)) !== 'blob') {
|
|
148
|
+
(0, auth_1.apiError)(res, 404, `no file at ${filePath} in ${ref}`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
buf = await found.repo.catBlob(ref, filePath);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
(0, auth_1.apiError)(res, 404, `no file at ${filePath} in ${ref}`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
res.setHeader('Content-Security-Policy', 'sandbox');
|
|
158
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
159
|
+
res.type((0, render_1.isBinary)(buf) ? 'application/octet-stream' : 'text/plain; charset=utf-8');
|
|
160
|
+
res.send(buf);
|
|
161
|
+
});
|
|
162
|
+
app.get('/api/repos/:collection/:repo/commits', async (req, res) => {
|
|
163
|
+
const found = (0, auth_1.requireRepo)(root, limiter, req, res);
|
|
164
|
+
if (!found)
|
|
165
|
+
return;
|
|
166
|
+
const ref = await refOf(req, found.repo);
|
|
167
|
+
if (!ref) {
|
|
168
|
+
(0, auth_1.apiError)(res, 400, 'ref is not a usable ref name');
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const filePath = String(req.query.path ?? '');
|
|
172
|
+
if (filePath !== '' && !(0, git_1.isValidRepoPath)(filePath)) {
|
|
173
|
+
(0, auth_1.apiError)(res, 400, 'that is not a usable path');
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const limit = (0, auth_1.limitParam)(req.query.limit, 30, MAX_COMMITS);
|
|
177
|
+
try {
|
|
178
|
+
const commits = await found.repo.log(ref, 0, limit, filePath || undefined);
|
|
179
|
+
res.json({ ref, commits });
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
(0, auth_1.apiError)(res, 404, `ref ${ref} not found`);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
app.get('/api/repos/:collection/:repo/commits/:sha', async (req, res) => {
|
|
186
|
+
const found = (0, auth_1.requireRepo)(root, limiter, req, res);
|
|
187
|
+
if (!found)
|
|
188
|
+
return;
|
|
189
|
+
const detail = await found.repo.commit(req.params.sha);
|
|
190
|
+
if (!detail) {
|
|
191
|
+
(0, auth_1.apiError)(res, 404, `no commit ${req.params.sha}`);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
res.json(detail);
|
|
195
|
+
});
|
|
196
|
+
app.get('/api/repos/:collection/:repo/commits/:sha/patch', async (req, res) => {
|
|
197
|
+
const found = (0, auth_1.requireRepo)(root, limiter, req, res);
|
|
198
|
+
if (!found)
|
|
199
|
+
return;
|
|
200
|
+
if (!(await found.repo.commit(req.params.sha))) {
|
|
201
|
+
(0, auth_1.apiError)(res, 404, `no commit ${req.params.sha}`);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
res.type('text/plain').send(await found.repo.commitPatch(req.params.sha));
|
|
205
|
+
});
|
|
206
|
+
// base...head, spelled as the web route spells it. A wildcard rather than one
|
|
207
|
+
// segment, because a branch name may contain slashes.
|
|
208
|
+
app.get('/api/repos/:collection/:repo/compare/*', async (req, res) => {
|
|
209
|
+
const found = (0, auth_1.requireRepo)(root, limiter, req, res);
|
|
210
|
+
if (!found)
|
|
211
|
+
return;
|
|
212
|
+
const parts = tail(req).split('...');
|
|
213
|
+
if (parts.length !== 2 || !(0, git_1.isValidRefName)(parts[0]) || !(0, git_1.isValidRefName)(parts[1])) {
|
|
214
|
+
(0, auth_1.apiError)(res, 400, 'ask for a comparison as <base>...<head>');
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
const cmp = await found.repo.compare(parts[0], parts[1]);
|
|
219
|
+
res.json({ base: parts[0], head: parts[1], ...cmp });
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
(0, auth_1.apiError)(res, 404, `could not compare ${parts[0]} with ${parts[1]}`);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
app.get('/api/repos/:collection/:repo/blame/*', async (req, res) => {
|
|
226
|
+
const found = (0, auth_1.requireRepo)(root, limiter, req, res);
|
|
227
|
+
if (!found)
|
|
228
|
+
return;
|
|
229
|
+
const ref = await refOf(req, found.repo);
|
|
230
|
+
const filePath = tail(req);
|
|
231
|
+
if (!ref || filePath === '' || !(0, git_1.isValidRepoPath)(filePath)) {
|
|
232
|
+
(0, auth_1.apiError)(res, 400, 'ask for blame as /blame/<path>?ref=<ref>');
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
res.json({ ref, path: filePath, lines: await found.repo.blame(ref, filePath) });
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
(0, auth_1.apiError)(res, 404, `no file at ${filePath} in ${ref}`);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
// Literal search, gated like the web route: git grep over a whole tree is the
|
|
243
|
+
// most expensive thing a reader can ask for.
|
|
244
|
+
app.get('/api/repos/:collection/:repo/search', async (req, res) => {
|
|
245
|
+
const found = (0, auth_1.requireRepo)(root, limiter, req, res);
|
|
246
|
+
if (!found)
|
|
247
|
+
return;
|
|
248
|
+
const ref = await refOf(req, found.repo);
|
|
249
|
+
const query = String(req.query.q ?? '').slice(0, 256);
|
|
250
|
+
if (!ref) {
|
|
251
|
+
(0, auth_1.apiError)(res, 400, 'ref is not a usable ref name');
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (query.trim() === '') {
|
|
255
|
+
res.json({ ref, query, hits: [], truncated: false });
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const release = await gates.search.enter();
|
|
259
|
+
if (!release) {
|
|
260
|
+
sendBusy(res);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
const { hits, truncated } = await found.repo.search(ref, query);
|
|
265
|
+
res.json({ ref, query, hits, truncated });
|
|
266
|
+
}
|
|
267
|
+
finally {
|
|
268
|
+
release();
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
// The file list the finder uses, which is what an agent wants before it asks
|
|
272
|
+
// for any one file.
|
|
273
|
+
app.get('/api/repos/:collection/:repo/paths', async (req, res) => {
|
|
274
|
+
const found = (0, auth_1.requireRepo)(root, limiter, req, res);
|
|
275
|
+
if (!found)
|
|
276
|
+
return;
|
|
277
|
+
const ref = await refOf(req, found.repo);
|
|
278
|
+
if (!ref) {
|
|
279
|
+
(0, auth_1.apiError)(res, 400, 'ref is not a usable ref name');
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const limit = (0, auth_1.limitParam)(req.query.limit, MAX_LISTED_PATHS, MAX_LISTED_PATHS);
|
|
283
|
+
const release = await gates.tree.enter();
|
|
284
|
+
if (!release) {
|
|
285
|
+
sendBusy(res);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
const { paths, truncated } = await found.repo.listPaths(ref, limit);
|
|
290
|
+
res.json({ ref, paths, truncated });
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
(0, auth_1.apiError)(res, 404, `ref ${ref} not found`);
|
|
294
|
+
}
|
|
295
|
+
finally {
|
|
296
|
+
release();
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
app.get('/api/repos/:collection/:repo/contributors', async (req, res) => {
|
|
300
|
+
const found = (0, auth_1.requireRepo)(root, limiter, req, res);
|
|
301
|
+
if (!found)
|
|
302
|
+
return;
|
|
303
|
+
const ref = await refOf(req, found.repo);
|
|
304
|
+
if (!ref) {
|
|
305
|
+
(0, auth_1.apiError)(res, 400, 'ref is not a usable ref name');
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
try {
|
|
309
|
+
res.json({ ref, contributors: await found.repo.contributors(ref) });
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
(0, auth_1.apiError)(res, 404, `ref ${ref} not found`);
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
app.get('/api/repos/:collection/:repo/languages', async (req, res) => {
|
|
316
|
+
const found = (0, auth_1.requireRepo)(root, limiter, req, res);
|
|
317
|
+
if (!found)
|
|
318
|
+
return;
|
|
319
|
+
const ref = await refOf(req, found.repo);
|
|
320
|
+
if (!ref) {
|
|
321
|
+
(0, auth_1.apiError)(res, 400, 'ref is not a usable ref name');
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const release = await gates.tree.enter();
|
|
325
|
+
if (!release) {
|
|
326
|
+
sendBusy(res);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
res.json({ ref, languages: await (0, languages_1.languageBreakdown)(found.repo.dir, ref) });
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
(0, auth_1.apiError)(res, 404, `ref ${ref} not found`);
|
|
334
|
+
}
|
|
335
|
+
finally {
|
|
336
|
+
release();
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
}
|