From 7531008df964a5c738f8c86de769563e6ace3463 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Thu, 16 Jul 2026 19:12:12 -0700 Subject: [PATCH 1/4] feat(gitlab): dynamic access levels + group and membership operations Access levels can now be bound to runtime references (e.g. a policy-table lookup), not just picked from a static list. The three access-level fields (accessLevel, memberAccessLevel, invitationAccessLevel) switch from dropdown to combobox so a reference expression is accepted at Copilot save-time instead of being rejected as an invalid enum value. The resolved value is validated at execution time by coerceGitLabAccessLevel, which accepts an integer, a numeric string, or a level name ("Developer"), and throws a clear error otherwise - preserving the real safety property (no bad integer reaches GitLab) while dropping the false one (levels must be known at design time). Also closes demand gaps from the AskIT data: - get_group / list_groups: resolve and list groups (provisioning critical path) - list_user_memberships: admin GET /users/:id/memberships, gated in its description; the non-admin path is composing list_members The access-level enum is now a single source of truth in tools/gitlab/utils.ts (GITLAB_ACCESS_LEVELS) that the block options and runtime coercion both derive from, replacing three inline copies. --- apps/sim/blocks/blocks/gitlab.test.ts | 98 +++++++++- apps/sim/blocks/blocks/gitlab.ts | 172 ++++++++++++------ apps/sim/tools/gitlab/get_group.ts | 68 +++++++ apps/sim/tools/gitlab/groups.test.ts | 153 ++++++++++++++++ apps/sim/tools/gitlab/index.ts | 7 + apps/sim/tools/gitlab/list_groups.ts | 120 ++++++++++++ .../sim/tools/gitlab/list_user_memberships.ts | 108 +++++++++++ apps/sim/tools/gitlab/types.ts | 68 +++++++ apps/sim/tools/gitlab/utils.test.ts | 48 +++++ apps/sim/tools/gitlab/utils.ts | 97 +++++++++- apps/sim/tools/registry.ts | 6 + 11 files changed, 887 insertions(+), 58 deletions(-) create mode 100644 apps/sim/tools/gitlab/get_group.ts create mode 100644 apps/sim/tools/gitlab/groups.test.ts create mode 100644 apps/sim/tools/gitlab/list_groups.ts create mode 100644 apps/sim/tools/gitlab/list_user_memberships.ts diff --git a/apps/sim/blocks/blocks/gitlab.test.ts b/apps/sim/blocks/blocks/gitlab.test.ts index fa959a3fae5..4eb7d441ffa 100644 --- a/apps/sim/blocks/blocks/gitlab.test.ts +++ b/apps/sim/blocks/blocks/gitlab.test.ts @@ -25,15 +25,16 @@ describe('GitLabBlock access operations', () => { } }) - it('exposes the named access-level dropdown with GitLab integer ids', () => { + it('exposes the named access-level combobox with GitLab integer ids', () => { const accessLevel = block.subBlocks.find((s) => s.id === 'accessLevel') - expect(accessLevel?.type).toBe('dropdown') + // A combobox (not a dropdown) so the level can be bound to a runtime reference. + expect(accessLevel?.type).toBe('combobox') 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', () => { + it('coerces the selected access level from the combobox string to an integer at execution time', () => { const addParams = block.tools.config.params?.({ accessToken: 'pat', operation: 'gitlab_add_member', @@ -55,6 +56,32 @@ describe('GitLabBlock access operations', () => { expect(typeof addParams?.accessLevel).toBe('number') }) + it('accepts an access level bound by name (from a resolved reference) and coerces it', () => { + const byName = block.tools.config.params?.({ + accessToken: 'pat', + operation: 'gitlab_add_member', + resourceType: 'group', + resourceId: '42', + userId: '7', + accessLevel: 'Developer', + }) + expect(byName).toMatchObject({ userId: 7, accessLevel: 30 }) + expect(typeof byName?.accessLevel).toBe('number') + }) + + it('throws loudly when a resolved access level is not a valid GitLab level', () => { + expect(() => + block.tools.config.params?.({ + accessToken: 'pat', + operation: 'gitlab_add_member', + resourceType: 'group', + resourceId: '42', + userId: '7', + accessLevel: 'root', + }) + ).toThrow(/access level/i) + }) + it('defaults list members to inherited members (directOnly falsy)', () => { const listParams = block.tools.config.params?.({ accessToken: 'pat', @@ -117,7 +144,7 @@ describe('GitLabBlock access operations', () => { 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?.type).toBe('combobox') expect(invAccess?.value?.()).toBe('') const options = typeof invAccess?.options === 'function' ? undefined : invAccess?.options expect(options?.[0]).toEqual({ label: 'Leave unchanged', id: '' }) @@ -158,3 +185,66 @@ describe('GitLabBlock access operations', () => { ).toThrow() }) }) + +describe('GitLabBlock group operations', () => { + it('registers and routes the new group/membership operations', () => { + for (const toolId of [ + 'gitlab_list_groups', + 'gitlab_get_group', + 'gitlab_list_user_memberships', + ]) { + expect(block.tools.access).toContain(toolId) + expect(block.tools.config.tool?.({ operation: toolId })).toBe(toolId) + } + }) + + it('maps list-groups filters to tool params', () => { + const params = block.tools.config.params?.({ + accessToken: 'pat', + operation: 'gitlab_list_groups', + owned: true, + searchQuery: 'plat', + groupsTopLevelOnly: true, + perPage: '50', + page: '2', + }) + expect(params).toMatchObject({ + owned: true, + search: 'plat', + topLevelOnly: true, + perPage: 50, + page: 2, + }) + }) + + it('requires a group id for get group', () => { + expect(() => + block.tools.config.params?.({ accessToken: 'pat', operation: 'gitlab_get_group' }) + ).toThrow(/group id/i) + + const params = block.tools.config.params?.({ + accessToken: 'pat', + operation: 'gitlab_get_group', + groupId: ' parent/child ', + }) + expect(params).toMatchObject({ groupId: 'parent/child' }) + }) + + it('requires a user id for list user memberships and forwards the type filter', () => { + expect(() => + block.tools.config.params?.({ + accessToken: 'pat', + operation: 'gitlab_list_user_memberships', + }) + ).toThrow(/user id/i) + + const params = block.tools.config.params?.({ + accessToken: 'pat', + operation: 'gitlab_list_user_memberships', + userId: '7', + membershipType: 'Namespace', + perPage: '25', + }) + expect(params).toMatchObject({ userId: '7', membershipType: 'Namespace', perPage: 25 }) + }) +}) diff --git a/apps/sim/blocks/blocks/gitlab.ts b/apps/sim/blocks/blocks/gitlab.ts index 36d5b5c6d7b..0cb3c55e6ea 100644 --- a/apps/sim/blocks/blocks/gitlab.ts +++ b/apps/sim/blocks/blocks/gitlab.ts @@ -2,6 +2,7 @@ import { GitLabIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' import type { GitLabResponse } from '@/tools/gitlab/types' +import { coerceGitLabAccessLevel, GITLAB_ACCESS_LEVEL_OPTIONS } from '@/tools/gitlab/utils' import { getTrigger } from '@/triggers' /** @@ -40,6 +41,7 @@ const USER_ID_OPS = [ 'gitlab_approve_user', 'gitlab_reject_user', 'gitlab_delete_user_identity', + 'gitlab_list_user_memberships', ] /** @@ -90,6 +92,9 @@ 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'] +/** Operations that take a single group ID (SAML links plus Get Group). */ +const GROUP_ID_OPS = [...SAML_LINK_OPS, 'gitlab_get_group'] + /** 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') @@ -143,6 +148,9 @@ export const GitLabBlock: BlockConfig = { // Project Operations { label: 'List Projects', id: 'gitlab_list_projects' }, { label: 'Get Project', id: 'gitlab_get_project' }, + // Group Operations + { label: 'List Groups', id: 'gitlab_list_groups' }, + { label: 'Get Group', id: 'gitlab_get_group' }, // Issue Operations { label: 'List Issues', id: 'gitlab_list_issues' }, { label: 'Get Issue', id: 'gitlab_get_issue' }, @@ -196,6 +204,7 @@ export const GitLabBlock: BlockConfig = { { 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: 'List User Memberships', id: 'gitlab_list_user_memberships' }, { label: 'Search Users', id: 'gitlab_search_users' }, // User Administration Operations (require an admin token) { label: 'Create User', id: 'gitlab_create_user' }, @@ -967,23 +976,34 @@ Return ONLY the commit message - no explanations, no extra text.`, id: 'searchQuery', title: 'Search', type: 'short-input', - placeholder: 'Search projects (name/path/description) or issues (title/description)', + placeholder: 'Search projects/groups (name/path) or issues (title/description)', mode: 'advanced', condition: { field: 'operation', - value: ['gitlab_list_projects', 'gitlab_list_issues'], + value: ['gitlab_list_projects', 'gitlab_list_groups', 'gitlab_list_issues'], }, }, - // List-projects filters + // List-projects / list-groups filters { id: 'owned', title: 'Owned Only', type: 'switch', mode: 'advanced', - description: 'Only projects explicitly owned by the current user', + description: 'Only resources explicitly owned by the current user', condition: { field: 'operation', - value: ['gitlab_list_projects'], + value: ['gitlab_list_projects', 'gitlab_list_groups'], + }, + }, + { + id: 'groupsTopLevelOnly', + title: 'Top-Level Only', + type: 'switch', + mode: 'advanced', + description: 'Only top-level groups, excluding subgroups', + condition: { + field: 'operation', + value: ['gitlab_list_groups'], }, }, { @@ -1365,6 +1385,7 @@ Return ONLY the JSON array - no explanations, no extra text.`, field: 'operation', value: [ 'gitlab_list_projects', + 'gitlab_list_groups', 'gitlab_list_issues', 'gitlab_list_merge_requests', 'gitlab_list_pipelines', @@ -1376,6 +1397,7 @@ Return ONLY the JSON array - no explanations, no extra text.`, 'gitlab_list_members', 'gitlab_list_invitations', 'gitlab_list_access_requests', + 'gitlab_list_user_memberships', 'gitlab_search_users', ], }, @@ -1391,6 +1413,7 @@ Return ONLY the JSON array - no explanations, no extra text.`, field: 'operation', value: [ 'gitlab_list_projects', + 'gitlab_list_groups', 'gitlab_list_issues', 'gitlab_list_merge_requests', 'gitlab_list_pipelines', @@ -1402,6 +1425,7 @@ Return ONLY the JSON array - no explanations, no extra text.`, 'gitlab_list_members', 'gitlab_list_invitations', 'gitlab_list_access_requests', + 'gitlab_list_user_memberships', 'gitlab_search_users', ], }, @@ -1443,7 +1467,7 @@ Return ONLY the JSON array - no explanations, no extra text.`, required: true, condition: { field: 'operation', - value: SAML_LINK_OPS, + value: GROUP_ID_OPS, }, }, // User ID (member target or admin user target) @@ -1473,22 +1497,32 @@ Return ONLY the JSON array - no explanations, no extra text.`, value: ['gitlab_add_member'], }, }, - // Access level (named dropdown mapping to GitLab integer access levels) + // Membership source filter for List User Memberships { - id: 'accessLevel', - title: 'Access Level', + id: 'membershipType', + title: 'Membership Type', 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' }, + { label: 'All', id: '' }, + { label: 'Projects', id: 'Project' }, + { label: 'Groups', id: 'Namespace' }, ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_user_memberships'], + }, + }, + // Access level. A combobox (not a dropdown) so the level can be bound to a + // runtime reference (e.g. a policy-table lookup) as well as picked by name. + // The resolved value is validated against the enum at execution time by + // coerceGitLabAccessLevel, which also accepts the level name ("Developer"). + { + id: 'accessLevel', + title: 'Access Level', + type: 'combobox', + options: GITLAB_ACCESS_LEVEL_OPTIONS, value: () => '30', required: { field: 'operation', @@ -1505,18 +1539,8 @@ Return ONLY the JSON array - no explanations, no extra text.`, { 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' }, - ], + type: 'combobox', + options: GITLAB_ACCESS_LEVEL_OPTIONS, required: true, condition: { field: 'operation', @@ -1528,19 +1552,8 @@ Return ONLY the JSON array - no explanations, no extra text.`, { 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' }, - ], + type: 'combobox', + options: [{ label: 'Leave unchanged', id: '' }, ...GITLAB_ACCESS_LEVEL_OPTIONS], value: () => '', condition: { field: 'operation', @@ -1865,6 +1878,9 @@ Return ONLY the JSON array - no explanations, no extra text.`, access: [ 'gitlab_list_projects', 'gitlab_get_project', + 'gitlab_list_groups', + 'gitlab_get_group', + 'gitlab_list_user_memberships', 'gitlab_list_issues', 'gitlab_get_issue', 'gitlab_create_issue', @@ -1959,6 +1975,39 @@ Return ONLY the JSON array - no explanations, no extra text.`, projectId: params.projectId.trim(), } + case 'gitlab_list_groups': + return { + ...baseParams, + owned: params.owned || undefined, + search: params.searchQuery?.trim() || undefined, + topLevelOnly: params.groupsTopLevelOnly || undefined, + perPage: params.perPage ? Number(params.perPage) : undefined, + page: params.page ? Number(params.page) : undefined, + } + + case 'gitlab_get_group': + if (!params.groupId?.trim()) { + throw new Error('Group ID is required.') + } + return { + ...baseParams, + groupId: params.groupId.trim(), + } + + case 'gitlab_list_user_memberships': { + const membershipUserId = String(params.userId ?? '').trim() + if (!membershipUserId) { + throw new Error('User ID is required.') + } + return { + ...baseParams, + userId: membershipUserId, + membershipType: params.membershipType || undefined, + perPage: params.perPage ? Number(params.perPage) : undefined, + page: params.page ? Number(params.page) : undefined, + } + } + case 'gitlab_list_issues': if (!params.projectId?.trim()) { throw new Error('Project ID is required.') @@ -2461,7 +2510,7 @@ Return ONLY the JSON array - no explanations, no extra text.`, resourceId: params.resourceId.trim(), userId: addMemberUserId ? Number(addMemberUserId) : undefined, username: params.username?.trim() || undefined, - accessLevel: Number(params.accessLevel), + accessLevel: coerceGitLabAccessLevel(params.accessLevel), expiresAt: params.expiresAt?.trim() || undefined, memberRoleId: params.memberRoleId ? Number(params.memberRoleId) : undefined, } @@ -2476,7 +2525,7 @@ Return ONLY the JSON array - no explanations, no extra text.`, resourceType: params.resourceType || 'project', resourceId: params.resourceId.trim(), userId: Number(params.userId), - accessLevel: Number(params.memberAccessLevel), + accessLevel: coerceGitLabAccessLevel(params.memberAccessLevel), expiresAt: params.clearExpiresAt ? '' : params.expiresAt?.trim() || undefined, memberRoleId: params.memberRoleId ? Number(params.memberRoleId) : undefined, } @@ -2503,7 +2552,7 @@ Return ONLY the JSON array - no explanations, no extra text.`, resourceType: params.resourceType || 'project', resourceId: params.resourceId.trim(), email: params.email.trim(), - accessLevel: Number(params.accessLevel), + accessLevel: coerceGitLabAccessLevel(params.accessLevel), expiresAt: params.expiresAt?.trim() || undefined, memberRoleId: params.memberRoleId ? Number(params.memberRoleId) : undefined, inviteSource: params.inviteSource?.trim() || undefined, @@ -2543,7 +2592,7 @@ Return ONLY the JSON array - no explanations, no extra text.`, // 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) + ? coerceGitLabAccessLevel(params.invitationAccessLevel) : undefined, expiresAt: params.clearExpiresAt ? '' : params.expiresAt?.trim() || undefined, } @@ -2580,7 +2629,9 @@ Return ONLY the JSON array - no explanations, no extra text.`, resourceType: params.resourceType || 'project', resourceId: params.resourceId.trim(), userId: Number(params.userId), - accessLevel: params.accessLevel ? Number(params.accessLevel) : undefined, + accessLevel: params.accessLevel + ? coerceGitLabAccessLevel(params.accessLevel) + : undefined, } case 'gitlab_deny_access_request': @@ -2611,7 +2662,7 @@ Return ONLY the JSON array - no explanations, no extra text.`, ...baseParams, groupId: params.groupId.trim(), samlGroupName: params.samlGroupName.trim(), - accessLevel: Number(params.accessLevel), + accessLevel: coerceGitLabAccessLevel(params.accessLevel), memberRoleId: params.memberRoleId ? Number(params.memberRoleId) : undefined, provider: params.samlProvider?.trim() || undefined, } @@ -2831,6 +2882,14 @@ Return ONLY the JSON array - no explanations, no extra text.`, 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' }, + groupsTopLevelOnly: { + type: 'boolean', + description: 'Limit group listings to top-level groups, excluding subgroups', + }, + membershipType: { + type: 'string', + description: "Membership source filter ('Project' or 'Namespace'; '' for all)", + }, userId: { type: 'number', description: 'Target user ID' }, username: { type: 'string', description: 'Username alternative for adding a member' }, skipSubresources: { @@ -2845,14 +2904,19 @@ Return ONLY the JSON array - no explanations, no extra text.`, 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)' }, + accessLevel: { + type: 'string', + description: 'GitLab access level - name ("Developer") or integer (30). Accepts a reference.', + }, memberAccessLevel: { type: 'string', - description: 'Access level for member updates (explicit choice, no default)', + description: + 'Access level for member updates - name or integer, no default (explicit choice). Accepts a reference.', }, invitationAccessLevel: { type: 'string', - description: 'Optional new access level for an invitation ("" leaves it unchanged)', + description: + 'Optional new access level for an invitation - name or integer ("" leaves it unchanged). Accepts a reference.', }, expiresAt: { type: 'string', description: 'Access expiration date (YYYY-MM-DD)' }, clearExpiresAt: { @@ -2890,6 +2954,10 @@ Return ONLY the JSON array - no explanations, no extra text.`, // Project outputs projects: { type: 'json', description: 'List of projects' }, project: { type: 'json', description: 'Project details' }, + // Group outputs + groups: { type: 'json', description: 'List of groups' }, + group: { type: 'json', description: 'Group details' }, + memberships: { type: 'json', description: "A user's project and group memberships" }, // Issue outputs issues: { type: 'json', description: 'List of issues' }, issue: { type: 'json', description: 'Issue details' }, diff --git a/apps/sim/tools/gitlab/get_group.ts b/apps/sim/tools/gitlab/get_group.ts new file mode 100644 index 00000000000..95cc8009763 --- /dev/null +++ b/apps/sim/tools/gitlab/get_group.ts @@ -0,0 +1,68 @@ +import type { GitLabGetGroupParams, GitLabGetGroupResponse } from '@/tools/gitlab/types' +import { getGitLabApiBase, getGitLabResourcePath } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabGetGroupTool: ToolConfig = { + id: 'gitlab_get_group', + name: 'GitLab Get Group', + description: 'Get details of a specific GitLab 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.', + }, + groupId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Group ID or path (e.g. mygroup or parent/subgroup)', + }, + }, + + request: { + url: (params) => { + return `${getGitLabApiBase(params.host)}/${getGitLabResourcePath('group', params.groupId)}` + }, + 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 group = await response.json() + + return { + success: true, + output: { + group, + }, + } + }, + + outputs: { + group: { + type: 'object', + description: 'The GitLab group details', + }, + }, +} diff --git a/apps/sim/tools/gitlab/groups.test.ts b/apps/sim/tools/gitlab/groups.test.ts new file mode 100644 index 00000000000..863c529ef51 --- /dev/null +++ b/apps/sim/tools/gitlab/groups.test.ts @@ -0,0 +1,153 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { gitlabGetGroupTool } from '@/tools/gitlab/get_group' +import { gitlabListGroupsTool } from '@/tools/gitlab/list_groups' +import { gitlabListUserMembershipsTool } from '@/tools/gitlab/list_user_memberships' + +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 +} + +describe('gitlab_get_group', () => { + it('builds the group endpoint and URL-encodes a namespaced path', () => { + expect(gitlabGetGroupTool.request.url({ accessToken: 'pat', groupId: '42' })).toBe( + 'https://gitlab.com/api/v4/groups/42' + ) + expect(gitlabGetGroupTool.request.url({ accessToken: 'pat', groupId: 'parent/child' })).toBe( + 'https://gitlab.com/api/v4/groups/parent%2Fchild' + ) + }) + + it('honors a self-managed host', () => { + expect( + gitlabGetGroupTool.request.url({ + accessToken: 'pat', + host: 'gitlab.example.com', + groupId: '7', + }) + ).toBe('https://gitlab.example.com/api/v4/groups/7') + }) + + it('returns the group on success', async () => { + const result = await gitlabGetGroupTool.transformResponse?.( + mockResponse({ json: { id: 42, name: 'Platform' } }), + {} as never + ) + expect(result?.success).toBe(true) + expect(result?.output.group).toEqual({ id: 42, name: 'Platform' }) + }) + + it('surfaces API errors', async () => { + const result = await gitlabGetGroupTool.transformResponse?.( + mockResponse({ ok: false, status: 404, text: 'Not found' }), + {} as never + ) + expect(result?.success).toBe(false) + expect(result?.error).toContain('404') + }) +}) + +describe('gitlab_list_groups', () => { + it('lists groups with no filters', () => { + expect(gitlabListGroupsTool.request.url({ accessToken: 'pat' })).toBe( + 'https://gitlab.com/api/v4/groups' + ) + }) + + it('forwards filters and pagination', () => { + const url = gitlabListGroupsTool.request.url({ + accessToken: 'pat', + owned: true, + search: 'plat', + topLevelOnly: true, + orderBy: 'name', + sort: 'asc', + perPage: 50, + page: 2, + }) + expect(url).toBe( + 'https://gitlab.com/api/v4/groups?owned=true&search=plat&top_level_only=true&order_by=name&sort=asc&per_page=50&page=2' + ) + }) + + it('reads the total from the x-total header, falling back to length', async () => { + const withHeader = await gitlabListGroupsTool.transformResponse?.( + mockResponse({ json: [{ id: 1 }, { id: 2 }], headers: { 'x-total': '17' } }), + {} as never + ) + expect(withHeader?.output.total).toBe(17) + + const withoutHeader = await gitlabListGroupsTool.transformResponse?.( + mockResponse({ json: [{ id: 1 }, { id: 2 }] }), + {} as never + ) + expect(withoutHeader?.output.total).toBe(2) + }) +}) + +describe('gitlab_list_user_memberships', () => { + it('builds the admin memberships endpoint', () => { + expect(gitlabListUserMembershipsTool.request.url({ accessToken: 'pat', userId: '7' })).toBe( + 'https://gitlab.com/api/v4/users/7/memberships' + ) + }) + + it('forwards the type filter and pagination', () => { + const url = gitlabListUserMembershipsTool.request.url({ + accessToken: 'pat', + userId: '7', + membershipType: 'Namespace', + perPage: 25, + page: 3, + }) + expect(url).toBe( + 'https://gitlab.com/api/v4/users/7/memberships?type=Namespace&per_page=25&page=3' + ) + }) + + it('returns memberships on success', async () => { + const result = await gitlabListUserMembershipsTool.transformResponse?.( + mockResponse({ + json: [{ source_id: 1, source_name: 'grp', source_type: 'Namespace', access_level: 30 }], + headers: { 'x-total': '1' }, + }), + {} as never + ) + expect(result?.success).toBe(true) + expect(result?.output.memberships).toHaveLength(1) + expect(result?.output.total).toBe(1) + }) + + it('surfaces a 403 for a non-admin token', async () => { + const result = await gitlabListUserMembershipsTool.transformResponse?.( + mockResponse({ ok: false, status: 403, text: 'Forbidden' }), + {} as never + ) + expect(result?.success).toBe(false) + expect(result?.error).toContain('403') + }) +}) diff --git a/apps/sim/tools/gitlab/index.ts b/apps/sim/tools/gitlab/index.ts index 6bcf8513b91..0228be6568c 100644 --- a/apps/sim/tools/gitlab/index.ts +++ b/apps/sim/tools/gitlab/index.ts @@ -20,6 +20,7 @@ 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 { gitlabGetGroupTool } from '@/tools/gitlab/get_group' import { gitlabGetIssueTool } from '@/tools/gitlab/get_issue' import { gitlabGetJobLogTool } from '@/tools/gitlab/get_job_log' import { gitlabGetMergeRequestTool } from '@/tools/gitlab/get_merge_request' @@ -30,6 +31,7 @@ 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 { gitlabListGroupsTool } from '@/tools/gitlab/list_groups' import { gitlabListInvitationsTool } from '@/tools/gitlab/list_invitations' import { gitlabListIssuesTool } from '@/tools/gitlab/list_issues' import { gitlabListMembersTool } from '@/tools/gitlab/list_members' @@ -40,6 +42,7 @@ 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 { gitlabListUserMembershipsTool } from '@/tools/gitlab/list_user_memberships' import { gitlabMergeMergeRequestTool } from '@/tools/gitlab/merge_merge_request' import { gitlabPlayJobTool } from '@/tools/gitlab/play_job' import { gitlabRemoveMemberTool } from '@/tools/gitlab/remove_member' @@ -67,6 +70,9 @@ export { // Projects gitlabListProjectsTool, gitlabGetProjectTool, + // Groups + gitlabListGroupsTool, + gitlabGetGroupTool, // Issues gitlabListIssuesTool, gitlabGetIssueTool, @@ -121,6 +127,7 @@ export { gitlabApproveAccessRequestTool, gitlabDenyAccessRequestTool, gitlabListSamlGroupLinksTool, + gitlabListUserMembershipsTool, gitlabSearchUsersTool, // Users (Admin) gitlabCreateUserTool, diff --git a/apps/sim/tools/gitlab/list_groups.ts b/apps/sim/tools/gitlab/list_groups.ts new file mode 100644 index 00000000000..f10823cca2d --- /dev/null +++ b/apps/sim/tools/gitlab/list_groups.ts @@ -0,0 +1,120 @@ +import type { GitLabListGroupsParams, GitLabListGroupsResponse } from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabListGroupsTool: ToolConfig = { + id: 'gitlab_list_groups', + name: 'GitLab List Groups', + description: 'List GitLab groups accessible to the authenticated user', + 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.', + }, + owned: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Limit to groups owned by the current user', + }, + search: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Search groups by name or path', + }, + topLevelOnly: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Limit to top-level groups, excluding subgroups', + }, + orderBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Order by field (name, path, id)', + }, + sort: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Sort direction (asc, desc)', + }, + 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() + if (params.owned) queryParams.append('owned', 'true') + if (params.search) queryParams.append('search', params.search) + if (params.topLevelOnly) queryParams.append('top_level_only', 'true') + if (params.orderBy) queryParams.append('order_by', params.orderBy) + if (params.sort) queryParams.append('sort', params.sort) + 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)}/groups${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 groups = await response.json() + const total = response.headers.get('x-total') + + return { + success: true, + output: { + groups, + total: total ? Number.parseInt(total, 10) : groups.length, + }, + } + }, + + outputs: { + groups: { + type: 'array', + description: 'List of GitLab groups', + }, + total: { + type: 'number', + description: 'Total number of groups', + }, + }, +} diff --git a/apps/sim/tools/gitlab/list_user_memberships.ts b/apps/sim/tools/gitlab/list_user_memberships.ts new file mode 100644 index 00000000000..9a722e33275 --- /dev/null +++ b/apps/sim/tools/gitlab/list_user_memberships.ts @@ -0,0 +1,108 @@ +import type { + GitLabListUserMembershipsParams, + GitLabListUserMembershipsResponse, +} from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabListUserMembershipsTool: ToolConfig< + GitLabListUserMembershipsParams, + GitLabListUserMembershipsResponse +> = { + id: 'gitlab_list_user_memberships', + name: 'GitLab List User Memberships', + description: + "List a user's project and group memberships. Requires an administrator access token (GET /users/:id/memberships is admin-only). For a non-admin path, iterate List Members on each project or group instead.", + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token (must belong to an administrator)', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + userId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the user whose memberships to list', + }, + membershipType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: "Filter by source: 'Project' or 'Namespace' (group). Omit for all memberships.", + }, + 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 encodedId = encodeURIComponent(String(params.userId).trim()) + const queryParams = new URLSearchParams() + if (params.membershipType) queryParams.append('type', params.membershipType) + 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)}/users/${encodedId}/memberships${ + 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 memberships = await response.json() + const total = response.headers.get('x-total') + + return { + success: true, + output: { + memberships, + total: total ? Number.parseInt(total, 10) : memberships.length, + }, + } + }, + + outputs: { + memberships: { + type: 'array', + description: "The user's project and group memberships", + }, + total: { + type: 'number', + description: 'Total number of memberships', + }, + }, +} diff --git a/apps/sim/tools/gitlab/types.ts b/apps/sim/tools/gitlab/types.ts index 84f8cc9f08b..efb81d28f74 100644 --- a/apps/sim/tools/gitlab/types.ts +++ b/apps/sim/tools/gitlab/types.ts @@ -25,6 +25,29 @@ interface GitLabProject { } } +interface GitLabGroup { + id: number + name: string + path: string + full_path: string + description?: string + visibility: string + web_url: string + parent_id?: number | null + created_at?: string +} + +/** + * A single project or group membership for a user, as returned by + * `GET /users/:id/memberships`. + */ +interface GitLabUserMembership { + source_id: number + source_name: string + source_type: 'Project' | 'Namespace' + access_level: number +} + interface GitLabIssue { id: number iid: number @@ -246,6 +269,28 @@ export interface GitLabGetProjectParams extends GitLabBaseParams { projectId: string | number } +export interface GitLabListGroupsParams extends GitLabBaseParams { + owned?: boolean + search?: string + topLevelOnly?: boolean + orderBy?: 'name' | 'path' | 'id' + sort?: 'asc' | 'desc' + perPage?: number + page?: number +} + +export interface GitLabGetGroupParams extends GitLabBaseParams { + groupId: string | number +} + +export interface GitLabListUserMembershipsParams extends GitLabBaseParams { + userId: string | number + /** Filter by membership source. Omit for all memberships. */ + membershipType?: 'Project' | 'Namespace' + perPage?: number + page?: number +} + export interface GitLabListIssuesParams extends GitLabBaseParams { projectId: string | number state?: 'opened' | 'closed' | 'all' @@ -573,6 +618,26 @@ export interface GitLabGetProjectResponse extends ToolResponse { } } +export interface GitLabListGroupsResponse extends ToolResponse { + output: { + groups?: GitLabGroup[] + total?: number + } +} + +export interface GitLabGetGroupResponse extends ToolResponse { + output: { + group?: GitLabGroup + } +} + +export interface GitLabListUserMembershipsResponse extends ToolResponse { + output: { + memberships?: GitLabUserMembership[] + total?: number + } +} + export interface GitLabListIssuesResponse extends ToolResponse { output: { issues?: GitLabIssue[] @@ -1118,6 +1183,9 @@ export interface GitLabDeleteUserIdentityResponse extends ToolResponse { export type GitLabResponse = | GitLabListProjectsResponse | GitLabGetProjectResponse + | GitLabListGroupsResponse + | GitLabGetGroupResponse + | GitLabListUserMembershipsResponse | GitLabListIssuesResponse | GitLabGetIssueResponse | GitLabCreateIssueResponse diff --git a/apps/sim/tools/gitlab/utils.test.ts b/apps/sim/tools/gitlab/utils.test.ts index 792aa66766e..b6705fb57b6 100644 --- a/apps/sim/tools/gitlab/utils.test.ts +++ b/apps/sim/tools/gitlab/utils.test.ts @@ -3,8 +3,10 @@ */ import { describe, expect, it } from 'vitest' import { + coerceGitLabAccessLevel, getGitLabApiBase, getGitLabResourcePath, + InvalidGitLabAccessLevelError, normalizeGitLabHost, UnsafeGitLabHostError, } from '@/tools/gitlab/utils' @@ -87,4 +89,50 @@ describe('getGitLabResourcePath', () => { ) expect(getGitLabResourcePath('group', 'parent/child')).toBe('groups/parent%2Fchild') }) + + it('does not double-encode a resourceId that is already URL-encoded', () => { + expect(getGitLabResourcePath('group', 'parent%2Fchild')).toBe('groups/parent%2Fchild') + expect(getGitLabResourcePath('project', ' rvt-sandbox%2Fplatform-eng ')).toBe( + 'projects/rvt-sandbox%2Fplatform-eng' + ) + }) + + it('treats a bare, non-percent-encoding "%" as a literal character', () => { + expect(getGitLabResourcePath('group', '100%-done')).toBe('groups/100%25-done') + }) +}) + +describe('coerceGitLabAccessLevel', () => { + it('accepts an integer already in the enum', () => { + expect(coerceGitLabAccessLevel(0)).toBe(0) + expect(coerceGitLabAccessLevel(30)).toBe(30) + expect(coerceGitLabAccessLevel(50)).toBe(50) + }) + + it('accepts a numeric string', () => { + expect(coerceGitLabAccessLevel('30')).toBe(30) + expect(coerceGitLabAccessLevel(' 40 ')).toBe(40) + }) + + it('accepts a level name, case-insensitively', () => { + expect(coerceGitLabAccessLevel('Developer')).toBe(30) + expect(coerceGitLabAccessLevel('developer')).toBe(30) + expect(coerceGitLabAccessLevel(' MAINTAINER ')).toBe(40) + expect(coerceGitLabAccessLevel('No access')).toBe(0) + expect(coerceGitLabAccessLevel('Security Manager')).toBe(25) + }) + + it('throws for values outside the enum', () => { + expect(() => coerceGitLabAccessLevel(999)).toThrow(InvalidGitLabAccessLevelError) + expect(() => coerceGitLabAccessLevel(31)).toThrow(InvalidGitLabAccessLevelError) + expect(() => coerceGitLabAccessLevel('root')).toThrow(InvalidGitLabAccessLevelError) + expect(() => coerceGitLabAccessLevel('')).toThrow(InvalidGitLabAccessLevelError) + expect(() => coerceGitLabAccessLevel(' ')).toThrow(InvalidGitLabAccessLevelError) + expect(() => coerceGitLabAccessLevel(null)).toThrow(InvalidGitLabAccessLevelError) + expect(() => coerceGitLabAccessLevel(undefined)).toThrow(InvalidGitLabAccessLevelError) + }) + + it('names the offending value and valid levels in the error message', () => { + expect(() => coerceGitLabAccessLevel('boss')).toThrow(/Developer \(30\)/) + }) }) diff --git a/apps/sim/tools/gitlab/utils.ts b/apps/sim/tools/gitlab/utils.ts index 7bc455a8a83..14060fe3ff4 100644 --- a/apps/sim/tools/gitlab/utils.ts +++ b/apps/sim/tools/gitlab/utils.ts @@ -67,6 +67,77 @@ export function getGitLabApiBase(rawHost: unknown): string { return `https://${normalizeGitLabHost(rawHost)}/api/v4` } +/** + * The GitLab member access levels, as the display name shown in the block and + * the integer the REST API expects. This is the single source of truth for the + * access-level enum: the block derives its combobox options from it, and runtime + * coercion validates against it. + * + * @see https://docs.gitlab.com/api/members/#roles + */ +export const GITLAB_ACCESS_LEVELS = [ + { name: 'No access', value: 0 }, + { name: 'Minimal Access', value: 5 }, + { name: 'Guest', value: 10 }, + { name: 'Planner', value: 15 }, + { name: 'Reporter', value: 20 }, + { name: 'Security Manager', value: 25 }, + { name: 'Developer', value: 30 }, + { name: 'Maintainer', value: 40 }, + { name: 'Owner', value: 50 }, +] as const + +/** + * Options for the block's access-level combobox. The `id` is the integer as a + * string so a literal pick serializes to the same value GitLab expects, while a + * reference expression (e.g. ``) passes through untouched. + */ +export const GITLAB_ACCESS_LEVEL_OPTIONS: { label: string; id: string }[] = + GITLAB_ACCESS_LEVELS.map((level) => ({ label: level.name, id: String(level.value) })) + +const GITLAB_ACCESS_LEVEL_VALUES = new Set(GITLAB_ACCESS_LEVELS.map((level) => level.value)) + +const GITLAB_ACCESS_LEVEL_BY_NAME = new Map( + GITLAB_ACCESS_LEVELS.map((level) => [level.name.toLowerCase(), level.value]) +) + +/** + * Error thrown when a resolved access-level value is not one of the known GitLab + * levels. Kept distinct so callers can surface a permissions-specific message. + */ +export class InvalidGitLabAccessLevelError extends Error { + constructor(value: unknown) { + const valid = GITLAB_ACCESS_LEVELS.map((level) => `${level.name} (${level.value})`).join(', ') + super(`Invalid GitLab access level: ${JSON.stringify(value)}. Expected one of: ${valid}.`) + this.name = 'InvalidGitLabAccessLevelError' + } +} + +/** + * Coerces a runtime access-level value to the GitLab integer. Accepts an integer + * (`30`), a numeric string (`'30'`), or a level name (`'Developer'`, + * case-insensitive). This runs at execution time - after reference expressions + * have resolved - so a level computed from a policy table (by name or number) is + * accepted while any value outside the enum fails loudly. + * + * @throws {InvalidGitLabAccessLevelError} when the value is not a known level. + */ +export function coerceGitLabAccessLevel(value: unknown): number { + if (typeof value === 'number' && GITLAB_ACCESS_LEVEL_VALUES.has(value)) { + return value + } + if (typeof value === 'string') { + const trimmed = value.trim() + const byName = GITLAB_ACCESS_LEVEL_BY_NAME.get(trimmed.toLowerCase()) + if (byName !== undefined) return byName + const numeric = Number(trimmed) + if (trimmed !== '' && Number.isFinite(numeric) && GITLAB_ACCESS_LEVEL_VALUES.has(numeric)) { + return numeric + } + } + throw new InvalidGitLabAccessLevelError(value) +} + /** * A GitLab access/membership resource is scoped either to a project or a group. * The two share an identical endpoint surface (`/members`, `/invitations`, @@ -74,16 +145,38 @@ export function getGitLabApiBase(rawHost: unknown): string { */ export type GitLabResourceType = 'project' | 'group' +/** + * Encodes a GitLab project/group id or path exactly once, regardless of + * whether the caller already URL-encoded it. GitLab's own API docs show + * namespaced paths pre-encoded (e.g. `groups/gitlab-org%2Fgitlab-test`), so + * callers - including LLM-authored block values that mirror that convention - + * may pass either `mygroup/myproject` or `mygroup%2Fmyproject`. Decoding + * first makes both inputs converge on the same single-encoded result; + * without it, a pre-encoded `%2F` gets re-encoded to `%252F`, GitLab decodes + * that once server-side to the literal string `%2F`, and the lookup 404s. + */ +function encodeGitLabResourceId(resourceId: string | number): string { + const raw = String(resourceId).trim() + let decoded = raw + try { + decoded = decodeURIComponent(raw) + } catch { + // Not a valid percent-encoding (e.g. a bare `%`) - treat as already raw. + } + return encodeURIComponent(decoded) +} + /** * 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. + * raw paths (`mygroup/myproject`), pre-encoded paths (`mygroup%2Fmyproject`), + * and numeric ids all work. */ export function getGitLabResourcePath( resourceType: GitLabResourceType, resourceId: string | number ): string { - const encodedId = encodeURIComponent(String(resourceId).trim()) + const encodedId = encodeGitLabResourceId(resourceId) const segment = resourceType === 'group' ? 'groups' : 'projects' return `${segment}/${encodedId}` } diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 3197c49b9bf..9c301e67b98 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -1289,6 +1289,7 @@ import { gitlabDeleteUserTool, gitlabDenyAccessRequestTool, gitlabGetFileTool, + gitlabGetGroupTool, gitlabGetIssueTool, gitlabGetJobLogTool, gitlabGetMergeRequestChangesTool, @@ -1299,6 +1300,7 @@ import { gitlabListAccessRequestsTool, gitlabListBranchesTool, gitlabListCommitsTool, + gitlabListGroupsTool, gitlabListInvitationsTool, gitlabListIssuesTool, gitlabListMembersTool, @@ -1309,6 +1311,7 @@ import { gitlabListReleasesTool, gitlabListRepositoryTreeTool, gitlabListSamlGroupLinksTool, + gitlabListUserMembershipsTool, gitlabMergeMergeRequestTool, gitlabPlayJobTool, gitlabRejectUserTool, @@ -6387,6 +6390,7 @@ export const tools: Record = { gitlab_delete_user_identity: gitlabDeleteUserIdentityTool, gitlab_deny_access_request: gitlabDenyAccessRequestTool, gitlab_get_file: gitlabGetFileTool, + gitlab_get_group: gitlabGetGroupTool, gitlab_get_issue: gitlabGetIssueTool, gitlab_get_job_log: gitlabGetJobLogTool, gitlab_get_merge_request: gitlabGetMergeRequestTool, @@ -6397,6 +6401,7 @@ export const tools: Record = { gitlab_list_access_requests: gitlabListAccessRequestsTool, gitlab_list_branches: gitlabListBranchesTool, gitlab_list_commits: gitlabListCommitsTool, + gitlab_list_groups: gitlabListGroupsTool, gitlab_list_invitations: gitlabListInvitationsTool, gitlab_list_issues: gitlabListIssuesTool, gitlab_list_members: gitlabListMembersTool, @@ -6407,6 +6412,7 @@ export const tools: Record = { gitlab_list_releases: gitlabListReleasesTool, gitlab_list_repository_tree: gitlabListRepositoryTreeTool, gitlab_list_saml_group_links: gitlabListSamlGroupLinksTool, + gitlab_list_user_memberships: gitlabListUserMembershipsTool, gitlab_merge_merge_request: gitlabMergeMergeRequestTool, gitlab_play_job: gitlabPlayJobTool, gitlab_reject_user: gitlabRejectUserTool, From 65e38db0dff302721a1f93968587f720807eb467 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Thu, 16 Jul 2026 19:28:18 -0700 Subject: [PATCH 2/4] fix(gitlab): treat access level 0 ("No access") as a provided value A runtime reference can resolve to the integer 0, but the block still gated access-level presence with truthiness: required ops rejected 0 as missing and optional ops silently omitted it. Add hasGitLabAccessLevel(value) (0 and '0' are provided; undefined/null/'' are not) and use it for all six presence gates so "No access" can be granted or set via a reference. --- apps/sim/blocks/blocks/gitlab.test.ts | 37 +++++++++++++++++++++++++++ apps/sim/blocks/blocks/gitlab.ts | 32 +++++++++++++++++------ apps/sim/tools/gitlab/utils.test.ts | 16 ++++++++++++ apps/sim/tools/gitlab/utils.ts | 11 ++++++++ 4 files changed, 88 insertions(+), 8 deletions(-) diff --git a/apps/sim/blocks/blocks/gitlab.test.ts b/apps/sim/blocks/blocks/gitlab.test.ts index 4eb7d441ffa..e5cac5211cf 100644 --- a/apps/sim/blocks/blocks/gitlab.test.ts +++ b/apps/sim/blocks/blocks/gitlab.test.ts @@ -69,6 +69,43 @@ describe('GitLabBlock access operations', () => { expect(typeof byName?.accessLevel).toBe('number') }) + it('accepts a runtime-resolved numeric 0 ("No access") on a required op', () => { + // A reference can resolve to the number 0; a truthiness guard would wrongly + // reject it as missing. It must pass and coerce to 0. + const byZero = block.tools.config.params?.({ + accessToken: 'pat', + operation: 'gitlab_add_member', + resourceType: 'group', + resourceId: '42', + userId: '7', + accessLevel: 0, + }) + expect(byZero).toMatchObject({ userId: 7, accessLevel: 0 }) + expect(byZero?.accessLevel).toBe(0) + }) + + it('sends a numeric 0 on optional ops instead of silently omitting it', () => { + const approve = block.tools.config.params?.({ + accessToken: 'pat', + operation: 'gitlab_approve_access_request', + resourceType: 'group', + resourceId: '42', + userId: '7', + accessLevel: 0, + }) + expect(approve?.accessLevel).toBe(0) + + const invite = block.tools.config.params?.({ + accessToken: 'pat', + operation: 'gitlab_update_invitation', + resourceType: 'group', + resourceId: '42', + email: 'a@b.com', + invitationAccessLevel: 0, + }) + expect(invite?.accessLevel).toBe(0) + }) + it('throws loudly when a resolved access level is not a valid GitLab level', () => { expect(() => block.tools.config.params?.({ diff --git a/apps/sim/blocks/blocks/gitlab.ts b/apps/sim/blocks/blocks/gitlab.ts index 0cb3c55e6ea..833dbdbb5a9 100644 --- a/apps/sim/blocks/blocks/gitlab.ts +++ b/apps/sim/blocks/blocks/gitlab.ts @@ -2,7 +2,11 @@ import { GitLabIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' import type { GitLabResponse } from '@/tools/gitlab/types' -import { coerceGitLabAccessLevel, GITLAB_ACCESS_LEVEL_OPTIONS } from '@/tools/gitlab/utils' +import { + coerceGitLabAccessLevel, + GITLAB_ACCESS_LEVEL_OPTIONS, + hasGitLabAccessLevel, +} from '@/tools/gitlab/utils' import { getTrigger } from '@/triggers' /** @@ -2498,7 +2502,7 @@ Return ONLY the JSON array - no explanations, no extra text.`, if ( !params.resourceId?.trim() || (!addMemberUserId && !params.username?.trim()) || - !params.accessLevel + !hasGitLabAccessLevel(params.accessLevel) ) { throw new Error( 'Project / Group ID, User ID (or Username), and Access Level are required.' @@ -2517,7 +2521,11 @@ Return ONLY the JSON array - no explanations, no extra text.`, } case 'gitlab_update_member': - if (!params.resourceId?.trim() || !params.userId || !params.memberAccessLevel) { + if ( + !params.resourceId?.trim() || + !params.userId || + !hasGitLabAccessLevel(params.memberAccessLevel) + ) { throw new Error('Project / Group ID, User ID, and Access Level are required.') } return { @@ -2544,7 +2552,11 @@ Return ONLY the JSON array - no explanations, no extra text.`, } case 'gitlab_invite_member': - if (!params.resourceId?.trim() || !params.email?.trim() || !params.accessLevel) { + if ( + !params.resourceId?.trim() || + !params.email?.trim() || + !hasGitLabAccessLevel(params.accessLevel) + ) { throw new Error('Project / Group ID, Email, and Access Level are required.') } return { @@ -2576,7 +2588,7 @@ Return ONLY the JSON array - no explanations, no extra text.`, throw new Error('Project / Group ID and Email are required.') } if ( - !params.invitationAccessLevel && + !hasGitLabAccessLevel(params.invitationAccessLevel) && !params.expiresAt?.trim() && !params.clearExpiresAt ) { @@ -2591,7 +2603,7 @@ Return ONLY the JSON array - no explanations, no extra text.`, email: params.email.trim(), // Only send access_level when a level is chosen; "Leave unchanged" // ('') keeps the invitation's current level instead of resetting it. - accessLevel: params.invitationAccessLevel + accessLevel: hasGitLabAccessLevel(params.invitationAccessLevel) ? coerceGitLabAccessLevel(params.invitationAccessLevel) : undefined, expiresAt: params.clearExpiresAt ? '' : params.expiresAt?.trim() || undefined, @@ -2629,7 +2641,7 @@ Return ONLY the JSON array - no explanations, no extra text.`, resourceType: params.resourceType || 'project', resourceId: params.resourceId.trim(), userId: Number(params.userId), - accessLevel: params.accessLevel + accessLevel: hasGitLabAccessLevel(params.accessLevel) ? coerceGitLabAccessLevel(params.accessLevel) : undefined, } @@ -2655,7 +2667,11 @@ Return ONLY the JSON array - no explanations, no extra text.`, } case 'gitlab_add_saml_group_link': - if (!params.groupId?.trim() || !params.samlGroupName?.trim() || !params.accessLevel) { + if ( + !params.groupId?.trim() || + !params.samlGroupName?.trim() || + !hasGitLabAccessLevel(params.accessLevel) + ) { throw new Error('Group ID, SAML Group Name, and Access Level are required.') } return { diff --git a/apps/sim/tools/gitlab/utils.test.ts b/apps/sim/tools/gitlab/utils.test.ts index b6705fb57b6..8bd2ea7f9b1 100644 --- a/apps/sim/tools/gitlab/utils.test.ts +++ b/apps/sim/tools/gitlab/utils.test.ts @@ -6,6 +6,7 @@ import { coerceGitLabAccessLevel, getGitLabApiBase, getGitLabResourcePath, + hasGitLabAccessLevel, InvalidGitLabAccessLevelError, normalizeGitLabHost, UnsafeGitLabHostError, @@ -136,3 +137,18 @@ describe('coerceGitLabAccessLevel', () => { expect(() => coerceGitLabAccessLevel('boss')).toThrow(/Developer \(30\)/) }) }) + +describe('hasGitLabAccessLevel', () => { + it('treats numeric zero ("No access") and its string form as provided', () => { + expect(hasGitLabAccessLevel(0)).toBe(true) + expect(hasGitLabAccessLevel('0')).toBe(true) + expect(hasGitLabAccessLevel(30)).toBe(true) + expect(hasGitLabAccessLevel('Developer')).toBe(true) + }) + + it('treats undefined, null, and the empty-string sentinel as not provided', () => { + expect(hasGitLabAccessLevel(undefined)).toBe(false) + expect(hasGitLabAccessLevel(null)).toBe(false) + expect(hasGitLabAccessLevel('')).toBe(false) + }) +}) diff --git a/apps/sim/tools/gitlab/utils.ts b/apps/sim/tools/gitlab/utils.ts index 14060fe3ff4..6b24294976b 100644 --- a/apps/sim/tools/gitlab/utils.ts +++ b/apps/sim/tools/gitlab/utils.ts @@ -113,6 +113,17 @@ export class InvalidGitLabAccessLevelError extends Error { } } +/** + * Whether an access-level field carries a value to send. Distinguishes a real + * level - including numeric `0` ("No access") - from "not provided" (undefined, + * null, or the empty-string "leave unchanged" sentinel). A bare truthiness check + * would wrongly treat a runtime-resolved `0` as absent, rejecting it on required + * operations and silently omitting it on optional ones. + */ +export function hasGitLabAccessLevel(value: unknown): boolean { + return value !== undefined && value !== null && value !== '' +} + /** * Coerces a runtime access-level value to the GitLab integer. Accepts an integer * (`30`), a numeric string (`'30'`), or a level name (`'Developer'`, From 6237de1d915348b1eca705569b060797fe04febc Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Jul 2026 13:26:58 -0700 Subject: [PATCH 3/4] feat(gitlab): expand group listing filters and harden access-level enum - list_groups: add visibility, min_access_level, and all_available filters (documented on GET /groups) plus order_by/sort, now surfaced in the block and forwarded in params - order_by widened to include GitLab's documented 'similarity' value - access-level enum label 'Minimal Access' -> 'Minimal access' to match GitLab verbatim; coercion already case-insensitive - min_access_level guard rejects 0 (GitLab floor is 5); reuse the access-level enum for the block dropdown (drops 'No access') - tests: out-of-enum numeric-string coercion case + full list_groups filter mapping --- apps/sim/blocks/blocks/gitlab.test.ts | 10 ++++ apps/sim/blocks/blocks/gitlab.ts | 70 ++++++++++++++++++++++++++- apps/sim/tools/gitlab/list_groups.ts | 28 ++++++++++- apps/sim/tools/gitlab/types.ts | 5 +- apps/sim/tools/gitlab/utils.test.ts | 1 + apps/sim/tools/gitlab/utils.ts | 2 +- 6 files changed, 111 insertions(+), 5 deletions(-) diff --git a/apps/sim/blocks/blocks/gitlab.test.ts b/apps/sim/blocks/blocks/gitlab.test.ts index e5cac5211cf..00b08eab93a 100644 --- a/apps/sim/blocks/blocks/gitlab.test.ts +++ b/apps/sim/blocks/blocks/gitlab.test.ts @@ -242,6 +242,11 @@ describe('GitLabBlock group operations', () => { owned: true, searchQuery: 'plat', groupsTopLevelOnly: true, + visibility: 'private', + groupMinAccessLevel: '30', + groupAllAvailable: true, + groupOrderBy: 'similarity', + sortOrder: 'asc', perPage: '50', page: '2', }) @@ -249,6 +254,11 @@ describe('GitLabBlock group operations', () => { owned: true, search: 'plat', topLevelOnly: true, + visibility: 'private', + minAccessLevel: 30, + allAvailable: true, + orderBy: 'similarity', + sort: 'asc', perPage: 50, page: 2, }) diff --git a/apps/sim/blocks/blocks/gitlab.ts b/apps/sim/blocks/blocks/gitlab.ts index 833dbdbb5a9..2f64212dece 100644 --- a/apps/sim/blocks/blocks/gitlab.ts +++ b/apps/sim/blocks/blocks/gitlab.ts @@ -1010,6 +1010,34 @@ Return ONLY the commit message - no explanations, no extra text.`, value: ['gitlab_list_groups'], }, }, + { + id: 'groupAllAvailable', + title: 'All Available', + type: 'switch', + mode: 'advanced', + description: + 'Include groups you can access but are not a member of (ignored when Owned Only or Minimum Access Level is set)', + condition: { + field: 'operation', + value: ['gitlab_list_groups'], + }, + }, + { + id: 'groupMinAccessLevel', + title: 'Minimum Access Level', + type: 'dropdown', + options: [ + { label: 'Any', id: '' }, + ...GITLAB_ACCESS_LEVEL_OPTIONS.filter((option) => option.id !== '0'), + ], + value: () => '', + mode: 'advanced', + description: 'Only groups where you have at least this access level', + condition: { + field: 'operation', + value: ['gitlab_list_groups'], + }, + }, { id: 'membership', title: 'Member Only', @@ -1035,7 +1063,7 @@ Return ONLY the commit message - no explanations, no extra text.`, mode: 'advanced', condition: { field: 'operation', - value: ['gitlab_list_projects'], + value: ['gitlab_list_projects', 'gitlab_list_groups'], }, }, // List-issues filters @@ -1105,6 +1133,24 @@ Return ONLY the commit message - no explanations, no extra text.`, value: ['gitlab_list_projects'], }, }, + { + id: 'groupOrderBy', + title: 'Order By', + type: 'dropdown', + options: [ + { label: 'Default (name)', id: '' }, + { label: 'Name', id: 'name' }, + { label: 'Path', id: 'path' }, + { label: 'ID', id: 'id' }, + { label: 'Similarity (requires search)', id: 'similarity' }, + ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_groups'], + }, + }, { id: 'issueOrderBy', title: 'Order By', @@ -1200,6 +1246,7 @@ Return ONLY the commit message - no explanations, no extra text.`, field: 'operation', value: [ 'gitlab_list_projects', + 'gitlab_list_groups', 'gitlab_list_issues', 'gitlab_list_merge_requests', 'gitlab_list_pipelines', @@ -1985,6 +2032,13 @@ Return ONLY the JSON array - no explanations, no extra text.`, owned: params.owned || undefined, search: params.searchQuery?.trim() || undefined, topLevelOnly: params.groupsTopLevelOnly || undefined, + visibility: params.visibility || undefined, + minAccessLevel: params.groupMinAccessLevel + ? Number(params.groupMinAccessLevel) + : undefined, + allAvailable: params.groupAllAvailable || undefined, + orderBy: params.groupOrderBy || undefined, + sort: params.sortOrder || undefined, perPage: params.perPage ? Number(params.perPage) : undefined, page: params.page ? Number(params.page) : undefined, } @@ -2815,7 +2869,7 @@ Return ONLY the JSON array - no explanations, no extra text.`, 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' }, + visibility: { type: 'string', description: 'Project or group 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' }, @@ -2902,6 +2956,18 @@ Return ONLY the JSON array - no explanations, no extra text.`, type: 'boolean', description: 'Limit group listings to top-level groups, excluding subgroups', }, + groupOrderBy: { + type: 'string', + description: "Order group listings by field ('name', 'path', 'id', or 'similarity')", + }, + groupAllAvailable: { + type: 'boolean', + description: 'Include groups the user can access but is not a member of', + }, + groupMinAccessLevel: { + type: 'string', + description: 'Minimum access level filter for group listings (integer access level)', + }, membershipType: { type: 'string', description: "Membership source filter ('Project' or 'Namespace'; '' for all)", diff --git a/apps/sim/tools/gitlab/list_groups.ts b/apps/sim/tools/gitlab/list_groups.ts index f10823cca2d..65365a80f27 100644 --- a/apps/sim/tools/gitlab/list_groups.ts +++ b/apps/sim/tools/gitlab/list_groups.ts @@ -39,11 +39,32 @@ export const gitlabListGroupsTool: ToolConfig 0) { + queryParams.append('min_access_level', String(params.minAccessLevel)) + } + if (params.allAvailable) queryParams.append('all_available', 'true') if (params.orderBy) queryParams.append('order_by', params.orderBy) if (params.sort) queryParams.append('sort', params.sort) if (params.perPage) queryParams.append('per_page', String(params.perPage)) diff --git a/apps/sim/tools/gitlab/types.ts b/apps/sim/tools/gitlab/types.ts index efb81d28f74..2e4f17f7ec4 100644 --- a/apps/sim/tools/gitlab/types.ts +++ b/apps/sim/tools/gitlab/types.ts @@ -273,7 +273,10 @@ export interface GitLabListGroupsParams extends GitLabBaseParams { owned?: boolean search?: string topLevelOnly?: boolean - orderBy?: 'name' | 'path' | 'id' + visibility?: 'public' | 'internal' | 'private' + minAccessLevel?: number + allAvailable?: boolean + orderBy?: 'name' | 'path' | 'id' | 'similarity' sort?: 'asc' | 'desc' perPage?: number page?: number diff --git a/apps/sim/tools/gitlab/utils.test.ts b/apps/sim/tools/gitlab/utils.test.ts index 8bd2ea7f9b1..22d5ae89a9e 100644 --- a/apps/sim/tools/gitlab/utils.test.ts +++ b/apps/sim/tools/gitlab/utils.test.ts @@ -126,6 +126,7 @@ describe('coerceGitLabAccessLevel', () => { it('throws for values outside the enum', () => { expect(() => coerceGitLabAccessLevel(999)).toThrow(InvalidGitLabAccessLevelError) expect(() => coerceGitLabAccessLevel(31)).toThrow(InvalidGitLabAccessLevelError) + expect(() => coerceGitLabAccessLevel('35')).toThrow(InvalidGitLabAccessLevelError) expect(() => coerceGitLabAccessLevel('root')).toThrow(InvalidGitLabAccessLevelError) expect(() => coerceGitLabAccessLevel('')).toThrow(InvalidGitLabAccessLevelError) expect(() => coerceGitLabAccessLevel(' ')).toThrow(InvalidGitLabAccessLevelError) diff --git a/apps/sim/tools/gitlab/utils.ts b/apps/sim/tools/gitlab/utils.ts index 6b24294976b..8535b280a22 100644 --- a/apps/sim/tools/gitlab/utils.ts +++ b/apps/sim/tools/gitlab/utils.ts @@ -77,7 +77,7 @@ export function getGitLabApiBase(rawHost: unknown): string { */ export const GITLAB_ACCESS_LEVELS = [ { name: 'No access', value: 0 }, - { name: 'Minimal Access', value: 5 }, + { name: 'Minimal access', value: 5 }, { name: 'Guest', value: 10 }, { name: 'Planner', value: 15 }, { name: 'Reporter', value: 20 }, From 160aaaaa726cae81712db9af6f8c06308f349b9b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Jul 2026 13:37:09 -0700 Subject: [PATCH 4/4] fix(gitlab): validate list_groups min-access-level and similarity ordering at execution time Greptile round 1 (both P1): - min_access_level: coerce via the shared access-level enum (coerceGitLabMinAccessLevel) and throw on out-of-enum values (31, 999) or 0, so a direct tool call fails loudly instead of sending an invalid filter to GitLab - order_by=similarity now requires a non-empty search term (GitLab ignores similarity ordering without one); throws a clear error otherwise - tests for both validations --- apps/sim/tools/gitlab/groups.test.ts | 34 ++++++++++++++++++++++++++++ apps/sim/tools/gitlab/list_groups.ts | 19 ++++++++++++---- apps/sim/tools/gitlab/utils.test.ts | 28 +++++++++++++++++++++++ apps/sim/tools/gitlab/utils.ts | 17 ++++++++++++++ 4 files changed, 93 insertions(+), 5 deletions(-) diff --git a/apps/sim/tools/gitlab/groups.test.ts b/apps/sim/tools/gitlab/groups.test.ts index 863c529ef51..22474783368 100644 --- a/apps/sim/tools/gitlab/groups.test.ts +++ b/apps/sim/tools/gitlab/groups.test.ts @@ -94,6 +94,40 @@ describe('gitlab_list_groups', () => { ) }) + it('forwards the documented visibility, min-access-level, and all-available filters', () => { + const url = gitlabListGroupsTool.request.url({ + accessToken: 'pat', + visibility: 'private', + minAccessLevel: 30, + allAvailable: true, + }) + expect(url).toBe( + 'https://gitlab.com/api/v4/groups?visibility=private&min_access_level=30&all_available=true' + ) + }) + + it('rejects an out-of-enum minimum access level instead of sending it to GitLab', () => { + expect(() => + gitlabListGroupsTool.request.url({ accessToken: 'pat', minAccessLevel: 31 }) + ).toThrow(/Invalid GitLab access level/) + expect(() => + gitlabListGroupsTool.request.url({ accessToken: 'pat', minAccessLevel: 0 }) + ).toThrow(/Invalid GitLab access level/) + }) + + it('rejects similarity ordering without a search term, but allows it with one', () => { + expect(() => + gitlabListGroupsTool.request.url({ accessToken: 'pat', orderBy: 'similarity' }) + ).toThrow(/similarity/) + expect( + gitlabListGroupsTool.request.url({ + accessToken: 'pat', + orderBy: 'similarity', + search: 'plat', + }) + ).toBe('https://gitlab.com/api/v4/groups?search=plat&order_by=similarity') + }) + it('reads the total from the x-total header, falling back to length', async () => { const withHeader = await gitlabListGroupsTool.transformResponse?.( mockResponse({ json: [{ id: 1 }, { id: 2 }], headers: { 'x-total': '17' } }), diff --git a/apps/sim/tools/gitlab/list_groups.ts b/apps/sim/tools/gitlab/list_groups.ts index 65365a80f27..488b63c0237 100644 --- a/apps/sim/tools/gitlab/list_groups.ts +++ b/apps/sim/tools/gitlab/list_groups.ts @@ -1,5 +1,5 @@ import type { GitLabListGroupsParams, GitLabListGroupsResponse } from '@/tools/gitlab/types' -import { getGitLabApiBase } from '@/tools/gitlab/utils' +import { coerceGitLabMinAccessLevel, getGitLabApiBase } from '@/tools/gitlab/utils' import type { ToolConfig } from '@/tools/types' export const gitlabListGroupsTool: ToolConfig = { @@ -89,15 +89,24 @@ export const gitlabListGroupsTool: ToolConfig { const queryParams = new URLSearchParams() + const search = params.search?.trim() if (params.owned) queryParams.append('owned', 'true') - if (params.search) queryParams.append('search', params.search) + if (search) queryParams.append('search', search) if (params.topLevelOnly) queryParams.append('top_level_only', 'true') if (params.visibility) queryParams.append('visibility', params.visibility) - if (params.minAccessLevel && params.minAccessLevel > 0) { - queryParams.append('min_access_level', String(params.minAccessLevel)) + const minAccessLevel = coerceGitLabMinAccessLevel(params.minAccessLevel) + if (minAccessLevel !== undefined) { + queryParams.append('min_access_level', String(minAccessLevel)) } if (params.allAvailable) queryParams.append('all_available', 'true') - if (params.orderBy) queryParams.append('order_by', params.orderBy) + if (params.orderBy) { + if (params.orderBy === 'similarity' && !search) { + throw new Error( + 'GitLab list groups: order_by "similarity" requires a non-empty search term.' + ) + } + queryParams.append('order_by', params.orderBy) + } if (params.sort) queryParams.append('sort', params.sort) if (params.perPage) queryParams.append('per_page', String(params.perPage)) if (params.page) queryParams.append('page', String(params.page)) diff --git a/apps/sim/tools/gitlab/utils.test.ts b/apps/sim/tools/gitlab/utils.test.ts index 22d5ae89a9e..ae0af6060fe 100644 --- a/apps/sim/tools/gitlab/utils.test.ts +++ b/apps/sim/tools/gitlab/utils.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest' import { coerceGitLabAccessLevel, + coerceGitLabMinAccessLevel, getGitLabApiBase, getGitLabResourcePath, hasGitLabAccessLevel, @@ -139,6 +140,33 @@ describe('coerceGitLabAccessLevel', () => { }) }) +describe('coerceGitLabMinAccessLevel', () => { + it('returns undefined for absent or blank values', () => { + expect(coerceGitLabMinAccessLevel(undefined)).toBeUndefined() + expect(coerceGitLabMinAccessLevel(null)).toBeUndefined() + expect(coerceGitLabMinAccessLevel('')).toBeUndefined() + }) + + it('coerces valid filter levels from integer, numeric string, or name', () => { + expect(coerceGitLabMinAccessLevel(30)).toBe(30) + expect(coerceGitLabMinAccessLevel('30')).toBe(30) + expect(coerceGitLabMinAccessLevel('Developer')).toBe(30) + expect(coerceGitLabMinAccessLevel(5)).toBe(5) + }) + + it('rejects zero ("No access") — GitLab\'s filter floor is 5', () => { + expect(() => coerceGitLabMinAccessLevel(0)).toThrow(InvalidGitLabAccessLevelError) + expect(() => coerceGitLabMinAccessLevel('No access')).toThrow(InvalidGitLabAccessLevelError) + }) + + it('rejects out-of-enum values so they never reach GitLab', () => { + expect(() => coerceGitLabMinAccessLevel(31)).toThrow(InvalidGitLabAccessLevelError) + expect(() => coerceGitLabMinAccessLevel(999)).toThrow(InvalidGitLabAccessLevelError) + expect(() => coerceGitLabMinAccessLevel('35')).toThrow(InvalidGitLabAccessLevelError) + expect(() => coerceGitLabMinAccessLevel('root')).toThrow(InvalidGitLabAccessLevelError) + }) +}) + describe('hasGitLabAccessLevel', () => { it('treats numeric zero ("No access") and its string form as provided', () => { expect(hasGitLabAccessLevel(0)).toBe(true) diff --git a/apps/sim/tools/gitlab/utils.ts b/apps/sim/tools/gitlab/utils.ts index 8535b280a22..0fe64c7ccf3 100644 --- a/apps/sim/tools/gitlab/utils.ts +++ b/apps/sim/tools/gitlab/utils.ts @@ -149,6 +149,23 @@ export function coerceGitLabAccessLevel(value: unknown): number { throw new InvalidGitLabAccessLevelError(value) } +/** + * Coerces an optional GitLab `min_access_level` filter value. Returns undefined + * when the field is absent or blank, otherwise coerces via the shared + * access-level rules and rejects "No access" (0) — GitLab's minimum-access + * filter floor is 5 (Minimal access). Runs at execution time so a direct tool + * call with an out-of-enum value (e.g. 31 or 999) fails loudly with a clear + * error instead of sending an invalid filter to GitLab. + * + * @throws {InvalidGitLabAccessLevelError} when the value is not a valid filter level. + */ +export function coerceGitLabMinAccessLevel(value: unknown): number | undefined { + if (value === undefined || value === null || value === '') return undefined + const level = coerceGitLabAccessLevel(value) + if (level === 0) throw new InvalidGitLabAccessLevelError(value) + return level +} + /** * A GitLab access/membership resource is scoped either to a project or a group. * The two share an identical endpoint surface (`/members`, `/invitations`,