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
+47
View File
@@ -0,0 +1,47 @@
name: Node.js CI
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
ci:
name: CI
runs-on: ubuntu-latest
steps:
- name: Checkout Source Files
uses: actions/checkout@v4
- name: Use Node.js v24
uses: actions/setup-node@v4
with:
node-version: 24
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 10
- name: Ensure Dependencies are Pinned
uses: naomi-lgbt/dependency-pin-check@main
with:
language: javascript
dev-dependencies: true
peer-dependencies: true
optional-dependencies: true
- name: Install Dependencies
run: pnpm install
- name: Lint Source Files
run: pnpm run lint
- name: Verify Build
run: pnpm run build
- name: Run Tests
run: pnpm run test
+3
View File
@@ -0,0 +1,3 @@
node_modules
prod
coverage
+134 -19
View File
@@ -1,39 +1,154 @@
# New Repository Template
# Minori - Dependency Update Manager
This template contains all of our basic files for a new GitHub repository. There is also a handy workflow that will create an issue on a new repository made from this template, with a checklist for the steps we usually take in setting up a new repository.
Minori is an automated dependency management system for Gitea repositories. It checks all repositories in your organisation for outdated npm dependencies and creates pull requests with changelogs for each update.
If you're starting a Node.JS project with TypeScript, we have a [specific template](https://github.com/naomi-lgbt/nodejs-typescript-template) for that purpose.
## Features
## Readme
- 🔍 Scans all repositories in a Gitea organisation
- 📦 Checks npm dependencies for updates
- 📝 Fetches changelogs from GitHub releases when available
- 🔄 Creates individual PRs for each dependency update
- ⏰ Runs on a configurable schedule or one-time
- 🌸 Adds a friendly signature to each PR
Delete all of the above text (including this line), and uncomment the below text to use our standard readme template.
## Prerequisites
<!-- # Project Name
- Node.js v20 or higher
- pnpm package manager
- A Gitea instance with API access
- 1Password CLI (for secret management)
Project Description
## Installation
## Live Version
1. Clone the repository:
```bash
git clone https://git.nhcarrigan.com/nhcarrigan/minori.git
cd minori
```
This page is currently deployed. [View the live website.]
2. Install dependencies:
```bash
pnpm install
```
## Feedback and Bugs
3. Build the project:
```bash
pnpm build
```
If you have feedback or a bug report, please [log a ticket on our forum](https://support.nhcarrigan.com).
## Configuration
## Contributing
Minori uses the `prod.env` file for secrets management with 1Password vault references:
If you would like to contribute to the project, you may create a Pull Request containing your proposed changes and we will review it as soon as we are able! Please review our [contributing guidelines](CONTRIBUTING.md) first.
```bash
# Gitea Authentication
GITEA_TOKEN=op://Personal/Gitea Personal Access Token/credential
```
## Code of Conduct
Other configuration values are set in `src/config.ts`:
- `GITEA_URL`: https://git.nhcarrigan.com
- `GITEA_ORG`: nhcarrigan
- `CHECK_INTERVAL`: 0 7 * * * (daily at 7am)
- `PR_BRANCH_PREFIX`: dependencies/update-
Before interacting with our community, please read our [Code of Conduct](CODE_OF_CONDUCT.md).
### Required Permissions
Your Gitea personal access token needs the following permissions:
- Read access to repositories
- Write access to create branches
- Write access to create pull requests
- Write access to push commits
## Usage
### Run Once (Testing)
To run a single dependency check without scheduling:
```bash
RUN_ONCE=true op run --env-file=prod.env -- node prod/index.js
```
Note: `RUN_ONCE` is a runtime flag, not a configuration value
### Run as Service
To run continuously on a schedule:
```bash
op run --env-file=prod.env -- node prod/index.js
```
### Systemd Service (Production)
Create a systemd service file at `/etc/systemd/system/minori.service`:
```ini
[Unit]
Description=Minori Dependency Update Manager
After=network.target
[Service]
Type=simple
User=naomi
WorkingDirectory=/path/to/minori
ExecStart=/usr/bin/op run --env-file=prod.env -- /usr/bin/node prod/index.js
Restart=on-failure
RestartSec=30
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
```
Enable and start the service:
```bash
sudo systemctl enable minori
sudo systemctl start minori
```
## How It Works
1. **Repository Discovery**: Minori fetches all repositories from your Gitea organisation
2. **Package Analysis**: For each repository with a `package.json`, it checks all dependencies
3. **Version Comparison**: Compares current versions with latest npm releases
4. **PR Creation**: For each outdated dependency:
- Creates a new branch
- Updates the version in `package.json`
- Fetches changelog information
- Creates a pull request with details
5. **Deduplication**: Skips creating PRs if one already exists for that dependency
## Development
```bash
# Install dependencies
pnpm install
# Run linting
pnpm lint
# Build the project
pnpm build
# Run tests
pnpm test
```
## Scripts
- `pnpm lint` - Run ESLint
- `pnpm build` - Build TypeScript to JavaScript
- `pnpm start` - Run the built application with 1Password
- `pnpm test` - Run tests (placeholder)
## License
This software is licensed under our [global software license](https://docs.nhcarrigan.com/#/license).
See LICENSE.md
Copyright held by Naomi Carrigan.
## Credits
## Contact
Created with 💖 by Naomi Carrigan
We may be contacted through our [Chat Server](http://chat.nhcarrigan.com) or via email at `contact@nhcarrigan.com`. -->
✨ Minori was built with help from Hikari~ 🌸
+5
View File
@@ -0,0 +1,5 @@
import NaomisConfig from "@nhcarrigan/eslint-config";
export default [
...NaomisConfig,
];
+35
View File
@@ -0,0 +1,35 @@
{
"name": "minori",
"version": "1.0.0",
"description": "Automated dependency updater for Gitea repositories",
"main": "./prod/index.js",
"type": "module",
"scripts": {
"lint": "eslint src test --max-warnings 0",
"build": "tsc",
"start": "op run --env-file=prod.env -- node prod/index.js",
"test": "vitest run --coverage"
},
"keywords": [],
"author": "Naomi Carrigan",
"license": "See LICENSE.md",
"packageManager": "pnpm@10.28.1",
"devDependencies": {
"@nhcarrigan/eslint-config": "5.2.0",
"@nhcarrigan/typescript-config": "4.0.0",
"@types/node": "25.2.0",
"@types/node-cron": "3.0.11",
"@types/semver": "7.7.1",
"@vitest/coverage-istanbul": "^4.0.18",
"@vitest/coverage-v8": "^4.0.18",
"eslint": "9.39.2",
"typescript": "5.9.3",
"vitest": "^4.0.18"
},
"dependencies": {
"@nhcarrigan/logger": "1.1.1",
"axios": "1.13.4",
"node-cron": "4.2.1",
"semver": "7.7.3"
}
}
+4573
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
# Minori Environment Configuration
# Uses 1Password references - safe to commit!
# Gitea Authentication
GITEA_TOKEN=op://Personal/Gitea Personal Access Token/credential
LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth"
+26
View File
@@ -0,0 +1,26 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
const config = {
checkInterval: "0 7 * * *",
giteaOrg: "nhcarrigan",
giteaToken: process.env.GITEA_TOKEN ?? "",
giteaUrl: "https://git.nhcarrigan.com",
npmRegistryUrl: "https://registry.npmjs.org",
prBranchPrefix: "dependencies/update-",
};
/**
* Validates the configuration.
* @throws Error if required configuration is missing.
*/
const validateConfig = (): void => {
if (config.giteaToken === "") {
throw new Error("GITEA_TOKEN is required");
}
};
export { config, validateConfig };
+75
View File
@@ -0,0 +1,75 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Logger } from "@nhcarrigan/logger";
import cron from "node-cron";
import { config, validateConfig } from "./config.js";
import {
UpdateOrchestratorService,
} from "./services/updateOrchestratorService.js";
const logger = new Logger("Minori", process.env.LOG_TOKEN ?? "");
/**
* Main entry point for the application.
*/
const main = async(): Promise<void> => {
await logger.log("info", "🌸 Minori - Dependency Update Manager");
await logger.log("info", "=====================================\n");
try {
validateConfig();
} catch (error) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Catch blocks receive unknown type, cast needed for logger.error
await logger.error("Configuration error", error as Error);
process.exit(1);
}
const orchestrator = new UpdateOrchestratorService();
const runOnce = process.env.RUN_ONCE === "true";
if (runOnce) {
await logger.log("info", "Running dependency check once...\n");
await orchestrator.checkAndUpdateAllRepositories();
process.exit(0);
} else {
await logger.log(
"info",
`Scheduling dependency checks: ${config.checkInterval}`,
);
await logger.log("info", "Press Ctrl+C to stop\n");
await orchestrator.checkAndUpdateAllRepositories();
cron.schedule(config.checkInterval, (): void => {
void (async(): Promise<void> => {
await logger.log(
"info",
"\n--- Scheduled dependency check starting ---",
);
await orchestrator.checkAndUpdateAllRepositories();
})();
});
}
};
process.on("SIGINT", (): void => {
void logger.log("info", "\n\n✨ Minori shutting down gracefully...");
process.exit(0);
});
process.on("SIGTERM", (): void => {
void logger.log("info", "\n\n✨ Minori shutting down gracefully...");
process.exit(0);
});
// eslint-disable-next-line unicorn/prefer-top-level-await -- Async main pattern requires .catch() for unhandled rejection handling
main().catch((error: unknown): void => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Catch blocks receive unknown type, cast needed for logger.error
void logger.error("Fatal error", error as Error);
process.exit(1);
});
+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 };
+44
View File
@@ -0,0 +1,44 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable @typescript-eslint/naming-convention -- Gitea API response types use snake_case property names */
interface GiteaRepository {
archived: boolean;
clone_url: string;
default_branch: string;
disabled: boolean;
full_name: string;
id: number;
mirror: boolean;
name: string;
}
interface GiteaFile {
content?: string;
encoding?: string;
path: string;
sha: string;
type: string;
}
interface GiteaPullRequest {
base: {
ref: string;
sha: string;
};
body: string;
head: {
ref: string;
sha: string;
};
id: number;
number: number;
state: "closed" | "open";
title: string;
}
/* eslint-enable @typescript-eslint/naming-convention -- End Gitea API types */
export type { GiteaFile, GiteaPullRequest, GiteaRepository };
+59
View File
@@ -0,0 +1,59 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
type DependencyType =
| "dependencies"
| "devDependencies"
| "optionalDependencies"
| "peerDependencies";
interface PackageJson {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
name?: string;
optionalDependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
version?: string;
}
interface DependencyUpdate {
currentVersion: string;
latestVersion: string;
packageName: string;
type: DependencyType;
}
/* eslint-disable @typescript-eslint/naming-convention -- Npm registry API response uses hyphenated property names */
interface NpmPackageInfo {
"dist-tags": {
[key: string]: string;
latest: string;
};
"name": string;
"versions": Record<
string,
{
repository?: {
url?: string;
};
version: string;
}
>;
}
/* eslint-enable @typescript-eslint/naming-convention -- End npm API types */
interface ChangelogInfo {
changes: string;
version: string;
}
export type {
ChangelogInfo,
DependencyType,
DependencyUpdate,
NpmPackageInfo,
PackageJson,
};
+11
View File
@@ -0,0 +1,11 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Logger } from "@nhcarrigan/logger";
const logger = new Logger("Minori", process.env.LOG_TOKEN ?? "");
export { logger };
+71
View File
@@ -0,0 +1,71 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable vitest/valid-expect -- Test expectations don't need messages */
/* eslint-disable max-nested-callbacks -- Vitest structure requires nested callbacks */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe("config", () => {
const originalEnvironment = process.env;
beforeEach(() => {
vi.resetModules();
process.env = { ...originalEnvironment };
});
afterEach(() => {
process.env = originalEnvironment;
});
it("should have the correct default values", async() => {
expect.assertions(5);
process.env.GITEA_TOKEN = "test-token";
const { config } = await import("../src/config.js");
expect(config.checkInterval).toBe("0 7 * * *");
expect(config.giteaOrg).toBe("nhcarrigan");
expect(config.giteaUrl).toBe("https://git.nhcarrigan.com");
expect(config.npmRegistryUrl).toBe("https://registry.npmjs.org");
expect(config.prBranchPrefix).toBe("dependencies/update-");
});
it("should use GITEA_TOKEN from environment", async() => {
expect.assertions(1);
process.env.GITEA_TOKEN = "my-secret-token";
const { config } = await import("../src/config.js");
expect(config.giteaToken).toBe("my-secret-token");
});
it("should default giteaToken to empty string when not set", async() => {
expect.assertions(1);
delete process.env.GITEA_TOKEN;
const { config } = await import("../src/config.js");
expect(config.giteaToken).toBe("");
});
it("should not throw when GITEA_TOKEN is set", async() => {
expect.assertions(1);
process.env.GITEA_TOKEN = "valid-token";
const { validateConfig } = await import("../src/config.js");
expect(() => {
validateConfig();
}).not.toThrow();
});
it("should throw when GITEA_TOKEN is empty", async() => {
expect.assertions(1);
delete process.env.GITEA_TOKEN;
const { validateConfig } = await import("../src/config.js");
expect(() => {
validateConfig();
}).toThrow("GITEA_TOKEN is required");
});
});
+16
View File
@@ -0,0 +1,16 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable vitest/valid-expect -- Test expectations don't need messages */
import { describe, expect, it } from "vitest";
describe("index", () => {
it("should export from the module", () => {
expect.assertions(1);
expect(true).toBeTruthy();
});
});
@@ -0,0 +1,294 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable vitest/valid-expect -- Test expectations don't need messages */
/* eslint-disable max-lines-per-function -- Test suites require many test cases */
/* eslint-disable @typescript-eslint/naming-convention -- Test data uses npm package names and destructured imports */
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Required for mocking */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@nhcarrigan/logger", () => {
return {
Logger: class MockLogger {
public error = vi.fn();
public log = vi.fn();
},
};
});
interface MockNpmService {
getPackageChangelog: ReturnType<typeof vi.fn>;
getPackageInfo: ReturnType<typeof vi.fn>;
}
const createMockNpmService = (): MockNpmService => {
return {
getPackageChangelog: vi.fn(),
getPackageInfo: vi.fn(),
};
};
describe("dependencyAnalyzerService", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
it("should return empty array when no dependencies", async() => {
expect.assertions(1);
const mockNpmService = createMockNpmService();
const { DependencyAnalyzerService }
= await import("../../src/services/dependencyAnalyzerService.js");
const analyzerService = new DependencyAnalyzerService(
mockNpmService as Parameters<typeof DependencyAnalyzerService>[0],
);
const result = await analyzerService.analyzePackageJson({});
expect(result).toStrictEqual([]);
});
it("should find updates for dependencies", async() => {
expect.assertions(2);
const mockNpmService = createMockNpmService();
mockNpmService.getPackageInfo.mockResolvedValue({
"dist-tags": { latest: "2.0.0" },
"name": "test-package",
"versions": {},
});
const { DependencyAnalyzerService }
= await import("../../src/services/dependencyAnalyzerService.js");
const analyzerService = new DependencyAnalyzerService(
mockNpmService as Parameters<typeof DependencyAnalyzerService>[0],
);
const result = await analyzerService.analyzePackageJson({
dependencies: {
"test-package": "1.0.0",
},
});
expect(result).toHaveLength(1);
expect(result[0]).toStrictEqual({
currentVersion: "1.0.0",
latestVersion: "2.0.0",
packageName: "test-package",
type: "dependencies",
});
});
it("should skip file: protocol dependencies", async() => {
expect.assertions(2);
const mockNpmService = createMockNpmService();
const { DependencyAnalyzerService }
= await import("../../src/services/dependencyAnalyzerService.js");
const analyzerService = new DependencyAnalyzerService(
mockNpmService as Parameters<typeof DependencyAnalyzerService>[0],
);
const result = await analyzerService.analyzePackageJson({
dependencies: {
"local-package": "file:../local-package",
},
});
expect(result).toStrictEqual([]);
expect(mockNpmService.getPackageInfo).not.toHaveBeenCalled();
});
it("should skip git: protocol dependencies", async() => {
expect.assertions(1);
const mockNpmService = createMockNpmService();
const { DependencyAnalyzerService }
= await import("../../src/services/dependencyAnalyzerService.js");
const analyzerService = new DependencyAnalyzerService(
mockNpmService as Parameters<typeof DependencyAnalyzerService>[0],
);
const result = await analyzerService.analyzePackageJson({
dependencies: {
"git-package": "git:github.com/user/repo",
},
});
expect(result).toStrictEqual([]);
});
it("should skip http: protocol dependencies", async() => {
expect.assertions(1);
const mockNpmService = createMockNpmService();
const { DependencyAnalyzerService }
= await import("../../src/services/dependencyAnalyzerService.js");
const analyzerService = new DependencyAnalyzerService(
mockNpmService as Parameters<typeof DependencyAnalyzerService>[0],
);
const result = await analyzerService.analyzePackageJson({
dependencies: {
"http-package": "http://example.com/package.tgz",
},
});
expect(result).toStrictEqual([]);
});
it("should skip https: protocol dependencies", async() => {
expect.assertions(1);
const mockNpmService = createMockNpmService();
const { DependencyAnalyzerService }
= await import("../../src/services/dependencyAnalyzerService.js");
const analyzerService = new DependencyAnalyzerService(
mockNpmService as Parameters<typeof DependencyAnalyzerService>[0],
);
const result = await analyzerService.analyzePackageJson({
dependencies: {
"https-package": "https://example.com/package.tgz",
},
});
expect(result).toStrictEqual([]);
});
it("should skip github: shorthand dependencies", async() => {
expect.assertions(1);
const mockNpmService = createMockNpmService();
const { DependencyAnalyzerService }
= await import("../../src/services/dependencyAnalyzerService.js");
const analyzerService = new DependencyAnalyzerService(
mockNpmService as Parameters<typeof DependencyAnalyzerService>[0],
);
const result = await analyzerService.analyzePackageJson({
dependencies: {
"github-package": "github:user/repo",
},
});
expect(result).toStrictEqual([]);
});
it("should skip * version dependencies", async() => {
expect.assertions(1);
const mockNpmService = createMockNpmService();
const { DependencyAnalyzerService }
= await import("../../src/services/dependencyAnalyzerService.js");
const analyzerService = new DependencyAnalyzerService(
mockNpmService as Parameters<typeof DependencyAnalyzerService>[0],
);
const result = await analyzerService.analyzePackageJson({
dependencies: {
"star-package": "*",
},
});
expect(result).toStrictEqual([]);
});
it("should skip latest version dependencies", async() => {
expect.assertions(1);
const mockNpmService = createMockNpmService();
const { DependencyAnalyzerService }
= await import("../../src/services/dependencyAnalyzerService.js");
const analyzerService = new DependencyAnalyzerService(
mockNpmService as Parameters<typeof DependencyAnalyzerService>[0],
);
const result = await analyzerService.analyzePackageJson({
dependencies: {
"latest-package": "latest",
},
});
expect(result).toStrictEqual([]);
});
it("should not include packages that are already up-to-date", async() => {
expect.assertions(1);
const mockNpmService = createMockNpmService();
mockNpmService.getPackageInfo.mockResolvedValue({
"dist-tags": { latest: "1.0.0" },
"name": "test-package",
"versions": {},
});
const { DependencyAnalyzerService }
= await import("../../src/services/dependencyAnalyzerService.js");
const analyzerService = new DependencyAnalyzerService(
mockNpmService as Parameters<typeof DependencyAnalyzerService>[0],
);
const result = await analyzerService.analyzePackageJson({
dependencies: {
"test-package": "1.0.0",
},
});
expect(result).toStrictEqual([]);
});
it("should handle packages not found on npm", async() => {
expect.assertions(1);
const mockNpmService = createMockNpmService();
mockNpmService.getPackageInfo.mockResolvedValue(null);
const { DependencyAnalyzerService }
= await import("../../src/services/dependencyAnalyzerService.js");
const analyzerService = new DependencyAnalyzerService(
mockNpmService as Parameters<typeof DependencyAnalyzerService>[0],
);
const result = await analyzerService.analyzePackageJson({
dependencies: {
"non-existent": "1.0.0",
},
});
expect(result).toStrictEqual([]);
});
it("should handle version prefixes like ^", async() => {
expect.assertions(2);
const mockNpmService = createMockNpmService();
mockNpmService.getPackageInfo.mockResolvedValue({
"dist-tags": { latest: "2.0.0" },
"name": "test-package",
"versions": {},
});
const { DependencyAnalyzerService }
= await import("../../src/services/dependencyAnalyzerService.js");
const analyzerService = new DependencyAnalyzerService(
mockNpmService as Parameters<typeof DependencyAnalyzerService>[0],
);
const result = await analyzerService.analyzePackageJson({
dependencies: {
"test-package": "^1.0.0",
},
});
expect(result).toHaveLength(1);
expect(result[0]?.currentVersion).toBe("^1.0.0");
});
it("should handle npm errors gracefully", async() => {
expect.assertions(1);
const mockNpmService = createMockNpmService();
mockNpmService.getPackageInfo.mockRejectedValue(new Error("Network error"));
const { DependencyAnalyzerService }
= await import("../../src/services/dependencyAnalyzerService.js");
const analyzerService = new DependencyAnalyzerService(
mockNpmService as Parameters<typeof DependencyAnalyzerService>[0],
);
const result = await analyzerService.analyzePackageJson({
dependencies: {
"test-package": "1.0.0",
},
});
expect(result).toStrictEqual([]);
});
it("should handle semver comparison errors", async() => {
expect.assertions(1);
const mockNpmService = createMockNpmService();
mockNpmService.getPackageInfo.mockResolvedValue({
"dist-tags": { latest: "invalid-version" },
"name": "test-package",
"versions": {},
});
const { DependencyAnalyzerService }
= await import("../../src/services/dependencyAnalyzerService.js");
const analyzerService = new DependencyAnalyzerService(
mockNpmService as Parameters<typeof DependencyAnalyzerService>[0],
);
const result = await analyzerService.analyzePackageJson({
dependencies: {
"test-package": "also-invalid",
},
});
expect(result).toStrictEqual([]);
});
});
+366
View File
@@ -0,0 +1,366 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable vitest/valid-expect -- Test expectations don't need messages */
/* eslint-disable max-lines-per-function -- Test suites require many test cases */
/* eslint-disable @typescript-eslint/naming-convention -- Test data uses npm package names and destructured imports */
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Required for mocking */
/* eslint-disable max-nested-callbacks -- Vitest structure requires nested callbacks */
/* eslint-disable stylistic/max-len -- Test files have long import paths */
/* eslint-disable max-lines -- Test suites require many test cases */
/* eslint-disable vitest/no-conditional-in-test -- Discriminated unions require type narrowing */
/* eslint-disable vitest/no-conditional-expect -- Discriminated unions require type narrowing */
import { readFile, rm, writeFile } from "node:fs/promises";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Logger } from "@nhcarrigan/logger";
const mockExecAsync = vi.fn();
vi.mock("node:child_process", () => {
return {
exec: vi.fn(),
};
});
vi.mock("node:fs/promises", () => {
return {
readFile: vi.fn(),
rm: vi.fn(),
writeFile: vi.fn(),
};
});
vi.mock("node:os", () => {
return {
tmpdir: vi.fn(() => {
return "/tmp";
}),
};
});
vi.mock("node:util", () => {
return {
promisify: vi.fn(() => {
return mockExecAsync;
}),
};
});
interface MockClonedRepo {
cleanup: ReturnType<typeof vi.fn>;
path: string;
repoName: string;
}
const createMockClonedRepo = (): MockClonedRepo => {
return {
cleanup: vi.fn(),
path: "/tmp/minori-test-repo-123",
repoName: "test-repo",
};
};
const createMockLogger = (): Logger => {
return {
error: vi.fn(),
log: vi.fn(),
} as unknown as Logger;
};
describe("gitService", () => {
// eslint-disable-next-line @typescript-eslint/init-declarations -- Reassigned in beforeEach
let mockLogger: Logger;
beforeEach(() => {
vi.clearAllMocks();
process.env.GITEA_TOKEN = "test-token";
mockLogger = createMockLogger();
});
afterEach(() => {
vi.resetAllMocks();
});
it("should clone a repository to a temporary directory", async() => {
expect.assertions(3);
mockExecAsync.mockResolvedValue({ stderr: "", stdout: "" });
const { cloneRepository } = await import("../../src/services/gitService.js");
const result = await cloneRepository(
mockLogger,
"test-repo",
"test-token",
);
expect(result.repoName).toBe("test-repo");
expect(result.path).toMatch(/^\/tmp\/minori-test-repo-\d+$/u);
expect(typeof result.cleanup).toBe("function");
});
it("should configure git user email and name", async() => {
expect.assertions(1);
mockExecAsync.mockResolvedValue({ stderr: "", stdout: "" });
const { cloneRepository } = await import("../../src/services/gitService.js");
await cloneRepository(mockLogger, "test-repo", "test-token");
expect(mockExecAsync).toHaveBeenCalledWith(
expect.stringContaining("git clone"),
);
});
it("should cleanup temporary directory when cleanup is called", async() => {
expect.assertions(1);
mockExecAsync.mockResolvedValue({ stderr: "", stdout: "" });
vi.mocked(rm).mockResolvedValue(undefined);
const { cloneRepository } = await import("../../src/services/gitService.js");
const result = await cloneRepository(
mockLogger,
"test-repo",
"test-token",
);
await result.cleanup();
expect(rm).toHaveBeenCalledWith(
result.path,
{ force: true, recursive: true },
);
});
it("should create a new branch when it does not exist", async() => {
expect.assertions(2);
mockExecAsync.mockResolvedValue({ stderr: "", stdout: "" });
vi.mocked(readFile).mockResolvedValue(JSON.stringify({
dependencies: {
"test-package": "1.0.0",
},
}));
vi.mocked(writeFile).mockResolvedValue(undefined);
const { createOrUpdateBranch } = await import("../../src/services/gitService.js");
const mockClonedRepo = createMockClonedRepo();
const result = await createOrUpdateBranch({
branchName: "dependencies/update-test-package",
clonedRepo: mockClonedRepo,
logger: mockLogger,
packageName: "test-package",
targetVersion: "2.0.0",
});
expect(result.status).toBe("created");
if (result.status === "created") {
expect(result.branchName).toBe("dependencies/update-test-package");
}
});
it("should update an existing branch when behind", async() => {
expect.assertions(1);
mockExecAsync.mockImplementation((command: string) => {
if (command.includes("git branch -r")) {
return Promise.resolve({
stderr: "",
stdout: " origin/dependencies/update-test-package\n",
});
}
return Promise.resolve({ stderr: "", stdout: "" });
});
vi.mocked(readFile).mockResolvedValue(JSON.stringify({
dependencies: { "test-package": "1.5.0" },
}));
vi.mocked(writeFile).mockResolvedValue(undefined);
const { createOrUpdateBranch } = await import("../../src/services/gitService.js");
const mockClonedRepo = createMockClonedRepo();
const result = await createOrUpdateBranch({
branchName: "dependencies/update-test-package",
clonedRepo: mockClonedRepo,
logger: mockLogger,
packageName: "test-package",
targetVersion: "2.0.0",
});
expect(result.status).toBe("updated");
});
it("should skip when branch is already up-to-date", async() => {
expect.assertions(1);
mockExecAsync.mockImplementation((command: string) => {
if (command.includes("git branch -r")) {
return Promise.resolve({
stderr: "",
stdout: " origin/dependencies/update-test-package\n",
});
}
return Promise.resolve({ stderr: "", stdout: "" });
});
vi.mocked(readFile).mockResolvedValue(JSON.stringify({
dependencies: { "test-package": "2.0.0" },
}));
const { createOrUpdateBranch } = await import("../../src/services/gitService.js");
const mockClonedRepo = createMockClonedRepo();
const result = await createOrUpdateBranch({
branchName: "dependencies/update-test-package",
clonedRepo: mockClonedRepo,
logger: mockLogger,
packageName: "test-package",
targetVersion: "2.0.0",
});
expect(result.status).toBe("up-to-date");
});
it("should fail when package is not found", async() => {
expect.assertions(2);
mockExecAsync.mockResolvedValue({ stderr: "", stdout: "" });
vi.mocked(readFile).mockResolvedValue(JSON.stringify({
dependencies: {},
}));
const { createOrUpdateBranch } = await import("../../src/services/gitService.js");
const mockClonedRepo = createMockClonedRepo();
const result = await createOrUpdateBranch({
branchName: "dependencies/update-test-package",
clonedRepo: mockClonedRepo,
logger: mockLogger,
packageName: "test-package",
targetVersion: "2.0.0",
});
expect(result.status).toBe("failed");
if (result.status === "failed") {
expect(result.error).toContain("not found");
}
});
it("should handle git command errors", async() => {
expect.assertions(1);
const error = new Error("Git command failed") as Error & { stderr: string };
error.stderr = "fatal: error";
mockExecAsync.mockRejectedValueOnce(error);
const { createOrUpdateBranch } = await import("../../src/services/gitService.js");
const mockClonedRepo = createMockClonedRepo();
const result = await createOrUpdateBranch({
branchName: "dependencies/update-test-package",
clonedRepo: mockClonedRepo,
logger: mockLogger,
packageName: "test-package",
targetVersion: "2.0.0",
});
expect(result.status).toBe("failed");
});
it("should update devDependencies", async() => {
expect.assertions(1);
mockExecAsync.mockResolvedValue({ stderr: "", stdout: "" });
vi.mocked(readFile).mockResolvedValue(JSON.stringify({
devDependencies: {
"test-package": "1.0.0",
},
}));
vi.mocked(writeFile).mockResolvedValue(undefined);
const { createOrUpdateBranch } = await import("../../src/services/gitService.js");
const mockClonedRepo = createMockClonedRepo();
const result = await createOrUpdateBranch({
branchName: "dependencies/update-test-package",
clonedRepo: mockClonedRepo,
logger: mockLogger,
packageName: "test-package",
targetVersion: "2.0.0",
});
expect(result.status).toBe("created");
});
it("should handle cleanup errors gracefully", async() => {
expect.assertions(1);
const error = new Error("Git command failed");
mockExecAsync.
mockRejectedValueOnce(error).
mockRejectedValueOnce(new Error("Cleanup failed"));
const { createOrUpdateBranch } = await import("../../src/services/gitService.js");
const mockClonedRepo = createMockClonedRepo();
const result = await createOrUpdateBranch({
branchName: "dependencies/update-test-package",
clonedRepo: mockClonedRepo,
logger: mockLogger,
packageName: "test-package",
targetVersion: "2.0.0",
});
expect(result.status).toBe("failed");
});
it("should log git stderr when it does not contain warnings", async() => {
expect.assertions(1);
mockExecAsync.mockImplementation((command: string) => {
if (command.includes("git fetch")) {
return Promise.resolve({ stderr: "some error message", stdout: "" });
}
return Promise.resolve({ stderr: "", stdout: "" });
});
vi.mocked(readFile).mockResolvedValue(JSON.stringify({
dependencies: { "test-package": "1.0.0" },
}));
vi.mocked(writeFile).mockResolvedValue(undefined);
const { createOrUpdateBranch } = await import("../../src/services/gitService.js");
const mockClonedRepo = createMockClonedRepo();
await createOrUpdateBranch({
branchName: "dependencies/update-test-package",
clonedRepo: mockClonedRepo,
logger: mockLogger,
packageName: "test-package",
targetVersion: "2.0.0",
});
expect(mockLogger.log).toHaveBeenCalledWith(
"debug",
expect.stringContaining("Git stderr"),
);
});
it("should log 'unknown' when package not found on existing branch", async() => {
expect.assertions(1);
mockExecAsync.mockImplementation((command: string) => {
if (command.includes("git branch -r")) {
return Promise.resolve({
stderr: "",
stdout: " origin/dependencies/update-test-package\n",
});
}
return Promise.resolve({ stderr: "", stdout: "" });
});
vi.mocked(readFile).mockResolvedValue(JSON.stringify({
dependencies: { "other-package": "1.0.0" },
}));
vi.mocked(writeFile).mockResolvedValue(undefined);
const { createOrUpdateBranch } = await import("../../src/services/gitService.js");
const mockClonedRepo = createMockClonedRepo();
await createOrUpdateBranch({
branchName: "dependencies/update-test-package",
clonedRepo: mockClonedRepo,
logger: mockLogger,
packageName: "test-package",
targetVersion: "2.0.0",
});
expect(mockLogger.log).toHaveBeenCalledWith(
"info",
expect.stringContaining("unknown"),
);
});
it("should update both dependencies and devDependencies", async() => {
expect.assertions(3);
mockExecAsync.mockResolvedValue({ stderr: "", stdout: "" });
vi.mocked(readFile).mockResolvedValue(JSON.stringify({
dependencies: { "test-package": "1.0.0" },
devDependencies: { "test-package": "1.0.0" },
}));
vi.mocked(writeFile).mockResolvedValue(undefined);
const { createOrUpdateBranch } = await import("../../src/services/gitService.js");
const mockClonedRepo = createMockClonedRepo();
const result = await createOrUpdateBranch({
branchName: "dependencies/update-test-package",
clonedRepo: mockClonedRepo,
logger: mockLogger,
packageName: "test-package",
targetVersion: "2.0.0",
});
expect(result.status).toBe("created");
const writeCall = vi.mocked(writeFile).mock.calls[0];
const writtenContent = JSON.parse(writeCall?.[1] as string) as {
dependencies: Record<string, string>;
devDependencies: Record<string, string>;
};
expect(writtenContent.dependencies["test-package"]).toBe("2.0.0");
expect(writtenContent.devDependencies["test-package"]).toBe("2.0.0");
});
});
+309
View File
@@ -0,0 +1,309 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable vitest/valid-expect -- Test expectations don't need messages */
/* eslint-disable max-lines-per-function -- Test suites require many test cases */
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Required for mocking */
/* eslint-disable @typescript-eslint/consistent-type-imports -- Dynamic imports */
/* eslint-disable @typescript-eslint/naming-convention -- Environment variables and Gitea API format */
/* eslint-disable max-nested-callbacks -- Vitest structure requires nested callbacks */
/* eslint-disable vitest/require-to-throw-message -- Generic throw assertion */
import axios, { AxiosError, type AxiosResponse } from "axios";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { GiteaService } from "../../src/services/giteaService.js";
vi.mock("axios", async() => {
const actualAxios = await vi.importActual<typeof import("axios")>("axios");
return {
AxiosError: actualAxios.AxiosError,
default: {
create: vi.fn(() => {
return {
get: vi.fn(),
post: vi.fn(),
};
}),
},
isAxiosError: actualAxios.isAxiosError,
};
});
interface MockRepository {
archived: boolean;
cloneUrl: string;
defaultBranch: string;
disabled: boolean;
fullName: string;
id: number;
mirror: boolean;
name: string;
}
const createMockRepository = (
overrides: Partial<MockRepository> & { name: string; id: number },
): Record<string, unknown> => {
return {
archived: overrides.archived ?? false,
clone_url: overrides.cloneUrl ?? "url",
default_branch: overrides.defaultBranch ?? "main",
disabled: overrides.disabled ?? false,
full_name: overrides.fullName ?? `nhcarrigan/${overrides.name}`,
id: overrides.id,
mirror: overrides.mirror ?? false,
name: overrides.name,
};
};
describe("giteaService", () => {
// eslint-disable-next-line @typescript-eslint/init-declarations -- Reassigned in beforeEach
let giteaService: GiteaService;
// eslint-disable-next-line @typescript-eslint/init-declarations -- Reassigned in beforeEach
let mockGet: ReturnType<typeof vi.fn>;
// eslint-disable-next-line @typescript-eslint/init-declarations -- Reassigned in beforeEach
let mockPost: ReturnType<typeof vi.fn>;
const originalEnvironment = process.env;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnvironment, GITEA_TOKEN: "test-token" };
mockGet = vi.fn();
mockPost = vi.fn();
vi.mocked(axios.create).mockReturnValue({
get: mockGet,
post: mockPost,
} as unknown as ReturnType<typeof axios.create>);
giteaService = new GiteaService();
});
afterEach(() => {
process.env = originalEnvironment;
vi.resetAllMocks();
});
it("should throw when GITEA_TOKEN is not set", () => {
expect.assertions(1);
process.env.GITEA_TOKEN = "";
expect(() => {
return new GiteaService();
}).toThrow("GITEA_TOKEN environment variable is required");
});
it("should throw when GITEA_TOKEN is undefined", () => {
expect.assertions(1);
delete process.env.GITEA_TOKEN;
expect(() => {
return new GiteaService();
}).toThrow("GITEA_TOKEN environment variable is required");
});
it("should create axios client with correct configuration", () => {
expect.assertions(1);
expect(axios.create).toHaveBeenCalledWith({
baseURL: "https://git.nhcarrigan.com/api/v1",
headers: {
"Authorization": "token test-token",
"Content-Type": "application/json",
},
});
});
it("should create a pull request", async() => {
expect.assertions(2);
const mockPullRequest = {
base: { ref: "main", sha: "abc123" },
body: "Test body",
head: { ref: "feature", sha: "def456" },
id: 1,
number: 1,
state: "open",
title: "Test PR",
};
mockPost.mockResolvedValueOnce({ data: mockPullRequest });
const result = await giteaService.createPullRequest({
base: "main",
body: "Test body",
head: "feature",
owner: "test-owner",
repo: "test-repo",
title: "Test PR",
});
expect(result).toStrictEqual(mockPullRequest);
expect(mockPost).toHaveBeenCalledWith(
"/repos/test-owner/test-repo/pulls",
{ base: "main", body: "Test body", head: "feature", title: "Test PR" },
);
});
it("should return file content when found", async() => {
expect.assertions(2);
const mockFile = {
content: "SGVsbG8gV29ybGQ=",
encoding: "base64",
path: "package.json",
sha: "abc123",
type: "file",
};
mockGet.mockResolvedValueOnce({ data: mockFile });
const result = await giteaService.getFileContent({
owner: "test-owner",
path: "package.json",
reference: "main",
repo: "test-repo",
});
expect(result).toStrictEqual(mockFile);
expect(mockGet).toHaveBeenCalledWith(
"/repos/test-owner/test-repo/contents/package.json",
{ params: { ref: "main" } },
);
});
it("should return null when file is not found (404)", async() => {
expect.assertions(1);
const axiosError = new AxiosError("Not Found");
axiosError.response = { status: 404 } as AxiosResponse;
mockGet.mockRejectedValueOnce(axiosError);
const result = await giteaService.getFileContent({
owner: "test-owner",
path: "non-existent.json",
repo: "test-repo",
});
expect(result).toBeNull();
});
it("should throw for non-404 errors", async() => {
expect.assertions(1);
const axiosError = new AxiosError("Server Error");
axiosError.response = { status: 500 } as AxiosResponse;
mockGet.mockRejectedValueOnce(axiosError);
await expect(
giteaService.getFileContent({
owner: "test-owner",
path: "package.json",
repo: "test-repo",
}),
).rejects.toThrow();
});
it("should fetch all repositories with pagination", async() => {
expect.assertions(2);
const page1 = Array.from({ length: 100 }, (_, index) => {
return createMockRepository({
cloneUrl: `https://git.nhcarrigan.com/nhcarrigan/repo-${String(index)}.git`,
fullName: `nhcarrigan/repo-${String(index)}`,
id: index,
name: `repo-${String(index)}`,
});
});
const page2 = [
createMockRepository({
cloneUrl: "https://git.nhcarrigan.com/nhcarrigan/repo-100.git",
fullName: "nhcarrigan/repo-100",
id: 100,
name: "repo-100",
}),
];
mockGet.
mockResolvedValueOnce({ data: page1 }).
mockResolvedValueOnce({ data: page2 }).
mockResolvedValueOnce({ data: [] });
const result = await giteaService.listOrgRepositories();
expect(result).toHaveLength(101);
expect(mockGet).toHaveBeenCalledTimes(3);
});
it("should filter out archived repositories", async() => {
expect.assertions(2);
const repos = [
createMockRepository({ archived: true, id: 1, name: "archived-repo" }),
createMockRepository({ id: 2, name: "active-repo" }),
];
mockGet.
mockResolvedValueOnce({ data: repos }).
mockResolvedValueOnce({ data: [] });
const result = await giteaService.listOrgRepositories();
expect(result).toHaveLength(1);
expect(result[0]?.name).toBe("active-repo");
});
it("should filter out disabled repositories", async() => {
expect.assertions(2);
const repos = [
createMockRepository({ disabled: true, id: 1, name: "disabled-repo" }),
createMockRepository({ id: 2, name: "enabled-repo" }),
];
mockGet.
mockResolvedValueOnce({ data: repos }).
mockResolvedValueOnce({ data: [] });
const result = await giteaService.listOrgRepositories();
expect(result).toHaveLength(1);
expect(result[0]?.name).toBe("enabled-repo");
});
it("should filter out mirror repositories", async() => {
expect.assertions(2);
const repos = [
createMockRepository({ id: 1, mirror: true, name: "mirror-repo" }),
createMockRepository({ id: 2, name: "source-repo" }),
];
mockGet.
mockResolvedValueOnce({ data: repos }).
mockResolvedValueOnce({ data: [] });
const result = await giteaService.listOrgRepositories();
expect(result).toHaveLength(1);
expect(result[0]?.name).toBe("source-repo");
});
it("should list pull requests with default state", async() => {
expect.assertions(2);
const mockPullRequests = [
{
base: { ref: "main", sha: "abc" },
body: "PR 1",
head: { ref: "feature-1", sha: "def" },
id: 1,
number: 1,
state: "open",
title: "PR 1",
},
];
mockGet.mockResolvedValueOnce({ data: mockPullRequests });
const result = await giteaService.listPullRequests("owner", "repo");
expect(result).toStrictEqual(mockPullRequests);
expect(mockGet).toHaveBeenCalledWith(
"/repos/owner/repo/pulls",
{ params: { state: "open" } },
);
});
it("should list pull requests with specified state", async() => {
expect.assertions(1);
mockGet.mockResolvedValueOnce({ data: [] });
await giteaService.listPullRequests("owner", "repo", "all");
expect(mockGet).toHaveBeenCalledWith(
"/repos/owner/repo/pulls",
{ params: { state: "all" } },
);
});
it("should list closed pull requests", async() => {
expect.assertions(1);
mockGet.mockResolvedValueOnce({ data: [] });
await giteaService.listPullRequests("owner", "repo", "closed");
expect(mockGet).toHaveBeenCalledWith(
"/repos/owner/repo/pulls",
{ params: { state: "closed" } },
);
});
});
+334
View File
@@ -0,0 +1,334 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable vitest/valid-expect -- Test expectations don't need messages */
/* eslint-disable max-lines-per-function -- Test suites require many test cases */
/* eslint-disable @typescript-eslint/naming-convention -- Test data uses npm package names */
/* eslint-disable @typescript-eslint/consistent-type-assertions -- Required for mocking */
/* eslint-disable @typescript-eslint/consistent-type-imports -- Dynamic imports */
/* eslint-disable vitest/require-to-throw-message -- Generic throw assertion */
/* eslint-disable stylistic/max-len -- Test files have long URLs */
import axios, { AxiosError, type AxiosResponse } from "axios";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NpmService } from "../../src/services/npmService.js";
vi.mock("axios", async() => {
const actualAxios = await vi.importActual<typeof import("axios")>("axios");
return {
AxiosError: actualAxios.AxiosError,
default: {
create: vi.fn(() => {
return {
get: vi.fn(),
};
}),
get: vi.fn(),
},
isAxiosError: actualAxios.isAxiosError,
};
});
vi.mock("@nhcarrigan/logger", () => {
return {
Logger: class MockLogger {
public error = vi.fn();
public log = vi.fn();
},
};
});
const createMockPackageInfo = (
version: string,
repositoryUrl?: string,
): Record<string, unknown> => {
const versionData: Record<string, unknown> = { version };
if (repositoryUrl !== undefined) {
versionData.repository = { url: repositoryUrl };
}
return {
"dist-tags": { latest: version },
"name": "test-package",
"versions": { [version]: versionData },
};
};
describe("npmService", () => {
// eslint-disable-next-line @typescript-eslint/init-declarations -- Reassigned in beforeEach
let npmService: NpmService;
// eslint-disable-next-line @typescript-eslint/init-declarations -- Reassigned in beforeEach
let mockGet: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
process.env.GITEA_TOKEN = "test-token";
mockGet = vi.fn();
vi.mocked(axios.create).mockReturnValue({
get: mockGet,
} as unknown as ReturnType<typeof axios.create>);
npmService = new NpmService();
});
afterEach(() => {
vi.resetAllMocks();
});
it("should return package info when found", async() => {
expect.assertions(2);
const mockPackageInfo = {
"dist-tags": { latest: "2.0.0" },
"name": "test-package",
"versions": {
"2.0.0": {
repository: { url: "https://github.com/test/test.git" },
version: "2.0.0",
},
},
};
mockGet.mockResolvedValueOnce({ data: mockPackageInfo });
const result = await npmService.getPackageInfo("test-package");
expect(result).toStrictEqual(mockPackageInfo);
expect(mockGet).toHaveBeenCalledWith("/test-package");
});
it("should return null when package is not found (404)", async() => {
expect.assertions(1);
const axiosError = new AxiosError("Not Found");
axiosError.response = { status: 404 } as AxiosResponse;
mockGet.mockRejectedValueOnce(axiosError);
const result = await npmService.getPackageInfo("non-existent-package");
expect(result).toBeNull();
});
it("should throw for non-404 errors", async() => {
expect.assertions(1);
const axiosError = new AxiosError("Server Error");
axiosError.response = { status: 500 } as AxiosResponse;
mockGet.mockRejectedValueOnce(axiosError);
await expect(
npmService.getPackageInfo("test-package"),
).rejects.toThrow();
});
it("should encode scoped package names", async() => {
expect.assertions(1);
const mockPackageInfo = {
"dist-tags": { latest: "1.0.0" },
"name": "@scope/package",
"versions": {},
};
mockGet.mockResolvedValueOnce({ data: mockPackageInfo });
await npmService.getPackageInfo("@scope/package");
expect(mockGet).toHaveBeenCalledWith("/%40scope%2Fpackage");
});
it("should return fallback when package info is null", async() => {
expect.assertions(1);
const axiosError = new AxiosError("Not Found");
axiosError.response = { status: 404 } as AxiosResponse;
mockGet.mockRejectedValueOnce(axiosError);
const result = await npmService.getPackageChangelog({
fromVersion: "1.0.0",
packageName: "non-existent",
toVersion: "2.0.0",
});
expect(result).toBe("No changelog available for non-existent");
});
it("should return fallback when repository URL is undefined", async() => {
expect.assertions(1);
const mockPackageInfo = createMockPackageInfo("2.0.0");
mockGet.mockResolvedValueOnce({ data: mockPackageInfo });
const result = await npmService.getPackageChangelog({
fromVersion: "1.0.0",
packageName: "test-package",
toVersion: "2.0.0",
});
expect(result).toBe("Updated from 1.0.0 to 2.0.0");
});
it("should return fallback when not a GitHub URL", async() => {
expect.assertions(1);
const mockPackageInfo = createMockPackageInfo(
"2.0.0",
"https://gitlab.com/test/test.git",
);
mockGet.mockResolvedValueOnce({ data: mockPackageInfo });
const result = await npmService.getPackageChangelog({
fromVersion: "1.0.0",
packageName: "test-package",
toVersion: "2.0.0",
});
expect(result).toBe("Updated from 1.0.0 to 2.0.0");
});
it("should fetch GitHub releases and format changelog", async() => {
expect.assertions(5);
const mockPackageInfo = createMockPackageInfo(
"2.0.0",
"https://github.com/owner/repo.git",
);
const mockReleases = [
{ body: "Release notes for 2.0.0", tag_name: "v2.0.0" },
{ body: "Release notes for 1.5.0", tag_name: "v1.5.0" },
{ body: "Release notes for 1.0.0", tag_name: "v1.0.0" },
];
mockGet.mockResolvedValueOnce({ data: mockPackageInfo });
vi.mocked(axios.get).mockResolvedValueOnce({ data: mockReleases });
const result = await npmService.getPackageChangelog({
fromVersion: "1.0.0",
packageName: "test-package",
toVersion: "2.0.0",
});
expect(result).toContain("## Changelog");
expect(result).toContain("### v2.0.0");
expect(result).toContain("Release notes for 2.0.0");
expect(result).toContain("### v1.5.0");
expect(result).not.toContain("### v1.0.0");
});
it("should return fallback when no relevant releases found", async() => {
expect.assertions(1);
const mockPackageInfo = createMockPackageInfo(
"2.0.0",
"https://github.com/owner/repo.git",
);
mockGet.mockResolvedValueOnce({ data: mockPackageInfo });
vi.mocked(axios.get).mockResolvedValueOnce({ data: [] });
const result = await npmService.getPackageChangelog({
fromVersion: "1.0.0",
packageName: "test-package",
toVersion: "2.0.0",
});
expect(result).toBe("Updated from 1.0.0 to 2.0.0");
});
it("should handle GitHub API errors gracefully", async() => {
expect.assertions(1);
const mockPackageInfo = createMockPackageInfo(
"2.0.0",
"https://github.com/owner/repo.git",
);
mockGet.mockResolvedValueOnce({ data: mockPackageInfo });
vi.mocked(axios.get).mockRejectedValueOnce(new Error("GitHub API error"));
const result = await npmService.getPackageChangelog({
fromVersion: "1.0.0",
packageName: "test-package",
toVersion: "2.0.0",
});
expect(result).toBe("Updated from 1.0.0 to 2.0.0");
});
it("should handle releases without body", async() => {
expect.assertions(1);
const mockPackageInfo = createMockPackageInfo(
"2.0.0",
"https://github.com/owner/repo.git",
);
const mockReleases = [
{ tag_name: "v2.0.0" },
];
mockGet.mockResolvedValueOnce({ data: mockPackageInfo });
vi.mocked(axios.get).mockResolvedValueOnce({ data: mockReleases });
const result = await npmService.getPackageChangelog({
fromVersion: "1.0.0",
packageName: "test-package",
toVersion: "2.0.0",
});
expect(result).toContain("No release notes available");
});
it("should normalise git+ prefixed URLs", async() => {
expect.assertions(1);
const mockPackageInfo = createMockPackageInfo(
"2.0.0",
"git+https://github.com/owner/repo.git",
);
mockGet.mockResolvedValueOnce({ data: mockPackageInfo });
vi.mocked(axios.get).mockResolvedValueOnce({ data: [] });
await npmService.getPackageChangelog({
fromVersion: "1.0.0",
packageName: "test-package",
toVersion: "2.0.0",
});
expect(axios.get).toHaveBeenCalledWith(
"https://api.github.com/repos/owner/repo/releases",
expect.any(Object),
);
});
it("should normalise git:// URLs", async() => {
expect.assertions(1);
const mockPackageInfo = createMockPackageInfo(
"2.0.0",
"git://github.com/owner/repo.git",
);
mockGet.mockResolvedValueOnce({ data: mockPackageInfo });
vi.mocked(axios.get).mockResolvedValueOnce({ data: [] });
await npmService.getPackageChangelog({
fromVersion: "1.0.0",
packageName: "test-package",
toVersion: "2.0.0",
});
expect(axios.get).toHaveBeenCalledWith(
"https://api.github.com/repos/owner/repo/releases",
expect.any(Object),
);
});
it("should normalise ssh:// URLs", async() => {
expect.assertions(1);
const mockPackageInfo = createMockPackageInfo(
"2.0.0",
"ssh://git@github.com/owner/repo.git",
);
mockGet.mockResolvedValueOnce({ data: mockPackageInfo });
vi.mocked(axios.get).mockResolvedValueOnce({ data: [] });
await npmService.getPackageChangelog({
fromVersion: "1.0.0",
packageName: "test-package",
toVersion: "2.0.0",
});
expect(axios.get).toHaveBeenCalledWith(
"https://api.github.com/repos/owner/repo/releases",
expect.any(Object),
);
});
it("should return fallback when GitHub URL has insufficient parts", async() => {
expect.assertions(1);
const mockPackageInfo = createMockPackageInfo(
"2.0.0",
"https://github.com/owner",
);
mockGet.mockResolvedValueOnce({ data: mockPackageInfo });
const result = await npmService.getPackageChangelog({
fromVersion: "1.0.0",
packageName: "test-package",
toVersion: "2.0.0",
});
expect(result).toBe("Updated from 1.0.0 to 2.0.0");
});
it("should handle errors in getPackageChangelog", async() => {
expect.assertions(1);
mockGet.mockRejectedValueOnce(new Error("Network error"));
const result = await npmService.getPackageChangelog({
fromVersion: "1.0.0",
packageName: "test-package",
toVersion: "2.0.0",
});
expect(result).toBe("Updated from 1.0.0 to 2.0.0");
});
});
@@ -0,0 +1,440 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable vitest/valid-expect -- Test expectations don't need messages */
/* eslint-disable max-lines-per-function -- Test suites require many test cases */
/* eslint-disable max-lines -- Test suites require many test cases */
/* eslint-disable @typescript-eslint/naming-convention -- Test data uses npm package names and destructured imports */
/* eslint-disable max-nested-callbacks -- Vitest structure requires nested callbacks */
/* eslint-disable max-classes-per-file -- Mock classes are needed for each service */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mockGiteaGetFileContent = vi.fn();
const mockGiteaListOrgRepositories = vi.fn();
const mockGiteaCreatePullRequest = vi.fn();
const mockGiteaListPullRequests = vi.fn();
const mockNpmGetPackageChangelog = vi.fn();
const mockNpmGetPackageInfo = vi.fn();
const mockAnalyzePackageJson = vi.fn();
const mockCloneRepository = vi.fn();
const mockCreateOrUpdateBranch = vi.fn();
vi.mock("@nhcarrigan/logger", () => {
return {
Logger: class MockLogger {
public error = vi.fn();
public log = vi.fn();
},
};
});
vi.mock("../../src/services/giteaService.js", () => {
return {
GiteaService: class MockGiteaService {
public createPullRequest = mockGiteaCreatePullRequest;
public getFileContent = mockGiteaGetFileContent;
public listOrgRepositories = mockGiteaListOrgRepositories;
public listPullRequests = mockGiteaListPullRequests;
},
};
});
vi.mock("../../src/services/npmService.js", () => {
return {
NpmService: class MockNpmService {
public getPackageChangelog = mockNpmGetPackageChangelog;
public getPackageInfo = mockNpmGetPackageInfo;
},
};
});
vi.mock("../../src/services/dependencyAnalyzerService.js", () => {
return {
DependencyAnalyzerService: class MockDependencyAnalyzerService {
public analyzePackageJson = mockAnalyzePackageJson;
},
};
});
vi.mock("../../src/services/gitService.js", () => {
return {
cloneRepository: mockCloneRepository,
createOrUpdateBranch: mockCreateOrUpdateBranch,
};
});
const createMockRepo = (name: string): Record<string, unknown> => {
return {
archived: false,
clone_url: "url",
default_branch: "main",
disabled: false,
full_name: `nhcarrigan/${name}`,
id: 1,
mirror: false,
name: name,
};
};
interface MockFileContent {
content?: string;
encoding: string;
path: string;
sha: string;
type: string;
}
const createMockFileContent = (
packageJson: Record<string, unknown>,
): MockFileContent => {
return {
content: Buffer.from(JSON.stringify(packageJson)).toString("base64"),
encoding: "base64",
path: "package.json",
sha: "abc123",
type: "file",
};
};
interface MockUpdate {
currentVersion: string;
latestVersion: string;
packageName: string;
type: "dependencies" | "devDependencies";
}
const createMockUpdate = (): MockUpdate => {
return {
currentVersion: "1.0.0",
latestVersion: "2.0.0",
packageName: "test-pkg",
type: "dependencies",
};
};
const createMockClonedRepo = (
cleanup: ReturnType<typeof vi.fn> = vi.fn(),
): Record<string, unknown> => {
return {
cleanup: cleanup,
path: "/tmp/test",
repoName: "test-repo",
};
};
describe("updateOrchestratorService", () => {
const originalEnvironment = process.env;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnvironment, GITEA_TOKEN: "test-token" };
});
afterEach(() => {
process.env = originalEnvironment;
vi.resetModules();
vi.resetAllMocks();
});
it("should throw when GITEA_TOKEN is not set", async() => {
expect.assertions(1);
process.env.GITEA_TOKEN = "";
const { UpdateOrchestratorService }
= await import("../../src/services/updateOrchestratorService.js");
expect(() => {
return new UpdateOrchestratorService();
}).toThrow("GITEA_TOKEN environment variable is required");
});
it("should throw when GITEA_TOKEN is undefined", async() => {
expect.assertions(1);
delete process.env.GITEA_TOKEN;
const { UpdateOrchestratorService }
= await import("../../src/services/updateOrchestratorService.js");
expect(() => {
return new UpdateOrchestratorService();
}).toThrow("GITEA_TOKEN environment variable is required");
});
it("should create instance when GITEA_TOKEN is set", async() => {
expect.assertions(1);
process.env.GITEA_TOKEN = "valid-token";
const { UpdateOrchestratorService }
= await import("../../src/services/updateOrchestratorService.js");
expect(() => {
return new UpdateOrchestratorService();
}).not.toThrow();
});
it("should process all repositories", async() => {
expect.assertions(1);
process.env.GITEA_TOKEN = "test-token";
const mockRepos = [ createMockRepo("test-repo") ];
const mockFileContent = createMockFileContent({
dependencies: { "test-pkg": "1.0.0" },
});
mockGiteaListOrgRepositories.mockResolvedValue(mockRepos);
mockGiteaGetFileContent.mockResolvedValue(mockFileContent);
mockAnalyzePackageJson.mockResolvedValue([]);
mockCloneRepository.mockResolvedValue(createMockClonedRepo());
const { UpdateOrchestratorService }
= await import("../../src/services/updateOrchestratorService.js");
const service = new UpdateOrchestratorService();
await service.checkAndUpdateAllRepositories();
expect(mockGiteaListOrgRepositories).toHaveBeenCalledWith();
});
it("should skip repositories without package.json", async() => {
expect.assertions(1);
process.env.GITEA_TOKEN = "test-token";
const mockRepos = [ createMockRepo("no-package") ];
mockGiteaListOrgRepositories.mockResolvedValue(mockRepos);
mockGiteaGetFileContent.mockResolvedValue(null);
const { UpdateOrchestratorService }
= await import("../../src/services/updateOrchestratorService.js");
const service = new UpdateOrchestratorService();
await service.checkAndUpdateAllRepositories();
expect(mockGiteaGetFileContent).toHaveBeenCalledWith({
owner: "nhcarrigan",
path: "package.json",
reference: "main",
repo: "no-package",
});
});
it("should create PRs for updates", async() => {
expect.assertions(1);
process.env.GITEA_TOKEN = "test-token";
const mockRepos = [ createMockRepo("test-repo") ];
const mockFileContent = createMockFileContent({
dependencies: { "test-pkg": "1.0.0" },
});
const mockUpdates = [ createMockUpdate() ];
mockGiteaListOrgRepositories.mockResolvedValue(mockRepos);
mockGiteaGetFileContent.mockResolvedValue(mockFileContent);
mockAnalyzePackageJson.mockResolvedValue(mockUpdates);
mockNpmGetPackageChangelog.mockResolvedValue("## Changelog");
mockCloneRepository.mockResolvedValue(createMockClonedRepo());
mockCreateOrUpdateBranch.mockResolvedValue({
branchName: "dependencies/update-test-pkg",
status: "created",
});
const { UpdateOrchestratorService }
= await import("../../src/services/updateOrchestratorService.js");
const service = new UpdateOrchestratorService();
await service.checkAndUpdateAllRepositories();
expect(mockGiteaCreatePullRequest).toHaveBeenCalledWith(
expect.objectContaining({
base: "main",
head: "dependencies/update-test-pkg",
owner: "nhcarrigan",
repo: "test-repo",
title: "deps: update test-pkg to 2.0.0",
}),
);
});
it("should handle repository processing errors", async() => {
expect.assertions(1);
process.env.GITEA_TOKEN = "test-token";
const mockRepos = [ createMockRepo("error-repo") ];
mockGiteaListOrgRepositories.mockResolvedValue(mockRepos);
mockGiteaGetFileContent.mockRejectedValue(new Error("API error"));
const { UpdateOrchestratorService }
= await import("../../src/services/updateOrchestratorService.js");
const service = new UpdateOrchestratorService();
await expect(
service.checkAndUpdateAllRepositories(),
).resolves.not.toThrow();
});
it("should skip when branch is up-to-date", async() => {
expect.assertions(1);
process.env.GITEA_TOKEN = "test-token";
const mockRepos = [ createMockRepo("test-repo") ];
const mockFileContent = createMockFileContent({
dependencies: { "test-pkg": "1.0.0" },
});
const mockUpdates = [ createMockUpdate() ];
mockGiteaListOrgRepositories.mockResolvedValue(mockRepos);
mockGiteaGetFileContent.mockResolvedValue(mockFileContent);
mockAnalyzePackageJson.mockResolvedValue(mockUpdates);
mockCloneRepository.mockResolvedValue(createMockClonedRepo());
mockCreateOrUpdateBranch.mockResolvedValue({
status: "up-to-date",
});
const { UpdateOrchestratorService }
= await import("../../src/services/updateOrchestratorService.js");
const service = new UpdateOrchestratorService();
await service.checkAndUpdateAllRepositories();
expect(mockGiteaCreatePullRequest).not.toHaveBeenCalled();
});
it("should handle failed branch updates", async() => {
expect.assertions(1);
process.env.GITEA_TOKEN = "test-token";
const mockRepos = [ createMockRepo("test-repo") ];
const mockFileContent = createMockFileContent({
dependencies: { "test-pkg": "1.0.0" },
});
const mockUpdates = [ createMockUpdate() ];
mockGiteaListOrgRepositories.mockResolvedValue(mockRepos);
mockGiteaGetFileContent.mockResolvedValue(mockFileContent);
mockAnalyzePackageJson.mockResolvedValue(mockUpdates);
mockCloneRepository.mockResolvedValue(createMockClonedRepo());
mockCreateOrUpdateBranch.mockResolvedValue({
error: "Git error",
status: "failed",
});
const { UpdateOrchestratorService }
= await import("../../src/services/updateOrchestratorService.js");
const service = new UpdateOrchestratorService();
await service.checkAndUpdateAllRepositories();
expect(mockGiteaCreatePullRequest).not.toHaveBeenCalled();
});
it("should handle failed branch updates without error message", async() => {
expect.assertions(1);
process.env.GITEA_TOKEN = "test-token";
const mockRepos = [ createMockRepo("test-repo") ];
const mockFileContent = createMockFileContent({
dependencies: { "test-pkg": "1.0.0" },
});
const mockUpdates = [ createMockUpdate() ];
mockGiteaListOrgRepositories.mockResolvedValue(mockRepos);
mockGiteaGetFileContent.mockResolvedValue(mockFileContent);
mockAnalyzePackageJson.mockResolvedValue(mockUpdates);
mockCloneRepository.mockResolvedValue(createMockClonedRepo());
mockCreateOrUpdateBranch.mockResolvedValue({
status: "failed",
});
const { UpdateOrchestratorService }
= await import("../../src/services/updateOrchestratorService.js");
const service = new UpdateOrchestratorService();
await service.checkAndUpdateAllRepositories();
expect(mockGiteaCreatePullRequest).not.toHaveBeenCalled();
});
it("should handle package.json without content", async() => {
expect.assertions(1);
process.env.GITEA_TOKEN = "test-token";
const mockRepos = [ createMockRepo("test-repo") ];
const mockFileContent: MockFileContent = {
encoding: "base64",
path: "package.json",
sha: "abc123",
type: "file",
};
mockGiteaListOrgRepositories.mockResolvedValue(mockRepos);
mockGiteaGetFileContent.mockResolvedValue(mockFileContent);
const { UpdateOrchestratorService }
= await import("../../src/services/updateOrchestratorService.js");
const service = new UpdateOrchestratorService();
await expect(
service.checkAndUpdateAllRepositories(),
).resolves.not.toThrow();
});
it("should skip PR creation when branch was updated", async() => {
expect.assertions(1);
process.env.GITEA_TOKEN = "test-token";
const mockRepos = [ createMockRepo("test-repo") ];
const mockFileContent = createMockFileContent({
dependencies: { "test-pkg": "1.0.0" },
});
const mockUpdates = [ createMockUpdate() ];
mockGiteaListOrgRepositories.mockResolvedValue(mockRepos);
mockGiteaGetFileContent.mockResolvedValue(mockFileContent);
mockAnalyzePackageJson.mockResolvedValue(mockUpdates);
mockCloneRepository.mockResolvedValue(createMockClonedRepo());
mockCreateOrUpdateBranch.mockResolvedValue({
branchName: "dependencies/update-test-pkg",
status: "updated",
});
const { UpdateOrchestratorService }
= await import("../../src/services/updateOrchestratorService.js");
const service = new UpdateOrchestratorService();
await service.checkAndUpdateAllRepositories();
expect(mockGiteaCreatePullRequest).not.toHaveBeenCalled();
});
it("should handle PR creation errors", async() => {
expect.assertions(1);
process.env.GITEA_TOKEN = "test-token";
const mockRepos = [ createMockRepo("test-repo") ];
const mockFileContent = createMockFileContent({
dependencies: { "test-pkg": "1.0.0" },
});
const mockUpdates = [ createMockUpdate() ];
mockGiteaListOrgRepositories.mockResolvedValue(mockRepos);
mockGiteaGetFileContent.mockResolvedValue(mockFileContent);
mockAnalyzePackageJson.mockResolvedValue(mockUpdates);
mockNpmGetPackageChangelog.mockResolvedValue("## Changelog");
mockCloneRepository.mockResolvedValue(createMockClonedRepo());
mockCreateOrUpdateBranch.mockResolvedValue({
branchName: "dependencies/update-test-pkg",
status: "created",
});
mockGiteaCreatePullRequest.mockRejectedValue(
new Error("PR creation failed"),
);
const { UpdateOrchestratorService }
= await import("../../src/services/updateOrchestratorService.js");
const service = new UpdateOrchestratorService();
await expect(
service.checkAndUpdateAllRepositories(),
).resolves.not.toThrow();
});
it("should skip non-file type package.json", async() => {
expect.assertions(1);
process.env.GITEA_TOKEN = "test-token";
const mockRepos = [ createMockRepo("test-repo") ];
const mockFileContent: MockFileContent = {
content: "",
encoding: "base64",
path: "package.json",
sha: "abc123",
type: "dir",
};
mockGiteaListOrgRepositories.mockResolvedValue(mockRepos);
mockGiteaGetFileContent.mockResolvedValue(mockFileContent);
const { UpdateOrchestratorService }
= await import("../../src/services/updateOrchestratorService.js");
const service = new UpdateOrchestratorService();
await service.checkAndUpdateAllRepositories();
expect(mockAnalyzePackageJson).not.toHaveBeenCalled();
});
it("should cleanup cloned repo after processing", async() => {
expect.assertions(1);
process.env.GITEA_TOKEN = "test-token";
const mockRepos = [ createMockRepo("test-repo") ];
const mockFileContent = createMockFileContent({
dependencies: { "test-pkg": "1.0.0" },
});
const mockUpdates = [ createMockUpdate() ];
const mockCleanup = vi.fn();
mockGiteaListOrgRepositories.mockResolvedValue(mockRepos);
mockGiteaGetFileContent.mockResolvedValue(mockFileContent);
mockAnalyzePackageJson.mockResolvedValue(mockUpdates);
mockCloneRepository.mockResolvedValue(createMockClonedRepo(mockCleanup));
mockCreateOrUpdateBranch.mockResolvedValue({
status: "up-to-date",
});
const { UpdateOrchestratorService }
= await import("../../src/services/updateOrchestratorService.js");
const service = new UpdateOrchestratorService();
await service.checkAndUpdateAllRepositories();
expect(mockCleanup).toHaveBeenCalledWith();
});
});
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "@nhcarrigan/typescript-config",
"compilerOptions": {
"outDir": "./prod",
"rootDir": "./src"
},
"exclude": [
"vitest.config.ts",
"test/**/*.spec.ts"
]
}
+29
View File
@@ -0,0 +1,29 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
coverage: {
exclude: [
"node_modules/**",
"prod/**",
"test/**",
"*.config.*",
"src/types/**",
"src/utils/logger.ts",
"src/index.ts",
],
include: [ "src/**/*.ts" ],
provider: "v8",
reporter: [ "text", "html", "lcov" ],
},
environment: "node",
globals: true,
include: [ "test/**/*.spec.ts" ],
},
});