Ctrl K
ComunidadGuías…

internal/expoimport/service.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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)
	}
}