update to ci, fix greptile recs

This commit is contained in:
dal 2025-08-19 15:07:34 -06:00
parent 38303771f7
commit db63ac4c83
No known key found for this signature in database
GPG Key ID: 16F4B0E1E9F61122
23 changed files with 90 additions and 74 deletions

View File

@ -160,25 +160,4 @@ jobs:
path: | path: |
**/coverage/** **/coverage/**
!**/coverage/tmp/** !**/coverage/tmp/**
retention-days: 7 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!"

View File

@ -80,7 +80,9 @@ export async function authCallbackHandler(
const callbackPayload = { const callbackPayload = {
action: 'created' as const, action: 'created' as const,
installation: { installation: {
id: Number.parseInt(request.installation_id, 10), id: Number.isNaN(Number(request.installation_id))
? 0
: Number.parseInt(request.installation_id, 10),
account: { account: {
// These will be updated when the webhook arrives with full details // These will be updated when the webhook arrives with full details
id: 0, id: 0,

View File

@ -277,12 +277,12 @@ describe('Auth Init Handler Integration Tests', () => {
const metadata = JSON.parse(storedState.description); const metadata = JSON.parse(storedState.description);
expect(metadata.expiresAt).toBeTruthy(); expect(metadata.expiresAt).toBeTruthy();
// Should expire in 15 minutes // Should expire in 10 minutes
const expiresAt = new Date(metadata.expiresAt); const expiresAt = new Date(metadata.expiresAt);
const now = new Date(); const now = new Date();
const diffMinutes = (expiresAt.getTime() - now.getTime()) / (1000 * 60); const diffMinutes = (expiresAt.getTime() - now.getTime()) / (1000 * 60);
expect(diffMinutes).toBeGreaterThan(14); expect(diffMinutes).toBeGreaterThan(9);
expect(diffMinutes).toBeLessThan(16); expect(diffMinutes).toBeLessThan(11);
} }
}); });
}); });

View File

@ -99,7 +99,7 @@ describe('webhookHandler', () => {
const result = await webhookHandler(suspendedPayload); const result = await webhookHandler(suspendedPayload);
expect(result.success).toBe(true); 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 () => { it('should handle installation unsuspended', async () => {
@ -116,7 +116,7 @@ describe('webhookHandler', () => {
const result = await webhookHandler(unsuspendedPayload); const result = await webhookHandler(unsuspendedPayload);
expect(result.success).toBe(true); 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 () => { it('should throw 400 for created action without org context', async () => {

View File

@ -26,10 +26,17 @@ export async function webhookHandler(
userId: userId || '', userId: userId || '',
}); });
const actionMessages: Record<string, string> = {
deleted: 'Installation deleted successfully',
suspend: 'Installation suspended successfully',
unsuspend: 'Installation unsuspended successfully',
new_permissions_accepted: 'Installation permissions updated successfully',
};
return { return {
success: true, success: true,
integration: result, integration: result,
message: `Installation ${payload.action} successfully`, message: actionMessages[payload.action] || `Installation ${payload.action} successfully`,
}; };
} catch (error) { } catch (error) {
console.error('Failed to handle installation callback:', error); console.error('Failed to handle installation callback:', error);

View File

@ -1,4 +1,3 @@
import { GitHubErrorCode } from '@buster/server-shared/github';
import { App } from 'octokit'; import { App } from 'octokit';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createGitHubApp, getGitHubAppCredentials } from './github-app'; import { createGitHubApp, getGitHubAppCredentials } from './github-app';
@ -86,7 +85,7 @@ describe('github-app', () => {
// Act & Assert // Act & Assert
expect(() => getGitHubAppCredentials()).toThrow( 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'; process.env.GITHUB_WEBHOOK_SECRET = 'test';
// Act & Assert // 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'
);
}); });
}); });

View File

@ -38,11 +38,12 @@ export function getGitHubAppCredentials(): {
// Decode the private key from base64 // Decode the private key from base64
let privateKey: string; let privateKey: string;
try { try {
// Check if it's valid base64 first // Check if it's valid base64 first (allow whitespace which will be trimmed)
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(privateKeyBase64)) { const trimmedBase64 = privateKeyBase64.trim();
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(trimmedBase64)) {
throw new Error('Invalid base64 format'); throw new Error('Invalid base64 format');
} }
privateKey = Buffer.from(privateKeyBase64, 'base64').toString('utf-8'); privateKey = Buffer.from(trimmedBase64, 'base64').toString('utf-8');
} catch (_error) { } catch (_error) {
throw createGitHubError( throw createGitHubError(
GitHubErrorCode.APP_CONFIGURATION_ERROR, GitHubErrorCode.APP_CONFIGURATION_ERROR,
@ -50,11 +51,11 @@ export function getGitHubAppCredentials(): {
); );
} }
// Validate the private key format // Validate the private key format (support both RSA and PKCS#8 formats)
if (!privateKey.includes('BEGIN RSA PRIVATE KEY')) { if (!privateKey.includes('BEGIN RSA PRIVATE KEY') && !privateKey.includes('BEGIN PRIVATE KEY')) {
throw createGitHubError( throw createGitHubError(
GitHubErrorCode.APP_CONFIGURATION_ERROR, 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'
); );
} }

View File

@ -99,9 +99,14 @@ async function handleInstallationCreated(params: {
} }
// Generate and store new token // 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 // Create new integration

View File

@ -22,6 +22,8 @@ export async function storeInstallationState(
data: InstallationState data: InstallationState
): Promise<void> { ): Promise<void> {
const key = generateStateVaultKey(state); const key = generateStateVaultKey(state);
const expirationTime = new Date(Date.now() + 10 * 60 * 1000).toISOString();
const description = `GitHub OAuth state expires at ${expirationTime}`;
try { try {
// Check if state already exists (shouldn't happen with random generation) // Check if state already exists (shouldn't happen with random generation)
@ -32,13 +34,13 @@ export async function storeInstallationState(
id: existing.id, id: existing.id,
secret: JSON.stringify(data), secret: JSON.stringify(data),
name: key, name: key,
description: `GitHub OAuth state expires at ${new Date(Date.now() + 10 * 60 * 1000).toISOString()}`, description,
}); });
} else { } else {
await createSecret({ await createSecret({
secret: JSON.stringify(data), secret: JSON.stringify(data),
name: key, 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<Installa
} }
// Check if state is expired (10 minutes) // Check if state is expired (10 minutes)
const data = JSON.parse(secret.secret) as InstallationState; let data: InstallationState;
try {
data = JSON.parse(secret.secret) as InstallationState;
} catch (error) {
console.error('Failed to parse OAuth state data:', error);
await deleteSecret(secret.id);
return null;
}
const createdAt = new Date(data.createdAt); const createdAt = new Date(data.createdAt);
const now = new Date(); const now = new Date();
const tenMinutes = 10 * 60 * 1000; const tenMinutes = 10 * 60 * 1000;

View File

@ -1,6 +1,6 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { deleteSecret, getSecretByName } from '@buster/database'; import { deleteSecret, getSecretByName } from '@buster/database';
import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it } from 'vitest';
import { import {
deleteInstallationToken, deleteInstallationToken,
generateTokenVaultKey, generateTokenVaultKey,

View File

@ -91,13 +91,13 @@ export async function retrieveInstallationToken(
? (JSON.parse(secret.description) as TokenMetadata) ? (JSON.parse(secret.description) as TokenMetadata)
: { : {
installationId, 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 { } catch {
// If description is not valid JSON, create minimal metadata // If description is not valid JSON, create minimal metadata
metadata = { metadata = {
installationId, installationId,
expiresAt: new Date().toISOString(), expiresAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), // Default to expired (24 hours ago)
}; };
} }

View File

@ -95,7 +95,7 @@ describe('getGithubIntegrationByInstallationId', () => {
}); });
afterEach(async () => { 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)); await db.delete(githubIntegrations).where(eq(githubIntegrations.organizationId, testOrgId));
// Clean up test user // Clean up test user
@ -103,7 +103,7 @@ describe('getGithubIntegrationByInstallationId', () => {
await db.delete(users).where(eq(users.id, testUserId)); await db.delete(users).where(eq(users.id, testUserId));
} }
// Clean up test organization // Clean up test organization last
if (testOrgId) { if (testOrgId) {
await db.delete(organizations).where(eq(organizations.id, testOrgId)); await db.delete(organizations).where(eq(organizations.id, testOrgId));
} }

View File

@ -1,11 +1,16 @@
import { and, eq, isNull } from 'drizzle-orm'; import { and, eq, isNull } from 'drizzle-orm';
import type { InferSelectModel } from 'drizzle-orm';
import { db } from '../../connection'; import { db } from '../../connection';
import { githubIntegrations } from '../../schema'; import { githubIntegrations } from '../../schema';
type GitHubIntegration = InferSelectModel<typeof githubIntegrations>;
/** /**
* Get GitHub integration by installation ID * Get GitHub integration by installation ID
*/ */
export async function getGithubIntegrationByInstallationId(installationId: string) { export async function getGithubIntegrationByInstallationId(
installationId: string
): Promise<GitHubIntegration | undefined> {
const [integration] = await db const [integration] = await db
.select() .select()
.from(githubIntegrations) .from(githubIntegrations)

View File

@ -24,6 +24,7 @@ node_modules/
.DS_Store .DS_Store
# Environment files # Environment files
.env
.env.local .env.local
.env.*.local .env.*.local

View File

@ -35,5 +35,10 @@
"@buster/server-shared": "workspace:*", "@buster/server-shared": "workspace:*",
"octokit": "^5.0.3", "octokit": "^5.0.3",
"zod": "^3.23.8" "zod": "^3.23.8"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"tsx": "^4.19.2",
"typescript": "^5.6.3"
} }
} }

