From 55e1a7e3c0177546c945324162d18ad741f3250e Mon Sep 17 00:00:00 2001 From: Bryan Zwicker Date: Tue, 21 Jul 2026 15:06:43 -0400 Subject: [PATCH 1/2] Add node IDs to project resolver results Split out of #2903 as a small prerequisite refactor. - Add a NodeID field to ResolvedField, populated for all three field variants in listAllProjectFields. - Refactor resolveProjectItemIDByIssueNumber into a thin wrapper over a new resolveProjectItemByIssueNumber that also returns the item node ID, delegating to resolveProjectItemByIssueNumberWithProjectID for an already-resolved project ID. The projectItems query now selects the item node ID alongside its full database ID. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 89f897b0-115f-4435-a071-46fb6c49be86 --- pkg/github/projects_resolver.go | 31 ++++++++++++++++++++-------- pkg/github/projects_resolver_test.go | 12 ++++++++--- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/pkg/github/projects_resolver.go b/pkg/github/projects_resolver.go index 25a690888f..3643d6eafa 100644 --- a/pkg/github/projects_resolver.go +++ b/pkg/github/projects_resolver.go @@ -20,10 +20,11 @@ type ResolvedFieldOption struct { Name string } -// ResolvedField is a project field resolved by name; Options is only set when -// DataType == "SINGLE_SELECT". +// ResolvedField contains a project's numeric database ID, GraphQL node ID, and +// type-specific options. type ResolvedField struct { ID string + NodeID string Name string DataType string Options []ResolvedFieldOption @@ -117,6 +118,7 @@ func listAllProjectFields(ctx context.Context, gqlClient *githubv4.Client, owner } all = append(all, ResolvedField{ ID: fmt.Sprintf("%d", n.ProjectV2SingleSelectField.DatabaseID), + NodeID: fmt.Sprintf("%v", n.ProjectV2SingleSelectField.ID), Name: string(n.ProjectV2SingleSelectField.Name), DataType: string(n.ProjectV2SingleSelectField.DataType), Options: opts, @@ -124,12 +126,14 @@ func listAllProjectFields(ctx context.Context, gqlClient *githubv4.Client, owner case n.ProjectV2IterationField.ID != nil: all = append(all, ResolvedField{ ID: fmt.Sprintf("%d", n.ProjectV2IterationField.DatabaseID), + NodeID: fmt.Sprintf("%v", n.ProjectV2IterationField.ID), Name: string(n.ProjectV2IterationField.Name), DataType: string(n.ProjectV2IterationField.DataType), }) case n.ProjectV2Field.ID != nil: all = append(all, ResolvedField{ ID: fmt.Sprintf("%d", n.ProjectV2Field.DatabaseID), + NodeID: fmt.Sprintf("%v", n.ProjectV2Field.ID), Name: string(n.ProjectV2Field.Name), DataType: string(n.ProjectV2Field.DataType), }) @@ -266,13 +270,22 @@ func resolveSingleSelectOptionByName(field *ResolvedField, optionName string) (s // project item's full database ID in one GraphQL hop. Returns a structured // error if the issue is not an item on the project. func resolveProjectItemIDByIssueNumber(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, issueOwner, issueRepo string, issueNumber int) (int64, error) { + _, itemID, err := resolveProjectItemByIssueNumber(ctx, gqlClient, owner, ownerType, projectNumber, issueOwner, issueRepo, issueNumber) + return itemID, err +} + +func resolveProjectItemByIssueNumber(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, issueOwner, issueRepo string, issueNumber int) (nodeID string, itemID int64, err error) { projectID, err := resolveProjectNodeID(ctx, gqlClient, owner, ownerType, projectNumber) if err != nil { - return 0, err + return "", 0, err } + return resolveProjectItemByIssueNumberWithProjectID(ctx, gqlClient, projectID, issueOwner, issueRepo, issueNumber) +} +func resolveProjectItemByIssueNumberWithProjectID(ctx context.Context, gqlClient *githubv4.Client, projectID githubv4.ID, issueOwner, issueRepo string, issueNumber int) (nodeID string, itemID int64, err error) { type projectItemsConnection struct { Nodes []struct { + ID githubv4.ID FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` Project struct { ID githubv4.ID @@ -296,18 +309,18 @@ func resolveProjectItemIDByIssueNumber(ctx context.Context, gqlClient *githubv4. } if err := gqlClient.Query(ctx, &firstPageQuery, vars); err != nil { - return 0, fmt.Errorf("failed to resolve project item for %s/%s#%d: %w", issueOwner, issueRepo, issueNumber, err) + return "", 0, fmt.Errorf("failed to resolve project item for %s/%s#%d: %w", issueOwner, issueRepo, issueNumber, err) } projectItems := firstPageQuery.Repository.Issue.ProjectItems for { for _, item := range projectItems.Nodes { if item.Project.ID == projectID { - itemID, parseErr := parseInt64(string(item.FullDatabaseID)) + parsedItemID, parseErr := parseInt64(string(item.FullDatabaseID)) if parseErr != nil { - return 0, fmt.Errorf("project item ID %q is not an integer: %w", string(item.FullDatabaseID), parseErr) + return "", 0, fmt.Errorf("project item ID %q is not an integer: %w", string(item.FullDatabaseID), parseErr) } - return itemID, nil + return fmt.Sprintf("%v", item.ID), parsedItemID, nil } } @@ -324,12 +337,12 @@ func resolveProjectItemIDByIssueNumber(ctx context.Context, gqlClient *githubv4. } vars["after"] = projectItems.PageInfo.EndCursor if err := gqlClient.Query(ctx, &nextPageQuery, vars); err != nil { - return 0, fmt.Errorf("failed to resolve project item for %s/%s#%d: %w", issueOwner, issueRepo, issueNumber, err) + return "", 0, fmt.Errorf("failed to resolve project item for %s/%s#%d: %w", issueOwner, issueRepo, issueNumber, err) } projectItems = nextPageQuery.Repository.Issue.ProjectItems } - return 0, ghErrors.NewStructuredResolutionError( + return "", 0, ghErrors.NewStructuredResolutionError( "item_not_in_project", fmt.Sprintf("%s/%s#%d", issueOwner, issueRepo, issueNumber), "the issue exists but is not an item on the named project; add it first via add_project_item", diff --git a/pkg/github/projects_resolver_test.go b/pkg/github/projects_resolver_test.go index 5b563d2fa1..04b913dfff 100644 --- a/pkg/github/projects_resolver_test.go +++ b/pkg/github/projects_resolver_test.go @@ -204,6 +204,7 @@ type resolveItemByIssueQuery struct { Issue struct { ProjectItems struct { Nodes []struct { + ID githubv4.ID FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` Project struct { ID githubv4.ID @@ -220,6 +221,7 @@ type resolveItemByIssuePageQuery struct { Issue struct { ProjectItems struct { Nodes []struct { + ID githubv4.ID FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` Project struct { ID githubv4.ID @@ -282,6 +284,7 @@ func Test_ResolveProjectItemIDByIssueNumber_Success(t *testing.T) { "project": map[string]any{"id": "PVT_other"}, }, map[string]any{ + "id": "PVTI_target", "fullDatabaseId": "4242", "project": map[string]any{"id": "PVT_project1"}, }, @@ -300,12 +303,13 @@ func Test_ResolveProjectItemIDByIssueNumber_Success(t *testing.T) { ) gql := githubv4.NewClient(mocked) - itemID, err := resolveProjectItemIDByIssueNumber(context.Background(), gql, "octo-org", "org", 1, "octo-issue-owner", "repo", 123) + nodeID, itemID, err := resolveProjectItemByIssueNumber(context.Background(), gql, "octo-org", "org", 1, "octo-issue-owner", "repo", 123) require.NoError(t, err) + assert.Equal(t, "PVTI_target", nodeID) assert.Equal(t, int64(4242), itemID) } -func Test_ResolveProjectItemIDByIssueNumber_TargetOnSecondPage(t *testing.T) { +func Test_ResolveProjectItemByIssueNumber_TargetOnSecondPage(t *testing.T) { mocked := githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( struct { @@ -367,6 +371,7 @@ func Test_ResolveProjectItemIDByIssueNumber_TargetOnSecondPage(t *testing.T) { "projectItems": map[string]any{ "nodes": []any{ map[string]any{ + "id": "PVTI_target", "fullDatabaseId": "4242", "project": map[string]any{"id": "PVT_project1"}, }, @@ -385,8 +390,9 @@ func Test_ResolveProjectItemIDByIssueNumber_TargetOnSecondPage(t *testing.T) { ) gql := githubv4.NewClient(mocked) - itemID, err := resolveProjectItemIDByIssueNumber(context.Background(), gql, "octo-org", "org", 1, "octo-issue-owner", "repo", 123) + nodeID, itemID, err := resolveProjectItemByIssueNumber(context.Background(), gql, "octo-org", "org", 1, "octo-issue-owner", "repo", 123) require.NoError(t, err) + assert.Equal(t, "PVTI_target", nodeID) assert.Equal(t, int64(4242), itemID) } From 9a5fb0bd00192acb36d2af1fc0cd514a62063c3e Mon Sep 17 00:00:00 2001 From: Bryan Zwicker Date: Tue, 21 Jul 2026 16:23:59 -0400 Subject: [PATCH 2/2] Address CCR feedback: assert resolved field NodeID for all variants; rename item resolver test - Assert field.NodeID in Test_ResolveProjectFieldByName_Success. - Add Test_ResolveProjectFieldByName_NodeIDsForAllVariants covering single-select, iteration, and generic fields (asserts NodeID + DataType). - Rename Test_ResolveProjectItemIDByIssueNumber_Success to Test_ResolveProjectItemByIssueNumber_Success to match the resolver it calls. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 89f897b0-115f-4435-a071-46fb6c49be86 --- pkg/github/projects_resolver_test.go | 60 +++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/pkg/github/projects_resolver_test.go b/pkg/github/projects_resolver_test.go index 04b913dfff..b08e00cac6 100644 --- a/pkg/github/projects_resolver_test.go +++ b/pkg/github/projects_resolver_test.go @@ -71,6 +71,27 @@ func statusFieldNode(nodeID string, databaseID int, name string, options []map[s } } +// iterationFieldNode is an iteration field response node for use in mock data. +func iterationFieldNode(nodeID string, databaseID int, name string) map[string]any { + return map[string]any{ + "id": nodeID, + "databaseId": databaseID, + "name": name, + "dataType": "ITERATION", + } +} + +// genericFieldNode is a plain field response node (neither single-select nor +// iteration, e.g. TEXT or NUMBER) for use in mock data. +func genericFieldNode(nodeID string, databaseID int, name, dataType string) map[string]any { + return map[string]any{ + "id": nodeID, + "databaseId": databaseID, + "name": name, + "dataType": dataType, + } +} + func fieldsResponse(nodes []map[string]any) map[string]any { return map[string]any{ "organization": map[string]any{ @@ -109,6 +130,7 @@ func Test_ResolveProjectFieldByName_Success(t *testing.T) { require.NoError(t, err) require.NotNil(t, field) assert.Equal(t, "12345", field.ID) + assert.Equal(t, "PVTSSF_lADOBBcDeFg123", field.NodeID) assert.Equal(t, "SINGLE_SELECT", field.DataType) assert.Len(t, field.Options, 3) @@ -117,6 +139,42 @@ func Test_ResolveProjectFieldByName_Success(t *testing.T) { assert.Equal(t, "OPT_b", optionID) } +func Test_ResolveProjectFieldByName_NodeIDsForAllVariants(t *testing.T) { + mocked := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 7), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + statusFieldNode("PVTSSF_single1", 111, "Status", []map[string]any{ + {"id": "OPT_a", "name": "Todo"}, + }), + iterationFieldNode("PVTIF_iteration1", 222, "Sprint"), + genericFieldNode("PVTF_text1", 333, "Notes", "TEXT"), + })), + ), + ) + gql := githubv4.NewClient(mocked) + + variants := []struct { + fieldName string + expectedType string + wantNodeID string + }{ + {"Status", "SINGLE_SELECT", "PVTSSF_single1"}, + {"Sprint", "ITERATION", "PVTIF_iteration1"}, + {"Notes", "TEXT", "PVTF_text1"}, + } + for _, v := range variants { + t.Run(v.fieldName, func(t *testing.T) { + field, err := resolveProjectFieldByName(context.Background(), gql, "octo-org", "org", 7, v.fieldName, v.expectedType) + require.NoError(t, err) + require.NotNil(t, field) + assert.Equal(t, v.wantNodeID, field.NodeID) + assert.Equal(t, v.expectedType, field.DataType) + }) + } +} + func Test_ResolveProjectFieldByName_NotFound_ReturnsStructuredError(t *testing.T) { mocked := githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( @@ -243,7 +301,7 @@ func (t *requestCountingTransport) RoundTrip(req *http.Request) (*http.Response, return t.inner.RoundTrip(req) } -func Test_ResolveProjectItemIDByIssueNumber_Success(t *testing.T) { +func Test_ResolveProjectItemByIssueNumber_Success(t *testing.T) { mocked := githubv4mock.NewMockedHTTPClient( // project node id lookup (org) githubv4mock.NewQueryMatcher(