Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c14a320
[CI Visibility] Fix CODEOWNERS parser correctness and normalize CI so…
tonyredondo Aug 21, 2026
29e64a5
Merge branch 'master' into tony/ci-visibility-codeowners-parser-fixes
tonyredondo Aug 22, 2026
7604e57
[CI Visibility] Address code review: single-regex descendant matching…
tonyredondo Aug 23, 2026
f9a4379
[CI Visibility] Clarify evaluation order comment in TryMatchGitLab
tonyredondo Aug 23, 2026
31bd122
[CI Visibility] Harden CODEOWNERS path anchoring, lazy descendant reg…
tonyredondo Aug 23, 2026
ac2a16d
[CI Visibility] Fix misplaced comment above Seal loop
tonyredondo Aug 23, 2026
e136693
[CI Visibility] Make CODEOWNERS Match null-safe and allocation-light …
tonyredondo Aug 23, 2026
410c18a
[CI Visibility] Simplify owner token validation condition
tonyredondo Aug 23, 2026
dd18633
[CI Visibility] Fix stale comment in CodeOwners integration test
tonyredondo Aug 23, 2026
8b25aa7
[CI Visibility] Align CODEOWNERS glob semantics and honor useOSSepara…
tonyredondo Aug 23, 2026
ea3eeb8
[CI Visibility] Harden CODEOWNERS parsing and fallback discovery
tonyredondo Aug 23, 2026
49102e9
[CI Visibility] Handle CODEOWNERS loading and owner edge cases
tonyredondo Aug 23, 2026
e42ef3e
[CI Visibility] Bound CODEOWNERS fallback cache and retries
tonyredondo Aug 23, 2026
d2040c0
[CI Visibility] Make CODEOWNERS fallback and parsing resilient
tonyredondo Aug 23, 2026
cc2938e
[CI Visibility] Fix CODEOWNERS follow-up issues
tonyredondo Aug 24, 2026
7e563e5
[CI Visibility] Address CODEOWNERS review feedback
tonyredondo Aug 24, 2026
ff5185b
[CI Visibility] Address CODEOWNERS review findings
tonyredondo Aug 24, 2026
7ecd006
[CI Visibility] Use CI provider for CODEOWNERS platform
tonyredondo Aug 24, 2026
d7bfb49
[CI Visibility] Simplify CODEOWNERS path handling
tonyredondo Aug 24, 2026
d207137
[CI Visibility] Simplify CODEOWNERS matching internals
tonyredondo Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -734,10 +734,12 @@
<type fullname="System.GC" />
<type fullname="System.GCGenerationInfo" />
<type fullname="System.GCMemoryInfo" />
<type fullname="System.Globalization.CharUnicodeInfo" />
<type fullname="System.Globalization.CultureInfo" />
<type fullname="System.Globalization.DateTimeStyles" />
<type fullname="System.Globalization.NumberFormatInfo" />
<type fullname="System.Globalization.NumberStyles" />
<type fullname="System.Globalization.UnicodeCategory" />
<type fullname="System.Guid" />
<type fullname="System.HashCode" />
<type fullname="System.IAsyncDisposable" />
Expand Down
242 changes: 215 additions & 27 deletions tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ internal abstract class CIEnvironmentValues
? StringComparer.OrdinalIgnoreCase
: StringComparer.Ordinal;

private static readonly char[] ForwardSlashCharacters = { '/' };

private readonly object _codeOwnersLock = new();
private readonly HashSet<string> _codeOwnersSearchStarts = new(CodeOwnersSearchComparer);

Expand Down Expand Up @@ -357,9 +359,9 @@ private static bool TryResolvePathWithinBase(string relativePath, string basePat
return false;
}

