From 8debbdf4fa3362d78f6b640613fe869cf01b5b9f Mon Sep 17 00:00:00 2001 From: Jonathan Seth Mainguy Date: Sun, 24 Mar 2024 07:55:55 -0400 Subject: [PATCH] feat: support autodiscovery Added support for autodiscovery of repos from a list of organizations and users. Changed configuration from env variables to a flat yaml file. Updated readme to support new changes. Changed how the output looks, added emojis. --- .github/workflows/push.yml | 4 +- .github/workflows/release-please.yaml | 2 +- README.md | 62 ++++++++----- config.go | 129 ++++++++++++++++++++++++++ go.mod | 9 +- go.sum | 15 +-- helper.go | 54 ++++++----- helper_test.go | 71 -------------- main.go | 63 ++++++++++++- prs.go | 2 + prs_test.go | 6 ++ repos.go | 116 +++++++++++++++++++++++ structs.go | 35 ++++++- 13 files changed, 429 insertions(+), 139 deletions(-) create mode 100644 config.go create mode 100644 repos.go diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 2ed4a19..da8e426 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -17,7 +17,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.19' + go-version: '1.21' - name: Get Build Tools run: | @@ -46,7 +46,7 @@ jobs: - name: install go uses: actions/setup-go@v5 with: - go-version: '1.19' + go-version: '1.21' - name: git checkout uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 diff --git a/.github/workflows/release-please.yaml b/.github/workflows/release-please.yaml index dae0f19..510edf5 100644 --- a/.github/workflows/release-please.yaml +++ b/.github/workflows/release-please.yaml @@ -27,7 +27,7 @@ jobs: - run: git fetch --force --tags - uses: actions/setup-go@v5 with: - go-version: "1.20" + go-version: "1.21" - name: Run GoReleaser uses: goreleaser/goreleaser-action@v5 with: diff --git a/README.md b/README.md index 361d74f..0c6e941 100644 --- a/README.md +++ b/README.md @@ -3,25 +3,30 @@ Application to check Github for Pull Requests, that are not Drafts, in repos the user cares about. ## Usage -The program takes no arguments, and is configured via ENV variables. +The program is configured via a YAML file located at `$HOME/.config/ghreport/config.yaml`. -* ghreportToken: Should be set to a Github API Token with access to the repos you are checking +* token: Should be set to a Github API Token with access to the repos you are checking * Set permissions for token to repo - full control of private repositories, enable SSO if your repos require it * ![Github Personal Access Token Permissions](https://github.com/Jmainguy/ghreport/blob/main/docs/permissions.png?raw=true) -* subscribedRepos: Should be set to a space delimmited list of Github Repos you want to check +* One of subscribedRepos, autoDiscover.organizations, or autoDiscover.users must be set if you wish to have any results. You can set all three if you wish. +* topic limits what is returned from organizations and users to just that topic, this is an optional field. -Example configuration in ~/.bashrc -``` -export ghreportToken=e0e9eac4e84446df6f3db180d07bfb222e91234 -export subscribedRepos="Jmainguy/ghreport Jmainguy/bible Jmainguy/ghReview Jmainguy/bak" -``` +Here's an example configuration: -Additionally if you have a long list of repos to watch you can use this format when setting the environment variable: -``` -export subscribedRepos="\ -somesite/aebot \ -somesite/ansible-okta-aws-auth \ -somesite/blahblah" +```yaml +autoDiscover: + organizations: + - name: your_organization_name + topic: topic_to_watch + users: + - name: your_username + topic: topic_to_watch +subscribedRepos: + - Jmainguy/ghreport + - Jmainguy/bible + - Jmainguy/ghReview + - Jmainguy/bak +token: e0e9eac4e84446df6f3db180d07bfb222e91234 ``` Running the progam @@ -32,14 +37,27 @@ ghreport Sample output ``` -[jmainguy@jmainguy-7410 ghreport]$ ghreport -https://github.com/Jmainguy/stockop/pull/9: createdAt 2023-04-07 01:10:22 +0000 UTC -``` - -## Linux / macOS homebrew install - -```/bin/bash -brew install jmainguy/tap/ghreport +jmainguy@fedora:~/Github/ghreport$ ./ghreport +https://github.com/Jmainguy/statuscode/pull/32 + author: renovate + Age: 3 days + reviewDecision: ❌ + mergeable ✅ +https://github.com/Jmainguy/statuscode/pull/33 + author: renovate + Age: 3 days + reviewDecision: ✅ + mergeable ✅ +https://github.com/Standouthost/Multicraft/pull/9 + author: TheWebGamer + Age: 3321 days + reviewDecision: ✅ + mergeable ❌ +https://github.com/Standouthost/Multicraft/pull/28 + author: ungarscool1 + Age: 2700 days + reviewDecision: 😅 + mergeable ✅ ``` ## Releases diff --git a/config.go b/config.go new file mode 100644 index 0000000..c325eb8 --- /dev/null +++ b/config.go @@ -0,0 +1,129 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v2" +) + +// Config : configuration for what orgs / users / repos and token to use +type Config struct { + AutoDiscover struct { + Organizations []struct { + Name string `yaml:"name"` + Topic string `yaml:"topic"` + } `yaml:"organizations"` + Users []struct { + Name string `yaml:"name"` + Topic string `yaml:"topic"` + } `yaml:"users"` + } `yaml:"autoDiscover"` + SubscribedRepos []string `yaml:"subscribedRepos"` + Token string `yaml:"token"` +} + +func ensureConfigDirExists(configDir string) error { + // Check if config directory exists + _, err := os.Stat(configDir) + if os.IsNotExist(err) { + // Config directory doesn't exist, create it + err := os.MkdirAll(configDir, 0700) // 0700 means only the owner can read, write, and execute + if err != nil { + return fmt.Errorf("failed to create config directory: %v", err) + } + } else if err != nil { + // Some error occurred while checking the existence of the directory + return fmt.Errorf("failed to check config directory: %v", err) + } + + return nil +} + +func ensureConfigFileExists(configFilePath string) error { + _, err := os.Stat(configFilePath) + if os.IsNotExist(err) { + // Config file doesn't exist, create it with default values + defaultConfig := Config{ + SubscribedRepos: []string{}, + Token: "", + } + configBytes, err := yaml.Marshal(defaultConfig) + if err != nil { + return fmt.Errorf("failed to marshal default config: %v", err) + } + err = os.WriteFile(configFilePath, configBytes, 0600) // 0600 means only the owner can read and write + if err != nil { + return fmt.Errorf("failed to create config file: %v", err) + } + } else if err != nil { + // Some error occurred while checking the existence of the file + return fmt.Errorf("failed to check config file: %v", err) + } + + return nil +} + +func getConfigFilePath() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + configDir := filepath.Join(homeDir, ".config/ghreport") + if err := ensureConfigDirExists(configDir); err != nil { + return "", err + } + configFilePath := filepath.Join(configDir, "config.yaml") + if err := ensureConfigFileExists(configFilePath); err != nil { + return "", err + } + return configFilePath, nil +} + +func readConfigFile(configFilePath string) (*Config, error) { + file, err := os.Open(configFilePath) + if err != nil { + return nil, err + } + defer file.Close() + + var config Config + decoder := yaml.NewDecoder(file) + if err := decoder.Decode(&config); err != nil { + return nil, err + } + + return &config, nil +} + +func getConfig() (*Config, error) { + // Check if config file exists + configFilePath, err := getConfigFilePath() + if err == nil { + if _, err := os.Stat(configFilePath); err == nil { + // Config file exists, read values from there + config, err := readConfigFile(configFilePath) + if err == nil { + return config, nil + } + } + } + + // If config file doesn't exist or there was an error reading it, fallback to environment variables + var config Config + envSubscribedRepos := os.Getenv("subscribedRepos") + if envSubscribedRepos == "" { + return &config, fmt.Errorf("env variable subscribedRepos is not defined") + } + config.SubscribedRepos = strings.Split(envSubscribedRepos, " ") + envToken := os.Getenv("ghreportToken") + if envToken == "" { + return &config, fmt.Errorf("env variable ghreportToken is not defined") + } + config.Token = envToken + + return &config, nil + +} diff --git a/go.mod b/go.mod index 56bc339..676633e 100644 --- a/go.mod +++ b/go.mod @@ -1,20 +1,21 @@ module github.com/jmainguy/ghreport -go 1.19 +go 1.21 require ( github.com/shurcooL/githubv4 v0.0.0-20240120211514-18a1ae0e79dc github.com/stretchr/testify v1.8.4 - golang.org/x/oauth2 v0.17.0 + golang.org/x/oauth2 v0.18.0 + gopkg.in/yaml.v2 v2.4.0 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/golang/protobuf v1.5.3 // indirect + github.com/golang/protobuf v1.5.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect github.com/stretchr/objx v0.5.0 // indirect google.golang.org/appengine v1.6.8 // indirect - google.golang.org/protobuf v1.32.0 // indirect + google.golang.org/protobuf v1.33.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index fe1d499..985a162 100644 --- a/go.sum +++ b/go.sum @@ -3,10 +3,11 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/shurcooL/githubv4 v0.0.0-20240120211514-18a1ae0e79dc h1:vH0NQbIDk+mJLvBliNGfcQgUmhlniWBDXC79oRxfZA0= @@ -28,8 +29,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/oauth2 v0.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ= -golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA= +golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI= +golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -52,10 +53,12 @@ google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAs google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= -google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/helper.go b/helper.go index 465d767..1ae0df5 100644 --- a/helper.go +++ b/helper.go @@ -3,29 +3,13 @@ package main import ( "context" "fmt" - "os" "strings" + "time" "github.com/shurcooL/githubv4" "golang.org/x/oauth2" ) -func getEnvVariables() ([]string, string, error) { - envSubscribedRepos := os.Getenv("subscribedRepos") - if envSubscribedRepos == "" { - return nil, "", fmt.Errorf("env variable subscribedRepos is not defined") - } - - subscribedRepos := strings.Split(envSubscribedRepos, " ") - - envToken := os.Getenv("ghreportToken") - if envToken == "" { - return nil, "", fmt.Errorf("env variable ghreportToken is not defined") - } - - return subscribedRepos, envToken, nil -} - func createGithubClient(envToken string) *githubv4.Client { src := oauth2.StaticTokenSource( &oauth2.Token{AccessToken: envToken}, @@ -49,14 +33,34 @@ func getOwnerAndRepo(ownerRepo string) (string, string, error) { return ownerAndRepo[0], ownerAndRepo[1], nil } -func compareSlices(a, b []string) bool { - if len(a) != len(b) { - return false +func formatTimeElapsed(duration time.Duration) string { + if duration.Hours() >= 24 { + return fmt.Sprintf("%.0f days", duration.Hours()/24) + } else if duration.Hours() >= 1 { + return fmt.Sprintf("%.0f hours", duration.Hours()) + } else { + return fmt.Sprintf("%.0f minutes", duration.Minutes()) } - for i, v := range a { - if v != b[i] { - return false - } +} + +func getReviewDecisionEmoji(decision string) string { + switch decision { + case "APPROVED": + return "✅" // Green checkmark emoji + case "CHANGES_REQUESTED": + return "❌" // Red x emoji + case "REVIEW_REQUIRED": + return "🔍" // Magnifying glass emoji or any other appropriate emoji + default: + return "😅" // Emoji indicating everything is okay, but no review was requested + } +} + +func getMergeableEmoji(mergeable string) string { + if mergeable == "MERGEABLE" { + return "✅" // Green checkmark emoji + } else if mergeable == "CONFLICTING" { + return "❌" // Red x emoji } - return true + return "" } diff --git a/helper_test.go b/helper_test.go index 7f1e466..b33888b 100644 --- a/helper_test.go +++ b/helper_test.go @@ -2,80 +2,9 @@ package main import ( "fmt" - "os" "testing" ) -func TestGetEnvVariablesSuccess(t *testing.T) { - os.Setenv("subscribedRepos", "owner1/repo1 owner2/repo2") - os.Setenv("ghreportToken", "testToken") - defer os.Unsetenv("subscribedRepos") - defer os.Unsetenv("ghreportToken") - - expectedSubscribedRepos := []string{"owner1/repo1", "owner2/repo2"} - expectedToken := "testToken" - - subscribedRepos, token, err := getEnvVariables() - - if err != nil { - t.Errorf("Expected no error, but got %v", err) - } - - if !compareSlices(subscribedRepos, expectedSubscribedRepos) { - t.Errorf("Expected subscribedRepos %v, but got %v", expectedSubscribedRepos, subscribedRepos) - } - - if token != expectedToken { - t.Errorf("Expected token %s, but got %s", expectedToken, token) - } -} - -func TestGetEnvVariablesMissingSubscribedRepos(t *testing.T) { - os.Setenv("ghreportToken", "testToken") - defer os.Unsetenv("ghreportToken") - - expectedErrorMessage := "env variable subscribedRepos is not defined" - - subscribedRepos, token, err := getEnvVariables() - - if subscribedRepos != nil { - t.Errorf("Expected subscribedRepos to be nil, but got %v", subscribedRepos) - } - - if token != "" { - t.Errorf("Expected token to be empty string, but got %s", token) - } - - if err == nil { - t.Error("Expected error, but got nil") - } else if err.Error() != expectedErrorMessage { - t.Errorf("Expected error message '%s', but got '%v'", expectedErrorMessage, err) - } -} - -func TestGetEnvVariablesMissingToken(t *testing.T) { - os.Setenv("subscribedRepos", "owner1/repo1 owner2/repo2") - defer os.Unsetenv("subscribedRepos") - - expectedErrorMessage := "env variable ghreportToken is not defined" - - subscribedRepos, token, err := getEnvVariables() - - if subscribedRepos != nil { - t.Errorf("Expected subscribedRepos to be nil, but got %v", subscribedRepos) - } - - if token != "" { - t.Errorf("Expected token to be empty string, but got %s", token) - } - - if err == nil { - t.Error("Expected error, but got nil") - } else if err.Error() != expectedErrorMessage { - t.Errorf("Expected error message '%s', but got '%v'", expectedErrorMessage, err) - } -} - func TestGetOwnerAndRepo(t *testing.T) { testCases := []struct { input string diff --git a/main.go b/main.go index a59bd6b..e33efdc 100644 --- a/main.go +++ b/main.go @@ -3,21 +3,66 @@ package main import ( "fmt" "os" + "strings" "text/tabwriter" + "time" ) func main() { w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - subscribedRepos, envToken, err := getEnvVariables() + + config, err := getConfig() if err != nil { fmt.Println(err) os.Exit(1) } - client := createGithubClient(envToken) + client := createGithubClient(config.Token) + + // If autoDiscover is configured and there are organizations specified, get repos from those organizations + if len(config.AutoDiscover.Organizations) > 0 { + for _, org := range config.AutoDiscover.Organizations { + repos, err := getReposFromOrganization(client, org.Name, org.Topic) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + // Append discovered repos to subscribedRepos list + var repoNames []string + for _, repo := range repos { + repoNames = append(repoNames, string(repo.NameWithOwner)) + } + config.SubscribedRepos = append(config.SubscribedRepos, repoNames...) + } + } + + if len(config.AutoDiscover.Users) > 0 { + for _, user := range config.AutoDiscover.Users { + repos, err := getReposFromUser(client, user.Name, user.Topic) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + // Append discovered repos to subscribedRepos list + var repoNames []string + for _, repo := range repos { + repoNames = append(repoNames, string(repo.NameWithOwner)) + } + config.SubscribedRepos = append(config.SubscribedRepos, repoNames...) + } + } + + // Ensure list of repos is unique + repoMap := make(map[string]bool) + for _, repoString := range config.SubscribedRepos { + repoMap[strings.ToLower(repoString)] = true + } // List of repos to watch - for _, ownerRepo := range subscribedRepos { + + for ownerRepo := range repoMap { owner, repo, err := getOwnerAndRepo(ownerRepo) if err != nil { fmt.Println(err) @@ -31,8 +76,16 @@ func main() { } for _, pr := range pullRequests { - //fmt.Printf("%s: createdAt %s\n", pr.URL, pr.CreatedAt) - fmt.Fprintf(w, "%s:\tauthor: %s\tcreatedAt %s\n", pr.URL, pr.Owner, pr.CreatedAt) + timeElapsed := time.Since(pr.CreatedAt.Time) + timeLabel := formatTimeElapsed(timeElapsed) + + reviewDecisionEmoji := getReviewDecisionEmoji(string(pr.ReviewDecision)) + + mergeableEmoji := getMergeableEmoji(string(pr.Mergeable)) + + fmt.Fprintf(w, "%s\n\tauthor: %s\n\tAge: %s \n\treviewDecision: %s\n\tmergeable %s\n", pr.URL, pr.Owner, timeLabel, reviewDecisionEmoji, mergeableEmoji) + + //fmt.Fprintf(w, "%s:\tauthor: %s\tcreatedAt %s\treviewDecison %s\tmergeable %s\n", pr.URL, pr.Owner, pr.CreatedAt, pr.ReviewDecision, pr.Mergeable) } } diff --git a/prs.go b/prs.go index b7db089..d840d70 100644 --- a/prs.go +++ b/prs.go @@ -66,6 +66,8 @@ func extractPRDataFromEdges(edges []PullRequestEdge) []PR { pr.URL = edge.Node.URL.String() pr.CreatedAt = edge.Node.CreatedAt pr.Owner = edge.Node.Author.Login + pr.Mergeable = edge.Node.Mergeable + pr.ReviewDecision = edge.Node.ReviewDecision PRS = append(PRS, pr) } } diff --git a/prs_test.go b/prs_test.go index 4ddfe06..60fe9fe 100644 --- a/prs_test.go +++ b/prs_test.go @@ -36,6 +36,8 @@ func TestExtractPRDataFromEdges(t *testing.T) { Author struct { Login githubv4.String } + ReviewDecision githubv4.String + Mergeable githubv4.String }{ URL: uri1, CreatedAt: githubv4.DateTime{Time: time.Now().UTC().Truncate(time.Hour)}, @@ -55,6 +57,8 @@ func TestExtractPRDataFromEdges(t *testing.T) { Author struct { Login githubv4.String } + ReviewDecision githubv4.String + Mergeable githubv4.String }{ URL: uri2, CreatedAt: githubv4.DateTime{Time: time.Now().UTC().Truncate(time.Hour)}, @@ -122,6 +126,8 @@ func TestGetPrFromRepo(t *testing.T) { Author struct { Login githubv4.String } + ReviewDecision githubv4.String + Mergeable githubv4.String }{ URL: uri, CreatedAt: githubv4.DateTime{}, diff --git a/repos.go b/repos.go new file mode 100644 index 0000000..754ccec --- /dev/null +++ b/repos.go @@ -0,0 +1,116 @@ +package main + +import ( + "context" + "time" + + "github.com/shurcooL/githubv4" +) + +func getReposFromOrganization(client Client, org string, topic string) ([]Repo, error) { + var repoQuery struct { + Organization struct { + Repositories struct { + PageInfo struct { + HasNextPage githubv4.Boolean + EndCursor githubv4.String + } + Edges []RepositoryEdge + } `graphql:"repositories(first: 100, after: $cursor, orderBy: {field: CREATED_AT, direction: DESC}, isArchived: false)"` + } `graphql:"organization(login: $org)"` + } + + var repos []Repo + + variables := map[string]interface{}{ + "org": githubv4.String(org), + "cursor": (*githubv4.String)(nil), // Null after argument to get first page. + } + + for { + err := client.Query(context.Background(), &repoQuery, variables) + if err != nil { + return repos, err + } + + repos = append(repos, extractRepoDataFromEdges(repoQuery.Organization.Repositories.Edges, topic)...) + + if !repoQuery.Organization.Repositories.PageInfo.HasNextPage { + break + } else { + variables["cursor"] = githubv4.NewString(repoQuery.Organization.Repositories.PageInfo.EndCursor) + } + // Sleep for at least a second. https://docs.github.com/en/rest/guides/best-practices-for-integrators + time.Sleep(2 * time.Second) + } + + return repos, nil +} + +func getReposFromUser(client Client, user string, topic string) ([]Repo, error) { + var repoQuery struct { + User struct { + Repositories struct { + PageInfo struct { + HasNextPage githubv4.Boolean + EndCursor githubv4.String + } + Edges []RepositoryEdge + } `graphql:"repositories(first: 100, after: $cursor, orderBy: {field: CREATED_AT, direction: DESC}, isArchived: false)"` + } `graphql:"user(login: $user)"` + } + + var repos []Repo + + variables := map[string]interface{}{ + "user": githubv4.String(user), + "cursor": (*githubv4.String)(nil), // Null after argument to get first page. + } + + for { + err := client.Query(context.Background(), &repoQuery, variables) + if err != nil { + return repos, err + } + + repos = append(repos, extractRepoDataFromEdges(repoQuery.User.Repositories.Edges, topic)...) + + if !repoQuery.User.Repositories.PageInfo.HasNextPage { + break + } else { + variables["cursor"] = githubv4.NewString(repoQuery.User.Repositories.PageInfo.EndCursor) + } + // Sleep for at least a second. https://docs.github.com/en/rest/guides/best-practices-for-integrators + time.Sleep(2 * time.Second) + } + + return repos, nil +} + +func extractRepoDataFromEdges(edges []RepositoryEdge, topic string) []Repo { + var repos []Repo + + for _, edge := range edges { + repo := edge.Node + var r Repo + r.NameWithOwner = repo.NameWithOwner + if topic != "" { + if containsTopic(repo.RepositoryTopics.Nodes, topic) { + repos = append(repos, r) + } + } else { + repos = append(repos, r) + } + } + + return repos +} + +func containsTopic(nodes []RepositoryTopicNode, topic string) bool { + for _, node := range nodes { + if node.Topic.Name == topic { + return true + } + } + return false +} diff --git a/structs.go b/structs.go index 35295e0..5465c62 100644 --- a/structs.go +++ b/structs.go @@ -8,9 +8,16 @@ import ( // PR : A pullRequest type PR struct { - CreatedAt githubv4.DateTime `json:"createdAt"` - URL string `json:"url"` - Owner githubv4.String `json:"owner"` + CreatedAt githubv4.DateTime `json:"createdAt"` + URL string `json:"url"` + Owner githubv4.String `json:"owner"` + ReviewDecision githubv4.String `json:"reviewDecision"` + Mergeable githubv4.String `json:"mergeable"` +} + +// Repo : Struct for repo providing NameWithOwner +type Repo struct { + NameWithOwner githubv4.String `json:"nameWithOwner"` } // PullRequestEdge : A PullRequestEdge @@ -22,6 +29,28 @@ type PullRequestEdge struct { Author struct { Login githubv4.String } + ReviewDecision githubv4.String + Mergeable githubv4.String + } +} + +// RepositoryEdge : Graphql edge for repository +type RepositoryEdge struct { + Node RepositoryNode +} + +// RepositoryNode : Graphql node for repository +type RepositoryNode struct { + NameWithOwner githubv4.String + RepositoryTopics struct { + Nodes []RepositoryTopicNode + } `graphql:"repositoryTopics(first: 100)"` +} + +// RepositoryTopicNode : Struct for the repository topic +type RepositoryTopicNode struct { + Topic struct { + Name string } }