mirror of
https://github.com/Gimanh/taskview-community.git
synced 2026-09-11 13:29:17 +00:00
Merge pull request #29 from Gimanh/feat/git-integration
feat: integrations github & gitlab
This commit is contained in:
+22
-1
@@ -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
|
||||
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
|
||||
+8
-1
@@ -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 }));
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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<string, RoutableConstructor> = {
|
||||
'/module/about': StartRoutes,
|
||||
'/module/kanban': KanbanRoutes,
|
||||
'/module/graph': GraphRoutes,
|
||||
'/module/integrations': IntegrationsRoutes,
|
||||
};
|
||||
|
||||
export default routes;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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<IntegrationsSchemaTypeForSelect | false> {
|
||||
return this.repository.create(data);
|
||||
}
|
||||
|
||||
async delete(data: IntegrationsArgDelete): Promise<boolean> {
|
||||
return this.repository.delete(data);
|
||||
}
|
||||
|
||||
async toggle(data: IntegrationsArgToggle): Promise<IntegrationsSchemaTypeForSelect | false> {
|
||||
const result = await this.repository.toggle(data);
|
||||
if (result && data.isActive) {
|
||||
this.syncIssues(data.id).catch(logError);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async fetch(data: IntegrationsArgFetch): Promise<IntegrationsSchemaTypeForSelect[]> {
|
||||
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<RepoItemForClient[]> {
|
||||
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<IntegrationsSchemaTypeForSelect | false> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
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<boolean> {
|
||||
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<string | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<IntegrationsSchemaTypeForSelect | false> {
|
||||
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<boolean> {
|
||||
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<IntegrationsSchemaTypeForSelect | false> {
|
||||
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<IntegrationsSchemaTypeForSelect[]> {
|
||||
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<IntegrationsSchemaTypeForSelect | undefined> {
|
||||
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<IntegrationsSchemaTypeForSelect | false> {
|
||||
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<boolean> {
|
||||
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<IntegrationsSchemaTypeForSelect | false> {
|
||||
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<IntegrationTaskMapSchemaTypeForSelect | false> {
|
||||
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<IntegrationTaskMapSchemaTypeForSelect[]> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<IntegrationsSchemaTypeForSelect[]> {
|
||||
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<IntegrationsSchemaTypeForSelect[]> {
|
||||
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<IntegrationTaskMapSchemaTypeForSelect | undefined> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<number> {
|
||||
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<boolean> {
|
||||
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<string | null> {
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<typeof Router>;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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<number | null> {
|
||||
// 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;
|
||||
}
|
||||
@@ -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<string> {
|
||||
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<GitHubRepo[]> {
|
||||
const repos: GitHubRepo[] = [];
|
||||
let page = 1;
|
||||
const perPage = 100;
|
||||
|
||||
while (true) {
|
||||
const res = await axios.get<GitHubRepo[]>(`${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<GitHubIssue[]> {
|
||||
const issues: GitHubIssue[] = [];
|
||||
let page = 1;
|
||||
const perPage = 100;
|
||||
|
||||
while (true) {
|
||||
const res = await axios.get<GitHubIssue[]>(`${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<void> {
|
||||
await axios.patch(
|
||||
`${GITHUB_API_URL}/repos/${repoFullName}/issues/${issueNumber}`,
|
||||
{ state },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/vnd.github+json',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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<GitLabRepo[]> {
|
||||
const repos: GitLabRepo[] = [];
|
||||
let page = 1;
|
||||
const perPage = 100;
|
||||
|
||||
while (true) {
|
||||
const res = await axios.get<GitLabRepo[]>(`${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<GitLabIssue[]> {
|
||||
const issues: GitLabIssue[] = [];
|
||||
let page = 1;
|
||||
const perPage = 100;
|
||||
|
||||
while (true) {
|
||||
const res = await axios.get<GitLabIssue[]>(`${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<void> {
|
||||
await axios.put(
|
||||
`${GITLAB_API_URL}/projects/${projectId}/issues/${issueIid}`,
|
||||
{ state_event: stateEvent },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<TaskForClientNew | null> {
|
||||
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);
|
||||
|
||||
@@ -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<number> {
|
||||
const min = await this.fetchTaskWithMinKanbanOrder(goalId, statusId);
|
||||
return (min ?? 0) - TasksRepository.KANBAN_ORDER_GAP;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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<AppResponse<IntegrationResponseFetch>>(`${this.moduleUrl}`, {
|
||||
params: { projectId },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public async createIntegration(data: IntegrationArgAdd) {
|
||||
return this.request(
|
||||
this.$axios.post<AppResponse<IntegrationResponseAdd>>(`${this.moduleUrl}`, data)
|
||||
);
|
||||
}
|
||||
|
||||
public async deleteIntegration(id: number) {
|
||||
return this.request(
|
||||
this.$axios.delete<AppResponse<IntegrationResponseDelete>>(`${this.moduleUrl}`, {
|
||||
data: { id },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public async toggleIntegration(data: IntegrationArgToggle) {
|
||||
return this.request(
|
||||
this.$axios.patch<AppResponse<IntegrationResponseToggle>>(`${this.moduleUrl}/toggle`, data)
|
||||
);
|
||||
}
|
||||
|
||||
public async fetchRepos(integrationId: number) {
|
||||
return this.request(
|
||||
this.$axios.get<AppResponse<IntegrationResponseRepos>>(`${this.moduleUrl}/repos`, {
|
||||
params: { integrationId },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public async selectRepo(data: IntegrationArgSelectRepo) {
|
||||
return this.request(
|
||||
this.$axios.patch<AppResponse<IntegrationResponseSelectRepo>>(`${this.moduleUrl}/select-repo`, data)
|
||||
);
|
||||
}
|
||||
|
||||
public async syncIntegration(integrationId: number) {
|
||||
return this.request(
|
||||
this.$axios.post<AppResponse<IntegrationResponseSync>>(`${this.moduleUrl}/sync`, { integrationId })
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -122,6 +122,9 @@ export const TvPermissions: Record<Uppercase<keyof GoalPermissions>, 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;
|
||||
};
|
||||
@@ -42,7 +42,7 @@ export interface Task extends TaskBase {
|
||||
type NotAllowedToUpdate = 'id' | 'goalId' | 'owner' | 'dateCreation' | 'dateComplete' | 'historyId';
|
||||
|
||||
export type TaskArgUpdate = Pick<Task, 'id'> & Partial<Omit<Task, NotAllowedToUpdate>>;
|
||||
export type TaskResponseUpdate = Task | null;
|
||||
export type TaskResponseUpdate = (Task & { syncFailed?: boolean }) | null;
|
||||
|
||||
export type TaskArgAdd = Pick<Task, 'goalId' | 'description'>
|
||||
& Partial<Omit<Task, NotAllowedToUpdate | 'subtasks' | 'tags' | 'assignedUsers' | 'creatorId' | 'amount'>>
|
||||
|
||||
@@ -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';
|
||||
export * from '@/api/kanban.types';
|
||||
export * from '@/api/integrations.types';
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<div
|
||||
v-if="!hasGoalSelected"
|
||||
class="flex flex-col items-center justify-center h-64 text-muted"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-plug"
|
||||
class="size-12 mb-4"
|
||||
/>
|
||||
<p>{{ t('integrations.selectProject') }}</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold">
|
||||
{{ projectName }}
|
||||
</h2>
|
||||
<UButton
|
||||
v-if="canManageIntegrations"
|
||||
:label="t('integrations.add')"
|
||||
icon="i-lucide-plus"
|
||||
color="primary"
|
||||
@click="isAddModalOpen = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loading"
|
||||
class="flex items-center justify-center h-32"
|
||||
>
|
||||
<p>{{ t('common.loading') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="integrations.length === 0"
|
||||
class="flex flex-col items-center justify-center h-64 text-muted"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-plug"
|
||||
class="size-12 mb-4"
|
||||
/>
|
||||
<p>{{ t('integrations.empty') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<!-- Pending integrations (OAuth done, repo not selected) -->
|
||||
<div
|
||||
v-for="item in pendingIntegrations"
|
||||
:key="'pending-' + item.id"
|
||||
class="flex items-center justify-between p-4 border border-dashed border-primary rounded-lg"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<UIcon
|
||||
:name="item.provider === 'github' ? 'i-mdi-github' : 'i-mdi-gitlab'"
|
||||
class="size-6"
|
||||
/>
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ t('integrations.pendingSelectRepo') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted">
|
||||
{{ item.provider === 'github' ? 'GitHub' : 'GitLab' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="canManageIntegrations"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<UButton
|
||||
:label="t('integrations.selectRepo')"
|
||||
color="primary"
|
||||
size="sm"
|
||||
@click="openRepoSelect(item)"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-trash-2"
|
||||
variant="ghost"
|
||||
color="error"
|
||||
size="xs"
|
||||
@click="handleRemove(item.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connected integrations -->
|
||||
<IntegrationItem
|
||||
v-for="item in connectedIntegrations"
|
||||
:key="item.id"
|
||||
:integration="item"
|
||||
:can-manage="canManageIntegrations"
|
||||
@toggle="handleToggle"
|
||||
@remove="handleRemove"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<AddIntegrationModal
|
||||
v-model:open="isAddModalOpen"
|
||||
:project-id="projectId"
|
||||
/>
|
||||
|
||||
<RepoSelectModal
|
||||
v-model:open="isRepoSelectOpen"
|
||||
:repos="repos"
|
||||
:loading="reposLoading"
|
||||
@select="handleRepoSelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useIntegrationsStore } from '@/stores/integrations.store'
|
||||
import { useGoalsStore } from '@/stores/goals.store'
|
||||
import { useAppRouteInfo } from '@/composables/useAppRouteInfo'
|
||||
import { useGoalPermissions } from '@/composables/useGoalPermissions'
|
||||
import IntegrationItem from './parts/IntegrationItem.vue'
|
||||
import AddIntegrationModal from './parts/AddIntegrationModal.vue'
|
||||
import RepoSelectModal from './parts/RepoSelectModal.vue'
|
||||
import type { IntegrationItem as IntegrationItemType, RepoItem } from 'taskview-api'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { projectId } = useAppRouteInfo()
|
||||
const goalsStore = useGoalsStore()
|
||||
const integrationsStore = useIntegrationsStore()
|
||||
|
||||
const { canViewIntegrations, canManageIntegrations } = useGoalPermissions()
|
||||
const { integrations, repos, reposLoading } = storeToRefs(integrationsStore)
|
||||
const loading = ref(false)
|
||||
const isAddModalOpen = ref(false)
|
||||
const isRepoSelectOpen = ref(false)
|
||||
const selectedPendingIntegration = ref<IntegrationItemType | null>(null)
|
||||
|
||||
const hasGoalSelected = computed(() => projectId.value > 0)
|
||||
|
||||
const projectName = computed(() => {
|
||||
const goal = goalsStore.goalMap.get(projectId.value)
|
||||
return goal?.name ?? ''
|
||||
})
|
||||
|
||||
const connectedIntegrations = computed(() =>
|
||||
integrations.value.filter((i) => i.repoFullName),
|
||||
)
|
||||
|
||||
const pendingIntegrations = computed(() =>
|
||||
integrations.value.filter((i) => !i.repoFullName),
|
||||
)
|
||||
|
||||
async function fetchData() {
|
||||
if (!hasGoalSelected.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
await integrationsStore.fetchIntegrations(projectId.value)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(projectId, () => {
|
||||
fetchData()
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(async () => {
|
||||
if (route.query.oauth === 'success') {
|
||||
router.replace({ query: {} })
|
||||
await fetchData()
|
||||
const pending = pendingIntegrations.value[0]
|
||||
if (pending) {
|
||||
openRepoSelect(pending)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function openRepoSelect(item: IntegrationItemType) {
|
||||
selectedPendingIntegration.value = item
|
||||
await integrationsStore.fetchRepos(item.id)
|
||||
isRepoSelectOpen.value = true
|
||||
}
|
||||
|
||||
async function handleToggle(item: IntegrationItemType) {
|
||||
await integrationsStore.toggleIntegration(item.id, !item.isActive)
|
||||
}
|
||||
|
||||
async function handleRemove(id: number) {
|
||||
await integrationsStore.removeIntegration(id)
|
||||
}
|
||||
|
||||
async function handleRepoSelect(repo: RepoItem) {
|
||||
if (!selectedPendingIntegration.value) return
|
||||
await integrationsStore.selectRepo(selectedPendingIntegration.value.id, repo)
|
||||
isRepoSelectOpen.value = false
|
||||
selectedPendingIntegration.value = null
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<UModal v-model:open="isOpen">
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold">
|
||||
{{ t('integrations.addTitle') }}
|
||||
</h3>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-sm text-muted">
|
||||
{{ t('integrations.connectDescription') }}
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<UButton
|
||||
label="GitHub"
|
||||
icon="i-mdi-github"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
class="flex-1 justify-center"
|
||||
@click="startOAuth('github')"
|
||||
/>
|
||||
<UButton
|
||||
label="GitLab"
|
||||
icon="i-mdi-gitlab"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
class="flex-1 justify-center"
|
||||
@click="startOAuth('gitlab')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useTaskViewMainUrl } from '@/composables/useTaskViewMainUrl'
|
||||
import $api from '@/helpers/axios'
|
||||
import type { IntegrationProvider } from 'taskview-api'
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: number
|
||||
}>()
|
||||
|
||||
const isOpen = defineModel<boolean>('open', { default: false })
|
||||
const { t } = useI18n()
|
||||
|
||||
function startOAuth(provider: IntegrationProvider) {
|
||||
const baseUrl = useTaskViewMainUrl()
|
||||
const token = $api.defaults.headers.common['Authorization']?.toString().replace('Bearer ', '') || ''
|
||||
const url = `${baseUrl}/module/integrations/oauth/${provider}?projectId=${props.projectId}&token=${encodeURIComponent(token)}`
|
||||
window.location.href = url
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between p-4 border border-default rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<UIcon
|
||||
:name="providerIcon"
|
||||
class="size-6"
|
||||
/>
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ integration.repoFullName }}
|
||||
</p>
|
||||
<p class="text-sm text-muted">
|
||||
{{ providerLabel }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="canManage"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<UButton
|
||||
icon="i-lucide-refresh-cw"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
:loading="syncing"
|
||||
:disabled="!integration.isActive"
|
||||
@click="handleSync"
|
||||
/>
|
||||
<USwitch
|
||||
:model-value="integration.isActive"
|
||||
@update:model-value="$emit('toggle', integration)"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-trash-2"
|
||||
variant="ghost"
|
||||
color="error"
|
||||
size="xs"
|
||||
@click="$emit('remove', integration.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { IntegrationItem } from 'taskview-api'
|
||||
import { useIntegrationsStore } from '@/stores/integrations.store'
|
||||
|
||||
const { t } = useI18n()
|
||||
const integrationsStore = useIntegrationsStore()
|
||||
const toast = useToast()
|
||||
|
||||
const props = defineProps<{
|
||||
integration: IntegrationItem
|
||||
canManage: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
toggle: [item: IntegrationItem]
|
||||
remove: [id: number]
|
||||
}>()
|
||||
|
||||
const syncing = ref(false)
|
||||
|
||||
const providerIcon = computed(() => props.integration.provider === 'github' ? 'i-mdi-github' : 'i-mdi-gitlab')
|
||||
|
||||
const providerLabel = computed(() => props.integration.provider === 'github' ? 'GitHub' : 'GitLab')
|
||||
|
||||
async function handleSync() {
|
||||
syncing.value = true
|
||||
try {
|
||||
await integrationsStore.syncIntegration(props.integration.id)
|
||||
toast.add({
|
||||
title: t('integrations.sync'),
|
||||
color: 'success',
|
||||
})
|
||||
} catch {
|
||||
toast.add({
|
||||
title: t('integrations.syncFailed'),
|
||||
color: 'error',
|
||||
})
|
||||
} finally {
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<UModal v-model:open="isOpen">
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold">
|
||||
{{ t('integrations.selectRepo') }}
|
||||
</h3>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-3">
|
||||
<UInput
|
||||
v-model="search"
|
||||
:placeholder="t('integrations.searchRepos')"
|
||||
icon="i-lucide-search"
|
||||
/>
|
||||
<div
|
||||
v-if="loading"
|
||||
class="flex items-center justify-center h-32"
|
||||
>
|
||||
<p>{{ t('common.loading') }}</p>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="filteredRepos.length === 0"
|
||||
class="flex items-center justify-center h-32 text-muted"
|
||||
>
|
||||
<p>{{ t('integrations.noReposFound') }}</p>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col gap-2 max-h-80 overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
v-for="repo in filteredRepos"
|
||||
:key="repo.id"
|
||||
class="flex items-center justify-between p-3 border border-default rounded-lg cursor-pointer hover:bg-elevated transition-colors"
|
||||
@click="handleSelect(repo)"
|
||||
>
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium truncate">{{ repo.fullName }}</span>
|
||||
<UBadge
|
||||
v-if="repo.isPrivate"
|
||||
:label="t('integrations.private')"
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
v-if="repo.description"
|
||||
class="text-xs text-muted truncate"
|
||||
>
|
||||
{{ repo.description }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { RepoItem } from 'taskview-api'
|
||||
|
||||
const props = defineProps<{
|
||||
repos: RepoItem[]
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [repo: RepoItem]
|
||||
}>()
|
||||
|
||||
const isOpen = defineModel<boolean>('open', { default: false })
|
||||
const { t } = useI18n()
|
||||
const search = ref('')
|
||||
|
||||
const filteredRepos = computed(() => {
|
||||
if (!search.value.trim()) return props.repos
|
||||
const q = search.value.toLowerCase()
|
||||
return props.repos.filter(
|
||||
(r) =>
|
||||
r.fullName.toLowerCase().includes(q) ||
|
||||
r.description?.toLowerCase().includes(q),
|
||||
)
|
||||
})
|
||||
|
||||
function handleSelect(repo: RepoItem) {
|
||||
emit('select', repo)
|
||||
}
|
||||
</script>
|
||||
@@ -85,6 +85,17 @@
|
||||
:to="{ name: 'collaboration', params: { projectId: selectedProject?.id } }"
|
||||
@click="contextMenu?.close()"
|
||||
/>
|
||||
<!-- Integrations -->
|
||||
<UButton
|
||||
v-if="canViewIntegrations"
|
||||
:label="t('contextMenu.integrations')"
|
||||
icon="i-lucide-plug"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
class="w-full justify-start"
|
||||
:to="{ name: 'integrations', params: { projectId: selectedProject?.id } }"
|
||||
@click="contextMenu?.close()"
|
||||
/>
|
||||
|
||||
<USeparator class="my-1" />
|
||||
|
||||
@@ -199,6 +210,7 @@ const selectedProject = ref<Project | null>(null)
|
||||
const {
|
||||
canViewKanban,
|
||||
canViewGraph,
|
||||
canViewIntegrations,
|
||||
canManageUsers,
|
||||
canEditGoal,
|
||||
canDeleteGoal,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -24,6 +24,8 @@ export function useGoalPermissionsFor(goalRef: Ref<GoalItem | null>) {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'Добро пожаловать',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<UDashboardPanel id="integrations">
|
||||
<template #header>
|
||||
<UDashboardNavbar :title="t('integrations.title')">
|
||||
<template #leading>
|
||||
<UDashboardSidebarCollapse />
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
</template>
|
||||
<template #body>
|
||||
<IntegrationsPanel />
|
||||
</template>
|
||||
</UDashboardPanel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import IntegrationsPanel from '@/components/features/integrations/IntegrationsPanel.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -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<void> {
|
||||
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<IntegrationItem | null> {
|
||||
const integration = await $tvApi.integrations.createIntegration(data)
|
||||
if (!integration) return null
|
||||
this.integrations.push(integration)
|
||||
return integration
|
||||
},
|
||||
|
||||
async removeIntegration(id: number): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
this.reposLoading = true
|
||||
const result = await $tvApi.integrations.fetchRepos(integrationId)
|
||||
this.repos = result || []
|
||||
this.reposLoading = false
|
||||
},
|
||||
|
||||
async syncIntegration(integrationId: number): Promise<boolean> {
|
||||
const result = await $tvApi.integrations.syncIntegration(integrationId)
|
||||
return !!(result && result.synced >= 0)
|
||||
},
|
||||
|
||||
async selectRepo(integrationId: number, repo: RepoItem): Promise<void> {
|
||||
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 = []
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -188,7 +188,7 @@ export const useTasksStore = defineStore('tasks', {
|
||||
async updateTaskCompleteStatus(
|
||||
data: TaskArgUpdate,
|
||||
deleteCompleted: boolean = true,
|
||||
): Promise<boolean | undefined> {
|
||||
): 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<boolean> {
|
||||
|
||||
Reference in New Issue
Block a user