generated from nhcarrigan/template
feat: initial prototype attempt
This commit is contained in:
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user