diff --git a/cloud/group.go b/cloud/group.go index 38736fae..1b589ae7 100644 --- a/cloud/group.go +++ b/cloud/group.go @@ -21,8 +21,19 @@ type groupMembersResult struct { Members []GroupMember `json:"values"` } +// Response body of https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-groups/#api-rest-api-2-group-member-get +type getGroupMembersResult struct { + IsLast bool `json:"isLast"` + MaxResults int `json:"maxResults"` + NextPage string `json:"nextPage"` + Total int `json:"total"` + StartAt int `json:"startAt"` + Values []GroupMember `json:"values"` +} + // Group represents a Jira group type Group struct { + ID string `json:"groupId,omitempty" structs:"groupId,omitempty"` Name string `json:"name,omitempty" structs:"name,omitempty"` Self string `json:"self,omitempty" structs:"self,omitempty"` Users GroupMembers `json:"users,omitempty" structs:"users,omitempty"` @@ -58,6 +69,12 @@ type GroupSearchOptions struct { IncludeInactiveUsers bool } +type Groups struct { + Groups []Group `json:"groups,omitempty"` + Header string `json:"header,omitempty"` + Total int `json:"total,omitempty"` +} + // Get returns a paginated list of members of the specified group and its subgroups. // Users in the page are ordered by user names. // User of this resource is required to have sysadmin or admin permissions. @@ -68,6 +85,7 @@ type GroupSearchOptions struct { // // TODO Double check this method if this works as expected, is using the latest API and the response is complete // This double check effort is done for v2 - Remove this two lines if this is completed. +// Deprecated: Use GetGroupMembers instead func (s *GroupService) Get(ctx context.Context, name string, options *GroupSearchOptions) ([]GroupMember, *Response, error) { var apiEndpoint string if options == nil { @@ -144,3 +162,120 @@ func (s *GroupService) RemoveUserByGroupName(ctx context.Context, groupName stri return resp, nil } + +// Sets case insensitive search +func WithCaseInsensitive() searchF { + return func(s search) search { + s = append(s, searchParam{name: "caseInsensitive", value: "true"}) + return s + } +} + +// Sets query string for filtering group names. +func WithGroupNameContains(contains string) searchF { + return func(s search) search { + s = append(s, searchParam{name: "query", value: contains}) + return s + } +} + +// Sets excluded group names. +func WithExcludedGroupNames(excluded []string) searchF { + return func(s search) search { + for _, name := range excluded { + s = append(s, searchParam{name: "exclude", value: name}) + } + + return s + } +} + +// Sets excluded group ids. +func WithExcludedGroupsIds(excluded []string) searchF { + return func(s search) search { + for _, id := range excluded { + s = append(s, searchParam{name: "excludeId", value: id}) + } + + return s + } +} + +// Search for the groups +// It can search by groupId, accountId or userName +// Apart from returning groups it also returns total number of groups +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-groups/#api-rest-api-3-groups-picker-get +func (s *GroupService) Find(ctx context.Context, tweaks ...searchF) ([]Group, *Response, error) { + search := []searchParam{} + for _, f := range tweaks { + search = f(search) + } + + apiEndpoint := "/rest/api/3/groups/picker" + + queryString := "" + for _, param := range search { + queryString += fmt.Sprintf("%s=%s&", param.name, param.value) + } + + if queryString != "" { + apiEndpoint += "?" + queryString + } + + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + groups := Groups{} + resp, err := s.client.Do(req, &groups) + if err != nil { + return nil, resp, NewJiraError(resp, err) + } + + return groups.Groups, resp, nil +} + +func WithInactiveUsers() searchF { + return func(s search) search { + s = append(s, searchParam{name: "includeInactiveUsers", value: "true"}) + return s + } +} + +// Search for the group members +// It can filter out inactive users +// Apart from returning group members it also returns total number of group members +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-groups/#api-rest-api-3-group-member-get +func (s *GroupService) GetGroupMembers(ctx context.Context, groupId string, tweaks ...searchF) ([]GroupMember, *Response, error) { + search := []searchParam{} + for _, f := range tweaks { + search = f(search) + } + + apiEndpoint := fmt.Sprintf("/rest/api/3/group/member?groupId=%s", groupId) + + queryString := "" + for _, param := range search { + queryString += fmt.Sprintf("%s=%s&", param.name, param.value) + } + + if queryString != "" { + apiEndpoint += "&" + queryString + } + + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + group := new(getGroupMembersResult) + resp, err := s.client.Do(req, group) + if err != nil { + return nil, resp, NewJiraError(resp, err) + } + + return group.Values, resp, nil +} diff --git a/cloud/group_test.go b/cloud/group_test.go index e37f79e5..690bafc7 100644 --- a/cloud/group_test.go +++ b/cloud/group_test.go @@ -95,3 +95,123 @@ func TestGroupService_Remove(t *testing.T) { t.Errorf("Error given: %s", err) } } + +func TestGroupService_Find_Success(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/3/groups/picker", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/api/3/groups/picker") + + fmt.Fprint(w, `{"header": "Showing 2 of 2 matching groups", + "total": 2, + "groups": [{ + "name": "jdog-developers", + "html": "jdog-developers", + "groupId": "276f955c-63d7-42c8-9520-92d01dca0625" + }, + { + "name": "juvenal-bot", + "html": "juvenal-bot", + "groupId": "6e87dc72-4f1f-421f-9382-2fee8b652487" + }]}`) + }) + + if group, _, err := testClient.Group.Find(context.Background()); err != nil { + t.Errorf("Error given: %s", err) + } else if group == nil { + t.Error("Expected group. Group is nil") + } else if len(group) != 2 { + t.Errorf("Expected 2 groups. Group is %d", len(group)) + } +} + +func TestGroupService_Find_SuccessParams(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/3/groups/picker", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/api/3/groups/picker?maxResults=2&caseInsensitive=true&excludeId=1&excludeId=2&exclude=test&query=test&accountId=123") + + fmt.Fprint(w, `{"header": "Showing 2 of 2 matching groups", + "total": 2, + "groups": [{ + "name": "jdog-developers", + "html": "jdog-developers", + "groupId": "276f955c-63d7-42c8-9520-92d01dca0625" + }, + { + "name": "juvenal-bot", + "html": "juvenal-bot", + "groupId": "6e87dc72-4f1f-421f-9382-2fee8b652487" + }]}`) + }) + + if group, _, err := testClient.Group.Find( + context.Background(), + WithMaxResults(2), + WithCaseInsensitive(), + WithExcludedGroupsIds([]string{"1", "2"}), + WithExcludedGroupNames([]string{"test"}), + WithGroupNameContains("test"), + WithAccountId("123"), + ); err != nil { + t.Errorf("Error given: %s", err) + } else if group == nil { + t.Error("Expected group. Group is nil") + } else if len(group) != 2 { + t.Errorf("Expected 2 groups. Group is %d", len(group)) + } +} + +func TestGroupService_GetGroupMembers_Success(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/api/3/group/member", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, "/rest/api/3/group/member?groupId=1&startAt=0&maxResults=2&includeInactiveUsers=true") + + fmt.Fprint(w, `{ + "self": "https://your-domain.atlassian.net/rest/api/3/group/member?groupname=jira-administrators&includeInactiveUsers=false&startAt=2&maxResults=2", + "nextPage": "https://your-domain.atlassian.net/rest/api/3/group/member?groupname=jira-administrators&includeInactiveUsers=false&startAt=4&maxResults=2", + "maxResults": 2, + "startAt": 3, + "total": 5, + "isLast": false, + "values": [ + { + "self": "https://your-domain.atlassian.net/rest/api/3/user?accountId=5b10a2844c20165700ede21g", + "name": "", + "key": "", + "accountId": "5b10a2844c20165700ede21g", + "emailAddress": "mia@example.com", + "avatarUrls": {}, + "displayName": "Mia", + "active": true, + "timeZone": "Australia/Sydney", + "accountType": "atlassian" + }, + { + "self": "https://your-domain.atlassian.net/rest/api/3/user?accountId=5b10a0effa615349cb016cd8", + "name": "", + "key": "", + "accountId": "5b10a0effa615349cb016cd8", + "emailAddress": "will@example.com", + "avatarUrls": {}, + "displayName": "Will", + "active": false, + "timeZone": "Australia/Sydney", + "accountType": "atlassian" + } + ] + }`) + }) + + if members, _, err := testClient.Group.GetGroupMembers(context.Background(), "1", WithStartAt(0), WithMaxResults(2), WithInactiveUsers()); err != nil { + t.Errorf("Error given: %s", err) + } else if len(members) != 2 { + t.Errorf("Expected 2 members. Members is %d", len(members)) + } else if members[0].AccountID != "5b10a2844c20165700ede21g" { + t.Errorf("Expected 5b10a2844c20165700ede21g. Members[0].AccountId is %s", members[0].AccountID) + } +} diff --git a/cloud/project.go b/cloud/project.go index add12bf9..ecc7c4a9 100644 --- a/cloud/project.go +++ b/cloud/project.go @@ -26,6 +26,17 @@ type ProjectList []struct { IssueTypes []IssueType `json:"issueTypes,omitempty" structs:"issueTypes,omitempty"` } +// Response body of https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-projects/#api-rest-api-2-project-search-get +type searchProjectsResponse struct { + Self string `json:"self,omitempty" structs:"self,omitempty"` + NextPage string `json:"nextPage,omitempty" structs:"nextPage,omitempty"` + MaxResults int `json:"maxResult,omitempty" structs:"maxResults,omitempty"` + StartAt int `json:"startAt,omitempty" structs:"startAt,omitempty"` + Total int `json:"total,omitempty" structs:"total,omitempty"` + IsLast bool `json:"isLast,omitempty" structs:"isLast,omitempty"` + Values []Project `json:"values,omitempty" structs:"values,omitempty"` +} + // ProjectCategory represents a single project category type ProjectCategory struct { Self string `json:"self" structs:"self,omitempty"` @@ -52,6 +63,7 @@ type Project struct { Roles map[string]string `json:"roles,omitempty" structs:"roles,omitempty"` AvatarUrls AvatarUrls `json:"avatarUrls,omitempty" structs:"avatarUrls,omitempty"` ProjectCategory ProjectCategory `json:"projectCategory,omitempty" structs:"projectCategory,omitempty"` + IsPrivate bool `json:"isPrivate,omitempty" structs:"isPrivate,omitempty"` } // ProjectComponent represents a single component of a project @@ -87,6 +99,7 @@ type PermissionScheme struct { // // TODO Double check this method if this works as expected, is using the latest API and the response is complete // This double check effort is done for v2 - Remove this two lines if this is completed. +// DEPRECATED: use Find instead func (s *ProjectService) GetAll(ctx context.Context, options *GetQueryOptions) (*ProjectList, *Response, error) { apiEndpoint := "rest/api/2/project" req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) @@ -161,3 +174,38 @@ func (s *ProjectService) GetPermissionScheme(ctx context.Context, projectID stri return ps, resp, nil } + +// Find searches for project paginated info from Jira +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-projects/#api-rest-api-2-project-search-get +func (s *ProjectService) Find(ctx context.Context, tweaks ...searchF) ([]Project, *Response, error) { + apiEndpoint := "rest/api/2/project/search" + + search := []searchParam{} + for _, f := range tweaks { + search = f(search) + } + + queryString := "" + for _, param := range search { + queryString += fmt.Sprintf("%s=%s&", param.name, param.value) + } + + if queryString != "" { + apiEndpoint += "?" + queryString + } + + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + response := new(searchProjectsResponse) + resp, err := s.client.Do(req, response) + if err != nil { + jerr := NewJiraError(resp, err) + return nil, resp, jerr + } + + return response.Values, resp, nil +} diff --git a/cloud/project_test.go b/cloud/project_test.go index d3c877cf..121a9245 100644 --- a/cloud/project_test.go +++ b/cloud/project_test.go @@ -137,3 +137,80 @@ func TestProjectService_GetPermissionScheme_Success(t *testing.T) { t.Errorf("Error given: %s", err) } } + +func TestProjectService_Find_Success(t *testing.T) { + setup() + defer teardown() + testapiEndpoint := "/rest/api/2/project/search" + + testMux.HandleFunc(testapiEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + testRequestURL(t, r, testapiEndpoint+"?startAt=0&maxResults=2") + fmt.Fprint(w, `{ + "self": "https://your-domain.atlassian.net/rest/api/2/project/search?startAt=0&maxResults=2", + "nextPage": "https://your-domain.atlassian.net/rest/api/2/project/search?startAt=2&maxResults=2", + "maxResults": 2, + "startAt": 0, + "total": 7, + "isLast": false, + "values": [ + { + "self": "https://your-domain.atlassian.net/rest/api/2/project/EX", + "id": "10000", + "key": "EX", + "name": "Example", + "avatarUrls": { + "48x48": "https://your-domain.atlassian.net/secure/projectavatar?size=large&pid=10000", + "24x24": "https://your-domain.atlassian.net/secure/projectavatar?size=small&pid=10000", + "16x16": "https://your-domain.atlassian.net/secure/projectavatar?size=xsmall&pid=10000", + "32x32": "https://your-domain.atlassian.net/secure/projectavatar?size=medium&pid=10000" + }, + "projectCategory": { + "self": "https://your-domain.atlassian.net/rest/api/2/projectCategory/10000", + "id": "10000", + "name": "FIRST", + "description": "First Project Category" + }, + "simplified": false, + "style": "classic", + "insight": { + "totalIssueCount": 100, + "lastIssueUpdateTime": "2023-10-27T00:46:39.889+0000" + } + }, + { + "self": "https://your-domain.atlassian.net/rest/api/2/project/ABC", + "id": "10001", + "key": "ABC", + "name": "Alphabetical", + "avatarUrls": { + "48x48": "https://your-domain.atlassian.net/secure/projectavatar?size=large&pid=10001", + "24x24": "https://your-domain.atlassian.net/secure/projectavatar?size=small&pid=10001", + "16x16": "https://your-domain.atlassian.net/secure/projectavatar?size=xsmall&pid=10001", + "32x32": "https://your-domain.atlassian.net/secure/projectavatar?size=medium&pid=10001" + }, + "projectCategory": { + "self": "https://your-domain.atlassian.net/rest/api/2/projectCategory/10000", + "id": "10000", + "name": "FIRST", + "description": "First Project Category" + }, + "simplified": false, + "style": "classic", + "insight": { + "totalIssueCount": 100, + "lastIssueUpdateTime": "2023-10-27T00:46:39.889+0000" + } + } + ] + }`) + }) + + if projects, _, err := testClient.Project.Find(context.Background(), WithStartAt(0), WithMaxResults(2)); err != nil { + t.Errorf("Error given: %s", err) + } else if len(projects) != 2 { + t.Errorf("Expected 2 projects. Projects is %d", len(projects)) + } else if projects[0].ID != "10000" { + t.Errorf("Expected 10000. Projects[0].ID is %s", projects[0].ID) + } +} diff --git a/cloud/role.go b/cloud/role.go index 3fd18d83..6c4d020c 100644 --- a/cloud/role.go +++ b/cloud/role.go @@ -22,12 +22,13 @@ type Role struct { // Actor represents a Jira actor type Actor struct { - ID int `json:"id" structs:"id"` - DisplayName string `json:"displayName" structs:"displayName"` - Type string `json:"type" structs:"type"` - Name string `json:"name" structs:"name"` - AvatarURL string `json:"avatarUrl" structs:"avatarUrl"` - ActorUser *ActorUser `json:"actorUser" structs:"actoruser"` + ID int `json:"id" structs:"id"` + DisplayName string `json:"displayName" structs:"displayName"` + Type string `json:"type" structs:"type"` + Name string `json:"name" structs:"name"` + AvatarURL string `json:"avatarUrl" structs:"avatarUrl"` + ActorUser *ActorUser `json:"actorUser" structs:"actoruser"` + ActorGroup *ActorGroup `json:"actorGroup" structs:"actorGroup"` } // ActorUser contains the account id of the actor/user @@ -35,6 +36,12 @@ type ActorUser struct { AccountID string `json:"accountId" structs:"accountId"` } +type ActorGroup struct { + DisplayName string `json:"displayName" structs:"displayName"` + GroupID string `json:"groupId" structs:"groupId"` + Name string `json:"name" structs:"name"` +} + // GetList returns a list of all available project roles // // Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/#api-api-3-role-get @@ -80,3 +87,21 @@ func (s *RoleService) Get(ctx context.Context, roleID int) (*Role, *Response, er return role, resp, err } + +// Get role actors for project +// +// Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-project-roles/#api-rest-api-3-project-projectidorkey-role-id-get +func (s *RoleService) GetRoleActorsForProject(ctx context.Context, projectID string, roleID int) ([]*Actor, *Response, error) { + apiEndpoint := fmt.Sprintf("rest/api/3/project/%s/role/%d", projectID, roleID) + req, err := s.client.NewRequest(ctx, http.MethodGet, apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + role := new(Role) + resp, err := s.client.Do(req, role) + if err != nil { + jerr := NewJiraError(resp, err) + return nil, resp, jerr + } + return role.Actors, resp, err +} diff --git a/cloud/role_test.go b/cloud/role_test.go index 20f7e8db..45baf84b 100644 --- a/cloud/role_test.go +++ b/cloud/role_test.go @@ -106,3 +106,32 @@ func TestRoleService_Get(t *testing.T) { t.Errorf("Error given: %s", err) } } + +func TestRoleService_GetRoleActorsForProject(t *testing.T) { + setup() + defer teardown() + rawResponseBody, err := os.ReadFile("../testing/mock-data/role_actors.json") + if err != nil { + t.Error(err.Error()) + } + testapiEndpoint := "/rest/api/3/project/10002/role/10006" + testMux.HandleFunc(testapiEndpoint, func(writer http.ResponseWriter, request *http.Request) { + testMethod(t, request, http.MethodGet) + testRequestURL(t, request, testapiEndpoint) + fmt.Fprint(writer, string(rawResponseBody)) + }) + + actors, _, err := testClient.Role.GetRoleActorsForProject(context.Background(), "10002", 10006) + if err != nil { + t.Errorf("Error given: %s", err) + } + if len(actors) != 2 { + t.Errorf("Expected 2 actors, got %d", len(actors)) + } + if actors[0].DisplayName != "jira-developers" { + t.Errorf("Expected jira-developers, got %s", actors[0].DisplayName) + } + if actors[1].DisplayName != "Mia Krystof" { + t.Errorf("Expected Mia Krystof, got %s", actors[1].DisplayName) + } +} diff --git a/cloud/searching.go b/cloud/searching.go new file mode 100644 index 00000000..4d015c4f --- /dev/null +++ b/cloud/searching.go @@ -0,0 +1,38 @@ +package cloud + +import "fmt" + +type ( + searchParam struct { + name string + value string + } + + search []searchParam + + searchF func(search) search +) + +// WithMaxResults sets the max results to return +func WithMaxResults(maxResults int) searchF { + return func(s search) search { + s = append(s, searchParam{name: "maxResults", value: fmt.Sprintf("%d", maxResults)}) + return s + } +} + +// WithAccountId sets the account id to search +func WithAccountId(accountId string) searchF { + return func(s search) search { + s = append(s, searchParam{name: "accountId", value: accountId}) + return s + } +} + +// WithUsername sets the username to search +func WithUsername(username string) searchF { + return func(s search) search { + s = append(s, searchParam{name: "username", value: username}) + return s + } +} diff --git a/cloud/user.go b/cloud/user.go index 3453a72e..844512e4 100644 --- a/cloud/user.go +++ b/cloud/user.go @@ -67,15 +67,6 @@ type ApplicationRole struct { // Key `defaultGroupsDetails` missing - https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-application-roles/#api-rest-api-3-applicationrole-key-get } -type userSearchParam struct { - name string - value string -} - -type userSearch []userSearchParam - -type userSearchF func(userSearch) userSearch - // Get gets user info from Jira using its Account Id // // Jira API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-rest-api-2-user-get @@ -209,58 +200,34 @@ func (s *UserService) GetCurrentUser(ctx context.Context) (*User, *Response, err return &user, resp, nil } -// WithMaxResults sets the max results to return -func WithMaxResults(maxResults int) userSearchF { - return func(s userSearch) userSearch { - s = append(s, userSearchParam{name: "maxResults", value: fmt.Sprintf("%d", maxResults)}) - return s - } -} - // WithStartAt set the start pager -func WithStartAt(startAt int) userSearchF { - return func(s userSearch) userSearch { - s = append(s, userSearchParam{name: "startAt", value: fmt.Sprintf("%d", startAt)}) +func WithStartAt(startAt int) searchF { + return func(s search) search { + s = append(s, searchParam{name: "startAt", value: fmt.Sprintf("%d", startAt)}) return s } } // WithActive sets the active users lookup -func WithActive(active bool) userSearchF { - return func(s userSearch) userSearch { - s = append(s, userSearchParam{name: "includeActive", value: fmt.Sprintf("%t", active)}) +func WithActive(active bool) searchF { + return func(s search) search { + s = append(s, searchParam{name: "includeActive", value: fmt.Sprintf("%t", active)}) return s } } // WithInactive sets the inactive users lookup -func WithInactive(inactive bool) userSearchF { - return func(s userSearch) userSearch { - s = append(s, userSearchParam{name: "includeInactive", value: fmt.Sprintf("%t", inactive)}) - return s - } -} - -// WithUsername sets the username to search -func WithUsername(username string) userSearchF { - return func(s userSearch) userSearch { - s = append(s, userSearchParam{name: "username", value: username}) - return s - } -} - -// WithAccountId sets the account id to search -func WithAccountId(accountId string) userSearchF { - return func(s userSearch) userSearch { - s = append(s, userSearchParam{name: "accountId", value: accountId}) +func WithInactive(inactive bool) searchF { + return func(s search) search { + s = append(s, searchParam{name: "includeInactive", value: fmt.Sprintf("%t", inactive)}) return s } } // WithProperty sets the property (Property keys are specified by path) to search -func WithProperty(property string) userSearchF { - return func(s userSearch) userSearch { - s = append(s, userSearchParam{name: "property", value: property}) +func WithProperty(property string) searchF { + return func(s search) search { + s = append(s, searchParam{name: "property", value: property}) return s } } @@ -272,8 +239,8 @@ func WithProperty(property string) userSearchF { // // TODO Double check this method if this works as expected, is using the latest API and the response is complete // This double check effort is done for v2 - Remove this two lines if this is completed. -func (s *UserService) Find(ctx context.Context, property string, tweaks ...userSearchF) ([]User, *Response, error) { - search := []userSearchParam{ +func (s *UserService) Find(ctx context.Context, property string, tweaks ...searchF) ([]User, *Response, error) { + search := []searchParam{ { name: "query", value: property, diff --git a/testing/mock-data/role_actors.json b/testing/mock-data/role_actors.json new file mode 100644 index 00000000..9457ebc7 --- /dev/null +++ b/testing/mock-data/role_actors.json @@ -0,0 +1,35 @@ +{ + "self": "https://your-domain.atlassian.net/rest/api/3/project/MKY/role/10360", + "name": "Developers", + "id": 10360, + "description": "A project role that represents developers in a project", + "actors": [ + { + "id": 10240, + "displayName": "jira-developers", + "type": "atlassian-group-role-actor", + "name": "jira-developers", + "actorGroup": { + "name": "jira-developers", + "displayName": "jira-developers", + "groupId": "952d12c3-5b5b-4d04-bb32-44d383afc4b2" + } + }, + { + "id": 10241, + "displayName": "Mia Krystof", + "type": "atlassian-user-role-actor", + "actorUser": { + "accountId": "5b10a2844c20165700ede21g" + } + } + ], + "scope": { + "type": "PROJECT", + "project": { + "id": "10000", + "key": "KEY", + "name": "Next Gen Project" + } + } +} \ No newline at end of file