aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--bridge/github/import.go321
-rw-r--r--bridge/github/import_query.go128
-rw-r--r--bug/operation.go8
-rw-r--r--bug/snapshot.go1
-rw-r--r--bug/timeline.go1
-rw-r--r--cache/bug_cache.go47
-rw-r--r--cache/repo_cache.go26
7 files changed, 416 insertions, 116 deletions
diff --git a/bridge/github/import.go b/bridge/github/import.go
index 03ce2746..41011082 100644
--- a/bridge/github/import.go
+++ b/bridge/github/import.go
@@ -17,104 +17,24 @@ const keyGithubUrl = "github-url"
// githubImporter implement the Importer interface
type githubImporter struct{}
-type Actor struct {
- Login githubv4.String
- AvatarUrl githubv4.String
-}
-
-type ActorEvent struct {
- Id githubv4.ID
- CreatedAt githubv4.DateTime
- Actor Actor
-}
-
-type AuthorEvent struct {
- Id githubv4.ID
- CreatedAt githubv4.DateTime
- Author Actor
-}
-
-type TimelineItem struct {
- Typename githubv4.String `graphql:"__typename"`
-
- // Issue
- IssueComment struct {
- AuthorEvent
- Body githubv4.String
- Url githubv4.URI
- // TODO: edition
- } `graphql:"... on IssueComment"`
-
- // Label
- LabeledEvent struct {
- ActorEvent
- Label struct {
- // Color githubv4.String
- Name githubv4.String
- }
- } `graphql:"... on LabeledEvent"`
- UnlabeledEvent struct {
- ActorEvent
- Label struct {
- // Color githubv4.String
- Name githubv4.String
- }
- } `graphql:"... on UnlabeledEvent"`
-
- // Status
- ClosedEvent struct {
- ActorEvent
- // Url githubv4.URI
- } `graphql:"... on ClosedEvent"`
- ReopenedEvent struct {
- ActorEvent
- } `graphql:"... on ReopenedEvent"`
-
- // Title
- RenamedTitleEvent struct {
- ActorEvent
- CurrentTitle githubv4.String
- PreviousTitle githubv4.String
- } `graphql:"... on RenamedTitleEvent"`
-}
-
-type Issue struct {
- AuthorEvent
- Title string
- Body githubv4.String
- Url githubv4.URI
-
- Timeline struct {
- Nodes []TimelineItem
- PageInfo struct {
- EndCursor githubv4.String
- HasNextPage bool
- }
- } `graphql:"timeline(first: $timelineFirst, after: $timelineAfter)"`
-}
-
-var q struct {
- Repository struct {
- Issues struct {
- Nodes []Issue
- PageInfo struct {
- EndCursor githubv4.String
- HasNextPage bool
- }
- } `graphql:"issues(first: $issueFirst, after: $issueAfter, orderBy: {field: CREATED_AT, direction: ASC})"`
- } `graphql:"repository(owner: $owner, name: $name)"`
-}
-
func (*githubImporter) ImportAll(repo *cache.RepoCache, conf core.Configuration) error {
client := buildClient(conf)
+ q := &issueTimelineQuery{}
variables := map[string]interface{}{
- "owner": githubv4.String(conf[keyUser]),
- "name": githubv4.String(conf[keyProject]),
- "issueFirst": githubv4.Int(1),
- "issueAfter": (*githubv4.String)(nil),
- "timelineFirst": githubv4.Int(10),
- "timelineAfter": (*githubv4.String)(nil),
+ "owner": githubv4.String(conf[keyUser]),
+ "name": githubv4.String(conf[keyProject]),
+ "issueFirst": githubv4.Int(1),
+ "issueAfter": (*githubv4.String)(nil),
+ "timelineFirst": githubv4.Int(10),
+ "timelineAfter": (*githubv4.String)(nil),
+ "commentEditFirst": githubv4.Int(10),
+ "commentEditAfter": (*githubv4.String)(nil),
+
+ // Fun fact, github provide the comment edition in reverse chronological
+ // order, because haha. Look at me, I'm dying of laughter.
+ "issueEditLast": githubv4.Int(10),
+ "issueEditBefore": (*githubv4.String)(nil),
}
var b *cache.BugCache
@@ -125,22 +45,22 @@ func (*githubImporter) ImportAll(repo *cache.RepoCache, conf core.Configuration)
return err
}
- if len(q.Repository.Issues.Nodes) != 1 {
- return fmt.Errorf("Something went wrong when iterating issues, len is %d", len(q.Repository.Issues.Nodes))
+ if len(q.Repository.Issues.Nodes) == 0 {
+ return nil
}
issue := q.Repository.Issues.Nodes[0]
if b == nil {
- b, err = importIssue(repo, issue)
+ b, err = ensureIssue(repo, issue, client, variables)
if err != nil {
return err
}
}
- for _, item := range q.Repository.Issues.Nodes[0].Timeline.Nodes {
- importTimelineItem(b, item)
- }
+ // for _, item := range q.Repository.Issues.Nodes[0].Timeline.Nodes {
+ // importTimelineItem(b, item)
+ // }
if !issue.Timeline.PageInfo.HasNextPage {
err = b.CommitAsNeeded()
@@ -172,28 +92,138 @@ func (*githubImporter) Import(repo *cache.RepoCache, conf core.Configuration, id
return nil
}
-func importIssue(repo *cache.RepoCache, issue Issue) (*cache.BugCache, error) {
+func ensureIssue(repo *cache.RepoCache, issue issueTimeline, client *githubv4.Client, rootVariables map[string]interface{}) (*cache.BugCache, error) {
fmt.Printf("import issue: %s\n", issue.Title)
+ b, err := repo.ResolveBugCreateMetadata(keyGithubId, parseId(issue.Id))
+ if err != nil && err != bug.ErrBugNotExist {
+ return nil, err
+ }
+
+ // if there is no edit, the UserContentEdits given by github is empty. That
+ // means that the original message is given by the issue message.
+
+ // if there is edits, the UserContentEdits given by github contains both the
+ // original message and the following edits. The issue message give the last
+ // version so we don't care about that.
+
+ if len(issue.UserContentEdits.Nodes) == 0 {
+ if err == bug.ErrBugNotExist {
+ b, err = repo.NewBugRaw(
+ makePerson(issue.Author),
+ issue.CreatedAt.Unix(),
+ // Todo: this might not be the initial title, we need to query the
+ // timeline to be sure
+ issue.Title,
+ cleanupText(string(issue.Body)),
+ nil,
+ map[string]string{
+ keyGithubId: parseId(issue.Id),
+ keyGithubUrl: issue.Url.String(),
+ },
+ )
+
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return b, nil
+ }
+
+ // reverse the order, because github
+ reverseEdits(issue.UserContentEdits.Nodes)
+
+ if err == bug.ErrBugNotExist {
+ firstEdit := issue.UserContentEdits.Nodes[0]
+
+ if firstEdit.Diff == nil {
+ return nil, fmt.Errorf("no diff")
+ }
+
+ b, err = repo.NewBugRaw(
+ makePerson(issue.Author),
+ issue.CreatedAt.Unix(),
+ // Todo: this might not be the initial title, we need to query the
+ // timeline to be sure
+ issue.Title,
+ cleanupText(string(*issue.UserContentEdits.Nodes[0].Diff)),
+ nil,
+ map[string]string{
+ keyGithubId: parseId(issue.Id),
+ keyGithubUrl: issue.Url.String(),
+ },
+ )
+ }
+
+ for i, edit := range issue.UserContentEdits.Nodes {
+ if i == 0 {
+ // The first edit in the github result is the creation itself, we already have that
+ continue
+ }
+
+ err := ensureCommentEdit(b, parseId(issue.Id), edit)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ if !issue.UserContentEdits.PageInfo.HasNextPage {
+ return b, nil
+ }
+
+ // We have more edit, querying them
+
+ q := &issueEditQuery{}
+ variables := map[string]interface{}{
+ "owner": rootVariables["owner"],
+ "name": rootVariables["name"],
+ "issueFirst": rootVariables["issueFirst"],
+ "issueAfter": rootVariables["issueAfter"],
+ "issueEditFirst": githubv4.Int(10),
+ "issueEditAfter": issue.UserContentEdits.PageInfo.EndCursor,
+ }
+
+ for {
+ err := client.Query(context.TODO(), &q, variables)
+ if err != nil {
+ return nil, err
+ }
+
+ edits := q.Repository.Issues.Nodes[0].UserContentEdits
+
+ if len(edits.Nodes) == 0 {
+ return b, nil
+ }
+
+ for i, edit := range edits.Nodes {
+ if i == 0 {
+ // The first edit in the github result is the creation itself, we already have that
+ continue
+ }
+
+ err := ensureCommentEdit(b, parseId(issue.Id), edit)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ if !edits.PageInfo.HasNextPage {
+ break
+ }
+
+ variables["issueEditAfter"] = edits.PageInfo.EndCursor
+ }
+
// TODO: check + import files
- return repo.NewBugRaw(
- makePerson(issue.Author),
- issue.CreatedAt.Unix(),
- issue.Title,
- cleanupText(string(issue.Body)),
- nil,
- map[string]string{
- keyGithubId: parseId(issue.Id),
- keyGithubUrl: issue.Url.String(),
- },
- )
+ return b, nil
}
-func importTimelineItem(b *cache.BugCache, item TimelineItem) error {
+func importTimelineItem(b *cache.BugCache, item timelineItem) error {
switch item.Typename {
case "IssueComment":
- // fmt.Printf("import %s: %s\n", item.Typename, item.IssueComment)
+ // fmt.Printf("import %s: %s\n", item.Typename, item.issueComment)
return b.AddCommentRaw(
makePerson(item.IssueComment.Author),
item.IssueComment.CreatedAt.Unix(),
@@ -214,6 +244,7 @@ func importTimelineItem(b *cache.BugCache, item TimelineItem) error {
string(item.LabeledEvent.Label.Name),
},
nil,
+ nil,
)
return err
@@ -226,6 +257,7 @@ func importTimelineItem(b *cache.BugCache, item TimelineItem) error {
[]string{
string(item.UnlabeledEvent.Label.Name),
},
+ nil,
)
return err
@@ -234,6 +266,7 @@ func importTimelineItem(b *cache.BugCache, item TimelineItem) error {
return b.CloseRaw(
makePerson(item.ClosedEvent.Actor),
item.ClosedEvent.CreatedAt.Unix(),
+ nil,
)
case "ReopenedEvent":
@@ -241,6 +274,7 @@ func importTimelineItem(b *cache.BugCache, item TimelineItem) error {
return b.OpenRaw(
makePerson(item.ReopenedEvent.Actor),
item.ReopenedEvent.CreatedAt.Unix(),
+ nil,
)
case "RenamedTitleEvent":
@@ -249,6 +283,7 @@ func importTimelineItem(b *cache.BugCache, item TimelineItem) error {
makePerson(item.RenamedTitleEvent.Actor),
item.RenamedTitleEvent.CreatedAt.Unix(),
string(item.RenamedTitleEvent.CurrentTitle),
+ nil,
)
default:
@@ -258,8 +293,57 @@ func importTimelineItem(b *cache.BugCache, item TimelineItem) error {
return nil
}
+func ensureCommentEdit(b *cache.BugCache, target string, edit userContentEdit) error {
+ if edit.Editor == nil {
+ return fmt.Errorf("no editor")
+ }
+
+ if edit.Diff == nil {
+ return fmt.Errorf("no diff")
+ }
+
+ _, err := b.ResolveTargetWithMetadata(keyGithubId, parseId(edit.Id))
+ if err == nil {
+ // already imported
+ return nil
+ }
+ if err != cache.ErrNoMatchingOp {
+ // real error
+ return err
+ }
+
+ fmt.Printf("import edition\n")
+
+ targetHash, err := b.ResolveTargetWithMetadata(keyGithubId, target)
+ if err != nil {
+ return err
+ }
+
+ switch {
+ case edit.DeletedAt != nil:
+ // comment deletion, not supported yet
+
+ case edit.DeletedAt == nil:
+ // comment edition
+ err := b.EditCommentRaw(
+ makePerson(*edit.Editor),
+ edit.CreatedAt.Unix(),
+ targetHash,
+ cleanupText(string(*edit.Diff)),
+ map[string]string{
+ keyGithubId: parseId(edit.Id),
+ },
+ )
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
// makePerson create a bug.Person from the Github data
-func makePerson(actor Actor) bug.Person {
+func makePerson(actor actor) bug.Person {
return bug.Person{
Name: string(actor.Login),
AvatarUrl: string(actor.AvatarUrl),
@@ -275,3 +359,10 @@ func cleanupText(text string) string {
// windows new line, Github, really ?
return strings.Replace(text, "\r\n", "\n", -1)
}
+
+func reverseEdits(edits []userContentEdit) []userContentEdit {
+ for i, j := 0, len(edits)-1; i < j; i, j = i+1, j-1 {
+ edits[i], edits[j] = edits[j], edits[i]
+ }
+ return edits
+}
diff --git a/bridge/github/import_query.go b/bridge/github/import_query.go
new file mode 100644
index 00000000..0eb8ad34
--- /dev/null
+++ b/bridge/github/import_query.go
@@ -0,0 +1,128 @@
+package github
+
+import "github.com/shurcooL/githubv4"
+
+type pageInfo struct {
+ EndCursor githubv4.String
+ HasNextPage bool
+}
+
+type actor struct {
+ Login githubv4.String
+ AvatarUrl githubv4.String
+}
+
+type actorEvent struct {
+ Id githubv4.ID
+ CreatedAt githubv4.DateTime
+ Actor actor
+}
+
+type authorEvent struct {
+ Id githubv4.ID
+ CreatedAt githubv4.DateTime
+ Author actor
+}
+
+type userContentEdit struct {
+ Id githubv4.ID
+ CreatedAt githubv4.DateTime
+ UpdatedAt githubv4.DateTime
+ EditedAt githubv4.DateTime
+ Editor *actor
+ DeletedAt *githubv4.DateTime
+ DeletedBy *actor
+ Diff *githubv4.String
+}
+
+type issueComment struct {
+ authorEvent
+ Body githubv4.String
+ Url githubv4.URI
+
+ UserContentEdits struct {
+ Nodes []userContentEdit
+ PageInfo pageInfo
+ } `graphql:"userContentEdits(first: $commentEditFirst, after: $commentEditAfter)"`
+}
+
+type timelineItem struct {
+ Typename githubv4.String `graphql:"__typename"`
+
+ // issue
+ IssueComment issueComment `graphql:"... on IssueComment"`
+
+ // Label
+ LabeledEvent struct {
+ actorEvent
+ Label struct {
+ // Color githubv4.String
+ Name githubv4.String
+ }
+ } `graphql:"... on LabeledEvent"`
+ UnlabeledEvent struct {
+ actorEvent
+ Label struct {
+ // Color githubv4.String
+ Name githubv4.String
+ }
+ } `graphql:"... on UnlabeledEvent"`
+
+ // Status
+ ClosedEvent struct {
+ actorEvent
+ // Url githubv4.URI
+ } `graphql:"... on ClosedEvent"`
+ ReopenedEvent struct {
+ actorEvent
+ } `graphql:"... on ReopenedEvent"`
+
+ // Title
+ RenamedTitleEvent struct {
+ actorEvent
+ CurrentTitle githubv4.String
+ PreviousTitle githubv4.String
+ } `graphql:"... on RenamedTitleEvent"`
+}
+
+type issueTimeline struct {
+ authorEvent
+ Title string
+ Body githubv4.String
+ Url githubv4.URI
+
+ Timeline struct {
+ Nodes []timelineItem
+ PageInfo pageInfo
+ } `graphql:"timeline(first: $timelineFirst, after: $timelineAfter)"`
+
+ UserContentEdits struct {
+ Nodes []userContentEdit
+ PageInfo pageInfo
+ } `graphql:"userContentEdits(last: $issueEditLast, before: $issueEditBefore)"`
+}
+
+type issueEdit struct {
+ UserContentEdits struct {
+ Nodes []userContentEdit
+ PageInfo pageInfo
+ } `graphql:"userContentEdits(last: $issueEditLast, before: $issueEditBefore)"`
+}
+
+type issueTimelineQuery struct {
+ Repository struct {
+ Issues struct {
+ Nodes []issueTimeline
+ PageInfo pageInfo
+ } `graphql:"issues(first: $issueFirst, after: $issueAfter, orderBy: {field: CREATED_AT, direction: ASC})"`
+ } `graphql:"repository(owner: $owner, name: $name)"`
+}
+
+type issueEditQuery struct {
+ Repository struct {
+ Issues struct {
+ Nodes []issueEdit
+ PageInfo pageInfo
+ } `graphql:"issues(first: $issueFirst, after: $issueAfter, orderBy: {field: CREATED_AT, direction: ASC})"`
+ } `graphql:"repository(owner: $owner, name: $name)"`
+}
diff --git a/bug/operation.go b/bug/operation.go
index f42a1192..bb88af1f 100644
--- a/bug/operation.go
+++ b/bug/operation.go
@@ -21,6 +21,7 @@ const (
SetStatusOp
LabelChangeOp
EditCommentOp
+ NoOpOp
)
// Operation define the interface to fulfill for an edit operation of a Bug
@@ -43,6 +44,8 @@ type Operation interface {
SetMetadata(key string, value string)
// GetMetadata retrieve arbitrary metadata about the operation
GetMetadata(key string) (string, bool)
+ // AllMetadata return all metadata for this operation
+ AllMetadata() map[string]string
}
func hashRaw(data []byte) git.Hash {
@@ -145,3 +148,8 @@ func (op *OpBase) GetMetadata(key string) (string, bool) {
val, ok := op.Metadata[key]
return val, ok
}
+
+// AllMetadata return all metadata for this operation
+func (op *OpBase) AllMetadata() map[string]string {
+ return op.Metadata
+}
diff --git a/bug/snapshot.go b/bug/snapshot.go
index 28a92961..1004b625 100644
--- a/bug/snapshot.go
+++ b/bug/snapshot.go
@@ -33,6 +33,7 @@ func (snap *Snapshot) HumanId() string {
return fmt.Sprintf("%.8s", snap.id)
}
+// Deprecated:should be moved in UI code
func (snap *Snapshot) Summary() string {
return fmt.Sprintf("C:%d L:%d",
len(snap.Comments)-1,
diff --git a/bug/timeline.go b/bug/timeline.go
index b5aa22a9..359389a6 100644
--- a/bug/timeline.go
+++ b/bug/timeline.go
@@ -56,6 +56,7 @@ func (c *CommentTimelineItem) Append(comment Comment) {
c.Files = comment.Files
c.LastEdit = comment.UnixTime
c.History = append(c.History, CommentHistoryStep{
+ Author: comment.Author,
Message: comment.Message,
UnixTime: comment.UnixTime,
})
diff --git a/cache/bug_cache.go b/cache/bug_cache.go
index b7c80a0c..52e9eafb 100644
--- a/cache/bug_cache.go
+++ b/cache/bug_cache.go
@@ -1,6 +1,8 @@
package cache
import (
+ "fmt"
+ "strings"
"time"
"github.com/MichaelMure/git-bug/bug"
@@ -35,6 +37,51 @@ func (c *BugCache) notifyUpdated() error {
return c.repoCache.bugUpdated(c.bug.Id())
}
+var ErrNoMatchingOp = fmt.Errorf("no matching operation found")
+
+type ErrMultipleMatchOp struct {
+ Matching []git.Hash
+}
+
+func (e ErrMultipleMatchOp) Error() string {
+ casted := make([]string, len(e.Matching))
+
+ for i := range e.Matching {
+ casted[i] = string(e.Matching[i])
+ }
+
+ return fmt.Sprintf("Multiple matching operation found:\n%s", strings.Join(casted, "\n"))
+}
+
+// ResolveTargetWithMetadata will find an operation that has the matching metadata
+func (c *BugCache) ResolveTargetWithMetadata(key string, value string) (git.Hash, error) {
+ // preallocate but empty
+ matching := make([]git.Hash, 0, 5)
+
+ it := bug.NewOperationIterator(c.bug)
+ for it.Next() {
+ op := it.Value()
+ opValue, ok := op.GetMetadata(key)
+ if ok && value == opValue {
+ h, err := op.Hash()
+ if err != nil {
+ return "", err
+ }
+ matching = append(matching, h)
+ }
+ }
+
+ if len(matching) == 0 {
+ return "", ErrNoMatchingOp
+ }
+
+ if len(matching) > 1 {
+ return "", ErrMultipleMatchOp{Matching: matching}
+ }
+
+ return matching[0], nil
+}
+
func (c *BugCache) AddComment(message string) error {
return c.AddCommentWithFiles(message, nil)
}
diff --git a/cache/repo_cache.go b/cache/repo_cache.go
index 74fc87c0..2cb6a030 100644
--- a/cache/repo_cache.go
+++ b/cache/repo_cache.go
@@ -244,7 +244,31 @@ func (c *RepoCache) ResolveBugPrefix(prefix string) (*BugCache, error) {
}
if len(matching) > 1 {
- return nil, fmt.Errorf("Multiple matching bug found:\n%s", strings.Join(matching, "\n"))
+ return nil, bug.ErrMultipleMatch{Matching: matching}
+ }
+
+ if len(matching) == 0 {
+ return nil, bug.ErrBugNotExist
+ }
+
+ return c.ResolveBug(matching[0])
+}
+
+// ResolveBugCreateMetadata retrieve a bug that has the exact given metadata on
+// its Create operation, that is, the first operation. It fails if multiple bugs
+// match.
+func (c *RepoCache) ResolveBugCreateMetadata(key string, value string) (*BugCache, error) {
+ // preallocate but empty
+ matching := make([]string, 0, 5)
+
+ for id, excerpt := range c.excerpts {
+ if excerpt.CreateMetadata[key] == value {
+ matching = append(matching, id)
+ }
+ }
+
+ if len(matching) > 1 {
+ return nil, bug.ErrMultipleMatch{Matching: matching}
}
if len(matching) == 0 {