Skip to content

Commit fa338aa

Browse files
committed
feat: Add support for GitHub Projects v2 Iteration Fields
- Add 'create_project' tool to create Projects V2 - Add 'create_iteration_field' tool to create and configure iteration fields (sprints) - Add 'getOwnerNodeID' and 'getProjectNodeID' helper functions
1 parent 238b143 commit fa338aa

File tree

2 files changed

+334
-0
lines changed

2 files changed

+334
-0
lines changed

pkg/github/projects.go

Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"io"
88
"net/http"
99
"strings"
10+
"time"
1011

1112
ghErrors "github.com/github/github-mcp-server/pkg/errors"
1213
"github.com/github/github-mcp-server/pkg/inventory"
@@ -16,6 +17,7 @@ import (
1617
"github.com/google/go-github/v79/github"
1718
"github.com/google/jsonschema-go/jsonschema"
1819
"github.com/modelcontextprotocol/go-sdk/mcp"
20+
"github.com/shurcooL/githubv4"
1921
)
2022

2123
const (
@@ -1868,3 +1870,333 @@ func extractPaginationOptionsFromArgs(args map[string]any) (github.ListProjectsP
18681870

18691871
return opts, nil
18701872
}
1873+
1874+
func CreateProject(t translations.TranslationHelperFunc) inventory.ServerTool {
1875+
tool := NewTool(
1876+
ToolsetMetadataProjects,
1877+
mcp.Tool{
1878+
Name: "create_project",
1879+
Description: t("TOOL_CREATE_PROJECT_DESCRIPTION", "Create a new GitHub Project (ProjectsV2). Returns the project ID and number."),
1880+
Annotations: &mcp.ToolAnnotations{
1881+
Title: t("TOOL_CREATE_PROJECT_USER_TITLE", "Create project"),
1882+
ReadOnlyHint: false,
1883+
},
1884+
InputSchema: &jsonschema.Schema{
1885+
Type: "object",
1886+
Properties: map[string]*jsonschema.Schema{
1887+
"owner": {
1888+
Type: "string",
1889+
Description: "GitHub username or organization name",
1890+
},
1891+
"owner_type": {
1892+
Type: "string",
1893+
Description: "Owner type",
1894+
Enum: []any{"user", "org"},
1895+
},
1896+
"title": {
1897+
Type: "string",
1898+
Description: "Project title",
1899+
},
1900+
"description": {
1901+
Type: "string",
1902+
Description: "Project description (optional)",
1903+
},
1904+
},
1905+
Required: []string{"owner", "owner_type", "title"},
1906+
},
1907+
},
1908+
[]scopes.Scope{scopes.Project},
1909+
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
1910+
owner, err := RequiredParam[string](args, "owner")
1911+
if err != nil {
1912+
return utils.NewToolResultError(err.Error()), nil, nil
1913+
}
1914+
ownerType, err := RequiredParam[string](args, "owner_type")
1915+
if err != nil {
1916+
return utils.NewToolResultError(err.Error()), nil, nil
1917+
}
1918+
title, err := RequiredParam[string](args, "title")
1919+
if err != nil {
1920+
return utils.NewToolResultError(err.Error()), nil, nil
1921+
}
1922+
1923+
gqlClient, err := deps.GetGQLClient(ctx)
1924+
if err != nil {
1925+
return utils.NewToolResultError(err.Error()), nil, nil
1926+
}
1927+
1928+
ownerId, err := getOwnerNodeID(ctx, gqlClient, owner, ownerType)
1929+
if err != nil {
1930+
return utils.NewToolResultError(fmt.Sprintf("failed to get owner ID: %v", err)), nil, nil
1931+
}
1932+
1933+
var mutation struct {
1934+
CreateProjectV2 struct {
1935+
ProjectV2 struct {
1936+
ID string
1937+
Number int
1938+
Title string
1939+
URL string
1940+
}
1941+
} `graphql:"createProjectV2(input: $input)"`
1942+
}
1943+
1944+
input := githubv4.CreateProjectV2Input{
1945+
OwnerID: githubv4.ID(ownerId),
1946+
Title: githubv4.String(title),
1947+
}
1948+
1949+
err = gqlClient.Mutate(ctx, &mutation, input, nil)
1950+
if err != nil {
1951+
return utils.NewToolResultError(fmt.Sprintf("failed to create project: %v", err)), nil, nil
1952+
}
1953+
1954+
return MarshalledTextResult(mutation.CreateProjectV2.ProjectV2), nil, nil
1955+
},
1956+
)
1957+
tool.FeatureFlagEnable = FeatureFlagConsolidatedProjects
1958+
return tool
1959+
}
1960+
1961+
func CreateIterationField(t translations.TranslationHelperFunc) inventory.ServerTool {
1962+
tool := NewTool(
1963+
ToolsetMetadataProjects,
1964+
mcp.Tool{
1965+
Name: "create_iteration_field",
1966+
Description: t("TOOL_CREATE_ITERATION_FIELD_DESCRIPTION", "Create an iteration field on a ProjectsV2 with weekly sprints. Returns field ID and iteration IDs."),
1967+
Annotations: &mcp.ToolAnnotations{
1968+
Title: t("TOOL_CREATE_ITERATION_FIELD_USER_TITLE", "Create iteration field"),
1969+
ReadOnlyHint: false,
1970+
},
1971+
InputSchema: &jsonschema.Schema{
1972+
Type: "object",
1973+
Properties: map[string]*jsonschema.Schema{
1974+
"owner": {
1975+
Type: "string",
1976+
Description: "GitHub username or organization name",
1977+
},
1978+
"owner_type": {
1979+
Type: "string",
1980+
Description: "Owner type",
1981+
Enum: []any{"user", "org"},
1982+
},
1983+
"project_number": {
1984+
Type: "number",
1985+
Description: "The project's number",
1986+
},
1987+
"field_name": {
1988+
Type: "string",
1989+
Description: "Field name (e.g., 'Sprint', 'Iteration')",
1990+
},
1991+
"duration": {
1992+
Type: "number",
1993+
Description: "Duration in days for each iteration (typically 7 for weekly)",
1994+
},
1995+
"start_date": {
1996+
Type: "string",
1997+
Description: "Start date in YYYY-MM-DD format",
1998+
},
1999+
"iterations": {
2000+
Type: "array",
2001+
Description: "Array of iteration definitions",
2002+
Items: &jsonschema.Schema{
2003+
Type: "object",
2004+
Properties: map[string]*jsonschema.Schema{
2005+
"title": {
2006+
Type: "string",
2007+
},
2008+
"startDate": {
2009+
Type: "string",
2010+
},
2011+
"duration": {
2012+
Type: "number",
2013+
},
2014+
},
2015+
Required: []string{"title", "startDate", "duration"},
2016+
},
2017+
},
2018+
},
2019+
Required: []string{"owner", "owner_type", "project_number", "field_name", "duration", "start_date", "iterations"},
2020+
},
2021+
},
2022+
[]scopes.Scope{scopes.Project},
2023+
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
2024+
owner, err := RequiredParam[string](args, "owner")
2025+
if err != nil {
2026+
return utils.NewToolResultError(err.Error()), nil, nil
2027+
}
2028+
ownerType, err := RequiredParam[string](args, "owner_type")
2029+
if err != nil {
2030+
return utils.NewToolResultError(err.Error()), nil, nil
2031+
}
2032+
projectNumber, err := RequiredInt(args, "project_number")
2033+
if err != nil {
2034+
return utils.NewToolResultError(err.Error()), nil, nil
2035+
}
2036+
fieldName, err := RequiredParam[string](args, "field_name")
2037+
if err != nil {
2038+
return utils.NewToolResultError(err.Error()), nil, nil
2039+
}
2040+
duration, err := RequiredInt(args, "duration")
2041+
if err != nil {
2042+
return utils.NewToolResultError(err.Error()), nil, nil
2043+
}
2044+
startDateStr, err := RequiredParam[string](args, "start_date")
2045+
if err != nil {
2046+
return utils.NewToolResultError(err.Error()), nil, nil
2047+
}
2048+
// Handle iterations array
2049+
rawIterations, ok := args["iterations"].([]any)
2050+
if !ok {
2051+
return utils.NewToolResultError("iterations must be an array"), nil, nil
2052+
}
2053+
2054+
client, err := deps.GetClient(ctx)
2055+
if err != nil {
2056+
return utils.NewToolResultError(err.Error()), nil, nil
2057+
}
2058+
gqlClient, err := deps.GetGQLClient(ctx)
2059+
if err != nil {
2060+
return utils.NewToolResultError(err.Error()), nil, nil
2061+
}
2062+
2063+
projectId, err := getProjectNodeID(ctx, client, owner, ownerType, projectNumber)
2064+
if err != nil {
2065+
return utils.NewToolResultError(fmt.Sprintf("failed to get project ID: %v", err)), nil, nil
2066+
}
2067+
2068+
// Step 1: Create Field
2069+
var createMutation struct {
2070+
CreateProjectV2Field struct {
2071+
ProjectV2Field struct {
2072+
ProjectV2IterationField struct {
2073+
ID string
2074+
Name string
2075+
} `graphql:"... on ProjectV2IterationField"`
2076+
}
2077+
} `graphql:"createProjectV2Field(input: $input)"`
2078+
}
2079+
2080+
createInput := githubv4.CreateProjectV2FieldInput{
2081+
ProjectID: githubv4.ID(projectId),
2082+
DataType: githubv4.ProjectV2CustomFieldTypeIteration,
2083+
Name: githubv4.String(fieldName),
2084+
}
2085+
2086+
err = gqlClient.Mutate(ctx, &createMutation, createInput, nil)
2087+
if err != nil {
2088+
return utils.NewToolResultError(fmt.Sprintf("failed to create iteration field: %v", err)), nil, nil
2089+
}
2090+
2091+
fieldId := createMutation.CreateProjectV2Field.ProjectV2Field.ProjectV2IterationField.ID
2092+
2093+
// Step 2: Update Field Configuration
2094+
var updateMutation struct {
2095+
UpdateProjectV2Field struct {
2096+
ProjectV2Field struct {
2097+
ProjectV2IterationField struct {
2098+
ID string
2099+
Name string
2100+
Configuration struct {
2101+
Iterations []struct {
2102+
ID string
2103+
Title string
2104+
StartDate string
2105+
Duration int
2106+
}
2107+
}
2108+
} `graphql:"... on ProjectV2IterationField"`
2109+
}
2110+
} `graphql:"updateProjectV2Field(input: $input)"`
2111+
}
2112+
2113+
var iterationsInput []githubv4.ProjectV2IterationFieldIterationInput
2114+
for _, item := range rawIterations {
2115+
iterMap, ok := item.(map[string]any)
2116+
if !ok { continue }
2117+
title, _ := iterMap["title"].(string)
2118+
sDate, _ := iterMap["startDate"].(string)
2119+
dur, _ := iterMap["duration"].(float64)
2120+
2121+
parsedSDate, err := time.Parse("2006-01-02", sDate)
2122+
if err != nil {
2123+
return utils.NewToolResultError(fmt.Sprintf("failed to parse iteration startDate %s: %v", sDate, err)), nil, nil
2124+
}
2125+
2126+
iterationsInput = append(iterationsInput, githubv4.ProjectV2IterationFieldIterationInput{
2127+
Title: githubv4.String(title),
2128+
StartDate: githubv4.Date{Time: parsedSDate},
2129+
Duration: githubv4.Int(dur),
2130+
})
2131+
}
2132+
2133+
parsedStartDate, err := time.Parse("2006-01-02", startDateStr)
2134+
if err != nil {
2135+
return utils.NewToolResultError(fmt.Sprintf("failed to parse start_date %s: %v", startDateStr, err)), nil, nil
2136+
}
2137+
2138+
configInput := githubv4.ProjectV2IterationFieldConfigurationInput{
2139+
Duration: githubv4.Int(duration),
2140+
StartDate: githubv4.Date{Time: parsedStartDate},
2141+
Iterations: &iterationsInput,
2142+
}
2143+
2144+
updateInput := githubv4.UpdateProjectV2FieldInput{
2145+
ProjectID: githubv4.ID(projectId),
2146+
FieldID: githubv4.ID(fieldId),
2147+
IterationConfiguration: &configInput,
2148+
}
2149+
2150+
err = gqlClient.Mutate(ctx, &updateMutation, updateInput, nil)
2151+
if err != nil {
2152+
return utils.NewToolResultError(fmt.Sprintf("failed to update iteration configuration: %v", err)), nil, nil
2153+
}
2154+
2155+
return MarshalledTextResult(updateMutation.UpdateProjectV2Field.ProjectV2Field.ProjectV2IterationField), nil, nil
2156+
},
2157+
)
2158+
tool.FeatureFlagEnable = FeatureFlagConsolidatedProjects
2159+
return tool
2160+
}
2161+
2162+
func getOwnerNodeID(ctx context.Context, client *githubv4.Client, owner string, ownerType string) (string, error) {
2163+
if ownerType == "org" {
2164+
var query struct {
2165+
Organization struct {
2166+
ID string
2167+
} `graphql:"organization(login: $login)"`
2168+
}
2169+
variables := map[string]interface{}{
2170+
"login": githubv4.String(owner),
2171+
}
2172+
err := client.Query(ctx, &query, variables)
2173+
return query.Organization.ID, err
2174+
} else {
2175+
var query struct {
2176+
User struct {
2177+
ID string
2178+
} `graphql:"user(login: $login)"`
2179+
}
2180+
variables := map[string]interface{}{
2181+
"login": githubv4.String(owner),
2182+
}
2183+
err := client.Query(ctx, &query, variables)
2184+
return query.User.ID, err
2185+
}
2186+
}
2187+
2188+
func getProjectNodeID(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int) (string, error) {
2189+
if ownerType == "org" {
2190+
project, _, err := client.Projects.GetOrganizationProject(ctx, owner, projectNumber)
2191+
if err != nil {
2192+
return "", err
2193+
}
2194+
return project.GetNodeID(), nil
2195+
} else {
2196+
project, _, err := client.Projects.GetUserProject(ctx, owner, projectNumber)
2197+
if err != nil {
2198+
return "", err
2199+
}
2200+
return project.GetNodeID(), nil
2201+
}
2202+
}

pkg/github/tools.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,8 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool {
283283
ProjectsList(t),
284284
ProjectsGet(t),
285285
ProjectsWrite(t),
286+
CreateProject(t),
287+
CreateIterationField(t),
286288

287289
// Label tools
288290
GetLabel(t),

0 commit comments

Comments
 (0)