Skip to content

Commit 1ffdd37

Browse files
Merge pull request #186 from hdresearch/feat/run-commit-ref-flag
feat(run-commit): accept repo:tag refs via --ref flag
2 parents a5293c9 + dc8dcc4 commit 1ffdd37

4 files changed

Lines changed: 258 additions & 6 deletions

File tree

cmd/run_commit.go

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package cmd
22

33
import (
44
"context"
5+
"fmt"
56
"os"
7+
"strings"
68

79
"github.com/hdresearch/vers-cli/internal/handlers"
810
"github.com/hdresearch/vers-cli/internal/jobs"
@@ -16,27 +18,53 @@ var (
1618
runCommitJSON bool
1719
runCommitFormat string
1820
runCommitWait bool
21+
runCommitIsRef bool
1922
)
2023

2124
// runCommitCmd represents the run-commit command
2225
var runCommitCmd = &cobra.Command{
23-
Use: "run-commit [commit-key]",
26+
Use: "run-commit [commit-key | repo:tag]",
2427
Short: "Start a development environment from a commit",
25-
Long: `Start a Vers development environment from an existing commit using its commit key.
28+
Long: `Start a Vers development environment from an existing commit.
29+
30+
The argument is treated as a commit ID (UUID) by default. Pass --ref to interpret
31+
it as a repository reference in "repo_name:tag_name" format instead — the API
32+
will resolve the tag to the commit it currently points at within your own org.
33+
34+
Examples:
35+
vers run-commit c123456789abcdef
36+
vers run-commit my-app:latest --ref
37+
vers run-commit my-app:latest --ref --vm-alias dev --wait
2638
2739
Use --json for machine-readable output.
2840
Use --wait to block until the VM is running.`,
2941
Args: cobra.ExactArgs(1),
3042
RunE: func(cmd *cobra.Command, args []string) error {
3143
commitKey := args[0]
44+
45+
// Friendly nudge for the common gotcha: user passed something that
46+
// looks like a repo:tag ref without --ref.
47+
if !runCommitIsRef && looksLikeRepoRef(commitKey) {
48+
return fmt.Errorf(
49+
"'%s' looks like a repo:tag reference; did you mean '--ref'?\n"+
50+
" vers run-commit %s --ref",
51+
commitKey, commitKey,
52+
)
53+
}
54+
3255
cfg, err := runconfig.Load()
3356
if err != nil {
3457
return err
3558
}
3659
applyFlagOverrides(cmd, cfg)
3760
apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APILong)
3861
defer cancel()
39-
req := handlers.RunCommitReq{CommitKey: commitKey, VMAlias: commitVmAlias, Wait: runCommitWait}
62+
req := handlers.RunCommitReq{
63+
CommitKey: commitKey,
64+
VMAlias: commitVmAlias,
65+
Wait: runCommitWait,
66+
IsRef: runCommitIsRef,
67+
}
4068
var jobID string
4169
if runCommitWait {
4270
jobID, _ = jobs.Submit(jobs.Submission{
@@ -70,6 +98,31 @@ Use --wait to block until the VM is running.`,
7098
},
7199
}
72100

