From dc07cd6c8f0504b34465d258d143daf8f36747e9 Mon Sep 17 00:00:00 2001 From: Brandon Hall Date: Thu, 21 May 2026 11:59:49 -0400 Subject: [PATCH 1/6] feat(client): add listMyTodos GraphQL method --- src/gitlab-client.ts | 166 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) diff --git a/src/gitlab-client.ts b/src/gitlab-client.ts index 9e7e01e..7e09eed 100644 --- a/src/gitlab-client.ts +++ b/src/gitlab-client.ts @@ -2565,6 +2565,172 @@ export class GitLabGraphQLClient { } } + /** + * List the authenticated user's GitLab todos. Always scoped to the caller via + * `currentUser.todos`. Filters are passed through to GraphQL; if the + * self-hosted GitLab schema is missing an enum value or filter argument we + * drop it and add a `_warning` field to the response, matching the fallback + * pattern used by updateIssueComposite. + */ + async listMyTodos( + params: { + state?: string; + action?: string; + type?: string; + groupPath?: string; + projectPath?: string; + first?: number; + after?: string; + fetchAll?: boolean; + }, + userConfig?: UserConfig + ): Promise { + await this.introspectSchema(userConfig); + + const first = params.first ?? 20; + const fetchAll = params.fetchAll ?? false; + + const currentUserType = this.schema.getType('CurrentUser') || this.schema.getType('UserCore'); + const todosField = currentUserType?.getFields?.()?.['todos']; + if (!todosField) { + throw new Error('GitLab GraphQL schema does not expose currentUser.todos on this instance.'); + } + const todosArgs: any[] = todosField.args || []; + const argNames = new Set(todosArgs.map((a: any) => a.name)); + + const warnings: string[] = []; + + const stateEnumName = this.getTypeName(todosArgs.find((a: any) => a.name === 'state')?.type) || 'TodoStateEnum'; + const stateAllowed = this.getEnumValues(stateEnumName).map(v => String(v)); + let stateFilter: string[] | undefined; + if (params.state && params.state.toLowerCase() !== 'all') { + const match = stateAllowed.find(v => v.toLowerCase() === params.state!.toLowerCase()); + if (match) { + stateFilter = [match]; + } else { + warnings.push(`state=${params.state} not supported by this GitLab; returning all states`); + } + // state='all': GitLab defaults to [PENDING], so we must enumerate explicitly — unlike issues/MRs where omitting the filter returns all states. + } else if (params.state?.toLowerCase() === 'all') { + if (stateAllowed.length >= 2) { + stateFilter = stateAllowed; + } + } else { + const match = stateAllowed.find(v => v.toLowerCase() === 'pending'); + if (match) stateFilter = [match]; + } + + const actionEnumName = this.getTypeName(todosArgs.find((a: any) => a.name === 'action')?.type) || 'TodoActionEnum'; + let actionFilter: string[] | undefined; + if (params.action) { + if (!argNames.has('action')) { + warnings.push(`action filter not supported by this GitLab; ignoring`); + } else { + const actionAllowed = this.getEnumValues(actionEnumName).map(v => String(v)); + const match = actionAllowed.find(v => v.toLowerCase() === params.action!.toLowerCase()); + if (match) { + actionFilter = [match]; + } else { + warnings.push(`action=${params.action} not in this GitLab's ${actionEnumName}; ignoring`); + } + } + } + + const typeEnumName = this.getTypeName(todosArgs.find((a: any) => a.name === 'type')?.type) || 'TodoTargetEnum'; + let typeFilter: string[] | undefined; + if (params.type) { + if (!argNames.has('type')) { + warnings.push(`type filter not supported by this GitLab; ignoring`); + } else { + const typeAllowed = this.getEnumValues(typeEnumName).map(v => String(v)); + const match = typeAllowed.find(v => v.toLowerCase() === params.type!.toLowerCase()); + if (match) { + typeFilter = [match]; + } else { + warnings.push(`type=${params.type} not in this GitLab's ${typeEnumName}; ignoring`); + } + } + } + + const groupArgName = argNames.has('groupId') ? 'groupId' : (argNames.has('group') ? 'group' : undefined); + const projectArgName = argNames.has('projectId') ? 'projectId' : (argNames.has('project') ? 'project' : undefined); + + if (params.groupPath && !groupArgName) warnings.push('group filter not supported by this GitLab; ignoring'); + if (params.projectPath && !projectArgName) warnings.push('project filter not supported by this GitLab; ignoring'); + + const variableDefs: string[] = ['$first: Int!', '$after: String']; + const argParts: string[] = ['first: $first', 'after: $after']; + const variables: Record = { first, after: params.after }; + + if (stateFilter) { + variableDefs.push(`$state: [${stateEnumName}!]`); + argParts.push('state: $state'); + variables.state = stateFilter; + } + if (actionFilter) { + variableDefs.push(`$action: [${actionEnumName}!]`); + argParts.push('action: $action'); + variables.action = actionFilter; + } + if (typeFilter) { + variableDefs.push(`$type: [${typeEnumName}!]`); + argParts.push('type: $type'); + variables.type = typeFilter; + } + if (params.groupPath && groupArgName) { + variableDefs.push(`$${groupArgName}: ID!`); + argParts.push(`${groupArgName}: $${groupArgName}`); + variables[groupArgName] = params.groupPath.trim(); + } + if (params.projectPath && projectArgName) { + variableDefs.push(`$${projectArgName}: ID!`); + argParts.push(`${projectArgName}: $${projectArgName}`); + variables[projectArgName] = params.projectPath.trim(); + } + + const query = gql` + query listMyTodos(${variableDefs.join(', ')}) { + currentUser { + todos(${argParts.join(', ')}) { + pageInfo { hasNextPage hasPreviousPage startCursor endCursor } + nodes { + id + body + state + action + targetType + createdAt + author { username name } + group { fullPath name } + project { fullPath name } + target { + ... on Issue { id iid title webUrl state } + ... on MergeRequest { id iid title webUrl state } + ... on Epic { id iid title webUrl state } + } + } + } + } + } + `; + + if (fetchAll) { + const result = await this.fetchAllPages(query, variables, 'currentUser.todos', { + maxItems: first, + pageSize: this.config.maxPageSize, + userConfig, + }); + return warnings.length > 0 ? { ...result, _warning: warnings.join('; ') } : result; + } + + const result = await this.query(query, variables, userConfig); + const connection = result?.currentUser?.todos; + if (!connection) { + throw new Error('currentUser.todos returned no data — check that the configured token belongs to a real user.'); + } + return warnings.length > 0 ? { ...connection, _warning: warnings.join('; ') } : connection; + } + /** * Resolve the GitLab base URL and access token for a REST call, honoring * per-request user credentials and falling back to the configured env token. From c3fa84bc9658e8721e445b055263ff6db74afe6e Mon Sep 17 00:00:00 2001 From: Brandon Hall Date: Thu, 21 May 2026 12:08:33 -0400 Subject: [PATCH 2/6] feat(client): add todo mark-done/restore mutations --- src/gitlab-client.ts | 86 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/src/gitlab-client.ts b/src/gitlab-client.ts index 7e09eed..f7f326a 100644 --- a/src/gitlab-client.ts +++ b/src/gitlab-client.ts @@ -2731,6 +2731,92 @@ export class GitLabGraphQLClient { return warnings.length > 0 ? { ...connection, _warning: warnings.join('; ') } : connection; } + /** + * Mark a single todo as done. Accepts either a bare numeric ID or a full + * gid:// URL. Requires write capability. + */ + async markTodoDone( + todoId: string, + userConfig?: UserConfig + ): Promise<{ todo: { id: string; state: string } | null; errors: string[] }> { + const gid = todoId.startsWith('gid://') ? todoId : `gid://gitlab/Todo/${todoId}`; + const mutation = ` + mutation TodoMarkDone($input: TodoMarkDoneInput!) { + todoMarkDone(input: $input) { + todo { id state } + errors + } + } + `; + const result = await this.query<{ todoMarkDone: { todo: any; errors: string[] } }>( + mutation, + { input: { id: gid } }, + userConfig, + true + ); + if (result.todoMarkDone.errors && result.todoMarkDone.errors.length > 0) { + throw new Error(`todoMarkDone failed: ${result.todoMarkDone.errors.join('; ')}`); + } + return result.todoMarkDone; + } + + /** + * Mark every pending todo for the authenticated user as done. Requires + * write capability. Idempotent: succeeds (with an empty updatedIds list) + * when nothing is pending. + */ + async markAllTodosDone( + userConfig?: UserConfig + ): Promise<{ updatedIds: string[]; errors: string[] }> { + const mutation = ` + mutation TodosMarkAllDone { + todosMarkAllDone(input: {}) { + updatedIds + errors + } + } + `; + const result = await this.query<{ todosMarkAllDone: { updatedIds: string[]; errors: string[] } }>( + mutation, + {}, + userConfig, + true + ); + if (result.todosMarkAllDone.errors && result.todosMarkAllDone.errors.length > 0) { + throw new Error(`todosMarkAllDone failed: ${result.todosMarkAllDone.errors.join('; ')}`); + } + return result.todosMarkAllDone; + } + + /** + * Restore a single previously-done todo back to pending. Accepts either a + * bare numeric ID or a full gid:// URL. Requires write capability. + */ + async restoreTodo( + todoId: string, + userConfig?: UserConfig + ): Promise<{ todo: { id: string; state: string } | null; errors: string[] }> { + const gid = todoId.startsWith('gid://') ? todoId : `gid://gitlab/Todo/${todoId}`; + const mutation = ` + mutation TodoRestore($input: TodoRestoreInput!) { + todoRestore(input: $input) { + todo { id state } + errors + } + } + `; + const result = await this.query<{ todoRestore: { todo: any; errors: string[] } }>( + mutation, + { input: { id: gid } }, + userConfig, + true + ); + if (result.todoRestore.errors && result.todoRestore.errors.length > 0) { + throw new Error(`todoRestore failed: ${result.todoRestore.errors.join('; ')}`); + } + return result.todoRestore; + } + /** * Resolve the GitLab base URL and access token for a REST call, honoring * per-request user credentials and falling back to the configured env token. From 15c5a1e60f28fb01ce474b76ea4b9eaae0103fe5 Mon Sep 17 00:00:00 2001 From: Brandon Hall Date: Thu, 21 May 2026 12:10:41 -0400 Subject: [PATCH 3/6] feat(tools): add list_my_todos --- src/tools.ts | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/src/tools.ts b/src/tools.ts index bf397bc..51d5030 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -1818,6 +1818,70 @@ const EventCommonFields = { per_page: z.number().int().min(1).max(100).default(20).describe('Results per page'), }; +// ─── Todos (authenticated user's to-do inbox) ──────────────────────────────── + +const listMyTodosTool: Tool = { + name: 'list_my_todos', + title: 'My Todos', + description: + 'List the authenticated user\'s GitLab to-do items (notifications about issues, MRs, mentions, reviews requested, etc.). Filter by state, action, target type, or group/project. Requires user authentication.', + requiresAuth: true, + requiresWrite: false, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + inputSchema: withUserAuth(z.object({ + state: z + .enum(['pending', 'done', 'all']) + .default('pending') + .describe('Filter by todo state: pending (default), done, or all'), + action: z + .string() + .optional() + .describe('Filter by todo action — e.g. assigned, mentioned, build_failed, marked, approval_required, unmergeable, directly_addressed, review_requested'), + type: z + .string() + .optional() + .describe('Filter by target type — e.g. Issue, MergeRequest, Epic, Commit, DesignManagement::Design, AlertManagement::Alert'), + groupPath: z + .string() + .optional() + .describe('Limit to a group by full path (e.g. "my-org/platform")'), + projectPath: z + .string() + .optional() + .describe('Limit to a project by full path (e.g. "my-org/my-repo")'), + first: z.number().min(1).max(100).default(20).describe('Number of todos to retrieve'), + after: z.string().optional().describe('Cursor for pagination'), + fetchAll: z.boolean().default(false).describe('Fetch all pages up to 100 results'), + })), + handler: async (input, client, userConfig) => { + const credentials = input.userCredentials ? validateUserConfig(input.userCredentials) : userConfig; + if (!credentials) { + throw new Error('list_my_todos requires user authentication — the to-do inbox is scoped to the caller.'); + } + const groupPath = input.groupPath?.trim(); + const projectPath = input.projectPath?.trim(); + if (input.groupPath !== undefined && !groupPath) { + throw new Error('groupPath cannot be empty or whitespace'); + } + if (input.projectPath !== undefined && !projectPath) { + throw new Error('projectPath cannot be empty or whitespace'); + } + return client.listMyTodos( + { + state: input.state, + action: input.action, + type: input.type, + groupPath, + projectPath, + first: input.first, + after: input.after, + fetchAll: input.fetchAll, + }, + credentials, + ); + }, +}; + const listMyEventsTool: Tool = { name: 'list_my_events', title: 'My Events', @@ -2284,6 +2348,7 @@ export const searchTools: Tool[] = [ searchMergeRequestsTool, getUserIssuesTool, getUserMergeRequestsTool, + listMyTodosTool, searchUsersTool, searchGroupsTool, searchLabelsTool, From d3650efa0d08ce4e6652face5166ed4cd2732f0e Mon Sep 17 00:00:00 2001 From: Brandon Hall Date: Thu, 21 May 2026 12:12:42 -0400 Subject: [PATCH 4/6] feat(tools): add mark_todo_done, mark_all_todos_done, restore_todo --- src/tools.ts | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/tools.ts b/src/tools.ts index 51d5030..95f2930 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -1882,6 +1882,61 @@ const listMyTodosTool: Tool = { }, }; +const markTodoDoneTool: Tool = { + name: 'mark_todo_done', + title: 'Mark Todo Done', + description: + 'Mark a single to-do item as done for the authenticated user. Requires the todo\'s ID (numeric or full gid://gitlab/Todo/N form) from list_my_todos.', + requiresAuth: false, + requiresWrite: true, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }, + inputSchema: withUserAuth(z.object({ + todoId: z + .string() + .min(1) + .describe('Todo ID — accepts a bare numeric ID ("42") or a full gid ("gid://gitlab/Todo/42")'), + })), + handler: async (input, client, userConfig) => { + const credentials = input.userCredentials ? validateUserConfig(input.userCredentials) : userConfig; + return client.markTodoDone(input.todoId.trim(), credentials); + }, +}; + +const markAllTodosDoneTool: Tool = { + name: 'mark_all_todos_done', + title: 'Mark All Todos Done', + description: + 'Mark every pending to-do item as done for the authenticated user. Irreversible without per-item restore_todo calls. Idempotent — succeeds even if there are zero pending todos.', + requiresAuth: false, + requiresWrite: true, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true }, + inputSchema: withUserAuth(z.object({})), + handler: async (input, client, userConfig) => { + const credentials = input.userCredentials ? validateUserConfig(input.userCredentials) : userConfig; + return client.markAllTodosDone(credentials); + }, +}; + +const restoreTodoTool: Tool = { + name: 'restore_todo', + title: 'Restore Todo', + description: + 'Restore a previously-marked-done to-do item back to pending state. Accepts the todo\'s ID (numeric or full gid://gitlab/Todo/N form).', + requiresAuth: false, + requiresWrite: true, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }, + inputSchema: withUserAuth(z.object({ + todoId: z + .string() + .min(1) + .describe('Todo ID — accepts a bare numeric ID ("42") or a full gid ("gid://gitlab/Todo/42")'), + })), + handler: async (input, client, userConfig) => { + const credentials = input.userCredentials ? validateUserConfig(input.userCredentials) : userConfig; + return client.restoreTodo(input.todoId.trim(), credentials); + }, +}; + const listMyEventsTool: Tool = { name: 'list_my_events', title: 'My Events', @@ -2339,6 +2394,9 @@ export const writeTools: Tool[] = [ createBroadcastMessageTool, updateBroadcastMessageTool, deleteBroadcastMessageTool, + markTodoDoneTool, + markAllTodosDoneTool, + restoreTodoTool, ]; export const searchTools: Tool[] = [ From 25fbe7bef34590044cc14569febb66d8e70c038f Mon Sep 17 00:00:00 2001 From: Brandon Hall Date: Thu, 21 May 2026 12:15:06 -0400 Subject: [PATCH 5/6] docs: document todo management tools --- CHANGELOG.md | 3 +++ README.md | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a2359b..4c3300b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Todo management tools: `list_my_todos`, `mark_todo_done`, `mark_all_todos_done`, `restore_todo`. Wraps GitLab's GraphQL `currentUser.todos` query and `todoMarkDone` / `todosMarkAllDone` / `todoRestore` mutations. Schema-introspection fallback drops unsupported filters on older self-hosted GitLab and surfaces a `_warning` field in the response. + ## [1.15.2] - 2026-05-07 ### Added diff --git a/README.md b/README.md index ef948ed..96a2383 100644 --- a/README.md +++ b/README.md @@ -324,6 +324,7 @@ code search, or if you prefer OAuth. | `list_work_items` | List work items in a group or project, filtered by type and state | | `list_broadcast_messages` | List instance-wide broadcast messages | | `get_broadcast_message` | Get a specific broadcast message by ID | +| `list_my_todos` | Authenticated user's to-do inbox — notifications about issues, MRs, mentions, reviews requested | | `list_my_events` | Authenticated user's activity feed — pushes, MRs, comments, approvals | | `list_user_events` | Another user's public activity feed by username or ID | | `list_project_events` | Activity events for a specific project | @@ -346,6 +347,9 @@ code search, or if you prefer OAuth. | `create_broadcast_message` | Create a broadcast message (instance admin) | | `update_broadcast_message` | Update a broadcast message (instance admin) | | `delete_broadcast_message` | Delete a broadcast message (instance admin) | +| `mark_todo_done` | Mark a single to-do item as done | +| `mark_all_todos_done` | Mark all pending to-do items as done for the authenticated user | +| `restore_todo` | Restore a previously-done to-do item back to pending | --- From 87cb349f5c24f6dd0d63ab8732d87e97369e5685 Mon Sep 17 00:00:00 2001 From: Brandon Hall Date: Thu, 21 May 2026 12:21:37 -0400 Subject: [PATCH 6/6] fix(client): warn on state=all when schema enum is incomplete --- src/gitlab-client.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/gitlab-client.ts b/src/gitlab-client.ts index f7f326a..36e6aa5 100644 --- a/src/gitlab-client.ts +++ b/src/gitlab-client.ts @@ -2614,6 +2614,8 @@ export class GitLabGraphQLClient { } else if (params.state?.toLowerCase() === 'all') { if (stateAllowed.length >= 2) { stateFilter = stateAllowed; + } else { + warnings.push('state=all not fully supported by this GitLab (TodoStateEnum has fewer than 2 values); results may only include pending todos'); } } else { const match = stateAllowed.find(v => v.toLowerCase() === 'pending');