Verified Commit 4cedd83e authored by Kai Armstrong's avatar Kai Armstrong Committed by GitLab
Browse files

feat: add GraphQL support for project targetBranchRules

Changelog: Improvements
parent c4fb21a2
Loading
Loading
Loading
Loading
+195 −0
Original line number Diff line number Diff line
package gitlab

import (
	"errors"
	"fmt"
	"time"
)

// TargetBranchRule represents a single target branch rule on a project.
//
// GitLab API docs: https://docs.gitlab.com/api/graphql/reference/#projecttargetbranchrule
type TargetBranchRule struct {
	ID           int64     `json:"id"`
	Name         string    `json:"name"`
	TargetBranch string    `json:"targetBranch"`
	CreatedAt    time.Time `json:"createdAt"`
}

// targetBranchRuleGQL is used to unmarshal GraphQL responses where the ID is a
// global ID string of the form gid://gitlab/Projects::TargetBranchRule/<n>.
type targetBranchRuleGQL struct {
	ID           gidGQL    `json:"id"`
	Name         string    `json:"name"`
	TargetBranch string    `json:"targetBranch"`
	CreatedAt    time.Time `json:"createdAt"`
}

func (r *targetBranchRuleGQL) unwrap() *TargetBranchRule {
	return &TargetBranchRule{
		ID:           r.ID.Int64,
		Name:         r.Name,
		TargetBranch: r.TargetBranch,
		CreatedAt:    r.CreatedAt,
	}
}

// CreateTargetBranchRuleOptions represents the available
// CreateTargetBranchRule() options.
//
// GitLab API docs: https://docs.gitlab.com/api/graphql/reference/#mutationprojecttargetbranchrulecreate
type CreateTargetBranchRuleOptions struct {
	Name         string
	TargetBranch string
}

// ListProjectTargetBranchRules returns the target branch rules for a project.
// projectFullPath must be the full namespace/project path string, as the
// GitLab GraphQL project(fullPath:) field does not accept numeric IDs.
//
// GitLab API docs: https://docs.gitlab.com/api/graphql/reference/#projecttargetbranchruleconnection
func (s *ProjectsService) ListProjectTargetBranchRules(projectFullPath string, options ...RequestOptionFunc) ([]TargetBranchRule, *Response, error) {
	query := GraphQLQuery{
		Query: `
			query($fullPath: ID!) {
				project(fullPath: $fullPath) {
					targetBranchRules {
						nodes {
							id
							name
							targetBranch
							createdAt
						}
					}
				}
			}
		`,
		Variables: map[string]any{
			"fullPath": projectFullPath,
		},
	}

	var result struct {
		Data struct {
			Project *struct {
				TargetBranchRules struct {
					Nodes []targetBranchRuleGQL `json:"nodes"`
				} `json:"targetBranchRules"`
			} `json:"project"`
		} `json:"data"`
		GenericGraphQLErrors
	}

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

	rules := make([]TargetBranchRule, 0, len(result.Data.Project.TargetBranchRules.Nodes))
	for i := range result.Data.Project.TargetBranchRules.Nodes {
		rules = append(rules, *result.Data.Project.TargetBranchRules.Nodes[i].unwrap())
	}

	return rules, resp, nil
}

// CreateTargetBranchRule creates a new target branch rule for a project.
//
// GitLab API docs: https://docs.gitlab.com/api/graphql/reference/#mutationprojecttargetbranchrulecreate
func (s *ProjectsService) CreateTargetBranchRule(pid int64, opt *CreateTargetBranchRuleOptions, options ...RequestOptionFunc) (*TargetBranchRule, *Response, error) {
	if opt == nil {
		return nil, nil, errors.New("opt is required")
	}

	projectGID := gidGQL{Type: "Project", Int64: pid}

	mutation := GraphQLQuery{
		Query: `
			mutation CreateTargetBranchRule($input: ProjectTargetBranchRuleCreateInput!) {
				projectTargetBranchRuleCreate(input: $input) {
					targetBranchRule {
						id
						name
						targetBranch
						createdAt
					}
					errors
				}
			}
		`,
		Variables: map[string]any{
			"input": map[string]any{
				"projectId":    projectGID.String(),
				"name":         opt.Name,
				"targetBranch": opt.TargetBranch,
			},
		},
	}

	var result struct {
		Data struct {
			ProjectTargetBranchRuleCreate struct {
				TargetBranchRule *targetBranchRuleGQL `json:"targetBranchRule"`
				Errors           []string             `json:"errors"`
			} `json:"projectTargetBranchRuleCreate"`
		} `json:"data"`
		GenericGraphQLErrors
	}

	resp, err := s.client.GraphQL.Do(mutation, &result, options...)
	if err != nil {
		return nil, resp, err
	}
	if len(result.Data.ProjectTargetBranchRuleCreate.Errors) > 0 {
		return nil, resp, fmt.Errorf("projectTargetBranchRuleCreate mutation errors: %v", result.Data.ProjectTargetBranchRuleCreate.Errors)
	}
	if result.Data.ProjectTargetBranchRuleCreate.TargetBranchRule == nil {
		return nil, resp, ErrNotFound
	}

	return result.Data.ProjectTargetBranchRuleCreate.TargetBranchRule.unwrap(), resp, nil
}

