diff --git a/docs/tables/github_project_v2.md b/docs/tables/github_project_v2.md new file mode 100644 index 0000000..8a0fa57 --- /dev/null +++ b/docs/tables/github_project_v2.md @@ -0,0 +1,209 @@ +--- +title: "Steampipe Table: github_project_v2 - Query GitHub Projects (V2) using SQL" +description: "Allows users to query GitHub Projects (V2), providing insights into the organization-level projects used to plan and track work." +folder: "Project" +--- + +# Table: github_project_v2 - Query GitHub Projects (V2) using SQL + +GitHub Projects (V2) is GitHub's flexible, table and board based tool for planning and tracking work across issues and pull requests. It allows teams to organize work items, track status, and view progress across one or more repositories using customizable views, fields, and workflows. + +## Table Usage Guide + +The `github_project_v2` table provides insights into the ProjectsV2 owned by a GitHub organization. As a project manager or developer, explore project-specific details through this table, including title, description, visibility, status, linked repositories, and linked teams. Utilize it to uncover information about projects, such as which ones are public, which repositories and teams are associated with them, and when they were last updated. + +To query this table using a [fine-grained access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-fine-grained-personal-access-token), the following permissions are required: + - Organization permissions: + - Projects (Read-only): Required to access all columns. + +**Important Notes** +- You must specify the `organization` column in a `where` or `join` clause to query the table. + +## Examples + +### List the projects in an organization +Explore the title, state, and visibility of the ProjectsV2 owned by a specific GitHub organization to get an overview of ongoing work. + +```sql+postgres +select + organization, + number, + title, + state, + is_public, + created_at +from + github_project_v2 +where + organization = 'turbot'; +``` + +```sql+sqlite +select + organization, + number, + title, + state, + is_public, + created_at +from + github_project_v2 +where + organization = 'turbot'; +``` + +### List open projects in an organization +Identify the projects that are still open in a specific organization, to help focus attention on active planning boards. + +```sql+postgres +select + organization, + number, + title, + created_at, + updated_at +from + github_project_v2 +where + organization = 'turbot' +and + state = 'open'; +``` + +```sql+sqlite +select + organization, + number, + title, + created_at, + updated_at +from + github_project_v2 +where + organization = 'turbot' + and state = 'open'; +``` + +### Get a specific project by number +Retrieve the details of a single project using its project number, useful when you already know which project you want to inspect. + +```sql+postgres +select + number, + title, + description, + owner, + creator +from + github_project_v2 +where + organization = 'turbot' +and + number = 1; +``` + +```sql+sqlite +select + number, + title, + description, + owner, + creator +from + github_project_v2 +where + organization = 'turbot' + and number = 1; +``` + +### List projects updated in the last 30 days +Discover the projects that have had recent activity, useful for tracking which planning boards are actively being maintained. + +```sql+postgres +select + number, + title, + updated_at +from + github_project_v2 +where + organization = 'turbot' +and + updated_at >= now() - interval '30 days' +order by + updated_at desc; +``` + +```sql+sqlite +select + number, + title, + updated_at +from + github_project_v2 +where + organization = 'turbot' + and updated_at >= datetime('now', '-30 days') +order by + updated_at desc; +``` + +### List repositories and teams linked to each project +Explore which repositories and teams are linked to each project, to understand the scope of collaboration around a project. + +```sql+postgres +select + number, + title, + repositories, + repositories_total_count, + teams, + teams_total_count +from + github_project_v2 +where + organization = 'turbot'; +``` + +```sql+sqlite +select + number, + title, + repositories, + repositories_total_count, + teams, + teams_total_count +from + github_project_v2 +where + organization = 'turbot'; +``` + +### List the latest status update for each project +Explore the most recent status update posted on each project, useful for quickly checking the reported health and progress of a project. + +```sql+postgres +select + number, + title, + latest_status_update ->> 'status' as status, + latest_status_update ->> 'body' as body, + latest_status_update ->> 'created_at' as reported_at +from + github_project_v2 +where + organization = 'turbot'; +``` + +```sql+sqlite +select + number, + title, + json_extract(latest_status_update, '$.status') as status, + json_extract(latest_status_update, '$.body') as body, + json_extract(latest_status_update, '$.created_at') as reported_at +from + github_project_v2 +where + organization = 'turbot'; +``` diff --git a/github/issue_pr_utils.go b/github/issue_pr_utils.go index c30065d..254e63b 100644 --- a/github/issue_pr_utils.go +++ b/github/issue_pr_utils.go @@ -158,6 +158,8 @@ 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") || slices.Contains(cols, "project_items_total_count")) + (*m)["includeIssueProjectsV2"] = githubv4.Boolean(slices.Contains(cols, "projects_v2_total_count")) } func issueHydrateIsReadByUser(_ context.Context, _ *plugin.QueryData, h *plugin.HydrateData) (interface{}, error) { @@ -472,6 +474,37 @@ 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 []string{}, nil + } + nodeIds := make([]string, len(issue.ProjectItems.Nodes)) + for i, item := range issue.ProjectItems.Nodes { + nodeIds[i] = item.Project.Id + } + return nodeIds, nil +} + +func issueHydrateProjectItemsTotalCount(_ context.Context, _ *plugin.QueryData, h *plugin.HydrateData) (interface{}, error) { + issue, err := extractIssueFromHydrateItem(h) + if err != nil { + return nil, err + } + return issue.ProjectItems.TotalCount, nil +} + +func issueHydrateProjectsV2TotalCount(_ context.Context, _ *plugin.QueryData, h *plugin.HydrateData) (interface{}, error) { + issue, err := extractIssueFromHydrateItem(h) + if err != nil { + return nil, err + } + return issue.ProjectsV2.TotalCount, 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..62eac92 100644 --- a/github/models/issue.go +++ b/github/models/issue.go @@ -49,6 +49,17 @@ 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"` + ProjectsV2 struct { + TotalCount int + } `graphql:"projectsV2 @include(if:$includeIssueProjectsV2)" json:"projects_v2"` } type IssueTemplate struct { diff --git a/github/models/misc.go b/github/models/misc.go index 2570a7d..4cb3270 100644 --- a/github/models/misc.go +++ b/github/models/misc.go @@ -1,6 +1,7 @@ package models import ( + "strings" "time" "github.com/shurcooL/githubv4" @@ -18,6 +19,35 @@ func (t NullableTime) MarshalJSON() ([]byte, error) { } } +// NullableDate wraps time.Time to support unmarshalling the GraphQL `Date` +// scalar (e.g. "2024-08-11"), which does not include a time-of-day or +// timezone component like `DateTime`/`ISO8601DateTime` scalars do. +type NullableDate struct { + time.Time +} + +func (d *NullableDate) UnmarshalJSON(data []byte) error { + s := strings.Trim(string(data), `"`) + if s == "" || s == "null" { + d.Time = time.Time{} + return nil + } + + parsed, err := time.Parse("2006-01-02", s) + if err != nil { + return err + } + d.Time = parsed + return nil +} + +func (d NullableDate) MarshalJSON() ([]byte, error) { + if d.IsZero() { + return []byte("null"), nil + } + return []byte(`"` + d.Time.Format("2006-01-02") + `"`), nil +} + type NameSlug struct { Name string `json:"name"` Slug string `json:"slug"` @@ -55,7 +85,7 @@ type SponsorsListing struct { FullDescription string `json:"full_description"` IsPublic bool `json:"is_public"` Name string `json:"name"` - NextPayoutDate time.Time `json:"next_payout_date"` + NextPayoutDate NullableDate `json:"next_payout_date"` ResidenceCountryOrRegion string `json:"residence_country_or_region"` ShortDescription string `json:"short_description"` Slug string `json:"slug"` diff --git a/github/models/project_v2.go b/github/models/project_v2.go new file mode 100644 index 0000000..4b59493 --- /dev/null +++ b/github/models/project_v2.go @@ -0,0 +1,61 @@ +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"` + Readme string `graphql:"readme @include(if:$includeReadme)" json:"readme"` + ResourcePath string `graphql:"resourcePath @include(if:$includeResourcePath)" json:"resource_path"` + Url string `graphql:"url @include(if:$includeUrl)" json:"url"` + Repositories struct { + TotalCount int + Nodes []struct { + NameWithOwner string `json:"name_with_owner"` + } + } `graphql:"repositories(first: 100) @include(if:$includeRepositories)" json:"repositories"` + Teams struct { + TotalCount int + Nodes []struct { + Slug string `json:"slug"` + } + } `graphql:"teams(first: 100) @include(if:$includeTeams)" json:"teams"` +} diff --git a/github/plugin.go b/github/plugin.go index ad5c0c0..11d518c 100644 --- a/github/plugin.go +++ b/github/plugin.go @@ -58,6 +58,7 @@ func Plugin(ctx context.Context) *plugin.Plugin { "github_organization_ruleset": tableGitHubOrganizationRuleset(), "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..2f0191c --- /dev/null +++ b/github/project_v2_utils.go @@ -0,0 +1,215 @@ +package github + +import ( + "context" + "fmt" + "slices" + + "github.com/shurcooL/githubv4" + "github.com/turbot/steampipe-plugin-github/github/models" + "github.com/turbot/steampipe-plugin-sdk/v6/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")) + (*m)["includeReadme"] = githubv4.Boolean(slices.Contains(cols, "readme")) + (*m)["includeResourcePath"] = githubv4.Boolean(slices.Contains(cols, "resource_path")) + (*m)["includeUrl"] = githubv4.Boolean(slices.Contains(cols, "url")) + (*m)["includeRepositories"] = githubv4.Boolean(slices.Contains(cols, "repositories") || slices.Contains(cols, "repositories_total_count")) + (*m)["includeTeams"] = githubv4.Boolean(slices.Contains(cols, "teams") || slices.Contains(cols, "teams_total_count")) +} + +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 +} + +func projectV2HydrateReadme(_ context.Context, _ *plugin.QueryData, h *plugin.HydrateData) (interface{}, error) { + project, err := extractProjectV2FromHydrateItem(h) + if err != nil { + return nil, err + } + return project.Readme, nil +} + +func projectV2HydrateResourcePath(_ context.Context, _ *plugin.QueryData, h *plugin.HydrateData) (interface{}, error) { + project, err := extractProjectV2FromHydrateItem(h) + if err != nil { + return nil, err + } + return project.ResourcePath, nil +} + +func projectV2HydrateUrl(_ context.Context, _ *plugin.QueryData, h *plugin.HydrateData) (interface{}, error) { + project, err := extractProjectV2FromHydrateItem(h) + if err != nil { + return nil, err + } + return project.Url, nil +} + +func projectV2HydrateRepositories(_ context.Context, _ *plugin.QueryData, h *plugin.HydrateData) (interface{}, error) { + project, err := extractProjectV2FromHydrateItem(h) + if err != nil { + return nil, err + } + repos := make([]string, 0, len(project.Repositories.Nodes)) + for _, node := range project.Repositories.Nodes { + repos = append(repos, node.NameWithOwner) + } + return repos, nil +} + +func projectV2HydrateRepositoriesTotalCount(_ context.Context, _ *plugin.QueryData, h *plugin.HydrateData) (interface{}, error) { + project, err := extractProjectV2FromHydrateItem(h) + if err != nil { + return nil, err + } + return project.Repositories.TotalCount, nil +} + +func projectV2HydrateTeams(_ context.Context, _ *plugin.QueryData, h *plugin.HydrateData) (interface{}, error) { + project, err := extractProjectV2FromHydrateItem(h) + if err != nil { + return nil, err + } + teams := make([]string, 0, len(project.Teams.Nodes)) + for _, node := range project.Teams.Nodes { + teams = append(teams, node.Slug) + } + return teams, nil +} + +func projectV2HydrateTeamsTotalCount(_ context.Context, _ *plugin.QueryData, h *plugin.HydrateData) (interface{}, error) { + project, err := extractProjectV2FromHydrateItem(h) + if err != nil { + return nil, err + } + return project.Teams.TotalCount, nil +} diff --git a/github/table_github_issue.go b/github/table_github_issue.go index b5cf602..f61112e 100644 --- a/github/table_github_issue.go +++ b/github/table_github_issue.go @@ -64,6 +64,9 @@ 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(), Description: "A list of project node IDs (PVT_...) for ProjectV2 projects the issue belongs to."}, + {Name: "project_items_total_count", Type: proto.ColumnType_INT, Hydrate: issueHydrateProjectItemsTotalCount, Transform: transform.FromValue(), Description: "Count of ProjectV2 projects the issue belongs to."}, + {Name: "projects_v2_total_count", Type: proto.ColumnType_INT, Hydrate: issueHydrateProjectsV2TotalCount, Transform: transform.FromValue(), Description: "Count of ProjectsV2 the issue is linked to."}, } } diff --git a/github/table_github_project_v2.go b/github/table_github_project_v2.go new file mode 100644 index 0000000..719573c --- /dev/null +++ b/github/table_github_project_v2.go @@ -0,0 +1,199 @@ +package github + +import ( + "context" + "time" + + "github.com/shurcooL/githubv4" + "github.com/turbot/steampipe-plugin-github/github/models" + + "github.com/turbot/steampipe-plugin-sdk/v6/grpc/proto" + "github.com/turbot/steampipe-plugin-sdk/v6/plugin" + "github.com/turbot/steampipe-plugin-sdk/v6/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."}, + {Name: "readme", Type: proto.ColumnType_STRING, Hydrate: projectV2HydrateReadme, Transform: transform.FromValue(), Description: "The readme of the project."}, + {Name: "resource_path", Type: proto.ColumnType_STRING, Hydrate: projectV2HydrateResourcePath, Transform: transform.FromValue(), Description: "The HTTP path for this project."}, + {Name: "url", Type: proto.ColumnType_STRING, Hydrate: projectV2HydrateUrl, Transform: transform.FromValue(), Description: "The HTTP URL for this project."}, + {Name: "repositories", Type: proto.ColumnType_JSON, Hydrate: projectV2HydrateRepositories, Transform: transform.FromValue(), Description: "Array of full repository names (owner/repo) linked to the project."}, + {Name: "repositories_total_count", Type: proto.ColumnType_INT, Hydrate: projectV2HydrateRepositoriesTotalCount, Transform: transform.FromValue(), Description: "Count of repositories linked to the project."}, + {Name: "teams", Type: proto.ColumnType_JSON, Hydrate: projectV2HydrateTeams, Transform: transform.FromValue(), Description: "Array of team slugs linked to the project."}, + {Name: "teams_total_count", Type: proto.ColumnType_INT, Hydrate: projectV2HydrateTeamsTotalCount, Transform: transform.FromValue(), Description: "Count of teams linked to the project."}, + } +} + +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) + + // The projectsV2 GraphQL field has no server-side filter for updatedAt, only an + // orderBy argument. To still honor the updated_at qual (and avoid scanning every + // page), we always request results ordered newest-first by updatedAt, then stop + // paging as soon as we see a project older than the requested threshold. + var minUpdatedAt *time.Time + var minUpdatedAtInclusive bool + if d.Quals["updated_at"] != nil { + for _, q := range d.Quals["updated_at"].Quals { + givenTime := q.Value.GetTimestampValue().AsTime() + switch q.Operator { + case ">": + if minUpdatedAt == nil || givenTime.After(*minUpdatedAt) { + minUpdatedAt = &givenTime + minUpdatedAtInclusive = false + } + case ">=": + if minUpdatedAt == nil || givenTime.After(*minUpdatedAt) { + minUpdatedAt = &givenTime + minUpdatedAtInclusive = true + } + } + } + } + + var query struct { + RateLimit models.RateLimit + Organization struct { + ProjectsV2 struct { + PageInfo models.PageInfo + TotalCount int + Nodes []models.ProjectV2 + } `graphql:"projectsV2(first: $pageSize, after: $cursor, orderBy: $orderBy)"` + } `graphql:"organization(login: $organization)"` + } + + variables := map[string]interface{}{ + "organization": githubv4.String(organization), + "pageSize": githubv4.Int(pageSize), + "cursor": (*githubv4.String)(nil), + "orderBy": githubv4.ProjectV2Order{ + Field: githubv4.ProjectV2OrderFieldUpdatedAt, + Direction: githubv4.OrderDirectionDesc, + }, + } + appendProjectV2ColumnIncludes(&variables, d.QueryContext.Columns) + if minUpdatedAt != nil { + // Force updatedAt to be fetched so we can compare it, even if the column wasn't requested. + variables["includeUpdatedAt"] = githubv4.Boolean(true) + } + + 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 { + if minUpdatedAt != nil && !project.UpdatedAt.IsZero() { + updatedAt := project.UpdatedAt.Time + if updatedAt.Before(*minUpdatedAt) || (!minUpdatedAtInclusive && updatedAt.Equal(*minUpdatedAt)) { + // Results are ordered newest-first, so once we see a project + // older than the threshold, every subsequent project (on this + // page and later pages) will also be too old. + return nil, nil + } + } + + 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 +}