Skip to content

Commit

Permalink
Merge pull request #9 from Chia-Network/add-cmds
Browse files Browse the repository at this point in the history
Add cmds
  • Loading branch information
pmaslana authored May 14, 2024
2 parents d7e4094 + 59c21f2 commit c338f1e
Show file tree
Hide file tree
Showing 11 changed files with 406 additions and 69 deletions.
50 changes: 50 additions & 0 deletions cmd/notifyPendingCI.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package cmd

import (
"log"
"time"

"github.com/google/go-github/v60/github"
"github.com/spf13/cobra"
"github.com/spf13/viper"

"github.com/chia-network/github-bot/internal/config"
github2 "github.com/chia-network/github-bot/internal/github"
)

var notifyPendingCICmd = &cobra.Command{
Use: "notify-pendingci",
Short: "Sends a Keybase message to a channel, alerting that a community PR is ready for CI to run",
Run: func(cmd *cobra.Command, args []string) {
cfg, err := config.LoadConfig(viper.GetString("config"))
if err != nil {
log.Fatalf("error loading config: %s\n", err.Error())
}
client := github.NewClient(nil).WithAuthToken(cfg.GithubToken)

loop := viper.GetBool("loop")
loopDuration := viper.GetDuration("loop-time")
var listPendingPRs []string
for {
log.Println("Checking for community PRs that are waiting for CI to run")
listPendingPRs, err = github2.CheckForPendingCI(client, cfg.InternalTeam, cfg.CheckStalePending)
if err != nil {
log.Printf("The following error occurred while obtaining a list of pending PRs: %s", err)
time.Sleep(loopDuration)
continue
}
log.Printf("Pending PRs ready for CI: %v\n", listPendingPRs)

if !loop {
break
}

log.Printf("Waiting %s for next iteration\n", loopDuration.String())
time.Sleep(loopDuration)
}
},
}

func init() {
rootCmd.AddCommand(notifyPendingCICmd)
}
49 changes: 49 additions & 0 deletions cmd/notifyStale.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package cmd

import (
"log"
"time"

"github.com/google/go-github/v60/github"
"github.com/spf13/cobra"
"github.com/spf13/viper"

"github.com/chia-network/github-bot/internal/config"
github2 "github.com/chia-network/github-bot/internal/github"
)

var notifyStaleCmd = &cobra.Command{
Use: "notify-stale",
Short: "Sends a Keybase message to a channel, alerting that a community PR has not been updated in 7 days",
Run: func(cmd *cobra.Command, args []string) {
cfg, err := config.LoadConfig(viper.GetString("config"))
if err != nil {
log.Fatalf("error loading config: %s\n", err.Error())
}
client := github.NewClient(nil).WithAuthToken(cfg.GithubToken)

loop := viper.GetBool("loop")
loopDuration := viper.GetDuration("loop-time")
var listPendingPRs []string
for {
log.Println("Checking for community PRs that have no update in the last 7 days")
_, err = github2.CheckStalePRs(client, cfg.InternalTeam, cfg.CheckStalePending)
if err != nil {
log.Printf("The following error occurred while obtaining a list of stale PRs: %s", err)
time.Sleep(loopDuration)
continue
}
log.Printf("Stale PRs: %v\n", listPendingPRs)
if !loop {
break
}

log.Printf("Waiting %s for next iteration\n", loopDuration.String())
time.Sleep(loopDuration)
}
},
}

func init() {
rootCmd.AddCommand(notifyStaleCmd)
}
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
Expand All @@ -28,5 +29,6 @@ require (
golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f // indirect
golang.org/x/sys v0.19.0 // indirect
golang.org/x/text v0.14.0 // indirect
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
)
8 changes: 6 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,16 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
Expand Down Expand Up @@ -71,8 +75,8 @@ golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Expand Down
12 changes: 9 additions & 3 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ package config

// Config defines the config for all aspects of the bot
type Config struct {
GithubToken string `yaml:"github_token"`
InternalTeam string `yaml:"internal_team"`
LabelConfig `yaml:",inline"`
GithubToken string `yaml:"github_token"`
InternalTeam string `yaml:"internal_team"`
LabelConfig `yaml:",inline"`
CheckStalePending `yaml:",inline"`
}

