@chappibunny/repolens 1.15.5 → 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.
@@ -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
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * GitHub App manifest. Minimal permissions for architecture check runs
3
+ * and the existing pull request comment. `metadata: read` is required by
4
+ * GitHub on every app.
5
+ *
6
+ * `hook_attributes.url` is a placeholder. Replace it with the public URL of
7
+ * `repolens app` before registering. This repository does not register the app.
8
+ */
9
+ export const GITHUB_APP_WEBHOOK_PATH = "/api/github/webhooks";
10
+
11
+ export const GITHUB_APP_MANIFEST = {
12
+ name: "RepoLens",
13
+ description: "Posts an architecture diff as a check run and one sticky pull request comment.",
14
+ url: "https://github.com/CHAPIBUNNY/repolens",
15
+ hook_attributes: {
16
+ url: `https://example.com${GITHUB_APP_WEBHOOK_PATH}`,
17
+ active: true,
18
+ },
19
+ redirect_url: "https://github.com/CHAPIBUNNY/repolens",
20
+ public: false,
21
+ default_permissions: {
22
+ checks: "write",
23
+ pull_requests: "write",
24
+ contents: "read",
25
+ metadata: "read",
26
+ },
27
+ default_events: [
28
+ "installation",
29
+ "installation_repositories",
30
+ "pull_request",
31
+ ],
32
+ };
33
+
34
+ export function manifestDocument() {
35
+ return JSON.stringify(GITHUB_APP_MANIFEST, null, 2);
36
+ }
@@ -0,0 +1,67 @@
1
+ import { manifestDocument } from "./manifest.js";
2
+ import { readAppPrivateKey } from "./auth.js";
3
+ import { createGitHubAppServer, listenGitHubApp } from "./server.js";
4
+ import { GITHUB_APP_WEBHOOK_PATH } from "./manifest.js";
5
+
6
+ function positiveInteger(value, fallback) {
7
+ if (value == null || value === "") return fallback;
8
+ if (!/^[0-9]+$/.test(String(value))) return null;
9
+ const parsed = Number(value);
10
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) return null;
11
+ return parsed;
12
+ }
13
+
14
+ /**
15
+ * `repolens app` self-hosts the webhook.
16
+ * `--manifest` prints the manifest JSON and does not need credentials.
17
+ * Returns a process exit code. The server process stays up on success.
18
+ */
19
+ export async function runGitHubApp({
20
+ manifest = false,
21
+ port,
22
+ host,
23
+ webhookPath,
24
+ env = process.env,
25
+ stdout = console.log,
26
+ stderr = console.error,
27
+ } = {}) {
28
+ if (manifest) {
29
+ stdout(`${manifestDocument()}\n`);
30
+ return 0;
31
+ }
32
+
33
+ const appId = env.REPOLENS_APP_ID || "";
34
+ const webhookSecret = env.REPOLENS_WEBHOOK_SECRET || "";
35
+ let privateKey = "";
36
+ try {
37
+ privateKey = readAppPrivateKey(env);
38
+ } catch (error) {
39
+ stderr(`Could not read REPOLENS_APP_PRIVATE_KEY_PATH: ${error.message}`);
40
+ return 2;
41
+ }
42
+ if (!appId || !webhookSecret || !privateKey) {
43
+ stderr("repolens app needs REPOLENS_APP_ID, REPOLENS_WEBHOOK_SECRET, and REPOLENS_APP_PRIVATE_KEY or REPOLENS_APP_PRIVATE_KEY_PATH.");
44
+ stderr("Print the manifest with: repolens app --manifest");
45
+ return 2;
46
+ }
47
+
48
+ const chosenPort = positiveInteger(port ?? env.REPOLENS_APP_PORT, 8787);
49
+ if (chosenPort == null) {
50
+ stderr("--port must be an integer from 0 to 65535");
51
+ return 2;
52
+ }
53
+ const chosenHost = host || env.REPOLENS_APP_HOST || "127.0.0.1";
54
+ const chosenPath = webhookPath || env.REPOLENS_APP_WEBHOOK_PATH || GITHUB_APP_WEBHOOK_PATH;
55
+
56
+ const server = createGitHubAppServer({
57
+ webhookSecret,
58
+ webhookPath: chosenPath,
59
+ appId,
60
+ privateKey,
61
+ });
62
+ const bound = await listenGitHubApp(server, { port: chosenPort, host: chosenHost });
63
+ stderr(`RepoLens GitHub App listening on http://${chosenHost}:${bound}${chosenPath}`);
64
+ stderr("GitHub must reach that URL over HTTPS. The process stays running until you stop it.");
65
+ await new Promise(() => {});
66
+ return 0;
67
+ }
@@ -0,0 +1,120 @@
1
+ import http from "node:http";
2
+ import { GITHUB_APP_WEBHOOK_PATH } from "./manifest.js";
3
+ import { verifyHubSignature256 } from "./signature.js";
4
+ import { handleGitHubAppEvent } from "./handler.js";
5
+
6
+ const MAX_BODY_BYTES = 2_000_000;
7
+
8
+ function sendJson(res, status, body) {
9
+ const payload = JSON.stringify(body);
10
+ res.writeHead(status, {
11
+ "Content-Type": "application/json; charset=utf-8",
12
+ "Content-Length": Buffer.byteLength(payload),
13
+ });
14
+ res.end(payload);
15
+ }
16
+
17
+ function readBody(req) {
18
+ return new Promise((resolve, reject) => {
19
+ const chunks = [];
20
+ let size = 0;
21
+ req.on("data", (chunk) => {
22
+ size += chunk.length;
23
+ if (size > MAX_BODY_BYTES) {
24
+ reject(Object.assign(new Error("Payload too large"), { status: 413 }));
25
+ req.destroy();
26
+ return;
27
+ }
28
+ chunks.push(chunk);
29
+ });
30
+ req.on("end", () => resolve(Buffer.concat(chunks)));
31
+ req.on("error", reject);
32
+ });
33
+ }
34
+
35
+ /**
36
+ * HTTP server for the GitHub App webhook.
37
+ *
38
+ * GitHub gives a webhook about 10 seconds. Analysis can take longer, so the
39
+ * default response is 202 and the check run is posted afterwards. Tests pass
40
+ * `inline: true` and wait for the handler before the response is sent.
41
+ */
42
+ export function createGitHubAppServer(options) {
43
+ const webhookPath = options.webhookPath || GITHUB_APP_WEBHOOK_PATH;
44
+ const server = http.createServer(async (req, res) => {
45
+ const url = new URL(req.url || "/", "http://127.0.0.1");
46
+ if (req.method === "GET" && (url.pathname === "/health" || url.pathname === "/")) {
47
+ sendJson(res, 200, { ok: true, service: "repolens-github-app" });
48
+ return;
49
+ }
50
+ if (req.method !== "POST" || url.pathname !== webhookPath) {
51
+ sendJson(res, 404, { ok: false, error: "Not found" });
52
+ return;
53
+ }
54
+
55
+ let raw;
56
+ try {
57
+ raw = await readBody(req);
58
+ } catch (error) {
59
+ sendJson(res, error.status || 400, { ok: false, error: error.message || "Could not read the body" });
60
+ return;
61
+ }
62
+
63
+ if (!verifyHubSignature256(raw, req.headers["x-hub-signature-256"], options.webhookSecret)) {
64
+ sendJson(res, 401, { ok: false, error: "Invalid signature" });
65
+ return;
66
+ }
67
+
68
+ let payload;
69
+ try {
70
+ payload = JSON.parse(raw.toString("utf8") || "null");
71
+ } catch {
72
+ sendJson(res, 400, { ok: false, error: "Invalid JSON" });
73
+ return;
74
+ }
75
+
76
+ const eventName = req.headers["x-github-event"];
77
+ const run = () => handleGitHubAppEvent({
78
+ eventName: typeof eventName === "string" ? eventName : "",
79
+ payload,
80
+ appId: options.appId,
81
+ privateKey: options.privateKey,
82
+ prepareRepo: options.prepareRepo,
83
+ analyze: options.analyze,
84
+ request: options.request,
85
+ getInstallationToken: options.getInstallationToken,
86
+ fetchImpl: options.fetchImpl,
87
+ });
88
+
89
+ if (options.inline) {
90
+ try {
91
+ const result = await run();
92
+ sendJson(res, result.status, result.body);
93
+ } catch (error) {
94
+ sendJson(res, 500, { ok: false, error: error.message || "Webhook failed" });
95
+ }
96
+ return;
97
+ }
98
+
99
+ sendJson(res, 202, { ok: true, accepted: true });
100
+ run().then((result) => {
101
+ if (result?.body?.ok === false) {
102
+ console.error(`RepoLens GitHub App: ${result.body.error || "event failed"}`);
103
+ }
104
+ }).catch((error) => {
105
+ console.error(`RepoLens GitHub App: ${error.message || error}`);
106
+ });
107
+ });
108
+ return server;
109
+ }
110
+
111
+ export function listenGitHubApp(server, { port, host }) {
112
+ return new Promise((resolve, reject) => {
113
+ server.once("error", reject);
114
+ server.listen(port, host, () => {
115
+ server.removeListener("error", reject);
116
+ const address = server.address();
117
+ resolve(typeof address === "object" && address ? address.port : port);
118
+ });
119
+ });
120
+ }
@@ -0,0 +1,22 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+
3
+ /**
4
+ * Verify a GitHub `X-Hub-Signature-256` header against the raw request body.
5
+ * The comparison is constant-time when the header has the expected length.
6
+ */
7
+ export function verifyHubSignature256(rawBody, signatureHeader, secret) {
8
+ if (!secret || typeof secret !== "string") return false;
9
+ if (typeof signatureHeader !== "string" || !signatureHeader.startsWith("sha256=")) return false;
10
+ const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody));
11
+ const digest = createHmac("sha256", secret).update(body).digest("hex");
12
+ const expected = Buffer.from(`sha256=${digest}`, "utf8");
13
+ const actual = Buffer.from(signatureHeader, "utf8");
14
+ if (actual.length !== expected.length) return false;
15
+ return timingSafeEqual(actual, expected);
16
+ }
17
+
18
+ export function signHubBody(rawBody, secret) {
19
+ const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody));
20
+ const digest = createHmac("sha256", secret).update(body).digest("hex");
21
+ return `sha256=${digest}`;
22
+ }
package/src/help.js CHANGED
@@ -8,6 +8,7 @@ const EXIT_CODES = `Exit codes:
8
8
  badge scored 0 source files,
9
9
  comment is missing a base or head ref,
10
10
  publish --strict is missing publisher credentials,
11
+ app is missing its id, webhook secret, or private key,
11
12
  or feedback has no terminal`;
