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