101+
// looksLikeRepoRef returns true for strings in "word:word" shape where the
102+
// parts contain only characters that are legal in repo/tag names. Used only
103+
// to guess user intent and suggest --ref; never to actually dispatch.
104+
func looksLikeRepoRef(s string) bool {
105+
i := strings.IndexByte(s, ':')
106+
if i <= 0 || i == len(s)-1 {
107+
return false
108+
}
109+
legal := func(c byte) bool {
110+
return (c >= 'a' && c <= 'z') ||
111+
(c >= 'A' && c <= 'Z') ||
112+
(c >= '0' && c <= '9') ||
113+
c == '-' || c == '_' || c == '.'
114+
}
115+
for j := 0; j < len(s); j++ {
116+
if j == i {
117+
continue
118+
}
119+
if !legal(s[j]) {
120+
return false
121+
}
122+
}
123+
return true
124+
}
125+
73126
func init() {
74127
rootCmd.AddCommand(runCommitCmd)
75128

@@ -78,4 +131,5 @@ func init() {
78131
runCommitCmd.Flags().StringVar(&runCommitFormat, "format", "", "Output format (json) [deprecated: use --json]")
79132
_ = runCommitCmd.Flags().MarkDeprecated("format", "use --json instead")
80133
runCommitCmd.Flags().BoolVar(&runCommitWait, "wait", false, "Wait until VM is running")
134+
runCommitCmd.Flags().BoolVar(&runCommitIsRef, "ref", false, "Interpret the argument as a repo:tag reference instead of a commit ID")
81135
}

cmd/run_commit_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package cmd
2+
3+
import "testing"
4+
5+
// TestLooksLikeRepoRef verifies the heuristic used to suggest --ref when
6+
// the user passes a repo:tag-shaped argument. False positives are cheap
7+
// (we just show an extra suggestion); false negatives mean the user hits
8+
// a cryptic 422 from the API, so the positive cases get careful coverage.
9+
func TestLooksLikeRepoRef(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
input string
13+
want bool
14+
}{
15+
// Positive: canonical repo:tag shapes
16+
{"simple", "my-app:latest", true},
17+
{"minimal", "a:b", true},
18+
{"semver-tag", "my_app:v1.0.0", true},
19+
{"dotted-parts", "x.y:1.2", true},
20+
{"numeric-parts", "123:456", true},
21+
{"mixed-legal-chars", "pi-agent:2026-04-19-v4", true},
22+
23+
// Negative: no colon
24+
{"empty", "", false},
25+
{"no-colon", "abc-123", false},
26+
{"uuid", "aa84beb8-a080-4be5-bb7f-86374a68b380", false},
27+
28+
// Negative: malformed colon placement
29+
{"colon-at-start", ":latest", false},
30+
{"colon-at-end", "my-app:", false},
31+
{"lone-colon", ":", false},
32+
33+
// Negative: contains characters not valid in repo/tag names
34+
{"space", "my app:latest", false},
35+
{"slash", "owner/my-app:latest", false},
36+
{"at-sign", "my-app@v1:latest", false},
37+
{"hash", "my-app#1:latest", false},
38+
{"non-ascii", "my-app:päivä", false},
39+
}
40+
41+
for _, tt := range tests {
42+
t.Run(tt.name, func(t *testing.T) {
43+
got := looksLikeRepoRef(tt.input)
44+
if got != tt.want {
45+
t.Errorf("looksLikeRepoRef(%q) = %v, want %v", tt.input, got, tt.want)
46+
}
47+
})
48+
}
49+
}
50+
51+
// TestLooksLikeRepoRef_MultipleColons documents the current behavior for
52+
// strings with more than one colon. The first colon splits repo from tag,
53+
// and subsequent characters (including another colon) must be legal name
54+
// chars. A bare `a:b:c` has a colon in the tag part, which fails the
55+
// legal-char check and returns false.
56+
func TestLooksLikeRepoRef_MultipleColons(t *testing.T) {
57+
if looksLikeRepoRef("a:b:c") {
58+
t.Error("expected a:b:c to NOT look like a repo:tag (colon in tag part is not a legal name char)")
59+
}
60+
}

internal/handlers/run_commit.go

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,30 @@ import (
1111
)
1212

1313
type RunCommitReq struct {
14+
// CommitKey is either a commit ID (UUID) or, when IsRef is true, a
15+
// repository reference in "repo_name:tag_name" format.
1416
CommitKey string
1517
VMAlias string
1618
Wait bool
19+
// IsRef switches the underlying API payload from {"commit_id": ...} to
20+
// {"ref": ...}, which enables resolving own-org repository tags like
21+
// "my-app:latest" instead of raw commit UUIDs.
22+
IsRef bool
1723
}
1824

