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 }