diff --git a/.gitignore b/.gitignore
index aaadf736..08d10687 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,6 +27,9 @@ go.work.sum
# env file
.env
+# Generated SVG cards from local runs
+output/
+
# Editor/IDE
# .idea/
# .vscode/
diff --git a/README.md b/README.md
index c6229020..569d6074 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/internal/card/card.go b/internal/card/card.go
index b268b338..d93ce021 100644
--- a/internal/card/card.go
+++ b/internal/card/card.go
@@ -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{},
}
diff --git a/internal/card/card_test.go b/internal/card/card_test.go
index 563a504a..8b49139d 100644
--- a/internal/card/card_test.go
+++ b/internal/card/card_test.go
@@ -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))
diff --git a/internal/card/languages.go b/internal/card/language_bar.go
similarity index 50%
rename from internal/card/languages.go
rename to internal/card/language_bar.go
index e9f2e3e3..42a66506 100644
--- a/internal/card/languages.go
+++ b/internal/card/language_bar.go
@@ -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, `
- No language data available.`, t.Muted)
+ No data available.`, 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, `
`,
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, `
`,
- offset, barY, w, barH, colorOrAccent(l.Color, t.Accent))
+ offset, barY, w, barH, colorOrAccent(s.Color, t.Accent))
offset += w
}
b.WriteString(`
`)
- // 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, `
%s %.2f%%`,
- 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 {
diff --git a/internal/card/most_commit_language.go b/internal/card/most_commit_language.go
new file mode 100644
index 00000000..684a2e4a
--- /dev/null
+++ b/internal/card/most_commit_language.go
@@ -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
+}
diff --git a/internal/card/productive.go b/internal/card/productive.go
index 135d7b19..15dbdf64 100644
--- a/internal/card/productive.go
+++ b/internal/card/productive.go
@@ -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"}
diff --git a/internal/card/repos_per_language.go b/internal/card/repos_per_language.go
new file mode 100644
index 00000000..d63745ab
--- /dev/null
+++ b/internal/card/repos_per_language.go
@@ -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
+}
diff --git a/internal/card/stats.go b/internal/card/stats.go
index 5438612e..5cf25647 100644
--- a/internal/card/stats.go
+++ b/internal/card/stats.go
@@ -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 (
diff --git a/internal/github/model.go b/internal/github/model.go
index fdce7493..fb61db82 100644
--- a/internal/github/model.go
+++ b/internal/github/model.go
@@ -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
diff --git a/internal/github/productive.go b/internal/github/productive.go
index d87a6c84..82ef3e1c 100644
--- a/internal/github/productive.go
+++ b/internal/github/productive.go
@@ -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
}
diff --git a/internal/github/profile.go b/internal/github/profile.go
index 0562e0f6..10a94032 100644
--- a/internal/github/profile.go
+++ b/internal/github/profile.go
@@ -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
})
diff --git a/internal/github/profile_test.go b/internal/github/profile_test.go
index 41b77496..0a963336 100644
--- a/internal/github/profile_test.go
+++ b/internal/github/profile_test.go
@@ -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) {
diff --git a/main.go b/main.go
index 0c54811a..dbd0f693 100644
--- a/main.go
+++ b/main.go
@@ -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)
}
}