diff --git a/go.mod b/go.mod index 17d0ce11c..eaafaef48 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/dukex/mixpanel v1.0.1 github.com/getsentry/sentry-go v0.27.0 github.com/go-git/go-git/v5 v5.11.0 + github.com/google/go-github v17.0.0+incompatible github.com/gosuri/uilive v0.0.4 github.com/logrusorgru/aurora/v4 v4.0.0 github.com/manifoldco/promptui v0.9.0 @@ -112,7 +113,6 @@ require ( github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb // indirect github.com/google/go-dap v0.11.0 // indirect - github.com/google/go-github v17.0.0+incompatible // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/s2a-go v0.1.7 // indirect github.com/google/uuid v1.6.0 // indirect diff --git a/internal/dependencymanager/add.go b/internal/dependencymanager/add.go index 22e3a71ac..22a01c78c 100644 --- a/internal/dependencymanager/add.go +++ b/internal/dependencymanager/add.go @@ -20,6 +20,7 @@ package dependencymanager import ( "fmt" + "github.com/onflow/flow-cli/internal/util" "github.com/spf13/cobra" @@ -46,7 +47,8 @@ var addCommand = &command.Command{ Example: "flow dependencies add testnet://0afe396ebc8eee65.FlowToken", Args: cobra.ExactArgs(1), }, - RunS: add, + RunS: add, + Flags: &struct{}{}, } func init() { diff --git a/internal/migrate/is_validated.go b/internal/migrate/is_validated.go index 5f77f04da..e7d8f890b 100644 --- a/internal/migrate/is_validated.go +++ b/internal/migrate/is_validated.go @@ -20,18 +20,51 @@ package migrate import ( "context" + "encoding/json" "fmt" + "io" + "path" + "regexp" + "strings" + "time" - "github.com/onflow/cadence" - "github.com/onflow/contract-updater/lib/go/templates" + "github.com/google/go-github/github" + "github.com/logrusorgru/aurora/v4" "github.com/onflow/flowkit/v2" + "github.com/onflow/flowkit/v2/config" "github.com/onflow/flowkit/v2/output" "github.com/spf13/cobra" "github.com/onflow/flow-cli/internal/command" - "github.com/onflow/flow-cli/internal/scripts" + "github.com/onflow/flow-cli/internal/util" ) +//go:generate mockery --name GitHubRepositoriesService --output ./mocks --case underscore +type GitHubRepositoriesService interface { + GetContents(ctx context.Context, owner string, repo string, path string, opt *github.RepositoryContentGetOptions) (fileContent *github.RepositoryContent, directoryContent []*github.RepositoryContent, resp *github.Response, err error) + DownloadContents(ctx context.Context, owner string, repo string, filepath string, opt *github.RepositoryContentGetOptions) (io.ReadCloser, error) +} + +type validator struct { + repoService GitHubRepositoriesService + state *flowkit.State + logger output.Logger + network config.Network +} + +type contractUpdateStatus struct { + Kind string `json:"kind,omitempty"` + AccountAddress string `json:"account_address"` + ContractName string `json:"contract_name"` + Error string `json:"error,omitempty"` +} + +type validationResult struct { + Timestamp time.Time + Status contractUpdateStatus + Network string +} + var isValidatedflags struct{} var IsValidatedCommand = &command.Command{ @@ -45,42 +78,283 @@ var IsValidatedCommand = &command.Command{ RunS: isValidated, } +const ( + repoOwner = "onflow" + repoName = "cadence" + repoPath = "migrations_data" + repoRef = "master" +) + +const moreInformationMessage = "For more information, please find the latest full migration report on GitHub (https://github.com/onflow/cadence/tree/master/migrations_data).\n\nNew reports are generated after each weekly emulated migration and your contract's status may change, so please actively monitor this status and stay tuned for the latest announcements until the migration deadline." +const contractUpdateFailureKind = "contract-update-failure" + func isValidated( args []string, - globalFlags command.GlobalFlags, - _ output.Logger, + _ command.GlobalFlags, + logger output.Logger, flow flowkit.Services, state *flowkit.State, ) (command.Result, error) { - err := checkNetwork(flow.Network()) + repoService := github.NewClient(nil).Repositories + v := newValidator(repoService, flow.Network(), state, logger) + + contractName := args[0] + return v.validate(contractName) +} + +func newValidator(repoService GitHubRepositoriesService, network config.Network, state *flowkit.State, logger output.Logger) *validator { + return &validator{ + repoService: repoService, + state: state, + logger: logger, + network: network, + } +} + +func (v *validator) validate(contractName string) (validationResult, error) { + err := checkNetwork(v.network) if err != nil { - return nil, err + return validationResult{}, err } - contractName := args[0] - addr, err := getAddressByContractName(state, contractName, flow.Network()) + v.logger.StartProgress("Checking if contract has been validated") + defer v.logger.StopProgress() + + addr, err := getAddressByContractName(v.state, contractName, v.network) + if err != nil { + return validationResult{}, err + } + + status, timestamp, err := v.getContractValidationStatus( + v.network, + addr.HexWithPrefix(), + contractName, + ) if err != nil { - return nil, fmt.Errorf("error getting address by contract name: %w", err) + // Append more information message to the error + // this way we can ensure that if, for whatever reason, we fail to fetch the report + // the user will still understand that they can find the report on GitHub + return validationResult{}, fmt.Errorf("%w\n\n%s%s", err, moreInformationMessage, "\n") } - caddr := cadence.NewAddress(addr) + return validationResult{ + Timestamp: *timestamp, + Status: status, + Network: v.network.Name, + }, nil +} + +func (v *validator) getContractValidationStatus(network config.Network, address string, contractName string) (contractUpdateStatus, *time.Time, error) { + // Get last migration report + report, timestamp, err := v.getLatestMigrationReport(network) + if err != nil { + return contractUpdateStatus{}, nil, err + } - cname, err := cadence.NewString(contractName) + // Get all the contract statuses from the report + statuses, err := v.fetchAndParseReport(report.GetPath()) if err != nil { - return nil, fmt.Errorf("failed to get cadence string from contract name: %w", err) + return contractUpdateStatus{}, nil, err + } + + // Get the validation result related to the contract + var status *contractUpdateStatus + for _, s := range statuses { + if s.ContractName == contractName && s.AccountAddress == address { + status = &s + break + } + } + + // Throw error if contract was not part of the last migration + if status == nil { + builder := strings.Builder{} + builder.WriteString("the contract does not appear to have been a part of any emulated migrations yet, please ensure that it has been staged & wait for the next emulated migration (last migration report was at ") + builder.WriteString(timestamp.Format(time.RFC3339)) + builder.WriteString(")\n\n") + + builder.WriteString(" - Account: ") + builder.WriteString(address) + builder.WriteString("\n - Contract: ") + builder.WriteString(contractName) + builder.WriteString("\n - Network: ") + builder.WriteString(network.Name) + + return contractUpdateStatus{}, nil, fmt.Errorf(builder.String()) + } + + return *status, timestamp, nil +} + +func (v *validator) getLatestMigrationReport(network config.Network) (*github.RepositoryContent, *time.Time, error) { + // Get the content of the migration reports folder + _, folderContent, _, err := v.repoService.GetContents( + context.Background(), + repoOwner, + repoName, + repoPath, + &github.RepositoryContentGetOptions{ + Ref: repoRef, + }, + ) + if err != nil { + return nil, nil, err + } + + // Find the latest report file + var latestReport *github.RepositoryContent + var latestReportTime *time.Time + for _, content := range folderContent { + if content.Type != nil && *content.Type == "file" { + contentPath := content.GetPath() + + // Try to extract the time from the filename + networkStr, t, err := extractInfoFromFilename(contentPath) + if err != nil { + // Ignore files that don't match the expected format + // Or have any another error while parsing + continue + } + + // Ignore reports from other networks + if networkStr != strings.ToLower(network.Name) { + continue + } + + // Check if this is the latest report + if latestReportTime == nil || t.After(*latestReportTime) { + latestReport = content + latestReportTime = t + } + } + } + + if latestReport == nil { + return nil, nil, fmt.Errorf("no emulated migration reports found for network `%s` within the remote repository - have any migrations been run yet for this network?", network.Name) } - value, err := flow.ExecuteScript( + return latestReport, latestReportTime, nil +} + +func (v *validator) fetchAndParseReport(reportPath string) ([]contractUpdateStatus, error) { + // Get the content of the latest report + rc, err := v.repoService.DownloadContents( context.Background(), - flowkit.Script{ - Code: templates.GenerateIsValidatedScript(MigrationContractStagingAddress(flow.Network().Name)), - Args: []cadence.Value{caddr, cname}, + repoOwner, + repoName, + reportPath, + &github.RepositoryContentGetOptions{ + Ref: repoRef, }, - flowkit.LatestScriptQuery, ) if err != nil { - return nil, fmt.Errorf("error executing script: %w", err) + return nil, err + } + defer rc.Close() + + // Read the report content + reportContent, err := io.ReadAll(rc) + if err != nil { + return nil, err + } + + // Parse the report + var statuses []contractUpdateStatus + err = json.Unmarshal(reportContent, &statuses) + if err != nil { + return nil, err + } + + return statuses, nil +} + +func extractInfoFromFilename(filename string) (string, *time.Time, error) { + // Extracts the timestamp from the filename in the format: migrations_data/raw/XXXXXX-MM-DD-YYYY--XXXXXX.json + fileName := path.Base(filename) + + expr := regexp.MustCompile(`^staged-contracts-report.*(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z)-([a-z]+).json$`) + regexpMatches := expr.FindStringSubmatch(fileName) + if regexpMatches == nil { + return "", nil, fmt.Errorf("filename does not match the expected format") + } + + // Extract the timestamp + timestampStr := regexpMatches[1] + timestamp, err := time.Parse("2006-01-02T15-04-05Z", timestampStr) + if err != nil { + return "", nil, fmt.Errorf("failed to parse timestamp from filename") } - return scripts.NewScriptResult(value), nil + // Extract the network + network := regexpMatches[2] + + return network, ×tamp, nil +} + +func (s contractUpdateStatus) IsFailure() bool { + // Just in case there are failures without an error message in the future + // we will also check the kind of the status + return s.Error != "" || s.Kind == contractUpdateFailureKind +} + +func (v validationResult) String() string { + status := v.Status + + builder := strings.Builder{} + builder.WriteString("Last emulated migration report was created at ") + builder.WriteString(v.Timestamp.Format(time.RFC3339)) + builder.WriteString("\n\n") + + statusBuilder := strings.Builder{} + emoji := "✅ " + statusColor := aurora.Green + if status.IsFailure() { + emoji = "❌ " + statusColor = aurora.Red + } + + statusBuilder.WriteString(util.PrintEmoji(emoji)) + statusBuilder.WriteString("The contract has ") + + if status.IsFailure() { + statusBuilder.WriteString("FAILED") + } else { + statusBuilder.WriteString("PASSED") + } + statusBuilder.WriteString(" the last emulated migration") + + statusBuilder.WriteString("\n\n - Account: ") + statusBuilder.WriteString(status.AccountAddress) + statusBuilder.WriteString("\n - Contract: ") + statusBuilder.WriteString(status.ContractName) + statusBuilder.WriteString("\n - Network: ") + statusBuilder.WriteString(v.Network) + statusBuilder.WriteString("\n\n") + + // Write colored status + builder.WriteString(statusColor(statusBuilder.String()).String()) + + if status.Error != "" { + builder.WriteString(status.Error) + builder.WriteString("\n") + } + + if status.IsFailure() { + builder.WriteString(aurora.Red(">> Please review the error and re-stage the contract to resolve these issues if necessary").String()) + builder.WriteString("\n\n") + } + + builder.WriteString(moreInformationMessage) + return builder.String() +} + +func (v validationResult) JSON() interface{} { + return v +} + +func (v validationResult) Oneliner() string { + if v.Status.IsFailure() { + return util.MessageWithEmojiPrefix("❌", "FAILED") + } + return util.MessageWithEmojiPrefix("✅", "FAIlED") } diff --git a/internal/migrate/is_validated_test.go b/internal/migrate/is_validated_test.go index b83cdf481..18e59b449 100644 --- a/internal/migrate/is_validated_test.go +++ b/internal/migrate/is_validated_test.go @@ -19,27 +19,66 @@ package migrate import ( + "bytes" + "encoding/json" + "io" "testing" + "time" - "github.com/onflow/cadence" - "github.com/onflow/contract-updater/lib/go/templates" - "github.com/onflow/flowkit/v2" + "github.com/google/go-github/github" "github.com/onflow/flowkit/v2/config" "github.com/onflow/flowkit/v2/tests" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" "github.com/onflow/flow-cli/internal/command" + "github.com/onflow/flow-cli/internal/migrate/mocks" "github.com/onflow/flow-cli/internal/util" ) func Test_IsValidated(t *testing.T) { - srv, state, _ := util.TestMocks(t) - + _, state, _ := util.TestMocks(t) testContract := tests.ContractSimple - t.Run("Success", func(t *testing.T) { + // use emulator-account because it already exists in mock, so we don't need to create it + emuAccount, err := state.EmulatorServiceAccount() + require.NoError(t, err) + + // Helper function to test the isValidated function + // with all of the necessary mocks + testIsValidatedWithStatuses := func(statuses []contractUpdateStatus) (command.Result, error) { + mockClient := mocks.NewGitHubRepositoriesService(t) + + // mock github file download + data, _ := json.Marshal(statuses) + mockClient.On("DownloadContents", mock.Anything, "onflow", "cadence", "migrations_data/staged-contracts-report-2024-04-17T20-05-50Z-testnet.json", mock.MatchedBy(func(opts *github.RepositoryContentGetOptions) bool { + return opts.Ref == "master" + })).Return(io.NopCloser(bytes.NewReader(data)), nil).Once() + + // mock github folder response + fileType := "file" + olderPath := "migrations_data/staged-contracts-report-2019-04-17T20-05-50Z-testnet.json" + wrongNetworkPath := "migrations_data/staged-contracts-report-2025-04-17T20-05-50Z-mainnet.json" + latestPath := "migrations_data/staged-contracts-report-2024-04-17T20-05-50Z-testnet.json" + mockFolderContent := []*github.RepositoryContent{ + { + Path: &olderPath, + Type: &fileType, + }, + { + Path: &wrongNetworkPath, + Type: &fileType, + }, + { + Path: &latestPath, + Type: &fileType, + }, + } + mockClient.On("GetContents", mock.Anything, "onflow", "cadence", "migrations_data", mock.MatchedBy(func(opts *github.RepositoryContentGetOptions) bool { + return opts.Ref == "master" + })).Return(nil, mockFolderContent, nil, nil).Once() + // mock flowkit contract state.Contracts().AddOrUpdate( config.Contract{ Name: testContract.Name, @@ -51,7 +90,7 @@ func Test_IsValidated(t *testing.T) { state.Deployments().AddOrUpdate( config.Deployment{ Network: "testnet", - Account: "emulator-account", + Account: emuAccount.Name, Contracts: []config.ContractDeployment{ { Name: testContract.Name, @@ -60,38 +99,56 @@ func Test_IsValidated(t *testing.T) { }, ) - srv.Network.Return(config.Network{ - Name: "testnet", - }, nil) + // call the isValidated function + validator := newValidator(mockClient, config.TestnetNetwork, state, util.NoLogger) - account, err := state.EmulatorServiceAccount() - assert.NoError(t, err) - - srv.ExecuteScript.Run(func(args mock.Arguments) { - script := args.Get(1).(flowkit.Script) - - assert.Equal(t, templates.GenerateIsValidatedScript(MigrationContractStagingAddress("testnet")), script.Code) + res, err := validator.validate( + testContract.Name, + ) - assert.Equal(t, 2, len(script.Args)) - actualContractAddressArg, actualContractNameArg := script.Args[0], script.Args[1] + require.Equal(t, true, mockClient.AssertExpectations(t)) + return res, err + } - contractName, _ := cadence.NewString(testContract.Name) - contractAddr := cadence.NewAddress(account.Address) - assert.Equal(t, contractName, actualContractNameArg) - assert.Equal(t, contractAddr, actualContractAddressArg) - }).Return(cadence.NewBool(true), nil) + t.Run("isValidated gets status from latest report on github", func(t *testing.T) { + res, err := testIsValidatedWithStatuses([]contractUpdateStatus{ + { + AccountAddress: "0x01", + ContractName: "some-other-contract", + Error: "4567", + }, + { + AccountAddress: emuAccount.Address.HexWithPrefix(), + ContractName: testContract.Name, + Error: "1234", + }, + }) + require.NoError(t, err) + require.NotNil(t, res) + + // use a different time format for expected time to better ensure parsing is correct + expectedTime, err := time.Parse(time.RFC3339, "2024-04-17T20:05:50Z") + require.NoError(t, err) + require.Equal(t, res.JSON(), validationResult{ + Timestamp: expectedTime, + Status: contractUpdateStatus{ + AccountAddress: emuAccount.Address.HexWithPrefix(), + ContractName: testContract.Name, + Error: "1234", + }, + Network: "testnet", + }) + }) - result, err := isValidated( - []string{testContract.Name}, - command.GlobalFlags{ - Network: "testnet", + t.Run("isValidated errors if contract was not in last migration", func(t *testing.T) { + _, err := testIsValidatedWithStatuses([]contractUpdateStatus{ + { + AccountAddress: "0x01", + ContractName: "some-other-contract", + Error: "4567", }, - util.NoLogger, - srv.Mock, - state, - ) - assert.NoError(t, err) - // TODO: fix this - assert.NotNil(t, result) + }) + + require.ErrorContains(t, err, "does not appear to have been a part of any emulated migrations yet") }) } diff --git a/internal/migrate/mocks/git_hub_repositories_service.go b/internal/migrate/mocks/git_hub_repositories_service.go new file mode 100644 index 000000000..e586ee016 --- /dev/null +++ b/internal/migrate/mocks/git_hub_repositories_service.go @@ -0,0 +1,109 @@ +// Code generated by mockery v2.38.0. DO NOT EDIT. + +package mocks + +import ( + context "context" + io "io" + + github "github.com/google/go-github/github" + + mock "github.com/stretchr/testify/mock" +) + +// GitHubRepositoriesService is an autogenerated mock type for the GitHubRepositoriesService type +type GitHubRepositoriesService struct { + mock.Mock +} + +// DownloadContents provides a mock function with given fields: ctx, owner, repo, filepath, opt +func (_m *GitHubRepositoriesService) DownloadContents(ctx context.Context, owner string, repo string, filepath string, opt *github.RepositoryContentGetOptions) (io.ReadCloser, error) { + ret := _m.Called(ctx, owner, repo, filepath, opt) + + if len(ret) == 0 { + panic("no return value specified for DownloadContents") + } + + var r0 io.ReadCloser + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, string, *github.RepositoryContentGetOptions) (io.ReadCloser, error)); ok { + return rf(ctx, owner, repo, filepath, opt) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string, string, *github.RepositoryContentGetOptions) io.ReadCloser); ok { + r0 = rf(ctx, owner, repo, filepath, opt) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(io.ReadCloser) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string, string, *github.RepositoryContentGetOptions) error); ok { + r1 = rf(ctx, owner, repo, filepath, opt) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetContents provides a mock function with given fields: ctx, owner, repo, path, opt +func (_m *GitHubRepositoriesService) GetContents(ctx context.Context, owner string, repo string, path string, opt *github.RepositoryContentGetOptions) (*github.RepositoryContent, []*github.RepositoryContent, *github.Response, error) { + ret := _m.Called(ctx, owner, repo, path, opt) + + if len(ret) == 0 { + panic("no return value specified for GetContents") + } + + var r0 *github.RepositoryContent + var r1 []*github.RepositoryContent + var r2 *github.Response + var r3 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, string, *github.RepositoryContentGetOptions) (*github.RepositoryContent, []*github.RepositoryContent, *github.Response, error)); ok { + return rf(ctx, owner, repo, path, opt) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string, string, *github.RepositoryContentGetOptions) *github.RepositoryContent); ok { + r0 = rf(ctx, owner, repo, path, opt) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*github.RepositoryContent) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string, string, *github.RepositoryContentGetOptions) []*github.RepositoryContent); ok { + r1 = rf(ctx, owner, repo, path, opt) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).([]*github.RepositoryContent) + } + } + + if rf, ok := ret.Get(2).(func(context.Context, string, string, string, *github.RepositoryContentGetOptions) *github.Response); ok { + r2 = rf(ctx, owner, repo, path, opt) + } else { + if ret.Get(2) != nil { + r2 = ret.Get(2).(*github.Response) + } + } + + if rf, ok := ret.Get(3).(func(context.Context, string, string, string, *github.RepositoryContentGetOptions) error); ok { + r3 = rf(ctx, owner, repo, path, opt) + } else { + r3 = ret.Error(3) + } + + return r0, r1, r2, r3 +} + +// NewGitHubRepositoriesService creates a new instance of GitHubRepositoriesService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewGitHubRepositoriesService(t interface { + mock.TestingT + Cleanup(func()) +}) *GitHubRepositoriesService { + mock := &GitHubRepositoriesService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +}