1package github
2
3import (
4 "context"
5 "fmt"
6 "strings"
7
8 "github.com/MichaelMure/git-bug/bridge/core"
9 "github.com/MichaelMure/git-bug/bug"
10 "github.com/MichaelMure/git-bug/cache"
11 "github.com/MichaelMure/git-bug/util/git"
12 "github.com/shurcooL/githubv4"
13)
14
15const keyGithubId = "github-id"
16const keyGithubUrl = "github-url"
17
18// githubImporter implement the Importer interface
19type githubImporter struct{}
20
21func (*githubImporter) ImportAll(repo *cache.RepoCache, conf core.Configuration) error {
22 client := buildClient(conf)
23
24 q := &issueTimelineQuery{}
25 variables := map[string]interface{}{
26 "owner": githubv4.String(conf[keyUser]),
27 "name": githubv4.String(conf[keyProject]),
28 "issueFirst": githubv4.Int(1),
29 "issueAfter": (*githubv4.String)(nil),
30 "timelineFirst": githubv4.Int(10),
31 "timelineAfter": (*githubv4.String)(nil),
32
33 // Fun fact, github provide the comment edition in reverse chronological
34 // order, because haha. Look at me, I'm dying of laughter.
35 "issueEditLast": githubv4.Int(10),
36 "issueEditBefore": (*githubv4.String)(nil),
37 "commentEditLast": githubv4.Int(10),
38 "commentEditBefore": (*githubv4.String)(nil),
39 }
40
41 var b *cache.BugCache
42
43 for {
44 err := client.Query(context.TODO(), &q, variables)
45 if err != nil {
46 return err
47 }
48
49 if len(q.Repository.Issues.Nodes) == 0 {
50 return nil
51 }
52
53 issue := q.Repository.Issues.Nodes[0]
54
55 if b == nil {
56 b, err = ensureIssue(repo, issue, client, variables)
57 if err != nil {
58 return err
59 }
60 }
61
62 for _, itemEdge := range q.Repository.Issues.Nodes[0].Timeline.Edges {
63 ensureTimelineItem(b, itemEdge.Cursor, itemEdge.Node, client, variables)
64 }
65
66 if !issue.Timeline.PageInfo.HasNextPage {
67 err = b.CommitAsNeeded()
68 if err != nil {
69 return err
70 }
71
72 b = nil
73
74 if !q.Repository.Issues.PageInfo.HasNextPage {
75 break
76 }
77
78 variables["issueAfter"] = githubv4.NewString(q.Repository.Issues.PageInfo.EndCursor)
79 variables["timelineAfter"] = (*githubv4.String)(nil)
80 continue
81 }
82
83 variables["timelineAfter"] = githubv4.NewString(issue.Timeline.PageInfo.EndCursor)
84 }
85
86 return nil
87}
88
89func (*githubImporter) Import(repo *cache.RepoCache, conf core.Configuration, id string) error {
90 fmt.Println(conf)
91 fmt.Println("IMPORT")
92
93 return nil
94}
95
96func ensureIssue(repo *cache.RepoCache, issue issueTimeline, client *githubv4.Client, rootVariables map[string]interface{}) (*cache.BugCache, error) {
97 fmt.Printf("import issue: %s\n", issue.Title)
98
99 b, err := repo.ResolveBugCreateMetadata(keyGithubId, parseId(issue.Id))
100 if err != nil && err != bug.ErrBugNotExist {
101 return nil, err
102 }
103
104 // if there is no edit, the UserContentEdits given by github is empty. That
105 // means that the original message is given by the issue message.
106
107 // if there is edits, the UserContentEdits given by github contains both the
108 // original message and the following edits. The issue message give the last
109 // version so we don't care about that.
110
111 if len(issue.UserContentEdits.Nodes) == 0 {
112 if err == bug.ErrBugNotExist {
113 b, err = repo.NewBugRaw(
114 makePerson(issue.Author),
115 issue.CreatedAt.Unix(),
116 // Todo: this might not be the initial title, we need to query the
117 // timeline to be sure
118 issue.Title,
119 cleanupText(string(issue.Body)),
120 nil,
121 map[string]string{
122 keyGithubId: parseId(issue.Id),
123 keyGithubUrl: issue.Url.String(),
124 },
125 )
126
127 if err != nil {
128 return nil, err
129 }
130 }
131
132 return b, nil
133 }
134
135 // reverse the order, because github
136 reverseEdits(issue.UserContentEdits.Nodes)
137
138 if err == bug.ErrBugNotExist {
139 firstEdit := issue.UserContentEdits.Nodes[0]
140
141 if firstEdit.Diff == nil {
142 return nil, fmt.Errorf("no diff")
143 }
144
145 b, err = repo.NewBugRaw(
146 makePerson(issue.Author),
147 issue.CreatedAt.Unix(),
148 // Todo: this might not be the initial title, we need to query the
149 // timeline to be sure
150 issue.Title,
151 cleanupText(string(*firstEdit.Diff)),
152 nil,
153 map[string]string{
154 keyGithubId: parseId(issue.Id),
155 keyGithubUrl: issue.Url.String(),
156 },
157 )
158 if err != nil {
159 return nil, err
160 }
161 }
162
163 target, err := b.ResolveTargetWithMetadata(keyGithubId, parseId(issue.Id))
164 if err != nil {
165 return nil, err
166 }
167
168 for i, edit := range issue.UserContentEdits.Nodes {
169 if i == 0 {
170 // The first edit in the github result is the creation itself, we already have that
171 continue
172 }
173
174 err := ensureCommentEdit(b, target, edit)
175 if err != nil {
176 return nil, err
177 }
178 }
179
180 if !issue.UserContentEdits.PageInfo.HasNextPage {
181 return b, nil
182 }
183
184 // We have more edit, querying them
185
186 q := &issueEditQuery{}
187 variables := map[string]interface{}{
188 "owner": rootVariables["owner"],
189 "name": rootVariables["name"],
190 "issueFirst": rootVariables["issueFirst"],
191 "issueAfter": rootVariables["issueAfter"],
192 "issueEditLast": githubv4.Int(10),
193 "issueEditBefore": issue.UserContentEdits.PageInfo.StartCursor,
194 }
195
196 for {
197 err := client.Query(context.TODO(), &q, variables)
198 if err != nil {
199 return nil, err
200 }
201
202 edits := q.Repository.Issues.Nodes[0].UserContentEdits
203
204 if len(edits.Nodes) == 0 {
205 return b, nil
206 }
207
208 for i, edit := range edits.Nodes {
209 if i == 0 {
210 // The first edit in the github result is the creation itself, we already have that
211 continue
212 }
213
214 err := ensureCommentEdit(b, target, edit)
215 if err != nil {
216 return nil, err
217 }
218 }
219
220 if !edits.PageInfo.HasNextPage {
221 break
222 }
223
224 variables["issueEditBefore"] = edits.PageInfo.StartCursor
225 }
226
227 // TODO: check + import files
228
229 return b, nil
230}
231
232func ensureTimelineItem(b *cache.BugCache, cursor githubv4.String, item timelineItem, client *githubv4.Client, rootVariables map[string]interface{}) error {
233 fmt.Printf("import %s\n", item.Typename)
234
235 switch item.Typename {
236 case "IssueComment":
237 return ensureComment(b, cursor, item.IssueComment, client, rootVariables)
238
239 case "LabeledEvent":
240 id := parseId(item.LabeledEvent.Id)
241 _, err := b.ResolveTargetWithMetadata(keyGithubId, id)
242 if err != cache.ErrNoMatchingOp {
243 return err
244 }
245 _, err = b.ChangeLabelsRaw(
246 makePerson(item.LabeledEvent.Actor),
247 item.LabeledEvent.CreatedAt.Unix(),
248 []string{
249 string(item.LabeledEvent.Label.Name),
250 },
251 nil,
252 map[string]string{keyGithubId: id},
253 )
254 return err
255
256 case "UnlabeledEvent":
257 id := parseId(item.UnlabeledEvent.Id)
258 _, err := b.ResolveTargetWithMetadata(keyGithubId, id)
259 if err != cache.ErrNoMatchingOp {
260 return err
261 }
262 _, err = b.ChangeLabelsRaw(
263 makePerson(item.UnlabeledEvent.Actor),
264 item.UnlabeledEvent.CreatedAt.Unix(),
265 nil,
266 []string{
267 string(item.UnlabeledEvent.Label.Name),
268 },
269 map[string]string{keyGithubId: id},
270 )
271 return err
272
273 case "ClosedEvent":
274 id := parseId(item.ClosedEvent.Id)
275 _, err := b.ResolveTargetWithMetadata(keyGithubId, id)
276 if err != cache.ErrNoMatchingOp {
277 return err
278 }
279 return b.CloseRaw(
280 makePerson(item.ClosedEvent.Actor),
281 item.ClosedEvent.CreatedAt.Unix(),
282 map[string]string{keyGithubId: id},
283 )
284
285 case "ReopenedEvent":
286 id := parseId(item.ReopenedEvent.Id)
287 _, err := b.ResolveTargetWithMetadata(keyGithubId, id)
288 if err != cache.ErrNoMatchingOp {
289 return err
290 }
291 return b.OpenRaw(
292 makePerson(item.ReopenedEvent.Actor),
293 item.ReopenedEvent.CreatedAt.Unix(),
294 map[string]string{keyGithubId: id},
295 )
296
297 case "RenamedTitleEvent":
298 id := parseId(item.RenamedTitleEvent.Id)
299 _, err := b.ResolveTargetWithMetadata(keyGithubId, id)
300 if err != cache.ErrNoMatchingOp {
301 return err
302 }
303 return b.SetTitleRaw(
304 makePerson(item.RenamedTitleEvent.Actor),
305 item.RenamedTitleEvent.CreatedAt.Unix(),
306 string(item.RenamedTitleEvent.CurrentTitle),
307 map[string]string{keyGithubId: id},
308 )
309
310 default:
311 fmt.Println("ignore event ", item.Typename)
312 }
313
314 return nil
315}
316
317func ensureComment(b *cache.BugCache, cursor githubv4.String, comment issueComment, client *githubv4.Client, rootVariables map[string]interface{}) error {
318 target, err := b.ResolveTargetWithMetadata(keyGithubId, parseId(comment.Id))
319 if err != nil && err != cache.ErrNoMatchingOp {
320 // real error
321 return err
322 }
323
324 // if there is no edit, the UserContentEdits given by github is empty. That
325 // means that the original message is given by the comment message.
326
327 // if there is edits, the UserContentEdits given by github contains both the
328 // original message and the following edits. The comment message give the last
329 // version so we don't care about that.
330
331 if len(comment.UserContentEdits.Nodes) == 0 {
332 if err == cache.ErrNoMatchingOp {
333 err = b.AddCommentRaw(
334 makePerson(comment.Author),
335 comment.CreatedAt.Unix(),
336 cleanupText(string(comment.Body)),
337 nil,
338 map[string]string{
339 keyGithubId: parseId(comment.Id),
340 },
341 )
342
343 if err != nil {
344 return err
345 }
346 }
347
348 return nil
349 }
350
351 // reverse the order, because github
352 reverseEdits(comment.UserContentEdits.Nodes)
353
354 if err == cache.ErrNoMatchingOp {
355 firstEdit := comment.UserContentEdits.Nodes[0]
356
357 if firstEdit.Diff == nil {
358 return fmt.Errorf("no diff")
359 }
360
361 err = b.AddCommentRaw(
362 makePerson(comment.Author),
363 comment.CreatedAt.Unix(),
364 cleanupText(string(*firstEdit.Diff)),
365 nil,
366 map[string]string{
367 keyGithubId: parseId(comment.Id),
368 keyGithubUrl: comment.Url.String(),
369 },
370 )
371 if err != nil {
372 return err
373 }
374
375 target, err = b.ResolveTargetWithMetadata(keyGithubId, parseId(comment.Id))
376 if err != nil {
377 return err
378 }
379 }
380
381 for i, edit := range comment.UserContentEdits.Nodes {
382 if i == 0 {
383 // The first edit in the github result is the comment creation itself, we already have that
384 continue
385 }
386
387 err := ensureCommentEdit(b, target, edit)
388 if err != nil {
389 return err
390 }
391 }
392
393 if !comment.UserContentEdits.PageInfo.HasNextPage {
394 return nil
395 }
396
397 // We have more edit, querying them
398
399 q := &commentEditQuery{}
400 variables := map[string]interface{}{
401 "owner": rootVariables["owner"],
402 "name": rootVariables["name"],
403 "issueFirst": rootVariables["issueFirst"],
404 "issueAfter": rootVariables["issueAfter"],
405 "timelineFirst": githubv4.Int(1),
406 "timelineAfter": cursor,
407 "commentEditLast": githubv4.Int(10),
408 "commentEditBefore": comment.UserContentEdits.PageInfo.StartCursor,
409 }
410
411 for {
412 err := client.Query(context.TODO(), &q, variables)
413 if err != nil {
414 return err
415 }
416
417 edits := q.Repository.Issues.Nodes[0].Timeline.Nodes[0].IssueComment.UserContentEdits
418
419 if len(edits.Nodes) == 0 {
420 return nil
421 }
422
423 for i, edit := range edits.Nodes {
424 if i == 0 {
425 // The first edit in the github result is the creation itself, we already have that
426 continue
427 }
428
429 err := ensureCommentEdit(b, target, edit)
430 if err != nil {
431 return err
432 }
433 }
434
435 if !edits.PageInfo.HasNextPage {
436 break
437 }
438
439 variables["commentEditBefore"] = edits.PageInfo.StartCursor
440 }
441
442 // TODO: check + import files
443
444 return nil
445}
446
447func ensureCommentEdit(b *cache.BugCache, target git.Hash, edit userContentEdit) error {
448 if edit.Editor == nil {
449 return fmt.Errorf("no editor")
450 }
451
452 if edit.Diff == nil {
453 return fmt.Errorf("no diff")
454 }
455
456 _, err := b.ResolveTargetWithMetadata(keyGithubId, parseId(edit.Id))
457 if err == nil {
458 // already imported
459 return nil
460 }
461 if err != cache.ErrNoMatchingOp {
462 // real error
463 return err
464 }
465
466 fmt.Printf("import edition\n")
467
468 switch {
469 case edit.DeletedAt != nil:
470 // comment deletion, not supported yet
471
472 case edit.DeletedAt == nil:
473 // comment edition
474 err := b.EditCommentRaw(
475 makePerson(*edit.Editor),
476 edit.CreatedAt.Unix(),
477 target,
478 cleanupText(string(*edit.Diff)),
479 map[string]string{
480 keyGithubId: parseId(edit.Id),
481 },
482 )
483 if err != nil {
484 return err
485 }
486 }
487
488 return nil
489}
490
491// makePerson create a bug.Person from the Github data
492func makePerson(actor actor) bug.Person {
493 return bug.Person{
494 Name: string(actor.Login),
495 AvatarUrl: string(actor.AvatarUrl),
496 }
497}
498
499// parseId convert the unusable githubv4.ID (an interface{}) into a string
500func parseId(id githubv4.ID) string {
501 return fmt.Sprintf("%v", id)
502}
503
504func cleanupText(text string) string {
505 // windows new line, Github, really ?
506 text = strings.Replace(text, "\r\n", "\n", -1)
507
508 // trim extra new line not displayed in the github UI but still present in the data
509 return strings.TrimSpace(text)
510}
511
512func reverseEdits(edits []userContentEdit) []userContentEdit {
513 for i, j := 0, len(edits)-1; i < j; i, j = i+1, j-1 {
514 edits[i], edits[j] = edits[j], edits[i]
515 }
516 return edits
517}