Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
125 changes: 125 additions & 0 deletions internal/engine/commit_evaluator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// SPDX-FileCopyrightText: Copyright 2026 The Minder Authors
// SPDX-License-Identifier: Apache-2.0

package engine

import "regexp"

const conventionalCommitTypePattern = `(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)`
const conventionalCommitScopePattern = `(\([A-Za-z0-9._/-]+\))?`

var conventionalCommitPattern = regexp.MustCompile(
`^` + conventionalCommitTypePattern + conventionalCommitScopePattern + `!?: .+`,
)

// Commit contains commit-level data that can be evaluated by policies.
type Commit struct {
SHA string
Message string
}

// CommitEvaluation captures the result of evaluating one policy.
type CommitEvaluation struct {
Policy string
Passed bool
Reason string
}

// CommitResult captures all evaluations for a single commit.
type CommitResult struct {
SHA string
Evaluations []CommitEvaluation
}

// CommitPolicy defines a single commit-level policy.
type CommitPolicy interface {
Name() string
Evaluate(commit Commit) CommitEvaluation
}

// CommitEvaluator evaluates commits against a list of commit-level policies.
type CommitEvaluator struct {
policies []CommitPolicy
}

// NewCommitEvaluator creates a commit evaluator with the provided policies.
func NewCommitEvaluator(policies ...CommitPolicy) *CommitEvaluator {
return &CommitEvaluator{policies: policies}
}

// Evaluate evaluates the commit against all configured policies.
func (e *CommitEvaluator) Evaluate(commit Commit) []CommitEvaluation {
if len(e.policies) == 0 {
return []CommitEvaluation{}
}

results := make([]CommitEvaluation, 0, len(e.policies))
for _, policy := range e.policies {
res := policy.Evaluate(commit)
if res.Policy == "" {
res.Policy = policy.Name()
}
results = append(results, res)
}

return results
}

// EvaluateAll evaluates all commits in a PR, preserving commit order.
func (e *CommitEvaluator) EvaluateAll(commits []Commit) []CommitResult {
results := make([]CommitResult, 0, len(commits))

for _, commit := range commits {
results = append(results, CommitResult{
SHA: commit.SHA,
Evaluations: e.Evaluate(commit),
})
}

return results
}

// HasPolicyFailures returns true if any policy evaluation fails.
func HasPolicyFailures(results []CommitResult) bool {
for _, commitResult := range results {
for _, eval := range commitResult.Evaluations {
if !eval.Passed {
return true
}
}
}

return false
}

// HasFailures returns true if any policy evaluation fails.
// Deprecated: use HasPolicyFailures for clearer semantics.
func HasFailures(results []CommitResult) bool {
return HasPolicyFailures(results)
}

// ConventionalCommitPolicy is an example commit policy that validates commit
// messages against a Conventional Commits-like format.
type ConventionalCommitPolicy struct{}

// Name returns the policy name.
func (ConventionalCommitPolicy) Name() string {
return "conventional_commit"
}

// Evaluate validates commit message format.
func (p ConventionalCommitPolicy) Evaluate(commit Commit) CommitEvaluation {
if conventionalCommitPattern.MatchString(commit.Message) {
return CommitEvaluation{
Policy: p.Name(),
Passed: true,
Reason: "commit message matches conventional commit format",
}
}

return CommitEvaluation{
Policy: p.Name(),
Passed: false,
Reason: "commit message must follow <type>(optional-scope): <description>",
}
}
144 changes: 144 additions & 0 deletions internal/engine/commit_evaluator_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// SPDX-FileCopyrightText: Copyright 2026 The Minder Authors
// SPDX-License-Identifier: Apache-2.0

package engine

import (
"testing"

"github.com/stretchr/testify/require"
)

type alwaysPassPolicy struct{}
type alwaysFailPolicy struct{}

func (alwaysPassPolicy) Name() string {
return "always_pass"
}

func (p alwaysPassPolicy) Evaluate(_ Commit) CommitEvaluation {
return CommitEvaluation{
Policy: p.Name(),
Passed: true,
Reason: "policy passed",
}
}

