Repository: xanzy/go-gitlab
Pull request #1528 (2022-08-17 14:38:40)

feat: add support for project feature flags
As discussed in https://github.com/xanzy/go-gitlab/issues/1526



Patch:
diff --git a/event_webhook_types.go b/event_webhook_types.go
index f3900dc..a0162e0 100644
--- a/event_webhook_types.go
+++ b/event_webhook_types.go
@@ -537,10 +537,6 @@ type MergeEvent struct {
 			Previous []*EventUser `json:"previous"`
 			Current  []*EventUser `json:"current"`
 		} `json:"assignees"`
-		Reviewers struct {
-			Previous []*EventUser `json:"previous"`
-			Current  []*EventUser `json:"current"`
-		} `json:"reviewers"`
 		Description struct {
 			Previous string `json:"previous"`
 			Current  string `json:"current"`
diff --git a/gitlab.go b/gitlab.go
index 8fb8869..033bbcf 100644
--- a/gitlab.go
+++ b/gitlab.go
@@ -171,6 +171,7 @@ type Client struct {
 	ProjectAccessTokens     *ProjectAccessTokensService
 	ProjectBadges           *ProjectBadgesService
 	ProjectCluster          *ProjectClustersService
+	ProjectFeatureFlags     *ProjectFeatureFlagService
 	ProjectImportExport     *ProjectImportExportService
 	ProjectIterations       *ProjectIterationsService
 	ProjectMembers          *ProjectMembersService
@@ -374,6 +375,7 @@ func newClient(options ...ClientOptionFunc) (*Client, error) {
 	c.ProjectAccessTokens = &ProjectAccessTokensService{client: c}
 	c.ProjectBadges = &ProjectBadgesService{client: c}
 	c.ProjectCluster = &ProjectClustersService{client: c}
+	c.ProjectFeatureFlags = &ProjectFeatureFlagService{client: c}
 	c.ProjectImportExport = &ProjectImportExportService{client: c}
 	c.ProjectIterations = &ProjectIterationsService{client: c}
 	c.ProjectMembers = &ProjectMembersService{client: c}
diff --git a/group_hooks.go b/group_hooks.go
index 6e75d6a..acb51d9 100644
--- a/group_hooks.go
+++ b/group_hooks.go
@@ -47,22 +47,17 @@ type GroupHook struct {
 	CreatedAt                *time.Time `json:"created_at"`
 }
 
-// ListGroupHooksOptions represents the available ListGroupHooks() options.
-//
-// GitLab API docs: https://docs.gitlab.com/ce/api/groups.html#list-group-hooks
-type ListGroupHooksOptions ListOptions
-
 // ListGroupHooks gets a list of group hooks.
 //
 // GitLab API docs: https://docs.gitlab.com/ce/api/groups.html#list-group-hooks
-func (s *GroupsService) ListGroupHooks(gid interface{}, opt *ListGroupHooksOptions, options ...RequestOptionFunc) ([]*GroupHook, *Response, error) {
+func (s *GroupsService) ListGroupHooks(gid interface{}, options ...RequestOptionFunc) ([]*GroupHook, *Response, error) {
 	group, err := parseID(gid)
 	if err != nil {
 		return nil, nil, err
 	}
 	u := fmt.Sprintf("groups/%s/hooks", PathEscape(group))
 
-	req, err := s.client.NewRequest(http.MethodGet, u, opt, options)
+	req, err := s.client.NewRequest(http.MethodGet, u, nil, options)
 	if err != nil {
 		return nil, nil, err
 	}
diff --git a/groups.go b/groups.go
index 96a1e3a..cfd9ef5 100644
--- a/groups.go
+++ b/groups.go
@@ -75,7 +75,6 @@ type Group struct {
 	LDAPCN                         string           `json:"ldap_cn"`
 	LDAPAccess                     AccessLevelValue `json:"ldap_access"`
 	LDAPGroupLinks                 []*LDAPGroupLink `json:"ldap_group_links"`
-	SAMLGroupLinks                 []*SAMLGroupLink `json:"saml_group_links"`
 	SharedRunnersMinutesLimit      int              `json:"shared_runners_minutes_limit"`
 	ExtraSharedRunnersMinutesLimit int              `json:"extra_shared_runners_minutes_limit"`
 	PreventForkingOutsideGroup     bool             `json:"prevent_forking_outside_group"`
@@ -101,14 +100,6 @@ type LDAPGroupLink struct {
 	Provider    string           `json:"provider"`
 }
 
-// SAMLGroupLink represents a GitLab SAML group link.
-//
-// GitLab API docs: https://docs.gitlab.com/ce/api/groups.html#saml-group-links
-type SAMLGroupLink struct {
-	Name        string           `json:"name"`
-	AccessLevel AccessLevelValue `json:"access_level"`
-}
-
 // ListGroupsOptions represents the available ListGroups() options.
 //
 // GitLab API docs: https://docs.gitlab.com/ce/api/groups.html#list-project-groups
@@ -753,113 +744,6 @@ func (s *GroupsService) DeleteGroupLDAPLinkForProvider(gid interface{}, provider
 	return s.client.Do(req, nil)
 }
 
-// ListGroupSAMLLinks lists the group's SAML links. Available only for users who
-// can edit groups.
-//
-// GitLab API docs:
-// https://docs.gitlab.com/ee/api/groups.html#list-saml-group-links
-func (s *GroupsService) ListGroupSAMLLinks(gid interface{}, options ...RequestOptionFunc) ([]*SAMLGroupLink, *Response, error) {
-	group, err := parseID(gid)
-	if err != nil {
-		return nil, nil, err
-	}
-	u := fmt.Sprintf("groups/%s/saml_group_links", PathEscape(group))
-
-	req, err := s.client.NewRequest(http.MethodGet, u, nil, options)
-	if err != nil {
-		return nil, nil, err
-	}
-
-	var gl []*SAMLGroupLink
-	resp, err := s.client.Do(req, &gl)
-	if err != nil {
-		return nil, resp, err
-	}
-
-	return gl, resp, nil
-}
-
-// GetGroupSAMLLink get a specific group SAML link. Available only for users who
-// can edit groups.
-//
-// GitLab API docs:
-// https://docs.gitlab.com/ee/api/groups.html#get-saml-group-link
-func (s *GroupsService) GetGroupSAMLLink(gid interface{}, samlGroupName string, options ...RequestOptionFunc) (*SAMLGroupLink, *Response, error) {
-	group, err := parseID(gid)
-	if err != nil {
-		return nil, nil, err
-	}
-	u := fmt.Sprintf("groups/%s/saml_group_links/%s", PathEscape(group), PathEscape(samlGroupName))
-
-	req, err := s.client.NewRequest(http.MethodGet, u, nil, options)
-	if err != nil {
-		return nil, nil, err
-	}
-
-	gl := new(SAMLGroupLink)
-	resp, err := s.client.Do(req, &gl)
-	if err != nil {
-		return nil, resp, err
-	}
-
-	return gl, resp, nil
-}
-
-// AddGroupSAMLLinkOptions represents the available AddGroupSAMLLink() options.
-//
-// GitLab API docs:
-// https://docs.gitlab.com/ee/api/groups.html#add-saml-group-link
-type AddGroupSAMLLinkOptions struct {
-	SAMLGroupName *string           `url:"saml_group_name,omitempty" json:"saml_group_name,omitempty"`
-	AccessLevel   *AccessLevelValue `url:"access_level,omitempty" json:"access_level,omitempty"`
-}
-
-// AddGroupSAMLLink creates a new group SAML link. Available only for users who
-// can edit groups.
-//
-// GitLab API docs:
-// https://docs.gitlab.com/ee/api/groups.html#add-saml-group-link
-func (s *GroupsService) AddGroupSAMLLink(gid interface{}, opt *AddGroupSAMLLinkOptions, options ...RequestOptionFunc) (*SAMLGroupLink, *Response, error) {
-	group, err := parseID(gid)
-	if err != nil {
-		return nil, nil, err
-	}
-	u := fmt.Sprintf("groups/%s/saml_group_links", PathEscape(group))
-
-	req, err := s.client.NewRequest(http.MethodPost, u, opt, options)
-	if err != nil {
-		return nil, nil, err
-	}
-
-	gl := new(SAMLGroupLink)
-	resp, err := s.client.Do(req, &gl)
-	if err != nil {
-		return nil, resp, err
-	}
-
-	return gl, resp, err
-}
-
-// DeleteGroupSAMLLink deletes a group SAML link. Available only for users who
-// can edit groups.
-//
-// GitLab API docs:
-// https://docs.gitlab.com/ee/api/groups.html#delete-saml-group-link
-func (s *GroupsService) DeleteGroupSAMLLink(gid interface{}, samlGroupName string, options ...RequestOptionFunc) (*Response, error) {
-	group, err := parseID(gid)
-	if err != nil {
-		return nil, err
-	}
-	u := fmt.Sprintf("groups/%s/saml_group_links/%s", PathEscape(group), PathEscape(samlGroupName))
-
-	req, err := s.client.NewRequest(http.MethodDelete, u, nil, options)
-	if err != nil {
-		return nil, err
-	}
-
-	return s.client.Do(req, nil)
-}
-
 // ShareGroupWithGroupOptions represents the available ShareGroupWithGroup() options.
 //
 // GitLab API docs:
diff --git a/issue_links.go b/issue_links.go
index 4fe81ff..f40b65c 100644
--- a/issue_links.go
+++ b/issue_links.go
@@ -45,12 +45,12 @@ type IssueLink struct {
 //
 // GitLab API docs:
 // https://docs.gitlab.com/ee/api/issue_links.html#list-issue-relations
-func (s *IssueLinksService) ListIssueRelations(pid interface{}, issue int, options ...RequestOptionFunc) ([]*Issue, *Response, error) {
+func (s *IssueLinksService) ListIssueRelations(pid interface{}, issueIID int, options ...RequestOptionFunc) ([]*Issue, *Response, error) {
 	project, err := parseID(pid)
 	if err != nil {
 		return nil, nil, err
 	}
-	u := fmt.Sprintf("projects/%s/issues/%d/links", PathEscape(project), issue)
+	u := fmt.Sprintf("projects/%s/issues/%d/links", PathEscape(project), issueIID)
 
 	req, err := s.client.NewRequest(http.MethodGet, u, nil, options)
 	if err != nil {
@@ -66,31 +66,6 @@ func (s *IssueLinksService) ListIssueRelations(pid interface{}, issue int, optio
 	return is, resp, err
 }
 
-// GetIssueLink gets a specific issue link.
-//
-// GitLab API docs:
-// https://docs.gitlab.com/ee/api/issue_links.html#get-an-issue-link
-func (s *IssueLinksService) GetIssueLink(pid interface{}, issue, issueLink int, options ...RequestOptionFunc) (*IssueLink, *Response, error) {
-	project, err := parseID(pid)
-	if err != nil {
-		return nil, nil, err
-	}
-	u := fmt.Sprintf("projects/%s/issues/%d/links/%d", PathEscape(project), issue, issueLink)
-
-	req, err := s.client.NewRequest(http.MethodGet, u, nil, options)
-	if err != nil {
-		return nil, nil, err
-	}
-
-	il := new(IssueLink)
-	resp, err := s.client.Do(req, il)
-	if err != nil {
-		return nil, resp, err
-	}
-
-	return il, resp, err
-}
-
 // CreateIssueLinkOptions represents the available CreateIssueLink() options.
 //
 // GitLab API docs: https://docs.gitlab.com/ee/api/issue_links.html
@@ -105,12 +80,12 @@ type CreateIssueLinkOptions struct {
 //
 // GitLab API docs:
 // https://docs.gitlab.com/ee/api/issue_links.html#create-an-issue-link
-func (s *IssueLinksService) CreateIssueLink(pid interface{}, issue int, opt *CreateIssueLinkOptions, options ...RequestOptionFunc) (*IssueLink, *Response, error) {
+func (s *IssueLinksService) CreateIssueLink(pid interface{}, issueIID int, opt *CreateIssueLinkOptions, options ...RequestOptionFunc) (*IssueLink, *Response, error) {
 	project, err := parseID(pid)
 	if err != nil {
 		return nil, nil, err
 	}
-	u := fmt.Sprintf("projects/%s/issues/%d/links", PathEscape(project), issue)
+	u := fmt.Sprintf("projects/%s/issues/%d/links", PathEscape(project), issueIID)
 
 	req, err := s.client.NewRequest(http.MethodPost, u, opt, options)
 	if err != nil {
@@ -130,26 +105,26 @@ func (s *IssueLinksService) CreateIssueLink(pid interface{}, issue int, opt *Cre
 //
 // GitLab API docs:
 // https://docs.gitlab.com/ee/api/issue_links.html#delete-an-issue-link
-func (s *IssueLinksService) DeleteIssueLink(pid interface{}, issue, issueLink int, options ...RequestOptionFunc) (*IssueLink, *Response, error) {
+func (s *IssueLinksService) DeleteIssueLink(pid interface{}, issueIID, issueLinkID int, options ...RequestOptionFunc) (*IssueLink, *Response, error) {
 	project, err := parseID(pid)
 	if err != nil {
 		return nil, nil, err
 	}
 	u := fmt.Sprintf("projects/%s/issues/%d/links/%d",
 		PathEscape(project),
-		issue,
-		issueLink)
+		issueIID,
+		issueLinkID)
 
 	req, err := s.client.NewRequest(http.MethodDelete, u, nil, options)
 	if err != nil {
 		return nil, nil, err
 	}
 
-	il := new(IssueLink)
-	resp, err := s.client.Do(req, &il)
+	i := new(IssueLink)
+	resp, err := s.client.Do(req, &i)
 	if err != nil {
 		return nil, resp, err
 	}
 
-	return il, resp, err
+	return i, resp, err
 }
diff --git a/project_feature_flags.go b/project_feature_flags.go
new file mode 100644
index 0000000..126c15f
--- /dev/null
+++ b/project_feature_flags.go
@@ -0,0 +1,210 @@
+package gitlab
+
+import (
+    "fmt"
+    "net/http"
+    "time"
+)
+
+// ProjectFeatureFlagService handles operations on gitlab project feature flags using the following api:
+// GitLab API docs: https://docs.gitlab.com/ee/api/feature_flags.html
+type ProjectFeatureFlagService struct {
+    client *Client
+}
+
+// ProjectFeatureFlag represents a GitLab project iteration.
+//
+// GitLab API docs: https://docs.gitlab.com/ee/api/feature_flags.html
+type ProjectFeatureFlag struct {
+    Name        string                       `json:"name"`
+    Description string                       `json:"description"`
+    Active      bool                         `json:"active"`
+    Version     string                       `json:"version"`
+    CreatedAt   *time.Time                   `json:"created_at"`
+    UpdatedAt   *time.Time                   `json:"updated_at"`
+    Scopes      []ProjectFeatureFlagScope    `json:"scopes"`
+    Strategies  []ProjectFeatureFlagStrategy `json:"strategies"`
+}
+
+// ProjectFeatureFlagScope defines the scopes of a feature flag
+type ProjectFeatureFlagScope struct {
+    ID               int    `json:"id"`
+    EnvironmentScope string `json:"environment_scope"`
+}
+
+// ProjectFeatureFlagStrategy defines the strategy used for a feature flag
+type ProjectFeatureFlagStrategy struct {
+    ID         int                                 `json:"id"`
+    Name       string                              `json:"name"`
+    Parameters ProjectFeatureFlagStrategyParameter `json:"parameters"`
+    Scopes     []ProjectFeatureFlagScope           `json:"scopes"`
+}
+
+// ProjectFeatureFlagStrategyParameter is used in updating and creating feature flags
+type ProjectFeatureFlagStrategyParameter struct {
+    GroupID    *string `json:"groupId,omitempty"`
+    UserIDs    *string `json:"userIds,omitempty"`
+    Percentage *string `json:"percentage,omitempty"`
+}
+
+// UpdateProjectFeatureFlagOptions is used to specify the values when updating feature flags
+type UpdateProjectFeatureFlagOptions struct {
+    Name        string  `json:"name,omitempty"`
+    Description *string `json:"description,omitempty"`
+    Active      *bool   `json:"active,omitempty"`
+    Strategies  []*FeatureFlagStrategy `json:"strategies,omitempty"`
+}
+
+
+// FeatureFlagStrategy represents a strategy for a feature flag
+type FeatureFlagStrategy struct {
+    Name       string                              `json:"Name,omitempty"`
+    Parameters ProjectFeatureFlagStrategyParameter `json:"parameters,omitempty"`
+    Scopes     []ProjectFeatureFlagScope           `json:"scopes,omitempty"`
+}
+
+// CreateProjectFeatureFlagOptions contains the options that can be specified when creating feature flags
+type CreateProjectFeatureFlagOptions struct {
+    Name        string  `json:"name"`
+    Description *string `json:"description,omitempty"`
+    Version     *string `json:"version,omitempty"`
+    Active      *bool   `json:"active,omitempty"`
+    Strategies  []*FeatureFlagStrategy `json:"strategies,omitempty"`
+}
+
+func (i ProjectFeatureFlag) String() string {
+    return Stringify(i)
+}
+
+// ListProjectFeatureFlagOptions contains the options for ListProjectFeatureFlags
+//
+// GitLab API docs:
+// https://docs.gitlab.com/ee/api/feature_flags.html#list-feature-flags-for-a-project
+type ListProjectFeatureFlagOptions struct {
+    ListOptions
+    Scope *string `url:"scope,omitempty" json:"scope,omitempty"`
+}
+
+// ListProjectFeatureFlags returns a list with the feature flags of a project.
+//
+// GitLab API docs:
+// https://docs.gitlab.com/ee/api/feature_flags.html#list-feature-flags-for-a-project
+func (s *ProjectFeatureFlagService) ListProjectFeatureFlags(pid interface{}, opt *ListProjectFeatureFlagOptions, options ...RequestOptionFunc) ([]*ProjectFeatureFlag, *Response, error) {
+    project, err := parseID(pid)
+    if err != nil {
+        return nil, nil, err
+    }
+    u := fmt.Sprintf("projects/%s/feature_flags", PathEscape(project))
+
+    req, err := s.client.NewRequest(http.MethodGet, u, opt, options)
+    if err != nil {
+        return nil, nil, err
+    }
+
+    var pis []*ProjectFeatureFlag
+    resp, err := s.client.Do(req, &pis)
+    if err != nil {
+        return nil, resp, err
+    }
+
+    return pis, resp, err
+}
+
+// GetProjectFeatureFlag gets a single feature flag for the specified project.
+//
+// GitLab API docs:
+// https://docs.gitlab.com/ee/api/feature_flags.html#get-a-single-feature-flag
+func (s *ProjectFeatureFlagService) GetProjectFeatureFlag(pid interface{}, name string, options ...RequestOptionFunc) (*ProjectFeatureFlag, *Response, error) {
+    project, err := parseID(pid)
+    if err != nil {
+        return nil, nil, err
+    }
+    u := fmt.Sprintf("projects/%s/feature_flags/%s", PathEscape(project), name)
+
+    req, err := s.client.NewRequest(http.MethodGet, u, nil, options)
+    if err != nil {
+        return nil, nil, err
+    }
+
+    b := new(ProjectFeatureFlag)
+    resp, err := s.client.Do(req, b)
+    if err != nil {
+        return nil, resp, err
+    }
+
+    return b, resp, err
+}
+
+// CreateProjectFeatureFlag creates a feature flag
+//
+// Gitlab API docs:
+// https://docs.gitlab.com/ee/api/feature_flags.html#create-a-feature-flag
+func (s *ProjectFeatureFlagService) CreateProjectFeatureFlag(pid interface{}, opt *CreateProjectFeatureFlagOptions, options ...RequestOptionFunc) (*ProjectFeatureFlag, *Response, error) {
+    project, err := parseID(pid)
+    if err != nil {
+        return nil, nil, err
+    }
+    u := fmt.Sprintf("projects/%s/feature_flags",
+        PathEscape(project),
+    )
+
+    req, err := s.client.NewRequest(http.MethodPost, u, opt, options)
+    if err != nil {
+        return nil, nil, err
+    }
+
+    var flag ProjectFeatureFlag
+    resp, err := s.client.Do(req, &flag)
+    if err != nil {
+        return &flag, resp, err
+    }
+
+    return &flag, resp, err
+}
+
+// UpdateProjectFeatureFlag updates a feature flag
+//
+// Gitlab API docs:
+// https://docs.gitlab.com/ee/api/feature_flags.html#update-a-feature-flag
+func (s *ProjectFeatureFlagService) UpdateProjectFeatureFlag(pid interface{}, name string, opt *UpdateProjectFeatureFlagOptions, options ...RequestOptionFunc) (*ProjectFeatureFlag, *Response, error) {
+    group, err := parseID(pid)
+    if err != nil {
+        return nil, nil, err
+    }
+    u := fmt.Sprintf("projects/%s/feature_flags/%s",
+        PathEscape(group),
+        name,
+    )
+
+    req, err := s.client.NewRequest(http.MethodPut, u, opt, options)
+    if err != nil {
+        return nil, nil, err
+    }
+
+    var flag ProjectFeatureFlag
+    resp, err := s.client.Do(req, &flag)
+    if err != nil {
+        return &flag, resp, err
+    }
+
+    return &flag, resp, err
+}
+
+// DeleteProjectFeatureFlag deletes a feature flag
+//
+// Gitlab API docs:
+// https://docs.gitlab.com/ee/api/feature_flags.html#delete-a-feature-flag
+func (s *ProjectFeatureFlagService) DeleteProjectFeatureFlag(pid interface{}, name string, options ...RequestOptionFunc) (*Response, error) {
+    project, err := parseID(pid)
+    if err != nil {
+        return nil, err
+    }
+    u := fmt.Sprintf("projects/%s/feature_flags/%s", PathEscape(project), name)
+
+    req, err := s.client.NewRequest(http.MethodDelete, u, nil, options)
+    if err != nil {
+        return nil, err
+    }
+
+    return s.client.Do(req, nil)
+}
diff --git a/users.go b/users.go
index d03e7e1..b170e28 100644
--- a/users.go
+++ b/users.go
@@ -117,7 +117,6 @@ type ListUsersOptions struct {
 	Active          *bool `url:"active,omitempty" json:"active,omitempty"`
 	Blocked         *bool `url:"blocked,omitempty" json:"blocked,omitempty"`
 	ExcludeInternal *bool `url:"exclude_internal,omitempty" json:"exclude_internal,omitempty"`
-	ExcludeExternal *bool `url:"exclude_external,omitempty" json:"exclude_external,omitempty"`
 
 	// The options below are only available for admins.
 	Search               *string    `url:"search,omitempty" json:"search,omitempty"`


Test patch:
diff --git a/event_webhook_types_test.go b/event_webhook_types_test.go
index a61a0f9..5ac45d0 100644
--- a/event_webhook_types_test.go
+++ b/event_webhook_types_test.go
@@ -234,18 +234,6 @@ func TestMergeEventUnmarshal(t *testing.T) {
 	if event.Assignees[0].Email != expectedEmail {
 		t.Errorf("Assignees[0].Email is %v, want %v", event.Assignees[0].Email, expectedEmail)
 	}
-
-	if len(event.Changes.Reviewers.Previous) != 0 {
-		t.Errorf("Changes.Reviewer.Previeous length is %d, want %d", len(event.Changes.Reviewers.Previous), 0)
-	}
-
-	if len(event.Changes.Reviewers.Current) != 1 {
-		t.Errorf("Changes.Reviewer.Current length is %d, want %d", len(event.Changes.Reviewers.Current), 1)
-	}
-
-	if event.Changes.Reviewers.Current[0].Username != expectedUsername {
-		t.Errorf("Changes.Reviewer.Current[0].Username is %v, want %v", event.Changes.Reviewers.Current[0].Username, expectedUsername)
-	}
 }
 
 func TestMergeEventUnmarshalFromGroup(t *testing.T) {
diff --git a/group_hooks_test.go b/group_hooks_test.go
index 6834231..07775c8 100644
--- a/group_hooks_test.go
+++ b/group_hooks_test.go
@@ -55,7 +55,7 @@ func TestListGroupHooks(t *testing.T) {
 ]`)
 	})
 
-	groupHooks, _, err := client.Groups.ListGroupHooks(1, nil)
+	groupHooks, _, err := client.Groups.ListGroupHooks(1)
 	if err != nil {
 		t.Error(err)
 	}
diff --git a/groups_test.go b/groups_test.go
index 0ab7b6b..8a37a43 100644
--- a/groups_test.go
+++ b/groups_test.go
@@ -364,106 +364,6 @@ func TestAddGroupLDAPLinkFilter(t *testing.T) {
 	}
 }
 
-func TestListGroupSAMLLinks(t *testing.T) {
-	mux, server, client := setup(t)
-	defer teardown(server)
-
-	mux.HandleFunc("/api/v4/groups/1/saml_group_links",
-		func(w http.ResponseWriter, r *http.Request) {
-			testMethod(t, r, http.MethodGet)
-			fmt.Fprint(w, `[
-	{
-		"access_level":30,
-		"name":"gitlab_group_example_developer"
-	},
-	{
-		"access_level":40,
-		"name":"gitlab_group_example_maintainer"
-	}
-]`)
-		})
-
-	links, _, err := client.Groups.ListGroupSAMLLinks(1)
-	if err != nil {
-		t.Errorf("Groups.ListGroupSAMLLinks returned error: %v", err)
-	}
-
-	want := []*SAMLGroupLink{
-		{
-			AccessLevel: DeveloperPermissions,
-			Name:        "gitlab_group_example_developer",
-		},
-		{
-			AccessLevel: MaintainerPermissions,
-			Name:        "gitlab_group_example_maintainer",
-		},
-	}
-	if !reflect.DeepEqual(want, links) {
-		t.Errorf("Groups.ListGroupSAMLLinks returned %+v, want %+v", links, want)
-	}
-}
-
-func TestGetGroupSAMLLink(t *testing.T) {
-	mux, server, client := setup(t)
-	defer teardown(server)
-
-	mux.HandleFunc("/api/v4/groups/1/saml_group_links/gitlab_group_example_developer",
-		func(w http.ResponseWriter, r *http.Request) {
-			testMethod(t, r, http.MethodGet)
-			fmt.Fprint(w, `
-{
-	"access_level":30,
-	"name":"gitlab_group_example_developer"
-}`)
-		})
-
-	links, _, err := client.Groups.GetGroupSAMLLink(1, "gitlab_group_example_developer")
-	if err != nil {
-		t.Errorf("Groups.GetGroupSAMLLinks returned error: %v", err)
-	}
-
-	want := &SAMLGroupLink{
-		AccessLevel: DeveloperPermissions,
-		Name:        "gitlab_group_example_developer",
-	}
-	if !reflect.DeepEqual(want, links) {
-		t.Errorf("Groups.GetGroupSAMLLink returned %+v, want %+v", links, want)
-	}
-}
-
-func TestAddGroupSAMLLink(t *testing.T) {
-	mux, server, client := setup(t)
-	defer teardown(server)
-
-	mux.HandleFunc("/api/v4/groups/1/saml_group_links",
-		func(w http.ResponseWriter, r *http.Request) {
-			testMethod(t, r, http.MethodPost)
-			fmt.Fprint(w, `
-{
-	"access_level":30,
-	"name":"gitlab_group_example_developer"
-}`)
-		})
-
-	opt := &AddGroupSAMLLinkOptions{
-		SAMLGroupName: String("gitlab_group_example_developer"),
-		AccessLevel:   AccessLevel(DeveloperPermissions),
-	}
-
-	link, _, err := client.Groups.AddGroupSAMLLink(1, opt)
-	if err != nil {
-		t.Errorf("Groups.AddGroupSAMLLink returned error: %v", err)
-	}
-
-	want := &SAMLGroupLink{
-		AccessLevel: DeveloperPermissions,
-		Name:        "gitlab_group_example_developer",
-	}
-	if !reflect.DeepEqual(want, link) {
-		t.Errorf("Groups.AddGroupSAMLLink returned %+v, want %+v", link, want)
-	}
-}
-
 func TestRestoreGroup(t *testing.T) {
 	mux, server, client := setup(t)
 	defer teardown(server)
diff --git a/project_feature_flags_test.go b/project_feature_flags_test.go
new file mode 100644
index 0000000..da1eea8
--- /dev/null
+++ b/project_feature_flags_test.go
@@ -0,0 +1,239 @@
+package gitlab
+
+import (
+    "net/http"
+    "testing"
+    "time"
+
+    "github.com/stretchr/testify/assert"
+)
+
+func TestListProjectFeatureFlags(t *testing.T) {
+    mux, server, client := setup(t)
+    defer teardown(server)
+
+    mux.HandleFunc("/api/v4/projects/333/feature_flags",
+        func(w http.ResponseWriter, r *http.Request) {
+            testMethod(t, r, http.MethodGet)
+            mustWriteHTTPResponse(t, w, "testdata/list_project_feature_flags.json")
+        })
+
+    actual, _, err := client.ProjectFeatureFlags.ListProjectFeatureFlags(333, &ListProjectFeatureFlagOptions{})
+    if err != nil {
+        t.Errorf("ProjectFeatureFlags.ListProjectFeatureFlags returned error: %v", err)
+        return
+    }
+
+    createdAt1 := time.Date(2019, 11, 4, 8, 13, 51, 0, time.UTC)
+    updatedAt1 := time.Date(2019, 11, 4, 8, 13, 11, 0, time.UTC)
+
+    createdAt2 := time.Date(2019, 11, 4, 8, 13, 10, 0, time.UTC)
+    updatedAt2 := time.Date(2019, 11, 4, 8, 13, 10, 0, time.UTC)
+
+    expected := []*ProjectFeatureFlag{
+        {
+            Name:        "merge_train",
+            Description: "This feature is about merge train",
+            Active:      true,
+            Version:     "new_version_flag",
+            CreatedAt:   &createdAt1,
+            UpdatedAt:   &updatedAt1,
+            Scopes:      []ProjectFeatureFlagScope{},
+            Strategies: []ProjectFeatureFlagStrategy{
+                {
+                    ID:   1,
+                    Name: "userWithId",
+                    Parameters: ProjectFeatureFlagStrategyParameter{
+                        UserIDs: String("user1"),
+                    },
+                    Scopes: []ProjectFeatureFlagScope{
+                        {
+                            ID:               1,
+                            EnvironmentScope: "production",
+                        },
+                    },
+                },
+            },
+        },
+        {
+            Name:        "new_live_trace",
+            Description: "This is a new live trace feature",
+            Active:      true,
+            Version:     "new_version_flag",
+            CreatedAt:   &createdAt2,
+            UpdatedAt:   &updatedAt2,
+            Scopes:      []ProjectFeatureFlagScope{},
+            Strategies: []ProjectFeatureFlagStrategy{
+                {
+                    ID:   2,
+                    Name: "default",
+                    Scopes: []ProjectFeatureFlagScope{
+                        {
+                            ID:               2,
+                            EnvironmentScope: "staging",
+                        },
+                    },
+                },
+            },
+        },
+    }
+
+    assert.Equal(t, len(expected), len(actual))
+    for i := range expected {
+        assert.Equal(t, expected[i], actual[i])
+    }
+}
+
+func TestGetProjectFeatureFlag(t *testing.T) {
+    mux, server, client := setup(t)
+    defer teardown(server)
+
+    mux.HandleFunc("/api/v4/projects/1/feature_flags/testing", func(w http.ResponseWriter, r *http.Request) {
+        testMethod(t, r, http.MethodGet)
+        mustWriteHTTPResponse(t, w, "testdata/get_project_feature_flag.json")
+    })
+
+    actual, resp, err := client.ProjectFeatureFlags.GetProjectFeatureFlag(1, "testing")
+    if err != nil {
+        t.Fatalf("ProjectFeatureFlags.GetProjectFeatureFlag returned error: %v, response %v", err, resp)
+    }
+
+    date := time.Date(2020, 05, 13, 19, 56, 33, 0, time.UTC)
+    expected := &ProjectFeatureFlag{
+        Name:      "awesome_feature",
+        Active:    true,
+        Version:   "new_version_flag",
+        CreatedAt: &date,
+        UpdatedAt: &date,
+        Scopes:    []ProjectFeatureFlagScope{},
+        Strategies: []ProjectFeatureFlagStrategy{
+            {
+                ID:         36,
+                Name:       "default",
+                Parameters: ProjectFeatureFlagStrategyParameter{},
+                Scopes: []ProjectFeatureFlagScope{
+                    {
+                        ID:               37,
+                        EnvironmentScope: "production",
+                    },
+                },
+            },
+        },
+    }
+
+    assert.Equal(t, expected, actual)
+}
+
+func TestCreateProjectFeatureFlag(t *testing.T) {
+    mux, server, client := setup(t)
+    defer teardown(server)
+
+    mux.HandleFunc("/api/v4/projects/1/feature_flags/testing", func(w http.ResponseWriter, r *http.Request) {
+        testMethod(t, r, http.MethodPut)
+        mustWriteHTTPResponse(t, w, "testdata/create_project_feature_flag.json")
+    })
+
+    actual, _, err := client.ProjectFeatureFlags.UpdateProjectFeatureFlag(1, "testing", &UpdateProjectFeatureFlagOptions{})
+    if err != nil {
+        t.Errorf("ProjectFeatureFlags.UpdateProjectFeatureFlag returned error: %v", err)
+        return
+    }
+
+    createdAt := time.Date(2020, 5, 13, 19, 56, 33, 0, time.UTC)
+    updatedAt := time.Date(2020, 5, 13, 19, 56, 33, 0, time.UTC)
+
+    expected := &ProjectFeatureFlag{
+        Name:      "awesome_feature",
+        Active:    true,
+        Version:   "new_version_flag",
+        CreatedAt: &createdAt,
+        UpdatedAt: &updatedAt,
+        Scopes:    []ProjectFeatureFlagScope{},
+        Strategies: []ProjectFeatureFlagStrategy{
+            {
+                ID:         36,
+                Name:       "default",
+                Parameters: ProjectFeatureFlagStrategyParameter{},
+                Scopes: []ProjectFeatureFlagScope{
+                    {
+                        ID:               37,
+                        EnvironmentScope: "production",
+                    },
+                },
+            },
+        },
+    }
+
+    assert.Equal(t, expected, actual)
+}
+
+func TestUpdateProjectFeatureFlag(t *testing.T) {
+    mux, server, client := setup(t)
+    defer teardown(server)
+
+    mux.HandleFunc("/api/v4/projects/1/feature_flags/testing", func(w http.ResponseWriter, r *http.Request) {
+        testMethod(t, r, http.MethodPut)
+        mustWriteHTTPResponse(t, w, "testdata/update_project_feature_flag.json")
+    })
+
+    actual, _, err := client.ProjectFeatureFlags.UpdateProjectFeatureFlag(1, "testing", &UpdateProjectFeatureFlagOptions{})
+    if err != nil {
+        t.Errorf("ProjectFeatureFlags.UpdateProjectFeatureFlag returned error: %v", err)
+        return
+    }
+
+    createdAt := time.Date(2020, 5, 13, 20, 10, 32, 0, time.UTC)
+    updatedAt := time.Date(2020, 5, 13, 20, 10, 32, 0, time.UTC)
+
+    expected := &ProjectFeatureFlag{
+        Name:      "awesome_feature",
+        Active:    true,
+        Version:   "new_version_flag",
+        CreatedAt: &createdAt,
+        UpdatedAt: &updatedAt,
+        Scopes:    []ProjectFeatureFlagScope{},
+        Strategies: []ProjectFeatureFlagStrategy{
+            {
+                ID:   38,
+                Name: "gradualRolloutUserId",
+                Parameters: ProjectFeatureFlagStrategyParameter{
+                    GroupID:    String("default"),
+                    Percentage: String("25"),
+                },
+                Scopes: []ProjectFeatureFlagScope{
+                    {
+                        ID:               40,
+                        EnvironmentScope: "staging",
+                    },
+                },
+            },
+            {
+                ID:   37,
+                Name: "default",
+                Scopes: []ProjectFeatureFlagScope{
+                    {
+                        ID:               39,
+                        EnvironmentScope: "production",
+                    },
+                },
+            },
+        },
+    }
+
+    assert.Equal(t, expected, actual)
+}
+
+func TestDeleteProjectFeatureFlag(t *testing.T) {
+    mux, server, client := setup(t)
+    defer teardown(server)
+
+    mux.HandleFunc("/api/v4/projects/1/feature_flags/testing", func(w http.ResponseWriter, r *http.Request) {
+        testMethod(t, r, http.MethodDelete)
+    })
+
+    _, err := client.ProjectFeatureFlags.DeleteProjectFeatureFlag(1, "testing")
+    if err != nil {
+        t.Errorf("ProjectFeatureFlags.DeleteProjectFeatureFlag returned error: %v", err)
+        return
+    }
+}
diff --git a/testdata/create_project_feature_flag.json b/testdata/create_project_feature_flag.json
new file mode 100644
index 0000000..e460627
--- /dev/null
+++ b/testdata/create_project_feature_flag.json
@@ -0,0 +1,22 @@
+{
+  "name": "awesome_feature",
+  "description": null,
+  "active": true,
+  "version": "new_version_flag",
+  "created_at": "2020-05-13T19:56:33Z",
+  "updated_at": "2020-05-13T19:56:33Z",
+  "scopes": [],
+  "strategies": [
+    {
+      "id": 36,
+      "name": "default",
+      "parameters": {},
+      "scopes": [
+        {
+          "id": 37,
+          "environment_scope": "production"
+        }
+      ]
+    }
+  ]
+}
diff --git a/testdata/get_project_feature_flag.json b/testdata/get_project_feature_flag.json
new file mode 100644
index 0000000..e460627
--- /dev/null
+++ b/testdata/get_project_feature_flag.json
@@ -0,0 +1,22 @@
+{
+  "name": "awesome_feature",
+  "description": null,
+  "active": true,
+  "version": "new_version_flag",
+  "created_at": "2020-05-13T19:56:33Z",
+  "updated_at": "2020-05-13T19:56:33Z",
+  "scopes": [],
+  "strategies": [
+    {
+      "id": 36,
+      "name": "default",
+      "parameters": {},
+      "scopes": [
+        {
+          "id": 37,
+          "environment_scope": "production"
+        }
+      ]
+    }
+  ]
+}
diff --git a/testdata/list_project_feature_flags.json b/testdata/list_project_feature_flags.json
new file mode 100644
index 0000000..660fd2f
--- /dev/null
+++ b/testdata/list_project_feature_flags.json
@@ -0,0 +1,48 @@
+[
+  {
+    "name":"merge_train",
+    "description":"This feature is about merge train",
+    "active": true,
+    "version": "new_version_flag",
+    "created_at":"2019-11-04T08:13:51Z",
+    "updated_at":"2019-11-04T08:13:11Z",
+    "scopes":[],
+    "strategies": [
+      {
+        "id": 1,
+        "name": "userWithId",
+        "parameters": {
+          "userIds": "user1"
+        },
+        "scopes": [
+          {
+            "id": 1,
+            "environment_scope": "production"
+          }
+        ]
+      }
+    ]
+  },
+  {
+    "name":"new_live_trace",
+    "description":"This is a new live trace feature",
+    "active": true,
+    "version": "new_version_flag",
+    "created_at":"2019-11-04T08:13:10Z",
+    "updated_at":"2019-11-04T08:13:10Z",
+    "scopes":[],
+    "strategies": [
+      {
+        "id": 2,
+        "name": "default",
+        "parameters": {},
+        "scopes": [
+          {
+            "id": 2,
+            "environment_scope": "staging"
+          }
+        ]
+      }
+    ]
+  }
+]
diff --git a/testdata/update_project_feature_flag.json b/testdata/update_project_feature_flag.json
new file mode 100644
index 0000000..f66b51a
--- /dev/null
+++ b/testdata/update_project_feature_flag.json
@@ -0,0 +1,36 @@
+{
+  "name": "awesome_feature",
+  "description": null,
+  "active": true,
+  "version": "new_version_flag",
+  "created_at": "2020-05-13T20:10:32Z",
+  "updated_at": "2020-05-13T20:10:32Z",
+  "scopes": [],
+  "strategies": [
+    {
+      "id": 38,
+      "name": "gradualRolloutUserId",
+      "parameters": {
+        "groupId": "default",
+        "percentage": "25"
+      },
+      "scopes": [
+        {
+          "id": 40,
+          "environment_scope": "staging"
+        }
+      ]
+    },
+    {
+      "id": 37,
+      "name": "default",
+      "parameters": {},
+      "scopes": [
+        {
+          "id": 39,
+          "environment_scope": "production"
+        }
+      ]
+    }
+  ]
+}
diff --git a/testdata/webhooks/merge_request.json b/testdata/webhooks/merge_request.json
index 721dd89..c52d560 100644
--- a/testdata/webhooks/merge_request.json
+++ b/testdata/webhooks/merge_request.json
@@ -159,18 +159,6 @@
         "type": "ProjectLabel",
         "group_id": 41
       }]
-    },
-    "reviewers": {
-      "previous": [],
-      "current": [
-        {
-          "id": 1,
-          "name": "User1",
-          "username": "user1",
-          "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40&d=identicon",
-          "email": "[REDACTED]"
-        }
-      ]
     }
   }
 }