View File

@ -10,8 +10,10 @@ loadRootEnv();
// Note: These are only required at runtime when using the GitHub integration // 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 // Making them optional for build time to allow packages to be built without GitHub setup
const requiredEnv = { const requiredEnv = {
// NODE_ENV is optional - will default to 'development' if not set // GitHub App configuration (required for runtime)
// GitHub variables are optional at build time but required at 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 // Validate environment variables

View File

@ -38,11 +38,12 @@ export function getGitHubAppCredentials(): {
// Decode the private key from base64 // Decode the private key from base64
let privateKey: string; let privateKey: string;
try { try {
// Check if it's valid base64 first // Check if it's valid base64 first (allow whitespace which will be trimmed)
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(privateKeyBase64)) { const trimmedBase64 = privateKeyBase64.trim();
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(trimmedBase64)) {
throw new Error('Invalid base64 format'); throw new Error('Invalid base64 format');
} }
privateKey = Buffer.from(privateKeyBase64, 'base64').toString('utf-8'); privateKey = Buffer.from(trimmedBase64, 'base64').toString('utf-8');
} catch (_error) { } catch (_error) {
throw createGitHubError( throw createGitHubError(
GitHubErrorCode.APP_CONFIGURATION_ERROR, GitHubErrorCode.APP_CONFIGURATION_ERROR,
@ -50,11 +51,11 @@ export function getGitHubAppCredentials(): {
); );
} }
// Validate the private key format // Validate the private key format (support both RSA and PKCS#8 formats)
if (!privateKey.includes('BEGIN RSA PRIVATE KEY')) { if (!privateKey.includes('BEGIN RSA PRIVATE KEY') && !privateKey.includes('BEGIN PRIVATE KEY')) {
throw createGitHubError( throw createGitHubError(
GitHubErrorCode.APP_CONFIGURATION_ERROR, 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'
); );
} }

View File

@ -1,4 +1,2 @@
// Export your library functions here // GitHub library utilities
export const howdy = () => { // Additional utility functions can be added here as needed
return 'Hello from @buster/github!';
};

View File

@ -115,13 +115,13 @@ export async function retrieveInstallationToken(
? (JSON.parse(secret.description) as TokenMetadata) ? (JSON.parse(secret.description) as TokenMetadata)
: { : {
installationId, 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 { } catch {
// If description is not valid JSON, create minimal metadata // If description is not valid JSON, create minimal metadata
metadata = { metadata = {
installationId, installationId,
expiresAt: new Date().toISOString(), expiresAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), // Default to expired (24 hours ago)
}; };
} }

View File

@ -1,7 +1,7 @@
import { createHmac } from 'node:crypto'; import { createHmac } from 'node:crypto';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { skipIfNoGitHubCredentials } from '../../../../apps/server/src/api/v2/github/test-helpers/github-test-setup'; 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('GitHub Webhook Service Integration Tests', () => {
describe('Webhook Signature Verification', () => { describe('Webhook Signature Verification', () => {
@ -30,7 +30,7 @@ describe('GitHub Webhook Service Integration Tests', () => {
const signature = `sha256=${createHmac('sha256', webhookSecret).update(payloadString).digest('hex')}`; const signature = `sha256=${createHmac('sha256', webhookSecret).update(payloadString).digest('hex')}`;
// Should verify successfully // Should verify successfully
const isValid = verifyWebhookSignature(payloadString, signature, webhookSecret); const isValid = verifyGitHubWebhookSignature(payloadString, signature);
expect(isValid).toBe(true); expect(isValid).toBe(true);
}); });
@ -77,7 +77,7 @@ describe('GitHub Webhook Service Integration Tests', () => {
]; ];
for (const signature of wrongFormats) { for (const signature of wrongFormats) {
const isValid = verifyWebhookSignature(payloadString, signature, webhookSecret); const isValid = verifyGitHubWebhookSignature(payloadString, signature);
expect(isValid).toBe(false); expect(isValid).toBe(false);
} }
}); });
@ -114,7 +114,7 @@ describe('GitHub Webhook Service Integration Tests', () => {
const payloadString = JSON.stringify(payload); const payloadString = JSON.stringify(payload);
const signature = `sha256=${createHmac('sha256', webhookSecret).update(payloadString).digest('hex')}`; 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); expect(isValid).toBe(true);
} }
}); });
@ -132,7 +132,7 @@ describe('GitHub Webhook Service Integration Tests', () => {
// Verify multiple times - should always return same result // Verify multiple times - should always return same result
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
const isValid = verifyWebhookSignature(payloadString, signature, webhookSecret); const isValid = verifyGitHubWebhookSignature(payloadString, signature);
expect(isValid).toBe(true); expect(isValid).toBe(true);
} }
}); });
@ -241,7 +241,7 @@ describe('GitHub Webhook Service Integration Tests', () => {
const payloadString = JSON.stringify(largePayload); const payloadString = JSON.stringify(largePayload);
const signature = `sha256=${createHmac('sha256', webhookSecret).update(payloadString).digest('hex')}`; 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); expect(isValid).toBe(true);
}); });
}); });

