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