mirror of
https://github.com/tiennm99/ghstats.git
synced 2026-05-14 16:58:54 +00:00
643a15862b
- 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.
113 lines
2.5 KiB
Go
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
|
|
}
|
|
|