Skip to content

Commit fb86b49

Browse files
feat(gitlab): dynamic access levels + group & membership ops + full group filters (#5743)
* feat(gitlab): dynamic access levels + group and membership operations Access levels can now be bound to runtime references (e.g. a policy-table lookup), not just picked from a static list. The three access-level fields (accessLevel, memberAccessLevel, invitationAccessLevel) switch from dropdown to combobox so a reference expression is accepted at Copilot save-time instead of being rejected as an invalid enum value. The resolved value is validated at execution time by coerceGitLabAccessLevel, which accepts an integer, a numeric string, or a level name ("Developer"), and throws a clear error otherwise - preserving the real safety property (no bad integer reaches GitLab) while dropping the false one (levels must be known at design time). Also closes demand gaps from the AskIT data: - get_group / list_groups: resolve and list groups (provisioning critical path) - list_user_memberships: admin GET /users/:id/memberships, gated in its description; the non-admin path is composing list_members The access-level enum is now a single source of truth in tools/gitlab/utils.ts (GITLAB_ACCESS_LEVELS) that the block options and runtime coercion both derive from, replacing three inline copies. * fix(gitlab): treat access level 0 ("No access") as a provided value A runtime reference can resolve to the integer 0, but the block still gated access-level presence with truthiness: required ops rejected 0 as missing and optional ops silently omitted it. Add hasGitLabAccessLevel(value) (0 and '0' are provided; undefined/null/'' are not) and use it for all six presence gates so "No access" can be granted or set via a reference. * feat(gitlab): expand group listing filters and harden access-level enum - list_groups: add visibility, min_access_level, and all_available filters (documented on GET /groups) plus order_by/sort, now surfaced in the block and forwarded in params - order_by widened to include GitLab's documented 'similarity' value - access-level enum label 'Minimal Access' -> 'Minimal access' to match GitLab verbatim; coercion already case-insensitive - min_access_level guard rejects 0 (GitLab floor is 5); reuse the access-level enum for the block dropdown (drops 'No access') - tests: out-of-enum numeric-string coercion case + full list_groups filter mapping * fix(gitlab): validate list_groups min-access-level and similarity ordering at execution time Greptile round 1 (both P1): - min_access_level: coerce via the shared access-level enum (coerceGitLabMinAccessLevel) and throw on out-of-enum values (31, 999) or 0, so a direct tool call fails loudly instead of sending an invalid filter to GitLab - order_by=similarity now requires a non-empty search term (GitLab ignores similarity ordering without one); throws a clear error otherwise - tests for both validations --------- Co-authored-by: Marcus Chandra <mzxchandra@gmail.com>
1 parent 772b710 commit fb86b49

11 files changed

Lines changed: 1169 additions & 66 deletions

File tree

apps/sim/blocks/blocks/gitlab.test.ts

Lines changed: 141 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,16 @@ describe('GitLabBlock access operations', () => {
2525
}
2626
})
2727

