From 0c0ceb8da3d0ec2081320950000575c72c3300dd Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Thu, 16 Jul 2026 01:51:05 -0700 Subject: [PATCH 01/13] 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 02/13] 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 03/13] 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 04/13] 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 05/13] 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 06/13] 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 07/13] 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' }, From c70a497527f8aa9d8d37fe81a6054594cc277a1e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 16 Jul 2026 10:31:02 -0700 Subject: [PATCH 08/13] =?UTF-8?q?fix(gitlab):=20validation=20pass=20?= =?UTF-8?q?=E2=80=94=20SAML=20provider=20param,=20member/invitation=20quer?= =?UTF-8?q?y=20filter,=20moderation=20user=20guard,=20registry=20order?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/docs/en/integrations/gitlab.mdx | 4 +- apps/sim/blocks/blocks/gitlab.ts | 38 +++++++++++++- apps/sim/tools/gitlab/add_saml_group_link.ts | 7 +++ .../tools/gitlab/delete_saml_group_link.ts | 12 ++++- apps/sim/tools/gitlab/types.ts | 5 ++ apps/sim/tools/gitlab/user_status_actions.ts | 5 +- apps/sim/tools/registry.ts | 52 +++++++++---------- 7 files changed, 92 insertions(+), 31 deletions(-) diff --git a/apps/docs/content/docs/en/integrations/gitlab.mdx b/apps/docs/content/docs/en/integrations/gitlab.mdx index 9b45eee8db5..c2058598754 100644 --- a/apps/docs/content/docs/en/integrations/gitlab.mdx +++ b/apps/docs/content/docs/en/integrations/gitlab.mdx @@ -27,7 +27,7 @@ Using Sim’s GitLab integration, your agents can programmatically interact with ## Usage Instructions -Integrate GitLab into the workflow. Can manage projects, issues, merge requests, pipelines, and add comments. Supports all core GitLab DevOps operations. +Integrate GitLab into the workflow. Can manage projects, issues, merge requests, pipelines, and add comments, plus project/group membership, invitations, access requests, SAML group links, and instance user administration. Supports all core GitLab DevOps operations. @@ -1680,6 +1680,7 @@ Add a SAML group link that maps an identity-provider group to a GitLab group at | `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\) | +| `provider` | string | No | Unique provider name that must match for this group link to be applied | #### Output @@ -1698,6 +1699,7 @@ Delete a SAML group link from a GitLab group | `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 | +| `provider` | string | No | Provider name of the link to delete. Required when multiple links share the same SAML group name. | #### Output diff --git a/apps/sim/blocks/blocks/gitlab.ts b/apps/sim/blocks/blocks/gitlab.ts index 3c19dcc7a51..2c14c531a89 100644 --- a/apps/sim/blocks/blocks/gitlab.ts +++ b/apps/sim/blocks/blocks/gitlab.ts @@ -95,7 +95,7 @@ export const GitLabBlock: BlockConfig = { authMode: AuthMode.ApiKey, triggerAllowed: true, longDescription: - 'Integrate GitLab into the workflow. Can manage projects, issues, merge requests, pipelines, and add comments. Supports all core GitLab DevOps operations.', + 'Integrate GitLab into the workflow. Can manage projects, issues, merge requests, pipelines, and add comments, plus project/group membership, invitations, access requests, SAML group links, and instance user administration. Supports all core GitLab DevOps operations.', docsLink: 'https://docs.sim.ai/integrations/gitlab', category: 'tools', integrationType: IntegrationType.DevOps, @@ -991,6 +991,18 @@ Return ONLY the commit message - no explanations, no extra text.`, value: EMAIL_OPS, }, }, + // Filter for member / invitation listings + { + id: 'query', + title: 'Filter', + type: 'short-input', + placeholder: 'Filter members by name/username, or invitations by email', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_members', 'gitlab_list_invitations'], + }, + }, // Direct members only toggle (list members) { id: 'directMembersOnly', @@ -1027,6 +1039,18 @@ Return ONLY the commit message - no explanations, no extra text.`, value: SAML_NAME_OPS, }, }, + // SAML provider name (add/delete SAML group link) + { + id: 'samlProvider', + title: 'SAML Provider', + type: 'short-input', + placeholder: 'Provider name (required when multiple links share a group name)', + mode: 'advanced', + condition: { + field: 'operation', + value: SAML_NAME_OPS, + }, + }, // Provider (delete user identity) { id: 'provider', @@ -1644,6 +1668,7 @@ Return ONLY the commit message - no explanations, no extra text.`, resourceType: params.resourceType || 'project', resourceId: params.resourceId.trim(), directOnly: params.directMembersOnly || undefined, + query: params.query?.trim() || undefined, perPage: params.perPage ? Number(params.perPage) : undefined, page: params.page ? Number(params.page) : undefined, } @@ -1709,6 +1734,7 @@ Return ONLY the commit message - no explanations, no extra text.`, ...baseParams, resourceType: params.resourceType || 'project', resourceId: params.resourceId.trim(), + query: params.query?.trim() || undefined, perPage: params.perPage ? Number(params.perPage) : undefined, page: params.page ? Number(params.page) : undefined, } @@ -1795,6 +1821,7 @@ Return ONLY the commit message - no explanations, no extra text.`, samlGroupName: params.samlGroupName.trim(), accessLevel: Number(params.accessLevel), memberRoleId: params.memberRoleId ? Number(params.memberRoleId) : undefined, + provider: params.samlProvider?.trim() || undefined, } case 'gitlab_delete_saml_group_link': @@ -1805,6 +1832,7 @@ Return ONLY the commit message - no explanations, no extra text.`, ...baseParams, groupId: params.groupId.trim(), samlGroupName: params.samlGroupName.trim(), + provider: params.samlProvider?.trim() || undefined, } case 'gitlab_search_users': @@ -1954,8 +1982,16 @@ Return ONLY the commit message - no explanations, no extra text.`, memberRoleId: { type: 'number', description: 'Custom member role ID (Ultimate)' }, email: { type: 'string', description: 'Email address for invitations' }, directMembersOnly: { type: 'boolean', description: 'Exclude inherited members' }, + query: { + type: 'string', + description: 'Filter members by name/username, or invitations by email', + }, userSearch: { type: 'string', description: 'User search query' }, samlGroupName: { type: 'string', description: 'SAML group name' }, + samlProvider: { + type: 'string', + description: 'SAML provider name for a group link (disambiguates duplicate link names)', + }, 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)" }, diff --git a/apps/sim/tools/gitlab/add_saml_group_link.ts b/apps/sim/tools/gitlab/add_saml_group_link.ts index 47e9cca5c27..b0fc85894e8 100644 --- a/apps/sim/tools/gitlab/add_saml_group_link.ts +++ b/apps/sim/tools/gitlab/add_saml_group_link.ts @@ -53,6 +53,12 @@ export const gitlabAddSamlGroupLinkTool: ToolConfig< visibility: 'user-or-llm', description: 'Custom member role ID (GitLab Ultimate only)', }, + provider: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Unique provider name that must match for this group link to be applied', + }, }, request: { @@ -72,6 +78,7 @@ export const gitlabAddSamlGroupLinkTool: ToolConfig< } if (params.memberRoleId !== undefined) body.member_role_id = params.memberRoleId + if (params.provider) body.provider = params.provider.trim() return body }, diff --git a/apps/sim/tools/gitlab/delete_saml_group_link.ts b/apps/sim/tools/gitlab/delete_saml_group_link.ts index 69b4ddbff9b..62aaa3ff5fa 100644 --- a/apps/sim/tools/gitlab/delete_saml_group_link.ts +++ b/apps/sim/tools/gitlab/delete_saml_group_link.ts @@ -39,13 +39,23 @@ export const gitlabDeleteSamlGroupLinkTool: ToolConfig< visibility: 'user-or-llm', description: 'The name of the SAML group link to delete', }, + provider: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Provider name of the link to delete. Required when multiple links share the same SAML group name.', + }, }, 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}` + const queryParams = new URLSearchParams() + if (params.provider) queryParams.append('provider', params.provider.trim()) + const query = queryParams.toString() + return `${getGitLabApiBase(params.host)}/groups/${encodedId}/saml_group_links/${encodedName}${query ? `?${query}` : ''}` }, method: 'DELETE', headers: (params) => ({ diff --git a/apps/sim/tools/gitlab/types.ts b/apps/sim/tools/gitlab/types.ts index 457d2a47237..0593d072240 100644 --- a/apps/sim/tools/gitlab/types.ts +++ b/apps/sim/tools/gitlab/types.ts @@ -774,6 +774,7 @@ interface GitLabInvitation { created_at?: string expires_at?: string | null user_name?: string + created_by_name?: string invite_token?: string member_role_id?: number | null } @@ -792,6 +793,7 @@ interface GitLabSamlGroupLink { name: string access_level: number member_role_id?: number | null + provider?: string | null } interface GitLabUser { @@ -886,11 +888,14 @@ export interface GitLabAddSamlGroupLinkParams extends GitLabBaseParams { samlGroupName: string accessLevel: number memberRoleId?: number + provider?: string } export interface GitLabDeleteSamlGroupLinkParams extends GitLabBaseParams { groupId: string | number samlGroupName: string + /** Provider name; required by GitLab when multiple links share the same SAML group name. */ + provider?: string } export interface GitLabSearchUsersParams extends GitLabBaseParams { diff --git a/apps/sim/tools/gitlab/user_status_actions.ts b/apps/sim/tools/gitlab/user_status_actions.ts index 3d781875b8a..05f891f3c3d 100644 --- a/apps/sim/tools/gitlab/user_status_actions.ts +++ b/apps/sim/tools/gitlab/user_status_actions.ts @@ -59,9 +59,10 @@ function createUserStatusActionTool( } } - // These endpoints return either `true` or the updated user object. + // These endpoints return `true`, `{ message: 'Success' }`, or the updated + // user object. Only surface objects that are actually a user record. const data = await response.json().catch(() => null) - const user = data && typeof data === 'object' ? data : undefined + const user = data && typeof data === 'object' && 'id' in data ? data : undefined return { success: true, diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 7245906eb3d..df2494cc974 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -6294,7 +6294,14 @@ export const tools: Record = { github_check_star_v2: githubCheckStarV2Tool, github_list_stargazers: githubListStargazersTool, github_list_stargazers_v2: githubListStargazersV2Tool, + gitlab_activate_user: gitlabActivateUserTool, + gitlab_add_member: gitlabAddMemberTool, + gitlab_add_saml_group_link: gitlabAddSamlGroupLinkTool, + gitlab_approve_access_request: gitlabApproveAccessRequestTool, gitlab_approve_merge_request: gitlabApproveMergeRequestTool, + gitlab_approve_user: gitlabApproveUserTool, + gitlab_ban_user: gitlabBanUserTool, + gitlab_block_user: gitlabBlockUserTool, gitlab_cancel_pipeline: gitlabCancelPipelineTool, gitlab_compare_branches: gitlabCompareBranchesTool, gitlab_create_branch: gitlabCreateBranchTool, @@ -6305,8 +6312,14 @@ export const tools: Record = { gitlab_create_merge_request_note: gitlabCreateMergeRequestNoteTool, gitlab_create_pipeline: gitlabCreatePipelineTool, gitlab_create_release: gitlabCreateReleaseTool, + gitlab_create_user: gitlabCreateUserTool, + gitlab_deactivate_user: gitlabDeactivateUserTool, gitlab_delete_branch: gitlabDeleteBranchTool, gitlab_delete_issue: gitlabDeleteIssueTool, + gitlab_delete_saml_group_link: gitlabDeleteSamlGroupLinkTool, + gitlab_delete_user: gitlabDeleteUserTool, + gitlab_delete_user_identity: gitlabDeleteUserIdentityTool, + gitlab_deny_access_request: gitlabDenyAccessRequestTool, gitlab_get_file: gitlabGetFileTool, gitlab_get_issue: gitlabGetIssueTool, gitlab_get_job_log: gitlabGetJobLogTool, @@ -6314,48 +6327,35 @@ export const tools: Record = { gitlab_get_merge_request_changes: gitlabGetMergeRequestChangesTool, gitlab_get_pipeline: gitlabGetPipelineTool, gitlab_get_project: gitlabGetProjectTool, + gitlab_invite_member: gitlabInviteMemberTool, + gitlab_list_access_requests: gitlabListAccessRequestsTool, gitlab_list_branches: gitlabListBranchesTool, gitlab_list_commits: gitlabListCommitsTool, + gitlab_list_invitations: gitlabListInvitationsTool, gitlab_list_issues: gitlabListIssuesTool, + gitlab_list_members: gitlabListMembersTool, gitlab_list_merge_requests: gitlabListMergeRequestsTool, gitlab_list_pipeline_jobs: gitlabListPipelineJobsTool, gitlab_list_pipelines: gitlabListPipelinesTool, gitlab_list_projects: gitlabListProjectsTool, gitlab_list_releases: gitlabListReleasesTool, gitlab_list_repository_tree: gitlabListRepositoryTreeTool, + gitlab_list_saml_group_links: gitlabListSamlGroupLinksTool, gitlab_merge_merge_request: gitlabMergeMergeRequestTool, gitlab_play_job: gitlabPlayJobTool, + gitlab_reject_user: gitlabRejectUserTool, + gitlab_remove_member: gitlabRemoveMemberTool, gitlab_retry_pipeline: gitlabRetryPipelineTool, + gitlab_revoke_invitation: gitlabRevokeInvitationTool, + gitlab_search_users: gitlabSearchUsersTool, + gitlab_unban_user: gitlabUnbanUserTool, + gitlab_unblock_user: gitlabUnblockUserTool, gitlab_update_file: gitlabUpdateFileTool, + gitlab_update_invitation: gitlabUpdateInvitationTool, 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_merge_request: gitlabUpdateMergeRequestTool, 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 18d23cbfe39e6bc1a5ba91c1327ed8032287ffff Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 16 Jul 2026 10:47:24 -0700 Subject: [PATCH 09/13] fix(gitlab): apply 10-agent validation findings across all 62 tools - MR draft flag now applied via Draft: title prefix (GitLab has no draft body param) - update_user only sends admin when a real boolean (untouched switch serialized null and could demote admins) - add_member 409 soft-success now verified against the conflict body - auto_merge sent alongside deprecated merge_when_pipeline_succeeds - job log capped at 200k chars, file content at 1M chars, with truncated outputs - MR diffs signal hasMore beyond 100 files - wire dropped params: update_issue milestoneId, MR milestone/squash/removeSourceBranch, pipelines ref, tree ref, branches search, commits since/until/path/author, update_file lastCommitId, jobs includeRetried, create_user forceRandomPassword - complete pipeline/job status enums, access-level enums, widen stale type unions - guards: update_invitation requires a change; create_user requires a password strategy - fix double-encoding trap in path descriptions; doc-accuracy touch-ups --- .../content/docs/en/integrations/gitlab.mdx | 178 ++++++++------- apps/sim/blocks/blocks/gitlab.ts | 208 ++++++++++++++++-- apps/sim/tools/gitlab/access.test.ts | 15 +- apps/sim/tools/gitlab/add_member.ts | 25 ++- apps/sim/tools/gitlab/add_saml_group_link.ts | 9 +- .../tools/gitlab/approve_access_request.ts | 4 +- .../sim/tools/gitlab/approve_merge_request.ts | 2 +- apps/sim/tools/gitlab/cancel_pipeline.ts | 2 +- apps/sim/tools/gitlab/compare_branches.ts | 2 +- apps/sim/tools/gitlab/create_branch.ts | 2 +- apps/sim/tools/gitlab/create_file.ts | 2 +- apps/sim/tools/gitlab/create_issue.ts | 2 +- apps/sim/tools/gitlab/create_issue_note.ts | 2 +- apps/sim/tools/gitlab/create_merge_request.ts | 13 +- .../tools/gitlab/create_merge_request_note.ts | 2 +- apps/sim/tools/gitlab/create_pipeline.ts | 2 +- apps/sim/tools/gitlab/create_release.ts | 2 +- apps/sim/tools/gitlab/create_user.ts | 9 + apps/sim/tools/gitlab/delete_branch.ts | 2 +- apps/sim/tools/gitlab/delete_issue.ts | 2 +- .../tools/gitlab/delete_saml_group_link.ts | 4 +- apps/sim/tools/gitlab/delete_user.ts | 2 +- apps/sim/tools/gitlab/deny_access_request.ts | 2 +- apps/sim/tools/gitlab/get_file.ts | 15 +- apps/sim/tools/gitlab/get_issue.ts | 2 +- apps/sim/tools/gitlab/get_job_log.ts | 21 +- apps/sim/tools/gitlab/get_merge_request.ts | 2 +- .../tools/gitlab/get_merge_request_changes.ts | 10 +- apps/sim/tools/gitlab/get_pipeline.ts | 2 +- apps/sim/tools/gitlab/get_project.ts | 2 +- apps/sim/tools/gitlab/invite_member.ts | 4 +- apps/sim/tools/gitlab/list_access_requests.ts | 2 +- apps/sim/tools/gitlab/list_branches.ts | 2 +- apps/sim/tools/gitlab/list_commits.ts | 5 +- apps/sim/tools/gitlab/list_invitations.ts | 2 +- apps/sim/tools/gitlab/list_issues.ts | 4 +- apps/sim/tools/gitlab/list_members.ts | 4 +- apps/sim/tools/gitlab/list_merge_requests.ts | 2 +- apps/sim/tools/gitlab/list_pipeline_jobs.ts | 2 +- apps/sim/tools/gitlab/list_pipelines.ts | 4 +- apps/sim/tools/gitlab/list_releases.ts | 2 +- apps/sim/tools/gitlab/list_repository_tree.ts | 2 +- .../sim/tools/gitlab/list_saml_group_links.ts | 2 +- apps/sim/tools/gitlab/merge_merge_request.ts | 9 +- apps/sim/tools/gitlab/play_job.ts | 2 +- apps/sim/tools/gitlab/remove_member.ts | 2 +- apps/sim/tools/gitlab/retry_pipeline.ts | 2 +- apps/sim/tools/gitlab/revoke_invitation.ts | 2 +- apps/sim/tools/gitlab/search_users.ts | 2 +- apps/sim/tools/gitlab/types.ts | 29 ++- apps/sim/tools/gitlab/update_file.ts | 2 +- apps/sim/tools/gitlab/update_invitation.ts | 7 +- apps/sim/tools/gitlab/update_issue.ts | 2 +- apps/sim/tools/gitlab/update_member.ts | 7 +- apps/sim/tools/gitlab/update_merge_request.ts | 15 +- apps/sim/tools/gitlab/update_user.ts | 7 +- 56 files changed, 483 insertions(+), 189 deletions(-) diff --git a/apps/docs/content/docs/en/integrations/gitlab.mdx b/apps/docs/content/docs/en/integrations/gitlab.mdx index c2058598754..f21260f73ae 100644 --- a/apps/docs/content/docs/en/integrations/gitlab.mdx +++ b/apps/docs/content/docs/en/integrations/gitlab.mdx @@ -67,7 +67,7 @@ Get details of a specific GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path \(e.g., "namespace/project"\) | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) \(e.g., "namespace/project"\) | #### Output @@ -84,13 +84,13 @@ List issues in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `state` | string | No | Filter by state \(opened, closed, all\) | | `labels` | string | No | Comma-separated list of label names | | `assigneeId` | number | No | Filter by assignee user ID | | `milestoneTitle` | string | No | Filter by milestone title | | `search` | string | No | Search issues by title and description | -| `orderBy` | string | No | Order by field \(created_at, updated_at, priority, due_date, relative_position, label_priority, milestone_due, popularity, title, weight\) | +| `orderBy` | string | No | Order by field \(created_at, updated_at, priority, due_date, relative_position, label_priority, milestone_due, popularity, weight\) | | `sort` | string | No | Sort direction \(asc, desc\) | | `perPage` | number | No | Number of results per page \(default 20, max 100\) | | `page` | number | No | Page number for pagination | @@ -111,7 +111,7 @@ Get details of a specific GitLab issue | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `issueIid` | number | Yes | Issue number within the project \(the # shown in GitLab UI\) | #### Output @@ -129,7 +129,7 @@ Create a new issue in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `title` | string | Yes | Issue title | | `description` | string | No | Issue description \(Markdown supported\) | | `labels` | string | No | Comma-separated list of label names | @@ -153,7 +153,7 @@ Update an existing issue in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `issueIid` | number | Yes | Issue internal ID \(IID\) | | `title` | string | No | New issue title | | `description` | string | No | New issue description \(Markdown supported\) | @@ -179,7 +179,7 @@ Delete an issue from a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `issueIid` | number | Yes | Issue internal ID \(IID\) | #### Output @@ -197,7 +197,7 @@ Add a comment to a GitLab issue | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `issueIid` | number | Yes | Issue internal ID \(IID\) | | `body` | string | Yes | Comment body \(Markdown supported\) | @@ -216,7 +216,7 @@ List merge requests in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `state` | string | No | Filter by state \(opened, closed, locked, merged, all\) | | `labels` | string | No | Comma-separated list of label names | | `sourceBranch` | string | No | Filter by source branch | @@ -242,7 +242,7 @@ Get details of a specific GitLab merge request | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `mergeRequestIid` | number | Yes | Merge request internal ID \(IID\) | #### Output @@ -260,7 +260,7 @@ Create a new merge request in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `sourceBranch` | string | Yes | Source branch name | | `targetBranch` | string | Yes | Target branch name | | `title` | string | Yes | Merge request title | @@ -270,7 +270,7 @@ Create a new merge request in a GitLab project | `milestoneId` | number | No | Milestone ID to assign | | `removeSourceBranch` | boolean | No | Delete source branch after merge | | `squash` | boolean | No | Squash commits on merge | -| `draft` | boolean | No | Mark as draft \(work in progress\) | +| `draft` | boolean | No | Mark as draft \(applied via the "Draft:" title prefix\) | #### Output @@ -287,7 +287,7 @@ Update an existing merge request in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `mergeRequestIid` | number | Yes | Merge request internal ID \(IID\) | | `title` | string | No | New merge request title | | `description` | string | No | New merge request description | @@ -298,7 +298,7 @@ Update an existing merge request in a GitLab project | `targetBranch` | string | No | New target branch | | `removeSourceBranch` | boolean | No | Delete source branch after merge | | `squash` | boolean | No | Squash commits on merge | -| `draft` | boolean | No | Mark as draft \(work in progress\) | +| `draft` | boolean | No | Mark as draft or remove draft status \(applied via the "Draft:" title prefix; requires title to be set\) | #### Output @@ -315,7 +315,7 @@ Merge a merge request in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `mergeRequestIid` | number | Yes | Merge request internal ID \(IID\) | | `mergeCommitMessage` | string | No | Custom merge commit message | | `squashCommitMessage` | string | No | Custom squash commit message | @@ -338,7 +338,7 @@ Add a comment to a GitLab merge request | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `mergeRequestIid` | number | Yes | Merge request internal ID \(IID\) | | `body` | string | Yes | Comment body \(Markdown supported\) | @@ -357,9 +357,9 @@ List pipelines in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `ref` | string | No | Filter by ref \(branch or tag\) | -| `status` | string | No | Filter by status \(created, waiting_for_resource, preparing, pending, running, success, failed, canceled, skipped, manual, scheduled\) | +| `status` | string | No | Filter by status \(created, waiting_for_resource, preparing, pending, running, success, failed, canceling, canceled, skipped, manual, scheduled, waiting_for_callback\) | | `orderBy` | string | No | Order by field \(id, status, ref, updated_at, user_id\) | | `sort` | string | No | Sort direction \(asc, desc\) | | `perPage` | number | No | Number of results per page \(default 20, max 100\) | @@ -381,7 +381,7 @@ Get details of a specific GitLab pipeline | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `pipelineId` | number | Yes | Pipeline ID | #### Output @@ -399,7 +399,7 @@ Trigger a new pipeline in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `ref` | string | Yes | Branch or tag to run the pipeline on | | `variables` | array | No | Array of variables for the pipeline \(each with key, value, and optional variable_type\) | @@ -418,7 +418,7 @@ Retry a failed GitLab pipeline | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `pipelineId` | number | Yes | Pipeline ID | #### Output @@ -436,7 +436,7 @@ Cancel a running GitLab pipeline | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `pipelineId` | number | Yes | Pipeline ID | #### Output @@ -454,7 +454,7 @@ List files and directories in a GitLab project repository | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `path` | string | No | Path inside the repository to list | | `ref` | string | No | Branch, tag, or commit SHA to list from | | `recursive` | boolean | No | Whether to list files recursively | @@ -477,7 +477,7 @@ Get the contents of a file from a GitLab project repository | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `filePath` | string | Yes | Path to the file in the repository | | `ref` | string | Yes | Branch, tag, or commit SHA | @@ -491,7 +491,8 @@ Get the contents of a file from a GitLab project repository | `ref` | string | The branch, tag, or commit SHA | | `blobId` | string | The blob ID | | `lastCommitId` | string | The last commit ID that modified the file | -| `content` | string | The decoded file content | +| `content` | string | The decoded file content, truncated to 1M characters | +| `truncated` | boolean | Whether the content was truncated | ### `gitlab_create_file` @@ -502,7 +503,7 @@ Create a new file in a GitLab project repository | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `filePath` | string | Yes | Path to the file in the repository | | `branch` | string | Yes | Branch to commit the new file to | | `content` | string | Yes | File content | @@ -524,7 +525,7 @@ Update an existing file in a GitLab project repository | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `filePath` | string | Yes | Path to the file in the repository | | `branch` | string | Yes | Branch to commit the update to | | `content` | string | Yes | New file content | @@ -547,7 +548,7 @@ Create a new branch in a GitLab project repository | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `branch` | string | Yes | Name of the new branch | | `ref` | string | Yes | Source branch/tag/SHA | @@ -569,7 +570,7 @@ Delete a branch from a GitLab project repository | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `branch` | string | Yes | Name of the branch to delete | #### Output @@ -587,7 +588,7 @@ Compare two branches, tags, or commits in a GitLab project repository | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `from` | string | Yes | Commit SHA or branch/tag name to compare from | | `to` | string | Yes | Commit SHA or branch/tag name to compare to | | `straight` | boolean | No | Compare directly from..to instead of using the merge base \(defaults to false\) | @@ -612,7 +613,7 @@ List branches in a GitLab project repository | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `search` | string | No | Filter branches by name | | `perPage` | number | No | Number of results per page \(default 20, max 100\) | | `page` | number | No | Page number for pagination | @@ -633,7 +634,7 @@ List commits in a GitLab project repository | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `refName` | string | No | Branch, tag, or revision range to list commits from | | `since` | string | No | Only commits after this ISO 8601 date | | `until` | string | No | Only commits before this ISO 8601 date | @@ -647,7 +648,7 @@ List commits in a GitLab project repository | Parameter | Type | Description | | --------- | ---- | ----------- | | `commits` | array | List of commits | -| `total` | number | Total number of commits | +| `total` | number | Number of commits returned on this page \(GitLab does not report a grand total for commits\) | ### `gitlab_get_merge_request_changes` @@ -658,7 +659,7 @@ Get the file changes (diffs) of a GitLab merge request | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `mergeRequestIid` | number | Yes | Merge request internal ID \(IID\) | #### Output @@ -667,7 +668,8 @@ Get the file changes (diffs) of a GitLab merge request | --------- | ---- | ----------- | | `mergeRequestIid` | number | The merge request internal ID \(IID\) | | `changes` | array | List of file changes \(diffs\) | -| `changesCount` | number | Number of changed files returned | +| `changesCount` | number | Number of changed files returned \(first 100\) | +| `hasMore` | boolean | Whether the merge request has more than 100 changed files \(results truncated\) | ### `gitlab_approve_merge_request` @@ -678,7 +680,7 @@ Approve a GitLab merge request | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `mergeRequestIid` | number | Yes | Merge request internal ID \(IID\) | | `sha` | string | No | HEAD SHA of the merge request to approve | @@ -699,7 +701,7 @@ List jobs for a GitLab pipeline | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `pipelineId` | number | Yes | Pipeline ID | | `scope` | string | No | Filter jobs by scope \(e.g. created, running, success, failed\) | | `includeRetried` | boolean | No | Whether to include retried jobs | @@ -722,14 +724,15 @@ Get the log (trace) of a GitLab job | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `jobId` | number | Yes | Job ID | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `log` | string | The job log \(trace\) output | +| `log` | string | The job log \(trace\) output, truncated to 200k characters | +| `truncated` | boolean | Whether the log was truncated | ### `gitlab_play_job` @@ -740,7 +743,7 @@ Trigger (play) a manual GitLab job | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `jobId` | number | Yes | Job ID | #### Output @@ -761,7 +764,7 @@ List releases in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `orderBy` | string | No | Order by field \(released_at, created_at\) | | `sort` | string | No | Sort direction \(asc, desc\) | | `perPage` | number | No | Number of results per page \(default 20, max 100\) | @@ -783,7 +786,7 @@ Create a new release in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `tagName` | string | Yes | The Git tag for the release | | `name` | string | No | The release name | | `description` | string | No | Release description/notes \(Markdown supported\) | @@ -807,9 +810,9 @@ List members of a GitLab project or group. Includes members inherited from ances | --------- | ---- | -------- | ----------- | | `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 | +| `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | | `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 | +| `query` | string | No | Filter members by name, email, or username | | `perPage` | number | No | Number of results per page \(default 20, max 100\) | | `page` | number | No | Page number for pagination | @@ -830,9 +833,9 @@ Add an existing GitLab user to a project or group at a given access level | --------- | ---- | -------- | ----------- | | `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 | +| `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | | `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\) | +| `accessLevel` | number | Yes | Access level: 10 \(Guest\), 15 \(Planner\), 20 \(Reporter\), 25 \(Security Manager\), 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\) | @@ -853,11 +856,11 @@ Update a member's access level in a GitLab project or group | --------- | ---- | -------- | ----------- | | `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 | +| `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | | `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\) | +| `accessLevel` | number | Yes | New access level: 0 \(No access\), 5 \(Minimal\), 10 \(Guest\), 15 \(Planner\), 20 \(Reporter\), 25 \(Security Manager\), 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\) | +| `memberRoleId` | number | No | Custom member role ID \(GitLab Ultimate only\). Warning: when omitted, GitLab removes any custom role the member currently holds. | #### Output @@ -875,7 +878,7 @@ Remove a member from a GitLab project or group | --------- | ---- | -------- | ----------- | | `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 | +| `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | | `userId` | number | Yes | The ID of the member to remove | #### Output @@ -894,9 +897,9 @@ Invite a person to a GitLab project or group by email address | --------- | ---- | -------- | ----------- | | `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 | +| `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | | `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\) | +| `accessLevel` | number | Yes | Access level: 10 \(Guest\), 15 \(Planner\), 20 \(Reporter\), 25 \(Security Manager\), 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\) | @@ -917,7 +920,7 @@ List pending email invitations for a GitLab project or group | --------- | ---- | -------- | ----------- | | `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 | +| `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | | `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 | @@ -939,10 +942,10 @@ Update a pending invitation to a GitLab project or group | --------- | ---- | -------- | ----------- | | `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 | +| `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | | `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 | +| `accessLevel` | number | No | New access level: 10 \(Guest\), 15 \(Planner\), 20 \(Reporter\), 25 \(Security Manager\), 30 \(Developer\), 40 \(Maintainer\), 50 \(Owner\) | +| `expiresAt` | string | No | Access expiration date \(ISO 8601, e.g. 2026-12-31T00:00:00Z; date-only also accepted\). At least one of accessLevel or expiresAt must be provided. | #### Output @@ -960,7 +963,7 @@ Revoke a pending email invitation to a GitLab project or group | --------- | ---- | -------- | ----------- | | `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 | +| `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | | `email` | string | Yes | Email address of the invitation to revoke | #### Output @@ -979,7 +982,7 @@ List pending access requests for a GitLab project or group | --------- | ---- | -------- | ----------- | | `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 | +| `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | | `perPage` | number | No | Number of results per page \(default 20, max 100\) | | `page` | number | No | Page number for pagination | @@ -1000,9 +1003,9 @@ Approve a pending access request for a GitLab project or group | --------- | ---- | -------- | ----------- | | `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 | +| `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | | `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\). | +| `accessLevel` | number | No | Access level to grant: 10 \(Guest\), 15 \(Planner\), 20 \(Reporter\), 25 \(Security Manager\), 30 \(Developer\), 40 \(Maintainer\), 50 \(Owner\). Defaults to 30 \(Developer\). | #### Output @@ -1020,7 +1023,7 @@ Deny (delete) a pending access request for a GitLab project or group | --------- | ---- | -------- | ----------- | | `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 | +| `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | | `userId` | number | Yes | The user ID of the access requester | #### Output @@ -1038,7 +1041,7 @@ List SAML group links for a GitLab group. Use this to detect whether a group is | 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 | +| `groupId` | string | Yes | Group ID or path \(e.g. my-org/my-group\) | #### Output @@ -1049,7 +1052,7 @@ List SAML group links for a GitLab group. Use this to detect whether a group is ### `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. +Search for GitLab users by name, username, or email. Email matches must be exact; private emails match only with an admin token. Use this to resolve an email to a user ID before adding a member. #### Input @@ -1081,6 +1084,7 @@ Create a new GitLab user. Requires an administrator token with admin_mode on the | `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 | +| `forceRandomPassword` | boolean | No | Set a random password without emailing a reset link \(useful for SSO-only accounts\). One of password, resetPassword, or forceRandomPassword is required. | | `admin` | boolean | No | Whether the new user is an administrator | | `skipConfirmation` | boolean | No | Skip email confirmation for the new user | @@ -1100,7 +1104,7 @@ Modify an existing GitLab user. Requires an administrator token with admin_mode | --------- | ---- | -------- | ----------- | | `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 | +| `email` | string | No | The user's new email address \(GitLab only allows changing to one of the user's existing verified secondary emails\) | | `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 | @@ -1121,7 +1125,7 @@ Delete a GitLab user. Requires an administrator token with admin_mode on the ins | --------- | ---- | -------- | ----------- | | `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 | +| `hardDelete` | boolean | No | When true, contributions, personal projects, AND groups owned solely by this user are deleted rather than moved to a Ghost User | #### Output @@ -1166,7 +1170,8 @@ Delete a GitLab user. Requires an administrator token with admin_mode on the ins | `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 | +| `changesCount` | number | Number of changed files returned \(first 100\) | +| `hasMore` | boolean | Whether more changed files exist beyond the first 100 | | `approvalsRequired` | number | Approvals required | | `approvalsLeft` | number | Approvals remaining | | `approvedBy` | json | List of approvers | @@ -1192,6 +1197,7 @@ Delete a GitLab user. Requires an administrator token with admin_mode on the ins | `users` | json | List of matching users | | `user` | json | User details | | `total` | number | Total number of items available across all pages | +| `truncated` | boolean | Whether returned content \(file content or job log\) was truncated | | `success` | boolean | Operation success status | ### `gitlab_unblock_user` @@ -1231,7 +1237,8 @@ Delete a GitLab user. Requires an administrator token with admin_mode on the ins | `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 | +| `changesCount` | number | Number of changed files returned \(first 100\) | +| `hasMore` | boolean | Whether more changed files exist beyond the first 100 | | `approvalsRequired` | number | Approvals required | | `approvalsLeft` | number | Approvals remaining | | `approvedBy` | json | List of approvers | @@ -1257,6 +1264,7 @@ Delete a GitLab user. Requires an administrator token with admin_mode on the ins | `users` | json | List of matching users | | `user` | json | User details | | `total` | number | Total number of items available across all pages | +| `truncated` | boolean | Whether returned content \(file content or job log\) was truncated | | `success` | boolean | Operation success status | ### `gitlab_deactivate_user` @@ -1296,7 +1304,8 @@ Delete a GitLab user. Requires an administrator token with admin_mode on the ins | `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 | +| `changesCount` | number | Number of changed files returned \(first 100\) | +| `hasMore` | boolean | Whether more changed files exist beyond the first 100 | | `approvalsRequired` | number | Approvals required | | `approvalsLeft` | number | Approvals remaining | | `approvedBy` | json | List of approvers | @@ -1322,6 +1331,7 @@ Delete a GitLab user. Requires an administrator token with admin_mode on the ins | `users` | json | List of matching users | | `user` | json | User details | | `total` | number | Total number of items available across all pages | +| `truncated` | boolean | Whether returned content \(file content or job log\) was truncated | | `success` | boolean | Operation success status | ### `gitlab_activate_user` @@ -1361,7 +1371,8 @@ Delete a GitLab user. Requires an administrator token with admin_mode on the ins | `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 | +| `changesCount` | number | Number of changed files returned \(first 100\) | +| `hasMore` | boolean | Whether more changed files exist beyond the first 100 | | `approvalsRequired` | number | Approvals required | | `approvalsLeft` | number | Approvals remaining | | `approvedBy` | json | List of approvers | @@ -1387,6 +1398,7 @@ Delete a GitLab user. Requires an administrator token with admin_mode on the ins | `users` | json | List of matching users | | `user` | json | User details | | `total` | number | Total number of items available across all pages | +| `truncated` | boolean | Whether returned content \(file content or job log\) was truncated | | `success` | boolean | Operation success status | ### `gitlab_ban_user` @@ -1426,7 +1438,8 @@ Delete a GitLab user. Requires an administrator token with admin_mode on the ins | `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 | +| `changesCount` | number | Number of changed files returned \(first 100\) | +| `hasMore` | boolean | Whether more changed files exist beyond the first 100 | | `approvalsRequired` | number | Approvals required | | `approvalsLeft` | number | Approvals remaining | | `approvedBy` | json | List of approvers | @@ -1452,6 +1465,7 @@ Delete a GitLab user. Requires an administrator token with admin_mode on the ins | `users` | json | List of matching users | | `user` | json | User details | | `total` | number | Total number of items available across all pages | +| `truncated` | boolean | Whether returned content \(file content or job log\) was truncated | | `success` | boolean | Operation success status | ### `gitlab_unban_user` @@ -1491,7 +1505,8 @@ Delete a GitLab user. Requires an administrator token with admin_mode on the ins | `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 | +| `changesCount` | number | Number of changed files returned \(first 100\) | +| `hasMore` | boolean | Whether more changed files exist beyond the first 100 | | `approvalsRequired` | number | Approvals required | | `approvalsLeft` | number | Approvals remaining | | `approvedBy` | json | List of approvers | @@ -1517,6 +1532,7 @@ Delete a GitLab user. Requires an administrator token with admin_mode on the ins | `users` | json | List of matching users | | `user` | json | User details | | `total` | number | Total number of items available across all pages | +| `truncated` | boolean | Whether returned content \(file content or job log\) was truncated | | `success` | boolean | Operation success status | ### `gitlab_approve_user` @@ -1556,7 +1572,8 @@ Delete a GitLab user. Requires an administrator token with admin_mode on the ins | `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 | +| `changesCount` | number | Number of changed files returned \(first 100\) | +| `hasMore` | boolean | Whether more changed files exist beyond the first 100 | | `approvalsRequired` | number | Approvals required | | `approvalsLeft` | number | Approvals remaining | | `approvedBy` | json | List of approvers | @@ -1582,6 +1599,7 @@ Delete a GitLab user. Requires an administrator token with admin_mode on the ins | `users` | json | List of matching users | | `user` | json | User details | | `total` | number | Total number of items available across all pages | +| `truncated` | boolean | Whether returned content \(file content or job log\) was truncated | | `success` | boolean | Operation success status | ### `gitlab_reject_user` @@ -1621,7 +1639,8 @@ Delete a GitLab user. Requires an administrator token with admin_mode on the ins | `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 | +| `changesCount` | number | Number of changed files returned \(first 100\) | +| `hasMore` | boolean | Whether more changed files exist beyond the first 100 | | `approvalsRequired` | number | Approvals required | | `approvalsLeft` | number | Approvals remaining | | `approvedBy` | json | List of approvers | @@ -1647,6 +1666,7 @@ Delete a GitLab user. Requires an administrator token with admin_mode on the ins | `users` | json | List of matching users | | `user` | json | User details | | `total` | number | Total number of items available across all pages | +| `truncated` | boolean | Whether returned content \(file content or job log\) was truncated | | `success` | boolean | Operation success status | ### `gitlab_delete_user_identity` @@ -1669,18 +1689,18 @@ Delete a user's authentication identity (e.g. SAML or LDAP). Requires an adminis ### `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 +Add a SAML group link that maps an identity-provider group to a GitLab group at a given access level (GitLab Premium/Ultimate) #### 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 | +| `groupId` | string | Yes | Group ID or path \(e.g. my-org/my-group\) | | `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\) | +| `accessLevel` | number | Yes | Access level granted to members of the SAML group: 10 \(Guest\), 15 \(Planner\), 20 \(Reporter\), 25 \(Security Manager\), 30 \(Developer\), 40 \(Maintainer\), 50 \(Owner\) | | `memberRoleId` | number | No | Custom member role ID \(GitLab Ultimate only\) | -| `provider` | string | No | Unique provider name that must match for this group link to be applied | +| `provider` | string | No | Unique provider name that must match for this group link to be applied \(GitLab 18.2+\) | #### Output @@ -1690,14 +1710,14 @@ Add a SAML group link that maps an identity-provider group to a GitLab group at ### `gitlab_delete_saml_group_link` -Delete a SAML group link from a GitLab group +Delete a SAML group link from a GitLab group (GitLab Premium/Ultimate) #### 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 | +| `groupId` | string | Yes | Group ID or path \(e.g. my-org/my-group\) | | `samlGroupName` | string | Yes | The name of the SAML group link to delete | | `provider` | string | No | Provider name of the link to delete. Required when multiple links share the same SAML group name. | diff --git a/apps/sim/blocks/blocks/gitlab.ts b/apps/sim/blocks/blocks/gitlab.ts index 2c14c531a89..16fb9e025e1 100644 --- a/apps/sim/blocks/blocks/gitlab.ts +++ b/apps/sim/blocks/blocks/gitlab.ts @@ -413,6 +413,8 @@ Return ONLY the comment text - no explanations, no extra formatting.`, 'gitlab_get_file', 'gitlab_create_branch', 'gitlab_create_release', + 'gitlab_list_repository_tree', + 'gitlab_list_pipelines', ], }, }, @@ -576,11 +578,11 @@ Return ONLY the timestamp string - no explanations, no extra text.`, id: 'path', title: 'Path', type: 'short-input', - placeholder: 'Subdirectory path (optional)', + placeholder: 'File or subdirectory path filter (optional)', mode: 'advanced', condition: { field: 'operation', - value: ['gitlab_list_repository_tree'], + value: ['gitlab_list_repository_tree', 'gitlab_list_commits'], }, }, // Recursive tree listing @@ -606,6 +608,80 @@ Return ONLY the timestamp string - no explanations, no extra text.`, value: ['gitlab_list_commits'], }, }, + // Commit time range filters + { + id: 'since', + title: 'Since', + type: 'short-input', + placeholder: 'Only commits after this ISO 8601 date (optional)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_commits'], + }, + wandConfig: { + enabled: true, + prompt: `Generate an ISO 8601 timestamp based on the user's description of the earliest commit date. + +Return ONLY the timestamp string - no explanations, no extra text.`, + generationType: 'timestamp', + placeholder: 'Describe the start of the time range...', + }, + }, + { + id: 'until', + title: 'Until', + type: 'short-input', + placeholder: 'Only commits before this ISO 8601 date (optional)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_commits'], + }, + wandConfig: { + enabled: true, + prompt: `Generate an ISO 8601 timestamp based on the user's description of the latest commit date. + +Return ONLY the timestamp string - no explanations, no extra text.`, + generationType: 'timestamp', + placeholder: 'Describe the end of the time range...', + }, + }, + // Commit author filter + { + id: 'author', + title: 'Author', + type: 'short-input', + placeholder: 'Filter commits by author name or email (optional)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_commits'], + }, + }, + // Optimistic-locking guard (update file) + { + id: 'lastCommitId', + title: 'Last Commit ID', + type: 'short-input', + placeholder: 'Fail if the file changed since this commit SHA (optional)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_update_file'], + }, + }, + // Include retried jobs (list pipeline jobs) + { + id: 'includeRetried', + title: 'Include Retried Jobs', + type: 'switch', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_pipeline_jobs'], + }, + }, // Job scope filter (for list pipeline jobs) { id: 'scope', @@ -613,12 +689,18 @@ Return ONLY the timestamp string - no explanations, no extra text.`, type: 'dropdown', options: [ { label: 'All', id: '' }, - { label: 'Failed', id: 'failed' }, - { label: 'Success', id: 'success' }, - { label: 'Running', id: 'running' }, + { label: 'Created', id: 'created' }, + { label: 'Waiting for resource', id: 'waiting_for_resource' }, + { label: 'Preparing', id: 'preparing' }, { label: 'Pending', id: 'pending' }, + { label: 'Running', id: 'running' }, + { label: 'Success', id: 'success' }, + { label: 'Failed', id: 'failed' }, + { label: 'Canceling', id: 'canceling' }, { label: 'Canceled', id: 'canceled' }, + { label: 'Skipped', id: 'skipped' }, { label: 'Manual', id: 'manual' }, + { label: 'Scheduled', id: 'scheduled' }, ], value: () => '', mode: 'advanced', @@ -684,7 +766,12 @@ Return ONLY the timestamp string - no explanations, no extra text.`, mode: 'advanced', condition: { field: 'operation', - value: ['gitlab_create_issue', 'gitlab_update_issue'], + value: [ + 'gitlab_create_issue', + 'gitlab_update_issue', + 'gitlab_create_merge_request', + 'gitlab_update_merge_request', + ], }, }, // State filter for issues @@ -746,12 +833,18 @@ Return ONLY the timestamp string - no explanations, no extra text.`, type: 'dropdown', options: [ { label: 'All', id: '' }, - { label: 'Running', id: 'running' }, + { label: 'Created', id: 'created' }, + { label: 'Waiting for resource', id: 'waiting_for_resource' }, + { label: 'Preparing', id: 'preparing' }, { label: 'Pending', id: 'pending' }, + { label: 'Running', id: 'running' }, { label: 'Success', id: 'success' }, { label: 'Failed', id: 'failed' }, + { label: 'Canceling', id: 'canceling' }, { label: 'Canceled', id: 'canceled' }, { label: 'Skipped', id: 'skipped' }, + { label: 'Manual', id: 'manual' }, + { label: 'Scheduled', id: 'scheduled' }, ], value: () => '', mode: 'advanced', @@ -768,7 +861,11 @@ Return ONLY the timestamp string - no explanations, no extra text.`, mode: 'advanced', condition: { field: 'operation', - value: ['gitlab_create_merge_request', 'gitlab_merge_merge_request'], + value: [ + 'gitlab_create_merge_request', + 'gitlab_update_merge_request', + 'gitlab_merge_merge_request', + ], }, }, // Squash commits @@ -779,7 +876,11 @@ Return ONLY the timestamp string - no explanations, no extra text.`, mode: 'advanced', condition: { field: 'operation', - value: ['gitlab_merge_merge_request'], + value: [ + 'gitlab_create_merge_request', + 'gitlab_update_merge_request', + 'gitlab_merge_merge_request', + ], }, }, // Merge commit message @@ -996,11 +1097,12 @@ Return ONLY the commit message - no explanations, no extra text.`, id: 'query', title: 'Filter', type: 'short-input', - placeholder: 'Filter members by name/username, or invitations by email', + placeholder: + 'Filter members by name/username, invitations by exact email, or branches by name', mode: 'advanced', condition: { field: 'operation', - value: ['gitlab_list_members', 'gitlab_list_invitations'], + value: ['gitlab_list_members', 'gitlab_list_invitations', 'gitlab_list_branches'], }, }, // Direct members only toggle (list members) @@ -1070,7 +1172,7 @@ Return ONLY the commit message - no explanations, no extra text.`, type: 'switch', mode: 'advanced', description: - 'Delete contributions and personal projects instead of moving them to a Ghost User', + 'Delete contributions, personal projects, and solely-owned groups instead of moving them to a Ghost User', condition: { field: 'operation', value: ['gitlab_delete_user'], @@ -1141,6 +1243,17 @@ Return ONLY the commit message - no explanations, no extra text.`, value: ['gitlab_create_user'], }, }, + { + id: 'forceRandomPassword', + title: 'Force Random Password', + type: 'switch', + mode: 'advanced', + description: 'Set a random password without emailing a reset link (for SSO-only accounts)', + condition: { + field: 'operation', + value: ['gitlab_create_user'], + }, + }, { id: 'skipConfirmation', title: 'Skip Email Confirmation', @@ -1314,6 +1427,7 @@ Return ONLY the commit message - no explanations, no extra text.`, assigneeIds: params.assigneeIds ? params.assigneeIds.split(',').map((id: string) => Number(id.trim())) : undefined, + milestoneId: params.milestoneId ? Number(params.milestoneId) : undefined, stateEvent: params.stateEvent || undefined, } @@ -1381,7 +1495,9 @@ Return ONLY the commit message - no explanations, no extra text.`, assigneeIds: params.assigneeIds ? params.assigneeIds.split(',').map((id: string) => Number(id.trim())) : undefined, + milestoneId: params.milestoneId ? Number(params.milestoneId) : undefined, removeSourceBranch: params.removeSourceBranch || undefined, + squash: params.squash || undefined, } case 'gitlab_update_merge_request': @@ -1398,7 +1514,10 @@ Return ONLY the commit message - no explanations, no extra text.`, assigneeIds: params.assigneeIds ? params.assigneeIds.split(',').map((id: string) => Number(id.trim())) : undefined, + milestoneId: params.milestoneId ? Number(params.milestoneId) : undefined, stateEvent: params.stateEvent || undefined, + removeSourceBranch: params.removeSourceBranch || undefined, + squash: params.squash || undefined, } case 'gitlab_merge_merge_request': @@ -1433,6 +1552,7 @@ Return ONLY the commit message - no explanations, no extra text.`, ...baseParams, projectId: params.projectId.trim(), status: params.pipelineStatus || undefined, + ref: params.ref?.trim() || undefined, perPage: params.perPage ? Number(params.perPage) : undefined, page: params.page ? Number(params.page) : undefined, } @@ -1476,6 +1596,7 @@ Return ONLY the commit message - no explanations, no extra text.`, ...baseParams, projectId: params.projectId.trim(), path: params.path?.trim() || undefined, + ref: params.ref?.trim() || undefined, recursive: params.recursive || undefined, perPage: params.perPage ? Number(params.perPage) : undefined, page: params.page ? Number(params.page) : undefined, @@ -1512,6 +1633,9 @@ Return ONLY the commit message - no explanations, no extra text.`, branch: params.branch.trim(), content: params.content, commitMessage: params.commitMessage.trim(), + ...(params.operation === 'gitlab_update_file' + ? { lastCommitId: params.lastCommitId?.trim() || undefined } + : {}), } case 'gitlab_create_branch': @@ -1532,6 +1656,7 @@ Return ONLY the commit message - no explanations, no extra text.`, return { ...baseParams, projectId: params.projectId.trim(), + search: params.query?.trim() || undefined, perPage: params.perPage ? Number(params.perPage) : undefined, page: params.page ? Number(params.page) : undefined, } @@ -1570,6 +1695,10 @@ Return ONLY the commit message - no explanations, no extra text.`, ...baseParams, projectId: params.projectId.trim(), refName: params.refName?.trim() || undefined, + since: params.since?.trim() || undefined, + until: params.until?.trim() || undefined, + path: params.path?.trim() || undefined, + author: params.author?.trim() || undefined, perPage: params.perPage ? Number(params.perPage) : undefined, page: params.page ? Number(params.page) : undefined, } @@ -1604,6 +1733,7 @@ Return ONLY the commit message - no explanations, no extra text.`, projectId: params.projectId.trim(), pipelineId: Number(params.pipelineId), scope: params.scope || undefined, + includeRetried: params.includeRetried || undefined, perPage: params.perPage ? Number(params.perPage) : undefined, page: params.page ? Number(params.page) : undefined, } @@ -1743,6 +1873,9 @@ Return ONLY the commit message - no explanations, no extra text.`, if (!params.resourceId?.trim() || !params.email?.trim()) { throw new Error('Project / Group ID and Email are required.') } + if (!params.invitationAccessLevel && !params.expiresAt?.trim()) { + throw new Error('At least one of Access Level or Expires At is required.') + } return { ...baseParams, resourceType: params.resourceType || 'project', @@ -1854,6 +1987,15 @@ Return ONLY the commit message - no explanations, no extra text.`, ) { throw new Error('Email, Username, and Full Name are required.') } + if ( + !params.userAdminPassword?.trim() && + !params.resetPassword && + !params.forceRandomPassword + ) { + throw new Error( + 'One of Password, Send Password Reset Link, or Force Random Password is required.' + ) + } return { ...baseParams, email: params.userAdminEmail.trim(), @@ -1861,6 +2003,7 @@ Return ONLY the commit message - no explanations, no extra text.`, name: params.userAdminName.trim(), password: params.userAdminPassword?.trim() || undefined, resetPassword: params.resetPassword || undefined, + forceRandomPassword: params.forceRandomPassword || undefined, admin: params.userAdminIsAdmin || undefined, skipConfirmation: params.skipConfirmation || undefined, } @@ -1875,10 +2018,11 @@ 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, - // 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, + // Only pass a real boolean: an explicit `false` demotes the user, + // while an untouched switch serializes as `null` and must be + // dropped so an unrelated update never touches the admin flag. + admin: + typeof params.userAdminIsAdmin === 'boolean' ? params.userAdminIsAdmin : undefined, } case 'gitlab_delete_user': @@ -1936,7 +2080,11 @@ Return ONLY the commit message - no explanations, no extra text.`, body: { type: 'string', description: 'Comment body' }, sourceBranch: { type: 'string', description: 'Source branch for merge request' }, targetBranch: { type: 'string', description: 'Target branch for merge request' }, - ref: { type: 'string', description: 'Branch or tag reference for pipeline' }, + ref: { + type: 'string', + description: + 'Branch, tag, or commit reference (pipelines, files, branches, releases, listings)', + }, labels: { type: 'string', description: 'Labels (comma-separated)' }, assigneeIds: { type: 'string', description: 'Assignee user IDs (comma-separated)' }, milestoneId: { type: 'number', description: 'Milestone ID' }, @@ -1956,11 +2104,19 @@ Return ONLY the commit message - no explanations, no extra text.`, branch: { type: 'string', description: 'Branch name' }, content: { type: 'string', description: 'File content' }, commitMessage: { type: 'string', description: 'Commit message' }, + lastCommitId: { + type: 'string', + description: 'Optimistic-locking commit SHA for file updates', + }, jobId: { type: 'number', description: 'Job ID' }, - path: { type: 'string', description: 'Subdirectory path for repository tree' }, + path: { type: 'string', description: 'File or subdirectory path filter' }, recursive: { type: 'boolean', description: 'Recursively list repository tree' }, refName: { type: 'string', description: 'Branch or tag name filter' }, + since: { type: 'string', description: 'Only commits after this ISO 8601 date' }, + until: { type: 'string', description: 'Only commits before this ISO 8601 date' }, + author: { type: 'string', description: 'Filter commits by author name or email' }, scope: { type: 'string', description: 'Job scope filter' }, + includeRetried: { type: 'boolean', description: 'Include retried jobs in the listing' }, sha: { type: 'string', description: 'Commit SHA' }, compareFrom: { type: 'string', description: 'Branch, tag, or commit SHA to compare from' }, compareTo: { type: 'string', description: 'Branch, tag, or commit SHA to compare to' }, @@ -1984,7 +2140,7 @@ Return ONLY the commit message - no explanations, no extra text.`, directMembersOnly: { type: 'boolean', description: 'Exclude inherited members' }, query: { type: 'string', - description: 'Filter members by name/username, or invitations by email', + description: 'Filter members by name/username, invitations by email, or branches by name', }, userSearch: { type: 'string', description: 'User search query' }, samlGroupName: { type: 'string', description: 'SAML group name' }, @@ -1999,6 +2155,10 @@ Return ONLY the commit message - no explanations, no extra text.`, 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)' }, + forceRandomPassword: { + type: 'boolean', + description: 'Set a random password without emailing a reset link (create user)', + }, skipConfirmation: { type: 'boolean', description: 'Skip email confirmation (create user)' }, userAdminIsAdmin: { type: 'boolean', description: 'Whether the user is an administrator' }, }, @@ -2036,7 +2196,11 @@ Return ONLY the commit message - no explanations, no extra text.`, webUrl: { type: 'string', description: 'Web URL' }, // Merge request change outputs changes: { type: 'json', description: 'Merge request file changes/diffs' }, - changesCount: { type: 'number', description: 'Number of changed files returned' }, + changesCount: { type: 'number', description: 'Number of changed files returned (first 100)' }, + hasMore: { + type: 'boolean', + description: 'Whether more changed files exist beyond the first 100', + }, approvalsRequired: { type: 'number', description: 'Approvals required' }, approvalsLeft: { type: 'number', description: 'Approvals remaining' }, approvedBy: { type: 'json', description: 'List of approvers' }, @@ -2068,6 +2232,10 @@ Return ONLY the commit message - no explanations, no extra text.`, user: { type: 'json', description: 'User details' }, // Pagination total: { type: 'number', description: 'Total number of items available across all pages' }, + truncated: { + type: 'boolean', + description: 'Whether returned content (file content or job log) was truncated', + }, // Success indicator success: { type: 'boolean', description: 'Operation success status' }, }, diff --git a/apps/sim/tools/gitlab/access.test.ts b/apps/sim/tools/gitlab/access.test.ts index fa6eb4b2441..65820f6f152 100644 --- a/apps/sim/tools/gitlab/access.test.ts +++ b/apps/sim/tools/gitlab/access.test.ts @@ -79,7 +79,12 @@ describe('gitlab_add_member', () => { 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' } }), + mockResponse({ + ok: false, + status: 409, + json: { message: 'Member already exists' }, + text: '{"message":"Member already exists"}', + }), {} as never ) expect(result.success).toBe(true) @@ -96,6 +101,14 @@ describe('gitlab_add_member', () => { expect(result.output.member).toEqual({ id: 7, access_level: 30 }) }) + it('surfaces a 409 that is not an already-member conflict as a failure', async () => { + const result = await gitlabAddMemberTool.transformResponse!( + mockResponse({ ok: false, status: 409, text: '{"message":"Seat limit reached"}' }), + {} as never + ) + expect(result.success).toBe(false) + }) + it('surfaces other errors as hard failures', async () => { const result = await gitlabAddMemberTool.transformResponse!( mockResponse({ ok: false, status: 403, text: 'Forbidden' }), diff --git a/apps/sim/tools/gitlab/add_member.ts b/apps/sim/tools/gitlab/add_member.ts index afe849ff49d..a52c549104b 100644 --- a/apps/sim/tools/gitlab/add_member.ts +++ b/apps/sim/tools/gitlab/add_member.ts @@ -31,7 +31,7 @@ export const gitlabAddMemberTool: ToolConfig { - // A 409 means the user is already a member. Treat it as a soft success so - // provisioning workflows remain safely re-runnable. + // A 409 with "already exists" means the user is already a member. Treat it + // as a soft success so provisioning workflows remain safely re-runnable — + // but only for that specific conflict, so other 409s still surface. if (response.status === 409) { + const conflictText = await response.text() + if (/already exists|already a member/i.test(conflictText)) { + return { + success: true, + output: { + alreadyMember: true, + }, + } + } return { - success: true, - output: { - alreadyMember: true, - }, + success: false, + error: `GitLab API error: 409 ${conflictText}`, + output: {}, } } diff --git a/apps/sim/tools/gitlab/add_saml_group_link.ts b/apps/sim/tools/gitlab/add_saml_group_link.ts index b0fc85894e8..17b833853c7 100644 --- a/apps/sim/tools/gitlab/add_saml_group_link.ts +++ b/apps/sim/tools/gitlab/add_saml_group_link.ts @@ -12,7 +12,7 @@ export const gitlabAddSamlGroupLinkTool: ToolConfig< 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', + 'Add a SAML group link that maps an identity-provider group to a GitLab group at a given access level (GitLab Premium/Ultimate)', version: '1.0.0', params: { @@ -32,7 +32,7 @@ export const gitlabAddSamlGroupLinkTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: 'Group ID or URL-encoded path', + description: 'Group ID or path (e.g. my-org/my-group)', }, samlGroupName: { type: 'string', @@ -45,7 +45,7 @@ export const gitlabAddSamlGroupLinkTool: ToolConfig< 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)', + 'Access level granted to members of the SAML group: 10 (Guest), 15 (Planner), 20 (Reporter), 25 (Security Manager), 30 (Developer), 40 (Maintainer), 50 (Owner)', }, memberRoleId: { type: 'number', @@ -57,7 +57,8 @@ export const gitlabAddSamlGroupLinkTool: ToolConfig< type: 'string', required: false, visibility: 'user-or-llm', - description: 'Unique provider name that must match for this group link to be applied', + description: + 'Unique provider name that must match for this group link to be applied (GitLab 18.2+)', }, }, diff --git a/apps/sim/tools/gitlab/approve_access_request.ts b/apps/sim/tools/gitlab/approve_access_request.ts index 4c307b91579..8bdfeb3c3b6 100644 --- a/apps/sim/tools/gitlab/approve_access_request.ts +++ b/apps/sim/tools/gitlab/approve_access_request.ts @@ -37,7 +37,7 @@ export const gitlabApproveAccessRequestTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: 'Project or group ID or URL-encoded path', + description: 'Project or group ID or path (e.g. mygroup/myproject)', }, userId: { type: 'number', @@ -50,7 +50,7 @@ export const gitlabApproveAccessRequestTool: ToolConfig< 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).', + 'Access level to grant: 10 (Guest), 15 (Planner), 20 (Reporter), 25 (Security Manager), 30 (Developer), 40 (Maintainer), 50 (Owner). Defaults to 30 (Developer).', }, }, diff --git a/apps/sim/tools/gitlab/approve_merge_request.ts b/apps/sim/tools/gitlab/approve_merge_request.ts index 0db63c53539..c818b130e27 100644 --- a/apps/sim/tools/gitlab/approve_merge_request.ts +++ b/apps/sim/tools/gitlab/approve_merge_request.ts @@ -31,7 +31,7 @@ export const gitlabApproveMergeRequestTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: 'Project ID or URL-encoded path', + description: 'Project ID or path (e.g. mygroup/myproject)', }, mergeRequestIid: { type: 'number', diff --git a/apps/sim/tools/gitlab/cancel_pipeline.ts b/apps/sim/tools/gitlab/cancel_pipeline.ts index b16ef0534e9..005a0538ed0 100644 --- a/apps/sim/tools/gitlab/cancel_pipeline.ts +++ b/apps/sim/tools/gitlab/cancel_pipeline.ts @@ -28,7 +28,7 @@ export const gitlabCancelPipelineTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: 'Project ID or URL-encoded path', + description: 'Project ID or path (e.g. mygroup/myproject)', }, pipelineId: { type: 'number', diff --git a/apps/sim/tools/gitlab/compare_branches.ts b/apps/sim/tools/gitlab/compare_branches.ts index ae6df3deedd..a854f633427 100644 --- a/apps/sim/tools/gitlab/compare_branches.ts +++ b/apps/sim/tools/gitlab/compare_branches.ts @@ -31,7 +31,7 @@ export const gitlabCompareBranchesTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: 'Project ID or URL-encoded path', + description: 'Project ID or path (e.g. mygroup/myproject)', }, from: { type: 'string', diff --git a/apps/sim/tools/gitlab/create_branch.ts b/apps/sim/tools/gitlab/create_branch.ts index 8fe6a8697c2..69e3b6dfe24 100644 --- a/apps/sim/tools/gitlab/create_branch.ts +++ b/apps/sim/tools/gitlab/create_branch.ts @@ -28,7 +28,7 @@ export const gitlabCreateBranchTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: 'Project ID or URL-encoded path', + description: 'Project ID or path (e.g. mygroup/myproject)', }, branch: { type: 'string', diff --git a/apps/sim/tools/gitlab/create_file.ts b/apps/sim/tools/gitlab/create_file.ts index f0468959b4a..3bdb74e9674 100644 --- a/apps/sim/tools/gitlab/create_file.ts +++ b/apps/sim/tools/gitlab/create_file.ts @@ -25,7 +25,7 @@ export const gitlabCreateFileTool: ToolConfig { + // GitLab has no `draft` body param — draft status is conveyed via a + // "Draft:" title prefix, so apply the prefix when requested. + const title = + params.draft === true && !/^\s*draft:/i.test(params.title) + ? `Draft: ${params.title}` + : params.title const body: Record = { source_branch: params.sourceBranch, target_branch: params.targetBranch, - title: params.title, + title, } if (params.description) body.description = params.description @@ -120,7 +126,6 @@ export const gitlabCreateMergeRequestTool: ToolConfig< if (params.removeSourceBranch !== undefined) body.remove_source_branch = params.removeSourceBranch if (params.squash !== undefined) body.squash = params.squash - if (params.draft !== undefined) body.draft = params.draft return body }, diff --git a/apps/sim/tools/gitlab/create_merge_request_note.ts b/apps/sim/tools/gitlab/create_merge_request_note.ts index 7f364efba51..7491c8f2d64 100644 --- a/apps/sim/tools/gitlab/create_merge_request_note.ts +++ b/apps/sim/tools/gitlab/create_merge_request_note.ts @@ -31,7 +31,7 @@ export const gitlabCreateMergeRequestNoteTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: 'Project ID or URL-encoded path', + description: 'Project ID or path (e.g. mygroup/myproject)', }, mergeRequestIid: { type: 'number', diff --git a/apps/sim/tools/gitlab/create_pipeline.ts b/apps/sim/tools/gitlab/create_pipeline.ts index 6a9777ea53b..dff4a546c66 100644 --- a/apps/sim/tools/gitlab/create_pipeline.ts +++ b/apps/sim/tools/gitlab/create_pipeline.ts @@ -28,7 +28,7 @@ export const gitlabCreatePipelineTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: 'Project ID or URL-encoded path', + description: 'Project ID or path (e.g. mygroup/myproject)', }, ref: { type: 'string', diff --git a/apps/sim/tools/gitlab/create_release.ts b/apps/sim/tools/gitlab/create_release.ts index 38d15f27e86..745709c876a 100644 --- a/apps/sim/tools/gitlab/create_release.ts +++ b/apps/sim/tools/gitlab/create_release.ts @@ -28,7 +28,7 @@ export const gitlabCreateReleaseTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: 'Project ID or URL-encoded path', + description: 'Project ID or path (e.g. mygroup/myproject)', }, tagName: { type: 'string', diff --git a/apps/sim/tools/gitlab/create_user.ts b/apps/sim/tools/gitlab/create_user.ts index 694a049e218..da6de99992b 100644 --- a/apps/sim/tools/gitlab/create_user.ts +++ b/apps/sim/tools/gitlab/create_user.ts @@ -52,6 +52,13 @@ export const gitlabCreateUserTool: ToolConfig = { id: 'gitlab_delete_saml_group_link', name: 'GitLab Delete SAML Group Link', - description: 'Delete a SAML group link from a GitLab group', + description: 'Delete a SAML group link from a GitLab group (GitLab Premium/Ultimate)', version: '1.0.0', params: { @@ -31,7 +31,7 @@ export const gitlabDeleteSamlGroupLinkTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: 'Group ID or URL-encoded path', + description: 'Group ID or path (e.g. my-org/my-group)', }, samlGroupName: { type: 'string', diff --git a/apps/sim/tools/gitlab/delete_user.ts b/apps/sim/tools/gitlab/delete_user.ts index acb9d29d40d..7f7ee452189 100644 --- a/apps/sim/tools/gitlab/delete_user.ts +++ b/apps/sim/tools/gitlab/delete_user.ts @@ -33,7 +33,7 @@ export const gitlabDeleteUserTool: ToolConfig maxContentChars return { success: true, @@ -76,7 +80,8 @@ export const gitlabGetFileTool: ToolConfig = { id: 'gitlab_get_job_log', name: 'GitLab Get Job Log', @@ -25,7 +33,7 @@ export const gitlabGetJobLogTool: ToolConfig MAX_LOG_CHARS, }, } }, @@ -69,7 +78,11 @@ export const gitlabGetJobLogTool: ToolConfig { const body: Record = {} - if (params.title) body.title = params.title + // GitLab has no `draft` body param — draft status is conveyed via the + // "Draft:" title prefix, so it can only be changed when a title is sent. + if (params.title) { + let title = params.title + if (params.draft === true && !/^\s*draft:/i.test(title)) title = `Draft: ${title}` + if (params.draft === false) title = title.replace(/^\s*draft:\s*/i, '') + body.title = title + } if (params.description !== undefined) body.description = params.description if (params.stateEvent) body.state_event = params.stateEvent if (params.labels !== undefined) body.labels = params.labels @@ -124,7 +132,6 @@ export const gitlabUpdateMergeRequestTool: ToolConfig< if (params.removeSourceBranch !== undefined) body.remove_source_branch = params.removeSourceBranch if (params.squash !== undefined) body.squash = params.squash - if (params.draft !== undefined) body.draft = params.draft return body }, diff --git a/apps/sim/tools/gitlab/update_user.ts b/apps/sim/tools/gitlab/update_user.ts index f85d25c7653..6a7831eac29 100644 --- a/apps/sim/tools/gitlab/update_user.ts +++ b/apps/sim/tools/gitlab/update_user.ts @@ -32,7 +32,8 @@ export const gitlabUpdateUserTool: ToolConfig Date: Thu, 16 Jul 2026 10:50:12 -0700 Subject: [PATCH 10/13] =?UTF-8?q?fix(gitlab):=20review=20round=201=20?= =?UTF-8?q?=E2=80=94=20dedicated=20no-default=20access=20level=20for=20upd?= =?UTF-8?q?ate=20member,=20expiration=20clearing=20via=20explicit=20empty?= =?UTF-8?q?=20string?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/docs/en/integrations/gitlab.mdx | 2 +- apps/sim/blocks/blocks/gitlab.ts | 40 ++++++++++++++++--- apps/sim/tools/gitlab/update_invitation.ts | 3 +- apps/sim/tools/gitlab/update_member.ts | 6 ++- 4 files changed, 42 insertions(+), 9 deletions(-) diff --git a/apps/docs/content/docs/en/integrations/gitlab.mdx b/apps/docs/content/docs/en/integrations/gitlab.mdx index f21260f73ae..ad5f5ff7468 100644 --- a/apps/docs/content/docs/en/integrations/gitlab.mdx +++ b/apps/docs/content/docs/en/integrations/gitlab.mdx @@ -859,7 +859,7 @@ Update a member's access level in a GitLab project or group | `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | | `userId` | number | Yes | The ID of the member to update | | `accessLevel` | number | Yes | New access level: 0 \(No access\), 5 \(Minimal\), 10 \(Guest\), 15 \(Planner\), 20 \(Reporter\), 25 \(Security Manager\), 30 \(Developer\), 40 \(Maintainer\), 50 \(Owner\) | -| `expiresAt` | string | No | Access expiration date in YYYY-MM-DD format | +| `expiresAt` | string | No | Access expiration date in YYYY-MM-DD format. Pass an empty string to clear an existing expiration. | | `memberRoleId` | number | No | Custom member role ID \(GitLab Ultimate only\). Warning: when omitted, GitLab removes any custom role the member currently holds. | #### Output diff --git a/apps/sim/blocks/blocks/gitlab.ts b/apps/sim/blocks/blocks/gitlab.ts index 16fb9e025e1..642330f7ec2 100644 --- a/apps/sim/blocks/blocks/gitlab.ts +++ b/apps/sim/blocks/blocks/gitlab.ts @@ -42,10 +42,13 @@ const USER_ID_OPS = [ 'gitlab_delete_user_identity', ] -/** Operations that take a named access-level dropdown (required unless noted). */ +/** + * Operations that take the shared access-level dropdown (required unless + * noted). Update Member deliberately has its own dropdown without a default so + * an expiry-only edit cannot silently reset a Maintainer/Owner to Developer. + */ const ACCESS_LEVEL_OPS = [ 'gitlab_add_member', - 'gitlab_update_member', 'gitlab_invite_member', 'gitlab_approve_access_request', 'gitlab_add_saml_group_link', @@ -54,7 +57,6 @@ const ACCESS_LEVEL_OPS = [ /** 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', ] @@ -1033,6 +1035,30 @@ Return ONLY the commit message - no explanations, no extra text.`, value: ACCESS_LEVEL_OPS, }, }, + // Access level for Update Member. Required by GitLab's edit-member API, but + // deliberately has NO default: the user must explicitly pick the level so an + // expiry-only edit can't silently downgrade a Maintainer/Owner to Developer. + { + id: 'memberAccessLevel', + 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' }, + ], + required: true, + condition: { + field: 'operation', + value: ['gitlab_update_member'], + }, + }, // Optional access level for Update Invitation. Defaults to "Leave unchanged" // so updating only the expiration does not silently reset the access level. { @@ -1818,7 +1844,7 @@ Return ONLY the commit message - no explanations, no extra text.`, } case 'gitlab_update_member': - if (!params.resourceId?.trim() || !params.userId || !params.accessLevel) { + if (!params.resourceId?.trim() || !params.userId || !params.memberAccessLevel) { throw new Error('Project / Group ID, User ID, and Access Level are required.') } return { @@ -1826,7 +1852,7 @@ Return ONLY the commit message - no explanations, no extra text.`, resourceType: params.resourceType || 'project', resourceId: params.resourceId.trim(), userId: Number(params.userId), - accessLevel: Number(params.accessLevel), + accessLevel: Number(params.memberAccessLevel), expiresAt: params.expiresAt?.trim() || undefined, memberRoleId: params.memberRoleId ? Number(params.memberRoleId) : undefined, } @@ -2130,6 +2156,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)' }, + memberAccessLevel: { + type: 'string', + description: 'Access level for member updates (explicit choice, no default)', + }, invitationAccessLevel: { type: 'string', description: 'Optional new access level for an invitation ("" leaves it unchanged)', diff --git a/apps/sim/tools/gitlab/update_invitation.ts b/apps/sim/tools/gitlab/update_invitation.ts index 867b493691d..3185e0525c9 100644 --- a/apps/sim/tools/gitlab/update_invitation.ts +++ b/apps/sim/tools/gitlab/update_invitation.ts @@ -76,7 +76,8 @@ export const gitlabUpdateInvitationTool: ToolConfig< const body: Record = {} if (params.accessLevel !== undefined) body.access_level = params.accessLevel - if (params.expiresAt) body.expires_at = params.expiresAt + // Send explicit empty string too, which clears an existing expiration. + if (params.expiresAt !== undefined) body.expires_at = params.expiresAt return body }, diff --git a/apps/sim/tools/gitlab/update_member.ts b/apps/sim/tools/gitlab/update_member.ts index e9ad9916ec2..8072c36ce97 100644 --- a/apps/sim/tools/gitlab/update_member.ts +++ b/apps/sim/tools/gitlab/update_member.ts @@ -53,7 +53,8 @@ export const gitlabUpdateMemberTool: ToolConfig< type: 'string', required: false, visibility: 'user-or-llm', - description: 'Access expiration date in YYYY-MM-DD format', + description: + 'Access expiration date in YYYY-MM-DD format. Pass an empty string to clear an existing expiration.', }, memberRoleId: { type: 'number', @@ -79,7 +80,8 @@ export const gitlabUpdateMemberTool: ToolConfig< access_level: params.accessLevel, } - if (params.expiresAt) body.expires_at = params.expiresAt + // Send explicit empty string too, which clears an existing expiration. + if (params.expiresAt !== undefined) body.expires_at = params.expiresAt if (params.memberRoleId !== undefined) body.member_role_id = params.memberRoleId return body From 8fb2dec5ede188b8bedcba805e14e33aeaa0a5f8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 16 Jul 2026 10:57:17 -0700 Subject: [PATCH 11/13] fix(gitlab): explicit Clear Expiration toggle for update member/invitation --- apps/sim/blocks/blocks/gitlab.ts | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/apps/sim/blocks/blocks/gitlab.ts b/apps/sim/blocks/blocks/gitlab.ts index 642330f7ec2..0042af1f0e9 100644 --- a/apps/sim/blocks/blocks/gitlab.ts +++ b/apps/sim/blocks/blocks/gitlab.ts @@ -1094,6 +1094,19 @@ Return ONLY the commit message - no explanations, no extra text.`, value: EXPIRES_AT_OPS, }, }, + // Explicit expiration clear (update member / update invitation). A blank + // Expires At field means "leave unchanged", so clearing needs its own toggle. + { + id: 'clearExpiresAt', + title: 'Clear Expiration', + type: 'switch', + mode: 'advanced', + description: 'Remove the existing access expiration date (overrides Expires At)', + condition: { + field: 'operation', + value: ['gitlab_update_member', 'gitlab_update_invitation'], + }, + }, // Custom member role ID (GitLab Ultimate) { id: 'memberRoleId', @@ -1853,7 +1866,7 @@ Return ONLY the commit message - no explanations, no extra text.`, resourceId: params.resourceId.trim(), userId: Number(params.userId), accessLevel: Number(params.memberAccessLevel), - expiresAt: params.expiresAt?.trim() || undefined, + expiresAt: params.clearExpiresAt ? '' : params.expiresAt?.trim() || undefined, memberRoleId: params.memberRoleId ? Number(params.memberRoleId) : undefined, } @@ -1899,8 +1912,14 @@ Return ONLY the commit message - no explanations, no extra text.`, if (!params.resourceId?.trim() || !params.email?.trim()) { throw new Error('Project / Group ID and Email are required.') } - if (!params.invitationAccessLevel && !params.expiresAt?.trim()) { - throw new Error('At least one of Access Level or Expires At is required.') + if ( + !params.invitationAccessLevel && + !params.expiresAt?.trim() && + !params.clearExpiresAt + ) { + throw new Error( + 'At least one of Access Level, Expires At, or Clear Expiration is required.' + ) } return { ...baseParams, @@ -1912,7 +1931,7 @@ Return ONLY the commit message - no explanations, no extra text.`, accessLevel: params.invitationAccessLevel ? Number(params.invitationAccessLevel) : undefined, - expiresAt: params.expiresAt?.trim() || undefined, + expiresAt: params.clearExpiresAt ? '' : params.expiresAt?.trim() || undefined, } case 'gitlab_revoke_invitation': @@ -2165,6 +2184,10 @@ Return ONLY the commit message - no explanations, no extra text.`, description: 'Optional new access level for an invitation ("" leaves it unchanged)', }, expiresAt: { type: 'string', description: 'Access expiration date (YYYY-MM-DD)' }, + clearExpiresAt: { + type: 'boolean', + description: 'Remove the existing access expiration date (update member/invitation)', + }, memberRoleId: { type: 'number', description: 'Custom member role ID (Ultimate)' }, email: { type: 'string', description: 'Email address for invitations' }, directMembersOnly: { type: 'boolean', description: 'Exclude inherited members' }, From 8df5a09d09c39b07b1752475632fe62eda0438d4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 16 Jul 2026 11:40:34 -0700 Subject: [PATCH 12/13] feat(gitlab): expose full documented API surface across tools and block - membership: add-by-username, remove-member cleanup flags, list-member filters (user_ids/state/seat info), invite_source - listings: search/visibility/owned/membership, assignee/milestone filters, source/target branch filters, per-domain order-by + sort direction - CI: pipeline variables + spec:inputs, manual-job variables - repo: commit authoring (start branch, author, execute flag), release tag message + asset links, cross-fork compare + unidiff, internal notes - catalog: access-governance template + member-provisioning and access-request-audit skills - hardening from 3-agent validation: declared release params, tolerate single-object asset links, NaN guard on assignee filter, null/scalar JSON rejection --- .../content/docs/en/integrations/gitlab.mdx | 29 +- apps/sim/blocks/blocks/gitlab.ts | 655 +++++++++++++++++- apps/sim/tools/gitlab/add_member.ts | 17 +- apps/sim/tools/gitlab/compare_branches.ts | 16 + apps/sim/tools/gitlab/create_file.ts | 45 +- apps/sim/tools/gitlab/create_issue_note.ts | 14 +- .../tools/gitlab/create_merge_request_note.ts | 14 +- apps/sim/tools/gitlab/create_pipeline.ts | 9 + apps/sim/tools/gitlab/create_release.ts | 20 + apps/sim/tools/gitlab/invite_member.ts | 9 +- apps/sim/tools/gitlab/list_members.ts | 27 + apps/sim/tools/gitlab/play_job.ts | 14 + apps/sim/tools/gitlab/remove_member.ts | 20 +- apps/sim/tools/gitlab/types.ts | 34 +- apps/sim/tools/gitlab/update_file.ts | 28 + 15 files changed, 924 insertions(+), 27 deletions(-) diff --git a/apps/docs/content/docs/en/integrations/gitlab.mdx b/apps/docs/content/docs/en/integrations/gitlab.mdx index ad5f5ff7468..cfeb0c53c8b 100644 --- a/apps/docs/content/docs/en/integrations/gitlab.mdx +++ b/apps/docs/content/docs/en/integrations/gitlab.mdx @@ -200,6 +200,7 @@ Add a comment to a GitLab issue | `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `issueIid` | number | Yes | Issue internal ID \(IID\) | | `body` | string | Yes | Comment body \(Markdown supported\) | +| `internal` | boolean | No | Create the comment as an internal note visible only to project members | #### Output @@ -341,6 +342,7 @@ Add a comment to a GitLab merge request | `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `mergeRequestIid` | number | Yes | Merge request internal ID \(IID\) | | `body` | string | Yes | Comment body \(Markdown supported\) | +| `internal` | boolean | No | Create the comment as an internal note visible only to project members | #### Output @@ -402,6 +404,7 @@ Trigger a new pipeline in a GitLab project | `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `ref` | string | Yes | Branch or tag to run the pipeline on | | `variables` | array | No | Array of variables for the pipeline \(each with key, value, and optional variable_type\) | +| `inputs` | json | No | Pipeline inputs as a key/value object \(for pipelines with spec:inputs\) | #### Output @@ -507,6 +510,10 @@ Create a new file in a GitLab project repository | `filePath` | string | Yes | Path to the file in the repository | | `branch` | string | Yes | Branch to commit the new file to | | `content` | string | Yes | File content | +| `startBranch` | string | No | Name of the base branch to create the target branch from, if it does not exist | +| `authorName` | string | No | Commit author name \(defaults to the token user\) | +| `authorEmail` | string | No | Commit author email \(defaults to the token user\) | +| `executeFilemode` | boolean | No | Enable the execute flag on the file | | `commitMessage` | string | Yes | Commit message | #### Output @@ -529,6 +536,10 @@ Update an existing file in a GitLab project repository | `filePath` | string | Yes | Path to the file in the repository | | `branch` | string | Yes | Branch to commit the update to | | `content` | string | Yes | New file content | +| `startBranch` | string | No | Name of the base branch to create the target branch from, if it does not exist | +| `authorName` | string | No | Commit author name \(defaults to the token user\) | +| `authorEmail` | string | No | Commit author email \(defaults to the token user\) | +| `executeFilemode` | boolean | No | Enable or disable the execute flag on the file | | `commitMessage` | string | Yes | Commit message | | `lastCommitId` | string | No | Last known commit ID for the file \(optimistic locking\) | @@ -592,6 +603,8 @@ Compare two branches, tags, or commits in a GitLab project repository | `from` | string | Yes | Commit SHA or branch/tag name to compare from | | `to` | string | Yes | Commit SHA or branch/tag name to compare to | | `straight` | boolean | No | Compare directly from..to instead of using the merge base \(defaults to false\) | +| `fromProjectId` | string | No | ID of the project to compare from \(for cross-fork comparisons\) | +| `unidiff` | boolean | No | Return diffs in unified diff format \(GitLab 16.5+\) | #### Output @@ -745,6 +758,7 @@ Trigger (play) a manual GitLab job | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | | `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `jobId` | number | Yes | Job ID | +| `jobVariables` | array | No | Variables for the manual job \(array of objects with key and value\) | #### Output @@ -792,6 +806,8 @@ Create a new release in a GitLab project | `description` | string | No | Release description/notes \(Markdown supported\) | | `ref` | string | No | Commit SHA, branch, or tag to create the tag from if it does not already exist | | `releasedAt` | string | No | ISO 8601 date for an upcoming or historical release | +| `tagMessage` | string | No | Annotation message to use if creating a new annotated tag | +| `assetLinks` | array | No | Release asset links: array of objects with name, url, and optional link_type \(other, runbook, image, package\) | | `milestones` | array | No | Array of milestone titles to associate with the release | #### Output @@ -813,6 +829,9 @@ List members of a GitLab project or group. Includes members inherited from ances | `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | | `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, email, or username | +| `userIds` | string | No | Comma-separated user IDs to filter the results to | +| `state` | string | No | Filter inherited-member results by state: 'awaiting' or 'active' \(Premium/Ultimate; only applies when inherited members are included\) | +| `showSeatInfo` | boolean | No | Include seat information for each member | | `perPage` | number | No | Number of results per page \(default 20, max 100\) | | `page` | number | No | Page number for pagination | @@ -834,8 +853,9 @@ Add an existing GitLab user to a project or group at a given access level | `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 path \(e.g. mygroup/myproject\) | -| `userId` | number | Yes | The ID of the user to add | -| `accessLevel` | number | Yes | Access level: 10 \(Guest\), 15 \(Planner\), 20 \(Reporter\), 25 \(Security Manager\), 30 \(Developer\), 40 \(Maintainer\), 50 \(Owner\) | +| `userId` | number | No | The ID of the user to add. Provide either userId or username. | +| `username` | string | No | The username of the user to add. Provide either userId or username. | +| `accessLevel` | number | Yes | Access level: 0 \(No access\), 5 \(Minimal\), 10 \(Guest\), 15 \(Planner\), 20 \(Reporter\), 25 \(Security Manager\), 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\) | @@ -880,6 +900,8 @@ Remove a member from a GitLab project or group | `resourceType` | string | Yes | Whether the resource is a 'project' or a 'group' | | `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | | `userId` | number | Yes | The ID of the member to remove | +| `skipSubresources` | boolean | No | Skip deleting the member from subgroups and projects below the target \(defaults to false\) | +| `unassignIssuables` | boolean | No | Unassign the member from all issues and merge requests in the target \(defaults to false\) | #### Output @@ -899,9 +921,10 @@ Invite a person to a GitLab project or group by email address | `resourceType` | string | Yes | Whether the resource is a 'project' or a 'group' | | `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | | `email` | string | Yes | Email address to invite \(comma-separated for multiple\) | -| `accessLevel` | number | Yes | Access level: 10 \(Guest\), 15 \(Planner\), 20 \(Reporter\), 25 \(Security Manager\), 30 \(Developer\), 40 \(Maintainer\), 50 \(Owner\) | +| `accessLevel` | number | Yes | Access level: 0 \(No access\), 5 \(Minimal\), 10 \(Guest\), 15 \(Planner\), 20 \(Reporter\), 25 \(Security Manager\), 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\) | +| `inviteSource` | string | No | Identifier recorded as the source of the invitation \(for attribution\) | #### Output diff --git a/apps/sim/blocks/blocks/gitlab.ts b/apps/sim/blocks/blocks/gitlab.ts index 0042af1f0e9..8842c970869 100644 --- a/apps/sim/blocks/blocks/gitlab.ts +++ b/apps/sim/blocks/blocks/gitlab.ts @@ -90,6 +90,30 @@ const SAML_LINK_OPS = [ /** SAML operations that take a SAML group name. */ const SAML_NAME_OPS = ['gitlab_add_saml_group_link', 'gitlab_delete_saml_group_link'] +/** Ops where the User ID field is strictly required (Add Member also accepts a username instead). */ +const USER_ID_REQUIRED_OPS = USER_ID_OPS.filter((op) => op !== 'gitlab_add_member') + +/** + * Parses an optional JSON text field at execution time. Returns undefined for + * blank input and throws a field-specific error for malformed JSON. + */ +function parseJsonParam(raw: unknown, label: string): any { + if (raw === undefined || raw === null || raw === '') return undefined + let parsed = raw + if (typeof raw === 'string') { + try { + parsed = JSON.parse(raw) + } catch { + throw new Error(`${label} must be valid JSON.`) + } + } + if (parsed === null) return undefined + if (typeof parsed !== 'object') { + throw new Error(`${label} must be a JSON object or array.`) + } + return parsed +} + export const GitLabBlock: BlockConfig = { type: 'gitlab', name: 'GitLab', @@ -905,6 +929,393 @@ Return ONLY the commit message - no explanations, no extra text.`, placeholder: 'Describe the merge...', }, }, + // Search filter (projects and issues listings) + { + id: 'searchQuery', + title: 'Search', + type: 'short-input', + placeholder: 'Search projects (name/path/description) or issues (title/description)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_projects', 'gitlab_list_issues'], + }, + }, + // List-projects filters + { + id: 'owned', + title: 'Owned Only', + type: 'switch', + mode: 'advanced', + description: 'Only projects explicitly owned by the current user', + condition: { + field: 'operation', + value: ['gitlab_list_projects'], + }, + }, + { + id: 'membership', + title: 'Member Only', + type: 'switch', + mode: 'advanced', + description: 'Only projects the current user is a member of', + condition: { + field: 'operation', + value: ['gitlab_list_projects'], + }, + }, + { + id: 'visibility', + title: 'Visibility', + type: 'dropdown', + options: [ + { label: 'All', id: '' }, + { label: 'Public', id: 'public' }, + { label: 'Internal', id: 'internal' }, + { label: 'Private', id: 'private' }, + ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_projects'], + }, + }, + // List-issues filters + { + id: 'assigneeId', + title: 'Assignee ID', + type: 'short-input', + placeholder: 'Filter by assignee user ID (optional)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_issues'], + }, + }, + { + id: 'milestoneTitle', + title: 'Milestone', + type: 'short-input', + placeholder: 'Filter by milestone title (optional)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_issues'], + }, + }, + // List-MRs branch filters + { + id: 'sourceBranchFilter', + title: 'Source Branch', + type: 'short-input', + placeholder: 'Filter by source branch (optional)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_merge_requests'], + }, + }, + { + id: 'targetBranchFilter', + title: 'Target Branch', + type: 'short-input', + placeholder: 'Filter by target branch (optional)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_merge_requests'], + }, + }, + // Per-domain sort fields + { + id: 'projectOrderBy', + title: 'Order By', + type: 'dropdown', + options: [ + { label: 'Default (created)', id: '' }, + { label: 'ID', id: 'id' }, + { label: 'Name', id: 'name' }, + { label: 'Path', id: 'path' }, + { label: 'Created', id: 'created_at' }, + { label: 'Updated', id: 'updated_at' }, + { label: 'Last activity', id: 'last_activity_at' }, + ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_projects'], + }, + }, + { + id: 'issueOrderBy', + title: 'Order By', + type: 'dropdown', + options: [ + { label: 'Default (created)', id: '' }, + { label: 'Created', id: 'created_at' }, + { label: 'Updated', id: 'updated_at' }, + { label: 'Priority', id: 'priority' }, + { label: 'Due date', id: 'due_date' }, + { label: 'Relative position', id: 'relative_position' }, + { label: 'Label priority', id: 'label_priority' }, + { label: 'Milestone due', id: 'milestone_due' }, + { label: 'Popularity', id: 'popularity' }, + { label: 'Weight', id: 'weight' }, + ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_issues'], + }, + }, + { + id: 'mrOrderBy', + title: 'Order By', + type: 'dropdown', + options: [ + { label: 'Default (created)', id: '' }, + { label: 'Created', id: 'created_at' }, + { label: 'Updated', id: 'updated_at' }, + { label: 'Merged (GitLab 17.2+)', id: 'merged_at' }, + { label: 'Priority', id: 'priority' }, + { label: 'Label priority', id: 'label_priority' }, + { label: 'Milestone due', id: 'milestone_due' }, + { label: 'Popularity', id: 'popularity' }, + { label: 'Title', id: 'title' }, + ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_merge_requests'], + }, + }, + { + id: 'pipelineOrderBy', + title: 'Order By', + type: 'dropdown', + options: [ + { label: 'Default (ID)', id: '' }, + { label: 'ID', id: 'id' }, + { label: 'Status', id: 'status' }, + { label: 'Ref', id: 'ref' }, + { label: 'Updated', id: 'updated_at' }, + { label: 'User ID', id: 'user_id' }, + ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_pipelines'], + }, + }, + { + id: 'releaseOrderBy', + title: 'Order By', + type: 'dropdown', + options: [ + { label: 'Default (released)', id: '' }, + { label: 'Released', id: 'released_at' }, + { label: 'Created', id: 'created_at' }, + ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_releases'], + }, + }, + { + id: 'sortOrder', + title: 'Sort Direction', + type: 'dropdown', + options: [ + { label: 'Default (descending)', id: '' }, + { label: 'Ascending', id: 'asc' }, + { label: 'Descending', id: 'desc' }, + ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: [ + 'gitlab_list_projects', + 'gitlab_list_issues', + 'gitlab_list_merge_requests', + 'gitlab_list_pipelines', + 'gitlab_list_releases', + ], + }, + }, + // Pipeline variables and inputs (create pipeline) + { + id: 'pipelineVariables', + title: 'Pipeline Variables', + type: 'long-input', + placeholder: '{"DEPLOY_ENV": "staging"} or [{"key": "DEPLOY_ENV", "value": "staging"}]', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_create_pipeline'], + }, + wandConfig: { + enabled: true, + prompt: `Generate a JSON object mapping GitLab CI variable names to values based on the user's description, e.g. {"DEPLOY_ENV": "staging", "DRY_RUN": "true"}. + +Return ONLY the JSON - no explanations, no extra text.`, + placeholder: 'Describe the pipeline variables...', + }, + }, + { + id: 'pipelineInputs', + title: 'Pipeline Inputs', + type: 'long-input', + placeholder: '{"environment": "staging"} (for pipelines defining spec:inputs)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_create_pipeline'], + }, + wandConfig: { + enabled: true, + prompt: `Generate a JSON object of GitLab pipeline inputs (spec:inputs) based on the user's description, e.g. {"environment": "staging"}. + +Return ONLY the JSON - no explanations, no extra text.`, + placeholder: 'Describe the pipeline inputs...', + }, + }, + // Manual job variables (play job) + { + id: 'jobVariables', + title: 'Job Variables', + type: 'long-input', + placeholder: '{"VAR": "value"} or [{"key": "VAR", "value": "value"}]', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_play_job'], + }, + wandConfig: { + enabled: true, + prompt: `Generate a JSON object mapping GitLab job variable names to values based on the user's description, e.g. {"DEPLOY_TARGET": "eu-west"}. + +Return ONLY the JSON - no explanations, no extra text.`, + placeholder: 'Describe the job variables...', + }, + }, + // Release extras + { + id: 'tagMessage', + title: 'Tag Message', + type: 'short-input', + placeholder: 'Annotation message when creating a new tag (optional)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_create_release'], + }, + }, + { + id: 'assetLinks', + title: 'Asset Links', + type: 'long-input', + placeholder: '[{"name": "Binaries", "url": "https://example.com/bin.zip"}]', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_create_release'], + }, + wandConfig: { + enabled: true, + prompt: `Generate a JSON array of GitLab release asset links based on the user's description. Each entry has "name" and "url", and optionally "link_type" (other, runbook, image, package), e.g. [{"name": "Binaries", "url": "https://example.com/bin.zip"}]. + +Return ONLY the JSON array - no explanations, no extra text.`, + placeholder: 'Describe the release assets...', + }, + }, + // Commit authoring options (create/update file) + { + id: 'startBranch', + title: 'Start Branch', + type: 'short-input', + placeholder: 'Base branch to create the target branch from, if missing (optional)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_create_file', 'gitlab_update_file'], + }, + }, + { + id: 'authorName', + title: 'Author Name', + type: 'short-input', + placeholder: 'Commit author name (optional)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_create_file', 'gitlab_update_file'], + }, + }, + { + id: 'authorEmail', + title: 'Author Email', + type: 'short-input', + placeholder: 'Commit author email (optional)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_create_file', 'gitlab_update_file'], + }, + }, + { + id: 'executeFilemode', + title: 'Executable', + type: 'switch', + mode: 'advanced', + description: 'Enable the execute flag on the file', + condition: { + field: 'operation', + value: ['gitlab_create_file', 'gitlab_update_file'], + }, + }, + // Cross-fork compare options + { + id: 'fromProjectId', + title: 'From Project ID', + type: 'short-input', + placeholder: 'Project to compare from, for cross-fork comparisons (optional)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_compare_branches'], + }, + }, + { + id: 'unidiff', + title: 'Unified Diff Format', + type: 'switch', + mode: 'advanced', + description: 'Return diffs in unified diff format (GitLab 16.5+)', + condition: { + field: 'operation', + value: ['gitlab_compare_branches'], + }, + }, + // Internal note toggle (comments) + { + id: 'internalNote', + title: 'Internal Note', + type: 'switch', + mode: 'advanced', + description: 'Visible only to project members with at least Reporter access', + condition: { + field: 'operation', + value: ['gitlab_create_issue_note', 'gitlab_create_merge_request_note'], + }, + }, // Per page (pagination) { id: 'perPage', @@ -1003,12 +1414,27 @@ Return ONLY the commit message - no explanations, no extra text.`, title: 'User ID', type: 'short-input', placeholder: 'Enter the user ID', - required: true, + required: { + field: 'operation', + value: USER_ID_REQUIRED_OPS, + }, condition: { field: 'operation', value: USER_ID_OPS, }, }, + // Username alternative for Add Member (GitLab accepts user_id OR username) + { + id: 'username', + title: 'Username', + type: 'short-input', + placeholder: 'Username to add instead of a user ID', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_add_member'], + }, + }, // Access level (named dropdown mapping to GitLab integer access levels) { id: 'accessLevel', @@ -1144,6 +1570,18 @@ Return ONLY the commit message - no explanations, no extra text.`, value: ['gitlab_list_members', 'gitlab_list_invitations', 'gitlab_list_branches'], }, }, + // Invitation source attribution (invite member) + { + id: 'inviteSource', + title: 'Invite Source', + type: 'short-input', + placeholder: 'Identifier recorded as the source of the invitation (optional)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_invite_member'], + }, + }, // Direct members only toggle (list members) { id: 'directMembersOnly', @@ -1156,6 +1594,70 @@ Return ONLY the commit message - no explanations, no extra text.`, value: ['gitlab_list_members'], }, }, + // Remove-member cleanup options + { + id: 'skipSubresources', + title: 'Skip Subresources', + type: 'switch', + mode: 'advanced', + description: 'Keep the member in subgroups and projects below the target', + condition: { + field: 'operation', + value: ['gitlab_remove_member'], + }, + }, + { + id: 'unassignIssuables', + title: 'Unassign Issues & MRs', + type: 'switch', + mode: 'advanced', + description: 'Unassign the member from all issues and merge requests in the target', + condition: { + field: 'operation', + value: ['gitlab_remove_member'], + }, + }, + // List-members filters + { + id: 'memberUserIds', + title: 'User IDs', + type: 'short-input', + placeholder: 'Comma-separated user IDs to filter to (optional)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_members'], + }, + }, + { + id: 'memberState', + title: 'Member State', + type: 'dropdown', + description: + 'Only applies when inherited members are included (Premium/Ultimate); ignored with Direct Members Only', + options: [ + { label: 'All', id: '' }, + { label: 'Active', id: 'active' }, + { label: 'Awaiting', id: 'awaiting' }, + ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_members'], + }, + }, + { + id: 'showSeatInfo', + title: 'Show Seat Info', + type: 'switch', + mode: 'advanced', + description: 'Include seat information for each member (Premium/Ultimate)', + condition: { + field: 'operation', + value: ['gitlab_list_members'], + }, + }, // User search query { id: 'userSearch', @@ -1400,6 +1902,12 @@ Return ONLY the commit message - no explanations, no extra text.`, case 'gitlab_list_projects': return { ...baseParams, + owned: params.owned || undefined, + membership: params.membership || undefined, + search: params.searchQuery?.trim() || undefined, + visibility: params.visibility || undefined, + orderBy: params.projectOrderBy || undefined, + sort: params.sortOrder || undefined, perPage: params.perPage ? Number(params.perPage) : undefined, page: params.page ? Number(params.page) : undefined, } @@ -1422,6 +1930,17 @@ Return ONLY the commit message - no explanations, no extra text.`, projectId: params.projectId.trim(), state: params.issueState !== 'all' ? params.issueState : undefined, labels: params.labels?.trim() || undefined, + search: params.searchQuery?.trim() || undefined, + assigneeId: (() => { + const raw = String(params.assigneeId ?? '').trim() + if (!raw) return undefined + const parsed = Number(raw) + if (Number.isNaN(parsed)) throw new Error('Assignee ID must be a number.') + return parsed + })(), + milestoneTitle: params.milestoneTitle?.trim() || undefined, + orderBy: params.issueOrderBy || undefined, + sort: params.sortOrder || undefined, perPage: params.perPage ? Number(params.perPage) : undefined, page: params.page ? Number(params.page) : undefined, } @@ -1489,6 +2008,7 @@ Return ONLY the commit message - no explanations, no extra text.`, projectId: params.projectId.trim(), issueIid: Number(params.issueIid), body: params.body.trim(), + internal: params.internalNote || undefined, } case 'gitlab_list_merge_requests': @@ -1500,6 +2020,10 @@ Return ONLY the commit message - no explanations, no extra text.`, projectId: params.projectId.trim(), state: params.mrState !== 'all' ? params.mrState : undefined, labels: params.labels?.trim() || undefined, + sourceBranch: params.sourceBranchFilter?.trim() || undefined, + targetBranch: params.targetBranchFilter?.trim() || undefined, + orderBy: params.mrOrderBy || undefined, + sort: params.sortOrder || undefined, perPage: params.perPage ? Number(params.perPage) : undefined, page: params.page ? Number(params.page) : undefined, } @@ -1581,6 +2105,7 @@ Return ONLY the commit message - no explanations, no extra text.`, projectId: params.projectId.trim(), mergeRequestIid: Number(params.mergeRequestIid), body: params.body.trim(), + internal: params.internalNote || undefined, } case 'gitlab_list_pipelines': @@ -1592,6 +2117,8 @@ Return ONLY the commit message - no explanations, no extra text.`, projectId: params.projectId.trim(), status: params.pipelineStatus || undefined, ref: params.ref?.trim() || undefined, + orderBy: params.pipelineOrderBy || undefined, + sort: params.sortOrder || undefined, perPage: params.perPage ? Number(params.perPage) : undefined, page: params.page ? Number(params.page) : undefined, } @@ -1614,6 +2141,19 @@ Return ONLY the commit message - no explanations, no extra text.`, ...baseParams, projectId: params.projectId.trim(), ref: params.ref.trim(), + variables: (() => { + const parsed = parseJsonParam(params.pipelineVariables, 'Pipeline Variables') + if (parsed === undefined) return undefined + // Accept {KEY: value} shorthand and normalize to GitLab's array form. + if (!Array.isArray(parsed) && typeof parsed === 'object') { + return Object.entries(parsed).map(([key, value]) => ({ + key, + value: String(value), + })) + } + return parsed + })(), + inputs: parseJsonParam(params.pipelineInputs, 'Pipeline Inputs'), } case 'gitlab_retry_pipeline': @@ -1672,6 +2212,10 @@ Return ONLY the commit message - no explanations, no extra text.`, branch: params.branch.trim(), content: params.content, commitMessage: params.commitMessage.trim(), + startBranch: params.startBranch?.trim() || undefined, + authorName: params.authorName?.trim() || undefined, + authorEmail: params.authorEmail?.trim() || undefined, + executeFilemode: params.executeFilemode || undefined, ...(params.operation === 'gitlab_update_file' ? { lastCommitId: params.lastCommitId?.trim() || undefined } : {}), @@ -1724,6 +2268,8 @@ Return ONLY the commit message - no explanations, no extra text.`, from: params.compareFrom.trim(), to: params.compareTo.trim(), straight: params.straight || undefined, + fromProjectId: params.fromProjectId?.trim() || undefined, + unidiff: params.unidiff || undefined, } case 'gitlab_list_commits': @@ -1795,6 +2341,17 @@ Return ONLY the commit message - no explanations, no extra text.`, ...baseParams, projectId: params.projectId.trim(), jobId: Number(params.jobId), + jobVariables: (() => { + const parsed = parseJsonParam(params.jobVariables, 'Job Variables') + if (parsed === undefined) return undefined + if (!Array.isArray(parsed) && typeof parsed === 'object') { + return Object.entries(parsed).map(([key, value]) => ({ + key, + value: String(value), + })) + } + return parsed + })(), } case 'gitlab_list_releases': @@ -1804,6 +2361,8 @@ Return ONLY the commit message - no explanations, no extra text.`, return { ...baseParams, projectId: params.projectId.trim(), + orderBy: params.releaseOrderBy || undefined, + sort: params.sortOrder || undefined, perPage: params.perPage ? Number(params.perPage) : undefined, page: params.page ? Number(params.page) : undefined, } @@ -1820,6 +2379,8 @@ Return ONLY the commit message - no explanations, no extra text.`, description: params.description?.trim() || undefined, ref: params.ref?.trim() || undefined, releasedAt: params.releasedAt?.trim() || undefined, + tagMessage: params.tagMessage?.trim() || undefined, + assetLinks: parseJsonParam(params.assetLinks, 'Asset Links'), milestones: params.releaseMilestones ? params.releaseMilestones .split(',') @@ -1838,23 +2399,35 @@ Return ONLY the commit message - no explanations, no extra text.`, resourceId: params.resourceId.trim(), directOnly: params.directMembersOnly || undefined, query: params.query?.trim() || undefined, + userIds: params.memberUserIds?.trim() || undefined, + state: params.memberState || undefined, + showSeatInfo: params.showSeatInfo || 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.') + case 'gitlab_add_member': { + const addMemberUserId = String(params.userId ?? '').trim() + if ( + !params.resourceId?.trim() || + (!addMemberUserId && !params.username?.trim()) || + !params.accessLevel + ) { + throw new Error( + 'Project / Group ID, User ID (or Username), and Access Level are required.' + ) } return { ...baseParams, resourceType: params.resourceType || 'project', resourceId: params.resourceId.trim(), - userId: Number(params.userId), + userId: addMemberUserId ? Number(addMemberUserId) : undefined, + username: params.username?.trim() || undefined, 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.memberAccessLevel) { @@ -1879,6 +2452,8 @@ Return ONLY the commit message - no explanations, no extra text.`, resourceType: params.resourceType || 'project', resourceId: params.resourceId.trim(), userId: Number(params.userId), + skipSubresources: params.skipSubresources || undefined, + unassignIssuables: params.unassignIssuables || undefined, } case 'gitlab_invite_member': @@ -1893,6 +2468,7 @@ Return ONLY the commit message - no explanations, no extra text.`, accessLevel: Number(params.accessLevel), expiresAt: params.expiresAt?.trim() || undefined, memberRoleId: params.memberRoleId ? Number(params.memberRoleId) : undefined, + inviteSource: params.inviteSource?.trim() || undefined, } case 'gitlab_list_invitations': @@ -2131,6 +2707,38 @@ Return ONLY the commit message - no explanations, no extra text.`, 'Branch, tag, or commit reference (pipelines, files, branches, releases, listings)', }, labels: { type: 'string', description: 'Labels (comma-separated)' }, + searchQuery: { type: 'string', description: 'Search filter for project/issue listings' }, + owned: { type: 'boolean', description: 'Only owned projects' }, + membership: { type: 'boolean', description: 'Only projects the user is a member of' }, + visibility: { type: 'string', description: 'Project visibility filter' }, + assigneeId: { type: 'number', description: 'Assignee user ID filter for issues' }, + milestoneTitle: { type: 'string', description: 'Milestone title filter for issues' }, + sourceBranchFilter: { type: 'string', description: 'Source branch filter for MR listings' }, + targetBranchFilter: { type: 'string', description: 'Target branch filter for MR listings' }, + projectOrderBy: { type: 'string', description: 'Project listing sort field' }, + issueOrderBy: { type: 'string', description: 'Issue listing sort field' }, + mrOrderBy: { type: 'string', description: 'Merge request listing sort field' }, + pipelineOrderBy: { type: 'string', description: 'Pipeline listing sort field' }, + releaseOrderBy: { type: 'string', description: 'Release listing sort field' }, + sortOrder: { type: 'string', description: 'Sort direction (asc or desc)' }, + pipelineVariables: { + type: 'string', + description: 'Pipeline variables as JSON (object or key/value array)', + }, + pipelineInputs: { type: 'string', description: 'Pipeline spec:inputs as a JSON object' }, + jobVariables: { + type: 'string', + description: 'Manual job variables as JSON (object or key/value array)', + }, + tagMessage: { type: 'string', description: 'Annotation message for a newly created tag' }, + assetLinks: { type: 'string', description: 'Release asset links as a JSON array' }, + startBranch: { type: 'string', description: 'Base branch for new-branch file commits' }, + authorName: { type: 'string', description: 'Commit author name override' }, + authorEmail: { type: 'string', description: 'Commit author email override' }, + executeFilemode: { type: 'boolean', description: 'Enable the execute flag on the file' }, + fromProjectId: { type: 'string', description: 'Project to compare from (cross-fork)' }, + unidiff: { type: 'boolean', description: 'Return diffs in unified diff format' }, + internalNote: { type: 'boolean', description: 'Create the comment as an internal note' }, assigneeIds: { type: 'string', description: 'Assignee user IDs (comma-separated)' }, milestoneId: { type: 'number', description: 'Milestone ID' }, issueState: { type: 'string', description: 'Issue state filter (opened, closed, all)' }, @@ -2174,6 +2782,19 @@ Return ONLY the commit message - no explanations, no extra text.`, 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' }, + username: { type: 'string', description: 'Username alternative for adding a member' }, + skipSubresources: { + type: 'boolean', + description: 'Keep the member in descendant subgroups/projects on removal', + }, + unassignIssuables: { + type: 'boolean', + description: 'Unassign the removed member from issues and MRs', + }, + inviteSource: { type: 'string', description: 'Attribution source recorded on the invitation' }, + memberUserIds: { type: 'string', description: 'Comma-separated user IDs filter for members' }, + memberState: { type: 'string', description: "Member state filter ('active' or 'awaiting')" }, + showSeatInfo: { type: 'boolean', description: 'Include seat information for members' }, accessLevel: { type: 'number', description: 'GitLab access level (10-50)' }, memberAccessLevel: { type: 'string', @@ -2368,6 +2989,16 @@ export const GitLabBlockMeta = { tags: ['engineering', 'devops', 'team'], alsoIntegrations: ['slack'], }, + { + icon: GitLabIcon, + title: 'GitLab access governance agent', + prompt: + 'Build a scheduled workflow that lists pending GitLab access requests and invitations across our key groups, checks each requester against a table of approved teams, approves or denies the access requests at the right access level, revokes stale invitations older than 14 days, and posts a summary of every access decision to Slack.', + modules: ['scheduled', 'tables', 'agent', 'workflows'], + category: 'engineering', + tags: ['engineering', 'security', 'automation'], + alsoIntegrations: ['slack'], + }, { icon: GitLabIcon, title: 'GitLab repository knowledge base', @@ -2379,6 +3010,20 @@ export const GitLabBlockMeta = { }, ], skills: [ + { + name: 'provision-gitlab-member', + description: + 'Resolve a person to a GitLab user and add them to a project or group at the right access level.', + content: + '# Provision GitLab Member\n\nUse GitLab to grant someone access to a project or group.\n\n## Steps\n1. Search Users by name, username, or email to resolve the target user ID.\n2. If the user exists, use Add Member with the project/group, user ID, access level, and an expiration date for time-boxed access. A user who is already a member is reported as a soft success.\n3. If no GitLab user matches, use Invite Member by Email instead so they receive an email invitation at the same access level.\n\n## Output\nConfirm who was added or invited, to which project/group, at what access level, and any expiration date applied.', + }, + { + name: 'audit-gitlab-access-requests', + description: + 'List pending access requests for a GitLab project or group and approve or deny each one.', + content: + '# Audit GitLab Access Requests\n\nUse GitLab to work through pending access requests.\n\n## Steps\n1. List Access Requests for the project or group to see who is waiting.\n2. For each requester, decide based on the stated policy: Approve Access Request with an explicit access level, or Deny Access Request.\n3. Optionally List Members afterwards to confirm the roster reflects the decisions.\n\n## Output\nReturn a decision log: each requester, whether they were approved (and at what level) or denied, and any requests intentionally left pending.', + }, { name: 'review-merge-request', description: diff --git a/apps/sim/tools/gitlab/add_member.ts b/apps/sim/tools/gitlab/add_member.ts index a52c549104b..10b0ebe9dbd 100644 --- a/apps/sim/tools/gitlab/add_member.ts +++ b/apps/sim/tools/gitlab/add_member.ts @@ -35,16 +35,22 @@ export const gitlabAddMemberTool: ToolConfig { const body: Record = { - user_id: params.userId, access_level: params.accessLevel, } + // GitLab accepts either user_id or username to identify the user. + if (params.userId !== undefined && params.userId !== null) body.user_id = params.userId + else if (params.username?.trim()) body.username = params.username.trim() + if (params.expiresAt) body.expires_at = params.expiresAt if (params.memberRoleId !== undefined) body.member_role_id = params.memberRoleId diff --git a/apps/sim/tools/gitlab/compare_branches.ts b/apps/sim/tools/gitlab/compare_branches.ts index a854f633427..f45e02a3042 100644 --- a/apps/sim/tools/gitlab/compare_branches.ts +++ b/apps/sim/tools/gitlab/compare_branches.ts @@ -51,6 +51,18 @@ export const gitlabCompareBranchesTool: ToolConfig< visibility: 'user-or-llm', description: 'Compare directly from..to instead of using the merge base (defaults to false)', }, + fromProjectId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'ID of the project to compare from (for cross-fork comparisons)', + }, + unidiff: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return diffs in unified diff format (GitLab 16.5+)', + }, }, request: { @@ -60,6 +72,10 @@ export const gitlabCompareBranchesTool: ToolConfig< queryParams.append('from', params.from) queryParams.append('to', params.to) if (params.straight) queryParams.append('straight', 'true') + if (params.fromProjectId) { + queryParams.append('from_project_id', String(params.fromProjectId).trim()) + } + if (params.unidiff) queryParams.append('unidiff', 'true') return `${getGitLabApiBase(params.host)}/projects/${encodedId}/repository/compare?${queryParams.toString()}` }, diff --git a/apps/sim/tools/gitlab/create_file.ts b/apps/sim/tools/gitlab/create_file.ts index 3bdb74e9674..7aa82c5de7a 100644 --- a/apps/sim/tools/gitlab/create_file.ts +++ b/apps/sim/tools/gitlab/create_file.ts @@ -45,6 +45,30 @@ export const gitlabCreateFileTool: ToolConfig ({ - branch: params.branch, - content: params.content, - commit_message: params.commitMessage, - encoding: 'text', - }), + body: (params) => { + const body: Record = { + branch: params.branch, + content: params.content, + commit_message: params.commitMessage, + encoding: 'text', + } + + if (params.startBranch) body.start_branch = params.startBranch + if (params.authorName) body.author_name = params.authorName + if (params.authorEmail) body.author_email = params.authorEmail + if (params.executeFilemode !== undefined) body.execute_filemode = params.executeFilemode + + return body + }, }, transformResponse: async (response) => { diff --git a/apps/sim/tools/gitlab/create_issue_note.ts b/apps/sim/tools/gitlab/create_issue_note.ts index 26519128299..1560c3adcd4 100644 --- a/apps/sim/tools/gitlab/create_issue_note.ts +++ b/apps/sim/tools/gitlab/create_issue_note.ts @@ -42,6 +42,12 @@ export const gitlabCreateIssueNoteTool: ToolConfig< visibility: 'user-or-llm', description: 'Comment body (Markdown supported)', }, + internal: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Create the comment as an internal note visible only to project members', + }, }, request: { @@ -54,9 +60,11 @@ export const gitlabCreateIssueNoteTool: ToolConfig< 'Content-Type': 'application/json', 'PRIVATE-TOKEN': params.accessToken, }), - body: (params) => ({ - body: params.body, - }), + body: (params) => { + const body: Record = { body: params.body } + if (params.internal !== undefined) body.internal = params.internal + return body + }, }, transformResponse: async (response) => { diff --git a/apps/sim/tools/gitlab/create_merge_request_note.ts b/apps/sim/tools/gitlab/create_merge_request_note.ts index 7491c8f2d64..09fc9fcffce 100644 --- a/apps/sim/tools/gitlab/create_merge_request_note.ts +++ b/apps/sim/tools/gitlab/create_merge_request_note.ts @@ -45,6 +45,12 @@ export const gitlabCreateMergeRequestNoteTool: ToolConfig< visibility: 'user-or-llm', description: 'Comment body (Markdown supported)', }, + internal: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Create the comment as an internal note visible only to project members', + }, }, request: { @@ -57,9 +63,11 @@ export const gitlabCreateMergeRequestNoteTool: ToolConfig< 'Content-Type': 'application/json', 'PRIVATE-TOKEN': params.accessToken, }), - body: (params) => ({ - body: params.body, - }), + body: (params) => { + const body: Record = { body: params.body } + if (params.internal !== undefined) body.internal = params.internal + return body + }, }, transformResponse: async (response) => { diff --git a/apps/sim/tools/gitlab/create_pipeline.ts b/apps/sim/tools/gitlab/create_pipeline.ts index dff4a546c66..5e238807ffe 100644 --- a/apps/sim/tools/gitlab/create_pipeline.ts +++ b/apps/sim/tools/gitlab/create_pipeline.ts @@ -43,6 +43,12 @@ export const gitlabCreatePipelineTool: ToolConfig< description: 'Array of variables for the pipeline (each with key, value, and optional variable_type)', }, + inputs: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'Pipeline inputs as a key/value object (for pipelines with spec:inputs)', + }, }, request: { @@ -63,6 +69,9 @@ export const gitlabCreatePipelineTool: ToolConfig< if (params.variables && params.variables.length > 0) { body.variables = params.variables } + if (params.inputs && Object.keys(params.inputs).length > 0) { + body.inputs = params.inputs + } return body }, diff --git a/apps/sim/tools/gitlab/create_release.ts b/apps/sim/tools/gitlab/create_release.ts index 745709c876a..776f30055b7 100644 --- a/apps/sim/tools/gitlab/create_release.ts +++ b/apps/sim/tools/gitlab/create_release.ts @@ -60,6 +60,19 @@ export const gitlabCreateReleaseTool: ToolConfig< visibility: 'user-or-llm', description: 'ISO 8601 date for an upcoming or historical release', }, + tagMessage: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Annotation message to use if creating a new annotated tag', + }, + assetLinks: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: + 'Release asset links: array of objects with name, url, and optional link_type (other, runbook, image, package)', + }, milestones: { type: 'array', required: false, @@ -83,6 +96,13 @@ export const gitlabCreateReleaseTool: ToolConfig< tag_name: params.tagName, } + if (params.tagMessage) body.tag_message = params.tagMessage + if (params.assetLinks) { + // Tolerate a single link object by wrapping it into the array GitLab expects. + const links = Array.isArray(params.assetLinks) ? params.assetLinks : [params.assetLinks] + if (links.length > 0) body.assets = { links } + } + if (params.name) body.name = params.name if (params.description) body.description = params.description if (params.ref) body.ref = params.ref diff --git a/apps/sim/tools/gitlab/invite_member.ts b/apps/sim/tools/gitlab/invite_member.ts index c02ff0f66ce..601b8d26db7 100644 --- a/apps/sim/tools/gitlab/invite_member.ts +++ b/apps/sim/tools/gitlab/invite_member.ts @@ -47,7 +47,7 @@ export const gitlabInviteMemberTool: ToolConfig< required: true, visibility: 'user-or-llm', description: - 'Access level: 10 (Guest), 15 (Planner), 20 (Reporter), 25 (Security Manager), 30 (Developer), 40 (Maintainer), 50 (Owner)', + 'Access level: 0 (No access), 5 (Minimal), 10 (Guest), 15 (Planner), 20 (Reporter), 25 (Security Manager), 30 (Developer), 40 (Maintainer), 50 (Owner)', }, expiresAt: { type: 'string', @@ -61,6 +61,12 @@ export const gitlabInviteMemberTool: ToolConfig< visibility: 'user-or-llm', description: 'Custom member role ID (GitLab Ultimate only)', }, + inviteSource: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Identifier recorded as the source of the invitation (for attribution)', + }, }, request: { @@ -90,6 +96,7 @@ export const gitlabInviteMemberTool: ToolConfig< if (params.expiresAt) body.expires_at = params.expiresAt if (params.memberRoleId !== undefined) body.member_role_id = params.memberRoleId + if (params.inviteSource?.trim()) body.invite_source = params.inviteSource.trim() return body }, diff --git a/apps/sim/tools/gitlab/list_members.ts b/apps/sim/tools/gitlab/list_members.ts index b17429c0e8a..700df34d87b 100644 --- a/apps/sim/tools/gitlab/list_members.ts +++ b/apps/sim/tools/gitlab/list_members.ts @@ -48,6 +48,25 @@ export const gitlabListMembersTool: ToolConfig ({ + 'Content-Type': 'application/json', 'PRIVATE-TOKEN': params.accessToken, }), + body: (params) => { + const body: Record = {} + if (params.jobVariables && params.jobVariables.length > 0) { + body.job_variables_attributes = params.jobVariables + } + return body + }, }, transformResponse: async (response) => { diff --git a/apps/sim/tools/gitlab/remove_member.ts b/apps/sim/tools/gitlab/remove_member.ts index 44980ea39ee..5ee3d2ce5bb 100644 --- a/apps/sim/tools/gitlab/remove_member.ts +++ b/apps/sim/tools/gitlab/remove_member.ts @@ -42,12 +42,30 @@ export const gitlabRemoveMemberTool: ToolConfig< visibility: 'user-or-llm', description: 'The ID of the member to remove', }, + skipSubresources: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + 'Skip deleting the member from subgroups and projects below the target (defaults to false)', + }, + unassignIssuables: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + 'Unassign the member from all issues and merge requests in the target (defaults to false)', + }, }, request: { url: (params) => { const resourcePath = getGitLabResourcePath(params.resourceType, params.resourceId) - return `${getGitLabApiBase(params.host)}/${resourcePath}/members/${params.userId}` + const queryParams = new URLSearchParams() + if (params.skipSubresources) queryParams.append('skip_subresources', 'true') + if (params.unassignIssuables) queryParams.append('unassign_issuables', 'true') + const query = queryParams.toString() + return `${getGitLabApiBase(params.host)}/${resourcePath}/members/${params.userId}${query ? `?${query}` : ''}` }, method: 'DELETE', headers: (params) => ({ diff --git a/apps/sim/tools/gitlab/types.ts b/apps/sim/tools/gitlab/types.ts index decf17e0663..84f8cc9f08b 100644 --- a/apps/sim/tools/gitlab/types.ts +++ b/apps/sim/tools/gitlab/types.ts @@ -398,6 +398,8 @@ export interface GitLabCreatePipelineParams extends GitLabBaseParams { projectId: string | number ref: string variables?: Array<{ key: string; value: string; variable_type?: 'env_var' | 'file' }> + /** Pipeline inputs (spec:inputs) as a key/value object. */ + inputs?: Record } export interface GitLabRetryPipelineParams extends GitLabBaseParams { @@ -433,6 +435,8 @@ export interface GitLabCompareBranchesParams extends GitLabBaseParams { from: string to: string straight?: boolean + fromProjectId?: string | number + unidiff?: boolean } export interface GitLabListRepositoryTreeParams extends GitLabBaseParams { @@ -456,6 +460,10 @@ export interface GitLabCreateFileParams extends GitLabBaseParams { branch: string content: string commitMessage: string + startBranch?: string + authorName?: string + authorEmail?: string + executeFilemode?: boolean } export interface GitLabUpdateFileParams extends GitLabBaseParams { @@ -465,6 +473,10 @@ export interface GitLabUpdateFileParams extends GitLabBaseParams { content: string commitMessage: string lastCommitId?: string + startBranch?: string + authorName?: string + authorEmail?: string + executeFilemode?: boolean } export interface GitLabListCommitsParams extends GitLabBaseParams { @@ -506,18 +518,21 @@ export interface GitLabGetJobLogParams extends GitLabBaseParams { export interface GitLabPlayJobParams extends GitLabBaseParams { projectId: string | number jobId: number + jobVariables?: Array<{ key: string; value: string }> } export interface GitLabCreateIssueNoteParams extends GitLabBaseParams { projectId: string | number issueIid: number body: string + internal?: boolean } export interface GitLabCreateMergeRequestNoteParams extends GitLabBaseParams { projectId: string | number mergeRequestIid: number body: string + internal?: boolean } export interface GitLabListReleasesParams extends GitLabBaseParams { @@ -536,6 +551,13 @@ export interface GitLabCreateReleaseParams extends GitLabBaseParams { ref?: string releasedAt?: string milestones?: string[] + tagMessage?: string + assetLinks?: Array<{ + name: string + url: string + link_type?: 'other' | 'runbook' | 'image' | 'package' + direct_asset_path?: string + }> } export interface GitLabListProjectsResponse extends ToolResponse { @@ -842,12 +864,19 @@ 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 + /** Comma-separated user IDs to filter to. */ + userIds?: string + /** 'awaiting' | 'active' — inherited-member listings only (Premium/Ultimate). */ + state?: string + showSeatInfo?: boolean perPage?: number page?: number } export interface GitLabAddMemberParams extends GitLabResourceScopedParams { - userId: number + /** Provide either userId or username to identify the user. */ + userId?: number + username?: string accessLevel: number expiresAt?: string memberRoleId?: number @@ -862,6 +891,8 @@ export interface GitLabUpdateMemberParams extends GitLabResourceScopedParams { export interface GitLabRemoveMemberParams extends GitLabResourceScopedParams { userId: number + skipSubresources?: boolean + unassignIssuables?: boolean } export interface GitLabInviteMemberParams extends GitLabResourceScopedParams { @@ -869,6 +900,7 @@ export interface GitLabInviteMemberParams extends GitLabResourceScopedParams { accessLevel: number expiresAt?: string memberRoleId?: number + inviteSource?: string } export interface GitLabListInvitationsParams extends GitLabResourceScopedParams { diff --git a/apps/sim/tools/gitlab/update_file.ts b/apps/sim/tools/gitlab/update_file.ts index 27836c0057e..c3381453d8a 100644 --- a/apps/sim/tools/gitlab/update_file.ts +++ b/apps/sim/tools/gitlab/update_file.ts @@ -45,6 +45,30 @@ export const gitlabUpdateFileTool: ToolConfig Date: Thu, 16 Jul 2026 11:47:15 -0700 Subject: [PATCH 13/13] fix(gitlab): tri-state controls for update-op booleans (executable flag, MR squash/remove-source-branch) --- apps/sim/blocks/blocks/gitlab.ts | 82 +++++++++++++++++++++++++------- 1 file changed, 66 insertions(+), 16 deletions(-) diff --git a/apps/sim/blocks/blocks/gitlab.ts b/apps/sim/blocks/blocks/gitlab.ts index 8842c970869..36d5b5c6d7b 100644 --- a/apps/sim/blocks/blocks/gitlab.ts +++ b/apps/sim/blocks/blocks/gitlab.ts @@ -114,6 +114,13 @@ function parseJsonParam(raw: unknown, label: string): any { return parsed } +/** Maps a "Leave unchanged / Yes / No" dropdown value to true, false, or undefined. */ +function parseTriState(raw: unknown): boolean | undefined { + if (raw === 'true' || raw === true) return true + if (raw === 'false' || raw === false) return false + return undefined +} + export const GitLabBlock: BlockConfig = { type: 'gitlab', name: 'GitLab', @@ -887,11 +894,41 @@ Return ONLY the timestamp string - no explanations, no extra text.`, mode: 'advanced', condition: { field: 'operation', - value: [ - 'gitlab_create_merge_request', - 'gitlab_update_merge_request', - 'gitlab_merge_merge_request', - ], + value: ['gitlab_create_merge_request', 'gitlab_merge_merge_request'], + }, + }, + // Tri-state variants for Update MR: a switch cannot express "turn this + // off" without also clobbering the setting on every unrelated update. + { + id: 'updateRemoveSourceBranch', + title: 'Remove Source Branch', + type: 'dropdown', + options: [ + { label: 'Leave unchanged', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_update_merge_request'], + }, + }, + { + id: 'updateSquash', + title: 'Squash Commits', + type: 'dropdown', + options: [ + { label: 'Leave unchanged', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_update_merge_request'], }, }, // Squash commits @@ -902,11 +939,7 @@ Return ONLY the timestamp string - no explanations, no extra text.`, mode: 'advanced', condition: { field: 'operation', - value: [ - 'gitlab_create_merge_request', - 'gitlab_update_merge_request', - 'gitlab_merge_merge_request', - ], + value: ['gitlab_create_merge_request', 'gitlab_merge_merge_request'], }, }, // Merge commit message @@ -1273,9 +1306,14 @@ Return ONLY the JSON array - no explanations, no extra text.`, { id: 'executeFilemode', title: 'Executable', - type: 'switch', + type: 'dropdown', + options: [ + { label: 'Leave unchanged', id: '' }, + { label: 'Enabled', id: 'true' }, + { label: 'Disabled', id: 'false' }, + ], + value: () => '', mode: 'advanced', - description: 'Enable the execute flag on the file', condition: { field: 'operation', value: ['gitlab_create_file', 'gitlab_update_file'], @@ -2079,8 +2117,8 @@ Return ONLY the JSON array - no explanations, no extra text.`, : undefined, milestoneId: params.milestoneId ? Number(params.milestoneId) : undefined, stateEvent: params.stateEvent || undefined, - removeSourceBranch: params.removeSourceBranch || undefined, - squash: params.squash || undefined, + removeSourceBranch: parseTriState(params.updateRemoveSourceBranch), + squash: parseTriState(params.updateSquash), } case 'gitlab_merge_merge_request': @@ -2215,7 +2253,7 @@ Return ONLY the JSON array - no explanations, no extra text.`, startBranch: params.startBranch?.trim() || undefined, authorName: params.authorName?.trim() || undefined, authorEmail: params.authorEmail?.trim() || undefined, - executeFilemode: params.executeFilemode || undefined, + executeFilemode: parseTriState(params.executeFilemode), ...(params.operation === 'gitlab_update_file' ? { lastCommitId: params.lastCommitId?.trim() || undefined } : {}), @@ -2735,7 +2773,10 @@ Return ONLY the JSON array - no explanations, no extra text.`, startBranch: { type: 'string', description: 'Base branch for new-branch file commits' }, authorName: { type: 'string', description: 'Commit author name override' }, authorEmail: { type: 'string', description: 'Commit author email override' }, - executeFilemode: { type: 'boolean', description: 'Enable the execute flag on the file' }, + executeFilemode: { + type: 'string', + description: "Execute flag on the file ('true', 'false', or '' to leave unchanged)", + }, fromProjectId: { type: 'string', description: 'Project to compare from (cross-fork)' }, unidiff: { type: 'boolean', description: 'Return diffs in unified diff format' }, internalNote: { type: 'boolean', description: 'Create the comment as an internal note' }, @@ -2750,6 +2791,15 @@ Return ONLY the JSON array - no explanations, no extra text.`, pipelineStatus: { type: 'string', description: 'Pipeline status filter' }, removeSourceBranch: { type: 'boolean', description: 'Remove source branch after merge' }, squash: { type: 'boolean', description: 'Squash commits on merge' }, + updateSquash: { + type: 'string', + description: "Squash setting on MR update ('true', 'false', or '' to leave unchanged)", + }, + updateRemoveSourceBranch: { + type: 'string', + description: + "Remove-source-branch setting on MR update ('true', 'false', or '' to leave unchanged)", + }, mergeCommitMessage: { type: 'string', description: 'Custom merge commit message' }, perPage: { type: 'number', description: 'Results per page' }, page: { type: 'number', description: 'Page number' },