gitlab: Add new iterator with state change events

Retrieving events is spread across various various Gitlab APIs. This
makes importing and sorting Gitlab events by time quite complicated.

This commit replaces the old iterators with a goroutine/channel-based
iterator, which merges the individual Gitlab API streams into a single
(sorted) event stream.
This commit is contained in:
Matthias Simon 2021-02-14 20:35:03 +01:00
parent a8f3b55986
commit aa4e225a80
8 changed files with 426 additions and 686 deletions

184
bridge/gitlab/event.go Normal file
View File

@ -0,0 +1,184 @@
package gitlab
import (
"fmt"
"sort"
"strings"
"time"
"github.com/MichaelMure/git-bug/util/text"
"github.com/xanzy/go-gitlab"
)
type EventKind int
const (
EventUnknown EventKind = iota
EventError
EventComment
EventTitleChanged
EventDescriptionChanged
EventClosed
EventReopened
EventLocked
EventUnlocked
EventChangedDuedate
EventRemovedDuedate
EventAssigned
EventUnassigned
EventChangedMilestone
EventRemovedMilestone
EventAddLabel
EventRemoveLabel
EventMentionedInIssue
EventMentionedInMergeRequest
)
type Event interface {
ID() string
UserID() int
Kind() EventKind
CreatedAt() time.Time
}
type ErrorEvent struct {
Err error
Time time.Time
}
func (e ErrorEvent) ID() string { return "" }
func (e ErrorEvent) UserID() int { return -1 }
func (e ErrorEvent) CreatedAt() time.Time { return e.Time }
func (e ErrorEvent) Kind() EventKind { return EventError }
type NoteEvent struct{ gitlab.Note }
func (n NoteEvent) ID() string { return fmt.Sprintf("%d", n.Note.ID) }
func (n NoteEvent) UserID() int { return n.Author.ID }
func (n NoteEvent) CreatedAt() time.Time { return *n.Note.CreatedAt }
func (n NoteEvent) Kind() EventKind {
switch {
case !n.System:
return EventComment
case n.Body == "closed":
return EventClosed
case n.Body == "reopened":
return EventReopened
case n.Body == "changed the description":
return EventDescriptionChanged
case n.Body == "locked this issue":
return EventLocked
case n.Body == "unlocked this issue":
return EventUnlocked
case strings.HasPrefix(n.Body, "changed title from"):
return EventTitleChanged
case strings.HasPrefix(n.Body, "changed due date to"):
return EventChangedDuedate
case n.Body == "removed due date":
return EventRemovedDuedate
case strings.HasPrefix(n.Body, "assigned to @"):
return EventAssigned
case strings.HasPrefix(n.Body, "unassigned @"):
return EventUnassigned
case strings.HasPrefix(n.Body, "changed milestone to %"):
return EventChangedMilestone
case strings.HasPrefix(n.Body, "removed milestone"):
return EventRemovedMilestone
case strings.HasPrefix(n.Body, "mentioned in issue"):
return EventMentionedInIssue
case strings.HasPrefix(n.Body, "mentioned in merge request"):
return EventMentionedInMergeRequest
default:
return EventUnknown
}
}
func (n NoteEvent) Title() string {
if n.Kind() == EventTitleChanged {
return getNewTitle(n.Body)
}
return text.CleanupOneLine(n.Body)
}
type LabelEvent struct{ gitlab.LabelEvent }
func (l LabelEvent) ID() string { return fmt.Sprintf("%d", l.LabelEvent.ID) }
func (l LabelEvent) UserID() int { return l.User.ID }
func (l LabelEvent) CreatedAt() time.Time { return *l.LabelEvent.CreatedAt }
func (l LabelEvent) Kind() EventKind {
switch l.Action {
case "add":
return EventAddLabel
case "remove":
return EventRemoveLabel
default:
return EventUnknown
}
}
type StateEvent struct{ gitlab.StateEvent }
func (s StateEvent) ID() string { return fmt.Sprintf("%d", s.StateEvent.ID) }
func (s StateEvent) UserID() int { return s.User.ID }
func (s StateEvent) CreatedAt() time.Time { return *s.StateEvent.CreatedAt }
func (s StateEvent) Kind() EventKind {
switch s.State {
case "closed":
return EventClosed
case "opened", "reopened":
return EventReopened
default:
return EventUnknown
}
}
func SortedEvents(c <-chan Event) []Event {
var events []Event
for e := range c {
events = append(events, e)
}
sort.Sort(eventsByCreation(events))
return events
}
type eventsByCreation []Event
func (e eventsByCreation) Len() int {
return len(e)
}
func (e eventsByCreation) Less(i, j int) bool {
return e[i].CreatedAt().Before(e[j].CreatedAt())
}
func (e eventsByCreation) Swap(i, j int) {
e[i], e[j] = e[j], e[i]
}
// getNewTitle parses body diff given by gitlab api and return it final form
// examples: "changed title from **fourth issue** to **fourth issue{+ changed+}**"
// "changed title from **fourth issue{- changed-}** to **fourth issue**"
// because Gitlab
func getNewTitle(diff string) string {
newTitle := strings.Split(diff, "** to **")[1]
newTitle = strings.Replace(newTitle, "{+", "", -1)
newTitle = strings.Replace(newTitle, "+}", "", -1)
return strings.TrimSuffix(newTitle, "**")
}

