commit e5119ec6d451bafc26d456c5a9516fd1eb956c59 Author: Ondrej Vlach Date: Thu Apr 2 13:25:24 2026 +0200 initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..fb04f7b --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# Timesheet + +CLI nástroj pro vypsání timesheetu z GitLabu jako ASCII tabulky. + +## Použití + +```bash +GITLAB_TOKEN=glpat-... \ +GITLAB_USERNAME=foo.bar \ +HOURLY_RATE=100 \ +go run . --month 2026-04 +``` + +Volitelně lze zadat vlastní GitLab instanci: + +```bash +GITLAB_URL=https://gitlab.example.com \ +GITLAB_TOKEN=glpat-... \ +GITLAB_USERNAME=foo.bar \ +HOURLY_RATE=100 \ +go run . --month 2026-04 +``` + +Filtrovat lze také podle issue nebo merge request IID: + +```bash +GITLAB_TOKEN=glpat-... \ +GITLAB_USERNAME=foo.bar \ +HOURLY_RATE=100 \ +go run . --month 2026-04 --id 123 +``` + +## Example Output + +```text + GitLab Timesheet — Duben 2026 + Hodinová sazba: 100 Kč/h | Norma: 8 h/den + ++---------------------------------------------------------------+ +| Datum | Den | Odpracováno | Rozdíl | Částka | ++---------------------------------------------------------------+ +| 2026-04-01 | St | 8.00 h | +0.00 h | 800 Kč | +| 2026-04-02 | Čt | 6.50 h | -1.50 h | 650 Kč | +| 2026-04-03 | Pá | 8.00 h | +0.00 h | 800 Kč | ++---------------------------------------------------------------+ +| Souhrn týdne | 22.50 h | -1.50 h | 2250 Kč | ++---------------------------------------------------------------+ + ++===============================================================+ +| SOUHRN MĚSÍCE | 160.00 h | +0.00 h | 16000 Kč | ++===============================================================+ +``` + +## Význam proměnných + +- `GITLAB_TOKEN` - GitLab Personal Access Token se scope `read_api`. Povinná proměnná. +- `GITLAB_USERNAME` - GitLab username uživatele, pro kterého se timesheet načítá. Povinná proměnná. +- `GITLAB_URL` - základní URL GitLabu. Volitelná proměnná, výchozí hodnota je `https://gitlab.com`. +- `HOURLY_RATE` - hodinová sazba v Kč za hodinu, ze které se počítá výsledná cena. Povinná proměnná. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4470019 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module gitlab-timesheet + +go 1.25.0 + +require github.com/spf13/cobra v1.10.2 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a6ee3e0 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/main.go b/main.go new file mode 100644 index 0000000..c114535 --- /dev/null +++ b/main.go @@ -0,0 +1,625 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/spf13/cobra" +) + +const ( + hoursPerDay = 8.0 + defaultGitlabURL = "https://gitlab.com" + envTokenKey = "GITLAB_TOKEN" + envUsernameKey = "GITLAB_USERNAME" + envGitlabURLKey = "GITLAB_URL" + envHourlyRateKey = "HOURLY_RATE" +) + +const ( + colorReset = "\033[0m" + colorGreen = "\033[32m" + colorRed = "\033[31m" + colorYellow = "\033[33m" + colorBold = "\033[1m" + colorCyan = "\033[36m" +) + +// ── GraphQL types ────────────────────────────────────────────────────────────── + +type gqlRequest struct { + Query string `json:"query"` + Variables map[string]interface{} `json:"variables"` +} + +type TimelogNode struct { + SpentAt string `json:"spentAt"` + TimeSpent int `json:"timeSpent"` // seconds + Issue *iidRef `json:"issue"` + MergeRequest *iidRef `json:"mergeRequest"` +} + +type iidRef struct { + IID string `json:"iid"` + Title string `json:"title"` +} + +type gqlResponse struct { + Data struct { + Timelogs struct { + Nodes []TimelogNode `json:"nodes"` + } `json:"timelogs"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` +} + +// ── Data model ───────────────────────────────────────────────────────────────── + +type DayEntry struct { + Date time.Time + Hours float64 + IsWeekend bool +} + +type WeekSummary struct { + WeekNum int + Days []DayEntry + TotalHours float64 + ExpectedHours float64 +} + +// ── GitLab API ───────────────────────────────────────────────────────────────── + +func gitlabGraphQLURL() string { + base := os.Getenv(envGitlabURLKey) + if base == "" { + base = defaultGitlabURL + } + return strings.TrimRight(base, "/") + "/api/graphql" +} + +func loadHourlyRate() (float64, error) { + value := os.Getenv(envHourlyRateKey) + if value == "" { + return 0, fmt.Errorf("proměnná prostředí %s není nastavena", envHourlyRateKey) + } + + rate, err := strconv.ParseFloat(value, 64) + if err != nil { + return 0, fmt.Errorf("proměnná prostředí %s má neplatnou hodnotu %q", envHourlyRateKey, value) + } + if rate < 0 { + return 0, fmt.Errorf("proměnná prostředí %s nesmí být záporná", envHourlyRateKey) + } + return rate, nil +} + +func fetchTimelogs(token, username string, startDate, endDate time.Time) ([]TimelogNode, error) { + query := ` +query($startDate: Time!, $endDate: Time!, $username: String!) { + timelogs(startDate: $startDate, endDate: $endDate, username: $username) { + nodes { + spentAt + timeSpent + issue { iid title } + mergeRequest { iid title } + } + } +} +` + variables := map[string]interface{}{ + "startDate": startDate.Format("2006-01-02"), + "endDate": endDate.Format("2006-01-02"), + "username": username, + } + + body, err := doGraphQL(token, query, variables) + if err != nil { + return nil, err + } + + var result gqlResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("unmarshal response: %w", err) + } + if len(result.Errors) > 0 { + msgs := make([]string, len(result.Errors)) + for i, e := range result.Errors { + msgs[i] = e.Message + } + return nil, fmt.Errorf("GraphQL errors: %s", strings.Join(msgs, "; ")) + } + return result.Data.Timelogs.Nodes, nil +} + +func doGraphQL(token, query string, variables map[string]interface{}) ([]byte, error) { + reqBody, err := json.Marshal(gqlRequest{Query: query, Variables: variables}) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + + req, err := http.NewRequest("POST", gitlabGraphQLURL(), bytes.NewBuffer(reqBody)) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("execute request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body)) + } + return body, nil +} + +// ── Build data ───────────────────────────────────────────────────────────────── + +// buildDayMap aggregates hours per date from all nodes (no ID filter). +func buildDayMap(nodes []TimelogNode) map[string]float64 { + dayMap := make(map[string]float64) + for _, n := range nodes { + date := dateKey(n.SpentAt) + dayMap[date] += float64(n.TimeSpent) / 3600.0 + } + return dayMap +} + +// buildDayMapForID aggregates hours per date only for nodes matching the given IID. +func buildDayMapForID(nodes []TimelogNode, id int) (map[string]float64, string) { + idStr := fmt.Sprintf("%d", id) + dayMap := make(map[string]float64) + title := "" + for _, n := range nodes { + matched := false + if n.Issue != nil && n.Issue.IID == idStr { + matched = true + if title == "" { + title = fmt.Sprintf("Issue #%d: %s", id, n.Issue.Title) + } + } + if n.MergeRequest != nil && n.MergeRequest.IID == idStr { + matched = true + if title == "" { + title = fmt.Sprintf("MR !%d: %s", id, n.MergeRequest.Title) + } + } + if matched { + date := dateKey(n.SpentAt) + dayMap[date] += float64(n.TimeSpent) / 3600.0 + } + } + return dayMap, title +} + +func dateKey(spentAt string) string { + if len(spentAt) > 10 { + return spentAt[:10] + } + return spentAt +} + +func buildWeeks(year, month int, dayMap map[string]float64) []WeekSummary { + firstDay := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.Local) + lastDay := firstDay.AddDate(0, 1, -1) + + var weeks []WeekSummary + weekNum := 1 + var cur *WeekSummary + + for d := firstDay; !d.After(lastDay); d = d.AddDate(0, 0, 1) { + isWeekend := d.Weekday() == time.Saturday || d.Weekday() == time.Sunday + + if d.Weekday() == time.Monday || cur == nil { + if cur != nil { + weeks = append(weeks, *cur) + } + cur = &WeekSummary{WeekNum: weekNum} + weekNum++ + } + + key := d.Format("2006-01-02") + entry := DayEntry{Date: d, Hours: dayMap[key], IsWeekend: isWeekend} + cur.Days = append(cur.Days, entry) + cur.TotalHours += entry.Hours + if !isWeekend { + cur.ExpectedHours += hoursPerDay + } + } + if cur != nil { + weeks = append(weeks, *cur) + } + return weeks +} + +// buildIDEntries collects only days that have hours logged for the filtered ID. +func buildIDEntries(year, month int, dayMap map[string]float64) []DayEntry { + firstDay := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.Local) + lastDay := firstDay.AddDate(0, 1, -1) + + var entries []DayEntry + for d := firstDay; !d.After(lastDay); d = d.AddDate(0, 0, 1) { + key := d.Format("2006-01-02") + if h := dayMap[key]; h > 0 { + entries = append(entries, DayEntry{ + Date: d, + Hours: h, + IsWeekend: d.Weekday() == time.Saturday || d.Weekday() == time.Sunday, + }) + } + } + return entries +} + +// ── Table helpers ────────────────────────────────────────────────────────────── + +type col struct { + header string + width int +} + +func colorStr(s, color string) string { return color + s + colorReset } + +func cell(s, plain string, width int, right bool) string { + pad := width - utf8.RuneCountInString(plain) + if pad < 0 { + pad = 0 + } + if right { + return " " + strings.Repeat(" ", pad) + s + " " + } + return " " + s + strings.Repeat(" ", pad) + " " +} + +func printRow(cols []col, cells []string) { + fmt.Print("|") + for i, c := range cells { + fmt.Print(c) + _ = cols[i] + fmt.Print("|") + } + fmt.Println() +} + +func tableWidth(cols []col) int { + w := 1 + for _, c := range cols { + w += c.width + 3 + } + return w +} + +func sep(cols []col) { + fmt.Println("+" + strings.Repeat("-", tableWidth(cols)-2) + "+") +} + +func doubleSep(cols []col) { + fmt.Println("+" + strings.Repeat("=", tableWidth(cols)-2) + "+") +} + +func printHeaders(cols []col) { + fmt.Print("|") + for _, c := range cols { + plain := c.header + colored := colorStr(plain, colorBold) + pad := c.width - utf8.RuneCountInString(plain) + if pad < 0 { + pad = 0 + } + fmt.Printf(" %s%s |", colored, strings.Repeat(" ", pad)) + } + fmt.Println() +} + +func fmtHours(h float64) string { return fmt.Sprintf("%.2f h", h) } +func fmtAmount(a float64) string { return fmt.Sprintf("%.0f Kč", a) } + +func fmtDiff(diff float64) (plain, colored string) { + plain = fmt.Sprintf("%+.2f h", diff) + if diff < 0 { + return plain, colorStr(plain, colorRed) + } + return plain, colorStr(plain, colorGreen) +} + +func czechDay(w time.Weekday) string { + return map[time.Weekday]string{ + time.Monday: "Po", time.Tuesday: "Út", time.Wednesday: "St", + time.Thursday: "Čt", time.Friday: "Pá", time.Saturday: "So", time.Sunday: "Ne", + }[w] +} + +func czechMonth(m time.Month) string { + return map[time.Month]string{ + time.January: "Leden", time.February: "Únor", time.March: "Březen", + time.April: "Duben", time.May: "Květen", time.June: "Červen", + time.July: "Červenec", time.August: "Srpen", time.September: "Září", + time.October: "Říjen", time.November: "Listopad", time.December: "Prosinec", + }[m] +} + +func weekHeader(cols []col, weekNum int) { + tw := tableWidth(cols) + label := fmt.Sprintf(" Týden %d ", weekNum) + rest := tw - 2 - len(label) + left := rest / 2 + right := rest - left + fmt.Println(colorStr("+"+strings.Repeat("-", left)+label+strings.Repeat("-", right)+"+", colorBold+colorCyan)) +} + +// ── Full month table ─────────────────────────────────────────────────────────── + +var fullCols = []col{ + {"Datum", 13}, + {"Den", 4}, + {"Odpracováno", 13}, + {"Rozdíl", 9}, + {"Částka", 13}, +} + +func printFullTable(weeks []WeekSummary, year, month int, hourlyRate float64) { + cols := fullCols + fmt.Println() + title := fmt.Sprintf(" GitLab Timesheet — %s %d ", czechMonth(time.Month(month)), year) + fmt.Println(colorStr(colorBold+title, colorBold+colorCyan)) + fmt.Printf(" Hodinová sazba: %.0f Kč/h | Norma: %.0f h/den\n\n", hourlyRate, hoursPerDay) + + var totalHours, totalExpected, totalAmount float64 + + for _, week := range weeks { + weekHeader(cols, week.WeekNum) + printHeaders(cols) + sep(cols) + + for _, day := range week.Days { + printFullDayRow(cols, day, hourlyRate) + } + + // week summary + diff := week.TotalHours - week.ExpectedHours + amount := week.TotalHours * hourlyRate + diffPlain, _ := fmtDiff(diff) + var diffColored string + if diff < 0 { + diffColored = colorStr(diffPlain, colorBold+colorRed) + } else { + diffColored = colorStr(diffPlain, colorBold+colorGreen) + } + label := "Souhrn týdne" + sep(cols) + printRow(cols, []string{ + cell(colorStr(label, colorBold), label, cols[0].width+cols[1].width+3, false), + cell(colorStr(fmtHours(week.TotalHours), colorBold), fmtHours(week.TotalHours), cols[2].width, true), + cell(diffColored, diffPlain, cols[3].width, true), + cell(colorStr(fmtAmount(amount), colorBold), fmtAmount(amount), cols[4].width, true), + }) + sep(cols) + fmt.Println() + + totalHours += week.TotalHours + totalExpected += week.ExpectedHours + totalAmount += amount + } + + // month summary + diff := totalHours - totalExpected + diffPlain, _ := fmtDiff(diff) + var diffColored string + if diff < 0 { + diffColored = colorStr(diffPlain, colorBold+colorRed) + } else { + diffColored = colorStr(diffPlain, colorBold+colorGreen) + } + label := "SOUHRN MĚSÍCE" + doubleSep(cols) + printRow(cols, []string{ + cell(colorStr(label, colorBold+colorCyan), label, cols[0].width+cols[1].width+3, false), + cell(colorStr(fmtHours(totalHours), colorBold+colorCyan), fmtHours(totalHours), cols[2].width, true), + cell(diffColored, diffPlain, cols[3].width, true), + cell(colorStr(fmtAmount(totalAmount), colorBold+colorCyan), fmtAmount(totalAmount), cols[4].width, true), + }) + doubleSep(cols) + fmt.Println() +} + +func printFullDayRow(cols []col, day DayEntry, hourlyRate float64) { + dateStr := day.Date.Format("2006-01-02") + dayName := czechDay(day.Date.Weekday()) + + if day.IsWeekend { + c := colorYellow + hoursPlain := fmtHours(day.Hours) + printRow(cols, []string{ + cell(colorStr(dateStr, c), dateStr, cols[0].width, false), + cell(colorStr(dayName, c), dayName, cols[1].width, false), + cell(colorStr(hoursPlain, c), hoursPlain, cols[2].width, true), + cell(colorStr(" - ", c), " - ", cols[3].width, true), + cell(colorStr("-", c), "-", cols[4].width, true), + }) + return + } + + diff := day.Hours - hoursPerDay + amount := day.Hours * hourlyRate + diffPlain, diffColored := fmtDiff(diff) + + hoursColor := colorGreen + amountColor := colorGreen + if day.Hours == 0 { + hoursColor = colorRed + amountColor = colorRed + } + hoursPlain := fmtHours(day.Hours) + amountPlain := fmtAmount(amount) + + printRow(cols, []string{ + cell(colorStr(dateStr, colorGreen), dateStr, cols[0].width, false), + cell(colorStr(dayName, colorGreen), dayName, cols[1].width, false), + cell(colorStr(hoursPlain, hoursColor), hoursPlain, cols[2].width, true), + cell(diffColored, diffPlain, cols[3].width, true), + cell(colorStr(amountPlain, amountColor), amountPlain, cols[4].width, true), + }) +} + +// ── Filtered by ID table ─────────────────────────────────────────────────────── + +var idCols = []col{ + {"Datum", 13}, + {"Den", 4}, + {"Hodiny", 10}, + {"Částka", 13}, +} + +func printIDTable(entries []DayEntry, year, month int, idTitle string, hourlyRate float64) { + cols := idCols + fmt.Println() + title := fmt.Sprintf(" GitLab Timesheet — %s %d ", czechMonth(time.Month(month)), year) + fmt.Println(colorStr(colorBold+title, colorBold+colorCyan)) + fmt.Println(colorStr(" "+idTitle, colorBold)) + fmt.Printf(" Hodinová sazba: %.0f Kč/h\n\n", hourlyRate) + + if len(entries) == 0 { + fmt.Println(" Žádné záznamy pro toto ID v daném měsíci.") + fmt.Println() + return + } + + doubleSep(cols) + printHeaders(cols) + doubleSep(cols) + + var totalHours float64 + for _, day := range entries { + hoursPlain := fmtHours(day.Hours) + amountPlain := fmtAmount(day.Hours * hourlyRate) + dateStr := day.Date.Format("2006-01-02") + dayName := czechDay(day.Date.Weekday()) + c := colorGreen + if day.IsWeekend { + c = colorYellow + } + printRow(cols, []string{ + cell(colorStr(dateStr, c), dateStr, cols[0].width, false), + cell(colorStr(dayName, c), dayName, cols[1].width, false), + cell(colorStr(hoursPlain, c), hoursPlain, cols[2].width, true), + cell(colorStr(amountPlain, c), amountPlain, cols[3].width, true), + }) + totalHours += day.Hours + } + + totalAmount := totalHours * hourlyRate + label := "Celkem" + doubleSep(cols) + printRow(cols, []string{ + cell(colorStr(label, colorBold+colorCyan), label, cols[0].width+cols[1].width+3, false), + cell(colorStr(fmtHours(totalHours), colorBold+colorCyan), fmtHours(totalHours), cols[2].width, true), + cell(colorStr(fmtAmount(totalAmount), colorBold+colorCyan), fmtAmount(totalAmount), cols[3].width, true), + }) + doubleSep(cols) + fmt.Println() +} + +// ── CLI ──────────────────────────────────────────────────────────────────────── + +func main() { + var ( + flagMonth = currentYearMonth() + flagID int + ) + + root := &cobra.Command{ + Use: "timesheet", + Short: "GitLab timesheet viewer", + Long: `Zobrazí timesheet z GitLab API ve formátu ASCII tabulky. + +Proměnné prostředí: + GITLAB_TOKEN GitLab Personal Access Token (povinný, scope: read_api) + GITLAB_USERNAME GitLab username (povinný) + GITLAB_URL GitLab URL (volitelný, výchozí: https://gitlab.com) + HOURLY_RATE Hodinová sazba v Kč/h (povinný)`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + token := os.Getenv(envTokenKey) + if token == "" { + return fmt.Errorf("proměnná prostředí %s není nastavena", envTokenKey) + } + username := os.Getenv(envUsernameKey) + if username == "" { + return fmt.Errorf("proměnná prostředí %s není nastavena", envUsernameKey) + } + hourlyRate, err := loadHourlyRate() + if err != nil { + return err + } + + year, month, err := parseYearMonth(flagMonth) + if err != nil { + return err + } + + startDate := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.Local) + endDate := startDate.AddDate(0, 1, -1) + + fmt.Printf("Načítám timesheety pro %s (%s – %s) [%s]...\n", + username, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), gitlabGraphQLURL()) + + nodes, err := fetchTimelogs(token, username, startDate, endDate) + if err != nil { + return fmt.Errorf("chyba při načítání dat: %w", err) + } + + if flagID != 0 { + dayMap, idTitle := buildDayMapForID(nodes, flagID) + if idTitle == "" { + idTitle = fmt.Sprintf("ID #%d (nenalezeno v timelozích)", flagID) + } + entries := buildIDEntries(year, month, dayMap) + printIDTable(entries, year, month, idTitle, hourlyRate) + } else { + dayMap := buildDayMap(nodes) + weeks := buildWeeks(year, month, dayMap) + printFullTable(weeks, year, month, hourlyRate) + } + return nil + }, + } + + root.Flags().StringVarP(&flagMonth, "month", "m", flagMonth, "Měsíc ve formátu YYYY-MM") + root.Flags().IntVarP(&flagID, "id", "i", 0, "Filtrovat podle čísla issue nebo MR (IID)") + + if err := root.Execute(); err != nil { + os.Exit(1) + } +} + +func currentYearMonth() string { + return time.Now().Format("2006-01") +} + +func parseYearMonth(s string) (year, month int, err error) { + var t time.Time + t, err = time.Parse("2006-01", s) + if err != nil { + return 0, 0, fmt.Errorf("chybný formát měsíce %q (očekáváno YYYY-MM)", s) + } + return t.Year(), int(t.Month()), nil +}