Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 22 additions & 9 deletions pkg/github/projects_resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -117,19 +118,22 @@ 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),
Comment thread
zwick marked this conversation as resolved.
Name: string(n.ProjectV2SingleSelectField.Name),
DataType: string(n.ProjectV2SingleSelectField.DataType),
Options: opts,
})
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),
})
Expand Down Expand Up @@ -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
Expand All @@ -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
}
}

Expand All @@ -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",
Expand Down
72 changes: 68 additions & 4 deletions pkg/github/projects_resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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)

Expand All @@ -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(
Expand Down Expand Up @@ -204,6 +262,7 @@ type resolveItemByIssueQuery struct {
Issue struct {
ProjectItems struct {
Nodes []struct {
ID githubv4.ID
FullDatabaseID githubv4.String `graphql:"fullDatabaseId"`
Project struct {
ID githubv4.ID
Expand All @@ -220,6 +279,7 @@ type resolveItemByIssuePageQuery struct {
Issue struct {
ProjectItems struct {
Nodes []struct {
ID githubv4.ID
FullDatabaseID githubv4.String `graphql:"fullDatabaseId"`
Project struct {
ID githubv4.ID
Expand All @@ -241,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(
Expand Down Expand Up @@ -282,6 +342,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"},
},
Expand All @@ -300,12 +361,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)
Comment thread
zwick marked this conversation as resolved.
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 {
Expand Down Expand Up @@ -367,6 +429,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"},
},
Expand All @@ -385,8 +448,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)
}

Expand Down
Loading