Merge pull request #29 from Gimanh/feat/git-integration

feat: integrations github & gitlab
This commit is contained in:
Gimanh
2026-03-08 17:56:15 +01:00
committed by GitHub
44 changed files with 2393 additions and 15 deletions
+22 -1
View File
@@ -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
View File
@@ -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 }));
}
+3
View File
@@ -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 {
+23
View File
@@ -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;
+2
View File
@@ -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}`,
},
},
);
}
+46
View File
@@ -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;
};
+3 -2
View File
@@ -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) => {
+15 -5
View File
@@ -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;
}
}
+3
View File
@@ -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 =
+33
View File
@@ -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'>>
+2 -1
View File
@@ -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
View File
@@ -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,
}
}
+21
View File
@@ -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',
+21
View File
@@ -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: 'Добро пожаловать',
+5
View File
@@ -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',
+21
View File
@@ -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>
+79
View File
@@ -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 = []
},
},
})
+2 -2
View File
@@ -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> {