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