generated from nhcarrigan/template
408 lines
13 KiB
TypeScript
408 lines
13 KiB
TypeScript
/**
|
|
* @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
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Runs pnpm install in the specified directory.
|
|
* @param logger - The logger instance.
|
|
* @param repoPath - The repository path.
|
|
*/
|
|
const runPnpmInstall = async(
|
|
logger: Logger,
|
|
repoPath: string,
|
|
): Promise<void> => {
|
|
const pnpmPath = "/home/naomi/.local/share/pnpm/pnpm";
|
|
/* eslint-disable capitalized-comments -- v8 coverage requires lowercase */
|
|
/* v8 ignore start -- @preserve */
|
|
/* eslint-enable capitalized-comments -- Re-enable rule */
|
|
const pnpmEnvironment = {
|
|
...process.env,
|
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Environment variables use SCREAMING_SNAKE_CASE
|
|
HOME: "/home/naomi",
|
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Environment variables use SCREAMING_SNAKE_CASE
|
|
PATH: `${process.env.PATH ?? ""}:/home/naomi/.local/share/pnpm`,
|
|
};
|
|
/* eslint-disable capitalized-comments -- v8 coverage requires lowercase */
|
|
/* v8 ignore stop -- @preserve */
|
|
/* eslint-enable capitalized-comments -- Re-enable rule */
|
|
try {
|
|
await execAsync(`${pnpmPath} install --no-frozen-lockfile --strict-peer-dependencies=false`, {
|
|
cwd: repoPath,
|
|
env: pnpmEnvironment,
|
|
});
|
|
} catch (pnpmError: unknown) {
|
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Catch blocks receive unknown, cast needed to access stderr/stdout */
|
|
const details = pnpmError as Error & { stderr?: string; stdout?: string };
|
|
|
|
/* eslint-disable capitalized-comments -- v8 coverage requires lowercase */
|
|
/* v8 ignore next 5 -- @preserve */
|
|
/* eslint-enable capitalized-comments -- Re-enable rule */
|
|
/* eslint-disable @typescript-eslint/strict-boolean-expressions, @typescript-eslint/prefer-nullish-coalescing -- Intentionally using || to treat empty strings as falsy */
|
|
const errorMessage = details.stderr || details.stdout || details.message;
|
|
/* eslint-enable @typescript-eslint/strict-boolean-expressions, @typescript-eslint/prefer-nullish-coalescing -- Re-enable rules */
|
|
|
|
void logger.log("info", `pnpm install failed: ${errorMessage}`);
|
|
throw pnpmError;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 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 runPnpmInstall(logger, 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,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Deletes a stale local branch if it exists.
|
|
* @param logger - The logger instance.
|
|
* @param repoPath - Path to the repository.
|
|
* @param branchName - Name of the branch to delete.
|
|
*/
|
|
const deleteStaleLocalBranch = async(
|
|
logger: Logger,
|
|
repoPath: string,
|
|
branchName: string,
|
|
): Promise<void> => {
|
|
const localBranchOutput = await runGitCommand(logger, repoPath, "git branch");
|
|
const localBranchNames = localBranchOutput.
|
|
split("\n").
|
|
map((line) => {
|
|
return line.replace(/^\*?\s*/, "").trim();
|
|
}).
|
|
filter((name) => {
|
|
return name.length > 0;
|
|
});
|
|
if (localBranchNames.includes(branchName)) {
|
|
await runGitCommand(logger, repoPath, `git branch -D ${branchName}`);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Fetches and checks out an existing remote branch.
|
|
* @param logger - The logger instance.
|
|
* @param repoPath - The repository path.
|
|
* @param branchName - The branch name to fetch and checkout.
|
|
* @returns True if successful, false if the branch no longer exists on remote.
|
|
*/
|
|
const fetchAndCheckoutRemoteBranch = async(
|
|
logger: Logger,
|
|
repoPath: string,
|
|
branchName: string,
|
|
): Promise<boolean> => {
|
|
try {
|
|
await runGitCommand(
|
|
logger,
|
|
repoPath,
|
|
`git fetch origin ${branchName}:refs/remotes/origin/${branchName}`,
|
|
);
|
|
await runGitCommand(
|
|
logger,
|
|
repoPath,
|
|
`git checkout -b ${branchName} origin/${branchName}`,
|
|
);
|
|
return true;
|
|
} catch {
|
|
// Branch may have been deleted between check and fetch
|
|
return false;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Handles updating an existing branch with a newer version.
|
|
* @param options - The branch update options.
|
|
* @returns The update result, or null if branch no longer exists.
|
|
*/
|
|
const handleExistingBranch = async(
|
|
options: BranchUpdateOptions,
|
|
): Promise<UpdateResult | null> => {
|
|
const { branchName, clonedRepo, logger, packageName, targetVersion }
|
|
= options;
|
|
const { path: repoPath } = clonedRepo;
|
|
|
|
await deleteStaleLocalBranch(logger, repoPath, branchName);
|
|
|
|
const checkedOut = await fetchAndCheckoutRemoteBranch(
|
|
logger,
|
|
repoPath,
|
|
branchName,
|
|
);
|
|
if (!checkedOut) {
|
|
// Branch was deleted on remote, fall back to creating new branch
|
|
return null;
|
|
}
|
|
|
|
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 {
|
|
// Use --prune to remove stale remote-tracking refs that no longer exist
|
|
await runGitCommand(logger, repoPath, "git fetch origin --prune");
|
|
const remoteBranches = await runGitCommand(
|
|
logger,
|
|
repoPath,
|
|
"git branch -r",
|
|
);
|
|
const branchExists = remoteBranches.includes(`origin/${branchName}`);
|
|
|
|
if (branchExists) {
|
|
const result = await handleExistingBranch(options);
|
|
// If null, branch was deleted on remote between check and fetch
|
|
if (result !== null) {
|
|
return result;
|
|
}
|
|
}
|
|
|
|
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 };
|