1// Copyright 2021 Google LLC
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//go:build linux || darwin
16// +build linux darwin
17
18package main
19
20import (
21	"encoding/json"
22	"flag"
23	"fmt"
24	"io/ioutil"
25	"log"
26	"os"
27	osexec "os/exec"
28	"path"
29	"strings"
30)
31
32// TODO(noahdietz): remove this once the fix in golang.org/x/tools is released.
33// https://github.com/golang/go/issues/44796
34const ignored = "- MaxPublishRequestBytes: value changed from 0.000582077 to 10000000"
35const rootMod = "cloud.google.com/go"
36
37var repoMetadataPath string
38var verbose bool
39var gapic string
40
41func init() {
42	flag.StringVar(&repoMetadataPath, "repo-metadata", "", "path to a repo-metadata-full JSON file [required]")
43	flag.StringVar(&gapic, "gapic", "", "import path of a specific GAPIC to diff")
44	flag.BoolVar(&verbose, "verbose", false, "enable verbose command logging")
45}
46
47func main() {
48	flag.Parse()
49	if repoMetadataPath == "" {
50		log.Fatalln("Missing required flag: -repo-metadata")
51	}
52
53	head, err := exec("git", "log", "-2")
54	if err != nil {
55		log.Fatalln(err)
56	}
57	if checkAllowBreakingChange(head) {
58		return
59	}
60
61	root, err := os.Getwd()
62	if err != nil {
63		log.Fatalln(err)
64	}
65
66	f, err := os.Open(repoMetadataPath)
67	if err != nil {
68		log.Fatalln(err)
69	}
70	defer f.Close()
71
72	var m manifest
73	if err := json.NewDecoder(f).Decode(&m); err != nil {
74		log.Fatalln(err)
75	}
76
77	_, err = exec("go", "install", "golang.org/x/exp/cmd/apidiff@latest")
78	if err != nil {
79		log.Fatalln(err)
80	}
81
82	temp, err := ioutil.TempDir("/tmp", "google-cloud-go-*")
83	if err != nil {
84		log.Fatalln(err)
85	}
86	defer os.RemoveAll(temp)
87
88	_, err = exec("git", "clone", "https://github.com/googleapis/google-cloud-go", temp)
89	if err != nil {
90		log.Fatalln(err)
91	}
92
93	diffs, diffingErrs, err := diffModules(root, temp, m)
94	if err != nil {
95		log.Fatalln(err)
96	}
97
98	if len(diffingErrs) > 0 {
99		fmt.Fprintln(os.Stderr, "The following packages encountered errors:")
100		for imp, err := range diffingErrs {
101			fmt.Fprintf(os.Stderr, "%s: %s\n", imp, err)
102		}
103	}
104
105	if len(diffs) > 0 {
106		fmt.Fprintln(os.Stderr, "The following breaking changes were found:")
107		for imp, d := range diffs {
108			fmt.Fprintf(os.Stderr, "%s:\n%s\n", imp, d)
109		}
110		os.Exit(1)
111	}
112}
113
114// manifestEntry is used for JSON marshaling in manifest.
115// Copied from internal/gapicgen/generator/gapics.go.
116type manifestEntry struct {
117	DistributionName  string `json:"distribution_name"`
118	Description       string `json:"description"`
119	Language          string `json:"language"`
120	ClientLibraryType string `json:"client_library_type"`
121	DocsURL           string `json:"docs_url"`
122	ReleaseLevel      string `json:"release_level"`
123}
124
125type manifest map[string]manifestEntry
126
127func diffModules(root, baseDir string, m manifest) (map[string]string, map[string]error, error) {
128	diffs := map[string]string{}
129	issues := map[string]error{}
130
131	for imp, entry := range m {
132		if gapic != "" && imp != gapic {
133			continue
134		}
135
136		// Prepare module directory paths relative to the repo root.
137		pkg := strings.TrimPrefix(imp, rootMod+"/")
138		baseModDir := baseDir
139		modDir := root
140
141		// Manual clients are also submodules, so we need to run apidiff in the
142		// submodule.
143		if entry.ClientLibraryType == "manual" {
144			baseModDir = path.Join(baseModDir, pkg)
145			modDir = path.Join(modDir, pkg)
146		}
147
148		// Create apidiff base from repo remote HEAD.
149		base, err := writeBase(m, baseModDir, imp, pkg)
150		if err != nil {
151			issues[imp] = err
152			continue
153		}
154
155		// Diff the current checked out change against remote HEAD base.
156		out, err := diff(m, modDir, imp, pkg, base)
157		if err != nil {
158			issues[imp] = err
159			continue
160		}
161
162		if out != "" && out != ignored {
163			diffs[imp] = out
164		}
165	}
166
167	return diffs, issues, nil
168}
169
170func writeBase(m manifest, baseModDir, imp, pkg string) (string, error) {
171	if err := cd(baseModDir); err != nil {
172		return "", err
173	}
174
175	base := path.Join(baseModDir, "pkg.master")
176	out, err := exec("apidiff", "-w", base, imp)
177	if err != nil && !isSubModErr(out) {
178		return "", err
179	}
180
181	// If there was an issue with loading a submodule, change into that
182	// submodule directory and try again.
183	if isSubModErr(out) {
184		parent := manualParent(m, imp)
185		if parent == pkg {
186			return "", fmt.Errorf("unable to find parent module for %q", imp)
187		}
188		if err := cd(parent); err != nil {
189			return "", err
190		}
191		out, err := exec("apidiff", "-w", base, imp)
192		if err != nil {
193			return "", fmt.Errorf("%s: %s", err, out)
194		}
195	}
196	return base, nil
197}
198
199func diff(m manifest, modDir, imp, pkg, base string) (string, error) {
200	if err := cd(modDir); err != nil {
201		return "", err
202	}
203	out, err := exec("apidiff", "-incompatible", base, imp)
204	if err != nil && !isSubModErr(out) {
205		return "", err
206	}
207	if isSubModErr(out) {
208		parent := manualParent(m, imp)
209		if parent == pkg {
210			return "", fmt.Errorf("unable to find parent module for %q", imp)
211		}
212		if err := cd(parent); err != nil {
213			return "", err
214		}
215		out, err = exec("apidiff", "-incompatible", base, imp)
216		if err != nil {
217			return "", fmt.Errorf("%s: %s", err, out)
218		}
219	}
220
221	return out, err
222}
223
224func checkAllowBreakingChange(commit string) bool {
225	if strings.Contains(commit, "BREAKING CHANGE:") {
226		log.Println("Not running apidiff because description contained tag BREAKING_CHANGE.")
227		return true
228	}
229
230	split := strings.Split(commit, "\n")
231	for _, s := range split {
232		if strings.Contains(s, "!:") || strings.Contains(s, "!(") {
233			log.Println("Not running apidiff because description contained breaking change indicator '!'.")
234			return true
235		}
236	}
237
238	return false
239}
240
241func manualParent(m manifest, imp string) string {
242	pkg := strings.TrimPrefix(imp, rootMod)
243	split := strings.Split(pkg, "/")
244
245	mod := rootMod
246	for _, seg := range split {
247		mod = path.Join(mod, seg)
248		if parent, ok := m[mod]; ok && parent.ClientLibraryType == "manual" {
249			return strings.TrimPrefix(mod, rootMod+"/")
250		}
251	}
252
253	return pkg
254}
255
256func isSubModErr(msg string) bool {
257	return strings.Contains(msg, "missing") || strings.Contains(msg, "required")
258}
259
260func cd(dir string) error {
261	if verbose {
262		log.Printf("+ cd %s\n", dir)
263	}
264	return os.Chdir(dir)
265}
266
267func exec(cmd string, args ...string) (string, error) {
268	if verbose {
269		log.Printf("+ %s %s\n", cmd, strings.Join(args, " "))
270	}
271	out, err := osexec.Command(cmd, args...).CombinedOutput()
272	return strings.TrimSpace(string(out)), err
273}
274