// DeleteTargetBranchRule deletes a target branch rule.
//
// GitLab API docs: https://docs.gitlab.com/api/graphql/reference/#mutationprojecttargetbranchruledestroy
func (s *ProjectsService) DeleteTargetBranchRule(id int64, options ...RequestOptionFunc) (*Response, error) {
	gid := gidGQL{Type: "Projects::TargetBranchRule", Int64: id}

	mutation := GraphQLQuery{
		Query: `
			mutation DeleteTargetBranchRule($input: ProjectTargetBranchRuleDestroyInput!) {
				projectTargetBranchRuleDestroy(input: $input) {
					errors
				}
			}
		`,
		Variables: map[string]any{
			"input": map[string]any{
				"id": gid.String(),
			},
		},
	}

	var result struct {
		Data struct {
			ProjectTargetBranchRuleDestroy struct {
				Errors []string `json:"errors"`
			} `json:"projectTargetBranchRuleDestroy"`
		} `json:"data"`
		GenericGraphQLErrors
	}

	resp, err := s.client.GraphQL.Do(mutation, &result, options...)
	if err != nil {
		return resp, err
	}
	if len(result.Data.ProjectTargetBranchRuleDestroy.Errors) > 0 {
		return resp, fmt.Errorf("projectTargetBranchRuleDestroy mutation errors: %v", result.Data.ProjectTargetBranchRuleDestroy.Errors)
	}

	return resp, nil
}
+199 −0
Original line number Diff line number Diff line
package gitlab

