Skip to content

Commit

Permalink
Implement remote and default config loading (#264)
Browse files Browse the repository at this point in the history
Use the go-githubapp/appconfig package to support remote configuration
references and organization-level defaults. To make this simpler to
implement, there are two changes to existing behavior:

1. If any config is invalid, Bulldozer stops processing the PR and logs
   a warning. Previously, it would fall back to global config (if set)
   in this case.

2. All defined config paths may contain either v1 or v0 configuration.
   Previously, some paths could only contain one version or the other.
   Since v0 config is undocumented and has been deprecated for a
   long-time, I don't expect this to impact anything.

I also did some refactoring, moving the fetching logic to the handler
package while leaving the parsing logic in the bulldozer package. This
mirrors the layout in policy-bot.
  • Loading branch information
bluekeyes authored Oct 27, 2021
1 parent 9a2fda3 commit 6df0106
Show file tree
Hide file tree
Showing 12 changed files with 745 additions and 284 deletions.
47 changes: 43 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,25 @@ The behavior of the bot is configured by a `.bulldozer.yml` file at the root of
the repository. The file name and location are configurable when running your
own instance of the server.

The `.bulldozer.yml` file is read from the most recent commit on the target
branch of each pull request. If `bulldozer` cannot find a configuration file,
it will take no action. This means it is safe to enable the `bulldozer` on all
repositories in an organization.
- The file is read from the most recent commit on the _target_ branch of each
pull request.

- The file may contain a reference to a configuration in a different
repository (see [Remote Configuration](#remote-configuration).)

- If the file does not exist in the repository, `bulldozer` tries to load a
shared `bulldozer.yml` file at the root of the `.github` repository in the
same organization. You can change this path and repository name when running
your own instance of the server.

- You can also define a global default configuration when running your own
instance of the server. This is used if the repository or the shared
organization repository do not define any configuration.

- If configuration does not exist in the repository or in the shared
organization repository and the server does not have a default configuration,
`bulldozer` does not act on the pull request. This means it is safe to enable
`bulldozer` on all repositories in an organization.

### bulldozer.yml Specification

Expand Down Expand Up @@ -171,6 +186,30 @@ update:
ignore_drafts: false
```
#### Remote Configuration
You can use a remote configuration by specifying a repository and an optional
path and Git reference. Place the following in the repository's
`.bulldozer.yml` file instead of the normal configuration:

```yaml
# The remote repository to read the configuration file from. This is required,
# and must be in "org/repo-name" form. Must be a public repository.
remote: org/repo-name
# The path to the configuration file in the remote repository. If not set,
# uses the default configuration path.
path: path/to/bulldozer.yml
# The branch (or tag, or commit hash) that should be used on the remote
# repository. If not set, uses the default branch of the repository.
ref: main
```

The remote file must contain `bulldozer` configuration and cannot be another
remote reference. However, the organization-level default configuration may be
a remote reference.

## FAQ

#### Can I specify both `ignore` and `trigger`?
Expand Down
144 changes: 144 additions & 0 deletions bulldozer/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// Copyright 2021 Palantir Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package bulldozer

import (
"github.com/pkg/errors"
"gopkg.in/yaml.v2"
)

func ParseConfig(c []byte) (*Config, error) {
config, v1err := parseConfigV1(c)
if v1err == nil {
return config, nil
}

config, v0err := parseConfigV0(c)
if v0err == nil {
return config, nil
}

// Encourage v1 usage by reporting the v1 parsing error in all cases
return nil, v1err
}

func parseConfigV1(bytes []byte) (*Config, error) {
var config Config
if err := yaml.UnmarshalStrict(bytes, &config); err != nil {
return nil, errors.Wrapf(err, "failed to unmarshal configuration")
}

// Merge old signals configurations if they exist when the new values aren't present
if config.Merge.Blacklist.Enabled() && !config.Merge.Ignore.Enabled() {
config.Merge.Ignore = config.Merge.Blacklist
}
if config.Merge.Whitelist.Enabled() && !config.Merge.Trigger.Enabled() {
config.Merge.Trigger = config.Merge.Whitelist
}
if config.Update.Blacklist.Enabled() && !config.Update.Ignore.Enabled() {
config.Update.Ignore = config.Update.Blacklist
}
if config.Update.Whitelist.Enabled() && !config.Update.Trigger.Enabled() {
config.Update.Trigger = config.Update.Whitelist
}

if config.Version != 1 {
return nil, errors.Errorf("unexpected version %d, expected 1", config.Version)
}

return &config, nil
}

func parseConfigV0(bytes []byte) (*Config, error) {
var configv0 ConfigV0
if err := yaml.UnmarshalStrict(bytes, &configv0); err != nil {
return nil, errors.Wrapf(err, "failed to unmarshal v0 configuration")
}

var config Config
switch configv0.Mode {
case ModeWhitelistV0:
config = Config{
Version: 1,
Update: UpdateConfig{
Trigger: Signals{
Labels: []string{"update me", "update-me", "update_me"},
},
},
Merge: MergeConfig{
Trigger: Signals{
Labels: []string{"merge when ready", "merge-when-ready", "merge_when_ready"},
},
DeleteAfterMerge: configv0.DeleteAfterMerge,
AllowMergeWithNoChecks: false,
Method: configv0.Strategy,
},
}
if config.Merge.Method == SquashAndMerge {
config.Merge.Options.Squash = &SquashOptions{
Body: SummarizeCommits,
}
}
case ModeBlacklistV0:
config = Config{
Version: 1,
Update: UpdateConfig{
Trigger: Signals{
Labels: []string{"update me", "update-me", "update_me"},
},
},
Merge: MergeConfig{
Ignore: Signals{
Labels: []string{"wip", "do not merge", "do-not-merge", "do_not_merge"},
},
DeleteAfterMerge: configv0.DeleteAfterMerge,
AllowMergeWithNoChecks: false,
Method: configv0.Strategy,
},
}
if config.Merge.Method == SquashAndMerge {
config.Merge.Options.Squash = &SquashOptions{
Body: SummarizeCommits,
}
}
case ModeBodyV0:
config = Config{
Version: 1,
Update: UpdateConfig{
Trigger: Signals{
Labels: []string{"update me", "update-me", "update_me"},
},
},
Merge: MergeConfig{
Trigger: Signals{
CommentSubstrings: []string{"==MERGE_WHEN_READY=="},
},
DeleteAfterMerge: configv0.DeleteAfterMerge,
AllowMergeWithNoChecks: false,
Method: configv0.Strategy,
},
}
if config.Merge.Method == SquashAndMerge {
config.Merge.Options.Squash = &SquashOptions{
Body: PullRequestBody,
MessageDelimiter: "==COMMIT_MSG==",
}
}
default:
return nil, errors.Errorf("unknown v0 mode: %q", configv0.Mode)
}

return &config, nil
}
Loading

0 comments on commit 6df0106

Please sign in to comment.