generated from nhcarrigan/template
feat: initial prototype attempt
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
prod
|
||||
coverage
|
||||
@@ -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~ 🌸
|
||||
@@ -0,0 +1,5 @@
|
||||
import NaomisConfig from "@nhcarrigan/eslint-config";
|
||||
|
||||
export default [
|
||||
...NaomisConfig,
|
||||
];
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+4573
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Logger } from "@nhcarrigan/logger";
|
||||
import semver from "semver";
|
||||
import type { NpmService } from "./npmService.js";
|
||||
import type {
|
||||
DependencyType,
|
||||
DependencyUpdate,
|
||||
PackageJson,
|
||||
} from "../types/package.types.js";
|
||||
|
||||
const logger = new Logger("DependencyAnalyzer", process.env.LOG_TOKEN ?? "");
|
||||
|
||||
/**
|
||||
* Checks if a version string is a valid semver range.
|
||||
* @param version - The version string to validate.
|
||||
* @returns True if the version is a valid semver range.
|
||||
*/
|
||||
const isValidSemverRange = (version: string): boolean => {
|
||||
if (version.startsWith("file:")) {
|
||||
return false;
|
||||
}
|
||||
if (version.startsWith("git:")) {
|
||||
return false;
|
||||
}
|
||||
if (version.startsWith("http:")) {
|
||||
return false;
|
||||
}
|
||||
if (version.startsWith("https:")) {
|
||||
return false;
|
||||
}
|
||||
if (version.includes("github:")) {
|
||||
return false;
|
||||
}
|
||||
if (version === "*" || version === "latest") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes version prefixes for comparison.
|
||||
* @param version - The version string to sanitise.
|
||||
* @returns The cleaned version string.
|
||||
*/
|
||||
const cleanVersion = (version: string): string => {
|
||||
return version.replace(/^[<=>^~]/, "");
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines if an update is needed based on version comparison.
|
||||
* @param currentVersion - The currently installed version.
|
||||
* @param latestVersion - The latest available version.
|
||||
* @returns True if an update is needed.
|
||||
*/
|
||||
const shouldUpdate = (
|
||||
currentVersion: string,
|
||||
latestVersion: string,
|
||||
): boolean => {
|
||||
try {
|
||||
if (currentVersion === latestVersion) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return semver.lt(currentVersion, latestVersion);
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
`Error comparing versions: ${currentVersion} vs ${latestVersion}`,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Catch blocks receive unknown type, cast needed for logger.error
|
||||
error as Error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Service for analysing package dependencies and finding updates.
|
||||
*/
|
||||
// eslint-disable-next-line stylistic/padded-blocks -- Blank line needed before JSDoc per lines-around-comment rule
|
||||
class DependencyAnalyzerService {
|
||||
|
||||
/**
|
||||
* Creates a new DependencyAnalyzerService instance.
|
||||
* @param npmService - The npm service for fetching package information.
|
||||
*/
|
||||
public constructor(private readonly npmService: NpmService) {}
|
||||
|
||||
/**
|
||||
* Analyses a package.json and finds available updates.
|
||||
* @param packageJson - The parsed package.json content.
|
||||
* @returns Array of available dependency updates.
|
||||
*/
|
||||
public async analyzePackageJson(
|
||||
packageJson: PackageJson,
|
||||
): Promise<Array<DependencyUpdate>> {
|
||||
const updates: Array<DependencyUpdate> = [];
|
||||
|
||||
const dependencyTypes: Array<DependencyType> = [
|
||||
"dependencies",
|
||||
"devDependencies",
|
||||
"peerDependencies",
|
||||
"optionalDependencies",
|
||||
];
|
||||
|
||||
for (const type of dependencyTypes) {
|
||||
const deps = packageJson[type];
|
||||
if (deps === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [ packageName, currentVersion ] of Object.entries(deps)) {
|
||||
if (!isValidSemverRange(currentVersion)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop -- Sequential dependency checks are required
|
||||
const update = await this.checkForUpdate(
|
||||
packageName,
|
||||
currentVersion,
|
||||
type,
|
||||
);
|
||||
if (update !== null) {
|
||||
updates.push(update);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a specific package has an available update.
|
||||
* @param packageName - The name of the package to check.
|
||||
* @param currentVersion - The currently installed version.
|
||||
* @param type - The dependency category (dependencies, devDependencies, etc.).
|
||||
* @returns The update information or null if no update available.
|
||||
*/
|
||||
private async checkForUpdate(
|
||||
packageName: string,
|
||||
currentVersion: string,
|
||||
type: DependencyType,
|
||||
): Promise<DependencyUpdate | null> {
|
||||
try {
|
||||
const packageInfo = await this.npmService.getPackageInfo(packageName);
|
||||
if (packageInfo === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const latestVersion = packageInfo["dist-tags"].latest;
|
||||
const cleanCurrentVersion = cleanVersion(currentVersion);
|
||||
|
||||
if (shouldUpdate(cleanCurrentVersion, latestVersion)) {
|
||||
return {
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
packageName,
|
||||
type,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
await logger.error(
|
||||
`Error checking update for ${packageName}`,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Catch blocks receive unknown type, cast needed for logger.error
|
||||
error as Error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { DependencyAnalyzerService };
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { exec } from "node:child_process";
|
||||
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { config } from "../config.js";
|
||||
import type { Logger } from "@nhcarrigan/logger";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
interface ClonedRepository {
|
||||
cleanup: ()=> Promise<void>;
|
||||
path: string;
|
||||
repoName: string;
|
||||
}
|
||||
|
||||
type UpdateResult =
|
||||
| { branchName: string; status: "created" }
|
||||
| { branchName: string; status: "updated" }
|
||||
| { error: string; status: "failed" }
|
||||
| { status: "up-to-date" };
|
||||
|
||||
interface PackageJsonDeps {
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface UpdatePackageOptions {
|
||||
logger: Logger;
|
||||
packageName: string;
|
||||
repoPath: string;
|
||||
targetVersion: string;
|
||||
}
|
||||
|
||||
interface BranchUpdateOptions {
|
||||
branchName: string;
|
||||
clonedRepo: ClonedRepository;
|
||||
logger: Logger;
|
||||
packageName: string;
|
||||
targetVersion: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a git command in the specified directory.
|
||||
* @param logger - The logger instance.
|
||||
* @param cwd - The working directory.
|
||||
* @param command - The git command to run.
|
||||
* @returns The command output.
|
||||
*/
|
||||
const runGitCommand = async(
|
||||
logger: Logger,
|
||||
cwd: string,
|
||||
command: string,
|
||||
): Promise<string> => {
|
||||
try {
|
||||
const { stderr, stdout } = await execAsync(command, { cwd });
|
||||
if (stderr.length > 0 && !stderr.includes("warning:")) {
|
||||
void logger.log("debug", `Git stderr: ${stderr}`);
|
||||
}
|
||||
return stdout.trim();
|
||||
} catch (error: unknown) {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Error type needs cast to access stderr property
|
||||
const gitError = error as Error & { stderr?: string };
|
||||
throw new Error(
|
||||
`Git command failed: ${command}\n${gitError.stderr ?? gitError.message}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the current version of a package from package.json.
|
||||
* @param repoPath - The repository path.
|
||||
* @param packageName - The name of the package to look up.
|
||||
* @returns The current version or null if not found.
|
||||
*/
|
||||
const getCurrentVersionOnBranch = async(
|
||||
repoPath: string,
|
||||
packageName: string,
|
||||
): Promise<null | string> => {
|
||||
const packageJsonPath = join(repoPath, "package.json");
|
||||
const packageJsonContent = await readFile(packageJsonPath, "utf-8");
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- Dynamic JSON parsing requires type assertion
|
||||
const packageJson: PackageJsonDeps = JSON.parse(packageJsonContent);
|
||||
|
||||
return (
|
||||
packageJson.dependencies?.[packageName]
|
||||
?? packageJson.devDependencies?.[packageName]
|
||||
?? null
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates package.json and creates a commit.
|
||||
* @param options - Configuration for which package to update and where.
|
||||
*/
|
||||
const updatePackageAndCommit = async(
|
||||
options: UpdatePackageOptions,
|
||||
): Promise<void> => {
|
||||
const { logger, packageName, repoPath, targetVersion } = options;
|
||||
const packageJsonPath = join(repoPath, "package.json");
|
||||
const packageJsonContent = await readFile(packageJsonPath, "utf-8");
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- Dynamic JSON parsing requires type assertion
|
||||
const packageJson: PackageJsonDeps = JSON.parse(packageJsonContent);
|
||||
|
||||
if (packageJson.dependencies?.[packageName] !== undefined) {
|
||||
packageJson.dependencies[packageName] = targetVersion;
|
||||
}
|
||||
|
||||
if (packageJson.devDependencies?.[packageName] !== undefined) {
|
||||
packageJson.devDependencies[packageName] = targetVersion;
|
||||
}
|
||||
|
||||
await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
|
||||
void logger.log("info", `Running pnpm install for ${packageName}...`);
|
||||
await execAsync("pnpm install --no-frozen-lockfile", { cwd: repoPath });
|
||||
|
||||
await runGitCommand(logger, repoPath, "git add package.json pnpm-lock.yaml");
|
||||
const commitMessage = `deps: update ${packageName} to ${targetVersion}`;
|
||||
await runGitCommand(logger, repoPath, `git commit -m "${commitMessage}"`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Clones a repository to a temporary directory.
|
||||
* @param logger - The logger instance.
|
||||
* @param repoName - The repository to clone.
|
||||
* @param giteaToken - Token for authenticating with the Gitea API.
|
||||
* @returns The cloned repository information.
|
||||
*/
|
||||
const cloneRepository = async(
|
||||
logger: Logger,
|
||||
repoName: string,
|
||||
giteaToken: string,
|
||||
): Promise<ClonedRepository> => {
|
||||
const timestamp = Date.now();
|
||||
const temporaryPath = join(
|
||||
tmpdir(),
|
||||
`minori-${repoName}-${String(timestamp)}`,
|
||||
);
|
||||
|
||||
const giteaHost = config.giteaUrl.replace("https://", "");
|
||||
const cloneUrl
|
||||
= `https://minori:${giteaToken}@${giteaHost}/${config.giteaOrg}/${repoName}.git`;
|
||||
|
||||
void logger.log("info", `Cloning ${repoName} to ${temporaryPath}...`);
|
||||
await execAsync(`git clone ${cloneUrl} ${temporaryPath}`);
|
||||
|
||||
await runGitCommand(
|
||||
logger,
|
||||
temporaryPath,
|
||||
`git config user.email "minori@nhcarrigan.com"`,
|
||||
);
|
||||
await runGitCommand(logger, temporaryPath, `git config user.name "Minori"`);
|
||||
|
||||
return {
|
||||
cleanup: async(): Promise<void> => {
|
||||
void logger.log("info", `Cleaning up temp directory for ${repoName}...`);
|
||||
await rm(temporaryPath, { force: true, recursive: true });
|
||||
},
|
||||
path: temporaryPath,
|
||||
repoName: repoName,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles updating an existing branch with a newer version.
|
||||
* @param options - The branch update options.
|
||||
* @returns The update result.
|
||||
*/
|
||||
const handleExistingBranch = async(
|
||||
options: BranchUpdateOptions,
|
||||
): Promise<UpdateResult> => {
|
||||
const { branchName, clonedRepo, logger, packageName, targetVersion }
|
||||
= options;
|
||||
const { path: repoPath } = clonedRepo;
|
||||
|
||||
await runGitCommand(logger, repoPath, `git checkout ${branchName}`);
|
||||
await runGitCommand(logger, repoPath, `git pull origin ${branchName}`);
|
||||
|
||||
const currentVersion = await getCurrentVersionOnBranch(repoPath, packageName);
|
||||
|
||||
if (currentVersion === targetVersion) {
|
||||
void logger.log(
|
||||
"info",
|
||||
`Branch ${branchName} is already on version ${targetVersion}, skipping...`,
|
||||
);
|
||||
await runGitCommand(logger, repoPath, "git checkout main");
|
||||
return { status: "up-to-date" };
|
||||
}
|
||||
|
||||
void logger.log(
|
||||
"info",
|
||||
`Branch ${branchName} has version ${currentVersion ?? "unknown"}, updating to ${targetVersion}...`,
|
||||
);
|
||||
await updatePackageAndCommit({
|
||||
logger,
|
||||
packageName,
|
||||
repoPath,
|
||||
targetVersion,
|
||||
});
|
||||
|
||||
void logger.log("info", `Pushing updated branch ${branchName}...`);
|
||||
await runGitCommand(logger, repoPath, `git push origin ${branchName}`);
|
||||
|
||||
await runGitCommand(logger, repoPath, "git checkout main");
|
||||
return { branchName: branchName, status: "updated" };
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles creating a branch for a version update.
|
||||
* @param options - Configuration for the branch to create.
|
||||
* @returns The update result.
|
||||
*/
|
||||
const handleNewBranch = async(
|
||||
options: BranchUpdateOptions,
|
||||
): Promise<UpdateResult> => {
|
||||
const { branchName, clonedRepo, logger, packageName, targetVersion }
|
||||
= options;
|
||||
const { path: repoPath } = clonedRepo;
|
||||
|
||||
await runGitCommand(logger, repoPath, "git checkout main");
|
||||
await runGitCommand(logger, repoPath, `git checkout -b ${branchName}`);
|
||||
|
||||
const currentVersion = await getCurrentVersionOnBranch(repoPath, packageName);
|
||||
if (currentVersion === null) {
|
||||
void logger.log("warn", `Package ${packageName} not found in package.json`);
|
||||
await runGitCommand(logger, repoPath, "git checkout main");
|
||||
await runGitCommand(logger, repoPath, `git branch -D ${branchName}`);
|
||||
return {
|
||||
error: `Package ${packageName} not found in package.json`,
|
||||
status: "failed",
|
||||
};
|
||||
}
|
||||
|
||||
await updatePackageAndCommit({
|
||||
logger,
|
||||
packageName,
|
||||
repoPath,
|
||||
targetVersion,
|
||||
});
|
||||
|
||||
void logger.log("info", `Pushing new branch ${branchName}...`);
|
||||
await runGitCommand(logger, repoPath, `git push -u origin ${branchName}`);
|
||||
|
||||
await runGitCommand(logger, repoPath, "git checkout main");
|
||||
return { branchName: branchName, status: "created" };
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates or updates a branch for a dependency update.
|
||||
* @param options - Configuration for the branch operation.
|
||||
* @returns The result of the operation.
|
||||
*/
|
||||
const createOrUpdateBranch = async(
|
||||
options: BranchUpdateOptions,
|
||||
): Promise<UpdateResult> => {
|
||||
const { branchName, clonedRepo, logger } = options;
|
||||
const { path: repoPath } = clonedRepo;
|
||||
|
||||
try {
|
||||
await runGitCommand(logger, repoPath, "git fetch origin");
|
||||
const remoteBranches = await runGitCommand(
|
||||
logger,
|
||||
repoPath,
|
||||
"git branch -r",
|
||||
);
|
||||
const branchExists = remoteBranches.includes(`origin/${branchName}`);
|
||||
|
||||
if (branchExists) {
|
||||
return await handleExistingBranch(options);
|
||||
}
|
||||
|
||||
return await handleNewBranch(options);
|
||||
} catch (error: unknown) {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Catch blocks receive unknown type, cast needed for logger.error
|
||||
void logger.error("createOrUpdateBranch", error as Error);
|
||||
try {
|
||||
await runGitCommand(logger, repoPath, "git checkout main");
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Catch blocks receive unknown type, cast needed to access message
|
||||
return { error: (error as Error).message, status: "failed" };
|
||||
}
|
||||
};
|
||||
|
||||
export type { BranchUpdateOptions, ClonedRepository, UpdateResult };
|
||||
export { cloneRepository, createOrUpdateBranch };
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import axios, { isAxiosError, type AxiosInstance } from "axios";
|
||||
import { config } from "../config.js";
|
||||
import type {
|
||||
GiteaFile,
|
||||
GiteaPullRequest,
|
||||
GiteaRepository,
|
||||
} from "../types/gitea.types.js";
|
||||
|
||||
interface CreatePullRequestOptions {
|
||||
base: string;
|
||||
body: string;
|
||||
head: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface GetFileContentOptions {
|
||||
owner: string;
|
||||
path: string;
|
||||
reference?: string;
|
||||
repo: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for interacting with the Gitea API.
|
||||
*/
|
||||
class GiteaService {
|
||||
private readonly client: AxiosInstance;
|
||||
|
||||
/**
|
||||
* Creates a new GiteaService instance.
|
||||
* @throws Error if GITEA_TOKEN environment variable is not set.
|
||||
*/
|
||||
public constructor() {
|
||||
const token = process.env.GITEA_TOKEN;
|
||||
if (token === undefined || token === "") {
|
||||
throw new Error("GITEA_TOKEN environment variable is required");
|
||||
}
|
||||
|
||||
this.client = axios.create({
|
||||
baseURL: `${config.giteaUrl}/api/v1`,
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- HTTP headers use PascalCase by convention */
|
||||
headers: {
|
||||
"Authorization": `token ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
/* eslint-enable @typescript-eslint/naming-convention -- End HTTP headers */
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new pull request in a repository.
|
||||
* @param options - The PR creation options.
|
||||
* @returns The created pull request.
|
||||
*/
|
||||
public async createPullRequest(
|
||||
options: CreatePullRequestOptions,
|
||||
): Promise<GiteaPullRequest> {
|
||||
const { base, body, head, owner, repo, title } = options;
|
||||
const { data } = await this.client.post<GiteaPullRequest>(
|
||||
`/repos/${owner}/${repo}/pulls`,
|
||||
{ base, body, head, title },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the content of a file in a repository.
|
||||
* @param options - Configuration specifying the file path and repository.
|
||||
* @returns The file content or null if not found.
|
||||
*/
|
||||
public async getFileContent(
|
||||
options: GetFileContentOptions,
|
||||
): Promise<GiteaFile | null> {
|
||||
const { owner, path, reference, repo } = options;
|
||||
try {
|
||||
const { data } = await this.client.get<GiteaFile>(
|
||||
`/repos/${owner}/${repo}/contents/${path}`,
|
||||
{ params: { ref: reference } },
|
||||
);
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (isAxiosError(error) && error.response?.status === 404) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all repositories in the configured organisation.
|
||||
* @returns Array of non-archived, non-disabled, non-mirror repositories.
|
||||
*/
|
||||
public async listOrgRepositories(): Promise<Array<GiteaRepository>> {
|
||||
const repositories: Array<GiteaRepository> = [];
|
||||
let page = 1;
|
||||
const limit = 100;
|
||||
|
||||
let hasMore = true;
|
||||
while (hasMore) {
|
||||
// eslint-disable-next-line no-await-in-loop -- Sequential pagination is required here
|
||||
const { data } = await this.client.get<Array<GiteaRepository>>(
|
||||
`/orgs/${config.giteaOrg}/repos`,
|
||||
{ params: { limit, page } },
|
||||
);
|
||||
|
||||
if (data.length === 0) {
|
||||
hasMore = false;
|
||||
} else {
|
||||
repositories.push(...data);
|
||||
page = page + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return repositories.filter((repo) => {
|
||||
return !repo.archived && !repo.disabled && !repo.mirror;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists pull requests in a repository.
|
||||
* @param owner - The repository owner.
|
||||
* @param repo - The repository name.
|
||||
* @param state - The PR state filter.
|
||||
* @returns Array of pull requests.
|
||||
*/
|
||||
public async listPullRequests(
|
||||
owner: string,
|
||||
repo: string,
|
||||
state: "all" | "closed" | "open" = "open",
|
||||
): Promise<Array<GiteaPullRequest>> {
|
||||
const { data } = await this.client.get<Array<GiteaPullRequest>>(
|
||||
`/repos/${owner}/${repo}/pulls`,
|
||||
{ params: { state } },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export { GiteaService };
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Logger } from "@nhcarrigan/logger";
|
||||
import axios, { isAxiosError, type AxiosInstance } from "axios";
|
||||
import { config } from "../config.js";
|
||||
import type { NpmPackageInfo } from "../types/package.types.js";
|
||||
|
||||
const logger = new Logger("NpmService", process.env.LOG_TOKEN ?? "");
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- GitHub API response types use snake_case property names */
|
||||
interface GitHubRelease {
|
||||
body?: string;
|
||||
tag_name: string;
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/naming-convention -- End GitHub API types */
|
||||
|
||||
interface ChangelogOptions {
|
||||
fromVersion: string;
|
||||
packageName: string;
|
||||
toVersion: string;
|
||||
}
|
||||
|
||||
interface GitHubReleaseOptions {
|
||||
fromVersion: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
toVersion: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a version is within a range.
|
||||
* @param version - The version to check.
|
||||
* @param from - The lower bound (exclusive).
|
||||
* @param to - The upper bound (inclusive).
|
||||
* @returns True if version is in range.
|
||||
*/
|
||||
const isVersionInRange = (
|
||||
version: string,
|
||||
from: string,
|
||||
to: string,
|
||||
): boolean => {
|
||||
return version > from && version <= to;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts repository information from a GitHub URL.
|
||||
* @param repoUrl - The repository URL.
|
||||
* @returns The owner and repo, or null if not a GitHub URL.
|
||||
*/
|
||||
const extractGitHubInfo = (
|
||||
repoUrl: string,
|
||||
): { owner: string; repo: string } | null => {
|
||||
if (!repoUrl.includes("github.com")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = repoUrl.split("github.com/")[1]?.split("/");
|
||||
if (parts === undefined || parts.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [ owner, repo ] = parts;
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 coverage ignore directive must be lowercase
|
||||
/* v8 ignore next 3 -- @preserve */
|
||||
if (owner === undefined || repo === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { owner, repo };
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalises a repository URL to HTTPS format.
|
||||
* @param url - The original repository URL.
|
||||
* @returns The normalised URL.
|
||||
*/
|
||||
const normaliseRepoUrl = (url: string): string => {
|
||||
return url.
|
||||
replace(/^git\+/, "").
|
||||
replace(/\.git$/, "").
|
||||
replace(/^git:\/\//, "https://").
|
||||
replace(/^ssh:\/\/git@/, "https://");
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches release notes from GitHub.
|
||||
* @param options - The GitHub release fetch options.
|
||||
* @returns Formatted changelog string.
|
||||
*/
|
||||
const fetchGitHubReleases = async(
|
||||
options: GitHubReleaseOptions,
|
||||
): Promise<string> => {
|
||||
const { fromVersion, owner, repo, toVersion } = options;
|
||||
const fallbackMessage = `Updated from ${fromVersion} to ${toVersion}`;
|
||||
|
||||
try {
|
||||
const { data: releases } = await axios.get<Array<GitHubRelease>>(
|
||||
`https://api.github.com/repos/${owner}/${repo}/releases`,
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- HTTP headers use PascalCase by convention */
|
||||
headers: {
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
/* eslint-enable @typescript-eslint/naming-convention -- End HTTP headers */
|
||||
},
|
||||
);
|
||||
|
||||
const relevantReleases = releases.filter((release) => {
|
||||
const tagName = release.tag_name.replace(/^v/, "");
|
||||
return isVersionInRange(tagName, fromVersion, toVersion);
|
||||
});
|
||||
|
||||
if (relevantReleases.length === 0) {
|
||||
return fallbackMessage;
|
||||
}
|
||||
|
||||
let changelog = `## Changelog\n\n`;
|
||||
for (const release of relevantReleases) {
|
||||
const version = release.tag_name;
|
||||
const body = release.body ?? "No release notes available";
|
||||
changelog = `${changelog}### ${version}\n\n${body}\n\n`;
|
||||
}
|
||||
|
||||
return changelog;
|
||||
} catch {
|
||||
return fallbackMessage;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Service for interacting with the npm registry.
|
||||
*/
|
||||
class NpmService {
|
||||
private readonly client: AxiosInstance;
|
||||
|
||||
/**
|
||||
* Creates a new NpmService instance.
|
||||
*/
|
||||
public constructor() {
|
||||
this.client = axios.create({
|
||||
baseURL: config.npmRegistryUrl,
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches changelog information for a package update.
|
||||
* @param options - The changelog fetch options.
|
||||
* @returns Formatted changelog string.
|
||||
*/
|
||||
public async getPackageChangelog(options: ChangelogOptions): Promise<string> {
|
||||
const { fromVersion, packageName, toVersion } = options;
|
||||
const fallbackMessage = `Updated from ${fromVersion} to ${toVersion}`;
|
||||
|
||||
try {
|
||||
const packageInfo = await this.getPackageInfo(packageName);
|
||||
if (packageInfo === null) {
|
||||
return `No changelog available for ${packageName}`;
|
||||
}
|
||||
|
||||
const repository = packageInfo.versions[toVersion]?.repository;
|
||||
if (repository?.url === undefined) {
|
||||
return fallbackMessage;
|
||||
}
|
||||
|
||||
const repoUrl = normaliseRepoUrl(repository.url);
|
||||
const githubInfo = extractGitHubInfo(repoUrl);
|
||||
|
||||
if (githubInfo === null) {
|
||||
return fallbackMessage;
|
||||
}
|
||||
|
||||
return await fetchGitHubReleases({
|
||||
fromVersion: fromVersion,
|
||||
owner: githubInfo.owner,
|
||||
repo: githubInfo.repo,
|
||||
toVersion: toVersion,
|
||||
});
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
`Error fetching changelog for ${packageName}`,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Catch blocks receive unknown type, cast needed for logger.error
|
||||
error as Error,
|
||||
);
|
||||
return fallbackMessage;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches package information from the npm registry.
|
||||
* @param packageName - The name of the package to look up.
|
||||
* @returns Package info or null if not found.
|
||||
*/
|
||||
public async getPackageInfo(
|
||||
packageName: string,
|
||||
): Promise<NpmPackageInfo | null> {
|
||||
try {
|
||||
const { data } = await this.client.get<NpmPackageInfo>(
|
||||
`/${encodeURIComponent(packageName)}`,
|
||||
);
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (isAxiosError(error) && error.response?.status === 404) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { NpmService };
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Logger } from "@nhcarrigan/logger";
|
||||
import { config } from "../config.js";
|
||||
import { DependencyAnalyzerService } from "./dependencyAnalyzerService.js";
|
||||
import { GiteaService } from "./giteaService.js";
|
||||
import {
|
||||
cloneRepository,
|
||||
createOrUpdateBranch,
|
||||
type ClonedRepository,
|
||||
} from "./gitService.js";
|
||||
import { NpmService } from "./npmService.js";
|
||||
import type { GiteaRepository } from "../types/gitea.types.js";
|
||||
import type { DependencyUpdate, PackageJson } from "../types/package.types.js";
|
||||
|
||||
const logger = new Logger("UpdateOrchestrator", process.env.LOG_TOKEN ?? "");
|
||||
|
||||
/**
|
||||
* Strips version prefix characters from a version string.
|
||||
* @param version - The version string with potential prefixes.
|
||||
* @returns The version without prefix characters.
|
||||
*/
|
||||
const stripVersionPrefix = (version: string): string => {
|
||||
return version.replace(/^[<=>^~]*/, "");
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates the body content for a PR.
|
||||
* @param update - The dependency update information.
|
||||
* @param changelog - The changelog content.
|
||||
* @returns The formatted PR body.
|
||||
*/
|
||||
const generatePRBody = (
|
||||
update: DependencyUpdate,
|
||||
changelog: string,
|
||||
): string => {
|
||||
return `## Dependency Update
|
||||
|
||||
Updates **${update.packageName}** from \`${update.currentVersion}\` to \`${update.latestVersion}\`.
|
||||
|
||||
### Type
|
||||
${update.type}
|
||||
|
||||
### Changelog
|
||||
${changelog}
|
||||
|
||||
---
|
||||
✨ This PR was created by Minori, your friendly dependency updater! 🌸`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Logs the result of a branch update operation.
|
||||
* @param update - The dependency update being processed.
|
||||
* @param result - The branch update operation result.
|
||||
* @param result.error - Error message if the operation failed.
|
||||
* @param result.status - The operation status (up-to-date, failed, updated, created).
|
||||
* @returns True if the PR should be created, false otherwise.
|
||||
*/
|
||||
const logBranchUpdateResult = async(
|
||||
update: DependencyUpdate,
|
||||
result: { error?: string; status: string },
|
||||
): Promise<boolean> => {
|
||||
if (result.status === "up-to-date") {
|
||||
await logger.log(
|
||||
"info",
|
||||
` ${update.packageName} branch already at ${update.latestVersion}, skipping...`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (result.status === "failed") {
|
||||
await logger.log(
|
||||
"warn",
|
||||
` Failed to update ${update.packageName}: ${result.error ?? "Unknown error"}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (result.status === "updated") {
|
||||
await logger.log(
|
||||
"info",
|
||||
` Updated existing branch for ${update.packageName} to ${update.latestVersion}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Service for orchestrating dependency updates across repositories.
|
||||
*/
|
||||
class UpdateOrchestratorService {
|
||||
private readonly dependencyAnalyzer: DependencyAnalyzerService;
|
||||
private readonly giteaService: GiteaService;
|
||||
private readonly giteaToken: string;
|
||||
private readonly npmService: NpmService;
|
||||
|
||||
/**
|
||||
* Creates a new UpdateOrchestratorService instance.
|
||||
* @throws Error if GITEA_TOKEN environment variable is not set.
|
||||
*/
|
||||
public constructor() {
|
||||
const token = process.env.GITEA_TOKEN;
|
||||
if (token === undefined || token === "") {
|
||||
throw new Error("GITEA_TOKEN environment variable is required");
|
||||
}
|
||||
this.giteaToken = token;
|
||||
this.giteaService = new GiteaService();
|
||||
this.npmService = new NpmService();
|
||||
this.dependencyAnalyzer = new DependencyAnalyzerService(this.npmService);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks and updates dependencies for all repositories.
|
||||
*/
|
||||
public async checkAndUpdateAllRepositories(): Promise<void> {
|
||||
await logger.log(
|
||||
"info",
|
||||
"Starting dependency update check for all repositories...",
|
||||
);
|
||||
|
||||
const repositories = await this.giteaService.listOrgRepositories();
|
||||
await logger.log(
|
||||
"info",
|
||||
`Found ${String(repositories.length)} repositories to check`,
|
||||
);
|
||||
|
||||
for (const repo of repositories) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop -- Sequential repository processing is required
|
||||
await this.processRepository(repo);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-await-in-loop -- Sequential processing is required
|
||||
await logger.error(
|
||||
`Error processing repository ${repo.name}`,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Catch blocks receive unknown type
|
||||
error as Error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await logger.log("info", "Dependency update check complete!");
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or updates a PR for a dependency update.
|
||||
* @param repo - The repository information.
|
||||
* @param update - The dependency update details.
|
||||
* @param clonedRepo - The cloned repository.
|
||||
*/
|
||||
private async createUpdatePR(
|
||||
repo: GiteaRepository,
|
||||
update: DependencyUpdate,
|
||||
clonedRepo: ClonedRepository,
|
||||
): Promise<void> {
|
||||
const branchName
|
||||
= `${config.prBranchPrefix}${update.packageName.replaceAll(/[/@]/g, "-")}`;
|
||||
|
||||
try {
|
||||
const result = await createOrUpdateBranch({
|
||||
branchName: branchName,
|
||||
clonedRepo: clonedRepo,
|
||||
logger: logger,
|
||||
packageName: update.packageName,
|
||||
targetVersion: update.latestVersion,
|
||||
});
|
||||
|
||||
const shouldCreatePR = await logBranchUpdateResult(update, result);
|
||||
if (!shouldCreatePR) {
|
||||
return;
|
||||
}
|
||||
|
||||
const changelog = await this.npmService.getPackageChangelog({
|
||||
fromVersion: stripVersionPrefix(update.currentVersion),
|
||||
packageName: update.packageName,
|
||||
toVersion: update.latestVersion,
|
||||
});
|
||||
|
||||
await this.giteaService.createPullRequest({
|
||||
base: repo.default_branch,
|
||||
body: generatePRBody(update, changelog),
|
||||
head: branchName,
|
||||
owner: config.giteaOrg,
|
||||
repo: repo.name,
|
||||
title: `deps: update ${update.packageName} to ${update.latestVersion}`,
|
||||
});
|
||||
|
||||
await logger.log("info", ` Created PR for ${update.packageName}`);
|
||||
} catch (error) {
|
||||
await logger.error(
|
||||
` Error creating PR for ${update.packageName}`,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Catch blocks receive unknown type
|
||||
error as Error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a single repository for dependency updates.
|
||||
* @param repo - The repository to process.
|
||||
*/
|
||||
private async processRepository(repo: GiteaRepository): Promise<void> {
|
||||
await logger.log("info", `\nChecking repository: ${repo.name}`);
|
||||
|
||||
const packageJsonFile = await this.giteaService.getFileContent({
|
||||
owner: config.giteaOrg,
|
||||
path: "package.json",
|
||||
reference: repo.default_branch,
|
||||
repo: repo.name,
|
||||
});
|
||||
|
||||
if (packageJsonFile === null || packageJsonFile.type !== "file") {
|
||||
await logger.log("info", ` No package.json found, skipping...`);
|
||||
return;
|
||||
}
|
||||
|
||||
const packageJsonContent = Buffer.from(
|
||||
packageJsonFile.content ?? "",
|
||||
"base64",
|
||||
).toString("utf-8");
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Dynamic JSON parsing requires type assertion
|
||||
const packageJson: PackageJson = JSON.parse(
|
||||
packageJsonContent,
|
||||
) as PackageJson;
|
||||
|
||||
const updates
|
||||
= await this.dependencyAnalyzer.analyzePackageJson(packageJson);
|
||||
|
||||
if (updates.length === 0) {
|
||||
await logger.log("info", ` All dependencies are up to date!`);
|
||||
return;
|
||||
}
|
||||
|
||||
await logger.log(
|
||||
"info",
|
||||
` Found ${String(updates.length)} dependencies to update`,
|
||||
);
|
||||
|
||||
const clonedRepo = await cloneRepository(
|
||||
logger,
|
||||
repo.name,
|
||||
this.giteaToken,
|
||||
);
|
||||
|
||||
try {
|
||||
for (const update of updates) {
|
||||
// eslint-disable-next-line no-await-in-loop -- Sequential update processing is required
|
||||
await this.createUpdatePR(repo, update, clonedRepo);
|
||||
}
|
||||
} finally {
|
||||
await clonedRepo.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { UpdateOrchestratorService };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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" } },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "@nhcarrigan/typescript-config",
|
||||
"compilerOptions": {
|
||||
"outDir": "./prod",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"exclude": [
|
||||
"vitest.config.ts",
|
||||
"test/**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
@@ -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" ],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user