feat(gitlab): access, membership, and user-admin operations (#5710)

* 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.

* 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.

* 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.

* docs(gitlab): document access and membership operations

* Update apps/sim/blocks/blocks/gitlab.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* 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

* 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.

* fix(gitlab): validation pass — SAML provider param, member/invitation query filter, moderation user guard, registry order

* 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

* fix(gitlab): review round 1 — dedicated no-default access level for update member, expiration clearing via explicit empty string

* fix(gitlab): explicit Clear Expiration toggle for update member/invitation

* 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

* fix(gitlab): tri-state controls for update-op booleans (executable flag, MR squash/remove-source-branch)

---------

Co-authored-by: Marcus Chandra <mzxchandra@gmail.com>
Co-authored-by: mzxchandra <129460234+mzxchandra@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Waleed
2026-07-16 12:00:12 -07:00
committed by GitHub
co-authored by Marcus Chandra mzxchandra greptile-apps[bot]
parent b5196a41a8
commit 8adeaab8a5
63 changed files with 5999 additions and 121 deletions
File diff suppressed because it is too large Load Diff
+160
View File
@@ -0,0 +1,160 @@
/**
* @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('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 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: '' })
// 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',
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(withLevel).toMatchObject({ email: 'a@b.com', accessLevel: 40 })
})
it('throws when required access fields are missing', () => {
expect(() =>
block.tools.config.params?.({
accessToken: 'pat',
operation: 'gitlab_add_member',
resourceType: 'group',
resourceId: '42',
})
).toThrow()
})
})
File diff suppressed because it is too large Load Diff
+214
View File
@@ -0,0 +1,214 @@
/**
* @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<string, string>
}
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' },
text: '{"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 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' }),
{} 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_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<string, unknown>
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<string, unknown>
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!(
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' })
})
})
+146
View File
@@ -0,0 +1,146 @@
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<GitLabAddMemberParams, GitLabAddMemberResponse> = {
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 path (e.g. mygroup/myproject)',
},
userId: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'The ID of the user to add. Provide either userId or username.',
},
username: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'The username of the user to add. Provide either userId or username.',
},
accessLevel: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description:
'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',
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<string, unknown> = {
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
return body
},
},
transformResponse: async (response) => {
// 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: false,
error: `GitLab API error: 409 ${conflictText}`,
output: {},
}
}
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)',
},
},
}
@@ -0,0 +1,114 @@
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 (GitLab Premium/Ultimate)',
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 path (e.g. my-org/my-group)',
},
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), 15 (Planner), 20 (Reporter), 25 (Security Manager), 30 (Developer), 40 (Maintainer), 50 (Owner)',
},
memberRoleId: {
type: 'number',
required: false,
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 (GitLab 18.2+)',
},
},
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<string, unknown> = {
saml_group_name: params.samlGroupName,
access_level: params.accessLevel,
}
if (params.memberRoleId !== undefined) body.member_role_id = params.memberRoleId
if (params.provider) body.provider = params.provider.trim()
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',
},
},
}
@@ -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 path (e.g. mygroup/myproject)',
},
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), 15 (Planner), 20 (Reporter), 25 (Security Manager), 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',
},
},
}
@@ -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',
+1 -1
View File
@@ -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',
+17 -1
View File
@@ -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',
@@ -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()}`
},
+1 -1
View File
@@ -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',
+40 -7
View File
@@ -25,7 +25,7 @@ export const gitlabCreateFileTool: ToolConfig<GitLabCreateFileParams, GitLabCrea
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Project ID or URL-encoded path',
description: 'Project ID or path (e.g. mygroup/myproject)',
},
filePath: {
type: 'string',
@@ -45,6 +45,30 @@ export const gitlabCreateFileTool: ToolConfig<GitLabCreateFileParams, GitLabCrea
visibility: 'user-or-llm',
description: 'File content',
},
startBranch: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Name of the base branch to create the target branch from, if it does not exist',
},
authorName: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Commit author name (defaults to the token user)',
},
authorEmail: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Commit author email (defaults to the token user)',
},
executeFilemode: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Enable the execute flag on the file',
},
commitMessage: {
type: 'string',
required: true,
@@ -64,12 +88,21 @@ export const gitlabCreateFileTool: ToolConfig<GitLabCreateFileParams, GitLabCrea
'Content-Type': 'application/json',
'PRIVATE-TOKEN': params.accessToken,
}),
body: (params) => ({
branch: params.branch,
content: params.content,
commit_message: params.commitMessage,
encoding: 'text',
}),
body: (params) => {
const body: Record<string, unknown> = {
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) => {
+1 -1
View File
@@ -26,7 +26,7 @@ export const gitlabCreateIssueTool: ToolConfig<GitLabCreateIssueParams, GitLabCr
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Project ID or URL-encoded path',
description: 'Project ID or path (e.g. mygroup/myproject)',
},
title: {
type: 'string',
+12 -4
View File
@@ -28,7 +28,7 @@ export const gitlabCreateIssueNoteTool: 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)',
},
issueIid: {
type: 'number',
@@ -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<string, unknown> = { body: params.body }
if (params.internal !== undefined) body.internal = params.internal
return body
},
},
transformResponse: async (response) => {
@@ -31,7 +31,7 @@ export const gitlabCreateMergeRequestTool: 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)',
},
sourceBranch: {
type: 'string',
@@ -91,7 +91,7 @@ export const gitlabCreateMergeRequestTool: ToolConfig<
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Mark as draft (work in progress)',
description: 'Mark as draft (applied via the "Draft:" title prefix)',
},
},
@@ -106,10 +106,16 @@ export const gitlabCreateMergeRequestTool: ToolConfig<
'PRIVATE-TOKEN': params.accessToken,
}),
body: (params) => {
// 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<string, any> = {
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
},
@@ -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',
@@ -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<string, unknown> = { body: params.body }
if (params.internal !== undefined) body.internal = params.internal
return body
},
},
transformResponse: async (response) => {
+10 -1
View File
@@ -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',
@@ -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
},
+21 -1
View File
@@ -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',
@@ -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
+127
View File
@@ -0,0 +1,127 @@
import type { GitLabCreateUserParams, GitLabUserResponse } from '@/tools/gitlab/types'
import { getGitLabApiBase } from '@/tools/gitlab/utils'
import type { ToolConfig } from '@/tools/types'
export const gitlabCreateUserTool: ToolConfig<GitLabCreateUserParams, GitLabUserResponse> = {
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',
},
forceRandomPassword: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description:
'Set a random password without emailing a reset link (useful for SSO-only accounts). One of password, resetPassword, or forceRandomPassword is required.',
},
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<string, unknown> = {
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.forceRandomPassword !== undefined)
body.force_random_password = params.forceRandomPassword
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',
},
},
}
+1 -1
View File
@@ -28,7 +28,7 @@ export const gitlabDeleteBranchTool: 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',
+1 -1
View File
@@ -26,7 +26,7 @@ export const gitlabDeleteIssueTool: ToolConfig<GitLabDeleteIssueParams, GitLabDe
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Project ID or URL-encoded path',
description: 'Project ID or path (e.g. mygroup/myproject)',
},
issueIid: {
type: 'number',
@@ -0,0 +1,90 @@
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 (GitLab Premium/Ultimate)',
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 path (e.g. my-org/my-group)',
},
samlGroupName: {
type: 'string',
required: true,
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())
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) => ({
'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',
},
},
}
+79
View File
@@ -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<GitLabDeleteUserParams, GitLabDeleteUserResponse> = {
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, personal projects, AND groups owned solely by this user 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',
},
},
}
@@ -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',
},
},
}
@@ -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 path (e.g. mygroup/myproject)',
},
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',
},
},
}
+12 -3
View File
@@ -25,7 +25,7 @@ export const gitlabGetFileTool: ToolConfig<GitLabGetFileParams, GitLabGetFileRes
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Project ID or URL-encoded path',
description: 'Project ID or path (e.g. mygroup/myproject)',
},
filePath: {
type: 'string',
@@ -66,6 +66,10 @@ export const gitlabGetFileTool: ToolConfig<GitLabGetFileParams, GitLabGetFileRes
const data = await response.json()
const decoded = Buffer.from(data.content ?? '', 'base64').toString('utf-8')
// Repository blobs can be hundreds of MB; cap what flows into the workflow
// payload so a huge file cannot blow up the execution log.
const maxContentChars = 1_000_000
const truncated = decoded.length > maxContentChars
return {
success: true,
@@ -76,7 +80,8 @@ export const gitlabGetFileTool: ToolConfig<GitLabGetFileParams, GitLabGetFileRes
ref: data.ref ?? null,
blobId: data.blob_id ?? null,
lastCommitId: data.last_commit_id ?? null,
content: decoded,
content: truncated ? decoded.slice(0, maxContentChars) : decoded,
truncated,
},
}
},
@@ -108,7 +113,11 @@ export const gitlabGetFileTool: ToolConfig<GitLabGetFileParams, GitLabGetFileRes
},
content: {
type: 'string',
description: 'The decoded file content',
description: 'The decoded file content, truncated to 1M characters',
},
truncated: {
type: 'boolean',
description: 'Whether the content was truncated',
},
},
}
+1 -1
View File
@@ -25,7 +25,7 @@ export const gitlabGetIssueTool: ToolConfig<GitLabGetIssueParams, GitLabGetIssue
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Project ID or URL-encoded path',
description: 'Project ID or path (e.g. mygroup/myproject)',
},
issueIid: {
type: 'number',
+17 -4
View File
@@ -1,7 +1,15 @@
import { truncate } from '@sim/utils/string'
import type { GitLabGetJobLogParams, GitLabGetJobLogResponse } from '@/tools/gitlab/types'
import { getGitLabApiBase } from '@/tools/gitlab/utils'
import type { ToolConfig } from '@/tools/types'
/**
* Job traces can reach ~100 MB on gitlab.com (more on self-managed); cap what
* is returned into the workflow payload so a huge trace cannot blow up the
* execution log. 200k characters comfortably covers failure diagnosis.
*/
const MAX_LOG_CHARS = 200_000
export const gitlabGetJobLogTool: ToolConfig<GitLabGetJobLogParams, GitLabGetJobLogResponse> = {
id: 'gitlab_get_job_log',
name: 'GitLab Get Job Log',
@@ -25,7 +33,7 @@ export const gitlabGetJobLogTool: ToolConfig<GitLabGetJobLogParams, GitLabGetJob
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Project ID or URL-encoded path',
description: 'Project ID or path (e.g. mygroup/myproject)',
},
jobId: {
type: 'number',
@@ -56,12 +64,13 @@ export const gitlabGetJobLogTool: ToolConfig<GitLabGetJobLogParams, GitLabGetJob
}
}
const log = await response.text()
const fullLog = await response.text()
return {
success: true,
output: {
log,
log: truncate(fullLog, MAX_LOG_CHARS),
truncated: fullLog.length > MAX_LOG_CHARS,
},
}
},
@@ -69,7 +78,11 @@ export const gitlabGetJobLogTool: ToolConfig<GitLabGetJobLogParams, GitLabGetJob
outputs: {
log: {
type: 'string',
description: 'The job log (trace) output',
description: 'The job log (trace) output, truncated to 200k characters',
},
truncated: {
type: 'boolean',
description: 'Whether the log was truncated',
},
},
}
+1 -1
View File
@@ -31,7 +31,7 @@ export const gitlabGetMergeRequestTool: 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',
@@ -31,7 +31,7 @@ export const gitlabGetMergeRequestChangesTool: 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',
@@ -70,6 +70,7 @@ export const gitlabGetMergeRequestChangesTool: ToolConfig<
const data = await response.json()
const changes = Array.isArray(data) ? data : []
const nextPage = response.headers.get('x-next-page')
return {
success: true,
@@ -77,6 +78,7 @@ export const gitlabGetMergeRequestChangesTool: ToolConfig<
mergeRequestIid: params?.mergeRequestIid ?? null,
changes,
changesCount: changes.length,
hasMore: Boolean(nextPage),
},
}
},
@@ -92,7 +94,11 @@ export const gitlabGetMergeRequestChangesTool: ToolConfig<
},
changesCount: {
type: 'number',
description: 'Number of changed files returned',
description: 'Number of changed files returned (first 100)',
},
hasMore: {
type: 'boolean',
description: 'Whether the merge request has more than 100 changed files (results truncated)',
},
},
}
+1 -1
View File
@@ -26,7 +26,7 @@ export const gitlabGetPipelineTool: ToolConfig<GitLabGetPipelineParams, GitLabGe
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',
+1 -1
View File
@@ -25,7 +25,7 @@ export const gitlabGetProjectTool: ToolConfig<GitLabGetProjectParams, GitLabGetP
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Project ID or URL-encoded path (e.g., "namespace/project")',
description: 'Project ID or path (e.g. mygroup/myproject) (e.g., "namespace/project")',
},
},
+58
View File
@@ -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,
}
+150
View File
@@ -0,0 +1,150 @@
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 path (e.g. mygroup/myproject)',
},
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: 0 (No access), 5 (Minimal), 10 (Guest), 15 (Planner), 20 (Reporter), 25 (Security Manager), 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)',
},
inviteSource: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Identifier recorded as the source of the invitation (for attribution)',
},
},
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) => {
// 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<string, unknown> = {
email,
access_level: params.accessLevel,
}
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
},
},
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',
},
},
}
@@ -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 path (e.g. mygroup/myproject)',
},
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',
},
},
}
+1 -1
View File
@@ -28,7 +28,7 @@ export const gitlabListBranchesTool: 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)',
},
search: {
type: 'string',
+3 -2
View File
@@ -26,7 +26,7 @@ export const gitlabListCommitsTool: ToolConfig<GitLabListCommitsParams, GitLabLi
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Project ID or URL-encoded path',
description: 'Project ID or path (e.g. mygroup/myproject)',
},
refName: {
type: 'string',
@@ -123,7 +123,8 @@ export const gitlabListCommitsTool: ToolConfig<GitLabListCommitsParams, GitLabLi
},
total: {
type: 'number',
description: 'Total number of commits',
description:
'Number of commits returned on this page (GitLab does not report a grand total for commits)',
},
},
}
+112
View File
@@ -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 path (e.g. mygroup/myproject)',
},
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',
},
},
}
+2 -2
View File
@@ -25,7 +25,7 @@ export const gitlabListIssuesTool: ToolConfig<GitLabListIssuesParams, GitLabList
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Project ID or URL-encoded path',
description: 'Project ID or path (e.g. mygroup/myproject)',
},
state: {
type: 'string',
@@ -62,7 +62,7 @@ export const gitlabListIssuesTool: ToolConfig<GitLabListIssuesParams, GitLabList
required: false,
visibility: 'user-or-llm',
description:
'Order by field (created_at, updated_at, priority, due_date, relative_position, label_priority, milestone_due, popularity, title, weight)',
'Order by field (created_at, updated_at, priority, due_date, relative_position, label_priority, milestone_due, popularity, weight)',
},
sort: {
type: 'string',
+143
View File
@@ -0,0 +1,143 @@
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<GitLabListMembersParams, GitLabListMembersResponse> =
{
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 path (e.g. mygroup/myproject)',
},
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, email, or username',
},
userIds: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Comma-separated user IDs to filter the results to',
},
state: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
"Filter inherited-member results by state: 'awaiting' or 'active' (Premium/Ultimate; only applies when inherited members are included)",
},
showSeatInfo: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Include seat information for each member',
},
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.userIds) {
for (const id of String(params.userIds).split(',')) {
const trimmed = id.trim()
if (trimmed) queryParams.append('user_ids[]', trimmed)
}
}
if (params.state && !params.directOnly) queryParams.append('state', params.state)
if (params.showSeatInfo) queryParams.append('show_seat_info', 'true')
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',
},
},
}
+1 -1
View File
@@ -31,7 +31,7 @@ export const gitlabListMergeRequestsTool: 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)',
},
state: {
type: 'string',
+1 -1
View File
@@ -31,7 +31,7 @@ export const gitlabListPipelineJobsTool: 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',
+2 -2
View File
@@ -28,7 +28,7 @@ export const gitlabListPipelinesTool: 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',
@@ -41,7 +41,7 @@ export const gitlabListPipelinesTool: ToolConfig<
required: false,
visibility: 'user-or-llm',
description:
'Filter by status (created, waiting_for_resource, preparing, pending, running, success, failed, canceled, skipped, manual, scheduled)',
'Filter by status (created, waiting_for_resource, preparing, pending, running, success, failed, canceling, canceled, skipped, manual, scheduled, waiting_for_callback)',
},
orderBy: {
type: 'string',
+1 -1
View File
@@ -28,7 +28,7 @@ export const gitlabListReleasesTool: 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)',
},
orderBy: {
type: 'string',
@@ -31,7 +31,7 @@ export const gitlabListRepositoryTreeTool: 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)',
},
path: {
type: 'string',
@@ -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 path (e.g. my-org/my-group)',
},
},
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',
},
},
}
+7 -2
View File
@@ -31,7 +31,7 @@ export const gitlabMergeMergeRequestTool: 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',
@@ -89,8 +89,13 @@ export const gitlabMergeMergeRequestTool: ToolConfig<
if (params.squash !== undefined) body.squash = params.squash
if (params.shouldRemoveSourceBranch !== undefined)
body.should_remove_source_branch = params.shouldRemoveSourceBranch
if (params.mergeWhenPipelineSucceeds !== undefined)
if (params.mergeWhenPipelineSucceeds !== undefined) {
// `merge_when_pipeline_succeeds` was deprecated in GitLab 17.11 in
// favor of `auto_merge`; send both so older self-managed instances
// (which ignore unknown params) keep working.
body.auto_merge = params.mergeWhenPipelineSucceeds
body.merge_when_pipeline_succeeds = params.mergeWhenPipelineSucceeds
}
return body
},
+15 -1
View File
@@ -25,7 +25,7 @@ export const gitlabPlayJobTool: ToolConfig<GitLabPlayJobParams, GitLabPlayJobRes
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Project ID or URL-encoded path',
description: 'Project ID or path (e.g. mygroup/myproject)',
},
jobId: {
type: 'number',
@@ -33,6 +33,12 @@ export const gitlabPlayJobTool: ToolConfig<GitLabPlayJobParams, GitLabPlayJobRes
visibility: 'user-or-llm',
description: 'Job ID',
},
jobVariables: {
type: 'array',
required: false,
visibility: 'user-or-llm',
description: 'Variables for the manual job (array of objects with key and value)',
},
},
request: {
@@ -42,8 +48,16 @@ export const gitlabPlayJobTool: ToolConfig<GitLabPlayJobParams, GitLabPlayJobRes
},
method: 'POST',
headers: (params) => ({
'Content-Type': 'application/json',
'PRIVATE-TOKEN': params.accessToken,
}),
body: (params) => {
const body: Record<string, unknown> = {}
if (params.jobVariables && params.jobVariables.length > 0) {
body.job_variables_attributes = params.jobVariables
}
return body
},
},
transformResponse: async (response) => {
+100
View File
@@ -0,0 +1,100 @@
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 path (e.g. mygroup/myproject)',
},
userId: {
type: 'number',
required: true,
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)
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) => ({
'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',
},
},
}
+1 -1
View File
@@ -28,7 +28,7 @@ export const gitlabRetryPipelineTool: 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',
@@ -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 path (e.g. mygroup/myproject)',
},
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',
},
},
}
+93
View File
@@ -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<GitLabSearchUsersParams, GitLabSearchUsersResponse> =
{
id: 'gitlab_search_users',
name: 'GitLab Search Users',
description:
'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.',
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',
},
},
}
+383 -3
View File
@@ -1,3 +1,4 @@
import type { GitLabResourceType } from '@/tools/gitlab/utils'
import type { ToolResponse } from '@/tools/types'
interface GitLabProject {
@@ -252,7 +253,16 @@ export interface GitLabListIssuesParams extends GitLabBaseParams {
assigneeId?: number
milestoneTitle?: string
search?: string
orderBy?: 'created_at' | 'updated_at'
orderBy?:
| 'created_at'
| 'updated_at'
| 'priority'
| 'due_date'
| 'relative_position'
| 'label_priority'
| 'milestone_due'
| 'popularity'
| 'weight'
sort?: 'asc' | 'desc'
perPage?: number
page?: number
@@ -294,11 +304,19 @@ export interface GitLabDeleteIssueParams extends GitLabBaseParams {
export interface GitLabListMergeRequestsParams extends GitLabBaseParams {
projectId: string | number
state?: 'opened' | 'closed' | 'merged' | 'all'
state?: 'opened' | 'closed' | 'locked' | 'merged' | 'all'
labels?: string
sourceBranch?: string
targetBranch?: string
orderBy?: 'created_at' | 'updated_at'
orderBy?:
| 'created_at'
| 'updated_at'
| 'merged_at'
| 'label_priority'
| 'priority'
| 'milestone_due'
| 'popularity'
| 'title'
sort?: 'asc' | 'desc'
perPage?: number
page?: number
@@ -359,10 +377,12 @@ export interface GitLabListPipelinesParams extends GitLabBaseParams {
| 'running'
| 'success'
| 'failed'
| 'canceling'
| 'canceled'
| 'skipped'
| 'manual'
| 'scheduled'
| 'waiting_for_callback'
orderBy?: 'id' | 'status' | 'ref' | 'updated_at' | 'user_id'
sort?: 'asc' | 'desc'
perPage?: number
@@ -378,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<string, unknown>
}
export interface GitLabRetryPipelineParams extends GitLabBaseParams {
@@ -413,6 +435,8 @@ export interface GitLabCompareBranchesParams extends GitLabBaseParams {
from: string
to: string
straight?: boolean
fromProjectId?: string | number
unidiff?: boolean
}
export interface GitLabListRepositoryTreeParams extends GitLabBaseParams {
@@ -436,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 {
@@ -445,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 {
@@ -486,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 {
@@ -516,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 {
@@ -692,6 +734,7 @@ export interface GitLabGetFileResponse extends ToolResponse {
blobId?: string | null
lastCommitId?: string | null
content?: string
truncated?: boolean
}
}
@@ -721,6 +764,7 @@ export interface GitLabGetMergeRequestChangesResponse extends ToolResponse {
mergeRequestIid?: number | null
changes?: GitLabMergeRequestChange[]
changesCount?: number
hasMore?: boolean
}
}
@@ -742,6 +786,7 @@ export interface GitLabListPipelineJobsResponse extends ToolResponse {
export interface GitLabGetJobLogResponse extends ToolResponse {
output: {
log?: string
truncated?: boolean
}
}
@@ -754,6 +799,322 @@ 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
created_by_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
provider?: string | 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
/** 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 {
/** Provide either userId or username to identify the user. */
userId?: number
username?: string
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
skipSubresources?: boolean
unassignIssuables?: boolean
}
export interface GitLabInviteMemberParams extends GitLabResourceScopedParams {
email: string
accessLevel: number
expiresAt?: string
memberRoleId?: number
inviteSource?: string
}
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
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 {
search: string
perPage?: number
page?: number
}
export interface GitLabCreateUserParams extends GitLabBaseParams {
email: string
username: string
name: string
password?: string
resetPassword?: boolean
forceRandomPassword?: 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 +1150,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
+29 -1
View File
@@ -25,7 +25,7 @@ export const gitlabUpdateFileTool: ToolConfig<GitLabUpdateFileParams, GitLabUpda
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Project ID or URL-encoded path',
description: 'Project ID or path (e.g. mygroup/myproject)',
},
filePath: {
type: 'string',
@@ -45,6 +45,30 @@ export const gitlabUpdateFileTool: ToolConfig<GitLabUpdateFileParams, GitLabUpda
visibility: 'user-or-llm',
description: 'New file content',
},
startBranch: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Name of the base branch to create the target branch from, if it does not exist',
},
authorName: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Commit author name (defaults to the token user)',
},
authorEmail: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Commit author email (defaults to the token user)',
},
executeFilemode: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Enable or disable the execute flag on the file',
},
commitMessage: {
type: 'string',
required: true,
@@ -79,6 +103,10 @@ export const gitlabUpdateFileTool: ToolConfig<GitLabUpdateFileParams, GitLabUpda
}
if (params.lastCommitId) body.last_commit_id = params.lastCommitId
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
},
+112
View File
@@ -0,0 +1,112 @@
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 path (e.g. mygroup/myproject)',
},
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), 15 (Planner), 20 (Reporter), 25 (Security Manager), 30 (Developer), 40 (Maintainer), 50 (Owner)',
},
expiresAt: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'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.',
},
},
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<string, unknown> = {}
if (params.accessLevel !== undefined) body.access_level = params.accessLevel
// Send explicit empty string too, which clears an existing expiration.
if (params.expiresAt !== undefined) 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',
},
},
}
+1 -1
View File
@@ -26,7 +26,7 @@ export const gitlabUpdateIssueTool: ToolConfig<GitLabUpdateIssueParams, GitLabUp
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Project ID or URL-encoded path',
description: 'Project ID or path (e.g. mygroup/myproject)',
},
issueIid: {
type: 'number',
+117
View File
@@ -0,0 +1,117 @@
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 path (e.g. mygroup/myproject)',
},
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), 5 (Minimal), 10 (Guest), 15 (Planner), 20 (Reporter), 25 (Security Manager), 30 (Developer), 40 (Maintainer), 50 (Owner)',
},
expiresAt: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Access expiration date in YYYY-MM-DD format. Pass an empty string to clear an existing expiration.',
},
memberRoleId: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description:
'Custom member role ID (GitLab Ultimate only). Warning: when omitted, GitLab removes any custom role the member currently holds.',
},
},
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<string, unknown> = {
access_level: params.accessLevel,
}
// 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
},
},
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',
},
},
}
+11 -4
View File
@@ -31,7 +31,7 @@ export const gitlabUpdateMergeRequestTool: 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',
@@ -97,7 +97,8 @@ export const gitlabUpdateMergeRequestTool: ToolConfig<
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Mark as draft (work in progress)',
description:
'Mark as draft or remove draft status (applied via the "Draft:" title prefix; requires title to be set)',
},
},
@@ -114,7 +115,14 @@ export const gitlabUpdateMergeRequestTool: ToolConfig<
body: (params) => {
const body: Record<string, any> = {}
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
},
+105
View File
@@ -0,0 +1,105 @@
import type { GitLabUpdateUserParams, GitLabUserResponse } from '@/tools/gitlab/types'
import { getGitLabApiBase } from '@/tools/gitlab/utils'
import type { ToolConfig } from '@/tools/types'
export const gitlabUpdateUserTool: ToolConfig<GitLabUpdateUserParams, GitLabUserResponse> = {
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 (GitLab only allows changing to one of the user's existing verified secondary emails)",
},
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<string, unknown> = {}
if (params.email) body.email = params.email
if (params.username) body.username = params.username
if (params.name) body.name = params.name
// Strict boolean check: an untouched block switch serializes as `null`,
// which must not be sent (GitLab would treat it as a demotion).
if (typeof params.admin === 'boolean') 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',
},
},
}
@@ -0,0 +1,135 @@
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<GitLabUserActionParams, GitLabUserActionResponse> {
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 `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' && 'id' in data ? 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'
)
+20 -1
View File
@@ -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')
})
})
+21
View File
@@ -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}`
}
+54
View File
@@ -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,
@@ -6333,7 +6360,14 @@ export const tools: Record<string, ToolConfig> = {
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,
@@ -6344,8 +6378,14 @@ export const tools: Record<string, ToolConfig> = {
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,
@@ -6353,21 +6393,35 @@ export const tools: Record<string, ToolConfig> = {
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_member: gitlabUpdateMemberTool,
gitlab_update_merge_request: gitlabUpdateMergeRequestTool,
gitlab_update_user: gitlabUpdateUserTool,
grain_list_recordings: grainListRecordingsTool,
grain_get_recording: grainGetRecordingTool,
grain_get_transcript: grainGetTranscriptTool,