Skip to content

Commit c8b8116

Browse files
authored
Merge branch 'main' into fix/github-graphql-stale-issue-cleanup
2 parents 44cd4ee + 94f7bca commit c8b8116

23 files changed

Lines changed: 490 additions & 19 deletions
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
Licensed to the Apache Software Foundation (ASF) under one or more
3+
contributor license agreements. See the NOTICE file distributed with
4+
this work for additional information regarding copyright ownership.
5+
The ASF licenses this file to You under the Apache License, Version 2.0
6+
(the "License"); you may not use this file except in compliance with
7+
the License. You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
package migrationscripts
19+
20+
import (
21+
"github.com/apache/incubator-devlake/core/context"
22+
"github.com/apache/incubator-devlake/core/errors"
23+
"github.com/apache/incubator-devlake/core/plugin"
24+
)
25+
26+
var _ plugin.MigrationScript = (*addRepoURLToSyncOperations)(nil)
27+
28+
type addRepoURLToSyncOperations struct{}
29+
30+
// addRepoURLSyncOpArchived is a snapshot of ArgocdSyncOperation used solely
31+
// for this migration so the live model can evolve independently.
32+
type addRepoURLSyncOpArchived struct {
33+
ConnectionId uint64 `gorm:"primaryKey"`
34+
ApplicationName string `gorm:"primaryKey;type:varchar(255)"`
35+
DeploymentId int64 `gorm:"primaryKey"`
36+
RepoURL string `gorm:"type:varchar(500)"`
37+
}
38+
39+
func (addRepoURLSyncOpArchived) TableName() string {
40+
return "_tool_argocd_sync_operations"
41+
}
42+
43+
func (m *addRepoURLToSyncOperations) Up(basicRes context.BasicRes) errors.Error {
44+
db := basicRes.GetDal()
45+
return db.AutoMigrate(&addRepoURLSyncOpArchived{})
46+
}
47+
48+
func (*addRepoURLToSyncOperations) Version() uint64 {
49+
return 20260331000000
50+
}
51+
52+
func (*addRepoURLToSyncOperations) Name() string {
53+
return "argocd add repo_url to sync operations"
54+
}

backend/plugins/argocd/models/migrationscripts/register.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,6 @@ func All() []plugin.MigrationScript {
2525
return []plugin.MigrationScript{
2626
new(addInitTables),
2727
new(addImageSupportArtifacts),
28+
new(addRepoURLToSyncOperations),
2829
}
2930
}

backend/plugins/argocd/models/sync_operation.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ type ArgocdSyncOperation struct {
2828
ApplicationName string `gorm:"primaryKey;type:varchar(255)"`
2929
DeploymentId int64 `gorm:"primaryKey"` // History ID from ArgoCD
3030
Revision string `gorm:"type:varchar(255)"` // Git SHA
31+
RepoURL string `gorm:"type:varchar(500)"` // Git repo URL resolved from source/sources at extraction time
3132
Kind string `gorm:"type:varchar(100)"` // Kubernetes resource kind: Deployment, ReplicaSet, Rollout, StatefulSet, DaemonSet, etc.
3233
StartedAt *time.Time
3334
FinishedAt *time.Time

backend/plugins/argocd/tasks/application_extractor.go

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@ var ExtractApplicationsMeta = plugin.SubTaskMeta{
3838
ProductTables: []string{models.ArgocdApplication{}.TableName()},
3939
}
4040

41+
type ArgocdApiApplicationSource struct {
42+
RepoURL string `json:"repoURL"`
43+
Path string `json:"path"`
44+
TargetRevision string `json:"targetRevision"`
45+
Chart string `json:"chart"`
46+
}
47+
4148
type ArgocdApiApplication struct {
4249
Metadata struct {
4350
Name string `json:"name"`
@@ -46,11 +53,9 @@ type ArgocdApiApplication struct {
4653
} `json:"metadata"`
4754
Spec struct {
4855
Project string `json:"project"`
49-
Source struct {
50-
RepoURL string `json:"repoURL"`
51-
Path string `json:"path"`
52-
TargetRevision string `json:"targetRevision"`
53-
} `json:"source"`
56+
// Single-source apps use Source; multi-source apps use Sources.
57+
Source ArgocdApiApplicationSource `json:"source"`
58+
Sources []ArgocdApiApplicationSource `json:"sources"`
5459
Destination struct {
5560
Server string `json:"server"`
5661
Namespace string `json:"namespace"`
@@ -88,13 +93,31 @@ func ExtractApplications(taskCtx plugin.SubTaskContext) errors.Error {
8893
return nil, errors.Default.Wrap(err, "error unmarshaling application")
8994
}
9095

96+
// Resolve the primary source. Multi-source apps populate spec.sources[]
97+
// instead of spec.source; we prefer the first git-hosted source so that
98+
// cicd_deployment_commits.repo_url is a browsable repository URL rather
99+
// than a Helm chart registry address.
100+
primarySource := apiApp.Spec.Source
101+
if primarySource.RepoURL == "" && len(apiApp.Spec.Sources) > 0 {
102+
for _, src := range apiApp.Spec.Sources {
103+
if isGitHostedURL(src.RepoURL) {
104+
primarySource = src
105+
break
106+
}
107+
}
108+
// Fallback: use the first source if none matched the git-host heuristic.
109+
if primarySource.RepoURL == "" {
110+
primarySource = apiApp.Spec.Sources[0]
111+
}
112+
}
113+
91114
application := &models.ArgocdApplication{
92115
Name: apiApp.Metadata.Name,
93116
Namespace: apiApp.Metadata.Namespace,
94117
Project: apiApp.Spec.Project,
95-
RepoURL: apiApp.Spec.Source.RepoURL,
96-
Path: apiApp.Spec.Source.Path,
97-
TargetRevision: apiApp.Spec.Source.TargetRevision,
118+
RepoURL: primarySource.RepoURL,
119+
Path: primarySource.Path,
120+
TargetRevision: primarySource.TargetRevision,
98121
DestServer: apiApp.Spec.Destination.Server,
99122
DestNamespace: apiApp.Spec.Destination.Namespace,
100123
SyncStatus: apiApp.Status.Sync.Status,

backend/plugins/argocd/tasks/sync_operation_convertor.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,14 @@ func ConvertSyncOperations(taskCtx plugin.SubTaskContext) errors.Error {
137137
results = append(results, deployment)
138138

139139
if syncOp.Revision != "" {
140+
// Priority: repo_url resolved at extraction time (always present for
141+
// multi-source apps) → application-level repo_url → deployment name
142+
// as a last-resort non-empty placeholder.
140143
repoUrl := deployment.Name
141-
if application != nil && application.RepoURL != "" {
144+
switch {
145+
case syncOp.RepoURL != "":
146+
repoUrl = syncOp.RepoURL
147+
case application != nil && application.RepoURL != "":
142148
repoUrl = application.RepoURL
143149
}
144150

backend/plugins/argocd/tasks/sync_operation_extractor.go

Lines changed: 155 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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+
4652
type 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

Comments
 (0)