From c4c369385b12a146e7186b08da3d3326d0dab033 Mon Sep 17 00:00:00 2001 From: Stas Alekseev <100800+salekseev@users.noreply.github.com> Date: Fri, 30 Jan 2026 10:17:21 -0500 Subject: [PATCH 1/3] Add user contribution stats table Expose ContributionsCollection totals and calendar data for GitHub users. --- docs/tables/github_user_contribution_stats.md | 70 ++++++++++++++ github/contribution_utils.go | 39 ++++++++ github/models/contribution.go | 41 ++++++++ github/plugin.go | 1 + .../table_github_user_contribution_stats.go | 95 +++++++++++++++++++ 5 files changed, 246 insertions(+) create mode 100644 docs/tables/github_user_contribution_stats.md create mode 100644 github/contribution_utils.go create mode 100644 github/models/contribution.go create mode 100644 github/table_github_user_contribution_stats.go diff --git a/docs/tables/github_user_contribution_stats.md b/docs/tables/github_user_contribution_stats.md new file mode 100644 index 0000000..045bcf2 --- /dev/null +++ b/docs/tables/github_user_contribution_stats.md @@ -0,0 +1,70 @@ +--- +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. + +**Important Notes** +- You must specify the `login` column in the `where` clause. + +## 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'; +``` 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..1744fd0 --- /dev/null +++ b/github/table_github_user_contribution_stats.go @@ -0,0 +1,95 @@ +package github + +import ( + "context" + "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" +) + +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{"="}}, + }, + 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: "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 + + 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}) + } + + 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(100), + } + + 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 +} From f4e1e8888b5fe40892998c64ccf9ca1ab539927c Mon Sep 17 00:00:00 2001 From: Stas Alekseev <100800+salekseev@users.noreply.github.com> Date: Fri, 30 Jan 2026 16:34:58 -0500 Subject: [PATCH 2/3] Add max_repositories option for contributions Allow callers to cap repositories returned for commit contributions. --- docs/tables/github_user_contribution_stats.md | 24 ++++++++++++++++++- .../table_github_user_contribution_stats.go | 14 ++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/docs/tables/github_user_contribution_stats.md b/docs/tables/github_user_contribution_stats.md index 045bcf2..ad3996d 100644 --- a/docs/tables/github_user_contribution_stats.md +++ b/docs/tables/github_user_contribution_stats.md @@ -10,7 +10,7 @@ The `github_user_contribution_stats` table provides access to GitHub's Contribut ## 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. +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. @@ -67,4 +67,26 @@ 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 = 200; +``` + +```sql+sqlite +select + commit_contributions_by_repository +from + github_user_contribution_stats +where + login = 'octocat' + and max_repositories = 200; +``` ``` diff --git a/github/table_github_user_contribution_stats.go b/github/table_github_user_contribution_stats.go index 1744fd0..88fe85e 100644 --- a/github/table_github_user_contribution_stats.go +++ b/github/table_github_user_contribution_stats.go @@ -2,6 +2,7 @@ package github import ( "context" + "fmt" "strings" "github.com/shurcooL/githubv4" @@ -20,6 +21,7 @@ func tableGitHubUserContributionStats() *plugin.Table { {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, @@ -28,6 +30,7 @@ func tableGitHubUserContributionStats() *plugin.Table { {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")}, @@ -44,6 +47,7 @@ func tableGitHubUserContributionStatsList(ctx context.Context, d *plugin.QueryDa var fromDate *githubv4.DateTime var toDate *githubv4.DateTime + maxRepositories := 100 if d.EqualsQuals["from_date"] != nil { fromTime := d.EqualsQuals["from_date"].GetTimestampValue().AsTime() @@ -55,6 +59,14 @@ func tableGitHubUserContributionStatsList(ctx context.Context, d *plugin.QueryDa 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") + } + var query struct { RateLimit models.RateLimit User struct { @@ -66,7 +78,7 @@ func tableGitHubUserContributionStatsList(ctx context.Context, d *plugin.QueryDa "login": githubv4.String(login), "from": (*githubv4.DateTime)(nil), "to": (*githubv4.DateTime)(nil), - "maxRepositories": githubv4.Int(100), + "maxRepositories": githubv4.Int(maxRepositories), } if fromDate != nil { From 8bb1307116ccfd8b564795e012f5a69c069ddba2 Mon Sep 17 00:00:00 2001 From: Stas Alekseev <100800+salekseev@users.noreply.github.com> Date: Fri, 30 Jan 2026 16:43:03 -0500 Subject: [PATCH 3/3] Address review feedback for contributions table Document max repository cap and fix markdown fences; enforce cap in code. --- docs/tables/github_user_contribution_stats.md | 12 +++++++----- github/table_github_user_contribution_stats.go | 8 +++++++- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/docs/tables/github_user_contribution_stats.md b/docs/tables/github_user_contribution_stats.md index ad3996d..6e43a5b 100644 --- a/docs/tables/github_user_contribution_stats.md +++ b/docs/tables/github_user_contribution_stats.md @@ -4,7 +4,7 @@ description: "Query GitHub user contribution summaries and calendar data from th folder: "User" --- -# Table: github_user_contribution_stats - Query GitHub user contributions summary using SQL +## 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. @@ -12,8 +12,10 @@ The `github_user_contribution_stats` table provides access to GitHub's Contribut 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** +## 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 @@ -67,6 +69,7 @@ where login = 'octocat' and from_date = '2025-01-01' and to_date = '2025-12-31'; +``` ### Limit repositories in commit contributions breakdown @@ -77,7 +80,7 @@ from github_user_contribution_stats where login = 'octocat' - and max_repositories = 200; + and max_repositories = 100; ``` ```sql+sqlite @@ -87,6 +90,5 @@ from github_user_contribution_stats where login = 'octocat' - and max_repositories = 200; -``` + and max_repositories = 100; ``` diff --git a/github/table_github_user_contribution_stats.go b/github/table_github_user_contribution_stats.go index 88fe85e..b53b2f4 100644 --- a/github/table_github_user_contribution_stats.go +++ b/github/table_github_user_contribution_stats.go @@ -12,6 +12,8 @@ import ( "github.com/turbot/steampipe-plugin-sdk/v5/plugin/transform" ) +const maxCommitContributionsRepositories = 100 + func tableGitHubUserContributionStats() *plugin.Table { return &plugin.Table{ Name: "github_user_contribution_stats", @@ -47,7 +49,7 @@ func tableGitHubUserContributionStatsList(ctx context.Context, d *plugin.QueryDa var fromDate *githubv4.DateTime var toDate *githubv4.DateTime - maxRepositories := 100 + maxRepositories := maxCommitContributionsRepositories if d.EqualsQuals["from_date"] != nil { fromTime := d.EqualsQuals["from_date"].GetTimestampValue().AsTime() @@ -67,6 +69,10 @@ func tableGitHubUserContributionStatsList(ctx context.Context, d *plugin.QueryDa 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 {