diff --git a/generators/cmd/contributions/README.md b/generators/cmd/contributions/README.md new file mode 100644 index 00000000..c3a54648 --- /dev/null +++ b/generators/cmd/contributions/README.md @@ -0,0 +1,284 @@ +# contributions + +A cli tool that fetches GitHub contributions of a user either per organization or per repository. + +This tool was built with the focus on helping guide decisions for community administrators about user inactivity. Concrete applications are: +* generating evidence that a specific user has been active in a repository or organization +* finding inactive community members for a GitHub organization +* finding inactive reviewers in a GitHub repository + +There are three flags that are complementary to each other when using the tool: +* `--username`: the username to check for contributions. This is the most basic use case - checking the contributions of a single user +* `--orgs-file-path`: file path to the orgs.yaml file, which defines the organization structure (see [peribolos] from Kubernetes). This checks the contributions of all organization members defined in target organization. +* `--owners-file-path`: file path to an [OWNERS] file that we want to check reviewers and approvers from, possibly accompanied by `--owners-aliases-file-path` to make given aliases resolvable + +The flags `--org` and `--repo` then target what contributions to check, where omitting `--repo` will generate a report over contributions for the org vs. contributions in a given repository. + +# `--username` + +The username check aims to quickly check contributions of a specific github user. + +## per organization + +Example: +```bash +$ go run ./generators/cmd/contributions \ + --github-token /path/to/oauth \ + --months 12 \ + --username dhiller +{"level":"info","msg":"creating report for user \"dhiller\"","time":"2024-12-05T15:01:07+01:00"} +activity log: + user: dhiller + org: kubevirt + since: 2023-12-05 15:01:07 + + issues + created: 59 + commented: 74 + pull requests: + reviewed: 512 + created: 182 + commented: 768 + commits: 180 +{"level":"debug","msg":"user activity log: \"/tmp/user-activity-dhiller-kubevirt-3984018412.yaml\"","time":"2024-12-05T15:01:13+01:00"} +$ # showing user contribution details +$ head -20 /tmp/user-activity-dhiller-kubevirt-3984018412.yaml +issuesCreated: + issueCount: 59 + nodes: + - issue: + number: 13436 + title: 'arm lane: clustered failure' + URL: https://github.com/kubevirt/kubevirt/issues/13436 + repository: + name: kubevirt + author: + login: dhiller + createdAt: 2024-12-04T10:11:01Z + - issue: + number: 13435 + title: Issue of postcopy migration with the check-tests-for-flakes lane + URL: https://github.com/kubevirt/kubevirt/issues/13435 + repository: + name: kubevirt + author: + login: dhiller +... +``` + +## per repository + +Example: +```bash +$ go run ./generators/cmd/contributions \ + --github-token /path/to/oauth \ + --months 12 \ + --repo project-infra \ + --username dhiller +{"level":"info","msg":"creating report for user \"dhiller\"","time":"2024-12-05T15:05:54+01:00"} +activity log: + user: dhiller + repository: kubevirt/project-infra + since: 2023-12-05 15:05:54 + + issues + created: 17 + commented: 15 + pull requests: + reviewed: 247 + created: 124 + commented: 333 + commits: 119 +{"level":"debug","msg":"user activity log: \"/tmp/user-activity-dhiller-kubevirt_project-infra-2764291029.yaml\"","time":"2024-12-05T15:06:05+01:00"} +$ # showing user contribution details +$ head -20 /tmp/user-activity-dhiller-kubevirt_project-infra-2764291029.yaml +issuesCreated: + issueCount: 17 + nodes: + - issue: + number: 3786 + title: 'flakefinder: live filtering in report - exclude lane' + URL: https://github.com/kubevirt/project-infra/issues/3786 + repository: + name: project-infra + author: + login: dhiller + createdAt: 2024-11-27T10:39:57Z + - issue: + number: 3768 + title: 'prowjob: remove outdated job config for kubevirt versions 0.3x.xx' + URL: https://github.com/kubevirt/project-infra/issues/3768 + repository: + name: project-infra + author: + login: dhiller +``` +# `--orgs-file-path` + +The orgs-file-path check is targeted to produce machine consumable output for later consumption by other processes. Therefore the flag `--report-output-file-path` is used to write the report output file and consume the `.report.inactiveUsers` yaml element. + +```bash +$ go run ./generators/cmd/contributions \ + --github-token /path/to/oauth \ + --orgs-file-path ../project-infra/github/ci/prow-deploy/kustom/base/configs/current/orgs/orgs.yaml \ + --report-output-file-path /tmp/contributions-report.yaml +{"level":"debug","msg":"active user: 0xFelix","time":"2024-12-05T15:36:59+01:00"} +{"level":"debug","msg":"skipping user aburdenthehand (reason: invisibleContributions)","time":"2024-12-05T15:36:59+01:00"} +{"level":"debug","msg":"active user: acardace","time":"2024-12-05T15:37:06+01:00"} +{"level":"debug","msg":"active user: Acedus","time":"2024-12-05T15:37:12+01:00"} +{"level":"debug","msg":"active user: aerosouund","time":"2024-12-05T15:37:17+01:00"} +{"level":"debug","msg":"active user: aglitke","time":"2024-12-05T15:37:21+01:00"} +{"level":"debug","msg":"active user: akalenyu","time":"2024-12-05T15:37:27+01:00"} +... +{"level":"info","msg":"inactive user: jobbler","time":"2024-12-05T15:41:02+01:00"} +{"level":"debug","msg":"user activity log: \"/tmp/user-activity-jobbler-kubevirt-2735337715.yaml\"","time":"2024-12-05T15:41:02+01:00"} +... +{"level":"debug","msg":"active user: nunnatsa","time":"2024-12-05T15:42:21+01:00"} +{"level":"debug","msg":"skipping user openshift-ci-robot (reason: bots)","time":"2024-12-05T15:42:21+01:00"} +{"level":"debug","msg":"skipping user openshift-merge-robot (reason: bots)","time":"2024-12-05T15:42:21+01:00"} +... +{"level":"debug","msg":"active user: zhlhahaha","time":"2024-12-05T15:44:32+01:00"} +inactive users: +- gouyang +- jobbler +- VirrageS +$ # show full contribution report (directed by --report-output-file-path , see command above) +$ cat /tmp/contributions-report.yaml +reportOptions: + org: kubevirt + repo: "" + username: "" + githubTokenPath: /home/dhiller/.tokens/github/kubevirt-bot/oauth + months: 6 + orgsConfigFilePath: ../project-infra/github/ci/prow-deploy/kustom/base/configs/current/orgs/orgs.yaml + ownersFilePath: "" + reportAll: false + reportOutputFilePath: /tmp/contributions-report.yaml + ownersAliasesFilePath: "" +reportConfig: # see default-config.yaml + skipInactive: + kubevirt: + - name: bots + github: + - kubevirt-bot + - kubevirt-commenter-bot + - ... + - name: orgAdmins + ... + ... +result: + activeUsers: + - 0xFelix + - acardace + - ... + inactiveUsers: + - gouyang + - jobbler + - VirrageS + skippedUsers: + bots: + - kubevirt-commenter-bot + - openshift-ci-robot + - openshift-merge-robot + invisibleContributions: + - aburdenthehand + - jberkus +log: + - | + activity log: + user: gouyang + org: kubevirt + since: 2024-06-05 15:40:21 + + issues + created: 0 + commented: 0 + pull requests: + reviewed: 0 + created: 0 + commented: 0 + commits: 0 + - activity log written to "/tmp/user-activity-gouyang-kubevirt-*.yaml" + ... +``` + +# `--owners-file-path` + +The owners-file-path is targeted to check a specific [OWNERS] file and produce a machine-consumable output by setting the flag `--report-output-file-path` and consuming the `.report.inactiveUsers` yaml element. + +## automatic OWNERS alias resolution + +For [OWNERS] files using aliases and having an adjacent `OWNERS_ALIASES` file (most likely in the root directory of the repository) those aliases will automatically get resolved into the list items of the alias list. If the [OWNERS] files are referring to an `OWNERS_ALIASES` file in a different location, the flag `--owners-aliases-file-path` needs to get set with the path to that file. + +```bash +$ go run ./generators/cmd/contributions \ + --github-token /path/to/oauth \ + --repo project-infra \ + --owners-file-path ../project-infra/OWNERS \ + --report-output-file-path /tmp/contributions-report.yaml +{"level":"debug","msg":"active user: aglitke","time":"2024-12-05T16:02:52+01:00"} +... +{"level":"debug","msg":"active user: xpivarc","time":"2024-12-05T16:03:56+01:00"} +inactive users: +[] +$ # show the report +$ cat / +reportOptions: + org: kubevirt + repo: project-infra + username: "" + githubTokenPath: /home/dhiller/.tokens/github/kubevirt-bot/oauth + months: 6 + orgsConfigFilePath: ../project-infra/github/ci/prow-deploy/kustom/base/configs/current/orgs/orgs.yaml + ownersFilePath: ../project-infra/OWNERS + reportAll: false + reportOutputFilePath: /tmp/contributions-report.yaml + ownersAliasesFilePath: "" +reportConfig: + skipInactive: + kubevirt: + - name: bots + github: + - kubevirt-bot + - kubevirt-commenter-bot + - ... +... +result: + activeUsers: + - brianmcarey + - davidvossel + - dhiller + - enp0s3 + - phoracek + - tiraboschi + - vladikr + - xpivarc + inactiveUsers: + - aglitke + - rmohr + skippedUsers: {} +log: + - | + activity log: + user: aglitke + repository: kubevirt/project-infra + since: 2024-06-05 16:12:38 + + issues + created: 0 + commented: 0 + pull requests: + reviewed: 0 + created: 0 + commented: 0 + commits: 0 + - activity log written to "/tmp/user-activity-aglitke-kubevirt_project-infra-*.yaml" + ... +``` + +# automated query retry + +Sometimes there might appear error messages indicating that a query failed, likely (at the time of writing) with a 502 or 504 http error. In general every query will get retried a number of times, after which the tool will give up and display the causing error. + +[peribolos]: https://docs.prow.k8s.io/docs/components/cli-tools/peribolos/ +[OWNERS]: https://www.kubernetes.dev/docs/guide/owners/ \ No newline at end of file diff --git a/generators/cmd/contributions/default-config.yaml b/generators/cmd/contributions/default-config.yaml new file mode 100644 index 00000000..16e36294 --- /dev/null +++ b/generators/cmd/contributions/default-config.yaml @@ -0,0 +1,38 @@ +# skipInactive holds configurations for which github users +# need not get checked, either per org or repos +skipInactive: + + # kubevirt org + kubevirt: + + - name: "bots" + github: + - kubevirt-bot + - kubevirt-commenter-bot + - kubevirt-snyk + - openshift-ci-robot + - openshift-merge-robot + - thelinuxfoundation + + - name: "orgAdmins" + # skip KubeVirt org admins (security measure so that we don't lose GitHub org access) + github: + - brianmcarey + - davidvossel + - dhiller + - fabiand + - rmohr + + - name: "invisibleContributions" + # skip users with invisible contributions (i.e. OSPO, KubeVirt community manager etc) + github: + - aburdenthehand + - jberkus + + # community repo + kubevirt/community: + - name: "communityAdmin" + # skip community manager, they should never get removed + github: + - aburdenthehand + diff --git a/generators/cmd/contributions/main.go b/generators/cmd/contributions/main.go new file mode 100644 index 00000000..a3a74866 --- /dev/null +++ b/generators/cmd/contributions/main.go @@ -0,0 +1,233 @@ +/* + * This file is part of the KubeVirt project + * + * 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 the KubeVirt Authors. + * + */ + +package main + +import ( + _ "embed" + "flag" + "fmt" + log "github.com/sirupsen/logrus" + "gopkg.in/yaml.v3" + "kubevirt.io/community/pkg/contributions" + "kubevirt.io/community/pkg/orgs" + "kubevirt.io/community/pkg/owners" + "os" + "path/filepath" + "sort" + "strings" +) + +type ContributionReportOptions struct { + Org string `yaml:"org"` + Repo string `yaml:"repo"` + Username string `yaml:"username"` + GithubTokenPath string `yaml:"githubTokenPath"` + Months int `yaml:"months"` + OrgsConfigFilePath string `yaml:"orgsConfigFilePath"` + OwnersFilePath string `yaml:"ownersFilePath"` + ReportAll bool `yaml:"reportAll"` + ReportOutputFilePath string `yaml:"reportOutputFilePath"` + OwnersAliasesFilePath string `yaml:"ownersAliasesFilePath"` +} + +type SkipInactiveCheckConfig struct { + Name string `yaml:"name"` + Github []string `yaml:"github"` +} + +type ContributionReportConfig struct { + SkipInactive map[string][]SkipInactiveCheckConfig `yaml:"skipInactive"` +} + +func (c *ContributionReportConfig) ShouldSkip(org, repo, userName string) (bool, string) { + var skipInactiveKey string + if repo != "" { + skipInactiveKey = fmt.Sprintf("%s/%s", org, repo) + } else { + skipInactiveKey = org + } + configs, exists := c.SkipInactive[skipInactiveKey] + if !exists { + return false, "" + } + for _, config := range configs { + for _, github := range config.Github { + if strings.ToLower(userName) == strings.ToLower(github) { + return true, config.Name + } + } + } + return false, "" +} + +var ( + //go:embed default-config.yaml + defaultConfigContent []byte + + defaultConfig *ContributionReportConfig +) + +func (o ContributionReportOptions) validate() error { + if o.Username != "" { + log.Infof("creating report for user %q", o.Username) + } else if o.OrgsConfigFilePath == "" && o.OwnersFilePath == "" { + return fmt.Errorf("username or orgs-config-file-path or owners-file-path is required") + } + if o.GithubTokenPath == "" { + return fmt.Errorf("github token path is required") + } + return nil +} + +func (o ContributionReportOptions) makeGeneratorOptions() contributions.ContributionReportGeneratorOptions { + return contributions.ContributionReportGeneratorOptions{ + Org: o.Org, + Repo: o.Repo, + GithubTokenPath: o.GithubTokenPath, + Months: o.Months, + } +} + +func gatherContributionReportOptions() (*ContributionReportOptions, error) { + o := ContributionReportOptions{} + fs := flag.NewFlagSet(os.Args[0], flag.ExitOnError) + fs.StringVar(&o.Org, "org", "kubevirt", "org name") + fs.StringVar(&o.Repo, "repo", "", "repo name (leave empty to create an org activity report)") + fs.StringVar(&o.Username, "username", "", "github handle") + fs.IntVar(&o.Months, "months", 6, "months to look back for fetching data") + fs.StringVar(&o.GithubTokenPath, "github-token", "/etc/github/oauth", "path to github token to use") + fs.StringVar(&o.OrgsConfigFilePath, "orgs-file-path", "../project-infra/github/ci/prow-deploy/kustom/base/configs/current/orgs/orgs.yaml", "file path to the orgs.yaml file to check") + fs.StringVar(&o.OwnersFilePath, "owners-file-path", "", "file path to the OWNERS file to check") + fs.BoolVar(&o.ReportAll, "report-all", false, "whether to only report inactive users or all users") + fs.StringVar(&o.ReportOutputFilePath, "report-output-file-path", "", "file path to write the report output into") + fs.StringVar(&o.OwnersAliasesFilePath, "owners-aliases-file-path", "", "file path to resolve OWNERS file references with") + err := fs.Parse(os.Args[1:]) + return &o, err +} + +func init() { + log.SetFormatter(&log.JSONFormatter{}) + log.SetLevel(log.DebugLevel) + + err := yaml.Unmarshal(defaultConfigContent, &defaultConfig) + if err != nil { + log.Fatalf("error unmarshalling default config: %v", err) + } +} + +func main() { + contributionReportOptions, err := gatherContributionReportOptions() + if err != nil { + log.Fatalf("error parsing arguments %v: %v", os.Args[1:], err) + } + if err = contributionReportOptions.validate(); err != nil { + log.Fatalf("error validating arguments: %v", err) + } + + generator, err := contributions.NewContributionReportGenerator(contributionReportOptions.makeGeneratorOptions()) + if err != nil { + log.Fatalf("failed to create report generator: %v", err) + } + + reporter := NewDefaultReporter(contributionReportOptions, defaultConfig) + userNames := []string{contributionReportOptions.Username} + if contributionReportOptions.Username == "" { + if !contributionReportOptions.ReportAll { + reporter = NewInactiveOnlyReporter(contributionReportOptions, defaultConfig) + } + if contributionReportOptions.OwnersFilePath != "" { + ownersYAML, err := owners.ReadFile(contributionReportOptions.OwnersFilePath) + if err != nil { + log.Fatalf("invalid arguments: %v", err) + } + ownersAliasesPath := defaultOwnersAsiasesPath(contributionReportOptions) + if contributionReportOptions.OwnersAliasesFilePath != "" { + ownersAliasesPath = contributionReportOptions.OwnersAliasesFilePath + } + stat, err := os.Stat(ownersAliasesPath) + ownersAliases := &owners.OwnersAliases{} + if err == nil && !stat.IsDir() { + ownersAliases, err = owners.ReadAliasesFile(ownersAliasesPath) + if err != nil { + log.Fatalf("invalid aliases file %q: %v", ownersAliasesPath, err) + } + } + userNames = ownersYAML.AllReviewers() + userNames = append(userNames, ownersYAML.AllApprovers()...) + userNames = ownersAliases.Resolve(userNames) + userNames = uniq(userNames) + sort.Strings(userNames) + } else if contributionReportOptions.OrgsConfigFilePath != "" { + orgsYAML, err := orgs.ReadFile(contributionReportOptions.OrgsConfigFilePath) + if err != nil { + log.Fatalf("invalid arguments: %v", err) + } + userNames = orgsYAML.Orgs[contributionReportOptions.Org].Members + } + } + + for _, userName := range userNames { + if contributionReportOptions.Username == "" { + shouldSkip, reason := defaultConfig.ShouldSkip(contributionReportOptions.Org, contributionReportOptions.Repo, userName) + if shouldSkip { + log.Debugf("skipping user %s (reason: %s)", userName, reason) + reporter.Skip(userName, reason) + continue + } + } + activity, err := generator.GenerateReport(userName) + if err != nil { + log.Fatalf("failed to generate report: %v", err) + } + err = reporter.Report(activity, userName) + if err != nil { + log.Fatalf("failed to report: %v", err) + } + } + fmt.Printf(reporter.Summary()) + if contributionReportOptions.ReportOutputFilePath != "" { + reportBytes, err := yaml.Marshal(reporter.Full()) + if err != nil { + log.Fatalf("failed to write report: %v", err) + } + err = os.WriteFile(contributionReportOptions.ReportOutputFilePath, reportBytes, 0666) + if err != nil { + log.Fatalf("failed to write report: %v", err) + } + } +} + +func defaultOwnersAsiasesPath(contributionReportOptions *ContributionReportOptions) string { + return filepath.Join(filepath.Dir(contributionReportOptions.OwnersFilePath), "OWNERS_ALIASES") +} + +func uniq(elements ...[]string) []string { + uniqMap := make(map[string]struct{}) + for _, values := range elements { + for _, value := range values { + uniqMap[value] = struct{}{} + } + } + var uniqueValues []string + for uniqueValue := range uniqMap { + uniqueValues = append(uniqueValues, uniqueValue) + } + return uniqueValues +} diff --git a/generators/cmd/contributions/report.go b/generators/cmd/contributions/report.go new file mode 100644 index 00000000..fb5bd57e --- /dev/null +++ b/generators/cmd/contributions/report.go @@ -0,0 +1,137 @@ +/* + * This file is part of the KubeVirt project + * + * 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 the KubeVirt Authors. + * + */ + +package main + +import ( + "fmt" + log "github.com/sirupsen/logrus" + "gopkg.in/yaml.v3" + "kubevirt.io/community/pkg/contributions" +) + +type ReportResult struct { + ActiveUsers []string `yaml:"activeUsers"` + InactiveUsers []string `yaml:"inactiveUsers"` + SkippedUsers map[string][]string `yaml:"skippedUsers"` +} + +func (receiver *ReportResult) SkipUser(reason, userName string) { + if receiver.SkippedUsers == nil { + receiver.SkippedUsers = make(map[string][]string) + } + receiver.SkippedUsers[reason] = append(receiver.SkippedUsers[reason], userName) +} + +type Report struct { + ReportOptions *ContributionReportOptions `yaml:"reportOptions"` + ReportConfig *ContributionReportConfig `yaml:"reportConfig"` + Result *ReportResult `yaml:"result"` + Log []string `yaml:"log"` +} + +func NewReportWithConfiguration(options *ContributionReportOptions, config *ContributionReportConfig) *Report { + return &Report{ + ReportConfig: config, + ReportOptions: options, + Result: &ReportResult{}, + } +} + +type Reporter interface { + Report(r contributions.ContributionReport, userName string) error + Summary() string + Full() *Report + Skip(userName string, reason string) +} + +type DefaultReporter struct { + report *Report +} + +func NewDefaultReporter(options *ContributionReportOptions, config *ContributionReportConfig) Reporter { + d := &DefaultReporter{} + d.report = NewReportWithConfiguration(options, config) + return d +} + +func (d *DefaultReporter) Skip(userName string, reason string) { + d.report.Result.SkipUser(reason, userName) +} + +func (d *DefaultReporter) Report(r contributions.ContributionReport, userName string) error { + fmt.Printf(r.Summary()) + _, err := r.WriteToFile("/tmp", userName) + if err != nil { + return fmt.Errorf("failed to write file: %v", err) + } + return nil +} + +func (d *DefaultReporter) Summary() string { + return "" +} + +func (d *DefaultReporter) Full() *Report { + return d.report +} + +type InactiveOnlyReporter struct { + report *Report +} + +func (d *InactiveOnlyReporter) Skip(userName string, reason string) { + d.report.Result.SkipUser(reason, userName) +} + +func NewInactiveOnlyReporter(options *ContributionReportOptions, config *ContributionReportConfig) Reporter { + i := &InactiveOnlyReporter{} + i.report = NewReportWithConfiguration(options, config) + return i +} + +func (d *InactiveOnlyReporter) Report(r contributions.ContributionReport, userName string) error { + if r.HasContributions() { + log.Debugf("active user: %s", userName) + d.report.Result.ActiveUsers = append(d.report.Result.ActiveUsers, userName) + return nil + } + log.Infof("inactive user: %s", userName) + d.report.Log = append(d.report.Log, r.Summary()) + fileName, err := r.WriteToFile("/tmp", userName) + if err != nil { + return fmt.Errorf("failed to write file: %v", err) + } + d.report.Log = append(d.report.Log, fmt.Sprintf("activity log written to %q", fileName)) + d.report.Result.InactiveUsers = append(d.report.Result.InactiveUsers, userName) + return nil +} + +func (d *InactiveOnlyReporter) Summary() string { + out, err := yaml.Marshal(d.report.Result.InactiveUsers) + if err != nil { + log.Fatalf("failed to serialize: %v", err) + } + return fmt.Sprintf(`inactive users: +%s`, string(out)) +} + +func (d *InactiveOnlyReporter) Full() *Report { + return d.report +} diff --git a/go.mod b/go.mod index a6609a5f..2d93b807 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,10 @@ go 1.22 require gopkg.in/yaml.v3 v3.0.1 require ( + github.com/avast/retry-go v3.0.0+incompatible // indirect + github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 // indirect + github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect github.com/sirupsen/logrus v1.9.3 // indirect + golang.org/x/oauth2 v0.24.0 // indirect golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect ) diff --git a/go.sum b/go.sum index 0842d75a..6663fb94 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,18 @@ +github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0= +github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 h1:cYCy18SHPKRkvclm+pWm1Lk4YrREb4IOIb/YdFO0p2M= +github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8= +github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0= +github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= +golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/pkg/contributions/types.go b/pkg/contributions/types.go new file mode 100644 index 00000000..ad1a59ac --- /dev/null +++ b/pkg/contributions/types.go @@ -0,0 +1,383 @@ +/* + * This file is part of the KubeVirt project + * + * 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 the KubeVirt Authors. + * + */ + +package contributions + +import ( + "fmt" + "path/filepath" + "time" +) + +type ContributionReport interface { + Summary() string + ReportFileName(userName string) string + HasContributions() bool + WriteToFile(dir, userName string) (string, error) +} + +type UserContributionReportForRepository struct { + IssuesCreated IssuesCreated `yaml:"issuesCreated"` + IssuesCommented IssuesCommented `yaml:"issuesCommented"` + PullRequestsCreated PullRequestsCreated `yaml:"pullRequestsCreated"` + PullRequestsReviewed PullRequestsReviewed `yaml:"pullRequestsReviewed"` + PullRequestsCommented PullRequestsCommented `yaml:"pullRequestsCommented"` + CommitsByUser CommitsByUser `yaml:"commitsByUser"` + Org string + Repo string + UserName string + UserID string + StartFrom time.Time +} + +func (u *UserContributionReportForRepository) Summary() string { + return fmt.Sprintf(`activity log: + user: %s + repository: %s/%s + since: %s + + issues + created: %d + commented: %d + pull requests: + reviewed: %d + created: %d + commented: %d + commits: %d +`, u.UserName, u.Org, u.Repo, u.StartFrom.Format(time.DateTime), + u.IssuesCreated.IssueCount, + u.IssuesCommented.IssueCount, + u.PullRequestsReviewed.IssueCount, + u.PullRequestsCreated.IssueCount, + u.PullRequestsCommented.IssueCount, + u.CommitsByUser.DefaultBranchRef.Target.Fragment.History.TotalCount, + ) +} + +func (u *UserContributionReportForRepository) ReportFileName(userName string) string { + return fmt.Sprintf("user-activity-%s-%s_%s-*.yaml", userName, u.Org, u.Repo) +} + +func (u *UserContributionReportForRepository) HasContributions() bool { + return u.IssuesCreated.IssueCount > 0 || + u.IssuesCommented.IssueCount > 0 || + u.PullRequestsReviewed.IssueCount > 0 || + u.PullRequestsCreated.IssueCount > 0 || + u.PullRequestsCommented.IssueCount > 0 || + u.CommitsByUser.DefaultBranchRef.Target.Fragment.History.TotalCount > 0 +} + +func (u *UserContributionReportForRepository) WriteToFile(dir string, userName string) (string, error) { + logFileName := u.ReportFileName(userName) + err := writeActivityToFile(u, dir, logFileName) + if err != nil { + return "", err + } + return filepath.Join(dir, logFileName), nil +} + +type UserContributionReportForOrganization struct { + IssuesCreated IssuesCreated `yaml:"issuesCreated"` + IssuesCommented IssuesCommented `yaml:"issuesCommented"` + PullRequestsCreated PullRequestsCreated `yaml:"pullRequestsCreated"` + PullRequestsReviewed PullRequestsReviewed `yaml:"pullRequestsReviewed"` + PullRequestsCommented PullRequestsCommented `yaml:"pullRequestsCommented"` + CommitsByUserInOrg CommitsByUserInOrg `yaml:"commitsByUserInOrg"` + Org string + UserName string + UserID string + StartFrom time.Time +} + +func (u *UserContributionReportForOrganization) Summary() string { + return fmt.Sprintf(`activity log: + user: %s + org: %s + since: %s + + issues + created: %d + commented: %d + pull requests: + reviewed: %d + created: %d + commented: %d + commits: %d +`, u.UserName, u.Org, u.StartFrom.Format(time.DateTime), + u.IssuesCreated.IssueCount, + u.IssuesCommented.IssueCount, + u.PullRequestsReviewed.IssueCount, + u.PullRequestsCreated.IssueCount, + u.PullRequestsCommented.IssueCount, + u.totalCommitCount(), + ) +} + +func (u *UserContributionReportForOrganization) totalCommitCount() int { + totalCommitCount := 0 + for _, node := range u.CommitsByUserInOrg.Repositories.Nodes { + totalCommitCount += node.DefaultBranchRef.Target.Fragment.History.TotalCount + } + return totalCommitCount +} + +func (u *UserContributionReportForOrganization) ReportFileName(userName string) string { + return fmt.Sprintf("user-activity-%s-%s-*.yaml", userName, u.Org) +} + +func (u *UserContributionReportForOrganization) HasContributions() bool { + return u.IssuesCreated.IssueCount > 0 || + u.IssuesCommented.IssueCount > 0 || + u.PullRequestsReviewed.IssueCount > 0 || + u.PullRequestsCreated.IssueCount > 0 || + u.PullRequestsCommented.IssueCount > 0 || + u.totalCommitCount() > 0 +} + +func (u *UserContributionReportForOrganization) WriteToFile(dir, userName string) (string, error) { + logFileName := u.ReportFileName(userName) + err := writeActivityToFile(u, dir, logFileName) + if err != nil { + return "", err + } + return filepath.Join(dir, logFileName), nil +} + +type Repository struct { + Name string `yaml:"name"` +} +type Author struct { + Login string `yaml:"login"` +} +type IssueFragment struct { + Number int `yaml:"number"` + Title string `yaml:"title"` + URL string `yaml:"URL"` + Repository Repository `yaml:"repository"` + Author Author `yaml:"author"` + CreatedAt time.Time `yaml:"createdAt"` +} + +type IssuesCreatedNodeItem struct { + Issue IssueFragment `graphql:"... on Issue" yaml:"issue"` +} + +type IssuesCreated struct { + IssueCount int `yaml:"issueCount"` + Nodes []IssuesCreatedNodeItem `yaml:"nodes"` +} + +type CommentAuthor struct { + Login string `yaml:"login"` +} +type CommentItem struct { + Author CommentAuthor `yaml:"author"` + CreatedAt time.Time `yaml:"createdAt"` + URL string `yaml:"URL"` +} + +type Comments struct { + Nodes []CommentItem `yaml:"nodes"` +} + +type IssueWithCommentFragment struct { + Number int `yaml:"number"` + Title string `yaml:"title"` + URL string `yaml:"URL"` + Repository Repository `yaml:"repository"` + Author Author `yaml:"author"` + Comments Comments `graphql:"comments(first:100, orderBy: {field: UPDATED_AT, direction: ASC} )" yaml:"comments"` +} + +type IssuesCommentedNodeItem struct { + Issue IssueWithCommentFragment `graphql:"... on Issue" yaml:"issue"` +} + +type IssuesCommented struct { + IssueCount int `yaml:"issueCount"` + Nodes []IssuesCommentedNodeItem `yaml:"nodes"` +} + +type PullRequestAuthor struct { + Login string `yaml:"login"` +} + +type PullRequestFragment struct { + Number int `yaml:"number"` + Title string `yaml:"title"` + URL string `yaml:"URL"` + CreatedAt time.Time `yaml:"createdAt"` + Author PullRequestAuthor `yaml:"author"` +} + +type PullRequestNodeItem struct { + PullRequest PullRequestFragment `graphql:"... on PullRequest" yaml:"pullRequest"` +} + +type PullRequestsCreated struct { + IssueCount int `yaml:"issueCount"` + Nodes []PullRequestNodeItem `yaml:"nodes"` +} + +type PullRequestReviewItem struct { + State string `yaml:"state"` + URL string `yaml:"URL"` +} + +type PullRequestReviews struct { + TotalCount int `yaml:"totalCount"` + Nodes []PullRequestReviewItem `yaml:"nodes"` +} + +type PullRequestReviewFragment struct { + Title string `yaml:"title"` + Number int `yaml:"number"` + URL string `yaml:"URL"` + CreatedAt time.Time `yaml:"createdAt"` + Reviews PullRequestReviews `graphql:"reviews(first:5, author: $username)" yaml:"reviews"` +} + +type PullRequestReviewNodeItem struct { + PullRequestReview PullRequestReviewFragment `graphql:"... on PullRequest" yaml:"pullRequestReview"` +} + +type PullRequestsReviewed struct { + IssueCount int `yaml:"issueCount"` + Nodes []PullRequestReviewNodeItem `yaml:"nodes"` +} + +type PullRequestCommentAuthor struct { + Login string `yaml:"login"` +} + +type PullRequestComment struct { + Author PullRequestCommentAuthor `yaml:"author"` + CreatedAt time.Time `yaml:"createdAt"` + URL string `yaml:"URL"` +} +type PullRequestCommentsItem struct { + Nodes []PullRequestComment `yaml:"nodes"` +} + +type PullRequestCommentedRepository struct { + Name string `yaml:"name"` +} + +type PullRequestCommentedAuthor struct { + Login string `yaml:"login"` +} + +type PullRequestCommentedFragment struct { + Number int `yaml:"number"` + Title string `yaml:"title"` + URL string `yaml:"URL"` + Repository PullRequestCommentedRepository `yaml:"repository"` + Author PullRequestCommentedAuthor `yaml:"author"` + Comments PullRequestCommentsItem `graphql:"comments(first:100, orderBy: {field: UPDATED_AT, direction: ASC} )" yaml:"comments"` +} + +type PullRequestCommentedItem struct { + PullRequest PullRequestCommentedFragment `graphql:"... on PullRequest" yaml:"pullRequest"` +} + +type PullRequestsCommented struct { + IssueCount int `yaml:"issueCount"` + Nodes []PullRequestCommentedItem `yaml:"nodes"` +} + +type AssociatedPullRequest struct { + Number int `yaml:"number"` + Title string `yaml:"title"` + URL string `yaml:"URL"` +} + +type AssociatedPullRequests struct { + Nodes []AssociatedPullRequest `yaml:"nodes"` +} + +type CommitsByUserTargetHistoryNode struct { + CommitUrl string `yaml:"commitUrl"` + AssociatedPullRequests `graphql:"associatedPullRequests(first: 5)" yaml:"associatedPullRequests"` +} + +type CommitsByUserTargetHistory struct { + TotalCount int `yaml:"totalCount"` + Nodes []CommitsByUserTargetHistoryNode `yaml:"nodes"` +} + +type CommitsByUserTargetFragment struct { + History CommitsByUserTargetHistory `graphql:"history(author: {id: $userID}, since: $startFrom)" yaml:"history"` +} + +type CommitsByUserTargetItem struct { + Fragment CommitsByUserTargetFragment `graphql:"... on Commit" yaml:"fragment"` +} + +type CommitsByUserRef struct { + Target CommitsByUserTargetItem `yaml:"target"` +} + +type CommitsByUser struct { + DefaultBranchRef CommitsByUserRef `yaml:"defaultBranchRef"` +} + +type RepositoryNodeRefTargetHistoryNodeAuthorUser struct { + Name string `yaml:"name"` +} + +type RepositoryNodeRefTargetHistoryNodeAuthor struct { + User RepositoryNodeRefTargetHistoryNodeAuthorUser `yaml:"user"` +} + +type RepositoryNodeRefTargetHistoryNode struct { + URL string `yaml:"URL"` + CommittedDate time.Time `yaml:"committedDate"` + Author RepositoryNodeRefTargetHistoryNodeAuthor `yaml:"author"` +} + +type RepositoryNodeRefTargetHistory struct { + TotalCount int `yaml:"totalCount"` + Nodes []RepositoryNodeRefTargetHistoryNode `yaml:"nodes"` +} + +type RepositoryNodeRefTargetFragment struct { + CommitURL string `yaml:"commitURL"` + History RepositoryNodeRefTargetHistory `graphql:"history(first: 3, author: {id: $userID}, since: $startFrom)" yaml:"history"` +} + +type RepositoryNodeRefTargetItem struct { + Fragment RepositoryNodeRefTargetFragment `graphql:"... on Commit" yaml:"fragment"` +} + +type RepositoryNodeRef struct { + Target RepositoryNodeRefTargetItem `yaml:"target"` +} + +type RepositoryNode struct { + Name string `yaml:"name"` + DefaultBranchRef RepositoryNodeRef `yaml:"defaultBranchRef"` +} + +type Repositories struct { + Nodes []RepositoryNode `yaml:"nodes"` +} + +type CommitsByUserInOrg struct { + Repositories Repositories `graphql:"repositories(first: 25, isArchived: false, visibility: PUBLIC)" yaml:"repositories"` +} diff --git a/pkg/contributions/user-contribution.go b/pkg/contributions/user-contribution.go new file mode 100644 index 00000000..70f2318c --- /dev/null +++ b/pkg/contributions/user-contribution.go @@ -0,0 +1,272 @@ +/* + * This file is part of the KubeVirt project + * + * 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 the KubeVirt Authors. + * + */ + +package contributions + +import ( + "context" + "fmt" + "github.com/avast/retry-go" + "github.com/shurcooL/githubv4" + log "github.com/sirupsen/logrus" + "golang.org/x/oauth2" + "gopkg.in/yaml.v3" + "os" + "strings" + "time" +) + +type ContributionReportGenerator struct { + client *githubv4.Client + opts ContributionReportGeneratorOptions + ReportingMode interface{} +} + +func NewContributionReportGenerator(opts ContributionReportGeneratorOptions) (*ContributionReportGenerator, error) { + token, err := os.ReadFile(opts.GithubTokenPath) + if err != nil { + return nil, fmt.Errorf("failed to use github token path %s: %v", opts.GithubTokenPath, err) + } + src := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: strings.TrimSpace(string(token))}, + ) + httpClient := oauth2.NewClient(context.Background(), src) + client := githubv4.NewClient(httpClient) + return &ContributionReportGenerator{client: client, opts: opts}, nil +} + +func (g ContributionReportGenerator) GenerateReport(userName string) (ContributionReport, error) { + var contributionReport ContributionReport + err := retry.Do( + func() error { + var err error + if g.opts.Repo != "" { + contributionReport, err = generateUserActivityReportForRepository(g.client, g.opts.Org, g.opts.Repo, userName, g.opts.startFrom()) + } else { + contributionReport, err = generateUserContributionReportForOrganization(g.client, g.opts.Org, userName, g.opts.startFrom()) + } + if err != nil { + log.Errorf("query failed (will retry): %v", err) + } + return err + }, + retry.LastErrorOnly(true), + ) + if contributionReport == nil && err != nil { + return nil, fmt.Errorf("query failed (aborting): %v", err) + } + return contributionReport, nil +} + +type ContributionReportGeneratorOptions struct { + Org string + Repo string + GithubTokenPath string + Months int +} + +func (o ContributionReportGeneratorOptions) validate() error { + if o.GithubTokenPath == "" { + return fmt.Errorf("github token path is required") + } + return nil +} + +func (o ContributionReportGeneratorOptions) startFrom() time.Time { + return time.Now().AddDate(0, -1*o.Months, 0) +} + +func generateUserActivityReportForRepository(client *githubv4.Client, org, repo, username string, startFrom time.Time) (*UserContributionReportForRepository, error) { + userid, err := getUserId(client, username) + if err != nil { + return nil, fmt.Errorf("failed to query: %v", err) + } + + var query struct { + IssuesCreated IssuesCreated `graphql:"issuesCreated: search(first: 5, type: ISSUE, query: $authorSearchQuery)"` + IssuesCommented IssuesCommented `graphql:"issuesCommented: search(first: 5, type: ISSUE, query: $commenterSearchQuery)"` + PullRequestsCreated PullRequestsCreated `graphql:"prsCreated: search(type: ISSUE, first: 5, query: $pullRequestsCreatedQuery)"` + PullRequestsReviewed PullRequestsReviewed `graphql:"prsReviewed: search(type: ISSUE, first: 5, query: $pullRequestsReviewedQuery)"` + PullRequestsCommented PullRequestsCommented `graphql:"prsCommented: search(last: 100, type: ISSUE, query: $pullRequestsCommentedQuery)"` + CommitsByUser CommitsByUser `graphql:"commitsByUser: repository(owner: $org, name: $repo)"` + } + + fromDate := startFrom.Format("2006-01-02") + + variables := map[string]interface{}{ + "org": githubv4.String(org), + "repo": githubv4.String(repo), + "username": githubv4.String(username), + "userID": githubv4.ID(userid), + "startFrom": githubv4.GitTimestamp{Time: startFrom}, + "authorSearchQuery": githubv4.String(fmt.Sprintf( + "repo:%s/%s author:%s is:issue created:>=%s", + org, + repo, + username, + fromDate, + )), + "commenterSearchQuery": githubv4.String(fmt.Sprintf( + "repo:%s/%s commenter:%s is:issue created:>=%s", + org, + repo, + username, + fromDate, + )), + "pullRequestsCreatedQuery": githubv4.String(fmt.Sprintf( + "repo:%s/%s author:%s is:pr created:>=%s", + org, + repo, + username, + fromDate, + )), + "pullRequestsReviewedQuery": githubv4.String(fmt.Sprintf( + "repo:%s/%s reviewed-by:%s is:pr updated:>=%s", + org, + repo, + username, + fromDate, + )), + "pullRequestsCommentedQuery": githubv4.String(fmt.Sprintf( + "repo:%s/%s commenter:%s is:pr updated:>=%s", + org, + repo, + username, + fromDate, + )), + } + + err = client.Query(context.Background(), &query, variables) + if err != nil { + return nil, fmt.Errorf("failed to use github query %+v with variables %v: %w", query, variables, err) + } + return &UserContributionReportForRepository{ + IssuesCreated: query.IssuesCreated, + IssuesCommented: query.IssuesCommented, + PullRequestsCreated: query.PullRequestsCreated, + PullRequestsReviewed: query.PullRequestsReviewed, + PullRequestsCommented: query.PullRequestsCommented, + CommitsByUser: query.CommitsByUser, + Org: org, + Repo: repo, + UserName: username, + UserID: userid, + StartFrom: startFrom, + }, nil +} + +func generateUserContributionReportForOrganization(client *githubv4.Client, org, username string, startFrom time.Time) (*UserContributionReportForOrganization, error) { + userid, err := getUserId(client, username) + if err != nil { + return nil, fmt.Errorf("failed to query: %v", err) + } + + var query struct { + IssuesCreated IssuesCreated `graphql:"issuesCreated: search(first: 5, type: ISSUE, query: $authorSearchQuery)"` + IssuesCommented IssuesCommented `graphql:"issuesCommented: search(first: 5, type: ISSUE, query: $commenterSearchQuery)"` + PullRequestsCreated PullRequestsCreated `graphql:"prsCreated: search(type: ISSUE, first: 5, query: $pullRequestsCreatedQuery)"` + PullRequestsReviewed PullRequestsReviewed `graphql:"prsReviewed: search(type: ISSUE, first: 5, query: $pullRequestsReviewedQuery)"` + PullRequestsCommented PullRequestsCommented `graphql:"prsCommented: search(last: 100, type: ISSUE, query: $pullRequestsCommentedQuery)"` + CommitsByUserInOrg CommitsByUserInOrg `graphql:"commitsByUserInOrg: organization(login: $org)"` + } + + fromDate := startFrom.Format("2006-01-02") + + variables := map[string]interface{}{ + "org": githubv4.String(org), + "username": githubv4.String(username), + "userID": githubv4.ID(userid), + "startFrom": githubv4.GitTimestamp{Time: startFrom}, + "authorSearchQuery": githubv4.String(fmt.Sprintf( + "org:%s author:%s is:issue created:>=%s", + org, + username, + fromDate, + )), + "commenterSearchQuery": githubv4.String(fmt.Sprintf( + "org:%s commenter:%s is:issue created:>=%s", + org, + username, + fromDate, + )), + "pullRequestsCreatedQuery": githubv4.String(fmt.Sprintf( + "org:%s author:%s is:pr created:>=%s", + org, + username, + fromDate, + )), + "pullRequestsReviewedQuery": githubv4.String(fmt.Sprintf( + "org:%s reviewed-by:%s is:pr updated:>=%s", + org, + username, + fromDate, + )), + "pullRequestsCommentedQuery": githubv4.String(fmt.Sprintf( + "org:%s commenter:%s is:pr updated:>=%s", + org, + username, + fromDate, + )), + } + + err = client.Query(context.Background(), &query, variables) + if err != nil { + return nil, fmt.Errorf("failed to use github query %+v with variables %v: %w", query, variables, err) + } + return &UserContributionReportForOrganization{ + IssuesCreated: query.IssuesCreated, + IssuesCommented: query.IssuesCommented, + PullRequestsCreated: query.PullRequestsCreated, + PullRequestsReviewed: query.PullRequestsReviewed, + PullRequestsCommented: query.PullRequestsCommented, + CommitsByUserInOrg: query.CommitsByUserInOrg, + Org: org, + UserName: username, + UserID: userid, + StartFrom: startFrom, + }, nil +} + +func getUserId(client *githubv4.Client, username string) (string, error) { + var query struct { + User struct { + ID string + } `graphql:"user(login: $username)"` + } + variables := map[string]interface{}{ + "username": githubv4.String(username), + } + err := client.Query(context.Background(), &query, variables) + if err != nil { + return "", fmt.Errorf("failed to use github query %+v with variables %v: %w", query, variables, err) + } + return query.User.ID, nil +} + +func writeActivityToFile(yamlObject interface{}, dir, fileName string) error { + tempFile, err := os.CreateTemp(dir, fileName) + defer tempFile.Close() + encoder := yaml.NewEncoder(tempFile) + err = encoder.Encode(&yamlObject) + if err != nil { + return err + } + log.Debugf(`user activity log: %q`, tempFile.Name()) + return nil +} diff --git a/pkg/owners/file.go b/pkg/owners/file.go new file mode 100644 index 00000000..685d8fd5 --- /dev/null +++ b/pkg/owners/file.go @@ -0,0 +1,54 @@ +/* + * This file is part of the KubeVirt project + * + * 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 the KubeVirt Authors. + * + */ + +package owners + +import ( + "fmt" + "gopkg.in/yaml.v3" + "os" +) + +func ReadFile(path string) (*Owners, error) { + buf, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("error reading %s: %v", path, err) + } + + owners := &Owners{} + err = yaml.Unmarshal(buf, owners) + if err != nil { + return nil, fmt.Errorf("in file %q: %v", path, err) + } + return owners, err +} + +func ReadAliasesFile(path string) (*OwnersAliases, error) { + buf, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("error reading %s: %v", path, err) + } + + ownersAliases := &OwnersAliases{} + err = yaml.Unmarshal(buf, ownersAliases) + if err != nil { + return nil, fmt.Errorf("in file %q: %v", path, err) + } + return ownersAliases, err +} diff --git a/pkg/owners/types.go b/pkg/owners/types.go new file mode 100644 index 00000000..30541a6c --- /dev/null +++ b/pkg/owners/types.go @@ -0,0 +1,62 @@ +/* + * This file is part of the KubeVirt project + * + * 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 the KubeVirt Authors. + * + */ + +package owners + +type Owners struct { + Reviewers []string `yaml:"reviewers"` + Approvers []string `yaml:"approvers"` + EmeritusApprovers []string `yaml:"emeritus_approvers"` + Filters map[string]Owners `yaml:"filters"` +} + +func (o *Owners) AllReviewers() []string { + var allReviewers []string + allReviewers = o.Reviewers + for _, filter := range o.Filters { + allReviewers = append(allReviewers, filter.Reviewers...) + } + return allReviewers +} + +func (o *Owners) AllApprovers() []string { + var allApprovers []string + allApprovers = o.Approvers + for _, filter := range o.Filters { + allApprovers = append(allApprovers, filter.Approvers...) + } + return allApprovers +} + +type OwnersAliases struct { + Aliases map[string][]string `yaml:"aliases"` +} + +func (a OwnersAliases) Resolve(aliases []string) []string { + var resolved []string + for _, alias := range aliases { + resolvedUserNames, exists := a.Aliases[alias] + if !exists { + resolved = append(resolved, alias) + continue + } + resolved = append(resolved, resolvedUserNames...) + } + return resolved +}