Skip to content

Commit 4287f89

Browse files
authored
Merge pull request #1441 from entireio/return-remote-from-create
repo create: stamp a usable entire:// remote in the JSON output
2 parents d9d2d03 + cce6f83 commit 4287f89

2 files changed

Lines changed: 224 additions & 1 deletion

File tree

cmd/entire/cli/repo.go

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ package cli
22

33
import (
44
"context"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"strings"
59

610
"github.com/spf13/cobra"
711

@@ -39,6 +43,55 @@ func repoRow(r coreapi.Repo) []string {
3943
return []string{r.ID, r.Name, r.OwningProjectId, r.ClusterHost.Or("-"), state}
4044
}
4145

46+
// repoRemoteURL synthesizes the entire:// clone/remote URL for a repo from
47+
// its resolved cluster host and path — the form `git clone` and
48+
// `git remote add` accept, which git-remote-entire reads back as the repo
49+
// slug from the URL path. Returns "" when either coordinate is missing (a
50+
// still-provisioning repo may not have them yet); a half-formed URL is worse
51+
// than none.
52+
func repoRemoteURL(r coreapi.Repo) string {
53+
host := strings.TrimSpace(r.ClusterHost.Or(""))
54+
path := strings.TrimSpace(r.Path.Or(""))
55+
if host == "" || path == "" {
56+
return ""
57+
}
58+
return "entire://" + host + "/" + strings.TrimPrefix(path, "/")
59+
}
60+
61+
// repoCreateOutput renders a created repo as JSON with a synthesized `remote`
62+
// field merged in — the entire:// URL callers paste into `git clone` or
63+
// `git remote add`. The repo carries a custom marshaler plus arbitrary
64+
// additional properties, so it can't simply be embedded in a wrapper struct;
65+
// instead it's round-tripped through its own encoder and the remote is merged
66+
// into the resulting object. The synthesis only fills a gap: if the wire
67+
// object already carries a `remote` (a future first-class field, or one
68+
// arriving via additional properties) it's left untouched, so the
69+
// server-provided value always wins. The field is omitted when the clone
70+
// coordinates aren't resolvable yet rather than emitted half-formed.
71+
func repoCreateOutput(r *coreapi.Repo) (any, error) {
72+
if r == nil {
73+
return nil, errors.New("nil repo")
74+
}
75+
raw, err := json.Marshal(r)
76+
if err != nil {
77+
return nil, fmt.Errorf("encode repo: %w", err)
78+
}
79+
var obj map[string]json.RawMessage
80+
if err := json.Unmarshal(raw, &obj); err != nil {
81+
return nil, fmt.Errorf("decode repo: %w", err)
82+
}
83+
if _, ok := obj["remote"]; !ok {
84+
if remote := repoRemoteURL(*r); remote != "" {
85+
encoded, err := json.Marshal(remote)
86+
if err != nil {
87+
return nil, fmt.Errorf("encode remote: %w", err)
88+
}
89+
obj["remote"] = encoded
90+
}
91+
}
92+
return obj, nil
93+
}
94+
4295
func newRepoCreateCmd() *cobra.Command {
4396
var (
4497
projectID string
@@ -57,7 +110,11 @@ func newRepoCreateCmd() *cobra.Command {
57110
if clusterHost != "" {
58111
body.ClusterHost = coreapi.NewOptString(clusterHost)
59112
}
60-
return c.CreateRepo(ctx, body)
113+
created, err := c.CreateRepo(ctx, body)
114+
if err != nil {
115+
return nil, err
116+
}
117+
return repoCreateOutput(created)
61118
})
62119
},
63120
}

