1package github
2
3import (
4 "context"
5 "time"
6
7 "github.com/pkg/errors"
8 "github.com/shurcooL/githubv4"
9)
10
11type iterator struct {
12 // Github graphql client
13 gc *githubv4.Client
14
15 // The iterator will only query issues updated or created after the date given in
16 // the variable since.
17 since time.Time
18
19 // Shared context, which is used for all graphql queries.
20 ctx context.Context
21
22 // Sticky error
23 err error
24
25 // Issue iterator
26 issueIter issueIter
27}
28
29type issueIter struct {
30 iterVars
31 query issueQuery
32 issueEditIter []issueEditIter
33 timelineIter []timelineIter
34}
35
36type issueEditIter struct {
37 iterVars
38 query issueEditQuery
39}
40
41type timelineIter struct {
42 iterVars
43 query timelineQuery
44 commentEditIter []commentEditIter
45}
46
47type commentEditIter struct {
48 iterVars
49 query commentEditQuery
50}
51
52type iterVars struct {
53 // Iterator index
54 index int
55
56 // capacity is the number of elements (issues, issue edits, timeline items, or
57 // comment edits) to query at a time. More capacity = more used memory =
58 // less queries to make.
59 capacity int
60
61 // Variable assignments for graphql query
62 variables varmap
63}
64
65type varmap map[string]interface{}
66
67func newIterVars(capacity int) iterVars {
68 return iterVars{
69 index: -1,
70 capacity: capacity,
71 variables: varmap{},
72 }
73}
74
75// NewIterator creates and initialize a new iterator.
76func NewIterator(ctx context.Context, client *githubv4.Client, capacity int, owner, project string, since time.Time) *iterator {
77 i := &iterator{
78 gc: client,
79 since: since,
80 ctx: ctx,
81 issueIter: issueIter{
82 iterVars: newIterVars(capacity),
83 timelineIter: make([]timelineIter, capacity),
84 issueEditIter: make([]issueEditIter, capacity),
85 },
86 }
87 i.issueIter.variables.setOwnerProject(owner, project)
88 for idx := range i.issueIter.issueEditIter {
89 ie := &i.issueIter.issueEditIter[idx]
90 ie.iterVars = newIterVars(capacity)
91 }
92 for i1 := range i.issueIter.timelineIter {
93 tli := &i.issueIter.timelineIter[i1]
94 tli.iterVars = newIterVars(capacity)
95 tli.commentEditIter = make([]commentEditIter, capacity)
96 for i2 := range tli.commentEditIter {
97 cei := &tli.commentEditIter[i2]
98 cei.iterVars = newIterVars(capacity)
99 }
100 }
101 i.resetIssueVars()
102 return i
103}
104
105func (v *varmap) setOwnerProject(owner, project string) {
106 (*v)["owner"] = githubv4.String(owner)
107 (*v)["name"] = githubv4.String(project)
108}
109
110func (i *iterator) resetIssueVars() {
111 vars := &i.issueIter.variables
112 (*vars)["issueFirst"] = githubv4.Int(i.issueIter.capacity)
113 (*vars)["issueAfter"] = (*githubv4.String)(nil)
114 (*vars)["issueSince"] = githubv4.DateTime{Time: i.since}
115 i.issueIter.query.Repository.Issues.PageInfo.HasNextPage = true
116 i.issueIter.query.Repository.Issues.PageInfo.EndCursor = ""
117}
118
119func (i *iterator) resetIssueEditVars() {
120 for idx := range i.issueIter.issueEditIter {
121 ie := &i.issueIter.issueEditIter[idx]
122 ie.variables["issueEditLast"] = githubv4.Int(ie.capacity)
123 ie.variables["issueEditBefore"] = (*githubv4.String)(nil)
124 ie.query.Node.Issue.UserContentEdits.PageInfo.HasNextPage = true
125 ie.query.Node.Issue.UserContentEdits.PageInfo.EndCursor = ""
126 }
127}
128
129func (i *iterator) resetTimelineVars() {
130 for idx := range i.issueIter.timelineIter {
131 ip := &i.issueIter.timelineIter[idx]
132 ip.variables["timelineFirst"] = githubv4.Int(ip.capacity)
133 ip.variables["timelineAfter"] = (*githubv4.String)(nil)
134 ip.query.Node.Issue.TimelineItems.PageInfo.HasNextPage = true
135 ip.query.Node.Issue.TimelineItems.PageInfo.EndCursor = ""
136 }
137}
138
139func (i *iterator) resetCommentEditVars() {
140 for i1 := range i.issueIter.timelineIter {
141 for i2 := range i.issueIter.timelineIter[i1].commentEditIter {
142 ce := &i.issueIter.timelineIter[i1].commentEditIter[i2]
143 ce.variables["commentEditLast"] = githubv4.Int(ce.capacity)
144 ce.variables["commentEditBefore"] = (*githubv4.String)(nil)
145 ce.query.Node.IssueComment.UserContentEdits.PageInfo.HasNextPage = true
146 ce.query.Node.IssueComment.UserContentEdits.PageInfo.EndCursor = ""
147 }
148 }
149}
150
151// Error return last encountered error
152func (i *iterator) Error() error {
153 if i.err != nil {
154 return i.err
155 }
156 return i.ctx.Err() // might return nil
157}
158
159func (i *iterator) HasError() bool {
160 return i.err != nil || i.ctx.Err() != nil
161}
162
163func (i *iterator) currIssueItem() *issue {
164 return &i.issueIter.query.Repository.Issues.Nodes[i.issueIter.index]
165}
166
167func (i *iterator) currIssueEditIter() *issueEditIter {
168 return &i.issueIter.issueEditIter[i.issueIter.index]
169}
170
171func (i *iterator) currTimelineIter() *timelineIter {
172 return &i.issueIter.timelineIter[i.issueIter.index]
173}
174
175func (i *iterator) currCommentEditIter() *commentEditIter {
176 timelineIter := i.currTimelineIter()
177 return &timelineIter.commentEditIter[timelineIter.index]
178}
179
180func (i *iterator) currIssueGqlNodeId() githubv4.ID {
181 return i.currIssueItem().Id
182}
183
184// NextIssue returns true if there exists a next issue and advances the iterator by one.
185// It is used to iterate over all issues. Queries to github are made when necessary.
186func (i *iterator) NextIssue() bool {
187 if i.HasError() {
188 return false
189 }
190 index := &i.issueIter.index
191 issues := &i.issueIter.query.Repository.Issues
192 issueItems := &issues.Nodes
193 if 0 <= *index && *index < len(*issueItems)-1 {
194 *index += 1
195 return true
196 }
197
198 if !issues.PageInfo.HasNextPage {
199 return false
200 }
201 nextIssue := i.queryIssue()
202 return nextIssue
203}
204
205// IssueValue returns the actual issue value.
206func (i *iterator) IssueValue() issue {
207 return *i.currIssueItem()
208}
209
210func (i *iterator) queryIssue() bool {
211 ctx, cancel := context.WithTimeout(i.ctx, defaultTimeout)
212 defer cancel()
213 if endCursor := i.issueIter.query.Repository.Issues.PageInfo.EndCursor; endCursor != "" {
214 i.issueIter.variables["issueAfter"] = endCursor
215 }
216 if err := i.gc.Query(ctx, &i.issueIter.query, i.issueIter.variables); err != nil {
217 i.err = err
218 return false
219 }
220 i.resetIssueEditVars()
221 i.resetTimelineVars()
222 issueItems := &i.issueIter.query.Repository.Issues.Nodes
223 if len(*issueItems) <= 0 {
224 i.issueIter.index = -1
225 return false
226 }
227 i.issueIter.index = 0
228 return true
229}
230
231// NextIssueEdit returns true if there exists a next issue edit and advances the iterator
232// by one. It is used to iterate over all the issue edits. Queries to github are made when
233// necessary.
234func (i *iterator) NextIssueEdit() bool {
235 if i.HasError() {
236 return false
237 }
238 ieIter := i.currIssueEditIter()
239 ieIdx := &ieIter.index
240 ieItems := ieIter.query.Node.Issue.UserContentEdits
241 if 0 <= *ieIdx && *ieIdx < len(ieItems.Nodes)-1 {
242 *ieIdx += 1
243 return i.nextValidIssueEdit()
244 }
245 if !ieItems.PageInfo.HasNextPage {
246 return false
247 }
248 querySucc := i.queryIssueEdit()
249 if !querySucc {
250 return false
251 }
252 return i.nextValidIssueEdit()
253}
254
255func (i *iterator) nextValidIssueEdit() bool {
256 // issueEdit.Diff == nil happen if the event is older than early 2018, Github doesn't have
257 // the data before that. Best we can do is to ignore the event.
258 if issueEdit := i.IssueEditValue(); issueEdit.Diff == nil || string(*issueEdit.Diff) == "" {
259 return i.NextIssueEdit()
260 }
261 return true
262}
263
264// IssueEditValue returns the actual issue edit value.
265func (i *iterator) IssueEditValue() userContentEdit {
266 iei := i.currIssueEditIter()
267 return iei.query.Node.Issue.UserContentEdits.Nodes[iei.index]
268}
269
270func (i *iterator) queryIssueEdit() bool {
271 ctx, cancel := context.WithTimeout(i.ctx, defaultTimeout)
272 defer cancel()
273 iei := i.currIssueEditIter()
274 if endCursor := iei.query.Node.Issue.UserContentEdits.PageInfo.EndCursor; endCursor != "" {
275 iei.variables["issueEditBefore"] = endCursor
276 }
277 iei.variables["gqlNodeId"] = i.currIssueGqlNodeId()
278 if err := i.gc.Query(ctx, &iei.query, iei.variables); err != nil {
279 i.err = err
280 return false
281 }
282 issueEditItems := iei.query.Node.Issue.UserContentEdits.Nodes
283 if len(issueEditItems) <= 0 {
284 iei.index = -1
285 return false
286 }
287 // The UserContentEditConnection in the Github API serves its elements in reverse chronological
288 // order. For our purpose we have to reverse the edits.
289 reverseEdits(issueEditItems)
290 iei.index = 0
291 return true
292}
293
294// NextTimelineItem returns true if there exists a next timeline item and advances the iterator
295// by one. It is used to iterate over all the timeline items. Queries to github are made when
296// necessary.
297func (i *iterator) NextTimelineItem() bool {
298 if i.HasError() {
299 return false
300 }
301 tlIter := &i.issueIter.timelineIter[i.issueIter.index]
302 tlIdx := &tlIter.index
303 tlItems := tlIter.query.Node.Issue.TimelineItems
304 if 0 <= *tlIdx && *tlIdx < len(tlItems.Nodes)-1 {
305 *tlIdx += 1
306 return true
307 }
308 if !tlItems.PageInfo.HasNextPage {
309 return false
310 }
311 nextTlItem := i.queryTimeline()
312 return nextTlItem
313}
314
315// TimelineItemValue returns the actual timeline item value.
316func (i *iterator) TimelineItemValue() timelineItem {
317 tli := i.currTimelineIter()
318 return tli.query.Node.Issue.TimelineItems.Nodes[tli.index]
319}
320
321func (i *iterator) queryTimeline() bool {
322 ctx, cancel := context.WithTimeout(i.ctx, defaultTimeout)
323 defer cancel()
324 tli := i.currTimelineIter()
325 if endCursor := tli.query.Node.Issue.TimelineItems.PageInfo.EndCursor; endCursor != "" {
326 tli.variables["timelineAfter"] = endCursor
327 }
328 tli.variables["gqlNodeId"] = i.currIssueGqlNodeId()
329 if err := i.gc.Query(ctx, &tli.query, tli.variables); err != nil {
330 i.err = err
331 return false
332 }
333 i.resetCommentEditVars()
334 timelineItems := &tli.query.Node.Issue.TimelineItems
335 if len(timelineItems.Nodes) <= 0 {
336 tli.index = -1
337 return false
338 }
339 tli.index = 0
340 return true
341}
342
343// NextCommentEdit returns true if there exists a next comment edit and advances the iterator
344// by one. It is used to iterate over all issue edits. Queries to github are made when
345// necessary.
346func (i *iterator) NextCommentEdit() bool {
347 if i.HasError() {
348 return false
349 }
350
351 tmlnVal := i.TimelineItemValue()
352 if tmlnVal.Typename != "IssueComment" {
353 // The timeline iterator does not point to a comment.
354 i.err = errors.New("Call to NextCommentEdit() while timeline item is not a comment")
355 return false
356 }
357
358 cei := i.currCommentEditIter()
359 ceIdx := &cei.index
360 ceItems := &cei.query.Node.IssueComment.UserContentEdits
361 if 0 <= *ceIdx && *ceIdx < len(ceItems.Nodes)-1 {
362 *ceIdx += 1
363 return i.nextValidCommentEdit()
364 }
365 if !ceItems.PageInfo.HasNextPage {
366 return false
367 }
368 querySucc := i.queryCommentEdit()
369 if !querySucc {
370 return false
371 }
372 return i.nextValidCommentEdit()
373}
374
375func (i *iterator) nextValidCommentEdit() bool {
376 // if comment edit diff is a nil pointer or points to an empty string look for next value
377 if commentEdit := i.CommentEditValue(); commentEdit.Diff == nil || string(*commentEdit.Diff) == "" {
378 return i.NextCommentEdit()
379 }
380 return true
381}
382
383// CommentEditValue returns the actual comment edit value.
384func (i *iterator) CommentEditValue() userContentEdit {
385 cei := i.currCommentEditIter()
386 return cei.query.Node.IssueComment.UserContentEdits.Nodes[cei.index]
387}
388
389func (i *iterator) queryCommentEdit() bool {
390 ctx, cancel := context.WithTimeout(i.ctx, defaultTimeout)
391 defer cancel()
392 cei := i.currCommentEditIter()
393
394 if endCursor := cei.query.Node.IssueComment.UserContentEdits.PageInfo.EndCursor; endCursor != "" {
395 cei.variables["commentEditBefore"] = endCursor
396 }
397 tmlnVal := i.TimelineItemValue()
398 if tmlnVal.Typename != "IssueComment" {
399 i.err = errors.New("Call to queryCommentEdit() while timeline item is not a comment")
400 return false
401 }
402 cei.variables["gqlNodeId"] = tmlnVal.IssueComment.Id
403 if err := i.gc.Query(ctx, &cei.query, cei.variables); err != nil {
404 i.err = err
405 return false
406 }
407 ceItems := cei.query.Node.IssueComment.UserContentEdits.Nodes
408 if len(ceItems) <= 0 {
409 cei.index = -1
410 return false
411 }
412 // The UserContentEditConnection in the Github API serves its elements in reverse chronological
413 // order. For our purpose we have to reverse the edits.
414 reverseEdits(ceItems)
415 cei.index = 0
416 return true
417}
418
419func reverseEdits(edits []userContentEdit) {
420 for i, j := 0, len(edits)-1; i < j; i, j = i+1, j-1 {
421 edits[i], edits[j] = edits[j], edits[i]
422 }
423}