diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f1aad53c..45eac1318 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,25 +160,4 @@ jobs: path: | **/coverage/** !**/coverage/tmp/** - retention-days: 7 - - # Final status check - ensures all parallel jobs pass - ci-status: - name: CI Status - needs: [build, lint, test] - runs-on: blacksmith-2vcpu-ubuntu-2404 - if: always() - timeout-minutes: 1 - steps: - - name: Check CI Status - run: | - if [[ "${{ needs.build.result }}" != "success" || - "${{ needs.lint.result }}" != "success" || - "${{ needs.test.result }}" != "success" ]]; then - echo "❌ CI failed" - echo "Build: ${{ needs.build.result }}" - echo "Lint: ${{ needs.lint.result }}" - echo "Test: ${{ needs.test.result }}" - exit 1 - fi - echo "✅ All CI checks passed!" \ No newline at end of file + retention-days: 7 \ No newline at end of file diff --git a/apps/server/src/api/v2/github/handlers/auth-callback.ts b/apps/server/src/api/v2/github/handlers/auth-callback.ts index 3fc12b26f..b68a4dc36 100644 --- a/apps/server/src/api/v2/github/handlers/auth-callback.ts +++ b/apps/server/src/api/v2/github/handlers/auth-callback.ts @@ -80,7 +80,9 @@ export async function authCallbackHandler( const callbackPayload = { action: 'created' as const, installation: { - id: Number.parseInt(request.installation_id, 10), + id: Number.isNaN(Number(request.installation_id)) + ? 0 + : Number.parseInt(request.installation_id, 10), account: { // These will be updated when the webhook arrives with full details id: 0, diff --git a/apps/server/src/api/v2/github/handlers/auth-init.int.test.ts b/apps/server/src/api/v2/github/handlers/auth-init.int.test.ts index e4dabd305..b5bbfa94f 100644 --- a/apps/server/src/api/v2/github/handlers/auth-init.int.test.ts +++ b/apps/server/src/api/v2/github/handlers/auth-init.int.test.ts @@ -277,12 +277,12 @@ describe('Auth Init Handler Integration Tests', () => { const metadata = JSON.parse(storedState.description); expect(metadata.expiresAt).toBeTruthy(); - // Should expire in 15 minutes + // Should expire in 10 minutes const expiresAt = new Date(metadata.expiresAt); const now = new Date(); const diffMinutes = (expiresAt.getTime() - now.getTime()) / (1000 * 60); - expect(diffMinutes).toBeGreaterThan(14); - expect(diffMinutes).toBeLessThan(16); + expect(diffMinutes).toBeGreaterThan(9); + expect(diffMinutes).toBeLessThan(11); } }); }); diff --git a/apps/server/src/api/v2/github/handlers/webhook.test.ts b/apps/server/src/api/v2/github/handlers/webhook.test.ts index e52c554b3..454e414d5 100644 --- a/apps/server/src/api/v2/github/handlers/webhook.test.ts +++ b/apps/server/src/api/v2/github/handlers/webhook.test.ts @@ -99,7 +99,7 @@ describe('webhookHandler', () => { const result = await webhookHandler(suspendedPayload); expect(result.success).toBe(true); - expect(result.message).toBe('Installation suspend successfully'); + expect(result.message).toBe('Installation suspended successfully'); }); it('should handle installation unsuspended', async () => { @@ -116,7 +116,7 @@ describe('webhookHandler', () => { const result = await webhookHandler(unsuspendedPayload); expect(result.success).toBe(true); - expect(result.message).toBe('Installation unsuspend successfully'); + expect(result.message).toBe('Installation unsuspended successfully'); }); it('should throw 400 for created action without org context', async () => { diff --git a/apps/server/src/api/v2/github/handlers/webhook.ts b/apps/server/src/api/v2/github/handlers/webhook.ts index 0b1e23edd..c63124d77 100644 --- a/apps/server/src/api/v2/github/handlers/webhook.ts +++ b/apps/server/src/api/v2/github/handlers/webhook.ts @@ -26,10 +26,17 @@ export async function webhookHandler( userId: userId || '', }); + const actionMessages: Record = { + deleted: 'Installation deleted successfully', + suspend: 'Installation suspended successfully', + unsuspend: 'Installation unsuspended successfully', + new_permissions_accepted: 'Installation permissions updated successfully', + }; + return { success: true, integration: result, - message: `Installation ${payload.action} successfully`, + message: actionMessages[payload.action] || `Installation ${payload.action} successfully`, }; } catch (error) { console.error('Failed to handle installation callback:', error); diff --git a/apps/server/src/api/v2/github/services/github-app.test.ts b/apps/server/src/api/v2/github/services/github-app.test.ts index add6fef9d..58dcf549d 100644 --- a/apps/server/src/api/v2/github/services/github-app.test.ts +++ b/apps/server/src/api/v2/github/services/github-app.test.ts @@ -1,4 +1,3 @@ -import { GitHubErrorCode } from '@buster/server-shared/github'; import { App } from 'octokit'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createGitHubApp, getGitHubAppCredentials } from './github-app'; @@ -86,7 +85,7 @@ describe('github-app', () => { // Act & Assert expect(() => getGitHubAppCredentials()).toThrow( - 'Failed to decode GITHUB_APP_PRIVATE_KEY_BASE64' + 'Failed to decode GITHUB_APP_PRIVATE_KEY_BASE64: Invalid base64 encoding' ); }); @@ -98,7 +97,9 @@ describe('github-app', () => { process.env.GITHUB_WEBHOOK_SECRET = 'test'; // Act & Assert - expect(() => getGitHubAppCredentials()).toThrow('Invalid GitHub App private key format'); + expect(() => getGitHubAppCredentials()).toThrow( + 'Invalid GitHub App private key format. Expected PEM-encoded RSA private key or PKCS#8 private key' + ); }); }); diff --git a/apps/server/src/api/v2/github/services/github-app.ts b/apps/server/src/api/v2/github/services/github-app.ts index 1963e2c6c..ded42fa53 100644 --- a/apps/server/src/api/v2/github/services/github-app.ts +++ b/apps/server/src/api/v2/github/services/github-app.ts @@ -38,11 +38,12 @@ export function getGitHubAppCredentials(): { // Decode the private key from base64 let privateKey: string; try { - // Check if it's valid base64 first - if (!/^[A-Za-z0-9+/]*={0,2}$/.test(privateKeyBase64)) { + // Check if it's valid base64 first (allow whitespace which will be trimmed) + const trimmedBase64 = privateKeyBase64.trim(); + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(trimmedBase64)) { throw new Error('Invalid base64 format'); } - privateKey = Buffer.from(privateKeyBase64, 'base64').toString('utf-8'); + privateKey = Buffer.from(trimmedBase64, 'base64').toString('utf-8'); } catch (_error) { throw createGitHubError( GitHubErrorCode.APP_CONFIGURATION_ERROR, @@ -50,11 +51,11 @@ export function getGitHubAppCredentials(): { ); } - // Validate the private key format - if (!privateKey.includes('BEGIN RSA PRIVATE KEY')) { + // Validate the private key format (support both RSA and PKCS#8 formats) + if (!privateKey.includes('BEGIN RSA PRIVATE KEY') && !privateKey.includes('BEGIN PRIVATE KEY')) { throw createGitHubError( GitHubErrorCode.APP_CONFIGURATION_ERROR, - 'Invalid GitHub App private key format. Expected PEM-encoded RSA private key' + 'Invalid GitHub App private key format. Expected PEM-encoded RSA private key or PKCS#8 private key' ); } diff --git a/apps/server/src/api/v2/github/services/handle-installation-callback.ts b/apps/server/src/api/v2/github/services/handle-installation-callback.ts index 10369c64d..de0cf1e39 100644 --- a/apps/server/src/api/v2/github/services/handle-installation-callback.ts +++ b/apps/server/src/api/v2/github/services/handle-installation-callback.ts @@ -99,9 +99,14 @@ async function handleInstallationCreated(params: { } // Generate and store new token - await generateAndStoreToken(installation.id.toString()); + const tokenVaultKey = await generateAndStoreToken(installation.id.toString()); - return updated; + // Update the integration with the new vault key + const fullyUpdated = await updateGithubIntegration(existing.id, { + tokenVaultKey, + }); + + return fullyUpdated || updated; } // Create new integration diff --git a/apps/server/src/api/v2/github/services/installation-state.ts b/apps/server/src/api/v2/github/services/installation-state.ts index 26830241a..c4f8a23fb 100644 --- a/apps/server/src/api/v2/github/services/installation-state.ts +++ b/apps/server/src/api/v2/github/services/installation-state.ts @@ -22,6 +22,8 @@ export async function storeInstallationState( data: InstallationState ): Promise { const key = generateStateVaultKey(state); + const expirationTime = new Date(Date.now() + 10 * 60 * 1000).toISOString(); + const description = `GitHub OAuth state expires at ${expirationTime}`; try { // Check if state already exists (shouldn't happen with random generation) @@ -32,13 +34,13 @@ export async function storeInstallationState( id: existing.id, secret: JSON.stringify(data), name: key, - description: `GitHub OAuth state expires at ${new Date(Date.now() + 10 * 60 * 1000).toISOString()}`, + description, }); } else { await createSecret({ secret: JSON.stringify(data), name: key, - description: `GitHub OAuth state expires at ${new Date(Date.now() + 10 * 60 * 1000).toISOString()}`, + description, }); } @@ -65,7 +67,14 @@ export async function retrieveInstallationState(state: string): Promise { }); afterEach(async () => { - // Clean up test GitHub integrations + // Clean up test GitHub integrations first (due to foreign key constraints) await db.delete(githubIntegrations).where(eq(githubIntegrations.organizationId, testOrgId)); // Clean up test user @@ -103,7 +103,7 @@ describe('getGithubIntegrationByInstallationId', () => { await db.delete(users).where(eq(users.id, testUserId)); } - // Clean up test organization + // Clean up test organization last if (testOrgId) { await db.delete(organizations).where(eq(organizations.id, testOrgId)); } diff --git a/packages/database/src/queries/github-integrations/get-github-integration-by-installation-id.ts b/packages/database/src/queries/github-integrations/get-github-integration-by-installation-id.ts index 9fad535d5..e1a837cc8 100644 --- a/packages/database/src/queries/github-integrations/get-github-integration-by-installation-id.ts +++ b/packages/database/src/queries/github-integrations/get-github-integration-by-installation-id.ts @@ -1,11 +1,16 @@ import { and, eq, isNull } from 'drizzle-orm'; +import type { InferSelectModel } from 'drizzle-orm'; import { db } from '../../connection'; import { githubIntegrations } from '../../schema'; +type GitHubIntegration = InferSelectModel; + /** * Get GitHub integration by installation ID */ -export async function getGithubIntegrationByInstallationId(installationId: string) { +export async function getGithubIntegrationByInstallationId( + installationId: string +): Promise { const [integration] = await db .select() .from(githubIntegrations) diff --git a/packages/github/.gitignore b/packages/github/.gitignore index f36a5da1b..9d71bf6cb 100644 --- a/packages/github/.gitignore +++ b/packages/github/.gitignore @@ -24,6 +24,7 @@ node_modules/ .DS_Store # Environment files +.env .env.local .env.*.local diff --git a/packages/github/package.json b/packages/github/package.json index 782d80f1b..b5a078d45 100644 --- a/packages/github/package.json +++ b/packages/github/package.json @@ -35,5 +35,10 @@ "@buster/server-shared": "workspace:*", "octokit": "^5.0.3", "zod": "^3.23.8" + }, + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "tsx": "^4.19.2", + "typescript": "^5.6.3" } } diff --git a/packages/github/scripts/validate-env.ts b/packages/github/scripts/validate-env.ts index 71cb20ac6..f8c634d56 100644 --- a/packages/github/scripts/validate-env.ts +++ b/packages/github/scripts/validate-env.ts @@ -10,8 +10,10 @@ loadRootEnv(); // Note: These are only required at runtime when using the GitHub integration // Making them optional for build time to allow packages to be built without GitHub setup const requiredEnv = { - // NODE_ENV is optional - will default to 'development' if not set - // GitHub variables are optional at build time but required at runtime + // GitHub App configuration (required for runtime) + GITHUB_APP_ID: process.env.GITHUB_APP_ID, + GITHUB_APP_PRIVATE_KEY_BASE64: process.env.GITHUB_APP_PRIVATE_KEY_BASE64, + GITHUB_WEBHOOK_SECRET: process.env.GITHUB_WEBHOOK_SECRET, }; // Validate environment variables diff --git a/packages/github/src/client/app.ts b/packages/github/src/client/app.ts index 1963e2c6c..c4addbc2d 100644 --- a/packages/github/src/client/app.ts +++ b/packages/github/src/client/app.ts @@ -38,11 +38,12 @@ export function getGitHubAppCredentials(): { // Decode the private key from base64 let privateKey: string; try { - // Check if it's valid base64 first - if (!/^[A-Za-z0-9+/]*={0,2}$/.test(privateKeyBase64)) { + // Check if it's valid base64 first (allow whitespace which will be trimmed) + const trimmedBase64 = privateKeyBase64.trim(); + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(trimmedBase64)) { throw new Error('Invalid base64 format'); } - privateKey = Buffer.from(privateKeyBase64, 'base64').toString('utf-8'); + privateKey = Buffer.from(trimmedBase64, 'base64').toString('utf-8'); } catch (_error) { throw createGitHubError( GitHubErrorCode.APP_CONFIGURATION_ERROR, @@ -50,11 +51,11 @@ export function getGitHubAppCredentials(): { ); } - // Validate the private key format - if (!privateKey.includes('BEGIN RSA PRIVATE KEY')) { + // Validate the private key format (support both RSA and PKCS#8 formats) + if (!privateKey.includes('BEGIN RSA PRIVATE KEY') && !privateKey.includes('BEGIN PRIVATE KEY')) { throw createGitHubError( GitHubErrorCode.APP_CONFIGURATION_ERROR, - 'Invalid GitHub App private key format. Expected PEM-encoded RSA private key' + 'Invalid GitHub App private key format. Expected PEM-encoded private key' ); } diff --git a/packages/github/src/lib/index.ts b/packages/github/src/lib/index.ts index 108782259..da4c72c04 100644 --- a/packages/github/src/lib/index.ts +++ b/packages/github/src/lib/index.ts @@ -1,4 +1,2 @@ -// Export your library functions here -export const howdy = () => { - return 'Hello from @buster/github!'; -}; +// GitHub library utilities +// Additional utility functions can be added here as needed diff --git a/packages/github/src/services/token.ts b/packages/github/src/services/token.ts index f0b004118..6cfabd762 100644 --- a/packages/github/src/services/token.ts +++ b/packages/github/src/services/token.ts @@ -115,13 +115,13 @@ export async function retrieveInstallationToken( ? (JSON.parse(secret.description) as TokenMetadata) : { installationId, - expiresAt: new Date().toISOString(), // Default to expired if no metadata + expiresAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), // Default to expired (24 hours ago) if no metadata }; } catch { // If description is not valid JSON, create minimal metadata metadata = { installationId, - expiresAt: new Date().toISOString(), + expiresAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), // Default to expired (24 hours ago) }; } diff --git a/packages/github/src/services/webhook.int.test.ts b/packages/github/src/services/webhook.int.test.ts index b5ec29d7c..dbd6bda97 100644 --- a/packages/github/src/services/webhook.int.test.ts +++ b/packages/github/src/services/webhook.int.test.ts @@ -1,7 +1,7 @@ import { createHmac } from 'node:crypto'; import { describe, expect, it } from 'vitest'; import { skipIfNoGitHubCredentials } from '../../../../apps/server/src/api/v2/github/test-helpers/github-test-setup'; -import { verifyWebhookSignature } from './webhook'; +import { verifyGitHubWebhookSignature } from './webhook'; describe('GitHub Webhook Service Integration Tests', () => { describe('Webhook Signature Verification', () => { @@ -30,7 +30,7 @@ describe('GitHub Webhook Service Integration Tests', () => { const signature = `sha256=${createHmac('sha256', webhookSecret).update(payloadString).digest('hex')}`; // Should verify successfully - const isValid = verifyWebhookSignature(payloadString, signature, webhookSecret); + const isValid = verifyGitHubWebhookSignature(payloadString, signature); expect(isValid).toBe(true); }); @@ -77,7 +77,7 @@ describe('GitHub Webhook Service Integration Tests', () => { ]; for (const signature of wrongFormats) { - const isValid = verifyWebhookSignature(payloadString, signature, webhookSecret); + const isValid = verifyGitHubWebhookSignature(payloadString, signature); expect(isValid).toBe(false); } }); @@ -114,7 +114,7 @@ describe('GitHub Webhook Service Integration Tests', () => { const payloadString = JSON.stringify(payload); const signature = `sha256=${createHmac('sha256', webhookSecret).update(payloadString).digest('hex')}`; - const isValid = verifyWebhookSignature(payloadString, signature, webhookSecret); + const isValid = verifyGitHubWebhookSignature(payloadString, signature); expect(isValid).toBe(true); } }); @@ -132,7 +132,7 @@ describe('GitHub Webhook Service Integration Tests', () => { // Verify multiple times - should always return same result for (let i = 0; i < 5; i++) { - const isValid = verifyWebhookSignature(payloadString, signature, webhookSecret); + const isValid = verifyGitHubWebhookSignature(payloadString, signature); expect(isValid).toBe(true); } }); @@ -241,7 +241,7 @@ describe('GitHub Webhook Service Integration Tests', () => { const payloadString = JSON.stringify(largePayload); const signature = `sha256=${createHmac('sha256', webhookSecret).update(payloadString).digest('hex')}`; - const isValid = verifyWebhookSignature(payloadString, signature, webhookSecret); + const isValid = verifyGitHubWebhookSignature(payloadString, signature); expect(isValid).toBe(true); }); }); diff --git a/packages/sandbox/GITHUB_TOKEN.md b/packages/sandbox/GITHUB_TOKEN.md index cc340810d..e650b0791 100644 --- a/packages/sandbox/GITHUB_TOKEN.md +++ b/packages/sandbox/GITHUB_TOKEN.md @@ -17,7 +17,7 @@ import { RuntimeContext } from '@mastra/core/runtime-context'; // Option 1: Create sandbox with GitHub token const runtimeContext = new RuntimeContext(); -const githubToken = await fetchGitHubToken(organizationId); +const githubToken = await getInstallationTokenByOrgId(organizationId); const sandbox = await createSandboxWithGitHubToken(runtimeContext, githubToken); // Option 2: Add token to existing context diff --git a/packages/server-shared/src/github/requests.types.ts b/packages/server-shared/src/github/requests.types.ts index c6a25ab49..c2b0226ed 100644 --- a/packages/server-shared/src/github/requests.types.ts +++ b/packages/server-shared/src/github/requests.types.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; // GitHub App Installation Webhook Payload // Received when a GitHub App is installed, deleted, suspended, or unsuspended export const InstallationCallbackSchema = z.object({ - action: z.enum(['created', 'deleted', 'suspend', 'unsuspend']), + action: z.enum(['created', 'deleted', 'suspend', 'unsuspend', 'new_permissions_accepted']), installation: z.object({ id: z.number(), // GitHub installation ID account: z.object({ diff --git a/packages/server-shared/src/github/responses.types.ts b/packages/server-shared/src/github/responses.types.ts index 4db5c7151..fe1eb82ff 100644 --- a/packages/server-shared/src/github/responses.types.ts +++ b/packages/server-shared/src/github/responses.types.ts @@ -22,7 +22,7 @@ export type InstallationCallbackResponse = z.infer