import (
	"fmt"
	"net/http"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

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

	mux, client := setup(t)

	mux.HandleFunc("/api/graphql", func(w http.ResponseWriter, r *http.Request) {
		testMethod(t, r, http.MethodPost)
		w.WriteHeader(http.StatusOK)
		fmt.Fprint(w, `
			{
				"data": {
					"project": {
						"targetBranchRules": {
							"nodes": [
								{
									"id": "gid://gitlab/Projects::TargetBranchRule/1",
									"name": "feature/*",
									"targetBranch": "develop",
									"createdAt": "2024-01-15T10:00:00Z"
								},
								{
									"id": "gid://gitlab/Projects::TargetBranchRule/2",
									"name": "hotfix/*",
									"targetBranch": "main",
									"createdAt": "2024-01-16T10:00:00Z"
								}
							]
						}
					}
				}
			}
		`)
	})

	rules, _, err := client.Projects.ListProjectTargetBranchRules("mygroup/myproject")
	require.NoError(t, err)

	t1, err := time.Parse(time.RFC3339, "2024-01-15T10:00:00Z")
	require.NoError(t, err)
	t2, err := time.Parse(time.RFC3339, "2024-01-16T10:00:00Z")
	require.NoError(t, err)

	want := []TargetBranchRule{
		{ID: 1, Name: "feature/*", TargetBranch: "develop", CreatedAt: t1},
		{ID: 2, Name: "hotfix/*", TargetBranch: "main", CreatedAt: t2},
	}
	assert.Equal(t, want, rules)
}

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

	mux, client := setup(t)

	mux.HandleFunc("/api/graphql", func(w http.ResponseWriter, r *http.Request) {
		testMethod(t, r, http.MethodPost)
		w.WriteHeader(http.StatusOK)
		fmt.Fprint(w, `{"data": {"project": null}}`)
	})

	_, _, err := client.Projects.ListProjectTargetBranchRules("nonexistent/project")
	assert.ErrorIs(t, err, ErrNotFound)
}

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

	mux, client := setup(t)

	mux.HandleFunc("/api/graphql", func(w http.ResponseWriter, r *http.Request) {
		testMethod(t, r, http.MethodPost)
		w.WriteHeader(http.StatusOK)
		fmt.Fprint(w, `
			{
				"data": {
					"projectTargetBranchRuleCreate": {
						"targetBranchRule": {
							"id": "gid://gitlab/Projects::TargetBranchRule/42",
							"name": "release/*",
							"targetBranch": "stable",
							"createdAt": "2024-03-01T08:00:00Z"
						},
						"errors": []
					}
				}
			}
		`)
	})

	opt := &CreateTargetBranchRuleOptions{
		Name:         "release/*",
		TargetBranch: "stable",
	}
	rule, _, err := client.Projects.CreateTargetBranchRule(1, opt)
	require.NoError(t, err)

	ts, err := time.Parse(time.RFC3339, "2024-03-01T08:00:00Z")
	require.NoError(t, err)

	want := &TargetBranchRule{
		ID:           42,
		Name:         "release/*",
		TargetBranch: "stable",
		CreatedAt:    ts,
	}
	assert.Equal(t, want, rule)
}

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

	_, client := setup(t)

	_, _, err := client.Projects.CreateTargetBranchRule(1, nil)
	assert.ErrorContains(t, err, "opt is required")
}

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

	mux, client := setup(t)

	mux.HandleFunc("/api/graphql", func(w http.ResponseWriter, r *http.Request) {
		testMethod(t, r, http.MethodPost)
		w.WriteHeader(http.StatusOK)
		fmt.Fprint(w, `
			{
				"data": {
					"projectTargetBranchRuleCreate": {
						"targetBranchRule": null,
						"errors": ["Name has already been taken"]
					}
				}
			}
		`)
	})

	opt := &CreateTargetBranchRuleOptions{Name: "release/*", TargetBranch: "stable"}
	_, _, err := client.Projects.CreateTargetBranchRule(1, opt)
	assert.ErrorContains(t, err, "Name has already been taken")
}

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

	mux, client := setup(t)

	mux.HandleFunc("/api/graphql", func(w http.ResponseWriter, r *http.Request) {
		testMethod(t, r, http.MethodPost)
		w.WriteHeader(http.StatusOK)
		fmt.Fprint(w, `
			{
				"data": {
					"projectTargetBranchRuleDestroy": {
						"errors": []
					}
				}
			}
		`)
	})

	_, err := client.Projects.DeleteTargetBranchRule(42)
	assert.NoError(t, err)
}

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

	mux, client := setup(t)

	mux.HandleFunc("/api/graphql", func(w http.ResponseWriter, r *http.Request) {
		testMethod(t, r, http.MethodPost)
		w.WriteHeader(http.StatusOK)
		fmt.Fprint(w, `
			{
				"data": {
					"projectTargetBranchRuleDestroy": {
						"errors": ["Record not found"]
					}
				}
			}
		`)
	})

	_, err := client.Projects.DeleteTargetBranchRule(999)
	assert.ErrorContains(t, err, "Record not found")
}
+15 −0
Original line number Diff line number Diff line
@@ -320,6 +320,21 @@ type (
		// GitLab API docs:
		// https://docs.gitlab.com/api/project_starring/#list-users-who-starred-a-project
		ListProjectStarrers(pid any, opts *ListProjectStarrersOptions, options ...RequestOptionFunc) ([]*ProjectStarrer, *Response, error)
		// ListProjectTargetBranchRules returns the target branch rules for a
		// project. projectFullPath must be the full namespace/project path
		// string, as the GitLab GraphQL project(fullPath:) field does not
		// accept numeric IDs.
		//
		// GitLab API docs: https://docs.gitlab.com/api/graphql/reference/#projecttargetbranchruleconnection
		ListProjectTargetBranchRules(projectFullPath string, options ...RequestOptionFunc) ([]TargetBranchRule, *Response, error)
		// CreateTargetBranchRule creates a new target branch rule for a project.
		//
		// GitLab API docs: https://docs.gitlab.com/api/graphql/reference/#mutationprojecttargetbranchrulecreate
		CreateTargetBranchRule(pid int64, opt *CreateTargetBranchRuleOptions, options ...RequestOptionFunc) (*TargetBranchRule, *Response, error)
		// DeleteTargetBranchRule deletes a target branch rule.
		//
		// GitLab API docs: https://docs.gitlab.com/api/graphql/reference/#mutationprojecttargetbranchruledestroy
		DeleteTargetBranchRule(id int64, options ...RequestOptionFunc) (*Response, error)
	}

	// ProjectsService handles communication with the repositories related methods
+134 −0
Original line number Diff line number Diff line
@@ -446,6 +446,51 @@ func (c *MockProjectsServiceInterfaceCreateProjectForkRelationCall) DoAndReturn(
	return c
}

