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