Added bento :)
This commit is contained in:
@@ -14,4 +14,6 @@ vendor/
|
||||
*.test
|
||||
*.out
|
||||
|
||||
# Executable and runtime files
|
||||
wrapped
|
||||
.wrapped/
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
.PHONY: build run clean
|
||||
|
||||
build:
|
||||
go build -o wrapped ./cmd/gitea-wrapped/
|
||||
go build -o wrapped ./cmd/wrapped-cli/
|
||||
|
||||
run: build
|
||||
./wrapped
|
||||
|
||||
clean:
|
||||
rm -f wrapped
|
||||
rm -rf ./wrapped ./.wrapped
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# Gitea Wrapped
|
||||
|
||||
This was me testing what all this vibe coding nonsense was like. IDK man...
|
||||
**NOTE: This was initially vibe-coded to see why people do this. My stance is still strictly anti-AI, and do not intend to continue to use it. Results were disappointing enough that I had to make significant changes before pushing anything. This is just a disclaimer for those who care about transparency.**
|
||||
|
||||
This is a Go based TUI that builds a "Spotify Wrapped" style recap of your gitea/forgejo activity. All login info is stored locally so I see nothing. Literally just sits in a JSON file on your machine. I hope you like this dumb project.
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/atridad/gitea-wrapped/pkg/ui"
|
||||
"github.com/atridad/wrapped-cli/pkg/ui"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -1,4 +1,4 @@
|
||||
module github.com/atridad/gitea-wrapped
|
||||
module github.com/atridad/wrapped-cli
|
||||
|
||||
go 1.26.2
|
||||
|
||||
@@ -19,6 +19,8 @@ require (
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/fogleman/gg v1.3.0 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
@@ -28,6 +30,8 @@ require (
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/image v0.39.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.3.8 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -24,6 +24,10 @@ github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
|
||||
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
@@ -44,9 +48,16 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Connection struct {
|
||||
Name string `yaml:"name"`
|
||||
URL string `yaml:"url"`
|
||||
Username string `yaml:"username"`
|
||||
Token string `yaml:"token"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Connections []Connection `yaml:"connections"`
|
||||
}
|
||||
|
||||
func GetConfigPath() (string, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir := filepath.Dir(exe)
|
||||
wrappedDir := filepath.Join(dir, ".wrapped")
|
||||
if err := os.MkdirAll(wrappedDir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(wrappedDir, "config.yaml"), nil
|
||||
}
|
||||
|
||||
func GetResultsDir() (string, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir := filepath.Dir(exe)
|
||||
resultsDir := filepath.Join(dir, ".wrapped", "results")
|
||||
if err := os.MkdirAll(resultsDir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resultsDir, nil
|
||||
}
|
||||
|
||||
func LoadConfig() (*Config, error) {
|
||||
configPath, err := GetConfigPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &Config{Connections: []Connection{}}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (c *Config) Save() error {
|
||||
configPath, err := GetConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := yaml.Marshal(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(configPath, data, 0o600)
|
||||
}
|
||||
|
||||
func (c *Config) GetConnection(name string) *Connection {
|
||||
for i := range c.Connections {
|
||||
if c.Connections[i].Name == name {
|
||||
return &c.Connections[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) AddConnection(conn Connection) {
|
||||
c.Connections = append(c.Connections, conn)
|
||||
}
|
||||
+44
-4
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -47,6 +48,7 @@ type GiteaRepo struct {
|
||||
|
||||
type GiteaCommit struct {
|
||||
SHA string `json:"sha"`
|
||||
RepoName string `json:"-"`
|
||||
Commit struct {
|
||||
Author struct {
|
||||
Name string `json:"name"`
|
||||
@@ -57,7 +59,11 @@ type GiteaCommit struct {
|
||||
} `json:"commit"`
|
||||
}
|
||||
|
||||
// do makes an authenticated HTTP request to Gitea API
|
||||
type RepoRef struct {
|
||||
Owner string
|
||||
Name string
|
||||
}
|
||||
|
||||
func (c *Client) do(method, path string) ([]byte, error) {
|
||||
url := fmt.Sprintf("%s/api/v1%s", c.BaseURL, path)
|
||||
|
||||
@@ -68,7 +74,7 @@ func (c *Client) do(method, path string) ([]byte, error) {
|
||||
|
||||
req.Header.Set("Authorization", fmt.Sprintf("token %s", c.Token))
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "gitea-wrapped/1.0")
|
||||
req.Header.Set("User-Agent", "wrapped-cli/1.0")
|
||||
|
||||
resp, err := c.Client.Do(req)
|
||||
if err != nil {
|
||||
@@ -96,7 +102,7 @@ func (c *Client) GetUserRepos() ([]GiteaRepo, error) {
|
||||
var repos []GiteaRepo
|
||||
page := 1
|
||||
pageSize := 50
|
||||
maxPages := 100 // Safety limit to prevent infinite loops
|
||||
maxPages := 100
|
||||
|
||||
for page <= maxPages {
|
||||
path := fmt.Sprintf("/user/repos?page=%d&limit=%d", page, pageSize)
|
||||
@@ -133,7 +139,7 @@ func (c *Client) GetRepoCommits(owner, repo string) ([]GiteaCommit, error) {
|
||||
var commits []GiteaCommit
|
||||
page := 1
|
||||
pageSize := 50
|
||||
maxPages := 1000 // Safety limit to prevent infinite loops
|
||||
maxPages := 1000
|
||||
|
||||
for page <= maxPages {
|
||||
path := fmt.Sprintf("/repos/%s/%s/commits?page=%d&limit=%d", owner, repo, page, pageSize)
|
||||
@@ -166,6 +172,40 @@ func (c *Client) GetRepoCommits(owner, repo string) ([]GiteaCommit, error) {
|
||||
return commits, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetReposCommitsParallel(repos []RepoRef) ([]GiteaCommit, error) {
|
||||
var allCommits []GiteaCommit
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
|
||||
semaphore := make(chan struct{}, 5)
|
||||
|
||||
for _, repo := range repos {
|
||||
wg.Add(1)
|
||||
go func(owner, repoName string) {
|
||||
defer wg.Done()
|
||||
|
||||
semaphore <- struct{}{}
|
||||
defer func() { <-semaphore }()
|
||||
|
||||
commits, err := c.GetRepoCommits(owner, repoName)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for i := range commits {
|
||||
commits[i].RepoName = repoName
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
allCommits = append(allCommits, commits...)
|
||||
mu.Unlock()
|
||||
}(repo.Owner, repo.Name)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return allCommits, nil
|
||||
}
|
||||
|
||||
func (c *Client) TestConnection() error {
|
||||
_, err := c.do("GET", "/user")
|
||||
if err != nil {
|
||||
|
||||
@@ -15,6 +15,7 @@ type Commit struct {
|
||||
SHA string
|
||||
Message string
|
||||
Author string
|
||||
AuthorEmail string
|
||||
Timestamp time.Time
|
||||
RepoName string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
package reports
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/atridad/wrapped-cli/pkg/config"
|
||||
"github.com/atridad/wrapped-cli/pkg/models"
|
||||
"github.com/fogleman/gg"
|
||||
)
|
||||
|
||||
const (
|
||||
canvasW = 1400
|
||||
canvasH = 1160
|
||||
gridGap = 20
|
||||
radius = 24
|
||||
unitCol = 325
|
||||
twoCol = 670
|
||||
fourCol = 1360
|
||||
)
|
||||
|
||||
var (
|
||||
bgColor = color.RGBA{9, 9, 11, 255}
|
||||
cardBg = color.RGBA{24, 24, 27, 255}
|
||||
cardBgPurple = color.RGBA{30, 20, 36, 255}
|
||||
cardBgBlue = color.RGBA{15, 23, 42, 255}
|
||||
accentMagenta = color.RGBA{192, 132, 252, 255}
|
||||
accentCyan = color.RGBA{45, 212, 191, 255}
|
||||
accentOrange = color.RGBA{251, 146, 60, 255}
|
||||
accentGreen = color.RGBA{74, 222, 128, 255}
|
||||
accentBlue = color.RGBA{96, 165, 250, 255}
|
||||
textPrimary = color.RGBA{244, 244, 245, 255}
|
||||
textSecondary = color.RGBA{161, 161, 170, 255}
|
||||
textMuted = color.RGBA{113, 113, 122, 255}
|
||||
borderColor = color.RGBA{39, 39, 42, 255}
|
||||
barTrack = color.RGBA{39, 39, 42, 255}
|
||||
)
|
||||
|
||||
type bentoCard struct {
|
||||
x, y, w, h float64
|
||||
bg color.RGBA
|
||||
accent color.RGBA
|
||||
}
|
||||
|
||||
func col(n int) float64 {
|
||||
return float64(gridGap + n*(unitCol+gridGap))
|
||||
}
|
||||
|
||||
func cardLayout() []bentoCard {
|
||||
r1y := float64(gridGap)
|
||||
r1h := float64(240)
|
||||
r2y := r1y + r1h + gridGap
|
||||
r2h := float64(240)
|
||||
r3y := r2y + r2h + gridGap
|
||||
r3h := float64(300)
|
||||
r4y := r3y + r3h + gridGap
|
||||
r4h := float64(280)
|
||||
|
||||
return []bentoCard{
|
||||
{col(0), r1y, twoCol, r1h, cardBgPurple, accentMagenta},
|
||||
{col(2), r1y, unitCol, r1h, cardBg, accentOrange},
|
||||
{col(3), r1y, unitCol, r1h, cardBg, accentCyan},
|
||||
{col(0), r2y, unitCol, r2h, cardBg, accentGreen},
|
||||
{col(1), r2y, unitCol, r2h, cardBg, accentOrange},
|
||||
{col(2), r2y, twoCol, r2h, cardBgBlue, accentBlue},
|
||||
{col(0), r3y, fourCol, r3h, cardBg, accentMagenta},
|
||||
{col(0), r4y, fourCol, r4h, cardBg, accentCyan},
|
||||
}
|
||||
}
|
||||
|
||||
func loadFont(dc *gg.Context, size float64) {
|
||||
paths := []string{
|
||||
"/System/Library/Fonts/Helvetica.ttc",
|
||||
"/System/Library/Fonts/SFNS.ttf",
|
||||
"/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
||||
}
|
||||
for _, p := range paths {
|
||||
if err := dc.LoadFontFace(p, size); err == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func drawCard(dc *gg.Context, c bentoCard) {
|
||||
dc.DrawRoundedRectangle(c.x, c.y, c.w, c.h, radius)
|
||||
dc.SetColor(c.bg)
|
||||
dc.Fill()
|
||||
dc.DrawRoundedRectangle(c.x, c.y, c.w, c.h, radius)
|
||||
dc.SetColor(borderColor)
|
||||
dc.SetLineWidth(1)
|
||||
dc.Stroke()
|
||||
}
|
||||
|
||||
func label(dc *gg.Context, c bentoCard, text string) {
|
||||
loadFont(dc, 12)
|
||||
dc.SetColor(textSecondary)
|
||||
dc.DrawString(text, c.x+24, c.y+32)
|
||||
}
|
||||
|
||||
func bigNum(dc *gg.Context, c bentoCard, value, sub string) {
|
||||
loadFont(dc, 64)
|
||||
dc.SetColor(c.accent)
|
||||
dc.DrawString(value, c.x+24, c.y+110)
|
||||
|
||||
if sub != "" {
|
||||
loadFont(dc, 14)
|
||||
dc.SetColor(textSecondary)
|
||||
dc.DrawString(sub, c.x+24, c.y+136)
|
||||
}
|
||||
}
|
||||
|
||||
func bigText(dc *gg.Context, c bentoCard, value string, size float64) {
|
||||
loadFont(dc, size)
|
||||
dc.SetColor(c.accent)
|
||||
dc.DrawString(value, c.x+24, c.y+110)
|
||||
}
|
||||
|
||||
func filledBar(dc *gg.Context, x, y, totalW, h, pct float64, fill color.RGBA) {
|
||||
dc.DrawRoundedRectangle(x, y, totalW, h, 6)
|
||||
dc.SetColor(barTrack)
|
||||
dc.Fill()
|
||||
|
||||
fillW := totalW * pct
|
||||
if fillW < 8 {
|
||||
fillW = 8
|
||||
}
|
||||
dc.DrawRoundedRectangle(x, y, fillW, h, 6)
|
||||
dc.SetColor(fill)
|
||||
dc.Fill()
|
||||
}
|
||||
|
||||
func renderCommitsCard(dc *gg.Context, c bentoCard, stats *models.UserStats) {
|
||||
label(dc, c, "TOTAL COMMITS")
|
||||
bigNum(dc, c, formatNumber(stats.TotalCommits),
|
||||
fmt.Sprintf("%.1f commits per day on average", stats.AverageCommitsPerDay))
|
||||
|
||||
dc.DrawRoundedRectangle(c.x+24, c.y+c.h-40, c.w-48, 10, 5)
|
||||
dc.SetColor(color.RGBA{50, 40, 60, 255})
|
||||
dc.Fill()
|
||||
dc.DrawRoundedRectangle(c.x+24, c.y+c.h-40, (c.w-48)*0.72, 10, 5)
|
||||
dc.SetColor(c.accent)
|
||||
dc.Fill()
|
||||
}
|
||||
|
||||
func renderReposCard(dc *gg.Context, c bentoCard, stats *models.UserStats) {
|
||||
label(dc, c, "REPOSITORIES")
|
||||
bigNum(dc, c, formatNumber(stats.TotalRepositories), "worked on this year")
|
||||
|
||||
y := c.y + 160
|
||||
maxW := c.w - 48
|
||||
for i, repo := range stats.TopRepositories {
|
||||
if i >= 3 {
|
||||
break
|
||||
}
|
||||
loadFont(dc, 12)
|
||||
dc.SetColor(textMuted)
|
||||
dc.DrawString(truncate(repo.Name, maxW, dc), c.x+24, y)
|
||||
y += 24
|
||||
}
|
||||
}
|
||||
|
||||
func renderActiveDayCard(dc *gg.Context, c bentoCard, stats *models.UserStats) {
|
||||
label(dc, c, "MOST ACTIVE DAY")
|
||||
bigText(dc, c, stats.MostActiveDay, 48)
|
||||
|
||||
if len(stats.CommitsByWeekday) > 0 {
|
||||
loadFont(dc, 14)
|
||||
dc.SetColor(textSecondary)
|
||||
dc.DrawString(
|
||||
fmt.Sprintf("%d commits that day", stats.CommitsByWeekday[0].Count),
|
||||
c.x+24, c.y+136,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func renderAvgCard(dc *gg.Context, c bentoCard, stats *models.UserStats) {
|
||||
label(dc, c, "AVG COMMITS / DAY")
|
||||
bigNum(dc, c, fmt.Sprintf("%.1f", stats.AverageCommitsPerDay), "daily average")
|
||||
}
|
||||
|
||||
func renderActiveMonthCard(dc *gg.Context, c bentoCard, stats *models.UserStats) {
|
||||
label(dc, c, "MOST ACTIVE MONTH")
|
||||
bigText(dc, c, stats.MostActiveMonth, 44)
|
||||
}
|
||||
|
||||
func renderTopRepoCard(dc *gg.Context, c bentoCard, stats *models.UserStats) {
|
||||
label(dc, c, "TOP REPOSITORY")
|
||||
|
||||
if len(stats.TopRepositories) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
top := stats.TopRepositories[0]
|
||||
maxW := c.w - 48
|
||||
|
||||
loadFont(dc, 32)
|
||||
dc.SetColor(c.accent)
|
||||
dc.DrawString(truncate(top.Name, maxW, dc), c.x+24, c.y+100)
|
||||
|
||||
loadFont(dc, 13)
|
||||
dc.SetColor(textSecondary)
|
||||
dc.DrawString("your #1 repository this year", c.x+24, c.y+126)
|
||||
|
||||
y := c.y + 164
|
||||
for i := 1; i < len(stats.TopRepositories) && i < 4; i++ {
|
||||
loadFont(dc, 12)
|
||||
dc.SetColor(textMuted)
|
||||
dc.DrawString(
|
||||
fmt.Sprintf("#%d %s", i+1, truncate(stats.TopRepositories[i].Name, maxW-30, dc)),
|
||||
c.x+24, y,
|
||||
)
|
||||
y += 24
|
||||
}
|
||||
}
|
||||
|
||||
func renderLanguagesCard(dc *gg.Context, c bentoCard, stats *models.UserStats) {
|
||||
label(dc, c, "LANGUAGES")
|
||||
|
||||
if len(stats.Languages) == 0 {
|
||||
loadFont(dc, 15)
|
||||
dc.SetColor(textSecondary)
|
||||
dc.DrawString("No language data available.", c.x+24, c.y+100)
|
||||
return
|
||||
}
|
||||
|
||||
const (
|
||||
numCols = 2
|
||||
rowsPerCol = 5
|
||||
rowH = 48.0
|
||||
startY = 60.0
|
||||
labelW = 110.0
|
||||
countW = 90.0
|
||||
barH = 14.0
|
||||
)
|
||||
|
||||
maxCount := stats.Languages[0].Count
|
||||
halfW := (c.w - 48 - gridGap) / 2
|
||||
|
||||
for i, lang := range stats.Languages {
|
||||
if i >= numCols*rowsPerCol {
|
||||
break
|
||||
}
|
||||
|
||||
colIdx := i / rowsPerCol
|
||||
rowIdx := i % rowsPerCol
|
||||
|
||||
baseX := c.x + 24 + float64(colIdx)*(halfW+float64(gridGap))
|
||||
baseY := c.y + startY + float64(rowIdx)*rowH
|
||||
|
||||
pct := float64(lang.Count) / float64(maxCount)
|
||||
barW := halfW - labelW - countW - 10
|
||||
|
||||
loadFont(dc, 13)
|
||||
dc.SetColor(textPrimary)
|
||||
dc.DrawString(truncatePx(lang.Language, labelW-8, dc), baseX, baseY+12)
|
||||
|
||||
filledBar(dc, baseX+labelW, baseY+2, barW, barH, pct, c.accent)
|
||||
|
||||
loadFont(dc, 12)
|
||||
dc.SetColor(textSecondary)
|
||||
dc.DrawString(
|
||||
fmt.Sprintf("%d %.1f%%", lang.Count, lang.Percent),
|
||||
baseX+labelW+barW+10, baseY+13,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func renderActivityCard(dc *gg.Context, c bentoCard, stats *models.UserStats) {
|
||||
label(dc, c, "ACTIVITY PATTERNS")
|
||||
|
||||
const (
|
||||
startY = 54.0
|
||||
rowH = 28.0
|
||||
labelW = 44.0
|
||||
countW = 50.0
|
||||
barH = 14.0
|
||||
)
|
||||
|
||||
halfW := (c.w - 48 - float64(gridGap)) / 2
|
||||
lx := c.x + 24
|
||||
|
||||
loadFont(dc, 12)
|
||||
dc.SetColor(textSecondary)
|
||||
dc.DrawString("By Weekday", lx, c.y+startY)
|
||||
|
||||
if len(stats.CommitsByWeekday) > 0 {
|
||||
maxC := stats.CommitsByWeekday[0].Count
|
||||
barW := halfW - labelW - countW - 10
|
||||
|
||||
for i, day := range stats.CommitsByWeekday {
|
||||
if i >= 7 {
|
||||
break
|
||||
}
|
||||
by := c.y + startY + 24 + float64(i)*rowH
|
||||
|
||||
loadFont(dc, 12)
|
||||
dc.SetColor(textPrimary)
|
||||
dc.DrawString(day.Day[:3], lx, by+12)
|
||||
|
||||
pct := float64(day.Count) / float64(maxC)
|
||||
filledBar(dc, lx+labelW, by+2, barW, barH, pct, accentCyan)
|
||||
|
||||
loadFont(dc, 11)
|
||||
dc.SetColor(textMuted)
|
||||
dc.DrawString(formatNumber(day.Count), lx+labelW+barW+8, by+12)
|
||||
}
|
||||
}
|
||||
|
||||
rx := c.x + 24 + halfW + float64(gridGap)
|
||||
|
||||
loadFont(dc, 12)
|
||||
dc.SetColor(textSecondary)
|
||||
dc.DrawString("By Month", rx, c.y+startY)
|
||||
|
||||
if len(stats.CommitsByMonth) > 0 {
|
||||
maxC := stats.CommitsByMonth[0].Count
|
||||
barW := halfW - labelW - countW - 10
|
||||
|
||||
for i, month := range stats.CommitsByMonth {
|
||||
if i >= 7 {
|
||||
break
|
||||
}
|
||||
by := c.y + startY + 24 + float64(i)*rowH
|
||||
|
||||
loadFont(dc, 12)
|
||||
dc.SetColor(textPrimary)
|
||||
dc.DrawString(month.Date.Format("Jan"), rx, by+12)
|
||||
|
||||
pct := float64(month.Count) / float64(maxC)
|
||||
filledBar(dc, rx+labelW, by+2, barW, barH, pct, accentOrange)
|
||||
|
||||
loadFont(dc, 11)
|
||||
dc.SetColor(textMuted)
|
||||
dc.DrawString(formatNumber(month.Count), rx+labelW+barW+8, by+12)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func formatNumber(n int) string {
|
||||
switch {
|
||||
case n >= 1_000_000:
|
||||
return fmt.Sprintf("%.1fM", float64(n)/1_000_000)
|
||||
case n >= 1_000:
|
||||
return fmt.Sprintf("%.1fK", float64(n)/1_000)
|
||||
default:
|
||||
return fmt.Sprintf("%d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func truncate(s string, maxPx float64, dc *gg.Context) string {
|
||||
return truncatePx(s, maxPx, dc)
|
||||
}
|
||||
|
||||
func truncatePx(s string, maxPx float64, dc *gg.Context) string {
|
||||
w, _ := dc.MeasureString(s)
|
||||
if w <= maxPx {
|
||||
return s
|
||||
}
|
||||
for len(s) > 1 {
|
||||
s = s[:len(s)-1]
|
||||
w, _ = dc.MeasureString(s + "…")
|
||||
if w <= maxPx {
|
||||
return s + "…"
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func ExportReportAsImage(stats *models.UserStats, format string) (string, error) {
|
||||
resultsDir, err := config.GetResultsDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
dc := gg.NewContext(canvasW, canvasH)
|
||||
dc.SetColor(bgColor)
|
||||
dc.Clear()
|
||||
|
||||
cards := cardLayout()
|
||||
for _, c := range cards {
|
||||
drawCard(dc, c)
|
||||
}
|
||||
|
||||
renderCommitsCard(dc, cards[0], stats)
|
||||
renderReposCard(dc, cards[1], stats)
|
||||
renderActiveDayCard(dc, cards[2], stats)
|
||||
renderAvgCard(dc, cards[3], stats)
|
||||
renderActiveMonthCard(dc, cards[4], stats)
|
||||
renderTopRepoCard(dc, cards[5], stats)
|
||||
renderLanguagesCard(dc, cards[6], stats)
|
||||
renderActivityCard(dc, cards[7], stats)
|
||||
|
||||
now := time.Now()
|
||||
timestamp := now.Format("2006-01-02 15-04-05")
|
||||
filenameWithExt := fmt.Sprintf("%s - Gitea Wrapped.%s", timestamp, format)
|
||||
filename := filepath.Join(resultsDir, filenameWithExt)
|
||||
|
||||
switch format {
|
||||
case "png":
|
||||
f, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
if err := png.Encode(f, dc.Image()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
case "jpg", "jpeg":
|
||||
f, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
if err := jpeg.Encode(f, dc.Image(), &jpeg.Options{Quality: 92}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported format: %s", format)
|
||||
}
|
||||
|
||||
return filenameWithExt, nil
|
||||
}
|
||||
|
||||
func GenerateReportImage(stats *models.UserStats, format string) (string, error) {
|
||||
return ExportReportAsImage(stats, format)
|
||||
}
|
||||
|
||||
func stripANSI(s string) string {
|
||||
b := make([]byte, 0, len(s))
|
||||
inEscape := false
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\x1b' {
|
||||
inEscape = true
|
||||
} else if inEscape && s[i] == 'm' {
|
||||
inEscape = false
|
||||
} else if !inEscape {
|
||||
b = append(b, s[i])
|
||||
}
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func getTextColor(line string) color.Color {
|
||||
if strings.Contains(line, "Overview") ||
|
||||
strings.Contains(line, "Languages") ||
|
||||
strings.Contains(line, "Activity") {
|
||||
return color.RGBA{0, 255, 255, 255}
|
||||
}
|
||||
return color.RGBA{200, 200, 200, 255}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package reports
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/atridad/wrapped-cli/pkg/config"
|
||||
"github.com/atridad/wrapped-cli/pkg/models"
|
||||
)
|
||||
|
||||
type ReportMetadata struct {
|
||||
Timestamp time.Time `yaml:"timestamp"`
|
||||
Username string `yaml:"username"`
|
||||
TotalCommits int `yaml:"total_commits"`
|
||||
Repositories int `yaml:"repositories"`
|
||||
Stats *models.UserStats `yaml:"stats"`
|
||||
}
|
||||
|
||||
type Report struct {
|
||||
Metadata *ReportMetadata
|
||||
MarkdownID string
|
||||
MetadataID string
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
func SaveReport(stats *models.UserStats) (string, error) {
|
||||
resultsDir, err := config.GetResultsDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
timestamp := now.Format("2006-01-02 15-04-05")
|
||||
metadataID := fmt.Sprintf("%s.yaml", now.Format("2006-01-02-15-04-05"))
|
||||
imageID := fmt.Sprintf("%s - Gitea Wrapped.png", timestamp)
|
||||
|
||||
metadata := &ReportMetadata{
|
||||
Timestamp: now,
|
||||
Username: stats.Username,
|
||||
TotalCommits: stats.TotalCommits,
|
||||
Repositories: stats.TotalRepositories,
|
||||
Stats: stats,
|
||||
}
|
||||
|
||||
metadataPath := filepath.Join(resultsDir, metadataID)
|
||||
data, err := yaml.Marshal(metadata)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(metadataPath, data, 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if _, err := ExportReportAsImage(stats, "png"); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to generate image: %v\n", err)
|
||||
}
|
||||
|
||||
return imageID, nil
|
||||
}
|
||||
|
||||
func ListReports() ([]Report, error) {
|
||||
resultsDir, err := config.GetResultsDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(resultsDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []Report{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var reports []Report
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
if filepath.Ext(entry.Name()) == ".yaml" {
|
||||
metadataPath := filepath.Join(resultsDir, entry.Name())
|
||||
data, err := os.ReadFile(metadataPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var metadata ReportMetadata
|
||||
if err := yaml.Unmarshal(data, &metadata); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
reports = append(reports, Report{
|
||||
Metadata: &metadata,
|
||||
MetadataID: entry.Name(),
|
||||
Timestamp: metadata.Timestamp,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(reports, func(i, j int) bool {
|
||||
return reports[i].Timestamp.After(reports[j].Timestamp)
|
||||
})
|
||||
|
||||
return reports, nil
|
||||
}
|
||||
|
||||
func GetReport(metadataID string) (*ReportMetadata, error) {
|
||||
resultsDir, err := config.GetResultsDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
metadataPath := filepath.Join(resultsDir, metadataID)
|
||||
data, err := os.ReadFile(metadataPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var metadata ReportMetadata
|
||||
if err := yaml.Unmarshal(data, &metadata); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &metadata, nil
|
||||
}
|
||||
|
||||
func generateMarkdown(stats *models.UserStats) string {
|
||||
md := fmt.Sprintf("# Gitea Wrapped — %s\n\n", time.Now().Format("January 2, 2006"))
|
||||
|
||||
md += fmt.Sprintf("**User**: %s\n\n", stats.Username)
|
||||
|
||||
md += "## Overview\n\n"
|
||||
md += fmt.Sprintf("- **Total Commits**: %d\n", stats.TotalCommits)
|
||||
md += fmt.Sprintf("- **Total Repositories**: %d\n", stats.TotalRepositories)
|
||||
md += fmt.Sprintf("- **Average Commits/Day**: %.2f\n", stats.AverageCommitsPerDay)
|
||||
md += fmt.Sprintf("- **Most Active Day**: %s\n", stats.MostActiveDay)
|
||||
md += fmt.Sprintf("- **Most Active Month**: %s\n\n", stats.MostActiveMonth)
|
||||
|
||||
md += "## Languages\n\n"
|
||||
for i, lang := range stats.Languages {
|
||||
if i >= 10 {
|
||||
break
|
||||
}
|
||||
md += fmt.Sprintf("- %s: %d repos (%.1f%%)\n", lang.Language, lang.Count, lang.Percent)
|
||||
}
|
||||
md += "\n"
|
||||
|
||||
md += "## Top Repositories\n\n"
|
||||
for i, repo := range stats.TopRepositories {
|
||||
md += fmt.Sprintf("%d. [%s](%s)\n", i+1, repo.Name, repo.HTMLURL)
|
||||
}
|
||||
md += "\n"
|
||||
|
||||
md += "## Most Active Days\n\n"
|
||||
for i, day := range stats.CommitsByWeekday {
|
||||
if i >= 7 {
|
||||
break
|
||||
}
|
||||
md += fmt.Sprintf("- %s: %d commits\n", day.Day, day.Count)
|
||||
}
|
||||
md += "\n"
|
||||
|
||||
md += "## Most Active Months\n\n"
|
||||
for i, month := range stats.CommitsByMonth {
|
||||
if i >= 12 {
|
||||
break
|
||||
}
|
||||
md += fmt.Sprintf("- %s: %d commits\n", month.Date.Format("January 2006"), month.Count)
|
||||
}
|
||||
|
||||
return md
|
||||
}
|
||||
+57
-31
@@ -4,8 +4,8 @@ import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/atridad/gitea-wrapped/pkg/gitea"
|
||||
"github.com/atridad/gitea-wrapped/pkg/models"
|
||||
"github.com/atridad/wrapped-cli/pkg/gitea"
|
||||
"github.com/atridad/wrapped-cli/pkg/models"
|
||||
)
|
||||
|
||||
type Analyzer struct {
|
||||
@@ -28,17 +28,48 @@ func (a *Analyzer) AddCommits(commits []models.Commit) {
|
||||
a.commits = append(a.commits, commits...)
|
||||
}
|
||||
|
||||
func (a *Analyzer) inferPrimaryEmail(username string) string {
|
||||
emailCount := make(map[string]int)
|
||||
|
||||
for _, c := range a.commits {
|
||||
if c.AuthorEmail != "" {
|
||||
emailCount[c.AuthorEmail]++
|
||||
}
|
||||
}
|
||||
|
||||
var bestEmail string
|
||||
max := 0
|
||||
|
||||
for email, count := range emailCount {
|
||||
if count > max {
|
||||
max = count
|
||||
bestEmail = email
|
||||
}
|
||||
}
|
||||
|
||||
return bestEmail
|
||||
}
|
||||
|
||||
func (a *Analyzer) Generate(username string) *models.UserStats {
|
||||
primaryEmail := a.inferPrimaryEmail(username)
|
||||
|
||||
var filteredCommits []models.Commit
|
||||
for _, commit := range a.commits {
|
||||
if primaryEmail != "" && commit.AuthorEmail == primaryEmail {
|
||||
filteredCommits = append(filteredCommits, commit)
|
||||
}
|
||||
}
|
||||
|
||||
stats := &models.UserStats{
|
||||
Username: username,
|
||||
TotalCommits: len(a.commits),
|
||||
TotalCommits: len(filteredCommits),
|
||||
TotalRepositories: len(a.repos),
|
||||
}
|
||||
|
||||
stats.Languages = a.generateLanguageStats()
|
||||
stats.CommitsByWeekday = a.generateWeekdayStats()
|
||||
stats.CommitsByMonth = a.generateMonthStats()
|
||||
stats.AverageCommitsPerDay = a.calculateAverageCommitsPerDay()
|
||||
stats.CommitsByWeekday = a.generateWeekdayStats(filteredCommits)
|
||||
stats.CommitsByMonth = a.generateMonthStats(filteredCommits)
|
||||
stats.AverageCommitsPerDay = a.calculateAverageCommitsPerDay(filteredCommits)
|
||||
|
||||
if len(stats.CommitsByMonth) > 0 {
|
||||
stats.MostActiveMonth = stats.CommitsByMonth[0].Date.Format("January")
|
||||
@@ -47,12 +78,11 @@ func (a *Analyzer) Generate(username string) *models.UserStats {
|
||||
stats.MostActiveDay = stats.CommitsByWeekday[0].Day
|
||||
}
|
||||
|
||||
stats.TopRepositories = a.getTopRepositories(5)
|
||||
stats.TopRepositories = a.getTopRepositories(filteredCommits, 5)
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// generateLanguageStats creates language statistics
|
||||
func (a *Analyzer) generateLanguageStats() []models.LanguageStats {
|
||||
langCount := make(map[string]int)
|
||||
|
||||
@@ -82,12 +112,11 @@ func (a *Analyzer) generateLanguageStats() []models.LanguageStats {
|
||||
return stats
|
||||
}
|
||||
|
||||
// generateWeekdayStats creates weekday statistics for commits
|
||||
func (a *Analyzer) generateWeekdayStats() []models.DateStats {
|
||||
func (a *Analyzer) generateWeekdayStats(commits []models.Commit) []models.DateStats {
|
||||
dayCount := make(map[time.Weekday]int)
|
||||
dayNames := [7]string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
|
||||
|
||||
for _, commit := range a.commits {
|
||||
for _, commit := range commits {
|
||||
day := commit.Timestamp.Weekday()
|
||||
dayCount[day]++
|
||||
}
|
||||
@@ -108,19 +137,17 @@ func (a *Analyzer) generateWeekdayStats() []models.DateStats {
|
||||
return stats
|
||||
}
|
||||
|
||||
// generateMonthStats creates monthly statistics for commits
|
||||
func (a *Analyzer) generateMonthStats() []models.DateStats {
|
||||
func (a *Analyzer) generateMonthStats(commits []models.Commit) []models.DateStats {
|
||||
monthCount := make(map[string]int)
|
||||
monthKeys := []string{}
|
||||
monthMap := make(map[string]time.Time)
|
||||
|
||||
for _, commit := range a.commits {
|
||||
for _, commit := range commits {
|
||||
monthStr := commit.Timestamp.Format("2006-01")
|
||||
monthCount[monthStr]++
|
||||
if _, exists := monthMap[monthStr]; !exists {
|
||||
monthKeys = append(monthKeys, monthStr)
|
||||
monthMap[monthStr] = commit.Timestamp
|
||||
}
|
||||
|
||||
for k := range monthCount {
|
||||
monthKeys = append(monthKeys, k)
|
||||
}
|
||||
|
||||
var stats []models.DateStats
|
||||
@@ -139,37 +166,35 @@ func (a *Analyzer) generateMonthStats() []models.DateStats {
|
||||
return stats
|
||||
}
|
||||
|
||||
// calculateAverageCommitsPerDay calculates average commits per calendar day
|
||||
func (a *Analyzer) calculateAverageCommitsPerDay() float64 {
|
||||
if len(a.commits) == 0 {
|
||||
func (a *Analyzer) calculateAverageCommitsPerDay(commits []models.Commit) float64 {
|
||||
if len(commits) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
if len(a.commits) < 2 {
|
||||
return float64(len(a.commits))
|
||||
if len(commits) < 2 {
|
||||
return float64(len(commits))
|
||||
}
|
||||
|
||||
sort.Slice(a.commits, func(i, j int) bool {
|
||||
return a.commits[i].Timestamp.Before(a.commits[j].Timestamp)
|
||||
sort.Slice(commits, func(i, j int) bool {
|
||||
return commits[i].Timestamp.Before(commits[j].Timestamp)
|
||||
})
|
||||
|
||||
firstDay := a.commits[0].Timestamp
|
||||
lastDay := a.commits[len(a.commits)-1].Timestamp
|
||||
firstDay := commits[0].Timestamp
|
||||
lastDay := commits[len(commits)-1].Timestamp
|
||||
|
||||
daysDiff := lastDay.Sub(firstDay).Hours() / 24
|
||||
if daysDiff == 0 {
|
||||
daysDiff = 1
|
||||
}
|
||||
|
||||
return float64(len(a.commits)) / daysDiff
|
||||
return float64(len(commits)) / daysDiff
|
||||
}
|
||||
|
||||
// getTopRepositories returns the top N repositories by commit count
|
||||
func (a *Analyzer) getTopRepositories(n int) []models.Repository {
|
||||
func (a *Analyzer) getTopRepositories(commits []models.Commit, n int) []models.Repository {
|
||||
repoCommitCount := make(map[string]int)
|
||||
repoMap := make(map[string]models.Repository)
|
||||
|
||||
for _, commit := range a.commits {
|
||||
for _, commit := range commits {
|
||||
repoCommitCount[commit.RepoName]++
|
||||
}
|
||||
|
||||
@@ -188,6 +213,7 @@ func (a *Analyzer) getTopRepositories(n int) []models.Repository {
|
||||
repo models.Repository
|
||||
count int
|
||||
}
|
||||
|
||||
var repos []repoWithCount
|
||||
for name, count := range repoCommitCount {
|
||||
if repo, exists := repoMap[name]; exists {
|
||||
|
||||
+94
-24
@@ -8,15 +8,17 @@ import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/atridad/gitea-wrapped/pkg/gitea"
|
||||
"github.com/atridad/gitea-wrapped/pkg/models"
|
||||
"github.com/atridad/gitea-wrapped/pkg/stats"
|
||||
"github.com/atridad/wrapped-cli/pkg/config"
|
||||
"github.com/atridad/wrapped-cli/pkg/gitea"
|
||||
"github.com/atridad/wrapped-cli/pkg/models"
|
||||
"github.com/atridad/wrapped-cli/pkg/stats"
|
||||
)
|
||||
|
||||
type screenType int
|
||||
|
||||
const (
|
||||
inputScreenType screenType = iota
|
||||
menuScreenType screenType = iota
|
||||
inputScreenType
|
||||
loadingScreenType
|
||||
reportScreenType
|
||||
)
|
||||
@@ -33,6 +35,7 @@ username string
|
||||
|
||||
type App struct {
|
||||
screen screenType
|
||||
menuScreen *MenuScreen
|
||||
inputScreen *InputScreen
|
||||
reportScreen *ReportScreen
|
||||
loadingMsg string
|
||||
@@ -42,19 +45,37 @@ analyzer *stats.Analyzer
|
||||
spinnerFrame int
|
||||
repos []gitea.GiteaRepo
|
||||
username string
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
func NewApp() *App {
|
||||
cfg, err := config.LoadConfig()
|
||||
if err != nil || cfg == nil {
|
||||
cfg = &config.Config{Connections: []config.Connection{}}
|
||||
}
|
||||
|
||||
screen := menuScreenType
|
||||
var inputScreen *InputScreen
|
||||
if len(cfg.Connections) == 0 {
|
||||
screen = inputScreenType
|
||||
inputScreen = NewInputScreen()
|
||||
}
|
||||
|
||||
return &App{
|
||||
screen: inputScreenType,
|
||||
inputScreen: NewInputScreen(),
|
||||
screen: screen,
|
||||
menuScreen: NewMenuScreen(cfg),
|
||||
inputScreen: inputScreen,
|
||||
config: cfg,
|
||||
analyzer: stats.NewAnalyzer(),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) Init() tea.Cmd {
|
||||
if a.screen == inputScreenType && a.inputScreen != nil {
|
||||
return a.inputScreen.Init()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
@@ -72,11 +93,41 @@ return tickMsg(t)
|
||||
}
|
||||
|
||||
switch a.screen {
|
||||
case menuScreenType:
|
||||
_, cmd := a.menuScreen.Update(msg)
|
||||
|
||||
if a.menuScreen.IsNewConnection() {
|
||||
a.screen = inputScreenType
|
||||
a.menuScreen.ResetSelection()
|
||||
a.inputScreen = NewInputScreen()
|
||||
return a, a.inputScreen.Init()
|
||||
}
|
||||
|
||||
if selected := a.menuScreen.GetSelectedName(); selected != "" {
|
||||
conn := a.config.GetConnection(selected)
|
||||
if conn != nil {
|
||||
a.menuScreen.ResetSelection()
|
||||
a.giteaClient = gitea.NewClient(conn.URL, conn.Token)
|
||||
a.username = conn.Username
|
||||
a.screen = loadingScreenType
|
||||
a.loadingMsg = "Connecting to Gitea..."
|
||||
a.spinnerFrame = 0
|
||||
|
||||
return a, tea.Batch(
|
||||
tea.Tick(time.Millisecond*200, func(t time.Time) tea.Msg {
|
||||
return tickMsg(t)
|
||||
}),
|
||||
a.testConnection())
|
||||
}
|
||||
}
|
||||
|
||||
return a, cmd
|
||||
|
||||
case inputScreenType:
|
||||
_, cmd := a.inputScreen.Update(msg)
|
||||
|
||||
if a.inputScreen.IsDone() {
|
||||
url, username, token := a.inputScreen.GetCredentials()
|
||||
url, username, token, connName := a.inputScreen.GetCredentials()
|
||||
|
||||
if url == "" {
|
||||
a.inputScreen.err = fmt.Errorf("URL is required")
|
||||
@@ -94,6 +145,16 @@ a.inputScreen.done = false
|
||||
return a, cmd
|
||||
}
|
||||
|
||||
if connName != "" {
|
||||
a.config.AddConnection(config.Connection{
|
||||
Name: connName,
|
||||
URL: url,
|
||||
Username: username,
|
||||
Token: token,
|
||||
})
|
||||
a.config.Save()
|
||||
}
|
||||
|
||||
a.giteaClient = gitea.NewClient(url, token)
|
||||
a.username = username
|
||||
a.screen = loadingScreenType
|
||||
@@ -137,6 +198,16 @@ return a, nil
|
||||
}
|
||||
|
||||
case reportScreenType:
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
if msg.String() == "q" {
|
||||
a.screen = menuScreenType
|
||||
a.menuScreen = NewMenuScreen(a.config)
|
||||
a.reportScreen = nil
|
||||
a.analyzer = stats.NewAnalyzer()
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
if a.reportScreen != nil {
|
||||
_, cmd := a.reportScreen.Update(msg)
|
||||
return a, cmd
|
||||
@@ -148,6 +219,8 @@ return a, nil
|
||||
|
||||
func (a *App) View() string {
|
||||
switch a.screen {
|
||||
case menuScreenType:
|
||||
return a.menuScreen.View()
|
||||
case inputScreenType:
|
||||
return a.inputScreen.View()
|
||||
case loadingScreenType:
|
||||
@@ -165,7 +238,7 @@ var s string
|
||||
s += lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("212")).
|
||||
Bold(true).
|
||||
Render("🎵 Gitea Wrapped") + "\n\n"
|
||||
Render("Gitea Wrapped") + "\n\n"
|
||||
|
||||
spinners := []string{"⠋", "⠙", "⠹", "⠸"}
|
||||
spinner := spinners[a.spinnerFrame%4]
|
||||
@@ -181,7 +254,6 @@ Render(fmt.Sprintf("Error: %v", a.err)) + "\n"
|
||||
return s
|
||||
}
|
||||
|
||||
// testConnection tests API connection
|
||||
func (a *App) testConnection() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if err := a.giteaClient.TestConnection(); err != nil {
|
||||
@@ -191,7 +263,6 @@ return progressMsg("Fetching repositories...")
|
||||
}
|
||||
}
|
||||
|
||||
// fetchRepos fetches all repositories
|
||||
func (a *App) fetchRepos() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
repos, err := a.giteaClient.GetUserRepos()
|
||||
@@ -204,37 +275,36 @@ return progressMsg("Fetching commits...")
|
||||
}
|
||||
}
|
||||
|
||||
// fetchCommits fetches commits from all repos
|
||||
func (a *App) fetchCommits() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
var commits []models.Commit
|
||||
|
||||
for i, repo := range a.repos {
|
||||
|
||||
var repoRefs []gitea.RepoRef
|
||||
for _, repo := range a.repos {
|
||||
parts := strings.Split(repo.FullName, "/")
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
owner := parts[0]
|
||||
giteaCommits, err := a.giteaClient.GetRepoCommits(owner, repo.Name)
|
||||
if err != nil {
|
||||
continue
|
||||
repoRefs = append(repoRefs, gitea.RepoRef{
|
||||
Owner: parts[0],
|
||||
Name: repo.Name,
|
||||
})
|
||||
}
|
||||
|
||||
giteaCommits, err := a.giteaClient.GetReposCommitsParallel(repoRefs)
|
||||
if err != nil {
|
||||
return errorMsg(fmt.Sprintf("failed to fetch commits: %v", err))
|
||||
}
|
||||
|
||||
var commits []models.Commit
|
||||
for _, gc := range giteaCommits {
|
||||
commits = append(commits, models.Commit{
|
||||
SHA: gc.SHA,
|
||||
Message: gc.Commit.Message,
|
||||
Author: gc.Commit.Author.Name,
|
||||
AuthorEmail: gc.Commit.Author.Email,
|
||||
Timestamp: gc.Commit.Author.Date,
|
||||
RepoName: repo.Name,
|
||||
RepoName: gc.RepoName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return doneMsg{a.repos, commits, a.username}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"github.com/atridad/wrapped-cli/pkg/models"
|
||||
"github.com/atridad/wrapped-cli/pkg/reports"
|
||||
)
|
||||
|
||||
func ExportReport(stats *models.UserStats) (string, error) {
|
||||
return reports.SaveReport(stats)
|
||||
}
|
||||
+28
-6
@@ -14,17 +14,20 @@ const (
|
||||
urlInput inputScreenState = iota
|
||||
usernameInput
|
||||
tokenInput
|
||||
connNameInput
|
||||
)
|
||||
|
||||
type InputScreen struct {
|
||||
urlField textinput.Model
|
||||
usernameField textinput.Model
|
||||
tokenField textinput.Model
|
||||
connNameField textinput.Model
|
||||
focusedField inputScreenState
|
||||
err error
|
||||
url string
|
||||
username string
|
||||
token string
|
||||
connName string
|
||||
done bool
|
||||
}
|
||||
|
||||
@@ -40,10 +43,14 @@ func NewInputScreen() *InputScreen {
|
||||
tokenField.Placeholder = "your_access_token"
|
||||
tokenField.EchoMode = textinput.EchoPassword
|
||||
|
||||
connNameField := textinput.New()
|
||||
connNameField.Placeholder = "(optional) Connection name"
|
||||
|
||||
return &InputScreen{
|
||||
urlField: urlField,
|
||||
usernameField: usernameField,
|
||||
tokenField: tokenField,
|
||||
connNameField: connNameField,
|
||||
focusedField: urlInput,
|
||||
}
|
||||
}
|
||||
@@ -72,6 +79,11 @@ func (is *InputScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return is, nil
|
||||
case tokenInput:
|
||||
is.token = is.tokenField.Value()
|
||||
is.focusedField = connNameInput
|
||||
is.connNameField.Focus()
|
||||
return is, nil
|
||||
case connNameInput:
|
||||
is.connName = is.connNameField.Value()
|
||||
is.done = true
|
||||
return is, nil
|
||||
}
|
||||
@@ -83,17 +95,20 @@ func (is *InputScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
is.username = is.usernameField.Value()
|
||||
case tokenInput:
|
||||
is.token = is.tokenField.Value()
|
||||
case connNameInput:
|
||||
is.connName = is.connNameField.Value()
|
||||
}
|
||||
|
||||
if msg.Type == tea.KeyTab {
|
||||
is.focusedField = (is.focusedField + 1) % 3
|
||||
is.focusedField = (is.focusedField + 1) % 4
|
||||
} else {
|
||||
is.focusedField = (is.focusedField - 1 + 3) % 3
|
||||
is.focusedField = (is.focusedField - 1 + 4) % 4
|
||||
}
|
||||
|
||||
is.urlField.Blur()
|
||||
is.usernameField.Blur()
|
||||
is.tokenField.Blur()
|
||||
is.connNameField.Blur()
|
||||
|
||||
switch is.focusedField {
|
||||
case urlInput:
|
||||
@@ -102,6 +117,8 @@ func (is *InputScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
is.usernameField.Focus()
|
||||
case tokenInput:
|
||||
is.tokenField.Focus()
|
||||
case connNameInput:
|
||||
is.connNameField.Focus()
|
||||
}
|
||||
return is, nil
|
||||
}
|
||||
@@ -119,6 +136,8 @@ func (is *InputScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
is.usernameField, cmd = is.usernameField.Update(msg)
|
||||
case tokenInput:
|
||||
is.tokenField, cmd = is.tokenField.Update(msg)
|
||||
case connNameInput:
|
||||
is.connNameField, cmd = is.connNameField.Update(msg)
|
||||
}
|
||||
|
||||
return is, cmd
|
||||
@@ -132,7 +151,7 @@ func (is *InputScreen) View() string {
|
||||
Bold(true).
|
||||
Render("Gitea Wrapped") + "\n\n"
|
||||
|
||||
s += "Enter your Gitea credentials to get started:\n\n"
|
||||
s += "Enter your Gitea credentials:\n\n"
|
||||
|
||||
s += "Gitea URL\n"
|
||||
s += is.urlField.View() + "\n\n"
|
||||
@@ -143,6 +162,9 @@ func (is *InputScreen) View() string {
|
||||
s += "Access Token\n"
|
||||
s += is.tokenField.View() + "\n\n"
|
||||
|
||||
s += "Connection Name (optional - to save for later)\n"
|
||||
s += is.connNameField.View() + "\n\n"
|
||||
|
||||
if is.err != nil {
|
||||
s += lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("1")).
|
||||
@@ -151,7 +173,7 @@ func (is *InputScreen) View() string {
|
||||
|
||||
s += lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("8")).
|
||||
Render("(press Tab to navigate, Enter to continue, Ctrl+C to quit)") + "\n"
|
||||
Render("(Tab to navigate, Enter to continue, Ctrl+C to quit)") + "\n"
|
||||
|
||||
return s
|
||||
}
|
||||
@@ -160,8 +182,8 @@ func (is *InputScreen) IsDone() bool {
|
||||
return is.done
|
||||
}
|
||||
|
||||
func (is *InputScreen) GetCredentials() (string, string, string) {
|
||||
return is.url, is.username, is.token
|
||||
func (is *InputScreen) GetCredentials() (url, username, token, connName string) {
|
||||
return is.url, is.username, is.token, is.connName
|
||||
}
|
||||
|
||||
type errMsg error
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/atridad/wrapped-cli/pkg/config"
|
||||
)
|
||||
|
||||
type MenuScreen struct {
|
||||
connections []config.Connection
|
||||
selected int
|
||||
newSelected bool
|
||||
confirmed bool
|
||||
}
|
||||
|
||||
func NewMenuScreen(cfg *config.Config) *MenuScreen {
|
||||
return &MenuScreen{
|
||||
connections: cfg.Connections,
|
||||
selected: 0,
|
||||
confirmed: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MenuScreen) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MenuScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "up", "k":
|
||||
if m.selected > 0 {
|
||||
m.selected--
|
||||
}
|
||||
case "down", "j":
|
||||
menuItems := len(m.connections) + 1
|
||||
if m.selected < menuItems-1 {
|
||||
m.selected++
|
||||
}
|
||||
case "enter":
|
||||
if m.selected == len(m.connections) {
|
||||
m.newSelected = true
|
||||
} else if m.selected < len(m.connections) {
|
||||
m.confirmed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *MenuScreen) View() string {
|
||||
var s string
|
||||
s += lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("212")).
|
||||
Bold(true).
|
||||
Render("Gitea Wrapped") + "\n\n"
|
||||
|
||||
if len(m.connections) == 0 {
|
||||
s += "No saved connections yet.\n\n"
|
||||
|
||||
newPrefix := " "
|
||||
if m.selected == 0 {
|
||||
newPrefix = "→ "
|
||||
}
|
||||
if m.selected == 1 {
|
||||
}
|
||||
|
||||
s += fmt.Sprintf("%s+ New Connection\n", newPrefix)
|
||||
|
||||
s += "\n" + lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("8")).
|
||||
Render("↑/↓ navigate | Enter to select | Ctrl+C to quit")
|
||||
} else {
|
||||
s += "Select a connection:\n\n"
|
||||
|
||||
for i, conn := range m.connections {
|
||||
prefix := " "
|
||||
if i == m.selected {
|
||||
prefix = "→ "
|
||||
}
|
||||
s += fmt.Sprintf("%s%s (%s)\n", prefix, conn.Name, conn.URL)
|
||||
}
|
||||
|
||||
newPrefix := " "
|
||||
if m.selected == len(m.connections) {
|
||||
newPrefix = "→ "
|
||||
}
|
||||
if m.selected == len(m.connections)+1 {
|
||||
}
|
||||
|
||||
s += fmt.Sprintf("%s+ New Connection\n", newPrefix)
|
||||
|
||||
s += "\n" + lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("8")).
|
||||
Render("↑/↓ navigate | Enter to select | Ctrl+C to quit")
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (m *MenuScreen) IsNewConnection() bool {
|
||||
return m.newSelected
|
||||
}
|
||||
|
||||
func (m *MenuScreen) GetSelectedName() string {
|
||||
if m.confirmed && m.selected < len(m.connections) {
|
||||
return m.connections[m.selected].Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *MenuScreen) ResetSelection() {
|
||||
m.newSelected = false
|
||||
m.confirmed = false
|
||||
m.selected = 0
|
||||
}
|
||||
+12
-2
@@ -7,20 +7,23 @@ import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/atridad/gitea-wrapped/pkg/models"
|
||||
"github.com/atridad/wrapped-cli/pkg/models"
|
||||
)
|
||||
|
||||
type ReportScreen struct {
|
||||
stats *models.UserStats
|
||||
page int
|
||||
maxPages int
|
||||
exportFile string
|
||||
}
|
||||
|
||||
func NewReportScreen(stats *models.UserStats) *ReportScreen {
|
||||
exportFile, _ := ExportReport(stats)
|
||||
return &ReportScreen{
|
||||
stats: stats,
|
||||
page: 0,
|
||||
maxPages: 3,
|
||||
exportFile: exportFile,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +70,14 @@ func (rs *ReportScreen) View() string {
|
||||
Foreground(lipgloss.Color("8")).
|
||||
Render(fmt.Sprintf("Page %d/%d | ← → to navigate | q to quit", rs.page+1, rs.maxPages))
|
||||
|
||||
return header + "\n" + content + "\n\n" + footer
|
||||
report := header + "\n" + content + "\n\n" + footer
|
||||
if rs.exportFile != "" {
|
||||
report += "\n" + lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("10")).
|
||||
Render(fmt.Sprintf("✓ Report saved: %s", rs.exportFile))
|
||||
}
|
||||
|
||||
return report
|
||||
}
|
||||
|
||||
func (rs *ReportScreen) renderOverviewPage() string {
|
||||
|
||||
Reference in New Issue
Block a user