// LabelConfig is the configuration options specific to labeling PRs
Expand All @@ -21,3 +22,8 @@ type CheckRepo struct {
Name string `yaml:"name"`
MinimumNumber int `yaml:"minimum_number"`
}

// CheckStalePending are config settings when checking a repo
type CheckStalePending struct {
CheckStalePending []CheckRepo `yaml:"check_stale_pending_repos"`
}
128 changes: 128 additions & 0 deletions internal/github/checkPendingCI.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package github

import (
"context"
"fmt"
"log"
"strconv"
"strings"
"time"

"github.com/google/go-github/v60/github" // Ensure your go-github library version matches

"github.com/chia-network/github-bot/internal/config"
)

// CheckForPendingCI returns a list of PR URLs that are ready for CI to run but haven't started yet.
func CheckForPendingCI(githubClient *github.Client, internalTeam string, cfg config.CheckStalePending) ([]string, error) {
teamMembers, _ := GetTeamMemberList(githubClient, internalTeam)
var pendingPRs []string

for _, fullRepo := range cfg.CheckStalePending {
log.Println("Checking repository:", fullRepo.Name)
parts := strings.Split(fullRepo.Name, "/")
if len(parts) != 2 {
log.Printf("invalid repository name - must contain owner and repository: %s", fullRepo.Name)
continue
}
owner, repo := parts[0], parts[1]

// Fetch community PRs using the FindCommunityPRs function
communityPRs, err := FindCommunityPRs(cfg.CheckStalePending, teamMembers, githubClient)
if err != nil {
return nil, err
}

for _, pr := range communityPRs {
// Dynamic cutoff time based on the last commit to the PR
lastCommitTime, err := getLastCommitTime(githubClient, owner, repo, pr.GetNumber())
if err != nil {
log.Printf("Error retrieving last commit time for PR #%d in %s/%s: %v", pr.GetNumber(), owner, repo, err)
continue
}
cutoffTime := lastCommitTime.Add(2 * time.Hour) // 2 hours after the last commit

if time.Now().Before(cutoffTime) {
log.Printf("Skipping PR #%d from %s/%s repo as it's still within the 2-hour window from the last commit.", pr.GetNumber(), owner, repo)
continue
}

hasCIRuns, err := checkCIStatus(githubClient, owner, repo, pr.GetNumber())
if err != nil {
log.Printf("Error checking CI status for PR #%d in %s/%s: %v", pr.GetNumber(), owner, repo, err)
continue
}

teamMemberActivity, err := checkTeamMemberActivity(githubClient, owner, repo, pr.GetNumber(), teamMembers, lastCommitTime)
if err != nil {
log.Printf("Error checking team member activity for PR #%d in %s/%s: %v", pr.GetNumber(), owner, repo, err)
continue // or handle the error as needed
}
if !hasCIRuns || !teamMemberActivity {
log.Printf("PR #%d in %s/%s by %s is ready for CI since %v but no CI actions have started yet, or it requires re-approval.", pr.GetNumber(), owner, repo, pr.User.GetLogin(), pr.CreatedAt)
pendingPRs = append(pendingPRs, pr.GetHTMLURL())
}
}
}
return pendingPRs, nil
}

func getLastCommitTime(client *github.Client, owner, repo string, prNumber int) (time.Time, error) {
commits, _, err := client.PullRequests.ListCommits(context.Background(), owner, repo, prNumber, nil)
if err != nil {
return time.Time{}, err // Properly handle API errors
}
if len(commits) == 0 {
return time.Time{}, fmt.Errorf("no commits found for PR #%d", prNumber) // Handle case where no commits are found
}
// Requesting a list of commits will return the json list in descending order
lastCommit := commits[len(commits)-1]
commitDate := lastCommit.GetCommit().GetAuthor().GetDate() // commitDate is of type Timestamp

// Since GetDate() returns a Timestamp (not *Timestamp), use the address to call GetTime()
commitTime := commitDate.GetTime() // Correctly accessing GetTime(), which returns *time.Time

if commitTime == nil {
return time.Time{}, fmt.Errorf("commit time is nil for PR #%d", prNumber)
}
return *commitTime, nil // Safely dereference *time.Time to get time.Time
}

