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>
This commit is contained in:
+17
@@ -0,0 +1,17 @@
|
|||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Go
|
||||||
|
vendor/
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
|
||||||
|
wrapped
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
.PHONY: build run clean
|
||||||
|
|
||||||
|
build:
|
||||||
|
go build -o wrapped ./cmd/gitea-wrapped/
|
||||||
|
|
||||||
|
run: build
|
||||||
|
./wrapped
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f wrapped
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Gitea Wrapped
|
||||||
|
|
||||||
|
This was me testing what all this vibe coding nonsense was like. IDK man...
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+240
@@ -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}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user