private static bool TryGetCodeOwnersPath(string sourceRoot, bool logLookup, [NotNullWhen(true)] out string? codeOwnersPath)
private static bool TryGetCodeOwnersPath(string sourceRoot, CodeOwners.Platform platform, bool logLookup, [NotNullWhen(true)] out string? codeOwnersPath)
{
foreach (var path in GetCodeOwnersPaths(sourceRoot))
foreach (var path in GetCodeOwnersPaths(sourceRoot, platform))
{
if (logLookup)
{
Expand All @@ -377,12 +379,66 @@ private static bool TryGetCodeOwnersPath(string sourceRoot, bool logLookup, [Not
return false;
}

private static IEnumerable<string> GetCodeOwnersPaths(string sourceRoot)
private static bool TryGetCodeOwnersPlatformFromRepository(string? repository, out CodeOwners.Platform platform)
{
yield return Path.Combine(sourceRoot, "CODEOWNERS");
yield return Path.Combine(sourceRoot, ".github", "CODEOWNERS");
yield return Path.Combine(sourceRoot, ".gitlab", "CODEOWNERS");
yield return Path.Combine(sourceRoot, ".docs", "CODEOWNERS");
platform = default;
if (StringUtil.IsNullOrWhiteSpace(repository))
{
return false;
}

string? host = null;
if (Uri.TryCreate(repository, UriKind.Absolute, out var repositoryUri) && !StringUtil.IsNullOrEmpty(repositoryUri.Host))
{
host = repositoryUri.Host;
}
else
{
// Handle SCP-style SSH URLs such as git@gitlab.com:group/project.git.
var hostStart = repository!.IndexOf('@') + 1;
var hostEnd = repository.IndexOf(':', hostStart);
if (hostStart > 0 && hostEnd > hostStart)
{
host = repository.Substring(hostStart, hostEnd - hostStart);
}
}

if (IsGitLabHost(host))
{
platform = CodeOwners.Platform.GitLab;
return true;
}

if (string.Equals(host, "github.com", StringComparison.OrdinalIgnoreCase))
{
platform = CodeOwners.Platform.GitHub;
return true;
}

return false;
}

private static bool IsGitLabHost(string? host)
=> string.Equals(host, "gitlab.com", StringComparison.OrdinalIgnoreCase) ||
(host?.StartsWith("gitlab.", StringComparison.OrdinalIgnoreCase) ?? false) ||
(host?.IndexOf(".gitlab.", StringComparison.OrdinalIgnoreCase) >= 0);

private static IEnumerable<string> GetCodeOwnersPaths(string sourceRoot, CodeOwners.Platform platform)
{
if (platform == CodeOwners.Platform.GitHub)
{
// GitHub searches .github first, then the repository root, then docs.
yield return Path.Combine(sourceRoot, ".github", "CODEOWNERS");
yield return Path.Combine(sourceRoot, "CODEOWNERS");
yield return Path.Combine(sourceRoot, "docs", "CODEOWNERS");
Comment thread
tonyredondo marked this conversation as resolved.
}
else
{
// GitLab searches the repository root first, then docs, then .gitlab.
yield return Path.Combine(sourceRoot, "CODEOWNERS");
yield return Path.Combine(sourceRoot, "docs", "CODEOWNERS");
yield return Path.Combine(sourceRoot, ".gitlab", "CODEOWNERS");
}
}

public void DecorateSpan(Span span)
Expand Down Expand Up @@ -506,11 +562,15 @@ protected void ReloadEnvironmentData()
// **********
if (!string.IsNullOrEmpty(SourceRoot))
{
if (TryGetCodeOwnersPath(SourceRoot!, logLookup: true, out var codeOwnersPath))
var platform = GetCodeOwnersPlatform(SourceRoot);
if (TryGetCodeOwnersPath(SourceRoot!, platform, logLookup: true, out var codeOwnersPath))
{
Log.Information("CODEOWNERS file found: {Path}", codeOwnersPath);
CodeOwners = new CodeOwners(codeOwnersPath, GetCodeOwnersPlatform());
CodeOwnersRoot = SourceRoot;
if (CodeOwners.TryLoad(codeOwnersPath, platform, out var parser))
{
CodeOwners = parser;
CodeOwnersRoot = SourceRoot;
}
}
}
}
Expand Down Expand Up @@ -554,13 +614,9 @@ public string MakeRelativePathFromSourceRoot(string absolutePath, bool useOSSepa
internal string MakeRelativePathFromSourceRootWithFallback(string sourceFilePath, bool useOSSeparator = true)
{
var sourceRelativePath = MakeRelativePathFromSourceRoot(sourceFilePath, useOSSeparator);
// If CODEOWNERS is rooted elsewhere, normalize SourceFile to that root for consistent matching.
if (TryGetCodeOwnersRelativePath(sourceFilePath, useOSSeparator, out var codeOwnersRelativePath))
{
return codeOwnersRelativePath;
}

return sourceRelativePath;
return TryGetCodeOwnersRelativePath(sourceFilePath, useOSSeparator, out var codeOwnersRelativePath)
? codeOwnersRelativePath
: sourceRelativePath;
}

internal bool TryGetCodeOwnersRelativePath(string sourceFilePath, bool useOSSeparator, [NotNullWhen(true)] out string? codeOwnersRelativePath)
Expand Down Expand Up @@ -593,14 +649,19 @@ internal bool TryGetCodeOwnersRelativePath(string sourceFilePath, bool useOSSepa

// Require a CODEOWNERS file at the resolved root to avoid mismatched roots.
// Avoid mixing CODEOWNERS content from one root with a different resolved root.
if (!TryGetCodeOwnersPath(resolvedRoot, logLookup: false, out _))
if (!TryGetCodeOwnersPath(resolvedRoot, GetCodeOwnersPlatform(resolvedRoot), logLookup: false, out _))
{
return false;
}

codeOwnersRoot = resolvedRoot;
}

if (TryAnchorPathToCodeOwnersRoot(sourceFilePath, codeOwnersRoot, useOSSeparator, out codeOwnersRelativePath))
{
return true;
}

// Only match when the source file can be resolved under the CODEOWNERS root.
string absolutePath;
if (Path.IsPathRooted(sourceFilePath) || Uri.TryCreate(sourceFilePath, UriKind.Absolute, out _))
Expand All @@ -611,7 +672,7 @@ internal bool TryGetCodeOwnersRelativePath(string sourceFilePath, bool useOSSepa
else
{
// For relative paths, enforce that they stay within the CODEOWNERS root.
// Relative paths must stay within the codeowners root; otherwise we skip.
// Relative paths must stay within the codeowners root; otherwise we try to anchor them.
if (!TryResolvePathWithinBase(sourceFilePath, codeOwnersRoot, out var resolvedPath))
{
return false;
Expand All @@ -637,6 +698,103 @@ internal bool TryGetCodeOwnersRelativePath(string sourceFilePath, bool useOSSepa
return true;
}

private bool TryAnchorPathToCodeOwnersRoot(string sourceFilePath, string codeOwnersRoot, bool useOSSeparator, [NotNullWhen(true)] out string? codeOwnersRelativePath)
{
// Compiler-recorded paths can be relative to a different base directory than the current
// workspace (e.g. "../../../_/tracer/test/SampleTests.cs" on CI agents). When strict
// resolution fails, anchor the path by finding the longest suffix that exists under the
// CODEOWNERS root, independently of the CI provider layout that produced the prefix.
codeOwnersRelativePath = null;
if (StringUtil.IsNullOrWhiteSpace(sourceFilePath))
{
return false;
}

var normalizedPath = sourceFilePath.Replace('\\', '/');
var segments = normalizedPath.Split(ForwardSlashCharacters, StringSplitOptions.RemoveEmptyEntries);
var start = -1;

// Azure may build on one OS and run tests on another. Its checkout marker gives us an
// unambiguous repository-relative suffix even when the recorded path is absolute here.
if (string.Equals(Provider, "azurepipelines", StringComparison.Ordinal) &&
!sourceFilePath.StartsWith("file:", StringComparison.OrdinalIgnoreCase) &&
(!Uri.TryCreate(sourceFilePath, UriKind.Absolute, out var uri) || uri.IsFile))
{
for (var i = 2; i < segments.Length - 1; i++)
{
if (segments[i].Equals("s", StringComparison.OrdinalIgnoreCase) &&
int.TryParse(segments[i - 1], NumberStyles.None, CultureInfo.InvariantCulture, out _) &&
(segments[i - 2].Equals("a", StringComparison.OrdinalIgnoreCase) ||
segments[i - 2].Equals("work", StringComparison.OrdinalIgnoreCase) ||
segments[i - 2].Equals("_work", StringComparison.OrdinalIgnoreCase)))
{
start = i + 1;
break;
}
}
}

var isAzureCheckoutPath = start >= 0;
if (!isAzureCheckoutPath)
{
if (Path.IsPathRooted(sourceFilePath) || Uri.TryCreate(sourceFilePath, UriKind.Absolute, out _))
{
return false;
}

var pathWithoutForeignPrefix = normalizedPath;
while (pathWithoutForeignPrefix.StartsWith("../", StringComparison.Ordinal) ||
pathWithoutForeignPrefix.StartsWith("./", StringComparison.Ordinal))
{
var prefixLength = pathWithoutForeignPrefix.StartsWith("../", StringComparison.Ordinal) ? 3 : 2;
pathWithoutForeignPrefix = pathWithoutForeignPrefix.Substring(prefixLength);
}

if (Path.IsPathRooted(pathWithoutForeignPrefix) || Uri.TryCreate(pathWithoutForeignPrefix, UriKind.Absolute, out _))
{
// Reject absolute paths hidden after leading navigation segments.
return false;
}

if (segments.Length < 2)
{
// Never anchor bare file names: too easy to match an unrelated file.
return false;
}

// Leading navigation segments belong to the compiler's foreign base directory.
start = 0;
while (start < segments.Length && (segments[start] == "." || segments[start] == ".."))
{
start++;
}
}

// Never anchor paths with interior navigation segments: their resolution depends on the
// unknown base directory and would produce malformed repository-relative paths.
for (var i = start; i < segments.Length; i++)
{
if (segments[i] == "." || segments[i] == "..")
{
return false;
}
}

var lastStart = isAzureCheckoutPath ? start + 1 : segments.Length - 1;
for (var i = start; i < lastStart; i++)
{
var candidateSuffix = string.Join(Path.DirectorySeparatorChar.ToString(), segments, i, segments.Length - i);
if (TryResolvePathWithinBase(candidateSuffix, codeOwnersRoot, out var candidatePath) && File.Exists(candidatePath))
{
var separator = useOSSeparator ? Path.DirectorySeparatorChar.ToString() : "/";
codeOwnersRelativePath = string.Join(separator, segments, i, segments.Length - i);
return true;
}
}

return false;
}

private string MakeRelativePath(string? basePath, string absolutePath, bool useOSSeparator)
{
var pivotFolder = basePath;
Expand Down Expand Up @@ -694,7 +852,7 @@ private void EnsureCodeOwnersFromFallback(string? sourceFilePath)

// Search order: source file path (most specific), then workspace root.
// Prefer a source-file-anchored search before falling back to the workspace root.
var platform = GetCodeOwnersPlatform();
var platform = GetCodeOwnersPlatform(SourceRoot ?? WorkspacePath);
if (TryLoadCodeOwnersFromAncestor(sourceFilePath, platform, WorkspacePath))
{
return;
Expand Down Expand Up @@ -738,15 +896,19 @@ private bool TryLoadCodeOwnersFromAncestor(string? startPath, CodeOwners.Platfor
// Walk parent directories until we find CODEOWNERS or hit a git boundary.
while (directoryInfo != null)
{
if (TryGetCodeOwnersPath(directoryInfo.FullName, logLookup: false, out var codeOwnersPath))
if (TryGetCodeOwnersPath(directoryInfo.FullName, platform, logLookup: false, out var codeOwnersPath))
{
Log.Information("CODEOWNERS file found using fallback search: {Path}", codeOwnersPath);
CodeOwners = new CodeOwners(codeOwnersPath, platform);
CodeOwnersRoot = directoryInfo.FullName;
return true;
if (CodeOwners.TryLoad(codeOwnersPath, platform, out var parser))
{
CodeOwners = parser;
CodeOwnersRoot = directoryInfo.FullName;
return true;
}

return false;
}

// Stop walking when we hit a git boundary.
if (HasGitDirectory(directoryInfo.FullName))
{
break;
Expand All @@ -758,6 +920,32 @@ private bool TryLoadCodeOwnersFromAncestor(string? startPath, CodeOwners.Platfor
return false;
}

private CodeOwners.Platform GetCodeOwnersPlatform()
=> GetType().Name.Contains("GitlabEnvironmentValues") ? CodeOwners.Platform.GitLab : CodeOwners.Platform.GitHub;
private CodeOwners.Platform GetCodeOwnersPlatform(string? sourceRoot)
{
if (TryGetCodeOwnersPlatformFromRepository(Repository, out var platform))
{
return platform;
}

if (string.Equals(Provider, "gitlab", StringComparison.Ordinal))
{
return CodeOwners.Platform.GitLab;
}

if (string.Equals(Provider, "github", StringComparison.Ordinal))
{
return CodeOwners.Platform.GitHub;
}

if (!StringUtil.IsNullOrEmpty(sourceRoot) &&
File.Exists(Path.Combine(sourceRoot!, ".gitlab", "CODEOWNERS")) &&
!File.Exists(Path.Combine(sourceRoot!, ".github", "CODEOWNERS")))
{
// A platform-specific location is the only reliable signal for self-managed GitLab
// instances whose host name does not identify the product.
return CodeOwners.Platform.GitLab;
}

return CodeOwners.Platform.GitHub;
}
}
Loading
Loading