diff --git a/Gopkg.lock b/Gopkg.lock index 0ea5b61b827facfb21b5022711439612ebefb972..eb03979c4f218c1d041878584d8261c94f077d4f 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -83,14 +83,6 @@ revision = "5b77d2a35fb0ede96d138fc9a99f5c9b6aef11b4" version = "v1.7.0" -[[projects]] - digest = "1:7f89e0c888fb99c61055c646f5678aae645b0b0a1443d9b2dcd9964d850827ce" - name = "github.com/go-test/deep" - packages = ["."] - pruneopts = "UT" - revision = "6592d9cc0a499ad2d5f574fde80a2b5c5cc3b4f5" - version = "v1.0.1" - [[projects]] digest = "1:97df918963298c287643883209a2c3f642e6593379f97ab400c2a2e219ab647d" name = "github.com/golang/protobuf" @@ -99,19 +91,6 @@ revision = "aa810b61a9c79d51363740d207bb46cf8e620ed5" version = "v1.2.0" -[[projects]] - digest = "1:2e3c336fc7fde5c984d2841455a658a6d626450b1754a854b3b32e7a8f49a07a" - name = "github.com/google/go-cmp" - packages = [ - "cmp", - "cmp/internal/diff", - "cmp/internal/function", - "cmp/internal/value", - ] - pruneopts = "UT" - revision = "3af367b6b30c263d47e8895973edcca9a49cf029" - version = "v0.2.0" - [[projects]] digest = "1:c79fb010be38a59d657c48c6ba1d003a8aa651fa56b579d959d74573b7dff8e1" name = "github.com/gorilla/context" @@ -440,20 +419,6 @@ revision = "5420a8b6744d3b0345ab293f6fcba19c978f1183" version = "v2.2.1" -[[projects]] - digest = "1:225f565dc88a02cebe329d3a49d0ca125789091af952a5cc4fde6312c34ce44d" - name = "gotest.tools" - packages = [ - "assert", - "assert/cmp", - "internal/difflib", - "internal/format", - "internal/source", - ] - pruneopts = "UT" - revision = "b6e20af1ed078cd01a6413b734051a292450b4cb" - version = "v2.1.0" - [solve-meta] analyzer-name = "dep" analyzer-version = 1 @@ -466,9 +431,9 @@ "github.com/cheekybits/genny/generic", "github.com/dustin/go-humanize", "github.com/fatih/color", - "github.com/go-test/deep", "github.com/gorilla/mux", "github.com/icrowley/fake", + "github.com/mattn/go-runewidth", "github.com/phayes/freeport", "github.com/pkg/errors", "github.com/shurcooL/githubv4", @@ -485,7 +450,6 @@ "github.com/vektah/gqlparser/ast", "golang.org/x/crypto/ssh/terminal", "golang.org/x/oauth2", - "gotest.tools/assert", ] solver-name = "gps-cdcl" solver-version = 1 diff --git a/Makefile b/Makefile index 3c6207c75f03779dbee93cac9e2f881c6ee3428d..94a820d5c6e6f3061275ae1e433361f6d8acaa87 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ debug-build: install: go generate - go install . + go install -ldflags "$(LDFLAGS)" . test: go test -bench=. ./... @@ -30,14 +30,19 @@ pack-webui: # produce a build that will fetch the web UI from the filesystem instead of from the binary debug-webui: - go build -tags=debugwebui + go build -ldflags "$(LDFLAGS)" -tags=debugwebui clean-local-bugs: git for-each-ref refs/bugs/ | cut -f 2 | xargs -r -n 1 git update-ref -d git for-each-ref refs/remotes/origin/bugs/ | cut -f 2 | xargs -r -n 1 git update-ref -d - rm -f .git/git-bug/cache + rm -f .git/git-bug/bug-cache clean-remote-bugs: git ls-remote origin "refs/bugs/*" | cut -f 2 | xargs -r git push origin -d +clean-local-identities: + git for-each-ref refs/identities/ | cut -f 2 | xargs -r -n 1 git update-ref -d + git for-each-ref refs/remotes/origin/identities/ | cut -f 2 | xargs -r -n 1 git update-ref -d + rm -f .git/git-bug/identity-cache + .PHONY: build install test pack-webui debug-webui clean-local-bugs clean-remote-bugs diff --git a/bridge/core/bridge.go b/bridge/core/bridge.go index 91ed5bfbbee26d12efd6892f5e9e5796ae2ab747..b849bec642505519f4614e2854547a91276b671f 100644 --- a/bridge/core/bridge.go +++ b/bridge/core/bridge.go @@ -12,8 +12,10 @@ import ( "github.com/pkg/errors" ) -var ErrImportNorSupported = errors.New("import is not supported") -var ErrExportNorSupported = errors.New("export is not supported") +var ErrImportNotSupported = errors.New("import is not supported") +var ErrExportNotSupported = errors.New("export is not supported") + +const bridgeConfigKeyPrefix = "git-bug.bridge" var bridgeImpl map[string]reflect.Type @@ -114,12 +116,12 @@ func splitFullName(fullName string) (string, string, error) { // ConfiguredBridges return the list of bridge that are configured for the given // repo func ConfiguredBridges(repo repository.RepoCommon) ([]string, error) { - configs, err := repo.ReadConfigs("git-bug.bridge.") + configs, err := repo.ReadConfigs(bridgeConfigKeyPrefix + ".") if err != nil { return nil, errors.Wrap(err, "can't read configured bridges") } - re, err := regexp.Compile(`git-bug.bridge.([^.]+\.[^.]+)`) + re, err := regexp.Compile(bridgeConfigKeyPrefix + `.([^.]+\.[^.]+)`) if err != nil { panic(err) } @@ -266,7 +268,7 @@ func (b *Bridge) ensureInit() error { func (b *Bridge) ImportAll() error { importer := b.getImporter() if importer == nil { - return ErrImportNorSupported + return ErrImportNotSupported } err := b.ensureConfig() @@ -285,7 +287,7 @@ func (b *Bridge) ImportAll() error { func (b *Bridge) Import(id string) error { importer := b.getImporter() if importer == nil { - return ErrImportNorSupported + return ErrImportNotSupported } err := b.ensureConfig() @@ -304,7 +306,7 @@ func (b *Bridge) Import(id string) error { func (b *Bridge) ExportAll() error { exporter := b.getExporter() if exporter == nil { - return ErrExportNorSupported + return ErrExportNotSupported } err := b.ensureConfig() @@ -323,7 +325,7 @@ func (b *Bridge) ExportAll() error { func (b *Bridge) Export(id string) error { exporter := b.getExporter() if exporter == nil { - return ErrExportNorSupported + return ErrExportNotSupported } err := b.ensureConfig() diff --git a/bridge/github/import.go b/bridge/github/import.go index 93390408d98d8c58efeba9f573be2393aecaa74d..d641b1925a9d8ba4c2eeebb22cb146b1002296ad 100644 --- a/bridge/github/import.go +++ b/bridge/github/import.go @@ -8,25 +8,26 @@ import ( "github.com/MichaelMure/git-bug/bridge/core" "github.com/MichaelMure/git-bug/bug" "github.com/MichaelMure/git-bug/cache" + "github.com/MichaelMure/git-bug/identity" "github.com/MichaelMure/git-bug/util/git" "github.com/shurcooL/githubv4" ) const keyGithubId = "github-id" const keyGithubUrl = "github-url" +const keyGithubLogin = "github-login" // githubImporter implement the Importer interface type githubImporter struct { client *githubv4.Client conf core.Configuration - ghost bug.Person } func (gi *githubImporter) Init(conf core.Configuration) error { gi.conf = conf gi.client = buildClient(conf) - return gi.fetchGhost() + return nil } func (gi *githubImporter) ImportAll(repo *cache.RepoCache) error { @@ -69,7 +70,10 @@ func (gi *githubImporter) ImportAll(repo *cache.RepoCache) error { } for _, itemEdge := range q.Repository.Issues.Nodes[0].Timeline.Edges { - gi.ensureTimelineItem(b, itemEdge.Cursor, itemEdge.Node, variables) + err = gi.ensureTimelineItem(repo, b, itemEdge.Cursor, itemEdge.Node, variables) + if err != nil { + return err + } } if !issue.Timeline.PageInfo.HasNextPage { @@ -104,6 +108,11 @@ func (gi *githubImporter) Import(repo *cache.RepoCache, id string) error { func (gi *githubImporter) ensureIssue(repo *cache.RepoCache, issue issueTimeline, rootVariables map[string]interface{}) (*cache.BugCache, error) { fmt.Printf("import issue: %s\n", issue.Title) + author, err := gi.ensurePerson(repo, issue.Author) + if err != nil { + return nil, err + } + b, err := repo.ResolveBugCreateMetadata(keyGithubId, parseId(issue.Id)) if err != nil && err != bug.ErrBugNotExist { return nil, err @@ -123,7 +132,7 @@ func (gi *githubImporter) ensureIssue(repo *cache.RepoCache, issue issueTimeline if len(issue.UserContentEdits.Nodes) == 0 { if err == bug.ErrBugNotExist { b, err = repo.NewBugRaw( - gi.makePerson(issue.Author), + author, issue.CreatedAt.Unix(), // Todo: this might not be the initial title, we need to query the // timeline to be sure @@ -135,7 +144,6 @@ func (gi *githubImporter) ensureIssue(repo *cache.RepoCache, issue issueTimeline keyGithubUrl: issue.Url.String(), }, ) - if err != nil { return nil, err } @@ -161,7 +169,7 @@ func (gi *githubImporter) ensureIssue(repo *cache.RepoCache, issue issueTimeline // we create the bug as soon as we have a legit first edition b, err = repo.NewBugRaw( - gi.makePerson(issue.Author), + author, issue.CreatedAt.Unix(), // Todo: this might not be the initial title, we need to query the // timeline to be sure @@ -179,12 +187,12 @@ func (gi *githubImporter) ensureIssue(repo *cache.RepoCache, issue issueTimeline continue } - target, err := b.ResolveTargetWithMetadata(keyGithubId, parseId(issue.Id)) + target, err := b.ResolveOperationWithMetadata(keyGithubId, parseId(issue.Id)) if err != nil { return nil, err } - err = gi.ensureCommentEdit(b, target, edit) + err = gi.ensureCommentEdit(repo, b, target, edit) if err != nil { return nil, err } @@ -194,7 +202,7 @@ func (gi *githubImporter) ensureIssue(repo *cache.RepoCache, issue issueTimeline // if we still didn't get a legit edit, create the bug from the issue data if b == nil { return repo.NewBugRaw( - gi.makePerson(issue.Author), + author, issue.CreatedAt.Unix(), // Todo: this might not be the initial title, we need to query the // timeline to be sure @@ -243,7 +251,7 @@ func (gi *githubImporter) ensureIssue(repo *cache.RepoCache, issue issueTimeline // we create the bug as soon as we have a legit first edition b, err = repo.NewBugRaw( - gi.makePerson(issue.Author), + author, issue.CreatedAt.Unix(), // Todo: this might not be the initial title, we need to query the // timeline to be sure @@ -261,12 +269,12 @@ func (gi *githubImporter) ensureIssue(repo *cache.RepoCache, issue issueTimeline continue } - target, err := b.ResolveTargetWithMetadata(keyGithubId, parseId(issue.Id)) + target, err := b.ResolveOperationWithMetadata(keyGithubId, parseId(issue.Id)) if err != nil { return nil, err } - err = gi.ensureCommentEdit(b, target, edit) + err = gi.ensureCommentEdit(repo, b, target, edit) if err != nil { return nil, err } @@ -284,7 +292,7 @@ func (gi *githubImporter) ensureIssue(repo *cache.RepoCache, issue issueTimeline // if we still didn't get a legit edit, create the bug from the issue data if b == nil { return repo.NewBugRaw( - gi.makePerson(issue.Author), + author, issue.CreatedAt.Unix(), // Todo: this might not be the initial title, we need to query the // timeline to be sure @@ -301,21 +309,25 @@ func (gi *githubImporter) ensureIssue(repo *cache.RepoCache, issue issueTimeline return b, nil } -func (gi *githubImporter) ensureTimelineItem(b *cache.BugCache, cursor githubv4.String, item timelineItem, rootVariables map[string]interface{}) error { +func (gi *githubImporter) ensureTimelineItem(repo *cache.RepoCache, b *cache.BugCache, cursor githubv4.String, item timelineItem, rootVariables map[string]interface{}) error { fmt.Printf("import %s\n", item.Typename) switch item.Typename { case "IssueComment": - return gi.ensureComment(b, cursor, item.IssueComment, rootVariables) + return gi.ensureComment(repo, b, cursor, item.IssueComment, rootVariables) case "LabeledEvent": id := parseId(item.LabeledEvent.Id) - _, err := b.ResolveTargetWithMetadata(keyGithubId, id) + _, err := b.ResolveOperationWithMetadata(keyGithubId, id) if err != cache.ErrNoMatchingOp { return err } - _, err = b.ChangeLabelsRaw( - gi.makePerson(item.LabeledEvent.Actor), + author, err := gi.ensurePerson(repo, item.LabeledEvent.Actor) + if err != nil { + return err + } + _, _, err = b.ChangeLabelsRaw( + author, item.LabeledEvent.CreatedAt.Unix(), []string{ string(item.LabeledEvent.Label.Name), @@ -327,12 +339,16 @@ func (gi *githubImporter) ensureTimelineItem(b *cache.BugCache, cursor githubv4. case "UnlabeledEvent": id := parseId(item.UnlabeledEvent.Id) - _, err := b.ResolveTargetWithMetadata(keyGithubId, id) + _, err := b.ResolveOperationWithMetadata(keyGithubId, id) if err != cache.ErrNoMatchingOp { return err } - _, err = b.ChangeLabelsRaw( - gi.makePerson(item.UnlabeledEvent.Actor), + author, err := gi.ensurePerson(repo, item.UnlabeledEvent.Actor) + if err != nil { + return err + } + _, _, err = b.ChangeLabelsRaw( + author, item.UnlabeledEvent.CreatedAt.Unix(), nil, []string{ @@ -344,40 +360,55 @@ func (gi *githubImporter) ensureTimelineItem(b *cache.BugCache, cursor githubv4. case "ClosedEvent": id := parseId(item.ClosedEvent.Id) - _, err := b.ResolveTargetWithMetadata(keyGithubId, id) + _, err := b.ResolveOperationWithMetadata(keyGithubId, id) if err != cache.ErrNoMatchingOp { return err } - return b.CloseRaw( - gi.makePerson(item.ClosedEvent.Actor), + author, err := gi.ensurePerson(repo, item.ClosedEvent.Actor) + if err != nil { + return err + } + _, err = b.CloseRaw( + author, item.ClosedEvent.CreatedAt.Unix(), map[string]string{keyGithubId: id}, ) + return err case "ReopenedEvent": id := parseId(item.ReopenedEvent.Id) - _, err := b.ResolveTargetWithMetadata(keyGithubId, id) + _, err := b.ResolveOperationWithMetadata(keyGithubId, id) if err != cache.ErrNoMatchingOp { return err } - return b.OpenRaw( - gi.makePerson(item.ReopenedEvent.Actor), + author, err := gi.ensurePerson(repo, item.ReopenedEvent.Actor) + if err != nil { + return err + } + _, err = b.OpenRaw( + author, item.ReopenedEvent.CreatedAt.Unix(), map[string]string{keyGithubId: id}, ) + return err case "RenamedTitleEvent": id := parseId(item.RenamedTitleEvent.Id) - _, err := b.ResolveTargetWithMetadata(keyGithubId, id) + _, err := b.ResolveOperationWithMetadata(keyGithubId, id) if err != cache.ErrNoMatchingOp { return err } - return b.SetTitleRaw( - gi.makePerson(item.RenamedTitleEvent.Actor), + author, err := gi.ensurePerson(repo, item.RenamedTitleEvent.Actor) + if err != nil { + return err + } + _, err = b.SetTitleRaw( + author, item.RenamedTitleEvent.CreatedAt.Unix(), string(item.RenamedTitleEvent.CurrentTitle), map[string]string{keyGithubId: id}, ) + return err default: fmt.Println("ignore event ", item.Typename) @@ -386,8 +417,14 @@ func (gi *githubImporter) ensureTimelineItem(b *cache.BugCache, cursor githubv4. return nil } -func (gi *githubImporter) ensureComment(b *cache.BugCache, cursor githubv4.String, comment issueComment, rootVariables map[string]interface{}) error { - target, err := b.ResolveTargetWithMetadata(keyGithubId, parseId(comment.Id)) +func (gi *githubImporter) ensureComment(repo *cache.RepoCache, b *cache.BugCache, cursor githubv4.String, comment issueComment, rootVariables map[string]interface{}) error { + author, err := gi.ensurePerson(repo, comment.Author) + if err != nil { + return err + } + + var target git.Hash + target, err = b.ResolveOperationWithMetadata(keyGithubId, parseId(comment.Id)) if err != nil && err != cache.ErrNoMatchingOp { // real error return err @@ -406,8 +443,8 @@ func (gi *githubImporter) ensureComment(b *cache.BugCache, cursor githubv4.Strin if len(comment.UserContentEdits.Nodes) == 0 { if err == cache.ErrNoMatchingOp { - err = b.AddCommentRaw( - gi.makePerson(comment.Author), + op, err := b.AddCommentRaw( + author, comment.CreatedAt.Unix(), cleanupText(string(comment.Body)), nil, @@ -415,7 +452,11 @@ func (gi *githubImporter) ensureComment(b *cache.BugCache, cursor githubv4.Strin keyGithubId: parseId(comment.Id), }, ) + if err != nil { + return err + } + target, err = op.Hash() if err != nil { return err } @@ -439,8 +480,8 @@ func (gi *githubImporter) ensureComment(b *cache.BugCache, cursor githubv4.Strin continue } - err = b.AddCommentRaw( - gi.makePerson(comment.Author), + op, err := b.AddCommentRaw( + author, comment.CreatedAt.Unix(), cleanupText(string(*edit.Diff)), nil, @@ -452,9 +493,14 @@ func (gi *githubImporter) ensureComment(b *cache.BugCache, cursor githubv4.Strin if err != nil { return err } + + target, err = op.Hash() + if err != nil { + return err + } } - err := gi.ensureCommentEdit(b, target, edit) + err := gi.ensureCommentEdit(repo, b, target, edit) if err != nil { return err } @@ -496,7 +542,7 @@ func (gi *githubImporter) ensureComment(b *cache.BugCache, cursor githubv4.Strin continue } - err := gi.ensureCommentEdit(b, target, edit) + err := gi.ensureCommentEdit(repo, b, target, edit) if err != nil { return err } @@ -514,18 +560,14 @@ func (gi *githubImporter) ensureComment(b *cache.BugCache, cursor githubv4.Strin return nil } -func (gi *githubImporter) ensureCommentEdit(b *cache.BugCache, target git.Hash, edit userContentEdit) error { +func (gi *githubImporter) ensureCommentEdit(repo *cache.RepoCache, b *cache.BugCache, target git.Hash, edit userContentEdit) error { if edit.Diff == nil { // this happen if the event is older than early 2018, Github doesn't have the data before that. // Best we can do is to ignore the event. return nil } - if edit.Editor == nil { - return fmt.Errorf("no editor") - } - - _, err := b.ResolveTargetWithMetadata(keyGithubId, parseId(edit.Id)) + _, err := b.ResolveOperationWithMetadata(keyGithubId, parseId(edit.Id)) if err == nil { // already imported return nil @@ -537,14 +579,19 @@ func (gi *githubImporter) ensureCommentEdit(b *cache.BugCache, target git.Hash, fmt.Println("import edition") + editor, err := gi.ensurePerson(repo, edit.Editor) + if err != nil { + return err + } + switch { case edit.DeletedAt != nil: // comment deletion, not supported yet case edit.DeletedAt == nil: // comment edition - err := b.EditCommentRaw( - gi.makePerson(edit.Editor), + _, err := b.EditCommentRaw( + editor, edit.CreatedAt.Unix(), target, cleanupText(string(*edit.Diff)), @@ -560,11 +607,23 @@ func (gi *githubImporter) ensureCommentEdit(b *cache.BugCache, target git.Hash, return nil } -// makePerson create a bug.Person from the Github data -func (gi *githubImporter) makePerson(actor *actor) bug.Person { +// ensurePerson create a bug.Person from the Github data +func (gi *githubImporter) ensurePerson(repo *cache.RepoCache, actor *actor) (*cache.IdentityCache, error) { + // When a user has been deleted, Github return a null actor, while displaying a profile named "ghost" + // in it's UI. So we need a special case to get it. if actor == nil { - return gi.ghost + return gi.getGhost(repo) + } + + // Look first in the cache + i, err := repo.ResolveIdentityImmutableMetadata(keyGithubLogin, string(actor.Login)) + if err == nil { + return i, nil + } + if _, ok := err.(identity.ErrMultipleMatch); ok { + return nil, err } + var name string var email string @@ -584,24 +643,36 @@ func (gi *githubImporter) makePerson(actor *actor) bug.Person { case "Bot": } - return bug.Person{ - Name: name, - Email: email, - Login: string(actor.Login), - AvatarUrl: string(actor.AvatarUrl), - } + return repo.NewIdentityRaw( + name, + email, + string(actor.Login), + string(actor.AvatarUrl), + map[string]string{ + keyGithubLogin: string(actor.Login), + }, + ) } -func (gi *githubImporter) fetchGhost() error { +func (gi *githubImporter) getGhost(repo *cache.RepoCache) (*cache.IdentityCache, error) { + // Look first in the cache + i, err := repo.ResolveIdentityImmutableMetadata(keyGithubLogin, "ghost") + if err == nil { + return i, nil + } + if _, ok := err.(identity.ErrMultipleMatch); ok { + return nil, err + } + var q userQuery variables := map[string]interface{}{ "login": githubv4.String("ghost"), } - err := gi.client.Query(context.TODO(), &q, variables) + err = gi.client.Query(context.TODO(), &q, variables) if err != nil { - return err + return nil, err } var name string @@ -609,14 +680,15 @@ func (gi *githubImporter) fetchGhost() error { name = string(*q.User.Name) } - gi.ghost = bug.Person{ - Name: name, - Login: string(q.User.Login), - AvatarUrl: string(q.User.AvatarUrl), - Email: string(q.User.Email), - } - - return nil + return repo.NewIdentityRaw( + name, + string(q.User.Email), + string(q.User.Login), + string(q.User.AvatarUrl), + map[string]string{ + keyGithubLogin: string(q.User.Login), + }, + ) } // parseId convert the unusable githubv4.ID (an interface{}) into a string diff --git a/bridge/launchpad/import.go b/bridge/launchpad/import.go index 10d25e6ccc3c19f72d926f819f81a7dea89b61ab..30ec5c3f82dba93ce80081c1941cc47897432ac0 100644 --- a/bridge/launchpad/import.go +++ b/bridge/launchpad/import.go @@ -7,6 +7,7 @@ import ( "github.com/MichaelMure/git-bug/bridge/core" "github.com/MichaelMure/git-bug/bug" "github.com/MichaelMure/git-bug/cache" + "github.com/MichaelMure/git-bug/identity" "github.com/pkg/errors" ) @@ -20,14 +21,27 @@ func (li *launchpadImporter) Init(conf core.Configuration) error { } const keyLaunchpadID = "launchpad-id" +const keyLaunchpadLogin = "launchpad-login" -func (li *launchpadImporter) makePerson(owner LPPerson) bug.Person { - return bug.Person{ - Name: owner.Name, - Email: "", - Login: owner.Login, - AvatarUrl: "", +func (li *launchpadImporter) ensurePerson(repo *cache.RepoCache, owner LPPerson) (*cache.IdentityCache, error) { + // Look first in the cache + i, err := repo.ResolveIdentityImmutableMetadata(keyLaunchpadLogin, owner.Login) + if err == nil { + return i, nil } + if _, ok := err.(identity.ErrMultipleMatch); ok { + return nil, err + } + + return repo.NewIdentityRaw( + owner.Name, + "", + owner.Login, + "", + map[string]string{ + keyLaunchpadLogin: owner.Login, + }, + ) } func (li *launchpadImporter) ImportAll(repo *cache.RepoCache) error { @@ -53,10 +67,15 @@ func (li *launchpadImporter) ImportAll(repo *cache.RepoCache) error { return err } + owner, err := li.ensurePerson(repo, lpBug.Owner) + if err != nil { + return err + } + if err == bug.ErrBugNotExist { createdAt, _ := time.Parse(time.RFC3339, lpBug.CreatedAt) b, err = repo.NewBugRaw( - li.makePerson(lpBug.Owner), + owner, createdAt.Unix(), lpBug.Title, lpBug.Description, @@ -81,7 +100,7 @@ func (li *launchpadImporter) ImportAll(repo *cache.RepoCache) error { // The Launchpad API returns the bug description as the first // comment, so skip it. for _, lpMessage := range lpBug.Messages[1:] { - _, err := b.ResolveTargetWithMetadata(keyLaunchpadID, lpMessage.ID) + _, err := b.ResolveOperationWithMetadata(keyLaunchpadID, lpMessage.ID) if err != nil && err != cache.ErrNoMatchingOp { return errors.Wrapf(err, "failed to fetch comments for bug #%s", lpBugID) } @@ -94,10 +113,15 @@ func (li *launchpadImporter) ImportAll(repo *cache.RepoCache) error { continue } + owner, err := li.ensurePerson(repo, lpMessage.Owner) + if err != nil { + return err + } + // This is a new comment, we can add it. createdAt, _ := time.Parse(time.RFC3339, lpMessage.CreatedAt) - err = b.AddCommentRaw( - li.makePerson(lpMessage.Owner), + _, err = b.AddCommentRaw( + owner, createdAt.Unix(), lpMessage.Content, nil, diff --git a/bridge/launchpad/launchpad.go b/bridge/launchpad/launchpad.go index f862f24ed457cc31693362a852d0337f2dc62688..1fd9edc28287eb142a2688a69198d6f0aa9d1e0e 100644 --- a/bridge/launchpad/launchpad.go +++ b/bridge/launchpad/launchpad.go @@ -1,4 +1,4 @@ -// Package launchad contains the Launchpad bridge implementation +// Package launchpad contains the Launchpad bridge implementation package launchpad import ( diff --git a/bug/bug.go b/bug/bug.go index b6d09c50822169c62fe173866f449a6f06419238..f1bd1114ca9fca0b6beb1aa00f1b69a5a411c887 100644 --- a/bug/bug.go +++ b/bug/bug.go @@ -6,10 +6,12 @@ import ( "fmt" "strings" + "github.com/pkg/errors" + + "github.com/MichaelMure/git-bug/identity" "github.com/MichaelMure/git-bug/repository" "github.com/MichaelMure/git-bug/util/git" "github.com/MichaelMure/git-bug/util/lamport" - "github.com/pkg/errors" ) const bugsRefPattern = "refs/bugs/" @@ -113,13 +115,6 @@ func ReadRemoteBug(repo repository.ClockedRepo, remote string, id string) (*Bug, // readBug will read and parse a Bug from git func readBug(repo repository.ClockedRepo, ref string) (*Bug, error) { - hashes, err := repo.ListCommits(ref) - - // TODO: this is not perfect, it might be a command invoke error - if err != nil { - return nil, ErrBugNotExist - } - refSplit := strings.Split(ref, "/") id := refSplit[len(refSplit)-1] @@ -127,6 +122,13 @@ func readBug(repo repository.ClockedRepo, ref string) (*Bug, error) { return nil, fmt.Errorf("invalid ref length") } + hashes, err := repo.ListCommits(ref) + + // TODO: this is not perfect, it might be a command invoke error + if err != nil { + return nil, ErrBugNotExist + } + bug := Bug{ id: id, } @@ -217,6 +219,13 @@ func readBug(repo repository.ClockedRepo, ref string) (*Bug, error) { bug.packs = append(bug.packs, *opp) } + // Make sure that the identities are properly loaded + resolver := identity.NewSimpleResolver(repo) + err = bug.EnsureIdentities(resolver) + if err != nil { + return nil, err + } + return &bug, nil } @@ -312,6 +321,11 @@ func (bug *Bug) Validate() error { return fmt.Errorf("first operation should be a Create op") } + // The bug ID should be the hash of the first commit + if len(bug.packs) > 0 && string(bug.packs[0].commitHash) != bug.id { + return fmt.Errorf("bug id should be the first commit hash") + } + // Check that there is no more CreateOp op it := NewOperationIterator(bug) createCount := 0 @@ -340,7 +354,8 @@ func (bug *Bug) HasPendingOp() bool { // Commit write the staging area in Git and move the operations to the packs func (bug *Bug) Commit(repo repository.ClockedRepo) error { - if bug.staging.IsEmpty() { + + if !bug.NeedCommit() { return fmt.Errorf("can't commit a bug with no pending operation") } @@ -450,12 +465,24 @@ func (bug *Bug) Commit(repo repository.ClockedRepo) error { return err } + bug.staging.commitHash = hash bug.packs = append(bug.packs, bug.staging) bug.staging = OperationPack{} return nil } +func (bug *Bug) CommitAsNeeded(repo repository.ClockedRepo) error { + if !bug.NeedCommit() { + return nil + } + return bug.Commit(repo) +} + +func (bug *Bug) NeedCommit() bool { + return !bug.staging.IsEmpty() +} + func makeMediaTree(pack OperationPack) []repository.TreeEntry { var tree []repository.TreeEntry counter := 0 @@ -504,9 +531,8 @@ func (bug *Bug) Merge(repo repository.Repo, other Interface) (bool, error) { } ancestor, err := repo.FindCommonAncestor(bug.lastCommit, otherBug.lastCommit) - if err != nil { - return false, err + return false, errors.Wrap(err, "can't find common ancestor") } ancestorIndex := 0 diff --git a/bug/bug_actions.go b/bug/bug_actions.go index 487ba25e5c8d42e65ee45535e0d4b67f9850ad6d..20360200d9e6567d091483a61f3c4d6f109f2831 100644 --- a/bug/bug_actions.go +++ b/bug/bug_actions.go @@ -4,29 +4,68 @@ import ( "fmt" "strings" + "github.com/MichaelMure/git-bug/identity" "github.com/MichaelMure/git-bug/repository" "github.com/pkg/errors" ) -// Fetch retrieve update from a remote +// Note: +// +// For the actions (fetch/push/pull/merge/commit), this package act as a master for +// the identity package and will also drive the needed identity actions. That is, +// if bug.Push() is called, identity.Push will also be called to make sure that +// the dependant identities are also present and up to date on the remote. +// +// I'm not entirely sure this is the correct way to do it, as it might introduce +// too much complexity and hard coupling, but it does make this package easier +// to use. + +// Fetch retrieve updates from a remote // This does not change the local bugs state func Fetch(repo repository.Repo, remote string) (string, error) { + stdout, err := identity.Fetch(repo, remote) + if err != nil { + return stdout, err + } + remoteRefSpec := fmt.Sprintf(bugsRemoteRefPattern, remote) fetchRefSpec := fmt.Sprintf("%s*:%s*", bugsRefPattern, remoteRefSpec) - return repo.FetchRefs(remote, fetchRefSpec) + stdout2, err := repo.FetchRefs(remote, fetchRefSpec) + + return stdout + "\n" + stdout2, err } // Push update a remote with the local changes func Push(repo repository.Repo, remote string) (string, error) { - return repo.PushRefs(remote, bugsRefPattern+"*") + stdout, err := identity.Push(repo, remote) + if err != nil { + return stdout, err + } + + stdout2, err := repo.PushRefs(remote, bugsRefPattern+"*") + + return stdout + "\n" + stdout2, err } // Pull will do a Fetch + MergeAll -// This function won't give details on the underlying process. If you need more -// use Fetch and MergeAll separately. +// This function will return an error if a merge fail func Pull(repo repository.ClockedRepo, remote string) error { - _, err := Fetch(repo, remote) + _, err := identity.Fetch(repo, remote) + if err != nil { + return err + } + + for merge := range identity.MergeAll(repo, remote) { + if merge.Err != nil { + return merge.Err + } + if merge.Status == identity.MergeStatusInvalid { + return errors.Errorf("merge failure: %s", merge.Reason) + } + } + + _, err = Fetch(repo, remote) if err != nil { return err } @@ -35,12 +74,21 @@ func Pull(repo repository.ClockedRepo, remote string) error { if merge.Err != nil { return merge.Err } + if merge.Status == MergeStatusInvalid { + return errors.Errorf("merge failure: %s", merge.Reason) + } } return nil } -// MergeAll will merge all the available remote bug +// MergeAll will merge all the available remote bug: +// +// - If the remote has new commit, the local bug is updated to match the same history +// (fast-forward update) +// - if the local bug has new commits but the remote don't, nothing is changed +// - if both local and remote bug have new commits (that is, we have a concurrent edition), +// new local commits are rewritten at the head of the remote history (that is, a rebase) func MergeAll(repo repository.ClockedRepo, remote string) <-chan MergeResult { out := make(chan MergeResult) diff --git a/bug/bug_actions_test.go b/bug/bug_actions_test.go index a60e5c685841e7e592d1611a41faaa1de2a8be8b..345b5e9a15a219b3bc80d34b596b194eac6dbaf3 100644 --- a/bug/bug_actions_test.go +++ b/bug/bug_actions_test.go @@ -1,93 +1,31 @@ package bug import ( - "github.com/MichaelMure/git-bug/repository" - "github.com/stretchr/testify/assert" - - "io/ioutil" - "log" - "os" "testing" -) - -func createRepo(bare bool) *repository.GitRepo { - dir, err := ioutil.TempDir("", "") - if err != nil { - log.Fatal(err) - } - - // fmt.Println("Creating repo:", dir) - - var creator func(string) (*repository.GitRepo, error) - - if bare { - creator = repository.InitBareGitRepo - } else { - creator = repository.InitGitRepo - } - - repo, err := creator(dir) - if err != nil { - log.Fatal(err) - } - - if err := repo.StoreConfig("user.name", "testuser"); err != nil { - log.Fatal("failed to set user.name for test repository: ", err) - } - if err := repo.StoreConfig("user.email", "testuser@example.com"); err != nil { - log.Fatal("failed to set user.email for test repository: ", err) - } - - return repo -} - -func cleanupRepo(repo repository.Repo) error { - path := repo.GetPath() - // fmt.Println("Cleaning repo:", path) - return os.RemoveAll(path) -} - -func setupRepos(t testing.TB) (repoA, repoB, remote *repository.GitRepo) { - repoA = createRepo(false) - repoB = createRepo(false) - remote = createRepo(true) + "time" - remoteAddr := "file://" + remote.GetPath() - - err := repoA.AddRemote("origin", remoteAddr) - if err != nil { - t.Fatal(err) - } - - err = repoB.AddRemote("origin", remoteAddr) - if err != nil { - t.Fatal(err) - } - - return repoA, repoB, remote -} - -func cleanupRepos(repoA, repoB, remote *repository.GitRepo) { - cleanupRepo(repoA) - cleanupRepo(repoB) - cleanupRepo(remote) -} + "github.com/MichaelMure/git-bug/identity" + "github.com/MichaelMure/git-bug/util/test" + "github.com/stretchr/testify/require" +) func TestPushPull(t *testing.T) { - repoA, repoB, remote := setupRepos(t) - defer cleanupRepos(repoA, repoB, remote) + repoA, repoB, remote := test.SetupReposAndRemote(t) + defer test.CleanupRepos(repoA, repoB, remote) - bug1, _, err := Create(rene, unix, "bug1", "message") - assert.Nil(t, err) + reneA := identity.NewIdentity("René Descartes", "rene@descartes.fr") + + bug1, _, err := Create(reneA, time.Now().Unix(), "bug1", "message") + require.NoError(t, err) err = bug1.Commit(repoA) - assert.Nil(t, err) + require.NoError(t, err) // A --> remote --> B _, err = Push(repoA, "origin") - assert.Nil(t, err) + require.NoError(t, err) err = Pull(repoB, "origin") - assert.Nil(t, err) + require.NoError(t, err) bugs := allBugs(t, ReadAllLocalBugs(repoB)) @@ -96,16 +34,19 @@ func TestPushPull(t *testing.T) { } // B --> remote --> A - bug2, _, err := Create(rene, unix, "bug2", "message") - assert.Nil(t, err) + reneB, err := identity.ReadLocal(repoA, reneA.Id()) + require.NoError(t, err) + + bug2, _, err := Create(reneB, time.Now().Unix(), "bug2", "message") + require.NoError(t, err) err = bug2.Commit(repoB) - assert.Nil(t, err) + require.NoError(t, err) _, err = Push(repoB, "origin") - assert.Nil(t, err) + require.NoError(t, err) err = Pull(repoA, "origin") - assert.Nil(t, err) + require.NoError(t, err) bugs = allBugs(t, ReadAllLocalBugs(repoA)) @@ -136,41 +77,46 @@ func BenchmarkRebaseTheirs(b *testing.B) { } func _RebaseTheirs(t testing.TB) { - repoA, repoB, remote := setupRepos(t) - defer cleanupRepos(repoA, repoB, remote) + repoA, repoB, remote := test.SetupReposAndRemote(t) + defer test.CleanupRepos(repoA, repoB, remote) + + reneA := identity.NewIdentity("René Descartes", "rene@descartes.fr") - bug1, _, err := Create(rene, unix, "bug1", "message") - assert.Nil(t, err) + bug1, _, err := Create(reneA, time.Now().Unix(), "bug1", "message") + require.NoError(t, err) err = bug1.Commit(repoA) - assert.Nil(t, err) + require.NoError(t, err) // A --> remote _, err = Push(repoA, "origin") - assert.Nil(t, err) + require.NoError(t, err) // remote --> B err = Pull(repoB, "origin") - assert.Nil(t, err) + require.NoError(t, err) bug2, err := ReadLocalBug(repoB, bug1.Id()) - assert.Nil(t, err) - - _, err = AddComment(bug2, rene, unix, "message2") - assert.Nil(t, err) - _, err = AddComment(bug2, rene, unix, "message3") - assert.Nil(t, err) - _, err = AddComment(bug2, rene, unix, "message4") - assert.Nil(t, err) + require.NoError(t, err) + + reneB, err := identity.ReadLocal(repoA, reneA.Id()) + require.NoError(t, err) + + _, err = AddComment(bug2, reneB, time.Now().Unix(), "message2") + require.NoError(t, err) + _, err = AddComment(bug2, reneB, time.Now().Unix(), "message3") + require.NoError(t, err) + _, err = AddComment(bug2, reneB, time.Now().Unix(), "message4") + require.NoError(t, err) err = bug2.Commit(repoB) - assert.Nil(t, err) + require.NoError(t, err) // B --> remote _, err = Push(repoB, "origin") - assert.Nil(t, err) + require.NoError(t, err) // remote --> A err = Pull(repoA, "origin") - assert.Nil(t, err) + require.NoError(t, err) bugs := allBugs(t, ReadAllLocalBugs(repoB)) @@ -179,7 +125,7 @@ func _RebaseTheirs(t testing.TB) { } bug3, err := ReadLocalBug(repoA, bug1.Id()) - assert.Nil(t, err) + require.NoError(t, err) if nbOps(bug3) != 4 { t.Fatal("Unexpected number of operations") @@ -197,52 +143,54 @@ func BenchmarkRebaseOurs(b *testing.B) { } func _RebaseOurs(t testing.TB) { - repoA, repoB, remote := setupRepos(t) - defer cleanupRepos(repoA, repoB, remote) + repoA, repoB, remote := test.SetupReposAndRemote(t) + defer test.CleanupRepos(repoA, repoB, remote) - bug1, _, err := Create(rene, unix, "bug1", "message") - assert.Nil(t, err) + reneA := identity.NewIdentity("René Descartes", "rene@descartes.fr") + + bug1, _, err := Create(reneA, time.Now().Unix(), "bug1", "message") + require.NoError(t, err) err = bug1.Commit(repoA) - assert.Nil(t, err) + require.NoError(t, err) // A --> remote _, err = Push(repoA, "origin") - assert.Nil(t, err) + require.NoError(t, err) // remote --> B err = Pull(repoB, "origin") - assert.Nil(t, err) - - _, err = AddComment(bug1, rene, unix, "message2") - assert.Nil(t, err) - _, err = AddComment(bug1, rene, unix, "message3") - assert.Nil(t, err) - _, err = AddComment(bug1, rene, unix, "message4") - assert.Nil(t, err) + require.NoError(t, err) + + _, err = AddComment(bug1, reneA, time.Now().Unix(), "message2") + require.NoError(t, err) + _, err = AddComment(bug1, reneA, time.Now().Unix(), "message3") + require.NoError(t, err) + _, err = AddComment(bug1, reneA, time.Now().Unix(), "message4") + require.NoError(t, err) err = bug1.Commit(repoA) - assert.Nil(t, err) - - _, err = AddComment(bug1, rene, unix, "message5") - assert.Nil(t, err) - _, err = AddComment(bug1, rene, unix, "message6") - assert.Nil(t, err) - _, err = AddComment(bug1, rene, unix, "message7") - assert.Nil(t, err) + require.NoError(t, err) + + _, err = AddComment(bug1, reneA, time.Now().Unix(), "message5") + require.NoError(t, err) + _, err = AddComment(bug1, reneA, time.Now().Unix(), "message6") + require.NoError(t, err) + _, err = AddComment(bug1, reneA, time.Now().Unix(), "message7") + require.NoError(t, err) err = bug1.Commit(repoA) - assert.Nil(t, err) - - _, err = AddComment(bug1, rene, unix, "message8") - assert.Nil(t, err) - _, err = AddComment(bug1, rene, unix, "message9") - assert.Nil(t, err) - _, err = AddComment(bug1, rene, unix, "message10") - assert.Nil(t, err) + require.NoError(t, err) + + _, err = AddComment(bug1, reneA, time.Now().Unix(), "message8") + require.NoError(t, err) + _, err = AddComment(bug1, reneA, time.Now().Unix(), "message9") + require.NoError(t, err) + _, err = AddComment(bug1, reneA, time.Now().Unix(), "message10") + require.NoError(t, err) err = bug1.Commit(repoA) - assert.Nil(t, err) + require.NoError(t, err) // remote --> A err = Pull(repoA, "origin") - assert.Nil(t, err) + require.NoError(t, err) bugs := allBugs(t, ReadAllLocalBugs(repoA)) @@ -251,7 +199,7 @@ func _RebaseOurs(t testing.TB) { } bug2, err := ReadLocalBug(repoA, bug1.Id()) - assert.Nil(t, err) + require.NoError(t, err) if nbOps(bug2) != 10 { t.Fatal("Unexpected number of operations") @@ -278,86 +226,91 @@ func BenchmarkRebaseConflict(b *testing.B) { } func _RebaseConflict(t testing.TB) { - repoA, repoB, remote := setupRepos(t) - defer cleanupRepos(repoA, repoB, remote) + repoA, repoB, remote := test.SetupReposAndRemote(t) + defer test.CleanupRepos(repoA, repoB, remote) + + reneA := identity.NewIdentity("René Descartes", "rene@descartes.fr") - bug1, _, err := Create(rene, unix, "bug1", "message") - assert.Nil(t, err) + bug1, _, err := Create(reneA, time.Now().Unix(), "bug1", "message") + require.NoError(t, err) err = bug1.Commit(repoA) - assert.Nil(t, err) + require.NoError(t, err) // A --> remote _, err = Push(repoA, "origin") - assert.Nil(t, err) + require.NoError(t, err) // remote --> B err = Pull(repoB, "origin") - assert.Nil(t, err) - - _, err = AddComment(bug1, rene, unix, "message2") - assert.Nil(t, err) - _, err = AddComment(bug1, rene, unix, "message3") - assert.Nil(t, err) - _, err = AddComment(bug1, rene, unix, "message4") - assert.Nil(t, err) + require.NoError(t, err) + + _, err = AddComment(bug1, reneA, time.Now().Unix(), "message2") + require.NoError(t, err) + _, err = AddComment(bug1, reneA, time.Now().Unix(), "message3") + require.NoError(t, err) + _, err = AddComment(bug1, reneA, time.Now().Unix(), "message4") + require.NoError(t, err) err = bug1.Commit(repoA) - assert.Nil(t, err) - - _, err = AddComment(bug1, rene, unix, "message5") - assert.Nil(t, err) - _, err = AddComment(bug1, rene, unix, "message6") - assert.Nil(t, err) - _, err = AddComment(bug1, rene, unix, "message7") - assert.Nil(t, err) + require.NoError(t, err) + + _, err = AddComment(bug1, reneA, time.Now().Unix(), "message5") + require.NoError(t, err) + _, err = AddComment(bug1, reneA, time.Now().Unix(), "message6") + require.NoError(t, err) + _, err = AddComment(bug1, reneA, time.Now().Unix(), "message7") + require.NoError(t, err) err = bug1.Commit(repoA) - assert.Nil(t, err) - - _, err = AddComment(bug1, rene, unix, "message8") - assert.Nil(t, err) - _, err = AddComment(bug1, rene, unix, "message9") - assert.Nil(t, err) - _, err = AddComment(bug1, rene, unix, "message10") - assert.Nil(t, err) + require.NoError(t, err) + + _, err = AddComment(bug1, reneA, time.Now().Unix(), "message8") + require.NoError(t, err) + _, err = AddComment(bug1, reneA, time.Now().Unix(), "message9") + require.NoError(t, err) + _, err = AddComment(bug1, reneA, time.Now().Unix(), "message10") + require.NoError(t, err) err = bug1.Commit(repoA) - assert.Nil(t, err) + require.NoError(t, err) bug2, err := ReadLocalBug(repoB, bug1.Id()) - assert.Nil(t, err) - - _, err = AddComment(bug2, rene, unix, "message11") - assert.Nil(t, err) - _, err = AddComment(bug2, rene, unix, "message12") - assert.Nil(t, err) - _, err = AddComment(bug2, rene, unix, "message13") - assert.Nil(t, err) + require.NoError(t, err) + + reneB, err := identity.ReadLocal(repoA, reneA.Id()) + require.NoError(t, err) + + _, err = AddComment(bug2, reneB, time.Now().Unix(), "message11") + require.NoError(t, err) + _, err = AddComment(bug2, reneB, time.Now().Unix(), "message12") + require.NoError(t, err) + _, err = AddComment(bug2, reneB, time.Now().Unix(), "message13") + require.NoError(t, err) err = bug2.Commit(repoB) - assert.Nil(t, err) - - _, err = AddComment(bug2, rene, unix, "message14") - assert.Nil(t, err) - _, err = AddComment(bug2, rene, unix, "message15") - assert.Nil(t, err) - _, err = AddComment(bug2, rene, unix, "message16") - assert.Nil(t, err) + require.NoError(t, err) + + _, err = AddComment(bug2, reneB, time.Now().Unix(), "message14") + require.NoError(t, err) + _, err = AddComment(bug2, reneB, time.Now().Unix(), "message15") + require.NoError(t, err) + _, err = AddComment(bug2, reneB, time.Now().Unix(), "message16") + require.NoError(t, err) err = bug2.Commit(repoB) - assert.Nil(t, err) - - _, err = AddComment(bug2, rene, unix, "message17") - assert.Nil(t, err) - _, err = AddComment(bug2, rene, unix, "message18") - assert.Nil(t, err) - _, err = AddComment(bug2, rene, unix, "message19") - assert.Nil(t, err) + require.NoError(t, err) + + _, err = AddComment(bug2, reneB, time.Now().Unix(), "message17") + require.NoError(t, err) + _, err = AddComment(bug2, reneB, time.Now().Unix(), "message18") + require.NoError(t, err) + _, err = AddComment(bug2, reneB, time.Now().Unix(), "message19") + require.NoError(t, err) err = bug2.Commit(repoB) - assert.Nil(t, err) + require.NoError(t, err) // A --> remote _, err = Push(repoA, "origin") - assert.Nil(t, err) + require.NoError(t, err) // remote --> B err = Pull(repoB, "origin") - assert.Nil(t, err) + require.NoError(t, err) bugs := allBugs(t, ReadAllLocalBugs(repoB)) @@ -366,7 +319,7 @@ func _RebaseConflict(t testing.TB) { } bug3, err := ReadLocalBug(repoB, bug1.Id()) - assert.Nil(t, err) + require.NoError(t, err) if nbOps(bug3) != 19 { t.Fatal("Unexpected number of operations") @@ -374,11 +327,11 @@ func _RebaseConflict(t testing.TB) { // B --> remote _, err = Push(repoB, "origin") - assert.Nil(t, err) + require.NoError(t, err) // remote --> A err = Pull(repoA, "origin") - assert.Nil(t, err) + require.NoError(t, err) bugs = allBugs(t, ReadAllLocalBugs(repoA)) @@ -387,7 +340,7 @@ func _RebaseConflict(t testing.TB) { } bug4, err := ReadLocalBug(repoA, bug1.Id()) - assert.Nil(t, err) + require.NoError(t, err) if nbOps(bug4) != 19 { t.Fatal("Unexpected number of operations") diff --git a/bug/bug_test.go b/bug/bug_test.go index 0fd373d5e5a444630a68ed4659e1c7c22294e9c3..4c3952ebe210499928414b0e60cf65f4c78ec85f 100644 --- a/bug/bug_test.go +++ b/bug/bug_test.go @@ -1,11 +1,12 @@ package bug import ( + "testing" + "time" + + "github.com/MichaelMure/git-bug/identity" "github.com/MichaelMure/git-bug/repository" - "github.com/go-test/deep" "github.com/stretchr/testify/assert" - - "testing" ) func TestBugId(t *testing.T) { @@ -13,6 +14,9 @@ func TestBugId(t *testing.T) { bug1 := NewBug() + rene := identity.NewIdentity("René Descartes", "rene@descartes.fr") + createOp := NewCreateOp(rene, time.Now().Unix(), "title", "message", nil) + bug1.Append(createOp) err := bug1.Commit(mockRepo) @@ -29,6 +33,9 @@ func TestBugValidity(t *testing.T) { bug1 := NewBug() + rene := identity.NewIdentity("René Descartes", "rene@descartes.fr") + createOp := NewCreateOp(rene, time.Now().Unix(), "title", "message", nil) + if bug1.Validate() == nil { t.Fatal("Empty bug should be invalid") } @@ -56,9 +63,14 @@ func TestBugValidity(t *testing.T) { } } -func TestBugSerialisation(t *testing.T) { +func TestBugCommitLoad(t *testing.T) { bug1 := NewBug() + rene := identity.NewIdentity("René Descartes", "rene@descartes.fr") + createOp := NewCreateOp(rene, time.Now().Unix(), "title", "message", nil) + setTitleOp := NewSetTitleOp(rene, time.Now().Unix(), "title2", "title1") + addCommentOp := NewAddCommentOp(rene, time.Now().Unix(), "message2", nil) + bug1.Append(createOp) bug1.Append(setTitleOp) bug1.Append(setTitleOp) @@ -70,25 +82,30 @@ func TestBugSerialisation(t *testing.T) { assert.Nil(t, err) bug2, err := ReadLocalBug(repo, bug1.Id()) - if err != nil { - t.Error(err) - } + assert.NoError(t, err) + equivalentBug(t, bug1, bug2) - // ignore some fields - bug2.packs[0].commitHash = bug1.packs[0].commitHash - for i := range bug1.packs[0].Operations { - bug2.packs[0].Operations[i].base().hash = bug1.packs[0].Operations[i].base().hash - } + // add more op - // check hashes - for i := range bug1.packs[0].Operations { - if !bug2.packs[0].Operations[i].base().hash.IsValid() { - t.Fatal("invalid hash") + bug1.Append(setTitleOp) + bug1.Append(addCommentOp) + + err = bug1.Commit(repo) + assert.Nil(t, err) + + bug3, err := ReadLocalBug(repo, bug1.Id()) + assert.NoError(t, err) + equivalentBug(t, bug1, bug3) +} + +func equivalentBug(t *testing.T, expected, actual *Bug) { + assert.Equal(t, len(expected.packs), len(actual.packs)) + + for i := range expected.packs { + for j := range expected.packs[i].Operations { + actual.packs[i].Operations[j].base().hash = expected.packs[i].Operations[j].base().hash } } - deep.CompareUnexportedFields = true - if diff := deep.Equal(bug1, bug2); diff != nil { - t.Fatal(diff) - } + assert.Equal(t, expected, actual) } diff --git a/bug/comment.go b/bug/comment.go index 67936634942abfd10a2c23da3131d0dfd9ded72f..f7a6eada769684cf479854435fc7ebe7c99f0a81 100644 --- a/bug/comment.go +++ b/bug/comment.go @@ -1,19 +1,21 @@ package bug import ( + "github.com/MichaelMure/git-bug/identity" "github.com/MichaelMure/git-bug/util/git" + "github.com/MichaelMure/git-bug/util/timestamp" "github.com/dustin/go-humanize" ) // Comment represent a comment in a Bug type Comment struct { - Author Person + Author identity.Interface Message string Files []git.Hash // Creation time of the comment. // Should be used only for human display, never for ordering as we can't rely on it in a distributed system. - UnixTime Timestamp + UnixTime timestamp.Timestamp } // FormatTimeRel format the UnixTime of the comment for human consumption diff --git a/bug/identity.go b/bug/identity.go new file mode 100644 index 0000000000000000000000000000000000000000..2eb2bcaf022bc1ba28fadca4f7a3ec0d677c7b87 --- /dev/null +++ b/bug/identity.go @@ -0,0 +1,27 @@ +package bug + +import ( + "github.com/MichaelMure/git-bug/identity" +) + +// EnsureIdentities walk the graph of operations and make sure that all Identity +// are properly loaded. That is, it replace all the IdentityStub with the full +// Identity, loaded through a Resolver. +func (bug *Bug) EnsureIdentities(resolver identity.Resolver) error { + it := NewOperationIterator(bug) + + for it.Next() { + op := it.Value() + base := op.base() + + if stub, ok := base.Author.(*identity.IdentityStub); ok { + i, err := resolver.ResolveIdentity(stub.Id()) + if err != nil { + return err + } + + base.Author = i + } + } + return nil +} diff --git a/bug/label.go b/bug/label.go index b73222ddb86117724e39c0e4c8e9075e2f0ede01..0ad84f224929f3c6aa9f2bfc7955ba5022049d75 100644 --- a/bug/label.go +++ b/bug/label.go @@ -28,7 +28,7 @@ func (l *Label) UnmarshalGQL(v interface{}) error { // MarshalGQL implements the graphql.Marshaler interface func (l Label) MarshalGQL(w io.Writer) { - w.Write([]byte(`"` + l.String() + `"`)) + _, _ = w.Write([]byte(`"` + l.String() + `"`)) } func (l Label) Validate() error { diff --git a/bug/op_add_comment.go b/bug/op_add_comment.go index 2d6fb21ac23df02265e98252b0d3ace905413e42..9ecef941efbb0d8bd47b568c1e2fdfdd5df37d8d 100644 --- a/bug/op_add_comment.go +++ b/bug/op_add_comment.go @@ -1,10 +1,13 @@ package bug import ( + "encoding/json" "fmt" + "github.com/MichaelMure/git-bug/identity" "github.com/MichaelMure/git-bug/util/git" "github.com/MichaelMure/git-bug/util/text" + "github.com/MichaelMure/git-bug/util/timestamp" ) var _ Operation = &AddCommentOperation{} @@ -12,9 +15,9 @@ var _ Operation = &AddCommentOperation{} // AddCommentOperation will add a new comment in the bug type AddCommentOperation struct { OpBase - Message string `json:"message"` + Message string // TODO: change for a map[string]util.hash to store the filename ? - Files []git.Hash `json:"files"` + Files []git.Hash } func (op *AddCommentOperation) base() *OpBase { @@ -30,7 +33,7 @@ func (op *AddCommentOperation) Apply(snapshot *Snapshot) { Message: op.Message, Author: op.Author, Files: op.Files, - UnixTime: Timestamp(op.UnixTime), + UnixTime: timestamp.Timestamp(op.UnixTime), } snapshot.Comments = append(snapshot.Comments, comment) @@ -65,10 +68,58 @@ func (op *AddCommentOperation) Validate() error { return nil } +// Workaround to avoid the inner OpBase.MarshalJSON overriding the outer op +// MarshalJSON +func (op *AddCommentOperation) MarshalJSON() ([]byte, error) { + base, err := json.Marshal(op.OpBase) + if err != nil { + return nil, err + } + + // revert back to a flat map to be able to add our own fields + var data map[string]interface{} + if err := json.Unmarshal(base, &data); err != nil { + return nil, err + } + + data["message"] = op.Message + data["files"] = op.Files + + return json.Marshal(data) +} + +// Workaround to avoid the inner OpBase.MarshalJSON overriding the outer op +// MarshalJSON +func (op *AddCommentOperation) UnmarshalJSON(data []byte) error { + // Unmarshal OpBase and the op separately + + base := OpBase{} + err := json.Unmarshal(data, &base) + if err != nil { + return err + } + + aux := struct { + Message string `json:"message"` + Files []git.Hash `json:"files"` + }{} + + err = json.Unmarshal(data, &aux) + if err != nil { + return err + } + + op.OpBase = base + op.Message = aux.Message + op.Files = aux.Files + + return nil +} + // Sign post method for gqlgen func (op *AddCommentOperation) IsAuthored() {} -func NewAddCommentOp(author Person, unixTime int64, message string, files []git.Hash) *AddCommentOperation { +func NewAddCommentOp(author identity.Interface, unixTime int64, message string, files []git.Hash) *AddCommentOperation { return &AddCommentOperation{ OpBase: newOpBase(AddCommentOp, author, unixTime), Message: message, @@ -82,11 +133,11 @@ type AddCommentTimelineItem struct { } // Convenience function to apply the operation -func AddComment(b Interface, author Person, unixTime int64, message string) (*AddCommentOperation, error) { +func AddComment(b Interface, author identity.Interface, unixTime int64, message string) (*AddCommentOperation, error) { return AddCommentWithFiles(b, author, unixTime, message, nil) } -func AddCommentWithFiles(b Interface, author Person, unixTime int64, message string, files []git.Hash) (*AddCommentOperation, error) { +func AddCommentWithFiles(b Interface, author identity.Interface, unixTime int64, message string, files []git.Hash) (*AddCommentOperation, error) { addCommentOp := NewAddCommentOp(author, unixTime, message, files) if err := addCommentOp.Validate(); err != nil { return nil, err diff --git a/bug/op_add_comment_test.go b/bug/op_add_comment_test.go new file mode 100644 index 0000000000000000000000000000000000000000..a38d0228e9c4cb91bcfedc4c26c02ca895f57dbc --- /dev/null +++ b/bug/op_add_comment_test.go @@ -0,0 +1,25 @@ +package bug + +import ( + "encoding/json" + "testing" + "time" + + "github.com/MichaelMure/git-bug/identity" + "github.com/stretchr/testify/assert" +) + +func TestAddCommentSerialize(t *testing.T) { + var rene = identity.NewBare("René Descartes", "rene@descartes.fr") + unix := time.Now().Unix() + before := NewAddCommentOp(rene, unix, "message", nil) + + data, err := json.Marshal(before) + assert.NoError(t, err) + + var after AddCommentOperation + err = json.Unmarshal(data, &after) + assert.NoError(t, err) + + assert.Equal(t, before, &after) +} diff --git a/bug/op_create.go b/bug/op_create.go index 3816d8b7887caf6f73736c5d1b37f98b81299753..42d40f71ea76e253022392971091c6953ed65538 100644 --- a/bug/op_create.go +++ b/bug/op_create.go @@ -1,11 +1,14 @@ package bug import ( + "encoding/json" "fmt" "strings" + "github.com/MichaelMure/git-bug/identity" "github.com/MichaelMure/git-bug/util/git" "github.com/MichaelMure/git-bug/util/text" + "github.com/MichaelMure/git-bug/util/timestamp" ) var _ Operation = &CreateOperation{} @@ -13,9 +16,9 @@ var _ Operation = &CreateOperation{} // CreateOperation define the initial creation of a bug type CreateOperation struct { OpBase - Title string `json:"title"` - Message string `json:"message"` - Files []git.Hash `json:"files"` + Title string + Message string + Files []git.Hash } func (op *CreateOperation) base() *OpBase { @@ -32,7 +35,7 @@ func (op *CreateOperation) Apply(snapshot *Snapshot) { comment := Comment{ Message: op.Message, Author: op.Author, - UnixTime: Timestamp(op.UnixTime), + UnixTime: timestamp.Timestamp(op.UnixTime), } snapshot.Comments = []Comment{comment} @@ -81,10 +84,61 @@ func (op *CreateOperation) Validate() error { return nil } +// Workaround to avoid the inner OpBase.MarshalJSON overriding the outer op +// MarshalJSON +func (op *CreateOperation) MarshalJSON() ([]byte, error) { + base, err := json.Marshal(op.OpBase) + if err != nil { + return nil, err + } + + // revert back to a flat map to be able to add our own fields + var data map[string]interface{} + if err := json.Unmarshal(base, &data); err != nil { + return nil, err + } + + data["title"] = op.Title + data["message"] = op.Message + data["files"] = op.Files + + return json.Marshal(data) +} + +// Workaround to avoid the inner OpBase.MarshalJSON overriding the outer op +// MarshalJSON +func (op *CreateOperation) UnmarshalJSON(data []byte) error { + // Unmarshal OpBase and the op separately + + base := OpBase{} + err := json.Unmarshal(data, &base) + if err != nil { + return err + } + + aux := struct { + Title string `json:"title"` + Message string `json:"message"` + Files []git.Hash `json:"files"` + }{} + + err = json.Unmarshal(data, &aux) + if err != nil { + return err + } + + op.OpBase = base + op.Title = aux.Title + op.Message = aux.Message + op.Files = aux.Files + + return nil +} + // Sign post method for gqlgen func (op *CreateOperation) IsAuthored() {} -func NewCreateOp(author Person, unixTime int64, title, message string, files []git.Hash) *CreateOperation { +func NewCreateOp(author identity.Interface, unixTime int64, title, message string, files []git.Hash) *CreateOperation { return &CreateOperation{ OpBase: newOpBase(CreateOp, author, unixTime), Title: title, @@ -99,11 +153,11 @@ type CreateTimelineItem struct { } // Convenience function to apply the operation -func Create(author Person, unixTime int64, title, message string) (*Bug, *CreateOperation, error) { +func Create(author identity.Interface, unixTime int64, title, message string) (*Bug, *CreateOperation, error) { return CreateWithFiles(author, unixTime, title, message, nil) } -func CreateWithFiles(author Person, unixTime int64, title, message string, files []git.Hash) (*Bug, *CreateOperation, error) { +func CreateWithFiles(author identity.Interface, unixTime int64, title, message string, files []git.Hash) (*Bug, *CreateOperation, error) { newBug := NewBug() createOp := NewCreateOp(author, unixTime, title, message, files) diff --git a/bug/op_create_test.go b/bug/op_create_test.go index d74051ecfc4733ec04d51f6d943af6eea164dee0..92ac191dc265d62e96bf972eafeec3dedd5955d2 100644 --- a/bug/op_create_test.go +++ b/bug/op_create_test.go @@ -1,20 +1,19 @@ package bug import ( + "encoding/json" "testing" "time" - "github.com/go-test/deep" + "github.com/MichaelMure/git-bug/identity" + "github.com/MichaelMure/git-bug/util/timestamp" + "github.com/stretchr/testify/assert" ) func TestCreate(t *testing.T) { snapshot := Snapshot{} - var rene = Person{ - Name: "René Descartes", - Email: "rene@descartes.fr", - } - + rene := identity.NewBare("René Descartes", "rene@descartes.fr") unix := time.Now().Unix() create := NewCreateOp(rene, unix, "title", "message", nil) @@ -22,11 +21,9 @@ func TestCreate(t *testing.T) { create.Apply(&snapshot) hash, err := create.Hash() - if err != nil { - t.Fatal(err) - } + assert.NoError(t, err) - comment := Comment{Author: rene, Message: "message", UnixTime: Timestamp(create.UnixTime)} + comment := Comment{Author: rene, Message: "message", UnixTime: timestamp.Timestamp(create.UnixTime)} expected := Snapshot{ Title: "title", @@ -42,8 +39,20 @@ func TestCreate(t *testing.T) { }, } - deep.CompareUnexportedFields = true - if diff := deep.Equal(snapshot, expected); diff != nil { - t.Fatal(diff) - } + assert.Equal(t, expected, snapshot) +} + +func TestCreateSerialize(t *testing.T) { + var rene = identity.NewBare("René Descartes", "rene@descartes.fr") + unix := time.Now().Unix() + before := NewCreateOp(rene, unix, "title", "message", nil) + + data, err := json.Marshal(before) + assert.NoError(t, err) + + var after CreateOperation + err = json.Unmarshal(data, &after) + assert.NoError(t, err) + + assert.Equal(t, before, &after) } diff --git a/bug/op_edit_comment.go b/bug/op_edit_comment.go index bc87310a1e999a6016d39105870fa56f0a45b386..527b7440dd1e06e2e514c25dc85ed47787d3962f 100644 --- a/bug/op_edit_comment.go +++ b/bug/op_edit_comment.go @@ -1,8 +1,12 @@ package bug import ( + "encoding/json" "fmt" + "github.com/MichaelMure/git-bug/identity" + "github.com/MichaelMure/git-bug/util/timestamp" + "github.com/MichaelMure/git-bug/util/git" "github.com/MichaelMure/git-bug/util/text" ) @@ -12,9 +16,9 @@ var _ Operation = &EditCommentOperation{} // EditCommentOperation will change a comment in the bug type EditCommentOperation struct { OpBase - Target git.Hash `json:"target"` - Message string `json:"message"` - Files []git.Hash `json:"files"` + Target git.Hash + Message string + Files []git.Hash } func (op *EditCommentOperation) base() *OpBase { @@ -55,7 +59,7 @@ func (op *EditCommentOperation) Apply(snapshot *Snapshot) { comment := Comment{ Message: op.Message, Files: op.Files, - UnixTime: Timestamp(op.UnixTime), + UnixTime: timestamp.Timestamp(op.UnixTime), } switch target.(type) { @@ -92,10 +96,61 @@ func (op *EditCommentOperation) Validate() error { return nil } +// Workaround to avoid the inner OpBase.MarshalJSON overriding the outer op +// MarshalJSON +func (op *EditCommentOperation) MarshalJSON() ([]byte, error) { + base, err := json.Marshal(op.OpBase) + if err != nil { + return nil, err + } + + // revert back to a flat map to be able to add our own fields + var data map[string]interface{} + if err := json.Unmarshal(base, &data); err != nil { + return nil, err + } + + data["target"] = op.Target + data["message"] = op.Message + data["files"] = op.Files + + return json.Marshal(data) +} + +// Workaround to avoid the inner OpBase.MarshalJSON overriding the outer op +// MarshalJSON +func (op *EditCommentOperation) UnmarshalJSON(data []byte) error { + // Unmarshal OpBase and the op separately + + base := OpBase{} + err := json.Unmarshal(data, &base) + if err != nil { + return err + } + + aux := struct { + Target git.Hash `json:"target"` + Message string `json:"message"` + Files []git.Hash `json:"files"` + }{} + + err = json.Unmarshal(data, &aux) + if err != nil { + return err + } + + op.OpBase = base + op.Target = aux.Target + op.Message = aux.Message + op.Files = aux.Files + + return nil +} + // Sign post method for gqlgen func (op *EditCommentOperation) IsAuthored() {} -func NewEditCommentOp(author Person, unixTime int64, target git.Hash, message string, files []git.Hash) *EditCommentOperation { +func NewEditCommentOp(author identity.Interface, unixTime int64, target git.Hash, message string, files []git.Hash) *EditCommentOperation { return &EditCommentOperation{ OpBase: newOpBase(EditCommentOp, author, unixTime), Target: target, @@ -105,11 +160,11 @@ func NewEditCommentOp(author Person, unixTime int64, target git.Hash, message st } // Convenience function to apply the operation -func EditComment(b Interface, author Person, unixTime int64, target git.Hash, message string) (*EditCommentOperation, error) { +func EditComment(b Interface, author identity.Interface, unixTime int64, target git.Hash, message string) (*EditCommentOperation, error) { return EditCommentWithFiles(b, author, unixTime, target, message, nil) } -func EditCommentWithFiles(b Interface, author Person, unixTime int64, target git.Hash, message string, files []git.Hash) (*EditCommentOperation, error) { +func EditCommentWithFiles(b Interface, author identity.Interface, unixTime int64, target git.Hash, message string, files []git.Hash) (*EditCommentOperation, error) { editCommentOp := NewEditCommentOp(author, unixTime, target, message, files) if err := editCommentOp.Validate(); err != nil { return nil, err diff --git a/bug/op_edit_comment_test.go b/bug/op_edit_comment_test.go index 71a7dda287265a3ac7fa90260b4a154800ea3c9d..72f8a1681c34bc151e1020ccd2d328fcaeb5f72a 100644 --- a/bug/op_edit_comment_test.go +++ b/bug/op_edit_comment_test.go @@ -1,37 +1,32 @@ package bug import ( + "encoding/json" "testing" "time" - "gotest.tools/assert" + "github.com/MichaelMure/git-bug/identity" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestEdit(t *testing.T) { snapshot := Snapshot{} - var rene = Person{ - Name: "René Descartes", - Email: "rene@descartes.fr", - } - + rene := identity.NewBare("René Descartes", "rene@descartes.fr") unix := time.Now().Unix() create := NewCreateOp(rene, unix, "title", "create", nil) create.Apply(&snapshot) hash1, err := create.Hash() - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) comment := NewAddCommentOp(rene, unix, "comment", nil) comment.Apply(&snapshot) hash2, err := comment.Hash() - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) edit := NewEditCommentOp(rene, unix, hash1, "create edited", nil) edit.Apply(&snapshot) @@ -51,3 +46,18 @@ func TestEdit(t *testing.T) { assert.Equal(t, snapshot.Comments[0].Message, "create edited") assert.Equal(t, snapshot.Comments[1].Message, "comment edited") } + +func TestEditCommentSerialize(t *testing.T) { + var rene = identity.NewBare("René Descartes", "rene@descartes.fr") + unix := time.Now().Unix() + before := NewEditCommentOp(rene, unix, "target", "message", nil) + + data, err := json.Marshal(before) + assert.NoError(t, err) + + var after EditCommentOperation + err = json.Unmarshal(data, &after) + assert.NoError(t, err) + + assert.Equal(t, before, &after) +} diff --git a/bug/op_label_change.go b/bug/op_label_change.go index d7aab06b9749fa1147dcc4d8ec4fff77cbb9d740..84542b6e54534cee88087f19462cc1d8b10f44f3 100644 --- a/bug/op_label_change.go +++ b/bug/op_label_change.go @@ -1,9 +1,13 @@ package bug import ( + "encoding/json" "fmt" "sort" + "github.com/MichaelMure/git-bug/identity" + "github.com/MichaelMure/git-bug/util/timestamp" + "github.com/MichaelMure/git-bug/util/git" "github.com/pkg/errors" ) @@ -13,8 +17,8 @@ var _ Operation = &LabelChangeOperation{} // LabelChangeOperation define a Bug operation to add or remove labels type LabelChangeOperation struct { OpBase - Added []Label `json:"added"` - Removed []Label `json:"removed"` + Added []Label + Removed []Label } func (op *LabelChangeOperation) base() *OpBase { @@ -65,7 +69,7 @@ AddLoop: item := &LabelChangeTimelineItem{ hash: hash, Author: op.Author, - UnixTime: Timestamp(op.UnixTime), + UnixTime: timestamp.Timestamp(op.UnixTime), Added: op.Added, Removed: op.Removed, } @@ -97,10 +101,58 @@ func (op *LabelChangeOperation) Validate() error { return nil } +// Workaround to avoid the inner OpBase.MarshalJSON overriding the outer op +// MarshalJSON +func (op *LabelChangeOperation) MarshalJSON() ([]byte, error) { + base, err := json.Marshal(op.OpBase) + if err != nil { + return nil, err + } + + // revert back to a flat map to be able to add our own fields + var data map[string]interface{} + if err := json.Unmarshal(base, &data); err != nil { + return nil, err + } + + data["added"] = op.Added + data["removed"] = op.Removed + + return json.Marshal(data) +} + +// Workaround to avoid the inner OpBase.MarshalJSON overriding the outer op +// MarshalJSON +func (op *LabelChangeOperation) UnmarshalJSON(data []byte) error { + // Unmarshal OpBase and the op separately + + base := OpBase{} + err := json.Unmarshal(data, &base) + if err != nil { + return err + } + + aux := struct { + Added []Label `json:"added"` + Removed []Label `json:"removed"` + }{} + + err = json.Unmarshal(data, &aux) + if err != nil { + return err + } + + op.OpBase = base + op.Added = aux.Added + op.Removed = aux.Removed + + return nil +} + // Sign post method for gqlgen func (op *LabelChangeOperation) IsAuthored() {} -func NewLabelChangeOperation(author Person, unixTime int64, added, removed []Label) *LabelChangeOperation { +func NewLabelChangeOperation(author identity.Interface, unixTime int64, added, removed []Label) *LabelChangeOperation { return &LabelChangeOperation{ OpBase: newOpBase(LabelChangeOp, author, unixTime), Added: added, @@ -110,8 +162,8 @@ func NewLabelChangeOperation(author Person, unixTime int64, added, removed []Lab type LabelChangeTimelineItem struct { hash git.Hash - Author Person - UnixTime Timestamp + Author identity.Interface + UnixTime timestamp.Timestamp Added []Label Removed []Label } @@ -121,7 +173,7 @@ func (l LabelChangeTimelineItem) Hash() git.Hash { } // ChangeLabels is a convenience function to apply the operation -func ChangeLabels(b Interface, author Person, unixTime int64, add, remove []string) ([]LabelChangeResult, *LabelChangeOperation, error) { +func ChangeLabels(b Interface, author identity.Interface, unixTime int64, add, remove []string) ([]LabelChangeResult, *LabelChangeOperation, error) { var added, removed []Label var results []LabelChangeResult diff --git a/bug/op_label_change_test.go b/bug/op_label_change_test.go new file mode 100644 index 0000000000000000000000000000000000000000..f5550b7296f08626090120ff9ba0c3e887233409 --- /dev/null +++ b/bug/op_label_change_test.go @@ -0,0 +1,25 @@ +package bug + +import ( + "encoding/json" + "testing" + "time" + + "github.com/MichaelMure/git-bug/identity" + "github.com/stretchr/testify/assert" +) + +func TestLabelChangeSerialize(t *testing.T) { + var rene = identity.NewBare("René Descartes", "rene@descartes.fr") + unix := time.Now().Unix() + before := NewLabelChangeOperation(rene, unix, []Label{"added"}, []Label{"removed"}) + + data, err := json.Marshal(before) + assert.NoError(t, err) + + var after LabelChangeOperation + err = json.Unmarshal(data, &after) + assert.NoError(t, err) + + assert.Equal(t, before, &after) +} diff --git a/bug/op_noop.go b/bug/op_noop.go index ac898ddece4910c55778c207e15fd1665068f362..3cd9f39adf7b9659cb3ad17cf2fc112d663c1717 100644 --- a/bug/op_noop.go +++ b/bug/op_noop.go @@ -1,12 +1,17 @@ package bug -import "github.com/MichaelMure/git-bug/util/git" +import ( + "encoding/json" + + "github.com/MichaelMure/git-bug/identity" + "github.com/MichaelMure/git-bug/util/git" +) var _ Operation = &NoOpOperation{} // NoOpOperation is an operation that does not change the bug state. It can // however be used to store arbitrary metadata in the bug history, for example -// to support a bridge feature +// to support a bridge feature. type NoOpOperation struct { OpBase } @@ -27,17 +32,57 @@ func (op *NoOpOperation) Validate() error { return opBaseValidate(op, NoOpOp) } +// Workaround to avoid the inner OpBase.MarshalJSON overriding the outer op +// MarshalJSON +func (op *NoOpOperation) MarshalJSON() ([]byte, error) { + base, err := json.Marshal(op.OpBase) + if err != nil { + return nil, err + } + + // revert back to a flat map to be able to add our own fields + var data map[string]interface{} + if err := json.Unmarshal(base, &data); err != nil { + return nil, err + } + + return json.Marshal(data) +} + +// Workaround to avoid the inner OpBase.MarshalJSON overriding the outer op +// MarshalJSON +func (op *NoOpOperation) UnmarshalJSON(data []byte) error { + // Unmarshal OpBase and the op separately + + base := OpBase{} + err := json.Unmarshal(data, &base) + if err != nil { + return err + } + + aux := struct{}{} + + err = json.Unmarshal(data, &aux) + if err != nil { + return err + } + + op.OpBase = base + + return nil +} + // Sign post method for gqlgen func (op *NoOpOperation) IsAuthored() {} -func NewNoOpOp(author Person, unixTime int64) *NoOpOperation { +func NewNoOpOp(author identity.Interface, unixTime int64) *NoOpOperation { return &NoOpOperation{ OpBase: newOpBase(NoOpOp, author, unixTime), } } // Convenience function to apply the operation -func NoOp(b Interface, author Person, unixTime int64, metadata map[string]string) (*NoOpOperation, error) { +func NoOp(b Interface, author identity.Interface, unixTime int64, metadata map[string]string) (*NoOpOperation, error) { op := NewNoOpOp(author, unixTime) for key, value := range metadata { diff --git a/bug/op_noop_test.go b/bug/op_noop_test.go new file mode 100644 index 0000000000000000000000000000000000000000..385bc9145d8bf28e6457d3135eb4d47b86226be0 --- /dev/null +++ b/bug/op_noop_test.go @@ -0,0 +1,25 @@ +package bug + +import ( + "encoding/json" + "testing" + "time" + + "github.com/MichaelMure/git-bug/identity" + "github.com/stretchr/testify/assert" +) + +func TestNoopSerialize(t *testing.T) { + var rene = identity.NewBare("René Descartes", "rene@descartes.fr") + unix := time.Now().Unix() + before := NewNoOpOp(rene, unix) + + data, err := json.Marshal(before) + assert.NoError(t, err) + + var after NoOpOperation + err = json.Unmarshal(data, &after) + assert.NoError(t, err) + + assert.Equal(t, before, &after) +} diff --git a/bug/op_set_metadata.go b/bug/op_set_metadata.go index aac81f3b69ff30d2bb40cdba69aedf25eb320f97..7616a59126152e9118f76cde634392c004ee0c51 100644 --- a/bug/op_set_metadata.go +++ b/bug/op_set_metadata.go @@ -1,13 +1,19 @@ package bug -import "github.com/MichaelMure/git-bug/util/git" +import ( + "encoding/json" + "fmt" + + "github.com/MichaelMure/git-bug/identity" + "github.com/MichaelMure/git-bug/util/git" +) var _ Operation = &SetMetadataOperation{} type SetMetadataOperation struct { OpBase - Target git.Hash `json:"target"` - NewMetadata map[string]string `json:"new_metadata"` + Target git.Hash + NewMetadata map[string]string } func (op *SetMetadataOperation) base() *OpBase { @@ -50,13 +56,65 @@ func (op *SetMetadataOperation) Validate() error { return err } + if !op.Target.IsValid() { + return fmt.Errorf("target hash is invalid") + } + + return nil +} + +// Workaround to avoid the inner OpBase.MarshalJSON overriding the outer op +// MarshalJSON +func (op *SetMetadataOperation) MarshalJSON() ([]byte, error) { + base, err := json.Marshal(op.OpBase) + if err != nil { + return nil, err + } + + // revert back to a flat map to be able to add our own fields + var data map[string]interface{} + if err := json.Unmarshal(base, &data); err != nil { + return nil, err + } + + data["target"] = op.Target + data["new_metadata"] = op.NewMetadata + + return json.Marshal(data) +} + +// Workaround to avoid the inner OpBase.MarshalJSON overriding the outer op +// MarshalJSON +func (op *SetMetadataOperation) UnmarshalJSON(data []byte) error { + // Unmarshal OpBase and the op separately + + base := OpBase{} + err := json.Unmarshal(data, &base) + if err != nil { + return err + } + + aux := struct { + Target git.Hash `json:"target"` + NewMetadata map[string]string `json:"new_metadata"` + }{} + + err = json.Unmarshal(data, &aux) + if err != nil { + return err + } + + op.OpBase = base + op.Target = aux.Target + op.NewMetadata = aux.NewMetadata + return nil } // Sign post method for gqlgen func (op *SetMetadataOperation) IsAuthored() {} -func NewSetMetadataOp(author Person, unixTime int64, target git.Hash, newMetadata map[string]string) *SetMetadataOperation { +func NewSetMetadataOp(author identity.Interface, unixTime int64, target git.Hash, newMetadata map[string]string) *SetMetadataOperation { return &SetMetadataOperation{ OpBase: newOpBase(SetMetadataOp, author, unixTime), Target: target, @@ -65,7 +123,7 @@ func NewSetMetadataOp(author Person, unixTime int64, target git.Hash, newMetadat } // Convenience function to apply the operation -func SetMetadata(b Interface, author Person, unixTime int64, target git.Hash, newMetadata map[string]string) (*SetMetadataOperation, error) { +func SetMetadata(b Interface, author identity.Interface, unixTime int64, target git.Hash, newMetadata map[string]string) (*SetMetadataOperation, error) { SetMetadataOp := NewSetMetadataOp(author, unixTime, target, newMetadata) if err := SetMetadataOp.Validate(); err != nil { return nil, err diff --git a/bug/op_set_metadata_test.go b/bug/op_set_metadata_test.go index 068e2bb0bf63364dd41de53b839279c3a50eb6be..a7f9f313a5e2831b3e4eb078b76f0b486ab0f608 100644 --- a/bug/op_set_metadata_test.go +++ b/bug/op_set_metadata_test.go @@ -1,20 +1,19 @@ package bug import ( + "encoding/json" "testing" "time" + "github.com/MichaelMure/git-bug/identity" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestSetMetadata(t *testing.T) { snapshot := Snapshot{} - var rene = Person{ - Name: "René Descartes", - Email: "rene@descartes.fr", - } - + rene := identity.NewBare("René Descartes", "rene@descartes.fr") unix := time.Now().Unix() create := NewCreateOp(rene, unix, "title", "create", nil) @@ -23,9 +22,7 @@ func TestSetMetadata(t *testing.T) { snapshot.Operations = append(snapshot.Operations, create) hash1, err := create.Hash() - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) comment := NewAddCommentOp(rene, unix, "comment", nil) comment.SetMetadata("key2", "value2") @@ -33,9 +30,7 @@ func TestSetMetadata(t *testing.T) { snapshot.Operations = append(snapshot.Operations, comment) hash2, err := comment.Hash() - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) op1 := NewSetMetadataOp(rene, unix, hash1, map[string]string{ "key": "override", @@ -96,3 +91,21 @@ func TestSetMetadata(t *testing.T) { assert.Equal(t, commentMetadata["key2"], "value2") assert.Equal(t, commentMetadata["key3"], "value3") } + +func TestSetMetadataSerialize(t *testing.T) { + var rene = identity.NewBare("René Descartes", "rene@descartes.fr") + unix := time.Now().Unix() + before := NewSetMetadataOp(rene, unix, "message", map[string]string{ + "key1": "value1", + "key2": "value2", + }) + + data, err := json.Marshal(before) + assert.NoError(t, err) + + var after SetMetadataOperation + err = json.Unmarshal(data, &after) + assert.NoError(t, err) + + assert.Equal(t, before, &after) +} diff --git a/bug/op_set_status.go b/bug/op_set_status.go index 54f476cb48b9386e294a776b03151b9333efbf87..0105d78d9b40340feb417a37e6feab951ab4bc34 100644 --- a/bug/op_set_status.go +++ b/bug/op_set_status.go @@ -1,7 +1,11 @@ package bug import ( + "encoding/json" + + "github.com/MichaelMure/git-bug/identity" "github.com/MichaelMure/git-bug/util/git" + "github.com/MichaelMure/git-bug/util/timestamp" "github.com/pkg/errors" ) @@ -10,7 +14,7 @@ var _ Operation = &SetStatusOperation{} // SetStatusOperation will change the status of a bug type SetStatusOperation struct { OpBase - Status Status `json:"status"` + Status Status } func (op *SetStatusOperation) base() *OpBase { @@ -34,7 +38,7 @@ func (op *SetStatusOperation) Apply(snapshot *Snapshot) { item := &SetStatusTimelineItem{ hash: hash, Author: op.Author, - UnixTime: Timestamp(op.UnixTime), + UnixTime: timestamp.Timestamp(op.UnixTime), Status: op.Status, } @@ -53,10 +57,55 @@ func (op *SetStatusOperation) Validate() error { return nil } +// Workaround to avoid the inner OpBase.MarshalJSON overriding the outer op +// MarshalJSON +func (op *SetStatusOperation) MarshalJSON() ([]byte, error) { + base, err := json.Marshal(op.OpBase) + if err != nil { + return nil, err + } + + // revert back to a flat map to be able to add our own fields + var data map[string]interface{} + if err := json.Unmarshal(base, &data); err != nil { + return nil, err + } + + data["status"] = op.Status + + return json.Marshal(data) +} + +// Workaround to avoid the inner OpBase.MarshalJSON overriding the outer op +// MarshalJSON +func (op *SetStatusOperation) UnmarshalJSON(data []byte) error { + // Unmarshal OpBase and the op separately + + base := OpBase{} + err := json.Unmarshal(data, &base) + if err != nil { + return err + } + + aux := struct { + Status Status `json:"status"` + }{} + + err = json.Unmarshal(data, &aux) + if err != nil { + return err + } + + op.OpBase = base + op.Status = aux.Status + + return nil +} + // Sign post method for gqlgen func (op *SetStatusOperation) IsAuthored() {} -func NewSetStatusOp(author Person, unixTime int64, status Status) *SetStatusOperation { +func NewSetStatusOp(author identity.Interface, unixTime int64, status Status) *SetStatusOperation { return &SetStatusOperation{ OpBase: newOpBase(SetStatusOp, author, unixTime), Status: status, @@ -65,8 +114,8 @@ func NewSetStatusOp(author Person, unixTime int64, status Status) *SetStatusOper type SetStatusTimelineItem struct { hash git.Hash - Author Person - UnixTime Timestamp + Author identity.Interface + UnixTime timestamp.Timestamp Status Status } @@ -75,7 +124,7 @@ func (s SetStatusTimelineItem) Hash() git.Hash { } // Convenience function to apply the operation -func Open(b Interface, author Person, unixTime int64) (*SetStatusOperation, error) { +func Open(b Interface, author identity.Interface, unixTime int64) (*SetStatusOperation, error) { op := NewSetStatusOp(author, unixTime, OpenStatus) if err := op.Validate(); err != nil { return nil, err @@ -85,7 +134,7 @@ func Open(b Interface, author Person, unixTime int64) (*SetStatusOperation, erro } // Convenience function to apply the operation -func Close(b Interface, author Person, unixTime int64) (*SetStatusOperation, error) { +func Close(b Interface, author identity.Interface, unixTime int64) (*SetStatusOperation, error) { op := NewSetStatusOp(author, unixTime, ClosedStatus) if err := op.Validate(); err != nil { return nil, err diff --git a/bug/op_set_status_test.go b/bug/op_set_status_test.go new file mode 100644 index 0000000000000000000000000000000000000000..2506b94787e80392b4581723873508ed22c1603c --- /dev/null +++ b/bug/op_set_status_test.go @@ -0,0 +1,25 @@ +package bug + +import ( + "encoding/json" + "testing" + "time" + + "github.com/MichaelMure/git-bug/identity" + "github.com/stretchr/testify/assert" +) + +func TestSetStatusSerialize(t *testing.T) { + var rene = identity.NewBare("René Descartes", "rene@descartes.fr") + unix := time.Now().Unix() + before := NewSetStatusOp(rene, unix, ClosedStatus) + + data, err := json.Marshal(before) + assert.NoError(t, err) + + var after SetStatusOperation + err = json.Unmarshal(data, &after) + assert.NoError(t, err) + + assert.Equal(t, before, &after) +} diff --git a/bug/op_set_title.go b/bug/op_set_title.go index b631ca181da9c84d601893d830df9ca2d43ba675..084838cb75386b4e3a22e89353b18ab5e1683c42 100644 --- a/bug/op_set_title.go +++ b/bug/op_set_title.go @@ -1,9 +1,13 @@ package bug import ( + "encoding/json" "fmt" "strings" + "github.com/MichaelMure/git-bug/identity" + "github.com/MichaelMure/git-bug/util/timestamp" + "github.com/MichaelMure/git-bug/util/git" "github.com/MichaelMure/git-bug/util/text" ) @@ -13,8 +17,8 @@ var _ Operation = &SetTitleOperation{} // SetTitleOperation will change the title of a bug type SetTitleOperation struct { OpBase - Title string `json:"title"` - Was string `json:"was"` + Title string + Was string } func (op *SetTitleOperation) base() *OpBase { @@ -38,7 +42,7 @@ func (op *SetTitleOperation) Apply(snapshot *Snapshot) { item := &SetTitleTimelineItem{ hash: hash, Author: op.Author, - UnixTime: Timestamp(op.UnixTime), + UnixTime: timestamp.Timestamp(op.UnixTime), Title: op.Title, Was: op.Was, } @@ -74,10 +78,58 @@ func (op *SetTitleOperation) Validate() error { return nil } +// Workaround to avoid the inner OpBase.MarshalJSON overriding the outer op +// MarshalJSON +func (op *SetTitleOperation) MarshalJSON() ([]byte, error) { + base, err := json.Marshal(op.OpBase) + if err != nil { + return nil, err + } + + // revert back to a flat map to be able to add our own fields + var data map[string]interface{} + if err := json.Unmarshal(base, &data); err != nil { + return nil, err + } + + data["title"] = op.Title + data["was"] = op.Was + + return json.Marshal(data) +} + +// Workaround to avoid the inner OpBase.MarshalJSON overriding the outer op +// MarshalJSON +func (op *SetTitleOperation) UnmarshalJSON(data []byte) error { + // Unmarshal OpBase and the op separately + + base := OpBase{} + err := json.Unmarshal(data, &base) + if err != nil { + return err + } + + aux := struct { + Title string `json:"title"` + Was string `json:"was"` + }{} + + err = json.Unmarshal(data, &aux) + if err != nil { + return err + } + + op.OpBase = base + op.Title = aux.Title + op.Was = aux.Was + + return nil +} + // Sign post method for gqlgen func (op *SetTitleOperation) IsAuthored() {} -func NewSetTitleOp(author Person, unixTime int64, title string, was string) *SetTitleOperation { +func NewSetTitleOp(author identity.Interface, unixTime int64, title string, was string) *SetTitleOperation { return &SetTitleOperation{ OpBase: newOpBase(SetTitleOp, author, unixTime), Title: title, @@ -87,8 +139,8 @@ func NewSetTitleOp(author Person, unixTime int64, title string, was string) *Set type SetTitleTimelineItem struct { hash git.Hash - Author Person - UnixTime Timestamp + Author identity.Interface + UnixTime timestamp.Timestamp Title string Was string } @@ -98,7 +150,7 @@ func (s SetTitleTimelineItem) Hash() git.Hash { } // Convenience function to apply the operation -func SetTitle(b Interface, author Person, unixTime int64, title string) (*SetTitleOperation, error) { +func SetTitle(b Interface, author identity.Interface, unixTime int64, title string) (*SetTitleOperation, error) { it := NewOperationIterator(b) var lastTitleOp Operation diff --git a/bug/op_set_title_test.go b/bug/op_set_title_test.go new file mode 100644 index 0000000000000000000000000000000000000000..1f7305962299cb80aa81da0eeb95bde9e71ca96f --- /dev/null +++ b/bug/op_set_title_test.go @@ -0,0 +1,25 @@ +package bug + +import ( + "encoding/json" + "testing" + "time" + + "github.com/MichaelMure/git-bug/identity" + "github.com/stretchr/testify/assert" +) + +func TestSetTitleSerialize(t *testing.T) { + var rene = identity.NewBare("René Descartes", "rene@descartes.fr") + unix := time.Now().Unix() + before := NewSetTitleOp(rene, unix, "title", "was") + + data, err := json.Marshal(before) + assert.NoError(t, err) + + var after SetTitleOperation + err = json.Unmarshal(data, &after) + assert.NoError(t, err) + + assert.Equal(t, before, &after) +} diff --git a/bug/operation.go b/bug/operation.go index 592b5616ea427bc9f79cd820427c6a5a3169b347..cc5b0007504d9ba5eb01761502f00b9f28e38598 100644 --- a/bug/operation.go +++ b/bug/operation.go @@ -6,6 +6,8 @@ import ( "fmt" "time" + "github.com/MichaelMure/git-bug/identity" + "github.com/MichaelMure/git-bug/util/git" "github.com/pkg/errors" ) @@ -76,19 +78,19 @@ func hashOperation(op Operation) (git.Hash, error) { // OpBase implement the common code for all operations type OpBase struct { - OperationType OperationType `json:"type"` - Author Person `json:"author"` - UnixTime int64 `json:"timestamp"` - Metadata map[string]string `json:"metadata,omitempty"` + OperationType OperationType + Author identity.Interface + UnixTime int64 + Metadata map[string]string // Not serialized. Store the op's hash in memory. hash git.Hash - // Not serialized. Store the extra metadata compiled from SetMetadataOperation - // in memory. + // Not serialized. Store the extra metadata in memory, + // compiled from SetMetadataOperation. extraMetadata map[string]string } // newOpBase is the constructor for an OpBase -func newOpBase(opType OperationType, author Person, unixTime int64) OpBase { +func newOpBase(opType OperationType, author identity.Interface, unixTime int64) OpBase { return OpBase{ OperationType: opType, Author: author, @@ -96,6 +98,46 @@ func newOpBase(opType OperationType, author Person, unixTime int64) OpBase { } } +func (op OpBase) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + OperationType OperationType `json:"type"` + Author identity.Interface `json:"author"` + UnixTime int64 `json:"timestamp"` + Metadata map[string]string `json:"metadata,omitempty"` + }{ + OperationType: op.OperationType, + Author: op.Author, + UnixTime: op.UnixTime, + Metadata: op.Metadata, + }) +} + +func (op *OpBase) UnmarshalJSON(data []byte) error { + aux := struct { + OperationType OperationType `json:"type"` + Author json.RawMessage `json:"author"` + UnixTime int64 `json:"timestamp"` + Metadata map[string]string `json:"metadata,omitempty"` + }{} + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + // delegate the decoding of the identity + author, err := identity.UnmarshalJSON(aux.Author) + if err != nil { + return err + } + + op.OperationType = aux.OperationType + op.Author = author + op.UnixTime = aux.UnixTime + op.Metadata = aux.Metadata + + return nil +} + // Time return the time when the operation was added func (op *OpBase) Time() time.Time { return time.Unix(op.UnixTime, 0) @@ -117,14 +159,14 @@ func opBaseValidate(op Operation, opType OperationType) error { return fmt.Errorf("incorrect operation type (expected: %v, actual: %v)", opType, op.base().OperationType) } - if _, err := op.Hash(); err != nil { - return errors.Wrap(err, "op is not serializable") - } - if op.GetUnixTime() == 0 { return fmt.Errorf("time not set") } + if op.base().Author == nil { + return fmt.Errorf("author not set") + } + if err := op.base().Author.Validate(); err != nil { return errors.Wrap(err, "author") } diff --git a/bug/operation_iterator_test.go b/bug/operation_iterator_test.go index 506cc94fa4a2d527e7f8a982bb914bc630e4400c..b922bec1691762d9ab4c5158965cff8147fe0427 100644 --- a/bug/operation_iterator_test.go +++ b/bug/operation_iterator_test.go @@ -1,29 +1,39 @@ package bug import ( + "github.com/MichaelMure/git-bug/identity" "github.com/MichaelMure/git-bug/repository" + "github.com/stretchr/testify/assert" + "testing" "time" ) -var ( - rene = Person{ - Name: "René Descartes", - Email: "rene@descartes.fr", - } +func ExampleOperationIterator() { + b := NewBug() - unix = time.Now().Unix() + // add operations - createOp = NewCreateOp(rene, unix, "title", "message", nil) - setTitleOp = NewSetTitleOp(rene, unix, "title2", "title1") - addCommentOp = NewAddCommentOp(rene, unix, "message2", nil) - setStatusOp = NewSetStatusOp(rene, unix, ClosedStatus) - labelChangeOp = NewLabelChangeOperation(rene, unix, []Label{"added"}, []Label{"removed"}) -) + it := NewOperationIterator(b) + + for it.Next() { + // do something with each operations + _ = it.Value() + } +} func TestOpIterator(t *testing.T) { mockRepo := repository.NewMockRepoForTest() + rene := identity.NewIdentity("René Descartes", "rene@descartes.fr") + unix := time.Now().Unix() + + createOp := NewCreateOp(rene, unix, "title", "message", nil) + setTitleOp := NewSetTitleOp(rene, unix, "title2", "title1") + addCommentOp := NewAddCommentOp(rene, unix, "message2", nil) + setStatusOp := NewSetStatusOp(rene, unix, ClosedStatus) + labelChangeOp := NewLabelChangeOperation(rene, unix, []Label{"added"}, []Label{"removed"}) + bug1 := NewBug() // first pack @@ -32,13 +42,15 @@ func TestOpIterator(t *testing.T) { bug1.Append(addCommentOp) bug1.Append(setStatusOp) bug1.Append(labelChangeOp) - bug1.Commit(mockRepo) + err := bug1.Commit(mockRepo) + assert.NoError(t, err) // second pack bug1.Append(setTitleOp) bug1.Append(setTitleOp) bug1.Append(setTitleOp) - bug1.Commit(mockRepo) + err = bug1.Commit(mockRepo) + assert.NoError(t, err) // staging bug1.Append(setTitleOp) diff --git a/bug/operation_pack.go b/bug/operation_pack.go index f33d94bf0925eae8edcb53e021e068ff915f2c63..5f3e9da8a10881eb846796fe339d2cdab2ffb7c6 100644 --- a/bug/operation_pack.go +++ b/bug/operation_pack.go @@ -20,7 +20,7 @@ const formatVersion = 1 type OperationPack struct { Operations []Operation - // Private field so not serialized by gob + // Private field so not serialized commitHash git.Hash } @@ -57,6 +57,7 @@ func (opp *OperationPack) UnmarshalJSON(data []byte) error { return err } + // delegate to specialized unmarshal function op, err := opp.unmarshalOp(raw, t.OperationType) if err != nil { return err @@ -73,28 +74,36 @@ func (opp *OperationPack) UnmarshalJSON(data []byte) error { func (opp *OperationPack) unmarshalOp(raw []byte, _type OperationType) (Operation, error) { switch _type { + case AddCommentOp: + op := &AddCommentOperation{} + err := json.Unmarshal(raw, &op) + return op, err case CreateOp: op := &CreateOperation{} err := json.Unmarshal(raw, &op) return op, err - case SetTitleOp: - op := &SetTitleOperation{} + case EditCommentOp: + op := &EditCommentOperation{} err := json.Unmarshal(raw, &op) return op, err - case AddCommentOp: - op := &AddCommentOperation{} + case LabelChangeOp: + op := &LabelChangeOperation{} err := json.Unmarshal(raw, &op) return op, err - case SetStatusOp: - op := &SetStatusOperation{} + case NoOpOp: + op := &NoOpOperation{} err := json.Unmarshal(raw, &op) return op, err - case LabelChangeOp: - op := &LabelChangeOperation{} + case SetMetadataOp: + op := &SetMetadataOperation{} err := json.Unmarshal(raw, &op) return op, err - case EditCommentOp: - op := &EditCommentOperation{} + case SetStatusOp: + op := &SetStatusOperation{} + err := json.Unmarshal(raw, &op) + return op, err + case SetTitleOp: + op := &SetTitleOperation{} err := json.Unmarshal(raw, &op) return op, err default: @@ -129,7 +138,21 @@ func (opp *OperationPack) Validate() error { // Write will serialize and store the OperationPack as a git blob and return // its hash -func (opp *OperationPack) Write(repo repository.Repo) (git.Hash, error) { +func (opp *OperationPack) Write(repo repository.ClockedRepo) (git.Hash, error) { + // make sure we don't write invalid data + err := opp.Validate() + if err != nil { + return "", errors.Wrap(err, "validation error") + } + + // First, make sure that all the identities are properly Commit as well + for _, op := range opp.Operations { + err := op.base().Author.CommitAsNeeded(repo) + if err != nil { + return "", err + } + } + data, err := json.Marshal(opp) if err != nil { diff --git a/bug/operation_pack_test.go b/bug/operation_pack_test.go index 48f9f80c1f7b81c4385c68eb16b9adf45431a2b9..09d159af978ac205e584813d40adfd603d240823 100644 --- a/bug/operation_pack_test.go +++ b/bug/operation_pack_test.go @@ -3,51 +3,59 @@ package bug import ( "encoding/json" "testing" + "time" + "github.com/MichaelMure/git-bug/identity" "github.com/MichaelMure/git-bug/util/git" - "github.com/go-test/deep" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestOperationPackSerialize(t *testing.T) { opp := &OperationPack{} + rene := identity.NewBare("René Descartes", "rene@descartes.fr") + createOp := NewCreateOp(rene, time.Now().Unix(), "title", "message", nil) + setTitleOp := NewSetTitleOp(rene, time.Now().Unix(), "title2", "title1") + addCommentOp := NewAddCommentOp(rene, time.Now().Unix(), "message2", nil) + setStatusOp := NewSetStatusOp(rene, time.Now().Unix(), ClosedStatus) + labelChangeOp := NewLabelChangeOperation(rene, time.Now().Unix(), []Label{"added"}, []Label{"removed"}) + opp.Append(createOp) opp.Append(setTitleOp) opp.Append(addCommentOp) opp.Append(setStatusOp) opp.Append(labelChangeOp) - opMeta := NewCreateOp(rene, unix, "title", "message", nil) + opMeta := NewSetTitleOp(rene, time.Now().Unix(), "title3", "title2") opMeta.SetMetadata("key", "value") opp.Append(opMeta) - if len(opMeta.Metadata) != 1 { - t.Fatal() - } + assert.Equal(t, 1, len(opMeta.Metadata)) - opFile := NewCreateOp(rene, unix, "title", "message", []git.Hash{ + opFile := NewAddCommentOp(rene, time.Now().Unix(), "message", []git.Hash{ "abcdef", "ghijkl", }) opp.Append(opFile) - if len(opFile.Files) != 2 { - t.Fatal() - } + assert.Equal(t, 2, len(opFile.Files)) data, err := json.Marshal(opp) - if err != nil { - t.Fatal(err) - } + assert.NoError(t, err) var opp2 *OperationPack err = json.Unmarshal(data, &opp2) - if err != nil { - t.Fatal(err) - } + assert.NoError(t, err) + + ensureHash(t, opp) + + assert.Equal(t, opp, opp2) +} - deep.CompareUnexportedFields = false - if diff := deep.Equal(opp, opp2); diff != nil { - t.Fatal(diff) +func ensureHash(t *testing.T, opp *OperationPack) { + for _, op := range opp.Operations { + _, err := op.Hash() + require.NoError(t, err) } } diff --git a/bug/operation_test.go b/bug/operation_test.go index 255d6d9881a40c1eab9105c461121f18b4bc1e0a..f9a7d1910a5299ef5f8901dccbd4bc292d2e9484 100644 --- a/bug/operation_test.go +++ b/bug/operation_test.go @@ -2,13 +2,19 @@ package bug import ( "testing" + "time" + "github.com/MichaelMure/git-bug/identity" "github.com/MichaelMure/git-bug/repository" "github.com/MichaelMure/git-bug/util/git" + "github.com/MichaelMure/git-bug/util/test" "github.com/stretchr/testify/require" ) func TestValidate(t *testing.T) { + rene := identity.NewIdentity("René Descartes", "rene@descartes.fr") + unix := time.Now().Unix() + good := []Operation{ NewCreateOp(rene, unix, "title", "message", nil), NewSetTitleOp(rene, unix, "title2", "title1"), @@ -25,11 +31,11 @@ func TestValidate(t *testing.T) { bad := []Operation{ // opbase - NewSetStatusOp(Person{Name: "", Email: "rene@descartes.fr"}, unix, ClosedStatus), - NewSetStatusOp(Person{Name: "René Descartes\u001b", Email: "rene@descartes.fr"}, unix, ClosedStatus), - NewSetStatusOp(Person{Name: "René Descartes", Email: "rene@descartes.fr\u001b"}, unix, ClosedStatus), - NewSetStatusOp(Person{Name: "René \nDescartes", Email: "rene@descartes.fr"}, unix, ClosedStatus), - NewSetStatusOp(Person{Name: "René Descartes", Email: "rene@\ndescartes.fr"}, unix, ClosedStatus), + NewSetStatusOp(identity.NewIdentity("", "rene@descartes.fr"), unix, ClosedStatus), + NewSetStatusOp(identity.NewIdentity("René Descartes\u001b", "rene@descartes.fr"), unix, ClosedStatus), + NewSetStatusOp(identity.NewIdentity("René Descartes", "rene@descartes.fr\u001b"), unix, ClosedStatus), + NewSetStatusOp(identity.NewIdentity("René \nDescartes", "rene@descartes.fr"), unix, ClosedStatus), + NewSetStatusOp(identity.NewIdentity("René Descartes", "rene@\ndescartes.fr"), unix, ClosedStatus), &CreateOperation{OpBase: OpBase{ Author: rene, UnixTime: 0, @@ -63,7 +69,8 @@ func TestValidate(t *testing.T) { } func TestMetadata(t *testing.T) { - op := NewCreateOp(rene, unix, "title", "message", nil) + rene := identity.NewIdentity("René Descartes", "rene@descartes.fr") + op := NewCreateOp(rene, time.Now().Unix(), "title", "message", nil) op.SetMetadata("key", "value") @@ -75,11 +82,13 @@ func TestMetadata(t *testing.T) { func TestHash(t *testing.T) { repos := []repository.ClockedRepo{ repository.NewMockRepoForTest(), - createRepo(false), + test.CreateRepo(false), } for _, repo := range repos { - b, op, err := Create(rene, unix, "title", "message") + rene := identity.NewBare("René Descartes", "rene@descartes.fr") + + b, op, err := Create(rene, time.Now().Unix(), "title", "message") require.Nil(t, err) h1, err := op.Hash() diff --git a/bug/person.go b/bug/person.go deleted file mode 100644 index 449e2262335c405548dcf9386f5c27bce47d505e..0000000000000000000000000000000000000000 --- a/bug/person.go +++ /dev/null @@ -1,95 +0,0 @@ -package bug - -import ( - "errors" - "fmt" - "strings" - - "github.com/MichaelMure/git-bug/repository" - "github.com/MichaelMure/git-bug/util/text" -) - -type Person struct { - Name string `json:"name"` - Email string `json:"email"` - Login string `json:"login"` - AvatarUrl string `json:"avatar_url"` -} - -// GetUser will query the repository for user detail and build the corresponding Person -func GetUser(repo repository.Repo) (Person, error) { - name, err := repo.GetUserName() - if err != nil { - return Person{}, err - } - if name == "" { - return Person{}, errors.New("User name is not configured in git yet. Please use `git config --global user.name \"John Doe\"`") - } - - email, err := repo.GetUserEmail() - if err != nil { - return Person{}, err - } - if email == "" { - return Person{}, errors.New("User name is not configured in git yet. Please use `git config --global user.email johndoe@example.com`") - } - - return Person{Name: name, Email: email}, nil -} - -// Match tell is the Person match the given query string -func (p Person) Match(query string) bool { - query = strings.ToLower(query) - - return strings.Contains(strings.ToLower(p.Name), query) || - strings.Contains(strings.ToLower(p.Login), query) -} - -func (p Person) Validate() error { - if text.Empty(p.Name) && text.Empty(p.Login) { - return fmt.Errorf("either name or login should be set") - } - - if strings.Contains(p.Name, "\n") { - return fmt.Errorf("name should be a single line") - } - - if !text.Safe(p.Name) { - return fmt.Errorf("name is not fully printable") - } - - if strings.Contains(p.Login, "\n") { - return fmt.Errorf("login should be a single line") - } - - if !text.Safe(p.Login) { - return fmt.Errorf("login is not fully printable") - } - - if strings.Contains(p.Email, "\n") { - return fmt.Errorf("email should be a single line") - } - - if !text.Safe(p.Email) { - return fmt.Errorf("email is not fully printable") - } - - if p.AvatarUrl != "" && !text.ValidUrl(p.AvatarUrl) { - return fmt.Errorf("avatarUrl is not a valid URL") - } - - return nil -} - -func (p Person) DisplayName() string { - switch { - case p.Name == "" && p.Login != "": - return p.Login - case p.Name != "" && p.Login == "": - return p.Name - case p.Name != "" && p.Login != "": - return fmt.Sprintf("%s (%s)", p.Name, p.Login) - } - - panic("invalid person data") -} diff --git a/bug/snapshot.go b/bug/snapshot.go index 72e673d4e54a9cf34db826ccbc48e7bca27ac7f5..46618ebddc77750dcd81ac1f79ff2e16ca929a8a 100644 --- a/bug/snapshot.go +++ b/bug/snapshot.go @@ -4,6 +4,7 @@ import ( "fmt" "time" + "github.com/MichaelMure/git-bug/identity" "github.com/MichaelMure/git-bug/util/git" ) @@ -15,7 +16,7 @@ type Snapshot struct { Title string Comments []Comment Labels []Label - Author Person + Author identity.Interface CreatedAt time.Time Timeline []TimelineItem diff --git a/bug/timeline.go b/bug/timeline.go index a84c45e4aefee42840c89ea25d003b5ad3e45e1a..d8ee2c6b0ed84bc439b7ff5712d80da63c4e8399 100644 --- a/bug/timeline.go +++ b/bug/timeline.go @@ -3,7 +3,9 @@ package bug import ( "strings" + "github.com/MichaelMure/git-bug/identity" "github.com/MichaelMure/git-bug/util/git" + "github.com/MichaelMure/git-bug/util/timestamp" ) type TimelineItem interface { @@ -15,20 +17,20 @@ type TimelineItem interface { type CommentHistoryStep struct { // The author of the edition, not necessarily the same as the author of the // original comment - Author Person + Author identity.Interface // The new message Message string - UnixTime Timestamp + UnixTime timestamp.Timestamp } // CommentTimelineItem is a TimelineItem that holds a Comment and its edition history type CommentTimelineItem struct { hash git.Hash - Author Person + Author identity.Interface Message string Files []git.Hash - CreatedAt Timestamp - LastEdit Timestamp + CreatedAt timestamp.Timestamp + LastEdit timestamp.Timestamp History []CommentHistoryStep } diff --git a/cache/bug_cache.go b/cache/bug_cache.go index 52e9eafbea562886eb85511f917afa0de21ab903..5fc766587e449838170346cde26ae90dc1ea2828 100644 --- a/cache/bug_cache.go +++ b/cache/bug_cache.go @@ -9,6 +9,10 @@ import ( "github.com/MichaelMure/git-bug/util/git" ) +// BugCache is a wrapper around a Bug. It provide multiple functions: +// +// 1. Provide a higher level API to use than the raw API from Bug. +// 2. Maintain an up to date Snapshot available. type BugCache struct { repoCache *RepoCache bug *bug.WithSnapshot @@ -53,8 +57,8 @@ func (e ErrMultipleMatchOp) Error() string { 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) { +// ResolveOperationWithMetadata will find an operation that has the matching metadata +func (c *BugCache) ResolveOperationWithMetadata(key string, value string) (git.Hash, error) { // preallocate but empty matching := make([]git.Hash, 0, 5) @@ -82,45 +86,45 @@ func (c *BugCache) ResolveTargetWithMetadata(key string, value string) (git.Hash return matching[0], nil } -func (c *BugCache) AddComment(message string) error { +func (c *BugCache) AddComment(message string) (*bug.AddCommentOperation, error) { return c.AddCommentWithFiles(message, nil) } -func (c *BugCache) AddCommentWithFiles(message string, files []git.Hash) error { - author, err := bug.GetUser(c.repoCache.repo) +func (c *BugCache) AddCommentWithFiles(message string, files []git.Hash) (*bug.AddCommentOperation, error) { + author, err := c.repoCache.GetUserIdentity() if err != nil { - return err + return nil, err } return c.AddCommentRaw(author, time.Now().Unix(), message, files, nil) } -func (c *BugCache) AddCommentRaw(author bug.Person, unixTime int64, message string, files []git.Hash, metadata map[string]string) error { - op, err := bug.AddCommentWithFiles(c.bug, author, unixTime, message, files) +func (c *BugCache) AddCommentRaw(author *IdentityCache, unixTime int64, message string, files []git.Hash, metadata map[string]string) (*bug.AddCommentOperation, error) { + op, err := bug.AddCommentWithFiles(c.bug, author.Identity, unixTime, message, files) if err != nil { - return err + return nil, err } for key, value := range metadata { op.SetMetadata(key, value) } - return c.notifyUpdated() + return op, c.notifyUpdated() } -func (c *BugCache) ChangeLabels(added []string, removed []string) ([]bug.LabelChangeResult, error) { - author, err := bug.GetUser(c.repoCache.repo) +func (c *BugCache) ChangeLabels(added []string, removed []string) ([]bug.LabelChangeResult, *bug.LabelChangeOperation, error) { + author, err := c.repoCache.GetUserIdentity() if err != nil { - return nil, err + return nil, nil, err } return c.ChangeLabelsRaw(author, time.Now().Unix(), added, removed, nil) } -func (c *BugCache) ChangeLabelsRaw(author bug.Person, unixTime int64, added []string, removed []string, metadata map[string]string) ([]bug.LabelChangeResult, error) { - changes, op, err := bug.ChangeLabels(c.bug, author, unixTime, added, removed) +func (c *BugCache) ChangeLabelsRaw(author *IdentityCache, unixTime int64, added []string, removed []string, metadata map[string]string) ([]bug.LabelChangeResult, *bug.LabelChangeOperation, error) { + changes, op, err := bug.ChangeLabels(c.bug, author.Identity, unixTime, added, removed) if err != nil { - return changes, err + return changes, nil, err } for key, value := range metadata { @@ -129,107 +133,112 @@ func (c *BugCache) ChangeLabelsRaw(author bug.Person, unixTime int64, added []st err = c.notifyUpdated() if err != nil { - return nil, err + return nil, nil, err } - return changes, nil + return changes, op, nil } -func (c *BugCache) Open() error { - author, err := bug.GetUser(c.repoCache.repo) +func (c *BugCache) Open() (*bug.SetStatusOperation, error) { + author, err := c.repoCache.GetUserIdentity() if err != nil { - return err + return nil, err } return c.OpenRaw(author, time.Now().Unix(), nil) } -func (c *BugCache) OpenRaw(author bug.Person, unixTime int64, metadata map[string]string) error { - op, err := bug.Open(c.bug, author, unixTime) +func (c *BugCache) OpenRaw(author *IdentityCache, unixTime int64, metadata map[string]string) (*bug.SetStatusOperation, error) { + op, err := bug.Open(c.bug, author.Identity, unixTime) if err != nil { - return err + return nil, err } for key, value := range metadata { op.SetMetadata(key, value) } - return c.notifyUpdated() + return op, c.notifyUpdated() } -func (c *BugCache) Close() error { - author, err := bug.GetUser(c.repoCache.repo) +func (c *BugCache) Close() (*bug.SetStatusOperation, error) { + author, err := c.repoCache.GetUserIdentity() if err != nil { - return err + return nil, err } return c.CloseRaw(author, time.Now().Unix(), nil) } -func (c *BugCache) CloseRaw(author bug.Person, unixTime int64, metadata map[string]string) error { - op, err := bug.Close(c.bug, author, unixTime) +func (c *BugCache) CloseRaw(author *IdentityCache, unixTime int64, metadata map[string]string) (*bug.SetStatusOperation, error) { + op, err := bug.Close(c.bug, author.Identity, unixTime) if err != nil { - return err + return nil, err } for key, value := range metadata { op.SetMetadata(key, value) } - return c.notifyUpdated() + return op, c.notifyUpdated() } -func (c *BugCache) SetTitle(title string) error { - author, err := bug.GetUser(c.repoCache.repo) +func (c *BugCache) SetTitle(title string) (*bug.SetTitleOperation, error) { + author, err := c.repoCache.GetUserIdentity() if err != nil { - return err + return nil, err } return c.SetTitleRaw(author, time.Now().Unix(), title, nil) } -func (c *BugCache) SetTitleRaw(author bug.Person, unixTime int64, title string, metadata map[string]string) error { - op, err := bug.SetTitle(c.bug, author, unixTime, title) +func (c *BugCache) SetTitleRaw(author *IdentityCache, unixTime int64, title string, metadata map[string]string) (*bug.SetTitleOperation, error) { + op, err := bug.SetTitle(c.bug, author.Identity, unixTime, title) if err != nil { - return err + return nil, err } for key, value := range metadata { op.SetMetadata(key, value) } - return c.notifyUpdated() + return op, c.notifyUpdated() } -func (c *BugCache) EditComment(target git.Hash, message string) error { - author, err := bug.GetUser(c.repoCache.repo) +func (c *BugCache) EditComment(target git.Hash, message string) (*bug.EditCommentOperation, error) { + author, err := c.repoCache.GetUserIdentity() if err != nil { - return err + return nil, err } return c.EditCommentRaw(author, time.Now().Unix(), target, message, nil) } -func (c *BugCache) EditCommentRaw(author bug.Person, unixTime int64, target git.Hash, message string, metadata map[string]string) error { - op, err := bug.EditComment(c.bug, author, unixTime, target, message) +func (c *BugCache) EditCommentRaw(author *IdentityCache, unixTime int64, target git.Hash, message string, metadata map[string]string) (*bug.EditCommentOperation, error) { + op, err := bug.EditComment(c.bug, author.Identity, unixTime, target, message) if err != nil { - return err + return nil, err } for key, value := range metadata { op.SetMetadata(key, value) } - return c.notifyUpdated() + return op, c.notifyUpdated() } func (c *BugCache) Commit() error { - return c.bug.Commit(c.repoCache.repo) + err := c.bug.Commit(c.repoCache.repo) + if err != nil { + return err + } + return c.notifyUpdated() } func (c *BugCache) CommitAsNeeded() error { - if c.bug.HasPendingOp() { - return c.bug.Commit(c.repoCache.repo) + err := c.bug.CommitAsNeeded(c.repoCache.repo) + if err != nil { + return err } - return nil + return c.notifyUpdated() } diff --git a/cache/bug_excerpt.go b/cache/bug_excerpt.go index 7a11fa822b808c2453120bb65ebb2c56a47f2748..fd06e51b192e8a2997def3ebae321e0e01904272 100644 --- a/cache/bug_excerpt.go +++ b/cache/bug_excerpt.go @@ -4,9 +4,15 @@ import ( "encoding/gob" "github.com/MichaelMure/git-bug/bug" + "github.com/MichaelMure/git-bug/identity" "github.com/MichaelMure/git-bug/util/lamport" ) +// Package initialisation used to register the type for (de)serialization +func init() { + gob.Register(BugExcerpt{}) +} + // BugExcerpt hold a subset of the bug values to be able to sort and filter bugs // efficiently without having to read and compile each raw bugs. type BugExcerpt struct { @@ -18,29 +24,52 @@ type BugExcerpt struct { EditUnixTime int64 Status bug.Status - Author bug.Person Labels []bug.Label + // If author is identity.Bare, LegacyAuthor is set + // If author is identity.Identity, AuthorId is set and data is deported + // in a IdentityExcerpt + LegacyAuthor LegacyAuthorExcerpt + AuthorId string + CreateMetadata map[string]string } +// identity.Bare data are directly embedded in the bug excerpt +type LegacyAuthorExcerpt struct { + Name string + Login string +} + func NewBugExcerpt(b bug.Interface, snap *bug.Snapshot) *BugExcerpt { - return &BugExcerpt{ + e := &BugExcerpt{ Id: b.Id(), CreateLamportTime: b.CreateLamportTime(), EditLamportTime: b.EditLamportTime(), CreateUnixTime: b.FirstOp().GetUnixTime(), EditUnixTime: snap.LastEditUnix(), Status: snap.Status, - Author: snap.Author, Labels: snap.Labels, CreateMetadata: b.FirstOp().AllMetadata(), } + + switch snap.Author.(type) { + case *identity.Identity: + e.AuthorId = snap.Author.Id() + case *identity.Bare: + e.LegacyAuthor = LegacyAuthorExcerpt{ + Login: snap.Author.Login(), + Name: snap.Author.Name(), + } + default: + panic("unhandled identity type") + } + + return e } -// Package initialisation used to register the type for (de)serialization -func init() { - gob.Register(BugExcerpt{}) +func (b *BugExcerpt) HumanId() string { + return bug.FormatHumanID(b.Id) } /* diff --git a/cache/filter.go b/cache/filter.go index 033df131195881064fa2fd3283f9bf039ce3921b..022a8ff25a058824ac21f59d36ba28c8f07c1824 100644 --- a/cache/filter.go +++ b/cache/filter.go @@ -1,11 +1,13 @@ package cache import ( + "strings" + "github.com/MichaelMure/git-bug/bug" ) -// Filter is a functor that match a subset of bugs -type Filter func(excerpt *BugExcerpt) bool +// Filter is a predicate that match a subset of bugs +type Filter func(repoCache *RepoCache, excerpt *BugExcerpt) bool // StatusFilter return a Filter that match a bug status func StatusFilter(query string) (Filter, error) { @@ -14,21 +16,36 @@ func StatusFilter(query string) (Filter, error) { return nil, err } - return func(excerpt *BugExcerpt) bool { + return func(repoCache *RepoCache, excerpt *BugExcerpt) bool { return excerpt.Status == status }, nil } // AuthorFilter return a Filter that match a bug author func AuthorFilter(query string) Filter { - return func(excerpt *BugExcerpt) bool { - return excerpt.Author.Match(query) + return func(repoCache *RepoCache, excerpt *BugExcerpt) bool { + query = strings.ToLower(query) + + // Normal identity + if excerpt.AuthorId != "" { + author, ok := repoCache.identitiesExcerpts[excerpt.AuthorId] + if !ok { + panic("missing identity in the cache") + } + + return strings.Contains(strings.ToLower(author.Name), query) || + strings.Contains(strings.ToLower(author.Login), query) + } + + // Legacy identity support + return strings.Contains(strings.ToLower(excerpt.LegacyAuthor.Name), query) || + strings.Contains(strings.ToLower(excerpt.LegacyAuthor.Login), query) } } // LabelFilter return a Filter that match a label func LabelFilter(label string) Filter { - return func(excerpt *BugExcerpt) bool { + return func(repoCache *RepoCache, excerpt *BugExcerpt) bool { for _, l := range excerpt.Labels { if string(l) == label { return true @@ -40,7 +57,7 @@ func LabelFilter(label string) Filter { // NoLabelFilter return a Filter that match the absence of labels func NoLabelFilter() Filter { - return func(excerpt *BugExcerpt) bool { + return func(repoCache *RepoCache, excerpt *BugExcerpt) bool { return len(excerpt.Labels) == 0 } } @@ -54,20 +71,20 @@ type Filters struct { } // Match check if a bug match the set of filters -func (f *Filters) Match(excerpt *BugExcerpt) bool { - if match := f.orMatch(f.Status, excerpt); !match { +func (f *Filters) Match(repoCache *RepoCache, excerpt *BugExcerpt) bool { + if match := f.orMatch(f.Status, repoCache, excerpt); !match { return false } - if match := f.orMatch(f.Author, excerpt); !match { + if match := f.orMatch(f.Author, repoCache, excerpt); !match { return false } - if match := f.orMatch(f.Label, excerpt); !match { + if match := f.orMatch(f.Label, repoCache, excerpt); !match { return false } - if match := f.andMatch(f.NoFilters, excerpt); !match { + if match := f.andMatch(f.NoFilters, repoCache, excerpt); !match { return false } @@ -75,28 +92,28 @@ func (f *Filters) Match(excerpt *BugExcerpt) bool { } // Check if any of the filters provided match the bug -func (*Filters) orMatch(filters []Filter, excerpt *BugExcerpt) bool { +func (*Filters) orMatch(filters []Filter, repoCache *RepoCache, excerpt *BugExcerpt) bool { if len(filters) == 0 { return true } match := false for _, f := range filters { - match = match || f(excerpt) + match = match || f(repoCache, excerpt) } return match } // Check if all of the filters provided match the bug -func (*Filters) andMatch(filters []Filter, excerpt *BugExcerpt) bool { +func (*Filters) andMatch(filters []Filter, repoCache *RepoCache, excerpt *BugExcerpt) bool { if len(filters) == 0 { return true } match := true for _, f := range filters { - match = match && f(excerpt) + match = match && f(repoCache, excerpt) } return match diff --git a/cache/identity_cache.go b/cache/identity_cache.go new file mode 100644 index 0000000000000000000000000000000000000000..2ae55f2d7c4841115aecfcb52c17e535a934f94a --- /dev/null +++ b/cache/identity_cache.go @@ -0,0 +1,43 @@ +package cache + +import ( + "github.com/MichaelMure/git-bug/identity" +) + +// IdentityCache is a wrapper around an Identity for caching. +type IdentityCache struct { + *identity.Identity + repoCache *RepoCache +} + +func NewIdentityCache(repoCache *RepoCache, id *identity.Identity) *IdentityCache { + return &IdentityCache{ + Identity: id, + repoCache: repoCache, + } +} + +func (i *IdentityCache) notifyUpdated() error { + return i.repoCache.identityUpdated(i.Identity.Id()) +} + +func (i *IdentityCache) AddVersion(version *identity.Version) error { + i.Identity.AddVersion(version) + return i.notifyUpdated() +} + +func (i *IdentityCache) Commit() error { + err := i.Identity.Commit(i.repoCache.repo) + if err != nil { + return err + } + return i.notifyUpdated() +} + +func (i *IdentityCache) CommitAsNeeded() error { + err := i.Identity.CommitAsNeeded(i.repoCache.repo) + if err != nil { + return err + } + return i.notifyUpdated() +} diff --git a/cache/identity_excerpt.go b/cache/identity_excerpt.go new file mode 100644 index 0000000000000000000000000000000000000000..2a13bc60deed462f6a2882b9eb9fe8bc3d07b975 --- /dev/null +++ b/cache/identity_excerpt.go @@ -0,0 +1,70 @@ +package cache + +import ( + "encoding/gob" + "fmt" + + "github.com/MichaelMure/git-bug/identity" +) + +// Package initialisation used to register the type for (de)serialization +func init() { + gob.Register(IdentityExcerpt{}) +} + +// IdentityExcerpt hold a subset of the identity values to be able to sort and +// filter identities efficiently without having to read and compile each raw +// identity. +type IdentityExcerpt struct { + Id string + + Name string + Login string + ImmutableMetadata map[string]string +} + +func NewIdentityExcerpt(i *identity.Identity) *IdentityExcerpt { + return &IdentityExcerpt{ + Id: i.Id(), + Name: i.Name(), + Login: i.Login(), + ImmutableMetadata: i.ImmutableMetadata(), + } +} + +func (i *IdentityExcerpt) HumanId() string { + return identity.FormatHumanID(i.Id) +} + +// DisplayName return a non-empty string to display, representing the +// identity, based on the non-empty values. +func (i *IdentityExcerpt) DisplayName() string { + switch { + case i.Name == "" && i.Login != "": + return i.Login + case i.Name != "" && i.Login == "": + return i.Name + case i.Name != "" && i.Login != "": + return fmt.Sprintf("%s (%s)", i.Name, i.Login) + } + + panic("invalid person data") +} + +/* + * Sorting + */ + +type IdentityById []*IdentityExcerpt + +func (b IdentityById) Len() int { + return len(b) +} + +func (b IdentityById) Less(i, j int) bool { + return b[i].Id < b[j].Id +} + +func (b IdentityById) Swap(i, j int) { + b[i], b[j] = b[j], b[i] +} diff --git a/cache/multi_repo_cache.go b/cache/multi_repo_cache.go index ec435ff236d9498a67282a4d070878c3d98dc8ec..da1c26bdde489459b75274fba7359d70934afd7f 100644 --- a/cache/multi_repo_cache.go +++ b/cache/multi_repo_cache.go @@ -8,6 +8,7 @@ import ( const lockfile = "lock" +// MultiRepoCache is the root cache, holding multiple RepoCache. type MultiRepoCache struct { repos map[string]*RepoCache } diff --git a/cache/repo_cache.go b/cache/repo_cache.go index 286e27a5c2b96f1a9a52df841b991946c35c36a9..2b0fa360592cb71bc7d574f765c00290515508a0 100644 --- a/cache/repo_cache.go +++ b/cache/repo_cache.go @@ -14,27 +14,64 @@ import ( "time" "github.com/MichaelMure/git-bug/bug" + "github.com/MichaelMure/git-bug/identity" "github.com/MichaelMure/git-bug/repository" "github.com/MichaelMure/git-bug/util/git" "github.com/MichaelMure/git-bug/util/process" ) -const cacheFile = "cache" -const formatVersion = 1 +const bugCacheFile = "bug-cache" +const identityCacheFile = "identity-cache" +// 1: original format +// 2: added cache for identities with a reference in the bug cache +const formatVersion = 2 + +type ErrInvalidCacheFormat struct { + message string +} + +func (e ErrInvalidCacheFormat) Error() string { + return e.message +} + +// RepoCache is a cache for a Repository. This cache has multiple functions: +// +// 1. After being loaded, a Bug is kept in memory in the cache, allowing for fast +// access later. +// 2. The cache maintain on memory and on disk a pre-digested excerpt for each bug, +// allowing for fast querying the whole set of bugs without having to load +// them individually. +// 3. The cache guarantee that a single instance of a Bug is loaded at once, avoiding +// loss of data that we could have with multiple copies in the same process. +// 4. The same way, the cache maintain in memory a single copy of the loaded identities. +// +// The cache also protect the on-disk data by locking the git repository for its +// own usage, by writing a lock file. Of course, normal git operations are not +// affected, only git-bug related one. type RepoCache struct { // the underlying repo repo repository.ClockedRepo + // excerpt of bugs data for all bugs - excerpts map[string]*BugExcerpt + bugExcerpts map[string]*BugExcerpt // bug loaded in memory bugs map[string]*BugCache + + // excerpt of identities data for all identities + identitiesExcerpts map[string]*IdentityExcerpt + // identities loaded in memory + identities map[string]*IdentityCache + + // the user identity's id, if known + userIdentityId string } func NewRepoCache(r repository.ClockedRepo) (*RepoCache, error) { c := &RepoCache{ - repo: r, - bugs: make(map[string]*BugCache), + repo: r, + bugs: make(map[string]*BugCache), + identities: make(map[string]*IdentityCache), } err := c.lock() @@ -46,6 +83,9 @@ func NewRepoCache(r repository.ClockedRepo) (*RepoCache, error) { if err == nil { return c, nil } + if _, ok := err.(ErrInvalidCacheFormat); ok { + return nil, err + } err = c.buildCache() if err != nil { @@ -125,14 +165,38 @@ func (c *RepoCache) bugUpdated(id string) error { panic("missing bug in the cache") } - c.excerpts[id] = NewBugExcerpt(b.bug, b.Snapshot()) + c.bugExcerpts[id] = NewBugExcerpt(b.bug, b.Snapshot()) - return c.write() + // we only need to write the bug cache + return c.writeBugCache() } -// load will try to read from the disk the bug cache file +// identityUpdated is a callback to trigger when the excerpt of an identity +// changed, that is each time an identity is updated +func (c *RepoCache) identityUpdated(id string) error { + i, ok := c.identities[id] + if !ok { + panic("missing identity in the cache") + } + + c.identitiesExcerpts[id] = NewIdentityExcerpt(i.Identity) + + // we only need to write the identity cache + return c.writeIdentityCache() +} + +// load will try to read from the disk all the cache files func (c *RepoCache) load() error { - f, err := os.Open(cacheFilePath(c.repo)) + err := c.loadBugCache() + if err != nil { + return err + } + return c.loadIdentityCache() +} + +// load will try to read from the disk the bug cache file +func (c *RepoCache) loadBugCache() error { + f, err := os.Open(bugCacheFilePath(c.repo)) if err != nil { return err } @@ -149,16 +213,56 @@ func (c *RepoCache) load() error { return err } - if aux.Version != 1 { - return fmt.Errorf("unknown cache format version %v", aux.Version) + if aux.Version != 2 { + return ErrInvalidCacheFormat{ + message: fmt.Sprintf("unknown cache format version %v", aux.Version), + } } - c.excerpts = aux.Excerpts + c.bugExcerpts = aux.Excerpts return nil } -// write will serialize on disk the bug cache file +// load will try to read from the disk the identity cache file +func (c *RepoCache) loadIdentityCache() error { + f, err := os.Open(identityCacheFilePath(c.repo)) + if err != nil { + return err + } + + decoder := gob.NewDecoder(f) + + aux := struct { + Version uint + Excerpts map[string]*IdentityExcerpt + }{} + + err = decoder.Decode(&aux) + if err != nil { + return err + } + + if aux.Version != 2 { + return ErrInvalidCacheFormat{ + message: fmt.Sprintf("unknown cache format version %v", aux.Version), + } + } + + c.identitiesExcerpts = aux.Excerpts + return nil +} + +// write will serialize on disk all the cache files func (c *RepoCache) write() error { + err := c.writeBugCache() + if err != nil { + return err + } + return c.writeIdentityCache() +} + +// write will serialize on disk the bug cache file +func (c *RepoCache) writeBugCache() error { var data bytes.Buffer aux := struct { @@ -166,7 +270,7 @@ func (c *RepoCache) write() error { Excerpts map[string]*BugExcerpt }{ Version: formatVersion, - Excerpts: c.excerpts, + Excerpts: c.bugExcerpts, } encoder := gob.NewEncoder(&data) @@ -176,7 +280,7 @@ func (c *RepoCache) write() error { return err } - f, err := os.Create(cacheFilePath(c.repo)) + f, err := os.Create(bugCacheFilePath(c.repo)) if err != nil { return err } @@ -189,14 +293,66 @@ func (c *RepoCache) write() error { return f.Close() } -func cacheFilePath(repo repository.Repo) string { - return path.Join(repo.GetPath(), ".git", "git-bug", cacheFile) +// write will serialize on disk the identity cache file +func (c *RepoCache) writeIdentityCache() error { + var data bytes.Buffer + + aux := struct { + Version uint + Excerpts map[string]*IdentityExcerpt + }{ + Version: formatVersion, + Excerpts: c.identitiesExcerpts, + } + + encoder := gob.NewEncoder(&data) + + err := encoder.Encode(aux) + if err != nil { + return err + } + + f, err := os.Create(identityCacheFilePath(c.repo)) + if err != nil { + return err + } + + _, err = f.Write(data.Bytes()) + if err != nil { + return err + } + + return f.Close() +} + +func bugCacheFilePath(repo repository.Repo) string { + return path.Join(repo.GetPath(), ".git", "git-bug", bugCacheFile) +} + +func identityCacheFilePath(repo repository.Repo) string { + return path.Join(repo.GetPath(), ".git", "git-bug", identityCacheFile) } func (c *RepoCache) buildCache() error { + _, _ = fmt.Fprintf(os.Stderr, "Building identity cache... ") + + c.identitiesExcerpts = make(map[string]*IdentityExcerpt) + + allIdentities := identity.ReadAllLocalIdentities(c.repo) + + for i := range allIdentities { + if i.Err != nil { + return i.Err + } + + c.identitiesExcerpts[i.Identity.Id()] = NewIdentityExcerpt(i.Identity) + } + + _, _ = fmt.Fprintln(os.Stderr, "Done.") + _, _ = fmt.Fprintf(os.Stderr, "Building bug cache... ") - c.excerpts = make(map[string]*BugExcerpt) + c.bugExcerpts = make(map[string]*BugExcerpt) allBugs := bug.ReadAllLocalBugs(c.repo) @@ -206,7 +362,7 @@ func (c *RepoCache) buildCache() error { } snap := b.Bug.Compile() - c.excerpts[b.Bug.Id()] = NewBugExcerpt(b.Bug, &snap) + c.bugExcerpts[b.Bug.Id()] = NewBugExcerpt(b.Bug, &snap) } _, _ = fmt.Fprintln(os.Stderr, "Done.") @@ -231,13 +387,23 @@ func (c *RepoCache) ResolveBug(id string) (*BugCache, error) { return cached, nil } +// ResolveBugExcerpt retrieve a BugExcerpt matching the exact given id +func (c *RepoCache) ResolveBugExcerpt(id string) (*BugExcerpt, error) { + e, ok := c.bugExcerpts[id] + if !ok { + return nil, bug.ErrBugNotExist + } + + return e, nil +} + // ResolveBugPrefix retrieve a bug matching an id prefix. It fails if multiple // bugs match. func (c *RepoCache) ResolveBugPrefix(prefix string) (*BugCache, error) { // preallocate but empty matching := make([]string, 0, 5) - for id := range c.excerpts { + for id := range c.bugExcerpts { if strings.HasPrefix(id, prefix) { matching = append(matching, id) } @@ -261,7 +427,7 @@ func (c *RepoCache) ResolveBugCreateMetadata(key string, value string) (*BugCach // preallocate but empty matching := make([]string, 0, 5) - for id, excerpt := range c.excerpts { + for id, excerpt := range c.bugExcerpts { if excerpt.CreateMetadata[key] == value { matching = append(matching, id) } @@ -278,6 +444,7 @@ func (c *RepoCache) ResolveBugCreateMetadata(key string, value string) (*BugCach return c.ResolveBug(matching[0]) } +// QueryBugs return the id of all Bug matching the given Query func (c *RepoCache) QueryBugs(query *Query) []string { if query == nil { return c.AllBugsIds() @@ -285,8 +452,8 @@ func (c *RepoCache) QueryBugs(query *Query) []string { var filtered []*BugExcerpt - for _, excerpt := range c.excerpts { - if query.Match(excerpt) { + for _, excerpt := range c.bugExcerpts { + if query.Match(c, excerpt) { filtered = append(filtered, excerpt) } } @@ -321,10 +488,10 @@ func (c *RepoCache) QueryBugs(query *Query) []string { // AllBugsIds return all known bug ids func (c *RepoCache) AllBugsIds() []string { - result := make([]string, len(c.excerpts)) + result := make([]string, len(c.bugExcerpts)) i := 0 - for _, excerpt := range c.excerpts { + for _, excerpt := range c.bugExcerpts { result[i] = excerpt.Id i++ } @@ -332,11 +499,6 @@ func (c *RepoCache) AllBugsIds() []string { return result } -// ClearAllBugs clear all bugs kept in memory -func (c *RepoCache) ClearAllBugs() { - c.bugs = make(map[string]*BugCache) -} - // ValidLabels list valid labels // // Note: in the future, a proper label policy could be implemented where valid @@ -345,7 +507,7 @@ func (c *RepoCache) ClearAllBugs() { func (c *RepoCache) ValidLabels() []bug.Label { set := map[bug.Label]interface{}{} - for _, excerpt := range c.excerpts { + for _, excerpt := range c.bugExcerpts { for _, l := range excerpt.Labels { set[l] = nil } @@ -376,7 +538,7 @@ func (c *RepoCache) NewBug(title string, message string) (*BugCache, error) { // NewBugWithFiles create a new bug with attached files for the message // The new bug is written in the repository (commit) func (c *RepoCache) NewBugWithFiles(title string, message string, files []git.Hash) (*BugCache, error) { - author, err := bug.GetUser(c.repo) + author, err := c.GetUserIdentity() if err != nil { return nil, err } @@ -387,8 +549,8 @@ func (c *RepoCache) NewBugWithFiles(title string, message string, files []git.Ha // NewBugWithFilesMeta create a new bug with attached files for the message, as // well as metadata for the Create operation. // The new bug is written in the repository (commit) -func (c *RepoCache) NewBugRaw(author bug.Person, unixTime int64, title string, message string, files []git.Hash, metadata map[string]string) (*BugCache, error) { - b, op, err := bug.CreateWithFiles(author, unixTime, title, message, files) +func (c *RepoCache) NewBugRaw(author *IdentityCache, unixTime int64, title string, message string, files []git.Hash, metadata map[string]string) (*BugCache, error) { + b, op, err := bug.CreateWithFiles(author.Identity, unixTime, title, message, files) if err != nil { return nil, err } @@ -402,9 +564,14 @@ func (c *RepoCache) NewBugRaw(author bug.Person, unixTime int64, title string, m return nil, err } + if _, has := c.bugs[b.Id()]; has { + return nil, fmt.Errorf("bug %s already exist in the cache", b.Id()) + } + cached := NewBugCache(c, b) c.bugs[b.Id()] = cached + // force the write of the excerpt err = c.bugUpdated(b.Id()) if err != nil { return nil, err @@ -421,6 +588,8 @@ func (c *RepoCache) Fetch(remote string) (string, error) { // MergeAll will merge all the available remote bug func (c *RepoCache) MergeAll(remote string) <-chan bug.MergeResult { + // TODO: add identities + out := make(chan bug.MergeResult) // Intercept merge results to update the cache properly @@ -441,7 +610,7 @@ func (c *RepoCache) MergeAll(remote string) <-chan bug.MergeResult { case bug.MergeStatusNew, bug.MergeStatusUpdated: b := result.Bug snap := b.Compile() - c.excerpts[id] = NewBugExcerpt(b, &snap) + c.bugExcerpts[id] = NewBugExcerpt(b, &snap) } } @@ -524,3 +693,166 @@ func repoIsAvailable(repo repository.Repo) error { return nil } + +// ResolveIdentity retrieve an identity matching the exact given id +func (c *RepoCache) ResolveIdentity(id string) (*IdentityCache, error) { + cached, ok := c.identities[id] + if ok { + return cached, nil + } + + i, err := identity.ReadLocal(c.repo, id) + if err != nil { + return nil, err + } + + cached = NewIdentityCache(c, i) + c.identities[id] = cached + + return cached, nil +} + +// ResolveIdentityExcerpt retrieve a IdentityExcerpt matching the exact given id +func (c *RepoCache) ResolveIdentityExcerpt(id string) (*IdentityExcerpt, error) { + e, ok := c.identitiesExcerpts[id] + if !ok { + return nil, identity.ErrIdentityNotExist + } + + return e, nil +} + +// ResolveIdentityPrefix retrieve an Identity matching an id prefix. +// It fails if multiple identities match. +func (c *RepoCache) ResolveIdentityPrefix(prefix string) (*IdentityCache, error) { + // preallocate but empty + matching := make([]string, 0, 5) + + for id := range c.identitiesExcerpts { + if strings.HasPrefix(id, prefix) { + matching = append(matching, id) + } + } + + if len(matching) > 1 { + return nil, identity.ErrMultipleMatch{Matching: matching} + } + + if len(matching) == 0 { + return nil, identity.ErrIdentityNotExist + } + + return c.ResolveIdentity(matching[0]) +} + +// ResolveIdentityImmutableMetadata retrieve an Identity that has the exact given metadata on +// one of it's version. If multiple version have the same key, the first defined take precedence. +func (c *RepoCache) ResolveIdentityImmutableMetadata(key string, value string) (*IdentityCache, error) { + // preallocate but empty + matching := make([]string, 0, 5) + + for id, i := range c.identitiesExcerpts { + if i.ImmutableMetadata[key] == value { + matching = append(matching, id) + } + } + + if len(matching) > 1 { + return nil, identity.ErrMultipleMatch{Matching: matching} + } + + if len(matching) == 0 { + return nil, identity.ErrIdentityNotExist + } + + return c.ResolveIdentity(matching[0]) +} + +// AllIdentityIds return all known identity ids +func (c *RepoCache) AllIdentityIds() []string { + result := make([]string, len(c.identitiesExcerpts)) + + i := 0 + for _, excerpt := range c.identitiesExcerpts { + result[i] = excerpt.Id + i++ + } + + return result +} + +func (c *RepoCache) SetUserIdentity(i *IdentityCache) error { + err := identity.SetUserIdentity(c.repo, i.Identity) + if err != nil { + return err + } + + // Make sure that everything is fine + if _, ok := c.identities[i.Id()]; !ok { + panic("SetUserIdentity while the identity is not from the cache, something is wrong") + } + + c.userIdentityId = i.Id() + + return nil +} + +func (c *RepoCache) GetUserIdentity() (*IdentityCache, error) { + if c.userIdentityId != "" { + i, ok := c.identities[c.userIdentityId] + if ok { + return i, nil + } + } + + i, err := identity.GetUserIdentity(c.repo) + if err != nil { + return nil, err + } + + cached := NewIdentityCache(c, i) + c.identities[i.Id()] = cached + c.userIdentityId = i.Id() + + return cached, nil +} + +// NewIdentity create a new identity +// The new identity is written in the repository (commit) +func (c *RepoCache) NewIdentity(name string, email string) (*IdentityCache, error) { + return c.NewIdentityRaw(name, email, "", "", nil) +} + +// NewIdentityFull create a new identity +// The new identity is written in the repository (commit) +func (c *RepoCache) NewIdentityFull(name string, email string, login string, avatarUrl string) (*IdentityCache, error) { + return c.NewIdentityRaw(name, email, login, avatarUrl, nil) +} + +func (c *RepoCache) NewIdentityRaw(name string, email string, login string, avatarUrl string, metadata map[string]string) (*IdentityCache, error) { + i := identity.NewIdentityFull(name, email, login, avatarUrl) + + for key, value := range metadata { + i.SetMetadata(key, value) + } + + err := i.Commit(c.repo) + if err != nil { + return nil, err + } + + if _, has := c.identities[i.Id()]; has { + return nil, fmt.Errorf("identity %s already exist in the cache", i.Id()) + } + + cached := NewIdentityCache(c, i) + c.identities[i.Id()] = cached + + // force the write of the excerpt + err = c.identityUpdated(i.Id()) + if err != nil { + return nil, err + } + + return cached, nil +} diff --git a/commands/add.go b/commands/add.go index 54ede1260daaa6f3e0cd9d12725ec9ca970a7a3b..ea40227cb397972a3bb6f81e590dce01e3444ca1 100644 --- a/commands/add.go +++ b/commands/add.go @@ -56,7 +56,7 @@ func runAddBug(cmd *cobra.Command, args []string) error { var addCmd = &cobra.Command{ Use: "add", - Short: "Create a new bug", + Short: "Create a new bug.", PreRunE: loadRepo, RunE: runAddBug, } diff --git a/commands/bridge.go b/commands/bridge.go index a473776d7eaa1692e93d5ddb355c1ffe3da4b0fc..2566fd068b07e4e2ba654e379b7f059c7eb97deb 100644 --- a/commands/bridge.go +++ b/commands/bridge.go @@ -31,7 +31,7 @@ func runBridge(cmd *cobra.Command, args []string) error { var bridgeCmd = &cobra.Command{ Use: "bridge", - Short: "Configure and use bridges to other bug trackers", + Short: "Configure and use bridges to other bug trackers.", PreRunE: loadRepo, RunE: runBridge, Args: cobra.NoArgs, diff --git a/commands/bridge_configure.go b/commands/bridge_configure.go index ef499f1ff59b97dee3fe05cc98fe6706ca228c5a..ce10d9af69a3bebd3dbb18c150332f3397775eff 100644 --- a/commands/bridge_configure.go +++ b/commands/bridge_configure.go @@ -91,7 +91,7 @@ func promptName() (string, error) { var bridgeConfigureCmd = &cobra.Command{ Use: "configure", - Short: "Configure a new bridge", + Short: "Configure a new bridge.", PreRunE: loadRepo, RunE: runBridgeConfigure, } diff --git a/commands/bridge_pull.go b/commands/bridge_pull.go index 669a67133b0ddd6b26386c7bf23d7d0fda841368..9b25147908e44183007cdc21af7f227a81cc15b0 100644 --- a/commands/bridge_pull.go +++ b/commands/bridge_pull.go @@ -38,7 +38,7 @@ func runBridgePull(cmd *cobra.Command, args []string) error { var bridgePullCmd = &cobra.Command{ Use: "pull []", - Short: "Pull updates", + Short: "Pull updates.", PreRunE: loadRepo, RunE: runBridgePull, } diff --git a/commands/bridge_rm.go b/commands/bridge_rm.go index 172fc0d8d2ff214789e69f80565542e787679ed6..80a831ff3e626479345625f472adb4626333154d 100644 --- a/commands/bridge_rm.go +++ b/commands/bridge_rm.go @@ -25,7 +25,7 @@ func runBridgeRm(cmd *cobra.Command, args []string) error { var bridgeRmCmd = &cobra.Command{ Use: "rm name ", - Short: "Delete a configured bridge", + Short: "Delete a configured bridge.", PreRunE: loadRepo, RunE: runBridgeRm, Args: cobra.ExactArgs(1), diff --git a/commands/commands.go b/commands/commands.go index e48cd542d85e49757d58086ed4389d7d8b74eb89..a30c38a50971f8e930aac24ccc6268dd8bd5bd42 100644 --- a/commands/commands.go +++ b/commands/commands.go @@ -61,7 +61,7 @@ func runCommands(cmd *cobra.Command, args []string) error { var commandsCmd = &cobra.Command{ Use: "commands [