mirror of
https://github.com/tiennm99/ghstats.git
synced 2026-08-31 10:34:02 +00:00
refactor: split language card into repos-per-language and most-commit-language
Align card set with github-profile-summary-cards' 5-card layout: 0-profile-details.svg (unchanged) 1-repos-per-language.svg (new) owned repos grouped by primary language 2-most-commit-language.svg (new) last-year commits attributed to each repo's primary language 3-stats.svg (renumbered) 4-productive-time.svg (renumbered) - FetchProductive now fills p.CommitsByLanguage from the same commit history it uses for the heatmap, so no extra API calls are introduced. - TopRepos carries primary language so productive-time can aggregate by lang. - LangStat.Bytes renamed to Value (repo count or commit count, context-dependent). - Shared bar+legend renderer extracted to language_bar.go. - Ignore generated output/ directory.
This commit is contained in:
@@ -27,6 +27,9 @@ go.work.sum
|
||||
# env file
|
||||
.env
|
||||
|
||||
# Generated SVG cards from local runs
|
||||
output/
|
||||
|
||||
# Editor/IDE
|
||||
# .idea/
|
||||
# .vscode/
|
||||
|
||||
@@ -7,7 +7,8 @@ public data for a GitHub user and writes a themed set of SVGs you can embed in
|
||||
your profile README:
|
||||
|
||||
- Profile details
|
||||
- Top languages
|
||||
- Repos per language (how many owned repos use each language as primary)
|
||||
- Most commit language (last year's commits attributed to each repo's primary language)
|
||||
- Stats (stars, commits, PRs, issues, PR reviews, contributed-to)
|
||||
- Productive time heatmap (weekday × hour)
|
||||
|
||||
@@ -45,9 +46,10 @@ Then embed the cards in your `README.md`:
|
||||
|
||||
```md
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
```
|
||||
|
||||
### Action inputs
|
||||
@@ -112,9 +114,10 @@ Run `ghstats -list-themes` for the full list. Built-ins include `default`,
|
||||
output/
|
||||
dracula/
|
||||
0-profile-details.svg
|
||||
1-languages.svg
|
||||
2-stats.svg
|
||||
3-productive-time.svg
|
||||
1-repos-per-language.svg
|
||||
2-most-commit-language.svg
|
||||
3-stats.svg
|
||||
4-productive-time.svg
|
||||
```
|
||||
|
||||
## Tokens & permissions
|
||||
|
||||
@@ -22,7 +22,8 @@ type Card interface {
|
||||
// Keep filename prefixes numeric so the output directory lists in a predictable order.
|
||||
var allCards = []Card{
|
||||
profileCard{},
|
||||
languagesCard{},
|
||||
reposPerLanguageCard{},
|
||||
mostCommitLanguageCard{},
|
||||
statsCard{},
|
||||
productiveCard{},
|
||||
}
|
||||
|
||||
@@ -19,10 +19,14 @@ func TestRenderAll(t *testing.T) {
|
||||
Following: 7,
|
||||
PublicRepos: 42,
|
||||
TotalStars: 1234,
|
||||
Languages: []github.LangStat{
|
||||
{Name: "Go", Color: "#00ADD8", Bytes: 5000},
|
||||
{Name: "TypeScript", Color: "#3178c6", Bytes: 3000},
|
||||
{Name: "Python", Color: "", Bytes: 2000},
|
||||
ReposByLanguage: []github.LangStat{
|
||||
{Name: "Go", Color: "#00ADD8", Value: 5},
|
||||
{Name: "TypeScript", Color: "#3178c6", Value: 3},
|
||||
{Name: "Python", Color: "", Value: 2},
|
||||
},
|
||||
CommitsByLanguage: []github.LangStat{
|
||||
{Name: "Go", Color: "#00ADD8", Value: 420},
|
||||
{Name: "Python", Color: "#3572A5", Value: 150},
|
||||
},
|
||||
}
|
||||
p.Productive[2][14] = 7
|
||||
@@ -39,9 +43,10 @@ func TestRenderAll(t *testing.T) {
|
||||
|
||||
want := []string{
|
||||
"0-profile-details.svg",
|
||||
"1-languages.svg",
|
||||
"2-stats.svg",
|
||||
"3-productive-time.svg",
|
||||
"1-repos-per-language.svg",
|
||||
"2-most-commit-language.svg",
|
||||
"3-stats.svg",
|
||||
"4-productive-time.svg",
|
||||
}
|
||||
for _, name := range want {
|
||||
data, err := os.ReadFile(filepath.Join(dir, "dracula", name))
|
||||
|
||||
@@ -8,11 +8,11 @@ import (
|
||||
"github.com/tiennm99/ghstats/internal/theme"
|
||||
)
|
||||
|
||||
type languagesCard struct{}
|
||||
|
||||
func (languagesCard) Filename() string { return "1-languages.svg" }
|
||||
|
||||
func (languagesCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
|
||||
// renderLanguageCard draws a horizontal stacked bar + legend from a list of
|
||||
// LangStats. Shared by the repos-per-language and most-commit-language cards.
|
||||
//
|
||||
// title is the card heading; empty is rendered as the "no data" fallback.
|
||||
func renderLanguageCard(title string, stats []github.LangStat, t theme.Theme) []byte {
|
||||
const (
|
||||
width = 500
|
||||
height = 220
|
||||
@@ -25,58 +25,55 @@ func (languagesCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
|
||||
)
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(header(width, height, t.Background, t.Title, "Top Languages"))
|
||||
b.WriteString(header(width, height, t.Background, t.Title, title))
|
||||
|
||||
langs := p.Languages
|
||||
if len(langs) > topN {
|
||||
langs = langs[:topN]
|
||||
if len(stats) > topN {
|
||||
stats = stats[:topN]
|
||||
}
|
||||
|
||||
if len(langs) == 0 {
|
||||
if len(stats) == 0 {
|
||||
fmt.Fprintf(&b, `
|
||||
<text x="25" y="90" font-size="13" fill="%s">No language data available.</text>`, t.Muted)
|
||||
<text x="25" y="90" font-size="13" fill="%s">No data available.</text>`, t.Muted)
|
||||
b.WriteString(footer)
|
||||
return []byte(b.String()), nil
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
var total int64
|
||||
for _, l := range langs {
|
||||
total += l.Bytes
|
||||
for _, s := range stats {
|
||||
total += s.Value
|
||||
}
|
||||
|
||||
// Stacked bar.
|
||||
fmt.Fprintf(&b, `
|
||||
<rect x="%d" y="%d" width="%d" height="%d" rx="5" fill="%s"/>
|
||||
<g>`,
|
||||
barX, barY, barW, barH, t.Muted)
|
||||
|
||||
offset := float64(barX)
|
||||
for _, l := range langs {
|
||||
w := float64(barW) * float64(l.Bytes) / float64(total)
|
||||
for _, s := range stats {
|
||||
w := float64(barW) * float64(s.Value) / float64(total)
|
||||
fmt.Fprintf(&b, `
|
||||
<rect x="%.2f" y="%d" width="%.2f" height="%d" fill="%s"/>`,
|
||||
offset, barY, w, barH, colorOrAccent(l.Color, t.Accent))
|
||||
offset, barY, w, barH, colorOrAccent(s.Color, t.Accent))
|
||||
offset += w
|
||||
}
|
||||
b.WriteString(`
|
||||
</g>`)
|
||||
|
||||
// Legend: two columns of up to 3 rows.
|
||||
for i, l := range langs {
|
||||
for i, s := range stats {
|
||||
col := i % 2
|
||||
row := i / 2
|
||||
x := legendX0 + col*230
|
||||
y := 110 + row*24
|
||||
pct := 100 * float64(l.Bytes) / float64(total)
|
||||
pct := 100 * float64(s.Value) / float64(total)
|
||||
fmt.Fprintf(&b, `
|
||||
<circle cx="%d" cy="%d" r="6" fill="%s"/>
|
||||
<text x="%d" y="%d" font-size="13" fill="%s">%s %.2f%%</text>`,
|
||||
x+6, y-4, colorOrAccent(l.Color, t.Accent),
|
||||
x+20, y, t.Text, escapeXML(l.Name), pct)
|
||||
x+6, y-4, colorOrAccent(s.Color, t.Accent),
|
||||
x+20, y, t.Text, escapeXML(s.Name), pct)
|
||||
}
|
||||
|
||||
b.WriteString(footer)
|
||||
return []byte(b.String()), nil
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
func colorOrAccent(c, fallback string) string {
|
||||
@@ -0,0 +1,14 @@
|
||||
package card
|
||||
|
||||
import (
|
||||
"github.com/tiennm99/ghstats/internal/github"
|
||||
"github.com/tiennm99/ghstats/internal/theme"
|
||||
)
|
||||
|
||||
type mostCommitLanguageCard struct{}
|
||||
|
||||
func (mostCommitLanguageCard) Filename() string { return "2-most-commit-language.svg" }
|
||||
|
||||
func (mostCommitLanguageCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
|
||||
return renderLanguageCard("Most Commit Language (last year)", p.CommitsByLanguage, t), nil
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
type productiveCard struct{}
|
||||
|
||||
func (productiveCard) Filename() string { return "3-productive-time.svg" }
|
||||
func (productiveCard) Filename() string { return "4-productive-time.svg" }
|
||||
|
||||
var weekdayLabels = [7]string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package card
|
||||
|
||||
import (
|
||||
"github.com/tiennm99/ghstats/internal/github"
|
||||
"github.com/tiennm99/ghstats/internal/theme"
|
||||
)
|
||||
|
||||
type reposPerLanguageCard struct{}
|
||||
|
||||
func (reposPerLanguageCard) Filename() string { return "1-repos-per-language.svg" }
|
||||
|
||||
func (reposPerLanguageCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
|
||||
return renderLanguageCard("Repos Per Language", p.ReposByLanguage, t), nil
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
type statsCard struct{}
|
||||
|
||||
func (statsCard) Filename() string { return "2-stats.svg" }
|
||||
func (statsCard) Filename() string { return "3-stats.svg" }
|
||||
|
||||
func (statsCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
|
||||
const (
|
||||
|
||||
@@ -28,22 +28,34 @@ type Profile struct {
|
||||
TotalContributedTo int
|
||||
TotalContributions int // lifetime contributions from calendar + restricted
|
||||
|
||||
// Sorted desc by bytes. Color is GitHub's linguist color or "" if absent.
|
||||
Languages []LangStat
|
||||
// Count of owned repos grouped by primary language, sorted desc by Value.
|
||||
ReposByLanguage []LangStat
|
||||
|
||||
// Count of commits (last year, by this user) attributed to each repo's
|
||||
// primary language, sorted desc. Populated by FetchProductive.
|
||||
CommitsByLanguage []LangStat
|
||||
|
||||
// Commit-count histogram indexed by [day-of-week 0=Sunday][hour-of-day 0-23].
|
||||
Productive [7][24]int
|
||||
|
||||
// TopRepos is the list of owned repo names sorted by stargazer count desc,
|
||||
// populated by FetchProfile. Used as the seed set for FetchProductive.
|
||||
TopRepos []string
|
||||
// TopRepos are owned repos sorted by stargazer count desc. Populated by
|
||||
// FetchProfile and consumed by FetchProductive.
|
||||
TopRepos []RepoInfo
|
||||
}
|
||||
|
||||
// LangStat is one row in the top-languages card.
|
||||
// LangStat is one row in a language breakdown card. Value is repo count or
|
||||
// commit count depending on which slice holds it.
|
||||
type LangStat struct {
|
||||
Name string
|
||||
Color string
|
||||
Bytes int64
|
||||
Value int64
|
||||
}
|
||||
|
||||
// RepoInfo is the minimal owned-repo summary used for downstream fetches.
|
||||
type RepoInfo struct {
|
||||
Name string
|
||||
PrimaryLanguage string
|
||||
PrimaryColor string
|
||||
}
|
||||
|
||||
// repoNode is the GraphQL shape of one repository node; kept here because
|
||||
|
||||
@@ -24,17 +24,22 @@ type productiveGQL struct {
|
||||
}
|
||||
|
||||
// FetchProductive fills p.Productive with a [7][24] commit histogram over the
|
||||
// last year, gathered from the user's top-starred owned repos. Each repo is
|
||||
// sampled up to maxPerRepo commits to keep the cost bounded.
|
||||
// last year and p.CommitsByLanguage with commit counts attributed to each
|
||||
// repo's primary language. Commits are gathered from the given repos (usually
|
||||
// p.TopRepos[:N]); each repo is sampled up to maxPerRepo commits to keep the
|
||||
// cost bounded.
|
||||
//
|
||||
// The timezone loc is applied to CommittedDate so the heatmap reflects when the
|
||||
// user actually commits, not UTC.
|
||||
func (c *Client) FetchProductive(p *Profile, repos []string, loc *time.Location, maxPerRepo int) error {
|
||||
// The timezone loc is applied to CommittedDate so the heatmap reflects when
|
||||
// the user actually commits, not UTC.
|
||||
func (c *Client) FetchProductive(p *Profile, repos []RepoInfo, loc *time.Location, maxPerRepo int) error {
|
||||
if loc == nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
since := time.Now().AddDate(-1, 0, 0).UTC().Format(time.RFC3339)
|
||||
|
||||
commitsByLang := map[string]int64{}
|
||||
langColor := map[string]string{}
|
||||
|
||||
for _, repo := range repos {
|
||||
var cursor *string
|
||||
seen := 0
|
||||
@@ -44,7 +49,7 @@ func (c *Client) FetchProductive(p *Profile, repos []string, loc *time.Location,
|
||||
}
|
||||
vars := map[string]any{
|
||||
"login": p.Login,
|
||||
"repo": repo,
|
||||
"repo": repo.Name,
|
||||
"userId": p.ID,
|
||||
"since": since,
|
||||
}
|
||||
@@ -68,6 +73,12 @@ func (c *Client) FetchProductive(p *Profile, repos []string, loc *time.Location,
|
||||
}
|
||||
tl := t.In(loc)
|
||||
p.Productive[int(tl.Weekday())][tl.Hour()]++
|
||||
if repo.PrimaryLanguage != "" {
|
||||
commitsByLang[repo.PrimaryLanguage]++
|
||||
if _, ok := langColor[repo.PrimaryLanguage]; !ok {
|
||||
langColor[repo.PrimaryLanguage] = repo.PrimaryColor
|
||||
}
|
||||
}
|
||||
seen++
|
||||
}
|
||||
if !h.PageInfo.HasNextPage {
|
||||
@@ -77,5 +88,7 @@ func (c *Client) FetchProductive(p *Profile, repos []string, loc *time.Location,
|
||||
cursor = &end
|
||||
}
|
||||
}
|
||||
|
||||
p.CommitsByLanguage = sortLangStats(commitsByLang, langColor)
|
||||
return nil
|
||||
}
|
||||
|
||||
+27
-12
@@ -50,15 +50,16 @@ type profileGQL struct {
|
||||
} `json:"user"`
|
||||
}
|
||||
|
||||
// FetchProfile collects profile, stats and language data for the given user.
|
||||
// Repositories are paginated up to 10 pages (1000 owned repos) as a safety cap.
|
||||
// FetchProfile collects profile, stats and repos-per-language data for the
|
||||
// given user. Owned repos are paginated up to 10 pages (1000 repos) as a
|
||||
// safety cap.
|
||||
func (c *Client) FetchProfile(login string) (*Profile, error) {
|
||||
if login == "" {
|
||||
return nil, errors.New("empty user")
|
||||
}
|
||||
|
||||
p := &Profile{Login: login}
|
||||
langBytes := map[string]int64{}
|
||||
reposPerLang := map[string]int64{}
|
||||
langColor := map[string]string{}
|
||||
|
||||
var cursor *string
|
||||
@@ -105,9 +106,22 @@ func (c *Client) FetchProfile(login string) (*Profile, error) {
|
||||
for _, r := range u.Repositories.Nodes {
|
||||
p.TotalStars += r.StargazerCount
|
||||
p.TotalForks += r.ForkCount
|
||||
p.TopRepos = append(p.TopRepos, r.Name)
|
||||
|
||||
info := RepoInfo{Name: r.Name}
|
||||
if r.PrimaryLanguage != nil {
|
||||
info.PrimaryLanguage = r.PrimaryLanguage.Name
|
||||
info.PrimaryColor = r.PrimaryLanguage.Color
|
||||
reposPerLang[r.PrimaryLanguage.Name]++
|
||||
if _, ok := langColor[r.PrimaryLanguage.Name]; !ok {
|
||||
langColor[r.PrimaryLanguage.Name] = r.PrimaryLanguage.Color
|
||||
}
|
||||
}
|
||||
p.TopRepos = append(p.TopRepos, info)
|
||||
|
||||
// Capture secondary language colors so productive-time's
|
||||
// per-language aggregation can color them even if that language
|
||||
// isn't the primary of any other repo.
|
||||
for _, e := range r.Languages.Edges {
|
||||
langBytes[e.Node.Name] += e.Size
|
||||
if _, ok := langColor[e.Node.Name]; !ok {
|
||||
langColor[e.Node.Name] = e.Node.Color
|
||||
}
|
||||
@@ -121,18 +135,19 @@ func (c *Client) FetchProfile(login string) (*Profile, error) {
|
||||
cursor = &end
|
||||
}
|
||||
|
||||
p.Languages = sortLanguages(langBytes, langColor)
|
||||
p.ReposByLanguage = sortLangStats(reposPerLang, langColor)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func sortLanguages(bytes map[string]int64, color map[string]string) []LangStat {
|
||||
out := make([]LangStat, 0, len(bytes))
|
||||
for name, b := range bytes {
|
||||
out = append(out, LangStat{Name: name, Color: color[name], Bytes: b})
|
||||
// sortLangStats returns a slice sorted desc by value; ties break alphabetically.
|
||||
func sortLangStats(values map[string]int64, color map[string]string) []LangStat {
|
||||
out := make([]LangStat, 0, len(values))
|
||||
for name, v := range values {
|
||||
out = append(out, LangStat{Name: name, Color: color[name], Value: v})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Bytes != out[j].Bytes {
|
||||
return out[i].Bytes > out[j].Bytes
|
||||
if out[i].Value != out[j].Value {
|
||||
return out[i].Value > out[j].Value
|
||||
}
|
||||
return out[i].Name < out[j].Name
|
||||
})
|
||||
|
||||
@@ -2,19 +2,19 @@ package github
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSortLanguages(t *testing.T) {
|
||||
bytes := map[string]int64{
|
||||
"Go": 500,
|
||||
"Python": 300,
|
||||
"TypeScript": 500, // tie with Go → alphabetical wins
|
||||
"HTML": 100,
|
||||
func TestSortLangStats(t *testing.T) {
|
||||
values := map[string]int64{
|
||||
"Go": 5,
|
||||
"Python": 3,
|
||||
"TypeScript": 5, // tie with Go → alphabetical wins
|
||||
"HTML": 1,
|
||||
}
|
||||
colors := map[string]string{
|
||||
"Go": "#00ADD8",
|
||||
"Python": "#3572A5",
|
||||
"TypeScript": "#3178c6",
|
||||
}
|
||||
got := sortLanguages(bytes, colors)
|
||||
got := sortLangStats(values, colors)
|
||||
|
||||
wantOrder := []string{"Go", "TypeScript", "Python", "HTML"}
|
||||
if len(got) != len(wantOrder) {
|
||||
|
||||
@@ -64,7 +64,7 @@ func main() {
|
||||
repos = repos[:*topRepos]
|
||||
}
|
||||
if err := client.FetchProductive(profile, repos, loc, *perRepo); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warn: productive-time fetch: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "warn: productive-time + commits-per-language fetch: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user