export_test.go

  1package github
  2
  3import (
  4	"bytes"
  5	"context"
  6	"encoding/json"
  7	"fmt"
  8	"log/slog"
  9	"math/rand"
 10	"net/http"
 11	"os"
 12	"testing"
 13	"time"
 14
 15	"github.com/stretchr/testify/require"
 16
 17	"github.com/git-bug/git-bug/bridge/core"
 18	"github.com/git-bug/git-bug/bridge/core/auth"
 19	"github.com/git-bug/git-bug/cache"
 20	"github.com/git-bug/git-bug/entity"
 21	"github.com/git-bug/git-bug/entity/dag"
 22	"github.com/git-bug/git-bug/repository"
 23	"github.com/git-bug/git-bug/util/interrupt"
 24)
 25
 26const (
 27	testRepoBaseName = "git-bug-test-github-exporter"
 28)
 29
 30type testCase struct {
 31	name    string
 32	bug     *cache.BugCache
 33	numOrOp int // number of original operations
 34}
 35
 36func testCases(t *testing.T, repo *cache.RepoCache) []*testCase {
 37	// simple bug
 38	simpleBug, _, err := repo.Bugs().New("simple bug", "new bug")
 39	require.NoError(t, err)
 40
 41	// bug with comments
 42	bugWithComments, _, err := repo.Bugs().New("bug with comments", "new bug")
 43	require.NoError(t, err)
 44
 45	_, _, err = bugWithComments.AddComment("new comment")
 46	require.NoError(t, err)
 47
 48	// bug with label changes
 49	bugLabelChange, _, err := repo.Bugs().New("bug label change", "new bug")
 50	require.NoError(t, err)
 51
 52	_, _, err = bugLabelChange.ChangeLabels([]string{"bug"}, nil)
 53	require.NoError(t, err)
 54
 55	_, _, err = bugLabelChange.ChangeLabels([]string{"core"}, nil)
 56	require.NoError(t, err)
 57
 58	_, _, err = bugLabelChange.ChangeLabels(nil, []string{"bug"})
 59	require.NoError(t, err)
 60
 61	_, _, err = bugLabelChange.ChangeLabels([]string{"InVaLiD"}, nil)
 62	require.NoError(t, err)
 63
 64	_, _, err = bugLabelChange.ChangeLabels([]string{"bUG"}, nil)
 65	require.NoError(t, err)
 66
 67	// bug with comments editions
 68	bugWithCommentEditions, createOp, err := repo.Bugs().New("bug with comments editions", "new bug")
 69	require.NoError(t, err)
 70
 71	_, err = bugWithCommentEditions.EditComment(
 72		entity.CombineIds(bugWithCommentEditions.Id(), createOp.Id()), "first comment edited")
 73	require.NoError(t, err)
 74
 75	commentId, _, err := bugWithCommentEditions.AddComment("first comment")
 76	require.NoError(t, err)
 77
 78	_, err = bugWithCommentEditions.EditComment(commentId, "first comment edited")
 79	require.NoError(t, err)
 80
 81	// bug status changed
 82	bugStatusChanged, _, err := repo.Bugs().New("bug status changed", "new bug")
 83	require.NoError(t, err)
 84
 85	_, err = bugStatusChanged.Close()
 86	require.NoError(t, err)
 87
 88	_, err = bugStatusChanged.Open()
 89	require.NoError(t, err)
 90
 91	// bug title changed
 92	bugTitleEdited, _, err := repo.Bugs().New("bug title edited", "new bug")
 93	require.NoError(t, err)
 94
 95	_, err = bugTitleEdited.SetTitle("bug title edited again")
 96	require.NoError(t, err)
 97
 98	return []*testCase{
 99		{
100			name:    "simple bug",
101			bug:     simpleBug,
102			numOrOp: 1,
103		},
104		{
105			name:    "bug with comments",
106			bug:     bugWithComments,
107			numOrOp: 2,
108		},
109		{
110			name:    "bug label change",
111			bug:     bugLabelChange,
112			numOrOp: 6,
113		},
114		{
115			name:    "bug with comment editions",
116			bug:     bugWithCommentEditions,
117			numOrOp: 4,
118		},
119		{
120			name:    "bug changed status",
121			bug:     bugStatusChanged,
122			numOrOp: 3,
123		},
124		{
125			name:    "bug title edited",
126			bug:     bugTitleEdited,
127			numOrOp: 2,
128		},
129	}
130}
131
132func TestGithubPushPull(t *testing.T) {
133	// repo owner
134	envUser := os.Getenv("GITHUB_USER")
135	if envUser == "" {
136		t.Skip("missing required environment variable: GITHUB_USER")
137	}
138
139	// token must have 'repo' and 'delete_repo' scopes
140	envToken := os.Getenv("GITHUB_TOKEN")
141	if envToken == "" {
142		t.Skip("missing required environment variable: GITHUB_TOKEN")
143	}
144
145	// create repo backend
146	repo := repository.CreateGoGitTestRepo(t, false)
147
148	backend, err := cache.NewRepoCacheNoEvents(repo)
149	require.NoError(t, err)
150
151	// set author identity
152	login := "identity-test"
153	author, err := backend.Identities().New("test identity", "test@test.org")
154	require.NoError(t, err)
155	author.SetMetadata(metaKeyGithubLogin, login)
156	err = author.Commit()
157	require.NoError(t, err)
158
159	err = backend.SetUserIdentity(author)
160	require.NoError(t, err)
161
162	defer backend.Close()
163	interrupt.RegisterCleaner(backend.Close)
164
165	// Setup token + cleanup
166	token := auth.NewToken(target, envToken)
167	token.SetMetadata(auth.MetaKeyLogin, login)
168	err = auth.Store(repo, token)
169	require.NoError(t, err)
170
171	cleanToken := func() error {
172		return auth.Remove(repo, token.ID())
173	}
174	defer cleanToken()
175	interrupt.RegisterCleaner(cleanToken)
176
177	tests := testCases(t, backend)
178
179	// generate project name
180	projectName := generateRepoName()
181
182	// create target Github repository
183	err = createRepository(projectName, envToken)
184	require.NoError(t, err)
185
186	slog.Info("created github repository", "name", projectName)
187
188	// Let Github handle the repo creation and update all their internal caches.
189	// Avoid HTTP error 404 retrieving repository node id
190	time.Sleep(10 * time.Second)
191
192	// Make sure to remove the Github repository when the test end
193	defer func(t *testing.T) {
194		ci := os.Getenv("CI") == "true"
195		if !t.Failed() || ci {
196			if err := deleteRepository(projectName, envUser, envToken); err != nil {
197				t.Fatal(err)
198			}
199
200			reason := "test success"
201			if ci {
202				reason = "CI"
203			}
204
205			slog.Info("deleted repository", "reason", reason, "name", projectName)
206		} else {
207			slog.Info("persisted repository", "reason", "test failure", "name", projectName)
208		}
209	}(t)
210
211	interrupt.RegisterCleaner(func() error {
212		return deleteRepository(projectName, envUser, envToken)
213	})
214
215	ctx := context.Background()
216
217	// initialize exporter
218	exporter := &githubExporter{}
219	err = exporter.Init(ctx, backend, core.Configuration{
220		confKeyOwner:        envUser,
221		confKeyProject:      projectName,
222		confKeyDefaultLogin: login,
223	})
224	require.NoError(t, err)
225
226	start := time.Now()
227
228	// export all bugs
229	exportEvents, err := exporter.ExportAll(ctx, backend, time.Time{})
230	require.NoError(t, err)
231
232	for result := range exportEvents {
233		require.NoError(t, result.Err)
234	}
235	require.NoError(t, err)
236
237	fmt.Printf("test repository exported in %f seconds\n", time.Since(start).Seconds())
238
239	repoTwo := repository.CreateGoGitTestRepo(t, false)
240
241	// create a second backend
242	backendTwo, err := cache.NewRepoCacheNoEvents(repoTwo)
243	require.NoError(t, err)
244
245	importer := &githubImporter{}
246	err = importer.Init(ctx, backend, core.Configuration{
247		confKeyOwner:        envUser,
248		confKeyProject:      projectName,
249		confKeyDefaultLogin: login,
250	})
251	require.NoError(t, err)
252
253	// import all exported bugs to the second backend
254	importEvents, err := importer.ImportAll(ctx, backendTwo, time.Time{})
255	require.NoError(t, err)
256
257	for result := range importEvents {
258		require.NoError(t, result.Err)
259	}
260
261	require.Len(t, backendTwo.Bugs().AllIds(), len(tests))
262
263	for _, tt := range tests {
264		t.Run(tt.name, func(t *testing.T) {
265			// for each operation a SetMetadataOperation will be added
266			// so number of operations should double
267			require.Len(t, tt.bug.Snapshot().Operations, tt.numOrOp*2)
268
269			// verify operation have correct metadata
270			for _, op := range tt.bug.Snapshot().Operations {
271				// Check if the originals operations (*not* SetMetadata) are tagged properly
272				if _, ok := op.(dag.OperationDoesntChangeSnapshot); !ok {
273					_, haveIDMetadata := op.GetMetadata(metaKeyGithubId)
274					require.True(t, haveIDMetadata)
275
276					_, haveURLMetada := op.GetMetadata(metaKeyGithubUrl)
277					require.True(t, haveURLMetada)
278				}
279			}
280
281			// get bug github ID
282			bugGithubID, ok := tt.bug.Snapshot().GetCreateMetadata(metaKeyGithubId)
283			require.True(t, ok)
284
285			// retrieve bug from backendTwo
286			importedBug, err := backendTwo.Bugs().ResolveBugCreateMetadata(metaKeyGithubId, bugGithubID)
287			require.NoError(t, err)
288
289			importedOpCount := len(importedBug.Snapshot().Operations)
290
291			if tt.numOrOp != len(importedBug.Snapshot().Operations) {
292				slog.Info("invalid number of ops for imported bug", "github-id", bugGithubID, "title", importedBug.Snapshot().Title, "opCount", importedOpCount, "labels", importedBug.Snapshot().Labels)
293				for _, op := range importedBug.Snapshot().Operations {
294					slog.Info("  operation", "type", op.Type(), "time", op.Time(), "author", op.Author().Name())
295					for k, v := range op.AllMetadata() {
296						slog.Info("    metadata", "key", k, "val", v)
297					}
298				}
299
300				slog.Info("final snapshot", "status", importedBug.Snapshot().Status.String(), "labels", importedBug.Snapshot().Labels)
301			}
302
303			// verify bug have same number of original operations
304			require.Equal(t, importedOpCount, tt.numOrOp)
305
306			// verify bugs are tagged with origin=github
307			issueOrigin, ok := importedBug.Snapshot().GetCreateMetadata(core.MetaKeyOrigin)
308			require.True(t, ok)
309			require.Equal(t, issueOrigin, target)
310
311			// TODO: maybe more tests to ensure bug final state
312		})
313	}
314}
315
316func generateRepoName() string {
317	rand.Seed(time.Now().UnixNano())
318	var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
319	b := make([]rune, 8)
320	for i := range b {
321		b[i] = letterRunes[rand.Intn(len(letterRunes))]
322	}
323	return fmt.Sprintf("%s-%s", testRepoBaseName, string(b))
324}
325
326// create repository need a token with scope 'repo'
327func createRepository(project, token string) error {
328	// This function use the V3 Github API because repository creation is not supported yet on the V4 API.
329	url := fmt.Sprintf("%s/user/repos", githubV3Url)
330
331	params := struct {
332		Name        string `json:"name"`
333		Description string `json:"description"`
334		Private     bool   `json:"private"`
335		HasIssues   bool   `json:"has_issues"`
336	}{
337		Name:        project,
338		Description: "git-bug exporter temporary test repository",
339		Private:     true,
340		HasIssues:   true,
341	}
342
343	data, err := json.Marshal(params)
344	if err != nil {
345		return err
346	}
347
348	req, err := http.NewRequest("POST", url, bytes.NewBuffer(data))
349	if err != nil {
350		return err
351	}
352
353	// need the token for private repositories
354	req.Header.Set("Authorization", fmt.Sprintf("token %s", token))
355
356	client := &http.Client{
357		Timeout: defaultTimeout,
358	}
359
360	resp, err := client.Do(req)
361	if err != nil {
362		return err
363	}
364
365	return resp.Body.Close()
366}
367
368// delete repository need a token with scope 'delete_repo'
369func deleteRepository(project, owner, token string) error {
370	// This function use the V3 Github API because repository removal is not supported yet on the V4 API.
371	url := fmt.Sprintf("%s/repos/%s/%s", githubV3Url, owner, project)
372
373	req, err := http.NewRequest("DELETE", url, nil)
374	if err != nil {
375		return err
376	}
377
378	// need the token for private repositories
379	req.Header.Set("Authorization", fmt.Sprintf("token %s", token))
380
381	client := &http.Client{
382		Timeout: defaultTimeout,
383	}
384
385	resp, err := client.Do(req)
386	if err != nil {
387		return err
388	}
389
390	defer resp.Body.Close()
391
392	if resp.StatusCode != http.StatusNoContent {
393		return fmt.Errorf("error deleting repository")
394	}
395
396	return nil
397}