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