Repository: cli/cli
Pull request #7636 (2023-06-28 04:42:23)

Add tenancy support
cc https://github.com/github/cli/issues/151


Patch:
diff --git a/internal/ghinstance/host.go b/internal/ghinstance/host.go
index 1fde25e23..729d47f31 100644
--- a/internal/ghinstance/host.go
+++ b/internal/ghinstance/host.go
@@ -6,37 +6,56 @@ import (
 	"strings"
 )
 
+// DefaultHostname is the domain name of the default GitHub instance.
 const defaultHostname = "github.com"
 
-// localhost is the domain name of a local GitHub instance
+// Localhost is the domain name of a local GitHub instance.
 const localhost = "github.localhost"
 
-// Default returns the host name of the default GitHub instance
+// TenancyHost is the domain name of a tenancy GitHub instance.
+const tenancyHost = "ghe.com"
+
+// Default returns the host name of the default GitHub instance.
 func Default() string {
 	return defaultHostname
 }
 
-// IsEnterprise reports whether a non-normalized host name looks like a GHE instance
+// IsEnterprise reports whether a non-normalized host name looks like a GHE instance.
 func IsEnterprise(h string) bool {
 	normalizedHostName := NormalizeHostname(h)
 	return normalizedHostName != defaultHostname && normalizedHostName != localhost
 }
 
+// IsTenancy reports whether a non-normalized host name looks like a tenancy instance.
+func IsTenancy(h string) bool {
+	normalizedHostName := NormalizeHostname(h)
+	return strings.HasSuffix(normalizedHostName, "."+tenancyHost)
+}
+
+// TenantName extracts the tenant name from tenancy host name and
+// reports whether it found the tenant name.
+func TenantName(h string) (string, bool) {
+	normalizedHostName := NormalizeHostname(h)
+	return cutSuffix(normalizedHostName, "."+tenancyHost)
+}
+
 func isGarage(h string) bool {
 	return strings.EqualFold(h, "garage.github.com")
 }
 
-// NormalizeHostname returns the canonical host name of a GitHub instance
+// NormalizeHostname returns the canonical host name of a GitHub instance.
 func NormalizeHostname(h string) string {
 	hostname := strings.ToLower(h)
 	if strings.HasSuffix(hostname, "."+defaultHostname) {
 		return defaultHostname
 	}
-
 	if strings.HasSuffix(hostname, "."+localhost) {
 		return localhost
 	}
-
+	if before, found := cutSuffix(hostname, "."+tenancyHost); found {
+		idx := strings.LastIndex(before, ".")
+		return fmt.Sprintf("%s.%s", before[idx+1:], tenancyHost)
+	}
 	return hostname
 }
 
@@ -78,11 +97,9 @@ func RESTPrefix(hostname string) string {
 
 func GistPrefix(hostname string) string {
 	prefix := "https://"
-
 	if strings.EqualFold(hostname, localhost) {
 		prefix = "http://"
 	}
-
 	return prefix + GistHost(hostname)
 }
 
@@ -105,3 +122,11 @@ func HostPrefix(hostname string) string {
 	}
 	return fmt.Sprintf("https://%s/", hostname)
 }
+
+// Backport strings.CutSuffix from Go 1.20.
+func cutSuffix(s, suffix string) (string, bool) {
+	if !strings.HasSuffix(s, suffix) {
+		return s, false
+	}
+	return s[:len(s)-len(suffix)], true
+}
diff --git a/internal/ghrepo/repo.go b/internal/ghrepo/repo.go
index 9c6c48a4d..4f0328e99 100644
--- a/internal/ghrepo/repo.go
+++ b/internal/ghrepo/repo.go
@@ -92,12 +92,13 @@ func GenerateRepoURL(repo Interface, p string, args ...interface{}) string {
 	return baseURL
 }
 
-// TODO there is a parallel implementation for non-isolated commands
 func FormatRemoteURL(repo Interface, protocol string) string {
 	if protocol == "ssh" {
+		if tenant, found := ghinstance.TenantName(repo.RepoHost()); found {
+			return fmt.Sprintf("%s@%s:%s/%s.git", tenant, repo.RepoHost(), repo.RepoOwner(), repo.RepoName())
+		}
 		return fmt.Sprintf("git@%s:%s/%s.git", repo.RepoHost(), repo.RepoOwner(), repo.RepoName())
 	}
-
 	return fmt.Sprintf("%s%s/%s.git", ghinstance.HostPrefix(repo.RepoHost()), repo.RepoOwner(), repo.RepoName())
 }
 
