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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 |

---

Expand Down
254 changes: 254 additions & 0 deletions src/gitlab-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2565,6 +2565,260 @@ 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<any> {
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 {
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');
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<string, any> = { 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<any>(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;
}

/**
* 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.
Expand Down
Loading
Loading