Merge pull request #571 from 5nord/add-new-gitlab-iterator

[gitlab] Add new iterator with state change events
This commit is contained in:
Michael Muré 2022-02-01 09:24:58 +01:00 committed by GitHub
commit 05d73e1b53
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 471 additions and 691 deletions

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

@ -0,0 +1,215 @@
package gitlab
import (
"fmt"
"strings"
"time"
"github.com/MichaelMure/git-bug/util/text"
"github.com/xanzy/go-gitlab"
)
// Event represents a unified GitLab event (note, label or state event).
type Event interface {
ID() string
UserID() int
Kind() EventKind
CreatedAt() time.Time
}
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
)
var _ Event = &NoteEvent{}
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)
}
var _ Event = &LabelEvent{}
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
}
}
var _ Event = &StateEvent{}
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
}
}
var _ Event = &ErrorEvent{}
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 }
// SortedEvents fan-in some Event-channels into one, sorted by creation date, using CreatedAt-method.
// This function assume that each channel is pre-ordered.
func SortedEvents(inputs ...<-chan Event) chan Event {
out := make(chan Event)
go func() {
defer close(out)
heads := make([]Event, len(inputs))
// pre-fill the head view
for i, input := range inputs {
if event, ok := <-input; ok {
heads[i] = event
}
}
for {
var earliestEvent Event
var originChannel int
// pick the earliest event of the heads
for i, head := range heads {
if head != nil && (earliestEvent == nil || head.CreatedAt().Before(earliestEvent.CreatedAt())) {
earliestEvent = head
originChannel = i
}
}
if earliestEvent == nil {
// no event anymore, we are done
return
}
// we have an event: consume it and replace it if possible
heads[originChannel] = nil
if event, ok := <-inputs[originChannel]; ok {
heads[originChannel] = event
}
out <- earliestEvent
}
}()
return out
}
// 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

@ -2,8 +2,10 @@ package gitlab
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetNewTitle(t *testing.T) {
@ -54,3 +56,40 @@ func TestGetNewTitle(t *testing.T) {
})
}
}
var _ Event = mockEvent(0)
type mockEvent int64
func (m mockEvent) ID() string { panic("implement me") }
func (m mockEvent) UserID() int { panic("implement me") }
func (m mockEvent) Kind() EventKind { panic("implement me") }
func (m mockEvent) CreatedAt() time.Time { return time.Unix(int64(m), 0) }
func TestSortedEvents(t *testing.T) {
makeInput := func(times ...int64) chan Event {
out := make(chan Event)
go func() {
for _, t := range times {
out <- mockEvent(t)
}
close(out)
}()
return out
}
sorted := SortedEvents(
makeInput(),
makeInput(1, 7, 9, 19),
makeInput(2, 8, 23),
makeInput(35, 48, 59, 64, 721),
)
var previous Event
for event := range sorted {
if previous != nil {
require.True(t, previous.CreatedAt().Before(event.CreatedAt()))
}
previous = event
}
}

View File

