| // Copyright 2023 Google LLC |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| package main |
| |
| import ( |
| "context" |
| _ "embed" |
| "errors" |
| "flag" |
| "fmt" |
| "go/ast" |
| "go/parser" |
| "go/token" |
| "html/template" |
| "io/fs" |
| "log" |
| "os" |
| "path/filepath" |
| "regexp" |
| "strings" |
| "time" |
| |
| "cloud.google.com/go/internal/postprocessor/execv/gocmd" |
| "github.com/google/go-github/v59/github" |
| ) |
| |
| const ( |
| owlBotBranchPrefix = "owl-bot-copy" |
| beginNestedCommitDelimiter = "BEGIN_NESTED_COMMIT" |
| endNestedCommitDelimiter = "END_NESTED_COMMIT" |
| copyTagSubstring = "Copy-Tag:" |
| |
| // This is the default Go version that will be generated into new go.mod |
| // files. It should be updated every time we drop support for old Go |
| // versions. |
| defaultGoModuleVersion = "1.19" |
| ) |
| |
| var ( |
| // hashFromLinePattern grabs the hash from the end of a github commit URL |
| hashFromLinePattern = regexp.MustCompile(`.*/(?P<hash>[a-zA-Z0-9]*).*`) |
| ) |
| |
| var ( |
| //go:embed _README.md.txt |
| readmeTmpl string |
| //go:embed _version.go.txt |
| versionTmpl string |
| //go:embed _internal_version.go.txt |
| internalVersionTmpl string |
| ) |
| |
| func main() { |
| clientRoot := flag.String("client-root", "/workspace/google-cloud-go", "Path to clients.") |
| googleapisDir := flag.String("googleapis-dir", "", "Path to googleapis/googleapis repo.") |
| directories := flag.String("dirs", "", "Comma-separated list of module names to run (not paths).") |
| branchOverride := flag.String("branch", "", "The branch that should be processed by this code") |
| githubUsername := flag.String("gh-user", "googleapis", "GitHub username where repo lives.") |
| prFilepath := flag.String("pr-file", "/workspace/new_pull_request_text.txt", "Path at which to write text file if changing PR title or body.") |
| |
| if len(os.Args) > 1 { |
| switch os.Args[1] { |
| case "validate": |
| log.Println("Starting config validation.") |
| if err := validate(); err != nil { |
| log.Fatal(err) |
| } |
| log.Println("Validation complete.") |
| return |
| } |
| } |
| flag.Parse() |
| ctx := context.Background() |
| |
| log.Println("client-root set to", *clientRoot) |
| log.Println("googleapis-dir set to", *googleapisDir) |
| log.Println("branch set to", *branchOverride) |
| log.Println("prFilepath is", *prFilepath) |
| log.Println("directories are", *directories) |
| |
| dirSlice := []string{} |
| if *directories != "" { |
| dirSlice = strings.Split(*directories, ",") |
| log.Println("Postprocessor running on", dirSlice) |
| } else { |
| log.Println("Postprocessor running on all modules.") |
| } |
| |
| if *googleapisDir == "" { |
| log.Println("creating temp dir") |
| tmpDir, err := os.MkdirTemp("", "update-postprocessor") |
| if err != nil { |
| log.Fatal(err) |
| } |
| defer os.RemoveAll(tmpDir) |
| |
| log.Printf("working out %s\n", tmpDir) |
| *googleapisDir = filepath.Join(tmpDir, "googleapis") |
| |
| if err := DeepClone("https://github.com/googleapis/googleapis", *googleapisDir); err != nil { |
| log.Fatal(err) |
| } |
| } |
| |
| p := &postProcessor{ |
| googleapisDir: *googleapisDir, |
| googleCloudDir: *clientRoot, |
| modules: dirSlice, |
| branchOverride: *branchOverride, |
| githubUsername: *githubUsername, |
| prFilepath: *prFilepath, |
| } |
| |
| if err := p.loadConfig(); err != nil { |
| log.Fatal(err) |
| } |
| |
| if err := p.run(ctx); err != nil { |
| log.Fatal(err) |
| } |
| log.Println("Completed successfully.") |
| } |
| |
| type postProcessor struct { |
| googleapisDir string |
| googleCloudDir string |
| |
| // At this time modules are either provided at the time of invocation locally |
| // and extracted from the open OwlBot PR description. If we would like |
| // the postprocessor to be able to be run on non-OwlBot PRs, we would |
| // need to change the method of populating this field. |
| modules []string |
| |
| branchOverride string |
| githubUsername string |
| prFilepath string |
| |
| config *config |
| } |
| |
| func (p *postProcessor) run(ctx context.Context) error { |
| if runAll, err := runAll(p.googleCloudDir, p.branchOverride); err != nil { |
| return err |
| } else if !runAll { |
| log.Println("exiting post processing early") |
| return nil |
| } |
| |
| manifest, err := p.Manifest() |
| if err != nil { |
| return err |
| } |
| if err := p.InitializeNewModules(manifest); err != nil { |
| return err |
| } |
| if err := p.UpdateSnippetsMetadata(); err != nil { |
| return err |
| } |
| prTitle, prBody, err := p.GetNewPRTitleAndBody(ctx) |
| if err != nil { |
| return err |
| } |
| if err := p.TidyAffectedMods(); err != nil { |
| return err |
| } |
| if err := p.UpdateReleaseFiles(); err != nil { |
| return err |
| } |
| if err := gocmd.Vet(p.googleCloudDir); err != nil { |
| return err |
| } |
| if err := p.WritePRInfoToFile(prTitle, prBody); err != nil { |
| return err |
| } |
| return nil |
| } |
| |
| // InitializeNewModule detects new modules and clients and generates the required minimum files |
| // For modules, the minimum required files are internal/version.go, README.md, CHANGES.md, and go.mod |
| // For clients, the minimum required files are a version.go file |
| func (p *postProcessor) InitializeNewModules(manifest map[string]ManifestEntry) error { |
| log.Println("checking for new modules and clients") |
| for _, moduleName := range p.config.Modules { |
| modulePath := filepath.Join(p.googleCloudDir, moduleName) |
| importPath := filepath.Join("cloud.google.com/go", moduleName) |
| |
| pathToModVersionFile := filepath.Join(modulePath, "internal/version.go") |
| // Check if <module>/internal/version.go file exists |
| if _, err := os.Stat(pathToModVersionFile); errors.Is(err, fs.ErrNotExist) { |
| log.Println("detected missing file: ", pathToModVersionFile) |
| var serviceImportPath string |
| for _, v := range p.config.GapicImportPaths() { |
| if strings.Contains(v, importPath) { |
| serviceImportPath = v |
| break |
| } |
| } |
| if serviceImportPath == "" { |
| return fmt.Errorf("no config found for module %s. Cannot generate min required files", importPath) |
| } |
| // serviceImportPath here should be a valid ImportPath from a MicrogenGapicConfigs |
| apiName := manifest[serviceImportPath].Description |
| if err := p.generateMinReqFilesNewMod(moduleName, modulePath, importPath, apiName); err != nil { |
| return err |
| } |
| log.Printf("Adding new module %s to list of modules to process", moduleName) |
| p.modules = append(p.modules, moduleName) |
| if err := p.modEditReplaceInSnippets(modulePath, importPath); err != nil { |
| return err |
| } |
| } |
| // Check if version.go files exist for each client |
| err := filepath.WalkDir(modulePath, func(path string, d fs.DirEntry, err error) error { |
| if err != nil { |
| return err |
| } |
| if !d.IsDir() { |
| return nil |
| } |
| splitPath := strings.Split(path, "/") |
| lastElement := splitPath[len(splitPath)-1] |
| if !strings.Contains(lastElement, "apiv") { |
| return nil |
| } |
| // Skip unless the presence of doc.go indicates that this is a client. |
| // Some modules contain only type protos, and don't need version.go. |
| pathToClientDocFile := filepath.Join(path, "doc.go") |
| if _, err = os.Stat(pathToClientDocFile); errors.Is(err, fs.ErrNotExist) { |
| return nil |
| } |
| pathToClientVersionFile := filepath.Join(path, "version.go") |
| if _, err = os.Stat(pathToClientVersionFile); errors.Is(err, fs.ErrNotExist) { |
| if err := p.generateVersionFile(moduleName, path); err != nil { |
| return err |
| } |
| } |
| return nil |
| }) |
| if err != nil { |
| return err |
| } |
| } |
| return nil |
| } |
| |
| func (p *postProcessor) generateMinReqFilesNewMod(moduleName, modulePath, importPath, apiName string) error { |
| log.Println("generating files for new module", apiName) |
| if err := generateReadmeAndChanges(modulePath, importPath, apiName); err != nil { |
| return err |
| } |
| if err := p.generateInternalVersionFile(moduleName); err != nil { |
| return err |
| } |
| if err := p.generateModule(modulePath, importPath); err != nil { |
| return err |
| } |
| return nil |
| } |
| |
| func (p *postProcessor) generateModule(modPath, importPath string) error { |
| if err := os.MkdirAll(modPath, os.ModePerm); err != nil { |
| return err |
| } |
| log.Printf("Creating %s/go.mod", modPath) |
| if err := gocmd.ModInit(modPath, importPath, defaultGoModuleVersion); err != nil { |
| return err |
| } |
| log.Print("Updating workspace") |
| return gocmd.WorkUse(p.googleCloudDir) |
| } |
| |
| func (p *postProcessor) generateVersionFile(moduleName, path string) error { |
| // These directories are not modules on purpose, don't generate a version |
| // file for them. |
| if strings.Contains(path, "debugger/apiv2") || strings.Contains(path, "orgpolicy/apiv1") { |
| return nil |
| } |
| log.Println("generating version.go file in", path) |
| pathSegments := strings.Split(filepath.Dir(path), "/") |
| |
| rootModInternal := fmt.Sprintf("cloud.google.com/go/%s/internal", moduleName) |
| |
| f, err := os.Create(filepath.Join(path, "version.go")) |
| if err != nil { |
| return err |
| } |
| defer f.Close() |
| |
| t := template.Must(template.New("version").Parse(versionTmpl)) |
| versionData := struct { |
| Year int |
| Package string |
| ModuleRootInternal string |
| }{ |
| Year: time.Now().Year(), |
| Package: pathSegments[len(pathSegments)-1], |
| ModuleRootInternal: rootModInternal, |
| } |
| if err := t.Execute(f, versionData); err != nil { |
| return err |
| } |
| return nil |
| } |
| |
| func (p *postProcessor) generateInternalVersionFile(apiName string) error { |
| rootModInternal := filepath.Join(apiName, "internal") |
| os.MkdirAll(filepath.Join(p.googleCloudDir, rootModInternal), os.ModePerm) |
| |
| f, err := os.Create(filepath.Join(p.googleCloudDir, rootModInternal, "version.go")) |
| if err != nil { |
| return err |
| } |
| defer f.Close() |
| |
| t := template.Must(template.New("internal_version").Parse(internalVersionTmpl)) |
| internalVersionData := struct { |
| Year int |
| }{ |
| Year: time.Now().Year(), |
| } |
| if err := t.Execute(f, internalVersionData); err != nil { |
| return err |
| } |
| return nil |
| } |
| |
| func (p *postProcessor) getDirs() []string { |
| dirs := []string{} |
| for _, module := range p.modules { |
| dirs = append(dirs, filepath.Join(p.googleCloudDir, module)) |
| } |
| return dirs |
| } |
| |
| func (p *postProcessor) modEditReplaceInSnippets(modulePath, importPath string) error { |
| // Replace it. Use a relative path to avoid issues on different systems. |
| snippetsDir := filepath.Join(p.googleCloudDir, "internal", "generated", "snippets") |
| rel, err := filepath.Rel(snippetsDir, modulePath) |
| if err != nil { |
| return err |
| } |
| return gocmd.EditReplace(snippetsDir, importPath, rel) |
| } |
| |
| func (p *postProcessor) UpdateSnippetsMetadata() error { |
| log.Println("updating snippets metadata") |
| for _, clientRelPath := range p.config.ClientRelPaths { |
| // OwlBot dest relative paths in ClientRelPaths begin with /, so the |
| // first path segment is the second element. |
| moduleName := strings.Split(clientRelPath, "/")[1] |
| if moduleName == "" { |
| return fmt.Errorf("unable to parse module name for %v", clientRelPath) |
| } |
| // Skip if dirs option set and this module is not included. |
| if len(p.modules) > 0 && !contains(p.modules, moduleName) { |
| continue |
| } |
| // debugger/apiv2 is not in a module so it does not have version info to read. |
| if strings.Contains(clientRelPath, "debugger/apiv2") { |
| continue |
| } |
| snpDir := filepath.Join(p.googleCloudDir, "internal", "generated", "snippets", clientRelPath) |
| glob := filepath.Join(snpDir, "snippet_metadata.*.json") |
| metadataFiles, err := filepath.Glob(glob) |
| if err != nil { |
| return err |
| } |
| if len(metadataFiles) == 0 { |
| log.Println("skipping, file not found with glob: ", glob) |
| continue |
| } |
| log.Println("updating ", glob) |
| version, err := getModuleVersion(filepath.Join(p.googleCloudDir, moduleName)) |
| if err != nil { |
| return err |
| } |
| read, err := os.ReadFile(metadataFiles[0]) |
| if err != nil { |
| return err |
| } |
| if strings.Contains(string(read), "$VERSION") { |
| log.Printf("setting $VERSION to %s in %s", version, metadataFiles[0]) |
| s := strings.Replace(string(read), "$VERSION", version, 1) |
| err = os.WriteFile(metadataFiles[0], []byte(s), 0) |
| if err != nil { |
| return err |
| } |
| } |
| } |
| return nil |
| } |
| |
| func getModuleVersion(dir string) (string, error) { |
| node, err := parser.ParseFile(token.NewFileSet(), filepath.Join(dir, "internal", "version.go"), nil, parser.ParseComments) |
| if err != nil { |
| return "", err |
| } |
| version := node.Scope.Objects["Version"].Decl.(*ast.ValueSpec).Values[0].(*ast.BasicLit).Value |
| version = strings.Trim(version, `"`) |
| return version, nil |
| } |
| |
| func (p *postProcessor) TidyAffectedMods() error { |
| dirs := p.getDirs() |
| for _, dir := range dirs { |
| if err := gocmd.ModTidy(dir); err != nil { |
| return err |
| } |
| } |
| return nil |
| } |
| |
| // Copied from generator package |
| func generateReadmeAndChanges(path, importPath, apiName string) error { |
| readmePath := filepath.Join(path, "README.md") |
| log.Printf("Creating %q", readmePath) |
| readmeFile, err := os.Create(readmePath) |
| if err != nil { |
| return err |
| } |
| defer readmeFile.Close() |
| t := template.Must(template.New("readme").Parse(readmeTmpl)) |
| readmeData := struct { |
| Name string |
| ImportPath string |
| }{ |
| Name: apiName, |
| ImportPath: importPath, |
| } |
| if err := t.Execute(readmeFile, readmeData); err != nil { |
| return err |
| } |
| |
| changesPath := filepath.Join(path, "CHANGES.md") |
| log.Printf("Creating %q", changesPath) |
| changesFile, err := os.Create(changesPath) |
| if err != nil { |
| return err |
| } |
| defer changesFile.Close() |
| _, err = changesFile.WriteString("# Changes\n") |
| return err |
| } |
| |
| func (p *postProcessor) GetNewPRTitleAndBody(ctx context.Context) (string, string, error) { |
| var prTitle, prBody string |
| log.Println("Amending PR title and body") |
| pr, err := p.getPR(ctx) |
| if err != nil { |
| return prTitle, prBody, err |
| } |
| newPRTitle, newPRBody, err := p.processCommit(*pr.Title, *pr.Body) |
| if err != nil { |
| return prTitle, prBody, err |
| } |
| return newPRTitle, newPRBody, nil |
| } |
| |
| func contains(s []string, str string) bool { |
| for _, elem := range s { |
| if elem == str { |
| return true |
| } |
| } |
| return false |
| } |
| |
| func (p *postProcessor) processCommit(title, body string) (string, string, error) { |
| var newTitle string |
| var newBody strings.Builder |
| var commitsSlice []string |
| startCommitIndex := 0 |
| |
| bodySlice := strings.Split(body, "\n") |
| |
| // Split body into separate commits, stripping nested commit delimiters |
| for index, line := range bodySlice { |
| if strings.Contains(line, beginNestedCommitDelimiter) || strings.Contains(line, endNestedCommitDelimiter) { |
| startCommitIndex = index + 1 |
| } |
| if strings.Contains(line, copyTagSubstring) { |
| thisCommit := strings.Join(bodySlice[startCommitIndex:index+1], "\n") |
| commitsSlice = append(commitsSlice, thisCommit) |
| startCommitIndex = index + 1 |
| } |
| } |
| |
| // Add scope to each commit |
| for commitIndex, commit := range commitsSlice { |
| commitLines := strings.Split(strings.TrimSpace(commit), "\n") |
| var currTitle string |
| if commitIndex == 0 { |
| currTitle = title |
| } else { |
| currTitle = commitLines[0] |
| commitLines = commitLines[1:] |
| newBody.WriteString(fmt.Sprintf("\n%v\n", beginNestedCommitDelimiter)) |
| } |
| for _, line := range commitLines { |
| // When OwlBot generates the commit body, after commit titles it provides 'Source-Link's. |
| // The source-link pointing to the googleapis/googleapis repo commit allows us to extract |
| // hash and find files changed in order to identify the commit's scope. |
| if strings.HasPrefix(line, "Source-Link") && strings.Contains(line, "googleapis/googleapis/") { |
| hash := extractHashFromLine(line) |
| scopes, err := p.getScopesFromGoogleapisCommitHash(hash) |
| if err != nil { |
| return "", "", err |
| } |
| for _, scope := range scopes { |
| if !contains(p.modules, scope) { |
| p.modules = append(p.modules, scope) |
| } |
| } |
| var scope string |
| if len(scopes) == 1 { |
| scope = scopes[0] |
| } |
| |
| newCommitTitle := updateCommitTitle(currTitle, scope) |
| if newTitle == "" { |
| newTitle = newCommitTitle |
| } else { |
| newBody.WriteString(fmt.Sprintf("%v\n", newCommitTitle)) |
| } |
| |
| newBody.WriteString(strings.Join(commitLines, "\n")) |
| if commitIndex != 0 { |
| newBody.WriteString(fmt.Sprintf("\n%v", endNestedCommitDelimiter)) |
| } |
| } |
| } |
| } |
| if p.branchOverride != "" { |
| p.modules = []string{} |
| p.modules = append(p.modules, p.config.Modules...) |
| } |
| return newTitle, newBody.String(), nil |
| } |
| |
| func (p *postProcessor) getPR(ctx context.Context) (*github.PullRequest, error) { |
| client := github.NewClient(nil) |
| prs, _, err := client.PullRequests.List(ctx, p.githubUsername, "google-cloud-go", nil) |
| if err != nil { |
| return nil, err |
| } |
| var owlbotPR *github.PullRequest |
| branch := p.branchOverride |
| if p.branchOverride == "" { |
| branch = owlBotBranchPrefix |
| } |
| for _, pr := range prs { |
| if strings.Contains(*pr.Head.Label, branch) { |
| owlbotPR = pr |
| } |
| } |
| if owlbotPR == nil { |
| return nil, errors.New("no OwlBot PR found") |
| } |
| return owlbotPR, nil |
| } |
| |
| func (p *postProcessor) getScopesFromGoogleapisCommitHash(commitHash string) ([]string, error) { |
| files, err := filesChanged(p.googleapisDir, commitHash) |
| if err != nil { |
| return nil, err |
| } |
| // if no files changed, return empty string |
| if len(files) == 0 { |
| return nil, nil |
| } |
| scopesMap := make(map[string]bool) |
| scopes := []string{} |
| for _, filePath := range files { |
| // Need import path |
| for inputDir, li := range p.config.GoogleapisToImportPath { |
| if inputDir == filepath.Dir(filePath) { |
| // trim service version |
| scope := filepath.Dir(li.RelPath) |
| // trim leading slash |
| scope = strings.TrimPrefix(scope, "/") |
| if _, value := scopesMap[scope]; !value { |
| scopesMap[scope] = true |
| scopes = append(scopes, scope) |
| } |
| break |
| } |
| } |
| } |
| return scopes, nil |
| } |
| |
| func extractHashFromLine(line string) string { |
| hash := fmt.Sprintf("${%s}", hashFromLinePattern.SubexpNames()[1]) |
| hashVal := hashFromLinePattern.ReplaceAllString(line, hash) |
| return hashVal |
| } |
| |
| func updateCommitTitle(title, titlePkg string) string { |
| var breakChangeIndicator string |
| titleParts := strings.Split(title, ":") |
| commitPrefix := titleParts[0] |
| msg := strings.TrimSpace(titleParts[1]) |
| |
| // If a scope is already provided, remove it. |
| if i := strings.Index(commitPrefix, "("); i > 0 { |
| commitPrefix = commitPrefix[:i] |
| } |
| if strings.HasSuffix(commitPrefix, "!") { |
| breakChangeIndicator = "!" |
| } |
| if titlePkg == "" { |
| return fmt.Sprintf("%v%v: %v", commitPrefix, breakChangeIndicator, msg) |
| } |
| return fmt.Sprintf("%v(%v)%v: %v", commitPrefix, titlePkg, breakChangeIndicator, msg) |
| } |
| |
| // WritePRInfoToFile uses OwlBot env variable specified path to write updated |
| // PR title and body at that location |
| func (p *postProcessor) WritePRInfoToFile(prTitle, prBody string) error { |
| if prTitle == "" && prBody == "" { |
| log.Println("No updated PR info found, will not write PR title and description to file.") |
| return nil |
| } |
| // if file exists at location, delete |
| if err := os.Remove(p.prFilepath); err != nil { |
| if errors.Is(err, fs.ErrNotExist) { |
| log.Println(err) |
| } else { |
| return err |
| } |
| } |
| f, err := os.OpenFile(p.prFilepath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) |
| if err != nil { |
| return err |
| } |
| defer f.Close() |
| log.Println("Writing PR title and description to file.") |
| if _, err := f.WriteString(fmt.Sprintf("%s\n\n%s", prTitle, prBody)); err != nil { |
| return err |
| } |
| return nil |
| } |