1package github
2
3import (
4 "context"
5 "fmt"
6 "time"
7
8 "github.com/MichaelMure/git-bug/bridge/core"
9 "github.com/MichaelMure/git-bug/bug"
10 "github.com/MichaelMure/git-bug/cache"
11 "github.com/MichaelMure/git-bug/identity"
12 "github.com/MichaelMure/git-bug/util/git"
13 "github.com/MichaelMure/git-bug/util/text"
14 "github.com/shurcooL/githubv4"
15)
16
17const (
18 keyGithubId = "github-id"
19 keyGithubUrl = "github-url"
20 keyGithubLogin = "github-login"
21)
22
23// githubImporter implement the Importer interface
24type githubImporter struct {
25 conf core.Configuration
26
27 // iterator
28 iterator *iterator
29
30 // number of imported issues
31 importedIssues int
32
33 // number of imported identities
34 importedIdentities int
35}
36
37func (gi *githubImporter) Init(conf core.Configuration) error {
38 gi.conf = conf
39 return nil
40}
41
42// ImportAll iterate over all the configured repository issues and ensure the creation of the
43// missing issues / timeline items / edits / label events ...
44func (gi *githubImporter) ImportAll(repo *cache.RepoCache, since time.Time) error {
45 gi.iterator = NewIterator(gi.conf[keyUser], gi.conf[keyProject], gi.conf[keyToken], since)
46
47 // Loop over all matching issues
48 for gi.iterator.NextIssue() {
49 issue := gi.iterator.IssueValue()
50 fmt.Printf("importing issue: %v\n", issue.Title)
51
52 // create issue
53 b, err := gi.ensureIssue(repo, issue)
54 if err != nil {
55 return fmt.Errorf("issue creation: %v", err)
56 }
57
58 // loop over timeline items
59 for gi.iterator.NextTimelineItem() {
60 if err := gi.ensureTimelineItem(repo, b, gi.iterator.TimelineItemValue()); err != nil {
61 return fmt.Errorf("timeline item creation: %v", err)
62 }
63 }
64
65 // commit bug state
66 if err := b.CommitAsNeeded(); err != nil {
67 return fmt.Errorf("bug commit: %v", err)
68 }
69 }
70
71 if err := gi.iterator.Error(); err != nil {
72 fmt.Printf("import error: %v\n", err)
73 return err
74 }
75
76 fmt.Printf("Successfully imported %d issues and %d identities from Github\n", gi.importedIssues, gi.importedIdentities)
77 return nil
78}
79
80func (gi *githubImporter) ensureIssue(repo *cache.RepoCache, issue issueTimeline) (*cache.BugCache, error) {
81 // ensure issue author
82 author, err := gi.ensurePerson(repo, issue.Author)
83 if err != nil {
84 return nil, err
85 }
86
87 // resolve bug
88 b, err := repo.ResolveBugCreateMetadata(keyGithubUrl, issue.Url.String())
89 if err != nil && err != bug.ErrBugNotExist {
90 return nil, err
91 }
92
93 // get issue edits
94 issueEdits := []userContentEdit{}
95 for gi.iterator.NextIssueEdit() {
96 issueEdits = append(issueEdits, gi.iterator.IssueEditValue())
97 }
98
99 // if issueEdits is empty
100 if len(issueEdits) == 0 {
101 if err == bug.ErrBugNotExist {
102 cleanText, err := text.Cleanup(string(issue.Body))
103 if err != nil {
104 return nil, err
105 }
106
107 // create bug
108 b, err = repo.NewBugRaw(
109 author,
110 issue.CreatedAt.Unix(),
111 issue.Title,
112 cleanText,
113 nil,
114 map[string]string{
115 keyGithubId: parseId(issue.Id),
116 keyGithubUrl: issue.Url.String(),
117 })
118 if err != nil {
119 return nil, err
120 }
121
122 // importing a new bug
123 gi.importedIssues++
124 }
125
126 } else {
127 // create bug from given issueEdits
128 for i, edit := range issueEdits {
129 if i == 0 && b != nil {
130 // The first edit in the github result is the issue creation itself, we already have that
131 continue
132 }
133
134 cleanText, err := text.Cleanup(string(*edit.Diff))
135 if err != nil {
136 return nil, err
137 }
138
139 // if the bug doesn't exist
140 if b == nil {
141 // we create the bug as soon as we have a legit first edition
142 b, err = repo.NewBugRaw(
143 author,
144 issue.CreatedAt.Unix(),
145 issue.Title,
146 cleanText,
147 nil,
148 map[string]string{
149 keyGithubId: parseId(issue.Id),
150 keyGithubUrl: issue.Url.String(),
151 },
152 )
153
154 if err != nil {
155 return nil, err
156 }
157
158 // importing a new bug
159 gi.importedIssues++
160
161 continue
162 }
163
164 // other edits will be added as CommentEdit operations
165 target, err := b.ResolveOperationWithMetadata(keyGithubUrl, issue.Url.String())
166 if err != nil {
167 return nil, err
168 }
169
170 err = gi.ensureCommentEdit(repo, b, target, edit)
171 if err != nil {
172 return nil, err
173 }
174 }
175 }
176
177 return b, nil
178}
179
180func (gi *githubImporter) ensureTimelineItem(repo *cache.RepoCache, b *cache.BugCache, item timelineItem) error {
181 fmt.Printf("import event item: %s\n", item.Typename)
182
183 switch item.Typename {
184 case "IssueComment":
185 // collect all comment edits
186 commentEdits := []userContentEdit{}
187 for gi.iterator.NextCommentEdit() {
188 commentEdits = append(commentEdits, gi.iterator.CommentEditValue())
189 }
190
191 err := gi.ensureTimelineComment(repo, b, item.IssueComment, commentEdits)
192 if err != nil {
193 return fmt.Errorf("timeline comment creation: %v", err)
194 }
195
196 case "LabeledEvent":
197 id := parseId(item.LabeledEvent.Id)
198 _, err := b.ResolveOperationWithMetadata(keyGithubId, id)
199 if err != cache.ErrNoMatchingOp {
200 return err
201 }
202 author, err := gi.ensurePerson(repo, item.LabeledEvent.Actor)
203 if err != nil {
204 return err
205 }
206 _, err = b.ForceChangeLabelsRaw(
207 author,
208 item.LabeledEvent.CreatedAt.Unix(),
209 []string{
210 string(item.LabeledEvent.Label.Name),
211 },
212 nil,
213 map[string]string{keyGithubId: id},
214 )
215
216 return err
217
218 case "UnlabeledEvent":
219 id := parseId(item.UnlabeledEvent.Id)
220 _, err := b.ResolveOperationWithMetadata(keyGithubId, id)
221 if err != cache.ErrNoMatchingOp {
222 return err
223 }
224 author, err := gi.ensurePerson(repo, item.UnlabeledEvent.Actor)
225 if err != nil {
226 return err
227 }
228
229 _, err = b.ForceChangeLabelsRaw(
230 author,
231 item.UnlabeledEvent.CreatedAt.Unix(),
232 nil,
233 []string{
234 string(item.UnlabeledEvent.Label.Name),
235 },
236 map[string]string{keyGithubId: id},
237 )
238 return err
239
240 case "ClosedEvent":
241 id := parseId(item.ClosedEvent.Id)
242 _, err := b.ResolveOperationWithMetadata(keyGithubId, id)
243 if err != cache.ErrNoMatchingOp {
244 return err
245 }
246 author, err := gi.ensurePerson(repo, item.ClosedEvent.Actor)
247 if err != nil {
248 return err
249 }
250 _, err = b.CloseRaw(
251 author,
252 item.ClosedEvent.CreatedAt.Unix(),
253 map[string]string{keyGithubId: id},
254 )
255 return err
256
257 case "ReopenedEvent":
258 id := parseId(item.ReopenedEvent.Id)
259 _, err := b.ResolveOperationWithMetadata(keyGithubId, id)
260 if err != cache.ErrNoMatchingOp {
261 return err
262 }
263 author, err := gi.ensurePerson(repo, item.ReopenedEvent.Actor)
264 if err != nil {
265 return err
266 }
267 _, err = b.OpenRaw(
268 author,
269 item.ReopenedEvent.CreatedAt.Unix(),
270 map[string]string{keyGithubId: id},
271 )
272 return err
273
274 case "RenamedTitleEvent":
275 id := parseId(item.RenamedTitleEvent.Id)
276 _, err := b.ResolveOperationWithMetadata(keyGithubId, id)
277 if err != cache.ErrNoMatchingOp {
278 return err
279 }
280 author, err := gi.ensurePerson(repo, item.RenamedTitleEvent.Actor)
281 if err != nil {
282 return err
283 }
284 _, err = b.SetTitleRaw(
285 author,
286 item.RenamedTitleEvent.CreatedAt.Unix(),
287 string(item.RenamedTitleEvent.CurrentTitle),
288 map[string]string{keyGithubId: id},
289 )
290 return err
291
292 default:
293 fmt.Printf("ignore event: %v\n", item.Typename)
294 }
295
296 return nil
297}
298
299func (gi *githubImporter) ensureTimelineComment(repo *cache.RepoCache, b *cache.BugCache, item issueComment, edits []userContentEdit) error {
300 // ensure person
301 author, err := gi.ensurePerson(repo, item.Author)
302 if err != nil {
303 return err
304 }
305
306 var target git.Hash
307 target, err = b.ResolveOperationWithMetadata(keyGithubId, parseId(item.Id))
308 if err != nil && err != cache.ErrNoMatchingOp {
309 // real error
310 return err
311 }
312 // if no edits are given we create the comment
313 if len(edits) == 0 {
314
315 // if comment doesn't exist
316 if err == cache.ErrNoMatchingOp {
317 cleanText, err := text.Cleanup(string(item.Body))
318 if err != nil {
319 return err
320 }
321
322 // add comment operation
323 op, err := b.AddCommentRaw(
324 author,
325 item.CreatedAt.Unix(),
326 cleanText,
327 nil,
328 map[string]string{
329 keyGithubId: parseId(item.Id),
330 keyGithubUrl: parseId(item.Url.String()),
331 },
332 )
333 if err != nil {
334 return err
335 }
336
337 // set hash
338 target, err = op.Hash()
339 if err != nil {
340 return err
341 }
342 }
343 } else {
344 for i, edit := range edits {
345 if i == 0 && target != "" {
346 // The first edit in the github result is the comment creation itself, we already have that
347 continue
348 }
349
350 // ensure editor identity
351 editor, err := gi.ensurePerson(repo, edit.Editor)
352 if err != nil {
353 return err
354 }
355
356 // create comment when target is empty
357 if target == "" {
358 cleanText, err := text.Cleanup(string(*edit.Diff))
359 if err != nil {
360 return err
361 }
362
363 op, err := b.AddCommentRaw(
364 editor,
365 edit.CreatedAt.Unix(),
366 cleanText,
367 nil,
368 map[string]string{
369 keyGithubId: parseId(item.Id),
370 keyGithubUrl: item.Url.String(),
371 },
372 )
373 if err != nil {
374 return err
375 }
376
377 // set hash
378 target, err = op.Hash()
379 if err != nil {
380 return err
381 }
382
383 continue
384 }
385
386 err = gi.ensureCommentEdit(repo, b, target, edit)
387 if err != nil {
388 return err
389 }
390 }
391 }
392 return nil
393}
394
395func (gi *githubImporter) ensureCommentEdit(repo *cache.RepoCache, b *cache.BugCache, target git.Hash, edit userContentEdit) error {
396 _, err := b.ResolveOperationWithMetadata(keyGithubId, parseId(edit.Id))
397 if err == nil {
398 // already imported
399 return nil
400 }
401 if err != cache.ErrNoMatchingOp {
402 // real error
403 return err
404 }
405
406 fmt.Println("import edition")
407
408 editor, err := gi.ensurePerson(repo, edit.Editor)
409 if err != nil {
410 return err
411 }
412
413 switch {
414 case edit.DeletedAt != nil:
415 // comment deletion, not supported yet
416 fmt.Println("comment deletion is not supported yet")
417
418 case edit.DeletedAt == nil:
419
420 cleanText, err := text.Cleanup(string(*edit.Diff))
421 if err != nil {
422 return err
423 }
424
425 // comment edition
426 _, err = b.EditCommentRaw(
427 editor,
428 edit.CreatedAt.Unix(),
429 target,
430 cleanText,
431 map[string]string{
432 keyGithubId: parseId(edit.Id),
433 },
434 )
435
436 if err != nil {
437 return err
438 }
439 }
440
441 return nil
442}
443
444// ensurePerson create a bug.Person from the Github data
445func (gi *githubImporter) ensurePerson(repo *cache.RepoCache, actor *actor) (*cache.IdentityCache, error) {
446 // When a user has been deleted, Github return a null actor, while displaying a profile named "ghost"
447 // in it's UI. So we need a special case to get it.
448 if actor == nil {
449 return gi.getGhost(repo)
450 }
451
452 // Look first in the cache
453 i, err := repo.ResolveIdentityImmutableMetadata(keyGithubLogin, string(actor.Login))
454 if err == nil {
455 return i, nil
456 }
457 if _, ok := err.(identity.ErrMultipleMatch); ok {
458 return nil, err
459 }
460
461 // importing a new identity
462 gi.importedIdentities++
463
464 var name string
465 var email string
466
467 switch actor.Typename {
468 case "User":
469 if actor.User.Name != nil {
470 name = string(*(actor.User.Name))
471 }
472 email = string(actor.User.Email)
473 case "Organization":
474 if actor.Organization.Name != nil {
475 name = string(*(actor.Organization.Name))
476 }
477 if actor.Organization.Email != nil {
478 email = string(*(actor.Organization.Email))
479 }
480 case "Bot":
481 }
482
483 return repo.NewIdentityRaw(
484 name,
485 email,
486 string(actor.Login),
487 string(actor.AvatarUrl),
488 map[string]string{
489 keyGithubLogin: string(actor.Login),
490 },
491 )
492}
493
494func (gi *githubImporter) getGhost(repo *cache.RepoCache) (*cache.IdentityCache, error) {
495 // Look first in the cache
496 i, err := repo.ResolveIdentityImmutableMetadata(keyGithubLogin, "ghost")
497 if err == nil {
498 return i, nil
499 }
500 if _, ok := err.(identity.ErrMultipleMatch); ok {
501 return nil, err
502 }
503
504 var q userQuery
505
506 variables := map[string]interface{}{
507 "login": githubv4.String("ghost"),
508 }
509
510 gc := buildClient(gi.conf[keyToken])
511
512 err = gc.Query(context.TODO(), &q, variables)
513 if err != nil {
514 return nil, err
515 }
516
517 var name string
518 if q.User.Name != nil {
519 name = string(*q.User.Name)
520 }
521
522 return repo.NewIdentityRaw(
523 name,
524 string(q.User.Email),
525 string(q.User.Login),
526 string(q.User.AvatarUrl),
527 map[string]string{
528 keyGithubLogin: string(q.User.Login),
529 },
530 )
531}
532
533// parseId convert the unusable githubv4.ID (an interface{}) into a string
534func parseId(id githubv4.ID) string {
535 return fmt.Sprintf("%v", id)
536}