@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/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 moduleKeyForFile(file, moduleRoots) {
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
- return parts.length > 1 ? parts[0] : "root";
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
- languages.add(extensionMap[ext]);
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
- if (allDeps["typescript"]) metadata.languages.add("TypeScript");
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]) => ({ 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;
@@ -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
+ }