171
bridge/gitlab/gitlab_api.go Normal file
View File

@ -0,0 +1,171 @@
package gitlab
import (
"context"
"sync"
"time"
"github.com/MichaelMure/git-bug/util/text"
"github.com/xanzy/go-gitlab"
)
func Issues(ctx context.Context, client *gitlab.Client, pid string, since time.Time) <-chan *gitlab.Issue {
out := make(chan *gitlab.Issue)
go func() {
defer close(out)
opts := gitlab.ListProjectIssuesOptions{
UpdatedAfter: &since,
Scope: gitlab.String("all"),
Sort: gitlab.String("asc"),
}
for {
issues, resp, err := client.Issues.ListProjectIssues(pid, &opts)
if err != nil {
return
}
for _, issue := range issues {
out <- issue
}
if resp.CurrentPage >= resp.TotalPages {
break
}
opts.Page = resp.NextPage
}
}()
return out
}
func IssueEvents(ctx context.Context, client *gitlab.Client, issue *gitlab.Issue) <-chan Event {
cs := []<-chan Event{
Notes(ctx, client, issue),
LabelEvents(ctx, client, issue),
StateEvents(ctx, client, issue),
}
var wg sync.WaitGroup
out := make(chan Event)
output := func(c <-chan Event) {
for n := range c {
out <- n
}
wg.Done()
}
wg.Add(len(cs))
for _, c := range cs {
go output(c)
}
go func() {
wg.Wait()
close(out)
}()
return out
}
func Notes(ctx context.Context, client *gitlab.Client, issue *gitlab.Issue) <-chan Event {
out := make(chan Event)
go func() {
defer close(out)
opts := gitlab.ListIssueNotesOptions{
OrderBy: gitlab.String("created_at"),
Sort: gitlab.String("asc"),
}
for {
notes, resp, err := client.Notes.ListIssueNotes(issue.ProjectID, issue.IID, &opts)
if err != nil {
out <- ErrorEvent{Err: err, Time: time.Now()}
}
for _, note := range notes {
out <- NoteEvent{*note}
}
if resp.CurrentPage >= resp.TotalPages {
break
}
opts.Page = resp.NextPage
}
}()
return out
}
func LabelEvents(ctx context.Context, client *gitlab.Client, issue *gitlab.Issue) <-chan Event {
out := make(chan Event)
go func() {
defer close(out)
opts := gitlab.ListLabelEventsOptions{}
for {
events, resp, err := client.ResourceLabelEvents.ListIssueLabelEvents(issue.ProjectID, issue.IID, &opts)
if err != nil {
out <- ErrorEvent{Err: err, Time: time.Now()}
}
for _, e := range events {
le := LabelEvent{*e}
le.Label.Name = text.CleanupOneLine(le.Label.Name)
out <- le
}
if resp.CurrentPage >= resp.TotalPages {
break
}
opts.Page = resp.NextPage
}
}()
return out
}
func StateEvents(ctx context.Context, client *gitlab.Client, issue *gitlab.Issue) <-chan Event {
out := make(chan Event)
go func() {
defer close(out)
opts := gitlab.ListStateEventsOptions{}
for {
events, resp, err := client.ResourceStateEvents.ListIssueStateEvents(issue.ProjectID, issue.IID, &opts)
if err != nil {
out <- ErrorEvent{Err: err, Time: time.Now()}
}
for _, e := range events {
out <- StateEvent{*e}
}
if resp.CurrentPage >= resp.TotalPages {
break
}
opts.Page = resp.NextPage
}
}()
return out
}

View File

