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 if len(i.timeline.query.Repository.Issues.Nodes) == 0 {
163 return false
164 }
165
166 i.reverseTimelineEditNodes()
167 return true
168}
169
170// NextIssue try to query the next issue and return true. Only one issue is
171// queried at each call.
172func (i *iterator) NextIssue() bool {
173 if i.err != nil {
174 return false
175 }
176
177 // if $issueAfter variable is nil we can directly make the first query
178 if i.timeline.variables["issueAfter"] == (*githubv4.String)(nil) {
179 nextIssue := i.queryIssue()
180 // prevent from infinite loop by setting a non nil cursor
181 i.timeline.variables["issueAfter"] = i.timeline.query.Repository.Issues.PageInfo.EndCursor
182 return nextIssue
183 }
184
185 if !i.timeline.query.Repository.Issues.PageInfo.HasNextPage {
186 return false
187 }
188
189 // if we have more issues, query them
190 i.timeline.variables["timelineAfter"] = (*githubv4.String)(nil)
191 i.timeline.variables["issueAfter"] = i.timeline.query.Repository.Issues.PageInfo.EndCursor
192 i.timeline.index = -1
193
194 // store cursor for future use
195 i.timeline.lastEndCursor = i.timeline.query.Repository.Issues.Nodes[0].Timeline.PageInfo.EndCursor
196
197 // query issue block
198 return i.queryIssue()
199}
200
201// IssueValue return the actual issue value
202func (i *iterator) IssueValue() issueTimeline {
203 return i.timeline.query.Repository.Issues.Nodes[0]
204}
205
206// NextTimelineItem return true if there is a next timeline item and increments the index by one.
207// It is used iterates over all the timeline items. Extra queries are made if it is necessary.
208func (i *iterator) NextTimelineItem() bool {
209 if i.err != nil {
210 return false
211 }
212
213 if i.ctx.Err() != nil {
214 return false
215 }
216
217 if len(i.timeline.query.Repository.Issues.Nodes[0].Timeline.Edges) == 0 {
218 return false
219 }
220
221 if i.timeline.index < len(i.timeline.query.Repository.Issues.Nodes[0].Timeline.Edges)-1 {
222 i.timeline.index++
223 return true
224 }
225
226 if !i.timeline.query.Repository.Issues.Nodes[0].Timeline.PageInfo.HasNextPage {
227 return false
228 }
229
230 i.timeline.lastEndCursor = i.timeline.query.Repository.Issues.Nodes[0].Timeline.PageInfo.EndCursor
231
232 // more timelines, query them
233 i.timeline.variables["timelineAfter"] = i.timeline.query.Repository.Issues.Nodes[0].Timeline.PageInfo.EndCursor
234
235 ctx, cancel := context.WithTimeout(i.ctx, defaultTimeout)
236 defer cancel()
237
238 if err := i.gc.Query(ctx, &i.timeline.query, i.timeline.variables); err != nil {
239 i.err = err
240 return false
241 }
242
243 i.reverseTimelineEditNodes()
244 i.timeline.index = 0
245 return true
246}
247
248// TimelineItemValue return the actual timeline item value
249func (i *iterator) TimelineItemValue() timelineItem {
250 return i.timeline.query.Repository.Issues.Nodes[0].Timeline.Edges[i.timeline.index].Node
251}
252
253func (i *iterator) queryIssueEdit() bool {
254 ctx, cancel := context.WithTimeout(i.ctx, defaultTimeout)
255 defer cancel()
256
257 if err := i.gc.Query(ctx, &i.issueEdit.query, i.issueEdit.variables); err != nil {
258 i.err = err
259 //i.timeline.issueEdit.index = -1
260 return false
261 }
262
263 // reverse issue edits because github
264 reverseEdits(i.issueEdit.query.Repository.Issues.Nodes[0].UserContentEdits.Nodes)
265
266 // this is not supposed to happen
267 if len(i.issueEdit.query.Repository.Issues.Nodes[0].UserContentEdits.Nodes) == 0 {
268 i.timeline.issueEdit.index = -1
269 return false
270 }
271
272 i.issueEdit.index = 0
273 i.timeline.issueEdit.index = -2
274 return i.nextValidIssueEdit()
275}
276
277func (i *iterator) nextValidIssueEdit() bool {
278 // issueEdit.Diff == nil happen if the event is older than early 2018, Github doesn't have the data before that.
279 // Best we can do is to ignore the event.
280 if issueEdit := i.IssueEditValue(); issueEdit.Diff == nil || string(*issueEdit.Diff) == "" {
281 return i.NextIssueEdit()
282 }
283 return true
284}
285
286// NextIssueEdit return true if there is a next issue edit and increments the index by one.
287// It is used iterates over all the issue edits. Extra queries are made if it is necessary.
288func (i *iterator) NextIssueEdit() bool {
289 if i.err != nil {
290 return false
291 }
292
293 if i.ctx.Err() != nil {
294 return false
295 }
296
297 // this mean we looped over all available issue edits in the timeline.
298 // now we have to use i.issueEditQuery
299 if i.timeline.issueEdit.index == -2 {
300 if i.issueEdit.index < len(i.issueEdit.query.Repository.Issues.Nodes[0].UserContentEdits.Nodes)-1 {
301 i.issueEdit.index++
302 return i.nextValidIssueEdit()
303 }
304
305 if !i.issueEdit.query.Repository.Issues.Nodes[0].UserContentEdits.PageInfo.HasPreviousPage {
306 i.timeline.issueEdit.index = -1
307 i.issueEdit.index = -1
308 return false
309 }
310
311 // if there is more edits, query them
312 i.issueEdit.variables["issueEditBefore"] = i.issueEdit.query.Repository.Issues.Nodes[0].UserContentEdits.PageInfo.StartCursor
313 return i.queryIssueEdit()
314 }
315
316 // if there is no edit, the UserContentEdits given by github is empty. That
317 // means that the original message is given by the issue message.
318 //
319 // if there is edits, the UserContentEdits given by github contains both the
320 // original message and the following edits. The issue message give the last
321 // version so we don't care about that.
322 //
323 // the tricky part: for an issue older than the UserContentEdits API, github
324 // doesn't have the previous message version anymore and give an edition
325 // with .Diff == nil. We have to filter them.
326 if len(i.timeline.query.Repository.Issues.Nodes[0].UserContentEdits.Nodes) == 0 {
327 return false
328 }
329
330 // loop over them timeline comment edits
331 if i.timeline.issueEdit.index < len(i.timeline.query.Repository.Issues.Nodes[0].UserContentEdits.Nodes)-1 {
332 i.timeline.issueEdit.index++
333 return i.nextValidIssueEdit()
334 }
335
336 if !i.timeline.query.Repository.Issues.Nodes[0].UserContentEdits.PageInfo.HasPreviousPage {
337 i.timeline.issueEdit.index = -1
338 return false
339 }
340
341 // if there is more edits, query them
342 i.initIssueEditQueryVariables()
343 i.issueEdit.variables["issueEditBefore"] = i.timeline.query.Repository.Issues.Nodes[0].UserContentEdits.PageInfo.StartCursor
344 return i.queryIssueEdit()
345}
346
347// IssueEditValue return the actual issue edit value
348func (i *iterator) IssueEditValue() userContentEdit {
349 // if we are using issue edit query
350 if i.timeline.issueEdit.index == -2 {
351 return i.issueEdit.query.Repository.Issues.Nodes[0].UserContentEdits.Nodes[i.issueEdit.index]
352 }
353
354 // else get it from timeline issue edit query
355 return i.timeline.query.Repository.Issues.Nodes[0].UserContentEdits.Nodes[i.timeline.issueEdit.index]
356}
357
358func (i *iterator) queryCommentEdit() bool {
359 ctx, cancel := context.WithTimeout(i.ctx, defaultTimeout)
360 defer cancel()
361
362 if err := i.gc.Query(ctx, &i.commentEdit.query, i.commentEdit.variables); err != nil {
363 i.err = err
364 return false
365 }
366
367 // this is not supposed to happen
368 if len(i.commentEdit.query.Repository.Issues.Nodes[0].Timeline.Nodes[0].IssueComment.UserContentEdits.Nodes) == 0 {
369 i.timeline.commentEdit.index = -1
370 return false
371 }
372
373 reverseEdits(i.commentEdit.query.Repository.Issues.Nodes[0].Timeline.Nodes[0].IssueComment.UserContentEdits.Nodes)
374
375 i.commentEdit.index = 0
376 i.timeline.commentEdit.index = -2
377 return i.nextValidCommentEdit()
378}
379
380func (i *iterator) nextValidCommentEdit() bool {
381 // if comment edit diff is a nil pointer or points to an empty string look for next value
382 if commentEdit := i.CommentEditValue(); commentEdit.Diff == nil || string(*commentEdit.Diff) == "" {
383 return i.NextCommentEdit()
384 }
385 return true
386}
387
388// NextCommentEdit return true if there is a next comment edit and increments the index by one.
389// It is used iterates over all the comment edits. Extra queries are made if it is necessary.
390func (i *iterator) NextCommentEdit() bool {
391 if i.err != nil {
392 return false
393 }
394
395 if i.ctx.Err() != nil {
396 return false
397 }
398
399 // same as NextIssueEdit
400 if i.timeline.commentEdit.index == -2 {
401
402 if i.commentEdit.index < len(i.commentEdit.query.Repository.Issues.Nodes[0].Timeline.Nodes[0].IssueComment.UserContentEdits.Nodes)-1 {
403 i.commentEdit.index++
404 return i.nextValidCommentEdit()
405 }
406
407 if !i.commentEdit.query.Repository.Issues.Nodes[0].Timeline.Nodes[0].IssueComment.UserContentEdits.PageInfo.HasPreviousPage {
408 i.timeline.commentEdit.index = -1
409 i.commentEdit.index = -1
410 return false
411 }
412
413 // if there is more comment edits, query them
414 i.commentEdit.variables["commentEditBefore"] = i.commentEdit.query.Repository.Issues.Nodes[0].Timeline.Nodes[0].IssueComment.UserContentEdits.PageInfo.StartCursor
415 return i.queryCommentEdit()
416 }
417
418 // if there is no comment edits
419 if len(i.timeline.query.Repository.Issues.Nodes[0].Timeline.Edges[i.timeline.index].Node.IssueComment.UserContentEdits.Nodes) == 0 {
420 return false
421 }
422
423 // loop over them timeline comment edits
424 if i.timeline.commentEdit.index < len(i.timeline.query.Repository.Issues.Nodes[0].Timeline.Edges[i.timeline.index].Node.IssueComment.UserContentEdits.Nodes)-1 {
425 i.timeline.commentEdit.index++
426 return i.nextValidCommentEdit()
427 }
428
429 if !i.timeline.query.Repository.Issues.Nodes[0].Timeline.Edges[i.timeline.index].Node.IssueComment.UserContentEdits.PageInfo.HasPreviousPage {
430 i.timeline.commentEdit.index = -1
431 return false
432 }
433
434 i.initCommentEditQueryVariables()
435 if i.timeline.index == 0 {
436 i.commentEdit.variables["timelineAfter"] = i.timeline.lastEndCursor
437 } else {
438 i.commentEdit.variables["timelineAfter"] = i.timeline.query.Repository.Issues.Nodes[0].Timeline.Edges[i.timeline.index-1].Cursor
439 }
440
441 i.commentEdit.variables["commentEditBefore"] = i.timeline.query.Repository.Issues.Nodes[0].Timeline.Edges[i.timeline.index].Node.IssueComment.UserContentEdits.PageInfo.StartCursor
442
443 return i.queryCommentEdit()
444}
445
446// CommentEditValue return the actual comment edit value
447func (i *iterator) CommentEditValue() userContentEdit {
448 if i.timeline.commentEdit.index == -2 {
449 return i.commentEdit.query.Repository.Issues.Nodes[0].Timeline.Nodes[0].IssueComment.UserContentEdits.Nodes[i.commentEdit.index]
450 }
451
452 return i.timeline.query.Repository.Issues.Nodes[0].Timeline.Edges[i.timeline.index].Node.IssueComment.UserContentEdits.Nodes[i.timeline.commentEdit.index]
453}
454
455func reverseEdits(edits []userContentEdit) {
456 for i, j := 0, len(edits)-1; i < j; i, j = i+1, j-1 {
457 edits[i], edits[j] = edits[j], edits[i]
458 }
459}