diff --git a/Makefile b/Makefile index faf49a1..7a401a6 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,7 @@ gen_mocks: $(info * generating mock code) mockgen -package mocks -source internal/autoupdate/autoupdate.go -destination internal/autoupdate/mocks/autoupdate.go mockgen -package mocks -source internal/githubclt/client.go -destination internal/autoupdate/mocks/githubclient.go + mockgen -package mocks -destination internal/autoupdate/mocks/ciclient.go github.com/simplesurance/directorius/internal/autoupdate CIClient .PHONY: check check: diff --git a/cmd/directorius/main.go b/cmd/directorius/main.go index 3611574..1067bde 100644 --- a/cmd/directorius/main.go +++ b/cmd/directorius/main.go @@ -5,6 +5,7 @@ import ( "errors" "flag" "fmt" + "math" "net/http" "os" "time" @@ -13,8 +14,10 @@ import ( "github.com/simplesurance/directorius/internal/cfg" "github.com/simplesurance/directorius/internal/githubclt" "github.com/simplesurance/directorius/internal/goordinator" + "github.com/simplesurance/directorius/internal/jenkins" "github.com/simplesurance/directorius/internal/logfields" "github.com/simplesurance/directorius/internal/provider/github" + "github.com/simplesurance/directorius/internal/set" "github.com/spf13/pflag" zaplogfmt "github.com/sykesm/zap-logfmt" @@ -32,7 +35,7 @@ const ( var logger *zap.Logger -// Version is set via a ldflag on compilation +// Version is set by goreleaser var Version = "version unknown" const EventChannelBufferSize = 1024 @@ -48,7 +51,7 @@ func exitOnErr(msg string, err error) { func panicHandler() { if r := recover(); r != nil { - logger.Info( + logger.Error( "panic caught, terminating gracefully", zap.String("panic", fmt.Sprintf("%v", r)), zap.StackSkip("stacktrace", 1), @@ -283,6 +286,7 @@ func mustInitLogger(config *cfg.Config) { logger = logger.Named("main") zap.ReplaceGlobals(logger) + // TODO: call goodbye.Exit on Fatal and Panic calls (zap.WithFatalHook) goodbye.Register(func(context.Context, os.Signal) { if err := logger.Sync(); err != nil { @@ -307,32 +311,34 @@ func normalizeHTTPEndpoint(endpoint string) string { return endpoint } -func mustStartPullRequestAutoupdater(config *cfg.Config, githubClient *githubclt.Client, mux *http.ServeMux) (*autoupdate.Autoupdater, chan<- *github.Event) { - if !config.TriggerOnAutoMerge && len(config.TriggerOnLabels) == 0 { - fmt.Fprintf(os.Stderr, "ERROR: config file: %s: trigger_on_auto_merge must be true or trigger_labels must be defined, both are empty in the configuration file\n", *args.ConfigFile) - os.Exit(1) +func mustConfigCItoAutoupdaterCI(cfg *cfg.CI) *autoupdate.CI { + if cfg == nil { + return nil } - if len(config.HeadLabel) == 0 { - fmt.Fprintf(os.Stderr, "ERROR: config file %s: queue_pr_head_label must be provided when autoupdater is enabled\n", *args.ConfigFile) + if len(cfg.Jobs) > 0 && cfg.ServerURL == "" { + fmt.Fprintf(os.Stderr, "ERROR: config file: %s: ci.jobs are defined but ci.server_url is empty\n", *args.ConfigFile) os.Exit(1) } - if len(config.GithubAPIToken) == 0 { - fmt.Fprintf(os.Stderr, "ERROR: config file %s: github_api_token must be provided when autoupdater is enabled\n", *args.ConfigFile) + if cfg.ServerURL != "" && len(cfg.Jobs) == 0 { + fmt.Fprintf(os.Stderr, "ERROR: config file: %s: ci.server_url is defined but no ci.jobs are defined\n", *args.ConfigFile) os.Exit(1) - } - if len(config.HTTPGithubWebhookEndpoint) == 0 { - fmt.Fprintf(os.Stderr, "ERROR: config file %s: github_webhook_endpoint must be provided when autoupdater is enabled\n", *args.ConfigFile) - os.Exit(1) } - if len(config.Repositories) == 0 { - logger.Info("github pull request updater is disabled, autoupdater.repository config field is empty") - return nil, nil + var result autoupdate.CI + result.Client = jenkins.NewClient(cfg.ServerURL, cfg.BasicAuth.User, cfg.BasicAuth.Password) + for _, job := range cfg.Jobs { + result.Jobs = append(result.Jobs, &jenkins.JobTemplate{ + RelURL: job.Endpoint, + Parameters: job.Parameters, + }) } + return &result +} +func mustStartPullRequestAutoupdater(config *cfg.Config, ch chan *github.Event, githubClient *githubclt.Client, mux *http.ServeMux) *autoupdate.Autoupdater { repos := make([]autoupdate.Repository, 0, len(config.Repositories)) for _, r := range config.Repositories { repos = append(repos, autoupdate.Repository{ @@ -341,18 +347,30 @@ func mustStartPullRequestAutoupdater(config *cfg.Config, githubClient *githubclt }) } - ch := make(chan *github.Event, EventChannelBufferSize) - autoupdater := autoupdate.NewAutoupdater( - githubClient, - ch, - goordinator.NewRetryer(), - repos, - config.TriggerOnAutoMerge, - config.TriggerOnLabels, - config.HeadLabel, - autoupdate.DryRun(*args.DryRun), + autoupdate.Config{ + GitHubClient: githubClient, + EventChan: ch, + Retryer: goordinator.NewRetryer(), + MonitoredRepositories: set.From(repos), + TriggerOnAutomerge: config.TriggerOnAutoMerge, + TriggerLabels: set.From(config.TriggerOnLabels), + HeadLabel: config.HeadLabel, + DryRun: *args.DryRun, + CI: mustConfigCItoAutoupdaterCI(&config.CI), + }, ) + + ctx, cancelFn := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancelFn() + if err := autoupdater.InitSync(ctx); err != nil { + logger.Error( + "autoupdater: initial synchronization failed", + logfields.Event("autoupdate_initial_sync_failed"), + zap.Error(err), + ) + } + autoupdater.Start() if config.WebInterfaceEndpoint != "" { @@ -364,13 +382,43 @@ func mustStartPullRequestAutoupdater(config *cfg.Config, githubClient *githubclt ) } - return autoupdater, ch + return autoupdater +} + +func mustValidateConfig(config *cfg.Config) { + if config.HTTPListenAddr == "" && config.HTTPSListenAddr == "" { + fmt.Fprintf(os.Stderr, "https_server_listen_addr or http_server_listen_addr must be defined in the config file, both are unset") + os.Exit(1) + } + + if !config.TriggerOnAutoMerge && len(config.TriggerOnLabels) == 0 { + fmt.Fprintf(os.Stderr, "ERROR: config file: %s: trigger_on_auto_merge must be true or trigger_labels must be defined, both are empty in the configuration file\n", *args.ConfigFile) + os.Exit(1) + } + + if len(config.HeadLabel) == 0 { + fmt.Fprintf(os.Stderr, "ERROR: config file %s: queue_pr_head_label must be provided when autoupdater is enabled\n", *args.ConfigFile) + os.Exit(1) + } + + if len(config.GithubAPIToken) == 0 { + fmt.Fprintf(os.Stderr, "ERROR: config file %s: github_api_token must be provided when autoupdater is enabled\n", *args.ConfigFile) + os.Exit(1) + } + + if len(config.HTTPGithubWebhookEndpoint) == 0 { + fmt.Fprintf(os.Stderr, "ERROR: config file %s: github_webhook_endpoint must be provided when autoupdater is enabled\n", *args.ConfigFile) + os.Exit(1) + } + + if len(config.Repositories) == 0 { + logger.Info("github pull request updater is disabled, autoupdater.repository config field is empty") + } } func main() { defer panicHandler() - defer goodbye.Exit(context.Background(), 1) goodbye.Notify(context.Background()) mustParseCommandlineParams() @@ -381,13 +429,11 @@ func main() { } config := mustParseCfg() + mustValidateConfig(config) mustInitLogger(config) - githubClient := githubclt.New(config.GithubAPIToken) - - logger.Info( - "loaded cfg file", + logger.Info("loaded cfg file", logfields.Event("cfg_loaded"), zap.String("cfg_file", *args.ConfigFile), zap.String("http_server_listen_addr", config.HTTPListenAddr), @@ -403,31 +449,25 @@ func main() { zap.Strings("trigger_labels", config.TriggerOnLabels), zap.Any("repositories", config.Repositories), zap.String("webinterface_endpoint", config.WebInterfaceEndpoint), + zap.String("ci.base_url", config.CI.ServerURL), + zap.String("ci.basic_auth.user", config.CI.BasicAuth.User), + zap.String("ci.basic_auth.password", hide(config.CI.BasicAuth.User)), + zap.Any("ci.job", config.CI.Jobs), ) - goodbye.Register(func(_ context.Context, sig os.Signal) { - logger.Info(fmt.Sprintf("terminating, received signal %s", sig.String())) - }) - - if config.HTTPListenAddr == "" && config.HTTPSListenAddr == "" { - fmt.Fprintf(os.Stderr, "https_server_listen_addr or http_server_listen_addr must be defined in the config file, both are unset") - os.Exit(1) - } + githubClient := githubclt.New(config.GithubAPIToken) - var chans []chan<- *github.Event + goodbye.RegisterWithPriority(func(_ context.Context, sig os.Signal) { + logger.Info(fmt.Sprintf("terminating, received signal %s", sig.String())) + }, math.MinInt) mux := http.NewServeMux() - autoupdater, ch := mustStartPullRequestAutoupdater(config, githubClient, mux) - if ch != nil { - chans = append(chans, ch) - } - + ch := make(chan *github.Event, EventChannelBufferSize) gh := github.New( - chans, + []chan<- *github.Event{ch}, github.WithPayloadSecret(config.GithubWebHookSecret), ) - mux.HandleFunc(config.HTTPGithubWebhookEndpoint, gh.HTTPHandler) logger.Info( "registered github webhook event http endpoint", @@ -457,26 +497,13 @@ func main() { ) } - goodbye.Register(func(context.Context, os.Signal) { - logger.Debug( - "stopping event loop", - logfields.Event("event_loop_stopping"), - ) - }) + autoupdater := mustStartPullRequestAutoupdater(config, ch, githubClient, mux) - if autoupdater != nil { - ctx, cancelFn := context.WithTimeout(context.Background(), 15*time.Minute) - if err := autoupdater.InitSync(ctx); err != nil { - logger.Error( - "autoupdater: initial synchronization failed", - logfields.Event("autoupdate_initial_sync_failed"), - zap.Error(err), - ) - } - cancelFn() - - autoupdater.Start() - } + waitForTermCh := make(chan struct{}) + goodbye.RegisterWithPriority(func(context.Context, os.Signal) { + autoupdater.Stop() + close(waitForTermCh) + }, -1) - select {} // TODO: refactor this, allow clean shutdown + <-waitForTermCh } diff --git a/config.example.toml b/config.example.toml index 1e0776e..e26607e 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,7 +1,7 @@ -#http_server_listen_addr = ":8085" -https_server_listen_addr = ":8084" -https_ssl_cert_file = "" -https_ssl_key_file = "" +http_server_listen_addr = ":8085" +#https_server_listen_addr = ":8084" +#https_ssl_cert_file = "" +#https_ssl_key_file = "" # the local http and https endpoints that receives github webhook events github_webhook_endpoint = "/listener/github" @@ -22,7 +22,7 @@ log_format = "logfmt" log_time_key = "time_iso8601" # log_level controls the priority threshold for log messages. -# All messages with the the specified or a higher priority are logged. +# All messages with the specified or a higher priority are logged. # Supported values: debug, info, warn, error, panic, fatal log_level = "info" @@ -43,3 +43,21 @@ queue_pr_head_label = "autoupdater-first" owner = "simplesurance" repository = "goordinator" +[ci] + server_url = "https://jenkins" + basic_auth.user = "cetautomatix" + basic_auth.password = "hammer" + + [[ci.job]] + endpoint = "job/check/{{ queryescape .Branch }}/build" + + [[ci.job]] + endpoint = "job/build/{{ queryescape .PullRequestNr }}/build" + + [[ci.job]] + endpoint = "job/test/buildWithParameters" + + [ci.job.parameters] + branch = "{{ .Branch }}" + pr_nr = "{{ .PullRequestNr }}" + diff --git a/internal/autoupdate/autoupdate.go b/internal/autoupdate/autoupdate.go index 2ce81ae..cf0ddb4 100644 --- a/internal/autoupdate/autoupdate.go +++ b/internal/autoupdate/autoupdate.go @@ -19,8 +19,6 @@ import ( "github.com/simplesurance/directorius/internal/set" ) -const loggerName = "autoupdater" - const defPeriodicTriggerInterval = 30 * time.Minute // GithubClient defines the methods of a GithubAPI Client that are used by the @@ -34,8 +32,7 @@ type GithubClient interface { AddLabel(ctx context.Context, owner, repo string, pullRequestOrIssueNumber int, label string) error } -// Retryer defines methods for running GithubClient operations repeately if -// they fail with a temporary error. +// Retryer defines methods for running GithubClient operations repeately if they fail with a temporary error. type Retryer interface { Run(context.Context, func(context.Context) error, []zap.Field) error } @@ -45,27 +42,19 @@ type Retryer interface { // base-branch. // Pull request branch updates are serialized per base-branch. type Autoupdater struct { - triggerOnAutomerge bool - triggerLabels map[string]struct{} - headLabel string - monitoredRepos map[Repository]struct{} + *Config // periodicTriggerIntv defines the time span between triggering // updates for the first pull request in the queues periodically. + // TODO: move periodicTriggerIntv to config periodicTriggerIntv time.Duration - ch <-chan *github_prov.Event - logger *zap.Logger - // queues contains a queue for each base-branch for which pull requests // are queued for autoupdates. queues map[BranchID]*queue // queuesLock must be hold when accessing queues queuesLock sync.Mutex - ghClient GithubClient - retryer Retryer - // wg is a waitgroup for the event-loop go-routine. wg sync.WaitGroup // shutdownChan can be closed to communicate to the event-loop go-routine to terminate. @@ -87,22 +76,6 @@ func (r *Repository) String() string { return fmt.Sprintf("%s/%s", r.OwnerLogin, r.RepositoryName) } -// DryRun is an option for NewAutoupdater. -// If it is enabled all GitHub API operation that could result in a change will -// be simulated and always succeed. -func DryRun(enabled bool) Opt { - return func(a *Autoupdater) { - if !enabled { - return - } - - a.ghClient = NewDryGithubClient(a.ghClient, a.logger) - a.logger.Info("dry run enabled") - } -} - -type Opt func(*Autoupdater) - // NewAutoupdater creates an Autoupdater instance. // Only webhook events for repositories listed in monitoredRepositories are processed. // At least one trigger (triggerOnAutomerge or a label in triggerLabels) must @@ -110,40 +83,26 @@ type Opt func(*Autoupdater) // When multiple event triggers are configured, the autoupdater reacts on each // received Event individually. // headLabel is the name of the GitHub label that is applied to the PR that is the first in the queue. -func NewAutoupdater( - ghClient GithubClient, - eventChan <-chan *github_prov.Event, - retryer Retryer, - monitoredRepositories []Repository, - triggerOnAutomerge bool, - triggerLabels []string, - headLabel string, - opts ...Opt, -) *Autoupdater { - repoMap := make(map[Repository]struct{}, len(monitoredRepositories)) - for _, r := range monitoredRepositories { - repoMap[r] = struct{}{} - } - +func NewAutoupdater(cfg Config) *Autoupdater { a := Autoupdater{ - ghClient: ghClient, - ch: eventChan, - logger: zap.L().Named(loggerName), + Config: &cfg, queues: map[BranchID]*queue{}, - retryer: retryer, wg: sync.WaitGroup{}, - triggerOnAutomerge: triggerOnAutomerge, - triggerLabels: set.From(triggerLabels), - headLabel: headLabel, - monitoredRepos: repoMap, periodicTriggerIntv: defPeriodicTriggerInterval, shutdownChan: make(chan struct{}, 1), } - for _, opt := range opts { - opt(&a) + cfg.setDefaults() + + if a.DryRun { + // TODO: let the caller of the constructor provide the dry clients instead + a.GitHubClient = NewDryGithubClient(a.GitHubClient, a.Logger) + a.CI.Client = NewDryCIClient(a.Logger) + a.Logger.Info("dry run enabled") } + cfg.mustValidate() + return &a } @@ -162,14 +121,14 @@ func ghBranchesAsStrings(branches []*github.Branch) []string { return result } -// isMonitoredRepository returns true if the repository is listed in the a.monitoredRepos. -func (a *Autoupdater) isMonitoredRepository(owner, repositoryName string) bool { +// isMonitoredRepositoriesitory returns true if the repository is listed in the a.monitoredRepos. +func (a *Autoupdater) isMonitoredRepositoriesitory(owner, repositoryName string) bool { repo := Repository{ OwnerLogin: owner, RepositoryName: repositoryName, } - _, exist := a.monitoredRepos[repo] + _, exist := a.MonitoredRepositories[repo] return exist } @@ -179,16 +138,16 @@ func (a *Autoupdater) isMonitoredRepository(owner, repositoryName string) bool { // that pull requests became stuck because GitHub webhook event was missed. // The eventLoop terminates when a.shutdownChan is closed. func (a *Autoupdater) eventLoop() { - a.logger.Info("autoupdater event loop started") + a.Logger.Info("autoupdater event loop started") periodicTrigger := time.NewTicker(a.periodicTriggerIntv) defer periodicTrigger.Stop() for { select { - case event, open := <-a.ch: + case event, open := <-a.EventChan: if !open { - a.logger.Info("autoupdater event loop terminated") + a.Logger.Info("autoupdater event loop terminated") return } @@ -197,13 +156,13 @@ func (a *Autoupdater) eventLoop() { case <-periodicTrigger.C: a.queuesLock.Lock() for _, q := range a.queues { - q.ScheduleUpdate(context.Background()) - a.logger.Debug("periodic run scheduled", q.baseBranch.Logfields...) + q.ScheduleUpdate(context.Background(), TaskNone) + a.Logger.Debug("periodic run scheduled", q.baseBranch.Logfields...) } a.queuesLock.Unlock() case <-a.shutdownChan: - a.logger.Info("event loop terminating") + a.Logger.Info("event loop terminating") return } } @@ -261,11 +220,11 @@ func (a *Autoupdater) processEvent(ctx context.Context, event *github_prov.Event metrics.ProcessedEventsInc() }() - logger := a.logger.With(event.LogFields...) + logger := a.Logger.With(event.LogFields...) switch ev := event.Event.(type) { case *github.PullRequestEvent: - if !a.isMonitoredRepository(ev.GetRepo().GetOwner().GetLogin(), ev.GetRepo().GetName()) { + if !a.isMonitoredRepositoriesitory(ev.GetRepo().GetOwner().GetLogin(), ev.GetRepo().GetName()) { logger.Debug( "event is for unmonitored repository", logEventEventIgnored, @@ -277,7 +236,7 @@ func (a *Autoupdater) processEvent(ctx context.Context, event *github_prov.Event a.processPullRequestEvent(ctx, logger, ev) case *github.PushEvent: - if !a.isMonitoredRepository(ev.GetRepo().GetOwner().GetLogin(), ev.GetRepo().GetName()) { + if !a.isMonitoredRepositoriesitory(ev.GetRepo().GetOwner().GetLogin(), ev.GetRepo().GetName()) { logger.Debug( "event is for repository that is not monitored", logEventEventIgnored, @@ -289,7 +248,7 @@ func (a *Autoupdater) processEvent(ctx context.Context, event *github_prov.Event a.processPushEvent(ctx, logger, ev) case *github.StatusEvent: - if !a.isMonitoredRepository(ev.GetRepo().GetOwner().GetLogin(), ev.GetRepo().GetName()) { + if !a.isMonitoredRepositoriesitory(ev.GetRepo().GetOwner().GetLogin(), ev.GetRepo().GetName()) { logger.Debug( "event is for repository that is not monitored", logEventEventIgnored, @@ -301,7 +260,7 @@ func (a *Autoupdater) processEvent(ctx context.Context, event *github_prov.Event a.processStatusEvent(ctx, logger, ev) case *github.CheckRunEvent: - if !a.isMonitoredRepository(ev.GetRepo().GetOwner().GetLogin(), ev.GetRepo().GetName()) { + if !a.isMonitoredRepositoriesitory(ev.GetRepo().GetOwner().GetLogin(), ev.GetRepo().GetName()) { logger.Debug( "event is for repository that is not monitored", logEventEventIgnored, @@ -312,7 +271,7 @@ func (a *Autoupdater) processEvent(ctx context.Context, event *github_prov.Event a.processCheckRunEvent(ctx, logger, ev) case *github.PullRequestReviewEvent: - if !a.isMonitoredRepository(ev.GetRepo().GetOwner().GetLogin(), ev.GetRepo().GetName()) { + if !a.isMonitoredRepositoriesitory(ev.GetRepo().GetOwner().GetLogin(), ev.GetRepo().GetName()) { logger.Debug( "event is for repository that is not monitored", logEventEventIgnored, @@ -367,7 +326,7 @@ func (a *Autoupdater) processPullRequestEvent(ctx context.Context, logger *zap.L // also have to monitor Open-Events for PRs that have the label already? case "auto_merge_enabled": - if !a.triggerOnAutomerge { + if !a.TriggerOnAutomerge { logger.Debug( "event ignored, triggerOnAutomerge is disabled", logEventEventIgnored, @@ -436,7 +395,7 @@ func (a *Autoupdater) processPullRequestEvent(ctx context.Context, logger *zap.L return } - if _, exist := a.triggerLabels[labelName]; !exist { + if !a.TriggerLabels.Contains(labelName) { return } @@ -480,7 +439,7 @@ func (a *Autoupdater) processPullRequestEvent(ctx context.Context, logger *zap.L ) case "auto_merge_disabled": - if !a.triggerOnAutomerge { + if !a.TriggerOnAutomerge { logger.Debug( "event ignored, triggerOnAutomerge is disabled", logEventEventIgnored, @@ -550,7 +509,7 @@ func (a *Autoupdater) processPullRequestEvent(ctx context.Context, logger *zap.L return } - if _, exist := a.triggerLabels[labelName]; !exist { + if !a.TriggerLabels.Contains(labelName) { return } @@ -601,7 +560,7 @@ func (a *Autoupdater) processPullRequestEvent(ctx context.Context, logger *zap.L return } - _, err = a.TriggerUpdateIfFirst(ctx, bb, &PRNumber{Number: prNumber}) + _, err = a.TriggerUpdateIfFirst(ctx, bb, &PRNumber{Number: prNumber}, TaskTriggerCI) if err == nil { logger.Info( "update for pull request triggered", @@ -717,17 +676,25 @@ func (a *Autoupdater) processPushEvent(ctx context.Context, logger *zap.Logger, return } - if err := a.UpdateBranch(ctx, bb); err != nil { - if !errors.Is(err, ErrNotFound) { - logger.Error( - "triggering updates for pr failed", - zap.Error(err), - logEventEventIgnored, - ) - } + + a.queuesLock.Lock() + defer a.queuesLock.Unlock() + + q, exist := a.queues[bb.BranchID] + if !exist { + logger.Error( + "triggering updates for pr failed", + zap.Error(err), + logEventEventIgnored, + ) } + q.ScheduleUpdate(ctx, TaskTriggerCI) - a.ResumeAllForBaseBranch(ctx, bb) + for bbID, q := range a.queues { + if bbID == bb.BranchID { + q.ResumeAll() + } + } } func (a *Autoupdater) processPullRequestReviewEvent(ctx context.Context, logger *zap.Logger, ev *github.PullRequestReviewEvent) { @@ -775,7 +742,7 @@ func (a *Autoupdater) processPullRequestReviewEvent(ctx context.Context, logger return } - _, err = a.TriggerUpdateIfFirst(ctx, bb, &PRNumber{Number: prNumber}) + _, err = a.TriggerUpdateIfFirst(ctx, bb, &PRNumber{Number: prNumber}, TaskNone) if err != nil { if errors.Is(err, ErrNotFound) { return @@ -976,7 +943,7 @@ func (a *Autoupdater) Enqueue(_ context.Context, baseBranch *BaseBranch, pr *Pul var q *queue var exist bool - logger := a.logger.With(baseBranch.Logfields...).With(pr.LogFields...) + logger := a.Logger.With(baseBranch.Logfields...).With(pr.LogFields...) a.queuesLock.Lock() defer a.queuesLock.Unlock() @@ -985,10 +952,11 @@ func (a *Autoupdater) Enqueue(_ context.Context, baseBranch *BaseBranch, pr *Pul if !exist { q = newQueue( baseBranch, - a.logger, - a.ghClient, - a.retryer, - a.headLabel, + a.Logger, + a.GitHubClient, + a.Retryer, + a.CI, + a.HeadLabel, ) a.queues[baseBranch.BranchID] = q @@ -1034,7 +1002,7 @@ func (a *Autoupdater) Dequeue(_ context.Context, baseBranch *BaseBranch, prNumbe q.Stop() delete(a.queues, baseBranch.BranchID) - logger := a.logger.With(pr.LogFields...).With(baseBranch.Logfields...) + logger := a.Logger.With(pr.LogFields...).With(baseBranch.Logfields...) logger.Debug("empty queue for base branch removed") } @@ -1042,19 +1010,6 @@ func (a *Autoupdater) Dequeue(_ context.Context, baseBranch *BaseBranch, prNumbe return pr, nil } -// ResumeAllForBaseBranch resumes updates for all Pull Requests that are based -// on baseBranch and for which updates are currently suspended. -func (a *Autoupdater) ResumeAllForBaseBranch(_ context.Context, baseBranch *BaseBranch) { - a.queuesLock.Lock() - defer a.queuesLock.Unlock() - - for bbID, q := range a.queues { - if bbID == baseBranch.BranchID { - q.ResumeAll() - } - } -} - // SetPRStaleSinceIfNewerByBranch sets the staleSince timestamp of the PRs for // the given branch names to updatdAt, if it is newer then the current // staleSince timestamp. @@ -1134,23 +1089,6 @@ func (a *Autoupdater) Resume(_ context.Context, baseBranch *BaseBranch, prNumber return q.Resume(prNumber) } -// UpdateBranch triggers updating the first PR queued for updates for the given -// baseBranch. -// -// See documentation on queue for more information. -func (a *Autoupdater) UpdateBranch(ctx context.Context, baseBranch *BaseBranch) error { - a.queuesLock.Lock() - defer a.queuesLock.Unlock() - - q, exist := a.queues[baseBranch.BranchID] - if !exist { - return ErrNotFound - } - - q.ScheduleUpdate(ctx) - return nil -} - // ChangeBaseBranch dequeues a Pull Request from the queue oldBaseBranch and // enqueues it at the queue for newBaseBranch. func (a *Autoupdater) ChangeBaseBranch( @@ -1178,6 +1116,7 @@ func (a *Autoupdater) TriggerUpdateIfFirst( ctx context.Context, baseBranch *BaseBranch, prSpec PRSpecifier, + task Task, ) (*PullRequest, error) { a.queuesLock.Lock() defer a.queuesLock.Unlock() @@ -1187,15 +1126,16 @@ func (a *Autoupdater) TriggerUpdateIfFirst( return nil, ErrNotFound } - return a._triggerUpdateIfFirst(ctx, q, prSpec) + return a._triggerUpdateIfFirst(ctx, q, prSpec, task) } func (a *Autoupdater) _triggerUpdateIfFirst( ctx context.Context, q *queue, prSpec PRSpecifier, + task Task, ) (*PullRequest, error) { - logger := a.logger.With(q.baseBranch.Logfields...).With(prSpec.LogField()) + logger := a.Logger.With(q.baseBranch.Logfields...).With(prSpec.LogField()) // there is a chance of a race here, the pr might not be first anymore // when ScheduleUpdateFirstPR() is called, this does not matter, if it @@ -1209,13 +1149,13 @@ func (a *Autoupdater) _triggerUpdateIfFirst( switch v := prSpec.(type) { case *PRNumber: if first.Number == v.Number { - q.ScheduleUpdate(ctx) + q.ScheduleUpdate(ctx, task) return first, nil } case *PRBranch: if first.Branch == v.BranchName { - q.ScheduleUpdate(ctx) + q.ScheduleUpdate(ctx, task) return first, nil } @@ -1244,7 +1184,7 @@ func (a *Autoupdater) TriggerUpdateIfFirstAllQueues( continue } - pr, err := a._triggerUpdateIfFirst(ctx, q, prSpec) + pr, err := a._triggerUpdateIfFirst(ctx, q, prSpec, TaskNone) if err == nil { return pr, nil } @@ -1271,7 +1211,7 @@ func (a *Autoupdater) Start() { // Stop stops the event-loop and waits until it terminates. // All queues will be deleted, operations that are in progress will be canceled. func (a *Autoupdater) Stop() { - a.logger.Debug("autoupdater terminating") + a.Logger.Debug("autoupdater terminating") select { case <-a.shutdownChan: // already closed @@ -1279,7 +1219,7 @@ func (a *Autoupdater) Stop() { close(a.shutdownChan) } - a.logger.Debug("waiting for event-loop to terminate") + a.Logger.Debug("waiting for event-loop to terminate") a.wg.Wait() a.queuesLock.Lock() @@ -1290,7 +1230,7 @@ func (a *Autoupdater) Stop() { delete(a.queues, branchID) } - a.logger.Debug("autoupdater terminated") + a.Logger.Debug("autoupdater terminated") } func (a *Autoupdater) HTTPService() *HTTPService { diff --git a/internal/autoupdate/autoupdate_sync.go b/internal/autoupdate/autoupdate_sync.go index 6fa9e60..175d06d 100644 --- a/internal/autoupdate/autoupdate_sync.go +++ b/internal/autoupdate/autoupdate_sync.go @@ -29,7 +29,7 @@ const ( // If it meets a condition for not being autoupdated, it is dequeued. // If a PR has the [a.headLabel] set it is removed. func (a *Autoupdater) InitSync(ctx context.Context) error { - for repo := range a.monitoredRepos { + for repo := range a.MonitoredRepositories { err := a.sync(ctx, repo.OwnerLogin, repo.RepositoryName) if err != nil { return fmt.Errorf("syncing %s failed: %w", repo, err) @@ -42,7 +42,7 @@ func (a *Autoupdater) InitSync(ctx context.Context) error { func (a *Autoupdater) sync(ctx context.Context, owner, repo string) error { stats := syncStat{StartTime: time.Now()} - logger := a.logger.With( + logger := a.Logger.With( logfields.Repository(repo), logfields.RepositoryOwner(owner), ) @@ -66,12 +66,12 @@ func (a *Autoupdater) sync(ctx context.Context, owner, repo string) error { // TODO: could we query less pull requests by ignoring PRs that are // closed and were last changed before goordinator started? - it := a.ghClient.ListPullRequests(ctx, owner, repo, stateFilter, "asc", "created") + it := a.GitHubClient.ListPullRequests(ctx, owner, repo, stateFilter, "asc", "created") for { var pr *github.PullRequest // TODO: use a lower timeout for the retries, otherwise we might get stuck here for too long on startup - err := a.retryer.Run(ctx, func(context.Context) error { + err := a.Retryer.Run(ctx, func(context.Context) error { var err error pr, err = it.Next() return err @@ -206,10 +206,10 @@ func (a *Autoupdater) removeLabel(ctx context.Context, repoOwner, repo string, g return err } - return a.retryer.Run(ctx, func(ctx context.Context) error { - return a.ghClient.RemoveLabel(ctx, + return a.Retryer.Run(ctx, func(ctx context.Context) error { + return a.GitHubClient.RemoveLabel(ctx, repoOwner, repo, pr.Number, - a.headLabel, + a.HeadLabel, ) }, append(pr.LogFields, logfields.Event("github_remove_label"))) } @@ -218,7 +218,7 @@ func (a *Autoupdater) evaluateActions(pr *github.PullRequest) []syncAction { var result []syncAction for _, label := range pr.Labels { - if label.GetName() == a.headLabel { + if label.GetName() == a.HeadLabel { result = append(result, unlabel) } } @@ -227,14 +227,14 @@ func (a *Autoupdater) evaluateActions(pr *github.PullRequest) []syncAction { return append(result, dequeue) } - if a.triggerOnAutomerge && pr.GetAutoMerge() != nil { + if a.TriggerOnAutomerge && pr.GetAutoMerge() != nil { return append(result, enqueue) } - if len(a.triggerLabels) != 0 { + if len(a.TriggerLabels) != 0 { for _, label := range pr.Labels { labelName := label.GetName() - if _, exist := a.triggerLabels[labelName]; exist { + if _, exist := a.TriggerLabels[labelName]; exist { return append(result, enqueue) } } diff --git a/internal/autoupdate/autoupdate_test.go b/internal/autoupdate/autoupdate_test.go index fe3ded6..2b31c72 100644 --- a/internal/autoupdate/autoupdate_test.go +++ b/internal/autoupdate/autoupdate_test.go @@ -19,6 +19,8 @@ import ( "github.com/simplesurance/directorius/internal/autoupdate/mocks" "github.com/simplesurance/directorius/internal/githubclt" "github.com/simplesurance/directorius/internal/goordinator" + "github.com/simplesurance/directorius/internal/jenkins" + "github.com/simplesurance/directorius/internal/set" github_prov "github.com/simplesurance/directorius/internal/provider/github" ) @@ -34,48 +36,6 @@ const ( condWaitTimeout = 5 * time.Second ) -// mustGetActivePR fetches from the base-branch queue of the autoupdater the -// pull request with the given pull request number. -// If the BaseBranch queue or the pull request does not exist, the testcase fails. -func mustGetActivePR(t *testing.T, autoupdater *Autoupdater, baseBranch *BaseBranch, prNumber int) *PullRequest { - t.Helper() - - autoupdater.queuesLock.Lock() - defer autoupdater.queuesLock.Unlock() - - queue := autoupdater.queues[baseBranch.BranchID] - if queue == nil { - t.Error("queue for base branch does not exist or is nil") - return nil - } - - queue.lock.Lock() - defer queue.lock.Unlock() - - pr := queue.active.Get(prNumber) - if pr == nil { - t.Error("pull request does not exist in active queue") - return nil - } - - return pr -} - -func mustQueueNotExist(t *testing.T, autoupdater *Autoupdater, baseBranch *BaseBranch) { - t.Helper() - - autoupdater.queuesLock.Lock() - defer autoupdater.queuesLock.Unlock() - - queue := autoupdater.queues[baseBranch.BranchID] - if queue != nil { - t.Errorf("queue for base branch (%s) exist but should not", baseBranch) - return - } - - t.Logf("queue for base branch (%s) does not exist", baseBranch) -} - func mockSuccessfulGithubAddLabelQueueHeadCall(clt *mocks.MockGithubClient, expectedPRNr int) *gomock.Call { return clt. EXPECT(). @@ -90,6 +50,18 @@ func mockSuccessfulGithubRemoveLabelQueueHeadCall(clt *mocks.MockGithubClient, e Return(nil) } +func mockCIBuildWithCallCnt(clt *mocks.MockCIClient) *atomic.Uint32 { + var callCounter atomic.Uint32 + + clt. + EXPECT(). + Build(gomock.Any(), gomock.Any()). + Do(func(_, _ any) { + callCounter.Add(1) + }).AnyTimes() + return &callCounter +} + // mockSuccessfulGithubUpdateBranchCall configures the mock to return a // successful response for the UpdateBranch() call if is called for // expectedPRNr. @@ -163,6 +135,18 @@ func (q *queue) suspendedLen() int { return len(q.suspended) } +func waitForCiBuildCallsEqual(t *testing.T, callCounter *atomic.Uint32, expectedCallCount uint32) { + t.Helper() + + require.Eventuallyf( + t, + func() bool { return callCounter.Load() == expectedCallCount }, + condWaitTimeout, + condCheckInterval, + "ci build call counter is %d, expecting %d", callCounter.Load(), expectedCallCount, + ) +} + func waitForQueueUpdateRunsGreaterThan(t *testing.T, q *queue, v uint64) { t.Helper() @@ -211,51 +195,24 @@ func waitForActiveQueueLen(t *testing.T, q *queue, wantedLen int) { ) } -func TestEnqueueDequeue(t *testing.T) { - t.Cleanup(zap.ReplaceGlobals(zaptest.NewLogger(t).Named(t.Name()))) - - evChan := make(chan *github_prov.Event, 10) - defer close(evChan) - - pr, err := NewPullRequest(1, "pr_branch", "", "", "") - require.NoError(t, err) - - mockctrl := gomock.NewController(t) - ghClient := mocks.NewMockGithubClient(mockctrl) - mockSuccessfulGithubUpdateBranchCall(ghClient, pr.Number, true).AnyTimes() - mockReadyForMergeStatus(ghClient, pr.Number, githubclt.ReviewDecisionApproved, githubclt.CIStatusPending).AnyTimes() - mockSuccessfulGithubRemoveLabelQueueHeadCall(ghClient, pr.Number).Times(1) - - retryer := goordinator.NewRetryer() - autoupdater := NewAutoupdater( - ghClient, - evChan, - retryer, - []Repository{{OwnerLogin: repoOwner, RepositoryName: repo}}, - true, - nil, - queueHeadLabel, - ) - - autoupdater.Start() - t.Cleanup(autoupdater.Stop) - - baseBranch, err := NewBaseBranch(repoOwner, repo, "main") - require.NoError(t, err) - - err = autoupdater.Enqueue(context.Background(), baseBranch, pr) - require.NoError(t, err) - - queue := autoupdater.getQueue(baseBranch.BranchID) - require.NotNil(t, queue) - assert.Equal(t, 1, queue.activeLen()) - - mustGetActivePR(t, autoupdater, baseBranch, pr.Number) - - _, err = autoupdater.Dequeue(context.Background(), baseBranch, pr.Number) - require.NoError(t, err) - - mustQueueNotExist(t, autoupdater, baseBranch) +func newAutoupdater( + ghClient GithubClient, + ciClient CIClient, + ch chan *github_prov.Event, + repos []Repository, + triggerOnAutomerge bool, + triggerLabels []string, +) *Autoupdater { + return NewAutoupdater(Config{ + GitHubClient: ghClient, + EventChan: ch, + Retryer: goordinator.NewRetryer(), + MonitoredRepositories: set.From(repos), + TriggerOnAutomerge: triggerOnAutomerge, + TriggerLabels: set.From(triggerLabels), + HeadLabel: queueHeadLabel, + CI: &CI{Client: ciClient}, + }) } func TestPushToBaseBranchTriggersUpdate(t *testing.T) { @@ -266,6 +223,7 @@ func TestPushToBaseBranchTriggersUpdate(t *testing.T) { mockctrl := gomock.NewController(t) ghClient := mocks.NewMockGithubClient(mockctrl) + ciClient := mocks.NewMockCIClient(mockctrl) pr, err := NewPullRequest(1, "pr_branch", "", "", "") require.NoError(t, err) @@ -276,15 +234,13 @@ func TestPushToBaseBranchTriggersUpdate(t *testing.T) { githubclt.ReviewDecisionApproved, githubclt.CIStatusPending, ).AnyTimes() - retryer := goordinator.NewRetryer() - autoupdater := NewAutoupdater( + autoupdater := newAutoupdater( ghClient, + ciClient, evChan, - retryer, []Repository{{OwnerLogin: repoOwner, RepositoryName: repo}}, true, nil, - queueHeadLabel, ) autoupdater.Start() @@ -313,6 +269,7 @@ func TestPushToBaseBranchResumesPRs(t *testing.T) { mockctrl := gomock.NewController(t) ghClient := mocks.NewMockGithubClient(mockctrl) + ciClient := mocks.NewMockCIClient(mockctrl) prNumber := 1 prBranch := "pr_branch" @@ -327,15 +284,13 @@ func TestPushToBaseBranchResumesPRs(t *testing.T) { mockSuccessfulGithubRemoveLabelQueueHeadCall(ghClient, prNumber).Times(1) - retryer := goordinator.NewRetryer() - autoupdater := NewAutoupdater( + autoupdater := newAutoupdater( ghClient, + ciClient, evChan, - retryer, []Repository{{OwnerLogin: repoOwner, RepositoryName: repo}}, true, []string{triggerLabel}, - queueHeadLabel, ) autoupdater.Start() @@ -360,13 +315,16 @@ func TestPushToBaseBranchResumesPRs(t *testing.T) { } func TestPRBaseBranchChangeMovesItToAnotherQueue(t *testing.T) { - t.Cleanup(zap.ReplaceGlobals(zaptest.NewLogger(t).Named(t.Name()))) + t.Cleanup(zap.ReplaceGlobals(zaptest.NewLogger(t, + zaptest.Level(zapcore.DebugLevel), + ).Named(t.Name()).WithOptions(zap.WithCaller(true)))) evChan := make(chan *github_prov.Event, 1) defer close(evChan) mockctrl := gomock.NewController(t) ghClient := mocks.NewMockGithubClient(mockctrl) + ciClient := mocks.NewMockCIClient(mockctrl) prNumber := 1 prBranch := "pr_branch" @@ -381,15 +339,13 @@ func TestPRBaseBranchChangeMovesItToAnotherQueue(t *testing.T) { mockSuccessfulGithubAddLabelQueueHeadCall(ghClient, prNumber).Times(2) mockSuccessfulGithubRemoveLabelQueueHeadCall(ghClient, prNumber).Times(1) - retryer := goordinator.NewRetryer() - autoupdater := NewAutoupdater( + autoupdater := newAutoupdater( ghClient, + ciClient, evChan, - retryer, []Repository{{OwnerLogin: repoOwner, RepositoryName: repo}}, true, []string{triggerLabel}, - queueHeadLabel, ) autoupdater.Start() @@ -429,6 +385,7 @@ func TestUnlabellingPRDequeuesPR(t *testing.T) { mockctrl := gomock.NewController(t) ghClient := mocks.NewMockGithubClient(mockctrl) + ciClient := mocks.NewMockCIClient(mockctrl) prNumber := 1 prBranch := "pr_branch" @@ -440,15 +397,13 @@ func TestUnlabellingPRDequeuesPR(t *testing.T) { githubclt.ReviewDecisionApproved, githubclt.CIStatusPending, ).Times(1) - retryer := goordinator.NewRetryer() - autoupdater := NewAutoupdater( + autoupdater := newAutoupdater( ghClient, + ciClient, evChan, - retryer, []Repository{{OwnerLogin: repoOwner, RepositoryName: repo}}, true, []string{triggerLabel}, - queueHeadLabel, ) mockSuccessfulGithubRemoveLabelQueueHeadCall(ghClient, prNumber).Times(1) @@ -481,6 +436,7 @@ func TestClosingPRDequeuesPR(t *testing.T) { mockctrl := gomock.NewController(t) ghClient := mocks.NewMockGithubClient(mockctrl) + ciClient := mocks.NewMockCIClient(mockctrl) prNumber := 1 prBranch := "pr_branch" @@ -495,15 +451,13 @@ func TestClosingPRDequeuesPR(t *testing.T) { mockSuccessfulGithubAddLabelQueueHeadCall(ghClient, prNumber).Times(1) mockSuccessfulGithubRemoveLabelQueueHeadCall(ghClient, prNumber).Times(1) - retryer := goordinator.NewRetryer() - autoupdater := NewAutoupdater( + autoupdater := newAutoupdater( ghClient, + ciClient, evChan, - retryer, []Repository{{OwnerLogin: repoOwner, RepositoryName: repo}}, true, []string{triggerLabel}, - queueHeadLabel, ) autoupdater.Start() @@ -586,6 +540,7 @@ func TestSuccessStatusOrCheckEventResumesPRs(t *testing.T) { mockctrl := gomock.NewController(t) ghClient := mocks.NewMockGithubClient(mockctrl) + ciClient := mocks.NewMockCIClient(mockctrl) pr1, err := NewPullRequest(1, "pr_branch1", "", "", "") require.NoError(t, err) @@ -631,15 +586,13 @@ func TestSuccessStatusOrCheckEventResumesPRs(t *testing.T) { Return(nil). AnyTimes() - retryer := goordinator.NewRetryer() - autoupdater := NewAutoupdater( + autoupdater := newAutoupdater( ghClient, + ciClient, evChan, - retryer, []Repository{{OwnerLogin: repoOwner, RepositoryName: repo}}, true, nil, - queueHeadLabel, ) autoupdater.Start() @@ -662,7 +615,6 @@ func TestSuccessStatusOrCheckEventResumesPRs(t *testing.T) { queueBaseBranch1 := autoupdater.getQueue(baseBranch1.BranchID) require.NotNil(t, queueBaseBranch1) - waitForSuspendQueueLen(t, queueBaseBranch1, 2) assert.Equal(t, 0, queueBaseBranch1.activeLen(), "active queue") assert.Equal(t, 2, queueBaseBranch1.suspendedLen(), "suspend queue") @@ -742,6 +694,7 @@ func TestFailedStatusEventSuspendsFirstPR(t *testing.T) { mockctrl := gomock.NewController(t) ghClient := mocks.NewMockGithubClient(mockctrl) + ciClient := mocks.NewMockCIClient(mockctrl) pr1, err := NewPullRequest(1, "pr_branch1", "", "", "") require.NoError(t, err) @@ -787,15 +740,13 @@ func TestFailedStatusEventSuspendsFirstPR(t *testing.T) { Return(&githubclt.UpdateBranchResult{HeadCommitID: headCommitID}, nil). AnyTimes() - retryer := goordinator.NewRetryer() - autoupdater := NewAutoupdater( + autoupdater := newAutoupdater( ghClient, + ciClient, evChan, - retryer, []Repository{{OwnerLogin: repoOwner, RepositoryName: repo}}, true, nil, - queueHeadLabel, ) autoupdater.Start() @@ -849,6 +800,7 @@ func TestPRIsSuspendedWhenStatusIsStuck(t *testing.T) { mockctrl := gomock.NewController(t) ghClient := mocks.NewMockGithubClient(mockctrl) + ciClient := mocks.NewMockCIClient(mockctrl) prNumber := 1 triggerLabel := "queue-add" @@ -859,15 +811,13 @@ func TestPRIsSuspendedWhenStatusIsStuck(t *testing.T) { githubclt.ReviewDecisionApproved, githubclt.CIStatusPending, ).MinTimes(2) - retryer := goordinator.NewRetryer() - autoupdater := NewAutoupdater( + autoupdater := newAutoupdater( ghClient, + ciClient, evChan, - retryer, []Repository{{OwnerLogin: repoOwner, RepositoryName: repo}}, true, []string{triggerLabel}, - queueHeadLabel, ) autoupdater.periodicTriggerIntv = time.Second @@ -940,6 +890,7 @@ func TestPRIsSuspendedWhenUptodateAndHasFailedStatus(t *testing.T) { mockctrl := gomock.NewController(t) ghClient := mocks.NewMockGithubClient(mockctrl) + ciClient := mocks.NewMockCIClient(mockctrl) prNumber := 1 prBranch := "pr_branch" @@ -954,15 +905,13 @@ func TestPRIsSuspendedWhenUptodateAndHasFailedStatus(t *testing.T) { githubclt.ReviewDecisionApproved, githubclt.CIStatusFailure, ).AnyTimes() - retryer := goordinator.NewRetryer() - autoupdater := NewAutoupdater( + autoupdater := newAutoupdater( ghClient, + ciClient, evChan, - retryer, []Repository{{OwnerLogin: repoOwner, RepositoryName: repo}}, true, []string{triggerLabel}, - queueHeadLabel, ) autoupdater.Start() @@ -988,6 +937,7 @@ func TestEnqueueDequeueByAutomergeEvents(t *testing.T) { mockctrl := gomock.NewController(t) ghClient := mocks.NewMockGithubClient(mockctrl) + ciClient := mocks.NewMockCIClient(mockctrl) prNumber := 1 prBranch := "pr_branch" @@ -999,15 +949,13 @@ func TestEnqueueDequeueByAutomergeEvents(t *testing.T) { mockSuccessfulGithubAddLabelQueueHeadCall(ghClient, prNumber).AnyTimes() mockSuccessfulGithubRemoveLabelQueueHeadCall(ghClient, prNumber).AnyTimes() - retryer := goordinator.NewRetryer() - autoupdater := NewAutoupdater( + autoupdater := newAutoupdater( ghClient, + ciClient, evChan, - retryer, []Repository{{OwnerLogin: repoOwner, RepositoryName: repo}}, true, nil, - queueHeadLabel, ) autoupdater.Start() t.Cleanup(autoupdater.Stop) @@ -1036,11 +984,12 @@ func TestInitialSync(t *testing.T) { mockctrl := gomock.NewController(t) ghClient := mocks.NewMockGithubClient(mockctrl) + ciClient := mocks.NewMockCIClient(mockctrl) ghClient. EXPECT(). UpdateBranch(gomock.Any(), gomock.Eq(repoOwner), gomock.Eq(repo), gomock.Any()). - Return(&githubclt.UpdateBranchResult{Changed: true, HeadCommitID: headCommitID}, nil). + Return(&githubclt.UpdateBranchResult{HeadCommitID: headCommitID}, nil). AnyTimes() prIterNone := mocks.NewMockPRIterator(mockctrl) @@ -1065,6 +1014,12 @@ func TestInitialSync(t *testing.T) { AnyTimes() mockSuccessfulGithubRemoveLabelQueueHeadCall(ghClient, 5).Times(1) + // we expect 2 calls because we enqueue PRs for 2 base branches + ciClient. + EXPECT(). + Build(gomock.Any(), gomock.Any()). + Times(2) + prAutoMergeEnabled := newBasicPullRequest(1, "main", "pr1") prAutoMergeEnabled.AutoMerge = &github.PullRequestAutoMerge{} @@ -1102,16 +1057,15 @@ func TestInitialSync(t *testing.T) { }). Times(len(syncPRRequestsRet) + 1) - retryer := goordinator.NewRetryer() - autoupdater := NewAutoupdater( + autoupdater := newAutoupdater( ghClient, + ciClient, evChan, - retryer, []Repository{{OwnerLogin: repoOwner, RepositoryName: repo}}, true, []string{"queue-add"}, - queueHeadLabel, ) + autoupdater.CI.Jobs = []*jenkins.JobTemplate{{RelURL: "here"}} err := autoupdater.InitSync(context.Background()) require.NoError(t, err) @@ -1140,6 +1094,7 @@ func TestFirstPRInQueueIsUpdatedPeriodically(t *testing.T) { mockctrl := gomock.NewController(t) ghClient := mocks.NewMockGithubClient(mockctrl) + ciClient := mocks.NewMockCIClient(mockctrl) prNumber := 1 triggerLabel := "queue-add" @@ -1151,15 +1106,13 @@ func TestFirstPRInQueueIsUpdatedPeriodically(t *testing.T) { githubclt.ReviewDecisionApproved, githubclt.CIStatusPending, ).AnyTimes() - retryer := goordinator.NewRetryer() - autoupdater := NewAutoupdater( + autoupdater := newAutoupdater( ghClient, + ciClient, evChan, - retryer, []Repository{{OwnerLogin: repoOwner, RepositoryName: repo}}, true, []string{triggerLabel}, - queueHeadLabel, ) autoupdater.periodicTriggerIntv = 2 * time.Second @@ -1187,6 +1140,7 @@ func TestReviewApprovedEventResumesSuspendedPR(t *testing.T) { mockctrl := gomock.NewController(t) ghClient := mocks.NewMockGithubClient(mockctrl) + ciClient := mocks.NewMockCIClient(mockctrl) prNumber := 1 prBranch := "pr_branch" triggerLabel := "queue-add" @@ -1200,15 +1154,13 @@ func TestReviewApprovedEventResumesSuspendedPR(t *testing.T) { mockSuccessfulGithubRemoveLabelQueueHeadCall(ghClient, prNumber).Times(1) - retryer := goordinator.NewRetryer() - autoupdater := NewAutoupdater( + autoupdater := newAutoupdater( ghClient, + ciClient, evChan, - retryer, []Repository{{OwnerLogin: repoOwner, RepositoryName: repo}}, true, []string{triggerLabel}, - queueHeadLabel, ) autoupdater.Start() @@ -1240,6 +1192,7 @@ func TestDismissingApprovalSuspendsActivePR(t *testing.T) { mockctrl := gomock.NewController(t) ghClient := mocks.NewMockGithubClient(mockctrl) + ciClient := mocks.NewMockCIClient(mockctrl) prNumber := 1 prBranch := "pr_branch" triggerLabel := "queue-add" @@ -1255,15 +1208,13 @@ func TestDismissingApprovalSuspendsActivePR(t *testing.T) { mockSuccessfulGithubAddLabelQueueHeadCall(ghClient, prNumber).Times(1) mockSuccessfulGithubRemoveLabelQueueHeadCall(ghClient, prNumber).Times(1) - retryer := goordinator.NewRetryer() - autoupdater := NewAutoupdater( + autoupdater := newAutoupdater( ghClient, + ciClient, evChan, - retryer, []Repository{{OwnerLogin: repoOwner, RepositoryName: repo}}, true, []string{triggerLabel}, - queueHeadLabel, ) autoupdater.Start() @@ -1295,6 +1246,7 @@ func TestRequestingReviewChangesSuspendsPR(t *testing.T) { mockctrl := gomock.NewController(t) ghClient := mocks.NewMockGithubClient(mockctrl) + ciClient := mocks.NewMockCIClient(mockctrl) prNumber := 1 prBranch := "pr_branch" triggerLabel := "queue-add" @@ -1308,15 +1260,13 @@ func TestRequestingReviewChangesSuspendsPR(t *testing.T) { mockSuccessfulGithubUpdateBranchCall(ghClient, prNumber, true).Times(1) - retryer := goordinator.NewRetryer() - autoupdater := NewAutoupdater( + autoupdater := newAutoupdater( ghClient, + ciClient, evChan, - retryer, []Repository{{OwnerLogin: repoOwner, RepositoryName: repo}}, true, []string{triggerLabel}, - queueHeadLabel, ) mockSuccessfulGithubRemoveLabelQueueHeadCall(ghClient, prNumber).Times(1) @@ -1342,13 +1292,14 @@ func TestRequestingReviewChangesSuspendsPR(t *testing.T) { assert.Len(t, queue.suspended, 1, "pr not suspended") } -func TestUpdatesAreResumeIfTestsFailAndBaseIsUpdated(t *testing.T) { +func TestUpdatesAreResumedIfTestsFailAndBaseIsUpdated(t *testing.T) { t.Cleanup(zap.ReplaceGlobals(zaptest.NewLogger(t).Named(t.Name()))) evChan := make(chan *github_prov.Event, 1) defer close(evChan) mockctrl := gomock.NewController(t) ghClient := mocks.NewMockGithubClient(mockctrl) + ciClient := mocks.NewMockCIClient(mockctrl) prNumber := 1 prBranch := "pr_branch" triggerLabel := "queue-add" @@ -1360,15 +1311,13 @@ func TestUpdatesAreResumeIfTestsFailAndBaseIsUpdated(t *testing.T) { ).Times(2) mockSuccessfulGithubUpdateBranchCall(ghClient, prNumber, false).Times(1) - retryer := goordinator.NewRetryer() - autoupdater := NewAutoupdater( + autoupdater := newAutoupdater( ghClient, + ciClient, evChan, - retryer, []Repository{{OwnerLogin: repoOwner, RepositoryName: repo}}, true, []string{triggerLabel}, - queueHeadLabel, ) mockSuccessfulGithubAddLabelQueueHeadCall(ghClient, prNumber).Times(0) // CI status is never pending mockSuccessfulGithubRemoveLabelQueueHeadCall(ghClient, prNumber).Times(1) @@ -1399,6 +1348,7 @@ func TestBaseBranchUpdatesBlockUntilFinished(t *testing.T) { mockctrl := gomock.NewController(t) ghClient := mocks.NewMockGithubClient(mockctrl) + ciClient := mocks.NewMockCIClient(mockctrl) prNumber := 1 prBranch := "pr_branch" triggerLabel := "queue-add" @@ -1426,15 +1376,13 @@ func TestBaseBranchUpdatesBlockUntilFinished(t *testing.T) { mockSuccessfulGithubAddLabelQueueHeadCall(ghClient, prNumber).AnyTimes() mockSuccessfulGithubRemoveLabelQueueHeadCall(ghClient, prNumber).AnyTimes() - retryer := goordinator.NewRetryer() - autoupdater := NewAutoupdater( + autoupdater := newAutoupdater( ghClient, + ciClient, evChan, - retryer, []Repository{{OwnerLogin: repoOwner, RepositoryName: repo}}, true, []string{triggerLabel}, - queueHeadLabel, ) autoupdater.Start() t.Cleanup(autoupdater.Stop) @@ -1463,6 +1411,7 @@ func TestPRHeadLabelIsAppliedToNextAfterMerge(t *testing.T) { mockctrl := gomock.NewController(t) ghClient := mocks.NewMockGithubClient(mockctrl) + ciClient := mocks.NewMockCIClient(mockctrl) pr1Number := 1 pr1Branch := "pr_branch" @@ -1478,15 +1427,13 @@ func TestPRHeadLabelIsAppliedToNextAfterMerge(t *testing.T) { mockSuccessfulGithubAddLabelQueueHeadCall(ghClient, pr1Number).Times(1) - retryer := goordinator.NewRetryer() - autoupdater := NewAutoupdater( + autoupdater := newAutoupdater( ghClient, + ciClient, evChan, - retryer, []Repository{{OwnerLogin: repoOwner, RepositoryName: repo}}, true, nil, - queueHeadLabel, ) autoupdater.Start() @@ -1518,3 +1465,189 @@ func TestPRHeadLabelIsAppliedToNextAfterMerge(t *testing.T) { waitForProcessedEventCnt(t, autoupdater, 4) waitForQueueUpdateRunsGreaterThan(t, queue, 2) } + +func TestCIJobsTriggeredOnSync(t *testing.T) { + t.Cleanup(zap.ReplaceGlobals(zaptest.NewLogger(t).Named(t.Name()))) + + evChan := make(chan *github_prov.Event, 1) + t.Cleanup(func() { close(evChan) }) + mockctrl := gomock.NewController(t) + ghClient := mocks.NewMockGithubClient(mockctrl) + ciClient := mocks.NewMockCIClient(mockctrl) + + prNumber := 1 + prBranch := "pr_branch" + baseBranch := "main" + + var updateBranchCalls atomic.Uint32 + ghClient. + EXPECT(). + UpdateBranch(gomock.Any(), gomock.Eq(repoOwner), gomock.Eq(repo), gomock.Any()). + DoAndReturn(func(context.Context, string, string, int) (*githubclt.UpdateBranchResult, error) { + updateBranchCalls.Add(1) + return &githubclt.UpdateBranchResult{ + Changed: updateBranchCalls.Load() == 1, + HeadCommitID: headCommitID, + }, nil + }).Times(2) + + mockReadyForMergeStatus( + ghClient, prNumber, + githubclt.ReviewDecisionApproved, githubclt.CIStatusPending, + ).Times(2) + mockSuccessfulGithubAddLabelQueueHeadCall(ghClient, prNumber).Times(1) + CiBuldCallCounter := mockCIBuildWithCallCnt(ciClient) + + autoupdater := newAutoupdater( + ghClient, + ciClient, + evChan, + []Repository{{OwnerLogin: repoOwner, RepositoryName: repo}}, + true, + nil, + ) + autoupdater.CI.Jobs = []*jenkins.JobTemplate{{RelURL: "here"}} + + autoupdater.Start() + t.Cleanup(autoupdater.Stop) + + evChan <- &github_prov.Event{Event: newPullRequestAutomergeEnabledEvent(prNumber, prBranch, baseBranch)} + waitForProcessedEventCnt(t, autoupdater, 1) + queue := autoupdater.getQueue(BranchID{RepositoryOwner: repoOwner, Repository: repo, Branch: baseBranch}) + + waitForQueueUpdateRunsGreaterThan(t, queue, 0) + evChan <- &github_prov.Event{Event: newSyncEvent(prNumber, prBranch, baseBranch)} + waitForQueueUpdateRunsGreaterThan(t, queue, 1) + waitForCiBuildCallsEqual(t, CiBuldCallCounter, 1) +} + +func TestCIJobsNotTriggeredWhenBranchNeedsUpdate(t *testing.T) { + t.Cleanup(zap.ReplaceGlobals(zaptest.NewLogger(t).Named(t.Name()))) + + evChan := make(chan *github_prov.Event, 1) + t.Cleanup(func() { close(evChan) }) + mockctrl := gomock.NewController(t) + ghClient := mocks.NewMockGithubClient(mockctrl) + ciClient := mocks.NewMockCIClient(mockctrl) + + prNumber := 1 + prBranch := "pr_branch" + baseBranch := "main" + + var updateBranchCalls atomic.Uint32 + ghClient. + EXPECT(). + UpdateBranch(gomock.Any(), gomock.Eq(repoOwner), gomock.Eq(repo), gomock.Any()). + DoAndReturn(func(context.Context, string, string, int) (*githubclt.UpdateBranchResult, error) { + updateBranchCalls.Add(1) + return &githubclt.UpdateBranchResult{ + Changed: true, + HeadCommitID: headCommitID, + }, nil + }).Times(1) + + mockReadyForMergeStatus( + ghClient, prNumber, + githubclt.ReviewDecisionApproved, githubclt.CIStatusPending, + ).Times(1) + + autoupdater := newAutoupdater( + ghClient, + ciClient, + evChan, + []Repository{{OwnerLogin: repoOwner, RepositoryName: repo}}, + true, + nil, + ) + autoupdater.CI.Jobs = []*jenkins.JobTemplate{{RelURL: "here"}} + + autoupdater.Start() + t.Cleanup(autoupdater.Stop) + + evChan <- &github_prov.Event{Event: newPullRequestAutomergeEnabledEvent(prNumber, prBranch, baseBranch)} + waitForProcessedEventCnt(t, autoupdater, 1) + queue := autoupdater.getQueue(BranchID{RepositoryOwner: repoOwner, Repository: repo, Branch: baseBranch}) + require.NotNil(t, queue) + waitForQueueUpdateRunsGreaterThan(t, queue, 0) +} + +func TestCIJobsOnlyTriggeredWhenCIStatusIsPending(t *testing.T) { + t.Cleanup(zap.ReplaceGlobals(zaptest.NewLogger(t).Named(t.Name()))) + + type testcase struct { + CIStatus githubclt.CIStatus + + ExpectedCICalls int + ExpectedAddLabelCalls int + } + tcs := []testcase{ + { + CIStatus: githubclt.CIStatusPending, + ExpectedCICalls: 1, + ExpectedAddLabelCalls: 1, + }, + { + CIStatus: githubclt.CIStatusFailure, + ExpectedCICalls: 0, + ExpectedAddLabelCalls: 0, + }, + { + CIStatus: githubclt.CIStatusSuccess, + ExpectedCICalls: 0, + ExpectedAddLabelCalls: 0, + }, + } + + for _, tc := range tcs { + t.Run("ci_status_"+string(tc.CIStatus), func(t *testing.T) { + t.Cleanup(zap.ReplaceGlobals(zaptest.NewLogger(t).Named(t.Name()))) + + evChan := make(chan *github_prov.Event, 1) + t.Cleanup(func() { close(evChan) }) + mockctrl := gomock.NewController(t) + ghClient := mocks.NewMockGithubClient(mockctrl) + ciClient := mocks.NewMockCIClient(mockctrl) + + prNumber := 1 + prBranch := "pr_branch" + baseBranch := "main" + + ghClient. + EXPECT(). + UpdateBranch(gomock.Any(), gomock.Eq(repoOwner), gomock.Eq(repo), gomock.Any()). + DoAndReturn(func(context.Context, string, string, int) (*githubclt.UpdateBranchResult, error) { + return &githubclt.UpdateBranchResult{ + HeadCommitID: headCommitID, + }, nil + }).Times(1) + + mockReadyForMergeStatus( + ghClient, prNumber, + githubclt.ReviewDecisionApproved, tc.CIStatus, + ).Times(1) + mockSuccessfulGithubAddLabelQueueHeadCall(ghClient, prNumber).Times(tc.ExpectedAddLabelCalls) + + mockSuccessfulGithubRemoveLabelQueueHeadCall(ghClient, prNumber).MaxTimes(1) + ciClient.EXPECT().Build(gomock.Any(), gomock.Any()).Times(tc.ExpectedCICalls) + + autoupdater := newAutoupdater( + ghClient, + ciClient, + evChan, + []Repository{{OwnerLogin: repoOwner, RepositoryName: repo}}, + true, + nil, + ) + autoupdater.CI.Jobs = []*jenkins.JobTemplate{{RelURL: "here"}} + + autoupdater.Start() + t.Cleanup(autoupdater.Stop) + + evChan <- &github_prov.Event{Event: newPullRequestAutomergeEnabledEvent(prNumber, prBranch, baseBranch)} + waitForProcessedEventCnt(t, autoupdater, 1) + queue := autoupdater.getQueue(BranchID{RepositoryOwner: repoOwner, Repository: repo, Branch: baseBranch}) + + waitForQueueUpdateRunsGreaterThan(t, queue, 0) + }) + } +} diff --git a/internal/autoupdate/ci.go b/internal/autoupdate/ci.go new file mode 100644 index 0000000..7fec347 --- /dev/null +++ b/internal/autoupdate/ci.go @@ -0,0 +1,45 @@ +package autoupdate + +import ( + "context" + "fmt" + "strconv" + + "github.com/simplesurance/directorius/internal/jenkins" + "github.com/simplesurance/directorius/internal/logfields" + + "go.uber.org/zap" +) + +func (c *CI) RunAll(ctx context.Context, retryer Retryer, pr *PullRequest) error { + for _, jobTempl := range c.Jobs { + job, err := jobTempl.Template(jenkins.TemplateData{ + PullRequestNumber: strconv.Itoa(pr.Number), + Branch: pr.Branch, + }) + if err != nil { + return fmt.Errorf("templating jenkins job %q failed: %w", jobTempl.RelURL, err) + } + + logfields := append( + []zap.Field{ + logfields.Event("triggering_ci_job"), + logfields.CIJob(job.String()), + }, + pr.LogFields..., + ) + + err = retryer.Run( + ctx, + func(ctx context.Context) error { return c.Client.Build(ctx, job) }, + logfields, + ) + if err != nil { + return fmt.Errorf("running ci job %q failed: %w", job, err) + } + + c.logger.Debug("triggered ci job", logfields...) + } + + return nil +} diff --git a/internal/autoupdate/config.go b/internal/autoupdate/config.go new file mode 100644 index 0000000..dd34ca0 --- /dev/null +++ b/internal/autoupdate/config.go @@ -0,0 +1,89 @@ +package autoupdate + +import ( + "context" + "fmt" + + "github.com/simplesurance/directorius/internal/jenkins" + github_prov "github.com/simplesurance/directorius/internal/provider/github" + "github.com/simplesurance/directorius/internal/set" + + "go.uber.org/zap" +) + +const loggerName = "autoupdater" + +type Config struct { + Logger *zap.Logger + + GitHubClient GithubClient + CI *CI + EventChan <-chan *github_prov.Event + Retryer Retryer + MonitoredRepositories map[Repository]struct{} + TriggerOnAutomerge bool + TriggerLabels set.Set[string] + HeadLabel string + // When DryRun is enabled all GitHub API operation that could result in + // a change will be simulated and always succeed. + DryRun bool +} + +type CIClient interface { + fmt.Stringer + Build(context.Context, *jenkins.Job) error +} + +type CI struct { + Client CIClient + Jobs []*jenkins.JobTemplate + + retryer Retryer + logger *zap.Logger +} + +func (cfg *Config) setDefaults() { + if cfg.Logger == nil { + cfg.Logger = zap.L().Named(loggerName) + } + + if cfg.TriggerLabels == nil { + cfg.TriggerLabels = set.Set[string]{} + } + + if cfg.CI == nil { + cfg.CI = &CI{} + } + + if cfg.CI != nil { + cfg.CI.retryer = cfg.Retryer + cfg.CI.logger = cfg.Logger.Named("ci") + } +} + +func (cfg *Config) mustValidate() { + if cfg.Logger == nil { + panic("autoupdater config: logger is nil") + } + if cfg.GitHubClient == nil { + panic("autoupdater config: githubclient is nil") + } + if cfg.EventChan == nil { + panic("autoupdater config: eventChan is nil") + } + if cfg.Retryer == nil { + panic("autoupdater config: retryer is nil") + } + if cfg.MonitoredRepositories == nil { + panic("autoupdater config: monitoredrepositories is nil") + } + if cfg.TriggerLabels == nil { + panic("autoupdater config: triggerlabels is nil") + } + + if cfg.CI != nil { + if cfg.CI.Client == nil && len(cfg.CI.Jobs) > 0 { + panic("autoupdater config: ci jobs are defined but no server") + } + } +} diff --git a/internal/autoupdate/doc.go b/internal/autoupdate/doc.go index 8e62259..9e0b2a6 100644 --- a/internal/autoupdate/doc.go +++ b/internal/autoupdate/doc.go @@ -1,52 +1,2 @@ -// Package autoupdate provides automatic serialized updating of GitHub -// pull request branches with their base-branch. -// -// The autoupdater can be used in combination with a automerge feature like the -// one from GitHub and specific branch protection rules to provide a -// serialized merge-queue. -// The GitHub branch protection rules must be configured to require -// >=1 status checks to pass before merging and branches being up to date -// before merging. -// -// The autoupdater reacts on GitHub webhook-events and interacts with the -// GitHub API. -// When a webhook event for an enqueue condition is received, the pull request -// is enqueued for autoupdates. -// When all required Status Checks succeed for the PR, it is uptodate with it's -// base branch and all configured mandatory PR reviewers approved, the -// auto-merge feature will merge the PR into it's base branch. -// Per base-branch, the autoupdater updates automatically the first PR in the -// queue with it's base branch. It serializes updating per basebranch, to avoid -// a race between multiples pull requests that result in unnecessary CI runs and -// merges. -// When updating a pull request branch with it's base-branch is not possible, -// a failed status check for the pull request was reported, or the PR became -// stale updates for it are suspended. -// This prevents that pull requests that can not be merged block the autoupdate -// fifo-queue. -// When a webhook event is received about a positive status check report, the -// base branch or the pull request branch changed, updates will be resumed. -// The pull request will be enqueued in the fifo list for updates again. -// -// pull requests are removed from the autoupdate queue, when it was closed, or -// an inverse trigger event (auto_merge_disabled, unlabeled) was received. -// -// # Components -// -// The main components are the queue and autoupdater. -// -// The autoupdater manages and coordinates operations on queues. -// It listens for GitHub Webhook events, creates/removes removes, -// enqueues/dequeues pull requests for updates and triggers update operations -// on queues. It can also synchronize the queue with the current state at -// GitHub, by querying information via the GitHub API. -// It also provides a minimal webinterface to view the current state. -// -// Queues serialize updates per base-branch. For each base-branch the -// Autoupdater manages one queue. -// pull requests in the queue are either in active or suspended state. -// If they are active, they are queued in FIFO datastructure and update -// operations can be run on the first element in the queue. -// If they are suspended they are currently not considered for autoupdates and -// stored in a separate datastructure. +// Package autoupdate provides a merge queue for GitHub. package autoupdate diff --git a/internal/autoupdate/dryciclient.go b/internal/autoupdate/dryciclient.go new file mode 100644 index 0000000..f65acdb --- /dev/null +++ b/internal/autoupdate/dryciclient.go @@ -0,0 +1,29 @@ +package autoupdate + +import ( + "context" + + "github.com/simplesurance/directorius/internal/jenkins" + "github.com/simplesurance/directorius/internal/logfields" + + "go.uber.org/zap" +) + +type DryCIClient struct { + logger *zap.Logger +} + +func NewDryCIClient(logger *zap.Logger) *DryCIClient { + return &DryCIClient{ + logger: logger.Named("dry_ci_client"), + } +} + +func (c *DryCIClient) Build(_ context.Context, job *jenkins.Job) error { + c.logger.Info("simulated triggering of ci job", logfields.CIJob(job.String())) + return nil +} + +func (c *DryCIClient) String() string { + return "N/A - dry run enabled" +} diff --git a/internal/autoupdate/httplistdata.go b/internal/autoupdate/httplistdata.go index 681c73e..617e476 100644 --- a/internal/autoupdate/httplistdata.go +++ b/internal/autoupdate/httplistdata.go @@ -14,14 +14,16 @@ type httpListQueue struct { // httpListData is used as template data when rending the autoupdater list // page. type httpListData struct { - Queues []*httpListQueue - TriggerOnAutomerge bool - TriggerLabels []string - MonitoredRepositories []string - PeriodicTriggerInterval time.Duration - ProcessedEvents uint64 - - // CreatedAt is the time when this datastructure was creted. + Queues []*httpListQueue + TriggerOnAutomerge bool + TriggerLabels []string + MonitoredRepositoriesitories []string + PeriodicTriggerInterval time.Duration + ProcessedEvents uint64 + CIServer string + CIJobURLs []string + + // CreatedAt is the time when this datastructure was created. CreatedAt time.Time } @@ -31,19 +33,29 @@ func (a *Autoupdater) httpListData() *httpListData { a.queuesLock.Lock() defer a.queuesLock.Unlock() - result.TriggerOnAutomerge = a.triggerOnAutomerge + result.TriggerOnAutomerge = a.TriggerOnAutomerge - for k := range a.triggerLabels { + for k := range a.TriggerLabels { result.TriggerLabels = append(result.TriggerLabels, k) } - for k := range a.monitoredRepos { - result.MonitoredRepositories = append(result.MonitoredRepositories, k.String()) + for k := range a.MonitoredRepositories { + result.MonitoredRepositoriesitories = append(result.MonitoredRepositoriesitories, k.String()) } result.PeriodicTriggerInterval = a.periodicTriggerIntv result.ProcessedEvents = a.processedEventCnt.Load() + if a.Config.CI == nil { + result.CIServer = "undefined" + } else { + result.CIServer = a.Config.CI.Client.String() + } + + for _, j := range a.Config.CI.Jobs { + result.CIJobURLs = append(result.CIJobURLs, j.RelURL) + } + for baseBranch, queue := range a.queues { queueData := httpListQueue{ RepositoryOwner: baseBranch.RepositoryOwner, diff --git a/internal/autoupdate/httpservice.go b/internal/autoupdate/httpservice.go index 1159550..7eff83b 100644 --- a/internal/autoupdate/httpservice.go +++ b/internal/autoupdate/httpservice.go @@ -39,7 +39,7 @@ func NewHTTPService(autoupdater *Autoupdater) *HTTPService { Funcs(templFuncs). ParseFS(templFS, "pages/templates/*"), ), - logger: autoupdater.logger.Named("http_service"), + logger: autoupdater.Logger.Named("http_service"), } } diff --git a/internal/autoupdate/mocks/autoupdate.go b/internal/autoupdate/mocks/autoupdate.go index 662553a..8fc8192 100644 --- a/internal/autoupdate/mocks/autoupdate.go +++ b/internal/autoupdate/mocks/autoupdate.go @@ -22,6 +22,7 @@ import ( type MockGithubClient struct { ctrl *gomock.Controller recorder *MockGithubClientMockRecorder + isgomock struct{} } // MockGithubClientMockRecorder is the mock recorder for MockGithubClient. @@ -131,6 +132,7 @@ func (mr *MockGithubClientMockRecorder) UpdateBranch(ctx, owner, repo, pullReque type MockRetryer struct { ctrl *gomock.Controller recorder *MockRetryerMockRecorder + isgomock struct{} } // MockRetryerMockRecorder is the mock recorder for MockRetryer. diff --git a/internal/autoupdate/mocks/ciclient.go b/internal/autoupdate/mocks/ciclient.go new file mode 100644 index 0000000..2e5119c --- /dev/null +++ b/internal/autoupdate/mocks/ciclient.go @@ -0,0 +1,70 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/simplesurance/directorius/internal/autoupdate (interfaces: CIClient) +// +// Generated by this command: +// +// mockgen -package mocks -destination internal/autoupdate/mocks/ciclient.go github.com/simplesurance/directorius/internal/autoupdate CIClient +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + jenkins "github.com/simplesurance/directorius/internal/jenkins" + gomock "go.uber.org/mock/gomock" +) + +// MockCIClient is a mock of CIClient interface. +type MockCIClient struct { + ctrl *gomock.Controller + recorder *MockCIClientMockRecorder + isgomock struct{} +} + +// MockCIClientMockRecorder is the mock recorder for MockCIClient. +type MockCIClientMockRecorder struct { + mock *MockCIClient +} + +// NewMockCIClient creates a new mock instance. +func NewMockCIClient(ctrl *gomock.Controller) *MockCIClient { + mock := &MockCIClient{ctrl: ctrl} + mock.recorder = &MockCIClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockCIClient) EXPECT() *MockCIClientMockRecorder { + return m.recorder +} + +// Build mocks base method. +func (m *MockCIClient) Build(arg0 context.Context, arg1 *jenkins.Job) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Build", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// Build indicates an expected call of Build. +func (mr *MockCIClientMockRecorder) Build(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Build", reflect.TypeOf((*MockCIClient)(nil).Build), arg0, arg1) +} + +// String mocks base method. +func (m *MockCIClient) String() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "String") + ret0, _ := ret[0].(string) + return ret0 +} + +// String indicates an expected call of String. +func (mr *MockCIClientMockRecorder) String() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "String", reflect.TypeOf((*MockCIClient)(nil).String)) +} diff --git a/internal/autoupdate/mocks/githubclient.go b/internal/autoupdate/mocks/githubclient.go index d6a10e1..e3d7559 100644 --- a/internal/autoupdate/mocks/githubclient.go +++ b/internal/autoupdate/mocks/githubclient.go @@ -20,6 +20,7 @@ import ( type MockPRIterator struct { ctrl *gomock.Controller recorder *MockPRIteratorMockRecorder + isgomock struct{} } // MockPRIteratorMockRecorder is the mock recorder for MockPRIterator. diff --git a/internal/autoupdate/mocks/jenkinsserver.go b/internal/autoupdate/mocks/jenkinsserver.go new file mode 100644 index 0000000..486f33d --- /dev/null +++ b/internal/autoupdate/mocks/jenkinsserver.go @@ -0,0 +1,10 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: internal/jenkins/server.go +// +// Generated by this command: +// +// mockgen -package mocks -source internal/jenkins/server.go -destination internal/autoupdate/mocks/jenkinsserver.go +// + +// Package mocks is a generated GoMock package. +package mocks diff --git a/internal/autoupdate/pages/templates/list.html.tmpl b/internal/autoupdate/pages/templates/list.html.tmpl index cc52e02..33b3ef4 100644 --- a/internal/autoupdate/pages/templates/list.html.tmpl +++ b/internal/autoupdate/pages/templates/list.html.tmpl @@ -14,10 +14,10 @@
- The autoupdater keeps pull requests up to date with their base branch. + The autoupdater triggers CI jobs and keeps pull requests up to date + with their base branch.
- A pull request is enqueued for autoupdates when auto_merge for the PR
+ A pull request is enqueued for autoupdates when auto-merge for the PR
is enabled or it is marked with a configurable label.
The first pull request in each queue has the active status and is kept
- up to date with its base-branch.
+ up to date with its base branch.
If the base branch changes, the base branch is merged into the pull
request branch.
- Other pull requests are enqueued for being kept uptodate.
+ Other pull requests are enqueued for being kept uptodate.
+ When directorius has been configured to trigger CI job, they are run
+ when the branch of the first PR in the queue changes and when a PR is
+ is assigned the first place.
diff --git a/internal/autoupdate/queue.go b/internal/autoupdate/queue.go index 55460cd..411f226 100644 --- a/internal/autoupdate/queue.go +++ b/internal/autoupdate/queue.go @@ -26,10 +26,14 @@ import ( // queue it's state has not changed for longer then this timeout. const DefStaleTimeout = 3 * time.Hour -// retryTimeout defines the maximum duration for which GitHub operation is -// retried on a temporary error. The longer the duration is, the longer it -// blocks the first element in the queue. -const retryTimeout = 20 * time.Minute +const ( + // gitHubRetryTimeout defines the maximum duration for which GitHub operation is + // retried on a temporary error. The longer the duration is, the longer it + // blocks the first element in the queue. + gitHubRetryTimeout = 20 * time.Minute + // ciRetryTimeout defines for how long CI operations are retried + ciRetryTimeout = 10 * time.Minute +) const updateBranchPollInterval = 2 * time.Second @@ -85,9 +89,11 @@ type queue struct { headLabel string metrics *queueMetrics + + ci *CI } -func newQueue(base *BaseBranch, logger *zap.Logger, ghClient GithubClient, retryer Retryer, headLabel string) *queue { +func newQueue(base *BaseBranch, logger *zap.Logger, ghClient GithubClient, retryer Retryer, ci *CI, headLabel string) *queue { q := queue{ baseBranch: *base, active: orderedmap.New[int, *PullRequest](), @@ -99,6 +105,7 @@ func newQueue(base *BaseBranch, logger *zap.Logger, ghClient GithubClient, retry staleTimeout: DefStaleTimeout, updateBranchPollInterval: updateBranchPollInterval, headLabel: headLabel, + ci: ci, } q.setLastRun(time.Time{}) @@ -120,21 +127,21 @@ func (q *queue) String() string { return fmt.Sprintf("queue for base branch: %s", q.baseBranch.String()) } -type runningTask struct { +type runningOperation struct { pr int cancelFunc context.CancelFunc } -func (q *queue) getExecuting() *runningTask { +func (q *queue) getExecuting() *runningOperation { v := q.executing.Load() if v == nil { return nil } - return v.(*runningTask) + return v.(*runningOperation) } -func (q *queue) setExecuting(v *runningTask) { +func (q *queue) setExecuting(v *runningOperation) { q.executing.Store(v) } @@ -225,7 +232,7 @@ func (q *queue) _enqueueActive(pr *PullRequest) error { logfields.Event("pull_request_enqueued"), ) - q.scheduleUpdate(context.Background(), pr) + q.scheduleUpdate(context.Background(), pr, TaskTriggerCI) return nil } @@ -324,8 +331,9 @@ func (q *queue) Dequeue(prNumber int) (*PullRequest, error) { zap.Int("github.pull_request_new_first", newFirstElem.Number), ) + // TODO: do we really should add the head label here? q.prAddQueueHeadLabel(context.Background(), newFirstElem) - q.scheduleUpdate(context.Background(), newFirstElem) + q.scheduleUpdate(context.Background(), newFirstElem, TaskNone) return removed, nil } @@ -370,7 +378,7 @@ func (q *queue) Suspend(prNumber int) error { zap.Int("github.pull_request_new_first", newFirstElem.Number), ) - q.scheduleUpdate(context.Background(), newFirstElem) + q.scheduleUpdate(context.Background(), newFirstElem, TaskNone) return nil } @@ -442,23 +450,23 @@ func (q *queue) isFirstActive(pr *PullRequest) bool { } // ScheduleUpdate schedules updating the first pull request in the queue. -func (q *queue) ScheduleUpdate(ctx context.Context) { +func (q *queue) ScheduleUpdate(ctx context.Context, task Task) { first := q.FirstActive() if first == nil { q.logger.Debug("ScheduleUpdateFirstPR was called but active queue is empty") return } - q.scheduleUpdate(ctx, first) + q.scheduleUpdate(ctx, first, task) } -func (q *queue) scheduleUpdate(ctx context.Context, pr *PullRequest) { +func (q *queue) scheduleUpdate(ctx context.Context, pr *PullRequest, task Task) { q.actionPool.Queue(func() { ctx, cancelFunc := context.WithCancel(ctx) defer cancelFunc() - q.setExecuting(&runningTask{pr: pr.Number, cancelFunc: cancelFunc}) - q.updatePR(ctx, pr) + q.setExecuting(&runningOperation{pr: pr.Number, cancelFunc: cancelFunc}) + q.updatePR(ctx, pr, task) q.setExecuting(nil) }) @@ -478,14 +486,14 @@ func isPRIsClosedErr(err error) bool { return strings.Contains(err.Error(), wantedErrStr) } -// isPRStale returns true if the FirstElementSince timestamp is older then +// isPRStale returns true if the [pr.GetStateUnchangedSince] timestamp is older then // q.staleTimeout. func (q *queue) isPRStale(pr *PullRequest) bool { lastStatusChange := pr.GetStateUnchangedSince() if lastStatusChange.IsZero() { // This can be caused by a race when action() is running and - // the PR is dequeuend/suspended in the meantime. + // the PR is dequeued/suspended in the meantime. q.logger.Debug("stateUnchangedSince timestamp of pr is zero", pr.LogFields...) return false } @@ -494,7 +502,7 @@ func (q *queue) isPRStale(pr *PullRequest) bool { } // updatePR updates runs the update operation for the pull request. -// If the ctx is cancelled or the pr is not the first one in the active queue +// If the ctx is canceled or the pr is not the first one in the active queue // nothing is done. // If the base-branch contains changes that are not in the pull request branch, // updating it, by merging the base-branch into the PR branch, is schedule via @@ -509,7 +517,7 @@ func (q *queue) isPRStale(pr *PullRequest) bool { // If the pull request was not updated, it's GitHub check status did not change // and it is the first element in the queue longer then q.staleTimeout it is // suspended. -func (q *queue) updatePR(ctx context.Context, pr *PullRequest) { +func (q *queue) updatePR(ctx context.Context, pr *PullRequest, task Task) { loggingFields := pr.LogFields logger := q.logger.With(loggingFields...) @@ -536,12 +544,12 @@ func (q *queue) updatePR(ctx context.Context, pr *PullRequest) { return } - ctx, cancelFunc := context.WithTimeout(ctx, retryTimeout) + ghCtx, cancelFunc := context.WithTimeout(ctx, gitHubRetryTimeout) defer cancelFunc() defer q.incUpdateRuns() - status, err := q.prReadyForMergeStatus(ctx, pr) + status, err := q.prReadyForMergeStatus(ghCtx, pr) if err != nil { logger.Error( "checking pr merge status failed", @@ -574,11 +582,12 @@ func (q *queue) updatePR(ctx context.Context, pr *PullRequest) { logger.Debug("pr is approved") - branchChanged, updateHeadCommit, err := q.updatePRWithBase(ctx, pr, logger, loggingFields) + branchChanged, updateHeadCommit, err := q.updatePRWithBase(ghCtx, pr, logger, loggingFields) if err != nil { // error is logged in q.updatePRIfNeeded return } + if branchChanged { logger.Info( "branch updated with changes from base branch", @@ -587,15 +596,10 @@ func (q *queue) updatePR(ctx context.Context, pr *PullRequest) { ) pr.SetStateUnchangedSinceIfNewer(time.Now()) - // queue label is not added yet, the update of the branch will - // cause a PullRequest synchronize event, that will trigger - // another run of this function which will then add the label. - // This allows to only add the label if the up2date PR - // fullfills all other requirements, as being reviewed, not - // stale and don't have a failed CI check. Delaying adding the - // label prevents that in some situations the label is added - // only to be removed shortly after again because e.g. a CI - // check failed or an approval was removed + // queue label is not added neither CI jobs are trigger yet, + // the update of the branch will cause a PullRequest + // synchronize event, that will trigger + // another run of this function which will then trigger the CI jobs and add the label. return } @@ -647,11 +651,28 @@ func (q *queue) updatePR(ctx context.Context, pr *PullRequest) { case githubclt.CIStatusPending: q.prAddQueueHeadLabel(context.Background(), pr) + logger.Info( "pull request is uptodate, approved and status checks are pending", logfields.Event("pr_status_pending"), ) + if task == TaskTriggerCI { + ciCtx, cancelFunc := context.WithTimeout(ctx, ciRetryTimeout) + defer cancelFunc() + err := q.ci.RunAll(ciCtx, q.retryer, pr) + if err != nil { + logger.Error("triggering CI jobs failed", + logfields.Event("triggering_ci_job_failed"), + zap.Error(err), + ) + return + } + + logger.Info("ci jobs triggered", logfields.Event("ci_jobs_triggered")) + pr.SetStateUnchangedSinceIfNewer(time.Now()) + } + case githubclt.CIStatusFailure: if err := q.Suspend(pr.Number); err != nil { logger.Error( @@ -694,7 +715,10 @@ func (q *queue) updatePR(ctx context.Context, pr *PullRequest) { } } +// TODO: passing logger and loggingFields as parameters is redundant, only pass one of them func (q *queue) updatePRWithBase(ctx context.Context, pr *PullRequest, logger *zap.Logger, loggingFields []zapcore.Field) (changed bool, headCommit string, updateBranchErr error) { + loggingFields = append(loggingFields, logfields.Event("update_branch")) + updateBranchErr = q.retryer.Run(ctx, func(ctx context.Context) error { result, err := q.ghClient.UpdateBranch( ctx, @@ -813,7 +837,7 @@ func (q *queue) prReadyForMergeStatus(ctx context.Context, pr *PullRequest) (*gi loggingFields := pr.LogFields - ctx, cancelFunc := context.WithTimeout(ctx, retryTimeout) + ctx, cancelFunc := context.WithTimeout(ctx, gitHubRetryTimeout) defer cancelFunc() err := q.retryer.Run(ctx, func(ctx context.Context) error { @@ -858,7 +882,7 @@ func (q *queue) prsByBranch(branchNames set.Set[string]) ( } func (q *queue) prAddQueueHeadLabel(ctx context.Context, pr *PullRequest) { - ctx, cancelFunc := context.WithTimeout(ctx, retryTimeout) + ctx, cancelFunc := context.WithTimeout(ctx, gitHubRetryTimeout) defer cancelFunc() err := q.retryer.Run(ctx, func(ctx context.Context) error { // if the PR already has the label, it succeeds @@ -887,7 +911,7 @@ func (q *queue) prAddQueueHeadLabel(ctx context.Context, pr *PullRequest) { } func (q *queue) prRemoveQueueHeadLabel(ctx context.Context, logReason string, pr *PullRequest) { - ctx, cancelFunc := context.WithTimeout(ctx, retryTimeout) + ctx, cancelFunc := context.WithTimeout(ctx, gitHubRetryTimeout) defer cancelFunc() err := q.retryer.Run(ctx, func(ctx context.Context) error { return q.ghClient.RemoveLabel(ctx, @@ -1014,10 +1038,10 @@ func (q *queue) ScheduleResumePRIfStatusPositive(ctx context.Context, pr *PullRe ctx, cancelFunc := context.WithCancel(ctx) defer cancelFunc() - ctx, cancelFunc = context.WithTimeout(ctx, retryTimeout) + ctx, cancelFunc = context.WithTimeout(ctx, gitHubRetryTimeout) defer cancelFunc() - q.setExecuting(&runningTask{pr: pr.Number, cancelFunc: cancelFunc}) + q.setExecuting(&runningOperation{pr: pr.Number, cancelFunc: cancelFunc}) err := q.resumeIfPRMergeStatusPositive(ctx, logger, pr) if err != nil && !errors.Is(err, ErrNotFound) { diff --git a/internal/autoupdate/queue_test.go b/internal/autoupdate/queue_test.go index 481401e..58f5870 100644 --- a/internal/autoupdate/queue_test.go +++ b/internal/autoupdate/queue_test.go @@ -25,7 +25,7 @@ func TestUpdatePR_DoesNotCallBaseBranchUpdateIfPRIsNotApproved(t *testing.T) { bb, err := NewBaseBranch(repoOwner, repo, "main") require.NoError(t, err) - q := newQueue(bb, zap.L(), ghClient, goordinator.NewRetryer(), "first") + q := newQueue(bb, zap.L(), ghClient, goordinator.NewRetryer(), nil, "first") t.Cleanup(q.Stop) pr, err := NewPullRequest(1, "testbr", "fho", "test pr", "") @@ -40,7 +40,7 @@ func TestUpdatePR_DoesNotCallBaseBranchUpdateIfPRIsNotApproved(t *testing.T) { ).AnyTimes() ghClient.EXPECT().UpdateBranch(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) - q.updatePR(context.Background(), pr) + q.updatePR(context.Background(), pr, TaskNone) } func TestUpdatePRWithBaseReturnsChangedWhenScheduled(t *testing.T) { @@ -65,7 +65,7 @@ func TestUpdatePRWithBaseReturnsChangedWhenScheduled(t *testing.T) { bb, err := NewBaseBranch(repoOwner, repo, "main") require.NoError(t, err) - q := newQueue(bb, zap.L(), ghClient, goordinator.NewRetryer(), "first") + q := newQueue(bb, zap.L(), ghClient, goordinator.NewRetryer(), nil, "first") pr, err := NewPullRequest(1, "pr_branch", "", "", "") require.NoError(t, err) diff --git a/internal/autoupdate/task.go b/internal/autoupdate/task.go new file mode 100644 index 0000000..0629c5a --- /dev/null +++ b/internal/autoupdate/task.go @@ -0,0 +1,8 @@ +package autoupdate + +type Task uint8 + +const ( + TaskNone Task = iota + TaskTriggerCI +) diff --git a/internal/cfg/config.go b/internal/cfg/config.go index a093334..d93b049 100644 --- a/internal/cfg/config.go +++ b/internal/cfg/config.go @@ -28,6 +28,7 @@ type Config struct { TriggerOnLabels []string `toml:"trigger_labels"` HeadLabel string `toml:"queue_pr_head_label"` Repositories []GithubRepository `toml:"repository"` + CI CI `toml:"ci"` } type GithubRepository struct { @@ -35,6 +36,22 @@ type GithubRepository struct { RepositoryName string `toml:"repository"` } +type CI struct { + ServerURL string `toml:"server_url"` + BasicAuth BasicAuth `toml:"basic_auth"` + Jobs []CIJob `toml:"job"` +} + +type BasicAuth struct { + User string `toml:"user"` + Password string `toml:"password"` +} + +type CIJob struct { + Endpoint string `toml:"endpoint"` + Parameters map[string]string `toml:"parameters"` +} + func Load(reader io.Reader) (*Config, error) { var result Config diff --git a/internal/goordinator/retryer.go b/internal/goordinator/retryer.go index a2db3ab..4239558 100644 --- a/internal/goordinator/retryer.go +++ b/internal/goordinator/retryer.go @@ -91,6 +91,10 @@ func (r *Retryer) Run(ctx context.Context, fn func(context.Context) error, logF err := fn(ctx) if err != nil { + // TODO: use an interface for RetryableErrors, + // so every package can implement their own + // variation and does not have to import + // goorderr var retryError *goorderr.RetryableError if errors.As(err, &retryError) { diff --git a/internal/jenkins/client.go b/internal/jenkins/client.go new file mode 100644 index 0000000..1f4866e --- /dev/null +++ b/internal/jenkins/client.go @@ -0,0 +1,106 @@ +package jenkins + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/simplesurance/directorius/internal/goorderr" +) + +type Client struct { + url string + auth *basicAuth + + clt *http.Client +} + +type basicAuth struct { + user string + password string +} + +const ( + requestTimeout = time.Minute + userAgent = "directorius" +) + +func NewClient(url, user, password string) *Client { + return &Client{ + url: url, + auth: &basicAuth{user: user, password: password}, + clt: &http.Client{Timeout: requestTimeout}, + } +} + +func (s *Client) Build(ctx context.Context, j *Job) error { + // https://wiki.jenkins-ci.org/display/JENKINS/Remote+access+API + url, err := url.JoinPath(s.url, j.relURL) + if err != nil { + return fmt.Errorf("concatening server (%q) with job (%q) url failed: %w", s.url, j.relURL, err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, toRequestBody(j)) + if err != nil { + return fmt.Errorf("creating http-request failed: %w", err) + } + + req.Header.Add("User-Agent", userAgent) + if req.Body != nil { + req.Header.Add("Content-Type", userAgent) + } + + req.SetBasicAuth(s.auth.user, s.auth.password) + + resp, err := s.clt.Do(req) + if err != nil { + return goorderr.NewRetryableAnytimeError(err) + } + + defer resp.Body.Close() + if resp.ProtoMajor == 1 { + defer func() { + // try to drain body but limit it to a non-excessive amount + _, _ = io.CopyN(io.Discard, resp.Body, 1024) + }() + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + /* we simply almost always retry to make it resilient, + * requests can fail and succeed later e.g. on: + - 404 because the multibranch job was not created yet but is soonish, + - 502, 504 jenkins temporarily down, + - 401: temporary issues with jenkins auth backend, + etc + */ + return goorderr.NewRetryableAnytimeError(fmt.Errorf("server returned status code: %d", resp.StatusCode)) + } + + if resp.StatusCode != http.StatusCreated { + return errors.New("server returned status code %d, expecting 201") + } + + // Jenkins returns 201 and sends in the Location header the URL of the queued item, + // it's url can be used to get the build id, query the status, cancel it, etc + // location := resp.Header.Get("Location") + + // s.clt.Do(req) + return nil +} + +func toRequestBody(j *Job) io.Reader { + if len(j.parametersJSON) == 0 { + return nil + } + + return bytes.NewReader(j.parametersJSON) +} + +func (s *Client) String() string { + return s.url +} diff --git a/internal/jenkins/job.go b/internal/jenkins/job.go new file mode 100644 index 0000000..d875cae --- /dev/null +++ b/internal/jenkins/job.go @@ -0,0 +1,10 @@ +package jenkins + +type Job struct { + relURL string + parametersJSON []byte +} + +func (j *Job) String() string { + return j.relURL +} diff --git a/internal/jenkins/jobtemplate.go b/internal/jenkins/jobtemplate.go new file mode 100644 index 0000000..35c0798 --- /dev/null +++ b/internal/jenkins/jobtemplate.go @@ -0,0 +1,93 @@ +package jenkins + +import ( + "bytes" + "encoding/json" + "fmt" + "net/url" + "text/template" +) + +var templateFuncs = template.FuncMap{ + "queryescape": url.QueryEscape, + "pathescape": url.PathEscape, +} + +// JobTemplate is a Job definition that can contain Go-Template statements. +type JobTemplate struct { + RelURL string + Parameters map[string]string +} + +type TemplateData struct { + PullRequestNumber string + Branch string +} + +// Template creates a concrete [Job] from j by templating it with +// [templateData] and [templateFuncs. +func (j *JobTemplate) Template(data TemplateData) (*Job, error) { + var relURLTemplated bytes.Buffer + + templ := template.New("job_templ").Funcs(templateFuncs).Option("missingkey=error") + + templ, err := templ.Parse(j.RelURL) + if err != nil { + return nil, fmt.Errorf("parsing endpoint as template failed: %w", err) + } + + if err := templ.Execute(&relURLTemplated, data); err != nil { + return nil, fmt.Errorf("templating post_data failed: %w", err) + } + + if len(j.Parameters) == 0 { + return &Job{ + relURL: relURLTemplated.String(), + }, nil + } + + templatedParams, err := j.templateParameters(data, templ) + if err != nil { + return nil, err + } + + jsonParams, err := json.Marshal(templatedParams) + if err != nil { + return nil, fmt.Errorf("converting templated job parameters to json failed: %w", err) + } + + return &Job{ + relURL: relURLTemplated.String(), + parametersJSON: jsonParams, + }, nil +} + +func (j *JobTemplate) templateParameters(data TemplateData, templ *template.Template) (map[string]string, error) { + templatedParams := make(map[string]string, len(j.Parameters)) + + for k, v := range j.Parameters { + var bufK, bufV bytes.Buffer + + templK, err := templ.Parse(k) + if err != nil { + return nil, fmt.Errorf("parsing parameter key %q as template failed: %w", k, err) + } + + if err := templK.Execute(&bufK, data); err != nil { + return nil, fmt.Errorf("templating post_data failed: %w", err) + } + + templV, err := templ.Parse(v) + if err != nil { + return nil, fmt.Errorf("parsing parameter value %q of key %q as template failed: %w", v, k, err) + } + + if err := templV.Execute(&bufV, data); err != nil { + return nil, fmt.Errorf("templating post_data failed: %w", err) + } + + templatedParams[bufK.String()] = bufV.String() + } + + return templatedParams, nil +} diff --git a/internal/jenkins/jobtemplate_test.go b/internal/jenkins/jobtemplate_test.go new file mode 100644 index 0000000..93a23d7 --- /dev/null +++ b/internal/jenkins/jobtemplate_test.go @@ -0,0 +1,53 @@ +package jenkins + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTemplate(t *testing.T) { + jt := JobTemplate{ + RelURL: "job/{{ pathescape .Branch }}/{{ .PullRequestNumber }}/build?branch={{ queryescape .Branch }}", + Parameters: map[string]string{ + "Branch": "{{ .Branch }}", + "PRNr": "{{ .PullRequestNumber }}", + }, + } + + d := TemplateData{ + PullRequestNumber: "456", + Branch: "ma/i-n br", + } + + j, err := jt.Template(d) + require.NoError(t, err) + + var params map[string]string + + err = json.Unmarshal(j.parametersJSON, ¶ms) + require.NoError(t, err) + + assert.Contains(t, params, "Branch") + assert.Equal(t, d.Branch, params["Branch"]) + assert.Contains(t, params, "PRNr") + assert.Equal(t, d.PullRequestNumber, params["PRNr"]) + + assert.Equal(t, "job/ma%2Fi-n%20br/456/build?branch=ma%2Fi-n+br", j.relURL) +} + +func TestTemplateFailsOnUndefinedKey(t *testing.T) { + jt := JobTemplate{ + RelURL: "abc", + Parameters: map[string]string{ + "UndefinedK": "{{ .Undefined }}", + }, + } + + d := TemplateData{} + + _, err := jt.Template(d) + require.Error(t, err) +} diff --git a/internal/logfields/ci.go b/internal/logfields/ci.go new file mode 100644 index 0000000..5f9ad1c --- /dev/null +++ b/internal/logfields/ci.go @@ -0,0 +1,7 @@ +package logfields + +import "go.uber.org/zap" + +func CIJob(name string) zap.Field { + return zap.String("ci.job", name) +}