diff --git a/apps/docs/content/docs/en/integrations/gitlab.mdx b/apps/docs/content/docs/en/integrations/gitlab.mdx index bc977d46bad..cfeb0c53c8b 100644 --- a/apps/docs/content/docs/en/integrations/gitlab.mdx +++ b/apps/docs/content/docs/en/integrations/gitlab.mdx @@ -27,7 +27,7 @@ Using Sim’s GitLab integration, your agents can programmatically interact with ## Usage Instructions -Integrate GitLab into the workflow. Can manage projects, issues, merge requests, pipelines, and add comments. Supports all core GitLab DevOps operations. +Integrate GitLab into the workflow. Can manage projects, issues, merge requests, pipelines, and add comments, plus project/group membership, invitations, access requests, SAML group links, and instance user administration. Supports all core GitLab DevOps operations. @@ -67,7 +67,7 @@ Get details of a specific GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path \(e.g., "namespace/project"\) | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) \(e.g., "namespace/project"\) | #### Output @@ -84,13 +84,13 @@ List issues in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `state` | string | No | Filter by state \(opened, closed, all\) | | `labels` | string | No | Comma-separated list of label names | | `assigneeId` | number | No | Filter by assignee user ID | | `milestoneTitle` | string | No | Filter by milestone title | | `search` | string | No | Search issues by title and description | -| `orderBy` | string | No | Order by field \(created_at, updated_at, priority, due_date, relative_position, label_priority, milestone_due, popularity, title, weight\) | +| `orderBy` | string | No | Order by field \(created_at, updated_at, priority, due_date, relative_position, label_priority, milestone_due, popularity, weight\) | | `sort` | string | No | Sort direction \(asc, desc\) | | `perPage` | number | No | Number of results per page \(default 20, max 100\) | | `page` | number | No | Page number for pagination | @@ -111,7 +111,7 @@ Get details of a specific GitLab issue | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `issueIid` | number | Yes | Issue number within the project \(the # shown in GitLab UI\) | #### Output @@ -129,7 +129,7 @@ Create a new issue in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `title` | string | Yes | Issue title | | `description` | string | No | Issue description \(Markdown supported\) | | `labels` | string | No | Comma-separated list of label names | @@ -153,7 +153,7 @@ Update an existing issue in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `issueIid` | number | Yes | Issue internal ID \(IID\) | | `title` | string | No | New issue title | | `description` | string | No | New issue description \(Markdown supported\) | @@ -179,7 +179,7 @@ Delete an issue from a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `issueIid` | number | Yes | Issue internal ID \(IID\) | #### Output @@ -197,9 +197,10 @@ Add a comment to a GitLab issue | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `issueIid` | number | Yes | Issue internal ID \(IID\) | | `body` | string | Yes | Comment body \(Markdown supported\) | +| `internal` | boolean | No | Create the comment as an internal note visible only to project members | #### Output @@ -216,7 +217,7 @@ List merge requests in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `state` | string | No | Filter by state \(opened, closed, locked, merged, all\) | | `labels` | string | No | Comma-separated list of label names | | `sourceBranch` | string | No | Filter by source branch | @@ -242,7 +243,7 @@ Get details of a specific GitLab merge request | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `mergeRequestIid` | number | Yes | Merge request internal ID \(IID\) | #### Output @@ -260,7 +261,7 @@ Create a new merge request in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `sourceBranch` | string | Yes | Source branch name | | `targetBranch` | string | Yes | Target branch name | | `title` | string | Yes | Merge request title | @@ -270,7 +271,7 @@ Create a new merge request in a GitLab project | `milestoneId` | number | No | Milestone ID to assign | | `removeSourceBranch` | boolean | No | Delete source branch after merge | | `squash` | boolean | No | Squash commits on merge | -| `draft` | boolean | No | Mark as draft \(work in progress\) | +| `draft` | boolean | No | Mark as draft \(applied via the "Draft:" title prefix\) | #### Output @@ -287,7 +288,7 @@ Update an existing merge request in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `mergeRequestIid` | number | Yes | Merge request internal ID \(IID\) | | `title` | string | No | New merge request title | | `description` | string | No | New merge request description | @@ -298,7 +299,7 @@ Update an existing merge request in a GitLab project | `targetBranch` | string | No | New target branch | | `removeSourceBranch` | boolean | No | Delete source branch after merge | | `squash` | boolean | No | Squash commits on merge | -| `draft` | boolean | No | Mark as draft \(work in progress\) | +| `draft` | boolean | No | Mark as draft or remove draft status \(applied via the "Draft:" title prefix; requires title to be set\) | #### Output @@ -315,7 +316,7 @@ Merge a merge request in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `mergeRequestIid` | number | Yes | Merge request internal ID \(IID\) | | `mergeCommitMessage` | string | No | Custom merge commit message | | `squashCommitMessage` | string | No | Custom squash commit message | @@ -338,9 +339,10 @@ Add a comment to a GitLab merge request | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `mergeRequestIid` | number | Yes | Merge request internal ID \(IID\) | | `body` | string | Yes | Comment body \(Markdown supported\) | +| `internal` | boolean | No | Create the comment as an internal note visible only to project members | #### Output @@ -357,9 +359,9 @@ List pipelines in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `ref` | string | No | Filter by ref \(branch or tag\) | -| `status` | string | No | Filter by status \(created, waiting_for_resource, preparing, pending, running, success, failed, canceled, skipped, manual, scheduled\) | +| `status` | string | No | Filter by status \(created, waiting_for_resource, preparing, pending, running, success, failed, canceling, canceled, skipped, manual, scheduled, waiting_for_callback\) | | `orderBy` | string | No | Order by field \(id, status, ref, updated_at, user_id\) | | `sort` | string | No | Sort direction \(asc, desc\) | | `perPage` | number | No | Number of results per page \(default 20, max 100\) | @@ -381,7 +383,7 @@ Get details of a specific GitLab pipeline | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `pipelineId` | number | Yes | Pipeline ID | #### Output @@ -399,9 +401,10 @@ Trigger a new pipeline in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `ref` | string | Yes | Branch or tag to run the pipeline on | | `variables` | array | No | Array of variables for the pipeline \(each with key, value, and optional variable_type\) | +| `inputs` | json | No | Pipeline inputs as a key/value object \(for pipelines with spec:inputs\) | #### Output @@ -418,7 +421,7 @@ Retry a failed GitLab pipeline | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `pipelineId` | number | Yes | Pipeline ID | #### Output @@ -436,7 +439,7 @@ Cancel a running GitLab pipeline | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `pipelineId` | number | Yes | Pipeline ID | #### Output @@ -454,7 +457,7 @@ List files and directories in a GitLab project repository | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `path` | string | No | Path inside the repository to list | | `ref` | string | No | Branch, tag, or commit SHA to list from | | `recursive` | boolean | No | Whether to list files recursively | @@ -477,7 +480,7 @@ Get the contents of a file from a GitLab project repository | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `filePath` | string | Yes | Path to the file in the repository | | `ref` | string | Yes | Branch, tag, or commit SHA | @@ -491,7 +494,8 @@ Get the contents of a file from a GitLab project repository | `ref` | string | The branch, tag, or commit SHA | | `blobId` | string | The blob ID | | `lastCommitId` | string | The last commit ID that modified the file | -| `content` | string | The decoded file content | +| `content` | string | The decoded file content, truncated to 1M characters | +| `truncated` | boolean | Whether the content was truncated | ### `gitlab_create_file` @@ -502,10 +506,14 @@ Create a new file in a GitLab project repository | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `filePath` | string | Yes | Path to the file in the repository | | `branch` | string | Yes | Branch to commit the new file to | | `content` | string | Yes | File content | +| `startBranch` | string | No | Name of the base branch to create the target branch from, if it does not exist | +| `authorName` | string | No | Commit author name \(defaults to the token user\) | +| `authorEmail` | string | No | Commit author email \(defaults to the token user\) | +| `executeFilemode` | boolean | No | Enable the execute flag on the file | | `commitMessage` | string | Yes | Commit message | #### Output @@ -524,10 +532,14 @@ Update an existing file in a GitLab project repository | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `filePath` | string | Yes | Path to the file in the repository | | `branch` | string | Yes | Branch to commit the update to | | `content` | string | Yes | New file content | +| `startBranch` | string | No | Name of the base branch to create the target branch from, if it does not exist | +| `authorName` | string | No | Commit author name \(defaults to the token user\) | +| `authorEmail` | string | No | Commit author email \(defaults to the token user\) | +| `executeFilemode` | boolean | No | Enable or disable the execute flag on the file | | `commitMessage` | string | Yes | Commit message | | `lastCommitId` | string | No | Last known commit ID for the file \(optimistic locking\) | @@ -547,7 +559,7 @@ Create a new branch in a GitLab project repository | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `branch` | string | Yes | Name of the new branch | | `ref` | string | Yes | Source branch/tag/SHA | @@ -569,7 +581,7 @@ Delete a branch from a GitLab project repository | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `branch` | string | Yes | Name of the branch to delete | #### Output @@ -587,10 +599,12 @@ Compare two branches, tags, or commits in a GitLab project repository | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `from` | string | Yes | Commit SHA or branch/tag name to compare from | | `to` | string | Yes | Commit SHA or branch/tag name to compare to | | `straight` | boolean | No | Compare directly from..to instead of using the merge base \(defaults to false\) | +| `fromProjectId` | string | No | ID of the project to compare from \(for cross-fork comparisons\) | +| `unidiff` | boolean | No | Return diffs in unified diff format \(GitLab 16.5+\) | #### Output @@ -612,7 +626,7 @@ List branches in a GitLab project repository | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `search` | string | No | Filter branches by name | | `perPage` | number | No | Number of results per page \(default 20, max 100\) | | `page` | number | No | Page number for pagination | @@ -633,7 +647,7 @@ List commits in a GitLab project repository | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `refName` | string | No | Branch, tag, or revision range to list commits from | | `since` | string | No | Only commits after this ISO 8601 date | | `until` | string | No | Only commits before this ISO 8601 date | @@ -647,7 +661,7 @@ List commits in a GitLab project repository | Parameter | Type | Description | | --------- | ---- | ----------- | | `commits` | array | List of commits | -| `total` | number | Total number of commits | +| `total` | number | Number of commits returned on this page \(GitLab does not report a grand total for commits\) | ### `gitlab_get_merge_request_changes` @@ -658,7 +672,7 @@ Get the file changes (diffs) of a GitLab merge request | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `mergeRequestIid` | number | Yes | Merge request internal ID \(IID\) | #### Output @@ -667,7 +681,8 @@ Get the file changes (diffs) of a GitLab merge request | --------- | ---- | ----------- | | `mergeRequestIid` | number | The merge request internal ID \(IID\) | | `changes` | array | List of file changes \(diffs\) | -| `changesCount` | number | Number of changed files returned | +| `changesCount` | number | Number of changed files returned \(first 100\) | +| `hasMore` | boolean | Whether the merge request has more than 100 changed files \(results truncated\) | ### `gitlab_approve_merge_request` @@ -678,7 +693,7 @@ Approve a GitLab merge request | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `mergeRequestIid` | number | Yes | Merge request internal ID \(IID\) | | `sha` | string | No | HEAD SHA of the merge request to approve | @@ -699,7 +714,7 @@ List jobs for a GitLab pipeline | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `pipelineId` | number | Yes | Pipeline ID | | `scope` | string | No | Filter jobs by scope \(e.g. created, running, success, failed\) | | `includeRetried` | boolean | No | Whether to include retried jobs | @@ -722,14 +737,15 @@ Get the log (trace) of a GitLab job | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `jobId` | number | Yes | Job ID | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `log` | string | The job log \(trace\) output | +| `log` | string | The job log \(trace\) output, truncated to 200k characters | +| `truncated` | boolean | Whether the log was truncated | ### `gitlab_play_job` @@ -740,8 +756,9 @@ Trigger (play) a manual GitLab job | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `jobId` | number | Yes | Job ID | +| `jobVariables` | array | No | Variables for the manual job \(array of objects with key and value\) | #### Output @@ -761,7 +778,7 @@ List releases in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `orderBy` | string | No | Order by field \(released_at, created_at\) | | `sort` | string | No | Sort direction \(asc, desc\) | | `perPage` | number | No | Number of results per page \(default 20, max 100\) | @@ -783,12 +800,14 @@ Create a new release in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | -| `projectId` | string | Yes | Project ID or URL-encoded path | +| `projectId` | string | Yes | Project ID or path \(e.g. mygroup/myproject\) | | `tagName` | string | Yes | The Git tag for the release | | `name` | string | No | The release name | | `description` | string | No | Release description/notes \(Markdown supported\) | | `ref` | string | No | Commit SHA, branch, or tag to create the tag from if it does not already exist | | `releasedAt` | string | No | ISO 8601 date for an upcoming or historical release | +| `tagMessage` | string | No | Annotation message to use if creating a new annotated tag | +| `assetLinks` | array | No | Release asset links: array of objects with name, url, and optional link_type \(other, runbook, image, package\) | | `milestones` | array | No | Array of milestone titles to associate with the release | #### Output @@ -797,6 +816,940 @@ Create a new release in a GitLab project | --------- | ---- | ----------- | | `release` | object | The created GitLab release | +### `gitlab_list_members` + +List members of a GitLab project or group. Includes members inherited from ancestor groups by default. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `resourceType` | string | Yes | Whether the resource is a 'project' or a 'group' | +| `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | +| `directOnly` | boolean | No | When true, returns only direct members. Defaults to false, which also returns members inherited from ancestor groups. | +| `query` | string | No | Filter members by name, email, or username | +| `userIds` | string | No | Comma-separated user IDs to filter the results to | +| `state` | string | No | Filter inherited-member results by state: 'awaiting' or 'active' \(Premium/Ultimate; only applies when inherited members are included\) | +| `showSeatInfo` | boolean | No | Include seat information for each member | +| `perPage` | number | No | Number of results per page \(default 20, max 100\) | +| `page` | number | No | Page number for pagination | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `members` | array | List of project or group members | +| `total` | number | Total number of members | + +### `gitlab_add_member` + +Add an existing GitLab user to a project or group at a given access level + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `resourceType` | string | Yes | Whether the resource is a 'project' or a 'group' | +| `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | +| `userId` | number | No | The ID of the user to add. Provide either userId or username. | +| `username` | string | No | The username of the user to add. Provide either userId or username. | +| `accessLevel` | number | Yes | Access level: 0 \(No access\), 5 \(Minimal\), 10 \(Guest\), 15 \(Planner\), 20 \(Reporter\), 25 \(Security Manager\), 30 \(Developer\), 40 \(Maintainer\), 50 \(Owner\) | +| `expiresAt` | string | No | Access expiration date in YYYY-MM-DD format | +| `memberRoleId` | number | No | Custom member role ID \(GitLab Ultimate only\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `member` | object | The added member | +| `alreadyMember` | boolean | Whether the user was already a member \(add was a no-op\) | + +### `gitlab_update_member` + +Update a member's access level in a GitLab project or group + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `resourceType` | string | Yes | Whether the resource is a 'project' or a 'group' | +| `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | +| `userId` | number | Yes | The ID of the member to update | +| `accessLevel` | number | Yes | New access level: 0 \(No access\), 5 \(Minimal\), 10 \(Guest\), 15 \(Planner\), 20 \(Reporter\), 25 \(Security Manager\), 30 \(Developer\), 40 \(Maintainer\), 50 \(Owner\) | +| `expiresAt` | string | No | Access expiration date in YYYY-MM-DD format. Pass an empty string to clear an existing expiration. | +| `memberRoleId` | number | No | Custom member role ID \(GitLab Ultimate only\). Warning: when omitted, GitLab removes any custom role the member currently holds. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `member` | object | The updated member | + +### `gitlab_remove_member` + +Remove a member from a GitLab project or group + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `resourceType` | string | Yes | Whether the resource is a 'project' or a 'group' | +| `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | +| `userId` | number | Yes | The ID of the member to remove | +| `skipSubresources` | boolean | No | Skip deleting the member from subgroups and projects below the target \(defaults to false\) | +| `unassignIssuables` | boolean | No | Unassign the member from all issues and merge requests in the target \(defaults to false\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the member was removed successfully | + +### `gitlab_invite_member` + +Invite a person to a GitLab project or group by email address + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `resourceType` | string | Yes | Whether the resource is a 'project' or a 'group' | +| `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | +| `email` | string | Yes | Email address to invite \(comma-separated for multiple\) | +| `accessLevel` | number | Yes | Access level: 0 \(No access\), 5 \(Minimal\), 10 \(Guest\), 15 \(Planner\), 20 \(Reporter\), 25 \(Security Manager\), 30 \(Developer\), 40 \(Maintainer\), 50 \(Owner\) | +| `expiresAt` | string | No | Access expiration date in YYYY-MM-DD format | +| `memberRoleId` | number | No | Custom member role ID \(GitLab Ultimate only\) | +| `inviteSource` | string | No | Identifier recorded as the source of the invitation \(for attribution\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | string | Invitation status returned by GitLab | +| `message` | object | Per-email result detail, if any | + +### `gitlab_list_invitations` + +List pending email invitations for a GitLab project or group + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `resourceType` | string | Yes | Whether the resource is a 'project' or a 'group' | +| `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | +| `query` | string | No | Filter invitations by invited email | +| `perPage` | number | No | Number of results per page \(default 20, max 100\) | +| `page` | number | No | Page number for pagination | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `invitations` | array | List of pending invitations | +| `total` | number | Total number of invitations | + +### `gitlab_update_invitation` + +Update a pending invitation to a GitLab project or group + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `resourceType` | string | Yes | Whether the resource is a 'project' or a 'group' | +| `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | +| `email` | string | Yes | Email address of the invitation to update | +| `accessLevel` | number | No | New access level: 10 \(Guest\), 15 \(Planner\), 20 \(Reporter\), 25 \(Security Manager\), 30 \(Developer\), 40 \(Maintainer\), 50 \(Owner\) | +| `expiresAt` | string | No | Access expiration date \(ISO 8601, e.g. 2026-12-31T00:00:00Z; date-only also accepted\). At least one of accessLevel or expiresAt must be provided. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `invitation` | object | The updated invitation | + +### `gitlab_revoke_invitation` + +Revoke a pending email invitation to a GitLab project or group + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `resourceType` | string | Yes | Whether the resource is a 'project' or a 'group' | +| `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | +| `email` | string | Yes | Email address of the invitation to revoke | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the invitation was revoked successfully | + +### `gitlab_list_access_requests` + +List pending access requests for a GitLab project or group + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `resourceType` | string | Yes | Whether the resource is a 'project' or a 'group' | +| `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | +| `perPage` | number | No | Number of results per page \(default 20, max 100\) | +| `page` | number | No | Page number for pagination | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `accessRequests` | array | List of pending access requests | +| `total` | number | Total number of access requests | + +### `gitlab_approve_access_request` + +Approve a pending access request for a GitLab project or group + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `resourceType` | string | Yes | Whether the resource is a 'project' or a 'group' | +| `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | +| `userId` | number | Yes | The user ID of the access requester | +| `accessLevel` | number | No | Access level to grant: 10 \(Guest\), 15 \(Planner\), 20 \(Reporter\), 25 \(Security Manager\), 30 \(Developer\), 40 \(Maintainer\), 50 \(Owner\). Defaults to 30 \(Developer\). | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `accessRequest` | object | The approved access request | + +### `gitlab_deny_access_request` + +Deny (delete) a pending access request for a GitLab project or group + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `resourceType` | string | Yes | Whether the resource is a 'project' or a 'group' | +| `resourceId` | string | Yes | Project or group ID or path \(e.g. mygroup/myproject\) | +| `userId` | number | Yes | The user ID of the access requester | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the access request was denied successfully | + +### `gitlab_list_saml_group_links` + +List SAML group links for a GitLab group. Use this to detect whether a group is governed by SAML group sync before provisioning members. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `groupId` | string | Yes | Group ID or path \(e.g. my-org/my-group\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `samlGroupLinks` | array | List of SAML group links | +| `total` | number | Number of SAML group links | + +### `gitlab_search_users` + +Search for GitLab users by name, username, or email. Email matches must be exact; private emails match only with an admin token. Use this to resolve an email to a user ID before adding a member. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `search` | string | Yes | Name, username, or email to search for | +| `perPage` | number | No | Number of results per page \(default 20, max 100\) | +| `page` | number | No | Page number for pagination | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `users` | array | List of matching users | +| `total` | number | Total number of matching users | + +### `gitlab_create_user` + +Create a new GitLab user. Requires an administrator token with admin_mode on the instance. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `email` | string | Yes | The user's email address | +| `username` | string | Yes | The user's username | +| `name` | string | Yes | The user's display name | +| `password` | string | No | The user's password. Omit and set resetPassword to email a reset link instead. | +| `resetPassword` | boolean | No | Send the user a password reset link instead of setting a password | +| `forceRandomPassword` | boolean | No | Set a random password without emailing a reset link \(useful for SSO-only accounts\). One of password, resetPassword, or forceRandomPassword is required. | +| `admin` | boolean | No | Whether the new user is an administrator | +| `skipConfirmation` | boolean | No | Skip email confirmation for the new user | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `user` | object | The created user | + +### `gitlab_update_user` + +Modify an existing GitLab user. Requires an administrator token with admin_mode on the instance. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `userId` | number | Yes | The ID of the user to modify | +| `email` | string | No | The user's new email address \(GitLab only allows changing to one of the user's existing verified secondary emails\) | +| `username` | string | No | The user's new username | +| `name` | string | No | The user's new display name | +| `admin` | boolean | No | Whether the user is an administrator | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `user` | object | The updated user | + +### `gitlab_delete_user` + +Delete a GitLab user. Requires an administrator token with admin_mode on the instance. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `userId` | number | Yes | The ID of the user to delete | +| `hardDelete` | boolean | No | When true, contributions, personal projects, AND groups owned solely by this user are deleted rather than moved to a Ghost User | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the user was deleted successfully | + +### `gitlab_block_user` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `projects` | json | List of projects | +| `project` | json | Project details | +| `issues` | json | List of issues | +| `issue` | json | Issue details | +| `mergeRequests` | json | List of merge requests | +| `mergeRequest` | json | Merge request details | +| `mergeRequestIid` | number | Merge request internal ID \(IID\) | +| `pipelines` | json | List of pipelines | +| `pipeline` | json | Pipeline details | +| `note` | json | Comment/note details | +| `tree` | json | Repository tree entries | +| `content` | string | File contents \(decoded\) | +| `fileName` | string | File name | +| `filePath` | string | Path to the file in the repository | +| `branch` | string | Branch the file was committed to | +| `branches` | json | List of branches | +| `commits` | json | List of commits | +| `commit` | json | A single commit \(e.g. latest commit in a comparison\) | +| `name` | string | Created branch name | +| `protected` | boolean | Whether the branch is protected | +| `size` | number | File size in bytes | +| `ref` | string | The branch, tag, or commit SHA | +| `blobId` | string | The blob ID | +| `lastCommitId` | string | The last commit ID that modified the file | +| `webUrl` | string | Web URL | +| `changes` | json | Merge request file changes/diffs | +| `changesCount` | number | Number of changed files returned \(first 100\) | +| `hasMore` | boolean | Whether more changed files exist beyond the first 100 | +| `approvalsRequired` | number | Approvals required | +| `approvalsLeft` | number | Approvals remaining | +| `approvedBy` | json | List of approvers | +| `jobs` | json | Pipeline jobs | +| `log` | string | Job log output | +| `id` | number | Job ID | +| `status` | string | Job status | +| `diffs` | json | File diffs between two compared references | +| `compareTimeout` | boolean | Whether the comparison timed out | +| `compareSameRef` | boolean | Whether both compared references match | +| `releases` | json | List of releases | +| `release` | json | Release details | +| `members` | json | List of project or group members | +| `member` | json | A single member | +| `alreadyMember` | boolean | Whether the user was already a member | +| `invitations` | json | List of pending invitations | +| `invitation` | json | A single invitation | +| `accessRequests` | json | List of pending access requests | +| `accessRequest` | json | A single access request | +| `samlGroupLinks` | json | List of SAML group links | +| `samlGroupLink` | json | A single SAML group link | +| `message` | json | Per-email invitation result detail | +| `users` | json | List of matching users | +| `user` | json | User details | +| `total` | number | Total number of items available across all pages | +| `truncated` | boolean | Whether returned content \(file content or job log\) was truncated | +| `success` | boolean | Operation success status | + +### `gitlab_unblock_user` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `projects` | json | List of projects | +| `project` | json | Project details | +| `issues` | json | List of issues | +| `issue` | json | Issue details | +| `mergeRequests` | json | List of merge requests | +| `mergeRequest` | json | Merge request details | +| `mergeRequestIid` | number | Merge request internal ID \(IID\) | +| `pipelines` | json | List of pipelines | +| `pipeline` | json | Pipeline details | +| `note` | json | Comment/note details | +| `tree` | json | Repository tree entries | +| `content` | string | File contents \(decoded\) | +| `fileName` | string | File name | +| `filePath` | string | Path to the file in the repository | +| `branch` | string | Branch the file was committed to | +| `branches` | json | List of branches | +| `commits` | json | List of commits | +| `commit` | json | A single commit \(e.g. latest commit in a comparison\) | +| `name` | string | Created branch name | +| `protected` | boolean | Whether the branch is protected | +| `size` | number | File size in bytes | +| `ref` | string | The branch, tag, or commit SHA | +| `blobId` | string | The blob ID | +| `lastCommitId` | string | The last commit ID that modified the file | +| `webUrl` | string | Web URL | +| `changes` | json | Merge request file changes/diffs | +| `changesCount` | number | Number of changed files returned \(first 100\) | +| `hasMore` | boolean | Whether more changed files exist beyond the first 100 | +| `approvalsRequired` | number | Approvals required | +| `approvalsLeft` | number | Approvals remaining | +| `approvedBy` | json | List of approvers | +| `jobs` | json | Pipeline jobs | +| `log` | string | Job log output | +| `id` | number | Job ID | +| `status` | string | Job status | +| `diffs` | json | File diffs between two compared references | +| `compareTimeout` | boolean | Whether the comparison timed out | +| `compareSameRef` | boolean | Whether both compared references match | +| `releases` | json | List of releases | +| `release` | json | Release details | +| `members` | json | List of project or group members | +| `member` | json | A single member | +| `alreadyMember` | boolean | Whether the user was already a member | +| `invitations` | json | List of pending invitations | +| `invitation` | json | A single invitation | +| `accessRequests` | json | List of pending access requests | +| `accessRequest` | json | A single access request | +| `samlGroupLinks` | json | List of SAML group links | +| `samlGroupLink` | json | A single SAML group link | +| `message` | json | Per-email invitation result detail | +| `users` | json | List of matching users | +| `user` | json | User details | +| `total` | number | Total number of items available across all pages | +| `truncated` | boolean | Whether returned content \(file content or job log\) was truncated | +| `success` | boolean | Operation success status | + +### `gitlab_deactivate_user` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `projects` | json | List of projects | +| `project` | json | Project details | +| `issues` | json | List of issues | +| `issue` | json | Issue details | +| `mergeRequests` | json | List of merge requests | +| `mergeRequest` | json | Merge request details | +| `mergeRequestIid` | number | Merge request internal ID \(IID\) | +| `pipelines` | json | List of pipelines | +| `pipeline` | json | Pipeline details | +| `note` | json | Comment/note details | +| `tree` | json | Repository tree entries | +| `content` | string | File contents \(decoded\) | +| `fileName` | string | File name | +| `filePath` | string | Path to the file in the repository | +| `branch` | string | Branch the file was committed to | +| `branches` | json | List of branches | +| `commits` | json | List of commits | +| `commit` | json | A single commit \(e.g. latest commit in a comparison\) | +| `name` | string | Created branch name | +| `protected` | boolean | Whether the branch is protected | +| `size` | number | File size in bytes | +| `ref` | string | The branch, tag, or commit SHA | +| `blobId` | string | The blob ID | +| `lastCommitId` | string | The last commit ID that modified the file | +| `webUrl` | string | Web URL | +| `changes` | json | Merge request file changes/diffs | +| `changesCount` | number | Number of changed files returned \(first 100\) | +| `hasMore` | boolean | Whether more changed files exist beyond the first 100 | +| `approvalsRequired` | number | Approvals required | +| `approvalsLeft` | number | Approvals remaining | +| `approvedBy` | json | List of approvers | +| `jobs` | json | Pipeline jobs | +| `log` | string | Job log output | +| `id` | number | Job ID | +| `status` | string | Job status | +| `diffs` | json | File diffs between two compared references | +| `compareTimeout` | boolean | Whether the comparison timed out | +| `compareSameRef` | boolean | Whether both compared references match | +| `releases` | json | List of releases | +| `release` | json | Release details | +| `members` | json | List of project or group members | +| `member` | json | A single member | +| `alreadyMember` | boolean | Whether the user was already a member | +| `invitations` | json | List of pending invitations | +| `invitation` | json | A single invitation | +| `accessRequests` | json | List of pending access requests | +| `accessRequest` | json | A single access request | +| `samlGroupLinks` | json | List of SAML group links | +| `samlGroupLink` | json | A single SAML group link | +| `message` | json | Per-email invitation result detail | +| `users` | json | List of matching users | +| `user` | json | User details | +| `total` | number | Total number of items available across all pages | +| `truncated` | boolean | Whether returned content \(file content or job log\) was truncated | +| `success` | boolean | Operation success status | + +### `gitlab_activate_user` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `projects` | json | List of projects | +| `project` | json | Project details | +| `issues` | json | List of issues | +| `issue` | json | Issue details | +| `mergeRequests` | json | List of merge requests | +| `mergeRequest` | json | Merge request details | +| `mergeRequestIid` | number | Merge request internal ID \(IID\) | +| `pipelines` | json | List of pipelines | +| `pipeline` | json | Pipeline details | +| `note` | json | Comment/note details | +| `tree` | json | Repository tree entries | +| `content` | string | File contents \(decoded\) | +| `fileName` | string | File name | +| `filePath` | string | Path to the file in the repository | +| `branch` | string | Branch the file was committed to | +| `branches` | json | List of branches | +| `commits` | json | List of commits | +| `commit` | json | A single commit \(e.g. latest commit in a comparison\) | +| `name` | string | Created branch name | +| `protected` | boolean | Whether the branch is protected | +| `size` | number | File size in bytes | +| `ref` | string | The branch, tag, or commit SHA | +| `blobId` | string | The blob ID | +| `lastCommitId` | string | The last commit ID that modified the file | +| `webUrl` | string | Web URL | +| `changes` | json | Merge request file changes/diffs | +| `changesCount` | number | Number of changed files returned \(first 100\) | +| `hasMore` | boolean | Whether more changed files exist beyond the first 100 | +| `approvalsRequired` | number | Approvals required | +| `approvalsLeft` | number | Approvals remaining | +| `approvedBy` | json | List of approvers | +| `jobs` | json | Pipeline jobs | +| `log` | string | Job log output | +| `id` | number | Job ID | +| `status` | string | Job status | +| `diffs` | json | File diffs between two compared references | +| `compareTimeout` | boolean | Whether the comparison timed out | +| `compareSameRef` | boolean | Whether both compared references match | +| `releases` | json | List of releases | +| `release` | json | Release details | +| `members` | json | List of project or group members | +| `member` | json | A single member | +| `alreadyMember` | boolean | Whether the user was already a member | +| `invitations` | json | List of pending invitations | +| `invitation` | json | A single invitation | +| `accessRequests` | json | List of pending access requests | +| `accessRequest` | json | A single access request | +| `samlGroupLinks` | json | List of SAML group links | +| `samlGroupLink` | json | A single SAML group link | +| `message` | json | Per-email invitation result detail | +| `users` | json | List of matching users | +| `user` | json | User details | +| `total` | number | Total number of items available across all pages | +| `truncated` | boolean | Whether returned content \(file content or job log\) was truncated | +| `success` | boolean | Operation success status | + +### `gitlab_ban_user` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `projects` | json | List of projects | +| `project` | json | Project details | +| `issues` | json | List of issues | +| `issue` | json | Issue details | +| `mergeRequests` | json | List of merge requests | +| `mergeRequest` | json | Merge request details | +| `mergeRequestIid` | number | Merge request internal ID \(IID\) | +| `pipelines` | json | List of pipelines | +| `pipeline` | json | Pipeline details | +| `note` | json | Comment/note details | +| `tree` | json | Repository tree entries | +| `content` | string | File contents \(decoded\) | +| `fileName` | string | File name | +| `filePath` | string | Path to the file in the repository | +| `branch` | string | Branch the file was committed to | +| `branches` | json | List of branches | +| `commits` | json | List of commits | +| `commit` | json | A single commit \(e.g. latest commit in a comparison\) | +| `name` | string | Created branch name | +| `protected` | boolean | Whether the branch is protected | +| `size` | number | File size in bytes | +| `ref` | string | The branch, tag, or commit SHA | +| `blobId` | string | The blob ID | +| `lastCommitId` | string | The last commit ID that modified the file | +| `webUrl` | string | Web URL | +| `changes` | json | Merge request file changes/diffs | +| `changesCount` | number | Number of changed files returned \(first 100\) | +| `hasMore` | boolean | Whether more changed files exist beyond the first 100 | +| `approvalsRequired` | number | Approvals required | +| `approvalsLeft` | number | Approvals remaining | +| `approvedBy` | json | List of approvers | +| `jobs` | json | Pipeline jobs | +| `log` | string | Job log output | +| `id` | number | Job ID | +| `status` | string | Job status | +| `diffs` | json | File diffs between two compared references | +| `compareTimeout` | boolean | Whether the comparison timed out | +| `compareSameRef` | boolean | Whether both compared references match | +| `releases` | json | List of releases | +| `release` | json | Release details | +| `members` | json | List of project or group members | +| `member` | json | A single member | +| `alreadyMember` | boolean | Whether the user was already a member | +| `invitations` | json | List of pending invitations | +| `invitation` | json | A single invitation | +| `accessRequests` | json | List of pending access requests | +| `accessRequest` | json | A single access request | +| `samlGroupLinks` | json | List of SAML group links | +| `samlGroupLink` | json | A single SAML group link | +| `message` | json | Per-email invitation result detail | +| `users` | json | List of matching users | +| `user` | json | User details | +| `total` | number | Total number of items available across all pages | +| `truncated` | boolean | Whether returned content \(file content or job log\) was truncated | +| `success` | boolean | Operation success status | + +### `gitlab_unban_user` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `projects` | json | List of projects | +| `project` | json | Project details | +| `issues` | json | List of issues | +| `issue` | json | Issue details | +| `mergeRequests` | json | List of merge requests | +| `mergeRequest` | json | Merge request details | +| `mergeRequestIid` | number | Merge request internal ID \(IID\) | +| `pipelines` | json | List of pipelines | +| `pipeline` | json | Pipeline details | +| `note` | json | Comment/note details | +| `tree` | json | Repository tree entries | +| `content` | string | File contents \(decoded\) | +| `fileName` | string | File name | +| `filePath` | string | Path to the file in the repository | +| `branch` | string | Branch the file was committed to | +| `branches` | json | List of branches | +| `commits` | json | List of commits | +| `commit` | json | A single commit \(e.g. latest commit in a comparison\) | +| `name` | string | Created branch name | +| `protected` | boolean | Whether the branch is protected | +| `size` | number | File size in bytes | +| `ref` | string | The branch, tag, or commit SHA | +| `blobId` | string | The blob ID | +| `lastCommitId` | string | The last commit ID that modified the file | +| `webUrl` | string | Web URL | +| `changes` | json | Merge request file changes/diffs | +| `changesCount` | number | Number of changed files returned \(first 100\) | +| `hasMore` | boolean | Whether more changed files exist beyond the first 100 | +| `approvalsRequired` | number | Approvals required | +| `approvalsLeft` | number | Approvals remaining | +| `approvedBy` | json | List of approvers | +| `jobs` | json | Pipeline jobs | +| `log` | string | Job log output | +| `id` | number | Job ID | +| `status` | string | Job status | +| `diffs` | json | File diffs between two compared references | +| `compareTimeout` | boolean | Whether the comparison timed out | +| `compareSameRef` | boolean | Whether both compared references match | +| `releases` | json | List of releases | +| `release` | json | Release details | +| `members` | json | List of project or group members | +| `member` | json | A single member | +| `alreadyMember` | boolean | Whether the user was already a member | +| `invitations` | json | List of pending invitations | +| `invitation` | json | A single invitation | +| `accessRequests` | json | List of pending access requests | +| `accessRequest` | json | A single access request | +| `samlGroupLinks` | json | List of SAML group links | +| `samlGroupLink` | json | A single SAML group link | +| `message` | json | Per-email invitation result detail | +| `users` | json | List of matching users | +| `user` | json | User details | +| `total` | number | Total number of items available across all pages | +| `truncated` | boolean | Whether returned content \(file content or job log\) was truncated | +| `success` | boolean | Operation success status | + +### `gitlab_approve_user` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `projects` | json | List of projects | +| `project` | json | Project details | +| `issues` | json | List of issues | +| `issue` | json | Issue details | +| `mergeRequests` | json | List of merge requests | +| `mergeRequest` | json | Merge request details | +| `mergeRequestIid` | number | Merge request internal ID \(IID\) | +| `pipelines` | json | List of pipelines | +| `pipeline` | json | Pipeline details | +| `note` | json | Comment/note details | +| `tree` | json | Repository tree entries | +| `content` | string | File contents \(decoded\) | +| `fileName` | string | File name | +| `filePath` | string | Path to the file in the repository | +| `branch` | string | Branch the file was committed to | +| `branches` | json | List of branches | +| `commits` | json | List of commits | +| `commit` | json | A single commit \(e.g. latest commit in a comparison\) | +| `name` | string | Created branch name | +| `protected` | boolean | Whether the branch is protected | +| `size` | number | File size in bytes | +| `ref` | string | The branch, tag, or commit SHA | +| `blobId` | string | The blob ID | +| `lastCommitId` | string | The last commit ID that modified the file | +| `webUrl` | string | Web URL | +| `changes` | json | Merge request file changes/diffs | +| `changesCount` | number | Number of changed files returned \(first 100\) | +| `hasMore` | boolean | Whether more changed files exist beyond the first 100 | +| `approvalsRequired` | number | Approvals required | +| `approvalsLeft` | number | Approvals remaining | +| `approvedBy` | json | List of approvers | +| `jobs` | json | Pipeline jobs | +| `log` | string | Job log output | +| `id` | number | Job ID | +| `status` | string | Job status | +| `diffs` | json | File diffs between two compared references | +| `compareTimeout` | boolean | Whether the comparison timed out | +| `compareSameRef` | boolean | Whether both compared references match | +| `releases` | json | List of releases | +| `release` | json | Release details | +| `members` | json | List of project or group members | +| `member` | json | A single member | +| `alreadyMember` | boolean | Whether the user was already a member | +| `invitations` | json | List of pending invitations | +| `invitation` | json | A single invitation | +| `accessRequests` | json | List of pending access requests | +| `accessRequest` | json | A single access request | +| `samlGroupLinks` | json | List of SAML group links | +| `samlGroupLink` | json | A single SAML group link | +| `message` | json | Per-email invitation result detail | +| `users` | json | List of matching users | +| `user` | json | User details | +| `total` | number | Total number of items available across all pages | +| `truncated` | boolean | Whether returned content \(file content or job log\) was truncated | +| `success` | boolean | Operation success status | + +### `gitlab_reject_user` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `projects` | json | List of projects | +| `project` | json | Project details | +| `issues` | json | List of issues | +| `issue` | json | Issue details | +| `mergeRequests` | json | List of merge requests | +| `mergeRequest` | json | Merge request details | +| `mergeRequestIid` | number | Merge request internal ID \(IID\) | +| `pipelines` | json | List of pipelines | +| `pipeline` | json | Pipeline details | +| `note` | json | Comment/note details | +| `tree` | json | Repository tree entries | +| `content` | string | File contents \(decoded\) | +| `fileName` | string | File name | +| `filePath` | string | Path to the file in the repository | +| `branch` | string | Branch the file was committed to | +| `branches` | json | List of branches | +| `commits` | json | List of commits | +| `commit` | json | A single commit \(e.g. latest commit in a comparison\) | +| `name` | string | Created branch name | +| `protected` | boolean | Whether the branch is protected | +| `size` | number | File size in bytes | +| `ref` | string | The branch, tag, or commit SHA | +| `blobId` | string | The blob ID | +| `lastCommitId` | string | The last commit ID that modified the file | +| `webUrl` | string | Web URL | +| `changes` | json | Merge request file changes/diffs | +| `changesCount` | number | Number of changed files returned \(first 100\) | +| `hasMore` | boolean | Whether more changed files exist beyond the first 100 | +| `approvalsRequired` | number | Approvals required | +| `approvalsLeft` | number | Approvals remaining | +| `approvedBy` | json | List of approvers | +| `jobs` | json | Pipeline jobs | +| `log` | string | Job log output | +| `id` | number | Job ID | +| `status` | string | Job status | +| `diffs` | json | File diffs between two compared references | +| `compareTimeout` | boolean | Whether the comparison timed out | +| `compareSameRef` | boolean | Whether both compared references match | +| `releases` | json | List of releases | +| `release` | json | Release details | +| `members` | json | List of project or group members | +| `member` | json | A single member | +| `alreadyMember` | boolean | Whether the user was already a member | +| `invitations` | json | List of pending invitations | +| `invitation` | json | A single invitation | +| `accessRequests` | json | List of pending access requests | +| `accessRequest` | json | A single access request | +| `samlGroupLinks` | json | List of SAML group links | +| `samlGroupLink` | json | A single SAML group link | +| `message` | json | Per-email invitation result detail | +| `users` | json | List of matching users | +| `user` | json | User details | +| `total` | number | Total number of items available across all pages | +| `truncated` | boolean | Whether returned content \(file content or job log\) was truncated | +| `success` | boolean | Operation success status | + +### `gitlab_delete_user_identity` + +Delete a user's authentication identity (e.g. SAML or LDAP). Requires an administrator token with admin_mode on the instance. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `userId` | number | Yes | The ID of the user | +| `provider` | string | Yes | The external identity provider name \(e.g. saml, ldapmain\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the identity was deleted successfully | + +### `gitlab_add_saml_group_link` + +Add a SAML group link that maps an identity-provider group to a GitLab group at a given access level (GitLab Premium/Ultimate) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `groupId` | string | Yes | Group ID or path \(e.g. my-org/my-group\) | +| `samlGroupName` | string | Yes | The name of the SAML group as sent by the identity provider | +| `accessLevel` | number | Yes | Access level granted to members of the SAML group: 10 \(Guest\), 15 \(Planner\), 20 \(Reporter\), 25 \(Security Manager\), 30 \(Developer\), 40 \(Maintainer\), 50 \(Owner\) | +| `memberRoleId` | number | No | Custom member role ID \(GitLab Ultimate only\) | +| `provider` | string | No | Unique provider name that must match for this group link to be applied \(GitLab 18.2+\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `samlGroupLink` | object | The created SAML group link | + +### `gitlab_delete_saml_group_link` + +Delete a SAML group link from a GitLab group (GitLab Premium/Ultimate) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `groupId` | string | Yes | Group ID or path \(e.g. my-org/my-group\) | +| `samlGroupName` | string | Yes | The name of the SAML group link to delete | +| `provider` | string | No | Provider name of the link to delete. Required when multiple links share the same SAML group name. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the SAML group link was deleted successfully | + ## Triggers diff --git a/apps/sim/blocks/blocks/gitlab.test.ts b/apps/sim/blocks/blocks/gitlab.test.ts new file mode 100644 index 00000000000..fa959a3fae5 --- /dev/null +++ b/apps/sim/blocks/blocks/gitlab.test.ts @@ -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() + }) +}) diff --git a/apps/sim/blocks/blocks/gitlab.ts b/apps/sim/blocks/blocks/gitlab.ts index 6de2a6ba07b..36d5b5c6d7b 100644 --- a/apps/sim/blocks/blocks/gitlab.ts +++ b/apps/sim/blocks/blocks/gitlab.ts @@ -4,6 +4,123 @@ import { AuthMode, IntegrationType } from '@/blocks/types' import type { GitLabResponse } from '@/tools/gitlab/types' import { getTrigger } from '@/triggers' +/** + * Access/membership operations scoped to a project OR group. These take a + * `resourceType` (Project | Group) plus a `resourceId`. + */ +const RESOURCE_SCOPED_OPS = [ + 'gitlab_list_members', + 'gitlab_add_member', + 'gitlab_update_member', + 'gitlab_remove_member', + 'gitlab_invite_member', + 'gitlab_list_invitations', + 'gitlab_update_invitation', + 'gitlab_revoke_invitation', + 'gitlab_list_access_requests', + 'gitlab_approve_access_request', + 'gitlab_deny_access_request', +] + +/** Operations that require a target user ID (member target or admin user target). */ +const USER_ID_OPS = [ + 'gitlab_add_member', + 'gitlab_update_member', + 'gitlab_remove_member', + 'gitlab_approve_access_request', + 'gitlab_deny_access_request', + 'gitlab_update_user', + 'gitlab_delete_user', + 'gitlab_block_user', + 'gitlab_unblock_user', + 'gitlab_deactivate_user', + 'gitlab_activate_user', + 'gitlab_ban_user', + 'gitlab_unban_user', + 'gitlab_approve_user', + 'gitlab_reject_user', + 'gitlab_delete_user_identity', +] + +/** + * Operations that take the shared access-level dropdown (required unless + * noted). Update Member deliberately has its own dropdown without a default so + * an expiry-only edit cannot silently reset a Maintainer/Owner to Developer. + */ +const ACCESS_LEVEL_OPS = [ + 'gitlab_add_member', + 'gitlab_invite_member', + 'gitlab_approve_access_request', + 'gitlab_add_saml_group_link', +] + +/** Operations where the access level is required (approve/invitation update are optional). */ +const ACCESS_LEVEL_REQUIRED_OPS = [ + 'gitlab_add_member', + 'gitlab_invite_member', + 'gitlab_add_saml_group_link', +] + +/** Operations that support an access-expiration date. */ +const EXPIRES_AT_OPS = [ + 'gitlab_add_member', + 'gitlab_update_member', + 'gitlab_invite_member', + 'gitlab_update_invitation', +] + +/** Operations that support a custom member role ID (Ultimate). */ +const MEMBER_ROLE_OPS = [ + 'gitlab_add_member', + 'gitlab_update_member', + 'gitlab_invite_member', + 'gitlab_add_saml_group_link', +] + +/** Operations that take an email address. */ +const EMAIL_OPS = ['gitlab_invite_member', 'gitlab_update_invitation', 'gitlab_revoke_invitation'] + +/** Group-only SAML group link operations that take a group ID. */ +const SAML_LINK_OPS = [ + 'gitlab_list_saml_group_links', + 'gitlab_add_saml_group_link', + 'gitlab_delete_saml_group_link', +] + +/** SAML operations that take a SAML group name. */ +const SAML_NAME_OPS = ['gitlab_add_saml_group_link', 'gitlab_delete_saml_group_link'] + +/** Ops where the User ID field is strictly required (Add Member also accepts a username instead). */ +const USER_ID_REQUIRED_OPS = USER_ID_OPS.filter((op) => op !== 'gitlab_add_member') + +/** + * Parses an optional JSON text field at execution time. Returns undefined for + * blank input and throws a field-specific error for malformed JSON. + */ +function parseJsonParam(raw: unknown, label: string): any { + if (raw === undefined || raw === null || raw === '') return undefined + let parsed = raw + if (typeof raw === 'string') { + try { + parsed = JSON.parse(raw) + } catch { + throw new Error(`${label} must be valid JSON.`) + } + } + if (parsed === null) return undefined + if (typeof parsed !== 'object') { + throw new Error(`${label} must be a JSON object or array.`) + } + return parsed +} + +/** Maps a "Leave unchanged / Yes / No" dropdown value to true, false, or undefined. */ +function parseTriState(raw: unknown): boolean | undefined { + if (raw === 'true' || raw === true) return true + if (raw === 'false' || raw === false) return false + return undefined +} + export const GitLabBlock: BlockConfig = { type: 'gitlab', name: 'GitLab', @@ -11,7 +128,7 @@ export const GitLabBlock: BlockConfig = { authMode: AuthMode.ApiKey, triggerAllowed: true, longDescription: - 'Integrate GitLab into the workflow. Can manage projects, issues, merge requests, pipelines, and add comments. Supports all core GitLab DevOps operations.', + 'Integrate GitLab into the workflow. Can manage projects, issues, merge requests, pipelines, and add comments, plus project/group membership, invitations, access requests, SAML group links, and instance user administration. Supports all core GitLab DevOps operations.', docsLink: 'https://docs.sim.ai/integrations/gitlab', category: 'tools', integrationType: IntegrationType.DevOps, @@ -66,6 +183,35 @@ export const GitLabBlock: BlockConfig = { // Release Operations { label: 'List Releases', id: 'gitlab_list_releases' }, { label: 'Create Release', id: 'gitlab_create_release' }, + // Access / Membership Operations + { label: 'List Members', id: 'gitlab_list_members' }, + { label: 'Add Member', id: 'gitlab_add_member' }, + { label: 'Update Member', id: 'gitlab_update_member' }, + { label: 'Remove Member', id: 'gitlab_remove_member' }, + { label: 'Invite Member by Email', id: 'gitlab_invite_member' }, + { label: 'List Invitations', id: 'gitlab_list_invitations' }, + { label: 'Update Invitation', id: 'gitlab_update_invitation' }, + { label: 'Revoke Invitation', id: 'gitlab_revoke_invitation' }, + { label: 'List Access Requests', id: 'gitlab_list_access_requests' }, + { label: 'Approve Access Request', id: 'gitlab_approve_access_request' }, + { label: 'Deny Access Request', id: 'gitlab_deny_access_request' }, + { label: 'List SAML Group Links', id: 'gitlab_list_saml_group_links' }, + { label: 'Search Users', id: 'gitlab_search_users' }, + // User Administration Operations (require an admin token) + { label: 'Create User', id: 'gitlab_create_user' }, + { label: 'Update User', id: 'gitlab_update_user' }, + { label: 'Delete User', id: 'gitlab_delete_user' }, + { label: 'Block User', id: 'gitlab_block_user' }, + { label: 'Unblock User', id: 'gitlab_unblock_user' }, + { label: 'Deactivate User', id: 'gitlab_deactivate_user' }, + { label: 'Activate User', id: 'gitlab_activate_user' }, + { label: 'Ban User', id: 'gitlab_ban_user' }, + { label: 'Unban User', id: 'gitlab_unban_user' }, + { label: 'Approve User Signup', id: 'gitlab_approve_user' }, + { label: 'Reject User Signup', id: 'gitlab_reject_user' }, + { label: 'Delete User Identity', id: 'gitlab_delete_user_identity' }, + { label: 'Add SAML Group Link', id: 'gitlab_add_saml_group_link' }, + { label: 'Delete SAML Group Link', id: 'gitlab_delete_saml_group_link' }, ], value: () => 'gitlab_list_projects', }, @@ -300,6 +446,8 @@ Return ONLY the comment text - no explanations, no extra formatting.`, 'gitlab_get_file', 'gitlab_create_branch', 'gitlab_create_release', + 'gitlab_list_repository_tree', + 'gitlab_list_pipelines', ], }, }, @@ -463,11 +611,11 @@ Return ONLY the timestamp string - no explanations, no extra text.`, id: 'path', title: 'Path', type: 'short-input', - placeholder: 'Subdirectory path (optional)', + placeholder: 'File or subdirectory path filter (optional)', mode: 'advanced', condition: { field: 'operation', - value: ['gitlab_list_repository_tree'], + value: ['gitlab_list_repository_tree', 'gitlab_list_commits'], }, }, // Recursive tree listing @@ -493,6 +641,80 @@ Return ONLY the timestamp string - no explanations, no extra text.`, value: ['gitlab_list_commits'], }, }, + // Commit time range filters + { + id: 'since', + title: 'Since', + type: 'short-input', + placeholder: 'Only commits after this ISO 8601 date (optional)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_commits'], + }, + wandConfig: { + enabled: true, + prompt: `Generate an ISO 8601 timestamp based on the user's description of the earliest commit date. + +Return ONLY the timestamp string - no explanations, no extra text.`, + generationType: 'timestamp', + placeholder: 'Describe the start of the time range...', + }, + }, + { + id: 'until', + title: 'Until', + type: 'short-input', + placeholder: 'Only commits before this ISO 8601 date (optional)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_commits'], + }, + wandConfig: { + enabled: true, + prompt: `Generate an ISO 8601 timestamp based on the user's description of the latest commit date. + +Return ONLY the timestamp string - no explanations, no extra text.`, + generationType: 'timestamp', + placeholder: 'Describe the end of the time range...', + }, + }, + // Commit author filter + { + id: 'author', + title: 'Author', + type: 'short-input', + placeholder: 'Filter commits by author name or email (optional)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_commits'], + }, + }, + // Optimistic-locking guard (update file) + { + id: 'lastCommitId', + title: 'Last Commit ID', + type: 'short-input', + placeholder: 'Fail if the file changed since this commit SHA (optional)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_update_file'], + }, + }, + // Include retried jobs (list pipeline jobs) + { + id: 'includeRetried', + title: 'Include Retried Jobs', + type: 'switch', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_pipeline_jobs'], + }, + }, // Job scope filter (for list pipeline jobs) { id: 'scope', @@ -500,12 +722,18 @@ Return ONLY the timestamp string - no explanations, no extra text.`, type: 'dropdown', options: [ { label: 'All', id: '' }, - { label: 'Failed', id: 'failed' }, - { label: 'Success', id: 'success' }, - { label: 'Running', id: 'running' }, + { label: 'Created', id: 'created' }, + { label: 'Waiting for resource', id: 'waiting_for_resource' }, + { label: 'Preparing', id: 'preparing' }, { label: 'Pending', id: 'pending' }, + { label: 'Running', id: 'running' }, + { label: 'Success', id: 'success' }, + { label: 'Failed', id: 'failed' }, + { label: 'Canceling', id: 'canceling' }, { label: 'Canceled', id: 'canceled' }, + { label: 'Skipped', id: 'skipped' }, { label: 'Manual', id: 'manual' }, + { label: 'Scheduled', id: 'scheduled' }, ], value: () => '', mode: 'advanced', @@ -571,7 +799,12 @@ Return ONLY the timestamp string - no explanations, no extra text.`, mode: 'advanced', condition: { field: 'operation', - value: ['gitlab_create_issue', 'gitlab_update_issue'], + value: [ + 'gitlab_create_issue', + 'gitlab_update_issue', + 'gitlab_create_merge_request', + 'gitlab_update_merge_request', + ], }, }, // State filter for issues @@ -626,111 +859,999 @@ Return ONLY the timestamp string - no explanations, no extra text.`, value: ['gitlab_update_issue', 'gitlab_update_merge_request'], }, }, - // Pipeline status filter + // Pipeline status filter + { + id: 'pipelineStatus', + title: 'Pipeline Status', + type: 'dropdown', + options: [ + { label: 'All', id: '' }, + { label: 'Created', id: 'created' }, + { label: 'Waiting for resource', id: 'waiting_for_resource' }, + { label: 'Preparing', id: 'preparing' }, + { label: 'Pending', id: 'pending' }, + { label: 'Running', id: 'running' }, + { label: 'Success', id: 'success' }, + { label: 'Failed', id: 'failed' }, + { label: 'Canceling', id: 'canceling' }, + { label: 'Canceled', id: 'canceled' }, + { label: 'Skipped', id: 'skipped' }, + { label: 'Manual', id: 'manual' }, + { label: 'Scheduled', id: 'scheduled' }, + ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_pipelines'], + }, + }, + // Remove source branch after merge + { + id: 'removeSourceBranch', + title: 'Remove Source Branch', + type: 'switch', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_create_merge_request', 'gitlab_merge_merge_request'], + }, + }, + // Tri-state variants for Update MR: a switch cannot express "turn this + // off" without also clobbering the setting on every unrelated update. + { + id: 'updateRemoveSourceBranch', + title: 'Remove Source Branch', + type: 'dropdown', + options: [ + { label: 'Leave unchanged', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_update_merge_request'], + }, + }, + { + id: 'updateSquash', + title: 'Squash Commits', + type: 'dropdown', + options: [ + { label: 'Leave unchanged', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_update_merge_request'], + }, + }, + // Squash commits + { + id: 'squash', + title: 'Squash Commits', + type: 'switch', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_create_merge_request', 'gitlab_merge_merge_request'], + }, + }, + // Merge commit message + { + id: 'mergeCommitMessage', + title: 'Merge Commit Message', + type: 'long-input', + placeholder: 'Enter custom merge commit message (optional)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_merge_merge_request'], + }, + wandConfig: { + enabled: true, + prompt: `Generate a clear merge commit message based on the user's request. +The message should summarize what is being merged and why. + +Return ONLY the commit message - no explanations, no extra text.`, + placeholder: 'Describe the merge...', + }, + }, + // Search filter (projects and issues listings) + { + id: 'searchQuery', + title: 'Search', + type: 'short-input', + placeholder: 'Search projects (name/path/description) or issues (title/description)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_projects', 'gitlab_list_issues'], + }, + }, + // List-projects filters + { + id: 'owned', + title: 'Owned Only', + type: 'switch', + mode: 'advanced', + description: 'Only projects explicitly owned by the current user', + condition: { + field: 'operation', + value: ['gitlab_list_projects'], + }, + }, + { + id: 'membership', + title: 'Member Only', + type: 'switch', + mode: 'advanced', + description: 'Only projects the current user is a member of', + condition: { + field: 'operation', + value: ['gitlab_list_projects'], + }, + }, + { + id: 'visibility', + title: 'Visibility', + type: 'dropdown', + options: [ + { label: 'All', id: '' }, + { label: 'Public', id: 'public' }, + { label: 'Internal', id: 'internal' }, + { label: 'Private', id: 'private' }, + ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_projects'], + }, + }, + // List-issues filters + { + id: 'assigneeId', + title: 'Assignee ID', + type: 'short-input', + placeholder: 'Filter by assignee user ID (optional)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_issues'], + }, + }, + { + id: 'milestoneTitle', + title: 'Milestone', + type: 'short-input', + placeholder: 'Filter by milestone title (optional)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_issues'], + }, + }, + // List-MRs branch filters + { + id: 'sourceBranchFilter', + title: 'Source Branch', + type: 'short-input', + placeholder: 'Filter by source branch (optional)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_merge_requests'], + }, + }, + { + id: 'targetBranchFilter', + title: 'Target Branch', + type: 'short-input', + placeholder: 'Filter by target branch (optional)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_merge_requests'], + }, + }, + // Per-domain sort fields + { + id: 'projectOrderBy', + title: 'Order By', + type: 'dropdown', + options: [ + { label: 'Default (created)', id: '' }, + { label: 'ID', id: 'id' }, + { label: 'Name', id: 'name' }, + { label: 'Path', id: 'path' }, + { label: 'Created', id: 'created_at' }, + { label: 'Updated', id: 'updated_at' }, + { label: 'Last activity', id: 'last_activity_at' }, + ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_projects'], + }, + }, + { + id: 'issueOrderBy', + title: 'Order By', + type: 'dropdown', + options: [ + { label: 'Default (created)', id: '' }, + { label: 'Created', id: 'created_at' }, + { label: 'Updated', id: 'updated_at' }, + { label: 'Priority', id: 'priority' }, + { label: 'Due date', id: 'due_date' }, + { label: 'Relative position', id: 'relative_position' }, + { label: 'Label priority', id: 'label_priority' }, + { label: 'Milestone due', id: 'milestone_due' }, + { label: 'Popularity', id: 'popularity' }, + { label: 'Weight', id: 'weight' }, + ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_issues'], + }, + }, + { + id: 'mrOrderBy', + title: 'Order By', + type: 'dropdown', + options: [ + { label: 'Default (created)', id: '' }, + { label: 'Created', id: 'created_at' }, + { label: 'Updated', id: 'updated_at' }, + { label: 'Merged (GitLab 17.2+)', id: 'merged_at' }, + { label: 'Priority', id: 'priority' }, + { label: 'Label priority', id: 'label_priority' }, + { label: 'Milestone due', id: 'milestone_due' }, + { label: 'Popularity', id: 'popularity' }, + { label: 'Title', id: 'title' }, + ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_merge_requests'], + }, + }, + { + id: 'pipelineOrderBy', + title: 'Order By', + type: 'dropdown', + options: [ + { label: 'Default (ID)', id: '' }, + { label: 'ID', id: 'id' }, + { label: 'Status', id: 'status' }, + { label: 'Ref', id: 'ref' }, + { label: 'Updated', id: 'updated_at' }, + { label: 'User ID', id: 'user_id' }, + ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_pipelines'], + }, + }, + { + id: 'releaseOrderBy', + title: 'Order By', + type: 'dropdown', + options: [ + { label: 'Default (released)', id: '' }, + { label: 'Released', id: 'released_at' }, + { label: 'Created', id: 'created_at' }, + ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_releases'], + }, + }, + { + id: 'sortOrder', + title: 'Sort Direction', + type: 'dropdown', + options: [ + { label: 'Default (descending)', id: '' }, + { label: 'Ascending', id: 'asc' }, + { label: 'Descending', id: 'desc' }, + ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: [ + 'gitlab_list_projects', + 'gitlab_list_issues', + 'gitlab_list_merge_requests', + 'gitlab_list_pipelines', + 'gitlab_list_releases', + ], + }, + }, + // Pipeline variables and inputs (create pipeline) + { + id: 'pipelineVariables', + title: 'Pipeline Variables', + type: 'long-input', + placeholder: '{"DEPLOY_ENV": "staging"} or [{"key": "DEPLOY_ENV", "value": "staging"}]', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_create_pipeline'], + }, + wandConfig: { + enabled: true, + prompt: `Generate a JSON object mapping GitLab CI variable names to values based on the user's description, e.g. {"DEPLOY_ENV": "staging", "DRY_RUN": "true"}. + +Return ONLY the JSON - no explanations, no extra text.`, + placeholder: 'Describe the pipeline variables...', + }, + }, + { + id: 'pipelineInputs', + title: 'Pipeline Inputs', + type: 'long-input', + placeholder: '{"environment": "staging"} (for pipelines defining spec:inputs)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_create_pipeline'], + }, + wandConfig: { + enabled: true, + prompt: `Generate a JSON object of GitLab pipeline inputs (spec:inputs) based on the user's description, e.g. {"environment": "staging"}. + +Return ONLY the JSON - no explanations, no extra text.`, + placeholder: 'Describe the pipeline inputs...', + }, + }, + // Manual job variables (play job) + { + id: 'jobVariables', + title: 'Job Variables', + type: 'long-input', + placeholder: '{"VAR": "value"} or [{"key": "VAR", "value": "value"}]', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_play_job'], + }, + wandConfig: { + enabled: true, + prompt: `Generate a JSON object mapping GitLab job variable names to values based on the user's description, e.g. {"DEPLOY_TARGET": "eu-west"}. + +Return ONLY the JSON - no explanations, no extra text.`, + placeholder: 'Describe the job variables...', + }, + }, + // Release extras + { + id: 'tagMessage', + title: 'Tag Message', + type: 'short-input', + placeholder: 'Annotation message when creating a new tag (optional)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_create_release'], + }, + }, + { + id: 'assetLinks', + title: 'Asset Links', + type: 'long-input', + placeholder: '[{"name": "Binaries", "url": "https://example.com/bin.zip"}]', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_create_release'], + }, + wandConfig: { + enabled: true, + prompt: `Generate a JSON array of GitLab release asset links based on the user's description. Each entry has "name" and "url", and optionally "link_type" (other, runbook, image, package), e.g. [{"name": "Binaries", "url": "https://example.com/bin.zip"}]. + +Return ONLY the JSON array - no explanations, no extra text.`, + placeholder: 'Describe the release assets...', + }, + }, + // Commit authoring options (create/update file) + { + id: 'startBranch', + title: 'Start Branch', + type: 'short-input', + placeholder: 'Base branch to create the target branch from, if missing (optional)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_create_file', 'gitlab_update_file'], + }, + }, + { + id: 'authorName', + title: 'Author Name', + type: 'short-input', + placeholder: 'Commit author name (optional)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_create_file', 'gitlab_update_file'], + }, + }, + { + id: 'authorEmail', + title: 'Author Email', + type: 'short-input', + placeholder: 'Commit author email (optional)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_create_file', 'gitlab_update_file'], + }, + }, + { + id: 'executeFilemode', + title: 'Executable', + type: 'dropdown', + options: [ + { label: 'Leave unchanged', id: '' }, + { label: 'Enabled', id: 'true' }, + { label: 'Disabled', id: 'false' }, + ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_create_file', 'gitlab_update_file'], + }, + }, + // Cross-fork compare options + { + id: 'fromProjectId', + title: 'From Project ID', + type: 'short-input', + placeholder: 'Project to compare from, for cross-fork comparisons (optional)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_compare_branches'], + }, + }, + { + id: 'unidiff', + title: 'Unified Diff Format', + type: 'switch', + mode: 'advanced', + description: 'Return diffs in unified diff format (GitLab 16.5+)', + condition: { + field: 'operation', + value: ['gitlab_compare_branches'], + }, + }, + // Internal note toggle (comments) + { + id: 'internalNote', + title: 'Internal Note', + type: 'switch', + mode: 'advanced', + description: 'Visible only to project members with at least Reporter access', + condition: { + field: 'operation', + value: ['gitlab_create_issue_note', 'gitlab_create_merge_request_note'], + }, + }, + // Per page (pagination) + { + id: 'perPage', + title: 'Results Per Page', + type: 'short-input', + placeholder: 'Number of results per page (default: 20, max: 100)', + mode: 'advanced', + condition: { + field: 'operation', + value: [ + 'gitlab_list_projects', + 'gitlab_list_issues', + 'gitlab_list_merge_requests', + 'gitlab_list_pipelines', + 'gitlab_list_repository_tree', + 'gitlab_list_branches', + 'gitlab_list_commits', + 'gitlab_list_pipeline_jobs', + 'gitlab_list_releases', + 'gitlab_list_members', + 'gitlab_list_invitations', + 'gitlab_list_access_requests', + 'gitlab_search_users', + ], + }, + }, + // Page number + { + id: 'page', + title: 'Page Number', + type: 'short-input', + placeholder: 'Page number (default: 1)', + mode: 'advanced', + condition: { + field: 'operation', + value: [ + 'gitlab_list_projects', + 'gitlab_list_issues', + 'gitlab_list_merge_requests', + 'gitlab_list_pipelines', + 'gitlab_list_repository_tree', + 'gitlab_list_branches', + 'gitlab_list_commits', + 'gitlab_list_pipeline_jobs', + 'gitlab_list_releases', + 'gitlab_list_members', + 'gitlab_list_invitations', + 'gitlab_list_access_requests', + 'gitlab_search_users', + ], + }, + }, + // Resource type (project or group) for access/membership operations + { + id: 'resourceType', + title: 'Resource Type', + type: 'dropdown', + options: [ + { label: 'Project', id: 'project' }, + { label: 'Group', id: 'group' }, + ], + value: () => 'project', + required: true, + condition: { + field: 'operation', + value: RESOURCE_SCOPED_OPS, + }, + }, + // Project / group ID for access/membership operations + { + id: 'resourceId', + title: 'Project / Group ID', + type: 'short-input', + placeholder: 'Enter project or group ID or path (e.g., mygroup/myproject)', + required: true, + condition: { + field: 'operation', + value: RESOURCE_SCOPED_OPS, + }, + }, + // Group ID for SAML group link operations (group-scoped only) + { + id: 'groupId', + title: 'Group ID', + type: 'short-input', + placeholder: 'Enter group ID or path', + required: true, + condition: { + field: 'operation', + value: SAML_LINK_OPS, + }, + }, + // User ID (member target or admin user target) + { + id: 'userId', + title: 'User ID', + type: 'short-input', + placeholder: 'Enter the user ID', + required: { + field: 'operation', + value: USER_ID_REQUIRED_OPS, + }, + condition: { + field: 'operation', + value: USER_ID_OPS, + }, + }, + // Username alternative for Add Member (GitLab accepts user_id OR username) + { + id: 'username', + title: 'Username', + type: 'short-input', + placeholder: 'Username to add instead of a user ID', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_add_member'], + }, + }, + // Access level (named dropdown mapping to GitLab integer access levels) + { + id: 'accessLevel', + title: 'Access Level', + type: 'dropdown', + options: [ + { label: 'No access', id: '0' }, + { label: 'Minimal Access', id: '5' }, + { label: 'Guest', id: '10' }, + { label: 'Planner', id: '15' }, + { label: 'Reporter', id: '20' }, + { label: 'Security Manager', id: '25' }, + { label: 'Developer', id: '30' }, + { label: 'Maintainer', id: '40' }, + { label: 'Owner', id: '50' }, + ], + value: () => '30', + required: { + field: 'operation', + value: ACCESS_LEVEL_REQUIRED_OPS, + }, + condition: { + field: 'operation', + value: ACCESS_LEVEL_OPS, + }, + }, + // Access level for Update Member. Required by GitLab's edit-member API, but + // deliberately has NO default: the user must explicitly pick the level so an + // expiry-only edit can't silently downgrade a Maintainer/Owner to Developer. + { + id: 'memberAccessLevel', + title: 'Access Level', + type: 'dropdown', + options: [ + { label: 'No access', id: '0' }, + { label: 'Minimal Access', id: '5' }, + { label: 'Guest', id: '10' }, + { label: 'Planner', id: '15' }, + { label: 'Reporter', id: '20' }, + { label: 'Security Manager', id: '25' }, + { label: 'Developer', id: '30' }, + { label: 'Maintainer', id: '40' }, + { label: 'Owner', id: '50' }, + ], + required: true, + condition: { + field: 'operation', + value: ['gitlab_update_member'], + }, + }, + // Optional access level for Update Invitation. Defaults to "Leave unchanged" + // so updating only the expiration does not silently reset the access level. + { + id: 'invitationAccessLevel', + title: 'Access Level', + type: 'dropdown', + options: [ + { label: 'Leave unchanged', id: '' }, + { label: 'No access', id: '0' }, + { label: 'Minimal Access', id: '5' }, + { label: 'Guest', id: '10' }, + { label: 'Planner', id: '15' }, + { label: 'Reporter', id: '20' }, + { label: 'Security Manager', id: '25' }, + { label: 'Developer', id: '30' }, + { label: 'Maintainer', id: '40' }, + { label: 'Owner', id: '50' }, + ], + value: () => '', + condition: { + field: 'operation', + value: ['gitlab_update_invitation'], + }, + }, + // Access expiration date (first-class time-boxed grants) + { + id: 'expiresAt', + title: 'Expires At', + type: 'short-input', + placeholder: 'YYYY-MM-DD (optional) - access is revoked on this date', + condition: { + field: 'operation', + value: EXPIRES_AT_OPS, + }, + }, + // Explicit expiration clear (update member / update invitation). A blank + // Expires At field means "leave unchanged", so clearing needs its own toggle. + { + id: 'clearExpiresAt', + title: 'Clear Expiration', + type: 'switch', + mode: 'advanced', + description: 'Remove the existing access expiration date (overrides Expires At)', + condition: { + field: 'operation', + value: ['gitlab_update_member', 'gitlab_update_invitation'], + }, + }, + // Custom member role ID (GitLab Ultimate) + { + id: 'memberRoleId', + title: 'Member Role ID', + type: 'short-input', + placeholder: 'Custom role ID (GitLab Ultimate only)', + mode: 'advanced', + condition: { + field: 'operation', + value: MEMBER_ROLE_OPS, + }, + }, + // Email address (invitations) + { + id: 'email', + title: 'Email', + type: 'short-input', + placeholder: 'Email address (comma-separated for multiple invites)', + required: true, + condition: { + field: 'operation', + value: EMAIL_OPS, + }, + }, + // Filter for member / invitation listings + { + id: 'query', + title: 'Filter', + type: 'short-input', + placeholder: + 'Filter members by name/username, invitations by exact email, or branches by name', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_members', 'gitlab_list_invitations', 'gitlab_list_branches'], + }, + }, + // Invitation source attribution (invite member) + { + id: 'inviteSource', + title: 'Invite Source', + type: 'short-input', + placeholder: 'Identifier recorded as the source of the invitation (optional)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_invite_member'], + }, + }, + // Direct members only toggle (list members) + { + id: 'directMembersOnly', + title: 'Direct Members Only', + type: 'switch', + mode: 'advanced', + description: 'Exclude members inherited from ancestor groups', + condition: { + field: 'operation', + value: ['gitlab_list_members'], + }, + }, + // Remove-member cleanup options + { + id: 'skipSubresources', + title: 'Skip Subresources', + type: 'switch', + mode: 'advanced', + description: 'Keep the member in subgroups and projects below the target', + condition: { + field: 'operation', + value: ['gitlab_remove_member'], + }, + }, + { + id: 'unassignIssuables', + title: 'Unassign Issues & MRs', + type: 'switch', + mode: 'advanced', + description: 'Unassign the member from all issues and merge requests in the target', + condition: { + field: 'operation', + value: ['gitlab_remove_member'], + }, + }, + // List-members filters + { + id: 'memberUserIds', + title: 'User IDs', + type: 'short-input', + placeholder: 'Comma-separated user IDs to filter to (optional)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_members'], + }, + }, + { + id: 'memberState', + title: 'Member State', + type: 'dropdown', + description: + 'Only applies when inherited members are included (Premium/Ultimate); ignored with Direct Members Only', + options: [ + { label: 'All', id: '' }, + { label: 'Active', id: 'active' }, + { label: 'Awaiting', id: 'awaiting' }, + ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_members'], + }, + }, + { + id: 'showSeatInfo', + title: 'Show Seat Info', + type: 'switch', + mode: 'advanced', + description: 'Include seat information for each member (Premium/Ultimate)', + condition: { + field: 'operation', + value: ['gitlab_list_members'], + }, + }, + // User search query + { + id: 'userSearch', + title: 'Search', + type: 'short-input', + placeholder: 'Name, username, or email to search for', + required: true, + condition: { + field: 'operation', + value: ['gitlab_search_users'], + }, + }, + // SAML group name + { + id: 'samlGroupName', + title: 'SAML Group Name', + type: 'short-input', + placeholder: 'Name of the SAML group as sent by the identity provider', + required: true, + condition: { + field: 'operation', + value: SAML_NAME_OPS, + }, + }, + // SAML provider name (add/delete SAML group link) + { + id: 'samlProvider', + title: 'SAML Provider', + type: 'short-input', + placeholder: 'Provider name (required when multiple links share a group name)', + mode: 'advanced', + condition: { + field: 'operation', + value: SAML_NAME_OPS, + }, + }, + // Provider (delete user identity) + { + id: 'provider', + title: 'Identity Provider', + type: 'short-input', + placeholder: 'e.g., saml, ldapmain', + required: true, + condition: { + field: 'operation', + value: ['gitlab_delete_user_identity'], + }, + }, + // Hard delete toggle (delete user) + { + id: 'hardDelete', + title: 'Hard Delete', + type: 'switch', + mode: 'advanced', + description: + 'Delete contributions, personal projects, and solely-owned groups instead of moving them to a Ghost User', + condition: { + field: 'operation', + value: ['gitlab_delete_user'], + }, + }, + // User attributes (create/update user) + { + id: 'userAdminEmail', + title: 'Email', + type: 'short-input', + placeholder: "The user's email address", + required: { + field: 'operation', + value: ['gitlab_create_user'], + }, + condition: { + field: 'operation', + value: ['gitlab_create_user', 'gitlab_update_user'], + }, + }, + { + id: 'userAdminUsername', + title: 'Username', + type: 'short-input', + placeholder: "The user's username", + required: { + field: 'operation', + value: ['gitlab_create_user'], + }, + condition: { + field: 'operation', + value: ['gitlab_create_user', 'gitlab_update_user'], + }, + }, { - id: 'pipelineStatus', - title: 'Pipeline Status', - type: 'dropdown', - options: [ - { label: 'All', id: '' }, - { label: 'Running', id: 'running' }, - { label: 'Pending', id: 'pending' }, - { label: 'Success', id: 'success' }, - { label: 'Failed', id: 'failed' }, - { label: 'Canceled', id: 'canceled' }, - { label: 'Skipped', id: 'skipped' }, - ], - value: () => '', - mode: 'advanced', + id: 'userAdminName', + title: 'Full Name', + type: 'short-input', + placeholder: "The user's display name", + required: { + field: 'operation', + value: ['gitlab_create_user'], + }, condition: { field: 'operation', - value: ['gitlab_list_pipelines'], + value: ['gitlab_create_user', 'gitlab_update_user'], }, }, - // Remove source branch after merge { - id: 'removeSourceBranch', - title: 'Remove Source Branch', - type: 'switch', + id: 'userAdminPassword', + title: 'Password', + type: 'short-input', + password: true, + placeholder: 'Password (omit and enable Send Reset Link instead)', mode: 'advanced', condition: { field: 'operation', - value: ['gitlab_create_merge_request', 'gitlab_merge_merge_request'], + value: ['gitlab_create_user'], }, }, - // Squash commits { - id: 'squash', - title: 'Squash Commits', + id: 'resetPassword', + title: 'Send Password Reset Link', type: 'switch', mode: 'advanced', condition: { field: 'operation', - value: ['gitlab_merge_merge_request'], + value: ['gitlab_create_user'], }, }, - // Merge commit message { - id: 'mergeCommitMessage', - title: 'Merge Commit Message', - type: 'long-input', - placeholder: 'Enter custom merge commit message (optional)', + id: 'forceRandomPassword', + title: 'Force Random Password', + type: 'switch', mode: 'advanced', + description: 'Set a random password without emailing a reset link (for SSO-only accounts)', condition: { field: 'operation', - value: ['gitlab_merge_merge_request'], - }, - wandConfig: { - enabled: true, - prompt: `Generate a clear merge commit message based on the user's request. -The message should summarize what is being merged and why. - -Return ONLY the commit message - no explanations, no extra text.`, - placeholder: 'Describe the merge...', + value: ['gitlab_create_user'], }, }, - // Per page (pagination) { - id: 'perPage', - title: 'Results Per Page', - type: 'short-input', - placeholder: 'Number of results per page (default: 20, max: 100)', + id: 'skipConfirmation', + title: 'Skip Email Confirmation', + type: 'switch', mode: 'advanced', condition: { field: 'operation', - value: [ - 'gitlab_list_projects', - 'gitlab_list_issues', - 'gitlab_list_merge_requests', - 'gitlab_list_pipelines', - 'gitlab_list_repository_tree', - 'gitlab_list_branches', - 'gitlab_list_commits', - 'gitlab_list_pipeline_jobs', - 'gitlab_list_releases', - ], + value: ['gitlab_create_user'], }, }, - // Page number { - id: 'page', - title: 'Page Number', - type: 'short-input', - placeholder: 'Page number (default: 1)', + id: 'userAdminIsAdmin', + title: 'Administrator', + type: 'switch', mode: 'advanced', + description: 'Whether the user is an instance administrator', condition: { field: 'operation', - value: [ - 'gitlab_list_projects', - 'gitlab_list_issues', - 'gitlab_list_merge_requests', - 'gitlab_list_pipelines', - 'gitlab_list_repository_tree', - 'gitlab_list_branches', - 'gitlab_list_commits', - 'gitlab_list_pipeline_jobs', - 'gitlab_list_releases', - ], + value: ['gitlab_create_user', 'gitlab_update_user'], }, }, ...getTrigger('gitlab_push').subBlocks, @@ -777,6 +1898,33 @@ Return ONLY the commit message - no explanations, no extra text.`, 'gitlab_play_job', 'gitlab_list_releases', 'gitlab_create_release', + 'gitlab_list_members', + 'gitlab_add_member', + 'gitlab_update_member', + 'gitlab_remove_member', + 'gitlab_invite_member', + 'gitlab_list_invitations', + 'gitlab_update_invitation', + 'gitlab_revoke_invitation', + 'gitlab_list_access_requests', + 'gitlab_approve_access_request', + 'gitlab_deny_access_request', + 'gitlab_list_saml_group_links', + 'gitlab_search_users', + 'gitlab_create_user', + 'gitlab_update_user', + 'gitlab_delete_user', + 'gitlab_block_user', + 'gitlab_unblock_user', + 'gitlab_deactivate_user', + 'gitlab_activate_user', + 'gitlab_ban_user', + 'gitlab_unban_user', + 'gitlab_approve_user', + 'gitlab_reject_user', + 'gitlab_delete_user_identity', + 'gitlab_add_saml_group_link', + 'gitlab_delete_saml_group_link', ], config: { tool: (params) => { @@ -792,6 +1940,12 @@ Return ONLY the commit message - no explanations, no extra text.`, case 'gitlab_list_projects': return { ...baseParams, + owned: params.owned || undefined, + membership: params.membership || undefined, + search: params.searchQuery?.trim() || undefined, + visibility: params.visibility || undefined, + orderBy: params.projectOrderBy || undefined, + sort: params.sortOrder || undefined, perPage: params.perPage ? Number(params.perPage) : undefined, page: params.page ? Number(params.page) : undefined, } @@ -814,6 +1968,17 @@ Return ONLY the commit message - no explanations, no extra text.`, projectId: params.projectId.trim(), state: params.issueState !== 'all' ? params.issueState : undefined, labels: params.labels?.trim() || undefined, + search: params.searchQuery?.trim() || undefined, + assigneeId: (() => { + const raw = String(params.assigneeId ?? '').trim() + if (!raw) return undefined + const parsed = Number(raw) + if (Number.isNaN(parsed)) throw new Error('Assignee ID must be a number.') + return parsed + })(), + milestoneTitle: params.milestoneTitle?.trim() || undefined, + orderBy: params.issueOrderBy || undefined, + sort: params.sortOrder || undefined, perPage: params.perPage ? Number(params.perPage) : undefined, page: params.page ? Number(params.page) : undefined, } @@ -858,6 +2023,7 @@ Return ONLY the commit message - no explanations, no extra text.`, assigneeIds: params.assigneeIds ? params.assigneeIds.split(',').map((id: string) => Number(id.trim())) : undefined, + milestoneId: params.milestoneId ? Number(params.milestoneId) : undefined, stateEvent: params.stateEvent || undefined, } @@ -880,6 +2046,7 @@ Return ONLY the commit message - no explanations, no extra text.`, projectId: params.projectId.trim(), issueIid: Number(params.issueIid), body: params.body.trim(), + internal: params.internalNote || undefined, } case 'gitlab_list_merge_requests': @@ -891,6 +2058,10 @@ Return ONLY the commit message - no explanations, no extra text.`, projectId: params.projectId.trim(), state: params.mrState !== 'all' ? params.mrState : undefined, labels: params.labels?.trim() || undefined, + sourceBranch: params.sourceBranchFilter?.trim() || undefined, + targetBranch: params.targetBranchFilter?.trim() || undefined, + orderBy: params.mrOrderBy || undefined, + sort: params.sortOrder || undefined, perPage: params.perPage ? Number(params.perPage) : undefined, page: params.page ? Number(params.page) : undefined, } @@ -925,7 +2096,9 @@ Return ONLY the commit message - no explanations, no extra text.`, assigneeIds: params.assigneeIds ? params.assigneeIds.split(',').map((id: string) => Number(id.trim())) : undefined, + milestoneId: params.milestoneId ? Number(params.milestoneId) : undefined, removeSourceBranch: params.removeSourceBranch || undefined, + squash: params.squash || undefined, } case 'gitlab_update_merge_request': @@ -942,7 +2115,10 @@ Return ONLY the commit message - no explanations, no extra text.`, assigneeIds: params.assigneeIds ? params.assigneeIds.split(',').map((id: string) => Number(id.trim())) : undefined, + milestoneId: params.milestoneId ? Number(params.milestoneId) : undefined, stateEvent: params.stateEvent || undefined, + removeSourceBranch: parseTriState(params.updateRemoveSourceBranch), + squash: parseTriState(params.updateSquash), } case 'gitlab_merge_merge_request': @@ -967,6 +2143,7 @@ Return ONLY the commit message - no explanations, no extra text.`, projectId: params.projectId.trim(), mergeRequestIid: Number(params.mergeRequestIid), body: params.body.trim(), + internal: params.internalNote || undefined, } case 'gitlab_list_pipelines': @@ -977,6 +2154,9 @@ Return ONLY the commit message - no explanations, no extra text.`, ...baseParams, projectId: params.projectId.trim(), status: params.pipelineStatus || undefined, + ref: params.ref?.trim() || undefined, + orderBy: params.pipelineOrderBy || undefined, + sort: params.sortOrder || undefined, perPage: params.perPage ? Number(params.perPage) : undefined, page: params.page ? Number(params.page) : undefined, } @@ -999,6 +2179,19 @@ Return ONLY the commit message - no explanations, no extra text.`, ...baseParams, projectId: params.projectId.trim(), ref: params.ref.trim(), + variables: (() => { + const parsed = parseJsonParam(params.pipelineVariables, 'Pipeline Variables') + if (parsed === undefined) return undefined + // Accept {KEY: value} shorthand and normalize to GitLab's array form. + if (!Array.isArray(parsed) && typeof parsed === 'object') { + return Object.entries(parsed).map(([key, value]) => ({ + key, + value: String(value), + })) + } + return parsed + })(), + inputs: parseJsonParam(params.pipelineInputs, 'Pipeline Inputs'), } case 'gitlab_retry_pipeline': @@ -1020,6 +2213,7 @@ Return ONLY the commit message - no explanations, no extra text.`, ...baseParams, projectId: params.projectId.trim(), path: params.path?.trim() || undefined, + ref: params.ref?.trim() || undefined, recursive: params.recursive || undefined, perPage: params.perPage ? Number(params.perPage) : undefined, page: params.page ? Number(params.page) : undefined, @@ -1056,6 +2250,13 @@ Return ONLY the commit message - no explanations, no extra text.`, branch: params.branch.trim(), content: params.content, commitMessage: params.commitMessage.trim(), + startBranch: params.startBranch?.trim() || undefined, + authorName: params.authorName?.trim() || undefined, + authorEmail: params.authorEmail?.trim() || undefined, + executeFilemode: parseTriState(params.executeFilemode), + ...(params.operation === 'gitlab_update_file' + ? { lastCommitId: params.lastCommitId?.trim() || undefined } + : {}), } case 'gitlab_create_branch': @@ -1076,6 +2277,7 @@ Return ONLY the commit message - no explanations, no extra text.`, return { ...baseParams, projectId: params.projectId.trim(), + search: params.query?.trim() || undefined, perPage: params.perPage ? Number(params.perPage) : undefined, page: params.page ? Number(params.page) : undefined, } @@ -1104,6 +2306,8 @@ Return ONLY the commit message - no explanations, no extra text.`, from: params.compareFrom.trim(), to: params.compareTo.trim(), straight: params.straight || undefined, + fromProjectId: params.fromProjectId?.trim() || undefined, + unidiff: params.unidiff || undefined, } case 'gitlab_list_commits': @@ -1114,6 +2318,10 @@ Return ONLY the commit message - no explanations, no extra text.`, ...baseParams, projectId: params.projectId.trim(), refName: params.refName?.trim() || undefined, + since: params.since?.trim() || undefined, + until: params.until?.trim() || undefined, + path: params.path?.trim() || undefined, + author: params.author?.trim() || undefined, perPage: params.perPage ? Number(params.perPage) : undefined, page: params.page ? Number(params.page) : undefined, } @@ -1148,6 +2356,7 @@ Return ONLY the commit message - no explanations, no extra text.`, projectId: params.projectId.trim(), pipelineId: Number(params.pipelineId), scope: params.scope || undefined, + includeRetried: params.includeRetried || undefined, perPage: params.perPage ? Number(params.perPage) : undefined, page: params.page ? Number(params.page) : undefined, } @@ -1170,6 +2379,17 @@ Return ONLY the commit message - no explanations, no extra text.`, ...baseParams, projectId: params.projectId.trim(), jobId: Number(params.jobId), + jobVariables: (() => { + const parsed = parseJsonParam(params.jobVariables, 'Job Variables') + if (parsed === undefined) return undefined + if (!Array.isArray(parsed) && typeof parsed === 'object') { + return Object.entries(parsed).map(([key, value]) => ({ + key, + value: String(value), + })) + } + return parsed + })(), } case 'gitlab_list_releases': @@ -1179,6 +2399,8 @@ Return ONLY the commit message - no explanations, no extra text.`, return { ...baseParams, projectId: params.projectId.trim(), + orderBy: params.releaseOrderBy || undefined, + sort: params.sortOrder || undefined, perPage: params.perPage ? Number(params.perPage) : undefined, page: params.page ? Number(params.page) : undefined, } @@ -1195,6 +2417,8 @@ Return ONLY the commit message - no explanations, no extra text.`, description: params.description?.trim() || undefined, ref: params.ref?.trim() || undefined, releasedAt: params.releasedAt?.trim() || undefined, + tagMessage: params.tagMessage?.trim() || undefined, + assetLinks: parseJsonParam(params.assetLinks, 'Asset Links'), milestones: params.releaseMilestones ? params.releaseMilestones .split(',') @@ -1203,6 +2427,299 @@ Return ONLY the commit message - no explanations, no extra text.`, : undefined, } + case 'gitlab_list_members': + if (!params.resourceId?.trim()) { + throw new Error('Project / Group ID is required.') + } + return { + ...baseParams, + resourceType: params.resourceType || 'project', + resourceId: params.resourceId.trim(), + directOnly: params.directMembersOnly || undefined, + query: params.query?.trim() || undefined, + userIds: params.memberUserIds?.trim() || undefined, + state: params.memberState || undefined, + showSeatInfo: params.showSeatInfo || undefined, + perPage: params.perPage ? Number(params.perPage) : undefined, + page: params.page ? Number(params.page) : undefined, + } + + case 'gitlab_add_member': { + const addMemberUserId = String(params.userId ?? '').trim() + if ( + !params.resourceId?.trim() || + (!addMemberUserId && !params.username?.trim()) || + !params.accessLevel + ) { + throw new Error( + 'Project / Group ID, User ID (or Username), and Access Level are required.' + ) + } + return { + ...baseParams, + resourceType: params.resourceType || 'project', + resourceId: params.resourceId.trim(), + userId: addMemberUserId ? Number(addMemberUserId) : undefined, + username: params.username?.trim() || undefined, + accessLevel: Number(params.accessLevel), + expiresAt: params.expiresAt?.trim() || undefined, + memberRoleId: params.memberRoleId ? Number(params.memberRoleId) : undefined, + } + } + + case 'gitlab_update_member': + if (!params.resourceId?.trim() || !params.userId || !params.memberAccessLevel) { + throw new Error('Project / Group ID, User ID, and Access Level are required.') + } + return { + ...baseParams, + resourceType: params.resourceType || 'project', + resourceId: params.resourceId.trim(), + userId: Number(params.userId), + accessLevel: Number(params.memberAccessLevel), + expiresAt: params.clearExpiresAt ? '' : params.expiresAt?.trim() || undefined, + memberRoleId: params.memberRoleId ? Number(params.memberRoleId) : undefined, + } + + case 'gitlab_remove_member': + if (!params.resourceId?.trim() || !params.userId) { + throw new Error('Project / Group ID and User ID are required.') + } + return { + ...baseParams, + resourceType: params.resourceType || 'project', + resourceId: params.resourceId.trim(), + userId: Number(params.userId), + skipSubresources: params.skipSubresources || undefined, + unassignIssuables: params.unassignIssuables || undefined, + } + + case 'gitlab_invite_member': + if (!params.resourceId?.trim() || !params.email?.trim() || !params.accessLevel) { + throw new Error('Project / Group ID, Email, and Access Level are required.') + } + return { + ...baseParams, + resourceType: params.resourceType || 'project', + resourceId: params.resourceId.trim(), + email: params.email.trim(), + accessLevel: Number(params.accessLevel), + expiresAt: params.expiresAt?.trim() || undefined, + memberRoleId: params.memberRoleId ? Number(params.memberRoleId) : undefined, + inviteSource: params.inviteSource?.trim() || undefined, + } + + case 'gitlab_list_invitations': + if (!params.resourceId?.trim()) { + throw new Error('Project / Group ID is required.') + } + return { + ...baseParams, + resourceType: params.resourceType || 'project', + resourceId: params.resourceId.trim(), + query: params.query?.trim() || undefined, + perPage: params.perPage ? Number(params.perPage) : undefined, + page: params.page ? Number(params.page) : undefined, + } + + case 'gitlab_update_invitation': + if (!params.resourceId?.trim() || !params.email?.trim()) { + throw new Error('Project / Group ID and Email are required.') + } + if ( + !params.invitationAccessLevel && + !params.expiresAt?.trim() && + !params.clearExpiresAt + ) { + throw new Error( + 'At least one of Access Level, Expires At, or Clear Expiration is required.' + ) + } + return { + ...baseParams, + resourceType: params.resourceType || 'project', + resourceId: params.resourceId.trim(), + email: params.email.trim(), + // Only send access_level when a level is chosen; "Leave unchanged" + // ('') keeps the invitation's current level instead of resetting it. + accessLevel: params.invitationAccessLevel + ? Number(params.invitationAccessLevel) + : undefined, + expiresAt: params.clearExpiresAt ? '' : params.expiresAt?.trim() || undefined, + } + + case 'gitlab_revoke_invitation': + if (!params.resourceId?.trim() || !params.email?.trim()) { + throw new Error('Project / Group ID and Email are required.') + } + return { + ...baseParams, + resourceType: params.resourceType || 'project', + resourceId: params.resourceId.trim(), + email: params.email.trim(), + } + + case 'gitlab_list_access_requests': + if (!params.resourceId?.trim()) { + throw new Error('Project / Group ID is required.') + } + return { + ...baseParams, + resourceType: params.resourceType || 'project', + resourceId: params.resourceId.trim(), + perPage: params.perPage ? Number(params.perPage) : undefined, + page: params.page ? Number(params.page) : undefined, + } + + case 'gitlab_approve_access_request': + if (!params.resourceId?.trim() || !params.userId) { + throw new Error('Project / Group ID and User ID are required.') + } + return { + ...baseParams, + resourceType: params.resourceType || 'project', + resourceId: params.resourceId.trim(), + userId: Number(params.userId), + accessLevel: params.accessLevel ? Number(params.accessLevel) : undefined, + } + + case 'gitlab_deny_access_request': + if (!params.resourceId?.trim() || !params.userId) { + throw new Error('Project / Group ID and User ID are required.') + } + return { + ...baseParams, + resourceType: params.resourceType || 'project', + resourceId: params.resourceId.trim(), + userId: Number(params.userId), + } + + case 'gitlab_list_saml_group_links': + if (!params.groupId?.trim()) { + throw new Error('Group ID is required.') + } + return { + ...baseParams, + groupId: params.groupId.trim(), + } + + case 'gitlab_add_saml_group_link': + if (!params.groupId?.trim() || !params.samlGroupName?.trim() || !params.accessLevel) { + throw new Error('Group ID, SAML Group Name, and Access Level are required.') + } + return { + ...baseParams, + groupId: params.groupId.trim(), + samlGroupName: params.samlGroupName.trim(), + accessLevel: Number(params.accessLevel), + memberRoleId: params.memberRoleId ? Number(params.memberRoleId) : undefined, + provider: params.samlProvider?.trim() || undefined, + } + + case 'gitlab_delete_saml_group_link': + if (!params.groupId?.trim() || !params.samlGroupName?.trim()) { + throw new Error('Group ID and SAML Group Name are required.') + } + return { + ...baseParams, + groupId: params.groupId.trim(), + samlGroupName: params.samlGroupName.trim(), + provider: params.samlProvider?.trim() || undefined, + } + + case 'gitlab_search_users': + if (!params.userSearch?.trim()) { + throw new Error('Search query is required.') + } + return { + ...baseParams, + search: params.userSearch.trim(), + perPage: params.perPage ? Number(params.perPage) : undefined, + page: params.page ? Number(params.page) : undefined, + } + + case 'gitlab_create_user': + if ( + !params.userAdminEmail?.trim() || + !params.userAdminUsername?.trim() || + !params.userAdminName?.trim() + ) { + throw new Error('Email, Username, and Full Name are required.') + } + if ( + !params.userAdminPassword?.trim() && + !params.resetPassword && + !params.forceRandomPassword + ) { + throw new Error( + 'One of Password, Send Password Reset Link, or Force Random Password is required.' + ) + } + return { + ...baseParams, + email: params.userAdminEmail.trim(), + username: params.userAdminUsername.trim(), + name: params.userAdminName.trim(), + password: params.userAdminPassword?.trim() || undefined, + resetPassword: params.resetPassword || undefined, + forceRandomPassword: params.forceRandomPassword || undefined, + admin: params.userAdminIsAdmin || undefined, + skipConfirmation: params.skipConfirmation || undefined, + } + + case 'gitlab_update_user': + if (!params.userId) { + throw new Error('User ID is required.') + } + return { + ...baseParams, + userId: Number(params.userId), + email: params.userAdminEmail?.trim() || undefined, + username: params.userAdminUsername?.trim() || undefined, + name: params.userAdminName?.trim() || undefined, + // Only pass a real boolean: an explicit `false` demotes the user, + // while an untouched switch serializes as `null` and must be + // dropped so an unrelated update never touches the admin flag. + admin: + typeof params.userAdminIsAdmin === 'boolean' ? params.userAdminIsAdmin : undefined, + } + + case 'gitlab_delete_user': + if (!params.userId) { + throw new Error('User ID is required.') + } + return { + ...baseParams, + userId: Number(params.userId), + hardDelete: params.hardDelete || undefined, + } + + case 'gitlab_block_user': + case 'gitlab_unblock_user': + case 'gitlab_deactivate_user': + case 'gitlab_activate_user': + case 'gitlab_ban_user': + case 'gitlab_unban_user': + case 'gitlab_approve_user': + case 'gitlab_reject_user': + if (!params.userId) { + throw new Error('User ID is required.') + } + return { + ...baseParams, + userId: Number(params.userId), + } + + case 'gitlab_delete_user_identity': + if (!params.userId || !params.provider?.trim()) { + throw new Error('User ID and Identity Provider are required.') + } + return { + ...baseParams, + userId: Number(params.userId), + provider: params.provider.trim(), + } + default: return baseParams } @@ -1222,8 +2739,47 @@ Return ONLY the commit message - no explanations, no extra text.`, body: { type: 'string', description: 'Comment body' }, sourceBranch: { type: 'string', description: 'Source branch for merge request' }, targetBranch: { type: 'string', description: 'Target branch for merge request' }, - ref: { type: 'string', description: 'Branch or tag reference for pipeline' }, + ref: { + type: 'string', + description: + 'Branch, tag, or commit reference (pipelines, files, branches, releases, listings)', + }, labels: { type: 'string', description: 'Labels (comma-separated)' }, + searchQuery: { type: 'string', description: 'Search filter for project/issue listings' }, + owned: { type: 'boolean', description: 'Only owned projects' }, + membership: { type: 'boolean', description: 'Only projects the user is a member of' }, + visibility: { type: 'string', description: 'Project visibility filter' }, + assigneeId: { type: 'number', description: 'Assignee user ID filter for issues' }, + milestoneTitle: { type: 'string', description: 'Milestone title filter for issues' }, + sourceBranchFilter: { type: 'string', description: 'Source branch filter for MR listings' }, + targetBranchFilter: { type: 'string', description: 'Target branch filter for MR listings' }, + projectOrderBy: { type: 'string', description: 'Project listing sort field' }, + issueOrderBy: { type: 'string', description: 'Issue listing sort field' }, + mrOrderBy: { type: 'string', description: 'Merge request listing sort field' }, + pipelineOrderBy: { type: 'string', description: 'Pipeline listing sort field' }, + releaseOrderBy: { type: 'string', description: 'Release listing sort field' }, + sortOrder: { type: 'string', description: 'Sort direction (asc or desc)' }, + pipelineVariables: { + type: 'string', + description: 'Pipeline variables as JSON (object or key/value array)', + }, + pipelineInputs: { type: 'string', description: 'Pipeline spec:inputs as a JSON object' }, + jobVariables: { + type: 'string', + description: 'Manual job variables as JSON (object or key/value array)', + }, + tagMessage: { type: 'string', description: 'Annotation message for a newly created tag' }, + assetLinks: { type: 'string', description: 'Release asset links as a JSON array' }, + startBranch: { type: 'string', description: 'Base branch for new-branch file commits' }, + authorName: { type: 'string', description: 'Commit author name override' }, + authorEmail: { type: 'string', description: 'Commit author email override' }, + executeFilemode: { + type: 'string', + description: "Execute flag on the file ('true', 'false', or '' to leave unchanged)", + }, + fromProjectId: { type: 'string', description: 'Project to compare from (cross-fork)' }, + unidiff: { type: 'boolean', description: 'Return diffs in unified diff format' }, + internalNote: { type: 'boolean', description: 'Create the comment as an internal note' }, assigneeIds: { type: 'string', description: 'Assignee user IDs (comma-separated)' }, milestoneId: { type: 'number', description: 'Milestone ID' }, issueState: { type: 'string', description: 'Issue state filter (opened, closed, all)' }, @@ -1235,6 +2791,15 @@ Return ONLY the commit message - no explanations, no extra text.`, pipelineStatus: { type: 'string', description: 'Pipeline status filter' }, removeSourceBranch: { type: 'boolean', description: 'Remove source branch after merge' }, squash: { type: 'boolean', description: 'Squash commits on merge' }, + updateSquash: { + type: 'string', + description: "Squash setting on MR update ('true', 'false', or '' to leave unchanged)", + }, + updateRemoveSourceBranch: { + type: 'string', + description: + "Remove-source-branch setting on MR update ('true', 'false', or '' to leave unchanged)", + }, mergeCommitMessage: { type: 'string', description: 'Custom merge commit message' }, perPage: { type: 'number', description: 'Results per page' }, page: { type: 'number', description: 'Page number' }, @@ -1242,11 +2807,19 @@ Return ONLY the commit message - no explanations, no extra text.`, branch: { type: 'string', description: 'Branch name' }, content: { type: 'string', description: 'File content' }, commitMessage: { type: 'string', description: 'Commit message' }, + lastCommitId: { + type: 'string', + description: 'Optimistic-locking commit SHA for file updates', + }, jobId: { type: 'number', description: 'Job ID' }, - path: { type: 'string', description: 'Subdirectory path for repository tree' }, + path: { type: 'string', description: 'File or subdirectory path filter' }, recursive: { type: 'boolean', description: 'Recursively list repository tree' }, refName: { type: 'string', description: 'Branch or tag name filter' }, + since: { type: 'string', description: 'Only commits after this ISO 8601 date' }, + until: { type: 'string', description: 'Only commits before this ISO 8601 date' }, + author: { type: 'string', description: 'Filter commits by author name or email' }, scope: { type: 'string', description: 'Job scope filter' }, + includeRetried: { type: 'boolean', description: 'Include retried jobs in the listing' }, sha: { type: 'string', description: 'Commit SHA' }, compareFrom: { type: 'string', description: 'Branch, tag, or commit SHA to compare from' }, compareTo: { type: 'string', description: 'Branch, tag, or commit SHA to compare to' }, @@ -1255,6 +2828,63 @@ Return ONLY the commit message - no explanations, no extra text.`, releaseName: { type: 'string', description: 'Release name' }, releasedAt: { type: 'string', description: 'ISO 8601 date for the release' }, releaseMilestones: { type: 'string', description: 'Milestone titles (comma-separated)' }, + resourceType: { type: 'string', description: "Access resource type ('project' or 'group')" }, + resourceId: { type: 'string', description: 'Project or group ID or URL-encoded path' }, + groupId: { type: 'string', description: 'Group ID or URL-encoded path' }, + userId: { type: 'number', description: 'Target user ID' }, + username: { type: 'string', description: 'Username alternative for adding a member' }, + skipSubresources: { + type: 'boolean', + description: 'Keep the member in descendant subgroups/projects on removal', + }, + unassignIssuables: { + type: 'boolean', + description: 'Unassign the removed member from issues and MRs', + }, + inviteSource: { type: 'string', description: 'Attribution source recorded on the invitation' }, + memberUserIds: { type: 'string', description: 'Comma-separated user IDs filter for members' }, + memberState: { type: 'string', description: "Member state filter ('active' or 'awaiting')" }, + showSeatInfo: { type: 'boolean', description: 'Include seat information for members' }, + accessLevel: { type: 'number', description: 'GitLab access level (10-50)' }, + memberAccessLevel: { + type: 'string', + description: 'Access level for member updates (explicit choice, no default)', + }, + invitationAccessLevel: { + type: 'string', + description: 'Optional new access level for an invitation ("" leaves it unchanged)', + }, + expiresAt: { type: 'string', description: 'Access expiration date (YYYY-MM-DD)' }, + clearExpiresAt: { + type: 'boolean', + description: 'Remove the existing access expiration date (update member/invitation)', + }, + memberRoleId: { type: 'number', description: 'Custom member role ID (Ultimate)' }, + email: { type: 'string', description: 'Email address for invitations' }, + directMembersOnly: { type: 'boolean', description: 'Exclude inherited members' }, + query: { + type: 'string', + description: 'Filter members by name/username, invitations by email, or branches by name', + }, + userSearch: { type: 'string', description: 'User search query' }, + samlGroupName: { type: 'string', description: 'SAML group name' }, + samlProvider: { + type: 'string', + description: 'SAML provider name for a group link (disambiguates duplicate link names)', + }, + provider: { type: 'string', description: 'External identity provider name' }, + hardDelete: { type: 'boolean', description: 'Hard-delete a user' }, + userAdminEmail: { type: 'string', description: "User's email (create/update user)" }, + userAdminUsername: { type: 'string', description: "User's username (create/update user)" }, + userAdminName: { type: 'string', description: "User's display name (create/update user)" }, + userAdminPassword: { type: 'string', description: "User's password (create user)" }, + resetPassword: { type: 'boolean', description: 'Send a password reset link (create user)' }, + forceRandomPassword: { + type: 'boolean', + description: 'Set a random password without emailing a reset link (create user)', + }, + skipConfirmation: { type: 'boolean', description: 'Skip email confirmation (create user)' }, + userAdminIsAdmin: { type: 'boolean', description: 'Whether the user is an administrator' }, }, outputs: { // Project outputs @@ -1290,7 +2920,11 @@ Return ONLY the commit message - no explanations, no extra text.`, webUrl: { type: 'string', description: 'Web URL' }, // Merge request change outputs changes: { type: 'json', description: 'Merge request file changes/diffs' }, - changesCount: { type: 'number', description: 'Number of changed files returned' }, + changesCount: { type: 'number', description: 'Number of changed files returned (first 100)' }, + hasMore: { + type: 'boolean', + description: 'Whether more changed files exist beyond the first 100', + }, approvalsRequired: { type: 'number', description: 'Approvals required' }, approvalsLeft: { type: 'number', description: 'Approvals remaining' }, approvedBy: { type: 'json', description: 'List of approvers' }, @@ -1306,8 +2940,26 @@ Return ONLY the commit message - no explanations, no extra text.`, // Release outputs releases: { type: 'json', description: 'List of releases' }, release: { type: 'json', description: 'Release details' }, + // Access / membership outputs + members: { type: 'json', description: 'List of project or group members' }, + member: { type: 'json', description: 'A single member' }, + alreadyMember: { type: 'boolean', description: 'Whether the user was already a member' }, + invitations: { type: 'json', description: 'List of pending invitations' }, + invitation: { type: 'json', description: 'A single invitation' }, + accessRequests: { type: 'json', description: 'List of pending access requests' }, + accessRequest: { type: 'json', description: 'A single access request' }, + samlGroupLinks: { type: 'json', description: 'List of SAML group links' }, + samlGroupLink: { type: 'json', description: 'A single SAML group link' }, + message: { type: 'json', description: 'Per-email invitation result detail' }, + // User outputs + users: { type: 'json', description: 'List of matching users' }, + user: { type: 'json', description: 'User details' }, // Pagination total: { type: 'number', description: 'Total number of items available across all pages' }, + truncated: { + type: 'boolean', + description: 'Whether returned content (file content or job log) was truncated', + }, // Success indicator success: { type: 'boolean', description: 'Operation success status' }, }, @@ -1387,6 +3039,16 @@ export const GitLabBlockMeta = { tags: ['engineering', 'devops', 'team'], alsoIntegrations: ['slack'], }, + { + icon: GitLabIcon, + title: 'GitLab access governance agent', + prompt: + 'Build a scheduled workflow that lists pending GitLab access requests and invitations across our key groups, checks each requester against a table of approved teams, approves or denies the access requests at the right access level, revokes stale invitations older than 14 days, and posts a summary of every access decision to Slack.', + modules: ['scheduled', 'tables', 'agent', 'workflows'], + category: 'engineering', + tags: ['engineering', 'security', 'automation'], + alsoIntegrations: ['slack'], + }, { icon: GitLabIcon, title: 'GitLab repository knowledge base', @@ -1398,6 +3060,20 @@ export const GitLabBlockMeta = { }, ], skills: [ + { + name: 'provision-gitlab-member', + description: + 'Resolve a person to a GitLab user and add them to a project or group at the right access level.', + content: + '# Provision GitLab Member\n\nUse GitLab to grant someone access to a project or group.\n\n## Steps\n1. Search Users by name, username, or email to resolve the target user ID.\n2. If the user exists, use Add Member with the project/group, user ID, access level, and an expiration date for time-boxed access. A user who is already a member is reported as a soft success.\n3. If no GitLab user matches, use Invite Member by Email instead so they receive an email invitation at the same access level.\n\n## Output\nConfirm who was added or invited, to which project/group, at what access level, and any expiration date applied.', + }, + { + name: 'audit-gitlab-access-requests', + description: + 'List pending access requests for a GitLab project or group and approve or deny each one.', + content: + '# Audit GitLab Access Requests\n\nUse GitLab to work through pending access requests.\n\n## Steps\n1. List Access Requests for the project or group to see who is waiting.\n2. For each requester, decide based on the stated policy: Approve Access Request with an explicit access level, or Deny Access Request.\n3. Optionally List Members afterwards to confirm the roster reflects the decisions.\n\n## Output\nReturn a decision log: each requester, whether they were approved (and at what level) or denied, and any requests intentionally left pending.', + }, { name: 'review-merge-request', description: diff --git a/apps/sim/tools/gitlab/access.test.ts b/apps/sim/tools/gitlab/access.test.ts new file mode 100644 index 00000000000..65820f6f152 --- /dev/null +++ b/apps/sim/tools/gitlab/access.test.ts @@ -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 +} + +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 + expect(body.email).toBe('alice@example.com,bob@example.com') + }) + + it('passes a single email through unchanged', () => { + const body = gitlabInviteMemberTool.request.body?.({ + ...baseArgs, + email: 'alice@example.com', + accessLevel: 30, + }) as Record + expect(body.email).toBe('alice@example.com') + }) +}) + +describe('gitlab user status actions', () => { + it('returns success with no user object when GitLab responds with a bare true', async () => { + const result = await gitlabBlockUserTool.transformResponse!( + mockResponse({ ok: true, status: 201, json: true }), + {} as never + ) + expect(result.success).toBe(true) + expect(result.output.success).toBe(true) + expect(result.output.user).toBeUndefined() + }) + + it('surfaces the updated user object when GitLab returns one', async () => { + const result = await gitlabBlockUserTool.transformResponse!( + mockResponse({ ok: true, status: 201, json: { id: 9, state: 'blocked' } }), + {} as never + ) + expect(result.success).toBe(true) + expect(result.output.user).toEqual({ id: 9, state: 'blocked' }) + }) +}) diff --git a/apps/sim/tools/gitlab/add_member.ts b/apps/sim/tools/gitlab/add_member.ts new file mode 100644 index 00000000000..10b0ebe9dbd --- /dev/null +++ b/apps/sim/tools/gitlab/add_member.ts @@ -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 = { + 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 = { + 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)', + }, + }, +} diff --git a/apps/sim/tools/gitlab/add_saml_group_link.ts b/apps/sim/tools/gitlab/add_saml_group_link.ts new file mode 100644 index 00000000000..17b833853c7 --- /dev/null +++ b/apps/sim/tools/gitlab/add_saml_group_link.ts @@ -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 = { + 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', + }, + }, +} diff --git a/apps/sim/tools/gitlab/approve_access_request.ts b/apps/sim/tools/gitlab/approve_access_request.ts new file mode 100644 index 00000000000..8bdfeb3c3b6 --- /dev/null +++ b/apps/sim/tools/gitlab/approve_access_request.ts @@ -0,0 +1,101 @@ +import type { + GitLabApproveAccessRequestParams, + GitLabApproveAccessRequestResponse, +} from '@/tools/gitlab/types' +import { getGitLabApiBase, getGitLabResourcePath } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabApproveAccessRequestTool: ToolConfig< + GitLabApproveAccessRequestParams, + GitLabApproveAccessRequestResponse +> = { + id: 'gitlab_approve_access_request', + name: 'GitLab Approve Access Request', + description: 'Approve a pending access request for a GitLab project or group', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + resourceType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Whether the resource is a 'project' or a 'group'", + }, + resourceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project or group ID or 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', + }, + }, +} diff --git a/apps/sim/tools/gitlab/approve_merge_request.ts b/apps/sim/tools/gitlab/approve_merge_request.ts index 0db63c53539..c818b130e27 100644 --- a/apps/sim/tools/gitlab/approve_merge_request.ts +++ b/apps/sim/tools/gitlab/approve_merge_request.ts @@ -31,7 +31,7 @@ export const gitlabApproveMergeRequestTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: 'Project ID or URL-encoded path', + description: 'Project ID or path (e.g. mygroup/myproject)', }, mergeRequestIid: { type: 'number', diff --git a/apps/sim/tools/gitlab/cancel_pipeline.ts b/apps/sim/tools/gitlab/cancel_pipeline.ts index b16ef0534e9..005a0538ed0 100644 --- a/apps/sim/tools/gitlab/cancel_pipeline.ts +++ b/apps/sim/tools/gitlab/cancel_pipeline.ts @@ -28,7 +28,7 @@ export const gitlabCancelPipelineTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: 'Project ID or URL-encoded path', + description: 'Project ID or path (e.g. mygroup/myproject)', }, pipelineId: { type: 'number', diff --git a/apps/sim/tools/gitlab/compare_branches.ts b/apps/sim/tools/gitlab/compare_branches.ts index ae6df3deedd..f45e02a3042 100644 --- a/apps/sim/tools/gitlab/compare_branches.ts +++ b/apps/sim/tools/gitlab/compare_branches.ts @@ -31,7 +31,7 @@ export const gitlabCompareBranchesTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: 'Project ID or URL-encoded path', + description: 'Project ID or path (e.g. mygroup/myproject)', }, from: { type: 'string', @@ -51,6 +51,18 @@ export const gitlabCompareBranchesTool: ToolConfig< visibility: 'user-or-llm', description: 'Compare directly from..to instead of using the merge base (defaults to false)', }, + fromProjectId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'ID of the project to compare from (for cross-fork comparisons)', + }, + unidiff: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return diffs in unified diff format (GitLab 16.5+)', + }, }, request: { @@ -60,6 +72,10 @@ export const gitlabCompareBranchesTool: ToolConfig< queryParams.append('from', params.from) queryParams.append('to', params.to) if (params.straight) queryParams.append('straight', 'true') + if (params.fromProjectId) { + queryParams.append('from_project_id', String(params.fromProjectId).trim()) + } + if (params.unidiff) queryParams.append('unidiff', 'true') return `${getGitLabApiBase(params.host)}/projects/${encodedId}/repository/compare?${queryParams.toString()}` }, diff --git a/apps/sim/tools/gitlab/create_branch.ts b/apps/sim/tools/gitlab/create_branch.ts index 8fe6a8697c2..69e3b6dfe24 100644 --- a/apps/sim/tools/gitlab/create_branch.ts +++ b/apps/sim/tools/gitlab/create_branch.ts @@ -28,7 +28,7 @@ export const gitlabCreateBranchTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: 'Project ID or URL-encoded path', + description: 'Project ID or path (e.g. mygroup/myproject)', }, branch: { type: 'string', diff --git a/apps/sim/tools/gitlab/create_file.ts b/apps/sim/tools/gitlab/create_file.ts index f0468959b4a..7aa82c5de7a 100644 --- a/apps/sim/tools/gitlab/create_file.ts +++ b/apps/sim/tools/gitlab/create_file.ts @@ -25,7 +25,7 @@ export const gitlabCreateFileTool: ToolConfig ({ - branch: params.branch, - content: params.content, - commit_message: params.commitMessage, - encoding: 'text', - }), + body: (params) => { + const body: Record = { + branch: params.branch, + content: params.content, + commit_message: params.commitMessage, + encoding: 'text', + } + + if (params.startBranch) body.start_branch = params.startBranch + if (params.authorName) body.author_name = params.authorName + if (params.authorEmail) body.author_email = params.authorEmail + if (params.executeFilemode !== undefined) body.execute_filemode = params.executeFilemode + + return body + }, }, transformResponse: async (response) => { diff --git a/apps/sim/tools/gitlab/create_issue.ts b/apps/sim/tools/gitlab/create_issue.ts index f3830a81f0a..0e6d51e702c 100644 --- a/apps/sim/tools/gitlab/create_issue.ts +++ b/apps/sim/tools/gitlab/create_issue.ts @@ -26,7 +26,7 @@ export const gitlabCreateIssueTool: ToolConfig ({ - body: params.body, - }), + body: (params) => { + const body: Record = { body: params.body } + if (params.internal !== undefined) body.internal = params.internal + return body + }, }, transformResponse: async (response) => { diff --git a/apps/sim/tools/gitlab/create_merge_request.ts b/apps/sim/tools/gitlab/create_merge_request.ts index 9c47556553e..f0ebff56a36 100644 --- a/apps/sim/tools/gitlab/create_merge_request.ts +++ b/apps/sim/tools/gitlab/create_merge_request.ts @@ -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 = { source_branch: params.sourceBranch, target_branch: params.targetBranch, - title: params.title, + title, } if (params.description) body.description = params.description @@ -120,7 +126,6 @@ export const gitlabCreateMergeRequestTool: ToolConfig< if (params.removeSourceBranch !== undefined) body.remove_source_branch = params.removeSourceBranch if (params.squash !== undefined) body.squash = params.squash - if (params.draft !== undefined) body.draft = params.draft return body }, diff --git a/apps/sim/tools/gitlab/create_merge_request_note.ts b/apps/sim/tools/gitlab/create_merge_request_note.ts index 7f364efba51..09fc9fcffce 100644 --- a/apps/sim/tools/gitlab/create_merge_request_note.ts +++ b/apps/sim/tools/gitlab/create_merge_request_note.ts @@ -31,7 +31,7 @@ export const gitlabCreateMergeRequestNoteTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: 'Project ID or URL-encoded path', + description: 'Project ID or path (e.g. mygroup/myproject)', }, mergeRequestIid: { type: 'number', @@ -45,6 +45,12 @@ export const gitlabCreateMergeRequestNoteTool: ToolConfig< visibility: 'user-or-llm', description: 'Comment body (Markdown supported)', }, + internal: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Create the comment as an internal note visible only to project members', + }, }, request: { @@ -57,9 +63,11 @@ export const gitlabCreateMergeRequestNoteTool: ToolConfig< 'Content-Type': 'application/json', 'PRIVATE-TOKEN': params.accessToken, }), - body: (params) => ({ - body: params.body, - }), + body: (params) => { + const body: Record = { body: params.body } + if (params.internal !== undefined) body.internal = params.internal + return body + }, }, transformResponse: async (response) => { diff --git a/apps/sim/tools/gitlab/create_pipeline.ts b/apps/sim/tools/gitlab/create_pipeline.ts index 6a9777ea53b..5e238807ffe 100644 --- a/apps/sim/tools/gitlab/create_pipeline.ts +++ b/apps/sim/tools/gitlab/create_pipeline.ts @@ -28,7 +28,7 @@ export const gitlabCreatePipelineTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: 'Project ID or URL-encoded path', + description: 'Project ID or path (e.g. mygroup/myproject)', }, ref: { type: 'string', @@ -43,6 +43,12 @@ export const gitlabCreatePipelineTool: ToolConfig< description: 'Array of variables for the pipeline (each with key, value, and optional variable_type)', }, + inputs: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'Pipeline inputs as a key/value object (for pipelines with spec:inputs)', + }, }, request: { @@ -63,6 +69,9 @@ export const gitlabCreatePipelineTool: ToolConfig< if (params.variables && params.variables.length > 0) { body.variables = params.variables } + if (params.inputs && Object.keys(params.inputs).length > 0) { + body.inputs = params.inputs + } return body }, diff --git a/apps/sim/tools/gitlab/create_release.ts b/apps/sim/tools/gitlab/create_release.ts index 38d15f27e86..776f30055b7 100644 --- a/apps/sim/tools/gitlab/create_release.ts +++ b/apps/sim/tools/gitlab/create_release.ts @@ -28,7 +28,7 @@ export const gitlabCreateReleaseTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: 'Project ID or URL-encoded path', + description: 'Project ID or path (e.g. mygroup/myproject)', }, tagName: { type: 'string', @@ -60,6 +60,19 @@ export const gitlabCreateReleaseTool: ToolConfig< visibility: 'user-or-llm', description: 'ISO 8601 date for an upcoming or historical release', }, + tagMessage: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Annotation message to use if creating a new annotated tag', + }, + assetLinks: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: + 'Release asset links: array of objects with name, url, and optional link_type (other, runbook, image, package)', + }, milestones: { type: 'array', required: false, @@ -83,6 +96,13 @@ export const gitlabCreateReleaseTool: ToolConfig< tag_name: params.tagName, } + if (params.tagMessage) body.tag_message = params.tagMessage + if (params.assetLinks) { + // Tolerate a single link object by wrapping it into the array GitLab expects. + const links = Array.isArray(params.assetLinks) ? params.assetLinks : [params.assetLinks] + if (links.length > 0) body.assets = { links } + } + if (params.name) body.name = params.name if (params.description) body.description = params.description if (params.ref) body.ref = params.ref diff --git a/apps/sim/tools/gitlab/create_user.ts b/apps/sim/tools/gitlab/create_user.ts new file mode 100644 index 00000000000..da6de99992b --- /dev/null +++ b/apps/sim/tools/gitlab/create_user.ts @@ -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 = { + 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 = { + 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', + }, + }, +} diff --git a/apps/sim/tools/gitlab/delete_branch.ts b/apps/sim/tools/gitlab/delete_branch.ts index 2570b90bdbc..560e15bb39c 100644 --- a/apps/sim/tools/gitlab/delete_branch.ts +++ b/apps/sim/tools/gitlab/delete_branch.ts @@ -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', diff --git a/apps/sim/tools/gitlab/delete_issue.ts b/apps/sim/tools/gitlab/delete_issue.ts index 7521b826629..9b0687e6754 100644 --- a/apps/sim/tools/gitlab/delete_issue.ts +++ b/apps/sim/tools/gitlab/delete_issue.ts @@ -26,7 +26,7 @@ export const gitlabDeleteIssueTool: ToolConfig = { + 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', + }, + }, +} diff --git a/apps/sim/tools/gitlab/delete_user.ts b/apps/sim/tools/gitlab/delete_user.ts new file mode 100644 index 00000000000..7f7ee452189 --- /dev/null +++ b/apps/sim/tools/gitlab/delete_user.ts @@ -0,0 +1,79 @@ +import type { GitLabDeleteUserParams, GitLabDeleteUserResponse } from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabDeleteUserTool: ToolConfig = { + id: 'gitlab_delete_user', + name: 'GitLab Delete User', + description: + 'Delete a GitLab user. Requires an administrator token with admin_mode on the instance.', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab admin Personal Access Token (admin_mode)', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + userId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the user to delete', + }, + hardDelete: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + 'When true, contributions, 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', + }, + }, +} diff --git a/apps/sim/tools/gitlab/delete_user_identity.ts b/apps/sim/tools/gitlab/delete_user_identity.ts new file mode 100644 index 00000000000..2e0087ac987 --- /dev/null +++ b/apps/sim/tools/gitlab/delete_user_identity.ts @@ -0,0 +1,80 @@ +import type { + GitLabDeleteUserIdentityParams, + GitLabDeleteUserIdentityResponse, +} from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabDeleteUserIdentityTool: ToolConfig< + GitLabDeleteUserIdentityParams, + GitLabDeleteUserIdentityResponse +> = { + id: 'gitlab_delete_user_identity', + name: 'GitLab Delete User Identity', + description: + "Delete a user's authentication identity (e.g. SAML or LDAP). Requires an administrator token with admin_mode on the instance.", + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab admin Personal Access Token (admin_mode)', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + userId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the user', + }, + provider: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The external identity provider name (e.g. saml, ldapmain)', + }, + }, + + request: { + url: (params) => { + const encodedProvider = encodeURIComponent(String(params.provider).trim()) + return `${getGitLabApiBase(params.host)}/users/${params.userId}/identities/${encodedProvider}` + }, + method: 'DELETE', + headers: (params) => ({ + 'PRIVATE-TOKEN': params.accessToken, + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + error: `GitLab API error: ${response.status} ${errorText}`, + output: {}, + } + } + + return { + success: true, + output: { + success: true, + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the identity was deleted successfully', + }, + }, +} diff --git a/apps/sim/tools/gitlab/deny_access_request.ts b/apps/sim/tools/gitlab/deny_access_request.ts new file mode 100644 index 00000000000..a194f0869bd --- /dev/null +++ b/apps/sim/tools/gitlab/deny_access_request.ts @@ -0,0 +1,85 @@ +import type { + GitLabDenyAccessRequestParams, + GitLabDenyAccessRequestResponse, +} from '@/tools/gitlab/types' +import { getGitLabApiBase, getGitLabResourcePath } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabDenyAccessRequestTool: ToolConfig< + GitLabDenyAccessRequestParams, + GitLabDenyAccessRequestResponse +> = { + id: 'gitlab_deny_access_request', + name: 'GitLab Deny Access Request', + description: 'Deny (delete) a pending access request for a GitLab project or group', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + resourceType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Whether the resource is a 'project' or a 'group'", + }, + resourceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project or group ID or 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', + }, + }, +} diff --git a/apps/sim/tools/gitlab/get_file.ts b/apps/sim/tools/gitlab/get_file.ts index 7c394b2591f..bf4220b61ca 100644 --- a/apps/sim/tools/gitlab/get_file.ts +++ b/apps/sim/tools/gitlab/get_file.ts @@ -25,7 +25,7 @@ export const gitlabGetFileTool: ToolConfig maxContentChars return { success: true, @@ -76,7 +80,8 @@ export const gitlabGetFileTool: ToolConfig = { id: 'gitlab_get_job_log', name: 'GitLab Get Job Log', @@ -25,7 +33,7 @@ export const gitlabGetJobLogTool: ToolConfig MAX_LOG_CHARS, }, } }, @@ -69,7 +78,11 @@ export const gitlabGetJobLogTool: ToolConfig = { + 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 = { + 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', + }, + }, +} diff --git a/apps/sim/tools/gitlab/list_access_requests.ts b/apps/sim/tools/gitlab/list_access_requests.ts new file mode 100644 index 00000000000..0a1d97eb7c3 --- /dev/null +++ b/apps/sim/tools/gitlab/list_access_requests.ts @@ -0,0 +1,105 @@ +import type { + GitLabListAccessRequestsParams, + GitLabListAccessRequestsResponse, +} from '@/tools/gitlab/types' +import { getGitLabApiBase, getGitLabResourcePath } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabListAccessRequestsTool: ToolConfig< + GitLabListAccessRequestsParams, + GitLabListAccessRequestsResponse +> = { + id: 'gitlab_list_access_requests', + name: 'GitLab List Access Requests', + description: 'List pending access requests for a GitLab project or group', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + resourceType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Whether the resource is a 'project' or a 'group'", + }, + resourceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project or group ID or 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', + }, + }, +} diff --git a/apps/sim/tools/gitlab/list_branches.ts b/apps/sim/tools/gitlab/list_branches.ts index 2943c250285..bbf4dfd5e45 100644 --- a/apps/sim/tools/gitlab/list_branches.ts +++ b/apps/sim/tools/gitlab/list_branches.ts @@ -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', diff --git a/apps/sim/tools/gitlab/list_commits.ts b/apps/sim/tools/gitlab/list_commits.ts index b3be01049d2..cccfaf2b36d 100644 --- a/apps/sim/tools/gitlab/list_commits.ts +++ b/apps/sim/tools/gitlab/list_commits.ts @@ -26,7 +26,7 @@ export const gitlabListCommitsTool: ToolConfig = { + 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', + }, + }, +} diff --git a/apps/sim/tools/gitlab/list_issues.ts b/apps/sim/tools/gitlab/list_issues.ts index a2bb9d1416b..9d31a58cc78 100644 --- a/apps/sim/tools/gitlab/list_issues.ts +++ b/apps/sim/tools/gitlab/list_issues.ts @@ -25,7 +25,7 @@ export const gitlabListIssuesTool: ToolConfig = + { + id: 'gitlab_list_members', + name: 'GitLab List Members', + description: + 'List members of a GitLab project or group. Includes members inherited from ancestor groups by default.', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + resourceType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Whether the resource is a 'project' or a 'group'", + }, + resourceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project or group ID or 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', + }, + }, + } diff --git a/apps/sim/tools/gitlab/list_merge_requests.ts b/apps/sim/tools/gitlab/list_merge_requests.ts index f495320775d..27869500a85 100644 --- a/apps/sim/tools/gitlab/list_merge_requests.ts +++ b/apps/sim/tools/gitlab/list_merge_requests.ts @@ -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', diff --git a/apps/sim/tools/gitlab/list_pipeline_jobs.ts b/apps/sim/tools/gitlab/list_pipeline_jobs.ts index db63ccb8df6..d77e3620057 100644 --- a/apps/sim/tools/gitlab/list_pipeline_jobs.ts +++ b/apps/sim/tools/gitlab/list_pipeline_jobs.ts @@ -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', diff --git a/apps/sim/tools/gitlab/list_pipelines.ts b/apps/sim/tools/gitlab/list_pipelines.ts index bb21081e2ea..e632e2476ab 100644 --- a/apps/sim/tools/gitlab/list_pipelines.ts +++ b/apps/sim/tools/gitlab/list_pipelines.ts @@ -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', diff --git a/apps/sim/tools/gitlab/list_releases.ts b/apps/sim/tools/gitlab/list_releases.ts index cdb01bdd7dc..e6d8e281090 100644 --- a/apps/sim/tools/gitlab/list_releases.ts +++ b/apps/sim/tools/gitlab/list_releases.ts @@ -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', diff --git a/apps/sim/tools/gitlab/list_repository_tree.ts b/apps/sim/tools/gitlab/list_repository_tree.ts index efbc439b852..045e7fea596 100644 --- a/apps/sim/tools/gitlab/list_repository_tree.ts +++ b/apps/sim/tools/gitlab/list_repository_tree.ts @@ -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', diff --git a/apps/sim/tools/gitlab/list_saml_group_links.ts b/apps/sim/tools/gitlab/list_saml_group_links.ts new file mode 100644 index 00000000000..b46b61ea411 --- /dev/null +++ b/apps/sim/tools/gitlab/list_saml_group_links.ts @@ -0,0 +1,81 @@ +import type { + GitLabListSamlGroupLinksParams, + GitLabListSamlGroupLinksResponse, +} from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabListSamlGroupLinksTool: ToolConfig< + GitLabListSamlGroupLinksParams, + GitLabListSamlGroupLinksResponse +> = { + id: 'gitlab_list_saml_group_links', + name: 'GitLab List SAML Group Links', + description: + 'List SAML group links for a GitLab group. Use this to detect whether a group is governed by SAML group sync before provisioning members.', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + groupId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Group ID or 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', + }, + }, +} diff --git a/apps/sim/tools/gitlab/merge_merge_request.ts b/apps/sim/tools/gitlab/merge_merge_request.ts index 4991e67e209..dd86ab9cb24 100644 --- a/apps/sim/tools/gitlab/merge_merge_request.ts +++ b/apps/sim/tools/gitlab/merge_merge_request.ts @@ -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 }, diff --git a/apps/sim/tools/gitlab/play_job.ts b/apps/sim/tools/gitlab/play_job.ts index bd8a37effca..0640fbb8d33 100644 --- a/apps/sim/tools/gitlab/play_job.ts +++ b/apps/sim/tools/gitlab/play_job.ts @@ -25,7 +25,7 @@ export const gitlabPlayJobTool: ToolConfig ({ + 'Content-Type': 'application/json', 'PRIVATE-TOKEN': params.accessToken, }), + body: (params) => { + const body: Record = {} + if (params.jobVariables && params.jobVariables.length > 0) { + body.job_variables_attributes = params.jobVariables + } + return body + }, }, transformResponse: async (response) => { diff --git a/apps/sim/tools/gitlab/remove_member.ts b/apps/sim/tools/gitlab/remove_member.ts new file mode 100644 index 00000000000..5ee3d2ce5bb --- /dev/null +++ b/apps/sim/tools/gitlab/remove_member.ts @@ -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', + }, + }, +} diff --git a/apps/sim/tools/gitlab/retry_pipeline.ts b/apps/sim/tools/gitlab/retry_pipeline.ts index 470ade6d9f0..9ef1b4a1fd6 100644 --- a/apps/sim/tools/gitlab/retry_pipeline.ts +++ b/apps/sim/tools/gitlab/retry_pipeline.ts @@ -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', diff --git a/apps/sim/tools/gitlab/revoke_invitation.ts b/apps/sim/tools/gitlab/revoke_invitation.ts new file mode 100644 index 00000000000..3d3166169e8 --- /dev/null +++ b/apps/sim/tools/gitlab/revoke_invitation.ts @@ -0,0 +1,86 @@ +import type { + GitLabRevokeInvitationParams, + GitLabRevokeInvitationResponse, +} from '@/tools/gitlab/types' +import { getGitLabApiBase, getGitLabResourcePath } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabRevokeInvitationTool: ToolConfig< + GitLabRevokeInvitationParams, + GitLabRevokeInvitationResponse +> = { + id: 'gitlab_revoke_invitation', + name: 'GitLab Revoke Invitation', + description: 'Revoke a pending email invitation to a GitLab project or group', + version: '1.0.0', + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'GitLab Personal Access Token', + }, + host: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Self-managed GitLab host (e.g. gitlab.example.com). Defaults to gitlab.com.', + }, + resourceType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Whether the resource is a 'project' or a 'group'", + }, + resourceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Project or group ID or 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', + }, + }, +} diff --git a/apps/sim/tools/gitlab/search_users.ts b/apps/sim/tools/gitlab/search_users.ts new file mode 100644 index 00000000000..f0d152485f3 --- /dev/null +++ b/apps/sim/tools/gitlab/search_users.ts @@ -0,0 +1,93 @@ +import type { GitLabSearchUsersParams, GitLabSearchUsersResponse } from '@/tools/gitlab/types' +import { getGitLabApiBase } from '@/tools/gitlab/utils' +import type { ToolConfig } from '@/tools/types' + +export const gitlabSearchUsersTool: ToolConfig = + { + id: 'gitlab_search_users', + name: 'GitLab Search Users', + description: + 'Search for GitLab users by name, username, or email. 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', + }, + }, + } diff --git a/apps/sim/tools/gitlab/types.ts b/apps/sim/tools/gitlab/types.ts index 694244d6325..84f8cc9f08b 100644 --- a/apps/sim/tools/gitlab/types.ts +++ b/apps/sim/tools/gitlab/types.ts @@ -1,3 +1,4 @@ +import type { GitLabResourceType } from '@/tools/gitlab/utils' import type { ToolResponse } from '@/tools/types' interface GitLabProject { @@ -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 } 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 diff --git a/apps/sim/tools/gitlab/update_file.ts b/apps/sim/tools/gitlab/update_file.ts index 38a7beece3d..c3381453d8a 100644 --- a/apps/sim/tools/gitlab/update_file.ts +++ b/apps/sim/tools/gitlab/update_file.ts @@ -25,7 +25,7 @@ export const gitlabUpdateFileTool: ToolConfig = { + 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 = {} + + 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', + }, + }, +} diff --git a/apps/sim/tools/gitlab/update_issue.ts b/apps/sim/tools/gitlab/update_issue.ts index b2e8b8503aa..79535938804 100644 --- a/apps/sim/tools/gitlab/update_issue.ts +++ b/apps/sim/tools/gitlab/update_issue.ts @@ -26,7 +26,7 @@ export const gitlabUpdateIssueTool: ToolConfig = { + 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 = { + 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', + }, + }, +} diff --git a/apps/sim/tools/gitlab/update_merge_request.ts b/apps/sim/tools/gitlab/update_merge_request.ts index a0930567db9..037e8398d03 100644 --- a/apps/sim/tools/gitlab/update_merge_request.ts +++ b/apps/sim/tools/gitlab/update_merge_request.ts @@ -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 = {} - if (params.title) body.title = params.title + // GitLab has no `draft` body param — draft status is conveyed via the + // "Draft:" title prefix, so it can only be changed when a title is sent. + if (params.title) { + let title = params.title + if (params.draft === true && !/^\s*draft:/i.test(title)) title = `Draft: ${title}` + if (params.draft === false) title = title.replace(/^\s*draft:\s*/i, '') + body.title = title + } if (params.description !== undefined) body.description = params.description if (params.stateEvent) body.state_event = params.stateEvent if (params.labels !== undefined) body.labels = params.labels @@ -124,7 +132,6 @@ export const gitlabUpdateMergeRequestTool: ToolConfig< if (params.removeSourceBranch !== undefined) body.remove_source_branch = params.removeSourceBranch if (params.squash !== undefined) body.squash = params.squash - if (params.draft !== undefined) body.draft = params.draft return body }, diff --git a/apps/sim/tools/gitlab/update_user.ts b/apps/sim/tools/gitlab/update_user.ts new file mode 100644 index 00000000000..6a7831eac29 --- /dev/null +++ b/apps/sim/tools/gitlab/update_user.ts @@ -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 = { + 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 = {} + + 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', + }, + }, +} diff --git a/apps/sim/tools/gitlab/user_status_actions.ts b/apps/sim/tools/gitlab/user_status_actions.ts new file mode 100644 index 00000000000..05f891f3c3d --- /dev/null +++ b/apps/sim/tools/gitlab/user_status_actions.ts @@ -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 { + 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' +) diff --git a/apps/sim/tools/gitlab/utils.test.ts b/apps/sim/tools/gitlab/utils.test.ts index f7eca36aef8..792aa66766e 100644 --- a/apps/sim/tools/gitlab/utils.test.ts +++ b/apps/sim/tools/gitlab/utils.test.ts @@ -2,7 +2,12 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { getGitLabApiBase, normalizeGitLabHost, UnsafeGitLabHostError } from '@/tools/gitlab/utils' +import { + getGitLabApiBase, + getGitLabResourcePath, + normalizeGitLabHost, + UnsafeGitLabHostError, +} from '@/tools/gitlab/utils' describe('normalizeGitLabHost', () => { it('defaults to gitlab.com when the host is empty, blank, or not a string', () => { @@ -69,3 +74,17 @@ describe('getGitLabApiBase', () => { expect(() => getGitLabApiBase('legit.com@evil.com')).toThrow(UnsafeGitLabHostError) }) }) + +describe('getGitLabResourcePath', () => { + it('builds project and group path segments', () => { + expect(getGitLabResourcePath('project', 42)).toBe('projects/42') + expect(getGitLabResourcePath('group', 7)).toBe('groups/7') + }) + + it('URL-encodes namespaced paths and trims whitespace', () => { + expect(getGitLabResourcePath('project', ' mygroup/myproject ')).toBe( + 'projects/mygroup%2Fmyproject' + ) + expect(getGitLabResourcePath('group', 'parent/child')).toBe('groups/parent%2Fchild') + }) +}) diff --git a/apps/sim/tools/gitlab/utils.ts b/apps/sim/tools/gitlab/utils.ts index 6334a7030ee..7bc455a8a83 100644 --- a/apps/sim/tools/gitlab/utils.ts +++ b/apps/sim/tools/gitlab/utils.ts @@ -66,3 +66,24 @@ export function normalizeGitLabHost(rawHost: unknown): string { export function getGitLabApiBase(rawHost: unknown): string { return `https://${normalizeGitLabHost(rawHost)}/api/v4` } + +/** + * A GitLab access/membership resource is scoped either to a project or a group. + * The two share an identical endpoint surface (`/members`, `/invitations`, + * `/access_requests`) that differs only in the leading path segment. + */ +export type GitLabResourceType = 'project' | 'group' + +/** + * Builds the path segment for a project- or group-scoped access resource, e.g. + * `projects/mygroup%2Fmyproject` or `groups/42`. The id is URL-encoded so that + * URL-encoded paths (`mygroup/myproject`) and numeric ids both work. + */ +export function getGitLabResourcePath( + resourceType: GitLabResourceType, + resourceId: string | number +): string { + const encodedId = encodeURIComponent(String(resourceId).trim()) + const segment = resourceType === 'group' ? 'groups' : 'projects' + return `${segment}/${encodedId}` +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 3629be229e3..df2494cc974 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -1262,7 +1262,14 @@ import { githubUpdateReleaseV2Tool, } from '@/tools/github' import { + gitlabActivateUserTool, + gitlabAddMemberTool, + gitlabAddSamlGroupLinkTool, + gitlabApproveAccessRequestTool, gitlabApproveMergeRequestTool, + gitlabApproveUserTool, + gitlabBanUserTool, + gitlabBlockUserTool, gitlabCancelPipelineTool, gitlabCompareBranchesTool, gitlabCreateBranchTool, @@ -1273,8 +1280,14 @@ import { gitlabCreateMergeRequestTool, gitlabCreatePipelineTool, gitlabCreateReleaseTool, + gitlabCreateUserTool, + gitlabDeactivateUserTool, gitlabDeleteBranchTool, gitlabDeleteIssueTool, + gitlabDeleteSamlGroupLinkTool, + gitlabDeleteUserIdentityTool, + gitlabDeleteUserTool, + gitlabDenyAccessRequestTool, gitlabGetFileTool, gitlabGetIssueTool, gitlabGetJobLogTool, @@ -1282,21 +1295,35 @@ import { gitlabGetMergeRequestTool, gitlabGetPipelineTool, gitlabGetProjectTool, + gitlabInviteMemberTool, + gitlabListAccessRequestsTool, gitlabListBranchesTool, gitlabListCommitsTool, + gitlabListInvitationsTool, gitlabListIssuesTool, + gitlabListMembersTool, gitlabListMergeRequestsTool, gitlabListPipelineJobsTool, gitlabListPipelinesTool, gitlabListProjectsTool, gitlabListReleasesTool, gitlabListRepositoryTreeTool, + gitlabListSamlGroupLinksTool, gitlabMergeMergeRequestTool, gitlabPlayJobTool, + gitlabRejectUserTool, + gitlabRemoveMemberTool, gitlabRetryPipelineTool, + gitlabRevokeInvitationTool, + gitlabSearchUsersTool, + gitlabUnbanUserTool, + gitlabUnblockUserTool, gitlabUpdateFileTool, + gitlabUpdateInvitationTool, gitlabUpdateIssueTool, + gitlabUpdateMemberTool, gitlabUpdateMergeRequestTool, + gitlabUpdateUserTool, } from '@/tools/gitlab' import { gmailAddLabelTool, @@ -6267,7 +6294,14 @@ export const tools: Record = { github_check_star_v2: githubCheckStarV2Tool, github_list_stargazers: githubListStargazersTool, github_list_stargazers_v2: githubListStargazersV2Tool, + gitlab_activate_user: gitlabActivateUserTool, + gitlab_add_member: gitlabAddMemberTool, + gitlab_add_saml_group_link: gitlabAddSamlGroupLinkTool, + gitlab_approve_access_request: gitlabApproveAccessRequestTool, gitlab_approve_merge_request: gitlabApproveMergeRequestTool, + gitlab_approve_user: gitlabApproveUserTool, + gitlab_ban_user: gitlabBanUserTool, + gitlab_block_user: gitlabBlockUserTool, gitlab_cancel_pipeline: gitlabCancelPipelineTool, gitlab_compare_branches: gitlabCompareBranchesTool, gitlab_create_branch: gitlabCreateBranchTool, @@ -6278,8 +6312,14 @@ export const tools: Record = { gitlab_create_merge_request_note: gitlabCreateMergeRequestNoteTool, gitlab_create_pipeline: gitlabCreatePipelineTool, gitlab_create_release: gitlabCreateReleaseTool, + gitlab_create_user: gitlabCreateUserTool, + gitlab_deactivate_user: gitlabDeactivateUserTool, gitlab_delete_branch: gitlabDeleteBranchTool, gitlab_delete_issue: gitlabDeleteIssueTool, + gitlab_delete_saml_group_link: gitlabDeleteSamlGroupLinkTool, + gitlab_delete_user: gitlabDeleteUserTool, + gitlab_delete_user_identity: gitlabDeleteUserIdentityTool, + gitlab_deny_access_request: gitlabDenyAccessRequestTool, gitlab_get_file: gitlabGetFileTool, gitlab_get_issue: gitlabGetIssueTool, gitlab_get_job_log: gitlabGetJobLogTool, @@ -6287,21 +6327,35 @@ export const tools: Record = { gitlab_get_merge_request_changes: gitlabGetMergeRequestChangesTool, gitlab_get_pipeline: gitlabGetPipelineTool, gitlab_get_project: gitlabGetProjectTool, + gitlab_invite_member: gitlabInviteMemberTool, + gitlab_list_access_requests: gitlabListAccessRequestsTool, gitlab_list_branches: gitlabListBranchesTool, gitlab_list_commits: gitlabListCommitsTool, + gitlab_list_invitations: gitlabListInvitationsTool, gitlab_list_issues: gitlabListIssuesTool, + gitlab_list_members: gitlabListMembersTool, gitlab_list_merge_requests: gitlabListMergeRequestsTool, gitlab_list_pipeline_jobs: gitlabListPipelineJobsTool, gitlab_list_pipelines: gitlabListPipelinesTool, gitlab_list_projects: gitlabListProjectsTool, gitlab_list_releases: gitlabListReleasesTool, gitlab_list_repository_tree: gitlabListRepositoryTreeTool, + gitlab_list_saml_group_links: gitlabListSamlGroupLinksTool, gitlab_merge_merge_request: gitlabMergeMergeRequestTool, gitlab_play_job: gitlabPlayJobTool, + gitlab_reject_user: gitlabRejectUserTool, + gitlab_remove_member: gitlabRemoveMemberTool, gitlab_retry_pipeline: gitlabRetryPipelineTool, + gitlab_revoke_invitation: gitlabRevokeInvitationTool, + gitlab_search_users: gitlabSearchUsersTool, + gitlab_unban_user: gitlabUnbanUserTool, + gitlab_unblock_user: gitlabUnblockUserTool, gitlab_update_file: gitlabUpdateFileTool, + gitlab_update_invitation: gitlabUpdateInvitationTool, gitlab_update_issue: gitlabUpdateIssueTool, + gitlab_update_member: gitlabUpdateMemberTool, gitlab_update_merge_request: gitlabUpdateMergeRequestTool, + gitlab_update_user: gitlabUpdateUserTool, grain_list_recordings: grainListRecordingsTool, grain_get_recording: grainGetRecordingTool, grain_get_transcript: grainGetTranscriptTool,