Files
Sam Morrow 56bfeec0a9 Centralize sanitization of untrusted GitHub response fields
Sanitization was previously applied ad hoc at a handful of tool call
sites (GetIssue, GetPullRequest, ListPullRequests) rather than in the
shared convertToMinimal* converters, so equivalent user-authored text
returned by other tools (issue comments, PR reviews, review comments,
releases, commit messages, discussions, project item titles) was
returned unsanitized.

- Apply sanitize.Sanitize inside the convertToMinimal* helpers in
  minimal_types.go for issue/PR titles and bodies, issue comments, PR
  reviews, review comments, releases, commit messages, and project
  item content titles. This is the single, shared conversion point
  used by nearly every read tool, so fixing it there covers get/list
  issues, pull requests, comments, reviews, review comments, releases,
  commits, and project items consistently.
- Add a sanitizeIssueTitleAndBody helper and use it for the two
  response paths that marshal a raw *github.Issue directly instead of
  a Minimal* type: search_issues (SearchIssueResult.MarshalJSON) and
  search_pull_requests (searchHandler).
- Sanitize discussion titles/bodies/comments (list_discussions,
  get_discussion, get_discussion_comments), which previously had no
  sanitization at all, via a new newMinimalDiscussionComment
  constructor and inline fixes.
- Sanitize project status update bodies.
- Remove the now-redundant scattered sanitize calls in GetIssue,
  GetPullRequest, and ListPullRequests now that the shared converters
  sanitize on their own.

Patches, diffs, and raw file contents are intentionally left
untouched to preserve fidelity.

Adds table-driven regression tests covering every touched converter,
the search_issues/search_pull_requests raw-passthrough paths, and a
fidelity check that patches/diffs are not altered.

Fixes #3106

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-19 15:20:33 +02:00

2889 lines
104 KiB
Go

