mirror of
https://github.com/Gimanh/taskview-community.git
synced 2026-09-10 22:25:56 +00:00
feat: gitea integration
This commit is contained in:
@@ -48,6 +48,14 @@ GITLAB_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/
|
||||
# GITLAB_BASE_URL=https://gitlab.yourcompany.com
|
||||
# GITLAB_API_URL=https://gitlab.yourcompany.com/api/v4
|
||||
|
||||
# Gitea Integration OAuth
|
||||
GITEA_INTEGRATION_CLIENT_ID=
|
||||
GITEA_INTEGRATION_CLIENT_SECRET=
|
||||
GITEA_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/gitea/callback
|
||||
# For self-hosted Gitea, override these:
|
||||
# GITEA_BASE_URL=https://gitea.yourcompany.com
|
||||
# GITEA_API_URL=https://gitea.yourcompany.com/api/v1
|
||||
|
||||
# Firebase Cloud Messaging (push notifications for mobile, optional)
|
||||
# Path to Firebase service account JSON file
|
||||
# FIREBASE_CREDENTIALS_PATH=./firebase-credentials.json
|
||||
|
||||
@@ -715,5 +715,16 @@
|
||||
"description": [
|
||||
"Add schedule_mode to recurrence_rules: 'fixed' (calendar schedule) or 'after-completion' (next occurrence = completion day + interval)"
|
||||
]
|
||||
},
|
||||
"56": {
|
||||
"version": "1.61.0",
|
||||
"name": "Gitea integration provider",
|
||||
"releaseDate": "20260726",
|
||||
"scripts": [
|
||||
"/1.61.0/0.alter-integrations-provider-check-gitea.sql"
|
||||
],
|
||||
"description": [
|
||||
"Extend integrations_provider_check constraint to allow the 'gitea' provider alongside 'github' and 'gitlab'"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE tasks.integrations DROP CONSTRAINT IF EXISTS integrations_provider_check;
|
||||
ALTER TABLE tasks.integrations ADD CONSTRAINT integrations_provider_check CHECK (provider IN ('github', 'gitlab', 'gitea'));
|
||||
@@ -1,11 +1,14 @@
|
||||
import { type } from 'arktype';
|
||||
import type { Request, Response } from 'express';
|
||||
import { logError } from '../../utils/api';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import { integrationsDebugLog } from './debugLog';
|
||||
import { decrypt } from '../../utils/crypto';
|
||||
import AuthController from '../auth/AuthController';
|
||||
import { IntegrationsRepository } from './IntegrationsRepository';
|
||||
import { verifyGitHubWebhookSignature, GITHUB_BASE_URL } from './providers/github.provider';
|
||||
import { verifyGitLabWebhookToken, GITLAB_BASE_URL } from './providers/gitlab.provider';
|
||||
import { verifyGiteaWebhookSignature, GITEA_BASE_URL } from './providers/gitea.provider';
|
||||
import { IntegrationsArkTypeAdd, IntegrationsArkTypeDelete, IntegrationsArkTypeFetch, IntegrationsArkTypeSelectRepo, IntegrationsArkTypeToggle } from './types';
|
||||
|
||||
export default class IntegrationsController {
|
||||
@@ -47,23 +50,29 @@ export default class IntegrationsController {
|
||||
|
||||
initiateOAuth = async (req: Request, res: Response) => {
|
||||
try {
|
||||
integrationsDebugLog({ step: 'initiate:start', data: { provider: req.params.provider, projectId: req.query.projectId, hasToken: !!req.query.token } });
|
||||
const token = req.query.token as string;
|
||||
if (!token) {
|
||||
integrationsDebugLog({ step: 'initiate:reject', data: 'token is required' });
|
||||
return res.status(401).send('token is required');
|
||||
}
|
||||
const userPayload = await AuthController.validateTokens(token);
|
||||
if (!userPayload?.userData?.id) {
|
||||
integrationsDebugLog({ step: 'initiate:reject', data: 'invalid token' });
|
||||
return res.status(401).send('Invalid token');
|
||||
}
|
||||
|
||||
const provider = req.params.provider;
|
||||
const projectId = Number(req.query.projectId);
|
||||
if (!projectId || isNaN(projectId)) {
|
||||
integrationsDebugLog({ step: 'initiate:reject', data: 'projectId is required' });
|
||||
return res.status(400).send('projectId is required');
|
||||
}
|
||||
const url = req.appUser.integrationsManager.getOAuthUrl(provider, projectId, userPayload.userData.id);
|
||||
integrationsDebugLog({ step: 'initiate:redirect', data: { userId: userPayload.userData.id, url } });
|
||||
return res.redirect(url);
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
integrationsDebugLog({ step: 'initiate:error', data: { message: err?.message, stack: err?.stack } });
|
||||
logError(err);
|
||||
return res.status(500).send('Failed to initiate OAuth');
|
||||
}
|
||||
@@ -74,15 +83,36 @@ export default class IntegrationsController {
|
||||
const provider = req.params.provider;
|
||||
const code = req.query.code as string;
|
||||
const state = req.query.state as string;
|
||||
integrationsDebugLog({ step: 'callback:start', data: { provider, hasCode: !!code, hasState: !!state, queryKeys: Object.keys(req.query) } });
|
||||
|
||||
if (!code || !state) {
|
||||
integrationsDebugLog({ step: 'callback:reject', data: 'missing code or 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);
|
||||
const { projectId, orgSlug } = await req.appUser.integrationsManager.handleOAuthCallback(provider, code, state);
|
||||
integrationsDebugLog({ step: 'callback:success', data: { projectId, orgSlug } });
|
||||
return res.redirect(`${process.env.APP_URL}/${orgSlug}/${projectId}/integrations?oauth=success`);
|
||||
} catch (err: any) {
|
||||
integrationsDebugLog({
|
||||
step: 'callback:error',
|
||||
data: {
|
||||
message: err?.message,
|
||||
responseStatus: err?.response?.status,
|
||||
responseData: err?.response?.data,
|
||||
stack: err?.stack,
|
||||
},
|
||||
});
|
||||
$logger.error(
|
||||
{
|
||||
provider: req.params.provider,
|
||||
errorMessage: err?.message,
|
||||
responseStatus: err?.response?.status,
|
||||
responseData: err?.response?.data,
|
||||
stack: err?.stack,
|
||||
},
|
||||
'[integrations] OAuth callback failed',
|
||||
);
|
||||
return res.redirect(`${process.env.APP_URL}?oauth=error`);
|
||||
}
|
||||
};
|
||||
@@ -210,6 +240,91 @@ export default class IntegrationsController {
|
||||
}
|
||||
};
|
||||
|
||||
handleGiteaWebhook = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const signature = req.headers['x-gitea-signature'] as string;
|
||||
const event = req.headers['x-gitea-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 || !verifyGiteaWebhookSignature({ 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,
|
||||
false,
|
||||
`${GITEA_BASE_URL}/${repoFullName}/issues/${issueNumber}`,
|
||||
);
|
||||
}
|
||||
} 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;
|
||||
|
||||
@@ -8,10 +8,12 @@ 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 type { IntegrationProvider, IntegrationsArgAdd, IntegrationsArgDelete, IntegrationsArgFetch, IntegrationsArgSelectRepo, IntegrationsArgToggle, OAuthStatePayload, RepoItemForClient } from './types';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { getGitHubOAuthUrl, exchangeGitHubCode, fetchGitHubRepos, fetchGitHubIssues, createGitHubWebhook, updateGitHubIssueState, GITHUB_BASE_URL } from './providers/github.provider';
|
||||
import { getGitLabOAuthUrl, exchangeGitLabCode, fetchGitLabRepos, fetchGitLabIssues, createGitLabWebhook, updateGitLabIssueState, refreshGitLabToken, GITLAB_BASE_URL } from './providers/gitlab.provider';
|
||||
import { getGiteaOAuthUrl, exchangeGiteaCode, fetchGiteaRepos, fetchGiteaIssues, createGiteaWebhook, updateGiteaIssueState, refreshGiteaToken, verifyGiteaToken, GITEA_BASE_URL } from './providers/gitea.provider';
|
||||
import { integrationsDebugLog } from './debugLog';
|
||||
|
||||
export class IntegrationsManager {
|
||||
public readonly repository: IntegrationsRepository;
|
||||
@@ -55,13 +57,16 @@ export class IntegrationsManager {
|
||||
return getGitHubOAuthUrl(state);
|
||||
} else if (provider === 'gitlab') {
|
||||
return getGitLabOAuthUrl(state);
|
||||
} else if (provider === 'gitea') {
|
||||
return getGiteaOAuthUrl(state);
|
||||
}
|
||||
throw new Error(`Unknown provider: ${provider}`);
|
||||
}
|
||||
|
||||
async handleOAuthCallback(provider: string, code: string, state: string): Promise<{ projectId: number; userLogin: string }> {
|
||||
async handleOAuthCallback(provider: string, code: string, state: string): Promise<{ projectId: number; orgSlug: string }> {
|
||||
$logger.debug({ provider }, '[integrations] handleOAuthCallback start');
|
||||
const payload = jwt.verify(state, process.env.JWT_SIGN as string) as OAuthStatePayload;
|
||||
integrationsDebugLog({ step: 'callback:state-verified', data: { userId: payload.userId, projectId: payload.projectId, provider: payload.provider } });
|
||||
|
||||
if (payload.provider !== provider) {
|
||||
$logger.error({ provider, payloadProvider: payload.provider }, '[integrations] provider mismatch in state');
|
||||
@@ -69,6 +74,7 @@ export class IntegrationsManager {
|
||||
}
|
||||
|
||||
const userLogin = await this.repository.fetchUserLogin(payload.userId);
|
||||
integrationsDebugLog({ step: 'callback:user-fetched', data: { userLogin } });
|
||||
if (!userLogin) {
|
||||
$logger.error({ userId: payload.userId }, '[integrations] user not found during OAuth callback');
|
||||
throw new Error('User not found');
|
||||
@@ -84,19 +90,35 @@ export class IntegrationsManager {
|
||||
const tokens = await exchangeGitLabCode(code);
|
||||
accessTokenEncrypted = encrypt(tokens.accessToken);
|
||||
refreshTokenEncrypted = encrypt(tokens.refreshToken);
|
||||
} else if (provider === 'gitea') {
|
||||
const tokens = await exchangeGiteaCode(code);
|
||||
integrationsDebugLog({ step: 'callback:token-exchanged', data: { hasAccessToken: !!tokens.accessToken, hasRefreshToken: !!tokens.refreshToken } });
|
||||
accessTokenEncrypted = encrypt(tokens.accessToken);
|
||||
refreshTokenEncrypted = tokens.refreshToken ? encrypt(tokens.refreshToken) : null;
|
||||
} else {
|
||||
throw new Error(`Unknown provider: ${provider}`);
|
||||
}
|
||||
integrationsDebugLog({ step: 'callback:tokens-encrypted' });
|
||||
|
||||
await this.repository.createWithToken(
|
||||
provider as 'github' | 'gitlab',
|
||||
const created = await this.repository.createWithToken(
|
||||
provider as IntegrationProvider,
|
||||
payload.projectId,
|
||||
accessTokenEncrypted,
|
||||
refreshTokenEncrypted,
|
||||
);
|
||||
if (!created) {
|
||||
integrationsDebugLog({ step: 'callback:db-insert-failed' });
|
||||
throw new Error('Failed to store integration record');
|
||||
}
|
||||
integrationsDebugLog({ step: 'callback:integration-created', data: { integrationId: created.id } });
|
||||
|
||||
$logger.debug({ provider, projectId: payload.projectId, userLogin }, '[integrations] OAuth callback completed');
|
||||
return { projectId: payload.projectId, userLogin };
|
||||
// The app routes are /:orgSlug/:projectId/... — redirect must use the slug
|
||||
// of the project's organization, falling back to the user login for legacy
|
||||
// projects without an organization.
|
||||
const orgSlug = await this.repository.fetchProjectOrgSlug(payload.projectId) ?? userLogin;
|
||||
|
||||
$logger.debug({ provider, projectId: payload.projectId, orgSlug }, '[integrations] OAuth callback completed');
|
||||
return { projectId: payload.projectId, orgSlug };
|
||||
}
|
||||
|
||||
async fetchRepos(integrationId: number): Promise<RepoItemForClient[]> {
|
||||
@@ -126,6 +148,16 @@ export class IntegrationsManager {
|
||||
description: r.description,
|
||||
url: r.web_url,
|
||||
}));
|
||||
} else if (integration.provider === 'gitea') {
|
||||
const repos = await fetchGiteaRepos(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,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
@@ -174,6 +206,14 @@ export class IntegrationsManager {
|
||||
} else if (integration.provider === 'gitlab' && integration.repoExternalId) {
|
||||
const result = await createGitLabWebhook(accessToken, Number(integration.repoExternalId), webhookUrl, webhookSecret);
|
||||
webhookId = String(result.id);
|
||||
} else if (integration.provider === 'gitea') {
|
||||
const result = await createGiteaWebhook({
|
||||
accessToken,
|
||||
repoFullName: integration.repoFullName,
|
||||
webhookUrl,
|
||||
secret: webhookSecret,
|
||||
});
|
||||
webhookId = String(result.id);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
@@ -197,12 +237,11 @@ export class IntegrationsManager {
|
||||
const existingMappings = await this.repository.fetchMappingsByIntegrationId(integrationId);
|
||||
const mappingsByIssueNumber = new Map(existingMappings.map((m) => [m.issueNumber, m]));
|
||||
|
||||
const issueUrlPrefix = this.getIssueUrlPrefix(integration);
|
||||
|
||||
// Backfill sourceUrl for existing tasks that don't have it yet
|
||||
if (existingMappings.length > 0) {
|
||||
const baseUrl = integration.provider === 'github' ? GITHUB_BASE_URL : GITLAB_BASE_URL;
|
||||
const issuePath = integration.provider === 'gitlab' ? '/-/issues/' : '/issues/';
|
||||
const prefix = `${baseUrl}/${integration.repoFullName}${issuePath}`;
|
||||
await this.repository.backfillSourceUrls(integrationId, prefix).catch(logError);
|
||||
await this.repository.backfillSourceUrls(integrationId, issueUrlPrefix).catch(logError);
|
||||
}
|
||||
|
||||
type NewIssueItem = { goalId: number; description: string; integrationId: number; issueNumber: number; issueState: string; note: string | null; complete: boolean; kanbanOrder: number; sourceUrl: string | null };
|
||||
@@ -263,6 +302,34 @@ export class IntegrationsManager {
|
||||
sourceUrl: `${GITLAB_BASE_URL}/${integration.repoFullName}/-/issues/${issue.iid}`,
|
||||
});
|
||||
}
|
||||
} else if (integration.provider === 'gitea') {
|
||||
const issues = await fetchGiteaIssues({ accessToken, repoFullName: 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);
|
||||
await this.repository.updateTaskSourceUrl(existing.taskId, `${issueUrlPrefix}${issue.number}`).catch(logError);
|
||||
continue;
|
||||
}
|
||||
const isClosed = issue.state === 'closed';
|
||||
newItems.push({
|
||||
goalId: integration.projectId,
|
||||
description: issue.title,
|
||||
integrationId,
|
||||
issueNumber: issue.number,
|
||||
issueState: isClosed ? 'closed' : 'open',
|
||||
note: issue.body ?? null,
|
||||
complete: isClosed,
|
||||
kanbanOrder: 0,
|
||||
sourceUrl: `${issueUrlPrefix}${issue.number}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Issues come newest-first from API.
|
||||
@@ -319,6 +386,13 @@ export class IntegrationsManager {
|
||||
mapping.issueNumber,
|
||||
complete ? 'close' : 'reopen',
|
||||
);
|
||||
} else if (integration.provider === 'gitea') {
|
||||
await updateGiteaIssueState({
|
||||
accessToken,
|
||||
repoFullName: integration.repoFullName,
|
||||
issueNumber: mapping.issueNumber,
|
||||
state: targetState,
|
||||
});
|
||||
}
|
||||
|
||||
await this.repository.updateMappingState(mapping.id, targetState);
|
||||
@@ -326,41 +400,56 @@ export class IntegrationsManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
private getIssueUrlPrefix(integration: IntegrationsSchemaTypeForSelect): string {
|
||||
if (integration.provider === 'gitlab') {
|
||||
return `${GITLAB_BASE_URL}/${integration.repoFullName}/-/issues/`;
|
||||
}
|
||||
const baseUrl = integration.provider === 'gitea' ? GITEA_BASE_URL : GITHUB_BASE_URL;
|
||||
return `${baseUrl}/${integration.repoFullName}/issues/`;
|
||||
}
|
||||
|
||||
private async getAccessToken(integration: IntegrationsSchemaTypeForSelect): Promise<string | null> {
|
||||
if (!integration.accessTokenEncrypted) return null;
|
||||
|
||||
const accessToken = decrypt(integration.accessTokenEncrypted);
|
||||
|
||||
if (integration.provider !== 'gitlab' || !integration.refreshTokenEncrypted) {
|
||||
const hasExpiringToken = integration.provider === 'gitlab' || integration.provider === 'gitea';
|
||||
if (!hasExpiringToken || !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}` },
|
||||
});
|
||||
if (integration.provider === 'gitea') {
|
||||
await verifyGiteaToken(accessToken);
|
||||
} else {
|
||||
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');
|
||||
$logger.debug({ integrationId: integration.id, provider: integration.provider }, '[integrations] token expired (401), refreshing');
|
||||
}
|
||||
|
||||
// Token expired, refresh it
|
||||
try {
|
||||
const refreshToken = decrypt(integration.refreshTokenEncrypted);
|
||||
const tokens = await refreshGitLabToken(refreshToken);
|
||||
const tokens = integration.provider === 'gitea'
|
||||
? await refreshGiteaToken(refreshToken)
|
||||
: 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');
|
||||
$logger.debug({ integrationId: integration.id, provider: integration.provider }, '[integrations] token refreshed successfully');
|
||||
return tokens.accessToken;
|
||||
} catch (err) {
|
||||
$logger.error({ integrationId: integration.id, err }, '[integrations] GitLab token refresh failed');
|
||||
$logger.error({ integrationId: integration.id, provider: integration.provider, err }, '[integrations] token refresh failed');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { and, eq, ne, isNull, sql } from 'drizzle-orm';
|
||||
import { IntegrationsSchema, IntegrationTaskMapSchema, TasksSchema, UsersSchema, type IntegrationsSchemaTypeForSelect, type IntegrationTaskMapSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import { GoalsSchema, IntegrationsSchema, IntegrationTaskMapSchema, OrganizationsSchema, 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 type { IntegrationProvider, IntegrationsArgAdd, IntegrationsArgDelete, IntegrationsArgSelectRepo, IntegrationsArgToggle } from './types';
|
||||
import { TasksRepository } from '../tasks/TasksRepository';
|
||||
|
||||
export class IntegrationsRepository {
|
||||
@@ -62,7 +62,7 @@ export class IntegrationsRepository {
|
||||
}
|
||||
|
||||
async createWithToken(
|
||||
provider: 'github' | 'gitlab',
|
||||
provider: IntegrationProvider,
|
||||
projectId: number,
|
||||
accessTokenEncrypted: string,
|
||||
refreshTokenEncrypted?: string | null,
|
||||
@@ -322,6 +322,17 @@ export class IntegrationsRepository {
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async fetchProjectOrgSlug(projectId: number): Promise<string | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ slug: OrganizationsSchema.slug })
|
||||
.from(GoalsSchema)
|
||||
.innerJoin(OrganizationsSchema, eq(GoalsSchema.organizationId, OrganizationsSchema.id))
|
||||
.where(eq(GoalsSchema.id, projectId))
|
||||
);
|
||||
if (!result || result.length === 0) return null;
|
||||
return result[0].slug;
|
||||
}
|
||||
|
||||
async fetchUserLogin(userId: number): Promise<string | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ login: UsersSchema.login }).from(UsersSchema)
|
||||
|
||||
@@ -31,5 +31,6 @@ export default class IntegrationsRoutes implements Routable {
|
||||
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);
|
||||
this.router.post('/webhook/gitea', this.controller.handleGiteaWebhook);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { appendFileSync } from 'fs';
|
||||
import type { IntegrationsDebugLogEntry } from './types';
|
||||
|
||||
// TEMPORARY debug instrumentation for the integrations OAuth flow.
|
||||
// Remove this file and all integrationsDebugLog() calls once the Gitea
|
||||
// connect issue is resolved.
|
||||
const LOG_PATH = '/private/tmp/claude-501/-Users-nikolaygiman-Programming-HandScreamInc-taskview/1d568266-6fbf-457c-83c2-5c5ca619edf1/scratchpad/integrations-debug.log';
|
||||
|
||||
export function integrationsDebugLog(entry: IntegrationsDebugLogEntry): void {
|
||||
try {
|
||||
appendFileSync(LOG_PATH, `${JSON.stringify({ ts: new Date().toISOString(), ...entry })}\n`);
|
||||
} catch {
|
||||
// debug logging must never break the flow
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import axios from 'axios';
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import type { GiteaCreateWebhookArgs, GiteaFetchIssuesArgs, GiteaUpdateIssueStateArgs, GiteaVerifyWebhookSignatureArgs } from '../types';
|
||||
|
||||
export const GITEA_BASE_URL = (process.env.GITEA_BASE_URL || 'https://gitea.com').replace(/\/+$/, '');
|
||||
const GITEA_API_URL = process.env.GITEA_API_URL || `${GITEA_BASE_URL}/api/v1`;
|
||||
|
||||
export type GiteaRepo = {
|
||||
id: number;
|
||||
full_name: string;
|
||||
name: string;
|
||||
private: boolean;
|
||||
description: string | null;
|
||||
html_url: string;
|
||||
};
|
||||
|
||||
export type GiteaIssue = {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
state: 'open' | 'closed';
|
||||
html_url: string;
|
||||
};
|
||||
|
||||
export function getGiteaOAuthUrl(state: string): string {
|
||||
const clientId = process.env.GITEA_INTEGRATION_CLIENT_ID;
|
||||
const redirectUri = process.env.GITEA_INTEGRATION_CALLBACK_URL;
|
||||
if (!clientId || !redirectUri) {
|
||||
throw new Error('Gitea integration OAuth is not configured');
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
state,
|
||||
});
|
||||
return `${GITEA_BASE_URL}/login/oauth/authorize?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function exchangeGiteaCode(code: string): Promise<{ accessToken: string; refreshToken: string | null }> {
|
||||
const res = await axios.post<{ access_token: string; refresh_token?: string; token_type: string }>(
|
||||
`${GITEA_BASE_URL}/login/oauth/access_token`,
|
||||
{
|
||||
client_id: process.env.GITEA_INTEGRATION_CLIENT_ID,
|
||||
client_secret: process.env.GITEA_INTEGRATION_CLIENT_SECRET,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: process.env.GITEA_INTEGRATION_CALLBACK_URL,
|
||||
},
|
||||
{
|
||||
headers: { Accept: 'application/json' },
|
||||
},
|
||||
);
|
||||
if (!res.data.access_token) {
|
||||
throw new Error('Failed to exchange Gitea code for token');
|
||||
}
|
||||
return {
|
||||
accessToken: res.data.access_token,
|
||||
refreshToken: res.data.refresh_token ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshGiteaToken(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> {
|
||||
const res = await axios.post<{ access_token: string; refresh_token: string; token_type: string }>(
|
||||
`${GITEA_BASE_URL}/login/oauth/access_token`,
|
||||
{
|
||||
client_id: process.env.GITEA_INTEGRATION_CLIENT_ID,
|
||||
client_secret: process.env.GITEA_INTEGRATION_CLIENT_SECRET,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
},
|
||||
{
|
||||
headers: { Accept: 'application/json' },
|
||||
},
|
||||
);
|
||||
if (!res.data.access_token) {
|
||||
throw new Error('Failed to refresh Gitea token');
|
||||
}
|
||||
return {
|
||||
accessToken: res.data.access_token,
|
||||
refreshToken: res.data.refresh_token,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyGiteaToken(accessToken: string): Promise<void> {
|
||||
await axios.get(`${GITEA_API_URL}/user`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchGiteaRepos(accessToken: string): Promise<GiteaRepo[]> {
|
||||
const repos: GiteaRepo[] = [];
|
||||
let page = 1;
|
||||
const perPage = 50;
|
||||
|
||||
while (true) {
|
||||
const res = await axios.get<GiteaRepo[]>(`${GITEA_API_URL}/user/repos`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
params: {
|
||||
limit: perPage,
|
||||
page,
|
||||
},
|
||||
});
|
||||
repos.push(...res.data);
|
||||
if (res.data.length < perPage) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
return repos;
|
||||
}
|
||||
|
||||
export async function fetchGiteaIssues(args: GiteaFetchIssuesArgs): Promise<GiteaIssue[]> {
|
||||
const issues: GiteaIssue[] = [];
|
||||
let page = 1;
|
||||
const perPage = 50;
|
||||
|
||||
while (true) {
|
||||
const res = await axios.get<GiteaIssue[]>(`${GITEA_API_URL}/repos/${args.repoFullName}/issues`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${args.accessToken}`,
|
||||
},
|
||||
params: {
|
||||
state: 'all',
|
||||
// Gitea returns pull requests from the issues endpoint too — this excludes them
|
||||
type: 'issues',
|
||||
limit: perPage,
|
||||
page,
|
||||
...(args.since ? { since: args.since } : {}),
|
||||
},
|
||||
});
|
||||
issues.push(...res.data);
|
||||
if (res.data.length < perPage) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
export async function createGiteaWebhook(args: GiteaCreateWebhookArgs): Promise<{ id: number }> {
|
||||
const res = await axios.post<{ id: number }>(
|
||||
`${GITEA_API_URL}/repos/${args.repoFullName}/hooks`,
|
||||
{
|
||||
type: 'gitea',
|
||||
active: true,
|
||||
events: ['issues'],
|
||||
config: {
|
||||
url: args.webhookUrl,
|
||||
content_type: 'json',
|
||||
secret: args.secret,
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${args.accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
return { id: res.data.id };
|
||||
}
|
||||
|
||||
export function verifyGiteaWebhookSignature(args: GiteaVerifyWebhookSignatureArgs): boolean {
|
||||
const expected = createHmac('sha256', args.secret).update(args.rawBody).digest('hex');
|
||||
try {
|
||||
return timingSafeEqual(Buffer.from(args.signature), Buffer.from(expected));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateGiteaIssueState(args: GiteaUpdateIssueStateArgs): Promise<void> {
|
||||
await axios.patch(
|
||||
`${GITEA_API_URL}/repos/${args.repoFullName}/issues/${args.issueNumber}`,
|
||||
{ state: args.state },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${args.accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type } from 'arktype';
|
||||
|
||||
export const IntegrationsArkTypeAdd = type({
|
||||
provider: "'github' | 'gitlab'",
|
||||
provider: "'github' | 'gitlab' | 'gitea'",
|
||||
repoFullName: 'string',
|
||||
projectId: 'number',
|
||||
});
|
||||
@@ -30,10 +30,43 @@ export const IntegrationsArkTypeSelectRepo = type({
|
||||
});
|
||||
export type IntegrationsArgSelectRepo = typeof IntegrationsArkTypeSelectRepo.infer;
|
||||
|
||||
export type IntegrationProvider = 'github' | 'gitlab' | 'gitea';
|
||||
|
||||
export type OAuthStatePayload = {
|
||||
userId: number;
|
||||
projectId: number;
|
||||
provider: 'github' | 'gitlab';
|
||||
provider: IntegrationProvider;
|
||||
};
|
||||
|
||||
export type GiteaFetchIssuesArgs = {
|
||||
accessToken: string;
|
||||
repoFullName: string;
|
||||
since?: string;
|
||||
};
|
||||
|
||||
export type GiteaCreateWebhookArgs = {
|
||||
accessToken: string;
|
||||
repoFullName: string;
|
||||
webhookUrl: string;
|
||||
secret: string;
|
||||
};
|
||||
|
||||
export type GiteaVerifyWebhookSignatureArgs = {
|
||||
rawBody: Buffer;
|
||||
signature: string;
|
||||
secret: string;
|
||||
};
|
||||
|
||||
export type GiteaUpdateIssueStateArgs = {
|
||||
accessToken: string;
|
||||
repoFullName: string;
|
||||
issueNumber: number;
|
||||
state: 'open' | 'closed';
|
||||
};
|
||||
|
||||
export type IntegrationsDebugLogEntry = {
|
||||
step: string;
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
export type RepoItemForClient = {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
title: GitHub & GitLab Setup
|
||||
description: Connect GitHub and GitLab repositories to TaskView. Import and sync issues as tasks with OAuth authorization, webhook-based real-time updates, and AES-256 encrypted token storage. Supports GitHub Enterprise and self-hosted GitLab.
|
||||
title: GitHub, GitLab & Gitea Setup
|
||||
description: Connect GitHub, GitLab and Gitea repositories to TaskView. Import and sync issues as tasks with OAuth authorization, webhook-based real-time updates, and AES-256 encrypted token storage. Supports GitHub Enterprise, self-hosted GitLab and self-hosted Gitea.
|
||||
navigation:
|
||||
icon: i-lucide-git-pull-request
|
||||
---
|
||||
|
||||
TaskView integrations allow you to connect GitHub or GitLab repositories to your projects. After connecting, issues from the repository are synced as tasks in TaskView and kept up to date via webhooks.
|
||||
TaskView integrations allow you to connect GitHub, GitLab or Gitea repositories to your projects. After connecting, issues from the repository are synced as tasks in TaskView and kept up to date via webhooks.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -81,7 +81,29 @@ GITLAB_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/
|
||||
|
||||
---
|
||||
|
||||
## 5. Full `.env.taskview` Example
|
||||
## 5. Create Gitea OAuth App (optional)
|
||||
|
||||
1. On [gitea.com](https://gitea.com) (or your own instance) go to **Settings → Applications → Manage OAuth2 Applications**
|
||||
2. Click **"Create Application"**
|
||||
3. Fill in:
|
||||
- **Application Name**: `TaskView Integrations`
|
||||
- **Redirect URIs**: `http://localhost:1401/module/integrations/oauth/gitea/callback`
|
||||
4. Click **"Create Application"**
|
||||
5. Copy **Client ID** and **Client Secret**
|
||||
|
||||
Add to `.env.taskview`:
|
||||
|
||||
```
|
||||
GITEA_INTEGRATION_CLIENT_ID=<your-client-id>
|
||||
GITEA_INTEGRATION_CLIENT_SECRET=<your-client-secret>
|
||||
GITEA_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/gitea/callback
|
||||
```
|
||||
|
||||
> **Note**: For self-hosted Gitea, also set `GITEA_BASE_URL=https://gitea.yourcompany.com` (defaults to `https://gitea.com`). The API URL is derived as `{GITEA_BASE_URL}/api/v1`; override with `GITEA_API_URL` only if it's served from a different address.
|
||||
|
||||
---
|
||||
|
||||
## 6. Full `.env.taskview` Example
|
||||
|
||||
```env
|
||||
# ... existing vars ...
|
||||
@@ -98,16 +120,21 @@ GITHUB_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/
|
||||
GITLAB_INTEGRATION_CLIENT_ID=app_id_123
|
||||
GITLAB_INTEGRATION_CLIENT_SECRET=secret_123
|
||||
GITLAB_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/gitlab/callback
|
||||
|
||||
# Gitea Integration OAuth (optional)
|
||||
GITEA_INTEGRATION_CLIENT_ID=client_id_123
|
||||
GITEA_INTEGRATION_CLIENT_SECRET=secret_123
|
||||
GITEA_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/gitea/callback
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Usage
|
||||
## 7. Usage
|
||||
|
||||
1. Open a project in TaskView
|
||||
2. Right-click the project in the sidebar → **"Integrations"**
|
||||
3. Click **"Add Integration"**
|
||||
4. Choose **GitHub** or **GitLab** - you'll be redirected to authorize
|
||||
4. Choose **GitHub**, **GitLab** or **Gitea** - you'll be redirected to authorize
|
||||
5. After authorization, select a repository from the list
|
||||
6. Done - the integration is active
|
||||
|
||||
@@ -119,7 +146,7 @@ You can toggle integrations on/off or delete them from the integrations page.
|
||||
|
||||
- **Callback URLs**: Update to your production domain (e.g., `https://api.yourdomain.com/module/integrations/oauth/github/callback`)
|
||||
- **ENCRYPTION_KEY**: Store securely, never commit to git. If changed, existing encrypted tokens become unreadable
|
||||
- **Separate OAuth Apps**: Create new GitHub/GitLab OAuth Apps for production with production callback URLs
|
||||
- **Separate OAuth Apps**: Create new GitHub/GitLab/Gitea OAuth Apps for production with production callback URLs
|
||||
- **CORS**: Ensure your production frontend domain is in `CORS_ALLOWED_ORIGINS`
|
||||
|
||||
---
|
||||
|
||||
@@ -112,7 +112,7 @@ If you change or lose the encryption key, all stored SSO configurations and inte
|
||||
|
||||
## GitHub Integration
|
||||
|
||||
For connecting GitHub repositories. See [GitHub & GitLab Setup](/docs/integrations/setup) for a step-by-step guide.
|
||||
For connecting GitHub repositories. See [GitHub, GitLab & Gitea Setup](/docs/integrations/setup) for a step-by-step guide.
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
@@ -134,6 +134,18 @@ For connecting GitLab repositories.
|
||||
| `GITLAB_BASE_URL` | No | `https://gitlab.com` | Override for self-hosted GitLab |
|
||||
| `GITLAB_API_URL` | No | `https://gitlab.com/api/v4` | Override for self-hosted GitLab API |
|
||||
|
||||
## Gitea Integration
|
||||
|
||||
For connecting Gitea repositories.
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `GITEA_INTEGRATION_CLIENT_ID` | No | - | OAuth2 application client ID |
|
||||
| `GITEA_INTEGRATION_CLIENT_SECRET` | No | - | OAuth2 application client secret |
|
||||
| `GITEA_INTEGRATION_CALLBACK_URL` | No | - | OAuth callback URL |
|
||||
| `GITEA_BASE_URL` | No | `https://gitea.com` | Override for self-hosted Gitea |
|
||||
| `GITEA_API_URL` | No | `{GITEA_BASE_URL}/api/v1` | Override for self-hosted Gitea API |
|
||||
|
||||
## Messaging Integrations (Telegram / Slack)
|
||||
|
||||
For delivering task notifications to messengers. See [Telegram & Slack Setup](/docs/integrations/messaging) for a step-by-step guide.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type IntegrationProvider = 'github' | 'gitlab';
|
||||
export type IntegrationProvider = 'github' | 'gitlab' | 'gitea';
|
||||
|
||||
export type IntegrationItem = {
|
||||
id: number;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { IconifyJSON } from '@iconify/vue'
|
||||
|
||||
export const tvIconsCollection: IconifyJSON = {
|
||||
prefix: 'tv',
|
||||
width: 24,
|
||||
height: 24,
|
||||
icons: {
|
||||
gitea: {
|
||||
body: '<path fill="currentColor" d="M4.209 4.603c-.247 0-.525.02-.84.088c-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768c1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367c2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068c0 0 2.107-4.471 2.107-8.823c-.042-1.318-.367-1.55-.443-1.627c-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17c-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12m8.33 2.554c.26.003.509.127.509.127l.868.422l-.529 1.075a.69.69 0 0 0-.614.359a.69.69 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527a.69.69 0 0 0 .347.763a.686.686 0 0 0 .867-.206a.69.69 0 0 0-.069-.882l.916-1.874a.7.7 0 0 0 .237-.02a.66.66 0 0 0 .271-.137a9 9 0 0 1 1.016.512a.76.76 0 0 1 .286.282c.073.21-.073.569-.073.569c-.087.29-.702 1.55-.702 1.55a.69.69 0 0 0-.676.477a.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431c.19-.397.515-1.16.515-1.16c.035-.066.218-.394.103-.814c-.095-.435-.48-.638-.48-.638c-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.7.7 0 0 0-.148-.241l.516-1.062l2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657c-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.1 1.1 0 0 1-.393-.045l-.202-.08l-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.9.9 0 0 1 .35-.077"/>',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { addCollection } from '@iconify/vue'
|
||||
import lucide from '@iconify-json/lucide/icons.json'
|
||||
import mdi from '@iconify-json/mdi/icons.json'
|
||||
import carbon from '@iconify-json/carbon/icons.json'
|
||||
import { tvIconsCollection } from '@/assets/tvIconsCollection'
|
||||
import { createPinia } from 'pinia'
|
||||
import type { Plugin } from 'vue'
|
||||
import { createApp } from 'vue'
|
||||
@@ -137,6 +138,7 @@ export async function createTaskviewApp(options: CreateTaskviewAppOptions = {})
|
||||
addCollection(lucide)
|
||||
addCollection(mdi)
|
||||
addCollection(carbon)
|
||||
addCollection(tvIconsCollection)
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<UPopover
|
||||
v-if="withActivator"
|
||||
v-model:open="open"
|
||||
:content="{ align: 'start' }"
|
||||
:content="{ align: 'start', collisionPadding: 8 }"
|
||||
:ui="{ content: 'rounded-2xl' }"
|
||||
>
|
||||
<UButton
|
||||
@@ -23,7 +23,7 @@
|
||||
</UButton>
|
||||
|
||||
<template #content>
|
||||
<div class="p-2 min-w-56">
|
||||
<div class="p-2 min-w-56 overflow-y-auto max-h-[min(24rem,var(--reka-popover-content-available-height))]">
|
||||
<TvDropdownOptions
|
||||
v-model="model"
|
||||
:items="items"
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<UIcon
|
||||
:name="item.provider === 'github' ? 'i-mdi-github' : 'i-mdi-gitlab'"
|
||||
:name="INTEGRATION_PROVIDERS[item.provider].icon"
|
||||
class="size-6"
|
||||
/>
|
||||
<div>
|
||||
@@ -63,7 +63,7 @@
|
||||
{{ t('integrations.pendingSelectRepo') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted">
|
||||
{{ item.provider === 'github' ? 'GitHub' : 'GitLab' }}
|
||||
{{ INTEGRATION_PROVIDERS[item.provider].label }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -125,6 +125,7 @@ import { useGoalPermissions } from '@/composables/useGoalPermissions'
|
||||
import IntegrationItem from './parts/IntegrationItem.vue'
|
||||
import AddIntegrationModal from './parts/AddIntegrationModal.vue'
|
||||
import RepoSelectModal from './parts/RepoSelectModal.vue'
|
||||
import { INTEGRATION_PROVIDERS } from '@/components/features/integrations/integrationProviders'
|
||||
import type { IntegrationItem as IntegrationItemType, RepoItem } from 'taskview-api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { IntegrationProvider } from 'taskview-api'
|
||||
import type { IntegrationProviderMeta } from './integrationProviders.types'
|
||||
|
||||
export const INTEGRATION_PROVIDERS: Record<IntegrationProvider, IntegrationProviderMeta> = {
|
||||
github: { label: 'GitHub', icon: 'i-mdi-github' },
|
||||
gitlab: { label: 'GitLab', icon: 'i-mdi-gitlab' },
|
||||
gitea: { label: 'Gitea', icon: 'i-tv-gitea' },
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export type IntegrationProviderMeta = {
|
||||
label: string
|
||||
icon: string
|
||||
}
|
||||
@@ -12,20 +12,14 @@
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<UButton
|
||||
label="GitHub"
|
||||
icon="i-mdi-github"
|
||||
v-for="(meta, provider) in INTEGRATION_PROVIDERS"
|
||||
:key="provider"
|
||||
:label="meta.label"
|
||||
:icon="meta.icon"
|
||||
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')"
|
||||
@click="startOAuth(provider)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -37,6 +31,7 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useTaskViewMainUrl } from '@/composables/useTaskViewMainUrl'
|
||||
import $api from '@/helpers/axios'
|
||||
import { INTEGRATION_PROVIDERS } from '@/components/features/integrations/integrationProviders'
|
||||
import type { IntegrationProvider } from 'taskview-api'
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -46,6 +46,7 @@ import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { IntegrationItem } from 'taskview-api'
|
||||
import { useIntegrationsStore } from '@/stores/integrations.store'
|
||||
import { INTEGRATION_PROVIDERS } from '@/components/features/integrations/integrationProviders'
|
||||
|
||||
const { t } = useI18n()
|
||||
const integrationsStore = useIntegrationsStore()
|
||||
@@ -63,9 +64,9 @@ defineEmits<{
|
||||
|
||||
const syncing = ref(false)
|
||||
|
||||
const providerIcon = computed(() => props.integration.provider === 'github' ? 'i-mdi-github' : 'i-mdi-gitlab')
|
||||
const providerIcon = computed(() => INTEGRATION_PROVIDERS[props.integration.provider].icon)
|
||||
|
||||
const providerLabel = computed(() => props.integration.provider === 'github' ? 'GitHub' : 'GitLab')
|
||||
const providerLabel = computed(() => INTEGRATION_PROVIDERS[props.integration.provider].label)
|
||||
|
||||
async function handleSync() {
|
||||
syncing.value = true
|
||||
|
||||
@@ -31,24 +31,13 @@
|
||||
</UTextarea>
|
||||
</div>
|
||||
</div>
|
||||
<TaskIdCopy
|
||||
:task-id="task.id"
|
||||
class="self-start -mt-2"
|
||||
/>
|
||||
<!-- Source link (GitHub/GitLab issue) -->
|
||||
<a
|
||||
v-if="task.sourceUrl"
|
||||
:href="task.sourceUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-2 mb-2 text-sm text-muted hover:text-default transition-colors"
|
||||
>
|
||||
<UIcon
|
||||
:name="task.sourceUrl.includes('github') ? 'i-lucide-github' : task.sourceUrl.includes('gitlab') ? 'i-lucide-gitlab' : 'i-lucide-external-link'"
|
||||
class="size-4 shrink-0"
|
||||
<div class="flex items-center flex-wrap gap-x-1 min-w-0 -mt-2 mb-1">
|
||||
<TaskIdCopy :task-id="task.id" />
|
||||
<TaskSourceLink
|
||||
v-if="task.sourceUrl"
|
||||
:source-url="task.sourceUrl"
|
||||
/>
|
||||
<span class="truncate underline underline-offset-2">{{ task.sourceUrl }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 @lg:grid-cols-2 gap-4">
|
||||
<template
|
||||
@@ -178,6 +167,7 @@ import TaskTimeTracking from '@/components/features/tasks/parts/TaskTimeTracking
|
||||
import TaskHistory from '@/components/features/tasks/parts/TaskHistory.vue'
|
||||
import TvDeadlineSelect from '@/components/features/base/TvDeadlineSelect.vue'
|
||||
import TaskRecurrence from '@/components/features/tasks/parts/TaskRecurrence.vue'
|
||||
import TaskSourceLink from '@/components/features/tasks/parts/TaskSourceLink.vue'
|
||||
import TaskSubtasks from '@/components/features/tasks/parts/TaskSubtasks.vue'
|
||||
import TaskIdCopy from '@/components/features/tasks/parts/TaskIdCopy.vue'
|
||||
import TvSprintSelect from '@/components/features/base/TvSprintSelect.vue'
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="flex items-center min-w-0">
|
||||
<UButton
|
||||
:href="sourceUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="link"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
:icon="provider.icon"
|
||||
:title="sourceUrl"
|
||||
class="min-w-0 text-muted hover:text-default"
|
||||
:ui="{ label: 'truncate' }"
|
||||
data-testid="task-source-link"
|
||||
>
|
||||
{{ issueRef }}
|
||||
</UButton>
|
||||
<UPopover :ui="{ content: 'rounded-2xl' }">
|
||||
<UButton
|
||||
icon="i-lucide-info"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="xs"
|
||||
class="text-muted hover:text-default"
|
||||
:aria-label="t('tasks.sourceOfTruthTitle', { provider: provider.label })"
|
||||
data-testid="task-source-info"
|
||||
/>
|
||||
<template #content>
|
||||
<div class="p-3 max-w-72 flex flex-col gap-1 text-sm">
|
||||
<p class="font-medium">
|
||||
{{ t('tasks.sourceOfTruthTitle', { provider: provider.label }) }}
|
||||
</p>
|
||||
<p class="text-muted">
|
||||
{{ t('tasks.sourceOfTruthDescription', { provider: provider.label }) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { INTEGRATION_PROVIDERS } from '@/components/features/integrations/integrationProviders'
|
||||
import type { IntegrationProviderMeta } from '@/components/features/integrations/integrationProviders.types'
|
||||
|
||||
const props = defineProps<{
|
||||
sourceUrl: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const provider = computed<IntegrationProviderMeta>(() => {
|
||||
const url = props.sourceUrl
|
||||
if (url.includes('github')) return INTEGRATION_PROVIDERS.github
|
||||
if (url.includes('gitlab')) return INTEGRATION_PROVIDERS.gitlab
|
||||
if (url.includes('gitea')) return INTEGRATION_PROVIDERS.gitea
|
||||
try {
|
||||
return { label: new URL(url).hostname, icon: 'i-lucide-external-link' }
|
||||
} catch {
|
||||
return { label: url, icon: 'i-lucide-external-link' }
|
||||
}
|
||||
})
|
||||
|
||||
// "owner/repo#1" — the way git hosting itself writes issue references.
|
||||
// GitLab's extra "-" path segment is dropped; anything unparseable falls
|
||||
// back to the raw URL.
|
||||
const issueRef = computed(() => {
|
||||
try {
|
||||
const url = new URL(props.sourceUrl)
|
||||
const segments = url.pathname.split('/').filter((s) => s && s !== '-')
|
||||
const issuesIndex = segments.indexOf('issues')
|
||||
if (issuesIndex > 0 && segments[issuesIndex + 1]) {
|
||||
return `${segments.slice(0, issuesIndex).join('/')}#${segments[issuesIndex + 1]}`
|
||||
}
|
||||
} catch {
|
||||
// not a URL — fall through to raw value
|
||||
}
|
||||
return props.sourceUrl
|
||||
})
|
||||
</script>
|
||||
@@ -11,7 +11,6 @@
|
||||
icon="i-lucide-calendar"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="flex-1"
|
||||
:ui="{ base: 'rounded-xl justify-start' }"
|
||||
/>
|
||||
@@ -38,7 +37,6 @@
|
||||
icon="i-lucide-calendar-check"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="flex-1"
|
||||
:ui="{ base: 'rounded-xl justify-start' }"
|
||||
/>
|
||||
@@ -62,7 +60,7 @@
|
||||
<UFormField :label="t('timeTracking.description')">
|
||||
<UInput
|
||||
v-model="description"
|
||||
size="lg"
|
||||
size="xl"
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
:ui="{ base: 'rounded-xl' }"
|
||||
|
||||
@@ -119,7 +119,6 @@ onMounted(async () => {
|
||||
orgStore.restoreCurrentOrg()
|
||||
if (orgStore.currentOrg) {
|
||||
router.replace({ name: 'user', params: { orgSlug: orgStore.currentOrgSlug } })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -504,6 +504,8 @@ export default {
|
||||
addDescription: 'Beschreibung hinzufügen...',
|
||||
note: 'Notiz',
|
||||
addNote: 'Notiz hinzufügen...',
|
||||
sourceOfTruthTitle: '{provider} ist die Datenquelle',
|
||||
sourceOfTruthDescription: 'Diese Aufgabe ist mit einem Issue in {provider} verknüpft. Werden dort Titel oder Beschreibung geändert, wird die Aufgabe automatisch aktualisiert.',
|
||||
priority: 'Priorität',
|
||||
priorityNone: 'Keine Priorität',
|
||||
priorityLow: 'Niedrig',
|
||||
|
||||
@@ -518,6 +518,8 @@ export default {
|
||||
addDescription: 'Add a description...',
|
||||
note: 'Note',
|
||||
addNote: 'Add a note...',
|
||||
sourceOfTruthTitle: '{provider} is the source of truth',
|
||||
sourceOfTruthDescription: 'This task is linked to an issue in {provider}. If the title or description is edited there, the task is updated automatically.',
|
||||
priority: 'Priority',
|
||||
priorityNone: 'No priority',
|
||||
priorityLow: 'Low',
|
||||
|
||||
@@ -504,6 +504,8 @@ export default {
|
||||
addDescription: 'Añade una descripción...',
|
||||
note: 'Nota',
|
||||
addNote: 'Añade una nota...',
|
||||
sourceOfTruthTitle: '{provider} es la fuente de verdad',
|
||||
sourceOfTruthDescription: 'Esta tarea está vinculada a una issue en {provider}. Si allí se edita el título o la descripción, la tarea se actualiza automáticamente.',
|
||||
priority: 'Prioridad',
|
||||
priorityNone: 'Sin prioridad',
|
||||
priorityLow: 'Baja',
|
||||
|
||||
@@ -491,6 +491,8 @@ export default {
|
||||
addDescription: 'Добавить описание...',
|
||||
note: 'Заметка',
|
||||
addNote: 'Добавить заметку...',
|
||||
sourceOfTruthTitle: 'Источник истины — {provider}',
|
||||
sourceOfTruthDescription: 'Задача связана с issue в {provider}. Если там изменят название или описание, задача обновится автоматически.',
|
||||
priority: 'Приоритет',
|
||||
priorityNone: 'Без приоритета',
|
||||
priorityLow: 'Низкий',
|
||||
|
||||
@@ -124,6 +124,10 @@ export const uiPluginOptions: NuxtUIOptions = {
|
||||
leadingIcon: 'size-4.5',
|
||||
leading: 'ps-3.5',
|
||||
},
|
||||
// 16px minimum — anything smaller makes iOS Safari zoom in on focus
|
||||
lg: {
|
||||
base: 'text-base',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user