@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.
- package/CHANGELOG.md +35 -0
- package/README.md +17 -14
- package/app.yml +23 -0
- package/github-app-manifest.json +22 -0
- package/llms-full.txt +1 -0
- package/llms-install.md +1 -0
- package/llms.txt +1 -0
- package/package.json +8 -6
- package/src/analyzers/context-builder.js +11 -3
- package/src/analyzers/dependency-graph.js +6 -6
- package/src/cli-args.js +4 -0
- package/src/cli.js +11 -0
- package/src/core/scan.js +62 -20
- 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/renderDiff.js +5 -3
- package/src/renderers/renderMap.js +7 -5
- package/src/utils/route-params.js +50 -0
- package/src/utils/yaml.js +11 -6
|
@@ -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
|
|