From f9b7a64fb13dfc72f3ded39bbdaf4d8af8f89af8 Mon Sep 17 00:00:00 2001 From: Isma <71719097+Doozers@users.noreply.github.com> Date: Tue, 9 May 2023 09:42:43 +0200 Subject: [PATCH] feat: merge multipmuri as a depviz/pkg (#664) --- go.mod | 1 - go.sum | 2 - pkg/dvcore/fetch.go | 2 +- pkg/dvcore/gen.go | 2 +- pkg/dvcore/gen_test.go | 2 +- pkg/dvparser/target.go | 2 +- pkg/dvserver/server.go | 2 +- pkg/dvstore/query.go | 2 +- pkg/dvstore/query_test.go | 2 +- pkg/githubprovider/github.go | 2 +- pkg/githubprovider/model.go | 4 +- pkg/multipmuri/decode.go | 64 ++ pkg/multipmuri/decode_test.go | 52 ++ pkg/multipmuri/github.go | 579 ++++++++++++++++++ pkg/multipmuri/github_test.go | 220 +++++++ pkg/multipmuri/gitlab.go | 493 +++++++++++++++ pkg/multipmuri/gitlab_test.go | 172 ++++++ pkg/multipmuri/helpers.go | 31 + pkg/multipmuri/helpers_test.go | 60 ++ pkg/multipmuri/multipmuri.go | 186 ++++++ pkg/multipmuri/pmbodyparser/pmbodyparser.go | 99 +++ .../pmbodyparser/pmbodyparser_test.go | 105 ++++ pkg/multipmuri/trello.go | 214 +++++++ pkg/multipmuri/trello_test.go | 60 ++ pkg/multipmuri/util.go | 38 ++ 25 files changed, 2383 insertions(+), 13 deletions(-) create mode 100644 pkg/multipmuri/decode.go create mode 100644 pkg/multipmuri/decode_test.go create mode 100644 pkg/multipmuri/github.go create mode 100644 pkg/multipmuri/github_test.go create mode 100644 pkg/multipmuri/gitlab.go create mode 100644 pkg/multipmuri/gitlab_test.go create mode 100644 pkg/multipmuri/helpers.go create mode 100644 pkg/multipmuri/helpers_test.go create mode 100644 pkg/multipmuri/multipmuri.go create mode 100644 pkg/multipmuri/pmbodyparser/pmbodyparser.go create mode 100644 pkg/multipmuri/pmbodyparser/pmbodyparser_test.go create mode 100644 pkg/multipmuri/trello.go create mode 100644 pkg/multipmuri/trello_test.go create mode 100644 pkg/multipmuri/util.go diff --git a/go.mod b/go.mod index af2255a43..020f2e40d 100644 --- a/go.mod +++ b/go.mod @@ -32,7 +32,6 @@ require ( moul.io/banner v1.0.1 moul.io/godev v1.7.0 moul.io/graphman v1.6.0 - moul.io/multipmuri v1.14.0 moul.io/srand v1.6.1 moul.io/u v1.27.0 moul.io/zapconfig v1.4.0 diff --git a/go.sum b/go.sum index c5a83ec23..c333c40f2 100644 --- a/go.sum +++ b/go.sum @@ -976,8 +976,6 @@ moul.io/godev v1.7.0 h1:PgnL7BsCQPPjKwu9V0oxIVm2MyZAHAN2sl0S3+E37U0= moul.io/godev v1.7.0/go.mod h1:5lgSpI1oH7xWpLl2Ew/Nsgk8DiNM6FzN9WV9+lgW8RQ= moul.io/graphman v1.6.0 h1:p0pOkQDul7fv5R3pdQQZsYXaUIvLPfGMonznNa6cCG0= moul.io/graphman v1.6.0/go.mod h1:WfW75G37UTvAu09o4xdmcflGDn+VbZiwStaKmJKxkE0= -moul.io/multipmuri v1.14.0 h1:zBVH1mbsYp4RoV1ubAT9zQK1LxhLIcDkat6VAyX6UrM= -moul.io/multipmuri v1.14.0/go.mod h1:NinVrznZaHGUDU+4zfcL7L9WzMsHtb/kjLr4RaCbM+c= moul.io/srand v1.6.1 h1:SJ335F+54ivLdlH7wH52Rtyv0Ffos6DpsF5wu3ZVMXU= moul.io/srand v1.6.1/go.mod h1:P2uaZB+GFstFNo8sEj6/U8FRV1n25kD0LLckFpJ+qvc= moul.io/u v1.23.0/go.mod h1:ytlQ/zt+Sdk+PFGEx+fpTivoa0ieA5yMo6itRswIWNQ= diff --git a/pkg/dvcore/fetch.go b/pkg/dvcore/fetch.go index 4cf93ac52..32641e299 100644 --- a/pkg/dvcore/fetch.go +++ b/pkg/dvcore/fetch.go @@ -7,7 +7,7 @@ import ( "github.com/cayleygraph/cayley/schema" "go.uber.org/zap" "moul.io/depviz/v3/pkg/dvparser" - "moul.io/multipmuri" + "moul.io/depviz/v3/pkg/multipmuri" ) type FetchOpts struct { diff --git a/pkg/dvcore/gen.go b/pkg/dvcore/gen.go index 3232b6934..832c2ea1f 100644 --- a/pkg/dvcore/gen.go +++ b/pkg/dvcore/gen.go @@ -16,9 +16,9 @@ import ( "moul.io/depviz/v3/pkg/dvparser" "moul.io/depviz/v3/pkg/dvstore" "moul.io/depviz/v3/pkg/githubprovider" + "moul.io/depviz/v3/pkg/multipmuri" "moul.io/godev" "moul.io/graphman" - "moul.io/multipmuri" ) type GenOpts struct { diff --git a/pkg/dvcore/gen_test.go b/pkg/dvcore/gen_test.go index ba587d89a..4ff570e68 100644 --- a/pkg/dvcore/gen_test.go +++ b/pkg/dvcore/gen_test.go @@ -9,8 +9,8 @@ import ( "github.com/cayleygraph/quad" "github.com/stretchr/testify/assert" "moul.io/depviz/v3/pkg/dvstore" + "moul.io/depviz/v3/pkg/multipmuri" "moul.io/depviz/v3/pkg/testutil" - "moul.io/multipmuri" ) func TestPullAndSave(t *testing.T) { diff --git a/pkg/dvparser/target.go b/pkg/dvparser/target.go index c24dfd97a..a61a8fb16 100644 --- a/pkg/dvparser/target.go +++ b/pkg/dvparser/target.go @@ -1,6 +1,6 @@ package dvparser -import "moul.io/multipmuri" +import "moul.io/depviz/v3/pkg/multipmuri" func ParseTargets(args []string) ([]multipmuri.Entity, error) { targets := []multipmuri.Entity{} diff --git a/pkg/dvserver/server.go b/pkg/dvserver/server.go index 91609943c..0b6c414ab 100644 --- a/pkg/dvserver/server.go +++ b/pkg/dvserver/server.go @@ -28,7 +28,7 @@ import ( "google.golang.org/grpc/credentials/insecure" "moul.io/depviz/v3/pkg/chiutil" "moul.io/depviz/v3/pkg/dvcore" - "moul.io/multipmuri" + "moul.io/depviz/v3/pkg/multipmuri" ) const ( diff --git a/pkg/dvstore/query.go b/pkg/dvstore/query.go index 3f43ed844..1dc20b13d 100644 --- a/pkg/dvstore/query.go +++ b/pkg/dvstore/query.go @@ -12,7 +12,7 @@ import ( "github.com/cayleygraph/quad" "go.uber.org/zap" "moul.io/depviz/v3/pkg/dvmodel" - "moul.io/multipmuri" + "moul.io/depviz/v3/pkg/multipmuri" ) func LastUpdatedIssueInRepo(ctx context.Context, h *cayley.Handle, entity multipmuri.Entity) (time.Time, error) { // nolint:interfacer diff --git a/pkg/dvstore/query_test.go b/pkg/dvstore/query_test.go index 087f20e3e..eb775779b 100644 --- a/pkg/dvstore/query_test.go +++ b/pkg/dvstore/query_test.go @@ -9,9 +9,9 @@ import ( _ "github.com/cayleygraph/quad/json" "github.com/stretchr/testify/assert" "moul.io/depviz/v3/pkg/dvparser" + "moul.io/depviz/v3/pkg/multipmuri" "moul.io/depviz/v3/pkg/testutil" "moul.io/godev" - "moul.io/multipmuri" ) func TestLoadTasks(t *testing.T) { diff --git a/pkg/githubprovider/github.go b/pkg/githubprovider/github.go index fcdcb8645..25c79dda6 100644 --- a/pkg/githubprovider/github.go +++ b/pkg/githubprovider/github.go @@ -9,7 +9,7 @@ import ( "go.uber.org/zap" "golang.org/x/oauth2" "moul.io/depviz/v3/pkg/dvmodel" - "moul.io/multipmuri" + "moul.io/depviz/v3/pkg/multipmuri" ) type Opts struct { diff --git a/pkg/githubprovider/model.go b/pkg/githubprovider/model.go index b92e5e944..f874ba389 100644 --- a/pkg/githubprovider/model.go +++ b/pkg/githubprovider/model.go @@ -10,8 +10,8 @@ import ( "go.uber.org/zap" "moul.io/depviz/v3/pkg/dvmodel" "moul.io/depviz/v3/pkg/dvparser" - "moul.io/multipmuri" - "moul.io/multipmuri/pmbodyparser" + "moul.io/depviz/v3/pkg/multipmuri" + "moul.io/depviz/v3/pkg/multipmuri/pmbodyparser" ) const ( diff --git a/pkg/multipmuri/decode.go b/pkg/multipmuri/decode.go new file mode 100644 index 000000000..72a1229eb --- /dev/null +++ b/pkg/multipmuri/decode.go @@ -0,0 +1,64 @@ +package multipmuri + +import ( + "fmt" + "net/url" + "strings" +) + +func DecodeString(input string) (Entity, error) { + return NewUnknownEntity().RelDecodeString(input) +} + +type unknownEntity struct{} + +func NewUnknownEntity() Entity { return &unknownEntity{} } + +func (unknownEntity) Kind() Kind { return UnknownKind } +func (unknownEntity) Provider() Provider { return UnknownProvider } +func (unknownEntity) String() string { return "" } +func (unknownEntity) Equals(Entity) bool { return false } +func (unknownEntity) Contains(Entity) bool { return false } +func (unknownEntity) RelDecodeString(input string) (Entity, error) { + // FIXME: support more providers' cloning URLs + if strings.HasPrefix(input, "git@github.com:") { + input = strings.Replace(input, "git@github.com:", "https://github.com/", 1) + } + u, err := url.Parse(input) + if err != nil { + return nil, err + } + + if isProviderScheme(u.Scheme) { + input = input[len(u.Scheme)+3:] + switch u.Scheme { + case string(GitHubProvider): + return gitHubRelDecodeString(getHostname(input), "", "", input, true) + case string(GitLabProvider): + return gitLabRelDecodeString(getHostname(input), "", "", input, true) + //case string(JiraProvider): + //case string(TrelloProvider): + } + } + + if u.Scheme == "" && u.Host == "" && u.Path != "" { // github.com/x/x + u.Host = strings.Split(u.Path, "/")[0] + // u.Path = u.Path[len(u.Host)+1:] + } + + switch u.Scheme { + case "", "https", "http": + switch u.Host { + case "github.com", "api.github.com": + return gitHubRelDecodeString("", "", "", input, true) + case "gitlab.com": + return gitLabRelDecodeString("", "", "", input, true) + case "trello.com": + return trelloRelDecodeString(input, true) + // case "jira.com", "atlassian.com": + } + } + + return nil, fmt.Errorf("ambiguous uri %q", input) +} +func (unknownEntity) LocalID() string { return "" } diff --git a/pkg/multipmuri/decode_test.go b/pkg/multipmuri/decode_test.go new file mode 100644 index 000000000..ddbb5408c --- /dev/null +++ b/pkg/multipmuri/decode_test.go @@ -0,0 +1,52 @@ +package multipmuri + +import "fmt" + +func ExampleDecodeString() { + for _, uri := range []string{ + "https://github.com", + "github.com", + "github.com/moul", + "@moul", + "github.com/moul/depviz", + "moul/depviz", + "moul/depviz/milestone/1", + "moul/depviz#1", + "github.com/moul/depviz/issues/2", + "github.com/moul/depviz/pull/1", + "https://github.com/moul/depviz/issues/1", + "https://github.com/moul/depviz#1", + "github://moul/depviz#1", + "github://github.com/moul/depviz#1", + "github://https://github.com/moul/depviz#1", + "github://ghenterprise.company.com/a/b#42", + "github://https://ghenterprise.company.com", + "git@github.com:moul/depviz", + } { + decoded, err := DecodeString(uri) + if err != nil { + fmt.Printf("%-42s error: %v\n", uri, err) + continue + } + fmt.Printf("%-42s %-48s %-8s %s\n", uri, decoded.String(), decoded.Provider(), decoded.Kind()) + } + // Output: + // https://github.com https://github.com/ github service + // github.com https://github.com/ github service + // github.com/moul https://github.com/moul github user-or-organization + // @moul error: ambiguous uri "@moul" + // github.com/moul/depviz https://github.com/moul/depviz github project + // moul/depviz error: ambiguous uri "moul/depviz" + // moul/depviz/milestone/1 error: ambiguous uri "moul/depviz/milestone/1" + // moul/depviz#1 error: ambiguous uri "moul/depviz#1" + // github.com/moul/depviz/issues/2 https://github.com/moul/depviz/issues/2 github issue + // github.com/moul/depviz/pull/1 https://github.com/moul/depviz/issues/1 github merge-request + // https://github.com/moul/depviz/issues/1 https://github.com/moul/depviz/issues/1 github issue + // https://github.com/moul/depviz#1 https://github.com/moul/depviz/issues/1 github issue-or-merge-request + // github://moul/depviz#1 https://github.com/moul/depviz/issues/1 github issue-or-merge-request + // github://github.com/moul/depviz#1 https://github.com/moul/depviz/issues/1 github issue-or-merge-request + // github://https://github.com/moul/depviz#1 https://github.com/moul/depviz/issues/1 github issue-or-merge-request + // github://ghenterprise.company.com/a/b#42 https://ghenterprise.company.com/a/b/issues/42 github issue-or-merge-request + // github://https://ghenterprise.company.com https://ghenterprise.company.com/ github service + // git@github.com:moul/depviz https://github.com/moul/depviz github project +} diff --git a/pkg/multipmuri/github.go b/pkg/multipmuri/github.go new file mode 100644 index 000000000..386c463ff --- /dev/null +++ b/pkg/multipmuri/github.go @@ -0,0 +1,579 @@ +package multipmuri + +import ( + "fmt" + "net/url" + "strings" +) + +// +// GitHubService +// + +type GitHubService struct { + Service + *withGitHubHostname +} + +func NewGitHubService(hostname string) *GitHubService { + return &GitHubService{ + Service: &service{}, + withGitHubHostname: &withGitHubHostname{hostname}, + } +} + +func (e GitHubService) String() string { + return fmt.Sprintf("https://%s/", e.Hostname()) +} + +func (e GitHubService) LocalID() string { + return e.Hostname() +} + +func (e GitHubService) RelDecodeString(input string) (Entity, error) { + return gitHubRelDecodeString(e.Hostname(), "", "", input, false) +} + +func (e GitHubService) Equals(other Entity) bool { + if typed, valid := other.(*GitHubService); valid { + return e.Hostname() == typed.Hostname() + } + return false +} + +func (e GitHubService) Contains(other Entity) bool { + switch other.(type) { + case *GitHubRepo, *GitHubOwner, *GitHubMilestone, *GitHubIssueOrPullRequest, *GitHubIssue, *GitHubPullRequest: + if typed, valid := other.(hasWithGitHubHostname); valid { + return e.Hostname() == typed.Hostname() + } + } + return false +} + +// +// GitHubIssue +// + +type GitHubIssue struct { + Issue + *withGitHubID +} + +func NewGitHubIssue(hostname, ownerID, repoID, id string) *GitHubIssue { + return &GitHubIssue{ + Issue: &issue{}, + withGitHubID: &withGitHubID{hostname, ownerID, repoID, id}, + } +} + +func (e GitHubIssue) String() string { + return fmt.Sprintf("https://%s/%s/%s/issues/%s", e.Hostname(), e.OwnerID(), e.RepoID(), e.ID()) +} + +func (e GitHubIssue) LocalID() string { + return fmt.Sprintf("%s/%s#%s", e.OwnerID(), e.RepoID(), e.ID()) +} + +func (e GitHubIssue) RelDecodeString(input string) (Entity, error) { + return gitHubRelDecodeString(e.Hostname(), e.OwnerID(), e.RepoID(), input, false) +} + +func (e GitHubIssue) Equals(other Entity) bool { + switch other.(type) { + case *GitHubIssueOrPullRequest, *GitHubIssue, *GitHubPullRequest: + if typed, valid := other.(hasWithGitHubID); valid { + return e.Hostname() == typed.Hostname() && + e.OwnerID() == typed.OwnerID() && + e.RepoID() == typed.RepoID() && + e.ID() == typed.ID() + } + } + return false +} + +func (e GitHubIssue) Contains(other Entity) bool { + return false +} + +// +// GitHubMilestone +// + +type GitHubMilestone struct { + Milestone + *withGitHubID +} + +func NewGitHubMilestone(hostname, ownerID, repoID, id string) *GitHubMilestone { + return &GitHubMilestone{ + Milestone: &milestone{}, + withGitHubID: &withGitHubID{hostname, ownerID, repoID, id}, + } +} + +func (e GitHubMilestone) String() string { + return fmt.Sprintf("https://%s/%s/%s/milestone/%s", e.Hostname(), e.OwnerID(), e.RepoID(), e.ID()) +} + +func (e GitHubMilestone) LocalID() string { + return fmt.Sprintf("%s/%s/milestone/%s", e.OwnerID(), e.RepoID(), e.ID()) +} + +func (e GitHubMilestone) RelDecodeString(input string) (Entity, error) { + return gitHubRelDecodeString(e.Hostname(), e.OwnerID(), e.RepoID(), input, false) +} + +func (e GitHubMilestone) Equals(other Entity) bool { + if typed, valid := other.(*GitHubMilestone); valid { + return e.Hostname() == typed.Hostname() && + e.OwnerID() == typed.OwnerID() && + e.RepoID() == typed.RepoID() && + e.ID() == typed.ID() + } + return false +} + +func (e GitHubMilestone) Contains(other Entity) bool { + return false +} + +// +// GitHubPullRequest +// + +type GitHubPullRequest struct { + MergeRequest + *withGitHubID +} + +func NewGitHubPullRequest(hostname, ownerID, repoID, id string) *GitHubPullRequest { + return &GitHubPullRequest{ + MergeRequest: &mergeRequest{}, + withGitHubID: &withGitHubID{hostname, ownerID, repoID, id}, + } +} + +func (e GitHubPullRequest) String() string { + // canonical URL for PR is voluntarily issues/%s instead of pull/%s + return fmt.Sprintf("https://%s/%s/%s/issues/%s", e.Hostname(), e.OwnerID(), e.RepoID(), e.ID()) +} + +func (e GitHubPullRequest) LocalID() string { + return fmt.Sprintf("%s/%s#%s", e.OwnerID(), e.RepoID(), e.ID()) +} + +func (e GitHubPullRequest) RelDecodeString(input string) (Entity, error) { + return gitHubRelDecodeString(e.Hostname(), e.OwnerID(), e.RepoID(), input, false) +} + +func (e GitHubPullRequest) Equals(other Entity) bool { + switch other.(type) { + case *GitHubIssueOrPullRequest, *GitHubIssue, *GitHubPullRequest: + if typed, valid := other.(hasWithGitHubID); valid { + return e.Hostname() == typed.Hostname() && + e.OwnerID() == typed.OwnerID() && + e.RepoID() == typed.RepoID() && + e.ID() == typed.ID() + } + } + return false +} + +func (e GitHubPullRequest) Contains(other Entity) bool { + return false +} + +// +// GitHubIssueOrPullRequest +// + +type GitHubIssueOrPullRequest struct { + IssueOrMergeRequest + *withGitHubID +} + +func NewGitHubIssueOrPullRequest(hostname, ownerID, repoID, id string) *GitHubIssueOrPullRequest { + return &GitHubIssueOrPullRequest{ + IssueOrMergeRequest: &issueOrMergeRequest{}, + withGitHubID: &withGitHubID{hostname, ownerID, repoID, id}, + } +} + +func (e GitHubIssueOrPullRequest) String() string { + return fmt.Sprintf("https://%s/%s/%s/issues/%s", e.Hostname(), e.OwnerID(), e.RepoID(), e.ID()) +} + +func (e GitHubIssueOrPullRequest) LocalID() string { + return fmt.Sprintf("%s/%s#%s", e.OwnerID(), e.RepoID(), e.ID()) +} + +func (e GitHubIssueOrPullRequest) RelDecodeString(input string) (Entity, error) { + return gitHubRelDecodeString(e.Hostname(), e.OwnerID(), e.RepoID(), input, false) +} + +func (e GitHubIssueOrPullRequest) Equals(other Entity) bool { + switch other.(type) { + case *GitHubIssueOrPullRequest, *GitHubIssue, *GitHubPullRequest: + if typed, valid := other.(hasWithGitHubID); valid { + return e.Hostname() == typed.Hostname() && + e.OwnerID() == typed.OwnerID() && + e.RepoID() == typed.RepoID() && + e.ID() == typed.ID() + } + } + return false +} + +func (e GitHubIssueOrPullRequest) Contains(other Entity) bool { + return false +} + +// +// GitHubOwner +// + +type GitHubOwner struct { + UserOrOrganization + *withGitHubOwner +} + +func NewGitHubOwner(hostname, ownerID string) *GitHubOwner { + return &GitHubOwner{ + UserOrOrganization: &userOrOrganization{}, + withGitHubOwner: &withGitHubOwner{hostname, ownerID}, + } +} + +func (e GitHubOwner) String() string { + return fmt.Sprintf("https://%s/%s", e.Hostname(), e.OwnerID()) +} + +func (e GitHubOwner) LocalID() string { + return "@" + e.OwnerID() +} + +func (e GitHubOwner) RelDecodeString(input string) (Entity, error) { + return gitHubRelDecodeString(e.Hostname(), e.OwnerID(), "", input, false) +} + +func (e GitHubOwner) Equals(other Entity) bool { + if typed, valid := other.(*GitHubOwner); valid { + return e.Hostname() == typed.Hostname() && + e.OwnerID() == typed.OwnerID() + } + return false +} + +func (e GitHubOwner) Contains(other Entity) bool { + switch other.(type) { + case *GitHubRepo, *GitHubMilestone, *GitHubIssueOrPullRequest, *GitHubIssue, *GitHubPullRequest: + if typed, valid := other.(hasWithGitHubOwner); valid { + return e.Hostname() == typed.Hostname() && + e.OwnerID() == typed.OwnerID() + } + } + return false +} + +// +// GitHubRepo +// + +type GitHubRepo struct { + Project + *withGitHubRepo +} + +func NewGitHubRepo(hostname, ownerID, repoID string) *GitHubRepo { + return &GitHubRepo{ + Project: &project{}, + withGitHubRepo: &withGitHubRepo{hostname, ownerID, repoID}, + } +} + +func (e GitHubRepo) String() string { + return fmt.Sprintf("https://%s/%s/%s", e.Hostname(), e.OwnerID(), e.RepoID()) +} + +func (e GitHubRepo) LocalID() string { + return fmt.Sprintf("%s/%s", e.OwnerID(), e.RepoID()) +} + +func (e GitHubRepo) RelDecodeString(input string) (Entity, error) { + return gitHubRelDecodeString(e.Hostname(), e.OwnerID(), e.RepoID(), input, false) +} + +func (e GitHubRepo) Equals(other Entity) bool { + if typed, valid := other.(*GitHubRepo); valid { + return e.Hostname() == typed.Hostname() && + e.OwnerID() == typed.OwnerID() && + e.RepoID() == typed.RepoID() + } + return false +} + +func (e GitHubRepo) Contains(other Entity) bool { + switch other.(type) { + case *GitHubMilestone, *GitHubIssueOrPullRequest, *GitHubIssue, *GitHubPullRequest: + if typed, valid := other.(hasWithGitHubRepo); valid { + return e.Hostname() == typed.Hostname() && + e.OwnerID() == typed.OwnerID() && + e.RepoID() == typed.RepoID() + } + } + return false +} + +// +// GitHubLabel +// + +type GitHubLabel struct { + Label + *withGitHubID +} + +func NewGitHubLabel(hostname, ownerID, repoID, id string) *GitHubLabel { + return &GitHubLabel{ + Label: &label{}, + withGitHubID: &withGitHubID{hostname, ownerID, repoID, id}, + } +} + +func (e GitHubLabel) String() string { + return fmt.Sprintf("https://%s/%s/%s/labels/%s", e.Hostname(), e.OwnerID(), e.RepoID(), e.ID()) +} + +func (e GitHubLabel) LocalID() string { + return fmt.Sprintf("%s/%s/labels/%s", e.OwnerID(), e.RepoID(), e.ID()) +} + +func (e GitHubLabel) RelDecodeString(input string) (Entity, error) { + return gitHubRelDecodeString(e.Hostname(), e.OwnerID(), e.RepoID(), input, false) +} + +func (e GitHubLabel) Equals(other Entity) bool { + if typed, valid := other.(*GitHubLabel); valid { + return e.Hostname() == typed.Hostname() && + e.OwnerID() == typed.OwnerID() && + e.RepoID() == typed.RepoID() && + e.ID() == typed.ID() + } + return false +} + +func (e GitHubLabel) Contains(other Entity) bool { + return false +} + +// +// GitHubCommon +// + +// githubHostname + +type hasWithGitHubHostname interface { + Hostname() string +} + +type withGitHubHostname struct{ hostname string } + +func (e *withGitHubHostname) Provider() Provider { return GitHubProvider } +func (e *withGitHubHostname) Hostname() string { return githubHostname(e.hostname) } +func (e *withGitHubHostname) Service() *GitHubService { return NewGitHubService(e.hostname) } +func (e *withGitHubHostname) ServiceEntity() Entity { return e.Service() } +func (e *withGitHubHostname) Owner(ownerID string) *GitHubOwner { + return NewGitHubOwner(e.hostname, ownerID) +} +func (e *withGitHubHostname) OwnerEntity(ownerID string) Entity { return e.Owner(ownerID) } + +//githubOwner + +type hasWithGitHubOwner interface { + Hostname() string + OwnerID() string +} + +type withGitHubOwner struct{ hostname, ownerID string } + +func (e *withGitHubOwner) Provider() Provider { return GitHubProvider } +func (e *withGitHubOwner) Hostname() string { return githubHostname(e.hostname) } +func (e *withGitHubOwner) Service() *GitHubService { return NewGitHubService(e.hostname) } +func (e *withGitHubOwner) ServiceEntity() Entity { return e.Service() } +func (e *withGitHubOwner) OwnerID() string { return e.ownerID } +func (e *withGitHubOwner) Owner() *GitHubOwner { return NewGitHubOwner(e.hostname, e.ownerID) } +func (e *withGitHubOwner) OwnerEntity() Entity { return e.Owner() } +func (e *withGitHubOwner) Repo(repoID string) *GitHubRepo { + return NewGitHubRepo(e.hostname, e.ownerID, repoID) +} +func (e *withGitHubOwner) RepoEntity(repoID string) Entity { return e.Repo(repoID) } + +// githubRepo + +type hasWithGitHubRepo interface { + Hostname() string + OwnerID() string + RepoID() string +} + +type withGitHubRepo struct{ hostname, ownerID, repoID string } + +func (e *withGitHubRepo) Provider() Provider { return GitHubProvider } +func (e *withGitHubRepo) Hostname() string { return githubHostname(e.hostname) } +func (e *withGitHubRepo) OwnerID() string { return e.ownerID } +func (e *withGitHubRepo) RepoID() string { return e.repoID } +func (e *withGitHubRepo) Service() *GitHubService { return NewGitHubService(e.hostname) } +func (e *withGitHubRepo) ServiceEntity() Entity { return e.Service() } +func (e *withGitHubRepo) Owner() *GitHubOwner { return NewGitHubOwner(e.hostname, e.ownerID) } +func (e *withGitHubRepo) OwnerEntity() Entity { return e.Owner() } +func (e *withGitHubRepo) Repo() *GitHubRepo { return NewGitHubRepo(e.hostname, e.ownerID, e.repoID) } +func (e *withGitHubRepo) RepoEntity() Entity { return e.Repo() } +func (e *withGitHubRepo) Issue(id string) *GitHubIssue { + return NewGitHubIssue(e.hostname, e.ownerID, e.repoID, id) +} +func (e *withGitHubRepo) IssueEntity(id string) Entity { return e.Issue(id) } +func (e *withGitHubRepo) Milestone(id string) *GitHubMilestone { + return NewGitHubMilestone(e.hostname, e.ownerID, e.repoID, id) +} +func (e *withGitHubRepo) MilestoneEntity(id string) Entity { return e.Milestone(id) } + +// githubID (issue, milestone, PR, ...)) + +type hasWithGitHubID interface { + Hostname() string + OwnerID() string + RepoID() string + ID() string +} + +type withGitHubID struct{ hostname, ownerID, repoID, id string } + +func (e *withGitHubID) Provider() Provider { return GitHubProvider } +func (e *withGitHubID) Hostname() string { return githubHostname(e.hostname) } +func (e *withGitHubID) OwnerID() string { return e.ownerID } +func (e *withGitHubID) RepoID() string { return e.repoID } +func (e *withGitHubID) ID() string { return e.id } +func (e *withGitHubID) Service() *GitHubService { return NewGitHubService(e.hostname) } +func (e *withGitHubID) ServiceEntity() Entity { return e.Service() } +func (e *withGitHubID) Owner() *GitHubOwner { return NewGitHubOwner(e.hostname, e.ownerID) } +func (e *withGitHubID) OwnerEntity() Entity { return e.Owner() } +func (e *withGitHubID) Repo() *GitHubRepo { return NewGitHubRepo(e.hostname, e.ownerID, e.repoID) } +func (e *withGitHubID) RepoEntity() Entity { return e.Repo() } + +// +// Helpers +// + +const ( + githubAPIReposPart = "repos" + githubAPIUsersPart = "users" + githubAPIIssuesPart = "issues" + githubAPIPullsPart = "pulls" + githubAPIMilestonesPart = "milestones" + githubAPILabelsPart = "labels" +) + +func gitHubRelDecodeString(hostname, owner, repo, input string, force bool) (Entity, error) { + if hostname == "" { + hostname = "github.com" + } + u, err := url.Parse(input) + if err != nil { + return nil, err + } + if u.Host == "api.github.com" { + parts := strings.Split(u.Path, "/") + if parts[0] == "" { + parts = parts[1:] + } + + if len(parts) >= 5 && parts[0] == githubAPIReposPart && parts[3] == githubAPILabelsPart { + return NewGitHubLabel(hostname, parts[1], parts[2], strings.Join(parts[4:], "/")), nil + } + + switch len(parts) { + case 2: + if parts[0] == githubAPIUsersPart { + return NewGitHubOwner(hostname, parts[1]), nil + } + case 3: + if parts[0] == githubAPIReposPart { + return NewGitHubRepo(hostname, parts[1], parts[2]), nil + } + case 5: + if parts[0] == githubAPIReposPart && parts[3] == githubAPIIssuesPart { + return NewGitHubIssueOrPullRequest(hostname, parts[1], parts[2], parts[4]), nil + } + if parts[0] == githubAPIReposPart && parts[3] == githubAPIPullsPart { + return NewGitHubIssueOrPullRequest(hostname, parts[1], parts[2], parts[4]), nil + } + if parts[0] == githubAPIReposPart && parts[3] == githubAPIMilestonesPart { + return NewGitHubMilestone(hostname, parts[1], parts[2], parts[4]), nil + } + } + return nil, fmt.Errorf("failed to parse %q", input) + + } + if isProviderScheme(u.Scheme) { // github://, gitlab://, ... + return DecodeString(input) + } + u.Path = strings.Trim(u.Path, "/") + if u.Host == "" && len(u.Path) > 0 { // domain.com/a/b + u.Host = getHostname(u.Path) + if u.Host != "" { + u.Path = u.Path[len(u.Host):] + u.Path = strings.Trim(u.Path, "/") + } + } + if u.Host != "" && u.Host != hostname && !force { + return DecodeString(input) + } + if owner != "" && repo != "" && u.Path == "" && u.Fragment != "" { // #42 from a repo + return NewGitHubIssueOrPullRequest(hostname, owner, repo, u.Fragment), nil + } + if u.Path == "" && u.Fragment == "" { + return NewGitHubService(hostname), nil + } + if u.Path != "" && u.Fragment != "" { // user/repo#42 + u.Path += "/issue-or-pull-request/" + u.Fragment + } + parts := strings.Split(u.Path, "/") + + if len(parts) >= 4 && parts[2] == "labels" { + return NewGitHubLabel(hostname, parts[0], parts[1], strings.Join(parts[3:], "/")), nil + } + + switch len(parts) { + case 1: + if u.Host != "" && parts[0][0] != '@' { + return NewGitHubOwner(hostname, parts[0]), nil + } + if parts[0][0] == '@' { + return NewGitHubOwner(hostname, parts[0][1:]), nil + } + case 2: + // FIXME: if starting with @ -> it's a team + return NewGitHubRepo(hostname, parts[0], parts[1]), nil + case 4: + switch parts[2] { + case "issues": + return NewGitHubIssue(hostname, parts[0], parts[1], parts[3]), nil + case "milestone": + return NewGitHubMilestone(hostname, parts[0], parts[1], parts[3]), nil + case "pull": + return NewGitHubPullRequest(hostname, parts[0], parts[1], parts[3]), nil + case "issue-or-pull-request": + return NewGitHubIssueOrPullRequest(hostname, parts[0], parts[1], parts[3]), nil + } + } + + return nil, fmt.Errorf("failed to parse %q", input) +} + +func githubHostname(input string) string { + if input == "" { + return "github.com" + } + return input +} diff --git a/pkg/multipmuri/github_test.go b/pkg/multipmuri/github_test.go new file mode 100644 index 000000000..ab3430243 --- /dev/null +++ b/pkg/multipmuri/github_test.go @@ -0,0 +1,220 @@ +package multipmuri + +import "fmt" + +func ExampleNewGitHubIssue() { + entity := NewGitHubIssue("", "moul", "depviz", "42") + fmt.Println("entity") + fmt.Println(" ", entity.String()) + fmt.Println(" ", entity.Kind()) + fmt.Println(" ", entity.Provider()) + + relatives := []string{ + "@moul", + "#4242", + "moul2/depviz2#43", + "moul/depviz#42", + "moul/depviz", + "github.com/moul2/depviz2#42", + "https://github.com/moul2/depviz2#42", + "https://example.com/a/b#42", + "https://gitlab.com/moul/depviz/issues/42", + } + fmt.Println("relationships") + for _, name := range relatives { + attrs := "" + rel, err := entity.RelDecodeString(name) + if err != nil { + fmt.Printf(" %-42s -> error: %v\n", name, err) + continue + } + if rel.Equals(entity) { + attrs += " (equals)" + } + if entity.Contains(rel) { + attrs += " (contains)" + } + if rel.Contains(entity) { + attrs += " (is contained)" + } + fmt.Printf(" %-42s -> %s%s\n", name, rel.String(), attrs) + } + fmt.Println("repo:", entity.RepoEntity().String()) + fmt.Println("owner:", entity.OwnerEntity().String()) + fmt.Println("complex relationship:", + entity.Owner(). + Service(). + Owner("test1"). + Repo("test2"). + Issue("42"). + Repo(). + Service(). + Owner("test3"). + Repo("test4"). + Milestone("42"). + String()) + // Output: + // entity + // https://github.com/moul/depviz/issues/42 + // issue + // github + // relationships + // @moul -> https://github.com/moul (is contained) + // #4242 -> https://github.com/moul/depviz/issues/4242 + // moul2/depviz2#43 -> https://github.com/moul2/depviz2/issues/43 + // moul/depviz#42 -> https://github.com/moul/depviz/issues/42 (equals) + // moul/depviz -> https://github.com/moul/depviz (is contained) + // github.com/moul2/depviz2#42 -> https://github.com/moul2/depviz2/issues/42 + // https://github.com/moul2/depviz2#42 -> https://github.com/moul2/depviz2/issues/42 + // https://example.com/a/b#42 -> error: ambiguous uri "https://example.com/a/b#42" + // https://gitlab.com/moul/depviz/issues/42 -> https://gitlab.com/moul/depviz/issues/42 + // repo: https://github.com/moul/depviz + // owner: https://github.com/moul + // complex relationship: https://github.com/test3/test4/milestone/42 +} + +func ExampleNewGitHubService() { + entity := NewGitHubService("github.com") + fmt.Println("entity") + fmt.Println(" ", entity.String()) + fmt.Println(" ", entity.Kind()) + fmt.Println(" ", entity.Provider()) + + relatives := []string{ + "https://github.com", + "github.com", + "github.com/moul", + "@moul", + "github.com/moul/depviz", + "moul/depviz", + "moul/depviz/labels/bug", + "moul/depviz/labels/a/b/c", + "moul/depviz/milestone/1", + "moul/depviz#1", + "github.com/moul/depviz/issues/2", + "github.com/moul/depviz/pull/1", + "https://github.com/moul/depviz/issues/1", + "https://github.com/moul/depviz#1", + "github://moul/depviz#1", + "github://github.com/moul/depviz#1", + "github://https://github.com/moul/depviz#1", + } + fmt.Println("relationships") + for _, name := range relatives { + rel, err := entity.RelDecodeString(name) + if err != nil { + fmt.Printf(" %-42s -> error: %v\n", name, err) + continue + } + fmt.Printf(" %-42s -> %-43s %s\n", name, rel.String(), rel.Kind()) + } + // Output: + // entity + // https://github.com/ + // service + // github + // relationships + // https://github.com -> https://github.com/ service + // github.com -> https://github.com/ service + // github.com/moul -> https://github.com/moul user-or-organization + // @moul -> https://github.com/moul user-or-organization + // github.com/moul/depviz -> https://github.com/moul/depviz project + // moul/depviz -> https://github.com/moul/depviz project + // moul/depviz/labels/bug -> https://github.com/moul/depviz/labels/bug label + // moul/depviz/labels/a/b/c -> https://github.com/moul/depviz/labels/a/b/c label + // moul/depviz/milestone/1 -> https://github.com/moul/depviz/milestone/1 milestone + // moul/depviz#1 -> https://github.com/moul/depviz/issues/1 issue-or-merge-request + // github.com/moul/depviz/issues/2 -> https://github.com/moul/depviz/issues/2 issue + // github.com/moul/depviz/pull/1 -> https://github.com/moul/depviz/issues/1 merge-request + // https://github.com/moul/depviz/issues/1 -> https://github.com/moul/depviz/issues/1 issue + // https://github.com/moul/depviz#1 -> https://github.com/moul/depviz/issues/1 issue-or-merge-request + // github://moul/depviz#1 -> https://github.com/moul/depviz/issues/1 issue-or-merge-request + // github://github.com/moul/depviz#1 -> https://github.com/moul/depviz/issues/1 issue-or-merge-request + // github://https://github.com/moul/depviz#1 -> https://github.com/moul/depviz/issues/1 issue-or-merge-request +} + +func ExampleNewGitHubService_API() { + entity := NewGitHubService("github.com") + relatives := []string{ + "https://api.github.com/repos/moul/depviz/labels/bug", + "https://api.github.com/repos/moul/depviz/labels/a/b/c", + "https://api.github.com/repos/moul/depviz", + "https://api.github.com/repos/moul/depviz/issues/1", + "https://api.github.com/repos/moul/depviz/pulls/170", + "https://api.github.com/users/moul", + "https://api.github.com/repos/moul/depviz/milestones/1", + } + for _, relative := range relatives { + rel, err := entity.RelDecodeString(relative) + if err != nil { + fmt.Printf("%-42s -> error %v\n", relative, err) + continue + } + fmt.Printf("%-53s -> %-50s %-30s %s\n", relative, rel.String(), rel.Kind(), rel.LocalID()) + } + + // Output: + // https://api.github.com/repos/moul/depviz/labels/bug -> https://github.com/moul/depviz/labels/bug label moul/depviz/labels/bug + // https://api.github.com/repos/moul/depviz/labels/a/b/c -> https://github.com/moul/depviz/labels/a/b/c label moul/depviz/labels/a/b/c + // https://api.github.com/repos/moul/depviz -> https://github.com/moul/depviz project moul/depviz + // https://api.github.com/repos/moul/depviz/issues/1 -> https://github.com/moul/depviz/issues/1 issue-or-merge-request moul/depviz#1 + // https://api.github.com/repos/moul/depviz/pulls/170 -> https://github.com/moul/depviz/issues/170 issue-or-merge-request moul/depviz#170 + // https://api.github.com/users/moul -> https://github.com/moul user-or-organization @moul + // https://api.github.com/repos/moul/depviz/milestones/1 -> https://github.com/moul/depviz/milestone/1 milestone moul/depviz/milestone/1 +} + +func ExampleNewGitHubService_Enterprise() { + entity := NewGitHubService("ge.company.com") + fmt.Println("entity") + fmt.Println(" ", entity.String()) + fmt.Println(" ", entity.Kind()) + fmt.Println(" ", entity.Provider()) + + relatives := []string{ + "https://github.com", + "github.com", + "github.com/moul", + "@moul", + "github.com/moul/depviz", + "moul/depviz", + "moul/depviz/milestone/1", + "moul/depviz#1", + "github.com/moul/depviz/issues/2", + "github.com/moul/depviz/pull/1", + "https://github.com/moul/depviz/issues/1", + "https://github.com/moul/depviz#1", + "github://moul/depviz#1", + "github://github.com/moul/depviz#1", + "github://https://github.com/moul/depviz#1", + } + fmt.Println("relationships") + for _, name := range relatives { + rel, err := entity.RelDecodeString(name) + if err != nil { + fmt.Printf(" %-42s -> error: %v\n", name, err) + continue + } + fmt.Printf(" %-42s -> %-48s %-30s %s\n", name, rel.String(), rel.Kind(), rel.LocalID()) + } + // Output: + // entity + // https://ge.company.com/ + // service + // github + // relationships + // https://github.com -> https://github.com/ service github.com + // github.com -> https://github.com/ service github.com + // github.com/moul -> https://github.com/moul user-or-organization @moul + // @moul -> https://ge.company.com/moul user-or-organization @moul + // github.com/moul/depviz -> https://github.com/moul/depviz project moul/depviz + // moul/depviz -> https://ge.company.com/moul/depviz project moul/depviz + // moul/depviz/milestone/1 -> https://ge.company.com/moul/depviz/milestone/1 milestone moul/depviz/milestone/1 + // moul/depviz#1 -> https://ge.company.com/moul/depviz/issues/1 issue-or-merge-request moul/depviz#1 + // github.com/moul/depviz/issues/2 -> https://github.com/moul/depviz/issues/2 issue moul/depviz#2 + // github.com/moul/depviz/pull/1 -> https://github.com/moul/depviz/issues/1 merge-request moul/depviz#1 + // https://github.com/moul/depviz/issues/1 -> https://github.com/moul/depviz/issues/1 issue moul/depviz#1 + // https://github.com/moul/depviz#1 -> https://github.com/moul/depviz/issues/1 issue-or-merge-request moul/depviz#1 + // github://moul/depviz#1 -> https://github.com/moul/depviz/issues/1 issue-or-merge-request moul/depviz#1 + // github://github.com/moul/depviz#1 -> https://github.com/moul/depviz/issues/1 issue-or-merge-request moul/depviz#1 + // github://https://github.com/moul/depviz#1 -> https://github.com/moul/depviz/issues/1 issue-or-merge-request moul/depviz#1 +} diff --git a/pkg/multipmuri/gitlab.go b/pkg/multipmuri/gitlab.go new file mode 100644 index 000000000..572c7e64e --- /dev/null +++ b/pkg/multipmuri/gitlab.go @@ -0,0 +1,493 @@ +package multipmuri + +import ( + "fmt" + "net/url" + "strings" +) + +// +// GitLabService +// + +type GitLabService struct { + Service + *withGitLabHostname +} + +func NewGitLabService(hostname string) *GitLabService { + return &GitLabService{ + Service: &service{}, + withGitLabHostname: &withGitLabHostname{hostname}, + } +} + +func (e GitLabService) String() string { + return fmt.Sprintf("https://%s/", e.Hostname()) +} + +func (e GitLabService) LocalID() string { + return e.Hostname() +} + +func (e GitLabService) RelDecodeString(input string) (Entity, error) { + return gitLabRelDecodeString(e.Hostname(), "", "", input, false) +} + +func (e GitLabService) Equals(other Entity) bool { + if typed, valid := other.(*GitLabService); valid { + return e.Hostname() == typed.Hostname() + } + return false +} + +func (e GitLabService) Contains(other Entity) bool { + switch other.(type) { + case *GitLabRepo, *GitLabOwnerOrRepo, *GitLabMilestone, *GitLabIssue, *GitLabMergeRequest, *GitLabOwner: + // FIXME: OrganizationOrRepo is not fully checked + if typed, valid := other.(hasWithGitLabHostname); valid { + return e.Hostname() == typed.Hostname() + } + } + return false +} + +// +// GitLabIssue +// + +type GitLabIssue struct { + Issue + *withGitLabID +} + +func NewGitLabIssue(hostname, owner, repo, id string) *GitLabIssue { + return &GitLabIssue{ + Issue: &issue{}, + withGitLabID: &withGitLabID{hostname, owner, repo, id}, + } +} + +func (e GitLabIssue) String() string { + return fmt.Sprintf("https://%s/%s/%s/issues/%s", e.Hostname(), e.Owner(), e.Repo(), e.ID()) +} + +func (e GitLabIssue) LocalID() string { + return fmt.Sprintf("%s/%s#%s", e.Owner(), e.Repo(), e.ID()) +} + +func (e GitLabIssue) RelDecodeString(input string) (Entity, error) { + return gitLabRelDecodeString(e.Hostname(), e.Owner(), e.Repo(), input, false) +} + +func (e GitLabIssue) Equals(other Entity) bool { + if typed, valid := other.(*GitLabIssue); valid { + return e.Hostname() == typed.Hostname() && + e.Owner() == typed.Owner() && + e.Repo() == typed.Repo() && + e.ID() == typed.ID() + } + return false +} + +func (e GitLabIssue) Contains(other Entity) bool { + return false +} + +// +// GitLabMilestone +// + +type GitLabMilestone struct { + Milestone + *withGitLabID +} + +func NewGitLabMilestone(hostname, owner, repo, id string) *GitLabMilestone { + return &GitLabMilestone{ + Milestone: &milestone{}, + withGitLabID: &withGitLabID{hostname, owner, repo, id}, + } +} + +func (e GitLabMilestone) String() string { + return fmt.Sprintf("https://%s/%s/%s/-/milestones/%s", e.Hostname(), e.Owner(), e.Repo(), e.ID()) +} + +func (e GitLabMilestone) LocalID() string { + return fmt.Sprintf("%s/%s/milestone/%s", e.Owner(), e.Repo(), e.ID()) +} + +func (e GitLabMilestone) RelDecodeString(input string) (Entity, error) { + return gitLabRelDecodeString(e.Hostname(), e.Owner(), e.Repo(), input, false) +} + +func (e GitLabMilestone) Equals(other Entity) bool { + if typed, valid := other.(*GitLabMilestone); valid { + return e.Hostname() == typed.Hostname() && + e.Owner() == typed.Owner() && + e.Repo() == typed.Repo() && + e.ID() == typed.ID() + } + return false +} + +func (e GitLabMilestone) Contains(other Entity) bool { + return false +} + +// +// GitLabMergeRequest +// + +type GitLabMergeRequest struct { + MergeRequest + *withGitLabID +} + +func NewGitLabMergeRequest(hostname, owner, repo, id string) *GitLabMergeRequest { + return &GitLabMergeRequest{ + MergeRequest: &mergeRequest{}, + withGitLabID: &withGitLabID{hostname, owner, repo, id}, + } +} + +func (e GitLabMergeRequest) String() string { + return fmt.Sprintf("https://%s/%s/%s/merge_requests/%s", e.Hostname(), e.Owner(), e.Repo(), e.ID()) +} + +func (e GitLabMergeRequest) LocalID() string { + return fmt.Sprintf("%s/%s!%s", e.Owner(), e.Repo(), e.ID()) +} + +func (e GitLabMergeRequest) RelDecodeString(input string) (Entity, error) { + return gitLabRelDecodeString(e.Hostname(), e.Owner(), e.Repo(), input, false) +} + +func (e GitLabMergeRequest) Equals(other Entity) bool { + if typed, valid := other.(*GitLabMergeRequest); valid { + return e.Hostname() == typed.Hostname() && + e.Owner() == typed.Owner() && + e.Repo() == typed.Repo() && + e.ID() == typed.ID() + } + return false +} + +func (e GitLabMergeRequest) Contains(other Entity) bool { + return false +} + +// +// GitLabOwner +// + +type GitLabOwner struct { + UserOrOrganization + *withGitLabOwner +} + +func NewGitLabOwner(hostname, owner string) *GitLabOwner { + return &GitLabOwner{ + UserOrOrganization: &userOrOrganization{}, + withGitLabOwner: &withGitLabOwner{hostname, owner}, + } +} + +func (e GitLabOwner) String() string { + return fmt.Sprintf("https://%s/%s", e.Hostname(), e.Owner()) +} + +func (e GitLabOwner) LocalID() string { + return fmt.Sprintf("@%s", e.Owner()) +} + +func (e GitLabOwner) RelDecodeString(input string) (Entity, error) { + return gitLabRelDecodeString(e.Hostname(), e.Owner(), "", input, false) +} + +func (e GitLabOwner) Equals(other Entity) bool { + if typed, valid := other.(*GitLabOwner); valid { + return e.Hostname() == typed.Hostname() && + e.Owner() == typed.Owner() + } + return false +} + +func (e GitLabOwner) Contains(other Entity) bool { + switch other.(type) { + case *GitLabRepo, *GitLabOwnerOrRepo, *GitLabMilestone, *GitLabIssue, *GitLabMergeRequest: + // FIXME: OrganizationOrRepo is not fully checked + if typed, valid := other.(hasWithGitLabOwner); valid { + return e.Hostname() == typed.Hostname() && + e.Owner() == typed.Owner() + } + } + return false +} + +// +// GitLabOwner +// + +type GitLabOwnerOrRepo struct { + OrganizationOrProject + *withGitLabRepo +} + +func NewGitLabOwnerOrRepo(hostname, owner, repo string) *GitLabOwnerOrRepo { + return &GitLabOwnerOrRepo{ + OrganizationOrProject: &organizationOrProject{}, + withGitLabRepo: &withGitLabRepo{hostname, owner, repo}, + } +} + +func (e GitLabOwnerOrRepo) String() string { + return fmt.Sprintf("https://%s/%s/%s", e.Hostname(), e.Owner(), e.Repo()) +} + +func (e GitLabOwnerOrRepo) LocalID() string { + return fmt.Sprintf("%s/%s", e.Owner(), e.Repo()) +} + +func (e GitLabOwnerOrRepo) RelDecodeString(input string) (Entity, error) { + return gitLabRelDecodeString(e.Hostname(), e.Owner(), e.Repo(), input, false) +} + +func (e GitLabOwnerOrRepo) Equals(other Entity) bool { + switch other.(type) { + case *GitLabOwnerOrRepo, *GitLabRepo: + if typed, valid := other.(hasWithGitLabRepo); valid { + return e.Hostname() == typed.Hostname() && + e.Owner() == typed.Owner() && + e.Repo() == typed.Repo() + } + } + return false +} + +func (e GitLabOwnerOrRepo) Contains(other Entity) bool { + // FIXME: check for GitLabRepo if GitLabOwnerOrRepo is actually a GitLabOwner + switch other.(type) { + case *GitLabMilestone, *GitLabIssue, *GitLabMergeRequest: + if typed, valid := other.(hasWithGitLabRepo); valid { + return e.Hostname() == typed.Hostname() && + e.Owner() == typed.Owner() && + e.Repo() == typed.Repo() + } + } + return false +} + +// +// GitLabRepo +// + +type GitLabRepo struct { + Project + *withGitLabRepo +} + +func NewGitLabRepo(hostname, owner, repo string) *GitLabRepo { + return &GitLabRepo{ + Project: &project{}, + withGitLabRepo: &withGitLabRepo{hostname, owner, repo}, + } +} + +func (e GitLabRepo) String() string { + return fmt.Sprintf("https://%s/%s/%s", e.Hostname(), e.Owner(), e.Repo()) +} + +func (e GitLabRepo) LocalID() string { + return fmt.Sprintf("%s/%s", e.Owner(), e.Repo()) +} + +func (e GitLabRepo) RelDecodeString(input string) (Entity, error) { + return gitLabRelDecodeString(e.Hostname(), e.Owner(), e.Repo(), input, false) +} + +func (e GitLabRepo) Equals(other Entity) bool { + switch other.(type) { + case *GitLabOwnerOrRepo, *GitLabRepo: + if typed, valid := other.(hasWithGitLabRepo); valid { + return e.Hostname() == typed.Hostname() && + e.Owner() == typed.Owner() && + e.Repo() == typed.Repo() + } + } + return false +} + +func (e GitLabRepo) Contains(other Entity) bool { + switch other.(type) { + case *GitLabMilestone, *GitLabIssue, *GitLabMergeRequest: + if typed, valid := other.(hasWithGitLabRepo); valid { + return e.Hostname() == typed.Hostname() && + e.Owner() == typed.Owner() && + e.Repo() == typed.Repo() + } + } + return false +} + +// +// GitLabCommon +// + +type hasWithGitLabHostname interface { + Hostname() string +} + +type withGitLabHostname struct{ hostname string } + +func (e *withGitLabHostname) Provider() Provider { return GitLabProvider } +func (e *withGitLabHostname) Hostname() string { return gitlabHostname(e.hostname) } +func (e *withGitLabHostname) ServiceEntity() *GitLabService { return NewGitLabService(e.hostname) } + +type hasWithGitLabOwner interface { + Hostname() string + Owner() string +} + +type withGitLabOwner struct{ hostname, owner string } + +func (e *withGitLabOwner) Provider() Provider { return GitLabProvider } +func (e *withGitLabOwner) Hostname() string { return gitlabHostname(e.hostname) } +func (e *withGitLabOwner) Owner() string { return e.owner } +func (e *withGitLabOwner) ServiceEntity() *GitLabService { return NewGitLabService(e.hostname) } +func (e *withGitLabOwner) RepoEntity(repo string) *GitLabRepo { + return NewGitLabRepo(e.hostname, e.owner, repo) +} + +type hasWithGitLabRepo interface { + Hostname() string + Owner() string + Repo() string +} + +type withGitLabRepo struct{ hostname, owner, repo string } + +func (e *withGitLabRepo) Provider() Provider { return GitLabProvider } +func (e *withGitLabRepo) Hostname() string { return gitlabHostname(e.hostname) } +func (e *withGitLabRepo) Owner() string { return e.owner } +func (e *withGitLabRepo) Repo() string { return e.repo } +func (e *withGitLabRepo) ServiceEntity() *GitLabService { return NewGitLabService(e.hostname) } +func (e *withGitLabRepo) RepoEntity() *GitLabRepo { return NewGitLabRepo(e.hostname, e.owner, e.repo) } + +/* unused +type hasWithGitLabID interface { + Hostname() string + Owner() string + Repo() string + ID() string +} +*/ + +type withGitLabID struct{ hostname, owner, repo, id string } + +func (e *withGitLabID) Provider() Provider { return GitLabProvider } +func (e *withGitLabID) Hostname() string { return gitlabHostname(e.hostname) } +func (e *withGitLabID) Owner() string { return e.owner } +func (e *withGitLabID) Repo() string { return e.repo } +func (e *withGitLabID) ID() string { return e.id } +func (e *withGitLabID) RepoEntity() *GitLabRepo { return NewGitLabRepo(e.hostname, e.owner, e.repo) } + +// +// Helpers +// + +func gitLabRelDecodeString(hostname, owner, repo, input string, force bool) (Entity, error) { + if hostname == "" { + hostname = "gitlab.com" + } + u, err := url.Parse(input) + if err != nil { + return nil, err + } + if isProviderScheme(u.Scheme) { // gitlab://, gitlab://, ... + return DecodeString(input) + } + u.Path = strings.Trim(u.Path, "/") + if u.Host == "" && len(u.Path) > 0 { // domain.com/a/b + u.Host = getHostname(u.Path) + if u.Host != "" { + u.Path = u.Path[len(u.Host):] + u.Path = strings.Trim(u.Path, "/") + } + } + if u.Host != "" && u.Host != hostname && !force { + return DecodeString(input) + } + if owner != "" && repo != "" && strings.HasPrefix(u.Path, "!") { // !42 from a repo + return NewGitLabMergeRequest(hostname, owner, repo, u.Path[1:]), nil + } + if owner != "" && repo != "" && u.Path == "" && u.Fragment != "" { // #42 from a repo + return NewGitLabIssue(hostname, owner, repo, u.Fragment), nil + } + if u.Path == "" && u.Fragment == "" { + return NewGitLabService(hostname), nil + } + if strings.Contains(u.Path, "!") { + parts := strings.Split(u.Path, "!") + u.Path = fmt.Sprintf("%s/merge_requests/%s", parts[0], parts[1]) + } + if u.Path != "" && u.Fragment != "" { // user/repo#42 + u.Path += "/issues/" + u.Fragment + } + parts := strings.Split(u.Path, "/") + lenParts := len(parts) + switch lenParts { + case 1: // user or org + if u.Host != "" && parts[0][0] != '@' { + return NewGitLabOwner(hostname, parts[0]), nil + } + if parts[0][0] == '@' { + return NewGitLabOwner(hostname, parts[0][1:]), nil + } + case 2: + // org or rep + return NewGitLabOwnerOrRepo(hostname, parts[0], parts[1]), nil + case 0: + panic("should not happen") + default: // more than 2 + switch { + case parts[lenParts-2] == "issues": + return NewGitLabIssue( + hostname, + strings.Join(parts[:lenParts-3], "/"), + parts[lenParts-3], + parts[lenParts-1], + ), nil + case parts[lenParts-2] == "merge_requests": + return NewGitLabMergeRequest( + hostname, + strings.Join(parts[:lenParts-3], "/"), + parts[lenParts-3], + parts[lenParts-1], + ), nil + case parts[lenParts-2] == "milestones" && parts[lenParts-3] == "-": + return NewGitLabMilestone( + hostname, + strings.Join(parts[:lenParts-4], "/"), + parts[lenParts-4], + parts[lenParts-1], + ), nil + default: + return NewGitLabRepo( + hostname, + strings.Join(parts[:lenParts-1], "/"), + parts[lenParts-1], + ), nil + } + } + + return nil, fmt.Errorf("failed to parse %q", input) +} + +func gitlabHostname(input string) string { + if input == "" { + return "gitlab.com" + } + return input +} diff --git a/pkg/multipmuri/gitlab_test.go b/pkg/multipmuri/gitlab_test.go new file mode 100644 index 000000000..6ea3c3bdc --- /dev/null +++ b/pkg/multipmuri/gitlab_test.go @@ -0,0 +1,172 @@ +package multipmuri + +import "fmt" + +func ExampleNewGitLabIssue() { + entity := NewGitLabIssue("", "moul", "depviz", "42") + fmt.Println("entity") + fmt.Println(" ", entity.String()) + fmt.Println(" ", entity.Kind()) + fmt.Println(" ", entity.Provider()) + + relatives := []string{ + "@moul", + "#4242", + "moul2/depviz2#43", + "gitlab.com/moul2/depviz2#42", + "https://gitlab.com/moul2/depviz2#42", + "https://example.com/a/b#42", + "https://gitlab.com/moul/depviz/issues/42", + } + fmt.Println("relationships") + for _, name := range relatives { + rel, err := entity.RelDecodeString(name) + if err != nil { + fmt.Printf(" %-42s -> error: %v\n", name, err) + continue + } + fmt.Printf(" %-42s -> %s\n", name, rel.String()) + } + fmt.Println("repo:", entity.RepoEntity().String()) + // Output: + // entity + // https://gitlab.com/moul/depviz/issues/42 + // issue + // gitlab + // relationships + // @moul -> https://gitlab.com/moul + // #4242 -> https://gitlab.com/moul/depviz/issues/4242 + // moul2/depviz2#43 -> https://gitlab.com/moul2/depviz2/issues/43 + // gitlab.com/moul2/depviz2#42 -> https://gitlab.com/moul2/depviz2/issues/42 + // https://gitlab.com/moul2/depviz2#42 -> https://gitlab.com/moul2/depviz2/issues/42 + // https://example.com/a/b#42 -> error: ambiguous uri "https://example.com/a/b#42" + // https://gitlab.com/moul/depviz/issues/42 -> https://gitlab.com/moul/depviz/issues/42 + // repo: https://gitlab.com/moul/depviz +} + +func ExampleNewGitLabService() { + entity := NewGitLabService("gitlab.com") + fmt.Println("entity") + fmt.Println(" ", entity.String()) + fmt.Println(" ", entity.Kind()) + fmt.Println(" ", entity.Provider()) + + relatives := []string{ + "https://gitlab.com", + "gitlab.com", + "gitlab.com/moul", + "@moul", + "gitlab.com/moul/depviz", + "moul/depviz", + "moul/depviz/-/milestones/1", + "moul/depviz#1", + "gitlab.com/moul/depviz/issues/2", + "gitlab.com/moul/depviz/merge_requests/1", + "https://gitlab.com/moul/depviz/issues/1", + "https://gitlab.com/moul/depviz#1", + "gitlab://moul/depviz#1", + "gitlab://gitlab.com/moul/depviz#1", + "gitlab://https://gitlab.com/moul/depviz#1", + "gitlab.com/a/b/c/d/e/f", + "gitlab.com/a/b/c/d/e/f#1", + "gitlab.com/a/b/c/d/e/f!1", + "a/b/c/d/e/f!1", + "a/b/c/d/e/f#1", + "a/b#1", + "a/b!1", + } + fmt.Println("relationships") + for _, name := range relatives { + rel, err := entity.RelDecodeString(name) + if err != nil { + fmt.Printf(" %-42s -> error: %v\n", name, err) + continue + } + fmt.Printf(" %-42s -> %-48s %s\n", name, rel.String(), rel.Kind()) + } + // Output: + // entity + // https://gitlab.com/ + // service + // gitlab + // relationships + // https://gitlab.com -> https://gitlab.com/ service + // gitlab.com -> https://gitlab.com/ service + // gitlab.com/moul -> https://gitlab.com/moul user-or-organization + // @moul -> https://gitlab.com/moul user-or-organization + // gitlab.com/moul/depviz -> https://gitlab.com/moul/depviz organization-or-project + // moul/depviz -> https://gitlab.com/moul/depviz organization-or-project + // moul/depviz/-/milestones/1 -> https://gitlab.com/moul/depviz/-/milestones/1 milestone + // moul/depviz#1 -> https://gitlab.com/moul/depviz/issues/1 issue + // gitlab.com/moul/depviz/issues/2 -> https://gitlab.com/moul/depviz/issues/2 issue + // gitlab.com/moul/depviz/merge_requests/1 -> https://gitlab.com/moul/depviz/merge_requests/1 merge-request + // https://gitlab.com/moul/depviz/issues/1 -> https://gitlab.com/moul/depviz/issues/1 issue + // https://gitlab.com/moul/depviz#1 -> https://gitlab.com/moul/depviz/issues/1 issue + // gitlab://moul/depviz#1 -> https://gitlab.com/moul/depviz/issues/1 issue + // gitlab://gitlab.com/moul/depviz#1 -> https://gitlab.com/moul/depviz/issues/1 issue + // gitlab://https://gitlab.com/moul/depviz#1 -> https://gitlab.com/moul/depviz/issues/1 issue + // gitlab.com/a/b/c/d/e/f -> https://gitlab.com/a/b/c/d/e/f project + // gitlab.com/a/b/c/d/e/f#1 -> https://gitlab.com/a/b/c/d/e/f/issues/1 issue + // gitlab.com/a/b/c/d/e/f!1 -> https://gitlab.com/a/b/c/d/e/f/merge_requests/1 merge-request + // a/b/c/d/e/f!1 -> https://gitlab.com/a/b/c/d/e/f/merge_requests/1 merge-request + // a/b/c/d/e/f#1 -> https://gitlab.com/a/b/c/d/e/f/issues/1 issue + // a/b#1 -> https://gitlab.com/a/b/issues/1 issue + // a/b!1 -> https://gitlab.com/a/b/merge_requests/1 merge-request + +} + +func ExampleNewGitLabService_Enterprise() { + entity := NewGitLabService("ge.company.com") + fmt.Println("entity") + fmt.Println(" ", entity.String()) + fmt.Println(" ", entity.Kind()) + fmt.Println(" ", entity.Provider()) + + relatives := []string{ + "https://gitlab.com", + "gitlab.com", + "gitlab.com/moul", + "@moul", + "gitlab.com/moul/depviz", + "moul/depviz", + "moul/depviz/-/milestones/1", + "moul/depviz#1", + "gitlab.com/moul/depviz/issues/2", + "gitlab.com/moul/depviz/merge_requests/1", + "https://gitlab.com/moul/depviz/issues/1", + "https://gitlab.com/moul/depviz#1", + "gitlab://moul/depviz#1", + "gitlab://gitlab.com/moul/depviz#1", + "gitlab://https://gitlab.com/moul/depviz#1", + } + fmt.Println("relationships") + for _, name := range relatives { + rel, err := entity.RelDecodeString(name) + if err != nil { + fmt.Printf(" %-42s -> error: %v\n", name, err) + continue + } + fmt.Printf(" %-42s -> %-43s %s\n", name, rel.String(), rel.Kind()) + } + // Output: + // entity + // https://ge.company.com/ + // service + // gitlab + // relationships + // https://gitlab.com -> https://gitlab.com/ service + // gitlab.com -> https://gitlab.com/ service + // gitlab.com/moul -> https://gitlab.com/moul user-or-organization + // @moul -> https://ge.company.com/moul user-or-organization + // gitlab.com/moul/depviz -> https://gitlab.com/moul/depviz organization-or-project + // moul/depviz -> https://ge.company.com/moul/depviz organization-or-project + // moul/depviz/-/milestones/1 -> https://ge.company.com/moul/depviz/-/milestones/1 milestone + // moul/depviz#1 -> https://ge.company.com/moul/depviz/issues/1 issue + // gitlab.com/moul/depviz/issues/2 -> https://gitlab.com/moul/depviz/issues/2 issue + // gitlab.com/moul/depviz/merge_requests/1 -> https://gitlab.com/moul/depviz/merge_requests/1 merge-request + // https://gitlab.com/moul/depviz/issues/1 -> https://gitlab.com/moul/depviz/issues/1 issue + // https://gitlab.com/moul/depviz#1 -> https://gitlab.com/moul/depviz/issues/1 issue + // gitlab://moul/depviz#1 -> https://gitlab.com/moul/depviz/issues/1 issue + // gitlab://gitlab.com/moul/depviz#1 -> https://gitlab.com/moul/depviz/issues/1 issue + // gitlab://https://gitlab.com/moul/depviz#1 -> https://gitlab.com/moul/depviz/issues/1 issue +} diff --git a/pkg/multipmuri/helpers.go b/pkg/multipmuri/helpers.go new file mode 100644 index 000000000..9ea17a7a7 --- /dev/null +++ b/pkg/multipmuri/helpers.go @@ -0,0 +1,31 @@ +package multipmuri + +func RepoEntity(source Entity) Entity { + type hasRepoEntity interface { + RepoEntity() Entity + } + if typed, found := source.(hasRepoEntity); found { + return typed.RepoEntity() + } + return nil +} + +func OwnerEntity(source Entity) Entity { + type hasOwnerEntity interface { + OwnerEntity() Entity + } + if typed, found := source.(hasOwnerEntity); found { + return typed.OwnerEntity() + } + return nil +} + +func ServiceEntity(source Entity) Entity { + type hasServiceEntity interface { + ServiceEntity() Entity + } + if typed, found := source.(hasServiceEntity); found { + return typed.ServiceEntity() + } + return nil +} diff --git a/pkg/multipmuri/helpers_test.go b/pkg/multipmuri/helpers_test.go new file mode 100644 index 000000000..81a411e25 --- /dev/null +++ b/pkg/multipmuri/helpers_test.go @@ -0,0 +1,60 @@ +package multipmuri + +import "fmt" + +func ExampleRepoEntity() { + entities := []Entity{ + NewGitHubIssue("", "moul", "depviz", "42"), + NewGitHubMilestone("", "moul", "depviz", "42"), + NewGitHubRepo("", "moul", "depviz"), + NewGitHubOwner("", "moul"), + NewGitHubService(""), + } + for _, entity := range entities { + fmt.Printf("%-50s -> %v\n", entity, RepoEntity(entity)) + } + // Output: + // https://github.com/moul/depviz/issues/42 -> https://github.com/moul/depviz + // https://github.com/moul/depviz/milestone/42 -> https://github.com/moul/depviz + // https://github.com/moul/depviz -> https://github.com/moul/depviz + // https://github.com/moul -> + // https://github.com/ -> +} + +func ExampleOwnerEntity() { + entities := []Entity{ + NewGitHubIssue("", "moul", "depviz", "42"), + NewGitHubMilestone("", "moul", "depviz", "42"), + NewGitHubRepo("", "moul", "depviz"), + NewGitHubOwner("", "moul"), + NewGitHubService(""), + } + for _, entity := range entities { + fmt.Printf("%-50s -> %v\n", entity, OwnerEntity(entity)) + } + // Output: + // https://github.com/moul/depviz/issues/42 -> https://github.com/moul + // https://github.com/moul/depviz/milestone/42 -> https://github.com/moul + // https://github.com/moul/depviz -> https://github.com/moul + // https://github.com/moul -> https://github.com/moul + // https://github.com/ -> +} + +func ExampleServiceEntity() { + entities := []Entity{ + NewGitHubIssue("", "moul", "depviz", "42"), + NewGitHubMilestone("", "moul", "depviz", "42"), + NewGitHubRepo("", "moul", "depviz"), + NewGitHubOwner("", "moul"), + NewGitHubService(""), + } + for _, entity := range entities { + fmt.Printf("%-50s -> %v\n", entity, ServiceEntity(entity)) + } + // Output: + // https://github.com/moul/depviz/issues/42 -> https://github.com/ + // https://github.com/moul/depviz/milestone/42 -> https://github.com/ + // https://github.com/moul/depviz -> https://github.com/ + // https://github.com/moul -> https://github.com/ + // https://github.com/ -> https://github.com/ +} diff --git a/pkg/multipmuri/multipmuri.go b/pkg/multipmuri/multipmuri.go new file mode 100644 index 000000000..4008fc5e6 --- /dev/null +++ b/pkg/multipmuri/multipmuri.go @@ -0,0 +1,186 @@ +package multipmuri + +// +// Entity +// + +type Entity interface { + WithKind // an entity always have a kind (issue, MR, provider, service, milestone, ...) + WithProvider // an entity always have a provider (GitHub, GitLab, Trello, Jira, ...) + RelDecodeString(string) (Entity, error) // try to parse an URI based on relative context + Equals(Entity) bool // is the same of + Contains(Entity) bool // is parent of or is the same of + String() string // canonical URL + LocalID() string // short name +} + +type WithKind interface { + Kind() Kind +} + +type WithProvider interface { + Provider() Provider +} + +// +// Entities +// + +type Entities []Entity + +// +// Enums +// + +type Provider string + +const ( + UnknownProvider Provider = "unknown-provider" + GitHubProvider Provider = "github" + GitLabProvider Provider = "gitlab" + JiraProvider Provider = "jira" + TrelloProvider Provider = "trello" +) + +type Kind string + +const ( + UnknownKind Kind = "unknown-kind" + IssueKind Kind = "issue" + MergeRequestKind Kind = "merge-request" + ProviderKind Kind = "provider" + UserOrOrganizationKind Kind = "user-or-organization" + OrganizationOrProjectKind Kind = "organization-or-project" + ServiceKind Kind = "service" + MilestoneKind Kind = "milestone" + IssueOrMergeRequestKind Kind = "issue-or-merge-request" + UserKind Kind = "user" + ProjectKind Kind = "project" + LabelKind Kind = "label" +) + +// +// Issue +// + +type Issue interface { + WithKind + IsIssue() +} + +type issue struct{} + +func (issue) IsIssue() {} +func (issue) Kind() Kind { return IssueKind } + +// +// OrganizationOrProject +// + +type OrganizationOrProject interface { + WithKind + IsOrganizationOrProject() +} + +type organizationOrProject struct{} + +func (organizationOrProject) IsOrganizationOrProject() {} +func (organizationOrProject) Kind() Kind { return OrganizationOrProjectKind } + +// +// IssueOrMergeRequest +// + +type IssueOrMergeRequest interface { + WithKind + IsIssueOrMergeRequest() +} + +type issueOrMergeRequest struct{} + +func (issueOrMergeRequest) IsIssueOrMergeRequest() {} +func (issueOrMergeRequest) Kind() Kind { return IssueOrMergeRequestKind } + +// +// Milestone +// + +type Milestone interface { + WithKind + IsMilestone() +} + +type milestone struct{} + +func (milestone) IsMilestone() {} +func (milestone) Kind() Kind { return MilestoneKind } + +// +// Project +// + +type Project interface { + WithKind + IsProject() +} + +type project struct{} + +func (project) IsProject() {} +func (project) Kind() Kind { return ProjectKind } + +// +// Label +// + +type Label interface { + WithKind + IsLabel() +} + +type label struct{} + +func (label) IsLabel() {} +func (label) Kind() Kind { return LabelKind } + +// +// MergeRequest +// + +type MergeRequest interface { + WithKind + IsMergeRequest() +} + +type mergeRequest struct{} + +func (mergeRequest) IsMergeRequest() {} +func (mergeRequest) Kind() Kind { return MergeRequestKind } + +// +// Service +// + +type Service interface { + WithKind + IsService() +} + +type service struct{} + +func (service) IsService() {} +func (service) Kind() Kind { return ServiceKind } + +// +// UserOrOrganization +// + +type UserOrOrganization interface { + WithKind + IsUserOrOrganization() +} + +type userOrOrganization struct{} + +func (userOrOrganization) IsUserOrOrganization() {} +func (userOrOrganization) Kind() Kind { return UserOrOrganizationKind } diff --git a/pkg/multipmuri/pmbodyparser/pmbodyparser.go b/pkg/multipmuri/pmbodyparser/pmbodyparser.go new file mode 100644 index 000000000..d000f3ea3 --- /dev/null +++ b/pkg/multipmuri/pmbodyparser/pmbodyparser.go @@ -0,0 +1,99 @@ +package pmbodyparser + +import ( + "fmt" + "regexp" + "sort" + + "moul.io/depviz/v3/pkg/multipmuri" +) + +type Kind string + +const ( + Blocks Kind = "blocks" + DependsOn Kind = "depends-on" + Fixes Kind = "fixes" + Closes Kind = "closes" + Addresses Kind = "addresses" + RelatedWith Kind = "related-with" + PartOf Kind = "part-of" + ParentOf Kind = "parent-of" +) + +type Relationship struct { + Kind Kind + Target multipmuri.Entity +} + +func (r Relationship) String() string { + return fmt.Sprintf("%s %s", r.Kind, r.Target) +} + +// FIXME: add isDependent / isDepending helpers + +type Relationships []Relationship + +func (r Relationships) Less(i, j int) bool { + if r[i].Kind < r[j].Kind { + return true + } + if r[j].Kind < r[i].Kind { + return false + } + return r[i].Target.String() < r[j].Target.String() +} + +func (r Relationships) Len() int { + return len(r) +} + +func (r Relationships) Swap(i, j int) { + r[i], r[j] = r[j], r[i] +} + +func ParseString(body string) (Relationships, []error) { + return RelParseString(multipmuri.NewUnknownEntity(), body) +} + +var ( + fixesRegex, _ = regexp.Compile(`(?im)^\s*(fix|fixes)\s*[:= ]\s*([^\s,]+)\s*$`) + blocksRegex, _ = regexp.Compile(`(?im)^\s*(block|blocks)\s*[:= ]\s*([^\s,]+)\s*$`) + closesRegex, _ = regexp.Compile(`(?im)^\s*(close|closes)\s*[:= ]\s*([^\s,]+)\s*$`) + parentOfRegex, _ = regexp.Compile(`(?im)^\s*(parent of|parent)\s*[:= ]\s*([^\s,]+)\s*$`) + partOfRegex, _ = regexp.Compile(`(?im)^\s*(part of|part)\s*[:= ]\s*([^\s,]+)\s*$`) + relatedWithRegex, _ = regexp.Compile(`(?im)^\s*(related|related with)\s*[:= ]\s*([^\s,]+)\s*$`) + addressesRegex, _ = regexp.Compile(`(?im)^\s*(address|addresses)\s*[:= ]\s*([^\s,]+)\s*$`) + dependsOnRegex, _ = regexp.Compile(`(?im)^\s*(depend|depends|depend on|depends on)\s*[:= ]\s*([^\s,]+)\s*$`) +) + +func RelParseString(context multipmuri.Entity, body string) (Relationships, []error) { + relationships := Relationships{} + errs := []error{} + + for kind, regex := range map[Kind]*regexp.Regexp{ + Fixes: fixesRegex, + Blocks: blocksRegex, + Closes: closesRegex, + DependsOn: dependsOnRegex, + ParentOf: parentOfRegex, + PartOf: partOfRegex, + RelatedWith: relatedWithRegex, + Addresses: addressesRegex, + } { + for _, match := range regex.FindAllStringSubmatch(body, -1) { + decoded, err := context.RelDecodeString(match[len(match)-1]) + if err != nil { + errs = append(errs, err) + continue + } + relationships = append( + relationships, + Relationship{Kind: kind, Target: decoded}, + ) + } + } + + sort.Sort(relationships) + return relationships, errs +} diff --git a/pkg/multipmuri/pmbodyparser/pmbodyparser_test.go b/pkg/multipmuri/pmbodyparser/pmbodyparser_test.go new file mode 100644 index 000000000..692182062 --- /dev/null +++ b/pkg/multipmuri/pmbodyparser/pmbodyparser_test.go @@ -0,0 +1,105 @@ +package pmbodyparser + +import ( + "fmt" + "testing" + + "moul.io/depviz/v3/pkg/multipmuri" +) + +func ExampleRelParseString() { + body := ` +This PR fixes a lot of things and implement plenty new features. + +Addresses #42 +Depends on: github.com/moul/depviz#42 +Blocks #45 +Block: #46 +fixes: #58 +FIX github.com/moul/depviz#1337 + +Signed-off-by: Super Developer +` + relationships, errs := RelParseString( + multipmuri.NewGitHubIssue("github.com", "moul", "depviz", "1"), + body, + ) + if len(errs) > 0 { + panic(errs) + } + for _, relationship := range relationships { + fmt.Println(relationship) + } + // Output: + // addresses https://github.com/moul/depviz/issues/42 + // blocks https://github.com/moul/depviz/issues/45 + // blocks https://github.com/moul/depviz/issues/46 + // depends-on https://github.com/moul/depviz/issues/42 + // fixes https://github.com/moul/depviz/issues/1337 + // fixes https://github.com/moul/depviz/issues/58 +} + +func ExampleParseString() { + rels, errs := ParseString("Depends on github.com/moul/depviz#1") + if len(errs) > 0 { + panic(errs) + } + for _, rel := range rels { + fmt.Println(rel) + } + // Output: + // depends-on https://github.com/moul/depviz/issues/1 +} + +func TestRelParseString(t *testing.T) { + var tests = []struct { + name string + base multipmuri.Entity + body string + expectedErrsCount int + expectedRels Relationships + }{ + { + "simple", + multipmuri.NewGitHubIssue("", "moul", "depviz", "1"), + "Depends on #2", + 0, + Relationships{ + {Kind: DependsOn, Target: multipmuri.NewGitHubIssue("", "moul", "depviz", "2")}, + }, + }, { + "multiple", + multipmuri.NewGitHubIssue("", "moul", "depviz", "1"), + "Depends on #2\nDepends on #3", + 0, + Relationships{ + {Kind: DependsOn, Target: multipmuri.NewGitHubIssue("", "moul", "depviz", "2")}, + {Kind: DependsOn, Target: multipmuri.NewGitHubIssue("", "moul", "depviz", "3")}, + }, + }, { + "with-spaces", + multipmuri.NewGitHubIssue("", "moul", "depviz", "1"), + " Depends on #2 \n Depends on #3 \n\n ", + 0, + Relationships{ + {Kind: DependsOn, Target: multipmuri.NewGitHubIssue("", "moul", "depviz", "2")}, + {Kind: DependsOn, Target: multipmuri.NewGitHubIssue("", "moul", "depviz", "3")}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + rels, errs := RelParseString(test.base, test.body) + if test.expectedErrsCount != len(errs) { + t.Errorf("Expected %d errs, got %d.", test.expectedErrsCount, len(errs)) + } + + expectedStr := fmt.Sprintf("%v", test.expectedRels) + gotStr := fmt.Sprintf("%v", rels) + if expectedStr != gotStr { + t.Errorf("Expected %s, got %s.", expectedStr, gotStr) + } + }) + } +} diff --git a/pkg/multipmuri/trello.go b/pkg/multipmuri/trello.go new file mode 100644 index 000000000..01d80922b --- /dev/null +++ b/pkg/multipmuri/trello.go @@ -0,0 +1,214 @@ +package multipmuri + +import ( + "fmt" + "net/url" + "strings" +) + +// +// TrelloService +// + +type TrelloService struct { + Service +} + +func NewTrelloService() *TrelloService { + return &TrelloService{ + Service: &service{}, + } +} + +func (e TrelloService) Provider() Provider { + return TrelloProvider +} + +func (e TrelloService) String() string { + return "https://trello.com/" +} + +func (e TrelloService) LocalID() string { + return "trello.com" +} + +func (e TrelloService) RelDecodeString(input string) (Entity, error) { + return trelloRelDecodeString(input, false) +} + +func (e TrelloService) Equals(other Entity) bool { + _, valid := other.(*TrelloService) + return valid +} + +func (e TrelloService) Contains(other Entity) bool { + return true +} + +// +// TrelloCard +// + +type TrelloCard struct { + Issue + *withTrelloID +} + +func NewTrelloCard(id string) *TrelloCard { + return &TrelloCard{ + Issue: &issue{}, + withTrelloID: &withTrelloID{id}, + } +} + +func (e TrelloCard) String() string { + return fmt.Sprintf("https://trello.com/c/%s", e.ID()) +} + +func (e TrelloCard) LocalID() string { + return fmt.Sprintf("c/%s", e.ID()) +} + +func (e TrelloCard) RelDecodeString(input string) (Entity, error) { + return trelloRelDecodeString(input, false) +} + +func (e TrelloCard) Equals(other Entity) bool { + if typed, valid := other.(*TrelloCard); valid { + return e.ID() == typed.ID() + } + return false +} + +func (e TrelloCard) Contains(other Entity) bool { + return false +} + +// +// TrelloUser +// + +type TrelloUser struct { + UserOrOrganization + *withTrelloID +} + +func NewTrelloUser(id string) *TrelloUser { + return &TrelloUser{ + UserOrOrganization: &userOrOrganization{}, + withTrelloID: &withTrelloID{id}, + } +} + +func (e TrelloUser) String() string { + return fmt.Sprintf("https://trello.com/%s", e.ID()) +} + +func (e TrelloUser) LocalID() string { + return fmt.Sprintf("@%s", e.ID()) +} + +func (e TrelloUser) RelDecodeString(input string) (Entity, error) { + return trelloRelDecodeString(input, false) +} + +func (e TrelloUser) Equals(other Entity) bool { + if typed, valid := other.(*TrelloUser); valid { + return e.ID() == typed.ID() + } + return false +} + +func (e TrelloUser) Contains(other Entity) bool { + return false +} + +// +// TrelloBoard +// + +type TrelloBoard struct { + Project + *withTrelloID +} + +func NewTrelloBoard(id string) *TrelloBoard { + return &TrelloBoard{ + Project: &project{}, + withTrelloID: &withTrelloID{id}, + } +} + +func (e TrelloBoard) String() string { + return fmt.Sprintf("https://trello.com/b/%s", e.ID()) +} + +func (e TrelloBoard) LocalID() string { + return fmt.Sprintf("b/%s", e.ID()) +} +func (e TrelloBoard) RelDecodeString(input string) (Entity, error) { + return trelloRelDecodeString(input, false) +} + +func (e TrelloBoard) Equals(other Entity) bool { + if typed, valid := other.(*TrelloBoard); valid { + return e.ID() == typed.ID() + } + return false +} + +func (e TrelloBoard) Contains(other Entity) bool { + return false +} + +// +// TrelloCommon +// + +type withTrelloID struct{ id string } + +func (e *withTrelloID) Provider() Provider { return TrelloProvider } +func (e *withTrelloID) ID() string { return e.id } + +// +// Helpers +// + +func trelloRelDecodeString(input string, force bool) (Entity, error) { + u, err := url.Parse(input) + if err != nil { + return nil, err + } + if isProviderScheme(u.Scheme) { // trello://, trello://, ... + return DecodeString(input) + } + u.Path = strings.Trim(u.Path, "/") + if u.Host == "" && len(u.Path) > 0 { // domain.com/a/b + u.Host = getHostname(u.Path) + if u.Host != "" { + u.Path = u.Path[len(u.Host):] + u.Path = strings.Trim(u.Path, "/") + } + } + //if owner != "" && repo != "" && strings.HasPrefix(u.Path, "@") { // @manfredtouron from a card + //return NewTrelloUser(u.Path[1:]), nil + //} + if u.Path == "" && u.Fragment == "" { + return NewTrelloService(), nil + } + parts := strings.Split(u.Path, "/") + lenParts := len(parts) + // FIXME: handle fragment (actions, comments, etc) + //log.Println("path", u.Path, "fragment", u.Fragment, "len", lenParts) + switch { + case lenParts > 1 && parts[0] == "c": + return NewTrelloCard(parts[1]), nil + case lenParts > 1 && parts[0] == "b": + return NewTrelloBoard(parts[1]), nil + default: + if parts[0][0] == '@' { + return NewTrelloUser(parts[0][1:]), nil + } + return NewTrelloUser(parts[0]), nil + } +} diff --git a/pkg/multipmuri/trello_test.go b/pkg/multipmuri/trello_test.go new file mode 100644 index 000000000..734154a0d --- /dev/null +++ b/pkg/multipmuri/trello_test.go @@ -0,0 +1,60 @@ +package multipmuri + +import "fmt" + +func ExampleNewTrelloCard() { + entity := NewTrelloCard("uworIKRP") + fmt.Println("entity") + fmt.Println(" ", entity.String()) + fmt.Println(" ", entity.Kind()) + fmt.Println(" ", entity.Provider()) + + relatives := []string{ + "https://trello.com/manfredtouron/boards", + "https://trello.com/bertytech/home", + "https://trello.com/manfredtouron", + "https://trello.com/b/QO8ORojV/test", + "https://trello.com/b/QO8ORojV", + "https://trello.com/c/uworIKRP/1-card-1", + "https://trello.com/c/uworIKRP", + "https://trello.com/c/pqon4iMh/2-card-2#comment-5d79f330ae4a3225dff6faf9", + "https://trello.com/c/pqon4iMh/blah#action-5d79f2b6053f4c137c9b9a28", + "@manfredtouron", + } + fmt.Println("relationships") + for _, name := range relatives { + attrs := "" + rel, err := entity.RelDecodeString(name) + if err != nil { + fmt.Printf(" %-80s -> error: %v\n", name, err) + continue + } + if rel.Equals(entity) { + attrs += " (equals)" + } + if entity.Contains(rel) { + attrs += " (contains)" + } + if rel.Contains(entity) { + attrs += " (is contained)" + } + fmt.Printf(" %-80s -> %s %s%s\n", name, rel.String(), rel.Kind(), attrs) + } + // Output: + // entity + // https://trello.com/c/uworIKRP + // issue + // trello + // relationships + // https://trello.com/manfredtouron/boards -> https://trello.com/manfredtouron user-or-organization + // https://trello.com/bertytech/home -> https://trello.com/bertytech user-or-organization + // https://trello.com/manfredtouron -> https://trello.com/manfredtouron user-or-organization + // https://trello.com/b/QO8ORojV/test -> https://trello.com/b/QO8ORojV project + // https://trello.com/b/QO8ORojV -> https://trello.com/b/QO8ORojV project + // https://trello.com/c/uworIKRP/1-card-1 -> https://trello.com/c/uworIKRP issue (equals) + // https://trello.com/c/uworIKRP -> https://trello.com/c/uworIKRP issue (equals) + // https://trello.com/c/pqon4iMh/2-card-2#comment-5d79f330ae4a3225dff6faf9 -> https://trello.com/c/pqon4iMh issue + // https://trello.com/c/pqon4iMh/blah#action-5d79f2b6053f4c137c9b9a28 -> https://trello.com/c/pqon4iMh issue + // @manfredtouron -> https://trello.com/manfredtouron user-or-organization + +} diff --git a/pkg/multipmuri/util.go b/pkg/multipmuri/util.go new file mode 100644 index 000000000..4e0c1a0da --- /dev/null +++ b/pkg/multipmuri/util.go @@ -0,0 +1,38 @@ +package multipmuri + +import ( + "net/url" + "strings" +) + +func getHostname(input string) string { + u, err := url.Parse(input) + if err != nil { + return "" + } + if u.Host != "" { + return u.Host + } + if u.Path != "" { + hostname := strings.Split(u.Path, "/")[0] + if isHostname(hostname) { + return hostname + } + } + return "" +} + +func isHostname(input string) bool { + return strings.Contains(input, ".") +} + +func isProviderScheme(scheme string) bool { + switch scheme { + case string(GitHubProvider), + string(TrelloProvider), + string(JiraProvider), + string(GitLabProvider): + return true + } + return false +}