1package github
2
3import (
4 "context"
5 "time"
6
7 "github.com/shurcooL/githubv4"
8)
9
10type indexer struct{ index int }
11
12type issueEditIterator struct {
13 index int
14 query issueEditQuery
15 variables map[string]interface{}
16}
17
18type commentEditIterator struct {
19 index int
20 query commentEditQuery
21 variables map[string]interface{}
22}
23
24type timelineIterator struct {
25 index int
26 query issueTimelineQuery
27 variables map[string]interface{}
28
29 issueEdit indexer
30 commentEdit indexer
31
32 // lastEndCursor cache the timeline end cursor for one iteration
33 lastEndCursor githubv4.String
34}
35
36type iterator struct {
37 // github graphql client
38 gc *githubv4.Client
39
40 // if since is given the iterator will query only the updated
41 // and created issues after this date
42 since time.Time
43
44 // number of timelines/userEditcontent/issueEdit to query
45 // at a time, more capacity = more used memory = less queries
46 // to make
47 capacity int
48
49 // shared context used for all graphql queries
50 ctx context.Context
51
52 // sticky error
53 err error
54
55 // timeline iterator
56 timeline timelineIterator
57
58 // issue edit iterator
59 issueEdit issueEditIterator
60
61 // comment edit iterator
62 commentEdit commentEditIterator
63}
64
65// NewIterator create and initialize a new iterator
66func NewIterator(ctx context.Context, capacity int, owner, project, token string, since time.Time) *iterator {
67 i := &iterator{
68 gc: buildClient(token),
69 since: since,
70 capacity: capacity,
71 ctx: ctx,
72 timeline: timelineIterator{
73 index: -1,
74 issueEdit: indexer{-1},
75 commentEdit: indexer{-1},
76 variables: map[string]interface{}{
77 "owner": githubv4.String(owner),
78 "name": githubv4.String(project),
79 },
80 },
81 commentEdit: commentEditIterator{
82 index: -1,
83 variables: map[string]interface{}{
84 "owner": githubv4.String(owner),
85 "name": githubv4.String(project),
86 },
87 },
88 issueEdit: issueEditIterator{
89 index: -1,
90 variables: map[string]interface{}{
91 "owner": githubv4.String(owner),
92 "name": githubv4.String(project),
93 },
94 },
95 }
96
97 i.initTimelineQueryVariables()
98 return i
99}
100
101// init issue timeline variables
102func (i *iterator) initTimelineQueryVariables() {
103 i.timeline.variables["issueFirst"] = githubv4.Int(1)
104 i.timeline.variables["issueAfter"] = (*githubv4.String)(nil)
105 i.timeline.variables["issueSince"] = githubv4.DateTime{Time: i.since}
106 i.timeline.variables["timelineFirst"] = githubv4.Int(i.capacity)
107 i.timeline.variables["timelineAfter"] = (*githubv4.String)(nil)
108 // Fun fact, github provide the comment edition in reverse chronological
109 // order, because haha. Look at me, I'm dying of laughter.
110 i.timeline.variables["issueEditLast"] = githubv4.Int(i.capacity)
111 i.timeline.variables["issueEditBefore"] = (*githubv4.String)(nil)
112 i.timeline.variables["commentEditLast"] = githubv4.Int(i.capacity)
113 i.timeline.variables["commentEditBefore"] = (*githubv4.String)(nil)
114}
115
116// init issue edit variables
117func (i *iterator) initIssueEditQueryVariables() {
118 i.issueEdit.variables["issueFirst"] = githubv4.Int(1)
119 i.issueEdit.variables["issueAfter"] = i.timeline.variables["issueAfter"]
120 i.issueEdit.variables["issueSince"] = githubv4.DateTime{Time: i.since}
121 i.issueEdit.variables["issueEditLast"] = githubv4.Int(i.capacity)
122 i.issueEdit.variables["issueEditBefore"] = (*githubv4.String)(nil)
123}
124
125// init issue comment variables
126func (i *iterator) initCommentEditQueryVariables() {
127 i.commentEdit.variables["issueFirst"] = githubv4.Int(1)
128 i.commentEdit.variables["issueAfter"] = i.timeline.variables["issueAfter"]
129 i.commentEdit.variables["issueSince"] = githubv4.DateTime{Time: i.since}
130 i.commentEdit.variables["timelineFirst"] = githubv4.Int(1)
131 i.commentEdit.variables["timelineAfter"] = (*githubv4.String)(nil)
132 i.commentEdit.variables["commentEditLast"] = githubv4.Int(i.capacity)
133 i.commentEdit.variables["commentEditBefore"] = (*githubv4.String)(nil)
134}
135
136// reverse UserContentEdits arrays in both of the issue and
137// comment timelines
138func (i *iterator) reverseTimelineEditNodes() {
139 node := i.timeline.query.Repository.Issues.Nodes[0]
140 reverseEdits(node.UserContentEdits.Nodes)
141 for index, ce := range node.Timeline.Edges {
142 if ce.Node.Typename == "IssueComment" && len(node.Timeline.Edges) != 0 {
143 reverseEdits(node.Timeline.Edges[index].Node.IssueComment.UserContentEdits.Nodes)
144 }
145 }
146}
147
148// Error return last encountered error
149func (i *iterator) Error() error {
150 return i.err
151}
152
153func (i *iterator) queryIssue() bool {
154 ctx, cancel := context.WithTimeout(i.ctx, defaultTimeout)
155 defer cancel()
156
157 if err := i.gc.Query(ctx, &i.timeline.query, i.timeline.variables); err != nil {
158 i.err = err
159 return false
160 }
161
162 issues := i.timeline.query.Repository.Issues.Nodes
163 if len(issues) == 0 {
164 return false
165 }
166
167 i.reverseTimelineEditNodes()
168 return true
169}
170
171// NextIssue try to query the next issue and return true. Only one issue is
172// queried at each call.
173func (i *iterator) NextIssue() bool {
174 if i.err != nil {
175 return false
176 }
177
178 // if $issueAfter variable is nil we can directly make the first query
179 if i.timeline.variables["issueAfter"] == (*githubv4.String)(nil) {
180 nextIssue := i.queryIssue()
181 // prevent from infinite loop by setting a non nil cursor
182 issues := i.timeline.query.Repository.Issues
183 i.timeline.variables["issueAfter"] = issues.PageInfo.EndCursor
184 return nextIssue
185 }
186
187 issues := i.timeline.query.Repository.Issues
188 if !issues.PageInfo.HasNextPage {
189 return false
190 }
191
192 // if we have more issues, query them
193 i.timeline.variables["timelineAfter"] = (*githubv4.String)(nil)
194 i.timeline.variables["issueAfter"] = issues.PageInfo.EndCursor
195 i.timeline.index = -1
196
197 timelineEndCursor := issues.Nodes[0].Timeline.PageInfo.EndCursor
198 // store cursor for future use
199 i.timeline.lastEndCursor = timelineEndCursor
200
201 // query issue block
202 return i.queryIssue()
203}
204
205// IssueValue return the actual issue value
206func (i *iterator) IssueValue() issueTimeline {
207 issues := i.timeline.query.Repository.Issues
208 return issues.Nodes[0]
209}
210
211// NextTimelineItem return true if there is a next timeline item and increments the index by one.
212// It is used iterates over all the timeline items. Extra queries are made if it is necessary.
213func (i *iterator) NextTimelineItem() bool {
214 if i.err != nil {
215 return false
216 }
217
218 if i.ctx.Err() != nil {
219 return false
220 }
221
222 timeline := i.timeline.query.Repository.Issues.Nodes[0].Timeline
223 // after NextIssue call it's good to check wether we have some timeline items or not
224 if len(timeline.Edges) == 0 {
225 return false
226 }
227
228 if i.timeline.index < len(timeline.Edges)-1 {
229 i.timeline.index++
230 return true
231 }
232
233 if !timeline.PageInfo.HasNextPage {
234 return false
235 }
236
237 i.timeline.lastEndCursor = timeline.PageInfo.EndCursor
238
239 // more timelines, query them
240 i.timeline.variables["timelineAfter"] = timeline.PageInfo.EndCursor
241
242 ctx, cancel := context.WithTimeout(i.ctx, defaultTimeout)
243 defer cancel()
244
245 if err := i.gc.Query(ctx, &i.timeline.query, i.timeline.variables); err != nil {
246 i.err = err
247 return false
248 }
249
250 timeline = i.timeline.query.Repository.Issues.Nodes[0].Timeline
251 // (in case github returns something wierd) just for safety: better return a false than a panic
252 if len(timeline.Edges) == 0 {
253 return false
254 }
255
256 i.reverseTimelineEditNodes()
257 i.timeline.index = 0
258 return true
259}
260
261// TimelineItemValue return the actual timeline item value
262func (i *iterator) TimelineItemValue() timelineItem {
263 timeline := i.timeline.query.Repository.Issues.Nodes[0].Timeline
264 return timeline.Edges[i.timeline.index].Node
265}
266
267func (i *iterator) queryIssueEdit() bool {
268 ctx, cancel := context.WithTimeout(i.ctx, defaultTimeout)
269 defer cancel()
270
271 if err := i.gc.Query(ctx, &i.issueEdit.query, i.issueEdit.variables); err != nil {
272 i.err = err
273 //i.timeline.issueEdit.index = -1
274 return false
275 }
276
277 issueEdits := i.issueEdit.query.Repository.Issues.Nodes[0].UserContentEdits
278 // reverse issue edits because github
279 reverseEdits(issueEdits.Nodes)
280
281 // this is not supposed to happen
282 if len(issueEdits.Nodes) == 0 {
283 i.timeline.issueEdit.index = -1
284 return false
285 }
286
287 i.issueEdit.index = 0
288 i.timeline.issueEdit.index = -2
289 return i.nextValidIssueEdit()
290}
291
292func (i *iterator) nextValidIssueEdit() bool {
293 // issueEdit.Diff == nil happen if the event is older than early 2018, Github doesn't have the data before that.
294 // Best we can do is to ignore the event.
295 if issueEdit := i.IssueEditValue(); issueEdit.Diff == nil || string(*issueEdit.Diff) == "" {
296 return i.NextIssueEdit()
297 }
298 return true
299}
300
301// NextIssueEdit return true if there is a next issue edit and increments the index by one.
302// It is used iterates over all the issue edits. Extra queries are made if it is necessary.
303func (i *iterator) NextIssueEdit() bool {
304 if i.err != nil {
305 return false
306 }
307
308 if i.ctx.Err() != nil {
309 return false
310 }
311
312 // this mean we looped over all available issue edits in the timeline.
313 // now we have to use i.issueEditQuery
314 if i.timeline.issueEdit.index == -2 {
315 issueEdits := i.issueEdit.query.Repository.Issues.Nodes[0].UserContentEdits
316 if i.issueEdit.index < len(issueEdits.Nodes)-1 {
317 i.issueEdit.index++
318 return i.nextValidIssueEdit()
319 }
320
321 if !issueEdits.PageInfo.HasPreviousPage {
322 i.timeline.issueEdit.index = -1
323 i.issueEdit.index = -1
324 return false
325 }
326
327 // if there is more edits, query them
328 i.issueEdit.variables["issueEditBefore"] = issueEdits.PageInfo.StartCursor
329 return i.queryIssueEdit()
330 }
331
332 issueEdits := i.timeline.query.Repository.Issues.Nodes[0].UserContentEdits
333 // if there is no edit, the UserContentEdits given by github is empty. That
334 // means that the original message is given by the issue message.
335 //
336 // if there is edits, the UserContentEdits given by github contains both the
337 // original message and the following edits. The issue message give the last
338 // version so we don't care about that.
339 //
340 // the tricky part: for an issue older than the UserContentEdits API, github
341 // doesn't have the previous message version anymore and give an edition
342 // with .Diff == nil. We have to filter them.
343 if len(issueEdits.Nodes) == 0 {
344 return false
345 }
346
347 // loop over them timeline comment edits
348 if i.timeline.issueEdit.index < len(issueEdits.Nodes)-1 {
349 i.timeline.issueEdit.index++
350 return i.nextValidIssueEdit()
351 }
352
353 if !issueEdits.PageInfo.HasPreviousPage {
354 i.timeline.issueEdit.index = -1
355 return false
356 }
357
358 // if there is more edits, query them
359 i.initIssueEditQueryVariables()
360 i.issueEdit.variables["issueEditBefore"] = issueEdits.PageInfo.StartCursor
361 return i.queryIssueEdit()
362}
363
364// IssueEditValue return the actual issue edit value
365func (i *iterator) IssueEditValue() userContentEdit {
366 // if we are using issue edit query
367 if i.timeline.issueEdit.index == -2 {
368 issueEdits := i.issueEdit.query.Repository.Issues.Nodes[0].UserContentEdits
369 return issueEdits.Nodes[i.issueEdit.index]
370 }
371
372 issueEdits := i.timeline.query.Repository.Issues.Nodes[0].UserContentEdits
373 // else get it from timeline issue edit query
374 return issueEdits.Nodes[i.timeline.issueEdit.index]
375}
376
377func (i *iterator) queryCommentEdit() bool {
378 ctx, cancel := context.WithTimeout(i.ctx, defaultTimeout)
379 defer cancel()
380
381 if err := i.gc.Query(ctx, &i.commentEdit.query, i.commentEdit.variables); err != nil {
382 i.err = err
383 return false
384 }
385
386 commentEdits := i.commentEdit.query.Repository.Issues.Nodes[0].Timeline.Nodes[0].IssueComment.UserContentEdits
387 // this is not supposed to happen
388 if len(commentEdits.Nodes) == 0 {
389 i.timeline.commentEdit.index = -1
390 return false
391 }
392
393 reverseEdits(commentEdits.Nodes)
394
395 i.commentEdit.index = 0
396 i.timeline.commentEdit.index = -2
397 return i.nextValidCommentEdit()
398}
399
400func (i *iterator) nextValidCommentEdit() bool {
401 // if comment edit diff is a nil pointer or points to an empty string look for next value
402 if commentEdit := i.CommentEditValue(); commentEdit.Diff == nil || string(*commentEdit.Diff) == "" {
403 return i.NextCommentEdit()
404 }
405 return true
406}
407
408// NextCommentEdit return true if there is a next comment edit and increments the index by one.
409// It is used iterates over all the comment edits. Extra queries are made if it is necessary.
410func (i *iterator) NextCommentEdit() bool {
411 if i.err != nil {
412 return false
413 }
414
415 if i.ctx.Err() != nil {
416 return false
417 }
418
419 // same as NextIssueEdit
420 if i.timeline.commentEdit.index == -2 {
421 commentEdits := i.commentEdit.query.Repository.Issues.Nodes[0].Timeline.Nodes[0].IssueComment.UserContentEdits
422 if i.commentEdit.index < len(commentEdits.Nodes)-1 {
423 i.commentEdit.index++
424 return i.nextValidCommentEdit()
425 }
426
427 if !commentEdits.PageInfo.HasPreviousPage {
428 i.timeline.commentEdit.index = -1
429 i.commentEdit.index = -1
430 return false
431 }
432
433 // if there is more comment edits, query them
434 i.commentEdit.variables["commentEditBefore"] = commentEdits.PageInfo.StartCursor
435 return i.queryCommentEdit()
436 }
437
438 commentEdits := i.timeline.query.Repository.Issues.Nodes[0].Timeline.Edges[i.timeline.index].Node.IssueComment
439 // if there is no comment edits
440 if len(commentEdits.UserContentEdits.Nodes) == 0 {
441 return false
442 }
443
444 // loop over them timeline comment edits
445 if i.timeline.commentEdit.index < len(commentEdits.UserContentEdits.Nodes)-1 {
446 i.timeline.commentEdit.index++
447 return i.nextValidCommentEdit()
448 }
449
450 if !commentEdits.UserContentEdits.PageInfo.HasPreviousPage {
451 i.timeline.commentEdit.index = -1
452 return false
453 }
454
455 i.initCommentEditQueryVariables()
456 if i.timeline.index == 0 {
457 i.commentEdit.variables["timelineAfter"] = i.timeline.lastEndCursor
458 } else {
459 i.commentEdit.variables["timelineAfter"] = i.timeline.query.Repository.Issues.Nodes[0].Timeline.Edges[i.timeline.index-1].Cursor
460 }
461
462 i.commentEdit.variables["commentEditBefore"] = commentEdits.UserContentEdits.PageInfo.StartCursor
463
464 return i.queryCommentEdit()
465}
466
467// CommentEditValue return the actual comment edit value
468func (i *iterator) CommentEditValue() userContentEdit {
469 if i.timeline.commentEdit.index == -2 {
470 return i.commentEdit.query.Repository.Issues.Nodes[0].Timeline.Nodes[0].IssueComment.UserContentEdits.Nodes[i.commentEdit.index]
471 }
472
473 return i.timeline.query.Repository.Issues.Nodes[0].Timeline.Edges[i.timeline.index].Node.IssueComment.UserContentEdits.Nodes[i.timeline.commentEdit.index]
474}
475
476func reverseEdits(edits []userContentEdit) {
477 for i, j := 0, len(edits)-1; i < j; i, j = i+1, j-1 {
478 edits[i], edits[j] = edits[j], edits[i]
479 }
480}