iterator.go

  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}