Verified Commit 4f8a7092 authored by Florian Forster's avatar Florian Forster
Browse files

feat(workitems): Implement the `ListWorkItems` method.

parent b44da7bb
Loading
Loading
Loading
Loading
+152 −0
Original line number Diff line number Diff line
@@ -6,6 +6,8 @@ import (
	"fmt"
	"regexp"
	"strconv"
	"strings"
	"text/template"
	"time"
)

@@ -13,6 +15,7 @@ type (
	WorkItemsServiceInterface interface {
		GetWorkItemByID(gid any, options ...RequestOptionFunc) (*WorkItem, *Response, error)
		GetWorkItem(fullPath string, iid int64, options ...RequestOptionFunc) (*WorkItem, *Response, error)
		ListWorkItems(fullPath string, opt *ListWorkItemsOptions, options ...RequestOptionFunc) ([]*WorkItem, *Response, error)
	}

	// WorkItemsService handles communication with the work item related methods
@@ -207,6 +210,155 @@ query ($fullPath: ID!, $iid: String) {
	return wiQL.unwrap(), resp, nil
}

/*
workItems(

	search: String
	in: [IssuableSearchableField!]
	ids: [WorkItemID!]
	authorUsername: String
	confidential: Boolean
	assigneeUsernames: [String!]
	assigneeWildcardId: AssigneeWildcardId
	labelName: [String!]
	milestoneTitle: [String!]
	milestoneWildcardId: MilestoneWildcardId
	myReactionEmoji: String
	iids: [String!]
	state: IssuableState
	types: [IssueType!]
	createdBefore: Time
	createdAfter: Time
	updatedBefore: Time
	updatedAfter: Time
	dueBefore: Time
	dueAfter: Time
	closedBefore: Time
	closedAfter: Time
	subscribed: SubscriptionStatus
	not: NegatedWorkItemFilterInput
	or: UnionedWorkItemFilterInput
	parentIds: [WorkItemID!]
	releaseTag: [String!]
	releaseTagWildcardId: ReleaseTagWildcardId
	crmContactId: String
	crmOrganizationId: String
	iid: String
	sort: WorkItemSort = CREATED_DESC
	verificationStatusWidget: VerificationStatusFilterInput
	healthStatusFilter: HealthStatusFilter
	weight: String
	weightWildcardId: WeightWildcardId
	iterationId: [ID]
	iterationWildcardId: IterationWildcardId
	iterationCadenceId: [IterationsCadenceID!]
	includeAncestors: Boolean = false
	includeDescendants: Boolean = false
	timeframe: Timeframe
	after: String
	before: String
	first: Int
	last: Int

): WorkItemConnection
*/

// ListWorkItemsOptions represents the available ListWorkItems() options.
//
// GitLab API docs:
// https://docs.gitlab.com/ee/api/graphql/reference/#queryworkitems
type ListWorkItemsOptions struct {
	State          *string
	AuthorUsername *string
}

var workItemFieldTypes = map[string]string{
	"state":          "IssuableState",
	"authorUsername": "String",
}

var listWorkItemsTemplate = template.Must(template.New("ListWorkItems").Parse(`
query ListWorkItems($fullPath: ID!{{ range .Fields }}, ${{ .Name }}: {{ .Type }}{{ end }}) {
  namespace(fullPath: $fullPath) {
    workItems({{ range $i, $f := .Fields }}{{ if ne $i 0 }}, {{ end }}{{ $f.Name }}: ${{ $f.Name }}{{ end }}) {
      nodes {
        id
        iid
        title
      }
    }
  }
}
`))

// ListWorkItems lists workitems in a given namespace (group or project).
func (s *WorkItemsService) ListWorkItems(fullPath string, opt *ListWorkItemsOptions, options ...RequestOptionFunc) ([]*WorkItem, *Response, error) {
	type fieldGQL struct {
		Name string
		Type string
	}

	var (
		queryFields    []fieldGQL
		queryVariables = map[string]any{
			"fullPath": fullPath,
		}
	)

	if opt != nil {
		if opt.State != nil {
			queryFields = append(queryFields, fieldGQL{"state", workItemFieldTypes["state"]})
			queryVariables["state"] = opt.State
		}
		if opt.AuthorUsername != nil {
			queryFields = append(queryFields, fieldGQL{"authorUsername", workItemFieldTypes["authorUsername"]})
			queryVariables["authorUsername"] = opt.AuthorUsername
		}
	}

	var queryBuilder strings.Builder

	if err := listWorkItemsTemplate.Execute(&queryBuilder, map[string]any{"Fields": queryFields}); err != nil {
		return nil, nil, err
	}

	query := GraphQLQuery{
		Query:     queryBuilder.String(),
		Variables: queryVariables,
	}

	var result struct {
		Data struct {
			Namespace struct {
				WorkItems struct {
					Nodes []workItemGQL `json:"nodes"`
				} `json:"workItems"`
			} `json:"namespace"`
		}
		GenericGraphQLErrors
	}

	resp, err := s.client.GraphQL.Do(query, &result, options...)
	if err != nil {
		return nil, resp, err
	}

	if len(result.Errors) != 0 {
		return nil, resp, &GraphQLResponseError{
			Err:    errors.New("GraphQL query failed"),
			Errors: result.GenericGraphQLErrors,
		}
	}

	var ret []*WorkItem

	for _, wi := range result.Data.Namespace.WorkItems.Nodes {
		ret = append(ret, wi.unwrap())
	}

	return ret, resp, nil
}

// workItemGQL represents the JSON structure returned by the GraphQL query.
// It is used to parse the response and convert it to the more user-friendly WorkItem type.
type workItemGQL struct {
+166 −0
Original line number Diff line number Diff line
@@ -3,6 +3,8 @@ package gitlab
import (
	"bytes"
	_ "embed"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"strings"
@@ -279,3 +281,167 @@ func TestGetWorkItemByID(t *testing.T) {
		})
	}
}