@ -242,11 +242,6 @@ func TestGitlabPushPull(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.name == "bug changed status" {
t.Skip("test known as broken, see https://github.com/MichaelMure/git-bug/issues/435 and complain to gitlab")
// TODO: fix, somehow, someday, or drop support.
}
// for each operation a SetMetadataOperation will be added
// so number of operations should double
require.Len(t, tt.bug.Snapshot().Operations, tt.numOpExp)

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

@ -0,0 +1,140 @@
package gitlab
import (
"context"
"time"
"github.com/MichaelMure/git-bug/util/text"
"github.com/xanzy/go-gitlab"
)
// Issues returns a channel with gitlab project issues, ascending order.
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, gitlab.WithContext(ctx))
if err != nil {
return
}
for _, issue := range issues {
out <- issue
}
if resp.CurrentPage >= resp.TotalPages {
break
}
opts.Page = resp.NextPage
}
}()
return out
}
// Notes returns a channel with note events
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, gitlab.WithContext(ctx))
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
}
// LabelEvents returns a channel with label events.
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, gitlab.WithContext(ctx))
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
}
// StateEvents returns a channel with state change events.
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, gitlab.WithContext(ctx))
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/auth"
"github.com/MichaelMure/git-bug/bridge/gitlab/iterator"
"github.com/MichaelMure/git-bug/bug"
"github.com/MichaelMure/git-bug/cache"
"github.com/MichaelMure/git-bug/entity"
@ -24,9 +23,6 @@ type gitlabImporter struct {
// default client
client *gitlab.Client
// iterator
iterator *iterator.Iterator
// send only channel
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
// 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) {
gi.iterator = iterator.NewIterator(ctx, gi.client, 10, gi.conf[confKeyProjectID], since)
out := make(chan core.ImportResult)
gi.out = out
go func() {
defer close(gi.out)
defer close(out)
// Loop over all matching issues
for gi.iterator.NextIssue() {
issue := gi.iterator.IssueValue()
for issue := range Issues(ctx, gi.client, gi.conf[confKeyProjectID], since) {
// create issue
b, err := gi.ensureIssue(repo, issue)
if err != nil {
err := fmt.Errorf("issue creation: %v", err)
@ -78,23 +71,20 @@ func (gi *gitlabImporter) ImportAll(ctx context.Context, repo *cache.RepoCache,
return
}
// Loop over all notes
for gi.iterator.NextNote() {
note := gi.iterator.NoteValue()
if err := gi.ensureNote(repo, b, note); err != nil {
err := fmt.Errorf("note creation: %v", err)
out <- core.NewImportError(err, entity.Id(strconv.Itoa(note.ID)))
return
}
}
issueEvents := SortedEvents(
Notes(ctx, gi.client, issue),
LabelEvents(ctx, gi.client, issue),
StateEvents(ctx, gi.client, issue),
)
// Loop over all label events
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
for e := range issueEvents {
if e, ok := e.(ErrorEvent); ok {
out <- core.NewImportError(e.Err, "")
continue
}
if err := gi.ensureIssueEvent(repo, b, issue, e); err != nil {
err := fmt.Errorf("issue event creation: %v", err)
out <- core.NewImportError(err, entity.Id(e.ID()))
}
}
@ -107,10 +97,6 @@ func (gi *gitlabImporter) ImportAll(ctx context.Context, repo *cache.RepoCache,
return
}
}
if err := gi.iterator.Error(); err != nil {
out <- core.NewImportError(err, "")
}
}()
return out, nil
@ -126,7 +112,7 @@ func (gi *gitlabImporter) ensureIssue(repo *cache.RepoCache, issue *gitlab.Issue
// resolve bug
b, err := repo.ResolveBugMatcher(func(excerpt *cache.BugExcerpt) bool {
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[metaKeyGitlabProject] == gi.conf[confKeyProjectID]
})
@ -146,7 +132,7 @@ func (gi *gitlabImporter) ensureIssue(repo *cache.RepoCache, issue *gitlab.Issue
nil,
map[string]string{
core.MetaKeyOrigin: target,
metaKeyGitlabId: parseID(issue.IID),
metaKeyGitlabId: fmt.Sprintf("%d", issue.IID),
metaKeyGitlabUrl: issue.WebURL,
metaKeyGitlabProject: gi.conf[confKeyProjectID],
metaKeyGitlabBaseUrl: gi.conf[confKeyGitlabBaseUrl],
@ -163,50 +149,49 @@ func (gi *gitlabImporter) ensureIssue(repo *cache.RepoCache, issue *gitlab.Issue
return b, nil
}
func (gi *gitlabImporter) ensureNote(repo *cache.RepoCache, b *cache.BugCache, note *gitlab.Note) error {
gitlabID := parseID(note.ID)
func (gi *gitlabImporter) ensureIssueEvent(repo *cache.RepoCache, b *cache.BugCache, issue *gitlab.Issue, event Event) error {
id, errResolve := b.ResolveOperationWithMetadata(metaKeyGitlabId, gitlabID)
id, errResolve := b.ResolveOperationWithMetadata(metaKeyGitlabId, event.ID())
if errResolve != nil && errResolve != cache.ErrNoMatchingOp {
return errResolve
}
// ensure issue author
author, err := gi.ensurePerson(repo, note.Author.ID)
author, err := gi.ensurePerson(repo, event.UserID())
if err != nil {
return err
}
noteType, body := GetNoteType(note)
switch noteType {
case NOTE_CLOSED:
switch event.Kind() {
case EventClosed:
if errResolve == nil {
return nil
}
op, err := b.CloseRaw(
author,
note.CreatedAt.Unix(),
event.CreatedAt().Unix(),
map[string]string{
metaKeyGitlabId: gitlabID,
metaKeyGitlabId: event.ID(),
},
)
if err != nil {
return err
}
gi.out <- core.NewImportStatusChange(op.Id())
case NOTE_REOPENED:
case EventReopened:
if errResolve == nil {
return nil
}
op, err := b.OpenRaw(
author,
note.CreatedAt.Unix(),
event.CreatedAt().Unix(),
map[string]string{
metaKeyGitlabId: gitlabID,
metaKeyGitlabId: event.ID(),
},
)
if err != nil {
@ -215,9 +200,7 @@ func (gi *gitlabImporter) ensureNote(repo *cache.RepoCache, b *cache.BugCache, n
gi.out <- core.NewImportStatusChange(op.Id())
case NOTE_DESCRIPTION_CHANGED:
issue := gi.iterator.IssueValue()
case EventDescriptionChanged:
firstComment := b.Snapshot().Comments[0]
// since gitlab doesn't provide the issue history
// we should check for "changed the description" notes and compare issue texts
@ -226,11 +209,11 @@ func (gi *gitlabImporter) ensureNote(repo *cache.RepoCache, b *cache.BugCache, n
// comment edition
op, err := b.EditCommentRaw(
author,
note.UpdatedAt.Unix(),
event.(NoteEvent).UpdatedAt.Unix(),
firstComment.Id(),
text.Cleanup(issue.Description),
map[string]string{
metaKeyGitlabId: gitlabID,
metaKeyGitlabId: event.ID(),
},
)
if err != nil {
@ -240,8 +223,8 @@ func (gi *gitlabImporter) ensureNote(repo *cache.RepoCache, b *cache.BugCache, n
gi.out <- core.NewImportTitleEdition(op.Id())
}
case NOTE_COMMENT:
cleanText := text.Cleanup(body)
case EventComment:
cleanText := text.Cleanup(event.(NoteEvent).Body)
// if we didn't import the comment
if errResolve == cache.ErrNoMatchingOp {
@ -249,11 +232,11 @@ func (gi *gitlabImporter) ensureNote(repo *cache.RepoCache, b *cache.BugCache, n
// add comment operation
op, err := b.AddCommentRaw(
author,
note.CreatedAt.Unix(),
event.CreatedAt().Unix(),
cleanText,
nil,
map[string]string{
metaKeyGitlabId: gitlabID,
metaKeyGitlabId: event.ID(),
},
)
if err != nil {
@ -271,12 +254,12 @@ func (gi *gitlabImporter) ensureNote(repo *cache.RepoCache, b *cache.BugCache, n
return err
}
// compare local bug comment with the new note body
// compare local bug comment with the new event body
if comment.Message != cleanText {
// comment edition
op, err := b.EditCommentRaw(
author,
note.UpdatedAt.Unix(),
event.(NoteEvent).UpdatedAt.Unix(),
comment.Id(),
cleanText,
nil,
@ -290,7 +273,7 @@ func (gi *gitlabImporter) ensureNote(repo *cache.RepoCache, b *cache.BugCache, n
return nil
case NOTE_TITLE_CHANGED:
case EventTitleChanged:
// title change events are given new notes
if errResolve == nil {
return nil
@ -298,10 +281,10 @@ func (gi *gitlabImporter) ensureNote(repo *cache.RepoCache, b *cache.BugCache, n
op, err := b.SetTitleRaw(
author,
note.CreatedAt.Unix(),
text.CleanupOneLine(body),
event.CreatedAt().Unix(),
event.(NoteEvent).Title(),
map[string]string{
metaKeyGitlabId: gitlabID,
metaKeyGitlabId: event.ID(),
},
)
if err != nil {
@ -310,69 +293,50 @@ func (gi *gitlabImporter) ensureNote(repo *cache.RepoCache, b *cache.BugCache, n
gi.out <- core.NewImportTitleEdition(op.Id())
case NOTE_UNKNOWN,
NOTE_ASSIGNED,
NOTE_UNASSIGNED,
NOTE_CHANGED_MILESTONE,
NOTE_REMOVED_MILESTONE,
NOTE_CHANGED_DUEDATE,
NOTE_REMOVED_DUEDATE,
NOTE_LOCKED,
NOTE_UNLOCKED,
NOTE_MENTIONED_IN_ISSUE,
NOTE_MENTIONED_IN_MERGE_REQUEST:
case EventAddLabel:
_, err = b.ForceChangeLabelsRaw(
author,
event.CreatedAt().Unix(),
[]string{event.(LabelEvent).Label.Name},
nil,
map[string]string{
metaKeyGitlabId: event.ID(),
},
)
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
default:
panic("unhandled note type")
return fmt.Errorf("unexpected event")
}
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) {
// Look first in the cache
i, err := repo.ResolveIdentityImmutableMetadata(metaKeyGitlabId, strconv.Itoa(id))
@ -407,7 +371,3 @@ func (gi *gitlabImporter) ensurePerson(repo *cache.RepoCache, id int) (*cache.Id
gi.out <- core.NewImportIdentity(i.Id())
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
}