cmd/entire/cli/repo_test.go

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
package cli
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"github.com/go-faster/jx"
8+
9+
"github.com/entireio/cli/internal/coreapi"
10+
)
11+
12+
func TestRepoRemoteURL(t *testing.T) {
13+
t.Parallel()
14+
tests := []struct {
15+
name string
16+
repo coreapi.Repo
17+
want string
18+
}{
19+
{
20+
name: "host and path produce an entire:// URL",
21+
repo: coreapi.Repo{
22+
ClusterHost: coreapi.NewOptString("aws-us-east-2.entire.io"),
23+
Path: coreapi.NewOptString("acme/web"),
24+
},
25+
want: "entire://aws-us-east-2.entire.io/acme/web",
26+
},
27+
{
28+
name: "leading slash on path is not doubled",
29+
repo: coreapi.Repo{
30+
ClusterHost: coreapi.NewOptString("aws-us-east-2.entire.io"),
31+
Path: coreapi.NewOptString("/acme/web"),
32+
},
33+
want: "entire://aws-us-east-2.entire.io/acme/web",
34+
},
35+
{
36+
name: "missing host yields no URL",
37+
repo: coreapi.Repo{Path: coreapi.NewOptString("acme/web")},
38+
want: "",
39+
},
40+
{
41+
name: "missing path yields no URL",
42+
repo: coreapi.Repo{ClusterHost: coreapi.NewOptString("aws-us-east-2.entire.io")},
43+
want: "",
44+
},
45+
{
46+
name: "blank coordinates yield no URL",
47+
repo: coreapi.Repo{
48+
ClusterHost: coreapi.NewOptString(" "),
49+
Path: coreapi.NewOptString(""),
50+
},
51+
want: "",
52+
},
53+
}
54+
for _, tt := range tests {
55+
t.Run(tt.name, func(t *testing.T) {
56+
t.Parallel()
57+
if got := repoRemoteURL(tt.repo); got != tt.want {
58+
t.Errorf("repoRemoteURL() = %q, want %q", got, tt.want)
59+
}
60+
})
61+
}
62+
}
63+
64+
func TestRepoCreateOutput_StampsRemote(t *testing.T) {
65+
t.Parallel()
66+
repo := &coreapi.Repo{
67+
ID: "01KS6KFJR2XS6PZ188MVYE07AN",
68+
Name: "web",
69+
OwningProjectId: "01KS6KFJR2XS6PZ188MVYE07AP",
70+
ClusterHost: coreapi.NewOptString("aws-us-east-2.entire.io"),
71+
Path: coreapi.NewOptString("acme/web"),
72+
}
73+
out, err := repoCreateOutput(repo)
74+
if err != nil {
75+
t.Fatalf("repoCreateOutput() error = %v", err)
76+
}
77+
raw, err := json.Marshal(out)
78+
if err != nil {
79+
t.Fatalf("marshal output: %v", err)
80+
}
81+
var got map[string]any
82+
if err := json.Unmarshal(raw, &got); err != nil {
83+
t.Fatalf("unmarshal output: %v", err)
84+
}
85+
if want := "entire://aws-us-east-2.entire.io/acme/web"; got["remote"] != want {
86+
t.Errorf("remote = %v, want %q", got["remote"], want)
87+
}
88+
// The original repo fields must survive the round-trip alongside the
89+
// synthesized remote.
90+
if got["id"] != repo.ID {
91+
t.Errorf("id = %v, want %q", got["id"], repo.ID)
92+
}
93+
if got["name"] != repo.Name {
94+
t.Errorf("name = %v, want %q", got["name"], repo.Name)
95+
}
96+
}
97+
98+
func TestRepoCreateOutput_PreservesServerProvidedRemote(t *testing.T) {
99+
t.Parallel()
100+
// A server-provided `remote` (here via additional properties, the same
101+
// path a future first-class field would surface through) must win over
102+
// the synthesized one — synthesis only fills a gap.
103+
const serverRemote = "entire://override.entire.io/server/value"
104+
repo := &coreapi.Repo{
105+
ID: "01KS6KFJR2XS6PZ188MVYE07AN",
106+
Name: "web",
107+
OwningProjectId: "01KS6KFJR2XS6PZ188MVYE07AP",
108+
ClusterHost: coreapi.NewOptString("aws-us-east-2.entire.io"),
109+
Path: coreapi.NewOptString("acme/web"),
110+
AdditionalProps: coreapi.RepoAdditional{
111+
"remote": jx.Raw(`"` + serverRemote + `"`),
112+
},
113+
}
114+
out, err := repoCreateOutput(repo)
115+
if err != nil {
116+
t.Fatalf("repoCreateOutput() error = %v", err)
117+
}
118+
raw, err := json.Marshal(out)
119+
if err != nil {
120+
t.Fatalf("marshal output: %v", err)
121+
}
122+
var got map[string]any
123+
if err := json.Unmarshal(raw, &got); err != nil {
124+
t.Fatalf("unmarshal output: %v", err)
125+
}
126+
if got["remote"] != serverRemote {
127+
t.Errorf("remote = %v, want server-provided %q", got["remote"], serverRemote)
128+
}
129+
}
130+
131+
func TestRepoCreateOutput_NilRepoErrors(t *testing.T) {
132+
t.Parallel()
133+
// Defends the contract rather than a real path (the caller only passes a
134+
// repo after a nil-error create): a nil pointer must return an error, not
135+
// panic on the later dereference.
136+
if _, err := repoCreateOutput(nil); err == nil {
137+
t.Fatal("expected an error for a nil repo, got nil")
138+
}
139+
}
140+
141+
func TestRepoCreateOutput_OmitsRemoteWhenUnresolvable(t *testing.T) {
142+
t.Parallel()
143+
// A still-provisioning repo may lack a path; omit the field rather than
144+
// emit a half-formed URL.
145+
repo := &coreapi.Repo{
146+
ID: "01KS6KFJR2XS6PZ188MVYE07AN",
147+
Name: "web",
148+
OwningProjectId: "01KS6KFJR2XS6PZ188MVYE07AP",
149+
ClusterHost: coreapi.NewOptString("aws-us-east-2.entire.io"),
150+
}
151+
out, err := repoCreateOutput(repo)
152+
if err != nil {
153+
t.Fatalf("repoCreateOutput() error = %v", err)
154+
}
155+
raw, err := json.Marshal(out)
156+
if err != nil {
157+
t.Fatalf("marshal output: %v", err)
158+
}
159+
var got map[string]any
160+
if err := json.Unmarshal(raw, &got); err != nil {
161+
t.Fatalf("unmarshal output: %v", err)
162+
}
163+
if _, ok := got["remote"]; ok {
164+
t.Errorf("expected no remote field, got %v", got["remote"])
165+
}
166+
}

0 commit comments

Comments
 (0)