func TestListWorkItems(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name            string
		fullPath        string
		opt             *ListWorkItemsOptions
		response        io.WriterTo
		wantQuerySubstr []string
		want            []*WorkItem
		wantErr         error
	}{
		{
			name:     "successful query with authorUsername",
			fullPath: "gitlab-com/gl-infra/platform/runway/team",
			opt: &ListWorkItemsOptions{
				AuthorUsername: Ptr("fforster"),
			},
			response: strings.NewReader(`
				{
				  "data": {
				    "namespace": {
				      "workItems": {
				        "nodes": [
				          {
				            "id": "gid://gitlab/WorkItem/181297786",
				            "iid": "39",
				            "title": "Phase 6: Rollout to Additional Services"
				          },
				          {
				            "id": "gid://gitlab/WorkItem/181297779",
				            "iid": "38",
				            "title": "Phase 5: Dedicated Integration"
				          }
				        ]
				      }
				    }
				  },
				  "correlationId": "9c88d56b0061dfef-IAD"
				}
			`),
			wantQuerySubstr: []string{
				`query ListWorkItems($fullPath: ID!, $authorUsername: String)`,
				`workItems(authorUsername: $authorUsername) {`,
			},
			want: []*WorkItem{
				{
					ID:    181297786,
					IID:   39,
					Title: "Phase 6: Rollout to Additional Services",
				},
				{
					ID:    181297779,
					IID:   38,
					Title: "Phase 5: Dedicated Integration",
				},
			},
		},
		{
			name:     "successful response with work item",
			fullPath: "gitlab-com/gl-infra/platform/runway/team",
			opt: &ListWorkItemsOptions{
				State:          Ptr("opened"),
				AuthorUsername: Ptr("fforster"),
			},
			response: strings.NewReader(`
				{
				  "data": {
				    "namespace": {
				      "workItems": {
				        "nodes": [
				          {
				            "id": "gid://gitlab/WorkItem/181297786",
				            "iid": "39",
				            "title": "Phase 6: Rollout to Additional Services"
				          }
				        ]
				      }
				    }
				  },
				  "correlationId": "9c88d56b0061dfef-IAD"
				}
			`),
			wantQuerySubstr: []string{
				`query ListWorkItems($fullPath: ID!, $state: IssuableState, $authorUsername: String)`,
				`workItems(state: $state, authorUsername: $authorUsername) {`,
			},
			want: []*WorkItem{
				{
					ID:    181297786,
					IID:   39,
					Title: "Phase 6: Rollout to Additional Services",
				},
			},
		},
		{
			name:     "empty response is not an error",
			fullPath: "gitlab-com/gl-infra/platform/runway/team",
			opt: &ListWorkItemsOptions{
				State:          Ptr("opened"),
				AuthorUsername: Ptr("fforster"),
			},
			response: strings.NewReader(`
				{
				  "data": {
				    "namespace": {
				      "workItems": {
				        "nodes": []
				      }
				    }
				  }
				}
			`),
			wantQuerySubstr: []string{
				`query ListWorkItems($fullPath: ID!, $state: IssuableState, $authorUsername: String)`,
				`workItems(state: $state, authorUsername: $authorUsername) {`,
			},
			want: nil,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			mux, client := setup(t)

			mux.HandleFunc("/api/graphql", func(w http.ResponseWriter, r *http.Request) {
				defer r.Body.Close()

				testMethod(t, r, http.MethodPost)

				var q GraphQLQuery

				if err := json.NewDecoder(r.Body).Decode(&q); err != nil {
					http.Error(w, err.Error(), http.StatusBadRequest)
					return
				}

				for _, ss := range tt.wantQuerySubstr {
					if !strings.Contains(q.Query, ss) {
						http.Error(w, fmt.Sprintf("want substring %q, got query %q", ss, q.Query), http.StatusBadRequest)
						return
					}
				}

				w.Header().Set("Content-Type", "application/json")
				tt.response.WriteTo(w)
			})

			got, _, err := client.WorkItems.ListWorkItems(tt.fullPath, tt.opt)

			if tt.wantErr != nil {
				require.ErrorIs(t, err, tt.wantErr)
				assert.Nil(t, got)

				return
			}

			require.NoError(t, err)
			assert.Equal(t, tt.want, got)
		})
	}
}