// CreateTargetBranchRule mocks base method.
func (m *MockProjectsServiceInterface) CreateTargetBranchRule(pid int64, opt *gitlab.CreateTargetBranchRuleOptions, options ...gitlab.RequestOptionFunc) (*gitlab.TargetBranchRule, *gitlab.Response, error) {
	m.ctrl.T.Helper()
	varargs := []any{pid, opt}
	for _, a := range options {
		varargs = append(varargs, a)
	}
	ret := m.ctrl.Call(m, "CreateTargetBranchRule", varargs...)
	ret0, _ := ret[0].(*gitlab.TargetBranchRule)
	ret1, _ := ret[1].(*gitlab.Response)
	ret2, _ := ret[2].(error)
	return ret0, ret1, ret2
}

// CreateTargetBranchRule indicates an expected call of CreateTargetBranchRule.
func (mr *MockProjectsServiceInterfaceMockRecorder) CreateTargetBranchRule(pid, opt any, options ...any) *MockProjectsServiceInterfaceCreateTargetBranchRuleCall {
	mr.mock.ctrl.T.Helper()
	varargs := append([]any{pid, opt}, options...)
	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateTargetBranchRule", reflect.TypeOf((*MockProjectsServiceInterface)(nil).CreateTargetBranchRule), varargs...)
	return &MockProjectsServiceInterfaceCreateTargetBranchRuleCall{Call: call}
}

// MockProjectsServiceInterfaceCreateTargetBranchRuleCall wrap *gomock.Call
type MockProjectsServiceInterfaceCreateTargetBranchRuleCall struct {
	*gomock.Call
}

// Return rewrite *gomock.Call.Return
func (c *MockProjectsServiceInterfaceCreateTargetBranchRuleCall) Return(arg0 *gitlab.TargetBranchRule, arg1 *gitlab.Response, arg2 error) *MockProjectsServiceInterfaceCreateTargetBranchRuleCall {
	c.Call = c.Call.Return(arg0, arg1, arg2)
	return c
}

// Do rewrite *gomock.Call.Do
func (c *MockProjectsServiceInterfaceCreateTargetBranchRuleCall) Do(f func(int64, *gitlab.CreateTargetBranchRuleOptions, ...gitlab.RequestOptionFunc) (*gitlab.TargetBranchRule, *gitlab.Response, error)) *MockProjectsServiceInterfaceCreateTargetBranchRuleCall {
	c.Call = c.Call.Do(f)
	return c
}

// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockProjectsServiceInterfaceCreateTargetBranchRuleCall) DoAndReturn(f func(int64, *gitlab.CreateTargetBranchRuleOptions, ...gitlab.RequestOptionFunc) (*gitlab.TargetBranchRule, *gitlab.Response, error)) *MockProjectsServiceInterfaceCreateTargetBranchRuleCall {
	c.Call = c.Call.DoAndReturn(f)
	return c
}

// DeleteProject mocks base method.
func (m *MockProjectsServiceInterface) DeleteProject(pid any, opt *gitlab.DeleteProjectOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
	m.ctrl.T.Helper()
@@ -798,6 +843,50 @@ func (c *MockProjectsServiceInterfaceDeleteSharedProjectFromGroupCall) DoAndRetu
	return c
}

// DeleteTargetBranchRule mocks base method.
func (m *MockProjectsServiceInterface) DeleteTargetBranchRule(id int64, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
	m.ctrl.T.Helper()
	varargs := []any{id}
	for _, a := range options {
		varargs = append(varargs, a)
	}
	ret := m.ctrl.Call(m, "DeleteTargetBranchRule", varargs...)
	ret0, _ := ret[0].(*gitlab.Response)
	ret1, _ := ret[1].(error)
	return ret0, ret1
}

// DeleteTargetBranchRule indicates an expected call of DeleteTargetBranchRule.
func (mr *MockProjectsServiceInterfaceMockRecorder) DeleteTargetBranchRule(id any, options ...any) *MockProjectsServiceInterfaceDeleteTargetBranchRuleCall {
	mr.mock.ctrl.T.Helper()
	varargs := append([]any{id}, options...)
	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTargetBranchRule", reflect.TypeOf((*MockProjectsServiceInterface)(nil).DeleteTargetBranchRule), varargs...)
	return &MockProjectsServiceInterfaceDeleteTargetBranchRuleCall{Call: call}
}

// MockProjectsServiceInterfaceDeleteTargetBranchRuleCall wrap *gomock.Call
type MockProjectsServiceInterfaceDeleteTargetBranchRuleCall struct {
	*gomock.Call
}

