Skip to content

Commit 5e0a308

Browse files
authored
feat: use API on app authentication mode (#326)
Uses the Github APIs for branch creation and reset as well as for creating and pushing commits when the authentication type used is "app". The use of Github APIs allows the generation of verified commits with the app as author.
1 parent 5eefa96 commit 5e0a308

File tree

4 files changed

+257
-6
lines changed

4 files changed

+257
-6
lines changed

repository/commit.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,8 @@ func NewCommitMessage(title, body, footer string) CommitMessage {
3737
Body: bodyWithFooter.String(),
3838
}
3939
}
40+
41+
type CommitFileChanges struct {
42+
Upserted []string
43+
Deleted []string
44+
}

repository/git.go

Lines changed: 238 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"fmt"
88
"net/url"
99
"os"
10+
"path/filepath"
1011
"strings"
1112
"time"
1213

@@ -16,6 +17,9 @@ import (
1617
"github.com/go-git/go-git/v5/plumbing"
1718
"github.com/go-git/go-git/v5/plumbing/object"
1819
"github.com/go-git/go-git/v5/plumbing/transport/http"
20+
"github.com/go-git/go-git/v5/utils/merkletrie"
21+
"github.com/google/go-github/v57/github"
22+
"github.com/shurcooL/githubv4"
1923
"github.com/sirupsen/logrus"
2024
)
2125

@@ -63,6 +67,81 @@ func cloneGitRepository(ctx context.Context, repo Repository, localPath string,
6367
return gitRepo, nil
6468
}
6569

