From f0f5b410e661c2b2657e2e1ddbfd1adc24994800 Mon Sep 17 00:00:00 2001 From: Marian Steinbach Date: Mon, 20 Oct 2025 16:08:03 +0200 Subject: [PATCH 1/8] Update CHANGELOG.md --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3ca5fe50..e92eac266 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- The `devctl repo setup` command now also generates a ruleset to enable Renovate to merge approved PRs. + ## [7.20.1] - 2025-10-17 ### Added From 63705bea2dad7a88169ca094492c804972779573 Mon Sep 17 00:00:00 2001 From: Marian Steinbach Date: Mon, 20 Oct 2025 16:08:11 +0200 Subject: [PATCH 2/8] Fix usage example --- cmd/repo/setup/renovate/command.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/repo/setup/renovate/command.go b/cmd/repo/setup/renovate/command.go index 6f9f1db46..68ae65906 100644 --- a/cmd/repo/setup/renovate/command.go +++ b/cmd/repo/setup/renovate/command.go @@ -55,7 +55,7 @@ func New(config Config) (*cobra.Command, error) { Long: longDescription, RunE: r.Run, Args: cobra.ExactArgs(1), - Example: " devctl setup renovate --remove giantswarm/myrepo", + Example: " devctl repo setup renovate --remove giantswarm/myrepo", } f.Init(c) From 58314ac27f78fcf9dfbd969a10ff0681246fd493 Mon Sep 17 00:00:00 2001 From: Marian Steinbach Date: Tue, 21 Oct 2025 10:05:44 +0200 Subject: [PATCH 3/8] Add dependency github.com/google/go-cmp --- go.mod | 1 + 1 file changed, 1 insertion(+) diff --git a/go.mod b/go.mod index 5ad032fc8..4449e3526 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/giantswarm/micrologger v1.1.2 github.com/giantswarm/release-operator/v4 v4.2.1 github.com/go-git/go-git/v5 v5.16.3 + github.com/google/go-cmp v0.7.0 github.com/google/go-github/v76 v76.0.0 github.com/jedib0t/go-pretty/v6 v6.6.8 github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 From 2e80afd390940c0107371b6e998edc48eac1aca1 Mon Sep 17 00:00:00 2001 From: Marian Steinbach Date: Tue, 21 Oct 2025 10:06:07 +0200 Subject: [PATCH 4/8] Add package comment --- pkg/updater/updater.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/updater/updater.go b/pkg/updater/updater.go index 172a4925d..fc5af6e1a 100644 --- a/pkg/updater/updater.go +++ b/pkg/updater/updater.go @@ -1,3 +1,4 @@ +// Package updater provides a way to self-update devctl. package updater import ( From e32d9fdf7b9b3c2578be874a7c864ad27b32fd96 Mon Sep 17 00:00:00 2001 From: Marian Steinbach Date: Tue, 21 Oct 2025 10:06:32 +0200 Subject: [PATCH 5/8] Add error type --- pkg/githubclient/error.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pkg/githubclient/error.go b/pkg/githubclient/error.go index d23b684d9..7a6ed1042 100644 --- a/pkg/githubclient/error.go +++ b/pkg/githubclient/error.go @@ -46,3 +46,12 @@ var prMergeTimeoutError = µerror.Error{ func IsPRMergeTimeout(err error) bool { return microerror.Cause(err) == prMergeTimeoutError } + +var rulesetNotFoundError = µerror.Error{ + Kind: "rulesetNotFoundError", +} + +// IsRulesetNotFound asserts rulesetNotFoundError. +func IsRulesetNotFound(err error) bool { + return microerror.Cause(err) == rulesetNotFoundError +} From 4094d0135ba18d75d98766763e52bf30c5590783 Mon Sep 17 00:00:00 2001 From: Marian Steinbach Date: Tue, 21 Oct 2025 10:06:53 +0200 Subject: [PATCH 6/8] Add creation of ruleset for renovate --- cmd/repo/setup/runner.go | 50 ++++++++++++++- pkg/githubclient/renovate.go | 114 +++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 1 deletion(-) diff --git a/cmd/repo/setup/runner.go b/cmd/repo/setup/runner.go index c9826b4a3..598125e7a 100644 --- a/cmd/repo/setup/runner.go +++ b/cmd/repo/setup/runner.go @@ -104,6 +104,7 @@ func (r *runner) run(ctx context.Context, cmd *cobra.Command, args []string) err return microerror.Mask(err) } + // Branch protection if r.flag.DisableBranchProtection { err = client.RemoveRepositoryBranchProtection(ctx, repository) if err != nil { @@ -116,12 +117,59 @@ func (r *runner) run(ctx context.Context, cmd *cobra.Command, args []string) err } } + // Renovate-related setup if r.flag.SetupRenovate { - r.logger.Printf("Adding %s/%s to repositories accessible by Renovate...", owner, *repository.Name) + rulesetNeedsUpdate := false + + // Add repository to Renovate permissions + r.logger.Infof("Adding %s/%s to repositories accessible by Renovate...", owner, repo) err = client.AddRepoToRenovatePermissions(ctx, owner, repository) if err != nil { return microerror.Mask(err) } + + // Find ruleset in repository + ruleset, err := client.ReadRenovateRuleset(ctx, owner, repo) + if ruleset != nil { + r.logger.Printf("Ruleset %s (ID %d) already exists in repository %s/%s", ruleset.Name, ruleset.GetID(), owner, repo) + + // check if up-to-date + if client.IsRulesetUpToDate(*ruleset) { + r.logger.Infof("Ruleset %s (ID %d) is up-to-date in repository %s/%s", ruleset.Name, ruleset.GetID(), owner, repo) + } else { + r.logger.Infof("Ruleset %s (ID %d) is not up-to-date in repository %s/%s", ruleset.Name, ruleset.GetID(), owner, repo) + rulesetNeedsUpdate = true + r.logger.Infof("Deleting ruleset %s (ID %d) in repository %s/%s", ruleset.Name, ruleset.GetID(), owner, repo) + err = client.DeleteRuleset(ctx, owner, repo, ruleset.GetID()) + if err != nil { + return microerror.Mask(err) + } + } + } else { + // handle not found error + if githubclient.IsRulesetNotFound(err) { + r.logger.Infof("Ruleset for renovate not found in repository %s/%s, creating it...", owner, repo) + rulesetNeedsUpdate = true + } else { + return microerror.Mask(err) + } + } + + if rulesetNeedsUpdate { + r.logger.Infof("Creating ruleset for renovate in repository %s/%s", owner, repo) + _, err = client.CreateRenovateRuleset(ctx, owner, repo) + if err != nil { + return microerror.Mask(err) + } + } + + } else { + r.logger.Printf("Removing %s/%s from repositories accessible by Renovate...", owner, *repository.Name) + err = client.RemoveRepoFromRenovatePermissions(ctx, owner, repository) + if err != nil { + // Not a critical error, we can continue + r.logger.Errorf("error removing %s/%s from repositories accessible by Renovate: %v", owner, *repository.Name, err) + } } r.logger.Info("completed repository setup") diff --git a/pkg/githubclient/renovate.go b/pkg/githubclient/renovate.go index 53a6ad7cf..b7d1bccf4 100644 --- a/pkg/githubclient/renovate.go +++ b/pkg/githubclient/renovate.go @@ -5,6 +5,8 @@ import ( "fmt" "net/http" + "github.com/giantswarm/microerror" + "github.com/google/go-cmp/cmp" "github.com/google/go-github/v76/github" ) @@ -13,7 +15,45 @@ const ( // https://github.com/organizations/giantswarm/settings/installations/17164699 renovateInstallationID = 17164699 + renovateIntegrationID = int64(2740) + githubApiVersion = "2022-11-28" + + rulesetName = "renovate-automerge" +) + +var ( + desiredRuleset = github.RepositoryRuleset{ + Name: rulesetName, + Target: github.Ptr(github.RulesetTargetBranch), + Enforcement: github.RulesetEnforcementActive, + BypassActors: []*github.BypassActor{ + &github.BypassActor{ + ActorID: github.Ptr(renovateIntegrationID), + ActorType: github.Ptr(github.BypassActorTypeIntegration), + BypassMode: github.Ptr(github.BypassModePullRequest), + }, + }, + Conditions: &github.RepositoryRulesetConditions{ + RefName: &github.RepositoryRulesetRefConditionParameters{ + Include: []string{"~DEFAULT_BRANCH"}, + Exclude: []string{}, + }, + }, + CurrentUserCanBypass: github.Ptr(github.BypassModeNever), + Rules: &github.RepositoryRulesetRules{ + PullRequest: &github.PullRequestRuleParameters{ + AllowedMergeMethods: []github.PullRequestMergeMethod{ + github.PullRequestMergeMethodMerge, + github.PullRequestMergeMethodSquash, + github.PullRequestMergeMethodRebase, + }, + AutomaticCopilotCodeReviewEnabled: github.Ptr(false), + RequiredApprovingReviewCount: 1, + }, + }, + SourceType: github.Ptr(github.RulesetSourceTypeRepository), + } ) // Add repository to the Renovate installation. Corresponds to @@ -59,3 +99,77 @@ func (c *Client) RemoveRepoFromRenovatePermissions(ctx context.Context, org stri return nil } + +// ReadRenovateRuleset reads and returns a ruleset by name from the given repository. +func (c *Client) ReadRenovateRuleset(ctx context.Context, owner, repo string) (*github.RepositoryRuleset, error) { + c.logger.Infof("reading ruleset %s from repository %s/%s", rulesetName, owner, repo) + + underlyingClient := c.GetUnderlyingClient(ctx) + + // List all rulesets in the repository + rulesets, _, err := underlyingClient.Repositories.GetAllRulesets(ctx, owner, repo, nil) + if err != nil { + return nil, microerror.Mask(err) + } + + // Find the ruleset with the matching name + id := int64(0) + for _, ruleset := range rulesets { + if ruleset.Name == rulesetName { + c.logger.Debugf("found ruleset %d with name %s in repository %s/%s", ruleset.GetID(), rulesetName, owner, repo) + id = ruleset.GetID() + } + } + + if id != 0 { + ruleset, _, err := underlyingClient.Repositories.GetRuleset(ctx, owner, repo, id, true) + if err != nil { + return nil, microerror.Mask(err) + } + return ruleset, nil + } + + // Ruleset not found + return nil, microerror.Maskf(rulesetNotFoundError, "ruleset %s not found in repository %s/%s", rulesetName, owner, repo) +} + +// CreateRenovateRuleset creates a ruleset in the given repository that allows Renovate to merge approved PRs. +func (c *Client) CreateRenovateRuleset(ctx context.Context, owner, repo string) (*github.RepositoryRuleset, error) { + c.logger.Infof("creating ruleset %s in repository %s/%s", rulesetName, owner, repo) + + underlyingClient := c.GetUnderlyingClient(ctx) + + ruleset, _, err := underlyingClient.Repositories.CreateRuleset(ctx, owner, repo, desiredRuleset) + if err != nil { + return nil, err + } + + c.logger.Debugf("created ruleset %d in repository %s/%s", ruleset.GetID(), owner, repo) + + return ruleset, nil +} + +func (c *Client) DeleteRuleset(ctx context.Context, owner, repo string, rulesetID int64) error { + underlyingClient := c.GetUnderlyingClient(ctx) + _, err := underlyingClient.Repositories.DeleteRuleset(ctx, owner, repo, rulesetID) + if err != nil { + return err + } + return nil +} + +// Helper function to compare read and desired ruleset +func (c *Client) IsRulesetUpToDate(readRuleset github.RepositoryRuleset) bool { + readRuleset.ID = nil + readRuleset.CreatedAt = nil + readRuleset.UpdatedAt = nil + readRuleset.NodeID = nil + readRuleset.Source = "" + readRuleset.Links = nil + if cmp.Equal(readRuleset, desiredRuleset) { + return true + } else { + c.logger.Infof("Found these differences in ruleset:\n%s\n", cmp.Diff(desiredRuleset, readRuleset, nil)) + return false + } +} From 7a9e33e046c71d425feeabc4577d13946382f929 Mon Sep 17 00:00:00 2001 From: Marian Steinbach Date: Tue, 21 Oct 2025 10:07:55 +0200 Subject: [PATCH 7/8] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e92eac266..de9bb5994 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- The `devctl repo setup` command now also generates a ruleset to enable Renovate to merge approved PRs. +- The `devctl repo setup` command now also generates a ruleset to enable Renovate to merge approved PRs, if `--renovate=true` (default). ## [7.20.1] - 2025-10-17 From 6446db5bc6193dea3eb48d9ad1c2eacd336ebee7 Mon Sep 17 00:00:00 2001 From: Marian Steinbach Date: Tue, 21 Oct 2025 10:30:34 +0200 Subject: [PATCH 8/8] Remove --dry-run flag --- CHANGELOG.md | 4 ++++ cmd/repo/setup/flag.go | 4 ---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de9bb5994..3405ccbe2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The `devctl repo setup` command now also generates a ruleset to enable Renovate to merge approved PRs, if `--renovate=true` (default). +### Removed + +- Removed the flag `--dry-run` from the `devctl repo setup` command, as it had no function. + ## [7.20.1] - 2025-10-17 ### Added diff --git a/cmd/repo/setup/flag.go b/cmd/repo/setup/flag.go index fee01df8f..ed41343ff 100644 --- a/cmd/repo/setup/flag.go +++ b/cmd/repo/setup/flag.go @@ -5,7 +5,6 @@ import ( ) type flag struct { - DryRun bool GithubTokenEnvVar string // Features @@ -41,9 +40,6 @@ func (f *flag) Init(cmd *cobra.Command) { // Persistent flags are also available to subcommands. cmd.PersistentFlags().StringVar(&f.GithubTokenEnvVar, "github-token-envvar", "GITHUB_TOKEN", "Environment variable name for Github token.") - // Standard flags - cmd.Flags().BoolVar(&f.DryRun, "dry-run", false, "Dry-run or ready-only mode. Show what is being made but do not apply any change.") - // Features cmd.Flags().BoolVar(&f.EnableWiki, "enable-wiki", false, "Enable wiki for this repo, false to remove it.") cmd.Flags().BoolVar(&f.EnableIssues, "enable-issues", true, "Enable issues for this repo, or false to remove them.")