From 0c0ceb8da3d0ec2081320950000575c72c3300dd Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Thu, 16 Jul 2026 01:51:05 -0700 Subject: [PATCH 1/7] feat(gitlab): add access, membership, and user-admin tools Adds member, invitation, access-request, SAML group link, and user administration tools to the GitLab integration. Resource-scoped ops work against projects or groups; user-admin ops require an admin token. All tools reuse the existing host/SSRF guard via getGitLabApiBase and add a shared getGitLabResourcePath helper. --- apps/sim/tools/gitlab/add_member.ts | 128 +++++++ apps/sim/tools/gitlab/add_saml_group_link.ts | 106 ++++++ .../tools/gitlab/approve_access_request.ts | 101 ++++++ apps/sim/tools/gitlab/create_user.ts | 118 +++++++ .../tools/gitlab/delete_saml_group_link.ts | 80 +++++ apps/sim/tools/gitlab/delete_user.ts | 79 +++++ apps/sim/tools/gitlab/delete_user_identity.ts | 80 +++++ apps/sim/tools/gitlab/deny_access_request.ts | 85 +++++ apps/sim/tools/gitlab/index.ts | 58 ++++ apps/sim/tools/gitlab/invite_member.ts | 134 ++++++++ apps/sim/tools/gitlab/list_access_requests.ts | 105 ++++++ apps/sim/tools/gitlab/list_invitations.ts | 112 ++++++ apps/sim/tools/gitlab/list_members.ts | 116 +++++++ .../sim/tools/gitlab/list_saml_group_links.ts | 81 +++++ apps/sim/tools/gitlab/remove_member.ts | 82 +++++ apps/sim/tools/gitlab/revoke_invitation.ts | 86 +++++ apps/sim/tools/gitlab/search_users.ts | 93 +++++ apps/sim/tools/gitlab/types.ts | 320 ++++++++++++++++++ apps/sim/tools/gitlab/update_invitation.ts | 110 ++++++ apps/sim/tools/gitlab/update_member.ts | 114 +++++++ apps/sim/tools/gitlab/update_user.ts | 102 ++++++ apps/sim/tools/gitlab/user_status_actions.ts | 134 ++++++++ apps/sim/tools/gitlab/utils.ts | 21 ++ apps/sim/tools/registry.ts | 54 +++ 24 files changed, 2499 insertions(+) create mode 100644 apps/sim/tools/gitlab/add_member.ts create mode 100644 apps/sim/tools/gitlab/add_saml_group_link.ts create mode 100644 apps/sim/tools/gitlab/approve_access_request.ts create mode 100644 apps/sim/tools/gitlab/create_user.ts create mode 100644 apps/sim/tools/gitlab/delete_saml_group_link.ts create mode 100644 apps/sim/tools/gitlab/delete_user.ts create mode 100644 apps/sim/tools/gitlab/delete_user_identity.ts create mode 100644 apps/sim/tools/gitlab/deny_access_request.ts create mode 100644 apps/sim/tools/gitlab/invite_member.ts create mode 100644 apps/sim/tools/gitlab/list_access_requests.ts create mode 100644 apps/sim/tools/gitlab/list_invitations.ts create mode 100644 apps/sim/tools/gitlab/list_members.ts create mode 100644 apps/sim/tools/gitlab/list_saml_group_links.ts create mode 100644 apps/sim/tools/gitlab/remove_member.ts create mode 100644 apps/sim/tools/gitlab/revoke_invitation.ts create mode 100644 apps/sim/tools/gitlab/search_users.ts create mode 100644 apps/sim/tools/gitlab/update_invitation.ts create mode 100644 apps/sim/tools/gitlab/update_member.ts create mode 100644 apps/sim/tools/gitlab/update_user.ts create mode 100644 apps/sim/tools/gitlab/user_status_actions.ts diff --git a/apps/sim/tools/gitlab/add_member.ts b/apps/sim/tools/gitlab/add_member.ts new file mode 100644 index 00000000000..afe849ff49d --- /dev/null +++ b/apps/sim/tools/gitlab/add_member.ts @@ -0,0 +1,128 @@ +import type { GitLabAddMemberParams, GitLabAddMemberResponse } from '@/tools/gitlab/types' +import { getGitLabApiBase, getGitLabResourcePath } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabAddMemberTool: ToolConfig = { + id: 'gitlab_add_member', + name: 'GitLab Add Member', + description: 'Add an existing GitLab user to a project or group at a given access level', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + resourceType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Whether the resource is a 'project' or a 'group'", + }, + resourceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project or group ID or URL-encoded path', + }, + userId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the user to add', + }, + accessLevel: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: + 'Access level: 10 (Guest), 20 (Reporter), 30 (Developer), 40 (Maintainer), 50 (Owner)', + }, + expiresAt: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Access expiration date in YYYY-MM-DD format', + }, + memberRoleId: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Custom member role ID (GitLab Ultimate only)', + }, + }, + + request: { + url: (params) => { + const resourcePath = getGitLabResourcePath(params.resourceType, params.resourceId) + return `${getGitLabApiBase(params.host)}/${resourcePath}/members` + }, + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + 'PRIVATE-TOKEN': params.accessToken, + }), + body: (params) => { + const body: Record = { + user_id: params.userId, + access_level: params.accessLevel, + } + + if (params.expiresAt) body.expires_at = params.expiresAt + if (params.memberRoleId !== undefined) body.member_role_id = params.memberRoleId + + return body + }, + }, + + transformResponse: async (response) => { + // A 409 means the user is already a member. Treat it as a soft success so + // provisioning workflows remain safely re-runnable. + if (response.status === 409) { + return { + success: true, + output: { + alreadyMember: true, + }, + } + } + + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + const member = await response.json() + + return { + success: true, + output: { + member, + alreadyMember: false, + }, + } + }, + + outputs: { + member: { + type: 'object', + description: 'The added member', + }, + alreadyMember: { + type: 'boolean', + description: 'Whether the user was already a member (add was a no-op)', + }, + }, +} diff --git a/apps/sim/tools/gitlab/add_saml_group_link.ts b/apps/sim/tools/gitlab/add_saml_group_link.ts new file mode 100644 index 00000000000..47e9cca5c27 --- /dev/null +++ b/apps/sim/tools/gitlab/add_saml_group_link.ts @@ -0,0 +1,106 @@ +import type { + GitLabAddSamlGroupLinkParams, + GitLabSamlGroupLinkResponse, +} from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabAddSamlGroupLinkTool: ToolConfig< + GitLabAddSamlGroupLinkParams, + GitLabSamlGroupLinkResponse +> = { + id: 'gitlab_add_saml_group_link', + name: 'GitLab Add SAML Group Link', + description: + 'Add a SAML group link that maps an identity-provider group to a GitLab group at a given access level', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token with group Owner rights', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + groupId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Group ID or URL-encoded path', + }, + samlGroupName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The name of the SAML group as sent by the identity provider', + }, + accessLevel: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: + 'Access level granted to members of the SAML group: 10 (Guest), 20 (Reporter), 30 (Developer), 40 (Maintainer), 50 (Owner)', + }, + memberRoleId: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Custom member role ID (GitLab Ultimate only)', + }, + }, + + request: { + url: (params) => { + const encodedId = encodeURIComponent(String(params.groupId).trim()) + return `${getGitLabApiBase(params.host)}/groups/${encodedId}/saml_group_links` + }, + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + 'PRIVATE-TOKEN': params.accessToken, + }), + body: (params) => { + const body: Record = { + saml_group_name: params.samlGroupName, + access_level: params.accessLevel, + } + + if (params.memberRoleId !== undefined) body.member_role_id = params.memberRoleId + + return body + }, + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + const samlGroupLink = await response.json() + + return { + success: true, + output: { + samlGroupLink, + }, + } + }, + + outputs: { + samlGroupLink: { + type: 'object', + description: 'The created SAML group link', + }, + }, +} diff --git a/apps/sim/tools/gitlab/approve_access_request.ts b/apps/sim/tools/gitlab/approve_access_request.ts new file mode 100644 index 00000000000..4c307b91579 --- /dev/null +++ b/apps/sim/tools/gitlab/approve_access_request.ts @@ -0,0 +1,101 @@ +import type { + GitLabApproveAccessRequestParams, + GitLabApproveAccessRequestResponse, +} from '@/tools/gitlab/types' +import { getGitLabApiBase, getGitLabResourcePath } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabApproveAccessRequestTool: ToolConfig< + GitLabApproveAccessRequestParams, + GitLabApproveAccessRequestResponse +> = { + id: 'gitlab_approve_access_request', + name: 'GitLab Approve Access Request', + description: 'Approve a pending access request for a GitLab project or group', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + resourceType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Whether the resource is a 'project' or a 'group'", + }, + resourceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project or group ID or URL-encoded path', + }, + userId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The user ID of the access requester', + }, + accessLevel: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: + 'Access level to grant: 10 (Guest), 20 (Reporter), 30 (Developer), 40 (Maintainer), 50 (Owner). Defaults to 30 (Developer).', + }, + }, + + request: { + url: (params) => { + const resourcePath = getGitLabResourcePath(params.resourceType, params.resourceId) + const queryParams = new URLSearchParams() + + if (params.accessLevel !== undefined) { + queryParams.append('access_level', String(params.accessLevel)) + } + + const query = queryParams.toString() + return `${getGitLabApiBase(params.host)}/${resourcePath}/access_requests/${params.userId}/approve${query ? `?${query}` : ''}` + }, + method: 'PUT', + headers: (params) => ({ + 'PRIVATE-TOKEN': params.accessToken, + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + const accessRequest = await response.json() + + return { + success: true, + output: { + accessRequest, + }, + } + }, + + outputs: { + accessRequest: { + type: 'object', + description: 'The approved access request', + }, + }, +} diff --git a/apps/sim/tools/gitlab/create_user.ts b/apps/sim/tools/gitlab/create_user.ts new file mode 100644 index 00000000000..694a049e218 --- /dev/null +++ b/apps/sim/tools/gitlab/create_user.ts @@ -0,0 +1,118 @@ +import type { GitLabCreateUserParams, GitLabUserResponse } from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabCreateUserTool: ToolConfig = { + id: 'gitlab_create_user', + name: 'GitLab Create User', + description: + 'Create a new GitLab user. Requires an administrator token with admin_mode on the instance.', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab admin Personal Access Token (admin_mode)', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + email: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "The user's email address", + }, + username: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "The user's username", + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "The user's display name", + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: "The user's password. Omit and set resetPassword to email a reset link instead.", + }, + resetPassword: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Send the user a password reset link instead of setting a password', + }, + admin: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the new user is an administrator', + }, + skipConfirmation: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Skip email confirmation for the new user', + }, + }, + + request: { + url: (params) => `${getGitLabApiBase(params.host)}/users`, + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + 'PRIVATE-TOKEN': params.accessToken, + }), + body: (params) => { + const body: Record = { + email: params.email, + username: params.username, + name: params.name, + } + + if (params.password) body.password = params.password + if (params.resetPassword !== undefined) body.reset_password = params.resetPassword + if (params.admin !== undefined) body.admin = params.admin + if (params.skipConfirmation !== undefined) body.skip_confirmation = params.skipConfirmation + + return body + }, + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + const user = await response.json() + + return { + success: true, + output: { + user, + }, + } + }, + + outputs: { + user: { + type: 'object', + description: 'The created user', + }, + }, +} diff --git a/apps/sim/tools/gitlab/delete_saml_group_link.ts b/apps/sim/tools/gitlab/delete_saml_group_link.ts new file mode 100644 index 00000000000..69b4ddbff9b --- /dev/null +++ b/apps/sim/tools/gitlab/delete_saml_group_link.ts @@ -0,0 +1,80 @@ +import type { + GitLabDeleteSamlGroupLinkParams, + GitLabDeleteSamlGroupLinkResponse, +} from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabDeleteSamlGroupLinkTool: ToolConfig< + GitLabDeleteSamlGroupLinkParams, + GitLabDeleteSamlGroupLinkResponse +> = { + id: 'gitlab_delete_saml_group_link', + name: 'GitLab Delete SAML Group Link', + description: 'Delete a SAML group link from a GitLab group', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token with group Owner rights', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + groupId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Group ID or URL-encoded path', + }, + samlGroupName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The name of the SAML group link to delete', + }, + }, + + request: { + url: (params) => { + const encodedId = encodeURIComponent(String(params.groupId).trim()) + const encodedName = encodeURIComponent(String(params.samlGroupName).trim()) + return `${getGitLabApiBase(params.host)}/groups/${encodedId}/saml_group_links/${encodedName}` + }, + method: 'DELETE', + headers: (params) => ({ + 'PRIVATE-TOKEN': params.accessToken, + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + return { + success: true, + output: { + success: true, + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the SAML group link was deleted successfully', + }, + }, +} diff --git a/apps/sim/tools/gitlab/delete_user.ts b/apps/sim/tools/gitlab/delete_user.ts new file mode 100644 index 00000000000..acb9d29d40d --- /dev/null +++ b/apps/sim/tools/gitlab/delete_user.ts @@ -0,0 +1,79 @@ +import type { GitLabDeleteUserParams, GitLabDeleteUserResponse } from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabDeleteUserTool: ToolConfig = { + id: 'gitlab_delete_user', + name: 'GitLab Delete User', + description: + 'Delete a GitLab user. Requires an administrator token with admin_mode on the instance.', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab admin Personal Access Token (admin_mode)', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + userId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the user to delete', + }, + hardDelete: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + 'When true, contributions and personal projects are deleted rather than moved to a Ghost User', + }, + }, + + request: { + url: (params) => { + const queryParams = new URLSearchParams() + if (params.hardDelete !== undefined) { + queryParams.append('hard_delete', String(params.hardDelete)) + } + const query = queryParams.toString() + return `${getGitLabApiBase(params.host)}/users/${params.userId}${query ? `?${query}` : ''}` + }, + method: 'DELETE', + headers: (params) => ({ + 'PRIVATE-TOKEN': params.accessToken, + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + return { + success: true, + output: { + success: true, + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the user was deleted successfully', + }, + }, +} diff --git a/apps/sim/tools/gitlab/delete_user_identity.ts b/apps/sim/tools/gitlab/delete_user_identity.ts new file mode 100644 index 00000000000..2e0087ac987 --- /dev/null +++ b/apps/sim/tools/gitlab/delete_user_identity.ts @@ -0,0 +1,80 @@ +import type { + GitLabDeleteUserIdentityParams, + GitLabDeleteUserIdentityResponse, +} from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabDeleteUserIdentityTool: ToolConfig< + GitLabDeleteUserIdentityParams, + GitLabDeleteUserIdentityResponse +> = { + id: 'gitlab_delete_user_identity', + name: 'GitLab Delete User Identity', + description: + "Delete a user's authentication identity (e.g. SAML or LDAP). Requires an administrator token with admin_mode on the instance.", + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab admin Personal Access Token (admin_mode)', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + userId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the user', + }, + provider: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The external identity provider name (e.g. saml, ldapmain)', + }, + }, + + request: { + url: (params) => { + const encodedProvider = encodeURIComponent(String(params.provider).trim()) + return `${getGitLabApiBase(params.host)}/users/${params.userId}/identities/${encodedProvider}` + }, + method: 'DELETE', + headers: (params) => ({ + 'PRIVATE-TOKEN': params.accessToken, + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + return { + success: true, + output: { + success: true, + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the identity was deleted successfully', + }, + }, +} diff --git a/apps/sim/tools/gitlab/deny_access_request.ts b/apps/sim/tools/gitlab/deny_access_request.ts new file mode 100644 index 00000000000..0c39c28f319 --- /dev/null +++ b/apps/sim/tools/gitlab/deny_access_request.ts @@ -0,0 +1,85 @@ +import type { + GitLabDenyAccessRequestParams, + GitLabDenyAccessRequestResponse, +} from '@/tools/gitlab/types' +import { getGitLabApiBase, getGitLabResourcePath } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabDenyAccessRequestTool: ToolConfig< + GitLabDenyAccessRequestParams, + GitLabDenyAccessRequestResponse +> = { + id: 'gitlab_deny_access_request', + name: 'GitLab Deny Access Request', + description: 'Deny (delete) a pending access request for a GitLab project or group', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + resourceType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Whether the resource is a 'project' or a 'group'", + }, + resourceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project or group ID or URL-encoded path', + }, + userId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The user ID of the access requester', + }, + }, + + request: { + url: (params) => { + const resourcePath = getGitLabResourcePath(params.resourceType, params.resourceId) + return `${getGitLabApiBase(params.host)}/${resourcePath}/access_requests/${params.userId}` + }, + method: 'DELETE', + headers: (params) => ({ + 'PRIVATE-TOKEN': params.accessToken, + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + return { + success: true, + output: { + success: true, + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the access request was denied successfully', + }, + }, +} diff --git a/apps/sim/tools/gitlab/index.ts b/apps/sim/tools/gitlab/index.ts index a87fd9de27f..6bcf8513b91 100644 --- a/apps/sim/tools/gitlab/index.ts +++ b/apps/sim/tools/gitlab/index.ts @@ -1,3 +1,6 @@ +import { gitlabAddMemberTool } from '@/tools/gitlab/add_member' +import { gitlabAddSamlGroupLinkTool } from '@/tools/gitlab/add_saml_group_link' +import { gitlabApproveAccessRequestTool } from '@/tools/gitlab/approve_access_request' import { gitlabApproveMergeRequestTool } from '@/tools/gitlab/approve_merge_request' import { gitlabCancelPipelineTool } from '@/tools/gitlab/cancel_pipeline' import { gitlabCompareBranchesTool } from '@/tools/gitlab/compare_branches' @@ -9,8 +12,13 @@ import { gitlabCreateMergeRequestTool } from '@/tools/gitlab/create_merge_reques import { gitlabCreateMergeRequestNoteTool } from '@/tools/gitlab/create_merge_request_note' import { gitlabCreatePipelineTool } from '@/tools/gitlab/create_pipeline' import { gitlabCreateReleaseTool } from '@/tools/gitlab/create_release' +import { gitlabCreateUserTool } from '@/tools/gitlab/create_user' import { gitlabDeleteBranchTool } from '@/tools/gitlab/delete_branch' import { gitlabDeleteIssueTool } from '@/tools/gitlab/delete_issue' +import { gitlabDeleteSamlGroupLinkTool } from '@/tools/gitlab/delete_saml_group_link' +import { gitlabDeleteUserTool } from '@/tools/gitlab/delete_user' +import { gitlabDeleteUserIdentityTool } from '@/tools/gitlab/delete_user_identity' +import { gitlabDenyAccessRequestTool } from '@/tools/gitlab/deny_access_request' import { gitlabGetFileTool } from '@/tools/gitlab/get_file' import { gitlabGetIssueTool } from '@/tools/gitlab/get_issue' import { gitlabGetJobLogTool } from '@/tools/gitlab/get_job_log' @@ -18,21 +26,42 @@ import { gitlabGetMergeRequestTool } from '@/tools/gitlab/get_merge_request' import { gitlabGetMergeRequestChangesTool } from '@/tools/gitlab/get_merge_request_changes' import { gitlabGetPipelineTool } from '@/tools/gitlab/get_pipeline' import { gitlabGetProjectTool } from '@/tools/gitlab/get_project' +import { gitlabInviteMemberTool } from '@/tools/gitlab/invite_member' +import { gitlabListAccessRequestsTool } from '@/tools/gitlab/list_access_requests' import { gitlabListBranchesTool } from '@/tools/gitlab/list_branches' import { gitlabListCommitsTool } from '@/tools/gitlab/list_commits' +import { gitlabListInvitationsTool } from '@/tools/gitlab/list_invitations' import { gitlabListIssuesTool } from '@/tools/gitlab/list_issues' +import { gitlabListMembersTool } from '@/tools/gitlab/list_members' import { gitlabListMergeRequestsTool } from '@/tools/gitlab/list_merge_requests' import { gitlabListPipelineJobsTool } from '@/tools/gitlab/list_pipeline_jobs' import { gitlabListPipelinesTool } from '@/tools/gitlab/list_pipelines' import { gitlabListProjectsTool } from '@/tools/gitlab/list_projects' import { gitlabListReleasesTool } from '@/tools/gitlab/list_releases' import { gitlabListRepositoryTreeTool } from '@/tools/gitlab/list_repository_tree' +import { gitlabListSamlGroupLinksTool } from '@/tools/gitlab/list_saml_group_links' import { gitlabMergeMergeRequestTool } from '@/tools/gitlab/merge_merge_request' import { gitlabPlayJobTool } from '@/tools/gitlab/play_job' +import { gitlabRemoveMemberTool } from '@/tools/gitlab/remove_member' import { gitlabRetryPipelineTool } from '@/tools/gitlab/retry_pipeline' +import { gitlabRevokeInvitationTool } from '@/tools/gitlab/revoke_invitation' +import { gitlabSearchUsersTool } from '@/tools/gitlab/search_users' import { gitlabUpdateFileTool } from '@/tools/gitlab/update_file' +import { gitlabUpdateInvitationTool } from '@/tools/gitlab/update_invitation' import { gitlabUpdateIssueTool } from '@/tools/gitlab/update_issue' +import { gitlabUpdateMemberTool } from '@/tools/gitlab/update_member' import { gitlabUpdateMergeRequestTool } from '@/tools/gitlab/update_merge_request' +import { gitlabUpdateUserTool } from '@/tools/gitlab/update_user' +import { + gitlabActivateUserTool, + gitlabApproveUserTool, + gitlabBanUserTool, + gitlabBlockUserTool, + gitlabDeactivateUserTool, + gitlabRejectUserTool, + gitlabUnbanUserTool, + gitlabUnblockUserTool, +} from '@/tools/gitlab/user_status_actions' export { // Projects @@ -79,4 +108,33 @@ export { // Releases gitlabListReleasesTool, gitlabCreateReleaseTool, + // Members / Access + gitlabListMembersTool, + gitlabAddMemberTool, + gitlabUpdateMemberTool, + gitlabRemoveMemberTool, + gitlabInviteMemberTool, + gitlabListInvitationsTool, + gitlabUpdateInvitationTool, + gitlabRevokeInvitationTool, + gitlabListAccessRequestsTool, + gitlabApproveAccessRequestTool, + gitlabDenyAccessRequestTool, + gitlabListSamlGroupLinksTool, + gitlabSearchUsersTool, + // Users (Admin) + gitlabCreateUserTool, + gitlabUpdateUserTool, + gitlabDeleteUserTool, + gitlabBlockUserTool, + gitlabUnblockUserTool, + gitlabDeactivateUserTool, + gitlabActivateUserTool, + gitlabBanUserTool, + gitlabUnbanUserTool, + gitlabApproveUserTool, + gitlabRejectUserTool, + gitlabDeleteUserIdentityTool, + gitlabAddSamlGroupLinkTool, + gitlabDeleteSamlGroupLinkTool, } diff --git a/apps/sim/tools/gitlab/invite_member.ts b/apps/sim/tools/gitlab/invite_member.ts new file mode 100644 index 00000000000..2ee7e64bb94 --- /dev/null +++ b/apps/sim/tools/gitlab/invite_member.ts @@ -0,0 +1,134 @@ +import type { GitLabInviteMemberParams, GitLabInviteMemberResponse } from '@/tools/gitlab/types' +import { getGitLabApiBase, getGitLabResourcePath } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabInviteMemberTool: ToolConfig< + GitLabInviteMemberParams, + GitLabInviteMemberResponse +> = { + id: 'gitlab_invite_member', + name: 'GitLab Invite Member', + description: 'Invite a person to a GitLab project or group by email address', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + resourceType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Whether the resource is a 'project' or a 'group'", + }, + resourceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project or group ID or URL-encoded path', + }, + email: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Email address to invite (comma-separated for multiple)', + }, + accessLevel: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: + 'Access level: 10 (Guest), 20 (Reporter), 30 (Developer), 40 (Maintainer), 50 (Owner)', + }, + expiresAt: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Access expiration date in YYYY-MM-DD format', + }, + memberRoleId: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Custom member role ID (GitLab Ultimate only)', + }, + }, + + request: { + url: (params) => { + const resourcePath = getGitLabResourcePath(params.resourceType, params.resourceId) + return `${getGitLabApiBase(params.host)}/${resourcePath}/invitations` + }, + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + 'PRIVATE-TOKEN': params.accessToken, + }), + body: (params) => { + const body: Record = { + email: params.email, + access_level: params.accessLevel, + } + + if (params.expiresAt) body.expires_at = params.expiresAt + if (params.memberRoleId !== undefined) body.member_role_id = params.memberRoleId + + return body + }, + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + const data = await response.json() + + // GitLab returns { status: 'error', message: {...} } with a 201 when an + // individual email fails, so surface the failure rather than reporting success. + if (data?.status === 'error') { + return { + success: false, + error: + typeof data.message === 'string' ? data.message : JSON.stringify(data.message ?? data), + output: { + status: data.status, + message: data.message, + }, + } + } + + return { + success: true, + output: { + status: data?.status ?? 'success', + message: data?.message, + }, + } + }, + + outputs: { + status: { + type: 'string', + description: 'Invitation status returned by GitLab', + }, + message: { + type: 'object', + description: 'Per-email result detail, if any', + }, + }, +} diff --git a/apps/sim/tools/gitlab/list_access_requests.ts b/apps/sim/tools/gitlab/list_access_requests.ts new file mode 100644 index 00000000000..fb5a27e0e1c --- /dev/null +++ b/apps/sim/tools/gitlab/list_access_requests.ts @@ -0,0 +1,105 @@ +import type { + GitLabListAccessRequestsParams, + GitLabListAccessRequestsResponse, +} from '@/tools/gitlab/types' +import { getGitLabApiBase, getGitLabResourcePath } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabListAccessRequestsTool: ToolConfig< + GitLabListAccessRequestsParams, + GitLabListAccessRequestsResponse +> = { + id: 'gitlab_list_access_requests', + name: 'GitLab List Access Requests', + description: 'List pending access requests for a GitLab project or group', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + resourceType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Whether the resource is a 'project' or a 'group'", + }, + resourceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project or group ID or URL-encoded path', + }, + perPage: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (default 20, max 100)', + }, + page: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Page number for pagination', + }, + }, + + request: { + url: (params) => { + const resourcePath = getGitLabResourcePath(params.resourceType, params.resourceId) + const queryParams = new URLSearchParams() + + if (params.perPage) queryParams.append('per_page', String(params.perPage)) + if (params.page) queryParams.append('page', String(params.page)) + + const query = queryParams.toString() + return `${getGitLabApiBase(params.host)}/${resourcePath}/access_requests${query ? `?${query}` : ''}` + }, + method: 'GET', + headers: (params) => ({ + 'PRIVATE-TOKEN': params.accessToken, + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + const accessRequests = await response.json() + const total = response.headers.get('x-total') + + return { + success: true, + output: { + accessRequests, + total: total ? Number.parseInt(total, 10) : accessRequests.length, + }, + } + }, + + outputs: { + accessRequests: { + type: 'array', + description: 'List of pending access requests', + }, + total: { + type: 'number', + description: 'Total number of access requests', + }, + }, +} diff --git a/apps/sim/tools/gitlab/list_invitations.ts b/apps/sim/tools/gitlab/list_invitations.ts new file mode 100644 index 00000000000..03c0bb38a1c --- /dev/null +++ b/apps/sim/tools/gitlab/list_invitations.ts @@ -0,0 +1,112 @@ +import type { + GitLabListInvitationsParams, + GitLabListInvitationsResponse, +} from '@/tools/gitlab/types' +import { getGitLabApiBase, getGitLabResourcePath } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabListInvitationsTool: ToolConfig< + GitLabListInvitationsParams, + GitLabListInvitationsResponse +> = { + id: 'gitlab_list_invitations', + name: 'GitLab List Invitations', + description: 'List pending email invitations for a GitLab project or group', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + resourceType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Whether the resource is a 'project' or a 'group'", + }, + resourceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project or group ID or URL-encoded path', + }, + query: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter invitations by invited email', + }, + perPage: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (default 20, max 100)', + }, + page: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Page number for pagination', + }, + }, + + request: { + url: (params) => { + const resourcePath = getGitLabResourcePath(params.resourceType, params.resourceId) + const queryParams = new URLSearchParams() + + if (params.query) queryParams.append('query', params.query) + if (params.perPage) queryParams.append('per_page', String(params.perPage)) + if (params.page) queryParams.append('page', String(params.page)) + + const query = queryParams.toString() + return `${getGitLabApiBase(params.host)}/${resourcePath}/invitations${query ? `?${query}` : ''}` + }, + method: 'GET', + headers: (params) => ({ + 'PRIVATE-TOKEN': params.accessToken, + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + const invitations = await response.json() + const total = response.headers.get('x-total') + + return { + success: true, + output: { + invitations, + total: total ? Number.parseInt(total, 10) : invitations.length, + }, + } + }, + + outputs: { + invitations: { + type: 'array', + description: 'List of pending invitations', + }, + total: { + type: 'number', + description: 'Total number of invitations', + }, + }, +} diff --git a/apps/sim/tools/gitlab/list_members.ts b/apps/sim/tools/gitlab/list_members.ts new file mode 100644 index 00000000000..a719afd68cf --- /dev/null +++ b/apps/sim/tools/gitlab/list_members.ts @@ -0,0 +1,116 @@ +import type { GitLabListMembersParams, GitLabListMembersResponse } from '@/tools/gitlab/types' +import { getGitLabApiBase, getGitLabResourcePath } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabListMembersTool: ToolConfig = + { + id: 'gitlab_list_members', + name: 'GitLab List Members', + description: + 'List members of a GitLab project or group. Includes members inherited from ancestor groups by default.', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + resourceType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Whether the resource is a 'project' or a 'group'", + }, + resourceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project or group ID or URL-encoded path', + }, + directOnly: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + 'When true, returns only direct members. Defaults to false, which also returns members inherited from ancestor groups.', + }, + query: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter members by name or username', + }, + perPage: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (default 20, max 100)', + }, + page: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Page number for pagination', + }, + }, + + request: { + url: (params) => { + const resourcePath = getGitLabResourcePath(params.resourceType, params.resourceId) + const membersPath = params.directOnly ? 'members' : 'members/all' + const queryParams = new URLSearchParams() + + if (params.query) queryParams.append('query', params.query) + if (params.perPage) queryParams.append('per_page', String(params.perPage)) + if (params.page) queryParams.append('page', String(params.page)) + + const query = queryParams.toString() + return `${getGitLabApiBase(params.host)}/${resourcePath}/${membersPath}${query ? `?${query}` : ''}` + }, + method: 'GET', + headers: (params) => ({ + 'PRIVATE-TOKEN': params.accessToken, + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + const members = await response.json() + const total = response.headers.get('x-total') + + return { + success: true, + output: { + members, + total: total ? Number.parseInt(total, 10) : members.length, + }, + } + }, + + outputs: { + members: { + type: 'array', + description: 'List of project or group members', + }, + total: { + type: 'number', + description: 'Total number of members', + }, + }, + } diff --git a/apps/sim/tools/gitlab/list_saml_group_links.ts b/apps/sim/tools/gitlab/list_saml_group_links.ts new file mode 100644 index 00000000000..2510bc767e1 --- /dev/null +++ b/apps/sim/tools/gitlab/list_saml_group_links.ts @@ -0,0 +1,81 @@ +import type { + GitLabListSamlGroupLinksParams, + GitLabListSamlGroupLinksResponse, +} from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabListSamlGroupLinksTool: ToolConfig< + GitLabListSamlGroupLinksParams, + GitLabListSamlGroupLinksResponse +> = { + id: 'gitlab_list_saml_group_links', + name: 'GitLab List SAML Group Links', + description: + 'List SAML group links for a GitLab group. Use this to detect whether a group is governed by SAML group sync before provisioning members.', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + groupId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Group ID or URL-encoded path', + }, + }, + + request: { + url: (params) => { + const encodedId = encodeURIComponent(String(params.groupId).trim()) + return `${getGitLabApiBase(params.host)}/groups/${encodedId}/saml_group_links` + }, + method: 'GET', + headers: (params) => ({ + 'PRIVATE-TOKEN': params.accessToken, + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + const samlGroupLinks = await response.json() + + return { + success: true, + output: { + samlGroupLinks, + total: Array.isArray(samlGroupLinks) ? samlGroupLinks.length : 0, + }, + } + }, + + outputs: { + samlGroupLinks: { + type: 'array', + description: 'List of SAML group links', + }, + total: { + type: 'number', + description: 'Number of SAML group links', + }, + }, +} diff --git a/apps/sim/tools/gitlab/remove_member.ts b/apps/sim/tools/gitlab/remove_member.ts new file mode 100644 index 00000000000..ef2fc952218 --- /dev/null +++ b/apps/sim/tools/gitlab/remove_member.ts @@ -0,0 +1,82 @@ +import type { GitLabRemoveMemberParams, GitLabRemoveMemberResponse } from '@/tools/gitlab/types' +import { getGitLabApiBase, getGitLabResourcePath } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabRemoveMemberTool: ToolConfig< + GitLabRemoveMemberParams, + GitLabRemoveMemberResponse +> = { + id: 'gitlab_remove_member', + name: 'GitLab Remove Member', + description: 'Remove a member from a GitLab project or group', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + resourceType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Whether the resource is a 'project' or a 'group'", + }, + resourceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project or group ID or URL-encoded path', + }, + userId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the member to remove', + }, + }, + + request: { + url: (params) => { + const resourcePath = getGitLabResourcePath(params.resourceType, params.resourceId) + return `${getGitLabApiBase(params.host)}/${resourcePath}/members/${params.userId}` + }, + method: 'DELETE', + headers: (params) => ({ + 'PRIVATE-TOKEN': params.accessToken, + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + return { + success: true, + output: { + success: true, + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the member was removed successfully', + }, + }, +} diff --git a/apps/sim/tools/gitlab/revoke_invitation.ts b/apps/sim/tools/gitlab/revoke_invitation.ts new file mode 100644 index 00000000000..81c693ee3f0 --- /dev/null +++ b/apps/sim/tools/gitlab/revoke_invitation.ts @@ -0,0 +1,86 @@ +import type { + GitLabRevokeInvitationParams, + GitLabRevokeInvitationResponse, +} from '@/tools/gitlab/types' +import { getGitLabApiBase, getGitLabResourcePath } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabRevokeInvitationTool: ToolConfig< + GitLabRevokeInvitationParams, + GitLabRevokeInvitationResponse +> = { + id: 'gitlab_revoke_invitation', + name: 'GitLab Revoke Invitation', + description: 'Revoke a pending email invitation to a GitLab project or group', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + resourceType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Whether the resource is a 'project' or a 'group'", + }, + resourceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project or group ID or URL-encoded path', + }, + email: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Email address of the invitation to revoke', + }, + }, + + request: { + url: (params) => { + const resourcePath = getGitLabResourcePath(params.resourceType, params.resourceId) + const encodedEmail = encodeURIComponent(String(params.email).trim()) + return `${getGitLabApiBase(params.host)}/${resourcePath}/invitations/${encodedEmail}` + }, + method: 'DELETE', + headers: (params) => ({ + 'PRIVATE-TOKEN': params.accessToken, + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + return { + success: true, + output: { + success: true, + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the invitation was revoked successfully', + }, + }, +} diff --git a/apps/sim/tools/gitlab/search_users.ts b/apps/sim/tools/gitlab/search_users.ts new file mode 100644 index 00000000000..77f84aab5c2 --- /dev/null +++ b/apps/sim/tools/gitlab/search_users.ts @@ -0,0 +1,93 @@ +import type { GitLabSearchUsersParams, GitLabSearchUsersResponse } from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabSearchUsersTool: ToolConfig = + { + id: 'gitlab_search_users', + name: 'GitLab Search Users', + description: + 'Search for GitLab users by name, username, or email. Use this to resolve an email to a user ID before adding a member.', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + search: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name, username, or email to search for', + }, + perPage: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (default 20, max 100)', + }, + page: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Page number for pagination', + }, + }, + + request: { + url: (params) => { + const queryParams = new URLSearchParams() + queryParams.append('search', String(params.search).trim()) + if (params.perPage) queryParams.append('per_page', String(params.perPage)) + if (params.page) queryParams.append('page', String(params.page)) + + return `${getGitLabApiBase(params.host)}/users?${queryParams.toString()}` + }, + method: 'GET', + headers: (params) => ({ + 'PRIVATE-TOKEN': params.accessToken, + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + const users = await response.json() + const total = response.headers.get('x-total') + + return { + success: true, + output: { + users, + total: total ? Number.parseInt(total, 10) : users.length, + }, + } + }, + + outputs: { + users: { + type: 'array', + description: 'List of matching users', + }, + total: { + type: 'number', + description: 'Total number of matching users', + }, + }, + } diff --git a/apps/sim/tools/gitlab/types.ts b/apps/sim/tools/gitlab/types.ts index 694244d6325..457d2a47237 100644 --- a/apps/sim/tools/gitlab/types.ts +++ b/apps/sim/tools/gitlab/types.ts @@ -1,3 +1,4 @@ +import type { GitLabResourceType } from '@/tools/gitlab/utils' import type { ToolResponse } from '@/tools/types' interface GitLabProject { @@ -754,6 +755,306 @@ export interface GitLabPlayJobResponse extends ToolResponse { } } +interface GitLabMember { + id: number + username: string + name: string + state: string + access_level: number + web_url?: string + expires_at?: string | null + membership_state?: string + member_role?: { id: number; name: string } | null +} + +interface GitLabInvitation { + id?: number + invite_email: string + access_level: number + created_at?: string + expires_at?: string | null + user_name?: string + invite_token?: string + member_role_id?: number | null +} + +interface GitLabAccessRequest { + id: number + username: string + name: string + state: string + requested_at: string + access_level?: number + web_url?: string +} + +interface GitLabSamlGroupLink { + name: string + access_level: number + member_role_id?: number | null +} + +interface GitLabUser { + id: number + username: string + name: string + email?: string + state: string + web_url?: string + is_admin?: boolean + created_at?: string +} + +/** + * The access resources (`/members`, `/invitations`, `/access_requests`) exist + * on both projects and groups; the tool receives `resourceType` to select which. + */ +interface GitLabResourceScopedParams extends GitLabBaseParams { + resourceType: GitLabResourceType + resourceId: string | number +} + +export interface GitLabListMembersParams extends GitLabResourceScopedParams { + /** When true, returns only direct members (`/members`). Defaults to false, which returns inherited members too (`/members/all`). */ + directOnly?: boolean + query?: string + perPage?: number + page?: number +} + +export interface GitLabAddMemberParams extends GitLabResourceScopedParams { + userId: number + accessLevel: number + expiresAt?: string + memberRoleId?: number +} + +export interface GitLabUpdateMemberParams extends GitLabResourceScopedParams { + userId: number + accessLevel: number + expiresAt?: string + memberRoleId?: number +} + +export interface GitLabRemoveMemberParams extends GitLabResourceScopedParams { + userId: number +} + +export interface GitLabInviteMemberParams extends GitLabResourceScopedParams { + email: string + accessLevel: number + expiresAt?: string + memberRoleId?: number +} + +export interface GitLabListInvitationsParams extends GitLabResourceScopedParams { + query?: string + perPage?: number + page?: number +} + +export interface GitLabUpdateInvitationParams extends GitLabResourceScopedParams { + email: string + accessLevel?: number + expiresAt?: string +} + +export interface GitLabRevokeInvitationParams extends GitLabResourceScopedParams { + email: string +} + +export interface GitLabListAccessRequestsParams extends GitLabResourceScopedParams { + perPage?: number + page?: number +} + +export interface GitLabApproveAccessRequestParams extends GitLabResourceScopedParams { + userId: number + accessLevel?: number +} + +export interface GitLabDenyAccessRequestParams extends GitLabResourceScopedParams { + userId: number +} + +export interface GitLabListSamlGroupLinksParams extends GitLabBaseParams { + groupId: string | number +} + +export interface GitLabAddSamlGroupLinkParams extends GitLabBaseParams { + groupId: string | number + samlGroupName: string + accessLevel: number + memberRoleId?: number +} + +export interface GitLabDeleteSamlGroupLinkParams extends GitLabBaseParams { + groupId: string | number + samlGroupName: string +} + +export interface GitLabSearchUsersParams extends GitLabBaseParams { + search: string + perPage?: number + page?: number +} + +export interface GitLabCreateUserParams extends GitLabBaseParams { + email: string + username: string + name: string + password?: string + resetPassword?: boolean + admin?: boolean + skipConfirmation?: boolean +} + +export interface GitLabUpdateUserParams extends GitLabBaseParams { + userId: number + email?: string + username?: string + name?: string + admin?: boolean +} + +export interface GitLabDeleteUserParams extends GitLabBaseParams { + userId: number + hardDelete?: boolean +} + +/** Shared shape for the single-user POST actions (block/unblock/deactivate/activate/ban/unban/approve/reject). */ +export interface GitLabUserActionParams extends GitLabBaseParams { + userId: number +} + +export interface GitLabDeleteUserIdentityParams extends GitLabBaseParams { + userId: number + provider: string +} + +export interface GitLabListMembersResponse extends ToolResponse { + output: { + members?: GitLabMember[] + total?: number + } +} + +export interface GitLabAddMemberResponse extends ToolResponse { + output: { + member?: GitLabMember + /** True when the user was already a member (409) — treated as a soft success so workflows are re-runnable. */ + alreadyMember?: boolean + } +} + +export interface GitLabUpdateMemberResponse extends ToolResponse { + output: { + member?: GitLabMember + } +} + +export interface GitLabRemoveMemberResponse extends ToolResponse { + output: { + success?: boolean + } +} + +export interface GitLabInviteMemberResponse extends ToolResponse { + output: { + status?: string + message?: unknown + } +} + +export interface GitLabListInvitationsResponse extends ToolResponse { + output: { + invitations?: GitLabInvitation[] + total?: number + } +} + +export interface GitLabUpdateInvitationResponse extends ToolResponse { + output: { + invitation?: GitLabInvitation + } +} + +export interface GitLabRevokeInvitationResponse extends ToolResponse { + output: { + success?: boolean + } +} + +export interface GitLabListAccessRequestsResponse extends ToolResponse { + output: { + accessRequests?: GitLabAccessRequest[] + total?: number + } +} + +export interface GitLabApproveAccessRequestResponse extends ToolResponse { + output: { + accessRequest?: GitLabAccessRequest + } +} + +export interface GitLabDenyAccessRequestResponse extends ToolResponse { + output: { + success?: boolean + } +} + +export interface GitLabListSamlGroupLinksResponse extends ToolResponse { + output: { + samlGroupLinks?: GitLabSamlGroupLink[] + total?: number + } +} + +export interface GitLabSamlGroupLinkResponse extends ToolResponse { + output: { + samlGroupLink?: GitLabSamlGroupLink + } +} + +export interface GitLabDeleteSamlGroupLinkResponse extends ToolResponse { + output: { + success?: boolean + } +} + +export interface GitLabSearchUsersResponse extends ToolResponse { + output: { + users?: GitLabUser[] + total?: number + } +} + +export interface GitLabUserResponse extends ToolResponse { + output: { + user?: GitLabUser + } +} + +export interface GitLabDeleteUserResponse extends ToolResponse { + output: { + success?: boolean + } +} + +export interface GitLabUserActionResponse extends ToolResponse { + output: { + success?: boolean + user?: GitLabUser + } +} + +export interface GitLabDeleteUserIdentityResponse extends ToolResponse { + output: { + success?: boolean + } +} + export type GitLabResponse = | GitLabListProjectsResponse | GitLabGetProjectResponse @@ -789,3 +1090,22 @@ export type GitLabResponse = | GitLabListPipelineJobsResponse | GitLabGetJobLogResponse | GitLabPlayJobResponse + | GitLabListMembersResponse + | GitLabAddMemberResponse + | GitLabUpdateMemberResponse + | GitLabRemoveMemberResponse + | GitLabInviteMemberResponse + | GitLabListInvitationsResponse + | GitLabUpdateInvitationResponse + | GitLabRevokeInvitationResponse + | GitLabListAccessRequestsResponse + | GitLabApproveAccessRequestResponse + | GitLabDenyAccessRequestResponse + | GitLabListSamlGroupLinksResponse + | GitLabSamlGroupLinkResponse + | GitLabDeleteSamlGroupLinkResponse + | GitLabSearchUsersResponse + | GitLabUserResponse + | GitLabDeleteUserResponse + | GitLabUserActionResponse + | GitLabDeleteUserIdentityResponse diff --git a/apps/sim/tools/gitlab/update_invitation.ts b/apps/sim/tools/gitlab/update_invitation.ts new file mode 100644 index 00000000000..e550cf7d778 --- /dev/null +++ b/apps/sim/tools/gitlab/update_invitation.ts @@ -0,0 +1,110 @@ +import type { + GitLabUpdateInvitationParams, + GitLabUpdateInvitationResponse, +} from '@/tools/gitlab/types' +import { getGitLabApiBase, getGitLabResourcePath } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabUpdateInvitationTool: ToolConfig< + GitLabUpdateInvitationParams, + GitLabUpdateInvitationResponse +> = { + id: 'gitlab_update_invitation', + name: 'GitLab Update Invitation', + description: 'Update a pending invitation to a GitLab project or group', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + resourceType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Whether the resource is a 'project' or a 'group'", + }, + resourceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project or group ID or URL-encoded path', + }, + email: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Email address of the invitation to update', + }, + accessLevel: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: + 'New access level: 10 (Guest), 20 (Reporter), 30 (Developer), 40 (Maintainer), 50 (Owner)', + }, + expiresAt: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Access expiration date in YYYY-MM-DD format', + }, + }, + + request: { + url: (params) => { + const resourcePath = getGitLabResourcePath(params.resourceType, params.resourceId) + const encodedEmail = encodeURIComponent(String(params.email).trim()) + return `${getGitLabApiBase(params.host)}/${resourcePath}/invitations/${encodedEmail}` + }, + method: 'PUT', + headers: (params) => ({ + 'Content-Type': 'application/json', + 'PRIVATE-TOKEN': params.accessToken, + }), + body: (params) => { + const body: Record = {} + + if (params.accessLevel !== undefined) body.access_level = params.accessLevel + if (params.expiresAt) body.expires_at = params.expiresAt + + return body + }, + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + const invitation = await response.json() + + return { + success: true, + output: { + invitation, + }, + } + }, + + outputs: { + invitation: { + type: 'object', + description: 'The updated invitation', + }, + }, +} diff --git a/apps/sim/tools/gitlab/update_member.ts b/apps/sim/tools/gitlab/update_member.ts new file mode 100644 index 00000000000..e5cbab36344 --- /dev/null +++ b/apps/sim/tools/gitlab/update_member.ts @@ -0,0 +1,114 @@ +import type { GitLabUpdateMemberParams, GitLabUpdateMemberResponse } from '@/tools/gitlab/types' +import { getGitLabApiBase, getGitLabResourcePath } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabUpdateMemberTool: ToolConfig< + GitLabUpdateMemberParams, + GitLabUpdateMemberResponse +> = { + id: 'gitlab_update_member', + name: 'GitLab Update Member', + description: "Update a member's access level in a GitLab project or group", + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + resourceType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Whether the resource is a 'project' or a 'group'", + }, + resourceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project or group ID or URL-encoded path', + }, + userId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the member to update', + }, + accessLevel: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: + 'New access level: 0 (No access), 10 (Guest), 20 (Reporter), 30 (Developer), 40 (Maintainer), 50 (Owner)', + }, + expiresAt: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Access expiration date in YYYY-MM-DD format', + }, + memberRoleId: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Custom member role ID (GitLab Ultimate only)', + }, + }, + + request: { + url: (params) => { + const resourcePath = getGitLabResourcePath(params.resourceType, params.resourceId) + return `${getGitLabApiBase(params.host)}/${resourcePath}/members/${params.userId}` + }, + method: 'PUT', + headers: (params) => ({ + 'Content-Type': 'application/json', + 'PRIVATE-TOKEN': params.accessToken, + }), + body: (params) => { + const body: Record = { + access_level: params.accessLevel, + } + + if (params.expiresAt) body.expires_at = params.expiresAt + if (params.memberRoleId !== undefined) body.member_role_id = params.memberRoleId + + return body + }, + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + const member = await response.json() + + return { + success: true, + output: { + member, + }, + } + }, + + outputs: { + member: { + type: 'object', + description: 'The updated member', + }, + }, +} diff --git a/apps/sim/tools/gitlab/update_user.ts b/apps/sim/tools/gitlab/update_user.ts new file mode 100644 index 00000000000..f85d25c7653 --- /dev/null +++ b/apps/sim/tools/gitlab/update_user.ts @@ -0,0 +1,102 @@ +import type { GitLabUpdateUserParams, GitLabUserResponse } from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabUpdateUserTool: ToolConfig = { + id: 'gitlab_update_user', + name: 'GitLab Update User', + description: + 'Modify an existing GitLab user. Requires an administrator token with admin_mode on the instance.', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab admin Personal Access Token (admin_mode)', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + userId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the user to modify', + }, + email: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: "The user's new email address", + }, + username: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: "The user's new username", + }, + name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: "The user's new display name", + }, + admin: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the user is an administrator', + }, + }, + + request: { + url: (params) => `${getGitLabApiBase(params.host)}/users/${params.userId}`, + method: 'PUT', + headers: (params) => ({ + 'Content-Type': 'application/json', + 'PRIVATE-TOKEN': params.accessToken, + }), + body: (params) => { + const body: Record = {} + + if (params.email) body.email = params.email + if (params.username) body.username = params.username + if (params.name) body.name = params.name + if (params.admin !== undefined) body.admin = params.admin + + return body + }, + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + const user = await response.json() + + return { + success: true, + output: { + user, + }, + } + }, + + outputs: { + user: { + type: 'object', + description: 'The updated user', + }, + }, +} diff --git a/apps/sim/tools/gitlab/user_status_actions.ts b/apps/sim/tools/gitlab/user_status_actions.ts new file mode 100644 index 00000000000..3d781875b8a --- /dev/null +++ b/apps/sim/tools/gitlab/user_status_actions.ts @@ -0,0 +1,134 @@ +import type { GitLabUserActionParams, GitLabUserActionResponse } from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +/** + * The GitLab admin user-state endpoints (`POST /users/:id/{block,unblock,...}`) + * are identical in shape - a single `user_id` path param, no body, and a boolean + * or user-object response. This factory builds one `ToolConfig` per action so + * each is registered and selectable individually while the wiring stays DRY. + * All require an administrator token with `admin_mode` on the instance. + */ +function createUserStatusActionTool( + action: string, + name: string, + description: string +): ToolConfig { + return { + id: `gitlab_${action}_user`, + name, + description, + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab admin Personal Access Token (admin_mode)', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + userId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the user to act on', + }, + }, + + request: { + url: (params) => `${getGitLabApiBase(params.host)}/users/${params.userId}/${action}`, + method: 'POST', + headers: (params) => ({ + 'PRIVATE-TOKEN': params.accessToken, + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + // These endpoints return either `true` or the updated user object. + const data = await response.json().catch(() => null) + const user = data && typeof data === 'object' ? data : undefined + + return { + success: true, + output: { + success: true, + user, + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the action succeeded', + }, + user: { + type: 'object', + description: 'The updated user, when returned by GitLab', + }, + }, + } +} + +export const gitlabBlockUserTool = createUserStatusActionTool( + 'block', + 'GitLab Block User', + 'Block a GitLab user, preventing them from signing in or accessing the instance' +) + +export const gitlabUnblockUserTool = createUserStatusActionTool( + 'unblock', + 'GitLab Unblock User', + 'Unblock a previously blocked GitLab user' +) + +export const gitlabDeactivateUserTool = createUserStatusActionTool( + 'deactivate', + 'GitLab Deactivate User', + 'Deactivate a dormant GitLab user' +) + +export const gitlabActivateUserTool = createUserStatusActionTool( + 'activate', + 'GitLab Activate User', + 'Reactivate a deactivated GitLab user' +) + +export const gitlabBanUserTool = createUserStatusActionTool( + 'ban', + 'GitLab Ban User', + 'Ban a GitLab user' +) + +export const gitlabUnbanUserTool = createUserStatusActionTool( + 'unban', + 'GitLab Unban User', + 'Unban a previously banned GitLab user' +) + +export const gitlabApproveUserTool = createUserStatusActionTool( + 'approve', + 'GitLab Approve User', + 'Approve a GitLab user whose signup is pending administrator approval' +) + +export const gitlabRejectUserTool = createUserStatusActionTool( + 'reject', + 'GitLab Reject User', + 'Reject a GitLab user whose signup is pending administrator approval' +) diff --git a/apps/sim/tools/gitlab/utils.ts b/apps/sim/tools/gitlab/utils.ts index 6334a7030ee..7bc455a8a83 100644 --- a/apps/sim/tools/gitlab/utils.ts +++ b/apps/sim/tools/gitlab/utils.ts @@ -66,3 +66,24 @@ export function normalizeGitLabHost(rawHost: unknown): string { export function getGitLabApiBase(rawHost: unknown): string { return `https://${normalizeGitLabHost(rawHost)}/api/v4` } + +/** + * A GitLab access/membership resource is scoped either to a project or a group. + * The two share an identical endpoint surface (`/members`, `/invitations`, + * `/access_requests`) that differs only in the leading path segment. + */ +export type GitLabResourceType = 'project' | 'group' + +/** + * Builds the path segment for a project- or group-scoped access resource, e.g. + * `projects/mygroup%2Fmyproject` or `groups/42`. The id is URL-encoded so that + * URL-encoded paths (`mygroup/myproject`) and numeric ids both work. + */ +export function getGitLabResourcePath( + resourceType: GitLabResourceType, + resourceId: string | number +): string { + const encodedId = encodeURIComponent(String(resourceId).trim()) + const segment = resourceType === 'group' ? 'groups' : 'projects' + return `${segment}/${encodedId}` +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 3629be229e3..7245906eb3d 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -1262,7 +1262,14 @@ import { githubUpdateReleaseV2Tool, } from '@/tools/github' import { + gitlabActivateUserTool, + gitlabAddMemberTool, + gitlabAddSamlGroupLinkTool, + gitlabApproveAccessRequestTool, gitlabApproveMergeRequestTool, + gitlabApproveUserTool, + gitlabBanUserTool, + gitlabBlockUserTool, gitlabCancelPipelineTool, gitlabCompareBranchesTool, gitlabCreateBranchTool, @@ -1273,8 +1280,14 @@ import { gitlabCreateMergeRequestTool, gitlabCreatePipelineTool, gitlabCreateReleaseTool, + gitlabCreateUserTool, + gitlabDeactivateUserTool, gitlabDeleteBranchTool, gitlabDeleteIssueTool, + gitlabDeleteSamlGroupLinkTool, + gitlabDeleteUserIdentityTool, + gitlabDeleteUserTool, + gitlabDenyAccessRequestTool, gitlabGetFileTool, gitlabGetIssueTool, gitlabGetJobLogTool, @@ -1282,21 +1295,35 @@ import { gitlabGetMergeRequestTool, gitlabGetPipelineTool, gitlabGetProjectTool, + gitlabInviteMemberTool, + gitlabListAccessRequestsTool, gitlabListBranchesTool, gitlabListCommitsTool, + gitlabListInvitationsTool, gitlabListIssuesTool, + gitlabListMembersTool, gitlabListMergeRequestsTool, gitlabListPipelineJobsTool, gitlabListPipelinesTool, gitlabListProjectsTool, gitlabListReleasesTool, gitlabListRepositoryTreeTool, + gitlabListSamlGroupLinksTool, gitlabMergeMergeRequestTool, gitlabPlayJobTool, + gitlabRejectUserTool, + gitlabRemoveMemberTool, gitlabRetryPipelineTool, + gitlabRevokeInvitationTool, + gitlabSearchUsersTool, + gitlabUnbanUserTool, + gitlabUnblockUserTool, gitlabUpdateFileTool, + gitlabUpdateInvitationTool, gitlabUpdateIssueTool, + gitlabUpdateMemberTool, gitlabUpdateMergeRequestTool, + gitlabUpdateUserTool, } from '@/tools/gitlab' import { gmailAddLabelTool, @@ -6302,6 +6329,33 @@ export const tools: Record = { gitlab_update_file: gitlabUpdateFileTool, gitlab_update_issue: gitlabUpdateIssueTool, gitlab_update_merge_request: gitlabUpdateMergeRequestTool, + gitlab_list_members: gitlabListMembersTool, + gitlab_add_member: gitlabAddMemberTool, + gitlab_update_member: gitlabUpdateMemberTool, + gitlab_remove_member: gitlabRemoveMemberTool, + gitlab_invite_member: gitlabInviteMemberTool, + gitlab_list_invitations: gitlabListInvitationsTool, + gitlab_update_invitation: gitlabUpdateInvitationTool, + gitlab_revoke_invitation: gitlabRevokeInvitationTool, + gitlab_list_access_requests: gitlabListAccessRequestsTool, + gitlab_approve_access_request: gitlabApproveAccessRequestTool, + gitlab_deny_access_request: gitlabDenyAccessRequestTool, + gitlab_list_saml_group_links: gitlabListSamlGroupLinksTool, + gitlab_search_users: gitlabSearchUsersTool, + gitlab_create_user: gitlabCreateUserTool, + gitlab_update_user: gitlabUpdateUserTool, + gitlab_delete_user: gitlabDeleteUserTool, + gitlab_block_user: gitlabBlockUserTool, + gitlab_unblock_user: gitlabUnblockUserTool, + gitlab_deactivate_user: gitlabDeactivateUserTool, + gitlab_activate_user: gitlabActivateUserTool, + gitlab_ban_user: gitlabBanUserTool, + gitlab_unban_user: gitlabUnbanUserTool, + gitlab_approve_user: gitlabApproveUserTool, + gitlab_reject_user: gitlabRejectUserTool, + gitlab_delete_user_identity: gitlabDeleteUserIdentityTool, + gitlab_add_saml_group_link: gitlabAddSamlGroupLinkTool, + gitlab_delete_saml_group_link: gitlabDeleteSamlGroupLinkTool, grain_list_recordings: grainListRecordingsTool, grain_get_recording: grainGetRecordingTool, grain_get_transcript: grainGetTranscriptTool, From 12c6be5f02fc9c2722acc2e1170d0c9a62f4a519 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Thu, 16 Jul 2026 01:51:15 -0700 Subject: [PATCH 2/7] feat(gitlab): wire access operations into the GitLab block Adds the new operations to the block dropdown and tools access list, with a named access-level dropdown (enum in, integer out), first-class expires_at, a /members/all default (direct-only opt-in), resource-type selector, and member_role_id passthrough. --- apps/sim/blocks/blocks/gitlab.ts | 689 +++++++++++++++++++++++++++++++ 1 file changed, 689 insertions(+) diff --git a/apps/sim/blocks/blocks/gitlab.ts b/apps/sim/blocks/blocks/gitlab.ts index 6de2a6ba07b..e88d9ca267d 100644 --- a/apps/sim/blocks/blocks/gitlab.ts +++ b/apps/sim/blocks/blocks/gitlab.ts @@ -4,6 +4,90 @@ import { AuthMode, IntegrationType } from '@/blocks/types' import type { GitLabResponse } from '@/tools/gitlab/types' import { getTrigger } from '@/triggers' +/** + * Access/membership operations scoped to a project OR group. These take a + * `resourceType` (Project | Group) plus a `resourceId`. + */ +const RESOURCE_SCOPED_OPS = [ + 'gitlab_list_members', + 'gitlab_add_member', + 'gitlab_update_member', + 'gitlab_remove_member', + 'gitlab_invite_member', + 'gitlab_list_invitations', + 'gitlab_update_invitation', + 'gitlab_revoke_invitation', + 'gitlab_list_access_requests', + 'gitlab_approve_access_request', + 'gitlab_deny_access_request', +] + +/** Operations that require a target user ID (member target or admin user target). */ +const USER_ID_OPS = [ + 'gitlab_add_member', + 'gitlab_update_member', + 'gitlab_remove_member', + 'gitlab_approve_access_request', + 'gitlab_deny_access_request', + 'gitlab_update_user', + 'gitlab_delete_user', + 'gitlab_block_user', + 'gitlab_unblock_user', + 'gitlab_deactivate_user', + 'gitlab_activate_user', + 'gitlab_ban_user', + 'gitlab_unban_user', + 'gitlab_approve_user', + 'gitlab_reject_user', + 'gitlab_delete_user_identity', +] + +/** Operations that take a named access-level dropdown (required unless noted). */ +const ACCESS_LEVEL_OPS = [ + 'gitlab_add_member', + 'gitlab_update_member', + 'gitlab_invite_member', + 'gitlab_approve_access_request', + 'gitlab_add_saml_group_link', +] + +/** Operations where the access level is required (approve/invitation update are optional). */ +const ACCESS_LEVEL_REQUIRED_OPS = [ + 'gitlab_add_member', + 'gitlab_update_member', + 'gitlab_invite_member', + 'gitlab_add_saml_group_link', +] + +/** Operations that support an access-expiration date. */ +const EXPIRES_AT_OPS = [ + 'gitlab_add_member', + 'gitlab_update_member', + 'gitlab_invite_member', + 'gitlab_update_invitation', +] + +/** Operations that support a custom member role ID (Ultimate). */ +const MEMBER_ROLE_OPS = [ + 'gitlab_add_member', + 'gitlab_update_member', + 'gitlab_invite_member', + 'gitlab_add_saml_group_link', +] + +/** Operations that take an email address. */ +const EMAIL_OPS = ['gitlab_invite_member', 'gitlab_update_invitation', 'gitlab_revoke_invitation'] + +/** Group-only SAML group link operations that take a group ID. */ +const SAML_LINK_OPS = [ + 'gitlab_list_saml_group_links', + 'gitlab_add_saml_group_link', + 'gitlab_delete_saml_group_link', +] + +/** SAML operations that take a SAML group name. */ +const SAML_NAME_OPS = ['gitlab_add_saml_group_link', 'gitlab_delete_saml_group_link'] + export const GitLabBlock: BlockConfig = { type: 'gitlab', name: 'GitLab', @@ -66,6 +150,35 @@ export const GitLabBlock: BlockConfig = { // Release Operations { label: 'List Releases', id: 'gitlab_list_releases' }, { label: 'Create Release', id: 'gitlab_create_release' }, + // Access / Membership Operations + { label: 'List Members', id: 'gitlab_list_members' }, + { label: 'Add Member', id: 'gitlab_add_member' }, + { label: 'Update Member', id: 'gitlab_update_member' }, + { label: 'Remove Member', id: 'gitlab_remove_member' }, + { label: 'Invite Member by Email', id: 'gitlab_invite_member' }, + { label: 'List Invitations', id: 'gitlab_list_invitations' }, + { label: 'Update Invitation', id: 'gitlab_update_invitation' }, + { label: 'Revoke Invitation', id: 'gitlab_revoke_invitation' }, + { label: 'List Access Requests', id: 'gitlab_list_access_requests' }, + { label: 'Approve Access Request', id: 'gitlab_approve_access_request' }, + { label: 'Deny Access Request', id: 'gitlab_deny_access_request' }, + { label: 'List SAML Group Links', id: 'gitlab_list_saml_group_links' }, + { label: 'Search Users', id: 'gitlab_search_users' }, + // User Administration Operations (require an admin token) + { label: 'Create User', id: 'gitlab_create_user' }, + { label: 'Update User', id: 'gitlab_update_user' }, + { label: 'Delete User', id: 'gitlab_delete_user' }, + { label: 'Block User', id: 'gitlab_block_user' }, + { label: 'Unblock User', id: 'gitlab_unblock_user' }, + { label: 'Deactivate User', id: 'gitlab_deactivate_user' }, + { label: 'Activate User', id: 'gitlab_activate_user' }, + { label: 'Ban User', id: 'gitlab_ban_user' }, + { label: 'Unban User', id: 'gitlab_unban_user' }, + { label: 'Approve User Signup', id: 'gitlab_approve_user' }, + { label: 'Reject User Signup', id: 'gitlab_reject_user' }, + { label: 'Delete User Identity', id: 'gitlab_delete_user_identity' }, + { label: 'Add SAML Group Link', id: 'gitlab_add_saml_group_link' }, + { label: 'Delete SAML Group Link', id: 'gitlab_delete_saml_group_link' }, ], value: () => 'gitlab_list_projects', }, @@ -708,6 +821,10 @@ Return ONLY the commit message - no explanations, no extra text.`, 'gitlab_list_commits', 'gitlab_list_pipeline_jobs', 'gitlab_list_releases', + 'gitlab_list_members', + 'gitlab_list_invitations', + 'gitlab_list_access_requests', + 'gitlab_search_users', ], }, }, @@ -730,9 +847,273 @@ Return ONLY the commit message - no explanations, no extra text.`, 'gitlab_list_commits', 'gitlab_list_pipeline_jobs', 'gitlab_list_releases', + 'gitlab_list_members', + 'gitlab_list_invitations', + 'gitlab_list_access_requests', + 'gitlab_search_users', ], }, }, + // Resource type (project or group) for access/membership operations + { + id: 'resourceType', + title: 'Resource Type', + type: 'dropdown', + options: [ + { label: 'Project', id: 'project' }, + { label: 'Group', id: 'group' }, + ], + value: () => 'project', + required: true, + condition: { + field: 'operation', + value: RESOURCE_SCOPED_OPS, + }, + }, + // Project / group ID for access/membership operations + { + id: 'resourceId', + title: 'Project / Group ID', + type: 'short-input', + placeholder: 'Enter project or group ID or path (e.g., mygroup/myproject)', + required: true, + condition: { + field: 'operation', + value: RESOURCE_SCOPED_OPS, + }, + }, + // Group ID for SAML group link operations (group-scoped only) + { + id: 'groupId', + title: 'Group ID', + type: 'short-input', + placeholder: 'Enter group ID or path', + required: true, + condition: { + field: 'operation', + value: SAML_LINK_OPS, + }, + }, + // User ID (member target or admin user target) + { + id: 'userId', + title: 'User ID', + type: 'short-input', + placeholder: 'Enter the user ID', + required: true, + condition: { + field: 'operation', + value: USER_ID_OPS, + }, + }, + // Access level (named dropdown mapping to GitLab integer access levels) + { + id: 'accessLevel', + title: 'Access Level', + type: 'dropdown', + options: [ + { label: 'No access', id: '0' }, + { label: 'Minimal Access', id: '5' }, + { label: 'Guest', id: '10' }, + { label: 'Planner', id: '15' }, + { label: 'Reporter', id: '20' }, + { label: 'Security Manager', id: '25' }, + { label: 'Developer', id: '30' }, + { label: 'Maintainer', id: '40' }, + { label: 'Owner', id: '50' }, + ], + value: () => '30', + required: { + field: 'operation', + value: ACCESS_LEVEL_REQUIRED_OPS, + }, + condition: { + field: 'operation', + value: ACCESS_LEVEL_OPS, + }, + }, + // Access expiration date (first-class time-boxed grants) + { + id: 'expiresAt', + title: 'Expires At', + type: 'short-input', + placeholder: 'YYYY-MM-DD (optional) - access is revoked on this date', + condition: { + field: 'operation', + value: EXPIRES_AT_OPS, + }, + }, + // Custom member role ID (GitLab Ultimate) + { + id: 'memberRoleId', + title: 'Member Role ID', + type: 'short-input', + placeholder: 'Custom role ID (GitLab Ultimate only)', + mode: 'advanced', + condition: { + field: 'operation', + value: MEMBER_ROLE_OPS, + }, + }, + // Email address (invitations) + { + id: 'email', + title: 'Email', + type: 'short-input', + placeholder: 'Email address (comma-separated for multiple invites)', + required: true, + condition: { + field: 'operation', + value: EMAIL_OPS, + }, + }, + // Direct members only toggle (list members) + { + id: 'directMembersOnly', + title: 'Direct Members Only', + type: 'switch', + mode: 'advanced', + description: 'Exclude members inherited from ancestor groups', + condition: { + field: 'operation', + value: ['gitlab_list_members'], + }, + }, + // User search query + { + id: 'userSearch', + title: 'Search', + type: 'short-input', + placeholder: 'Name, username, or email to search for', + required: true, + condition: { + field: 'operation', + value: ['gitlab_search_users'], + }, + }, + // SAML group name + { + id: 'samlGroupName', + title: 'SAML Group Name', + type: 'short-input', + placeholder: 'Name of the SAML group as sent by the identity provider', + required: true, + condition: { + field: 'operation', + value: SAML_NAME_OPS, + }, + }, + // Provider (delete user identity) + { + id: 'provider', + title: 'Identity Provider', + type: 'short-input', + placeholder: 'e.g., saml, ldapmain', + required: true, + condition: { + field: 'operation', + value: ['gitlab_delete_user_identity'], + }, + }, + // Hard delete toggle (delete user) + { + id: 'hardDelete', + title: 'Hard Delete', + type: 'switch', + mode: 'advanced', + description: + 'Delete contributions and personal projects instead of moving them to a Ghost User', + condition: { + field: 'operation', + value: ['gitlab_delete_user'], + }, + }, + // User attributes (create/update user) + { + id: 'userAdminEmail', + title: 'Email', + type: 'short-input', + placeholder: "The user's email address", + required: { + field: 'operation', + value: ['gitlab_create_user'], + }, + condition: { + field: 'operation', + value: ['gitlab_create_user', 'gitlab_update_user'], + }, + }, + { + id: 'userAdminUsername', + title: 'Username', + type: 'short-input', + placeholder: "The user's username", + required: { + field: 'operation', + value: ['gitlab_create_user'], + }, + condition: { + field: 'operation', + value: ['gitlab_create_user', 'gitlab_update_user'], + }, + }, + { + id: 'userAdminName', + title: 'Full Name', + type: 'short-input', + placeholder: "The user's display name", + required: { + field: 'operation', + value: ['gitlab_create_user'], + }, + condition: { + field: 'operation', + value: ['gitlab_create_user', 'gitlab_update_user'], + }, + }, + { + id: 'userAdminPassword', + title: 'Password', + type: 'short-input', + password: true, + placeholder: 'Password (omit and enable Send Reset Link instead)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_create_user'], + }, + }, + { + id: 'resetPassword', + title: 'Send Password Reset Link', + type: 'switch', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_create_user'], + }, + }, + { + id: 'skipConfirmation', + title: 'Skip Email Confirmation', + type: 'switch', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_create_user'], + }, + }, + { + id: 'userAdminIsAdmin', + title: 'Administrator', + type: 'switch', + mode: 'advanced', + description: 'Whether the user is an instance administrator', + condition: { + field: 'operation', + value: ['gitlab_create_user', 'gitlab_update_user'], + }, + }, ...getTrigger('gitlab_push').subBlocks, ...getTrigger('gitlab_merge_request').subBlocks, ...getTrigger('gitlab_issue').subBlocks, @@ -777,6 +1158,33 @@ Return ONLY the commit message - no explanations, no extra text.`, 'gitlab_play_job', 'gitlab_list_releases', 'gitlab_create_release', + 'gitlab_list_members', + 'gitlab_add_member', + 'gitlab_update_member', + 'gitlab_remove_member', + 'gitlab_invite_member', + 'gitlab_list_invitations', + 'gitlab_update_invitation', + 'gitlab_revoke_invitation', + 'gitlab_list_access_requests', + 'gitlab_approve_access_request', + 'gitlab_deny_access_request', + 'gitlab_list_saml_group_links', + 'gitlab_search_users', + 'gitlab_create_user', + 'gitlab_update_user', + 'gitlab_delete_user', + 'gitlab_block_user', + 'gitlab_unblock_user', + 'gitlab_deactivate_user', + 'gitlab_activate_user', + 'gitlab_ban_user', + 'gitlab_unban_user', + 'gitlab_approve_user', + 'gitlab_reject_user', + 'gitlab_delete_user_identity', + 'gitlab_add_saml_group_link', + 'gitlab_delete_saml_group_link', ], config: { tool: (params) => { @@ -1203,6 +1611,253 @@ Return ONLY the commit message - no explanations, no extra text.`, : undefined, } + case 'gitlab_list_members': + if (!params.resourceId?.trim()) { + throw new Error('Project / Group ID is required.') + } + return { + ...baseParams, + resourceType: params.resourceType || 'project', + resourceId: params.resourceId.trim(), + directOnly: params.directMembersOnly || undefined, + perPage: params.perPage ? Number(params.perPage) : undefined, + page: params.page ? Number(params.page) : undefined, + } + + case 'gitlab_add_member': + if (!params.resourceId?.trim() || !params.userId || !params.accessLevel) { + throw new Error('Project / Group ID, User ID, and Access Level are required.') + } + return { + ...baseParams, + resourceType: params.resourceType || 'project', + resourceId: params.resourceId.trim(), + userId: Number(params.userId), + accessLevel: Number(params.accessLevel), + expiresAt: params.expiresAt?.trim() || undefined, + memberRoleId: params.memberRoleId ? Number(params.memberRoleId) : undefined, + } + + case 'gitlab_update_member': + if (!params.resourceId?.trim() || !params.userId || !params.accessLevel) { + throw new Error('Project / Group ID, User ID, and Access Level are required.') + } + return { + ...baseParams, + resourceType: params.resourceType || 'project', + resourceId: params.resourceId.trim(), + userId: Number(params.userId), + accessLevel: Number(params.accessLevel), + expiresAt: params.expiresAt?.trim() || undefined, + memberRoleId: params.memberRoleId ? Number(params.memberRoleId) : undefined, + } + + case 'gitlab_remove_member': + if (!params.resourceId?.trim() || !params.userId) { + throw new Error('Project / Group ID and User ID are required.') + } + return { + ...baseParams, + resourceType: params.resourceType || 'project', + resourceId: params.resourceId.trim(), + userId: Number(params.userId), + } + + case 'gitlab_invite_member': + if (!params.resourceId?.trim() || !params.email?.trim() || !params.accessLevel) { + throw new Error('Project / Group ID, Email, and Access Level are required.') + } + return { + ...baseParams, + resourceType: params.resourceType || 'project', + resourceId: params.resourceId.trim(), + email: params.email.trim(), + accessLevel: Number(params.accessLevel), + expiresAt: params.expiresAt?.trim() || undefined, + memberRoleId: params.memberRoleId ? Number(params.memberRoleId) : undefined, + } + + case 'gitlab_list_invitations': + if (!params.resourceId?.trim()) { + throw new Error('Project / Group ID is required.') + } + return { + ...baseParams, + resourceType: params.resourceType || 'project', + resourceId: params.resourceId.trim(), + perPage: params.perPage ? Number(params.perPage) : undefined, + page: params.page ? Number(params.page) : undefined, + } + + case 'gitlab_update_invitation': + if (!params.resourceId?.trim() || !params.email?.trim()) { + throw new Error('Project / Group ID and Email are required.') + } + return { + ...baseParams, + resourceType: params.resourceType || 'project', + resourceId: params.resourceId.trim(), + email: params.email.trim(), + accessLevel: params.accessLevel ? Number(params.accessLevel) : undefined, + expiresAt: params.expiresAt?.trim() || undefined, + } + + case 'gitlab_revoke_invitation': + if (!params.resourceId?.trim() || !params.email?.trim()) { + throw new Error('Project / Group ID and Email are required.') + } + return { + ...baseParams, + resourceType: params.resourceType || 'project', + resourceId: params.resourceId.trim(), + email: params.email.trim(), + } + + case 'gitlab_list_access_requests': + if (!params.resourceId?.trim()) { + throw new Error('Project / Group ID is required.') + } + return { + ...baseParams, + resourceType: params.resourceType || 'project', + resourceId: params.resourceId.trim(), + perPage: params.perPage ? Number(params.perPage) : undefined, + page: params.page ? Number(params.page) : undefined, + } + + case 'gitlab_approve_access_request': + if (!params.resourceId?.trim() || !params.userId) { + throw new Error('Project / Group ID and User ID are required.') + } + return { + ...baseParams, + resourceType: params.resourceType || 'project', + resourceId: params.resourceId.trim(), + userId: Number(params.userId), + accessLevel: params.accessLevel ? Number(params.accessLevel) : undefined, + } + + case 'gitlab_deny_access_request': + if (!params.resourceId?.trim() || !params.userId) { + throw new Error('Project / Group ID and User ID are required.') + } + return { + ...baseParams, + resourceType: params.resourceType || 'project', + resourceId: params.resourceId.trim(), + userId: Number(params.userId), + } + + case 'gitlab_list_saml_group_links': + if (!params.groupId?.trim()) { + throw new Error('Group ID is required.') + } + return { + ...baseParams, + groupId: params.groupId.trim(), + } + + case 'gitlab_add_saml_group_link': + if (!params.groupId?.trim() || !params.samlGroupName?.trim() || !params.accessLevel) { + throw new Error('Group ID, SAML Group Name, and Access Level are required.') + } + return { + ...baseParams, + groupId: params.groupId.trim(), + samlGroupName: params.samlGroupName.trim(), + accessLevel: Number(params.accessLevel), + memberRoleId: params.memberRoleId ? Number(params.memberRoleId) : undefined, + } + + case 'gitlab_delete_saml_group_link': + if (!params.groupId?.trim() || !params.samlGroupName?.trim()) { + throw new Error('Group ID and SAML Group Name are required.') + } + return { + ...baseParams, + groupId: params.groupId.trim(), + samlGroupName: params.samlGroupName.trim(), + } + + case 'gitlab_search_users': + if (!params.userSearch?.trim()) { + throw new Error('Search query is required.') + } + return { + ...baseParams, + search: params.userSearch.trim(), + perPage: params.perPage ? Number(params.perPage) : undefined, + page: params.page ? Number(params.page) : undefined, + } + + case 'gitlab_create_user': + if ( + !params.userAdminEmail?.trim() || + !params.userAdminUsername?.trim() || + !params.userAdminName?.trim() + ) { + throw new Error('Email, Username, and Full Name are required.') + } + return { + ...baseParams, + email: params.userAdminEmail.trim(), + username: params.userAdminUsername.trim(), + name: params.userAdminName.trim(), + password: params.userAdminPassword?.trim() || undefined, + resetPassword: params.resetPassword || undefined, + admin: params.userAdminIsAdmin || undefined, + skipConfirmation: params.skipConfirmation || undefined, + } + + case 'gitlab_update_user': + if (!params.userId) { + throw new Error('User ID is required.') + } + return { + ...baseParams, + userId: Number(params.userId), + email: params.userAdminEmail?.trim() || undefined, + username: params.userAdminUsername?.trim() || undefined, + name: params.userAdminName?.trim() || undefined, + admin: params.userAdminIsAdmin || undefined, + } + + case 'gitlab_delete_user': + if (!params.userId) { + throw new Error('User ID is required.') + } + return { + ...baseParams, + userId: Number(params.userId), + hardDelete: params.hardDelete || undefined, + } + + case 'gitlab_block_user': + case 'gitlab_unblock_user': + case 'gitlab_deactivate_user': + case 'gitlab_activate_user': + case 'gitlab_ban_user': + case 'gitlab_unban_user': + case 'gitlab_approve_user': + case 'gitlab_reject_user': + if (!params.userId) { + throw new Error('User ID is required.') + } + return { + ...baseParams, + userId: Number(params.userId), + } + + case 'gitlab_delete_user_identity': + if (!params.userId || !params.provider?.trim()) { + throw new Error('User ID and Identity Provider are required.') + } + return { + ...baseParams, + userId: Number(params.userId), + provider: params.provider.trim(), + } + default: return baseParams } @@ -1255,6 +1910,26 @@ Return ONLY the commit message - no explanations, no extra text.`, releaseName: { type: 'string', description: 'Release name' }, releasedAt: { type: 'string', description: 'ISO 8601 date for the release' }, releaseMilestones: { type: 'string', description: 'Milestone titles (comma-separated)' }, + resourceType: { type: 'string', description: "Access resource type ('project' or 'group')" }, + resourceId: { type: 'string', description: 'Project or group ID or URL-encoded path' }, + groupId: { type: 'string', description: 'Group ID or URL-encoded path' }, + userId: { type: 'number', description: 'Target user ID' }, + accessLevel: { type: 'number', description: 'GitLab access level (10-50)' }, + expiresAt: { type: 'string', description: 'Access expiration date (YYYY-MM-DD)' }, + memberRoleId: { type: 'number', description: 'Custom member role ID (Ultimate)' }, + email: { type: 'string', description: 'Email address for invitations' }, + directMembersOnly: { type: 'boolean', description: 'Exclude inherited members' }, + userSearch: { type: 'string', description: 'User search query' }, + samlGroupName: { type: 'string', description: 'SAML group name' }, + provider: { type: 'string', description: 'External identity provider name' }, + hardDelete: { type: 'boolean', description: 'Hard-delete a user' }, + userAdminEmail: { type: 'string', description: "User's email (create/update user)" }, + userAdminUsername: { type: 'string', description: "User's username (create/update user)" }, + userAdminName: { type: 'string', description: "User's display name (create/update user)" }, + userAdminPassword: { type: 'string', description: "User's password (create user)" }, + resetPassword: { type: 'boolean', description: 'Send a password reset link (create user)' }, + skipConfirmation: { type: 'boolean', description: 'Skip email confirmation (create user)' }, + userAdminIsAdmin: { type: 'boolean', description: 'Whether the user is an administrator' }, }, outputs: { // Project outputs @@ -1306,6 +1981,20 @@ Return ONLY the commit message - no explanations, no extra text.`, // Release outputs releases: { type: 'json', description: 'List of releases' }, release: { type: 'json', description: 'Release details' }, + // Access / membership outputs + members: { type: 'json', description: 'List of project or group members' }, + member: { type: 'json', description: 'A single member' }, + alreadyMember: { type: 'boolean', description: 'Whether the user was already a member' }, + invitations: { type: 'json', description: 'List of pending invitations' }, + invitation: { type: 'json', description: 'A single invitation' }, + accessRequests: { type: 'json', description: 'List of pending access requests' }, + accessRequest: { type: 'json', description: 'A single access request' }, + samlGroupLinks: { type: 'json', description: 'List of SAML group links' }, + samlGroupLink: { type: 'json', description: 'A single SAML group link' }, + message: { type: 'json', description: 'Per-email invitation result detail' }, + // User outputs + users: { type: 'json', description: 'List of matching users' }, + user: { type: 'json', description: 'User details' }, // Pagination total: { type: 'number', description: 'Total number of items available across all pages' }, // Success indicator From 933f4d2b936a804ed104096a9f803a7e502d3eb3 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Thu, 16 Jul 2026 01:51:15 -0700 Subject: [PATCH 3/7] test(gitlab): cover access operations Covers the access_level enum-to-integer coercion, the /members/all default vs direct-only, the 409-duplicate-add soft success, invitation per-email error handling, user-status-action response parsing, and getGitLabResourcePath. --- apps/sim/blocks/blocks/gitlab.test.ts | 110 ++++++++++++++++ apps/sim/tools/gitlab/access.test.ts | 181 ++++++++++++++++++++++++++ apps/sim/tools/gitlab/utils.test.ts | 21 ++- 3 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 apps/sim/blocks/blocks/gitlab.test.ts create mode 100644 apps/sim/tools/gitlab/access.test.ts diff --git a/apps/sim/blocks/blocks/gitlab.test.ts b/apps/sim/blocks/blocks/gitlab.test.ts new file mode 100644 index 00000000000..f321c7e4ce8 --- /dev/null +++ b/apps/sim/blocks/blocks/gitlab.test.ts @@ -0,0 +1,110 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { GitLabBlock } from './gitlab' + +const block = GitLabBlock + +describe('GitLabBlock access operations', () => { + it('routes every access operation to its matching tool id without serialization-time coercion', () => { + const accessOps = [ + 'gitlab_list_members', + 'gitlab_add_member', + 'gitlab_update_member', + 'gitlab_remove_member', + 'gitlab_invite_member', + 'gitlab_approve_access_request', + 'gitlab_search_users', + 'gitlab_block_user', + 'gitlab_add_saml_group_link', + ] + for (const toolId of accessOps) { + expect(block.tools.access).toContain(toolId) + expect(block.tools.config.tool?.({ operation: toolId })).toBe(toolId) + } + }) + + it('exposes the named access-level dropdown with GitLab integer ids', () => { + const accessLevel = block.subBlocks.find((s) => s.id === 'accessLevel') + expect(accessLevel?.type).toBe('dropdown') + const options = typeof accessLevel?.options === 'function' ? undefined : accessLevel?.options + expect(options?.map((o) => o.id)).toEqual(['0', '5', '10', '15', '20', '25', '30', '40', '50']) + expect(accessLevel?.value?.()).toBe('30') + }) + + it('coerces the selected access level from the dropdown string to an integer at execution time', () => { + const addParams = block.tools.config.params?.({ + accessToken: 'pat', + operation: 'gitlab_add_member', + resourceType: 'group', + resourceId: '42', + userId: '7', + accessLevel: '40', + expiresAt: '2026-12-31', + memberRoleId: '5', + }) + expect(addParams).toMatchObject({ + resourceType: 'group', + resourceId: '42', + userId: 7, + accessLevel: 40, + expiresAt: '2026-12-31', + memberRoleId: 5, + }) + expect(typeof addParams?.accessLevel).toBe('number') + }) + + it('defaults list members to inherited members (directOnly falsy)', () => { + const listParams = block.tools.config.params?.({ + accessToken: 'pat', + operation: 'gitlab_list_members', + resourceType: 'project', + resourceId: 'grp/proj', + }) + expect(listParams).toMatchObject({ resourceType: 'project', resourceId: 'grp/proj' }) + expect(listParams?.directOnly).toBeUndefined() + + const directParams = block.tools.config.params?.({ + accessToken: 'pat', + operation: 'gitlab_list_members', + resourceType: 'project', + resourceId: 'grp/proj', + directMembersOnly: true, + }) + expect(directParams?.directOnly).toBe(true) + }) + + it('coerces the target user id for admin user actions', () => { + const blockParams = block.tools.config.params?.({ + accessToken: 'pat', + operation: 'gitlab_block_user', + userId: '99', + }) + expect(blockParams).toMatchObject({ userId: 99 }) + expect(typeof blockParams?.userId).toBe('number') + }) + + it('optionally coerces the granted access level for approve access request', () => { + const approveParams = block.tools.config.params?.({ + accessToken: 'pat', + operation: 'gitlab_approve_access_request', + resourceType: 'group', + resourceId: '42', + userId: '7', + accessLevel: '30', + }) + expect(approveParams).toMatchObject({ userId: 7, accessLevel: 30 }) + }) + + it('throws when required access fields are missing', () => { + expect(() => + block.tools.config.params?.({ + accessToken: 'pat', + operation: 'gitlab_add_member', + resourceType: 'group', + resourceId: '42', + }) + ).toThrow() + }) +}) diff --git a/apps/sim/tools/gitlab/access.test.ts b/apps/sim/tools/gitlab/access.test.ts new file mode 100644 index 00000000000..ed350f686f3 --- /dev/null +++ b/apps/sim/tools/gitlab/access.test.ts @@ -0,0 +1,181 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { gitlabAddMemberTool } from '@/tools/gitlab/add_member' +import { gitlabApproveAccessRequestTool } from '@/tools/gitlab/approve_access_request' +import { gitlabInviteMemberTool } from '@/tools/gitlab/invite_member' +import { gitlabListMembersTool } from '@/tools/gitlab/list_members' +import { gitlabUpdateMemberTool } from '@/tools/gitlab/update_member' +import { gitlabBlockUserTool } from '@/tools/gitlab/user_status_actions' + +interface MockResponseOptions { + ok?: boolean + status?: number + json?: unknown + text?: string + headers?: Record +} + +function mockResponse({ + ok = true, + status = 200, + json, + text = '', + headers = {}, +}: MockResponseOptions): Response { + return { + ok, + status, + json: async () => json, + text: async () => text, + headers: { + get: (key: string) => headers[key.toLowerCase()] ?? null, + }, + } as unknown as Response +} + +const baseArgs = { accessToken: 'pat', resourceType: 'group' as const, resourceId: '42' } + +describe('gitlab_list_members', () => { + it('defaults to /members/all so inherited members are included', () => { + const url = gitlabListMembersTool.request.url({ ...baseArgs }) + expect(url).toBe('https://gitlab.com/api/v4/groups/42/members/all') + }) + + it('uses /members when directOnly is set', () => { + const url = gitlabListMembersTool.request.url({ ...baseArgs, directOnly: true }) + expect(url).toBe('https://gitlab.com/api/v4/groups/42/members') + }) + + it('builds a project path and forwards pagination', () => { + const url = gitlabListMembersTool.request.url({ + ...baseArgs, + resourceType: 'project', + resourceId: 'grp/proj', + perPage: 50, + page: 2, + }) + expect(url).toBe('https://gitlab.com/api/v4/projects/grp%2Fproj/members/all?per_page=50&page=2') + }) +}) + +describe('gitlab_add_member', () => { + it('sends integer user_id and access_level in the body', () => { + const body = gitlabAddMemberTool.request.body?.({ + ...baseArgs, + userId: 7, + accessLevel: 30, + expiresAt: '2026-12-31', + memberRoleId: 5, + }) + expect(body).toEqual({ + user_id: 7, + access_level: 30, + expires_at: '2026-12-31', + member_role_id: 5, + }) + }) + + it('treats a 409 as a soft success so workflows are re-runnable', async () => { + const result = await gitlabAddMemberTool.transformResponse!( + mockResponse({ ok: false, status: 409, json: { message: 'Member already exists' } }), + {} as never + ) + expect(result.success).toBe(true) + expect(result.output.alreadyMember).toBe(true) + }) + + it('returns the created member on success', async () => { + const result = await gitlabAddMemberTool.transformResponse!( + mockResponse({ ok: true, status: 201, json: { id: 7, access_level: 30 } }), + {} as never + ) + expect(result.success).toBe(true) + expect(result.output.alreadyMember).toBe(false) + expect(result.output.member).toEqual({ id: 7, access_level: 30 }) + }) + + it('surfaces other errors as hard failures', async () => { + const result = await gitlabAddMemberTool.transformResponse!( + mockResponse({ ok: false, status: 403, text: 'Forbidden' }), + {} as never + ) + expect(result.success).toBe(false) + }) +}) + +describe('gitlab_update_member', () => { + it('sends the new access_level integer and expires_at', () => { + const body = gitlabUpdateMemberTool.request.body?.({ + ...baseArgs, + userId: 7, + accessLevel: 40, + expiresAt: '2027-01-01', + }) + expect(body).toEqual({ access_level: 40, expires_at: '2027-01-01' }) + }) +}) + +describe('gitlab_approve_access_request', () => { + it('passes the granted access_level as an integer query param', () => { + const url = gitlabApproveAccessRequestTool.request.url({ + ...baseArgs, + userId: 7, + accessLevel: 40, + }) + expect(url).toBe( + 'https://gitlab.com/api/v4/groups/42/access_requests/7/approve?access_level=40' + ) + }) + + it('omits access_level when not provided (GitLab defaults to Developer)', () => { + const url = gitlabApproveAccessRequestTool.request.url({ ...baseArgs, userId: 7 }) + expect(url).toBe('https://gitlab.com/api/v4/groups/42/access_requests/7/approve') + }) +}) + +describe('gitlab_invite_member', () => { + it('reports a per-email failure even when GitLab returns 200 with status:error', async () => { + const result = await gitlabInviteMemberTool.transformResponse!( + mockResponse({ + ok: true, + status: 201, + json: { status: 'error', message: { 'a@b.com': 'Already invited' } }, + }), + {} as never + ) + expect(result.success).toBe(false) + expect(result.output.status).toBe('error') + }) + + it('reports success when GitLab accepts the invite', async () => { + const result = await gitlabInviteMemberTool.transformResponse!( + mockResponse({ ok: true, status: 201, json: { status: 'success' } }), + {} as never + ) + expect(result.success).toBe(true) + expect(result.output.status).toBe('success') + }) +}) + +describe('gitlab user status actions', () => { + it('returns success with no user object when GitLab responds with a bare true', async () => { + const result = await gitlabBlockUserTool.transformResponse!( + mockResponse({ ok: true, status: 201, json: true }), + {} as never + ) + expect(result.success).toBe(true) + expect(result.output.success).toBe(true) + expect(result.output.user).toBeUndefined() + }) + + it('surfaces the updated user object when GitLab returns one', async () => { + const result = await gitlabBlockUserTool.transformResponse!( + mockResponse({ ok: true, status: 201, json: { id: 9, state: 'blocked' } }), + {} as never + ) + expect(result.success).toBe(true) + expect(result.output.user).toEqual({ id: 9, state: 'blocked' }) + }) +}) diff --git a/apps/sim/tools/gitlab/utils.test.ts b/apps/sim/tools/gitlab/utils.test.ts index f7eca36aef8..792aa66766e 100644 --- a/apps/sim/tools/gitlab/utils.test.ts +++ b/apps/sim/tools/gitlab/utils.test.ts @@ -2,7 +2,12 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { getGitLabApiBase, normalizeGitLabHost, UnsafeGitLabHostError } from '@/tools/gitlab/utils' +import { + getGitLabApiBase, + getGitLabResourcePath, + normalizeGitLabHost, + UnsafeGitLabHostError, +} from '@/tools/gitlab/utils' describe('normalizeGitLabHost', () => { it('defaults to gitlab.com when the host is empty, blank, or not a string', () => { @@ -69,3 +74,17 @@ describe('getGitLabApiBase', () => { expect(() => getGitLabApiBase('legit.com@evil.com')).toThrow(UnsafeGitLabHostError) }) }) + +describe('getGitLabResourcePath', () => { + it('builds project and group path segments', () => { + expect(getGitLabResourcePath('project', 42)).toBe('projects/42') + expect(getGitLabResourcePath('group', 7)).toBe('groups/7') + }) + + it('URL-encodes namespaced paths and trims whitespace', () => { + expect(getGitLabResourcePath('project', ' mygroup/myproject ')).toBe( + 'projects/mygroup%2Fmyproject' + ) + expect(getGitLabResourcePath('group', 'parent/child')).toBe('groups/parent%2Fchild') + }) +}) From 5907a44f4e8201e7d6aa538c0687c3b22cdde5d7 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Thu, 16 Jul 2026 01:51:15 -0700 Subject: [PATCH 4/7] docs(gitlab): document access and membership operations --- .../content/docs/en/integrations/gitlab.mdx | 908 ++++++++++++++++++ 1 file changed, 908 insertions(+) diff --git a/apps/docs/content/docs/en/integrations/gitlab.mdx b/apps/docs/content/docs/en/integrations/gitlab.mdx index bc977d46bad..9b45eee8db5 100644 --- a/apps/docs/content/docs/en/integrations/gitlab.mdx +++ b/apps/docs/content/docs/en/integrations/gitlab.mdx @@ -797,6 +797,914 @@ Create a new release in a GitLab project | --------- | ---- | ----------- | | `release` | object | The created GitLab release | +### `gitlab_list_members` + +List members of a GitLab project or group. Includes members inherited from ancestor groups by default. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `resourceType` | string | Yes | Whether the resource is a 'project' or a 'group' | +| `resourceId` | string | Yes | Project or group ID or URL-encoded path | +| `directOnly` | boolean | No | When true, returns only direct members. Defaults to false, which also returns members inherited from ancestor groups. | +| `query` | string | No | Filter members by name or username | +| `perPage` | number | No | Number of results per page \(default 20, max 100\) | +| `page` | number | No | Page number for pagination | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `members` | array | List of project or group members | +| `total` | number | Total number of members | + +### `gitlab_add_member` + +Add an existing GitLab user to a project or group at a given access level + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `resourceType` | string | Yes | Whether the resource is a 'project' or a 'group' | +| `resourceId` | string | Yes | Project or group ID or URL-encoded path | +| `userId` | number | Yes | The ID of the user to add | +| `accessLevel` | number | Yes | Access level: 10 \(Guest\), 20 \(Reporter\), 30 \(Developer\), 40 \(Maintainer\), 50 \(Owner\) | +| `expiresAt` | string | No | Access expiration date in YYYY-MM-DD format | +| `memberRoleId` | number | No | Custom member role ID \(GitLab Ultimate only\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `member` | object | The added member | +| `alreadyMember` | boolean | Whether the user was already a member \(add was a no-op\) | + +### `gitlab_update_member` + +Update a member's access level in a GitLab project or group + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `resourceType` | string | Yes | Whether the resource is a 'project' or a 'group' | +| `resourceId` | string | Yes | Project or group ID or URL-encoded path | +| `userId` | number | Yes | The ID of the member to update | +| `accessLevel` | number | Yes | New access level: 0 \(No access\), 10 \(Guest\), 20 \(Reporter\), 30 \(Developer\), 40 \(Maintainer\), 50 \(Owner\) | +| `expiresAt` | string | No | Access expiration date in YYYY-MM-DD format | +| `memberRoleId` | number | No | Custom member role ID \(GitLab Ultimate only\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `member` | object | The updated member | + +### `gitlab_remove_member` + +Remove a member from a GitLab project or group + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `resourceType` | string | Yes | Whether the resource is a 'project' or a 'group' | +| `resourceId` | string | Yes | Project or group ID or URL-encoded path | +| `userId` | number | Yes | The ID of the member to remove | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the member was removed successfully | + +### `gitlab_invite_member` + +Invite a person to a GitLab project or group by email address + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `resourceType` | string | Yes | Whether the resource is a 'project' or a 'group' | +| `resourceId` | string | Yes | Project or group ID or URL-encoded path | +| `email` | string | Yes | Email address to invite \(comma-separated for multiple\) | +| `accessLevel` | number | Yes | Access level: 10 \(Guest\), 20 \(Reporter\), 30 \(Developer\), 40 \(Maintainer\), 50 \(Owner\) | +| `expiresAt` | string | No | Access expiration date in YYYY-MM-DD format | +| `memberRoleId` | number | No | Custom member role ID \(GitLab Ultimate only\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | string | Invitation status returned by GitLab | +| `message` | object | Per-email result detail, if any | + +### `gitlab_list_invitations` + +List pending email invitations for a GitLab project or group + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `resourceType` | string | Yes | Whether the resource is a 'project' or a 'group' | +| `resourceId` | string | Yes | Project or group ID or URL-encoded path | +| `query` | string | No | Filter invitations by invited email | +| `perPage` | number | No | Number of results per page \(default 20, max 100\) | +| `page` | number | No | Page number for pagination | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `invitations` | array | List of pending invitations | +| `total` | number | Total number of invitations | + +### `gitlab_update_invitation` + +Update a pending invitation to a GitLab project or group + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `resourceType` | string | Yes | Whether the resource is a 'project' or a 'group' | +| `resourceId` | string | Yes | Project or group ID or URL-encoded path | +| `email` | string | Yes | Email address of the invitation to update | +| `accessLevel` | number | No | New access level: 10 \(Guest\), 20 \(Reporter\), 30 \(Developer\), 40 \(Maintainer\), 50 \(Owner\) | +| `expiresAt` | string | No | Access expiration date in YYYY-MM-DD format | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `invitation` | object | The updated invitation | + +### `gitlab_revoke_invitation` + +Revoke a pending email invitation to a GitLab project or group + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `resourceType` | string | Yes | Whether the resource is a 'project' or a 'group' | +| `resourceId` | string | Yes | Project or group ID or URL-encoded path | +| `email` | string | Yes | Email address of the invitation to revoke | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the invitation was revoked successfully | + +### `gitlab_list_access_requests` + +List pending access requests for a GitLab project or group + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `resourceType` | string | Yes | Whether the resource is a 'project' or a 'group' | +| `resourceId` | string | Yes | Project or group ID or URL-encoded path | +| `perPage` | number | No | Number of results per page \(default 20, max 100\) | +| `page` | number | No | Page number for pagination | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `accessRequests` | array | List of pending access requests | +| `total` | number | Total number of access requests | + +### `gitlab_approve_access_request` + +Approve a pending access request for a GitLab project or group + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `resourceType` | string | Yes | Whether the resource is a 'project' or a 'group' | +| `resourceId` | string | Yes | Project or group ID or URL-encoded path | +| `userId` | number | Yes | The user ID of the access requester | +| `accessLevel` | number | No | Access level to grant: 10 \(Guest\), 20 \(Reporter\), 30 \(Developer\), 40 \(Maintainer\), 50 \(Owner\). Defaults to 30 \(Developer\). | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `accessRequest` | object | The approved access request | + +### `gitlab_deny_access_request` + +Deny (delete) a pending access request for a GitLab project or group + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `resourceType` | string | Yes | Whether the resource is a 'project' or a 'group' | +| `resourceId` | string | Yes | Project or group ID or URL-encoded path | +| `userId` | number | Yes | The user ID of the access requester | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the access request was denied successfully | + +### `gitlab_list_saml_group_links` + +List SAML group links for a GitLab group. Use this to detect whether a group is governed by SAML group sync before provisioning members. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `groupId` | string | Yes | Group ID or URL-encoded path | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `samlGroupLinks` | array | List of SAML group links | +| `total` | number | Number of SAML group links | + +### `gitlab_search_users` + +Search for GitLab users by name, username, or email. Use this to resolve an email to a user ID before adding a member. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `search` | string | Yes | Name, username, or email to search for | +| `perPage` | number | No | Number of results per page \(default 20, max 100\) | +| `page` | number | No | Page number for pagination | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `users` | array | List of matching users | +| `total` | number | Total number of matching users | + +### `gitlab_create_user` + +Create a new GitLab user. Requires an administrator token with admin_mode on the instance. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `email` | string | Yes | The user's email address | +| `username` | string | Yes | The user's username | +| `name` | string | Yes | The user's display name | +| `password` | string | No | The user's password. Omit and set resetPassword to email a reset link instead. | +| `resetPassword` | boolean | No | Send the user a password reset link instead of setting a password | +| `admin` | boolean | No | Whether the new user is an administrator | +| `skipConfirmation` | boolean | No | Skip email confirmation for the new user | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `user` | object | The created user | + +### `gitlab_update_user` + +Modify an existing GitLab user. Requires an administrator token with admin_mode on the instance. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `userId` | number | Yes | The ID of the user to modify | +| `email` | string | No | The user's new email address | +| `username` | string | No | The user's new username | +| `name` | string | No | The user's new display name | +| `admin` | boolean | No | Whether the user is an administrator | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `user` | object | The updated user | + +### `gitlab_delete_user` + +Delete a GitLab user. Requires an administrator token with admin_mode on the instance. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `userId` | number | Yes | The ID of the user to delete | +| `hardDelete` | boolean | No | When true, contributions and personal projects are deleted rather than moved to a Ghost User | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the user was deleted successfully | + +### `gitlab_block_user` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `projects` | json | List of projects | +| `project` | json | Project details | +| `issues` | json | List of issues | +| `issue` | json | Issue details | +| `mergeRequests` | json | List of merge requests | +| `mergeRequest` | json | Merge request details | +| `mergeRequestIid` | number | Merge request internal ID \(IID\) | +| `pipelines` | json | List of pipelines | +| `pipeline` | json | Pipeline details | +| `note` | json | Comment/note details | +| `tree` | json | Repository tree entries | +| `content` | string | File contents \(decoded\) | +| `fileName` | string | File name | +| `filePath` | string | Path to the file in the repository | +| `branch` | string | Branch the file was committed to | +| `branches` | json | List of branches | +| `commits` | json | List of commits | +| `commit` | json | A single commit \(e.g. latest commit in a comparison\) | +| `name` | string | Created branch name | +| `protected` | boolean | Whether the branch is protected | +| `size` | number | File size in bytes | +| `ref` | string | The branch, tag, or commit SHA | +| `blobId` | string | The blob ID | +| `lastCommitId` | string | The last commit ID that modified the file | +| `webUrl` | string | Web URL | +| `changes` | json | Merge request file changes/diffs | +| `changesCount` | number | Number of changed files returned | +| `approvalsRequired` | number | Approvals required | +| `approvalsLeft` | number | Approvals remaining | +| `approvedBy` | json | List of approvers | +| `jobs` | json | Pipeline jobs | +| `log` | string | Job log output | +| `id` | number | Job ID | +| `status` | string | Job status | +| `diffs` | json | File diffs between two compared references | +| `compareTimeout` | boolean | Whether the comparison timed out | +| `compareSameRef` | boolean | Whether both compared references match | +| `releases` | json | List of releases | +| `release` | json | Release details | +| `members` | json | List of project or group members | +| `member` | json | A single member | +| `alreadyMember` | boolean | Whether the user was already a member | +| `invitations` | json | List of pending invitations | +| `invitation` | json | A single invitation | +| `accessRequests` | json | List of pending access requests | +| `accessRequest` | json | A single access request | +| `samlGroupLinks` | json | List of SAML group links | +| `samlGroupLink` | json | A single SAML group link | +| `message` | json | Per-email invitation result detail | +| `users` | json | List of matching users | +| `user` | json | User details | +| `total` | number | Total number of items available across all pages | +| `success` | boolean | Operation success status | + +### `gitlab_unblock_user` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `projects` | json | List of projects | +| `project` | json | Project details | +| `issues` | json | List of issues | +| `issue` | json | Issue details | +| `mergeRequests` | json | List of merge requests | +| `mergeRequest` | json | Merge request details | +| `mergeRequestIid` | number | Merge request internal ID \(IID\) | +| `pipelines` | json | List of pipelines | +| `pipeline` | json | Pipeline details | +| `note` | json | Comment/note details | +| `tree` | json | Repository tree entries | +| `content` | string | File contents \(decoded\) | +| `fileName` | string | File name | +| `filePath` | string | Path to the file in the repository | +| `branch` | string | Branch the file was committed to | +| `branches` | json | List of branches | +| `commits` | json | List of commits | +| `commit` | json | A single commit \(e.g. latest commit in a comparison\) | +| `name` | string | Created branch name | +| `protected` | boolean | Whether the branch is protected | +| `size` | number | File size in bytes | +| `ref` | string | The branch, tag, or commit SHA | +| `blobId` | string | The blob ID | +| `lastCommitId` | string | The last commit ID that modified the file | +| `webUrl` | string | Web URL | +| `changes` | json | Merge request file changes/diffs | +| `changesCount` | number | Number of changed files returned | +| `approvalsRequired` | number | Approvals required | +| `approvalsLeft` | number | Approvals remaining | +| `approvedBy` | json | List of approvers | +| `jobs` | json | Pipeline jobs | +| `log` | string | Job log output | +| `id` | number | Job ID | +| `status` | string | Job status | +| `diffs` | json | File diffs between two compared references | +| `compareTimeout` | boolean | Whether the comparison timed out | +| `compareSameRef` | boolean | Whether both compared references match | +| `releases` | json | List of releases | +| `release` | json | Release details | +| `members` | json | List of project or group members | +| `member` | json | A single member | +| `alreadyMember` | boolean | Whether the user was already a member | +| `invitations` | json | List of pending invitations | +| `invitation` | json | A single invitation | +| `accessRequests` | json | List of pending access requests | +| `accessRequest` | json | A single access request | +| `samlGroupLinks` | json | List of SAML group links | +| `samlGroupLink` | json | A single SAML group link | +| `message` | json | Per-email invitation result detail | +| `users` | json | List of matching users | +| `user` | json | User details | +| `total` | number | Total number of items available across all pages | +| `success` | boolean | Operation success status | + +### `gitlab_deactivate_user` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `projects` | json | List of projects | +| `project` | json | Project details | +| `issues` | json | List of issues | +| `issue` | json | Issue details | +| `mergeRequests` | json | List of merge requests | +| `mergeRequest` | json | Merge request details | +| `mergeRequestIid` | number | Merge request internal ID \(IID\) | +| `pipelines` | json | List of pipelines | +| `pipeline` | json | Pipeline details | +| `note` | json | Comment/note details | +| `tree` | json | Repository tree entries | +| `content` | string | File contents \(decoded\) | +| `fileName` | string | File name | +| `filePath` | string | Path to the file in the repository | +| `branch` | string | Branch the file was committed to | +| `branches` | json | List of branches | +| `commits` | json | List of commits | +| `commit` | json | A single commit \(e.g. latest commit in a comparison\) | +| `name` | string | Created branch name | +| `protected` | boolean | Whether the branch is protected | +| `size` | number | File size in bytes | +| `ref` | string | The branch, tag, or commit SHA | +| `blobId` | string | The blob ID | +| `lastCommitId` | string | The last commit ID that modified the file | +| `webUrl` | string | Web URL | +| `changes` | json | Merge request file changes/diffs | +| `changesCount` | number | Number of changed files returned | +| `approvalsRequired` | number | Approvals required | +| `approvalsLeft` | number | Approvals remaining | +| `approvedBy` | json | List of approvers | +| `jobs` | json | Pipeline jobs | +| `log` | string | Job log output | +| `id` | number | Job ID | +| `status` | string | Job status | +| `diffs` | json | File diffs between two compared references | +| `compareTimeout` | boolean | Whether the comparison timed out | +| `compareSameRef` | boolean | Whether both compared references match | +| `releases` | json | List of releases | +| `release` | json | Release details | +| `members` | json | List of project or group members | +| `member` | json | A single member | +| `alreadyMember` | boolean | Whether the user was already a member | +| `invitations` | json | List of pending invitations | +| `invitation` | json | A single invitation | +| `accessRequests` | json | List of pending access requests | +| `accessRequest` | json | A single access request | +| `samlGroupLinks` | json | List of SAML group links | +| `samlGroupLink` | json | A single SAML group link | +| `message` | json | Per-email invitation result detail | +| `users` | json | List of matching users | +| `user` | json | User details | +| `total` | number | Total number of items available across all pages | +| `success` | boolean | Operation success status | + +### `gitlab_activate_user` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `projects` | json | List of projects | +| `project` | json | Project details | +| `issues` | json | List of issues | +| `issue` | json | Issue details | +| `mergeRequests` | json | List of merge requests | +| `mergeRequest` | json | Merge request details | +| `mergeRequestIid` | number | Merge request internal ID \(IID\) | +| `pipelines` | json | List of pipelines | +| `pipeline` | json | Pipeline details | +| `note` | json | Comment/note details | +| `tree` | json | Repository tree entries | +| `content` | string | File contents \(decoded\) | +| `fileName` | string | File name | +| `filePath` | string | Path to the file in the repository | +| `branch` | string | Branch the file was committed to | +| `branches` | json | List of branches | +| `commits` | json | List of commits | +| `commit` | json | A single commit \(e.g. latest commit in a comparison\) | +| `name` | string | Created branch name | +| `protected` | boolean | Whether the branch is protected | +| `size` | number | File size in bytes | +| `ref` | string | The branch, tag, or commit SHA | +| `blobId` | string | The blob ID | +| `lastCommitId` | string | The last commit ID that modified the file | +| `webUrl` | string | Web URL | +| `changes` | json | Merge request file changes/diffs | +| `changesCount` | number | Number of changed files returned | +| `approvalsRequired` | number | Approvals required | +| `approvalsLeft` | number | Approvals remaining | +| `approvedBy` | json | List of approvers | +| `jobs` | json | Pipeline jobs | +| `log` | string | Job log output | +| `id` | number | Job ID | +| `status` | string | Job status | +| `diffs` | json | File diffs between two compared references | +| `compareTimeout` | boolean | Whether the comparison timed out | +| `compareSameRef` | boolean | Whether both compared references match | +| `releases` | json | List of releases | +| `release` | json | Release details | +| `members` | json | List of project or group members | +| `member` | json | A single member | +| `alreadyMember` | boolean | Whether the user was already a member | +| `invitations` | json | List of pending invitations | +| `invitation` | json | A single invitation | +| `accessRequests` | json | List of pending access requests | +| `accessRequest` | json | A single access request | +| `samlGroupLinks` | json | List of SAML group links | +| `samlGroupLink` | json | A single SAML group link | +| `message` | json | Per-email invitation result detail | +| `users` | json | List of matching users | +| `user` | json | User details | +| `total` | number | Total number of items available across all pages | +| `success` | boolean | Operation success status | + +### `gitlab_ban_user` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `projects` | json | List of projects | +| `project` | json | Project details | +| `issues` | json | List of issues | +| `issue` | json | Issue details | +| `mergeRequests` | json | List of merge requests | +| `mergeRequest` | json | Merge request details | +| `mergeRequestIid` | number | Merge request internal ID \(IID\) | +| `pipelines` | json | List of pipelines | +| `pipeline` | json | Pipeline details | +| `note` | json | Comment/note details | +| `tree` | json | Repository tree entries | +| `content` | string | File contents \(decoded\) | +| `fileName` | string | File name | +| `filePath` | string | Path to the file in the repository | +| `branch` | string | Branch the file was committed to | +| `branches` | json | List of branches | +| `commits` | json | List of commits | +| `commit` | json | A single commit \(e.g. latest commit in a comparison\) | +| `name` | string | Created branch name | +| `protected` | boolean | Whether the branch is protected | +| `size` | number | File size in bytes | +| `ref` | string | The branch, tag, or commit SHA | +| `blobId` | string | The blob ID | +| `lastCommitId` | string | The last commit ID that modified the file | +| `webUrl` | string | Web URL | +| `changes` | json | Merge request file changes/diffs | +| `changesCount` | number | Number of changed files returned | +| `approvalsRequired` | number | Approvals required | +| `approvalsLeft` | number | Approvals remaining | +| `approvedBy` | json | List of approvers | +| `jobs` | json | Pipeline jobs | +| `log` | string | Job log output | +| `id` | number | Job ID | +| `status` | string | Job status | +| `diffs` | json | File diffs between two compared references | +| `compareTimeout` | boolean | Whether the comparison timed out | +| `compareSameRef` | boolean | Whether both compared references match | +| `releases` | json | List of releases | +| `release` | json | Release details | +| `members` | json | List of project or group members | +| `member` | json | A single member | +| `alreadyMember` | boolean | Whether the user was already a member | +| `invitations` | json | List of pending invitations | +| `invitation` | json | A single invitation | +| `accessRequests` | json | List of pending access requests | +| `accessRequest` | json | A single access request | +| `samlGroupLinks` | json | List of SAML group links | +| `samlGroupLink` | json | A single SAML group link | +| `message` | json | Per-email invitation result detail | +| `users` | json | List of matching users | +| `user` | json | User details | +| `total` | number | Total number of items available across all pages | +| `success` | boolean | Operation success status | + +### `gitlab_unban_user` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `projects` | json | List of projects | +| `project` | json | Project details | +| `issues` | json | List of issues | +| `issue` | json | Issue details | +| `mergeRequests` | json | List of merge requests | +| `mergeRequest` | json | Merge request details | +| `mergeRequestIid` | number | Merge request internal ID \(IID\) | +| `pipelines` | json | List of pipelines | +| `pipeline` | json | Pipeline details | +| `note` | json | Comment/note details | +| `tree` | json | Repository tree entries | +| `content` | string | File contents \(decoded\) | +| `fileName` | string | File name | +| `filePath` | string | Path to the file in the repository | +| `branch` | string | Branch the file was committed to | +| `branches` | json | List of branches | +| `commits` | json | List of commits | +| `commit` | json | A single commit \(e.g. latest commit in a comparison\) | +| `name` | string | Created branch name | +| `protected` | boolean | Whether the branch is protected | +| `size` | number | File size in bytes | +| `ref` | string | The branch, tag, or commit SHA | +| `blobId` | string | The blob ID | +| `lastCommitId` | string | The last commit ID that modified the file | +| `webUrl` | string | Web URL | +| `changes` | json | Merge request file changes/diffs | +| `changesCount` | number | Number of changed files returned | +| `approvalsRequired` | number | Approvals required | +| `approvalsLeft` | number | Approvals remaining | +| `approvedBy` | json | List of approvers | +| `jobs` | json | Pipeline jobs | +| `log` | string | Job log output | +| `id` | number | Job ID | +| `status` | string | Job status | +| `diffs` | json | File diffs between two compared references | +| `compareTimeout` | boolean | Whether the comparison timed out | +| `compareSameRef` | boolean | Whether both compared references match | +| `releases` | json | List of releases | +| `release` | json | Release details | +| `members` | json | List of project or group members | +| `member` | json | A single member | +| `alreadyMember` | boolean | Whether the user was already a member | +| `invitations` | json | List of pending invitations | +| `invitation` | json | A single invitation | +| `accessRequests` | json | List of pending access requests | +| `accessRequest` | json | A single access request | +| `samlGroupLinks` | json | List of SAML group links | +| `samlGroupLink` | json | A single SAML group link | +| `message` | json | Per-email invitation result detail | +| `users` | json | List of matching users | +| `user` | json | User details | +| `total` | number | Total number of items available across all pages | +| `success` | boolean | Operation success status | + +### `gitlab_approve_user` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `projects` | json | List of projects | +| `project` | json | Project details | +| `issues` | json | List of issues | +| `issue` | json | Issue details | +| `mergeRequests` | json | List of merge requests | +| `mergeRequest` | json | Merge request details | +| `mergeRequestIid` | number | Merge request internal ID \(IID\) | +| `pipelines` | json | List of pipelines | +| `pipeline` | json | Pipeline details | +| `note` | json | Comment/note details | +| `tree` | json | Repository tree entries | +| `content` | string | File contents \(decoded\) | +| `fileName` | string | File name | +| `filePath` | string | Path to the file in the repository | +| `branch` | string | Branch the file was committed to | +| `branches` | json | List of branches | +| `commits` | json | List of commits | +| `commit` | json | A single commit \(e.g. latest commit in a comparison\) | +| `name` | string | Created branch name | +| `protected` | boolean | Whether the branch is protected | +| `size` | number | File size in bytes | +| `ref` | string | The branch, tag, or commit SHA | +| `blobId` | string | The blob ID | +| `lastCommitId` | string | The last commit ID that modified the file | +| `webUrl` | string | Web URL | +| `changes` | json | Merge request file changes/diffs | +| `changesCount` | number | Number of changed files returned | +| `approvalsRequired` | number | Approvals required | +| `approvalsLeft` | number | Approvals remaining | +| `approvedBy` | json | List of approvers | +| `jobs` | json | Pipeline jobs | +| `log` | string | Job log output | +| `id` | number | Job ID | +| `status` | string | Job status | +| `diffs` | json | File diffs between two compared references | +| `compareTimeout` | boolean | Whether the comparison timed out | +| `compareSameRef` | boolean | Whether both compared references match | +| `releases` | json | List of releases | +| `release` | json | Release details | +| `members` | json | List of project or group members | +| `member` | json | A single member | +| `alreadyMember` | boolean | Whether the user was already a member | +| `invitations` | json | List of pending invitations | +| `invitation` | json | A single invitation | +| `accessRequests` | json | List of pending access requests | +| `accessRequest` | json | A single access request | +| `samlGroupLinks` | json | List of SAML group links | +| `samlGroupLink` | json | A single SAML group link | +| `message` | json | Per-email invitation result detail | +| `users` | json | List of matching users | +| `user` | json | User details | +| `total` | number | Total number of items available across all pages | +| `success` | boolean | Operation success status | + +### `gitlab_reject_user` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `projects` | json | List of projects | +| `project` | json | Project details | +| `issues` | json | List of issues | +| `issue` | json | Issue details | +| `mergeRequests` | json | List of merge requests | +| `mergeRequest` | json | Merge request details | +| `mergeRequestIid` | number | Merge request internal ID \(IID\) | +| `pipelines` | json | List of pipelines | +| `pipeline` | json | Pipeline details | +| `note` | json | Comment/note details | +| `tree` | json | Repository tree entries | +| `content` | string | File contents \(decoded\) | +| `fileName` | string | File name | +| `filePath` | string | Path to the file in the repository | +| `branch` | string | Branch the file was committed to | +| `branches` | json | List of branches | +| `commits` | json | List of commits | +| `commit` | json | A single commit \(e.g. latest commit in a comparison\) | +| `name` | string | Created branch name | +| `protected` | boolean | Whether the branch is protected | +| `size` | number | File size in bytes | +| `ref` | string | The branch, tag, or commit SHA | +| `blobId` | string | The blob ID | +| `lastCommitId` | string | The last commit ID that modified the file | +| `webUrl` | string | Web URL | +| `changes` | json | Merge request file changes/diffs | +| `changesCount` | number | Number of changed files returned | +| `approvalsRequired` | number | Approvals required | +| `approvalsLeft` | number | Approvals remaining | +| `approvedBy` | json | List of approvers | +| `jobs` | json | Pipeline jobs | +| `log` | string | Job log output | +| `id` | number | Job ID | +| `status` | string | Job status | +| `diffs` | json | File diffs between two compared references | +| `compareTimeout` | boolean | Whether the comparison timed out | +| `compareSameRef` | boolean | Whether both compared references match | +| `releases` | json | List of releases | +| `release` | json | Release details | +| `members` | json | List of project or group members | +| `member` | json | A single member | +| `alreadyMember` | boolean | Whether the user was already a member | +| `invitations` | json | List of pending invitations | +| `invitation` | json | A single invitation | +| `accessRequests` | json | List of pending access requests | +| `accessRequest` | json | A single access request | +| `samlGroupLinks` | json | List of SAML group links | +| `samlGroupLink` | json | A single SAML group link | +| `message` | json | Per-email invitation result detail | +| `users` | json | List of matching users | +| `user` | json | User details | +| `total` | number | Total number of items available across all pages | +| `success` | boolean | Operation success status | + +### `gitlab_delete_user_identity` + +Delete a user's authentication identity (e.g. SAML or LDAP). Requires an administrator token with admin_mode on the instance. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `userId` | number | Yes | The ID of the user | +| `provider` | string | Yes | The external identity provider name \(e.g. saml, ldapmain\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the identity was deleted successfully | + +### `gitlab_add_saml_group_link` + +Add a SAML group link that maps an identity-provider group to a GitLab group at a given access level + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `groupId` | string | Yes | Group ID or URL-encoded path | +| `samlGroupName` | string | Yes | The name of the SAML group as sent by the identity provider | +| `accessLevel` | number | Yes | Access level granted to members of the SAML group: 10 \(Guest\), 20 \(Reporter\), 30 \(Developer\), 40 \(Maintainer\), 50 \(Owner\) | +| `memberRoleId` | number | No | Custom member role ID \(GitLab Ultimate only\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `samlGroupLink` | object | The created SAML group link | + +### `gitlab_delete_saml_group_link` + +Delete a SAML group link from a GitLab group + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `groupId` | string | Yes | Group ID or URL-encoded path | +| `samlGroupName` | string | Yes | The name of the SAML group link to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the SAML group link was deleted successfully | + ## Triggers From a19b09f18c2e82c5ce406ab64b1ae06e55a92dcd Mon Sep 17 00:00:00 2001 From: mzxchandra <129460234+mzxchandra@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:02:46 -0700 Subject: [PATCH 5/7] Update apps/sim/blocks/blocks/gitlab.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- apps/sim/blocks/blocks/gitlab.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/sim/blocks/blocks/gitlab.ts b/apps/sim/blocks/blocks/gitlab.ts index e88d9ca267d..be90f1f6018 100644 --- a/apps/sim/blocks/blocks/gitlab.ts +++ b/apps/sim/blocks/blocks/gitlab.ts @@ -47,6 +47,7 @@ const ACCESS_LEVEL_OPS = [ 'gitlab_add_member', 'gitlab_update_member', 'gitlab_invite_member', + 'gitlab_update_invitation', 'gitlab_approve_access_request', 'gitlab_add_saml_group_link', ] From 5bf49434edc3ce904dc23b4b8b4a28bfd1d32578 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Thu, 16 Jul 2026 02:08:10 -0700 Subject: [PATCH 6/7] fix(gitlab): address review findings - update_user now sends admin:false so the Administrator switch can demote (an untouched switch stays undefined and leaves the flag unchanged) - expose the access-level dropdown for Update Invitation - normalize comma-separated invite emails so spaced multi-email input works --- apps/sim/blocks/blocks/gitlab.test.ts | 35 ++++++++++++++++++++++++++ apps/sim/blocks/blocks/gitlab.ts | 5 +++- apps/sim/tools/gitlab/access.test.ts | 20 +++++++++++++++ apps/sim/tools/gitlab/invite_member.ts | 11 +++++++- 4 files changed, 69 insertions(+), 2 deletions(-) diff --git a/apps/sim/blocks/blocks/gitlab.test.ts b/apps/sim/blocks/blocks/gitlab.test.ts index f321c7e4ce8..ce7aacdc550 100644 --- a/apps/sim/blocks/blocks/gitlab.test.ts +++ b/apps/sim/blocks/blocks/gitlab.test.ts @@ -97,6 +97,41 @@ describe('GitLabBlock access operations', () => { expect(approveParams).toMatchObject({ userId: 7, accessLevel: 30 }) }) + it('sends admin:false so update user can demote an administrator', () => { + const demote = block.tools.config.params?.({ + accessToken: 'pat', + operation: 'gitlab_update_user', + userId: '9', + userAdminIsAdmin: false, + }) + expect(demote?.admin).toBe(false) + + // An untouched switch is undefined and must leave the admin flag unchanged. + const untouched = block.tools.config.params?.({ + accessToken: 'pat', + operation: 'gitlab_update_user', + userId: '9', + }) + expect(untouched?.admin).toBeUndefined() + }) + + it('exposes the access-level dropdown for update invitation and coerces it', () => { + const accessLevel = block.subBlocks.find((s) => s.id === 'accessLevel') + const condition = accessLevel?.condition + const ops = condition && 'value' in condition ? condition.value : undefined + expect(ops).toContain('gitlab_update_invitation') + + const params = block.tools.config.params?.({ + accessToken: 'pat', + operation: 'gitlab_update_invitation', + resourceType: 'group', + resourceId: '42', + email: 'a@b.com', + accessLevel: '40', + }) + expect(params).toMatchObject({ email: 'a@b.com', accessLevel: 40 }) + }) + it('throws when required access fields are missing', () => { expect(() => block.tools.config.params?.({ diff --git a/apps/sim/blocks/blocks/gitlab.ts b/apps/sim/blocks/blocks/gitlab.ts index be90f1f6018..06d20b8e5d5 100644 --- a/apps/sim/blocks/blocks/gitlab.ts +++ b/apps/sim/blocks/blocks/gitlab.ts @@ -1820,7 +1820,10 @@ Return ONLY the commit message - no explanations, no extra text.`, email: params.userAdminEmail?.trim() || undefined, username: params.userAdminUsername?.trim() || undefined, name: params.userAdminName?.trim() || undefined, - admin: params.userAdminIsAdmin || undefined, + // Pass the boolean through (not `|| undefined`) so an explicit `false` + // demotes the user. An untouched switch is `undefined` and is skipped + // by the tool body, leaving the admin flag unchanged. + admin: params.userAdminIsAdmin, } case 'gitlab_delete_user': diff --git a/apps/sim/tools/gitlab/access.test.ts b/apps/sim/tools/gitlab/access.test.ts index ed350f686f3..fa6eb4b2441 100644 --- a/apps/sim/tools/gitlab/access.test.ts +++ b/apps/sim/tools/gitlab/access.test.ts @@ -159,6 +159,26 @@ describe('gitlab_invite_member', () => { }) }) +describe('gitlab_invite_member email normalization', () => { + it('normalizes a comma-separated list with spaces into GitLab-accepted form', () => { + const body = gitlabInviteMemberTool.request.body?.({ + ...baseArgs, + email: 'alice@example.com, bob@example.com', + accessLevel: 30, + }) as Record + expect(body.email).toBe('alice@example.com,bob@example.com') + }) + + it('passes a single email through unchanged', () => { + const body = gitlabInviteMemberTool.request.body?.({ + ...baseArgs, + email: 'alice@example.com', + accessLevel: 30, + }) as Record + expect(body.email).toBe('alice@example.com') + }) +}) + describe('gitlab user status actions', () => { it('returns success with no user object when GitLab responds with a bare true', async () => { const result = await gitlabBlockUserTool.transformResponse!( diff --git a/apps/sim/tools/gitlab/invite_member.ts b/apps/sim/tools/gitlab/invite_member.ts index 2ee7e64bb94..993c38ab915 100644 --- a/apps/sim/tools/gitlab/invite_member.ts +++ b/apps/sim/tools/gitlab/invite_member.ts @@ -74,8 +74,17 @@ export const gitlabInviteMemberTool: ToolConfig< 'PRIVATE-TOKEN': params.accessToken, }), body: (params) => { + // GitLab accepts a comma-separated list of emails in a single `email` + // field. Normalize surrounding whitespace so "a@b.com, c@d.com" invites + // both addresses rather than sending a malformed second entry. + const email = String(params.email) + .split(',') + .map((address) => address.trim()) + .filter(Boolean) + .join(',') + const body: Record = { - email: params.email, + email, access_level: params.accessLevel, } From d41d8f11182827763744f2af1b5a48254143d528 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Thu, 16 Jul 2026 02:15:57 -0700 Subject: [PATCH 7/7] fix(gitlab): make update-invitation access level optional Update Invitation now uses a dedicated dropdown that defaults to 'Leave unchanged', so updating only the expiration no longer silently resets the invitation's access level to Developer. The level is sent only when explicitly chosen. --- apps/sim/blocks/blocks/gitlab.test.ts | 31 ++++++++++++++++++------ apps/sim/blocks/blocks/gitlab.ts | 35 +++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 10 deletions(-) diff --git a/apps/sim/blocks/blocks/gitlab.test.ts b/apps/sim/blocks/blocks/gitlab.test.ts index ce7aacdc550..fa959a3fae5 100644 --- a/apps/sim/blocks/blocks/gitlab.test.ts +++ b/apps/sim/blocks/blocks/gitlab.test.ts @@ -115,21 +115,36 @@ describe('GitLabBlock access operations', () => { expect(untouched?.admin).toBeUndefined() }) - it('exposes the access-level dropdown for update invitation and coerces it', () => { - const accessLevel = block.subBlocks.find((s) => s.id === 'accessLevel') - const condition = accessLevel?.condition - const ops = condition && 'value' in condition ? condition.value : undefined - expect(ops).toContain('gitlab_update_invitation') + it('exposes an optional access-level dropdown for update invitation that defaults to unchanged', () => { + const invAccess = block.subBlocks.find((s) => s.id === 'invitationAccessLevel') + expect(invAccess?.type).toBe('dropdown') + expect(invAccess?.value?.()).toBe('') + const options = typeof invAccess?.options === 'function' ? undefined : invAccess?.options + expect(options?.[0]).toEqual({ label: 'Leave unchanged', id: '' }) - const params = block.tools.config.params?.({ + // Updating only the expiration must NOT send an access level (no silent reset). + const expiryOnly = block.tools.config.params?.({ accessToken: 'pat', operation: 'gitlab_update_invitation', resourceType: 'group', resourceId: '42', email: 'a@b.com', - accessLevel: '40', + expiresAt: '2027-01-01', + invitationAccessLevel: '', + }) + expect(expiryOnly).toMatchObject({ email: 'a@b.com', expiresAt: '2027-01-01' }) + expect(expiryOnly?.accessLevel).toBeUndefined() + + // Choosing a level sends the coerced integer. + const withLevel = block.tools.config.params?.({ + accessToken: 'pat', + operation: 'gitlab_update_invitation', + resourceType: 'group', + resourceId: '42', + email: 'a@b.com', + invitationAccessLevel: '40', }) - expect(params).toMatchObject({ email: 'a@b.com', accessLevel: 40 }) + expect(withLevel).toMatchObject({ email: 'a@b.com', accessLevel: 40 }) }) it('throws when required access fields are missing', () => { diff --git a/apps/sim/blocks/blocks/gitlab.ts b/apps/sim/blocks/blocks/gitlab.ts index 06d20b8e5d5..3c19dcc7a51 100644 --- a/apps/sim/blocks/blocks/gitlab.ts +++ b/apps/sim/blocks/blocks/gitlab.ts @@ -47,7 +47,6 @@ const ACCESS_LEVEL_OPS = [ 'gitlab_add_member', 'gitlab_update_member', 'gitlab_invite_member', - 'gitlab_update_invitation', 'gitlab_approve_access_request', 'gitlab_add_saml_group_link', ] @@ -933,6 +932,30 @@ Return ONLY the commit message - no explanations, no extra text.`, value: ACCESS_LEVEL_OPS, }, }, + // Optional access level for Update Invitation. Defaults to "Leave unchanged" + // so updating only the expiration does not silently reset the access level. + { + id: 'invitationAccessLevel', + title: 'Access Level', + type: 'dropdown', + options: [ + { label: 'Leave unchanged', id: '' }, + { label: 'No access', id: '0' }, + { label: 'Minimal Access', id: '5' }, + { label: 'Guest', id: '10' }, + { label: 'Planner', id: '15' }, + { label: 'Reporter', id: '20' }, + { label: 'Security Manager', id: '25' }, + { label: 'Developer', id: '30' }, + { label: 'Maintainer', id: '40' }, + { label: 'Owner', id: '50' }, + ], + value: () => '', + condition: { + field: 'operation', + value: ['gitlab_update_invitation'], + }, + }, // Access expiration date (first-class time-boxed grants) { id: 'expiresAt', @@ -1699,7 +1722,11 @@ Return ONLY the commit message - no explanations, no extra text.`, resourceType: params.resourceType || 'project', resourceId: params.resourceId.trim(), email: params.email.trim(), - accessLevel: params.accessLevel ? Number(params.accessLevel) : undefined, + // Only send access_level when a level is chosen; "Leave unchanged" + // ('') keeps the invitation's current level instead of resetting it. + accessLevel: params.invitationAccessLevel + ? Number(params.invitationAccessLevel) + : undefined, expiresAt: params.expiresAt?.trim() || undefined, } @@ -1919,6 +1946,10 @@ Return ONLY the commit message - no explanations, no extra text.`, groupId: { type: 'string', description: 'Group ID or URL-encoded path' }, userId: { type: 'number', description: 'Target user ID' }, accessLevel: { type: 'number', description: 'GitLab access level (10-50)' }, + invitationAccessLevel: { + type: 'string', + description: 'Optional new access level for an invitation ("" leaves it unchanged)', + }, expiresAt: { type: 'string', description: 'Access expiration date (YYYY-MM-DD)' }, memberRoleId: { type: 'number', description: 'Custom member role ID (Ultimate)' }, email: { type: 'string', description: 'Email address for invitations' },