diff --git a/cmd/main.go b/cmd/main.go index 98121252..9c83e13f 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -98,7 +98,7 @@ func run(cfg *Config) error { baseBranch, targetBranch, appSelectionOptions, - cfg.Repo, + cfg.RepoSelector, redirectRevisions, ) if err != nil { @@ -246,7 +246,7 @@ func run(cfg *Config) error { targetApps, baseBranch, targetBranch, - cfg.Repo, + cfg.RepoSelector, tempFolder, redirectRevisions, cfg.Debug, @@ -330,7 +330,7 @@ func run(cfg *Config) error { cfg.Concurrency, baseApps.SelectedApps, targetApps.SelectedApps, - cfg.Repo, + cfg.RepoSelector, appSelectionOptions, tempFolder, ) @@ -343,7 +343,7 @@ func run(cfg *Config) error { cfg.Concurrency, baseApps.SelectedApps, targetApps.SelectedApps, - cfg.Repo, + cfg.RepoSelector, ) } } else { diff --git a/cmd/options.go b/cmd/options.go index 7a7a48ee..3ff4b6f5 100644 --- a/cmd/options.go +++ b/cmd/options.go @@ -22,6 +22,7 @@ import ( "github.com/dag-andersen/argocd-diff-preview/pkg/k3d" "github.com/dag-andersen/argocd-diff-preview/pkg/kind" "github.com/dag-andersen/argocd-diff-preview/pkg/minikube" + "github.com/dag-andersen/argocd-diff-preview/pkg/repository" "github.com/dag-andersen/argocd-diff-preview/pkg/resource_filter" "github.com/dag-andersen/argocd-diff-preview/pkg/vars" ) @@ -97,6 +98,7 @@ type RawOptions struct { BaseBranch string `mapstructure:"base-branch"` TargetBranch string `mapstructure:"target-branch"` Repo string `mapstructure:"repo"` + RepoRegex string `mapstructure:"repo-regex"` OutputFolder string `mapstructure:"output-folder"` SecretsFolder string `mapstructure:"secrets-folder"` CreateCluster bool `mapstructure:"create-cluster"` @@ -146,7 +148,7 @@ type Config struct { LineCount uint BaseBranch string TargetBranch string - Repo string + RepoSelector repository.Selector OutputFolder string SecretsFolder string CreateCluster bool @@ -302,7 +304,8 @@ func Parse() *Config { // Git related rootCmd.Flags().StringP("base-branch", "b", DefaultBaseBranch, "Base branch name") rootCmd.Flags().StringP("target-branch", "t", "", "Target branch name (required)") - rootCmd.Flags().String("repo", "", "Git Repository. Format: OWNER/REPO (required)") + rootCmd.Flags().String("repo", "", "Git repository. Format: OWNER/REPO. Mutually exclusive with --repo-regex") + rootCmd.Flags().String("repo-regex", "", "Regex matched against normalized Argo CD repoURL values for templated repository URLs. Mutually exclusive with --repo") // Folders rootCmd.Flags().StringP("output-folder", "o", DefaultOutputFolder, "Output folder where the diff will be saved") @@ -327,7 +330,7 @@ func Parse() *Config { rootCmd.Flags().Bool("auto-detect-files-changed", DefaultAutoDetectFilesChanged, "Auto detect files changed between branches") rootCmd.Flags().Bool("ignore-invalid-watch-pattern", DefaultIgnoreInvalidWatchPattern, "Ignore invalid watch pattern Regex on Applications") rootCmd.Flags().Bool("watch-if-no-watch-pattern-found", DefaultWatchIfNoWatchPatternFound, "Render applications without watch pattern") - rootCmd.Flags().String("redirect-target-revisions", "", "List of target revisions to redirect") + rootCmd.Flags().String("redirect-target-revisions", "", "Comma-separated source targetRevision values to redirect to the target branch. Example: main,HEAD. By default, every targetRevision in matching repositories is redirected") rootCmd.Flags().String("title", DefaultTitle, "Custom title for the markdown output") rootCmd.Flags().Bool("hide-deleted-app-diff", DefaultHideDeletedAppDiff, "Hide diff content for fully deleted applications (only show deletion header)") rootCmd.Flags().String("argocd-ui-url", DefaultArgocdUIURL, "Argo CD URL to generate application links in diff output (e.g., https://argocd.example.com)") @@ -373,8 +376,11 @@ func (o *RawOptions) checkRequired() []string { if o.TargetBranch == "" { errors = append(errors, "target-branch") } - if o.Repo == "" { - errors = append(errors, "repo") + if o.Repo == "" && o.RepoRegex == "" { + errors = append(errors, "repo or repo-regex") + } + if o.Repo != "" && o.RepoRegex != "" { + errors = append(errors, "repo and repo-regex are mutually exclusive") } return errors } @@ -390,7 +396,6 @@ func (o *RawOptions) ToConfig() (*Config, error) { LineCount: o.LineCount, BaseBranch: o.BaseBranch, TargetBranch: o.TargetBranch, - Repo: o.Repo, OutputFolder: o.OutputFolder, SecretsFolder: o.SecretsFolder, CreateCluster: o.CreateCluster, @@ -447,6 +452,11 @@ func (o *RawOptions) ToConfig() (*Config, error) { return nil, fmt.Errorf("invalid file-regex: %w", err) } + cfg.RepoSelector, err = o.parseRepositorySelector() + if err != nil { + return nil, err + } + // Parse selectors cfg.Selectors, err = o.parseSelectors() if err != nil { @@ -521,6 +531,15 @@ func (o *RawOptions) parseFileRegex() (*regexp.Regexp, error) { return regexp.Compile(o.FileRegex) } +// parseRepositorySelector returns a Repository Selector based on the repo or repo-regex flags +func (o *RawOptions) parseRepositorySelector() (repository.Selector, error) { + repoSelector, err := repository.NewSelector(o.Repo, o.RepoRegex) + if err != nil { + return repository.Selector{}, fmt.Errorf("invalid repo-regex: %w", err) + } + return *repoSelector, nil +} + // parseRedirectRevisions parses the redirect-target-revisions string into a slice of strings func (o *RawOptions) parseRedirectRevisions() []string { if o.RedirectTargetRevisions == "" { @@ -654,7 +673,12 @@ func (o *Config) LogConfig() { if o.ArgocdConfigPath != DefaultArgocdConfigPath { log.Info().Msgf("✨ - argocd-config-dir: %s", o.ArgocdConfigPath) } - log.Info().Msgf("✨ - repo: %s", o.Repo) + if o.RepoSelector.Repo != "" { + log.Info().Msgf("✨ - repo: %s", o.RepoSelector.Repo) + } + if o.RepoSelector.Regex != nil { + log.Info().Msgf("✨ - repo-regex: %s", o.RepoSelector.Regex.String()) + } log.Info().Msgf("✨ - timeout: %d seconds", o.Timeout) if o.LogFormat != DefaultLogFormat { log.Info().Msgf("✨ - log-format: %s", o.LogFormat) diff --git a/docs/options.md b/docs/options.md index 85689210..a44f5897 100644 --- a/docs/options.md +++ b/docs/options.md @@ -5,14 +5,14 @@ This document describes all the available options for `argocd-diff-preview`. Opt ## Usage ```bash -argocd-diff-preview [FLAGS] [OPTIONS] --repo --target-branch +argocd-diff-preview [FLAGS] [OPTIONS] (--repo | --repo-regex ) --target-branch ``` ## Required Options | Flag | Environment Variable | Description | | --------------------------------------- | -------------------- | -------------------------------------------------------------------------------- | -| `--repo ` | `REPO` | Git Repository in format `OWNER/REPO` (e.g., `dag-andersen/argocd-diff-preview`) | +| `--repo ` or `--repo-regex ` | `REPO` or `REPO_REGEX` | Git repository in format `OWNER/REPO`, or a regex for templated Argo CD repoURL values. These options are mutually exclusive | | `--target-branch `, `-t` | `TARGET_BRANCH` | Target branch name (the branch you want to compare with the base branch) | ## Flags @@ -63,8 +63,9 @@ argocd-diff-preview [FLAGS] [OPTIONS] --repo --target-branch ` | `LOG_FORMAT` | `human` | Log format. Options: `human`, `json` | | `--max-diff-length ` | `MAX_DIFF_LENGTH` | `65536` | Max diff message character count (only limits the generated Markdown file) | | `--output-folder `, `-o` | `OUTPUT_FOLDER` | `./output` | Output folder where the diff will be saved | -| `--redirect-target-revisions ` | `REDIRECT_TARGET_REVISIONS` | - | List of target revisions to redirect | +| `--redirect-target-revisions ` | `REDIRECT_TARGET_REVISIONS` | - | Comma-separated source targetRevision values to redirect to the target branch. Example: main,HEAD. By default, every targetRevision in matching repositories is redirected | | `--render-method ` | `RENDER_METHOD` | `server-api` | Manifest rendering method. Options: `cli`, `server-api`, `repo-server-api` | +| `--repo-regex ` | `REPO_REGEX` | - | Advanced repository matcher for templated Argo CD repoURL values. Mutually exclusive with `--repo` | | `--secrets-folder `, `-s` | `SECRETS_FOLDER` | `./secrets` | Secrets folder where the secrets are read from | | `--selector `, `-l` | `SELECTOR` | - | Label selector to filter on (e.g., `key1=value1,key2=value2`) | | `--timeout ` | `TIMEOUT` | `180` | Set timeout in seconds | diff --git a/integration-test/integration_test.go b/integration-test/integration_test.go index 8703b035..b0da4fb9 100644 --- a/integration-test/integration_test.go +++ b/integration-test/integration_test.go @@ -65,6 +65,7 @@ type TestCase struct { ArgocdConfigDir string // Custom argocd-config directory (relative to integration-test/); overrides auto-derived path ArgocdUIURL string // Argo CD URL for generating application links in diff output TraverseAppOfApps string // If "true", enables recursive child app discovery (--traverse-app-of-apps) + RepoRegex string // If set, use --repo-regex instead of --repo ExpectFailure bool // If true, the test is expected to fail } @@ -234,6 +235,7 @@ var testCases = []TestCase{ BaseBranch: "integration-test/branch-9/base", Suffix: "-3", MaxDiffLength: "400", + RepoRegex: "go.*diff-pre", }, { Name: "branch-10/target-1", @@ -272,6 +274,7 @@ var testCases = []TestCase{ TargetBranch: "integration-test/branch-13/target", BaseBranch: "integration-test/branch-13/base", Suffix: "-1", + RepoRegex: "-diff-", }, { Name: "branch-13/target-2", @@ -872,7 +875,11 @@ func runWithDocker(tc TestCase, createCluster bool, runDirs RunDirs) error { // Add environment variables args = append(args, "-e", fmt.Sprintf("BASE_BRANCH=%s", tc.BaseBranch)) args = append(args, "-e", fmt.Sprintf("TARGET_BRANCH=%s", tc.TargetBranch)) - args = append(args, "-e", fmt.Sprintf("REPO=%s/%s", defaultGitHubOrg, defaultGitOpsRepo)) + if tc.RepoRegex != "" { + args = append(args, "-e", fmt.Sprintf("REPO_REGEX=%s", tc.RepoRegex)) + } else { + args = append(args, "-e", fmt.Sprintf("REPO=%s/%s", defaultGitHubOrg, defaultGitOpsRepo)) + } args = append(args, "-e", fmt.Sprintf("TIMEOUT=%s", defaultTimeout)) args = append(args, "-e", fmt.Sprintf("LINE_COUNT=%s", getLineCount(tc))) args = append(args, "-e", fmt.Sprintf("MAX_DIFF_LENGTH=%s", getMaxDiffLength(tc))) @@ -964,7 +971,6 @@ func buildArgs(tc TestCase, createCluster bool, runDirs RunDirs, repoRoot string args := []string{ "--base-branch", tc.BaseBranch, "--target-branch", tc.TargetBranch, - "--repo", fmt.Sprintf("%s/%s", defaultGitHubOrg, defaultGitOpsRepo), "--argocd-namespace", argocdNamespace, "--timeout", defaultTimeout, "--line-count", getLineCount(tc), @@ -973,6 +979,11 @@ func buildArgs(tc TestCase, createCluster bool, runDirs RunDirs, repoRoot string "--keep-cluster-alive", "--disable-client-throttling", } + if tc.RepoRegex != "" { + args = append(args, "--repo-regex", tc.RepoRegex) + } else { + args = append(args, "--repo", fmt.Sprintf("%s/%s", defaultGitHubOrg, defaultGitOpsRepo)) + } // Don't keep cluster alive for tests that expect failure (cluster may be in broken state) if !tc.ExpectFailure { diff --git a/pkg/argoapplication/app_status.go b/pkg/argoapplication/app_status.go index a3236709..2e3c06a3 100644 --- a/pkg/argoapplication/app_status.go +++ b/pkg/argoapplication/app_status.go @@ -3,13 +3,14 @@ package argoapplication import ( "errors" "fmt" + "strings" "github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1" "github.com/dag-andersen/argocd-diff-preview/pkg/argocd" ) func isErrorCondition(condType string) bool { - return condType != "" && containsIgnoreCase(condType, "error") + return condType != "" && strings.Contains(strings.ToLower(condType), "error") } // GetApplicationStatus returns the error status of an application diff --git a/pkg/argoapplication/application_sets.go b/pkg/argoapplication/application_sets.go index 441ac313..c5829938 100644 --- a/pkg/argoapplication/application_sets.go +++ b/pkg/argoapplication/application_sets.go @@ -11,6 +11,7 @@ import ( "github.com/dag-andersen/argocd-diff-preview/pkg/argocd" "github.com/dag-andersen/argocd-diff-preview/pkg/git" + "github.com/dag-andersen/argocd-diff-preview/pkg/repository" "github.com/dag-andersen/argocd-diff-preview/pkg/utils" ) @@ -20,7 +21,7 @@ func ConvertAppSetsToAppsInBothBranches( targetApps *ArgoSelection, baseBranch *git.Branch, targetBranch *git.Branch, - repo string, + repoSelector repository.Selector, tempFolder string, redirectRevisions []string, debug bool, @@ -40,7 +41,7 @@ func ConvertAppSetsToAppsInBothBranches( debug, failOnDuplicateGeneratedApplications, appSelectionOptions, - repo, + repoSelector, redirectRevisions, ) @@ -58,7 +59,7 @@ func ConvertAppSetsToAppsInBothBranches( debug, failOnDuplicateGeneratedApplications, appSelectionOptions, - repo, + repoSelector, redirectRevisions, ) if err != nil { @@ -79,7 +80,7 @@ func processAppSets( debug bool, failOnDuplicateGeneratedApplications bool, appSelectionOptions ApplicationSelectionOptions, - repo string, + repoSelector repository.Selector, redirectRevisions []string, ) (*ArgoSelection, error) { @@ -150,7 +151,7 @@ func processAppSets( argocd.Namespace, selection.SelectedApps, branch, - repo, + repoSelector, redirectRevisions, ) if err != nil { diff --git a/pkg/argoapplication/applications.go b/pkg/argoapplication/applications.go index 4782ba49..3d5569e8 100644 --- a/pkg/argoapplication/applications.go +++ b/pkg/argoapplication/applications.go @@ -9,6 +9,7 @@ import ( "github.com/dag-andersen/argocd-diff-preview/pkg/fileparsing" "github.com/dag-andersen/argocd-diff-preview/pkg/git" + "github.com/dag-andersen/argocd-diff-preview/pkg/repository" "github.com/dag-andersen/argocd-diff-preview/pkg/utils" "sigs.k8s.io/yaml" ) @@ -74,14 +75,14 @@ func GetApplicationsForBranches( baseBranch *git.Branch, targetBranch *git.Branch, appSelectionOptions ApplicationSelectionOptions, - repo string, + repoSelector repository.Selector, redirectRevisions []string, ) (*ArgoSelection, *ArgoSelection, error) { baseApps, err := getApplications( argocdNamespace, baseBranch, appSelectionOptions, - repo, + repoSelector, redirectRevisions, ) if err != nil { @@ -92,7 +93,7 @@ func GetApplicationsForBranches( argocdNamespace, targetBranch, appSelectionOptions, - repo, + repoSelector, redirectRevisions, ) if err != nil { @@ -107,7 +108,7 @@ func getApplications( argocdNamespace string, branch *git.Branch, appSelectionOptions ApplicationSelectionOptions, - repo string, + repoSelector repository.Selector, redirectRevisions []string, ) (*ArgoSelection, error) { log.Info().Str("branch", branch.Name).Msg("🤖 Fetching all files for branch") @@ -156,7 +157,7 @@ func getApplications( argocdNamespace, selection.SelectedApps, branch, - repo, + repoSelector, redirectRevisions, ) if err != nil { diff --git a/pkg/argoapplication/patching.go b/pkg/argoapplication/patching.go index 1ec0d5a6..6baaa852 100644 --- a/pkg/argoapplication/patching.go +++ b/pkg/argoapplication/patching.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/dag-andersen/argocd-diff-preview/pkg/git" + "github.com/dag-andersen/argocd-diff-preview/pkg/repository" "github.com/rs/zerolog/log" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) @@ -14,7 +15,7 @@ func patchApplications( argocdNamespace string, applications []ArgoResource, branch *git.Branch, - repo string, + repoSelector repository.Selector, redirectRevisions []string, ) ([]ArgoResource, error) { var patchedApps []ArgoResource @@ -24,7 +25,7 @@ func patchApplications( argocdNamespace, app, branch, - repo, + repoSelector, redirectRevisions, ) if err != nil { @@ -41,10 +42,9 @@ func PatchApplication( argocdNamespace string, app ArgoResource, branch *git.Branch, - repo string, + repoSelector repository.Selector, redirectRevisions []string, ) (*ArgoResource, error) { - // Chain the modifications app.SetNamespace(argocdNamespace) @@ -74,7 +74,7 @@ func PatchApplication( // RedirectSourceHydrator must be called BEFORE RedirectSources so that // the converted spec.source can have its targetRevision redirected - err = app.RedirectSourceHydrator(repo, branch.Name, redirectRevisions) + err = app.RedirectSourceHydrator(repoSelector.Repo, branch.Name, redirectRevisions) if err != nil { log.Info().Msgf("❌ Failed to patch application: %s", app.GetLongName()) return nil, fmt.Errorf("failed to redirect source hydrator: %w", err) @@ -86,13 +86,13 @@ func PatchApplication( return nil, fmt.Errorf("failed to set Helm releaseName: %w", err) } - err = app.RedirectSources(repo, branch.Name, redirectRevisions) + err = app.RedirectSources(&repoSelector, branch.Name, redirectRevisions) if err != nil { log.Info().Msgf("❌ Failed to patch application: %s", app.GetLongName()) return nil, fmt.Errorf("failed to redirect sources: %w", err) } - err = app.RedirectGenerators(repo, branch.Name, redirectRevisions) + err = app.RedirectGenerators(&repoSelector, branch.Name, redirectRevisions) if err != nil { log.Info().Msgf("❌ Failed to patch application: %s", app.GetLongName()) return nil, fmt.Errorf("failed to redirect generators: %w", err) @@ -295,7 +295,7 @@ func (a *ArgoResource) RemoveSyncPolicy() error { } // RedirectSources updates the source/sources targetRevision to point to the specified branch -func (a *ArgoResource) RedirectSources(repo, branch string, redirectRevisions []string) error { +func (a *ArgoResource) RedirectSources(repoSelector *repository.Selector, branch string, redirectRevisions []string) error { if a.Yaml == nil { log.Warn().Str("patchType", "redirectSources").Str(a.Kind.ShortName(), a.GetLongName()).Msg("⚠️ No YAML for Application") return nil @@ -320,7 +320,7 @@ func (a *ArgoResource) RedirectSources(repo, branch string, redirectRevisions [] // Handle single source if source, ok := specMap["source"].(map[string]any); ok { - if err := a.redirectSourceMap(source, repo, branch, redirectRevisions); err != nil { + if err := a.redirectSourceMap(source, repoSelector, branch, redirectRevisions); err != nil { return err } } @@ -330,7 +330,7 @@ func (a *ArgoResource) RedirectSources(repo, branch string, redirectRevisions [] if sources, ok := sourcesInterface.([]any); ok { for _, sourceInterface := range sources { if source, ok := sourceInterface.(map[string]any); ok { - if err := a.redirectSourceMap(source, repo, branch, redirectRevisions); err != nil { + if err := a.redirectSourceMap(source, repoSelector, branch, redirectRevisions); err != nil { return err } } @@ -347,7 +347,7 @@ func (a *ArgoResource) RedirectSources(repo, branch string, redirectRevisions [] } // Helper function to redirect a single source -func (a *ArgoResource) redirectSourceMap(source map[string]any, repo, branch string, redirectRevisions []string) error { +func (a *ArgoResource) redirectSourceMap(source map[string]any, repoSelector *repository.Selector, branch string, redirectRevisions []string) error { // Skip helm charts if _, hasChart := source["chart"]; hasChart { log.Debug().Str("patchType", "redirectSource").Str(a.Kind.ShortName(), a.GetLongName()).Msg("Found helm chart") @@ -361,8 +361,8 @@ func (a *ArgoResource) redirectSourceMap(source map[string]any, repo, branch str return nil } - if !containsIgnoreCase(repoURL, repo) { - log.Debug().Str("patchType", "redirectSource").Str(a.Kind.ShortName(), a.GetLongName()).Msgf("Skipping source: %s (repoURL does not match %s)", repoURL, repo) + if !repoSelector.Matches(repoURL) { + log.Debug().Str("patchType", "redirectSource").Str(a.Kind.ShortName(), a.GetLongName()).Msgf("Skipping source: %s (repoURL does not match repository matcher)", repoURL) return nil } @@ -391,7 +391,7 @@ func (a *ArgoResource) redirectSourceMap(source map[string]any, repo, branch str } // RedirectGenerators updates the git generator targetRevision to point to the specified branch -func (a *ArgoResource) RedirectGenerators(repo, branch string, redirectRevisions []string) error { +func (a *ArgoResource) RedirectGenerators(repoSelector *repository.Selector, branch string, redirectRevisions []string) error { // Only process ApplicationSets if a.Kind != ApplicationSet || a.Yaml == nil { return nil @@ -405,7 +405,7 @@ func (a *ArgoResource) RedirectGenerators(repo, branch string, redirectRevisions } // Process generators - if err := a.processGenerators(generators, repo, branch, redirectRevisions, "spec.generators", 0); err != nil { + if err := a.processGenerators(generators, repoSelector, branch, redirectRevisions, "spec.generators", 0); err != nil { log.Error().Str("patchType", "redirectGenerators").Str("branch", branch).Str(a.Kind.ShortName(), a.GetLongName()).Err(err).Msg("error processing generators") return err } @@ -415,7 +415,7 @@ func (a *ArgoResource) RedirectGenerators(repo, branch string, redirectRevisions } // processGenerators processes a slice of generators recursively -func (a *ArgoResource) processGenerators(generators []any, repo, branch string, redirectRevisions []string, parent string, level int) error { +func (a *ArgoResource) processGenerators(generators []any, repoSelector *repository.Selector, branch string, redirectRevisions []string, parent string, level int) error { // Limit nesting level to prevent infinite recursion if level > 2 { return fmt.Errorf("too many levels of nested matrix generators in ApplicationSet: %s", a.Name) @@ -458,7 +458,7 @@ func (a *ArgoResource) processGenerators(generators []any, repo, branch string, // Process nested generators matrixParent := fmt.Sprintf("%s[%d].matrix.generators", parent, i) - if err := a.processGenerators(nestedGenSlice, repo, branch, redirectRevisions, matrixParent, level+1); err != nil { + if err := a.processGenerators(nestedGenSlice, repoSelector, branch, redirectRevisions, matrixParent, level+1); err != nil { return err } @@ -489,7 +489,7 @@ func (a *ArgoResource) processGenerators(generators []any, repo, branch string, // Process nested generators mergeParent := fmt.Sprintf("%s[%d].merge.generators", parent, i) - if err := a.processGenerators(nestedGenSlice, repo, branch, redirectRevisions, mergeParent, level+1); err != nil { + if err := a.processGenerators(nestedGenSlice, repoSelector, branch, redirectRevisions, mergeParent, level+1); err != nil { return err } @@ -507,8 +507,8 @@ func (a *ArgoResource) processGenerators(generators []any, repo, branch string, // Check repoURL repoURL, ok := gitMap["repoURL"].(string) - if !ok || !containsIgnoreCase(repoURL, repo) { - log.Debug().Str(a.Kind.ShortName(), a.GetLongName()).Str("patchType", "redirectGenerators").Msgf("Skipping source: %s (repoURL does not match %s)", repoURL, repo) + if !ok || !repoSelector.Matches(repoURL) { + log.Debug().Str(a.Kind.ShortName(), a.GetLongName()).Str("patchType", "redirectGenerators").Msgf("Skipping source: %s (repoURL does not match repository matcher)", repoURL) continue } @@ -531,10 +531,6 @@ func (a *ArgoResource) processGenerators(generators []any, repo, branch string, return nil } -func containsIgnoreCase(s, substr string) bool { - return strings.Contains(strings.ToLower(s), strings.ToLower(substr)) -} - // RedirectSourceHydrator converts sourceHydrator applications to regular applications. // It moves spec.sourceHydrator.drySource to spec.source and removes the sourceHydrator field. // This allows argocd-diff-preview to render manifests from the dry source directly. diff --git a/pkg/argoapplication/patching_test.go b/pkg/argoapplication/patching_test.go index 17741903..674008d8 100644 --- a/pkg/argoapplication/patching_test.go +++ b/pkg/argoapplication/patching_test.go @@ -5,12 +5,21 @@ import ( "testing" "github.com/dag-andersen/argocd-diff-preview/pkg/git" + "github.com/dag-andersen/argocd-diff-preview/pkg/repository" "github.com/rs/zerolog" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "sigs.k8s.io/yaml" ) +func mustNewTestSelector(t *testing.T, repo, regex string) *repository.Selector { + t.Helper() + selector, err := repository.NewSelector(repo, regex) + require.NoError(t, err) + return selector +} + func TestRedirectGenerators(t *testing.T) { zerolog.SetGlobalLevel(zerolog.FatalLevel) @@ -506,6 +515,27 @@ func TestRedirectGenerators(t *testing.T) { revision: HEAD files: - path: config.yaml +`), + redirectRevisions: []string{}, + expectErr: nil, + }, + { + name: "application set with git generator repoURL sharing prefix is not redirected", + yaml: applicationSetSpec(` + generators: + - git: + repoURL: https://github.com/org/repo-deploy.git + revision: HEAD + files: + - path: config.yaml +`), + want: applicationSetSpec(` + generators: + - git: + repoURL: https://github.com/org/repo-deploy.git + revision: HEAD + files: + - path: config.yaml `), redirectRevisions: []string{}, expectErr: nil, @@ -564,7 +594,9 @@ func TestRedirectGenerators(t *testing.T) { } // Run redirect generators - err = app.RedirectGenerators(repo, branch, tt.redirectRevisions) + repoSelector, selectorErr := repository.NewSelector(repo, "") + require.NoError(t, selectorErr) + err = app.RedirectGenerators(repoSelector, branch, tt.redirectRevisions) // Check result if tt.expectErr == nil { @@ -1257,6 +1289,42 @@ spec: destination: server: https://kubernetes.default.svc namespace: default +`, + }, + { + name: "source revision not redirected when repoURL only shares prefix", + kind: Application, + inputYAML: ` +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: prefix-app + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/org/repo-deploy.git + targetRevision: HEAD + path: apps/prefix-app + destination: + server: https://kubernetes.default.svc + namespace: default +`, + wantYAML: ` +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: prefix-app + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/org/repo-deploy.git + targetRevision: HEAD + path: apps/prefix-app + destination: + server: https://kubernetes.default.svc + namespace: default `, }, { @@ -1396,7 +1464,9 @@ spec: redirectRevisions := tt.redirectRevisions // ── Normal cases ──────────────────────────────────────────────── - patched, err := PatchApplication(argocdNamespace, *resource, branch, prRepo, redirectRevisions) + repoSelector, selectorErr := repository.NewSelector(prRepo, "") + require.NoError(t, selectorErr) + patched, err := PatchApplication(argocdNamespace, *resource, branch, *repoSelector, redirectRevisions) assert.NoError(t, err) assert.NotNil(t, patched) @@ -1412,6 +1482,48 @@ spec: } } +func TestPatchApplication_RepoRegexRedirectsTemplatedSource(t *testing.T) { + branch := git.NewBranch("my-feature", git.Target) + var obj map[string]any + require.NoError(t, yaml.Unmarshal([]byte(` +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: templated-app + namespace: argocd +spec: + project: default + source: + repoURL: git@github.com:my-org/my-repo-{{.metadata.annotations.repo}}-overrides.git + targetRevision: HEAD + path: apps/templated-app + destination: + server: https://kubernetes.default.svc + namespace: default +`), &obj)) + resource := &ArgoResource{ + Yaml: &unstructured.Unstructured{Object: obj}, + Kind: Application, + Id: "test-id", + Name: "templated-app", + FileName: "test.yaml", + } + + patched, err := PatchApplication( + "argocd", + *resource, + branch, + *mustNewTestSelector(t, "my-org/my-repo", `^git@github\.com:my-org/my-repo-[^/]+-overrides$`), + nil, + ) + require.NoError(t, err) + + targetRevision, found, err := unstructured.NestedString(patched.Yaml.Object, "spec", "source", "targetRevision") + require.NoError(t, err) + require.True(t, found) + assert.Equal(t, "my-feature", targetRevision) +} + func TestRedirectSourceHydrator(t *testing.T) { zerolog.SetGlobalLevel(zerolog.FatalLevel) diff --git a/pkg/reposerverextract/appofapps.go b/pkg/reposerverextract/appofapps.go index d73a9ebe..7ef764dc 100644 --- a/pkg/reposerverextract/appofapps.go +++ b/pkg/reposerverextract/appofapps.go @@ -27,6 +27,7 @@ import ( "github.com/dag-andersen/argocd-diff-preview/pkg/extract" "github.com/dag-andersen/argocd-diff-preview/pkg/git" "github.com/dag-andersen/argocd-diff-preview/pkg/reposerver" + "github.com/dag-andersen/argocd-diff-preview/pkg/repository" ) // maxAppOfAppsDepth is the maximum recursion depth allowed when following @@ -133,7 +134,7 @@ func RenderApplicationsFromBothBranchesWithAppOfApps( maxConcurrency uint, baseApps []argoapplication.ArgoResource, targetApps []argoapplication.ArgoResource, - prRepo string, + repoSelector repository.Selector, appSelectionOptions argoapplication.ApplicationSelectionOptions, tempFolder string, ) ([]extract.ExtractedApp, []extract.ExtractedApp, time.Duration, error) { @@ -346,7 +347,7 @@ func RenderApplicationsFromBothBranchesWithAppOfApps( ctx, cancel := context.WithTimeout(context.Background(), time.Duration(remainingTime())*time.Second) defer cancel() - manifests, childApps, err := renderAppWithChildDiscovery(ctx, repoClient, argocd, item.app, branchFolderByType, branchByType, namespacedScopedResources, creds, prRepo, argocd.Namespace, tempFolder, item.depth) + manifests, childApps, err := renderAppWithChildDiscovery(ctx, repoClient, argocd, item.app, branchFolderByType, branchByType, namespacedScopedResources, creds, &repoSelector, argocd.Namespace, tempFolder, item.depth) if err != nil { results <- renderResult{err: fmt.Errorf("failed to render app %s: %w", item.app.GetLongName(), err)} return @@ -407,12 +408,12 @@ func renderAppWithChildDiscovery( branchByType map[git.BranchType]*git.Branch, namespacedScopedResources map[schema.GroupKind]bool, creds *RepoCreds, - prRepo string, + repoSelector *repository.Selector, argocdNamespace string, tempFolder string, depth int, ) ([]unstructured.Unstructured, []argoapplication.ArgoResource, error) { - allManifests, err := renderApp(ctx, repoClient, app, branchFolderByType, namespacedScopedResources, creds, prRepo) + allManifests, err := renderApp(ctx, repoClient, app, branchFolderByType, namespacedScopedResources, creds, repoSelector) if err != nil { return nil, nil, err } @@ -440,7 +441,7 @@ func renderAppWithChildDiscovery( // Deep copy so PatchApplication mutates the copy, leaving m in // allManifests (the diff) untouched. resource := argoapplication.NewArgoResource(m.DeepCopy(), argoapplication.Application, name, name, fileName, app.Branch) - child, err := argoapplication.PatchApplication(argocdNamespace, *resource, branchByType[app.Branch], prRepo, nil) + child, err := argoapplication.PatchApplication(argocdNamespace, *resource, branchByType[app.Branch], *repoSelector, nil) if err != nil { log.Warn().Err(err). Str("parentApp", app.Name). @@ -469,7 +470,7 @@ func renderAppWithChildDiscovery( // Deep copy so PatchApplication mutates the copy, leaving m in // allManifests (the diff) untouched. appSetResource := argoapplication.NewArgoResource(m.DeepCopy(), argoapplication.ApplicationSet, appSetName, appSetName, app.FileName, app.Branch) - patchedAppSet, err := argoapplication.PatchApplication(argocdNamespace, *appSetResource, branch, prRepo, nil) + patchedAppSet, err := argoapplication.PatchApplication(argocdNamespace, *appSetResource, branch, *repoSelector, nil) if err != nil { log.Warn().Err(err). Str("parentApp", app.Name). @@ -502,7 +503,7 @@ func renderAppWithChildDiscovery( continue } resource := argoapplication.NewArgoResource(&genDoc, argoapplication.Application, name, name, breadcrumb, app.Branch) - child, err := argoapplication.PatchApplication(argocdNamespace, *resource, branch, prRepo, nil) + child, err := argoapplication.PatchApplication(argocdNamespace, *resource, branch, *repoSelector, nil) if err != nil { log.Warn().Err(err). Str("parentApp", app.Name). diff --git a/pkg/reposerverextract/extract.go b/pkg/reposerverextract/extract.go index 7dd09a54..ac7e3d52 100644 --- a/pkg/reposerverextract/extract.go +++ b/pkg/reposerverextract/extract.go @@ -36,6 +36,7 @@ import ( "github.com/dag-andersen/argocd-diff-preview/pkg/extract" "github.com/dag-andersen/argocd-diff-preview/pkg/git" "github.com/dag-andersen/argocd-diff-preview/pkg/reposerver" + "github.com/dag-andersen/argocd-diff-preview/pkg/repository" ) // resourceInfoProvider implements kubeutil.ResourceInfoProvider to supply @@ -71,7 +72,7 @@ func RenderApplicationsFromBothBranches( maxConcurrency uint, baseApps []argoapplication.ArgoResource, targetApps []argoapplication.ArgoResource, - prRepo string, + repoSelector repository.Selector, ) ([]extract.ExtractedApp, []extract.ExtractedApp, time.Duration, error) { startTime := time.Now() @@ -175,7 +176,7 @@ func RenderApplicationsFromBothBranches( ctx, cancel := context.WithTimeout(context.Background(), time.Duration(remainingTime())*time.Second) defer cancel() - manifests, err := renderApp(ctx, repoClient, app, branchFolderByType, namespacedScopedResources, creds, prRepo) + manifests, err := renderApp(ctx, repoClient, app, branchFolderByType, namespacedScopedResources, creds, &repoSelector) if err != nil { results <- result{err: fmt.Errorf("failed to render app %s: %w", app.GetLongName(), err)} return @@ -249,7 +250,7 @@ func renderApp( branchFolderByType map[git.BranchType]string, namespacedScopedResources map[schema.GroupKind]bool, creds *RepoCreds, - prRepo string, + repoSelector *repository.Selector, ) ([]unstructured.Unstructured, error) { branchFolder, ok := branchFolderByType[app.Branch] if !ok { @@ -264,7 +265,7 @@ func renderApp( var allManifestStrings []string for i, contentSource := range contentSources { - request, streamDir, cleanup, err := buildManifestRequestForSource(app, contentSource, refSources, hasMultipleSources, branchFolder, creds, prRepo) + request, streamDir, cleanup, err := buildManifestRequestForSource(app, contentSource, refSources, hasMultipleSources, branchFolder, creds, repoSelector) if err != nil { return nil, fmt.Errorf("failed to build manifest request for content source %d: %w", i, err) } @@ -500,7 +501,7 @@ func buildManifestRequestForSource( hasMultipleSources bool, branchFolder string, creds *RepoCreds, - prRepo string, + repoSelector *repository.Selector, ) (request *repoapiclient.ManifestRequest, streamDir string, cleanup func(), err error) { obj := app.Yaml.Object @@ -553,11 +554,11 @@ func buildManifestRequestForSource( // repository than the PR repo. Those files are not checked out // locally, so we cannot stream them. Fall back to the remote // GenerateManifest RPC and let the repo server fetch them itself. - if prRepo != "" && !repoURLContains(primarySource.RepoURL, prRepo) { + if !repoSelector.Matches(primarySource.RepoURL) { log.Debug(). Str("App", app.GetLongName()). Str("sourceRepoURL", primarySource.RepoURL). - Str("prRepo", prRepo). + Str("prRepo", repoSelector.String()). Msg("Source repoURL does not match PR repo - using remote RPC") return newManifestRequest(&primarySource), "", nil, nil } @@ -592,11 +593,11 @@ func buildManifestRequestForSource( // The same applies when a ref source lives outside the PR repository. Copying // the local branch folder into .refs would provide the wrong repository // content, especially for ref-only sources whose Path is empty. - if prRepo != "" && (!repoURLContains(primarySource.RepoURL, prRepo) || hasExternalRefSource(refSources, prRepo)) { + if !repoSelector.Matches(primarySource.RepoURL) || hasExternalRefSource(refSources, repoSelector) { log.Debug(). Str("App", app.GetLongName()). Str("sourceRepoURL", primarySource.RepoURL). - Str("prRepo", prRepo). + Str("prRepo", repoSelector.String()). Msg("Source or ref repoURL does not match PR repo (slow path) - using remote RPC") request = newManifestRequest(&primarySource) request.RefSources = buildRefSourcesMap(refSources, creds) @@ -706,9 +707,9 @@ func buildRefSourcesMap(refSources []v1alpha1.ApplicationSource, creds *RepoCred return refSourcesMap } -func hasExternalRefSource(refSources []v1alpha1.ApplicationSource, prRepo string) bool { +func hasExternalRefSource(refSources []v1alpha1.ApplicationSource, repoSelector *repository.Selector) bool { for _, ref := range refSources { - if !repoURLContains(ref.RepoURL, prRepo) { + if !repoSelector.Matches(ref.RepoURL) { return true } } diff --git a/pkg/reposerverextract/extract_test.go b/pkg/reposerverextract/extract_test.go index 62ecda44..a2322768 100644 --- a/pkg/reposerverextract/extract_test.go +++ b/pkg/reposerverextract/extract_test.go @@ -25,6 +25,7 @@ import ( repoapiclient "github.com/argoproj/argo-cd/v3/reposerver/apiclient" "github.com/dag-andersen/argocd-diff-preview/pkg/argoapplication" "github.com/dag-andersen/argocd-diff-preview/pkg/git" + "github.com/dag-andersen/argocd-diff-preview/pkg/repository" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -72,6 +73,13 @@ func assertDefaultProjectFields(t *testing.T, req *repoapiclient.ManifestRequest assert.Equal(t, []string{"*"}, req.ProjectSourceRepos, "source repos must be permissive so helm build errors are not masked as permission errors") } +func testRepoSelector(t *testing.T, repo string) *repository.Selector { + t.Helper() + selector, err := repository.NewSelector(repo, "") + require.NoError(t, err) + return selector +} + // ───────────────────────────────────────────────────────────────────────────── // 1. Single-source, local chart (fast path, no refs) // ───────────────────────────────────────────────────────────────────────────── @@ -99,7 +107,7 @@ spec: require.Empty(t, refSources) assert.False(t, hasMultipleSources) - req, streamDir, cleanup, err := buildManifestRequestForSource(app, contentSources[0], refSources, hasMultipleSources, branchFolder, nil, "") + req, streamDir, cleanup, err := buildManifestRequestForSource(app, contentSources[0], refSources, hasMultipleSources, branchFolder, nil, testRepoSelector(t, "")) require.NoError(t, err) if cleanup != nil { defer cleanup() @@ -141,7 +149,7 @@ spec: require.NoError(t, err) require.Len(t, contentSources, 1) - req, streamDir, cleanup, err := buildManifestRequestForSource(app, contentSources[0], refSources, hasMultipleSources, branchFolder, nil, "") + req, streamDir, cleanup, err := buildManifestRequestForSource(app, contentSources[0], refSources, hasMultipleSources, branchFolder, nil, testRepoSelector(t, "")) require.NoError(t, err) if cleanup != nil { defer cleanup() @@ -209,7 +217,7 @@ spec: require.Len(t, contentSources, 1, "only the chart source is a content source") require.Len(t, refSources, 1) - req, streamDir, cleanup, err := buildManifestRequestForSource(app, contentSources[0], refSources, hasMultipleSources, branchFolder, nil, "") + req, streamDir, cleanup, err := buildManifestRequestForSource(app, contentSources[0], refSources, hasMultipleSources, branchFolder, nil, testRepoSelector(t, "")) require.NoError(t, err) if cleanup != nil { defer cleanup() @@ -291,7 +299,7 @@ spec: require.Len(t, contentSources, 1) require.Len(t, refSources, 1) - req, streamDir, cleanup, err := buildManifestRequestForSource(app, contentSources[0], refSources, hasMultipleSources, branchFolder, nil, "") + req, streamDir, cleanup, err := buildManifestRequestForSource(app, contentSources[0], refSources, hasMultipleSources, branchFolder, nil, testRepoSelector(t, "")) require.NoError(t, err) require.NotEmpty(t, streamDir, "local chart with refs must stream a temp dir") defer cleanup() @@ -402,7 +410,7 @@ spec: } require.NotEmpty(t, chartSource.Chart, "should find the chart content source") - req, streamDir, cleanup, err := buildManifestRequestForSource(app, chartSource, refSources, hasMultipleSources, branchFolder, nil, "") + req, streamDir, cleanup, err := buildManifestRequestForSource(app, chartSource, refSources, hasMultipleSources, branchFolder, nil, testRepoSelector(t, "")) require.NoError(t, err) if cleanup != nil { defer cleanup() @@ -462,7 +470,7 @@ spec: require.Len(t, contentSources, 1) require.Len(t, refSources, 1) - req, streamDir, cleanup, err := buildManifestRequestForSource(app, contentSources[0], refSources, hasMultipleSources, branchFolder, nil, "") + req, streamDir, cleanup, err := buildManifestRequestForSource(app, contentSources[0], refSources, hasMultipleSources, branchFolder, nil, testRepoSelector(t, "")) require.NoError(t, err) if cleanup != nil { defer cleanup() @@ -547,7 +555,7 @@ spec: // Capture requests so we can verify per-source paths without duplicate calls. reqs := make([]struct{ path string }, len(contentSources)) for i, cs := range contentSources { - req, streamDir, cleanup, buildErr := buildManifestRequestForSource(app, cs, refSources, hasMultipleSources, branchFolder, nil, "") + req, streamDir, cleanup, buildErr := buildManifestRequestForSource(app, cs, refSources, hasMultipleSources, branchFolder, nil, testRepoSelector(t, "")) require.NoError(t, buildErr, "content source %d should not error", i) if cleanup != nil { defer cleanup() @@ -607,7 +615,7 @@ spec: require.Len(t, contentSources, 1) require.Empty(t, refSources) - req, streamDir, cleanup, err := buildManifestRequestForSource(app, contentSources[0], refSources, hasMultipleSources, branchFolder, nil, prRepo) + req, streamDir, cleanup, err := buildManifestRequestForSource(app, contentSources[0], refSources, hasMultipleSources, branchFolder, nil, testRepoSelector(t, prRepo)) require.NoError(t, err) if cleanup != nil { defer cleanup() @@ -657,7 +665,7 @@ spec: require.NoError(t, err) require.Len(t, contentSources, 1) - req, streamDir, cleanup, err := buildManifestRequestForSource(app, contentSources[0], refSources, hasMultipleSources, branchFolder, nil, prRepo) + req, streamDir, cleanup, err := buildManifestRequestForSource(app, contentSources[0], refSources, hasMultipleSources, branchFolder, nil, testRepoSelector(t, prRepo)) require.NoError(t, err) if cleanup != nil { defer cleanup() @@ -698,7 +706,7 @@ spec: require.Len(t, contentSources, 1) require.Len(t, refSources, 1) - req, streamDir, cleanup, err := buildManifestRequestForSource(app, contentSources[0], refSources, hasMultipleSources, branchFolder, nil, prRepo) + req, streamDir, cleanup, err := buildManifestRequestForSource(app, contentSources[0], refSources, hasMultipleSources, branchFolder, nil, testRepoSelector(t, prRepo)) require.NoError(t, err) if cleanup != nil { defer cleanup() @@ -716,6 +724,51 @@ spec: assertDefaultProjectFields(t, req) } +func TestBuildManifestRequest_SameRepoPrimaryWithPrefixExternalRef_UsesRemoteRPC(t *testing.T) { + prRepo := "org/helm-charts" + branchFolder := makeBranchFolder(t, "charts/my-chart") + + app := makeApp(t, ` +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: my-app +spec: + destination: + namespace: production + sources: + - repoURL: https://github.com/org/helm-charts.git + path: charts/my-chart + targetRevision: main + helm: + valueFiles: + - $helm-values-repo/imageTag.yaml + - repoURL: https://github.com/org/helm-charts-deploy.git + targetRevision: main + ref: helm-values-repo +`) + + contentSources, refSources, hasMultipleSources, err := splitSources(app) + require.NoError(t, err) + require.Len(t, contentSources, 1) + require.Len(t, refSources, 1) + + req, streamDir, cleanup, err := buildManifestRequestForSource(app, contentSources[0], refSources, hasMultipleSources, branchFolder, nil, testRepoSelector(t, prRepo)) + require.NoError(t, err) + if cleanup != nil { + defer cleanup() + } + + assert.Empty(t, streamDir, + "ref repo sharing the PR repo prefix must still be treated as external") + assert.Equal(t, []string{"$helm-values-repo/imageTag.yaml"}, req.ApplicationSource.Helm.ValueFiles) + require.NotNil(t, req.RefSources) + refTarget, ok := req.RefSources["$helm-values-repo"] + require.True(t, ok) + assert.Equal(t, "https://github.com/org/helm-charts-deploy.git", refTarget.Repo.Repo) + assertDefaultProjectFields(t, req) +} + // ───────────────────────────────────────────────────────────────────────────── // repocreds: GetRepo normalises .git suffix on lookup // @@ -770,86 +823,12 @@ func TestNormalizeRepoURL(t *testing.T) { } } -// ───────────────────────────────────────────────────────────────────────────── -// repoURLContains - substring match used for prRepo comparisons -// ───────────────────────────────────────────────────────────────────────────── - -func TestRepoURLContains(t *testing.T) { - cases := []struct { - name string - repoURL string - substr string - want bool - }{ - { - name: "full URL matches full URL", - repoURL: "https://github.com/org/repo.git", - substr: "https://github.com/org/repo.git", - want: true, - }, - { - name: "slug matches full URL", - repoURL: "https://github.com/org/repo.git", - substr: "org/repo", - want: true, - }, - { - name: "slug matches full URL without .git", - repoURL: "https://github.com/org/repo", - substr: "org/repo", - want: true, - }, - { - name: "case insensitive", - repoURL: "https://github.com/Org/Repo.git", - substr: "org/repo", - want: true, - }, - { - name: "different repos do not match", - repoURL: "https://github.com/org/repo-a.git", - substr: "org/repo-b", - want: false, - }, - { - name: "different orgs do not match", - repoURL: "https://github.com/org-a/repo.git", - substr: "org-b/repo", - want: false, - }, - { - name: "GitLab full URL with slug", - repoURL: "https://gitlab.com/company/project.git", - substr: "company/project", - want: true, - }, - { - name: "Bitbucket full URL with slug", - repoURL: "https://bitbucket.org/team/repo.git", - substr: "team/repo", - want: true, - }, - { - name: "empty substr matches anything", - repoURL: "https://github.com/org/repo.git", - substr: "", - want: true, - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - assert.Equal(t, tc.want, repoURLContains(tc.repoURL, tc.substr), "repoURL=%q substr=%q", tc.repoURL, tc.substr) - }) - } -} - // ───────────────────────────────────────────────────────────────────────────── // 10. Same-repo source with prRepo as owner/repo slug → streams locally // // When the user passes --repo=owner/repo (the documented format), the // source repoURL is a full URL like "https://github.com/owner/repo.git". -// The comparison must use substring matching so the slug is recognised as -// belonging to the same repository. +// The comparison must recognise the slug as belonging to the same repository. // // ───────────────────────────────────────────────────────────────────────────── func TestBuildManifestRequest_SameRepoSource_WithSlugPrRepo_StreamsLocally(t *testing.T) { @@ -880,7 +859,7 @@ spec: require.NoError(t, err) require.Len(t, contentSources, 1) - req, streamDir, cleanup, err := buildManifestRequestForSource(app, contentSources[0], refSources, hasMultipleSources, branchFolder, nil, prRepo) + req, streamDir, cleanup, err := buildManifestRequestForSource(app, contentSources[0], refSources, hasMultipleSources, branchFolder, nil, testRepoSelector(t, prRepo)) require.NoError(t, err) if cleanup != nil { defer cleanup() diff --git a/pkg/reposerverextract/repocreds.go b/pkg/reposerverextract/repocreds.go index 8bfd0440..b9ccb6be 100644 --- a/pkg/reposerverextract/repocreds.go +++ b/pkg/reposerverextract/repocreds.go @@ -194,17 +194,6 @@ func normalizeRepoURL(u string) string { return u } -// repoURLContains reports whether the normalised form of repoURL contains the -// normalised form of substr. This is used to match source repoURLs against the -// --repo flag value which can be either a full URL -// ("https://github.com/org/repo.git") or a short slug ("org/repo"). -// -// Using a substring match (like the patching code's containsIgnoreCase) keeps -// the comparison provider-agnostic — we don't assume GitHub, GitLab, etc. -func repoURLContains(repoURL, substr string) bool { - return strings.Contains(normalizeRepoURL(repoURL), normalizeRepoURL(substr)) -} - // HelmRepos returns the Helm + OCI repository lists to pass as // ManifestRequest.Repos. For OCI primary sources the OCI list is merged in // (mirrors controller/state.go behaviour). diff --git a/pkg/repository/match.go b/pkg/repository/match.go new file mode 100644 index 00000000..96d681a3 --- /dev/null +++ b/pkg/repository/match.go @@ -0,0 +1,89 @@ +package repository + +import ( + "regexp" + "strings" +) + +type Selector struct { + Repo string + Regex *regexp.Regexp +} + +func NewSelector(repo, regex string) (*Selector, error) { + selector := &Selector{Repo: repo} + if strings.TrimSpace(regex) != "" { + compiled, err := regexp.Compile(regex) + if err != nil { + return nil, err + } + selector.Regex = compiled + } + return selector, nil +} + +func (s *Selector) Matches(repoURL string) bool { + if s == nil { + return false + } + if s.Regex != nil { + return s.Regex.MatchString(normalizeRepoMatchInput(repoURL)) + } + if s.Repo == "" { + return true + } + return matchesRepo(repoURL, s.Repo) +} + +func (s *Selector) String() string { + if s == nil { + return "" + } + if s.Regex != nil { + return s.Regex.String() + } + return s.Repo +} + +// matchesRepo reports whether repoURL contains repo as complete repository path +// segments. repo can be either a full URL or a short path such as owner/repo. +// +// This intentionally uses bounded matching instead of strings.Contains so +// owner/repo matches https://example.com/owner/repo.git, but does not match +// https://example.com/owner/repo-deploy.git. +func matchesRepo(repoURL, repo string) bool { + normalizedURL := normalizeRepoMatchInput(repoURL) + normalizedRepo := normalizeRepoMatchInput(repo) + if normalizedURL == "" || normalizedRepo == "" { + return false + } + + if normalizedURL == normalizedRepo { + return true + } + + start := 0 + for { + index := strings.Index(normalizedURL[start:], normalizedRepo) + if index == -1 { + return false + } + index += start + + beforeMatches := index == 0 || normalizedURL[index-1] == ':' || normalizedURL[index-1] == '/' + afterIndex := index + len(normalizedRepo) + afterMatches := afterIndex == len(normalizedURL) || normalizedURL[afterIndex] == '/' + if beforeMatches && afterMatches { + return true + } + + start = index + 1 + } +} + +func normalizeRepoMatchInput(input string) string { + input = strings.ToLower(strings.TrimSpace(input)) + input = strings.Trim(input, "/") + input = strings.TrimSuffix(input, ".git") + return input +} diff --git a/pkg/repository/match_test.go b/pkg/repository/match_test.go new file mode 100644 index 00000000..6cb21a5c --- /dev/null +++ b/pkg/repository/match_test.go @@ -0,0 +1,140 @@ +package repository + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMatchesRepo(t *testing.T) { + cases := []struct { + name string + repoURL string + repo string + want bool + }{ + { + name: "full URL matches full URL", + repoURL: "https://github.com/org/repo.git", + repo: "https://github.com/org/repo.git", + want: true, + }, + { + name: "slug matches full URL", + repoURL: "https://github.com/org/repo.git", + repo: "org/repo", + want: true, + }, + { + name: "case insensitive", + repoURL: "https://github.com/Org/Repo.git", + repo: "org/repo", + want: true, + }, + { + name: "SSH URL matches slug", + repoURL: "git@github.com:org/repo.git", + repo: "org/repo", + want: true, + }, + { + name: "GitLab nested group matches full path", + repoURL: "https://gitlab.example.com/platform/team/repo.git", + repo: "platform/team/repo", + want: true, + }, + { + name: "Bitbucket full URL with slug", + repoURL: "https://bitbucket.org/team/repo.git", + repo: "team/repo", + want: true, + }, + { + name: "different repos do not match", + repoURL: "https://github.com/org/repo-a.git", + repo: "org/repo-b", + want: false, + }, + { + name: "repo prefix does not match different repo", + repoURL: "https://github.com/org/helm-charts-deploy.git", + repo: "org/helm-charts", + want: false, + }, + { + name: "repo name containing repo does not match", + repoURL: "https://github.com/org/monorepo-values.git", + repo: "org/monorepo", + want: false, + }, + { + name: "different orgs do not match", + repoURL: "https://github.com/org-a/repo.git", + repo: "org-b/repo", + want: false, + }, + { + name: "empty repo does not match", + repoURL: "https://github.com/org/repo.git", + repo: "", + want: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, matchesRepo(tc.repoURL, tc.repo), "repoURL=%q repo=%q", tc.repoURL, tc.repo) + }) + } +} + +func TestSelectorWithRegex(t *testing.T) { + cases := []struct { + name string + regex string + repoURL string + }{ + { + name: "GitHub SSH URL", + regex: `^git@github\.com:my-org/my-repo-[^/]+-overrides$`, + repoURL: "git@github.com:my-org/my-repo-{{.metadata.annotations.repo}}-overrides.git", + }, + { + name: "GitLab HTTPS URL with nested groups", + regex: `^https://gitlab\.example\.com/platform/team/my-repo-[^/]+-overrides$`, + repoURL: "https://gitlab.example.com/platform/team/my-repo-{{.metadata.annotations.repo}}-overrides.git", + }, + { + name: "GitLab SSH URL with nested groups", + regex: `^git@gitlab\.example\.com:platform/team/my-repo-[^/]+-overrides$`, + repoURL: "git@gitlab.example.com:platform/team/my-repo-{{.metadata.annotations.repo}}-overrides.git", + }, + { + name: "Bitbucket HTTPS URL", + regex: `^https://bitbucket\.org/team/my-repo-[^/]+-overrides$`, + repoURL: "https://bitbucket.org/team/my-repo-{{.metadata.annotations.repo}}-overrides.git", + }, + { + name: "Azure DevOps HTTPS URL", + regex: `^https://dev\.azure\.com/org/project/_git/my-repo-[^/]+-overrides$`, + repoURL: "https://dev.azure.com/org/project/_git/my-repo-{{.metadata.annotations.repo}}-overrides.git", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + selector, err := NewSelector("org/repo", tc.regex) + assert.NoError(t, err) + + assert.True(t, selector.Matches(tc.repoURL)) + assert.False(t, selector.Matches(strings.TrimSuffix(tc.repoURL, "-overrides.git")+"-overrides-extra.git")) + assert.False(t, selector.Matches("https://github.com/org/repo.git"), "repo-regex overrides default repo matching") + }) + } +} + +func TestNewSelectorInvalidRegex(t *testing.T) { + _, err := NewSelector("org/repo", "[") + assert.Error(t, err) +}