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