70+
type createBranchOptions struct {
71+
GitHubOpts GitHubOptions
72+
Repository Repository
73+
BranchName string
74+
CommitSHA string
75+
}
76+
77+
func createBranchWithAPI(ctx context.Context, opts createBranchOptions) error {
78+
client, _, err := githubClient(ctx, opts.GitHubOpts)
79+
if err != nil {
80+
return fmt.Errorf("failed to create github client: %w", err)
81+
}
82+
83+
repository, _, err := client.Repositories.Get(ctx, opts.Repository.Owner, opts.Repository.Name)
84+
if err != nil {
85+
return fmt.Errorf("failed to fetch repository: %w", err)
86+
}
87+
88+
gqlClient, err := githubGraphqlClient(ctx, opts.GitHubOpts)
89+
if err != nil {
90+
return fmt.Errorf("failed to create github GraphQL client: %w", err)
91+
}
92+
93+
inputs := githubv4.CreateRefInput{
94+
RepositoryID: githubv4.ID(repository.NodeID),
95+
Name: githubv4.String(fmt.Sprintf("refs/heads/%s", opts.BranchName)),
96+
Oid: githubv4.GitObjectID(opts.CommitSHA),
97+
}
98+
99+
var mutation struct {
100+
CreateRefInput struct {
101+
ClientMutationID string
102+
} `graphql:"createRef(input: $input)"`
103+
}
104+
105+
err = gqlClient.Mutate(ctx, &mutation, inputs, nil)
106+
if err != nil {
107+
return fmt.Errorf("failed to create branch: %w", err)
108+
}
109+
return nil
110+
}
111+
112+
type resetBranchOptions struct {
113+
GitHubOpts GitHubOptions
114+
Repository Repository
115+
BranchName string
116+
CommitSHA string
117+
}
118+
119+
func resetBranchWithAPI(ctx context.Context, opts resetBranchOptions) error {
120+
client, _, err := githubClient(ctx, opts.GitHubOpts)
121+
if err != nil {
122+
return fmt.Errorf("failed to create github client: %w", err)
123+
}
124+
125+
branchRef := fmt.Sprintf("refs/heads/%s", opts.BranchName)
126+
127+
_, _, err = client.Git.UpdateRef(
128+
ctx,
129+
opts.Repository.Owner,
130+
opts.Repository.Name,
131+
&github.Reference{
132+
Ref: &branchRef,
133+
Object: &github.GitObject{
134+
SHA: &opts.CommitSHA,
135+
},
136+
},
137+
true,
138+
)
139+
if err != nil {
140+
return fmt.Errorf("failed to update branch ref: %w", err)
141+
}
142+
return nil
143+
}
144+
66145
type switchBranchOptions struct {
67146
Repository Repository
68147
BranchName string
@@ -198,14 +277,146 @@ func parseSigningKey(signingKeyPath, signingKeyPassphrase string) (*openpgp.Enti
198277
return signingKey, nil
199278
}
200279

201-
type pushOptions struct {
202-
GitHubOpts GitHubOptions
203-
Repository Repository
204-
BranchName string
205-
ResetFromBase bool
280+
func getLatestCommit(_ context.Context, gitRepo *git.Repository) (*object.Commit, error) {
281+
headCommitRef, err := gitRepo.Head()
282+
if err != nil {
283+
return nil, fmt.Errorf("failed to fetch head: %w", err)
284+
}
285+
286+
latestCommit, err := gitRepo.CommitObject(headCommitRef.Hash())
287+
if err != nil {
288+
return nil, fmt.Errorf("failed to fetch commit: %w", err)
289+
}
290+
return latestCommit, nil
206291
}
207292

208-
func pushChanges(ctx context.Context, gitRepo *git.Repository, opts pushOptions) error {
293+
func compareCommits(base, head *object.Commit) (*CommitFileChanges, error) {
294+
baseTree, err := base.Tree()
295+
if err != nil {
296+
return nil, fmt.Errorf("failed to get base commit tree: %w", err)
297+
}
298+
299+
headTree, err := head.Tree()
300+
if err != nil {
301+
return nil, fmt.Errorf("failed to get head commit tree: %w", err)
302+
}
303+
304+
changes, err := baseTree.Diff(headTree)
305+
if err != nil {
306+
return nil, fmt.Errorf("failed to compare commit trees: %w", err)
307+
}
308+
309+
commitFileChanges := CommitFileChanges{}
310+
for _, change := range changes {
311+
action, err := change.Action()
312+
if err != nil {
313+
return nil, fmt.Errorf("failed to get commit change action: %w", err)
314+
}
315+
316+
if action == merkletrie.Delete {
317+
commitFileChanges.Deleted = append(commitFileChanges.Deleted, change.From.Name)
318+
} else {
319+
commitFileChanges.Upserted = append(commitFileChanges.Upserted, change.To.Name)
320+
}
321+
}
322+
return &commitFileChanges, nil
323+
}
324+
325+
func pushChangesWithAPI(ctx context.Context, gitRepo *git.Repository, opts pushOptions) error {
326+
commit, err := getLatestCommit(ctx, gitRepo)
327+
if err != nil {
328+
return fmt.Errorf("failed to fetch latest commit: %w", err)
329+
}
330+
331+
parentCommit, err := commit.Parent(0)
332+
if err != nil {
333+
return fmt.Errorf("failed to fetch parent of latest commit: %w", err)
334+
}
335+
336+
parentCommitSHA := parentCommit.Hash.String()
337+
338+
if opts.CreateBranch {
339+
err = createBranchWithAPI(ctx, createBranchOptions{
340+
GitHubOpts: opts.GitHubOpts,
341+
Repository: opts.Repository,
342+
BranchName: opts.BranchName,
343+
CommitSHA: parentCommitSHA,
344+
})
345+
if err != nil {
346+
return fmt.Errorf("failed to create branch: %w", err)
347+
}
348+
} else if opts.ResetFromBase {
349+
err = resetBranchWithAPI(ctx, resetBranchOptions{
350+
GitHubOpts: opts.GitHubOpts,
351+
Repository: opts.Repository,
352+
BranchName: opts.BranchName,
353+
CommitSHA: parentCommitSHA,
354+
})
355+
if err != nil {
356+
return fmt.Errorf("failed to reset branch: %w", err)
357+
}
358+
}
359+
360+
changes, err := compareCommits(parentCommit, commit)
361+
if err != nil {
362+
return fmt.Errorf("failed to compare commits: %w", err)
363+
}
364+
365+
deletions := make([]githubv4.FileDeletion, 0, len(changes.Deleted))
366+
for _, path := range changes.Deleted {
367+
deletions = append(deletions, githubv4.FileDeletion{
368+
Path: githubv4.String(path),
369+
})
370+
}
371+
372+
additions := make([]githubv4.FileAddition, 0, len(changes.Upserted))
373+
repoDirPath := filepath.Join(opts.GitCloneDir, opts.Repository.Owner, opts.Repository.Name)
374+
for _, path := range changes.Upserted {
375+
base64FileContent, err := base64EncodeFile(filepath.Join(repoDirPath, path))
376+
if err != nil {
377+
return fmt.Errorf("failed to encode file to base64: %w", err)
378+
}
379+
additions = append(additions, githubv4.FileAddition{
380+
Path: githubv4.String(path),
381+
Contents: githubv4.Base64String(base64FileContent),
382+
})
383+
}
384+
385+
inputs := githubv4.CreateCommitOnBranchInput{
386+
Branch: githubv4.CommittableBranch{
387+
RepositoryNameWithOwner: githubv4.NewString(githubv4.String(opts.Repository.FullName())),
388+
BranchName: githubv4.NewString(githubv4.String(opts.BranchName)),
389+
},
390+
Message: githubv4.CommitMessage{
391+
Headline: githubv4.String(opts.CommitMessage.Headline),
392+
Body: githubv4.NewString(githubv4.String(opts.CommitMessage.Body)),
393+
},
394+
FileChanges: &githubv4.FileChanges{
395+
Additions: &additions,
396+
Deletions: &deletions,
397+
},
398+
ExpectedHeadOid: githubv4.GitObjectID(parentCommitSHA),
399+
}
400+
401+
var mutation struct {
402+
CreateCommitOnBranchInput struct {
403+
ClientMutationID string
404+
} `graphql:"createCommitOnBranch(input: $input)"`
405+
}
406+
407+
gqlClient, err := githubGraphqlClient(ctx, opts.GitHubOpts)
408+
if err != nil {
409+
return fmt.Errorf("failed to create github GraphQL client: %w", err)
410+
}
411+
412+
err = gqlClient.Mutate(ctx, &mutation, inputs, nil)
413+
if err != nil {
414+
return fmt.Errorf("failed to push branch %s to %s: %w", opts.BranchName, opts.Repository.FullName(), err)
415+
}
416+
return nil
417+
}
418+
419+
func pushChangesWithGit(ctx context.Context, gitRepo *git.Repository, opts pushOptions) error {
209420
refSpec := fmt.Sprintf("refs/heads/%[1]s:refs/heads/%[1]s", opts.BranchName)
210421
if opts.ResetFromBase {
211422
// https://git-scm.com/book/en/v2/Git-Internals-The-Refspec
@@ -242,3 +453,24 @@ func pushChanges(ctx context.Context, gitRepo *git.Repository, opts pushOptions)
242453
}).Debug("Git changes pushed")
243454
return nil
244455
}
456+
457+
type pushOptions struct {
458+
GitHubOpts GitHubOptions
459+
GitCloneDir string
460+
Repository Repository
461+
BranchName string
462+
CreateBranch bool
463+
ResetFromBase bool
464+
CommitMessage CommitMessage
465+
}
466+
467+
func pushChanges(ctx context.Context, gitRepo *git.Repository, opts pushOptions) error {
468+
switch opts.GitHubOpts.AuthMethod {
469+
case "token":
470+
return pushChangesWithGit(ctx, gitRepo, opts)
471+
case "app":
472+
return pushChangesWithAPI(ctx, gitRepo, opts)
473+
default:
474+
return fmt.Errorf("GitHub auth method unrecognized (allowed values: app, token): %s", opts.GitHubOpts.AuthMethod)
475+
}
476+
}

repository/strategy.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,12 @@ func (s *Strategy) Run(ctx context.Context) (bool, *github.PullRequest, error) {
9696

9797
err = pushChanges(ctx, gitRepo, pushOptions{
9898
GitHubOpts: s.Options.GitHub,
99+
GitCloneDir: s.Options.Git.CloneDir,
99100
Repository: s.Repository,
100101
BranchName: branchName,
102+
CreateBranch: existingPR == nil,
101103
ResetFromBase: s.ResetFromBase,
104+
CommitMessage: commitMessage,
102105
})
103106
if err != nil {
104107
return false, existingPR, fmt.Errorf("failed to push changes to git repository %s: %w", s.Repository.FullName(), err)

repository/utils.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package repository
22

33
import (
44
"context"
5+
"encoding/base64"
56
"fmt"
7+
"os"
68
"slices"
79

810
"github.com/google/go-github/v57/github"
@@ -77,3 +79,12 @@ func searchRepositories(ctx context.Context, ghClient *github.Client, query stri
7779

7880
return repos, resp, nil
7981
}
82+
83+
// base64EncodeFile returns the contents of a file in base64
84+
func base64EncodeFile(path string) (string, error) {
85+
fileContent, err := os.ReadFile(path)
86+
if err != nil {
87+
return "", fmt.Errorf("unable to read file: %w", err)
88+
}
89+
return base64.StdEncoding.EncodeToString(fileContent), nil
90+
}

0 commit comments

Comments
 (0)