diff --git a/package.json b/package.json index 3ab0a13c..7c08049e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.4.7", + "version": "1.4.8", "main": "index.ts", "license": "BUSL-1.1", "scripts": { @@ -41,8 +41,9 @@ "@graphql-tools/merge": "^8.3.1", "@graphql-tools/schema": "^8.5.1", "@graphql-tools/utils": "^8.9.0", + "@hawk.so/github-sdk": "^1.0.4", "@hawk.so/nodejs": "^3.3.1", - "@hawk.so/types": "^0.5.9", + "@hawk.so/types": "^0.6.0-rc.2", "@n1ru4l/json-patch-plus": "^0.2.0", "@node-saml/node-saml": "^5.0.1", "@octokit/oauth-methods": "^4.0.0", diff --git a/src/integrations/github/index.ts b/src/integrations/github/index.ts index 00942422..26309c49 100644 --- a/src/integrations/github/index.ts +++ b/src/integrations/github/index.ts @@ -2,11 +2,6 @@ import express from 'express'; import { createGitHubRouter } from './routes'; import { ContextFactories } from '../../types/graphql'; -/** - * Re-export types and service from service.ts for backward compatibility - */ -export { GitHubService, IssueData, GitHubIssue, Installation, Repository } from './service'; - /** * Append GitHub routes to Express app * diff --git a/src/integrations/github/routes.ts b/src/integrations/github/routes.ts index 8144ec34..9278a90b 100644 --- a/src/integrations/github/routes.ts +++ b/src/integrations/github/routes.ts @@ -3,12 +3,13 @@ import express from 'express'; import { v4 as uuid } from 'uuid'; import { ObjectId } from 'mongodb'; import { createHmac, timingSafeEqual } from 'crypto'; -import { GitHubService } from './service'; -import { ProjectDBScheme } from '@hawk.so/types'; +import { GitHubService } from '@hawk.so/github-sdk'; +import { ProjectDBScheme, GitHubInstallation } from '@hawk.so/types'; import { ContextFactories } from '../../types/graphql'; import { RedisInstallStateStore } from './store/install-state.redis.store'; import ProjectModel from '../../models/project'; import WorkspaceModel from '../../models/workspace'; +import UserModel from '../../models/user'; import { sgr, Effect } from '../../utils/ansi'; import { databases } from '../../mongo'; @@ -26,7 +27,20 @@ const DEFAULT_TASK_THRESHOLD_TOTAL_COUNT = 50; */ export function createGitHubRouter(factories: ContextFactories): express.Router { const router = express.Router(); - const githubService = new GitHubService(); + + if (!process.env.GITHUB_APP_ID || !process.env.GITHUB_PRIVATE_KEY) { + throw new Error('GITHUB_APP_ID and GITHUB_PRIVATE_KEY environment variables are required'); + } + + const githubService = new GitHubService({ + appId: process.env.GITHUB_APP_ID, + privateKey: process.env.GITHUB_PRIVATE_KEY, + appSlug: process.env.GITHUB_APP_SLUG, + clientId: process.env.GITHUB_APP_CLIENT_ID, + clientSecret: process.env.GITHUB_APP_CLIENT_SECRET, + apiUrl: process.env.API_URL, + }); + const stateStore = new RedisInstallStateStore(); /** @@ -152,6 +166,69 @@ export function createGitHubRouter(factories: ContextFactories): express.Router }; } + /** + * Validate workspace access and admin permissions + * + * @param req - Express request object + * @param res - Express response object + * @param workspaceId - workspace ID from query parameters + * @param errorMessagePrefix - prefix for admin permission error message + * @returns Object with workspace and userId if validation passes, null otherwise (response already sent) + */ + async function validateWorkspaceAdminAccess( + req: express.Request, + res: express.Response, + workspaceId: string | undefined, + errorMessagePrefix = 'perform this action' + ): Promise<{ workspace: WorkspaceModel; userId: string } | null> { + const userId = req.context?.user?.id; + + if (!userId) { + res.status(401).json({ error: 'Unauthorized. Please provide authorization token.' }); + + return null; + } + + if (!workspaceId || typeof workspaceId !== 'string') { + res.status(400).json({ error: 'workspaceId query parameter is required' }); + + return null; + } + + if (!ObjectId.isValid(workspaceId)) { + res.status(400).json({ error: `Invalid workspaceId format: ${workspaceId}` }); + + return null; + } + + const workspace = await factories.workspacesFactory.findById(workspaceId); + + if (!workspace) { + res.status(404).json({ error: `Workspace not found: ${workspaceId}` }); + + return null; + } + + const member = await workspace.getMemberInfo(userId); + + if (!member || WorkspaceModel.isPendingMember(member)) { + res.status(403).json({ error: 'You are not a member of this workspace' }); + + return null; + } + + if (!member.isAdmin) { + res.status(403).json({ error: `Not enough permissions. Only workspace admin can ${errorMessagePrefix}.` }); + + return null; + } + + return { + workspace, + userId, + }; + } + /** * Log message with GitHub Integration prefix * @@ -211,58 +288,77 @@ export function createGitHubRouter(factories: ContextFactories): express.Router const WEBHOOK_LOG_PREFIX = '[🍏 🍎 ✨ Webhook] '; /** - * GET /integration/github/connect?projectId= - * Initiate GitHub integration connection + * GET /integration/github/connect?workspaceId=&projectId= + * Initiate GitHub integration connection. + * + * If workspace already has installations → returns them so frontend can show repo picker directly. + * If workspace has no installations → returns GitHub install URL for redirect. */ router.get('/connect', async (req, res, next) => { try { - const { projectId } = req.query; + const { workspaceId, projectId } = req.query; /** - * Validate project access and admin permissions + * Validate projectId — connection is always initiated from a project settings page */ - const access = await validateProjectAdminAccess(req, res, projectId as string | undefined, 'connect Task Manager integration'); + if (!projectId || typeof projectId !== 'string' || !ObjectId.isValid(projectId)) { + res.status(400).json({ error: 'projectId query parameter is required' }); + + return; + } + + /** + * Validate workspace access and admin permissions + */ + const access = await validateWorkspaceAdminAccess(req, res, workspaceId as string | undefined, 'connect Task Manager integration'); if (!access) { return; } - const { project, userId } = access; - const validatedProjectId = project._id.toString(); + const { workspace, userId } = access; + const validatedWorkspaceId = workspace._id.toString(); /** - * Generate unique state for CSRF protection - * Using UUID v4 for simplicity (alternative: JWT token) + * Check if workspace already has GitHub installations. + * If yes, return them so the frontend can show repo picker directly. */ - const state = uuid(); + const existingInstallations = workspace.getGitHubInstallations(); + + if (existingInstallations.length > 0) { + log('info', `Workspace ${validatedWorkspaceId} already has ${existingInstallations.length} installation(s), returning them`); + + return res.json({ + hasInstallations: true, + installations: existingInstallations.map((i) => ({ + installationId: i.installationId, + account: i.account, + })), + }); + } /** - * Save state data in Redis with TTL - * Data includes: projectId, userId, timestamp + * No installations — generate GitHub App install URL */ + const state = uuid(); + const stateData = { - projectId: validatedProjectId, + workspaceId: validatedWorkspaceId, + projectId, userId, timestamp: Date.now(), }; await stateStore.saveState(state, stateData); - log('info', validatedProjectId, `Created state: ${sgr(state.slice(0, 8), Effect.ForegroundGray)}...`); + log('info', `Created state for workspace ${validatedWorkspaceId}: ${sgr(state.slice(0, 8), Effect.ForegroundGray)}...`); - /** - * Generate GitHub installation URL with state - */ const installationUrl = githubService.getInstallationUrl(state); - log('info', validatedProjectId, 'Generated GitHub installation URL: ' + sgr(installationUrl, Effect.ForegroundGreen)); + log('info', `Generated GitHub installation URL: ${sgr(installationUrl, Effect.ForegroundGreen)}`); - /** - * Return installation URL in JSON response - * Frontend will handle the redirect using window.location.href - * This allows Authorization header to be sent correctly - */ res.json({ + hasInstallations: false, redirectUrl: installationUrl, }); } catch (error) { @@ -273,23 +369,25 @@ export function createGitHubRouter(factories: ContextFactories): express.Router /** * GET /integration/github/oauth?code=&state=&installation_id= - * Handle GitHub OAuth callback for user-to-server token - * Also handles GitHub App installation if installation_id is present + * Handle GitHub OAuth callback. + * + * Called by GitHub after the user completes the GitHub App installation + OAuth authorization flow. + * GitHub redirects here with `code` (OAuth authorization code), `state` (CSRF token), + * and optionally `installation_id` (present when a new GitHub App installation was created). + * + * This endpoint: + * 1. If installation_id is present — saves the installation to workspace.integrations.github.installations[] + * 2. Exchanges the OAuth code for tokens and saves refreshToken to user.githubAuthorizations[] + * 3. Redirects back to Garage project settings page */ router.get('/oauth', async (req, res, next) => { try { // eslint-disable-next-line @typescript-eslint/camelcase, camelcase const { code, state, installation_id } = req.query; - /** - * Log OAuth callback request for debugging - */ // eslint-disable-next-line @typescript-eslint/camelcase, camelcase - log('info', `OAuth callback received: state=${state}, code=${code ? 'present' : 'missing'}, installation_id=${installation_id ? 'present' : 'missing'}, query=${JSON.stringify(req.query)}`); + log('info', `OAuth callback received: state=${state}, code=${code ? 'present' : 'missing'}, installation_id=${installation_id ? 'present' : 'missing'}`); - /** - * Validate required parameters - */ if (!code || typeof code !== 'string') { return res.redirect(buildGarageRedirectUrl('/', { apiError: 'Missing or invalid OAuth code', @@ -304,7 +402,6 @@ export function createGitHubRouter(factories: ContextFactories): express.Router /** * Verify state (CSRF protection) - * getState() atomically gets and deletes the state, preventing reuse */ const stateData = await stateStore.getState(state); @@ -316,177 +413,157 @@ export function createGitHubRouter(factories: ContextFactories): express.Router })); } - const { projectId, userId } = stateData; + const { workspaceId, projectId, userId } = stateData; - log('info', projectId, `Processing OAuth callback initiated by user ${sgr(userId, Effect.ForegroundCyan)}`); + log('info', `Processing OAuth callback for workspace ${sgr(workspaceId, Effect.ForegroundCyan)}, initiated by user ${sgr(userId, Effect.ForegroundCyan)}`); /** - * Verify project exists + * Verify workspace exists */ - const project = await factories.projectsFactory.findById(projectId); + const workspace = await factories.workspacesFactory.findById(workspaceId); - if (!project) { - log('error', projectId, 'Project not found'); + if (!workspace) { + log('error', `Workspace not found: ${workspaceId}`); - return res.redirect(buildGarageRedirectUrl(`/project/${projectId}/settings/task-manager`, { - error: `Project not found: ${projectId}`, + return res.redirect(buildGarageRedirectUrl('/', { + apiError: `Workspace not found: ${workspaceId}`, })); } + const redirectPath = `/project/${projectId}/settings/task-manager`; + /** - * If installation_id is present, handle GitHub App installation first - * This happens when "Request user authorization (OAuth) during installation" is enabled + * If installation_id is present, save installation to workspace */ // eslint-disable-next-line @typescript-eslint/camelcase, camelcase if (installation_id && typeof installation_id === 'string') { // eslint-disable-next-line @typescript-eslint/camelcase, camelcase - log('info', projectId, `GitHub App installation detected (installation_id: ${installation_id}), processing installation first`); + const installId = parseInt(installation_id, 10); - /** - * Get installation info from GitHub (validates installation exists) - */ - try { - await githubService.getInstallationForRepository(installation_id); - log('info', projectId, `Retrieved installation info for installation_id: ${sgr(installation_id, Effect.ForegroundCyan)}`); - } catch (error) { - log('error', projectId, `Failed to get installation info: ${error instanceof Error ? error.message : String(error)}`); - - return res.redirect(buildGarageRedirectUrl(`/project/${projectId}/settings/task-manager`, { - error: 'Failed to retrieve GitHub installation information. Please try again.', - })); - } + log('info', `GitHub App installation detected (installation_id: ${installation_id}), saving to workspace`); /** - * Create or update taskManager config with installation info + * Check if this installation already exists in the workspace */ - const taskManagerConfig = { - type: 'github' as const, - autoTaskEnabled: false, - taskThresholdTotalCount: DEFAULT_TASK_THRESHOLD_TOTAL_COUNT, - assignAgent: false, - connectedAt: new Date(), - updatedAt: new Date(), - config: { - // eslint-disable-next-line @typescript-eslint/camelcase, camelcase - installationId: installation_id, - repoId: '', - repoFullName: '', - }, - }; + const existingInstallation = workspace.findGitHubInstallation(installId); - try { - await project.updateProject({ - taskManager: project.taskManager ? { - ...project.taskManager, - ...taskManagerConfig, - config: { - ...project.taskManager.config, - // eslint-disable-next-line @typescript-eslint/camelcase, camelcase - installationId: installation_id, - // Preserve existing repoId and repoFullName if they exist, otherwise use defaults - repoId: project.taskManager.config.repoId || taskManagerConfig.config.repoId, - repoFullName: project.taskManager.config.repoFullName || taskManagerConfig.config.repoFullName, - }, - } : taskManagerConfig, - } as Partial); - - log('info', projectId, 'Successfully saved GitHub App installation'); - } catch (error) { - log('error', projectId, `Failed to save taskManager config: ${error instanceof Error ? error.message : String(error)}`); - - return res.redirect(buildGarageRedirectUrl(`/project/${projectId}/settings/task-manager`, { - error: 'Failed to save Task Manager configuration. Please try again.', - })); - } + if (!existingInstallation) { + /** + * Get installation metadata from GitHub + */ + let installationInfo; - /** - * Reload project to get updated taskManager config - */ - const updatedProject = await factories.projectsFactory.findById(projectId); + try { + // eslint-disable-next-line @typescript-eslint/camelcase, camelcase + installationInfo = await githubService.getInstallationForRepository(installation_id); + } catch (error) { + log('error', `Failed to get installation info: ${error instanceof Error ? error.message : String(error)}`); - if (!updatedProject) { - log('error', projectId, 'Project not found after update'); + return res.redirect(buildGarageRedirectUrl(redirectPath, { + error: 'Failed to retrieve GitHub installation information. Please try again.', + })); + } - return res.redirect(buildGarageRedirectUrl(`/project/${projectId}/settings/task-manager`, { - error: `Project not found: ${projectId}`, - })); + const newInstallation: GitHubInstallation = { + installationId: installId, + account: { + id: installationInfo.account?.id ?? 0, + login: installationInfo.account?.login ?? 'unknown', + type: installationInfo.target_type === 'Organization' ? 'Organization' : 'User', + }, + connectedByHawkUserId: userId, + connectedAt: new Date(), + updatedAt: new Date(), + delegatedUser: null, + }; + + try { + await workspace.addGitHubInstallation(newInstallation); + log('info', `Saved installation ${installId} to workspace ${workspaceId}`); + } catch (error) { + log('error', `Failed to save installation to workspace: ${error instanceof Error ? error.message : String(error)}`); + + return res.redirect(buildGarageRedirectUrl(redirectPath, { + error: 'Failed to save installation. Please try again.', + })); + } + } else { + log('info', `Installation ${installId} already exists in workspace ${workspaceId}, skipping`); } - - /** - * Use updated project for OAuth processing - */ - Object.assign(project, updatedProject); - } - - /** - * Verify project has taskManager config (should exist after installation or already exist) - */ - if (!project.taskManager) { - log('error', projectId, 'Project does not have taskManager config after installation'); - - return res.redirect(buildGarageRedirectUrl(`/project/${projectId}/settings/task-manager`, { - error: 'GitHub App installation failed. Please try connecting again.', - })); } /** * Exchange OAuth code for user-to-server token - * This method already validates the token by calling getAuthenticated(), - * so no additional validation is needed */ let tokenData; try { tokenData = await githubService.exchangeOAuthCodeForToken(code); - log('info', projectId, `Successfully exchanged OAuth code for token for user ${sgr(tokenData.user.login, Effect.ForegroundCyan)}`); + log('info', `Successfully exchanged OAuth code for user ${sgr(tokenData.user.login, Effect.ForegroundCyan)}`); } catch (error) { - log('error', projectId, `Failed to exchange OAuth code: ${error instanceof Error ? error.message : String(error)}`); + log('error', `Failed to exchange OAuth code: ${error instanceof Error ? error.message : String(error)}`); - return res.redirect(buildGarageRedirectUrl(`/project/${projectId}/settings/task-manager`, { + return res.redirect(buildGarageRedirectUrl(redirectPath, { error: 'Failed to exchange OAuth code for token. Please try again.', })); } /** - * Update project with delegatedUser token - * Token is already validated in exchangeOAuthCodeForToken() via getAuthenticated() + * Save OAuth tokens to user.githubAuthorizations[] (NOT to project) */ - const delegatedUser = { - hawkUserId: userId, - githubUserId: tokenData.user.id, - githubLogin: tokenData.user.login, - accessToken: tokenData.accessToken, - accessTokenExpiresAt: tokenData.expiresAt, - refreshToken: tokenData.refreshToken, - refreshTokenExpiresAt: tokenData.refreshTokenExpiresAt, - tokenCreatedAt: new Date(), - tokenLastValidatedAt: new Date(), // Token was validated in exchangeOAuthCodeForToken() - status: 'active' as const, - }; + try { + const user = await factories.usersFactory.findById(userId) as UserModel | null; - /** - * Update taskManager config with delegatedUser - * Preserve existing config fields - */ - const updatedTaskManager = { - ...project.taskManager, - config: { - ...project.taskManager.config, - delegatedUser, - }, - updatedAt: new Date(), - }; + if (!user) { + log('error', `User not found: ${userId}`); - try { - await project.updateProject({ - taskManager: updatedTaskManager, - } as Partial); + return res.redirect(buildGarageRedirectUrl(redirectPath, { + error: 'User not found during OAuth callback.', + })); + } + + await user.upsertGitHubAuthorization({ + githubUserId: tokenData.user.id, + githubLogin: tokenData.user.login, + refreshToken: tokenData.refreshToken, + refreshTokenExpiresAt: tokenData.refreshTokenExpiresAt, + tokenCreatedAt: new Date(), + tokenLastValidatedAt: new Date(), + status: 'active', + }); - log('info', projectId, `Successfully saved delegatedUser token for user ${sgr(tokenData.user.login, Effect.ForegroundCyan)}`); + log('info', `Saved GitHub authorization for user ${sgr(tokenData.user.login, Effect.ForegroundCyan)}`); + + /** + * Update delegatedUser in workspace installation if we just created one + */ + // eslint-disable-next-line @typescript-eslint/camelcase, camelcase + if (installation_id && typeof installation_id === 'string') { + // eslint-disable-next-line @typescript-eslint/camelcase, camelcase + const installId = parseInt(installation_id, 10); + + await databases.hawk?.collection('workspaces')!.updateOne( + { + _id: new ObjectId(workspaceId), + 'integrations.github.installations.installationId': installId, + }, + { + $set: { + 'integrations.github.installations.$.delegatedUser': { + hawkUserId: userId, + githubUserId: tokenData.user.id, + githubLogin: tokenData.user.login, + status: 'active', + }, + 'integrations.github.installations.$.updatedAt': new Date(), + }, + } + ); + } } catch (error) { - log('error', projectId, `Failed to save delegatedUser token: ${error instanceof Error ? error.message : String(error)}`); + log('error', `Failed to save GitHub authorization: ${error instanceof Error ? error.message : String(error)}`); - return res.redirect(buildGarageRedirectUrl(`/project/${projectId}/settings/task-manager`, { + return res.redirect(buildGarageRedirectUrl(redirectPath, { error: 'Failed to save OAuth token. Please try again.', })); } @@ -494,11 +571,11 @@ export function createGitHubRouter(factories: ContextFactories): express.Router /** * Redirect to Garage with success parameter */ - const successRedirectUrl = buildGarageRedirectUrl(`/project/${projectId}/settings/task-manager`, { + const successRedirectUrl = buildGarageRedirectUrl(redirectPath, { success: 'true', }); - log('info', projectId, 'OAuth authorization completed successfully. Redirecting to ' + sgr(successRedirectUrl, Effect.ForegroundGreen)); + log('info', 'OAuth authorization completed successfully. Redirecting to ' + sgr(successRedirectUrl, Effect.ForegroundGreen)); return res.redirect(successRedirectUrl); } catch (error) { @@ -639,19 +716,37 @@ export function createGitHubRouter(factories: ContextFactories): express.Router log('info', `Processing installation.deleted for installation_id: ${sgr(installationId, Effect.ForegroundCyan)}`); - /** - * Find all projects with this installationId - * Using MongoDB query directly as projectsFactory doesn't have a method for this - */ + const workspacesCollection = databases.hawk?.collection('workspaces'); const projectsCollection = databases.hawk?.collection('projects'); - if (!projectsCollection) { - log('error', 'MongoDB projects collection is not available'); + if (!projectsCollection || !workspacesCollection) { + log('error', 'MongoDB collections are not available'); return res.status(500).json({ error: 'Database connection error' }); } try { + const installIdNum = parseInt(installationId, 10); + + /** + * Remove installation from all workspaces that have it + */ + const workspaceResult = await workspacesCollection.updateMany( + { + 'integrations.github.installations.installationId': installIdNum, + }, + { + $pull: { + 'integrations.github.installations': { installationId: installIdNum }, + } as any, + } + ); + + log('info', `Removed installation from ${sgr(workspaceResult.modifiedCount.toString(), Effect.ForegroundCyan)} workspace(s)`); + + /** + * Remove taskManager configuration from all projects with this installationId + */ const projects = await projectsCollection .find({ 'taskManager.config.installationId': installationId, @@ -660,9 +755,6 @@ export function createGitHubRouter(factories: ContextFactories): express.Router log('info', `Found ${sgr(projects.length.toString(), Effect.ForegroundCyan)} project(s) with installation_id ${installationId}`); - /** - * Remove taskManager configuration from all projects - */ if (projects.length > 0) { const projectIds = projects.map((p) => p._id.toString()); @@ -683,7 +775,7 @@ export function createGitHubRouter(factories: ContextFactories): express.Router log('info', `Removed taskManager configuration from ${sgr(projects.length.toString(), Effect.ForegroundCyan)} project(s): ${projectIds.join(', ')}`); } } catch (error) { - log('error', `Failed to remove taskManager configurations: ${error instanceof Error ? error.message : String(error)}`); + log('error', `Failed to process installation.deleted: ${error instanceof Error ? error.message : String(error)}`); return res.status(500).json({ error: 'Failed to process installation.deleted event' }); } @@ -705,42 +797,58 @@ export function createGitHubRouter(factories: ContextFactories): express.Router }); /** - * GET /integration/github/repositories?projectId= - * Get list of repositories accessible to GitHub App installation + * GET /integration/github/repositories?workspaceId=&installationId= + * Get list of repositories accessible to GitHub App installation(s) of the workspace. + * + * If workspace has a single installation, installationId is optional. + * If workspace has multiple installations, installationId is required. */ router.get('/repositories', async (req, res, next) => { try { - const { projectId } = req.query; + const { workspaceId, installationId: installationIdParam } = req.query; /** - * Validate project access and admin permissions + * Validate workspace access and admin permissions */ - const access = await validateProjectAdminAccess(req, res, projectId as string | undefined, 'access repository list'); + const access = await validateWorkspaceAdminAccess(req, res, workspaceId as string | undefined, 'access repository list'); if (!access) { return; } - const { project } = access; - - /** - * Check if taskManager is configured - */ - const taskManager = project.taskManager; + const { workspace } = access; + const installations = workspace.getGitHubInstallations(); - if (!taskManager) { - res.status(400).json({ error: 'Task Manager is not configured for this project' }); + if (installations.length === 0) { + res.status(400).json({ error: 'No GitHub installations found for this workspace' }); return; } /** - * Extract installationId from project configuration + * Determine which installation to use */ - const installationId = taskManager.config.installationId; + let installationId: string; - if (!installationId) { - res.status(400).json({ error: 'GitHub installation ID is not configured for this project' }); + if (installationIdParam && typeof installationIdParam === 'string') { + const found = installations.find((i) => i.installationId.toString() === installationIdParam); + + if (!found) { + res.status(404).json({ error: `Installation ${installationIdParam} not found in this workspace` }); + + return; + } + installationId = installationIdParam; + } else if (installations.length === 1) { + installationId = installations[0].installationId.toString(); + } else { + res.status(400).json({ + error: 'Multiple installations found. Please specify installationId.', + installations: installations.map((i) => ({ + installationId: i.installationId, + account: i.account, + })), + }); return; } @@ -751,19 +859,16 @@ export function createGitHubRouter(factories: ContextFactories): express.Router try { const repositories = await githubService.getRepositoriesForInstallation(installationId); - /** - * Log repository details for debugging - */ const repoOwners = [ ...new Set(repositories.map((r) => r.fullName.split('/')[0])) ]; - log('info', projectId, `Retrieved ${repositories.length} repository(ies) for installation ${installationId}`); - log('info', projectId, `Repository owners: ${repoOwners.join(', ')}`); + log('info', `Retrieved ${repositories.length} repository(ies) for installation ${installationId}`); + log('info', `Repository owners: ${repoOwners.join(', ')}`); res.json({ repositories, }); } catch (error) { - log('error', projectId, `Failed to get repositories: ${error instanceof Error ? error.message : String(error)}`); + log('error', `Failed to get repositories: ${error instanceof Error ? error.message : String(error)}`); res.status(500).json({ error: 'Failed to retrieve repositories from GitHub. Please try again.', @@ -777,12 +882,16 @@ export function createGitHubRouter(factories: ContextFactories): express.Router /** * PUT /integration/github/repository?projectId= - * Update selected repository for GitHub App installation + * Bind a repository to a project. + * + * Body: { installationId, repoId, repoFullName, repoLanguage? } + * Verifies that the installation belongs to the project's workspace. + * Creates or updates taskManager config in the project. */ router.put('/repository', async (req, res, next) => { try { const { projectId } = req.query; - const { repoId, repoFullName } = req.body; + const { installationId, repoId, repoFullName, repoLanguage } = req.body; /** * Validate project access and admin permissions @@ -793,12 +902,18 @@ export function createGitHubRouter(factories: ContextFactories): express.Router return; } - const { project } = access; + const { project, workspace } = access; const validatedProjectId = project._id.toString(); /** * Validate request body */ + if (!installationId) { + res.status(400).json({ error: 'installationId is required' }); + + return; + } + if (!repoId || typeof repoId !== 'string') { res.status(400).json({ error: 'repoId is required and must be a string' }); @@ -812,35 +927,47 @@ export function createGitHubRouter(factories: ContextFactories): express.Router } /** - * Check if taskManager is configured + * Verify that the installation belongs to this workspace */ - const taskManager = project.taskManager; + const installIdNum = typeof installationId === 'number' + ? installationId + : parseInt(String(installationId), 10); + + const installation = workspace.findGitHubInstallation(installIdNum); - if (!taskManager) { - res.status(400).json({ error: 'Task Manager is not configured for this project' }); + if (!installation) { + res.status(400).json({ error: `Installation ${installationId} does not belong to this workspace` }); return; } /** - * Update taskManager config with selected repository + * Create or update taskManager config with repository binding. + * installationId is stored as a copy from workspace for worker optimization. */ - const updatedTaskManager = { - ...taskManager, + const now = new Date(); + const taskManagerConfig = { + type: 'github' as const, + autoTaskEnabled: project.taskManager?.autoTaskEnabled ?? false, + taskThresholdTotalCount: project.taskManager?.taskThresholdTotalCount ?? DEFAULT_TASK_THRESHOLD_TOTAL_COUNT, + assignAgent: project.taskManager?.assignAgent ?? false, + usage: project.taskManager?.usage, + connectedAt: project.taskManager?.connectedAt ?? now, + updatedAt: now, config: { - ...taskManager.config, + installationId: String(installationId), repoId, repoFullName, + ...(repoLanguage ? { repoLanguage } : {}), }, - updatedAt: new Date(), }; try { await project.updateProject({ - taskManager: updatedTaskManager, - }); + taskManager: taskManagerConfig, + } as Partial); - log('info', validatedProjectId, `Updated repository selection: ${repoFullName} (${repoId})`); + log('info', validatedProjectId, `Bound repository ${repoFullName} (installation: ${installationId})`); res.json({ success: true, diff --git a/src/integrations/github/service.ts b/src/integrations/github/service.ts deleted file mode 100644 index 4acc33f0..00000000 --- a/src/integrations/github/service.ts +++ /dev/null @@ -1,935 +0,0 @@ -import jwt from 'jsonwebtoken'; -import { Octokit } from '@octokit/rest'; -import type { Endpoints } from '@octokit/types'; -import { exchangeWebFlowCode, refreshToken as refreshOAuthToken } from '@octokit/oauth-methods'; - -/** - * Type for GitHub Issue creation parameters - * Extracted from Octokit types for POST /repos/{owner}/{repo}/issues - */ -export type IssueData = Pick< - Endpoints['POST /repos/{owner}/{repo}/issues']['parameters'], - 'title' | 'body' | 'labels' ->; - -/** - * Type for GitHub Issue response data - * Extracted from Octokit types for POST /repos/{owner}/{repo}/issues response - */ -export type GitHubIssue = Pick< - Endpoints['POST /repos/{owner}/{repo}/issues']['response']['data'], - 'number' | 'html_url' | 'title' | 'state' ->; - -/** - * Type for GitHub Repository data - * Ephemeral data, not stored in database - */ -export type Repository = { - /** - * Repository ID - */ - id: string; - - /** - * Repository name (without owner) - */ - name: string; - - /** - * Repository full name (owner/repo) - */ - fullName: string; - - /** - * Whether repository is private - */ - private: boolean; - - /** - * Repository HTML URL - */ - htmlUrl: string; - - /** - * Last update date - */ - updatedAt: Date; - - /** - * Primary programming language - */ - language: string | null; -}; - -/** - * Type for GitHub Installation response data - * Simplified version of Octokit Installation type with essential fields only - * account.login and account.type are extracted from the full GitHub account object - */ -export type Installation = { - /** - * Installation ID - */ - id: number; - - /** - * Account (user or organization) that installed the app - */ - account: { - login: string; - type: string; - }; - - /** - * Installation target type - */ - // eslint-disable-next-line camelcase - target_type: string; - - /** - * Permissions granted to the app - */ - permissions: Record; -}; - -/** - * Service for interacting with GitHub API - */ -export class GitHubService { - /** - * Default timeout for GitHub API requests (in milliseconds) - * Increased from default 10s to 60s to handle slow network connections - */ - private static readonly DEFAULT_TIMEOUT = 10000; - - /** - * GitHub App ID from environment variables - */ - private readonly appId: string; - - /** - * GitHub App Client ID from environment variables - * Required for OAuth token exchange (different from App ID) - */ - private readonly clientId: string; - - /** - * GitHub App slug/name from environment variables - */ - private readonly appSlug: string; - - /** - * GitHub App Client Secret from environment variables - * Required for OAuth token exchange - */ - private readonly clientSecret: string; - - /** - * Creates an instance of GitHubService - */ - constructor() { - if (!process.env.GITHUB_APP_ID) { - throw new Error('GITHUB_APP_ID environment variable is not set'); - } - - if (!process.env.GITHUB_APP_CLIENT_ID) { - throw new Error('GITHUB_APP_CLIENT_ID environment variable is not set'); - } - - if (!process.env.GITHUB_APP_CLIENT_SECRET) { - throw new Error('GITHUB_APP_CLIENT_SECRET environment variable is not set'); - } - - this.appId = process.env.GITHUB_APP_ID; - this.clientId = process.env.GITHUB_APP_CLIENT_ID; - this.appSlug = process.env.GITHUB_APP_SLUG || 'hawk-tracker'; - this.clientSecret = process.env.GITHUB_APP_CLIENT_SECRET; - } - - /** - * Generate URL for GitHub App installation - * - * @param {string} state - State parameter for CSRF protection and context preservation. - * GitHub will return this value unchanged in the callback URL, - * allowing you to verify that the callback corresponds to the original installation request. - * Typically this is a JWT token or a random string (UUID) that serves as a key - * to retrieve stored context data (projectId, userId, etc.) from Redis or session storage. - * - * Example values: - * - JWT: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwcm9qZWN0SWQiOiI2NzgiLCJ1c2VySWQiOiIxMjMiLCJ0aW1lc3RhbXAiOjE3MDk4NzY1NDB9..." - * - UUID: "550e8400-e29b-41d4-a716-446655440000" - * - * @returns {string} Installation URL with state and redirect_url parameters - * - * Note: Both Setup URL (in GitHub App settings) and redirect_url parameter can be used. - * The redirect_url parameter takes precedence if provided. We use redirect_url to ensure - * the state parameter is properly passed to our callback endpoint. - */ - public getInstallationUrl(state: string): string { - if (!process.env.API_URL) { - throw new Error('API_URL environment variable must be set to generate installation URL with redirect_url'); - } - - /** - * Form callback URL based on API_URL environment variable - * This allows different callback URLs for different environments (dev, staging, production) - * The redirect_url parameter ensures GitHub redirects to our callback with state preserved - * Note: When "Request user authorization (OAuth) during installation" is enabled, - * GitHub redirects to /oauth with both installation_id and code parameters - */ - const redirectUrl = `${process.env.API_URL}/integration/github/oauth`; - - /** - * Include both state and redirect_url parameters - * The redirect_url parameter ensures GitHub redirects to our callback endpoint - * even if Setup URL is configured differently in GitHub App settings - */ - return `https://github.com/apps/${this.appSlug}/installations/new?state=${encodeURIComponent(state)}&redirect_url=${encodeURIComponent(redirectUrl)}`; - } - - /** - * Delete GitHub App installation by installation ID - * - * @param {string} installationId - GitHub App installation ID - * @returns {Promise} - */ - public async deleteInstallation(installationId: string): Promise { - const token = this.createJWT(); - const octokit = this.createOctokit(token); - - await octokit.rest.apps.deleteInstallation({ - // eslint-disable-next-line @typescript-eslint/camelcase, camelcase - installation_id: parseInt(installationId, 10), - }); - } - - /** - * Get installation information - * - * Installation represents a GitHub App installation in a user's or organization's account. - * When a user or organization installs a GitHub App, GitHub creates an Installation object - * that links the app to the account and grants specific permissions to the app for accessing - * repositories and resources. This installation is required to generate installation access tokens - * that allow the app to make API calls on behalf of the installation. - * - * @param {string} installationId - GitHub App installation ID (unique identifier for the installation) - * @returns {Promise} Installation information containing: - * - id: Installation ID - * - account: User or organization that installed the app (with login and type) - * - target_type: Type of target (User or Organization) - * - permissions: Permissions granted to the app for this installation - * @throws {Error} If request fails - */ - public async getInstallationForRepository(installationId: string): Promise { - const token = this.createJWT(); - - /** - * Create Octokit instance with JWT authentication and configured timeout - */ - const octokit = this.createOctokit(token); - - try { - const { data } = await octokit.rest.apps.getInstallation({ - // eslint-disable-next-line @typescript-eslint/camelcase, camelcase - installation_id: parseInt(installationId, 10), - }); - - /** - * Extract account login and type - * account can be User (has 'login' and 'type') or Organization (has 'slug' but not 'login') - */ - let accountLogin = ''; - let accountType = ''; - - if (data.account) { - /** - * Check if account has 'login' property (User) or 'slug' property (Organization) - */ - if ('login' in data.account) { - accountLogin = data.account.login; - accountType = 'login' in data.account && 'type' in data.account ? data.account.type : 'User'; - } else if ('slug' in data.account) { - /** - * For Organization, use 'slug' as login identifier - */ - accountLogin = data.account.slug; - accountType = 'Organization'; - } - } - - return { - id: data.id, - account: { - login: accountLogin, - type: accountType, - }, - // eslint-disable-next-line @typescript-eslint/camelcase, camelcase - target_type: data.target_type, - permissions: data.permissions || {}, - }; - } catch (error) { - throw new Error(`Failed to get installation: ${error instanceof Error ? error.message : String(error)}`); - } - } - - /** - * Get list of repositories accessible to GitHub App installation - * - * @param {string} installationId - GitHub App installation ID - * @returns {Promise} Array of repositories accessible to the installation - * @throws {Error} If request fails - */ - public async getRepositoriesForInstallation(installationId: string): Promise { - /** - * Get installation access token - */ - if (!installationId) { - throw new Error('installationId is required for getting repositories'); - } - - const accessToken = await this.createInstallationToken(installationId); - - /** - * Create Octokit instance with installation access token and configured timeout - */ - const octokit = this.createOctokit(accessToken); - - try { - /** - * Get installation info using JWT token (not installation access token) - * This is needed to check what account/organization it's installed on - */ - const jwtToken = this.createJWT(); - const jwtOctokit = this.createOctokit(jwtToken); - - const installationInfo = await jwtOctokit.rest.apps.getInstallation({ - // eslint-disable-next-line @typescript-eslint/camelcase, camelcase - installation_id: parseInt(installationId, 10), - }); - - /** - * Log installation info for debugging - */ - console.log('Installation info:', { - id: installationInfo.data.id, - account: installationInfo.data.account, - // eslint-disable-next-line @typescript-eslint/camelcase, camelcase - target_type: installationInfo.data.target_type, - // eslint-disable-next-line @typescript-eslint/camelcase, camelcase - repository_selection: installationInfo.data.repository_selection, - }); - - /** - * Get all repositories accessible to the installation - * Use Octokit's paginate helper to automatically fetch all pages - * This ensures we get repositories from both personal accounts and organizations - * Use installation access token for this call - */ - const repositoriesData = await octokit.paginate( - octokit.rest.apps.listReposAccessibleToInstallation, - { - // eslint-disable-next-line @typescript-eslint/camelcase, camelcase - installation_id: parseInt(installationId, 10), - // eslint-disable-next-line @typescript-eslint/camelcase, camelcase - per_page: 100, - } - ); - - console.log(`Total repositories fetched: ${repositoriesData.length}`); - - /** - * Transform GitHub repository objects to our Repository type - * Sort by updatedAt descending (newest first) - */ - const repositories = repositoriesData.map((repo) => ({ - id: repo.id.toString(), - name: repo.name, - fullName: repo.full_name, - private: repo.private || false, - htmlUrl: repo.html_url, - updatedAt: repo.updated_at ? new Date(repo.updated_at) : new Date(0), - language: repo.language || null, - })); - - /** - * Sort repositories by updatedAt descending (newest first) - */ - return repositories.sort((a, b) => { - return b.updatedAt.getTime() - a.updatedAt.getTime(); - }); - } catch (error) { - throw new Error(`Failed to get repositories: ${error instanceof Error ? error.message : String(error)}`); - } - } - - /** - * Create a GitHub issue using GitHub App installation token - * - * @param {string} repoFullName - Repository full name (owner/repo) - * @param {string | null} installationId - GitHub App installation ID - * @param {IssueData} issueData - Issue data (title, body, labels) - * @returns {Promise} Created issue - * @throws {Error} If issue creation fails - */ - public async createIssue( - repoFullName: string, - installationId: string | null, - issueData: IssueData - ): Promise { - const [owner, repo] = repoFullName.split('/'); - - if (!owner || !repo) { - throw new Error(`Invalid repository name format: ${repoFullName}. Expected format: owner/repo`); - } - - /** - * Get installation access token (GitHub App token) - */ - if (!installationId) { - throw new Error('installationId is required for creating GitHub issues'); - } - - const accessToken = await this.createInstallationToken(installationId); - - /** - * Create Octokit instance with installation token and configured timeout - */ - const octokit = this.createOctokit(accessToken); - - /** - * Create issue via REST API using installation token - */ - try { - const { data } = await octokit.rest.issues.create({ - owner, - repo, - title: issueData.title, - body: issueData.body, - labels: issueData.labels, - }); - - return { - number: data.number, - // eslint-disable-next-line @typescript-eslint/camelcase, camelcase - html_url: data.html_url, - title: data.title, - state: data.state, - }; - } catch (error) { - throw new Error(`Failed to create issue: ${error instanceof Error ? error.message : String(error)}`); - } - } - - /** - * Assign Copilot agent to a GitHub issue using user-to-server OAuth token - * - * @param {string} repoFullName - Repository full name (owner/repo) - * @param {number} issueNumber - Issue number - * @param {string} delegatedUserToken - User-to-server OAuth token - * @returns {Promise} - * @throws {Error} If Copilot assignment fails - */ - public async assignCopilot( - repoFullName: string, - issueNumber: number, - delegatedUserToken: string - ): Promise { - const [owner, repo] = repoFullName.split('/'); - - if (!owner || !repo) { - throw new Error(`Invalid repository name format: ${repoFullName}. Expected format: owner/repo`); - } - - /** - * Create Octokit instance with user-to-server OAuth token - */ - const octokit = this.createOctokit(delegatedUserToken); - - try { - /** - * Step 1: Get repository ID and find Copilot bot ID - */ - const repoInfoQuery = ` - query($owner: String!, $name: String!, $issueNumber: Int!) { - repository(owner: $owner, name: $name) { - id - issue(number: $issueNumber) { - id - } - suggestedActors(capabilities: [CAN_BE_ASSIGNED], first: 100) { - nodes { - login - __typename - ... on Bot { - id - } - ... on User { - id - } - } - } - } - } - `; - - type RepoInfoGraphQLResponse = { - repository?: { - id: string; - issue?: { id: string }; - suggestedActors: { - nodes: Array<{ login: string; __typename?: string; id?: string }>; - }; - }; - }; - - const repoInfo = await octokit.graphql(repoInfoQuery, { - owner, - name: repo, - issueNumber, - }); - - console.log('[GitHub API] Repository info query response:', JSON.stringify(repoInfo, null, 2)); - - const repositoryId = repoInfo?.repository?.id; - const issueId = repoInfo?.repository?.issue?.id; - - if (!repositoryId) { - throw new Error(`Failed to get repository ID for ${repoFullName}`); - } - - if (!issueId) { - throw new Error(`Failed to get issue ID for issue #${issueNumber}`); - } - - /** - * Find Copilot bot in suggested actors - */ - type SuggestedActorNode = { login: string; __typename?: string; id?: string }; - let copilotBot = (repoInfo?.repository?.suggestedActors?.nodes ?? []).find( - (node: SuggestedActorNode) => node.login === 'copilot-swe-agent' - ); - - console.log('[GitHub API] Copilot bot found in suggestedActors:', copilotBot ? { - login: copilotBot.login, - id: copilotBot.id, - } : 'not found'); - - /** - * If not found in suggestedActors, try to get it directly by login - */ - if (!copilotBot || !copilotBot.id) { - console.log('[GitHub API] Trying to get Copilot bot directly by login...'); - - try { - const copilotBotQuery = ` - query($login: String!) { - user(login: $login) { - id - login - __typename - } - } - `; - - type CopilotUserInfoGraphQLResponse = { - user?: { id: string; login: string; __typename?: string }; - }; - - const copilotUserInfo = await octokit.graphql(copilotBotQuery, { - login: 'copilot-swe-agent', - }); - - console.log('[GitHub API] Direct Copilot bot query response:', JSON.stringify(copilotUserInfo, null, 2)); - - if (copilotUserInfo?.user?.id) { - copilotBot = { - login: copilotUserInfo.user.login, - id: copilotUserInfo.user.id, - }; - } - } catch (directQueryError) { - console.log('[GitHub API] Failed to get Copilot bot directly:', directQueryError); - } - } - - if (!copilotBot || !copilotBot.id) { - throw new Error('Copilot coding agent (copilot-swe-agent) is not available for this repository'); - } - - console.log('[GitHub API] Using Copilot bot:', { - login: copilotBot.login, - id: copilotBot.id, - }); - - /** - * Step 2: Assign Copilot to issue via GraphQL - * Note: Assignable is a union type (Issue | PullRequest), so we need to use fragments - */ - const assignCopilotMutation = ` - mutation($issueId: ID!, $assigneeIds: [ID!]!) { - addAssigneesToAssignable(input: { - assignableId: $issueId - assigneeIds: $assigneeIds - }) { - assignable { - ... on Issue { - id - number - assignees(first: 10) { - nodes { - login - } - } - } - ... on PullRequest { - id - number - assignees(first: 10) { - nodes { - login - } - } - } - } - } - } - `; - - type AssignCopilotGraphQLResponse = { - addAssigneesToAssignable?: { - assignable?: { - id: string; - number: number; - assignees?: { nodes?: Array<{ login: string }> }; - }; - }; - }; - - const response = await octokit.graphql(assignCopilotMutation, { - issueId, - assigneeIds: [ copilotBot.id ], - }); - - console.log('[GitHub API] Assign Copilot mutation response:', JSON.stringify(response, null, 2)); - - const assignable = response?.addAssigneesToAssignable?.assignable; - - if (!assignable) { - throw new Error('Failed to assign Copilot to issue'); - } - - // eslint-disable-next-line valid-jsdoc - /** - * Assignable is a union type (Issue | PullRequest), so we need to check which type it is - * Both Issue and PullRequest have assignees field, so we can access it directly - * - * Note: The assignees list might not be immediately updated in the response, - * so we check if the mutation succeeded (assignable is not null) rather than - * verifying the assignees list directly - */ - const assignedLogins = assignable.assignees?.nodes?.map((n: { login: string }) => n.login) || []; - - /** - * Log assignees for debugging (but don't fail if Copilot is not in the list yet) - * GitHub API might not immediately reflect the assignment in the response - */ - console.log(`[GitHub API] Issue assignees after mutation:`, assignedLogins); - - /** - * Get issue number from assignable (works for both Issue and PullRequest) - */ - const assignedNumber = assignable.number; - - /** - * If Copilot is in the list, log success. Otherwise, just log a warning - * but don't throw an error, as the mutation might have succeeded even if - * the response doesn't show the assignee yet - */ - if (assignedLogins.includes('copilot-swe-agent')) { - console.log(`[GitHub API] Successfully assigned Copilot to issue #${assignedNumber}`); - } else { - /** - * Mutation succeeded (assignable is not null), but assignees list might not be updated yet - * This is a known behavior of GitHub API - the mutation succeeds but the response - * might not immediately reflect the new assignee - */ - console.log(`[GitHub API] Copilot assignment mutation completed for issue #${assignedNumber}, but assignees list not yet updated in response`); - } - } catch (error) { - throw new Error(`Failed to assign Copilot: ${error instanceof Error ? error.message : String(error)}`); - } - } - - /** - * Exchange OAuth authorization code for user-to-server access token - * This token allows the GitHub App to perform actions on behalf of the user - * - * @param code - OAuth authorization code from GitHub callback - * @param redirectUri - Redirect URI that was used in the OAuth authorization request (must match) - * @returns Tokens and user info - * @throws If token exchange fails - */ - public async exchangeOAuthCodeForToken( - code: string, - redirectUri?: string - ): Promise<{ - accessToken: string; - refreshToken: string; - expiresAt: Date | null; - refreshTokenExpiresAt: Date | null; - user: { id: number; login: string }; - }> { - try { - /** - * Build redirect URI if not provided - */ - if (!redirectUri) { - if (!process.env.API_URL) { - throw new Error('API_URL environment variable must be set to generate redirect URI'); - } - - redirectUri = `${process.env.API_URL}/integration/github/oauth`; - } - - /** - * Use Octokit OAuth methods for token exchange - * This is the recommended way to exchange OAuth code for access token - */ - const { authentication } = await exchangeWebFlowCode({ - clientType: 'github-app', - clientId: this.clientId, - clientSecret: this.clientSecret, - code, - redirectUrl: redirectUri, - }); - - if (!authentication.token) { - throw new Error('No access token in OAuth response'); - } - - const accessToken = authentication.token; - /** - * refreshToken, expiresAt, and refreshTokenExpiresAt are only available in certain authentication types - * Use type guards to safely access these properties - */ - const refreshToken = 'refreshToken' in authentication && authentication.refreshToken - ? authentication.refreshToken - : ''; - const expiresAt = 'expiresAt' in authentication && authentication.expiresAt - ? new Date(authentication.expiresAt) - : null; - const refreshTokenExpiresAt = 'refreshTokenExpiresAt' in authentication && authentication.refreshTokenExpiresAt - ? new Date(authentication.refreshTokenExpiresAt) - : null; - - /** - * Get user info using the access token - */ - const octokit = this.createOctokit(accessToken); - const { data: userData } = await octokit.rest.users.getAuthenticated(); - - return { - accessToken, - refreshToken, - expiresAt, - refreshTokenExpiresAt, - user: { - id: userData.id, - login: userData.login, - }, - }; - } catch (error) { - throw new Error(`Failed to exchange OAuth code for token: ${error instanceof Error ? error.message : String(error)}`); - } - } - - /** - * Validate user-to-server access token by making GET /user request - * Updates tokenLastValidatedAt if validation succeeds - * - * @param accessToken - User-to-server access token - * @returns Validation result - */ - public async validateUserToken(accessToken: string): Promise<{ valid: boolean; user?: { id: number; login: string }; status: 'active' | 'revoked' }> { - try { - const octokit = this.createOctokit(accessToken); - const { data: userData } = await octokit.rest.users.getAuthenticated(); - - return { - valid: true, - user: { - id: userData.id, - login: userData.login, - }, - status: 'active', - }; - } catch (error: any) { - /** - * Check if error is 401 or 403 (token revoked/invalid) - */ - if (error?.status === 401 || error?.status === 403) { - return { - valid: false, - status: 'revoked', - }; - } - - /** - * Other errors (network, etc.) - consider token as potentially valid - * but log the error - */ - throw new Error(`Failed to validate user token: ${error instanceof Error ? error.message : String(error)}`); - } - } - - /** - * Refresh user-to-server access token using refresh token - * Rotates refresh token if a new one is provided - * - * @param refreshToken - OAuth refresh token - * @returns New tokens - * @throws If token refresh fails - */ - public async refreshUserToken(refreshToken: string): Promise<{ - accessToken: string; - refreshToken: string; - expiresAt: Date | null; - refreshTokenExpiresAt: Date | null; - }> { - try { - const { authentication } = await refreshOAuthToken({ - clientType: 'github-app', - clientId: this.clientId, - clientSecret: this.clientSecret, - refreshToken, - }); - - if (!authentication.token) { - throw new Error('No access token in refresh response'); - } - - /** - * refreshToken is only available in GitHubAppAuthenticationWithRefreshToken type - * Check if it exists before accessing - */ - const newRefreshToken = 'refreshToken' in authentication - ? authentication.refreshToken || refreshToken - : refreshToken; // Use new refresh token if provided, otherwise keep old one - - return { - accessToken: authentication.token, - refreshToken: newRefreshToken, - expiresAt: 'expiresAt' in authentication && authentication.expiresAt - ? new Date(authentication.expiresAt) - : null, - refreshTokenExpiresAt: 'refreshTokenExpiresAt' in authentication && authentication.refreshTokenExpiresAt - ? new Date(authentication.refreshTokenExpiresAt) - : null, - }; - } catch (error) { - throw new Error(`Failed to refresh user token: ${error instanceof Error ? error.message : String(error)}`); - } - } - - /** - * Create Octokit instance with configured timeout - * - * @param auth - Authentication token (JWT or installation access token) - * @returns Configured Octokit instance - */ - private createOctokit(auth: string): Octokit { - return new Octokit({ - auth, - request: { - timeout: GitHubService.DEFAULT_TIMEOUT, - }, - }); - } - - /** - * Get private key from environment variables or file - * - * @returns {string} Private key in PEM format with real newlines - * @throws {Error} If GITHUB_PRIVATE_KEY is not set - */ - private getPrivateKey(): string { - if (process.env.GITHUB_PRIVATE_KEY) { - /** - * Get private key from environment variable - * dotenv v16+ handles both multiline strings and escaped \n automatically - * But we check if there are literal \n sequences (not actual newlines) and replace them - */ - let privateKey = process.env.GITHUB_PRIVATE_KEY; - - /** - * Check if the string contains literal \n (backslash followed by n) instead of actual newlines - * This can happen if the value was stored as a single line with escaped newlines - */ - if (privateKey.includes('\\n') && !privateKey.includes('\n')) { - /** - * Replace literal \n with actual newlines - */ - privateKey = privateKey.replace(/\\n/g, '\n'); - } - - return privateKey; - } - - throw new Error('GITHUB_PRIVATE_KEY must be set'); - } - - /** - * Create JWT token for GitHub App authentication - * - * @returns {string} JWT token - */ - private createJWT(): string { - const privateKey = this.getPrivateKey(); - const now = Math.floor(Date.now() / 1000); - - /** - * JWT payload for GitHub App - * - iat: issued at time (current time) - * - exp: expiration time (10 minutes from now, GitHub allows up to 10 minutes) - * - iss: issuer (GitHub App ID) - */ - const payload = { - iat: now - 60, // Allow 1 minute clock skew - exp: now + 600, // 10 minutes expiration - iss: this.appId, - }; - - return jwt.sign(payload, privateKey, { algorithm: 'RS256' }); - } - - /** - * Get installation access token from GitHub API - * - * @param {string} installationId - GitHub App installation ID - * @returns {Promise} Installation access token (valid for 1 hour) - * @throws {Error} If token creation fails - */ - private async createInstallationToken(installationId: string): Promise { - const token = this.createJWT(); - - /** - * Create Octokit instance with JWT authentication and configured timeout - */ - const octokit = this.createOctokit(token); - - try { - /** - * Request installation access token - */ - const { data } = await octokit.rest.apps.createInstallationAccessToken({ - // eslint-disable-next-line @typescript-eslint/camelcase, camelcase - installation_id: parseInt(installationId, 10), - }); - - return data.token; - } catch (error) { - throw new Error(`Failed to create installation token: ${error instanceof Error ? error.message : String(error)}`); - } - } -} diff --git a/src/integrations/github/store/InstallStateStoreInterface.ts b/src/integrations/github/store/InstallStateStoreInterface.ts index bbed981f..54c59aa5 100644 --- a/src/integrations/github/store/InstallStateStoreInterface.ts +++ b/src/integrations/github/store/InstallStateStoreInterface.ts @@ -1,8 +1,33 @@ +/** + * State data for GitHub App installation flow + */ +export interface InstallStateData { + /** + * Workspace ID where the installation will be saved + */ + workspaceId: string; + + /** + * Project ID — used for redirect after OAuth callback + */ + projectId: string; + + /** + * User ID who initiated the flow + */ + userId: string; + + /** + * Timestamp when state was created + */ + timestamp: number; +} + /** * Interface for GitHub App installation state store implementations * * Defines contract for storing temporary GitHub App installation state: - * - Installation state: maps state ID to projectId, userId, and timestamp + * - Installation state: maps state ID to workspaceId, projectId, userId, and timestamp * Used for CSRF protection during GitHub App installation flow */ export interface InstallStateStoreInterface { @@ -18,9 +43,9 @@ export interface InstallStateStoreInterface { * Save installation state data * * @param stateId - unique state identifier (usually UUID) - * @param data - installation state data (projectId, userId, timestamp) + * @param data - installation state data */ - saveState(stateId: string, data: { projectId: string; userId: string; timestamp: number }): Promise; + saveState(stateId: string, data: InstallStateData): Promise; /** * Get and consume installation state data @@ -28,7 +53,7 @@ export interface InstallStateStoreInterface { * @param stateId - state identifier * @returns installation state data or null if not found/expired */ - getState(stateId: string): Promise<{ projectId: string; userId: string; timestamp: number } | null>; + getState(stateId: string): Promise; /** * Stop cleanup timer (for testing) diff --git a/src/integrations/github/store/install-state.redis.store.ts b/src/integrations/github/store/install-state.redis.store.ts index 5c0fd823..b0354f4a 100644 --- a/src/integrations/github/store/install-state.redis.store.ts +++ b/src/integrations/github/store/install-state.redis.store.ts @@ -1,6 +1,6 @@ import { RedisClientType } from 'redis'; import RedisHelper from '../../../redisHelper'; -import { InstallStateStoreInterface } from './InstallStateStoreInterface'; +import { InstallStateStoreInterface, InstallStateData } from './InstallStateStoreInterface'; /** * Redis-based store for GitHub App installation state @@ -45,11 +45,11 @@ export class RedisInstallStateStore implements InstallStateStoreInterface { * Save installation state data * * @param stateId - unique state identifier (usually UUID) - * @param data - installation state data (projectId, userId, timestamp) + * @param data - installation state data */ public async saveState( stateId: string, - data: { projectId: string; userId: string; timestamp: number } + data: InstallStateData ): Promise { const client = this.getClient(); const key = `${this.STATE_PREFIX}${stateId}`; @@ -64,7 +64,7 @@ export class RedisInstallStateStore implements InstallStateStoreInterface { * @param stateId - state identifier * @returns installation state data or null if not found/expired */ - public async getState(stateId: string): Promise<{ projectId: string; userId: string; timestamp: number } | null> { + public async getState(stateId: string): Promise { const client = this.getClient(); const key = `${this.STATE_PREFIX}${stateId}`; @@ -90,7 +90,7 @@ export class RedisInstallStateStore implements InstallStateStoreInterface { } try { - return JSON.parse(value) as { projectId: string; userId: string; timestamp: number }; + return JSON.parse(value) as InstallStateData; } catch (error) { console.error('[Redis GitHub Install Store] Failed to parse state:', error); diff --git a/src/models/user.ts b/src/models/user.ts index 26c696db..1eabc022 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -5,7 +5,7 @@ import { Collection, ObjectId, OptionalId } from 'mongodb'; import AbstractModel from './abstractModel'; import objectHasOnlyProps from '../utils/objectHasOnlyProps'; import { NotificationsChannelsDBScheme } from '../types/notification-channels'; -import { BankCard, UserDBScheme } from '@hawk.so/types'; +import { BankCard, UserDBScheme, GitHubAuthorization } from '@hawk.so/types'; import { v4 as uuid } from 'uuid'; /** @@ -160,6 +160,11 @@ export default class UserModel extends AbstractModel> }; }; + /** + * GitHub OAuth authorizations + */ + public githubAuthorizations?: GitHubAuthorization[]; + /** * Model's collection */ @@ -496,4 +501,69 @@ export default class UserModel extends AbstractModel> public getSamlIdentity(workspaceId: string): { id: string; email: string } | null { return this.identities?.[workspaceId]?.saml || null; } + + /** + * Add or update a GitHub OAuth authorization for this user. + * If an authorization with the same githubUserId already exists, it is replaced. + * + * @param authorization - GitHub authorization data + */ + public async upsertGitHubAuthorization(authorization: GitHubAuthorization): Promise { + const existing = this.githubAuthorizations?.find( + (a) => a.githubUserId === authorization.githubUserId + ); + + if (existing) { + await this.collection.updateOne( + { + _id: new ObjectId(this._id), + 'githubAuthorizations.githubUserId': authorization.githubUserId, + }, + { + $set: { + 'githubAuthorizations.$': authorization, + }, + } + ); + } else { + await this.collection.updateOne( + { _id: new ObjectId(this._id) }, + { + $push: { + githubAuthorizations: authorization, + }, + } + ); + } + + if (!this.githubAuthorizations) { + this.githubAuthorizations = []; + } + + const idx = this.githubAuthorizations.findIndex( + (a) => a.githubUserId === authorization.githubUserId + ); + + if (idx >= 0) { + this.githubAuthorizations[idx] = authorization; + } else { + this.githubAuthorizations.push(authorization); + } + } + + /** + * Find a GitHub authorization by githubUserId + * + * @param githubUserId - GitHub user ID + */ + public findGitHubAuthorization(githubUserId: number): GitHubAuthorization | undefined { + return this.githubAuthorizations?.find((a) => a.githubUserId === githubUserId); + } + + /** + * Get first active GitHub authorization (if any) + */ + public getActiveGitHubAuthorization(): GitHubAuthorization | undefined { + return this.githubAuthorizations?.find((a) => a.status === 'active'); + } } diff --git a/src/models/workspace.ts b/src/models/workspace.ts index 6b0cde0e..798475e4 100644 --- a/src/models/workspace.ts +++ b/src/models/workspace.ts @@ -2,7 +2,7 @@ import { Collection, ObjectId } from 'mongodb'; import AbstractModel from './abstractModel'; import { OptionalId } from '../mongo'; import UserModel from './user'; -import { ConfirmedMemberDBScheme, MemberDBScheme, PendingMemberDBScheme, WorkspaceDBScheme } from '@hawk.so/types'; +import { ConfirmedMemberDBScheme, MemberDBScheme, PendingMemberDBScheme, WorkspaceDBScheme, GitHubInstallation } from '@hawk.so/types'; import crypto from 'crypto'; /** @@ -87,6 +87,11 @@ export default class WorkspaceModel extends AbstractModel imp */ public sso?: WorkspaceDBScheme['sso']; + /** + * External integrations (GitHub, etc.) + */ + public integrations?: WorkspaceDBScheme['integrations']; + /** * Model's collection */ @@ -460,4 +465,67 @@ export default class WorkspaceModel extends AbstractModel imp return date > this.getTariffPlanDueDate(); } + + /** + * Get GitHub installations for this workspace + */ + public getGitHubInstallations(): GitHubInstallation[] { + return this.integrations?.github?.installations ?? []; + } + + /** + * Add a GitHub App installation to this workspace + * + * @param installation - installation data to add + */ + public async addGitHubInstallation(installation: GitHubInstallation): Promise { + await this.collection.updateOne( + { _id: new ObjectId(this._id) }, + { + $push: { + 'integrations.github.installations': installation, + }, + } + ); + + if (!this.integrations) { + this.integrations = { github: { installations: [] } }; + } + if (!this.integrations.github) { + this.integrations.github = { installations: [] }; + } + + this.integrations.github.installations.push(installation); + } + + /** + * Remove a GitHub App installation from this workspace by installationId + * + * @param installationId - GitHub installation ID to remove + */ + public async removeGitHubInstallation(installationId: number): Promise { + await this.collection.updateOne( + { _id: new ObjectId(this._id) }, + { + $pull: { + 'integrations.github.installations': { installationId }, + }, + } + ); + + if (this.integrations?.github?.installations) { + this.integrations.github.installations = this.integrations.github.installations.filter( + (i: GitHubInstallation) => i.installationId !== installationId + ); + } + } + + /** + * Find a GitHub installation by installationId + * + * @param installationId - GitHub installation ID to find + */ + public findGitHubInstallation(installationId: number): GitHubInstallation | undefined { + return this.getGitHubInstallations().find((i) => i.installationId === installationId); + } } diff --git a/src/resolvers/project.js b/src/resolvers/project.js index c1a2767c..bf9f7026 100644 --- a/src/resolvers/project.js +++ b/src/resolvers/project.js @@ -9,7 +9,6 @@ const getEventsFactory = require('./helpers/eventsFactory').default; const ProjectToWorkspace = require('../models/projectToWorkspace'); const { dateFromObjectId } = require('../utils/dates'); const ProjectModel = require('../models/project').default; -const { GitHubService } = require('../integrations/github/service'); const EVENTS_GROUP_HASH_INDEX_NAME = 'groupHashUnique'; const REPETITIONS_GROUP_HASH_INDEX_NAME = 'groupHash_hashed'; @@ -416,19 +415,8 @@ module.exports = { try { /** - * If Task Manager is configured with GitHub and has installationId, - * try to delete GitHub App installation as part of disconnect - */ - const taskManager = project.taskManager; - - if (taskManager && taskManager.type === 'github' && taskManager.config && taskManager.config.installationId) { - const githubService = new GitHubService(); - - await githubService.deleteInstallation(taskManager.config.installationId); - } - - /** - * Remove taskManager field from project + * Remove taskManager field from project. + * The workspace-level installation is preserved — other projects may use it. */ return await project.updateProject({ taskManager: null, diff --git a/src/resolvers/workspace.js b/src/resolvers/workspace.js index 2333099d..049019fe 100644 --- a/src/resolvers/workspace.js +++ b/src/resolvers/workspace.js @@ -600,6 +600,29 @@ module.exports = { return workspaceModel.sso || null; }, + + /** + * Returns workspace integrations (GitHub installations, etc.) + * + * @param {WorkspaceDBScheme} workspace - result from resolver above (parent workspace object) + * @returns {Object|null} + */ + integrations(workspace) { + if (!workspace.integrations) { + return null; + } + + return { + github: workspace.integrations.github + ? { + installations: (workspace.integrations.github.installations || []).map((i) => ({ + installationId: i.installationId, + account: i.account, + })), + } + : null, + }; + }, }, /** diff --git a/src/typeDefs/taskManager.ts b/src/typeDefs/taskManager.ts index 518b20dd..6f0f5ed8 100644 --- a/src/typeDefs/taskManager.ts +++ b/src/typeDefs/taskManager.ts @@ -34,6 +34,11 @@ export default gql` Repository full name (owner/repo) """ repoFullName: String! + + """ + Primary programming language of the repository + """ + repoLanguage: String } """ diff --git a/src/typeDefs/workspace.ts b/src/typeDefs/workspace.ts index a40ad0f7..f210709c 100644 --- a/src/typeDefs/workspace.ts +++ b/src/typeDefs/workspace.ts @@ -56,6 +56,61 @@ export default gql` """ union Member = ConfirmedMember | PendingMember + """ + GitHub account (user or org) where the App is installed + """ + type GitHubInstallationAccount { + """ + GitHub account numeric ID + """ + id: Int! + + """ + GitHub username or organization login + """ + login: String! + + """ + Account type: "User" or "Organization" + """ + type: String! + } + + """ + GitHub App installation stored at workspace level + """ + type GitHubInstallation { + """ + GitHub App installation ID issued by GitHub + """ + installationId: Int! + + """ + Account (user or org) where the App is installed + """ + account: GitHubInstallationAccount! + } + + """ + GitHub integration data for workspace + """ + type WorkspaceGitHubIntegration { + """ + List of GitHub App installations linked to this workspace + """ + installations: [GitHubInstallation!]! + } + + """ + Workspace integrations + """ + type WorkspaceIntegrations { + """ + GitHub integration data (App installations) + """ + github: WorkspaceGitHubIntegration + } + """ Represent Workspace info """ @@ -141,6 +196,11 @@ export default gql` SSO configuration (admin only, returns null for non-admin users) """ sso: WorkspaceSsoConfig @definedOnlyForAdmins + + """ + External integrations (GitHub, etc.) + """ + integrations: WorkspaceIntegrations } """ diff --git a/test/integrations/github-routes.test.ts b/test/integrations/github-routes.test.ts index 03eacc94..967ffb9f 100644 --- a/test/integrations/github-routes.test.ts +++ b/test/integrations/github-routes.test.ts @@ -5,15 +5,19 @@ import { createGitHubRouter } from '../../src/integrations/github/routes'; import { ContextFactories } from '../../src/types/graphql'; /** - * Mock GitHubService + * All mock functions declared before jest.mock calls (Jest hoists mocks) + */ +/** + * All mock functions declared before jest.mock calls (ts-jest requires this) */ const mockGetInstallationUrl = jest.fn((state: string) => { return `https://github.com/apps/test-app/installations/new?state=${encodeURIComponent(state)}&redirect_url=${encodeURIComponent('http://localhost:4000/integration/github/oauth')}`; }); const mockGetInstallationForRepository = jest.fn(); const mockExchangeOAuthCodeForToken = jest.fn(); +const mockGetState = jest.fn(); -jest.mock('../../src/integrations/github/service', () => ({ +jest.mock('@hawk.so/github-sdk', () => ({ GitHubService: jest.fn().mockImplementation(() => ({ getInstallationUrl: mockGetInstallationUrl, getInstallationForRepository: mockGetInstallationForRepository, @@ -21,10 +25,6 @@ jest.mock('../../src/integrations/github/service', () => ({ })), })); -/** - * Mock install state store - */ -const mockGetState = jest.fn(); jest.mock('../../src/integrations/github/store/install-state.redis.store', () => ({ RedisInstallStateStore: jest.fn().mockImplementation(() => ({ saveState: jest.fn().mockResolvedValue(undefined), @@ -32,33 +32,30 @@ jest.mock('../../src/integrations/github/store/install-state.redis.store', () => })), })); -const DEMO_WORKSPACE_ID = '6213b6a01e6281087467cc7a'; - -function createMockProject(options: { - projectId?: string; - workspaceId?: string; -}): any { - const { - projectId = new ObjectId().toString(), - workspaceId = new ObjectId().toString(), - } = options; +jest.mock('../../src/mongo', () => ({ + databases: { + hawk: { + collection: jest.fn().mockReturnValue({ + updateOne: jest.fn().mockResolvedValue({ modifiedCount: 1 }), + }), + }, + }, +})); - return { - _id: new ObjectId(projectId), - workspaceId: new ObjectId(workspaceId), - name: 'Test Project', - }; -} +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { databases: mockDatabases } = require('../../src/mongo'); function createMockWorkspace(options: { workspaceId?: string; isAdmin?: boolean; member?: any; + installations?: any[]; }): any { const { workspaceId = new ObjectId().toString(), isAdmin = true, member, + installations = [], } = options; const defaultMember = { @@ -69,6 +66,16 @@ function createMockWorkspace(options: { return { _id: new ObjectId(workspaceId), getMemberInfo: jest.fn().mockResolvedValue(member !== undefined ? member : defaultMember), + getGitHubInstallations: jest.fn().mockReturnValue(installations), + findGitHubInstallation: jest.fn().mockReturnValue(null), + addGitHubInstallation: jest.fn().mockResolvedValue(undefined), + }; +} + +function createMockUser(userId: string): any { + return { + _id: new ObjectId(userId), + upsertGitHubAuthorization: jest.fn().mockResolvedValue(undefined), }; } @@ -200,37 +207,35 @@ describe('GitHub Routes - /integration/github/connect', () => { mockGetState.mockReset(); mockGetInstallationForRepository.mockReset(); mockExchangeOAuthCodeForToken.mockReset(); + mockDatabases.hawk.collection().updateOne.mockReset(); + mockDatabases.hawk.collection().updateOne.mockResolvedValue({ modifiedCount: 1 }); /** * Setup environment variables */ process.env.GITHUB_APP_ID = '123456'; + process.env.GITHUB_PRIVATE_KEY = 'test-private-key'; process.env.API_URL = 'http://localhost:4000'; process.env.GARAGE_URL = 'http://localhost:8080'; }); afterEach(() => { Reflect.deleteProperty(process.env, 'GITHUB_APP_ID'); + Reflect.deleteProperty(process.env, 'GITHUB_PRIVATE_KEY'); Reflect.deleteProperty(process.env, 'API_URL'); }); describe('GET /integration/github/connect', () => { - it('should return JSON with redirectUrl when user is authenticated and is admin', async () => { + it('should return redirectUrl when no installations exist', async () => { const projectId = new ObjectId().toString(); const workspaceId = new ObjectId().toString(); - const mockProject = createMockProject({ - projectId, - workspaceId, - }); const mockWorkspace = createMockWorkspace({ workspaceId, isAdmin: true, }); const factories: ContextFactories = { - projectsFactory: { - findById: jest.fn().mockResolvedValue(mockProject), - } as any, + projectsFactory: {} as any, workspacesFactory: { findById: jest.fn().mockResolvedValue(mockWorkspace), } as any, @@ -242,16 +247,52 @@ describe('GitHub Routes - /integration/github/connect', () => { setupRouter(factories); - const response = await makeRequest(app, 'GET', '/integration/github/connect', { projectId }); + const response = await makeRequest(app, 'GET', '/integration/github/connect', { projectId, workspaceId }); expect(response.status).toBe(200); + expect(response.body.hasInstallations).toBe(false); expect(response.body).toHaveProperty('redirectUrl'); expect(response.body.redirectUrl).toContain('https://github.com/apps/test-app/installations/new'); expect(response.body.redirectUrl).toMatch(/state=[^&]+/); }); + it('should return existing installations when workspace already has them', async () => { + const projectId = new ObjectId().toString(); + const workspaceId = new ObjectId().toString(); + const existingInstallation = { + installationId: 12345, + account: { id: 1, login: 'test-org', type: 'Organization' }, + }; + const mockWorkspace = createMockWorkspace({ + workspaceId, + isAdmin: true, + installations: [existingInstallation], + }); + + const factories: ContextFactories = { + projectsFactory: {} as any, + workspacesFactory: { + findById: jest.fn().mockResolvedValue(mockWorkspace), + } as any, + usersFactory: {} as any, + plansFactory: {} as any, + businessOperationsFactory: {} as any, + releasesFactory: {} as any, + }; + + setupRouter(factories); + + const response = await makeRequest(app, 'GET', '/integration/github/connect', { projectId, workspaceId }); + + expect(response.status).toBe(200); + expect(response.body.hasInstallations).toBe(true); + expect(response.body.installations).toHaveLength(1); + expect(response.body.installations[0].installationId).toBe(12345); + }); + it('should return 401 when user is not authenticated', async () => { const projectId = new ObjectId().toString(); + const workspaceId = new ObjectId().toString(); const factories: ContextFactories = { projectsFactory: {} as any, workspacesFactory: {} as any, @@ -265,7 +306,7 @@ describe('GitHub Routes - /integration/github/connect', () => { req.context.user.id = undefined; }); - const response = await makeRequest(app, 'GET', '/integration/github/connect', { projectId }); + const response = await makeRequest(app, 'GET', '/integration/github/connect', { projectId, workspaceId }); expect(response.status).toBe(401); expect(response.body).toHaveProperty('error'); @@ -273,6 +314,7 @@ describe('GitHub Routes - /integration/github/connect', () => { }); it('should return 400 when projectId is missing', async () => { + const workspaceId = new ObjectId().toString(); const factories: ContextFactories = { projectsFactory: {} as any, workspacesFactory: {} as any, @@ -284,7 +326,7 @@ describe('GitHub Routes - /integration/github/connect', () => { setupRouter(factories); - const response = await makeRequest(app, 'GET', '/integration/github/connect'); + const response = await makeRequest(app, 'GET', '/integration/github/connect', { workspaceId }); expect(response.status).toBe(400); expect(response.body).toHaveProperty('error'); @@ -292,6 +334,7 @@ describe('GitHub Routes - /integration/github/connect', () => { }); it('should return 400 when projectId format is invalid', async () => { + const workspaceId = new ObjectId().toString(); const factories: ContextFactories = { projectsFactory: {} as any, workspacesFactory: {} as any, @@ -303,19 +346,17 @@ describe('GitHub Routes - /integration/github/connect', () => { setupRouter(factories); - const response = await makeRequest(app, 'GET', '/integration/github/connect', { projectId: 'invalid-id' }); + const response = await makeRequest(app, 'GET', '/integration/github/connect', { projectId: 'invalid-id', workspaceId }); expect(response.status).toBe(400); expect(response.body).toHaveProperty('error'); - expect(response.body.error).toContain('Invalid projectId format'); + expect(response.body.error).toContain('projectId'); }); - it('should return 404 when project is not found', async () => { + it('should return 400 when workspaceId is missing', async () => { const projectId = new ObjectId().toString(); const factories: ContextFactories = { - projectsFactory: { - findById: jest.fn().mockResolvedValue(null), - } as any, + projectsFactory: {} as any, workspacesFactory: {} as any, usersFactory: {} as any, plansFactory: {} as any, @@ -327,21 +368,15 @@ describe('GitHub Routes - /integration/github/connect', () => { const response = await makeRequest(app, 'GET', '/integration/github/connect', { projectId }); - expect(response.status).toBe(404); + expect(response.status).toBe(400); expect(response.body).toHaveProperty('error'); - expect(response.body.error).toContain('Project not found'); + expect(response.body.error).toContain('workspaceId'); }); - it('should return 400 when project is demo project', async () => { + it('should return 400 when workspaceId format is invalid', async () => { const projectId = new ObjectId().toString(); - const mockProject = createMockProject({ - projectId, - workspaceId: DEMO_WORKSPACE_ID, - }); const factories: ContextFactories = { - projectsFactory: { - findById: jest.fn().mockResolvedValue(mockProject), - } as any, + projectsFactory: {} as any, workspacesFactory: {} as any, usersFactory: {} as any, plansFactory: {} as any, @@ -351,29 +386,52 @@ describe('GitHub Routes - /integration/github/connect', () => { setupRouter(factories); - const response = await makeRequest(app, 'GET', '/integration/github/connect', { projectId }); + const response = await makeRequest(app, 'GET', '/integration/github/connect', { + projectId, + workspaceId: 'invalid-workspace-id', + }); expect(response.status).toBe(400); expect(response.body).toHaveProperty('error'); - expect(response.body.error).toContain('Unable to update demo project'); + expect(response.body.error).toContain('Invalid workspaceId format'); }); - it('should return 403 when user is not admin', async () => { + it('should return 404 when workspace is not found', async () => { const projectId = new ObjectId().toString(); const workspaceId = new ObjectId().toString(); - const mockProject = createMockProject({ + const factories: ContextFactories = { + projectsFactory: {} as any, + workspacesFactory: { + findById: jest.fn().mockResolvedValue(null), + } as any, + usersFactory: {} as any, + plansFactory: {} as any, + businessOperationsFactory: {} as any, + releasesFactory: {} as any, + }; + + setupRouter(factories); + + const response = await makeRequest(app, 'GET', '/integration/github/connect', { projectId, - workspaceId, + workspaceId }); + + expect(response.status).toBe(404); + expect(response.body).toHaveProperty('error'); + expect(response.body.error).toContain('Workspace not found'); + }); + + it('should return 403 when user is not admin', async () => { + const projectId = new ObjectId().toString(); + const workspaceId = new ObjectId().toString(); const mockWorkspace = createMockWorkspace({ workspaceId, isAdmin: false, }); const factories: ContextFactories = { - projectsFactory: { - findById: jest.fn().mockResolvedValue(mockProject), - } as any, + projectsFactory: {} as any, workspacesFactory: { findById: jest.fn().mockResolvedValue(mockWorkspace), } as any, @@ -385,7 +443,10 @@ describe('GitHub Routes - /integration/github/connect', () => { setupRouter(factories); - const response = await makeRequest(app, 'GET', '/integration/github/connect', { projectId }); + const response = await makeRequest(app, 'GET', '/integration/github/connect', { + projectId, + workspaceId + }); expect(response.status).toBe(403); expect(response.body).toHaveProperty('error'); @@ -395,19 +456,13 @@ describe('GitHub Routes - /integration/github/connect', () => { it('should return 403 when user is not a member of workspace', async () => { const projectId = new ObjectId().toString(); const workspaceId = new ObjectId().toString(); - const mockProject = createMockProject({ - projectId, - workspaceId, - }); const mockWorkspace = createMockWorkspace({ workspaceId, member: null, }); const factories: ContextFactories = { - projectsFactory: { - findById: jest.fn().mockResolvedValue(mockProject), - } as any, + projectsFactory: {} as any, workspacesFactory: { findById: jest.fn().mockResolvedValue(mockWorkspace), } as any, @@ -419,7 +474,7 @@ describe('GitHub Routes - /integration/github/connect', () => { setupRouter(factories); - const response = await makeRequest(app, 'GET', '/integration/github/connect', { projectId }); + const response = await makeRequest(app, 'GET', '/integration/github/connect', { projectId, workspaceId }); expect(response.status).toBe(403); expect(response.body).toHaveProperty('error'); @@ -434,45 +489,38 @@ describe('GitHub Routes - /integration/github/connect', () => { const code = 'test-oauth-code'; const installationId = '12345678'; - /** - * Helper to create mock project with taskManager - */ - function createMockProjectWithTaskManager(taskManager?: any): any { - const mockProject = createMockProject({ - projectId, - workspaceId, - }); - + function createDefaultStateData(): any { return { - ...mockProject, - taskManager: taskManager || { - type: 'github', - autoTaskEnabled: false, - taskThresholdTotalCount: 50, - assignAgent: false, - connectedAt: new Date('2025-01-01'), - updatedAt: new Date('2025-01-01'), - config: { - installationId: installationId, - repoId: '789012', - repoFullName: 'owner/repo', - }, - }, - updateProject: jest.fn().mockResolvedValue(undefined), + workspaceId, + projectId, + userId, + timestamp: Date.now(), }; } - it('should redirect with error when code is missing', async () => { - const factories: ContextFactories = { + function createOAuthFactories(options: { + workspace?: any; + user?: any; + } = {}): ContextFactories { + const mockWorkspace = options.workspace || createMockWorkspace({ workspaceId }); + const mockUser = options.user || createMockUser(userId); + + return { projectsFactory: {} as any, - workspacesFactory: {} as any, - usersFactory: {} as any, + workspacesFactory: { + findById: jest.fn().mockResolvedValue(mockWorkspace), + } as any, + usersFactory: { + findById: jest.fn().mockResolvedValue(mockUser), + } as any, plansFactory: {} as any, businessOperationsFactory: {} as any, releasesFactory: {} as any, }; + } - setupRouter(factories); + it('should redirect with error when code is missing', async () => { + setupRouter(createOAuthFactories()); const response = await makeRequest(app, 'GET', '/integration/github/oauth', { state, @@ -484,16 +532,7 @@ describe('GitHub Routes - /integration/github/connect', () => { }); it('should redirect with error when state is missing', async () => { - const factories: ContextFactories = { - projectsFactory: {} as any, - workspacesFactory: {} as any, - usersFactory: {} as any, - plansFactory: {} as any, - businessOperationsFactory: {} as any, - releasesFactory: {} as any, - }; - - setupRouter(factories); + setupRouter(createOAuthFactories()); const response = await makeRequest(app, 'GET', '/integration/github/oauth', { code, @@ -507,16 +546,7 @@ describe('GitHub Routes - /integration/github/connect', () => { it('should redirect with error when state is invalid or expired', async () => { mockGetState.mockResolvedValue(null); - const factories: ContextFactories = { - projectsFactory: {} as any, - workspacesFactory: {} as any, - usersFactory: {} as any, - plansFactory: {} as any, - businessOperationsFactory: {} as any, - releasesFactory: {} as any, - }; - - setupRouter(factories); + setupRouter(createOAuthFactories()); const response = await makeRequest(app, 'GET', '/integration/github/oauth', { code, @@ -529,18 +559,14 @@ describe('GitHub Routes - /integration/github/connect', () => { expect(mockGetState).toHaveBeenCalledWith(state); }); - it('should redirect with error when project is not found', async () => { - mockGetState.mockResolvedValue({ - projectId, - userId, - timestamp: Date.now(), - }); + it('should redirect with error when workspace is not found', async () => { + mockGetState.mockResolvedValue(createDefaultStateData()); const factories: ContextFactories = { - projectsFactory: { + projectsFactory: {} as any, + workspacesFactory: { findById: jest.fn().mockResolvedValue(null), } as any, - workspacesFactory: {} as any, usersFactory: {} as any, plansFactory: {} as any, businessOperationsFactory: {} as any, @@ -555,32 +581,15 @@ describe('GitHub Routes - /integration/github/connect', () => { }); expect(response.status).toBe(302); - expect(response.body).toContain(`/project/${projectId}/settings/task-manager`); - expect(response.body).toContain('error=Project+not+found'); + expect(response.body).toContain('http://localhost:8080/'); + expect(response.body).toContain('apiError=Workspace+not+found'); }); - it('should redirect with error when installation_id is present but getInstallationForRepository fails', async () => { - mockGetState.mockResolvedValue({ - projectId, - userId, - timestamp: Date.now(), - }); - - const mockProject = createMockProjectWithTaskManager(); + it('should redirect with error when getInstallationForRepository fails', async () => { + mockGetState.mockResolvedValue(createDefaultStateData()); mockGetInstallationForRepository.mockRejectedValue(new Error('Installation not found')); - const factories: ContextFactories = { - projectsFactory: { - findById: jest.fn().mockResolvedValue(mockProject), - } as any, - workspacesFactory: {} as any, - usersFactory: {} as any, - plansFactory: {} as any, - businessOperationsFactory: {} as any, - releasesFactory: {} as any, - }; - - setupRouter(factories); + setupRouter(createOAuthFactories()); const response = await makeRequest(app, 'GET', '/integration/github/oauth', { code, @@ -595,29 +604,17 @@ describe('GitHub Routes - /integration/github/connect', () => { expect(mockGetInstallationForRepository).toHaveBeenCalledWith(installationId); }); - it('should redirect with error when saving taskManager config fails', async () => { - mockGetState.mockResolvedValue({ - projectId, - userId, - timestamp: Date.now(), + it('should redirect with error when saving installation to workspace fails', async () => { + mockGetState.mockResolvedValue(createDefaultStateData()); + mockGetInstallationForRepository.mockResolvedValue({ + account: { id: 1, login: 'test-org' }, + target_type: 'Organization', }); - const mockProject = createMockProjectWithTaskManager(); - mockProject.updateProject.mockRejectedValue(new Error('Database error')); - mockGetInstallationForRepository.mockResolvedValue({}); + const mockWorkspace = createMockWorkspace({ workspaceId }); + mockWorkspace.addGitHubInstallation.mockRejectedValue(new Error('Database error')); - const factories: ContextFactories = { - projectsFactory: { - findById: jest.fn().mockResolvedValue(mockProject), - } as any, - workspacesFactory: {} as any, - usersFactory: {} as any, - plansFactory: {} as any, - businessOperationsFactory: {} as any, - releasesFactory: {} as any, - }; - - setupRouter(factories); + setupRouter(createOAuthFactories({ workspace: mockWorkspace })); const response = await makeRequest(app, 'GET', '/integration/github/oauth', { code, @@ -628,107 +625,37 @@ describe('GitHub Routes - /integration/github/connect', () => { expect(response.status).toBe(302); expect(response.body).toContain(`/project/${projectId}/settings/task-manager`); - expect(response.body).toContain('error=Failed+to+save+Task+Manager+configuration'); + expect(response.body).toContain('error=Failed+to+save+installation'); }); - it('should redirect with error when project is not found after update', async () => { - mockGetState.mockResolvedValue({ - projectId, - userId, - timestamp: Date.now(), - }); - - const mockProject = createMockProjectWithTaskManager(); - mockGetInstallationForRepository.mockResolvedValue({}); - mockProject.updateProject.mockResolvedValue(undefined); - - const factories: ContextFactories = { - projectsFactory: { - findById: jest.fn() - .mockResolvedValueOnce(mockProject) // First call - project exists - .mockResolvedValueOnce(null), // Second call after update - project not found - } as any, - workspacesFactory: {} as any, - usersFactory: {} as any, - plansFactory: {} as any, - businessOperationsFactory: {} as any, - releasesFactory: {} as any, - }; - - setupRouter(factories); - - const response = await makeRequest(app, 'GET', '/integration/github/oauth', { - code, - state, - // eslint-disable-next-line @typescript-eslint/camelcase, camelcase - installation_id: installationId, - }); - - expect(response.status).toBe(302); - expect(response.body).toContain(`/project/${projectId}/settings/task-manager`); - expect(response.body).toContain('error=Project+not+found'); - }); - - it('should redirect with error when project does not have taskManager after installation', async () => { - mockGetState.mockResolvedValue({ - projectId, - userId, - timestamp: Date.now(), - }); - - const mockProject = createMockProject({ - projectId, - workspaceId, - }); - mockProject.taskManager = null; - mockProject.updateProject = jest.fn().mockResolvedValue(undefined); - mockGetInstallationForRepository.mockResolvedValue({}); - - const factories: ContextFactories = { - projectsFactory: { - findById: jest.fn().mockResolvedValue(mockProject), - } as any, - workspacesFactory: {} as any, - usersFactory: {} as any, - plansFactory: {} as any, - businessOperationsFactory: {} as any, - releasesFactory: {} as any, - }; + it('should redirect with error when exchangeOAuthCodeForToken fails', async () => { + mockGetState.mockResolvedValue(createDefaultStateData()); + mockExchangeOAuthCodeForToken.mockRejectedValue(new Error('Invalid code')); - setupRouter(factories); + setupRouter(createOAuthFactories()); const response = await makeRequest(app, 'GET', '/integration/github/oauth', { code, state, - // eslint-disable-next-line @typescript-eslint/camelcase, camelcase - installation_id: installationId, }); expect(response.status).toBe(302); expect(response.body).toContain(`/project/${projectId}/settings/task-manager`); - expect(response.body).toContain('error=GitHub+App+installation+failed'); + expect(response.body).toContain('error=Failed+to+exchange+OAuth+code+for+token'); + expect(mockExchangeOAuthCodeForToken).toHaveBeenCalledWith(code); }); - it('should redirect with error when exchangeOAuthCodeForToken fails', async () => { - mockGetState.mockResolvedValue({ - projectId, - userId, - timestamp: Date.now(), + it('should redirect with error when user is not found during token save', async () => { + mockGetState.mockResolvedValue(createDefaultStateData()); + mockExchangeOAuthCodeForToken.mockResolvedValue({ + user: { id: 'github-user-123', login: 'testuser' }, + accessToken: 'token-123', + refreshToken: 'refresh-123', + refreshTokenExpiresAt: new Date(), }); - const mockProject = createMockProjectWithTaskManager(); - mockExchangeOAuthCodeForToken.mockRejectedValue(new Error('Invalid code')); - - const factories: ContextFactories = { - projectsFactory: { - findById: jest.fn().mockResolvedValue(mockProject), - } as any, - workspacesFactory: {} as any, - usersFactory: {} as any, - plansFactory: {} as any, - businessOperationsFactory: {} as any, - releasesFactory: {} as any, - }; + const factories = createOAuthFactories(); + (factories.usersFactory as any).findById = jest.fn().mockResolvedValue(null); setupRouter(factories); @@ -739,42 +666,22 @@ describe('GitHub Routes - /integration/github/connect', () => { expect(response.status).toBe(302); expect(response.body).toContain(`/project/${projectId}/settings/task-manager`); - expect(response.body).toContain('error=Failed+to+exchange+OAuth+code+for+token'); - expect(mockExchangeOAuthCodeForToken).toHaveBeenCalledWith(code); + expect(response.body).toContain('error=User+not+found'); }); - it('should redirect with error when saving delegatedUser fails', async () => { - mockGetState.mockResolvedValue({ - projectId, - userId, - timestamp: Date.now(), - }); - - const mockProject = createMockProjectWithTaskManager(); - mockProject.updateProject.mockRejectedValueOnce(new Error('Database error')); + it('should redirect with error when saving authorization fails', async () => { + mockGetState.mockResolvedValue(createDefaultStateData()); mockExchangeOAuthCodeForToken.mockResolvedValue({ - user: { - id: 'github-user-123', - login: 'testuser', - }, + user: { id: 'github-user-123', login: 'testuser' }, accessToken: 'token-123', - expiresAt: new Date(), refreshToken: 'refresh-123', refreshTokenExpiresAt: new Date(), }); - const factories: ContextFactories = { - projectsFactory: { - findById: jest.fn().mockResolvedValue(mockProject), - } as any, - workspacesFactory: {} as any, - usersFactory: {} as any, - plansFactory: {} as any, - businessOperationsFactory: {} as any, - releasesFactory: {} as any, - }; + const mockUser = createMockUser(userId); + mockUser.upsertGitHubAuthorization.mockRejectedValue(new Error('DB error')); - setupRouter(factories); + setupRouter(createOAuthFactories({ user: mockUser })); const response = await makeRequest(app, 'GET', '/integration/github/oauth', { code, @@ -786,148 +693,57 @@ describe('GitHub Routes - /integration/github/connect', () => { expect(response.body).toContain('error=Failed+to+save+OAuth+token'); }); - /** - * Scenario: GitHub App is already installed, user authorizes via OAuth to get access token - * This happens when: - * 1. GitHub App was installed earlier (taskManager config already exists with installationId) - * 2. User clicks "Connect" again or needs to re-authorize - * 3. GitHub redirects back with OAuth code (but no installation_id, since installation already exists) - * Expected: OAuth code is exchanged for token, delegatedUser is saved to existing taskManager config - */ - it('should save OAuth token when GitHub App is already installed (no installation_id in callback)', async () => { - mockGetState.mockResolvedValue({ - projectId, - userId, - timestamp: Date.now(), - }); - - /** - * Project already has taskManager config with installationId from previous installation - */ - const mockProject = createMockProjectWithTaskManager(); - mockProject.updateProject.mockResolvedValue(undefined); - - /** - * Mock successful OAuth token exchange - */ + it('should successfully complete OAuth flow without installation_id', async () => { + mockGetState.mockResolvedValue(createDefaultStateData()); mockExchangeOAuthCodeForToken.mockResolvedValue({ - user: { - id: 'github-user-123', - login: 'testuser', - }, + user: { id: 'github-user-123', login: 'testuser' }, accessToken: 'token-123', expiresAt: new Date('2025-12-31'), refreshToken: 'refresh-123', refreshTokenExpiresAt: new Date('2026-12-31'), }); - const factories: ContextFactories = { - projectsFactory: { - findById: jest.fn().mockResolvedValue(mockProject), - } as any, - workspacesFactory: {} as any, - usersFactory: {} as any, - plansFactory: {} as any, - businessOperationsFactory: {} as any, - releasesFactory: {} as any, - }; + const mockUser = createMockUser(userId); - setupRouter(factories); + setupRouter(createOAuthFactories({ user: mockUser })); - /** - * OAuth callback without installation_id (installation already exists) - */ const response = await makeRequest(app, 'GET', '/integration/github/oauth', { code, state, }); - /** - * Should redirect to settings page with success - */ expect(response.status).toBe(302); expect(response.body).toContain(`/project/${projectId}/settings/task-manager`); expect(response.body).toContain('success=true'); - - /** - * Should exchange OAuth code for token - */ expect(mockExchangeOAuthCodeForToken).toHaveBeenCalledWith(code); - - /** - * Should save delegatedUser to existing taskManager config - */ - expect(mockProject.updateProject).toHaveBeenCalledWith( + expect(mockUser.upsertGitHubAuthorization).toHaveBeenCalledWith( expect.objectContaining({ - taskManager: expect.objectContaining({ - config: expect.objectContaining({ - delegatedUser: expect.objectContaining({ - hawkUserId: userId, - githubUserId: 'github-user-123', - githubLogin: 'testuser', - accessToken: 'token-123', - }), - }), - }), + githubUserId: 'github-user-123', + githubLogin: 'testuser', + refreshToken: 'refresh-123', + status: 'active', }) ); }); it('should successfully complete OAuth flow with installation_id', async () => { - mockGetState.mockResolvedValue({ - projectId, - userId, - timestamp: Date.now(), + mockGetState.mockResolvedValue(createDefaultStateData()); + mockGetInstallationForRepository.mockResolvedValue({ + account: { id: 1, login: 'test-org' }, + target_type: 'Organization', }); - - const mockProject = createMockProject({ - projectId, - workspaceId, - }); - mockProject.taskManager = null; - mockProject.updateProject = jest.fn().mockResolvedValue(undefined); - - mockGetInstallationForRepository.mockResolvedValue({}); mockExchangeOAuthCodeForToken.mockResolvedValue({ - user: { - id: 'github-user-123', - login: 'testuser', - }, + user: { id: 'github-user-123', login: 'testuser' }, accessToken: 'token-123', expiresAt: new Date('2025-12-31'), refreshToken: 'refresh-123', refreshTokenExpiresAt: new Date('2026-12-31'), }); - const factories: ContextFactories = { - projectsFactory: { - findById: jest.fn() - .mockResolvedValueOnce(mockProject) // First call - before installation - .mockResolvedValueOnce({ // Second call - after installation update - ...mockProject, - taskManager: { - type: 'github', - autoTaskEnabled: false, - taskThresholdTotalCount: 50, - assignAgent: false, - connectedAt: expect.any(Date), - updatedAt: expect.any(Date), - config: { - installationId: installationId, - repoId: '', - repoFullName: '', - }, - }, - }), - } as any, - workspacesFactory: {} as any, - usersFactory: {} as any, - plansFactory: {} as any, - businessOperationsFactory: {} as any, - releasesFactory: {} as any, - }; + const mockWorkspace = createMockWorkspace({ workspaceId }); + const mockUser = createMockUser(userId); - setupRouter(factories); + setupRouter(createOAuthFactories({ workspace: mockWorkspace, user: mockUser })); const response = await makeRequest(app, 'GET', '/integration/github/oauth', { code, @@ -939,65 +755,58 @@ describe('GitHub Routes - /integration/github/connect', () => { expect(response.status).toBe(302); expect(response.body).toContain(`/project/${projectId}/settings/task-manager`); expect(response.body).toContain('success=true'); - expect(mockGetInstallationForRepository).toHaveBeenCalledWith(installationId); - expect(mockExchangeOAuthCodeForToken).toHaveBeenCalledWith(code); - expect(mockProject.updateProject).toHaveBeenCalledTimes(2); // Once for installation, once for delegatedUser - }); - it('should preserve existing taskManager config when updating with installation_id', async () => { - mockGetState.mockResolvedValue({ - projectId, - userId, - timestamp: Date.now(), - }); + expect(mockGetInstallationForRepository).toHaveBeenCalledWith(installationId); + expect(mockWorkspace.addGitHubInstallation).toHaveBeenCalledWith( + expect.objectContaining({ + installationId: parseInt(installationId, 10), + account: expect.objectContaining({ + id: 1, + login: 'test-org', + type: 'Organization', + }), + delegatedUser: null, + }) + ); - const existingConfig = { - installationId: 'old-installation-id', - repoId: 'existing-repo-id', - repoFullName: 'existing/owner-repo', - delegatedUser: { - hawkUserId: userId, - githubUserId: 'old-github-user', - githubLogin: 'olduser', - accessToken: 'old-token', - }, - }; + expect(mockExchangeOAuthCodeForToken).toHaveBeenCalledWith(code); + expect(mockUser.upsertGitHubAuthorization).toHaveBeenCalled(); - const mockProject = createMockProjectWithTaskManager({ - type: 'github', - autoTaskEnabled: true, - taskThresholdTotalCount: 100, - assignAgent: true, - connectedAt: new Date('2024-01-01'), - updatedAt: new Date('2024-01-01'), - config: existingConfig, - }); - mockProject.updateProject = jest.fn().mockResolvedValue(undefined); + expect(mockDatabases.hawk.collection().updateOne).toHaveBeenCalledWith( + expect.objectContaining({ + 'integrations.github.installations.installationId': parseInt(installationId, 10), + }), + expect.objectContaining({ + $set: expect.objectContaining({ + 'integrations.github.installations.$.delegatedUser': expect.objectContaining({ + hawkUserId: userId, + githubUserId: 'github-user-123', + githubLogin: 'testuser', + status: 'active', + }), + }), + }) + ); + }); - mockGetInstallationForRepository.mockResolvedValue({}); + it('should skip saving installation when it already exists in workspace', async () => { + mockGetState.mockResolvedValue(createDefaultStateData()); mockExchangeOAuthCodeForToken.mockResolvedValue({ - user: { - id: 'github-user-123', - login: 'testuser', - }, + user: { id: 'github-user-123', login: 'testuser' }, accessToken: 'token-123', - expiresAt: new Date('2025-12-31'), refreshToken: 'refresh-123', refreshTokenExpiresAt: new Date('2026-12-31'), }); - const factories: ContextFactories = { - projectsFactory: { - findById: jest.fn().mockResolvedValue(mockProject), - } as any, - workspacesFactory: {} as any, - usersFactory: {} as any, - plansFactory: {} as any, - businessOperationsFactory: {} as any, - releasesFactory: {} as any, - }; + const mockWorkspace = createMockWorkspace({ workspaceId }); + mockWorkspace.findGitHubInstallation.mockReturnValue({ + installationId: parseInt(installationId, 10), + account: { id: 1, login: 'test-org', type: 'Organization' }, + }); - setupRouter(factories); + const mockUser = createMockUser(userId); + + setupRouter(createOAuthFactories({ workspace: mockWorkspace, user: mockUser })); const response = await makeRequest(app, 'GET', '/integration/github/oauth', { code, @@ -1008,15 +817,8 @@ describe('GitHub Routes - /integration/github/connect', () => { expect(response.status).toBe(302); expect(response.body).toContain('success=true'); - - /** - * Check that first update (installation) preserves existing config - */ - const firstUpdateCall = mockProject.updateProject.mock.calls[0]; - expect(firstUpdateCall[0].taskManager.config.installationId).toBe(installationId); - expect(firstUpdateCall[0].taskManager.config.repoId).toBe('existing-repo-id'); - expect(firstUpdateCall[0].taskManager.config.repoFullName).toBe('existing/owner-repo'); - expect(firstUpdateCall[0].taskManager.config.delegatedUser).toEqual(existingConfig.delegatedUser); + expect(mockWorkspace.addGitHubInstallation).not.toHaveBeenCalled(); + expect(mockGetInstallationForRepository).not.toHaveBeenCalled(); }); }); }); diff --git a/test/integrations/github.test.ts b/test/integrations/github.test.ts deleted file mode 100644 index 187631f0..00000000 --- a/test/integrations/github.test.ts +++ /dev/null @@ -1,402 +0,0 @@ -import '../../src/env-test'; -import { GitHubService } from '../../src/integrations/github/service'; -import jwt from 'jsonwebtoken'; - -/** - * Mock @octokit/rest as virtual mock since module might not be installed in test environment - * Using virtual: true allows Jest to create a mock without requiring the module to exist - */ -jest.mock('@octokit/rest', () => ({ - Octokit: jest.fn(), -}), { virtual: true }); - -/** - * Mock jsonwebtoken - */ -jest.mock('jsonwebtoken'); - -describe('GitHubService', () => { - let githubService: GitHubService; - const testAppId = '123456'; - const testAppSlug = 'hawk-tracker'; - const testPrivateKey = '-----BEGIN RSA PRIVATE KEY-----\nTEST_KEY\n-----END RSA PRIVATE KEY-----'; - const testClientId = 'Iv1.client-id'; - const testClientSecret = 'client-secret'; - const testInstallationId = '789012'; - const testApiUrl = 'https://api.example.com'; - - let mockOctokit: { - rest: { - apps: { - createInstallationAccessToken: jest.Mock; - getInstallation: jest.Mock; - }; - issues: { - create: jest.Mock; - addAssignees: jest.Mock; - }; - }; - graphql: jest.Mock; - }; - - const createMockOctokit = (): typeof mockOctokit => { - const createTokenMock = jest.fn(); - const getInstallationMock = jest.fn(); - const createIssueMock = jest.fn(); - const addAssigneesMock = jest.fn(); - const graphqlMock = jest.fn(); - - return { - rest: { - apps: { - createInstallationAccessToken: createTokenMock, - getInstallation: getInstallationMock, - }, - issues: { - create: createIssueMock, - addAssignees: addAssigneesMock, - }, - }, - graphql: graphqlMock, - }; - }; - - beforeEach(() => { - /** - * Clear all mocks - */ - jest.clearAllMocks(); - - /** - * Setup environment variables - */ - process.env.GITHUB_APP_ID = testAppId; - process.env.GITHUB_APP_SLUG = testAppSlug; - process.env.GITHUB_PRIVATE_KEY = testPrivateKey; - process.env.GITHUB_APP_CLIENT_ID = testClientId; - process.env.GITHUB_APP_CLIENT_SECRET = testClientSecret; - process.env.API_URL = testApiUrl; - - /** - * Mock Octokit instance - */ - mockOctokit = createMockOctokit(); - - /** - * Get mocked Octokit constructor and set implementation - */ - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { Octokit } = require('@octokit/rest'); - - Octokit.mockImplementation(() => mockOctokit); - - /** - * Create service instance - */ - githubService = new GitHubService(); - }); - - afterEach(() => { - /** - * Clean up environment - */ - Reflect.deleteProperty(process.env, 'GITHUB_APP_ID'); - Reflect.deleteProperty(process.env, 'GITHUB_APP_SLUG'); - Reflect.deleteProperty(process.env, 'GITHUB_PRIVATE_KEY'); - Reflect.deleteProperty(process.env, 'GITHUB_APP_CLIENT_ID'); - Reflect.deleteProperty(process.env, 'GITHUB_APP_CLIENT_SECRET'); - Reflect.deleteProperty(process.env, 'API_URL'); - }); - - describe('getInstallationUrl', () => { - it('should generate installation URL with state and redirect_url parameters url encoded', () => { - const state = 'test-state-123'; - - const url = githubService.getInstallationUrl(state); - - expect(url).toBe( - `https://github.com/apps/${testAppSlug}/installations/new?state=${encodeURIComponent(state)}&redirect_url=${encodeURIComponent(`${testApiUrl}/integration/github/oauth`)}` - ); - }); - - it('should throw error if API_URL is not set', () => { - delete process.env.API_URL; - - const service = new GitHubService(); - - expect(() => { - service.getInstallationUrl('test-state'); - }).toThrow('API_URL environment variable must be set to generate installation URL with redirect_url'); - }); - }); - - describe('getInstallationForRepository', () => { - const mockJwtToken = 'mock-jwt-token'; - - it('should get installation information for User account', async () => { - (jwt.sign as jest.Mock).mockReturnValue(mockJwtToken); - - /* eslint-disable @typescript-eslint/camelcase, camelcase, @typescript-eslint/no-explicit-any */ - mockOctokit.rest.apps.getInstallation.mockResolvedValue({ - data: { - id: 12345, - account: { - login: 'octocat', - type: 'User', - id: 1, - node_id: 'MDQ6VXNlcjE=', - avatar_url: 'https://github.com/images/error/octocat_happy.gif', - }, - target_type: 'User', - permissions: { - issues: 'write', - metadata: 'read', - }, - }, - } as any); - /* eslint-enable @typescript-eslint/camelcase, camelcase, @typescript-eslint/no-explicit-any */ - - const result = await githubService.getInstallationForRepository(testInstallationId); - - expect(result).toEqual({ - id: 12345, - account: { - login: 'octocat', - type: 'User', - }, - // eslint-disable-next-line @typescript-eslint/camelcase, camelcase - target_type: 'User', - permissions: { - issues: 'write', - metadata: 'read', - }, - }); - - expect(mockOctokit.rest.apps.getInstallation).toHaveBeenCalledWith({ - // eslint-disable-next-line @typescript-eslint/camelcase, camelcase - installation_id: parseInt(testInstallationId, 10), - }); - }); - - it('should get installation information for Organization account', async () => { - (jwt.sign as jest.Mock).mockReturnValue(mockJwtToken); - - /* eslint-disable @typescript-eslint/camelcase, camelcase, @typescript-eslint/no-explicit-any */ - mockOctokit.rest.apps.getInstallation.mockResolvedValue({ - data: { - id: 12345, - account: { - slug: 'my-org', - type: 'Organization', - id: 1, - node_id: 'MDEyOk9yZ2FuaXphdGlvbjE=', - avatar_url: 'https://github.com/images/error/octocat_happy.gif', - }, - target_type: 'Organization', - permissions: { - issues: 'write', - metadata: 'read', - }, - }, - } as any); - /* eslint-enable @typescript-eslint/camelcase, camelcase, @typescript-eslint/no-explicit-any */ - - const result = await githubService.getInstallationForRepository(testInstallationId); - - expect(result).toEqual({ - id: 12345, - account: { - login: 'my-org', - type: 'Organization', - }, - // eslint-disable-next-line @typescript-eslint/camelcase, camelcase - target_type: 'Organization', - permissions: { - issues: 'write', - metadata: 'read', - }, - }); - }); - - it('should throw error if request fails', async () => { - (jwt.sign as jest.Mock).mockReturnValue(mockJwtToken); - - mockOctokit.rest.apps.getInstallation.mockRejectedValue(new Error('Network error')); - - await expect( - githubService.getInstallationForRepository(testInstallationId) - ).rejects.toThrow('Failed to get installation'); - }); - }); - - describe('createIssue', () => { - const mockJwtToken = 'mock-jwt-token'; - const mockInstallationToken = 'mock-installation-token'; - - beforeEach(() => { - (jwt.sign as jest.Mock).mockReturnValue(mockJwtToken); - - /* eslint-disable @typescript-eslint/camelcase, camelcase, @typescript-eslint/no-explicit-any */ - mockOctokit.rest.apps.createInstallationAccessToken.mockResolvedValue({ - data: { - token: mockInstallationToken, - expires_at: '2025-01-01T00:00:00Z', - }, - } as any); - /* eslint-enable @typescript-eslint/camelcase, camelcase, @typescript-eslint/no-explicit-any */ - }); - - it('should create issue successfully', async () => { - const issueData = { - title: 'Test Issue', - body: 'Test body', - labels: [ 'bug' ], - }; - - /* eslint-disable @typescript-eslint/camelcase, camelcase, @typescript-eslint/no-explicit-any */ - mockOctokit.rest.issues.create.mockResolvedValue({ - data: { - number: 123, - html_url: 'https://github.com/owner/repo/issues/123', - title: 'Test Issue', - state: 'open', - }, - } as any); - /* eslint-enable @typescript-eslint/camelcase, camelcase, @typescript-eslint/no-explicit-any */ - - const result = await githubService.createIssue('owner/repo', testInstallationId, issueData); - - expect(result).toEqual({ - number: 123, - // eslint-disable-next-line @typescript-eslint/camelcase, camelcase - html_url: 'https://github.com/owner/repo/issues/123', - title: 'Test Issue', - state: 'open', - }); - - expect(mockOctokit.rest.issues.create).toHaveBeenCalledWith({ - owner: 'owner', - repo: 'repo', - title: 'Test Issue', - body: 'Test body', - labels: [ 'bug' ], - }); - }); - - it('should create issue without labels', async () => { - const issueData = { - title: 'Test Issue', - body: 'Test body', - }; - - /* eslint-disable @typescript-eslint/camelcase, camelcase, @typescript-eslint/no-explicit-any */ - mockOctokit.rest.issues.create.mockResolvedValue({ - data: { - number: 124, - html_url: 'https://github.com/owner/repo/issues/124', - title: 'Test Issue', - state: 'open', - }, - } as any); - /* eslint-enable @typescript-eslint/camelcase, camelcase, @typescript-eslint/no-explicit-any */ - - const result = await githubService.createIssue('owner/repo', testInstallationId, issueData); - - expect(result.number).toBe(124); - expect(mockOctokit.rest.issues.create).toHaveBeenCalledWith({ - owner: 'owner', - repo: 'repo', - title: 'Test Issue', - body: 'Test body', - labels: undefined, - }); - }); - - it('should throw error for invalid repository name format', async () => { - const issueData = { - title: 'Test Issue', - body: 'Test body', - }; - - await expect( - githubService.createIssue('invalid-repo-name', testInstallationId, issueData) - ).rejects.toThrow('Invalid repository name format: invalid-repo-name. Expected format: owner/repo'); - }); - - it('should throw error if issue creation fails', async () => { - const issueData = { - title: 'Test Issue', - body: 'Test body', - }; - - mockOctokit.rest.issues.create.mockRejectedValue(new Error('Repository not found')); - - await expect( - githubService.createIssue('owner/repo', testInstallationId, issueData) - ).rejects.toThrow('Failed to create issue'); - }); - }); - - describe('assignCopilot', () => { - const mockDelegatedUserToken = 'mock-delegated-user-token'; - - it('should assign Copilot to issue successfully', async () => { - const issueNumber = 123; - - mockOctokit.graphql - .mockResolvedValueOnce({ - repository: { - id: 'repo-123', - issue: { id: 'issue-456' }, - suggestedActors: { - nodes: [ - { - login: 'copilot-swe-agent', - __typename: 'Bot', - id: 'bot-789', - }, - ], - }, - }, - }) - .mockResolvedValueOnce({ - addAssigneesToAssignable: { - assignable: { - id: 'issue-456', - number: issueNumber, - assignees: { nodes: [] }, - }, - }, - }); - - await githubService.assignCopilot('owner/repo', issueNumber, mockDelegatedUserToken); - - expect(mockOctokit.graphql).toHaveBeenCalledTimes(2); - }); - - it('should throw error if assignment fails', async () => { - const issueNumber = 123; - - mockOctokit.graphql - .mockResolvedValueOnce({ - repository: { - id: 'repo-123', - issue: { id: 'issue-456' }, - suggestedActors: { - nodes: [ - { - login: 'copilot-swe-agent', - id: 'bot-789', - }, - ], - }, - }, - }) - .mockRejectedValue(new Error('Issue not found')); - - await expect( - githubService.assignCopilot('owner/repo', issueNumber, mockDelegatedUserToken) - ).rejects.toThrow('Failed to assign Copilot'); - }); - }); -}); diff --git a/test/resolvers/project.test.ts b/test/resolvers/project.test.ts index 8f50acbc..c493b654 100644 --- a/test/resolvers/project.test.ts +++ b/test/resolvers/project.test.ts @@ -4,10 +4,6 @@ import { ProjectDBScheme, ProjectTaskManagerConfig } from '@hawk.so/types'; import { ResolverContextWithUser } from '../../src/types/graphql'; import { ApolloError, UserInputError } from 'apollo-server-express'; -jest.mock('../../src/integrations/github/service', () => require('../__mocks__/github-service')); -// eslint-disable-next-line @typescript-eslint/no-var-requires -import { deleteInstallationMock, GitHubService } from '../__mocks__/github-service'; - // @ts-expect-error - CommonJS module, TypeScript can't infer types properly import projectResolverModule from '../../src/resolvers/project'; @@ -146,8 +142,6 @@ describe('Project Resolver - Task Manager Mutations', () => { )) as { taskManager: ProjectTaskManagerConfig | null }; expect(context.factories.projectsFactory.findById).toHaveBeenCalledWith(mockProject._id.toString()); - expect(GitHubService).toHaveBeenCalledTimes(1); - expect(deleteInstallationMock).toHaveBeenCalledWith('123456'); expect(mockProject.updateProject).toHaveBeenCalledWith({ taskManager: null, }); @@ -224,8 +218,6 @@ describe('Project Resolver - Task Manager Mutations', () => { context )) as { taskManager: ProjectTaskManagerConfig | null }; - expect(GitHubService).not.toHaveBeenCalled(); - expect(deleteInstallationMock).not.toHaveBeenCalled(); expect(mockProject.updateProject).toHaveBeenCalledWith({ taskManager: null, }); diff --git a/yarn.lock b/yarn.lock index bfe5bde9..4aad0b76 100644 --- a/yarn.lock +++ b/yarn.lock @@ -494,6 +494,17 @@ dependencies: tslib "^2.4.0" +"@hawk.so/github-sdk@^1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@hawk.so/github-sdk/-/github-sdk-1.0.4.tgz#6edef6290651ff1fe678ae09fbfc40f9ce2dd1c5" + integrity sha512-nt0oM7ZqK8PfcgD74B/DwaeKNjv3pkd6at9k+yQKkTDdeh2rraafbHdxVzJqAAAIjaDE2ecH6QWolWlOJZ3UdQ== + dependencies: + "@hawk.so/utils" "^1.0.0" + "@octokit/oauth-methods" "^4.0.0" + "@octokit/rest" "^22.0.1" + "@octokit/types" "^16.0.0" + jsonwebtoken "^9.0.3" + "@hawk.so/nodejs@^3.3.1": version "3.3.1" resolved "https://registry.yarnpkg.com/@hawk.so/nodejs/-/nodejs-3.3.1.tgz#23e304607a64cd3a91e488d481cc968fccab6dba" @@ -510,13 +521,18 @@ dependencies: bson "^7.0.0" -"@hawk.so/types@^0.5.9": - version "0.5.9" - resolved "https://registry.yarnpkg.com/@hawk.so/types/-/types-0.5.9.tgz#817e8b26283d0367371125f055f2e37a274797bc" - integrity sha512-86aE0Bdzvy8C+Dqd1iZpnDho44zLGX/t92SGuAv2Q52gjSJ7SHQdpGDWtM91FXncfT5uzAizl9jYMuE6Qrtm0Q== +"@hawk.so/types@^0.6.0-rc.2": + version "0.6.0-rc.2" + resolved "https://registry.yarnpkg.com/@hawk.so/types/-/types-0.6.0-rc.2.tgz#115fc21dc078261990ec17dd811c9597d5215cdd" + integrity sha512-tSU2WHQTa0+C9ix3+yoJKQjRQz/Jpa+79czrmZxaU9B8bQPK7kKa3a32+AgGSUDpxlD1qx+a3MFIhzmskULbDA== dependencies: bson "^7.0.0" +"@hawk.so/utils@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@hawk.so/utils/-/utils-1.0.0.tgz#08164d0bdff966a414aabcfb90311174d495d127" + integrity sha512-iqAHgoKGc9rU9LnuFa8wMAaiaKaZ0U8Hxeja0qxBuQPGSyBJuI/1AfdFG9z0yA9KQQlIGXv+ij1MO5a++bUpAA== + "@isaacs/cliui@^8.0.2": version "8.0.2" resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550"