@edgehero/pi-dispatch 0.1.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/.env.example +160 -0
- package/deploy/com.pi-dispatch.worker.plist +66 -0
- package/deploy/nssm-install.cmd +59 -0
- package/deploy/receiver.service +36 -0
- package/deploy/worker-env-wrapper.cmd +50 -0
- package/deploy/worker-env-wrapper.sh +63 -0
- package/deploy/worker.service +55 -0
- package/package.json +83 -0
- package/src/azure-auth.mjs +61 -0
- package/src/azure-host.mjs +236 -0
- package/src/azure-identity.mjs +63 -0
- package/src/azure-prompt.mjs +118 -0
- package/src/branch.mjs +80 -0
- package/src/budget.mjs +179 -0
- package/src/cli.mjs +208 -0
- package/src/config.mjs +329 -0
- package/src/connection.mjs +40 -0
- package/src/cron.mjs +94 -0
- package/src/docker-run.mjs +119 -0
- package/src/doctor.mjs +1127 -0
- package/src/env-allowlist.mjs +198 -0
- package/src/env-file.mjs +153 -0
- package/src/exit-code.mjs +32 -0
- package/src/flow-gate.mjs +82 -0
- package/src/forgejo-auth.mjs +77 -0
- package/src/forgejo-host.mjs +172 -0
- package/src/forgejo-identity.mjs +74 -0
- package/src/forgejo-prompt.mjs +123 -0
- package/src/forges.mjs +148 -0
- package/src/get-token.mjs +226 -0
- package/src/git-dirty.mjs +16 -0
- package/src/github-app-setup.mjs +517 -0
- package/src/github-host.mjs +159 -0
- package/src/github-prompt.mjs +286 -0
- package/src/gitlab-auth.mjs +72 -0
- package/src/gitlab-host.mjs +200 -0
- package/src/gitlab-identity.mjs +61 -0
- package/src/gitlab-prompt.mjs +123 -0
- package/src/identity.mjs +57 -0
- package/src/image-preflight.mjs +180 -0
- package/src/import-pi.mjs +451 -0
- package/src/index.mjs +177 -0
- package/src/init.mjs +77 -0
- package/src/job-id.mjs +100 -0
- package/src/materialize.mjs +138 -0
- package/src/outbox.mjs +179 -0
- package/src/packages.mjs +188 -0
- package/src/pause-windows.mjs +218 -0
- package/src/prepare-github.mjs +260 -0
- package/src/prepare-local.mjs +76 -0
- package/src/prepare.mjs +199 -0
- package/src/pricing.mjs +168 -0
- package/src/processor.mjs +360 -0
- package/src/queue.mjs +152 -0
- package/src/run-container.mjs +133 -0
- package/src/run-history.mjs +534 -0
- package/src/runtime-settings.mjs +188 -0
- package/src/sandbox-cli.mjs +156 -0
- package/src/sandbox-store.mjs +269 -0
- package/src/sandbox.mjs +171 -0
- package/src/scheduler-stall-guard.mjs +67 -0
- package/src/schedules.mjs +62 -0
- package/src/service.mjs +677 -0
- package/src/session-key.mjs +108 -0
- package/src/session-store.mjs +249 -0
- package/src/start.mjs +502 -0
- package/src/subscriptions.mjs +208 -0
- package/src/triggers.mjs +491 -0
- package/src/up.mjs +315 -0
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pi-dispatch setup github` — mint GitHub App credentials via the App Manifest flow (issue #81,
|
|
3
|
+
* DES-GH-APP-MANIFEST-SETUP).
|
|
4
|
+
*
|
|
5
|
+
* Today the App source is the strongest credential this system supports (per-repo, one-hour
|
|
6
|
+
* installation tokens — CONST-TOKEN-SCOPED-PER-JOB) and also the most manual mile of setup: five
|
|
7
|
+
* settings pages, a hand-invented webhook secret, an installation id hunted out of URLs. The manifest
|
|
8
|
+
* flow compresses all of it into ONE browser click: a throwaway listener on this machine's loopback
|
|
9
|
+
* serves a self-submitting form that POSTs the manifest to github.com, GitHub bounces the browser back
|
|
10
|
+
* to the listener with a single-use code (valid 1h), and the UNAUTHENTICATED
|
|
11
|
+
* `POST /app-manifests/{code}/conversions` returns `{ id, slug, pem, webhook_secret, … }` in one
|
|
12
|
+
* response.
|
|
13
|
+
*
|
|
14
|
+
* Trust framing, which every edit here must preserve: NOTHING crosses a maintainer-controlled
|
|
15
|
+
* service. The listener is the operator's own 127.0.0.1; GitHub is the only remote party; the
|
|
16
|
+
* conversion code is single-use and expires. The wizard's own doctrine on top of that:
|
|
17
|
+
*
|
|
18
|
+
* - There is deliberately NO `--yes` flag. Unlike `up`'s docker actions, every write here carries a
|
|
19
|
+
* credential (a private key, a webhook secret, the auth source the worker will boot with), so each
|
|
20
|
+
* one is shown verbatim and individually consented — a wizard that can be waved through end-to-end
|
|
21
|
+
* is a wizard that writes keys nobody looked at.
|
|
22
|
+
* - Secrets never reach output. The pem, webhook_secret, and client_secret are registered with a
|
|
23
|
+
* scrubber the moment they exist, and EVERY byte this module prints — including error paths —
|
|
24
|
+
* passes through it. The .env plan names keys and paths, never secret values.
|
|
25
|
+
* - The PEM lands mode 0600 and NEVER clobbers: an existing file at the target path is a refusal,
|
|
26
|
+
* because overwriting a key file destroys a credential this tool cannot restore.
|
|
27
|
+
* - An existing WEBHOOK_SECRET is kept (setEnvKeyIfEmpty): the operator's configured forge hooks
|
|
28
|
+
* verify against it, and rotating it here would silently break every working delivery. The three
|
|
29
|
+
* values the operator DID just consent to replacing (source, app id, key path) go through the
|
|
30
|
+
* overwrite transform — the consent prompt above is exactly the gate env-file.mjs documents.
|
|
31
|
+
*
|
|
32
|
+
* Everything side-effecting is injected (fetch, the listener, the browser opener, prompt, fs, clock),
|
|
33
|
+
* defaulting to the real thing — the up.mjs convention — so the whole flow is testable offline.
|
|
34
|
+
*/
|
|
35
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
36
|
+
import { createSign } from "node:crypto";
|
|
37
|
+
import { chmodSync, existsSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
38
|
+
import { hostname } from "node:os";
|
|
39
|
+
import { join } from "node:path";
|
|
40
|
+
import { parseArgs } from "node:util";
|
|
41
|
+
import { updateEnvFile } from "./env-file.mjs";
|
|
42
|
+
import { defaultPrompt } from "./up.mjs";
|
|
43
|
+
|
|
44
|
+
const API_ROOT = "https://api.github.com";
|
|
45
|
+
// GitHub rejects requests without a User-Agent, and node's fetch does not always send one.
|
|
46
|
+
const USER_AGENT = "pi-dispatch-setup-github";
|
|
47
|
+
// The repo homepage, shown on the App's about page — informational, not a trust anchor.
|
|
48
|
+
const HOMEPAGE = "https://github.com/edgehero/pi-dispatch";
|
|
49
|
+
// GitHub app names are GLOBALLY unique and capped at 34 characters.
|
|
50
|
+
const NAME_MAX = 34;
|
|
51
|
+
|
|
52
|
+
const FLAG_HELP = `usage: pi-dispatch setup github (--webhook-url <URL> | --no-webhook) [--org <org>] [--name <appName>]
|
|
53
|
+
|
|
54
|
+
--webhook-url <URL> the public URL of your pi-dispatch-receiver — GitHub delivers events there
|
|
55
|
+
--no-webhook create the App with its webhook INACTIVE (the polling-ready shape; no public URL needed)
|
|
56
|
+
--org <org> create the App under an organization instead of your user account
|
|
57
|
+
--name <appName> the App's name (globally unique on GitHub, max ${NAME_MAX} chars; default pi-dispatch-<host>)`;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Run the wizard. `deps` (all injected, all defaulted):
|
|
61
|
+
* env, fetchFn, listen, openBrowser, out, prompt, fs, cwd, now — plus codeTimeoutMs, the ceiling on
|
|
62
|
+
* how long the listener waits for the browser to come back with the conversion code.
|
|
63
|
+
*/
|
|
64
|
+
export async function runGithubAppSetup(argv = [], deps = {}) {
|
|
65
|
+
const {
|
|
66
|
+
env = process.env,
|
|
67
|
+
fetchFn = fetch,
|
|
68
|
+
listen = defaultListen,
|
|
69
|
+
openBrowser = defaultOpenBrowser,
|
|
70
|
+
out = (s) => process.stdout.write(s),
|
|
71
|
+
prompt = defaultPrompt,
|
|
72
|
+
fs = { existsSync, readFileSync, writeFileSync, renameSync, statSync, chmodSync },
|
|
73
|
+
cwd = process.cwd(),
|
|
74
|
+
now = () => Date.now(),
|
|
75
|
+
codeTimeoutMs = 600000, // ~10 min for a human to click through GitHub's create-app page
|
|
76
|
+
} = deps;
|
|
77
|
+
|
|
78
|
+
// Every byte printed goes through the scrubber. Secrets are registered the moment they exist, so
|
|
79
|
+
// even an error path that stringifies something secret-bearing cannot leak it into a scrollback.
|
|
80
|
+
const secrets = [];
|
|
81
|
+
const scrub = (s) => secrets.reduce((acc, secret) => (secret ? acc.split(secret).join("[redacted]") : acc), String(s));
|
|
82
|
+
const say = (s) => out(scrub(s));
|
|
83
|
+
const summary = [];
|
|
84
|
+
|
|
85
|
+
// -- flags ------------------------------------------------------------------------------------
|
|
86
|
+
let values;
|
|
87
|
+
try {
|
|
88
|
+
({ values } = parseArgs({
|
|
89
|
+
args: argv,
|
|
90
|
+
options: {
|
|
91
|
+
org: { type: "string" },
|
|
92
|
+
name: { type: "string" },
|
|
93
|
+
"webhook-url": { type: "string" },
|
|
94
|
+
"no-webhook": { type: "boolean", default: false },
|
|
95
|
+
},
|
|
96
|
+
}));
|
|
97
|
+
} catch (error) {
|
|
98
|
+
say(`error: ${error.message}\n\n${FLAG_HELP}\n`);
|
|
99
|
+
return 1;
|
|
100
|
+
}
|
|
101
|
+
const webhookUrl = values["webhook-url"];
|
|
102
|
+
const noWebhook = values["no-webhook"];
|
|
103
|
+
// Deliberately UNDEFAULTED, like GITLAB_WEBHOOK_MODE: a receiver URL cannot be guessed, and
|
|
104
|
+
// silently creating a hook-inactive App for an operator who wanted deliveries is the kind of
|
|
105
|
+
// "helpful" default that surfaces as a webhook that never fires.
|
|
106
|
+
if (!webhookUrl && !noWebhook) {
|
|
107
|
+
say(`error: choose a webhook shape — pass --webhook-url <public receiver URL>, or --no-webhook for the hook-inactive (polling-ready) shape\n\n${FLAG_HELP}\n`);
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
if (webhookUrl && noWebhook) {
|
|
111
|
+
say(`error: --webhook-url and --no-webhook contradict each other — pass exactly one\n\n${FLAG_HELP}\n`);
|
|
112
|
+
return 1;
|
|
113
|
+
}
|
|
114
|
+
const appName = sanitizeAppName(values.name ?? defaultAppName(hostname()));
|
|
115
|
+
const postTarget = values.org
|
|
116
|
+
? `https://github.com/organizations/${encodeURIComponent(values.org)}/settings/apps/new`
|
|
117
|
+
: "https://github.com/settings/apps/new";
|
|
118
|
+
|
|
119
|
+
say("pi-dispatch setup github — mint GitHub App credentials in one browser click (App Manifest flow)\n");
|
|
120
|
+
say("nothing is written locally without your explicit consent; secrets are never printed\n\n");
|
|
121
|
+
|
|
122
|
+
// -- (b) loopback listener + the self-submitting form -----------------------------------------
|
|
123
|
+
// The manifest flow REQUIRES a browser form POST — a plain link cannot carry the payload — so the
|
|
124
|
+
// listener serves GET / (the auto-submitting form) and GET /callback?code=… (the bounce-back).
|
|
125
|
+
// The manifest is built lazily against the listener's final URL, because redirect_url needs the port.
|
|
126
|
+
let listener;
|
|
127
|
+
try {
|
|
128
|
+
listener = await listen((baseUrl) =>
|
|
129
|
+
buildFormPage(buildManifest({ name: appName, redirectUrl: `${baseUrl}/callback`, webhookUrl: noWebhook ? undefined : webhookUrl }), postTarget),
|
|
130
|
+
);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
say(`error: could not bind a loopback listener (${error?.message ?? "unknown"})\n`);
|
|
133
|
+
say(" → nothing was created or written; free a local port (the listener picks a random one on 127.0.0.1) and re-run `pi-dispatch setup github`\n");
|
|
134
|
+
return 1;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
say(`Open this URL in a browser to create the App (name: ${appName}, target: ${values.org ? `org ${values.org}` : "your user account"}):\n\n`);
|
|
139
|
+
say(` ${listener.url}/\n\n`);
|
|
140
|
+
// The redirect lands on THIS machine's 127.0.0.1 — said out loud, because on a remote server
|
|
141
|
+
// the browser cannot reach it without help, and the failure mode otherwise is a silent hang.
|
|
142
|
+
say(`note: GitHub will redirect the browser back to 127.0.0.1:${listener.port} ON THE MACHINE RUNNING THIS WIZARD.\n`);
|
|
143
|
+
say(` Setting up a remote server? Open the URL in a browser on that machine, or forward the port first:\n`);
|
|
144
|
+
say(` ssh -L ${listener.port}:127.0.0.1:${listener.port} <your-server> then open the URL locally.\n\n`);
|
|
145
|
+
openBrowser(`${listener.url}/`);
|
|
146
|
+
|
|
147
|
+
let code;
|
|
148
|
+
try {
|
|
149
|
+
code = await listener.waitForCode(codeTimeoutMs);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
say(`error: gave up waiting for GitHub's redirect (${error?.message ?? "timeout"})\n`);
|
|
152
|
+
say(" → no credentials were minted or written. If you completed the GitHub page after the timeout, the App may\n");
|
|
153
|
+
say(" exist without local credentials — delete it under Settings → Developer settings → GitHub Apps, then\n");
|
|
154
|
+
say(" re-run `pi-dispatch setup github` (each conversion code is single-use and expires after an hour)\n");
|
|
155
|
+
return 1;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// -- (c) the conversion: code -> credentials. UNAUTHENTICATED by design (the code IS the proof),
|
|
159
|
+
// single-use, valid 1h.
|
|
160
|
+
let response;
|
|
161
|
+
try {
|
|
162
|
+
response = await fetchFn(`${API_ROOT}/app-manifests/${encodeURIComponent(code)}/conversions`, {
|
|
163
|
+
method: "POST",
|
|
164
|
+
headers: { accept: "application/vnd.github+json", "user-agent": USER_AGENT },
|
|
165
|
+
});
|
|
166
|
+
} catch (error) {
|
|
167
|
+
say(`error: could not reach ${API_ROOT} to convert the manifest code (${error?.message ?? "network error"})\n`);
|
|
168
|
+
say(" → check this machine's network egress and re-run `pi-dispatch setup github`; the code is single-use, so a fresh run mints a fresh one\n");
|
|
169
|
+
return 1;
|
|
170
|
+
}
|
|
171
|
+
const body = await response.text();
|
|
172
|
+
if (response.status !== 201) {
|
|
173
|
+
// A failed conversion carries no secrets (that is the failure), so a snippet is safe — and
|
|
174
|
+
// still scrubbed, one rule for all output.
|
|
175
|
+
say(`error: the manifest conversion returned ${response.status} (expected 201): ${body.slice(0, 300)}\n`);
|
|
176
|
+
say(" → the conversion code is SINGLE-USE and expires after one hour — a re-used or stale code fails exactly like this. Re-run `pi-dispatch setup github` for a fresh one\n");
|
|
177
|
+
return 1;
|
|
178
|
+
}
|
|
179
|
+
let app;
|
|
180
|
+
try {
|
|
181
|
+
app = JSON.parse(body);
|
|
182
|
+
} catch {
|
|
183
|
+
// The 201 body carries the private key, so on a parse failure it is NEVER echoed.
|
|
184
|
+
say("error: GitHub's conversion response was not parseable JSON (body withheld — it may contain the private key)\n");
|
|
185
|
+
say(" → the App may now exist on GitHub without local credentials; check Settings → Developer settings → GitHub Apps, delete it, and re-run\n");
|
|
186
|
+
return 1;
|
|
187
|
+
}
|
|
188
|
+
// Register the secrets BEFORE any further printing — from here on nothing can echo them.
|
|
189
|
+
for (const secret of [app.pem, app.webhook_secret, app.client_secret]) {
|
|
190
|
+
if (typeof secret === "string" && secret !== "") secrets.push(secret);
|
|
191
|
+
}
|
|
192
|
+
if (!app.id || !app.slug || !app.pem) {
|
|
193
|
+
say("error: the conversion response is missing id/slug/pem — refusing to continue (response withheld; it may contain credentials)\n");
|
|
194
|
+
return 1;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// -- (d) show EXACTLY what would be written, then ONE consent -----------------------------
|
|
198
|
+
const pemPath = join(cwd, `github-app-${app.slug}.pem`);
|
|
199
|
+
const envPath = join(cwd, ".env");
|
|
200
|
+
// The secret is only worth writing when the App actually delivers webhooks: under --no-webhook
|
|
201
|
+
// nothing will ever sign a delivery with it, and planting it would only shadow whatever the
|
|
202
|
+
// operator sets when they wire a real receiver later.
|
|
203
|
+
const offerSecret = !noWebhook && typeof app.webhook_secret === "string" && app.webhook_secret !== "";
|
|
204
|
+
say(`\nGitHub created the App "${app.slug}" (id ${app.id}). Nothing is written locally yet.\n\n`);
|
|
205
|
+
say("setup would write:\n");
|
|
206
|
+
say(` ${pemPath}\n`);
|
|
207
|
+
say(" the App's private key, mode 0600 (contents never shown; refuses if the file already exists)\n");
|
|
208
|
+
say(` ${envPath}\n`);
|
|
209
|
+
say(" GITHUB_AUTH_SOURCE=app\n");
|
|
210
|
+
say(` GITHUB_APP_ID=${app.id}\n`);
|
|
211
|
+
say(` GITHUB_APP_PRIVATE_KEY_PATH=${pemPath}\n`);
|
|
212
|
+
if (offerSecret) {
|
|
213
|
+
say(" WEBHOOK_SECRET=<value not shown> — only if .env has none; an existing secret is KEPT, because your configured hooks verify deliveries against it\n");
|
|
214
|
+
}
|
|
215
|
+
if (!(await consent(prompt, "write these? [y/N] "))) {
|
|
216
|
+
say("\ndeclined — nothing was written locally.\n");
|
|
217
|
+
say(`note: the App itself DOES now exist on GitHub: ${app.html_url ?? `https://github.com/apps/${app.slug}`}\n`);
|
|
218
|
+
say(" to remove it: its settings page → Advanced → Delete GitHub App (or keep it and re-run setup later — a fresh run mints a fresh key)\n");
|
|
219
|
+
return 0;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// -- (e) the consented writes -------------------------------------------------------------
|
|
223
|
+
// Key files are never clobbered: the old file may be the only copy of a credential still in
|
|
224
|
+
// use somewhere, and this tool cannot restore what it overwrites.
|
|
225
|
+
if (fs.existsSync(pemPath)) {
|
|
226
|
+
say(`error: refusing to overwrite the existing key file at ${pemPath}\n`);
|
|
227
|
+
say(" → move or delete it first (is another App's key living there?), then re-run `pi-dispatch setup github`. Nothing was written\n");
|
|
228
|
+
return 1;
|
|
229
|
+
}
|
|
230
|
+
fs.writeFileSync(pemPath, app.pem, { mode: 0o600 });
|
|
231
|
+
summary.push(["private key", `written to ${pemPath} (mode 0600)`]);
|
|
232
|
+
if (!fs.existsSync(envPath)) {
|
|
233
|
+
// The consent above was for ".env lines"; with no .env the lines need a file. Created
|
|
234
|
+
// EMPTY here (not scaffolded — that is init's job) so the consented lines are all it gains.
|
|
235
|
+
fs.writeFileSync(envPath, "");
|
|
236
|
+
say(`note: no .env existed here — created one for the lines you approved\n`);
|
|
237
|
+
}
|
|
238
|
+
updateEnvFile(envPath, "GITHUB_AUTH_SOURCE", "app", { fs, overwrite: true });
|
|
239
|
+
updateEnvFile(envPath, "GITHUB_APP_ID", String(app.id), { fs, overwrite: true });
|
|
240
|
+
updateEnvFile(envPath, "GITHUB_APP_PRIVATE_KEY_PATH", pemPath, { fs, overwrite: true });
|
|
241
|
+
summary.push(["auth source", "GITHUB_AUTH_SOURCE=app (+ app id and key path) written to .env"]);
|
|
242
|
+
if (offerSecret) {
|
|
243
|
+
// setEnvKeyIfEmpty, NOT overwrite: an operator's existing webhook secret is what their
|
|
244
|
+
// already-configured hooks sign with — replacing it would invalidate working deliveries.
|
|
245
|
+
const { changed } = updateEnvFile(envPath, "WEBHOOK_SECRET", app.webhook_secret, { fs });
|
|
246
|
+
say(changed ? "✓ WEBHOOK_SECRET set in .env (value not shown)\n" : "✓ WEBHOOK_SECRET already set in .env — kept (your configured hooks keep verifying)\n");
|
|
247
|
+
summary.push(["WEBHOOK_SECRET", changed ? "set from the App's minted secret (value not shown)" : "already set — kept, so existing deliveries stay valid"]);
|
|
248
|
+
}
|
|
249
|
+
say(`✓ credentials written\n`);
|
|
250
|
+
|
|
251
|
+
// -- (f) install, then discover the installation id ---------------------------------------
|
|
252
|
+
const installUrl = `https://github.com/apps/${app.slug}/installations/new`;
|
|
253
|
+
say(`\nInstall the App on the repositories it should work on (a GitHub consent screen — deliberately not automated):\n\n ${installUrl}\n\n`);
|
|
254
|
+
openBrowser(installUrl);
|
|
255
|
+
const manualIdHint = `find the id later under the App's settings → Install App (the number in the installation URL), then set GITHUB_APP_INSTALLATION_ID=<id> in ${envPath}`;
|
|
256
|
+
if (!(await consent(prompt, "installed? [y/N] "))) {
|
|
257
|
+
say(`\nno problem — install it whenever you like at the URL above; ${manualIdHint}\n`);
|
|
258
|
+
summary.push(["installation id", "not discovered (App not installed yet) — set GITHUB_APP_INSTALLATION_ID by hand after installing"]);
|
|
259
|
+
printSummary(say, summary, { noWebhook });
|
|
260
|
+
return 0;
|
|
261
|
+
}
|
|
262
|
+
// A ~15-line hand-rolled RS256 JWT (mintAppJwt below), deliberately NOT @octokit/auth-app:
|
|
263
|
+
// this runs once at setup time, and a dependency-light, auditable JWT is worth more here than
|
|
264
|
+
// library reuse — the operator can read every byte that touches their brand-new key. Job-time
|
|
265
|
+
// minting (get-token.mjs) keeps using @octokit/auth-app, unchanged.
|
|
266
|
+
const jwt = mintAppJwt(app.id, app.pem, Math.floor(now() / 1000));
|
|
267
|
+
secrets.push(jwt); // short-lived, but a valid credential for ~9 minutes — same no-print rule
|
|
268
|
+
let instResponse;
|
|
269
|
+
let instBody;
|
|
270
|
+
try {
|
|
271
|
+
instResponse = await fetchFn(`${API_ROOT}/app/installations`, {
|
|
272
|
+
headers: { authorization: `Bearer ${jwt}`, accept: "application/vnd.github+json", "user-agent": USER_AGENT },
|
|
273
|
+
});
|
|
274
|
+
instBody = await instResponse.text();
|
|
275
|
+
} catch (error) {
|
|
276
|
+
say(`error: could not reach ${API_ROOT} to list installations (${error?.message ?? "network error"})\n`);
|
|
277
|
+
say(` → the credentials above ARE written and valid; ${manualIdHint}\n`);
|
|
278
|
+
return 1;
|
|
279
|
+
}
|
|
280
|
+
if (instResponse.status !== 200) {
|
|
281
|
+
say(`error: GET /app/installations returned ${instResponse.status} (expected 200): ${String(instBody).slice(0, 300)}\n`);
|
|
282
|
+
say(` → the credentials above ARE written; ${manualIdHint}\n`);
|
|
283
|
+
return 1;
|
|
284
|
+
}
|
|
285
|
+
let installations;
|
|
286
|
+
try {
|
|
287
|
+
installations = JSON.parse(instBody);
|
|
288
|
+
} catch {
|
|
289
|
+
say(`error: GitHub's installations response was not parseable JSON\n → ${manualIdHint}\n`);
|
|
290
|
+
return 1;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
let chosen;
|
|
294
|
+
if (installations.length === 0) {
|
|
295
|
+
say(`\nGitHub reports no installations yet — the install may still be in flight.\n`);
|
|
296
|
+
say(` → install at ${installUrl}, then ${manualIdHint}\n`);
|
|
297
|
+
summary.push(["installation id", "none found — install the App, then set GITHUB_APP_INSTALLATION_ID by hand"]);
|
|
298
|
+
printSummary(say, summary, { noWebhook });
|
|
299
|
+
return 0;
|
|
300
|
+
}
|
|
301
|
+
if (installations.length === 1) {
|
|
302
|
+
chosen = installations[0];
|
|
303
|
+
} else {
|
|
304
|
+
say("\nthe App is installed in more than one place:\n");
|
|
305
|
+
for (const inst of installations) say(` ${String(inst.id).padEnd(12)} ${inst.account?.login ?? "(unknown account)"}\n`);
|
|
306
|
+
const answer = String((await prompt("installation id to use (blank to skip): ")) ?? "").trim();
|
|
307
|
+
if (answer === "") {
|
|
308
|
+
say(`skipped — ${manualIdHint}\n`);
|
|
309
|
+
summary.push(["installation id", "skipped — set GITHUB_APP_INSTALLATION_ID by hand"]);
|
|
310
|
+
printSummary(say, summary, { noWebhook });
|
|
311
|
+
return 0;
|
|
312
|
+
}
|
|
313
|
+
chosen = installations.find((inst) => String(inst.id) === answer);
|
|
314
|
+
if (!chosen) {
|
|
315
|
+
say(`error: ${answer} is not one of the listed installation ids\n → ${manualIdHint}\n`);
|
|
316
|
+
return 1;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
// Consented and shown first, like every other write in this wizard — an id is not a secret,
|
|
320
|
+
// so this one IS printed in full.
|
|
321
|
+
say(`\nsetup would write to ${envPath}:\n GITHUB_APP_INSTALLATION_ID=${chosen.id}${chosen.account?.login ? ` (account: ${chosen.account.login})` : ""}\n`);
|
|
322
|
+
if (await consent(prompt, "write it? [y/N] ")) {
|
|
323
|
+
updateEnvFile(envPath, "GITHUB_APP_INSTALLATION_ID", String(chosen.id), { fs, overwrite: true });
|
|
324
|
+
say("✓ GITHUB_APP_INSTALLATION_ID written\n");
|
|
325
|
+
summary.push(["installation id", `GITHUB_APP_INSTALLATION_ID=${chosen.id} written to .env`]);
|
|
326
|
+
} else {
|
|
327
|
+
say(`declined — ${manualIdHint}\n`);
|
|
328
|
+
summary.push(["installation id", "declined — set GITHUB_APP_INSTALLATION_ID by hand"]);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// -- (g) summary ---------------------------------------------------------------------------
|
|
332
|
+
printSummary(say, summary, { noWebhook });
|
|
333
|
+
return 0;
|
|
334
|
+
} finally {
|
|
335
|
+
listener.close();
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** What was written and what remains — up.mjs's summary voice, so the two wizards read as one tool. */
|
|
340
|
+
function printSummary(say, summary, { noWebhook }) {
|
|
341
|
+
say("\nsetup github: summary\n");
|
|
342
|
+
for (const [name, note] of summary) say(` ${name.padEnd(17)} ${note}\n`);
|
|
343
|
+
say("\nNext:\n");
|
|
344
|
+
if (noWebhook) {
|
|
345
|
+
say(" the App was created with its webhook INACTIVE (--no-webhook) — the polling-ready shape.\n");
|
|
346
|
+
say(" Polling delivery is a follow-up; until it lands, forge triggers for this App will not fire on their own.\n");
|
|
347
|
+
} else {
|
|
348
|
+
say(" start the receiver so deliveries have somewhere to land: `pi-dispatch-receiver`\n");
|
|
349
|
+
say(" (or the deploy/docker-compose.yml receiver profile), reachable at the --webhook-url you gave GitHub.\n");
|
|
350
|
+
}
|
|
351
|
+
say(" `pi-dispatch doctor` re-checks the whole deployment, including these credentials.\n");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** up.mjs's consent contract: default No; only an explicit y/yes (any case) accepts. */
|
|
355
|
+
async function consent(prompt, question) {
|
|
356
|
+
const answer = await prompt(question);
|
|
357
|
+
return /^y(es)?$/i.test(String(answer ?? "").trim());
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** `pi-dispatch-<host>`, before sanitization — split out so the derivation is testable without os. */
|
|
361
|
+
export function defaultAppName(host) {
|
|
362
|
+
return `pi-dispatch-${host ?? ""}`;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Fit a name into GitHub's App-name rules: ≤34 chars, and kept to lowercase letters, digits, and
|
|
367
|
+
* hyphens so the name survives GitHub's own slugging predictably (the slug becomes the install URL
|
|
368
|
+
* and the PEM filename). Anything else collapses to a hyphen; runs and edge hyphens are trimmed.
|
|
369
|
+
*/
|
|
370
|
+
export function sanitizeAppName(raw) {
|
|
371
|
+
const cleaned = String(raw ?? "")
|
|
372
|
+
.toLowerCase()
|
|
373
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
374
|
+
.replace(/-+/g, "-")
|
|
375
|
+
.replace(/^-|-$/g, "")
|
|
376
|
+
.slice(0, NAME_MAX)
|
|
377
|
+
.replace(/-$/, "");
|
|
378
|
+
return cleaned || "pi-dispatch";
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* The manifest GitHub converts into the App — the narrowest shape this system needs
|
|
383
|
+
* (SECURITY.md's auth-source ladder): write on exactly the three surfaces jobs touch, metadata read
|
|
384
|
+
* because the API requires it, private, and only the three event families the triggers file can name.
|
|
385
|
+
* `hook_attributes` carries the receiver URL, or `active: false` under --no-webhook — the
|
|
386
|
+
* no-public-URL shape the polling transport will consume.
|
|
387
|
+
*/
|
|
388
|
+
export function buildManifest({ name, redirectUrl, webhookUrl }) {
|
|
389
|
+
return {
|
|
390
|
+
name,
|
|
391
|
+
url: HOMEPAGE,
|
|
392
|
+
redirect_url: redirectUrl,
|
|
393
|
+
hook_attributes: webhookUrl ? { url: webhookUrl } : { active: false },
|
|
394
|
+
public: false,
|
|
395
|
+
default_permissions: { contents: "write", pull_requests: "write", issues: "write", metadata: "read" },
|
|
396
|
+
default_events: ["issues", "issue_comment", "pull_request"],
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* The page the loopback listener serves at GET /: a form that POSTs `manifest=<json>` to the GitHub
|
|
402
|
+
* create-from-manifest endpoint. A FORM, not a link, because the manifest flow requires the payload
|
|
403
|
+
* in a browser POST body. Auto-submits via script; the visible button is the no-JS fallback and the
|
|
404
|
+
* human-readable statement of what is about to be sent where.
|
|
405
|
+
*/
|
|
406
|
+
export function buildFormPage(manifest, postTarget) {
|
|
407
|
+
const json = escapeHtml(JSON.stringify(manifest));
|
|
408
|
+
const target = escapeHtml(postTarget);
|
|
409
|
+
return `<!doctype html>
|
|
410
|
+
<meta charset="utf-8">
|
|
411
|
+
<title>pi-dispatch setup github</title>
|
|
412
|
+
<body>
|
|
413
|
+
<p>Submitting the GitHub App manifest to <code>${target}</code> …</p>
|
|
414
|
+
<form id="m" action="${target}" method="post">
|
|
415
|
+
<input type="hidden" name="manifest" value="${json}">
|
|
416
|
+
<button type="submit">Create the GitHub App</button>
|
|
417
|
+
</form>
|
|
418
|
+
<script>document.getElementById("m").submit();</script>
|
|
419
|
+
</body>`;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function escapeHtml(s) {
|
|
423
|
+
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* An RS256 app JWT, hand-rolled on node:crypto — deliberately NOT @octokit/auth-app, although it is
|
|
428
|
+
* already a dependency: this signature runs ONCE at setup time with a key minted seconds ago, and
|
|
429
|
+
* ~15 auditable lines the operator can read beat a library call they have to trust. `iat` is
|
|
430
|
+
* backdated 60s (GitHub's own clock-drift allowance) and `exp` stays inside the 10-minute maximum.
|
|
431
|
+
* `iss` is the app id, stringified — GitHub accepts either form.
|
|
432
|
+
*/
|
|
433
|
+
export function mintAppJwt(appId, pem, nowSeconds) {
|
|
434
|
+
const b64url = (s) => Buffer.from(s).toString("base64url");
|
|
435
|
+
const header = b64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
|
|
436
|
+
const iat = nowSeconds - 60;
|
|
437
|
+
const payload = b64url(JSON.stringify({ iat, exp: iat + 600, iss: String(appId) }));
|
|
438
|
+
const signer = createSign("RSA-SHA256");
|
|
439
|
+
signer.update(`${header}.${payload}`);
|
|
440
|
+
const signature = signer.sign(pem).toString("base64url");
|
|
441
|
+
return `${header}.${payload}.${signature}`;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* The default listener seam: node:http on 127.0.0.1, port 0 (kernel-assigned, so nothing collides).
|
|
446
|
+
* Serves GET / with `pageFor(url)` (built lazily — the manifest's redirect_url needs the final port)
|
|
447
|
+
* and GET /callback?code=… as the capture. Returns exactly `{ port, url, waitForCode, close }`;
|
|
448
|
+
* `waitForCode(timeoutMs)` resolves with the code or rejects on timeout. Loopback-only by
|
|
449
|
+
* construction: the bind address is 127.0.0.1, so nothing off-machine can reach the form or the code.
|
|
450
|
+
*/
|
|
451
|
+
async function defaultListen(pageFor) {
|
|
452
|
+
const { createServer } = await import("node:http");
|
|
453
|
+
let resolveCode;
|
|
454
|
+
const codePromise = new Promise((resolve) => {
|
|
455
|
+
resolveCode = resolve;
|
|
456
|
+
});
|
|
457
|
+
let url = "";
|
|
458
|
+
const server = createServer((req, res) => {
|
|
459
|
+
const requestUrl = new URL(req.url, "http://127.0.0.1");
|
|
460
|
+
if (req.method === "GET" && requestUrl.pathname === "/") {
|
|
461
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
462
|
+
res.end(pageFor(url));
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (req.method === "GET" && requestUrl.pathname === "/callback") {
|
|
466
|
+
const code = requestUrl.searchParams.get("code");
|
|
467
|
+
if (!code) {
|
|
468
|
+
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
|
|
469
|
+
res.end("missing ?code — this URL is GitHub's redirect target, not a page to open by hand\n");
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
473
|
+
res.end("<!doctype html><meta charset=\"utf-8\"><p>Got it — return to the terminal running <code>pi-dispatch setup github</code>. You can close this tab.</p>");
|
|
474
|
+
resolveCode(code);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
478
|
+
res.end("not found\n");
|
|
479
|
+
});
|
|
480
|
+
await new Promise((resolve, reject) => {
|
|
481
|
+
server.once("error", reject);
|
|
482
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
483
|
+
});
|
|
484
|
+
const port = server.address().port;
|
|
485
|
+
url = `http://127.0.0.1:${port}`;
|
|
486
|
+
return {
|
|
487
|
+
port,
|
|
488
|
+
url,
|
|
489
|
+
waitForCode: (timeoutMs) =>
|
|
490
|
+
new Promise((resolve, reject) => {
|
|
491
|
+
const timer = setTimeout(() => reject(new Error(`no redirect after ${Math.round(timeoutMs / 1000)}s`)), timeoutMs);
|
|
492
|
+
codePromise.then((code) => {
|
|
493
|
+
clearTimeout(timer);
|
|
494
|
+
resolve(code);
|
|
495
|
+
});
|
|
496
|
+
}),
|
|
497
|
+
close: () => server.close(),
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Best-effort platform browser opener. ALWAYS paired with the URL printed to the terminal — a
|
|
503
|
+
* headless or SSH'd operator has no opener that works, and the printed URL pasted into any browser
|
|
504
|
+
* (on the right machine, or through the port-forward the wizard suggests) is the real contract; the
|
|
505
|
+
* spawn is only a convenience on top. Failures are swallowed for the same reason.
|
|
506
|
+
*/
|
|
507
|
+
function defaultOpenBrowser(url) {
|
|
508
|
+
const [cmd, args] =
|
|
509
|
+
process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
510
|
+
try {
|
|
511
|
+
const child = nodeSpawn(cmd, args, { stdio: "ignore", detached: true });
|
|
512
|
+
child.on("error", () => {});
|
|
513
|
+
child.unref();
|
|
514
|
+
} catch {
|
|
515
|
+
// No opener on this host — the printed URL carries the flow.
|
|
516
|
+
}
|
|
517
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side GitHub reads and writes the worker performs AROUND a job, distinct from anything the
|
|
3
|
+
* agent does inside its container: resolve the default-branch tip, decide whether that branch is
|
|
4
|
+
* protected, and post an outcome comment back to the triggering issue.
|
|
5
|
+
*
|
|
6
|
+
* REQ-BRANCH-PROTECTION-PRECONDITION / CONST-MERGE-NEVER-AUTOMATIC: `isDefaultBranchProtected` is
|
|
7
|
+
* the free gate the processor consults before spending. The agent's scoped token carries
|
|
8
|
+
* contents:write, which covers push AND merge, so branch protection is the only technical barrier
|
|
9
|
+
* to a self-merge. A transient read failure must therefore NOT collapse to "unprotected" and let
|
|
10
|
+
* the job run: only a real 404 (the repo has no protection object) is the determinate unprotected
|
|
11
|
+
* state that drives the policy refusal; any other error is retryable and never a silent `false`.
|
|
12
|
+
*
|
|
13
|
+
* REQ-JOB-STATUS-COMMENTS: `postStatusComment` reports outcome to the issue or PR. It is content-
|
|
14
|
+
* agnostic -- the no-trigger-phrase guard belongs to the caller -- and never logs the comment body or
|
|
15
|
+
* any issue/PR-derived content (no-pii-in-logs); a job is identified by `repo#number` alone.
|
|
16
|
+
*
|
|
17
|
+
* CONST-TOKEN-SCOPED-PER-JOB: the token is minted per job and passed to every method. Each method
|
|
18
|
+
* constructs a FRESH Octokit through the injected `octokitFor(token)`; no client is cached or reused
|
|
19
|
+
* across calls, so one job's short-lived credential never bleeds into another job's request.
|
|
20
|
+
*
|
|
21
|
+
* `octokitFor` is injected -- defaulting to the real `@octokit/rest` -- so the module is testable
|
|
22
|
+
* offline against a fake `request(route, params)`, matching the identity.mjs / get-token.mjs
|
|
23
|
+
* convention. This module calls NO merge API of any kind.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { Octokit } from "@octokit/rest";
|
|
27
|
+
import { configError } from "./config.mjs";
|
|
28
|
+
import { InfraRetry } from "./processor.mjs";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build the host surface. `octokitFor` is `(token) => new Octokit({ auth: token })`; inject a fake
|
|
32
|
+
* to test offline. Returns `{ resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment }`.
|
|
33
|
+
*/
|
|
34
|
+
export function makeGitHubHost({ octokitFor = (token) => new Octokit({ auth: token }) } = {}) {
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the default branch and its tip SHA with FRESH API calls only -- never a webhook field.
|
|
37
|
+
* `GET /repos/{owner}/{repo}` yields `default_branch`; `GET .../branches/{branch}` yields the tip.
|
|
38
|
+
* Returns `{ branch, sha }` so the caller has both the name and the commit to clone at.
|
|
39
|
+
*/
|
|
40
|
+
async function resolveDefaultBranchSha(repo, token) {
|
|
41
|
+
const [owner, name] = splitRepo(repo);
|
|
42
|
+
const octokit = octokitFor(token);
|
|
43
|
+
const { data: repoData } = await octokit.request("GET /repos/{owner}/{repo}", {
|
|
44
|
+
owner,
|
|
45
|
+
repo: name,
|
|
46
|
+
});
|
|
47
|
+
const branch = repoData.default_branch;
|
|
48
|
+
const { data: branchData } = await octokit.request("GET /repos/{owner}/{repo}/branches/{branch}", {
|
|
49
|
+
owner,
|
|
50
|
+
repo: name,
|
|
51
|
+
branch,
|
|
52
|
+
});
|
|
53
|
+
return { branch, sha: branchData.commit.sha };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* True when the default branch has a protection object, false when it provably does not (404).
|
|
58
|
+
* A non-404 error is retryable, NOT `false`: a transient blip must not read as "unprotected" and
|
|
59
|
+
* bypass the never-merge backstop.
|
|
60
|
+
*/
|
|
61
|
+
async function isDefaultBranchProtected(repo, token) {
|
|
62
|
+
const [owner, name] = splitRepo(repo);
|
|
63
|
+
const octokit = octokitFor(token);
|
|
64
|
+
const { data: repoData } = await octokit.request("GET /repos/{owner}/{repo}", {
|
|
65
|
+
owner,
|
|
66
|
+
repo: name,
|
|
67
|
+
});
|
|
68
|
+
const branch = repoData.default_branch;
|
|
69
|
+
try {
|
|
70
|
+
await octokit.request("GET /repos/{owner}/{repo}/branches/{branch}/protection", {
|
|
71
|
+
owner,
|
|
72
|
+
repo: name,
|
|
73
|
+
branch,
|
|
74
|
+
});
|
|
75
|
+
return true;
|
|
76
|
+
} catch (error) {
|
|
77
|
+
// A missing protection object is a real, determinate state -- the repo is unprotected.
|
|
78
|
+
if (error?.status === 404) return false;
|
|
79
|
+
// Any other status is retryable rather than resolved. A 403 (the token lacks the scope to
|
|
80
|
+
// read protection) is treated as InfraRetry here too: acceptable for v1, though it could be
|
|
81
|
+
// a permanent config error. Collapsing non-404 to false would silently bypass never-merge.
|
|
82
|
+
throw new InfraRetry(
|
|
83
|
+
`github-host: branch-protection read failed for ${owner}/${name} (status ${error?.status ?? "unknown"})`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Post `text` as a comment on `target` -- the job's discriminated `{ type, number }`.
|
|
90
|
+
* Content-agnostic: `text` is passed through as `body` verbatim, never inspected, filtered, or logged.
|
|
91
|
+
*
|
|
92
|
+
* `target.type` is deliberately UNREAD here: on GitHub an issue and a pull request share one number
|
|
93
|
+
* sequence and one `/issues/{n}/comments` endpoint, so posting to a PR number comments on the PR
|
|
94
|
+
* conversation. The parameter is the whole target rather than a bare number because that is what a
|
|
95
|
+
* forge whose issue and merge-request comments are DIFFERENT endpoints needs, and a host method that
|
|
96
|
+
* cannot be called uniformly is not a seam. Passing the number alone would push that discrimination
|
|
97
|
+
* into the caller, where it would have to know which forge it was talking to.
|
|
98
|
+
*/
|
|
99
|
+
async function postStatusComment(repo, target, text, token) {
|
|
100
|
+
const [owner, name] = splitRepo(repo);
|
|
101
|
+
const octokit = octokitFor(token);
|
|
102
|
+
await octokit.request("POST /repos/{owner}/{repo}/issues/{issue_number}/comments", {
|
|
103
|
+
owner,
|
|
104
|
+
repo: name,
|
|
105
|
+
issue_number: target?.number,
|
|
106
|
+
body: text,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The head ref of a pull request, and the repository that ref lives in, from ONE fresh API call
|
|
112
|
+
* (REQ-RESUMABLE-SESSION, INT-SESSION-STORE-CONTRACT).
|
|
113
|
+
*
|
|
114
|
+
* Fresh rather than off the payload, for two reasons that are not the same. An `issue_comment` on a
|
|
115
|
+
* pull request -- which is how review feedback actually arrives -- carries no head at all, so the
|
|
116
|
+
* payload route simply cannot answer for the case this feature exists to serve. And
|
|
117
|
+
* `head.repo.full_name` in a webhook body is attacker-supplied: it decides the fork gate, so it must
|
|
118
|
+
* come from the forge rather than from the sender.
|
|
119
|
+
*
|
|
120
|
+
* Both fields are returned together deliberately. A caller that got the ref without the repo could
|
|
121
|
+
* key a session on a stranger's branch name; returning the pair makes the fork check available
|
|
122
|
+
* wherever the ref is.
|
|
123
|
+
*/
|
|
124
|
+
async function resolvePullRequestHead(job, token) {
|
|
125
|
+
const [owner, name] = splitRepo(job);
|
|
126
|
+
const octokit = octokitFor(token);
|
|
127
|
+
const { data } = await octokit.request("GET /repos/{owner}/{repo}/pulls/{pull_number}", {
|
|
128
|
+
owner,
|
|
129
|
+
repo: name,
|
|
130
|
+
pull_number: job?.target?.number,
|
|
131
|
+
});
|
|
132
|
+
return { headRef: data?.head?.ref, headRepo: data?.head?.repo?.full_name };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, resolvePullRequestHead };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Split `"owner/name"` into `[owner, name]`, throwing configError unless both parts are non-empty.
|
|
140
|
+
*
|
|
141
|
+
* Accepts the job itself or a bare `"owner/name"`. Taking the job is what lets every forge's host expose
|
|
142
|
+
* the same three signatures while reading whatever identifies a target on its own side -- GitLab's takes
|
|
143
|
+
* the numeric project id off the same object, because its `group/subgroup/project` paths have no fixed
|
|
144
|
+
* segment count and this split would pass on one while silently naming the parent group.
|
|
145
|
+
*/
|
|
146
|
+
function splitRepo(ref) {
|
|
147
|
+
const repo = typeof ref === "object" && ref !== null ? ref.repo : ref;
|
|
148
|
+
const segments = String(repo ?? "").split("/");
|
|
149
|
+
const [owner, name] = segments;
|
|
150
|
+
// EXACTLY two segments, not "at least two". The docstring above already describes what a longer path
|
|
151
|
+
// does to this split, and until now the code did not guard it: "org/project/repo" yields owner="org",
|
|
152
|
+
// name="project", both non-empty -- so it passes, and every call silently names a DIFFERENT repository
|
|
153
|
+
// than the job asked for. Azure DevOps is org/project/repo and GitLab is group/subgroup/project, so the
|
|
154
|
+
// wrong answer here is reachable by configuration, not only by a bug.
|
|
155
|
+
if (segments.length !== 2 || !owner || !name) {
|
|
156
|
+
throw configError(`github-host: malformed repo: ${JSON.stringify(repo)} (expected exactly "owner/name")`);
|
|
157
|
+
}
|
|
158
|
+
return [owner, name];
|
|
159
|
+
}
|