@@ -43,15 +43,23 @@ var ExtractSyncOperationsMeta = plugin.SubTaskMeta{
4343 ProductTables : []string {models.ArgocdSyncOperation {}.TableName ()},
4444}
4545
46+ // ArgocdApiSyncSource represents a single source in a multi-source ArgoCD application.
47+ type ArgocdApiSyncSource struct {
48+ RepoURL string `json:"repoURL"`
49+ Chart string `json:"chart"`
50+ }
51+
4652type ArgocdApiSyncOperation struct {
4753 // For history entries
4854 ID int64 `json:"id"`
4955 Revision string `json:"revision"`
56+ Revisions []string `json:"revisions"` // multi-source apps populate this instead of revision
5057 DeployedAt time.Time `json:"deployedAt"`
5158 DeployStartedAt * time.Time `json:"deployStartedAt"`
5259 Source struct {
5360 RepoURL string `json:"repoURL"`
5461 } `json:"source"`
62+ Sources []ArgocdApiSyncSource `json:"sources"` // multi-source apps populate this instead of source
5563 InitiatedBy struct {
5664 Username string `json:"username"`
5765 Automated bool `json:"automated"`
@@ -66,6 +74,7 @@ type ArgocdApiSyncOperation struct {
6674 FinishedAt * time.Time `json:"finishedAt"`
6775 SyncResult struct {
6876 Revision string `json:"revision"`
77+ Revisions []string `json:"revisions"` // multi-source apps
6978 Resources []ArgocdApiSyncResourceItem `json:"resources"`
7079 } `json:"syncResult"`
7180}
@@ -179,18 +188,36 @@ func ExtractSyncOperations(taskCtx plugin.SubTaskContext) errors.Error {
179188
180189 isOperationState := apiOp .Phase != ""
181190
191+ // For multi-source apps ArgoCD sets revisions[] instead of revision. Resolve
192+ // the single commit SHA we care about before deciding whether to skip this entry.
193+ if apiOp .Revision == "" {
194+ apiOp .Revision = resolveMultiSourceRevision (apiOp .Revisions , apiOp .Sources )
195+ }
182196 if ! isOperationState && apiOp .DeployedAt .IsZero () && apiOp .Revision == "" {
183197 return nil , nil
184198 }
185199
200+ // Resolve the git repo URL at extraction time so the convertor can set
201+ // cicd_deployment_commits.repo_url correctly even when
202+ // _tool_argocd_applications.repo_url is empty (e.g. for multi-source apps
203+ // whose collectApplications subtask was skipped due to state caching).
204+ syncOp .RepoURL = resolveGitRepoURL (apiOp .Source .RepoURL , apiOp .Sources )
205+
186206 if isOperationState {
187207 start := normalize (apiOp .StartedAt )
188208 if start != nil {
189209 syncOp .DeploymentId = start .Unix ()
190210 } else {
191211 syncOp .DeploymentId = time .Now ().Unix ()
192212 }
193- syncOp .Revision = apiOp .SyncResult .Revision
213+ // Prefer the top-level resolved revision; fall back to syncResult.
214+ syncOp .Revision = apiOp .Revision
215+ if syncOp .Revision == "" {
216+ syncOp .Revision = resolveMultiSourceRevision (apiOp .SyncResult .Revisions , apiOp .Sources )
217+ }
218+ if syncOp .Revision == "" {
219+ syncOp .Revision = apiOp .SyncResult .Revision
220+ }
194221 syncOp .StartedAt = start
195222 syncOp .FinishedAt = normalizePtr (apiOp .FinishedAt )
196223 syncOp .Phase = apiOp .Phase
@@ -380,3 +407,130 @@ func stringSlicesEqual(a, b []string) bool {
380407 }
381408 return true
382409}
410+
411+ // resolveMultiSourceRevision picks the git commit SHA from a multi-source ArgoCD
412+ // application's revisions slice. ArgoCD multi-source apps store one revision per
413+ // source: Helm chart sources carry a semver tag while git sources carry a 40-hex
414+ // commit SHA. We prefer the first git-hosted source (github.com / gitlab.com /
415+ // bitbucket.org) and fall back to any entry that looks like a 40-character hex SHA.
416+ //
417+ // Single-source apps already populate the top-level "revision" field, so this
418+ // function is only called when that field is empty.
419+ func resolveMultiSourceRevision (revisions []string , sources []ArgocdApiSyncSource ) string {
420+ if len (revisions ) == 0 {
421+ return ""
422+ }
423+
424+ // Pass 1: prefer a revision whose corresponding source is a git hosting service.
425+ for i , rev := range revisions {
426+ if i >= len (sources ) {
427+ break
428+ }
429+ repoURL := sources [i ].RepoURL
430+ if isGitHostedURL (repoURL ) && isCommitSHA (rev ) {
431+ return rev
432+ }
433+ }
434+
435+ // Pass 2: accept any revision that looks like a full commit SHA regardless of
436+ // source type (covers self-hosted Gitea / Forgejo / etc.).
437+ for _ , rev := range revisions {
438+ if isCommitSHA (rev ) {
439+ return rev
440+ }
441+ }
442+
443+ return ""
444+ }
445+
446+ // isGitHostedURL returns true when the URL belongs to a known git hosting service
447+ // or is clearly not a Helm chart registry.
448+ func isGitHostedURL (repoURL string ) bool {
449+ if repoURL == "" {
450+ return false
451+ }
452+ gitHosts := []string {
453+ "github.com" ,
454+ "gitlab.com" ,
455+ "bitbucket.org" ,
456+ "dev.azure.com" ,
457+ "ssh.dev.azure.com" ,
458+ "gitea." ,
459+ "forgejo." ,
460+ }
461+ lower := strings .ToLower (repoURL )
462+ for _ , host := range gitHosts {
463+ if strings .Contains (lower , host ) {
464+ return true
465+ }
466+ }
467+ // Any https/ssh git URL that is not a chart registry (gs://, oci://, https://*.azurecr.io, etc.)
468+ chartPrefixes := []string {"gs://" , "oci://" , "s3://" }
469+ for _ , pfx := range chartPrefixes {
470+ if strings .HasPrefix (lower , pfx ) {
471+ return false
472+ }
473+ }
474+ // .git suffix is a strong signal
475+ return strings .HasSuffix (strings .TrimSpace (repoURL ), ".git" )
476+ }
477+
478+ // resolveGitRepoURL returns the best git repository URL from a sync operation's
479+ // source metadata. For single-source apps the source.repoURL is used directly.
480+ // For multi-source apps (sources[]) the first URL that matches a known git
481+ // hosting service is preferred; if none match the heuristic, the first non-chart
482+ // HTTPS/SSH URL is used as a fallback so that cicd_deployment_commits.repo_url
483+ // is never left as the deployment-name placeholder.
484+ //
485+ // This is called during extractSyncOperations which always runs, providing
486+ // reliable repo_url population even when extractApplications is skipped due
487+ // to the collector state cache.
488+ func resolveGitRepoURL (singleSourceURL string , sources []ArgocdApiSyncSource ) string {
489+ // Single-source app: use the URL directly.
490+ if singleSourceURL != "" {
491+ return singleSourceURL
492+ }
493+
494+ // Multi-source app: pass 1 — prefer a known git host.
495+ for _ , src := range sources {
496+ if isGitHostedURL (src .RepoURL ) {
497+ return src .RepoURL
498+ }
499+ }
500+
501+ // Pass 2 — fall back to the first non-chart URL (covers self-hosted instances
502+ // not in the known-host list, e.g. on-prem GitLab with a custom domain).
503+ chartPrefixes := []string {"gs://" , "oci://" , "s3://" }
504+ for _ , src := range sources {
505+ if src .RepoURL == "" {
506+ continue
507+ }
508+ lower := strings .ToLower (src .RepoURL )
509+ isChart := false
510+ for _ , pfx := range chartPrefixes {
511+ if strings .HasPrefix (lower , pfx ) {
512+ isChart = true
513+ break
514+ }
515+ }
516+ if ! isChart {
517+ return src .RepoURL
518+ }
519+ }
520+
521+ return ""
522+ }
523+
524+ // isCommitSHA returns true for a 40-character lowercase hexadecimal string,
525+ // which is the standard representation of a Git commit SHA-1.
526+ func isCommitSHA (s string ) bool {
527+ if len (s ) != 40 {
528+ return false
529+ }
530+ for _ , c := range s {
531+ if ! ((c >= '0' && c <= '9' ) || (c >= 'a' && c <= 'f' ) || (c >= 'A' && c <= 'F' )) {
532+ return false
533+ }
534+ }
535+ return true
536+ }
0 commit comments