From f78f79385bfe967a7a73baa710b4b99a88465305 Mon Sep 17 00:00:00 2001 From: tnm Date: Thu, 26 Dec 2024 19:18:02 -0800 Subject: [PATCH] introduce hypersonic --- .eslintrc.js | 19 +++-- .prettierignore | 4 ++ examples/basic/package.json | 2 +- package-lock.json | 29 ++++++-- package.json | 9 ++- src/__tests__/config.test.ts | 26 ++++--- src/__tests__/github.test.ts | 58 ++++++--------- src/__tests__/hypersonic.test.ts | 120 ++++++++++++------------------- src/core/config.ts | 2 +- src/core/github.ts | 12 +--- src/core/pr.ts | 21 +++--- src/index.ts | 2 +- 12 files changed, 139 insertions(+), 165 deletions(-) create mode 100644 .prettierignore diff --git a/.eslintrc.js b/.eslintrc.js index 6421567..bae34da 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -4,16 +4,23 @@ module.exports = { plugins: ['@typescript-eslint'], extends: [ 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', - 'prettier', + 'plugin:@typescript-eslint/recommended' ], env: { node: true, - jest: true, + jest: true }, rules: { - '@typescript-eslint/explicit-function-return-type': 'warn', - '@typescript-eslint/no-explicit-any': 'warn', - '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-var-requires': 'off' }, + overrides: [ + { + files: ['**/__tests__/**/*'], + rules: { + '@typescript-eslint/no-unused-vars': 'off' + } + } + ] }; \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..ad96ead --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +dist +node_modules +coverage +*.d.ts \ No newline at end of file diff --git a/examples/basic/package.json b/examples/basic/package.json index b9aa75f..65a0b52 100644 --- a/examples/basic/package.json +++ b/examples/basic/package.json @@ -6,7 +6,7 @@ "start": "ts-node test-hypersonic.ts" }, "dependencies": { - "@runcased/hypersonic": "^0.2.3" + "@runcased/hypersonic": "^0.3.0" }, "devDependencies": { "typescript": "^5.3.0", diff --git a/package-lock.json b/package-lock.json index 67056bc..c8c80e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@runcased/hypersonic", - "version": "0.2.3", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@runcased/hypersonic", - "version": "0.2.3", + "version": "0.3.0", "license": "MIT", "dependencies": { "@octokit/rest": "^20.0.0", @@ -21,9 +21,10 @@ "glob": "^10.3.10", "jest": "^29.7.0", "lru-cache": "^10.2.0", + "prettier": "^3.2.5", "rimraf": "^5.0.5", "ts-jest": "^29.1.2", - "typescript": "^5.3.3" + "typescript": "5.5.3" }, "peerDependencies": { "@octokit/rest": ">=18.0.0" @@ -4904,6 +4905,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", + "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -5615,9 +5632,9 @@ } }, "node_modules/typescript": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", - "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", + "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index 7a45c50..bc57277 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,16 @@ { "name": "@runcased/hypersonic", - "version": "0.2.3", + "version": "0.3.0", "description": "Streamlined GitHub PR automation for modern TypeScript applications", "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { "build": "tsc", "test": "jest", - "lint": "eslint src/**/*.ts", + "lint": "eslint 'src/**/*.{js,ts}'", "clean": "rimraf dist", + "format": "prettier --write \"src/**/*.{js,ts}\"", + "format:check": "prettier --check \"src/**/*.{js,ts}\"", "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { @@ -24,9 +26,10 @@ "glob": "^10.3.10", "jest": "^29.7.0", "lru-cache": "^10.2.0", + "prettier": "^3.2.5", "rimraf": "^5.0.5", "ts-jest": "^29.1.2", - "typescript": "^5.3.3" + "typescript": "5.5.3" }, "peerDependencies": { "@octokit/rest": ">=18.0.0" diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index deeafce..1828310 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -1,9 +1,9 @@ -import { DEFAULT_PR_CONFIG, DEFAULT_CONFIG, MergeStrategy } from '../core/config'; +import { DEFAULT_PR_CONFIG, MergeStrategy } from '../core/config'; -describe('Configuration', () => { - describe('DEFAULT_PR_CONFIG', () => { - test('has all required fields', () => { - expect(DEFAULT_PR_CONFIG).toEqual(expect.objectContaining({ +describe('DEFAULT_PR_CONFIG', () => { + test('has all required fields', () => { + expect(DEFAULT_PR_CONFIG).toEqual( + expect.objectContaining({ title: expect.any(String), baseBranch: expect.any(String), draft: expect.any(Boolean), @@ -12,14 +12,12 @@ describe('Configuration', () => { teamReviewers: expect.any(Array), mergeStrategy: expect.any(String), deleteBranchOnMerge: expect.any(Boolean), - autoMerge: expect.any(Boolean) - })); - }); - - test('uses valid merge strategy', () => { - expect(Object.values(MergeStrategy)).toContain(DEFAULT_PR_CONFIG.mergeStrategy); - }); + autoMerge: expect.any(Boolean), + }) + ); }); - // ... more tests -}); \ No newline at end of file + test('uses valid merge strategy', () => { + expect(Object.values(MergeStrategy)).toContain(DEFAULT_PR_CONFIG.mergeStrategy); + }); +}); diff --git a/src/__tests__/github.test.ts b/src/__tests__/github.test.ts index 1d66c2d..521f2d2 100644 --- a/src/__tests__/github.test.ts +++ b/src/__tests__/github.test.ts @@ -10,26 +10,26 @@ describe('GitHubAPI', () => { beforeEach(() => { jest.clearAllMocks(); - + // Create base mock Octokit instance mockOctokit = { git: { getRef: jest.fn(), - createRef: jest.fn() + createRef: jest.fn(), }, repos: { getContent: jest.fn(), createOrUpdateFileContents: jest.fn(), - get: jest.fn() + get: jest.fn(), }, pulls: { create: jest.fn(), requestReviewers: jest.fn(), - updateBranch: jest.fn() + updateBranch: jest.fn(), }, issues: { - addLabels: jest.fn() - } + addLabels: jest.fn(), + }, } as unknown as Octokit; (Octokit as jest.MockedClass).mockImplementation(() => mockOctokit); @@ -39,7 +39,7 @@ describe('GitHubAPI', () => { describe('createBranch', () => { test('creates branch from base', async () => { jest.spyOn(mockOctokit.git, 'getRef').mockResolvedValue({ - data: { object: { sha: 'test-sha' } } + data: { object: { sha: 'test-sha' } }, } as any); await github.createBranch('user/repo', 'new-branch', 'main'); @@ -48,32 +48,24 @@ describe('GitHubAPI', () => { owner: 'user', repo: 'repo', ref: 'refs/heads/new-branch', - sha: 'test-sha' + sha: 'test-sha', }); }); test('handles errors', async () => { jest.spyOn(mockOctokit.git, 'getRef').mockRejectedValue(new Error('API error')); - await expect( - github.createBranch('user/repo', 'branch', 'main') - ).rejects.toThrow(GitHubError); + await expect(github.createBranch('user/repo', 'branch', 'main')).rejects.toThrow(GitHubError); }); }); describe('updateFile', () => { test('updates existing file', async () => { jest.spyOn(mockOctokit.repos, 'getContent').mockResolvedValue({ - data: { sha: 'existing-sha' } + data: { sha: 'existing-sha' }, } as any); - await github.updateFile( - 'user/repo', - 'test.txt', - 'content', - 'update message', - 'main' - ); + await github.updateFile('user/repo', 'test.txt', 'content', 'update message', 'main'); expect(mockOctokit.repos.createOrUpdateFileContents).toHaveBeenCalledWith({ owner: 'user', @@ -82,20 +74,14 @@ describe('GitHubAPI', () => { message: 'update message', content: expect.any(String), branch: 'main', - sha: 'existing-sha' + sha: 'existing-sha', }); }); test('creates new file', async () => { jest.spyOn(mockOctokit.repos, 'getContent').mockRejectedValue({ status: 404 }); - await github.updateFile( - 'user/repo', - 'new.txt', - 'content', - 'create message', - 'main' - ); + await github.updateFile('user/repo', 'new.txt', 'content', 'create message', 'main'); expect(mockOctokit.repos.createOrUpdateFileContents).toHaveBeenCalledWith({ owner: 'user', @@ -103,7 +89,7 @@ describe('GitHubAPI', () => { path: 'new.txt', message: 'create message', content: expect.any(String), - branch: 'main' + branch: 'main', }); }); }); @@ -111,7 +97,7 @@ describe('GitHubAPI', () => { describe('createPullRequest', () => { test('creates PR successfully', async () => { jest.spyOn(mockOctokit.pulls, 'create').mockResolvedValue({ - data: { html_url: 'https://github.com/user/repo/pull/1' } + data: { html_url: 'https://github.com/user/repo/pull/1' }, } as any); const url = await github.createPullRequest( @@ -130,7 +116,7 @@ describe('GitHubAPI', () => { body: 'Test description', head: 'feature-branch', base: 'main', - draft: false + draft: false, }); expect(url).toBe('https://github.com/user/repo/pull/1'); }); @@ -144,7 +130,7 @@ describe('GitHubAPI', () => { owner: 'user', repo: 'repo', pull_number: 123, - reviewers: ['reviewer1', 'reviewer2'] + reviewers: ['reviewer1', 'reviewer2'], }); }); }); @@ -157,7 +143,7 @@ describe('GitHubAPI', () => { owner: 'user', repo: 'repo', issue_number: 123, - labels: ['bug', 'enhancement'] + labels: ['bug', 'enhancement'], }); }); }); @@ -165,7 +151,7 @@ describe('GitHubAPI', () => { describe('getDefaultBranch', () => { test('gets default branch', async () => { jest.spyOn(mockOctokit.repos, 'get').mockResolvedValue({ - data: { default_branch: 'main' } + data: { default_branch: 'main' }, } as any); const branch = await github.getDefaultBranch('user/repo'); @@ -173,7 +159,7 @@ describe('GitHubAPI', () => { expect(branch).toBe('main'); expect(mockOctokit.repos.get).toHaveBeenCalledWith({ owner: 'user', - repo: 'repo' + repo: 'repo', }); }); }); @@ -200,8 +186,8 @@ describe('GitHubAPI', () => { jest.spyOn(mockOctokit.repos, 'get').mockRejectedValue({ status: 404 }); await expect(github.getDefaultBranch('user/repo')).rejects.toMatchObject({ - status: 404 + status: 404, }); }); }); -}); \ No newline at end of file +}); diff --git a/src/__tests__/hypersonic.test.ts b/src/__tests__/hypersonic.test.ts index 3c05cf1..57db75f 100644 --- a/src/__tests__/hypersonic.test.ts +++ b/src/__tests__/hypersonic.test.ts @@ -15,7 +15,7 @@ describe('Hypersonic', () => { beforeEach(() => { jest.clearAllMocks(); - + // Create a properly typed mock mockGitHub = { createBranch: jest.fn(), @@ -31,16 +31,16 @@ describe('Hypersonic', () => { octokit: {} as any, token: 'test-token', baseUrl: 'https://api.github.com', - git: {} as any + git: {} as any, } as unknown as jest.Mocked; - + (GitHubAPI as jest.MockedClass).mockImplementation(() => mockGitHub); - + // Create instance with all required config const config: HypersonicConfig = { githubToken: 'test-token', baseUrl: 'https://api.github.com', - defaultPrConfig: DEFAULT_PR_CONFIG + defaultPrConfig: DEFAULT_PR_CONFIG, }; hypersonic = new Hypersonic(config); @@ -58,7 +58,7 @@ describe('Hypersonic', () => { const config: HypersonicConfig = { githubToken: 'test-token', baseUrl: 'https://custom.github.com', - defaultPrConfig: DEFAULT_PR_CONFIG + defaultPrConfig: DEFAULT_PR_CONFIG, }; const instance = new Hypersonic(config); expect(instance).toBeInstanceOf(Hypersonic); @@ -74,11 +74,14 @@ describe('Hypersonic', () => { }); test('requires baseUrl when initialized with config object', () => { - expect(() => new Hypersonic({ - githubToken: 'test-token', - defaultPrConfig: DEFAULT_PR_CONFIG - // missing baseUrl - } as any)).toThrow(); + expect( + () => + new Hypersonic({ + githubToken: 'test-token', + defaultPrConfig: DEFAULT_PR_CONFIG, + // missing baseUrl + } as any) + ).toThrow(); }); }); @@ -89,11 +92,7 @@ describe('Hypersonic', () => { }); test('creates PR with minimal config', async () => { - await hypersonic.createPrFromContent( - 'user/repo', - 'test content', - 'test.txt' - ); + await hypersonic.createPrFromContent('user/repo', 'test content', 'test.txt'); expect(mockGitHub.createBranch).toHaveBeenCalled(); expect(mockGitHub.updateFile).toHaveBeenCalledWith( @@ -107,19 +106,14 @@ describe('Hypersonic', () => { }); test('creates PR with full config', async () => { - await hypersonic.createPrFromContent( - 'user/repo', - 'content', - 'file.txt', - { - title: 'Test PR', - description: 'Test description', - labels: ['test'], - reviewers: ['reviewer'], - draft: true, - commitMessage: 'custom commit' - } - ); + await hypersonic.createPrFromContent('user/repo', 'content', 'file.txt', { + title: 'Test PR', + description: 'Test description', + labels: ['test'], + reviewers: ['reviewer'], + draft: true, + commitMessage: 'custom commit', + }); expect(mockGitHub.updateFile).toHaveBeenCalledWith( 'user/repo', @@ -128,16 +122,8 @@ describe('Hypersonic', () => { 'custom commit', expect.any(String) ); - expect(mockGitHub.addLabels).toHaveBeenCalledWith( - 'user/repo', - 1, - ['test'] - ); - expect(mockGitHub.addReviewers).toHaveBeenCalledWith( - 'user/repo', - 1, - ['reviewer'] - ); + expect(mockGitHub.addLabels).toHaveBeenCalledWith('user/repo', 1, ['test']); + expect(mockGitHub.addReviewers).toHaveBeenCalledWith('user/repo', 1, ['reviewer']); }); test('handles API errors gracefully', async () => { @@ -155,15 +141,11 @@ describe('Hypersonic', () => { baseUrl: 'https://api.github.com', defaultPrConfig: { ...DEFAULT_PR_CONFIG, - baseBranch: 'develop' - } + baseBranch: 'develop', + }, }); - await customHypersonic.createPrFromContent( - 'user/repo', - 'content', - 'file.txt' - ); + await customHypersonic.createPrFromContent('user/repo', 'content', 'file.txt'); expect(mockGitHub.createBranch).toHaveBeenCalledWith( 'user/repo', @@ -182,13 +164,10 @@ describe('Hypersonic', () => { test('creates PR with multiple files', async () => { const contents = { 'file1.txt': 'content1', - 'file2.txt': 'content2' + 'file2.txt': 'content2', }; - await hypersonic.createPrFromMultipleContents( - 'user/repo', - contents - ); + await hypersonic.createPrFromMultipleContents('user/repo', contents); expect(mockGitHub.updateFile).toHaveBeenCalledTimes(2); expect(mockGitHub.createBranch).toHaveBeenCalledTimes(1); @@ -196,9 +175,7 @@ describe('Hypersonic', () => { }); test('handles empty files object', async () => { - await expect( - hypersonic.createPrFromMultipleContents('user/repo', {}) - ).rejects.toThrow(); + await expect(hypersonic.createPrFromMultipleContents('user/repo', {})).rejects.toThrow(); }); test('uses custom commit messages', async () => { @@ -230,11 +207,7 @@ describe('Hypersonic', () => { const mockContent = 'file content'; (require('fs/promises').readFile as jest.Mock).mockResolvedValue(mockContent); - await hypersonic.createPrFromFile( - 'user/repo', - 'local/file.txt', - 'remote/file.txt' - ); + await hypersonic.createPrFromFile('user/repo', 'local/file.txt', 'remote/file.txt'); expect(mockGitHub.updateFile).toHaveBeenCalledWith( 'user/repo', @@ -258,7 +231,7 @@ describe('Hypersonic', () => { test('handles multiple local files', async () => { const mockFiles = { 'local1.txt': 'remote1.txt', - 'local2.txt': 'remote2.txt' + 'local2.txt': 'remote2.txt', }; (require('fs/promises').readFile as jest.Mock).mockResolvedValue('content'); @@ -268,9 +241,9 @@ describe('Hypersonic', () => { }); test('rejects empty files object', async () => { - await expect( - hypersonic.createPrFromFiles('user/repo', {}) - ).rejects.toThrow('No files provided'); + await expect(hypersonic.createPrFromFiles('user/repo', {})).rejects.toThrow( + 'No files provided' + ); }); }); @@ -279,7 +252,7 @@ describe('Hypersonic', () => { const config: HypersonicConfig = { githubToken: 'test-token', baseUrl: 'https://custom.github.com', - defaultPrConfig: DEFAULT_PR_CONFIG + defaultPrConfig: DEFAULT_PR_CONFIG, }; const instance = new Hypersonic(config); expect(GitHubAPI).toHaveBeenCalledWith('test-token', 'https://custom.github.com'); @@ -296,25 +269,20 @@ describe('Hypersonic', () => { baseUrl: 'https://api.github.com', defaultPrConfig: { ...DEFAULT_PR_CONFIG, - labels: ['default-label'] - } + labels: ['default-label'], + }, }; const instance = new Hypersonic(config); - await instance.createPrFromContent( - 'user/repo', - 'content', - 'file.txt', - { - labels: ['custom-label'] - } - ); + await instance.createPrFromContent('user/repo', 'content', 'file.txt', { + labels: ['custom-label'], + }); expect(mockGitHub.addLabels).toHaveBeenCalledWith( 'user/repo', expect.any(Number), - ['custom-label'] // Should use provided labels, not defaults + ['custom-label'] // Should use provided labels, not defaults ); }); }); -}); \ No newline at end of file +}); diff --git a/src/core/config.ts b/src/core/config.ts index 3d33fcf..f276416 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -1,7 +1,7 @@ export enum MergeStrategy { MERGE = 'merge', SQUASH = 'squash', - REBASE = 'rebase' + REBASE = 'rebase', } // Add a type for string literals that match MergeStrategy values diff --git a/src/core/github.ts b/src/core/github.ts index 91beec6..987ba8e 100644 --- a/src/core/github.ts +++ b/src/core/github.ts @@ -91,10 +91,7 @@ export class GitHubAPI { } } } catch (error: any) { - throw new GitHubError( - `Failed to update file: ${error}`, - error?.status - ); + throw new GitHubError(`Failed to update file: ${error}`, error?.status); } } @@ -166,7 +163,7 @@ export class GitHubAPI { owner, repo: repoName, pull_number: prNumber, - merge_method: strategy + merge_method: strategy, }); } catch (error) { throw new GitHubError(`Failed to enable auto-merge: ${error}`); @@ -184,10 +181,7 @@ export class GitHubAPI { return repository.default_branch; } catch (error: any) { - throw new GitHubError( - `Failed to get default branch: ${error}`, - error?.status - ); + throw new GitHubError(`Failed to get default branch: ${error}`, error?.status); } } diff --git a/src/core/pr.ts b/src/core/pr.ts index 684a74e..6e1113a 100644 --- a/src/core/pr.ts +++ b/src/core/pr.ts @@ -12,9 +12,9 @@ export class Hypersonic { if (!config) { throw new Error('GitHub token is required'); } - this.config = { - ...DEFAULT_CONFIG, - githubToken: config + this.config = { + ...DEFAULT_CONFIG, + githubToken: config, }; } else { if (!config.githubToken) { @@ -23,16 +23,13 @@ export class Hypersonic { if (!config.baseUrl) { throw new Error('baseUrl is required when using config object'); } - this.config = { - ...DEFAULT_CONFIG, - ...config + this.config = { + ...DEFAULT_CONFIG, + ...config, }; } - this.github = new GitHubAPI( - this.config.githubToken, - this.config.baseUrl - ); + this.github = new GitHubAPI(this.config.githubToken, this.config.baseUrl); } private async preparePrConfig(config: Partial = {}): Promise { @@ -40,9 +37,9 @@ export class Hypersonic { const mergedConfig: PRConfig = { ...DEFAULT_PR_CONFIG, ...this.config.defaultPrConfig, - ...config + ...config, }; - + return mergedConfig; } diff --git a/src/index.ts b/src/index.ts index f346f2f..42596b8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,4 +2,4 @@ export { Hypersonic } from './core/pr'; export { HypersonicConfig, PRConfig, MergeStrategy } from './core/config'; export { GitHubError } from './core/errors'; -export const version = '0.2.3'; +export const version = '0.3.0';