feat: initial prototype attempt
Node.js CI / CI (push) Failing after 7s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 50s

This commit is contained in:
2026-02-03 17:13:57 -08:00
parent 729bd4b472
commit 5bc2cfbe43
26 changed files with 7982 additions and 19 deletions
+294
View File
@@ -0,0 +1,294 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { exec } from "node:child_process";
import { readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { config } from "../config.js";
import type { Logger } from "@nhcarrigan/logger";
const execAsync = promisify(exec);
interface ClonedRepository {
cleanup: ()=> Promise<void>;
path: string;
repoName: string;
}
type UpdateResult =
| { branchName: string; status: "created" }
| { branchName: string; status: "updated" }
| { error: string; status: "failed" }
| { status: "up-to-date" };
interface PackageJsonDeps {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
}
interface UpdatePackageOptions {
logger: Logger;
packageName: string;
repoPath: string;
targetVersion: string;
}
interface BranchUpdateOptions {
branchName: string;
clonedRepo: ClonedRepository;
logger: Logger;
packageName: string;
targetVersion: string;
}
/**
* Runs a git command in the specified directory.
* @param logger - The logger instance.
* @param cwd - The working directory.
* @param command - The git command to run.
* @returns The command output.
*/
const runGitCommand = async(
logger: Logger,
cwd: string,
command: string,
): Promise<string> => {
try {
const { stderr, stdout } = await execAsync(command, { cwd });
if (stderr.length > 0 && !stderr.includes("warning:")) {
void logger.log("debug", `Git stderr: ${stderr}`);
}
return stdout.trim();
} catch (error: unknown) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Error type needs cast to access stderr property
const gitError = error as Error & { stderr?: string };
throw new Error(
`Git command failed: ${command}\n${gitError.stderr ?? gitError.message}`,
);
}
};
/**
* Gets the current version of a package from package.json.
* @param repoPath - The repository path.
* @param packageName - The name of the package to look up.
* @returns The current version or null if not found.
*/
const getCurrentVersionOnBranch = async(
repoPath: string,
packageName: string,
): Promise<null | string> => {
const packageJsonPath = join(repoPath, "package.json");
const packageJsonContent = await readFile(packageJsonPath, "utf-8");
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- Dynamic JSON parsing requires type assertion
const packageJson: PackageJsonDeps = JSON.parse(packageJsonContent);
return (
packageJson.dependencies?.[packageName]
?? packageJson.devDependencies?.[packageName]
?? null
);
};
/**
* Updates package.json and creates a commit.
* @param options - Configuration for which package to update and where.
*/
const updatePackageAndCommit = async(
options: UpdatePackageOptions,
): Promise<void> => {
const { logger, packageName, repoPath, targetVersion } = options;
const packageJsonPath = join(repoPath, "package.json");
const packageJsonContent = await readFile(packageJsonPath, "utf-8");
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- Dynamic JSON parsing requires type assertion
const packageJson: PackageJsonDeps = JSON.parse(packageJsonContent);
if (packageJson.dependencies?.[packageName] !== undefined) {
packageJson.dependencies[packageName] = targetVersion;
}
if (packageJson.devDependencies?.[packageName] !== undefined) {
packageJson.devDependencies[packageName] = targetVersion;
}
await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
void logger.log("info", `Running pnpm install for ${packageName}...`);
await execAsync("pnpm install --no-frozen-lockfile", { cwd: repoPath });
await runGitCommand(logger, repoPath, "git add package.json pnpm-lock.yaml");
const commitMessage = `deps: update ${packageName} to ${targetVersion}`;
await runGitCommand(logger, repoPath, `git commit -m "${commitMessage}"`);
};
/**
* Clones a repository to a temporary directory.
* @param logger - The logger instance.
* @param repoName - The repository to clone.
* @param giteaToken - Token for authenticating with the Gitea API.
* @returns The cloned repository information.
*/
const cloneRepository = async(
logger: Logger,
repoName: string,
giteaToken: string,
): Promise<ClonedRepository> => {
const timestamp = Date.now();
const temporaryPath = join(
tmpdir(),
`minori-${repoName}-${String(timestamp)}`,
);
const giteaHost = config.giteaUrl.replace("https://", "");
const cloneUrl
= `https://minori:${giteaToken}@${giteaHost}/${config.giteaOrg}/${repoName}.git`;
void logger.log("info", `Cloning ${repoName} to ${temporaryPath}...`);
await execAsync(`git clone ${cloneUrl} ${temporaryPath}`);
await runGitCommand(
logger,
temporaryPath,
`git config user.email "minori@nhcarrigan.com"`,
);
await runGitCommand(logger, temporaryPath, `git config user.name "Minori"`);
return {
cleanup: async(): Promise<void> => {
void logger.log("info", `Cleaning up temp directory for ${repoName}...`);
await rm(temporaryPath, { force: true, recursive: true });
},
path: temporaryPath,
repoName: repoName,
};
};
/**
* Handles updating an existing branch with a newer version.
* @param options - The branch update options.
* @returns The update result.
*/
const handleExistingBranch = async(
options: BranchUpdateOptions,
): Promise<UpdateResult> => {
const { branchName, clonedRepo, logger, packageName, targetVersion }
= options;
const { path: repoPath } = clonedRepo;
await runGitCommand(logger, repoPath, `git checkout ${branchName}`);
await runGitCommand(logger, repoPath, `git pull origin ${branchName}`);
const currentVersion = await getCurrentVersionOnBranch(repoPath, packageName);
if (currentVersion === targetVersion) {
void logger.log(
"info",
`Branch ${branchName} is already on version ${targetVersion}, skipping...`,
);
await runGitCommand(logger, repoPath, "git checkout main");
return { status: "up-to-date" };
}
void logger.log(
"info",
`Branch ${branchName} has version ${currentVersion ?? "unknown"}, updating to ${targetVersion}...`,
);
await updatePackageAndCommit({
logger,
packageName,
repoPath,
targetVersion,
});
void logger.log("info", `Pushing updated branch ${branchName}...`);
await runGitCommand(logger, repoPath, `git push origin ${branchName}`);
await runGitCommand(logger, repoPath, "git checkout main");
return { branchName: branchName, status: "updated" };
};
/**
* Handles creating a branch for a version update.
* @param options - Configuration for the branch to create.
* @returns The update result.
*/
const handleNewBranch = async(
options: BranchUpdateOptions,
): Promise<UpdateResult> => {
const { branchName, clonedRepo, logger, packageName, targetVersion }
= options;
const { path: repoPath } = clonedRepo;
await runGitCommand(logger, repoPath, "git checkout main");
await runGitCommand(logger, repoPath, `git checkout -b ${branchName}`);
const currentVersion = await getCurrentVersionOnBranch(repoPath, packageName);
if (currentVersion === null) {
void logger.log("warn", `Package ${packageName} not found in package.json`);
await runGitCommand(logger, repoPath, "git checkout main");
await runGitCommand(logger, repoPath, `git branch -D ${branchName}`);
return {
error: `Package ${packageName} not found in package.json`,
status: "failed",
};
}
await updatePackageAndCommit({
logger,
packageName,
repoPath,
targetVersion,
});
void logger.log("info", `Pushing new branch ${branchName}...`);
await runGitCommand(logger, repoPath, `git push -u origin ${branchName}`);
await runGitCommand(logger, repoPath, "git checkout main");
return { branchName: branchName, status: "created" };
};
/**
* Creates or updates a branch for a dependency update.
* @param options - Configuration for the branch operation.
* @returns The result of the operation.
*/
const createOrUpdateBranch = async(
options: BranchUpdateOptions,
): Promise<UpdateResult> => {
const { branchName, clonedRepo, logger } = options;
const { path: repoPath } = clonedRepo;
try {
await runGitCommand(logger, repoPath, "git fetch origin");
const remoteBranches = await runGitCommand(
logger,
repoPath,
"git branch -r",
);
const branchExists = remoteBranches.includes(`origin/${branchName}`);
if (branchExists) {
return await handleExistingBranch(options);
}
return await handleNewBranch(options);
} catch (error: unknown) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Catch blocks receive unknown type, cast needed for logger.error
void logger.error("createOrUpdateBranch", error as Error);
try {
await runGitCommand(logger, repoPath, "git checkout main");
} catch {
// Ignore cleanup errors
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Catch blocks receive unknown type, cast needed to access message
return { error: (error as Error).message, status: "failed" };
}
};
export type { BranchUpdateOptions, ClonedRepository, UpdateResult };
export { cloneRepository, createOrUpdateBranch };