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

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(&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

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
}