Skip to content

Commit c338f1e

Browse files
authored
Merge pull request #9 from Chia-Network/add-cmds
Add cmds
2 parents d7e4094 + 59c21f2 commit c338f1e

File tree

11 files changed

+406
-69
lines changed

11 files changed

+406
-69
lines changed

cmd/notifyPendingCI.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package cmd
2+
3+
import (
4+
"log"
5+
"time"
6+
7+
"github.com/google/go-github/v60/github"
8+
"github.com/spf13/cobra"
9+
"github.com/spf13/viper"
10+
11+
"github.com/chia-network/github-bot/internal/config"
12+
github2 "github.com/chia-network/github-bot/internal/github"
13+
)
14+
15+
var notifyPendingCICmd = &cobra.Command{
16+
Use: "notify-pendingci",
17+
Short: "Sends a Keybase message to a channel, alerting that a community PR is ready for CI to run",
18+
Run: func(cmd *cobra.Command, args []string) {
19+
cfg, err := config.LoadConfig(viper.GetString("config"))
20+
if err != nil {
21+
log.Fatalf("error loading config: %s\n", err.Error())
22+
}
23+
client := github.NewClient(nil).WithAuthToken(cfg.GithubToken)
24+
25+
loop := viper.GetBool("loop")
26+
loopDuration := viper.GetDuration("loop-time")
27+
var listPendingPRs []string
28+
for {
29+
log.Println("Checking for community PRs that are waiting for CI to run")
30+
listPendingPRs, err = github2.CheckForPendingCI(client, cfg.InternalTeam, cfg.CheckStalePending)
31+
if err != nil {
32+
log.Printf("The following error occurred while obtaining a list of pending PRs: %s", err)
33+
time.Sleep(loopDuration)
34+
continue
35+
}
36+
log.Printf("Pending PRs ready for CI: %v\n", listPendingPRs)
37+
38+
if !loop {
39+
break
40+
}
41+
42+
log.Printf("Waiting %s for next iteration\n", loopDuration.String())
43+
time.Sleep(loopDuration)
44+
}
45+
},
46+
}
47+
48+
func init() {
49+
rootCmd.AddCommand(notifyPendingCICmd)
50+
}

