@chappibunny/repolens 1.15.4 → 1.16.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/CHANGELOG.md +64 -0
- package/README.md +18 -15
- package/app.yml +23 -0
- package/github-app-manifest.json +22 -0
- package/llms-full.txt +1 -0
- package/llms-install.md +2 -0
- package/llms.txt +1 -0
- package/package.json +8 -6
- package/src/ai/generate-sections.js +130 -52
- package/src/analyzers/context-builder.js +104 -57
- package/src/analyzers/dependency-graph.js +59 -1
- package/src/analyzers/domain-inference.js +35 -4
- package/src/analyzers/flow-inference.js +27 -23
- package/src/analyzers/security-patterns.js +29 -3
- package/src/cli-args.js +4 -0
- package/src/cli.js +11 -0
- package/src/core/scan.js +106 -18
- package/src/docs/generate-doc-set.js +21 -0
- package/src/docs/llms-text.js +2 -0
- package/src/github-app/auth.js +130 -0
- package/src/github-app/checkout.js +70 -0
- package/src/github-app/handler.js +170 -0
- package/src/github-app/manifest.js +36 -0
- package/src/github-app/run.js +67 -0
- package/src/github-app/server.js +120 -0
- package/src/github-app/signature.js +22 -0
- package/src/help.js +44 -0
- package/src/renderers/render.js +3 -3
- package/src/renderers/renderAnalysis.js +28 -9
- package/src/renderers/renderDiff.js +5 -3
- package/src/renderers/renderMap.js +7 -5
- package/src/utils/path-roles.js +58 -0
- package/src/utils/route-params.js +50 -0
- package/src/utils/yaml.js +11 -6
package/src/core/scan.js
CHANGED
|
@@ -5,6 +5,8 @@ import { info, warn } from "../utils/logger.js";
|
|
|
5
5
|
import { trackScan } from "../utils/telemetry.js";
|
|
6
6
|
import { detectMonorepo } from "../analyzers/monorepo-detector.js";
|
|
7
7
|
import { isInsideRoot } from "../utils/safe-path.js";
|
|
8
|
+
import { isNonProductPath, isTestPath } from "../utils/path-roles.js";
|
|
9
|
+
import { replaceBracketParams } from "../utils/route-params.js";
|
|
8
10
|
|
|
9
11
|
const norm = (p) => p.replace(/\\/g, "/");
|
|
10
12
|
|
|
@@ -92,7 +94,13 @@ async function readFileSafe(file) {
|
|
|
92
94
|
}
|
|
93
95
|
}
|
|
94
96
|
|
|
95
|
-
function
|
|
97
|
+
function rootModuleLabel(repoRoot) {
|
|
98
|
+
const base = path.basename(path.resolve(repoRoot || ""));
|
|
99
|
+
if (!base || base === "." || base === "..") return "root";
|
|
100
|
+
return base;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function moduleKeyForFile(file, moduleRoots, rootLabel = "root") {
|
|
96
104
|
const normalized = norm(file);
|
|
97
105
|
|
|
98
106
|
const sortedRoots = [...moduleRoots].sort((a, b) => b.length - a.length);
|
|
@@ -101,13 +109,18 @@ function moduleKeyForFile(file, moduleRoots) {
|
|
|
101
109
|
if (normalized === root) return root;
|
|
102
110
|
if (normalized.startsWith(`${root}/`)) {
|
|
103
111
|
const remainder = normalized.slice(root.length + 1);
|
|
112
|
+
// A file directly under the root belongs to the root itself. Only a sub-directory
|
|
113
|
+
// is its own module, so a flat `src/` is one module and not one module per file.
|
|
114
|
+
if (!remainder.includes("/")) return root;
|
|
104
115
|
const nextSegment = remainder.split("/")[0];
|
|
105
116
|
return nextSegment ? `${root}/${nextSegment}` : root;
|
|
106
117
|
}
|
|
107
118
|
}
|
|
108
119
|
|
|
109
120
|
const parts = normalized.split("/");
|
|
110
|
-
|
|
121
|
+
// A file next to go.mod or package.json is one module, named for the directory.
|
|
122
|
+
// Calling it "root" hid that package on the system map.
|
|
123
|
+
return parts.length > 1 ? parts[0] : rootLabel;
|
|
111
124
|
}
|
|
112
125
|
|
|
113
126
|
function routePathFromFile(file) {
|
|
@@ -117,10 +130,9 @@ function routePathFromFile(file) {
|
|
|
117
130
|
const appIndex = f.indexOf("/app/");
|
|
118
131
|
const relative = f.slice(appIndex + 5);
|
|
119
132
|
|
|
120
|
-
const cleaned = relative
|
|
133
|
+
const cleaned = replaceBracketParams(relative
|
|
121
134
|
.replace(/\/page\.(ts|tsx|js|jsx)$/, "")
|
|
122
|
-
.replace(/\/route\.(ts|tsx|js|jsx)$/, "")
|
|
123
|
-
.replace(/\[(.*?)\]/g, ":$1")
|
|
135
|
+
.replace(/\/route\.(ts|tsx|js|jsx)$/, ""))
|
|
124
136
|
.replace(/\/$/, "");
|
|
125
137
|
|
|
126
138
|
return cleaned ? `/${cleaned}` : "/";
|
|
@@ -137,10 +149,9 @@ function routePathFromFile(file) {
|
|
|
137
149
|
const pagesIndex = f.indexOf("/pages/");
|
|
138
150
|
const relative = f.slice(pagesIndex + 7);
|
|
139
151
|
|
|
140
|
-
const cleaned = relative
|
|
152
|
+
const cleaned = replaceBracketParams(relative
|
|
141
153
|
.replace(/\.(ts|tsx|js|jsx)$/, "")
|
|
142
|
-
.replace(/\/index$/, "")
|
|
143
|
-
.replace(/\[(.*?)\]/g, ":$1")
|
|
154
|
+
.replace(/\/index$/, ""))
|
|
144
155
|
.replace(/\/$/, "");
|
|
145
156
|
|
|
146
157
|
return cleaned ? `/${cleaned}` : "/";
|
|
@@ -149,6 +160,26 @@ function routePathFromFile(file) {
|
|
|
149
160
|
return file;
|
|
150
161
|
}
|
|
151
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Source files a package.json names as its public entry: `main`, `module`, `types`, and every
|
|
165
|
+
* string under `exports`. Paths are repo-relative with no leading `./`.
|
|
166
|
+
*/
|
|
167
|
+
function packageEntryFiles(pkg) {
|
|
168
|
+
const found = new Set();
|
|
169
|
+
const add = (value) => {
|
|
170
|
+
if (typeof value !== "string" || !/\.(c|m)?[jt]sx?$/.test(value) || value.endsWith(".d.ts")) return;
|
|
171
|
+
found.add(value.replace(/^\.\//, ""));
|
|
172
|
+
};
|
|
173
|
+
const walk = (value) => {
|
|
174
|
+
if (typeof value === "string") add(value);
|
|
175
|
+
else if (value && typeof value === "object") Object.values(value).forEach(walk);
|
|
176
|
+
};
|
|
177
|
+
add(pkg.main);
|
|
178
|
+
add(pkg.module);
|
|
179
|
+
walk(pkg.exports);
|
|
180
|
+
return [...found];
|
|
181
|
+
}
|
|
182
|
+
|
|
152
183
|
/**
|
|
153
184
|
* Detect languages from file extensions
|
|
154
185
|
*/
|
|
@@ -204,9 +235,12 @@ function detectLanguagesFromFiles(files) {
|
|
|
204
235
|
|
|
205
236
|
for (const file of files) {
|
|
206
237
|
const ext = path.extname(file).toLowerCase();
|
|
207
|
-
if (extensionMap[ext])
|
|
208
|
-
|
|
209
|
-
|
|
238
|
+
if (!extensionMap[ext]) continue;
|
|
239
|
+
// Type declarations (`index.d.ts`) and type tests describe a JavaScript library's
|
|
240
|
+
// types. They do not make the library a TypeScript project.
|
|
241
|
+
if (ext === ".ts" && /\.d\.ts$/i.test(file)) continue;
|
|
242
|
+
if (extensionMap[ext] === "TypeScript" && isTestPath(file)) continue;
|
|
243
|
+
languages.add(extensionMap[ext]);
|
|
210
244
|
}
|
|
211
245
|
|
|
212
246
|
return languages;
|
|
@@ -268,7 +302,9 @@ async function extractRepoMetadata(repoRoot, files = []) {
|
|
|
268
302
|
if (allDeps["parcel"]) metadata.buildTools.push("Parcel");
|
|
269
303
|
|
|
270
304
|
// Detect TypeScript from dependencies (supplements file detection)
|
|
271
|
-
|
|
305
|
+
// A `typescript` devDependency alone does not make the source TypeScript: plain JavaScript
|
|
306
|
+
// libraries use it to check their `.d.ts` files. Trust it only when no files were scanned.
|
|
307
|
+
if (allDeps["typescript"] && files.length === 0) metadata.languages.add("TypeScript");
|
|
272
308
|
|
|
273
309
|
// Detect Node.js runtime indicators
|
|
274
310
|
const hasNodeEngines = pkg.engines && pkg.engines.node;
|
|
@@ -279,6 +315,25 @@ async function extractRepoMetadata(repoRoot, files = []) {
|
|
|
279
315
|
if (hasNodeEngines || hasBin || hasNodeDeps || pkg.type === "module") {
|
|
280
316
|
metadata.languages.add("Node.js");
|
|
281
317
|
}
|
|
318
|
+
|
|
319
|
+
// A package with a library entry point (main, module, exports, types) is a library even
|
|
320
|
+
// when it also ships a `bin/` helper. Only a package with a bin and no library entry is a CLI.
|
|
321
|
+
// When it has both, the description decides: "Library that parses..." is a library,
|
|
322
|
+
// while a package that calls itself a CLI keeps that label.
|
|
323
|
+
const hasLibraryEntry = Boolean(pkg.main || pkg.module || pkg.exports || pkg.types || pkg.typings || pkg.browser);
|
|
324
|
+
const isShippedApp = Boolean(allDeps["electron"] || allDeps["next"]);
|
|
325
|
+
metadata.packageEntryFiles = packageEntryFiles(pkg);
|
|
326
|
+
const description = String(pkg.description || "");
|
|
327
|
+
const keywords = Array.isArray(pkg.keywords) ? pkg.keywords.join(" ") : "";
|
|
328
|
+
const saysLibrary = /\b(library|sdk|parser|toolkit|framework)\b/i.test(`${description} ${keywords}`);
|
|
329
|
+
const saysCli = /\bcli\b|command[- ]line/i.test(`${description} ${keywords}`);
|
|
330
|
+
// Express 5 publishes index.js with no `main` field. A description that says
|
|
331
|
+
// library or framework is enough; Node's default entry is that index.js.
|
|
332
|
+
if ((hasLibraryEntry || saysLibrary) && !isShippedApp) {
|
|
333
|
+
metadata.projectKind = hasBin && !saysLibrary && saysCli ? "cli" : "library";
|
|
334
|
+
} else if (hasBin && !hasLibraryEntry) {
|
|
335
|
+
metadata.projectKind = "cli";
|
|
336
|
+
}
|
|
282
337
|
} catch {
|
|
283
338
|
// No package.json or invalid JSON
|
|
284
339
|
}
|
|
@@ -362,16 +417,37 @@ async function extractRepoMetadata(repoRoot, files = []) {
|
|
|
362
417
|
return metadata;
|
|
363
418
|
}
|
|
364
419
|
|
|
420
|
+
function isCommentLine(line) {
|
|
421
|
+
const trimmed = String(line || "").trim();
|
|
422
|
+
return trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*");
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function lineAt(content, index) {
|
|
426
|
+
const start = content.lastIndexOf("\n", Math.max(0, index - 1)) + 1;
|
|
427
|
+
const end = content.indexOf("\n", index);
|
|
428
|
+
return content.slice(start, end === -1 ? content.length : end);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Express route paths start with `/`. `app.get('query parser fn')` is a setting. */
|
|
432
|
+
function isExpressPath(routePath) {
|
|
433
|
+
return typeof routePath === "string"
|
|
434
|
+
&& routePath.length > 0
|
|
435
|
+
&& routePath.length <= 200
|
|
436
|
+
&& (routePath.startsWith("/") || routePath === "*");
|
|
437
|
+
}
|
|
438
|
+
|
|
365
439
|
function extractExpressRoutes(content) {
|
|
366
440
|
const routes = [];
|
|
367
441
|
const seenRoutes = new Set();
|
|
368
442
|
|
|
369
|
-
// Match app.METHOD(path) or router.METHOD(path)
|
|
443
|
+
// Match app.METHOD(path) or router.METHOD(path). Skip comments and setting lookups.
|
|
370
444
|
const methodPattern = /(?:app|router)\.(get|post|put|patch|delete|all)\s*\(\s*['"`]([^'"`]+)['"`]/gi;
|
|
371
445
|
let match;
|
|
372
446
|
|
|
373
447
|
while ((match = methodPattern.exec(content)) !== null) {
|
|
448
|
+
if (isCommentLine(lineAt(content, match.index))) continue;
|
|
374
449
|
const [, method, path] = match;
|
|
450
|
+
if (!isExpressPath(path)) continue;
|
|
375
451
|
const routeKey = `${method.toUpperCase()}:${path}`;
|
|
376
452
|
|
|
377
453
|
if (!seenRoutes.has(routeKey)) {
|
|
@@ -394,7 +470,9 @@ function extractExpressRoutes(content) {
|
|
|
394
470
|
// Match app.route(path).METHOD()
|
|
395
471
|
const routePattern = /(?:app|router)\.route\s*\(\s*['"`]([^'"`]+)['"`]\s*\)([.\s\w()]*)/gi;
|
|
396
472
|
while ((match = routePattern.exec(content)) !== null) {
|
|
473
|
+
if (isCommentLine(lineAt(content, match.index))) continue;
|
|
397
474
|
const [, path, chain] = match;
|
|
475
|
+
if (!isExpressPath(path)) continue;
|
|
398
476
|
const methods = [];
|
|
399
477
|
|
|
400
478
|
if (chain.includes(".get(")) methods.push("GET");
|
|
@@ -510,21 +588,28 @@ export async function scanRepo(cfg) {
|
|
|
510
588
|
}
|
|
511
589
|
|
|
512
590
|
const moduleCounts = new Map();
|
|
591
|
+
const flatKeys = new Set();
|
|
592
|
+
const rootLabel = rootModuleLabel(repoRoot);
|
|
513
593
|
|
|
514
594
|
for (const file of files) {
|
|
515
595
|
// Go test files belong to the package they exercise. Counting each
|
|
516
596
|
// *_test.go as its own module (the filename under module_roots) splits
|
|
517
597
|
// one package into a source module and a test module.
|
|
518
598
|
if (/_test\.go$/.test(norm(file))) continue;
|
|
519
|
-
const key = moduleKeyForFile(file, cfg.module_roots || []);
|
|
599
|
+
const key = moduleKeyForFile(file, cfg.module_roots || [], rootLabel);
|
|
600
|
+
if (!norm(file).includes("/")) flatKeys.add(key);
|
|
520
601
|
moduleCounts.set(key, (moduleCounts.get(key) || 0) + 1);
|
|
521
602
|
}
|
|
522
603
|
|
|
523
604
|
const modules = [...moduleCounts.entries()]
|
|
524
|
-
.map(([key, fileCount]) => ({
|
|
605
|
+
.map(([key, fileCount]) => ({
|
|
606
|
+
key,
|
|
607
|
+
fileCount,
|
|
608
|
+
...(flatKeys.has(key) ? { flat: true } : {}),
|
|
609
|
+
}))
|
|
525
610
|
.sort((a, b) => b.fileCount - a.fileCount);
|
|
526
611
|
|
|
527
|
-
const apiFiles = files.filter(isNextRoute);
|
|
612
|
+
const apiFiles = files.filter((file) => isNextRoute(file) && !isNonProductPath(file));
|
|
528
613
|
const api = [];
|
|
529
614
|
|
|
530
615
|
// Extract Next.js API routes
|
|
@@ -547,8 +632,9 @@ export async function scanRepo(cfg) {
|
|
|
547
632
|
});
|
|
548
633
|
}
|
|
549
634
|
|
|
550
|
-
// Extract Express.js routes
|
|
635
|
+
// Extract Express.js routes. Tests and examples are not the product API.
|
|
551
636
|
for (const file of files) {
|
|
637
|
+
if (isNonProductPath(file)) continue;
|
|
552
638
|
if (file.endsWith(".ts") || file.endsWith(".js")) {
|
|
553
639
|
const absoluteFile = path.join(repoRoot, file);
|
|
554
640
|
const content = await readFileSafe(absoluteFile);
|
|
@@ -567,7 +653,7 @@ export async function scanRepo(cfg) {
|
|
|
567
653
|
}
|
|
568
654
|
}
|
|
569
655
|
|
|
570
|
-
const pageFiles = files.filter(isNextPage);
|
|
656
|
+
const pageFiles = files.filter((file) => isNextPage(file) && !isNonProductPath(file));
|
|
571
657
|
const pages = pageFiles.map((file) => ({
|
|
572
658
|
file,
|
|
573
659
|
path: routePathFromFile(file),
|
|
@@ -576,6 +662,7 @@ export async function scanRepo(cfg) {
|
|
|
576
662
|
|
|
577
663
|
// Extract React Router routes
|
|
578
664
|
for (const file of files) {
|
|
665
|
+
if (isNonProductPath(file)) continue;
|
|
579
666
|
if (file.endsWith(".tsx") || file.endsWith(".jsx")) {
|
|
580
667
|
const absoluteFile = path.join(repoRoot, file);
|
|
581
668
|
const content = await readFileSafe(absoluteFile);
|
|
@@ -589,6 +676,7 @@ export async function scanRepo(cfg) {
|
|
|
589
676
|
|
|
590
677
|
// Extract Vue Router routes
|
|
591
678
|
for (const file of files) {
|
|
679
|
+
if (isNonProductPath(file)) continue;
|
|
592
680
|
if (file.endsWith(".ts") || file.endsWith(".js") || file.endsWith(".vue")) {
|
|
593
681
|
const absoluteFile = path.join(repoRoot, file);
|
|
594
682
|
const content = await readFileSafe(absoluteFile);
|
|
@@ -130,6 +130,11 @@ export async function generateDocumentSet(scanResult, config, diffData = null, p
|
|
|
130
130
|
|
|
131
131
|
for (const docPlan of activeDocuments) {
|
|
132
132
|
docIndex++;
|
|
133
|
+
const emptyReason = emptyDocumentReason(docPlan, { config, scanResult, graphqlResult });
|
|
134
|
+
if (emptyReason) {
|
|
135
|
+
info(`[${docIndex}/${totalDocs}] Skipping ${docPlan.key}: ${emptyReason}`);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
133
138
|
info(`[${docIndex}/${totalDocs}] Generating ${docPlan.key}...`);
|
|
134
139
|
let content = null;
|
|
135
140
|
|
|
@@ -237,6 +242,22 @@ export async function generateDocumentSet(scanResult, config, diffData = null, p
|
|
|
237
242
|
};
|
|
238
243
|
}
|
|
239
244
|
|
|
245
|
+
/**
|
|
246
|
+
* Documents that would only say "nothing found" are not written. A library has no GraphQL
|
|
247
|
+
* schema and no routes, so those two pages would be noise. A document the config lists
|
|
248
|
+
* explicitly in `documentation.sections` is always written.
|
|
249
|
+
*/
|
|
250
|
+
export function emptyDocumentReason(docPlan, { config, scanResult, graphqlResult }) {
|
|
251
|
+
if (config?.documentation?.sections?.includes(docPlan.key)) return null;
|
|
252
|
+
if (docPlan.key === "graphql_schema" && !graphqlResult?.detected) {
|
|
253
|
+
return "no GraphQL schema detected";
|
|
254
|
+
}
|
|
255
|
+
if (docPlan.key === "route_map" && !(scanResult?.pages?.length) && !(scanResult?.api?.length)) {
|
|
256
|
+
return "no pages or API routes detected";
|
|
257
|
+
}
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
|
|
240
261
|
async function generateDocument(docPlan, context) {
|
|
241
262
|
const { key } = docPlan;
|
|
242
263
|
const { scanResult, config, aiContext, moduleContext, flows, diffData, graphqlResult, tsResult, jsdocResult, depGraph, driftResult, securityResult, codeHealthResult, ownershipMap, pluginManager } = context;
|
package/src/docs/llms-text.js
CHANGED
|
@@ -34,6 +34,7 @@ RepoLens is the npm package \`@chappibunny/repolens\` (Node.js 18+). It is a CLI
|
|
|
34
34
|
- [publish](${REPO_URL}/blob/main/docs/INSTALLATION.md): \`npx @chappibunny/repolens publish\` scans and publishes
|
|
35
35
|
- [badge](${REPO_URL}/blob/main/README.md): \`npx @chappibunny/repolens badge\` prints shields.io code-health JSON, SVG, or markdown
|
|
36
36
|
- [comment](${REPO_URL}/blob/main/README.md): \`npx @chappibunny/repolens comment --base <ref> --head <ref>\` prints an architecture diff
|
|
37
|
+
- [app](${REPO_URL}/blob/main/docs/GITHUB_APP.md): \`npx @chappibunny/repolens app\` self-hosts the GitHub App webhook. \`--manifest\` prints the manifest. No credentials are required to print it
|
|
37
38
|
- [doctor](${REPO_URL}/blob/main/docs/INSTALLATION.md): \`npx @chappibunny/repolens doctor\` validates setup
|
|
38
39
|
- [mcp](${REPO_URL}/blob/main/docs/MCP.md): \`npx -y @chappibunny/repolens mcp\` starts the stdio server
|
|
39
40
|
|
|
@@ -95,6 +96,7 @@ Agent-oriented install steps, including the Cline MCP snippet, are in llms-insta
|
|
|
95
96
|
- \`repolens watch\` regenerates docs on change and exits 2 when nothing matches.
|
|
96
97
|
- \`repolens badge\` prints a shields.io endpoint JSON document for the code-health score (0–100). \`--format svg\` prints a square SVG. \`--format markdown --url https://host/repolens-badge.json\` prints a README snippet. \`--format all --out repolens-badge.json\` writes JSON and SVG. Exit 2 when no JavaScript, TypeScript, Python, or Go files were scored. The score is not reported as 100 when zero files match.
|
|
97
98
|
- \`repolens comment --base <ref> --head <ref>\` compares those commits. It prints markdown locally. In GitHub Actions, with \`GITHUB_TOKEN\`, \`GITHUB_REPOSITORY\`, and a pull_request event, it creates or updates one comment.
|
|
99
|
+
- \`repolens app\` self-hosts a GitHub App webhook. It verifies \`X-Hub-Signature-256\`, posts a Check Run named RepoLens, and updates the sticky architecture comment. \`repolens app --manifest\` prints the manifest and needs no secrets. Listening needs \`REPOLENS_APP_ID\`, \`REPOLENS_WEBHOOK_SECRET\`, and a private key. See docs/GITHUB_APP.md.
|
|
98
100
|
- \`repolens mcp\` starts the stdio MCP server. \`repolens-mcp\` is the same entry.
|
|
99
101
|
|
|
100
102
|
Exit codes: 0 success, 1 runtime error or unknown command, 2 validation failure (bad flags, missing config, empty scan, and the other cases in the README).
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { createSign } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { fetchWithRetry } from "../utils/retry.js";
|
|
4
|
+
import { sanitizeSecrets } from "../utils/secrets.js";
|
|
5
|
+
|
|
6
|
+
function base64url(value) {
|
|
7
|
+
return Buffer.from(value).toString("base64url");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* GitHub App JWT (RS256). `iat` is 60s in the past so a skewed clock still works.
|
|
12
|
+
* `exp` is 9 minutes ahead (GitHub rejects more than 10).
|
|
13
|
+
*/
|
|
14
|
+
export function createAppJwt({ appId, privateKey, now = Date.now() }) {
|
|
15
|
+
if (!appId) throw new Error("Missing GitHub App id");
|
|
16
|
+
if (!privateKey || !String(privateKey).includes("BEGIN")) {
|
|
17
|
+
throw new Error("GitHub App private key must be a PEM body");
|
|
18
|
+
}
|
|
19
|
+
const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
|
|
20
|
+
const payload = base64url(JSON.stringify({
|
|
21
|
+
iat: Math.floor(now / 1000) - 60,
|
|
22
|
+
exp: Math.floor(now / 1000) + 9 * 60,
|
|
23
|
+
iss: String(appId),
|
|
24
|
+
}));
|
|
25
|
+
const unsigned = `${header}.${payload}`;
|
|
26
|
+
const signer = createSign("RSA-SHA256");
|
|
27
|
+
signer.update(unsigned);
|
|
28
|
+
signer.end();
|
|
29
|
+
const signature = signer.sign(privateKey).toString("base64url");
|
|
30
|
+
return `${unsigned}.${signature}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function readAppPrivateKey(env = process.env) {
|
|
34
|
+
const file = env.REPOLENS_APP_PRIVATE_KEY_PATH;
|
|
35
|
+
if (file) {
|
|
36
|
+
return readFileSync(file, "utf8");
|
|
37
|
+
}
|
|
38
|
+
const raw = env.REPOLENS_APP_PRIVATE_KEY || "";
|
|
39
|
+
return raw.includes("\\n") ? raw.replace(/\\n/g, "\n") : raw;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function redactToken(text, token) {
|
|
43
|
+
const value = String(text || "");
|
|
44
|
+
if (!token) return value;
|
|
45
|
+
return value.split(token).join("[redacted]");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Exchange the app JWT for an installation access token.
|
|
50
|
+
* `fetchImpl` is injectable in tests. It must return a Response.
|
|
51
|
+
*/
|
|
52
|
+
export async function fetchInstallationToken({
|
|
53
|
+
appId,
|
|
54
|
+
privateKey,
|
|
55
|
+
installationId,
|
|
56
|
+
fetchImpl,
|
|
57
|
+
now,
|
|
58
|
+
}) {
|
|
59
|
+
if (!/^[0-9]+$/.test(String(installationId))) {
|
|
60
|
+
throw new Error("Invalid installation id");
|
|
61
|
+
}
|
|
62
|
+
const jwt = createAppJwt({ appId, privateKey, now });
|
|
63
|
+
const url = `https://api.github.com/app/installations/${installationId}/access_tokens`;
|
|
64
|
+
const send = fetchImpl || ((resource, options) => fetchWithRetry(resource, options, {
|
|
65
|
+
retries: 2,
|
|
66
|
+
baseDelayMs: 400,
|
|
67
|
+
maxDelayMs: 2000,
|
|
68
|
+
label: "GitHub App token",
|
|
69
|
+
}));
|
|
70
|
+
const response = await send(url, {
|
|
71
|
+
method: "POST",
|
|
72
|
+
headers: {
|
|
73
|
+
Authorization: `Bearer ${jwt}`,
|
|
74
|
+
Accept: "application/vnd.github+json",
|
|
75
|
+
"User-Agent": "RepoLens",
|
|
76
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
const text = await response.text();
|
|
80
|
+
if (!response.ok) {
|
|
81
|
+
const clipped = sanitizeSecrets(String(text || "").replace(/\s+/g, " ").slice(0, 300));
|
|
82
|
+
throw Object.assign(new Error(`GitHub App token request failed (${response.status}): ${clipped}`), {
|
|
83
|
+
status: response.status,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const json = text ? JSON.parse(text) : {};
|
|
87
|
+
if (!json.token) throw new Error("GitHub App token response had no token");
|
|
88
|
+
return json.token;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createGitHubRequest(token, fetchImpl) {
|
|
92
|
+
if (typeof token !== "string" || !/^[A-Za-z0-9_]+$/.test(token)) {
|
|
93
|
+
throw new Error("Installation token has an unexpected shape");
|
|
94
|
+
}
|
|
95
|
+
return async function githubRequest(method, urlPath, body) {
|
|
96
|
+
const send = fetchImpl || ((resource, options) => fetchWithRetry(resource, options, {
|
|
97
|
+
retries: 2,
|
|
98
|
+
baseDelayMs: 400,
|
|
99
|
+
maxDelayMs: 2000,
|
|
100
|
+
label: `GitHub ${method}`,
|
|
101
|
+
}));
|
|
102
|
+
const response = await send(`https://api.github.com${urlPath}`, {
|
|
103
|
+
method,
|
|
104
|
+
headers: {
|
|
105
|
+
Authorization: `Bearer ${token}`,
|
|
106
|
+
Accept: "application/vnd.github+json",
|
|
107
|
+
"Content-Type": "application/json",
|
|
108
|
+
"User-Agent": "RepoLens",
|
|
109
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
110
|
+
},
|
|
111
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
112
|
+
});
|
|
113
|
+
const text = await response.text();
|
|
114
|
+
let json = null;
|
|
115
|
+
if (text) {
|
|
116
|
+
try {
|
|
117
|
+
json = JSON.parse(text);
|
|
118
|
+
} catch {
|
|
119
|
+
json = null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (!response.ok) {
|
|
123
|
+
const clipped = sanitizeSecrets(redactToken(text, token).replace(/\s+/g, " ").slice(0, 300));
|
|
124
|
+
throw Object.assign(new Error(`GitHub API error ${response.status}: ${clipped}`), {
|
|
125
|
+
status: response.status,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return json;
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { isSafeGitRef } from "../core/diff.js";
|
|
6
|
+
import { assertGitHubRepo } from "../utils/github-repo.js";
|
|
7
|
+
import { redactToken } from "./auth.js";
|
|
8
|
+
|
|
9
|
+
function assertToken(token) {
|
|
10
|
+
if (typeof token !== "string" || !/^[A-Za-z0-9_]+$/.test(token)) {
|
|
11
|
+
throw new Error("Installation token has an unexpected shape");
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function git(args, cwd, token) {
|
|
16
|
+
try {
|
|
17
|
+
execFileSync("git", [
|
|
18
|
+
"-c", "safe.directory=*",
|
|
19
|
+
"-c", `http.extraheader=AUTHORIZATION: bearer ${token}`,
|
|
20
|
+
...args,
|
|
21
|
+
], {
|
|
22
|
+
cwd,
|
|
23
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
24
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
25
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
26
|
+
});
|
|
27
|
+
} catch (error) {
|
|
28
|
+
const stderr = redactToken(error.stderr || error.message || error, token)
|
|
29
|
+
.replace(/\s+/g, " ")
|
|
30
|
+
.slice(0, 300);
|
|
31
|
+
throw new Error(`git failed: ${stderr}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Clone the base repository and fetch the pull request head plus the base SHA.
|
|
37
|
+
* The token is passed as an HTTP header, not in the remote URL, and is stripped
|
|
38
|
+
* from git's error text. It can still appear in the process list on the host.
|
|
39
|
+
*/
|
|
40
|
+
export async function checkoutPullRequest({ token, owner, repo, pullNumber, baseSha, headSha }) {
|
|
41
|
+
assertToken(token);
|
|
42
|
+
assertGitHubRepo(`${owner}/${repo}`);
|
|
43
|
+
if (!/^[0-9]+$/.test(String(pullNumber))) {
|
|
44
|
+
throw new Error("Invalid pull request number");
|
|
45
|
+
}
|
|
46
|
+
if (!isSafeGitRef(baseSha) || !isSafeGitRef(headSha)) {
|
|
47
|
+
throw new Error("Invalid base or head commit");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const parent = await fs.mkdtemp(path.join(os.tmpdir(), "repolens-app-"));
|
|
51
|
+
const dir = path.join(parent, "repo");
|
|
52
|
+
const remote = `https://github.com/${owner}/${repo}.git`;
|
|
53
|
+
try {
|
|
54
|
+
await fs.mkdir(dir);
|
|
55
|
+
git(["init", "--quiet"], dir, token);
|
|
56
|
+
git(["remote", "add", "origin", remote], dir, token);
|
|
57
|
+
git(["fetch", "--quiet", "--depth", "1", "origin", baseSha], dir, token);
|
|
58
|
+
git(["fetch", "--quiet", "--depth", "1", "origin", `pull/${pullNumber}/head`], dir, token);
|
|
59
|
+
git(["checkout", "--quiet", "--detach", headSha], dir, token);
|
|
60
|
+
return {
|
|
61
|
+
dir,
|
|
62
|
+
async cleanup() {
|
|
63
|
+
await fs.rm(parent, { recursive: true, force: true });
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
} catch (error) {
|
|
67
|
+
await fs.rm(parent, { recursive: true, force: true });
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { compareArchitecture, renderArchitectureComment, upsertArchitectureComment } from "../delivery/architecture-comment.js";
|
|
2
|
+
import { assertGitHubRepo } from "../utils/github-repo.js";
|
|
3
|
+
import { sanitizeSecrets } from "../utils/secrets.js";
|
|
4
|
+
import { createGitHubRequest, fetchInstallationToken } from "./auth.js";
|
|
5
|
+
import { checkoutPullRequest } from "./checkout.js";
|
|
6
|
+
|
|
7
|
+
const CHECK_NAME = "RepoLens";
|
|
8
|
+
const ANALYZE_ACTIONS = new Set(["opened", "synchronize", "reopened", "ready_for_review"]);
|
|
9
|
+
const SUMMARY_LIMIT = 65000;
|
|
10
|
+
|
|
11
|
+
export function checkRunBody(headSha, summary, conclusion = "success") {
|
|
12
|
+
const text = String(summary || "RepoLens completed with no summary.").slice(0, SUMMARY_LIMIT);
|
|
13
|
+
return {
|
|
14
|
+
name: CHECK_NAME,
|
|
15
|
+
head_sha: headSha,
|
|
16
|
+
status: "completed",
|
|
17
|
+
conclusion,
|
|
18
|
+
output: {
|
|
19
|
+
title: conclusion === "success" ? "Architecture diff" : "Architecture diff failed",
|
|
20
|
+
summary: text,
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function defaultAnalyze(dir, base, head) {
|
|
26
|
+
const report = await compareArchitecture(dir, base, head);
|
|
27
|
+
return { summary: renderArchitectureComment(report), report };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function installationIdOf(payload) {
|
|
31
|
+
const id = payload?.installation?.id;
|
|
32
|
+
return id == null ? "" : String(id);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Handle one verified GitHub App webhook.
|
|
37
|
+
*
|
|
38
|
+
* installation and installation_repositories are acknowledged.
|
|
39
|
+
* pull_request opened / synchronize / reopened / ready_for_review runs the
|
|
40
|
+
* existing architecture diff, then posts a Check Run and the sticky comment.
|
|
41
|
+
*
|
|
42
|
+
* Inject `prepareRepo`, `analyze`, `request`, or `getInstallationToken` in tests.
|
|
43
|
+
* Returns `{ status, body }`.
|
|
44
|
+
*/
|
|
45
|
+
export async function handleGitHubAppEvent({
|
|
46
|
+
eventName,
|
|
47
|
+
payload,
|
|
48
|
+
appId,
|
|
49
|
+
privateKey,
|
|
50
|
+
prepareRepo = checkoutPullRequest,
|
|
51
|
+
analyze = defaultAnalyze,
|
|
52
|
+
request,
|
|
53
|
+
getInstallationToken = fetchInstallationToken,
|
|
54
|
+
fetchImpl,
|
|
55
|
+
now,
|
|
56
|
+
} = {}) {
|
|
57
|
+
if (!eventName) {
|
|
58
|
+
return { status: 400, body: { ok: false, error: "Missing X-GitHub-Event" } };
|
|
59
|
+
}
|
|
60
|
+
if (eventName === "ping") {
|
|
61
|
+
return { status: 200, body: { ok: true, handled: "ping" } };
|
|
62
|
+
}
|
|
63
|
+
if (eventName === "installation" || eventName === "installation_repositories") {
|
|
64
|
+
return {
|
|
65
|
+
status: 200,
|
|
66
|
+
body: {
|
|
67
|
+
ok: true,
|
|
68
|
+
handled: eventName,
|
|
69
|
+
action: payload?.action || "",
|
|
70
|
+
installationId: installationIdOf(payload),
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (eventName !== "pull_request") {
|
|
75
|
+
return { status: 200, body: { ok: true, ignored: eventName } };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const action = payload?.action || "";
|
|
79
|
+
if (!ANALYZE_ACTIONS.has(action)) {
|
|
80
|
+
return { status: 200, body: { ok: true, ignored: "pull_request", action } };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const pull = payload?.pull_request;
|
|
84
|
+
const repository = payload?.repository?.full_name;
|
|
85
|
+
const installationId = installationIdOf(payload);
|
|
86
|
+
const baseSha = pull?.base?.sha;
|
|
87
|
+
const headSha = pull?.head?.sha;
|
|
88
|
+
const pullNumber = pull?.number;
|
|
89
|
+
if (!pull || !repository || !installationId || !baseSha || !headSha || pullNumber == null) {
|
|
90
|
+
return { status: 422, body: { ok: false, error: "Pull request payload is missing repository, installation, or commits" } };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let owner;
|
|
94
|
+
let repo;
|
|
95
|
+
try {
|
|
96
|
+
assertGitHubRepo(repository);
|
|
97
|
+
[owner, repo] = repository.split("/");
|
|
98
|
+
} catch (error) {
|
|
99
|
+
return { status: 422, body: { ok: false, error: error.message } };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let call = request;
|
|
103
|
+
let token = "";
|
|
104
|
+
if (!call) {
|
|
105
|
+
try {
|
|
106
|
+
token = await getInstallationToken({
|
|
107
|
+
appId,
|
|
108
|
+
privateKey,
|
|
109
|
+
installationId,
|
|
110
|
+
fetchImpl,
|
|
111
|
+
now,
|
|
112
|
+
});
|
|
113
|
+
call = createGitHubRequest(token, fetchImpl);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
return { status: 502, body: { ok: false, error: sanitizeSecrets(error.message || String(error)) } };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let workspace;
|
|
120
|
+
try {
|
|
121
|
+
workspace = await prepareRepo({
|
|
122
|
+
token,
|
|
123
|
+
owner,
|
|
124
|
+
repo,
|
|
125
|
+
pullNumber,
|
|
126
|
+
baseSha,
|
|
127
|
+
headSha,
|
|
128
|
+
});
|
|
129
|
+
const analyzed = await analyze(workspace.dir, baseSha, headSha);
|
|
130
|
+
const summary = analyzed?.summary || "";
|
|
131
|
+
const check = await call(
|
|
132
|
+
"POST",
|
|
133
|
+
`/repos/${owner}/${repo}/check-runs`,
|
|
134
|
+
checkRunBody(headSha, summary, "success"),
|
|
135
|
+
);
|
|
136
|
+
const comment = await upsertArchitectureComment({
|
|
137
|
+
owner,
|
|
138
|
+
repo,
|
|
139
|
+
issueNumber: pullNumber,
|
|
140
|
+
body: summary,
|
|
141
|
+
request: call,
|
|
142
|
+
});
|
|
143
|
+
return {
|
|
144
|
+
status: 200,
|
|
145
|
+
body: {
|
|
146
|
+
ok: true,
|
|
147
|
+
handled: "pull_request",
|
|
148
|
+
action,
|
|
149
|
+
checkRunId: check?.id ?? null,
|
|
150
|
+
comment: comment?.action || null,
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
} catch (error) {
|
|
154
|
+
const message = sanitizeSecrets(error.message || String(error));
|
|
155
|
+
try {
|
|
156
|
+
await call(
|
|
157
|
+
"POST",
|
|
158
|
+
`/repos/${owner}/${repo}/check-runs`,
|
|
159
|
+
checkRunBody(headSha, message, "failure"),
|
|
160
|
+
);
|
|
161
|
+
} catch {
|
|
162
|
+
// The failed check is best-effort. The caller still sees the error.
|
|
163
|
+
}
|
|
164
|
+
return { status: 500, body: { ok: false, error: message } };
|
|
165
|
+
} finally {
|
|
166
|
+
if (workspace?.cleanup) {
|
|
167
|
+
await workspace.cleanup();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|