@chappibunny/repolens 1.15.1 → 1.15.2

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.
@@ -0,0 +1,480 @@
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 { classifyDiffLine, isSafeGitRef } from "../core/diff.js";
6
+ import { buildArchitectureDiffData } from "../renderers/renderDiff.js";
7
+ import { fetchWithRetry } from "../utils/retry.js";
8
+ import { assertGitHubRepo } from "../utils/github-repo.js";
9
+ import { sanitizeSecrets } from "../utils/secrets.js";
10
+ import { scoreRepository } from "../badge.js";
11
+ import {
12
+ ARCHITECTURE_COMMENT_MARKER,
13
+ COMMENT_FOOTER,
14
+ isArchitectureComment,
15
+ } from "./comment-constants.js";
16
+
17
+ const SHA = /^[0-9a-f]{40}$/i;
18
+
19
+ function emptyDiff() {
20
+ return { added: [], removed: [], modified: [] };
21
+ }
22
+
23
+ function parseNameStatus(output) {
24
+ const added = [];
25
+ const removed = [];
26
+ const modified = [];
27
+ for (const line of String(output || "").split("\n")) {
28
+ if (!line) continue;
29
+ const classified = classifyDiffLine(line);
30
+ if (!classified) continue;
31
+ if (classified.bucket === "added") added.push(classified.file);
32
+ else if (classified.bucket === "removed") removed.push(classified.file);
33
+ else modified.push(classified.file);
34
+ }
35
+ return { added, removed, modified };
36
+ }
37
+
38
+ function gitDiff(cwd, specArgs) {
39
+ try {
40
+ const output = execFileSync(
41
+ "git",
42
+ ["-c", "safe.directory=*", "diff", "--name-status", ...specArgs],
43
+ { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 10 * 1024 * 1024 },
44
+ );
45
+ return parseNameStatus(output);
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ export function diffBetweenRefs(cwd, base, head) {
52
+ if (!isSafeGitRef(base) || !isSafeGitRef(head)) return emptyDiff();
53
+ const triple = `${base}...${head}`;
54
+ if (isSafeGitRef(triple)) {
55
+ const ranged = gitDiff(cwd, [triple]);
56
+ if (ranged) return ranged;
57
+ }
58
+ return gitDiff(cwd, [base, head]) || emptyDiff();
59
+ }
60
+
61
+ export async function withGitWorktree(repo, ref, fn) {
62
+ if (!isSafeGitRef(ref)) {
63
+ throw new Error("git ref contains unsupported characters");
64
+ }
65
+ const parent = await fs.mkdtemp(path.join(os.tmpdir(), "repolens-wt-"));
66
+ const dir = path.join(parent, "tree");
67
+ try {
68
+ execFileSync(
69
+ "git",
70
+ ["-c", "safe.directory=*", "worktree", "add", "--detach", "--quiet", dir, ref],
71
+ { cwd: repo, stdio: ["ignore", "pipe", "pipe"] },
72
+ );
73
+ return await fn(dir);
74
+ } finally {
75
+ try {
76
+ execFileSync(
77
+ "git",
78
+ ["-c", "safe.directory=*", "worktree", "remove", "--force", dir],
79
+ { cwd: repo, stdio: "ignore" },
80
+ );
81
+ } catch {
82
+ // The temp directory is removed below when git cannot detach the worktree.
83
+ }
84
+ await fs.rm(parent, { recursive: true, force: true });
85
+ }
86
+ }
87
+
88
+ async function scoreRef(repo, ref) {
89
+ try {
90
+ const scored = await withGitWorktree(repo, ref, (dir) => scoreRepository(dir));
91
+ return { ok: true, ...scored };
92
+ } catch (error) {
93
+ return { ok: false, error: error.message || String(error) };
94
+ }
95
+ }
96
+
97
+ function edgeKey(edge) {
98
+ return `${edge.from} -> ${edge.to}`;
99
+ }
100
+
101
+ function fileKey(file) {
102
+ return String(file || "").replace(/\\/g, "/").replace(/\.(js|ts|jsx|tsx|mjs|cjs|py|pyw|go)$/, "");
103
+ }
104
+
105
+ export function diffGraphs(baseGraph, headGraph, changedFiles = []) {
106
+ const baseExt = new Set(baseGraph?.externalDeps || []);
107
+ const headExt = new Set(headGraph?.externalDeps || []);
108
+ const addedDeps = [...headExt].filter((item) => !baseExt.has(item)).sort();
109
+ const removedDeps = [...baseExt].filter((item) => !headExt.has(item)).sort();
110
+
111
+ const baseEdges = new Map((baseGraph?.edges || []).map((edge) => [edgeKey(edge), edge]));
112
+ const headEdges = new Map((headGraph?.edges || []).map((edge) => [edgeKey(edge), edge]));
113
+ const addedEdges = [...headEdges.keys()].filter((key) => !baseEdges.has(key)).sort();
114
+ const removedEdges = [...baseEdges.keys()].filter((key) => !headEdges.has(key)).sort();
115
+
116
+ const changed = new Set(changedFiles.map(fileKey));
117
+ const touchesChange = (key) => {
118
+ if (changed.size === 0) return true;
119
+ const [from, to] = key.split(" -> ");
120
+ return changed.has(from) || changed.has(to);
121
+ };
122
+
123
+ return {
124
+ addedDeps,
125
+ removedDeps,
126
+ addedEdges: addedEdges.filter(touchesChange),
127
+ removedEdges: removedEdges.filter(touchesChange),
128
+ addedEdgeCount: addedEdges.length,
129
+ removedEdgeCount: removedEdges.length,
130
+ };
131
+ }
132
+
133
+ export function newHotspots(baseHealth, headHealth) {
134
+ const previous = new Set((baseHealth?.hotspots || []).map((item) => item.file));
135
+ return (headHealth?.hotspots || []).filter((item) => !previous.has(item.file));
136
+ }
137
+
138
+ function shortRef(ref) {
139
+ return SHA.test(ref) ? ref.slice(0, 7) : ref;
140
+ }
141
+
142
+ function bulletFiles(lines, files, verb, limit = 20) {
143
+ const shown = files.slice(0, limit);
144
+ for (const file of shown) {
145
+ lines.push(`- \`${String(file).replace(/`/g, "")}\` (${verb})`);
146
+ }
147
+ if (files.length > limit) {
148
+ lines.push(`- ${files.length - limit} more ${verb}`);
149
+ }
150
+ }
151
+
152
+ function scoreCell(health) {
153
+ if (!health?.ok) return "unavailable";
154
+ const score = health.health?.stats?.avgScore;
155
+ return score == null ? "n/a" : String(score);
156
+ }
157
+
158
+ function countCell(health, field) {
159
+ if (!health?.ok) return "unavailable";
160
+ if (field === "files") return String(health.health?.stats?.totalFiles ?? 0);
161
+ if (field === "hotspots") return String(health.health?.hotspots?.length ?? 0);
162
+ return "n/a";
163
+ }
164
+
165
+ export function renderArchitectureComment(report) {
166
+ const diff = report.diffData;
167
+ const lines = [
168
+ ARCHITECTURE_COMMENT_MARKER,
169
+ "## RepoLens — Architecture diff",
170
+ "",
171
+ `Compared \`${shortRef(report.base)}\`...\`${shortRef(report.head)}\`.`,
172
+ "",
173
+ "### Files",
174
+ "",
175
+ `Added: ${diff.added.length}`,
176
+ `Removed: ${diff.removed.length}`,
177
+ `Modified: ${diff.modified.length}`,
178
+ "",
179
+ ];
180
+
181
+ bulletFiles(lines, diff.added, "added");
182
+ bulletFiles(lines, diff.removed, "removed");
183
+ bulletFiles(lines, diff.modified, "modified");
184
+ if (!diff.added.length && !diff.removed.length && !diff.modified.length) {
185
+ lines.push("No architecture-relevant file changes detected.");
186
+ }
187
+ lines.push("");
188
+
189
+ lines.push("### Modules", "");
190
+ if (diff.impactedModules.length) {
191
+ for (const module of diff.impactedModules.slice(0, 30)) {
192
+ lines.push(`- \`${String(module).replace(/`/g, "")}\``);
193
+ }
194
+ if (diff.impactedModules.length > 30) {
195
+ lines.push(`- ${diff.impactedModules.length - 30} more modules`);
196
+ }
197
+ } else {
198
+ lines.push("No modules changed.");
199
+ }
200
+ lines.push("");
201
+
202
+ const deps = report.deps;
203
+ lines.push("### Dependencies", "");
204
+ if (!report.baseScore?.ok || !report.headScore?.ok) {
205
+ lines.push("Dependency delta needs both commits checked out. Fetch the base and head SHAs (`actions/checkout` with `fetch-depth: 0`).");
206
+ } else {
207
+ lines.push(`Added: ${deps.addedDeps.length ? deps.addedDeps.slice(0, 20).map((item) => `\`${item}\``).join(", ") : "none"}`);
208
+ lines.push(`Removed: ${deps.removedDeps.length ? deps.removedDeps.slice(0, 20).map((item) => `\`${item}\``).join(", ") : "none"}`);
209
+ lines.push(`Internal import edges added: ${deps.addedEdgeCount}`);
210
+ lines.push(`Internal import edges removed: ${deps.removedEdgeCount}`);
211
+ const edgeLines = [
212
+ ...deps.addedEdges.slice(0, 12).map((item) => `- \`${item}\` (added)`),
213
+ ...deps.removedEdges.slice(0, 12).map((item) => `- \`${item}\` (removed)`),
214
+ ];
215
+ if (edgeLines.length) {
216
+ lines.push("");
217
+ lines.push(...edgeLines);
218
+ }
219
+ }
220
+ lines.push("");
221
+
222
+ const baseAvg = report.baseScore?.ok ? report.baseScore.health?.stats?.avgScore : null;
223
+ const headAvg = report.headScore?.ok ? report.headScore.health?.stats?.avgScore : null;
224
+ let delta = "unavailable";
225
+ if (baseAvg != null && headAvg != null) {
226
+ const value = headAvg - baseAvg;
227
+ delta = `${value > 0 ? "+" : ""}${value}`;
228
+ }
229
+ lines.push("### Code health", "");
230
+ lines.push("| | Base | Head |");
231
+ lines.push("| --- | --- | --- |");
232
+ lines.push(`| Score | ${scoreCell(report.baseScore)} | ${scoreCell(report.headScore)} |`);
233
+ lines.push(`| Delta | ${delta} | |`);
234
+ lines.push(`| Files scored | ${countCell(report.baseScore, "files")} | ${countCell(report.headScore, "files")} |`);
235
+ lines.push(`| Hotspots | ${countCell(report.baseScore, "hotspots")} | ${countCell(report.headScore, "hotspots")} |`);
236
+ if (report.baseScore?.capped || report.headScore?.capped) {
237
+ lines.push("");
238
+ lines.push("Scoring stopped at 2500 source files on at least one side.");
239
+ }
240
+ if (report.baseScore?.error || report.headScore?.error) {
241
+ lines.push("");
242
+ lines.push("Code health delta needs both commits. Fetch the base ref (`actions/checkout` with `fetch-depth: 0`).");
243
+ }
244
+ lines.push("");
245
+
246
+ lines.push("### New hotspots", "");
247
+ const fresh = report.hotspots || [];
248
+ if (!report.baseScore?.ok || !report.headScore?.ok) {
249
+ lines.push("New hotspots need a score for both commits.");
250
+ } else if (!fresh.length) {
251
+ lines.push("No new hotspots (files newly under 60).");
252
+ } else {
253
+ for (const item of fresh.slice(0, 20)) {
254
+ lines.push(`- \`${String(item.file).replace(/`/g, "")}\` — ${item.score} (${item.grade})`);
255
+ }
256
+ }
257
+ lines.push("");
258
+ lines.push(COMMENT_FOOTER);
259
+ const body = lines.join("\n");
260
+ if (body.length <= 60000) return body;
261
+ return `${body.slice(0, 59000)}\n\n${COMMENT_FOOTER}`;
262
+ }
263
+
264
+ export async function compareArchitecture(repo, base, head) {
265
+ const raw = diffBetweenRefs(repo, base, head);
266
+ const diffData = buildArchitectureDiffData(raw);
267
+ const changed = [...diffData.added, ...diffData.removed, ...diffData.modified];
268
+ // Worktrees on one repository lock .git, so the two trees are scored in order.
269
+ const baseScore = await scoreRef(repo, base);
270
+ const headScore = await scoreRef(repo, head);
271
+ const deps = diffGraphs(baseScore.depGraph, headScore.depGraph, changed);
272
+ const hotspots = newHotspots(baseScore.health, headScore.health);
273
+ return {
274
+ base,
275
+ head,
276
+ diffData,
277
+ baseScore,
278
+ headScore,
279
+ deps,
280
+ hotspots,
281
+ };
282
+ }
283
+
284
+ export function isForkPullRequest(event) {
285
+ const pull = event?.pull_request;
286
+ if (!pull) return false;
287
+ const headRepo = pull.head?.repo;
288
+ const baseRepo = pull.base?.repo;
289
+ if (!headRepo || headRepo.fork === true) return true;
290
+ if (headRepo.full_name && baseRepo?.full_name && headRepo.full_name !== baseRepo.full_name) {
291
+ return true;
292
+ }
293
+ return false;
294
+ }
295
+
296
+ export async function writeJobSummary(markdown, file) {
297
+ if (!file) {
298
+ console.error(markdown);
299
+ return false;
300
+ }
301
+ const text = markdown.endsWith("\n") ? markdown : `${markdown}\n`;
302
+ await fs.appendFile(file, text);
303
+ return true;
304
+ }
305
+
306
+ async function defaultGitHubRequest(method, urlPath, body, env) {
307
+ const token = env.GITHUB_TOKEN;
308
+ if (!token) throw Object.assign(new Error("Missing GITHUB_TOKEN"), { status: 401 });
309
+ const response = await fetchWithRetry(`https://api.github.com${urlPath}`, {
310
+ method,
311
+ headers: {
312
+ Authorization: `Bearer ${token}`,
313
+ Accept: "application/vnd.github+json",
314
+ "Content-Type": "application/json",
315
+ "User-Agent": "RepoLens",
316
+ "X-GitHub-Api-Version": "2022-11-28",
317
+ },
318
+ body: body ? JSON.stringify(body) : undefined,
319
+ }, {
320
+ retries: 2,
321
+ baseDelayMs: 400,
322
+ maxDelayMs: 2000,
323
+ label: `GitHub ${method}`,
324
+ });
325
+ const text = await response.text();
326
+ let json = null;
327
+ if (text) {
328
+ try {
329
+ json = JSON.parse(text);
330
+ } catch {
331
+ json = null;
332
+ }
333
+ }
334
+ if (!response.ok) {
335
+ const clipped = sanitizeSecrets(String(text || "").replace(/\s+/g, " ").slice(0, 300));
336
+ throw Object.assign(new Error(`GitHub API error ${response.status}: ${clipped}`), { status: response.status });
337
+ }
338
+ return json;
339
+ }
340
+
341
+ export async function upsertArchitectureComment({ owner, repo, issueNumber, body, request }) {
342
+ assertGitHubRepo(`${owner}/${repo}`);
343
+ const comments = [];
344
+ for (let page = 1; page <= 10; page += 1) {
345
+ const batch = await request(
346
+ "GET",
347
+ `/repos/${owner}/${repo}/issues/${issueNumber}/comments?per_page=100&page=${page}`,
348
+ );
349
+ if (!Array.isArray(batch) || batch.length === 0) break;
350
+ comments.push(...batch);
351
+ if (batch.length < 100) break;
352
+ }
353
+ const existing = comments.find((comment) => isArchitectureComment(comment?.body));
354
+ if (existing?.id != null) {
355
+ await request("PATCH", `/repos/${owner}/${repo}/issues/comments/${existing.id}`, { body });
356
+ return { action: "updated", id: existing.id };
357
+ }
358
+ const created = await request(
359
+ "POST",
360
+ `/repos/${owner}/${repo}/issues/${issueNumber}/comments`,
361
+ { body },
362
+ );
363
+ return { action: "created", id: created?.id };
364
+ }
365
+
366
+ async function loadEvent(env, event) {
367
+ if (event !== undefined) return event;
368
+ if (!env.GITHUB_EVENT_PATH) return null;
369
+ try {
370
+ const raw = await fs.readFile(env.GITHUB_EVENT_PATH, "utf8");
371
+ return JSON.parse(raw);
372
+ } catch (error) {
373
+ throw new Error(`Could not read GITHUB_EVENT_PATH: ${error.message}`);
374
+ }
375
+ }
376
+
377
+ function forkNote() {
378
+ return [
379
+ "Fork pull request: RepoLens skipped the pull request comment and wrote this job summary instead.",
380
+ "`GITHUB_TOKEN` on a fork pull request cannot write comments (`pull-requests: write` is downgraded to read).",
381
+ "",
382
+ ].join("\n");
383
+ }
384
+
385
+ /**
386
+ * Compute the architecture diff and post one sticky comment.
387
+ * Fork pull requests skip the API and append the same body to the job summary.
388
+ * Returns a process exit code.
389
+ */
390
+ export async function runPullRequestComment({
391
+ repo = process.cwd(),
392
+ base,
393
+ head,
394
+ env = process.env,
395
+ event,
396
+ request,
397
+ summaryFile,
398
+ stdout = console.log,
399
+ } = {}) {
400
+ process.env.REPOLENS_LOG_STDERR = "1";
401
+ let payload;
402
+ try {
403
+ payload = await loadEvent(env, event);
404
+ } catch (error) {
405
+ console.error(error.message);
406
+ return 1;
407
+ }
408
+
409
+ const pull = payload?.pull_request;
410
+ const baseRef = base || pull?.base?.sha;
411
+ const headRef = head || pull?.head?.sha;
412
+ if (!baseRef || !headRef) {
413
+ const namedEvent = env.GITHUB_EVENT_NAME;
414
+ if (!base && !head && namedEvent && namedEvent !== "pull_request") {
415
+ console.error(`Architecture comment runs on pull_request (this event is ${namedEvent}). Nothing was posted.`);
416
+ return 0;
417
+ }
418
+ console.error("Architecture comment needs a base and head commit. On pull_request these come from the event. Locally pass --base and --head.");
419
+ return 2;
420
+ }
421
+ if (!isSafeGitRef(baseRef) || !isSafeGitRef(headRef)) {
422
+ console.error("base and head refs contain unsupported characters.");
423
+ return 2;
424
+ }
425
+
426
+ let report;
427
+ try {
428
+ report = await compareArchitecture(repo, baseRef, headRef);
429
+ } catch (error) {
430
+ console.error(error.message || String(error));
431
+ return 1;
432
+ }
433
+ const body = renderArchitectureComment(report);
434
+ const fork = isForkPullRequest(payload);
435
+
436
+ if (fork) {
437
+ const summary = `${forkNote()}\n${body}`;
438
+ await writeJobSummary(summary, summaryFile || env.GITHUB_STEP_SUMMARY);
439
+ console.error("Fork pull request: skipped the comment and wrote the job summary.");
440
+ return 0;
441
+ }
442
+
443
+ const token = env.GITHUB_TOKEN;
444
+ const repository = env.GITHUB_REPOSITORY;
445
+ const issueNumber = pull?.number;
446
+ const shouldPost = Boolean(request || (token && repository && issueNumber));
447
+ if (!shouldPost) {
448
+ stdout(body);
449
+ return 0;
450
+ }
451
+
452
+ const call = request || ((method, urlPath, payloadBody) => defaultGitHubRequest(method, urlPath, payloadBody, env));
453
+ try {
454
+ assertGitHubRepo(repository);
455
+ const [owner, name] = repository.split("/");
456
+ const result = await upsertArchitectureComment({
457
+ owner,
458
+ repo: name,
459
+ issueNumber,
460
+ body,
461
+ request: call,
462
+ });
463
+ console.error(`Architecture comment ${result.action}${result.id != null ? ` (${result.id})` : ""}.`);
464
+ return 0;
465
+ } catch (error) {
466
+ if (error.status === 403 || error.status === 401) {
467
+ const summary = [
468
+ "RepoLens could not post the pull request comment (GitHub returned " + error.status + ").",
469
+ "The job summary has the architecture diff instead.",
470
+ "",
471
+ body,
472
+ ].join("\n");
473
+ await writeJobSummary(summary, summaryFile || env.GITHUB_STEP_SUMMARY);
474
+ console.error(error.message);
475
+ return 0;
476
+ }
477
+ console.error(error.message || String(error));
478
+ return 1;
479
+ }
480
+ }
@@ -0,0 +1,15 @@
1
+ export const ARCHITECTURE_COMMENT_MARKER = "<!-- repolens:architecture-diff -->";
2
+
3
+ export const REPO_URL = "https://github.com/CHAPIBUNNY/repolens";
4
+ export const GALLERY_URL = "https://chapibunny.github.io/repolens/";
5
+
6
+ export const COMMENT_FOOTER =
7
+ `Generated by [RepoLens](${REPO_URL}) · [Gallery](${GALLERY_URL})`;
8
+
9
+ export function isArchitectureComment(body) {
10
+ return typeof body === "string" && (
11
+ body.includes(ARCHITECTURE_COMMENT_MARKER)
12
+ || body.includes("## RepoLens — Architecture Summary")
13
+ || body.includes("## RepoLens — Architecture diff")
14
+ );
15
+ }
@@ -2,6 +2,11 @@ import { log } from "../utils/logger.js";
2
2
  import { fetchWithRetry } from "../utils/retry.js";