// Return rewrite *gomock.Call.Return
func (c *MockProjectsServiceInterfaceDeleteTargetBranchRuleCall) Return(arg0 *gitlab.Response, arg1 error) *MockProjectsServiceInterfaceDeleteTargetBranchRuleCall {
	c.Call = c.Call.Return(arg0, arg1)
	return c
}

// Do rewrite *gomock.Call.Do
func (c *MockProjectsServiceInterfaceDeleteTargetBranchRuleCall) Do(f func(int64, ...gitlab.RequestOptionFunc) (*gitlab.Response, error)) *MockProjectsServiceInterfaceDeleteTargetBranchRuleCall {
	c.Call = c.Call.Do(f)
	return c
}

// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockProjectsServiceInterfaceDeleteTargetBranchRuleCall) DoAndReturn(f func(int64, ...gitlab.RequestOptionFunc) (*gitlab.Response, error)) *MockProjectsServiceInterfaceDeleteTargetBranchRuleCall {
	c.Call = c.Call.DoAndReturn(f)
	return c
}

// DownloadAvatar mocks base method.
func (m *MockProjectsServiceInterface) DownloadAvatar(pid any, options ...gitlab.RequestOptionFunc) (*bytes.Reader, *gitlab.Response, error) {
	m.ctrl.T.Helper()
@@ -1563,6 +1652,51 @@ func (c *MockProjectsServiceInterfaceListProjectStarrersCall) DoAndReturn(f func
	return c
}

// ListProjectTargetBranchRules mocks base method.
func (m *MockProjectsServiceInterface) ListProjectTargetBranchRules(projectFullPath string, options ...gitlab.RequestOptionFunc) ([]gitlab.TargetBranchRule, *gitlab.Response, error) {
	m.ctrl.T.Helper()
	varargs := []any{projectFullPath}
	for _, a := range options {
		varargs = append(varargs, a)
	}
	ret := m.ctrl.Call(m, "ListProjectTargetBranchRules", varargs...)
	ret0, _ := ret[0].([]gitlab.TargetBranchRule)
	ret1, _ := ret[1].(*gitlab.Response)
	ret2, _ := ret[2].(error)
	return ret0, ret1, ret2
}

// ListProjectTargetBranchRules indicates an expected call of ListProjectTargetBranchRules.
func (mr *MockProjectsServiceInterfaceMockRecorder) ListProjectTargetBranchRules(projectFullPath any, options ...any) *MockProjectsServiceInterfaceListProjectTargetBranchRulesCall {
	mr.mock.ctrl.T.Helper()
	varargs := append([]any{projectFullPath}, options...)
	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListProjectTargetBranchRules", reflect.TypeOf((*MockProjectsServiceInterface)(nil).ListProjectTargetBranchRules), varargs...)
	return &MockProjectsServiceInterfaceListProjectTargetBranchRulesCall{Call: call}
}

// MockProjectsServiceInterfaceListProjectTargetBranchRulesCall wrap *gomock.Call
type MockProjectsServiceInterfaceListProjectTargetBranchRulesCall struct {
	*gomock.Call
}

// Return rewrite *gomock.Call.Return
func (c *MockProjectsServiceInterfaceListProjectTargetBranchRulesCall) Return(arg0 []gitlab.TargetBranchRule, arg1 *gitlab.Response, arg2 error) *MockProjectsServiceInterfaceListProjectTargetBranchRulesCall {
	c.Call = c.Call.Return(arg0, arg1, arg2)
	return c
}

// Do rewrite *gomock.Call.Do
func (c *MockProjectsServiceInterfaceListProjectTargetBranchRulesCall) Do(f func(string, ...gitlab.RequestOptionFunc) ([]gitlab.TargetBranchRule, *gitlab.Response, error)) *MockProjectsServiceInterfaceListProjectTargetBranchRulesCall {
	c.Call = c.Call.Do(f)
	return c
}

// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockProjectsServiceInterfaceListProjectTargetBranchRulesCall) DoAndReturn(f func(string, ...gitlab.RequestOptionFunc) ([]gitlab.TargetBranchRule, *gitlab.Response, error)) *MockProjectsServiceInterfaceListProjectTargetBranchRulesCall {
	c.Call = c.Call.DoAndReturn(f)
	return c
}

// ListProjects mocks base method.
func (m *MockProjectsServiceInterface) ListProjects(opt *gitlab.ListProjectsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Project, *gitlab.Response, error) {
	m.ctrl.T.Helper()