diff --git a/pkg/cmd/project/shared/format/json.go b/pkg/cmd/project/shared/format/json.go
index 175485314..17509c6c5 100644
--- a/pkg/cmd/project/shared/format/json.go
+++ b/pkg/cmd/project/shared/format/json.go
@@ -256,9 +256,11 @@ func projectFieldValueData(v queries.FieldValueNodes) any {
 		return v.ProjectV2ItemFieldDateValue.Date
 	case "ProjectV2ItemFieldIterationValue":
 		return struct {
+			Title     string `json:"title"`
 			StartDate string `json:"startDate"`
 			Duration  int    `json:"duration"`
 		}{
+			Title:     v.ProjectV2ItemFieldIterationValue.Title,
 			StartDate: v.ProjectV2ItemFieldIterationValue.StartDate,
 			Duration:  v.ProjectV2ItemFieldIterationValue.Duration,
 		}
@@ -270,9 +272,11 @@ func projectFieldValueData(v queries.FieldValueNodes) any {
 		return v.ProjectV2ItemFieldTextValue.Text
 	case "ProjectV2ItemFieldMilestoneValue":
 		return struct {
+			Title       string `json:"title"`
 			Description string `json:"description"`
 			DueOn       string `json:"dueOn"`
 		}{
+			Title:       v.ProjectV2ItemFieldMilestoneValue.Milestone.Title,
 			Description: v.ProjectV2ItemFieldMilestoneValue.Milestone.Description,
 			DueOn:       v.ProjectV2ItemFieldMilestoneValue.Milestone.DueOn,
 		}
diff --git a/pkg/cmd/project/shared/queries/queries.go b/pkg/cmd/project/shared/queries/queries.go
index d58dd1204..b540b347f 100644
--- a/pkg/cmd/project/shared/queries/queries.go
+++ b/pkg/cmd/project/shared/queries/queries.go
@@ -167,6 +167,7 @@ type FieldValueNodes struct {
 		Field ProjectField
 	} `graphql:"... on ProjectV2ItemFieldDateValue"`
 	ProjectV2ItemFieldIterationValue struct {
+		Title     string
 		StartDate string
 		Duration  int
 		Field     ProjectField
@@ -193,6 +194,7 @@ type FieldValueNodes struct {
 	} `graphql:"... on ProjectV2ItemFieldTextValue"`
 	ProjectV2ItemFieldMilestoneValue struct {
 		Milestone struct {
+			Title       string
 			Description string
 			DueOn       string
 		}


Test patch:
diff --git a/internal/ghinstance/host_test.go b/internal/ghinstance/host_test.go
index 5fa6eb4f3..673fad24c 100644
--- a/internal/ghinstance/host_test.go
+++ b/internal/ghinstance/host_test.go
@@ -49,6 +49,87 @@ func TestIsEnterprise(t *testing.T) {
 	}
 }
 
+func TestIsTenancy(t *testing.T) {
+	tests := []struct {
+		host string
+		want bool
+	}{
+		{
+			host: "github.com",
+			want: false,
+		},
+		{
+			host: "github.localhost",
+			want: false,
+		},
+		{
+			host: "garage.github.com",
+			want: false,
+		},
+		{
+			host: "ghe.com",
+			want: false,
+		},
+		{
+			host: "tenant.ghe.com",
+			want: true,
+		},
+		{
+			host: "api.tenant.ghe.com",
+			want: true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.host, func(t *testing.T) {
+			if got := IsTenancy(tt.host); got != tt.want {
+				t.Errorf("IsTenancy() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestTenantName(t *testing.T) {
+	tests := []struct {
+		host       string
+		wantTenant string
+		wantFound  bool
+	}{
+		{
+			host:       "github.com",
+			wantTenant: "github.com",
+		},
+		{
+			host:       "github.localhost",
+			wantTenant: "github.localhost",
+		},
+		{
+			host:       "garage.github.com",
+			wantTenant: "github.com",
+		},
+		{
+			host:       "ghe.com",
+			wantTenant: "ghe.com",
+		},
+		{
+			host:       "tenant.ghe.com",
+			wantTenant: "tenant",
+			wantFound:  true,
+		},
+		{
+			host:       "api.tenant.ghe.com",
+			wantTenant: "tenant",
+			wantFound:  true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.host, func(t *testing.T) {
+			if tenant, found := TenantName(tt.host); tenant != tt.wantTenant || found != tt.wantFound {
+				t.Errorf("TenantName(%v) = %v %v, want %v %v", tt.host, tenant, found, tt.wantTenant, tt.wantFound)
+			}
+		})
+	}
+}
+
 func TestNormalizeHostname(t *testing.T) {
 	tests := []struct {
 		host string
@@ -90,6 +171,18 @@ func TestNormalizeHostname(t *testing.T) {
 			host: "git.my.org",
 			want: "git.my.org",
 		},
+		{
+			host: "ghe.com",
+			want: "ghe.com",
+		},
+		{
+			host: "tenant.ghe.com",
+			want: "tenant.ghe.com",
+		},
+		{
+			host: "api.tenant.ghe.com",
+			want: "tenant.ghe.com",
+		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.host, func(t *testing.T) {
@@ -139,6 +232,7 @@ func TestHostnameValidator(t *testing.T) {
 		})
 	}
 }
+
 func TestGraphQLEndpoint(t *testing.T) {
 	tests := []struct {
 		host string
diff --git a/internal/ghrepo/repo_test.go b/internal/ghrepo/repo_test.go
index 1530f8f24..7ea6b0598 100644
--- a/internal/ghrepo/repo_test.go
+++ b/internal/ghrepo/repo_test.go
@@ -220,3 +220,59 @@ func TestFromFullName(t *testing.T) {
 		})
 	}
 }
+
+func TestFormatRemoteURL(t *testing.T) {
+	tests := []struct {
+		name      string
+		repoHost  string
+		repoOwner string
+		repoName  string
+		protocol  string
+		want      string
+	}{
+		{
+			name:      "https protocol",
+			repoHost:  "github.com",
+			repoOwner: "owner",
+			repoName:  "name",
+			protocol:  "https",
+			want:      "https://github.com/owner/name.git",
+		},
+		{
+			name:      "https protocol local host",
+			repoHost:  "github.localhost",
+			repoOwner: "owner",
+			repoName:  "name",
+			protocol:  "https",
+			want:      "http://github.localhost/owner/name.git",
+		},
+		{
+			name:      "ssh protocol",
+			repoHost:  "github.com",
+			repoOwner: "owner",
+			repoName:  "name",
+			protocol:  "ssh",
+			want:      "git@github.com:owner/name.git",
+		},
+		{
+			name:      "ssh protocol tenancy host",
+			repoHost:  "tenant.ghe.com",
+			repoOwner: "owner",
+			repoName:  "name",
+			protocol:  "ssh",
+			want:      "tenant@tenant.ghe.com:owner/name.git",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			r := ghRepo{
+				hostname: tt.repoHost,
+				owner:    tt.repoOwner,
+				name:     tt.repoName,
+			}
+			if url := FormatRemoteURL(r, tt.protocol); url != tt.want {
+				t.Errorf("expected url %q, got %q", tt.want, url)
+			}
+		})
+	}
+}
diff --git a/pkg/cmd/project/shared/format/json_test.go b/pkg/cmd/project/shared/format/json_test.go
index 31bdcccc6..c05a3e5ff 100644
--- a/pkg/cmd/project/shared/format/json_test.go
+++ b/pkg/cmd/project/shared/format/json_test.go
@@ -279,6 +279,82 @@ func TestJSONProjectDraftIssue(t *testing.T) {
 	assert.Equal(t, `{"id":"123","title":"title","body":"a body","type":"DraftIssue"}`, string(b))
 }
 
+func TestJSONProjectItem_DraftIssue_ProjectV2ItemFieldIterationValue(t *testing.T) {
+	iterationField := queries.ProjectField{TypeName: "ProjectV2IterationField"}
+	iterationField.IterationField.ID = "sprint"
+	iterationField.IterationField.Name = "Sprint"
+
+	iterationFieldValue := queries.FieldValueNodes{Type: "ProjectV2ItemFieldIterationValue"}
+	iterationFieldValue.ProjectV2ItemFieldIterationValue.Title = "Iteration Title"
+	iterationFieldValue.ProjectV2ItemFieldIterationValue.Field = iterationField
+
+	draftIssue := queries.ProjectItem{
+		Id: "draftIssueId",
+		Content: queries.ProjectItemContent{
+			TypeName: "DraftIssue",
+			DraftIssue: queries.DraftIssue{
+				Title: "Pull Request title",
+				Body:  "a body",
+			},
+		},
+	}
+	draftIssue.FieldValues.Nodes = []queries.FieldValueNodes{
+		iterationFieldValue,
+	}
+	p := &queries.Project{}
+	p.Fields.Nodes = []queries.ProjectField{iterationField}
+	p.Items.TotalCount = 5
+	p.Items.Nodes = []queries.ProjectItem{
+		draftIssue,
+	}
+
+	out, err := JSONProjectDetailedItems(p)
+	assert.NoError(t, err)
+	assert.JSONEq(
+		t,
+		`{"items":[{"sprint":{"title":"Iteration Title","startDate":"","duration":0},"content":{"type":"DraftIssue","body":"a body","title":"Pull Request title"},"id":"draftIssueId"}],"totalCount":5}`,
+		string(out))
+
+}
+
+func TestJSONProjectItem_DraftIssue_ProjectV2ItemFieldMilestoneValue(t *testing.T) {
+	milestoneField := queries.ProjectField{TypeName: "ProjectV2IterationField"}
+	milestoneField.IterationField.ID = "milestone"
+	milestoneField.IterationField.Name = "Milestone"
+
+	milestoneFieldValue := queries.FieldValueNodes{Type: "ProjectV2ItemFieldMilestoneValue"}
+	milestoneFieldValue.ProjectV2ItemFieldMilestoneValue.Milestone.Title = "Milestone Title"
+	milestoneFieldValue.ProjectV2ItemFieldMilestoneValue.Field = milestoneField
+
+	draftIssue := queries.ProjectItem{
+		Id: "draftIssueId",
+		Content: queries.ProjectItemContent{
+			TypeName: "DraftIssue",
+			DraftIssue: queries.DraftIssue{
+				Title: "Pull Request title",
+				Body:  "a body",
+			},
+		},
+	}
+	draftIssue.FieldValues.Nodes = []queries.FieldValueNodes{
+		milestoneFieldValue,
+	}
+	p := &queries.Project{}
+	p.Fields.Nodes = []queries.ProjectField{milestoneField}
+	p.Items.TotalCount = 5
+	p.Items.Nodes = []queries.ProjectItem{
+		draftIssue,
+	}
+
+	out, err := JSONProjectDetailedItems(p)
+	assert.NoError(t, err)
+	assert.JSONEq(
+		t,
+		`{"items":[{"milestone":{"title":"Milestone Title","dueOn":"","description":""},"content":{"type":"DraftIssue","body":"a body","title":"Pull Request title"},"id":"draftIssueId"}],"totalCount":5}`,
+		string(out))
+
+}
+
 func TestCamelCase(t *testing.T) {
 	assert.Equal(t, "camelCase", CamelCase("camelCase"))
 	assert.Equal(t, "camelCase", CamelCase("CamelCase"))
diff --git a/pkg/cmd/project/shared/queries/queries_test.go b/pkg/cmd/project/shared/queries/queries_test.go
index b5a8a1f76..e1cf8c68b 100644
--- a/pkg/cmd/project/shared/queries/queries_test.go
+++ b/pkg/cmd/project/shared/queries/queries_test.go
@@ -165,6 +165,7 @@ func TestProjectItems_NoLimit(t *testing.T) {
 }
 
 func TestProjectFields_LowerLimit(t *testing.T) {
+
 	defer gock.Off()
 	gock.Observe(gock.DumpRequest)
 
@@ -363,3 +364,67 @@ func TestNewOwner_nonTTY(t *testing.T) {
 	assert.EqualError(t, err, "login is required when not running interactively")
 
 }
+
+func TestProjectItems_FieldTitle(t *testing.T) {
+	defer gock.Off()
+	gock.Observe(gock.DumpRequest)
+
+	// list project items
+	gock.New("https://api.github.com").
+		Post("/graphql").
+		JSON(map[string]interface{}{
+			"query": "query UserProjectWithItems.*",
+			"variables": map[string]interface{}{
+				"firstItems":  LimitMax,
+				"afterItems":  nil,
+				"firstFields": LimitMax,
+				"afterFields": nil,
+				"login":       "monalisa",
+				"number":      1,
+			},
+		}).
+		Reply(200).
+		JSON(map[string]interface{}{
+			"data": map[string]interface{}{
+				"user": map[string]interface{}{
+					"projectV2": map[string]interface{}{
+						"items": map[string]interface{}{
+							"nodes": []map[string]interface{}{
+								{
+									"id": "draft issue ID",
+									"fieldValues": map[string]interface{}{
+										"nodes": []map[string]interface{}{
+											{
+												"__typename": "ProjectV2ItemFieldIterationValue",
+												"title":      "Iteration Title 1",
+											},
+											{
+												"__typename": "ProjectV2ItemFieldMilestoneValue",
+												"milestone": map[string]interface{}{
+													"title": "Milestone Title 1",
+												},
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		})
+
+	client := NewTestClient()
+
+	owner := &Owner{
+		Type:  "USER",
+		Login: "monalisa",
+		ID:    "user ID",
+	}
+	project, err := client.ProjectItems(owner, 1, LimitMax)
+	assert.NoError(t, err)
+	assert.Len(t, project.Items.Nodes, 1)
+	assert.Len(t, project.Items.Nodes[0].FieldValues.Nodes, 2)
+	assert.Equal(t, project.Items.Nodes[0].FieldValues.Nodes[0].ProjectV2ItemFieldIterationValue.Title, "Iteration Title 1")
+	assert.Equal(t, project.Items.Nodes[0].FieldValues.Nodes[1].ProjectV2ItemFieldMilestoneValue.Milestone.Title, "Milestone Title 1")
+}