Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions internal/github/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,10 @@ func (c *Client) GetRawContent(ctx context.Context, path, ref string) ([]byte, e
// which must have a GitHub HTTPS URL. We assume a base branch of "main".
func (c *Client) CreatePullRequest(ctx context.Context, repo *Repository, remoteBranch, title, body string) (*PullRequestMetadata, error) {
if body == "" {
slog.Warn("Provided PR body is empty, setting default.")
body = "Regenerated all changed APIs. See individual commits for details."
}
slog.Info("Creating PR", "branch", remoteBranch, "title", title, "body", body)
newPR := &github.NewPullRequest{
Title: &title,
Head: &remoteBranch,
Expand Down
59 changes: 57 additions & 2 deletions internal/gitrepo/gitrepo.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ import (
"strings"

"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
httpAuth "github.com/go-git/go-git/v5/plumbing/transport/http"
)

// Repository defines the interface for git repository operations.
Expand All @@ -37,12 +39,15 @@ type Repository interface {
HeadHash() (string, error)
ChangedFilesInCommit(commitHash string) ([]string, error)
GetCommitsForPathsSinceTag(paths []string, tagName string) ([]*Commit, error)
CreateBranchAndCheckout(name string) error
Push(branchName string) error
}

// LocalRepository represents a git repository.
type LocalRepository struct {
Dir string
repo *git.Repository
Dir string
repo *git.Repository
gitPassword string
}

// Commit represents a git commit.
Expand All @@ -63,6 +68,8 @@ type RepositoryOptions struct {
// CI is the type of Continuous Integration (CI) environment in which
// the tool is executing.
CI string
// GitPassword is used for HTTP basic auth.
GitPassword string
}

// NewRepository provides access to a git repository based on the provided options.
Expand All @@ -72,6 +79,15 @@ type RepositoryOptions struct {
// otherwise it clones from opts.RemoteURL.
// If opts.Clone is CloneOptionAlways, it always clones from opts.RemoteURL.
func NewRepository(opts *RepositoryOptions) (*LocalRepository, error) {
repo, err := newRepositoryWithoutUser(opts)
if err != nil {
return repo, err
}
repo.gitPassword = opts.GitPassword
return repo, nil
}

func newRepositoryWithoutUser(opts *RepositoryOptions) (*LocalRepository, error) {
if opts.Dir == "" {
return nil, errors.New("gitrepo: dir is required")
}
Expand Down Expand Up @@ -100,6 +116,7 @@ func open(dir string) (*LocalRepository, error) {
if err != nil {
return nil, err
}

return &LocalRepository{
Dir: dir,
repo: repo,
Expand Down Expand Up @@ -363,3 +380,41 @@ func (r *LocalRepository) ChangedFilesInCommit(commitHash string) ([]string, err
}
return files, nil
}

// CreateBranchAndCheckout creates a new git branch and checks out the
// branch in the local git repository.
func (r *LocalRepository) CreateBranchAndCheckout(name string) error {
slog.Info("Creating branch and checking out", "name", name)
worktree, err := r.repo.Worktree()
if err != nil {
return err
}
return worktree.Checkout(&git.CheckoutOptions{
Branch: plumbing.NewBranchReferenceName(name),
Create: true,
Keep: true,
})
}

// Push pushes the local branch to the origin remote.
func (r *LocalRepository) Push(branchName string) error {
// https://stackoverflow.com/a/75727620
refSpec := config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/heads/%s", branchName, branchName))
slog.Info("Pushing changes", slog.Any("refspec", refSpec))
var auth *httpAuth.BasicAuth
if r.gitPassword != "" {
slog.Info("Authenticating with basic auth")
auth = &httpAuth.BasicAuth{
Password: r.gitPassword,
}
}
if err := r.repo.Push(&git.PushOptions{
RemoteName: "origin",
RefSpecs: []config.RefSpec{refSpec},
Auth: auth,
}); err != nil {
return err
}
slog.Info("Successfully pushed branch to remote 'origin", "branch", branchName)
return nil
}
41 changes: 41 additions & 0 deletions internal/gitrepo/gitrepo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -975,6 +975,47 @@ func TestGetCommitsForPathsSinceTag(t *testing.T) {
}
}

func TestCreateBranchAndCheckout(t *testing.T) {
for _, test := range []struct {
name string
branchName string
wantErr bool
wantErrPhrase string
}{
{
name: "works",
branchName: "test-branch",
},
{
name: "invalid branch name",
branchName: "invalid branch name",
wantErr: true,
wantErrPhrase: "invalid",
},
} {
t.Run(test.name, func(t *testing.T) {
repo, _ := setupRepoForGetCommitsTest(t)
err := repo.CreateBranchAndCheckout(test.branchName)
if test.wantErr {
if err == nil {
t.Errorf("%s should return error", test.name)
}
if !strings.Contains(err.Error(), test.wantErrPhrase) {
t.Errorf("CreateBranchAndCheckout() returned error %q, want to contain %q", err.Error(), test.wantErrPhrase)
}
return
}
if err != nil {
t.Fatal(err)
}
head, _ := repo.repo.Head()
if diff := cmp.Diff(test.branchName, head.Name().Short()); diff != "" {
t.Errorf("CreateBranchAndCheckout() mismatch (-want +got):\n%s", diff)
}
})
}
}

// initTestRepo creates a new git repository in a temporary directory.
func initTestRepo(t *testing.T) (*git.Repository, string) {
t.Helper()
Expand Down
37 changes: 25 additions & 12 deletions internal/librarian/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,16 @@ func newCommandRunner(cfg *config.Config) (*commandRunner, error) {
if cfg.APISource == "" {
cfg.APISource = "https://github.com/googleapis/googleapis"
}
languageRepo, err := cloneOrOpenRepo(cfg.WorkRoot, cfg.Repo, cfg.CI)

languageRepo, err := cloneOrOpenRepo(cfg.WorkRoot, cfg.Repo, cfg.CI, cfg.GitHubToken)
if err != nil {
return nil, err
}

var sourceRepo gitrepo.Repository
var sourceRepoDir string
if cfg.CommandName != tagAndReleaseCmdName {
sourceRepo, err = cloneOrOpenRepo(cfg.WorkRoot, cfg.APISource, cfg.CI)
sourceRepo, err = cloneOrOpenRepo(cfg.WorkRoot, cfg.APISource, cfg.CI, cfg.GitHubToken)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -99,7 +100,7 @@ func newCommandRunner(cfg *config.Config) (*commandRunner, error) {
}, nil
}

func cloneOrOpenRepo(workRoot, repo, ci string) (*gitrepo.LocalRepository, error) {
func cloneOrOpenRepo(workRoot, repo, ci string, gitPassword string) (*gitrepo.LocalRepository, error) {
if repo == "" {
return nil, errors.New("repo must be specified")
}
Expand All @@ -111,10 +112,11 @@ func cloneOrOpenRepo(workRoot, repo, ci string) (*gitrepo.LocalRepository, error
repoName := path.Base(strings.TrimSuffix(repo, "/"))
repoPath := filepath.Join(workRoot, repoName)
return gitrepo.NewRepository(&gitrepo.RepositoryOptions{
Dir: repoPath,
MaybeClone: true,
RemoteURL: repo,
CI: ci,
Dir: repoPath,
MaybeClone: true,
RemoteURL: repo,
CI: ci,
GitPassword: gitPassword,
})
}
// repo is a directory
Expand All @@ -123,8 +125,9 @@ func cloneOrOpenRepo(workRoot, repo, ci string) (*gitrepo.LocalRepository, error
return nil, err
}
githubRepo, err := gitrepo.NewRepository(&gitrepo.RepositoryOptions{
Dir: absRepoRoot,
CI: ci,
Dir: absRepoRoot,
CI: ci,
GitPassword: gitPassword,
})
if err != nil {
return nil, err
Expand Down Expand Up @@ -204,17 +207,27 @@ func commitAndPush(ctx context.Context, r *generateRunner, commitMessage string)
return nil
}

datetimeNow := formatTimestamp(time.Now())
branch := fmt.Sprintf("librarian-%s", datetimeNow)
slog.Info("Creating branch", slog.String("branch", branch))
if err := r.repo.CreateBranchAndCheckout(branch); err != nil {
return err
}

// TODO: get correct language for message (https://github.com/googleapis/librarian/issues/885)
slog.Info("Committing", "message", commitMessage)
if err := r.repo.Commit(commitMessage); err != nil {
return err
}

if err := r.repo.Push(branch); err != nil {
return err
}

// Create a new branch, set title and message for the PR.
datetimeNow := formatTimestamp(time.Now())
titlePrefix := "Librarian pull request"
branch := fmt.Sprintf("librarian-%s", datetimeNow)
title := fmt.Sprintf("%s: %s", titlePrefix, datetimeNow)

slog.Info("Creating pull request", slog.String("branch", branch), slog.String("title", title))
if _, err = r.ghClient.CreatePullRequest(ctx, gitHubRepo, branch, title, commitMessage); err != nil {
return fmt.Errorf("failed to create pull request: %w", err)
}
Expand Down
85 changes: 56 additions & 29 deletions internal/librarian/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ func TestCloneOrOpenLanguageRepo(t *testing.T) {
}
}()

repo, err := cloneOrOpenRepo(workRoot, test.repo, test.ci)
repo, err := cloneOrOpenRepo(workRoot, test.repo, test.ci, "")
if test.wantErr {
if err == nil {
t.Error("cloneOrOpenLanguageRepo() expected an error but got nil")
Expand Down Expand Up @@ -302,42 +302,21 @@ func TestCommitAndPush(t *testing.T) {
{
name: "Happy Path",
setupMockRepo: func(t *testing.T) gitrepo.Repository {
repoDir := newTestGitRepoWithCommit(t, "")
// Add remote so FetchGitHubRepoFromRemote succeeds.
cmd := exec.Command("git", "remote", "add", "origin", "https://github.com/test-owner/test-repo.git")
cmd.Dir = repoDir
if err := cmd.Run(); err != nil {
t.Fatalf("git remote add: %v", err)
}
// Add a file to make the repo dirty, so there's something to commit.
if err := os.WriteFile(filepath.Join(repoDir, "new-file.txt"), []byte("new content"), 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
repo, err := gitrepo.NewRepository(&gitrepo.RepositoryOptions{Dir: repoDir})
if err != nil {
t.Fatalf("Failed to create test repo: %v", err)
remote := git.NewRemote(memory.NewStorage(), &gogitConfig.RemoteConfig{
Name: "origin",
URLs: []string{"https://github.com/googleapis/librarian.git"},
})
return &MockRepository{
Dir: t.TempDir(),
RemotesValue: []*git.Remote{remote},
}
return repo
},
setupMockClient: func(t *testing.T) GitHubClient {
return &mockGitHubClient{
createdPR: &github.PullRequestMetadata{Number: 123, Repo: &github.Repository{Owner: "test-owner", Name: "test-repo"}},
}
},
push: true,
validatePostTest: func(t *testing.T, repo gitrepo.Repository) {
localRepo, ok := repo.(*gitrepo.LocalRepository)
if !ok {
t.Fatalf("Expected *gitrepo.LocalRepository, got %T", repo)
}
isClean, err := localRepo.IsClean()
if err != nil {
t.Fatalf("Failed to check repo status: %v", err)
}
if !isClean {
t.Errorf("Expected repository to be clean after commit, but it's dirty")
}
},
},
{
name: "No GitHub Remote",
Expand Down Expand Up @@ -374,6 +353,30 @@ func TestCommitAndPush(t *testing.T) {
wantErr: true,
expectedErrMsg: "mock add all error",
},
{
name: "Create branch error",
setupMockRepo: func(t *testing.T) gitrepo.Repository {
remote := git.NewRemote(memory.NewStorage(), &gogitConfig.RemoteConfig{
Name: "origin",
URLs: []string{"https://github.com/googleapis/librarian.git"},
})

status := make(git.Status)
status["file.txt"] = &git.FileStatus{Worktree: git.Modified}
return &MockRepository{
Dir: t.TempDir(),
AddAllStatus: status,
RemotesValue: []*git.Remote{remote},
CreateBranchAndCheckoutError: errors.New("create branch error"),
}
},
setupMockClient: func(t *testing.T) GitHubClient {
return nil
},
push: true,
wantErr: true,
expectedErrMsg: "create branch error",
},
{
name: "Commit error",
setupMockRepo: func(t *testing.T) gitrepo.Repository {
Expand All @@ -398,6 +401,30 @@ func TestCommitAndPush(t *testing.T) {
wantErr: true,
expectedErrMsg: "commit error",
},
{
name: "Push error",
setupMockRepo: func(t *testing.T) gitrepo.Repository {
remote := git.NewRemote(memory.NewStorage(), &gogitConfig.RemoteConfig{
Name: "origin",
URLs: []string{"https://github.com/googleapis/librarian.git"},
})

status := make(git.Status)
status["file.txt"] = &git.FileStatus{Worktree: git.Modified}
return &MockRepository{
Dir: t.TempDir(),
AddAllStatus: status,
RemotesValue: []*git.Remote{remote},
PushError: errors.New("push error"),
}
},
setupMockClient: func(t *testing.T) GitHubClient {
return nil
},
push: true,
wantErr: true,
expectedErrMsg: "push error",
},
{
name: "Create pull request error",
setupMockRepo: func(t *testing.T) gitrepo.Repository {
Expand Down
1 change: 1 addition & 0 deletions internal/librarian/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ func (r *generateRunner) run(ctx context.Context) error {
if err := r.generateSingleLibrary(ctx, libraryID, outputDir); err != nil {
return err
}
prBody += fmt.Sprintf("feat: generated %s\n", libraryID)
} else {
for _, library := range r.state.Libraries {
if err := r.generateSingleLibrary(ctx, library.ID, outputDir); err != nil {
Expand Down
Loading