From 4ac46d2d3cd019b571039d05dfa676ce9615a09e Mon Sep 17 00:00:00 2001 From: alma Date: Tue, 6 May 2025 19:18:23 +0200 Subject: [PATCH] missions api2 --- lib/services/gitea-service.ts | 273 ++++++++++++++++++++++++++++ lib/services/integration-service.ts | 77 +++++++- 2 files changed, 342 insertions(+), 8 deletions(-) create mode 100644 lib/services/gitea-service.ts diff --git a/lib/services/gitea-service.ts b/lib/services/gitea-service.ts new file mode 100644 index 00000000..ef53081c --- /dev/null +++ b/lib/services/gitea-service.ts @@ -0,0 +1,273 @@ +import axios from 'axios'; + +export class GiteaService { + private apiUrl: string; + private apiToken: string; + private owner: string; + + constructor() { + this.apiUrl = process.env.GITEA_API_URL || ''; + this.apiToken = process.env.GITEA_API_TOKEN || ''; + this.owner = process.env.GITEA_OWNER || ''; + console.log('GiteaService initialized with URL:', this.apiUrl); + } + + /** + * Get the properly formatted API endpoint + */ + private getApiEndpoint(path: string): string { + const baseUrl = this.apiUrl.endsWith('/') + ? this.apiUrl.slice(0, -1) + : this.apiUrl; + + const pathWithoutLeadingSlash = path.startsWith('/') + ? path.slice(1) + : path; + + return `${baseUrl}/api/v1/${pathWithoutLeadingSlash}`; + } + + /** + * Create a new repository for a mission + * @param mission The mission data + * @returns Repository info or throws error + */ + async createRepository(mission: any): Promise { + try { + // Create a repo name from the mission name + // Replace spaces with dashes and remove special characters + const repoName = mission.name + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, ''); + + console.log(`Creating repository: ${repoName}`); + + // Determine if we're creating under a user or an organization + const endpoint = this.owner.startsWith('@') + ? this.getApiEndpoint(`org/${this.owner.substring(1)}/repos`) + : this.getApiEndpoint('user/repos'); + + // Create the repository + const response = await axios.post( + endpoint, + { + name: repoName, + description: mission.intention || mission.name, + private: true, + auto_init: true, + gitignores: 'Node', + license: 'MIT', + readme: 'Default', + default_branch: 'main' + }, + { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `token ${this.apiToken}` + } + } + ); + + console.log(`Repository created: ${response.data.full_name}`); + + // If the mission has users, add them as collaborators + if (mission.missionUsers && mission.missionUsers.length > 0) { + await this.addCollaborators(response.data.owner.login, repoName, mission.missionUsers); + } + + return response.data; + } catch (error) { + if (axios.isAxiosError(error) && error.response) { + console.error('Gitea API Error:', { + status: error.response.status, + data: error.response.data + }); + } + console.error('Error creating Gitea repository:', error); + throw new Error(`Gitea integration failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * Add collaborators to a repository + * @param owner Repository owner + * @param repo Repository name + * @param missionUsers The mission users to add as collaborators + */ + async addCollaborators(owner: string, repo: string, missionUsers: any[]): Promise { + console.log(`Adding ${missionUsers.length} collaborators to ${owner}/${repo}`); + + const addCollaboratorPromises = missionUsers.map(async (missionUser) => { + try { + // Skip if no username is provided + if (!missionUser.user.username) { + console.log(`No username for user ${missionUser.user.email}, skipping`); + return; + } + + // Determine permission level based on role + // For Gitea: 'read', 'write', 'admin' + let permission = 'write'; + if (missionUser.role === 'gardien-temps') { + permission = 'admin'; + } else if (missionUser.role === 'observateur') { + permission = 'read'; + } + + console.log(`Adding ${missionUser.user.username} as ${permission}`); + + await axios.put( + this.getApiEndpoint(`repos/${owner}/${repo}/collaborators/${missionUser.user.username}`), + { permission }, + { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `token ${this.apiToken}` + } + } + ); + + console.log(`Added ${missionUser.user.username} as collaborator`); + } catch (error) { + console.error(`Error adding collaborator ${missionUser.user.username}:`, error); + // Continue with other collaborators even if one fails + } + }); + + // Wait for all collaborator additions to complete + await Promise.all(addCollaboratorPromises); + console.log('Finished adding collaborators'); + } + + /** + * Delete a repository + * @param owner Repository owner + * @param repo Repository name + * @returns True if successful, false otherwise + */ + async deleteRepository(owner: string, repo: string): Promise { + try { + await axios.delete( + this.getApiEndpoint(`repos/${owner}/${repo}`), + { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `token ${this.apiToken}` + } + } + ); + + console.log(`Repository ${owner}/${repo} deleted successfully`); + return true; + } catch (error) { + console.error(`Error deleting repository ${owner}/${repo}:`, error); + return false; + } + } + + /** + * Create a new branch in a repository + * @param owner Repository owner + * @param repo Repository name + * @param branchName New branch name + * @param baseBranchName Base branch name (default: 'main') + * @returns True if successful, false otherwise + */ + async createBranch( + owner: string, + repo: string, + branchName: string, + baseBranchName: string = 'main' + ): Promise { + try { + // First, get the SHA of the base branch + const refResponse = await axios.get( + this.getApiEndpoint(`repos/${owner}/${repo}/git/refs/heads/${baseBranchName}`), + { + headers: { + 'Authorization': `token ${this.apiToken}` + } + } + ); + + const sha = refResponse.data.object.sha; + + // Create the new branch + await axios.post( + this.getApiEndpoint(`repos/${owner}/${repo}/git/refs`), + { + ref: `refs/heads/${branchName}`, + sha: sha + }, + { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `token ${this.apiToken}` + } + } + ); + + console.log(`Branch ${branchName} created in ${owner}/${repo}`); + return true; + } catch (error) { + console.error(`Error creating branch ${branchName} in ${owner}/${repo}:`, error); + return false; + } + } + + /** + * Create a file in a repository + * @param owner Repository owner + * @param repo Repository name + * @param path File path + * @param content File content (will be base64 encoded) + * @param message Commit message + * @param branch Branch name (default: 'main') + * @returns True if successful, false otherwise + */ + async createFile( + owner: string, + repo: string, + path: string, + content: string, + message: string, + branch: string = 'main' + ): Promise { + try { + // Convert content to base64 + const contentBase64 = Buffer.from(content).toString('base64'); + + await axios.post( + this.getApiEndpoint(`repos/${owner}/${repo}/contents/${path}`), + { + message, + content: contentBase64, + branch + }, + { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `token ${this.apiToken}` + } + } + ); + + console.log(`File ${path} created in ${owner}/${repo}`); + return true; + } catch (error) { + console.error(`Error creating file ${path} in ${owner}/${repo}:`, error); + return false; + } + } + + /** + * Get repository URL + * @param repoName Repository name + * @returns Full repository URL + */ + getRepositoryUrl(repoName: string): string { + const baseUrl = this.apiUrl.replace('/api/v1', ''); + return `${baseUrl}/${this.owner}/${repoName}`; + } +} \ No newline at end of file diff --git a/lib/services/integration-service.ts b/lib/services/integration-service.ts index bcf47cbd..654cf4b1 100644 --- a/lib/services/integration-service.ts +++ b/lib/services/integration-service.ts @@ -2,6 +2,7 @@ import { prisma } from '@/lib/prisma'; import { LeantimeService } from './leantime-service'; import { OutlineService } from './outline-service'; import { RocketChatService } from './rocketchat-service'; +import { GiteaService } from './gitea-service'; import axios from 'axios'; interface IntegrationResult { @@ -20,11 +21,13 @@ export class IntegrationService { private leantimeService: LeantimeService; private outlineService: OutlineService; private rocketChatService: RocketChatService; + private giteaService: GiteaService; constructor() { this.leantimeService = new LeantimeService(); this.outlineService = new OutlineService(); this.rocketChatService = new RocketChatService(); + this.giteaService = new GiteaService(); } /** @@ -55,12 +58,14 @@ export class IntegrationService { let leantimeProjectId: number | undefined; let outlineCollectionId: string | undefined; let rocketChatChannelId: string | undefined; + let giteaRepositoryUrl: string | undefined; // Track which integrations succeeded and which failed const integrationStatus = { leantime: { success: false, id: undefined as number | undefined, error: undefined as string | undefined }, outline: { success: false, id: undefined as string | undefined, error: undefined as string | undefined }, - rocketchat: { success: false, id: undefined as string | undefined, error: undefined as string | undefined } + rocketchat: { success: false, id: undefined as string | undefined, error: undefined as string | undefined }, + gitea: { success: false, url: undefined as string | undefined, error: undefined as string | undefined } }; // A flag to determine if we should consider this a success or failure overall @@ -131,14 +136,43 @@ export class IntegrationService { // Don't set criticalFailure - Rocket.Chat is non-critical } + // Add a delay to avoid rate limits (extended to 3 seconds) + console.log('Waiting 3 seconds before proceeding to Gitea integration...'); + await new Promise(resolve => setTimeout(resolve, 3000)); + + // Step 4: Create Gitea repository (Consider this non-critical) + try { + console.log('Starting Gitea repository creation...'); + const repoData = await this.giteaService.createRepository(mission); + if (repoData && repoData.html_url) { + giteaRepositoryUrl = repoData.html_url; + } else { + giteaRepositoryUrl = this.giteaService.getRepositoryUrl( + mission.name + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, '') + ); + } + console.log(`Gitea repository created at: ${giteaRepositoryUrl}`); + integrationStatus.gitea.success = true; + integrationStatus.gitea.url = giteaRepositoryUrl; + } catch (giteaError) { + console.error('Error creating Gitea repository:', giteaError); + integrationStatus.gitea.success = false; + integrationStatus.gitea.error = giteaError instanceof Error ? giteaError.message : String(giteaError); + + // Don't set criticalFailure - Gitea is non-critical + } + // Update the mission with the integration IDs (only for successful integrations) await prisma.mission.update({ where: { id: missionId }, data: { leantimeProjectId: integrationStatus.leantime.success ? leantimeProjectId?.toString() : undefined, outlineCollectionId: integrationStatus.outline.success ? outlineCollectionId : undefined, - rocketChatChannelId: integrationStatus.rocketchat.success ? rocketChatChannelId : undefined - // giteaRepositoryUrl and penpotProjectId will be added when implemented + rocketChatChannelId: integrationStatus.rocketchat.success ? rocketChatChannelId : undefined, + giteaRepositoryUrl: integrationStatus.gitea.success ? giteaRepositoryUrl : undefined } }); @@ -152,14 +186,15 @@ export class IntegrationService { data: { leantimeProjectId, outlineCollectionId, - rocketChatChannelId + rocketChatChannelId, + giteaRepositoryUrl } }; } catch (error) { console.error('Error setting up integrations:', error); // Rollback any created resources - await this.rollbackIntegrations(leantimeProjectId, outlineCollectionId, rocketChatChannelId); + await this.rollbackIntegrations(leantimeProjectId, outlineCollectionId, rocketChatChannelId, giteaRepositoryUrl); // Delete the mission itself since a critical integration failed try { @@ -190,11 +225,13 @@ export class IntegrationService { * @param leantimeProjectId The Leantime project ID to delete * @param outlineCollectionId The Outline collection ID to delete * @param rocketChatChannelId The Rocket.Chat channel ID to delete + * @param giteaRepositoryUrl The Gitea repository URL to extract owner/repo from for deletion */ private async rollbackIntegrations( leantimeProjectId?: number, outlineCollectionId?: string, - rocketChatChannelId?: string + rocketChatChannelId?: string, + giteaRepositoryUrl?: string ): Promise { console.log('⚠️ Rolling back integrations due to an error...'); @@ -202,7 +239,8 @@ export class IntegrationService { const rollbackStatuses = { leantime: false, outline: false, - rocketchat: false + rocketchat: false, + gitea: false }; try { @@ -247,17 +285,39 @@ export class IntegrationService { const rocketChatSuccess = await this.rocketChatService.deleteChannel(rocketChatChannelId); rollbackStatuses.rocketchat = rocketChatSuccess; console.log(`Rocket.Chat channel deletion ${rocketChatSuccess ? 'successful' : 'failed'}: ${rocketChatChannelId}`); + + // Add a longer delay to avoid rate limiting (3 seconds) + console.log('Waiting 3 seconds before next rollback operation...'); + await new Promise(resolve => setTimeout(resolve, 3000)); } catch (rocketChatError) { console.error('Error during Rocket.Chat rollback:', rocketChatError); console.log(`⚠️ Note: Rocket.Chat channel ${rocketChatChannelId} may need to be deleted manually`); } } + // Attempt to delete Gitea repository + if (giteaRepositoryUrl) { + try { + // Extract owner and repo from URL + const urlParts = giteaRepositoryUrl.split('/'); + const owner = urlParts[urlParts.length - 2]; + const repo = urlParts[urlParts.length - 1]; + + console.log(`Attempting to delete Gitea repository: ${owner}/${repo}`); + const giteaSuccess = await this.giteaService.deleteRepository(owner, repo); + rollbackStatuses.gitea = giteaSuccess; + console.log(`Gitea repository deletion ${giteaSuccess ? 'successful' : 'failed'}: ${owner}/${repo}`); + } catch (giteaError) { + console.error('Error during Gitea rollback:', giteaError); + console.log(`⚠️ Note: Gitea repository at ${giteaRepositoryUrl} may need to be deleted manually`); + } + } + // Provide a summary of rollback operations console.log('Rollback summary:', JSON.stringify(rollbackStatuses)); // If any rollbacks failed, provide a note - if (!rollbackStatuses.leantime || !rollbackStatuses.outline || !rollbackStatuses.rocketchat) { + if (!rollbackStatuses.leantime || !rollbackStatuses.outline || !rollbackStatuses.rocketchat || !rollbackStatuses.gitea) { console.log('⚠️ Some resources may need to be deleted manually.'); } } catch (error) { @@ -266,6 +326,7 @@ export class IntegrationService { if (leantimeProjectId) console.log(`- Leantime project: ${leantimeProjectId}`); if (outlineCollectionId) console.log(`- Outline collection: ${outlineCollectionId}`); if (rocketChatChannelId) console.log(`- Rocket.Chat channel: ${rocketChatChannelId}`); + if (giteaRepositoryUrl) console.log(`- Gitea repository: ${giteaRepositoryUrl}`); } } } \ No newline at end of file