diff --git a/package-lock.json b/package-lock.json index 2c15f7809..c7b0cc475 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "@nestjs/jwt": "^11.0.0", "@nestjs/platform-express": "^11.1.6", "@nestjs/typeorm": "^11.0.0", + "@octokit/auth-app": "^8.1.1", "@packmind/deployments": "^0.0.1", "@react-router/fs-routes": "^7.6.3", "@react-router/node": "^7.2.0", @@ -12527,6 +12528,7 @@ "version": "11.1.3", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.3.tgz", "integrity": "sha512-CeXG6/eEqgFIkPkmU00y18Dd3DLOIDFhPItzJK1SWckKo6IhcnfoRJzGx75bmuvUMjb51j6An96S/+MJ2ty9jA==", + "dev": true, "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -15602,6 +15604,152 @@ "node": ">=10" } }, + "node_modules/@octokit/auth-app": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@octokit/auth-app/-/auth-app-8.1.1.tgz", + "integrity": "sha512-yW9YUy1cuqWlz8u7908ed498wJFt42VYsYWjvepjojM4BdZSp4t+5JehFds7LfvYi550O/GaUI94rgbhswvxfA==", + "license": "MIT", + "dependencies": { + "@octokit/auth-oauth-app": "^9.0.2", + "@octokit/auth-oauth-user": "^6.0.1", + "@octokit/request": "^10.0.5", + "@octokit/request-error": "^7.0.1", + "@octokit/types": "^15.0.0", + "toad-cache": "^3.7.0", + "universal-github-app-jwt": "^2.2.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/auth-oauth-app": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-app/-/auth-oauth-app-9.0.2.tgz", + "integrity": "sha512-vmjSHeuHuM+OxZLzOuoYkcY3OPZ8erJ5lfswdTmm+4XiAKB5PmCk70bA1is4uwSl/APhRVAv4KHsgevWfEKIPQ==", + "license": "MIT", + "dependencies": { + "@octokit/auth-oauth-device": "^8.0.2", + "@octokit/auth-oauth-user": "^6.0.1", + "@octokit/request": "^10.0.5", + "@octokit/types": "^15.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/auth-oauth-device": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-device/-/auth-oauth-device-8.0.2.tgz", + "integrity": "sha512-KW7Ywrz7ei7JX+uClWD2DN1259fnkoKuVdhzfpQ3/GdETaCj4Tx0IjvuJrwhP/04OhcMu5yR6tjni0V6LBihdw==", + "license": "MIT", + "dependencies": { + "@octokit/oauth-methods": "^6.0.1", + "@octokit/request": "^10.0.5", + "@octokit/types": "^15.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/auth-oauth-user": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-user/-/auth-oauth-user-6.0.1.tgz", + "integrity": "sha512-vlKsL1KUUPvwXpv574zvmRd+/4JiDFXABIZNM39+S+5j2kODzGgjk7w5WtiQ1x24kRKNaE7v9DShNbw43UA3Hw==", + "license": "MIT", + "dependencies": { + "@octokit/auth-oauth-device": "^8.0.2", + "@octokit/oauth-methods": "^6.0.1", + "@octokit/request": "^10.0.5", + "@octokit/types": "^15.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.1.tgz", + "integrity": "sha512-7P1dRAZxuWAOPI7kXfio88trNi/MegQ0IJD3vfgC3b+LZo1Qe6gRJc2v0mz2USWWJOKrB2h5spXCzGbw+fAdqA==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^15.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/oauth-authorization-url": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@octokit/oauth-authorization-url/-/oauth-authorization-url-8.0.0.tgz", + "integrity": "sha512-7QoLPRh/ssEA/HuHBHdVdSgF8xNLz/Bc5m9fZkArJE5bb6NmVkDm3anKxXPmN1zh6b5WKZPRr3697xKT/yM3qQ==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/oauth-methods": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@octokit/oauth-methods/-/oauth-methods-6.0.1.tgz", + "integrity": "sha512-xi6Iut3izMCFzXBJtxxJehxJmAKjE8iwj6L5+raPRwlTNKAbOOBJX7/Z8AF5apD4aXvc2skwIdOnC+CQ4QuA8Q==", + "license": "MIT", + "dependencies": { + "@octokit/oauth-authorization-url": "^8.0.0", + "@octokit/request": "^10.0.5", + "@octokit/request-error": "^7.0.1", + "@octokit/types": "^15.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-26.0.0.tgz", + "integrity": "sha512-7AtcfKtpo77j7Ts73b4OWhOZHTKo/gGY8bB3bNBQz4H+GRSWqx2yvj8TXRsbdTE0eRmYmXOEY66jM7mJ7LzfsA==", + "license": "MIT" + }, + "node_modules/@octokit/request": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.5.tgz", + "integrity": "sha512-TXnouHIYLtgDhKo+N6mXATnDBkV05VwbR0TtMWpgTHIoQdRQfCSzmy/LGqR1AbRMbijq/EckC/E3/ZNcU92NaQ==", + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.1", + "@octokit/request-error": "^7.0.1", + "@octokit/types": "^15.0.0", + "fast-content-type-parse": "^3.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.0.1.tgz", + "integrity": "sha512-CZpFwV4+1uBrxu7Cw8E5NCXDWFNf18MSY23TdxCBgjw1tXXHvTrZVsXlW8hgFTOLw8RQR1BBrMvYRtuyaijHMA==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^15.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-15.0.1.tgz", + "integrity": "sha512-sdiirM93IYJ9ODDCBgmRPIboLbSkpLa5i+WLuXH8b8Atg+YMLAyLvDDhNWLV4OYd08tlvYfVm/dw88cqHWtw1Q==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^26.0.0" + } + }, "node_modules/@opentelemetry/api": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", @@ -16183,10 +16331,6 @@ "resolved": "packages/accounts", "link": true }, - "node_modules/@packmind/amplitude": { - "resolved": "packages/amplitude", - "link": true - }, "node_modules/@packmind/analytics": { "resolved": "packages/analytics", "link": true @@ -16219,10 +16363,6 @@ "resolved": "packages/jobs", "link": true }, - "node_modules/@packmind/linter": { - "resolved": "packages/linter", - "link": true - }, "node_modules/@packmind/linter-ast": { "resolved": "packages/linter-ast", "link": true @@ -29494,6 +29634,22 @@ "node": ">=0.10.0" } }, + "node_modules/fast-content-type-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", + "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/fast-decode-uri-component": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", @@ -49738,6 +49894,18 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/universal-github-app-jwt": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/universal-github-app-jwt/-/universal-github-app-jwt-2.2.2.tgz", + "integrity": "sha512-dcmbeSrOdTnsjGjUfAlqNDJrhxXizjAz94ija9Qw8YkZ1uu0d+GoZzyH+Jb9tIIqvGsadUfwg+22k5aDqqwzbw==", + "license": "MIT" + }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "license": "ISC" + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -52314,6 +52482,7 @@ "packages/amplitude": { "name": "@packmind/amplitude", "version": "0.0.1", + "extraneous": true, "dependencies": { "@nestjs/common": "^11.1.6", "@nestjs/testing": "^11.0.0", @@ -52443,6 +52612,7 @@ "packages/linter": { "name": "@packmind/linter", "version": "0.0.1", + "extraneous": true, "dependencies": { "@nestjs/common": "^11.1.6", "@packmind/accounts": "*", diff --git a/package.json b/package.json index de7dd1da7..d2201cc11 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "@types/validator": "^13.15.3", "@vitejs/plugin-react": "^4.2.0", "babel-jest": "^29.7.0", + "copy-webpack-plugin": "^11.0.0", "esbuild-loader": "^4.3.0", "eslint": "^9.8.0", "eslint-config-prettier": "^10.1.8", @@ -110,7 +111,6 @@ "vite-plugin-checker": "^0.10.3", "vite-plugin-dts": "~4.5.0", "vite-tsconfig-paths": "^5.1.4", - "copy-webpack-plugin": "^11.0.0", "webpack-cli": "^5.1.4" }, "dependencies": { @@ -142,6 +142,7 @@ "@nestjs/jwt": "^11.0.0", "@nestjs/platform-express": "^11.1.6", "@nestjs/typeorm": "^11.0.0", + "@octokit/auth-app": "^8.1.1", "@packmind/deployments": "^0.0.1", "@react-router/fs-routes": "^7.6.3", "@react-router/node": "^7.2.0", diff --git a/packages/git/README.md b/packages/git/README.md index e46c52284..10b98d4a3 100644 --- a/packages/git/README.md +++ b/packages/git/README.md @@ -32,15 +32,37 @@ This will create or update the file at the specified path in the repository. ### GitHub Provider -The `GithubProvider` class implements the `IGitProvider` interface and provides functionality to interact with GitHub using a personal access token. +The `GithubProvider` class implements the `IGitProvider` interface and provides functionality to interact with GitHub using either a personal access token or GitHub App authentication. #### Initialization +##### With Personal Access Token + ```typescript import { GithubProvider } from '@packmind/git'; // Initialize with a GitHub token -const githubProvider = new GithubProvider('your-github-token', logger); +const githubProvider = new GithubProvider( + { type: 'token', token: 'your-github-token' }, + logger +); +``` + +##### With GitHub App Authentication + +```typescript +import { GithubProvider } from '@packmind/git'; + +// Initialize with GitHub App credentials +const githubProvider = new GithubProvider( + { + type: 'app', + appId: 'your-app-id', + privateKey: '-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----', + installationId: 'your-installation-id', + }, + logger +); ``` #### Listing Available Repositories diff --git a/packages/git/src/infra/repositories/GitProviderFactory.ts b/packages/git/src/infra/repositories/GitProviderFactory.ts index 681c086b3..67cb9d0f0 100644 --- a/packages/git/src/infra/repositories/GitProviderFactory.ts +++ b/packages/git/src/infra/repositories/GitProviderFactory.ts @@ -25,20 +25,43 @@ export class GitProviderFactory implements IGitProviderFactory { } createGitProvider(provider: GitProvider): IGitProvider { - if (!provider.token) { - throw new Error('Git provider token not configured'); - } - switch (provider.source) { - case GitProviderVendors.github: - return new GithubProvider(provider.token); + case GitProviderVendors.github: { + // Check for GitHub App authentication + if ( + provider.appId && + provider.privateKey && + provider.installationId + ) { + return new GithubProvider({ + type: 'app', + appId: provider.appId, + privateKey: provider.privateKey, + installationId: provider.installationId, + }); + } + + // Fallback to token authentication + if (!provider.token) { + throw new Error( + 'GitHub provider requires either token or GitHub App credentials (appId, privateKey, installationId)', + ); + } + + return new GithubProvider({ type: 'token', token: provider.token }); + } + + case GitProviderVendors.gitlab: { + if (!provider.token) { + throw new Error('GitLab provider token not configured'); + } - case GitProviderVendors.gitlab: return new GitlabProvider( provider.token, this.logger, provider.url || undefined, ); + } default: throw new Error(`Unsupported git provider source: ${provider.source}`); diff --git a/packages/git/src/infra/repositories/github/GithubProvider.spec.ts b/packages/git/src/infra/repositories/github/GithubProvider.spec.ts index fac943c75..532fa2eb6 100644 --- a/packages/git/src/infra/repositories/github/GithubProvider.spec.ts +++ b/packages/git/src/infra/repositories/github/GithubProvider.spec.ts @@ -1,10 +1,16 @@ import axios from 'axios'; -import { GithubProvider } from './GithubProvider'; +import { GithubProvider, GithubAuthConfig } from './GithubProvider'; import { PackmindLogger } from '@packmind/shared'; import { stubLogger } from '@packmind/shared/test'; +import { createAppAuth } from '@octokit/auth-app'; jest.mock('axios'); +jest.mock('@octokit/auth-app'); + const mockedAxios = axios as jest.Mocked; +const mockedCreateAppAuth = createAppAuth as jest.MockedFunction< + typeof createAppAuth +>; describe('GithubProvider', () => { let githubProvider: GithubProvider; @@ -17,17 +23,32 @@ describe('GithubProvider', () => { mockAxiosInstance = { get: jest.fn(), + interceptors: { + request: { + use: jest.fn((handler) => { + mockAxiosInstance._requestInterceptor = handler; + return 0; + }), + }, + }, }; mockedAxios.create.mockReturnValue(mockAxiosInstance); - - githubProvider = new GithubProvider('fake-token', mockLogger); }); afterEach(() => { jest.clearAllMocks(); }); + describe('with token authentication', () => { + beforeEach(() => { + const authConfig: GithubAuthConfig = { + type: 'token', + token: 'fake-token', + }; + githubProvider = new GithubProvider(authConfig, mockLogger); + }); + describe('listAvailableRepositories', () => { describe('when API call succeeds', () => { it('returns formatted repository list', async () => { @@ -399,4 +420,99 @@ describe('GithubProvider', () => { ); }); }); + }); + + describe('with GitHub App authentication', () => { + let mockAuth: jest.Mock; + + beforeEach(() => { + mockAuth = jest.fn().mockResolvedValue({ token: 'app-installation-token' }); + mockedCreateAppAuth.mockReturnValue(mockAuth); + + const authConfig: GithubAuthConfig = { + type: 'app', + appId: '123456', + privateKey: '-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----', + installationId: '789012', + }; + githubProvider = new GithubProvider(authConfig, mockLogger); + }); + + describe('listAvailableRepositories', () => { + it('uses GitHub App token for authentication', async () => { + const mockResponse = { + data: [ + { + name: 'test-repo', + owner: { login: 'test-owner' }, + description: 'Test repository', + private: false, + default_branch: 'main', + language: 'TypeScript', + stargazers_count: 42, + permissions: { + pull: true, + push: true, + admin: false, + }, + }, + ], + }; + + mockAxiosInstance.get.mockResolvedValue(mockResponse); + + const result = await githubProvider.listAvailableRepositories(); + + expect(mockedCreateAppAuth).toHaveBeenCalledWith({ + appId: '123456', + privateKey: '-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----', + installationId: '789012', + }); + + expect(mockAuth).toHaveBeenCalledWith({ type: 'installation' }); + + expect(result).toEqual([ + { + name: 'test-repo', + owner: 'test-owner', + description: 'Test repository', + private: false, + defaultBranch: 'main', + language: 'TypeScript', + stars: 42, + }, + ]); + }); + }); + + describe('checkBranchExists', () => { + const owner = 'test-owner'; + const repo = 'test-repo'; + const branch = 'main'; + + it('uses GitHub App token for authentication', async () => { + const mockResponse = { + data: { + name: 'main', + commit: { sha: 'abc123' }, + protected: false, + }, + }; + + mockAxiosInstance.get.mockResolvedValue(mockResponse); + + const result = await githubProvider.checkBranchExists(owner, repo, branch); + + expect(mockedCreateAppAuth).toHaveBeenCalledWith({ + appId: '123456', + privateKey: '-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----', + installationId: '789012', + }); + + expect(mockAuth).toHaveBeenCalledWith({ type: 'installation' }); + + expect(result).toBe(true); + }); + }); + }); }); diff --git a/packages/git/src/infra/repositories/github/GithubProvider.ts b/packages/git/src/infra/repositories/github/GithubProvider.ts index a706d71cc..46399f802 100644 --- a/packages/git/src/infra/repositories/github/GithubProvider.ts +++ b/packages/git/src/infra/repositories/github/GithubProvider.ts @@ -2,24 +2,61 @@ import { IGitProvider } from '../../../domain/repositories/IGitProvider'; import axios, { AxiosInstance } from 'axios'; import { PackmindLogger } from '@packmind/shared'; import { isNativeError } from 'util/types'; +import { createAppAuth } from '@octokit/auth-app'; const origin = 'GithubProvider'; +export type GithubAuthConfig = + | { + type: 'token'; + token: string; + } + | { + type: 'app'; + appId: string; + privateKey: string; + installationId: string; + }; + export class GithubProvider implements IGitProvider { private readonly client: AxiosInstance; + private readonly authConfig: GithubAuthConfig; constructor( - private readonly token: string, + authConfig: GithubAuthConfig, private readonly logger: PackmindLogger = new PackmindLogger(origin), ) { + this.authConfig = authConfig; this.client = axios.create({ baseURL: 'https://api.github.com', headers: { - Authorization: `token ${token}`, 'Content-Type': 'application/json', Accept: 'application/vnd.github.v3+json', }, }); + + // Add interceptor to handle authentication + this.client.interceptors.request.use(async (config) => { + const token = await this.getAuthToken(); + config.headers.Authorization = `token ${token}`; + return config; + }); + } + + private async getAuthToken(): Promise { + if (this.authConfig.type === 'token') { + return this.authConfig.token; + } + + // GitHub App authentication + const auth = createAppAuth({ + appId: this.authConfig.appId, + privateKey: this.authConfig.privateKey, + installationId: this.authConfig.installationId, + }); + + const { token } = await auth({ type: 'installation' }); + return token; } async listAvailableRepositories(): Promise< diff --git a/packages/shared/src/types/git/GitProvider.ts b/packages/shared/src/types/git/GitProvider.ts index 23660a50e..ddac3e487 100644 --- a/packages/shared/src/types/git/GitProvider.ts +++ b/packages/shared/src/types/git/GitProvider.ts @@ -19,6 +19,10 @@ export type GitProvider = { organizationId: OrganizationId; url: string | null; token: string | null; + // GitHub App authentication fields + appId?: string | null; + privateKey?: string | null; + installationId?: string | null; organization?: Organization; repos?: GitRepo[]; };