Skip to content

Commit 965fd8a

Browse files
committed
Add Project Issue Field updates
Resolve attached Issue Fields through project field names and dispatch singular updates through the Issue Field mutation while keeping batch writes explicit. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1d574277-3735-4356-a753-8354aa6b537c
1 parent a387c39 commit 965fd8a

9 files changed

Lines changed: 786 additions & 84 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1133,7 +1133,7 @@ The following sets of tools are available:
11331133
- `status`: The status of the project. Used for 'create_project_status_update' method. (string, optional)
11341134
- `target_date`: The target date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method. (string, optional)
11351135
- `title`: The project title. Required for 'create_project' method. (string, optional)
1136-
- `updated_field`: 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. (object, optional)
1136+
- `updated_field`: 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', the name form supports attached Issue Fields on Issue items and accepts SINGLE_SELECT option names case-insensitively. Attached Issue Fields are not supported by 'update_project_items'; use singular 'update_project_item'. The ID form addresses standard Project fields and expects an option ID for SINGLE_SELECT. (object, optional)
11371137

11381138
</details>
11391139

pkg/github/__toolsnaps__/projects_write.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@
186186
"type": "string"
187187
},
188188
"updated_field": {
189-
"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.",
189+
"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', the name form supports attached Issue Fields on Issue items and accepts SINGLE_SELECT option names case-insensitively. Attached Issue Fields are not supported by 'update_project_items'; use singular 'update_project_item'. The ID form addresses standard Project fields and expects an option ID for SINGLE_SELECT.",
190190
"oneOf": [
191191
{
192192
"additionalProperties": false,

pkg/github/granular_tools_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2428,7 +2428,7 @@ func TestGranularSetIssueFields(t *testing.T) {
24282428
require.False(t, result.IsError, getTextResult(t, result).Text)
24292429
// The last request captured is the mutation; the preceding issue ID
24302430
// query does not require the feature flag.
2431-
assert.Equal(t, "update_issue_suggestions", spy.captured.Get(headers.GraphQLFeaturesHeader))
2431+
assert.Equal(t, "issue_fields, repo_issue_fields, update_issue_suggestions", spy.captured.Get(headers.GraphQLFeaturesHeader))
24322432
})
24332433
}
24342434

pkg/github/issues_granular.go

Lines changed: 44 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1263,6 +1263,47 @@ type IssueFieldCreateOrUpdateInput struct {
12631263
Suggest *githubv4.Boolean `json:"suggest,omitempty"`
12641264
}
12651265

1266+
type setIssueFieldValueMutation struct {
1267+
SetIssueFieldValue struct {
1268+
Issue struct {
1269+
ID githubv4.ID
1270+
Number githubv4.Int
1271+
URL githubv4.String
1272+
}
1273+
IssueFieldValues []struct {
1274+
TextValue struct {
1275+
Value string
1276+
} `graphql:"... on IssueFieldTextValue"`
1277+
SingleSelectValue struct {
1278+
Name string
1279+
} `graphql:"... on IssueFieldSingleSelectValue"`
1280+
DateValue struct {
1281+
Value string
1282+
} `graphql:"... on IssueFieldDateValue"`
1283+
NumberValue struct {
1284+
Value float64
1285+
} `graphql:"... on IssueFieldNumberValue"`
1286+
}
1287+
} `graphql:"setIssueFieldValue(input: $input)"`
1288+
}
1289+
1290+
// SetIssueFieldValues applies typed Issue Field values to an issue node.
1291+
func SetIssueFieldValues(ctx context.Context, gqlClient *githubv4.Client, issueID githubv4.ID, issueFields []IssueFieldCreateOrUpdateInput) (MinimalResponse, error) {
1292+
var mutation setIssueFieldValueMutation
1293+
input := SetIssueFieldValueInput{
1294+
IssueID: issueID,
1295+
IssueFields: issueFields,
1296+
}
1297+
ctx = ghcontext.WithGraphQLFeatures(ctx, "issue_fields", "repo_issue_fields", "update_issue_suggestions")
1298+
if err := gqlClient.Mutate(ctx, &mutation, input, nil); err != nil {
1299+
return MinimalResponse{}, err
1300+
}
1301+
return MinimalResponse{
1302+
ID: fmt.Sprintf("%v", mutation.SetIssueFieldValue.Issue.ID),
1303+
URL: string(mutation.SetIssueFieldValue.Issue.URL),
1304+
}, nil
1305+
}
1306+
12661307
// GranularSetIssueFields creates a tool to set issue field values on an issue using GraphQL.
12671308
func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool {
12681309
st := NewTool(
@@ -1486,47 +1527,12 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv
14861527
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get issue", err), nil, nil
14871528
}
14881529

1489-
// Execute the setIssueFieldValue mutation
1490-
var mutation struct {
1491-
SetIssueFieldValue struct {
1492-
Issue struct {
1493-
ID githubv4.ID
1494-
Number githubv4.Int
1495-
URL githubv4.String
1496-
}
1497-
IssueFieldValues []struct {
1498-
TextValue struct {
1499-
Value string
1500-
} `graphql:"... on IssueFieldTextValue"`
1501-
SingleSelectValue struct {
1502-
Name string
1503-
} `graphql:"... on IssueFieldSingleSelectValue"`
1504-
DateValue struct {
1505-
Value string
1506-
} `graphql:"... on IssueFieldDateValue"`
1507-
NumberValue struct {
1508-
Value float64
1509-
} `graphql:"... on IssueFieldNumberValue"`
1510-
}
1511-
} `graphql:"setIssueFieldValue(input: $input)"`
1512-
}
1513-
1514-
mutationInput := SetIssueFieldValueInput{
1515-
IssueID: issueID,
1516-
IssueFields: issueFields,
1517-
}
1518-
1519-
// The rationale and suggest input fields on IssueFieldCreateOrUpdateInput
1520-
// are gated behind the update_issue_suggestions GraphQL feature flag.
1521-
ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "update_issue_suggestions")
1522-
if err := gqlClient.Mutate(ctxWithFeatures, &mutation, mutationInput, nil); err != nil {
1530+
response, err := SetIssueFieldValues(ctx, gqlClient, issueID, issueFields)
1531+
if err != nil {
15231532
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to set issue field values", err), nil, nil
15241533
}
15251534

1526-
r, err := json.Marshal(MinimalResponse{
1527-
ID: fmt.Sprintf("%v", mutation.SetIssueFieldValue.Issue.ID),
1528-
URL: string(mutation.SetIssueFieldValue.Issue.URL),
1529-
})
1535+
r, err := json.Marshal(response)
15301536
if err != nil {
15311537
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
15321538
}

pkg/github/projects.go

Lines changed: 165 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -551,7 +551,7 @@ func projectUpdatedFieldSchema() *jsonschema.Schema {
551551

552552
return &jsonschema.Schema{
553553
Type: "object",
554-
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.",
554+
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', the name form supports attached Issue Fields on Issue items and accepts SINGLE_SELECT option names case-insensitively. Attached Issue Fields are not supported by 'update_project_items'; use singular 'update_project_item'. The ID form addresses standard Project fields and expects an option ID for SINGLE_SELECT.",
555555
OneOf: []*jsonschema.Schema{
556556
variant([]string{"id", "value"}, map[string]*jsonschema.Schema{
557557
"id": {
@@ -1191,7 +1191,7 @@ func getProjectField(ctx context.Context, client *github.Client, owner, ownerTyp
11911191
return utils.NewToolResultText(string(r)), nil, nil
11921192
}
11931193

1194-
func getProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64, fields []int64) (*mcp.CallToolResult, any, error) {
1194+
func fetchProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64, fields []int64) (*github.ProjectV2Item, *github.Response, error) {
11951195
var resp *github.Response
11961196
var projectItem *github.ProjectV2Item
11971197
var opts *github.GetProjectItemOptions
@@ -1208,6 +1208,11 @@ func getProjectItem(ctx context.Context, client *github.Client, owner, ownerType
12081208
} else {
12091209
projectItem, resp, err = client.Projects.GetUserProjectItem(ctx, owner, projectNumber, itemID, opts)
12101210
}
1211+
return projectItem, resp, err
1212+
}
1213+
1214+
func getProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64, fields []int64) (*mcp.CallToolResult, any, error) {
1215+
projectItem, resp, err := fetchProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, fields)
12111216

12121217
if err != nil {
12131218
return ghErrors.NewGitHubAPIErrorResponse(ctx,
@@ -1235,7 +1240,7 @@ func getProjectItem(ctx context.Context, client *github.Client, owner, ownerType
12351240
}
12361241

12371242
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) {
1238-
updatePayload, err := buildUpdateProjectItem(ctx, gqlClient, owner, ownerType, projectNumber, fieldValue)
1243+
update, err := buildUpdateProjectItem(ctx, gqlClient, owner, ownerType, projectNumber, fieldValue)
12391244
if err != nil {
12401245
var structured *ghErrors.StructuredResolutionError
12411246
if errors.As(err, &structured) {
@@ -1244,13 +1249,44 @@ func updateProjectItem(ctx context.Context, client *github.Client, gqlClient *gi
12441249
return utils.NewToolResultError(err.Error()), nil, nil
12451250
}
12461251

1252+
if update.IssueField != nil {
1253+
projectItem, resp, fetchErr := fetchProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, nil)
1254+
if fetchErr != nil {
1255+
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to inspect project item content", resp, fetchErr), nil, nil
1256+
}
1257+
if resp != nil && resp.Body != nil {
1258+
defer func() { _ = resp.Body.Close() }()
1259+
}
1260+
if resp == nil || resp.StatusCode != http.StatusOK {
1261+
return utils.NewToolResultError("failed to inspect project item content"), nil, nil
1262+
}
1263+
1264+
issueID, resolveErr := projectItemIssueNodeID(projectItem)
1265+
if resolveErr != nil {
1266+
var structured *ghErrors.StructuredResolutionError
1267+
if errors.As(resolveErr, &structured) {
1268+
return ghErrors.NewStructuredResolutionErrorResponse(structured), nil, nil
1269+
}
1270+
return utils.NewToolResultError(resolveErr.Error()), nil, nil
1271+
}
1272+
response, mutationErr := SetIssueFieldValues(ctx, gqlClient, issueID, []IssueFieldCreateOrUpdateInput{*update.IssueField})
1273+
if mutationErr != nil {
1274+
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to update Issue Field", mutationErr), nil, nil
1275+
}
1276+
r, marshalErr := json.Marshal(response)
1277+
if marshalErr != nil {
1278+
return nil, nil, fmt.Errorf("failed to marshal response: %w", marshalErr)
1279+
}
1280+
return utils.NewToolResultText(string(r)), nil, nil
1281+
}
1282+
12471283
var resp *github.Response
12481284
var updatedItem *github.ProjectV2Item
12491285

12501286
if ownerType == "org" {
1251-
updatedItem, resp, err = client.Projects.UpdateOrganizationProjectItem(ctx, owner, projectNumber, itemID, updatePayload)
1287+
updatedItem, resp, err = client.Projects.UpdateOrganizationProjectItem(ctx, owner, projectNumber, itemID, update.Project)
12521288
} else {
1253-
updatedItem, resp, err = client.Projects.UpdateUserProjectItem(ctx, owner, projectNumber, itemID, updatePayload)
1289+
updatedItem, resp, err = client.Projects.UpdateUserProjectItem(ctx, owner, projectNumber, itemID, update.Project)
12541290
}
12551291

12561292
if err != nil {
@@ -1277,6 +1313,36 @@ func updateProjectItem(ctx context.Context, client *github.Client, gqlClient *gi
12771313
return utils.NewToolResultText(string(r)), nil, nil
12781314
}
12791315

1316+
func projectItemIssueNodeID(item *github.ProjectV2Item) (githubv4.ID, error) {
1317+
if item == nil || item.ContentType == nil {
1318+
return "", ghErrors.NewStructuredResolutionError(
1319+
"issue_field_metadata_unavailable",
1320+
"",
1321+
"the project item response did not identify its content type; Issue Fields can only be updated on Issue items",
1322+
nil,
1323+
)
1324+
}
1325+
1326+
contentType := string(*item.ContentType)
1327+
if contentType != "Issue" {
1328+
return "", ghErrors.NewStructuredResolutionError(
1329+
"unsupported_item_type",
1330+
contentType,
1331+
"Issue Fields can only be updated on Issue project items, not pull requests or draft issues",
1332+
nil,
1333+
)
1334+
}
1335+
if item.Content == nil || item.Content.Issue == nil || item.Content.Issue.GetNodeID() == "" {
1336+
return "", ghErrors.NewStructuredResolutionError(
1337+
"issue_field_metadata_unavailable",
1338+
"Issue",
1339+
"the project item response did not include the underlying Issue node ID needed to update the Issue Field",
1340+
nil,
1341+
)
1342+
}
1343+
return githubv4.ID(item.Content.Issue.GetNodeID()), nil
1344+
}
1345+
12801346
func deleteProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64) (*mcp.CallToolResult, any, error) {
12811347
var resp *github.Response
12821348
var err error
@@ -1614,8 +1680,13 @@ func validateAndConvertToInt64(value any) (int64, error) {
16141680
}
16151681
}
16161682

1617-
// buildUpdateProjectItem builds UpdateProjectItemOptions, resolving field names and SINGLE_SELECT option names server-side.
1618-
func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, input map[string]any) (*github.UpdateProjectItemOptions, error) {
1683+
type resolvedProjectItemUpdate struct {
1684+
Project *github.UpdateProjectItemOptions
1685+
IssueField *IssueFieldCreateOrUpdateInput
1686+
}
1687+
1688+
// buildUpdateProjectItem resolves the target field and builds the matching Project or Issue Field write.
1689+
func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, input map[string]any) (*resolvedProjectItemUpdate, error) {
16191690
if input == nil {
16201691
return nil, fmt.Errorf("updated_field must be an object")
16211692
}
@@ -1659,6 +1730,13 @@ func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, own
16591730
if err != nil {
16601731
return nil, err
16611732
}
1733+
if resolved.IsIssueField {
1734+
issueField, buildErr := buildIssueFieldUpdate(resolved, valueField)
1735+
if buildErr != nil {
1736+
return nil, buildErr
1737+
}
1738+
return &resolvedProjectItemUpdate{IssueField: issueField}, nil
1739+
}
16621740
parsedID, parseErr := parseInt64(resolved.ID)
16631741
if parseErr != nil {
16641742
return nil, fmt.Errorf("resolved field %q has non-numeric ID %q; pass updated_field.id directly", resolved.Name, resolved.ID)
@@ -1694,7 +1772,86 @@ func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, own
16941772
}},
16951773
}
16961774

1697-
return payload, nil
1775+
return &resolvedProjectItemUpdate{Project: payload}, nil
1776+
}
1777+
1778+
func buildIssueFieldUpdate(field *ResolvedField, raw any) (*IssueFieldCreateOrUpdateInput, error) {
1779+
if field == nil || field.IssueFieldNodeID == "" {
1780+
name := ""
1781+
if field != nil {
1782+
name = field.Name
1783+
}
1784+
return nil, ghErrors.NewStructuredResolutionError(
1785+
"issue_field_metadata_unavailable",
1786+
name,
1787+
"the attached Project field did not include the underlying Issue Field node ID; refresh field metadata and retry",
1788+
nil,
1789+
)
1790+
}
1791+
1792+
input := &IssueFieldCreateOrUpdateInput{FieldID: githubv4.ID(field.IssueFieldNodeID)}
1793+
if raw == nil {
1794+
deleteValue := githubv4.Boolean(true)
1795+
input.Delete = &deleteValue
1796+
return input, nil
1797+
}
1798+
1799+
invalidValue := func(hint string) (*IssueFieldCreateOrUpdateInput, error) {
1800+
return nil, ghErrors.NewStructuredResolutionError("invalid_field_value", field.Name, hint, nil)
1801+
}
1802+
1803+
switch field.DataType {
1804+
case "TEXT":
1805+
value, ok := raw.(string)
1806+
if !ok {
1807+
return invalidValue(fmt.Sprintf("Issue Field %q is TEXT; value must be a string or null to clear it", field.Name))
1808+
}
1809+
input.TextValue = githubv4.NewString(githubv4.String(value))
1810+
case "NUMBER":
1811+
value, ok := toFloat64(raw)
1812+
if !ok {
1813+
return invalidValue(fmt.Sprintf("Issue Field %q is NUMBER; value must be a finite number or null to clear it", field.Name))
1814+
}
1815+
number := githubv4.Float(value)
1816+
input.NumberValue = &number
1817+
case "DATE":
1818+
value, ok := raw.(string)
1819+
if !ok {
1820+
return invalidValue(fmt.Sprintf("Issue Field %q is DATE; value must be a YYYY-MM-DD string or null to clear it", field.Name))
1821+
}
1822+
if _, err := time.Parse("2006-01-02", value); err != nil {
1823+
return invalidValue(fmt.Sprintf("Issue Field %q is DATE; value %q must use YYYY-MM-DD format", field.Name, value))
1824+
}
1825+
input.DateValue = githubv4.NewString(githubv4.String(value))
1826+
case "SINGLE_SELECT":
1827+
value, ok := raw.(string)
1828+
if !ok || value == "" {
1829+
return invalidValue(fmt.Sprintf("Issue Field %q is SINGLE_SELECT; value must be a non-empty option name or ID, or null to clear it", field.Name))
1830+
}
1831+
optionID, err := resolveSingleSelectOptionByName(field, value)
1832+
if err != nil {
1833+
for _, option := range field.Options {
1834+
if option.ID == value {
1835+
optionID = value
1836+
err = nil
1837+
break
1838+
}
1839+
}
1840+
if err != nil {
1841+
return nil, err
1842+
}
1843+
}
1844+
id := githubv4.ID(optionID)
1845+
input.SingleSelectOptionID = &id
1846+
default:
1847+
return nil, ghErrors.NewStructuredResolutionError(
1848+
"unsupported_field_type",
1849+
field.Name,
1850+
fmt.Sprintf("Issue Field %q has unsupported data type %q; supported types are TEXT, NUMBER, DATE, and SINGLE_SELECT", field.Name, field.DataType),
1851+
nil,
1852+
)
1853+
}
1854+
return input, nil
16981855
}
16991856

17001857
func extractPaginationOptionsFromArgs(args map[string]any) (github.ListProjectsPaginationOptions, error) {

pkg/github/projects_batch.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,12 @@ func updateProjectItemsBatch(ctx context.Context, client *github.Client, gqlClie
119119
if fieldErr != nil {
120120
return batchTopLevelError(fieldErr), nil, nil
121121
}
122+
if field.IsIssueField {
123+
return utils.NewToolResultError(fmt.Sprintf(
124+
"field %q is an attached Issue Field; update_project_items does not support Issue Fields because they are values on Issue content. Use singular update_project_item for each Issue item",
125+
field.Name,
126+
)), nil, nil
127+
}
122128

123129
kind := batchMutationUpdate
124130
var value githubv4.ProjectV2FieldValue

0 commit comments

Comments
 (0)