mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
co-authored by
Marcus Chandra
mzxchandra
greptile-apps[bot]
parent
b5196a41a8
commit
8adeaab8a5
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
})
|
||||
})
|
||||
+1688
-12
File diff suppressed because it is too large
Load Diff
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()}`
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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")',
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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)',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user