diff --git a/README.md b/README.md index cc92b366..016ef48a 100644 --- a/README.md +++ b/README.md @@ -17,4 +17,4 @@ Please note that cr-bot is still in its early stages, and we have plans to integ Lastly, we welcome any contributions to the project! Your bug reports, feature suggestions, and active participation in the development process are greatly appreciated. Together, we can make cr-bot even better. -![Alt text](docs/static/image.png) \ No newline at end of file +![Alt text](docs/static/image.png) diff --git a/config/config.go b/config/config.go index 3c26d759..50e99ba2 100644 --- a/config/config.go +++ b/config/config.go @@ -34,7 +34,7 @@ func NewConfig(conf string) (Config, error) { return c, nil } -func (c Config) Linters(org, repo string) map[string]Linter { +func (c Config) CustomLinterConfigs(org, repo string) map[string]Linter { if repoConfig, ok := c[org+"/"+repo]; ok { return repoConfig } diff --git a/config/config.yaml b/config/config.yaml index d99265b7..2dd87939 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -1,10 +1,15 @@ -qbox: # github organization name - staticcheck: # linters - enable: true - command: staticcheck # linter command, if not set, will use the linter name registered in init stage - args: ["./..."] - run_if_changed: [".go$"] # only run if changed files match the regex - +# This is the default config file, you can override it by creating a config.yaml in the same directory +# by default, all linters are enabled if you don't specify. You can disable them by setting enable to false +# example1: disable staticcheck for org +# qbox: +# staticcheck: +# enable: false +# +# example2: disable staticcheck for repo +# qbox/kodo: +# staticcheck: +# enable: false +# qbox/kodo: # github repository name, which will override the settings in qbox org staticcheck: # repo specific settings will override the settings in qbox org enable: true diff --git a/pulls.go b/github.go similarity index 84% rename from pulls.go rename to github.go index 018a6c56..827ba869 100644 --- a/pulls.go +++ b/github.go @@ -1,18 +1,19 @@ /* -Copyright 2023 Qiniu Cloud (qiniu.com). - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. + Copyright 2024 Qiniu Cloud (qiniu.com). + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. */ + package main import ( @@ -22,7 +23,7 @@ import ( "strings" "time" - "github.com/cr-bot/linters" + "github.com/cr-bot/internal/linters" "github.com/google/go-github/v57/github" "github.com/qiniu/x/log" ) @@ -51,7 +52,7 @@ func (s *Server) ListPullRequestsFiles(ctx context.Context, owner string, repo s } } -func (s *Server) PostCommentsWithRetry(ctx context.Context, owner string, repo string, number int, comments []*github.PullRequestComment) error { +func (s *Server) PostPullReviewCommentsWithRetry(ctx context.Context, owner string, repo string, number int, comments []*github.PullRequestComment) error { var existedComments []*github.PullRequestComment err := retryWithBackoff(ctx, func() error { originalComments, resp, err := s.gc.PullRequests.ListComments(ctx, owner, repo, number, nil) diff --git a/hunk.go b/hunk.go index cc363a51..7ff5db98 100644 --- a/hunk.go +++ b/hunk.go @@ -1,18 +1,19 @@ /* -Copyright 2023 Qiniu Cloud (qiniu.com). - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. + Copyright 2024 Qiniu Cloud (qiniu.com). + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. */ + package main import ( diff --git a/hunk_test.go b/hunk_test.go index d99465d2..40b704f9 100644 --- a/hunk_test.go +++ b/hunk_test.go @@ -1,18 +1,19 @@ /* -Copyright 2023 Qiniu Cloud (qiniu.com). - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. + Copyright 2024 Qiniu Cloud (qiniu.com). + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. */ + package main import ( diff --git a/internal/linters/git-flow/rebase-suggestion/rebase-suggestion.go b/internal/linters/git-flow/rebase-suggestion/rebase-suggestion.go new file mode 100644 index 00000000..eaa002c8 --- /dev/null +++ b/internal/linters/git-flow/rebase-suggestion/rebase-suggestion.go @@ -0,0 +1,83 @@ +/* + Copyright 2024 Qiniu Cloud (qiniu.com). + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package rebase_suggestion + +import ( + "context" + "fmt" + "regexp" + + "github.com/cr-bot/config" + "github.com/cr-bot/internal/linters" + "github.com/google/go-github/v57/github" + "github.com/qiniu/x/log" +) + +var lintName = "rebase-suggestion" + +func init() { + // register linter + linters.RegisterCommentHandler(lintName, rebaseSuggestionHandler) +} + +func rebaseSuggestionHandler(linterConfig config.Linter, agent linters.Agent, event github.PullRequestEvent) error { + opts := &github.ListOptions{} + commits, response, err := agent.GitHubClient().PullRequests.ListCommits(context.Background(), event.GetRepo().GetOwner().GetLogin(), event.GetRepo().GetName(), event.GetNumber(), opts) + if err != nil { + return err + } + + if response.StatusCode != 200 { + log.Errorf("list commits failed: %v", response) + return fmt.Errorf("list commits failed: %v", response.Body) + } + + comment := checkCommitMessage(commits) + if len(comment) == 0 { + return nil + } + c, resp, err := agent.GitHubClient().Issues.CreateComment(context.Background(), event.GetRepo().GetOwner().GetLogin(), event.GetRepo().GetName(), event.GetNumber(), &github.IssueComment{ + Body: &comment, + }) + if err != nil { + return err + } + + if resp.StatusCode != 201 { + log.Errorf("create comment failed: %v", resp) + return fmt.Errorf("create comment failed: %v", resp.Body) + } + + log.Infof("create comment success: %v", c) + + return nil +} + +func checkCommitMessage(commits []*github.RepositoryCommit) string { + pattern := `^Merge (.*) into (.*)$` + reg := regexp.MustCompile(pattern) + + for _, commit := range commits { + if commit.Commit != nil && commit.Commit.Message != nil { + if reg.MatchString(*commit.Commit.Message) { + return "please rebase your PR" + } + } + } + + return "" +} diff --git a/linters/staticcheck/staticcheck.go b/internal/linters/go/staticcheck/staticcheck.go similarity index 80% rename from linters/staticcheck/staticcheck.go rename to internal/linters/go/staticcheck/staticcheck.go index 56e98636..312396f6 100644 --- a/linters/staticcheck/staticcheck.go +++ b/internal/linters/go/staticcheck/staticcheck.go @@ -1,18 +1,19 @@ /* -Copyright 2023 Qiniu Cloud (qiniu.com). - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. + Copyright 2024 Qiniu Cloud (qiniu.com). + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. */ + package staticcheck import ( @@ -23,15 +24,18 @@ import ( "strings" "github.com/cr-bot/config" - "github.com/cr-bot/linters" + "github.com/cr-bot/internal/linters" + "github.com/google/go-github/v57/github" "github.com/qiniu/x/log" ) +var lintName = "staticcheck" + func init() { - linters.RegisterLinter("staticcheck", staticcheckHandler) + linters.RegisterCodeReviewHandler(lintName, staticcheckHandler) } -func staticcheckHandler(linterConfig config.Linter) (map[string][]linters.LinterOutput, error) { +func staticcheckHandler(linterConfig config.Linter, agent linters.Agent, event github.PullRequestEvent) (map[string][]linters.LinterOutput, error) { executor, err := NewStaticcheckExecutor(linterConfig.WorkDir) if err != nil { return nil, err diff --git a/linters/staticcheck/staticcheck_test.go b/internal/linters/go/staticcheck/staticcheck_test.go similarity index 73% rename from linters/staticcheck/staticcheck_test.go rename to internal/linters/go/staticcheck/staticcheck_test.go index 85d578b3..8e220069 100644 --- a/linters/staticcheck/staticcheck_test.go +++ b/internal/linters/go/staticcheck/staticcheck_test.go @@ -1,9 +1,25 @@ +/* + Copyright 2024 Qiniu Cloud (qiniu.com). + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + package staticcheck import ( "testing" - "github.com/cr-bot/linters" + "github.com/cr-bot/internal/linters" ) func TestFormatStaticcheckLine(t *testing.T) { diff --git a/internal/linters/linters.go b/internal/linters/linters.go new file mode 100644 index 00000000..75f5d6b2 --- /dev/null +++ b/internal/linters/linters.go @@ -0,0 +1,118 @@ +/* + Copyright 2024 Qiniu Cloud (qiniu.com). + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package linters + +import ( + "github.com/cr-bot/config" + "github.com/google/go-github/v57/github" + gitv2 "k8s.io/test-infra/prow/git/v2" +) + +var ( + codeReviewHandlers = map[string]CodeReviewHandlerFunc{} + commentHandlers = map[string]CommentHandlerFunc{} +) + +// CommentHandlerFunc knows how to comment on a PR. +type CommentHandlerFunc func(config.Linter, Agent, github.PullRequestEvent) error + +// RegisterCommentHandler registers a CommentHandlerFunc for the given linter name. +func RegisterCommentHandler(name string, handler CommentHandlerFunc) { + commentHandlers[name] = handler +} + +// CommentHandler returns a CommentHandlerFunc for the given linter name. +func CommentHandler(name string) CommentHandlerFunc { + if handler, ok := commentHandlers[name]; ok { + return handler + } + return nil +} + +// TotalCommentHandlers returns all registered CommentHandlerFunc. +func TotalCommentHandlers() map[string]CommentHandlerFunc { + var handlers = make(map[string]CommentHandlerFunc, len(commentHandlers)) + for name, handler := range commentHandlers { + handlers[name] = handler + } + + return handlers +} + +// CodeReviewHandlerFunc knows how to code review on a PR. +type CodeReviewHandlerFunc func(config.Linter, Agent, github.PullRequestEvent) (map[string][]LinterOutput, error) + +// RegisterCodeReviewHandler registers a CodeReviewHandlerFunc for the given linter name. +func RegisterCodeReviewHandler(name string, handler CodeReviewHandlerFunc) { + codeReviewHandlers[name] = handler +} + +// CodeReviewHandler returns a CodeReviewHandlerFunc for the given linter name. +func TotalCodeReviewHandlers() map[string]CodeReviewHandlerFunc { + var handlers = make(map[string]CodeReviewHandlerFunc, len(codeReviewHandlers)) + for name, handler := range codeReviewHandlers { + handlers[name] = handler + } + + return handlers +} + +// Linter knows how to execute linters. +type Linter interface { + // Run executes a linter command. + Run(args ...string) ([]byte, error) + // Parse parses the output of a linter command. + Parse(output []byte) (map[string][]LinterOutput, error) +} + +type LinterOutput struct { + // File is the File name + File string + // Line is the Line number + Line int + // Column is the Column number + Column int + // Message is the staticcheck Message + Message string +} + +// Agent knows necessary information from cr-bot. +type Agent struct { + gc *github.Client + gitClientFactory gitv2.ClientFactory + config config.Config +} + +func NewAgent(gc *github.Client, gitClientFactory gitv2.ClientFactory, config config.Config) Agent { + return Agent{ + gc: gc, + gitClientFactory: gitClientFactory, + config: config, + } +} + +func (a Agent) GitHubClient() *github.Client { + return a.gc +} + +func (a Agent) GitClientFactory() gitv2.ClientFactory { + return a.gitClientFactory +} + +func (a Agent) Config() config.Config { + return a.config +} diff --git a/linters/linters.go b/linters/linters.go deleted file mode 100644 index 41e7a940..00000000 --- a/linters/linters.go +++ /dev/null @@ -1,58 +0,0 @@ -/* -Copyright 2023 Qiniu Cloud (qiniu.com). - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ -package linters - -import ( - "github.com/cr-bot/config" -) - -var ( - lintersHandlers = map[string]LinterHandlerFunc{} -) - -// LinterHandlerFunc knows how to run a linter. -type LinterHandlerFunc func(config.Linter) (map[string][]LinterOutput, error) - -func RegisterLinter(name string, handler LinterHandlerFunc) { - lintersHandlers[name] = handler -} - -// LinterHandler returns a LinterHandlerFunc for the given linter name. -func LinterHandler(name string) LinterHandlerFunc { - if handler, ok := lintersHandlers[name]; ok { - return handler - } - return nil -} - -// Linter knows how to execute linters. -type Linter interface { - // Run executes a linter command. - Run(args ...string) ([]byte, error) - // Parse parses the output of a linter command. - Parse(output []byte) (map[string][]LinterOutput, error) -} - -type LinterOutput struct { - // File is the File name - File string - // Line is the Line number - Line int - // Column is the Column number - Column int - // Message is the staticcheck Message - Message string -} diff --git a/main.go b/main.go index 07107fec..e8aa3ce5 100644 --- a/main.go +++ b/main.go @@ -1,18 +1,19 @@ /* -Copyright 2023 Qiniu Cloud (qiniu.com). - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. + Copyright 2024 Qiniu Cloud (qiniu.com). + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. */ + package main import ( @@ -31,7 +32,8 @@ import ( gitv2 "k8s.io/test-infra/prow/git/v2" // linters import - _ "github.com/cr-bot/linters/staticcheck" + _ "github.com/cr-bot/internal/linters/git-flow/rebase-suggestion" + _ "github.com/cr-bot/internal/linters/go/staticcheck" ) type options struct { diff --git a/server.go b/server.go index 76948bf3..7bb616ba 100644 --- a/server.go +++ b/server.go @@ -1,18 +1,19 @@ /* -Copyright 2023 Qiniu Cloud (qiniu.com). - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. + Copyright 2024 Qiniu Cloud (qiniu.com). + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. */ + package main import ( @@ -21,7 +22,7 @@ import ( "net/http" "github.com/cr-bot/config" - "github.com/cr-bot/linters" + "github.com/cr-bot/internal/linters" "github.com/google/go-github/v57/github" "github.com/qiniu/x/xlog" gitv2 "k8s.io/test-infra/prow/git/v2" @@ -69,7 +70,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (s *Server) processPullRequestEvent(log *xlog.Logger, event *github.PullRequestEvent, eventGUID string) error { // TODO: synchronization 是什么意思? - if event.GetAction() != "opened" && event.GetAction() != "reopened" { + if event.GetAction() != "opened" && event.GetAction() != "reopened" && event.GetAction() != "synchronize" { log.Debugf("skipping action %s\n", event.GetAction()) return nil } @@ -105,41 +106,65 @@ func (s *Server) handle(log *xlog.Logger, ctx context.Context, event *github.Pul log.Errorf("failed to checkout pull request %d: %v", num, err) return err } - defer r.Clean() - var totalComments []*github.PullRequestComment + customLinterConfigs := s.config.CustomLinterConfigs(org, repo) + log.Infof("found %d custom linter configs for %s\n", len(customLinterConfigs), org+"/"+repo) - for name, lingerConfig := range s.config.Linters(org, repo) { - f := linters.LinterHandler(name) - if f == nil { - continue + for name, fn := range linters.TotalCodeReviewHandlers() { + var lingerConfig config.Linter + if v, ok := customLinterConfigs[name]; ok { + lingerConfig = v } - // 更新完整的工作目录 - lingerConfig.WorkDir = r.Directory() + "/" + lingerConfig.WorkDir + if lingerConfig.WorkDir != "" { + // 更新完整的工作目录 + lingerConfig.WorkDir = r.Directory() + "/" + lingerConfig.WorkDir + } + + log.Infof("running %s on repo %v with config %v", name, fmt.Sprintf("%s/%s", org, repo), lingerConfig) - log.Infof("name: %v, lingerConfig: %+v", name, lingerConfig) - lintResults, err := f(lingerConfig) + lintResults, err := fn(lingerConfig, linters.Agent{}, *event) if err != nil { log.Errorf("failed to run linter: %v", err) return err } - log.Infof("%s found total %d files with lint errors on repo %v", name, len(lintResults), repo) + //TODO: move到linters包中 + log.Infof("found total %d files with lint errors on repo %v", len(lintResults), repo) comments, err := buildPullRequestCommentBody(name, lintResults, pullRequestAffectedFiles) if err != nil { log.Errorf("failed to build pull request comment body: %v", err) return err } + log.Infof("%s found valid %d comments related to this PR %d (%s) \n", name, len(comments), num, org+"/"+repo) - totalComments = append(totalComments, comments...) + if err := s.PostPullReviewCommentsWithRetry(ctx, org, repo, num, comments); err != nil { + log.Errorf("failed to post comments: %v", err) + return err + } + log.Infof("commented on PR %d (%s) successfully\n", num, org+"/"+repo) + } - if err := s.PostCommentsWithRetry(ctx, org, repo, num, totalComments); err != nil { - log.Errorf("failed to post comments: %v", err) - return err + for name, fn := range linters.TotalCommentHandlers() { + var lingerConfig config.Linter + if v, ok := customLinterConfigs[name]; ok { + lingerConfig = v + } + + if lingerConfig.WorkDir != "" { + // 更新完整的工作目录 + lingerConfig.WorkDir = r.Directory() + "/" + lingerConfig.WorkDir + } + + agent := linters.NewAgent(s.gc, s.gitClientFactory, s.config) + if err := fn(lingerConfig, agent, *event); err != nil { + log.Errorf("failed to run linter: %v", err) + return err + } + log.Infof("commented on PR %d (%s) successfully\n", num, org+"/"+repo) } - log.Info("posted comments success\n") + return nil }