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 }