12
13
 
13
14
  const COMMAND_HELP = {
@@ -283,6 +284,42 @@ ${EXIT_CODES}
283
284
  Examples:
284
285
  repolens comment --base origin/main --head HEAD
285
286
  `,
287
+
288
+ app: `
289
+ RepoLens app — self-host the GitHub App webhook
290
+
291
+ Usage:
292
+ repolens app [--port <n>] [--host <address>] [--webhook-path <path>]
293
+ repolens app --manifest
294
+
295
+ Options:
296
+ --port <n> Listen port (default 8787, or REPOLENS_APP_PORT)
297
+ --host <address> Listen address (default 127.0.0.1, or REPOLENS_APP_HOST)
298
+ --webhook-path <path> Path GitHub posts to (default /api/github/webhooks)
299
+ --manifest Print the GitHub App manifest JSON and exit
300
+ --help, -h Show this help
301
+
302
+ Verifies X-Hub-Signature-256, acknowledges installation events, and on
303
+ pull_request opened, synchronize, reopened, and ready_for_review runs the
304
+ architecture diff. It posts a Check Run named RepoLens and updates the
305
+ sticky pull request comment. The check is informational: a completed diff
306
+ is success, and a failed analysis is a failed check.
307
+
308
+ \`--manifest\` needs no credentials. Running the server needs
309
+ REPOLENS_APP_ID, REPOLENS_WEBHOOK_SECRET, and either
310
+ REPOLENS_APP_PRIVATE_KEY or REPOLENS_APP_PRIVATE_KEY_PATH.
311
+ Replace hook_attributes.url in the manifest before you register the app.
312
+ This command does not register an app on GitHub.
313
+
314
+ GitHub allows about 10 seconds for the HTTP response. The server answers
315
+ 202 and posts the check after the analysis finishes.
316
+
317
+ ${EXIT_CODES}
318
+
319
+ Examples:
320
+ repolens app --manifest
321
+ repolens app --port 8787
322
+ `,
286
323
  };
