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