Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 26 additions & 10 deletions packages/gitlab-mcp/src/entities/workitems/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,13 @@
*/
async function listWorkItems(
client: GraphQLClient,
vars: { namespacePath: string; types?: string[]; first: number; after?: string },
vars: {
namespacePath: string;
types?: string[];
state?: 'opened' | 'closed';
first: number;
after?: string;
},
) {
if (graphqlSupports('Namespace', 'workItems')) {
return (await client.request(GET_NAMESPACE_WORK_ITEMS, vars)).namespace?.workItems ?? null;
Expand Down Expand Up @@ -385,7 +391,7 @@
// project/group fallbacks for the namespace-level queries).
requirements: { default: { tier: 'free' } },
gate: { envVar: 'USE_WORKITEMS', defaultValue: true },
handler: async (args: unknown): Promise<unknown> => {

Check failure on line 394 in packages/gitlab-mcp/src/entities/workitems/registry.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 19 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=structured-world_gitlab-mcp&issues=AaDUyQqbSOkZyY_Athup&open=AaDUyQqbSOkZyY_Athup&pullRequest=611
const input = BrowseWorkItemsSchema.parse(args);

assertActionAllowed('browse_work_items', input.action);
Expand All @@ -400,23 +406,33 @@
const connectionManager = ConnectionManager.getInstance();
const client = connectionManager.getClient(getGitLabApiUrlFromContext());

// For the work items GraphQL query, use type names as-is (GraphQL expects enum values)
const resolvedTypes: string[] | undefined = types;
// No state requested matches nothing.
if (state.length === 0) return { items: [], hasMore: false, endCursor: null };

// GitLab filters by state (IssuableState) before paginating, so each
// page and its hasNextPage describe the requested states only. Both
// states need no filter.
const wantsOpen = state.includes('OPEN');
const wantsClosed = state.includes('CLOSED');
let serverState: 'opened' | 'closed' | undefined;
if (wantsOpen !== wantsClosed) serverState = wantsOpen ? 'opened' : 'closed';

const listVars = { namespacePath, types: resolvedTypes, first: first || 20, after };
// For the work items GraphQL query, use type names as-is (GraphQL expects enum values)
const listVars = {
namespacePath,
types,
state: serverState,
first: first || 20,
after,
};
const workItemsData = await listWorkItems(client, listVars);
const allItems = workItemsData?.nodes ?? [];
const pageInfo = {
hasNextPage: workItemsData?.pageInfo?.hasNextPage ?? false,
endCursor: workItemsData?.pageInfo?.endCursor ?? null,
};
// Apply state filtering (client-side since GitLab API doesn't support it reliably)
const filteredItems = allItems.filter((item: GraphQLWorkItem) => {
return state.includes(item.state);
});

// Apply simplification if requested and clean GIDs
const finalResults = filteredItems.map((item: GraphQLWorkItem) => {
const finalResults = (workItemsData?.nodes ?? []).map((item: GraphQLWorkItem) => {
const cleanedItem = cleanWorkItemResponse(item as unknown as GitLabWorkItem);
return simplifyWorkItem(cleanedItem as GraphQLWorkItem, simple);
});
Expand Down
25 changes: 20 additions & 5 deletions packages/gitlab-mcp/src/graphql/workItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,14 @@ interface WorkItemListConnection {
};
}

type WorkItemListVars = { namespacePath: string; types?: string[]; first?: number; after?: string };
type WorkItemListVars = {
namespacePath: string;
types?: string[];
/** IssuableState; omitted for every state. */
state?: 'opened' | 'closed';
first?: number;
after?: string;
};

// Listing selection shared by the namespace query and its project/group fallbacks.
const WORK_ITEM_LIST_CONNECTION = `
Expand Down Expand Up @@ -659,13 +666,14 @@ export const GET_NAMESPACE_WORK_ITEMS: TypedDocumentNode<
query GetNamespaceWorkItems(
$namespacePath: ID!
$types: [IssueType!]
$state: IssuableState
$first: Int
$after: String
) {
namespace(fullPath: $namespacePath) {
__typename
fullPath
workItems(types: $types, first: $first, after: $after) {
workItems(types: $types, state: $state, first: $first, after: $after) {
${WORK_ITEM_LIST_CONNECTION}
}
}
Expand All @@ -680,11 +688,12 @@ export const LIST_PROJECT_WORK_ITEMS: TypedDocumentNode<
query ListProjectWorkItems(
$namespacePath: ID!
$types: [IssueType!]
$state: IssuableState
$first: Int
$after: String
) {
project(fullPath: $namespacePath) {
workItems(types: $types, first: $first, after: $after) {
workItems(types: $types, state: $state, first: $first, after: $after) {
${WORK_ITEM_LIST_CONNECTION}
}
}
Expand All @@ -695,9 +704,15 @@ export const LIST_GROUP_WORK_ITEMS: TypedDocumentNode<
{ group: { workItems: WorkItemListConnection | null } | null },
WorkItemListVars
> = gql`
query ListGroupWorkItems($namespacePath: ID!, $types: [IssueType!], $first: Int, $after: String) {
query ListGroupWorkItems(
$namespacePath: ID!
$types: [IssueType!]
$state: IssuableState
$first: Int
$after: String
) {
group(fullPath: $namespacePath) {
workItems(types: $types, first: $first, after: $after) {
workItems(types: $types, state: $state, first: $first, after: $after) {
${WORK_ITEM_LIST_CONNECTION}
}
}
Expand Down
47 changes: 47 additions & 0 deletions packages/gitlab-mcp/tests/integration/workitems.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,53 @@ describe('Work Items Integration - Using Handler Functions', () => {
}, 30000);
});

describe('Work item state filter', () => {
// The state filter must reach GitLab: filtered after pagination, closed items
// newer than an open one push it off the page and leave the page empty.
it('returns the open item on the first page even when newer items are closed', async () => {
const namespace = requireTestData().project.path_with_namespace;
const created: string[] = [];
const create = async (title: string) => {
const item = (await helper.createWorkItem({
namespace,
title,
workItemType: 'ISSUE',
})) as { id: string };
created.push(item.id);
return item.id;
};

try {
const suffix = Date.now();
const openId = await create(`State filter open ${suffix}`);
for (const title of [
`State filter closed B ${suffix}`,
`State filter closed C ${suffix}`,
]) {
await helper.updateWorkItem({ id: await create(title), state: 'CLOSE' });
}

const open = (await helper.listWorkItems({ namespace, state: ['OPEN'], first: 2 })) as {
items: Array<{ id: string; state: string }>;
};
expect(open.items[0]?.id).toBe(openId);
expect(open.items.every((item) => item.state === 'OPEN')).toBe(true);

const closed = (await helper.listWorkItems({ namespace, state: ['CLOSED'], first: 2 })) as {
items: Array<{ id: string; state: string }>;
};
expect(closed.items).toHaveLength(2);
expect(closed.items.map((item) => item.id)).toEqual(created.slice(1).reverse());
} finally {
for (const id of created) {
await helper.deleteWorkItem({ id }).catch((error: unknown) => {
console.warn(`Could not delete state filter fixture ${id}:`, error);
});
}
}
}, 60000);
});

describe('Work Items Widget Validation through Handlers', () => {
it('should validate core widget types through list_work_items handler', async () => {
const testData = requireTestData();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -306,9 +306,11 @@ describe('Workitems Registry - CQRS Tools', () => {
const tool = workitemsToolRegistry.get('browse_work_items');
const result = await tool?.handler({ action: 'list', namespace: 'test-group' });

// The default state (OPEN) is filtered by GitLab.
expect(mockClient.request).toHaveBeenCalledWith(expect.any(Object), {
namespacePath: 'test-group',
types: undefined,
state: 'opened',
first: 20,
after: undefined,
});
Expand Down Expand Up @@ -379,6 +381,7 @@ describe('Workitems Registry - CQRS Tools', () => {
expect(mockClient.request).toHaveBeenCalledWith(expect.any(Object), {
namespacePath: 'test-group',
types: undefined,
state: 'opened',
first: 50,
after: 'cursor-123',
});
Expand Down Expand Up @@ -426,6 +429,7 @@ describe('Workitems Registry - CQRS Tools', () => {
expect(mockClient.request).toHaveBeenCalledWith(expect.any(Object), {
namespacePath: 'test-group',
types: ['EPIC', 'ISSUE'],
state: 'opened',
first: 20,
after: undefined,
});
Expand Down Expand Up @@ -916,17 +920,14 @@ describe('Workitems Registry - CQRS Tools', () => {
expect(widgets.some((widget) => widget.type === 'TEST_REPORTS')).toBe(false);
});

it('should filter by state parameter', async () => {
const mockWorkItems = [
createMockWorkItem({ id: 'gid://gitlab/WorkItem/1', state: 'OPEN' }),
createMockWorkItem({ id: 'gid://gitlab/WorkItem/2', state: 'CLOSED' }),
];

it('should filter by state parameter in the GitLab query', async () => {
// GitLab applies the state filter before paginating, so the page it
// returns is passed through as is.
mockClient.request.mockResolvedValueOnce({
namespace: {
__typename: 'Group',
workItems: {
nodes: mockWorkItems,
nodes: [createMockWorkItem({ id: 'gid://gitlab/WorkItem/1', state: 'OPEN' })],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
Expand All @@ -939,7 +940,7 @@ describe('Workitems Registry - CQRS Tools', () => {
state: ['OPEN'],
})) as { items: unknown[] };

// Client-side filtering should only return OPEN items
expect(mockClient.request.mock.calls[0][1].state).toBe('opened');
expect(result.items.length).toBe(1);
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,51 @@ describe('browse_work_items list', () => {
});
});

describe('browse_work_items list state filter', () => {
// Filtering after GitLab paginated would leave pages empty while hasMore is
// true; the filter has to be part of the query.
it.each([
[['OPEN'], 'opened'],
[['CLOSED'], 'closed'],
[['OPEN', 'CLOSED'], undefined],
])('sends state %j to GitLab as %s', async (state, expected) => {
mockRequest.mockResolvedValueOnce({ namespace: { workItems: connection('1') } });
await browse().handler({ action: 'list', namespace: 'grp/proj', state });
expect(mockRequest.mock.calls[0][1].state).toBe(expected);
});

it('passes the state to the project fallback too', async () => {
missing.add('Namespace.workItems');
mockRequest.mockResolvedValueOnce({ project: { workItems: connection('7') } });
await browse().handler({ action: 'list', namespace: 'grp/proj', state: ['CLOSED'] });
expect(mockRequest.mock.calls[0][1].state).toBe('closed');
});

it('returns the page as GitLab filtered it, with its own hasMore', async () => {
mockRequest.mockResolvedValueOnce({
namespace: {
workItems: {
nodes: [item('3')],
pageInfo: { hasNextPage: false, endCursor: 'c' },
},
},
});
const result = (await browse().handler({
action: 'list',
namespace: 'grp/proj',
state: ['OPEN'],
})) as { items: Array<{ iid: string }>; hasMore: boolean };
expect(result.items.map((i) => i.iid)).toEqual(['3']);
expect(result.hasMore).toBe(false);
});

it('matches nothing for an empty state list without calling GitLab', async () => {
const result = await browse().handler({ action: 'list', namespace: 'grp/proj', state: [] });
expect(result).toEqual({ items: [], hasMore: false, endCursor: null });
expect(mockRequest).not.toHaveBeenCalled();
});
});

describe('browse_work_items get by IID', () => {
it('falls back to the project listing filtered by IID', async () => {
missing.add('Namespace.workItem');
Expand Down
Loading