diff --git a/README.md b/README.md index 9878a35..cbfd1d1 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,18 @@ [![MacUnitTest](https://github.com/nao1215/leadtime/actions/workflows/mac_test.yml/badge.svg)](https://github.com/nao1215/leadtime/actions/workflows/mac_test.yml) [![WindowsUnitTest](https://github.com/nao1215/leadtime/actions/workflows/windows_test.yml/badge.svg)](https://github.com/nao1215/leadtime/actions/workflows/windows_test.yml) # leadtime - calculate PR lead time statistics on GitHub +``` +|------------- lead time -------------| +| |--- time to merge ---| +--------------------------------------- +^ ^ ^ +first commit create PR merge PR +``` leedtime is a command that outputs statistics about the time it takes for a GitHub Pull Request to be merged. The leadtime command was developed under the influence of the following books. - Eng: [Accelerate: The Science of Lean Software and DevOps: Building and Scaling High Performing Technology Organizations](https://www.amazon.com/dp/1942788339/ref=cm_sw_r_cp_ep_dp_sBN8BbGC11MBS) - JP: [LeanとDevOpsの科学[Accelerate]](https://www.amazon.co.jp/Lean%E3%81%A8DevOps%E3%81%AE%E7%A7%91%E5%AD%A6%EF%BC%BBAccelerate%EF%BC%BD-%E3%83%86%E3%82%AF%E3%83%8E%E3%83%AD%E3%82%B8%E3%83%BC%E3%81%AE%E6%88%A6%E7%95%A5%E7%9A%84%E6%B4%BB%E7%94%A8%E3%81%8C%E7%B5%84%E7%B9%94%E5%A4%89%E9%9D%A9%E3%82%92%E5%8A%A0%E9%80%9F%E3%81%99%E3%82%8B-impress-top-gear%E3%82%B7%E3%83%AA%E3%83%BC%E3%82%BA-ebook/dp/B07L2R3LTN) -The motivation for developing the leadtime command is to measure lead time for changes. I used unit test coverage as a measure of software quality. However, as the number of unit tests increased but the code was not rewritten, I questioned whether the quality was improving. +The motivation for developing the leadtime command is to measure lead time for changes. I previously used unit test coverage as a measure of software quality. However, I felt that unless we wrote creative tests and rewrote the code, test coverage would increase but quality would not improve. Therefore, I considered measuring lead time, one of the indicators presented in the above book. @@ -37,10 +44,12 @@ Examples: LT_GITHUB_ACCESS_TOKEN=XXX leadtime stat --owner=nao1215 --repo=sqly Flags: + -a, --all Print all data used for statistics -B, --exclude-bot Exclude Pull Requests created by bots -P, --exclude-pr ints Exclude specified Pull Requests (e.g. '-P 1,3,19') -U, --exclude-user strings Exclude Pull Requests created by specified user (e.g. '-U nao,alice') -h, --help help for stat + -j, --json Output json -m, --markdown Output markdown -o, --owner string Specify GitHub owner name -r, --repo string Specify GitHub repository name @@ -50,36 +59,6 @@ Flags: You need to set GitHub access token in environment variable "LT_GITHUB_ACCESS_TOKEN". If you want to check github.com/nao1215/sqly repository, you execute bellow. ``` $ leadtime stat --owner=nao1215 --repo=sqly -PR Author Bot LeadTime[min] Title -#29 dependabot[bot] yes 21144 Bump github.com/fatih/color from 1.13.0 to 1.14.1 -#28 nao1215 no 12 Change golden pacakge import path -#27 nao1215 no 17 add unit test for infra package -#26 nao1215 no 686 Add basic unit test for shell -#25 dependabot[bot] yes 1850 Bump github.com/google/go-cmp from 0.2.0 to 0.5.9 -#24 nao1215 no 6458 Add unit test for model package -#23 nao1215 no 187 Change golden test package from goldie to golden and more -#22 nao1215 no 1 Add sqlite3 syntax completion -#21 nao1215 no 1769 Add unit test for argument paser -#20 nao1215 no 53 Feat dump tsv ltsv json -#19 nao1215 no 6 Add featuer thar print date by markdown table format -#18 nao1215 no 10 Feat import ltsv -#17 nao1215 no 117 Feat import tsv -#15 nao1215 no 57 Fix panic bug when import file that is without extension -#14 nao1215 no 42 Feat import json -#13 nao1215 no 139 Fix input delays when increasing records -#12 nao1215 no 18 Add header command -#11 nao1215 no 1552 Fixed a display collapse problem when multiple lines are entered -#10 nao1215 no 4 Fixed a bug that caused SQL to fail if there was a trailing semicolon -#9 nao1215 no 29 Add move cursor function in intaractive shell -#8 nao1215 no 3 Fixed a bug in which the wrong arguments were used -#7 nao1215 no 76 Added CSV output mode -#6 nao1215 no 222 Improve execute query -#5 nao1215 no 498 Add history usecase, repository, infra. sqly manage history by sqlite3 -#4 nao1215 no 139 Add function that execute select query -#3 nao1215 no 37 Add import command -#2 nao1215 no 57 Add .tables command -#1 nao1215 no 127 Add .exit/.help command and history manager - [statistics] Total PR = 28 Lead Time(Max) = 21144[min] @@ -89,6 +68,76 @@ PR Author Bot LeadTime[min] Title Lead Time(Median) = 66.50[min] ``` + +### json format output +If you change output format to json, you use --json option. +``` +$ leadtime stat --owner=nao1215 --repo=sqly --json | jq . +{ + "total_pr": 28, + "lead_time_maximum": 21144, + "lead_time_minimum": 1, + "lead_time_summation": 35310, + "lead_time_average": 1261.0714285714287, + "lead_time_median": 66.5 +} +``` + +### markdown format output +If you change output format to markdown, you use --markdown option. Markdown output sample is [here](doc/sample_leadtime.md). +``` +$ leadtime stat --owner=nao1215 --repo=gup --markdown +``` + +If you use --markdown, leadtime command output lead time line graph, like this. +![PR Lead Time](./doc/leadtime.png) + +### PR information used in statistics +If you want to check PR information used in statistics, you use --all option. The --all option is available for all output formats (json, markdown, default). +``` +$ leadtime stat --owner=nao1215 --repo=sqly --json --all | jq . +{ + "lead_time_statistics": { + "total_pr": 28, + "lead_time_maximum": 21144, + "lead_time_minimum": 1, + "lead_time_summation": 35310, + "lead_time_average": 1261.0714285714287, + "lead_time_median": 66.5 + }, + "pull_requests": [ + { + "number": 29, + "state": "closed", + "title": "Bump github.com/fatih/color from 1.13.0 to 1.14.1", + "first_commit_at": "2023-01-23T20:21:58Z", + "created_at": "2023-02-07T12:46:37Z", + "closed_at": "2023-02-07T12:46:37Z", + "merged_at": "2023-02-07T12:46:37Z", + "user": { + "name": "dependabot[bot]", + "Bot": true + }, + "merge_time_minutes": 21144 + }, + { + "number": 28, + "state": "closed", + "title": "Change golden pacakge import path", + "first_commit_at": "2022-12-03T09:48:37Z", + "created_at": "2022-12-03T10:01:18Z", + "closed_at": "2022-12-03T10:01:18Z", + "merged_at": "2022-12-03T10:01:18Z", + "user": { + "name": "nao1215", + "Bot": false + }, + "merge_time_minutes": 12 + }, + ~~ + ~~ +``` + ### Exclude PRs - --exclude-bot option: Exclude Pull Requests created by bots ``` @@ -105,18 +154,10 @@ PR Author Bot LeadTime[min] Title leadtime stat --owner=nao1215 --repo=gup --exclude-user=nao,mio ``` -### markdown format output -If you change output format to markdown, you use --markdown option. Markdown output sample is [here](doc/sample_leadtime.md). -``` -$ leadtime stat --owner=nao1215 --repo=gup --markdown -``` - -If you use --markdown, leadtime command output lead time line graph, like this. -![PR Lead Time](./doc/leadtime.png) - ## Features to be added +The leadtime command is targeted to be combined with a GitHub action to be able to look back at statistical data on GitHub. I also plan to make it possible to output the information necessary to shorten leadtime. - [ ] CSV output format -- [ ] JSON output format +- [x] JSON output format - [ ] Markdown file output - [ ] Output to file - [ ] Supports GitHub Actions @@ -132,5 +173,12 @@ First off, thanks for taking the time to contribute! heart Contributions are not If you would like to send comments such as "find a bug" or "request for additional features" to the developer, please use one of the following contacts. - [GitHub Issue](https://github.com/nao1215/leadtime/issues) +## Other project +- [shibayu36/merged-pr-stat](https://github.com/shibayu36/merged-pr-stat) +- [isanasan/dmps](https://github.com/isanasan/dmps) +- [Trendyol/four-key](https://github.com/Trendyol/four-key) +- [hmiyado/four-keys](https://github.com/hmiyado/four-keys) + + ## LICENSE The leadtime project is licensed under the terms of [MIT LICENSE](./LICENSE). \ No newline at end of file diff --git a/cmd/completion.go b/cmd/completion.go new file mode 100644 index 0000000..7904dd7 --- /dev/null +++ b/cmd/completion.go @@ -0,0 +1,256 @@ +package cmd + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/nao1215/gorky/file" + "github.com/spf13/cobra" +) + +func newCompletionCmd() *cobra.Command { + return &cobra.Command{ + Use: "completion", + Short: "Create shell completion files (bash, fish, zsh)", + Long: `Create shell completion files (bash, fish, zsh)`, + RunE: completion, + } +} + +func completion(cmd *cobra.Command, args []string) error { + return deployShellCompletionFileIfNeeded(cmd) +} + +// isWindows check whether runtime is windosw or not. +func isWindows() bool { + return runtime.GOOS == "windows" +} + +// deployShellCompletionFileIfNeeded creates the shell completion file. +// If the file with the same contents already exists, it is not created. +func deployShellCompletionFileIfNeeded(cmd *cobra.Command) error { + if isWindows() { + fmt.Println("not support windows") + return nil + } + makeBashCompletionFileIfNeeded(cmd) + makeFishCompletionFileIfNeeded(cmd) + makeZshCompletionFileIfNeeded(cmd) + + return nil +} + +func makeBashCompletionFileIfNeeded(cmd *cobra.Command) error { + if existSameBashCompletionFile(cmd) { + return nil + } + + path := bashCompletionFilePath() + bashCompletion := new(bytes.Buffer) + if err := cmd.GenBashCompletion(bashCompletion); err != nil { + return fmt.Errorf("can not generate bash completion content: %w", err) + } + + if !file.IsDir(path) { + if err := os.MkdirAll(filepath.Dir(path), 0750); err != nil { + return fmt.Errorf("can not create bash-completion file: %w", err) + } + } + fp, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0600) + if err != nil { + return fmt.Errorf("can not open .bash_completion: %w", err) + } + + if _, err := fp.WriteString(bashCompletion.String()); err != nil { + return fmt.Errorf("can not write .bash_completion %w", err) + } + + if err := fp.Close(); err != nil { + return fmt.Errorf("can not close .bash_completion %w", err) + } + + fmt.Printf("created %s\n", path) + + return nil +} + +func makeFishCompletionFileIfNeeded(cmd *cobra.Command) error { + if isSameFishCompletionFile(cmd) { + return nil + } + + path := fishCompletionFilePath() + if err := os.MkdirAll(filepath.Dir(path), 0750); err != nil { + return fmt.Errorf("can not create fish-completion file: %w", err) + } + + if err := cmd.GenFishCompletionFile(path, false); err != nil { + return fmt.Errorf("can not create fish-completion file: %w", err) + } + + fmt.Printf("created %s\n", path) + + return nil +} + +func makeZshCompletionFileIfNeeded(cmd *cobra.Command) error { + if isSameZshCompletionFile(cmd) { + return nil + } + + path := zshCompletionFilePath() + if err := os.MkdirAll(filepath.Dir(path), 0750); err != nil { + return fmt.Errorf("can not create zsh-completion file: %w", err) + } + + if err := cmd.GenZshCompletionFile(path); err != nil { + return fmt.Errorf("can not create zsh-completion file: %w", err) + } + + if err := appendFpathAtZshrcIfNeeded(); err != nil { + return err + } + + fmt.Printf("created %s\n", path) + + return nil +} + +func appendFpathAtZshrcIfNeeded() error { + const zshFpath = ` +# setting for golling command (auto generate) +fpath=(~/.zsh/completion $fpath) +autoload -Uz compinit && compinit -i +` + zshrcPath := zshrcPath() + if !file.IsFile(zshrcPath) { + fp, err := os.OpenFile(zshrcPath, os.O_RDWR|os.O_CREATE, 0600) + if err != nil { + return fmt.Errorf("can not open .zshrc: %w", err) + } + + if _, err := fp.WriteString(zshFpath); err != nil { + return fmt.Errorf("can not write zsh $fpath in .zshrc: %w", err) + } + + if err := fp.Close(); err != nil { + return fmt.Errorf("can not close .zshrc: %w", err) + } + return nil + } + + zshrc, err := os.ReadFile(zshrcPath) + if err != nil { + return fmt.Errorf("can not read .zshrc: %w", err) + } + + if strings.Contains(string(zshrc), zshFpath) { + return nil + } + + fp, err := os.OpenFile(zshrcPath, os.O_RDWR|os.O_APPEND, 0600) + if err != nil { + return fmt.Errorf("can not open .zshrc: %w", err) + } + + if _, err := fp.WriteString(zshFpath); err != nil { + return fmt.Errorf("can not write zsh $fpath in .zshrc: %w", err) + } + + if err := fp.Close(); err != nil { + return fmt.Errorf("can not close .zshrc: %w", err) + } + return nil +} + +func existSameBashCompletionFile(cmd *cobra.Command) bool { + if !file.IsFile(bashCompletionFilePath()) { + return false + } + return hasSameBashCompletionContent(cmd) +} + +func hasSameBashCompletionContent(cmd *cobra.Command) bool { + bashCompletionFileInLocal, err := os.ReadFile(bashCompletionFilePath()) + if err != nil { + fmt.Printf("can not read .bash_completion: %s\n", err.Error()) + return false + } + + currentBashCompletion := new(bytes.Buffer) + if err := cmd.GenBashCompletion(currentBashCompletion); err != nil { + return false + } + if !strings.Contains(string(bashCompletionFileInLocal), currentBashCompletion.String()) { + return false + } + return true +} + +func isSameFishCompletionFile(cmd *cobra.Command) bool { + path := fishCompletionFilePath() + if !file.IsFile(path) { + return false + } + + currentFishCompletion := new(bytes.Buffer) + if err := cmd.GenFishCompletion(currentFishCompletion, false); err != nil { + return false + } + + fishCompletionInLocal, err := os.ReadFile(path) + if err != nil { + return false + } + + if !bytes.Equal(currentFishCompletion.Bytes(), fishCompletionInLocal) { + return false + } + return true +} + +func isSameZshCompletionFile(cmd *cobra.Command) bool { + path := zshCompletionFilePath() + if !file.IsFile(path) { + return false + } + + currentZshCompletion := new(bytes.Buffer) + if err := cmd.GenZshCompletion(currentZshCompletion); err != nil { + return false + } + + zshCompletionInLocal, err := os.ReadFile(path) + if err != nil { + return false + } + + if !bytes.Equal(currentZshCompletion.Bytes(), zshCompletionInLocal) { + return false + } + return true +} + +// bashCompletionFilePath return bash-completion file path. +func bashCompletionFilePath() string { + return filepath.Join(os.Getenv("HOME"), ".bash_completion.d", Name) +} + +// fishCompletionFilePath return fish-completion file path. +func fishCompletionFilePath() string { + return filepath.Join(os.Getenv("HOME"), ".config", "fish", "completions", Name+".fish") +} + +// zshCompletionFilePath return zsh-completion file path. +func zshCompletionFilePath() string { + return filepath.Join(os.Getenv("HOME"), ".zsh", "completion", "_"+Name) +} + +// zshrcPath return .zshrc path. +func zshrcPath() string { + return filepath.Join(os.Getenv("HOME"), ".zshrc") +} diff --git a/cmd/errors.go b/cmd/errors.go new file mode 100644 index 0000000..cca7fde --- /dev/null +++ b/cmd/errors.go @@ -0,0 +1,7 @@ +package cmd + +import "errors" + +var ( + ErrMultipleOutputFlag = errors.New("multiple output flags are specified at once") +) diff --git a/cmd/root.go b/cmd/root.go index 52caf28..08e017d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -7,9 +7,14 @@ import ( func newRootCmd() *cobra.Command { return &cobra.Command{ - Use: "leadtime", - Short: "leadtime statistics on the time it took for PRs to be merged", - Long: "leadtime statistics on the time it took for PRs to be merged", + Use: "leadtime", + Short: "leadtime statistics on the time it took for PRs to be merged", + Long: `leadtime statistics on the time it took for PRs to be merged. +|------------- lead time -------------| +| |--- time to merge ---| +--------------------------------------- +^ ^ ^ +first commit create PR merge PR`, Example: " LT_GITHUB_ACCESS_TOKEN=XXX leadtime stat --owner=nao1215 --repo=sqly", } } @@ -17,8 +22,13 @@ func newRootCmd() *cobra.Command { // Execute run leadtime process. func Execute() int { rootCmd := newRootCmd() + rootCmd.CompletionOptions.DisableDefaultCmd = true + rootCmd.SilenceUsage = true + rootCmd.SilenceErrors = true + rootCmd.AddCommand(newStatCmd()) rootCmd.AddCommand(newVersionCmd()) + rootCmd.AddCommand(newCompletionCmd()) rootCmd.CompletionOptions.DisableDefaultCmd = true if err := rootCmd.Execute(); err != nil { diff --git a/cmd/stat.go b/cmd/stat.go index bdb4768..7a1b308 100644 --- a/cmd/stat.go +++ b/cmd/stat.go @@ -2,13 +2,18 @@ package cmd import ( "context" + "encoding/json" "fmt" "image/color" + "io" + "os" + "sort" "github.com/nao1215/leadtime/di" "github.com/nao1215/leadtime/domain/usecase" "github.com/shogo82148/pointer" "github.com/spf13/cobra" + "golang.org/x/exp/slices" "gonum.org/v1/plot" "gonum.org/v1/plot/plotter" "gonum.org/v1/plot/vg" @@ -19,8 +24,13 @@ func newStatCmd() *cobra.Command { Use: "stat", Short: "Print GitHub pull request leadtime statics", Long: `Print GitHub pull request leadtime statics. - -leadtime calculates statistics for PRs already in Closed/Merged status.`, +leadtime calculates statistics for PRs already in Closed/Merged status. +|------------- lead time -------------| +| |--- time to merge ---| +--------------------------------------- +^ ^ ^ +first commit create PR merge PR +`, Example: " LT_GITHUB_ACCESS_TOKEN=XXX leadtime stat --owner=nao1215 --repo=sqly", RunE: stat, } @@ -31,63 +41,88 @@ leadtime calculates statistics for PRs already in Closed/Merged status.`, statCmd.Flags().BoolP("exclude-bot", "B", false, "Exclude Pull Requests created by bots") statCmd.Flags().IntSliceP("exclude-pr", "P", []int{}, "Exclude specified Pull Requests (e.g. '-P 1,3,19')") statCmd.Flags().StringSliceP("exclude-user", "U", []string{}, "Exclude Pull Requests created by specified user (e.g. '-U nao,alice')") + statCmd.Flags().BoolP("all", "a", false, "Print all data used for statistics") + statCmd.Flags().BoolP("json", "j", false, "Output json") return statCmd } type option struct { - // gitHubOwner is owner name - gitHubOwner string - // gitHubRepo is github repository - gitHubRepo string - // markdown is markdown output mode flag - markdown bool + // all is flag whether output statistical data instead of statistical information or not + all bool // excludeBot is whether PRs created by bots exclude or not excludeBot bool // excludePRs is PR number list for exclusion excludePRs []int // excludeUsers is user list for exclusion excludeUsers []string + // gitHubOwner is owner name + gitHubOwner string + // gitHubRepo is github repository + gitHubRepo string + // json is json output mode flag + json bool + // markdown is markdown output mode flag + markdown bool +} + +func (o *option) valid() error { + if o.json && o.markdown { + return ErrMultipleOutputFlag + } + return nil } func newOption(cmd *cobra.Command) (*option, error) { - owner, err := cmd.Flags().GetString("owner") + all, err := cmd.Flags().GetBool("all") if err != nil { return nil, err } - repo, err := cmd.Flags().GetString("repo") + bot, err := cmd.Flags().GetBool("exclude-bot") if err != nil { return nil, err } - markdown, err := cmd.Flags().GetBool("markdown") + excludePRs, err := cmd.Flags().GetIntSlice("exclude-pr") if err != nil { return nil, err } - bot, err := cmd.Flags().GetBool("exclude-bot") + excludeUsers, err := cmd.Flags().GetStringSlice("exclude-user") if err != nil { return nil, err } - excludePRs, err := cmd.Flags().GetIntSlice("exclude-pr") + owner, err := cmd.Flags().GetString("owner") if err != nil { return nil, err } - excludeUsers, err := cmd.Flags().GetStringSlice("exclude-user") + repo, err := cmd.Flags().GetString("repo") + if err != nil { + return nil, err + } + + json, err := cmd.Flags().GetBool("json") + if err != nil { + return nil, err + } + + markdown, err := cmd.Flags().GetBool("markdown") if err != nil { return nil, err } return &option{ - gitHubOwner: owner, - gitHubRepo: repo, - markdown: markdown, + all: all, excludeBot: bot, excludePRs: excludePRs, excludeUsers: excludeUsers, + gitHubOwner: owner, + gitHubRepo: repo, + markdown: markdown, + json: json, }, nil } @@ -102,6 +137,10 @@ func stat(cmd *cobra.Command, args []string) error { //nolint return err } + if err := opt.valid(); err != nil { + return err + } + input := &usecase.LeadTimeUsecaseStatInput{ Owner: opt.gitHubOwner, Repository: opt.gitHubRepo, @@ -115,36 +154,41 @@ func stat(cmd *cobra.Command, args []string) error { //nolint return err } - output.LeadTime.RemoveOpenPR() - if opt.excludeBot { - output.LeadTime.RemovePRCreatedByBot() - } - if len(opt.excludePRs) != 0 { - output.LeadTime.RemovePRs(opt.excludePRs) - } - if len(opt.excludeUsers) != 0 { - output.LeadTime.RemovePRsCreatedByTargetUser(opt.excludeUsers) - } + dlts := newDetailLeadTimeStat(output.LeadTime) + dlts.removePRs(opt) + dlts.stat() + + return dlts.print(opt) +} +func (dlts *DetailLeadTimeStat) print(opt *option) error { if opt.markdown { - if err := drawGraph(output.LeadTime); err != nil { + if err := dlts.drawGraph(); err != nil { return err } - outputMarkdown(output.LeadTime) + dlts.markdown(opt.all) return nil } - outputDefault(output.LeadTime) + + if opt.json { + if err := dlts.json(os.Stdout, opt.all); err != nil { + return err + } + return nil + } + + dlts.stdout(opt.all) return nil } -func drawGraph(lt *usecase.LeadTime) error { +func (dlts *DetailLeadTimeStat) drawGraph() error { p := plot.New() p.X.Label.Text = "PR number" p.Y.Label.Text = "Lead Time[min]" - data := make(plotter.XYs, 0, len(lt.PRs)) - for _, v := range lt.PRs { + data := make(plotter.XYs, 0, len(dlts.PullRequests)) + for _, v := range dlts.PullRequests { data = append(data, plotter.XY{ X: float64(v.Number), Y: float64(v.MergeTimeMinutes), @@ -156,7 +200,7 @@ func drawGraph(lt *usecase.LeadTime) error { return err } p.Add(plotter.NewGrid()) - p.Y.Max = float64(lt.Max()) + 100 + p.Y.Max = float64(dlts.max()) + 100 line.Color = color.RGBA{R: 226, G: 45, B: 60, A: 255} line.Width = vg.Points(1.5) p.Add(line) @@ -167,48 +211,237 @@ func drawGraph(lt *usecase.LeadTime) error { return nil } -func outputMarkdown(lt *usecase.LeadTime) { +func (dlts *DetailLeadTimeStat) markdown(all bool) { fmt.Println("# Pull Request Lead Time") fmt.Println("## Statistics") - fmt.Printf("Statistics were calculated for %d closed PRs. \n", len(lt.PRs)) + fmt.Printf("Statistics were calculated for %d closed PRs. \n", len(dlts.PullRequests)) fmt.Println("| Item | Result |") fmt.Println("|:-----|:-------|") - fmt.Printf("| Lead Time(Max)|%d[min]|\n", lt.Max()) - fmt.Printf("| Lead Time(Min)|%d[min]|\n", lt.Min()) - fmt.Printf("| Lead Time(Sum)|%d[min]|\n", lt.Sum()) - fmt.Printf("| Lead Time(Ave)|%.2f[min]|\n", lt.Average()) - fmt.Printf("| Lead Time(MN )|%.2f[min]|\n", lt.Median()) + fmt.Printf("| Lead Time(Max)|%d[min]|\n", dlts.max()) + fmt.Printf("| Lead Time(Min)|%d[min]|\n", dlts.min()) + fmt.Printf("| Lead Time(Sum)|%d[min]|\n", dlts.sum()) + fmt.Printf("| Lead Time(Ave)|%.2f[min]|\n", dlts.average()) + fmt.Printf("| Lead Time(MN )|%.2f[min]|\n", dlts.median()) fmt.Println() fmt.Println("![PR Lead Time](./leadtime.png)") fmt.Println() - fmt.Println("## Pull Request Detail") - fmt.Println("| Number | Author | Bot | LeadTime[min] | Title |") - fmt.Println("|:-------|:-------|:----|:--------------|:------|") - for _, v := range lt.PRs { - if v.User.Bot { - fmt.Printf("|#%d|%s|%s|%d|%s|\n", v.Number, pointer.StringValue(v.User.Name), "yes", v.MergeTimeMinutes, v.Title) + + if all { + fmt.Println("## Pull Request Detail") + fmt.Println("| Number | Author | Bot | LeadTime[min] | Title |") + fmt.Println("|:-------|:-------|:----|:--------------|:------|") + for _, v := range dlts.PullRequests { + if v.User.Bot { + fmt.Printf("|#%d|%s|%s|%d|%s|\n", v.Number, pointer.StringValue(v.User.Name), "yes", v.MergeTimeMinutes, v.Title) + continue + } + fmt.Printf("|#%d|%s|%s|%d|%s|\n", v.Number, pointer.StringValue(v.User.Name), "no", v.MergeTimeMinutes, v.Title) + } + } +} + +func (dlts *DetailLeadTimeStat) stdout(all bool) { + if all { + fmt.Printf("PR\tAuthor\tBot\tLeadTime[min]\tTitle\n") + for _, v := range dlts.PullRequests { + if v.User.Bot { + fmt.Printf("#%d\t%s\t%s\t%d\t%s\n", v.Number, pointer.StringValue(v.User.Name), "yes", v.MergeTimeMinutes, v.Title) + continue + } + fmt.Printf("#%d\t%s\t%s\t%d\t%s\n", v.Number, pointer.StringValue(v.User.Name), "no", v.MergeTimeMinutes, v.Title) + } + fmt.Println("") + } + fmt.Println("[statistics]") + fmt.Printf(" Total PR = %d\n", len(dlts.PullRequests)) + fmt.Printf(" Lead Time(Max) = %d[min]\n", dlts.max()) + fmt.Printf(" Lead Time(Min) = %d[min]\n", dlts.min()) + fmt.Printf(" Lead Time(Sum) = %d[min]\n", dlts.sum()) + fmt.Printf(" Lead Time(Ave) = %.2f[min]\n", dlts.average()) + fmt.Printf(" Lead Time(Median) = %.2f[min]\n", dlts.median()) +} + +// LeadTimeStat is Lead time statistics. +type LeadTimeStat struct { + TotalPR int `json:"total_pr,omitempty"` + LeadTimeMaximum int `json:"lead_time_maximum,omitempty"` + LeadTimeMinimum int `json:"lead_time_minimum,omitempty"` + LeadTimeSummation int `json:"lead_time_summation,omitempty"` + LeadTimeAverage float64 `json:"lead_time_average,omitempty"` + LeadTimeMedian float64 `json:"lead_time_median,omitempty"` +} + +type DetailLeadTimeStat struct { + LeadTimeStatistics *LeadTimeStat `json:"lead_time_statistics,omitempty"` + PullRequests []*usecase.PullRequest `json:"pull_requests,omitempty"` +} + +func newDetailLeadTimeStat(lt *usecase.LeadTime) *DetailLeadTimeStat { + return &DetailLeadTimeStat{ + LeadTimeStatistics: &LeadTimeStat{}, + PullRequests: lt.PullRequests, + } +} + +func (dlts *DetailLeadTimeStat) json(w io.Writer, all bool) error { + var bytes []byte + var err error + if all { + bytes, err = json.Marshal(dlts) + } else { + bytes, err = json.Marshal(dlts.LeadTimeStatistics) + } + if err != nil { + return err + } + + fmt.Fprintln(w, string(bytes)) + + return nil +} + +func (dlts *DetailLeadTimeStat) stat() { + dlts.LeadTimeStatistics = &LeadTimeStat{ + TotalPR: len(dlts.PullRequests), + LeadTimeMaximum: dlts.max(), + LeadTimeMinimum: dlts.min(), + LeadTimeSummation: dlts.sum(), + LeadTimeAverage: dlts.average(), + LeadTimeMedian: dlts.median(), + } +} + +func (dlts *DetailLeadTimeStat) removePRs(opt *option) { + dlts.removeOpenPR() + if opt.excludeBot { + dlts.removePRCreatedByBot() + } + if len(opt.excludePRs) != 0 { + dlts.removeSpecifiedPRs(opt.excludePRs) + } + if len(opt.excludeUsers) != 0 { + dlts.removePRsCreatedByTargetUser(opt.excludeUsers) + } +} + +func (dlts *DetailLeadTimeStat) removeOpenPR() { + prs := make([]*usecase.PullRequest, 0, len(dlts.PullRequests)) + for _, v := range dlts.PullRequests { + if v.State == "open" { continue } - fmt.Printf("|#%d|%s|%s|%d|%s|\n", v.Number, pointer.StringValue(v.User.Name), "no", v.MergeTimeMinutes, v.Title) + prs = append(prs, v) } + dlts.PullRequests = prs } -func outputDefault(lt *usecase.LeadTime) { - fmt.Printf("PR\tAuthor\tBot\tLeadTime[min]\tTitle\n") - for _, v := range lt.PRs { - if v.User.Bot { - fmt.Printf("#%d\t%s\t%s\t%d\t%s\n", v.Number, pointer.StringValue(v.User.Name), "yes", v.MergeTimeMinutes, v.Title) +func (dlts *DetailLeadTimeStat) removePRCreatedByBot() { + prs := make([]*usecase.PullRequest, 0, len(dlts.PullRequests)) + for _, v := range dlts.PullRequests { + if v.User.IsBot() { continue } - fmt.Printf("#%d\t%s\t%s\t%d\t%s\n", v.Number, pointer.StringValue(v.User.Name), "no", v.MergeTimeMinutes, v.Title) + prs = append(prs, v) } + dlts.PullRequests = prs +} - fmt.Println("") - fmt.Println("[statistics]") - fmt.Printf(" Total PR = %d\n", len(lt.PRs)) - fmt.Printf(" Lead Time(Max) = %d[min]\n", lt.Max()) - fmt.Printf(" Lead Time(Min) = %d[min]\n", lt.Min()) - fmt.Printf(" Lead Time(Sum) = %d[min]\n", lt.Sum()) - fmt.Printf(" Lead Time(Ave) = %.2f[min]\n", lt.Average()) - fmt.Printf(" Lead Time(Median) = %.2f[min]\n", lt.Median()) +func (dlts *DetailLeadTimeStat) removeSpecifiedPRs(removeTargetPRs []int) { + prs := make([]*usecase.PullRequest, 0, len(dlts.PullRequests)) + for _, v := range dlts.PullRequests { + if slices.Contains(removeTargetPRs, v.Number) { + continue + } + prs = append(prs, v) + } + dlts.PullRequests = prs +} + +func (dlts *DetailLeadTimeStat) removePRsCreatedByTargetUser(target []string) { + prs := make([]*usecase.PullRequest, 0, len(dlts.PullRequests)) + for _, v := range dlts.PullRequests { + if slices.Contains(target, pointer.StringValue(v.User.Name)) { + continue + } + prs = append(prs, v) + } + dlts.PullRequests = prs +} + +func (dlts *DetailLeadTimeStat) min() int { + if len(dlts.PullRequests) == 0 { + return 0 + } + + min := dlts.PullRequests[0].MergeTimeMinutes + for _, v := range dlts.PullRequests[1:] { + if v.MergeTimeMinutes < min { + min = v.MergeTimeMinutes + } + } + + return min +} + +func (dlts *DetailLeadTimeStat) max() int { + if len(dlts.PullRequests) == 0 { + return 0 + } + + max := dlts.PullRequests[0].MergeTimeMinutes + for _, v := range dlts.PullRequests[1:] { + if v.MergeTimeMinutes > max { + max = v.MergeTimeMinutes + } + } + + return max +} + +func (dlts *DetailLeadTimeStat) average() float64 { + if len(dlts.PullRequests) == 0 { + return 0 + } + + sum := float64(0) + for _, v := range dlts.PullRequests { + sum += float64(v.MergeTimeMinutes) + } + + return sum / float64(len(dlts.PullRequests)) +} + +func (dlts *DetailLeadTimeStat) sum() int { + if len(dlts.PullRequests) == 0 { + return 0 + } + + sum := 0 + for _, v := range dlts.PullRequests { + sum += v.MergeTimeMinutes + } + + return sum +} + +func (dlts *DetailLeadTimeStat) median() float64 { + if len(dlts.PullRequests) == 0 { + return 0 + } + + nums := make([]int, 0, len(dlts.PullRequests)) + for _, v := range dlts.PullRequests { + nums = append(nums, v.MergeTimeMinutes) + } + sort.Ints(nums) + + var median float64 + mid := len(nums) / 2 + if len(nums)%2 == 0 { + median = float64(nums[mid-1]+nums[mid]) / 2 + } else { + median = float64(nums[mid]) + } + + return median } diff --git a/domain/usecase/leadtime.go b/domain/usecase/leadtime.go index c41e006..6800d50 100644 --- a/domain/usecase/leadtime.go +++ b/domain/usecase/leadtime.go @@ -3,14 +3,12 @@ package usecase import ( "context" "errors" - "sort" "time" "github.com/nao1215/leadtime/domain/model" "github.com/nao1215/leadtime/domain/repository" "github.com/nao1215/leadtime/infrastructure/github" "github.com/shogo82148/pointer" - "golang.org/x/exp/slices" ) // LeadTimeUsecase is use cases for stat leadtime @@ -56,15 +54,15 @@ func NewLeadTimeUsecase(gitHubRepo repository.GitHubRepository) LeadTimeUsecase // PullRequest is PR information for presentation layer. type PullRequest struct { - Number int - State string - Title string - FirstCommitAt time.Time - CreatedAt time.Time - ClosedAt time.Time - MergedAt time.Time - User *model.User - MergeTimeMinutes int + Number int `json:"number,omitempty"` + State string `json:"state,omitempty"` + Title string `json:"title,omitempty"` + FirstCommitAt time.Time `json:"first_commit_at,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + ClosedAt time.Time `json:"closed_at,omitempty"` + MergedAt time.Time `json:"merged_at,omitempty"` + User *model.User `json:"user,omitempty"` + MergeTimeMinutes int `json:"merge_time_minutes,omitempty"` } func (p *PullRequest) toUsecasePullRequest(domainModelPR *model.PullRequest, firstCommitAt time.Time) *PullRequest { @@ -98,129 +96,7 @@ func (p *PullRequest) toUsecasePullRequest(domainModelPR *model.PullRequest, fir } type LeadTime struct { - PRs []*PullRequest -} - -func (lt *LeadTime) RemoveOpenPR() { - prs := make([]*PullRequest, 0, len(lt.PRs)) - for _, v := range lt.PRs { - if v.State == "open" { - continue - } - prs = append(prs, v) - } - lt.PRs = prs -} - -func (lt *LeadTime) RemovePRCreatedByBot() { - prs := make([]*PullRequest, 0, len(lt.PRs)) - for _, v := range lt.PRs { - if v.User.IsBot() { - continue - } - prs = append(prs, v) - } - lt.PRs = prs -} - -func (lt *LeadTime) RemovePRs(removeTargetPRs []int) { - prs := make([]*PullRequest, 0, len(lt.PRs)) - for _, v := range lt.PRs { - if slices.Contains(removeTargetPRs, v.Number) { - continue - } - prs = append(prs, v) - } - lt.PRs = prs -} - -func (lt *LeadTime) RemovePRsCreatedByTargetUser(target []string) { - prs := make([]*PullRequest, 0, len(lt.PRs)) - for _, v := range lt.PRs { - if slices.Contains(target, pointer.StringValue(v.User.Name)) { - continue - } - prs = append(prs, v) - } - lt.PRs = prs -} - -func (lt *LeadTime) Min() int { - if len(lt.PRs) == 0 { - return 0 - } - - min := lt.PRs[0].MergeTimeMinutes - for _, v := range lt.PRs[1:] { - if v.MergeTimeMinutes < min { - min = v.MergeTimeMinutes - } - } - - return min -} - -func (lt *LeadTime) Max() int { - if len(lt.PRs) == 0 { - return 0 - } - - max := lt.PRs[0].MergeTimeMinutes - for _, v := range lt.PRs[1:] { - if v.MergeTimeMinutes > max { - max = v.MergeTimeMinutes - } - } - - return max -} - -func (lt *LeadTime) Average() float64 { - if len(lt.PRs) == 0 { - return 0 - } - - sum := float64(0) - for _, v := range lt.PRs { - sum += float64(v.MergeTimeMinutes) - } - - return sum / float64(len(lt.PRs)) -} - -func (lt *LeadTime) Sum() int { - if len(lt.PRs) == 0 { - return 0 - } - - sum := 0 - for _, v := range lt.PRs { - sum += v.MergeTimeMinutes - } - - return sum -} - -func (lt *LeadTime) Median() float64 { - if len(lt.PRs) == 0 { - return 0 - } - - nums := make([]int, 0, len(lt.PRs)) - for _, v := range lt.PRs { - nums = append(nums, v.MergeTimeMinutes) - } - sort.Ints(nums) - - var median float64 - mid := len(nums) / 2 - if len(nums)%2 == 0 { - median = float64(nums[mid-1]+nums[mid]) / 2 - } else { - median = float64(nums[mid]) - } - - return median + PullRequests []*PullRequest `json:"pull_requests,omitempty"` } // Stat return lead time statistics @@ -250,7 +126,7 @@ func (lt *LTUsecase) Stat(ctx context.Context, input *LeadTimeUsecaseStatInput) return &LeadTimeUsecaseStatOutput{ LeadTime: &LeadTime{ - PRs: pullReqs, + PullRequests: pullReqs, }, }, nil } diff --git a/go.mod b/go.mod index fd35f2c..a6180c1 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/google/go-cmp v0.5.9 github.com/google/go-github/v50 v50.1.0 github.com/google/wire v0.5.0 + github.com/nao1215/gorky v0.2.0 github.com/shogo82148/pointer v1.2.0 github.com/spf13/cobra v1.6.1 golang.org/x/exp v0.0.0-20220827204233-334a2380cb91 diff --git a/go.sum b/go.sum index 2b0542e..5e411e2 100644 --- a/go.sum +++ b/go.sum @@ -66,6 +66,8 @@ github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68 h1:y1p/ycavWjGT9Fn github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0 h1:STjmj0uFfRryL9fzRA/OupNppeAID6QJYPMavTL7jtY= github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= +github.com/nao1215/gorky v0.2.0 h1:dGhccwANGFbA2wryX0fkRe3O7h+Pov63hNQxSmKypgA= +github.com/nao1215/gorky v0.2.0/go.mod h1:RHHEeEhkdrCuNEduNB2i4RCgkJWiAD6gK+AZq0Kyx44= github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2dXMnm1mY= github.com/phpdave11/gofpdi v1.0.12/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=