feat(control-plane): resolve account owners by github:handle · Entire
feat(control-plane): resolve account owners by github:handle
75bb7e6·
toothbrush·3w ago·5 files·+85 added/-17 removed
project create --owner now accepts a github-qualified handle (e.g. github:alice) for --owner-type=account, resolving it to the account ULID via ResolveHandle — matching the org case, which already accepts a name. A raw ULID still passes through unchanged for both owner types.
Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
Sessions
64b9771c3417View transcript
Changes
5
cmd/entire/cli
checkpoint
Mopen.go+1/-1
Mproject.go+16/-14
Mresolveref.go+31
Mresolveref_test.go+35
strategy
Mmanual_commit.go+2/-2
54 unmodified lines
55
56
57
58
58
59
60
61
54 unmodified lines
}
// Temporary returns the git-backed temporary shadow-branch store.
func (s *Stores) Temporary() TemporaryStore { return s.temporary } //nolint:ireturn // temporary store capability is the abstraction boundary
func (s *Stores) Temporary() TemporaryStore { return s.temporary }
// Refs returns the resolved committed-ref topology.
func (s *Stores) Refs() CommittedRefs { return s.refs }
Mcmd/entire/cli/checkpoint/open.go+1/-1
39 unmodified lines
40
41
42
43
44
43
44
45
46
47
48
47
48
49
50
51
2 unmodified lines
54
55
56
57
58
59
60
61
62
63
64
65
57
58
59
60
61
62
63
64
65
66
67
68
69
70
7 unmodified lines
78
79
80
79
81
82
83
84
39 unmodified lines
Use: "create <name>",
Short: "Create a project under an org or account",
Long: "Creates a project owned by an org or an account. --owner is the " +
"owning org (name or ULID) or account ULID, and --owner-type selects " +
"which (org or account).",
Example: " # Project under an org (by name)\n" +
" entire project create widgets --owner acme --owner-type org\n\n" +
" # Project owned by an account\n" +
" entire project create widgets --owner 01J0... --owner-type account",
" # Project owned by an account (by handle)\n" +
" entire project create widgets --owner github:alice --owner-type account",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cmd.SilenceUsage = true
2 unmodified lines
return err
}
return runCoreJSON(cmd, func(ctx context.Context, c *coreapi.Client) (any, error) {
ownerRef := ownerID
// Only org owners have a friendly-name index; account owners
// must be addressed by ULID.
if ot == coreapi.CreateProjectInputBodyOwnerTypeOrg {
resolved, err := resolveOrgRef(ctx, c, ownerID)
if err != nil {
return nil, err
}
ownerRef = resolved
// Orgs are addressed by name, accounts by github:handle; both
// also accept a raw ULID.
var ownerRef string
switch ot {
case coreapi.CreateProjectInputBodyOwnerTypeOrg:
ownerRef, err = resolveOrgRef(ctx, c, ownerID)
case coreapi.CreateProjectInputBodyOwnerTypeAccount:
ownerRef, err = resolveAccountRef(ctx, c, ownerID)
}
if err != nil {
return nil, err
}
body := &coreapi.CreateProjectInputBody{
Name: args[0],
7 unmodified lines
})
}
}
cmd.Flags().StringVar(&ownerID, "owner", "", "owning org (name or ULID), or account ULID (required)")
cmd.Flags().StringVar(&ownerID, "owner", "", "owning org (name or ULID), or account (github:handle or ULID) (required)")
cmd.Flags().StringVar(&ownerType, "owner-type", "org", "owner kind: org or account")
cmd.Flags().StringVar(®ion, "region", "", "jurisdiction slug (defaults to the server's home jurisdiction)")
markRequired(cmd, "owner")
Mcmd/entire/cli/project.go+16/-14
48 unmodified lines
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
48 unmodified lines
return pickOrg(out.Orgs, ref)
// resolveAccountRef turns an account reference into its ULID. A ULID passes
// through unchanged; otherwise the ref is a provider-qualified handle (e.g.
// "github:alice") resolved via the control plane. We support github-backed
// user accounts today; other providers will resolve once they exist server-side.
func resolveAccountRef(ctx context.Context, c *coreapi.Client, ref string) (string, error) {
if looksLikeULID(ref) {
return ref, nil
}
provider, handle, err := parseQualifiedHandle(ref)
if err != nil {
return "", err
}
id, err := c.ResolveHandle(ctx, coreapi.ResolveHandleParams{Provider: provider, Handle: handle})
if err != nil {
return "", err
}
return id.AccountId, nil
}
// parseQualifiedHandle splits a provider-qualified handle like "github:alice"
// into its provider ("github") and handle ("alice"). Accounts are addressed by
// this friendly form; a value with no "provider:" prefix is rejected so the
// user gets a clear hint rather than a confusing lookup miss.
func parseQualifiedHandle(ref string) (provider, handle string, err error) {
provider, handle, ok := strings.Cut(ref, ":")
if !ok || provider == "" || handle == "" {
return "", "", fmt.Errorf("account %q must be a qualified handle like \"github:alice\" (or a ULID)", ref)
}
return provider, handle, nil
}
// resolveProjectRef turns a project reference (ULID or name) into its ULID. A
// ULID is returned unchanged; a name is looked up via the server's exact-name
// filter (the same call `entire project list --name` uses).
Mcmd/entire/cli/resolveref.go+31
33 unmodified lines
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
33 unmodified lines
}
func TestParseQualifiedHandle(t *testing.T) {
t.Parallel()
tests := []struct {
in string
wantProvider string
wantHandle string
wantErr bool
}{
{in: "github:alice", wantProvider: "github", wantHandle: "alice"},
{in: "github:alice:bob", wantProvider: "github", wantHandle: "alice:bob"}, // only first colon splits
{in: "alice", wantErr: true}, // no provider prefix
{in: "github:", wantErr: true}, // empty handle
{in: ":alice", wantErr: true}, // empty provider
{in: "", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.in, func(t *testing.T) {
t.Parallel()
provider, handle, err := parseQualifiedHandle(tt.in)
if tt.wantErr {
if err == nil {
t.Errorf("parseQualifiedHandle(%q) expected error", tt.in)
}
return
}
if err != nil {
t.Fatalf("parseQualifiedHandle(%q): %v", tt.in, err)
}
if provider != tt.wantProvider || handle != tt.wantHandle {
t.Errorf("parseQualifiedHandle(%q) = (%q, %q), want (%q, %q)", tt.in, provider, handle, tt.wantProvider, tt.wantHandle)
}
})
}
}
func TestPickOrg(t *testing.T) {
t.Parallel()
orgs := []coreapi.Org{
Mcmd/entire/cli/resolveref_test.go+35
51 unmodified lines
52
53
54
55
55
56
57
58
3 unmodified lines
62
63
64
65
65
66
67
68
51 unmodified lines
// topology. Writes target refs.Primary; reads target refs.Read. The strategy's
// blob fetcher is wired in so reads can fetch blobs on demand after a treeless
// fetch.
func (s *ManualCommitStrategy) getCheckpointStore(ctx context.Context, repo *git.Repository) (checkpoint.CommittedStore, error) { //nolint:ireturn // committed store capability is the abstraction boundary
func (s *ManualCommitStrategy) getCheckpointStore(ctx context.Context, repo *git.Repository) (checkpoint.CommittedStore, error) {
stores, err := s.getCheckpointStores(ctx, repo)
if err != nil {
return nil, err
}
3 unmodified lines
// getTemporaryStore returns the git-backed shadow-branch store with the
// strategy's blob fetcher wired in.
func (s *ManualCommitStrategy) getTemporaryStore(ctx context.Context, repo *git.Repository) (checkpoint.TemporaryStore, error) { //nolint:ireturn // temporary store capability is the abstraction boundary
func (s *ManualCommitStrategy) getTemporaryStore(ctx context.Context, repo *git.Repository) (checkpoint.TemporaryStore, error) {
stores, err := s.getCheckpointStores(ctx, repo)
if err != nil {
return nil, err
}