@ -10,7 +10,6 @@ import (
"github.com/MichaelMure/git-bug/bridge/core" "github.com/MichaelMure/git-bug/bridge/core"
"github.com/MichaelMure/git-bug/bridge/core/auth" "github.com/MichaelMure/git-bug/bridge/core/auth"
"github.com/MichaelMure/git-bug/bridge/gitlab/iterator"
"github.com/MichaelMure/git-bug/bug" "github.com/MichaelMure/git-bug/bug"
"github.com/MichaelMure/git-bug/cache" "github.com/MichaelMure/git-bug/cache"
"github.com/MichaelMure/git-bug/entity" "github.com/MichaelMure/git-bug/entity"
@ -24,9 +23,6 @@ type gitlabImporter struct {
// default client // default client
client *gitlab.Client client *gitlab.Client
// iterator
iterator *iterator.Iterator
// send only channel // send only channel
out chan<- core.ImportResult out chan<- core.ImportResult
} }
@ -59,18 +55,15 @@ func (gi *gitlabImporter) Init(_ context.Context, repo *cache.RepoCache, conf co
// ImportAll iterate over all the configured repository issues (notes) and ensure the creation // ImportAll iterate over all the configured repository issues (notes) and ensure the creation
// of the missing issues / comments / label events / title changes ... // of the missing issues / comments / label events / title changes ...
func (gi *gitlabImporter) ImportAll(ctx context.Context, repo *cache.RepoCache, since time.Time) (<-chan core.ImportResult, error) { func (gi *gitlabImporter) ImportAll(ctx context.Context, repo *cache.RepoCache, since time.Time) (<-chan core.ImportResult, error) {
gi.iterator = iterator.NewIterator(ctx, gi.client, 10, gi.conf[confKeyProjectID], since)
out := make(chan core.ImportResult) out := make(chan core.ImportResult)
gi.out = out gi.out = out
go func() { go func() {
defer close(gi.out) defer close(out)
// Loop over all matching issues for issue := range Issues(ctx, gi.client, gi.conf[confKeyProjectID], since) {
for gi.iterator.NextIssue() {
issue := gi.iterator.IssueValue()
// create issue
b, err := gi.ensureIssue(repo, issue) b, err := gi.ensureIssue(repo, issue)
if err != nil { if err != nil {
err := fmt.Errorf("issue creation: %v", err) err := fmt.Errorf("issue creation: %v", err)
@ -78,23 +71,14 @@ func (gi *gitlabImporter) ImportAll(ctx context.Context, repo *cache.RepoCache,
return return
} }
// Loop over all notes for _, e := range SortedEvents(IssueEvents(ctx, gi.client, issue)) {
for gi.iterator.NextNote() { if e, ok := e.(ErrorEvent); ok {
note := gi.iterator.NoteValue() out <- core.NewImportError(e.Err, "")
if err := gi.ensureNote(repo, b, note); err != nil { continue
err := fmt.Errorf("note creation: %v", err)
out <- core.NewImportError(err, entity.Id(strconv.Itoa(note.ID)))
return
} }
} if err := gi.ensureIssueEvent(repo, b, issue, e); err != nil {
err := fmt.Errorf("issue event creation: %v", err)
// Loop over all label events out <- core.NewImportError(err, entity.Id(e.ID()))
for gi.iterator.NextLabelEvent() {
labelEvent := gi.iterator.LabelEventValue()
if err := gi.ensureLabelEvent(repo, b, labelEvent); err != nil {
err := fmt.Errorf("label event creation: %v", err)
out <- core.NewImportError(err, entity.Id(strconv.Itoa(labelEvent.ID)))
return
} }
} }
@ -107,10 +91,6 @@ func (gi *gitlabImporter) ImportAll(ctx context.Context, repo *cache.RepoCache,
return return
} }
} }
if err := gi.iterator.Error(); err != nil {
out <- core.NewImportError(err, "")
}
}() }()
return out, nil return out, nil
@ -126,7 +106,7 @@ func (gi *gitlabImporter) ensureIssue(repo *cache.RepoCache, issue *gitlab.Issue
// resolve bug // resolve bug
b, err := repo.ResolveBugMatcher(func(excerpt *cache.BugExcerpt) bool { b, err := repo.ResolveBugMatcher(func(excerpt *cache.BugExcerpt) bool {
return excerpt.CreateMetadata[core.MetaKeyOrigin] == target && return excerpt.CreateMetadata[core.MetaKeyOrigin] == target &&
excerpt.CreateMetadata[metaKeyGitlabId] == parseID(issue.IID) && excerpt.CreateMetadata[metaKeyGitlabId] == fmt.Sprintf("%d", issue.IID) &&
excerpt.CreateMetadata[metaKeyGitlabBaseUrl] == gi.conf[confKeyGitlabBaseUrl] && excerpt.CreateMetadata[metaKeyGitlabBaseUrl] == gi.conf[confKeyGitlabBaseUrl] &&
excerpt.CreateMetadata[metaKeyGitlabProject] == gi.conf[confKeyProjectID] excerpt.CreateMetadata[metaKeyGitlabProject] == gi.conf[confKeyProjectID]
}) })
@ -146,7 +126,7 @@ func (gi *gitlabImporter) ensureIssue(repo *cache.RepoCache, issue *gitlab.Issue
nil, nil,
map[string]string{ map[string]string{
core.MetaKeyOrigin: target, core.MetaKeyOrigin: target,
metaKeyGitlabId: parseID(issue.IID), metaKeyGitlabId: fmt.Sprintf("%d", issue.IID),
metaKeyGitlabUrl: issue.WebURL, metaKeyGitlabUrl: issue.WebURL,
metaKeyGitlabProject: gi.conf[confKeyProjectID], metaKeyGitlabProject: gi.conf[confKeyProjectID],
metaKeyGitlabBaseUrl: gi.conf[confKeyGitlabBaseUrl], metaKeyGitlabBaseUrl: gi.conf[confKeyGitlabBaseUrl],
@ -163,50 +143,49 @@ func (gi *gitlabImporter) ensureIssue(repo *cache.RepoCache, issue *gitlab.Issue
return b, nil return b, nil
} }
func (gi *gitlabImporter) ensureNote(repo *cache.RepoCache, b *cache.BugCache, note *gitlab.Note) error { func (gi *gitlabImporter) ensureIssueEvent(repo *cache.RepoCache, b *cache.BugCache, issue *gitlab.Issue, event Event) error {
gitlabID := parseID(note.ID)
id, errResolve := b.ResolveOperationWithMetadata(metaKeyGitlabId, gitlabID) id, errResolve := b.ResolveOperationWithMetadata(metaKeyGitlabId, event.ID())
if errResolve != nil && errResolve != cache.ErrNoMatchingOp { if errResolve != nil && errResolve != cache.ErrNoMatchingOp {
return errResolve return errResolve
} }
// ensure issue author // ensure issue author
author, err := gi.ensurePerson(repo, note.Author.ID) author, err := gi.ensurePerson(repo, event.UserID())
if err != nil { if err != nil {
return err return err
} }
noteType, body := GetNoteType(note) switch event.Kind() {
switch noteType { case EventClosed:
case NOTE_CLOSED:
if errResolve == nil { if errResolve == nil {
return nil return nil
} }
op, err := b.CloseRaw( op, err := b.CloseRaw(
author, author,
note.CreatedAt.Unix(), event.CreatedAt().Unix(),
map[string]string{ map[string]string{
metaKeyGitlabId: gitlabID, metaKeyGitlabId: event.ID(),
}, },
) )
if err != nil { if err != nil {
return err return err
} }
gi.out <- core.NewImportStatusChange(op.Id()) gi.out <- core.NewImportStatusChange(op.Id())
case NOTE_REOPENED: case EventReopened:
if errResolve == nil { if errResolve == nil {
return nil return nil
} }
op, err := b.OpenRaw( op, err := b.OpenRaw(
author, author,
note.CreatedAt.Unix(), event.CreatedAt().Unix(),
map[string]string{ map[string]string{
metaKeyGitlabId: gitlabID, metaKeyGitlabId: event.ID(),
}, },
) )
if err != nil { if err != nil {
@ -215,9 +194,7 @@ func (gi *gitlabImporter) ensureNote(repo *cache.RepoCache, b *cache.BugCache, n
gi.out <- core.NewImportStatusChange(op.Id()) gi.out <- core.NewImportStatusChange(op.Id())
case NOTE_DESCRIPTION_CHANGED: case EventDescriptionChanged:
issue := gi.iterator.IssueValue()
firstComment := b.Snapshot().Comments[0] firstComment := b.Snapshot().Comments[0]
// since gitlab doesn't provide the issue history // since gitlab doesn't provide the issue history
// we should check for "changed the description" notes and compare issue texts // we should check for "changed the description" notes and compare issue texts
@ -226,11 +203,11 @@ func (gi *gitlabImporter) ensureNote(repo *cache.RepoCache, b *cache.BugCache, n
// comment edition // comment edition
op, err := b.EditCommentRaw( op, err := b.EditCommentRaw(
author, author,
note.UpdatedAt.Unix(), event.(NoteEvent).UpdatedAt.Unix(),
firstComment.Id(), firstComment.Id(),
text.Cleanup(issue.Description), text.Cleanup(issue.Description),
map[string]string{ map[string]string{
metaKeyGitlabId: gitlabID, metaKeyGitlabId: event.ID(),
}, },
) )
if err != nil { if err != nil {
@ -240,8 +217,8 @@ func (gi *gitlabImporter) ensureNote(repo *cache.RepoCache, b *cache.BugCache, n
gi.out <- core.NewImportTitleEdition(op.Id()) gi.out <- core.NewImportTitleEdition(op.Id())
} }
case NOTE_COMMENT: case EventComment:
cleanText := text.Cleanup(body) cleanText := text.Cleanup(event.(NoteEvent).Body)
// if we didn't import the comment // if we didn't import the comment
if errResolve == cache.ErrNoMatchingOp { if errResolve == cache.ErrNoMatchingOp {
@ -249,11 +226,11 @@ func (gi *gitlabImporter) ensureNote(repo *cache.RepoCache, b *cache.BugCache, n
// add comment operation // add comment operation
op, err := b.AddCommentRaw( op, err := b.AddCommentRaw(
author, author,
note.CreatedAt.Unix(), event.CreatedAt().Unix(),
cleanText, cleanText,
nil, nil,
map[string]string{ map[string]string{
metaKeyGitlabId: gitlabID, metaKeyGitlabId: event.ID(),
}, },
) )
if err != nil { if err != nil {
@ -271,12 +248,12 @@ func (gi *gitlabImporter) ensureNote(repo *cache.RepoCache, b *cache.BugCache, n
return err return err
} }
// compare local bug comment with the new note body // compare local bug comment with the new event body
if comment.Message != cleanText { if comment.Message != cleanText {
// comment edition // comment edition
op, err := b.EditCommentRaw( op, err := b.EditCommentRaw(
author, author,
note.UpdatedAt.Unix(), event.(NoteEvent).UpdatedAt.Unix(),
comment.Id(), comment.Id(),
cleanText, cleanText,
nil, nil,
@ -290,7 +267,7 @@ func (gi *gitlabImporter) ensureNote(repo *cache.RepoCache, b *cache.BugCache, n
return nil return nil
case NOTE_TITLE_CHANGED: case EventTitleChanged:
// title change events are given new notes // title change events are given new notes
if errResolve == nil { if errResolve == nil {
return nil return nil
@ -298,10 +275,10 @@ func (gi *gitlabImporter) ensureNote(repo *cache.RepoCache, b *cache.BugCache, n
op, err := b.SetTitleRaw( op, err := b.SetTitleRaw(
author, author,
note.CreatedAt.Unix(), event.CreatedAt().Unix(),
text.CleanupOneLine(body), event.(NoteEvent).Title(),
map[string]string{ map[string]string{
metaKeyGitlabId: gitlabID, metaKeyGitlabId: event.ID(),
}, },
) )
if err != nil { if err != nil {
@ -310,69 +287,50 @@ func (gi *gitlabImporter) ensureNote(repo *cache.RepoCache, b *cache.BugCache, n
gi.out <- core.NewImportTitleEdition(op.Id()) gi.out <- core.NewImportTitleEdition(op.Id())
case NOTE_UNKNOWN, case EventAddLabel:
NOTE_ASSIGNED, _, err = b.ForceChangeLabelsRaw(
NOTE_UNASSIGNED, author,
NOTE_CHANGED_MILESTONE, event.CreatedAt().Unix(),
NOTE_REMOVED_MILESTONE, []string{event.(LabelEvent).Label.Name},
NOTE_CHANGED_DUEDATE, nil,
NOTE_REMOVED_DUEDATE, map[string]string{
NOTE_LOCKED, metaKeyGitlabId: event.ID(),
NOTE_UNLOCKED, },
NOTE_MENTIONED_IN_ISSUE, )
NOTE_MENTIONED_IN_MERGE_REQUEST: return err
case EventRemoveLabel:
_, err = b.ForceChangeLabelsRaw(
author,
event.CreatedAt().Unix(),
nil,
[]string{event.(LabelEvent).Label.Name},
map[string]string{
metaKeyGitlabId: event.ID(),
},
)
return err
case EventAssigned,
EventUnassigned,
EventChangedMilestone,
EventRemovedMilestone,
EventChangedDuedate,
EventRemovedDuedate,
EventLocked,
EventUnlocked,
EventMentionedInIssue,
EventMentionedInMergeRequest:
return nil return nil
default: default:
panic("unhandled note type") return fmt.Errorf("unexpected event")
} }
return nil return nil
} }
func (gi *gitlabImporter) ensureLabelEvent(repo *cache.RepoCache, b *cache.BugCache, labelEvent *gitlab.LabelEvent) error {
_, err := b.ResolveOperationWithMetadata(metaKeyGitlabId, parseID(labelEvent.ID))
if err != cache.ErrNoMatchingOp {
return err
}
// ensure issue author
author, err := gi.ensurePerson(repo, labelEvent.User.ID)
if err != nil {
return err
}
switch labelEvent.Action {
case "add":
_, err = b.ForceChangeLabelsRaw(
author,
labelEvent.CreatedAt.Unix(),
[]string{text.CleanupOneLine(labelEvent.Label.Name)},
nil,
map[string]string{
metaKeyGitlabId: parseID(labelEvent.ID),
},
)
case "remove":
_, err = b.ForceChangeLabelsRaw(
author,
labelEvent.CreatedAt.Unix(),
nil,
[]string{text.CleanupOneLine(labelEvent.Label.Name)},
map[string]string{
metaKeyGitlabId: parseID(labelEvent.ID),
},
)
default:
err = fmt.Errorf("unexpected label event action")
}
return err
}
func (gi *gitlabImporter) ensurePerson(repo *cache.RepoCache, id int) (*cache.IdentityCache, error) { func (gi *gitlabImporter) ensurePerson(repo *cache.RepoCache, id int) (*cache.IdentityCache, error) {
// Look first in the cache // Look first in the cache
i, err := repo.ResolveIdentityImmutableMetadata(metaKeyGitlabId, strconv.Itoa(id)) i, err := repo.ResolveIdentityImmutableMetadata(metaKeyGitlabId, strconv.Itoa(id))
@ -407,7 +365,3 @@ func (gi *gitlabImporter) ensurePerson(repo *cache.RepoCache, id int) (*cache.Id
gi.out <- core.NewImportIdentity(i.Id()) gi.out <- core.NewImportIdentity(i.Id())
return i, nil return i, nil
} }
func parseID(id int) string {
return fmt.Sprintf("%d", id)
}

View File

@ -1,147 +0,0 @@
package gitlab
import (
"strings"
"github.com/xanzy/go-gitlab"
)
type NoteType int
const (
_ NoteType = iota
NOTE_COMMENT
NOTE_TITLE_CHANGED
NOTE_DESCRIPTION_CHANGED
NOTE_CLOSED
NOTE_REOPENED
NOTE_LOCKED
NOTE_UNLOCKED
NOTE_CHANGED_DUEDATE
NOTE_REMOVED_DUEDATE
NOTE_ASSIGNED
NOTE_UNASSIGNED
NOTE_CHANGED_MILESTONE
NOTE_REMOVED_MILESTONE
NOTE_MENTIONED_IN_ISSUE
NOTE_MENTIONED_IN_MERGE_REQUEST
NOTE_UNKNOWN
)
func (nt NoteType) String() string {
switch nt {
case NOTE_COMMENT:
return "note comment"
case NOTE_TITLE_CHANGED:
return "note title changed"
case NOTE_DESCRIPTION_CHANGED:
return "note description changed"
case NOTE_CLOSED:
return "note closed"
case NOTE_REOPENED:
return "note reopened"
case NOTE_LOCKED:
return "note locked"
case NOTE_UNLOCKED:
return "note unlocked"
case NOTE_CHANGED_DUEDATE:
return "note changed duedate"
case NOTE_REMOVED_DUEDATE:
return "note remove duedate"
case NOTE_ASSIGNED:
return "note assigned"
case NOTE_UNASSIGNED:
return "note unassigned"
case NOTE_CHANGED_MILESTONE:
return "note changed milestone"
case NOTE_REMOVED_MILESTONE:
return "note removed in milestone"
case NOTE_MENTIONED_IN_ISSUE:
return "note mentioned in issue"
case NOTE_MENTIONED_IN_MERGE_REQUEST:
return "note mentioned in merge request"
case NOTE_UNKNOWN:
return "note unknown"
default:
panic("unknown note type")
}
}
// GetNoteType parse a note system and body and return the note type and it content
func GetNoteType(n *gitlab.Note) (NoteType, string) {
// when a note is a comment system is set to false
// when a note is a different event system is set to true
// because Gitlab
if !n.System {
return NOTE_COMMENT, n.Body
}
if n.Body == "closed" {
return NOTE_CLOSED, ""
}
if n.Body == "reopened" {
return NOTE_REOPENED, ""
}
if n.Body == "changed the description" {
return NOTE_DESCRIPTION_CHANGED, ""
}
if n.Body == "locked this issue" {
return NOTE_LOCKED, ""
}
if n.Body == "unlocked this issue" {
return NOTE_UNLOCKED, ""
}
if strings.HasPrefix(n.Body, "changed title from") {
return NOTE_TITLE_CHANGED, getNewTitle(n.Body)
}
if strings.HasPrefix(n.Body, "changed due date to") {
return NOTE_CHANGED_DUEDATE, ""
}
if n.Body == "removed due date" {
return NOTE_REMOVED_DUEDATE, ""
}
if strings.HasPrefix(n.Body, "assigned to @") {
return NOTE_ASSIGNED, ""
}
if strings.HasPrefix(n.Body, "unassigned @") {
return NOTE_UNASSIGNED, ""
}
if strings.HasPrefix(n.Body, "changed milestone to %") {
return NOTE_CHANGED_MILESTONE, ""
}
if strings.HasPrefix(n.Body, "removed milestone") {
return NOTE_REMOVED_MILESTONE, ""
}
if strings.HasPrefix(n.Body, "mentioned in issue") {
return NOTE_MENTIONED_IN_ISSUE, ""
}
if strings.HasPrefix(n.Body, "mentioned in merge request") {
return NOTE_MENTIONED_IN_MERGE_REQUEST, ""
}
return NOTE_UNKNOWN, ""
}
// getNewTitle parses body diff given by gitlab api and return it final form
// examples: "changed title from **fourth issue** to **fourth issue{+ changed+}**"
// "changed title from **fourth issue{- changed-}** to **fourth issue**"
// because Gitlab
func getNewTitle(diff string) string {
newTitle := strings.Split(diff, "** to **")[1]
newTitle = strings.Replace(newTitle, "{+", "", -1)
newTitle = strings.Replace(newTitle, "+}", "", -1)
return strings.TrimSuffix(newTitle, "**")
}

View File

@ -1,89 +0,0 @@
package iterator
import (
"context"
"github.com/xanzy/go-gitlab"
)
type issueIterator struct {
page int
lastPage bool
index int
cache []*gitlab.Issue
}
func newIssueIterator() *issueIterator {
ii := &issueIterator{}
ii.Reset()
return ii
}
func (ii *issueIterator) Next(ctx context.Context, conf config) (bool, error) {
// first query
if ii.cache == nil {
return ii.getNext(ctx, conf)
}
// move cursor index
if ii.index < len(ii.cache)-1 {
ii.index++
return true, nil
}
return ii.getNext(ctx, conf)
}
func (ii *issueIterator) Value() *gitlab.Issue {
return ii.cache[ii.index]
}
func (ii *issueIterator) getNext(ctx context.Context, conf config) (bool, error) {
if ii.lastPage {
return false, nil
}
ctx, cancel := context.WithTimeout(ctx, conf.timeout)
defer cancel()
issues, resp, err := conf.gc.Issues.ListProjectIssues(
conf.project,
&gitlab.ListProjectIssuesOptions{
ListOptions: gitlab.ListOptions{
Page: ii.page,
PerPage: conf.capacity,
},
Scope: gitlab.String("all"),
UpdatedAfter: &conf.since,
Sort: gitlab.String("asc"),
},
gitlab.WithContext(ctx),
)
if err != nil {
ii.Reset()
return false, err
}
if resp.TotalPages == ii.page {
ii.lastPage = true
}
// if repository doesn't have any issues
if len(issues) == 0 {
return false, nil
}
ii.cache = issues
ii.index = 0
ii.page++
return true, nil
}
func (ii *issueIterator) Reset() {
ii.index = -1
ii.page = 1
ii.lastPage = false
ii.cache = nil
}

View File

@ -1,138 +0,0 @@
package iterator
import (
"context"
"time"
"github.com/xanzy/go-gitlab"
)
type Iterator struct {
// shared context
ctx context.Context
// to pass to sub-iterators
conf config
// sticky error
err error
// issues iterator
issue *issueIterator
// notes iterator
note *noteIterator
// labelEvent iterator
labelEvent *labelEventIterator
}
type config struct {
// gitlab api v4 client
gc *gitlab.Client
timeout time.Duration
// if since is given the iterator will query only the issues
// updated after this date
since time.Time
// project id
project string
// number of issues and notes to query at once
capacity int
}
// NewIterator create a new iterator
func NewIterator(ctx context.Context, client *gitlab.Client, capacity int, projectID string, since time.Time) *Iterator {
return &Iterator{
ctx: ctx,
conf: config{
gc: client,
timeout: 60 * time.Second,
since: since,
project: projectID,
capacity: capacity,
},
issue: newIssueIterator(),
note: newNoteIterator(),
labelEvent: newLabelEventIterator(),
}
}
// Error return last encountered error
func (i *Iterator) Error() error {
return i.err
}
func (i *Iterator) NextIssue() bool {
if i.err != nil {
return false
}
if i.ctx.Err() != nil {
return false
}
more, err := i.issue.Next(i.ctx, i.conf)
if err != nil {
i.err = err
return false
}
// Also reset the other sub iterators as they would
// no longer be valid
i.note.Reset(i.issue.Value().IID)
i.labelEvent.Reset(i.issue.Value().IID)
return more
}
func (i *Iterator) IssueValue() *gitlab.Issue {
return i.issue.Value()
}
func (i *Iterator) NextNote() bool {
if i.err != nil {
return false
}
if i.ctx.Err() != nil {
return false
}
more, err := i.note.Next(i.ctx, i.conf)
if err != nil {
i.err = err
return false
}
return more
}
func (i *Iterator) NoteValue() *gitlab.Note {
return i.note.Value()
}
func (i *Iterator) NextLabelEvent() bool {
if i.err != nil {
return false
}
if i.ctx.Err() != nil {
return false
}
more, err := i.labelEvent.Next(i.ctx, i.conf)
if err != nil {
i.err = err
return false
}
return more
}
func (i *Iterator) LabelEventValue() *gitlab.LabelEvent {
return i.labelEvent.Value()
}

View File

@ -1,105 +0,0 @@
package iterator
import (
"context"
"sort"
"github.com/xanzy/go-gitlab"
)
// Since Gitlab does not return the label events items in the correct order
// we need to sort the list ourselves and stop relying on the pagination model
// #BecauseGitlab
type labelEventIterator struct {
issue int
index int
cache []*gitlab.LabelEvent
}
func newLabelEventIterator() *labelEventIterator {
lei := &labelEventIterator{}
lei.Reset(-1)
return lei
}
func (lei *labelEventIterator) Next(ctx context.Context, conf config) (bool, error) {
// first query
if lei.cache == nil {
return lei.getNext(ctx, conf)
}
// move cursor index
if lei.index < len(lei.cache)-1 {
lei.index++
return true, nil
}
return false, nil
}
func (lei *labelEventIterator) Value() *gitlab.LabelEvent {
return lei.cache[lei.index]
}
func (lei *labelEventIterator) getNext(ctx context.Context, conf config) (bool, error) {
ctx, cancel := context.WithTimeout(ctx, conf.timeout)
defer cancel()
// since order is not guaranteed we should query all label events
// and sort them by ID
page := 1
for {
labelEvents, resp, err := conf.gc.ResourceLabelEvents.ListIssueLabelEvents(
conf.project,
lei.issue,
&gitlab.ListLabelEventsOptions{
ListOptions: gitlab.ListOptions{
Page: page,
PerPage: conf.capacity,
},
},
gitlab.WithContext(ctx),
)
if err != nil {
lei.Reset(-1)
return false, err
}
if len(labelEvents) == 0 {
break
}
lei.cache = append(lei.cache, labelEvents...)
if resp.TotalPages == page {
break
}
page++
}
sort.Sort(lei)
lei.index = 0
return len(lei.cache) > 0, nil
}
func (lei *labelEventIterator) Reset(issue int) {
lei.issue = issue
lei.index = -1
lei.cache = nil
}
// ORDERING
func (lei *labelEventIterator) Len() int {
return len(lei.cache)
}
func (lei *labelEventIterator) Swap(i, j int) {
lei.cache[i], lei.cache[j] = lei.cache[j], lei.cache[i]
}
func (lei *labelEventIterator) Less(i, j int) bool {
return lei.cache[i].ID < lei.cache[j].ID
}

View File

@ -1,90 +0,0 @@
package iterator
import (
"context"
"github.com/xanzy/go-gitlab"
)
type noteIterator struct {
issue int
page int
lastPage bool
index int
cache []*gitlab.Note
}
func newNoteIterator() *noteIterator {
in := &noteIterator{}
in.Reset(-1)
return in
}
func (in *noteIterator) Next(ctx context.Context, conf config) (bool, error) {
// first query
if in.cache == nil {
return in.getNext(ctx, conf)
}
// move cursor index
if in.index < len(in.cache)-1 {
in.index++
return true, nil
}
return in.getNext(ctx, conf)
}
func (in *noteIterator) Value() *gitlab.Note {
return in.cache[in.index]
}
func (in *noteIterator) getNext(ctx context.Context, conf config) (bool, error) {
if in.lastPage {
return false, nil
}
ctx, cancel := context.WithTimeout(ctx, conf.timeout)
defer cancel()
notes, resp, err := conf.gc.Notes.ListIssueNotes(
conf.project,
in.issue,
&gitlab.ListIssueNotesOptions{
ListOptions: gitlab.ListOptions{
Page: in.page,
PerPage: conf.capacity,
},
Sort: gitlab.String("asc"),
OrderBy: gitlab.String("created_at"),
},
gitlab.WithContext(ctx),
)
if err != nil {
in.Reset(-1)
return false, err
}
if resp.TotalPages == in.page {
in.lastPage = true
}
if len(notes) == 0 {
return false, nil
}
in.cache = notes
in.index = 0
in.page++
return true, nil
}
func (in *noteIterator) Reset(issue int) {
in.issue = issue
in.index = -1
in.page = 1
in.lastPage = false
in.cache = nil
}