diff --git a/docs/tables/github_user_contribution_stats.md b/docs/tables/github_user_contribution_stats.md new file mode 100644 index 0000000..6e43a5b --- /dev/null +++ b/docs/tables/github_user_contribution_stats.md @@ -0,0 +1,94 @@ +--- +title: "Steampipe Table: github_user_contribution_stats - Query GitHub user contributions summary using SQL" +description: "Query GitHub user contribution summaries and calendar data from the GraphQL ContributionsCollection." +folder: "User" +--- + +## Table: github_user_contribution_stats - Query GitHub user contributions summary using SQL + +The `github_user_contribution_stats` table provides access to GitHub's ContributionsCollection data for a user, including total contribution counts and the contribution calendar (weeks/days). This makes it possible to build dashboards and reports similar to a user's public contribution graph. + +## Table Usage Guide + +The table is scoped to a single user per query. Optionally specify `from_date` and `to_date` to constrain the contribution window, and `max_repositories` to control how many repositories are returned for commit contributions by repository. + +## Important Notes + +- You must specify the `login` column in the `where` clause. +- The `commit_contributions_by_repository` field returns at most 100 repositories (default 100). + +## Examples + +### Get contribution summary for a user + +```sql+postgres +select + total_commit_contributions, + total_issue_contributions, + total_pull_request_contributions, + total_pull_request_review_contributions, + total_repositories_with_contributed_commits +from + github_user_contribution_stats +where + login = 'octocat'; +``` + +```sql+sqlite +select + total_commit_contributions, + total_issue_contributions, + total_pull_request_contributions, + total_pull_request_review_contributions, + total_repositories_with_contributed_commits +from + github_user_contribution_stats +where + login = 'octocat'; +``` + +### Get contribution calendar for a date range + +```sql+postgres +select + contribution_calendar +from + github_user_contribution_stats +where + login = 'octocat' + and from_date = '2025-01-01' + and to_date = '2025-12-31'; +``` + +```sql+sqlite +select + contribution_calendar +from + github_user_contribution_stats +where + login = 'octocat' + and from_date = '2025-01-01' + and to_date = '2025-12-31'; +``` + +### Limit repositories in commit contributions breakdown + +```sql+postgres +select + commit_contributions_by_repository +from + github_user_contribution_stats +where + login = 'octocat' + and max_repositories = 100; +``` + +```sql+sqlite +select + commit_contributions_by_repository +from + github_user_contribution_stats +where + login = 'octocat' + and max_repositories = 100; +``` diff --git a/github/contribution_utils.go b/github/contribution_utils.go new file mode 100644 index 0000000..bdb692b --- /dev/null +++ b/github/contribution_utils.go @@ -0,0 +1,39 @@ +package github + +import ( + "context" + "fmt" + "slices" + + "github.com/shurcooL/githubv4" + "github.com/turbot/steampipe-plugin-github/github/models" + "github.com/turbot/steampipe-plugin-sdk/v5/plugin" +) + +func appendContributionColumnIncludes(m *map[string]interface{}, cols []string) { + (*m)["includeContributionCalendar"] = githubv4.Boolean(slices.Contains(cols, "contribution_calendar")) + (*m)["includeCommitContributionsByRepository"] = githubv4.Boolean(slices.Contains(cols, "commit_contributions_by_repository")) +} + +func extractContributionsCollectionFromHydrateItem(h *plugin.HydrateData) (models.ContributionsCollection, error) { + if collection, ok := h.Item.(models.ContributionsCollection); ok { + return collection, nil + } + return models.ContributionsCollection{}, fmt.Errorf("unable to parse hydrate item %v as ContributionsCollection", h.Item) +} + +func contributionHydrateCalendar(_ context.Context, _ *plugin.QueryData, h *plugin.HydrateData) (interface{}, error) { + collection, err := extractContributionsCollectionFromHydrateItem(h) + if err != nil { + return nil, err + } + return collection.ContributionCalendar, nil +} + +func contributionHydrateCommitContributionsByRepository(_ context.Context, _ *plugin.QueryData, h *plugin.HydrateData) (interface{}, error) { + collection, err := extractContributionsCollectionFromHydrateItem(h) + if err != nil { + return nil, err + } + return collection.CommitContributionsByRepository, nil +} diff --git a/github/models/contribution.go b/github/models/contribution.go new file mode 100644 index 0000000..aa98328 --- /dev/null +++ b/github/models/contribution.go @@ -0,0 +1,41 @@ +package models + +import "github.com/shurcooL/githubv4" + +type ContributionCalendar struct { + TotalContributions int `graphql:"totalContributions" json:"total_contributions"` + Weeks []ContributionWeek `graphql:"weeks" json:"weeks"` +} + +type ContributionWeek struct { + ContributionDays []ContributionDay `graphql:"contributionDays" json:"contribution_days"` + FirstDay githubv4.Date `graphql:"firstDay" json:"first_day"` +} + +type ContributionDay struct { + Color string `graphql:"color" json:"color"` + ContributionCount int `graphql:"contributionCount" json:"contribution_count"` + ContributionLevel githubv4.ContributionLevel `graphql:"contributionLevel" json:"contribution_level"` + Date githubv4.Date `graphql:"date" json:"date"` + Weekday int `graphql:"weekday" json:"weekday"` +} + +type CommitContributionsByRepository struct { + Repository struct { + NameWithOwner string `graphql:"nameWithOwner" json:"name_with_owner"` + Url string `graphql:"url" json:"url"` + } `graphql:"repository" json:"repository"` + Contributions struct { + TotalCount int `graphql:"totalCount" json:"total_count"` + } `graphql:"contributions" json:"contributions"` +} + +type ContributionsCollection struct { + TotalCommitContributions int `graphql:"totalCommitContributions" json:"total_commit_contributions"` + TotalIssueContributions int `graphql:"totalIssueContributions" json:"total_issue_contributions"` + TotalPullRequestContributions int `graphql:"totalPullRequestContributions" json:"total_pull_request_contributions"` + TotalPullRequestReviewContributions int `graphql:"totalPullRequestReviewContributions" json:"total_pull_request_review_contributions"` + TotalRepositoriesWithContributedCommits int `graphql:"totalRepositoriesWithContributedCommits" json:"total_repositories_with_contributed_commits"` + ContributionCalendar ContributionCalendar `graphql:"contributionCalendar @include(if:$includeContributionCalendar)" json:"contribution_calendar,omitempty"` + CommitContributionsByRepository []CommitContributionsByRepository `graphql:"commitContributionsByRepository(maxRepositories: $maxRepositories) @include(if:$includeCommitContributionsByRepository)" json:"commit_contributions_by_repository,omitempty"` +} diff --git a/github/plugin.go b/github/plugin.go index 7915a0d..ff73f59 100644 --- a/github/plugin.go +++ b/github/plugin.go @@ -90,6 +90,7 @@ func Plugin(ctx context.Context) *plugin.Plugin { "github_traffic_view_weekly": tableGitHubTrafficViewWeekly(), "github_tree": tableGitHubTree(), "github_user": tableGitHubUser(), + "github_user_contribution_stats": tableGitHubUserContributionStats(), "github_workflow": tableGitHubWorkflow(), }, } diff --git a/github/table_github_user_contribution_stats.go b/github/table_github_user_contribution_stats.go new file mode 100644 index 0000000..b53b2f4 --- /dev/null +++ b/github/table_github_user_contribution_stats.go @@ -0,0 +1,113 @@ +package github + +import ( + "context" + "fmt" + "strings" + + "github.com/shurcooL/githubv4" + "github.com/turbot/steampipe-plugin-github/github/models" + "github.com/turbot/steampipe-plugin-sdk/v5/grpc/proto" + "github.com/turbot/steampipe-plugin-sdk/v5/plugin" + "github.com/turbot/steampipe-plugin-sdk/v5/plugin/transform" +) + +const maxCommitContributionsRepositories = 100 + +func tableGitHubUserContributionStats() *plugin.Table { + return &plugin.Table{ + Name: "github_user_contribution_stats", + Description: "Contribution summary and calendar data for a GitHub user.", + List: &plugin.ListConfig{ + KeyColumns: []*plugin.KeyColumn{ + {Name: "login", Require: plugin.Required}, + {Name: "from_date", Require: plugin.Optional, Operators: []string{"="}}, + {Name: "to_date", Require: plugin.Optional, Operators: []string{"="}}, + {Name: "max_repositories", Require: plugin.Optional}, + }, + ShouldIgnoreError: isNotFoundError([]string{"404"}), + Hydrate: tableGitHubUserContributionStatsList, + }, + Columns: commonColumns([]*plugin.Column{ + {Name: "login", Type: proto.ColumnType_STRING, Description: "The login name of the user.", Transform: transform.FromQual("login")}, + {Name: "from_date", Type: proto.ColumnType_TIMESTAMP, Description: "Start date for the contribution window.", Transform: transform.FromQual("from_date")}, + {Name: "to_date", Type: proto.ColumnType_TIMESTAMP, Description: "End date for the contribution window.", Transform: transform.FromQual("to_date")}, + {Name: "max_repositories", Type: proto.ColumnType_INT, Description: "Maximum repositories returned for commit contributions by repository.", Transform: transform.FromQual("max_repositories")}, + {Name: "total_commit_contributions", Type: proto.ColumnType_INT, Description: "Total count of commit contributions.", Transform: transform.FromField("TotalCommitContributions")}, + {Name: "total_issue_contributions", Type: proto.ColumnType_INT, Description: "Total count of issue contributions.", Transform: transform.FromField("TotalIssueContributions")}, + {Name: "total_pull_request_contributions", Type: proto.ColumnType_INT, Description: "Total count of pull request contributions.", Transform: transform.FromField("TotalPullRequestContributions")}, + {Name: "total_pull_request_review_contributions", Type: proto.ColumnType_INT, Description: "Total count of pull request review contributions.", Transform: transform.FromField("TotalPullRequestReviewContributions")}, + {Name: "total_repositories_with_contributed_commits", Type: proto.ColumnType_INT, Description: "Total count of repositories with contributed commits.", Transform: transform.FromField("TotalRepositoriesWithContributedCommits")}, + {Name: "contribution_calendar", Type: proto.ColumnType_JSON, Description: "Contribution calendar with weeks and days.", Hydrate: contributionHydrateCalendar, Transform: transform.FromValue().NullIfZero()}, + {Name: "commit_contributions_by_repository", Type: proto.ColumnType_JSON, Description: "Commit contributions aggregated by repository.", Hydrate: contributionHydrateCommitContributionsByRepository, Transform: transform.FromValue().NullIfZero()}, + }), + } +} + +func tableGitHubUserContributionStatsList(ctx context.Context, d *plugin.QueryData, _ *plugin.HydrateData) (interface{}, error) { + login := d.EqualsQuals["login"].GetStringValue() + + var fromDate *githubv4.DateTime + var toDate *githubv4.DateTime + maxRepositories := maxCommitContributionsRepositories + + if d.EqualsQuals["from_date"] != nil { + fromTime := d.EqualsQuals["from_date"].GetTimestampValue().AsTime() + fromDate = githubv4.NewDateTime(githubv4.DateTime{Time: fromTime}) + } + + if d.EqualsQuals["to_date"] != nil { + toTime := d.EqualsQuals["to_date"].GetTimestampValue().AsTime() + toDate = githubv4.NewDateTime(githubv4.DateTime{Time: toTime}) + } + + if d.EqualsQuals["max_repositories"] != nil { + maxRepositories = int(d.EqualsQuals["max_repositories"].GetInt64Value()) + } + + if maxRepositories <= 0 { + return nil, fmt.Errorf("invalid value for 'max_repositories' must be greater than 0") + } + + if maxRepositories > maxCommitContributionsRepositories { + return nil, fmt.Errorf("invalid value for 'max_repositories' must be <= %d", maxCommitContributionsRepositories) + } + + var query struct { + RateLimit models.RateLimit + User struct { + ContributionsCollection models.ContributionsCollection `graphql:"contributionsCollection(from: $from, to: $to)"` + } `graphql:"user(login: $login)"` + } + + variables := map[string]interface{}{ + "login": githubv4.String(login), + "from": (*githubv4.DateTime)(nil), + "to": (*githubv4.DateTime)(nil), + "maxRepositories": githubv4.Int(maxRepositories), + } + + if fromDate != nil { + variables["from"] = fromDate + } + if toDate != nil { + variables["to"] = toDate + } + + appendContributionColumnIncludes(&variables, d.QueryContext.Columns) + + client := connectV4(ctx, d) + err := client.Query(ctx, &query, variables) + plugin.Logger(ctx).Debug(rateLimitLogString("github_user_contribution_stats", &query.RateLimit)) + if err != nil { + plugin.Logger(ctx).Error("github_user_contribution_stats", "api_error", err) + if strings.Contains(err.Error(), "Could not resolve to a User with the login of") { + return nil, nil + } + return nil, err + } + + d.StreamListItem(ctx, query.User.ContributionsCollection) + + return nil, nil +}