initial commit

This commit is contained in:
2026-04-02 13:25:24 +02:00
commit e5119ec6d4
4 changed files with 704 additions and 0 deletions

59
README.md Normal file
View File

@@ -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á.

10
go.mod Normal file
View File

@@ -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
)

10
go.sum Normal file
View File

@@ -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=

625
main.go Normal file
View File

@@ -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
}