From f5b79af7fe304cbde91d5a34e67adbeba04fd29a Mon Sep 17 00:00:00 2001 From: Daniel Hiller Date: Mon, 2 Dec 2024 10:57:14 +0100 Subject: [PATCH 1/8] add user activity tool with GraphQL Signed-off-by: Daniel Hiller --- generators/cmd/contributions/README.md | 132 ++++++++ generators/cmd/contributions/main.go | 176 ++++++++++ .../user-activity-in-organization.go | 148 +++++++++ .../user-activity-in-repository.go | 300 ++++++++++++++++++ go.mod | 3 + go.sum | 6 + 6 files changed, 765 insertions(+) create mode 100644 generators/cmd/contributions/README.md create mode 100644 generators/cmd/contributions/main.go create mode 100644 generators/cmd/contributions/user-activity-in-organization.go create mode 100644 generators/cmd/contributions/user-activity-in-repository.go diff --git a/generators/cmd/contributions/README.md b/generators/cmd/contributions/README.md new file mode 100644 index 00000000..b574b72c --- /dev/null +++ b/generators/cmd/contributions/README.md @@ -0,0 +1,132 @@ +# contributions + +A tool that fetches GitHub contributions by a user per organization or per repository. + +## per organization + +Example: +```bash +$ go run ./generators/cmd/contributions \ + --github-token /path/to/oauth \ + --months 12 \ + --username dhiller +activity log: + user dhiller + organization kubevirt + since 2023-12-02 17:50:50 + + hasContributions: true + totalIssueContributions: 57 + totalPullRequestContributions: 181 + totalPullRequestReviewContributions: 455 + totalCommitContributions: 199 + +user activity log: "/tmp/user-activity-dhiller-kubevirt-2101388502.yaml" +$ cat /tmp/user-activity-dhiller-kubevirt-2101388502.yaml +hasAnyContributions: true +totalCommitContributions: 199 +totalIssueContributions: 57 +totalPullRequestContributions: 181 +totalPullRequestReviewContributions: 455 +issueContributions: + totalCount: 57 + nodes: + - issue: + URL: https://github.com/kubevirt/community/issues/359 + occurredAt: 2024-11-28T09:18:48Z +pullRequestContributions: + totalCount: 181 + nodes: + - pullRequest: + URL: https://github.com/kubevirt/community/pull/361 + occurredAt: 2024-12-02T09:59:10Z +pullRequestReviewContributions: + totalCount: 455 + nodes: + - pullRequestReview: + repository: + nameWithOwner: kubevirt/kubevirt + pullRequest: + URL: https://github.com/kubevirt/kubevirt/pull/13274 + createdAt: 2024-11-29T09:01:07Z + state: APPROVED +commitContributionsByRepository: + - contributions: + totalCount: 119 + nodes: + - repository: + nameWithOwner: kubevirt/project-infra + user: + name: Daniel Hiller + occurredAt: 2024-11-26T08:00:00Z +... +``` + +## per repository + +Example: +```bash +$ go run ./generators/cmd/contributions \ + --github-token /path/to/oauth \ + --months 12 \ + --repo project-infra \ + --username dhiller +activity log: + user dhiller + repository kubevirt/project-infra + since 2023-12-02 17:54:45 + + issues + created: 17 + commented: 15 + pull requests: + reviewed: 244 + created: 124 + commented: 330 + commits: 117 + +user activity log: "/tmp/user-activity-dhiller-kubevirt_project-infra-3828787132.yaml" +$ cat /tmp/user-activity-dhiller-kubevirt_project-infra-3828787132.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/37 +68 + repository: + name: project-infra + author: + login: dhiller + createdAt: 2024-11-19T10:53:26Z + - issue: + number: 3712 + title: Remove label needs-approver-review after either c +losed/merged or review by approver has happened + URL: https://github.com/kubevirt/project-infra/issues/37 +12 + repository: + name: project-infra + author: + login: dhiller + createdAt: 2024-10-25T11:08:15Z + - issue: + number: 3711 + title: Check prow update mechanism + URL: https://github.com/kubevirt/project-infra/issues/37 +11 + repository: + name: project-infra + author: + login: dhiller + createdAt: 2024-10-25T10:31:19Z +... +``` diff --git a/generators/cmd/contributions/main.go b/generators/cmd/contributions/main.go new file mode 100644 index 00000000..45a80f7c --- /dev/null +++ b/generators/cmd/contributions/main.go @@ -0,0 +1,176 @@ +/* + * 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 ( + "context" + "flag" + "fmt" + "github.com/shurcooL/githubv4" + log "github.com/sirupsen/logrus" + "golang.org/x/oauth2" + "gopkg.in/yaml.v3" + "os" + "strings" + "time" +) + +type options struct { + org string + repo string + username string + githubTokenPath string + months int +} + +func (o options) validate() error { + if o.username == "" { + return fmt.Errorf("username is required") + } + if o.githubTokenPath == "" { + return fmt.Errorf("github token path is required") + } + return nil +} + +func gatherOptions() (options, error) { + o := options{} + 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") + err := fs.Parse(os.Args[1:]) + return o, err +} + +func init() { + log.SetFormatter(&log.JSONFormatter{}) + log.SetLevel(log.DebugLevel) +} + +func main() { + opts, err := gatherOptions() + if err != nil { + log.Fatalf("error parsing arguments %v: %v", os.Args[1:], err) + } + if err = opts.validate(); err != nil { + log.Fatalf("error validating arguments: %v", err) + } + + token, err := os.ReadFile(opts.githubTokenPath) + if err != nil { + log.Fatalf("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) + graphqlClient := githubv4.NewClient(httpClient) + + xMonthsAgo := time.Now().AddDate(0, -1*opts.months, 0) + + if opts.repo != "" { + id, err := getUserId(graphqlClient, opts.username) + if err != nil { + log.Fatalf("failed to query: %v", err) + } + + activity, err := generateUserActivityReportInRepository(graphqlClient, opts.org, opts.repo, opts.username, id, xMonthsAgo) + if err != nil { + log.Fatalf("failed to query: %v", err) + } + tempFile, err := os.CreateTemp("/tmp", fmt.Sprintf("user-activity-%s-%s_%s-*.yaml", opts.username, opts.org, opts.repo)) + if err != nil { + log.Fatal(err) + } + defer tempFile.Close() + encoder := yaml.NewEncoder(tempFile) + err = encoder.Encode(&activity) + if err != nil { + log.Fatal(err) + } + fmt.Printf(`activity log: + user: %s + repository: %s/%s + since: %s + + issues + created: %d + commented: %d + pull requests: + reviewed: %d + created: %d + commented: %d + commits: %d + +user activity log: %q +`, opts.username, opts.org, opts.repo, xMonthsAgo.Format(time.DateTime), + activity.IssuesCreated.IssueCount, + activity.IssuesCommented.IssueCount, + activity.PullRequestsReviewed.IssueCount, + activity.PullRequestsCreated.IssueCount, + activity.PullRequestsCommented.IssueCount, + activity.CommitsByUser.DefaultBranchRef.Target.Fragment.History.TotalCount, + tempFile.Name(), + ) + } else { + id, err := getOrganizationId(graphqlClient, opts.org) + if err != nil { + log.Fatalf("failed to query: %v", err) + } + + activity, err := generateUserActivityReportInOrganization(graphqlClient, id, opts.username, xMonthsAgo) + if err != nil { + log.Fatalf("failed to query: %v", err) + } + tempFile, err := os.CreateTemp("/tmp", fmt.Sprintf("user-activity-%s-%s-*.yaml", opts.username, opts.org)) + if err != nil { + log.Fatal(err) + } + defer tempFile.Close() + encoder := yaml.NewEncoder(tempFile) + err = encoder.Encode(&activity) + if err != nil { + log.Fatal(err) + } + fmt.Printf(`activity log: + user: %s + organization: %s + since: %s + + hasContributions: %t + totalIssueContributions: %d + totalPullRequestContributions: %d + totalPullRequestReviewContributions: %d + totalCommitContributions: %d + +user activity log: %q +`, opts.username, opts.org, xMonthsAgo.Format(time.DateTime), + activity.HasAnyContributions, + activity.TotalIssueContributions, + activity.TotalPullRequestContributions, + activity.TotalPullRequestReviewContributions, + activity.TotalCommitContributions, + tempFile.Name(), + ) + } +} diff --git a/generators/cmd/contributions/user-activity-in-organization.go b/generators/cmd/contributions/user-activity-in-organization.go new file mode 100644 index 00000000..74a2eebf --- /dev/null +++ b/generators/cmd/contributions/user-activity-in-organization.go @@ -0,0 +1,148 @@ +/* + * 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 ( + "context" + "fmt" + "github.com/shurcooL/githubv4" + "time" +) + +type IssueContributionNodeFragment struct { + URL string `yaml:"URL"` +} + +type IssueContributionNode struct { + Issue IssueContributionNodeFragment `yaml:"issue"` + OccurredAt time.Time `yaml:"occurredAt"` +} + +type IssueContributions struct { + TotalCount int `yaml:"totalCount"` + Nodes []IssueContributionNode `yaml:"nodes"` +} + +type PullRequestContributionNodeFragment struct { + URL string `yaml:"URL"` +} + +type PullRequestContributionNode struct { + PullRequest PullRequestContributionNodeFragment `yaml:"pullRequest"` + OccurredAt time.Time `yaml:"occurredAt"` +} + +type PullRequestContributions struct { + TotalCount int `yaml:"totalCount"` + Nodes []PullRequestContributionNode `yaml:"nodes"` +} + +type PullRequestReviewContributionNodePullRequest struct { + URL string `yaml:"URL"` +} +type PullRequestReviewContributionNodeRepository struct { + NameWithOwner string `yaml:"nameWithOwner"` +} +type PullRequestReviewContributionNodeFragment struct { + Repository PullRequestReviewContributionNodeRepository `yaml:"repository"` + PullRequest PullRequestReviewContributionNodePullRequest `yaml:"pullRequest"` + CreatedAt time.Time `yaml:"createdAt"` + State string `yaml:"state"` +} +type PullRequestReviewContributionNode struct { + PullRequestReview PullRequestReviewContributionNodeFragment `yaml:"pullRequestReview"` +} +type PullRequestReviewContributions struct { + TotalCount int `yaml:"totalCount"` + Nodes []PullRequestReviewContributionNode `yaml:"nodes"` +} + +type CommitContributionsByRepositoryContributionUser struct { + Name string `yaml:"name"` +} +type CommitContributionsByRepositoryContributionRepository struct { + NameWithOwner string `yaml:"nameWithOwner"` +} + +type CommitContributionsByRepositoryContributionsNode struct { + Repository CommitContributionsByRepositoryContributionRepository `yaml:"repository"` + User CommitContributionsByRepositoryContributionUser `yaml:"user"` + OccurredAt time.Time `yaml:"occurredAt"` +} + +type CommitContributionsByRepositoryContributions struct { + TotalCount int `yaml:"totalCount"` + Nodes []CommitContributionsByRepositoryContributionsNode `yaml:"nodes"` +} +type CommitContributionsByRepository struct { + Contributions CommitContributionsByRepositoryContributions `graphql:"contributions(first: 10,orderBy: {field: OCCURRED_AT, direction: DESC})"` +} + +type ContributionsCollection struct { + HasAnyContributions bool `yaml:"hasAnyContributions"` + TotalCommitContributions int `yaml:"totalCommitContributions"` + TotalIssueContributions int `yaml:"totalIssueContributions"` + TotalPullRequestContributions int `yaml:"totalPullRequestContributions"` + TotalPullRequestReviewContributions int `yaml:"totalPullRequestReviewContributions"` + IssueContributions `graphql:"issueContributions(first: 1, orderBy: {direction: DESC})" yaml:"issueContributions"` + PullRequestContributions `graphql:"pullRequestContributions(first: 1, orderBy: {direction: DESC})" yaml:"pullRequestContributions"` + PullRequestReviewContributions `graphql:"pullRequestReviewContributions(first: 1, orderBy: {direction: DESC})" yaml:"pullRequestReviewContributions"` + CommitContributionsByRepository []CommitContributionsByRepository `graphql:"commitContributionsByRepository(maxRepositories: 10)" yaml:"commitContributionsByRepository"` +} + +type UserContributionsInOrg struct { + ContributionsCollection `graphql:"contributionsCollection(organizationID: $organizationID, from: $startFrom)" yaml:"contributionsCollection"` +} + +func generateUserActivityReportInOrganization(client *githubv4.Client, orgId, username string, startFrom time.Time) (*ContributionsCollection, error) { + + var query struct { + UserContributionsInOrg UserContributionsInOrg `graphql:"userContributionsInOrg: user(login: $username)"` + } + + variables := map[string]interface{}{ + "username": githubv4.String(username), + "organizationID": githubv4.ID(orgId), + "startFrom": githubv4.DateTime{Time: startFrom}, + } + + 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 &query.UserContributionsInOrg.ContributionsCollection, nil +} + +func getOrganizationId(client *githubv4.Client, organizationName string) (string, error) { + var query struct { + Organization struct { + ID string + } `graphql:"organization(login: $organizationName)"` + } + variables := map[string]interface{}{ + "organizationName": githubv4.String(organizationName), + } + 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.Organization.ID, nil +} diff --git a/generators/cmd/contributions/user-activity-in-repository.go b/generators/cmd/contributions/user-activity-in-repository.go new file mode 100644 index 00000000..495b3496 --- /dev/null +++ b/generators/cmd/contributions/user-activity-in-repository.go @@ -0,0 +1,300 @@ +/* + * 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 ( + "context" + "fmt" + "github.com/shurcooL/githubv4" + "time" +) + +type UserActivityReportInRepository 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"` +} + +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"` +} + +func generateUserActivityReportInRepository(client *githubv4.Client, org, repo, username, userid string, startFrom time.Time) (*UserActivityReportInRepository, error) { + + 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 &UserActivityReportInRepository{ + IssuesCreated: query.IssuesCreated, + IssuesCommented: query.IssuesCommented, + PullRequestsCreated: query.PullRequestsCreated, + PullRequestsReviewed: query.PullRequestsReviewed, + PullRequestsCommented: query.PullRequestsCommented, + CommitsByUser: query.CommitsByUser, + }, 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 +} diff --git a/go.mod b/go.mod index a6609a5f..63e4bc80 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,9 @@ go 1.22 require gopkg.in/yaml.v3 v3.0.1 require ( + 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..85fcc4d7 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,16 @@ 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= From f119625a66a02930a4a39fda74fc0836c7b562b8 Mon Sep 17 00:00:00 2001 From: Daniel Hiller Date: Tue, 3 Dec 2024 14:29:45 +0100 Subject: [PATCH 2/8] contributions: refactoring Signed-off-by: Daniel Hiller --- generators/cmd/contributions/main.go | 136 +++------- .../user-activity-in-organization.go | 148 ----------- .../contributions/types.go | 241 +++++++++++------- pkg/contributions/user-activity.go | 240 +++++++++++++++++ 4 files changed, 425 insertions(+), 340 deletions(-) delete mode 100644 generators/cmd/contributions/user-activity-in-organization.go rename generators/cmd/contributions/user-activity-in-repository.go => pkg/contributions/types.go (52%) create mode 100644 pkg/contributions/user-activity.go diff --git a/generators/cmd/contributions/main.go b/generators/cmd/contributions/main.go index 45a80f7c..81c9161d 100644 --- a/generators/cmd/contributions/main.go +++ b/generators/cmd/contributions/main.go @@ -20,19 +20,14 @@ package main import ( - "context" "flag" "fmt" - "github.com/shurcooL/githubv4" log "github.com/sirupsen/logrus" - "golang.org/x/oauth2" - "gopkg.in/yaml.v3" + "kubevirt.io/community/pkg/contributions" "os" - "strings" - "time" ) -type options struct { +type ContributionReportOptions struct { org string repo string username string @@ -40,7 +35,7 @@ type options struct { months int } -func (o options) validate() error { +func (o ContributionReportOptions) validate() error { if o.username == "" { return fmt.Errorf("username is required") } @@ -50,8 +45,17 @@ func (o options) validate() error { return nil } -func gatherOptions() (options, error) { - o := options{} +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)") @@ -68,109 +72,31 @@ func init() { } func main() { - opts, err := gatherOptions() + contributionReportOptions, err := gatherContributionReportOptions() if err != nil { log.Fatalf("error parsing arguments %v: %v", os.Args[1:], err) } - if err = opts.validate(); err != nil { + if err = contributionReportOptions.validate(); err != nil { log.Fatalf("error validating arguments: %v", err) } - - token, err := os.ReadFile(opts.githubTokenPath) + err = generateReport( + []string{contributionReportOptions.username}, + contributionReportOptions.MakeGeneratorOptions(), + ) if err != nil { - log.Fatalf("failed to use github token path %s: %v", opts.githubTokenPath, err) + log.Fatalf("failed to generate report: %v", err) } - src := oauth2.StaticTokenSource( - &oauth2.Token{AccessToken: strings.TrimSpace(string(token))}, - ) - httpClient := oauth2.NewClient(context.Background(), src) - graphqlClient := githubv4.NewClient(httpClient) - - xMonthsAgo := time.Now().AddDate(0, -1*opts.months, 0) - - if opts.repo != "" { - id, err := getUserId(graphqlClient, opts.username) - if err != nil { - log.Fatalf("failed to query: %v", err) - } - - activity, err := generateUserActivityReportInRepository(graphqlClient, opts.org, opts.repo, opts.username, id, xMonthsAgo) - if err != nil { - log.Fatalf("failed to query: %v", err) - } - tempFile, err := os.CreateTemp("/tmp", fmt.Sprintf("user-activity-%s-%s_%s-*.yaml", opts.username, opts.org, opts.repo)) - if err != nil { - log.Fatal(err) - } - defer tempFile.Close() - encoder := yaml.NewEncoder(tempFile) - err = encoder.Encode(&activity) - if err != nil { - log.Fatal(err) - } - fmt.Printf(`activity log: - user: %s - repository: %s/%s - since: %s - - issues - created: %d - commented: %d - pull requests: - reviewed: %d - created: %d - commented: %d - commits: %d - -user activity log: %q -`, opts.username, opts.org, opts.repo, xMonthsAgo.Format(time.DateTime), - activity.IssuesCreated.IssueCount, - activity.IssuesCommented.IssueCount, - activity.PullRequestsReviewed.IssueCount, - activity.PullRequestsCreated.IssueCount, - activity.PullRequestsCommented.IssueCount, - activity.CommitsByUser.DefaultBranchRef.Target.Fragment.History.TotalCount, - tempFile.Name(), - ) - } else { - id, err := getOrganizationId(graphqlClient, opts.org) - if err != nil { - log.Fatalf("failed to query: %v", err) - } +} - activity, err := generateUserActivityReportInOrganization(graphqlClient, id, opts.username, xMonthsAgo) - if err != nil { - log.Fatalf("failed to query: %v", err) - } - tempFile, err := os.CreateTemp("/tmp", fmt.Sprintf("user-activity-%s-%s-*.yaml", opts.username, opts.org)) - if err != nil { - log.Fatal(err) - } - defer tempFile.Close() - encoder := yaml.NewEncoder(tempFile) - err = encoder.Encode(&activity) - if err != nil { - log.Fatal(err) +func generateReport(userNames []string, opts contributions.ContributionReportGeneratorOptions) error { + generator, err := contributions.NewContributionReportGenerator(opts) + if err != nil { + return fmt.Errorf("failed to create report generator: %v", err) + } + for _, userName := range userNames { + if err := generator.GenerateReport(userName); err != nil { + return err } - fmt.Printf(`activity log: - user: %s - organization: %s - since: %s - - hasContributions: %t - totalIssueContributions: %d - totalPullRequestContributions: %d - totalPullRequestReviewContributions: %d - totalCommitContributions: %d - -user activity log: %q -`, opts.username, opts.org, xMonthsAgo.Format(time.DateTime), - activity.HasAnyContributions, - activity.TotalIssueContributions, - activity.TotalPullRequestContributions, - activity.TotalPullRequestReviewContributions, - activity.TotalCommitContributions, - tempFile.Name(), - ) } + return nil } diff --git a/generators/cmd/contributions/user-activity-in-organization.go b/generators/cmd/contributions/user-activity-in-organization.go deleted file mode 100644 index 74a2eebf..00000000 --- a/generators/cmd/contributions/user-activity-in-organization.go +++ /dev/null @@ -1,148 +0,0 @@ -/* - * 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 ( - "context" - "fmt" - "github.com/shurcooL/githubv4" - "time" -) - -type IssueContributionNodeFragment struct { - URL string `yaml:"URL"` -} - -type IssueContributionNode struct { - Issue IssueContributionNodeFragment `yaml:"issue"` - OccurredAt time.Time `yaml:"occurredAt"` -} - -type IssueContributions struct { - TotalCount int `yaml:"totalCount"` - Nodes []IssueContributionNode `yaml:"nodes"` -} - -type PullRequestContributionNodeFragment struct { - URL string `yaml:"URL"` -} - -type PullRequestContributionNode struct { - PullRequest PullRequestContributionNodeFragment `yaml:"pullRequest"` - OccurredAt time.Time `yaml:"occurredAt"` -} - -type PullRequestContributions struct { - TotalCount int `yaml:"totalCount"` - Nodes []PullRequestContributionNode `yaml:"nodes"` -} - -type PullRequestReviewContributionNodePullRequest struct { - URL string `yaml:"URL"` -} -type PullRequestReviewContributionNodeRepository struct { - NameWithOwner string `yaml:"nameWithOwner"` -} -type PullRequestReviewContributionNodeFragment struct { - Repository PullRequestReviewContributionNodeRepository `yaml:"repository"` - PullRequest PullRequestReviewContributionNodePullRequest `yaml:"pullRequest"` - CreatedAt time.Time `yaml:"createdAt"` - State string `yaml:"state"` -} -type PullRequestReviewContributionNode struct { - PullRequestReview PullRequestReviewContributionNodeFragment `yaml:"pullRequestReview"` -} -type PullRequestReviewContributions struct { - TotalCount int `yaml:"totalCount"` - Nodes []PullRequestReviewContributionNode `yaml:"nodes"` -} - -type CommitContributionsByRepositoryContributionUser struct { - Name string `yaml:"name"` -} -type CommitContributionsByRepositoryContributionRepository struct { - NameWithOwner string `yaml:"nameWithOwner"` -} - -type CommitContributionsByRepositoryContributionsNode struct { - Repository CommitContributionsByRepositoryContributionRepository `yaml:"repository"` - User CommitContributionsByRepositoryContributionUser `yaml:"user"` - OccurredAt time.Time `yaml:"occurredAt"` -} - -type CommitContributionsByRepositoryContributions struct { - TotalCount int `yaml:"totalCount"` - Nodes []CommitContributionsByRepositoryContributionsNode `yaml:"nodes"` -} -type CommitContributionsByRepository struct { - Contributions CommitContributionsByRepositoryContributions `graphql:"contributions(first: 10,orderBy: {field: OCCURRED_AT, direction: DESC})"` -} - -type ContributionsCollection struct { - HasAnyContributions bool `yaml:"hasAnyContributions"` - TotalCommitContributions int `yaml:"totalCommitContributions"` - TotalIssueContributions int `yaml:"totalIssueContributions"` - TotalPullRequestContributions int `yaml:"totalPullRequestContributions"` - TotalPullRequestReviewContributions int `yaml:"totalPullRequestReviewContributions"` - IssueContributions `graphql:"issueContributions(first: 1, orderBy: {direction: DESC})" yaml:"issueContributions"` - PullRequestContributions `graphql:"pullRequestContributions(first: 1, orderBy: {direction: DESC})" yaml:"pullRequestContributions"` - PullRequestReviewContributions `graphql:"pullRequestReviewContributions(first: 1, orderBy: {direction: DESC})" yaml:"pullRequestReviewContributions"` - CommitContributionsByRepository []CommitContributionsByRepository `graphql:"commitContributionsByRepository(maxRepositories: 10)" yaml:"commitContributionsByRepository"` -} - -type UserContributionsInOrg struct { - ContributionsCollection `graphql:"contributionsCollection(organizationID: $organizationID, from: $startFrom)" yaml:"contributionsCollection"` -} - -func generateUserActivityReportInOrganization(client *githubv4.Client, orgId, username string, startFrom time.Time) (*ContributionsCollection, error) { - - var query struct { - UserContributionsInOrg UserContributionsInOrg `graphql:"userContributionsInOrg: user(login: $username)"` - } - - variables := map[string]interface{}{ - "username": githubv4.String(username), - "organizationID": githubv4.ID(orgId), - "startFrom": githubv4.DateTime{Time: startFrom}, - } - - 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 &query.UserContributionsInOrg.ContributionsCollection, nil -} - -func getOrganizationId(client *githubv4.Client, organizationName string) (string, error) { - var query struct { - Organization struct { - ID string - } `graphql:"organization(login: $organizationName)"` - } - variables := map[string]interface{}{ - "organizationName": githubv4.String(organizationName), - } - 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.Organization.ID, nil -} diff --git a/generators/cmd/contributions/user-activity-in-repository.go b/pkg/contributions/types.go similarity index 52% rename from generators/cmd/contributions/user-activity-in-repository.go rename to pkg/contributions/types.go index 495b3496..129007c3 100644 --- a/generators/cmd/contributions/user-activity-in-repository.go +++ b/pkg/contributions/types.go @@ -17,15 +17,50 @@ * */ -package main +package contributions import ( - "context" "fmt" - "github.com/shurcooL/githubv4" "time" ) +type ActivityReport interface { + GenerateActivityLog() string + GenerateLogFileName(userName string) string +} + +type UserActivityReportInOrg struct { + Collection *ContributionsCollection + Org string + UserName string + StartFrom time.Time +} + +func (u *UserActivityReportInOrg) GenerateActivityLog() string { + return fmt.Sprintf(`activity log: + user: %s + organization: %s + since: %s + + hasContributions: %t + totalIssueContributions: %d + totalPullRequestContributions: %d + totalPullRequestReviewContributions: %d + totalCommitContributions: %d + +`, u.UserName, u.Org, u.StartFrom.Format(time.DateTime), + u.Collection.HasAnyContributions, + u.Collection.TotalIssueContributions, + u.Collection.TotalPullRequestContributions, + u.Collection.TotalPullRequestReviewContributions, + u.Collection.TotalCommitContributions, + ) +} + +func (u *UserActivityReportInOrg) GenerateLogFileName(userName string) string { + return fmt.Sprintf("user-activity-%s-%s-*.yaml", userName, u.Org) +} + type UserActivityReportInRepository struct { IssuesCreated IssuesCreated `yaml:"issuesCreated"` IssuesCommented IssuesCommented `yaml:"issuesCommented"` @@ -33,6 +68,39 @@ type UserActivityReportInRepository struct { 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 *UserActivityReportInRepository) GenerateActivityLog() 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 *UserActivityReportInRepository) GenerateLogFileName(userName string) string { + return fmt.Sprintf("user-activity-%s-%s_%s-*.yaml", userName, u.Org, u.Repo) } type Repository struct { @@ -213,88 +281,87 @@ type CommitsByUser struct { DefaultBranchRef CommitsByUserRef `yaml:"defaultBranchRef"` } -func generateUserActivityReportInRepository(client *githubv4.Client, org, repo, username, userid string, startFrom time.Time) (*UserActivityReportInRepository, error) { - - 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 &UserActivityReportInRepository{ - IssuesCreated: query.IssuesCreated, - IssuesCommented: query.IssuesCommented, - PullRequestsCreated: query.PullRequestsCreated, - PullRequestsReviewed: query.PullRequestsReviewed, - PullRequestsCommented: query.PullRequestsCommented, - CommitsByUser: query.CommitsByUser, - }, 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 +type IssueContributionNodeFragment struct { + URL string `yaml:"URL"` +} + +type IssueContributionNode struct { + Issue IssueContributionNodeFragment `yaml:"issue"` + OccurredAt time.Time `yaml:"occurredAt"` +} + +type IssueContributions struct { + TotalCount int `yaml:"totalCount"` + Nodes []IssueContributionNode `yaml:"nodes"` +} + +type PullRequestContributionNodeFragment struct { + URL string `yaml:"URL"` +} + +type PullRequestContributionNode struct { + PullRequest PullRequestContributionNodeFragment `yaml:"pullRequest"` + OccurredAt time.Time `yaml:"occurredAt"` +} + +type PullRequestContributions struct { + TotalCount int `yaml:"totalCount"` + Nodes []PullRequestContributionNode `yaml:"nodes"` +} + +type PullRequestReviewContributionNodePullRequest struct { + URL string `yaml:"URL"` +} +type PullRequestReviewContributionNodeRepository struct { + NameWithOwner string `yaml:"nameWithOwner"` +} +type PullRequestReviewContributionNodeFragment struct { + Repository PullRequestReviewContributionNodeRepository `yaml:"repository"` + PullRequest PullRequestReviewContributionNodePullRequest `yaml:"pullRequest"` + CreatedAt time.Time `yaml:"createdAt"` + State string `yaml:"state"` +} +type PullRequestReviewContributionNode struct { + PullRequestReview PullRequestReviewContributionNodeFragment `yaml:"pullRequestReview"` +} +type PullRequestReviewContributions struct { + TotalCount int `yaml:"totalCount"` + Nodes []PullRequestReviewContributionNode `yaml:"nodes"` +} + +type CommitContributionsByRepositoryContributionUser struct { + Name string `yaml:"name"` +} +type CommitContributionsByRepositoryContributionRepository struct { + NameWithOwner string `yaml:"nameWithOwner"` +} + +type CommitContributionsByRepositoryContributionsNode struct { + Repository CommitContributionsByRepositoryContributionRepository `yaml:"repository"` + User CommitContributionsByRepositoryContributionUser `yaml:"user"` + OccurredAt time.Time `yaml:"occurredAt"` +} + +type CommitContributionsByRepositoryContributions struct { + TotalCount int `yaml:"totalCount"` + Nodes []CommitContributionsByRepositoryContributionsNode `yaml:"nodes"` +} +type CommitContributionsByRepository struct { + Contributions CommitContributionsByRepositoryContributions `graphql:"contributions(first: 10,orderBy: {field: OCCURRED_AT, direction: DESC})"` +} + +type ContributionsCollection struct { + HasAnyContributions bool `yaml:"hasAnyContributions"` + TotalCommitContributions int `yaml:"totalCommitContributions"` + TotalIssueContributions int `yaml:"totalIssueContributions"` + TotalPullRequestContributions int `yaml:"totalPullRequestContributions"` + TotalPullRequestReviewContributions int `yaml:"totalPullRequestReviewContributions"` + IssueContributions `graphql:"issueContributions(first: 1, orderBy: {direction: DESC})" yaml:"issueContributions"` + PullRequestContributions `graphql:"pullRequestContributions(first: 1, orderBy: {direction: DESC})" yaml:"pullRequestContributions"` + PullRequestReviewContributions `graphql:"pullRequestReviewContributions(first: 1, orderBy: {direction: DESC})" yaml:"pullRequestReviewContributions"` + CommitContributionsByRepository []CommitContributionsByRepository `graphql:"commitContributionsByRepository(maxRepositories: 10)" yaml:"commitContributionsByRepository"` +} + +type UserContributionsInOrg struct { + ContributionsCollection `graphql:"contributionsCollection(organizationID: $organizationID, from: $startFrom)" yaml:"contributionsCollection"` } diff --git a/pkg/contributions/user-activity.go b/pkg/contributions/user-activity.go new file mode 100644 index 00000000..ad2c3212 --- /dev/null +++ b/pkg/contributions/user-activity.go @@ -0,0 +1,240 @@ +/* + * 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/shurcooL/githubv4" + "golang.org/x/oauth2" + "gopkg.in/yaml.v3" + "os" + "strings" + "time" +) + +type ContributionReportGenerator struct { + client *githubv4.Client + opts ContributionReportGeneratorOptions +} + +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) error { + var activity ActivityReport + var err error + if g.opts.Repo != "" { + activity, err = generateUserActivityReportInRepository(g.client, g.opts.Org, g.opts.Repo, userName, g.opts.startFrom()) + } else { + activity, err = generateUserActivityReportInOrganization(g.client, g.opts.Org, userName, g.opts.startFrom()) + } + if err != nil { + return fmt.Errorf("failed to query: %v", err) + } + fmt.Printf(activity.GenerateActivityLog()) + err = writeActivityToFile(activity, "/tmp", activity.GenerateLogFileName(userName)) + if err != nil { + return fmt.Errorf("failed to write file: %v", err) + } + return 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 generateUserActivityReportInRepository(client *githubv4.Client, org, repo, username string, startFrom time.Time) (*UserActivityReportInRepository, 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 &UserActivityReportInRepository{ + 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 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 generateUserActivityReportInOrganization(client *githubv4.Client, org, username string, startFrom time.Time) (*UserActivityReportInOrg, error) { + organizationId, err := getOrganizationId(client, org) + if err != nil { + return nil, err + } + + var query struct { + UserContributionsInOrg UserContributionsInOrg `graphql:"userContributionsInOrg: user(login: $username)"` + } + + variables := map[string]interface{}{ + "username": githubv4.String(username), + "organizationID": githubv4.ID(organizationId), + "startFrom": githubv4.DateTime{Time: startFrom}, + } + + 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) + } + + collection := &query.UserContributionsInOrg.ContributionsCollection + result := &UserActivityReportInOrg{ + Collection: collection, + Org: org, + UserName: username, + StartFrom: startFrom, + } + return result, 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 + } + fmt.Printf(`user activity log: %q`, tempFile.Name()) + return nil +} + +func getOrganizationId(client *githubv4.Client, organizationName string) (string, error) { + var query struct { + Organization struct { + ID string + } `graphql:"organization(login: $organizationName)"` + } + variables := map[string]interface{}{ + "organizationName": githubv4.String(organizationName), + } + 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.Organization.ID, nil +} From 5243ea364cf818d292ab0473d4e7235f411bb7d0 Mon Sep 17 00:00:00 2001 From: Daniel Hiller Date: Wed, 4 Dec 2024 14:37:28 +0100 Subject: [PATCH 3/8] contributions: add orgs.yaml parsing We add parsing of orgs.yaml and check members from the org, whether they have any contributions. Signed-off-by: Daniel Hiller --- generators/cmd/contributions/main.go | 84 +++++++--- pkg/contributions/types.go | 232 ++++++++++++++------------- pkg/contributions/user-activity.go | 135 +++++++++------- 3 files changed, 266 insertions(+), 185 deletions(-) diff --git a/generators/cmd/contributions/main.go b/generators/cmd/contributions/main.go index 81c9161d..b3113b27 100644 --- a/generators/cmd/contributions/main.go +++ b/generators/cmd/contributions/main.go @@ -24,20 +24,24 @@ import ( "fmt" log "github.com/sirupsen/logrus" "kubevirt.io/community/pkg/contributions" + "kubevirt.io/community/pkg/orgs" "os" ) type ContributionReportOptions struct { - org string - repo string - username string - githubTokenPath string - months int + org string + repo string + username string + githubTokenPath string + months int + orgsConfigFilePath string } func (o ContributionReportOptions) validate() error { - if o.username == "" { - return fmt.Errorf("username is required") + if o.username != "" { + log.Infof("creating report for user %q", o.username) + } else if o.orgsConfigFilePath == "" { + return fmt.Errorf("username or orgs-config-file-path is required") } if o.githubTokenPath == "" { return fmt.Errorf("github token path is required") @@ -45,7 +49,7 @@ func (o ContributionReportOptions) validate() error { return nil } -func (o ContributionReportOptions) MakeGeneratorOptions() contributions.ContributionReportGeneratorOptions { +func (o ContributionReportOptions) makeGeneratorOptions() contributions.ContributionReportGeneratorOptions { return contributions.ContributionReportGeneratorOptions{ Org: o.org, Repo: o.repo, @@ -62,6 +66,7 @@ func gatherContributionReportOptions() (ContributionReportOptions, error) { 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") err := fs.Parse(os.Args[1:]) return o, err } @@ -79,24 +84,63 @@ func main() { if err = contributionReportOptions.validate(); err != nil { log.Fatalf("error validating arguments: %v", err) } - err = generateReport( - []string{contributionReportOptions.username}, - contributionReportOptions.MakeGeneratorOptions(), - ) + + generator, err := contributions.NewContributionReportGenerator(contributionReportOptions.makeGeneratorOptions()) if err != nil { - log.Fatalf("failed to generate report: %v", err) + log.Fatalf("failed to create report generator: %v", err) } -} -func generateReport(userNames []string, opts contributions.ContributionReportGeneratorOptions) error { - generator, err := contributions.NewContributionReportGenerator(opts) - if err != nil { - return fmt.Errorf("failed to create report generator: %v", err) + var reporter Reporter = DefaultReporter{} + userNames := []string{contributionReportOptions.username} + if contributionReportOptions.username == "" && contributionReportOptions.orgsConfigFilePath != "" { + orgsYAML, err := orgs.ReadFile(contributionReportOptions.orgsConfigFilePath) + if err != nil { + log.Fatalf("invalid arguments: %v", err) + } + userNames = orgsYAML.Orgs[contributionReportOptions.org].Members + reporter = InactiveReporter{} } + for _, userName := range userNames { - if err := generator.GenerateReport(userName); err != nil { - return err + 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) } } +} + +type Reporter interface { + Report(r contributions.ActivityReport, userName string) error +} + +type DefaultReporter struct{} + +func (d DefaultReporter) Report(r contributions.ActivityReport, userName string) error { + fmt.Printf(r.GenerateActivityLog()) + _, err := r.WriteToFile("/tmp", userName) + if err != nil { + return fmt.Errorf("failed to write file: %v", err) + } + return nil +} + +type InactiveReporter struct { + inactiveUsers []string +} + +func (d InactiveReporter) Report(r contributions.ActivityReport, userName string) error { + if r.HasActivity() { + return nil + } + fmt.Printf(r.GenerateActivityLog()) + _, err := r.WriteToFile("/tmp", userName) + if err != nil { + return fmt.Errorf("failed to write file: %v", err) + } + d.inactiveUsers = append(d.inactiveUsers, userName) return nil } diff --git a/pkg/contributions/types.go b/pkg/contributions/types.go index 129007c3..c4632ee7 100644 --- a/pkg/contributions/types.go +++ b/pkg/contributions/types.go @@ -21,86 +21,142 @@ package contributions import ( "fmt" + "path/filepath" "time" ) type ActivityReport interface { GenerateActivityLog() string GenerateLogFileName(userName string) string + HasActivity() bool + WriteToFile(dir, userName string) (string, error) } -type UserActivityReportInOrg struct { - Collection *ContributionsCollection - Org string - UserName string - StartFrom time.Time +type UserActivityReportInRepository 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 *UserActivityReportInOrg) GenerateActivityLog() string { +func (u *UserActivityReportInRepository) GenerateActivityLog() string { return fmt.Sprintf(`activity log: - user: %s - organization: %s - since: %s + 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, + ) +} - hasContributions: %t - totalIssueContributions: %d - totalPullRequestContributions: %d - totalPullRequestReviewContributions: %d - totalCommitContributions: %d +func (u *UserActivityReportInRepository) GenerateLogFileName(userName string) string { + return fmt.Sprintf("user-activity-%s-%s_%s-*.yaml", userName, u.Org, u.Repo) +} -`, u.UserName, u.Org, u.StartFrom.Format(time.DateTime), - u.Collection.HasAnyContributions, - u.Collection.TotalIssueContributions, - u.Collection.TotalPullRequestContributions, - u.Collection.TotalPullRequestReviewContributions, - u.Collection.TotalCommitContributions, - ) +func (u *UserActivityReportInRepository) HasActivity() 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 *UserActivityReportInOrg) GenerateLogFileName(userName string) string { - return fmt.Sprintf("user-activity-%s-%s-*.yaml", userName, u.Org) +func (u *UserActivityReportInRepository) WriteToFile(dir string, userName string) (string, error) { + logFileName := u.GenerateLogFileName(userName) + err := writeActivityToFile(u, dir, logFileName) + if err != nil { + return "", err + } + return filepath.Join(dir, logFileName), nil } -type UserActivityReportInRepository struct { +type UserActivityReportInOrg2 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"` + CommitsByUserInOrg CommitsByUserInOrg `yaml:"commitsByUserInOrg"` Org string - Repo string UserName string UserID string StartFrom time.Time } -func (u *UserActivityReportInRepository) GenerateActivityLog() string { +func (u *UserActivityReportInOrg2) GenerateActivityLog() 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), + 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.CommitsByUser.DefaultBranchRef.Target.Fragment.History.TotalCount, + u.totalCommitCount(), ) } -func (u *UserActivityReportInRepository) GenerateLogFileName(userName string) string { - return fmt.Sprintf("user-activity-%s-%s_%s-*.yaml", userName, u.Org, u.Repo) +func (u *UserActivityReportInOrg2) totalCommitCount() int { + totalCommitCount := 0 + for _, node := range u.CommitsByUserInOrg.Repositories.Nodes { + totalCommitCount += node.DefaultBranchRef.Target.Fragment.History.TotalCount + } + return totalCommitCount +} + +func (u *UserActivityReportInOrg2) GenerateLogFileName(userName string) string { + return fmt.Sprintf("user-activity-%s-%s-*.yaml", userName, u.Org) +} + +func (u *UserActivityReportInOrg2) HasActivity() 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 *UserActivityReportInOrg2) WriteToFile(dir, userName string) (string, error) { + logFileName := u.GenerateLogFileName(userName) + err := writeActivityToFile(u, dir, logFileName) + if err != nil { + return "", err + } + return filepath.Join(dir, logFileName), nil } type Repository struct { @@ -281,87 +337,47 @@ type CommitsByUser struct { DefaultBranchRef CommitsByUserRef `yaml:"defaultBranchRef"` } -type IssueContributionNodeFragment struct { - URL string `yaml:"URL"` -} - -type IssueContributionNode struct { - Issue IssueContributionNodeFragment `yaml:"issue"` - OccurredAt time.Time `yaml:"occurredAt"` -} - -type IssueContributions struct { - TotalCount int `yaml:"totalCount"` - Nodes []IssueContributionNode `yaml:"nodes"` +type RepositoryNodeRefTargetHistoryNodeAuthorUser struct { + Name string `yaml:"name"` } -type PullRequestContributionNodeFragment struct { - URL string `yaml:"URL"` +type RepositoryNodeRefTargetHistoryNodeAuthor struct { + User RepositoryNodeRefTargetHistoryNodeAuthorUser `yaml:"user"` } -type PullRequestContributionNode struct { - PullRequest PullRequestContributionNodeFragment `yaml:"pullRequest"` - OccurredAt time.Time `yaml:"occurredAt"` +type RepositoryNodeRefTargetHistoryNode struct { + URL string `yaml:"URL"` + CommittedDate time.Time `yaml:"committedDate"` + Author RepositoryNodeRefTargetHistoryNodeAuthor `yaml:"author"` } -type PullRequestContributions struct { - TotalCount int `yaml:"totalCount"` - Nodes []PullRequestContributionNode `yaml:"nodes"` +type RepositoryNodeRefTargetHistory struct { + TotalCount int `yaml:"totalCount"` + Nodes []RepositoryNodeRefTargetHistoryNode `yaml:"nodes"` } -type PullRequestReviewContributionNodePullRequest struct { - URL string `yaml:"URL"` -} -type PullRequestReviewContributionNodeRepository struct { - NameWithOwner string `yaml:"nameWithOwner"` -} -type PullRequestReviewContributionNodeFragment struct { - Repository PullRequestReviewContributionNodeRepository `yaml:"repository"` - PullRequest PullRequestReviewContributionNodePullRequest `yaml:"pullRequest"` - CreatedAt time.Time `yaml:"createdAt"` - State string `yaml:"state"` -} -type PullRequestReviewContributionNode struct { - PullRequestReview PullRequestReviewContributionNodeFragment `yaml:"pullRequestReview"` -} -type PullRequestReviewContributions struct { - TotalCount int `yaml:"totalCount"` - Nodes []PullRequestReviewContributionNode `yaml:"nodes"` +type RepositoryNodeRefTargetFragment struct { + CommitURL string `yaml:"commitURL"` + History RepositoryNodeRefTargetHistory `graphql:"history(first: 3, author: {id: $userID}, since: $startFrom)" yaml:"history"` } -type CommitContributionsByRepositoryContributionUser struct { - Name string `yaml:"name"` -} -type CommitContributionsByRepositoryContributionRepository struct { - NameWithOwner string `yaml:"nameWithOwner"` +type RepositoryNodeRefTargetItem struct { + Fragment RepositoryNodeRefTargetFragment `graphql:"... on Commit" yaml:"fragment"` } -type CommitContributionsByRepositoryContributionsNode struct { - Repository CommitContributionsByRepositoryContributionRepository `yaml:"repository"` - User CommitContributionsByRepositoryContributionUser `yaml:"user"` - OccurredAt time.Time `yaml:"occurredAt"` +type RepositoryNodeRef struct { + Target RepositoryNodeRefTargetItem `yaml:"target"` } -type CommitContributionsByRepositoryContributions struct { - TotalCount int `yaml:"totalCount"` - Nodes []CommitContributionsByRepositoryContributionsNode `yaml:"nodes"` -} -type CommitContributionsByRepository struct { - Contributions CommitContributionsByRepositoryContributions `graphql:"contributions(first: 10,orderBy: {field: OCCURRED_AT, direction: DESC})"` +type RepositoryNode struct { + Name string `yaml:"name"` + DefaultBranchRef RepositoryNodeRef `yaml:"defaultBranchRef"` } -type ContributionsCollection struct { - HasAnyContributions bool `yaml:"hasAnyContributions"` - TotalCommitContributions int `yaml:"totalCommitContributions"` - TotalIssueContributions int `yaml:"totalIssueContributions"` - TotalPullRequestContributions int `yaml:"totalPullRequestContributions"` - TotalPullRequestReviewContributions int `yaml:"totalPullRequestReviewContributions"` - IssueContributions `graphql:"issueContributions(first: 1, orderBy: {direction: DESC})" yaml:"issueContributions"` - PullRequestContributions `graphql:"pullRequestContributions(first: 1, orderBy: {direction: DESC})" yaml:"pullRequestContributions"` - PullRequestReviewContributions `graphql:"pullRequestReviewContributions(first: 1, orderBy: {direction: DESC})" yaml:"pullRequestReviewContributions"` - CommitContributionsByRepository []CommitContributionsByRepository `graphql:"commitContributionsByRepository(maxRepositories: 10)" yaml:"commitContributionsByRepository"` +type Repositories struct { + Nodes []RepositoryNode `yaml:"nodes"` } -type UserContributionsInOrg struct { - ContributionsCollection `graphql:"contributionsCollection(organizationID: $organizationID, from: $startFrom)" yaml:"contributionsCollection"` +type CommitsByUserInOrg struct { + Repositories Repositories `graphql:"repositories(first: 25, isArchived: false, visibility: PUBLIC)" yaml:"repositories"` } diff --git a/pkg/contributions/user-activity.go b/pkg/contributions/user-activity.go index ad2c3212..1b350617 100644 --- a/pkg/contributions/user-activity.go +++ b/pkg/contributions/user-activity.go @@ -31,8 +31,9 @@ import ( ) type ContributionReportGenerator struct { - client *githubv4.Client - opts ContributionReportGeneratorOptions + client *githubv4.Client + opts ContributionReportGeneratorOptions + ReportingMode interface{} } func NewContributionReportGenerator(opts ContributionReportGeneratorOptions) (*ContributionReportGenerator, error) { @@ -48,23 +49,18 @@ func NewContributionReportGenerator(opts ContributionReportGeneratorOptions) (*C return &ContributionReportGenerator{client: client, opts: opts}, nil } -func (g ContributionReportGenerator) GenerateReport(userName string) error { +func (g ContributionReportGenerator) GenerateReport(userName string) (ActivityReport, error) { var activity ActivityReport var err error if g.opts.Repo != "" { activity, err = generateUserActivityReportInRepository(g.client, g.opts.Org, g.opts.Repo, userName, g.opts.startFrom()) } else { - activity, err = generateUserActivityReportInOrganization(g.client, g.opts.Org, userName, g.opts.startFrom()) + activity, err = generateUserActivityReportInOrg2(g.client, g.opts.Org, userName, g.opts.startFrom()) } if err != nil { - return fmt.Errorf("failed to query: %v", err) - } - fmt.Printf(activity.GenerateActivityLog()) - err = writeActivityToFile(activity, "/tmp", activity.GenerateLogFileName(userName)) - if err != nil { - return fmt.Errorf("failed to write file: %v", err) + return nil, fmt.Errorf("failed to query: %v", err) } - return nil + return activity, nil } type ContributionReportGeneratorOptions struct { @@ -164,51 +160,92 @@ func generateUserActivityReportInRepository(client *githubv4.Client, org, repo, }, 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 generateUserActivityReportInOrganization(client *githubv4.Client, org, username string, startFrom time.Time) (*UserActivityReportInOrg, error) { - organizationId, err := getOrganizationId(client, org) +func generateUserActivityReportInOrg2(client *githubv4.Client, org, username string, startFrom time.Time) (*UserActivityReportInOrg2, error) { + userid, err := getUserId(client, username) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to query: %v", err) } var query struct { - UserContributionsInOrg UserContributionsInOrg `graphql:"userContributionsInOrg: user(login: $username)"` + 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{}{ - "username": githubv4.String(username), - "organizationID": githubv4.ID(organizationId), - "startFrom": githubv4.DateTime{Time: startFrom}, + "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 &UserActivityReportInOrg2{ + 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 +} - collection := &query.UserContributionsInOrg.ContributionsCollection - result := &UserActivityReportInOrg{ - Collection: collection, - Org: org, - UserName: username, - StartFrom: startFrom, +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 result, nil + return query.User.ID, nil } func writeActivityToFile(yamlObject interface{}, dir, fileName string) error { @@ -222,19 +259,3 @@ func writeActivityToFile(yamlObject interface{}, dir, fileName string) error { fmt.Printf(`user activity log: %q`, tempFile.Name()) return nil } - -func getOrganizationId(client *githubv4.Client, organizationName string) (string, error) { - var query struct { - Organization struct { - ID string - } `graphql:"organization(login: $organizationName)"` - } - variables := map[string]interface{}{ - "organizationName": githubv4.String(organizationName), - } - 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.Organization.ID, nil -} From c526307880b20ac9dda229b0740a35bd20eec8c2 Mon Sep 17 00:00:00 2001 From: Daniel Hiller Date: Wed, 4 Dec 2024 15:41:00 +0100 Subject: [PATCH 4/8] contributions: add OWNERS file support Signed-off-by: Daniel Hiller --- generators/cmd/contributions/main.go | 59 +++++++++++++++++++++++----- pkg/owners/file.go | 40 +++++++++++++++++++ pkg/owners/types.go | 26 ++++++++++++ 3 files changed, 115 insertions(+), 10 deletions(-) create mode 100644 pkg/owners/file.go create mode 100644 pkg/owners/types.go diff --git a/generators/cmd/contributions/main.go b/generators/cmd/contributions/main.go index b3113b27..af5e62e8 100644 --- a/generators/cmd/contributions/main.go +++ b/generators/cmd/contributions/main.go @@ -23,9 +23,12 @@ import ( "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" + "sort" ) type ContributionReportOptions struct { @@ -35,13 +38,15 @@ type ContributionReportOptions struct { githubTokenPath string months int orgsConfigFilePath string + ownersFilePath string + reportAll bool } func (o ContributionReportOptions) validate() error { if o.username != "" { log.Infof("creating report for user %q", o.username) - } else if o.orgsConfigFilePath == "" { - return fmt.Errorf("username or orgs-config-file-path is required") + } 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") @@ -67,6 +72,8 @@ func gatherContributionReportOptions() (ContributionReportOptions, error) { 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") err := fs.Parse(os.Args[1:]) return o, err } @@ -92,13 +99,28 @@ func main() { var reporter Reporter = DefaultReporter{} userNames := []string{contributionReportOptions.username} - if contributionReportOptions.username == "" && contributionReportOptions.orgsConfigFilePath != "" { - orgsYAML, err := orgs.ReadFile(contributionReportOptions.orgsConfigFilePath) - if err != nil { - log.Fatalf("invalid arguments: %v", err) + if contributionReportOptions.username == "" { + if contributionReportOptions.ownersFilePath != "" { + ownersYAML, err := owners.ReadFile(contributionReportOptions.ownersFilePath) + if err != nil { + log.Fatalf("invalid arguments: %v", err) + } + userNames = ownersYAML.Reviewers + userNames = append(userNames, ownersYAML.Approvers...) + sort.Strings(userNames) + if !contributionReportOptions.reportAll { + reporter = InactiveOnlyReporter{} + } + } 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 + if !contributionReportOptions.reportAll { + reporter = InactiveOnlyReporter{} + } } - userNames = orgsYAML.Orgs[contributionReportOptions.org].Members - reporter = InactiveReporter{} } for _, userName := range userNames { @@ -111,10 +133,12 @@ func main() { log.Fatalf("failed to report: %v", err) } } + fmt.Printf(reporter.Summary()) } type Reporter interface { Report(r contributions.ActivityReport, userName string) error + Summary() string } type DefaultReporter struct{} @@ -128,14 +152,20 @@ func (d DefaultReporter) Report(r contributions.ActivityReport, userName string) return nil } -type InactiveReporter struct { +func (d DefaultReporter) Summary() string { + return "" +} + +type InactiveOnlyReporter struct { inactiveUsers []string } -func (d InactiveReporter) Report(r contributions.ActivityReport, userName string) error { +func (d InactiveOnlyReporter) Report(r contributions.ActivityReport, userName string) error { if r.HasActivity() { + log.Debugf("active user: %s", userName) return nil } + log.Infof("inactive user: %s", userName) fmt.Printf(r.GenerateActivityLog()) _, err := r.WriteToFile("/tmp", userName) if err != nil { @@ -144,3 +174,12 @@ func (d InactiveReporter) Report(r contributions.ActivityReport, userName string d.inactiveUsers = append(d.inactiveUsers, userName) return nil } + +func (d InactiveOnlyReporter) Summary() string { + out, err := yaml.Marshal(d.inactiveUsers) + if err != nil { + log.Fatalf("failed to serialize: %v", err) + } + return fmt.Sprintf(`inactive users: +%s`, string(out)) +} diff --git a/pkg/owners/file.go b/pkg/owners/file.go new file mode 100644 index 00000000..a9c4d63c --- /dev/null +++ b/pkg/owners/file.go @@ -0,0 +1,40 @@ +/* + * 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 +} diff --git a/pkg/owners/types.go b/pkg/owners/types.go new file mode 100644 index 00000000..43ef7da6 --- /dev/null +++ b/pkg/owners/types.go @@ -0,0 +1,26 @@ +/* + * 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"` +} From 351a015fddc3096c8989a4b76f1f79ca711bbbbf Mon Sep 17 00:00:00 2001 From: Daniel Hiller Date: Thu, 5 Dec 2024 11:09:32 +0100 Subject: [PATCH 5/8] support for skipping per org and repo Further: * refactorings/renamings * more detailed reporting * support of a report output file in yaml Signed-off-by: Daniel Hiller --- .../cmd/contributions/default-config.yaml | 33 +++ generators/cmd/contributions/main.go | 194 ++++++++++-------- generators/cmd/contributions/report.go | 137 +++++++++++++ pkg/contributions/types.go | 34 +-- ...{user-activity.go => user-contribution.go} | 17 +- 5 files changed, 305 insertions(+), 110 deletions(-) create mode 100644 generators/cmd/contributions/default-config.yaml create mode 100644 generators/cmd/contributions/report.go rename pkg/contributions/{user-activity.go => user-contribution.go} (92%) diff --git a/generators/cmd/contributions/default-config.yaml b/generators/cmd/contributions/default-config.yaml new file mode 100644 index 00000000..88b552d2 --- /dev/null +++ b/generators/cmd/contributions/default-config.yaml @@ -0,0 +1,33 @@ +skipInactive: + kubevirt: + # bots (kubevirt, openshift, the linux foundation) + - name: "bots" + github: + - kubevirt-bot + - kubevirt-commenter-bot + - kubevirt-snyk + - openshift-ci-robot + - openshift-merge-robot + - thelinuxfoundation + + # KubeVirt org admins (security measure so that we don't lose GitHub org access) + - name: "orgAdmins" + github: + - brianmcarey + - davidvossel + - dhiller + - fabiand + - rmohr + + # users with invisible contributions (i.e. OSPO, KubeVirt community manager etc) + - name: "invisibleContributions" + github: + - aburdenthehand + - jberkus + + kubevirt/community: + - name: "communityAdmin" + github: + - aburdenthehand + - jberkus + diff --git a/generators/cmd/contributions/main.go b/generators/cmd/contributions/main.go index af5e62e8..fb044c49 100644 --- a/generators/cmd/contributions/main.go +++ b/generators/cmd/contributions/main.go @@ -20,6 +20,7 @@ package main import ( + _ "embed" "flag" "fmt" log "github.com/sirupsen/logrus" @@ -29,26 +30,65 @@ import ( "kubevirt.io/community/pkg/owners" "os" "sort" + "strings" ) type ContributionReportOptions struct { - org string - repo string - username string - githubTokenPath string - months int - orgsConfigFilePath string - ownersFilePath string - reportAll bool + 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"` } +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 == "" { + 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 == "" { + if o.GithubTokenPath == "" { return fmt.Errorf("github token path is required") } return nil @@ -56,31 +96,37 @@ func (o ContributionReportOptions) validate() error { func (o ContributionReportOptions) makeGeneratorOptions() contributions.ContributionReportGeneratorOptions { return contributions.ContributionReportGeneratorOptions{ - Org: o.org, - Repo: o.repo, - GithubTokenPath: o.githubTokenPath, - Months: o.months, + Org: o.Org, + Repo: o.Repo, + GithubTokenPath: o.GithubTokenPath, + Months: o.Months, } } -func gatherContributionReportOptions() (ContributionReportOptions, error) { +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.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") err := fs.Parse(os.Args[1:]) - return o, err + 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() { @@ -97,33 +143,35 @@ func main() { log.Fatalf("failed to create report generator: %v", err) } - var reporter Reporter = DefaultReporter{} - userNames := []string{contributionReportOptions.username} - if contributionReportOptions.username == "" { - if contributionReportOptions.ownersFilePath != "" { - ownersYAML, err := owners.ReadFile(contributionReportOptions.ownersFilePath) + 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) } - userNames = ownersYAML.Reviewers - userNames = append(userNames, ownersYAML.Approvers...) + userNames = uniq(ownersYAML.Reviewers, ownersYAML.Approvers) sort.Strings(userNames) - if !contributionReportOptions.reportAll { - reporter = InactiveOnlyReporter{} - } - } else if contributionReportOptions.orgsConfigFilePath != "" { - orgsYAML, err := orgs.ReadFile(contributionReportOptions.orgsConfigFilePath) + } 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 - if !contributionReportOptions.reportAll { - reporter = InactiveOnlyReporter{} - } + userNames = orgsYAML.Orgs[contributionReportOptions.Org].Members } } for _, userName := range userNames { + 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) @@ -134,52 +182,28 @@ func main() { } } fmt.Printf(reporter.Summary()) -} - -type Reporter interface { - Report(r contributions.ActivityReport, userName string) error - Summary() string -} - -type DefaultReporter struct{} - -func (d DefaultReporter) Report(r contributions.ActivityReport, userName string) error { - fmt.Printf(r.GenerateActivityLog()) - _, err := r.WriteToFile("/tmp", userName) - if err != nil { - return fmt.Errorf("failed to write file: %v", err) + 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) + } } - return nil } -func (d DefaultReporter) Summary() string { - return "" -} - -type InactiveOnlyReporter struct { - inactiveUsers []string -} - -func (d InactiveOnlyReporter) Report(r contributions.ActivityReport, userName string) error { - if r.HasActivity() { - log.Debugf("active user: %s", userName) - return nil - } - log.Infof("inactive user: %s", userName) - fmt.Printf(r.GenerateActivityLog()) - _, err := r.WriteToFile("/tmp", userName) - if err != nil { - return fmt.Errorf("failed to write file: %v", err) +func uniq(elements ...[]string) []string { + uniqMap := make(map[string]struct{}) + for _, values := range elements { + for _, value := range values { + uniqMap[value] = struct{}{} + } } - d.inactiveUsers = append(d.inactiveUsers, userName) - return nil -} - -func (d InactiveOnlyReporter) Summary() string { - out, err := yaml.Marshal(d.inactiveUsers) - if err != nil { - log.Fatalf("failed to serialize: %v", err) + var uniqueValues []string + for uniqueValue := range uniqMap { + uniqueValues = append(uniqueValues, uniqueValue) } - return fmt.Sprintf(`inactive users: -%s`, string(out)) + 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/pkg/contributions/types.go b/pkg/contributions/types.go index c4632ee7..ad1a59ac 100644 --- a/pkg/contributions/types.go +++ b/pkg/contributions/types.go @@ -25,14 +25,14 @@ import ( "time" ) -type ActivityReport interface { - GenerateActivityLog() string - GenerateLogFileName(userName string) string - HasActivity() bool +type ContributionReport interface { + Summary() string + ReportFileName(userName string) string + HasContributions() bool WriteToFile(dir, userName string) (string, error) } -type UserActivityReportInRepository struct { +type UserContributionReportForRepository struct { IssuesCreated IssuesCreated `yaml:"issuesCreated"` IssuesCommented IssuesCommented `yaml:"issuesCommented"` PullRequestsCreated PullRequestsCreated `yaml:"pullRequestsCreated"` @@ -46,7 +46,7 @@ type UserActivityReportInRepository struct { StartFrom time.Time } -func (u *UserActivityReportInRepository) GenerateActivityLog() string { +func (u *UserContributionReportForRepository) Summary() string { return fmt.Sprintf(`activity log: user: %s repository: %s/%s @@ -70,11 +70,11 @@ func (u *UserActivityReportInRepository) GenerateActivityLog() string { ) } -func (u *UserActivityReportInRepository) GenerateLogFileName(userName string) string { +func (u *UserContributionReportForRepository) ReportFileName(userName string) string { return fmt.Sprintf("user-activity-%s-%s_%s-*.yaml", userName, u.Org, u.Repo) } -func (u *UserActivityReportInRepository) HasActivity() bool { +func (u *UserContributionReportForRepository) HasContributions() bool { return u.IssuesCreated.IssueCount > 0 || u.IssuesCommented.IssueCount > 0 || u.PullRequestsReviewed.IssueCount > 0 || @@ -83,8 +83,8 @@ func (u *UserActivityReportInRepository) HasActivity() bool { u.CommitsByUser.DefaultBranchRef.Target.Fragment.History.TotalCount > 0 } -func (u *UserActivityReportInRepository) WriteToFile(dir string, userName string) (string, error) { - logFileName := u.GenerateLogFileName(userName) +func (u *UserContributionReportForRepository) WriteToFile(dir string, userName string) (string, error) { + logFileName := u.ReportFileName(userName) err := writeActivityToFile(u, dir, logFileName) if err != nil { return "", err @@ -92,7 +92,7 @@ func (u *UserActivityReportInRepository) WriteToFile(dir string, userName string return filepath.Join(dir, logFileName), nil } -type UserActivityReportInOrg2 struct { +type UserContributionReportForOrganization struct { IssuesCreated IssuesCreated `yaml:"issuesCreated"` IssuesCommented IssuesCommented `yaml:"issuesCommented"` PullRequestsCreated PullRequestsCreated `yaml:"pullRequestsCreated"` @@ -105,7 +105,7 @@ type UserActivityReportInOrg2 struct { StartFrom time.Time } -func (u *UserActivityReportInOrg2) GenerateActivityLog() string { +func (u *UserContributionReportForOrganization) Summary() string { return fmt.Sprintf(`activity log: user: %s org: %s @@ -129,7 +129,7 @@ func (u *UserActivityReportInOrg2) GenerateActivityLog() string { ) } -func (u *UserActivityReportInOrg2) totalCommitCount() int { +func (u *UserContributionReportForOrganization) totalCommitCount() int { totalCommitCount := 0 for _, node := range u.CommitsByUserInOrg.Repositories.Nodes { totalCommitCount += node.DefaultBranchRef.Target.Fragment.History.TotalCount @@ -137,11 +137,11 @@ func (u *UserActivityReportInOrg2) totalCommitCount() int { return totalCommitCount } -func (u *UserActivityReportInOrg2) GenerateLogFileName(userName string) string { +func (u *UserContributionReportForOrganization) ReportFileName(userName string) string { return fmt.Sprintf("user-activity-%s-%s-*.yaml", userName, u.Org) } -func (u *UserActivityReportInOrg2) HasActivity() bool { +func (u *UserContributionReportForOrganization) HasContributions() bool { return u.IssuesCreated.IssueCount > 0 || u.IssuesCommented.IssueCount > 0 || u.PullRequestsReviewed.IssueCount > 0 || @@ -150,8 +150,8 @@ func (u *UserActivityReportInOrg2) HasActivity() bool { u.totalCommitCount() > 0 } -func (u *UserActivityReportInOrg2) WriteToFile(dir, userName string) (string, error) { - logFileName := u.GenerateLogFileName(userName) +func (u *UserContributionReportForOrganization) WriteToFile(dir, userName string) (string, error) { + logFileName := u.ReportFileName(userName) err := writeActivityToFile(u, dir, logFileName) if err != nil { return "", err diff --git a/pkg/contributions/user-activity.go b/pkg/contributions/user-contribution.go similarity index 92% rename from pkg/contributions/user-activity.go rename to pkg/contributions/user-contribution.go index 1b350617..1004946f 100644 --- a/pkg/contributions/user-activity.go +++ b/pkg/contributions/user-contribution.go @@ -23,6 +23,7 @@ import ( "context" "fmt" "github.com/shurcooL/githubv4" + log "github.com/sirupsen/logrus" "golang.org/x/oauth2" "gopkg.in/yaml.v3" "os" @@ -49,13 +50,13 @@ func NewContributionReportGenerator(opts ContributionReportGeneratorOptions) (*C return &ContributionReportGenerator{client: client, opts: opts}, nil } -func (g ContributionReportGenerator) GenerateReport(userName string) (ActivityReport, error) { - var activity ActivityReport +func (g ContributionReportGenerator) GenerateReport(userName string) (ContributionReport, error) { + var activity ContributionReport var err error if g.opts.Repo != "" { activity, err = generateUserActivityReportInRepository(g.client, g.opts.Org, g.opts.Repo, userName, g.opts.startFrom()) } else { - activity, err = generateUserActivityReportInOrg2(g.client, g.opts.Org, userName, g.opts.startFrom()) + activity, err = generateUserContributionReportForOrganization(g.client, g.opts.Org, userName, g.opts.startFrom()) } if err != nil { return nil, fmt.Errorf("failed to query: %v", err) @@ -81,7 +82,7 @@ func (o ContributionReportGeneratorOptions) startFrom() time.Time { return time.Now().AddDate(0, -1*o.Months, 0) } -func generateUserActivityReportInRepository(client *githubv4.Client, org, repo, username string, startFrom time.Time) (*UserActivityReportInRepository, error) { +func generateUserActivityReportInRepository(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) @@ -145,7 +146,7 @@ func generateUserActivityReportInRepository(client *githubv4.Client, org, repo, if err != nil { return nil, fmt.Errorf("failed to use github query %+v with variables %v: %w", query, variables, err) } - return &UserActivityReportInRepository{ + return &UserContributionReportForRepository{ IssuesCreated: query.IssuesCreated, IssuesCommented: query.IssuesCommented, PullRequestsCreated: query.PullRequestsCreated, @@ -160,7 +161,7 @@ func generateUserActivityReportInRepository(client *githubv4.Client, org, repo, }, nil } -func generateUserActivityReportInOrg2(client *githubv4.Client, org, username string, startFrom time.Time) (*UserActivityReportInOrg2, error) { +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) @@ -218,7 +219,7 @@ func generateUserActivityReportInOrg2(client *githubv4.Client, org, username str if err != nil { return nil, fmt.Errorf("failed to use github query %+v with variables %v: %w", query, variables, err) } - return &UserActivityReportInOrg2{ + return &UserContributionReportForOrganization{ IssuesCreated: query.IssuesCreated, IssuesCommented: query.IssuesCommented, PullRequestsCreated: query.PullRequestsCreated, @@ -256,6 +257,6 @@ func writeActivityToFile(yamlObject interface{}, dir, fileName string) error { if err != nil { return err } - fmt.Printf(`user activity log: %q`, tempFile.Name()) + log.Debugf(`user activity log: %q`, tempFile.Name()) return nil } From c229e308b98d814ca79095989eda42aaf31726a9 Mon Sep 17 00:00:00 2001 From: Daniel Hiller Date: Thu, 5 Dec 2024 12:59:25 +0100 Subject: [PATCH 6/8] contributions: add OWNERS_ALIASES support Adds support of resolving aliases from the default OWNERS_ALIASES if it's in the same directory or from an aliases file in a given file path Signed-off-by: Daniel Hiller --- generators/cmd/contributions/main.go | 42 +++++++++++++++++++++------- pkg/owners/file.go | 14 ++++++++++ pkg/owners/types.go | 24 ++++++++++++++-- 3 files changed, 67 insertions(+), 13 deletions(-) diff --git a/generators/cmd/contributions/main.go b/generators/cmd/contributions/main.go index fb044c49..9ecb8395 100644 --- a/generators/cmd/contributions/main.go +++ b/generators/cmd/contributions/main.go @@ -29,20 +29,22 @@ import ( "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"` + 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 { @@ -115,6 +117,7 @@ func gatherContributionReportOptions() (*ContributionReportOptions, error) { 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 } @@ -154,7 +157,22 @@ func main() { if err != nil { log.Fatalf("invalid arguments: %v", err) } - userNames = uniq(ownersYAML.Reviewers, ownersYAML.Approvers) + 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.Reviewers + userNames = append(userNames, ownersYAML.Approvers...) + userNames = ownersAliases.Resolve(userNames) + userNames = uniq(userNames) sort.Strings(userNames) } else if contributionReportOptions.OrgsConfigFilePath != "" { orgsYAML, err := orgs.ReadFile(contributionReportOptions.OrgsConfigFilePath) @@ -194,6 +212,10 @@ func main() { } } +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 { diff --git a/pkg/owners/file.go b/pkg/owners/file.go index a9c4d63c..685d8fd5 100644 --- a/pkg/owners/file.go +++ b/pkg/owners/file.go @@ -38,3 +38,17 @@ func ReadFile(path string) (*Owners, error) { } 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 index 43ef7da6..da77ada8 100644 --- a/pkg/owners/types.go +++ b/pkg/owners/types.go @@ -20,7 +20,25 @@ package owners type Owners struct { - Reviewers []string `yaml:"reviewers"` - Approvers []string `yaml:"approvers"` - EmeritusApprovers []string `yaml:"emeritus_approvers"` + Reviewers []string `yaml:"reviewers"` + Approvers []string `yaml:"approvers"` + EmeritusApprovers []string `yaml:"emeritus_approvers"` + Filters map[string][]string `yaml:"filters"` +} + +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 } From 0d5f3db515a53719c6ff99530642ac0977f83cbc Mon Sep 17 00:00:00 2001 From: Daniel Hiller Date: Thu, 5 Dec 2024 13:54:49 +0100 Subject: [PATCH 7/8] contributions: add support for filters in OWNERS Signed-off-by: Daniel Hiller --- generators/cmd/contributions/main.go | 4 ++-- pkg/owners/types.go | 26 ++++++++++++++++++++++---- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/generators/cmd/contributions/main.go b/generators/cmd/contributions/main.go index 9ecb8395..239a19c8 100644 --- a/generators/cmd/contributions/main.go +++ b/generators/cmd/contributions/main.go @@ -169,8 +169,8 @@ func main() { log.Fatalf("invalid aliases file %q: %v", ownersAliasesPath, err) } } - userNames = ownersYAML.Reviewers - userNames = append(userNames, ownersYAML.Approvers...) + userNames = ownersYAML.AllReviewers() + userNames = append(userNames, ownersYAML.AllApprovers()...) userNames = ownersAliases.Resolve(userNames) userNames = uniq(userNames) sort.Strings(userNames) diff --git a/pkg/owners/types.go b/pkg/owners/types.go index da77ada8..30541a6c 100644 --- a/pkg/owners/types.go +++ b/pkg/owners/types.go @@ -20,10 +20,28 @@ package owners type Owners struct { - Reviewers []string `yaml:"reviewers"` - Approvers []string `yaml:"approvers"` - EmeritusApprovers []string `yaml:"emeritus_approvers"` - Filters map[string][]string `yaml:"filters"` + 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 { From 57eb77b0b2eef2cc8f41c63bbae3d5edc6165d5c Mon Sep 17 00:00:00 2001 From: Daniel Hiller Date: Thu, 5 Dec 2024 16:19:04 +0100 Subject: [PATCH 8/8] contributions: update README, add retry Also rename a func. Signed-off-by: Daniel Hiller --- generators/cmd/contributions/README.md | 334 +++++++++++++----- .../cmd/contributions/default-config.yaml | 13 +- generators/cmd/contributions/main.go | 12 +- go.mod | 1 + go.sum | 2 + pkg/contributions/user-contribution.go | 32 +- 6 files changed, 283 insertions(+), 111 deletions(-) diff --git a/generators/cmd/contributions/README.md b/generators/cmd/contributions/README.md index b574b72c..c3a54648 100644 --- a/generators/cmd/contributions/README.md +++ b/generators/cmd/contributions/README.md @@ -1,6 +1,22 @@ # contributions -A tool that fetches GitHub contributions by a user per organization or per repository. +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 @@ -10,55 +26,43 @@ $ 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 - organization kubevirt - since 2023-12-02 17:50:50 - - hasContributions: true - totalIssueContributions: 57 - totalPullRequestContributions: 181 - totalPullRequestReviewContributions: 455 - totalCommitContributions: 199 - -user activity log: "/tmp/user-activity-dhiller-kubevirt-2101388502.yaml" -$ cat /tmp/user-activity-dhiller-kubevirt-2101388502.yaml -hasAnyContributions: true -totalCommitContributions: 199 -totalIssueContributions: 57 -totalPullRequestContributions: 181 -totalPullRequestReviewContributions: 455 -issueContributions: - totalCount: 57 + 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: - URL: https://github.com/kubevirt/community/issues/359 - occurredAt: 2024-11-28T09:18:48Z -pullRequestContributions: - totalCount: 181 - nodes: - - pullRequest: - URL: https://github.com/kubevirt/community/pull/361 - occurredAt: 2024-12-02T09:59:10Z -pullRequestReviewContributions: - totalCount: 455 - nodes: - - pullRequestReview: + 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: - nameWithOwner: kubevirt/kubevirt - pullRequest: - URL: https://github.com/kubevirt/kubevirt/pull/13274 - createdAt: 2024-11-29T09:01:07Z - state: APPROVED -commitContributionsByRepository: - - contributions: - totalCount: 119 - nodes: - - repository: - nameWithOwner: kubevirt/project-infra - user: - name: Daniel Hiller - occurredAt: 2024-11-26T08:00:00Z + name: kubevirt + author: + login: dhiller ... ``` @@ -71,62 +75,210 @@ $ go run ./generators/cmd/contributions \ --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-02 17:54:45 - - issues - created: 17 - commented: 15 - pull requests: - reviewed: 244 - created: 124 - commented: 330 - commits: 117 - -user activity log: "/tmp/user-activity-dhiller-kubevirt_project-infra-3828787132.yaml" -$ cat /tmp/user-activity-dhiller-kubevirt_project-infra-3828787132.yaml -issuesCreated: issueCount: 17 - nodes: - issue: + 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' + 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/37 -68 repository: name: project-infra author: login: dhiller - createdAt: 2024-11-19T10:53:26Z + createdAt: 2024-11-27T10:39:57Z - issue: - number: 3712 - title: Remove label needs-approver-review after either c -losed/merged or review by approver has happened - URL: https://github.com/kubevirt/project-infra/issues/37 -12 - repository: - name: project-infra - author: - login: dhiller - createdAt: 2024-10-25T11:08:15Z - - issue: - number: 3711 - title: Check prow update mechanism - URL: https://github.com/kubevirt/project-infra/issues/37 -11 + 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 - createdAt: 2024-10-25T10:31:19Z +``` +# `--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 index 88b552d2..16e36294 100644 --- a/generators/cmd/contributions/default-config.yaml +++ b/generators/cmd/contributions/default-config.yaml @@ -1,6 +1,10 @@ +# skipInactive holds configurations for which github users +# need not get checked, either per org or repos skipInactive: + + # kubevirt org kubevirt: - # bots (kubevirt, openshift, the linux foundation) + - name: "bots" github: - kubevirt-bot @@ -10,8 +14,8 @@ skipInactive: - openshift-merge-robot - thelinuxfoundation - # KubeVirt org admins (security measure so that we don't lose GitHub org access) - name: "orgAdmins" + # skip KubeVirt org admins (security measure so that we don't lose GitHub org access) github: - brianmcarey - davidvossel @@ -19,15 +23,16 @@ skipInactive: - fabiand - rmohr - # users with invisible contributions (i.e. OSPO, KubeVirt community manager etc) - 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 - - jberkus diff --git a/generators/cmd/contributions/main.go b/generators/cmd/contributions/main.go index 239a19c8..a3a74866 100644 --- a/generators/cmd/contributions/main.go +++ b/generators/cmd/contributions/main.go @@ -184,11 +184,13 @@ func main() { } for _, userName := range userNames { - 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 + 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 { diff --git a/go.mod b/go.mod index 63e4bc80..2d93b807 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ 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 diff --git a/go.sum b/go.sum index 85fcc4d7..6663fb94 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +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= diff --git a/pkg/contributions/user-contribution.go b/pkg/contributions/user-contribution.go index 1004946f..70f2318c 100644 --- a/pkg/contributions/user-contribution.go +++ b/pkg/contributions/user-contribution.go @@ -22,6 +22,7 @@ package contributions import ( "context" "fmt" + "github.com/avast/retry-go" "github.com/shurcooL/githubv4" log "github.com/sirupsen/logrus" "golang.org/x/oauth2" @@ -51,17 +52,26 @@ func NewContributionReportGenerator(opts ContributionReportGeneratorOptions) (*C } func (g ContributionReportGenerator) GenerateReport(userName string) (ContributionReport, error) { - var activity ContributionReport - var err error - if g.opts.Repo != "" { - activity, err = generateUserActivityReportInRepository(g.client, g.opts.Org, g.opts.Repo, userName, g.opts.startFrom()) - } else { - activity, err = generateUserContributionReportForOrganization(g.client, g.opts.Org, userName, g.opts.startFrom()) - } - if err != nil { - return nil, fmt.Errorf("failed to query: %v", err) + 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 activity, nil + return contributionReport, nil } type ContributionReportGeneratorOptions struct { @@ -82,7 +92,7 @@ func (o ContributionReportGeneratorOptions) startFrom() time.Time { return time.Now().AddDate(0, -1*o.Months, 0) } -func generateUserActivityReportInRepository(client *githubv4.Client, org, repo, username string, startFrom time.Time) (*UserContributionReportForRepository, error) { +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)