Skip to main content
Glama
by ttpears
gitlab-client.ts35.8 kB
import { GraphQLClient, gql } from 'graphql-request'; import { buildClientSchema, getIntrospectionQuery, IntrospectionQuery } from 'graphql'; import type { Config, UserConfig } from './config.js'; export class GitLabGraphQLClient { private baseClient: GraphQLClient | null = null; private config: Config; private schema: any = null; private userClients: Map<string, GraphQLClient> = new Map(); constructor(config: Config) { this.config = config; // Create base client for shared operations (if shared token provided) if (config.sharedAccessToken) { this.baseClient = this.createClient(config.gitlabUrl, config.sharedAccessToken); } } private createClient(gitlabUrl: string, accessToken: string): GraphQLClient { const endpoint = `${gitlabUrl.replace(/\/$/, '')}/api/graphql`; return new GraphQLClient(endpoint, { headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, }); } private getUserClient(userConfig: UserConfig): GraphQLClient { const userKey = `${userConfig.gitlabUrl || this.config.gitlabUrl}:${userConfig.accessToken}`; if (!this.userClients.has(userKey)) { const client = this.createClient( userConfig.gitlabUrl || this.config.gitlabUrl, userConfig.accessToken ); this.userClients.set(userKey, client); } return this.userClients.get(userKey)!; } private getClient(userConfig?: UserConfig, requiresWrite = false): GraphQLClient { // If user config provided, use user-specific client if (userConfig) { return this.getUserClient(userConfig); } // If write operation required, user must provide credentials if (requiresWrite) { throw new Error('Write operations require user authentication. Please provide your GitLab credentials.'); } // For read operations, try shared client first if (this.baseClient && this.config.authMode !== 'per-user') { return this.baseClient; } // If no shared client and hybrid/per-user mode, require user auth if (this.config.authMode === 'per-user' || this.config.authMode === 'hybrid') { throw new Error('This operation requires user authentication. Please provide your GitLab credentials.'); } throw new Error('No authentication configured. Please provide GitLab credentials or configure a shared access token.'); } async introspectSchema(userConfig?: UserConfig): Promise<void> { if (this.schema) return; try { const client = this.getClient(userConfig); const introspectionResult = await client.request<IntrospectionQuery>( getIntrospectionQuery() ); this.schema = buildClientSchema(introspectionResult); } catch (error) { throw new Error(`Failed to introspect GitLab GraphQL schema: ${error}`); } } async query<T = any>(query: string, variables?: any, userConfig?: UserConfig, requiresWrite = false): Promise<T> { try { const client = this.getClient(userConfig, requiresWrite); return await client.request<T>(query, variables); } catch (error) { throw new Error(`GraphQL query failed: ${error}`); } } async getCurrentUser(userConfig?: UserConfig): Promise<any> { const query = gql` query getCurrentUser { currentUser { id username name email avatarUrl webUrl } } `; return this.query(query, undefined, userConfig); } async getProject(fullPath: string, userConfig?: UserConfig): Promise<any> { const query = gql` query getProject($fullPath: ID!) { project(fullPath: $fullPath) { id name description fullPath webUrl createdAt updatedAt visibility repository { tree { lastCommit { sha message authoredDate author { name email } } } } } } `; return this.query(query, { fullPath }, userConfig); } async getProjects(first: number = 20, after?: string, userConfig?: UserConfig): Promise<any> { const query = gql` query getProjects($first: Int!, $after: String) { projects(first: $first, after: $after) { pageInfo { hasNextPage hasPreviousPage startCursor endCursor } nodes { id name description fullPath webUrl visibility createdAt updatedAt issuesEnabled mergeRequestsEnabled } } } `; return this.query(query, { first, after }, userConfig); } async getIssues(projectPath: string, first: number = 20, after?: string, userConfig?: UserConfig): Promise<any> { const query = gql` query getIssues($projectPath: ID!, $first: Int!, $after: String) { project(fullPath: $projectPath) { issues(first: $first, after: $after) { pageInfo { hasNextPage hasPreviousPage startCursor endCursor } nodes { id iid title description state createdAt updatedAt closedAt webUrl author { id username name } assignees { nodes { id username name } } labels { nodes { id title color description } } } } } } `; return this.query(query, { projectPath, first, after }, userConfig); } async getMergeRequests(projectPath: string, first: number = 20, after?: string, userConfig?: UserConfig): Promise<any> { const query = gql` query getMergeRequests($projectPath: ID!, $first: Int!, $after: String) { project(fullPath: $projectPath) { mergeRequests(first: $first, after: $after) { pageInfo { hasNextPage hasPreviousPage startCursor endCursor } nodes { id iid title description state createdAt updatedAt mergedAt webUrl sourceBranch targetBranch author { id username name } assignees { nodes { id username name } } reviewers { nodes { id username name } } labels { nodes { id title color description } } } } } } `; return this.query(query, { projectPath, first, after }, userConfig); } async createIssue(projectPath: string, title: string, description?: string, userConfig?: UserConfig): Promise<any> { await this.introspectSchema(userConfig); const mutationType = this.schema?.getMutationType(); const fields = mutationType ? mutationType.getFields() : {}; const fieldName = fields['createIssue'] ? 'createIssue' : (fields['issueCreate'] ? 'issueCreate' : null); if (!fieldName) { throw new Error('Neither createIssue nor issueCreate mutation is available on this GitLab instance'); } const hasCreateInput = !!this.schema.getType('CreateIssueInput'); const hasLegacyInput = !!this.schema.getType('IssueCreateInput'); const inputType = hasCreateInput ? 'CreateIssueInput' : (hasLegacyInput ? 'IssueCreateInput' : null); if (!inputType) { throw new Error('Neither CreateIssueInput nor IssueCreateInput input type is available on this GitLab instance'); } const mutation = gql` mutation createIssue($input: ${inputType}!) { ${fieldName}(input: $input) { issue { id iid title description webUrl state createdAt } errors } } `; const input = { projectPath, title, description, }; const result = await this.query(mutation, { input }, userConfig, true); // Normalize payload to { createIssue: ... } const payload = (result as any)[fieldName]; return { createIssue: payload }; } async createMergeRequest( projectPath: string, title: string, sourceBranch: string, targetBranch: string, description?: string, userConfig?: UserConfig ): Promise<any> { await this.introspectSchema(userConfig); const mutationType = this.schema?.getMutationType(); const fields = mutationType ? mutationType.getFields() : {}; const fieldName = fields['createMergeRequest'] ? 'createMergeRequest' : (fields['mergeRequestCreate'] ? 'mergeRequestCreate' : null); if (!fieldName) { throw new Error('Neither createMergeRequest nor mergeRequestCreate mutation is available on this GitLab instance'); } const hasCreateInput = !!this.schema.getType('CreateMergeRequestInput'); const hasLegacyInput = !!this.schema.getType('MergeRequestCreateInput'); const inputType = hasCreateInput ? 'CreateMergeRequestInput' : (hasLegacyInput ? 'MergeRequestCreateInput' : null); if (!inputType) { throw new Error('Neither CreateMergeRequestInput nor MergeRequestCreateInput input type is available on this GitLab instance'); } const mutation = gql` mutation createMergeRequest($input: ${inputType}!) { ${fieldName}(input: $input) { mergeRequest { id iid title description webUrl state sourceBranch targetBranch createdAt } errors } } `; const input = { projectPath, title, sourceBranch, targetBranch, description, }; const result = await this.query(mutation, { input }, userConfig, true); // Normalize payload to { createMergeRequest: ... } const payload = (result as any)[fieldName]; return { createMergeRequest: payload }; } getSchema() { return this.schema; } getAvailableQueries(): string[] { if (!this.schema) return []; const queryType = this.schema.getQueryType(); if (!queryType) return []; return Object.keys(queryType.getFields()); } getAvailableMutations(): string[] { if (!this.schema) return []; const mutationType = this.schema.getMutationType(); if (!mutationType) return []; return Object.keys(mutationType.getFields()); } // Helpers for updates async getIssueId(projectPath: string, iid: string, userConfig?: UserConfig): Promise<string> { const query = gql` query issueId($projectPath: ID!, $iid: String!) { project(fullPath: $projectPath) { issue(iid: $iid) { id } } } `; const result = await this.query(query, { projectPath, iid }, userConfig); const id = result?.project?.issue?.id; if (!id) throw new Error('Issue not found'); return id; } async getUserIdsByUsernames(usernames: string[], userConfig?: UserConfig): Promise<Record<string, string>> { const ids: Record<string, string> = {}; if (!usernames || usernames.length === 0) return ids; const query = gql` query users($search: String!, $first: Int!) { users(search: $search, first: $first) { nodes { id username } } } `; for (const name of usernames) { const res = await this.query(query, { search: name, first: 20 }, userConfig); const node = res?.users?.nodes?.find((u: any) => u.username === name); if (node?.id) ids[name] = node.id; } return ids; } async getLabelIds(projectPath: string, labelNames: string[], userConfig?: UserConfig): Promise<Record<string, string>> { const ids: Record<string, string> = {}; if (!labelNames || labelNames.length === 0) return ids; const query = gql` query projLabels($projectPath: ID!, $search: String!, $first: Int!) { project(fullPath: $projectPath) { labels(search: $search, first: $first) { nodes { id title } } } } `; for (const title of labelNames) { const res = await this.query(query, { projectPath, search: title, first: 50 }, userConfig); const node = res?.project?.labels?.nodes?.find((l: any) => l.title === title); if (node?.id) ids[title] = node.id; } return ids; } async updateIssueComposite( projectPath: string, iid: string, options: { title?: string; description?: string; assigneeUsernames?: string[]; labelNames?: string[]; dueDate?: string; }, userConfig?: UserConfig ): Promise<any> { await this.introspectSchema(userConfig); const mutationType = this.schema?.getMutationType(); const fields = mutationType ? mutationType.getFields() : {}; const issueId = await this.getIssueId(projectPath, iid, userConfig); const assigneeIdsMap = await this.getUserIdsByUsernames(options.assigneeUsernames || [], userConfig); const assigneeIds = Object.values(assigneeIdsMap); const labelIdsMap = await this.getLabelIds(projectPath, options.labelNames || [], userConfig); const labelIds = Object.values(labelIdsMap); const results: any = { iid, projectPath }; // Title/description/dueDate via updateIssue if available if (fields['updateIssue']) { const mutation = gql` mutation UpdateIssue($input: UpdateIssueInput!) { updateIssue(input: $input) { issue { id iid title description dueDate webUrl updatedAt } errors } } `; const input: any = { projectPath, iid }; if (options.title) input.title = options.title; if (options.description) input.description = options.description; if (options.dueDate) input.dueDate = options.dueDate; if (labelIds.length > 0) input.labelIds = labelIds; if (assigneeIds.length > 0) input.assigneeIds = assigneeIds; const res = await this.query(mutation, { input }, userConfig, true); results.updateIssue = res.updateIssue; } else { // Fallback to granular mutations if present if (assigneeIds.length > 0 && fields['issueSetAssignees']) { const mutation = gql` mutation SetAssignees($input: IssueSetAssigneesInput!) { issueSetAssignees(input: $input) { issue { id iid assignees { nodes { username } } } errors } } `; const res = await this.query(mutation, { input: { issueId, assigneeIds } }, userConfig, true); results.issueSetAssignees = res.issueSetAssignees; } if (labelIds.length > 0 && fields['issueSetLabels']) { const mutation = gql` mutation SetLabels($input: IssueSetLabelsInput!) { issueSetLabels(input: $input) { issue { id iid labels { nodes { title } } } errors } } `; const res = await this.query(mutation, { input: { issueId, labelIds } }, userConfig, true); results.issueSetLabels = res.issueSetLabels; } if (options.dueDate && fields['issueSetDueDate']) { const mutation = gql` mutation SetDueDate($input: IssueSetDueDateInput!) { issueSetDueDate(input: $input) { issue { id iid dueDate } errors } } `; const res = await this.query(mutation, { input: { issueId, dueDate: options.dueDate } }, userConfig, true); results.issueSetDueDate = res.issueSetDueDate; } } return results; } async getMergeRequestId(projectPath: string, iid: string, userConfig?: UserConfig): Promise<string> { const query = gql` query mrId($projectPath: ID!, $iid: String!) { project(fullPath: $projectPath) { mergeRequest(iid: $iid) { id } } } `; const result = await this.query(query, { projectPath, iid }, userConfig); const id = result?.project?.mergeRequest?.id; if (!id) throw new Error('Merge request not found'); return id; } async updateMergeRequestComposite( projectPath: string, iid: string, options: { title?: string; description?: string; assigneeUsernames?: string[]; reviewerUsernames?: string[]; labelNames?: string[]; }, userConfig?: UserConfig ): Promise<any> { await this.introspectSchema(userConfig); const mutationType = this.schema?.getMutationType(); const fields = mutationType ? mutationType.getFields() : {}; const mrId = await this.getMergeRequestId(projectPath, iid, userConfig); const assigneeIdsMap = await this.getUserIdsByUsernames(options.assigneeUsernames || [], userConfig); const assigneeIds = Object.values(assigneeIdsMap); const reviewerIdsMap = await this.getUserIdsByUsernames(options.reviewerUsernames || [], userConfig); const reviewerIds = Object.values(reviewerIdsMap); const labelIdsMap = await this.getLabelIds(projectPath, options.labelNames || [], userConfig); const labelIds = Object.values(labelIdsMap); const results: any = { iid, projectPath }; if (fields['updateMergeRequest']) { const mutation = gql` mutation UpdateMergeRequest($input: UpdateMergeRequestInput!) { updateMergeRequest(input: $input) { mergeRequest { id iid title description webUrl updatedAt labels { nodes { title } } assignees { nodes { username } } } errors } } `; const input: any = { projectPath, iid }; if (options.title) input.title = options.title; if (options.description) input.description = options.description; if (labelIds.length > 0) input.labelIds = labelIds; if (assigneeIds.length > 0) input.assigneeIds = assigneeIds; const res = await this.query(mutation, { input }, userConfig, true); results.updateMergeRequest = res.updateMergeRequest; } else { if (assigneeIds.length > 0 && fields['mergeRequestSetAssignees']) { const mutation = gql` mutation SetMRAssignees($input: MergeRequestSetAssigneesInput!) { mergeRequestSetAssignees(input: $input) { mergeRequest { id iid assignees { nodes { username } } } errors } } `; const res = await this.query(mutation, { input: { mergeRequestId: mrId, assigneeIds } }, userConfig, true); results.mergeRequestSetAssignees = res.mergeRequestSetAssignees; } if (labelIds.length > 0 && fields['mergeRequestSetLabels']) { const mutation = gql` mutation SetMRLabels($input: MergeRequestSetLabelsInput!) { mergeRequestSetLabels(input: $input) { mergeRequest { id iid labels { nodes { title } } } errors } } `; const res = await this.query(mutation, { input: { mergeRequestId: mrId, labelIds } }, userConfig, true); results.mergeRequestSetLabels = res.mergeRequestSetLabels; } if (reviewerIds.length > 0 && fields['mergeRequestSetReviewers']) { const mutation = gql` mutation SetMRReviewers($input: MergeRequestSetReviewersInput!) { mergeRequestSetReviewers(input: $input) { mergeRequest { id iid reviewers { nodes { username } } } errors } } `; const res = await this.query(mutation, { input: { mergeRequestId: mrId, reviewerIds } }, userConfig, true); results.mergeRequestSetReviewers = res.mergeRequestSetReviewers; } if (options.title || options.description) { // Attempt legacy/update fallback if available const legacyName = fields['mergeRequestUpdate'] ? 'mergeRequestUpdate' : undefined; if (legacyName) { const mutation = gql` mutation LegacyMRUpdate($input: MergeRequestUpdateInput!) { mergeRequestUpdate(input: $input) { mergeRequest { id iid title description } errors } } `; const input: any = { mergeRequestId: mrId }; if (options.title) input.title = options.title; if (options.description) input.description = options.description; const res = await this.query(mutation, { input }, userConfig, true); results.mergeRequestUpdate = res.mergeRequestUpdate; } } } return results; } getTypeFields(typeName: string): string[] { if (!this.schema) return []; const type = this.schema.getType(typeName); if (!type || typeof (type as any).getFields !== 'function') return []; const fields = (type as any).getFields(); return Object.keys(fields); } // Search methods async globalSearch(searchTerm?: string, scope?: string, userConfig?: UserConfig): Promise<any> { const query = gql` query globalSearch($search: String, $first: Int!) { projects(search: $search, first: $first) { nodes { id name fullPath description webUrl visibility lastActivityAt } } issues(search: $search, first: $first) { nodes { id iid title description state webUrl createdAt updatedAt author { username name } project { fullPath } } } mergeRequests(search: $search, first: $first) { nodes { id iid title description state webUrl createdAt updatedAt author { username name } project { fullPath } } } } `; return this.query(query, { search: searchTerm || undefined, first: this.config.maxPageSize }, userConfig); } async searchProjects(searchTerm: string, first: number = 20, after?: string, userConfig?: UserConfig): Promise<any> { const query = gql` query searchProjects($search: String!, $first: Int!, $after: String) { projects(search: $search, first: $first, after: $after) { pageInfo { hasNextPage hasPreviousPage startCursor endCursor } nodes { id name fullPath description webUrl visibility createdAt updatedAt lastActivityAt issuesEnabled mergeRequestsEnabled starCount forksCount } } } `; return this.query(query, { search: searchTerm, first, after }, userConfig); } private getTypeName(t: any): string | undefined { if (!t) return undefined; return t.name || (t.ofType ? this.getTypeName(t.ofType) : undefined); } private getEnumValues(enumTypeName: string | undefined): string[] { if (!enumTypeName || !this.schema) return []; const enumType = this.schema.getType(enumTypeName); const values = (enumType && typeof (enumType as any).getValues === 'function') ? (enumType as any).getValues() : []; return Array.isArray(values) ? values.map((v: any) => v.name) : []; } async searchIssues( searchTerm: string, projectPath?: string, state?: string, first: number = 20, after?: string, userConfig?: UserConfig ): Promise<any> { await this.introspectSchema(userConfig); const mappedState = state && state.toLowerCase() !== 'all' ? state.toUpperCase() : undefined; if (projectPath) { const projectType = this.schema.getType('Project'); const projFields = projectType?.getFields?.() || {}; const issuesField = projFields['issues']; const stateArgType = issuesField?.args?.find((a: any) => a.name === 'state')?.type; const stateEnum = this.getTypeName(stateArgType) || 'IssueState'; const allowed = this.getEnumValues(stateEnum).map(v => String(v)); const mapped = state ? (allowed.find(v => v.toLowerCase() === state.toLowerCase()) || undefined) : undefined; const query = gql` query searchIssuesProject($projectPath: ID!, $search: String, $state: ${stateEnum}, $first: Int!, $after: String) { project(fullPath: $projectPath) { issues(search: $search, state: $state, first: $first, after: $after) { pageInfo { hasNextPage hasPreviousPage startCursor endCursor } nodes { id iid title description state webUrl createdAt updatedAt closedAt author { id username name } assignees { nodes { username name } } labels { nodes { title color description } } } } } } `; return this.query(query, { projectPath, search: searchTerm, state: mapped, first, after }, userConfig); } else { const queryType = this.schema.getQueryType(); const qFields = queryType?.getFields?.() || {}; const issuesField = qFields['issues']; const stateArgType = issuesField?.args?.find((a: any) => a.name === 'state')?.type; const stateEnum = this.getTypeName(stateArgType) || (this.schema.getType('IssuableState') ? 'IssuableState' : 'IssueState'); const allowed = this.getEnumValues(stateEnum).map(v => String(v)); const mapped = state ? (allowed.find(v => v.toLowerCase() === state.toLowerCase()) || undefined) : undefined; const query = gql` query searchIssuesGlobal($search: String, $state: ${stateEnum}, $first: Int!, $after: String) { issues(search: $search, state: $state, first: $first, after: $after) { pageInfo { hasNextPage hasPreviousPage startCursor endCursor } nodes { id iid title description state webUrl createdAt updatedAt closedAt author { id username name } assignees { nodes { username name } } labels { nodes { title color description } } } } } `; return this.query(query, { search: searchTerm, state: mapped, first, after }, userConfig); } } async searchMergeRequests( searchTerm: string, projectPath?: string, state?: string, first: number = 20, after?: string, userConfig?: UserConfig ): Promise<any> { const mappedState = state && state.toLowerCase() !== 'all' ? state.toUpperCase() : undefined; if (projectPath) { const query = gql` query searchMergeRequestsProject($projectPath: ID!, $search: String, $state: MergeRequestState, $first: Int!, $after: String) { project(fullPath: $projectPath) { mergeRequests(search: $search, state: $state, first: $first, after: $after) { pageInfo { hasNextPage hasPreviousPage startCursor endCursor } nodes { id iid title description state webUrl createdAt updatedAt mergedAt sourceBranch targetBranch author { id username name } assignees { nodes { username name } } reviewers { nodes { username name } } labels { nodes { title color description } } } } } } `; return this.query(query, { projectPath, search: searchTerm, state: mappedState, first, after }, userConfig); } else { // GitLab doesn't support global MR search, so search in projects that match the search term // This makes the tool more intuitive - find relevant projects first, then search their MRs const projectsQuery = gql` query findProjectsForMRSearch($search: String!, $first: Int!) { projects(search: $search, first: $first) { nodes { fullPath name } } } `; const projectsResult = await this.query(projectsQuery, { search: searchTerm, first: Math.min(5, first) // Search in top 5 matching projects }, userConfig); if (!projectsResult?.projects?.nodes || projectsResult.projects.nodes.length === 0) { return { pageInfo: { hasNextPage: false, hasPreviousPage: false }, nodes: [], _note: `No projects found matching "${searchTerm}". Try searching with a project name or providing projectPath.` }; } // Search MRs in each found project const allMRs: any[] = []; for (const project of projectsResult.projects.nodes) { try { const mrResult = await this.searchMergeRequests( searchTerm, project.fullPath, state, first, after, userConfig ); if (mrResult?.project?.mergeRequests?.nodes) { allMRs.push(...mrResult.project.mergeRequests.nodes); } } catch (e) { // Skip projects where MR search fails (permissions, etc.) continue; } } // Sort by most recently updated and limit to requested count const sortedMRs = allMRs .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) .slice(0, first); return { pageInfo: { hasNextPage: allMRs.length > first, hasPreviousPage: false }, nodes: sortedMRs, _searchedProjects: projectsResult.projects.nodes.map((p: any) => p.fullPath) }; } } async searchRepositoryFiles( projectPath: string, path: string, ref?: string, userConfig?: UserConfig ): Promise<any> { const query = gql` query searchRepositoryFiles($projectPath: ID!, $path: String, $ref: String) { project(fullPath: $projectPath) { repository { tree(path: $path, ref: $ref, recursive: true) { blobs { nodes { name path type mode webUrl } } trees { nodes { name path type webUrl } } } } } } `; // Note: This searches file names. For content search, we'd need to use the search API return this.query(query, { projectPath, path: path || "", ref: ref || "HEAD" }, userConfig); } async resolvePath(fullPath: string, first: number = 20, after?: string, userConfig?: UserConfig): Promise<any> { const query = gql` query resolvePath($fullPath: ID!, $first: Int!, $after: String) { project: project(fullPath: $fullPath) { id name fullPath webUrl description visibility } group: group(fullPath: $fullPath) { id name fullPath webUrl description projects(first: $first, after: $after) { pageInfo { hasNextPage endCursor } nodes { id name fullPath webUrl visibility lastActivityAt } } } } `; return this.query(query, { fullPath, first, after }, userConfig); } async getGroup(fullPath: string, first: number = 20, after?: string, searchTerm?: string, userConfig?: UserConfig): Promise<any> { const query = gql` query getGroup($fullPath: ID!, $first: Int!, $after: String, $search: String) { group(fullPath: $fullPath) { id name fullPath webUrl description projects(first: $first, after: $after, search: $search) { pageInfo { hasNextPage endCursor } nodes { id name fullPath webUrl visibility lastActivityAt } } } } `; return this.query(query, { fullPath, first, after, search: searchTerm }, userConfig); } async getFileContent( projectPath: string, filePath: string, ref?: string, userConfig?: UserConfig ): Promise<any> { const query = gql` query getFileContent($projectPath: ID!, $path: String!, $ref: String) { project(fullPath: $projectPath) { repository { blobs(paths: [$path], ref: $ref) { nodes { name path rawBlob size webUrl lfsOid } } } } } `; return this.query(query, { projectPath, path: filePath, ref: ref || "HEAD" }, userConfig); } async searchUsers(searchTerm: string, first: number = 20, userConfig?: UserConfig): Promise<any> { const query = gql` query searchUsers($search: String!, $first: Int!) { users(search: $search, first: $first) { nodes { id username name email avatarUrl webUrl publicEmail location bio } } } `; return this.query(query, { search: searchTerm, first }, userConfig); } async searchGroups(searchTerm: string, first: number = 20, userConfig?: UserConfig): Promise<any> { const query = gql` query searchGroups($search: String!, $first: Int!) { groups(search: $search, first: $first) { nodes { id name fullName fullPath description webUrl visibility avatarUrl createdAt } } } `; return this.query(query, { search: searchTerm, first }, userConfig); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/ttpears/gitlab-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server