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