626 lines
18 KiB
Go
626 lines
18 KiB
Go
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
|
||
}
|