Added bento :)

This commit is contained in:
2026-05-01 14:10:22 -06:00
parent fec14022cd
commit 9ea2cf8c34
17 changed files with 1304 additions and 253 deletions
+2
View File
@@ -14,4 +14,6 @@ vendor/
*.test *.test
*.out *.out
# Executable and runtime files
wrapped wrapped
.wrapped/
+2 -2
View File
@@ -1,10 +1,10 @@
.PHONY: build run clean .PHONY: build run clean
build: build:
go build -o wrapped ./cmd/gitea-wrapped/ go build -o wrapped ./cmd/wrapped-cli/
run: build run: build
./wrapped ./wrapped
clean: clean:
rm -f wrapped rm -rf ./wrapped ./.wrapped
+3 -1
View File
@@ -1,3 +1,5 @@
# Gitea Wrapped # 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" tea "github.com/charmbracelet/bubbletea"
"github.com/atridad/gitea-wrapped/pkg/ui" "github.com/atridad/wrapped-cli/pkg/ui"
) )
func main() { func main() {
+6 -2
View File
@@ -1,4 +1,4 @@
module github.com/atridad/gitea-wrapped module github.com/atridad/wrapped-cli
go 1.26.2 go 1.26.2
@@ -19,6 +19,8 @@ require (
github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // 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/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // 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/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // 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/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
) )
+11
View File
@@ -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/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 h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 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 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 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= 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= 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 h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= 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/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 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 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=
+94
View File
@@ -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
View File
@@ -6,6 +6,7 @@ import (
"io" "io"
"net/http" "net/http"
"strings" "strings"
"sync"
"time" "time"
) )
@@ -47,6 +48,7 @@ type GiteaRepo struct {
type GiteaCommit struct { type GiteaCommit struct {
SHA string `json:"sha"` SHA string `json:"sha"`
RepoName string `json:"-"`
Commit struct { Commit struct {
Author struct { Author struct {
Name string `json:"name"` Name string `json:"name"`
@@ -57,7 +59,11 @@ type GiteaCommit struct {
} `json:"commit"` } `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) { func (c *Client) do(method, path string) ([]byte, error) {
url := fmt.Sprintf("%s/api/v1%s", c.BaseURL, path) 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("Authorization", fmt.Sprintf("token %s", c.Token))
req.Header.Set("Accept", "application/json") 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) resp, err := c.Client.Do(req)
if err != nil { if err != nil {
@@ -96,7 +102,7 @@ func (c *Client) GetUserRepos() ([]GiteaRepo, error) {
var repos []GiteaRepo var repos []GiteaRepo
page := 1 page := 1
pageSize := 50 pageSize := 50
maxPages := 100 // Safety limit to prevent infinite loops maxPages := 100
for page <= maxPages { for page <= maxPages {
path := fmt.Sprintf("/user/repos?page=%d&limit=%d", page, pageSize) 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 var commits []GiteaCommit
page := 1 page := 1
pageSize := 50 pageSize := 50
maxPages := 1000 // Safety limit to prevent infinite loops maxPages := 1000
for page <= maxPages { for page <= maxPages {
path := fmt.Sprintf("/repos/%s/%s/commits?page=%d&limit=%d", owner, repo, page, pageSize) 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 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 { func (c *Client) TestConnection() error {
_, err := c.do("GET", "/user") _, err := c.do("GET", "/user")
if err != nil { if err != nil {
+1
View File
@@ -15,6 +15,7 @@ type Commit struct {
SHA string SHA string
Message string Message string
Author string Author string
AuthorEmail string
Timestamp time.Time Timestamp time.Time
RepoName string RepoName string
} }
+458
View File
@@ -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}
}
+179
View File
@@ -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
View File
@@ -4,8 +4,8 @@ import (
"sort" "sort"
"time" "time"
"github.com/atridad/gitea-wrapped/pkg/gitea" "github.com/atridad/wrapped-cli/pkg/gitea"
"github.com/atridad/gitea-wrapped/pkg/models" "github.com/atridad/wrapped-cli/pkg/models"
) )
type Analyzer struct { type Analyzer struct {
@@ -28,17 +28,48 @@ func (a *Analyzer) AddCommits(commits []models.Commit) {
a.commits = append(a.commits, commits...) 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 { 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{ stats := &models.UserStats{
Username: username, Username: username,
TotalCommits: len(a.commits), TotalCommits: len(filteredCommits),
TotalRepositories: len(a.repos), TotalRepositories: len(a.repos),
} }
stats.Languages = a.generateLanguageStats() stats.Languages = a.generateLanguageStats()
stats.CommitsByWeekday = a.generateWeekdayStats() stats.CommitsByWeekday = a.generateWeekdayStats(filteredCommits)
stats.CommitsByMonth = a.generateMonthStats() stats.CommitsByMonth = a.generateMonthStats(filteredCommits)
stats.AverageCommitsPerDay = a.calculateAverageCommitsPerDay() stats.AverageCommitsPerDay = a.calculateAverageCommitsPerDay(filteredCommits)
if len(stats.CommitsByMonth) > 0 { if len(stats.CommitsByMonth) > 0 {
stats.MostActiveMonth = stats.CommitsByMonth[0].Date.Format("January") 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.MostActiveDay = stats.CommitsByWeekday[0].Day
} }
stats.TopRepositories = a.getTopRepositories(5) stats.TopRepositories = a.getTopRepositories(filteredCommits, 5)
return stats return stats
} }
// generateLanguageStats creates language statistics
func (a *Analyzer) generateLanguageStats() []models.LanguageStats { func (a *Analyzer) generateLanguageStats() []models.LanguageStats {
langCount := make(map[string]int) langCount := make(map[string]int)
@@ -82,12 +112,11 @@ func (a *Analyzer) generateLanguageStats() []models.LanguageStats {
return stats return stats
} }
// generateWeekdayStats creates weekday statistics for commits func (a *Analyzer) generateWeekdayStats(commits []models.Commit) []models.DateStats {
func (a *Analyzer) generateWeekdayStats() []models.DateStats {
dayCount := make(map[time.Weekday]int) dayCount := make(map[time.Weekday]int)
dayNames := [7]string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"} dayNames := [7]string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
for _, commit := range a.commits { for _, commit := range commits {
day := commit.Timestamp.Weekday() day := commit.Timestamp.Weekday()
dayCount[day]++ dayCount[day]++
} }
@@ -108,19 +137,17 @@ func (a *Analyzer) generateWeekdayStats() []models.DateStats {
return stats return stats
} }
// generateMonthStats creates monthly statistics for commits func (a *Analyzer) generateMonthStats(commits []models.Commit) []models.DateStats {
func (a *Analyzer) generateMonthStats() []models.DateStats {
monthCount := make(map[string]int) monthCount := make(map[string]int)
monthKeys := []string{} monthKeys := []string{}
monthMap := make(map[string]time.Time)
for _, commit := range a.commits { for _, commit := range commits {
monthStr := commit.Timestamp.Format("2006-01") monthStr := commit.Timestamp.Format("2006-01")
monthCount[monthStr]++ 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 var stats []models.DateStats
@@ -139,37 +166,35 @@ func (a *Analyzer) generateMonthStats() []models.DateStats {
return stats return stats
} }
// calculateAverageCommitsPerDay calculates average commits per calendar day func (a *Analyzer) calculateAverageCommitsPerDay(commits []models.Commit) float64 {
func (a *Analyzer) calculateAverageCommitsPerDay() float64 { if len(commits) == 0 {
if len(a.commits) == 0 {
return 0 return 0
} }
if len(a.commits) < 2 { if len(commits) < 2 {
return float64(len(a.commits)) return float64(len(commits))
} }
sort.Slice(a.commits, func(i, j int) bool { sort.Slice(commits, func(i, j int) bool {
return a.commits[i].Timestamp.Before(a.commits[j].Timestamp) return commits[i].Timestamp.Before(commits[j].Timestamp)
}) })
firstDay := a.commits[0].Timestamp firstDay := commits[0].Timestamp
lastDay := a.commits[len(a.commits)-1].Timestamp lastDay := commits[len(commits)-1].Timestamp
daysDiff := lastDay.Sub(firstDay).Hours() / 24 daysDiff := lastDay.Sub(firstDay).Hours() / 24
if daysDiff == 0 { if daysDiff == 0 {
daysDiff = 1 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(commits []models.Commit, n int) []models.Repository {
func (a *Analyzer) getTopRepositories(n int) []models.Repository {
repoCommitCount := make(map[string]int) repoCommitCount := make(map[string]int)
repoMap := make(map[string]models.Repository) repoMap := make(map[string]models.Repository)
for _, commit := range a.commits { for _, commit := range commits {
repoCommitCount[commit.RepoName]++ repoCommitCount[commit.RepoName]++
} }
@@ -188,6 +213,7 @@ func (a *Analyzer) getTopRepositories(n int) []models.Repository {
repo models.Repository repo models.Repository
count int count int
} }
var repos []repoWithCount var repos []repoWithCount
for name, count := range repoCommitCount { for name, count := range repoCommitCount {
if repo, exists := repoMap[name]; exists { if repo, exists := repoMap[name]; exists {
+243 -173
View File
@@ -8,17 +8,19 @@ import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/atridad/gitea-wrapped/pkg/gitea" "github.com/atridad/wrapped-cli/pkg/config"
"github.com/atridad/gitea-wrapped/pkg/models" "github.com/atridad/wrapped-cli/pkg/gitea"
"github.com/atridad/gitea-wrapped/pkg/stats" "github.com/atridad/wrapped-cli/pkg/models"
"github.com/atridad/wrapped-cli/pkg/stats"
) )
type screenType int type screenType int
const ( const (
inputScreenType screenType = iota menuScreenType screenType = iota
loadingScreenType inputScreenType
reportScreenType loadingScreenType
reportScreenType
) )
type tickMsg time.Time type tickMsg time.Time
@@ -26,215 +28,283 @@ type progressMsg string
type errorMsg string type errorMsg string
type doneMsg struct { type doneMsg struct {
repos []gitea.GiteaRepo repos []gitea.GiteaRepo
commits []models.Commit commits []models.Commit
username string username string
} }
type App struct { type App struct {
screen screenType screen screenType
inputScreen *InputScreen menuScreen *MenuScreen
reportScreen *ReportScreen inputScreen *InputScreen
loadingMsg string reportScreen *ReportScreen
err error loadingMsg string
giteaClient *gitea.Client err error
analyzer *stats.Analyzer giteaClient *gitea.Client
spinnerFrame int analyzer *stats.Analyzer
repos []gitea.GiteaRepo spinnerFrame int
username string repos []gitea.GiteaRepo
username string
config *config.Config
} }
func NewApp() *App { func NewApp() *App {
return &App{ cfg, err := config.LoadConfig()
screen: inputScreenType, if err != nil || cfg == nil {
inputScreen: NewInputScreen(), cfg = &config.Config{Connections: []config.Connection{}}
analyzer: stats.NewAnalyzer(), }
}
screen := menuScreenType
var inputScreen *InputScreen
if len(cfg.Connections) == 0 {
screen = inputScreenType
inputScreen = NewInputScreen()
}
return &App{
screen: screen,
menuScreen: NewMenuScreen(cfg),
inputScreen: inputScreen,
config: cfg,
analyzer: stats.NewAnalyzer(),
}
} }
func (a *App) Init() tea.Cmd { func (a *App) Init() tea.Cmd {
return a.inputScreen.Init() if a.screen == inputScreenType && a.inputScreen != nil {
return a.inputScreen.Init()
}
return nil
} }
func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.KeyMsg: case tea.KeyMsg:
if msg.Type == tea.KeyCtrlC { if msg.Type == tea.KeyCtrlC {
return a, tea.Quit return a, tea.Quit
} }
case tickMsg: case tickMsg:
if a.screen == loadingScreenType { if a.screen == loadingScreenType {
a.spinnerFrame = (a.spinnerFrame + 1) % 4 a.spinnerFrame = (a.spinnerFrame + 1) % 4
return a, tea.Tick(time.Millisecond*200, func(t time.Time) tea.Msg { return a, tea.Tick(time.Millisecond*200, func(t time.Time) tea.Msg {
return tickMsg(t) return tickMsg(t)
}) })
} }
} }
switch a.screen { switch a.screen {
case inputScreenType: case menuScreenType:
_, cmd := a.inputScreen.Update(msg) _, cmd := a.menuScreen.Update(msg)
if a.inputScreen.IsDone() { if a.menuScreen.IsNewConnection() {
url, username, token := a.inputScreen.GetCredentials() a.screen = inputScreenType
a.menuScreen.ResetSelection()
a.inputScreen = NewInputScreen()
return a, a.inputScreen.Init()
}
if url == "" { if selected := a.menuScreen.GetSelectedName(); selected != "" {
a.inputScreen.err = fmt.Errorf("URL is required") conn := a.config.GetConnection(selected)
a.inputScreen.done = false if conn != nil {
return a, cmd a.menuScreen.ResetSelection()
} a.giteaClient = gitea.NewClient(conn.URL, conn.Token)
if username == "" { a.username = conn.Username
a.inputScreen.err = fmt.Errorf("username is required") a.screen = loadingScreenType
a.inputScreen.done = false a.loadingMsg = "Connecting to Gitea..."
return a, cmd a.spinnerFrame = 0
}
if token == "" {
a.inputScreen.err = fmt.Errorf("token is required")
a.inputScreen.done = false
return a, cmd
}
a.giteaClient = gitea.NewClient(url, token) return a, tea.Batch(
a.username = username tea.Tick(time.Millisecond*200, func(t time.Time) tea.Msg {
a.screen = loadingScreenType return tickMsg(t)
a.loadingMsg = "Connecting to Gitea..." }),
a.spinnerFrame = 0 a.testConnection())
}
}
return a, tea.Batch(cmd, return a, cmd
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)
case loadingScreenType: if a.inputScreen.IsDone() {
switch msg := msg.(type) { url, username, token, connName := a.inputScreen.GetCredentials()
case progressMsg:
a.loadingMsg = string(msg)
switch string(msg) { if url == "" {
case "Fetching repositories...": a.inputScreen.err = fmt.Errorf("URL is required")
return a, a.fetchRepos() a.inputScreen.done = false
case "Fetching commits...": return a, cmd
return a, a.fetchCommits() }
} if username == "" {
return a, nil a.inputScreen.err = fmt.Errorf("username is required")
a.inputScreen.done = false
return a, cmd
}
if token == "" {
a.inputScreen.err = fmt.Errorf("token is required")
a.inputScreen.done = false
return a, cmd
}
case errorMsg: if connName != "" {
a.err = fmt.Errorf("%s", msg) a.config.AddConnection(config.Connection{
a.loadingMsg = "Error" Name: connName,
return a, nil URL: url,
Username: username,
Token: token,
})
a.config.Save()
}
case doneMsg: a.giteaClient = gitea.NewClient(url, token)
a.analyzer.AddRepos(msg.repos) a.username = username
a.analyzer.AddCommits(msg.commits) a.screen = loadingScreenType
userStats := a.analyzer.Generate(msg.username) a.loadingMsg = "Connecting to Gitea..."
a.reportScreen = NewReportScreen(userStats) a.spinnerFrame = 0
a.screen = reportScreenType
return a, nil
}
case reportScreenType: return a, tea.Batch(cmd,
if a.reportScreen != nil { tea.Tick(time.Millisecond*200, func(t time.Time) tea.Msg {
_, cmd := a.reportScreen.Update(msg) return tickMsg(t)
return a, cmd }),
} a.testConnection())
} }
return a, nil return a, cmd
case loadingScreenType:
switch msg := msg.(type) {
case progressMsg:
a.loadingMsg = string(msg)
switch string(msg) {
case "Fetching repositories...":
return a, a.fetchRepos()
case "Fetching commits...":
return a, a.fetchCommits()
}
return a, nil
case errorMsg:
a.err = fmt.Errorf("%s", msg)
a.loadingMsg = "Error"
return a, nil
case doneMsg:
a.analyzer.AddRepos(msg.repos)
a.analyzer.AddCommits(msg.commits)
userStats := a.analyzer.Generate(msg.username)
a.reportScreen = NewReportScreen(userStats)
a.screen = reportScreenType
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
}
}
return a, nil
} }
func (a *App) View() string { func (a *App) View() string {
switch a.screen { switch a.screen {
case inputScreenType: case menuScreenType:
return a.inputScreen.View() return a.menuScreen.View()
case loadingScreenType: case inputScreenType:
return a.renderLoadingScreen() return a.inputScreen.View()
case reportScreenType: case loadingScreenType:
if a.reportScreen != nil { return a.renderLoadingScreen()
return a.reportScreen.View() case reportScreenType:
} if a.reportScreen != nil {
} return a.reportScreen.View()
return "" }
}
return ""
} }
func (a *App) renderLoadingScreen() string { func (a *App) renderLoadingScreen() string {
var s string var s string
s += lipgloss.NewStyle(). s += lipgloss.NewStyle().
Foreground(lipgloss.Color("212")). Foreground(lipgloss.Color("212")).
Bold(true). Bold(true).
Render("🎵 Gitea Wrapped") + "\n\n" Render("Gitea Wrapped") + "\n\n"
spinners := []string{"⠋", "⠙", "⠹", "⠸"} spinners := []string{"⠋", "⠙", "⠹", "⠸"}
spinner := spinners[a.spinnerFrame%4] spinner := spinners[a.spinnerFrame%4]
s += fmt.Sprintf("%s %s\n", spinner, a.loadingMsg) s += fmt.Sprintf("%s %s\n", spinner, a.loadingMsg)
if a.err != nil { if a.err != nil {
s += "\n" + lipgloss.NewStyle(). s += "\n" + lipgloss.NewStyle().
Foreground(lipgloss.Color("1")). Foreground(lipgloss.Color("1")).
Render(fmt.Sprintf("Error: %v", a.err)) + "\n" Render(fmt.Sprintf("Error: %v", a.err)) + "\n"
}
return s
} }
return s
}
// testConnection tests API connection
func (a *App) testConnection() tea.Cmd { func (a *App) testConnection() tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
if err := a.giteaClient.TestConnection(); err != nil { if err := a.giteaClient.TestConnection(); err != nil {
return errorMsg(fmt.Sprintf("failed to connect: %v", err)) return errorMsg(fmt.Sprintf("failed to connect: %v", err))
} }
return progressMsg("Fetching repositories...") return progressMsg("Fetching repositories...")
} }
} }
// fetchRepos fetches all repositories
func (a *App) fetchRepos() tea.Cmd { func (a *App) fetchRepos() tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
repos, err := a.giteaClient.GetUserRepos() repos, err := a.giteaClient.GetUserRepos()
if err != nil { if err != nil {
return errorMsg(fmt.Sprintf("failed to fetch repos: %v", err)) return errorMsg(fmt.Sprintf("failed to fetch repos: %v", err))
}
a.repos = repos
return progressMsg("Fetching commits...")
}
} }
a.repos = repos
return progressMsg("Fetching commits...")
}
}
// fetchCommits fetches commits from all repos
func (a *App) fetchCommits() tea.Cmd { func (a *App) fetchCommits() tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
var commits []models.Commit var repoRefs []gitea.RepoRef
for _, repo := range a.repos {
parts := strings.Split(repo.FullName, "/")
if len(parts) < 2 {
continue
}
repoRefs = append(repoRefs, gitea.RepoRef{
Owner: parts[0],
Name: repo.Name,
})
}
for i, repo := range a.repos { giteaCommits, err := a.giteaClient.GetReposCommitsParallel(repoRefs)
if err != nil {
return errorMsg(fmt.Sprintf("failed to fetch commits: %v", err))
}
parts := strings.Split(repo.FullName, "/") var commits []models.Commit
if len(parts) < 2 { for _, gc := range giteaCommits {
continue commits = append(commits, models.Commit{
} SHA: gc.SHA,
Message: gc.Commit.Message,
owner := parts[0] Author: gc.Commit.Author.Name,
giteaCommits, err := a.giteaClient.GetRepoCommits(owner, repo.Name) AuthorEmail: gc.Commit.Author.Email,
if err != nil { Timestamp: gc.Commit.Author.Date,
continue RepoName: gc.RepoName,
} })
}
return doneMsg{a.repos, commits, a.username}
for _, gc := range giteaCommits { }
commits = append(commits, models.Commit{
SHA: gc.SHA,
Message: gc.Commit.Message,
Author: gc.Commit.Author.Name,
Timestamp: gc.Commit.Author.Date,
RepoName: repo.Name,
})
}
}
return doneMsg{a.repos, commits, a.username}
}
} }
+10
View File
@@ -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
View File
@@ -14,17 +14,20 @@ const (
urlInput inputScreenState = iota urlInput inputScreenState = iota
usernameInput usernameInput
tokenInput tokenInput
connNameInput
) )
type InputScreen struct { type InputScreen struct {
urlField textinput.Model urlField textinput.Model
usernameField textinput.Model usernameField textinput.Model
tokenField textinput.Model tokenField textinput.Model
connNameField textinput.Model
focusedField inputScreenState focusedField inputScreenState
err error err error
url string url string
username string username string
token string token string
connName string
done bool done bool
} }
@@ -40,10 +43,14 @@ func NewInputScreen() *InputScreen {
tokenField.Placeholder = "your_access_token" tokenField.Placeholder = "your_access_token"
tokenField.EchoMode = textinput.EchoPassword tokenField.EchoMode = textinput.EchoPassword
connNameField := textinput.New()
connNameField.Placeholder = "(optional) Connection name"
return &InputScreen{ return &InputScreen{
urlField: urlField, urlField: urlField,
usernameField: usernameField, usernameField: usernameField,
tokenField: tokenField, tokenField: tokenField,
connNameField: connNameField,
focusedField: urlInput, focusedField: urlInput,
} }
} }
@@ -72,6 +79,11 @@ func (is *InputScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return is, nil return is, nil
case tokenInput: case tokenInput:
is.token = is.tokenField.Value() is.token = is.tokenField.Value()
is.focusedField = connNameInput
is.connNameField.Focus()
return is, nil
case connNameInput:
is.connName = is.connNameField.Value()
is.done = true is.done = true
return is, nil return is, nil
} }
@@ -83,17 +95,20 @@ func (is *InputScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
is.username = is.usernameField.Value() is.username = is.usernameField.Value()
case tokenInput: case tokenInput:
is.token = is.tokenField.Value() is.token = is.tokenField.Value()
case connNameInput:
is.connName = is.connNameField.Value()
} }
if msg.Type == tea.KeyTab { if msg.Type == tea.KeyTab {
is.focusedField = (is.focusedField + 1) % 3 is.focusedField = (is.focusedField + 1) % 4
} else { } else {
is.focusedField = (is.focusedField - 1 + 3) % 3 is.focusedField = (is.focusedField - 1 + 4) % 4
} }
is.urlField.Blur() is.urlField.Blur()
is.usernameField.Blur() is.usernameField.Blur()
is.tokenField.Blur() is.tokenField.Blur()
is.connNameField.Blur()
switch is.focusedField { switch is.focusedField {
case urlInput: case urlInput:
@@ -102,6 +117,8 @@ func (is *InputScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
is.usernameField.Focus() is.usernameField.Focus()
case tokenInput: case tokenInput:
is.tokenField.Focus() is.tokenField.Focus()
case connNameInput:
is.connNameField.Focus()
} }
return is, nil 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) is.usernameField, cmd = is.usernameField.Update(msg)
case tokenInput: case tokenInput:
is.tokenField, cmd = is.tokenField.Update(msg) is.tokenField, cmd = is.tokenField.Update(msg)
case connNameInput:
is.connNameField, cmd = is.connNameField.Update(msg)
} }
return is, cmd return is, cmd
@@ -132,7 +151,7 @@ func (is *InputScreen) View() string {
Bold(true). Bold(true).
Render("Gitea Wrapped") + "\n\n" 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 += "Gitea URL\n"
s += is.urlField.View() + "\n\n" s += is.urlField.View() + "\n\n"
@@ -143,6 +162,9 @@ func (is *InputScreen) View() string {
s += "Access Token\n" s += "Access Token\n"
s += is.tokenField.View() + "\n\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 { if is.err != nil {
s += lipgloss.NewStyle(). s += lipgloss.NewStyle().
Foreground(lipgloss.Color("1")). Foreground(lipgloss.Color("1")).
@@ -151,7 +173,7 @@ func (is *InputScreen) View() string {
s += lipgloss.NewStyle(). s += lipgloss.NewStyle().
Foreground(lipgloss.Color("8")). 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 return s
} }
@@ -160,8 +182,8 @@ func (is *InputScreen) IsDone() bool {
return is.done return is.done
} }
func (is *InputScreen) GetCredentials() (string, string, string) { func (is *InputScreen) GetCredentials() (url, username, token, connName string) {
return is.url, is.username, is.token return is.url, is.username, is.token, is.connName
} }
type errMsg error type errMsg error
+122
View File
@@ -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
View File
@@ -7,20 +7,23 @@ import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/atridad/gitea-wrapped/pkg/models" "github.com/atridad/wrapped-cli/pkg/models"
) )
type ReportScreen struct { type ReportScreen struct {
stats *models.UserStats stats *models.UserStats
page int page int
maxPages int maxPages int
exportFile string
} }
func NewReportScreen(stats *models.UserStats) *ReportScreen { func NewReportScreen(stats *models.UserStats) *ReportScreen {
exportFile, _ := ExportReport(stats)
return &ReportScreen{ return &ReportScreen{
stats: stats, stats: stats,
page: 0, page: 0,
maxPages: 3, maxPages: 3,
exportFile: exportFile,
} }
} }
@@ -67,7 +70,14 @@ func (rs *ReportScreen) View() string {
Foreground(lipgloss.Color("8")). Foreground(lipgloss.Color("8")).
Render(fmt.Sprintf("Page %d/%d | ← → to navigate | q to quit", rs.page+1, rs.maxPages)) 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 { func (rs *ReportScreen) renderOverviewPage() string {