Created
March 16, 2026 21:05
-
-
Save vdmgolub/03cfe5f2a3354583322ee68b8ce9626a to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* eslint-disable camelcase */ | |
| import axios from 'axios'; | |
| import { err, ok, Result } from 'neverthrow'; | |
| import { Logger } from 'pino'; | |
| import semver from 'semver'; | |
| import { sleep } from '../../utils/helpers'; | |
| import { GitlabTokenRefreshError } from '../errors'; | |
| import RestApiClient from './restApiClient'; | |
| export type FullGitlabPullRequest = { | |
| id: number; | |
| iid: number; | |
| project_id: number; | |
| title: string; | |
| description: string; | |
| state: string; | |
| imported: boolean; | |
| imported_from: string; | |
| created_at: string; | |
| updated_at: string; | |
| merged_by?: string; | |
| merge_user?: string; | |
| merged_at?: string; | |
| merge_after?: string; | |
| prepared_at?: string; | |
| closed_by?: string; | |
| closed_at?: string; | |
| target_branch: string; | |
| source_branch: string; | |
| user_notes_count: number; | |
| upvotes: number; | |
| downvotes: number; | |
| author: { | |
| id: number; | |
| username: string; | |
| name: string; | |
| state: string; | |
| avatar_url: string; | |
| web_url: string; | |
| }; | |
| source_project_id: number; | |
| target_project_id: number; | |
| labels: string[]; | |
| draft: boolean; | |
| work_in_progress: boolean; | |
| milestone: string; | |
| merge_when_pipeline_succeeds: boolean; | |
| merge_status: string; | |
| detailed_merge_status: string; | |
| sha: string; | |
| merge_commit_sha?: string; | |
| squash_commit_sha?: string; | |
| discussion_locked: boolean; | |
| should_remove_source_branch: boolean; | |
| force_remove_source_branch: boolean; | |
| reference: string; | |
| references: { | |
| short: string; | |
| relative: string; | |
| full: string; | |
| }; | |
| web_url: string; | |
| time_stats: { | |
| time_estimate: number; | |
| total_time_spent: number; | |
| human_time_estimate?: string; | |
| human_total_time_spent?: string; | |
| }; | |
| squash: boolean; | |
| task_completion_status: { | |
| count: number; | |
| completed_count: number; | |
| }; | |
| has_conflicts: boolean; | |
| blocking_discussions_resolved: boolean; | |
| approvals_before_merge: { | |
| id: number; | |
| title: string; | |
| approvals_before_merge: number; | |
| }; | |
| subscribed: boolean; | |
| changes_count: string; | |
| latest_build_started_at?: string; | |
| latest_build_finished_at?: string; | |
| first_deployed_to_production_at?: string; | |
| pipeline: { | |
| id: number; | |
| iid: number; | |
| project_id: number; | |
| sha: string; | |
| ref: string; | |
| status: string; | |
| source: string; | |
| created_at: string; | |
| updated_at: string; | |
| web_url: string; | |
| }; | |
| head_pipeline: { | |
| id: number; | |
| iid: number; | |
| project_id: number; | |
| sha: string; | |
| ref: string; | |
| status: string; | |
| source: string; | |
| created_at: string; | |
| updated_at: string; | |
| web_url: string; | |
| before_sha: string; | |
| tag: boolean; | |
| user: { | |
| id: number; | |
| username: string; | |
| name: string; | |
| state: string; | |
| avatar_url: string; | |
| web_url: string; | |
| }; | |
| started_at: string; | |
| finished_at: string; | |
| duration: number; | |
| queued_duration: number; | |
| detailed_status: { | |
| icon: string; | |
| text: string; | |
| label: string; | |
| group: string; | |
| tooltip: string; | |
| has_details: boolean; | |
| details_path: string; | |
| favicon: string; | |
| }; | |
| }; | |
| diff_refs: { | |
| base_sha: string; | |
| head_sha: string; | |
| start_sha: string; | |
| }; | |
| first_contribution: boolean; | |
| user: { | |
| can_merge: boolean; | |
| }; | |
| }; | |
| export type GitlabClientToken = { | |
| accessToken: string; | |
| refreshToken: string; | |
| expiresIn: number; | |
| createdAt: number; | |
| }; | |
| export type GitlabUser = { | |
| id: number; | |
| email: string; | |
| name: string; | |
| username: string; | |
| }; | |
| export type GitlabPullRequest = { | |
| prNumber: string; | |
| url: string; | |
| headSha: string; | |
| author: GitlabUser; | |
| mergedBy: GitlabUser | null; | |
| mergedAt: Date | null; | |
| }; | |
| type GitlabPullRequestResponse = { | |
| iid: number; | |
| sha: string; | |
| web_url: string; | |
| author: GitlabApiUser; | |
| merge_user: GitlabApiUser | null; | |
| merged_at: string | null; | |
| }; | |
| type GitlabApiAccessToken = { | |
| access_token: string; | |
| refresh_token: string; | |
| token_type: string; | |
| expires_in: number; | |
| created_at: number; | |
| }; | |
| export type GitlabApiProject = { | |
| id: string; | |
| owner: string; | |
| archived: boolean; | |
| defaultBranch: string; | |
| name: string; | |
| fullPath: string; | |
| httpUrlToRepo: string; | |
| namespace?: { | |
| fullName: string; | |
| } | null; | |
| repository?: { | |
| rootRef?: string | null; | |
| } | null; | |
| languages?: { | |
| name: string; | |
| }[]; | |
| }; | |
| export type GitlabApiProjectsResponse = { | |
| nodes: GitlabApiProject[]; | |
| pageInfo: { | |
| endCursor: string; | |
| hasNextPage: boolean; | |
| }; | |
| }; | |
| export type GitlabApiUser = { | |
| id: number; | |
| email: string; | |
| name: string; | |
| username: string; | |
| avatar_url: string; | |
| }; | |
| type GitlabApiSearchResult = { | |
| basename: string; | |
| data: string; | |
| path: string; | |
| filename: string; | |
| ref: string; | |
| startline: number; | |
| // eslint-disable-next-line camelcase | |
| project_id: string; | |
| }; | |
| export class GitlabNotFoundError extends Error {} | |
| export class GitlabAuthorizationError extends Error {} | |
| const GITLAB_HOSTED_DOMAIN = 'gitlab.com'; | |
| const RETRY_MAX_ATTEMPTS = 3; | |
| const RETRY_DELAY_MS = 1000; | |
| const GITLAB_LANGUAGES_API_VERSION = '15.6.0'; | |
| export default class GitlabClient { | |
| private accessToken: string; | |
| private baseUrl: string; | |
| constructor({ | |
| accessToken, | |
| domain = GITLAB_HOSTED_DOMAIN, | |
| }: { | |
| accessToken: string; | |
| domain?: string; | |
| }) { | |
| this.accessToken = accessToken; | |
| this.baseUrl = generateBaseUrl(domain); | |
| } | |
| static authUrl({ | |
| state, | |
| domain, | |
| clientId, | |
| redirectUri, | |
| }: { | |
| state: string; | |
| domain: string; | |
| clientId: string; | |
| redirectUri: string; | |
| }) { | |
| return `https://${domain}/oauth/authorize?client_id=${clientId}&state=${encodeURIComponent( | |
| state | |
| )}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code`; | |
| } | |
| static async authorize({ | |
| authCode, | |
| domain, | |
| clientId, | |
| clientSecret, | |
| redirectUri, | |
| }: { | |
| authCode: string; | |
| domain: string; | |
| clientId: string; | |
| clientSecret: string; | |
| redirectUri: string; | |
| }): Promise<Result<GitlabClientToken, Error>> { | |
| const endpoint = new URL('/oauth/token', generateBaseUrl(domain)).href; | |
| const requestPayload = { | |
| client_id: clientId, | |
| client_secret: clientSecret, | |
| code: authCode, | |
| redirect_uri: redirectUri, | |
| grant_type: 'authorization_code', | |
| }; | |
| const headers = { | |
| Accept: 'application/json', | |
| 'Content-Type': 'application/json', | |
| }; | |
| try { | |
| const response = await axios.post<GitlabApiAccessToken>(endpoint, requestPayload, { | |
| headers, | |
| }); | |
| if (response.status === 200) { | |
| return ok({ | |
| accessToken: response.data.access_token, | |
| refreshToken: response.data.refresh_token, | |
| expiresIn: response.data.expires_in, | |
| createdAt: response.data.created_at, | |
| }); | |
| } | |
| } catch (e) { | |
| if (axios.isAxiosError(e)) { | |
| return err(new Error(`Failed to authorize GitLab app: ${e.message}`)); | |
| } | |
| return err(e instanceof Error ? e : new Error(String(e))); | |
| } | |
| return err(new Error('Failed to authorize GitLab app')); | |
| } | |
| static async refreshAccessToken({ | |
| clientId, | |
| clientSecret, | |
| domain, | |
| refreshToken, | |
| redirectUri, | |
| }: { | |
| clientId: string; | |
| clientSecret: string; | |
| domain: string; | |
| refreshToken: string; | |
| redirectUri: string; | |
| }): Promise<Result<GitlabClientToken, Error>> { | |
| const endpoint = new URL('/oauth/token', generateBaseUrl(domain)).href; | |
| const requestPayload = { | |
| client_id: clientId, | |
| client_secret: clientSecret, | |
| redirect_uri: redirectUri, | |
| refresh_token: refreshToken, | |
| grant_type: 'refresh_token', | |
| }; | |
| const headers = { | |
| Accept: 'application/json', | |
| 'Content-Type': 'application/json', | |
| }; | |
| try { | |
| const response = await axios.post<GitlabApiAccessToken>(endpoint, requestPayload, { | |
| headers, | |
| }); | |
| if (response.status === 200) { | |
| return ok({ | |
| accessToken: response.data.access_token, | |
| refreshToken: response.data.refresh_token, | |
| expiresIn: response.data.expires_in, | |
| createdAt: response.data.created_at, | |
| }); | |
| } | |
| } catch (e) { | |
| if (axios.isAxiosError(e)) { | |
| return err( | |
| new GitlabTokenRefreshError(`Failed to refresh GitLab access token: ${e.message}`) | |
| ); | |
| } | |
| return err(e instanceof Error ? e : new Error(String(e))); | |
| } | |
| return err(new GitlabTokenRefreshError('Failed to refresh GitLab access token')); | |
| } | |
| static extractId(id: string, object: string) { | |
| return id.replace(`gid://gitlab/${object}/`, ''); | |
| } | |
| async getVersion(): Promise<Result<string, Error>> { | |
| const endpoint = this.endpoint('/api/v4/version'); | |
| const headers = this.generateHeaders({ | |
| Accept: 'application/json', | |
| }); | |
| try { | |
| const response = await axios.get<{ version: string }>(endpoint, { headers }); | |
| if (response.status === 200) { | |
| return ok(response.data.version); | |
| } | |
| } catch (e) { | |
| if (axios.isAxiosError(e)) { | |
| return err(new Error(`Failed to get GitLab version: ${e.message}`)); | |
| } | |
| return err(e instanceof Error ? e : new Error(String(e))); | |
| } | |
| return err(new Error('Failed to get GitLab version')); | |
| } | |
| async reposCount({ | |
| logger, | |
| includeArchived = false, | |
| }: { | |
| logger: Logger; | |
| includeArchived?: boolean; | |
| }) { | |
| const endpoint = this.endpoint('/api/graphql'); | |
| // active: true excludes archived projects | |
| const activeOnlyQuery = `query { | |
| projects(active: true, membership: true) { | |
| count | |
| } | |
| }`; | |
| // membership: true restricts results to projects the user is a member of; | |
| // archived projects are also included here because we omit active: true, so | |
| // we rely on the API's default behavior (which includes archived projects). | |
| const allProjectsQuery = `query { | |
| projects(membership: true) { | |
| count | |
| } | |
| }`; | |
| // When excluding archived, try active: true first (which excludes archived), | |
| // then fall back to the legacy query without active: true for older GitLab versions. | |
| // When including archived, use the query without active: true so archived projects are included by default. | |
| const queries = includeArchived ? [allProjectsQuery] : [activeOnlyQuery, allProjectsQuery]; | |
| const restClient = new RestApiClient({ | |
| token: this.accessToken, | |
| logger, | |
| }); | |
| const errorMessage = 'Failed to fetch GitLab repos count'; | |
| for (const query of queries) { | |
| const [result, error] = await restClient.post<{ | |
| data?: { projects?: { count: number } | null } | null; | |
| errors?: { message: string; extensions: { code: string } }[] | null; | |
| }>({ | |
| endpoint, | |
| data: { query }, | |
| errorMessage, | |
| }); | |
| if (error) { | |
| return err(error); | |
| } | |
| // Older versions of self-managed GitLab do not support the `active` (nor archived: EXCLUDE) argument | |
| const responseError = result.errors?.[0]; | |
| if (responseError) { | |
| logger.error(`${errorMessage}: ${responseError.message} (first attempt)`); | |
| } | |
| const errorCode = responseError?.extensions?.code; | |
| if (errorCode === 'argumentNotAccepted') { | |
| continue; | |
| } | |
| if (!result.data?.projects) { | |
| return err(errorMessage); | |
| } | |
| return ok(result.data.projects.count); | |
| } | |
| return err(errorMessage); | |
| } | |
| async listProjects({ | |
| after, | |
| perPage, | |
| }: { | |
| after: string | undefined; | |
| perPage: number; | |
| }): Promise<Result<GitlabApiProjectsResponse, Error>> { | |
| let languagesField = `languages { | |
| name | |
| }`; | |
| // The languages field was added in v15.6.0 | |
| if (this.isSelfHosted()) { | |
| const version = await this.getVersion(); | |
| if (version.isErr()) { | |
| return err(version.error); | |
| } | |
| try { | |
| if (semver.lt(version.value, GITLAB_LANGUAGES_API_VERSION)) { | |
| languagesField = ''; | |
| } | |
| } catch (e) { | |
| return err(new Error(`Failed to parse GitLab version: ${e}`)); | |
| } | |
| } | |
| const endpoint = this.endpoint('/api/graphql'); | |
| const params = [`first: ${perPage}`, 'membership: true']; | |
| if (after) { | |
| params.push(`after: "${after}"`); | |
| } | |
| const query = `query { | |
| projects(${params.join(', ')}) { | |
| nodes { | |
| id | |
| archived | |
| name | |
| fullPath | |
| httpUrlToRepo | |
| namespace { | |
| fullName | |
| } | |
| repository { | |
| rootRef | |
| } | |
| ${languagesField} | |
| } | |
| pageInfo { | |
| hasNextPage | |
| endCursor | |
| } | |
| } | |
| }`; | |
| const headers = this.generateHeaders({ | |
| Accept: 'application/json', | |
| }); | |
| try { | |
| const response = await axios.post<{ data: { projects: GitlabApiProjectsResponse } }>( | |
| endpoint, | |
| { query }, | |
| { headers } | |
| ); | |
| if (response.status === 200) { | |
| return ok(response.data.data.projects); | |
| } | |
| } catch (e) { | |
| if (axios.isAxiosError(e)) { | |
| return err(new Error(`Failed to list GitLab projects: ${e.message}`)); | |
| } | |
| return err(e instanceof Error ? e : new Error(String(e))); | |
| } | |
| return err(new Error('No projects found')); | |
| } | |
| async listProjectWebhooks({ | |
| projectId, | |
| url, | |
| }: { | |
| projectId: string; | |
| url: string; | |
| }): Promise<Result<number[], Error>> { | |
| const endpoint = this.endpoint(`/api/v4/projects/${projectId}/hooks`); | |
| try { | |
| const response = await axios.get<{ id: number; url: string }[]>(endpoint, { | |
| headers: this.generateHeaders(), | |
| }); | |
| if (response.status === 200) { | |
| return ok( | |
| response.data.filter((d) => d.url.toLowerCase() === url.toLowerCase()).map((d) => d.id) | |
| ); | |
| } | |
| return ok([]); | |
| } catch (e) { | |
| if (axios.isAxiosError(e)) { | |
| return err( | |
| new Error(`Failed to list GitLab project hooks: ${e.message}. Project ID: ${projectId})`) | |
| ); | |
| } | |
| return err(e instanceof Error ? e : new Error(String(e))); | |
| } | |
| } | |
| async updateProjectWebhook({ | |
| projectId, | |
| hookId, | |
| url, | |
| secret, | |
| }: { | |
| projectId: string; | |
| hookId: number; | |
| url: string; | |
| secret: string; | |
| }): Promise<Result<boolean, Error>> { | |
| const endpoint = this.endpoint(`/api/v4/projects/${projectId}/hooks/${hookId}`); | |
| const payload = { | |
| url, | |
| merge_requests_events: true, | |
| push_events: true, | |
| note_events: true, | |
| token: secret, | |
| }; | |
| try { | |
| const response = await axios.put<{ id: string }>(endpoint, payload, { | |
| headers: this.generateHeaders(), | |
| }); | |
| if (response.status === 200) { | |
| return ok(true); | |
| } | |
| return ok(false); | |
| } catch (e) { | |
| if (axios.isAxiosError(e)) { | |
| return err( | |
| new Error( | |
| `Failed to update a GitLab project hook: ${e.message}. Project ID: ${projectId})` | |
| ) | |
| ); | |
| } | |
| return err(e instanceof Error ? e : new Error(String(e))); | |
| } | |
| } | |
| async createProjectWebhook({ | |
| projectId, | |
| url, | |
| secret, | |
| }: { | |
| projectId: string; | |
| url: string; | |
| secret: string; | |
| }): Promise<Result<string, Error>> { | |
| // https://docs.gitlab.com/api/project_webhooks/#add-a-webhook-to-a-project | |
| const endpoint = this.endpoint(`/api/v4/projects/${projectId}/hooks`); | |
| const payload = { | |
| url, | |
| merge_requests_events: true, // pull request events | |
| push_events: true, // default branch push events | |
| note_events: true, // comment events | |
| token: secret, | |
| }; | |
| try { | |
| const response = await axios.post<{ id: string }>(endpoint, payload, { | |
| headers: this.generateHeaders(), | |
| }); | |
| if (response.status === 201) { | |
| return ok(response.data.id); | |
| } | |
| return err(new Error(`Failed to create a GitLab project hook. Project ID: ${projectId})`)); | |
| } catch (e) { | |
| if (axios.isAxiosError(e)) { | |
| const data = e.response?.data; | |
| let dataStr = 'empty'; | |
| if (data) { | |
| try { | |
| dataStr = JSON.stringify(data); | |
| } catch { | |
| dataStr = String(data); | |
| } | |
| } | |
| return err( | |
| new Error( | |
| `Failed to create a GitLab project hook: ${e.message}. Project ID: ${projectId}. Response data: ${dataStr})` | |
| ) | |
| ); | |
| } | |
| return err(e instanceof Error ? e : new Error(String(e))); | |
| } | |
| } | |
| async deleteProjectWebhook({ | |
| projectId, | |
| hookId, | |
| }: { | |
| projectId: string; | |
| hookId: number; | |
| }): Promise<Result<boolean, Error>> { | |
| const endpoint = this.endpoint(`/api/v4/projects/${projectId}/hooks/${hookId}`); | |
| try { | |
| const response = await axios.delete(endpoint, { headers: this.generateHeaders() }); | |
| if (response.status === 200) { | |
| return ok(true); | |
| } | |
| return ok(false); | |
| } catch (e) { | |
| if (axios.isAxiosError(e)) { | |
| if (e.response?.status === 404) { | |
| return err(new GitlabNotFoundError()); | |
| } | |
| return err( | |
| new Error(`Failed to delete GitLab project hook: ${e.message}. Project ID: ${projectId})`) | |
| ); | |
| } | |
| return err(e instanceof Error ? e : new Error(String(e))); | |
| } | |
| } | |
| async findMergeRequestPipeline({ | |
| projectId, | |
| commitSha, | |
| prNumber, | |
| }: { | |
| projectId: string; | |
| commitSha: string; | |
| prNumber: string; | |
| }): Promise<Result<number | null, Error>> { | |
| // https://docs.gitlab.com/ee/api/pipelines.html#list-project-pipelines | |
| const endpoint = this.endpoint(`/api/v4/projects/${projectId}/pipelines`); | |
| // Unable to use searchParams as it escapes slashes, building manually | |
| const params = { | |
| ref: `refs/merge-requests/${prNumber}/head`, | |
| sha: commitSha, | |
| source: 'merge_request_event', | |
| }; | |
| const query = Object.entries(params) | |
| .map(([key, value]) => `${key}=${value}`) | |
| .join('&'); | |
| const url = `${endpoint}?${query}`; | |
| try { | |
| const response = await axios.get<{ id: number; ref: string }[]>(url, { | |
| headers: this.generateHeaders(), | |
| }); | |
| if (response.status === 200 && response.data.length > 0) { | |
| return ok(response.data[0].id); | |
| } | |
| return ok(null); | |
| } catch (e) { | |
| if (axios.isAxiosError(e)) { | |
| return err( | |
| new Error( | |
| `Failed to find merge request pipeline: ${e.message}. Project ID: ${projectId}, commit SHA: ${commitSha}` | |
| ) | |
| ); | |
| } | |
| return err(e instanceof Error ? e : new Error(String(e))); | |
| } | |
| } | |
| async updateCommitStatus({ | |
| projectId, | |
| sha, | |
| pipelineId, | |
| name, | |
| state = 'running', | |
| details = { | |
| description: '', | |
| }, | |
| logger, | |
| }: { | |
| projectId: string; | |
| sha: string; | |
| pipelineId?: number | null; | |
| name: string; | |
| state?: 'running' | 'success' | 'failed' | 'pending' | 'skipped'; | |
| details?: { description: string; targetUrl?: string }; | |
| logger: Logger; | |
| }): Promise<Result<boolean, Error>> { | |
| // https://docs.gitlab.com/ee/api/commits.html#set-the-pipeline-status-of-a-commit | |
| const endpoint = this.endpoint(`/api/v4/projects/${projectId}/statuses/${sha}`); | |
| const payload = { | |
| state, | |
| name, | |
| pipeline_id: pipelineId ?? undefined, | |
| description: details.description, | |
| target_url: details.targetUrl, | |
| }; | |
| try { | |
| logger.debug({ endpoint, payload }, 'GitLab commit status request endpoint and payload'); | |
| const response = await axios.post(endpoint, payload, { | |
| headers: this.generateHeaders(), | |
| }); | |
| logger.debug({ response }, 'GitLab commit status response'); | |
| return ok(response.status === 201); | |
| } catch (e) { | |
| if (axios.isAxiosError(e)) { | |
| return err( | |
| new Error( | |
| `Failed to create GitLab commit status: ${e.message}. Project ID: ${projectId}), SHA: ${sha}` | |
| ) | |
| ); | |
| } | |
| return err(e instanceof Error ? e : new Error(String(e))); | |
| } | |
| } | |
| async updateCommitStatusIfRunning({ | |
| projectId, | |
| sha, | |
| pipelineId, | |
| name, | |
| logger, | |
| }: { | |
| projectId: string; | |
| sha: string; | |
| pipelineId?: number | null; | |
| name: string; | |
| logger: Logger; | |
| }): Promise<Result<boolean, Error>> { | |
| const findResult = await this.findCommitStatus({ | |
| projectId, | |
| sha, | |
| name, | |
| pipelineId, | |
| }); | |
| if (findResult.isErr()) { | |
| return err(findResult.error); | |
| } | |
| if (!findResult.value) { | |
| return err( | |
| new Error( | |
| `could not find any GitLab commit statuses for sha: ${sha}. Project ID: ${projectId})` | |
| ) | |
| ); | |
| } | |
| if (findResult.value.status !== 'running') { | |
| return ok(false); | |
| } | |
| logger.info( | |
| `commit status run was still in "running" after runner completion, updating commit status to "success".` | |
| ); | |
| return this.updateCommitStatus({ | |
| projectId, | |
| sha, | |
| pipelineId, | |
| name, | |
| state: 'success', | |
| logger, | |
| }); | |
| } | |
| async findCommitStatus({ | |
| projectId, | |
| sha, | |
| name, | |
| pipelineId, | |
| }: { | |
| projectId: string; | |
| sha: string; | |
| name: string; | |
| pipelineId?: number | null; | |
| }): Promise<Result<{ pipelineId: number | null; status: string } | null, Error>> { | |
| // https://docs.gitlab.com/api/commits/#list-commit-statuses | |
| const endpoint = this.endpoint( | |
| `/api/v4/projects/${projectId}/repository/commits/${sha}/statuses` | |
| ); | |
| try { | |
| const response = await axios.get<{ status: string; pipeline_id?: number }[]>(endpoint, { | |
| params: { | |
| name, | |
| }, | |
| headers: this.generateHeaders(), | |
| }); | |
| if (response.data.length === 0) { | |
| return ok(null); | |
| } | |
| const commitStatus = pipelineId | |
| ? response.data.find((s) => s.pipeline_id === pipelineId) | |
| : response.data[0]; | |
| if (!commitStatus) { | |
| return ok(null); | |
| } | |
| return ok({ pipelineId: commitStatus.pipeline_id ?? null, status: commitStatus.status }); | |
| } catch (e) { | |
| if (axios.isAxiosError(e)) { | |
| return err( | |
| new Error( | |
| `Failed to find GitLab commit status: ${e.message}. Project ID: ${projectId}, SHA: ${sha}` | |
| ) | |
| ); | |
| } | |
| return err(e instanceof Error ? e : new Error(String(e))); | |
| } | |
| } | |
| async getApprovers({ | |
| projectId, | |
| prNumber, | |
| }: { | |
| projectId: string; | |
| prNumber: string; | |
| }): Promise<Result<string[], Error>> { | |
| // The docs are for Premium tier, but this also works for a free one too. | |
| // https://docs.gitlab.com/ee/api/merge_request_approvals.html#get-configuration-1 | |
| const endpoint = this.endpoint( | |
| `/api/v4/projects/${projectId}/merge_requests/${prNumber}/approvals` | |
| ); | |
| try { | |
| const response = await axios.get<{ approved_by: { user: { username: string } }[] }>( | |
| endpoint, | |
| { | |
| headers: this.generateHeaders(), | |
| } | |
| ); | |
| if (response.status === 200) { | |
| return ok(response.data.approved_by.map((a) => a.user.username)); | |
| } | |
| return err(new Error(`Failed to get GitLab merge request approvers`)); | |
| } catch (e) { | |
| if (axios.isAxiosError(e)) { | |
| return err( | |
| new Error( | |
| `Failed to get GitLab merge request approvers: ${e.message}. Project ID: ${projectId}), PR number: ${prNumber}` | |
| ) | |
| ); | |
| } | |
| return err(e instanceof Error ? e : new Error(String(e))); | |
| } | |
| } | |
| async getUserInfo({ | |
| username, | |
| }: { | |
| username: string; | |
| }): Promise<Result<{ name: string; avatarUrl: string }, Error>> { | |
| const endpoint = this.endpoint(`/api/v4/users?username=${username}`); | |
| try { | |
| const response = await axios.get<GitlabApiUser[]>(endpoint, { | |
| headers: this.generateHeaders(), | |
| }); | |
| if (response.status === 200) { | |
| if (response.data.length > 0 && response.data[0].name) { | |
| return ok({ name: response.data[0].name, avatarUrl: response.data[0].avatar_url }); | |
| } | |
| return err(new Error(`Failed to find GitLab user`)); | |
| } | |
| return err(new Error(`Failed to get GitLab user: ${response.status}`)); | |
| } catch (e) { | |
| if (axios.isAxiosError(e)) { | |
| return err(new Error(`Failed to get GitLab merge request approvers: ${e.message}`)); | |
| } | |
| return err(e instanceof Error ? e : new Error(String(e))); | |
| } | |
| } | |
| async getCurrentUser(): Promise< | |
| Result<{ id: number; username: string; name: string; isServiceAccount: boolean }, Error> | |
| > { | |
| const endpoint = this.endpoint(`/api/v4/user`); | |
| try { | |
| const response = await axios.get<{ | |
| id: number; | |
| username: string; | |
| name: string; | |
| bot?: boolean; | |
| user_type?: string; | |
| }>(endpoint, { | |
| headers: this.generateHeaders(), | |
| }); | |
| if (response.status === 200) { | |
| // Service accounts have user_type 'service_account' in GitLab 16.1+ | |
| const isServiceAccount = | |
| response.data.user_type === 'service_account' || response.data.bot === true; | |
| return ok({ | |
| id: response.data.id, | |
| username: response.data.username, | |
| name: response.data.name, | |
| isServiceAccount, | |
| }); | |
| } | |
| return err(new Error(`Failed to get current user: ${response.status}`)); | |
| } catch (e) { | |
| if (axios.isAxiosError(e)) { | |
| return err(new Error(`Failed to get current GitLab user: ${e.message}`)); | |
| } | |
| return err(e instanceof Error ? e : new Error(String(e))); | |
| } | |
| } | |
| async getProject({ projectId }: { projectId: string }): Promise<Result<GitlabApiProject, Error>> { | |
| const endpoint = this.endpoint(`/api/v4/projects/${projectId}`); | |
| return withRetry<GitlabApiProject>(async () => { | |
| try { | |
| const response = await axios.get<{ | |
| id: number; | |
| archived: boolean; | |
| name: string; | |
| path_with_namespace: string; | |
| http_url_to_repo: string; | |
| namespace: { | |
| full_path: string; | |
| }; | |
| owner?: { | |
| name: string; | |
| }; | |
| default_branch: string; | |
| }>(endpoint, { | |
| headers: this.generateHeaders(), | |
| }); | |
| if (response.status === 200) { | |
| return ok({ | |
| id: String(response.data.id), | |
| archived: response.data.archived, | |
| name: response.data.name, | |
| defaultBranch: response.data.default_branch, | |
| fullPath: response.data.path_with_namespace, | |
| httpUrlToRepo: response.data.http_url_to_repo, | |
| owner: response.data.owner?.name ?? '', // owner is not always present for self-hosted GitLab | |
| namespace: response.data.namespace | |
| ? { | |
| fullName: response.data.namespace.full_path, | |
| } | |
| : null, | |
| }); | |
| } | |
| return err(new Error('Project not found')); | |
| } catch (e) { | |
| if (axios.isAxiosError(e)) { | |
| if (e.response?.status === 404) { | |
| return err(new GitlabNotFoundError()); | |
| } | |
| if (e.response?.status === 401 || e.response?.status === 403) { | |
| return err(new GitlabAuthorizationError()); | |
| } | |
| return err(new Error(`Failed to get GitLab project: ${e.message}`)); | |
| } | |
| return err(e instanceof Error ? e : new Error(String(e))); | |
| } | |
| }); | |
| } | |
| async getFullMergeRequest({ | |
| projectId, | |
| prNumber, | |
| }: { | |
| projectId: string; | |
| prNumber: string; | |
| }): Promise<Result<FullGitlabPullRequest, Error>> { | |
| // https://docs.gitlab.com/ee/api/merge_requests.html#get-single-mr | |
| const endpoint = this.endpoint(`/api/v4/projects/${projectId}/merge_requests/${prNumber}`); | |
| try { | |
| const response = await axios.get<FullGitlabPullRequest>(endpoint, { | |
| headers: this.generateHeaders(), | |
| }); | |
| if (response.status === 200) { | |
| return ok(response.data); | |
| } | |
| return err(new Error(`Failed to get GitLab merge request`)); | |
| } catch (e) { | |
| if (axios.isAxiosError(e)) { | |
| return err( | |
| new Error( | |
| `Failed to get GitLab merge request: ${e.message}. Project ID: ${projectId}), PR number: ${prNumber}` | |
| ) | |
| ); | |
| } | |
| return err(e instanceof Error ? e : new Error(String(e))); | |
| } | |
| } | |
| async getMergeRequest({ | |
| projectId, | |
| prNumber, | |
| }: { | |
| projectId: string; | |
| prNumber: string; | |
| }): Promise<Result<GitlabPullRequest, Error>> { | |
| // https://docs.gitlab.com/ee/api/merge_requests.html#get-single-mr | |
| const endpoint = this.endpoint(`/api/v4/projects/${projectId}/merge_requests/${prNumber}`); | |
| return withRetry(async () => { | |
| try { | |
| const response = await axios.get<GitlabPullRequestResponse>(endpoint, { | |
| headers: this.generateHeaders(), | |
| }); | |
| if (response.status === 200) { | |
| return ok(convertPullRequestResponse(response.data)); | |
| } | |
| return err(new Error(`Failed to get GitLab merge request`)); | |
| } catch (e) { | |
| if (axios.isAxiosError(e)) { | |
| if (e.response?.status === 404) { | |
| return err(new GitlabNotFoundError()); | |
| } | |
| if (e.response?.status === 401 || e.response?.status === 403) { | |
| return err(new GitlabAuthorizationError()); | |
| } | |
| return err( | |
| new Error( | |
| `Failed to get GitLab merge request: ${e.message}. Project ID: ${projectId}), PR number: ${prNumber}` | |
| ) | |
| ); | |
| } | |
| return err(e instanceof Error ? e : new Error(String(e))); | |
| } | |
| }); | |
| } | |
| async listMergedMergeRequests({ | |
| projectId, | |
| perPage = 100, | |
| page = 1, | |
| }: { | |
| projectId: string; | |
| perPage?: number; | |
| page?: number; | |
| }): Promise<Result<GitlabPullRequest[], Error>> { | |
| // https://docs.gitlab.com/ee/api/merge_requests.html#list-project-merge-requests | |
| const endpoint = this.endpoint(`/api/v4/projects/${projectId}/merge_requests`); | |
| const query = formatParams({ | |
| sort: 'desc', | |
| order_by: 'created_at', | |
| per_page: perPage, | |
| page, | |
| state: 'merged', | |
| }); | |
| const url = `${endpoint}?${query}`; | |
| try { | |
| const response = await axios.get<GitlabPullRequestResponse[]>(url, { | |
| headers: this.generateHeaders(), | |
| }); | |
| if (response.status === 200) { | |
| return ok(response.data.map((d) => convertPullRequestResponse(d))); | |
| } | |
| return err( | |
| new Error(`Failed to fetch GitLab merged merge requests: ${response.status} status`) | |
| ); | |
| } catch (e) { | |
| if (axios.isAxiosError(e)) { | |
| return err( | |
| new Error( | |
| `Failed to fetch GitLab merged merge requests: ${e.message}. Project ID: ${projectId})` | |
| ) | |
| ); | |
| } | |
| return err(e instanceof Error ? e : new Error(String(e))); | |
| } | |
| } | |
| async reactToComment({ | |
| projectId, | |
| noteId, | |
| prNumber, | |
| }: { | |
| projectId: string; | |
| noteId: string; | |
| prNumber: string; | |
| }): Promise<Result<boolean, Error>> { | |
| // https://docs.gitlab.com/ee/api/emoji_reactions.html#add-a-new-emoji-reaction-to-a-comment | |
| const endpoint = this.endpoint( | |
| `/api/v4/projects/${projectId}/merge_requests/${prNumber}/notes/${noteId}/award_emoji` | |
| ); | |
| const payload = { | |
| name: 'thumbsup', | |
| }; | |
| try { | |
| const response = await axios.post(endpoint, payload, { | |
| headers: this.generateHeaders(), | |
| }); | |
| if (response.status === 201) { | |
| return ok(true); | |
| } | |
| return ok(false); | |
| } catch (e) { | |
| if (axios.isAxiosError(e)) { | |
| return err( | |
| new Error( | |
| `Failed to add thumbs up reaction: ${e.message}. Project ID: ${projectId}, Note ID: ${noteId}` | |
| ) | |
| ); | |
| } | |
| return err(e instanceof Error ? e : new Error(String(e))); | |
| } | |
| } | |
| async createComment({ | |
| projectId, | |
| mergeRequestIid, | |
| content, | |
| }: { | |
| projectId: string; | |
| mergeRequestIid: string; | |
| content: string; | |
| }): Promise<Result<boolean, Error>> { | |
| // https://docs.gitlab.com/ee/api/notes.html#create-new-merge-request-note | |
| const endpoint = this.endpoint( | |
| `/api/v4/projects/${projectId}/merge_requests/${mergeRequestIid}/notes` | |
| ); | |
| const payload = { | |
| body: content, | |
| }; | |
| try { | |
| const response = await axios.post(endpoint, payload, { | |
| headers: this.generateHeaders(), | |
| }); | |
| if (response.status === 201) { | |
| return ok(true); | |
| } | |
| return ok(false); | |
| } catch (e) { | |
| if (axios.isAxiosError(e)) { | |
| return err( | |
| new Error( | |
| `Failed to create comment: ${e.message}. Project ID: ${projectId}, MR IID: ${mergeRequestIid}` | |
| ) | |
| ); | |
| } | |
| return err(e instanceof Error ? e : new Error(String(e))); | |
| } | |
| } | |
| async updateMergeRequest({ | |
| projectId, | |
| mergeRequestIid, | |
| stateEvent, | |
| }: { | |
| projectId: string; | |
| mergeRequestIid: string; | |
| stateEvent: 'close' | 'reopen'; | |
| }): Promise<Result<boolean, Error>> { | |
| // https://docs.gitlab.com/ee/api/merge_requests.html#update-mr | |
| const endpoint = this.endpoint( | |
| `/api/v4/projects/${projectId}/merge_requests/${mergeRequestIid}` | |
| ); | |
| const payload = { | |
| state_event: stateEvent, | |
| }; | |
| const client = new RestApiClient({ token: this.accessToken }); | |
| const [, error] = await client.put({ | |
| endpoint, | |
| data: payload, | |
| errorMessage: `Failed to update merge request. Project ID: ${projectId}, MR IID: ${mergeRequestIid}`, | |
| }); | |
| if (error) { | |
| return err(error); | |
| } | |
| return ok(true); | |
| } | |
| async searchGitlabGroup({ | |
| groupPath, | |
| search, | |
| scope = 'blobs', | |
| }: { | |
| groupPath: string; | |
| search: string; | |
| scope?: 'blobs'; | |
| }): Promise<Result<GitlabApiSearchResult[], Error>> { | |
| const results: GitlabApiSearchResult[] = []; | |
| const perPage = 100; | |
| let page = 1; | |
| let hasMorePages = true; | |
| while (hasMorePages) { | |
| const params = new URLSearchParams({ | |
| scope, | |
| search, | |
| per_page: String(perPage), | |
| page: String(page), | |
| }); | |
| const endpoint = this.endpoint( | |
| `/api/v4/groups/${encodeURIComponent(groupPath)}/search?${params.toString()}` | |
| ); | |
| const response = await axios.get<GitlabApiSearchResult[]>(endpoint, { | |
| headers: this.generateHeaders(), | |
| }); | |
| if (response.status !== 200) { | |
| return err(new Error(`Failed to search GitLab group: ${response.status}`)); | |
| } | |
| if (response.data.length === 0) { | |
| hasMorePages = false; | |
| } else { | |
| results.push(...response.data); | |
| if (response.data.length < perPage) { | |
| hasMorePages = false; | |
| } else { | |
| page++; | |
| } | |
| } | |
| } | |
| return ok(results); | |
| } | |
| async searchGitlabProject({ | |
| projectId, | |
| search, | |
| filename, | |
| extension, | |
| scope = 'blobs', | |
| }: { | |
| projectId: string; | |
| search: string; | |
| scope?: 'blobs'; | |
| filename?: string; | |
| extension?: string; | |
| }): Promise<Result<GitlabApiSearchResult[], Error>> { | |
| // Build search query with filters as part of the search string | |
| let searchQuery = search; | |
| if (filename) { | |
| searchQuery += ` filename:${filename}`; | |
| } | |
| if (extension) { | |
| searchQuery += ` extension:${extension}`; | |
| } | |
| const params = new URLSearchParams({ | |
| scope, | |
| search: searchQuery.trim(), | |
| }); | |
| const endpoint = this.endpoint(`/api/v4/projects/${projectId}/search?${params.toString()}`); | |
| try { | |
| const response = await axios.get<GitlabApiSearchResult[]>(endpoint, { | |
| headers: this.generateHeaders(), | |
| }); | |
| if (response.status === 200) { | |
| return ok(response.data); | |
| } | |
| return err( | |
| new Error(`Failed to search GitLab project: ${response.status}: ${response.data}`) | |
| ); | |
| } catch (e) { | |
| if (axios.isAxiosError(e)) { | |
| return err(new Error(`Failed to search GitLab project: ${e.message}`)); | |
| } | |
| return err(e instanceof Error ? e : new Error(String(e))); | |
| } | |
| } | |
| isSelfHosted() { | |
| return this.baseUrl !== `https://${GITLAB_HOSTED_DOMAIN}`; | |
| } | |
| endpoint(path: string) { | |
| return new URL(path, this.baseUrl).href; | |
| } | |
| generateHeaders(headers: { [key: string]: string } = {}) { | |
| return { | |
| ...headers, | |
| Authorization: `Bearer ${this.accessToken}`, | |
| 'Content-Type': 'application/json', | |
| }; | |
| } | |
| } | |
| async function withRetry<T>( | |
| fn: () => Promise<Result<T, Error>>, | |
| { | |
| maxAttempts = RETRY_MAX_ATTEMPTS, | |
| delayMs = RETRY_DELAY_MS, | |
| }: { maxAttempts?: number; delayMs?: number } = {} | |
| ): Promise<Result<T, Error>> { | |
| let attempts = 0; | |
| let result: Result<T, Error>; | |
| do { | |
| result = await fn(); | |
| if ( | |
| result.isOk() || | |
| result.error instanceof GitlabNotFoundError || | |
| result.error instanceof GitlabAuthorizationError | |
| ) { | |
| break; | |
| } | |
| attempts++; | |
| if (attempts < maxAttempts) { | |
| await sleep(delayMs); | |
| } | |
| } while (attempts < maxAttempts); | |
| return result; | |
| } | |
| function generateBaseUrl(domain: string) { | |
| return `https://${domain}`; | |
| } | |
| function formatParams(params: { [key: string]: string | number | null | undefined }) { | |
| return Object.entries(params) | |
| .filter(([, value]) => value != null) | |
| .map(([key, value]) => `${key}=${value}`) | |
| .join('&'); | |
| } | |
| export function convertPullRequestResponse(data: GitlabPullRequestResponse): GitlabPullRequest { | |
| return { | |
| prNumber: String(data.iid), | |
| headSha: data.sha, | |
| url: data.web_url, | |
| author: { | |
| id: data.author.id, | |
| email: data.author.email, | |
| name: data.author.name, | |
| username: data.author.username, | |
| }, | |
| mergedAt: data.merged_at ? new Date(data.merged_at) : null, | |
| mergedBy: data.merge_user | |
| ? { | |
| id: data.merge_user.id, | |
| email: data.merge_user.email, | |
| name: data.merge_user.name, | |
| username: data.merge_user.username, | |
| } | |
| : null, | |
| }; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment