Files
gitlab-time-calculator/main.go
2026-04-02 13:25:27 +02:00

626 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}