internal/expoimport/service.go
Rama main · réplica de lectura del repositorio original.
package expoimport
import (
"context"
"errors"
"fmt"
"log"
"strings"
"xprem/config"
"xprem/internal/bucket"
"xprem/internal/jobs"
"xprem/internal/providers/expo"
"xprem/internal/repository"
"xprem/internal/services"
"xprem/internal/types"
"xprem/internal/validation"
"github.com/google/uuid"
)
// Service creates a local app from an existing Expo project, under
// the same project UUID. The Expo credential is never stored.
type Service struct {
apps *services.AppService
branches *services.BranchService
channels *services.ChannelService
updateRepo services.UpdateRepository
jobs *jobs.Client
blobStore *bucket.BlobStore
updateStore *bucket.UpdateStore
}
func NewService(apps *services.AppService, branches *services.BranchService, channels *services.ChannelService, updateRepo services.UpdateRepository, jobsClient *jobs.Client, blobStore *bucket.BlobStore, updateStore *bucket.UpdateStore) *Service {
return &Service{
apps: apps,
branches: branches,
channels: channels,
updateRepo: updateRepo,
jobs: jobsClient,
blobStore: blobStore,
updateStore: updateStore,
}
}
type Result struct {
AppId string `json:"appId"`
Name string `json:"name"`
BranchCount int `json:"branchCount"`
ChannelCount int `json:"channelCount"`
Skipped []string `json:"skipped,omitempty"`
Warnings []string `json:"warnings,omitempty"`
HistoryJobId string `json:"historyJobId,omitempty"`
}
// Plan is the dry run shown before an import; ImportApp executes
// this exact plan.
type Plan struct {
AppId string `json:"appId"`
// Expo's name, or the UUID when that name fails validation.
Name string `json:"name"`
ExpoName string `json:"expoName"`
// Conflict is why the import cannot run at all; empty when it can.
Conflict string `json:"conflict,omitempty"`
Branches []PlanItem `json:"branches"`
Channels []PlanItem `json:"channels"`
}
// SkipReason set means the entry will not be created; Warning means created,
// with a caveat.
type PlanItem struct {
Name string `json:"name"`
MappedBranch string `json:"mappedBranch,omitempty"`
SkipReason string `json:"skipReason,omitempty"`
Warning string `json:"warning,omitempty"`
}
func requireExpoAuth(auth types.Auth) error {
if !expo.HasCredential(auth) {
return validation.Errorf("accessToken", "provide an Expo access token")
}
return nil
}
func (s *Service) ListImportableApps(ctx context.Context, auth types.Auth) ([]expo.AccountApps, error) {
if !config.IsDBMode() {
return nil, repository.ErrNotSupportedInStatelessMode
}
if err := requireExpoAuth(auth); err != nil {
return nil, err
}
return expo.FetchAccountApps(ctx, auth)
}
func (s *Service) fetchImportStructure(ctx context.Context, auth types.Auth, expoAppId string) (uuid.UUID, *expo.ProjectStructure, error) {
if !config.IsDBMode() {
return uuid.UUID{}, nil, repository.ErrNotSupportedInStatelessMode
}
if err := requireExpoAuth(auth); err != nil {
return uuid.UUID{}, nil, err
}
parsedId, err := uuid.Parse(strings.TrimSpace(expoAppId))
if err != nil {
return uuid.UUID{}, nil, validation.Errorf("expoAppId", "must be the Expo project UUID (app.json, extra.eas.projectId)")
}
structure, err := expo.FetchProjectStructure(ctx, auth, parsedId.String())
if err != nil {
return uuid.UUID{}, nil, err
}
return parsedId, structure, nil
}
// buildImportPlan applies the same name rules CreateBranch and CreateChannel
// enforce, so the plan never promises an entry the import would refuse.
func buildImportPlan(appId uuid.UUID, structure *expo.ProjectStructure) *Plan {
plan := &Plan{
AppId: appId.String(),
Name: structure.Name,
ExpoName: structure.Name,
Branches: []PlanItem{},
Channels: []PlanItem{},
}
if validation.DisplayName("name", structure.Name) != nil {
plan.Name = appId.String()
}
keptBranches := make(map[string]bool, len(structure.Branches))
for _, branchName := range structure.Branches {
item := PlanItem{Name: branchName}
if err := services.ValidateBranchName(branchName); err != nil {
item.SkipReason = validationMessage(err)
} else {
keptBranches[branchName] = true
}
plan.Branches = append(plan.Branches, item)
}
for _, channel := range structure.Channels {
item := PlanItem{Name: channel.Name}
if channel.BranchName != nil {
item.MappedBranch = *channel.BranchName
}
if err := validation.Name("channelName", channel.Name); err != nil {
item.SkipReason = validationMessage(err)
} else if item.MappedBranch != "" && !keptBranches[item.MappedBranch] {
item.Warning = fmt.Sprintf("left unmapped, its branch %q is not imported", item.MappedBranch)
item.MappedBranch = ""
} else if channel.UnresolvedBranchID != "" {
item.Warning = "left unmapped, its Expo branch is not in the project listing"
}
plan.Channels = append(plan.Channels, item)
}
return plan
}
func validationMessage(err error) string {
var valErr *validation.Error
if errors.As(err, &valErr) {
return valErr.Message
}
return err.Error()
}
func (s *Service) PreviewImport(ctx context.Context, auth types.Auth, expoAppId string) (*Plan, error) {
parsedId, structure, err := s.fetchImportStructure(ctx, auth, expoAppId)
if err != nil {
return nil, err
}
plan := buildImportPlan(parsedId, structure)
if _, err := s.apps.GetAppByID(ctx, plan.AppId); err == nil {
plan.Conflict = "an app with this UUID already exists here; delete it first to import again"
}
return plan, nil
}
// ImportApp executes the plan PreviewImport shows. Any failure past app
// creation, the job not starting included, rolls the app back.
func (s *Service) ImportApp(ctx context.Context, auth types.Auth, expoAppId string, keysConfig config.KeysConfig, historyLimit int) (*Result, error) {
if historyLimit < 0 || historyLimit > MaxHistoryImportGroups {
return nil, validation.Errorf("historyLimit", "must be between 0 and %d", MaxHistoryImportGroups)
}
parsedId, structure, err := s.fetchImportStructure(ctx, auth, expoAppId)
if err != nil {
return nil, err
}
plan := buildImportPlan(parsedId, structure)
appId, err := s.apps.CreateAppWithId(ctx, parsedId, plan.Name, keysConfig)
if err != nil {
return nil, err
}
result := &Result{AppId: appId, Name: plan.Name}
for _, branch := range plan.Branches {
if branch.SkipReason != "" {
result.Skipped = append(result.Skipped, fmt.Sprintf("branch %q: %s", branch.Name, branch.SkipReason))
continue
}
if _, err := s.branches.CreateBranch(ctx, appId, branch.Name); err != nil {
s.rollback(ctx, appId, plan.Name)
return nil, fmt.Errorf("failed to import branch %q: %w", branch.Name, err)
}
result.BranchCount++
}
for _, channel := range plan.Channels {
if channel.SkipReason != "" {
result.Skipped = append(result.Skipped, fmt.Sprintf("channel %q: %s", channel.Name, channel.SkipReason))
continue
}
if channel.Warning != "" {
result.Warnings = append(result.Warnings, fmt.Sprintf("channel %q: %s", channel.Name, channel.Warning))
}
var branchName *string
if channel.MappedBranch != "" {
branchName = &channel.MappedBranch
}
if _, err := s.channels.CreateChannel(ctx, appId, branchName, channel.Name); err != nil {
s.rollback(ctx, appId, plan.Name)
return nil, fmt.Errorf("failed to import channel %q: %w", channel.Name, err)
}
result.ChannelCount++
}
if historyLimit > 0 {
jobId, err := s.StartHistoryImport(ctx, auth, appId, historyLimit)
if err != nil {
s.rollback(ctx, appId, plan.Name)
return nil, fmt.Errorf("failed to start the update history import: %w", err)
}
result.HistoryJobId = jobId
}
return result, nil
}
// App deletion cascades to branches and channels.
func (s *Service) rollback(ctx context.Context, appId string, name string) {
if err := s.apps.DeleteApp(ctx, config.AppConfig{Id: appId, Name: name}); err != nil {
log.Printf("[expo-import] failed to roll back app %s after a failed import: %v", appId, err)
}
}