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	// 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}