commit fec14022cd4672048a44b8fe7109cef0076f0a93 Author: Atridad Lahiji Date: Fri May 1 09:43:09 2026 -0600 Initial implementation of Gitea Wrapped TUI - Gitea API client with repository and commit fetching - Interactive credential input screen with masked token input - Statistics analyzer for commits, languages, and activity patterns - Multi-page report screen with ASCII charts and visualizations - Integration of all components in main app coordinator - Comprehensive README with usage instructions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..44c0656 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Go +vendor/ +*.test +*.out + +wrapped diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3c06456 --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +.PHONY: build run clean + +build: + go build -o wrapped ./cmd/gitea-wrapped/ + +run: build + ./wrapped + +clean: + rm -f wrapped diff --git a/README.md b/README.md new file mode 100644 index 0000000..55664de --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Gitea Wrapped + +This was me testing what all this vibe coding nonsense was like. IDK man... diff --git a/cmd/gitea-wrapped/main.go b/cmd/gitea-wrapped/main.go new file mode 100644 index 0000000..6cc0f26 --- /dev/null +++ b/cmd/gitea-wrapped/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/atridad/gitea-wrapped/pkg/ui" +) + +func main() { + app := ui.NewApp() + p := tea.NewProgram(app) + if _, err := p.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9cac35e --- /dev/null +++ b/go.mod @@ -0,0 +1,33 @@ +module github.com/atridad/gitea-wrapped + +go 1.26.2 + +require ( + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + 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/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 + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + 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/sys v0.38.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e92ed90 --- /dev/null +++ b/go.sum @@ -0,0 +1,52 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +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/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= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +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/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= diff --git a/pkg/gitea/client.go b/pkg/gitea/client.go new file mode 100644 index 0000000..3d1b260 --- /dev/null +++ b/pkg/gitea/client.go @@ -0,0 +1,175 @@ +package gitea + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +type Client struct { + BaseURL string + Token string + Client *http.Client +} + +func NewClient(baseURL, token string) *Client { + baseURL = strings.TrimRight(baseURL, "/") + + if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") { + baseURL = "https://" + baseURL + } + + if strings.HasSuffix(baseURL, "/api/v1") { + baseURL = strings.TrimSuffix(baseURL, "/api/v1") + } + if strings.HasSuffix(baseURL, "/api") { + baseURL = strings.TrimSuffix(baseURL, "/api") + } + + return &Client{ + BaseURL: baseURL, + Token: token, + Client: &http.Client{Timeout: 60 * time.Second}, + } +} + +type GiteaRepo struct { + ID int64 `json:"id"` + Name string `json:"name"` + FullName string `json:"full_name"` + HTMLURL string `json:"html_url"` + Language string `json:"language"` + Topics []string `json:"topics"` +} + +type GiteaCommit struct { + SHA string `json:"sha"` + Commit struct { + Author struct { + Name string `json:"name"` + Email string `json:"email"` + Date time.Time `json:"date"` + } `json:"author"` + Message string `json:"message"` + } `json:"commit"` +} + +// do makes an authenticated HTTP request to Gitea API +func (c *Client) do(method, path string) ([]byte, error) { + url := fmt.Sprintf("%s/api/v1%s", c.BaseURL, path) + + req, err := http.NewRequest(method, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request for %s: %w", url, err) + } + + 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") + + resp, err := c.Client.Do(req) + if err != nil { + return nil, fmt.Errorf("API request failed (%s %s): %w", method, url, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + errMsg := string(body) + if len(errMsg) > 500 { + errMsg = errMsg[:500] + } + return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, errMsg) + } + + return body, nil +} + +func (c *Client) GetUserRepos() ([]GiteaRepo, error) { + var repos []GiteaRepo + page := 1 + pageSize := 50 + maxPages := 100 // Safety limit to prevent infinite loops + + for page <= maxPages { + path := fmt.Sprintf("/user/repos?page=%d&limit=%d", page, pageSize) + body, err := c.do("GET", path) + if err != nil { + if len(repos) > 0 { + return repos, nil + } + return nil, err + } + + var pageRepos []GiteaRepo + if err := json.Unmarshal(body, &pageRepos); err != nil { + return nil, err + } + + if len(pageRepos) == 0 { + break + } + + repos = append(repos, pageRepos...) + + if len(pageRepos) < pageSize { + break + } + + page++ + } + + return repos, nil +} + +func (c *Client) GetRepoCommits(owner, repo string) ([]GiteaCommit, error) { + var commits []GiteaCommit + page := 1 + pageSize := 50 + maxPages := 1000 // Safety limit to prevent infinite loops + + for page <= maxPages { + path := fmt.Sprintf("/repos/%s/%s/commits?page=%d&limit=%d", owner, repo, page, pageSize) + body, err := c.do("GET", path) + if err != nil { + if len(commits) > 0 { + return commits, nil + } + return nil, err + } + + var pageCommits []GiteaCommit + if err := json.Unmarshal(body, &pageCommits); err != nil { + return nil, err + } + + if len(pageCommits) == 0 { + break + } + + commits = append(commits, pageCommits...) + + if len(pageCommits) < pageSize { + break + } + + page++ + } + + return commits, nil +} + +func (c *Client) TestConnection() error { + _, err := c.do("GET", "/user") + if err != nil { + return err + } + return nil +} diff --git a/pkg/models/types.go b/pkg/models/types.go new file mode 100644 index 0000000..055e90c --- /dev/null +++ b/pkg/models/types.go @@ -0,0 +1,51 @@ +package models + +import "time" + +type Repository struct { + ID int64 + Name string + FullName string + HTMLURL string + Language string + Topics []string +} + +type Commit struct { + SHA string + Message string + Author string + Timestamp time.Time + RepoName string +} + +type LanguageStats struct { + Language string + Count int + Percent float64 +} + +type DateStats struct { + Date time.Time + Count int + Day string +} + +type TagStats struct { + Tag string + Count int +} + +type UserStats struct { + Username string + TotalCommits int + TotalRepositories int + Languages []LanguageStats + Tags []TagStats + CommitsByWeekday []DateStats + CommitsByMonth []DateStats + TopRepositories []Repository + MostActiveMonth string + MostActiveDay string + AverageCommitsPerDay float64 +} diff --git a/pkg/stats/analyzer.go b/pkg/stats/analyzer.go new file mode 100644 index 0000000..8fbc08d --- /dev/null +++ b/pkg/stats/analyzer.go @@ -0,0 +1,212 @@ +package stats + +import ( + "sort" + "time" + + "github.com/atridad/gitea-wrapped/pkg/gitea" + "github.com/atridad/gitea-wrapped/pkg/models" +) + +type Analyzer struct { + repos []gitea.GiteaRepo + commits []models.Commit +} + +func NewAnalyzer() *Analyzer { + return &Analyzer{ + repos: []gitea.GiteaRepo{}, + commits: []models.Commit{}, + } +} + +func (a *Analyzer) AddRepos(repos []gitea.GiteaRepo) { + a.repos = repos +} + +func (a *Analyzer) AddCommits(commits []models.Commit) { + a.commits = append(a.commits, commits...) +} + +func (a *Analyzer) Generate(username string) *models.UserStats { + stats := &models.UserStats{ + Username: username, + TotalCommits: len(a.commits), + TotalRepositories: len(a.repos), + } + + stats.Languages = a.generateLanguageStats() + stats.CommitsByWeekday = a.generateWeekdayStats() + stats.CommitsByMonth = a.generateMonthStats() + stats.AverageCommitsPerDay = a.calculateAverageCommitsPerDay() + + if len(stats.CommitsByMonth) > 0 { + stats.MostActiveMonth = stats.CommitsByMonth[0].Date.Format("January") + } + if len(stats.CommitsByWeekday) > 0 { + stats.MostActiveDay = stats.CommitsByWeekday[0].Day + } + + stats.TopRepositories = a.getTopRepositories(5) + + return stats +} + +// generateLanguageStats creates language statistics +func (a *Analyzer) generateLanguageStats() []models.LanguageStats { + langCount := make(map[string]int) + + for _, repo := range a.repos { + if repo.Language != "" { + langCount[repo.Language]++ + } + } + + var stats []models.LanguageStats + for lang, count := range langCount { + stats = append(stats, models.LanguageStats{ + Language: lang, + Count: count, + }) + } + + sort.Slice(stats, func(i, j int) bool { + return stats[i].Count > stats[j].Count + }) + + total := len(a.repos) + for i := range stats { + stats[i].Percent = float64(stats[i].Count) / float64(total) * 100 + } + + return stats +} + +// generateWeekdayStats creates weekday statistics for commits +func (a *Analyzer) generateWeekdayStats() []models.DateStats { + dayCount := make(map[time.Weekday]int) + dayNames := [7]string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"} + + for _, commit := range a.commits { + day := commit.Timestamp.Weekday() + dayCount[day]++ + } + + var stats []models.DateStats + for i := 0; i < 7; i++ { + day := time.Weekday(i) + stats = append(stats, models.DateStats{ + Day: dayNames[i], + Count: dayCount[day], + }) + } + + sort.Slice(stats, func(i, j int) bool { + return stats[i].Count > stats[j].Count + }) + + return stats +} + +// generateMonthStats creates monthly statistics for commits +func (a *Analyzer) generateMonthStats() []models.DateStats { + monthCount := make(map[string]int) + monthKeys := []string{} + monthMap := make(map[string]time.Time) + + for _, commit := range a.commits { + monthStr := commit.Timestamp.Format("2006-01") + monthCount[monthStr]++ + if _, exists := monthMap[monthStr]; !exists { + monthKeys = append(monthKeys, monthStr) + monthMap[monthStr] = commit.Timestamp + } + } + + var stats []models.DateStats + for _, monthStr := range monthKeys { + t, _ := time.Parse("2006-01", monthStr) + stats = append(stats, models.DateStats{ + Date: t, + Count: monthCount[monthStr], + }) + } + + sort.Slice(stats, func(i, j int) bool { + return stats[i].Count > stats[j].Count + }) + + return stats +} + +// calculateAverageCommitsPerDay calculates average commits per calendar day +func (a *Analyzer) calculateAverageCommitsPerDay() float64 { + if len(a.commits) == 0 { + return 0 + } + + if len(a.commits) < 2 { + return float64(len(a.commits)) + } + + sort.Slice(a.commits, func(i, j int) bool { + return a.commits[i].Timestamp.Before(a.commits[j].Timestamp) + }) + + firstDay := a.commits[0].Timestamp + lastDay := a.commits[len(a.commits)-1].Timestamp + + daysDiff := lastDay.Sub(firstDay).Hours() / 24 + if daysDiff == 0 { + daysDiff = 1 + } + + return float64(len(a.commits)) / daysDiff +} + +// getTopRepositories returns the top N repositories by commit count +func (a *Analyzer) getTopRepositories(n int) []models.Repository { + repoCommitCount := make(map[string]int) + repoMap := make(map[string]models.Repository) + + for _, commit := range a.commits { + repoCommitCount[commit.RepoName]++ + } + + for _, repo := range a.repos { + repoMap[repo.Name] = models.Repository{ + ID: repo.ID, + Name: repo.Name, + FullName: repo.FullName, + HTMLURL: repo.HTMLURL, + Language: repo.Language, + Topics: repo.Topics, + } + } + + type repoWithCount struct { + repo models.Repository + count int + } + var repos []repoWithCount + for name, count := range repoCommitCount { + if repo, exists := repoMap[name]; exists { + repos = append(repos, repoWithCount{repo, count}) + } + } + + sort.Slice(repos, func(i, j int) bool { + return repos[i].count > repos[j].count + }) + + if len(repos) > n { + repos = repos[:n] + } + + result := make([]models.Repository, len(repos)) + for i, rc := range repos { + result[i] = rc.repo + } + + return result +} diff --git a/pkg/ui/app.go b/pkg/ui/app.go new file mode 100644 index 0000000..8a0ea04 --- /dev/null +++ b/pkg/ui/app.go @@ -0,0 +1,240 @@ +package ui + +import ( + "fmt" + "strings" + "time" + + 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" +) + +type screenType int + +const ( +inputScreenType screenType = iota +loadingScreenType +reportScreenType +) + +type tickMsg time.Time +type progressMsg string +type errorMsg string + +type doneMsg struct { +repos []gitea.GiteaRepo +commits []models.Commit +username string +} + +type App struct { +screen screenType +inputScreen *InputScreen +reportScreen *ReportScreen +loadingMsg string +err error +giteaClient *gitea.Client +analyzer *stats.Analyzer +spinnerFrame int +repos []gitea.GiteaRepo +username string +} + +func NewApp() *App { +return &App{ +screen: inputScreenType, +inputScreen: NewInputScreen(), +analyzer: stats.NewAnalyzer(), +} +} + +func (a *App) Init() tea.Cmd { +return a.inputScreen.Init() +} + +func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +switch msg := msg.(type) { +case tea.KeyMsg: +if msg.Type == tea.KeyCtrlC { +return a, tea.Quit +} +case tickMsg: +if a.screen == loadingScreenType { +a.spinnerFrame = (a.spinnerFrame + 1) % 4 +return a, tea.Tick(time.Millisecond*200, func(t time.Time) tea.Msg { +return tickMsg(t) +}) +} +} + +switch a.screen { +case inputScreenType: +_, cmd := a.inputScreen.Update(msg) + +if a.inputScreen.IsDone() { +url, username, token := a.inputScreen.GetCredentials() + +if url == "" { +a.inputScreen.err = fmt.Errorf("URL is required") +a.inputScreen.done = false +return a, cmd +} +if username == "" { +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 +} + +a.giteaClient = gitea.NewClient(url, token) +a.username = username +a.screen = loadingScreenType +a.loadingMsg = "Connecting to Gitea..." +a.spinnerFrame = 0 + +return a, tea.Batch(cmd, +tea.Tick(time.Millisecond*200, func(t time.Time) tea.Msg { +return tickMsg(t) +}), +a.testConnection()) +} + +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: +if a.reportScreen != nil { +_, cmd := a.reportScreen.Update(msg) +return a, cmd +} +} + +return a, nil +} + +func (a *App) View() string { +switch a.screen { +case inputScreenType: +return a.inputScreen.View() +case loadingScreenType: +return a.renderLoadingScreen() +case reportScreenType: +if a.reportScreen != nil { +return a.reportScreen.View() +} +} +return "" +} + +func (a *App) renderLoadingScreen() string { +var s string +s += lipgloss.NewStyle(). +Foreground(lipgloss.Color("212")). +Bold(true). +Render("🎵 Gitea Wrapped") + "\n\n" + +spinners := []string{"⠋", "⠙", "⠹", "⠸"} +spinner := spinners[a.spinnerFrame%4] + +s += fmt.Sprintf("%s %s\n", spinner, a.loadingMsg) + +if a.err != nil { +s += "\n" + lipgloss.NewStyle(). +Foreground(lipgloss.Color("1")). +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 { +return errorMsg(fmt.Sprintf("failed to connect: %v", err)) +} +return progressMsg("Fetching repositories...") +} +} + +// fetchRepos fetches all repositories +func (a *App) fetchRepos() tea.Cmd { +return func() tea.Msg { +repos, err := a.giteaClient.GetUserRepos() +if err != nil { +return errorMsg(fmt.Sprintf("failed to fetch repos: %v", err)) +} + +a.repos = repos +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 { + +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 +} + + +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} +} +} diff --git a/pkg/ui/input_screen.go b/pkg/ui/input_screen.go new file mode 100644 index 0000000..63bd229 --- /dev/null +++ b/pkg/ui/input_screen.go @@ -0,0 +1,167 @@ +package ui + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type inputScreenState int + +const ( + urlInput inputScreenState = iota + usernameInput + tokenInput +) + +type InputScreen struct { + urlField textinput.Model + usernameField textinput.Model + tokenField textinput.Model + focusedField inputScreenState + err error + url string + username string + token string + done bool +} + +func NewInputScreen() *InputScreen { + urlField := textinput.New() + urlField.Placeholder = "https://gitea.example.com" + urlField.Focus() + + usernameField := textinput.New() + usernameField.Placeholder = "your_username" + + tokenField := textinput.New() + tokenField.Placeholder = "your_access_token" + tokenField.EchoMode = textinput.EchoPassword + + return &InputScreen{ + urlField: urlField, + usernameField: usernameField, + tokenField: tokenField, + focusedField: urlInput, + } +} + +func (is *InputScreen) Init() tea.Cmd { + return textinput.Blink +} + +func (is *InputScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlC: + return is, tea.Quit + case tea.KeyEnter: + switch is.focusedField { + case urlInput: + is.url = is.urlField.Value() + is.focusedField = usernameInput + is.usernameField.Focus() + return is, nil + case usernameInput: + is.username = is.usernameField.Value() + is.focusedField = tokenInput + is.tokenField.Focus() + return is, nil + case tokenInput: + is.token = is.tokenField.Value() + is.done = true + return is, nil + } + case tea.KeyTab, tea.KeyShiftTab: + switch is.focusedField { + case urlInput: + is.url = is.urlField.Value() + case usernameInput: + is.username = is.usernameField.Value() + case tokenInput: + is.token = is.tokenField.Value() + } + + if msg.Type == tea.KeyTab { + is.focusedField = (is.focusedField + 1) % 3 + } else { + is.focusedField = (is.focusedField - 1 + 3) % 3 + } + + is.urlField.Blur() + is.usernameField.Blur() + is.tokenField.Blur() + + switch is.focusedField { + case urlInput: + is.urlField.Focus() + case usernameInput: + is.usernameField.Focus() + case tokenInput: + is.tokenField.Focus() + } + return is, nil + } + + case errMsg: + is.err = msg + return is, nil + } + + var cmd tea.Cmd + switch is.focusedField { + case urlInput: + is.urlField, cmd = is.urlField.Update(msg) + case usernameInput: + is.usernameField, cmd = is.usernameField.Update(msg) + case tokenInput: + is.tokenField, cmd = is.tokenField.Update(msg) + } + + return is, cmd +} + +func (is *InputScreen) View() string { + var s string + + s += lipgloss.NewStyle(). + Foreground(lipgloss.Color("212")). + Bold(true). + Render("Gitea Wrapped") + "\n\n" + + s += "Enter your Gitea credentials to get started:\n\n" + + s += "Gitea URL\n" + s += is.urlField.View() + "\n\n" + + s += "Username\n" + s += is.usernameField.View() + "\n\n" + + s += "Access Token\n" + s += is.tokenField.View() + "\n\n" + + if is.err != nil { + s += lipgloss.NewStyle(). + Foreground(lipgloss.Color("1")). + Render(fmt.Sprintf("Error: %v", is.err)) + "\n" + } + + s += lipgloss.NewStyle(). + Foreground(lipgloss.Color("8")). + Render("(press Tab to navigate, Enter to continue, Ctrl+C to quit)") + "\n" + + return s +} + +func (is *InputScreen) IsDone() bool { + return is.done +} + +func (is *InputScreen) GetCredentials() (string, string, string) { + return is.url, is.username, is.token +} + +type errMsg error diff --git a/pkg/ui/report_screen.go b/pkg/ui/report_screen.go new file mode 100644 index 0000000..74915e1 --- /dev/null +++ b/pkg/ui/report_screen.go @@ -0,0 +1,173 @@ +package ui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/atridad/gitea-wrapped/pkg/models" +) + +type ReportScreen struct { + stats *models.UserStats + page int + maxPages int +} + +func NewReportScreen(stats *models.UserStats) *ReportScreen { + return &ReportScreen{ + stats: stats, + page: 0, + maxPages: 3, + } +} + +func (rs *ReportScreen) Init() tea.Cmd { + return nil +} + +func (rs *ReportScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + return rs, tea.Quit + case "right", "l", "n": + if rs.page < rs.maxPages-1 { + rs.page++ + } + case "left", "h", "p": + if rs.page > 0 { + rs.page-- + } + } + } + return rs, nil +} + +func (rs *ReportScreen) View() string { + header := lipgloss.NewStyle(). + Foreground(lipgloss.Color("212")). + Bold(true). + Render(fmt.Sprintf("Gitea Wrapped %d", 2026)) + "\n" + + var content string + switch rs.page { + case 0: + content = rs.renderOverviewPage() + case 1: + content = rs.renderLanguagesPage() + case 2: + content = rs.renderActivityPage() + } + + footer := lipgloss.NewStyle(). + 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 +} + +func (rs *ReportScreen) renderOverviewPage() string { + var s strings.Builder + + s.WriteString(lipgloss.NewStyle(). + Foreground(lipgloss.Color("14")). + Bold(true). + Render("Your 2026 Overview") + "\n\n") + + s.WriteString(fmt.Sprintf("Username: %s\n", rs.stats.Username)) + s.WriteString(fmt.Sprintf("Total Commits: %d\n", rs.stats.TotalCommits)) + s.WriteString(fmt.Sprintf("Total Repositories: %d\n", rs.stats.TotalRepositories)) + s.WriteString(fmt.Sprintf("Average Commits/Day: %.2f\n", rs.stats.AverageCommitsPerDay)) + s.WriteString(fmt.Sprintf("Most Active Day: %s\n", rs.stats.MostActiveDay)) + s.WriteString(fmt.Sprintf("Most Active Month: %s\n", rs.stats.MostActiveMonth)) + + s.WriteString("\nTop Repositories:\n") + for i, repo := range rs.stats.TopRepositories { + if i >= 5 { + break + } + s.WriteString(fmt.Sprintf(" %d. %s\n", i+1, repo.Name)) + } + + return s.String() +} + +func (rs *ReportScreen) renderLanguagesPage() string { + var s strings.Builder + + s.WriteString(lipgloss.NewStyle(). + Foreground(lipgloss.Color("14")). + Bold(true). + Render("Languages You Love") + "\n\n") + + if len(rs.stats.Languages) == 0 { + s.WriteString("No language data available.\n") + return s.String() + } + + maxLength := 30 + maxCount := 0 + for _, lang := range rs.stats.Languages { + if lang.Count > maxCount { + maxCount = lang.Count + } + } + + for i, lang := range rs.stats.Languages { + if i >= 10 { + break + } + + barLength := int(float64(lang.Count) / float64(maxCount) * float64(maxLength)) + bar := strings.Repeat("█", barLength) + strings.Repeat("░", maxLength-barLength) + s.WriteString(fmt.Sprintf("%-12s %s %d (%.1f%%)\n", lang.Language, bar, lang.Count, lang.Percent)) + } + + return s.String() +} + +func (rs *ReportScreen) renderActivityPage() string { + var s strings.Builder + + s.WriteString(lipgloss.NewStyle(). + Foreground(lipgloss.Color("14")). + Bold(true). + Render("Your Activity Patterns") + "\n\n") + + s.WriteString("Most Active Days of the Week:\n") + if len(rs.stats.CommitsByWeekday) > 0 { + maxCount := rs.stats.CommitsByWeekday[0].Count + for i, day := range rs.stats.CommitsByWeekday { + if i >= 7 { + break + } + barLength := int(float64(day.Count) / float64(maxCount) * 20) + bar := strings.Repeat("█", barLength) + strings.Repeat("░", 20-barLength) + s.WriteString(fmt.Sprintf("%-12s %s %d\n", day.Day, bar, day.Count)) + } + } else { + s.WriteString("No weekday data available.\n") + } + + s.WriteString("\nMost Active Months:\n") + if len(rs.stats.CommitsByMonth) > 0 { + maxCount := rs.stats.CommitsByMonth[0].Count + for i, month := range rs.stats.CommitsByMonth { + if i >= 12 { + break + } + barLength := int(float64(month.Count) / float64(maxCount) * 20) + bar := strings.Repeat("█", barLength) + strings.Repeat("░", 20-barLength) + monthStr := month.Date.Format("Jan 2006") + s.WriteString(fmt.Sprintf("%-12s %s %d\n", monthStr, bar, month.Count)) + } + } else { + s.WriteString("No monthly data available.\n") + } + + return s.String() +}