287
324
 
288
325
  const GLOBAL_HELP = `
@@ -306,6 +343,7 @@ Commands:
306
343
  mcp Start the stdio MCP server (also available as repolens-mcp)
307
344
  badge Print a shields.io code-health badge (JSON, SVG, or markdown)
308
345
  comment Print or post an architecture diff for a pull request
346
+ app Self-host the GitHub App webhook (check run and PR comment)
309
347
  version Print the current RepoLens version
310
348
  help Show this help message
311
349
 
@@ -320,6 +358,10 @@ Options:
320
358
  --label <text> badge: shields.io label (default: code health)
321
359
  --base <ref> comment: base commit or ref
322
360
  --head <ref> comment: head commit or ref
361
+ --port <n> app: listen port (default 8787)
362
+ --host <address> app: listen address (default 127.0.0.1)
363
+ --webhook-path app: webhook path (default /api/github/webhooks)
364
+ --manifest app: print the GitHub App manifest JSON and exit
323
365
  --quick Scaffold without the wizard (also --non-interactive).
324
366
  Markdown only; globs follow the detected language.
325
367
  --strict publish: exit 2 when publisher credentials are missing
@@ -356,6 +398,8 @@ Examples:
356
398
  repolens badge
357
399
  repolens badge --format markdown --url https://example.github.io/repo/repolens-badge.json
358
400
  repolens comment --base origin/main --head HEAD
401
+ repolens app --manifest
402
+ repolens app --port 8787
359
403
  repolens --version
360
404
  `;
361
405