diff --git a/api/.env.example b/api/.env.example index 22aac1e..d31f924 100644 --- a/api/.env.example +++ b/api/.env.example @@ -11,6 +11,7 @@ DB_PORT=5432 # Application APP_PORT=1401 APP_URL=http://localhost:3000 +API_URL=http://localhost:1401 # JWT Configuration JWT_SIGN=your_jwt_secret_here @@ -25,4 +26,24 @@ SMTP_USERNAME=your_email@example.com SMTP_PASSWORD=your_smtp_password_here SMTP_ENCRYPTION=ssl SMTP_FROM_NAME=TaskView -SMTP_FROM_EMAIL=your_email@example.com \ No newline at end of file +SMTP_FROM_EMAIL=your_email@example.com + +# Encryption (32-byte hex key for AES-256-GCM) +# Generate a key: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +ENCRYPTION_KEY= + +# GitHub Integration OAuth (separate from login OAuth) +GITHUB_INTEGRATION_CLIENT_ID= +GITHUB_INTEGRATION_CLIENT_SECRET= +GITHUB_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/github/callback +# For GitHub Enterprise, override these: +# GITHUB_BASE_URL=https://github.yourcompany.com +# GITHUB_API_URL=https://github.yourcompany.com/api/v3 + +# GitLab Integration OAuth +GITLAB_INTEGRATION_CLIENT_ID= +GITLAB_INTEGRATION_CLIENT_SECRET= +GITLAB_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/gitlab/callback +# For self-hosted GitLab, override these: +# GITLAB_BASE_URL=https://gitlab.yourcompany.com +# GITLAB_API_URL=https://gitlab.yourcompany.com/api/v4 \ No newline at end of file diff --git a/api/src/App.ts b/api/src/App.ts index a4efa45..53e1e1c 100644 --- a/api/src/App.ts +++ b/api/src/App.ts @@ -63,7 +63,14 @@ export default class App { })); this.app.use(helmet()); - this.app.use(express.json()); + this.app.use(express.json({ + verify: (req: any, _res, buf) => { + // Store raw body for webhook signature verification github and gitlab integrations + if (req.url?.includes('/webhook/')) { + req.rawBody = buf; + } + }, + })); this.app.use(express.urlencoded({ extended: true })); } diff --git a/api/src/core/AppUser.ts b/api/src/core/AppUser.ts index 2464059..cb54f76 100644 --- a/api/src/core/AppUser.ts +++ b/api/src/core/AppUser.ts @@ -7,6 +7,7 @@ import { KanbanManager } from '../tv-modules/kanban/KanbanManager'; import { GoalListManager } from '../tv-modules/lists/GoalListManager'; import { StartManager } from '../tv-modules/start/StartManager'; import { TagsManager } from '../tv-modules/tags/TagsManager'; +import { IntegrationsManager } from '../tv-modules/integrations/IntegrationsManager'; import { TasksManager } from '../tv-modules/tasks/TasksManager'; import type { UserDbRecord, UserJwtPayload } from '../types/auth.types'; import { GoalPermissionsFetcher } from './GoalPermissionsFetcher'; @@ -26,6 +27,7 @@ export class AppUser { public readonly startManager: StartManager; public readonly kanbanManager: KanbanManager; public readonly graphManager: GraphManager; + public readonly integrationsManager: IntegrationsManager; constructor(userData?: UserJwtPayload) { this.userData = userData; @@ -40,6 +42,7 @@ export class AppUser { this.startManager = new StartManager(this); this.kanbanManager = new KanbanManager(this); this.graphManager = new GraphManager(this); + this.integrationsManager = new IntegrationsManager(this); } getTokenId(): number | undefined { diff --git a/api/src/migrations/taskview/migrate.json b/api/src/migrations/taskview/migrate.json index 2810911..51b2c71 100644 --- a/api/src/migrations/taskview/migrate.json +++ b/api/src/migrations/taskview/migrate.json @@ -305,5 +305,28 @@ "description": [ "Validate tag and task belong to the same project on insert into tasks_to_tags" ] + }, + "24": { + "version": "1.21.0", + "name": "Release 1.21.0", + "releaseDate": "20260302", + "scripts": [ + "/1.21.0/0.1.21.0.sql" + ], + "description": [ + "Added integrations table for GitHub/GitLab integration", + "Added integration_task_map table for issue-task mapping" + ] + }, + "25": { + "version": "1.22.0", + "name": "Release 1.22.0", + "releaseDate": "20260304", + "scripts": [ + "/1.22.0/0.1.22.0.sql" + ], + "description": [ + "Added last_synced_at column to integrations for incremental sync" + ] } } \ No newline at end of file diff --git a/api/src/migrations/taskview/sql/1.21.0/0.1.21.0.sql b/api/src/migrations/taskview/sql/1.21.0/0.1.21.0.sql new file mode 100644 index 0000000..557e33f --- /dev/null +++ b/api/src/migrations/taskview/sql/1.21.0/0.1.21.0.sql @@ -0,0 +1,24 @@ +CREATE TABLE IF NOT EXISTS tasks.integrations ( + id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + provider VARCHAR(20) NOT NULL CHECK (provider IN ('github', 'gitlab')), + access_token_encrypted TEXT, + refresh_token_encrypted TEXT, + repo_external_id VARCHAR(255), + repo_full_name VARCHAR(255), + project_id INTEGER NOT NULL REFERENCES tasks.goals(id) ON DELETE CASCADE, + webhook_id VARCHAR(255), + webhook_secret_encrypted TEXT, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS tasks.integration_task_map ( + id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + integration_id INTEGER NOT NULL REFERENCES tasks.integrations(id) ON DELETE CASCADE, + task_id INTEGER NOT NULL REFERENCES tasks.tasks(id) ON DELETE CASCADE, + issue_number INTEGER NOT NULL, + issue_state VARCHAR(20) NOT NULL DEFAULT 'open', + synced_at TIMESTAMP DEFAULT NOW(), + UNIQUE(integration_id, issue_number) +); diff --git a/api/src/migrations/taskview/sql/1.22.0/0.1.22.0.sql b/api/src/migrations/taskview/sql/1.22.0/0.1.22.0.sql new file mode 100644 index 0000000..9c14d1f --- /dev/null +++ b/api/src/migrations/taskview/sql/1.22.0/0.1.22.0.sql @@ -0,0 +1,25 @@ +ALTER TABLE tasks.integrations ADD COLUMN IF NOT EXISTS last_synced_at TIMESTAMP; + +INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales) +VALUES ( + 'integrations_can_manage', + 'User can manage integrations (connect, disconnect, sync)', + 2, + '{ + "en": "Manage integrations. User can connect, disconnect, configure and sync integrations", + "ru": "Управление интеграциями. Пользователь может подключать, отключать, настраивать и синхронизировать интеграции" + }'::jsonb +) +ON CONFLICT DO NOTHING; + +INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales) +VALUES ( + 'integrations_can_view', + 'User can view integrations list', + 2, + '{ + "en": "View integrations. User can view the list of connected integrations", + "ru": "Просмотр интеграций. Пользователь может просматривать список подключённых интеграций" + }'::jsonb +) +ON CONFLICT DO NOTHING; diff --git a/api/src/routes/index.ts b/api/src/routes/index.ts index 61bb004..ff09b13 100644 --- a/api/src/routes/index.ts +++ b/api/src/routes/index.ts @@ -3,6 +3,7 @@ import CollaborationRoutes from '../tv-modules/collaboration/CollaborationRoutes import CollaborationRolesRoutes from '../tv-modules/collaboration-roles/CollaborationRolesRoutes'; import GoalsRoutes from '../tv-modules/goals/GoalsRoutes'; import GraphRoutes from '../tv-modules/graph/GraphRoutes'; +import IntegrationsRoutes from '../tv-modules/integrations/IntegrationsRoutes'; import KanbanRoutes from '../tv-modules/kanban/KanbanRoutes'; import GoalListRoutes from '../tv-modules/lists/GoalListRoutes'; import StartRoutes from '../tv-modules/start/StartRoutes'; @@ -23,6 +24,7 @@ const routes: Record = { '/module/about': StartRoutes, '/module/kanban': KanbanRoutes, '/module/graph': GraphRoutes, + '/module/integrations': IntegrationsRoutes, }; export default routes; diff --git a/api/src/tv-modules/integrations/IntegrationsController.ts b/api/src/tv-modules/integrations/IntegrationsController.ts new file mode 100644 index 0000000..8fe9a3e --- /dev/null +++ b/api/src/tv-modules/integrations/IntegrationsController.ts @@ -0,0 +1,290 @@ +import { type } from 'arktype'; +import type { Request, Response } from 'express'; +import { logError } from '../../utils/api'; +import { decrypt } from '../../utils/crypto'; +import AuthController from '../auth/AuthController'; +import { IntegrationsRepository } from './IntegrationsRepository'; +import { verifyGitHubWebhookSignature } from './providers/github.provider'; +import { verifyGitLabWebhookToken } from './providers/gitlab.provider'; +import { IntegrationsArkTypeAdd, IntegrationsArkTypeDelete, IntegrationsArkTypeFetch, IntegrationsArkTypeSelectRepo, IntegrationsArkTypeToggle } from './types'; + +export default class IntegrationsController { + createIntegration = async (req: Request, res: Response) => { + const out = IntegrationsArkTypeAdd(req.body); + if (out instanceof type.errors) { + return res.status(400).send(out.summary); + } + const result = await req.appUser.integrationsManager.create(out).catch(logError); + return res.tvJson(result ?? null); + }; + + deleteIntegration = async (req: Request, res: Response) => { + const out = IntegrationsArkTypeDelete(req.body); + if (out instanceof type.errors) { + return res.status(400).send(out.summary); + } + const result = await req.appUser.integrationsManager.delete(out).catch(logError); + return res.tvJson(!!result); + }; + + toggleIntegration = async (req: Request, res: Response) => { + const out = IntegrationsArkTypeToggle(req.body); + if (out instanceof type.errors) { + return res.status(400).send(out.summary); + } + const result = await req.appUser.integrationsManager.toggle(out).catch(logError); + return res.tvJson(result ?? null); + }; + + fetchIntegrations = async (req: Request, res: Response) => { + const out = IntegrationsArkTypeFetch(req.query); + if (out instanceof type.errors) { + return res.status(400).send(out.summary); + } + const result = await req.appUser.integrationsManager.fetch(out).catch(logError); + return res.tvJson(result ?? []); + }; + + initiateOAuth = async (req: Request, res: Response) => { + try { + const token = req.query.token as string; + if (!token) { + return res.status(401).send('token is required'); + } + const userPayload = await AuthController.validateTokens(token); + if (!userPayload?.userData?.id) { + return res.status(401).send('Invalid token'); + } + + const provider = req.params.provider; + const projectId = Number(req.query.projectId); + if (!projectId || isNaN(projectId)) { + return res.status(400).send('projectId is required'); + } + const url = req.appUser.integrationsManager.getOAuthUrl(provider, projectId, userPayload.userData.id); + return res.redirect(url); + } catch (err) { + logError(err); + return res.status(500).send('Failed to initiate OAuth'); + } + }; + + handleOAuthCallback = async (req: Request, res: Response) => { + try { + const provider = req.params.provider; + const code = req.query.code as string; + const state = req.query.state as string; + + if (!code || !state) { + return res.redirect(`${process.env.APP_URL}?oauth=error`); + } + + const { projectId, userLogin } = await req.appUser.integrationsManager.handleOAuthCallback(provider, code, state); + return res.redirect(`${process.env.APP_URL}/${userLogin}/${projectId}/integrations?oauth=success`); + } catch (err) { + logError(err); + return res.redirect(`${process.env.APP_URL}?oauth=error`); + } + }; + + fetchRepos = async (req: Request, res: Response) => { + try { + const integrationId = Number(req.query.integrationId); + if (!integrationId || isNaN(integrationId)) { + return res.status(400).send('integrationId is required'); + } + const repos = await req.appUser.integrationsManager.fetchRepos(integrationId); + return res.tvJson(repos); + } catch (err) { + logError(err); + return res.tvJson([]); + } + }; + + selectRepo = async (req: Request, res: Response) => { + const out = IntegrationsArkTypeSelectRepo(req.body); + if (out instanceof type.errors) { + return res.status(400).send(out.summary); + } + const result = await req.appUser.integrationsManager.selectRepo(out).catch(logError); + return res.tvJson(result ?? null); + }; + + syncIntegration = async (req: Request, res: Response) => { + try { + const integrationId = Number(req.body.integrationId); + if (!integrationId || isNaN(integrationId)) { + return res.status(400).send('integrationId is required'); + } + const synced = await req.appUser.integrationsManager.syncIssues(integrationId); + return res.tvJson({ synced }); + } catch (err) { + logError(err); + return res.tvJson({ synced: 0 }); + } + }; + + handleGitHubWebhook = async (req: Request, res: Response) => { + try { + const signature = req.headers['x-hub-signature-256'] as string; + const event = req.headers['x-github-event'] as string; + + if (!signature) { + return res.status(401).send('Missing signature'); + } + + if (event !== 'issues') { + return res.status(200).send('OK'); + } + + const repoFullName = req.body?.repository?.full_name; + if (!repoFullName) { + return res.status(400).send('Missing repository'); + } + + const repo = new IntegrationsRepository(); + const integrations = await repo.fetchAllActiveByRepoFullName(repoFullName); + if (integrations.length === 0) { + return res.status(404).send('Integration not found'); + } + + // Verify signature with the first integration that has a webhook secret + const withSecret = integrations.find((i) => i.webhookSecretEncrypted); + if (!withSecret) { + return res.status(401).send('No webhook secret'); + } + const secret = decrypt(withSecret.webhookSecretEncrypted!); + const rawBody = (req as any).rawBody as Buffer; + if (!rawBody || !verifyGitHubWebhookSignature(rawBody, signature, secret)) { + return res.status(401).send('Invalid signature'); + } + + const action = req.body.action as string; + const issue = req.body.issue; + if (!issue) { + return res.status(200).send('OK'); + } + + const issueNumber = issue.number as number; + const issueTitle = issue.title as string; + const issueBody = (issue.body as string) || null; + + for (const integration of integrations) { + const mapping = await repo.fetchMappingByIssueNumber(integration.id, issueNumber); + + if (action === 'opened') { + if (!mapping) { + await repo.createTaskAndMapping( + integration.projectId, + issueTitle, + integration.id, + issueNumber, + 'open', + issueBody, + ); + } + } else if (action === 'edited') { + if (mapping) { + await repo.updateTaskTitleAndNote(mapping.taskId, issueTitle, issueBody); + } + } else if (action === 'closed') { + if (mapping) { + await repo.updateTaskComplete(mapping.taskId, true); + await repo.updateMappingState(mapping.id, 'closed'); + } + } else if (action === 'reopened') { + if (mapping) { + await repo.updateTaskComplete(mapping.taskId, false); + await repo.updateMappingState(mapping.id, 'open'); + } + } + } + + return res.status(200).send('OK'); + } catch (err) { + logError(err); + return res.status(500).send('Webhook processing failed'); + } + }; + + handleGitLabWebhook = async (req: Request, res: Response) => { + try { + const token = req.headers['x-gitlab-token'] as string; + + if (!token) { + return res.status(401).send('Missing token'); + } + + if (req.body?.object_kind !== 'issue') { + return res.status(200).send('OK'); + } + + const projectId = String(req.body?.project?.id); + if (!projectId) { + return res.status(400).send('Missing project'); + } + + const repo = new IntegrationsRepository(); + const integrations = await repo.fetchAllActiveByRepoExternalId(projectId); + if (integrations.length === 0) { + return res.status(404).send('Integration not found'); + } + + // Verify token with the first integration that has a webhook secret + const withSecret = integrations.find((i) => i.webhookSecretEncrypted); + if (!withSecret) { + return res.status(401).send('No webhook secret'); + } + const secret = decrypt(withSecret.webhookSecretEncrypted!); + if (!verifyGitLabWebhookToken(token, secret)) { + return res.status(401).send('Invalid token'); + } + + const attrs = req.body.object_attributes; + if (!attrs) { + return res.status(200).send('OK'); + } + + const issueIid = attrs.iid as number; + const issueTitle = attrs.title as string; + const issueDescription = (attrs.description as string) || null; + const action = attrs.action as string; + + for (const integration of integrations) { + const mapping = await repo.fetchMappingByIssueNumber(integration.id, issueIid); + + if (action === 'open') { + if (!mapping) { + await repo.createTaskAndMapping( + integration.projectId, + issueTitle, + integration.id, + issueIid, + 'open', + issueDescription, + ); + } + } else if (action === 'update') { + if (mapping) { + await repo.updateTaskTitleAndNote(mapping.taskId, issueTitle, issueDescription); + } + } else if (action === 'close') { + if (mapping) { + await repo.updateTaskComplete(mapping.taskId, true); + await repo.updateMappingState(mapping.id, 'closed'); + } + } else if (action === 'reopen') { + if (mapping) { + await repo.updateTaskComplete(mapping.taskId, false); + await repo.updateMappingState(mapping.id, 'open'); + } + } + } + + return res.status(200).send('OK'); + } catch (err) { + logError(err); + return res.status(500).send('Webhook processing failed'); + } + }; +} diff --git a/api/src/tv-modules/integrations/IntegrationsManager.ts b/api/src/tv-modules/integrations/IntegrationsManager.ts new file mode 100644 index 0000000..e4d53cd --- /dev/null +++ b/api/src/tv-modules/integrations/IntegrationsManager.ts @@ -0,0 +1,356 @@ +import jwt from 'jsonwebtoken'; +import type { AppUser } from '../../core/AppUser'; +import { encrypt, decrypt } from '../../utils/crypto'; + +import { logError } from '../../utils/api'; +import { $logger } from '../../modules/logget'; + +import { IntegrationsRepository } from './IntegrationsRepository'; +import { TasksRepository } from '../tasks/TasksRepository'; +import type { IntegrationsSchemaTypeForSelect } from 'taskview-db-schemas'; +import type { IntegrationsArgAdd, IntegrationsArgDelete, IntegrationsArgFetch, IntegrationsArgSelectRepo, IntegrationsArgToggle, OAuthStatePayload, RepoItemForClient } from './types'; +import { randomBytes } from 'crypto'; +import { getGitHubOAuthUrl, exchangeGitHubCode, fetchGitHubRepos, fetchGitHubIssues, createGitHubWebhook, updateGitHubIssueState } from './providers/github.provider'; +import { getGitLabOAuthUrl, exchangeGitLabCode, fetchGitLabRepos, fetchGitLabIssues, createGitLabWebhook, updateGitLabIssueState, refreshGitLabToken } from './providers/gitlab.provider'; + +export class IntegrationsManager { + public readonly repository: IntegrationsRepository; + private readonly user: AppUser; + + constructor(user: AppUser) { + this.user = user; + this.repository = new IntegrationsRepository(); + } + + async create(data: IntegrationsArgAdd): Promise { + return this.repository.create(data); + } + + async delete(data: IntegrationsArgDelete): Promise { + return this.repository.delete(data); + } + + async toggle(data: IntegrationsArgToggle): Promise { + const result = await this.repository.toggle(data); + if (result && data.isActive) { + this.syncIssues(data.id).catch(logError); + } + return result; + } + + async fetch(data: IntegrationsArgFetch): Promise { + const projectId = Number(data.projectId); + if (isNaN(projectId)) return []; + return this.repository.fetchByProjectId(projectId); + } + + getOAuthUrl(provider: string, projectId: number, userId: number): string { + const state = jwt.sign( + { userId, projectId, provider } as OAuthStatePayload, + process.env.JWT_SIGN as string, + { expiresIn: '10m' } + ); + + if (provider === 'github') { + return getGitHubOAuthUrl(state); + } else if (provider === 'gitlab') { + return getGitLabOAuthUrl(state); + } + throw new Error(`Unknown provider: ${provider}`); + } + + async handleOAuthCallback(provider: string, code: string, state: string): Promise<{ projectId: number; userLogin: string }> { + $logger.debug({ provider }, '[integrations] handleOAuthCallback start'); + const payload = jwt.verify(state, process.env.JWT_SIGN as string) as OAuthStatePayload; + + if (payload.provider !== provider) { + $logger.error({ provider, payloadProvider: payload.provider }, '[integrations] provider mismatch in state'); + throw new Error('Provider mismatch in state'); + } + + const userLogin = await this.repository.fetchUserLogin(payload.userId); + if (!userLogin) { + $logger.error({ userId: payload.userId }, '[integrations] user not found during OAuth callback'); + throw new Error('User not found'); + } + + let accessTokenEncrypted: string; + let refreshTokenEncrypted: string | null = null; + + if (provider === 'github') { + const accessToken = await exchangeGitHubCode(code); + accessTokenEncrypted = encrypt(accessToken); + } else if (provider === 'gitlab') { + const tokens = await exchangeGitLabCode(code); + accessTokenEncrypted = encrypt(tokens.accessToken); + refreshTokenEncrypted = encrypt(tokens.refreshToken); + } else { + throw new Error(`Unknown provider: ${provider}`); + } + + await this.repository.createWithToken( + provider as 'github' | 'gitlab', + payload.projectId, + accessTokenEncrypted, + refreshTokenEncrypted, + ); + + $logger.debug({ provider, projectId: payload.projectId, userLogin }, '[integrations] OAuth callback completed'); + return { projectId: payload.projectId, userLogin }; + } + + async fetchRepos(integrationId: number): Promise { + const integration = await this.repository.fetchById(integrationId); + if (!integration || !integration.accessTokenEncrypted) return []; + + const accessToken = await this.getAccessToken(integration); + if (!accessToken) return []; + + if (integration.provider === 'github') { + const repos = await fetchGitHubRepos(accessToken); + return repos.map((r) => ({ + id: r.id, + fullName: r.full_name, + name: r.name, + isPrivate: r.private, + description: r.description, + url: r.html_url, + })); + } else if (integration.provider === 'gitlab') { + const repos = await fetchGitLabRepos(accessToken); + return repos.map((r) => ({ + id: r.id, + fullName: r.path_with_namespace, + name: r.name, + isPrivate: r.visibility === 'private', + description: r.description, + url: r.web_url, + })); + } + + return []; + } + + async selectRepo(data: IntegrationsArgSelectRepo): Promise { + const integration = await this.repository.fetchById(data.integrationId); + if (!integration) return false; + + const exists = await this.repository.existsRepoInProject(integration.projectId, data.repoFullName, data.integrationId); + if (exists) { + $logger.debug({ integrationId: data.integrationId, repoFullName: data.repoFullName, projectId: integration.projectId }, '[integrations] repo already connected to project, skipping'); + return false; + } + + const result = await this.repository.updateRepo(data); + if (result) { + $logger.debug({ integrationId: data.integrationId, repoFullName: data.repoFullName }, '[integrations] repo selected, starting sync and webhook registration'); + this.syncIssues(data.integrationId).catch(logError); + this.registerWebhook(data.integrationId).catch(logError); + } + return result; + } + + private async registerWebhook(integrationId: number): Promise { + const integration = await this.repository.fetchById(integrationId); + if (!integration || !integration.accessTokenEncrypted || !integration.repoFullName) return; + + const apiUrl = process.env.API_URL; + if (!apiUrl) return; + + const accessToken = await this.getAccessToken(integration); + if (!accessToken) { + $logger.error({ integrationId, provider: integration.provider }, '[integrations] registerWebhook failed: no access token'); + return; + } + + const webhookSecret = randomBytes(32).toString('hex'); + const webhookUrl = `${apiUrl}/module/integrations/webhook/${integration.provider}`; + + let webhookId: string; + + if (integration.provider === 'github') { + const result = await createGitHubWebhook(accessToken, integration.repoFullName, webhookUrl, webhookSecret); + webhookId = String(result.id); + } else if (integration.provider === 'gitlab' && integration.repoExternalId) { + const result = await createGitLabWebhook(accessToken, Number(integration.repoExternalId), webhookUrl, webhookSecret); + webhookId = String(result.id); + } else { + return; + } + + $logger.debug({ integrationId, provider: integration.provider, webhookId }, '[integrations] webhook registered'); + await this.repository.updateWebhook(integrationId, webhookId, encrypt(webhookSecret)); + } + + async syncIssues(integrationId: number): Promise { + const integration = await this.repository.fetchById(integrationId); + if (!integration || !integration.accessTokenEncrypted || !integration.repoFullName) return 0; + + const accessToken = await this.getAccessToken(integration); + if (!accessToken) { + $logger.error({ integrationId, provider: integration.provider }, '[integrations] syncIssues failed: no access token'); + return 0; + } + + const since = integration.lastSyncedAt?.toISOString(); + $logger.debug({ integrationId, provider: integration.provider, repo: integration.repoFullName, since: since ?? 'full sync' }, '[integrations] syncIssues start'); + const existingMappings = await this.repository.fetchMappingsByIntegrationId(integrationId); + const mappingsByIssueNumber = new Map(existingMappings.map((m) => [m.issueNumber, m])); + + type NewIssueItem = { goalId: number; description: string; integrationId: number; issueNumber: number; issueState: string; note: string | null; complete: boolean; kanbanOrder: number }; + const newItems: NewIssueItem[] = []; + + if (integration.provider === 'github') { + const issues = await fetchGitHubIssues(accessToken, integration.repoFullName, since); + for (const issue of issues) { + const existing = mappingsByIssueNumber.get(issue.number); + if (existing) { + const isClosed = issue.state === 'closed'; + const targetState = isClosed ? 'closed' : 'open'; + await this.repository.updateTaskComplete(existing.taskId, isClosed).catch(logError); + if (existing.issueState !== targetState) { + await this.repository.updateMappingState(existing.id, targetState).catch(logError); + } + await this.repository.updateTaskTitleAndNote(existing.taskId, issue.title, issue.body ?? null).catch(logError); + continue; + } + newItems.push({ + goalId: integration.projectId, + description: issue.title, + integrationId, + issueNumber: issue.number, + issueState: issue.state === 'open' ? 'open' : 'closed', + note: issue.body ?? null, + complete: issue.state === 'closed', + kanbanOrder: 0, + }); + } + } else if (integration.provider === 'gitlab' && integration.repoExternalId) { + const issues = await fetchGitLabIssues(accessToken, Number(integration.repoExternalId), since); + for (const issue of issues) { + const existing = mappingsByIssueNumber.get(issue.iid); + if (existing) { + const isClosed = issue.state === 'closed'; + const targetState = isClosed ? 'closed' : 'open'; + await this.repository.updateTaskComplete(existing.taskId, isClosed).catch(logError); + if (existing.issueState !== targetState) { + await this.repository.updateMappingState(existing.id, targetState).catch(logError); + } + await this.repository.updateTaskTitleAndNote(existing.taskId, issue.title, issue.description ?? null).catch(logError); + continue; + } + const isClosed = issue.state === 'closed'; + newItems.push({ + goalId: integration.projectId, + description: issue.title, + integrationId, + issueNumber: issue.iid, + issueState: isClosed ? 'closed' : 'open', + note: issue.description ?? null, + complete: isClosed, + kanbanOrder: 0, + }); + } + } + + // Issues come newest-first from API. + // Reverse so oldest is inserted first (lower ID) and newest last (higher ID). + // This way list view (ORDER BY id DESC) shows newest first. + // Assign kanbanOrder so newest = smallest (appears first in kanban). + // Issues come newest-first from API. + // Reverse so oldest is inserted first (lower ID) and newest last (higher ID). + // List view (ORDER BY id DESC) shows newest first. + // Assign kanbanOrder: each next item goes further into minus from current min. + // Newest (last in array) gets the smallest value → appears first in kanban. + if (newItems.length > 0) { + newItems.reverse(); + const { KANBAN_ORDER_GAP } = TasksRepository; + const min = await this.user.tasksManager.repository.fetchTaskWithMinKanbanOrder(integration.projectId, null); + for (let i = 0; i < newItems.length; i++) { + newItems[i].kanbanOrder = (min ?? 0) - KANBAN_ORDER_GAP * (i + 1); + } + } + + const created = await this.repository.createTasksAndMappingsBatch(newItems); + await this.repository.updateLastSyncedAt(integrationId); + + $logger.debug({ integrationId, created, updatedExisting: existingMappings.length, totalIssuesFetched: newItems.length + existingMappings.length }, '[integrations] syncIssues completed'); + return created; + } + + async onTaskCompleteChanged(taskId: number, complete: boolean): Promise { + const mapping = await this.repository.fetchMappingByTaskId(taskId); + if (!mapping) return true; + + const { integration } = mapping; + $logger.debug({ taskId, complete, provider: integration.provider, isActive: integration.isActive, issueNumber: mapping.issueNumber, issueState: mapping.issueState }, '[integrations] onTaskCompleteChanged'); + if (!integration.isActive || !integration.accessTokenEncrypted || !integration.repoFullName) return true; + + const targetState = complete ? 'closed' : 'open'; + if (mapping.issueState === targetState) { + $logger.debug({ taskId, targetState }, '[integrations] onTaskCompleteChanged: state already matches, skipping'); + return true; + } + + const accessToken = await this.getAccessToken(integration); + if (!accessToken) { + $logger.error({ taskId, integrationId: integration.id, provider: integration.provider }, '[integrations] onTaskCompleteChanged: no access token'); + return false; + } + + if (integration.provider === 'github') { + await updateGitHubIssueState(accessToken, integration.repoFullName, mapping.issueNumber, targetState); + } else if (integration.provider === 'gitlab' && integration.repoExternalId) { + await updateGitLabIssueState( + accessToken, + Number(integration.repoExternalId), + mapping.issueNumber, + complete ? 'close' : 'reopen', + ); + } + + await this.repository.updateMappingState(mapping.id, targetState); + $logger.debug({ taskId, issueNumber: mapping.issueNumber, targetState }, '[integrations] onTaskCompleteChanged: issue state updated'); + return true; + } + + private async getAccessToken(integration: IntegrationsSchemaTypeForSelect): Promise { + if (!integration.accessTokenEncrypted) return null; + + const accessToken = decrypt(integration.accessTokenEncrypted); + + if (integration.provider !== 'gitlab' || !integration.refreshTokenEncrypted) { + return accessToken; + } + + // Try the current token, refresh on 401 + try { + const axios = (await import('axios')).default; + const gitlabApiUrl = process.env.GITLAB_API_URL || 'https://gitlab.com/api/v4'; + await axios.get(`${gitlabApiUrl}/user`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + return accessToken; + } catch (err: any) { + if (err?.response?.status !== 401) return accessToken; + $logger.debug({ integrationId: integration.id }, '[integrations] GitLab token expired (401), refreshing'); + } + + // Token expired, refresh it + try { + const refreshToken = decrypt(integration.refreshTokenEncrypted); + const tokens = await refreshGitLabToken(refreshToken); + await this.repository.updateTokens( + integration.id, + encrypt(tokens.accessToken), + encrypt(tokens.refreshToken), + ); + $logger.debug({ integrationId: integration.id }, '[integrations] GitLab token refreshed successfully'); + return tokens.accessToken; + } catch (err) { + $logger.error({ integrationId: integration.id, err }, '[integrations] GitLab token refresh failed'); + return null; + } + } + +} diff --git a/api/src/tv-modules/integrations/IntegrationsRepository.ts b/api/src/tv-modules/integrations/IntegrationsRepository.ts new file mode 100644 index 0000000..88842f0 --- /dev/null +++ b/api/src/tv-modules/integrations/IntegrationsRepository.ts @@ -0,0 +1,308 @@ +import { and, eq, ne } from 'drizzle-orm'; +import { IntegrationsSchema, IntegrationTaskMapSchema, TasksSchema, UsersSchema, type IntegrationsSchemaTypeForSelect, type IntegrationTaskMapSchemaTypeForSelect } from 'taskview-db-schemas'; +import { Database } from '../../modules/db'; +import { callWithCatch } from '../../utils/helpers'; +import type { IntegrationsArgAdd, IntegrationsArgDelete, IntegrationsArgSelectRepo, IntegrationsArgToggle } from './types'; +import { TasksRepository } from '../tasks/TasksRepository'; + +export class IntegrationsRepository { + private readonly db: Database; + + constructor() { + this.db = Database.getInstance(); + } + + async create(data: IntegrationsArgAdd): Promise { + const result = await callWithCatch(() => + this.db.dbDrizzle.insert(IntegrationsSchema).values({ + provider: data.provider, + repoFullName: data.repoFullName, + projectId: data.projectId, + }).returning() + ); + if (!result) return false; + return result[0]; + } + + async delete(data: IntegrationsArgDelete): Promise { + const result = await callWithCatch(() => + this.db.dbDrizzle.delete(IntegrationsSchema).where(eq(IntegrationsSchema.id, data.id)) + ); + if (!result) return false; + return !!(result?.rowCount && result.rowCount > 0); + } + + async toggle(data: IntegrationsArgToggle): Promise { + const result = await callWithCatch(() => + this.db.dbDrizzle.update(IntegrationsSchema) + .set({ isActive: data.isActive, updatedAt: new Date() }) + .where(eq(IntegrationsSchema.id, data.id)) + .returning() + ); + if (!result) return false; + return result[0]; + } + + async fetchByProjectId(projectId: number): Promise { + const result = await callWithCatch(() => + this.db.dbDrizzle.select().from(IntegrationsSchema) + .where(eq(IntegrationsSchema.projectId, projectId)) + ); + if (!result) return []; + return result; + } + + async fetchById(id: number): Promise { + const result = await callWithCatch(() => + this.db.dbDrizzle.select().from(IntegrationsSchema) + .where(eq(IntegrationsSchema.id, id)) + ); + if (!result || result.length === 0) return undefined; + return result[0]; + } + + async createWithToken( + provider: 'github' | 'gitlab', + projectId: number, + accessTokenEncrypted: string, + refreshTokenEncrypted?: string | null, + ): Promise { + const result = await callWithCatch(() => + this.db.dbDrizzle.insert(IntegrationsSchema).values({ + provider, + projectId, + accessTokenEncrypted, + refreshTokenEncrypted: refreshTokenEncrypted ?? null, + }).returning() + ); + if (!result) return false; + return result[0]; + } + + async existsRepoInProject(projectId: number, repoFullName: string, excludeIntegrationId?: number): Promise { + const conditions = [ + eq(IntegrationsSchema.projectId, projectId), + eq(IntegrationsSchema.repoFullName, repoFullName), + ]; + if (excludeIntegrationId) { + conditions.push(ne(IntegrationsSchema.id, excludeIntegrationId)); + } + const result = await callWithCatch(() => + this.db.dbDrizzle.select({ id: IntegrationsSchema.id }).from(IntegrationsSchema) + .where(and(...conditions)) + ); + return !!result && result.length > 0; + } + + async updateRepo(data: IntegrationsArgSelectRepo): Promise { + const result = await callWithCatch(() => + this.db.dbDrizzle.update(IntegrationsSchema) + .set({ + repoFullName: data.repoFullName, + repoExternalId: data.repoExternalId, + updatedAt: new Date(), + }) + .where(eq(IntegrationsSchema.id, data.integrationId)) + .returning() + ); + if (!result) return false; + return result[0]; + } + + async createTaskAndMapping( + goalId: number, + description: string, + integrationId: number, + issueNumber: number, + issueState: string, + note?: string | null, + complete?: boolean, + ): Promise { + const tasksRepo = new TasksRepository(); + const kanbanOrder = await tasksRepo.getNextKanbanOrder(goalId); + + const result = await callWithCatch(async () => { + const [task] = await this.db.dbDrizzle.insert(TasksSchema).values({ + goalId, + description, + complete: complete ?? false, + note: note || null, + kanbanOrder, + }).returning(); + const [mapping] = await this.db.dbDrizzle.insert(IntegrationTaskMapSchema).values({ + integrationId, + taskId: task.id, + issueNumber, + issueState, + }).returning(); + return mapping; + }); + if (!result) return false; + return result; + } + + async fetchMappingsByIntegrationId(integrationId: number): Promise { + const result = await callWithCatch(() => + this.db.dbDrizzle.select().from(IntegrationTaskMapSchema) + .where(eq(IntegrationTaskMapSchema.integrationId, integrationId)) + ); + if (!result) return []; + return result; + } + + async updateTokens(integrationId: number, accessTokenEncrypted: string, refreshTokenEncrypted: string | null): Promise { + const result = await callWithCatch(() => + this.db.dbDrizzle.update(IntegrationsSchema) + .set({ accessTokenEncrypted, refreshTokenEncrypted, updatedAt: new Date() }) + .where(eq(IntegrationsSchema.id, integrationId)) + ); + return !!result; + } + + async updateWebhook(integrationId: number, webhookId: string, webhookSecretEncrypted: string): Promise { + const result = await callWithCatch(() => + this.db.dbDrizzle.update(IntegrationsSchema) + .set({ webhookId, webhookSecretEncrypted, updatedAt: new Date() }) + .where(eq(IntegrationsSchema.id, integrationId)) + ); + return !!result; + } + + async fetchAllActiveByRepoFullName(repoFullName: string): Promise { + const result = await callWithCatch(() => + this.db.dbDrizzle.select().from(IntegrationsSchema) + .where( + and( + eq(IntegrationsSchema.repoFullName, repoFullName), + eq(IntegrationsSchema.isActive, true), + ) + ) + ); + return result || []; + } + + async fetchAllActiveByRepoExternalId(repoExternalId: string): Promise { + const result = await callWithCatch(() => + this.db.dbDrizzle.select().from(IntegrationsSchema) + .where( + and( + eq(IntegrationsSchema.repoExternalId, repoExternalId), + eq(IntegrationsSchema.isActive, true), + ) + ) + ); + return result || []; + } + + async fetchMappingByIssueNumber(integrationId: number, issueNumber: number): Promise { + const result = await callWithCatch(() => + this.db.dbDrizzle.select().from(IntegrationTaskMapSchema) + .where( + and( + eq(IntegrationTaskMapSchema.integrationId, integrationId), + eq(IntegrationTaskMapSchema.issueNumber, issueNumber), + ) + ) + ); + if (!result || result.length === 0) return undefined; + return result[0]; + } + + async fetchMappingByTaskId(taskId: number): Promise<(IntegrationTaskMapSchemaTypeForSelect & { integration: IntegrationsSchemaTypeForSelect }) | undefined> { + const result = await callWithCatch(() => + this.db.dbDrizzle + .select() + .from(IntegrationTaskMapSchema) + .innerJoin(IntegrationsSchema, eq(IntegrationTaskMapSchema.integrationId, IntegrationsSchema.id)) + .where(eq(IntegrationTaskMapSchema.taskId, taskId)) + ); + if (!result || result.length === 0) return undefined; + return { + ...result[0].integration_task_map, + integration: result[0].integrations, + }; + } + + async updateTaskComplete(taskId: number, complete: boolean): Promise { + const result = await callWithCatch(() => + this.db.dbDrizzle.update(TasksSchema) + .set({ complete }) + .where(eq(TasksSchema.id, taskId)) + ); + return !!result; + } + + async updateTaskTitleAndNote(taskId: number, description: string, note: string | null): Promise { + const result = await callWithCatch(() => + this.db.dbDrizzle.update(TasksSchema) + .set({ description, note }) + .where(eq(TasksSchema.id, taskId)) + ); + return !!result; + } + + async updateMappingState(mappingId: number, issueState: string): Promise { + const result = await callWithCatch(() => + this.db.dbDrizzle.update(IntegrationTaskMapSchema) + .set({ issueState, syncedAt: new Date() }) + .where(eq(IntegrationTaskMapSchema.id, mappingId)) + ); + return !!result; + } + + async createTasksAndMappingsBatch( + items: Array<{ goalId: number; description: string; integrationId: number; issueNumber: number; issueState: string; note: string | null; complete: boolean; kanbanOrder: number }>, + ): Promise { + if (items.length === 0) return 0; + let created = 0; + const BATCH_SIZE = 100; + + for (let i = 0; i < items.length; i += BATCH_SIZE) { + const batch = items.slice(i, i + BATCH_SIZE); + const result = await callWithCatch(async () => { + const tasks = await this.db.dbDrizzle.insert(TasksSchema).values( + batch.map((item) => ({ + goalId: item.goalId, + description: item.description, + complete: item.complete, + note: item.note, + kanbanOrder: item.kanbanOrder, + })), + ).returning({ id: TasksSchema.id }); + + await this.db.dbDrizzle.insert(IntegrationTaskMapSchema).values( + tasks.map((task, idx) => ({ + integrationId: batch[idx].integrationId, + taskId: task.id, + issueNumber: batch[idx].issueNumber, + issueState: batch[idx].issueState, + })), + ); + + return tasks.length; + }); + if (result) created += result; + } + + return created; + } + + async updateLastSyncedAt(integrationId: number): Promise { + const result = await callWithCatch(() => + this.db.dbDrizzle.update(IntegrationsSchema) + .set({ lastSyncedAt: new Date() }) + .where(eq(IntegrationsSchema.id, integrationId)) + ); + return !!result; + } + + async fetchUserLogin(userId: number): Promise { + const result = await callWithCatch(() => + this.db.dbDrizzle.select({ login: UsersSchema.login }).from(UsersSchema) + .where(eq(UsersSchema.id, userId)) + ); + if (!result || result.length === 0) return null; + return result[0].login; + } + +} diff --git a/api/src/tv-modules/integrations/IntegrationsRoutes.ts b/api/src/tv-modules/integrations/IntegrationsRoutes.ts new file mode 100644 index 0000000..3c7ee6f --- /dev/null +++ b/api/src/tv-modules/integrations/IntegrationsRoutes.ts @@ -0,0 +1,35 @@ +import { Router } from 'express'; +import type { Routable } from '../../types/routable.type'; +import { IsLoggedIn } from '../auth/middlewares/is-logged-in'; +import IntegrationsController from './IntegrationsController'; +import { CanManageIntegrations } from './middlewares/CanManageIntegrations'; +import { CanViewIntegrations } from './middlewares/CanViewIntegrations'; + +export default class IntegrationsRoutes implements Routable { + private readonly router: ReturnType; + private readonly controller: IntegrationsController; + + constructor() { + this.router = Router(); + this.controller = new IntegrationsController(); + this.initRoutes(); + } + + getRouter() { + return this.router; + } + + initRoutes() { + this.router.get('', [IsLoggedIn, CanViewIntegrations], this.controller.fetchIntegrations); + this.router.post('', [IsLoggedIn, CanManageIntegrations], this.controller.createIntegration); + this.router.delete('', [IsLoggedIn, CanManageIntegrations], this.controller.deleteIntegration); + this.router.patch('/toggle', [IsLoggedIn, CanManageIntegrations], this.controller.toggleIntegration); + this.router.patch('/select-repo', [IsLoggedIn, CanManageIntegrations], this.controller.selectRepo); + this.router.post('/sync', [IsLoggedIn, CanManageIntegrations], this.controller.syncIntegration); + this.router.get('/repos', [IsLoggedIn, CanViewIntegrations], this.controller.fetchRepos); + this.router.get('/oauth/:provider', this.controller.initiateOAuth); + this.router.get('/oauth/:provider/callback', this.controller.handleOAuthCallback); + this.router.post('/webhook/github', this.controller.handleGitHubWebhook); + this.router.post('/webhook/gitlab', this.controller.handleGitLabWebhook); + } +} diff --git a/api/src/tv-modules/integrations/middlewares/CanManageIntegrations.ts b/api/src/tv-modules/integrations/middlewares/CanManageIntegrations.ts new file mode 100644 index 0000000..86b082c --- /dev/null +++ b/api/src/tv-modules/integrations/middlewares/CanManageIntegrations.ts @@ -0,0 +1,17 @@ +import type { NextFunction, Request, Response } from 'express'; +import { GoalPermissions } from '../../../types/auth.types'; +import { resolveProjectId } from './resolveProjectId'; + +export const CanManageIntegrations = async (req: Request, res: Response, next: NextFunction) => { + const projectId = await resolveProjectId(req); + if (!projectId) { + return res.status(400).end(); + } + + const checker = await req.appUser.permissionsFetcher.getCheckerForGoal(projectId); + if (checker.hasPermissions(GoalPermissions.INTEGRATIONS_CAN_MANAGE)) { + return next(); + } + + return res.status(403).end(); +}; diff --git a/api/src/tv-modules/integrations/middlewares/CanViewIntegrations.ts b/api/src/tv-modules/integrations/middlewares/CanViewIntegrations.ts new file mode 100644 index 0000000..88fc7b8 --- /dev/null +++ b/api/src/tv-modules/integrations/middlewares/CanViewIntegrations.ts @@ -0,0 +1,17 @@ +import type { NextFunction, Request, Response } from 'express'; +import { GoalPermissions } from '../../../types/auth.types'; +import { resolveProjectId } from './resolveProjectId'; + +export const CanViewIntegrations = async (req: Request, res: Response, next: NextFunction) => { + const projectId = await resolveProjectId(req); + if (!projectId) { + return res.status(400).end(); + } + + const checker = await req.appUser.permissionsFetcher.getCheckerForGoal(projectId); + if (checker.hasPermissions(GoalPermissions.INTEGRATIONS_CAN_VIEW)) { + return next(); + } + + return res.status(403).end(); +}; diff --git a/api/src/tv-modules/integrations/middlewares/resolveProjectId.ts b/api/src/tv-modules/integrations/middlewares/resolveProjectId.ts new file mode 100644 index 0000000..5c939fb --- /dev/null +++ b/api/src/tv-modules/integrations/middlewares/resolveProjectId.ts @@ -0,0 +1,23 @@ +import type { Request } from 'express'; +import { IntegrationsRepository } from '../IntegrationsRepository'; + +/** + * Resolves projectId from request. + * Checks body (projectId, integrationId, id) and query (projectId, integrationId). + */ +export async function resolveProjectId(req: Request): Promise { + // Direct projectId in body or query + const directId = req.body?.projectId ?? req.query?.projectId; + if (directId) { + const id = Number(directId); + return isNaN(id) ? null : id; + } + + // integrationId from body or query, or id from body + const integrationId = Number(req.body?.integrationId || req.query?.integrationId || req.body?.id); + if (!integrationId || isNaN(integrationId)) return null; + + const repo = new IntegrationsRepository(); + const integration = await repo.fetchById(integrationId); + return integration?.projectId ?? null; +} diff --git a/api/src/tv-modules/integrations/providers/github.provider.ts b/api/src/tv-modules/integrations/providers/github.provider.ts new file mode 100644 index 0000000..db0a47f --- /dev/null +++ b/api/src/tv-modules/integrations/providers/github.provider.ts @@ -0,0 +1,169 @@ +import axios from 'axios'; +import { createHmac, timingSafeEqual } from 'crypto'; + +const GITHUB_BASE_URL = process.env.GITHUB_BASE_URL || 'https://github.com'; +const GITHUB_API_URL = process.env.GITHUB_API_URL || 'https://api.github.com'; +const GITHUB_OAUTH_URL = `${GITHUB_BASE_URL}/login/oauth/authorize`; +const GITHUB_TOKEN_URL = `${GITHUB_BASE_URL}/login/oauth/access_token`; + +export type GitHubRepo = { + id: number; + full_name: string; + name: string; + private: boolean; + description: string | null; + html_url: string; +}; + +export type GitHubIssue = { + number: number; + title: string; + body: string | null; + state: 'open' | 'closed'; + html_url: string; + pull_request?: unknown; +}; + +export function getGitHubOAuthUrl(state: string): string { + const clientId = process.env.GITHUB_INTEGRATION_CLIENT_ID; + const redirectUri = process.env.GITHUB_INTEGRATION_CALLBACK_URL; + if (!clientId || !redirectUri) { + throw new Error('GitHub integration OAuth is not configured'); + } + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + scope: 'repo', + state, + }); + return `${GITHUB_OAUTH_URL}?${params.toString()}`; +} + +export async function exchangeGitHubCode(code: string): Promise { + const res = await axios.post<{ access_token: string; token_type: string }>( + GITHUB_TOKEN_URL, + { + client_id: process.env.GITHUB_INTEGRATION_CLIENT_ID, + client_secret: process.env.GITHUB_INTEGRATION_CLIENT_SECRET, + code, + }, + { + headers: { Accept: 'application/json' }, + } + ); + if (!res.data.access_token) { + throw new Error('Failed to exchange GitHub code for token'); + } + return res.data.access_token; +} + +export async function fetchGitHubRepos(accessToken: string): Promise { + const repos: GitHubRepo[] = []; + let page = 1; + const perPage = 100; + + while (true) { + const res = await axios.get(`${GITHUB_API_URL}/user/repos`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github+json', + }, + params: { + per_page: perPage, + page, + sort: 'updated', + direction: 'desc', + }, + }); + repos.push(...res.data); + if (res.data.length < perPage) break; + page++; + } + + return repos; +} + +export async function fetchGitHubIssues(accessToken: string, repoFullName: string, since?: string): Promise { + const issues: GitHubIssue[] = []; + let page = 1; + const perPage = 100; + + while (true) { + const res = await axios.get(`${GITHUB_API_URL}/repos/${repoFullName}/issues`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github+json', + }, + params: { + state: 'all', + per_page: perPage, + page, + sort: since ? 'updated' : 'created', + direction: 'desc', + ...(since ? { since } : {}), + }, + }); + // GitHub API returns pull requests as issues too — filter them out + const realIssues = res.data.filter((i) => !i.pull_request); + issues.push(...realIssues); + if (res.data.length < perPage) break; + page++; + } + + return issues; +} + +export async function createGitHubWebhook( + accessToken: string, + repoFullName: string, + webhookUrl: string, + secret: string, +): Promise<{ id: number }> { + const res = await axios.post<{ id: number }>( + `${GITHUB_API_URL}/repos/${repoFullName}/hooks`, + { + name: 'web', + active: true, + events: ['issues'], + config: { + url: webhookUrl, + content_type: 'json', + secret, + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github+json', + }, + }, + ); + return { id: res.data.id }; +} + +export function verifyGitHubWebhookSignature(rawBody: Buffer, signature: string, secret: string): boolean { + const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex'); + try { + return timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); + } catch { + return false; + } +} + +export async function updateGitHubIssueState( + accessToken: string, + repoFullName: string, + issueNumber: number, + state: 'open' | 'closed', +): Promise { + await axios.patch( + `${GITHUB_API_URL}/repos/${repoFullName}/issues/${issueNumber}`, + { state }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github+json', + }, + }, + ); +} diff --git a/api/src/tv-modules/integrations/providers/gitlab.provider.ts b/api/src/tv-modules/integrations/providers/gitlab.provider.ts new file mode 100644 index 0000000..f374849 --- /dev/null +++ b/api/src/tv-modules/integrations/providers/gitlab.provider.ts @@ -0,0 +1,175 @@ +import axios from 'axios'; + +const GITLAB_BASE_URL = process.env.GITLAB_BASE_URL || 'https://gitlab.com'; +const GITLAB_API_URL = process.env.GITLAB_API_URL || `${GITLAB_BASE_URL}/api/v4`; +const GITLAB_OAUTH_URL = `${GITLAB_BASE_URL}/oauth/authorize`; +const GITLAB_TOKEN_URL = `${GITLAB_BASE_URL}/oauth/token`; + +export type GitLabRepo = { + id: number; + path_with_namespace: string; + name: string; + visibility: 'private' | 'internal' | 'public'; + description: string | null; + web_url: string; +}; + +export type GitLabIssue = { + iid: number; + title: string; + description: string | null; + state: 'opened' | 'closed'; + web_url: string; +}; + +export function getGitLabOAuthUrl(state: string): string { + const clientId = process.env.GITLAB_INTEGRATION_CLIENT_ID; + const redirectUri = process.env.GITLAB_INTEGRATION_CALLBACK_URL; + if (!clientId || !redirectUri) { + throw new Error('GitLab integration OAuth is not configured'); + } + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: 'api', + state, + }); + return `${GITLAB_OAUTH_URL}?${params.toString()}`; +} + +export async function exchangeGitLabCode(code: string): Promise<{ accessToken: string; refreshToken: string }> { + const res = await axios.post<{ access_token: string; refresh_token: string; token_type: string }>( + GITLAB_TOKEN_URL, + { + client_id: process.env.GITLAB_INTEGRATION_CLIENT_ID, + client_secret: process.env.GITLAB_INTEGRATION_CLIENT_SECRET, + code, + grant_type: 'authorization_code', + redirect_uri: process.env.GITLAB_INTEGRATION_CALLBACK_URL, + }, + ); + if (!res.data.access_token) { + throw new Error('Failed to exchange GitLab code for token'); + } + return { + accessToken: res.data.access_token, + refreshToken: res.data.refresh_token, + }; +} + +export async function refreshGitLabToken(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> { + const res = await axios.post<{ access_token: string; refresh_token: string; token_type: string }>( + GITLAB_TOKEN_URL, + { + client_id: process.env.GITLAB_INTEGRATION_CLIENT_ID, + client_secret: process.env.GITLAB_INTEGRATION_CLIENT_SECRET, + refresh_token: refreshToken, + grant_type: 'refresh_token', + redirect_uri: process.env.GITLAB_INTEGRATION_CALLBACK_URL, + }, + ); + if (!res.data.access_token) { + throw new Error('Failed to refresh GitLab token'); + } + return { + accessToken: res.data.access_token, + refreshToken: res.data.refresh_token, + }; +} + +export async function fetchGitLabRepos(accessToken: string): Promise { + const repos: GitLabRepo[] = []; + let page = 1; + const perPage = 100; + + while (true) { + const res = await axios.get(`${GITLAB_API_URL}/projects`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: { + membership: true, + per_page: perPage, + page, + order_by: 'updated_at', + sort: 'desc', + }, + }); + repos.push(...res.data); + if (res.data.length < perPage) break; + page++; + } + + return repos; +} + +export async function fetchGitLabIssues(accessToken: string, projectId: number, updatedAfter?: string): Promise { + const issues: GitLabIssue[] = []; + let page = 1; + const perPage = 100; + + while (true) { + const res = await axios.get(`${GITLAB_API_URL}/projects/${projectId}/issues`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: { + state: 'all', + per_page: perPage, + page, + order_by: updatedAfter ? 'updated_at' : 'created_at', + sort: 'desc', + ...(updatedAfter ? { updated_after: updatedAfter } : {}), + }, + }); + issues.push(...res.data); + if (res.data.length < perPage) break; + page++; + } + + return issues; +} + +export async function createGitLabWebhook( + accessToken: string, + projectId: number, + webhookUrl: string, + secret: string, +): Promise<{ id: number }> { + const res = await axios.post<{ id: number }>( + `${GITLAB_API_URL}/projects/${projectId}/hooks`, + { + url: webhookUrl, + issues_events: true, + token: secret, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + return { id: res.data.id }; +} + +export function verifyGitLabWebhookToken(headerToken: string, secret: string): boolean { + return headerToken === secret; +} + +export async function updateGitLabIssueState( + accessToken: string, + projectId: number, + issueIid: number, + stateEvent: 'close' | 'reopen', +): Promise { + await axios.put( + `${GITLAB_API_URL}/projects/${projectId}/issues/${issueIid}`, + { state_event: stateEvent }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); +} diff --git a/api/src/tv-modules/integrations/types.ts b/api/src/tv-modules/integrations/types.ts new file mode 100644 index 0000000..d4ee27b --- /dev/null +++ b/api/src/tv-modules/integrations/types.ts @@ -0,0 +1,46 @@ +import { type } from 'arktype'; + +export const IntegrationsArkTypeAdd = type({ + provider: "'github' | 'gitlab'", + repoFullName: 'string', + projectId: 'number', +}); +export type IntegrationsArgAdd = typeof IntegrationsArkTypeAdd.infer; + +export const IntegrationsArkTypeDelete = type({ + id: 'number', +}); +export type IntegrationsArgDelete = typeof IntegrationsArkTypeDelete.infer; + +export const IntegrationsArkTypeToggle = type({ + id: 'number', + isActive: 'boolean', +}); +export type IntegrationsArgToggle = typeof IntegrationsArkTypeToggle.infer; + +export const IntegrationsArkTypeFetch = type({ + projectId: 'string', +}); +export type IntegrationsArgFetch = typeof IntegrationsArkTypeFetch.infer; + +export const IntegrationsArkTypeSelectRepo = type({ + integrationId: 'number', + repoFullName: 'string', + repoExternalId: 'string', +}); +export type IntegrationsArgSelectRepo = typeof IntegrationsArkTypeSelectRepo.infer; + +export type OAuthStatePayload = { + userId: number; + projectId: number; + provider: 'github' | 'gitlab'; +}; + +export type RepoItemForClient = { + id: number; + fullName: string; + name: string; + isPrivate: boolean; + description: string | null; + url: string; +}; diff --git a/api/src/tv-modules/tasks/TasksController.ts b/api/src/tv-modules/tasks/TasksController.ts index fee4c40..e1211b2 100644 --- a/api/src/tv-modules/tasks/TasksController.ts +++ b/api/src/tv-modules/tasks/TasksController.ts @@ -343,9 +343,10 @@ export class TasksController { return res.status(400).send(args.summary); } - const task = await req.appUser.tasksManager.updateTask(args); + const result = await req.appUser.tasksManager.updateTask(args); + if (!result) return res.tvJson(null); - return res.tvJson(task); + return res.tvJson({ ...result.task, syncFailed: result.syncFailed }); }; fetchTasksNew = async (req: Request, res: Response) => { diff --git a/api/src/tv-modules/tasks/TasksManager.ts b/api/src/tv-modules/tasks/TasksManager.ts index 41fafa3..59ced37 100644 --- a/api/src/tv-modules/tasks/TasksManager.ts +++ b/api/src/tv-modules/tasks/TasksManager.ts @@ -156,6 +156,9 @@ export class TasksManager { if (!task) return false; + // Sync task completion state to linked GitHub/GitLab issue + this.user.integrationsManager.onTaskCompleteChanged(arg.taskId, arg.complete).catch(() => {}); + return new TaskItemForClient(task); } @@ -259,13 +262,22 @@ export class TasksManager { return await this.repository.updateTransactionType(data); } - async updateTask(data: TaskArgUpdate): Promise { + async updateTask(data: TaskArgUpdate): Promise<{ task: TaskForClientNew; syncFailed?: boolean } | null> { const task = await this.repository.updateTask(data); if (!task) { return null; } + + let syncFailed = false; + if (data.complete !== undefined) { + const synced = await this.user.integrationsManager.onTaskCompleteChanged(data.id, data.complete).catch(() => false); + if (!synced) syncFailed = true; + } + const tasks = await this.extendTasksWithTagsAndAssignees([task]); - return tasks[0] ?? null; + const result = tasks[0] ?? null; + if (!result) return null; + return { task: result, syncFailed: syncFailed || undefined }; } async fetchTasksNew(data: TaskArgFetchTasksNew) { @@ -372,9 +384,7 @@ export class TasksManager { let newData = { ...data }; if (data.kanbanOrder === null || data.kanbanOrder === undefined) { - const minKanbanOrder = await this.repository.fetchTaskWithMinKanbanOrder(data.goalId, data.statusId ?? null); - const GAP = 16384; - newData.kanbanOrder = (minKanbanOrder ?? 0) - GAP; + newData.kanbanOrder = await this.repository.getNextKanbanOrder(data.goalId, data.statusId ?? null); } const task = await this.repository.addTaskNew(newData); diff --git a/api/src/tv-modules/tasks/TasksRepository.ts b/api/src/tv-modules/tasks/TasksRepository.ts index 9664edf..7c8c00a 100644 --- a/api/src/tv-modules/tasks/TasksRepository.ts +++ b/api/src/tv-modules/tasks/TasksRepository.ts @@ -670,4 +670,11 @@ export class TasksRepository { }).from(TasksSchema).where(and(...conditions))); return result?.[0]?.minKanbanOrder ?? null; } + + static readonly KANBAN_ORDER_GAP = 16384; + + async getNextKanbanOrder(goalId: number, statusId: number | null = null): Promise { + const min = await this.fetchTaskWithMinKanbanOrder(goalId, statusId); + return (min ?? 0) - TasksRepository.KANBAN_ORDER_GAP; + } } diff --git a/api/src/types/auth.types.ts b/api/src/types/auth.types.ts index d4885fe..fc2737c 100644 --- a/api/src/types/auth.types.ts +++ b/api/src/types/auth.types.ts @@ -131,6 +131,9 @@ export const GoalPermissions = { TASKS_CAN_RECOVERY_HISTORY: 'task_can_recovery_history', TASKS_CAN_ASSIGN_USERS: 'task_can_assign_users', TASKS_CAN_WATCH_ASSIGNED_USERS: 'task_can_watch_assigned_users', + + INTEGRATIONS_CAN_MANAGE: 'integrations_can_manage', + INTEGRATIONS_CAN_VIEW: 'integrations_can_view', } as const; export type PermissionsEntityType = diff --git a/api/src/utils/crypto.ts b/api/src/utils/crypto.ts new file mode 100644 index 0000000..ee3168d --- /dev/null +++ b/api/src/utils/crypto.ts @@ -0,0 +1,33 @@ +import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 12; +const AUTH_TAG_LENGTH = 16; + +function getKey(): Buffer { + const hex = process.env.ENCRYPTION_KEY; + if (!hex || hex.length !== 64) { + throw new Error('ENCRYPTION_KEY must be a 64-character hex string (32 bytes)'); + } + return Buffer.from(hex, 'hex'); +} + +export function encrypt(text: string): string { + const key = getKey(); + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH }); + const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`; +} + +export function decrypt(encrypted: string): string { + const key = getKey(); + const [ivHex, authTagHex, dataHex] = encrypted.split(':'); + const iv = Buffer.from(ivHex, 'hex'); + const authTag = Buffer.from(authTagHex, 'hex'); + const data = Buffer.from(dataHex, 'hex'); + const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH }); + decipher.setAuthTag(authTag); + return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8'); +} diff --git a/taskview-packages/taskview-api/src/api/integrations.ts b/taskview-packages/taskview-api/src/api/integrations.ts new file mode 100644 index 0000000..164b7ef --- /dev/null +++ b/taskview-packages/taskview-api/src/api/integrations.ts @@ -0,0 +1,66 @@ +import TvApiBase from "./base"; +import type { AppResponse } from "./base.types"; +import type { + IntegrationArgAdd, + IntegrationArgSelectRepo, + IntegrationArgToggle, + IntegrationResponseAdd, + IntegrationResponseDelete, + IntegrationResponseFetch, + IntegrationResponseRepos, + IntegrationResponseSelectRepo, + IntegrationResponseSync, + IntegrationResponseToggle, +} from "./integrations.types"; + +export default class TvIntegrationsApi extends TvApiBase { + protected moduleUrl = '/module/integrations'; + + public async fetchIntegrations(projectId: number) { + return this.request( + this.$axios.get>(`${this.moduleUrl}`, { + params: { projectId }, + }) + ); + } + + public async createIntegration(data: IntegrationArgAdd) { + return this.request( + this.$axios.post>(`${this.moduleUrl}`, data) + ); + } + + public async deleteIntegration(id: number) { + return this.request( + this.$axios.delete>(`${this.moduleUrl}`, { + data: { id }, + }) + ); + } + + public async toggleIntegration(data: IntegrationArgToggle) { + return this.request( + this.$axios.patch>(`${this.moduleUrl}/toggle`, data) + ); + } + + public async fetchRepos(integrationId: number) { + return this.request( + this.$axios.get>(`${this.moduleUrl}/repos`, { + params: { integrationId }, + }) + ); + } + + public async selectRepo(data: IntegrationArgSelectRepo) { + return this.request( + this.$axios.patch>(`${this.moduleUrl}/select-repo`, data) + ); + } + + public async syncIntegration(integrationId: number) { + return this.request( + this.$axios.post>(`${this.moduleUrl}/sync`, { integrationId }) + ); + } +} diff --git a/taskview-packages/taskview-api/src/api/integrations.types.ts b/taskview-packages/taskview-api/src/api/integrations.types.ts new file mode 100644 index 0000000..7a12945 --- /dev/null +++ b/taskview-packages/taskview-api/src/api/integrations.types.ts @@ -0,0 +1,50 @@ +export type IntegrationProvider = 'github' | 'gitlab'; + +export type IntegrationItem = { + id: number; + provider: IntegrationProvider; + repoFullName: string | null; + repoExternalId: string | null; + projectId: number; + isActive: boolean; + createdAt: string | null; + updatedAt: string | null; +}; + +export type IntegrationArgAdd = { + provider: IntegrationProvider; + repoFullName: string; + projectId: number; +}; + +export type IntegrationArgDelete = { + id: number; +}; + +export type IntegrationArgToggle = { + id: number; + isActive: boolean; +}; + +export type IntegrationArgSelectRepo = { + integrationId: number; + repoFullName: string; + repoExternalId: string; +}; + +export type RepoItem = { + id: number; + fullName: string; + name: string; + isPrivate: boolean; + description: string | null; + url: string; +}; + +export type IntegrationResponseAdd = IntegrationItem | null; +export type IntegrationResponseFetch = IntegrationItem[]; +export type IntegrationResponseToggle = IntegrationItem | null; +export type IntegrationResponseDelete = boolean; +export type IntegrationResponseSelectRepo = IntegrationItem | null; +export type IntegrationResponseRepos = RepoItem[]; +export type IntegrationResponseSync = { synced: number }; diff --git a/taskview-packages/taskview-api/src/api/permissions.ts b/taskview-packages/taskview-api/src/api/permissions.ts index 1f89f72..3dea854 100644 --- a/taskview-packages/taskview-api/src/api/permissions.ts +++ b/taskview-packages/taskview-api/src/api/permissions.ts @@ -122,6 +122,9 @@ export const TvPermissions: Record, keyof GoalP GRAPH_CAN_MANAGE: 'graph_can_manage', GRAPH_CAN_VIEW: 'graph_can_view', + + INTEGRATIONS_CAN_MANAGE: 'integrations_can_manage', + INTEGRATIONS_CAN_VIEW: 'integrations_can_view', } as const; export type GoalPermissions = { @@ -161,4 +164,7 @@ export type GoalPermissions = { graph_can_manage?: true; graph_can_view?: true; + + integrations_can_manage?: true; + integrations_can_view?: true; }; \ No newline at end of file diff --git a/taskview-packages/taskview-api/src/api/tasks.api.types.ts b/taskview-packages/taskview-api/src/api/tasks.api.types.ts index c24517c..cd488ad 100644 --- a/taskview-packages/taskview-api/src/api/tasks.api.types.ts +++ b/taskview-packages/taskview-api/src/api/tasks.api.types.ts @@ -42,7 +42,7 @@ export interface Task extends TaskBase { type NotAllowedToUpdate = 'id' | 'goalId' | 'owner' | 'dateCreation' | 'dateComplete' | 'historyId'; export type TaskArgUpdate = Pick & Partial>; -export type TaskResponseUpdate = Task | null; +export type TaskResponseUpdate = (Task & { syncFailed?: boolean }) | null; export type TaskArgAdd = Pick & Partial> diff --git a/taskview-packages/taskview-api/src/index.ts b/taskview-packages/taskview-api/src/index.ts index d501e81..53fce78 100644 --- a/taskview-packages/taskview-api/src/index.ts +++ b/taskview-packages/taskview-api/src/index.ts @@ -8,4 +8,5 @@ export * from '@/api/goals.types'; export * from '@/api/collaboration.types'; export * from '@/api/tags.api.types'; export * from '@/api/goals-list.types'; -export * from '@/api/kanban.types'; \ No newline at end of file +export * from '@/api/kanban.types'; +export * from '@/api/integrations.types'; \ No newline at end of file diff --git a/taskview-packages/taskview-api/src/tv.ts b/taskview-packages/taskview-api/src/tv.ts index 96e7359..7fc2984 100644 --- a/taskview-packages/taskview-api/src/tv.ts +++ b/taskview-packages/taskview-api/src/tv.ts @@ -5,6 +5,7 @@ import TvGoalApi from "./api/goals"; import { TvCollaborationApi } from "./api/collaboration"; import TvGoalListApi from "./api/goals-list"; import TvTagsApi from "./api/tags"; +import TvIntegrationsApi from "./api/integrations"; import TvKanban from "./api/kanban"; export class TvApi { @@ -25,6 +26,8 @@ export class TvApi { public kanban: TvKanban; + public integrations: TvIntegrationsApi; + constructor($axios: AxiosInstance) { this.$axios = $axios; @@ -41,6 +44,8 @@ export class TvApi { this.tags = new TvTagsApi(this.$axios); this.kanban = new TvKanban(this.$axios); + + this.integrations = new TvIntegrationsApi(this.$axios); } public setBaseUrl(baseUrl: string) { diff --git a/taskview-packages/taskview-db-schemas/src/index.ts b/taskview-packages/taskview-db-schemas/src/index.ts index 825af27..2aa9b6b 100644 --- a/taskview-packages/taskview-db-schemas/src/index.ts +++ b/taskview-packages/taskview-db-schemas/src/index.ts @@ -8,3 +8,4 @@ export * from './schemas/tasks-assignee.schema'; export * from './schemas/goals.schema'; export * from './schemas/goals-list.schema'; export * from './schemas/tasks-statuses.schema'; +export * from './schemas/integrations.schema'; diff --git a/taskview-packages/taskview-db-schemas/src/schemas/integrations.schema.ts b/taskview-packages/taskview-db-schemas/src/schemas/integrations.schema.ts new file mode 100644 index 0000000..f87fd2e --- /dev/null +++ b/taskview-packages/taskview-db-schemas/src/schemas/integrations.schema.ts @@ -0,0 +1,35 @@ +import { boolean, integer, pgSchema, timestamp, uniqueIndex, varchar } from "drizzle-orm/pg-core"; +import { GoalsSchema } from "./goals.schema"; +import { TasksSchema } from "./tasks.schema"; + +export const IntegrationsSchema = pgSchema('tasks').table('integrations', { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + provider: varchar({ length: 20 }).notNull(), + accessTokenEncrypted: varchar('access_token_encrypted'), + refreshTokenEncrypted: varchar('refresh_token_encrypted'), + repoExternalId: varchar('repo_external_id', { length: 255 }), + repoFullName: varchar('repo_full_name', { length: 255 }), + projectId: integer('project_id').notNull().references(() => GoalsSchema.id, { onDelete: 'cascade' }), + webhookId: varchar('webhook_id', { length: 255 }), + webhookSecretEncrypted: varchar('webhook_secret_encrypted'), + isActive: boolean('is_active').notNull().default(true), + lastSyncedAt: timestamp('last_synced_at'), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at').defaultNow(), +}); + +export const IntegrationTaskMapSchema = pgSchema('tasks').table('integration_task_map', { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + integrationId: integer('integration_id').notNull().references(() => IntegrationsSchema.id, { onDelete: 'cascade' }), + taskId: integer('task_id').notNull().references(() => TasksSchema.id, { onDelete: 'cascade' }), + issueNumber: integer('issue_number').notNull(), + issueState: varchar('issue_state', { length: 20 }).notNull().default('open'), + syncedAt: timestamp('synced_at').defaultNow(), +}, (table) => [ + uniqueIndex('integration_issue_unique').on(table.integrationId, table.issueNumber), +]); + +export type IntegrationsSchemaTypeForSelect = typeof IntegrationsSchema.$inferSelect; +export type IntegrationsSchemaTypeForInsert = typeof IntegrationsSchema.$inferInsert; +export type IntegrationTaskMapSchemaTypeForSelect = typeof IntegrationTaskMapSchema.$inferSelect; +export type IntegrationTaskMapSchemaTypeForInsert = typeof IntegrationTaskMapSchema.$inferInsert; diff --git a/web/src/components/features/integrations/IntegrationsPanel.vue b/web/src/components/features/integrations/IntegrationsPanel.vue new file mode 100644 index 0000000..661a7b7 --- /dev/null +++ b/web/src/components/features/integrations/IntegrationsPanel.vue @@ -0,0 +1,204 @@ + + + diff --git a/web/src/components/features/integrations/parts/AddIntegrationModal.vue b/web/src/components/features/integrations/parts/AddIntegrationModal.vue new file mode 100644 index 0000000..e005121 --- /dev/null +++ b/web/src/components/features/integrations/parts/AddIntegrationModal.vue @@ -0,0 +1,55 @@ + + + diff --git a/web/src/components/features/integrations/parts/IntegrationItem.vue b/web/src/components/features/integrations/parts/IntegrationItem.vue new file mode 100644 index 0000000..869ee49 --- /dev/null +++ b/web/src/components/features/integrations/parts/IntegrationItem.vue @@ -0,0 +1,87 @@ + + + diff --git a/web/src/components/features/integrations/parts/RepoSelectModal.vue b/web/src/components/features/integrations/parts/RepoSelectModal.vue new file mode 100644 index 0000000..7eae1de --- /dev/null +++ b/web/src/components/features/integrations/parts/RepoSelectModal.vue @@ -0,0 +1,92 @@ + + + diff --git a/web/src/components/features/projects/parts/ProjectListBase.vue b/web/src/components/features/projects/parts/ProjectListBase.vue index 5df065d..f6e224a 100644 --- a/web/src/components/features/projects/parts/ProjectListBase.vue +++ b/web/src/components/features/projects/parts/ProjectListBase.vue @@ -85,6 +85,17 @@ :to="{ name: 'collaboration', params: { projectId: selectedProject?.id } }" @click="contextMenu?.close()" /> + + @@ -199,6 +210,7 @@ const selectedProject = ref(null) const { canViewKanban, canViewGraph, + canViewIntegrations, canManageUsers, canEditGoal, canDeleteGoal, diff --git a/web/src/components/features/tasks/TaskDetailPanel.vue b/web/src/components/features/tasks/TaskDetailPanel.vue index b836383..f4e89af 100644 --- a/web/src/components/features/tasks/TaskDetailPanel.vue +++ b/web/src/components/features/tasks/TaskDetailPanel.vue @@ -136,6 +136,7 @@ import type { TaskBase } from 'taskview-api' import { useGoalPermissions } from '@/composables/useGoalPermissions' const { t } = useI18n() +const toast = useToast() const tasksStore = useTasksStore() const { canEditTaskStatus, @@ -145,10 +146,17 @@ const projectId = computed(() => task.value?.goalId ?? 0) async function toggleComplete() { if (!task.value) return - await tasksStore.updateTaskCompleteStatus({ + const result = await tasksStore.updateTaskCompleteStatus({ id: task.value.id, complete: !task.value.complete, }) + if (result?.syncFailed) { + toast.add({ + title: t('integrations.syncFailed'), + description: t('integrations.syncFailedDescription'), + color: 'warning', + }) + } } async function updateTaskTitle(event: Event) { diff --git a/web/src/components/features/tasks/TasksPanel.vue b/web/src/components/features/tasks/TasksPanel.vue index cb6dda1..afdb61f 100644 --- a/web/src/components/features/tasks/TasksPanel.vue +++ b/web/src/components/features/tasks/TasksPanel.vue @@ -84,6 +84,7 @@ import { useGoalPermissions } from '@/composables/useGoalPermissions' const { t } = useI18n() const route = useRoute() +const toast = useToast() const { canViewTasks } = useGoalPermissions() const tasksStore = useTasksStore() const { tasks, loading, fetchRules } = storeToRefs(tasksStore) @@ -142,10 +143,17 @@ async function loadMoreTasks() { } async function toggleTask(task: Task) { - await tasksStore.updateTaskCompleteStatus({ + const result = await tasksStore.updateTaskCompleteStatus({ id: task.id, complete: !task.complete, }) + if (result?.syncFailed) { + toast.add({ + title: t('integrations.syncFailed'), + description: t('integrations.syncFailedDescription'), + color: 'warning', + }) + } } async function addTask(description: string) { diff --git a/web/src/composables/useGoalPermissions.ts b/web/src/composables/useGoalPermissions.ts index 6fb6d34..b0eb120 100644 --- a/web/src/composables/useGoalPermissions.ts +++ b/web/src/composables/useGoalPermissions.ts @@ -24,6 +24,8 @@ export function useGoalPermissionsFor(goalRef: Ref) { canManageUsers: can('GOAL_CAN_MANAGE_USERS'), canViewKanban: computed(() => hasPermission(goalRef.value, 'KANBAN_CAN_VIEW') || hasPermission(goalRef.value, 'KANBAN_CAN_MANAGE')), canViewGraph: computed(() => hasPermission(goalRef.value, 'GRAPH_CAN_VIEW') || hasPermission(goalRef.value, 'GRAPH_CAN_MANAGE')), + canViewIntegrations: computed(() => hasPermission(goalRef.value, 'INTEGRATIONS_CAN_VIEW') || hasPermission(goalRef.value, 'INTEGRATIONS_CAN_MANAGE')), + canManageIntegrations: computed(() => hasPermission(goalRef.value, 'INTEGRATIONS_CAN_MANAGE')), } } @@ -75,6 +77,8 @@ export const useGoalPermissions = () => { const canManageKanban = computed(() => permissions.KANBAN_CAN_MANAGE) const canViewGraph = computed(() => permissions.GRAPH_CAN_VIEW || permissions.GRAPH_CAN_MANAGE) const canManageGraph = computed(() => permissions.GRAPH_CAN_MANAGE) + const canViewIntegrations = computed(() => permissions.INTEGRATIONS_CAN_VIEW || permissions.INTEGRATIONS_CAN_MANAGE) + const canManageIntegrations = computed(() => permissions.INTEGRATIONS_CAN_MANAGE) return { permissions, @@ -108,5 +112,7 @@ export const useGoalPermissions = () => { canManageKanban, canViewGraph, canManageGraph, + canViewIntegrations, + canManageIntegrations, } } diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 43ca5c6..0998aee 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -96,6 +96,7 @@ export default { kanban: 'Kanban', graph: 'Graph', collaboration: 'Collaboration', + integrations: 'Integrations', edit: 'Edit', moveToArchive: 'Move to Archive', restoreFromArchive: 'Restore from Archive', @@ -263,6 +264,26 @@ export default { manage_rolesDesc: 'Can create, edit and delete roles', }, }, + integrations: { + title: 'Integrations', + selectProject: 'Select a project to manage integrations', + add: 'Add Integration', + addTitle: 'Connect Repository', + connectDescription: 'Choose a provider to connect your repository.', + provider: 'Provider', + repoFullName: 'Repository', + repoPlaceholder: 'owner/repository', + empty: 'No integrations yet. Connect a GitHub or GitLab repository to sync issues.', + selectRepo: 'Select Repository', + searchRepos: 'Search repositories...', + noReposFound: 'No repositories found', + pendingSelectRepo: 'Repository not selected', + private: 'Private', + syncFailed: 'Issue sync failed', + syncFailedDescription: 'Could not update the issue on the remote repository. Use the sync button on the integrations page to retry.', + sync: 'Sync', + syncing: 'Syncing...', + }, main: 'Main', mainScreen: { welcome: 'Welcome', diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index eea96e3..4eafa2a 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -96,6 +96,7 @@ export default { kanban: 'Канбан', graph: 'Граф', collaboration: 'Совместная работа', + integrations: 'Интеграции', edit: 'Редактировать', moveToArchive: 'В архив', restoreFromArchive: 'Восстановить из архива', @@ -263,6 +264,26 @@ export default { manage_rolesDesc: 'Может создавать, редактировать и удалять роли', }, }, + integrations: { + title: 'Интеграции', + selectProject: 'Выберите проект для управления интеграциями', + add: 'Добавить интеграцию', + addTitle: 'Подключить репозиторий', + connectDescription: 'Выберите провайдер для подключения репозитория.', + provider: 'Провайдер', + repoFullName: 'Репозиторий', + repoPlaceholder: 'owner/repository', + empty: 'Пока нет интеграций. Подключите репозиторий GitHub или GitLab для синхронизации issues.', + selectRepo: 'Выберите репозиторий', + searchRepos: 'Поиск репозиториев...', + noReposFound: 'Репозитории не найдены', + pendingSelectRepo: 'Репозиторий не выбран', + private: 'Приватный', + syncFailed: 'Ошибка синхронизации', + syncFailedDescription: 'Не удалось обновить issue в удалённом репозитории. Используйте кнопку синхронизации на странице интеграций.', + sync: 'Синхронизация', + syncing: 'Синхронизация...', + }, main: 'Главная', mainScreen: { welcome: 'Добро пожаловать', diff --git a/web/src/main.ts b/web/src/main.ts index 67efa51..7e3d24b 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -59,6 +59,11 @@ const router = createRouter({ name: 'collaboration', component: () => import('./pages/user/collaboration.vue'), }, + { + path: ':projectId/integrations', + name: 'integrations', + component: () => import('./pages/user/integrations.vue'), + }, { path: 'account', name: 'account', diff --git a/web/src/pages/user/integrations.vue b/web/src/pages/user/integrations.vue new file mode 100644 index 0000000..f35ed6f --- /dev/null +++ b/web/src/pages/user/integrations.vue @@ -0,0 +1,21 @@ + + + diff --git a/web/src/stores/integrations.store.ts b/web/src/stores/integrations.store.ts new file mode 100644 index 0000000..c15de96 --- /dev/null +++ b/web/src/stores/integrations.store.ts @@ -0,0 +1,79 @@ +import { defineStore } from 'pinia' +import type { IntegrationArgAdd, IntegrationItem, RepoItem } from 'taskview-api' +import { $tvApi } from '@/plugins/axios' + +interface IntegrationsStoreState { + loading: boolean + integrations: IntegrationItem[] + repos: RepoItem[] + reposLoading: boolean +} + +export const useIntegrationsStore = defineStore('integrations', { + state: (): IntegrationsStoreState => ({ + loading: false, + integrations: [], + repos: [], + reposLoading: false, + }), + actions: { + async fetchIntegrations(projectId: number): Promise { + if (this.loading) return + this.loading = true + const result = await $tvApi.integrations.fetchIntegrations(projectId) + this.integrations = result || [] + this.loading = false + }, + + async addIntegration(data: IntegrationArgAdd): Promise { + const integration = await $tvApi.integrations.createIntegration(data) + if (!integration) return null + this.integrations.push(integration) + return integration + }, + + async removeIntegration(id: number): Promise { + const result = await $tvApi.integrations.deleteIntegration(id) + if (!result) return + const index = this.integrations.findIndex((i) => i.id === id) + if (index !== -1) { + this.integrations.splice(index, 1) + } + }, + + async toggleIntegration(id: number, isActive: boolean): Promise { + const result = await $tvApi.integrations.toggleIntegration({ id, isActive }) + if (!result) return + const index = this.integrations.findIndex((i) => i.id === id) + if (index !== -1) { + this.integrations[index] = result + } + }, + + async fetchRepos(integrationId: number): Promise { + this.reposLoading = true + const result = await $tvApi.integrations.fetchRepos(integrationId) + this.repos = result || [] + this.reposLoading = false + }, + + async syncIntegration(integrationId: number): Promise { + const result = await $tvApi.integrations.syncIntegration(integrationId) + return !!(result && result.synced >= 0) + }, + + async selectRepo(integrationId: number, repo: RepoItem): Promise { + const result = await $tvApi.integrations.selectRepo({ + integrationId, + repoFullName: repo.fullName, + repoExternalId: String(repo.id), + }) + if (!result) return + const index = this.integrations.findIndex((i) => i.id === integrationId) + if (index !== -1) { + this.integrations[index] = result + } + this.repos = [] + }, + }, +}) diff --git a/web/src/stores/tasks.store.ts b/web/src/stores/tasks.store.ts index 99d7fb4..b45760c 100644 --- a/web/src/stores/tasks.store.ts +++ b/web/src/stores/tasks.store.ts @@ -188,7 +188,7 @@ export const useTasksStore = defineStore('tasks', { async updateTaskCompleteStatus( data: TaskArgUpdate, deleteCompleted: boolean = true, - ): Promise { + ): Promise<{ complete: boolean; syncFailed?: boolean } | undefined> { this.loading = true const taskResult = await $tvApi.tasks @@ -239,7 +239,7 @@ export const useTasksStore = defineStore('tasks', { this.updateSelectedTask(parentTask) - return taskResult.complete + return { complete: taskResult.complete, syncFailed: taskResult.syncFailed } }, async updateTaskDescription(data: TaskArgUpdate): Promise {