This is an automated email from the ASF dual-hosted git repository.

rbstp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/incubator-devlake.git


The following commit(s) were added to refs/heads/main by this push:
     new 94f7bca49 fix(argocd): extract revision from multi-source application 
revisions[] (#8810)
94f7bca49 is described below

commit 94f7bca4937aa549437137a13ce190c93538e3d4
Author: Anvesh Vemula <[email protected]>
AuthorDate: Fri Apr 3 05:48:20 2026 +0530

    fix(argocd): extract revision from multi-source application revisions[] 
(#8810)
---
 .../20260331_add_repo_url_to_sync_operations.go    |  54 +++++++
 .../argocd/models/migrationscripts/register.go     |   1 +
 backend/plugins/argocd/models/sync_operation.go    |   1 +
 .../plugins/argocd/tasks/application_extractor.go  |  39 ++++--
 .../argocd/tasks/sync_operation_convertor.go       |   8 +-
 .../argocd/tasks/sync_operation_extractor.go       | 156 ++++++++++++++++++++-
 .../argocd/tasks/sync_operation_extractor_test.go  | 140 ++++++++++++++++++
 7 files changed, 389 insertions(+), 10 deletions(-)

diff --git 
a/backend/plugins/argocd/models/migrationscripts/20260331_add_repo_url_to_sync_operations.go
 
b/backend/plugins/argocd/models/migrationscripts/20260331_add_repo_url_to_sync_operations.go
new file mode 100644
index 000000000..5acdc543b
--- /dev/null
+++ 
b/backend/plugins/argocd/models/migrationscripts/20260331_add_repo_url_to_sync_operations.go
@@ -0,0 +1,54 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package migrationscripts
+
+import (
+       "github.com/apache/incubator-devlake/core/context"
+       "github.com/apache/incubator-devlake/core/errors"
+       "github.com/apache/incubator-devlake/core/plugin"
+)
+
+var _ plugin.MigrationScript = (*addRepoURLToSyncOperations)(nil)
+
+type addRepoURLToSyncOperations struct{}
+
+// addRepoURLSyncOpArchived is a snapshot of ArgocdSyncOperation used solely
+// for this migration so the live model can evolve independently.
+type addRepoURLSyncOpArchived struct {
+       ConnectionId    uint64 `gorm:"primaryKey"`
+       ApplicationName string `gorm:"primaryKey;type:varchar(255)"`
+       DeploymentId    int64  `gorm:"primaryKey"`
+       RepoURL         string `gorm:"type:varchar(500)"`
+}
+
+func (addRepoURLSyncOpArchived) TableName() string {
+       return "_tool_argocd_sync_operations"
+}
+
+func (m *addRepoURLToSyncOperations) Up(basicRes context.BasicRes) 
errors.Error {
+       db := basicRes.GetDal()
+       return db.AutoMigrate(&addRepoURLSyncOpArchived{})
+}
+
+func (*addRepoURLToSyncOperations) Version() uint64 {
+       return 20260331000000
+}
+
+func (*addRepoURLToSyncOperations) Name() string {
+       return "argocd add repo_url to sync operations"
+}
diff --git a/backend/plugins/argocd/models/migrationscripts/register.go 
b/backend/plugins/argocd/models/migrationscripts/register.go
index 2c70d8115..9ed924206 100644
--- a/backend/plugins/argocd/models/migrationscripts/register.go
+++ b/backend/plugins/argocd/models/migrationscripts/register.go
@@ -25,5 +25,6 @@ func All() []plugin.MigrationScript {
        return []plugin.MigrationScript{
                new(addInitTables),
                new(addImageSupportArtifacts),
+               new(addRepoURLToSyncOperations),
        }
 }
diff --git a/backend/plugins/argocd/models/sync_operation.go 
b/backend/plugins/argocd/models/sync_operation.go
index 77cc09544..ea8838cb1 100644
--- a/backend/plugins/argocd/models/sync_operation.go
+++ b/backend/plugins/argocd/models/sync_operation.go
@@ -28,6 +28,7 @@ type ArgocdSyncOperation struct {
        ApplicationName string `gorm:"primaryKey;type:varchar(255)"`
        DeploymentId    int64  `gorm:"primaryKey"`        // History ID from 
ArgoCD
        Revision        string `gorm:"type:varchar(255)"` // Git SHA
+       RepoURL         string `gorm:"type:varchar(500)"` // Git repo URL 
resolved from source/sources at extraction time
        Kind            string `gorm:"type:varchar(100)"` // Kubernetes 
resource kind: Deployment, ReplicaSet, Rollout, StatefulSet, DaemonSet, etc.
        StartedAt       *time.Time
        FinishedAt      *time.Time
diff --git a/backend/plugins/argocd/tasks/application_extractor.go 
b/backend/plugins/argocd/tasks/application_extractor.go
index 3a0568609..1c789999c 100644
--- a/backend/plugins/argocd/tasks/application_extractor.go
+++ b/backend/plugins/argocd/tasks/application_extractor.go
@@ -38,6 +38,13 @@ var ExtractApplicationsMeta = plugin.SubTaskMeta{
        ProductTables:    []string{models.ArgocdApplication{}.TableName()},
 }
 
+type ArgocdApiApplicationSource struct {
+       RepoURL        string `json:"repoURL"`
+       Path           string `json:"path"`
+       TargetRevision string `json:"targetRevision"`
+       Chart          string `json:"chart"`
+}
+
 type ArgocdApiApplication struct {
        Metadata struct {
                Name              string    `json:"name"`
@@ -46,11 +53,9 @@ type ArgocdApiApplication struct {
        } `json:"metadata"`
        Spec struct {
                Project string `json:"project"`
-               Source  struct {
-                       RepoURL        string `json:"repoURL"`
-                       Path           string `json:"path"`
-                       TargetRevision string `json:"targetRevision"`
-               } `json:"source"`
+               // Single-source apps use Source; multi-source apps use Sources.
+               Source      ArgocdApiApplicationSource   `json:"source"`
+               Sources     []ArgocdApiApplicationSource `json:"sources"`
                Destination struct {
                        Server    string `json:"server"`
                        Namespace string `json:"namespace"`
@@ -88,13 +93,31 @@ func ExtractApplications(taskCtx plugin.SubTaskContext) 
errors.Error {
                                return nil, errors.Default.Wrap(err, "error 
unmarshaling application")
                        }
 
+                       // Resolve the primary source. Multi-source apps 
populate spec.sources[]
+                       // instead of spec.source; we prefer the first 
git-hosted source so that
+                       // cicd_deployment_commits.repo_url is a browsable 
repository URL rather
+                       // than a Helm chart registry address.
+                       primarySource := apiApp.Spec.Source
+                       if primarySource.RepoURL == "" && 
len(apiApp.Spec.Sources) > 0 {
+                               for _, src := range apiApp.Spec.Sources {
+                                       if isGitHostedURL(src.RepoURL) {
+                                               primarySource = src
+                                               break
+                                       }
+                               }
+                               // Fallback: use the first source if none 
matched the git-host heuristic.
+                               if primarySource.RepoURL == "" {
+                                       primarySource = apiApp.Spec.Sources[0]
+                               }
+                       }
+
                        application := &models.ArgocdApplication{
                                Name:           apiApp.Metadata.Name,
                                Namespace:      apiApp.Metadata.Namespace,
                                Project:        apiApp.Spec.Project,
-                               RepoURL:        apiApp.Spec.Source.RepoURL,
-                               Path:           apiApp.Spec.Source.Path,
-                               TargetRevision: 
apiApp.Spec.Source.TargetRevision,
+                               RepoURL:        primarySource.RepoURL,
+                               Path:           primarySource.Path,
+                               TargetRevision: primarySource.TargetRevision,
                                DestServer:     apiApp.Spec.Destination.Server,
                                DestNamespace:  
apiApp.Spec.Destination.Namespace,
                                SyncStatus:     apiApp.Status.Sync.Status,
diff --git a/backend/plugins/argocd/tasks/sync_operation_convertor.go 
b/backend/plugins/argocd/tasks/sync_operation_convertor.go
index 9b34d0339..e577b7af1 100644
--- a/backend/plugins/argocd/tasks/sync_operation_convertor.go
+++ b/backend/plugins/argocd/tasks/sync_operation_convertor.go
@@ -137,8 +137,14 @@ func ConvertSyncOperations(taskCtx plugin.SubTaskContext) 
errors.Error {
                        results = append(results, deployment)
 
                        if syncOp.Revision != "" {
+                               // Priority: repo_url resolved at extraction 
time (always present for
+                               // multi-source apps) → application-level 
repo_url → deployment name
+                               // as a last-resort non-empty placeholder.
                                repoUrl := deployment.Name
-                               if application != nil && application.RepoURL != 
"" {
+                               switch {
+                               case syncOp.RepoURL != "":
+                                       repoUrl = syncOp.RepoURL
+                               case application != nil && application.RepoURL 
!= "":
                                        repoUrl = application.RepoURL
                                }
 
diff --git a/backend/plugins/argocd/tasks/sync_operation_extractor.go 
b/backend/plugins/argocd/tasks/sync_operation_extractor.go
index ed5ce8e95..ae90584dc 100644
--- a/backend/plugins/argocd/tasks/sync_operation_extractor.go
+++ b/backend/plugins/argocd/tasks/sync_operation_extractor.go
@@ -43,15 +43,23 @@ var ExtractSyncOperationsMeta = plugin.SubTaskMeta{
        ProductTables:    []string{models.ArgocdSyncOperation{}.TableName()},
 }
 
+// ArgocdApiSyncSource represents a single source in a multi-source ArgoCD 
application.
+type ArgocdApiSyncSource struct {
+       RepoURL string `json:"repoURL"`
+       Chart   string `json:"chart"`
+}
+
 type ArgocdApiSyncOperation struct {
        // For history entries
        ID              int64      `json:"id"`
        Revision        string     `json:"revision"`
+       Revisions       []string   `json:"revisions"` // multi-source apps 
populate this instead of revision
        DeployedAt      time.Time  `json:"deployedAt"`
        DeployStartedAt *time.Time `json:"deployStartedAt"`
        Source          struct {
                RepoURL string `json:"repoURL"`
        } `json:"source"`
+       Sources     []ArgocdApiSyncSource `json:"sources"` // multi-source apps 
populate this instead of source
        InitiatedBy struct {
                Username  string `json:"username"`
                Automated bool   `json:"automated"`
@@ -66,6 +74,7 @@ type ArgocdApiSyncOperation struct {
        FinishedAt *time.Time `json:"finishedAt"`
        SyncResult struct {
                Revision  string                      `json:"revision"`
+               Revisions []string                    `json:"revisions"` // 
multi-source apps
                Resources []ArgocdApiSyncResourceItem `json:"resources"`
        } `json:"syncResult"`
 }
@@ -179,10 +188,21 @@ func ExtractSyncOperations(taskCtx plugin.SubTaskContext) 
errors.Error {
 
                        isOperationState := apiOp.Phase != ""
 
+                       // For multi-source apps ArgoCD sets revisions[] 
instead of revision. Resolve
+                       // the single commit SHA we care about before deciding 
whether to skip this entry.
+                       if apiOp.Revision == "" {
+                               apiOp.Revision = 
resolveMultiSourceRevision(apiOp.Revisions, apiOp.Sources)
+                       }
                        if !isOperationState && apiOp.DeployedAt.IsZero() && 
apiOp.Revision == "" {
                                return nil, nil
                        }
 
+                       // Resolve the git repo URL at extraction time so the 
convertor can set
+                       // cicd_deployment_commits.repo_url correctly even when
+                       // _tool_argocd_applications.repo_url is empty (e.g. 
for multi-source apps
+                       // whose collectApplications subtask was skipped due to 
state caching).
+                       syncOp.RepoURL = 
resolveGitRepoURL(apiOp.Source.RepoURL, apiOp.Sources)
+
                        if isOperationState {
                                start := normalize(apiOp.StartedAt)
                                if start != nil {
@@ -190,7 +210,14 @@ func ExtractSyncOperations(taskCtx plugin.SubTaskContext) 
errors.Error {
                                } else {
                                        syncOp.DeploymentId = time.Now().Unix()
                                }
-                               syncOp.Revision = apiOp.SyncResult.Revision
+                               // Prefer the top-level resolved revision; fall 
back to syncResult.
+                               syncOp.Revision = apiOp.Revision
+                               if syncOp.Revision == "" {
+                                       syncOp.Revision = 
resolveMultiSourceRevision(apiOp.SyncResult.Revisions, apiOp.Sources)
+                               }
+                               if syncOp.Revision == "" {
+                                       syncOp.Revision = 
apiOp.SyncResult.Revision
+                               }
                                syncOp.StartedAt = start
                                syncOp.FinishedAt = 
normalizePtr(apiOp.FinishedAt)
                                syncOp.Phase = apiOp.Phase
@@ -380,3 +407,130 @@ func stringSlicesEqual(a, b []string) bool {
        }
        return true
 }
+
+// resolveMultiSourceRevision picks the git commit SHA from a multi-source 
ArgoCD
+// application's revisions slice. ArgoCD multi-source apps store one revision 
per
+// source: Helm chart sources carry a semver tag while git sources carry a 
40-hex
+// commit SHA. We prefer the first git-hosted source (github.com / gitlab.com /
+// bitbucket.org) and fall back to any entry that looks like a 40-character 
hex SHA.
+//
+// Single-source apps already populate the top-level "revision" field, so this
+// function is only called when that field is empty.
+func resolveMultiSourceRevision(revisions []string, sources 
[]ArgocdApiSyncSource) string {
+       if len(revisions) == 0 {
+               return ""
+       }
+
+       // Pass 1: prefer a revision whose corresponding source is a git 
hosting service.
+       for i, rev := range revisions {
+               if i >= len(sources) {
+                       break
+               }
+               repoURL := sources[i].RepoURL
+               if isGitHostedURL(repoURL) && isCommitSHA(rev) {
+                       return rev
+               }
+       }
+
+       // Pass 2: accept any revision that looks like a full commit SHA 
regardless of
+       // source type (covers self-hosted Gitea / Forgejo / etc.).
+       for _, rev := range revisions {
+               if isCommitSHA(rev) {
+                       return rev
+               }
+       }
+
+       return ""
+}
+
+// isGitHostedURL returns true when the URL belongs to a known git hosting 
service
+// or is clearly not a Helm chart registry.
+func isGitHostedURL(repoURL string) bool {
+       if repoURL == "" {
+               return false
+       }
+       gitHosts := []string{
+               "github.com",
+               "gitlab.com",
+               "bitbucket.org",
+               "dev.azure.com",
+               "ssh.dev.azure.com",
+               "gitea.",
+               "forgejo.",
+       }
+       lower := strings.ToLower(repoURL)
+       for _, host := range gitHosts {
+               if strings.Contains(lower, host) {
+                       return true
+               }
+       }
+       // Any https/ssh git URL that is not a chart registry (gs://, oci://, 
https://*.azurecr.io, etc.)
+       chartPrefixes := []string{"gs://", "oci://", "s3://"}
+       for _, pfx := range chartPrefixes {
+               if strings.HasPrefix(lower, pfx) {
+                       return false
+               }
+       }
+       // .git suffix is a strong signal
+       return strings.HasSuffix(strings.TrimSpace(repoURL), ".git")
+}
+
+// resolveGitRepoURL returns the best git repository URL from a sync 
operation's
+// source metadata. For single-source apps the source.repoURL is used directly.
+// For multi-source apps (sources[]) the first URL that matches a known git
+// hosting service is preferred; if none match the heuristic, the first 
non-chart
+// HTTPS/SSH URL is used as a fallback so that cicd_deployment_commits.repo_url
+// is never left as the deployment-name placeholder.
+//
+// This is called during extractSyncOperations which always runs, providing
+// reliable repo_url population even when extractApplications is skipped due
+// to the collector state cache.
+func resolveGitRepoURL(singleSourceURL string, sources []ArgocdApiSyncSource) 
string {
+       // Single-source app: use the URL directly.
+       if singleSourceURL != "" {
+               return singleSourceURL
+       }
+
+       // Multi-source app: pass 1 — prefer a known git host.
+       for _, src := range sources {
+               if isGitHostedURL(src.RepoURL) {
+                       return src.RepoURL
+               }
+       }
+
+       // Pass 2 — fall back to the first non-chart URL (covers self-hosted 
instances
+       // not in the known-host list, e.g. on-prem GitLab with a custom 
domain).
+       chartPrefixes := []string{"gs://", "oci://", "s3://"}
+       for _, src := range sources {
+               if src.RepoURL == "" {
+                       continue
+               }
+               lower := strings.ToLower(src.RepoURL)
+               isChart := false
+               for _, pfx := range chartPrefixes {
+                       if strings.HasPrefix(lower, pfx) {
+                               isChart = true
+                               break
+                       }
+               }
+               if !isChart {
+                       return src.RepoURL
+               }
+       }
+
+       return ""
+}
+
+// isCommitSHA returns true for a 40-character lowercase hexadecimal string,
+// which is the standard representation of a Git commit SHA-1.
+func isCommitSHA(s string) bool {
+       if len(s) != 40 {
+               return false
+       }
+       for _, c := range s {
+               if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 
'A' && c <= 'F')) {
+                       return false
+               }
+       }
+       return true
+}
diff --git a/backend/plugins/argocd/tasks/sync_operation_extractor_test.go 
b/backend/plugins/argocd/tasks/sync_operation_extractor_test.go
index 9d71cec41..59f95e5ad 100644
--- a/backend/plugins/argocd/tasks/sync_operation_extractor_test.go
+++ b/backend/plugins/argocd/tasks/sync_operation_extractor_test.go
@@ -76,3 +76,143 @@ func 
TestCollectContainerImages_FallbackRevisionAndSummary(t *testing.T) {
        // normalizeImages: dedupe + sort
        assert.Equal(t, []string{"a", "b"}, normalizeImages([]string{"b", "a", 
"b"}))
 }
+
+// ── resolveMultiSourceRevision 
────────────────────────────────────────────────
+
+func TestResolveMultiSourceRevision_GitHubSourceWins(t *testing.T) {
+       // Multi-source pattern: Helm chart (GCS) + git values repo (GitHub).
+       revisions := []string{"2.6.2", 
"5dd95b4efd7e9b668c361bbddb8d7f1e56c32ac1"}
+       sources := []ArgocdApiSyncSource{
+               {RepoURL: "gs://charts-example-net/infra/stable", Chart: 
"generic-service"},
+               {RepoURL: "https://github.com/example/my-repo"},
+       }
+       got := resolveMultiSourceRevision(revisions, sources)
+       assert.Equal(t, "5dd95b4efd7e9b668c361bbddb8d7f1e56c32ac1", got)
+}
+
+func TestResolveMultiSourceRevision_GitLabSourceWins(t *testing.T) {
+       revisions := []string{"1.0.0", 
"aabbccdd11223344aabbccdd11223344aabbccdd"}
+       sources := []ArgocdApiSyncSource{
+               {RepoURL: "oci://registry.example.com/charts", Chart: "app"},
+               {RepoURL: "https://gitlab.com/example/config"},
+       }
+       got := resolveMultiSourceRevision(revisions, sources)
+       assert.Equal(t, "aabbccdd11223344aabbccdd11223344aabbccdd", got)
+}
+
+func TestResolveMultiSourceRevision_FallbackToAnySHA(t *testing.T) {
+       // Neither source matches a known git hosting service (no 
github/gitlab/gitea/etc.
+       // prefix). The function should still return the 40-hex SHA via the 
fallback
+       // pass that accepts any commit-SHA-shaped revision regardless of 
source type.
+       revisions := []string{"1.2.3", 
"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"}
+       sources := []ArgocdApiSyncSource{
+               {RepoURL: "gs://bucket/charts"},
+               {RepoURL: "https://git.acme-corp.internal/team/config"},
+       }
+       got := resolveMultiSourceRevision(revisions, sources)
+       assert.Equal(t, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", got)
+}
+
+func TestResolveMultiSourceRevision_EmptyRevisions(t *testing.T) {
+       assert.Equal(t, "", resolveMultiSourceRevision(nil, nil))
+       assert.Equal(t, "", resolveMultiSourceRevision([]string{}, 
[]ArgocdApiSyncSource{}))
+}
+
+func TestResolveMultiSourceRevision_AllSemver(t *testing.T) {
+       // All revisions are semver tags; nothing looks like a commit SHA.
+       revisions := []string{"1.0.0", "2.3.4"}
+       sources := []ArgocdApiSyncSource{
+               {RepoURL: "oci://registry.example.com/charts"},
+               {RepoURL: "oci://registry.example.com/other"},
+       }
+       assert.Equal(t, "", resolveMultiSourceRevision(revisions, sources))
+}
+
+func TestResolveMultiSourceRevision_SingleGitSHA(t *testing.T) {
+       // Single-source multi-source edge case.
+       revisions := []string{"abcdef1234567890abcdef1234567890abcdef12"}
+       sources := []ArgocdApiSyncSource{{RepoURL: 
"https://github.com/example/repo"}}
+       got := resolveMultiSourceRevision(revisions, sources)
+       assert.Equal(t, "abcdef1234567890abcdef1234567890abcdef12", got)
+}
+
+// ── isCommitSHA 
───────────────────────────────────────────────────────────────
+
+func TestIsCommitSHA(t *testing.T) {
+       assert.True(t, isCommitSHA("5dd95b4efd7e9b668c361bbddb8d7f1e56c32ac1"))
+       assert.True(t, isCommitSHA("AABBCCDD11223344AABBCCDD11223344AABBCCDD"))
+       assert.False(t, isCommitSHA("2.6.2"))
+       assert.False(t, isCommitSHA(""))
+       assert.False(t, isCommitSHA("5dd95b4efd7e9b668c361bbddb8d7f1e56c32ac")) 
// 39 chars
+       assert.False(t, 
isCommitSHA("5dd95b4efd7e9b668c361bbddb8d7f1e56c32ac12")) // 41 chars
+}
+
+// ── resolveGitRepoURL 
─────────────────────────────────────────────────────────
+
+func TestResolveGitRepoURL_SingleSource(t *testing.T) {
+       // Single-source app: singleSourceURL is used directly, sources ignored.
+       got := resolveGitRepoURL("https://github.com/example/my-app";, nil)
+       assert.Equal(t, "https://github.com/example/my-app";, got)
+}
+
+func TestResolveGitRepoURL_MultiSourceGitHubWins(t *testing.T) {
+       // Multi-source pattern: GCS chart + GitHub values ref.
+       sources := []ArgocdApiSyncSource{
+               {RepoURL: "gs://charts-example-net/infra/stable", Chart: 
"generic-service"},
+               {RepoURL: "https://github.com/example/my-app"},
+       }
+       got := resolveGitRepoURL("", sources)
+       assert.Equal(t, "https://github.com/example/my-app";, got)
+}
+
+func TestResolveGitRepoURL_MultiSourceOCIChart(t *testing.T) {
+       // OCI chart + GitLab values repo.
+       sources := []ArgocdApiSyncSource{
+               {RepoURL: "oci://registry.example.com/charts", Chart: "app"},
+               {RepoURL: "https://gitlab.com/org/config"},
+       }
+       got := resolveGitRepoURL("", sources)
+       assert.Equal(t, "https://gitlab.com/org/config";, got)
+}
+
+func TestResolveGitRepoURL_FallbackNonChartURL(t *testing.T) {
+       // No known git host but a non-chart HTTPS URL is still better than 
nothing.
+       sources := []ArgocdApiSyncSource{
+               {RepoURL: "gs://bucket/charts"},
+               {RepoURL: "https://git.acme-corp.internal/team/config"},
+       }
+       got := resolveGitRepoURL("", sources)
+       assert.Equal(t, "https://git.acme-corp.internal/team/config";, got)
+}
+
+func TestResolveGitRepoURL_AllChartSources(t *testing.T) {
+       // All sources are chart registries — returns empty string.
+       sources := []ArgocdApiSyncSource{
+               {RepoURL: "gs://charts-example-net/infra/stable"},
+               {RepoURL: "oci://registry.example.com/charts"},
+       }
+       got := resolveGitRepoURL("", sources)
+       assert.Equal(t, "", got)
+}
+
+func TestResolveGitRepoURL_EmptySources(t *testing.T) {
+       assert.Equal(t, "", resolveGitRepoURL("", nil))
+       assert.Equal(t, "", resolveGitRepoURL("", []ArgocdApiSyncSource{}))
+}
+
+// ── isGitHostedURL 
────────────────────────────────────────────────────────────
+
+func TestIsGitHostedURL(t *testing.T) {
+       assert.True(t, isGitHostedURL("https://github.com/org/repo";))
+       assert.True(t, isGitHostedURL("[email protected]:org/repo.git"))
+       assert.True(t, isGitHostedURL("https://gitlab.com/org/repo";))
+       assert.True(t, isGitHostedURL("https://bitbucket.org/org/repo";))
+       assert.True(t, 
isGitHostedURL("https://dev.azure.com/org/proj/_git/repo";))
+       assert.True(t, 
isGitHostedURL("https://gitea.internal.corp/team/config";))
+       assert.True(t, isGitHostedURL("https://example.com/repo.git";))
+
+       assert.False(t, isGitHostedURL("gs://charts-example-net/infra/stable"))
+       assert.False(t, isGitHostedURL("oci://registry.example.com/charts"))
+       assert.False(t, isGitHostedURL("s3://my-bucket/charts"))
+       assert.False(t, isGitHostedURL(""))
+}

Reply via email to