diff --git a/client/client.go b/client/client.go new file mode 100644 index 0000000..4b28927 --- /dev/null +++ b/client/client.go @@ -0,0 +1,122 @@ +package client + +import ( + "fmt" + + "github.com/xanzy/go-gitlab" +) + +const MR_MERGE_STATUS_MERGEABLE = "mergeable" + +type GitlabConfig struct { + AccessToken string + BaseURL string +} + +type GitlabClient struct { + client *gitlab.Client + me *gitlab.User + config *GitlabConfig +} + +func NewGitlabClient(config GitlabConfig) (*GitlabClient, error) { + git, err := gitlab.NewClient(config.AccessToken, gitlab.WithBaseURL(config.BaseURL)) + if err != nil { + return nil, fmt.Errorf("failed to authenticate to GitLab: %w", err) + } + me, _, err := git.Users.CurrentUser() + if err != nil { + return nil, fmt.Errorf("failed to get current user information from GitLab: %w", err) + } + return &GitlabClient{ + client: git, + me: me, + config: &config, + }, nil +} + +func (g *GitlabClient) GetConfigFileForMR(mr *gitlab.MergeRequest, filePath string) (*[]byte, error) { + opts := &gitlab.GetRawFileOptions{Ref: &mr.SourceBranch} + file, _, err := g.client.RepositoryFiles.GetRawFile(mr.ProjectID, filePath, opts) + if err != nil { + return nil, fmt.Errorf("failed to fetch config file: %w", err) + } + return &file, nil +} + +func (g *GitlabClient) ListMrsWithLabel(label string) ([]*gitlab.MergeRequest, error) { + labels := gitlab.LabelOptions{label} + opts := &gitlab.ListMergeRequestsOptions{ + ListOptions: gitlab.ListOptions { + PerPage: 20, + Page: 1, + }, + State: gitlab.Ptr("opened"), + Labels: &labels, + WithMergeStatusRecheck: gitlab.Ptr(true), + } + var allMrs []*gitlab.MergeRequest + + for { + mrs, resp, err := g.client.MergeRequests.ListMergeRequests(opts) + if err != nil { + return nil, fmt.Errorf("failed to list MRs: %w", err) + } + allMrs = append(allMrs, mrs...) + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + + return allMrs, nil +} + +func (g *GitlabClient) RefreshMr(mr *gitlab.MergeRequest) (*gitlab.MergeRequest, error) { + opts := &gitlab.GetMergeRequestsOptions{} + mr, _, err := g.client.MergeRequests.GetMergeRequest(mr.ProjectID, mr.IID, opts) + if err != nil { + return nil, fmt.Errorf("failed to get MR: %w", err) + } + + return mr, nil +} + +func (g *GitlabClient) MergeMr(mr *gitlab.MergeRequest) (error) { + opts := &gitlab.AcceptMergeRequestOptions{ShouldRemoveSourceBranch: gitlab.Ptr(true)} + _, _, err := g.client.MergeRequests.AcceptMergeRequest(mr.ProjectID, mr.IID, opts) + if err != nil { + return fmt.Errorf("failed to merge MR: %w", err) + } + return nil +} + +func IsMergeable(mr *gitlab.MergeRequest) (bool) { + return mr.DetailedMergeStatus == MR_MERGE_STATUS_MERGEABLE +} + +func (g *GitlabClient) Comment(mr *gitlab.MergeRequest, comment string) error { + nopts := &gitlab.ListMergeRequestNotesOptions{} + notes, _, err := g.client.Notes.ListMergeRequestNotes(mr.ProjectID, mr.IID, nopts) + if err != nil { + return fmt.Errorf("failed to get comments on MR: %w", err) + } + + for _, n := range notes { + if n.Author.ID == g.me.ID { + if n.Body == comment { + return nil + } + break + } + } + + opts := &gitlab.CreateMergeRequestNoteOptions{ + Body: gitlab.Ptr(comment), + } + _, _, err = g.client.Notes.CreateMergeRequestNote(mr.ProjectID, mr.IID, opts) + if err != nil { + return fmt.Errorf("failed to add comment to MR: %w", err) + } + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..96ebbb2 --- /dev/null +++ b/go.mod @@ -0,0 +1,25 @@ +module github.com/vshn/gitlab-scheduled-merge + +go 1.22.2 + +require ( + github.com/robfig/cron/v3 v3.0.1 + github.com/spf13/cobra v1.8.1 + github.com/xanzy/go-gitlab v0.106.0 + go.uber.org/multierr v1.11.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.2 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/net v0.8.0 // indirect + golang.org/x/oauth2 v0.6.0 // indirect + golang.org/x/time v0.3.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.29.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..23a2029 --- /dev/null +++ b/go.sum @@ -0,0 +1,60 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= +github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= +github.com/hashicorp/go-retryablehttp v0.7.2 h1:AcYqCvkpalPnPF2pn0KamgwamS42TqUDDYFRKq/RAd0= +github.com/hashicorp/go-retryablehttp v0.7.2/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/xanzy/go-gitlab v0.106.0 h1:EDfD03K74cIlQo2EducfiupVrip+Oj02bq9ofw5F8sA= +github.com/xanzy/go-gitlab v0.106.0/go.mod h1:ETg8tcj4OhrB84UEgeE8dSuV/0h4BBL1uOV/qK0vlyI= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/oauth2 v0.6.0 h1:Lh8GPgSKBfWSwFvtuWOfeI3aAAnbXTSutYxJiOJFgIw= +golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.29.1 h1:7QBf+IK2gx70Ap/hDsOmam3GE0v9HicjfEdAxE62UoM= +google.golang.org/protobuf v1.29.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..74e4c67 --- /dev/null +++ b/main.go @@ -0,0 +1,89 @@ +package main + +import ( + "fmt" + "log" + "os" + + "github.com/vshn/gitlab-scheduled-merge/client" + "github.com/vshn/gitlab-scheduled-merge/task" + "github.com/robfig/cron/v3" + "github.com/spf13/cobra" +) + +var ( + version = "snapshot" + commit = "unknown" + date = "unknown" +) + +type RepositoryConfig struct { + MergeWindows []MergeWindow `yaml:"mergeWindows"` +} +type MergeWindow struct { + Cron string `yaml:"cron"` + MaxDelay string `yaml:"maxDelay"` +} + +func main() { + cmd := &cobra.Command{ + Use: "gitlab-schedule-merge", + } + + gitlabToken := cmd.Flags().StringP("gitlab-token", "t", "", "Token with which to authenticate with GitLab") + gitlabBaseUrl := cmd.Flags().String("gitlab-base-url", "https://gitlab.com/api/v4", "Base URL of GitLab API to use") + scheduledLabel := cmd.Flags().String("scheduled-label", "scheduled", "Name of the label which indicates a MR should be scheduled") + configFilePath := cmd.Flags().String("config-file-path", ".merge-schedule.yml", "Path of the config file in the repo which is used to configure merge windows") + taskSchedule := cmd.Flags().String("task-schedule", "@every 15m", "Cron schedule for how frequently to process merge requests") + + cmd.Run = func(*cobra.Command, []string) { + gitlabConfig := client.GitlabConfig{ + AccessToken: *gitlabToken, + BaseURL: *gitlabBaseUrl, + } + gitlabClient, err := client.NewGitlabClient(gitlabConfig) + if err != nil { + log.Fatalf("GitLab client error: %s", err.Error()) + } + + task, err := setupCronTask(gitlabClient, *taskSchedule, *scheduledLabel, *configFilePath) + if err != nil { + log.Fatalf("Error setting up cron task: %s", err.Error()) + } + + log.Println("Starting task...") + task.Run() + + } + + if err := cmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func setupCronTask( + client *client.GitlabClient, + crontab string, + scheduledLabel string, + configFilePath string, +) (*cron.Cron, error) { + config := task.TaskConfig{ + MergeRequestScheduledLabel: scheduledLabel, + ConfigFilePath: configFilePath, + } + periodicTask := task.NewTask(client, config) + + c := cron.New() + _, err := c.AddFunc(crontab, func() { + err := periodicTask.Run() + if err == nil { + return + } + log.Printf("error during periodic job: %s\n", err.Error()) + }) + if err != nil { + return nil, err + } + return c, nil +} diff --git a/task/task.go b/task/task.go new file mode 100644 index 0000000..a0eecf1 --- /dev/null +++ b/task/task.go @@ -0,0 +1,184 @@ +package task + +import ( + "fmt" + "log" + "strconv" + "time" + + "github.com/vshn/gitlab-scheduled-merge/client" + "github.com/robfig/cron/v3" + "github.com/xanzy/go-gitlab" + "go.uber.org/multierr" + "gopkg.in/yaml.v3" +) + +type TaskConfig struct { + MergeRequestScheduledLabel string + ConfigFilePath string +} + +type Task struct { + config TaskConfig + client *client.GitlabClient +} + +type RepositoryConfig struct { + MergeWindows []MergeWindow `yaml:"mergeWindows"` +} +type MergeWindow struct { + Schedule MergeSchedule `yaml:schedule` + MaxDelay time.Duration `yaml:"maxDelay"` +} +type MergeSchedule struct { + Cron string `yaml:"cron"` + IsoWeek string `yaml:"isoWeek"` + Location string `yaml:"location"` +} + +func NewTask(client *client.GitlabClient, config TaskConfig) Task { + return Task{ + config: config, + client: client, + } +} + +func (t Task) Run() error { + log.Println("Running task...") + mrs, err := t.client.ListMrsWithLabel(t.config.MergeRequestScheduledLabel) + if err != nil { + return fmt.Errorf("failed to list MRs: %w", err) + } + + errs := make([]error, 0) + for _, mr := range mrs { + err := t.processMR(mr) + if err != nil { + errs = append(errs, err) + } + } + return multierr.Combine(errs...) +} + +func (t Task) processMR(mr *gitlab.MergeRequest) error { + file, err := t.client.GetConfigFileForMR(mr, t.config.ConfigFilePath) + + if err != nil { + return t.client.Comment(mr, "Failed to schedule merge: missing config file.") + } + + config := RepositoryConfig{} + err = yaml.Unmarshal(*file, &config) + + if err != nil { + return t.client.Comment(mr, "Failed to schedule merge: error while parsing config file.") + } + + now := time.Now() + var earliestMergeWindow *MergeWindow = nil + earliestMergeWindowTime := now.Add(1000000 * time.Hour) + for _, w := range config.MergeWindows { + nextActiveStartTime, err := w.getNextActiveWindowStartTime(now) + if err != nil { + return t.client.Comment(mr, "Failed to schedule merge: error while parsing merge windows.") + } + if nextActiveStartTime.Before(now) { + return t.mergeMR(mr) + } + if nextActiveStartTime.Before(earliestMergeWindowTime) { + earliestMergeWindow = &w + earliestMergeWindowTime = nextActiveStartTime + } + } + nextActiveEndTime := earliestMergeWindowTime.Add(earliestMergeWindow.MaxDelay) + + msg := fmt.Sprintf( + "Merge scheduled. This MR will be merged between %s and %s.", + earliestMergeWindowTime.Format(time.UnixDate), + nextActiveEndTime.Format(time.UnixDate), + ) + + if !client.IsMergeable(mr) { + msg = fmt.Sprintf( + "%s\n\nWarning: This merge request is currently not mergeable. Current status: %s", + msg, + mr.DetailedMergeStatus, + ) + + } + return t.client.Comment(mr, msg) +} + +func (t Task) mergeMR(mr *gitlab.MergeRequest) error { + // We need to recheck MRs - we might in the interim have merged other things that led to conflicts + rmr, err := t.client.RefreshMr(mr) + if err != nil { + return t.client.Comment(mr, "Failed to merge: error while refreshing merge request data.") + } + + if !client.IsMergeable(rmr) { + return t.client.Comment(mr, fmt.Sprintf("Not merging automatically: MR is not mergeable. Current status: %s", rmr.DetailedMergeStatus)) + } + + err = t.client.MergeMr(rmr) + if err != nil { + return t.client.Comment(mr, "Failed to merge: error while merging.") + } + + return nil +} + +// getNextActiveWindowStartTime returns the start time of the next active merge window +// including potential windows that have already started but are still active at timestamp `t` +func (w MergeWindow) getNextActiveWindowStartTime(t time.Time) (time.Time, error) { + location := time.Local + if w.Schedule.Location != "" { + l, err := time.LoadLocation(w.Schedule.Location) + if err != nil { + return time.Time{}, fmt.Errorf("Failed to load location for merge window: %w", err) + } + location = l + } + now := t.In(location) + earliestTime := now.Add(-w.MaxDelay) + + sched, err := cron.ParseStandard(w.Schedule.Cron) + if err != nil { + return time.Time{}, fmt.Errorf("Failed to parse cron schedule: %w", err) + } + + nextRun := sched.Next(earliestTime) + for i := 0; i < 1000; i++ { + isoWeekOK, err := w.checkIsoWeek(nextRun) + if err != nil { + return time.Time{}, err + } + if isoWeekOK { + return nextRun, nil + } + nextRun = sched.Next(nextRun) + } + return time.Time{}, fmt.Errorf("could not find next run, max time: %s", nextRun) +} + +// checkIsoWeek checks if the given time is in the given iso week. +// The iso week can be one of the following: +// - "": every iso week +// - "@even": every even iso week +// - "@odd": every odd iso week +// - "": every iso week N +func (w MergeWindow) checkIsoWeek(t time.Time) (bool, error) { + _, iw := t.ISOWeek() + switch w.Schedule.IsoWeek { + case "": + return true, nil + case "@even": + return iw%2 == 0, nil + case "@odd": + return iw%2 == 1, nil + case strconv.Itoa(iw): + return true, nil + default: + return false, fmt.Errorf("unknown iso week: %s", w.Schedule.IsoWeek) + } +}