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}