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