package db
import (
"bytes"
"context"
_ "embed"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"time"
)
var projectQuery string
var listProjectsQuery string
type GraphQLQuery struct {
Query string `json:"query"`
Variables map[string]interface{} `json:"variables"`
}
type GraphQLResponse struct {
Data json.RawMessage `json:"data"`
Errors []map[string]interface{} `json:"errors,omitempty"`
}
type ProjectV2Response struct {
Organization struct {
ProjectV2 struct {
ID string `json:"id"`
Title string `json:"title"`
ShortDescription string `json:"shortDescription"`
Closed bool `json:"closed"`
URL string `json:"url"`
Fields struct {
Nodes []struct {
ID string `json:"id"`
Name string `json:"name"`
DataType string `json:"dataType"`
} `json:"nodes"`
} `json:"fields"`
Items struct {
Nodes []struct {
ID string `json:"id"`
UpdatedAt time.Time `json:"updatedAt"`
FieldValues struct {
Nodes []struct {
Name string `json:"name"`
Field struct {
Name string `json:"name"`
} `json:"field"`
} `json:"nodes"`
} `json:"fieldValues"`
Content struct {
Title string `json:"title"`
URL string `json:"url"`
State string `json:"state"`
Body string `json:"body"`
Labels struct {
Nodes []struct {
Name string `json:"name"`
} `json:"nodes"`
} `json:"labels"`
} `json:"content"`
} `json:"nodes"`
PageInfo struct {
EndCursor string `json:"endCursor"`
HasNextPage bool `json:"hasNextPage"`
} `json:"pageInfo"`
} `json:"items"`
} `json:"projectV2"`
} `json:"organization"`
}
type Client struct {
Endpoint string
Token string
HTTP *http.Client
}
func New(endpoint string) *Client {
token := os.Getenv("GITHUB_TOKEN")
if token == "" {
panic("set GITHUB_TOKEN env variable")
}
return &Client{
Endpoint: endpoint,
Token: token,
HTTP: &http.Client{Timeout: 15 * time.Second},
}
}
func DoQuery[T any](ctx context.Context, c *Client, query string, variables map[string]any) (T, error) {
slog.Info("executing graphql", "query", query)
var result T
reqBody := GraphQLQuery{
Query: query,
Variables: variables,
}
body, err := json.Marshal(reqBody)
if err != nil {
return result, err
}
req, err := http.NewRequestWithContext(ctx, "POST", c.Endpoint, bytes.NewReader(body))
if err != nil {
return result, err
}
req.Header.Set("Authorization", "Bearer "+c.Token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.HTTP.Do(req)
if err != nil {
return result, err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
return result, err
}
var gqlResp GraphQLResponse
if err := json.Unmarshal(b, &gqlResp); err != nil {
return result, err
}
if len(gqlResp.Errors) > 0 {
return result, fmt.Errorf("failed to execute project GraphQL with %#v", gqlResp.Errors)
}
err = json.Unmarshal(gqlResp.Data, &result)
return result, err
}
type listProjectsResponse struct {
Organization struct {
ProjectsV2 struct {
Nodes []ProjectInfo `json:"nodes"`
} `json:"projectsv2"`
} `json:"organization"`
}
type ProjectInfo struct {
Id int `json:"number"`
Title string `json:"title"`
ShortDescription string `json:"shortDescription"`
Closed bool `json:"closed"`
URL string `json:"url"`
}
func (c *Client) ListProjects(ctx context.Context, org string) (projects []ProjectInfo, _ error) {
variables := map[string]any{
"orgName": org,
}
resp, err := DoQuery[listProjectsResponse](ctx, c, listProjectsQuery, variables)
if err != nil {
return projects, fmt.Errorf("failed to list projects for org '%s' with %w", org, err)
}
return resp.Organization.ProjectsV2.Nodes, nil
}
func (c *Client) GetOrgProject(ctx context.Context, org string, projectNumber int, since time.Time) ([]Issue, error) {
var issues []Issue
after := ""
var times int
for {
variables := map[string]interface{}{
"org": org,
"projectNumber": projectNumber,
"after": nil,
}
if after != "" {
variables["after"] = after
}
resp, err := DoQuery[ProjectV2Response](ctx, c, projectQuery, variables)
if err != nil {
slog.Error("failed to request projectv2", "with", err)
return issues, err
}
nodes := resp.Organization.ProjectV2.Items.Nodes
for _, node := range nodes {
if !node.UpdatedAt.IsZero() && node.UpdatedAt.Before(since) {
slog.Warn("unknown node date invariant", "node", node)
return issues, err
}
var status string
for _, fieldValue := range node.FieldValues.Nodes {
if fieldValue.Field.Name == "Status" {
status = fieldValue.Name
break
}
}
var labels []string
for _, label := range node.Content.Labels.Nodes {
labels = append(labels, label.Name)
}
content := node.Content
issues = append(issues, Issue{
Title: content.Title,
URL: content.URL,
State: content.State,
Body: content.Body,
Labels: labels,
Status: status,
})
}
pageInfo := resp.Organization.ProjectV2.Items.PageInfo
if !pageInfo.HasNextPage || pageInfo.EndCursor == "" {
break
}
after = pageInfo.EndCursor
times += 1
slog.Info("continue fetching pages", "pageInfo", pageInfo, "times", times)
}
slog.Info("GraphQL was queried", "times", times)
return issues, nil
}
type Issue struct {
Title string `json:"title"`
URL string `json:"url"`
State string `json:"state"`
Body string `json:"body"`
Labels []string `json:"labels"`
Status string `json:"status"`
}