1925
func HandleRunCommit(ctx context.Context, a *app.App, r RunCommitReq) (presenters.RunCommitView, error) {
20-
body := vers.VmRestoreFromCommitParams{
21-
VmFromCommitRequest: vers.VmFromCommitRequestParam{
26+
var reqUnion vers.VmFromCommitRequestUnionParam
27+
if r.IsRef {
28+
reqUnion = vers.VmFromCommitRequestRefParam{
29+
Ref: vers.F(r.CommitKey),
30+
}
31+
} else {
32+
reqUnion = vers.VmFromCommitRequestCommitIDParam{
2233
CommitID: vers.F(r.CommitKey),
23-
},
34+
}
35+
}
36+
body := vers.VmRestoreFromCommitParams{
37+
VmFromCommitRequest: reqUnion,
2438
}
2539

2640
resp, err := a.Client.Vm.RestoreFromCommit(ctx, body)
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package handlers_test
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"io"
7+
"net/http"
8+
"net/http/httptest"
9+
"testing"
10+
11+
"github.com/hdresearch/vers-cli/internal/handlers"
12+
)
13+
14+
// TestHandleRunCommit_CommitIDPath verifies that when IsRef is false, the
15+
// request body places the argument under "commit_id" (no "ref" key).
16+
// This is the default behavior — any existing callers must not regress.
17+
func TestHandleRunCommit_CommitIDPath(t *testing.T) {
18+
var receivedBody map[string]interface{}
19+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20+
if r.URL.Path != "/api/v1/vm/from_commit" {
21+
t.Errorf("unexpected path: %s", r.URL.Path)
22+
}
23+
if r.Method != http.MethodPost {
24+
t.Errorf("expected POST, got %s", r.Method)
25+
}
26+
body, _ := io.ReadAll(r.Body)
27+
json.Unmarshal(body, &receivedBody)
28+
29+
w.Header().Set("Content-Type", "application/json")
30+
w.WriteHeader(http.StatusOK)
31+
w.Write([]byte(`{"vm_id": "vm-uuid-1"}`))
32+
}))
33+
defer server.Close()
34+
35+
a := testApp(server.URL)
36+
res, err := handlers.HandleRunCommit(context.Background(), a, handlers.RunCommitReq{
37+
CommitKey: "abc-123",
38+
IsRef: false,
39+
})
40+
if err != nil {
41+
t.Fatalf("unexpected error: %v", err)
42+
}
43+
if res.RootVmID != "vm-uuid-1" {
44+
t.Errorf("expected VM ID vm-uuid-1, got %s", res.RootVmID)
45+
}
46+
if res.CommitKey != "abc-123" {
47+
t.Errorf("expected CommitKey abc-123, got %s", res.CommitKey)
48+
}
49+
50+
// The SDK flattens the vm_from_commit_request union into the top-level
51+
// request body (MarshalJSON on VmRestoreFromCommitParams inlines it).
52+
if receivedBody["commit_id"] != "abc-123" {
53+
t.Errorf("expected commit_id=abc-123, got %v", receivedBody["commit_id"])
54+
}
55+
if _, refPresent := receivedBody["ref"]; refPresent {
56+
t.Errorf("expected no 'ref' key when IsRef=false, got: %v", receivedBody)
57+
}
58+
}
59+
60+
// TestHandleRunCommit_RefPath verifies that when IsRef is true, the request
61+
// body uses "ref" instead of "commit_id". This is the new behavior added
62+
// by the --ref flag.
63+
func TestHandleRunCommit_RefPath(t *testing.T) {
64+
var receivedBody map[string]interface{}
65+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
66+
if r.URL.Path != "/api/v1/vm/from_commit" {
67+
t.Errorf("unexpected path: %s", r.URL.Path)
68+
}
69+
if r.Method != http.MethodPost {
70+
t.Errorf("expected POST, got %s", r.Method)
71+
}
72+
body, _ := io.ReadAll(r.Body)
73+
json.Unmarshal(body, &receivedBody)
74+
75+
w.Header().Set("Content-Type", "application/json")
76+
w.WriteHeader(http.StatusOK)
77+
w.Write([]byte(`{"vm_id": "vm-uuid-2"}`))
78+
}))
79+
defer server.Close()
80+
81+
a := testApp(server.URL)
82+
res, err := handlers.HandleRunCommit(context.Background(), a, handlers.RunCommitReq{
83+
CommitKey: "my-app:latest",
84+
IsRef: true,
85+
})
86+
if err != nil {
87+
t.Fatalf("unexpected error: %v", err)
88+
}
89+
if res.RootVmID != "vm-uuid-2" {
90+
t.Errorf("expected VM ID vm-uuid-2, got %s", res.RootVmID)
91+
}
92+
if res.CommitKey != "my-app:latest" {
93+
t.Errorf("expected CommitKey my-app:latest, got %s", res.CommitKey)
94+
}
95+
96+
// The SDK flattens the vm_from_commit_request union into the top-level
97+
// request body (MarshalJSON on VmRestoreFromCommitParams inlines it).
98+
if receivedBody["ref"] != "my-app:latest" {
99+
t.Errorf("expected ref=my-app:latest, got %v", receivedBody["ref"])
100+
}
101+
if _, commitIDPresent := receivedBody["commit_id"]; commitIDPresent {
102+
t.Errorf("expected no 'commit_id' key when IsRef=true, got: %v", receivedBody)
103+
}
104+
}
105+
106+
// TestHandleRunCommit_ServerError verifies that non-2xx responses surface as
107+
// errors to the caller (no silent swallowing).
108+
func TestHandleRunCommit_ServerError(t *testing.T) {
109+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
110+
w.Header().Set("Content-Type", "application/json")
111+
w.WriteHeader(http.StatusUnprocessableEntity)
112+
w.Write([]byte(`{"error": "invalid commit id"}`))
113+
}))
114+
defer server.Close()
115+
116+
a := testApp(server.URL)
117+
_, err := handlers.HandleRunCommit(context.Background(), a, handlers.RunCommitReq{
118+
CommitKey: "not-a-uuid",
119+
IsRef: false,
120+
})
121+
if err == nil {
122+
t.Fatal("expected error for 422 response, got nil")
123+
}
124+
}

0 commit comments

Comments
 (0)