3
3
  import { assertGitHubRepo } from "../utils/github-repo.js";
4
4
  import { sanitizeSecrets } from "../utils/secrets.js";
5
+ import {
6
+ ARCHITECTURE_COMMENT_MARKER,
7
+ COMMENT_FOOTER,
8
+ isArchitectureComment,
9
+ } from "./comment-constants.js";
5
10
 
6
11
  function safe(text) {
7
12
  return String(text || "").trim();
@@ -9,6 +14,7 @@ function safe(text) {
9
14
 
10
15
  function buildBody(diff) {
11
16
  const lines = [
17
+ ARCHITECTURE_COMMENT_MARKER,
12
18
  "## RepoLens — Architecture Summary",
13
19
  "",
14
20
  `**Added files:** ${diff.added.length}`,
@@ -44,7 +50,7 @@ function buildBody(diff) {
44
50
  lines.push("");
45
51
  }
46
52
 
47
- lines.push("_Generated by RepoLens_");
53
+ lines.push(COMMENT_FOOTER);
48
54
  return lines.join("\n");
49
55
  }
50
56
 
@@ -86,9 +92,7 @@ async function findExistingRepoLensComment(owner, repo, issueNumber) {
86
92
  `/repos/${owner}/${repo}/issues/${issueNumber}/comments`
87
93
  );
88
94
 
89
- return comments.find((c) =>
90
- typeof c.body === "string" && c.body.includes("## RepoLens — Architecture Summary")
91
- );
95
+ return comments.find((c) => isArchitectureComment(c.body));
92
96
  }
93
97
 
94
98
  export async function upsertPrComment(diff) {
@@ -145,4 +149,4 @@ export async function upsertPrComment(diff) {
145
149
  );
146
150
 
147
151
  log(`Created PR comment ${created.id}`);
148
- }// update existing PR comment test
152
+ }
@@ -219,13 +219,14 @@ export async function generateDocumentSet(scanResult, config, diffData = null, p
219
219
  // Run afterRender hook
220
220
  if (pluginManager) {
221
221
  const rendered = await pluginManager.runHook("afterRender", documents);
222
- return { documents: rendered, artifacts, config };
222
+ return { documents: rendered, artifacts, config, codeHealth: codeHealthResult };
223
223
  }
224
224
 
225
225
  return {
226
226
  documents,
227
227
  artifacts,
228
- config
228
+ config,
229
+ codeHealth: codeHealthResult,
229
230
  };
230
231
  }
231
232