Skip to content

Instantly share code, notes, and snippets.

@vdmgolub
Created March 16, 2026 21:05
Show Gist options
  • Select an option

  • Save vdmgolub/03cfe5f2a3354583322ee68b8ce9626a to your computer and use it in GitHub Desktop.

Select an option

Save vdmgolub/03cfe5f2a3354583322ee68b8ce9626a to your computer and use it in GitHub Desktop.
/* 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