28-
it('exposes the named access-level dropdown with GitLab integer ids', () => {
28+
it('exposes the named access-level combobox with GitLab integer ids', () => {
2929
const accessLevel = block.subBlocks.find((s) => s.id === 'accessLevel')
30-
expect(accessLevel?.type).toBe('dropdown')
30+
// A combobox (not a dropdown) so the level can be bound to a runtime reference.
31+
expect(accessLevel?.type).toBe('combobox')
3132
const options = typeof accessLevel?.options === 'function' ? undefined : accessLevel?.options
3233
expect(options?.map((o) => o.id)).toEqual(['0', '5', '10', '15', '20', '25', '30', '40', '50'])
3334
expect(accessLevel?.value?.()).toBe('30')
3435
})
3536

36-
it('coerces the selected access level from the dropdown string to an integer at execution time', () => {
37+
it('coerces the selected access level from the combobox string to an integer at execution time', () => {
3738
const addParams = block.tools.config.params?.({
3839
accessToken: 'pat',
3940
operation: 'gitlab_add_member',
@@ -55,6 +56,69 @@ describe('GitLabBlock access operations', () => {
5556
expect(typeof addParams?.accessLevel).toBe('number')
5657
})
5758

59+
it('accepts an access level bound by name (from a resolved reference) and coerces it', () => {
60+
const byName = block.tools.config.params?.({
61+
accessToken: 'pat',
62+
operation: 'gitlab_add_member',
63+
resourceType: 'group',
64+
resourceId: '42',
65+
userId: '7',
66+
accessLevel: 'Developer',
67+
})
68+
expect(byName).toMatchObject({ userId: 7, accessLevel: 30 })
69+
expect(typeof byName?.accessLevel).toBe('number')
70+
})
71+
72+
it('accepts a runtime-resolved numeric 0 ("No access") on a required op', () => {
73+
// A reference can resolve to the number 0; a truthiness guard would wrongly
74+
// reject it as missing. It must pass and coerce to 0.
75+
const byZero = block.tools.config.params?.({
76+
accessToken: 'pat',
77+
operation: 'gitlab_add_member',
78+
resourceType: 'group',
79+
resourceId: '42',
80+
userId: '7',
81+
accessLevel: 0,
82+
})
83+
expect(byZero).toMatchObject({ userId: 7, accessLevel: 0 })
84+
expect(byZero?.accessLevel).toBe(0)
85+
})
86+
87+
it('sends a numeric 0 on optional ops instead of silently omitting it', () => {
88+
const approve = block.tools.config.params?.({
89+
accessToken: 'pat',
90+
operation: 'gitlab_approve_access_request',
91+
resourceType: 'group',
92+
resourceId: '42',
93+
userId: '7',
94+
accessLevel: 0,
95+
})
96+
expect(approve?.accessLevel).toBe(0)
97+
98+
const invite = block.tools.config.params?.({
99+
accessToken: 'pat',
100+
operation: 'gitlab_update_invitation',
101+
resourceType: 'group',
102+
resourceId: '42',
103+
email: 'a@b.com',
104+
invitationAccessLevel: 0,
105+
})
106+
expect(invite?.accessLevel).toBe(0)
107+
})
108+
109+
it('throws loudly when a resolved access level is not a valid GitLab level', () => {
110+
expect(() =>
111+
block.tools.config.params?.({
112+
accessToken: 'pat',
113+
operation: 'gitlab_add_member',
114+
resourceType: 'group',
115+
resourceId: '42',
116+
userId: '7',
117+
accessLevel: 'root',
118+
})
119+
).toThrow(/access level/i)
120+
})
121+
58122
it('defaults list members to inherited members (directOnly falsy)', () => {
59123
const listParams = block.tools.config.params?.({
60124
accessToken: 'pat',
@@ -117,7 +181,7 @@ describe('GitLabBlock access operations', () => {
117181

118182
it('exposes an optional access-level dropdown for update invitation that defaults to unchanged', () => {
119183
const invAccess = block.subBlocks.find((s) => s.id === 'invitationAccessLevel')
120-
expect(invAccess?.type).toBe('dropdown')
184+
expect(invAccess?.type).toBe('combobox')
121185
expect(invAccess?.value?.()).toBe('')
122186
const options = typeof invAccess?.options === 'function' ? undefined : invAccess?.options
123187
expect(options?.[0]).toEqual({ label: 'Leave unchanged', id: '' })
@@ -158,3 +222,76 @@ describe('GitLabBlock access operations', () => {
158222
).toThrow()
159223
})
160224
})
225+
226+
describe('GitLabBlock group operations', () => {
227+
it('registers and routes the new group/membership operations', () => {
228+
for (const toolId of [
229+
'gitlab_list_groups',
230+
'gitlab_get_group',
231+
'gitlab_list_user_memberships',
232+
]) {
233+
expect(block.tools.access).toContain(toolId)
234+
expect(block.tools.config.tool?.({ operation: toolId })).toBe(toolId)
235+
}
236+
})
237+
238+
it('maps list-groups filters to tool params', () => {
239+
const params = block.tools.config.params?.({
240+
accessToken: 'pat',
241+
operation: 'gitlab_list_groups',
242+
owned: true,
243+
searchQuery: 'plat',
244+
groupsTopLevelOnly: true,
245+
visibility: 'private',
246+
groupMinAccessLevel: '30',
247+
groupAllAvailable: true,
248+
groupOrderBy: 'similarity',
249+
sortOrder: 'asc',
250+
perPage: '50',
251+
page: '2',
252+
})
253+
expect(params).toMatchObject({
254+
owned: true,
255+
search: 'plat',
256+
topLevelOnly: true,
257+
visibility: 'private',
258+
minAccessLevel: 30,
259+
allAvailable: true,
260+
orderBy: 'similarity',
261+
sort: 'asc',
262+
perPage: 50,
263+
page: 2,
264+
})
265+
})
266+
267+
it('requires a group id for get group', () => {
268+
expect(() =>
269+
block.tools.config.params?.({ accessToken: 'pat', operation: 'gitlab_get_group' })
270+
).toThrow(/group id/i)
271+
272+
const params = block.tools.config.params?.({
273+
accessToken: 'pat',
274+
operation: 'gitlab_get_group',
275+
groupId: ' parent/child ',
276+
})
277+
expect(params).toMatchObject({ groupId: 'parent/child' })
278+
})
279+
280+
it('requires a user id for list user memberships and forwards the type filter', () => {
281+
expect(() =>
282+
block.tools.config.params?.({
283+
accessToken: 'pat',
284+
operation: 'gitlab_list_user_memberships',
285+
})
286+
).toThrow(/user id/i)
287+
288+
const params = block.tools.config.params?.({
289+
accessToken: 'pat',
290+
operation: 'gitlab_list_user_memberships',
291+
userId: '7',
292+
membershipType: 'Namespace',
293+
perPage: '25',
294+
})
295+
expect(params).toMatchObject({ userId: '7', membershipType: 'Namespace', perPage: 25 })
296+
})
297+
})

0 commit comments

Comments
 (0)