feat(control-plane): resolve account owners by github:handle · Entire

feat(control-plane): resolve account owners by github:handle

ef0a49e·

toothbrush·3w ago·3 files·+82 added/-14 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

3

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).",
        "owning org (name or ULID) or account (github:handle or 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(&region, "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