mirror of
https://github.com/tiennm99/ghstats.git
synced 2026-08-31 20:32:20 +00:00
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.
This commit is contained in:
+100
-30
@@ -1,42 +1,112 @@
|
||||
// Package github fetches profile data from the GitHub API.
|
||||
// Package github fetches profile data from the GitHub GraphQL API.
|
||||
package github
|
||||
|
||||
import "errors"
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Profile is the aggregate of data other packages render into cards.
|
||||
// Fields are stubs; flesh them out as cards are implemented.
|
||||
type Profile struct {
|
||||
Login string
|
||||
Name string
|
||||
Bio string
|
||||
Followers int
|
||||
Following int
|
||||
PublicRepos int
|
||||
const endpoint = "https://api.github.com/graphql"
|
||||
|
||||
// Top languages aggregated across repos (name → bytes).
|
||||
Languages map[string]int64
|
||||
|
||||
// Commit-count histogram indexed by [day-of-week][hour-of-day], local tz.
|
||||
Productive [7][24]int
|
||||
}
|
||||
|
||||
// Client wraps GitHub REST + GraphQL access.
|
||||
// Client issues authenticated GraphQL requests.
|
||||
type Client struct {
|
||||
token string
|
||||
// TODO: http.Client, rate-limit handling
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// NewClient returns a client that authenticates with the given PAT.
|
||||
// Empty token uses unauthenticated access (low rate limit).
|
||||
// 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}
|
||||
return &Client{
|
||||
token: token,
|
||||
http: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Profile loads the profile summary for a user.
|
||||
func (c *Client) Profile(user string) (*Profile, error) {
|
||||
if user == "" {
|
||||
return nil, errors.New("empty user")
|
||||
}
|
||||
// TODO: fetch via GraphQL: viewer, user.repositories, contributionsCollection
|
||||
return &Profile{Login: user}, nil
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user