diff --git a/docs/changelog.md b/docs/changelog.md index 44b6ba7..8b48ed6 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -6,18 +6,18 @@ - Add support GitHub Access Token by parameter -You could still provide it by the environment variable + You could still provide it by the environment variable -```bash -$ export GITHUB_TOKEN=secret -$ maintainer github ... -``` + ```bash + $ export GITHUB_TOKEN=secret + $ maintainer github ... + ``` -But now, you also could choose the parameter for its provisioning + But now, you also could choose the parameter for its provisioning -```bash -$ maintainer github --token=secret ... -``` + ```bash + $ maintainer github --token=secret ... + ``` - Add commands to work with GitHub Contributions Calendar @@ -61,7 +61,7 @@ $ maintainer github --token=secret ... * Makes a snapshot of contributions for a specified year ```bash - $ maintainer github contribution snapshot 2013 | tee snap.2013.json | jq + $ maintainer github contribution snapshot 2013 | tee /tmp/snap.01.2013.json | jq { "2013-11-13T00:00:00Z": 1, ... diff --git a/docs/readme.md b/docs/readme.md index dcc0aa4..bab2d35 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -6,18 +6,18 @@ - Add support GitHub Access Token by parameter -You could still provide it by the environment variable + You could still provide it by the environment variable -```bash -$ export GITHUB_TOKEN=secret -$ maintainer github ... -``` + ```bash + $ export GITHUB_TOKEN=secret + $ maintainer github ... + ``` -But now, you also could choose the parameter for its provisioning + But now, you also could choose the parameter for its provisioning -```bash -$ maintainer github --token=secret ... -``` + ```bash + $ maintainer github --token=secret ... + ``` - Add commands to work with GitHub Contributions Calendar diff --git a/internal/command/github/contribution.go b/internal/command/github/contribution.go index 827871d..18c6b40 100644 --- a/internal/command/github/contribution.go +++ b/internal/command/github/contribution.go @@ -11,6 +11,7 @@ import ( "go.octolab.org/toolset/maintainer/internal/command/github/view" "go.octolab.org/toolset/maintainer/internal/config" "go.octolab.org/toolset/maintainer/internal/model/github/contribution" + "go.octolab.org/toolset/maintainer/internal/pkg/config/flag" "go.octolab.org/toolset/maintainer/internal/pkg/http" "go.octolab.org/toolset/maintainer/internal/pkg/time" "go.octolab.org/toolset/maintainer/internal/service/github" @@ -21,6 +22,103 @@ func Contribution(cnf *config.Tool) *cobra.Command { Use: "contribution", } + // + // $ maintainer github contribution diff --base=/tmp/snap.01.2013.json --head=/tmp/snap.02.2013.json + // + // Day / Week #46 #48 #49 #50 + // ---------------------- --------------- --------------- --------------- ----------- + // Sunday - - - - + // Monday - - - - + // Tuesday - - - - + // Wednesday +4 - +1 - + // Thursday - - - +1 + // Friday - +2 - - + // Saturday - - - - + // ---------------------- --------------- --------------- --------------- ----------- + // The diff between head{"/tmp/snap.02.2013.json"} → base{"/tmp/snap.01.2013.json"} + // + // $ maintainer github contribution diff --base=/tmp/snap.01.2013.json 2013 + // + diff := cobra.Command{ + Use: "diff", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // dependencies and defaults + service := github.New(http.TokenSourcedClient(cmd.Context(), cnf.Token)) + date := time.TruncateToYear(time.Now().UTC()) + + // input validation: files{params}, date(year){args} + var baseSource, headSource string + dst, err := flag.Adopt(cmd.Flags()).GetFile("base") + if err != nil { + return err + } + if dst == nil { + return fmt.Errorf("please provide a base file by `--base` parameter") + } + baseSource = dst.Name() + + src, err := flag.Adopt(cmd.Flags()).GetFile("head") + if err != nil { + return err + } + if src == nil && len(args) == 0 { + return fmt.Errorf("please provide a compared file by `--head` parameter or year in args") + } + if src != nil && len(args) > 0 { + return fmt.Errorf("please omit `--head` or argument, only one of them is allowed") + } + if len(args) == 1 { + var err error + wrap := func(err error) error { + return fmt.Errorf( + "please provide argument in format YYYY, e.g., 2006: %w", + fmt.Errorf("invalid argument %q: %w", args[0], err), + ) + } + + switch input := args[0]; len(input) { + case len(time.RFC3339Year): + date, err = time.Parse(time.RFC3339Year, input) + default: + err = fmt.Errorf("unsupported format") + } + if err != nil { + return wrap(err) + } + headSource = fmt.Sprintf("upstream:year(%s)", date.Format(time.RFC3339Year)) + } else { + headSource = src.Name() + } + + // data provisioning + var ( + base contribution.HeatMap + head contribution.HeatMap + ) + if err := json.NewDecoder(dst).Decode(&base); err != nil { + return err + } + if src != nil { + if err := json.NewDecoder(src).Decode(&head); err != nil { + return err + } + } else { + scope := time.RangeByYears(date, 0, false).ExcludeFuture() + head, err = service.ContributionHeatMap(cmd.Context(), scope) + if err != nil { + return err + } + } + + // data presentation + return view.Diff(cmd, base.Diff(head), baseSource, headSource) + }, + } + flag.Adopt(diff.Flags()).File("base", "", "path to a base file") + flag.Adopt(diff.Flags()).File("head", "", "path to a head file") + cmd.AddCommand(&diff) + // // $ maintainer github contribution histogram 2013 // @@ -163,6 +261,57 @@ func Contribution(cnf *config.Tool) *cobra.Command { } cmd.AddCommand(&lookup) + // + // $ maintainer github contribution snapshot 2013 | tee /tmp/snap.01.2013.json | jq + // + // { + // "2013-11-13T00:00:00Z": 1, + // ... + // "2013-12-27T00:00:00Z": 2 + // } + // + snapshot := cobra.Command{ + Use: "snapshot", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // dependencies and defaults + service := github.New(http.TokenSourcedClient(cmd.Context(), cnf.Token)) + date := time.TruncateToYear(time.Now().UTC()) + + // input validation: date(year) + if len(args) == 1 { + var err error + wrap := func(err error) error { + return fmt.Errorf( + "please provide argument in format YYYY, e.g., 2006: %w", + fmt.Errorf("invalid argument %q: %w", args[0], err), + ) + } + + switch input := args[0]; len(input) { + case len(time.RFC3339Year): + date, err = time.Parse(time.RFC3339Year, input) + default: + err = fmt.Errorf("unsupported format") + } + if err != nil { + return wrap(err) + } + } + + // data provisioning + scope := time.RangeByYears(date, 0, false).ExcludeFuture() + chm, err := service.ContributionHeatMap(cmd.Context(), scope) + if err != nil { + return err + } + + // data presentation + return json.NewEncoder(cmd.OutOrStdout()).Encode(chm) + }, + } + cmd.AddCommand(&snapshot) + // // $ maintainer github contribution suggest 2013-11-20 // @@ -277,47 +426,5 @@ func Contribution(cnf *config.Tool) *cobra.Command { } cmd.AddCommand(&suggest) - snapshot := cobra.Command{ - Use: "snapshot", - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - // dependencies and defaults - service := github.New(http.TokenSourcedClient(cmd.Context(), cnf.Token)) - date := time.TruncateToYear(time.Now().UTC()) - - // input validation: date(year) - if len(args) == 1 { - var err error - wrap := func(err error) error { - return fmt.Errorf( - "please provide argument in format YYYY, e.g., 2006: %w", - fmt.Errorf("invalid argument %q: %w", args[0], err), - ) - } - - switch input := args[0]; len(input) { - case len(time.RFC3339Year): - date, err = time.Parse(time.RFC3339Year, input) - default: - err = fmt.Errorf("unsupported format") - } - if err != nil { - return wrap(err) - } - } - - // data provisioning - scope := time.RangeByYears(date, 0, false).ExcludeFuture() - chm, err := service.ContributionHeatMap(cmd.Context(), scope) - if err != nil { - return err - } - - // data presentation - return json.NewEncoder(cmd.OutOrStdout()).Encode(chm) - }, - } - cmd.AddCommand(&snapshot) - return &cmd } diff --git a/internal/command/github/view/diff.go b/internal/command/github/view/diff.go new file mode 100644 index 0000000..8c19d61 --- /dev/null +++ b/internal/command/github/view/diff.go @@ -0,0 +1,87 @@ +package view + +import ( + "fmt" + "github.com/alexeyco/simpletable" + + "go.octolab.org/toolset/maintainer/internal/model/github/contribution" + "go.octolab.org/toolset/maintainer/internal/pkg/time" +) + +func Diff( + printer interface{ Println(...interface{}) }, + heatmap contribution.HeatMap, + base, head string, +) error { + data := prepare(heatmap) + table := simpletable.New() + + if len(data) == 0 { + printer.Println(fmt.Sprintf("There is no diff between head{%q} → base{%q}", head, base)) + return nil + } + + table.Header = &simpletable.Header{ + Cells: []*simpletable.Cell{ + {Align: simpletable.AlignLeft, Text: "Day / Week"}, + }, + } + for i, week := range data { + if shiftIsNeeded(i, week.Report) { + continue + } + table.Header.Cells = append(table.Header.Cells, &simpletable.Cell{ + Align: simpletable.AlignCenter, + Text: fmt.Sprintf("#%d", week.Number), + }) + } + + // shift Sunday to the right + row := make([]*simpletable.Cell, 0, 4) + row = append(row, &simpletable.Cell{Text: time.Sunday.String()}) + for i := range data { + if shiftIsNeeded(i, data[i].Report) { + continue + } + // TODO:unclear explain + if i == 0 { + row = append(row, &simpletable.Cell{Align: simpletable.AlignCenter, Text: "-"}) + continue + } + txt := "-" + if count := data[i-1].Report[time.Sunday]; count != 0 { + txt = fmt.Sprintf("%+d", count) + } + row = append(row, &simpletable.Cell{Align: simpletable.AlignCenter, Text: txt}) + } + table.Body.Cells = append(table.Body.Cells, row) + + for i := time.Monday; i <= time.Saturday; i++ { + row = make([]*simpletable.Cell, 0, 4) + row = append(row, &simpletable.Cell{Text: i.String()}) + for j, week := range data { + if shiftIsNeeded(j, week.Report) { + continue + } + txt := "-" + if count := week.Report[i]; count != 0 { + txt = fmt.Sprintf("%+d", count) + } + row = append(row, &simpletable.Cell{Align: simpletable.AlignCenter, Text: txt}) + } + table.Body.Cells = append(table.Body.Cells, row) + } + + table.Footer = &simpletable.Footer{ + Cells: []*simpletable.Cell{ + { + Span: len(table.Header.Cells), + Text: fmt.Sprintf("The diff between head{%q} → base{%q}", head, base), + }, + }, + } + + table.SetStyle(simpletable.StyleCompactLite) + printer.Println(table.String()) + return nil +} diff --git a/internal/command/github/view/helpers.go b/internal/command/github/view/helpers.go index 0ee990b..7d37486 100644 --- a/internal/command/github/view/helpers.go +++ b/internal/command/github/view/helpers.go @@ -5,6 +5,11 @@ import ( "go.octolab.org/toolset/maintainer/internal/pkg/time" ) +type WeekReport struct { + Number int + Report map[time.Weekday]int +} + func convert( scope time.Range, histogram []contribution.HistogramByWeekdayRow, @@ -37,3 +42,38 @@ func convert( } return report } + +func prepare(heatmap contribution.HeatMap) []WeekReport { + report := make([]WeekReport, 0, 8) + + start := time.TruncateToWeek(heatmap.From()) + for week, end := start, heatmap.To(); week.Before(end); week = week.Add(time.Week) { + subset := heatmap.Subset(time.RangeByWeeks(week, 0, false).Shift(-time.Day)) + if len(subset) == 0 { + continue + } + + _, num := week.ISOWeek() + row := WeekReport{ + Number: num, + Report: make(map[time.Weekday]int, len(subset)), + } + for ts, count := range subset { + row.Report[ts.Weekday()] = count + } + report = append(report, row) + } + + return report +} + +// If it's a first-week report with a single entry for Sunday, +// we skip it completely. +// +// It's because GitHub shows the contribution chart started on Sunday +// of the previous week. For that reason we have to shift it to the right +// and compensate `.Shift(-time.Day)` call for the scope. +func shiftIsNeeded(idx int, report map[time.Weekday]int) bool { + _, is := report[time.Sunday] + return idx == 0 && len(report) == 1 && is +} diff --git a/internal/command/github/view/lookup.go b/internal/command/github/view/lookup.go index 0e3e41d..3d138ce 100644 --- a/internal/command/github/view/lookup.go +++ b/internal/command/github/view/lookup.go @@ -10,11 +10,6 @@ import ( "go.octolab.org/toolset/maintainer/internal/pkg/time" ) -type WeekReport struct { - Number int - Report map[time.Weekday]int -} - func Lookup( printer interface{ Println(...interface{}) }, scope time.Range, @@ -45,6 +40,7 @@ func Lookup( if shiftIsNeeded(i, data[i].Report) { continue } + // TODO:unclear explain if i == 0 { row = append(row, &simpletable.Cell{Align: simpletable.AlignCenter, Text: "?"}) continue @@ -95,14 +91,3 @@ func Lookup( printer.Println(table.String()) return nil } - -// If it's a first-week report with a single entry for Sunday, -// we skip it completely. -// -// It's because GitHub shows the contribution chart started on Sunday -// of the previous week. For that reason we have to shift it to the right -// and compensate `.Shift(-time.Day)` call for the scope. -func shiftIsNeeded(idx int, report map[time.Weekday]int) bool { - _, is := report[time.Sunday] - return idx == 0 && len(report) == 1 && is -} diff --git a/internal/command/github/view/suggest.go b/internal/command/github/view/suggest.go index 322b0fe..af8a87f 100644 --- a/internal/command/github/view/suggest.go +++ b/internal/command/github/view/suggest.go @@ -10,6 +10,9 @@ import ( "go.octolab.org/toolset/maintainer/internal/pkg/time" ) +// TODO:refactoring combine with Lookup, use HeatMap as input +// TODO:refactoring extract "table builder", compare with others views + func Suggest( printer interface{ Println(...interface{}) }, scope time.Range, @@ -42,6 +45,7 @@ func Suggest( if shiftIsNeeded(i, data[i].Report) { continue } + // TODO:unclear explain if i == 0 { row = append(row, &simpletable.Cell{Align: simpletable.AlignCenter, Text: "?"}) continue diff --git a/internal/model/github/contribution/heatmap.go b/internal/model/github/contribution/heatmap.go index ae2591d..9eda43b 100644 --- a/internal/model/github/contribution/heatmap.go +++ b/internal/model/github/contribution/heatmap.go @@ -32,7 +32,27 @@ func (chm HeatMap) Subset(scope time.Range) HeatMap { return subset } -// From returns minimum time of the heat map, otherwise the zero time instant. +// Diff calculates the difference between two heatmaps. +func (chm HeatMap) Diff(src HeatMap) HeatMap { + diff := make(HeatMap) + + keys := make(map[time.Time]struct{}, len(chm)+len(src)) + for ts := range chm { + keys[ts] = struct{}{} + } + for ts := range src { + keys[ts] = struct{}{} + } + for ts := range keys { + if delta := src[ts] - chm[ts]; delta != 0 { + diff[ts] = delta + } + } + + return diff +} + +// From returns minimum time of the heatmap, otherwise the zero time instant. func (chm HeatMap) From() time.Time { var min time.Time for ts := range chm { @@ -43,7 +63,7 @@ func (chm HeatMap) From() time.Time { return min } -// To returns maximum time of the heat map, otherwise the zero time instant. +// To returns maximum time of the heatmap, otherwise the zero time instant. func (chm HeatMap) To() time.Time { var max time.Time for ts := range chm { @@ -54,7 +74,7 @@ func (chm HeatMap) To() time.Time { return max } -// Range returns time range of the heat map, otherwise the zero time range instant. +// Range returns time range of the heatmap, otherwise the zero time range instant. func (chm HeatMap) Range() time.Range { return time.NewRange(chm.From(), chm.To()) } diff --git a/internal/pkg/config/flag/file.go b/internal/pkg/config/flag/file.go new file mode 100644 index 0000000..598d9f4 --- /dev/null +++ b/internal/pkg/config/flag/file.go @@ -0,0 +1,82 @@ +package flag + +import ( + "fmt" + "os" + + "github.com/spf13/pflag" +) + +const fileType = "file" + +func (f *Set) GetFile(name string) (*os.File, error) { + flag := (*pflag.FlagSet)(f).Lookup(name) + if flag == nil { + return nil, fmt.Errorf("flag accessed but not defined: %s", name) + } + + switch val := flag.Value.(type) { + case *File: + if val.lazy == "" { + return val.file, nil + } + if err := val.Set(val.lazy); err != nil { + return nil, err + } + return val.file, nil + default: + return nil, fmt.Errorf("trying to get %s value of flag of type %s", fileType, flag.Value.Type()) + } +} + +func (f *Set) File(name, value, usage string) *File { + p := new(File) + f.FileVarP(p, name, "", File{lazy: value}, usage) + return p +} + +func (f *Set) FileP(name, shorthand, value, usage string) *File { + p := new(File) + f.FileVarP(p, name, shorthand, File{lazy: value}, usage) + return p +} + +func (f *Set) FileVarP(p *File, name, shorthand string, value File, usage string) { + (*pflag.FlagSet)(f).VarP(newFileValue(value, p), name, shorthand, usage) +} + +func newFileValue(value File, p *File) *File { + *p = value + return p +} + +type File struct { + file *os.File + lazy string +} + +func (val *File) String() string { + if val.file == nil { + return val.lazy + } + return val.file.Name() +} + +func (val *File) Set(name string) error { + if val.file != nil { + if err := val.file.Close(); err != nil { + return err + } + } + file, err := os.Open(name) + if err != nil { + return err + } + val.file = file + val.lazy = "" + return nil +} + +func (val *File) Type() string { + return fileType +} diff --git a/internal/pkg/config/flag/type.go b/internal/pkg/config/flag/type.go new file mode 100644 index 0000000..1aa13c0 --- /dev/null +++ b/internal/pkg/config/flag/type.go @@ -0,0 +1,7 @@ +package flag + +import "github.com/spf13/pflag" + +type Set pflag.FlagSet + +func Adopt(set *pflag.FlagSet) *Set { return (*Set)(set) }