Skip to content

Commit 944fdb4

Browse files
committed
Support Issue Field updates by ID
Resolve numeric Project field IDs before singular updates so attached Issue Fields use the same name-or-ID targeting contract as standard fields. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1d574277-3735-4356-a753-8354aa6b537c
1 parent 338f40f commit 944fdb4

6 files changed

Lines changed: 120 additions & 60 deletions

File tree

pkg/github/__toolsnaps__/projects_write.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@
192192
"additionalProperties": false,
193193
"properties": {
194194
"id": {
195-
"description": "The numeric ID of a standard Project field. Attached Issue Fields must be updated by name with singular 'update_project_item'.",
195+
"description": "The numeric Project field ID.",
196196
"type": "integer"
197197
},
198198
"value": {

pkg/github/projects.go

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -553,7 +553,7 @@ func projectUpdatedFieldSchema() *jsonschema.Schema {
553553
variant([]string{"id", "value"}, map[string]*jsonschema.Schema{
554554
"id": {
555555
Type: "integer",
556-
Description: "The numeric ID of a standard Project field. Attached Issue Fields must be updated by name with singular 'update_project_item'.",
556+
Description: "The numeric Project field ID.",
557557
},
558558
}, "The new value, or null to clear the field. For SINGLE_SELECT, use the option ID."),
559559
variant([]string{"name", "value"}, map[string]*jsonschema.Schema{
@@ -1704,8 +1704,9 @@ func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, own
17041704
}
17051705

17061706
var (
1707-
fieldID int64
1708-
resolved *ResolvedField
1707+
fieldID int64
1708+
resolved *ResolvedField
1709+
resolvedByName bool
17091710
)
17101711

17111712
if hasID {
@@ -1714,6 +1715,13 @@ func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, own
17141715
if err != nil {
17151716
return nil, fmt.Errorf("updated_field.id: %w", err)
17161717
}
1718+
if gqlClient == nil {
1719+
return nil, fmt.Errorf("internal error: gqlClient is required to resolve updated_field.id")
1720+
}
1721+
resolved, err = resolveProjectFieldByID(ctx, gqlClient, owner, ownerType, projectNumber, fieldID)
1722+
if err != nil {
1723+
return nil, err
1724+
}
17171725
} else {
17181726
fieldName, ok := nameField.(string)
17191727
if !ok || fieldName == "" {
@@ -1727,22 +1735,24 @@ func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, own
17271735
if err != nil {
17281736
return nil, err
17291737
}
1730-
if resolved.IsIssueField {
1731-
issueField, buildErr := buildIssueFieldUpdate(resolved, valueField)
1732-
if buildErr != nil {
1733-
return nil, buildErr
1734-
}
1735-
return &resolvedProjectItemUpdate{IssueField: issueField}, nil
1736-
}
1738+
resolvedByName = true
17371739
parsedID, parseErr := parseInt64(resolved.ID)
17381740
if parseErr != nil {
17391741
return nil, fmt.Errorf("resolved field %q has non-numeric ID %q; pass updated_field.id directly", resolved.Name, resolved.ID)
17401742
}
17411743
fieldID = parsedID
17421744
}
17431745

1746+
if resolved.IsIssueField {
1747+
issueField, buildErr := buildIssueFieldUpdate(resolved, valueField, resolvedByName)
1748+
if buildErr != nil {
1749+
return nil, buildErr
1750+
}
1751+
return &resolvedProjectItemUpdate{IssueField: issueField}, nil
1752+
}
1753+
17441754
// SINGLE_SELECT: resolve option name to ID; pass through if it's already a known option ID.
1745-
if resolved != nil && resolved.DataType == "SINGLE_SELECT" {
1755+
if resolvedByName && resolved.DataType == "SINGLE_SELECT" {
17461756
if str, ok := valueField.(string); ok && str != "" {
17471757
if optID, optErr := resolveSingleSelectOptionByName(resolved, str); optErr == nil {
17481758
valueField = optID
@@ -1772,7 +1782,7 @@ func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, own
17721782
return &resolvedProjectItemUpdate{Project: payload}, nil
17731783
}
17741784

1775-
func buildIssueFieldUpdate(field *ResolvedField, raw any) (*IssueFieldCreateOrUpdateInput, error) {
1785+
func buildIssueFieldUpdate(field *ResolvedField, raw any, resolveOptionName bool) (*IssueFieldCreateOrUpdateInput, error) {
17761786
if field == nil || field.IssueFieldNodeID == "" {
17771787
name := ""
17781788
if field != nil {
@@ -1825,17 +1835,32 @@ func buildIssueFieldUpdate(field *ResolvedField, raw any) (*IssueFieldCreateOrUp
18251835
if !ok || value == "" {
18261836
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))
18271837
}
1828-
optionID, err := resolveSingleSelectOptionByName(field, value)
1829-
if err != nil {
1838+
optionID := value
1839+
if resolveOptionName {
1840+
var err error
1841+
optionID, err = resolveSingleSelectOptionByName(field, value)
1842+
if err != nil {
1843+
for _, option := range field.Options {
1844+
if option.ID == value {
1845+
optionID = value
1846+
err = nil
1847+
break
1848+
}
1849+
}
1850+
if err != nil {
1851+
return nil, err
1852+
}
1853+
}
1854+
} else {
1855+
known := false
18301856
for _, option := range field.Options {
18311857
if option.ID == value {
1832-
optionID = value
1833-
err = nil
1858+
known = true
18341859
break
18351860
}
18361861
}
1837-
if err != nil {
1838-
return nil, err
1862+
if !known {
1863+
return invalidValue(fmt.Sprintf("Issue Field %q is SINGLE_SELECT; when updated_field.id is used, value must be an option ID", field.Name))
18391864
}
18401865
}
18411866
id := githubv4.ID(optionID)

pkg/github/projects_batch.go

Lines changed: 1 addition & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -544,36 +544,7 @@ func resolveBatchProjectField(ctx context.Context, gqlClient *githubv4.Client, o
544544
if spec.name != "" {
545545
return resolveProjectFieldByName(ctx, gqlClient, owner, ownerType, projectNumber, spec.name, "")
546546
}
547-
548-
fields, err := listAllProjectFields(ctx, gqlClient, owner, ownerType, projectNumber)
549-
if err != nil {
550-
return nil, err
551-
}
552-
553-
id := fmt.Sprintf("%d", spec.id)
554-
for _, field := range fields {
555-
if field.ID == id {
556-
return &field, nil
557-
}
558-
}
559-
return nil, ghErrors.NewStructuredResolutionError(
560-
"field_not_found",
561-
id,
562-
fmt.Sprintf("no project field with id %s on project %s#%d; see candidates for available fields", id, owner, projectNumber),
563-
projectFieldCandidates(fields),
564-
)
565-
}
566-
567-
func projectFieldCandidates(fields []ResolvedField) []any {
568-
candidates := make([]any, 0, len(fields))
569-
for _, field := range fields {
570-
candidates = append(candidates, map[string]any{
571-
"id": field.ID,
572-
"name": field.Name,
573-
"data_type": field.DataType,
574-
})
575-
}
576-
return candidates
547+
return resolveProjectFieldByID(ctx, gqlClient, owner, ownerType, projectNumber, spec.id)
577548
}
578549

579550
func convertProjectFieldValue(field *ResolvedField, raw any) (githubv4.ProjectV2FieldValue, error) {

pkg/github/projects_issue_fields_test.go

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package github
33
import (
44
"context"
55
"encoding/json"
6+
"maps"
67
"net/http"
78
"testing"
89

@@ -192,14 +193,24 @@ func Test_ProjectsWrite_UpdateProjectItem_AttachedIssueFieldTypes(t *testing.T)
192193
tests := []struct {
193194
name string
194195
fieldNode map[string]any
195-
fieldName string
196+
fieldRef map[string]any
196197
value any
197198
want IssueFieldCreateOrUpdateInput
198199
}{
199200
{
200201
name: "text",
201202
fieldNode: attachedIssueFieldNode("PVTF_customer", 701, "IF_customer", "Customer", "TEXT", nil),
202-
fieldName: "cUsToMeR",
203+
fieldRef: map[string]any{"name": "cUsToMeR"},
204+
value: "Acme",
205+
want: IssueFieldCreateOrUpdateInput{
206+
FieldID: githubv4.ID("IF_customer"),
207+
TextValue: githubv4.NewString(githubv4.String("Acme")),
208+
},
209+
},
210+
{
211+
name: "text by project field ID",
212+
fieldNode: attachedIssueFieldNode("PVTF_customer", 701, "IF_customer", "Customer", "TEXT", nil),
213+
fieldRef: map[string]any{"id": float64(701)},
203214
value: "Acme",
204215
want: IssueFieldCreateOrUpdateInput{
205216
FieldID: githubv4.ID("IF_customer"),
@@ -209,7 +220,7 @@ func Test_ProjectsWrite_UpdateProjectItem_AttachedIssueFieldTypes(t *testing.T)
209220
{
210221
name: "number",
211222
fieldNode: attachedIssueFieldNode("PVTF_score", 702, "IF_score", "Score", "NUMBER", nil),
212-
fieldName: "Score",
223+
fieldRef: map[string]any{"name": "Score"},
213224
value: 3.5,
214225
want: IssueFieldCreateOrUpdateInput{
215226
FieldID: githubv4.ID("IF_score"),
@@ -219,7 +230,7 @@ func Test_ProjectsWrite_UpdateProjectItem_AttachedIssueFieldTypes(t *testing.T)
219230
{
220231
name: "date",
221232
fieldNode: attachedIssueFieldNode("PVTF_due", 703, "IF_due", "Due", "DATE", nil),
222-
fieldName: "Due",
233+
fieldRef: map[string]any{"name": "Due"},
223234
value: "2026-08-01",
224235
want: IssueFieldCreateOrUpdateInput{
225236
FieldID: githubv4.ID("IF_due"),
@@ -232,8 +243,21 @@ func Test_ProjectsWrite_UpdateProjectItem_AttachedIssueFieldTypes(t *testing.T)
232243
{"id": "IFO_low", "name": "Low"},
233244
{"id": "IFO_high", "name": "High"},
234245
}),
235-
fieldName: "rIsK",
236-
value: "hIgH",
246+
fieldRef: map[string]any{"name": "rIsK"},
247+
value: "hIgH",
248+
want: IssueFieldCreateOrUpdateInput{
249+
FieldID: githubv4.ID("IF_risk"),
250+
SingleSelectOptionID: &optionID,
251+
},
252+
},
253+
{
254+
name: "single select by project field ID",
255+
fieldNode: attachedIssueFieldNode("PVTSSF_risk", 704, "IF_risk", "Risk", "SINGLE_SELECT", []map[string]any{
256+
{"id": "IFO_low", "name": "Low"},
257+
{"id": "IFO_high", "name": "High"},
258+
}),
259+
fieldRef: map[string]any{"id": float64(704)},
260+
value: "IFO_high",
237261
want: IssueFieldCreateOrUpdateInput{
238262
FieldID: githubv4.ID("IF_risk"),
239263
SingleSelectOptionID: &optionID,
@@ -242,7 +266,7 @@ func Test_ProjectsWrite_UpdateProjectItem_AttachedIssueFieldTypes(t *testing.T)
242266
{
243267
name: "clear",
244268
fieldNode: attachedIssueFieldNode("PVTF_customer", 701, "IF_customer", "Customer", "TEXT", nil),
245-
fieldName: "Customer",
269+
fieldRef: map[string]any{"name": "Customer"},
246270
value: nil,
247271
want: IssueFieldCreateOrUpdateInput{
248272
FieldID: githubv4.ID("IF_customer"),
@@ -275,16 +299,15 @@ func Test_ProjectsWrite_UpdateProjectItem_AttachedIssueFieldTypes(t *testing.T)
275299
deps := BaseDeps{Client: mustNewGHClient(t, rest), GQLClient: gql}
276300
serverTool := ProjectsWrite(translations.NullTranslationHelper)
277301
handler := serverTool.Handler(deps)
302+
updatedField := map[string]any{"value": tt.value}
303+
maps.Copy(updatedField, tt.fieldRef)
278304
request := createMCPRequest(map[string]any{
279305
"method": projectsMethodUpdateProjectItem,
280306
"owner": "octo-org",
281307
"owner_type": "org",
282308
"project_number": float64(1),
283309
"item_id": float64(1001),
284-
"updated_field": map[string]any{
285-
"name": tt.fieldName,
286-
"value": tt.value,
287-
},
310+
"updated_field": updatedField,
288311
})
289312
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
290313
require.NoError(t, err)

pkg/github/projects_resolver.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,38 @@ func resolveProjectFieldByName(ctx context.Context, gqlClient *githubv4.Client,
277277
return &field, nil
278278
}
279279

280+
func resolveProjectFieldByID(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, fieldID int64) (*ResolvedField, error) {
281+
all, err := listAllProjectFields(ctx, gqlClient, owner, ownerType, projectNumber)
282+
if err != nil {
283+
return nil, err
284+
}
285+
286+
id := strconv.FormatInt(fieldID, 10)
287+
for _, field := range all {
288+
if field.ID == id {
289+
return &field, nil
290+
}
291+
}
292+
return nil, ghErrors.NewStructuredResolutionError(
293+
"field_not_found",
294+
id,
295+
fmt.Sprintf("no project field with id %s on project %s#%d; see candidates for available fields", id, owner, projectNumber),
296+
projectFieldCandidates(all),
297+
)
298+
}
299+
300+
func projectFieldCandidates(fields []ResolvedField) []any {
301+
candidates := make([]any, 0, len(fields))
302+
for _, field := range fields {
303+
candidates = append(candidates, map[string]any{
304+
"id": field.ID,
305+
"name": field.Name,
306+
"data_type": field.DataType,
307+
})
308+
}
309+
return candidates
310+
}
311+
280312
// resolveSingleSelectOptionByName resolves an option name to its ID on a
281313
// SINGLE_SELECT field. Returns a structured error if not found or ambiguous.
282314
func resolveSingleSelectOptionByName(field *ResolvedField, optionName string) (string, error) {

pkg/github/projects_test.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1235,6 +1235,15 @@ func Test_ProjectsWrite_UpdateProjectItem(t *testing.T) {
12351235
client := mustNewGHClient(t, mockedClient)
12361236
deps := BaseDeps{
12371237
Client: client,
1238+
GQLClient: githubv4.NewClient(githubv4mock.NewMockedHTTPClient(
1239+
githubv4mock.NewQueryMatcher(
1240+
projectFieldsTestQuery{},
1241+
fieldsQueryVars("octo-org", 1),
1242+
githubv4mock.DataResponse(fieldsResponse([]map[string]any{
1243+
genericFieldNode("PVTF_notes", 101, "Notes", "TEXT"),
1244+
})),
1245+
),
1246+
)),
12381247
}
12391248
handler := toolDef.Handler(deps)
12401249
request := createMCPRequest(map[string]any{

0 commit comments

Comments
 (0)