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