diff --git a/controllers/pr_check.go b/controllers/pr_check.go new file mode 100644 index 0000000..2a6de1d --- /dev/null +++ b/controllers/pr_check.go @@ -0,0 +1,82 @@ +// Copyright 2021 The casbin Authors. All Rights Reserved. +// +// 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 controllers + +import ( + "strconv" + + "github.com/casbin/casbin-oa/object" +) + +// GetPrChecks retrieves all PR check records for a specific PR. +func (c *ApiController) GetPrChecks() { + org := c.Input().Get("org") + repo := c.Input().Get("repo") + prNumberStr := c.Input().Get("prNumber") + + if org == "" || repo == "" || prNumberStr == "" { + c.Data["json"] = map[string]interface{}{ + "status": "error", + "msg": "Missing required parameters", + } + c.ServeJSON() + return + } + + prNumber, err := strconv.Atoi(prNumberStr) + if err != nil { + c.Data["json"] = map[string]interface{}{ + "status": "error", + "msg": "Invalid PR number", + } + c.ServeJSON() + return + } + + prChecks := object.GetPrChecksByPR(org, repo, prNumber) + c.Data["json"] = prChecks + c.ServeJSON() +} + +// GetPrCheck retrieves a specific PR check record. +func (c *ApiController) GetPrCheck() { + org := c.Input().Get("org") + repo := c.Input().Get("repo") + prNumberStr := c.Input().Get("prNumber") + checkName := c.Input().Get("checkName") + + if org == "" || repo == "" || prNumberStr == "" || checkName == "" { + c.Data["json"] = map[string]interface{}{ + "status": "error", + "msg": "Missing required parameters", + } + c.ServeJSON() + return + } + + prNumber, err := strconv.Atoi(prNumberStr) + if err != nil { + c.Data["json"] = map[string]interface{}{ + "status": "error", + "msg": "Invalid PR number", + } + c.ServeJSON() + return + } + + prCheck := object.GetPrCheck(org, repo, prNumber, checkName) + c.Data["json"] = prCheck + c.ServeJSON() +} diff --git a/controllers/webhook.go b/controllers/webhook.go index aba248c..b89ecd5 100644 --- a/controllers/webhook.go +++ b/controllers/webhook.go @@ -17,6 +17,7 @@ package controllers import ( "encoding/json" "fmt" + "time" "github.com/casbin/casbin-oa/object" "github.com/casbin/casbin-oa/util" @@ -26,9 +27,26 @@ import ( func (c *ApiController) WebhookOpen() { var issueEvent github.IssuesEvent var pullRequestEvent github.PullRequestEvent + var checkRunEvent github.CheckRunEvent + + // Try to parse as different event types. + eventType := c.Ctx.Request.Header.Get("X-GitHub-Event") + + result := false + switch eventType { + case "check_run": + err := json.Unmarshal(c.Ctx.Input.RequestBody, &checkRunEvent) + if err == nil && checkRunEvent.CheckRun != nil { + result = HandleCheckRunEvent(checkRunEvent) + c.Data["json"] = result + c.ServeJSON() + return + } + } + + // Legacy handling for issues and pull requests. json.Unmarshal(c.Ctx.Input.RequestBody, &pullRequestEvent) - var result bool if pullRequestEvent.PullRequest != nil { result = PullRequestOpen(pullRequestEvent) } else { @@ -106,6 +124,89 @@ func PullRequestOpen(pullRequestEvent github.PullRequestEvent) bool { go util.Comment(commentStr, owner, repo, pullRequestEvent.GetNumber()) } } + // Request automatic code review from copilot for human-created PRs. + go util.RequestCopilotReview(owner, repo, pullRequestEvent.GetNumber()) + } + return true +} + +// HandleCheckRunEvent handles check_run webhook events. +func HandleCheckRunEvent(event github.CheckRunEvent) bool { + if event.GetAction() != "completed" { + return false + } + + checkRun := event.GetCheckRun() + if checkRun == nil { + return false + } + + // Only process failed checks. + if checkRun.GetConclusion() != "failure" && checkRun.GetConclusion() != "cancelled" { + return false + } + + // Get PR information. + prs := checkRun.PullRequests + if len(prs) == 0 { + return false + } + + owner, repo := util.GetOwnerAndNameFromId(event.Repo.GetFullName()) + //issueWebhook := object.GetIssueIfExist(owner, repo) + //if issueWebhook == nil { + // return false + //} + + for _, pr := range prs { + prNumber := pr.GetNumber() + checkName := checkRun.GetName() + + // Check if this is a linter check. + if !util.IsLinterCheck(checkName) { + continue + } + + // Check if we should attempt to fix (max 3 attempts). + if !object.ShouldAttemptFix(owner, repo, prNumber, checkName) { + continue + } + + // Get or create PR check record. + prCheck := object.GetPrCheck(owner, repo, prNumber, checkName) + if prCheck == nil { + // Create new record. + prCheck = &object.PrCheck{ + Org: owner, + Repo: repo, + PrNumber: prNumber, + CheckRunId: checkRun.GetID(), + CheckName: checkName, + Status: checkRun.GetStatus(), + Conclusion: checkRun.GetConclusion(), + FailureReason: util.GetCheckFailureDetails(checkRun), + FixAttempts: 0, + LastAttemptAt: time.Now(), + IsFixed: false, + CreatedAt: time.Now(), + } + object.AddPrCheck(prCheck) + } else { + // Update existing record. + prCheck.CheckRunId = checkRun.GetID() + prCheck.Status = checkRun.GetStatus() + prCheck.Conclusion = checkRun.GetConclusion() + prCheck.FailureReason = util.GetCheckFailureDetails(checkRun) + object.UpdatePrCheck(prCheck.Id, prCheck) + } + + // Increment fix attempts and get updated record. + prCheck = object.IncrementFixAttempts(owner, repo, prNumber, checkName) + if prCheck != nil { + // Comment on PR with failure details and tag copilot. + go util.CommentOnPRWithCopilotTag(owner, repo, prNumber, prCheck.FailureReason, prCheck.FixAttempts) + } } + return true } diff --git a/object/adapter.go b/object/adapter.go index 69c21e5..c7c4609 100644 --- a/object/adapter.go +++ b/object/adapter.go @@ -132,4 +132,9 @@ func (a *Adapter) createTable() { if err != nil { panic(err) } + + err = a.Engine.Sync2(new(PrCheck)) + if err != nil { + panic(err) + } } diff --git a/object/pr_check.go b/object/pr_check.go new file mode 100644 index 0000000..a55e20d --- /dev/null +++ b/object/pr_check.go @@ -0,0 +1,102 @@ +// Copyright 2021 The casbin Authors. All Rights Reserved. +// +// 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 object + +import ( + "time" + + "github.com/casbin/casbin-oa/util" +) + +type PrCheck struct { + Id int `xorm:"int notnull pk autoincr" json:"id"` + Org string `xorm:"varchar(100)" json:"org"` + Repo string `xorm:"varchar(100)" json:"repo"` + PrNumber int `xorm:"int" json:"prNumber"` + CheckRunId int64 `xorm:"bigint" json:"checkRunId"` + CheckName string `xorm:"varchar(200)" json:"checkName"` + Status string `xorm:"varchar(50)" json:"status"` + Conclusion string `xorm:"varchar(50)" json:"conclusion"` + FailureReason string `xorm:"text" json:"failureReason"` + FixAttempts int `xorm:"int" json:"fixAttempts"` + LastAttemptAt time.Time `xorm:"datetime" json:"lastAttemptAt"` + IsFixed bool `xorm:"bool" json:"isFixed"` + CreatedAt time.Time `xorm:"datetime" json:"createdAt"` +} + +// GetPrCheck retrieves a PR check by org, repo, PR number and check name. +func GetPrCheck(org string, repo string, prNumber int, checkName string) *PrCheck { + prCheck := PrCheck{} + existed, err := adapter.Engine.Where("org = ? and repo = ? and pr_number = ? and check_name = ?", + org, repo, prNumber, checkName).Desc("id").Get(&prCheck) + if err != nil { + panic(err) + } + if existed { + return &prCheck + } + return nil +} + +// GetPrChecksByPR retrieves all checks for a specific PR. +func GetPrChecksByPR(org string, repo string, prNumber int) []*PrCheck { + prChecks := []*PrCheck{} + err := adapter.Engine.Where("org = ? and repo = ? and pr_number = ?", + org, repo, prNumber).Find(&prChecks) + if err != nil { + panic(err) + } + return prChecks +} + +// AddPrCheck adds a new PR check record. +func AddPrCheck(prCheck *PrCheck) bool { + affected, err := adapter.Engine.Insert(prCheck) + if err != nil { + panic(err) + } + return affected != 0 +} + +// UpdatePrCheck updates an existing PR check record. +func UpdatePrCheck(id int, prCheck *PrCheck) bool { + _, err := adapter.Engine.ID(id).AllCols().Update(prCheck) + if err != nil { + panic(err) + } + return true +} + +// IncrementFixAttempts increments the fix attempts counter and returns the updated record. +func IncrementFixAttempts(org string, repo string, prNumber int, checkName string) *PrCheck { + prCheck := GetPrCheck(org, repo, prNumber, checkName) + if prCheck == nil { + return nil + } + prCheck.FixAttempts++ + prCheck.LastAttemptAt = time.Now() + UpdatePrCheck(prCheck.Id, prCheck) + return prCheck +} + +// ShouldAttemptFix checks if we should attempt to fix this check (max attempts configurable). +func ShouldAttemptFix(org string, repo string, prNumber int, checkName string) bool { + prCheck := GetPrCheck(org, repo, prNumber, checkName) + // First time, should attempt. + if prCheck == nil { + return true + } + return prCheck.FixAttempts < util.MaxFixAttempts && !prCheck.IsFixed +} diff --git a/routers/router.go b/routers/router.go index 4f4f3f0..6cef223 100644 --- a/routers/router.go +++ b/routers/router.go @@ -79,6 +79,9 @@ func initAPI() { beego.Router("/api/webhook", &controllers.ApiController{}, "Post:WebhookOpen") beego.Router("/api/is-mainland-ip", &controllers.ApiController{}, "GET:IsMainlandIp") + beego.Router("/api/get-pr-checks", &controllers.ApiController{}, "GET:GetPrChecks") + beego.Router("/api/get-pr-check", &controllers.ApiController{}, "GET:GetPrCheck") + beego.Router("/api/get-machines", &controllers.ApiController{}, "GET:GetMachines") beego.Router("/api/get-machine", &controllers.ApiController{}, "GET:GetMachine") beego.Router("/api/update-machine", &controllers.ApiController{}, "POST:UpdateMachine") diff --git a/util/check_api.go b/util/check_api.go new file mode 100644 index 0000000..07ece85 --- /dev/null +++ b/util/check_api.go @@ -0,0 +1,129 @@ +// Copyright 2021 The casbin Authors. All Rights Reserved. +// +// 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 util + +import ( + "context" + "fmt" + "strings" + + "github.com/google/go-github/v74/github" +) + +const ( + // MaxCheckFailureTextLength is the maximum length of failure text to include in comments. + MaxCheckFailureTextLength = 500 + // CopilotUsername is the GitHub username of the copilot bot. + CopilotUsername = "Copilot" + // MaxFixAttempts is the maximum number of fix attempts for a failing check. + MaxFixAttempts = 3 +) + +// GetPRCheckRuns retrieves all check runs for a specific commit SHA. +func GetPRCheckRuns(owner string, repo string, ref string) ([]*github.CheckRun, error) { + client := GetClient() + opts := &github.ListCheckRunsOptions{ + ListOptions: github.ListOptions{PerPage: 100}, + } + + result, _, err := client.Checks.ListCheckRunsForRef(context.Background(), owner, repo, ref, opts) + if err != nil { + return nil, err + } + + return result.CheckRuns, nil +} + +// IsLinterCheck determines if a check is a linter check based on its name. +func IsLinterCheck(checkName string) bool { + linterKeywords := []string{ + "lint", "linter", "eslint", "golangci", "golint", "go-lint", + "prettier", "style", "format", "rubocop", "pylint", + "flake8", "clippy", "checkstyle", "pmd", "spotbugs", + } + + checkNameLower := strings.ToLower(checkName) + for _, keyword := range linterKeywords { + if strings.Contains(checkNameLower, keyword) { + return true + } + } + return false +} + +// GetCheckFailureDetails extracts failure details from a check run. +func GetCheckFailureDetails(checkRun *github.CheckRun) string { + if checkRun == nil { + return "No check run details available" + } + + var details strings.Builder + details.WriteString(fmt.Sprintf("Check: %s\n", checkRun.GetName())) + details.WriteString(fmt.Sprintf("Status: %s\n", checkRun.GetStatus())) + details.WriteString(fmt.Sprintf("Conclusion: %s\n", checkRun.GetConclusion())) + + if checkRun.Output != nil { + if checkRun.Output.Title != nil { + details.WriteString(fmt.Sprintf("Title: %s\n", checkRun.Output.GetTitle())) + } + if checkRun.Output.Summary != nil { + details.WriteString(fmt.Sprintf("Summary: %s\n", checkRun.Output.GetSummary())) + } + if checkRun.Output.Text != nil { + text := checkRun.Output.GetText() + // Limit the text to avoid too long messages. + if len(text) > MaxCheckFailureTextLength { + text = text[:MaxCheckFailureTextLength] + "...(truncated)" + } + details.WriteString(fmt.Sprintf("Details: %s\n", text)) + } + } + + return details.String() +} + +// CommentOnPRWithCopilotTag comments on a PR and tags the copilot for fixing. +func CommentOnPRWithCopilotTag(owner string, repo string, prNumber int, failureDetails string, attemptNumber int) error { + commentBody := fmt.Sprintf(`@%s The CI check has failed. Please help fix the following issue: + +**Attempt**: %d/%d + +**Failure Details**: +%s + +Please investigate and fix this issue.`, CopilotUsername, attemptNumber, MaxFixAttempts, failureDetails) + + success := Comment(commentBody, owner, repo, prNumber) + if !success { + return fmt.Errorf("failed to post comment on PR #%d", prNumber) + } + return nil +} + +// RequestCopilotReview requests a review from copilot on a PR. +func RequestCopilotReview(owner string, repo string, prNumber int) error { + // First, comment to notify about the review request. + commentBody := fmt.Sprintf(`@%s Please review this PR.`, CopilotUsername) + success := Comment(commentBody, owner, repo, prNumber) + if !success { + return fmt.Errorf("failed to post review request comment on PR #%d", prNumber) + } + + // Try to request reviewer (may fail if copilot is not a collaborator). + // We ignore errors here as the comment is the primary notification. + _ = RequestReviewers(owner, repo, prNumber, []string{CopilotUsername}) + + return nil +}