func (alwaysFailPolicy) Name() string {
return "always_fail"
}

func (p alwaysFailPolicy) Evaluate(_ Commit) CommitEvaluation {
return CommitEvaluation{
Policy: p.Name(),
Passed: false,
Reason: "policy failed",
}
}

func TestCommitEvaluatorEvaluateWithoutPolicies(t *testing.T) {
t.Parallel()

evaluator := NewCommitEvaluator()

results := evaluator.Evaluate(Commit{Message: "feat: add evaluator"})
require.NotNil(t, results)
require.Empty(t, results)
}

func TestCommitEvaluatorEvaluateWithPolicy(t *testing.T) {
t.Parallel()

evaluator := NewCommitEvaluator(alwaysPassPolicy{})

results := evaluator.Evaluate(Commit{Message: "anything"})
require.Len(t, results, 1)
require.Equal(t, "always_pass", results[0].Policy)
require.True(t, results[0].Passed)
require.Equal(t, "policy passed", results[0].Reason)
}

func TestConventionalCommitPolicy(t *testing.T) {
t.Parallel()

policy := ConventionalCommitPolicy{}

t.Run("passes on valid message", func(t *testing.T) {
t.Parallel()

result := policy.Evaluate(Commit{Message: "feat(engine): add commit evaluator"})
require.True(t, result.Passed)
require.Equal(t, "conventional_commit", result.Policy)
})

t.Run("passes with slash scope", func(t *testing.T) {
t.Parallel()

result := policy.Evaluate(Commit{Message: "feat(api/v1): add endpoint"})
require.True(t, result.Passed)
require.Equal(t, "conventional_commit", result.Policy)
})

t.Run("passes with uppercase scope", func(t *testing.T) {
t.Parallel()

result := policy.Evaluate(Commit{Message: "fix(API): handle timeout"})
require.True(t, result.Passed)
require.Equal(t, "conventional_commit", result.Policy)
})

t.Run("fails on invalid message", func(t *testing.T) {
t.Parallel()

result := policy.Evaluate(Commit{Message: "Add commit evaluator"})
require.False(t, result.Passed)
require.Equal(t, "conventional_commit", result.Policy)
require.Contains(t, result.Reason, "commit message")
})
}

func TestCommitEvaluatorEvaluateAll(t *testing.T) {
t.Parallel()

evaluator := NewCommitEvaluator(alwaysPassPolicy{})

results := evaluator.EvaluateAll([]Commit{
{SHA: "sha1", Message: "feat: one"},
{SHA: "sha2", Message: "fix: two"},
})

require.Len(t, results, 2)
require.Equal(t, "sha1", results[0].SHA)
require.Equal(t, "sha2", results[1].SHA)
require.Len(t, results[0].Evaluations, 1)
require.True(t, results[0].Evaluations[0].Passed)
}

func TestEvaluateAllEmpty(t *testing.T) {
t.Parallel()

evaluator := NewCommitEvaluator(alwaysPassPolicy{})
results := evaluator.EvaluateAll(nil)
require.Empty(t, results)
}

func TestHasPolicyFailures(t *testing.T) {
t.Parallel()

allPass := []CommitResult{
{SHA: "sha1", Evaluations: []CommitEvaluation{{Policy: "p1", Passed: true}}},
{SHA: "sha2", Evaluations: []CommitEvaluation{{Policy: "p1", Passed: true}}},
}
require.False(t, HasPolicyFailures(allPass))
require.False(t, HasFailures(allPass))

hasFail := []CommitResult{
{SHA: "sha1", Evaluations: []CommitEvaluation{{Policy: "p1", Passed: true}}},
{SHA: "sha2", Evaluations: []CommitEvaluation{{Policy: "p1", Passed: false}}},
}
require.True(t, HasPolicyFailures(hasFail))
require.True(t, HasFailures(hasFail))

evaluator := NewCommitEvaluator(alwaysPassPolicy{}, alwaysFailPolicy{})
require.True(t, HasPolicyFailures(evaluator.EvaluateAll([]Commit{{SHA: "sha3", Message: "any"}})))
}
Loading