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.
Files changed (29) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -2
  3. data/ext/datadog_ci_native/ci.c +10 -0
  4. data/ext/{datadog_cov → datadog_ci_native}/datadog_cov.c +119 -147
  5. data/ext/datadog_ci_native/datadog_cov.h +3 -0
  6. data/ext/datadog_ci_native/datadog_source_code.c +28 -0
  7. data/ext/datadog_ci_native/datadog_source_code.h +3 -0
  8. data/ext/{datadog_cov → datadog_ci_native}/extconf.rb +1 -1
  9. data/lib/datadog/ci/contrib/minitest/test.rb +17 -7
  10. data/lib/datadog/ci/contrib/rspec/example.rb +14 -7
  11. data/lib/datadog/ci/ext/telemetry.rb +1 -2
  12. data/lib/datadog/ci/ext/test.rb +1 -0
  13. data/lib/datadog/ci/git/base_branch_sha_detection/base.rb +66 -0
  14. data/lib/datadog/ci/git/base_branch_sha_detection/branch_metric.rb +34 -0
  15. data/lib/datadog/ci/git/base_branch_sha_detection/guesser.rb +137 -0
  16. data/lib/datadog/ci/git/base_branch_sha_detection/merge_base_extractor.rb +29 -0
  17. data/lib/datadog/ci/git/base_branch_sha_detector.rb +63 -0
  18. data/lib/datadog/ci/git/cli.rb +56 -0
  19. data/lib/datadog/ci/git/local_repository.rb +73 -294
  20. data/lib/datadog/ci/git/telemetry.rb +14 -0
  21. data/lib/datadog/ci/impacted_tests_detection/component.rb +0 -2
  22. data/lib/datadog/ci/test_optimisation/component.rb +10 -6
  23. data/lib/datadog/ci/test_optimisation/coverage/ddcov.rb +1 -1
  24. data/lib/datadog/ci/test_visibility/telemetry.rb +3 -0
  25. data/lib/datadog/ci/utils/command.rb +116 -0
  26. data/lib/datadog/ci/utils/source_code.rb +31 -0
  27. data/lib/datadog/ci/version.rb +1 -1
  28. metadata +16 -5
  29. 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"
@@ -20,6 +20,7 @@ module Datadog
20
20
  TAG_COMMAND = "test.command"
21
21
  TAG_SOURCE_FILE = "test.source.file"
22
22
  TAG_SOURCE_START = "test.source.start"
23
+ TAG_SOURCE_END = "test.source.end"
23
24
  TAG_CODEOWNERS = "test.codeowners"
24
25
  TAG_PARAMETERS = "test.parameters"
25
26
 
@@ -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