datadog-ci 1.17.0 → 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -2
- data/ext/datadog_ci_native/ci.c +10 -0
- data/ext/{datadog_cov → datadog_ci_native}/datadog_cov.c +119 -147
- data/ext/datadog_ci_native/datadog_cov.h +3 -0
- data/ext/datadog_ci_native/datadog_source_code.c +28 -0
- data/ext/datadog_ci_native/datadog_source_code.h +3 -0
- data/ext/{datadog_cov → datadog_ci_native}/extconf.rb +1 -1
- data/lib/datadog/ci/contrib/minitest/test.rb +17 -7
- data/lib/datadog/ci/contrib/rspec/example.rb +14 -7
- data/lib/datadog/ci/ext/telemetry.rb +1 -2
- data/lib/datadog/ci/ext/test.rb +1 -0
- data/lib/datadog/ci/git/base_branch_sha_detection/base.rb +66 -0
- data/lib/datadog/ci/git/base_branch_sha_detection/branch_metric.rb +34 -0
- data/lib/datadog/ci/git/base_branch_sha_detection/guesser.rb +137 -0
- data/lib/datadog/ci/git/base_branch_sha_detection/merge_base_extractor.rb +29 -0
- data/lib/datadog/ci/git/base_branch_sha_detector.rb +63 -0
- data/lib/datadog/ci/git/cli.rb +56 -0
- data/lib/datadog/ci/git/local_repository.rb +73 -294
- data/lib/datadog/ci/git/telemetry.rb +14 -0
- data/lib/datadog/ci/impacted_tests_detection/component.rb +0 -2
- data/lib/datadog/ci/test_optimisation/component.rb +10 -6
- data/lib/datadog/ci/test_optimisation/coverage/ddcov.rb +1 -1
- data/lib/datadog/ci/test_visibility/telemetry.rb +3 -0
- data/lib/datadog/ci/utils/command.rb +116 -0
- data/lib/datadog/ci/utils/source_code.rb +31 -0
- data/lib/datadog/ci/version.rb +1 -1
- metadata +16 -5
- data/lib/datadog/ci/impacted_tests_detection/telemetry.rb +0 -16
@@ -2,6 +2,7 @@
|
|
2
2
|
|
3
3
|
require_relative "../../ext/test"
|
4
4
|
require_relative "../../git/local_repository"
|
5
|
+
require_relative "../../utils/source_code"
|
5
6
|
require_relative "../../utils/test_run"
|
6
7
|
require_relative "../instrumentation"
|
7
8
|
require_relative "ext"
|
@@ -26,17 +27,23 @@ module Datadog
|
|
26
27
|
# don't report test to RSpec::Core::Reporter until retries are done
|
27
28
|
@skip_reporting = true
|
28
29
|
|
30
|
+
# @type var tags : Hash[String, String]
|
31
|
+
tags = {
|
32
|
+
CI::Ext::Test::TAG_FRAMEWORK => Ext::FRAMEWORK,
|
33
|
+
CI::Ext::Test::TAG_FRAMEWORK_VERSION => datadog_integration.version.to_s,
|
34
|
+
CI::Ext::Test::TAG_SOURCE_FILE => Git::LocalRepository.relative_to_root(metadata[:file_path]),
|
35
|
+
CI::Ext::Test::TAG_SOURCE_START => metadata[:line_number].to_s,
|
36
|
+
CI::Ext::Test::TAG_PARAMETERS => datadog_test_parameters
|
37
|
+
}
|
38
|
+
|
39
|
+
end_line = Utils::SourceCode.last_line(@example_block)
|
40
|
+
tags[CI::Ext::Test::TAG_SOURCE_END] = end_line.to_s if end_line
|
41
|
+
|
29
42
|
test_retries_component.with_retries do
|
30
43
|
test_visibility_component.trace_test(
|
31
44
|
datadog_test_name,
|
32
45
|
datadog_test_suite_name,
|
33
|
-
tags:
|
34
|
-
CI::Ext::Test::TAG_FRAMEWORK => Ext::FRAMEWORK,
|
35
|
-
CI::Ext::Test::TAG_FRAMEWORK_VERSION => datadog_integration.version.to_s,
|
36
|
-
CI::Ext::Test::TAG_SOURCE_FILE => Git::LocalRepository.relative_to_root(metadata[:file_path]),
|
37
|
-
CI::Ext::Test::TAG_SOURCE_START => metadata[:line_number].to_s,
|
38
|
-
CI::Ext::Test::TAG_PARAMETERS => datadog_test_parameters
|
39
|
-
},
|
46
|
+
tags: tags,
|
40
47
|
service: datadog_configuration[:service_name]
|
41
48
|
) do |test_span|
|
42
49
|
test_span&.itr_unskippable! if datadog_unskippable?
|
@@ -68,8 +68,6 @@ module Datadog
|
|
68
68
|
|
69
69
|
METRIC_TEST_SESSION = "test_session"
|
70
70
|
|
71
|
-
METRIC_IMPACTED_TESTS_IS_MODIFIED = "impacted_tests.is_modified"
|
72
|
-
|
73
71
|
TAG_TEST_FRAMEWORK = "test_framework"
|
74
72
|
TAG_EVENT_TYPE = "event_type"
|
75
73
|
TAG_HAS_CODEOWNER = "has_codeowner"
|
@@ -92,6 +90,7 @@ module Datadog
|
|
92
90
|
TAG_IS_QUARANTINED = "is_quarantined"
|
93
91
|
TAG_IS_TEST_DISABLED = "is_disabled"
|
94
92
|
TAG_HAS_FAILED_ALL_RETRIES = "has_failed_all_retries"
|
93
|
+
TAG_IS_MODIFIED = "is_modified"
|
95
94
|
# tags for git_requests.settings_response metric
|
96
95
|
TAG_COVERAGE_ENABLED = "coverage_enabled"
|
97
96
|
TAG_ITR_ENABLED = "itr_enabled"
|
data/lib/datadog/ci/ext/test.rb
CHANGED
@@ -0,0 +1,66 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../cli"
|
4
|
+
|
5
|
+
module Datadog
|
6
|
+
module CI
|
7
|
+
module Git
|
8
|
+
module BaseBranchShaDetection
|
9
|
+
class Base
|
10
|
+
attr_reader :remote_name
|
11
|
+
attr_reader :source_branch
|
12
|
+
|
13
|
+
def initialize(remote_name, source_branch)
|
14
|
+
@remote_name = remote_name
|
15
|
+
@source_branch = source_branch
|
16
|
+
end
|
17
|
+
|
18
|
+
def call
|
19
|
+
raise NotImplementedError, "Subclasses must implement #call"
|
20
|
+
end
|
21
|
+
|
22
|
+
protected
|
23
|
+
|
24
|
+
def merge_base_sha(branch, source_branch)
|
25
|
+
CLI.exec_git_command(["merge-base", branch, source_branch], timeout: CLI::LONG_TIMEOUT)&.strip
|
26
|
+
rescue CLI::GitCommandExecutionError => e
|
27
|
+
Datadog.logger.debug { "Merge base calculation failed for branches '#{branch}' and '#{source_branch}': #{e}" }
|
28
|
+
nil
|
29
|
+
end
|
30
|
+
|
31
|
+
def check_and_fetch_branch(branch, remote_name)
|
32
|
+
# @type var short_branch_name: String
|
33
|
+
short_branch_name = remove_remote_prefix(branch, remote_name)
|
34
|
+
|
35
|
+
# Check if branch already fetched from remote
|
36
|
+
CLI.exec_git_command(["show-ref", "--verify", "--quiet", "refs/remotes/#{remote_name}/#{short_branch_name}"])
|
37
|
+
Datadog.logger.debug { "Branch '#{remote_name}/#{short_branch_name}' already fetched from remote, skipping" }
|
38
|
+
rescue CLI::GitCommandExecutionError => e
|
39
|
+
Datadog.logger.debug { "Branch '#{remote_name}/#{short_branch_name}' doesn't exist locally, checking remote..." }
|
40
|
+
|
41
|
+
begin
|
42
|
+
remote_heads = CLI.exec_git_command(["ls-remote", "--heads", remote_name, short_branch_name])
|
43
|
+
if remote_heads.nil? || remote_heads.empty?
|
44
|
+
Datadog.logger.debug { "Branch '#{remote_name}/#{short_branch_name}' doesn't exist in remote" }
|
45
|
+
return
|
46
|
+
end
|
47
|
+
|
48
|
+
Datadog.logger.debug { "Branch '#{remote_name}/#{short_branch_name}' exists in remote, fetching" }
|
49
|
+
CLI.exec_git_command(["fetch", "--depth", "1", remote_name, short_branch_name], timeout: CLI::LONG_TIMEOUT)
|
50
|
+
rescue CLI::GitCommandExecutionError => e
|
51
|
+
Datadog.logger.debug { "Branch '#{remote_name}/#{short_branch_name}' couldn't be fetched from remote: #{e}" }
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def remove_remote_prefix(branch_name, remote_name)
|
56
|
+
branch_name&.sub(/^#{Regexp.escape(remote_name)}\//, "")
|
57
|
+
end
|
58
|
+
|
59
|
+
def branches_equal?(branch_name, default_branch, remote_name)
|
60
|
+
remove_remote_prefix(branch_name, remote_name) == remove_remote_prefix(default_branch, remote_name)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Datadog
|
4
|
+
module CI
|
5
|
+
module Git
|
6
|
+
module BaseBranchShaDetection
|
7
|
+
# Represents metrics for a git branch comparison
|
8
|
+
# including how far behind/ahead it is from a source branch
|
9
|
+
# and the common base commit SHA
|
10
|
+
class BranchMetric
|
11
|
+
attr_reader :branch_name, :behind, :ahead, :base_sha
|
12
|
+
|
13
|
+
def initialize(branch_name:, behind:, ahead:, base_sha:)
|
14
|
+
@branch_name = branch_name
|
15
|
+
@behind = behind
|
16
|
+
@ahead = ahead
|
17
|
+
@base_sha = base_sha
|
18
|
+
end
|
19
|
+
|
20
|
+
# Checks if the branch is up to date with the source branch
|
21
|
+
def up_to_date?
|
22
|
+
@behind == 0 && @ahead == 0
|
23
|
+
end
|
24
|
+
|
25
|
+
# Used for comparison when finding the best branch
|
26
|
+
# Lower divergence score is better
|
27
|
+
def divergence_score
|
28
|
+
@ahead
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,137 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "base"
|
4
|
+
require_relative "branch_metric"
|
5
|
+
|
6
|
+
require_relative "../local_repository"
|
7
|
+
|
8
|
+
module Datadog
|
9
|
+
module CI
|
10
|
+
module Git
|
11
|
+
module BaseBranchShaDetection
|
12
|
+
class Guesser < Base
|
13
|
+
POSSIBLE_BASE_BRANCHES = %w[main master preprod prod dev development trunk].freeze
|
14
|
+
DEFAULT_LIKE_BRANCH_FILTER = /^(#{POSSIBLE_BASE_BRANCHES.join("|")}|release\/.*|hotfix\/.*)$/.freeze
|
15
|
+
|
16
|
+
def call
|
17
|
+
# Check and fetch base branches if they don't exist in local git repository
|
18
|
+
check_and_fetch_base_branches(POSSIBLE_BASE_BRANCHES, remote_name)
|
19
|
+
|
20
|
+
candidates = build_candidate_list(remote_name)
|
21
|
+
|
22
|
+
if candidates.nil? || candidates.empty?
|
23
|
+
Datadog.logger.debug { "No candidate branches found." }
|
24
|
+
return nil
|
25
|
+
end
|
26
|
+
|
27
|
+
metrics = compute_branch_metrics(candidates, source_branch)
|
28
|
+
Datadog.logger.debug { "Branch metrics: '#{metrics}'" }
|
29
|
+
|
30
|
+
best_branch_sha = find_best_branch(metrics, remote_name)
|
31
|
+
Datadog.logger.debug { "Best branch SHA: '#{best_branch_sha}'" }
|
32
|
+
|
33
|
+
best_branch_sha
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def check_and_fetch_base_branches(branches, remote_name)
|
39
|
+
branches.each do |branch|
|
40
|
+
check_and_fetch_branch(branch, remote_name)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def main_like_branch?(branch_name, remote_name)
|
45
|
+
short_branch_name = remove_remote_prefix(branch_name, remote_name)
|
46
|
+
short_branch_name&.match?(DEFAULT_LIKE_BRANCH_FILTER)
|
47
|
+
end
|
48
|
+
|
49
|
+
def detect_default_branch(remote_name)
|
50
|
+
# @type var default_branch: String?
|
51
|
+
default_branch = nil
|
52
|
+
begin
|
53
|
+
default_ref = CLI.exec_git_command(["symbolic-ref", "--quiet", "--short", "refs/remotes/#{remote_name}/HEAD"])
|
54
|
+
default_branch = remove_remote_prefix(default_ref, remote_name) unless default_ref.nil?
|
55
|
+
rescue
|
56
|
+
Datadog.logger.debug { "Could not get symbolic-ref, trying to find a fallback (main, master)..." }
|
57
|
+
end
|
58
|
+
|
59
|
+
default_branch = find_fallback_default_branch(remote_name) if default_branch.nil?
|
60
|
+
default_branch
|
61
|
+
end
|
62
|
+
|
63
|
+
def find_fallback_default_branch(remote_name)
|
64
|
+
["main", "master"].each do |fallback|
|
65
|
+
CLI.exec_git_command(["show-ref", "--verify", "--quiet", "refs/remotes/#{remote_name}/#{fallback}"])
|
66
|
+
Datadog.logger.debug { "Found fallback default branch '#{fallback}'" }
|
67
|
+
return fallback
|
68
|
+
rescue
|
69
|
+
next
|
70
|
+
end
|
71
|
+
nil
|
72
|
+
end
|
73
|
+
|
74
|
+
def build_candidate_list(remote_name)
|
75
|
+
# we cannot assume that local branches are the same as remote branches
|
76
|
+
# so we need to go over remote branches only
|
77
|
+
candidates = CLI.exec_git_command(["for-each-ref", "--format=%(refname:short)", "refs/remotes/#{remote_name}"])&.lines&.map(&:strip)
|
78
|
+
Datadog.logger.debug { "Available branches: '#{candidates}'" }
|
79
|
+
candidates&.select! do |candidate_branch|
|
80
|
+
main_like_branch?(candidate_branch, remote_name)
|
81
|
+
end
|
82
|
+
Datadog.logger.debug { "Candidate branches: '#{candidates}'" }
|
83
|
+
candidates
|
84
|
+
end
|
85
|
+
|
86
|
+
def compute_branch_metrics(candidates, source_branch)
|
87
|
+
metrics = []
|
88
|
+
candidates.each do |cand|
|
89
|
+
base_sha = merge_base_sha(cand, source_branch)
|
90
|
+
next if base_sha.nil? || base_sha.empty?
|
91
|
+
|
92
|
+
rev_list_output = CLI.exec_git_command(["rev-list", "--left-right", "--count", "#{cand}...#{source_branch}"], timeout: CLI::LONG_TIMEOUT)&.strip
|
93
|
+
next if rev_list_output.nil?
|
94
|
+
|
95
|
+
behind, ahead = rev_list_output.split.map(&:to_i)
|
96
|
+
next if behind.nil? || ahead.nil?
|
97
|
+
|
98
|
+
metric = BranchMetric.new(
|
99
|
+
branch_name: cand,
|
100
|
+
behind: behind,
|
101
|
+
ahead: ahead,
|
102
|
+
base_sha: base_sha
|
103
|
+
)
|
104
|
+
|
105
|
+
if metric.up_to_date?
|
106
|
+
Datadog.logger.debug { "Branch '#{cand}' is up to date with '#{source_branch}'" }
|
107
|
+
next
|
108
|
+
end
|
109
|
+
|
110
|
+
metrics << metric
|
111
|
+
end
|
112
|
+
metrics
|
113
|
+
end
|
114
|
+
|
115
|
+
def find_best_branch(metrics, remote_name)
|
116
|
+
return nil if metrics.empty?
|
117
|
+
|
118
|
+
# If there's only one metric, return its base SHA
|
119
|
+
return metrics.first.base_sha if metrics.size == 1
|
120
|
+
|
121
|
+
default_branch = detect_default_branch(remote_name)
|
122
|
+
Datadog.logger.debug { "Default branch: '#{default_branch}'" }
|
123
|
+
|
124
|
+
best_metric = metrics.min_by do |metric|
|
125
|
+
[
|
126
|
+
metric.divergence_score,
|
127
|
+
branches_equal?(metric.branch_name, default_branch, remote_name) ? 0 : 1 # prefer default branch on tie
|
128
|
+
]
|
129
|
+
end
|
130
|
+
|
131
|
+
best_metric&.base_sha
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "base"
|
4
|
+
require_relative "../local_repository"
|
5
|
+
|
6
|
+
module Datadog
|
7
|
+
module CI
|
8
|
+
module Git
|
9
|
+
module BaseBranchShaDetection
|
10
|
+
class MergeBaseExtractor < Base
|
11
|
+
attr_reader :base_branch
|
12
|
+
|
13
|
+
def initialize(remote_name, source_branch, base_branch)
|
14
|
+
super(remote_name, source_branch)
|
15
|
+
|
16
|
+
@base_branch = base_branch
|
17
|
+
end
|
18
|
+
|
19
|
+
def call
|
20
|
+
check_and_fetch_branch(base_branch, remote_name)
|
21
|
+
|
22
|
+
full_base_branch_name = "#{remote_name}/#{remove_remote_prefix(base_branch, remote_name)}"
|
23
|
+
merge_base_sha(full_base_branch_name, source_branch)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "base_branch_sha_detection/guesser"
|
4
|
+
require_relative "base_branch_sha_detection/merge_base_extractor"
|
5
|
+
|
6
|
+
module Datadog
|
7
|
+
module CI
|
8
|
+
module Git
|
9
|
+
module BaseBranchShaDetector
|
10
|
+
def self.base_branch_sha(base_branch)
|
11
|
+
Datadog.logger.debug { "Base branch: '#{base_branch}'" }
|
12
|
+
|
13
|
+
remote_name = get_remote_name
|
14
|
+
Datadog.logger.debug { "Remote name: '#{remote_name}'" }
|
15
|
+
|
16
|
+
source_branch = get_source_branch
|
17
|
+
return nil if source_branch.nil?
|
18
|
+
|
19
|
+
Datadog.logger.debug { "Source branch: '#{source_branch}'" }
|
20
|
+
|
21
|
+
strategy = if base_branch.nil?
|
22
|
+
BaseBranchShaDetection::Guesser.new(remote_name, source_branch)
|
23
|
+
else
|
24
|
+
BaseBranchShaDetection::MergeBaseExtractor.new(remote_name, source_branch, base_branch)
|
25
|
+
end
|
26
|
+
|
27
|
+
strategy.call
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.get_remote_name
|
31
|
+
# Try to find remote from upstream tracking
|
32
|
+
upstream = LocalRepository.get_upstream_branch
|
33
|
+
|
34
|
+
if upstream
|
35
|
+
upstream.split("/").first
|
36
|
+
else
|
37
|
+
# Fallback to first remote if no upstream is set
|
38
|
+
first_remote_value = CLI.exec_git_command(["remote"])&.split("\n")&.first
|
39
|
+
Datadog.logger.debug { "First remote value: '#{first_remote_value}'" }
|
40
|
+
first_remote_value || "origin"
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def self.get_source_branch
|
45
|
+
source_branch = CLI.exec_git_command(["rev-parse", "--abbrev-ref", "HEAD"])
|
46
|
+
if source_branch.nil?
|
47
|
+
Datadog.logger.debug { "Could not get current branch" }
|
48
|
+
return nil
|
49
|
+
end
|
50
|
+
|
51
|
+
# Verify the branch exists
|
52
|
+
begin
|
53
|
+
CLI.exec_git_command(["rev-parse", "--verify", "--quiet", source_branch])
|
54
|
+
rescue CLI::GitCommandExecutionError
|
55
|
+
# Branch verification failed
|
56
|
+
return nil
|
57
|
+
end
|
58
|
+
source_branch
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../utils/command"
|
4
|
+
|
5
|
+
module Datadog
|
6
|
+
module CI
|
7
|
+
module Git
|
8
|
+
module CLI
|
9
|
+
class GitCommandExecutionError < StandardError
|
10
|
+
attr_reader :output, :command, :status
|
11
|
+
def initialize(message, output:, command:, status:)
|
12
|
+
super(message)
|
13
|
+
|
14
|
+
@output = output
|
15
|
+
@command = command
|
16
|
+
@status = status
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
# Timeout constants for git commands (in seconds)
|
21
|
+
# These values were set based on internal telemetry
|
22
|
+
UNSHALLOW_TIMEOUT = 500
|
23
|
+
LONG_TIMEOUT = 30
|
24
|
+
SHORT_TIMEOUT = 3
|
25
|
+
|
26
|
+
# Execute a git command with optional stdin input and timeout
|
27
|
+
#
|
28
|
+
# @param cmd [Array<String>] The git command as an array of strings
|
29
|
+
# @param stdin [String, nil] Optional stdin data to pass to the command
|
30
|
+
# @param timeout [Integer] Timeout in seconds for the command execution
|
31
|
+
# @return [String, nil] The command output, or nil if the output is empty
|
32
|
+
# @raise [GitCommandExecutionError] If the command fails or times out
|
33
|
+
def self.exec_git_command(cmd, stdin: nil, timeout: SHORT_TIMEOUT)
|
34
|
+
# @type var out: String
|
35
|
+
# @type var status: Process::Status?
|
36
|
+
out, status = Utils::Command.exec_command(["git"] + cmd, stdin_data: stdin, timeout: timeout)
|
37
|
+
|
38
|
+
if status.nil? || !status.success?
|
39
|
+
# Convert command to string representation for error message
|
40
|
+
cmd_str = cmd.join(" ")
|
41
|
+
raise GitCommandExecutionError.new(
|
42
|
+
"Failed to run git command [#{cmd_str}] with input [#{stdin}] and output [#{out}]. Status: #{status}",
|
43
|
+
output: out,
|
44
|
+
command: cmd_str,
|
45
|
+
status: status
|
46
|
+
)
|
47
|
+
end
|
48
|
+
|
49
|
+
return nil if out.empty?
|
50
|
+
|
51
|
+
out
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|