techbro 0.0.0-stage → 0.5.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/README.md +453 -2
- package/assets/baseline-rules.yml +60 -0
- package/assets/caller-workflow.yml +26 -0
- package/assets/review-action.yml +27 -0
- package/assets/review-config.json +4 -0
- package/assets/review-workflow.yml +443 -0
- package/assets/review.mjs +721 -0
- package/bin/techbro.js +9 -0
- package/package.json +23 -4
- package/src/cli.js +690 -0
- package/src/cluster.js +134 -0
- package/src/config.js +35 -0
- package/src/doctor.js +125 -0
- package/src/github.js +152 -0
- package/src/init.js +167 -0
- package/src/kube-project.js +468 -0
- package/src/kube.js +317 -0
- package/src/managed.js +63 -0
- package/src/project.js +202 -0
- package/src/review.js +165 -0
- package/src/secret.js +80 -0
- package/src/setup.js +91 -0
- package/src/team-workflow.js +106 -0
- package/src/team.js +145 -0
- package/src/templates.js +44 -0
- package/src/upgrade.js +94 -0
- package/templates/_default/.agents/skills/README.md +36 -0
- package/templates/_default/.agents/skills/database-migrations/SKILL.md +72 -0
- package/templates/_default/.agents/skills/frontend-feature/SKILL.md +65 -0
- package/templates/_default/.agents/skills/release-safe-change/SKILL.md +75 -0
- package/templates/_default/.agents/skills/rust-backend-feature/SKILL.md +68 -0
- package/templates/_default/.editorconfig +15 -0
- package/templates/_default/.env.example +9 -0
- package/templates/_default/.github/workflows/ci.yml +60 -0
- package/templates/_default/.nvmrc +1 -0
- package/templates/_default/Cargo.lock +1834 -0
- package/templates/_default/Cargo.toml +3 -0
- package/templates/_default/README.md +257 -0
- package/templates/_default/TEMPLATE.md +29 -0
- package/templates/_default/_dockerignore +8 -0
- package/templates/_default/_gitignore +8 -0
- package/templates/_default/apps/backend/Cargo.toml +22 -0
- package/templates/_default/apps/backend/Dockerfile +34 -0
- package/templates/_default/apps/backend/README.md +13 -0
- package/templates/_default/apps/backend/src/config.rs +111 -0
- package/templates/_default/apps/backend/src/controllers/health.rs +36 -0
- package/templates/_default/apps/backend/src/controllers/items.rs +79 -0
- package/templates/_default/apps/backend/src/controllers/mod.rs +2 -0
- package/templates/_default/apps/backend/src/error.rs +115 -0
- package/templates/_default/apps/backend/src/lib.rs +7 -0
- package/templates/_default/apps/backend/src/main.rs +71 -0
- package/templates/_default/apps/backend/src/models/item.rs +94 -0
- package/templates/_default/apps/backend/src/models/mod.rs +1 -0
- package/templates/_default/apps/backend/src/routes.rs +94 -0
- package/templates/_default/apps/backend/src/state.rs +12 -0
- package/templates/_default/apps/backend/src/views/items.rs +18 -0
- package/templates/_default/apps/backend/src/views/mod.rs +1 -0
- package/templates/_default/apps/backend/tests/api.rs +234 -0
- package/templates/_default/apps/frontend/.env.example +2 -0
- package/templates/_default/apps/frontend/.prettierignore +3 -0
- package/templates/_default/apps/frontend/.prettierrc +1 -0
- package/templates/_default/apps/frontend/Dockerfile +15 -0
- package/templates/_default/apps/frontend/README.md +39 -0
- package/templates/_default/apps/frontend/_dockerignore +3 -0
- package/templates/_default/apps/frontend/_gitignore +24 -0
- package/templates/_default/apps/frontend/components.json +25 -0
- package/templates/_default/apps/frontend/eslint.config.js +37 -0
- package/templates/_default/apps/frontend/index.html +13 -0
- package/templates/_default/apps/frontend/jsconfig.json +7 -0
- package/templates/_default/apps/frontend/nginx.conf +30 -0
- package/templates/_default/apps/frontend/package-lock.json +4338 -0
- package/templates/_default/apps/frontend/package.json +49 -0
- package/templates/_default/apps/frontend/public/favicon.svg +1 -0
- package/templates/_default/apps/frontend/src/App.jsx +11 -0
- package/templates/_default/apps/frontend/src/App.test.jsx +40 -0
- package/templates/_default/apps/frontend/src/api/client.js +50 -0
- package/templates/_default/apps/frontend/src/api/client.test.js +70 -0
- package/templates/_default/apps/frontend/src/api/items.js +26 -0
- package/templates/_default/apps/frontend/src/api/system.js +11 -0
- package/templates/_default/apps/frontend/src/components/ItemList.jsx +20 -0
- package/templates/_default/apps/frontend/src/components/ItemList.test.jsx +18 -0
- package/templates/_default/apps/frontend/src/components/Layout.jsx +35 -0
- package/templates/_default/apps/frontend/src/components/StatusBadge.jsx +22 -0
- package/templates/_default/apps/frontend/src/components/StepList.jsx +19 -0
- package/templates/_default/apps/frontend/src/components/ui/button.jsx +52 -0
- package/templates/_default/apps/frontend/src/components/ui/card.jsx +83 -0
- package/templates/_default/apps/frontend/src/components/ui/input.jsx +19 -0
- package/templates/_default/apps/frontend/src/hooks/useBackendStatus.js +33 -0
- package/templates/_default/apps/frontend/src/hooks/useItems.js +47 -0
- package/templates/_default/apps/frontend/src/index.css +130 -0
- package/templates/_default/apps/frontend/src/lib/config.js +2 -0
- package/templates/_default/apps/frontend/src/lib/utils.js +6 -0
- package/templates/_default/apps/frontend/src/main.jsx +11 -0
- package/templates/_default/apps/frontend/src/models/items.js +24 -0
- package/templates/_default/apps/frontend/src/models/items.test.js +24 -0
- package/templates/_default/apps/frontend/src/pages/ItemsPage.jsx +41 -0
- package/templates/_default/apps/frontend/src/pages/WelcomePage.jsx +156 -0
- package/templates/_default/apps/frontend/src/pages/WelcomePage.test.jsx +55 -0
- package/templates/_default/apps/frontend/src/routes.jsx +17 -0
- package/templates/_default/apps/frontend/src/shadcn.css +655 -0
- package/templates/_default/apps/frontend/src/test/setup.js +1 -0
- package/templates/_default/apps/frontend/vite.config.js +25 -0
- package/templates/_default/database/Dockerfile +9 -0
- package/templates/_default/database/README.md +15 -0
- package/templates/_default/database/migrations/postgres/20261008120000_create_items.sql +5 -0
- package/templates/_default/database/migrations/sqlite/20261008120000_create_items.sql +5 -0
- package/templates/_default/docker-compose.prod.yml +53 -0
- package/templates/_default/docker-compose.yml +69 -0
- package/templates/_default/docs/api/README.md +41 -0
- package/templates/_default/docs/api/openapi.yaml +152 -0
- package/templates/_default/docs/decisions/0001-adopt-engineering-convention.md +13 -0
- package/templates/_default/docs/decisions/README.md +6 -0
- package/templates/_default/docs/deployment.md +46 -0
- package/templates/_default/docs/docker.md +63 -0
- package/templates/_default/docs/getting-started.md +214 -0
- package/templates/_default/package.json +15 -0
- package/templates/_default/rust-toolchain.toml +3 -0
- package/templates/_default/scripts/check +10 -0
- package/templates/_default/scripts/db-migrate +25 -0
- package/templates/_default/scripts/install-hooks +5 -0
- package/templates/_default/scripts/pre-push +3 -0
- package/templates/_default/scripts/setup +26 -0
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
|
|
6
|
+
const SHA_RE = /^[0-9a-f]{40}$/;
|
|
7
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
8
|
+
const NAME_RE = /^[A-Za-z0-9._-]{1,100}$/;
|
|
9
|
+
const SAFE_PATH_RE = /^[A-Za-z0-9._@+\-/]{1,400}$/;
|
|
10
|
+
const WRITE_PERMS = ["write", "maintain", "admin"];
|
|
11
|
+
const MAX_UNPACKED = 2 * 1024 * 1024 * 1024;
|
|
12
|
+
const MAX_LCOV = 5 * 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
const FAIL_ON = ["ERROR", "WARNING", "INFO", "none"];
|
|
15
|
+
const SONAR_MODES = ["optional", "required", "off"];
|
|
16
|
+
const RUST_VERSION_RE = /^(stable|beta|\d{1,2}\.\d{1,3}(\.\d{1,3})?)$/;
|
|
17
|
+
const SEV_RANK = { INFO: 1, WARNING: 2, ERROR: 3, UNKNOWN: 3 };
|
|
18
|
+
|
|
19
|
+
const REMEDIATION = {
|
|
20
|
+
"techbro.js.eval": "Remove eval; parse data with JSON.parse or use explicit functions instead of executing strings.",
|
|
21
|
+
"techbro.js.new-function": "Avoid the Function constructor; use a normal function or a safe lookup table.",
|
|
22
|
+
"techbro.py.eval": "Remove eval; use ast.literal_eval or json.loads for data, explicit dispatch for behavior.",
|
|
23
|
+
"techbro.py.subprocess-shell": "Pass an argument list and omit shell=True; never interpolate untrusted input into a shell string.",
|
|
24
|
+
"techbro.any.private-key": "Remove the key from the repository, rotate it, and load it from a secret store.",
|
|
25
|
+
"techbro.any.aws-access-key": "Remove the credential, rotate it in AWS, and load it from a secret store.",
|
|
26
|
+
"techbro.any.github-token": "Remove the token, revoke it in GitHub, and use a secret store or workflow token.",
|
|
27
|
+
"techbro.any.slack-token": "Remove the token, revoke it in Slack, and use a secret store.",
|
|
28
|
+
"techbro.rust.shell-command": "Invoke programs directly with Command::new(program).args([...]) instead of sh -c with built strings.",
|
|
29
|
+
"techbro.rust.unsafe-block": "Prefer safe abstractions; if unsafe is required, keep it minimal and document the invariants.",
|
|
30
|
+
"techbro.rust.unwrap": "Handle the error or None case explicitly (match, ?, or expect with context) instead of unwrap.",
|
|
31
|
+
};
|
|
32
|
+
const GENERIC_REMEDIATION = "Review this finding against team standards, fix the underlying issue, or ask a reviewer about the rule.";
|
|
33
|
+
export const remediationFor = (rule) => (Object.hasOwn(REMEDIATION, rule) ? REMEDIATION[rule] : GENERIC_REMEDIATION);
|
|
34
|
+
|
|
35
|
+
const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
36
|
+
const fail = (m) => {
|
|
37
|
+
throw new Error(m);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function safeRelPath(p) {
|
|
41
|
+
if (typeof p !== "string") return null;
|
|
42
|
+
const s = p.replace(/^(\.\/)+/, "");
|
|
43
|
+
if (!SAFE_PATH_RE.test(s) || s.startsWith("/") || s.split("/").some((x) => x === ".." || x === "")) return null;
|
|
44
|
+
return s;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function workdirOf(v) {
|
|
48
|
+
if (v === undefined || v === "" || v === ".") return ".";
|
|
49
|
+
const s = safeRelPath(v);
|
|
50
|
+
if (!s) fail(`invalid workdir/path in config: ${JSON.stringify(v)}`);
|
|
51
|
+
return s;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const oneLine = (v, what) => {
|
|
55
|
+
if (typeof v !== "string" || v.length > 500 || /[\r\n\0]/.test(v)) fail(`invalid ${what} in config (single line, max 500 chars)`);
|
|
56
|
+
return v;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export function parseGithubUrl(url) {
|
|
60
|
+
const m = /^https:\/\/github\.com\/([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/.exec(String(url ?? "").trim());
|
|
61
|
+
if (!m) fail(`not a GitHub repository URL: ${JSON.stringify(String(url).slice(0, 100))}`);
|
|
62
|
+
return { owner: m[1], repo: m[2] };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function normalizeTest(t) {
|
|
66
|
+
if (t === undefined || t === null) return null;
|
|
67
|
+
if (!isObj(t)) fail("config test must be an object");
|
|
68
|
+
if (!t.command) return null;
|
|
69
|
+
const runtime = t.runtime ?? "system";
|
|
70
|
+
if (!["node", "rust-node", "system"].includes(runtime)) fail(`unsupported test runtime "${runtime}" (node|rust-node|system)`);
|
|
71
|
+
const nodeVersion = t.nodeVersion ?? "20";
|
|
72
|
+
if (!/^(\d{1,2}(\.\d+){0,2}|lts\/\*)$/.test(String(nodeVersion))) fail("invalid nodeVersion in config");
|
|
73
|
+
return {
|
|
74
|
+
runtime,
|
|
75
|
+
nodeVersion: String(nodeVersion),
|
|
76
|
+
workdir: workdirOf(t.workdir),
|
|
77
|
+
install: t.install ? oneLine(t.install, "install") : "",
|
|
78
|
+
command: oneLine(t.command, "command"),
|
|
79
|
+
coverage: t.coverage ? workdirOf(t.coverage) : "",
|
|
80
|
+
rustVersion: runtime === "rust-node" ? rustVersionOf(t.rustVersion) : "",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function rustVersionOf(v) {
|
|
85
|
+
if (v === undefined || v === "") return "";
|
|
86
|
+
const s = v;
|
|
87
|
+
if (typeof s !== "string" || !RUST_VERSION_RE.test(s)) fail("invalid rustVersion in config (stable|beta|X.Y[.Z])");
|
|
88
|
+
return s;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function checkKeys(o, allowed, what) {
|
|
92
|
+
for (const k of Object.keys(o)) if (!allowed.includes(k)) fail(`unknown key "${k}" in ${what} config`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function normalizeSecurity(s) {
|
|
96
|
+
if (s === undefined) return { failOn: "ERROR" };
|
|
97
|
+
if (!isObj(s)) fail("config security must be an object");
|
|
98
|
+
checkKeys(s, ["failOn"], "security");
|
|
99
|
+
const failOn = s.failOn ?? "ERROR";
|
|
100
|
+
if (!FAIL_ON.includes(failOn)) fail(`invalid security.failOn ${JSON.stringify(failOn)} (ERROR|WARNING|INFO|none)`);
|
|
101
|
+
return { failOn };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const DUP_MODES = ["required", "off"];
|
|
105
|
+
const PERCENT_RE = /^\d{1,3}(\.\d{1,4})?$/;
|
|
106
|
+
const validPercent = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0 && n <= 100 && PERCENT_RE.test(String(n));
|
|
107
|
+
|
|
108
|
+
export function normalizeDuplication(d) {
|
|
109
|
+
if (d === undefined) return { mode: "required", maxPercent: 10 };
|
|
110
|
+
if (!isObj(d)) fail("config duplication must be an object");
|
|
111
|
+
checkKeys(d, ["mode", "maxPercent"], "duplication");
|
|
112
|
+
const mode = d.mode ?? "required";
|
|
113
|
+
if (!DUP_MODES.includes(mode)) fail(`invalid duplication.mode ${JSON.stringify(mode)} (required|off)`);
|
|
114
|
+
const maxPercent = d.maxPercent ?? 10;
|
|
115
|
+
if (!validPercent(maxPercent)) fail("invalid duplication.maxPercent (number 0..100, up to 4 decimals)");
|
|
116
|
+
return { mode, maxPercent };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function normalizeSonar(s) {
|
|
120
|
+
if (s === undefined) return { mode: "optional", projectKey: "" };
|
|
121
|
+
if (!isObj(s)) fail("config sonar must be an object");
|
|
122
|
+
checkKeys(s, ["projectKey", "mode"], "sonar");
|
|
123
|
+
const mode = s.mode ?? "optional";
|
|
124
|
+
if (!SONAR_MODES.includes(mode)) fail(`invalid sonar.mode ${JSON.stringify(mode)} (optional|required|off)`);
|
|
125
|
+
const key = s.projectKey;
|
|
126
|
+
if (key !== undefined && (typeof key !== "string" || !/^[A-Za-z0-9_.:-]{1,400}$/.test(key))) fail("invalid sonar projectKey in config");
|
|
127
|
+
return { mode, projectKey: key ?? "" };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function validateInputs({ project, sha, requestId }) {
|
|
131
|
+
if (project !== undefined && !NAME_RE.test(project)) fail("invalid project name");
|
|
132
|
+
if (!SHA_RE.test(sha ?? "")) fail("sha must be 40 lowercase hex characters");
|
|
133
|
+
if (!UUID_RE.test(requestId ?? "")) fail("request_id must be a UUID");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const REPO_RE = /^([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?)\/([A-Za-z0-9._-]{1,100})$/;
|
|
137
|
+
|
|
138
|
+
export function parseRepository(repository) {
|
|
139
|
+
const m = REPO_RE.exec(typeof repository === "string" ? repository : "");
|
|
140
|
+
if (!m || m[2] === "." || m[2] === "..") fail("invalid repository");
|
|
141
|
+
return { owner: m[1], repo: m[2] };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function canonicalUrl(url) {
|
|
145
|
+
try {
|
|
146
|
+
const { owner, repo } = parseGithubUrl(url);
|
|
147
|
+
return `${owner}/${repo}`.toLowerCase();
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function buildPlan({ inputs, repository, eventName, actor, refName, defaultBranch, projects, members, config, permission }) {
|
|
154
|
+
validateInputs({ sha: inputs?.sha, requestId: inputs?.requestId });
|
|
155
|
+
if (eventName !== "workflow_dispatch") fail(`review must be started by workflow_dispatch, got ${eventName}`);
|
|
156
|
+
const { owner, repo } = parseRepository(repository);
|
|
157
|
+
if (!defaultBranch || refName !== defaultBranch) fail(`workflow must be dispatched on the default branch (${defaultBranch}), got ${refName}`);
|
|
158
|
+
if (!WRITE_PERMS.includes(permission)) fail(`${actor} lacks write access to ${owner}/${repo}`);
|
|
159
|
+
const list = isObj(members) && Array.isArray(members.members) ? members.members : fail("members.json is malformed");
|
|
160
|
+
if (!list.some((m) => String(m).toLowerCase() === String(actor).toLowerCase())) fail(`${actor} is not in members.json`);
|
|
161
|
+
const all = isObj(projects) && Array.isArray(projects.projects) ? projects.projects : fail("projects.json is malformed");
|
|
162
|
+
const want = `${owner}/${repo}`.toLowerCase();
|
|
163
|
+
const matches = all.filter((p) => isObj(p) && canonicalUrl(p.url) === want);
|
|
164
|
+
if (matches.length === 0) fail(`repository ${owner}/${repo} is not registered in projects.json`);
|
|
165
|
+
if (matches.length > 1) fail(`repository ${owner}/${repo} is registered more than once in projects.json`);
|
|
166
|
+
const name = matches[0].name;
|
|
167
|
+
if (typeof name !== "string" || !NAME_RE.test(name)) fail("registered project name is invalid");
|
|
168
|
+
const pc = isObj(config?.projects) && Object.hasOwn(config.projects, name) && isObj(config.projects[name]) ? config.projects[name] : {};
|
|
169
|
+
const sonar = normalizeSonar(pc.sonar);
|
|
170
|
+
const security = normalizeSecurity(pc.security);
|
|
171
|
+
const dup = normalizeDuplication(pc.duplication);
|
|
172
|
+
return {
|
|
173
|
+
project: name,
|
|
174
|
+
sha: inputs.sha,
|
|
175
|
+
requestId: inputs.requestId,
|
|
176
|
+
owner,
|
|
177
|
+
repo,
|
|
178
|
+
test: normalizeTest(pc.test),
|
|
179
|
+
sonarKey: sonar.projectKey,
|
|
180
|
+
sonarMode: sonar.mode,
|
|
181
|
+
failOn: security.failOn,
|
|
182
|
+
dupMode: dup.mode,
|
|
183
|
+
dupMaxPercent: dup.maxPercent,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function cleanFinding(f) {
|
|
188
|
+
const rule = typeof f?.rule === "string" && /^[\w.\-/:]{1,300}$/.test(f.rule) ? f.rule : null;
|
|
189
|
+
const p = safeRelPath(f?.path);
|
|
190
|
+
const num = (n) => (Number.isInteger(n) && n >= 0 && n <= 10_000_000 ? n : null);
|
|
191
|
+
const line = num(f?.line);
|
|
192
|
+
if (!rule || !p || line === null) return null;
|
|
193
|
+
const severity = ["ERROR", "WARNING", "INFO"].includes(f.severity) ? f.severity : "UNKNOWN";
|
|
194
|
+
const cwe = [...new Set((Array.isArray(f.cwe) ? f.cwe : []).map((c) => /^CWE-\d{1,5}/.exec(String(c))?.[0]).filter(Boolean))].slice(0, 5);
|
|
195
|
+
return { rule, path: p, line, endLine: num(f.endLine) ?? line, severity, cwe };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function sanitizeOpengrep(raw, rc) {
|
|
199
|
+
if (!isObj(raw) || !Array.isArray(raw.results)) return { status: "error", exitCode: rc, total: 0, dropped: 0, bySeverity: {}, findings: [] };
|
|
200
|
+
const findings = [];
|
|
201
|
+
let dropped = 0;
|
|
202
|
+
for (const r of raw.results.slice(0, 5000)) {
|
|
203
|
+
const cwe = r?.extra?.metadata?.cwe;
|
|
204
|
+
const f = cleanFinding({
|
|
205
|
+
rule: r?.check_id,
|
|
206
|
+
path: r?.path,
|
|
207
|
+
line: r?.start?.line,
|
|
208
|
+
endLine: r?.end?.line,
|
|
209
|
+
severity: r?.extra?.severity,
|
|
210
|
+
cwe: Array.isArray(cwe) ? cwe : cwe ? [cwe] : [],
|
|
211
|
+
});
|
|
212
|
+
if (f) findings.push(f);
|
|
213
|
+
else dropped++;
|
|
214
|
+
}
|
|
215
|
+
const scanErrors = Array.isArray(raw.errors) ? raw.errors.length : 0;
|
|
216
|
+
const status = rc !== 0 || !Number.isInteger(rc) ? "error" : scanErrors ? "partial" : "ok";
|
|
217
|
+
return { status, exitCode: rc, scanErrors, total: findings.length, dropped, bySeverity: countBy(findings), findings };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const MAX_CLONES = 200;
|
|
221
|
+
const JS_STATUS = ["ok", "error"];
|
|
222
|
+
const DUP_REMEDIATION = "Review the duplicated regions and reduce duplication where it improves clarity; do not extract an abstraction blindly.";
|
|
223
|
+
|
|
224
|
+
function cleanClonePart(f) {
|
|
225
|
+
const p = safeRelPath(typeof f?.name === "string" ? f.name : null);
|
|
226
|
+
const n = (v) => (Number.isInteger(v) && v >= 0 && v <= 10_000_000 ? v : null);
|
|
227
|
+
const start = n(f?.start);
|
|
228
|
+
const end = n(f?.end);
|
|
229
|
+
if (!p || start === null || end === null) return null;
|
|
230
|
+
return { path: p, start, end };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function sanitizeJscpd(raw, rc) {
|
|
234
|
+
const bad = { status: "error", exitCode: Number.isInteger(rc) ? rc : null, percentage: 0, lines: 0, duplicatedLines: 0, clones: 0, locations: [] };
|
|
235
|
+
const t = raw?.statistics?.total;
|
|
236
|
+
if (rc !== 0 || !isObj(raw) || !isObj(t) || !Array.isArray(raw.duplicates)) return bad;
|
|
237
|
+
const pct = t.percentage;
|
|
238
|
+
const ok = (v) => Number.isFinite(v) && v >= 0 && v <= 1e12;
|
|
239
|
+
if (!(typeof pct === "number" && Number.isFinite(pct) && pct >= 0 && pct <= 100) || !ok(t.lines) || !ok(t.duplicatedLines) || !ok(t.clones)) return bad;
|
|
240
|
+
const locations = [];
|
|
241
|
+
for (const d of raw.duplicates.slice(0, MAX_CLONES)) {
|
|
242
|
+
const a = cleanClonePart(d?.firstFile);
|
|
243
|
+
const b = cleanClonePart(d?.secondFile);
|
|
244
|
+
if (a && b) locations.push({ a, b });
|
|
245
|
+
}
|
|
246
|
+
return { status: "ok", exitCode: 0, percentage: pct, lines: t.lines, duplicatedLines: t.duplicatedLines, clones: t.clones, locations };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function loadVerified(file, sha, max = 2 * 1024 * 1024) {
|
|
250
|
+
try {
|
|
251
|
+
if (!/^[0-9a-f]{64}$/.test(sha ?? "")) return null;
|
|
252
|
+
const st = fs.lstatSync(file);
|
|
253
|
+
if (!st.isFile() || st.size > max) return null;
|
|
254
|
+
const buf = fs.readFileSync(file);
|
|
255
|
+
if (createHash("sha256").update(buf).digest("hex") !== sha) return null;
|
|
256
|
+
return JSON.parse(buf.toString("utf8"));
|
|
257
|
+
} catch {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const countBy = (fs_) => fs_.reduce((a, f) => ((a[f.severity] = (a[f.severity] ?? 0) + 1), a), {});
|
|
263
|
+
|
|
264
|
+
export function lcovToGenericXml(text, { srcRoot, workdir, workspaceSrc }) {
|
|
265
|
+
const root = fs.realpathSync(srcRoot);
|
|
266
|
+
const base = path.resolve(root, workdir);
|
|
267
|
+
const files = new Map();
|
|
268
|
+
let cur = null;
|
|
269
|
+
let skipped = 0;
|
|
270
|
+
let records = 0;
|
|
271
|
+
for (const line of text.split(/\r?\n/)) {
|
|
272
|
+
if (line.startsWith("SF:")) {
|
|
273
|
+
records++;
|
|
274
|
+
cur = null;
|
|
275
|
+
const sf = line.slice(3).trim();
|
|
276
|
+
let abs = path.isAbsolute(sf) ? sf : path.resolve(base, sf);
|
|
277
|
+
if (workspaceSrc && abs.startsWith(workspaceSrc + path.sep)) abs = path.join(root, abs.slice(workspaceSrc.length));
|
|
278
|
+
try {
|
|
279
|
+
const st = fs.lstatSync(abs);
|
|
280
|
+
const real = fs.realpathSync(abs);
|
|
281
|
+
const rel = path.relative(root, real);
|
|
282
|
+
if (!st.isFile() || rel.startsWith("..") || path.isAbsolute(rel) || !safeRelPath(rel.split(path.sep).join("/"))) throw new Error("x");
|
|
283
|
+
cur = files.get(rel) ?? new Map();
|
|
284
|
+
files.set(rel, cur);
|
|
285
|
+
} catch {
|
|
286
|
+
skipped++;
|
|
287
|
+
}
|
|
288
|
+
} else if (line.startsWith("DA:") && cur) {
|
|
289
|
+
const m = /^DA:(\d{1,8}),(\d{1,12})(?:,.*)?$/.exec(line);
|
|
290
|
+
if (m && Number(m[1]) > 0) cur.set(Number(m[1]), (cur.get(Number(m[1])) ?? 0) + Number(m[2]));
|
|
291
|
+
}
|
|
292
|
+
if (files.size > 20000) fail("too many files in LCOV");
|
|
293
|
+
}
|
|
294
|
+
let out = '<coverage version="1">\n';
|
|
295
|
+
let lines = 0;
|
|
296
|
+
for (const [rel, das] of files) {
|
|
297
|
+
out += ` <file path="${rel.split(path.sep).join("/")}">\n`;
|
|
298
|
+
for (const [n, hits] of das) {
|
|
299
|
+
out += ` <lineToCover lineNumber="${n}" covered="${hits > 0}"/>\n`;
|
|
300
|
+
lines++;
|
|
301
|
+
}
|
|
302
|
+
out += " </file>\n";
|
|
303
|
+
}
|
|
304
|
+
return { xml: out + "</coverage>\n", stats: { records, files: files.size, lines, skipped } };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function readBoundedRegular(file, max) {
|
|
308
|
+
const st = fs.lstatSync(file);
|
|
309
|
+
if (!st.isFile()) fail("not a regular file");
|
|
310
|
+
if (st.size > max) fail("file too large");
|
|
311
|
+
return fs.readFileSync(file, "utf8");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function sonarGate({ hostUrl, hasToken, projectKey, defaultHead, mode = "required" }) {
|
|
315
|
+
if (mode === "off") return { status: "off", reason: "disabled by config (sonar.mode off)" };
|
|
316
|
+
let host = null;
|
|
317
|
+
try {
|
|
318
|
+
const u = new URL(hostUrl ?? "");
|
|
319
|
+
if (u.protocol === "https:" && !u.username && !u.password && !u.search && !u.hash) host = u.origin + u.pathname.replace(/\/$/, "");
|
|
320
|
+
} catch {
|
|
321
|
+
// invalid
|
|
322
|
+
}
|
|
323
|
+
if (!host) return { status: "incomplete", reason: "SONAR_HOST_URL variable missing or not a plain https URL" };
|
|
324
|
+
if (!hasToken) return { status: "incomplete", reason: "SONAR_TOKEN secret missing" };
|
|
325
|
+
if (!projectKey) return { status: "incomplete", reason: "sonar.projectKey not configured for this project" };
|
|
326
|
+
if (!defaultHead) return { status: "unsupported-revision", reason: "SonarQube Community analyzes only the project default branch head; this sha is not it" };
|
|
327
|
+
return { status: "ready", host };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const METRICS = ["bugs", "vulnerabilities", "code_smells", "security_hotspots", "coverage", "alert_status"];
|
|
331
|
+
|
|
332
|
+
export function cleanMetrics(measures) {
|
|
333
|
+
const out = {};
|
|
334
|
+
for (const m of Array.isArray(measures) ? measures : []) {
|
|
335
|
+
if (!METRICS.includes(m?.metric)) continue;
|
|
336
|
+
const v = String(m.value ?? "");
|
|
337
|
+
if (m.metric === "alert_status" ? ["OK", "WARN", "ERROR", "NONE"].includes(v) : /^\d{1,12}(\.\d+)?$/.test(v)) out[m.metric] = v;
|
|
338
|
+
}
|
|
339
|
+
return out;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const OG_STATUS = ["ok", "partial", "error"];
|
|
343
|
+
const SN_STATUS = ["ok", "incomplete", "unsupported-revision", "failed", "off"];
|
|
344
|
+
|
|
345
|
+
function derivePolicy(outs, authorized) {
|
|
346
|
+
const dupMode = DUP_MODES.includes(outs.dup_mode) ? outs.dup_mode : null;
|
|
347
|
+
const dupMax = PERCENT_RE.test(String(outs.dup_max_percent ?? "")) && Number(outs.dup_max_percent) <= 100 ? Number(outs.dup_max_percent) : null;
|
|
348
|
+
const failOn = FAIL_ON.includes(outs.fail_on) ? outs.fail_on : null;
|
|
349
|
+
const sonarMode = SONAR_MODES.includes(outs.sonar_mode) ? outs.sonar_mode : null;
|
|
350
|
+
return {
|
|
351
|
+
failOn: failOn ?? "INFO",
|
|
352
|
+
sonarMode: sonarMode ?? "required",
|
|
353
|
+
dupMode: dupMode ?? "required",
|
|
354
|
+
dupMax: dupMax ?? 0,
|
|
355
|
+
invalid: authorized && (!failOn || !sonarMode || !dupMode || dupMax === null),
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function deriveReport({ inputs, needs, opengrep, sonar, jscpd, runUrl }) {
|
|
360
|
+
const res = (j) => needs?.[j]?.result ?? "skipped";
|
|
361
|
+
const outs = needs?.authorize?.outputs ?? {};
|
|
362
|
+
const incomplete = [];
|
|
363
|
+
const failures = [];
|
|
364
|
+
const notes = [];
|
|
365
|
+
let valid = true;
|
|
366
|
+
try {
|
|
367
|
+
validateInputs({ sha: inputs?.sha, requestId: inputs?.requestId });
|
|
368
|
+
} catch {
|
|
369
|
+
valid = false;
|
|
370
|
+
}
|
|
371
|
+
const policy = derivePolicy(outs, res("authorize") === "success");
|
|
372
|
+
if (policy.invalid) failures.push("review policy outputs missing or invalid (failing closed)");
|
|
373
|
+
if (res("authorize") !== "success") incomplete.push("authorization failed or did not run");
|
|
374
|
+
else if (res("prepare") !== "success") incomplete.push("source snapshot failed (see prepare job)");
|
|
375
|
+
|
|
376
|
+
const oc = (v) => (["success", "failure", "skipped"].includes(v) ? v : "unknown");
|
|
377
|
+
let tests;
|
|
378
|
+
if (res("authorize") !== "success" || res("prepare") !== "success") tests = { status: "skipped" };
|
|
379
|
+
else if (outs.test_configured !== "true") {
|
|
380
|
+
tests = { status: "not-configured" };
|
|
381
|
+
incomplete.push("tests not configured in .github/techbro-review.json");
|
|
382
|
+
} else {
|
|
383
|
+
const t = needs?.tests?.outputs ?? {};
|
|
384
|
+
tests = { status: oc(t.install_outcome) === "failure" ? "install-failed" : oc(t.test_outcome) === "success" ? "passed" : oc(t.test_outcome) === "failure" ? "failed" : "unknown" };
|
|
385
|
+
if (res("tests") !== "success" || tests.status === "unknown") incomplete.push("tests did not complete");
|
|
386
|
+
else if (tests.status !== "passed") failures.push(`tests ${tests.status}`);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
let og;
|
|
390
|
+
if (res("opengrep") === "skipped") {
|
|
391
|
+
og = { status: "skipped", total: 0, bySeverity: {}, findings: [] };
|
|
392
|
+
incomplete.push("opengrep did not run");
|
|
393
|
+
} else if (res("opengrep") !== "success" || !isObj(opengrep) || !OG_STATUS.includes(opengrep.status)) {
|
|
394
|
+
og = { status: "error", total: 0, bySeverity: {}, findings: [] };
|
|
395
|
+
failures.push("opengrep job failed or its result is missing/untrusted");
|
|
396
|
+
} else {
|
|
397
|
+
const findings = (Array.isArray(opengrep.findings) ? opengrep.findings : [])
|
|
398
|
+
.map(cleanFinding)
|
|
399
|
+
.filter(Boolean)
|
|
400
|
+
.slice(0, 5000);
|
|
401
|
+
og = { status: opengrep.status, total: findings.length, bySeverity: countBy(findings), findings: findings.map((f) => ({ ...f, remediation: remediationFor(f.rule) })) };
|
|
402
|
+
if (og.status !== "ok") failures.push(`opengrep ${og.status}`);
|
|
403
|
+
if (policy.failOn !== "none") {
|
|
404
|
+
const hits = findings.filter((f) => SEV_RANK[f.severity] >= SEV_RANK[policy.failOn]).length;
|
|
405
|
+
if (hits) failures.push(`opengrep findings at or above ${policy.failOn}: ${hits}`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
let dup;
|
|
410
|
+
if (policy.dupMode === "off") {
|
|
411
|
+
dup = { status: "off", mode: "off", threshold: policy.dupMax, percentage: 0, clones: 0, locations: [] };
|
|
412
|
+
notes.push("duplication check skipped: disabled by config (duplication.mode off)");
|
|
413
|
+
} else {
|
|
414
|
+
const base = { mode: "required", threshold: policy.dupMax, percentage: 0, clones: 0, locations: [] };
|
|
415
|
+
if (res("jscpd") === "skipped") {
|
|
416
|
+
dup = { ...base, status: "skipped" };
|
|
417
|
+
incomplete.push("jscpd did not run");
|
|
418
|
+
} else if (res("jscpd") !== "success" || !isObj(jscpd) || !JS_STATUS.includes(jscpd.status)) {
|
|
419
|
+
dup = { ...base, status: "error" };
|
|
420
|
+
failures.push("jscpd job failed or its result is missing/untrusted");
|
|
421
|
+
} else {
|
|
422
|
+
const clean = sanitizeJscpd(
|
|
423
|
+
{ statistics: { total: { percentage: jscpd.percentage, lines: jscpd.lines, duplicatedLines: jscpd.duplicatedLines, clones: jscpd.clones } }, duplicates: (Array.isArray(jscpd.locations) ? jscpd.locations : []).map((l) => ({ firstFile: { name: l?.a?.path, start: l?.a?.start, end: l?.a?.end }, secondFile: { name: l?.b?.path, start: l?.b?.start, end: l?.b?.end } })) },
|
|
424
|
+
jscpd.status === "ok" ? 0 : 1,
|
|
425
|
+
);
|
|
426
|
+
if (clean.status !== "ok") {
|
|
427
|
+
dup = { ...base, status: "error" };
|
|
428
|
+
failures.push("jscpd failed or reported invalid statistics");
|
|
429
|
+
} else {
|
|
430
|
+
const over = clean.percentage > policy.dupMax;
|
|
431
|
+
dup = { ...base, status: over ? "threshold-exceeded" : "ok", percentage: clean.percentage, lines: clean.lines, duplicatedLines: clean.duplicatedLines, clones: clean.clones, locations: clean.locations, remediation: over ? DUP_REMEDIATION : undefined };
|
|
432
|
+
if (over) failures.push(`duplication ${clean.percentage.toFixed(2)}% exceeds ${policy.dupMax}%`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
let sn;
|
|
438
|
+
const optional = policy.sonarMode === "optional";
|
|
439
|
+
if (policy.sonarMode === "off") {
|
|
440
|
+
sn = { status: "off", reason: "disabled by config (sonar.mode off)" };
|
|
441
|
+
notes.push("sonar skipped: disabled by config (sonar.mode off)");
|
|
442
|
+
} else if (res("sonar") === "skipped") {
|
|
443
|
+
sn = { status: "skipped" };
|
|
444
|
+
incomplete.push("sonar did not run");
|
|
445
|
+
} else if (res("sonar") !== "success" || !isObj(sonar) || !SN_STATUS.includes(sonar.status) || sonar.status === "off") {
|
|
446
|
+
sn = { status: "failed", reason: "sonar job failed or its result is missing/untrusted" };
|
|
447
|
+
failures.push(sn.reason);
|
|
448
|
+
} else {
|
|
449
|
+
sn = sonar;
|
|
450
|
+
if (sn.status === "failed") failures.push(`sonar failed${sn.reason ? `: ${sn.reason}` : ""}`);
|
|
451
|
+
else if (sn.status !== "ok") {
|
|
452
|
+
const msg = `sonar ${sn.status}${sn.reason ? `: ${sn.reason}` : ""}`;
|
|
453
|
+
if (optional) {
|
|
454
|
+
sn = { ...sn, status: "skipped-optional" };
|
|
455
|
+
notes.push(`${msg} (skipped-optional: sonar.mode optional)`);
|
|
456
|
+
} else incomplete.push(msg);
|
|
457
|
+
} else if (sn.metrics?.alert_status === "ERROR") {
|
|
458
|
+
sn = { ...sn, status: "gate-failed" };
|
|
459
|
+
failures.push("sonar quality gate failed");
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return {
|
|
464
|
+
schemaVersion: 3,
|
|
465
|
+
policy: { failOn: policy.failOn, sonarMode: policy.sonarMode, duplication: { mode: policy.dupMode, maxPercent: policy.dupMax } },
|
|
466
|
+
project: NAME_RE.test(inputs?.project ?? "") ? inputs.project : "unknown",
|
|
467
|
+
sha: valid ? inputs.sha : "invalid",
|
|
468
|
+
requestId: valid ? inputs.requestId : "invalid",
|
|
469
|
+
runUrl: /^https:\/\/github\.com\/[\w.\-/]+$/.test(runUrl ?? "") ? runUrl : "",
|
|
470
|
+
jobs: Object.fromEntries(["authorize", "prepare", "tests", "opengrep", "jscpd", "sonar"].map((j) => [j, oc(res(j))])),
|
|
471
|
+
tests,
|
|
472
|
+
opengrep: og,
|
|
473
|
+
sonar: sn,
|
|
474
|
+
duplication: dup,
|
|
475
|
+
complete: incomplete.length === 0,
|
|
476
|
+
incomplete,
|
|
477
|
+
failures,
|
|
478
|
+
notes,
|
|
479
|
+
pass: incomplete.length === 0 && failures.length === 0,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const esc = (s) => String(s).replace(/[^\w .\-/:@+]/g, "_");
|
|
484
|
+
|
|
485
|
+
export function reportMarkdown(r) {
|
|
486
|
+
const L = [`# techbro review: ${esc(r.project)} @ ${esc(r.sha.slice(0, 12))}`, "", `request \`${esc(r.requestId)}\` - ${r.complete ? "complete" : "**INCOMPLETE**"}`, ""];
|
|
487
|
+
L.push("| job | result |", "| --- | --- |", ...Object.entries(r.jobs).map(([k, v]) => `| ${k} | ${v} |`), "");
|
|
488
|
+
L.push(`- tests: ${esc(r.tests.status)}`);
|
|
489
|
+
L.push(`- opengrep: ${esc(r.opengrep.status)}, findings ${r.opengrep.total ?? 0} (${esc(JSON.stringify(r.opengrep.bySeverity ?? {}))})`);
|
|
490
|
+
if (r.duplication) L.push(`- duplication: ${esc(r.duplication.status)}, ${Number(r.duplication.percentage ?? 0).toFixed(2)}% (max ${Number(r.duplication.threshold ?? 0)}%), clones ${Number(r.duplication.clones ?? 0)}`);
|
|
491
|
+
L.push(`- sonar: ${esc(r.sonar.status)} ${esc(JSON.stringify(r.sonar.metrics ?? {}))}${r.sonar.coverage ? ` coverage import: ${esc(r.sonar.coverage.status)}` : ""}`);
|
|
492
|
+
if (r.incomplete.length) L.push("", "## Incomplete", ...r.incomplete.map((x) => `- ${esc(x)}`));
|
|
493
|
+
if (r.failures?.length) L.push("", "## Failures", ...r.failures.map((x) => `- ${esc(x)}`));
|
|
494
|
+
if (r.notes?.length) L.push("", "## Notes", ...r.notes.map((x) => `- ${esc(x)}`));
|
|
495
|
+
if (r.opengrep.findings?.length) {
|
|
496
|
+
L.push("", "## Opengrep findings (metadata only)", "| severity | rule | location | remediation |", "| --- | --- | --- | --- |");
|
|
497
|
+
for (const f of r.opengrep.findings.slice(0, 50)) L.push(`| ${f.severity} | ${esc(f.rule)} | ${esc(f.path)}:${f.line} | ${esc(f.remediation ?? remediationFor(f.rule))} |`);
|
|
498
|
+
}
|
|
499
|
+
if (r.duplication?.locations?.length) {
|
|
500
|
+
L.push("", "## Duplicated regions (locations only)", "| first | second |", "| --- | --- |");
|
|
501
|
+
for (const l of r.duplication.locations.slice(0, 20)) L.push(`| ${esc(l.a.path)}:${l.a.start}-${l.a.end} | ${esc(l.b.path)}:${l.b.start}-${l.b.end} |`);
|
|
502
|
+
if (r.duplication.remediation) L.push("", esc(r.duplication.remediation));
|
|
503
|
+
}
|
|
504
|
+
return L.join("\n") + "\n";
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function setOutput(k, v) {
|
|
508
|
+
v = String(v);
|
|
509
|
+
if (/[\r\n]/.test(v)) fail(`output ${k} contains a newline`);
|
|
510
|
+
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${k}=${v}\n`);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async function api(token, p, init = {}) {
|
|
514
|
+
const res = await fetch(`https://api.github.com${p}`, {
|
|
515
|
+
...init,
|
|
516
|
+
headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", "User-Agent": "techbro-review" },
|
|
517
|
+
signal: AbortSignal.timeout(60000),
|
|
518
|
+
});
|
|
519
|
+
return res;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async function apiJson(token, p) {
|
|
523
|
+
const res = await api(token, p);
|
|
524
|
+
if (!res.ok) fail(`GitHub ${res.status} for ${p.split("?")[0]}`);
|
|
525
|
+
return res.json();
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function removeUnsafe(dir) {
|
|
529
|
+
let total = 0;
|
|
530
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
531
|
+
const p = path.join(dir, e.name);
|
|
532
|
+
if (e.isSymbolicLink()) fs.rmSync(p);
|
|
533
|
+
else if (e.isDirectory()) total += removeUnsafe(p);
|
|
534
|
+
else if (e.isFile()) total += fs.statSync(p).size;
|
|
535
|
+
else fs.rmSync(p, { force: true });
|
|
536
|
+
}
|
|
537
|
+
return total;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function emitHash(file) {
|
|
541
|
+
setOutput("sha", createHash("sha256").update(fs.readFileSync(file)).digest("hex"));
|
|
542
|
+
}
|
|
543
|
+
const readJson = (f) => JSON.parse(fs.readFileSync(f, "utf8"));
|
|
544
|
+
const writeJson = (f, v) => {
|
|
545
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
546
|
+
fs.writeFileSync(f, JSON.stringify(v, null, 2) + "\n");
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
const commands = {
|
|
550
|
+
async authorize() {
|
|
551
|
+
const e = process.env;
|
|
552
|
+
const { owner, repo } = parseRepository(e.REPOSITORY);
|
|
553
|
+
const dir = e.TEAM_DIR || fail("TEAM_DIR not set");
|
|
554
|
+
const res = await api(e.GH_TOKEN, `/repos/${owner}/${repo}/collaborators/${encodeURIComponent(e.ACTOR)}/permission`);
|
|
555
|
+
const permission = res.ok ? (await res.json()).permission : "none";
|
|
556
|
+
const cfg = path.join(dir, "techbro-review.json");
|
|
557
|
+
const plan = buildPlan({
|
|
558
|
+
inputs: { sha: e.INPUT_SHA, requestId: e.INPUT_REQUEST_ID },
|
|
559
|
+
repository: e.REPOSITORY,
|
|
560
|
+
eventName: e.EVENT_NAME,
|
|
561
|
+
actor: e.ACTOR,
|
|
562
|
+
refName: e.REF_NAME,
|
|
563
|
+
defaultBranch: e.DEFAULT_BRANCH,
|
|
564
|
+
projects: readJson(path.join(dir, "projects.json")),
|
|
565
|
+
members: readJson(path.join(dir, "members.json")),
|
|
566
|
+
config: fs.existsSync(cfg) ? readJson(cfg) : {},
|
|
567
|
+
permission,
|
|
568
|
+
});
|
|
569
|
+
writeJson("ctl/plan.json", plan);
|
|
570
|
+
const t = plan.test;
|
|
571
|
+
for (const [k, v] of Object.entries({
|
|
572
|
+
ok: "true", project: plan.project, sha: plan.sha, owner: plan.owner, repo: plan.repo, sonar_key: plan.sonarKey,
|
|
573
|
+
test_configured: t ? "true" : "false", test_runtime: t?.runtime ?? "", test_node_version: t?.nodeVersion ?? "",
|
|
574
|
+
test_workdir: t?.workdir ?? ".", test_install: t?.install ?? "", test_command: t?.command ?? "", test_coverage: t?.coverage ?? "",
|
|
575
|
+
test_rust_version: t?.rustVersion ?? "", fail_on: plan.failOn, sonar_mode: plan.sonarMode,
|
|
576
|
+
dup_mode: plan.dupMode, dup_max_percent: plan.dupMaxPercent,
|
|
577
|
+
})) setOutput(k, v);
|
|
578
|
+
},
|
|
579
|
+
|
|
580
|
+
async snapshot() {
|
|
581
|
+
const e = process.env;
|
|
582
|
+
const plan = readJson("ctl/plan.json");
|
|
583
|
+
const headFile = "checkout/.git/HEAD";
|
|
584
|
+
if (!fs.lstatSync(headFile).isFile() || fs.readFileSync(headFile, "utf8").trim() !== plan.sha) fail("checked-out commit does not match the requested sha");
|
|
585
|
+
const base = `/repos/${plan.owner}/${plan.repo}`;
|
|
586
|
+
const repo = await apiJson(e.GH_TOKEN, base);
|
|
587
|
+
const head = await apiJson(e.GH_TOKEN, `${base}/commits/${encodeURIComponent(repo.default_branch)}`);
|
|
588
|
+
fs.mkdirSync("snapshot", { recursive: true });
|
|
589
|
+
fs.cpSync("checkout", "snapshot", { recursive: true, verbatimSymlinks: true, filter: (src) => path.basename(src) !== ".git" });
|
|
590
|
+
if (removeUnsafe("snapshot") > MAX_UNPACKED) fail("unpacked source exceeds size limit");
|
|
591
|
+
setOutput("default_head", head.sha === plan.sha ? "true" : "false");
|
|
592
|
+
},
|
|
593
|
+
|
|
594
|
+
async sanitize() {
|
|
595
|
+
let raw = null;
|
|
596
|
+
try {
|
|
597
|
+
raw = readJson(process.env.RAW);
|
|
598
|
+
} catch {
|
|
599
|
+
// missing or invalid raw output
|
|
600
|
+
}
|
|
601
|
+
writeJson(process.env.OUT, sanitizeOpengrep(raw, Number(process.env.RC)));
|
|
602
|
+
emitHash(process.env.OUT);
|
|
603
|
+
},
|
|
604
|
+
|
|
605
|
+
async "jscpd-sanitize"() {
|
|
606
|
+
let raw = null;
|
|
607
|
+
try {
|
|
608
|
+
raw = JSON.parse(readBoundedRegular(process.env.RAW, 200 * 1024 * 1024));
|
|
609
|
+
} catch {
|
|
610
|
+
// missing, oversized or invalid raw output
|
|
611
|
+
}
|
|
612
|
+
writeJson(process.env.OUT, sanitizeJscpd(raw, Number(process.env.RC)));
|
|
613
|
+
emitHash(process.env.OUT);
|
|
614
|
+
},
|
|
615
|
+
|
|
616
|
+
async "sonar-gate"() {
|
|
617
|
+
const e = process.env;
|
|
618
|
+
const g = sonarGate({ hostUrl: e.SONAR_HOST_URL, hasToken: e.HAS_TOKEN === "true", projectKey: e.SONAR_KEY, defaultHead: e.DEFAULT_HEAD === "true", mode: e.SONAR_MODE });
|
|
619
|
+
setOutput("status", g.status);
|
|
620
|
+
writeJson(e.GATE_OUT, g);
|
|
621
|
+
},
|
|
622
|
+
|
|
623
|
+
async "sonar-coverage"() {
|
|
624
|
+
const e = process.env;
|
|
625
|
+
const plan = readJson("ctl/plan.json");
|
|
626
|
+
const cov = { status: "none" };
|
|
627
|
+
const lcov = path.join(e.COV_DIR, "lcov.info");
|
|
628
|
+
if (plan.test?.coverage && fs.existsSync(lcov)) {
|
|
629
|
+
try {
|
|
630
|
+
const { xml, stats } = lcovToGenericXml(readBoundedRegular(lcov, MAX_LCOV), { srcRoot: "src", workdir: plan.test.workdir, workspaceSrc: path.join(e.GITHUB_WORKSPACE, "src") });
|
|
631
|
+
Object.assign(cov, stats, { status: stats.files === 0 ? "empty" : stats.skipped ? "partial" : "ok" });
|
|
632
|
+
if (stats.files > 0) {
|
|
633
|
+
fs.writeFileSync(e.COV_XML, xml);
|
|
634
|
+
setOutput("arg", `-Dsonar.coverageReportPaths=${e.COV_XML}`);
|
|
635
|
+
}
|
|
636
|
+
} catch (err) {
|
|
637
|
+
cov.status = "invalid";
|
|
638
|
+
cov.reason = String(err.message).slice(0, 100);
|
|
639
|
+
}
|
|
640
|
+
} else if (plan.test?.coverage) cov.status = "missing";
|
|
641
|
+
writeJson(e.COV_OUT, cov);
|
|
642
|
+
},
|
|
643
|
+
|
|
644
|
+
async "sonar-result"() {
|
|
645
|
+
const e = process.env;
|
|
646
|
+
const gate = readJson(e.GATE_OUT);
|
|
647
|
+
const coverage = fs.existsSync(e.COV_OUT) ? readJson(e.COV_OUT) : { status: "none" };
|
|
648
|
+
const r = { status: gate.status, reason: gate.reason, coverage };
|
|
649
|
+
if (gate.status === "ready") {
|
|
650
|
+
const key = encodeURIComponent(e.SONAR_KEY);
|
|
651
|
+
const auth = "Basic " + Buffer.from(`${e.SONAR_TOKEN}:`).toString("base64");
|
|
652
|
+
const get = async (p) => {
|
|
653
|
+
const res = await fetch(`${gate.host}${p}`, { headers: { Authorization: auth }, signal: AbortSignal.timeout(30000) });
|
|
654
|
+
if (!res.ok) fail(`sonar API ${res.status}`);
|
|
655
|
+
return res.json();
|
|
656
|
+
};
|
|
657
|
+
const scanOk = e.SCAN_OUTCOME === "success";
|
|
658
|
+
try {
|
|
659
|
+
const m = await get(`/api/measures/component?component=${key}&metricKeys=${METRICS.join(",")}`);
|
|
660
|
+
r.metrics = cleanMetrics(m?.component?.measures);
|
|
661
|
+
r.dashboard = `${gate.host}/dashboard?id=${key}`;
|
|
662
|
+
if (scanOk || r.metrics.alert_status === "ERROR") {
|
|
663
|
+
r.status = "ok";
|
|
664
|
+
delete r.reason;
|
|
665
|
+
} else {
|
|
666
|
+
r.status = "failed";
|
|
667
|
+
r.reason = "scanner failed";
|
|
668
|
+
}
|
|
669
|
+
} catch (err) {
|
|
670
|
+
r.status = "failed";
|
|
671
|
+
r.reason = scanOk ? String(err.message).slice(0, 100) : "scanner failed";
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
delete r.host;
|
|
675
|
+
writeJson(e.OUT, r);
|
|
676
|
+
emitHash(e.OUT);
|
|
677
|
+
},
|
|
678
|
+
|
|
679
|
+
async report() {
|
|
680
|
+
const e = process.env;
|
|
681
|
+
const og = loadVerified(path.join(e.ART_DIR, "techbro-opengrep-result", "opengrep.json"), e.OG_SHA);
|
|
682
|
+
const sn = loadVerified(path.join(e.ART_DIR, "techbro-sonar-result", "sonar.json"), e.SN_SHA);
|
|
683
|
+
const sonar = isObj(sn)
|
|
684
|
+
? {
|
|
685
|
+
status: SN_STATUS.includes(sn.status) ? sn.status : "failed",
|
|
686
|
+
reason: typeof sn.reason === "string" ? esc(sn.reason).slice(0, 200) : undefined,
|
|
687
|
+
metrics: cleanMetrics(Object.entries(sn.metrics ?? {}).map(([metric, value]) => ({ metric, value }))),
|
|
688
|
+
coverage: isObj(sn.coverage) ? { status: esc(sn.coverage.status).slice(0, 20) } : undefined,
|
|
689
|
+
dashboard: typeof sn.dashboard === "string" && /^https:\/\/[\w.\-:/?=%]+$/.test(sn.dashboard) ? sn.dashboard : undefined,
|
|
690
|
+
}
|
|
691
|
+
: null;
|
|
692
|
+
const jd = loadVerified(path.join(e.ART_DIR, "techbro-jscpd-result", "jscpd.json"), e.JD_SHA);
|
|
693
|
+
const jscpd = isObj(jd) ? jd : null;
|
|
694
|
+
const opengrep = isObj(og) ? { status: og.status, findings: Array.isArray(og.findings) ? og.findings : [] } : null;
|
|
695
|
+
const report = deriveReport({
|
|
696
|
+
inputs: { project: e.INPUT_PROJECT, sha: e.INPUT_SHA, requestId: e.INPUT_REQUEST_ID },
|
|
697
|
+
needs: JSON.parse(e.NEEDS),
|
|
698
|
+
opengrep,
|
|
699
|
+
sonar,
|
|
700
|
+
jscpd,
|
|
701
|
+
runUrl: e.RUN_URL,
|
|
702
|
+
});
|
|
703
|
+
writeJson("report/report.json", report);
|
|
704
|
+
const md = reportMarkdown(report);
|
|
705
|
+
fs.writeFileSync("report/report.md", md);
|
|
706
|
+
if (e.GITHUB_STEP_SUMMARY) fs.appendFileSync(e.GITHUB_STEP_SUMMARY, md);
|
|
707
|
+
if (!report.pass) process.exitCode = 1;
|
|
708
|
+
},
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
712
|
+
const cmd = commands[process.argv[2]];
|
|
713
|
+
if (!cmd) {
|
|
714
|
+
console.error(`unknown command: ${process.argv[2]}`);
|
|
715
|
+
process.exit(2);
|
|
716
|
+
}
|
|
717
|
+
cmd().catch((err) => {
|
|
718
|
+
console.error(`techbro-review: ${err.message}`);
|
|
719
|
+
process.exit(1);
|
|
720
|
+
});
|
|
721
|
+
}
|