github.com/zaquestion/lab@v0.25.1/cmd/ci_view.go (about)

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