Skip to content

Commit e3c9e31

Browse files
authored
feat: add ability to create branch and push to remote repository (#1775)
Fixes #1762
1 parent f3c16e8 commit e3c9e31

File tree

7 files changed

+198
-43
lines changed

7 files changed

+198
-43
lines changed

internal/github/github.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,10 @@ func (c *Client) GetRawContent(ctx context.Context, path, ref string) ([]byte, e
119119
// which must have a GitHub HTTPS URL. We assume a base branch of "main".
120120
func (c *Client) CreatePullRequest(ctx context.Context, repo *Repository, remoteBranch, title, body string) (*PullRequestMetadata, error) {
121121
if body == "" {
122+
slog.Warn("Provided PR body is empty, setting default.")
122123
body = "Regenerated all changed APIs. See individual commits for details."
123124
}
125+
slog.Info("Creating PR", "branch", remoteBranch, "title", title, "body", body)
124126
newPR := &github.NewPullRequest{
125127
Title: &title,
126128
Head: &remoteBranch,

internal/gitrepo/gitrepo.go

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@ import (
2323
"strings"
2424

2525
"github.com/go-git/go-git/v5"
26+
"github.com/go-git/go-git/v5/config"
2627
"github.com/go-git/go-git/v5/plumbing"
2728
"github.com/go-git/go-git/v5/plumbing/object"
29+
httpAuth "github.com/go-git/go-git/v5/plumbing/transport/http"
2830
)
2931

3032
// Repository defines the interface for git repository operations.
@@ -37,12 +39,15 @@ type Repository interface {
3739
HeadHash() (string, error)
3840
ChangedFilesInCommit(commitHash string) ([]string, error)
3941
GetCommitsForPathsSinceTag(paths []string, tagName string) ([]*Commit, error)
42+
CreateBranchAndCheckout(name string) error
43+
Push(branchName string) error
4044
}
4145

4246
// LocalRepository represents a git repository.
4347
type LocalRepository struct {
44-
Dir string
45-
repo *git.Repository
48+
Dir string
49+
repo *git.Repository
50+
gitPassword string
4651
}
4752

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

6875
// NewRepository provides access to a git repository based on the provided options.
@@ -72,6 +79,15 @@ type RepositoryOptions struct {
7279
// otherwise it clones from opts.RemoteURL.
7380
// If opts.Clone is CloneOptionAlways, it always clones from opts.RemoteURL.
7481
func NewRepository(opts *RepositoryOptions) (*LocalRepository, error) {
82+
repo, err := newRepositoryWithoutUser(opts)
83+
if err != nil {
84+
return repo, err
85+
}
86+
repo.gitPassword = opts.GitPassword
87+
return repo, nil
88+
}
89+
90+
func newRepositoryWithoutUser(opts *RepositoryOptions) (*LocalRepository, error) {
7591
if opts.Dir == "" {
7692
return nil, errors.New("gitrepo: dir is required")
7793
}
@@ -100,6 +116,7 @@ func open(dir string) (*LocalRepository, error) {
100116
if err != nil {
101117
return nil, err
102118
}
119+
103120
return &LocalRepository{
104121
Dir: dir,
105122
repo: repo,
@@ -363,3 +380,41 @@ func (r *LocalRepository) ChangedFilesInCommit(commitHash string) ([]string, err
363380
}
364381
return files, nil
365382
}
383+
384+
// CreateBranchAndCheckout creates a new git branch and checks out the
385+
// branch in the local git repository.
386+
func (r *LocalRepository) CreateBranchAndCheckout(name string) error {
387+
slog.Info("Creating branch and checking out", "name", name)
388+
worktree, err := r.repo.Worktree()
389+
if err != nil {
390+
return err
391+
}
392+
return worktree.Checkout(&git.CheckoutOptions{
393+
Branch: plumbing.NewBranchReferenceName(name),
394+
Create: true,
395+
Keep: true,
396+
})
397+
}
398+
399+
// Push pushes the local branch to the origin remote.
400+
func (r *LocalRepository) Push(branchName string) error {
401+
// https://stackoverflow.com/a/75727620
402+
refSpec := config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/heads/%s", branchName, branchName))
403+
slog.Info("Pushing changes", slog.Any("refspec", refSpec))
404+
var auth *httpAuth.BasicAuth
405+
if r.gitPassword != "" {
406+
slog.Info("Authenticating with basic auth")
407+
auth = &httpAuth.BasicAuth{
408+
Password: r.gitPassword,
409+
}
410+
}
411+
if err := r.repo.Push(&git.PushOptions{
412+
RemoteName: "origin",
413+
RefSpecs: []config.RefSpec{refSpec},
414+
Auth: auth,
415+
}); err != nil {
416+
return err
417+
}
418+
slog.Info("Successfully pushed branch to remote 'origin", "branch", branchName)
419+
return nil
420+
}

internal/gitrepo/gitrepo_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -975,6 +975,47 @@ func TestGetCommitsForPathsSinceTag(t *testing.T) {
975975
}
976976
}
977977

978+
func TestCreateBranchAndCheckout(t *testing.T) {
979+
for _, test := range []struct {
980+
name string
981+
branchName string
982+
wantErr bool
983+
wantErrPhrase string
984+
}{
985+
{
986+
name: "works",
987+
branchName: "test-branch",
988+
},
989+
{
990+
name: "invalid branch name",
991+
branchName: "invalid branch name",
992+
wantErr: true,
993+
wantErrPhrase: "invalid",
994+
},
995+
} {
996+
t.Run(test.name, func(t *testing.T) {
997+
repo, _ := setupRepoForGetCommitsTest(t)
998+
err := repo.CreateBranchAndCheckout(test.branchName)
999+
if test.wantErr {
1000+
if err == nil {
1001+
t.Errorf("%s should return error", test.name)
1002+
}
1003+
if !strings.Contains(err.Error(), test.wantErrPhrase) {
1004+
t.Errorf("CreateBranchAndCheckout() returned error %q, want to contain %q", err.Error(), test.wantErrPhrase)
1005+
}
1006+
return
1007+
}
1008+
if err != nil {
1009+
t.Fatal(err)
1010+
}
1011+
head, _ := repo.repo.Head()
1012+
if diff := cmp.Diff(test.branchName, head.Name().Short()); diff != "" {
1013+
t.Errorf("CreateBranchAndCheckout() mismatch (-want +got):\n%s", diff)
1014+
}
1015+
})
1016+
}
1017+
}
1018+
9781019
// initTestRepo creates a new git repository in a temporary directory.
9791020
func initTestRepo(t *testing.T) (*git.Repository, string) {
9801021
t.Helper()

internal/librarian/command.go

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,16 @@ func newCommandRunner(cfg *config.Config) (*commandRunner, error) {
4747
if cfg.APISource == "" {
4848
cfg.APISource = "https://github.com/googleapis/googleapis"
4949
}
50-
languageRepo, err := cloneOrOpenRepo(cfg.WorkRoot, cfg.Repo, cfg.CI)
50+
51+
languageRepo, err := cloneOrOpenRepo(cfg.WorkRoot, cfg.Repo, cfg.CI, cfg.GitHubToken)
5152
if err != nil {
5253
return nil, err
5354
}
5455

5556
var sourceRepo gitrepo.Repository
5657
var sourceRepoDir string
5758
if cfg.CommandName != tagAndReleaseCmdName {
58-
sourceRepo, err = cloneOrOpenRepo(cfg.WorkRoot, cfg.APISource, cfg.CI)
59+
sourceRepo, err = cloneOrOpenRepo(cfg.WorkRoot, cfg.APISource, cfg.CI, cfg.GitHubToken)
5960
if err != nil {
6061
return nil, err
6162
}
@@ -107,7 +108,7 @@ func newCommandRunner(cfg *config.Config) (*commandRunner, error) {
107108
}, nil
108109
}
109110

110-
func cloneOrOpenRepo(workRoot, repo, ci string) (*gitrepo.LocalRepository, error) {
111+
func cloneOrOpenRepo(workRoot, repo, ci string, gitPassword string) (*gitrepo.LocalRepository, error) {
111112
if repo == "" {
112113
return nil, errors.New("repo must be specified")
113114
}
@@ -119,10 +120,11 @@ func cloneOrOpenRepo(workRoot, repo, ci string) (*gitrepo.LocalRepository, error
119120
repoName := path.Base(strings.TrimSuffix(repo, "/"))
120121
repoPath := filepath.Join(workRoot, repoName)
121122
return gitrepo.NewRepository(&gitrepo.RepositoryOptions{
122-
Dir: repoPath,
123-
MaybeClone: true,
124-
RemoteURL: repo,
125-
CI: ci,
123+
Dir: repoPath,
124+
MaybeClone: true,
125+
RemoteURL: repo,
126+
CI: ci,
127+
GitPassword: gitPassword,
126128
})
127129
}
128130
// repo is a directory
@@ -131,8 +133,9 @@ func cloneOrOpenRepo(workRoot, repo, ci string) (*gitrepo.LocalRepository, error
131133
return nil, err
132134
}
133135
githubRepo, err := gitrepo.NewRepository(&gitrepo.RepositoryOptions{
134-
Dir: absRepoRoot,
135-
CI: ci,
136+
Dir: absRepoRoot,
137+
CI: ci,
138+
GitPassword: gitPassword,
136139
})
137140
if err != nil {
138141
return nil, err
@@ -212,17 +215,27 @@ func commitAndPush(ctx context.Context, r *generateRunner, commitMessage string)
212215
return nil
213216
}
214217

218+
datetimeNow := formatTimestamp(time.Now())
219+
branch := fmt.Sprintf("librarian-%s", datetimeNow)
220+
slog.Info("Creating branch", slog.String("branch", branch))
221+
if err := r.repo.CreateBranchAndCheckout(branch); err != nil {
222+
return err
223+
}
224+
215225
// TODO: get correct language for message (https://github.com/googleapis/librarian/issues/885)
226+
slog.Info("Committing", "message", commitMessage)
216227
if err := r.repo.Commit(commitMessage); err != nil {
217228
return err
218229
}
219230

231+
if err := r.repo.Push(branch); err != nil {
232+
return err
233+
}
234+
220235
// Create a new branch, set title and message for the PR.
221-
datetimeNow := formatTimestamp(time.Now())
222236
titlePrefix := "Librarian pull request"
223-
branch := fmt.Sprintf("librarian-%s", datetimeNow)
224237
title := fmt.Sprintf("%s: %s", titlePrefix, datetimeNow)
225-
238+
slog.Info("Creating pull request", slog.String("branch", branch), slog.String("title", title))
226239
if _, err = r.ghClient.CreatePullRequest(ctx, gitHubRepo, branch, title, commitMessage); err != nil {
227240
return fmt.Errorf("failed to create pull request: %w", err)
228241
}

internal/librarian/command_test.go

Lines changed: 56 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ func TestCloneOrOpenLanguageRepo(t *testing.T) {
254254
}
255255
}()
256256

257-
repo, err := cloneOrOpenRepo(workRoot, test.repo, test.ci)
257+
repo, err := cloneOrOpenRepo(workRoot, test.repo, test.ci, "")
258258
if test.wantErr {
259259
if err == nil {
260260
t.Error("cloneOrOpenLanguageRepo() expected an error but got nil")
@@ -302,42 +302,21 @@ func TestCommitAndPush(t *testing.T) {
302302
{
303303
name: "Happy Path",
304304
setupMockRepo: func(t *testing.T) gitrepo.Repository {
305-
repoDir := newTestGitRepoWithCommit(t, "")
306-
// Add remote so FetchGitHubRepoFromRemote succeeds.
307-
cmd := exec.Command("git", "remote", "add", "origin", "https://github.com/test-owner/test-repo.git")
308-
cmd.Dir = repoDir
309-
if err := cmd.Run(); err != nil {
310-
t.Fatalf("git remote add: %v", err)
311-
}
312-
// Add a file to make the repo dirty, so there's something to commit.
313-
if err := os.WriteFile(filepath.Join(repoDir, "new-file.txt"), []byte("new content"), 0644); err != nil {
314-
t.Fatalf("WriteFile: %v", err)
315-
}
316-
repo, err := gitrepo.NewRepository(&gitrepo.RepositoryOptions{Dir: repoDir})
317-
if err != nil {
318-
t.Fatalf("Failed to create test repo: %v", err)
305+
remote := git.NewRemote(memory.NewStorage(), &gogitConfig.RemoteConfig{
306+
Name: "origin",
307+
URLs: []string{"https://github.com/googleapis/librarian.git"},
308+
})
309+
return &MockRepository{
310+
Dir: t.TempDir(),
311+
RemotesValue: []*git.Remote{remote},
319312
}
320-
return repo
321313
},
322314
setupMockClient: func(t *testing.T) GitHubClient {
323315
return &mockGitHubClient{
324316
createdPR: &github.PullRequestMetadata{Number: 123, Repo: &github.Repository{Owner: "test-owner", Name: "test-repo"}},
325317
}
326318
},
327319
push: true,
328-
validatePostTest: func(t *testing.T, repo gitrepo.Repository) {
329-
localRepo, ok := repo.(*gitrepo.LocalRepository)
330-
if !ok {
331-
t.Fatalf("Expected *gitrepo.LocalRepository, got %T", repo)
332-
}
333-
isClean, err := localRepo.IsClean()
334-
if err != nil {
335-
t.Fatalf("Failed to check repo status: %v", err)
336-
}
337-
if !isClean {
338-
t.Errorf("Expected repository to be clean after commit, but it's dirty")
339-
}
340-
},
341320
},
342321
{
343322
name: "No GitHub Remote",
@@ -374,6 +353,30 @@ func TestCommitAndPush(t *testing.T) {
374353
wantErr: true,
375354
expectedErrMsg: "mock add all error",
376355
},
356+
{
357+
name: "Create branch error",
358+
setupMockRepo: func(t *testing.T) gitrepo.Repository {
359+
remote := git.NewRemote(memory.NewStorage(), &gogitConfig.RemoteConfig{
360+
Name: "origin",
361+
URLs: []string{"https://github.com/googleapis/librarian.git"},
362+
})
363+
364+
status := make(git.Status)
365+
status["file.txt"] = &git.FileStatus{Worktree: git.Modified}
366+
return &MockRepository{
367+
Dir: t.TempDir(),
368+
AddAllStatus: status,
369+
RemotesValue: []*git.Remote{remote},
370+
CreateBranchAndCheckoutError: errors.New("create branch error"),
371+
}
372+
},
373+
setupMockClient: func(t *testing.T) GitHubClient {
374+
return nil
375+
},
376+
push: true,
377+
wantErr: true,
378+
expectedErrMsg: "create branch error",
379+
},
377380
{
378381
name: "Commit error",
379382
setupMockRepo: func(t *testing.T) gitrepo.Repository {
@@ -398,6 +401,30 @@ func TestCommitAndPush(t *testing.T) {
398401
wantErr: true,
399402
expectedErrMsg: "commit error",
400403
},
404+
{
405+
name: "Push error",
406+
setupMockRepo: func(t *testing.T) gitrepo.Repository {
407+
remote := git.NewRemote(memory.NewStorage(), &gogitConfig.RemoteConfig{
408+
Name: "origin",
409+
URLs: []string{"https://github.com/googleapis/librarian.git"},
410+
})
411+
412+
status := make(git.Status)
413+
status["file.txt"] = &git.FileStatus{Worktree: git.Modified}
414+
return &MockRepository{
415+
Dir: t.TempDir(),
416+
AddAllStatus: status,
417+
RemotesValue: []*git.Remote{remote},
418+
PushError: errors.New("push error"),
419+
}
420+
},
421+
setupMockClient: func(t *testing.T) GitHubClient {
422+
return nil
423+
},
424+
push: true,
425+
wantErr: true,
426+
expectedErrMsg: "push error",
427+
},
401428
{
402429
name: "Create pull request error",
403430
setupMockRepo: func(t *testing.T) gitrepo.Repository {

internal/librarian/generate.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ func (r *generateRunner) run(ctx context.Context) error {
135135
if err := r.generateSingleLibrary(ctx, libraryID, outputDir); err != nil {
136136
return err
137137
}
138+
prBody += fmt.Sprintf("feat: generated %s\n", libraryID)
138139
} else {
139140
for _, library := range r.state.Libraries {
140141
if err := r.generateSingleLibrary(ctx, library.ID, outputDir); err != nil {

0 commit comments

Comments
 (0)