Skip to content

Commit 6cbbe1c

Browse files
authored
Add integration tests for patch application (#6)
Because this library is pretty tightly coupled with GitHub, the tests require a GitHub token and use the real API. If the required values are not in the environment, tests that use them are skipped. Tests are loaded dynamically by looking for patch files in the testdata directory. Each test applies the patch against fixed base content and then compares the result with the expected files.
1 parent 5114fb1 commit 6cbbe1c

File tree

27 files changed

+615
-0
lines changed

27 files changed

+615
-0
lines changed

.github/workflows/go.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,6 @@ jobs:
2424

2525
- name: Test
2626
run: go test -v ./...
27+
env:
28+
PATCH2PR_TEST_REPO: bluekeyes/patch2pr
29+
PATCH2PR_TEST_GITHUB_TOKEN: ${{ github.repository_owner == 'bluekeyes' && secrets.GITHUB_TOKEN || '' }}

applier_test.go

Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
package patch2pr
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/base64"
7+
"fmt"
8+
"io/fs"
9+
"os"
10+
"path/filepath"
11+
"strconv"
12+
"strings"
13+
"testing"
14+
"time"
15+
16+
"github.com/bluekeyes/go-gitdiff/gitdiff"
17+
"github.com/google/go-github/v35/github"
18+
"golang.org/x/oauth2"
19+
)
20+
21+
const (
22+
BaseBranch = "base"
23+
DeletedExt = ".deleted"
24+
)
25+
26+
const (
27+
EnvRepo = "PATCH2PR_TEST_REPO"
28+
EnvToken = "PATCH2PR_TEST_GITHUB_TOKEN"
29+
EnvDebug = "PATCH2PR_TEST_DEBUG"
30+
)
31+
32+
func TestApplier(t *testing.T) {
33+
tctx := prepareTestContext(t)
34+
35+
t.Logf("Test ID: %s", tctx.ID)
36+
t.Logf("Test Repository: %s", tctx.Repo.String())
37+
38+
createBranch(t, tctx)
39+
defer cleanupBranches(t, tctx)
40+
41+
patches, err := filepath.Glob(filepath.Join("testdata", "patches", "*.patch"))
42+
if err != nil {
43+
t.Fatalf("error listing patches: %v", err)
44+
}
45+
46+
t.Logf("Discovered %d patches", len(patches))
47+
for _, patch := range patches {
48+
name := strings.TrimSuffix(filepath.Base(patch), ".patch")
49+
t.Run(name, func(t *testing.T) {
50+
runPatchTest(t, tctx, name)
51+
})
52+
}
53+
}
54+
55+
type TestContext struct {
56+
context.Context
57+
58+
ID string
59+
Repo Repository
60+
61+
BaseCommit *github.Commit
62+
BaseTree *github.Tree
63+
64+
Client *github.Client
65+
}
66+
67+
func (tctx *TestContext) Branch(name string) string {
68+
return fmt.Sprintf("refs/heads/test/%s/%s", tctx.ID, name)
69+
}
70+
71+
func runPatchTest(t *testing.T, tctx *TestContext, name string) {
72+
f, err := os.Open(filepath.Join("testdata", "patches", name+".patch"))
73+
if err != nil {
74+
t.Fatalf("error opening patch file: %v", err)
75+
}
76+
defer f.Close()
77+
78+
files, _, err := gitdiff.Parse(f)
79+
if err != nil {
80+
t.Fatalf("error parsing patch: %v", err)
81+
}
82+
83+
applier := NewApplier(tctx.Client, tctx.Repo, tctx.BaseCommit)
84+
for _, file := range files {
85+
if _, err := applier.Apply(tctx, file); err != nil {
86+
t.Fatalf("error applying file patch: %s: %v", file.NewName, err)
87+
}
88+
}
89+
90+
commit, err := applier.Commit(tctx, nil, &gitdiff.PatchHeader{
91+
Title: name,
92+
})
93+
if err != nil {
94+
t.Fatalf("error committing changes: %v", err)
95+
}
96+
97+
ref := NewReference(tctx.Client, tctx.Repo, tctx.Branch(name))
98+
if err := ref.Set(tctx, commit.GetSHA(), true); err != nil {
99+
t.Fatalf("error creating ref: %v", err)
100+
}
101+
102+
assertPatchResult(t, tctx, name, commit)
103+
}
104+
105+
type treeFile struct {
106+
Mode string
107+
SHA string
108+
Content []byte
109+
}
110+
111+
func assertPatchResult(t *testing.T, tctx *TestContext, name string, c *github.Commit) {
112+
expected := entriesToMap(tctx.BaseTree.Entries)
113+
114+
root := filepath.Join("testdata", "patches", name) + string(filepath.Separator)
115+
if err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
116+
if err != nil {
117+
return err
118+
}
119+
if d.IsDir() {
120+
return nil
121+
}
122+
123+
relpath := filepath.ToSlash(strings.TrimPrefix(path, root))
124+
if strings.HasSuffix(relpath, DeletedExt) {
125+
delete(expected, strings.TrimSuffix(relpath, DeletedExt))
126+
return nil
127+
}
128+
129+
info, err := d.Info()
130+
if err != nil {
131+
return err
132+
}
133+
134+
content, err := os.ReadFile(path)
135+
if err != nil {
136+
return err
137+
}
138+
139+
expected[relpath] = treeFile{
140+
Mode: fmt.Sprintf("100%o", info.Mode()),
141+
Content: content,
142+
}
143+
144+
return nil
145+
}); err != nil {
146+
t.Fatalf("error listing expected files: %v", err)
147+
}
148+
149+
actualTree, _, err := tctx.Client.Git.GetTree(tctx, tctx.Repo.Owner, tctx.Repo.Name, c.GetTree().GetSHA(), true)
150+
if err != nil {
151+
t.Fatalf("error getting actual tree: %v", err)
152+
}
153+
154+
actual := entriesToMap(actualTree.Entries)
155+
for path, file := range actual {
156+
expectedFile, ok := expected[path]
157+
if !ok {
158+
t.Errorf("unexpected file %s", path)
159+
continue
160+
}
161+
delete(expected, path)
162+
163+
if expectedFile.SHA != "" {
164+
if expectedFile.SHA != file.SHA {
165+
t.Errorf("unexpected modification to %s", path)
166+
}
167+
continue
168+
}
169+
170+
if expectedFile.Mode != file.Mode {
171+
t.Errorf("incorrect mode: expected %s, actual %s: %s", expectedFile.Mode, file.Mode, path)
172+
continue
173+
}
174+
175+
content, _, err := tctx.Client.Git.GetBlobRaw(tctx, tctx.Repo.Owner, tctx.Repo.Name, file.SHA)
176+
if err != nil {
177+
t.Fatalf("error getting blob content: %v", err)
178+
}
179+
if !bytes.Equal(expectedFile.Content, content) {
180+
t.Errorf("incorrect content: %s\nexpected: %q\n actual: %q", path, expectedFile.Content, content)
181+
}
182+
}
183+
184+
for path := range expected {
185+
t.Errorf("missing file %s", path)
186+
}
187+
}
188+
189+
func entriesToMap(entries []*github.TreeEntry) map[string]treeFile {
190+
m := make(map[string]treeFile)
191+
for _, entry := range entries {
192+
if entry.GetType() != "blob" {
193+
continue
194+
}
195+
m[entry.GetPath()] = treeFile{
196+
Mode: entry.GetMode(),
197+
SHA: entry.GetSHA(),
198+
}
199+
}
200+
return m
201+
}
202+
203+
func prepareTestContext(t *testing.T) *TestContext {
204+
id := strconv.FormatInt(time.Now().UnixNano()/1000000, 10)
205+
206+
fullRepo, ok := os.LookupEnv(EnvRepo)
207+
if !ok || fullRepo == "" {
208+
t.Skipf("%s must be set in the environment", EnvRepo)
209+
}
210+
token, ok := os.LookupEnv(EnvToken)
211+
if !ok || token == "" {
212+
t.Skipf("%s must be set in the environment", EnvToken)
213+
}
214+
215+
repo, err := ParseRepository(fullRepo)
216+
if err != nil {
217+
t.Fatalf("Invalid %s value: %v", EnvRepo, err)
218+
}
219+
220+
ctx := context.Background()
221+
client := github.NewClient(oauth2.NewClient(ctx, oauth2.StaticTokenSource(
222+
&oauth2.Token{AccessToken: token},
223+
)))
224+
225+
tctx := TestContext{
226+
Context: ctx,
227+
ID: id,
228+
Repo: repo,
229+
Client: client,
230+
}
231+
return &tctx
232+
}
233+
234+
func createBranch(t *testing.T, tctx *TestContext) {
235+
root := filepath.Join("testdata", "base") + string(filepath.Separator)
236+
237+
var entries []*github.TreeEntry
238+
if err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
239+
if err != nil {
240+
return err
241+
}
242+
if d.IsDir() {
243+
return nil
244+
}
245+
246+
content, err := os.ReadFile(path)
247+
if err != nil {
248+
return err
249+
}
250+
251+
info, err := d.Info()
252+
if err != nil {
253+
return err
254+
}
255+
256+
treePath := strings.TrimPrefix(path, root)
257+
entry := github.TreeEntry{
258+
Path: &treePath,
259+
Type: github.String("blob"),
260+
Mode: github.String(fmt.Sprintf("100%o", info.Mode())),
261+
}
262+
263+
if strings.HasSuffix(d.Name(), ".bin") {
264+
c := base64.StdEncoding.EncodeToString(content)
265+
blob, _, err := tctx.Client.Git.CreateBlob(tctx, tctx.Repo.Owner, tctx.Repo.Name, &github.Blob{
266+
Encoding: github.String("base64"),
267+
Content: &c,
268+
})
269+
if err != nil {
270+
return err
271+
}
272+
entry.SHA = blob.SHA
273+
} else {
274+
c := string(content)
275+
entry.Content = &c
276+
}
277+
278+
entries = append(entries, &entry)
279+
return nil
280+
}); err != nil {
281+
t.Fatalf("error listing base files: %v", err)
282+
}
283+
284+
tree, _, err := tctx.Client.Git.CreateTree(tctx, tctx.Repo.Owner, tctx.Repo.Name, "", entries)
285+
if err != nil {
286+
t.Fatalf("error creating tree: %v", err)
287+
}
288+
289+
fullTree, _, err := tctx.Client.Git.GetTree(tctx, tctx.Repo.Owner, tctx.Repo.Name, tree.GetSHA(), true)
290+
if err != nil {
291+
t.Fatalf("error getting recursive tree: %v", err)
292+
}
293+
294+
commit, _, err := tctx.Client.Git.CreateCommit(tctx, tctx.Repo.Owner, tctx.Repo.Name, &github.Commit{
295+
Message: github.String("Base commit for test"),
296+
Tree: tree,
297+
})
298+
if err != nil {
299+
t.Fatalf("error creating commit: %v", err)
300+
}
301+
302+
tctx.BaseCommit = commit
303+
tctx.BaseTree = fullTree
304+
305+
if _, _, err := tctx.Client.Git.CreateRef(tctx, tctx.Repo.Owner, tctx.Repo.Name, &github.Reference{
306+
Ref: github.String(tctx.Branch(BaseBranch)),
307+
Object: &github.GitObject{
308+
SHA: commit.SHA,
309+
},
310+
}); err != nil {
311+
t.Fatalf("error creating ref: %v", err)
312+
}
313+
}
314+
315+
func cleanupBranches(t *testing.T, tctx *TestContext) {
316+
if isDebug() && t.Failed() {
317+
t.Logf("Debug mode enabled with failing tests, skipping ref cleanup: %s", tctx.ID)
318+
return
319+
}
320+
321+
refs, _, err := tctx.Client.Git.ListMatchingRefs(tctx, tctx.Repo.Owner, tctx.Repo.Name, &github.ReferenceListOptions{
322+
Ref: fmt.Sprintf("heads/test/%s/", tctx.ID),
323+
})
324+
if err != nil {
325+
t.Logf("WARNING: failed to list refs; skipping cleanup: %v", err)
326+
return
327+
}
328+
329+
t.Logf("Found %d refs to remove", len(refs))
330+
for _, ref := range refs {
331+
t.Logf("Deleting %s", ref.GetRef())
332+
if _, err := tctx.Client.Git.DeleteRef(tctx, tctx.Repo.Owner, tctx.Repo.Name, ref.GetRef()); err != nil {
333+
t.Logf("WARNING: failed to delete ref %s: %v", ref.GetRef(), err)
334+
}
335+
}
336+
}
337+
338+
func isDebug() bool {
339+
val := os.Getenv(EnvDebug)
340+
return val != ""
341+
}

