diff --git a/.gitea/workflows/CI.yml b/.gitea/workflows/CI.yml new file mode 100644 index 0000000..23d4be6 --- /dev/null +++ b/.gitea/workflows/CI.yml @@ -0,0 +1,29 @@ +name: "Go CI" + +on: + pull_request: + +jobs: + golangci: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: 1.24.2 + - name: golangci-lint + uses: golangci/golangci-lint-action@v7 + with: + version: v2.0 + + test: + name: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: 1.24.2 + - name: Test + run: go test ./... diff --git a/.gitea/workflows/pr.yml b/.gitea/workflows/pr.yml new file mode 100644 index 0000000..bea49c0 --- /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/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index e1a90fb..0eea3b6 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -52,23 +52,25 @@ jobs: run: | git add docs/ git commit -m "chore(release): [skip ci]" + continue-on-error: true - name: Push changes to release branch if: ${{ steps.semantic.outputs.new_release_published }} run: | git remote set-url origin git@${{ env.GITEA_DOMAIN }}:${{ github.repository }}.git git push origin HEAD:${{ env.RELEASE_BRANCH }} + continue-on-error: true - name: Checkout target branch - if: ${{ steps.semantic.outputs.new_release_published && !(env.SKIP_MERGE == true) }} + if: ${{ !(env.SKIP_MERGE == true) }} run: git reset --hard && git checkout ${{ env.TARGET_BRANCH }} && git pull - name: Merge release - if: ${{ steps.semantic.outputs.new_release_published && !(env.SKIP_MERGE == true) }} + if: ${{ !(env.SKIP_MERGE == true) }} run: git merge ${{ env.RELEASE_BRANCH }} - name: Push changes to target branch - if: ${{ steps.semantic.outputs.new_release_published && !(env.SKIP_MERGE == true) }} + if: ${{ !(env.SKIP_MERGE == true) }} run: | git remote set-url origin git@${{ env.GITEA_DOMAIN }}:${{ github.repository }}.git git push origin HEAD:${{ env.TARGET_BRANCH }} diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..d5ac850 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,4 @@ +version: "2" +linters: + enable: + - revive diff --git a/README.md b/README.md index a198e3f..7c7a6db 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,41 @@ # pull-request-lint -Lints the title of a pull request \ No newline at end of file +A GitHub/Gitea Action that lints pull request titles to ensure they follow the [Conventional Commits](https://www.conventionalcommits.org/) format. + +## Usage + +Add the following to your GitHub/Gitea workflow: + +```yaml +name: Pull Request Lint + +on: + pull_request: + types: [opened, edited, reopened, synchronize] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install go + uses: actions/setup-go@v5 + with: + go-version: 1.24.2 + - uses: https://git.kjan.de/actions/pull-request-lint@release +``` + +## Validation Rules + +The action enforces the following Conventional Commits rules for PR titles: + +- Format must be: `type(scope)!: description` (scope and breaking change marker `!` are optional) +- Type and scope must be lowercase +- Description must start with lowercase +- Breaking change indicator and footer are mutually exclusive +- Body must be separated from description by a blank line +- Footer tokens must use hyphens instead of spaces (except for "BREAKING CHANGE") + +## License + +See [LICENSE](LICENSE) file for details. diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..02052c2 --- /dev/null +++ b/action.yml @@ -0,0 +1,5 @@ +name: 'Pull Request Lint' +description: 'A gitea action to validate if a pr follows the semantic commit guidelines' +runs: + using: 'go' + main: 'main.go' diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3383ec9..15dad97 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,3 +1,46 @@ +# [1.1.0](https://git.kjan.de/actions/pull-request-lint/compare/v1.0.0...v1.1.0) (2025-04-09) + + +### Features + +* add linters ([#11](https://git.kjan.de/actions/pull-request-lint/issues/11)) ([e332ef8](https://git.kjan.de/actions/pull-request-lint/commit/e332ef87cbab6a7e0ebde5bd17a37b50c9aea967)) + +# [1.0.0](https://git.kjan.de/actions/pull-request-lint/compare/v0.3.0...v1.0.0) (2025-04-09) + + +### Code Refactoring + +* some polishing ([86febd9](https://git.kjan.de/actions/pull-request-lint/commit/86febd99e507d250adb18fdfede0a01f6e6f8d46)) + + +### BREAKING CHANGES + +* Version 1 + +# [0.3.0](https://git.kjan.de/actions/pull-request-lint/compare/v0.2.1...v0.3.0) (2025-04-09) + + +### Bug Fixes + +* **main:** improve error message for invalid PR title ([c84b1ec](https://git.kjan.de/actions/pull-request-lint/commit/c84b1ecd26d7cbd4530fed79e6078ced5d222e18)) +* **main:** improve error message for invalid PR title ([8981c83](https://git.kjan.de/actions/pull-request-lint/commit/8981c8367edc7beff7a477510ba3a3a98b6e6751)) +* **validation:** update error message for invalid commit format ([9b1a6a9](https://git.kjan.de/actions/pull-request-lint/commit/9b1a6a928e3483fd4ea6579cdcd8c8a9e95bb669)) + + +### Features + +* **action:** update to use Go for pull request linting ([6d9c480](https://git.kjan.de/actions/pull-request-lint/commit/6d9c48078c912b91bb60d8147b1b2d7e74ad8643)) +* add GitHub event handling and pull request title display ([0448235](https://git.kjan.de/actions/pull-request-lint/commit/044823552b5fecf0b076098d1bc47d213c00f604)) +* add pull request linting action and validation logic ([09380fb](https://git.kjan.de/actions/pull-request-lint/commit/09380fb8d002b200cebb4d56e8800569a0b60f16)) +* **invoke-binary:** add ls command execution in runGo function ([da4a8b3](https://git.kjan.de/actions/pull-request-lint/commit/da4a8b3030406f5ba8c82093f2385060cd0d48b8)) + +## [0.2.1](https://git.kjan.de/actions/pull-request-lint/compare/v0.2.0...v0.2.1) (2025-04-09) + + +### Bug Fixes + +* Fix release pipeline ([bc0bf00](https://git.kjan.de/actions/pull-request-lint/commit/bc0bf00947c563652b674cab947f863c5c477dd2)) + # [0.2.0](https://git.kjan.de/actions/pull-request-lint/compare/v0.1.0...v0.2.0) (2025-04-09) 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..0a4ada9 --- /dev/null +++ b/internal/validation/validation.go @@ -0,0 +1,78 @@ +// Package validation General validation tasks +package validation + +import ( + "fmt" + "regexp" + "strings" +) + +// ValidateConventionalCommit validates if string follows conventional commit +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.+)$`) + match := re.FindStringSubmatch(commit) + + if len(match) == 0 { + return fmt.Errorf("invalid commit format") + } + + typeIndex := re.SubexpIndex("type") + scopeIndex := re.SubexpIndex("scope") + breakingIndex := re.SubexpIndex("breaking") + + commitType := match[typeIndex] + scope := match[scopeIndex] + breaking := match[breakingIndex] + + // Type MUST be lowercase + if commitType != strings.ToLower(commitType) { + return fmt.Errorf("type must be 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..507692b --- /dev/null +++ b/main.go @@ -0,0 +1,54 @@ +// Package main is the main package +package main + +import ( + "encoding/json" + "fmt" + "os" + + "git.kjan.de/actions/pull-request-lint/internal/validation" +) + +// GithubEvent represents a github actions event +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 func() { + closeFileErr := eventFile.Close() + if closeFileErr != nil { + fmt.Println("Error closing eventFile") + os.Exit(1) + } + }() + + 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) +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..c183f9d --- /dev/null +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "local>Renovate/renovate-config" + ] +}