bosun 0.28.2 → 0.28.4
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 +68 -0
- package/README.md +1 -1
- package/agent-prompts.mjs +12 -6
- package/agent-work-analyzer.mjs +39 -15
- package/cli.mjs +4 -1
- package/codex-config.mjs +7 -0
- package/monitor.mjs +83 -24
- package/package.json +2 -1
- package/preflight.mjs +3 -1
- package/primary-agent.mjs +5 -1
- package/pwsh-runtime.mjs +62 -0
- package/setup.mjs +70 -3
- package/task-executor.mjs +125 -2
- package/telegram-bot.mjs +45 -8
- package/ui/app.js +2 -16
- package/ui/components/workspace-switcher.js +25 -32
- package/ui/modules/settings-schema.js +7 -0
- package/ui/styles/base.css +3 -28
- package/ui/styles/components.css +309 -73
- package/ui/styles/kanban.css +10 -16
- package/ui/styles/layout.css +81 -101
- package/ui/styles/sessions.css +27 -32
- package/ui/styles/variables.css +8 -8
- package/ui/styles/workspace-switcher.css +2 -4
- package/ui/tabs/control.js +40 -71
- package/ui/tabs/settings.js +207 -0
- package/ui/tabs/tasks.js +116 -129
- package/ui-server.mjs +487 -0
- package/workspace-manager.mjs +57 -11
package/ui-server.mjs
CHANGED
|
@@ -618,6 +618,8 @@ const SETTINGS_KNOWN_KEYS = [
|
|
|
618
618
|
"KANBAN_BACKEND", "KANBAN_SYNC_POLICY", "BOSUN_TASK_LABEL",
|
|
619
619
|
"BOSUN_ENFORCE_TASK_LABEL", "STALE_TASK_AGE_HOURS",
|
|
620
620
|
"TASK_PLANNER_MODE", "TASK_PLANNER_DEDUP_HOURS",
|
|
621
|
+
"TASK_BRANCH_MODE", "TASK_BRANCH_AUTO_MODULE", "TASK_UPSTREAM_SYNC_MAIN",
|
|
622
|
+
"MODULE_BRANCH_PREFIX", "DEFAULT_TARGET_BRANCH",
|
|
621
623
|
"BOSUN_PROMPT_PLANNER",
|
|
622
624
|
"GITHUB_TOKEN", "GITHUB_REPOSITORY", "GITHUB_PROJECT_MODE",
|
|
623
625
|
"GITHUB_PROJECT_NUMBER", "GITHUB_DEFAULT_ASSIGNEE", "GITHUB_AUTO_ASSIGN_CREATOR",
|
|
@@ -2113,6 +2115,430 @@ async function handleGitHubProjectWebhook(req, res) {
|
|
|
2113
2115
|
}
|
|
2114
2116
|
}
|
|
2115
2117
|
|
|
2118
|
+
// ─── GitHub App webhook ──────────────────────────────────────────────────────
|
|
2119
|
+
|
|
2120
|
+
function getAppWebhookPath() {
|
|
2121
|
+
return (
|
|
2122
|
+
process.env.BOSUN_GITHUB_APP_WEBHOOK_PATH ||
|
|
2123
|
+
"/api/webhooks/github/app"
|
|
2124
|
+
);
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
/**
|
|
2128
|
+
* Handles App-level webhook deliveries from GitHub.
|
|
2129
|
+
* Events: installation, installation_repositories, ping, pull_request, push…
|
|
2130
|
+
* Validates HMAC-SHA256 signature using BOSUN_GITHUB_WEBHOOK_SECRET.
|
|
2131
|
+
*/
|
|
2132
|
+
async function handleGitHubAppWebhook(req, res) {
|
|
2133
|
+
if (req.method !== "POST") {
|
|
2134
|
+
jsonResponse(res, 405, { ok: false, error: "Method not allowed" });
|
|
2135
|
+
return;
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
const deliveryId = String(req.headers["x-github-delivery"] || "");
|
|
2139
|
+
const eventType = String(req.headers["x-github-event"] || "").toLowerCase();
|
|
2140
|
+
|
|
2141
|
+
let rawBody;
|
|
2142
|
+
try {
|
|
2143
|
+
rawBody = await readRawBody(req);
|
|
2144
|
+
} catch (err) {
|
|
2145
|
+
jsonResponse(res, 400, { ok: false, error: "Failed to read body" });
|
|
2146
|
+
return;
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
// Validate HMAC signature if secret is configured
|
|
2150
|
+
const webhookSecret = process.env.BOSUN_GITHUB_WEBHOOK_SECRET || "";
|
|
2151
|
+
if (webhookSecret) {
|
|
2152
|
+
const sigHeader = req.headers["x-hub-signature-256"] || "";
|
|
2153
|
+
if (!verifyGitHubWebhookSignature(rawBody, sigHeader, webhookSecret)) {
|
|
2154
|
+
console.warn(
|
|
2155
|
+
`[app-webhook] delivery=${deliveryId} invalid signature — check BOSUN_GITHUB_WEBHOOK_SECRET`,
|
|
2156
|
+
);
|
|
2157
|
+
jsonResponse(res, 401, { ok: false, error: "Invalid webhook signature" });
|
|
2158
|
+
return;
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
let payload = {};
|
|
2163
|
+
try {
|
|
2164
|
+
payload = rawBody ? JSON.parse(rawBody) : {};
|
|
2165
|
+
} catch {
|
|
2166
|
+
jsonResponse(res, 400, { ok: false, error: "Invalid JSON payload" });
|
|
2167
|
+
return;
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
// Acknowledge immediately — GitHub requires a fast response
|
|
2171
|
+
jsonResponse(res, 202, { ok: true, deliveryId, eventType });
|
|
2172
|
+
|
|
2173
|
+
// Process asynchronously
|
|
2174
|
+
setImmediate(() => {
|
|
2175
|
+
try {
|
|
2176
|
+
_processAppWebhookEvent(eventType, payload, deliveryId);
|
|
2177
|
+
} catch (err) {
|
|
2178
|
+
console.warn(`[app-webhook] processing error delivery=${deliveryId}: ${err.message}`);
|
|
2179
|
+
}
|
|
2180
|
+
});
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
function _processAppWebhookEvent(eventType, payload, deliveryId) {
|
|
2184
|
+
switch (eventType) {
|
|
2185
|
+
case "ping":
|
|
2186
|
+
console.log(
|
|
2187
|
+
`[app-webhook] ping delivery=${deliveryId} zen="${payload.zen || ""}"`,
|
|
2188
|
+
);
|
|
2189
|
+
break;
|
|
2190
|
+
|
|
2191
|
+
case "installation": {
|
|
2192
|
+
const action = payload.action || "";
|
|
2193
|
+
const login = payload.installation?.account?.login || "unknown";
|
|
2194
|
+
const repos = (payload.repositories || []).map((r) => r.full_name);
|
|
2195
|
+
console.log(
|
|
2196
|
+
`[app-webhook] installation ${action} account=${login} repos=${repos.join(",") || "(all)"}`,
|
|
2197
|
+
);
|
|
2198
|
+
broadcastUiEvent(["overview"], "invalidate", {
|
|
2199
|
+
reason: `github-app-installation-${action}`,
|
|
2200
|
+
account: login,
|
|
2201
|
+
});
|
|
2202
|
+
break;
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
case "installation_repositories": {
|
|
2206
|
+
const action = payload.action || "";
|
|
2207
|
+
const added = (payload.repositories_added || []).map((r) => r.full_name);
|
|
2208
|
+
const removed = (payload.repositories_removed || []).map((r) => r.full_name);
|
|
2209
|
+
console.log(
|
|
2210
|
+
`[app-webhook] installation_repositories ${action} added=${added.join(",")} removed=${removed.join(",")}`,
|
|
2211
|
+
);
|
|
2212
|
+
break;
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
default:
|
|
2216
|
+
console.log(
|
|
2217
|
+
`[app-webhook] delivery=${deliveryId} event=${eventType} action=${payload.action || ""} (unhandled, ack'd)`,
|
|
2218
|
+
);
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
// ─── GitHub OAuth callback ────────────────────────────────────────────────────
|
|
2223
|
+
|
|
2224
|
+
/**
|
|
2225
|
+
* Handles the OAuth redirect from GitHub after a user installs/authorizes
|
|
2226
|
+
* the Bosun[botswain] GitHub App.
|
|
2227
|
+
*
|
|
2228
|
+
* Flow:
|
|
2229
|
+
* 1. GitHub redirects: GET /api/github/callback?code=xxx&installation_id=yyy
|
|
2230
|
+
* 2. We exchange `code` for a user access token
|
|
2231
|
+
* 3. We fetch the user's GitHub login for confirmation
|
|
2232
|
+
* 4. We redirect the user to the main app UI with a success banner
|
|
2233
|
+
*
|
|
2234
|
+
* The token is surfaced in the response so the operator can copy it;
|
|
2235
|
+
* it is also written to .env as GH_TOKEN if GH_TOKEN is currently unset.
|
|
2236
|
+
*/
|
|
2237
|
+
async function handleGitHubOAuthCallback(req, res) {
|
|
2238
|
+
if (req.method !== "GET") {
|
|
2239
|
+
jsonResponse(res, 405, { ok: false, error: "Method not allowed" });
|
|
2240
|
+
return;
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
const urlObj = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
|
2244
|
+
const code = urlObj.searchParams.get("code") || "";
|
|
2245
|
+
const installationId = urlObj.searchParams.get("installation_id") || "";
|
|
2246
|
+
const setupAction = urlObj.searchParams.get("setup_action") || "";
|
|
2247
|
+
|
|
2248
|
+
// If no code, this might be a test ping — just return 200
|
|
2249
|
+
if (!code) {
|
|
2250
|
+
jsonResponse(res, 400, { ok: false, error: "Missing code parameter" });
|
|
2251
|
+
return;
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
const clientId = process.env.BOSUN_GITHUB_CLIENT_ID || "";
|
|
2255
|
+
const clientSecret = process.env.BOSUN_GITHUB_CLIENT_SECRET || "";
|
|
2256
|
+
|
|
2257
|
+
if (!clientId || !clientSecret) {
|
|
2258
|
+
console.warn("[oauth-callback] BOSUN_GITHUB_CLIENT_ID/CLIENT_SECRET not set — cannot exchange code");
|
|
2259
|
+
res.writeHead(302, { Location: "/?oauth=error&reason=not_configured" });
|
|
2260
|
+
res.end();
|
|
2261
|
+
return;
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
try {
|
|
2265
|
+
// Exchange code for user access token
|
|
2266
|
+
const tokenBody = new URLSearchParams({
|
|
2267
|
+
client_id: clientId,
|
|
2268
|
+
client_secret: clientSecret,
|
|
2269
|
+
code,
|
|
2270
|
+
});
|
|
2271
|
+
const tokenRes = await fetch("https://github.com/login/oauth/access_token", {
|
|
2272
|
+
method: "POST",
|
|
2273
|
+
headers: {
|
|
2274
|
+
Accept: "application/json",
|
|
2275
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
2276
|
+
"User-Agent": "bosun-botswain",
|
|
2277
|
+
},
|
|
2278
|
+
body: tokenBody.toString(),
|
|
2279
|
+
});
|
|
2280
|
+
|
|
2281
|
+
if (!tokenRes.ok) {
|
|
2282
|
+
throw new Error(`Token exchange HTTP ${tokenRes.status}`);
|
|
2283
|
+
}
|
|
2284
|
+
const tokenData = await tokenRes.json();
|
|
2285
|
+
if (tokenData.error) {
|
|
2286
|
+
throw new Error(`${tokenData.error}: ${tokenData.error_description || ""}`);
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
const accessToken = tokenData.access_token;
|
|
2290
|
+
|
|
2291
|
+
// Fetch the user identity for logging / display
|
|
2292
|
+
let login = "unknown";
|
|
2293
|
+
try {
|
|
2294
|
+
const userRes = await fetch("https://api.github.com/user", {
|
|
2295
|
+
headers: {
|
|
2296
|
+
Authorization: `Bearer ${accessToken}`,
|
|
2297
|
+
Accept: "application/vnd.github+json",
|
|
2298
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
2299
|
+
"User-Agent": "bosun-botswain",
|
|
2300
|
+
},
|
|
2301
|
+
});
|
|
2302
|
+
if (userRes.ok) {
|
|
2303
|
+
const u = await userRes.json();
|
|
2304
|
+
login = u.login || "unknown";
|
|
2305
|
+
}
|
|
2306
|
+
} catch {
|
|
2307
|
+
// non-fatal
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
console.log(
|
|
2311
|
+
`[oauth-callback] authorized user=${login} installation_id=${installationId} setup_action=${setupAction}`,
|
|
2312
|
+
);
|
|
2313
|
+
|
|
2314
|
+
// If GH_TOKEN is not already set, write the token to .env so Bosun can use it.
|
|
2315
|
+
if (!process.env.GH_TOKEN && accessToken) {
|
|
2316
|
+
try {
|
|
2317
|
+
updateEnvFile({ GH_TOKEN: accessToken });
|
|
2318
|
+
process.env.GH_TOKEN = accessToken;
|
|
2319
|
+
console.log(`[oauth-callback] wrote GH_TOKEN to .env for user=${login}`);
|
|
2320
|
+
} catch (err) {
|
|
2321
|
+
console.warn(`[oauth-callback] could not write GH_TOKEN to .env: ${err.message}`);
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
broadcastUiEvent(["overview"], "invalidate", {
|
|
2326
|
+
reason: "github-oauth-authorized",
|
|
2327
|
+
login,
|
|
2328
|
+
installationId,
|
|
2329
|
+
});
|
|
2330
|
+
|
|
2331
|
+
// Redirect to the main UI with success indication
|
|
2332
|
+
const redirectUrl = `/?oauth=success&gh_user=${encodeURIComponent(login)}${installationId ? `&installation_id=${encodeURIComponent(installationId)}` : ""}`;
|
|
2333
|
+
res.writeHead(302, { Location: redirectUrl });
|
|
2334
|
+
res.end();
|
|
2335
|
+
} catch (err) {
|
|
2336
|
+
console.warn(`[oauth-callback] error: ${err.message}`);
|
|
2337
|
+
res.writeHead(302, {
|
|
2338
|
+
Location: `/?oauth=error&reason=${encodeURIComponent(err.message.slice(0, 100))}`,
|
|
2339
|
+
});
|
|
2340
|
+
res.end();
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
// ─── GitHub Device Flow ───────────────────────────────────────────────────────
|
|
2345
|
+
|
|
2346
|
+
/**
|
|
2347
|
+
* POST /api/github/device/start
|
|
2348
|
+
* Kicks off the OAuth Device Flow — returns a user code + verification URL.
|
|
2349
|
+
* No public URL, no callback, no client secret needed.
|
|
2350
|
+
* User visits github.com/login/device, enters the code, done.
|
|
2351
|
+
*/
|
|
2352
|
+
async function handleDeviceFlowStart(req, res) {
|
|
2353
|
+
if (req.method !== "POST") {
|
|
2354
|
+
jsonResponse(res, 405, { ok: false, error: "Method not allowed" });
|
|
2355
|
+
return;
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
const clientId = (process.env.BOSUN_GITHUB_CLIENT_ID || "").trim();
|
|
2359
|
+
if (!clientId) {
|
|
2360
|
+
jsonResponse(res, 400, {
|
|
2361
|
+
ok: false,
|
|
2362
|
+
error: "BOSUN_GITHUB_CLIENT_ID is not set. Configure it in Settings → GitHub.",
|
|
2363
|
+
});
|
|
2364
|
+
return;
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
try {
|
|
2368
|
+
const body = new URLSearchParams({ client_id: clientId, scope: "repo" });
|
|
2369
|
+
const ghRes = await fetch("https://github.com/login/device/code", {
|
|
2370
|
+
method: "POST",
|
|
2371
|
+
headers: {
|
|
2372
|
+
Accept: "application/json",
|
|
2373
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
2374
|
+
"User-Agent": "bosun-botswain",
|
|
2375
|
+
},
|
|
2376
|
+
body: body.toString(),
|
|
2377
|
+
});
|
|
2378
|
+
|
|
2379
|
+
if (!ghRes.ok) {
|
|
2380
|
+
const text = await ghRes.text();
|
|
2381
|
+
throw new Error(`GitHub device/code ${ghRes.status}: ${text}`);
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
const data = await ghRes.json();
|
|
2385
|
+
if (data.error) {
|
|
2386
|
+
throw new Error(`${data.error}: ${data.error_description || ""}`);
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
console.log(`[device-flow] started — user code: ${data.user_code}`);
|
|
2390
|
+
|
|
2391
|
+
jsonResponse(res, 200, {
|
|
2392
|
+
ok: true,
|
|
2393
|
+
data: {
|
|
2394
|
+
deviceCode: data.device_code,
|
|
2395
|
+
userCode: data.user_code,
|
|
2396
|
+
verificationUri: data.verification_uri,
|
|
2397
|
+
expiresIn: data.expires_in,
|
|
2398
|
+
interval: data.interval || 5,
|
|
2399
|
+
},
|
|
2400
|
+
});
|
|
2401
|
+
} catch (err) {
|
|
2402
|
+
console.warn(`[device-flow] start error: ${err.message}`);
|
|
2403
|
+
jsonResponse(res, 500, { ok: false, error: err.message });
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
/**
|
|
2408
|
+
* POST /api/github/device/poll
|
|
2409
|
+
* Polls GitHub to check if the user has entered the device code.
|
|
2410
|
+
* Body: { deviceCode: "..." }
|
|
2411
|
+
*
|
|
2412
|
+
* Returns:
|
|
2413
|
+
* { status: "pending" } — still waiting
|
|
2414
|
+
* { status: "complete", login } — done, token saved
|
|
2415
|
+
* { status: "expired" } — code expired, restart
|
|
2416
|
+
* { status: "error", error } — something went wrong
|
|
2417
|
+
*/
|
|
2418
|
+
async function handleDeviceFlowPoll(req, res) {
|
|
2419
|
+
if (req.method !== "POST") {
|
|
2420
|
+
jsonResponse(res, 405, { ok: false, error: "Method not allowed" });
|
|
2421
|
+
return;
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
const clientId = (process.env.BOSUN_GITHUB_CLIENT_ID || "").trim();
|
|
2425
|
+
if (!clientId) {
|
|
2426
|
+
jsonResponse(res, 400, { ok: false, error: "BOSUN_GITHUB_CLIENT_ID not set" });
|
|
2427
|
+
return;
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
let deviceCode;
|
|
2431
|
+
try {
|
|
2432
|
+
const body = await readJsonBody(req);
|
|
2433
|
+
deviceCode = body?.deviceCode;
|
|
2434
|
+
} catch {
|
|
2435
|
+
jsonResponse(res, 400, { ok: false, error: "Invalid JSON body" });
|
|
2436
|
+
return;
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
if (!deviceCode) {
|
|
2440
|
+
jsonResponse(res, 400, { ok: false, error: "deviceCode is required" });
|
|
2441
|
+
return;
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
try {
|
|
2445
|
+
const body = new URLSearchParams({
|
|
2446
|
+
client_id: clientId,
|
|
2447
|
+
device_code: deviceCode,
|
|
2448
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
2449
|
+
});
|
|
2450
|
+
|
|
2451
|
+
const ghRes = await fetch("https://github.com/login/oauth/access_token", {
|
|
2452
|
+
method: "POST",
|
|
2453
|
+
headers: {
|
|
2454
|
+
Accept: "application/json",
|
|
2455
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
2456
|
+
"User-Agent": "bosun-botswain",
|
|
2457
|
+
},
|
|
2458
|
+
body: body.toString(),
|
|
2459
|
+
});
|
|
2460
|
+
|
|
2461
|
+
if (!ghRes.ok) {
|
|
2462
|
+
throw new Error(`Token poll HTTP ${ghRes.status}`);
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
const data = await ghRes.json();
|
|
2466
|
+
|
|
2467
|
+
// Success — got a token
|
|
2468
|
+
if (data.access_token) {
|
|
2469
|
+
const accessToken = data.access_token;
|
|
2470
|
+
|
|
2471
|
+
// Fetch user identity
|
|
2472
|
+
let login = "unknown";
|
|
2473
|
+
try {
|
|
2474
|
+
const userRes = await fetch("https://api.github.com/user", {
|
|
2475
|
+
headers: {
|
|
2476
|
+
Authorization: `Bearer ${accessToken}`,
|
|
2477
|
+
Accept: "application/vnd.github+json",
|
|
2478
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
2479
|
+
"User-Agent": "bosun-botswain",
|
|
2480
|
+
},
|
|
2481
|
+
});
|
|
2482
|
+
if (userRes.ok) {
|
|
2483
|
+
const u = await userRes.json();
|
|
2484
|
+
login = u.login || "unknown";
|
|
2485
|
+
}
|
|
2486
|
+
} catch {
|
|
2487
|
+
// non-fatal
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
console.log(`[device-flow] authorized user=${login}`);
|
|
2491
|
+
|
|
2492
|
+
// Save token to .env and process.env
|
|
2493
|
+
if (accessToken) {
|
|
2494
|
+
try {
|
|
2495
|
+
updateEnvFile({ GH_TOKEN: accessToken });
|
|
2496
|
+
process.env.GH_TOKEN = accessToken;
|
|
2497
|
+
console.log(`[device-flow] wrote GH_TOKEN to .env for user=${login}`);
|
|
2498
|
+
} catch (err) {
|
|
2499
|
+
console.warn(`[device-flow] could not write GH_TOKEN to .env: ${err.message}`);
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
broadcastUiEvent(["overview"], "invalidate", {
|
|
2504
|
+
reason: "github-device-flow-authorized",
|
|
2505
|
+
login,
|
|
2506
|
+
});
|
|
2507
|
+
|
|
2508
|
+
jsonResponse(res, 200, { ok: true, data: { status: "complete", login } });
|
|
2509
|
+
return;
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
// Still pending or error
|
|
2513
|
+
switch (data.error) {
|
|
2514
|
+
case "authorization_pending":
|
|
2515
|
+
jsonResponse(res, 200, { ok: true, data: { status: "pending" } });
|
|
2516
|
+
return;
|
|
2517
|
+
case "slow_down":
|
|
2518
|
+
jsonResponse(res, 200, {
|
|
2519
|
+
ok: true,
|
|
2520
|
+
data: { status: "slow_down", interval: data.interval },
|
|
2521
|
+
});
|
|
2522
|
+
return;
|
|
2523
|
+
case "expired_token":
|
|
2524
|
+
jsonResponse(res, 200, { ok: true, data: { status: "expired" } });
|
|
2525
|
+
return;
|
|
2526
|
+
default:
|
|
2527
|
+
jsonResponse(res, 200, {
|
|
2528
|
+
ok: true,
|
|
2529
|
+
data: {
|
|
2530
|
+
status: "error",
|
|
2531
|
+
error: data.error,
|
|
2532
|
+
description: data.error_description || "",
|
|
2533
|
+
},
|
|
2534
|
+
});
|
|
2535
|
+
}
|
|
2536
|
+
} catch (err) {
|
|
2537
|
+
console.warn(`[device-flow] poll error: ${err.message}`);
|
|
2538
|
+
jsonResponse(res, 500, { ok: false, error: err.message });
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2116
2542
|
async function readStatusSnapshot() {
|
|
2117
2543
|
try {
|
|
2118
2544
|
const raw = await readFile(statusPath, "utf8");
|
|
@@ -4045,6 +4471,44 @@ async function handleApi(req, res, url) {
|
|
|
4045
4471
|
return;
|
|
4046
4472
|
}
|
|
4047
4473
|
|
|
4474
|
+
if (path === "/api/github/app/config") {
|
|
4475
|
+
const appId = (process.env.BOSUN_GITHUB_APP_ID || "").trim();
|
|
4476
|
+
const privateKeyPath = (process.env.BOSUN_GITHUB_PRIVATE_KEY_PATH || "").trim();
|
|
4477
|
+
const clientId = (process.env.BOSUN_GITHUB_CLIENT_ID || "").trim();
|
|
4478
|
+
const webhookSecretSet = Boolean(process.env.BOSUN_GITHUB_WEBHOOK_SECRET);
|
|
4479
|
+
const appWebhookPath = getAppWebhookPath();
|
|
4480
|
+
|
|
4481
|
+
// Build public URLs from tunnel URL if available, else from request host
|
|
4482
|
+
const host = req.headers["x-forwarded-host"] || req.headers.host || "localhost";
|
|
4483
|
+
const proto = uiServerTls || req.headers["x-forwarded-proto"] === "https" ? "https" : "http";
|
|
4484
|
+
const baseUrl = `${proto}://${host}`;
|
|
4485
|
+
|
|
4486
|
+
jsonResponse(res, 200, {
|
|
4487
|
+
ok: true,
|
|
4488
|
+
data: {
|
|
4489
|
+
appId: appId || null,
|
|
4490
|
+
appSlug: "bosun-botswain",
|
|
4491
|
+
botUsername: "bosun-botswain[bot]",
|
|
4492
|
+
appUrl: "https://github.com/apps/bosun-botswain",
|
|
4493
|
+
configured: {
|
|
4494
|
+
appId: Boolean(appId),
|
|
4495
|
+
privateKey: Boolean(privateKeyPath),
|
|
4496
|
+
oauthClient: Boolean(clientId),
|
|
4497
|
+
webhookSecret: webhookSecretSet,
|
|
4498
|
+
},
|
|
4499
|
+
urls: {
|
|
4500
|
+
webhookUrl: `${baseUrl}${appWebhookPath}`,
|
|
4501
|
+
oauthCallbackUrl: `${baseUrl}/api/github/callback`,
|
|
4502
|
+
},
|
|
4503
|
+
paths: {
|
|
4504
|
+
webhookPath: appWebhookPath,
|
|
4505
|
+
oauthCallbackPath: "/api/github/callback",
|
|
4506
|
+
},
|
|
4507
|
+
},
|
|
4508
|
+
});
|
|
4509
|
+
return;
|
|
4510
|
+
}
|
|
4511
|
+
|
|
4048
4512
|
if (path === "/api/command") {
|
|
4049
4513
|
try {
|
|
4050
4514
|
const body = await readJsonBody(req);
|
|
@@ -4789,6 +5253,29 @@ export async function startTelegramUiServer(options = {}) {
|
|
|
4789
5253
|
return;
|
|
4790
5254
|
}
|
|
4791
5255
|
|
|
5256
|
+
// GitHub App webhook (installation events, pr events, etc.)
|
|
5257
|
+
const appWebhookPath = getAppWebhookPath();
|
|
5258
|
+
if (url.pathname === appWebhookPath) {
|
|
5259
|
+
await handleGitHubAppWebhook(req, res);
|
|
5260
|
+
return;
|
|
5261
|
+
}
|
|
5262
|
+
|
|
5263
|
+
// GitHub OAuth callback — public (no session auth required)
|
|
5264
|
+
if (url.pathname === "/api/github/callback") {
|
|
5265
|
+
await handleGitHubOAuthCallback(req, res);
|
|
5266
|
+
return;
|
|
5267
|
+
}
|
|
5268
|
+
|
|
5269
|
+
// GitHub Device Flow — no public URL needed
|
|
5270
|
+
if (url.pathname === "/api/github/device/start") {
|
|
5271
|
+
await handleDeviceFlowStart(req, res);
|
|
5272
|
+
return;
|
|
5273
|
+
}
|
|
5274
|
+
if (url.pathname === "/api/github/device/poll") {
|
|
5275
|
+
await handleDeviceFlowPoll(req, res);
|
|
5276
|
+
return;
|
|
5277
|
+
}
|
|
5278
|
+
|
|
4792
5279
|
if (url.pathname.startsWith("/api/")) {
|
|
4793
5280
|
await handleApi(req, res, url);
|
|
4794
5281
|
return;
|
package/workspace-manager.mjs
CHANGED
|
@@ -432,12 +432,24 @@ export function pullWorkspaceRepos(configDir, workspaceId) {
|
|
|
432
432
|
stdio: ["pipe", "pipe", "pipe"],
|
|
433
433
|
});
|
|
434
434
|
if (clone.status !== 0) {
|
|
435
|
+
const stderr = String(clone.stderr || clone.stdout || "");
|
|
436
|
+
let hint = "";
|
|
437
|
+
if (/permission denied \(publickey\)/i.test(stderr)) {
|
|
438
|
+
hint =
|
|
439
|
+
"SSH auth failed. Configure SSH keys or use an HTTPS URL instead.";
|
|
440
|
+
} else if (/authentication failed|fatal: authentication failed/i.test(stderr)) {
|
|
441
|
+
hint =
|
|
442
|
+
"HTTPS auth failed. Use a PAT/credential helper or switch to SSH.";
|
|
443
|
+
} else if (/repository .* not found|not found/i.test(stderr)) {
|
|
444
|
+
hint =
|
|
445
|
+
"Repository not found or access denied. Verify the org/repo and permissions.";
|
|
446
|
+
}
|
|
435
447
|
results.push({
|
|
436
448
|
name: repo.name,
|
|
437
449
|
success: false,
|
|
438
|
-
error: `git clone failed: ${
|
|
439
|
-
|
|
440
|
-
}`,
|
|
450
|
+
error: `git clone failed (${repoUrl}): ${
|
|
451
|
+
stderr || clone.error?.message || "unknown error"
|
|
452
|
+
}${hint ? ` — ${hint}` : ""}`,
|
|
441
453
|
});
|
|
442
454
|
continue;
|
|
443
455
|
}
|
|
@@ -446,18 +458,52 @@ export function pullWorkspaceRepos(configDir, workspaceId) {
|
|
|
446
458
|
results.push({
|
|
447
459
|
name: repo.name,
|
|
448
460
|
success: false,
|
|
449
|
-
error: `git clone failed: ${err.message || err}`,
|
|
461
|
+
error: `git clone failed (${repoUrl}): ${err.message || err}`,
|
|
450
462
|
});
|
|
451
463
|
continue;
|
|
452
464
|
}
|
|
453
465
|
}
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
466
|
+
const gitDir = resolve(repoPath, ".git");
|
|
467
|
+
if (!existsSync(gitDir)) {
|
|
468
|
+
try {
|
|
469
|
+
const contents = existsSync(repoPath) ? readdirSync(repoPath) : [];
|
|
470
|
+
const isEmpty = contents.length === 0;
|
|
471
|
+
const repoUrl =
|
|
472
|
+
repo.url ||
|
|
473
|
+
(repo.slug ? `https://github.com/${repo.slug.replace(/\.git$/i, "")}.git` : "");
|
|
474
|
+
if (isEmpty && repoUrl) {
|
|
475
|
+
console.log(TAG, `Cloning ${repoUrl} into existing empty directory ${repoPath}...`);
|
|
476
|
+
const clone = spawnSync("git", ["clone", repoUrl, "."], {
|
|
477
|
+
encoding: "utf8",
|
|
478
|
+
timeout: 300000,
|
|
479
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
480
|
+
cwd: repoPath,
|
|
481
|
+
});
|
|
482
|
+
if (clone.status !== 0) {
|
|
483
|
+
const stderr = String(clone.stderr || clone.stdout || "");
|
|
484
|
+
results.push({
|
|
485
|
+
name: repo.name,
|
|
486
|
+
success: false,
|
|
487
|
+
error: `git clone failed (${repoUrl}): ${stderr || clone.error?.message || "unknown error"}`,
|
|
488
|
+
});
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
} else {
|
|
492
|
+
results.push({
|
|
493
|
+
name: repo.name,
|
|
494
|
+
success: false,
|
|
495
|
+
error: "Directory exists but is not a git repository",
|
|
496
|
+
});
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
} catch (err) {
|
|
500
|
+
results.push({
|
|
501
|
+
name: repo.name,
|
|
502
|
+
success: false,
|
|
503
|
+
error: `Directory check failed: ${err.message || err}`,
|
|
504
|
+
});
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
461
507
|
}
|
|
462
508
|
try {
|
|
463
509
|
execSync("git pull --rebase", {
|