View File

@ -17,7 +17,7 @@ import { RuntimeContext } from '@mastra/core/runtime-context';
// Option 1: Create sandbox with GitHub token // Option 1: Create sandbox with GitHub token
const runtimeContext = new RuntimeContext(); const runtimeContext = new RuntimeContext();
const githubToken = await fetchGitHubToken(organizationId); const githubToken = await getInstallationTokenByOrgId(organizationId);
const sandbox = await createSandboxWithGitHubToken(runtimeContext, githubToken); const sandbox = await createSandboxWithGitHubToken(runtimeContext, githubToken);
// Option 2: Add token to existing context // Option 2: Add token to existing context

View File

@ -3,7 +3,7 @@ import { z } from 'zod';
// GitHub App Installation Webhook Payload // GitHub App Installation Webhook Payload
// Received when a GitHub App is installed, deleted, suspended, or unsuspended // Received when a GitHub App is installed, deleted, suspended, or unsuspended
export const InstallationCallbackSchema = z.object({ 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({ installation: z.object({
id: z.number(), // GitHub installation ID id: z.number(), // GitHub installation ID
account: z.object({ account: z.object({

View File

@ -22,7 +22,7 @@ export type InstallationCallbackResponse = z.infer<typeof InstallationCallbackRe
// Response containing installation access token // Response containing installation access token
export const InstallationTokenResponseSchema = z.object({ export const InstallationTokenResponseSchema = z.object({
token: z.string(), token: z.string(),
expires_at: z.string(), // ISO 8601 date string expires_at: z.string().datetime(), // ISO 8601 date string
permissions: z.record(z.string()).optional(), // e.g., { "contents": "read", "issues": "write" } permissions: z.record(z.string()).optional(), // e.g., { "contents": "read", "issues": "write" }
repository_selection: z.enum(['all', 'selected']).optional(), repository_selection: z.enum(['all', 'selected']).optional(),
repositories: z repositories: z
@ -49,8 +49,8 @@ export const GetGitHubIntegrationResponseSchema = z.object({
github_org_name: z.string(), github_org_name: z.string(),
github_org_id: z.string(), github_org_id: z.string(),
installation_id: z.string(), installation_id: z.string(),
installed_at: z.string(), installed_at: z.string().datetime(),
last_used_at: z.string().optional(), last_used_at: z.string().datetime().optional(),
repository_count: z.number().optional(), repository_count: z.number().optional(),
}) })
.optional(), .optional(),