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[metaKeyGithubUrl] == issue.Url.String() &&
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 .
199 // The title provided by the GraphQL API actually consists of a space followed by a
200 // zero width space (U+200B). This title would cause the NewBugRaw() function to
201 // return an error: empty title.
202 title := string(issue.Title)
203 if title == " \u200b" { // U+200B == zero width space
204 title = EmptyTitlePlaceholder
205 }
206
207 var textInput string
208 if issueEdit != nil {
209 // use the first issue edit: it represents the bug creation itself
210 textInput = string(*issueEdit.Diff)
211 } else {
212 // if there are no issue edits then the issue struct holds the bug creation
213 textInput = string(issue.Body)
214 }
215
216 // create bug
217 b, _, err = repo.NewBugRaw(
218 author,
219 issue.CreatedAt.Unix(),
220 text.CleanupOneLine(title), // TODO: this is the *current* title, not the original one
221 text.Cleanup(textInput),
222 nil,
223 map[string]string{
224 core.MetaKeyOrigin: target,
225 metaKeyGithubId: parseId(issue.Id),
226 metaKeyGithubUrl: issue.Url.String(),
227 })
228 if err != nil {
229 return nil, err
230 }
231 // importing a new bug
232 gi.out <- core.NewImportBug(b.Id())
233
234 return b, nil
235}
236
237func (gi *githubImporter) ensureIssueEdit(ctx context.Context, repo *cache.RepoCache, bug *cache.BugCache, ghIssueId githubv4.ID, edit *userContentEdit) error {
238 return gi.ensureCommentEdit(ctx, repo, bug, ghIssueId, edit)
239}
240
241func (gi *githubImporter) ensureTimelineItem(ctx context.Context, repo *cache.RepoCache, b *cache.BugCache, item *timelineItem) error {
242
243 switch item.Typename {
244 case "IssueComment":
245 err := gi.ensureComment(ctx, repo, b, &item.IssueComment, nil)
246 if err != nil {
247 return fmt.Errorf("timeline comment creation: %v", err)
248 }
249 return nil
250
251 case "LabeledEvent":
252 id := parseId(item.LabeledEvent.Id)
253 _, err := b.ResolveOperationWithMetadata(metaKeyGithubId, id)
254 if err == nil {
255 return nil
256 }
257
258 if err != cache.ErrNoMatchingOp {
259 return err
260 }
261 author, err := gi.ensurePerson(ctx, repo, item.LabeledEvent.Actor)
262 if err != nil {
263 return err
264 }
265 op, err := b.ForceChangeLabelsRaw(
266 author,
267 item.LabeledEvent.CreatedAt.Unix(),
268 []string{
269 text.CleanupOneLine(string(item.LabeledEvent.Label.Name)),
270 },
271 nil,
272 map[string]string{metaKeyGithubId: id},
273 )
274 if err != nil {
275 return err
276 }
277
278 gi.out <- core.NewImportLabelChange(op.Id())
279 return nil
280
281 case "UnlabeledEvent":
282 id := parseId(item.UnlabeledEvent.Id)
283 _, err := b.ResolveOperationWithMetadata(metaKeyGithubId, id)
284 if err == nil {
285 return nil
286 }
287 if err != cache.ErrNoMatchingOp {
288 return err
289 }
290 author, err := gi.ensurePerson(ctx, repo, item.UnlabeledEvent.Actor)
291 if err != nil {
292 return err
293 }
294
295 op, err := b.ForceChangeLabelsRaw(
296 author,
297 item.UnlabeledEvent.CreatedAt.Unix(),
298 nil,
299 []string{
300 text.CleanupOneLine(string(item.UnlabeledEvent.Label.Name)),
301 },
302 map[string]string{metaKeyGithubId: id},
303 )
304 if err != nil {
305 return err
306 }
307
308 gi.out <- core.NewImportLabelChange(op.Id())
309 return nil
310
311 case "ClosedEvent":
312 id := parseId(item.ClosedEvent.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(ctx, repo, item.ClosedEvent.Actor)
321 if err != nil {
322 return err
323 }
324 op, err := b.CloseRaw(
325 author,
326 item.ClosedEvent.CreatedAt.Unix(),
327 map[string]string{metaKeyGithubId: id},
328 )
329
330 if err != nil {
331 return err
332 }
333
334 gi.out <- core.NewImportStatusChange(op.Id())
335 return nil
336
337 case "ReopenedEvent":
338 id := parseId(item.ReopenedEvent.Id)
339 _, err := b.ResolveOperationWithMetadata(metaKeyGithubId, id)
340 if err != cache.ErrNoMatchingOp {
341 return err
342 }
343 if err == nil {
344 return nil
345 }
346 author, err := gi.ensurePerson(ctx, repo, item.ReopenedEvent.Actor)
347 if err != nil {
348 return err
349 }
350 op, err := b.OpenRaw(
351 author,
352 item.ReopenedEvent.CreatedAt.Unix(),
353 map[string]string{metaKeyGithubId: id},
354 )
355
356 if err != nil {
357 return err
358 }
359
360 gi.out <- core.NewImportStatusChange(op.Id())
361 return nil
362
363 case "RenamedTitleEvent":
364 id := parseId(item.RenamedTitleEvent.Id)
365 _, err := b.ResolveOperationWithMetadata(metaKeyGithubId, id)
366 if err != cache.ErrNoMatchingOp {
367 return err
368 }
369 if err == nil {
370 return nil
371 }
372 author, err := gi.ensurePerson(ctx, repo, item.RenamedTitleEvent.Actor)
373 if err != nil {
374 return err
375 }
376
377 // At Github there exist issues with seemingly empty titles. An example is
378 // https://github.com/NixOS/nixpkgs/issues/72730 .
379 // The title provided by the GraphQL API actually consists of a space followed
380 // by a zero width space (U+200B). This title would cause the NewBugRaw()
381 // function to return an error: empty title.
382 title := text.CleanupOneLine(string(item.RenamedTitleEvent.CurrentTitle))
383 if title == " \u200b" { // U+200B == zero width space
384 title = EmptyTitlePlaceholder
385 }
386
387 op, err := b.SetTitleRaw(
388 author,
389 item.RenamedTitleEvent.CreatedAt.Unix(),
390 title,
391 map[string]string{metaKeyGithubId: id},
392 )
393 if err != nil {
394 return err
395 }
396
397 gi.out <- core.NewImportTitleEdition(op.Id())
398 return nil
399 }
400
401 return nil
402}
403
404func (gi *githubImporter) ensureCommentEdit(ctx context.Context, repo *cache.RepoCache, b *cache.BugCache, ghTargetId githubv4.ID, edit *userContentEdit) error {
405 // find comment
406 target, err := b.ResolveOperationWithMetadata(metaKeyGithubId, parseId(ghTargetId))
407 if err != nil {
408 return err
409 }
410 _, err = b.ResolveOperationWithMetadata(metaKeyGithubId, parseId(edit.Id))
411 if err == nil {
412 return nil
413 }
414 if err != cache.ErrNoMatchingOp {
415 // real error
416 return err
417 }
418
419 editor, err := gi.ensurePerson(ctx, repo, edit.Editor)
420 if err != nil {
421 return err
422 }
423
424 if edit.DeletedAt != nil {
425 // comment deletion, not supported yet
426 return nil
427 }
428
429 // comment edition
430 op, err := b.EditCommentRaw(
431 editor,
432 edit.CreatedAt.Unix(),
433 target,
434 text.Cleanup(string(*edit.Diff)),
435 map[string]string{
436 metaKeyGithubId: parseId(edit.Id),
437 },
438 )
439
440 if err != nil {
441 return err
442 }
443
444 gi.out <- core.NewImportCommentEdition(op.Id())
445 return nil
446}
447
448func (gi *githubImporter) ensureComment(ctx context.Context, repo *cache.RepoCache, b *cache.BugCache, comment *issueComment, firstEdit *userContentEdit) error {
449 author, err := gi.ensurePerson(ctx, repo, comment.Author)
450 if err != nil {
451 return err
452 }
453
454 _, err = b.ResolveOperationWithMetadata(metaKeyGithubId, parseId(comment.Id))
455 if err == nil {
456 return nil
457 }
458 if err != cache.ErrNoMatchingOp {
459 // real error
460 return err
461 }
462
463 var textInput string
464 if firstEdit != nil {
465 // use the first comment edit: it represents the comment creation itself
466 textInput = string(*firstEdit.Diff)
467 } else {
468 // if there are not comment edits, then the comment struct holds the comment creation
469 textInput = string(comment.Body)
470 }
471
472 // add comment operation
473 op, err := b.AddCommentRaw(
474 author,
475 comment.CreatedAt.Unix(),
476 text.Cleanup(textInput),
477 nil,
478 map[string]string{
479 metaKeyGithubId: parseId(comment.Id),
480 metaKeyGithubUrl: comment.Url.String(),
481 },
482 )
483 if err != nil {
484 return err
485 }
486
487 gi.out <- core.NewImportComment(op.Id())
488 return nil
489}
490
491// ensurePerson create a bug.Person from the Github data
492func (gi *githubImporter) ensurePerson(ctx context.Context, repo *cache.RepoCache, actor *actor) (*cache.IdentityCache, error) {
493 // When a user has been deleted, Github return a null actor, while displaying a profile named "ghost"
494 // in it's UI. So we need a special case to get it.
495 if actor == nil {
496 return gi.getGhost(ctx, repo)
497 }
498
499 // Look first in the cache
500 i, err := repo.ResolveIdentityImmutableMetadata(metaKeyGithubLogin, string(actor.Login))
501 if err == nil {
502 return i, nil
503 }
504 if entity.IsErrMultipleMatch(err) {
505 return nil, err
506 }
507
508 // importing a new identity
509 var name string
510 var email string
511
512 switch actor.Typename {
513 case "User":
514 if actor.User.Name != nil {
515 name = string(*(actor.User.Name))
516 }
517 email = string(actor.User.Email)
518 case "Organization":
519 if actor.Organization.Name != nil {
520 name = string(*(actor.Organization.Name))
521 }
522 if actor.Organization.Email != nil {
523 email = string(*(actor.Organization.Email))
524 }
525 case "Bot":
526 }
527
528 // Name is not necessarily set, fallback to login as a name is required in the identity
529 if name == "" {
530 name = string(actor.Login)
531 }
532
533 i, err = repo.NewIdentityRaw(
534 name,
535 email,
536 string(actor.Login),
537 string(actor.AvatarUrl),
538 nil,
539 map[string]string{
540 metaKeyGithubLogin: string(actor.Login),
541 },
542 )
543
544 if err != nil {
545 return nil, err
546 }
547
548 gi.out <- core.NewImportIdentity(i.Id())
549 return i, nil
550}
551
552func (gi *githubImporter) getGhost(ctx context.Context, repo *cache.RepoCache) (*cache.IdentityCache, error) {
553 loginName := "ghost"
554 // Look first in the cache
555 i, err := repo.ResolveIdentityImmutableMetadata(metaKeyGithubLogin, loginName)
556 if err == nil {
557 return i, nil
558 }
559 if entity.IsErrMultipleMatch(err) {
560 return nil, err
561 }
562 user, err := gi.mediator.User(ctx, loginName)
563 if err != nil {
564 return nil, err
565 }
566 userName := ""
567 if user.Name != nil {
568 userName = string(*user.Name)
569 }
570 return repo.NewIdentityRaw(
571 userName,
572 "",
573 string(user.Login),
574 string(user.AvatarUrl),
575 nil,
576 map[string]string{
577 metaKeyGithubLogin: string(user.Login),
578 },
579 )
580}
581
582// parseId converts the unusable githubv4.ID (an interface{}) into a string
583func parseId(id githubv4.ID) string {
584 return fmt.Sprintf("%v", id)
585}