@jossuealcala/madre 0.7.2 → 1.0.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/CHANGELOG.md +817 -1
- package/CONTRIBUTING.md +5 -1
- package/NOTICE +20 -1
- package/README.md +99 -34
- package/TRADEMARKS.md +65 -0
- package/bin/madre.mjs +30 -10
- package/docs/INTERNALS.md +2 -2
- package/docs/REFERENCE.md +159 -82
- package/docs/SDK.md +23 -19
- package/docs/madre-banner.png +0 -0
- package/package.json +4 -3
- package/public/app.js +11668 -4219
- package/public/assets/brand/Geist-latin-ext.woff2 +0 -0
- package/public/assets/brand/Geist-latin.woff2 +0 -0
- package/public/assets/brand/GeistMono-latin-ext.woff2 +0 -0
- package/public/assets/brand/GeistMono-latin.woff2 +0 -0
- package/public/assets/brand/JetBrainsMono-latin-ext.woff2 +0 -0
- package/public/assets/brand/JetBrainsMono-latin.woff2 +0 -0
- package/public/assets/brand/OFL-Geist.txt +93 -0
- package/public/assets/brand/OFL-GeistMono.txt +93 -0
- package/public/assets/brand/OFL-JetBrainsMono.txt +93 -0
- package/public/assets/brand/OFL-Sora.txt +93 -0
- package/public/assets/brand/Sora-latin-ext.woff2 +0 -0
- package/public/assets/brand/Sora-latin.woff2 +0 -0
- package/public/assets/brand/madre-app-180.png +0 -0
- package/public/assets/brand/madre-app-192.png +0 -0
- package/public/assets/brand/madre-app-512.png +0 -0
- package/public/assets/brand/madre-app.svg +1 -0
- package/public/assets/brand/madre-favicon.svg +1 -0
- package/public/assets/news/1.0/alerts.webp +0 -0
- package/public/assets/news/1.0/bar.webp +0 -0
- package/public/assets/news/1.0/finder.webp +0 -0
- package/public/assets/news/1.0/github.webp +0 -0
- package/public/assets/news/1.0/intents.webp +0 -0
- package/public/assets/news/1.0/looks.webp +0 -0
- package/public/assets/news/1.0/modules.webp +0 -0
- package/public/assets/news/1.0/mother.webp +0 -0
- package/public/assets/news/1.0/nostromo.webp +0 -0
- package/public/assets/news/1.0/permissions.webp +0 -0
- package/public/assets/news/1.0/room.webp +0 -0
- package/public/assets/news/1.0/settings.webp +0 -0
- package/public/assets/wallpapers/biznaga.webp +0 -0
- package/public/assets/wallpapers/canela.webp +0 -0
- package/public/assets/wallpapers/jarrito.webp +0 -0
- package/public/assets/wallpapers/magna.webp +0 -0
- package/public/assets/wallpapers/oceano.webp +0 -0
- package/public/assets/wallpapers/paraiso.webp +0 -0
- package/public/assets/wallpapers/quila.webp +0 -0
- package/public/assets/wallpapers/tradicion.webp +0 -0
- package/public/assets/wallpapers/tropical.webp +0 -0
- package/public/assets/wallpapers/urbania.webp +0 -0
- package/public/assets/wallpapers/ventura.webp +0 -0
- package/public/assets/wallpapers/xico.webp +0 -0
- package/public/brands.js +28 -2
- package/public/editor.js +744 -0
- package/public/es.js +3443 -1425
- package/public/i18n.js +1 -1
- package/public/index.html +201 -175
- package/public/inquiry.js +11 -11
- package/public/loader.js +79 -0
- package/public/macroverso.js +1517 -0
- package/public/manifest.webmanifest +14 -0
- package/public/nostromo-engine.js +2222 -0
- package/public/resay.js +24 -0
- package/public/review-ask.js +85 -0
- package/public/salas-mac.css +1281 -0
- package/public/salas-mac.html +158 -0
- package/public/salas-mac.js +4593 -0
- package/public/salas.css +85 -0
- package/public/salas.html +47 -0
- package/public/salas.js +200 -0
- package/public/styles.css +6372 -426
- package/public/touch.js +83 -0
- package/public/tour-chapters.js +452 -0
- package/public/tour.js +546 -0
- package/public/troubleshooting.js +160 -141
- package/src/adapters/codex.mjs +46 -6
- package/src/adapters/gemini.mjs +30 -10
- package/src/adapters/madre.mjs +110 -18
- package/src/adapters/process.mjs +42 -2
- package/src/auth-probe.mjs +31 -8
- package/src/bridge/devices.mjs +202 -0
- package/src/bridge/handoff.mjs +45 -0
- package/src/bridge/host.mjs +82 -0
- package/src/bridge/network.mjs +133 -0
- package/src/bridge/policy.mjs +89 -0
- package/src/bridge/qr.mjs +287 -0
- package/src/bridge/server.mjs +269 -0
- package/src/capabilities.mjs +5 -9
- package/src/chats.mjs +59 -22
- package/src/checkpoint.mjs +137 -35
- package/src/choices.mjs +18 -3
- package/src/cli-args.mjs +12 -1
- package/src/commands.mjs +15 -15
- package/src/config.mjs +73 -9
- package/src/conversation-context.mjs +5 -2
- package/src/credentials.mjs +80 -0
- package/src/dataset.mjs +3 -1
- package/src/directives.mjs +6 -11
- package/src/enlaces.mjs +451 -0
- package/src/event-store.mjs +55 -10
- package/src/exam.mjs +5 -5
- package/src/extensions.mjs +31 -12
- package/src/file-ops.mjs +23 -0
- package/src/files.mjs +52 -2
- package/src/github.mjs +407 -0
- package/src/heartbeat.mjs +55 -0
- package/src/json-file.mjs +63 -0
- package/src/lease.mjs +2 -2
- package/src/lobby-work.mjs +103 -0
- package/src/local-ai.mjs +48 -0
- package/src/looks.mjs +88 -0
- package/src/maturity.mjs +3 -3
- package/src/mcp/github-server.mjs +164 -0
- package/src/mcp/image-server.mjs +1 -1
- package/src/mcp/memory-server.mjs +11 -3
- package/src/memory.mjs +38 -12
- package/src/models.mjs +127 -5
- package/src/modules/ahp.mjs +4 -4
- package/src/modules/ash.mjs +1 -1
- package/src/modules/bishop-plan.mjs +207 -0
- package/src/modules/bishop.mjs +645 -0
- package/src/modules/choices.mjs +2 -2
- package/src/modules/correo.mjs +1 -1
- package/src/modules/enlaces.mjs +255 -0
- package/src/modules/git-pulse.mjs +2 -2
- package/src/modules/github.mjs +322 -0
- package/src/modules/image-studio.mjs +3 -3
- package/src/modules/index.mjs +18 -4
- package/src/modules/intents-calibration.mjs +338 -0
- package/src/modules/intents-desk.mjs +481 -0
- package/src/modules/intents-observe.mjs +266 -0
- package/src/modules/intents-policy.mjs +414 -0
- package/src/modules/intents-try.mjs +91 -0
- package/src/modules/intents.mjs +145 -0
- package/src/modules/ollama.mjs +18 -6
- package/src/modules/playwright.mjs +121 -11
- package/src/modules/ripley.mjs +1 -1
- package/src/modules/runs.mjs +2 -2
- package/src/modules/sdk.mjs +10 -4
- package/src/ollama.mjs +6 -1
- package/src/outbound.mjs +36 -17
- package/src/override.mjs +100 -0
- package/src/ports.mjs +23 -1
- package/src/prices.mjs +72 -0
- package/src/privacy.mjs +30 -11
- package/src/quota-sources.mjs +0 -8
- package/src/request-guard.mjs +1 -1
- package/src/review-ask.mjs +111 -0
- package/src/room/archivist.mjs +55 -37
- package/src/room/budget.mjs +17 -5
- package/src/room/control.mjs +70 -7
- package/src/room/economy.mjs +35 -7
- package/src/room/escalation.mjs +3 -0
- package/src/room/guard.mjs +26 -2
- package/src/room/prompt.mjs +22 -15
- package/src/room/runs.mjs +84 -21
- package/src/room/vectors.mjs +19 -4
- package/src/room.mjs +411 -84
- package/src/router.mjs +3 -1
- package/src/runs.mjs +23 -5
- package/src/salas.mjs +313 -0
- package/src/sentinel-errors.mjs +1 -1
- package/src/server-sentinel.mjs +124 -0
- package/src/server.mjs +1864 -178
- package/src/setup.mjs +1 -0
- package/src/tour.mjs +174 -0
- package/src/updates.mjs +48 -8
- package/src/usage-sentinel.mjs +1 -1
- package/src/verdict.mjs +7 -7
- package/src/whats-new.mjs +160 -0
- package/docs/madre-banner.svg +0 -25
package/src/extensions.mjs
CHANGED
|
@@ -6,8 +6,6 @@ export { gitToplevel, findOnPath } from './modules/helpers.mjs';
|
|
|
6
6
|
// server and the tests have always used, now answered by one file per module.
|
|
7
7
|
export const EXTENSIONS = MODULES;
|
|
8
8
|
export const AHP = moduleById('ahp');
|
|
9
|
-
export const IMAGE_STUDIO = moduleById('image-studio');
|
|
10
|
-
export const GIT_PULSE = moduleById('git-pulse');
|
|
11
9
|
export const ASH = moduleById('ash');
|
|
12
10
|
export const RIPLEY = moduleById('ripley');
|
|
13
11
|
export const OLLAMA = moduleById('ollama');
|
|
@@ -27,38 +25,59 @@ export async function listExtensions({ projectRoot, agents = [], config = {}, im
|
|
|
27
25
|
// Runs one installer inside the project, streaming output lines. `runner`
|
|
28
26
|
// lets tests substitute the real installer with a local script.
|
|
29
27
|
// `npm` and other Node CLIs are .cmd shims on Windows, which cannot be spawned directly.
|
|
30
|
-
|
|
28
|
+
//
|
|
29
|
+
// A sign-in is the same spawn with a human in the middle of it, so three knobs exist for it:
|
|
30
|
+
// `stdin: 'pipe'` opens a way back in (a CLI that asks for a pasted code reads it there),
|
|
31
|
+
// `onChild` hands the caller that way in, and `flushIdleMs` emits a line that never ends in a
|
|
32
|
+
// newline — a prompt like `Paste code here > ` — once the output has been still for that long.
|
|
33
|
+
// Without it the prompt sits in the buffer and the room shows a sign-in that says nothing.
|
|
34
|
+
export function runInstaller({ command, args, projectRoot, onLine, timeoutMs = 600000, heartbeatMs = 8000, env = process.env, stdin = 'ignore', onChild = null, flushIdleMs = 0, quietNote = 'installer is quiet (npm install or git status can take a while)' }) {
|
|
31
35
|
return new Promise((resolve) => {
|
|
32
|
-
const child = spawn(command, args, { cwd: projectRoot, env: { ...env, NO_COLOR: '1', CI: '1' }, stdio: [
|
|
36
|
+
const child = spawn(command, args, { cwd: projectRoot, env: { ...env, NO_COLOR: '1', CI: '1' }, stdio: [stdin, 'pipe', 'pipe'], shell: process.platform === 'win32', windowsHide: true });
|
|
33
37
|
const started = Date.now();
|
|
34
38
|
let lastOutput = started;
|
|
39
|
+
let buffer = '';
|
|
40
|
+
const emit = (line, stream) => { lastOutput = Date.now(); onLine?.(line, stream); };
|
|
35
41
|
// Installers go quiet for long stretches (npm install, git status); say so.
|
|
36
42
|
const heartbeat = setInterval(() => {
|
|
43
|
+
// A half-line waiting on a human is said before the silence is reported, so the room shows
|
|
44
|
+
// the question instead of the heartbeat that would otherwise be the only thing on screen.
|
|
45
|
+
if (flushIdleMs && buffer.trim() && Date.now() - lastOutput >= flushIdleMs) { const partial = buffer.trim(); buffer = ''; emit(partial, 'stdout'); return; }
|
|
37
46
|
if (Date.now() - lastOutput >= heartbeatMs) {
|
|
38
|
-
onLine?.(`… still running · ${Math.round((Date.now() - started) / 1000)}s ·
|
|
47
|
+
onLine?.(`… still running · ${Math.round((Date.now() - started) / 1000)}s · ${quietNote}`, 'heartbeat');
|
|
39
48
|
lastOutput = Date.now();
|
|
40
49
|
}
|
|
41
|
-
}, Math.min(heartbeatMs, 2000));
|
|
50
|
+
}, Math.min(flushIdleMs || heartbeatMs, heartbeatMs, 2000));
|
|
42
51
|
heartbeat.unref?.();
|
|
43
|
-
let buffer = '';
|
|
44
52
|
const feed = (chunk, stream) => {
|
|
45
53
|
buffer += chunk;
|
|
46
54
|
let index;
|
|
47
55
|
while ((index = buffer.indexOf('\n')) >= 0) {
|
|
48
56
|
const line = buffer.slice(0, index).replace(/\r$/, '');
|
|
49
57
|
buffer = buffer.slice(index + 1);
|
|
50
|
-
if (line.trim())
|
|
58
|
+
if (line.trim()) emit(line, stream);
|
|
51
59
|
}
|
|
52
60
|
};
|
|
53
61
|
child.stdout.on('data', (chunk) => feed(String(chunk), 'stdout'));
|
|
54
62
|
child.stderr.on('data', (chunk) => feed(String(chunk), 'stderr'));
|
|
55
63
|
const timer = setTimeout(() => { child.kill('SIGKILL'); }, timeoutMs);
|
|
56
|
-
|
|
64
|
+
let done = false;
|
|
65
|
+
const settle = (result) => { if (done) return; done = true; clearTimeout(timer); clearInterval(heartbeat); resolve(result); };
|
|
66
|
+
onChild?.({
|
|
67
|
+
// What the room can do to a sign-in while it runs: answer it, or call it off.
|
|
68
|
+
write: (text) => { try { child.stdin?.write(text); return true; } catch { return false; } },
|
|
69
|
+
cancel: () => {
|
|
70
|
+
try { child.kill('SIGTERM'); } catch { /* already gone */ }
|
|
71
|
+
// A terminal UI can ignore a polite ask; it does not get to ignore the second one.
|
|
72
|
+
const hard = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } }, 1500);
|
|
73
|
+
hard.unref?.();
|
|
74
|
+
},
|
|
75
|
+
pid: child.pid ?? null,
|
|
76
|
+
});
|
|
77
|
+
child.on('error', (error) => settle({ code: 1, error: error.code === 'ENOENT' ? `${error.message} (is the project folder present and ${command} on PATH?)` : error.message }));
|
|
57
78
|
child.on('close', (code) => {
|
|
58
|
-
clearTimeout(timer);
|
|
59
|
-
clearInterval(heartbeat);
|
|
60
79
|
if (buffer.trim()) onLine?.(buffer.trim(), 'stdout');
|
|
61
|
-
|
|
80
|
+
settle({ code: code ?? 1 });
|
|
62
81
|
});
|
|
63
82
|
});
|
|
64
83
|
}
|
package/src/file-ops.mjs
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
// the same word the room asks for before CONTROL. Typing a folder name is a small ceremony;
|
|
13
13
|
// restoring a deleted key is not.
|
|
14
14
|
|
|
15
|
+
import { realpath } from 'node:fs/promises';
|
|
16
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
15
17
|
import { isForbidden } from './checkpoint.mjs';
|
|
16
18
|
|
|
17
19
|
export const OPERATIONS = ['copy', 'move', 'new-file', 'new-folder'];
|
|
@@ -24,6 +26,27 @@ export function needsDesignation(...paths) {
|
|
|
24
26
|
return paths.filter(Boolean).some((path) => isForbidden(String(path).replace(/^\.\//, '')));
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
// The same question asked of where a path really lands (2026-10-12, SRV-05 of the 1.0 review): the
|
|
30
|
+
// words of a path are not its place. `src/../.git/config`, a link named `gitlink` that points at
|
|
31
|
+
// `.git`, `.ENV` on a disk that does not tell it from `.env` — each wrote into a guarded zone with
|
|
32
|
+
// no designation, because only the words were read. A path that does not exist yet is judged by
|
|
33
|
+
// the real folder it would land in.
|
|
34
|
+
export async function needsDesignationOnDisk(root, ...paths) {
|
|
35
|
+
if (needsDesignation(...paths)) return true;
|
|
36
|
+
const top = await realpath(root).catch(() => null);
|
|
37
|
+
if (!top) return false;
|
|
38
|
+
for (const path of paths.filter(Boolean)) {
|
|
39
|
+
const wanted = resolve(top, String(path).replace(/^\/+/, ''));
|
|
40
|
+
const real = await realpath(wanted).catch(async () => {
|
|
41
|
+
const folder = await realpath(dirname(wanted)).catch(() => null);
|
|
42
|
+
return folder ? join(folder, basename(wanted)) : null;
|
|
43
|
+
});
|
|
44
|
+
if (!real || (real !== top && !real.startsWith(top + sep))) continue; // outside: the fence refuses it elsewhere
|
|
45
|
+
if (needsDesignation(relative(top, real).split(sep).join('/'))) return true;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
27
50
|
// What the room will say about an operation before doing any of it.
|
|
28
51
|
export function planFileOp({ operation, from, to }) {
|
|
29
52
|
if (!OPERATIONS.includes(operation)) return { ok: false, error: `Unknown operation: ${operation}.` };
|
package/src/files.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { mkdir, readdir, readFile, realpath, stat, writeFile } from 'node:fs/promises';
|
|
3
|
-
import { extname, join, resolve, sep } from 'node:path';
|
|
3
|
+
import { dirname, extname, join, resolve, sep } from 'node:path';
|
|
4
4
|
|
|
5
5
|
// Read-only file access for the room UI, and human attachments.
|
|
6
6
|
//
|
|
@@ -21,6 +21,12 @@ const TYPES = {
|
|
|
21
21
|
'.mp4': 'video/mp4', '.webm': 'video/webm', '.mp3': 'audio/mpeg', '.wav': 'audio/wav',
|
|
22
22
|
};
|
|
23
23
|
export const contentTypeFor = (name) => TYPES[extname(name).toLowerCase()] ?? 'application/octet-stream';
|
|
24
|
+
// What a file IS, for the record of what a turn made. contentTypeFor is how MADRE SERVES a project
|
|
25
|
+
// file, and a page is served as text on purpose: an HTML file from the project served as HTML would
|
|
26
|
+
// run its scripts in the room's own origin (RIPLEY renders it, sealed, elsewhere). The record said
|
|
27
|
+
// tests.html was text/plain, which is the serving rule leaking into the description.
|
|
28
|
+
const DECLARED = { '.html': 'text/html', '.htm': 'text/html', '.xml': 'application/xml', '.ts': 'text/typescript', '.tsx': 'text/typescript', '.jsx': 'text/javascript' };
|
|
29
|
+
export const declaredTypeFor = (name) => DECLARED[extname(name).toLowerCase()] ?? contentTypeFor(name);
|
|
24
30
|
export const isImage = (name) => contentTypeFor(name).startsWith('image/');
|
|
25
31
|
export const isText = (name) => /^(text\/|application\/(json|x-ndjson))/.test(contentTypeFor(name));
|
|
26
32
|
|
|
@@ -152,7 +158,51 @@ export async function readServable(root, relative, { maxBytes = MAX_FILE_BYTES }
|
|
|
152
158
|
if (!info || !info.isFile()) return { status: 404, error: 'Not found.' };
|
|
153
159
|
if (info.size > maxBytes) return { status: 413, error: `File is larger than ${Math.round(maxBytes / 1024 / 1024)} MB.` };
|
|
154
160
|
const body = await readFile(path);
|
|
155
|
-
return { status: 200, body, contentType: contentTypeFor(path), size: info.size, path };
|
|
161
|
+
return { status: 200, body, contentType: contentTypeFor(path), size: info.size, path, mtimeMs: info.mtimeMs };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Writing a file the human edited in the room. Only a file that already exists inside the root,
|
|
165
|
+
// only text, and only if it is still the file the editor opened: a file that changed on disk since
|
|
166
|
+
// (an agent wrote it, the human saved it elsewhere) is not overwritten without being asked.
|
|
167
|
+
export const MAX_EDIT_BYTES = 2 * 1024 * 1024;
|
|
168
|
+
export async function writeEdited(root, relative, content, { mtimeMs = null, force = false } = {}) {
|
|
169
|
+
if (typeof content !== 'string') return { status: 400, error: 'Nothing to save.' };
|
|
170
|
+
if (Buffer.byteLength(content, 'utf8') > MAX_EDIT_BYTES) return { status: 413, error: `The editor saves files up to ${MAX_EDIT_BYTES / 1024 / 1024} MB.` };
|
|
171
|
+
const path = await resolveInside(root, relative);
|
|
172
|
+
if (!path) return { status: 404, error: 'Not found.' };
|
|
173
|
+
const info = await stat(path).catch(() => null);
|
|
174
|
+
if (!info || !info.isFile()) return { status: 404, error: 'Not found.' };
|
|
175
|
+
if (!force && Number.isFinite(Number(mtimeMs)) && Math.abs(info.mtimeMs - Number(mtimeMs)) > 1) {
|
|
176
|
+
return { status: 409, error: 'The file changed on disk since it was opened.', changed: true, mtimeMs: info.mtimeMs };
|
|
177
|
+
}
|
|
178
|
+
await writeFile(path, content, 'utf8');
|
|
179
|
+
const after = await stat(path);
|
|
180
|
+
return { status: 200, path, bytes: after.size, mtimeMs: after.mtimeMs };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// A new, empty file in the project, from the Finder. Only a plain relative path: no `..`, no
|
|
184
|
+
// drive (`C:`) or UNC prefix, no folder of git's. Its nearest existing folder must already be
|
|
185
|
+
// inside the project (the same fence as every read and write); missing folders under it are
|
|
186
|
+
// made. An existing file is never touched.
|
|
187
|
+
export async function createEmptyFile(root, relative) {
|
|
188
|
+
const clean = String(relative ?? '').trim().replace(/^[/\\]+/, '');
|
|
189
|
+
const parts = clean.split(/[/\\]+/).filter(Boolean);
|
|
190
|
+
if (!clean || clean.includes('\0') || /[/\\]$/.test(clean) || /^[a-zA-Z]:/.test(clean) || parts.some((part) => part === '..' || part === '.') || parts[0] === '.git') {
|
|
191
|
+
return { status: 400, error: 'Not a file name this project can create.' };
|
|
192
|
+
}
|
|
193
|
+
const canonicalRoot = await realpath(root).catch(() => null);
|
|
194
|
+
if (!canonicalRoot) return { status: 404, error: 'Not found.' };
|
|
195
|
+
const target = resolve(canonicalRoot, ...parts);
|
|
196
|
+
if (!target.startsWith(canonicalRoot + sep)) return { status: 400, error: 'Not a file name this project can create.' };
|
|
197
|
+
// The nearest folder that exists is checked against the fence, symlinks resolved.
|
|
198
|
+
let folder = dirname(target);
|
|
199
|
+
while (folder !== canonicalRoot && !(await stat(folder).catch(() => null))) folder = dirname(folder);
|
|
200
|
+
const inside = await resolveInside(canonicalRoot, folder === canonicalRoot ? '.' : folder.slice(canonicalRoot.length + 1));
|
|
201
|
+
if (!inside) return { status: 400, error: 'Not a file name this project can create.' };
|
|
202
|
+
await mkdir(dirname(target), { recursive: true });
|
|
203
|
+
try { await writeFile(target, '', { encoding: 'utf8', flag: 'wx' }); }
|
|
204
|
+
catch (error) { return error.code === 'EEXIST' ? { status: 409, error: 'A file with that name is already there.' } : { status: 400, error: error.message }; }
|
|
205
|
+
return { status: 201, path: parts.join('/') };
|
|
156
206
|
}
|
|
157
207
|
|
|
158
208
|
const safeName = (name) => String(name ?? 'file').replace(/[^\w.\- ]+/g, '_').replace(/^\.+/, '').slice(0, 120) || 'file';
|
package/src/github.mjs
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
// GitHub, as the GITHUB module and its MCP server both speak to it.
|
|
2
|
+
//
|
|
3
|
+
// Two ways in, and the order matters. First the `gh` CLI, if this computer has it and it is
|
|
4
|
+
// signed in: then MADRE holds no credential at all, the session lives where gh keeps it, and gh
|
|
5
|
+
// makes the request. Only without one, a fine-grained token the human pasted into MADRE's vault,
|
|
6
|
+
// sent as `Authorization: Bearer` from this process. Neither is ever written by this file: not
|
|
7
|
+
// in a result, not in an error, not in the outbound log.
|
|
8
|
+
//
|
|
9
|
+
// Everything that runs a process goes through `client.run`, without a shell and with an
|
|
10
|
+
// argument array (docs/WINDOWS.md, WIN-016): a title or a comment is somebody's words, and a
|
|
11
|
+
// shell would read `&`, `|` and `%VAR%` inside them. A `gh` that resolves to a .cmd or .bat shim
|
|
12
|
+
// cannot be run that way, so it is treated as absent and the token path is offered instead.
|
|
13
|
+
//
|
|
14
|
+
// What comes back is trimmed to what a person or an agent reads: numbers, names, states, links.
|
|
15
|
+
// GitHub's own payloads are large, and a turn pays for every character it is handed.
|
|
16
|
+
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
import { findExecutable } from './runtime-detection.mjs';
|
|
19
|
+
|
|
20
|
+
export const API_HOST = 'api.github.com';
|
|
21
|
+
const API = `https://${API_HOST}`;
|
|
22
|
+
const WEB_HOST = 'github.com';
|
|
23
|
+
|
|
24
|
+
// The four answers `src/outbound.mjs` asks of any destination. Here and not in the module
|
|
25
|
+
// because the MCP server is a process of its own and must declare the same thing to its log.
|
|
26
|
+
export const REACHES = [{
|
|
27
|
+
id: 'api',
|
|
28
|
+
host: API_HOST,
|
|
29
|
+
to: 'GitHub · the repository of this project, with your own account',
|
|
30
|
+
what: 'What you or an agent asked for: which pull requests, issues or checks to read, and — only when you confirm one or an agent in #4 AIRLOCK posts one — the title and text of a pull request, issue or comment. The credential is your gh session or your token.',
|
|
31
|
+
when: 'When you type /gh in the room or an agent uses a GitHub tool. Writing needs your confirm in the room, or a turn in #4 AIRLOCK.',
|
|
32
|
+
where: 'Modules › GitHub: switch it off, or forget its token, and nothing reaches GitHub.',
|
|
33
|
+
}];
|
|
34
|
+
|
|
35
|
+
// The seam every outside effect passes through, so a test can answer for git, gh and GitHub
|
|
36
|
+
// without any of them being installed and without a byte leaving the machine.
|
|
37
|
+
export const client = {
|
|
38
|
+
which: (name, env = process.env) => findExecutable([name], env.PATH ?? '', env),
|
|
39
|
+
run: runProcess,
|
|
40
|
+
fetch: (...args) => globalThis.fetch(...args),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// One process, no shell, its output collected. `quiet` sends stdout nowhere: asking `gh auth
|
|
44
|
+
// token` whether a session exists must not bring the token into this process to find out.
|
|
45
|
+
export function runProcess(file, args, { input = null, env = process.env, cwd = undefined, timeout = 20000, quiet = false } = {}) {
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
let child;
|
|
48
|
+
try {
|
|
49
|
+
child = spawn(file, args, { cwd, env, shell: false, windowsHide: true, stdio: [input == null ? 'ignore' : 'pipe', quiet ? 'ignore' : 'pipe', quiet ? 'ignore' : 'pipe'] });
|
|
50
|
+
} catch (error) {
|
|
51
|
+
resolve({ code: -1, stdout: '', stderr: '', error: error.code ?? 'SPAWN' });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
let stdout = '';
|
|
55
|
+
let stderr = '';
|
|
56
|
+
let done = false;
|
|
57
|
+
const finish = (result) => { if (!done) { done = true; clearTimeout(timer); resolve(result); } };
|
|
58
|
+
const timer = setTimeout(() => { try { child.kill(); } catch { /* already gone */ } finish({ code: -1, stdout, stderr, error: 'TIMEOUT' }); }, timeout);
|
|
59
|
+
child.stdout?.setEncoding('utf8').on('data', (chunk) => { if (stdout.length < 4 * 1024 * 1024) stdout += chunk; });
|
|
60
|
+
child.stderr?.setEncoding('utf8').on('data', (chunk) => { if (stderr.length < 64 * 1024) stderr += chunk; });
|
|
61
|
+
child.on('error', (error) => finish({ code: -1, stdout, stderr, error: error.code ?? 'SPAWN' }));
|
|
62
|
+
child.on('close', (code) => finish({ code: code ?? -1, stdout, stderr }));
|
|
63
|
+
if (input != null) { child.stdin.on('error', () => {}); child.stdin.end(input); }
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── The repository this project is ────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
const SEGMENT = /^[A-Za-z0-9_.-]+$/;
|
|
70
|
+
const GITHUB_HOSTS = new Set([WEB_HOST, `www.${WEB_HOST}`, `ssh.${WEB_HOST}`]);
|
|
71
|
+
|
|
72
|
+
// `owner/repo` out of whatever a git remote says, for github.com only. Enterprise hosts and every
|
|
73
|
+
// other forge answer null: the module would otherwise send a credential for github.com to them.
|
|
74
|
+
export function repoFromRemote(url) {
|
|
75
|
+
const text = String(url ?? '').trim();
|
|
76
|
+
if (!text) return null;
|
|
77
|
+
let host;
|
|
78
|
+
let path;
|
|
79
|
+
const scp = text.match(/^(?:[^@/\s]+@)?([^:/\s]+):(?!\/\/)(.+)$/); // git@github.com:owner/repo.git
|
|
80
|
+
if (scp && scp[2].includes('/')) {
|
|
81
|
+
[, host, path] = scp;
|
|
82
|
+
} else {
|
|
83
|
+
let parsed;
|
|
84
|
+
try { parsed = new URL(text); } catch { return null; }
|
|
85
|
+
if (!['https:', 'http:', 'ssh:', 'git:', 'git+ssh:', 'ssh+git:'].includes(parsed.protocol)) return null;
|
|
86
|
+
host = parsed.hostname;
|
|
87
|
+
path = decodeURIComponent(parsed.pathname);
|
|
88
|
+
}
|
|
89
|
+
if (!GITHUB_HOSTS.has(String(host).toLowerCase())) return null;
|
|
90
|
+
const parts = path.replace(/^\/+|\/+$/g, '').replace(/\.git$/i, '').split('/');
|
|
91
|
+
if (parts.length !== 2) return null;
|
|
92
|
+
const [owner, repo] = parts;
|
|
93
|
+
if (!SEGMENT.test(owner) || !SEGMENT.test(repo) || /^\.+$/.test(owner) || /^\.+$/.test(repo)) return null;
|
|
94
|
+
return { owner, repo, slug: `${owner}/${repo}` };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function git(root, args, env) {
|
|
98
|
+
const result = await client.run('git', ['-C', root, ...args], { env, timeout: 5000 });
|
|
99
|
+
return result.code === 0 ? result.stdout.trim() : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// What the project says about itself: whether it is a repository, which remote is GitHub's, and
|
|
103
|
+
// the branch it is on. All of it read from git on this computer; nothing asks GitHub.
|
|
104
|
+
export async function projectRepo(root, { env = process.env } = {}) {
|
|
105
|
+
if (!root) return { git: false, repo: null, remote: null };
|
|
106
|
+
const top = await git(root, ['rev-parse', '--show-toplevel'], env);
|
|
107
|
+
if (!top) return { git: false, repo: null, remote: null };
|
|
108
|
+
const names = (await git(root, ['remote'], env) ?? '').split('\n').map((one) => one.trim()).filter(Boolean);
|
|
109
|
+
const order = [...names.filter((one) => one === 'origin'), ...names.filter((one) => one === 'upstream'), ...names.filter((one) => one !== 'origin' && one !== 'upstream')];
|
|
110
|
+
for (const remote of order) {
|
|
111
|
+
const repo = repoFromRemote(await git(root, ['remote', 'get-url', remote], env));
|
|
112
|
+
if (repo) return { git: true, repo, remote };
|
|
113
|
+
}
|
|
114
|
+
return { git: true, repo: null, remote: null, remotes: names.length };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function currentBranch(root, { env = process.env } = {}) {
|
|
118
|
+
const branch = await git(root, ['rev-parse', '--abbrev-ref', 'HEAD'], env);
|
|
119
|
+
return branch && branch !== 'HEAD' ? branch : null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Whether the branch is on a remote at all. A pull request from a branch GitHub has never seen
|
|
123
|
+
// is refused there with a sentence about refs; saying «push it first» here is kinder.
|
|
124
|
+
export async function upstreamOf(root, { env = process.env } = {}) {
|
|
125
|
+
return git(root, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], env);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Which credential ──────────────────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
// A `gh` this file can run, signed in to github.com. Both questions are answered from files gh
|
|
131
|
+
// keeps on this computer: who is signed in from its config, and — only when the config names
|
|
132
|
+
// nobody — whether it holds a token at all, with stdout sent nowhere so the token never arrives.
|
|
133
|
+
export async function ghSession({ env = process.env } = {}) {
|
|
134
|
+
// PULSE_GH=0 leaves gh out of it: the token is the only way in (and the suite asks nothing).
|
|
135
|
+
if (env.PULSE_GH === '0') return null;
|
|
136
|
+
const gh = await client.which('gh', env);
|
|
137
|
+
if (!gh || /\.(cmd|bat)$/i.test(gh)) return null;
|
|
138
|
+
const ghEnv = { ...env, GH_PROMPT_DISABLED: '1', GH_NO_UPDATE_NOTIFIER: '1', NO_COLOR: '1' };
|
|
139
|
+
const user = await client.run(gh, ['config', 'get', 'user', '-h', WEB_HOST], { env: ghEnv, timeout: 5000 });
|
|
140
|
+
const login = user.code === 0 ? user.stdout.trim().split('\n')[0].trim() : '';
|
|
141
|
+
if (login && /^[A-Za-z0-9-]+$/.test(login)) return { kind: 'gh', gh, login };
|
|
142
|
+
const held = await client.run(gh, ['auth', 'token', '-h', WEB_HOST], { env: ghEnv, timeout: 5000, quiet: true });
|
|
143
|
+
return held.code === 0 ? { kind: 'gh', gh, login: null } : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Asked once a minute at most: a card is drawn often, and the answer changes when somebody signs
|
|
147
|
+
// in or out, which is not often.
|
|
148
|
+
const sessions = new Map();
|
|
149
|
+
export const forgetSession = () => sessions.clear();
|
|
150
|
+
export async function cachedSession({ env = process.env, ttlMs = 60000, now = Date.now() } = {}) {
|
|
151
|
+
const key = String(env.PATH ?? '');
|
|
152
|
+
const known = sessions.get(key);
|
|
153
|
+
if (known && now - known.at < ttlMs) return known.session;
|
|
154
|
+
const session = await ghSession({ env });
|
|
155
|
+
sessions.set(key, { at: now, session });
|
|
156
|
+
return session;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// The gh session when there is one, the token when there is not, null when neither.
|
|
160
|
+
export async function resolveAuth({ env = process.env, token = null, session = undefined } = {}) {
|
|
161
|
+
const gh = session === undefined ? await cachedSession({ env }) : session;
|
|
162
|
+
if (gh) return gh;
|
|
163
|
+
const value = String(token ?? '').trim();
|
|
164
|
+
return value ? { kind: 'token', token: value } : null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// What a card or a brief may say about the credential: never the credential.
|
|
168
|
+
export const authLabel = (auth) => (auth?.kind === 'gh' ? `gh · ${auth.login ? `@${auth.login}` : 'github.com'}` : auth?.kind === 'token' ? 'token' : null);
|
|
169
|
+
|
|
170
|
+
// ── One request ───────────────────────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
export class GithubError extends Error {
|
|
173
|
+
constructor(code, message, { status = null, detail = null } = {}) {
|
|
174
|
+
super(message);
|
|
175
|
+
this.code = code;
|
|
176
|
+
this.status = status;
|
|
177
|
+
this.detail = detail;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Fixed sentences, so a screen can say them in its own language. GitHub's own words, when it
|
|
182
|
+
// gives any, travel separately in `detail`.
|
|
183
|
+
export const ERRORS = {
|
|
184
|
+
UNAUTHORIZED_GH: 'GitHub did not accept the gh session on this computer. Sign in again with gh auth login.',
|
|
185
|
+
UNAUTHORIZED_TOKEN: 'GitHub did not accept the token. It may have expired or been revoked: make a new one and paste it in Modules › GitHub.',
|
|
186
|
+
NOT_FOUND: 'GitHub says it is not there: the repository or that number is not visible with this credential.',
|
|
187
|
+
RATE_LIMIT: 'This credential has used up its GitHub rate limit. Try again in a while.',
|
|
188
|
+
FORBIDDEN: 'This credential is not allowed to do that here. A fine-grained token needs that permission on this repository.',
|
|
189
|
+
INVALID: 'GitHub refused the request as it was written.',
|
|
190
|
+
NETWORK: 'GitHub could not be reached from this computer.',
|
|
191
|
+
NO_SESSION: 'gh is on this computer but not signed in. Sign in with gh auth login, or paste a token in Modules › GitHub.',
|
|
192
|
+
FAILED: 'GitHub answered with an error.',
|
|
193
|
+
INVALID_NUMBER: 'That is not a pull request or issue number.',
|
|
194
|
+
NO_BRANCH: 'There is no branch to ask about: the project is not on one.',
|
|
195
|
+
NO_TITLE: 'It needs a title.',
|
|
196
|
+
NO_BODY: 'There is nothing to post.',
|
|
197
|
+
NO_BASE: 'There is no base branch to open it against.',
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
function explain({ status, data, kind, rateLimited = false, stderr = '' }) {
|
|
201
|
+
const said = typeof data?.message === 'string' ? data.message.slice(0, 200) : null;
|
|
202
|
+
const errors = Array.isArray(data?.errors) ? data.errors.map((one) => (typeof one === 'string' ? one : one?.message ?? one?.code)).filter(Boolean).join('; ').slice(0, 300) : '';
|
|
203
|
+
const detail = [said, errors].filter(Boolean).join(' · ') || null;
|
|
204
|
+
if (status === 401) return new GithubError('UNAUTHORIZED', kind === 'gh' ? ERRORS.UNAUTHORIZED_GH : ERRORS.UNAUTHORIZED_TOKEN, { status });
|
|
205
|
+
if (status === 404) return new GithubError('NOT_FOUND', ERRORS.NOT_FOUND, { status });
|
|
206
|
+
if ((status === 403 || status === 429) && (rateLimited || /rate limit/i.test(`${said ?? ''} ${stderr}`))) return new GithubError('RATE_LIMIT', ERRORS.RATE_LIMIT, { status });
|
|
207
|
+
if (status === 403) return new GithubError('FORBIDDEN', ERRORS.FORBIDDEN, { status, detail });
|
|
208
|
+
if (status === 422) return new GithubError('INVALID', ERRORS.INVALID, { status, detail });
|
|
209
|
+
return new GithubError(status ? `HTTP_${status}` : 'FAILED', ERRORS.FAILED, { status, detail });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// `gh api --include` prints the status line and headers, a blank line, then the body.
|
|
213
|
+
export function parseIncluded(text) {
|
|
214
|
+
const raw = String(text ?? '');
|
|
215
|
+
const split = raw.search(/\r?\n\r?\n/);
|
|
216
|
+
const head = split >= 0 ? raw.slice(0, split) : raw;
|
|
217
|
+
const body = split >= 0 ? raw.slice(split).replace(/^\r?\n\r?\n/, '') : '';
|
|
218
|
+
const status = Number(head.match(/^HTTP\/[\d.]+\s+(\d{3})/)?.[1]) || null;
|
|
219
|
+
const remaining = head.match(/^x-ratelimit-remaining:\s*(\d+)/im)?.[1];
|
|
220
|
+
let data = null;
|
|
221
|
+
try { data = body.trim() ? JSON.parse(body) : null; } catch { data = null; }
|
|
222
|
+
return { status, data, rateLimited: remaining === '0' };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// One request to the REST API. `log` is an OutboundLog: gh makes its own connection, so its line
|
|
226
|
+
// is written here; fetch goes through the log's wrapper, which is the same wrapper the room
|
|
227
|
+
// already has installed — it notices that and does not count the request twice. Either way the
|
|
228
|
+
// line holds the method, the address, whether it went and how long it took. Never a body, never
|
|
229
|
+
// a header, never the credential.
|
|
230
|
+
export async function githubRequest({ method = 'GET', path, body = null, auth, log = null, env = process.env, timeoutMs = 20000 }) {
|
|
231
|
+
if (!auth) throw new GithubError('NO_CREDENTIAL', ERRORS.NO_SESSION);
|
|
232
|
+
if (!String(path ?? '').startsWith('/')) throw new GithubError('FAILED', ERRORS.FAILED);
|
|
233
|
+
const verb = String(method).toUpperCase();
|
|
234
|
+
const url = `${API}${path}`;
|
|
235
|
+
const started = Date.now();
|
|
236
|
+
if (auth.kind === 'gh') {
|
|
237
|
+
const args = ['api', '--method', verb, '--include', '-H', 'Accept: application/vnd.github+json', '-H', 'X-GitHub-Api-Version: 2022-11-28', path.replace(/^\/+/, '')];
|
|
238
|
+
if (body != null) args.push('--input', '-');
|
|
239
|
+
// GH_HOST pinned: a session for an enterprise host in the environment must not take this
|
|
240
|
+
// request anywhere but the host the module declared.
|
|
241
|
+
const result = await client.run(auth.gh, args, { env: { ...env, GH_HOST: WEB_HOST, GH_PROMPT_DISABLED: '1', GH_NO_UPDATE_NOTIFIER: '1', NO_COLOR: '1' }, input: body != null ? JSON.stringify(body) : null, timeout: timeoutMs });
|
|
242
|
+
const parsed = parseIncluded(result.stdout);
|
|
243
|
+
const ok = Boolean(parsed.status && parsed.status < 300);
|
|
244
|
+
log?.record({ url: url.split('?')[0], method: verb, status: parsed.status, ok, ms: Date.now() - started, ...(parsed.status ? {} : { error: result.error ?? 'GH' }) });
|
|
245
|
+
if (ok) return { status: parsed.status, data: parsed.data };
|
|
246
|
+
if (!parsed.status) {
|
|
247
|
+
if (/auth login|not logged in|authentication/i.test(result.stderr)) throw new GithubError('NO_SESSION', ERRORS.NO_SESSION);
|
|
248
|
+
throw new GithubError('NETWORK', ERRORS.NETWORK);
|
|
249
|
+
}
|
|
250
|
+
throw explain({ ...parsed, kind: 'gh', stderr: result.stderr });
|
|
251
|
+
}
|
|
252
|
+
// In the room, fetch is already watched and the request is logged there; wrapping it again
|
|
253
|
+
// would count it twice. In the MCP server's own process nothing watches it but this.
|
|
254
|
+
const send = log && !globalThis.fetch?.watched ? log.watch(client.fetch) : client.fetch;
|
|
255
|
+
let response;
|
|
256
|
+
try {
|
|
257
|
+
response = await send(url, {
|
|
258
|
+
method: verb,
|
|
259
|
+
headers: {
|
|
260
|
+
accept: 'application/vnd.github+json',
|
|
261
|
+
'x-github-api-version': '2022-11-28',
|
|
262
|
+
'user-agent': 'MADRE',
|
|
263
|
+
authorization: `Bearer ${auth.token}`,
|
|
264
|
+
...(body != null ? { 'content-type': 'application/json' } : {}),
|
|
265
|
+
},
|
|
266
|
+
...(body != null ? { body: JSON.stringify(body) } : {}),
|
|
267
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
268
|
+
});
|
|
269
|
+
} catch {
|
|
270
|
+
throw new GithubError('NETWORK', ERRORS.NETWORK);
|
|
271
|
+
}
|
|
272
|
+
const data = await response.json().catch(() => null);
|
|
273
|
+
if (response.ok) return { status: response.status, data };
|
|
274
|
+
throw explain({ status: response.status, data, kind: 'token', rateLimited: response.headers?.get?.('x-ratelimit-remaining') === '0' });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ── What a person or an agent asks for, trimmed ───────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
const cut = (text, max) => { const value = String(text ?? '').trim(); return value.length > max ? `${value.slice(0, max - 1)}…` : value; };
|
|
280
|
+
const base = (repo) => `/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.repo)}`;
|
|
281
|
+
const number = (value) => {
|
|
282
|
+
const n = Number(String(value ?? '').replace(/^#/, ''));
|
|
283
|
+
if (!Number.isInteger(n) || n < 1) throw new GithubError('INVALID_NUMBER', ERRORS.INVALID_NUMBER);
|
|
284
|
+
return n;
|
|
285
|
+
};
|
|
286
|
+
const per = (value, fallback, max = 50) => Math.min(Math.max(Number(value) || fallback, 1), max);
|
|
287
|
+
|
|
288
|
+
export async function getRepo({ repo, ...rest }) {
|
|
289
|
+
const { data } = await githubRequest({ path: base(repo), ...rest });
|
|
290
|
+
return { slug: repo.slug, defaultBranch: data?.default_branch ?? null, private: Boolean(data?.private), openIssuesAndPulls: data?.open_issues_count ?? null, url: data?.html_url ?? null };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export async function listPulls({ repo, limit = 20, ...rest }) {
|
|
294
|
+
const { data } = await githubRequest({ path: `${base(repo)}/pulls?state=open&per_page=${per(limit, 20, 100)}`, ...rest });
|
|
295
|
+
return (Array.isArray(data) ? data : []).map((pr) => ({
|
|
296
|
+
number: pr.number, title: cut(pr.title, 140), author: pr.user?.login ?? null, branch: pr.head?.ref ?? null, base: pr.base?.ref ?? null, draft: Boolean(pr.draft), updated: pr.updated_at ?? null, url: pr.html_url ?? null,
|
|
297
|
+
}));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// The latest review of each reviewer decides; a comment after an approval does not undo it.
|
|
301
|
+
export function reviewState(reviews = []) {
|
|
302
|
+
const latest = new Map();
|
|
303
|
+
for (const review of Array.isArray(reviews) ? reviews : []) {
|
|
304
|
+
const who = review?.user?.login;
|
|
305
|
+
if (!who || !['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(review.state)) continue;
|
|
306
|
+
latest.set(who, review.state);
|
|
307
|
+
}
|
|
308
|
+
const approved = [...latest].filter(([, state]) => state === 'APPROVED').map(([who]) => who);
|
|
309
|
+
const changes = [...latest].filter(([, state]) => state === 'CHANGES_REQUESTED').map(([who]) => who);
|
|
310
|
+
return { state: changes.length ? 'changes requested' : approved.length ? 'approved' : 'no review yet', approved, changesRequested: changes };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export async function getPull({ repo, number: n, ...rest }) {
|
|
314
|
+
const id = number(n);
|
|
315
|
+
const [{ data: pr }, { data: reviews }] = await Promise.all([
|
|
316
|
+
githubRequest({ path: `${base(repo)}/pulls/${id}`, ...rest }),
|
|
317
|
+
githubRequest({ path: `${base(repo)}/pulls/${id}/reviews?per_page=100`, ...rest }),
|
|
318
|
+
]);
|
|
319
|
+
return {
|
|
320
|
+
number: pr?.number ?? id,
|
|
321
|
+
title: cut(pr?.title, 200),
|
|
322
|
+
state: pr?.merged_at ? 'merged' : pr?.state ?? null,
|
|
323
|
+
draft: Boolean(pr?.draft),
|
|
324
|
+
author: pr?.user?.login ?? null,
|
|
325
|
+
branch: pr?.head?.ref ?? null,
|
|
326
|
+
base: pr?.base?.ref ?? null,
|
|
327
|
+
mergeable: pr?.mergeable ?? null,
|
|
328
|
+
mergeableState: pr?.mergeable_state ?? null,
|
|
329
|
+
additions: pr?.additions ?? null,
|
|
330
|
+
deletions: pr?.deletions ?? null,
|
|
331
|
+
changedFiles: pr?.changed_files ?? null,
|
|
332
|
+
commits: pr?.commits ?? null,
|
|
333
|
+
review: reviewState(reviews),
|
|
334
|
+
body: cut(pr?.body, 1200),
|
|
335
|
+
url: pr?.html_url ?? null,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// One word for a ref: the old commit statuses and the newer check runs, folded together. Any
|
|
340
|
+
// failure is a failure; anything still running is pending; nothing at all is said to be nothing.
|
|
341
|
+
export function foldChecks({ statuses = [], runs = [] }) {
|
|
342
|
+
const checks = [
|
|
343
|
+
...statuses.map((one) => ({ name: one.context ?? 'status', state: one.state === 'success' ? 'success' : one.state === 'pending' ? 'pending' : 'failure' })),
|
|
344
|
+
...runs.map((one) => ({ name: one.name ?? 'check', state: one.status !== 'completed' ? 'pending' : ['success', 'neutral', 'skipped'].includes(one.conclusion) ? 'success' : 'failure' })),
|
|
345
|
+
];
|
|
346
|
+
const state = !checks.length ? 'none' : checks.some((one) => one.state === 'failure') ? 'failure' : checks.some((one) => one.state === 'pending') ? 'pending' : 'success';
|
|
347
|
+
return { state, checks: checks.slice(0, 30) };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export async function ciStatus({ repo, ref, ...rest }) {
|
|
351
|
+
if (!ref) throw new GithubError('NO_BRANCH', ERRORS.NO_BRANCH);
|
|
352
|
+
const at = encodeURIComponent(ref);
|
|
353
|
+
const [{ data: combined }, { data: checks }] = await Promise.all([
|
|
354
|
+
githubRequest({ path: `${base(repo)}/commits/${at}/status`, ...rest }),
|
|
355
|
+
githubRequest({ path: `${base(repo)}/commits/${at}/check-runs?per_page=50`, ...rest }),
|
|
356
|
+
]);
|
|
357
|
+
return { ref, ...foldChecks({ statuses: combined?.statuses ?? [], runs: checks?.check_runs ?? [] }) };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export async function workflowRuns({ repo, branch, limit = 5, ...rest }) {
|
|
361
|
+
const query = `per_page=${per(limit, 5, 20)}${branch ? `&branch=${encodeURIComponent(branch)}` : ''}`;
|
|
362
|
+
const { data } = await githubRequest({ path: `${base(repo)}/actions/runs?${query}`, ...rest });
|
|
363
|
+
return (data?.workflow_runs ?? []).map((run) => ({
|
|
364
|
+
name: cut(run.name, 80), status: run.status ?? null, conclusion: run.conclusion ?? null, event: run.event ?? null, sha: String(run.head_sha ?? '').slice(0, 7), created: run.created_at ?? null, url: run.html_url ?? null,
|
|
365
|
+
}));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export async function listIssues({ repo, limit = 20, ...rest }) {
|
|
369
|
+
const { data } = await githubRequest({ path: `${base(repo)}/issues?state=open&per_page=${per(limit, 20, 100)}`, ...rest });
|
|
370
|
+
// The issues endpoint lists pull requests too; they have their own list.
|
|
371
|
+
return (Array.isArray(data) ? data : []).filter((issue) => !issue.pull_request).map((issue) => ({
|
|
372
|
+
number: issue.number, title: cut(issue.title, 140), author: issue.user?.login ?? null, labels: (issue.labels ?? []).map((one) => (typeof one === 'string' ? one : one?.name)).filter(Boolean).slice(0, 6), comments: issue.comments ?? 0, updated: issue.updated_at ?? null, url: issue.html_url ?? null,
|
|
373
|
+
}));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export async function getIssue({ repo, number: n, ...rest }) {
|
|
377
|
+
const { data: issue } = await githubRequest({ path: `${base(repo)}/issues/${number(n)}`, ...rest });
|
|
378
|
+
return {
|
|
379
|
+
number: issue?.number ?? number(n), title: cut(issue?.title, 200), state: issue?.state ?? null, author: issue?.user?.login ?? null,
|
|
380
|
+
labels: (issue?.labels ?? []).map((one) => (typeof one === 'string' ? one : one?.name)).filter(Boolean).slice(0, 10),
|
|
381
|
+
comments: issue?.comments ?? 0, isPullRequest: Boolean(issue?.pull_request), body: cut(issue?.body, 1500), url: issue?.html_url ?? null,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ── Writes. Each one posts under the human's name and does not come back. ─────────────────────
|
|
386
|
+
|
|
387
|
+
const required = (value, code) => {
|
|
388
|
+
const text = String(value ?? '').trim();
|
|
389
|
+
if (!text) throw new GithubError(code, ERRORS[code]);
|
|
390
|
+
return text;
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
export async function createPull({ repo, title, body = '', head, base: into = null, ...rest }) {
|
|
394
|
+
const target = into || (await getRepo({ repo, ...rest })).defaultBranch;
|
|
395
|
+
const { data } = await githubRequest({ method: 'POST', path: `${base(repo)}/pulls`, body: { title: required(title, 'NO_TITLE'), head: required(head, 'NO_BRANCH'), base: required(target, 'NO_BASE'), body: String(body ?? '') }, ...rest });
|
|
396
|
+
return { number: data?.number ?? null, url: data?.html_url ?? null, head, base: target };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export async function comment({ repo, number: n, body, ...rest }) {
|
|
400
|
+
const { data } = await githubRequest({ method: 'POST', path: `${base(repo)}/issues/${number(n)}/comments`, body: { body: required(body, 'NO_BODY') }, ...rest });
|
|
401
|
+
return { number: number(n), url: data?.html_url ?? null };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export async function createIssue({ repo, title, body = '', ...rest }) {
|
|
405
|
+
const { data } = await githubRequest({ method: 'POST', path: `${base(repo)}/issues`, body: { title: required(title, 'NO_TITLE'), body: String(body ?? '') }, ...rest });
|
|
406
|
+
return { number: data?.number ?? null, url: data?.html_url ?? null };
|
|
407
|
+
}
|