testdata/base/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Test Data
2+
3+
This directory contains arbitrary files used to construct a base branch against
4+
which tests apply patches.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package memory
2+
3+
type KVStore struct {
4+
data map[string]interface{}
5+
}
6+
7+
func (kv *KVStore) Get(key string) interface{} {
8+
return kv.data[key]
9+
}
10+
11+
func (kv *KVStore) Put(key string, value interface{}) {
12+
kv.data[key] = value
13+
}

testdata/base/data.bin

4 KB
Binary file not shown.

testdata/base/main/bits.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package main
2+
3+
func Twist(v uint64) uint64 {
4+
x := 1823 * v
5+
y := v | (v << 16)
6+
z := x ^ y
7+
return z & ((v >> 32) | (v << 32))
8+
}
9+
10+
func Fiddle(v uint64) uint64 {
11+
v |= 0x80
12+
v |= 0x8000
13+
return (v&0xFFFF)<<48 | v&0xFFFFFFFF0000 | (v>>48)&0xFFFF
14+
}
15+
16+
func Collide(a, b uint64) uint64 {
17+
v := a&(((1<<32)-1)<<32) | (b&(1<<32) - 1)
18+
v ^= b&(((1<<32)-1)<<32) | (a&(1<<32) - 1)
19+
return v
20+
}
21+
22+
func Rotate(v uint64, n int) uint64 {
23+
for i := 0; i < n%64; i++ {
24+
v = (v&0x1)<<63 | (v >> 1)
25+
}
26+
return v
27+
}

0 commit comments

Comments
 (0)