cmd/notifyStale.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package cmd
2+
3+
import (
4+
"log"
5+
"time"
6+
7+
"github.com/google/go-github/v60/github"
8+
"github.com/spf13/cobra"
9+
"github.com/spf13/viper"
10+
11+
"github.com/chia-network/github-bot/internal/config"
12+
github2 "github.com/chia-network/github-bot/internal/github"
13+
)
14+
15+
var notifyStaleCmd = &cobra.Command{
16+
Use: "notify-stale",
17+
Short: "Sends a Keybase message to a channel, alerting that a community PR has not been updated in 7 days",
18+
Run: func(cmd *cobra.Command, args []string) {
19+
cfg, err := config.LoadConfig(viper.GetString("config"))
20+
if err != nil {
21+
log.Fatalf("error loading config: %s\n", err.Error())
22+
}
23+
client := github.NewClient(nil).WithAuthToken(cfg.GithubToken)
24+
25+
loop := viper.GetBool("loop")
26+
loopDuration := viper.GetDuration("loop-time")
27+
var listPendingPRs []string
28+
for {
29+
log.Println("Checking for community PRs that have no update in the last 7 days")
30+
_, err = github2.CheckStalePRs(client, cfg.InternalTeam, cfg.CheckStalePending)
31+
if err != nil {
32+
log.Printf("The following error occurred while obtaining a list of stale PRs: %s", err)
33+
time.Sleep(loopDuration)
34+
continue
35+
}
36+
log.Printf("Stale PRs: %v\n", listPendingPRs)
37+
if !loop {
38+
break
39+
}
40+
41+
log.Printf("Waiting %s for next iteration\n", loopDuration.String())
42+
time.Sleep(loopDuration)
43+
}
44+
},
45+
}
46+
47+
func init() {
48+
rootCmd.AddCommand(notifyStaleCmd)
49+
}

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ require (
1616
github.com/inconshreveable/mousetrap v1.1.0 // indirect
1717
github.com/magiconair/properties v1.8.7 // indirect
1818
github.com/mitchellh/mapstructure v1.5.0 // indirect
19+
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
1920
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
2021
github.com/sagikazarmark/locafero v0.4.0 // indirect
2122
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
@@ -28,5 +29,6 @@ require (
2829
golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f // indirect
2930
golang.org/x/sys v0.19.0 // indirect
3031
golang.org/x/text v0.14.0 // indirect
32+
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
3133
gopkg.in/ini.v1 v1.67.0 // indirect
3234
)

go.sum

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,16 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
2020
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
2121
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
2222
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
23+
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
24+
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
2325
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
2426
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
2527
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
2628
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
2729
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
2830
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
31+
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
32+
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
2933
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
3034
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
3135
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -71,8 +75,8 @@ golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
7175
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
7276
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
7377
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
74-
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
75-
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
78+
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
79+
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
7680
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
7781
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
7882
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

internal/config/config.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ package config
22

33
// Config defines the config for all aspects of the bot
44
type Config struct {
5-
GithubToken string `yaml:"github_token"`
6-
InternalTeam string `yaml:"internal_team"`
7-
LabelConfig `yaml:",inline"`
5+
GithubToken string `yaml:"github_token"`
6+
InternalTeam string `yaml:"internal_team"`
7+
LabelConfig `yaml:",inline"`
8+
CheckStalePending `yaml:",inline"`
89
}
910

1011
// LabelConfig is the configuration options specific to labeling PRs
@@ -21,3 +22,8 @@ type CheckRepo struct {
2122
Name string `yaml:"name"`
2223
MinimumNumber int `yaml:"minimum_number"`
2324
}
25+
26+
// CheckStalePending are config settings when checking a repo
27+
type CheckStalePending struct {
28+
CheckStalePending []CheckRepo `yaml:"check_stale_pending_repos"`
29+
}

internal/github/checkPendingCI.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log"
7+
"strconv"
8+
"strings"
9+
"time"
10+
11+
"github.com/google/go-github/v60/github" // Ensure your go-github library version matches
12+
13+
"github.com/chia-network/github-bot/internal/config"
14+
)
15+
16+
// CheckForPendingCI returns a list of PR URLs that are ready for CI to run but haven't started yet.
17+
func CheckForPendingCI(githubClient *github.Client, internalTeam string, cfg config.CheckStalePending) ([]string, error) {
18+
teamMembers, _ := GetTeamMemberList(githubClient, internalTeam)
19+
var pendingPRs []string
20+
21+
for _, fullRepo := range cfg.CheckStalePending {
22+
log.Println("Checking repository:", fullRepo.Name)
23+
parts := strings.Split(fullRepo.Name, "/")
24+
if len(parts) != 2 {
25+
log.Printf("invalid repository name - must contain owner and repository: %s", fullRepo.Name)
26+
continue
27+
}
28+
owner, repo := parts[0], parts[1]
29+
30+
// Fetch community PRs using the FindCommunityPRs function
31+
communityPRs, err := FindCommunityPRs(cfg.CheckStalePending, teamMembers, githubClient)
32+
if err != nil {
33+
return nil, err
34+
}
35+
36+
for _, pr := range communityPRs {
37+
// Dynamic cutoff time based on the last commit to the PR
38+
lastCommitTime, err := getLastCommitTime(githubClient, owner, repo, pr.GetNumber())
39+
if err != nil {
40+
log.Printf("Error retrieving last commit time for PR #%d in %s/%s: %v", pr.GetNumber(), owner, repo, err)
41+
continue
42+
}
43+
cutoffTime := lastCommitTime.Add(2 * time.Hour) // 2 hours after the last commit
44+
45+
if time.Now().Before(cutoffTime) {
46+
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)
47+
continue
48+
}
49+
50+
hasCIRuns, err := checkCIStatus(githubClient, owner, repo, pr.GetNumber())
51+
if err != nil {
52+
log.Printf("Error checking CI status for PR #%d in %s/%s: %v", pr.GetNumber(), owner, repo, err)
53+
continue
54+
}
55+
56+
teamMemberActivity, err := checkTeamMemberActivity(githubClient, owner, repo, pr.GetNumber(), teamMembers, lastCommitTime)
57+
if err != nil {
58+
log.Printf("Error checking team member activity for PR #%d in %s/%s: %v", pr.GetNumber(), owner, repo, err)
59+
continue // or handle the error as needed
60+
}
61+
if !hasCIRuns || !teamMemberActivity {
62+
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)
63+
pendingPRs = append(pendingPRs, pr.GetHTMLURL())
64+
}
65+
}
66+
}
67+
return pendingPRs, nil
68+
}
69+
70+
func getLastCommitTime(client *github.Client, owner, repo string, prNumber int) (time.Time, error) {
71+
commits, _, err := client.PullRequests.ListCommits(context.Background(), owner, repo, prNumber, nil)
72+
if err != nil {
73+
return time.Time{}, err // Properly handle API errors
74+
}
75+
if len(commits) == 0 {
76+
return time.Time{}, fmt.Errorf("no commits found for PR #%d", prNumber) // Handle case where no commits are found
77+
}
78+
// Requesting a list of commits will return the json list in descending order
79+
lastCommit := commits[len(commits)-1]
80+
commitDate := lastCommit.GetCommit().GetAuthor().GetDate() // commitDate is of type Timestamp
81+
82+
// Since GetDate() returns a Timestamp (not *Timestamp), use the address to call GetTime()
83+
commitTime := commitDate.GetTime() // Correctly accessing GetTime(), which returns *time.Time
84+
85+
if commitTime == nil {
86+
return time.Time{}, fmt.Errorf("commit time is nil for PR #%d", prNumber)
87+
}
88+
return *commitTime, nil // Safely dereference *time.Time to get time.Time
89+
}
90+
91+
func checkCIStatus(client *github.Client, owner, repo string, prNumber int) (bool, error) {
92+
checks, _, err := client.Checks.ListCheckRunsForRef(context.Background(), owner, repo, strconv.Itoa(prNumber), &github.ListCheckRunsOptions{})
93+
if err != nil {
94+
return false, err
95+
}
96+
return checks.GetTotal() > 0, nil
97+
}
98+
99+
func checkTeamMemberActivity(client *github.Client, owner, repo string, prNumber int, teamMembers map[string]bool, lastCommitTime time.Time) (bool, error) {
100+
comments, _, err := client.Issues.ListComments(context.Background(), owner, repo, prNumber, nil)
101+
if err != nil {
102+
return false, fmt.Errorf("failed to fetch comments: %w", err)
103+
}
104+
105+
for _, comment := range comments {
106+
if _, ok := teamMembers[comment.User.GetLogin()]; ok && comment.CreatedAt.After(lastCommitTime) {
107+
// Check if the comment is after the last commit
108+
return true, nil // Active and relevant participation
109+
}
110+
}
111+
112+
reviews, _, err := client.PullRequests.ListReviews(context.Background(), owner, repo, prNumber, nil)
113+
if err != nil {
114+
return false, fmt.Errorf("failed to fetch reviews: %w", err)
115+
}
116+
117+
for _, review := range reviews {
118+
if _, ok := teamMembers[review.User.GetLogin()]; ok && review.SubmittedAt.After(lastCommitTime) {
119+
switch review.GetState() {
120+
case "DISMISSED", "CHANGES_REQUESTED", "COMMENTED":
121+
// Check if the review is after the last commit and is in one of the specified states
122+
return true, nil
123+
}
124+
}
125+
}
126+
127+
return false, nil // No recent relevant activity from team members
128+
}

