Files
minori/test/services/updateOrchestratorService.spec.ts
hikari d9f959d115
Node.js CI / CI (push) Successful in 24s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m50s
feat: auto-merge non-breaking dependency updates (#5)
## Summary
Minori now automatically merges dependency update PRs when they meet safety criteria, reducing manual work whilst maintaining safety for potentially breaking changes.

## Changes
-  Add version comparison utility to detect major version bumps
-  Add Gitea service methods for checking commit status and merging PRs
-  Add auto-merge logic that checks:
  - Is it a major version bump? (if yes, skip auto-merge)
  - Did CI checks pass? (if no, skip auto-merge)
  - If both conditions pass → auto-merge! 🎉
-  Add comprehensive tests for all new functionality
- 📊 Maintain ~94% test coverage

## How It Works
When Minori processes a dependency update:
1. Check if a PR already exists for that dependency
2. If it exists, verify:
   - **Not a major version bump** (major bumps need manual review)
   - **CI status = "success"** (all checks must pass)
3. If both conditions are met → automatically merge the PR and delete the branch

## Test Plan
- [x] All 114 tests passing
- [x] New tests for version comparison utility
- [x] New tests for Gitea service extensions
- [x] Build successful
- [x] Linting clean

---
 This PR was created with help from Hikari~ 🌸

Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Reviewed-on: #5
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-20 20:04:18 -08:00

452 lines
16 KiB
TypeScript

/**
* @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 mockGiteaGetCommitStatus = vi.fn();
const mockGiteaMergePullRequest = 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 getCommitStatus = mockGiteaGetCommitStatus;
public getFileContent = mockGiteaGetFileContent;
public listOrgRepositories = mockGiteaListOrgRepositories;
public listPullRequests = mockGiteaListPullRequests;
public mergePullRequest = mockGiteaMergePullRequest;
},
};
});
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);
mockGiteaListPullRequests.mockResolvedValue([]);
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);
mockGiteaListPullRequests.mockResolvedValue([]);
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);
mockGiteaListPullRequests.mockResolvedValue([]);
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);
mockGiteaListPullRequests.mockResolvedValue([]);
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);
mockGiteaListPullRequests.mockResolvedValue([]);
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);
mockGiteaListPullRequests.mockResolvedValue([]);
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);
mockGiteaListPullRequests.mockResolvedValue([]);
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();
});
});