diff --git a/Gopkg.lock b/Gopkg.lock index bdf61ab..715f64e 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -1,6 +1,14 @@ # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. +[[projects]] + digest = "1:52b6c8beb19a31fe7b40b23c9b694caf4213cfa3d6cce711cd0636db68ce67aa" + name = "github.com/Luzifer/go_helpers" + packages = ["duration"] + pruneopts = "NUT" + revision = "4e368ddb27c0a08a0c84e5ceac52d57b464bffce" + version = "v2.7.0" + [[projects]] digest = "1:3c2b6707179ff866ca06c5eecd544c9079a6ec35d735031a1bf61abfff336186" name = "github.com/Luzifer/rconfig" @@ -33,6 +41,14 @@ pruneopts = "NUT" revision = "53e6ce116135b80d037921a7fdd5138cf32d7a8a" +[[projects]] + branch = "master" + digest = "1:071a57a67bde66b0e6438ffee4599ef99532a0e29de3ea12926b5438608f90a6" + name = "github.com/leekchan/gtf" + packages = ["."] + pruneopts = "NUT" + revision = "79e3a68ab435edb72c6490289a33ef8bedcee15a" + [[projects]] digest = "1:5cf3f025cbee5951a4ee961de067c8a89fc95a5adabead774f82822efabab121" name = "github.com/pkg/errors" @@ -134,6 +150,7 @@ analyzer-name = "dep" analyzer-version = 1 input-imports = [ + "github.com/Luzifer/go_helpers/duration", "github.com/Luzifer/rconfig", "github.com/google/go-github/github", "github.com/pkg/errors", diff --git a/Gopkg.toml b/Gopkg.toml index 5fae588..984106f 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -25,6 +25,10 @@ # unused-packages = true +[[constraint]] + name = "github.com/Luzifer/go_helpers" + version = "2.7.0" + [[constraint]] name = "github.com/Luzifer/rconfig" version = "2.1.0" diff --git a/README.md b/README.md index a0e76bc..5433a72 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,8 @@ Usage of clean-github-branches: --version Prints current version and exits $ clean-github-branches -r '^[lL]uzifer(-docker|-ansible|)/' -WARN[0013] Stale branch found ahead=1 behind=1 branch=develop dry-run=true repo=luzifer-docker/etherpad-lite -INFO[0013] Done. +WARN[0012] Stale branch found age=2y174d23h ahead=1 behind=1 branch=develop dry-run=true repo=luzifer-docker/etherpad-lite +INFO[0012] Done. ``` All parameters causing destructive actions are set to sane defaults: By default a `dry-run` is done which prevents any deletion. Also `delete-stale` is disabled as it might cause data loss as the branch is not merged and all commits in it will be lost. diff --git a/main.go b/main.go index f29cf47..6a5fef9 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( "sync" "time" + "github.com/Luzifer/go_helpers/duration" "github.com/Luzifer/rconfig" "github.com/google/go-github/github" @@ -18,6 +19,10 @@ import ( "golang.org/x/oauth2" ) +const humanizeTemplate = `{{if gt .Years 0}}{{.Years}}y{{end}}` + + `{{if gt .Days 0}}{{.Days}}d{{end}}` + + `{{if gt .Hours 0}}{{.Hours}}h{{end}}` + var ( cfg = struct { BranchStaleness time.Duration `flag:"branch-staleness" default:"2160h" description:"When to see a branch as stale (default 90d)"` @@ -277,6 +282,8 @@ func processRepo(ctx context.Context, logger *log.Entry, client *github.Client, float64(time.Since(b.GetCommit().GetCommit().GetAuthor().GetDate())), float64(time.Since(b.GetCommit().GetCommit().GetCommitter().GetDate())), )) + d, _ := duration.CustomHumanizeDuration(branchLastModified, humanizeTemplate) + branchLogger = branchLogger.WithField("age", d) // Check all PRs whether they match the branch (head) and are merged hasValidMerge, hasOpenPR := analysePullRequests(branchLogger, prs, b) diff --git a/vendor/github.com/Luzifer/go_helpers/duration/time.go b/vendor/github.com/Luzifer/go_helpers/duration/time.go new file mode 100644 index 0000000..f560a2a --- /dev/null +++ b/vendor/github.com/Luzifer/go_helpers/duration/time.go @@ -0,0 +1,61 @@ +package duration + +import ( + "bytes" + "math" + "strings" + "text/template" + "time" + + "github.com/leekchan/gtf" +) + +const defaultDurationFormat = `{{if gt .Years 0}}{{.Years}} year{{.Years|pluralize "s"}}, {{end}}` + + `{{if gt .Days 0}}{{.Days}} day{{.Days|pluralize "s"}}, {{end}}` + + `{{if gt .Hours 0}}{{.Hours}} hour{{.Hours|pluralize "s"}}, {{end}}` + + `{{if gt .Minutes 0}}{{.Minutes}} minute{{.Minutes|pluralize "s"}}, {{end}}` + + `{{if gt .Seconds 0}}{{.Seconds}} second{{.Seconds|pluralize "s"}}{{end}}` + +func HumanizeDuration(in time.Duration) string { + f, err := CustomHumanizeDuration(in, defaultDurationFormat) + if err != nil { + panic(err) + } + return strings.Trim(f, " ,") +} + +func CustomHumanizeDuration(in time.Duration, tpl string) (string, error) { + result := struct{ Years, Days, Hours, Minutes, Seconds int64 }{} + + in = time.Duration(math.Abs(float64(in))) + + for in > 0 { + switch { + case in > 365.25*24*time.Hour: + result.Years = int64(in / (365 * 24 * time.Hour)) + in = in - time.Duration(result.Years)*365*24*time.Hour + case in > 24*time.Hour: + result.Days = int64(in / (24 * time.Hour)) + in = in - time.Duration(result.Days)*24*time.Hour + case in > time.Hour: + result.Hours = int64(in / time.Hour) + in = in - time.Duration(result.Hours)*time.Hour + case in > time.Minute: + result.Minutes = int64(in / time.Minute) + in = in - time.Duration(result.Minutes)*time.Minute + default: + result.Seconds = int64(in / time.Second) + in = 0 + } + } + + tmpl, err := template.New("timeformat").Funcs(template.FuncMap(gtf.GtfFuncMap)).Parse(tpl) + if err != nil { + return "", err + } + + buf := bytes.NewBuffer([]byte{}) + tmpl.Execute(buf, result) + + return buf.String(), nil +} diff --git a/vendor/github.com/leekchan/gtf/LICENSE b/vendor/github.com/leekchan/gtf/LICENSE new file mode 100644 index 0000000..244f4d8 --- /dev/null +++ b/vendor/github.com/leekchan/gtf/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2015 Kyoung-chan Lee (leekchan@gmail.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/vendor/github.com/leekchan/gtf/gtf.go b/vendor/github.com/leekchan/gtf/gtf.go new file mode 100644 index 0000000..4cf7a86 --- /dev/null +++ b/vendor/github.com/leekchan/gtf/gtf.go @@ -0,0 +1,470 @@ +package gtf + +import ( + "fmt" + htmlTemplate "html/template" + "math" + "math/rand" + "net/url" + "reflect" + "regexp" + "strings" + textTemplate "text/template" + "time" +) + +var striptagsRegexp = regexp.MustCompile("<[^>]*?>") + +// recovery will silently swallow all unexpected panics. +func recovery() { + recover() +} + +var GtfTextFuncMap = textTemplate.FuncMap{ + "replace": func(s1 string, s2 string) string { + defer recovery() + + return strings.Replace(s2, s1, "", -1) + }, + "title": func(s string) string { + defer recovery() + return strings.Title(s) + }, + "default": func(arg interface{}, value interface{}) interface{} { + defer recovery() + + v := reflect.ValueOf(value) + switch v.Kind() { + case reflect.String, reflect.Slice, reflect.Array, reflect.Map: + if v.Len() == 0 { + return arg + } + case reflect.Bool: + if !v.Bool() { + return arg + } + default: + return value + } + + return value + }, + "length": func(value interface{}) int { + defer recovery() + + v := reflect.ValueOf(value) + switch v.Kind() { + case reflect.Slice, reflect.Array, reflect.Map: + return v.Len() + case reflect.String: + return len([]rune(v.String())) + } + + return 0 + }, + "lower": func(s string) string { + defer recovery() + + return strings.ToLower(s) + }, + "upper": func(s string) string { + defer recovery() + + return strings.ToUpper(s) + }, + "truncatechars": func(n int, s string) string { + defer recovery() + + if n < 0 { + return s + } + + r := []rune(s) + rLength := len(r) + + if n >= rLength { + return s + } + + if n > 3 && rLength > 3 { + return string(r[:n-3]) + "..." + } + + return string(r[:n]) + }, + "urlencode": func(s string) string { + defer recovery() + + return url.QueryEscape(s) + }, + "wordcount": func(s string) int { + defer recovery() + + return len(strings.Fields(s)) + }, + "divisibleby": func(arg interface{}, value interface{}) bool { + defer recovery() + + var v float64 + switch value.(type) { + case int, int8, int16, int32, int64: + v = float64(reflect.ValueOf(value).Int()) + case uint, uint8, uint16, uint32, uint64: + v = float64(reflect.ValueOf(value).Uint()) + case float32, float64: + v = reflect.ValueOf(value).Float() + default: + return false + } + + var a float64 + switch arg.(type) { + case int, int8, int16, int32, int64: + a = float64(reflect.ValueOf(arg).Int()) + case uint, uint8, uint16, uint32, uint64: + a = float64(reflect.ValueOf(arg).Uint()) + case float32, float64: + a = reflect.ValueOf(arg).Float() + default: + return false + } + + return math.Mod(v, a) == 0 + }, + "lengthis": func(arg int, value interface{}) bool { + defer recovery() + + v := reflect.ValueOf(value) + switch v.Kind() { + case reflect.Slice, reflect.Array, reflect.Map: + return v.Len() == arg + case reflect.String: + return len([]rune(v.String())) == arg + } + + return false + }, + "trim": func(s string) string { + defer recovery() + + return strings.TrimSpace(s) + }, + "capfirst": func(s string) string { + defer recovery() + + return strings.ToUpper(string(s[0])) + s[1:] + }, + "pluralize": func(arg string, value interface{}) string { + defer recovery() + + flag := false + v := reflect.ValueOf(value) + switch v.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + flag = v.Int() == 1 + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + flag = v.Uint() == 1 + default: + return "" + } + + if !strings.Contains(arg, ",") { + arg = "," + arg + } + + bits := strings.Split(arg, ",") + + if len(bits) > 2 { + return "" + } + + if flag { + return bits[0] + } + + return bits[1] + }, + "yesno": func(yes string, no string, value bool) string { + defer recovery() + + if value { + return yes + } + + return no + }, + "rjust": func(arg int, value string) string { + defer recovery() + + n := arg - len([]rune(value)) + + if n > 0 { + value = strings.Repeat(" ", n) + value + } + + return value + }, + "ljust": func(arg int, value string) string { + defer recovery() + + n := arg - len([]rune(value)) + + if n > 0 { + value = value + strings.Repeat(" ", n) + } + + return value + }, + "center": func(arg int, value string) string { + defer recovery() + + n := arg - len([]rune(value)) + + if n > 0 { + left := n / 2 + right := n - left + value = strings.Repeat(" ", left) + value + strings.Repeat(" ", right) + } + + return value + }, + "filesizeformat": func(value interface{}) string { + defer recovery() + + var size float64 + + v := reflect.ValueOf(value) + switch v.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + size = float64(v.Int()) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + size = float64(v.Uint()) + case reflect.Float32, reflect.Float64: + size = v.Float() + default: + return "" + } + + var KB float64 = 1 << 10 + var MB float64 = 1 << 20 + var GB float64 = 1 << 30 + var TB float64 = 1 << 40 + var PB float64 = 1 << 50 + + filesizeFormat := func(filesize float64, suffix string) string { + return strings.Replace(fmt.Sprintf("%.1f %s", filesize, suffix), ".0", "", -1) + } + + var result string + if size < KB { + result = filesizeFormat(size, "bytes") + } else if size < MB { + result = filesizeFormat(size/KB, "KB") + } else if size < GB { + result = filesizeFormat(size/MB, "MB") + } else if size < TB { + result = filesizeFormat(size/GB, "GB") + } else if size < PB { + result = filesizeFormat(size/TB, "TB") + } else { + result = filesizeFormat(size/PB, "PB") + } + + return result + }, + "apnumber": func(value interface{}) interface{} { + defer recovery() + + name := [10]string{"one", "two", "three", "four", "five", + "six", "seven", "eight", "nine"} + + v := reflect.ValueOf(value) + switch v.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if v.Int() < 10 { + return name[v.Int()-1] + } + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + if v.Uint() < 10 { + return name[v.Uint()-1] + } + } + + return value + }, + "intcomma": func(value interface{}) string { + defer recovery() + + v := reflect.ValueOf(value) + + var x uint + minus := false + switch v.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if v.Int() < 0 { + minus = true + x = uint(-v.Int()) + } else { + x = uint(v.Int()) + } + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + x = uint(v.Uint()) + default: + return "" + } + + var result string + for x >= 1000 { + result = fmt.Sprintf(",%03d%s", x%1000, result) + x /= 1000 + } + result = fmt.Sprintf("%d%s", x, result) + + if minus { + result = "-" + result + } + + return result + }, + "ordinal": func(value interface{}) string { + defer recovery() + + v := reflect.ValueOf(value) + + var x uint + switch v.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if v.Int() < 0 { + return "" + } + x = uint(v.Int()) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + x = uint(v.Uint()) + default: + return "" + } + + suffixes := [10]string{"th", "st", "nd", "rd", "th", "th", "th", "th", "th", "th"} + + switch x % 100 { + case 11, 12, 13: + return fmt.Sprintf("%d%s", x, suffixes[0]) + } + + return fmt.Sprintf("%d%s", x, suffixes[x%10]) + }, + "first": func(value interface{}) interface{} { + defer recovery() + + v := reflect.ValueOf(value) + + switch v.Kind() { + case reflect.String: + return string([]rune(v.String())[0]) + case reflect.Slice, reflect.Array: + return v.Index(0).Interface() + } + + return "" + }, + "last": func(value interface{}) interface{} { + defer recovery() + + v := reflect.ValueOf(value) + + switch v.Kind() { + case reflect.String: + str := []rune(v.String()) + return string(str[len(str)-1]) + case reflect.Slice, reflect.Array: + return v.Index(v.Len() - 1).Interface() + } + + return "" + }, + "join": func(arg string, value []string) string { + defer recovery() + + return strings.Join(value, arg) + }, + "slice": func(start int, end int, value interface{}) interface{} { + defer recovery() + + v := reflect.ValueOf(value) + + if start < 0 { + start = 0 + } + + switch v.Kind() { + case reflect.String: + str := []rune(v.String()) + + if end > len(str) { + end = len(str) + } + + return string(str[start:end]) + case reflect.Slice: + return v.Slice(start, end).Interface() + } + return "" + }, + "random": func(value interface{}) interface{} { + defer recovery() + + rand.Seed(time.Now().UTC().UnixNano()) + + v := reflect.ValueOf(value) + + switch v.Kind() { + case reflect.String: + str := []rune(v.String()) + return string(str[rand.Intn(len(str))]) + case reflect.Slice, reflect.Array: + return v.Index(rand.Intn(v.Len())).Interface() + } + + return "" + }, + "striptags": func(s string) string { + return strings.TrimSpace(striptagsRegexp.ReplaceAllString(s, "")) + }, +} + +var GtfFuncMap = htmlTemplate.FuncMap(GtfTextFuncMap) + +// gtf.New is a wrapper function of template.New(https://golang.org/pkg/html/template/#New). +// It automatically adds the gtf functions to the template's function map +// and returns template.Template(http://golang.org/pkg/html/template/#Template). +func New(name string) *htmlTemplate.Template { + return htmlTemplate.New(name).Funcs(GtfFuncMap) +} + +// gtf.Inject injects gtf functions into the passed FuncMap. +// It does not overwrite the original function which have same name as a gtf function. +func Inject(funcs map[string]interface{}) { + for k, v := range GtfFuncMap { + if _, ok := funcs[k]; !ok { + funcs[k] = v + } + } +} + +// gtf.ForceInject injects gtf functions into the passed FuncMap. +// It overwrites the original function which have same name as a gtf function. +func ForceInject(funcs map[string]interface{}) { + for k, v := range GtfFuncMap { + funcs[k] = v + } +} + +// gtf.Inject injects gtf functions into the passed FuncMap. +// It prefixes the gtf functions with the specified prefix. +// If there are many function which have same names as the gtf functions, +// you can use this function to prefix the gtf functions. +func InjectWithPrefix(funcs map[string]interface{}, prefix string) { + for k, v := range GtfFuncMap { + funcs[prefix+k] = v + } +}