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
+178
View File
@@ -0,0 +1,178 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Logger } from "@nhcarrigan/logger";
import semver from "semver";
import type { NpmService } from "./npmService.js";
import type {
DependencyType,
DependencyUpdate,
PackageJson,
} from "../types/package.types.js";
const logger = new Logger("DependencyAnalyzer", process.env.LOG_TOKEN ?? "");
/**
* Checks if a version string is a valid semver range.
* @param version - The version string to validate.
* @returns True if the version is a valid semver range.
*/
const isValidSemverRange = (version: string): boolean => {
if (version.startsWith("file:")) {
return false;
}
if (version.startsWith("git:")) {
return false;
}
if (version.startsWith("http:")) {
return false;
}
if (version.startsWith("https:")) {
return false;
}
if (version.includes("github:")) {
return false;
}
if (version === "*" || version === "latest") {
return false;
}
return true;
};
/**
* Removes version prefixes for comparison.
* @param version - The version string to sanitise.
* @returns The cleaned version string.
*/
const cleanVersion = (version: string): string => {
return version.replace(/^[<=>^~]/, "");
};
/**
* Determines if an update is needed based on version comparison.
* @param currentVersion - The currently installed version.
* @param latestVersion - The latest available version.
* @returns True if an update is needed.
*/
const shouldUpdate = (
currentVersion: string,
latestVersion: string,
): boolean => {
try {
if (currentVersion === latestVersion) {
return false;
}
return semver.lt(currentVersion, latestVersion);
} catch (error) {
void logger.error(
`Error comparing versions: ${currentVersion} vs ${latestVersion}`,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Catch blocks receive unknown type, cast needed for logger.error
error as Error,
);
return false;
}
};
/**
* Service for analysing package dependencies and finding updates.
*/
// eslint-disable-next-line stylistic/padded-blocks -- Blank line needed before JSDoc per lines-around-comment rule
class DependencyAnalyzerService {
/**
* Creates a new DependencyAnalyzerService instance.
* @param npmService - The npm service for fetching package information.
*/
public constructor(private readonly npmService: NpmService) {}
/**
* Analyses a package.json and finds available updates.
* @param packageJson - The parsed package.json content.
* @returns Array of available dependency updates.
*/
public async analyzePackageJson(
packageJson: PackageJson,
): Promise<Array<DependencyUpdate>> {
const updates: Array<DependencyUpdate> = [];
const dependencyTypes: Array<DependencyType> = [
"dependencies",
"devDependencies",
"peerDependencies",
"optionalDependencies",
];
for (const type of dependencyTypes) {
const deps = packageJson[type];
if (deps === undefined) {
continue;
}
for (const [ packageName, currentVersion ] of Object.entries(deps)) {
if (!isValidSemverRange(currentVersion)) {
continue;
}
// eslint-disable-next-line no-await-in-loop -- Sequential dependency checks are required
const update = await this.checkForUpdate(
packageName,
currentVersion,
type,
);
if (update !== null) {
updates.push(update);
}
}
}
return updates;
}
/**
* Checks if a specific package has an available update.
* @param packageName - The name of the package to check.
* @param currentVersion - The currently installed version.
* @param type - The dependency category (dependencies, devDependencies, etc.).
* @returns The update information or null if no update available.
*/
private async checkForUpdate(
packageName: string,
currentVersion: string,
type: DependencyType,
): Promise<DependencyUpdate | null> {
try {
const packageInfo = await this.npmService.getPackageInfo(packageName);
if (packageInfo === null) {
return null;
}
const latestVersion = packageInfo["dist-tags"].latest;
const cleanCurrentVersion = cleanVersion(currentVersion);
if (shouldUpdate(cleanCurrentVersion, latestVersion)) {
return {
currentVersion,
latestVersion,
packageName,
type,
};
}
return null;
} catch (error) {
await logger.error(
`Error checking update for ${packageName}`,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Catch blocks receive unknown type, cast needed for logger.error
error as Error,
);
return null;
}
}
}
export { DependencyAnalyzerService };
+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 };
+147
View File
@@ -0,0 +1,147 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import axios, { isAxiosError, type AxiosInstance } from "axios";
import { config } from "../config.js";
import type {
GiteaFile,
GiteaPullRequest,
GiteaRepository,
} from "../types/gitea.types.js";
interface CreatePullRequestOptions {
base: string;
body: string;
head: string;
owner: string;
repo: string;
title: string;
}
interface GetFileContentOptions {
owner: string;
path: string;
reference?: string;
repo: string;
}
/**
* Service for interacting with the Gitea API.
*/
class GiteaService {
private readonly client: AxiosInstance;
/**
* Creates a new GiteaService instance.
* @throws Error if GITEA_TOKEN environment variable is not set.
*/
public constructor() {
const token = process.env.GITEA_TOKEN;
if (token === undefined || token === "") {
throw new Error("GITEA_TOKEN environment variable is required");
}
this.client = axios.create({
baseURL: `${config.giteaUrl}/api/v1`,
/* eslint-disable @typescript-eslint/naming-convention -- HTTP headers use PascalCase by convention */
headers: {
"Authorization": `token ${token}`,
"Content-Type": "application/json",
},
/* eslint-enable @typescript-eslint/naming-convention -- End HTTP headers */
});
}
/**
* Creates a new pull request in a repository.
* @param options - The PR creation options.
* @returns The created pull request.
*/
public async createPullRequest(
options: CreatePullRequestOptions,
): Promise<GiteaPullRequest> {
const { base, body, head, owner, repo, title } = options;
const { data } = await this.client.post<GiteaPullRequest>(
`/repos/${owner}/${repo}/pulls`,
{ base, body, head, title },
);
return data;
}
/**
* Gets the content of a file in a repository.
* @param options - Configuration specifying the file path and repository.
* @returns The file content or null if not found.
*/
public async getFileContent(
options: GetFileContentOptions,
): Promise<GiteaFile | null> {
const { owner, path, reference, repo } = options;
try {
const { data } = await this.client.get<GiteaFile>(
`/repos/${owner}/${repo}/contents/${path}`,
{ params: { ref: reference } },
);
return data;
} catch (error) {
if (isAxiosError(error) && error.response?.status === 404) {
return null;
}
throw error;
}
}
/**
* Lists all repositories in the configured organisation.
* @returns Array of non-archived, non-disabled, non-mirror repositories.
*/
public async listOrgRepositories(): Promise<Array<GiteaRepository>> {
const repositories: Array<GiteaRepository> = [];
let page = 1;
const limit = 100;
let hasMore = true;
while (hasMore) {
// eslint-disable-next-line no-await-in-loop -- Sequential pagination is required here
const { data } = await this.client.get<Array<GiteaRepository>>(
`/orgs/${config.giteaOrg}/repos`,
{ params: { limit, page } },
);
if (data.length === 0) {
hasMore = false;
} else {
repositories.push(...data);
page = page + 1;
}
}
return repositories.filter((repo) => {
return !repo.archived && !repo.disabled && !repo.mirror;
});
}
/**
* Lists pull requests in a repository.
* @param owner - The repository owner.
* @param repo - The repository name.
* @param state - The PR state filter.
* @returns Array of pull requests.
*/
public async listPullRequests(
owner: string,
repo: string,
state: "all" | "closed" | "open" = "open",
): Promise<Array<GiteaPullRequest>> {
const { data } = await this.client.get<Array<GiteaPullRequest>>(
`/repos/${owner}/${repo}/pulls`,
{ params: { state } },
);
return data;
}
}
export { GiteaService };
+216
View File
@@ -0,0 +1,216 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Logger } from "@nhcarrigan/logger";
import axios, { isAxiosError, type AxiosInstance } from "axios";
import { config } from "../config.js";
import type { NpmPackageInfo } from "../types/package.types.js";
const logger = new Logger("NpmService", process.env.LOG_TOKEN ?? "");
/* eslint-disable @typescript-eslint/naming-convention -- GitHub API response types use snake_case property names */
interface GitHubRelease {
body?: string;
tag_name: string;
}
/* eslint-enable @typescript-eslint/naming-convention -- End GitHub API types */
interface ChangelogOptions {
fromVersion: string;
packageName: string;
toVersion: string;
}
interface GitHubReleaseOptions {
fromVersion: string;
owner: string;
repo: string;
toVersion: string;
}
/**
* Checks if a version is within a range.
* @param version - The version to check.
* @param from - The lower bound (exclusive).
* @param to - The upper bound (inclusive).
* @returns True if version is in range.
*/
const isVersionInRange = (
version: string,
from: string,
to: string,
): boolean => {
return version > from && version <= to;
};
/**
* Extracts repository information from a GitHub URL.
* @param repoUrl - The repository URL.
* @returns The owner and repo, or null if not a GitHub URL.
*/
const extractGitHubInfo = (
repoUrl: string,
): { owner: string; repo: string } | null => {
if (!repoUrl.includes("github.com")) {
return null;
}
const parts = repoUrl.split("github.com/")[1]?.split("/");
if (parts === undefined || parts.length < 2) {
return null;
}
const [ owner, repo ] = parts;
// eslint-disable-next-line capitalized-comments -- v8 coverage ignore directive must be lowercase
/* v8 ignore next 3 -- @preserve */
if (owner === undefined || repo === undefined) {
return null;
}
return { owner, repo };
};
/**
* Normalises a repository URL to HTTPS format.
* @param url - The original repository URL.
* @returns The normalised URL.
*/
const normaliseRepoUrl = (url: string): string => {
return url.
replace(/^git\+/, "").
replace(/\.git$/, "").
replace(/^git:\/\//, "https://").
replace(/^ssh:\/\/git@/, "https://");
};
/**
* Fetches release notes from GitHub.
* @param options - The GitHub release fetch options.
* @returns Formatted changelog string.
*/
const fetchGitHubReleases = async(
options: GitHubReleaseOptions,
): Promise<string> => {
const { fromVersion, owner, repo, toVersion } = options;
const fallbackMessage = `Updated from ${fromVersion} to ${toVersion}`;
try {
const { data: releases } = await axios.get<Array<GitHubRelease>>(
`https://api.github.com/repos/${owner}/${repo}/releases`,
{
/* eslint-disable @typescript-eslint/naming-convention -- HTTP headers use PascalCase by convention */
headers: {
Accept: "application/vnd.github.v3+json",
},
/* eslint-enable @typescript-eslint/naming-convention -- End HTTP headers */
},
);
const relevantReleases = releases.filter((release) => {
const tagName = release.tag_name.replace(/^v/, "");
return isVersionInRange(tagName, fromVersion, toVersion);
});
if (relevantReleases.length === 0) {
return fallbackMessage;
}
let changelog = `## Changelog\n\n`;
for (const release of relevantReleases) {
const version = release.tag_name;
const body = release.body ?? "No release notes available";
changelog = `${changelog}### ${version}\n\n${body}\n\n`;
}
return changelog;
} catch {
return fallbackMessage;
}
};
/**
* Service for interacting with the npm registry.
*/
class NpmService {
private readonly client: AxiosInstance;
/**
* Creates a new NpmService instance.
*/
public constructor() {
this.client = axios.create({
baseURL: config.npmRegistryUrl,
timeout: 10_000,
});
}
/**
* Fetches changelog information for a package update.
* @param options - The changelog fetch options.
* @returns Formatted changelog string.
*/
public async getPackageChangelog(options: ChangelogOptions): Promise<string> {
const { fromVersion, packageName, toVersion } = options;
const fallbackMessage = `Updated from ${fromVersion} to ${toVersion}`;
try {
const packageInfo = await this.getPackageInfo(packageName);
if (packageInfo === null) {
return `No changelog available for ${packageName}`;
}
const repository = packageInfo.versions[toVersion]?.repository;
if (repository?.url === undefined) {
return fallbackMessage;
}
const repoUrl = normaliseRepoUrl(repository.url);
const githubInfo = extractGitHubInfo(repoUrl);
if (githubInfo === null) {
return fallbackMessage;
}
return await fetchGitHubReleases({
fromVersion: fromVersion,
owner: githubInfo.owner,
repo: githubInfo.repo,
toVersion: toVersion,
});
} catch (error) {
void logger.error(
`Error fetching changelog for ${packageName}`,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Catch blocks receive unknown type, cast needed for logger.error
error as Error,
);
return fallbackMessage;
}
}
/**
* Fetches package information from the npm registry.
* @param packageName - The name of the package to look up.
* @returns Package info or null if not found.
*/
public async getPackageInfo(
packageName: string,
): Promise<NpmPackageInfo | null> {
try {
const { data } = await this.client.get<NpmPackageInfo>(
`/${encodeURIComponent(packageName)}`,
);
return data;
} catch (error) {
if (isAxiosError(error) && error.response?.status === 404) {
return null;
}
throw error;
}
}
}
export { NpmService };
+258
View File
@@ -0,0 +1,258 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Logger } from "@nhcarrigan/logger";
import { config } from "../config.js";
import { DependencyAnalyzerService } from "./dependencyAnalyzerService.js";
import { GiteaService } from "./giteaService.js";
import {
cloneRepository,
createOrUpdateBranch,
type ClonedRepository,
} from "./gitService.js";
import { NpmService } from "./npmService.js";
import type { GiteaRepository } from "../types/gitea.types.js";
import type { DependencyUpdate, PackageJson } from "../types/package.types.js";
const logger = new Logger("UpdateOrchestrator", process.env.LOG_TOKEN ?? "");
/**
* Strips version prefix characters from a version string.
* @param version - The version string with potential prefixes.
* @returns The version without prefix characters.
*/
const stripVersionPrefix = (version: string): string => {
return version.replace(/^[<=>^~]*/, "");
};
/**
* Generates the body content for a PR.
* @param update - The dependency update information.
* @param changelog - The changelog content.
* @returns The formatted PR body.
*/
const generatePRBody = (
update: DependencyUpdate,
changelog: string,
): string => {
return `## Dependency Update
Updates **${update.packageName}** from \`${update.currentVersion}\` to \`${update.latestVersion}\`.
### Type
${update.type}
### Changelog
${changelog}
---
✨ This PR was created by Minori, your friendly dependency updater! 🌸`;
};
/**
* Logs the result of a branch update operation.
* @param update - The dependency update being processed.
* @param result - The branch update operation result.
* @param result.error - Error message if the operation failed.
* @param result.status - The operation status (up-to-date, failed, updated, created).
* @returns True if the PR should be created, false otherwise.
*/
const logBranchUpdateResult = async(
update: DependencyUpdate,
result: { error?: string; status: string },
): Promise<boolean> => {
if (result.status === "up-to-date") {
await logger.log(
"info",
` ${update.packageName} branch already at ${update.latestVersion}, skipping...`,
);
return false;
}
if (result.status === "failed") {
await logger.log(
"warn",
` Failed to update ${update.packageName}: ${result.error ?? "Unknown error"}`,
);
return false;
}
if (result.status === "updated") {
await logger.log(
"info",
` Updated existing branch for ${update.packageName} to ${update.latestVersion}`,
);
return false;
}
return true;
};
/**
* Service for orchestrating dependency updates across repositories.
*/
class UpdateOrchestratorService {
private readonly dependencyAnalyzer: DependencyAnalyzerService;
private readonly giteaService: GiteaService;
private readonly giteaToken: string;
private readonly npmService: NpmService;
/**
* Creates a new UpdateOrchestratorService instance.
* @throws Error if GITEA_TOKEN environment variable is not set.
*/
public constructor() {
const token = process.env.GITEA_TOKEN;
if (token === undefined || token === "") {
throw new Error("GITEA_TOKEN environment variable is required");
}
this.giteaToken = token;
this.giteaService = new GiteaService();
this.npmService = new NpmService();
this.dependencyAnalyzer = new DependencyAnalyzerService(this.npmService);
}
/**
* Checks and updates dependencies for all repositories.
*/
public async checkAndUpdateAllRepositories(): Promise<void> {
await logger.log(
"info",
"Starting dependency update check for all repositories...",
);
const repositories = await this.giteaService.listOrgRepositories();
await logger.log(
"info",
`Found ${String(repositories.length)} repositories to check`,
);
for (const repo of repositories) {
try {
// eslint-disable-next-line no-await-in-loop -- Sequential repository processing is required
await this.processRepository(repo);
} catch (error) {
// eslint-disable-next-line no-await-in-loop -- Sequential processing is required
await logger.error(
`Error processing repository ${repo.name}`,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Catch blocks receive unknown type
error as Error,
);
}
}
await logger.log("info", "Dependency update check complete!");
}
/**
* Creates or updates a PR for a dependency update.
* @param repo - The repository information.
* @param update - The dependency update details.
* @param clonedRepo - The cloned repository.
*/
private async createUpdatePR(
repo: GiteaRepository,
update: DependencyUpdate,
clonedRepo: ClonedRepository,
): Promise<void> {
const branchName
= `${config.prBranchPrefix}${update.packageName.replaceAll(/[/@]/g, "-")}`;
try {
const result = await createOrUpdateBranch({
branchName: branchName,
clonedRepo: clonedRepo,
logger: logger,
packageName: update.packageName,
targetVersion: update.latestVersion,
});
const shouldCreatePR = await logBranchUpdateResult(update, result);
if (!shouldCreatePR) {
return;
}
const changelog = await this.npmService.getPackageChangelog({
fromVersion: stripVersionPrefix(update.currentVersion),
packageName: update.packageName,
toVersion: update.latestVersion,
});
await this.giteaService.createPullRequest({
base: repo.default_branch,
body: generatePRBody(update, changelog),
head: branchName,
owner: config.giteaOrg,
repo: repo.name,
title: `deps: update ${update.packageName} to ${update.latestVersion}`,
});
await logger.log("info", ` Created PR for ${update.packageName}`);
} catch (error) {
await logger.error(
` Error creating PR for ${update.packageName}`,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Catch blocks receive unknown type
error as Error,
);
}
}
/**
* Processes a single repository for dependency updates.
* @param repo - The repository to process.
*/
private async processRepository(repo: GiteaRepository): Promise<void> {
await logger.log("info", `\nChecking repository: ${repo.name}`);
const packageJsonFile = await this.giteaService.getFileContent({
owner: config.giteaOrg,
path: "package.json",
reference: repo.default_branch,
repo: repo.name,
});
if (packageJsonFile === null || packageJsonFile.type !== "file") {
await logger.log("info", ` No package.json found, skipping...`);
return;
}
const packageJsonContent = Buffer.from(
packageJsonFile.content ?? "",
"base64",
).toString("utf-8");
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Dynamic JSON parsing requires type assertion
const packageJson: PackageJson = JSON.parse(
packageJsonContent,
) as PackageJson;
const updates
= await this.dependencyAnalyzer.analyzePackageJson(packageJson);
if (updates.length === 0) {
await logger.log("info", ` All dependencies are up to date!`);
return;
}
await logger.log(
"info",
` Found ${String(updates.length)} dependencies to update`,
);
const clonedRepo = await cloneRepository(
logger,
repo.name,
this.giteaToken,
);
try {
for (const update of updates) {
// eslint-disable-next-line no-await-in-loop -- Sequential update processing is required
await this.createUpdatePR(repo, update, clonedRepo);
}
} finally {
await clonedRepo.cleanup();
}
}
}
export { UpdateOrchestratorService };