Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ 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, 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
Expand Down
4 changes: 0 additions & 4 deletions cmd/repo/setup/flag.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
)

type flag struct {
DryRun bool
GithubTokenEnvVar string

// Features
Expand Down Expand Up @@ -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.")
Expand Down
2 changes: 1 addition & 1 deletion cmd/repo/setup/renovate/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
50 changes: 49 additions & 1 deletion cmd/repo/setup/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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")
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions pkg/githubclient/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,12 @@ var prMergeTimeoutError = &microerror.Error{
func IsPRMergeTimeout(err error) bool {
return microerror.Cause(err) == prMergeTimeoutError
}

var rulesetNotFoundError = &microerror.Error{
Kind: "rulesetNotFoundError",
}

// IsRulesetNotFound asserts rulesetNotFoundError.
func IsRulesetNotFound(err error) bool {
return microerror.Cause(err) == rulesetNotFoundError
}
114 changes: 114 additions & 0 deletions pkg/githubclient/renovate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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
Expand Down Expand Up @@ -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
}
}
1 change: 1 addition & 0 deletions pkg/updater/updater.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// Package updater provides a way to self-update devctl.
package updater

import (
Expand Down