feat: parse conventional commits
All checks were successful
build / build (push) Successful in 1m14s
build / trigger-build-image (push) Successful in 6s

This commit is contained in:
张泊明518370910136 2024-10-02 05:50:40 -04:00
parent 7cc6747a25
commit d723d98de0
GPG Key ID: D47306D7062CDA9D
3 changed files with 144 additions and 67 deletions

View File

@ -1,10 +1,9 @@
package main
import (
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"regexp"
"strings"
@ -19,6 +18,7 @@ type Conf struct {
OutputPath string `default:"joj3_result.json"`
Stages []struct {
Name string
Group string
Executor struct {
Name string
With struct {
@ -64,54 +64,28 @@ type OptionalCmd struct {
AddressSpaceLimit *bool
}
// TODO: add other fields to match? not only limit to latest commit message
type MetaConf struct {
Patterns []struct {
Filename string
Regex string
}
type ConventionalCommit struct {
Type string
Scope string
Description string
Body string
Footer string
}
func parseMetaConfFile(path string) (metaConf MetaConf, err error) {
// FIXME: remove this default meta config, it is only for demonstration
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
slog.Debug("meta conf not found", "path", path)
return MetaConf{
Patterns: []struct {
Filename string
Regex string
}{
{
Filename: "conf.json",
Regex: ".*",
},
},
}, nil
func parseConventionalCommit(commit string) (*ConventionalCommit, error) {
re := regexp.MustCompile(`^(\w+)(\(([^)]+)\))?!?: (.+)(\n\n(.+))?(\n\n(.+))?$`)
matches := re.FindStringSubmatch(strings.TrimSpace(commit))
if matches == nil {
return nil, fmt.Errorf("invalid conventional commit format")
}
d := &multiconfig.DefaultLoader{}
loaders := []multiconfig.Loader{}
loaders = append(loaders, &multiconfig.TagLoader{})
if strings.HasSuffix(path, "toml") {
loaders = append(loaders, &multiconfig.TOMLLoader{Path: path})
cc := &ConventionalCommit{
Type: matches[1],
Scope: matches[3],
Description: strings.TrimSpace(matches[4]),
Body: strings.TrimSpace(matches[6]),
Footer: strings.TrimSpace(matches[8]),
}
if strings.HasSuffix(path, "json") {
loaders = append(loaders, &multiconfig.JSONLoader{Path: path})
}
if strings.HasSuffix(path, "yml") || strings.HasSuffix(path, "yaml") {
loaders = append(loaders, &multiconfig.YAMLLoader{Path: path})
}
d.Loader = multiconfig.MultiLoader(loaders...)
d.Validator = multiconfig.MultiValidator(&multiconfig.RequiredValidator{})
if err = d.Load(&metaConf); err != nil {
slog.Error("parse meta conf", "error", err)
return
}
if err = d.Validate(&metaConf); err != nil {
slog.Error("validate meta conf", "error", err)
return
}
slog.Debug("meta conf loaded", "metaConf", metaConf)
return
return cc, nil
}
func parseConfFile(path string) (conf Conf, err error) {
@ -132,20 +106,32 @@ func parseConfFile(path string) (conf Conf, err error) {
return
}
func msgToConf(metaConfPath string, msg string) (conf Conf, err error) {
slog.Info("msg to conf", "msg", msg)
metaConf, err := parseMetaConfFile(metaConfPath)
func parseMsg(confRoot, confName, msg string) (conf Conf, group string, err error) {
slog.Info("parse msg", "msg", msg)
conventionalCommit, err := parseConventionalCommit(msg)
if err != nil {
return
}
for _, pattern := range metaConf.Patterns {
if matched, _ := regexp.MatchString(pattern.Regex, msg); matched {
slog.Debug("pattern matched",
"pattern", pattern, "filename", pattern.Filename)
slog.Info("pattern matched", "filename", pattern.Filename)
return parseConfFile(pattern.Filename)
slog.Info("conventional commit", "commit", conventionalCommit)
confRoot = filepath.Clean(confRoot)
confPath := filepath.Clean(fmt.Sprintf("%s/%s/%s",
confRoot, conventionalCommit.Scope, confName))
relPath, err := filepath.Rel(confRoot, confPath)
if err != nil {
return
}
if strings.HasPrefix(relPath, "..") {
err = fmt.Errorf("invalid scope as path: %s", conventionalCommit.Scope)
return
}
slog.Info("try to load conf", "path", confPath)
conf, err = parseConfFile(confPath)
if err != nil {
return
}
if strings.Contains(
strings.ToLower(conventionalCommit.Description), "joj") {
group = "joj"
}
err = fmt.Errorf("no pattern matched")
return
}

86
cmd/joj3/conf_test.go Normal file
View File

@ -0,0 +1,86 @@
package main
import (
"reflect"
"testing"
)
func TestParseConventionalCommit(t *testing.T) {
tests := []struct {
name string
commit string
want *ConventionalCommit
wantErr bool
}{
{
name: "Simple feat commit",
commit: "feat: add new feature",
want: &ConventionalCommit{
Type: "feat",
Description: "add new feature",
},
wantErr: false,
},
{
name: "Commit with scope",
commit: "fix(core): resolve memory leak",
want: &ConventionalCommit{
Type: "fix",
Scope: "core",
Description: "resolve memory leak",
},
wantErr: false,
},
{
name: "Breaking change commit",
commit: "feat(api)!: redesign user authentication",
want: &ConventionalCommit{
Type: "feat",
Scope: "api",
Description: "redesign user authentication",
},
wantErr: false,
},
{
name: "Commit with body",
commit: "docs: update README\n\nAdd installation instructions and improve examples",
want: &ConventionalCommit{
Type: "docs",
Description: "update README",
Body: "Add installation instructions and improve examples",
},
wantErr: false,
},
{
name: "Full commit with body and footer",
commit: "feat(auth)!: implement OAuth2\n\nThis commit adds OAuth2 support to the authentication system.\n\nBREAKING CHANGE: Previous authentication tokens are no longer valid.",
want: &ConventionalCommit{
Type: "feat",
Scope: "auth",
Description: "implement OAuth2",
Body: "This commit adds OAuth2 support to the authentication system.",
Footer: "BREAKING CHANGE: Previous authentication tokens are no longer valid.",
},
wantErr: false,
},
{
name: "Invalid commit format",
commit: "This is not a valid conventional commit",
want: nil,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseConventionalCommit(tt.commit)
if (err != nil) != tt.wantErr {
t.Errorf("ParseConventionalCommit() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("ParseConventionalCommit() = %v, want %v", got, tt.want)
}
})
}
}

View File

@ -32,9 +32,12 @@ func getCommitMsg() (msg string, err error) {
return
}
func generateStages(conf Conf) ([]stage.Stage, error) {
func generateStages(conf Conf, group string) ([]stage.Stage, error) {
stages := []stage.Stage{}
for _, s := range conf.Stages {
if s.Group != "" && group != "" && group != s.Group {
continue
}
var cmds []stage.Cmd
defaultCmd := s.Executor.With.Default
for _, optionalCmd := range s.Executor.With.Cases {
@ -81,14 +84,16 @@ func outputResult(outputPath string, results []stage.StageResult) error {
}
var (
metaConfPath string
confRoot string
confName string
msg string
showVersion *bool
Version string
Version string = "debug"
)
func init() {
flag.StringVar(&metaConfPath, "meta-conf", "meta-conf.toml", "meta config file path")
flag.StringVar(&confRoot, "conf-root", ".", "root path for all config files")
flag.StringVar(&confName, "conf-name", "conf.json", "filename for config files")
flag.StringVar(&msg, "msg", "", "message to trigger the running, leave empty to use git commit message on HEAD")
showVersion = flag.Bool("version", false, "print current version")
}
@ -97,12 +102,12 @@ func mainImpl() error {
if err := setupSlog(""); err != nil { // before conf is loaded
return err
}
slog.Info("start joj3", "version", Version)
flag.Parse()
if *showVersion {
fmt.Println(Version)
return nil
}
slog.Info("start joj3", "version", Version)
if msg == "" {
var err error
msg, err = getCommitMsg()
@ -111,9 +116,9 @@ func mainImpl() error {
return err
}
}
conf, err := msgToConf(metaConfPath, msg)
conf, group, err := parseMsg(confRoot, confName, msg)
if err != nil {
slog.Error("no conf found", "error", err)
slog.Error("parse msg", "error", err)
return err
}
if err := setupSlog(conf.LogPath); err != nil { // after conf is loaded
@ -122,7 +127,7 @@ func mainImpl() error {
slog.Info("debug log", "path", conf.LogPath)
slog.Debug("conf loaded", "conf", conf)
executors.InitWithConf(conf.SandboxExecServer, conf.SandboxToken)
stages, err := generateStages(conf)
stages, err := generateStages(conf, group)
if err != nil {
slog.Error("generate stages", "error", err)
return err