package github
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
ghcontext "github.com/github/github-mcp-server/pkg/context"
ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/ifc"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/sanitize"
"github.com/github/github-mcp-server/pkg/scopes"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/github/github-mcp-server/pkg/utils"
"github.com/google/go-github/v89/github"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/shurcooL/githubv4"
)
const (
ProjectUpdateFailedError = "failed to update a project item"
ProjectAddFailedError = "failed to add a project item"
ProjectDeleteFailedError = "failed to delete a project item"
ProjectListFailedError = "failed to list project items"
ProjectStatusUpdateListFailedError = "failed to list project status updates"
ProjectStatusUpdateGetFailedError = "failed to get project status update"
ProjectStatusUpdateCreateFailedError = "failed to create project status update"
ProjectViewListFailedError = "failed to list project views"
ProjectViewGetFailedError = "failed to get project view"
ProjectViewCreateFailedError = "failed to create project view"
ProjectViewUpdateFailedError = "failed to update project view"
ProjectViewDeleteFailedError = "failed to delete project view"
ProjectResolveIDFailedError = "failed to resolve project ID"
MaxProjectsPerPage = 50
maxProjectItemsPerBatch = 50
)
// Method constants for consolidated project tools
const (
projectsMethodListProjects = "list_projects"
projectsMethodListProjectFields = "list_project_fields"
projectsMethodListProjectItems = "list_project_items"
projectsMethodGetProject = "get_project"
projectsMethodGetProjectField = "get_project_field"
projectsMethodGetProjectItem = "get_project_item"
projectsMethodAddProjectItem = "add_project_item"
projectsMethodUpdateProjectItem = "update_project_item"
projectsMethodUpdateProjectItems = "update_project_items"
projectsMethodDeleteProjectItem = "delete_project_item"
projectsMethodListProjectStatusUpdates = "list_project_status_updates"
projectsMethodGetProjectStatusUpdate = "get_project_status_update"
projectsMethodCreateProjectStatusUpdate = "create_project_status_update"
projectsMethodListProjectViews = "list_project_views"
projectsMethodGetProjectView = "get_project_view"
projectsMethodCreateProjectView = "create_project_view"
projectsMethodUpdateProjectView = "update_project_view"
projectsMethodDeleteProjectView = "delete_project_view"
projectsMethodCreateProject = "create_project"
projectsMethodCreateIterationField = "create_iteration_field"
)
// GraphQL types for ProjectV2 status updates
type statusUpdateNode struct {
ID githubv4.ID
Body *githubv4.String
Status *githubv4.String
CreatedAt githubv4.DateTime
StartDate *githubv4.String
TargetDate *githubv4.String
Creator struct {
Login githubv4.String
}
}
type projectVisibility struct {
Public githubv4.Boolean
}
type statusUpdateNodeWithProject struct {
statusUpdateNode
Project projectVisibility
}
type statusUpdateConnection struct {
Nodes []statusUpdateNode
PageInfo PageInfoFragment
}
type statusUpdatesProject struct {
Public githubv4.Boolean
StatusUpdates statusUpdateConnection `graphql:"statusUpdates(first: $first, after: $after, orderBy: {field: CREATED_AT, direction: DESC})"`
}
// statusUpdatesUserQuery is the GraphQL query for listing status updates on a user-owned project.
type statusUpdatesUserQuery struct {
User struct {
ProjectV2 statusUpdatesProject `graphql:"projectV2(number: $projectNumber)"`
} `graphql:"user(login: $owner)"`
}
// statusUpdatesOrgQuery is the GraphQL query for listing status updates on an org-owned project.
type statusUpdatesOrgQuery struct {
Organization struct {
ProjectV2 statusUpdatesProject `graphql:"projectV2(number: $projectNumber)"`
} `graphql:"organization(login: $owner)"`
}
// statusUpdateNodeQuery is the GraphQL query for fetching a single status update by node ID.
type statusUpdateNodeQuery struct {
Node struct {
StatusUpdate statusUpdateNodeWithProject `graphql:"... on ProjectV2StatusUpdate"`
} `graphql:"node(id: $id)"`
}
type projectViewNode struct {
ID githubv4.ID
Number githubv4.Int
Name githubv4.String
Layout githubv4.ProjectV2ViewLayout
Filter *githubv4.String
Configuration projectViewConfiguration
}
type projectViewConfiguration struct {
VisibleFields projectViewVisibleFieldsConnection `graphql:"visibleFields(first: 100)"`
}
type projectViewVisibleFieldsConnection struct {
Nodes []projectViewVisibleFieldNode
}
type projectViewVisibleFieldNode struct {
ProjectV2Field struct {
DatabaseID githubv4.Int `graphql:"databaseId"`
} `graphql:"... on ProjectV2Field"`
ProjectV2IterationField struct {
DatabaseID githubv4.Int `graphql:"databaseId"`
} `graphql:"... on ProjectV2IterationField"`
ProjectV2MultiSelectField struct {
DatabaseID githubv4.Int `graphql:"databaseId"`
} `graphql:"... on ProjectV2MultiSelectField"`
ProjectV2SingleSelectField struct {
DatabaseID githubv4.Int `graphql:"databaseId"`
} `graphql:"... on ProjectV2SingleSelectField"`
}
type projectViewNodeWithProject struct {
projectViewNode
Project projectVisibility
}
type projectViewConnection struct {
Nodes []projectViewNode
PageInfo PageInfoFragment
}
type projectViewsProject struct {
ID githubv4.ID
Public githubv4.Boolean
Views projectViewConnection `graphql:"views(first: $first, after: $after, last: $last, before: $before)"`
}
type projectViewsUserQuery struct {
User struct {
ProjectV2 projectViewsProject `graphql:"projectV2(number: $projectNumber)"`
} `graphql:"user(login: $owner)"`
}
type projectViewsOrgQuery struct {
Organization struct {
ProjectV2 projectViewsProject `graphql:"projectV2(number: $projectNumber)"`
} `graphql:"organization(login: $owner)"`
}
type projectViewNodeQuery struct {
Node struct {
ProjectView projectViewNodeWithProject `graphql:"... on ProjectV2View"`
} `graphql:"node(id: $id)"`
}
type projectViewParentQuery struct {
Node struct {
ProjectView struct {
ID githubv4.ID
Layout githubv4.ProjectV2ViewLayout
Project struct {
ID githubv4.ID
}
} `graphql:"... on ProjectV2View"`
} `graphql:"node(id: $id)"`
}
// ProjectV2ViewConfigurationInput is the GraphQL view configuration input.
type ProjectV2ViewConfigurationInput struct {
VisibleFieldIDs []githubv4.ID `json:"visibleFieldIds"`
}
// CreateProjectV2ViewInput is the GraphQL input for creating a project view.
type CreateProjectV2ViewInput struct {
ProjectID githubv4.ID `json:"projectId"`
Name githubv4.String `json:"name"`
Layout githubv4.ProjectV2ViewLayout `json:"layout"`
Configuration *ProjectV2ViewConfigurationInput `json:"configuration,omitempty"`
}
// UpdateProjectV2ViewInput is the GraphQL input for updating a project view.
type UpdateProjectV2ViewInput struct {
ViewID githubv4.ID `json:"viewId"`
Name *githubv4.String `json:"name,omitempty"`
Layout *githubv4.ProjectV2ViewLayout `json:"layout,omitempty"`
Filter *githubv4.String `json:"filter,omitempty"`
Configuration *ProjectV2ViewConfigurationInput `json:"configuration,omitempty"`
}
type createProjectV2ViewMutation struct {
CreateProjectV2View struct {
ProjectV2View projectViewNode `graphql:"projectV2View"`
} `graphql:"createProjectV2View(input: $input)"`
}
type updateProjectV2ViewMutation struct {
UpdateProjectV2View struct {
ProjectV2View projectViewNode `graphql:"projectV2View"`
} `graphql:"updateProjectV2View(input: $input)"`
}
// DeleteProjectV2ViewInput is the GraphQL input for deleting a project view.
type DeleteProjectV2ViewInput struct {
ViewID githubv4.ID `json:"viewId"`
}
// CreateProjectV2StatusUpdateInput is the input for the createProjectV2StatusUpdate mutation.
// Defined locally because the shurcooL/githubv4 library does not include this type.
type CreateProjectV2StatusUpdateInput struct {
ProjectID githubv4.ID `json:"projectId"`
Body *githubv4.String `json:"body,omitempty"`
Status *githubv4.String `json:"status,omitempty"`
StartDate *githubv4.String `json:"startDate,omitempty"`
TargetDate *githubv4.String `json:"targetDate,omitempty"`
ClientMutationID *githubv4.String `json:"clientMutationId,omitempty"`
}
// validProjectV2StatusUpdateStatuses is the set of valid status values for the createProjectV2StatusUpdate mutation.
var validProjectV2StatusUpdateStatuses = map[string]bool{
"INACTIVE": true,
"ON_TRACK": true,
"AT_RISK": true,
"OFF_TRACK": true,
"COMPLETE": true,
}
func convertToMinimalStatusUpdate(node statusUpdateNode) MinimalProjectStatusUpdate {
var creator *MinimalUser
if login := string(node.Creator.Login); login != "" {
creator = &MinimalUser{Login: login}
}
return MinimalProjectStatusUpdate{
ID: fmt.Sprintf("%v", node.ID),
Body: sanitize.Sanitize(derefString(node.Body)),
Status: derefString(node.Status),
CreatedAt: node.CreatedAt.Time.Format(time.RFC3339),
StartDate: derefString(node.StartDate),
TargetDate: derefString(node.TargetDate),
Creator: creator,
}
}
func derefString(s *githubv4.String) string {
if s == nil {
return ""
}
return string(*s)
}
// ProjectsList returns the tool and handler for listing GitHub Projects resources.
func ProjectsList(t translations.TranslationHelperFunc) inventory.ServerTool {
tool := NewTool(
ToolsetMetadataProjects,
mcp.Tool{
Name: "projects_list",
Description: t("TOOL_PROJECTS_LIST_DESCRIPTION",
`Tools for listing GitHub Projects resources.
Use this tool to list projects for a user or organization, or list project fields, items, views, and status updates for a specific project.
`),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_PROJECTS_LIST_USER_TITLE", "List GitHub Projects resources"),
ReadOnlyHint: true,
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"method": {
Type: "string",
Description: "The action to perform",
Enum: []any{
projectsMethodListProjects,
projectsMethodListProjectFields,
projectsMethodListProjectItems,
projectsMethodListProjectStatusUpdates,
projectsMethodListProjectViews,
},
},
"owner_type": {
Type: "string",
Description: "Owner type (user or org). If not provided, will automatically try both.",
Enum: []any{"user", "org"},
},
"owner": {
Type: "string",
Description: "The owner (user or organization login). The name is not case sensitive.",
},
"project_number": {
Type: "number",
Description: "The project's number. Required for 'list_project_fields', 'list_project_items', 'list_project_views', and 'list_project_status_updates' methods.",
},
"query": {
Type: "string",
Description: `Filter/query string. For list_projects: filter by title text and state (e.g. "roadmap is:open"). For list_project_items: advanced filtering using GitHub's project filtering syntax.`,
},
"fields": {
Type: "array",
Description: "Field IDs to include when listing project items (e.g. [\"102589\", \"985201\"]). CRITICAL: Always provide to get field values. Without this (and without 'field_names'), only titles returned. Mutually exclusive with 'field_names' — provide one, not both. Only used for 'list_project_items' method.",
Items: &jsonschema.Schema{
Type: "string",
},
},
"field_names": {
Type: "array",
Description: "Field names to include when listing project items (e.g. [\"Status\", \"Priority\"]). Resolved server-side to field IDs — pass this instead of 'fields' when you only know the human-readable names. Names that fail to resolve return a structured error. Mutually exclusive with 'fields' — provide one, not both. Only used for 'list_project_items' method.",
Items: &jsonschema.Schema{
Type: "string",
},
},
"per_page": {
Type: "number",
Description: fmt.Sprintf("Results per page (max %d)", MaxProjectsPerPage),
},
"after": {
Type: "string",
Description: "Forward pagination cursor from previous pageInfo.nextCursor.",
},
"before": {
Type: "string",
Description: "Backward pagination cursor from previous pageInfo.prevCursor (rare).",
},
},
Required: []string{"method", "owner"},
},
},
[]scopes.Scope{scopes.ReadProject},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
method, err := RequiredParam[string](args, "method")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
ownerType, err := OptionalParam[string](args, "owner_type")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
switch method {
case projectsMethodListProjects:
result, visibilities, payload, err := listProjects(ctx, client, args, owner, ownerType)
result = attachJoinedIFCLabel(ctx, deps, result, visibilities, ifc.LabelProjectList)
return result, payload, err
case projectsMethodListProjectFields, projectsMethodListProjectItems, projectsMethodListProjectStatusUpdates, projectsMethodListProjectViews:
// All other methods require project_number and ownerType detection
projectNumber, err := RequiredInt(args, "project_number")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
if ownerType == "" {
ownerType, err = detectOwnerType(ctx, client, owner, projectNumber)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
}
switch method {
case projectsMethodListProjectFields:
result, payload, err := listProjectFields(ctx, client, args, owner, ownerType)
if shouldAttachIFCLabel(ctx, deps, result) {
isPrivate, visibilityErr := FetchProjectIsPrivate(ctx, client, owner, ownerType, projectNumber)
if visibilityErr == nil {
result = attachProjectVisibilityIFCLabel(ctx, deps, result, isPrivate, ifc.LabelProject)
}
}
return result, payload, err
case projectsMethodListProjectItems:
gqlClient, gqlErr := deps.GetGQLClient(ctx)
if gqlErr != nil {
return utils.NewToolResultError(gqlErr.Error()), nil, nil
}
result, payload, err := listProjectItems(ctx, client, gqlClient, args, owner, ownerType)
if shouldAttachIFCLabel(ctx, deps, result) {
isPrivate, visibilityErr := FetchProjectIsPrivate(ctx, client, owner, ownerType, projectNumber)
if visibilityErr == nil {
result = attachProjectVisibilityIFCLabel(ctx, deps, result, isPrivate, ifc.LabelProjectContent)
}
}
return result, payload, err
case projectsMethodListProjectStatusUpdates:
gqlClient, err := deps.GetGQLClient(ctx)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
result, isPrivate, payload, err := listProjectStatusUpdates(ctx, gqlClient, args, owner, ownerType)
result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelProjectContent(isPrivate))
return result, payload, err
case projectsMethodListProjectViews:
gqlClient, err := deps.GetGQLClient(ctx)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
result, isPrivate, payload, err := listProjectViews(ctx, gqlClient, args, owner, ownerType)
result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelProjectContent(isPrivate))
return result, payload, err
default:
return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil
}
default:
return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil
}
},
)
return tool
}
// ProjectsGet returns the tool and handler for getting GitHub Projects resources.
func ProjectsGet(t translations.TranslationHelperFunc) inventory.ServerTool {
tool := NewTool(
ToolsetMetadataProjects,
mcp.Tool{
Name: "projects_get",
Description: t("TOOL_PROJECTS_GET_DESCRIPTION", `Get details about specific GitHub Projects resources.
Use this tool to get details about individual projects, project fields, project items, and project views by their unique IDs.
`),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_PROJECTS_GET_USER_TITLE", "Get details of GitHub Projects resources"),
ReadOnlyHint: true,
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"method": {
Type: "string",
Description: "The method to execute",
Enum: []any{
projectsMethodGetProject,
projectsMethodGetProjectField,
projectsMethodGetProjectItem,
projectsMethodGetProjectStatusUpdate,
projectsMethodGetProjectView,
},
},
"owner_type": {
Type: "string",
Description: "Owner type (user or org). If not provided, will be automatically detected.",
Enum: []any{"user", "org"},
},
"owner": {
Type: "string",
Description: "The owner (user or organization login). The name is not case sensitive.",
},
"project_number": {
Type: "number",
Description: "The project's number.",
},
"field_id": {
Type: "number",
Description: "The field's ID. Required for 'get_project_field' method.",
},
"item_id": {
Type: "number",
Description: "The item's ID. Required for 'get_project_item' method.",
},
"fields": {
Type: "array",
Description: "Specific list of field IDs to include in the response when getting a project item (e.g. [\"102589\", \"985201\", \"169875\"]). If neither 'fields' nor 'field_names' is provided, only the title field is included. Mutually exclusive with 'field_names' — provide one, not both. Only used for 'get_project_item' method.",
Items: &jsonschema.Schema{
Type: "string",
},
},
"field_names": {
Type: "array",
Description: "Specific list of field names to include in the response when getting a project item (e.g. [\"Status\", \"Priority\"]). Resolved server-side to field IDs — pass this instead of 'fields' when you only know the human-readable names. Mutually exclusive with 'fields' — provide one, not both. Only used for 'get_project_item' method.",
Items: &jsonschema.Schema{
Type: "string",
},
},
"status_update_id": {
Type: "string",
Description: "The node ID of the project status update. Required for 'get_project_status_update' method.",
},
"view_id": {
Type: "string",
Description: "The node ID of the project view. Required for 'get_project_view' method.",
},
},
Required: []string{"method"},
},
},
[]scopes.Scope{scopes.ReadProject},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
method, err := RequiredParam[string](args, "method")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
// Handle node-ID-only methods before requiring owner and project_number.
if method == projectsMethodGetProjectStatusUpdate {
statusUpdateID, err := RequiredParam[string](args, "status_update_id")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
gqlClient, err := deps.GetGQLClient(ctx)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
result, isPrivate, payload, err := getProjectStatusUpdate(ctx, gqlClient, statusUpdateID)
result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelProjectContent(isPrivate))
return result, payload, err
}
if method == projectsMethodGetProjectView {
viewID, err := RequiredParam[string](args, "view_id")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
gqlClient, err := deps.GetGQLClient(ctx)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
result, isPrivate, payload, err := getProjectView(ctx, gqlClient, viewID)
result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelProjectContent(isPrivate))
return result, payload, err
}
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
ownerType, err := OptionalParam[string](args, "owner_type")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
projectNumber, err := RequiredInt(args, "project_number")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
// Detect owner type if not provided
if ownerType == "" {
ownerType, err = detectOwnerType(ctx, client, owner, projectNumber)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
}
switch method {
case projectsMethodGetProject:
result, isPrivate, payload, err := getProject(ctx, client, owner, ownerType, projectNumber)
result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelProject(isPrivate))
return result, payload, err
case projectsMethodGetProjectField:
fieldID, err := RequiredBigInt(args, "field_id")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
result, payload, err := getProjectField(ctx, client, owner, ownerType, projectNumber, fieldID)
if shouldAttachIFCLabel(ctx, deps, result) {
isPrivate, visibilityErr := FetchProjectIsPrivate(ctx, client, owner, ownerType, projectNumber)
if visibilityErr == nil {
result = attachProjectVisibilityIFCLabel(ctx, deps, result, isPrivate, ifc.LabelProject)
}
}
return result, payload, err
case projectsMethodGetProjectItem:
itemID, err := RequiredBigInt(args, "item_id")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
fields, err := OptionalBigIntArrayParam(args, "fields")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
fieldNames, err := OptionalStringArrayParam(args, "field_names")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
if len(fields) > 0 && len(fieldNames) > 0 {
return utils.NewToolResultError("provide either 'fields' or 'field_names', not both"), nil, nil
}
if len(fieldNames) > 0 {
gqlClient, gqlErr := deps.GetGQLClient(ctx)
if gqlErr != nil {
return utils.NewToolResultError(gqlErr.Error()), nil, nil
}
resolvedIDs, resolveErr := resolveFieldNamesToIDs(ctx, gqlClient, owner, ownerType, projectNumber, fieldNames, "fields")
if resolveErr != nil {
var structured *ghErrors.StructuredResolutionError
if errors.As(resolveErr, &structured) {
return ghErrors.NewStructuredResolutionErrorResponse(structured), nil, nil
}
return utils.NewToolResultError(resolveErr.Error()), nil, nil
}
fields = append(fields, resolvedIDs...)
}
result, payload, err := getProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, fields)
if shouldAttachIFCLabel(ctx, deps, result) {
isPrivate, visibilityErr := FetchProjectIsPrivate(ctx, client, owner, ownerType, projectNumber)
if visibilityErr == nil {
result = attachProjectVisibilityIFCLabel(ctx, deps, result, isPrivate, ifc.LabelProjectContent)
}
}
return result, payload, err
default:
return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil
}
},
)
return tool
}
func updateProjectItemsItemSchema() *jsonschema.Schema {
variant := func(required []string, properties map[string]*jsonschema.Schema) *jsonschema.Schema {
return &jsonschema.Schema{
Type: "object",
AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}},
Properties: properties,
Required: required,
}
}
return &jsonschema.Schema{
Type: "object",
OneOf: []*jsonschema.Schema{
variant([]string{"node_id"}, map[string]*jsonschema.Schema{
"node_id": {
Type: "string",
Description: "The project item's GraphQL node ID, as returned by 'list_project_items' or 'add_project_item'.",
},
}),
variant([]string{"item_id"}, map[string]*jsonschema.Schema{
"item_id": {
Type: "integer",
Description: "The numeric project item ID.",
},
}),
variant([]string{"item_owner", "item_repo", "issue_number"}, map[string]*jsonschema.Schema{
"item_owner": {
Type: "string",
Description: "Owner of the repository containing the issue.",
},
"item_repo": {
Type: "string",
Description: "Repository containing the issue.",
},
"issue_number": {
Type: "integer",
Description: "Issue number used to resolve the project item.",
},
}),
},
}
}
func projectUpdatedFieldSchema() *jsonschema.Schema {
value := &jsonschema.Schema{
Description: "The value to apply. Any JSON value is accepted; use null to clear the field.",
}
variant := func(required []string, properties map[string]*jsonschema.Schema) *jsonschema.Schema {
properties["value"] = value
return &jsonschema.Schema{
Type: "object",
AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}},
Properties: properties,
Required: required,
}
}
return &jsonschema.Schema{
Type: "object",
Description: "The field/value to apply, using {\"id\": 123, \"value\": ...} or {\"name\": \"Status\", \"value\": ...}; null clears the field. Required for 'update_project_item' and 'update_project_items', where one top-level field/value applies to every item in a batch. For 'update_project_item' SINGLE_SELECT fields, the name form accepts option names; the ID form expects an option ID.",
OneOf: []*jsonschema.Schema{
variant([]string{"id", "value"}, map[string]*jsonschema.Schema{
"id": {
Type: "integer",
Description: "The numeric project field ID.",
},
}),
variant([]string{"name", "value"}, map[string]*jsonschema.Schema{
"name": {
Type: "string",
Description: "The project field name. Matching is case-insensitive.",
},
}),
},
}
}
// ProjectsWrite returns the tool and handler for modifying GitHub Projects resources.
func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
tool := NewTool(
ToolsetMetadataProjects,
mcp.Tool{
Name: "projects_write",
Description: t("TOOL_PROJECTS_WRITE_DESCRIPTION", "Create and manage GitHub Projects: create projects, add/update/delete items, bulk-update many items at once, manage views, create status updates, and add iteration fields."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_PROJECTS_WRITE_USER_TITLE", "Manage GitHub Projects"),
ReadOnlyHint: false,
DestructiveHint: jsonschema.Ptr(true),
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"method": {
Type: "string",
Description: "The method to execute",
Enum: []any{
projectsMethodAddProjectItem,
projectsMethodUpdateProjectItem,
projectsMethodUpdateProjectItems,
projectsMethodDeleteProjectItem,
projectsMethodCreateProjectStatusUpdate,
projectsMethodCreateProjectView,
projectsMethodUpdateProjectView,
projectsMethodDeleteProjectView,
projectsMethodCreateProject,
projectsMethodCreateIterationField,
},
},
"owner_type": {
Type: "string",
Description: "Owner type (user or org). Required for 'create_project' method. If not provided for other methods, will be automatically detected.",
Enum: []any{"user", "org"},
},
"owner": {
Type: "string",
Description: "The project owner (user or organization login). The name is not case sensitive.",
},
"project_number": {
Type: "number",
Description: "The project's number. Required for all methods except 'create_project'.",
},
"title": {
Type: "string",
Description: "The project title. Required for 'create_project' method.",
},
"view_id": {
Type: "string",
Description: "Project view node ID for update or delete; must belong to owner/project_number.",
},
"name": {
Type: "string",
Description: "View name; required when creating a view.",
},
"layout": {
Type: "string",
Description: "View layout; required when creating a view.",
Enum: []any{"table", "board", "roadmap"},
},
"filter": {
AnyOf: []*jsonschema.Schema{
{Type: "string"},
{Type: "null"},
},
Description: "Saved view filter; omit on update to preserve it, or pass null to clear it.",
},
"visible_fields": {
Type: "array",
Description: "Ordered project field database IDs to show on create or replace on update; omit on update to preserve, or pass [] to reset. Mutually exclusive with visible_field_names. Roadmap accepts only [].",
Items: &jsonschema.Schema{
Type: "string",
},
},
"visible_field_names": {
Type: "array",
Description: "Ordered project field names to show on create or replace on update; omit on update to preserve, or pass [] to reset. Mutually exclusive with visible_fields. Roadmap accepts only [].",
Items: &jsonschema.Schema{
Type: "string",
},
},
"item_id": {
Type: "number",
Description: "The project item ID. Required for 'delete_project_item'. For 'update_project_item', provide either item_id, or (item_owner + item_repo + issue_number) to resolve the item by issue.",
},
"item_type": {
Type: "string",
Description: "The item's type, either issue or pull_request. Required for 'add_project_item' method.",
Enum: []any{"issue", "pull_request"},
},
"item_owner": {
Type: "string",
Description: "The owner (user or organization) of the repository containing the issue or pull request. Required for 'add_project_item' method. Also accepted by 'update_project_item' when resolving the item by issue number.",
},
"item_repo": {
Type: "string",
Description: "The name of the repository containing the issue or pull request. Required for 'add_project_item' method. Also accepted by 'update_project_item' when resolving the item by issue number.",
},
"issue_number": {
Type: "number",
Description: "The issue number. Required for 'add_project_item' when item_type is 'issue'. Also accepted by 'update_project_item' to resolve the item by issue number (combine with item_owner and item_repo).",
},
"pull_request_number": {
Type: "number",
Description: "The pull request number (use when item_type is 'pull_request' for 'add_project_item' method). Provide either issue_number or pull_request_number.",
},
"updated_field": projectUpdatedFieldSchema(),
"items": {
Type: "array",
Description: "The items to update with the top-level 'updated_field'. Required for 'update_project_items'; prefer it over calling 'update_project_item' in a loop. Each entry must match exactly one reference variant: 'node_id', numeric 'item_id', or 'item_owner' + 'item_repo' + 'issue_number'. Limit: " + strconv.Itoa(maxProjectItemsPerBatch) + " items per call.",
Items: updateProjectItemsItemSchema(),
},
"body": {
Type: "string",
Description: "The body of the status update (markdown). Used for 'create_project_status_update' method.",
},
"status": {
Type: "string",
Description: "The status of the project. Used for 'create_project_status_update' method.",
Enum: []any{"INACTIVE", "ON_TRACK", "AT_RISK", "OFF_TRACK", "COMPLETE"},
},
"start_date": {
Type: "string",
Description: "Start date in YYYY-MM-DD format. Used for 'create_project_status_update' and 'create_iteration_field' methods.",
},
"target_date": {
Type: "string",
Description: "The target date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method.",
},
"field_name": {
Type: "string",
Description: "The name of the iteration field (e.g. 'Sprint'). Required for 'create_iteration_field' method.",
},
"iteration_duration": {
Type: "number",
Description: "Duration in days for iterations of the field (e.g. 7 for weekly, 14 for bi-weekly). Required for 'create_iteration_field' method.",
},
"iterations": {
Type: "array",
Description: "Custom iterations for 'create_iteration_field' method. Only set this when you need iterations with varying durations, breaks between them, or specific titles. Otherwise omit it: GitHub auto-creates three iterations of 'iteration_duration' days starting on 'start_date', which is the right choice for most cases.",
Items: &jsonschema.Schema{
Type: "object",
AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}},
Properties: map[string]*jsonschema.Schema{
"title": {
Type: "string",
Description: "Iteration title (e.g. 'Sprint 1')",
},
"start_date": {
Type: "string",
Description: "Start date in YYYY-MM-DD format",
},
"duration": {
Type: "number",
Description: "Duration in days",
},
},
Required: []string{"title", "start_date", "duration"},
},
},
},
Required: []string{"method", "owner"},
},
},
[]scopes.Scope{scopes.Project},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
method, err := RequiredParam[string](args, "method")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
ownerType, err := OptionalParam[string](args, "owner_type")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
// create_project does not require project_number or a REST client
if method == projectsMethodCreateProject {
gqlClient, gqlErr := deps.GetGQLClient(ctx)
if gqlErr != nil {
return utils.NewToolResultError(gqlErr.Error()), nil, nil
}
return createProject(ctx, gqlClient, owner, ownerType, args)
}
projectNumber, err := RequiredInt(args, "project_number")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
// Detect owner type if not provided
if ownerType == "" {
ownerType, err = detectOwnerType(ctx, client, owner, projectNumber)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
}
gqlClient, err := deps.GetGQLClient(ctx)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
switch method {
case projectsMethodAddProjectItem:
itemType, err := RequiredParam[string](args, "item_type")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
itemOwner, err := RequiredParam[string](args, "item_owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
itemRepo, err := RequiredParam[string](args, "item_repo")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
var itemNumber int
switch itemType {
case "issue":
itemNumber, err = RequiredInt(args, "issue_number")
if err != nil {
return utils.NewToolResultError("issue_number is required when item_type is 'issue'"), nil, nil
}
case "pull_request":
itemNumber, err = RequiredInt(args, "pull_request_number")
if err != nil {
return utils.NewToolResultError("pull_request_number is required when item_type is 'pull_request'"), nil, nil
}
default:
return utils.NewToolResultError("item_type must be either 'issue' or 'pull_request'"), nil, nil
}
return addProjectItem(ctx, gqlClient, owner, ownerType, projectNumber, itemOwner, itemRepo, itemNumber, itemType)
case projectsMethodUpdateProjectItem:
var itemID int64
if _, hasItemID := args["item_id"]; hasItemID {
id, err := RequiredBigInt(args, "item_id")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
itemID = id
} else {
// Resolve the item by (item_owner, item_repo, issue_number).
resolvedItemID, resolveErr := resolveItemIDFromIssueArgs(ctx, gqlClient, owner, ownerType, projectNumber, args)
if resolveErr != nil {
var structured *ghErrors.StructuredResolutionError
if errors.As(resolveErr, &structured) {
return ghErrors.NewStructuredResolutionErrorResponse(structured), nil, nil
}
return utils.NewToolResultError(resolveErr.Error()), nil, nil
}
itemID = resolvedItemID
}
rawUpdatedField, exists := args["updated_field"]
if !exists {
return utils.NewToolResultError("missing required parameter: updated_field"), nil, nil
}
fieldValue, ok := rawUpdatedField.(map[string]any)
if !ok || fieldValue == nil {
return utils.NewToolResultError("updated_field must be an object"), nil, nil
}
return updateProjectItem(ctx, client, gqlClient, owner, ownerType, projectNumber, itemID, fieldValue)
case projectsMethodUpdateProjectItems:
return updateProjectItemsBatch(ctx, client, gqlClient, owner, ownerType, projectNumber, args)
case projectsMethodDeleteProjectItem:
itemID, err := RequiredBigInt(args, "item_id")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
return deleteProjectItem(ctx, client, owner, ownerType, projectNumber, itemID)
case projectsMethodCreateProjectStatusUpdate:
body, err := OptionalParam[string](args, "body")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
status, err := OptionalParam[string](args, "status")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
startDate, err := OptionalParam[string](args, "start_date")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
targetDate, err := OptionalParam[string](args, "target_date")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
return createProjectStatusUpdate(ctx, gqlClient, owner, ownerType, projectNumber, body, status, startDate, targetDate)
case projectsMethodCreateIterationField:
return createIterationField(ctx, gqlClient, owner, ownerType, projectNumber, args)
case projectsMethodCreateProjectView:
return createProjectView(ctx, gqlClient, args, owner, ownerType, projectNumber)
case projectsMethodUpdateProjectView:
return updateProjectView(ctx, gqlClient, args, owner, ownerType, projectNumber)
case projectsMethodDeleteProjectView:
return deleteProjectView(ctx, gqlClient, args, owner, ownerType, projectNumber)
default:
return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil
}
},
)
return tool
}
// Helper functions for consolidated projects tools
func listProjects(ctx context.Context, client *github.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, []bool, any, error) {
queryStr, err := OptionalParam[string](args, "query")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil, nil
}
pagination, err := extractPaginationOptionsFromArgs(args)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil, nil
}
var resp *github.Response
var projects []*github.ProjectV2
minimalProjects := []MinimalProject{}
opts := &github.ListProjectsOptions{
ListProjectsPaginationOptions: pagination,
Query: queryStr,
}
// If owner_type not provided, fetch from both user and org
switch ownerType {
case "":
return listProjectsFromBothOwnerTypes(ctx, client, owner, opts)
case "org":
projects, resp, err = client.Projects.ListOrganizationProjects(ctx, owner, opts)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
"failed to list projects",
resp,
err,
), nil, nil, nil
}
default:
projects, resp, err = client.Projects.ListUserProjects(ctx, owner, opts)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
"failed to list projects",
resp,
err,
), nil, nil, nil
}
}
// For specified owner_type, process normally
if ownerType != "" {
defer func() { _ = resp.Body.Close() }()
for _, project := range projects {
mp := convertToMinimalProject(project)
mp.OwnerType = ownerType
minimalProjects = append(minimalProjects, *mp)
}
response := map[string]any{
"projects": minimalProjects,
"pageInfo": buildPageInfo(resp),
}
r, err := json.Marshal(response)
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to marshal response: %w", err)
}
return utils.NewToolResultText(string(r)), projectVisibilities(minimalProjects), nil, nil
}
return nil, nil, nil, fmt.Errorf("unexpected state in listProjects")
}
// listProjectsFromBothOwnerTypes fetches projects from both user and org endpoints
// when owner_type is not specified, combining the results with owner_type labels.
func listProjectsFromBothOwnerTypes(ctx context.Context, client *github.Client, owner string, opts *github.ListProjectsOptions) (*mcp.CallToolResult, []bool, any, error) {
var minimalProjects []MinimalProject
var resp *github.Response
// Fetch user projects
userProjects, userResp, userErr := client.Projects.ListUserProjects(ctx, owner, opts)
if userErr == nil && userResp.StatusCode == http.StatusOK {
for _, project := range userProjects {
mp := convertToMinimalProject(project)
mp.OwnerType = "user"
minimalProjects = append(minimalProjects, *mp)
}
_ = userResp.Body.Close()
}
// Fetch org projects
orgProjects, orgResp, orgErr := client.Projects.ListOrganizationProjects(ctx, owner, opts)
if orgErr == nil && orgResp.StatusCode == http.StatusOK {
for _, project := range orgProjects {
mp := convertToMinimalProject(project)
mp.OwnerType = "org"
minimalProjects = append(minimalProjects, *mp)
}
resp = orgResp // Use org response for pagination info
} else if userResp != nil {
resp = userResp // Fallback to user response
}
// If both failed, return error
if (userErr != nil || userResp == nil || userResp.StatusCode != http.StatusOK) &&
(orgErr != nil || orgResp == nil || orgResp.StatusCode != http.StatusOK) {
return utils.NewToolResultError(fmt.Sprintf("failed to list projects for owner '%s': not found as user or organization", owner)), nil, nil, nil
}
response := map[string]any{
"projects": minimalProjects,
"note": "Results include both user and org projects. Each project includes 'owner_type' field. Pagination is limited when owner_type is not specified - specify 'owner_type' for full pagination support.",
}
if resp != nil {
response["pageInfo"] = buildPageInfo(resp)
defer func() { _ = resp.Body.Close() }()
}
r, err := json.Marshal(response)
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to marshal response: %w", err)
}
return utils.NewToolResultText(string(r)), projectVisibilities(minimalProjects), nil, nil
}
func projectVisibilities(projects []MinimalProject) []bool {
visibilities := make([]bool, 0, len(projects))
for _, project := range projects {
isPrivate := true
if project.Public != nil {
isPrivate = !*project.Public
}
visibilities = append(visibilities, isPrivate)
}
return visibilities
}
func listProjectFields(ctx context.Context, client *github.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, any, error) {
projectNumber, err := RequiredInt(args, "project_number")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
pagination, err := extractPaginationOptionsFromArgs(args)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
var resp *github.Response
var projectFields []*github.ProjectV2Field
opts := &github.ListProjectsOptions{
ListProjectsPaginationOptions: pagination,
}
if ownerType == "org" {
projectFields, resp, err = client.Projects.ListOrganizationProjectFields(ctx, owner, projectNumber, opts)
} else {
projectFields, resp, err = client.Projects.ListUserProjectFields(ctx, owner, projectNumber, opts)
}
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
"failed to list project fields",
resp,
err,
), nil, nil
}
defer func() { _ = resp.Body.Close() }()
response := map[string]any{
"fields": projectFields,
"pageInfo": buildPageInfo(resp),
}
r, err := json.Marshal(response)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
}
return utils.NewToolResultText(string(r)), nil, nil
}
func listProjectItems(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, any, error) {
projectNumber, err := RequiredInt(args, "project_number")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
queryStr, err := OptionalParam[string](args, "query")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
fields, err := OptionalBigIntArrayParam(args, "fields")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
fieldNames, err := OptionalStringArrayParam(args, "field_names")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
if len(fields) > 0 && len(fieldNames) > 0 {
return utils.NewToolResultError("provide either 'fields' or 'field_names', not both"), nil, nil
}
if len(fieldNames) > 0 {
resolvedIDs, resolveErr := resolveFieldNamesToIDs(ctx, gqlClient, owner, ownerType, projectNumber, fieldNames, "fields")
if resolveErr != nil {
var structured *ghErrors.StructuredResolutionError
if errors.As(resolveErr, &structured) {
return ghErrors.NewStructuredResolutionErrorResponse(structured), nil, nil
}
return utils.NewToolResultError(resolveErr.Error()), nil, nil
}
fields = append(fields, resolvedIDs...)
}
pagination, err := extractPaginationOptionsFromArgs(args)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
var resp *github.Response
var projectItems []*github.ProjectV2Item
opts := &github.ListProjectItemsOptions{
Fields: fields,
ListProjectsOptions: github.ListProjectsOptions{
ListProjectsPaginationOptions: pagination,
Query: queryStr,
},
}
if ownerType == "org" {
projectItems, resp, err = client.Projects.ListOrganizationProjectItems(ctx, owner, projectNumber, opts)
} else {
projectItems, resp, err = client.Projects.ListUserProjectItems(ctx, owner, projectNumber, opts)
}
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
ProjectListFailedError,
resp,
err,
), nil, nil
}
defer func() { _ = resp.Body.Close() }()
minimalItems := make([]MinimalProjectItem, 0, len(projectItems))
for _, item := range projectItems {
minimalItems = append(minimalItems, convertToMinimalProjectItem(item))
}
response := map[string]any{
"items": minimalItems,
"pageInfo": buildPageInfo(resp),
}
r, err := json.Marshal(response)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
}
return utils.NewToolResultText(string(r)), nil, nil
}
func fetchProjectV2(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int) (*github.ProjectV2, *github.Response, error) {
if ownerType == "org" {
return client.Projects.GetOrganizationProject(ctx, owner, projectNumber)
}
return client.Projects.GetUserProject(ctx, owner, projectNumber)
}
// FetchProjectIsPrivate returns whether a GitHub Project is private.
func FetchProjectIsPrivate(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int) (bool, error) {
project, resp, err := fetchProjectV2(ctx, client, owner, ownerType, projectNumber)
if resp != nil && resp.Body != nil {
defer func() { _ = resp.Body.Close() }()
}
if err != nil {
return false, err
}
if resp == nil || resp.StatusCode != http.StatusOK {
return false, fmt.Errorf("failed to fetch project visibility")
}
return !project.GetPublic(), nil
}
func getProject(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int) (*mcp.CallToolResult, bool, any, error) {
project, resp, err := fetchProjectV2(ctx, client, owner, ownerType, projectNumber)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
"failed to get project",
resp,
err,
), false, nil, nil
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, false, nil, fmt.Errorf("failed to read response body: %w", err)
}
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get project", resp, body), false, nil, nil
}
minimalProject := convertToMinimalProject(project)
r, err := json.Marshal(minimalProject)
if err != nil {
return nil, false, nil, fmt.Errorf("failed to marshal response: %w", err)
}
return utils.NewToolResultText(string(r)), !project.GetPublic(), nil, nil
}
func getProjectField(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, fieldID int64) (*mcp.CallToolResult, any, error) {
var resp *github.Response
var projectField *github.ProjectV2Field
var err error
if ownerType == "org" {
projectField, resp, err = client.Projects.GetOrganizationProjectField(ctx, owner, projectNumber, fieldID)
} else {
projectField, resp, err = client.Projects.GetUserProjectField(ctx, owner, projectNumber, fieldID)
}
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
"failed to get project field",
resp,
err,
), nil, nil
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, nil, fmt.Errorf("failed to read response body: %w", err)
}
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get project field", resp, body), nil, nil
}
r, err := json.Marshal(projectField)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
}
return utils.NewToolResultText(string(r)), nil, nil
}
func getProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64, fields []int64) (*mcp.CallToolResult, any, error) {
projectItem, resp, err := fetchProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, fields)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
"failed to get project item",
resp,
err,
), nil, nil
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, nil, fmt.Errorf("failed to read response body: %w", err)
}
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get project item", resp, body), nil, nil
}
r, err := json.Marshal(convertToMinimalProjectItem(projectItem))
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
}
return utils.NewToolResultText(string(r)), nil, nil
}
func fetchProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64, fields []int64) (*github.ProjectV2Item, *github.Response, error) {
var resp *github.Response
var projectItem *github.ProjectV2Item
var opts *github.GetProjectItemOptions
var err error
if len(fields) > 0 {
opts = &github.GetProjectItemOptions{
Fields: fields,
}
}
if ownerType == "org" {
projectItem, resp, err = client.Projects.GetOrganizationProjectItem(ctx, owner, projectNumber, itemID, opts)
} else {
projectItem, resp, err = client.Projects.GetUserProjectItem(ctx, owner, projectNumber, itemID, opts)
}
return projectItem, resp, err
}
func updateProjectItem(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, itemID int64, fieldValue map[string]any) (*mcp.CallToolResult, any, error) {
updatePayload, issueField, err := buildUpdateProjectItem(ctx, gqlClient, owner, ownerType, projectNumber, fieldValue)
if err != nil {
var structured *ghErrors.StructuredResolutionError
if errors.As(err, &structured) {
return ghErrors.NewStructuredResolutionErrorResponse(structured), nil, nil
}
return utils.NewToolResultError(err.Error()), nil, nil
}
if issueField != nil {
projectItem, resp, fetchErr := fetchProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, nil)
if fetchErr != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get project item", resp, fetchErr), nil, nil
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return nil, nil, fmt.Errorf("failed to read response body: %w", readErr)
}
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get project item", resp, body), nil, nil
}
issueID, resolveErr := projectItemIssueID(projectItem)
if resolveErr != nil {
var structured *ghErrors.StructuredResolutionError
if errors.As(resolveErr, &structured) {
return ghErrors.NewStructuredResolutionErrorResponse(structured), nil, nil
}
return utils.NewToolResultError(resolveErr.Error()), nil, nil
}
// The setIssueFieldValue mutation is gated behind the update_issue_suggestions
// GraphQL feature flag, matching the set_issue_fields tool.
ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "update_issue_suggestions")
response, mutationErr := SetIssueFieldValues(ctxWithFeatures, gqlClient, SetIssueFieldValueInput{
IssueID: issueID,
IssueFields: []IssueFieldCreateOrUpdateInput{*issueField},
})
if mutationErr != nil {
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to set issue field value", mutationErr), nil, nil
}
r, marshalErr := json.Marshal(response)
if marshalErr != nil {
return nil, nil, fmt.Errorf("failed to marshal response: %w", marshalErr)
}
return utils.NewToolResultText(string(r)), nil, nil
}
var resp *github.Response
var updatedItem *github.ProjectV2Item
if ownerType == "org" {
updatedItem, resp, err = client.Projects.UpdateOrganizationProjectItem(ctx, owner, projectNumber, itemID, updatePayload)
} else {
updatedItem, resp, err = client.Projects.UpdateUserProjectItem(ctx, owner, projectNumber, itemID, updatePayload)
}
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
ProjectUpdateFailedError,
resp,
err,
), nil, nil
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, nil, fmt.Errorf("failed to read response body: %w", err)
}
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, ProjectUpdateFailedError, resp, body), nil, nil
}
r, err := json.Marshal(convertToMinimalProjectItem(updatedItem))
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
}
return utils.NewToolResultText(string(r)), nil, nil
}
func projectItemIssueID(item *github.ProjectV2Item) (githubv4.ID, error) {
if item == nil {
return nil, ghErrors.NewStructuredResolutionError(
"missing_metadata",
"",
"project item metadata is missing",
nil,
)
}
contentType := ""
if item.ContentType != nil {
contentType = string(*item.ContentType)
}
if contentType != string(github.ProjectV2ItemContentTypeIssue) {
return nil, ghErrors.NewStructuredResolutionError(
"unsupported_item_type",
contentType,
"attached Issue Fields can only be updated on Issue project items",
nil,
)
}
content := item.GetContent()
if content == nil || content.GetIssue() == nil || content.GetIssue().GetNodeID() == "" {
return nil, ghErrors.NewStructuredResolutionError(
"missing_metadata",
contentType,
"project Issue item is missing its Issue node ID",
nil,
)
}
return githubv4.ID(content.GetIssue().GetNodeID()), nil
}
func deleteProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64) (*mcp.CallToolResult, any, error) {
var resp *github.Response
var err error
if ownerType == "org" {
resp, err = client.Projects.DeleteOrganizationProjectItem(ctx, owner, projectNumber, itemID)
} else {
resp, err = client.Projects.DeleteUserProjectItem(ctx, owner, projectNumber, itemID)
}
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
ProjectDeleteFailedError,
resp,
err,
), nil, nil
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusNoContent {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, nil, fmt.Errorf("failed to read response body: %w", err)
}
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, ProjectDeleteFailedError, resp, body), nil, nil
}
return utils.NewToolResultText("project item successfully deleted"), nil, nil
}
// resolveProjectNodeID resolves (owner, ownerType, projectNumber) to a project node ID via GraphQL.
func resolveProjectNodeID(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int) (githubv4.ID, error) {
var projectIDQueryUser struct {
User struct {
ProjectV2 struct {
ID githubv4.ID
} `graphql:"projectV2(number: $projectNumber)"`
} `graphql:"user(login: $owner)"`
}
var projectIDQueryOrg struct {
Organization struct {
ProjectV2 struct {
ID githubv4.ID
} `graphql:"projectV2(number: $projectNumber)"`
} `graphql:"organization(login: $owner)"`
}
queryVars := map[string]any{
"owner": githubv4.String(owner),
"projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec // Project numbers are small integers
}
if ownerType == "org" {
err := gqlClient.Query(ctx, &projectIDQueryOrg, queryVars)
if err != nil {
return "", fmt.Errorf("%s: %w", ProjectResolveIDFailedError, err)
}
return projectIDQueryOrg.Organization.ProjectV2.ID, nil
}
err := gqlClient.Query(ctx, &projectIDQueryUser, queryVars)
if err != nil {
return "", fmt.Errorf("%s: %w", ProjectResolveIDFailedError, err)
}
return projectIDQueryUser.User.ProjectV2.ID, nil
}
// addProjectItem adds an item to a project by resolving the issue/PR number to a node ID
func addProjectItem(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, itemOwner, itemRepo string, itemNumber int, itemType string) (*mcp.CallToolResult, any, error) {
if itemType != "issue" && itemType != "pull_request" {
return utils.NewToolResultError("item_type must be either 'issue' or 'pull_request'"), nil, nil
}
// Resolve the item number to a node ID
var nodeID githubv4.ID
var err error
if itemType == "issue" {
nodeID, err = resolveIssueNodeID(ctx, gqlClient, itemOwner, itemRepo, itemNumber)
} else {
nodeID, err = resolvePullRequestNodeID(ctx, gqlClient, itemOwner, itemRepo, itemNumber)
}
if err != nil {
return utils.NewToolResultError(fmt.Sprintf("failed to resolve %s: %v", itemType, err)), nil, nil
}
// Use GraphQL to add the item to the project
var mutation struct {
AddProjectV2ItemByID struct {
Item struct {
ID githubv4.ID
FullDatabaseID string `graphql:"fullDatabaseId"`
}
} `graphql:"addProjectV2ItemById(input: $input)"`
}
// Resolve the project number to a node ID
projectID, err := resolveProjectNodeID(ctx, gqlClient, owner, ownerType, projectNumber)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
// Add the item to the project
input := githubv4.AddProjectV2ItemByIdInput{
ProjectID: projectID,
ContentID: nodeID,
}
err = gqlClient.Mutate(ctx, &mutation, input, nil)
if err != nil {
return utils.NewToolResultError(fmt.Sprintf(ProjectAddFailedError+": %v", err)), nil, nil
}
result := map[string]any{
"id": mutation.AddProjectV2ItemByID.Item.ID,
"message": fmt.Sprintf("Successfully added %s %s/%s#%d to project %s/%d", itemType, itemOwner, itemRepo, itemNumber, owner, projectNumber),
}
if fullDatabaseID := mutation.AddProjectV2ItemByID.Item.FullDatabaseID; fullDatabaseID != "" {
result["full_database_id"] = fullDatabaseID
if itemID, err := strconv.ParseInt(fullDatabaseID, 10, 64); err == nil {
result["item_id"] = itemID
}
}
r, err := json.Marshal(result)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
}
return utils.NewToolResultText(string(r)), nil, nil
}
// validateDateFormat checks that a date string is in YYYY-MM-DD format.
func validateDateFormat(value, fieldName string) error {
if _, err := time.Parse("2006-01-02", value); err != nil {
return fmt.Errorf("invalid %s %q: must be YYYY-MM-DD format", fieldName, value)
}
return nil
}
// createProjectStatusUpdate creates a new status update for a project via GraphQL.
func createProjectStatusUpdate(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, body, status, startDate, targetDate string) (*mcp.CallToolResult, any, error) {
// Validate inputs
if ownerType != "user" && ownerType != "org" {
return utils.NewToolResultError(fmt.Sprintf("invalid owner_type %q: must be \"user\" or \"org\"", ownerType)), nil, nil
}
if status != "" && !validProjectV2StatusUpdateStatuses[status] {
return utils.NewToolResultError(fmt.Sprintf("invalid status %q: must be one of INACTIVE, ON_TRACK, AT_RISK, OFF_TRACK, COMPLETE", status)), nil, nil
}
if startDate != "" {
if err := validateDateFormat(startDate, "start_date"); err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
}
if targetDate != "" {
if err := validateDateFormat(targetDate, "target_date"); err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
}
// Resolve project number to project node ID
projectID, err := resolveProjectNodeID(ctx, gqlClient, owner, ownerType, projectNumber)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
// Build mutation input
input := CreateProjectV2StatusUpdateInput{
ProjectID: projectID,
}
if body != "" {
s := githubv4.String(body)
input.Body = &s
}
if status != "" {
s := githubv4.String(status)
input.Status = &s
}
if startDate != "" {
s := githubv4.String(startDate)
input.StartDate = &s
}
if targetDate != "" {
s := githubv4.String(targetDate)
input.TargetDate = &s
}
// Execute mutation
var mutation struct {
CreateProjectV2StatusUpdate struct {
StatusUpdate statusUpdateNode
} `graphql:"createProjectV2StatusUpdate(input: $input)"`
}
err = gqlClient.Mutate(ctx, &mutation, input, nil)
if err != nil {
return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateCreateFailedError, err)), nil, nil
}
// Convert and return
result := convertToMinimalStatusUpdate(mutation.CreateProjectV2StatusUpdate.StatusUpdate)
r, err := json.Marshal(result)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
}
return utils.NewToolResultText(string(r)), nil, nil
}
// listProjectStatusUpdates lists status updates for a project via GraphQL.
func listProjectStatusUpdates(ctx context.Context, gqlClient *githubv4.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, bool, any, error) {
if ownerType != "user" && ownerType != "org" {
return utils.NewToolResultError(fmt.Sprintf("invalid owner_type %q: must be \"user\" or \"org\"", ownerType)), false, nil, nil
}
projectNumber, err := RequiredInt(args, "project_number")
if err != nil {
return utils.NewToolResultError(err.Error()), false, nil, nil
}
perPage, err := OptionalIntParamWithDefault(args, "per_page", MaxProjectsPerPage)
if err != nil {
return utils.NewToolResultError(err.Error()), false, nil, nil
}
if perPage > MaxProjectsPerPage {
perPage = MaxProjectsPerPage
}
if perPage < 1 {
perPage = MaxProjectsPerPage
}
afterCursor, err := OptionalParam[string](args, "after")
if err != nil {
return utils.NewToolResultError(err.Error()), false, nil, nil
}
vars := map[string]any{
"owner": githubv4.String(owner),
"projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec // Project numbers are small integers
"first": githubv4.Int(int32(perPage)), //nolint:gosec // perPage is bounded by MaxProjectsPerPage
}
if afterCursor != "" {
vars["after"] = githubv4.String(afterCursor)
} else {
vars["after"] = (*githubv4.String)(nil)
}
var nodes []statusUpdateNode
var pi PageInfoFragment
var isPrivate bool
if ownerType == "org" {
var q statusUpdatesOrgQuery
if err := gqlClient.Query(ctx, &q, vars); err != nil {
return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateListFailedError, err)), false, nil, nil
}
project := q.Organization.ProjectV2
nodes = project.StatusUpdates.Nodes
pi = project.StatusUpdates.PageInfo
isPrivate = !bool(project.Public)
} else {
var q statusUpdatesUserQuery
if err := gqlClient.Query(ctx, &q, vars); err != nil {
return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateListFailedError, err)), false, nil, nil
}
project := q.User.ProjectV2
nodes = project.StatusUpdates.Nodes
pi = project.StatusUpdates.PageInfo
isPrivate = !bool(project.Public)
}
updates := make([]MinimalProjectStatusUpdate, 0, len(nodes))
for _, n := range nodes {
updates = append(updates, convertToMinimalStatusUpdate(n))
}
response := map[string]any{
"statusUpdates": updates,
"pageInfo": map[string]any{
"hasNextPage": pi.HasNextPage,
"hasPreviousPage": pi.HasPreviousPage,
"nextCursor": string(pi.EndCursor),
"prevCursor": string(pi.StartCursor),
},
}
r, err := json.Marshal(response)
if err != nil {
return nil, false, nil, fmt.Errorf("failed to marshal response: %w", err)
}
return utils.NewToolResultText(string(r)), isPrivate, nil, nil
}
// getProjectStatusUpdate fetches a single status update by its node ID via GraphQL.
func getProjectStatusUpdate(ctx context.Context, gqlClient *githubv4.Client, statusUpdateID string) (*mcp.CallToolResult, bool, any, error) {
var q statusUpdateNodeQuery
vars := map[string]any{
"id": githubv4.ID(statusUpdateID),
}
if err := gqlClient.Query(ctx, &q, vars); err != nil {
return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateGetFailedError, err)), false, nil, nil
}
if q.Node.StatusUpdate.ID == nil || q.Node.StatusUpdate.ID == "" {
return utils.NewToolResultError(fmt.Sprintf("%s: node is not a ProjectV2StatusUpdate or was not found", ProjectStatusUpdateGetFailedError)), false, nil, nil
}
update := convertToMinimalStatusUpdate(q.Node.StatusUpdate.statusUpdateNode)
isPrivate := !bool(q.Node.StatusUpdate.Project.Public)
r, err := json.Marshal(update)
if err != nil {
return nil, false, nil, fmt.Errorf("failed to marshal response: %w", err)
}
return utils.NewToolResultText(string(r)), isPrivate, nil, nil
}
func convertToMinimalProjectView(node projectViewNode) MinimalProjectView {
visibleFields := make([]int64, 0, len(node.Configuration.VisibleFields.Nodes))
for _, field := range node.Configuration.VisibleFields.Nodes {
switch {
case field.ProjectV2SingleSelectField.DatabaseID != 0:
visibleFields = append(visibleFields, int64(field.ProjectV2SingleSelectField.DatabaseID))
case field.ProjectV2MultiSelectField.DatabaseID != 0:
visibleFields = append(visibleFields, int64(field.ProjectV2MultiSelectField.DatabaseID))
case field.ProjectV2IterationField.DatabaseID != 0:
visibleFields = append(visibleFields, int64(field.ProjectV2IterationField.DatabaseID))
default:
visibleFields = append(visibleFields, int64(field.ProjectV2Field.DatabaseID))
}
}
return MinimalProjectView{
ID: fmt.Sprintf("%v", node.ID),
Number: int(node.Number),
Name: string(node.Name),
Layout: projectViewLayoutName(node.Layout),
Filter: derefString(node.Filter),
VisibleFields: visibleFields,
}
}
func projectViewLayoutName(layout githubv4.ProjectV2ViewLayout) string {
switch layout {
case githubv4.ProjectV2ViewLayoutTableLayout:
return "table"
case githubv4.ProjectV2ViewLayoutBoardLayout:
return "board"
case githubv4.ProjectV2ViewLayoutRoadmapLayout:
return "roadmap"
default:
return strings.ToLower(strings.TrimSuffix(string(layout), "_LAYOUT"))
}
}
func parseProjectViewLayout(layout string) (githubv4.ProjectV2ViewLayout, error) {
switch strings.ToLower(strings.TrimSpace(layout)) {
case "table":
return githubv4.ProjectV2ViewLayoutTableLayout, nil
case "board":
return githubv4.ProjectV2ViewLayoutBoardLayout, nil
case "roadmap":
return githubv4.ProjectV2ViewLayoutRoadmapLayout, nil
default:
return "", fmt.Errorf("invalid layout %q: must be \"table\", \"board\", or \"roadmap\"", layout)
}
}
func listProjectViews(ctx context.Context, gqlClient *githubv4.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, bool, any, error) {
if ownerType != "user" && ownerType != "org" {
return utils.NewToolResultError(fmt.Sprintf("invalid owner_type %q: must be \"user\" or \"org\"", ownerType)), false, nil, nil
}
projectNumber, err := RequiredInt(args, "project_number")
if err != nil {
return utils.NewToolResultError(err.Error()), false, nil, nil
}
perPage, err := OptionalIntParamWithDefault(args, "per_page", MaxProjectsPerPage)
if err != nil {
return utils.NewToolResultError(err.Error()), false, nil, nil
}
if perPage < 1 || perPage > MaxProjectsPerPage {
perPage = MaxProjectsPerPage
}
after, err := OptionalParam[string](args, "after")
if err != nil {
return utils.NewToolResultError(err.Error()), false, nil, nil
}
before, err := OptionalParam[string](args, "before")
if err != nil {
return utils.NewToolResultError(err.Error()), false, nil, nil
}
if after != "" && before != "" {
return utils.NewToolResultError("provide either 'after' or 'before', not both"), false, nil, nil
}
vars := map[string]any{
"owner": githubv4.String(owner),
"projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec // Project numbers are small integers
"first": (*githubv4.Int)(nil),
"after": (*githubv4.String)(nil),
"last": (*githubv4.Int)(nil),
"before": (*githubv4.String)(nil),
}
if before != "" {
last := githubv4.Int(int32(perPage)) //nolint:gosec // perPage is bounded by MaxProjectsPerPage
cursor := githubv4.String(before)
vars["last"] = &last
vars["before"] = &cursor
} else {
first := githubv4.Int(int32(perPage)) //nolint:gosec // perPage is bounded by MaxProjectsPerPage
vars["first"] = &first
if after != "" {
cursor := githubv4.String(after)
vars["after"] = &cursor
}
}
var project projectViewsProject
if ownerType == "org" {
var query projectViewsOrgQuery
if err := gqlClient.Query(ctx, &query, vars); err != nil {
return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectViewListFailedError, err)), false, nil, nil
}
project = query.Organization.ProjectV2
} else {
var query projectViewsUserQuery
if err := gqlClient.Query(ctx, &query, vars); err != nil {
return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectViewListFailedError, err)), false, nil, nil
}
project = query.User.ProjectV2
}
if project.ID == nil || project.ID == "" {
return utils.NewToolResultError(fmt.Sprintf("%s: project was not found", ProjectViewListFailedError)), false, nil, nil
}
views := make([]MinimalProjectView, 0, len(project.Views.Nodes))
for _, node := range project.Views.Nodes {
views = append(views, convertToMinimalProjectView(node))
}
response := map[string]any{
"views": views,
"pageInfo": map[string]any{
"hasNextPage": project.Views.PageInfo.HasNextPage,
"hasPreviousPage": project.Views.PageInfo.HasPreviousPage,
"nextCursor": string(project.Views.PageInfo.EndCursor),
"prevCursor": string(project.Views.PageInfo.StartCursor),
},
}
result, err := json.Marshal(response)
if err != nil {
return nil, false, nil, fmt.Errorf("failed to marshal response: %w", err)
}
return utils.NewToolResultText(string(result)), !bool(project.Public), nil, nil
}
func getProjectView(ctx context.Context, gqlClient *githubv4.Client, viewID string) (*mcp.CallToolResult, bool, any, error) {
var query projectViewNodeQuery
vars := map[string]any{"id": githubv4.ID(viewID)}
if err := gqlClient.Query(ctx, &query, vars); err != nil {
return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectViewGetFailedError, err)), false, nil, nil
}
if query.Node.ProjectView.ID == nil || query.Node.ProjectView.ID == "" {
return utils.NewToolResultError(fmt.Sprintf("%s: node is not a ProjectV2View or was not found", ProjectViewGetFailedError)), false, nil, nil
}
view := convertToMinimalProjectView(query.Node.ProjectView.projectViewNode)
result, err := json.Marshal(view)
if err != nil {
return nil, false, nil, fmt.Errorf("failed to marshal response: %w", err)
}
return utils.NewToolResultText(string(result)), !bool(query.Node.ProjectView.Project.Public), nil, nil
}
func projectViewVisibleFieldsInput(ctx context.Context, gqlClient *githubv4.Client, args map[string]any, owner, ownerType string, projectNumber int) (*ProjectV2ViewConfigurationInput, error) {
_, hasVisibleFields := args["visible_fields"]
_, hasVisibleFieldNames := args["visible_field_names"]
if !hasVisibleFields && !hasVisibleFieldNames {
return nil, nil
}
databaseIDs, err := OptionalBigIntArrayParam(args, "visible_fields")
if err != nil {
return nil, err
}
names, err := OptionalStringArrayParam(args, "visible_field_names")
if err != nil {
return nil, err
}
if len(databaseIDs) > 0 && len(names) > 0 {
return nil, errors.New("provide either 'visible_fields' or 'visible_field_names', not both")
}
if len(databaseIDs) == 0 && len(names) == 0 {
return &ProjectV2ViewConfigurationInput{VisibleFieldIDs: []githubv4.ID{}}, nil
}
all, err := listAllProjectFields(ctx, gqlClient, owner, ownerType, projectNumber)
if err != nil {
return nil, err
}
var resolved []ResolvedField
if len(names) > 0 {
resolved, err = resolveFieldsByName(all, owner, projectNumber, names, "visible_fields")
if err != nil {
return nil, err
}
} else {
byDatabaseID := make(map[int64]ResolvedField, len(all))
for _, field := range all {
id, parseErr := parseInt64(field.ID)
if parseErr != nil {
continue
}
byDatabaseID[id] = field
}
resolved = make([]ResolvedField, 0, len(databaseIDs))
for _, id := range databaseIDs {
field, ok := byDatabaseID[id]
if !ok {
return nil, fmt.Errorf("project field database ID %d was not found on project %s#%d", id, owner, projectNumber)
}
resolved = append(resolved, field)
}
}
nodeIDs := make([]githubv4.ID, 0, len(resolved))
seen := make(map[string]struct{}, len(resolved))
for _, field := range resolved {
if _, ok := seen[field.NodeID]; ok {
return nil, fmt.Errorf("project field %q is included more than once", field.Name)
}
seen[field.NodeID] = struct{}{}
nodeIDs = append(nodeIDs, githubv4.ID(field.NodeID))
}
return &ProjectV2ViewConfigurationInput{VisibleFieldIDs: nodeIDs}, nil
}
// projectViewRequestsVisibleFields reports whether the caller asked for a non-empty
// set of visible fields, without resolving them against the project.
func projectViewRequestsVisibleFields(args map[string]any) bool {
if databaseIDs, err := OptionalBigIntArrayParam(args, "visible_fields"); err == nil && len(databaseIDs) > 0 {
return true
}
names, err := OptionalStringArrayParam(args, "visible_field_names")
return err == nil && len(names) > 0
}
func createProjectView(ctx context.Context, gqlClient *githubv4.Client, args map[string]any, owner, ownerType string, projectNumber int) (*mcp.CallToolResult, any, error) {
name, err := RequiredParam[string](args, "name")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
if strings.TrimSpace(name) == "" {
return utils.NewToolResultError("name must not be empty"), nil, nil
}
layoutName, err := RequiredParam[string](args, "layout")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
layout, err := parseProjectViewLayout(layoutName)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
filter, hasFilter, err := OptionalNullableStringParam(args, "filter")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
if layout == githubv4.ProjectV2ViewLayoutRoadmapLayout && projectViewRequestsVisibleFields(args) {
return utils.NewToolResultError("visible fields are not supported for roadmap views"), nil, nil
}
configuration, err := projectViewVisibleFieldsInput(ctx, gqlClient, args, owner, ownerType, projectNumber)
if err != nil {
var structured *ghErrors.StructuredResolutionError
if errors.As(err, &structured) {
return ghErrors.NewStructuredResolutionErrorResponse(structured), nil, nil
}
return utils.NewToolResultError(err.Error()), nil, nil
}
projectID, err := resolveProjectNodeID(ctx, gqlClient, owner, ownerType, projectNumber)
if err != nil {
return utils.NewToolResultError(fmt.Sprintf("%s: failed to resolve project: %v", ProjectViewCreateFailedError, err)), nil, nil
}
if projectID == nil || projectID == "" {
return utils.NewToolResultError(fmt.Sprintf("%s: project was not found", ProjectViewCreateFailedError)), nil, nil
}
input := CreateProjectV2ViewInput{
ProjectID: projectID,
Name: githubv4.String(name),
Layout: layout,
Configuration: configuration,
}
var mutation createProjectV2ViewMutation
if err := gqlClient.Mutate(ctx, &mutation, input, nil); err != nil {
return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectViewCreateFailedError, err)), nil, nil
}
view := mutation.CreateProjectV2View.ProjectV2View
if view.ID == nil || view.ID == "" {
return utils.NewToolResultError(fmt.Sprintf("%s: response did not include a project view", ProjectViewCreateFailedError)), nil, nil
}
if hasFilter && filter != nil {
filterValue := githubv4.String(*filter)
updateInput := UpdateProjectV2ViewInput{
ViewID: githubv4.ID(fmt.Sprintf("%v", view.ID)),
Filter: &filterValue,
}
var updateMutation updateProjectV2ViewMutation
if err := gqlClient.Mutate(ctx, &updateMutation, updateInput, nil); err != nil {
cleanupErr := deleteProjectViewByID(ctx, gqlClient, updateInput.ViewID)
if cleanupErr != nil {
return utils.NewToolResultError(fmt.Sprintf("%s: failed to set filter: %v; failed to clean up created view %v: %v", ProjectViewCreateFailedError, err, updateInput.ViewID, cleanupErr)), nil, nil
}
return utils.NewToolResultError(fmt.Sprintf("%s: failed to set filter: %v; created view was cleaned up", ProjectViewCreateFailedError, err)), nil, nil
}
view = updateMutation.UpdateProjectV2View.ProjectV2View
}
return MarshalledTextResult(convertToMinimalProjectView(view)), nil, nil
}
func verifyProjectViewParent(ctx context.Context, gqlClient *githubv4.Client, viewID, owner, ownerType string, projectNumber int) (githubv4.ProjectV2ViewLayout, error) {
expectedProjectID, err := resolveProjectNodeID(ctx, gqlClient, owner, ownerType, projectNumber)
if err != nil {
return "", fmt.Errorf("failed to resolve requested project: %w", err)
}
if expectedProjectID == nil || expectedProjectID == "" {
return "", fmt.Errorf("requested project was not found")
}
var query projectViewParentQuery
if err := gqlClient.Query(ctx, &query, map[string]any{"id": githubv4.ID(viewID)}); err != nil {
return "", fmt.Errorf("failed to resolve project view: %w", err)
}
if query.Node.ProjectView.ID == nil || query.Node.ProjectView.ID == "" {
return "", fmt.Errorf("node is not a ProjectV2View or was not found")
}
if query.Node.ProjectView.Project.ID != expectedProjectID {
return "", fmt.Errorf("project view does not belong to the requested project")
}
return query.Node.ProjectView.Layout, nil
}
func updateProjectView(ctx context.Context, gqlClient *githubv4.Client, args map[string]any, owner, ownerType string, projectNumber int) (*mcp.CallToolResult, any, error) {
viewID, err := RequiredParam[string](args, "view_id")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
name, hasName, err := OptionalParamOK[string](args, "name")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
layoutName, hasLayout, err := OptionalParamOK[string](args, "layout")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
filter, hasFilter, err := OptionalNullableStringParam(args, "filter")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
_, hasVisibleFields := args["visible_fields"]
_, hasVisibleFieldNames := args["visible_field_names"]
if !hasName && !hasLayout && !hasFilter && !hasVisibleFields && !hasVisibleFieldNames {
return utils.NewToolResultError("update_project_view requires at least one of name, layout, filter, visible_fields, or visible_field_names"), nil, nil
}
if hasName && strings.TrimSpace(name) == "" {
return utils.NewToolResultError("name must not be empty"), nil, nil
}
input := UpdateProjectV2ViewInput{ViewID: githubv4.ID(viewID)}
if hasName {
value := githubv4.String(name)
input.Name = &value
}
if hasLayout {
layout, err := parseProjectViewLayout(layoutName)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
input.Layout = &layout
}
if hasFilter {
// The API clears a filter with an empty string, so a null filter is sent as "".
value := githubv4.String("")
if filter != nil {
value = githubv4.String(*filter)
}
input.Filter = &value
}
currentLayout, err := verifyProjectViewParent(ctx, gqlClient, viewID, owner, ownerType, projectNumber)
if err != nil {
return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectViewUpdateFailedError, err)), nil, nil
}
effectiveLayout := currentLayout
if input.Layout != nil {
effectiveLayout = *input.Layout
}
if effectiveLayout == githubv4.ProjectV2ViewLayoutRoadmapLayout && projectViewRequestsVisibleFields(args) {
return utils.NewToolResultError("visible fields are not supported for roadmap views"), nil, nil
}
configuration, err := projectViewVisibleFieldsInput(ctx, gqlClient, args, owner, ownerType, projectNumber)
if err != nil {
var structured *ghErrors.StructuredResolutionError
if errors.As(err, &structured) {
return ghErrors.NewStructuredResolutionErrorResponse(structured), nil, nil
}
return utils.NewToolResultError(err.Error()), nil, nil
}
input.Configuration = configuration
var mutation updateProjectV2ViewMutation
if err := gqlClient.Mutate(ctx, &mutation, input, nil); err != nil {
return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectViewUpdateFailedError, err)), nil, nil
}
if mutation.UpdateProjectV2View.ProjectV2View.ID == nil || mutation.UpdateProjectV2View.ProjectV2View.ID == "" {
return utils.NewToolResultError(fmt.Sprintf("%s: response did not include a project view", ProjectViewUpdateFailedError)), nil, nil
}
return MarshalledTextResult(convertToMinimalProjectView(mutation.UpdateProjectV2View.ProjectV2View)), nil, nil
}
func deleteProjectViewByID(ctx context.Context, gqlClient *githubv4.Client, viewID githubv4.ID) error {
input := DeleteProjectV2ViewInput{ViewID: viewID}
var mutation struct {
DeleteProjectV2View struct {
ProjectV2View struct {
ID githubv4.ID
} `graphql:"projectV2View"`
} `graphql:"deleteProjectV2View(input: $input)"`
}
if err := gqlClient.Mutate(ctx, &mutation, input, nil); err != nil {
return err
}
if id := mutation.DeleteProjectV2View.ProjectV2View.ID; id == nil || id == "" {
return errors.New("response did not include the deleted project view")
}
return nil
}
func deleteProjectView(ctx context.Context, gqlClient *githubv4.Client, args map[string]any, owner, ownerType string, projectNumber int) (*mcp.CallToolResult, any, error) {
viewID, err := RequiredParam[string](args, "view_id")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
if _, err := verifyProjectViewParent(ctx, gqlClient, viewID, owner, ownerType, projectNumber); err != nil {
return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectViewDeleteFailedError, err)), nil, nil
}
if err := deleteProjectViewByID(ctx, gqlClient, githubv4.ID(viewID)); err != nil {
return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectViewDeleteFailedError, err)), nil, nil
}
return MarshalledTextResult(map[string]string{"deleted_view_id": viewID}), nil, nil
}
// validateAndConvertToInt64 ensures the value is a number and converts it to int64.
func validateAndConvertToInt64(value any) (int64, error) {
switch v := value.(type) {
case float64:
// Validate that the float64 can be safely converted to int64
intVal := int64(v)
if float64(intVal) != v {
return 0, fmt.Errorf("value must be a valid integer (got %v)", v)
}
return intVal, nil
case int64:
return v, nil
case int:
return int64(v), nil
default:
return 0, fmt.Errorf("value must be a number (got %T)", v)
}
}
// buildUpdateProjectItem builds either a standard Project update or an attached Issue Field update.
func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, input map[string]any) (*github.UpdateProjectItemOptions, *IssueFieldCreateOrUpdateInput, error) {
if input == nil {
return nil, nil, fmt.Errorf("updated_field must be an object")
}
valueField, hasValue := input["value"]
if !hasValue {
return nil, nil, fmt.Errorf("updated_field.value is required")
}
idField, hasID := input["id"]
nameField, hasName := input["name"]
switch {
case hasID && hasName:
return nil, nil, fmt.Errorf("updated_field must set either id or name, not both")
case !hasID && !hasName:
return nil, nil, fmt.Errorf("updated_field requires either id or name")
}
var (
fieldID int64
resolved *ResolvedField
)
if hasID {
var err error
fieldID, err = validateAndConvertToInt64(idField)
if err != nil {
return nil, nil, fmt.Errorf("updated_field.id: %w", err)
}
} else {
fieldName, ok := nameField.(string)
if !ok || fieldName == "" {
return nil, nil, fmt.Errorf("updated_field.name must be a non-empty string")
}
if gqlClient == nil {
return nil, nil, fmt.Errorf("internal error: gqlClient is required to resolve updated_field.name")
}
var err error
resolved, err = resolveProjectFieldByName(ctx, gqlClient, owner, ownerType, projectNumber, fieldName, "")
if err != nil {
return nil, nil, err
}
if supportsIssueFieldUpdate(resolved.DataType) {
resolved, err = resolveIssueFieldForUpdate(ctx, gqlClient, owner, ownerType, projectNumber, resolved)
if err != nil {
return nil, nil, err
}
if resolved.IsIssueField {
issueField, buildErr := buildIssueFieldUpdate(resolved, valueField)
if buildErr != nil {
return nil, nil, buildErr
}
return nil, issueField, nil
}
}
parsedID, parseErr := parseInt64(resolved.ID)
if parseErr != nil {
return nil, nil, fmt.Errorf("resolved field %q has non-numeric ID %q; pass updated_field.id directly", resolved.Name, resolved.ID)
}
fieldID = parsedID
}
// SINGLE_SELECT: resolve option name to ID; pass through if it's already a known option ID.
if resolved != nil && resolved.DataType == "SINGLE_SELECT" {
if str, ok := valueField.(string); ok && str != "" {
if optID, optErr := resolveSingleSelectOptionByName(resolved, str); optErr == nil {
valueField = optID
} else {
// Fall back: if the string is already a known option ID, accept it.
known := false
for _, opt := range resolved.Options {
if opt.ID == str {
known = true
break
}
}
if !known {
return nil, nil, optErr
}
}
}
}
payload := &github.UpdateProjectItemOptions{
Fields: []*github.UpdateProjectV2Field{{
ID: fieldID,
Value: valueField,
}},
}
return payload, nil, nil
}
func supportsIssueFieldUpdate(dataType string) bool {
switch dataType {
case "TEXT", "NUMBER", "DATE", "SINGLE_SELECT":
return true
default:
return false
}
}
func buildIssueFieldUpdate(field *ResolvedField, value any) (*IssueFieldCreateOrUpdateInput, error) {
if !supportsIssueFieldUpdate(field.DataType) {
return nil, ghErrors.NewStructuredResolutionError(
"unsupported_field_type",
field.Name,
fmt.Sprintf("attached Issue Field %q has unsupported data type %q", field.Name, field.DataType),
nil,
)
}
if field.IssueFieldID == "" {
return nil, ghErrors.NewStructuredResolutionError(
"missing_field_metadata",
field.Name,
fmt.Sprintf("attached Issue Field %q is missing its Issue Field node ID", field.Name),
nil,
)
}
input := &IssueFieldCreateOrUpdateInput{FieldID: githubv4.ID(field.IssueFieldID)}
if value == nil {
input.Delete = githubv4.NewBoolean(githubv4.Boolean(true))
return input, nil
}
switch field.DataType {
case "TEXT":
text, ok := value.(string)
if !ok {
return nil, invalidIssueFieldValue(field, "value must be a string")
}
input.TextValue = githubv4.NewString(githubv4.String(text))
case "NUMBER":
number, ok := toFloat64(value)
if !ok {
return nil, invalidIssueFieldValue(field, "value must be a number")
}
input.NumberValue = githubv4.NewFloat(githubv4.Float(number))
case "DATE":
date, ok := value.(string)
if !ok {
return nil, invalidIssueFieldValue(field, "value must be a date string in YYYY-MM-DD format")
}
if _, err := time.Parse(time.DateOnly, date); err != nil {
return nil, invalidIssueFieldValue(field, "value must be a valid date in YYYY-MM-DD format")
}
input.DateValue = githubv4.NewString(githubv4.String(date))
case "SINGLE_SELECT":
optionName, ok := value.(string)
if !ok || optionName == "" {
return nil, invalidIssueFieldValue(field, "value must be a non-empty option name")
}
optionID, err := resolveSingleSelectOptionByName(field, optionName)
if err != nil {
return nil, err
}
input.SingleSelectOptionID = githubv4.NewID(githubv4.ID(optionID))
}
return input, nil
}
func invalidIssueFieldValue(field *ResolvedField, hint string) error {
return ghErrors.NewStructuredResolutionError(
"invalid_field_value",
field.Name,
fmt.Sprintf("invalid value for attached Issue Field %q: %s", field.Name, hint),
nil,
)
}
func extractPaginationOptionsFromArgs(args map[string]any) (github.ListProjectsPaginationOptions, error) {
perPage, err := OptionalIntParamWithDefault(args, "per_page", MaxProjectsPerPage)
if err != nil {
return github.ListProjectsPaginationOptions{}, err
}
if perPage > MaxProjectsPerPage {
perPage = MaxProjectsPerPage
}
after, err := OptionalParam[string](args, "after")
if err != nil {
return github.ListProjectsPaginationOptions{}, err
}
before, err := OptionalParam[string](args, "before")
if err != nil {
return github.ListProjectsPaginationOptions{}, err
}
opts := github.ListProjectsPaginationOptions{
PerPage: perPage,
After: after,
Before: before,
}
return opts, nil
}
// resolveIssueNodeID resolves an issue number to its GraphQL node ID
func resolveIssueNodeID(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, issueNumber int) (githubv4.ID, error) {
var query struct {
Repository struct {
Issue struct {
ID githubv4.ID
} `graphql:"issue(number: $issueNumber)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
variables := map[string]any{
"owner": githubv4.String(owner),
"repo": githubv4.String(repo),
"issueNumber": githubv4.Int(int32(issueNumber)), //nolint:gosec // Issue numbers are small integers
}
err := gqlClient.Query(ctx, &query, variables)
if err != nil {
return "", fmt.Errorf("failed to resolve issue %s/%s#%d: %w", owner, repo, issueNumber, err)
}
return query.Repository.Issue.ID, nil
}
// resolvePullRequestNodeID resolves a pull request number to its GraphQL node ID
func resolvePullRequestNodeID(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, prNumber int) (githubv4.ID, error) {
var query struct {
Repository struct {
PullRequest struct {
ID githubv4.ID
} `graphql:"pullRequest(number: $prNumber)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
variables := map[string]any{
"owner": githubv4.String(owner),
"repo": githubv4.String(repo),
"prNumber": githubv4.Int(int32(prNumber)), //nolint:gosec // PR numbers are small integers
}
err := gqlClient.Query(ctx, &query, variables)
if err != nil {
return "", fmt.Errorf("failed to resolve pull request %s/%s#%d: %w", owner, repo, prNumber, err)
}
return query.Repository.PullRequest.ID, nil
}
// createProject handles the create_project method for ProjectsWrite.
func createProject(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, args map[string]any) (*mcp.CallToolResult, any, error) {
if ownerType == "" {
return utils.NewToolResultError("owner_type is required for create_project"), nil, nil
}
if ownerType != "user" && ownerType != "org" {
return utils.NewToolResultError(fmt.Sprintf("invalid owner_type %q: must be \"user\" or \"org\"", ownerType)), nil, nil
}
title, err := RequiredParam[string](args, "title")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
ownerID, err := getOwnerNodeID(ctx, gqlClient, owner, ownerType)
if err != nil {
return utils.NewToolResultError(fmt.Sprintf("failed to get owner ID: %v", err)), nil, nil
}
var mutation struct {
CreateProjectV2 struct {
ProjectV2 struct {
ID string
Number int
Title string
URL string
}
} `graphql:"createProjectV2(input: $input)"`
}
input := githubv4.CreateProjectV2Input{
OwnerID: githubv4.ID(ownerID),
Title: githubv4.String(title),
}
err = gqlClient.Mutate(ctx, &mutation, input, nil)
if err != nil {
return utils.NewToolResultError(fmt.Sprintf("failed to create project: %v", err)), nil, nil
}
result := struct {
ID string `json:"id"`
Number int `json:"number"`
Title string `json:"title"`
URL string `json:"url"`
}{
ID: mutation.CreateProjectV2.ProjectV2.ID,
Number: mutation.CreateProjectV2.ProjectV2.Number,
Title: mutation.CreateProjectV2.ProjectV2.Title,
URL: mutation.CreateProjectV2.ProjectV2.URL,
}
return MarshalledTextResult(result), nil, nil
}
// createIterationField handles the create_iteration_field method for ProjectsWrite.
//
// GitHub's GraphQL API requires two mutations to fully configure an iteration field:
// 1. createProjectV2Field creates the field with DataType=ITERATION (no schedule yet).
// 2. updateProjectV2Field sets the start date, duration, and optional named iterations.
//
// If step 2 fails, the field already exists with default settings and can be reconfigured
// by calling this method again (the create will fail with a duplicate-name error, which
// surfaces clearly) or by deleting the field via the GitHub UI.
func createIterationField(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, args map[string]any) (*mcp.CallToolResult, any, error) {
fieldName, err := RequiredParam[string](args, "field_name")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
duration, err := RequiredInt(args, "iteration_duration")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
startDateStr, err := RequiredParam[string](args, "start_date")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
projectID, err := resolveProjectNodeID(ctx, gqlClient, owner, ownerType, projectNumber)
if err != nil {
return utils.NewToolResultError(fmt.Sprintf("failed to get project ID: %v", err)), nil, nil
}
// Step 1: Create the iteration field.
var createMutation struct {
CreateProjectV2Field struct {
ProjectV2Field struct {
ProjectV2IterationField struct {
ID string
Name string
} `graphql:"... on ProjectV2IterationField"`
} `graphql:"projectV2Field"`
} `graphql:"createProjectV2Field(input: $input)"`
}
createInput := githubv4.CreateProjectV2FieldInput{
ProjectID: githubv4.ID(projectID),
DataType: githubv4.ProjectV2CustomFieldType("ITERATION"),
Name: githubv4.String(fieldName),
}
err = gqlClient.Mutate(ctx, &createMutation, createInput, nil)
if err != nil {
return utils.NewToolResultError(fmt.Sprintf("failed to create iteration field: %v", err)), nil, nil
}
fieldID := createMutation.CreateProjectV2Field.ProjectV2Field.ProjectV2IterationField.ID
// Step 2: Configure the iteration field with start date and duration.
var updateMutation struct {
UpdateProjectV2Field struct {
ProjectV2Field struct {
ProjectV2IterationField struct {
ID string
Name string
Configuration struct {
Iterations []struct {
ID string
Title string
StartDate string
Duration int
}
}
} `graphql:"... on ProjectV2IterationField"`
} `graphql:"projectV2Field"`
} `graphql:"updateProjectV2Field(input: $input)"`
}
parsedStartDate, err := time.Parse("2006-01-02", startDateStr)
if err != nil {
return utils.NewToolResultError(fmt.Sprintf("failed to parse start_date %s: %v", startDateStr, err)), nil, nil
}
// GitHub's ProjectV2IterationFieldConfigurationInput requires `iterations` as a
// non-null array, so we always send at least an empty slice. When omitted, GitHub
// generates a default set of iterations from start_date and duration.
iterationsInput := []ProjectV2IterationFieldIterationInput{}
if rawIterations, ok := args["iterations"].([]any); ok && len(rawIterations) > 0 {
for i, item := range rawIterations {
iterMap, ok := item.(map[string]any)
if !ok {
return utils.NewToolResultError(fmt.Sprintf("iterations[%d] must be an object", i)), nil, nil
}
iterTitle, ok := iterMap["title"].(string)
if !ok || iterTitle == "" {
return utils.NewToolResultError(fmt.Sprintf("iterations[%d]: title is required and must be a non-empty string", i)), nil, nil
}
iterStartDate, ok := iterMap["start_date"].(string)
if !ok || iterStartDate == "" {
return utils.NewToolResultError(fmt.Sprintf("iterations[%d]: start_date is required and must be a non-empty string", i)), nil, nil
}
iterDuration, ok := iterMap["duration"].(float64)
if !ok || iterDuration <= 0 {
return utils.NewToolResultError(fmt.Sprintf("iterations[%d]: duration is required and must be a positive number", i)), nil, nil
}
parsedIterStartDate, err := time.Parse("2006-01-02", iterStartDate)
if err != nil {
return utils.NewToolResultError(fmt.Sprintf("iterations[%d]: failed to parse start_date %q: %v", i, iterStartDate, err)), nil, nil
}
iterationsInput = append(iterationsInput, ProjectV2IterationFieldIterationInput{
Title: githubv4.String(iterTitle),
StartDate: githubv4.Date{Time: parsedIterStartDate},
Duration: githubv4.Int(int32(iterDuration)), //nolint:gosec // Iteration durations are small day counts
})
}
}
configInput := ProjectV2IterationFieldConfigurationInput{
Duration: githubv4.Int(int32(duration)), //nolint:gosec // Iteration durations are small day counts
StartDate: githubv4.Date{Time: parsedStartDate},
Iterations: iterationsInput,
}
updateInput := UpdateProjectV2FieldInput{
FieldID: githubv4.ID(fieldID),
IterationConfiguration: &configInput,
}
err = gqlClient.Mutate(ctx, &updateMutation, updateInput, nil)
if err != nil {
return utils.NewToolResultError(fmt.Sprintf("failed to update iteration configuration: %v", err)), nil, nil
}
field := updateMutation.UpdateProjectV2Field.ProjectV2Field.ProjectV2IterationField
iterResults := make([]map[string]any, 0, len(field.Configuration.Iterations))
for _, iter := range field.Configuration.Iterations {
iterResults = append(iterResults, map[string]any{
"id": iter.ID,
"title": iter.Title,
"start_date": iter.StartDate,
"duration": iter.Duration,
})
}
result := map[string]any{
"id": field.ID,
"name": field.Name,
"configuration": map[string]any{
"iterations": iterResults,
},
}
return MarshalledTextResult(result), nil, nil
}
// getOwnerNodeID resolves a GitHub user or organization login to its GraphQL node ID.
func getOwnerNodeID(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string) (string, error) {
if ownerType == "org" {
var query struct {
Organization struct {
ID string
} `graphql:"organization(login: $login)"`
}
variables := map[string]any{
"login": githubv4.String(owner),
}
err := gqlClient.Query(ctx, &query, variables)
return query.Organization.ID, err
}
var query struct {
User struct {
ID string
} `graphql:"user(login: $login)"`
}
variables := map[string]any{
"login": githubv4.String(owner),
}
err := gqlClient.Query(ctx, &query, variables)
return query.User.ID, err
}
// UpdateProjectV2FieldInput is the GraphQL input for the updateProjectV2Field mutation.
// These types are defined locally because the pinned shurcooL/githubv4 release
// (v0.0.0-20240727222349) does not yet expose them. Upstream master now generates
// equivalent types, so this block can be removed when the dependency is next bumped.
type UpdateProjectV2FieldInput struct {
FieldID githubv4.ID `json:"fieldId"`
IterationConfiguration *ProjectV2IterationFieldConfigurationInput `json:"iterationConfiguration,omitempty"`
}
// ProjectV2IterationFieldConfigurationInput is the GraphQL input for configuring an iteration field.
// GitHub's schema marks iterations as a required non-null list, so the field is not omitempty.
type ProjectV2IterationFieldConfigurationInput struct {
Duration githubv4.Int `json:"duration"`
StartDate githubv4.Date `json:"startDate"`
Iterations []ProjectV2IterationFieldIterationInput `json:"iterations"`
}
// ProjectV2IterationFieldIterationInput is the GraphQL input for a single iteration definition.
type ProjectV2IterationFieldIterationInput struct {
StartDate githubv4.Date `json:"startDate"`
Duration githubv4.Int `json:"duration"`
Title githubv4.String `json:"title"`
}
// detectOwnerType attempts to detect whether the project owner is a user or org.
// It first asks GitHub for the account type, then falls back to project probes
// for older or mocked clients where the account type is unavailable.
func detectOwnerType(ctx context.Context, client *github.Client, owner string, projectNumber int) (string, error) {
user, resp, err := client.Users.Get(ctx, owner)
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
}
if err == nil && resp != nil && resp.StatusCode == http.StatusOK {
switch user.GetType() {
case "User":
return "user", nil
case "Organization":
return "org", nil
}
}
// Try user first (more common for personal projects)
_, resp, err = client.Projects.GetUserProject(ctx, owner, projectNumber)
if err == nil && resp.StatusCode == http.StatusOK {
_ = resp.Body.Close()
return "user", nil
}
if resp != nil {
_ = resp.Body.Close()
}
// If not found (404) or other error, try org
_, resp, err = client.Projects.GetOrganizationProject(ctx, owner, projectNumber)
if err == nil && resp.StatusCode == http.StatusOK {
_ = resp.Body.Close()
return "org", nil
}
if resp != nil {
_ = resp.Body.Close()
}
return "", fmt.Errorf("could not determine owner type for %s with project %d: owner is neither a user nor an org with this project", owner, projectNumber)
}