func checkCIStatus(client *github.Client, owner, repo string, prNumber int) (bool, error) {
checks, _, err := client.Checks.ListCheckRunsForRef(context.Background(), owner, repo, strconv.Itoa(prNumber), &github.ListCheckRunsOptions{})
if err != nil {
return false, err
}
return checks.GetTotal() > 0, nil
}

func checkTeamMemberActivity(client *github.Client, owner, repo string, prNumber int, teamMembers map[string]bool, lastCommitTime time.Time) (bool, error) {
comments, _, err := client.Issues.ListComments(context.Background(), owner, repo, prNumber, nil)
if err != nil {
return false, fmt.Errorf("failed to fetch comments: %w", err)
}

for _, comment := range comments {
if _, ok := teamMembers[comment.User.GetLogin()]; ok && comment.CreatedAt.After(lastCommitTime) {
// Check if the comment is after the last commit
return true, nil // Active and relevant participation
}
}

reviews, _, err := client.PullRequests.ListReviews(context.Background(), owner, repo, prNumber, nil)
if err != nil {
return false, fmt.Errorf("failed to fetch reviews: %w", err)
}

for _, review := range reviews {
if _, ok := teamMembers[review.User.GetLogin()]; ok && review.SubmittedAt.After(lastCommitTime) {
switch review.GetState() {
case "DISMISSED", "CHANGES_REQUESTED", "COMMENTED":
// Check if the review is after the last commit and is in one of the specified states
return true, nil
}
}
}

return false, nil // No recent relevant activity from team members
}
69 changes: 69 additions & 0 deletions internal/github/checkStalePRs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package github

import (
"context"
"fmt"
"log"
"time"

"github.com/google/go-github/v60/github"

"github.com/chia-network/github-bot/internal/config"
)

// CheckStalePRs will return a list of PR URLs that have not been updated in the last 7 days by internal team members.
func CheckStalePRs(githubClient *github.Client, internalTeam string, cfg config.CheckStalePending) ([]string, error) {
var stalePRUrls []string
cutoffDate := time.Now().Add(7 * 24 * time.Hour) // 7 days ago
teamMembers, err := GetTeamMemberList(githubClient, internalTeam)
if err != nil {
return nil, err
}
communityPRs, err := FindCommunityPRs(cfg.CheckStalePending, teamMembers, githubClient)
if err != nil {
return nil, err
}

for _, pr := range communityPRs {
repoName := pr.GetBase().GetRepo().GetFullName() // Get the full name of the repository
stale, err := isStale(githubClient, pr, teamMembers, cutoffDate) // Handle both returned values
if err != nil {
log.Printf("Error checking if PR in repo %s is stale: %v", repoName, err)
continue // Skip this PR or handle the error appropriately
}
if stale {
stalePRUrls = append(stalePRUrls, pr.GetHTMLURL()) // Append if PR is confirmed stale
}
}
return stalePRUrls, nil
}

// Checks if a PR is stale based on the last update from team members
func isStale(githubClient *github.Client, pr *github.PullRequest, teamMembers map[string]bool, cutoffDate time.Time) (bool, error) {
listOptions := &github.ListOptions{PerPage: 100}
for {
// Create a context for each request
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) // 30 seconds timeout for each request

events, resp, err := githubClient.Issues.ListIssueTimeline(ctx, pr.Base.Repo.Owner.GetLogin(), pr.Base.Repo.GetName(), pr.GetNumber(), listOptions)
if err != nil {
cancel() // Explicitly cancel the context when an error occurs
return false, fmt.Errorf("failed to get timeline for PR #%d: %w", pr.GetNumber(), err)
}
for _, event := range events {
if event.Event == nil || event.Actor == nil || event.Actor.Login == nil {
continue
}
if (*event.Event == "commented" || *event.Event == "reviewed") && teamMembers[*event.Actor.Login] && event.CreatedAt.After(cutoffDate) {
cancel() // Clean up the context when returning within the loop
return false, nil
}
}
cancel() // Clean up the context at the end of the loop iteration
if resp.NextPage == 0 {
break
}
listOptions.Page = resp.NextPage
}
return true, nil
}
Loading

0 comments on commit c338f1e

Please sign in to comment.