github.com/matthewdale/lab@v0.14.0/cmd/ci_view.go (about)

     1  package cmd
     2  
     3  import (
     4  	"bufio"
     5  	"context"
     6  	"fmt"
     7  	"io"
     8  	"log"
     9  	"os"
    10  	"runtime/debug"
    11  	"strings"
    12  	"time"
    13  
    14  	"github.com/gdamore/tcell"
    15  	"github.com/pkg/errors"
    16  	"github.com/rivo/tview"
    17  	"github.com/spf13/cobra"
    18  
    19  	"github.com/lunixbochs/vtclean"
    20  	"github.com/xanzy/go-gitlab"
    21  
    22  	"github.com/zaquestion/lab/internal/git"
    23  	lab "github.com/zaquestion/lab/internal/gitlab"
    24  )
    25  
    26  var (
    27  	projectID int
    28  	branch    string
    29  )
    30  
    31  // ciViewCmd represents the ci command
    32  var ciViewCmd = &cobra.Command{
    33  	Use:   "view [remote]",
    34  	Short: "View, run, trace, and/or cancel CI jobs current pipeline",
    35  	Long: `Supports viewing, running, tracing, and canceling jobs
    36  
    37  'r', 'p' to run/retry/play a job -- Tab navigates modal and Enter to confirm
    38  't' to toggle trace/logs (runs in background, so you can jump in and out)
    39  'T' to toggle trace/logs by suspending application (similar to lab ci trace)
    40  'c' to cancel job
    41  
    42  Supports vi style (hjkl,Gg) bindings and arrow keys for navigating jobs and logs.
    43  
    44  Feedback Encouraged!: https://github.com/zaquestion/lab/issues`,
    45  	Run: func(cmd *cobra.Command, args []string) {
    46  		a := tview.NewApplication()
    47  		defer recoverPanic(a)
    48  		var (
    49  			remote string
    50  			err    error
    51  		)
    52  		branch, err = git.CurrentBranch()
    53  		if err != nil {
    54  			log.Fatal(err)
    55  		}
    56  
    57  		remote = determineSourceRemote(branch)
    58  		if len(args) > 0 {
    59  			ok, err := git.IsRemote(args[0])
    60  			if err != nil || !ok {
    61  				log.Fatal(args[0], " is not a remote:", err)
    62  			}
    63  			remote = args[0]
    64  		}
    65  
    66  		// See if we're in a git repo or if global is set to determine
    67  		// if this should be a personal snippet
    68  		rn, err := git.PathWithNameSpace(remote)
    69  		if err != nil {
    70  			log.Fatal(err)
    71  		}
    72  		project, err := lab.FindProject(rn)
    73  		if err != nil {
    74  			log.Fatal(err)
    75  		}
    76  		projectID = project.ID
    77  		root := tview.NewPages()
    78  		root.SetBorderPadding(1, 1, 2, 2)
    79  
    80  		boxes = make(map[string]*tview.TextView)
    81  		jobsCh := make(chan []*gitlab.Job)
    82  
    83  		var navi navigator
    84  		a.SetInputCapture(inputCapture(a, root, navi))
    85  		go updateJobs(a, jobsCh, project.ID, branch)
    86  		go refreshScreen(a, root)
    87  		if err := a.SetRoot(root, true).SetBeforeDrawFunc(jobsView(a, jobsCh, root)).SetAfterDrawFunc(connectJobsView(a)).Run(); err != nil {
    88  			log.Fatal(err)
    89  		}
    90  	},
    91  }
    92  
    93  func inputCapture(a *tview.Application, root *tview.Pages, navi navigator) func(event *tcell.EventKey) *tcell.EventKey {
    94  	return func(event *tcell.EventKey) *tcell.EventKey {
    95  		if event.Rune() == 'q' || event.Key() == tcell.KeyEscape {
    96  			switch {
    97  			case modalVisible:
    98  				modalVisible = !modalVisible
    99  				root.HidePage("yesno")
   100  			case logsVisible:
   101  				logsVisible = !logsVisible
   102  				root.HidePage("logs-" + curJob.Name)
   103  				a.Draw()
   104  			default:
   105  				a.Stop()
   106  				return nil
   107  			}
   108  		}
   109  		if !modalVisible && !logsVisible {
   110  			curJob = navi.Navigate(jobs, event)
   111  		}
   112  		switch event.Rune() {
   113  		case 'c':
   114  			job, err := lab.CICancel(projectID, curJob.ID)
   115  			if err != nil {
   116  				a.Stop()
   117  				log.Fatal(err)
   118  			}
   119  			curJob = job
   120  			root.RemovePage("logs-" + curJob.Name)
   121  			a.Draw()
   122  		case 'p', 'r':
   123  			if modalVisible {
   124  				break
   125  			}
   126  			modalVisible = true
   127  			modal := tview.NewModal().
   128  				SetText(fmt.Sprintf("Are you sure you want to run %s", curJob.Name)).
   129  				AddButtons([]string{"No", "Yes"}).
   130  				SetDoneFunc(func(buttonIndex int, buttonLabel string) {
   131  					modalVisible = false
   132  					root.RemovePage("yesno")
   133  					if buttonLabel == "No" {
   134  						a.Draw()
   135  						return
   136  					}
   137  					root.RemovePage("logs-" + curJob.Name)
   138  					a.Draw()
   139  
   140  					job, err := lab.CIPlayOrRetry(projectID, curJob.ID, curJob.Status)
   141  					if err != nil {
   142  						a.Stop()
   143  						log.Fatal(err)
   144  					}
   145  					if job != nil {
   146  						curJob = job
   147  						a.Draw()
   148  					}
   149  				})
   150  			root.AddAndSwitchToPage("yesno", modal, false)
   151  			a.Draw()
   152  			return nil
   153  		case 't':
   154  			logsVisible = !logsVisible
   155  			if !logsVisible {
   156  				root.HidePage("logs-" + curJob.Name)
   157  			}
   158  			a.Draw()
   159  			return nil
   160  		case 'T':
   161  			a.Suspend(func() {
   162  				ctx, cancel := context.WithCancel(context.Background())
   163  				go func() {
   164  					err := doTrace(ctx, os.Stdout, projectID, branch, curJob.Name)
   165  					if err != nil {
   166  						a.Stop()
   167  						log.Fatal(err)
   168  					}
   169  					if ctx.Err() == nil { // not done or cancelled
   170  						fmt.Println("\nPush <Enter> to resume ci view")
   171  					}
   172  				}()
   173  				reader := bufio.NewReader(os.Stdin)
   174  				for {
   175  					r, _, err := reader.ReadRune()
   176  					if err != io.EOF && err != nil {
   177  						a.Stop()
   178  						log.Fatal(err)
   179  					}
   180  					if r == '\n' {
   181  						cancel()
   182  						break
   183  					}
   184  				}
   185  			})
   186  			return nil
   187  		}
   188  		return event
   189  	}
   190  }
   191  
   192  var (
   193  	logsVisible, modalVisible bool
   194  	curJob                    *gitlab.Job
   195  	jobs                      []*gitlab.Job
   196  	boxes                     map[string]*tview.TextView
   197  )
   198  
   199  // navigator manages the internal state for processing tcell.EventKeys
   200  type navigator struct {
   201  	depth, idx int
   202  }
   203  
   204  // Navigate uses the ci stages as boundaries and returns the currently focused
   205  // job index after processing a *tcell.EventKey
   206  func (n *navigator) Navigate(jobs []*gitlab.Job, event *tcell.EventKey) *gitlab.Job {
   207  	stage := jobs[n.idx].Stage
   208  	prev, next := adjacentStages(jobs, stage)
   209  	switch event.Key() {
   210  	case tcell.KeyLeft:
   211  		stage = prev
   212  	case tcell.KeyRight:
   213  		stage = next
   214  	}
   215  	switch event.Rune() {
   216  	case 'h':
   217  		stage = prev
   218  	case 'l':
   219  		stage = next
   220  	}
   221  	l, u := stageBounds(jobs, stage)
   222  
   223  	switch event.Key() {
   224  	case tcell.KeyDown:
   225  		n.depth++
   226  		if n.depth > u-l {
   227  			n.depth = u - l
   228  		}
   229  	case tcell.KeyUp:
   230  		n.depth--
   231  	}
   232  	switch event.Rune() {
   233  	case 'j':
   234  		n.depth++
   235  		if n.depth > u-l {
   236  			n.depth = u - l
   237  		}
   238  	case 'k':
   239  		n.depth--
   240  	case 'g':
   241  		n.depth = 0
   242  	case 'G':
   243  		n.depth = u - l
   244  	}
   245  
   246  	if n.depth < 0 {
   247  		n.depth = 0
   248  	}
   249  	n.idx = l + n.depth
   250  	if n.idx > u {
   251  		n.idx = u
   252  	}
   253  	return jobs[n.idx]
   254  }
   255  
   256  func stageBounds(jobs []*gitlab.Job, s string) (l, u int) {
   257  	if len(jobs) <= 1 {
   258  		return 0, 0
   259  	}
   260  	p := jobs[0].Stage
   261  	for i, v := range jobs {
   262  		if v.Stage != s && u != 0 {
   263  			return
   264  		}
   265  		if v.Stage != p {
   266  			l = i
   267  			p = v.Stage
   268  		}
   269  		if v.Stage == s {
   270  			u = i
   271  		}
   272  	}
   273  	return
   274  }
   275  
   276  func adjacentStages(jobs []*gitlab.Job, s string) (p, n string) {
   277  	if len(jobs) == 0 {
   278  		return "", ""
   279  	}
   280  	p = jobs[0].Stage
   281  
   282  	for _, v := range jobs {
   283  		if v.Stage != s && n != "" {
   284  			n = v.Stage
   285  			return
   286  		}
   287  		if v.Stage == s {
   288  			n = "cur"
   289  		}
   290  		if n == "" {
   291  			p = v.Stage
   292  		}
   293  	}
   294  	n = jobs[len(jobs)-1].Stage
   295  	return
   296  }
   297  
   298  func jobsView(app *tview.Application, jobsCh chan []*gitlab.Job, root *tview.Pages) func(screen tcell.Screen) bool {
   299  	return func(screen tcell.Screen) bool {
   300  		defer recoverPanic(app)
   301  		screen.Clear()
   302  		select {
   303  		case jobs = <-jobsCh:
   304  		default:
   305  			if len(jobs) == 0 {
   306  				jobs = <-jobsCh
   307  			}
   308  		}
   309  		if curJob == nil && len(jobs) > 0 {
   310  			curJob = jobs[0]
   311  		}
   312  		if modalVisible {
   313  			return false
   314  		}
   315  		if logsVisible {
   316  			logsKey := "logs-" + curJob.Name
   317  			if !root.SwitchToPage(logsKey).HasPage(logsKey) {
   318  				tv := tview.NewTextView()
   319  				tv.SetDynamicColors(true)
   320  				tv.SetBorderPadding(0, 0, 1, 1).SetBorder(true)
   321  
   322  				go func() {
   323  					err := doTrace(context.Background(), vtclean.NewWriter(tview.ANSIIWriter(tv), true), projectID, branch, curJob.Name)
   324  					if err != nil {
   325  						app.Stop()
   326  						log.Fatal(err)
   327  					}
   328  				}()
   329  				root.AddAndSwitchToPage("logs-"+curJob.Name, tv, true)
   330  			}
   331  			return false
   332  		}
   333  		px, _, maxX, maxY := root.GetInnerRect()
   334  		var (
   335  			stages    = 0
   336  			lastStage = ""
   337  		)
   338  		// get the number of stages
   339  		for _, j := range jobs {
   340  			if j.Stage != lastStage {
   341  				lastStage = j.Stage
   342  				stages++
   343  			}
   344  		}
   345  		lastStage = ""
   346  		var (
   347  			rowIdx   = 0
   348  			stageIdx = 0
   349  			maxTitle = 20
   350  		)
   351  		for _, j := range jobs {
   352  			boxX := px + (maxX / stages * stageIdx)
   353  			if j.Stage != lastStage {
   354  				rowIdx = 0
   355  				stageIdx++
   356  				lastStage = j.Stage
   357  				key := "stage-" + j.Stage
   358  
   359  				x, y, w, h := boxX, maxY/6-4, maxTitle+2, 3
   360  				b := box(root, key, x, y, w, h)
   361  				b.SetText(strings.Title(j.Stage))
   362  				b.SetTextAlign(tview.AlignCenter)
   363  
   364  			}
   365  		}
   366  		lastStage = jobs[0].Stage
   367  		rowIdx = 0
   368  		stageIdx = 0
   369  		for _, j := range jobs {
   370  			if j.Stage != lastStage {
   371  				rowIdx = 0
   372  				lastStage = j.Stage
   373  				stageIdx++
   374  			}
   375  			boxX := px + (maxX / stages * stageIdx)
   376  
   377  			key := "jobs-" + j.Name
   378  			x, y, w, h := boxX, maxY/6+(rowIdx*5), maxTitle+2, 4
   379  			b := box(root, key, x, y, w, h)
   380  			b.SetTitle(j.Name)
   381  			// The scope of jobs to show, one or array of: created, pending, running,
   382  			// failed, success, canceled, skipped; showing all jobs if none provided
   383  			var statChar rune
   384  			switch j.Status {
   385  			case "success":
   386  				b.SetBorderColor(tcell.ColorGreen)
   387  				statChar = '✔'
   388  			case "failed":
   389  				b.SetBorderColor(tcell.ColorRed)
   390  				statChar = '✘'
   391  			case "running":
   392  				b.SetBorderColor(tcell.ColorBlue)
   393  				statChar = '●'
   394  			case "pending":
   395  				b.SetBorderColor(tcell.ColorYellow)
   396  				statChar = '●'
   397  			case "manual":
   398  				b.SetBorderColor(tcell.ColorGrey)
   399  				statChar = '●'
   400  			}
   401  			// retryChar := '⟳'
   402  			title := fmt.Sprintf("%c %s", statChar, j.Name)
   403  			// trim the suffix if it matches the stage, I've seen
   404  			// the pattern in 2 different places to handle
   405  			// different stages for the same service and it tends
   406  			// to make the title spill over the max
   407  			title = strings.TrimSuffix(title, ":"+j.Stage)
   408  			b.SetTitle(title)
   409  			// tview default aligns center, which is nice, but if
   410  			// the title is too long we want to bias towards seeing
   411  			// the beginning of it
   412  			if tview.StringWidth(title) > maxTitle {
   413  				b.SetTitleAlign(tview.AlignLeft)
   414  			}
   415  			if j.StartedAt != nil {
   416  				end := time.Now()
   417  				if j.FinishedAt != nil {
   418  					end = *j.FinishedAt
   419  				}
   420  				b.SetText("\n" + fmtDuration(end.Sub(*j.StartedAt)))
   421  				b.SetTextAlign(tview.AlignRight)
   422  			} else {
   423  				b.SetText("")
   424  			}
   425  			rowIdx++
   426  
   427  		}
   428  		// last box keeps getting focus'd some how
   429  		for _, b := range boxes {
   430  			b.Blur()
   431  		}
   432  		boxes["jobs-"+curJob.Name].Focus(nil)
   433  		return false
   434  	}
   435  }
   436  func fmtDuration(d time.Duration) string {
   437  	d = d.Round(time.Second)
   438  	m := d / time.Minute
   439  	d -= m * time.Minute
   440  	s := d / time.Second
   441  	return fmt.Sprintf("%02dm %02ds", m, s)
   442  }
   443  func box(root *tview.Pages, key string, x, y, w, h int) *tview.TextView {
   444  	b, ok := boxes[key]
   445  	if !ok {
   446  		b = tview.NewTextView()
   447  		b.SetBorder(true)
   448  		boxes[key] = b
   449  	}
   450  	b.SetRect(x, y, w, h)
   451  
   452  	root.AddPage(key, b, false, true)
   453  	return b
   454  }
   455  
   456  func recoverPanic(app *tview.Application) {
   457  	if r := recover(); r != nil {
   458  		app.Stop()
   459  		log.Fatalf("%s\n%s\n", r, string(debug.Stack()))
   460  	}
   461  }
   462  
   463  func refreshScreen(app *tview.Application, root *tview.Pages) {
   464  	defer recoverPanic(app)
   465  	for {
   466  		app.Draw()
   467  		time.Sleep(time.Second * 1)
   468  	}
   469  }
   470  
   471  func updateJobs(app *tview.Application, jobsCh chan []*gitlab.Job, pid interface{}, branch string) {
   472  	defer recoverPanic(app)
   473  	for {
   474  		if modalVisible {
   475  			time.Sleep(time.Second * 1)
   476  			continue
   477  		}
   478  		jobs, err := lab.CIJobs(pid, branch)
   479  		if len(jobs) == 0 || err != nil {
   480  			app.Stop()
   481  			log.Fatal(errors.Wrap(err, "failed to find ci jobs"))
   482  		}
   483  		jobsCh <- latestJobs(jobs)
   484  		time.Sleep(time.Second * 5)
   485  	}
   486  }
   487  
   488  func connectJobsView(app *tview.Application) func(screen tcell.Screen) {
   489  	return func(screen tcell.Screen) {
   490  		defer recoverPanic(app)
   491  		err := connectJobs(screen, jobs, boxes)
   492  		if err != nil {
   493  			app.Stop()
   494  			log.Fatal(err)
   495  		}
   496  	}
   497  }
   498  
   499  func connectJobs(screen tcell.Screen, jobs []*gitlab.Job, boxes map[string]*tview.TextView) error {
   500  	if logsVisible || modalVisible {
   501  		return nil
   502  	}
   503  	for i, j := range jobs {
   504  		if _, ok := boxes["jobs-"+j.Name]; !ok {
   505  			return errors.Errorf("jobs-%s not found at index: %d", jobs[i].Name, i)
   506  		}
   507  	}
   508  	var padding int
   509  	// find the abount of space between two jobs is adjacent stages
   510  	for i, k := 0, 1; k < len(jobs); i, k = i+1, k+1 {
   511  		if jobs[i].Stage == jobs[k].Stage {
   512  			continue
   513  		}
   514  		x1, _, w, _ := boxes["jobs-"+jobs[i].Name].GetRect()
   515  		x2, _, _, _ := boxes["jobs-"+jobs[k].Name].GetRect()
   516  		stageWidth := x2 - x1 - w
   517  		switch {
   518  		case stageWidth <= 3:
   519  			padding = 1
   520  		case stageWidth <= 6:
   521  			padding = 2
   522  		case stageWidth > 6:
   523  			padding = 3
   524  		}
   525  	}
   526  	for i, k := 0, 1; k < len(jobs); i, k = i+1, k+1 {
   527  		v1 := boxes["jobs-"+jobs[i].Name]
   528  		v2 := boxes["jobs-"+jobs[k].Name]
   529  		connect(screen, v1.Box, v2.Box, padding,
   530  			jobs[i].Stage == jobs[0].Stage,           // is first stage?
   531  			jobs[i].Stage == jobs[len(jobs)-1].Stage) // is last stage?
   532  	}
   533  	return nil
   534  }
   535  
   536  func connect(screen tcell.Screen, v1 *tview.Box, v2 *tview.Box, padding int, firstStage, lastStage bool) {
   537  	x1, y1, w, h := v1.GetRect()
   538  	x2, y2, _, _ := v2.GetRect()
   539  
   540  	dx, dy := x2-x1, y2-y1
   541  
   542  	p := padding
   543  
   544  	// drawing stages
   545  	if dx != 0 {
   546  		hline(screen, x1+w, y2+h/2, dx-w)
   547  		if dy != 0 {
   548  			// dy != 0 means the last stage had multple jobs
   549  			screen.SetContent(x1+w+p-1, y2+h/2, '┳', nil, tcell.StyleDefault)
   550  		}
   551  		return
   552  	}
   553  
   554  	// Drawing a job in the same stage
   555  	// left of view
   556  	if !firstStage {
   557  		if r, _, _, _ := screen.GetContent(x2-p, y1+h/2); r == '┗' {
   558  			screen.SetContent(x2-p, y1+h/2, '┣', nil, tcell.StyleDefault)
   559  		} else {
   560  			screen.SetContent(x2-p, y1+h/2, '┳', nil, tcell.StyleDefault)
   561  		}
   562  
   563  		for i := 1; i < p; i++ {
   564  			screen.SetContent(x2-i, y2+h/2, '━', nil, tcell.StyleDefault)
   565  		}
   566  		screen.SetContent(x2-p, y2+h/2, '┗', nil, tcell.StyleDefault)
   567  
   568  		vline(screen, x2-p, y1+h-1, dy-1)
   569  	}
   570  	// right of view
   571  	if !lastStage {
   572  		if r, _, _, _ := screen.GetContent(x2+w+p-1, y1+h/2); r == '┛' {
   573  			screen.SetContent(x2+w+p-1, y1+h/2, '┫', nil, tcell.StyleDefault)
   574  		}
   575  		for i := 0; i < p-1; i++ {
   576  			screen.SetContent(x2+w+i, y2+h/2, '━', nil, tcell.StyleDefault)
   577  		}
   578  		screen.SetContent(x2+w+p-1, y2+h/2, '┛', nil, tcell.StyleDefault)
   579  
   580  		vline(screen, x2+w+p-1, y1+h-1, dy-1)
   581  	}
   582  }
   583  
   584  func hline(screen tcell.Screen, x, y, l int) {
   585  	for i := 0; i < l; i++ {
   586  		screen.SetContent(x+i, y, '━', nil, tcell.StyleDefault)
   587  	}
   588  }
   589  
   590  func vline(screen tcell.Screen, x, y, l int) {
   591  	for i := 0; i < l; i++ {
   592  		screen.SetContent(x, y+i, '┃', nil, tcell.StyleDefault)
   593  	}
   594  }
   595  
   596  // latestJobs returns a list of unique jobs favoring the last stage+name
   597  // version of a job in the provided list
   598  func latestJobs(jobs []*gitlab.Job) []*gitlab.Job {
   599  	var (
   600  		lastJob = make(map[string]*gitlab.Job, len(jobs))
   601  		dupIdx  = -1
   602  	)
   603  	for i, j := range jobs {
   604  		_, ok := lastJob[j.Stage+j.Name]
   605  		if dupIdx == -1 && ok {
   606  			dupIdx = i
   607  		}
   608  		// always want the latest job
   609  		lastJob[j.Stage+j.Name] = j
   610  	}
   611  	if dupIdx == -1 {
   612  		dupIdx = len(jobs)
   613  	}
   614  	// first duplicate marks where retries begin
   615  	outJobs := make([]*gitlab.Job, dupIdx)
   616  	for i := range outJobs {
   617  		j := jobs[i]
   618  		outJobs[i] = lastJob[j.Stage+j.Name]
   619  	}
   620  
   621  	return outJobs
   622  }
   623  
   624  func init() {
   625  	ciCmd.AddCommand(ciViewCmd)
   626  }