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

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

24d24efmain·

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

Use: "create ", 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

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], } }) }, }

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")

48 unmodified lines

// 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).

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{