Skip to content
Merged
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
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,6 @@ COPY --from=builder /tmp/bot /root/
# HEALTHCHECK --interval=10s --timeout=3s \
# CMD curl -f http://localhost:8080/health || exit 1

ENV SENTRY_RELEASE=2.2.0 SENTRY_ENVIRONMENT=container
ENV SENTRY_RELEASE=3.2.0 SENTRY_ENVIRONMENT=container

CMD exec /root/bot
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ stale_branches_deletion:
enabled: false # Clean up stale branches after merge
days: 90 # Consider branches stale after N days
batch_size: 5 # Number of branches can be deleted at once
wait_days: 1 # Wait N days before MR/branch deletion
wait_days: 1 # Wait N days before MR/branch deletion, merge-bot:stale label is set
```

#### Example Configuration
Expand Down Expand Up @@ -189,6 +189,7 @@ stale_branches_deletion:
enabled: true
days: 30
batch_size: 2
wait_days: 1
```

## Features
Expand All @@ -204,6 +205,14 @@ Customize welcome messages for new merge requests using Go templates. Available
- `{{ .TitleRegex }}`
- `{{ .Approvers }}`

### Labels

The bot creates 2 labels:
- merge-bot:stale
- merge-bot:auto-update

Use `merge-bot:auto-update` label if you need to update merge request when target branch (master) is updated.

## Demo

Test the bot on our public demo repository: [https://gitlab.com/Gasoid/sugar-test](https://gitlab.com/Gasoid/sugar-test)
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ services:
# github registry
# image: ghcr.io/gasoid/merge-bot:latest
# dockerhub registry
image: gasoid/merge-bot:3.1.3
image: gasoid/merge-bot:3.2.0
restart: always
volumes:
- tls-cache:/tmp/tls/.cache
Expand Down
166 changes: 119 additions & 47 deletions handlers/checkers.go
Original file line number Diff line number Diff line change
@@ -1,70 +1,142 @@
package handlers

import "regexp"
import (
"fmt"
"regexp"
)

type Checker struct {
text string
checkFunc func(*Config, *MrInfo) (bool, bool)
type CheckResult struct {
Passed bool
Required bool
Message string
}

func checkTitle(mrConfig *Config, info *MrInfo) (bool, bool) {
match, _ := regexp.MatchString(mrConfig.Rules.TitleRegex, info.Title)
return match, true
func checkTitle(mrConfig *Config, info *MrInfo) CheckResult {
if mrConfig.Rules.TitleRegex == "" {
return CheckResult{Passed: true, Required: false, Message: "No title regex configured"}
}

match, err := regexp.MatchString(mrConfig.Rules.TitleRegex, info.Title)
if err != nil {
return CheckResult{Passed: false, Required: true, Message: "Invalid title regex pattern"}
}

if match {
return CheckResult{Passed: true, Required: true, Message: "Title matches required pattern"}
}
return CheckResult{Passed: false, Required: true, Message: "Title doesn't match required pattern"}
}

func checkDescription(mrConfig *Config, info *MrInfo) (bool, bool) {
return len(info.Description) > 0, !mrConfig.Rules.AllowEmptyDescription
func checkDescription(mrConfig *Config, info *MrInfo) CheckResult {
hasDescription := len(info.Description) > 0
required := !mrConfig.Rules.AllowEmptyDescription

if required && !hasDescription {
return CheckResult{Passed: false, Required: true, Message: "Description is required but empty"}
}
if hasDescription {
return CheckResult{Passed: true, Required: required, Message: "Description provided"}
}
return CheckResult{Passed: true, Required: false, Message: "Description not required"}
}

func checkApprovals(mrConfig *Config, info *MrInfo) (bool, bool) {
return len(info.Approvals) >= mrConfig.Rules.MinApprovals, true
func checkApprovals(mrConfig *Config, info *MrInfo) CheckResult {
actual := len(info.Approvals)
required := mrConfig.Rules.MinApprovals

if actual >= required {
return CheckResult{
Passed: true,
Required: true,
Message: fmt.Sprintf("Has %d approvals (required: %d)", actual, required),
}
}
return CheckResult{
Passed: false,
Required: true,
Message: fmt.Sprintf("Has %d approvals, need %d", actual, required),
}
}

func checkApprovers(mrConfig *Config, info *MrInfo) (bool, bool) {
if len(mrConfig.Rules.Approvers) > 0 {
for _, a := range mrConfig.Rules.Approvers {
if _, ok := info.Approvals[a]; !ok {
return false, true
func checkApprovers(mrConfig *Config, info *MrInfo) CheckResult {
if len(mrConfig.Rules.Approvers) == 0 {
return CheckResult{Passed: true, Required: false, Message: "No specific approvers required"}
}

for _, requiredApprover := range mrConfig.Rules.Approvers {
if requiredApprover == info.Author {
continue
}

if _, approved := info.Approvals[requiredApprover]; !approved {
return CheckResult{
Passed: false,
Required: true,
Message: fmt.Sprintf("Missing approval from required approver: %s", requiredApprover),
}
}
return true, true
}
return true, false

return CheckResult{
Passed: true,
Required: true,
Message: "All required approvers have approved",
}
}

func checkPipelines(mrConfig *Config, info *MrInfo) (bool, bool) {
return info.FailedPipelines == 0, !mrConfig.Rules.AllowFailingPipelines
func checkPipelines(mrConfig *Config, info *MrInfo) CheckResult {
required := !mrConfig.Rules.AllowFailingPipelines
passed := info.FailedPipelines == 0

if passed {
return CheckResult{Passed: true, Required: required, Message: "All pipelines passed"}
}

if required {
return CheckResult{
Passed: false,
Required: true,
Message: fmt.Sprintf("%d pipeline(s) failed", info.FailedPipelines),
}
}

return CheckResult{
Passed: true,
Required: false,
Message: fmt.Sprintf("%d pipeline(s) failed (allowed)", info.FailedPipelines),
}
}

func checkTests(mrConfig *Config, info *MrInfo) (bool, bool) {
return info.FailedTests == 0, !mrConfig.Rules.AllowFailingTests
func checkTests(mrConfig *Config, info *MrInfo) CheckResult {
required := !mrConfig.Rules.AllowFailingTests
passed := info.FailedTests == 0

if passed {
return CheckResult{Passed: true, Required: required, Message: "All tests passed"}
}

if required {
return CheckResult{
Passed: false,
Required: true,
Message: fmt.Sprintf("%d test(s) failed", info.FailedTests),
}
}

return CheckResult{
Passed: true,
Required: false,
Message: fmt.Sprintf("%d test(s) failed (allowed)", info.FailedTests),
}
}

var (
checkers = []Checker{
{
text: "Title meets rules",
checkFunc: checkTitle,
},
{
text: "Description meets rules",
checkFunc: checkDescription,
},
{
text: "Number of approvals (mr author is ignored)",
checkFunc: checkApprovals,
},
{
text: "Required approvers (mr author is ignored)",
checkFunc: checkApprovers,
},
{
text: "Pipeline didn't fail ",
checkFunc: checkPipelines,
},
{
text: "Tests",
checkFunc: checkTests,
},
checkers = []func(*Config, *MrInfo) CheckResult{
checkTitle,
checkDescription,
checkApprovals,
checkApprovers,
checkPipelines,
checkTests,
}
)
Loading
Loading