From 6751d97c917fe6dde422624c579b262b942d53d0 Mon Sep 17 00:00:00 2001 From: J Smith Date: Thu, 18 Jun 2026 14:27:34 -0300 Subject: [PATCH] Add the `github_project_v2` table --- github/issue_pr_utils.go | 16 ++++ github/models/issue.go | 8 ++ github/models/project_v2.go | 46 +++++++++ github/plugin.go | 1 + github/project_v2_utils.go | 146 +++++++++++++++++++++++++++++ github/table_github_issue.go | 1 + github/table_github_project_v2.go | 149 ++++++++++++++++++++++++++++++ 7 files changed, 367 insertions(+) create mode 100644 github/models/project_v2.go create mode 100644 github/project_v2_utils.go create mode 100644 github/table_github_project_v2.go diff --git a/github/issue_pr_utils.go b/github/issue_pr_utils.go index f8e39bb..8be34a6 100644 --- a/github/issue_pr_utils.go +++ b/github/issue_pr_utils.go @@ -158,6 +158,7 @@ func appendIssueColumnIncludes(m *map[string]interface{}, cols []string) { (*m)["includeIssueNodeId"] = githubv4.Boolean(slices.Contains(cols, "node_id")) (*m)["includeIssueId"] = githubv4.Boolean(slices.Contains(cols, "id")) (*m)["includeIssueIsReadByUser"] = githubv4.Boolean(slices.Contains(cols, "is_read_by_user")) + (*m)["includeIssueProjectItems"] = githubv4.Boolean(slices.Contains(cols, "project_items")) } func issueHydrateIsReadByUser(_ context.Context, _ *plugin.QueryData, h *plugin.HydrateData) (interface{}, error) { @@ -472,6 +473,21 @@ func issueHydrateLabels(_ context.Context, _ *plugin.QueryData, h *plugin.Hydrat return issue.Labels.Nodes, nil } +func issueHydrateProjectItems(_ context.Context, _ *plugin.QueryData, h *plugin.HydrateData) (interface{}, error) { + issue, err := extractIssueFromHydrateItem(h) + if err != nil { + return nil, err + } + if len(issue.ProjectItems.Nodes) == 0 { + return nil, nil + } + nodeIds := make([]string, len(issue.ProjectItems.Nodes)) + for i, item := range issue.ProjectItems.Nodes { + nodeIds[i] = item.Project.Id + } + return nodeIds, nil +} + func extractIssueCommentFromHydrateItem(h *plugin.HydrateData) (models.IssueComment, error) { if issueComment, ok := h.Item.(models.IssueComment); ok { return issueComment, nil diff --git a/github/models/issue.go b/github/models/issue.go index f7113ff..042d4a5 100644 --- a/github/models/issue.go +++ b/github/models/issue.go @@ -49,6 +49,14 @@ type Issue struct { TotalCount int Nodes []BaseUser } `graphql:"assignees(first: 10) @include(if:$includeIssueAssignees)" json:"assignees"` + ProjectItems struct { + TotalCount int + Nodes []struct { + Project struct { + Id string `graphql:"id" json:"id"` + } `json:"project"` + } + } `graphql:"projectItems(first: 100) @include(if:$includeIssueProjectItems)" json:"project_items"` } type IssueTemplate struct { diff --git a/github/models/project_v2.go b/github/models/project_v2.go new file mode 100644 index 0000000..4970d98 --- /dev/null +++ b/github/models/project_v2.go @@ -0,0 +1,46 @@ +package models + +// ProjectV2Owner represents the owner of a project, which can be an Organization or User. +type ProjectV2Owner struct { + TypeName string `graphql:"type: __typename" json:"type"` + Organization struct { + Id int `graphql:"id: databaseId" json:"id"` + Login string `json:"login"` + } `graphql:"... on Organization" json:"organization,omitempty"` + User struct { + Id int `graphql:"id: databaseId" json:"id"` + Login string `json:"login"` + } `graphql:"... on User" json:"user,omitempty"` +} + +// ProjectV2StatusUpdate represents a single status update on a project. +type ProjectV2StatusUpdate struct { + Id string `graphql:"id: fullDatabaseId" json:"id"` + NodeId string `graphql:"nodeId: id" json:"node_id"` + Status string `json:"status"` + Body string `json:"body"` + StartDate string `json:"start_date"` + TargetDate string `json:"target_date"` + CreatedAt NullableTime `json:"created_at"` + UpdatedAt NullableTime `json:"updated_at"` + Creator Actor `json:"creator"` +} + +type ProjectV2 struct { + Id string `graphql:"id: fullDatabaseId @include(if:$includeId)" json:"id"` + NodeId string `graphql:"nodeId: id @include(if:$includeNodeId)" json:"node_id"` + Number int `json:"number"` + Owner ProjectV2Owner `graphql:"owner @include(if:$includeOwner)" json:"owner,omitempty"` + Creator Actor `graphql:"creator @include(if:$includeCreator)" json:"creator,omitempty"` + Title string `graphql:"title @include(if:$includeTitle)" json:"title"` + Description string `graphql:"description: shortDescription @include(if:$includeDescription)" json:"description"` + IsPublic bool `graphql:"public @include(if:$includeIsPublic)" json:"public"` + ClosedAt NullableTime `graphql:"closedAt @include(if:$includeClosedAt)" json:"closed_at"` + CreatedAt NullableTime `graphql:"createdAt @include(if:$includeCreatedAt)" json:"created_at"` + UpdatedAt NullableTime `graphql:"updatedAt @include(if:$includeUpdatedAt)" json:"updated_at"` + Closed bool `graphql:"closed @include(if:$includeState)" json:"closed"` + LatestStatusUpdate struct { + Nodes []ProjectV2StatusUpdate + } `graphql:"statusUpdates(last: 1) @include(if:$includeLatestStatusUpdate)" json:"latest_status_update"` + IsTemplate bool `graphql:"template @include(if:$includeIsTemplate)" json:"template"` +} diff --git a/github/plugin.go b/github/plugin.go index 7915a0d..b9882cd 100644 --- a/github/plugin.go +++ b/github/plugin.go @@ -55,6 +55,7 @@ func Plugin(ctx context.Context) *plugin.Plugin { "github_organization_collaborator": tableGitHubOrganizationCollaborator(), "github_package": tableGitHubPackage(), "github_package_version": tableGitHubPackageVersion(), + "github_project_v2": tableGitHubProjectV2(), "github_pull_request": tableGitHubPullRequest(), "github_pull_request_comment": tableGitHubPullRequestComment(), "github_pull_request_review": tableGitHubPullRequestReview(), diff --git a/github/project_v2_utils.go b/github/project_v2_utils.go new file mode 100644 index 0000000..ac50a59 --- /dev/null +++ b/github/project_v2_utils.go @@ -0,0 +1,146 @@ +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 extractProjectV2FromHydrateItem(h *plugin.HydrateData) (models.ProjectV2, error) { + if project, ok := h.Item.(models.ProjectV2); ok { + return project, nil + } else { + return models.ProjectV2{}, fmt.Errorf("unable to parse hydrate item %v as a ProjectV2", h.Item) + } +} +func appendProjectV2ColumnIncludes(m *map[string]interface{}, cols []string) { + (*m)["includeId"] = githubv4.Boolean(slices.Contains(cols, "id")) + (*m)["includeNodeId"] = githubv4.Boolean(slices.Contains(cols, "node_id")) + (*m)["includeOwner"] = githubv4.Boolean(slices.Contains(cols, "owner")) + (*m)["includeCreator"] = githubv4.Boolean(slices.Contains(cols, "creator")) + (*m)["includeTitle"] = githubv4.Boolean(slices.Contains(cols, "title")) + (*m)["includeDescription"] = githubv4.Boolean(slices.Contains(cols, "description")) + (*m)["includeIsPublic"] = githubv4.Boolean(slices.Contains(cols, "is_public")) + (*m)["includeClosedAt"] = githubv4.Boolean(slices.Contains(cols, "closed_at")) + (*m)["includeCreatedAt"] = githubv4.Boolean(slices.Contains(cols, "created_at")) + (*m)["includeUpdatedAt"] = githubv4.Boolean(slices.Contains(cols, "updated_at")) + (*m)["includeState"] = githubv4.Boolean(slices.Contains(cols, "state")) + (*m)["includeLatestStatusUpdate"] = githubv4.Boolean(slices.Contains(cols, "latest_status_update")) + (*m)["includeIsTemplate"] = githubv4.Boolean(slices.Contains(cols, "is_template")) +} + +func projectV2HydrateId(_ context.Context, _ *plugin.QueryData, h *plugin.HydrateData) (interface{}, error) { + project, err := extractProjectV2FromHydrateItem(h) + if err != nil { + return nil, err + } + return project.Id, nil +} + +func projectV2HydrateNodeId(_ context.Context, _ *plugin.QueryData, h *plugin.HydrateData) (interface{}, error) { + project, err := extractProjectV2FromHydrateItem(h) + if err != nil { + return nil, err + } + return project.NodeId, nil +} + +func projectV2HydrateOwner(_ context.Context, _ *plugin.QueryData, h *plugin.HydrateData) (interface{}, error) { + project, err := extractProjectV2FromHydrateItem(h) + if err != nil { + return nil, err + } + return project.Owner, nil +} + +func projectV2HydrateCreator(_ context.Context, _ *plugin.QueryData, h *plugin.HydrateData) (interface{}, error) { + project, err := extractProjectV2FromHydrateItem(h) + if err != nil { + return nil, err + } + return project.Creator, nil +} + +func projectV2HydrateTitle(_ context.Context, _ *plugin.QueryData, h *plugin.HydrateData) (interface{}, error) { + project, err := extractProjectV2FromHydrateItem(h) + if err != nil { + return nil, err + } + return project.Title, nil +} + +func projectV2HydrateDescription(_ context.Context, _ *plugin.QueryData, h *plugin.HydrateData) (interface{}, error) { + project, err := extractProjectV2FromHydrateItem(h) + if err != nil { + return nil, err + } + return project.Description, nil +} + +func projectV2HydrateIsPublic(_ context.Context, _ *plugin.QueryData, h *plugin.HydrateData) (interface{}, error) { + project, err := extractProjectV2FromHydrateItem(h) + if err != nil { + return nil, err + } + return project.IsPublic, nil +} + +func projectV2HydrateClosedAt(_ context.Context, _ *plugin.QueryData, h *plugin.HydrateData) (interface{}, error) { + project, err := extractProjectV2FromHydrateItem(h) + if err != nil { + return nil, err + } + return project.ClosedAt, nil +} + +func projectV2HydrateCreatedAt(_ context.Context, _ *plugin.QueryData, h *plugin.HydrateData) (interface{}, error) { + project, err := extractProjectV2FromHydrateItem(h) + if err != nil { + return nil, err + } + return project.CreatedAt, nil +} + +func projectV2HydrateUpdatedAt(_ context.Context, _ *plugin.QueryData, h *plugin.HydrateData) (interface{}, error) { + project, err := extractProjectV2FromHydrateItem(h) + if err != nil { + return nil, err + } + return project.UpdatedAt, nil +} + +// projectV2HydrateState derives the REST-compatible "open"/"closed" state string from the GraphQL boolean. +func projectV2HydrateState(_ context.Context, _ *plugin.QueryData, h *plugin.HydrateData) (interface{}, error) { + project, err := extractProjectV2FromHydrateItem(h) + if err != nil { + return nil, err + } + if project.Closed { + return "closed", nil + } + return "open", nil +} + +// projectV2HydrateLatestStatusUpdate returns the most recent status update from the statusUpdates connection. +func projectV2HydrateLatestStatusUpdate(_ context.Context, _ *plugin.QueryData, h *plugin.HydrateData) (interface{}, error) { + project, err := extractProjectV2FromHydrateItem(h) + if err != nil { + return nil, err + } + if len(project.LatestStatusUpdate.Nodes) > 0 { + return project.LatestStatusUpdate.Nodes[0], nil + } + return nil, nil +} + +func projectV2HydrateIsTemplate(_ context.Context, _ *plugin.QueryData, h *plugin.HydrateData) (interface{}, error) { + project, err := extractProjectV2FromHydrateItem(h) + if err != nil { + return nil, err + } + return project.IsTemplate, nil +} diff --git a/github/table_github_issue.go b/github/table_github_issue.go index 686a66b..b7ab0ec 100644 --- a/github/table_github_issue.go +++ b/github/table_github_issue.go @@ -64,6 +64,7 @@ func sharedIssueColumns() []*plugin.Column { {Name: "user_did_author", Type: proto.ColumnType_BOOL, Hydrate: issueHydrateUserDidAuthor, Transform: transform.FromValue(), Description: "If true, user authored the issue."}, {Name: "user_subscription", Type: proto.ColumnType_STRING, Hydrate: issueHydrateUserSubscription, Transform: transform.FromValue(), Description: "Subscription state of the user to the issue."}, {Name: "assignees", Type: proto.ColumnType_JSON, Hydrate: issueHydrateAssignees, Transform: transform.FromValue().NullIfZero(), Description: "A list of Users assigned to the issue."}, + {Name: "project_items", Type: proto.ColumnType_JSON, Hydrate: issueHydrateProjectItems, Transform: transform.FromValue().NullIfZero(), Description: "A list of project node IDs (PVT_...) for ProjectV2 projects the issue belongs to."}, } } diff --git a/github/table_github_project_v2.go b/github/table_github_project_v2.go new file mode 100644 index 0000000..36915a7 --- /dev/null +++ b/github/table_github_project_v2.go @@ -0,0 +1,149 @@ +package github + +import ( + "context" + + "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 gitHubProjectV2Columns() []*plugin.Column { + tableCols := []*plugin.Column{ + {Name: "organization", Type: proto.ColumnType_STRING, Transform: transform.FromQual("organization"), Description: "The organization name."}, + } + + return append(tableCols, sharedProjectV2Columns()...) +} + +func sharedProjectV2Columns() []*plugin.Column { + return []*plugin.Column{ + {Name: "number", Type: proto.ColumnType_INT, Transform: transform.FromField("Number", "Node.Number"), Description: "The project number."}, + {Name: "id", Type: proto.ColumnType_INT, Hydrate: projectV2HydrateId, Transform: transform.FromValue(), Description: "The ID of the project."}, + {Name: "node_id", Type: proto.ColumnType_STRING, Hydrate: projectV2HydrateNodeId, Transform: transform.FromValue(), Description: "The node ID of the project."}, + {Name: "owner", Type: proto.ColumnType_JSON, Hydrate: projectV2HydrateOwner, Transform: transform.FromValue().NullIfZero(), Description: "The owner of the project."}, + {Name: "creator", Type: proto.ColumnType_JSON, Hydrate: projectV2HydrateCreator, Transform: transform.FromValue().NullIfZero(), Description: "The creator of the project."}, + {Name: "title", Type: proto.ColumnType_STRING, Hydrate: projectV2HydrateTitle, Transform: transform.FromValue(), Description: "The title of the project."}, + {Name: "description", Type: proto.ColumnType_STRING, Hydrate: projectV2HydrateDescription, Transform: transform.FromValue(), Description: "The description of the project (maps to shortDescription in GraphQL)."}, + {Name: "is_public", Type: proto.ColumnType_BOOL, Hydrate: projectV2HydrateIsPublic, Transform: transform.FromValue(), Description: "If true, the project is public."}, + {Name: "closed_at", Type: proto.ColumnType_TIMESTAMP, Hydrate: projectV2HydrateClosedAt, Transform: transform.FromValue().NullIfZero().Transform(convertTimestamp), Description: "The time when the project was closed."}, + {Name: "created_at", Type: proto.ColumnType_TIMESTAMP, Hydrate: projectV2HydrateCreatedAt, Transform: transform.FromValue().NullIfZero().Transform(convertTimestamp), Description: "The time when the project was created."}, + {Name: "updated_at", Type: proto.ColumnType_TIMESTAMP, Hydrate: projectV2HydrateUpdatedAt, Transform: transform.FromValue().NullIfZero().Transform(convertTimestamp), Description: "The time when the project was last updated."}, + {Name: "state", Type: proto.ColumnType_STRING, Hydrate: projectV2HydrateState, Transform: transform.FromValue(), Description: "The state of the project (open or closed). Derived from the GraphQL closed boolean."}, + {Name: "latest_status_update", Type: proto.ColumnType_JSON, Hydrate: projectV2HydrateLatestStatusUpdate, Transform: transform.FromValue().NullIfZero(), Description: "The latest status update of the project."}, + {Name: "is_template", Type: proto.ColumnType_BOOL, Hydrate: projectV2HydrateIsTemplate, Transform: transform.FromValue(), Description: "If true, the project is a template."}, + } +} + +func tableGitHubProjectV2() *plugin.Table { + return &plugin.Table{ + Name: "github_project_v2", + Description: "GitHub Projects are used to organize and manage work on GitHub.", + List: &plugin.ListConfig{ + KeyColumns: []*plugin.KeyColumn{ + { + Name: "organization", + Require: plugin.Required, + }, + { + Name: "updated_at", + Require: plugin.Optional, + Operators: []string{">", ">="}, + }, + }, + ShouldIgnoreError: isNotFoundError([]string{"404"}), + Hydrate: tableGitHubProjectV2List, + }, + Get: &plugin.GetConfig{ + KeyColumns: plugin.AllColumns([]string{"organization", "number"}), + ShouldIgnoreError: isNotFoundError([]string{"404"}), + Hydrate: tableGitHubProjectV2Get, + }, + Columns: commonColumns(gitHubProjectV2Columns()), + } +} + +func tableGitHubProjectV2List(ctx context.Context, d *plugin.QueryData, h *plugin.HydrateData) (interface{}, error) { + quals := d.EqualsQuals + organization := quals["organization"].GetStringValue() + + pageSize := adjustPageSize(100, d.QueryContext.Limit) + + var query struct { + RateLimit models.RateLimit + Organization struct { + ProjectsV2 struct { + PageInfo models.PageInfo + TotalCount int + Nodes []models.ProjectV2 + } `graphql:"projectsV2(first: $pageSize, after: $cursor)"` + } `graphql:"organization(login: $organization)"` + } + + variables := map[string]interface{}{ + "organization": githubv4.String(organization), + "pageSize": githubv4.Int(pageSize), + "cursor": (*githubv4.String)(nil), + } + appendProjectV2ColumnIncludes(&variables, d.QueryContext.Columns) + + client := connectV4(ctx, d) + + for { + err := client.Query(ctx, &query, variables) + plugin.Logger(ctx).Debug(rateLimitLogString("github_project_v2", &query.RateLimit)) + if err != nil { + plugin.Logger(ctx).Error("github_project_v2", "api_error", err) + return nil, err + } + + for _, project := range query.Organization.ProjectsV2.Nodes { + d.StreamListItem(ctx, project) + + // Context can be cancelled due to manual cancellation or the limit has been hit + if d.RowsRemaining(ctx) == 0 { + return nil, nil + } + } + + if !query.Organization.ProjectsV2.PageInfo.HasNextPage { + break + } + variables["cursor"] = githubv4.NewString(query.Organization.ProjectsV2.PageInfo.EndCursor) + } + + return nil, nil +} + +func tableGitHubProjectV2Get(ctx context.Context, d *plugin.QueryData, _ *plugin.HydrateData) (interface{}, error) { + quals := d.EqualsQuals + projectId := int(quals["id"].GetInt64Value()) + organization := quals["organization"].GetStringValue() + + client := connectV4(ctx, d) + + var query struct { + RateLimit models.RateLimit + Organization struct { + ProjectV2 models.ProjectV2 `graphql:"projectV2(id: $projectId)"` + } `graphql:"organization(login: $organization)"` + } + + variables := map[string]interface{}{ + "organization": githubv4.String(organization), + "projectId": githubv4.Int(projectId), + } + appendProjectV2ColumnIncludes(&variables, d.QueryContext.Columns) + + err := client.Query(ctx, &query, variables) + plugin.Logger(ctx).Debug(rateLimitLogString("github_project_v2", &query.RateLimit)) + if err != nil { + plugin.Logger(ctx).Error("github_project_v2", "api_error", err) + return nil, err + } + + return query.Organization.ProjectV2, nil +}