diff --git a/.gitea/workflows/pr.yml b/.gitea/workflows/pr.yml new file mode 100644 index 0000000..2eba5c0 --- /dev/null +++ b/.gitea/workflows/pr.yml @@ -0,0 +1,21 @@ +name: "Lint Pull Request" + +on: + pull_request: + types: [opened, edited, reopened, synchronize] + +jobs: + lint: + name: Lint Pr Title + runs-on: ubuntu-latest + + if: github.base_ref != 'release' + + steps: + - name: Install go + uses: actions/setup-go@v5 + with: + go-version: 1.24.2 + + - name: Run Pull Request Lint Action + uses: https://git.kjan.de/actions/pull-request-lint@main diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..3aeab77 --- /dev/null +++ b/action.yml @@ -0,0 +1,5 @@ +name: 'Pull Request Lint' +description: 'A simple Gitea action written in go' +runs: + using: 'go' + main: 'main.go' diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..575ab3e --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.kjan.de/actions/pull-request-lint + +go 1.24.2 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/internal/validation/validation.go b/internal/validation/validation.go new file mode 100644 index 0000000..4d48e4a --- /dev/null +++ b/internal/validation/validation.go @@ -0,0 +1,83 @@ +package validation + +import ( + "fmt" + "regexp" + "strings" +) + +func ValidateConventionalCommit(commit string) error { + // Regex to match the commit format + // type(scope)!: description + // or + // type!: description + // or + // type: description + re := regexp.MustCompile(`^(?P[a-z]+)(?P\([a-z]+\))?(?P!)?: (?P[a-z].+)$`) + match := re.FindStringSubmatch(commit) + + if len(match) == 0 { + return fmt.Errorf("Invalid PR title") + } + + typeIndex := re.SubexpIndex("type") + scopeIndex := re.SubexpIndex("scope") + breakingIndex := re.SubexpIndex("breaking") + descriptionIndex := re.SubexpIndex("description") + + commitType := match[typeIndex] + scope := match[scopeIndex] + breaking := match[breakingIndex] + description := match[descriptionIndex] + + // Type MUST be lowercase + if commitType != strings.ToLower(commitType) { + return fmt.Errorf("type must be lowercase") + } + + // Description MUST start with lowercase + if description != strings.ToLower(description) { + return fmt.Errorf("description must start with lowercase") + } + + // Scope MUST be lowercase + if scope != "" && scope != strings.ToLower(scope) { + return fmt.Errorf("scope must be lowercase") + } + + // Check for breaking change indicator + hasBreakingChangeFooter := strings.Contains(commit, "BREAKING CHANGE:") + if breaking == "!" && hasBreakingChangeFooter { + return fmt.Errorf("breaking change indicator and footer are mutually exclusive") + } + + lines := strings.Split(commit, "\n") + if len(lines) > 1 { + body := strings.Join(lines[1:], "\n") + // Check if body is separated from description by a blank line + if !strings.HasPrefix(body, "\n") { + return fmt.Errorf("body must be separated from description by a blank line") + } + } + + // Check for footers + footerRegex := regexp.MustCompile(`(?m)^(?P[A-Za-z-]+|BREAKING CHANGE): (?P.*)$`) + footerMatches := footerRegex.FindAllStringSubmatch(commit, -1) + + for _, footerMatch := range footerMatches { + tokenIndex := footerRegex.SubexpIndex("token") + token := footerMatch[tokenIndex] + + // BREAKING-CHANGE MUST be synonymous with BREAKING CHANGE + if token == "BREAKING-CHANGE" { + continue + } + + // Token MUST use - in place of whitespace characters, except for BREAKING CHANGE + if token != "BREAKING CHANGE" && strings.Contains(token, " ") { + return fmt.Errorf("footer token must use - in place of whitespace characters") + } + } + + return nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..3a70316 --- /dev/null +++ b/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + + "git.kjan.de/actions/pull-request-lint/internal/validation" +) + +type GithubEvent struct { + PullRequest struct { + Title string `json:"title"` + } `json:"pull_request"` +} + +func main() { + eventPath := os.Getenv("GITHUB_EVENT_PATH") + if eventPath == "" { + fmt.Println("GITHUB_EVENT_PATH not set") + os.Exit(1) + } + + eventFile, err := os.Open(eventPath) + if err != nil { + fmt.Printf("Error opening %s: %v\n", eventPath, err) + os.Exit(1) + } + defer eventFile.Close() + + var event GithubEvent + decoder := json.NewDecoder(eventFile) + err = decoder.Decode(&event) + if err != nil { + fmt.Printf("Error decoding event.json: %v\n", err) + os.Exit(1) + } + + prTitle := event.PullRequest.Title + semanticValidationErr := validation.ValidateConventionalCommit(prTitle) + if semanticValidationErr != nil { + fmt.Println(semanticValidationErr) + os.Exit(1) + } + os.Exit(0) +}