Files
ghstats/internal/github/client.go
T
tiennm99 643a15862b feat: implement profile summary cards with GraphQL fetch and Action wrapper
- Add GraphQL client fetching profile, stats, language aggregation, and
  per-repo commit histograms for the productive-time heatmap.
- Render real SVG cards (profile details, top languages, stats grid,
  weekday×hour heatmap) with XML escaping and thousands-formatted numbers.
- Expand theme palette to 30 built-ins ported from github-readme-stats;
  add -list-themes, multi-theme rendering, and 'all' shortcut.
- Package as Docker-based GitHub Action (action.yml, Dockerfile,
  entrypoint.sh) with optional auto-commit of generated cards.
- Release workflow publishes GHCR image and cross-platform binaries on
  v* tags.
- Unit tests cover rendering, XML escape, number formatting, language sort.
2026-04-18 18:39:51 +07:00

113 lines
2.5 KiB
Go

// Package github fetches profile data from the GitHub GraphQL API.
package github
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const endpoint = "https://api.github.com/graphql"
// Client issues authenticated GraphQL requests.
type Client struct {
token string
http *http.Client
}
// NewClient returns a client authenticated with the given PAT. An empty token
// falls back to unauthenticated requests (60/h rate limit, no private data).
func NewClient(token string) *Client {
return &Client{
token: token,
http: &http.Client{Timeout: 30 * time.Second},
}
}
type gqlRequest struct {
Query string `json:"query"`
Variables map[string]any `json:"variables,omitempty"`
}
type gqlError struct {
Message string `json:"message"`
Type string `json:"type,omitempty"`
Path []string `json:"path,omitempty"`
}
type gqlResponse struct {
Data json.RawMessage `json:"data"`
Errors []gqlError `json:"errors,omitempty"`
}
// query runs a GraphQL query and unmarshals the `data` field into out.
func (c *Client) query(q string, vars map[string]any, out any) error {
body, err := json.Marshal(gqlRequest{Query: q, Variables: vars})
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("new request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "ghstats")
if c.token != "" {
req.Header.Set("Authorization", "bearer "+c.token)
}
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("http: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read body: %w", err)
}
if resp.StatusCode >= 400 {
return fmt.Errorf("http %d: %s", resp.StatusCode, truncate(raw, 500))
}
var r gqlResponse
if err := json.Unmarshal(raw, &r); err != nil {
return fmt.Errorf("decode body: %w", err)
}
if len(r.Errors) > 0 {
msgs := make([]string, 0, len(r.Errors))
for _, e := range r.Errors {
msgs = append(msgs, e.Message)
}
return fmt.Errorf("graphql: %s", joinErrs(msgs))
}
if out != nil {
if err := json.Unmarshal(r.Data, out); err != nil {
return fmt.Errorf("decode data: %w", err)
}
}
return nil
}
func truncate(b []byte, n int) string {
if len(b) <= n {
return string(b)
}
return string(b[:n]) + "…"
}
func joinErrs(ss []string) string {
if len(ss) == 0 {
return ""
}
out := ss[0]
for _, s := range ss[1:] {
out += "; " + s
}
return out
}