github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/pkg/cmd/pr/diff/diff.go (about)

     1  package diff
     2  
     3  import (
     4  	"bufio"
     5  	"errors"
     6  	"fmt"
     7  	"io"
     8  	"net/http"
     9  	"regexp"
    10  	"strings"
    11  
    12  	"github.com/MakeNowJust/heredoc"
    13  	"github.com/ungtb10d/cli/v2/api"
    14  	"github.com/ungtb10d/cli/v2/internal/browser"
    15  	"github.com/ungtb10d/cli/v2/internal/ghinstance"
    16  	"github.com/ungtb10d/cli/v2/internal/ghrepo"
    17  	"github.com/ungtb10d/cli/v2/internal/text"
    18  	"github.com/ungtb10d/cli/v2/pkg/cmd/pr/shared"
    19  	"github.com/ungtb10d/cli/v2/pkg/cmdutil"
    20  	"github.com/ungtb10d/cli/v2/pkg/iostreams"
    21  	"github.com/spf13/cobra"
    22  )
    23  
    24  type DiffOptions struct {
    25  	HttpClient func() (*http.Client, error)
    26  	IO         *iostreams.IOStreams
    27  	Browser    browser.Browser
    28  
    29  	Finder shared.PRFinder
    30  
    31  	SelectorArg string
    32  	UseColor    bool
    33  	Patch       bool
    34  	NameOnly    bool
    35  	BrowserMode bool
    36  }
    37  
    38  func NewCmdDiff(f *cmdutil.Factory, runF func(*DiffOptions) error) *cobra.Command {
    39  	opts := &DiffOptions{
    40  		IO:         f.IOStreams,
    41  		HttpClient: f.HttpClient,
    42  		Browser:    f.Browser,
    43  	}
    44  
    45  	var colorFlag string
    46  
    47  	cmd := &cobra.Command{
    48  		Use:   "diff [<number> | <url> | <branch>]",
    49  		Short: "View changes in a pull request",
    50  		Long: heredoc.Doc(`
    51  			View changes in a pull request. 
    52  
    53  			Without an argument, the pull request that belongs to the current branch
    54  			is selected.
    55  			
    56  			With '--web', open the pull request diff in a web browser instead.
    57  		`),
    58  		Args: cobra.MaximumNArgs(1),
    59  		RunE: func(cmd *cobra.Command, args []string) error {
    60  			opts.Finder = shared.NewFinder(f)
    61  
    62  			if repoOverride, _ := cmd.Flags().GetString("repo"); repoOverride != "" && len(args) == 0 {
    63  				return cmdutil.FlagErrorf("argument required when using the `--repo` flag")
    64  			}
    65  
    66  			if len(args) > 0 {
    67  				opts.SelectorArg = args[0]
    68  			}
    69  
    70  			switch colorFlag {
    71  			case "always":
    72  				opts.UseColor = true
    73  			case "auto":
    74  				opts.UseColor = opts.IO.ColorEnabled()
    75  			case "never":
    76  				opts.UseColor = false
    77  			default:
    78  				return fmt.Errorf("unsupported color %q", colorFlag)
    79  			}
    80  
    81  			if runF != nil {
    82  				return runF(opts)
    83  			}
    84  			return diffRun(opts)
    85  		},
    86  	}
    87  
    88  	cmdutil.StringEnumFlag(cmd, &colorFlag, "color", "", "auto", []string{"always", "never", "auto"}, "Use color in diff output")
    89  	cmd.Flags().BoolVar(&opts.Patch, "patch", false, "Display diff in patch format")
    90  	cmd.Flags().BoolVar(&opts.NameOnly, "name-only", false, "Display only names of changed files")
    91  	cmd.Flags().BoolVarP(&opts.BrowserMode, "web", "w", false, "Open the pull request diff in the browser")
    92  
    93  	return cmd
    94  }
    95  
    96  func diffRun(opts *DiffOptions) error {
    97  	findOptions := shared.FindOptions{
    98  		Selector: opts.SelectorArg,
    99  		Fields:   []string{"number"},
   100  	}
   101  
   102  	if opts.BrowserMode {
   103  		findOptions.Fields = []string{"url"}
   104  	}
   105  
   106  	pr, baseRepo, err := opts.Finder.Find(findOptions)
   107  	if err != nil {
   108  		return err
   109  	}
   110  
   111  	if opts.BrowserMode {
   112  		openUrl := fmt.Sprintf("%s/files", pr.URL)
   113  		if opts.IO.IsStdoutTTY() {
   114  			fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(openUrl))
   115  		}
   116  		return opts.Browser.Browse(openUrl)
   117  	}
   118  
   119  	httpClient, err := opts.HttpClient()
   120  	if err != nil {
   121  		return err
   122  	}
   123  
   124  	if opts.NameOnly {
   125  		opts.Patch = false
   126  	}
   127  
   128  	diff, err := fetchDiff(httpClient, baseRepo, pr.Number, opts.Patch)
   129  	if err != nil {
   130  		return fmt.Errorf("could not find pull request diff: %w", err)
   131  	}
   132  	defer diff.Close()
   133  
   134  	if err := opts.IO.StartPager(); err == nil {
   135  		defer opts.IO.StopPager()
   136  	} else {
   137  		fmt.Fprintf(opts.IO.ErrOut, "failed to start pager: %v\n", err)
   138  	}
   139  
   140  	if opts.NameOnly {
   141  		return changedFilesNames(opts.IO.Out, diff)
   142  	}
   143  
   144  	if !opts.UseColor {
   145  		_, err = io.Copy(opts.IO.Out, diff)
   146  		return err
   147  	}
   148  
   149  	return colorDiffLines(opts.IO.Out, diff)
   150  }
   151  
   152  func fetchDiff(httpClient *http.Client, baseRepo ghrepo.Interface, prNumber int, asPatch bool) (io.ReadCloser, error) {
   153  	url := fmt.Sprintf(
   154  		"%srepos/%s/pulls/%d",
   155  		ghinstance.RESTPrefix(baseRepo.RepoHost()),
   156  		ghrepo.FullName(baseRepo),
   157  		prNumber,
   158  	)
   159  	acceptType := "application/vnd.github.v3.diff"
   160  	if asPatch {
   161  		acceptType = "application/vnd.github.v3.patch"
   162  	}
   163  
   164  	req, err := http.NewRequest("GET", url, nil)
   165  	if err != nil {
   166  		return nil, err
   167  	}
   168  
   169  	req.Header.Set("Accept", acceptType)
   170  
   171  	resp, err := httpClient.Do(req)
   172  	if err != nil {
   173  		return nil, err
   174  	}
   175  	if resp.StatusCode != 200 {
   176  		return nil, api.HandleHTTPError(resp)
   177  	}
   178  
   179  	return resp.Body, nil
   180  }
   181  
   182  const lineBufferSize = 4096
   183  
   184  var (
   185  	colorHeader   = []byte("\x1b[1;38m")
   186  	colorAddition = []byte("\x1b[32m")
   187  	colorRemoval  = []byte("\x1b[31m")
   188  	colorReset    = []byte("\x1b[m")
   189  )
   190  
   191  func colorDiffLines(w io.Writer, r io.Reader) error {
   192  	diffLines := bufio.NewReaderSize(r, lineBufferSize)
   193  	wasPrefix := false
   194  	needsReset := false
   195  
   196  	for {
   197  		diffLine, isPrefix, err := diffLines.ReadLine()
   198  		if err != nil {
   199  			if errors.Is(err, io.EOF) {
   200  				break
   201  			}
   202  			return fmt.Errorf("error reading pull request diff: %w", err)
   203  		}
   204  
   205  		var color []byte
   206  		if !wasPrefix {
   207  			if isHeaderLine(diffLine) {
   208  				color = colorHeader
   209  			} else if isAdditionLine(diffLine) {
   210  				color = colorAddition
   211  			} else if isRemovalLine(diffLine) {
   212  				color = colorRemoval
   213  			}
   214  		}
   215  
   216  		if color != nil {
   217  			if _, err := w.Write(color); err != nil {
   218  				return err
   219  			}
   220  			needsReset = true
   221  		}
   222  
   223  		if _, err := w.Write(diffLine); err != nil {
   224  			return err
   225  		}
   226  
   227  		if !isPrefix {
   228  			if needsReset {
   229  				if _, err := w.Write(colorReset); err != nil {
   230  					return err
   231  				}
   232  				needsReset = false
   233  			}
   234  			if _, err := w.Write([]byte{'\n'}); err != nil {
   235  				return err
   236  			}
   237  		}
   238  		wasPrefix = isPrefix
   239  	}
   240  	return nil
   241  }
   242  
   243  var diffHeaderPrefixes = []string{"+++", "---", "diff", "index"}
   244  
   245  func isHeaderLine(l []byte) bool {
   246  	dl := string(l)
   247  	for _, p := range diffHeaderPrefixes {
   248  		if strings.HasPrefix(dl, p) {
   249  			return true
   250  		}
   251  	}
   252  	return false
   253  }
   254  
   255  func isAdditionLine(l []byte) bool {
   256  	return len(l) > 0 && l[0] == '+'
   257  }
   258  
   259  func isRemovalLine(l []byte) bool {
   260  	return len(l) > 0 && l[0] == '-'
   261  }
   262  
   263  func changedFilesNames(w io.Writer, r io.Reader) error {
   264  	diff, err := io.ReadAll(r)
   265  	if err != nil {
   266  		return err
   267  	}
   268  
   269  	pattern := regexp.MustCompile(`(?:^|\n)diff\s--git.*\sb/(.*)`)
   270  	matches := pattern.FindAllStringSubmatch(string(diff), -1)
   271  
   272  	for _, val := range matches {
   273  		name := strings.TrimSpace(val[1])
   274  		if _, err := w.Write([]byte(name + "\n")); err != nil {
   275  			return err
   276  		}
   277  	}
   278  
   279  	return nil
   280  }