diff --git a/pkg/github/notifications.go b/pkg/github/notifications.go index b6b6bfd79b..5c9030f472 100644 --- a/pkg/github/notifications.go +++ b/pkg/github/notifications.go @@ -22,6 +22,58 @@ const ( FilterOnlyParticipating = "only_participating" ) +// Error message formats reused across the notification tool handlers. Defining +// them as constants avoids duplicated string literals (SonarQube S1192). +const ( + errFailedToGetClient = "failed to get GitHub client: %w" + errFailedToReadBody = "failed to read response body: %w" + errFailedToMarshal = "failed to marshal response: %w" +) + +// Tool parameter names reused across the notification tools. +const ( + paramOwner = "owner" + paramRepo = "repo" + paramAction = "action" + paramNotificationID = "notificationID" + paramSince = "since" + paramBefore = "before" + paramLastReadAt = "lastReadAt" +) + +// marshalToolResult marshals v as JSON and returns it as a tool text result. +func marshalToolResult(v any) (*mcp.CallToolResult, error) { + r, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf(errFailedToMarshal, err) + } + return mcp.NewToolResultText(string(r)), nil +} + +// responseErrorResult reads the body of an unexpected response and returns a tool +// error result prefixed with message. +func responseErrorResult(resp *github.Response, message string) (*mcp.CallToolResult, error) { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf(errFailedToReadBody, err) + } + return mcp.NewToolResultError(fmt.Sprintf("%s: %s", message, string(body))), nil +} + +// parseOptionalTimestamp parses an optional RFC3339 timestamp. An empty value +// yields the zero time and a nil result. An invalid value yields a tool error +// result describing the offending field. +func parseOptionalTimestamp(value, field string) (time.Time, *mcp.CallToolResult) { + if value == "" { + return time.Time{}, nil + } + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + return time.Time{}, mcp.NewToolResultError(fmt.Sprintf("invalid %s time format, should be RFC3339/ISO8601: %v", field, err)) + } + return parsed, nil +} + // ListNotifications creates a tool to list notifications for the current user. func ListNotifications(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("list_notifications", @@ -34,119 +86,123 @@ func ListNotifications(getClient GetClientFn, t translations.TranslationHelperFu mcp.Description("Filter notifications to, use default unless specified. Read notifications are ones that have already been acknowledged by the user. Participating notifications are those that the user is directly involved in, such as issues or pull requests they have commented on or created."), mcp.Enum(FilterDefault, FilterIncludeRead, FilterOnlyParticipating), ), - mcp.WithString("since", + mcp.WithString(paramSince, mcp.Description("Only show notifications updated after the given time (ISO 8601 format)"), ), - mcp.WithString("before", + mcp.WithString(paramBefore, mcp.Description("Only show notifications updated before the given time (ISO 8601 format)"), ), - mcp.WithString("owner", + mcp.WithString(paramOwner, mcp.Description("Optional repository owner. If provided with repo, only notifications for this repository are listed."), ), - mcp.WithString("repo", + mcp.WithString(paramRepo, mcp.Description("Optional repository name. If provided with owner, only notifications for this repository are listed."), ), WithPagination(), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - filter, err := OptionalParam[string](request, "filter") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - since, err := OptionalParam[string](request, "since") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - before, err := OptionalParam[string](request, "before") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - owner, err := OptionalParam[string](request, "owner") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - repo, err := OptionalParam[string](request, "repo") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - paginationParams, err := OptionalPaginationParams(request) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Build options - opts := &github.NotificationListOptions{ - All: filter == FilterIncludeRead, - Participating: filter == FilterOnlyParticipating, - ListOptions: github.ListOptions{ - Page: paginationParams.page, - PerPage: paginationParams.perPage, - }, - } - - // Parse time parameters if provided - if since != "" { - sinceTime, err := time.Parse(time.RFC3339, since) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("invalid since time format, should be RFC3339/ISO8601: %v", err)), nil - } - opts.Since = sinceTime - } - - if before != "" { - beforeTime, err := time.Parse(time.RFC3339, before) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("invalid before time format, should be RFC3339/ISO8601: %v", err)), nil - } - opts.Before = beforeTime - } - - var notifications []*github.Notification - var resp *github.Response - - if owner != "" && repo != "" { - notifications, resp, err = client.Activity.ListRepositoryNotifications(ctx, owner, repo, opts) - } else { - notifications, resp, err = client.Activity.ListNotifications(ctx, opts) - } - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to list notifications", - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - return mcp.NewToolResultError(fmt.Sprintf("failed to get notifications: %s", string(body))), nil - } - - // Marshal response to JSON - r, err := json.Marshal(notifications) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil + return listNotificationsHandler(ctx, getClient, request) } } +// listNotificationsParams holds the parsed parameters for the list_notifications tool. +type listNotificationsParams struct { + filter string + since string + before string + owner string + repo string + pagination PaginationParams +} + +// parseListNotificationsParams extracts the list_notifications parameters from the +// request. A non-nil result indicates a parameter error that should be returned. +func parseListNotificationsParams(request mcp.CallToolRequest) (listNotificationsParams, *mcp.CallToolResult) { + var p listNotificationsParams + var err error + + if p.filter, err = OptionalParam[string](request, "filter"); err != nil { + return p, mcp.NewToolResultError(err.Error()) + } + if p.since, err = OptionalParam[string](request, paramSince); err != nil { + return p, mcp.NewToolResultError(err.Error()) + } + if p.before, err = OptionalParam[string](request, paramBefore); err != nil { + return p, mcp.NewToolResultError(err.Error()) + } + if p.owner, err = OptionalParam[string](request, paramOwner); err != nil { + return p, mcp.NewToolResultError(err.Error()) + } + if p.repo, err = OptionalParam[string](request, paramRepo); err != nil { + return p, mcp.NewToolResultError(err.Error()) + } + if p.pagination, err = OptionalPaginationParams(request); err != nil { + return p, mcp.NewToolResultError(err.Error()) + } + return p, nil +} + +func listNotificationsHandler(ctx context.Context, getClient GetClientFn, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf(errFailedToGetClient, err) + } + + params, errResult := parseListNotificationsParams(request) + if errResult != nil { + return errResult, nil + } + + opts := &github.NotificationListOptions{ + All: params.filter == FilterIncludeRead, + Participating: params.filter == FilterOnlyParticipating, + ListOptions: github.ListOptions{ + Page: params.pagination.page, + PerPage: params.pagination.perPage, + }, + } + + sinceTime, errResult := parseOptionalTimestamp(params.since, paramSince) + if errResult != nil { + return errResult, nil + } + if !sinceTime.IsZero() { + opts.Since = sinceTime + } + + beforeTime, errResult := parseOptionalTimestamp(params.before, paramBefore) + if errResult != nil { + return errResult, nil + } + if !beforeTime.IsZero() { + opts.Before = beforeTime + } + + var notifications []*github.Notification + var resp *github.Response + if params.owner != "" && params.repo != "" { + notifications, resp, err = client.Activity.ListRepositoryNotifications(ctx, params.owner, params.repo, opts) + } else { + notifications, resp, err = client.Activity.ListNotifications(ctx, opts) + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to list notifications", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return responseErrorResult(resp, "failed to get notifications") + } + + return marshalToolResult(notifications) +} + // DismissNotification creates a tool to mark a notification as read/done. -func DismissNotification(getclient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +func DismissNotification(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("dismiss_notification", mcp.WithDescription(t("TOOL_DISMISS_NOTIFICATION_DESCRIPTION", "Dismiss a notification by marking it as read or done")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ @@ -160,56 +216,56 @@ func DismissNotification(getclient GetClientFn, t translations.TranslationHelper mcp.WithString("state", mcp.Description("The new state of the notification (read/done)"), mcp.Enum("read", "done")), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - client, err := getclient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - threadID, err := RequiredParam[string](request, "threadID") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - state, err := RequiredParam[string](request, "state") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - var resp *github.Response - switch state { - case "done": - // for some inexplicable reason, the API seems to have threadID as int64 and string depending on the endpoint - var threadIDInt int64 - threadIDInt, err = strconv.ParseInt(threadID, 10, 64) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("invalid threadID format: %v", err)), nil - } - resp, err = client.Activity.MarkThreadDone(ctx, threadIDInt) - case "read": - resp, err = client.Activity.MarkThreadRead(ctx, threadID) - default: - return mcp.NewToolResultError("Invalid state. Must be one of: read, done."), nil - } - - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to mark notification as %s", state), - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusResetContent && resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - return mcp.NewToolResultError(fmt.Sprintf("failed to mark notification as %s: %s", state, string(body))), nil - } - - return mcp.NewToolResultText(fmt.Sprintf("Notification marked as %s", state)), nil + return dismissNotificationHandler(ctx, getClient, request) + } +} + +func dismissNotificationHandler(ctx context.Context, getClient GetClientFn, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf(errFailedToGetClient, err) + } + + threadID, err := RequiredParam[string](request, "threadID") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + state, err := RequiredParam[string](request, "state") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + var resp *github.Response + switch state { + case "done": + // for some inexplicable reason, the API seems to have threadID as int64 and string depending on the endpoint + var threadIDInt int64 + threadIDInt, err = strconv.ParseInt(threadID, 10, 64) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid threadID format: %v", err)), nil } + resp, err = client.Activity.MarkThreadDone(ctx, threadIDInt) + case "read": + resp, err = client.Activity.MarkThreadRead(ctx, threadID) + default: + return mcp.NewToolResultError("Invalid state. Must be one of: read, done."), nil + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to mark notification as %s", state), + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusResetContent && resp.StatusCode != http.StatusOK { + return responseErrorResult(resp, fmt.Sprintf("failed to mark notification as %s", state)) + } + + return mcp.NewToolResultText(fmt.Sprintf("Notification marked as %s", state)), nil } // MarkAllNotificationsRead creates a tool to mark all notifications as read. @@ -220,77 +276,75 @@ func MarkAllNotificationsRead(getClient GetClientFn, t translations.TranslationH Title: t("TOOL_MARK_ALL_NOTIFICATIONS_READ_USER_TITLE", "Mark all notifications as read"), ReadOnlyHint: ToBoolPtr(false), }), - mcp.WithString("lastReadAt", + mcp.WithString(paramLastReadAt, mcp.Description("Describes the last point that notifications were checked (optional). Default: Now"), ), - mcp.WithString("owner", + mcp.WithString(paramOwner, mcp.Description("Optional repository owner. If provided with repo, only notifications for this repository are marked as read."), ), - mcp.WithString("repo", + mcp.WithString(paramRepo, mcp.Description("Optional repository name. If provided with owner, only notifications for this repository are marked as read."), ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - lastReadAt, err := OptionalParam[string](request, "lastReadAt") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - owner, err := OptionalParam[string](request, "owner") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - repo, err := OptionalParam[string](request, "repo") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - var lastReadTime time.Time - if lastReadAt != "" { - lastReadTime, err = time.Parse(time.RFC3339, lastReadAt) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("invalid lastReadAt time format, should be RFC3339/ISO8601: %v", err)), nil - } - } else { - lastReadTime = time.Now() - } - - markReadOptions := github.Timestamp{ - Time: lastReadTime, - } - - var resp *github.Response - if owner != "" && repo != "" { - resp, err = client.Activity.MarkRepositoryNotificationsRead(ctx, owner, repo, markReadOptions) - } else { - resp, err = client.Activity.MarkNotificationsRead(ctx, markReadOptions) - } - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to mark all notifications as read", - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusResetContent && resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - return mcp.NewToolResultError(fmt.Sprintf("failed to mark all notifications as read: %s", string(body))), nil - } - - return mcp.NewToolResultText("All notifications marked as read"), nil + return markAllNotificationsReadHandler(ctx, getClient, request) } } +func markAllNotificationsReadHandler(ctx context.Context, getClient GetClientFn, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf(errFailedToGetClient, err) + } + + lastReadAt, err := OptionalParam[string](request, paramLastReadAt) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + owner, err := OptionalParam[string](request, paramOwner) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := OptionalParam[string](request, paramRepo) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + lastReadTime, errResult := parseOptionalTimestamp(lastReadAt, paramLastReadAt) + if errResult != nil { + return errResult, nil + } + if lastReadTime.IsZero() { + lastReadTime = time.Now() + } + + markReadOptions := github.Timestamp{ + Time: lastReadTime, + } + + var resp *github.Response + if owner != "" && repo != "" { + resp, err = client.Activity.MarkRepositoryNotificationsRead(ctx, owner, repo, markReadOptions) + } else { + resp, err = client.Activity.MarkNotificationsRead(ctx, markReadOptions) + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to mark all notifications as read", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusResetContent && resp.StatusCode != http.StatusOK { + return responseErrorResult(resp, "failed to mark all notifications as read") + } + + return mcp.NewToolResultText("All notifications marked as read"), nil +} + // GetNotificationDetails creates a tool to get details for a specific notification. func GetNotificationDetails(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_notification_details", @@ -299,49 +353,44 @@ func GetNotificationDetails(getClient GetClientFn, t translations.TranslationHel Title: t("TOOL_GET_NOTIFICATION_DETAILS_USER_TITLE", "Get notification details"), ReadOnlyHint: ToBoolPtr(true), }), - mcp.WithString("notificationID", + mcp.WithString(paramNotificationID, mcp.Required(), mcp.Description("The ID of the notification"), ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - notificationID, err := RequiredParam[string](request, "notificationID") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - thread, resp, err := client.Activity.GetThread(ctx, notificationID) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to get notification details for ID '%s'", notificationID), - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - return mcp.NewToolResultError(fmt.Sprintf("failed to get notification details: %s", string(body))), nil - } - - r, err := json.Marshal(thread) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil + return getNotificationDetailsHandler(ctx, getClient, request) } } +func getNotificationDetailsHandler(ctx context.Context, getClient GetClientFn, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf(errFailedToGetClient, err) + } + + notificationID, err := RequiredParam[string](request, paramNotificationID) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + thread, resp, err := client.Activity.GetThread(ctx, notificationID) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to get notification details for ID '%s'", notificationID), + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return responseErrorResult(resp, "failed to get notification details") + } + + return marshalToolResult(thread) +} + // Enum values for ManageNotificationSubscription action const ( NotificationActionIgnore = "ignore" @@ -349,6 +398,24 @@ const ( NotificationActionDelete = "delete" ) +// setThreadSubscription performs the requested subscription action on a notification +// thread. ok is false when action is not a recognized value. +func setThreadSubscription(ctx context.Context, client *github.Client, action, notificationID string) (result any, resp *github.Response, ok bool, err error) { + switch action { + case NotificationActionIgnore: + sub := &github.Subscription{Ignored: ToBoolPtr(true)} + result, resp, err = client.Activity.SetThreadSubscription(ctx, notificationID, sub) + case NotificationActionWatch: + sub := &github.Subscription{Ignored: ToBoolPtr(false), Subscribed: ToBoolPtr(true)} + result, resp, err = client.Activity.SetThreadSubscription(ctx, notificationID, sub) + case NotificationActionDelete: + resp, err = client.Activity.DeleteThreadSubscription(ctx, notificationID) + default: + return nil, nil, false, nil + } + return result, resp, true, err +} + // ManageNotificationSubscription creates a tool to manage a notification subscription (ignore, watch, delete) func ManageNotificationSubscription(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("manage_notification_subscription", @@ -357,83 +424,87 @@ func ManageNotificationSubscription(getClient GetClientFn, t translations.Transl Title: t("TOOL_MANAGE_NOTIFICATION_SUBSCRIPTION_USER_TITLE", "Manage notification subscription"), ReadOnlyHint: ToBoolPtr(false), }), - mcp.WithString("notificationID", + mcp.WithString(paramNotificationID, mcp.Required(), mcp.Description("The ID of the notification thread."), ), - mcp.WithString("action", + mcp.WithString(paramAction, mcp.Required(), mcp.Description("Action to perform: ignore, watch, or delete the notification subscription."), mcp.Enum(NotificationActionIgnore, NotificationActionWatch, NotificationActionDelete), ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - notificationID, err := RequiredParam[string](request, "notificationID") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - action, err := RequiredParam[string](request, "action") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - var ( - resp *github.Response - result any - apiErr error - ) - - switch action { - case NotificationActionIgnore: - sub := &github.Subscription{Ignored: ToBoolPtr(true)} - result, resp, apiErr = client.Activity.SetThreadSubscription(ctx, notificationID, sub) - case NotificationActionWatch: - sub := &github.Subscription{Ignored: ToBoolPtr(false), Subscribed: ToBoolPtr(true)} - result, resp, apiErr = client.Activity.SetThreadSubscription(ctx, notificationID, sub) - case NotificationActionDelete: - resp, apiErr = client.Activity.DeleteThreadSubscription(ctx, notificationID) - default: - return mcp.NewToolResultError("Invalid action. Must be one of: ignore, watch, delete."), nil - } - - if apiErr != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to %s notification subscription", action), - resp, - apiErr, - ), nil - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(resp.Body) - return mcp.NewToolResultError(fmt.Sprintf("failed to %s notification subscription: %s", action, string(body))), nil - } - - if action == NotificationActionDelete { - // Special case for delete as there is no response body - return mcp.NewToolResultText("Notification subscription deleted"), nil - } - - r, err := json.Marshal(result) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - return mcp.NewToolResultText(string(r)), nil + return manageNotificationSubscriptionHandler(ctx, getClient, request) } } +func manageNotificationSubscriptionHandler(ctx context.Context, getClient GetClientFn, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf(errFailedToGetClient, err) + } + + notificationID, err := RequiredParam[string](request, paramNotificationID) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + action, err := RequiredParam[string](request, paramAction) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + result, resp, ok, apiErr := setThreadSubscription(ctx, client, action, notificationID) + if !ok { + return mcp.NewToolResultError("Invalid action. Must be one of: ignore, watch, delete."), nil + } + + if apiErr != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to %s notification subscription", action), + resp, + apiErr, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return mcp.NewToolResultError(fmt.Sprintf("failed to %s notification subscription: %s", action, string(body))), nil + } + + if action == NotificationActionDelete { + // Special case for delete as there is no response body + return mcp.NewToolResultText("Notification subscription deleted"), nil + } + + return marshalToolResult(result) +} + const ( RepositorySubscriptionActionWatch = "watch" RepositorySubscriptionActionIgnore = "ignore" RepositorySubscriptionActionDelete = "delete" ) +// setRepositorySubscription performs the requested subscription action on a +// repository. ok is false when action is not a recognized value. +func setRepositorySubscription(ctx context.Context, client *github.Client, action, owner, repo string) (result any, resp *github.Response, ok bool, err error) { + switch action { + case RepositorySubscriptionActionIgnore: + sub := &github.Subscription{Ignored: ToBoolPtr(true)} + result, resp, err = client.Activity.SetRepositorySubscription(ctx, owner, repo, sub) + case RepositorySubscriptionActionWatch: + sub := &github.Subscription{Ignored: ToBoolPtr(false), Subscribed: ToBoolPtr(true)} + result, resp, err = client.Activity.SetRepositorySubscription(ctx, owner, repo, sub) + case RepositorySubscriptionActionDelete: + resp, err = client.Activity.DeleteRepositorySubscription(ctx, owner, repo) + default: + return nil, nil, false, nil + } + return result, resp, true, err +} + // ManageRepositoryNotificationSubscription creates a tool to manage a repository notification subscription (ignore, watch, delete) func ManageRepositoryNotificationSubscription(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("manage_repository_notification_subscription", @@ -442,84 +513,70 @@ func ManageRepositoryNotificationSubscription(getClient GetClientFn, t translati Title: t("TOOL_MANAGE_REPOSITORY_NOTIFICATION_SUBSCRIPTION_USER_TITLE", "Manage repository notification subscription"), ReadOnlyHint: ToBoolPtr(false), }), - mcp.WithString("owner", + mcp.WithString(paramOwner, mcp.Required(), mcp.Description("The account owner of the repository."), ), - mcp.WithString("repo", + mcp.WithString(paramRepo, mcp.Required(), mcp.Description("The name of the repository."), ), - mcp.WithString("action", + mcp.WithString(paramAction, mcp.Required(), mcp.Description("Action to perform: ignore, watch, or delete the repository notification subscription."), mcp.Enum(RepositorySubscriptionActionIgnore, RepositorySubscriptionActionWatch, RepositorySubscriptionActionDelete), ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - owner, err := RequiredParam[string](request, "owner") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - repo, err := RequiredParam[string](request, "repo") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - action, err := RequiredParam[string](request, "action") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - var ( - resp *github.Response - result any - apiErr error - ) - - switch action { - case RepositorySubscriptionActionIgnore: - sub := &github.Subscription{Ignored: ToBoolPtr(true)} - result, resp, apiErr = client.Activity.SetRepositorySubscription(ctx, owner, repo, sub) - case RepositorySubscriptionActionWatch: - sub := &github.Subscription{Ignored: ToBoolPtr(false), Subscribed: ToBoolPtr(true)} - result, resp, apiErr = client.Activity.SetRepositorySubscription(ctx, owner, repo, sub) - case RepositorySubscriptionActionDelete: - resp, apiErr = client.Activity.DeleteRepositorySubscription(ctx, owner, repo) - default: - return mcp.NewToolResultError("Invalid action. Must be one of: ignore, watch, delete."), nil - } - - if apiErr != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to %s repository subscription", action), - resp, - apiErr, - ), nil - } - if resp != nil { - defer func() { _ = resp.Body.Close() }() - } - - // Handle non-2xx status codes - if resp != nil && (resp.StatusCode < 200 || resp.StatusCode >= 300) { - body, _ := io.ReadAll(resp.Body) - return mcp.NewToolResultError(fmt.Sprintf("failed to %s repository subscription: %s", action, string(body))), nil - } - - if action == RepositorySubscriptionActionDelete { - // Special case for delete as there is no response body - return mcp.NewToolResultText("Repository subscription deleted"), nil - } - - r, err := json.Marshal(result) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - return mcp.NewToolResultText(string(r)), nil + return manageRepositoryNotificationSubscriptionHandler(ctx, getClient, request) } } + +func manageRepositoryNotificationSubscriptionHandler(ctx context.Context, getClient GetClientFn, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf(errFailedToGetClient, err) + } + + owner, err := RequiredParam[string](request, paramOwner) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, paramRepo) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + action, err := RequiredParam[string](request, paramAction) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + result, resp, ok, apiErr := setRepositorySubscription(ctx, client, action, owner, repo) + if !ok { + return mcp.NewToolResultError("Invalid action. Must be one of: ignore, watch, delete."), nil + } + + if apiErr != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to %s repository subscription", action), + resp, + apiErr, + ), nil + } + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + + // Handle non-2xx status codes + if resp != nil && (resp.StatusCode < 200 || resp.StatusCode >= 300) { + body, _ := io.ReadAll(resp.Body) + return mcp.NewToolResultError(fmt.Sprintf("failed to %s repository subscription: %s", action, string(body))), nil + } + + if action == RepositorySubscriptionActionDelete { + // Special case for delete as there is no response body + return mcp.NewToolResultText("Repository subscription deleted"), nil + } + + return marshalToolResult(result) +}