internal/github/checkStalePRs.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log"
7+
"time"
8+
9+
"github.com/google/go-github/v60/github"
10+
11+
"github.com/chia-network/github-bot/internal/config"
12+
)
13+
14+
// CheckStalePRs will return a list of PR URLs that have not been updated in the last 7 days by internal team members.
15+
func CheckStalePRs(githubClient *github.Client, internalTeam string, cfg config.CheckStalePending) ([]string, error) {
16+
var stalePRUrls []string
17+
cutoffDate := time.Now().Add(7 * 24 * time.Hour) // 7 days ago
18+
teamMembers, err := GetTeamMemberList(githubClient, internalTeam)
19+
if err != nil {
20+
return nil, err
21+
}
22+
communityPRs, err := FindCommunityPRs(cfg.CheckStalePending, teamMembers, githubClient)
23+
if err != nil {
24+
return nil, err
25+
}
26+
27+
for _, pr := range communityPRs {
28+
repoName := pr.GetBase().GetRepo().GetFullName() // Get the full name of the repository
29+
stale, err := isStale(githubClient, pr, teamMembers, cutoffDate) // Handle both returned values
30+
if err != nil {
31+
log.Printf("Error checking if PR in repo %s is stale: %v", repoName, err)
32+
continue // Skip this PR or handle the error appropriately
33+
}
34+
if stale {
35+
stalePRUrls = append(stalePRUrls, pr.GetHTMLURL()) // Append if PR is confirmed stale
36+
}
37+
}
38+
return stalePRUrls, nil
39+
}
40+
41+
// Checks if a PR is stale based on the last update from team members
42+
func isStale(githubClient *github.Client, pr *github.PullRequest, teamMembers map[string]bool, cutoffDate time.Time) (bool, error) {
43+
listOptions := &github.ListOptions{PerPage: 100}
44+
for {
45+
// Create a context for each request
46+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) // 30 seconds timeout for each request
47+
48+
events, resp, err := githubClient.Issues.ListIssueTimeline(ctx, pr.Base.Repo.Owner.GetLogin(), pr.Base.Repo.GetName(), pr.GetNumber(), listOptions)
49+
if err != nil {
50+
cancel() // Explicitly cancel the context when an error occurs
51+
return false, fmt.Errorf("failed to get timeline for PR #%d: %w", pr.GetNumber(), err)
52+
}
53+
for _, event := range events {
54+
if event.Event == nil || event.Actor == nil || event.Actor.Login == nil {
55+
continue
56+
}
57+
if (*event.Event == "commented" || *event.Event == "reviewed") && teamMembers[*event.Actor.Login] && event.CreatedAt.After(cutoffDate) {
58+
cancel() // Clean up the context when returning within the loop
59+
return false, nil
60+
}
61+
}
62+
cancel() // Clean up the context at the end of the loop iteration
63+
if resp.NextPage == 0 {
64+
break
65+
}
66+
listOptions.Page = resp.NextPage
67+
}
68+
return true, nil
69+
}

0 commit comments

Comments
 (0)