diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c075af5a4cbcdc9492e1f152d90b3aea74430c73..bb6461417acc44048f0d8def7a01fa1cdf8211af 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -17,7 +17,7 @@ linters: image: alpinelinux/golang script: - go version - - wget -O- -nv https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s v1.45.2 + - wget -O- -nv https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s v1.49.0 - ./bin/golangci-lint --version - ./bin/golangci-lint run tags: diff --git a/Services/AutoLabeler/definition.go b/Services/AutoLabeler/definition.go index efa4c3139319785b2019fbf3d6cb435e1303119b..e4b1767303e6508c75bf41a5b8cab75d6b536a6f 100644 --- a/Services/AutoLabeler/definition.go +++ b/Services/AutoLabeler/definition.go @@ -12,7 +12,7 @@ import ( "github.com/rs/zerolog" "github.com/xanzy/go-gitlab" - "gitlab.alpinelinux.org/Cogitri/aports-qa-bot/MergeRequest" + "gitlab.alpinelinux.org/alpine/infra/aports-qa-bot/MergeRequest" ) // isUpgrade checks whether the values of the pkgver variable in an APKBUILD have changed diff --git a/Services/AutoMaintainer/definition.go b/Services/AutoMaintainer/definition.go index cf92573a408eb14fc538e0305608ad8977f81174..fad6fec86aa72113e4fb4d59f8c3470679577e0b 100644 --- a/Services/AutoMaintainer/definition.go +++ b/Services/AutoMaintainer/definition.go @@ -15,45 +15,58 @@ import ( "github.com/rs/zerolog" "github.com/xanzy/go-gitlab" - "gitlab.alpinelinux.org/Cogitri/aports-qa-bot/MergeRequest" + "gitlab.alpinelinux.org/alpine/infra/aports-qa-bot/MergeRequest" ) +type Maintainer struct { + name string + email string +} + +func (m *Maintainer) GetName() string { + return m.name +} + +func (m *Maintainer) GetEmail() string { + return m.email +} + // hasUser takes an email address and searches in the gitlab database for it // // It will return an error if: -// - There is more than 1 user with a matching email address -// - There are no users with the matching email address -// - The request to GitLab has failed +// - There is more than 1 user with a matching email address +// - There are no users with the matching email address +// - The request to GitLab has failed // // If only one user is found then it is returned -func hasUser(gitlabClient *gitlab.Client, name string) (*gitlab.User, error) { +func hasUser(gitlabClient *gitlab.Client, email string) (*gitlab.User, error) { users, _, err := gitlabClient.Users.ListUsers( &gitlab.ListUsersOptions{ - Search: &name, + Search: &email, }, ) if err != nil { return nil, err } if len(users) > 1 { - return nil, fmt.Errorf("too many users found with email address %q", name) + return nil, fmt.Errorf("too many users found with email address %q", email) } if len(users) == 0 { - return nil, fmt.Errorf("no users found with email address %q", name) + return nil, fmt.Errorf("no users found with email address %q", email) } return users[0], nil } -// extractMaintainerEmail takes a ProjectID and a filename and tries to extract the email -// address of the '# Maintainer:' field -func extractMaintainerEmail(gitlabClient *gitlab.Client, projectID interface{}, filename, ref string) (string, error) { +// ExtractMaintainer takes a ProjectID and a filename and tries to extract the user name +// and email address of the '# Maintainer:' field +func ExtractMaintainer(gitlabClient *gitlab.Client, projectID interface{}, filename, ref string) (*Maintainer, error) { file, _, err := gitlabClient.RepositoryFiles.GetRawFile( projectID, filename, &gitlab.GetRawFileOptions{Ref: gitlab.String(ref)}, ) if err != nil { - return "", err + return nil, err } // Run over every line of the received raw file, we will check the first @@ -69,14 +82,18 @@ func extractMaintainerEmail(gitlabClient *gitlab.Client, projectID interface{}, address, err := mail.ParseAddress(line) if err != nil { - return "", err + return nil, err } // Strip the +tag so we get the true address re := regexp.MustCompile(`\+.+@`) - return re.ReplaceAllString(address.Address, "@"), nil + maintainer := Maintainer{ + name: address.Name, + email: re.ReplaceAllString(address.Address, "@"), + } + return &maintainer, nil } } - return "", errors.New("no maintainer field") + return nil, errors.New("no maintainer field") } type Service struct { @@ -147,7 +164,7 @@ func (s Service) Process(payload *gitlab.MergeEvent, log *zerolog.Logger) { continue } - maintainer, err := extractMaintainerEmail( + maintainer, err := ExtractMaintainer( s.gitlabClient, payload.ObjectAttributes.TargetProjectID, change.OldPath, // Use OldPath because we query the repository which doesn't have the changes @@ -161,8 +178,8 @@ func (s Service) Process(payload *gitlab.MergeEvent, log *zerolog.Logger) { Msg("could not extract maintainer email") continue } - // Add the maintainer we found from the APKBUILD into the map - maintainersFound[maintainer] = true + // Add the maintainerEmail we found from the APKBUILD into the map + maintainersFound[maintainer.GetEmail()] = true } if len(maintainersFound) == 0 { diff --git a/Services/AutoMaintainer/definition_test.go b/Services/AutoMaintainer/definition_test.go index baddd9bddd6c6ae67fe467a33da34a5457d4dd9c..16b6f25d4ecffbbf4fa262f29e0105d97cefc48a 100644 --- a/Services/AutoMaintainer/definition_test.go +++ b/Services/AutoMaintainer/definition_test.go @@ -9,7 +9,7 @@ import ( "net/http" "testing" - "gitlab.alpinelinux.org/Cogitri/aports-qa-bot/mocklab" + "gitlab.alpinelinux.org/alpine/infra/aports-qa-bot/mocklab" ) // Taken from server_test.go, remove them once split @@ -71,7 +71,7 @@ func Test_hasUserMultiMatch(t *testing.T) { } } -func Test_extractMaintainerEmailSimple(t *testing.T) { +func Test_ExtractMaintainerSimple(t *testing.T) { mux, server, client := mocklab.Setup(t) defer mocklab.Teardown(server) @@ -81,88 +81,89 @@ func Test_extractMaintainerEmailSimple(t *testing.T) { } }) - address, err := extractMaintainerEmail(client, 1, "APKBUILD", "master") + maintainer, err := ExtractMaintainer(client, 1, "APKBUILD", "master") if err != nil { t.Errorf("got unexpected error: %v", err) } - assertEqStr(t, "foo@bar.org", address) + assertEqStr(t, "foo@bar.org", maintainer.GetEmail()) + assertEqStr(t, "Foo", maintainer.GetName()) } -func Test_extractMaintainerEmailSimpleNoName(t *testing.T) { +func Test_ExtractMaintainerTeam(t *testing.T) { mux, server, client := mocklab.Setup(t) defer mocklab.Teardown(server) mux.HandleFunc("/api/v4/projects/1/repository/files/APKBUILD/raw", func(w http.ResponseWriter, r *http.Request) { - if _, err := io.WriteString(w, "# Maintainer: "); err != nil { + if _, err := io.WriteString(w, "# Maintainer: team/X "); err != nil { t.Fatalf("error writing response: %v", err) } }) - address, err := extractMaintainerEmail(client, 1, "APKBUILD", "master") + maintainer, err := ExtractMaintainer(client, 1, "APKBUILD", "master") if err != nil { t.Errorf("got unexpected error: %v", err) } - assertEqStr(t, "foo@bar.org", address) + assertEqStr(t, "foo@bar.org", maintainer.GetEmail()) + assertEqStr(t, "team/X", maintainer.GetName()) } -func Test_extractMaintainerEmailNoMaintainer(t *testing.T) { +func Test_ExtractMaintainerSimpleNoName(t *testing.T) { mux, server, client := mocklab.Setup(t) defer mocklab.Teardown(server) mux.HandleFunc("/api/v4/projects/1/repository/files/APKBUILD/raw", func(w http.ResponseWriter, r *http.Request) { - if _, err := io.WriteString(w, ""); err != nil { + if _, err := io.WriteString(w, "# Maintainer: "); err != nil { t.Fatalf("error writing response: %v", err) } }) - _, err := extractMaintainerEmail(client, 1, "APKBUILD", "master") - if err == nil { - t.Error(`expected error: no maintainer field`) - } - if err.Error() != `no maintainer field` { - t.Errorf("unexpected error: %v", err) + maintainer, err := ExtractMaintainer(client, 1, "APKBUILD", "master") + if err != nil { + t.Errorf("got unexpected error: %v", err) } + assertEqStr(t, "foo@bar.org", maintainer.GetEmail()) + assertEqStr(t, "", maintainer.GetName()) } -func Test_extractMaintainerEmailBadFormatUnclosedAngleAddr(t *testing.T) { +func Test_ExtractMaintainerNoMaintainer(t *testing.T) { mux, server, client := mocklab.Setup(t) defer mocklab.Teardown(server) mux.HandleFunc("/api/v4/projects/1/repository/files/APKBUILD/raw", func(w http.ResponseWriter, r *http.Request) { - if _, err := io.WriteString(w, "# Maintainer: Foo +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package CommentPingTeam + +import ( + "fmt" + "strings" + + "github.com/rs/zerolog" + "github.com/xanzy/go-gitlab" + "gitlab.alpinelinux.org/alpine/infra/aports-qa-bot/MergeRequest" + "gitlab.alpinelinux.org/alpine/infra/aports-qa-bot/Services/AutoMaintainer" +) + +const noteTemplate = "\U0001F3D3 @%s" + +var PingTeams = []string{"team/gnome"} + +type Service struct { + dryRun bool + gitlabClient *gitlab.Client +} + +func New(dryRun bool, gitlabClient *gitlab.Client) Service { + return Service{ + dryRun: dryRun, + gitlabClient: gitlabClient, + } +} + +var actions = map[MergeRequest.Action]bool{ + MergeRequest.Open: true, +} + +var states = map[MergeRequest.State]bool{ + MergeRequest.Opened: true, +} + +func (Service) GetActions() map[MergeRequest.Action]bool { + return actions +} + +func (Service) GetStates() map[MergeRequest.State]bool { + return states +} + +func (s Service) Process(payload *gitlab.MergeEvent, log *zerolog.Logger) { + // Create a logger we can use with all the information we need + sLog := log.With(). + Str("service", "CommentPingTeam"). + Logger() + + sLog.Info().Msg("starting") + + res, _, err := s.gitlabClient.MergeRequests.GetMergeRequestChanges( + payload.ObjectAttributes.TargetProjectID, + payload.ObjectAttributes.IID, + &gitlab.GetMergeRequestChangesOptions{}, + ) + if err != nil { + sLog.Error(). + Err(err). + Msg("failed") + return + } + + // List of maintainers that have been found + maintainersFound := make(map[string]bool) + + for _, change := range res.Changes { + // Skip over if the change is not an APKBUILD + if !strings.Contains(change.NewPath, "/APKBUILD") { + continue + } + + // Search for the Team in the target repository unless it is a + // new file. In such case, ping the team to let them know that + // there is a new package under their maintenance + projectID := payload.ObjectAttributes.TargetProjectID + path := change.OldPath + branch := payload.ObjectAttributes.TargetBranch + if change.NewFile { + projectID = payload.ObjectAttributes.SourceProjectID + path = change.NewPath + branch = payload.ObjectAttributes.SourceBranch + } + + var maintainer *AutoMaintainer.Maintainer + maintainer, err = AutoMaintainer.ExtractMaintainer( + s.gitlabClient, + projectID, + path, + branch, + ) + if err != nil { + sLog.Warn(). + Err(err). + Str("file", path). + Str("reference", branch). + Msg("could not extract maintainer name") + continue + } + + maintainersFound[maintainer.GetName()] = true + } + + if len(maintainersFound) == 0 { + sLog.Warn().Msg("no maintainers found") + return + } + + var teams []string + for k := range maintainersFound { + for _, t := range PingTeams { + if strings.EqualFold(k, t) { + teams = append(teams, t) + } + } + } + + if len(teams) == 0 { + sLog.Warn().Msg("no teams found to ping") + return + } + + if !s.dryRun { + for _, t := range teams { + noteText := fmt.Sprintf(noteTemplate, t) + _, _, err = s.gitlabClient.Notes.CreateMergeRequestNote( + payload.ObjectAttributes.TargetProjectID, + payload.ObjectAttributes.IID, + &gitlab.CreateMergeRequestNoteOptions{Body: gitlab.String(noteText)}, + ) + if err != nil { + sLog.Error(). + Err(err). + Msg("failed to ping team") + return + } + } + } + + sLog.Info().Msg("finished") +} diff --git a/Services/GreetFirstContributors/definition.go b/Services/GreetFirstContributors/definition.go index 15d28e035088ed09dbde1d24a14689e21eb42a71..6be6c9553a5ca8fef818c68fc1eeb01951a9cf3c 100644 --- a/Services/GreetFirstContributors/definition.go +++ b/Services/GreetFirstContributors/definition.go @@ -9,7 +9,7 @@ import ( "github.com/rs/zerolog" "github.com/xanzy/go-gitlab" - "gitlab.alpinelinux.org/Cogitri/aports-qa-bot/MergeRequest" + "gitlab.alpinelinux.org/alpine/infra/aports-qa-bot/MergeRequest" ) const note = `Hello there, @%s! diff --git a/Services/MinimumRequiredSettings/definition.go b/Services/MinimumRequiredSettings/definition.go index bf5af8fc264c2e5c66143acd4bbd4f467b486209..1905f99002a1906b2c15070b46b9e7d055806aa2 100644 --- a/Services/MinimumRequiredSettings/definition.go +++ b/Services/MinimumRequiredSettings/definition.go @@ -8,7 +8,7 @@ package MinimumRequiredSettings import ( "github.com/rs/zerolog" "github.com/xanzy/go-gitlab" - "gitlab.alpinelinux.org/Cogitri/aports-qa-bot/MergeRequest" + "gitlab.alpinelinux.org/alpine/infra/aports-qa-bot/MergeRequest" ) type Service struct { diff --git a/Services/WarnProtectedBranch/definition.go b/Services/WarnProtectedBranch/definition.go index 8fff713df6c71bfe6fc4195ab87f30fe903f0997..4191d5344f7b373317ef27d11a8109f70d79a9f9 100644 --- a/Services/WarnProtectedBranch/definition.go +++ b/Services/WarnProtectedBranch/definition.go @@ -9,7 +9,7 @@ import ( "github.com/rs/zerolog" "github.com/xanzy/go-gitlab" - "gitlab.alpinelinux.org/Cogitri/aports-qa-bot/MergeRequest" + "gitlab.alpinelinux.org/alpine/infra/aports-qa-bot/MergeRequest" ) // We unfortunately cannot use a raw string literal (``) because we have diff --git a/Services/services.go b/Services/services.go index aeb111aea6cf46dfff295476ee37527779d264c0..8b13d662ee3f3a4d3b3628b2cb277a710f3b6af0 100644 --- a/Services/services.go +++ b/Services/services.go @@ -7,15 +7,16 @@ package Services import ( "github.com/rs/zerolog" "github.com/xanzy/go-gitlab" - "gitlab.alpinelinux.org/Cogitri/aports-qa-bot/MergeRequest" - "gitlab.alpinelinux.org/Cogitri/aports-qa-bot/Services/AutoLabeler" - "gitlab.alpinelinux.org/Cogitri/aports-qa-bot/Services/AutoMaintainer" - "gitlab.alpinelinux.org/Cogitri/aports-qa-bot/Services/AutoStale" - "gitlab.alpinelinux.org/Cogitri/aports-qa-bot/Services/CancelMergeRequestPipelines" - "gitlab.alpinelinux.org/Cogitri/aports-qa-bot/Services/CommentOnApproval" - "gitlab.alpinelinux.org/Cogitri/aports-qa-bot/Services/GreetFirstContributors" - "gitlab.alpinelinux.org/Cogitri/aports-qa-bot/Services/MinimumRequiredSettings" - "gitlab.alpinelinux.org/Cogitri/aports-qa-bot/Services/WarnProtectedBranch" + "gitlab.alpinelinux.org/alpine/infra/aports-qa-bot/MergeRequest" + "gitlab.alpinelinux.org/alpine/infra/aports-qa-bot/Services/AutoLabeler" + "gitlab.alpinelinux.org/alpine/infra/aports-qa-bot/Services/AutoMaintainer" + "gitlab.alpinelinux.org/alpine/infra/aports-qa-bot/Services/AutoStale" + "gitlab.alpinelinux.org/alpine/infra/aports-qa-bot/Services/CancelMergeRequestPipelines" + "gitlab.alpinelinux.org/alpine/infra/aports-qa-bot/Services/CommentOnApproval" + "gitlab.alpinelinux.org/alpine/infra/aports-qa-bot/Services/GreetFirstContributors" + "gitlab.alpinelinux.org/alpine/infra/aports-qa-bot/Services/MinimumRequiredSettings" + "gitlab.alpinelinux.org/alpine/infra/aports-qa-bot/Services/WarnProtectedBranch" + "gitlab.alpinelinux.org/alpine/infra/aports-qa-bot/Services/CommentPingTeam" ) func RegisterWebHookServices(client *gitlab.Client, serviceStates WebHookServices) (r []WebHookService) { @@ -63,6 +64,12 @@ func RegisterWebHookServices(client *gitlab.Client, serviceStates WebHookService ) r = append(r, warnProtectedBranchJob) } + if serviceStates.CommentPingTeam != Disabled { + commentPingTeamJob := CommentPingTeam.New( + (serviceStates.CommentPingTeam == DryRun), client, + ) + r = append(r, commentPingTeamJob) + } return r } @@ -82,9 +89,9 @@ func RegisterPollerServices(client *gitlab.Client, serviceStates PollerServices) // The Process function takes: // 1. the *gitlab.MergeEvent which contains all the information necessary // for the service to do its job -// 2. a `zerolog.Logger` struct that has fields associated with it including -// an uuid that is used to associate all messages generated by the processor -// and any services that it starts +// 2. a `zerolog.Logger` struct that has fields associated with it including +// an uuid that is used to associate all messages generated by the processor +// and any services that it starts // // The GetStates and GetActions functions are used to determine in which // states (Open, Closed, Merged) and actions (Open, Reopen, Close, Merge) @@ -103,6 +110,7 @@ type WebHookServices struct { GreetFirstContributors State CommentOnApproval State WarnProtectedBranch State + CommentPingTeam State } type PollerService interface { diff --git a/client/client.go b/client/client.go index da607c0747a1cc32d0c6f76f1170ef4280442536..93c8f0572a2f3247902635092cdfc50de535f638 100644 --- a/client/client.go +++ b/client/client.go @@ -9,8 +9,8 @@ import ( "github.com/robfig/cron/v3" "github.com/rs/zerolog/log" "github.com/xanzy/go-gitlab" - "gitlab.alpinelinux.org/Cogitri/aports-qa-bot/Services" - "gitlab.alpinelinux.org/Cogitri/aports-qa-bot/conf" + "gitlab.alpinelinux.org/alpine/infra/aports-qa-bot/Services" + "gitlab.alpinelinux.org/alpine/infra/aports-qa-bot/conf" ) // The Client polls the Gitlab API periodically diff --git a/conf/options.go b/conf/options.go index 00312f3163e09f1eb57d5a97242cce816971a665..3219cdf998b0cea921338630f5de0be9918f5e0b 100644 --- a/conf/options.go +++ b/conf/options.go @@ -4,7 +4,7 @@ package conf -import "gitlab.alpinelinux.org/Cogitri/aports-qa-bot/Services" +import "gitlab.alpinelinux.org/alpine/infra/aports-qa-bot/Services" // Options holds all configuration options for the server and poller pat of aports-qa-bot type Options struct { diff --git a/go.mod b/go.mod index 6912233e10f75206edd826e4e361c4427a12c05a..cf28830ffb327d8fd99d972bd40e2b001891dafb 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module gitlab.alpinelinux.org/Cogitri/aports-qa-bot +module gitlab.alpinelinux.org/alpine/infra/aports-qa-bot go 1.18 diff --git a/main.go b/main.go index 765ef90c6711a0d658d710b90c6f2dab99294133..a85bc4581b0daacc31d68447959be7a70791e077 100644 --- a/main.go +++ b/main.go @@ -11,9 +11,9 @@ import ( "github.com/rs/zerolog" "github.com/rs/zerolog/log" - "gitlab.alpinelinux.org/Cogitri/aports-qa-bot/client" - "gitlab.alpinelinux.org/Cogitri/aports-qa-bot/conf" - "gitlab.alpinelinux.org/Cogitri/aports-qa-bot/server" + "gitlab.alpinelinux.org/alpine/infra/aports-qa-bot/client" + "gitlab.alpinelinux.org/alpine/infra/aports-qa-bot/conf" + "gitlab.alpinelinux.org/alpine/infra/aports-qa-bot/server" ) func main() { diff --git a/server/server.go b/server/server.go index 33ac0f9c094070709dd5f235219648d686c2da1e..3753649e42a7bafb7d48bff99d431c2e66d9b360 100644 --- a/server/server.go +++ b/server/server.go @@ -15,9 +15,9 @@ import ( "github.com/google/uuid" "github.com/rs/zerolog/log" "github.com/xanzy/go-gitlab" - "gitlab.alpinelinux.org/Cogitri/aports-qa-bot/MergeRequest" - "gitlab.alpinelinux.org/Cogitri/aports-qa-bot/Services" - "gitlab.alpinelinux.org/Cogitri/aports-qa-bot/conf" + "gitlab.alpinelinux.org/alpine/infra/aports-qa-bot/MergeRequest" + "gitlab.alpinelinux.org/alpine/infra/aports-qa-bot/Services" + "gitlab.alpinelinux.org/alpine/infra/aports-qa-bot/conf" ) // The WebhookEventListener server, which listens for notifications from Gitlab diff --git a/server/server_test.go b/server/server_test.go index 0b6319eb385f0714179c23a475c9fcd9385755a0..dfd88817b203de13a4ec26cf1b1d84b39308b8ef 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -10,8 +10,8 @@ import ( "strings" "testing" - "gitlab.alpinelinux.org/Cogitri/aports-qa-bot/Services" - "gitlab.alpinelinux.org/Cogitri/aports-qa-bot/conf" + "gitlab.alpinelinux.org/alpine/infra/aports-qa-bot/Services" + "gitlab.alpinelinux.org/alpine/infra/aports-qa-bot/conf" ) const mergeRequestJobJSON = "{\"object_kind\":\"merge_request\",\"user\":{\"name\":\"Administrator\",\"username\":\"root\",\"avatar_url\":\"http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40&d=identicon\"},\"project\":{\"id\":19765543,\"name\":\"Gitlab Test\",\"description\":\"Aut reprehenderit ut est.\",\"web_url\":\"http://example.com/gitlabhq/gitlab-test\",\"avatar_url\":null,\"git_ssh_url\":\"git@example.com:gitlabhq/gitlab-test.git\",\"git_http_url\":\"http://example.com/gitlabhq/gitlab-test.git\",\"namespace\":\"GitlabHQ\",\"visibility_level\":20,\"path_with_namespace\":\"gitlabhq/gitlab-test\",\"default_branch\":\"master\",\"homepage\":\"http://example.com/gitlabhq/gitlab-test\",\"url\":\"http://example.com/gitlabhq/gitlab-test.git\",\"ssh_url\":\"git@example.com:gitlabhq/gitlab-test.git\",\"http_url\":\"http://example.com/gitlabhq/gitlab-test.git\"},\"repository\":{\"name\":\"Gitlab Test\",\"url\":\"http://example.com/gitlabhq/gitlab-test.git\",\"description\":\"Aut reprehenderit ut est.\",\"homepage\":\"http://example.com/gitlabhq/gitlab-test\"},\"object_attributes\":{\"id\":99,\"target_branch\":\"master\",\"source_branch\":\"ms-viewport\",\"source_project_id\":14,\"author_id\":51,\"assignee_id\":6,\"title\":\"MS-Viewport\",\"created_at\":\"2013-12-03T17:23:34Z\",\"updated_at\":\"2013-12-03T17:23:34Z\",\"milestone_id\":null,\"state\":\"opened\",\"merge_status\":\"unchecked\",\"target_project_id\":14,\"iid\":21,\"description\":\"\",\"source\":{\"name\":\"Awesome Project\",\"description\":\"Aut reprehenderit ut est.\",\"web_url\":\"http://example.com/awesome_space/awesome_project\",\"avatar_url\":null,\"git_ssh_url\":\"git@example.com:awesome_space/awesome_project.git\",\"git_http_url\":\"http://example.com/awesome_space/awesome_project.git\",\"namespace\":\"Awesome Space\",\"visibility_level\":20,\"path_with_namespace\":\"awesome_space/awesome_project\",\"default_branch\":\"master\",\"homepage\":\"http://example.com/awesome_space/awesome_project\",\"url\":\"http://example.com/awesome_space/awesome_project.git\",\"ssh_url\":\"git@example.com:awesome_space/awesome_project.git\",\"http_url\":\"http://example.com/awesome_space/awesome_project.git\"},\"target\":{\"name\":\"Awesome Project\",\"description\":\"Aut reprehenderit ut est.\",\"web_url\":\"http://example.com/awesome_space/awesome_project\",\"avatar_url\":null,\"git_ssh_url\":\"git@example.com:awesome_space/awesome_project.git\",\"git_http_url\":\"http://example.com/awesome_space/awesome_project.git\",\"namespace\":\"Awesome Space\",\"visibility_level\":20,\"path_with_namespace\":\"awesome_space/awesome_project\",\"default_branch\":\"master\",\"homepage\":\"http://example.com/awesome_space/awesome_project\",\"url\":\"http://example.com/awesome_space/awesome_project.git\",\"ssh_url\":\"git@example.com:awesome_space/awesome_project.git\",\"http_url\":\"http://example.com/awesome_space/awesome_project.git\"},\"last_commit\":{\"id\":\"da1560886d4f094c3e6c9ef40349f7d38b5d27d7\",\"message\":\"testing/alpine-qa-bot: update to 0.2\",\"timestamp\":\"2012-01-03T23:36:29+02:00\",\"url\":\"http://example.com/awesome_space/awesome_project/commits/da1560886d4f094c3e6c9ef40349f7d38b5d27d7\",\"author\":{\"name\":\"GitLab dev user\",\"email\":\"gitlabdev@dv6700.(none)\"}},\"work_in_progress\":false,\"url\":\"http://example.com/diaspora/merge_requests/1\",\"action\":\"open\",\"assignee\":{\"name\":\"User1\",\"username\":\"user1\",\"avatar_url\":\"http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40&d=identicon\"}},\"labels\":[{\"id\":206,\"title\":\"API\",\"color\":\"#ffffff\",\"project_id\":14,\"created_at\":\"2013-12-03T17:15:43Z\",\"updated_at\":\"2013-12-03T17:15:43Z\",\"template\":false,\"description\":\"API related issues\",\"type\":\"ProjectLabel\",\"group_id\":41}],\"changes\":{\"updated_by_id\":{\"previous\":null,\"current\":1},\"updated_at\":{\"previous\":\"2017-09-15 16:50:55 UTC\",\"current\":\"2017-09-15 16:52:00 UTC\"},\"labels\":{\"previous\":[{\"id\":206,\"title\":\"API\",\"color\":\"#ffffff\",\"project_id\":14,\"created_at\":\"2013-12-03T17:15:43Z\",\"updated_at\":\"2013-12-03T17:15:43Z\",\"template\":false,\"description\":\"API related issues\",\"type\":\"ProjectLabel\",\"group_id\":41}],\"current\":[{\"id\":205,\"title\":\"Platform\",\"color\":\"#123123\",\"project_id\":14,\"created_at\":\"2013-12-03T17:15:43Z\",\"updated_at\":\"2013-12-03T17:15:43Z\",\"template\":false,\"description\":\"Platform related issues\",\"type\":\"ProjectLabel\",\"group_id\":41}]}}}"