initial commit
This commit is contained in:
59
README.md
Normal file
59
README.md
Normal 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
10
go.mod
Normal 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
10
go.sum
Normal 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
625
main.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user