blob: 9d68021ef5b731d3bf4e2f6f9d712ff81e8e37fb [file] [log] [blame]
// 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
}