diff --git a/api/.env.example b/api/.env.example index bbb732c..6507d50 100644 --- a/api/.env.example +++ b/api/.env.example @@ -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 diff --git a/api/src/migrations/taskview/migrate.json b/api/src/migrations/taskview/migrate.json index c401e45..3ff5c95 100644 --- a/api/src/migrations/taskview/migrate.json +++ b/api/src/migrations/taskview/migrate.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'" + ] } } diff --git a/api/src/migrations/taskview/sql/1.61.0/0.alter-integrations-provider-check-gitea.sql b/api/src/migrations/taskview/sql/1.61.0/0.alter-integrations-provider-check-gitea.sql new file mode 100644 index 0000000..718ae82 --- /dev/null +++ b/api/src/migrations/taskview/sql/1.61.0/0.alter-integrations-provider-check-gitea.sql @@ -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')); diff --git a/api/src/tv-modules/integrations/IntegrationsController.ts b/api/src/tv-modules/integrations/IntegrationsController.ts index 4853cc6..df2eb39 100644 --- a/api/src/tv-modules/integrations/IntegrationsController.ts +++ b/api/src/tv-modules/integrations/IntegrationsController.ts @@ -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; diff --git a/api/src/tv-modules/integrations/IntegrationsManager.ts b/api/src/tv-modules/integrations/IntegrationsManager.ts index bc8201c..cee070b 100644 --- a/api/src/tv-modules/integrations/IntegrationsManager.ts +++ b/api/src/tv-modules/integrations/IntegrationsManager.ts @@ -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 { @@ -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 { 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; } } diff --git a/api/src/tv-modules/integrations/IntegrationsRepository.ts b/api/src/tv-modules/integrations/IntegrationsRepository.ts index 55a04a0..6cbd85a 100644 --- a/api/src/tv-modules/integrations/IntegrationsRepository.ts +++ b/api/src/tv-modules/integrations/IntegrationsRepository.ts @@ -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 { + 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 { const result = await callWithCatch(() => this.db.dbDrizzle.select({ login: UsersSchema.login }).from(UsersSchema) diff --git a/api/src/tv-modules/integrations/IntegrationsRoutes.ts b/api/src/tv-modules/integrations/IntegrationsRoutes.ts index 3c7ee6f..c1b862e 100644 --- a/api/src/tv-modules/integrations/IntegrationsRoutes.ts +++ b/api/src/tv-modules/integrations/IntegrationsRoutes.ts @@ -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); } } diff --git a/api/src/tv-modules/integrations/debugLog.ts b/api/src/tv-modules/integrations/debugLog.ts new file mode 100644 index 0000000..6adfb8a --- /dev/null +++ b/api/src/tv-modules/integrations/debugLog.ts @@ -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 + } +} diff --git a/api/src/tv-modules/integrations/providers/gitea.provider.ts b/api/src/tv-modules/integrations/providers/gitea.provider.ts new file mode 100644 index 0000000..8fa3d88 --- /dev/null +++ b/api/src/tv-modules/integrations/providers/gitea.provider.ts @@ -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 { + await axios.get(`${GITEA_API_URL}/user`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); +} + +export async function fetchGiteaRepos(accessToken: string): Promise { + const repos: GiteaRepo[] = []; + let page = 1; + const perPage = 50; + + while (true) { + const res = await axios.get(`${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 { + const issues: GiteaIssue[] = []; + let page = 1; + const perPage = 50; + + while (true) { + const res = await axios.get(`${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 { + await axios.patch( + `${GITEA_API_URL}/repos/${args.repoFullName}/issues/${args.issueNumber}`, + { state: args.state }, + { + headers: { + Authorization: `Bearer ${args.accessToken}`, + }, + }, + ); +} diff --git a/api/src/tv-modules/integrations/types.ts b/api/src/tv-modules/integrations/types.ts index d4ee27b..2627848 100644 --- a/api/src/tv-modules/integrations/types.ts +++ b/api/src/tv-modules/integrations/types.ts @@ -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 = { diff --git a/docs/3.integrations/1.setup.md b/docs/3.integrations/1.setup.md index 64864d8..bfe9baf 100644 --- a/docs/3.integrations/1.setup.md +++ b/docs/3.integrations/1.setup.md @@ -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= +GITEA_INTEGRATION_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` --- diff --git a/docs/4.configuration/1.environment-variables.md b/docs/4.configuration/1.environment-variables.md index 95c5ed5..093c29b 100644 --- a/docs/4.configuration/1.environment-variables.md +++ b/docs/4.configuration/1.environment-variables.md @@ -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. diff --git a/taskview-packages/taskview-api/src/api/integrations.types.ts b/taskview-packages/taskview-api/src/api/integrations.types.ts index 7a12945..855eff3 100644 --- a/taskview-packages/taskview-api/src/api/integrations.types.ts +++ b/taskview-packages/taskview-api/src/api/integrations.types.ts @@ -1,4 +1,4 @@ -export type IntegrationProvider = 'github' | 'gitlab'; +export type IntegrationProvider = 'github' | 'gitlab' | 'gitea'; export type IntegrationItem = { id: number; diff --git a/web/src/assets/tvIconsCollection.ts b/web/src/assets/tvIconsCollection.ts new file mode 100644 index 0000000..6f8041b --- /dev/null +++ b/web/src/assets/tvIconsCollection.ts @@ -0,0 +1,12 @@ +import type { IconifyJSON } from '@iconify/vue' + +export const tvIconsCollection: IconifyJSON = { + prefix: 'tv', + width: 24, + height: 24, + icons: { + gitea: { + body: '', + }, + }, +} diff --git a/web/src/bootstrap.ts b/web/src/bootstrap.ts index 65d43a2..d329aab 100644 --- a/web/src/bootstrap.ts +++ b/web/src/bootstrap.ts @@ -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) diff --git a/web/src/components/features/base/TvDropdown.vue b/web/src/components/features/base/TvDropdown.vue index 514f9c9..4d15c27 100644 --- a/web/src/components/features/base/TvDropdown.vue +++ b/web/src/components/features/base/TvDropdown.vue @@ -2,7 +2,7 @@