diff --git a/Gopkg.lock b/Gopkg.lock index 2c21147e..ec5dfc0c 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -10,8 +10,8 @@ [[projects]] name = "github.com/cpuguy83/go-md2man" packages = ["md2man"] - revision = "1d903dcb749992f3741d744c0f8376b4bd7eb3e1" - version = "v1.0.7" + revision = "20f5889cbdc3c73dbd2862796665e7c465ade7d1" + version = "v1.0.8" [[projects]] name = "github.com/davecgh/go-spew" @@ -22,8 +22,22 @@ [[projects]] name = "github.com/fsnotify/fsnotify" packages = ["."] - revision = "629574ca2a5df945712d3079857300b5e4da0236" - version = "v1.4.2" + revision = "c2828203cd70a50dcccfb2761f8b1f8ceef9a8e9" + version = "v1.4.7" + +[[projects]] + branch = "master" + name = "github.com/gdamore/encoding" + packages = ["."] + revision = "b23993cbb6353f0e6aa98d0ee318a34728f628b9" + +[[projects]] + name = "github.com/gdamore/tcell" + packages = [ + ".", + "terminfo" + ] + revision = "2f258105ca8ce35819115b49f5ac58197241653e" [[projects]] branch = "master" @@ -46,7 +60,7 @@ "json/scanner", "json/token" ] - revision = "23c074d0eceb2b8a5bfdbb271ab780cde70f05a8" + revision = "ef8a98b0bbce4a65b5aa4c368430a80ddc533168" [[projects]] name = "github.com/inconshreveable/mousetrap" @@ -54,11 +68,23 @@ revision = "76626ae9c91c4f2a10f34cad8ce83ea42c93bb75" version = "v1.0" +[[projects]] + branch = "master" + name = "github.com/lucasb-eyer/go-colorful" + packages = ["."] + revision = "231272389856c976b7500c4fffcc52ddf06ff4eb" + [[projects]] name = "github.com/magiconair/properties" packages = ["."] - revision = "be5ece7dd465ab0765a9682137865547526d1dfb" - version = "v1.7.3" + revision = "c3beff4c2358b44d0493c7dda585e7db7ff28ae6" + version = "v1.7.6" + +[[projects]] + name = "github.com/mattn/go-runewidth" + packages = ["."] + revision = "9e777a8366cce605130a531d2cd6363d07ad7317" + version = "v0.0.2" [[projects]] branch = "master" @@ -70,13 +96,13 @@ branch = "master" name = "github.com/mitchellh/mapstructure" packages = ["."] - revision = "06020f85339e21b2478f756a78e295255ffa4d6a" + revision = "00c29f56e2386353d58c599509e8dc3801b0d716" [[projects]] name = "github.com/pelletier/go-toml" packages = ["."] - revision = "16398bac157da96aa88f98a2df640c7f32af1da2" - version = "v1.0.1" + revision = "acdc4509485b587f5e675510c4f2c63e90ff68a8" + version = "v1.1.0" [[projects]] name = "github.com/pkg/errors" @@ -90,11 +116,17 @@ revision = "792786c7400a136282c1664665ae0a8db921c6c2" version = "v1.0.0" +[[projects]] + branch = "master" + name = "github.com/rivo/tview" + packages = ["."] + revision = "f855bee0205c35e6a055b86cc341effea0f446ce" + [[projects]] name = "github.com/russross/blackfriday" packages = ["."] - revision = "4048872b16cc0fc2c5fd9eacf0ed2c2fedaa0c8c" - version = "v1.5" + revision = "55d61fa8aa702f59229e6cff85793c22e580eaf5" + version = "v1.5.1" [[projects]] name = "github.com/spf13/afero" @@ -102,14 +134,14 @@ ".", "mem" ] - revision = "8d919cbe7e2627e417f3e45c3c0e489a5b7e2536" - version = "v1.0.0" + revision = "63644898a8da0bc22138abf860edaf5277b6102e" + version = "v1.1.0" [[projects]] name = "github.com/spf13/cast" packages = ["."] - revision = "acbeb36b902d72a7a4c18e8f3241075e7ab763e4" - version = "v1.1.0" + revision = "8965335b8c7107321228e3e3702cab9832751bac" + version = "v1.2.0" [[projects]] branch = "master" @@ -118,25 +150,25 @@ ".", "doc" ] - revision = "fd32f09af19efc9b1279c54e0d8ed23f66232a15" + revision = "615425954c3b0d9485a7027d4d451fdcdfdee84e" [[projects]] branch = "master" name = "github.com/spf13/jwalterweatherman" packages = ["."] - revision = "12bd96e66386c1960ab0f74ced1362f66f552f7b" + revision = "7c0cea34c8ece3fbeb2b27ab9b59511d360fb394" [[projects]] name = "github.com/spf13/pflag" packages = ["."] - revision = "e57e3eeb33f795204c1ca35f56c44f83227c6e66" - version = "v1.0.0" + revision = "583c0c0531f06d5278b7d917446061adc344b5cd" + version = "v1.0.1" [[projects]] branch = "master" name = "github.com/spf13/viper" packages = ["."] - revision = "1a0c4a370c3e8286b835467d2dfcdaf636c3538b" + revision = "8dc2790b029dc41e2b8ff772c63c26adbb1db70d" [[projects]] name = "github.com/stretchr/testify" @@ -144,8 +176,8 @@ "assert", "require" ] - revision = "69483b4bd14f5845b5a1e55bca19e954e827f1d0" - version = "v1.1.4" + revision = "12b6f73e6084dad08a7c6e575284b177ecafbc71" + version = "v1.2.1" [[projects]] name = "github.com/tcnksm/go-gitconfig" @@ -154,15 +186,16 @@ version = "v0.1.2" [[projects]] + branch = "master" name = "github.com/xanzy/go-gitlab" packages = ["."] - revision = "266c87ba209d842f6c190920a55db959e5b13971" + revision = "60ef0cdb1b7e433233e0fb28f6cbe397e5c462a4" [[projects]] branch = "master" name = "golang.org/x/crypto" packages = ["ssh/terminal"] - revision = "9f005a07e0d31d45e6656d241bb5c0f2efd4bc94" + revision = "e73bf333ef8920dbb52ad18d4bd38ad9d9bc76d7" [[projects]] branch = "master" @@ -171,12 +204,13 @@ "unix", "windows" ] - revision = "0dd5e194bbf5eb84a39666eb4c98a4d007e4203a" + revision = "79b0c6888797020a994db17c8510466c72fe75d9" [[projects]] - branch = "master" name = "golang.org/x/text" packages = [ + "encoding", + "encoding/internal/identifier", "internal/gen", "internal/triegen", "internal/ucd", @@ -184,17 +218,18 @@ "unicode/cldr", "unicode/norm" ] - revision = "be25de41fadfae372d6470bda81ca6beb55ef551" + revision = "f21a4dfb5e38f5895301dc265a8def02365cc3d0" + version = "v0.3.0" [[projects]] - branch = "v2" name = "gopkg.in/yaml.v2" packages = ["."] - revision = "287cf08546ab5e7e37d55a84f7ed3fd1db036de5" + revision = "5420a8b6744d3b0345ab293f6fcba19c978f1183" + version = "v2.2.1" [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "f902c44fdfda24711d2fc77ec47941bc42dc0a0f1aad18922657e7ef5b1542f0" + inputs-digest = "a817e735e4272c01c1c85c4645aaca48c62faad6071fba30107c3ac89cd58b1a" solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index dcc647b6..aedcf309 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -38,5 +38,9 @@ version = "0.1.2" [[constraint]] + branch = "master" name = "github.com/xanzy/go-gitlab" - revision = "266c87ba209d842f6c190920a55db959e5b13971" + +[[constraint]] + name = "github.com/gdamore/tcell" + revision = "2f258105ca8ce35819115b49f5ac58197241653e" diff --git a/Makefile b/Makefile index 386e1345..950a5431 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,6 @@ test: bash -c "trap 'trap - SIGINT SIGTERM ERR; mv testdata/.git testdata/test.git; rm coverage-* 2>&1 > /dev/null; exit 1' SIGINT SIGTERM ERR; $(MAKE) internal-test" internal-test: - dep ensure rm coverage-* 2>&1 > /dev/null || true mv testdata/test.git testdata/.git go test -coverprofile=coverage-main.out -covermode=count -coverpkg ./... -run=$(run) github.com/zaquestion/lab/cmd github.com/zaquestion/lab/internal/... diff --git a/cmd/ci.go b/cmd/ci.go index 64295d06..e2ff9a72 100644 --- a/cmd/ci.go +++ b/cmd/ci.go @@ -7,10 +7,8 @@ import ( // ciCmd represents the ci command var ciCmd = &cobra.Command{ Use: "ci", - Short: "", + Short: "Work with GitLab CI pipelines and jobs", Long: ``, - Run: func(cmd *cobra.Command, args []string) { - }, } func init() { diff --git a/cmd/ciTrace.go b/cmd/ciTrace.go index 95532a51..c27b2858 100644 --- a/cmd/ciTrace.go +++ b/cmd/ciTrace.go @@ -18,7 +18,7 @@ import ( // ciLintCmd represents the lint command var ciTraceCmd = &cobra.Command{ - Use: "trace [remote [[:]job]]", + Use: "trace [remote [[branch:]job]]", Aliases: []string{"logs"}, Short: "Trace the output of a ci job", Long: `If a job is not specified the latest running job or last job in the pipeline is used`, @@ -27,6 +27,19 @@ var ciTraceCmd = &cobra.Command{ remote string jobName string ) + + branch, err := git.CurrentBranch() + if err != nil { + log.Fatal(err) + } + if len(args) > 1 { + jobName = args[1] + if strings.Contains(args[1], ":") { + ps := strings.Split(args[1], ":") + branch, jobName = ps[0], ps[1] + } + } + remote = determineSourceRemote(branch) if len(args) > 0 { ok, err := git.IsRemote(args[0]) if err != nil || !ok { @@ -34,9 +47,6 @@ var ciTraceCmd = &cobra.Command{ } remote = args[0] } - if remote == "" { - remote = forkedFromRemote - } rn, err := git.PathWithNameSpace(remote) if err != nil { @@ -46,25 +56,13 @@ var ciTraceCmd = &cobra.Command{ if err != nil { log.Fatal(err) } - var ref = "HEAD" - if len(args) > 1 { - jobName = args[1] - if strings.Contains(args[1], ":") { - ps := strings.Split(args[1], ":") - ref, jobName = ps[0], ps[1] - } - } - sha, err := git.Sha(ref) - if err != nil { - log.Fatal(err) - } var ( once sync.Once offset int64 ) FOR: for range time.NewTicker(time.Second * 3).C { - trace, job, err := lab.CITrace(project.ID, sha, jobName) + trace, job, err := lab.CITrace(project.ID, branch, jobName) if job == nil { log.Fatal(errors.Wrap(err, "failed to find job")) } diff --git a/cmd/ciTrace_test.go b/cmd/ciTrace_test.go index 33e273b2..0619ee93 100644 --- a/cmd/ciTrace_test.go +++ b/cmd/ciTrace_test.go @@ -24,6 +24,13 @@ func Test_ciTrace(t *testing.T) { t.Fatal(err) } + cmd = exec.Command("../lab_bin", "checkout", "-b", "ci_test_pipeline") + cmd.Dir = repo + if b, err := cmd.CombinedOutput(); err != nil { + t.Log(string(b)) + t.Fatal(err) + } + tests := []struct { desc string args []string @@ -33,7 +40,7 @@ func Test_ciTrace(t *testing.T) { desc: "noargs", args: []string{}, assertContains: func(t *testing.T, out string) { - assert.Contains(t, out, "Showing logs for deploy10 job #62958489") + assert.Contains(t, out, "Showing logs for deploy10") assert.Contains(t, out, "Checking out 09b519cb as ci_test_pipeline...") assert.Contains(t, out, "For example you might run an update here or install a build dependency") assert.Contains(t, out, "$ echo \"Or perhaps you might print out some debugging details\"") @@ -51,7 +58,7 @@ func Test_ciTrace(t *testing.T) { desc: "arg job name", args: []string{"origin", "deploy1"}, assertContains: func(t *testing.T, out string) { - assert.Contains(t, out, "Showing logs for deploy1 job #62958479") + assert.Contains(t, out, "Showing logs for deploy1") assert.Contains(t, out, "Checking out 09b519cb as ci_test_pipeline...") assert.Contains(t, out, "For example you might run an update here or install a build dependency") assert.Contains(t, out, "$ echo \"Or perhaps you might print out some debugging details\"") @@ -59,10 +66,10 @@ func Test_ciTrace(t *testing.T) { }, }, { - desc: "explicit sha:job", - args: []string{"origin", "09b519cba018b707c98fc56e37df15806d89d866:deploy1"}, + desc: "explicit branch:job", + args: []string{"origin", "ci_test_pipeline:deploy1"}, assertContains: func(t *testing.T, out string) { - assert.Contains(t, out, "Showing logs for deploy1 job #62958479") + assert.Contains(t, out, "Showing logs for deploy1") assert.Contains(t, out, "Checking out 09b519cb as ci_test_pipeline...") assert.Contains(t, out, "For example you might do some cleanup here") assert.Contains(t, out, "Job succeeded") diff --git a/cmd/ciView.go b/cmd/ciView.go new file mode 100644 index 00000000..55976f2f --- /dev/null +++ b/cmd/ciView.go @@ -0,0 +1,370 @@ +package cmd + +import ( + "fmt" + "log" + "runtime/debug" + "strings" + "time" + + "github.com/gdamore/tcell" + "github.com/pkg/errors" + "github.com/rivo/tview" + "github.com/spf13/cobra" + + "github.com/xanzy/go-gitlab" + + "github.com/zaquestion/lab/internal/git" + lab "github.com/zaquestion/lab/internal/gitlab" +) + +// ciViewCmd represents the ci command +var ciViewCmd = &cobra.Command{ + Use: "view [remote]", + Short: "(beta) render the CI Pipeline to the terminal", + Long: `This feature is currently under development and only supports +viewing jobs. In the future we hope to support starting jobs and jumping to +logs. + +Feedback Welcome!: https://github.com/zaquestion/lab/issues/74`, + Run: func(cmd *cobra.Command, args []string) { + a := tview.NewApplication() + defer recoverPanic(a) + var ( + remote string + ) + + branch, err := git.CurrentBranch() + if err != nil { + log.Fatal(err) + } + + remote = determineSourceRemote(branch) + if len(args) > 0 { + ok, err := git.IsRemote(args[0]) + if err != nil || !ok { + log.Fatal(args[0], "is not a remote:", err) + } + remote = args[0] + } + + // See if we're in a git repo or if global is set to determine + // if this should be a personal snippet + rn, err := git.PathWithNameSpace(remote) + if err != nil { + log.Fatal(err) + } + project, err := lab.FindProject(rn) + if err != nil { + log.Fatal(err) + } + root := tview.NewPages() + root.SetBorderPadding(1, 1, 2, 2) + + boxes = make(map[string]*tview.TextView) + jobsCh := make(chan []*gitlab.Job) + + go updateJobs(a, jobsCh, project.ID, branch) + go refreshScreen(a) + if err := a.SetRoot(root, true).SetBeforeDrawFunc(jobsView(a, jobsCh, root)).SetAfterDrawFunc(connectJobsView(a)).Run(); err != nil { + log.Fatal(err) + } + }, +} + +var ( + jobs []*gitlab.Job + boxes map[string]*tview.TextView +) + +func jobsView(app *tview.Application, jobsCh chan []*gitlab.Job, root *tview.Pages) func(screen tcell.Screen) bool { + return func(screen tcell.Screen) bool { + defer recoverPanic(app) + screen.Clear() + select { + case jobs = <-jobsCh: + default: + if len(jobs) == 0 { + jobs = <-jobsCh + } + } + px, _, maxX, maxY := root.GetInnerRect() + var ( + stages = 0 + lastStage = "" + ) + // get the number of stages + for _, j := range jobs { + if j.Stage != lastStage { + lastStage = j.Stage + stages++ + } + } + lastStage = "" + var ( + rowIdx = 0 + stageIdx = 0 + maxTitle = 20 + ) + for _, j := range jobs { + boxX := px + (maxX / stages * stageIdx) + if j.Stage != lastStage { + rowIdx = 0 + stageIdx++ + lastStage = j.Stage + key := "stage-" + j.Stage + + x, y, w, h := boxX, maxY/6-4, maxTitle+2, 3 + b := box(root, key, x, y, w, h) + b.SetText(strings.Title(j.Stage)) + b.SetTextAlign(tview.AlignCenter) + + } + } + lastStage = jobs[0].Stage + rowIdx = 0 + stageIdx = 0 + for _, j := range jobs { + if j.Stage != lastStage { + rowIdx = 0 + lastStage = j.Stage + stageIdx++ + } + boxX := px + (maxX / stages * stageIdx) + + key := "jobs-" + j.Name + x, y, w, h := boxX, maxY/6+(rowIdx*5), maxTitle+2, 4 + b := box(root, key, x, y, w, h) + b.SetTitle(j.Name) + // The scope of jobs to show, one or array of: created, pending, running, + // failed, success, canceled, skipped; showing all jobs if none provided + var statChar rune + switch j.Status { + case "success": + b.SetBorderColor(tcell.ColorGreen) + statChar = '✔' + case "failed": + b.SetBorderColor(tcell.ColorRed) + statChar = '✘' + case "running": + b.SetBorderColor(tcell.ColorBlue) + statChar = '●' + case "pending": + b.SetBorderColor(tcell.ColorYellow) + statChar = '●' + case "manual": + b.SetBorderColor(tcell.ColorGrey) + statChar = '●' + } + // retryChar := '⟳' + title := fmt.Sprintf("%c %s", statChar, j.Name) + // trim the suffix if it matches the stage, I've seen + // the pattern in 2 different places to handle + // different stages for the same service and it tends + // to make the title spill over the max + title = strings.TrimSuffix(title, ":"+j.Stage) + b.SetTitle(title) + // tview default aligns center, which is nice, but if + // the title is too long we want to bias towards seeing + // the beginning of it + if tview.StringWidth(title) > maxTitle { + b.SetTitleAlign(tview.AlignLeft) + } + if j.StartedAt != nil { + end := time.Now() + if j.FinishedAt != nil { + end = *j.FinishedAt + } + b.SetText("\n" + fmtDuration(end.Sub(*j.StartedAt))) + b.SetTextAlign(tview.AlignRight) + } + rowIdx++ + + } + return false + } +} +func fmtDuration(d time.Duration) string { + d = d.Round(time.Second) + m := d / time.Minute + d -= m * time.Minute + s := d / time.Second + return fmt.Sprintf("%02dm %02ds", m, s) +} +func box(root *tview.Pages, key string, x, y, w, h int) *tview.TextView { + //fmt.Printf("key: %s, x: %d, y: %d, w: %d, h: %d\n", key, x, y, w, h) + b, ok := boxes[key] + if !ok { + b = tview.NewTextView() + b.SetBorder(true) + boxes[key] = b + } + b.SetRect(x, y, w, h) + root.AddPage(key, b, false, true) + return b +} + +func recoverPanic(app *tview.Application) { + if r := recover(); r != nil { + app.Stop() + log.Fatalf("%s\n%s\n", r, string(debug.Stack())) + } +} + +func refreshScreen(app *tview.Application) { + defer recoverPanic(app) + for { + app.Draw() + time.Sleep(time.Second * 1) + } +} + +func updateJobs(app *tview.Application, jobsCh chan []*gitlab.Job, pid interface{}, branch string) { + defer recoverPanic(app) + for { + jobs, err := lab.CIJobs(pid, branch) + if len(jobs) == 0 || err != nil { + app.Stop() + log.Fatal(errors.Wrap(err, "failed to find ci jobs")) + } + jobsCh <- latestJobs(jobs) + time.Sleep(time.Second * 5) + } +} + +func connectJobsView(app *tview.Application) func(screen tcell.Screen) { + return func(screen tcell.Screen) { + defer recoverPanic(app) + err := connectJobs(screen, jobs, boxes) + if err != nil { + app.Stop() + log.Fatal(err) + } + } +} + +func connectJobs(screen tcell.Screen, jobs []*gitlab.Job, boxes map[string]*tview.TextView) error { + for i, j := range jobs { + if _, ok := boxes["jobs-"+j.Name]; !ok { + return errors.Errorf("jobs-%s not found at index: %d", jobs[i].Name, i) + } + } + var padding int + // find the abount of space between two jobs is adjacent stages + for i, k := 0, 1; k < len(jobs); i, k = i+1, k+1 { + if jobs[i].Stage == jobs[k].Stage { + continue + } + x1, _, w, _ := boxes["jobs-"+jobs[i].Name].GetRect() + x2, _, _, _ := boxes["jobs-"+jobs[k].Name].GetRect() + stageWidth := x2 - x1 - w + switch { + case stageWidth <= 3: + padding = 1 + case stageWidth <= 6: + padding = 2 + case stageWidth > 6: + padding = 3 + } + } + for i, k := 0, 1; k < len(jobs); i, k = i+1, k+1 { + v1 := boxes["jobs-"+jobs[i].Name] + v2 := boxes["jobs-"+jobs[k].Name] + connect(screen, v1.Box, v2.Box, padding, + jobs[i].Stage == jobs[0].Stage, // is first stage? + jobs[i].Stage == jobs[len(jobs)-1].Stage) // is last stage? + } + return nil +} + +func connect(screen tcell.Screen, v1 *tview.Box, v2 *tview.Box, padding int, firstStage, lastStage bool) { + x1, y1, w, h := v1.GetRect() + x2, y2, _, _ := v2.GetRect() + + dx, dy := x2-x1, y2-y1 + + p := padding + + // drawing stages + if dx != 0 { + hline(screen, x1+w, y2+h/2, dx-w) + if dy != 0 { + // dy != 0 means the last stage had multple jobs + screen.SetContent(x1+w+p-1, y2+h/2, '┳', nil, tcell.StyleDefault) + } + return + } + + // Drawing a job in the same stage + // left of view + if !firstStage { + if r, _, _, _ := screen.GetContent(x2-p, y1+h/2); r == '┗' { + screen.SetContent(x2-p, y1+h/2, '┣', nil, tcell.StyleDefault) + } else { + screen.SetContent(x2-p, y1+h/2, '┳', nil, tcell.StyleDefault) + } + + for i := 1; i < p; i++ { + screen.SetContent(x2-i, y2+h/2, '━', nil, tcell.StyleDefault) + } + screen.SetContent(x2-p, y2+h/2, '┗', nil, tcell.StyleDefault) + + vline(screen, x2-p, y1+h-1, dy-1) + } + // right of view + if !lastStage { + if r, _, _, _ := screen.GetContent(x2+w+p-1, y1+h/2); r == '┛' { + screen.SetContent(x2+w+p-1, y1+h/2, '┫', nil, tcell.StyleDefault) + } + for i := 0; i < p-1; i++ { + screen.SetContent(x2+w+i, y2+h/2, '━', nil, tcell.StyleDefault) + } + screen.SetContent(x2+w+p-1, y2+h/2, '┛', nil, tcell.StyleDefault) + + vline(screen, x2+w+p-1, y1+h-1, dy-1) + } +} + +func hline(screen tcell.Screen, x, y, l int) { + for i := 0; i < l; i++ { + screen.SetContent(x+i, y, '━', nil, tcell.StyleDefault) + } +} + +func vline(screen tcell.Screen, x, y, l int) { + for i := 0; i < l; i++ { + screen.SetContent(x, y+i, '┃', nil, tcell.StyleDefault) + } +} + +// latestJobs returns a list of unique jobs favoring the last stage+name +// version of a job in the provided list +func latestJobs(jobs []*gitlab.Job) []*gitlab.Job { + var ( + lastJob = make(map[string]*gitlab.Job, len(jobs)) + dupIdx = -1 + ) + for i, j := range jobs { + _, ok := lastJob[j.Stage+j.Name] + if dupIdx == -1 && ok { + dupIdx = i + } + // always want the latest job + lastJob[j.Stage+j.Name] = j + } + if dupIdx == -1 { + dupIdx = len(jobs) + } + // first duplicate marks where retries begin + outJobs := make([]*gitlab.Job, dupIdx) + for i := range outJobs { + j := jobs[i] + outJobs[i] = lastJob[j.Stage+j.Name] + } + + return outJobs +} + +func init() { + ciCmd.AddCommand(ciViewCmd) +} diff --git a/cmd/ciView_test.go b/cmd/ciView_test.go new file mode 100644 index 00000000..0d11ea83 --- /dev/null +++ b/cmd/ciView_test.go @@ -0,0 +1,664 @@ +package cmd + +import ( + "strconv" + "testing" + "time" + + "github.com/gdamore/tcell" + "github.com/rivo/tview" + "github.com/stretchr/testify/assert" + "github.com/xanzy/go-gitlab" +) + +func assertScreen(t *testing.T, screen tcell.Screen, expected []string) { + sx, sy := screen.Size() + assert.Equal(t, len(expected), sy) + assert.Equal(t, len([]rune(expected[0])), sx) + actual := make([]string, sy) + for y, str := range expected { + runes := make([]rune, len(str)) + row := []rune(str) + for x, expectedRune := range row { + r, _, _, _ := screen.GetContent(x, y) + runes[x] = r + assert.Equal(t, expectedRune, r, "%s != %s at (%d,%d)", + strconv.QuoteRune(expectedRune), strconv.QuoteRune(r), x, y) + } + actual[y] = string(runes) + } + t.Logf("Expected w: %d l: %d", len([]rune(expected[0])), len(expected)) + for _, str := range expected { + t.Log(str) + } + t.Logf("Actual w: %d l: %d", len([]rune(actual[0])), len(actual)) + for _, str := range actual { + t.Log(str) + } +} + +func Test_line(t *testing.T) { + tests := []struct { + desc string + lineF func(screen tcell.Screen, x, y, l int) + x, y, l int + expected []string + }{ + { + "hline", + hline, + 2, 2, 5, + []string{ + " ", + " ", + " ━━━━━ ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + }, + }, + { + "hline overflow", + hline, + 2, 2, 10, + []string{ + " ", + " ", + " ━━━━━━━━", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + }, + }, + { + "vline", + vline, + 2, 2, 5, + []string{ + " ", + " ", + " ┃ ", + " ┃ ", + " ┃ ", + " ┃ ", + " ┃ ", + " ", + " ", + " ", + }, + }, + { + "vline overflow", + vline, + 2, 2, 10, + []string{ + " ", + " ", + " ┃ ", + " ┃ ", + " ┃ ", + " ┃ ", + " ┃ ", + " ┃ ", + " ┃ ", + " ┃ ", + }, + }, + } + + for _, test := range tests { + screen := tcell.NewSimulationScreen("UTF-8") + err := screen.Init() + if err != nil { + t.Fatal(err) + } + // Set screen to matrix size + screen.SetSize(len(test.expected), len(test.expected[0])) + + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + test.lineF(screen, test.x, test.y, test.l) + screen.Show() + assertScreen(t, screen, test.expected) + }) + } +} + +func testbox(x, y, w, h int) *tview.TextView { + b := tview.NewTextView() + b.SetBorder(true) + b.SetRect(x, y, w, h) + return b +} + +func Test_connect(t *testing.T) { + tests := []struct { + desc string + b1, b2 *tview.Box + first, last bool + expected []string + }{ + { + "first stage", + testbox(2, 1, 3, 3).Box, testbox(2, 5, 3, 3).Box, + true, false, + []string{ + " ", + " ┌─┐ ", + " │ │ ", + " └─┘ ┃ ", + " ┃ ", + " ┌─┐ ┃ ", + " │ │━┛ ", + " └─┘ ", + " ", + " ", + }, + }, + { + "last stage", + testbox(5, 1, 3, 3).Box, testbox(5, 5, 3, 3).Box, + false, true, + []string{ + " ", + " ┌─┐ ", + " ┳ │ │ ", + " ┃ └─┘ ", + " ┃ ", + " ┃ ┌─┐ ", + " ┗━│ │ ", + " └─┘ ", + " ", + " ", + }, + }, + { + "cross stage", + testbox(1, 1, 3, 3).Box, testbox(7, 1, 3, 3).Box, + false, false, + []string{ + " ", + " ┌─┐ ┌─┐", + " │ │━━━│ │", + " └─┘ └─┘", + " ", + " ", + " ", + " ", + " ", + " ", + }, + }, + } + + for _, test := range tests { + screen := tcell.NewSimulationScreen("UTF-8") + err := screen.Init() + if err != nil { + t.Fatal(err) + } + // Set screen to matrix size + screen.SetSize(len(test.expected), len(test.expected[0])) + + test.b1.Draw(screen) + test.b2.Draw(screen) + + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + connect(screen, test.b1, test.b2, 2, test.first, test.last) + screen.Show() + assertScreen(t, screen, test.expected) + }) + } +} + +func Test_connectJobs(t *testing.T) { + expected := []string{ + " ", + " ┌─┐ ┌─┐ ┌─┐ ", + " │ │┳━┳│ │┳━┳│ │ ", + " └─┘┃ ┃└─┘┃ ┃└─┘ ", + " ┃ ┃ ┃ ┃ ", + " ┌─┐┃ ┃┌─┐┃ ┃┌─┐ ", + " │ │┫ ┣│ │┫ ┗│ │ ", + " └─┘┃ ┃└─┘┃ └─┘ ", + " ┃ ┃ ┃ ", + " ┌─┐┃ ┃┌─┐┃ ", + " │ │┫ ┗│ │┛ ", + " └─┘┃ └─┘ ", + " ┃ ", + " ┌─┐┃ ", + " │ │┛ ", + " └─┘ ", + " ", + } + jobs := []*gitlab.Job{ + &gitlab.Job{ + Name: "stage1-job1", + Stage: "stage1", + }, + &gitlab.Job{ + Name: "stage1-job2", + Stage: "stage1", + }, + &gitlab.Job{ + Name: "stage1-job3", + Stage: "stage1", + }, + &gitlab.Job{ + Name: "stage1-job4", + Stage: "stage1", + }, + &gitlab.Job{ + Name: "stage2-job1", + Stage: "stage2", + }, + &gitlab.Job{ + Name: "stage2-job2", + Stage: "stage2", + }, + &gitlab.Job{ + Name: "stage2-job3", + Stage: "stage2", + }, + &gitlab.Job{ + Name: "stage3-job1", + Stage: "stage3", + }, + &gitlab.Job{ + Name: "stage3-job2", + Stage: "stage3", + }, + } + boxes := map[string]*tview.TextView{ + "jobs-stage1-job1": testbox(1, 1, 3, 3), + "jobs-stage1-job2": testbox(1, 5, 3, 3), + "jobs-stage1-job3": testbox(1, 9, 3, 3), + "jobs-stage1-job4": testbox(1, 13, 3, 3), + + "jobs-stage2-job1": testbox(7, 1, 3, 3), + "jobs-stage2-job2": testbox(7, 5, 3, 3), + "jobs-stage2-job3": testbox(7, 9, 3, 3), + + "jobs-stage3-job1": testbox(13, 1, 3, 3), + "jobs-stage3-job2": testbox(13, 5, 3, 3), + } + + screen := tcell.NewSimulationScreen("UTF-8") + err := screen.Init() + if err != nil { + t.Fatal(err) + } + // Set screen to matrix size + screen.SetSize(len(expected), len(expected[0])) + + for _, b := range boxes { + b.Draw(screen) + } + + err = connectJobs(screen, jobs, boxes) + if err != nil { + t.Fatal(err) + } + + screen.Show() + assertScreen(t, screen, expected) +} + +func Test_connectJobsNegative(t *testing.T) { + tests := []struct { + desc string + jobs []*gitlab.Job + boxes map[string]*tview.TextView + }{ + { + "determinePadding -- first job missing", + []*gitlab.Job{ + &gitlab.Job{ + Name: "stage1-job1", + Stage: "stage1", + }, + }, + map[string]*tview.TextView{ + "jobs-stage2-job1": testbox(1, 5, 3, 3), + "jobs-stage2-job2": testbox(1, 9, 3, 3), + }, + }, + { + "determinePadding -- second job missing", + []*gitlab.Job{ + &gitlab.Job{ + Name: "stage1-job1", + Stage: "stage1", + }, + &gitlab.Job{ + Name: "stage2-job1", + Stage: "stage2", + }, + &gitlab.Job{ + Name: "stage2-job2", + Stage: "stage2", + }, + }, + map[string]*tview.TextView{ + "jobs-stage1-job1": testbox(1, 1, 3, 3), + "jobs-stage2-job2": testbox(1, 9, 3, 3), + }, + }, + { + "connect -- third job missing", + []*gitlab.Job{ + &gitlab.Job{ + Name: "stage1-job1", + Stage: "stage1", + }, + &gitlab.Job{ + Name: "stage2-job1", + Stage: "stage2", + }, + &gitlab.Job{ + Name: "stage2-job2", + Stage: "stage2", + }, + }, + map[string]*tview.TextView{ + "jobs-stage1-job1": testbox(1, 1, 3, 3), + "jobs-stage2-job1": testbox(1, 5, 3, 3), + }, + }, + { + "connect -- third job missing", + []*gitlab.Job{ + &gitlab.Job{ + Name: "stage1-job1", + Stage: "stage1", + }, + &gitlab.Job{ + Name: "stage2-job1", + Stage: "stage2", + }, + &gitlab.Job{ + Name: "stage2-job2", + Stage: "stage2", + }, + }, + map[string]*tview.TextView{ + "jobs-stage1-job1": testbox(1, 1, 3, 3), + "jobs-stage2-job1": testbox(1, 5, 3, 3), + }, + }, + } + for _, test := range tests { + screen := tcell.NewSimulationScreen("UTF-8") + err := screen.Init() + if err != nil { + t.Fatal(err) + } + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + assert.Error(t, connectJobs(screen, test.jobs, test.boxes)) + + }) + } +} + +func Test_jobsView(t *testing.T) { + expected := []string{ + " ┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐ ", + " │ Stage1 │ │ Stage2 │ │ Stage3 │ ", + " └────────────────────┘ └────────────────────┘ └────────────────────┘ ", + " ", + " ┌✔ stage1-job1-reall…┐ ┌───● stage2-job1────┐ ┌───● stage3-job1────┐ ", + " │ │ │ │ │ │ ", + " │ 01m 01s│━┳━━┳━│ │━┳━━┳━│ │ ", + " └────────────────────┘ ┃ ┃ └────────────────────┘ ┃ ┃ └────────────────────┘ ", + " ┃ ┃ ┃ ┃ ", + " ┌───✔ stage1-job2────┐ ┃ ┃ ┌───● stage2-job2────┐ ┃ ┃ ┌───● stage3-job2────┐ ", + " │ │ ┃ ┃ │ │ ┃ ┃ │ │ ", + " │ │━┫ ┣━│ │━┫ ┗━│ │ ", + " └────────────────────┘ ┃ ┃ └────────────────────┘ ┃ └────────────────────┘ ", + " ┃ ┃ ┃ ", + " ┌───✔ stage1-job3────┐ ┃ ┃ ┌───● stage2-job3────┐ ┃ ", + " │ │ ┃ ┃ │ │ ┃ ", + " │ │━┫ ┗━│ │━┛ ", + " └────────────────────┘ ┃ └────────────────────┘ ", + " ┃ ", + " ┌───✘ stage1-job4────┐ ┃ ", + " │ │ ┃ ", + " │ │━┛ ", + " └────────────────────┘ ", + " ", + " ", + " ", + } + now := time.Now() + past := now.Add(time.Second * -61) + jobs := []*gitlab.Job{ + &gitlab.Job{ + Name: "stage1-job1-really-long", + Stage: "stage1", + Status: "success", + StartedAt: &past, // relies on test running in <1s we'll see how it goes + FinishedAt: &now, + }, + &gitlab.Job{ + Name: "stage1-job2", + Stage: "stage1", + Status: "success", + }, + &gitlab.Job{ + Name: "stage1-job3", + Stage: "stage1", + Status: "success", + }, + &gitlab.Job{ + Name: "stage1-job4", + Stage: "stage1", + Status: "failed", + }, + &gitlab.Job{ + Name: "stage2-job1", + Stage: "stage2", + Status: "running", + }, + &gitlab.Job{ + Name: "stage2-job2", + Stage: "stage2", + Status: "running", + }, + &gitlab.Job{ + Name: "stage2-job3", + Stage: "stage2", + Status: "pending", + }, + &gitlab.Job{ + Name: "stage3-job1", + Stage: "stage3", + Status: "manual", + }, + &gitlab.Job{ + Name: "stage3-job2", + Stage: "stage3", + Status: "manual", + }, + } + + boxes = make(map[string]*tview.TextView) + jobsCh := make(chan []*gitlab.Job) + root := tview.NewPages() + root.SetBorderPadding(1, 1, 2, 2) + + screen := tcell.NewSimulationScreen("UTF-8") + err := screen.Init() + if err != nil { + t.Fatal(err) + } + // Set screen to matrix size + screen.SetSize(len([]rune(expected[0])), len(expected)) + w, h := screen.Size() + root.SetRect(0, 0, w, h) + + go func() { + jobsCh <- jobs + }() + jobsView(nil, jobsCh, root)(screen) + root.Draw(screen) + connectJobsView(nil)(screen) + screen.Sync() + assertScreen(t, screen, expected) +} + +func Test_latestJobs(t *testing.T) { + tests := []struct { + desc string + jobs []*gitlab.Job + expected []*gitlab.Job + }{ + { + desc: "no newer jobs", + jobs: []*gitlab.Job{ + &gitlab.Job{ + ID: 1, + Name: "stage1-job1", + Stage: "stage1", + }, + &gitlab.Job{ + ID: 2, + Name: "stage1-job2", + Stage: "stage1", + }, + &gitlab.Job{ + ID: 3, + Name: "stage1-job3", + Stage: "stage1", + }, + }, + expected: []*gitlab.Job{ + &gitlab.Job{ + ID: 1, + Name: "stage1-job1", + Stage: "stage1", + }, + &gitlab.Job{ + ID: 2, + Name: "stage1-job2", + Stage: "stage1", + }, + &gitlab.Job{ + ID: 3, + Name: "stage1-job3", + Stage: "stage1", + }, + }, + }, + { + desc: "1 newer", + jobs: []*gitlab.Job{ + &gitlab.Job{ + ID: 1, + Name: "stage1-job1", + Stage: "stage1", + }, + &gitlab.Job{ + ID: 2, + Name: "stage1-job2", + Stage: "stage1", + }, + &gitlab.Job{ + ID: 3, + Name: "stage1-job3", + Stage: "stage1", + }, + &gitlab.Job{ + ID: 4, + Name: "stage1-job1", + Stage: "stage1", + }, + }, + expected: []*gitlab.Job{ + &gitlab.Job{ + ID: 4, + Name: "stage1-job1", + Stage: "stage1", + }, + &gitlab.Job{ + ID: 2, + Name: "stage1-job2", + Stage: "stage1", + }, + &gitlab.Job{ + ID: 3, + Name: "stage1-job3", + Stage: "stage1", + }, + }, + }, + { + desc: "2 newer", + jobs: []*gitlab.Job{ + &gitlab.Job{ + ID: 1, + Name: "stage1-job1", + Stage: "stage1", + }, + &gitlab.Job{ + ID: 2, + Name: "stage1-job2", + Stage: "stage1", + }, + &gitlab.Job{ + ID: 3, + Name: "stage1-job3", + Stage: "stage1", + }, + &gitlab.Job{ + ID: 4, + Name: "stage1-job3", + Stage: "stage1", + }, + &gitlab.Job{ + ID: 5, + Name: "stage1-job1", + Stage: "stage1", + }, + }, + expected: []*gitlab.Job{ + &gitlab.Job{ + ID: 5, + Name: "stage1-job1", + Stage: "stage1", + }, + &gitlab.Job{ + ID: 2, + Name: "stage1-job2", + Stage: "stage1", + }, + &gitlab.Job{ + ID: 4, + Name: "stage1-job3", + Stage: "stage1", + }, + }, + }, + } + + for _, test := range tests { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + jobs := latestJobs(test.jobs) + assert.Equal(t, test.expected, jobs) + }) + } +} diff --git a/cmd/mrCreate.go b/cmd/mrCreate.go index b8883769..1d4afe7c 100644 --- a/cmd/mrCreate.go +++ b/cmd/mrCreate.go @@ -126,14 +126,7 @@ func determineSourceRemote(branch string) string { return r } - // If not, check if the fork is named after the user - _, err = gitconfig.Local("remote." + lab.User() + ".url") - if err == nil { - return lab.User() - } - - // If not, default to origin - return "origin" + return forkRemote } func mrText(base, head, sourceRemote, forkedFromRemote string) (string, error) { diff --git a/cmd/root.go b/cmd/root.go index 53f90115..ef0c06e1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -10,7 +10,6 @@ import ( "strings" "syscall" "text/template" - "unicode" "github.com/pkg/errors" "github.com/spf13/cobra" @@ -33,21 +32,16 @@ var RootCmd = &cobra.Command{ }, } -func trimRightSpace(s string) string { - return strings.TrimRightFunc(s, unicode.IsSpace) -} - func rpad(s string, padding int) string { template := fmt.Sprintf("%%-%ds", padding) return fmt.Sprintf(template, s) } var templateFuncs = template.FuncMap{ - "trimTrailingWhitespaces": trimRightSpace, "rpad": rpad, } -const labUsageTmpl = `{{range .Commands}}{{if (and (or .IsAvailableCommand (ne .Name "help")) (and (ne .Name "clone") (ne .Name "version") (ne .Name "ci")))}} +const labUsageTmpl = `{{range .Commands}}{{if (and (or .IsAvailableCommand (ne .Name "help")) (and (ne .Name "clone") (ne .Name "version")))}} {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}` func labUsageFormat(c *cobra.Command) string { @@ -64,11 +58,16 @@ func labUsageFormat(c *cobra.Command) string { } func helpFunc(cmd *cobra.Command, args []string) { + // When help func is called from the help command args will be + // populated. When help is called with cmd.Help(), the args are not + // passed through, so we pick them up ourselves here if len(args) == 0 { args = os.Args[1:] } rootCmd := cmd.Root() - if cmd, _, err := rootCmd.Find(args); err == nil && cmd != rootCmd { + // Show help for sub/commands -- any commands that isn't "lab" or "help" + if cmd, _, err := rootCmd.Find(args); err == nil && + cmd != rootCmd && strings.Split(cmd.Use, " ")[0] != "help" { // Cobra will check parent commands for a helpFunc and we only // want the root command to actually use this custom help func. // Here we trick cobra into thinking that there is no help func @@ -93,7 +92,7 @@ func helpFunc(cmd *cobra.Command, args []string) { } var helpCmd = &cobra.Command{ - Use: "help", + Use: "help [command [subcommand...]]", Short: "Show the help for lab", Long: ``, Run: helpFunc, diff --git a/cmd/root_test.go b/cmd/root_test.go index fe84f517..5e96b4a7 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -11,6 +11,7 @@ import ( "path" "path/filepath" "strconv" + "strings" "testing" "time" @@ -100,10 +101,10 @@ Date: Sun Apr 1 19:40:47 2018 -0700 func TestRootNoArg(t *testing.T) { cmd := exec.Command("../lab_bin") b, _ := cmd.CombinedOutput() - assert.Contains(t, string(b), "usage: git [--version] [--help] [-C ] [-c name=value]") + assert.Contains(t, string(b), "usage: git [--version] [--help] [-C ]") assert.Contains(t, string(b), `These GitLab commands are provided by lab: - fork Fork a remote repository on GitLab and add as remote`) + ci Work with GitLab CI pipelines and jobs`) } func TestRootVersion(t *testing.T) { @@ -133,28 +134,40 @@ func TestRootVersion(t *testing.T) { func TestGitHelp(t *testing.T) { cmd := exec.Command("../lab_bin") - expected, _ := cmd.CombinedOutput() + b, _ := cmd.CombinedOutput() + expected := string(b) + expected = expected[:strings.LastIndex(strings.TrimSpace(expected), "\n")] tests := []struct { - Cmd string + Cmds []string }{ { - Cmd: "--help", + Cmds: []string{"--", "--help"}, + }, + { + Cmds: []string{"--", "-h"}, + }, + { + Cmds: []string{"help"}, }, { - Cmd: "help", + Cmds: []string{""}, }, } for _, test := range tests { - t.Run(test.Cmd, func(t *testing.T) { - cmd := exec.Command("../lab_bin") + t.Run(test.Cmds[len(test.Cmds)-1], func(t *testing.T) { + cmd := exec.Command("../lab_bin", test.Cmds...) b, _ := cmd.CombinedOutput() - assert.Equal(t, expected, b) - assert.Contains(t, string(b), "usage: git [--version] [--help] [-C ] [-c name=value]") - assert.Contains(t, string(b), `These GitLab commands are provided by lab: + res := string(b) + res = res[:strings.LastIndex(strings.TrimSpace(res), "\n")] + t.Log(expected) + t.Log(res) + assert.Equal(t, expected, res) + assert.Contains(t, res, "usage: git [--version] [--help] [-C ]") + assert.Contains(t, res, `These GitLab commands are provided by lab: - fork Fork a remote repository on GitLab and add as remote`) + ci Work with GitLab CI pipelines and jobs`) }) } } diff --git a/cmd/snippetList.go b/cmd/snippetList.go index 7a08bb57..4db248ea 100644 --- a/cmd/snippetList.go +++ b/cmd/snippetList.go @@ -32,9 +32,7 @@ var snippetListCmd = &cobra.Command{ // if this should be a personal snippet rn, _ := git.PathWithNameSpace(remote) if global || rn == "" { - opts := gitlab.ListSnippetsOptions{ - ListOptions: listOpts, - } + opts := gitlab.ListSnippetsOptions(listOpts) snips, err := lab.SnippetList(&opts) if err != nil { log.Fatal(err) @@ -49,9 +47,7 @@ var snippetListCmd = &cobra.Command{ if err != nil { log.Fatal(err) } - opts := gitlab.ListProjectSnippetsOptions{ - ListOptions: listOpts, - } + opts := gitlab.ListProjectSnippetsOptions(listOpts) snips, err := lab.ProjectSnippetList(project.ID, &opts) if err != nil { log.Fatal(err) diff --git a/internal/git/git.go b/internal/git/git.go index 3f849ba2..e972f082 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -79,7 +79,6 @@ func CommentChar() string { if err == nil { return char } - return "#" } @@ -219,14 +218,3 @@ func InsideGitRepo() bool { out, _ := cmd.CombinedOutput() return bytes.Contains(out, []byte("true\n")) } - -// Sha returns the git sha for a given ref -func Sha(ref string) (string, error) { - cmd := New("rev-parse", ref) - cmd.Stdout = nil - sha, err := cmd.Output() - if err != nil { - return "", err - } - return strings.TrimSpace(string(sha)), nil -} diff --git a/internal/gitlab/gitlab.go b/internal/gitlab/gitlab.go index 17de533b..29ae1bed 100644 --- a/internal/gitlab/gitlab.go +++ b/internal/gitlab/gitlab.go @@ -12,7 +12,6 @@ import ( "net/http" "os" "path/filepath" - "sort" "strings" "github.com/pkg/errors" @@ -364,65 +363,67 @@ func ProjectDelete(pid interface{}) error { // CIJobs returns a list of jobs in a pipeline for a given sha. The jobs are // returned sorted by their CreatedAt time -func CIJobs(pid interface{}, sha string) ([]gitlab.Job, error) { - pipelines, _, err := lab.Pipelines.ListProjectPipelines(pid) - if err != nil { +func CIJobs(pid interface{}, branch string) ([]*gitlab.Job, error) { + pipelines, _, err := lab.Pipelines.ListProjectPipelines(pid, &gitlab.ListProjectPipelinesOptions{ + Ref: gitlab.String(branch), + }) + if len(pipelines) == 0 || err != nil { return nil, err } - var target int - for _, p := range pipelines { - if p.Sha != sha { - continue - } - target = p.ID - break - } - jobs, _, err := lab.Jobs.ListPipelineJobs(pid, target, &gitlab.ListJobsOptions{}) + target := pipelines[0].ID + jobs, _, err := lab.Jobs.ListPipelineJobs(pid, target, &gitlab.ListJobsOptions{ + ListOptions: gitlab.ListOptions{ + PerPage: 500, + }, + }) if err != nil { return nil, err } - sort.Sort(ciJobs(jobs)) return jobs, nil } -type ciJobs []gitlab.Job - -func (js ciJobs) Len() int { - return len(js) -} - -func (js ciJobs) Less(i, j int) bool { - return js[i].CreatedAt.Before(*js[j].CreatedAt) -} - -func (js ciJobs) Swap(i, j int) { - js[i], js[j] = js[j], js[i] -} - // CITrace searches by name for a job and returns its trace file. The trace is // static so may only be a portion of the logs if the job is till running. If -// no name is provided the most recent running job -func CITrace(pid interface{}, sha, name string) (io.Reader, *gitlab.Job, error) { - jobs, err := CIJobs(pid, sha) - if err != nil { +// no name is provided job is picked using the first available: +// 1. Last Running Job +// 2. First Pending Job +// 3. Last Job in Pipeline +func CITrace(pid interface{}, branch, name string) (io.Reader, *gitlab.Job, error) { + jobs, err := CIJobs(pid, branch) + if len(jobs) == 0 || err != nil { return nil, nil, err } var ( - job = jobs[len(jobs)-1] + job *gitlab.Job + lastRunning *gitlab.Job + firstPending *gitlab.Job ) + for _, j := range jobs { if j.Status == "running" { - job = j + lastRunning = j + } + if j.Status == "pending" && firstPending == nil { + firstPending = j } if j.Name == name { job = j - break + // don't break because there may be a newer version of the job } } + if job == nil { + job = lastRunning + } + if job == nil { + job = firstPending + } + if job == nil { + job = jobs[len(jobs)-1] + } r, _, err := lab.Jobs.GetTraceFile(pid, job.ID) if err != nil { - return nil, &job, err + return nil, job, err } - return r, &job, err + return r, job, err } diff --git a/testdata/test.git/config b/testdata/test.git/config index 6142a18b..e6529639 100644 --- a/testdata/test.git/config +++ b/testdata/test.git/config @@ -46,3 +46,6 @@ [remote "origin-subfolder-custom-port"] url = ssh://git@git.mydomain.net:12345/zaquestion/sub/folder/test.git fetch = +refs/heads/*:refs/remotes/origin-subfolder-custom-port/* +[branch "ci_test_pipeline"] + remote = origin + merge = refs/heads/ci_test_pipeline