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 *rateLimitHandlerClient
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
77 currEvent = nextEvent
78 if currEvent == nil {
79 currEvent = gi.getEventHandleMsgs()
80 }
81 if currEvent == nil {
82 break
83 }
84 nextEvent = gi.getEventHandleMsgs()
85
86 switch event := currEvent.(type) {
87 case RateLimitingEvent:
88 out <- core.NewImportRateLimiting(event.msg)
89 case IssueEvent:
90 // first: commit what is being held in currBug
91 if err = gi.commit(currBug, out); err != nil {
92 out <- core.NewImportError(err, "")
93 return
94 }
95 // second: create new issue
96 switch next := nextEvent.(type) {
97 case IssueEditEvent:
98 // consuming and using next event
99 nextEvent = nil
100 currBug, err = gi.ensureIssue(ctx, repo, &event.issue, &next.userContentEdit)
101 default:
102 currBug, err = gi.ensureIssue(ctx, repo, &event.issue, nil)
103 }
104 if err != nil {
105 err := fmt.Errorf("issue creation: %v", err)
106 out <- core.NewImportError(err, "")
107 return
108 }
109 case IssueEditEvent:
110 err = gi.ensureIssueEdit(ctx, repo, currBug, event.issueId, &event.userContentEdit)
111 if err != nil {
112 err = fmt.Errorf("issue edit: %v", err)
113 out <- core.NewImportError(err, "")
114 return
115 }
116 case TimelineEvent:
117 if next, ok := nextEvent.(CommentEditEvent); ok && event.Typename == "IssueComment" {
118 // consuming and using next event
119 nextEvent = nil
120 err = gi.ensureComment(ctx, repo, currBug, &event.timelineItem.IssueComment, &next.userContentEdit)
121 } else {
122 err = gi.ensureTimelineItem(ctx, repo, currBug, &event.timelineItem)
123 }
124 if err != nil {
125 err = fmt.Errorf("timeline item creation: %v", err)
126 out <- core.NewImportError(err, "")
127 return
128 }
129 case CommentEditEvent:
130 err = gi.ensureCommentEdit(ctx, repo, currBug, event.commentId, &event.userContentEdit)
131 if err != nil {
132 err = fmt.Errorf("comment edit: %v", err)
133 out <- core.NewImportError(err, "")
134 return
135 }
136 default:
137 panic("Unknown event type")
138 }
139 }
140 // commit what is being held in currBug before returning
141 if err = gi.commit(currBug, out); err != nil {
142 out <- core.NewImportError(err, "")
143 }
144 if err = gi.mediator.Error(); err != nil {
145 gi.out <- core.NewImportError(err, "")
146 }
147 }()
148
149 return out, nil
150}
151
152func (gi *githubImporter) getEventHandleMsgs() ImportEvent {
153 for {
154 // read event from import mediator
155 event := gi.mediator.NextImportEvent()
156 // consume (and use) all rate limiting events
157 if e, ok := event.(RateLimitingEvent); ok {
158 gi.out <- core.NewImportRateLimiting(e.msg)
159 continue
160 }
161 return event
162 }
163}
164
165func (gi *githubImporter) commit(b *cache.BugCache, out chan<- core.ImportResult) error {
166 if b == nil {
167 return nil
168 }
169 if !b.NeedCommit() {
170 out <- core.NewImportNothing(b.Id(), "no imported operation")
171 return nil
172 } else if err := b.Commit(); err != nil {
173 // commit bug state
174 return fmt.Errorf("bug commit: %v", err)
175 }
176 return nil
177}
178
179func (gi *githubImporter) ensureIssue(ctx context.Context, repo *cache.RepoCache, issue *issue, issueEdit *userContentEdit) (*cache.BugCache, error) {
180 author, err := gi.ensurePerson(ctx, repo, issue.Author)
181 if err != nil {
182 return nil, err
183 }
184
185 // resolve bug
186 b, err := repo.ResolveBugMatcher(func(excerpt *cache.BugExcerpt) bool {
187 return excerpt.CreateMetadata[core.MetaKeyOrigin] == target &&
188 excerpt.CreateMetadata[metaKeyGithubId] == parseId(issue.Id)
189 })
190 if err == nil {
191 return b, nil
192 }
193 if err != bug.ErrBugNotExist {
194 return nil, err
195 }
196
197 // At Github there exist issues with seemingly empty titles. An example is
198 // https://github.com/NixOS/nixpkgs/issues/72730 (here the title is actually
199 // a zero width space U+200B).
200 // Set title to some non-empty string, since git-bug does not accept empty titles.
201 title := text.CleanupOneLine(string(issue.Title))
202 if text.Empty(title) {
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 (here the title is actually
378 // a zero width space U+200B).
379 // Set title to some non-empty string, since git-bug does not accept empty titles.
380 title := text.CleanupOneLine(string(item.RenamedTitleEvent.CurrentTitle))
381 if text.Empty(title) {
382 title = EmptyTitlePlaceholder
383 }
384
385 op, err := b.SetTitleRaw(
386 author,
387 item.RenamedTitleEvent.CreatedAt.Unix(),
388 title,
389 map[string]string{metaKeyGithubId: id},
390 )
391 if err != nil {
392 return err
393 }
394
395 gi.out <- core.NewImportTitleEdition(op.Id())
396 return nil
397 }
398
399 return nil
400}
401
402func (gi *githubImporter) ensureCommentEdit(ctx context.Context, repo *cache.RepoCache, b *cache.BugCache, ghTargetId githubv4.ID, edit *userContentEdit) error {
403 // find comment
404 target, err := b.ResolveOperationWithMetadata(metaKeyGithubId, parseId(ghTargetId))
405 if err != nil {
406 return err
407 }
408 _, err = b.ResolveOperationWithMetadata(metaKeyGithubId, parseId(edit.Id))
409 if err == nil {
410 return nil
411 }
412 if err != cache.ErrNoMatchingOp {
413 // real error
414 return err
415 }
416
417 editor, err := gi.ensurePerson(ctx, repo, edit.Editor)
418 if err != nil {
419 return err
420 }
421
422 if edit.DeletedAt != nil {
423 // comment deletion, not supported yet
424 return nil
425 }
426
427 // comment edition
428 op, err := b.EditCommentRaw(
429 editor,
430 edit.CreatedAt.Unix(),
431 target,
432 text.Cleanup(string(*edit.Diff)),
433 map[string]string{
434 metaKeyGithubId: parseId(edit.Id),
435 },
436 )
437
438 if err != nil {
439 return err
440 }
441
442 gi.out <- core.NewImportCommentEdition(op.Id())
443 return nil
444}
445
446func (gi *githubImporter) ensureComment(ctx context.Context, repo *cache.RepoCache, b *cache.BugCache, comment *issueComment, firstEdit *userContentEdit) error {
447 author, err := gi.ensurePerson(ctx, repo, comment.Author)
448 if err != nil {
449 return err
450 }
451
452 _, err = b.ResolveOperationWithMetadata(metaKeyGithubId, parseId(comment.Id))
453 if err == nil {
454 return nil
455 }
456 if err != cache.ErrNoMatchingOp {
457 // real error
458 return err
459 }
460
461 var textInput string
462 if firstEdit != nil {
463 // use the first comment edit: it represents the comment creation itself
464 textInput = string(*firstEdit.Diff)
465 } else {
466 // if there are not comment edits, then the comment struct holds the comment creation
467 textInput = string(comment.Body)
468 }
469
470 // add comment operation
471 op, err := b.AddCommentRaw(
472 author,
473 comment.CreatedAt.Unix(),
474 text.Cleanup(textInput),
475 nil,
476 map[string]string{
477 metaKeyGithubId: parseId(comment.Id),
478 metaKeyGithubUrl: comment.Url.String(),
479 },
480 )
481 if err != nil {
482 return err
483 }
484
485 gi.out <- core.NewImportComment(op.Id())
486 return nil
487}
488
489// ensurePerson create a bug.Person from the Github data
490func (gi *githubImporter) ensurePerson(ctx context.Context, repo *cache.RepoCache, actor *actor) (*cache.IdentityCache, error) {
491 // When a user has been deleted, Github return a null actor, while displaying a profile named "ghost"
492 // in it's UI. So we need a special case to get it.
493 if actor == nil {
494 return gi.getGhost(ctx, repo)
495 }
496
497 // Look first in the cache
498 i, err := repo.ResolveIdentityImmutableMetadata(metaKeyGithubLogin, string(actor.Login))
499 if err == nil {
500 return i, nil
501 }
502 if entity.IsErrMultipleMatch(err) {
503 return nil, err
504 }
505
506 // importing a new identity
507 var name string
508 var email string
509
510 switch actor.Typename {
511 case "User":
512 if actor.User.Name != nil {
513 name = string(*(actor.User.Name))
514 }
515 email = string(actor.User.Email)
516 case "Organization":
517 if actor.Organization.Name != nil {
518 name = string(*(actor.Organization.Name))
519 }
520 if actor.Organization.Email != nil {
521 email = string(*(actor.Organization.Email))
522 }
523 case "Bot":
524 }
525
526 // Name is not necessarily set, fallback to login as a name is required in the identity
527 if name == "" {
528 name = string(actor.Login)
529 }
530
531 i, err = repo.NewIdentityRaw(
532 name,
533 email,
534 string(actor.Login),
535 string(actor.AvatarUrl),
536 nil,
537 map[string]string{
538 metaKeyGithubLogin: string(actor.Login),
539 },
540 )
541
542 if err != nil {
543 return nil, err
544 }
545
546 gi.out <- core.NewImportIdentity(i.Id())
547 return i, nil
548}
549
550func (gi *githubImporter) getGhost(ctx context.Context, repo *cache.RepoCache) (*cache.IdentityCache, error) {
551 loginName := "ghost"
552 // Look first in the cache
553 i, err := repo.ResolveIdentityImmutableMetadata(metaKeyGithubLogin, loginName)
554 if err == nil {
555 return i, nil
556 }
557 if entity.IsErrMultipleMatch(err) {
558 return nil, err
559 }
560 user, err := gi.mediator.User(ctx, loginName)
561 if err != nil {
562 return nil, err
563 }
564 userName := ""
565 if user.Name != nil {
566 userName = string(*user.Name)
567 }
568 return repo.NewIdentityRaw(
569 userName,
570 "",
571 string(user.Login),
572 string(user.AvatarUrl),
573 nil,
574 map[string]string{
575 metaKeyGithubLogin: string(user.Login),
576 },
577 )
578}
579
580// parseId converts the unusable githubv4.ID (an interface{}) into a string
581func parseId(id githubv4.ID) string {
582 return fmt.Sprintf("%v", id)
583}