diff --git a/tracer/src/Datadog.Trace.Trimming/build/Datadog.Trace.Trimming.xml b/tracer/src/Datadog.Trace.Trimming/build/Datadog.Trace.Trimming.xml
index 16f7f8773f1b..f7ee6a2f3b32 100644
--- a/tracer/src/Datadog.Trace.Trimming/build/Datadog.Trace.Trimming.xml
+++ b/tracer/src/Datadog.Trace.Trimming/build/Datadog.Trace.Trimming.xml
@@ -734,10 +734,12 @@
+
+
diff --git a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.CodeOwners.cs b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.CodeOwners.cs
new file mode 100644
index 000000000000..cfd2d54ecf81
--- /dev/null
+++ b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.CodeOwners.cs
@@ -0,0 +1,611 @@
+//
+// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License.
+// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc.
+//
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+
+namespace Datadog.Trace.Ci.CiEnvironment;
+
+internal abstract partial class CIEnvironmentValues
+{
+ private const int CodeOwnersSearchCacheLimit = 256;
+
+ private static readonly StringComparer CodeOwnersSearchComparer = FrameworkDescription.Instance.IsWindows()
+ ? StringComparer.OrdinalIgnoreCase
+ : StringComparer.Ordinal;
+
+ private static readonly char[] ForwardSlashCharacters = { '/' };
+
+ private readonly object _codeOwnersLock = new();
+ private readonly HashSet _codeOwnersSearchStarts = new(CodeOwnersSearchComparer);
+
+ public CodeOwners? CodeOwners { get; protected set; }
+
+ internal string? CodeOwnersRoot { get; private set; }
+
+ ///
+ /// Returns the source-root-relative path when possible, falling back to the CODEOWNERS root
+ /// for compiler paths that were recorded relative to a different CI workspace.
+ ///
+ /// The compiler-recorded source file path.
+ /// Whether to use the current operating system's directory separator.
+ /// The normalized path relative to the source root or CODEOWNERS root.
+ internal string MakeRelativePathFromSourceRootWithFallback(string sourceFilePath, bool useOSSeparator = true)
+ {
+ var sourceRelativePath = MakeRelativePathFromSourceRoot(sourceFilePath, useOSSeparator);
+ return TryGetCodeOwnersRelativePath(sourceFilePath, useOSSeparator, out var codeOwnersRelativePath)
+ ? codeOwnersRelativePath
+ : sourceRelativePath;
+ }
+
+ ///
+ /// Resolves a source path to a safe repository-relative path that can be matched against the
+ /// loaded CODEOWNERS file, discovering the file lazily when necessary.
+ ///
+ /// The compiler-recorded source file path.
+ /// Whether to use the current operating system's directory separator.
+ /// The path relative to the root containing the loaded CODEOWNERS file.
+ /// true when the source path can be safely resolved; otherwise, false.
+ internal bool TryGetCodeOwnersRelativePath(string sourceFilePath, bool useOSSeparator, [NotNullWhen(true)] out string? codeOwnersRelativePath)
+ {
+ codeOwnersRelativePath = null;
+
+ if (StringUtil.IsNullOrWhiteSpace(sourceFilePath))
+ {
+ return false;
+ }
+
+ // Algorithm: load CODEOWNERS (with fallback), resolve roots, then normalize source file to repo-relative.
+ // Ensure CODEOWNERS is loaded (or discovered via fallback) before attempting normalization.
+ EnsureCodeOwnersFromFallback(sourceFilePath);
+
+ if (CodeOwners is null || StringUtil.IsNullOrWhiteSpace(CodeOwnersRoot))
+ {
+ return false;
+ }
+
+ var codeOwnersRoot = CodeOwnersRoot;
+ if (!Path.IsPathRooted(codeOwnersRoot))
+ {
+ // If SourceRoot was relative, re-anchor to WorkspacePath before matching.
+ if (StringUtil.IsNullOrWhiteSpace(WorkspacePath) ||
+ !TryResolvePathWithinBase(codeOwnersRoot, WorkspacePath, out var resolvedRoot))
+ {
+ return false;
+ }
+
+ // 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, 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 _))
+ {
+ // Absolute paths are already resolved, no workspace anchoring needed.
+ absolutePath = sourceFilePath;
+ }
+ else
+ {
+ // For relative paths, enforce that they stay within the CODEOWNERS root.
+ // Relative paths must stay within the codeowners root; otherwise we try to anchor them.
+ if (!TryResolvePathWithinBase(sourceFilePath, codeOwnersRoot, out var resolvedPath))
+ {
+ return false;
+ }
+
+ absolutePath = resolvedPath;
+ }
+
+ // Normalize to a repo-relative path before matching the CODEOWNERS rules.
+ var relativePath = MakeRelativePath(codeOwnersRoot, absolutePath, useOSSeparator);
+ // Guard against paths that escape the root or remain absolute after normalization.
+ if (StringUtil.IsNullOrWhiteSpace(relativePath) ||
+ Path.IsPathRooted(relativePath) ||
+ Uri.TryCreate(relativePath, UriKind.Absolute, out _) ||
+ relativePath.Equals("..", StringComparison.Ordinal) ||
+ relativePath.StartsWith("../", StringComparison.Ordinal) ||
+ relativePath.StartsWith("..\\", StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ codeOwnersRelativePath = relativePath;
+ return true;
+ }
+
+ ///
+ /// Resolves a candidate source path and returns the directory from which an ancestor
+ /// CODEOWNERS search should start, without falling back to the current working directory.
+ ///
+ /// The source file or directory path used to start the search.
+ /// The absolute base used to resolve a relative .
+ /// The resolved search directory, or null when the path cannot be resolved safely.
+ private static string? GetCodeOwnersSearchStart(string? path, string? basePath)
+ {
+ if (StringUtil.IsNullOrWhiteSpace(path))
+ {
+ return null;
+ }
+
+ string? resolvedPath = null;
+ try
+ {
+ if (Path.IsPathRooted(path) || Uri.TryCreate(path, UriKind.Absolute, out _))
+ {
+ resolvedPath = path;
+ }
+ else if (!StringUtil.IsNullOrWhiteSpace(basePath) && Path.IsPathRooted(basePath))
+ {
+ // Keep relative paths anchored to a known workspace and reject escapes (no CWD fallback).
+ TryResolvePathWithinBase(path, basePath, out resolvedPath);
+ }
+
+ if (StringUtil.IsNullOrWhiteSpace(resolvedPath))
+ {
+ return null;
+ }
+
+ // Start searching from the directory containing the candidate path.
+ if (Directory.Exists(resolvedPath))
+ {
+ return resolvedPath;
+ }
+
+ return Path.GetDirectoryName(resolvedPath);
+ }
+ catch (Exception ex)
+ {
+ Log.Debug(ex, "Error resolving CODEOWNERS search start for '{Path}'", resolvedPath ?? path);
+ return null;
+ }
+ }
+
+ ///
+ /// Detects a repository boundary represented by either a .git directory or a worktree .git file.
+ ///
+ /// The directory in which to look for the Git marker.
+ /// true when the directory contains a Git marker; otherwise, false.
+ private static bool HasGitDirectory(string path)
+ {
+ var gitPath = Path.Combine(path, ".git");
+ return Directory.Exists(gitPath) || File.Exists(gitPath);
+ }
+
+ ///
+ /// Anchors a relative path to an absolute base directory and rejects rooted inputs or traversal
+ /// that would escape that base.
+ ///
+ /// The relative path to resolve.
+ /// The absolute directory that must contain the resolved path.
+ /// The resolved absolute path when resolution succeeds.
+ /// true when the path resolves within the base directory; otherwise, false.
+ private static bool TryResolvePathWithinBase(string relativePath, string basePath, [NotNullWhen(true)] out string? absolutePath)
+ {
+ absolutePath = null;
+
+ if (StringUtil.IsNullOrWhiteSpace(relativePath) || StringUtil.IsNullOrWhiteSpace(basePath))
+ {
+ return false;
+ }
+
+ try
+ {
+ // Only combine relative paths; rooted or absolute inputs bypass base anchoring.
+ if (Path.IsPathRooted(relativePath) || Uri.TryCreate(relativePath, UriKind.Absolute, out _))
+ {
+ return false;
+ }
+
+ if (!Path.IsPathRooted(basePath))
+ {
+ return false;
+ }
+
+ // Normalize to full paths and ensure the combined path stays within the base.
+ var comparison = Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
+ var fullBasePath = Path.GetFullPath(basePath);
+ var fullBasePathWithSeparator = fullBasePath;
+ if (!fullBasePathWithSeparator.EndsWith(Path.DirectorySeparatorChar.ToString(), comparison) &&
+ !fullBasePathWithSeparator.EndsWith(Path.AltDirectorySeparatorChar.ToString(), comparison))
+ {
+ fullBasePathWithSeparator += Path.DirectorySeparatorChar;
+ }
+
+ var combinedPath = Path.Combine(fullBasePath, relativePath);
+ var fullCombinedPath = Path.GetFullPath(combinedPath);
+ // Reject traversal that escapes the base directory.
+ if (!fullCombinedPath.StartsWith(fullBasePathWithSeparator, comparison) &&
+ !string.Equals(fullCombinedPath, fullBasePath, comparison))
+ {
+ return false;
+ }
+
+ absolutePath = fullCombinedPath;
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Log.Debug(ex, "Error resolving relative path '{Path}' within base '{BasePath}'", relativePath, basePath);
+ }
+
+ return false;
+ }
+
+ ///
+ /// Probes the platform-specific CODEOWNERS locations in priority order and returns the first
+ /// existing file.
+ ///
+ /// The repository root under which to search.
+ /// The platform whose CODEOWNERS lookup order should be used.
+ /// Whether each candidate path should be logged.
+ /// The first existing CODEOWNERS path.
+ /// true when a CODEOWNERS file is found; otherwise, false.
+ private static bool TryGetCodeOwnersPath(string sourceRoot, CodeOwners.Platform platform, bool logLookup, [NotNullWhen(true)] out string? codeOwnersPath)
+ {
+ foreach (var path in GetCodeOwnersPaths(sourceRoot, platform))
+ {
+ if (logLookup)
+ {
+ Log.Debug("Looking for CODEOWNERS file in: {Path}", path);
+ }
+
+ if (File.Exists(path))
+ {
+ codeOwnersPath = path;
+ return true;
+ }
+ }
+
+ codeOwnersPath = null;
+ return false;
+ }
+
+ ///
+ /// Infers the CODEOWNERS dialect from standard repository URLs and SCP-style SSH URLs.
+ ///
+ /// The repository URL to inspect.
+ /// The platform inferred from the repository host.
+ /// true when the repository host identifies a supported platform; otherwise, false.
+ private static bool TryGetCodeOwnersPlatformFromRepository(string? repository, out CodeOwners.Platform platform)
+ {
+ 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;
+ }
+
+ ///
+ /// Recognizes gitlab.com and common self-managed GitLab host naming conventions.
+ ///
+ /// The repository host name.
+ /// true when the host identifies GitLab; otherwise, 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);
+
+ ///
+ /// Enumerates the supported CODEOWNERS locations in the order defined by each platform,
+ /// including all known locations when the platform value is unknown.
+ ///
+ /// The repository root under which candidate paths are built.
+ /// The platform whose lookup order should be used.
+ /// The candidate CODEOWNERS paths in lookup order.
+ private static IEnumerable 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");
+ }
+ else if (platform == CodeOwners.Platform.GitLab)
+ {
+ // 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");
+ }
+ else
+ {
+ // Unknown platform: search all known locations in a reasonable order.
+ yield return Path.Combine(sourceRoot, "CODEOWNERS");
+ yield return Path.Combine(sourceRoot, "docs", "CODEOWNERS");
+ yield return Path.Combine(sourceRoot, ".github", "CODEOWNERS");
+ yield return Path.Combine(sourceRoot, ".gitlab", "CODEOWNERS");
+ }
+ }
+
+ ///
+ /// Clears the loaded parser, its repository root, and cached fallback search locations.
+ ///
+ private void ResetCodeOwners()
+ {
+ CodeOwners = null;
+ CodeOwnersRoot = null;
+ lock (_codeOwnersLock)
+ {
+ _codeOwnersSearchStarts.Clear();
+ }
+ }
+
+ ///
+ /// Performs the initial CODEOWNERS lookup at SourceRoot using the detected platform semantics.
+ ///
+ private void LoadCodeOwners()
+ {
+ if (!StringUtil.IsNullOrEmpty(SourceRoot))
+ {
+ var platform = GetCodeOwnersPlatform(SourceRoot);
+ if (TryGetCodeOwnersPath(SourceRoot, platform, logLookup: true, out var codeOwnersPath))
+ {
+ Log.Information("CODEOWNERS file found: {Path}", codeOwnersPath);
+ if (CodeOwners.TryLoad(codeOwnersPath, platform, out var parser))
+ {
+ CodeOwners = parser;
+ CodeOwnersRoot = SourceRoot;
+ }
+ }
+ }
+ }
+
+ ///
+ /// Re-anchors an unresolved relative compiler path by selecting the longest file suffix that
+ /// exists below the CODEOWNERS root; absolute, ambiguous, and traversing paths are rejected.
+ ///
+ /// The unresolved compiler-recorded source path.
+ /// The repository root containing the loaded CODEOWNERS file.
+ /// Whether to use the current operating system's directory separator.
+ /// The existing suffix relative to the CODEOWNERS root.
+ /// true when an unambiguous existing suffix is found; otherwise, false.
+ 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);
+ 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.
+ var 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;
+ }
+ }
+
+ for (var i = start; i < segments.Length - 1; 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;
+ }
+
+ ///
+ /// Lazily discovers CODEOWNERS from the source path or workspace when the initial SourceRoot
+ /// lookup did not load one, while serializing concurrent fallback attempts.
+ ///
+ /// The source path from which to begin the most specific fallback search.
+ private void EnsureCodeOwnersFromFallback(string? sourceFilePath)
+ {
+ if (CodeOwners is not null)
+ {
+ return;
+ }
+
+ lock (_codeOwnersLock)
+ {
+ if (CodeOwners is not null)
+ {
+ return;
+ }
+
+ // 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(SourceRoot ?? WorkspacePath);
+ if (TryLoadCodeOwnersFromAncestor(sourceFilePath, platform, WorkspacePath))
+ {
+ return;
+ }
+
+ TryLoadCodeOwnersFromAncestor(WorkspacePath, platform, basePath: null);
+ }
+ }
+
+ ///
+ /// Walks ancestors from a resolved start directory, loading the first CODEOWNERS file and
+ /// stopping at the nearest Git boundary; repeated start locations are cached.
+ ///
+ /// The source file or directory path from which to start.
+ /// The platform semantics used to locate and parse CODEOWNERS.
+ /// The absolute base used to resolve a relative .
+ /// true when a CODEOWNERS file is found and loaded; otherwise, false.
+ private bool TryLoadCodeOwnersFromAncestor(string? startPath, CodeOwners.Platform platform, string? basePath)
+ {
+ var startDirectory = GetCodeOwnersSearchStart(startPath, basePath);
+ if (StringUtil.IsNullOrEmpty(startDirectory))
+ {
+ return false;
+ }
+
+ DirectoryInfo? directoryInfo;
+ try
+ {
+ directoryInfo = new DirectoryInfo(startDirectory);
+ }
+ catch (Exception ex)
+ {
+ Log.Debug(ex, "Error resolving CODEOWNERS search directory from '{Path}'", startDirectory);
+ return false;
+ }
+
+ // Limit cache growth to avoid unbounded memory in large test suites.
+ if (_codeOwnersSearchStarts.Count >= CodeOwnersSearchCacheLimit)
+ {
+ _codeOwnersSearchStarts.Clear();
+ }
+
+ // Skip repeated lookups for the same starting directory.
+ if (!_codeOwnersSearchStarts.Add(directoryInfo.FullName))
+ {
+ return false;
+ }
+
+ // Walk parent directories until we find CODEOWNERS or hit a git boundary.
+ while (directoryInfo != null)
+ {
+ if (TryGetCodeOwnersPath(directoryInfo.FullName, platform, logLookup: false, out var codeOwnersPath))
+ {
+ Log.Information("CODEOWNERS file found using fallback search: {Path}", codeOwnersPath);
+ if (CodeOwners.TryLoad(codeOwnersPath, platform, out var parser))
+ {
+ CodeOwners = parser;
+ CodeOwnersRoot = directoryInfo.FullName;
+ return true;
+ }
+
+ return false;
+ }
+
+ if (HasGitDirectory(directoryInfo.FullName))
+ {
+ break;
+ }
+
+ directoryInfo = directoryInfo.Parent;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Selects the CODEOWNERS dialect from repository host, CI provider, or platform-specific file
+ /// placement, defaulting to GitHub when no reliable GitLab signal exists.
+ ///
+ /// The repository root used to inspect platform-specific file locations.
+ /// The CODEOWNERS platform whose semantics should be used.
+ 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;
+ }
+}
diff --git a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs
index a35e4de1d3f3..9724ca63a971 100644
--- a/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs
+++ b/tracer/src/Datadog.Trace/Ci/CiEnvironment/CIEnvironmentValues.cs
@@ -6,7 +6,6 @@
using System;
using System.Collections.Generic;
-using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Runtime.CompilerServices;
@@ -20,20 +19,12 @@ namespace Datadog.Trace.Ci.CiEnvironment;
// ReSharper disable once InconsistentNaming
-internal abstract class CIEnvironmentValues
+internal abstract partial class CIEnvironmentValues
{
- private const int CodeOwnersSearchCacheLimit = 256;
internal const string RepositoryUrlPattern = @"((http|git|ssh|http(s)|file|\/?)|(git@[\w\.\-]+))(:(\/\/)?)([\w\.@\:/\-~]+)(\.git)?(\/)?";
protected static readonly IDatadogLogger Log = DatadogLogging.GetLoggerFor(typeof(CIEnvironmentValues));
private static readonly Lazy LazyInstance = new(Create);
private static readonly Regex BranchOrTagsRegex = new(@"^refs\/heads\/tags\/(.*)|refs\/heads\/(.*)|refs\/tags\/(.*)|refs\/(.*)|origin\/tags\/(.*)|origin\/(.*)$", RegexOptions.Compiled);
- private static readonly StringComparer CodeOwnersSearchComparer = FrameworkDescription.Instance.IsWindows()
- ? StringComparer.OrdinalIgnoreCase
- : StringComparer.Ordinal;
-
- private readonly object _codeOwnersLock = new();
- private readonly HashSet _codeOwnersSearchStarts = new(CodeOwnersSearchComparer);
-
private string? _gitSearchFolder;
public static CIEnvironmentValues Instance => LazyInstance.Value;
@@ -124,10 +115,6 @@ public string? GitSearchFolder
public string? HeadMessage { get; protected set; }
- public CodeOwners? CodeOwners { get; protected set; }
-
- internal string? CodeOwnersRoot { get; private set; }
-
public Dictionary? VariablesToBypass { get; protected set; }
public MetricTags.CIVisibilityTestSessionProvider MetricTag { get; protected set; } = MetricTags.CIVisibilityTestSessionProvider.Unsupported;
@@ -259,132 +246,6 @@ protected static bool IsHex(IEnumerable chars)
return Tuple.Create(branch, tag);
}
- private static string? GetCodeOwnersSearchStart(string? path, string? basePath)
- {
- if (StringUtil.IsNullOrWhiteSpace(path))
- {
- return null;
- }
-
- string? resolvedPath = null;
- try
- {
- if (Path.IsPathRooted(path) || Uri.TryCreate(path, UriKind.Absolute, out _))
- {
- resolvedPath = path;
- }
- else if (!StringUtil.IsNullOrWhiteSpace(basePath) && Path.IsPathRooted(basePath))
- {
- // Keep relative paths anchored to a known workspace and reject escapes (no CWD fallback).
- TryResolvePathWithinBase(path, basePath, out resolvedPath);
- }
-
- if (StringUtil.IsNullOrWhiteSpace(resolvedPath))
- {
- return null;
- }
-
- // Start searching from the directory containing the candidate path.
- if (Directory.Exists(resolvedPath))
- {
- return resolvedPath;
- }
-
- return Path.GetDirectoryName(resolvedPath);
- }
- catch (Exception ex)
- {
- Log.Debug(ex, "Error resolving CODEOWNERS search start for '{Path}'", resolvedPath ?? path);
- return null;
- }
- }
-
- private static bool HasGitDirectory(string path)
- {
- var gitPath = Path.Combine(path, ".git");
- return Directory.Exists(gitPath) || File.Exists(gitPath);
- }
-
- private static bool TryResolvePathWithinBase(string relativePath, string basePath, [NotNullWhen(true)] out string? absolutePath)
- {
- absolutePath = null;
-
- if (StringUtil.IsNullOrWhiteSpace(relativePath) || StringUtil.IsNullOrWhiteSpace(basePath))
- {
- return false;
- }
-
- try
- {
- // Only combine relative paths; rooted or absolute inputs bypass base anchoring.
- if (Path.IsPathRooted(relativePath) || Uri.TryCreate(relativePath, UriKind.Absolute, out _))
- {
- return false;
- }
-
- if (!Path.IsPathRooted(basePath))
- {
- return false;
- }
-
- // Normalize to full paths and ensure the combined path stays within the base.
- var comparison = Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
- var fullBasePath = Path.GetFullPath(basePath);
- var fullBasePathWithSeparator = fullBasePath;
- if (!fullBasePathWithSeparator.EndsWith(Path.DirectorySeparatorChar.ToString(), comparison) &&
- !fullBasePathWithSeparator.EndsWith(Path.AltDirectorySeparatorChar.ToString(), comparison))
- {
- fullBasePathWithSeparator += Path.DirectorySeparatorChar;
- }
-
- var combinedPath = Path.Combine(fullBasePath, relativePath);
- var fullCombinedPath = Path.GetFullPath(combinedPath);
- // Reject traversal that escapes the base directory.
- if (!fullCombinedPath.StartsWith(fullBasePathWithSeparator, comparison) &&
- !string.Equals(fullCombinedPath, fullBasePath, comparison))
- {
- return false;
- }
-
- absolutePath = fullCombinedPath;
- return true;
- }
- catch (Exception ex)
- {
- Log.Debug(ex, "Error resolving relative path '{Path}' within base '{BasePath}'", relativePath, basePath);
- }
-
- return false;
- }
-
- private static bool TryGetCodeOwnersPath(string sourceRoot, bool logLookup, [NotNullWhen(true)] out string? codeOwnersPath)
- {
- foreach (var path in GetCodeOwnersPaths(sourceRoot))
- {
- if (logLookup)
- {
- Log.Debug("Looking for CODEOWNERS file in: {Path}", path);
- }
-
- if (File.Exists(path))
- {
- codeOwnersPath = path;
- return true;
- }
- }
-
- codeOwnersPath = null;
- return false;
- }
-
- private static IEnumerable GetCodeOwnersPaths(string sourceRoot)
- {
- 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");
- }
-
public void DecorateSpan(Span span)
{
if (span == null)
@@ -470,14 +331,9 @@ protected void ReloadEnvironmentData()
CommitterDate = null;
Message = null;
SourceRoot = null;
- CodeOwners = null;
- CodeOwnersRoot = null;
- lock (_codeOwnersLock)
- {
- _codeOwnersSearchStarts.Clear();
- }
+ ResetCodeOwners();
- Setup(string.IsNullOrEmpty(_gitSearchFolder) ? GitInfo.GetCurrent() : GitInfo.GetFrom(_gitSearchFolder!));
+ Setup(StringUtil.IsNullOrEmpty(_gitSearchFolder) ? GitInfo.GetCurrent() : GitInfo.GetFrom(_gitSearchFolder));
// **********
// Remove sensitive info from repository url
@@ -493,26 +349,15 @@ protected void ReloadEnvironmentData()
// **********
// Sanitize Repository Url (Remove username:password info from the url)
// **********
- if (!string.IsNullOrEmpty(Repository) &&
+ if (!StringUtil.IsNullOrEmpty(Repository) &&
Uri.TryCreate(Repository, UriKind.Absolute, out var uriRepository) &&
- !string.IsNullOrEmpty(uriRepository.UserInfo))
+ !StringUtil.IsNullOrEmpty(uriRepository.UserInfo))
{
- Repository = Repository!.Replace(uriRepository.UserInfo + "@", string.Empty);
+ Repository = Repository.Replace(uriRepository.UserInfo + "@", string.Empty);
Repository = Repository.Replace(uriRepository.UserInfo, string.Empty);
}
- // **********
- // Try load CodeOwners
- // **********
- if (!string.IsNullOrEmpty(SourceRoot))
- {
- if (TryGetCodeOwnersPath(SourceRoot!, logLookup: true, out var codeOwnersPath))
- {
- Log.Information("CODEOWNERS file found: {Path}", codeOwnersPath);
- CodeOwners = new CodeOwners(codeOwnersPath, GetCodeOwnersPlatform());
- CodeOwnersRoot = SourceRoot;
- }
- }
+ LoadCodeOwners();
}
protected abstract void Setup(IGitInfo gitInfo);
@@ -551,92 +396,6 @@ public string MakeRelativePathFromSourceRoot(string absolutePath, bool useOSSepa
return MakeRelativePath(SourceRoot, absolutePath, useOSSeparator);
}
- 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;
- }
-
- internal bool TryGetCodeOwnersRelativePath(string sourceFilePath, bool useOSSeparator, [NotNullWhen(true)] out string? codeOwnersRelativePath)
- {
- codeOwnersRelativePath = null;
-
- if (StringUtil.IsNullOrWhiteSpace(sourceFilePath))
- {
- return false;
- }
-
- // Algorithm: load CODEOWNERS (with fallback), resolve roots, then normalize source file to repo-relative.
- // Ensure CODEOWNERS is loaded (or discovered via fallback) before attempting normalization.
- EnsureCodeOwnersFromFallback(sourceFilePath);
-
- if (CodeOwners is null || StringUtil.IsNullOrWhiteSpace(CodeOwnersRoot))
- {
- return false;
- }
-
- var codeOwnersRoot = CodeOwnersRoot!;
- if (!Path.IsPathRooted(codeOwnersRoot))
- {
- // If SourceRoot was relative, re-anchor to WorkspacePath before matching.
- if (StringUtil.IsNullOrWhiteSpace(WorkspacePath) ||
- !TryResolvePathWithinBase(codeOwnersRoot, WorkspacePath!, out var resolvedRoot))
- {
- return false;
- }
-
- // 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 _))
- {
- return false;
- }
-
- codeOwnersRoot = resolvedRoot;
- }
-
- // 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 _))
- {
- // Absolute paths are already resolved, no workspace anchoring needed.
- absolutePath = sourceFilePath;
- }
- else
- {
- // For relative paths, enforce that they stay within the CODEOWNERS root.
- // Relative paths must stay within the codeowners root; otherwise we skip.
- if (!TryResolvePathWithinBase(sourceFilePath, codeOwnersRoot, out var resolvedPath))
- {
- return false;
- }
-
- absolutePath = resolvedPath;
- }
-
- // Normalize to a repo-relative path before matching the CODEOWNERS rules.
- var relativePath = MakeRelativePath(codeOwnersRoot, absolutePath, useOSSeparator);
- // Guard against paths that escape the root or remain absolute after normalization.
- if (StringUtil.IsNullOrWhiteSpace(relativePath) ||
- Path.IsPathRooted(relativePath) ||
- Uri.TryCreate(relativePath, UriKind.Absolute, out _) ||
- relativePath.Equals("..", StringComparison.Ordinal) ||
- relativePath.StartsWith("../", StringComparison.Ordinal) ||
- relativePath.StartsWith("..\\", StringComparison.Ordinal))
- {
- return false;
- }
-
- codeOwnersRelativePath = relativePath;
- return true;
- }
-
private string MakeRelativePath(string? basePath, string absolutePath, bool useOSSeparator)
{
var pivotFolder = basePath;
@@ -647,7 +406,7 @@ private string MakeRelativePath(string? basePath, string absolutePath, bool useO
if (StringUtil.IsNullOrEmpty(absolutePath))
{
- return pivotFolder!;
+ return pivotFolder;
}
try
@@ -677,87 +436,4 @@ private string MakeRelativePath(string? basePath, string absolutePath, bool useO
return absolutePath;
}
-
- private void EnsureCodeOwnersFromFallback(string? sourceFilePath)
- {
- if (CodeOwners is not null)
- {
- return;
- }
-
- lock (_codeOwnersLock)
- {
- if (CodeOwners is not null)
- {
- return;
- }
-
- // 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();
- if (TryLoadCodeOwnersFromAncestor(sourceFilePath, platform, WorkspacePath))
- {
- return;
- }
-
- TryLoadCodeOwnersFromAncestor(WorkspacePath, platform, basePath: null);
- }
- }
-
- private bool TryLoadCodeOwnersFromAncestor(string? startPath, CodeOwners.Platform platform, string? basePath)
- {
- var startDirectory = GetCodeOwnersSearchStart(startPath, basePath);
- if (StringUtil.IsNullOrEmpty(startDirectory))
- {
- return false;
- }
-
- DirectoryInfo? directoryInfo;
- try
- {
- directoryInfo = new DirectoryInfo(startDirectory);
- }
- catch (Exception ex)
- {
- Log.Debug(ex, "Error resolving CODEOWNERS search directory from '{Path}'", startDirectory);
- return false;
- }
-
- // Limit cache growth to avoid unbounded memory in large test suites.
- if (_codeOwnersSearchStarts.Count >= CodeOwnersSearchCacheLimit)
- {
- _codeOwnersSearchStarts.Clear();
- }
-
- // Skip repeated lookups for the same starting directory.
- if (!_codeOwnersSearchStarts.Add(directoryInfo.FullName))
- {
- return false;
- }
-
- // Walk parent directories until we find CODEOWNERS or hit a git boundary.
- while (directoryInfo != null)
- {
- if (TryGetCodeOwnersPath(directoryInfo.FullName, 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;
- }
-
- // Stop walking when we hit a git boundary.
- if (HasGitDirectory(directoryInfo.FullName))
- {
- break;
- }
-
- directoryInfo = directoryInfo.Parent;
- }
-
- return false;
- }
-
- private CodeOwners.Platform GetCodeOwnersPlatform()
- => GetType().Name.Contains("GitlabEnvironmentValues") ? CodeOwners.Platform.GitLab : CodeOwners.Platform.GitHub;
}
diff --git a/tracer/src/Datadog.Trace/Ci/CodeOwners.GitHub.cs b/tracer/src/Datadog.Trace/Ci/CodeOwners.GitHub.cs
new file mode 100644
index 000000000000..dba0f9adcc5c
--- /dev/null
+++ b/tracer/src/Datadog.Trace/Ci/CodeOwners.GitHub.cs
@@ -0,0 +1,319 @@
+//
+// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License.
+// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc.
+//
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+
+namespace Datadog.Trace.Ci
+{
+ internal sealed partial class CodeOwners
+ {
+ ///
+ /// Reads and validates the owner list from a GitHub rule.
+ ///
+ private static class GitHubOwnerTokenizer
+ {
+ ///
+ /// Splits the owner text, removes duplicates, and reports invalid owners.
+ ///
+ public static string[] TokenizeGitHub(string segment, out bool allValid)
+ {
+ if (string.IsNullOrWhiteSpace(segment))
+ {
+ allValid = true;
+ return [];
+ }
+
+ var owners = new List();
+ var uniqueOwners = new HashSet(StringComparer.Ordinal);
+ allValid = true;
+ foreach (var token in segment.Split(OwnerSeparators, StringSplitOptions.RemoveEmptyEntries))
+ {
+ if (IsValidGitHubOwner(token))
+ {
+ AddUniqueOwner(owners, uniqueOwners, token);
+ }
+ else
+ {
+ allValid = false;
+ }
+ }
+
+ return owners.Count == 0 ? [] : owners.ToArray();
+ }
+
+ ///
+ /// Checks whether a token is a valid GitHub user, team, or email address.
+ ///
+ private static bool IsValidGitHubOwner(string token)
+ {
+ // GitHub users and teams use @user or @organization/team.
+ if (token.Length > 1 && token[0] == '@' && token[1] != '@')
+ {
+ var slash = token.IndexOf('/');
+ return slash < 0
+ ? IsValidGitHubIdentifier(token, 1, token.Length)
+ : token.IndexOf('/', slash + 1) < 0 &&
+ IsValidGitHubIdentifier(token, 1, slash) &&
+ IsValidGitHubIdentifier(token, slash + 1, token.Length);
+ }
+
+ // Any other owner must be a valid email address.
+ var at = token.IndexOf('@');
+ if (at is < 1 or > 100 ||
+ token.Length - at - 1 is < 1 or > 255 ||
+ token.IndexOf('@', at + 1) >= 0)
+ {
+ return false;
+ }
+
+ for (var i = 0; i < at; i++)
+ {
+ if (!IsEmailLocalCharacter(token[i]))
+ {
+ return false;
+ }
+ }
+
+ for (var i = at + 1; i < token.Length; i++)
+ {
+ if (!IsAsciiLetterOrDigit(token[i]) && token[i] is not '.' and not '-' and not '_')
+ {
+ return false;
+ }
+ }
+
+ return IsAsciiLetterOrDigit(token[token.Length - 1]) || token[token.Length - 1] == '_';
+ }
+
+ ///
+ /// Checks one GitHub user, organization, or team name inside a token.
+ ///
+ private static bool IsValidGitHubIdentifier(string value, int start, int end)
+ {
+ if (start >= end || !IsAsciiLetterOrDigit(value[start]) || !IsAsciiLetterOrDigit(value[end - 1]))
+ {
+ return false;
+ }
+
+ var previousWasHyphen = false;
+ for (var i = start; i < end; i++)
+ {
+ var character = value[i];
+ if (!IsAsciiLetterOrDigit(character) && character is not '-' and not '_')
+ {
+ return false;
+ }
+
+ if (character == '-' && previousWasHyphen)
+ {
+ return false;
+ }
+
+ previousWasHyphen = character == '-';
+ }
+
+ return true;
+ }
+
+ ///
+ /// Checks whether a character is allowed before the at sign in an email address.
+ ///
+ private static bool IsEmailLocalCharacter(char character)
+ => IsAsciiLetterOrDigit(character) || ".!#$%&'*+/=?^_`{|}~-".IndexOf(character) >= 0;
+ }
+
+ ///
+ /// Stores GitHub rules in last-match-first order.
+ ///
+ ///
+ /// See GitHub CODEOWNERS syntax and precedence.
+ ///
+ private sealed class GitHubDocument : Document
+ {
+ private readonly Entry[] _rules;
+
+ ///
+ /// Initializes a new instance of the class from compiled rules.
+ ///
+ private GitHubDocument(Entry[] rules)
+ {
+ _rules = rules;
+ }
+
+ public static GitHubDocument Empty { get; } = new([]);
+
+ ///
+ /// Parses valid GitHub rules and counts invalid lines.
+ ///
+ public static GitHubDocument Parse(IEnumerable lines, out int parsingDiagnosticsCount)
+ {
+ parsingDiagnosticsCount = 0;
+ var rules = new List();
+
+ foreach (var line in lines)
+ {
+ var raw = line.Trim();
+ if (raw.Length == 0 || raw[0] == '#')
+ {
+ continue;
+ }
+
+ var entry = Entry.ParseGitHub(raw);
+ if (entry is null)
+ {
+ parsingDiagnosticsCount++;
+ }
+ else
+ {
+ rules.Add(entry);
+ }
+ }
+
+ // GitHub uses the last matching rule, so search the rules from bottom to top.
+ rules.Reverse();
+ return new GitHubDocument(rules.ToArray());
+ }
+
+ ///
+ /// Returns the owners from the last GitHub rule that matches the path.
+ ///
+ public override IEnumerable Match(string path)
+ {
+ foreach (var rule in _rules)
+ {
+ if (rule.Match(path))
+ {
+ return rule.Owners;
+ }
+ }
+
+ return [];
+ }
+ }
+
+ private sealed partial class Entry
+ {
+ ///
+ /// Parses one GitHub rule and compiles its path pattern.
+ ///
+ public static Entry? ParseGitHub(string raw)
+ {
+ if (raw.StartsWith("\\#"))
+ {
+ return null;
+ }
+
+ var idxHash = FindUnescapedCharacter(raw, '#');
+ var effective = idxHash >= 0 ? raw.Substring(0, idxHash).TrimEnd() : raw;
+ if (string.IsNullOrWhiteSpace(effective))
+ {
+ return null;
+ }
+
+ string patternToken;
+ string ownersSegment;
+ SplitEscapedEntry(effective, out patternToken, out ownersSegment, out _);
+
+ if (patternToken.Length == 0 || IsUnsupportedGitHubPattern(patternToken))
+ {
+ return null;
+ }
+
+ var owners = GitHubOwnerTokenizer.TokenizeGitHub(ownersSegment, out var allOwnersValid);
+ if (!allOwnersValid)
+ {
+ return null;
+ }
+
+ var glob = GlobPattern.CompileGitHub(patternToken, includeDescendants: IsDirectoryPattern(patternToken));
+ if (glob is null)
+ {
+ return null;
+ }
+
+ return new Entry(glob, patternToken, exclusion: false, owners);
+ }
+
+ ///
+ /// Finds a character that is not escaped with a backslash.
+ ///
+ private static int FindUnescapedCharacter(string value, char character)
+ {
+ for (var i = 0; i < value.Length; i++)
+ {
+ if (value[i] == '\\' && i + 1 < value.Length)
+ {
+ i++;
+ }
+ else if (value[i] == character)
+ {
+ return i;
+ }
+ }
+
+ return -1;
+ }
+
+ ///
+ /// Rejects pattern features that GitHub CODEOWNERS does not support.
+ ///
+ private static bool IsUnsupportedGitHubPattern(string patternToken)
+ {
+ if (patternToken.StartsWith("!"))
+ {
+ return true;
+ }
+
+ var hasOpeningBracket = false;
+ for (var i = 0; i < patternToken.Length; i++)
+ {
+ if (patternToken[i] == '\\' && i + 1 < patternToken.Length)
+ {
+ i++;
+ }
+ else if (patternToken[i] == '[')
+ {
+ hasOpeningBracket = true;
+ }
+ else if (patternToken[i] == ']' && hasOpeningBracket)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Checks whether the final path segment names a directory without wildcards.
+ ///
+ private static bool IsDirectoryPattern(string patternToken)
+ {
+ var lastSegmentStart = patternToken.LastIndexOf('/');
+ var lastSegment = lastSegmentStart >= 0 ? patternToken.Substring(lastSegmentStart + 1) : patternToken;
+ if (lastSegment.Length == 0)
+ {
+ return false;
+ }
+
+ for (var i = 0; i < lastSegment.Length; i++)
+ {
+ if (lastSegment[i] == '\\' && i + 1 < lastSegment.Length)
+ {
+ i++;
+ }
+ else if (lastSegment[i] is '*' or '?')
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+ }
+ }
+}
diff --git a/tracer/src/Datadog.Trace/Ci/CodeOwners.GitLab.cs b/tracer/src/Datadog.Trace/Ci/CodeOwners.GitLab.cs
new file mode 100644
index 000000000000..2ebd87362549
--- /dev/null
+++ b/tracer/src/Datadog.Trace/Ci/CodeOwners.GitLab.cs
@@ -0,0 +1,734 @@
+//
+// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License.
+// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc.
+//
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace Datadog.Trace.Ci
+{
+ internal sealed partial class CodeOwners
+ {
+ private static readonly Regex SectionHeaderRegex = new(
+ @"^\s*(\^)?\[(?.*?)\](?:\[(?[\s\d]*)\])?(?\s*[@\w.\-/\s]*)?",
+ RegexOptions.Compiled | RegexOptions.CultureInvariant);
+
+ private static readonly Regex GitLabRoleReferenceRegex = new(
+ @"(?
+ /// Parses a GitLab section header, its name, and its default owners.
+ ///
+ private static bool TryParseGitLabSectionHeader(
+ string raw,
+ [NotNullWhen(true)] out GitLabSection? section,
+ out bool hasDiagnostics)
+ {
+ // Accepted forms:
+ // [Docs]
+ // ^[Go]
+ // [Backend][2] @team @another
+ var m = SectionHeaderRegex.Match(raw);
+ if (!m.Success)
+ {
+ section = null;
+ hasDiagnostics = false;
+ return false;
+ }
+
+ var required = !m.Groups[1].Success; // ^ prefix => optional section
+ var name = m.Groups["name"].Value.Trim();
+ hasDiagnostics = name.Length == 0 ||
+ !IsStrictSectionHeader(raw);
+
+ var approvals = 0;
+ if (m.Groups["cnt"].Success)
+ {
+ if (int.TryParse(m.Groups["cnt"].Value, out var val))
+ {
+ approvals = val;
+ }
+ else
+ {
+ hasDiagnostics = true;
+ }
+ }
+
+ hasDiagnostics |= !required && approvals > 0;
+
+ // Use only the owner text matched by the header. Ignore text after a bad suffix.
+ var defaults = GitLabOwnerTokenizer.TokenizeGitLab(m.Groups["defaults"].Value, out var allDefaultsValid);
+ hasDiagnostics |= !allDefaultsValid;
+ section = new GitLabSection(name, defaults);
+ return true;
+ }
+
+ ///
+ /// Checks whether a line looks like a section header but could not be parsed.
+ ///
+ private static bool IsUnparsableSectionHeader(string raw)
+ => raw.StartsWith("[", StringComparison.Ordinal) || raw.StartsWith("^[", StringComparison.Ordinal);
+
+ ///
+ /// Checks whether a section header follows GitLab's strict syntax.
+ ///
+ private static bool IsStrictSectionHeader(string raw)
+ {
+ var index = raw[0] == '^' ? 1 : 0;
+ if (index >= raw.Length || raw[index] != '[')
+ {
+ return false;
+ }
+
+ var nameStart = ++index;
+ while (index < raw.Length && raw[index] != ']')
+ {
+ index++;
+ }
+
+ if (index == nameStart || index >= raw.Length)
+ {
+ return false;
+ }
+
+ index++;
+ if (index < raw.Length && raw[index] == '[')
+ {
+ var approvalStart = ++index;
+ while (index < raw.Length && char.IsDigit(raw[index]))
+ {
+ index++;
+ }
+
+ if (index == approvalStart || index >= raw.Length || raw[index] != ']')
+ {
+ return false;
+ }
+
+ index++;
+ }
+
+ if (index == raw.Length)
+ {
+ return true;
+ }
+
+ if (!char.IsWhiteSpace(raw[index]))
+ {
+ return false;
+ }
+
+ for (; index < raw.Length; index++)
+ {
+ if (!IsStrictSectionOwnerCharacter(raw[index]))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ /// Checks whether a character is allowed in the owner part of a section header.
+ ///
+ private static bool IsStrictSectionOwnerCharacter(char character)
+ {
+ if (char.IsLetterOrDigit(character) || char.IsWhiteSpace(character) || character is '@' or '.' or '-' or '/')
+ {
+ return true;
+ }
+
+ var category = CharUnicodeInfo.GetUnicodeCategory(character);
+ return category is UnicodeCategory.NonSpacingMark or UnicodeCategory.ConnectorPunctuation;
+ }
+
+ ///
+ /// Finds GitLab users, groups, roles, and emails inside owner text.
+ ///
+ private static class GitLabOwnerTokenizer
+ {
+ ///
+ /// Extracts valid owners, removes duplicates, and reports text without a valid owner.
+ ///
+ public static string[] TokenizeGitLab(string segment, out bool allValid)
+ {
+ if (string.IsNullOrWhiteSpace(segment))
+ {
+ allValid = true;
+ return [];
+ }
+
+ var owners = new List();
+ var uniqueOwners = new HashSet(StringComparer.Ordinal);
+ allValid = true;
+ foreach (var token in segment.Split(OwnerSeparators, StringSplitOptions.RemoveEmptyEntries))
+ {
+ if (!ExtractGitLabOwners(token, owners, uniqueOwners))
+ {
+ allValid = false;
+ }
+ }
+
+ return owners.Count == 0 ? [] : owners.ToArray();
+ }
+
+ ///
+ /// Finds every valid GitLab owner reference inside one token.
+ ///
+ private static bool ExtractGitLabOwners(string token, List owners, HashSet uniqueOwners)
+ {
+ // Handle common complete references without extra parsing.
+ if (IsWholeNamespaceReference(token) || IsValidGitLabRole(token))
+ {
+ AddUniqueOwner(owners, uniqueOwners, token);
+ return true;
+ }
+
+ // GitLab accepts references inside punctuation, such as "(@team)".
+ var foundReference = false;
+ var searchStart = 0;
+ while (TryFindNamespaceReference(token, searchStart, out var referenceStart, out var referenceEnd, out searchStart))
+ {
+ var reference = token.Substring(referenceStart, referenceEnd - referenceStart);
+ AddUniqueOwner(owners, uniqueOwners, reference);
+ foundReference = true;
+ }
+
+ var roleMatches = GitLabRoleReferenceRegex.Matches(token);
+ for (var i = 0; i < roleMatches.Count; i++)
+ {
+ var roleMatch = roleMatches[i];
+ AddUniqueOwner(owners, uniqueOwners, roleMatch.Value);
+ foundReference = true;
+ }
+
+ searchStart = 0;
+ while (TryExtractGitLabEmailReference(token, searchStart, out var emailStart, out var emailEnd, out searchStart))
+ {
+ var email = emailStart == 0 && emailEnd == token.Length
+ ? token
+ : token.Substring(emailStart, emailEnd - emailStart);
+ // Do not add an email when the same text contains a valid group reference.
+ if (!ContainsNamespaceReference(email))
+ {
+ AddUniqueOwner(owners, uniqueOwners, email);
+ foundReference = true;
+ }
+ }
+
+ return foundReference;
+ }
+
+ ///
+ /// Checks whether text contains a GitLab user or group reference.
+ ///
+ private static bool ContainsNamespaceReference(string value)
+ => TryFindNamespaceReference(value, 0, out _, out _, out _);
+
+ ///
+ /// Checks whether a token is one of GitLab's supported role references.
+ ///
+ private static bool IsValidGitLabRole(string token)
+ {
+ if (!token.StartsWith("@@", StringComparison.Ordinal) || token.Length <= 2)
+ {
+ return false;
+ }
+
+ var role = token.Substring(2);
+ return role.Equals("developer", StringComparison.OrdinalIgnoreCase) ||
+ role.Equals("developers", StringComparison.OrdinalIgnoreCase) ||
+ role.Equals("maintainer", StringComparison.OrdinalIgnoreCase) ||
+ role.Equals("maintainers", StringComparison.OrdinalIgnoreCase) ||
+ role.Equals("owner", StringComparison.OrdinalIgnoreCase) ||
+ role.Equals("owners", StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Checks whether the full token is one GitLab user or group reference.
+ ///
+ private static bool IsWholeNamespaceReference(string token)
+ => TryFindNamespaceReference(token, 0, out var start, out var end, out _) && start == 0 && end == token.Length;
+
+ ///
+ /// Finds the next GitLab user or group reference starting at the requested position.
+ ///
+ private static bool TryFindNamespaceReference(
+ string token,
+ int searchStart,
+ out int referenceStart,
+ out int referenceEnd,
+ out int nextSearchStart)
+ {
+ // Find each @ and reject it when the characters around it make it part of another word.
+ for (var atIndex = token.IndexOf('@', searchStart); atIndex >= 0; atIndex = token.IndexOf('@', atIndex + 1))
+ {
+ nextSearchStart = atIndex + 1;
+ if ((atIndex > 0 && (IsWordCharacter(token[atIndex - 1]) || token[atIndex - 1] == '@')) ||
+ atIndex + 1 >= token.Length ||
+ token[atIndex + 1] == '@' ||
+ !IsNamespaceStart(token[atIndex + 1]))
+ {
+ continue;
+ }
+
+ // Read slash-separated namespace parts and remember the last valid end.
+ var segmentStart = atIndex + 1;
+ var lastValidEnd = -1;
+ for (var i = segmentStart; i < token.Length; i++)
+ {
+ var character = token[i];
+ if (character == '/')
+ {
+ if (i == segmentStart || lastValidEnd != i)
+ {
+ break;
+ }
+
+ segmentStart = i + 1;
+ continue;
+ }
+
+ if ((i == segmentStart && !IsNamespaceStart(character)) || !IsNamespaceCharacter(character))
+ {
+ break;
+ }
+
+ if (IsNamespaceEnd(character))
+ {
+ lastValidEnd = i + 1;
+ }
+ }
+
+ if (lastValidEnd > atIndex + 1)
+ {
+ referenceStart = atIndex;
+ referenceEnd = lastValidEnd;
+ nextSearchStart = lastValidEnd;
+ return true;
+ }
+ }
+
+ referenceStart = -1;
+ referenceEnd = -1;
+ nextSearchStart = token.Length;
+ return false;
+ }
+
+ ///
+ /// Finds the next email-like owner reference while enforcing GitLab's length limits.
+ ///
+ private static bool TryExtractGitLabEmailReference(
+ string token,
+ int searchStart,
+ out int referenceStart,
+ out int referenceEnd,
+ out int nextSearchStart)
+ {
+ for (var atIndex = token.IndexOf('@', searchStart); atIndex >= 0; atIndex = token.IndexOf('@', atIndex + 1))
+ {
+ // Read the local part backwards from @.
+ var localStart = atIndex - 1;
+ var localLength = 0;
+ while (localStart >= searchStart &&
+ localLength < 100 &&
+ token[localStart] != '@' &&
+ !char.IsWhiteSpace(token[localStart]))
+ {
+ localStart--;
+ localLength++;
+ }
+
+ localStart++;
+ if (localLength == 0)
+ {
+ continue;
+ }
+
+ // Read the domain forwards and keep the last valid word character.
+ var domainEnd = atIndex + 1;
+ var domainLimit = Math.Min(token.Length, domainEnd + 255);
+ var lastWordEnd = -1;
+ while (domainEnd < domainLimit && token[domainEnd] != '@' && !char.IsWhiteSpace(token[domainEnd]))
+ {
+ if (IsRegexWordCharacter(token[domainEnd]))
+ {
+ lastWordEnd = domainEnd + 1;
+ }
+
+ domainEnd++;
+ }
+
+ if (lastWordEnd <= atIndex + 1)
+ {
+ continue;
+ }
+
+ referenceStart = localStart;
+ referenceEnd = lastWordEnd;
+ nextSearchStart = lastWordEnd;
+ return true;
+ }
+
+ referenceStart = -1;
+ referenceEnd = -1;
+ nextSearchStart = token.Length;
+ return false;
+ }
+
+ ///
+ /// Checks whether a character can start a GitLab namespace segment.
+ ///
+ private static bool IsNamespaceStart(char character)
+ => IsAsciiLetterOrDigit(character) || character is '_' or '.';
+
+ ///
+ /// Checks whether a character can appear inside a GitLab namespace segment.
+ ///
+ private static bool IsNamespaceCharacter(char character)
+ => IsNamespaceStart(character) || character == '-';
+
+ ///
+ /// Checks whether a character can end a GitLab namespace segment.
+ ///
+ private static bool IsNamespaceEnd(char character)
+ => IsAsciiLetterOrDigit(character) || character is '_' or '-';
+
+ ///
+ /// Checks whether a character is a Unicode word character used as a left boundary.
+ ///
+ private static bool IsWordCharacter(char character)
+ => char.IsLetterOrDigit(character) || character == '_';
+
+ ///
+ /// Applies the Unicode word-character rules used by GitLab's email parser.
+ ///
+ private static bool IsRegexWordCharacter(char character)
+ {
+ if (char.IsLetterOrDigit(character))
+ {
+ return true;
+ }
+
+ var category = CharUnicodeInfo.GetUnicodeCategory(character);
+ return category is UnicodeCategory.NonSpacingMark or UnicodeCategory.ConnectorPunctuation;
+ }
+ }
+
+ ///
+ /// Stores GitLab rules grouped into independent sections.
+ ///
+ ///
+ /// See GitLab CODEOWNERS section rules.
+ ///
+ private sealed class GitLabDocument : Document
+ {
+ private readonly GitLabSection[] _sections;
+
+ ///
+ /// Initializes a new instance of the class from parsed sections.
+ ///
+ private GitLabDocument(GitLabSection[] sections)
+ {
+ _sections = sections;
+ }
+
+ ///
+ /// Parses GitLab sections and rules and counts invalid input.
+ ///
+ public static GitLabDocument Parse(IEnumerable lines, out int parsingDiagnosticsCount)
+ {
+ parsingDiagnosticsCount = 0;
+ var sections = new List();
+ // Rules before the first header belong to the unnamed section.
+ var current = GitLabSection.CreateUnnamed();
+ var currentDefaultOwners = current.DefaultOwners;
+ var namedSections = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ sections.Add(current);
+
+ foreach (var line in lines)
+ {
+ var raw = line.Trim();
+ if (raw.Length == 0)
+ {
+ continue;
+ }
+
+ if (TryParseGitLabSectionHeader(raw, out var newSection, out var sectionHasDiagnostics))
+ {
+ if (sectionHasDiagnostics)
+ {
+ parsingDiagnosticsCount++;
+ }
+
+ currentDefaultOwners = newSection.DefaultOwners;
+ if (namedSections.TryGetValue(newSection.Name, out var existingSection))
+ {
+ // Repeated section names add rules to the first section with that name.
+ current = existingSection;
+ }
+ else
+ {
+ current = newSection;
+ sections.Add(current);
+ namedSections.Add(current.Name, current);
+ }
+
+ continue;
+ }
+
+ if (IsUnparsableSectionHeader(raw))
+ {
+ // A malformed header is invalid and must not become a path rule.
+ parsingDiagnosticsCount++;
+ continue;
+ }
+
+ if (raw[0] == '#')
+ {
+ // Comments do not assign owners to paths.
+ continue;
+ }
+
+ var entry = Entry.ParseGitLab(raw, currentDefaultOwners, out var entryHasDiagnostics);
+ if (entry is null)
+ {
+ parsingDiagnosticsCount++;
+ }
+ else
+ {
+ if (entryHasDiagnostics)
+ {
+ parsingDiagnosticsCount++;
+ }
+
+ current.Add(entry);
+ }
+ }
+
+ // Finish each section after all repeated definitions have been joined.
+ foreach (var section in sections)
+ {
+ section.Seal();
+ }
+
+ return new GitLabDocument(sections.ToArray());
+ }
+
+ ///
+ /// Matches each section separately and combines the owners from every matching section.
+ ///
+ public override IEnumerable Match(string path)
+ {
+ // Create the set only after the first section matches.
+ HashSet? owners = null;
+ foreach (var section in _sections)
+ {
+ if (section.TryMatch(path, out var sectionOwners))
+ {
+ owners ??= new HashSet(StringComparer.Ordinal);
+ foreach (var owner in sectionOwners)
+ {
+ owners.Add(owner);
+ }
+ }
+ }
+
+ return owners ?? [];
+ }
+ }
+
+ ///
+ /// Stores the rules and default owners for one GitLab section.
+ ///
+ private sealed class GitLabSection
+ {
+ private readonly List _entries = new();
+ private Entry[]? _cache;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public GitLabSection(string name, string[] defaultOwners)
+ {
+ Name = name;
+ DefaultOwners = defaultOwners.Length == 0 ? [] : defaultOwners;
+ }
+
+ public string Name { get; }
+
+ public string[] DefaultOwners { get; }
+
+ ///
+ /// Creates the section used for rules before the first named header.
+ ///
+ public static GitLabSection CreateUnnamed() => new(string.Empty, []);
+
+ ///
+ /// Adds a parsed rule to this section.
+ ///
+ public void Add(Entry entry) => _entries.Add(entry);
+
+ ///
+ /// Prepares the section for matching by keeping the last rule for each exact pattern.
+ ///
+ public void Seal()
+ {
+ var seenPatterns = new HashSet(StringComparer.Ordinal);
+ var cache = new List(_entries.Count);
+ // Walk backwards because later rules replace earlier rules with the same pattern.
+ for (var i = _entries.Count - 1; i >= 0; i--)
+ {
+ var entry = _entries[i];
+ if (seenPatterns.Add(entry.PatternKey))
+ {
+ cache.Add(entry);
+ }
+ }
+
+ _cache = cache.ToArray();
+ _entries.Clear();
+ }
+
+ ///
+ /// Returns the owners selected by this section, unless a matching exclusion removes the path.
+ ///
+ ///
+ /// See GitLab CODEOWNERS exclusion rules.
+ ///
+ public bool TryMatch(string path, [NotNullWhen(true)] out string[]? owners)
+ {
+ var rules = _cache ?? [];
+ string[]? matchedOwners = null;
+
+ foreach (var rule in rules)
+ {
+ if (!rule.Match(path))
+ {
+ continue;
+ }
+
+ if (rule.IsExclusion)
+ {
+ // An exclusion applies only to this section.
+ owners = null;
+ return false;
+ }
+
+ // Rules are stored last-first, so the first normal match wins.
+ matchedOwners ??= rule.Owners;
+ }
+
+ if (matchedOwners is null || matchedOwners.Length == 0)
+ {
+ owners = null;
+ return false;
+ }
+
+ owners = matchedOwners;
+ return true;
+ }
+ }
+
+ private sealed partial class Entry
+ {
+ ///
+ /// Parses one GitLab rule, applies section defaults, and compiles its path pattern.
+ ///
+ public static Entry? ParseGitLab(string raw, string[] defaultOwners, out bool hasDiagnostics)
+ {
+ hasDiagnostics = false;
+ SplitEscapedEntry(raw, out var patternToken, out var ownersSegment, out var hasExplicitOwners);
+
+ var isExclusion = patternToken.StartsWith("!");
+ if (isExclusion)
+ {
+ patternToken = patternToken.Substring(1, patternToken.Length - 1);
+ }
+
+ if (patternToken.Length == 0)
+ {
+ return null;
+ }
+
+ var allOwnersValid = true;
+ var owners = isExclusion
+ ? []
+ : GitLabOwnerTokenizer.TokenizeGitLab(ownersSegment, out allOwnersValid);
+ hasDiagnostics = !isExclusion && !allOwnersValid;
+
+ if (!isExclusion && !hasExplicitOwners && defaultOwners.Length > 0)
+ {
+ // A rule without owners inherits the current section defaults.
+ owners = defaultOwners;
+ }
+
+ if (!isExclusion && owners.Length == 0)
+ {
+ hasDiagnostics = true;
+ }
+
+ var glob = GlobPattern.CompileGitLab(patternToken);
+ if (glob is null)
+ {
+ return null;
+ }
+
+ return new Entry(glob, NormalizeGitLabPatternKey(patternToken), isExclusion, owners);
+ }
+
+ ///
+ /// Normalizes equivalent GitLab patterns so later duplicates can replace earlier ones.
+ ///
+ private static string NormalizeGitLabPatternKey(string patternToken)
+ {
+ if (patternToken == "*")
+ {
+ return "/**/*";
+ }
+
+ var normalizedToken = NormalizeGitLabEscapes(patternToken);
+ var normalized = normalizedToken.StartsWith("/") ? normalizedToken : "/**/" + normalizedToken;
+ return normalized.EndsWith("/") ? normalized + "**/*" : normalized;
+ }
+
+ ///
+ /// Removes GitLab escapes for a leading hash and for whitespace.
+ ///
+ private static string NormalizeGitLabEscapes(string patternToken)
+ {
+ StringBuilder? builder = null;
+ var copyStart = 0;
+ for (var i = 0; i + 1 < patternToken.Length; i++)
+ {
+ var unescape = i == 0 && patternToken[i] == '\\' && patternToken[i + 1] == '#';
+ unescape |= patternToken[i] == '\\' && char.IsWhiteSpace(patternToken[i + 1]);
+ if (!unescape)
+ {
+ continue;
+ }
+
+ builder ??= new StringBuilder(patternToken.Length);
+ builder.Append(patternToken, copyStart, i - copyStart);
+ copyStart = i + 1;
+ }
+
+ if (builder is null)
+ {
+ return patternToken;
+ }
+
+ builder.Append(patternToken, copyStart, patternToken.Length - copyStart);
+ return builder.ToString();
+ }
+ }
+ }
+}
diff --git a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs
index ef1d69d2bd54..c70e31cb35d5 100644
--- a/tracer/src/Datadog.Trace/Ci/CodeOwners.cs
+++ b/tracer/src/Datadog.Trace/Ci/CodeOwners.cs
@@ -8,23 +8,26 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
-using System.Linq;
-using System.Text.RegularExpressions;
+using System.Security;
+using System.Text;
+using Datadog.Trace.Logging;
namespace Datadog.Trace.Ci
{
///
- /// A feature‑complete, allocation‑conscious CODEOWNERS parser that supports both GitHub and GitLab
- /// semantics (sections, exclusions, optional sections, approval counts, role owners, globstar, etc.).
- /// Usage:
- /// var owners = new CodeOwners(pathToFile, CodeOwners.Platform.GitLab).Match("src/app/Program.cs");
- /// // owners is an IEnumerable{string} of unique owners that apply to that file.
+ /// Parses and matches GitHub and GitLab CODEOWNERS files.
///
- internal sealed class CodeOwners
+ internal sealed partial class CodeOwners
{
- private readonly IReadOnlyList _sections;
- private readonly Platform _platform;
+ internal const long GitHubMaximumFileSizeBytes = 3 * 1024 * 1024;
+ private static readonly char[] OwnerSeparators = [' ', '\t'];
+ private static readonly IDatadogLogger Log = DatadogLogging.GetLoggerFor();
+ private readonly Document _document;
+
+ ///
+ /// Initializes a new instance of the class and loads the selected platform rules.
+ ///
public CodeOwners(string filePath, Platform platform)
{
if (string.IsNullOrEmpty(filePath))
@@ -32,8 +35,54 @@ public CodeOwners(string filePath, Platform platform)
throw new ArgumentNullException(nameof(filePath));
}
- _platform = platform;
- _sections = Parse(File.ReadLines(filePath), platform);
+ // GitHub ignores CODEOWNERS files larger than 3 MB.
+ // https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners#codeowners-file-size
+ if (platform == Platform.GitHub && new FileInfo(filePath).Length > GitHubMaximumFileSizeBytes)
+ {
+ _document = GitHubDocument.Empty;
+ Log.Warning(
+ "GitHub CODEOWNERS file exceeds the {MaximumSize} byte limit and will be ignored: {Path}",
+ GitHubMaximumFileSizeBytes,
+ filePath);
+ return;
+ }
+
+ int parsingDiagnosticsCount;
+ _document = platform switch
+ {
+ Platform.GitHub => GitHubDocument.Parse(File.ReadLines(filePath), out parsingDiagnosticsCount),
+ Platform.GitLab => GitLabDocument.Parse(File.ReadLines(filePath), out parsingDiagnosticsCount),
+ _ => throw new ArgumentOutOfRangeException(nameof(platform)),
+ };
+
+ ParsingDiagnosticsCount = parsingDiagnosticsCount;
+ if (parsingDiagnosticsCount > 0)
+ {
+ Log.Warning(
+ "CODEOWNERS file contains {Count} invalid lines. Invalid rules were ignored or parsed with errors: {Path}",
+ parsingDiagnosticsCount,
+ filePath);
+ }
+ }
+
+ internal int ParsingDiagnosticsCount { get; }
+
+ ///
+ /// Tries to load a CODEOWNERS file and handles file access errors.
+ ///
+ internal static bool TryLoad(string filePath, Platform platform, [NotNullWhen(true)] out CodeOwners? codeOwners)
+ {
+ try
+ {
+ codeOwners = new CodeOwners(filePath, platform);
+ return true;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException)
+ {
+ Log.Warning(ex, "Unable to load CODEOWNERS file; ownership matching will be skipped: {Path}", filePath);
+ codeOwners = null;
+ return false;
+ }
}
///
@@ -42,229 +91,602 @@ public CodeOwners(string filePath, Platform platform)
///
public IEnumerable Match(string path)
{
- var owners = new HashSet(StringComparer.Ordinal);
+ if (path is null)
+ {
+ // Returning no owners keeps this method safe if a caller passes null.
+ return [];
+ }
+
var normalizedPath = path.IndexOf('\\') >= 0 ? path.Replace('\\', '/') : path;
- foreach (var section in _sections)
+ // Rooted patterns are anchored to the repository root, so collapse any leading slashes:
+ // "", "/", and "//C:/file" all normalize to a single rooted form.
+ normalizedPath = "/" + normalizedPath.TrimStart('/');
+
+ return _document.Match(normalizedPath);
+ }
+
+ ///
+ /// Adds an owner once while keeping the original order.
+ ///
+ private static void AddUniqueOwner(List owners, HashSet uniqueOwners, string owner)
+ {
+ if (uniqueOwners.Add(owner))
{
- if (section.TryMatch(normalizedPath, _platform, out var sectionOwners))
- {
- foreach (var o in sectionOwners)
- {
- owners.Add(o);
- }
- }
+ owners.Add(owner);
+ }
+ }
+
+ ///
+ /// Checks whether a character is an ASCII letter or digit.
+ ///
+ private static bool IsAsciiLetterOrDigit(char character)
+ => character is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9';
+
+#pragma warning disable SA1201
+ ///
+ /// Identifies the CODEOWNERS syntax to use.
+ ///
+ public enum Platform
+#pragma warning restore SA1201
+ {
+ GitHub,
+ GitLab
+ }
+
+ private enum CharacterClassParseResult
+ {
+ NotAClass,
+ Success,
+ Invalid
+ }
+
+ ///
+ /// Represents either one path segment pattern or a globstar that can consume directories.
+ ///
+ private readonly struct GlobPathSegment
+ {
+ private readonly SegmentPattern? _segment;
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ private GlobPathSegment(SegmentPattern? segment, bool isGlobStar, bool requiresSegment)
+ {
+ _segment = segment;
+ IsGlobStar = isGlobStar;
+ RequiresSegment = requiresSegment;
}
- return owners;
+ public bool IsGlobStar { get; }
+
+ public bool RequiresSegment { get; }
+
+ ///
+ /// Creates a globstar that consumes zero or more path segments, or at least one when required.
+ ///
+ public static GlobPathSegment GlobStar(bool requiresSegment) => new(null, isGlobStar: true, requiresSegment: requiresSegment);
+
+ ///
+ /// Wraps a normal compiled segment pattern.
+ ///
+ public static GlobPathSegment Pattern(SegmentPattern segment) => new(segment, isGlobStar: false, requiresSegment: false);
+
+ ///
+ /// Checks whether this normal segment pattern matches the selected part of a path.
+ ///
+ public bool Matches(string path, int start, int end) => _segment!.IsMatch(path, start, end);
}
- private static List Parse(IEnumerable lines, Platform platform)
+ ///
+ /// Matches a full repository path by combining normal segment patterns and globstars.
+ ///
+ private sealed class GlobPattern
{
- var sections = new List();
- var current = Section.CreateUnnamed();
- sections.Add(current);
+ private readonly GlobPathSegment[] _segments;
- var lineNo = 0;
- foreach (var line in lines)
+ ///
+ /// Initializes a new instance of the class from compiled segments.
+ ///
+ private GlobPattern(GlobPathSegment[] segments)
{
- lineNo++;
- var raw = line.TrimEnd();
- if (raw.Length == 0)
+ _segments = segments;
+ }
+
+ ///
+ /// Compiles a pattern with GitHub rules.
+ ///
+ ///
+ /// See GitHub CODEOWNERS syntax.
+ ///
+ public static GlobPattern? CompileGitHub(string pattern, bool includeDescendants)
+ => Compile(pattern, Platform.GitHub, includeDescendants);
+
+ ///
+ /// Compiles a pattern with GitLab rules.
+ ///
+ ///
+ /// See GitLab CODEOWNERS path matching.
+ ///
+ public static GlobPattern? CompileGitLab(string pattern)
+ => Compile(pattern, Platform.GitLab, includeDescendants: false);
+
+ ///
+ /// Splits a pattern into path segments and compiles each segment with the selected platform rules.
+ ///
+ private static GlobPattern? Compile(string pattern, Platform platform, bool includeDescendants)
+ {
+ var rawSegments = SplitPattern(pattern, out var firstSeparator, out var hasTrailingSlash);
+ var rooted = (rawSegments.Length > 0 && rawSegments[0].Length == 0) ||
+ (platform == Platform.GitHub && firstSeparator >= 0 && firstSeparator < pattern.Length - 1);
+ var firstSegment = rooted && rawSegments.Length > 0 && rawSegments[0].Length == 0 ? 1 : 0;
+ var lastSegment = rawSegments.Length;
+ if (hasTrailingSlash && lastSegment > firstSegment && rawSegments[lastSegment - 1].Length == 0)
{
- continue;
+ lastSegment--;
}
- if (TryParseSectionHeader(raw, out var newSection))
+ var segments = new List(rawSegments.Length + 2);
+ if (!rooted)
{
- current = newSection;
- sections.Add(current);
- continue;
+ AddGlobStar(segments, requiresSegment: false);
}
- if (raw[0] == '#')
+ for (var i = firstSegment; i < lastSegment; i++)
{
- // Comment line. GitLab parses owners found inside comments so they appear in MR widget,
- // but those owners are not bound to any path pattern, so we ignore them for matching.
- continue;
+ if (rawSegments[i] == "**" &&
+ !(platform == Platform.GitLab && i == lastSegment - 1))
+ {
+ // On GitHub, a final /** must match at least one child segment.
+ // On GitLab, a final ** works like * inside the last segment.
+ // A middle ** can match zero or more segments on both platforms.
+ AddGlobStar(segments, requiresSegment: i == lastSegment - 1);
+ }
+ else if (SegmentPattern.TryCompile(rawSegments[i], platform, out var segment))
+ {
+ segments.Add(GlobPathSegment.Pattern(segment));
+ }
+ else
+ {
+ // Ignore the full rule when one segment is invalid.
+ return null;
+ }
}
- var entry = Entry.Parse(raw, platform, lineNo);
- if (entry is not null)
+ if (hasTrailingSlash || includeDescendants)
{
- current.Add(entry);
+ // A trailing slash means a directory and must match a child path.
+ // A plain GitHub directory name may also be a file, so its child match is optional.
+ AddGlobStar(segments, requiresSegment: hasTrailingSlash);
}
+
+ return new GlobPattern(segments.ToArray());
}
- // Last‑rule precedence: iterate rules in reverse order at run‑time without additional copies.
- foreach (var s in sections)
+ ///
+ /// Splits a pattern on path separators while keeping escaped characters inside their segment.
+ ///
+ private static string[] SplitPattern(string pattern, out int firstSeparator, out bool hasTrailingSlash)
{
- s.Seal();
- }
+ var segments = new List();
+ var segment = new StringBuilder(pattern.Length);
+ firstSeparator = -1;
+ hasTrailingSlash = false;
- return sections;
- }
+ for (var i = 0; i < pattern.Length; i++)
+ {
+ var character = pattern[i];
+ if (character == '\\' && i + 1 < pattern.Length)
+ {
+ var escapedCharacter = pattern[i + 1];
+ if (escapedCharacter != '/')
+ {
+ // Keep this escape so SegmentPattern can process it.
+ segment.Append(character);
+ segment.Append(escapedCharacter);
+ i++;
+ hasTrailingSlash = false;
+ continue;
+ }
+
+ // An escaped slash is still a path separator.
+ i++;
+ }
+ else if (character != '/')
+ {
+ segment.Append(character);
+ hasTrailingSlash = false;
+ continue;
+ }
- private static bool TryParseSectionHeader(string raw, [NotNullWhen(true)] out Section? section)
- {
- // Accepted forms:
- // [Docs]
- // ^[Go]
- // [Backend][2] @team @another
- var m = Regex.Match(raw, @"^\s*(\^)?\[(?[^\]]+)\](?:\[(?\d+)\])?(?.*)$");
- if (!m.Success)
- {
- section = null;
- return false;
+ firstSeparator = firstSeparator < 0 ? i : firstSeparator;
+ segments.Add(segment.ToString());
+ segment.Clear();
+ hasTrailingSlash = i == pattern.Length - 1;
+ }
+
+ segments.Add(segment.ToString());
+ return segments.ToArray();
}
- var required = !m.Groups[1].Success; // ^ prefix => optional section
- var name = m.Groups["name"].Value.Trim();
- var approvals = 0;
- if (m.Groups["cnt"].Success && int.TryParse(m.Groups["cnt"].Value, out var val))
+ ///
+ /// Matches path segments from left to right and lets the latest globstar consume more segments when needed.
+ ///
+ public bool IsMatch(string path)
{
- approvals = val;
- }
+ var patternIndex = 0;
+ var pathSegmentStart = path.Length > 1 ? 1 : -1;
+ var globStarIndex = -1;
+ var globStarPathStart = -1;
- var defaults = OwnerTokenizer.Tokenize(m.Groups["rest"].Value).ToArray();
- section = new Section(name, required, approvals, defaults);
- return true;
- }
+ while (pathSegmentStart >= 0)
+ {
+ if (patternIndex < _segments.Length && _segments[patternIndex].IsGlobStar)
+ {
+ var globStar = _segments[patternIndex];
+ globStarIndex = patternIndex++;
+ if (globStar.RequiresSegment)
+ {
+ // Consume the required first segment immediately. If the remainder
+ // fails, the fallback below grows the same globstar one segment at a time.
+ var requiredSegmentEnd = GetSegmentEnd(path, pathSegmentStart);
+ pathSegmentStart = GetNextSegmentStart(path, requiredSegmentEnd);
+ globStarPathStart = pathSegmentStart;
+ }
+ else
+ {
+ // First try the zero-directory interpretation.
+ globStarPathStart = pathSegmentStart;
+ }
- ///
- /// Converts a CODEOWNERS ‑style glob into a Regex.
- /// Supports **, *, ?, /‑rooted, and trailing / semantics.
- ///
- private static Regex CompileGlob(string pattern)
- {
- // Escape regex metachars first.
- var rx = Regex.Escape(pattern);
+ continue;
+ }
- // Temporary sentinel for ** that we restore after dealing with single *.
- rx = rx.Replace("\\*\\*", "§§DOUBLESTAR§§");
- rx = rx.Replace("\\*", "[^/]*"); // single‑level wildcard
- rx = rx.Replace("§§DOUBLESTAR§§", ".*"); // multi‑level wildcard
- rx = rx.Replace("\\?", "."); // single char
+ var pathSegmentEnd = GetSegmentEnd(path, pathSegmentStart);
+ if (patternIndex < _segments.Length &&
+ !_segments[patternIndex].IsGlobStar &&
+ _segments[patternIndex].Matches(path, pathSegmentStart, pathSegmentEnd))
+ {
+ patternIndex++;
+ pathSegmentStart = GetNextSegmentStart(path, pathSegmentEnd);
+ continue;
+ }
- if (pattern.EndsWith("/"))
- {
- rx += ".*"; // directory pattern matches everything underneath
+ if (globStarIndex < 0 || globStarPathStart < 0)
+ {
+ return false;
+ }
+
+ var globStarSegmentEnd = GetSegmentEnd(path, globStarPathStart);
+ globStarPathStart = GetNextSegmentStart(path, globStarSegmentEnd);
+ pathSegmentStart = globStarPathStart;
+ patternIndex = globStarIndex + 1;
+ }
+
+ while (patternIndex < _segments.Length &&
+ _segments[patternIndex].IsGlobStar &&
+ !_segments[patternIndex].RequiresSegment)
+ {
+ patternIndex++;
+ }
+
+ return patternIndex == _segments.Length;
}
- if (pattern.StartsWith("/"))
+ ///
+ /// Adds a globstar and merges it with the previous one when they are next to each other.
+ ///
+ private static void AddGlobStar(List segments, bool requiresSegment)
{
- // keep the escaped leading slash so paths like "/foo/bar" match
- rx = "^" + rx;
+ if (segments.Count > 0 && segments[segments.Count - 1].IsGlobStar)
+ {
+ // Adjacent globstars become one. If either needs a segment, the result does too.
+ if (requiresSegment && !segments[segments.Count - 1].RequiresSegment)
+ {
+ segments[segments.Count - 1] = GlobPathSegment.GlobStar(requiresSegment: true);
+ }
+
+ return;
+ }
+
+ segments.Add(GlobPathSegment.GlobStar(requiresSegment));
}
- else
+
+ ///
+ /// Finds the end of the current path segment.
+ ///
+ private static int GetSegmentEnd(string path, int segmentStart)
{
- // Allowed anywhere in repo tree; use non‑capturing look‑behind to avoid double counting.
- rx = "(^|.*/)" + rx;
+ var separator = path.IndexOf('/', segmentStart);
+ return separator >= 0 ? separator : path.Length;
}
- rx += "$";
- return new Regex(rx, RegexOptions.Compiled | RegexOptions.CultureInvariant);
+ ///
+ /// Returns the start of the next path segment, or -1 when there is no next segment.
+ ///
+ private static int GetNextSegmentStart(string path, int segmentEnd)
+ => segmentEnd < path.Length - 1 ? segmentEnd + 1 : -1;
}
-#pragma warning disable SA1201
- public enum Platform
-#pragma warning restore SA1201
+ ///
+ /// Matches one path segment using literals, escapes, wildcards, and GitLab character classes.
+ ///
+ private sealed class SegmentPattern
{
- GitHub,
- GitLab
- }
+ private const int MaximumPatternLength = 1_024;
+ private const int MaximumMatchSteps = 65_536;
- private static class OwnerTokenizer
- {
- public static IEnumerable Tokenize(string segment)
+ private readonly string _pattern;
+ private readonly Platform _platform;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ private SegmentPattern(string pattern, Platform platform)
{
- if (string.IsNullOrWhiteSpace(segment))
+ _pattern = pattern;
+ _platform = platform;
+ }
+
+ ///
+ /// Validates one segment and compiles it when its syntax and size are safe.
+ ///
+ public static bool TryCompile(string pattern, Platform platform, [NotNullWhen(true)] out SegmentPattern? segment)
+ {
+ if (pattern.Length > MaximumPatternLength)
{
- yield break;
+ segment = null;
+ return false;
}
- foreach (var token in segment.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries))
+ for (var i = 0; i < pattern.Length; i++)
{
- if (token.StartsWith("@@") || token.StartsWith("@") || token.Contains("@"))
+ var character = pattern[i];
+ if (character == '\\')
+ {
+ if (i + 1 >= pattern.Length)
+ {
+ segment = null;
+ return false;
+ }
+
+ i++;
+ }
+ else if (platform == Platform.GitLab && character == '[')
{
- yield return token;
+ var result = TryParseCharacterClass(pattern, i, default, evaluate: false, out var closingBracket, out _);
+ if (result == CharacterClassParseResult.Invalid)
+ {
+ segment = null;
+ return false;
+ }
+
+ if (result == CharacterClassParseResult.Success)
+ {
+ i = closingBracket;
+ }
}
}
- }
- }
- private sealed class Section
- {
- private readonly List _entries = new();
- private Entry[]? _cache;
+ segment = new SegmentPattern(pattern, platform);
+ return true;
+ }
- public Section(string name, bool required, int approvalCount, string[] defaultOwners)
+ ///
+ /// Parses a GitLab character class and optionally checks whether it contains a character.
+ ///
+ private static CharacterClassParseResult TryParseCharacterClass(
+ string pattern,
+ int openingBracket,
+ char value,
+ bool evaluate,
+ out int closingBracket,
+ out bool matches)
{
- Name = name;
- Required = required;
- ApprovalCount = approvalCount;
- DefaultOwners = defaultOwners.Length == 0 ? [] : defaultOwners;
- }
+ closingBracket = -1;
+ matches = false;
+ var contentStart = openingBracket + 1;
+ // GitLab allows ! or ^ after [ to negate the class.
+ var negated = contentStart < pattern.Length && pattern[contentStart] is '!' or '^';
+ var atomStart = negated ? contentStart + 1 : contentStart;
+ if (atomStart < pattern.Length && pattern[atomStart] == ']')
+ {
+ return CharacterClassParseResult.Invalid;
+ }
- public string Name { get; }
+ // Find the first closing bracket that is not escaped.
+ for (var i = atomStart; i < pattern.Length; i++)
+ {
+ if (pattern[i] == '\\' && i + 1 < pattern.Length)
+ {
+ i++;
+ }
+ else if (pattern[i] == ']')
+ {
+ closingBracket = i;
+ break;
+ }
+ }
- public bool Required { get; }
+ if (closingBracket < 0)
+ {
+ return CharacterClassParseResult.NotAClass;
+ }
- public int ApprovalCount { get; }
+ if (atomStart == closingBracket)
+ {
+ return CharacterClassParseResult.Invalid;
+ }
- public string[] DefaultOwners { get; }
+ var atomIndex = atomStart;
+ // Each item is either one character or a start-end range.
+ while (atomIndex < closingBracket)
+ {
+ var rangeStart = ReadCharacterClassAtom(pattern, ref atomIndex, closingBracket, out _);
+ var lookahead = atomIndex;
+ var separatorEscaped = false;
+ var separator = lookahead < closingBracket
+ ? ReadCharacterClassAtom(pattern, ref lookahead, closingBracket, out separatorEscaped)
+ : default;
+ if (separator == '-' && !separatorEscaped && lookahead < closingBracket)
+ {
+ var rangeEnd = ReadCharacterClassAtom(pattern, ref lookahead, closingBracket, out _);
+ if (rangeStart > rangeEnd)
+ {
+ return CharacterClassParseResult.Invalid;
+ }
+
+ if (evaluate && value >= rangeStart && value <= rangeEnd)
+ {
+ matches = true;
+ }
+
+ atomIndex = lookahead;
+ }
+ else if (evaluate && value == rangeStart)
+ {
+ matches = true;
+ }
+ }
- public static Section CreateUnnamed() => new(string.Empty, required: true, approvalCount: 0, defaultOwners: []);
+ matches = negated ? !matches : matches;
+ return CharacterClassParseResult.Success;
+ }
- public void Add(Entry entry) => _entries.Add(entry);
+ ///
+ /// Reads one literal or escaped character from a character class.
+ ///
+ private static char ReadCharacterClassAtom(
+ string pattern,
+ ref int index,
+ int closingBracket,
+ out bool escaped)
+ {
+ escaped = pattern[index] == '\\' && index + 1 < closingBracket;
+ if (escaped)
+ {
+ index++;
+ }
- public void Seal() => _cache = _entries.AsEnumerable().Reverse().ToArray();
+ return pattern[index++];
+ }
- public bool TryMatch(string path, Platform platform, out IEnumerable owners)
+ ///
+ /// Matches one path segment and backtracks only to the latest star when a token fails.
+ ///
+ public bool IsMatch(string path, int start, int end)
{
- owners = [];
- var rules = _cache ?? [];
+ var patternIndex = 0;
+ var pathIndex = start;
+ var starPatternIndex = -1;
+ var starPathIndex = -1;
+ var remainingSteps = MaximumMatchSteps;
- foreach (var rule in rules)
+ while (pathIndex < end)
{
- // GitHub doesn’t support exclusion rules. Keep them parse‑able but ignore when evaluating.
- if (rule.IsExclusion && platform == Platform.GitHub)
+ if (remainingSteps-- == 0)
{
+ // Stop malformed patterns from using too much CPU.
+ return false;
+ }
+
+ if (patternIndex < _pattern.Length && _pattern[patternIndex] == '*')
+ {
+ // First let the star match zero characters.
+ do
+ {
+ patternIndex++;
+ }
+ while (patternIndex < _pattern.Length && _pattern[patternIndex] == '*');
+
+ starPatternIndex = patternIndex;
+ starPathIndex = pathIndex;
continue;
}
- if (!rule.Match(path))
+ if (TryMatchToken(patternIndex, path[pathIndex], out var nextPatternIndex))
{
+ patternIndex = nextPatternIndex;
+ pathIndex++;
continue;
}
- if (rule.IsExclusion)
+ if (starPatternIndex < 0)
{
- // Excluded for this section; stop evaluating this section.
return false;
}
- owners = rule.Owners.Length > 0 ? rule.Owners : DefaultOwners;
- return owners.Any();
+ // Retry the latest star after letting it consume one more character.
+ patternIndex = starPatternIndex;
+ pathIndex = ++starPathIndex;
}
- if (DefaultOwners.Length > 0)
+ while (patternIndex < _pattern.Length && _pattern[patternIndex] == '*')
{
- owners = DefaultOwners;
- return true;
+ patternIndex++;
}
- return false;
+ return patternIndex == _pattern.Length;
+ }
+
+ ///
+ /// Matches one pattern token against one path character.
+ ///
+ private bool TryMatchToken(int patternIndex, char value, out int nextPatternIndex)
+ {
+ if (patternIndex >= _pattern.Length)
+ {
+ nextPatternIndex = patternIndex;
+ return false;
+ }
+
+ var token = _pattern[patternIndex];
+ if (token == '\\')
+ {
+ nextPatternIndex = patternIndex + 2;
+ return _pattern[patternIndex + 1] == value;
+ }
+
+ if (_platform == Platform.GitLab && token == '[')
+ {
+ var result = TryParseCharacterClass(_pattern, patternIndex, value, evaluate: true, out var closingBracket, out var matches);
+ if (result == CharacterClassParseResult.Success)
+ {
+ nextPatternIndex = closingBracket + 1;
+ return matches;
+ }
+ }
+
+ nextPatternIndex = patternIndex + 1;
+ return token == '?' || token == value;
}
}
- private sealed class Entry
+ ///
+ /// Defines how one platform evaluates its parsed rules.
+ ///
+ private abstract class Document
+ {
+ ///
+ /// Returns the owners that apply to a normalized repository path.
+ ///
+ public abstract IEnumerable Match(string path);
+ }
+
+ ///
+ /// Stores one compiled CODEOWNERS rule.
+ ///
+ private sealed partial class Entry
{
- private readonly Regex _regex;
+ private readonly GlobPattern _glob;
- private Entry(Regex regex, bool exclusion, string[] owners)
+ ///
+ /// Initializes a new instance of the class with a compiled pattern and owners.
+ ///
+ private Entry(GlobPattern glob, string patternKey, bool exclusion, string[] owners)
{
- _regex = regex;
+ _glob = glob;
+ PatternKey = patternKey;
IsExclusion = exclusion;
Owners = owners;
}
@@ -273,58 +695,36 @@ private Entry(Regex regex, bool exclusion, string[] owners)
public string[] Owners { get; }
- public static Entry? Parse(string raw, Platform platform, int lineNo)
- {
- // Strip inline comments for GitHub. GitLab treats everything after # as data (inline comments unsupported).
- var idxHash = raw.IndexOf('#');
- var effective = idxHash >= 0 && platform == Platform.GitHub ? raw.Substring(0, idxHash).TrimEnd() : raw;
- if (string.IsNullOrWhiteSpace(effective))
- {
- return null;
- }
+ public string PatternKey { get; }
- // 2. Tokenise
- // * GitHub: simple whitespace split
- // * GitLab: split on whitespace NOT escaped with back-slash
- string[] tokens;
-
- if (platform == Platform.GitLab)
- {
- // Split on space / tab that are **not** escaped: (? t.Length > 0)
- // Undo the escaping: "\ " → " ", "\#" → "#", "\\" → "\"
- .Select(t => Regex.Replace(t, @"\\([ #\\])", "$1"))
- .ToArray();
- }
- else
- {
- tokens = effective.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries);
- }
-
- if (tokens.Length == 0)
- {
- return null;
- }
-
- // 3. Pattern & exclusion
- var patternToken = tokens[0];
- var isExclusion = platform == Platform.GitLab && patternToken.StartsWith("!");
- if (isExclusion)
+ ///
+ /// Splits a rule at its first unescaped whitespace into the pattern and owner text.
+ ///
+ private static void SplitEscapedEntry(string entry, out string pattern, out string owners, out bool hasExplicitOwners)
+ {
+ var patternEnd = entry.Length;
+ for (var i = 0; i < entry.Length; i++)
{
- patternToken = patternToken.Substring(1, patternToken.Length - 1);
+ if (entry[i] == '\\' && i + 1 < entry.Length)
+ {
+ i++;
+ }
+ else if (entry[i] is ' ' or '\t')
+ {
+ patternEnd = i;
+ break;
+ }
}
- // 4. Owners (validate through OwnerTokenizer to drop any bogus tokens)
- var ownersSegment = tokens.Length > 1 ? string.Join(" ", tokens.Skip(1)) : string.Empty;
- var owners = OwnerTokenizer.Tokenize(ownersSegment).ToArray();
-
- // 5. Compile the glob
- var rx = CompileGlob(patternToken);
- return new Entry(rx, isExclusion, owners);
+ pattern = entry.Substring(0, patternEnd);
+ owners = patternEnd < entry.Length ? entry.Substring(patternEnd).Trim() : string.Empty;
+ hasExplicitOwners = owners.Length > 0;
}
- public bool Match(string path) => _regex.IsMatch(path);
+ ///
+ /// Checks whether this rule matches a normalized repository path.
+ ///
+ public bool Match(string path) => _glob.IsMatch(path);
}
}
}
diff --git a/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/CI/CodeOwnersFallbackTests.cs b/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/CI/CodeOwnersFallbackTests.cs
deleted file mode 100644
index 02a9bb77351d..000000000000
--- a/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/CI/CodeOwnersFallbackTests.cs
+++ /dev/null
@@ -1,293 +0,0 @@
-//
-// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License.
-// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc.
-//
-#nullable enable
-
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using Datadog.Trace.Ci.CiEnvironment;
-using Datadog.Trace.Configuration;
-using Xunit;
-
-namespace Datadog.Trace.ClrProfiler.IntegrationTests.CI
-{
- public class CodeOwnersFallbackTests
- {
- private const string CommitSha = "3245605c3d1edc67226d725799ee969c71f7632b";
-
- [SkippableFact]
- public void UsesFallbackRootWhenSourceRootIsDifferent()
- {
- using var tempDirectory = new TemporaryDirectory();
- var repoRoot = tempDirectory.RootPath;
- var srcDir = Path.Combine(repoRoot, "src");
- Directory.CreateDirectory(srcDir);
- var sourceFile = Path.Combine(srcDir, "SpanBenchmark.cs");
- File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @global\n/src/ @owner\n");
- File.WriteAllText(sourceFile, "class SpanBenchmark {}");
-
- var env = new Dictionary
- {
- [PlatformKeys.Ci.GitHub.Sha] = CommitSha,
- [PlatformKeys.Ci.GitHub.Workspace] = Path.Combine(repoRoot, "other"),
- [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet",
- };
-
- var ciValues = CIEnvironmentValues.Create(env);
- var relative = ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false);
-
- Assert.Equal("src/SpanBenchmark.cs", relative);
-
- var owners = ciValues.CodeOwners!.Match("/" + relative).OrderBy(o => o).ToArray();
- Assert.Equal(new[] { "@owner" }, owners);
- }
-
- [SkippableFact]
- public void UsesFallbackRootWhenSourceRootIsSubdirectory()
- {
- using var tempDirectory = new TemporaryDirectory();
- var repoRoot = tempDirectory.RootPath;
- var srcDir = Path.Combine(repoRoot, "src");
- Directory.CreateDirectory(srcDir);
- var sourceFile = Path.Combine(srcDir, "SpanBenchmark.cs");
- File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @global\n/src/ @owner\n");
- File.WriteAllText(sourceFile, "class SpanBenchmark {}");
-
- var env = new Dictionary
- {
- [PlatformKeys.Ci.GitHub.Sha] = CommitSha,
- [PlatformKeys.Ci.GitHub.Workspace] = srcDir,
- [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet",
- };
-
- var ciValues = CIEnvironmentValues.Create(env);
- var relative = ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false);
-
- Assert.Equal("src/SpanBenchmark.cs", relative);
-
- var owners = ciValues.CodeOwners!.Match("/" + relative).OrderBy(o => o).ToArray();
- Assert.Equal(new[] { "@owner" }, owners);
- }
-
- [SkippableFact]
- public void DoesNotUseCurrentDirectoryForRelativeSourceFile()
- {
- using var tempDirectory = new TemporaryDirectory();
- var repoRoot = tempDirectory.RootPath;
- var srcDir = Path.Combine(repoRoot, "src");
- Directory.CreateDirectory(srcDir);
- var sourceFile = Path.Combine(srcDir, "SpanBenchmark.cs");
- File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @owner\n");
- File.WriteAllText(sourceFile, "class SpanBenchmark {}");
-
- var env = new Dictionary
- {
- [PlatformKeys.Ci.GitHub.Sha] = CommitSha,
- [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet",
- };
-
- var originalDirectory = Environment.CurrentDirectory;
- Environment.CurrentDirectory = repoRoot;
- try
- {
- var ciValues = CIEnvironmentValues.Create(env);
- var relative = ciValues.MakeRelativePathFromSourceRootWithFallback("src/SpanBenchmark.cs", false);
-
- Assert.Equal("src/SpanBenchmark.cs", relative);
- Assert.Null(ciValues.CodeOwners);
- }
- finally
- {
- Environment.CurrentDirectory = originalDirectory;
- }
- }
-
- [SkippableFact]
- public void AllowsFallbackRetryWithDifferentStartPath()
- {
- using var repoDirectory = new TemporaryDirectory();
- using var otherDirectory = new TemporaryDirectory();
-
- var repoRoot = repoDirectory.RootPath;
- var srcDir = Path.Combine(repoRoot, "src");
- Directory.CreateDirectory(srcDir);
- var sourceFile = Path.Combine(srcDir, "SpanBenchmark.cs");
- File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @global\n/src/ @owner\n");
- File.WriteAllText(sourceFile, "class SpanBenchmark {}");
-
- var otherRoot = otherDirectory.RootPath;
- var otherSrcDir = Path.Combine(otherRoot, "src");
- Directory.CreateDirectory(otherSrcDir);
- var otherFile = Path.Combine(otherSrcDir, "Other.cs");
- File.WriteAllText(otherFile, "class Other {}");
-
- var env = new Dictionary
- {
- [PlatformKeys.Ci.GitHub.Sha] = CommitSha,
- [PlatformKeys.Ci.GitHub.Workspace] = otherRoot,
- [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet",
- };
-
- var ciValues = CIEnvironmentValues.Create(env);
- var otherRelative = ciValues.MakeRelativePathFromSourceRootWithFallback(otherFile, false);
-
- Assert.Equal("src/Other.cs", otherRelative);
- Assert.Null(ciValues.CodeOwners);
-
- var relative = ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false);
-
- Assert.Equal("src/SpanBenchmark.cs", relative);
- Assert.NotNull(ciValues.CodeOwners);
-
- Assert.True(ciValues.TryGetCodeOwnersRelativePath(sourceFile, false, out var codeOwnersRelativePath));
- var owners = ciValues.CodeOwners!.Match("/" + codeOwnersRelativePath).OrderBy(o => o).ToArray();
- Assert.Equal(new[] { "@owner" }, owners);
- }
-
- [SkippableFact]
- public void DoesNotMatchCodeOwnersForFileOutsideRoot()
- {
- using var repoDirectory = new TemporaryDirectory();
- using var externalDirectory = new TemporaryDirectory();
-
- var repoRoot = repoDirectory.RootPath;
- File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @owner\n");
-
- var externalFile = Path.Combine(externalDirectory.RootPath, "SpanBenchmark.cs");
- File.WriteAllText(externalFile, "class SpanBenchmark {}");
-
- var env = new Dictionary
- {
- [PlatformKeys.Ci.GitHub.Sha] = CommitSha,
- [PlatformKeys.Ci.GitHub.Workspace] = repoRoot,
- [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet",
- };
-
- var ciValues = CIEnvironmentValues.Create(env);
-
- Assert.NotNull(ciValues.CodeOwners);
- Assert.False(ciValues.TryGetCodeOwnersRelativePath(externalFile, false, out _));
- }
-
- [SkippableFact]
- public void KeepsSourceRootMatchWhenFallbackCannotResolve()
- {
- using var repoDirectory = new TemporaryDirectory();
- var repoRoot = repoDirectory.RootPath;
- File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @global\n");
-
- var env = new Dictionary
- {
- [PlatformKeys.Ci.GitHub.Sha] = CommitSha,
- [PlatformKeys.Ci.GitHub.Workspace] = repoRoot,
- [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet",
- };
-
- var ciValues = CIEnvironmentValues.Create(env);
-
- var externalRoot = Path.Combine(Path.GetTempPath(), "dd-ci-outside-" + Guid.NewGuid().ToString("N"));
- var sourceFile = Path.Combine(externalRoot, "tracer", "test", "Snapshots", "Snapshot.cs");
- Directory.CreateDirectory(Path.GetDirectoryName(sourceFile)!);
- File.WriteAllText(sourceFile, "class Snapshot {}");
-
- var relative = ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false);
-
- Assert.StartsWith("..", relative, StringComparison.Ordinal);
- Assert.False(ciValues.TryGetCodeOwnersRelativePath(sourceFile, false, out _));
-
- var owners = ciValues.CodeOwners!.Match("/" + relative).OrderBy(o => o).ToArray();
- Assert.Equal(new[] { "@global" }, owners);
- }
-
- [SkippableFact]
- public void UsesWorkspaceFallbackWhenSourceRootIsDifferent()
- {
- using var tempDirectory = new TemporaryDirectory();
- var repoRoot = tempDirectory.RootPath;
- var srcDir = Path.Combine(repoRoot, "tracer", "test", "benchmarks", "Benchmarks.Trace");
- Directory.CreateDirectory(srcDir);
- var sourceFile = Path.Combine(srcDir, "SpanBenchmark.cs");
- File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @global\n/tracer/test/benchmarks/Benchmarks.Trace/ @owner\n");
- File.WriteAllText(sourceFile, "class SpanBenchmark {}");
-
- var ciValues = new TestCIEnvironmentValues("/go/src/github.com/DataDog/apm-reliability/dd-trace-dotnet", repoRoot);
- var relative = ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false);
-
- Assert.Equal("tracer/test/benchmarks/Benchmarks.Trace/SpanBenchmark.cs", relative);
-
- Assert.True(ciValues.TryGetCodeOwnersRelativePath(sourceFile, false, out var codeOwnersRelativePath));
- var owners = ciValues.CodeOwners!.Match("/" + codeOwnersRelativePath).OrderBy(o => o).ToArray();
- Assert.Equal(new[] { "@owner" }, owners);
- }
-
- [SkippableFact]
- public void DoesNotSearchOutsideWorkspaceForRelativeSourceFile()
- {
- using var repoDirectory = new TemporaryDirectory();
- using var outsideDirectory = new TemporaryDirectory();
-
- var repoRoot = repoDirectory.RootPath;
- Directory.CreateDirectory(Path.Combine(repoRoot, ".git"));
- var outsideRoot = outsideDirectory.RootPath;
- File.WriteAllText(Path.Combine(outsideRoot, "CODEOWNERS"), "* @owner\n");
- File.WriteAllText(Path.Combine(outsideRoot, "SpanBenchmark.cs"), "class SpanBenchmark {}");
-
- var outsideFolderName = Path.GetFileName(outsideRoot);
- var relativeSourcePath = Path.Combine("..", outsideFolderName, "SpanBenchmark.cs");
-
- var env = new Dictionary
- {
- [PlatformKeys.Ci.GitHub.Sha] = CommitSha,
- [PlatformKeys.Ci.GitHub.Workspace] = repoRoot,
- [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet",
- };
-
- var ciValues = CIEnvironmentValues.Create(env);
-
- Assert.False(ciValues.TryGetCodeOwnersRelativePath(relativeSourcePath, false, out _));
- Assert.Null(ciValues.CodeOwners);
- }
-
- private sealed class TemporaryDirectory : IDisposable
- {
- public TemporaryDirectory()
- {
- RootPath = Path.Combine(Path.GetTempPath(), "dd-ci-codeowners-" + Guid.NewGuid().ToString("N"));
- Directory.CreateDirectory(RootPath);
- }
-
- public string RootPath { get; }
-
- public void Dispose()
- {
- try
- {
- if (Directory.Exists(RootPath))
- {
- Directory.Delete(RootPath, recursive: true);
- }
- }
- catch
- {
- // Cleanup failure should not fail tests.
- }
- }
- }
-
- private sealed class TestCIEnvironmentValues : CIEnvironmentValues
- {
- public TestCIEnvironmentValues(string? sourceRoot, string? workspacePath)
- {
- SourceRoot = sourceRoot;
- WorkspacePath = workspacePath;
- }
-
- protected override void Setup(IGitInfo gitInfo)
- {
- }
- }
- }
-}
diff --git a/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/CI/CodeOwnersTests.cs b/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/CI/CodeOwnersTests.cs
deleted file mode 100644
index c13f2001fd95..000000000000
--- a/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/CI/CodeOwnersTests.cs
+++ /dev/null
@@ -1,95 +0,0 @@
-//
-// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License.
-// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc.
-//
-
-using System.IO;
-using System.Linq;
-using Datadog.Trace.Ci;
-using Xunit;
-
-namespace Datadog.Trace.ClrProfiler.IntegrationTests.CI
-{
- public class CodeOwnersTests
- {
- private readonly CodeOwners _githubCodeOwners;
- private readonly CodeOwners _gitlabCodeOwners;
-
- public CodeOwnersTests()
- {
- var ciDataFolder = DataHelpers.GetCiDataDirectory();
-
- var githubCodeOwnersFile = Path.Combine(ciDataFolder, "CODEOWNERS_GITHUB");
- _githubCodeOwners = new CodeOwners(githubCodeOwnersFile, CodeOwners.Platform.GitHub);
-
- var gitlabCodeOwnersFile = Path.Combine(ciDataFolder, "CODEOWNERS_GITLAB");
- _gitlabCodeOwners = new CodeOwners(gitlabCodeOwnersFile, CodeOwners.Platform.GitLab);
- }
-
- [SkippableTheory]
- // Existing baseline expectations
- [InlineData("unexistent/path/test.cs", "[\"@global-owner1\",\"@global-owner2\"]")]
- [InlineData("apps/test.cs", "[\"@octocat\"]")]
- [InlineData("/example/apps/test.cs", "[\"@octocat\"]")]
- [InlineData("/docs/test.cs", "[\"@doctocat\"]")]
- [InlineData("/examples/docs/test.cs", "[\"docs@example.com\"]")]
- [InlineData("/src/vendor/match.go", "[\"docs@example.com\"]")]
- [InlineData("/examples/docs/inside/test.cs", "[\"@global-owner1\",\"@global-owner2\"]")]
- [InlineData("/component/path/test.js", "[\"@js-owner\"]")]
- [InlineData("/mytextbox.txt", "[\"@octo-org/octocats\"]")]
- [InlineData("/scripts/artifacts/value.js", "[\"@doctocat\",\"@octocat\"]")]
- [InlineData("/apps/octo/test.cs", "[\"@octocat\"]")]
- [InlineData("/apps/github", null)]
- // Windows path separators
- [InlineData(@"unexistent\path\test.cs", "[\"@global-owner1\",\"@global-owner2\"]")]
- [InlineData(@"apps\test.cs", "[\"@octocat\"]")]
- [InlineData(@"\docs\test.cs", "[\"@doctocat\"]")]
- [InlineData(@"docs\getting-started.md", "[\"docs@example.com\"]")]
- [InlineData(@"\scripts\artifacts\value.js", "[\"@doctocat\",\"@octocat\"]")]
- [InlineData(@"\apps\github", null)]
- [InlineData(@"\x\logs\error.txt", "[\"@octo-org/octocats\"]")]
- // New GitHub quirks
- [InlineData("/x/logs/error.txt", "[\"@octo-org/octocats\"]")] // **/logs pattern
- [InlineData("docs/getting-started.md", "[\"docs@example.com\"]")] // docs/* pattern
- public void CheckGithubCodeOwners(string value, string expected)
- {
- var match = _githubCodeOwners.Match(value);
- var actual = match.Any() ? "[\"" + string.Join("\",\"", match.OrderBy(o => o)) + "\"]" : null;
- Assert.Equal(expected, actual);
- }
-
- [SkippableTheory]
- // Existing baseline expectations
- [InlineData("apps/README.md", "[\"@code\",\"@database\",\"@docs\",\"@multiple\",\"@owners\"]")]
- [InlineData("model/db", "[\"@code\",\"@database\",\"@multiple\",\"@owners\"]")]
- [InlineData("/config/data.conf", "[\"@config-owner\"]")]
- [InlineData("/docs/root.md", "[\"@root-docs\"]")]
- [InlineData("/docs/sub/root.md", "[\"@all-docs\"]")]
- [InlineData("/src/README", "[\"@group\",\"@group/with-nested/subgroup\"]")]
- [InlineData("/src/lib/internal.h", "[\"@lib-owner\"]")]
- [InlineData("src/ee/docs", "[\"@code\",\"@docs\",\"@multiple\",\"@owners\"]")]
- // Windows path separators
- [InlineData(@"apps\README.md", "[\"@code\",\"@database\",\"@docs\",\"@multiple\",\"@owners\"]")]
- [InlineData(@"model\db", "[\"@code\",\"@database\",\"@multiple\",\"@owners\"]")]
- [InlineData(@"\config\data.conf", "[\"@config-owner\"]")]
- [InlineData(@"\docs\root.md", "[\"@root-docs\"]")]
- [InlineData(@"\docs\sub\root.md", "[\"@all-docs\"]")]
- [InlineData(@"\src\README", "[\"@group\",\"@group/with-nested/subgroup\"]")]
- [InlineData(@"\src\lib\internal.h", "[\"@lib-owner\"]")]
- [InlineData(@"src\ee\docs", "[\"@code\",\"@docs\",\"@multiple\",\"@owners\"]")]
- [InlineData(@"path with spaces\example.txt", "[\"@space-owner\"]")]
- [InlineData(@"src\app\sample.rb", "[\"@ruby-owner\"]")]
- // New GitLab quirks present in existing fixture
- [InlineData("#file_with_pound.rb", "[\"@owner-file-with-pound\"]")] // escaped # char
- [InlineData("path with spaces/example.txt", "[\"@space-owner\"]")] // escaped spaces in path
- [InlineData("src/app/sample.rb", "[\"@ruby-owner\"]")] // *.rb pattern
- [InlineData("random/file.xyz", "[\"@code\",\"@multiple\",\"@owners\"]")] // last * rule wins
- [InlineData("LICENSE", "[\"@legal\",\"janedoe@gitlab.com\"]")] // username + email
- public void CheckGitlabCodeOwners(string value, string expected)
- {
- var match = _gitlabCodeOwners.Match(value);
- var actual = match.Any() ? "[\"" + string.Join("\",\"", match.OrderBy(o => o)) + "\"]" : null;
- Assert.Equal(expected, actual);
- }
- }
-}
diff --git a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs
new file mode 100644
index 000000000000..4774e14108d0
--- /dev/null
+++ b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersFallbackTests.cs
@@ -0,0 +1,633 @@
+//
+// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License.
+// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc.
+//
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Datadog.Trace.Ci.CiEnvironment;
+using Datadog.Trace.Configuration;
+using Xunit;
+
+namespace Datadog.Trace.Tests.Ci;
+
+[Collection(nameof(EnvironmentVariablesTestCollection))]
+public class CodeOwnersFallbackTests
+{
+ private const string CommitSha = "3245605c3d1edc67226d725799ee969c71f7632b";
+
+ [SkippableFact]
+ public void UsesFallbackRootWhenSourceRootIsDifferent()
+ {
+ using var tempDirectory = new TemporaryDirectory();
+ var repoRoot = tempDirectory.RootPath;
+ var srcDir = Path.Combine(repoRoot, "src");
+ Directory.CreateDirectory(srcDir);
+ var sourceFile = Path.Combine(srcDir, "SpanBenchmark.cs");
+ File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @global\n/src/ @owner\n");
+ File.WriteAllText(sourceFile, "class SpanBenchmark {}");
+
+ var env = new Dictionary
+ {
+ [PlatformKeys.Ci.GitHub.Sha] = CommitSha,
+ [PlatformKeys.Ci.GitHub.Workspace] = Path.Combine(repoRoot, "other"),
+ [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet",
+ };
+
+ var ciValues = CIEnvironmentValues.Create(env);
+ var relative = ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false);
+
+ Assert.Equal("src/SpanBenchmark.cs", relative);
+
+ var owners = ciValues.CodeOwners!.Match("/" + relative).OrderBy(o => o).ToArray();
+ Assert.Equal(new[] { "@owner" }, owners);
+ }
+
+ [SkippableFact]
+ public void UsesFallbackRootWhenSourceRootIsSubdirectory()
+ {
+ using var tempDirectory = new TemporaryDirectory();
+ var repoRoot = tempDirectory.RootPath;
+ var srcDir = Path.Combine(repoRoot, "src");
+ Directory.CreateDirectory(Path.Combine(repoRoot, ".git"));
+ Directory.CreateDirectory(srcDir);
+ var sourceFile = Path.Combine(srcDir, "SpanBenchmark.cs");
+ File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @global\n/src/ @owner\n");
+ File.WriteAllText(sourceFile, "class SpanBenchmark {}");
+
+ var env = new Dictionary
+ {
+ [PlatformKeys.Ci.GitHub.Sha] = CommitSha,
+ [PlatformKeys.Ci.GitHub.Workspace] = srcDir,
+ [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet",
+ };
+
+ var ciValues = CIEnvironmentValues.Create(env);
+ var relative = ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false);
+
+ Assert.Equal("src/SpanBenchmark.cs", relative);
+
+ var owners = ciValues.CodeOwners!.Match("/" + relative).OrderBy(o => o).ToArray();
+ Assert.Equal(new[] { "@owner" }, owners);
+ }
+
+ [SkippableFact]
+ public void DoesNotUseCurrentDirectoryForRelativeSourceFile()
+ {
+ using var tempDirectory = new TemporaryDirectory();
+ var repoRoot = tempDirectory.RootPath;
+ var srcDir = Path.Combine(repoRoot, "src");
+ Directory.CreateDirectory(srcDir);
+ var sourceFile = Path.Combine(srcDir, "SpanBenchmark.cs");
+ File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @owner\n");
+ File.WriteAllText(sourceFile, "class SpanBenchmark {}");
+
+ var env = new Dictionary
+ {
+ [PlatformKeys.Ci.GitHub.Sha] = CommitSha,
+ [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet",
+ };
+
+ var originalDirectory = Environment.CurrentDirectory;
+ Environment.CurrentDirectory = repoRoot;
+ try
+ {
+ var ciValues = CIEnvironmentValues.Create(env);
+ var relative = ciValues.MakeRelativePathFromSourceRootWithFallback("src/SpanBenchmark.cs", false);
+
+ Assert.Equal("src/SpanBenchmark.cs", relative);
+ Assert.Null(ciValues.CodeOwners);
+ }
+ finally
+ {
+ Environment.CurrentDirectory = originalDirectory;
+ }
+ }
+
+ [SkippableFact]
+ public void AllowsFallbackRetryWithDifferentStartPath()
+ {
+ using var repoDirectory = new TemporaryDirectory();
+ using var otherDirectory = new TemporaryDirectory();
+
+ var repoRoot = repoDirectory.RootPath;
+ var srcDir = Path.Combine(repoRoot, "src");
+ Directory.CreateDirectory(srcDir);
+ var sourceFile = Path.Combine(srcDir, "SpanBenchmark.cs");
+ File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @global\n/src/ @owner\n");
+ File.WriteAllText(sourceFile, "class SpanBenchmark {}");
+
+ var otherRoot = otherDirectory.RootPath;
+ var otherSrcDir = Path.Combine(otherRoot, "src");
+ Directory.CreateDirectory(otherSrcDir);
+ var otherFile = Path.Combine(otherSrcDir, "Other.cs");
+ File.WriteAllText(otherFile, "class Other {}");
+
+ var env = new Dictionary
+ {
+ [PlatformKeys.Ci.GitHub.Sha] = CommitSha,
+ [PlatformKeys.Ci.GitHub.Workspace] = otherRoot,
+ [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet",
+ };
+
+ var ciValues = CIEnvironmentValues.Create(env);
+ var otherRelative = ciValues.MakeRelativePathFromSourceRootWithFallback(otherFile, false);
+
+ Assert.Equal("src/Other.cs", otherRelative);
+ Assert.Null(ciValues.CodeOwners);
+
+ var relative = ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false);
+
+ Assert.Equal("src/SpanBenchmark.cs", relative);
+ Assert.NotNull(ciValues.CodeOwners);
+
+ Assert.True(ciValues.TryGetCodeOwnersRelativePath(sourceFile, false, out var codeOwnersRelativePath));
+ var owners = ciValues.CodeOwners!.Match("/" + codeOwnersRelativePath).OrderBy(o => o).ToArray();
+ Assert.Equal(new[] { "@owner" }, owners);
+ }
+
+ [SkippableFact]
+ public void GitHubProviderUsesOfficialCodeOwnersLocationPriority()
+ {
+ using var tempDirectory = new TemporaryDirectory();
+ var repoRoot = tempDirectory.RootPath;
+ Directory.CreateDirectory(Path.Combine(repoRoot, ".github"));
+ Directory.CreateDirectory(Path.Combine(repoRoot, "docs"));
+ File.WriteAllText(Path.Combine(repoRoot, ".github", "CODEOWNERS"), "* @github-directory\n");
+ File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @repository-root\n");
+ File.WriteAllText(Path.Combine(repoRoot, "docs", "CODEOWNERS"), "* @docs-directory\n");
+ var ciValues = new ReloadingEnvironmentValues(repoRoot, "github");
+
+ ciValues.Reload();
+ Assert.Equal(["@github-directory"], ciValues.CodeOwners!.Match("/file.cs"));
+
+ File.Delete(Path.Combine(repoRoot, ".github", "CODEOWNERS"));
+ ciValues.Reload();
+ Assert.Equal(["@repository-root"], ciValues.CodeOwners!.Match("/file.cs"));
+
+ File.Delete(Path.Combine(repoRoot, "CODEOWNERS"));
+ ciValues.Reload();
+ Assert.Equal(["@docs-directory"], ciValues.CodeOwners!.Match("/file.cs"));
+ }
+
+ [SkippableFact]
+ public void GitLabProviderUsesOfficialCodeOwnersLocationPriority()
+ {
+ using var tempDirectory = new TemporaryDirectory();
+ var repoRoot = tempDirectory.RootPath;
+ Directory.CreateDirectory(Path.Combine(repoRoot, "docs"));
+ Directory.CreateDirectory(Path.Combine(repoRoot, ".gitlab"));
+ File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @repository-root\n");
+ File.WriteAllText(Path.Combine(repoRoot, "docs", "CODEOWNERS"), "* @docs-directory\n");
+ File.WriteAllText(Path.Combine(repoRoot, ".gitlab", "CODEOWNERS"), "* @gitlab-directory\n");
+ var ciValues = new ReloadingEnvironmentValues(repoRoot, "gitlab");
+
+ ciValues.Reload();
+ Assert.Equal(["@repository-root"], ciValues.CodeOwners!.Match("/file.cs"));
+
+ File.Delete(Path.Combine(repoRoot, "CODEOWNERS"));
+ ciValues.Reload();
+ Assert.Equal(["@docs-directory"], ciValues.CodeOwners!.Match("/file.cs"));
+
+ File.Delete(Path.Combine(repoRoot, "docs", "CODEOWNERS"));
+ ciValues.Reload();
+ Assert.Equal(["@gitlab-directory"], ciValues.CodeOwners!.Match("/file.cs"));
+ }
+
+ [SkippableFact]
+ public void CodeOwnersDiscoveryIgnoresOtherPlatformSpecificLocations()
+ {
+ using var githubDirectory = new TemporaryDirectory();
+ Directory.CreateDirectory(Path.Combine(githubDirectory.RootPath, ".gitlab"));
+ File.WriteAllText(Path.Combine(githubDirectory.RootPath, ".gitlab", "CODEOWNERS"), "* @gitlab-only\n");
+ var githubValues = new ReloadingEnvironmentValues(githubDirectory.RootPath, "github");
+
+ githubValues.Reload();
+ Assert.Null(githubValues.CodeOwners);
+
+ using var gitlabDirectory = new TemporaryDirectory();
+ Directory.CreateDirectory(Path.Combine(gitlabDirectory.RootPath, ".github"));
+ File.WriteAllText(Path.Combine(gitlabDirectory.RootPath, ".github", "CODEOWNERS"), "* @github-only\n");
+ var gitlabValues = new ReloadingEnvironmentValues(gitlabDirectory.RootPath, "gitlab");
+
+ gitlabValues.Reload();
+ Assert.Null(gitlabValues.CodeOwners);
+ }
+
+ [SkippableTheory]
+ [InlineData("https://gitlab.com/DataDog/dd-trace-dotnet.git")]
+ [InlineData("git@gitlab.com:DataDog/dd-trace-dotnet.git")]
+ [InlineData("https://gitlab.example.com/DataDog/dd-trace-dotnet.git")]
+ [InlineData("git@gitlab.example.com:DataDog/dd-trace-dotnet.git")]
+ public void UsesRepositoryHostToSelectCodeOwnersPlatform(string repositoryUrl)
+ {
+ using var tempDirectory = new TemporaryDirectory();
+ var repoRoot = tempDirectory.RootPath;
+ Directory.CreateDirectory(Path.Combine(repoRoot, ".gitlab"));
+ File.WriteAllText(Path.Combine(repoRoot, ".gitlab", "CODEOWNERS"), "[Section] @gitlab-owner\n*.cs\n");
+
+ var env = new Dictionary
+ {
+ [PlatformKeys.Ci.Jenkins.Url] = "https://jenkins.example.com",
+ [PlatformKeys.Ci.Jenkins.GitUrl] = repositoryUrl,
+ [PlatformKeys.Ci.Jenkins.GitCommit] = CommitSha,
+ [PlatformKeys.Ci.Jenkins.Workspace] = repoRoot,
+ };
+
+ var ciValues = CIEnvironmentValues.Create(env);
+
+ Assert.Equal("jenkins", ciValues.Provider);
+ Assert.Equal(["@gitlab-owner"], ciValues.CodeOwners!.Match("/file.cs"));
+ }
+
+ [SkippableFact]
+ public void UsesGitLabSpecificLocationWhenRepositoryHostIsUnknown()
+ {
+ using var tempDirectory = new TemporaryDirectory();
+ var repoRoot = tempDirectory.RootPath;
+ Directory.CreateDirectory(Path.Combine(repoRoot, ".gitlab"));
+ File.WriteAllText(Path.Combine(repoRoot, ".gitlab", "CODEOWNERS"), "[Section] @gitlab-owner\n*.cs\n");
+
+ var env = new Dictionary
+ {
+ [PlatformKeys.Ci.Jenkins.Url] = "https://jenkins.example.com",
+ [PlatformKeys.Ci.Jenkins.GitUrl] = "https://source.example.com/DataDog/dd-trace-dotnet.git",
+ [PlatformKeys.Ci.Jenkins.GitCommit] = CommitSha,
+ [PlatformKeys.Ci.Jenkins.Workspace] = repoRoot,
+ };
+
+ var ciValues = CIEnvironmentValues.Create(env);
+
+ Assert.Equal(["@gitlab-owner"], ciValues.CodeOwners!.Match("/file.cs"));
+ }
+
+ [SkippableFact]
+ public void DoesNotMatchCodeOwnersForFileOutsideRoot()
+ {
+ using var repoDirectory = new TemporaryDirectory();
+ using var externalDirectory = new TemporaryDirectory();
+
+ var repoRoot = repoDirectory.RootPath;
+ File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @owner\n");
+
+ var externalFile = Path.Combine(externalDirectory.RootPath, "SpanBenchmark.cs");
+ File.WriteAllText(externalFile, "class SpanBenchmark {}");
+
+ var env = new Dictionary
+ {
+ [PlatformKeys.Ci.GitHub.Sha] = CommitSha,
+ [PlatformKeys.Ci.GitHub.Workspace] = repoRoot,
+ [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet",
+ };
+
+ var ciValues = CIEnvironmentValues.Create(env);
+
+ Assert.NotNull(ciValues.CodeOwners);
+ Assert.False(ciValues.TryGetCodeOwnersRelativePath(externalFile, false, out _));
+ }
+
+ [SkippableFact]
+ public void KeepsSourceRootMatchWhenFallbackCannotResolve()
+ {
+ using var repoDirectory = new TemporaryDirectory();
+ var repoRoot = repoDirectory.RootPath;
+ File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @global\n");
+
+ var env = new Dictionary
+ {
+ [PlatformKeys.Ci.GitHub.Sha] = CommitSha,
+ [PlatformKeys.Ci.GitHub.Workspace] = repoRoot,
+ [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet",
+ };
+
+ var ciValues = CIEnvironmentValues.Create(env);
+
+ var externalRoot = Path.Combine(Path.GetTempPath(), "dd-ci-outside-" + Guid.NewGuid().ToString("N"));
+ var sourceFile = Path.Combine(externalRoot, "tracer", "test", "Snapshots", "Snapshot.cs");
+ Directory.CreateDirectory(Path.GetDirectoryName(sourceFile)!);
+ File.WriteAllText(sourceFile, "class Snapshot {}");
+
+ var relative = ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false);
+
+ Assert.StartsWith("..", relative, StringComparison.Ordinal);
+ Assert.False(ciValues.TryGetCodeOwnersRelativePath(sourceFile, false, out _));
+
+ var owners = ciValues.CodeOwners!.Match("/" + relative).OrderBy(o => o).ToArray();
+ Assert.Equal(new[] { "@global" }, owners);
+ }
+
+ [SkippableFact]
+ public void UsesWorkspaceFallbackWhenSourceRootIsDifferent()
+ {
+ using var tempDirectory = new TemporaryDirectory();
+ var repoRoot = tempDirectory.RootPath;
+ var srcDir = Path.Combine(repoRoot, "tracer", "test", "benchmarks", "Benchmarks.Trace");
+ Directory.CreateDirectory(srcDir);
+ var sourceFile = Path.Combine(srcDir, "SpanBenchmark.cs");
+ File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @global\n/tracer/test/benchmarks/Benchmarks.Trace/ @owner\n");
+ File.WriteAllText(sourceFile, "class SpanBenchmark {}");
+
+ var ciValues = new TestCIEnvironmentValues("/go/src/github.com/DataDog/apm-reliability/dd-trace-dotnet", repoRoot);
+ var relative = ciValues.MakeRelativePathFromSourceRootWithFallback(sourceFile, false);
+
+ Assert.Equal("tracer/test/benchmarks/Benchmarks.Trace/SpanBenchmark.cs", relative);
+
+ Assert.True(ciValues.TryGetCodeOwnersRelativePath(sourceFile, false, out var codeOwnersRelativePath));
+ var owners = ciValues.CodeOwners!.Match("/" + codeOwnersRelativePath).OrderBy(o => o).ToArray();
+ Assert.Equal(new[] { "@owner" }, owners);
+ }
+
+ [SkippableFact]
+ public void DoesNotSearchOutsideWorkspaceForRelativeSourceFile()
+ {
+ using var repoDirectory = new TemporaryDirectory();
+ using var outsideDirectory = new TemporaryDirectory();
+
+ var repoRoot = repoDirectory.RootPath;
+ Directory.CreateDirectory(Path.Combine(repoRoot, ".git"));
+ var outsideRoot = outsideDirectory.RootPath;
+ File.WriteAllText(Path.Combine(outsideRoot, "CODEOWNERS"), "* @owner\n");
+ File.WriteAllText(Path.Combine(outsideRoot, "SpanBenchmark.cs"), "class SpanBenchmark {}");
+
+ var outsideFolderName = Path.GetFileName(outsideRoot);
+ var relativeSourcePath = Path.Combine("..", outsideFolderName, "SpanBenchmark.cs");
+
+ var env = new Dictionary
+ {
+ [PlatformKeys.Ci.GitHub.Sha] = CommitSha,
+ [PlatformKeys.Ci.GitHub.Workspace] = repoRoot,
+ [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet",
+ };
+
+ var ciValues = CIEnvironmentValues.Create(env);
+
+ Assert.False(ciValues.TryGetCodeOwnersRelativePath(relativeSourcePath, false, out _));
+ Assert.Null(ciValues.CodeOwners);
+ }
+
+ [SkippableFact]
+ public void AnchorsForeignRelativePathsToCodeOwnersRoot()
+ {
+ using var tempDirectory = new TemporaryDirectory();
+ var repoRoot = tempDirectory.RootPath;
+ var srcDir = Path.Combine(repoRoot, "tracer", "test");
+ Directory.CreateDirectory(srcDir);
+ var sourceFile = Path.Combine(srcDir, "SpanBenchmark.cs");
+ File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @global\n/tracer/test/ @owner\n");
+ File.WriteAllText(sourceFile, "class SpanBenchmark {}");
+
+ var env = new Dictionary
+ {
+ [PlatformKeys.Ci.GitHub.Sha] = CommitSha,
+ [PlatformKeys.Ci.GitHub.Workspace] = repoRoot,
+ [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet",
+ };
+
+ var ciValues = CIEnvironmentValues.Create(env);
+
+ // Paths recorded against a foreign base directory (e.g. "../../../_/..." on CI agents)
+ // must be anchored back to a repository-relative path.
+ var foreignRelativePath = "../../../_/tracer/test/SpanBenchmark.cs";
+ Assert.True(ciValues.TryGetCodeOwnersRelativePath(foreignRelativePath, false, out var codeOwnersRelativePath));
+ Assert.Equal("tracer/test/SpanBenchmark.cs", codeOwnersRelativePath);
+
+ var owners = ciValues.CodeOwners!.Match("/" + codeOwnersRelativePath).OrderBy(o => o).ToArray();
+ Assert.Equal(new[] { "@owner" }, owners);
+ }
+
+ [SkippableFact]
+ public void AnchoredPathRespectsUseOSSeparator()
+ {
+ using var tempDirectory = new TemporaryDirectory();
+ var repoRoot = tempDirectory.RootPath;
+ var srcDir = Path.Combine(repoRoot, "tracer", "test");
+ Directory.CreateDirectory(srcDir);
+ File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @global\n");
+ File.WriteAllText(Path.Combine(srcDir, "SpanBenchmark.cs"), "class SpanBenchmark {}");
+
+ var env = new Dictionary
+ {
+ [PlatformKeys.Ci.GitHub.Sha] = CommitSha,
+ [PlatformKeys.Ci.GitHub.Workspace] = repoRoot,
+ [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet",
+ };
+
+ var ciValues = CIEnvironmentValues.Create(env);
+ var foreignRelativePath = "../../../_/tracer/test/SpanBenchmark.cs";
+
+ Assert.True(ciValues.TryGetCodeOwnersRelativePath(foreignRelativePath, useOSSeparator: false, out var forwardSlashPath));
+ Assert.Equal("tracer/test/SpanBenchmark.cs", forwardSlashPath);
+
+ Assert.True(ciValues.TryGetCodeOwnersRelativePath(foreignRelativePath, useOSSeparator: true, out var osPath));
+ Assert.Equal(Path.Combine("tracer", "test", "SpanBenchmark.cs"), osPath);
+ }
+
+ [SkippableFact]
+ public void DoesNotAnchorForeignPathsWhenSuffixDoesNotExistUnderRoot()
+ {
+ using var repoDirectory = new TemporaryDirectory();
+ using var externalDirectory = new TemporaryDirectory();
+
+ var repoRoot = repoDirectory.RootPath;
+ Directory.CreateDirectory(Path.Combine(repoRoot, "src"));
+ File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @owner\n/src/ @src-owner\n");
+ File.WriteAllText(Path.Combine(repoRoot, "src", "SpanBenchmark.cs"), "class SpanBenchmark {}");
+
+ var externalFile = Path.Combine(externalDirectory.RootPath, "other", "SpanBenchmark.cs");
+ Directory.CreateDirectory(Path.GetDirectoryName(externalFile)!);
+ File.WriteAllText(externalFile, "class SpanBenchmark {}");
+
+ var env = new Dictionary
+ {
+ [PlatformKeys.Ci.GitHub.Sha] = CommitSha,
+ [PlatformKeys.Ci.GitHub.Workspace] = repoRoot,
+ [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet",
+ };
+
+ var ciValues = CIEnvironmentValues.Create(env);
+
+ // A foreign path whose suffix does not exist under the repository must not be re-anchored.
+ Assert.False(ciValues.TryGetCodeOwnersRelativePath("../other/SpanBenchmark.cs", false, out _));
+ }
+
+ [SkippableFact]
+ public void AnchorsAzurePipelinesCompilerRecordedPaths()
+ {
+ // Reproduces the reported CI Visibility issue: on Azure Pipelines agents, compiler-recorded
+ // source paths are relative to a foreign base directory (e.g.
+ // "../../../../../../_/tracer/test/Datadog.Trace.DuckTyping.Tests/ExceptionsTests.cs"),
+ // which made every test span lose its test.codeowners tag once the CODEOWNERS rules were
+ // changed back to rooted patterns.
+ using var tempDirectory = new TemporaryDirectory();
+ var repoRoot = tempDirectory.RootPath;
+ var sourceDir = Path.Combine(repoRoot, "tracer", "test", "Datadog.Trace.DuckTyping.Tests");
+ Directory.CreateDirectory(sourceDir);
+ var sourceFile = Path.Combine(sourceDir, "ExceptionsTests.cs");
+ Directory.CreateDirectory(Path.Combine(repoRoot, ".github"));
+ File.WriteAllText(Path.Combine(repoRoot, ".github", "CODEOWNERS"), "/tracer/test/ @DataDog/tracing-dotnet\n");
+ File.WriteAllText(sourceFile, "// test");
+
+ var env = new Dictionary
+ {
+ [PlatformKeys.Ci.Azure.TFBuild] = "True",
+ [PlatformKeys.Ci.Azure.SystemTeamFoundationServerUri] = "https://dev.azure.com/datadoghq/",
+ [PlatformKeys.Ci.Azure.BuildSourcesDirectory] = repoRoot,
+ [PlatformKeys.Ci.Azure.BuildSourceVersion] = CommitSha,
+ [PlatformKeys.Ci.Azure.BuildRepositoryUri] = "https://github.com/DataDog/dd-trace-dotnet",
+ };
+
+ var ciValues = CIEnvironmentValues.Create(env);
+ Assert.NotNull(ciValues.CodeOwners);
+
+ var foreignRelativePath = "../../../../../../_/tracer/test/Datadog.Trace.DuckTyping.Tests/ExceptionsTests.cs";
+ var relative = ciValues.MakeRelativePathFromSourceRootWithFallback(foreignRelativePath, false);
+ Assert.Equal("tracer/test/Datadog.Trace.DuckTyping.Tests/ExceptionsTests.cs", relative);
+
+ var owners = ciValues.CodeOwners!.Match("/" + relative).OrderBy(o => o).ToArray();
+ Assert.Equal(new[] { "@DataDog/tracing-dotnet" }, owners);
+ }
+
+ [SkippableTheory]
+ [InlineData(@"D:\a\_work\1\s\src\SpanBenchmark.cs")]
+ [InlineData(@"D:\a\1\s\src\SpanBenchmark.cs")]
+ [InlineData("/home/vsts/work/1/s/src/SpanBenchmark.cs")]
+ [InlineData("/tmp/work/1/s/src/SpanBenchmark.cs")]
+ [InlineData("file:///D:/a/_work/1/s/src/SpanBenchmark.cs")]
+ [InlineData("https://example.com/a/_work/1/s/src/SpanBenchmark.cs")]
+ public void DoesNotAnchorAbsoluteAzurePipelinesPathsWithMatchingRepositorySuffix(string sourcePath)
+ {
+ using var tempDirectory = new TemporaryDirectory();
+ var repoRoot = tempDirectory.RootPath;
+ var sourceDir = Path.Combine(repoRoot, "src");
+ Directory.CreateDirectory(sourceDir);
+ File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "/src/ @owner\n");
+ File.WriteAllText(Path.Combine(sourceDir, "SpanBenchmark.cs"), "// test");
+
+ var env = new Dictionary
+ {
+ [PlatformKeys.Ci.Azure.TFBuild] = "True",
+ [PlatformKeys.Ci.Azure.BuildSourcesDirectory] = repoRoot,
+ [PlatformKeys.Ci.Azure.BuildSourceVersion] = CommitSha,
+ [PlatformKeys.Ci.Azure.BuildRepositoryUri] = "https://github.com/DataDog/dd-trace-dotnet",
+ };
+
+ var ciValues = CIEnvironmentValues.Create(env);
+
+ Assert.False(ciValues.TryGetCodeOwnersRelativePath(sourcePath, false, out _));
+ }
+
+ [SkippableFact]
+ public void DoesNotAnchorPathsWithInteriorNavigationSegments()
+ {
+ using var tempDirectory = new TemporaryDirectory();
+ var repoRoot = tempDirectory.RootPath;
+ var srcDir = Path.Combine(repoRoot, "src");
+ Directory.CreateDirectory(srcDir);
+ File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @owner\n/src/ @src-owner\n");
+ File.WriteAllText(Path.Combine(srcDir, "SpanBenchmark.cs"), "class SpanBenchmark {}");
+
+ var env = new Dictionary
+ {
+ [PlatformKeys.Ci.GitHub.Sha] = CommitSha,
+ [PlatformKeys.Ci.GitHub.Workspace] = repoRoot,
+ [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet",
+ };
+
+ var ciValues = CIEnvironmentValues.Create(env);
+
+ // An interior navigation segment depends on the unknown base directory where the path was
+ // recorded; anchoring it would produce a malformed repository-relative path.
+ Assert.False(ciValues.TryGetCodeOwnersRelativePath("../other/src/../src/SpanBenchmark.cs", false, out _));
+ }
+
+ [SkippableTheory]
+ [InlineData("file:///outside/src/SpanBenchmark.cs")]
+ [InlineData("https://example.com/src/SpanBenchmark.cs")]
+ [InlineData("../../C:/outside/src/SpanBenchmark.cs")]
+ [InlineData("../..//outside/src/SpanBenchmark.cs")]
+ [InlineData(@"..\..\\server\share\src\SpanBenchmark.cs")]
+ public void DoesNotAnchorAbsoluteOrEmbeddedRootedPathsWithMatchingRepositorySuffix(string sourceFilePath)
+ {
+ using var tempDirectory = new TemporaryDirectory();
+ var repoRoot = tempDirectory.RootPath;
+ var srcDir = Path.Combine(repoRoot, "src");
+ Directory.CreateDirectory(srcDir);
+ File.WriteAllText(Path.Combine(repoRoot, "CODEOWNERS"), "* @owner\n/src/ @src-owner\n");
+ File.WriteAllText(Path.Combine(srcDir, "SpanBenchmark.cs"), "class SpanBenchmark {}");
+
+ var env = new Dictionary
+ {
+ [PlatformKeys.Ci.GitHub.Sha] = CommitSha,
+ [PlatformKeys.Ci.GitHub.Workspace] = repoRoot,
+ [PlatformKeys.Ci.GitHub.Repository] = "DataDog/dd-trace-dotnet",
+ };
+
+ var ciValues = CIEnvironmentValues.Create(env);
+
+ Assert.False(ciValues.TryGetCodeOwnersRelativePath(sourceFilePath, false, out _));
+ }
+
+ private sealed class TemporaryDirectory : IDisposable
+ {
+ public TemporaryDirectory()
+ {
+ RootPath = Path.Combine(Path.GetTempPath(), "dd-ci-codeowners-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(RootPath);
+ }
+
+ public string RootPath { get; }
+
+ public void Dispose()
+ {
+ try
+ {
+ if (Directory.Exists(RootPath))
+ {
+ Directory.Delete(RootPath, recursive: true);
+ }
+ }
+ catch
+ {
+ // Cleanup failure should not fail tests.
+ }
+ }
+ }
+
+ private sealed class TestCIEnvironmentValues : CIEnvironmentValues
+ {
+ public TestCIEnvironmentValues(string? sourceRoot, string? workspacePath, string? provider = null)
+ {
+ SourceRoot = sourceRoot;
+ WorkspacePath = workspacePath;
+ Provider = provider;
+ }
+
+ protected override void Setup(IGitInfo gitInfo)
+ {
+ }
+ }
+
+ private sealed class ReloadingEnvironmentValues : CIEnvironmentValues
+ {
+ private readonly string _sourceRoot;
+ private readonly string _provider;
+
+ public ReloadingEnvironmentValues(string sourceRoot, string provider)
+ {
+ _sourceRoot = sourceRoot;
+ _provider = provider;
+ }
+
+ public void Reload() => ReloadEnvironmentData();
+
+ protected override void Setup(IGitInfo gitInfo)
+ {
+ SourceRoot = _sourceRoot;
+ WorkspacePath = _sourceRoot;
+ Provider = _provider;
+ }
+ }
+}
diff --git a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersRepositoryTests.cs b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersRepositoryTests.cs
new file mode 100644
index 000000000000..03d18e366212
--- /dev/null
+++ b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersRepositoryTests.cs
@@ -0,0 +1,179 @@
+//
+// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License.
+// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc.
+//
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Datadog.Trace.Ci;
+using Datadog.Trace.TestHelpers;
+using FluentAssertions;
+using Xunit;
+
+namespace Datadog.Trace.Tests.Ci;
+
+public class CodeOwnersRepositoryTests
+{
+ private static readonly HashSet BuildOutputDirectories = new(StringComparer.OrdinalIgnoreCase) { "bin", "obj" };
+
+ [SkippableFact]
+ public void EveryTestFileHasAnOwner()
+ {
+ var repoRoot = GetRepositoryRoot();
+ Skip.If(repoRoot is null, "Could not locate the repository root");
+
+ var codeOwners = new CodeOwners(Path.Combine(repoRoot!, ".github", "CODEOWNERS"), CodeOwners.Platform.GitHub);
+ var unownedFiles = new List();
+ var totalFiles = 0;
+
+ foreach (var testRoot in new[] { "tracer/test", "profiler/test" })
+ {
+ var fullRoot = Path.Combine(repoRoot!, testRoot.Replace('/', Path.DirectorySeparatorChar));
+ if (!Directory.Exists(fullRoot))
+ {
+ continue;
+ }
+
+ foreach (var file in EnumerateRepositoryFiles(fullRoot))
+ {
+ var relativePath = file.Substring(repoRoot!.Length + 1).Replace('\\', '/');
+ totalFiles++;
+ if (!codeOwners.Match("/" + relativePath).Any())
+ {
+ unownedFiles.Add(relativePath);
+ }
+ }
+ }
+
+ totalFiles.Should().BeGreaterThan(0, "expected to find test files in the repository");
+ unownedFiles.Should().BeEmpty("every test file should be owned by at least one team in .github/CODEOWNERS");
+ }
+
+ [SkippableFact]
+ public void RepositoryFileEnumerationSkipsBuildOutputs()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "dd-codeowners-enumeration-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ Directory.CreateDirectory(Path.Combine(root, "src", "nested"));
+ Directory.CreateDirectory(Path.Combine(root, "bin", "Debug"));
+ Directory.CreateDirectory(Path.Combine(root, "src", "obj", "Debug"));
+ File.WriteAllText(Path.Combine(root, "Source.cs"), string.Empty);
+ File.WriteAllText(Path.Combine(root, "src", "nested", "Nested.cs"), string.Empty);
+ File.WriteAllText(Path.Combine(root, "bin", "Debug", "Generated.dll"), string.Empty);
+ File.WriteAllText(Path.Combine(root, "src", "obj", "Debug", "Generated.cs"), string.Empty);
+
+ EnumerateRepositoryFiles(root)
+ .Select(path => path.Substring(root.Length + 1).Replace('\\', '/'))
+ .Should()
+ .BeEquivalentTo(["Source.cs", "src/nested/Nested.cs"]);
+ }
+ finally
+ {
+ if (Directory.Exists(root))
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+ }
+
+ [SkippableTheory]
+ [InlineData("/docs/development/AzureFunctions.md", new[] { "@DataDog/tracing-dotnet", "@DataDog/apm-serverless", "@DataDog/serverless-azure-and-gcp" })]
+ // `**/` must also match zero directories: a file directly under tracer/test/ still matches /tracer/test/**/*Lambda*
+ [InlineData("/tracer/test/FooLambdaTests.cs", new[] { "@DataDog/tracing-dotnet", "@DataDog/apm-serverless", "@DataDog/serverless-aws" })]
+ // Rooted patterns must match paths passed without a leading slash too
+ [InlineData("tracer/src/Datadog.Trace/Ci/CodeOwners.cs", new[] { "@DataDog/ci-app-libraries-dotnet", "@DataDog/apm-dotnet" })]
+ [InlineData("/tracer/src/Datadog.Trace/Ci/CodeOwners.cs", new[] { "@DataDog/ci-app-libraries-dotnet", "@DataDog/apm-dotnet" })]
+ public void RepositoryCodeOwnersMatchesExpectedTeams(string path, string[] expected)
+ {
+ var repoRoot = GetRepositoryRoot();
+ Skip.If(repoRoot is null, "Could not locate the repository root");
+
+ var codeOwners = new CodeOwners(Path.Combine(repoRoot!, ".github", "CODEOWNERS"), CodeOwners.Platform.GitHub);
+ codeOwners.Match(path).OrderBy(o => o).Should().Equal(expected.OrderBy(o => o));
+ }
+
+ [SkippableFact]
+ public void SectionDefaultOwnersDoNotApplyToUnmatchedPaths()
+ {
+ var filePath = Path.Combine(Path.GetTempPath(), "dd-codeowners-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ // Owners listed on a section header line are not a catch-all rule for every other path.
+ File.WriteAllText(filePath, "[Section] @team\n/src/ @owner\n");
+ var codeOwners = new CodeOwners(filePath, CodeOwners.Platform.GitLab);
+
+ codeOwners.Match("/src/code.cs").Should().Equal(["@owner"]);
+ codeOwners.Match("/other/file.cs").Should().BeEmpty();
+ }
+ finally
+ {
+ File.Delete(filePath);
+ }
+ }
+
+ [SkippableFact]
+ public void GitLabExclusionRuleRemovesPathFromSection()
+ {
+ var filePath = Path.Combine(Path.GetTempPath(), "dd-codeowners-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ File.WriteAllText(filePath, "* @global\n/docs/ @docs\n!/docs/generated/\n");
+ var codeOwners = new CodeOwners(filePath, CodeOwners.Platform.GitLab);
+
+ codeOwners.Match("/docs/a.cs").Should().Equal(["@docs"]);
+ codeOwners.Match("/docs/generated/x.cs").Should().BeEmpty();
+ codeOwners.Match("/other/file.cs").Should().Equal(["@global"]);
+ }
+ finally
+ {
+ File.Delete(filePath);
+ }
+ }
+
+ private static string? GetRepositoryRoot()
+ {
+ // The solution directory is the repository root in this repo, but walk up as a safety net
+ // in case the solution is ever moved into a subdirectory.
+ var current = new DirectoryInfo(EnvironmentTools.GetSolutionDirectory());
+ while (current is not null)
+ {
+ if (File.Exists(Path.Combine(current.FullName, ".github", "CODEOWNERS")))
+ {
+ return current.FullName;
+ }
+
+ current = current.Parent;
+ }
+
+ return null;
+ }
+
+ private static IEnumerable EnumerateRepositoryFiles(string root)
+ {
+ var pending = new Stack();
+ pending.Push(root);
+
+ while (pending.Count > 0)
+ {
+ var directory = pending.Pop();
+ foreach (var file in Directory.EnumerateFiles(directory))
+ {
+ yield return file;
+ }
+
+ foreach (var child in Directory.EnumerateDirectories(directory))
+ {
+ var name = Path.GetFileName(child);
+ if (!BuildOutputDirectories.Contains(name) &&
+ (File.GetAttributes(child) & FileAttributes.ReparsePoint) == 0)
+ {
+ pending.Push(child);
+ }
+ }
+ }
+ }
+}
diff --git a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs
new file mode 100644
index 000000000000..e06629a8ca17
--- /dev/null
+++ b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersSpecTests.cs
@@ -0,0 +1,973 @@
+//
+// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License.
+// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc.
+//
+#nullable enable
+
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using Datadog.Trace.Ci;
+using FluentAssertions;
+using Xunit;
+
+namespace Datadog.Trace.Tests.Ci;
+
+///
+/// Specification tests for the CODEOWNERS parser based on the official GitHub and GitLab documentation:
+/// https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
+/// https://docs.gitlab.com/user/project/codeowners/reference/
+///
+public class CodeOwnersSpecTests
+{
+ private const string GitlabSectionsExample = """
+ * @admin
+
+ [README Owners]
+ README.md @user1 @user2
+ internal/README.md @user4
+
+ [README other owners]
+ README.md @user3
+ """;
+
+ private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(10);
+
+ [SkippableFact]
+ public void GithubInlineCommentsAndEmailOwners()
+ {
+ var codeOwners = Create("* @global-owner1 @global-owner2\n*.js @js-owner #This is an inline comment.\n*.go docs@example.com\n", CodeOwners.Platform.GitHub);
+ Match(codeOwners, "/app.js").Should().Equal(["@js-owner"]);
+ Match(codeOwners, "/app.go").Should().Equal(["docs@example.com"]);
+ Match(codeOwners, "/file.rb").Should().Equal(["@global-owner1", "@global-owner2"]);
+ }
+
+ [SkippableFact]
+ public void GithubRootedDirectoryPatternMatchesSubdirectoriesOnlyAtRoot()
+ {
+ // "/build/logs/" owns the root build/logs directory and all its subdirectories
+ var codeOwners = Create("/build/logs/ @doctocat\n", CodeOwners.Platform.GitHub);
+ Match(codeOwners, "/build/logs/build-app/error.txt").Should().Equal(["@doctocat"]);
+ Match(codeOwners, "/build/logs/error.txt").Should().Equal(["@doctocat"]);
+ Match(codeOwners, "/x/build/logs/error.txt").Should().BeEmpty();
+ }
+
+ [SkippableFact]
+ public void GithubWildcardSegmentDoesNotOwnNestedFiles()
+ {
+ // `docs/*` matches files like `docs/getting-started.md` but not deeper nested files like
+ // `docs/build-app/troubleshooting.md`
+ var codeOwners = Create("* @global\ndocs/* docs@example.com\n", CodeOwners.Platform.GitHub);
+ Match(codeOwners, "/docs/getting-started.md").Should().Equal(["docs@example.com"]);
+ Match(codeOwners, "/docs/build-app/troubleshooting.md").Should().Equal(["@global"]);
+ }
+
+ [SkippableFact]
+ public void GithubDirectoryPatternOwnsEverythingUnderneath()
+ {
+ var codeOwners = Create("* @global\n/docs/ @doctocat\n", CodeOwners.Platform.GitHub);
+ Match(codeOwners, "/docs/getting-started.md").Should().Equal(["@doctocat"]);
+ Match(codeOwners, "/docs/build-app/troubleshooting.md").Should().Equal(["@doctocat"]);
+ }
+
+ [SkippableFact]
+ public void GithubDirectoryAndTerminalGlobstarPatternsRequireDescendants()
+ {
+ var codeOwners = Create("/docs/ @directory\n/archive/** @globstar\n", CodeOwners.Platform.GitHub);
+
+ Match(codeOwners, "/docs").Should().BeEmpty("a trailing slash only denotes a directory and its contents");
+ Match(codeOwners, "/docs/file.txt").Should().Equal(["@directory"]);
+ Match(codeOwners, "/archive").Should().BeEmpty("a terminal /** means content inside the directory");
+ Match(codeOwners, "/archive/file.txt").Should().Equal(["@globstar"]);
+ Match(codeOwners, "/archive/deep/file.txt").Should().Equal(["@globstar"]);
+ }
+
+ [SkippableFact]
+ public void GitlabTerminalGlobstarMatchesOnePathSegment()
+ {
+ var terminalGlobstar = Create("/archive/** @single-segment\n", CodeOwners.Platform.GitLab);
+ var recursiveGlobstar = Create("/archive/**/* @recursive\n", CodeOwners.Platform.GitLab);
+
+ Match(terminalGlobstar, "/archive").Should().BeEmpty();
+ Match(terminalGlobstar, "/archive/file.txt").Should().Equal(["@single-segment"]);
+ Match(terminalGlobstar, "/archive/deep/file.txt").Should().BeEmpty();
+ Match(recursiveGlobstar, "/archive/deep/file.txt").Should().Equal(["@recursive"]);
+ }
+
+ [SkippableFact]
+ public void GithubRootedVersusUnrootedPatterns()
+ {
+ // Unrooted patterns match anywhere in the repository, rooted ones only at the repository root
+ Match(Create("/apps/ @root-apps\n", CodeOwners.Platform.GitHub), "/apps/a.go").Should().Equal(["@root-apps"]);
+ Match(Create("/apps/ @root-apps\n", CodeOwners.Platform.GitHub), "/x/apps/a.go").Should().BeEmpty();
+ Match(Create("apps/ @anywhere\n", CodeOwners.Platform.GitHub), "/x/apps/a.go").Should().Equal(["@anywhere"]);
+ }
+
+ [SkippableFact]
+ public void GithubPatternsWithMiddleSlashAreRootedWhileGitLabPatternsRemainRelative()
+ {
+ const string content = "* @global\ndocs/* @docs\na/**/b @globstar\napps/ @apps\n**/logs @logs\n";
+
+ var github = Create(content, CodeOwners.Platform.GitHub);
+ Match(github, "/docs/a.md").Should().Equal(["@docs"]);
+ Match(github, "/examples/docs/a.md").Should().Equal(["@global"]);
+ Match(github, "/a/b").Should().Equal(["@globstar"]);
+ Match(github, "/a/x/b").Should().Equal(["@globstar"]);
+ Match(github, "/x/a/b").Should().Equal(["@global"]);
+ Match(github, "/x/apps/a.md").Should().Equal(["@apps"]);
+ Match(github, "/x/logs/a.md").Should().Equal(["@logs"]);
+
+ var gitlab = Create(content, CodeOwners.Platform.GitLab);
+ Match(gitlab, "/docs/a.md").Should().Equal(["@docs"]);
+ Match(gitlab, "/examples/docs/a.md").Should().Equal(["@docs"]);
+ Match(gitlab, "/x/a/b").Should().Equal(["@globstar"]);
+ }
+
+ [SkippableFact]
+ public void GithubGlobstarDirectoryPatternOwnsDirectoryContents()
+ {
+ // `**/logs` owns any file in a logs directory such as `/build/logs`, `/scripts/logs`,
+ // and `/deeply/nested/logs`
+ var codeOwners = Create("**/logs @octocat\n*.tmp @temp-team\n", CodeOwners.Platform.GitHub);
+ Match(codeOwners, "/build/logs/error.txt").Should().Equal(["@octocat"]);
+ Match(codeOwners, "/deeply/nested/logs/x.txt").Should().Equal(["@octocat"]);
+ Match(codeOwners, "/logs").Should().Equal(["@octocat"]);
+ Match(codeOwners, "/catalog/data.tmp").Should().Equal(["@temp-team"]); // no partial segment matches
+ }
+
+ [SkippableFact]
+ public void OwnerlessEntryLeavesSubtreeUnowned()
+ {
+ // Example from the GitHub documentation: `/apps/github` has no owners, so any change inside
+ // it can be made with the approval of any user with write access (i.e. it is unowned).
+ var codeOwners = Create("* @global-owner1 @global-owner2\n*.go docs@example.com\n/apps/ @octocat\n/apps/github\n", CodeOwners.Platform.GitHub);
+ Match(codeOwners, "/apps/github").Should().BeEmpty();
+ Match(codeOwners, "/apps/github/proj/main.go").Should().BeEmpty();
+ Match(codeOwners, "/other.go").Should().Equal(["docs@example.com"]);
+ }
+
+ [SkippableFact]
+ public void PathsAreCaseSensitive()
+ {
+ var codeOwners = Create("Readme.MD @docs\n*.Txt @txt\n", CodeOwners.Platform.GitHub);
+ Match(codeOwners, "/readme.md").Should().BeEmpty();
+ Match(codeOwners, "/Readme.MD").Should().Equal(["@docs"]);
+ Match(codeOwners, "/a.txt").Should().BeEmpty();
+ Match(codeOwners, "/a.Txt").Should().Equal(["@txt"]);
+ }
+
+ [SkippableFact]
+ public void LastMatchingRuleWinsGloballyForGitHub()
+ {
+ var codeOwners = Create("*.js @first\n*.js @second\n", CodeOwners.Platform.GitHub);
+ Match(codeOwners, "/x/y.js").Should().Equal(["@second"]);
+ }
+
+ [SkippableFact]
+ public void GitLabSectionSyntaxDoesNotScopeGitHubRules()
+ {
+ var codeOwners = Create("*.js @first\n[Docs] @ignored\n*.md @docs\n*.js @second\n", CodeOwners.Platform.GitHub);
+
+ Match(codeOwners, "/x/y.js").Should().Equal(["@second"]);
+ Match(codeOwners, "/README.md").Should().Equal(["@docs"]);
+ codeOwners.ParsingDiagnosticsCount.Should().Be(1);
+ }
+
+ [SkippableFact]
+ public void GlobstarMatchesZeroDirectories()
+ {
+ var codeOwners = Create("/db/**/index.md @index-docs\n/docs/**/*.md @markdown-docs\n", CodeOwners.Platform.GitLab);
+ Match(codeOwners, "/db/index.md").Should().Equal(["@index-docs"]);
+ Match(codeOwners, "/db/v2/index.md").Should().Equal(["@index-docs"]);
+ Match(codeOwners, "/docs/index.md").Should().Equal(["@markdown-docs"]);
+ Match(codeOwners, "/docs/api/graphql/index.md").Should().Equal(["@markdown-docs"]);
+ Match(codeOwners, "/docs/api/index.xml").Should().BeEmpty();
+ }
+
+ [SkippableFact]
+ public void RelativePathsMatchAtAnyDepth()
+ {
+ // GitLab: paths without a leading slash are treated as globstar paths and match at any depth
+ var codeOwners = Create("internal/README.md @user4\n", CodeOwners.Platform.GitLab);
+ Match(codeOwners, "/internal/README.md").Should().Equal(["@user4"]);
+ Match(codeOwners, "/docs/api/internal/README.md").Should().Equal(["@user4"]);
+ Match(codeOwners, "/docs/README.md").Should().BeEmpty();
+ }
+
+ [SkippableFact]
+ public void GitLabSectionsAreEvaluatedIndependentlyAndCombined()
+ {
+ var codeOwners = Create(GitlabSectionsExample, CodeOwners.Platform.GitLab);
+ // The last matching entry in each section is used, and all sections are combined
+ Match(codeOwners, "/README.md").Should().Equal(["@admin", "@user1", "@user2", "@user3"]);
+ Match(codeOwners, "/internal/README.md").Should().Equal(["@admin", "@user3", "@user4"]);
+ }
+
+ [SkippableFact]
+ public void SectionDefaultOwnersApplyOnlyToEntriesWithoutExplicitOwners()
+ {
+ // Example from the GitLab documentation
+ var content = """
+ [Database] @database-team @agarcia
+ model/db/
+ config/db/database-setup.md @docs-team
+ """;
+ var codeOwners = Create(content, CodeOwners.Platform.GitLab);
+
+ Match(codeOwners, "/model/db/schema.rb").Should().Equal(["@agarcia", "@database-team"]);
+ Match(codeOwners, "/config/db/database-setup.md").Should().Equal(["@docs-team"]);
+ // Paths not covered by any entry of the section are not owned by the section default
+ Match(codeOwners, "/other/file.txt").Should().BeEmpty();
+ }
+
+ [SkippableFact]
+ public void OptionalSectionsAndApprovalCountsDoNotAffectMatching()
+ {
+ var codeOwners = Create("^[Go]\n*.go @go-owner\n[Big][5]\nbig/ @big-owner\n[Team] @default-team\nteam/\n", CodeOwners.Platform.GitLab);
+ Match(codeOwners, "/x.go").Should().Equal(["@go-owner"]);
+ Match(codeOwners, "/big/file.txt").Should().Equal(["@big-owner"]);
+ Match(codeOwners, "/team/file.txt").Should().Equal(["@default-team"]); // inherits section defaults
+ }
+
+ [SkippableFact]
+ public void RoleOwnersAreKeptAsOwners()
+ {
+ var codeOwners = Create("/config/setup.yml @@maintainer\n", CodeOwners.Platform.GitLab);
+ Match(codeOwners, "/config/setup.yml").Should().Equal(["@@maintainer"]);
+ }
+
+ [SkippableFact]
+ public void ExclusionsAreStickyWithinSection()
+ {
+ // Example from the GitLab documentation: once a path is excluded, later rules in the same
+ // section cannot re-include it.
+ var codeOwners = Create("* @default-owner\n!*.rb\n/special/*.rb @ruby-owner\n", CodeOwners.Platform.GitLab);
+ Match(codeOwners, "/special/foo.rb").Should().BeEmpty();
+ Match(codeOwners, "/code.rb").Should().BeEmpty();
+ Match(codeOwners, "/other.txt").Should().Equal(["@default-owner"]);
+ }
+
+ [SkippableFact]
+ public void ExclusionsApplyPerSection()
+ {
+ // Example from the GitLab documentation: use multiple sections to exclude with one owner set
+ // and still require approval from another.
+ var content = """
+ [Ruby]
+ *.rb @ruby-team
+ !/config/**/*.rb
+
+ [Config]
+ /config/ @ops-team
+ """;
+ var codeOwners = Create(content, CodeOwners.Platform.GitLab);
+
+ Match(codeOwners, "/config/routes.rb").Should().Equal(["@ops-team"]);
+ Match(codeOwners, "/lib/foo.rb").Should().Equal(["@ruby-team"]);
+ Match(codeOwners, "/config/other.xml").Should().Equal(["@ops-team"]);
+ }
+
+ [SkippableFact]
+ public void InlineCommentsAreUnsupportedInGitLab()
+ {
+ var codeOwners = Create("*.rb @ruby-owner # note to self\n", CodeOwners.Platform.GitLab);
+ Match(codeOwners, "/a.rb").Should().Equal(["@ruby-owner"]);
+ }
+
+ [SkippableFact]
+ public void CommentLinesWithLeadingWhitespaceAreIgnored()
+ {
+ foreach (var platform in new[] { CodeOwners.Platform.GitHub, CodeOwners.Platform.GitLab })
+ {
+ var codeOwners = Create(" # indented comment\n *.md @md-owner\n", platform);
+ Match(codeOwners, "/a.md").Should().Equal(["@md-owner"]);
+ // The indented comment must not be parsed as a "#" pattern entry
+ Match(codeOwners, "/#").Should().BeEmpty();
+ }
+ }
+
+ [SkippableFact]
+ public void DirectoryPatternsMatchWithWindowsSeparators()
+ {
+ var codeOwners = Create("**/logs @octocat\n/build/logs/ @doctocat\n", CodeOwners.Platform.GitHub);
+ Match(codeOwners, @"\build\logs\error.txt").Should().Equal(["@doctocat"]);
+ Match(codeOwners, @"\scripts\logs\x.txt").Should().Equal(["@octocat"]);
+ }
+
+ [SkippableFact]
+ public void DuplicateEntriesUseLastWithinSection()
+ {
+ // "If an entry is duplicated in a section, the last entry is used"
+ var codeOwners = Create("README.md @old\nREADME.md @new\n", CodeOwners.Platform.GitLab);
+ Match(codeOwners, "/README.md").Should().Equal(["@new"]);
+ }
+
+ [SkippableFact]
+ public void GitLabLastDuplicatePatternReplacesEarlierEntriesIncludingExclusions()
+ {
+ var exactDuplicates = Create("[Ruby]\n*.rb @old\n!*.rb\n*.rb @new\n", CodeOwners.Platform.GitLab);
+ Match(exactDuplicates, "/model.rb").Should().Equal(["@new"]);
+
+ // GitLab normalizes these spellings to the same pattern key before replacing duplicates.
+ var normalizedDuplicates = Create("* @old\n!/**/*\n* @new\n", CodeOwners.Platform.GitLab);
+ Match(normalizedDuplicates, "/nested/file.cs").Should().Equal(["@new"]);
+ }
+
+ [SkippableFact]
+ public void GitLabDuplicateNormalizationUnescapesLeadingHashBeforeReplacement()
+ {
+ var laterOwner = Create("!#file\n\\#file @new\n", CodeOwners.Platform.GitLab);
+ Match(laterOwner, "/#file").Should().Equal(["@new"]);
+
+ var laterExclusion = Create("\\#file @old\n!#file\n", CodeOwners.Platform.GitLab);
+ Match(laterExclusion, "/#file").Should().BeEmpty();
+ }
+
+ [SkippableFact]
+ public void GitLabCharacterClassesSupportSetsRangesAndNegationWithoutCrossingDirectories()
+ {
+ var content = "digit[0-9].txt @digit\nletter[ab].txt @letter\nnon-digit[!0-9].txt @non-digit\npath[!x]file @same-segment\n";
+ var codeOwners = Create(content, CodeOwners.Platform.GitLab);
+
+ Match(codeOwners, "/digit7.txt").Should().Equal(["@digit"]);
+ Match(codeOwners, "/digitx.txt").Should().BeEmpty();
+ Match(codeOwners, "/lettera.txt").Should().Equal(["@letter"]);
+ Match(codeOwners, "/letterc.txt").Should().BeEmpty();
+ Match(codeOwners, "/non-digita.txt").Should().Equal(["@non-digit"]);
+ Match(codeOwners, "/non-digit7.txt").Should().BeEmpty();
+ Match(codeOwners, "/path/file").Should().BeEmpty();
+ }
+
+ [SkippableFact]
+ public void GitLabMalformedCharacterClassesCannotAbortParserInitialization()
+ {
+ var content = "* @fallback\nfile[z-a].txt @invalid-range\nbroken\\\nfile[---!].txt @punctuation\n";
+
+ var codeOwners = Create(content, CodeOwners.Platform.GitLab);
+
+ codeOwners.ParsingDiagnosticsCount.Should().Be(2, "invalid rules should produce one aggregated parsing diagnostic count");
+ Match(codeOwners, "/filex.txt").Should().Equal(["@fallback"]);
+ Match(codeOwners, "/file-.txt").Should().Equal(["@punctuation"]);
+ Match(codeOwners, "/file!.txt").Should().Equal(["@punctuation"]);
+ }
+
+ [SkippableFact]
+ public void GitLabCharacterClassesCannotStartWithClosingBracket()
+ {
+ var codeOwners = Create("* @fallback\nfile[]a].txt @positive\nfile[!]].txt @negated\n", CodeOwners.Platform.GitLab);
+
+ codeOwners.ParsingDiagnosticsCount.Should().Be(2);
+ Match(codeOwners, "/filea.txt").Should().Equal(["@fallback"]);
+ Match(codeOwners, "/file].txt").Should().Equal(["@fallback"]);
+ Match(codeOwners, "/filex.txt").Should().Equal(["@fallback"]);
+ }
+
+ [SkippableFact]
+ public void GitLabBackslashEscapesGlobMetacharactersAndOrdinaryCharacters()
+ {
+ var content = """
+ file\?.txt @question
+ literal\*.txt @star
+ letter\a.txt @ordinary
+ bracket[\]].txt @closing-bracket
+ hyphen[\-].txt @hyphen
+ """;
+ var codeOwners = Create(content, CodeOwners.Platform.GitLab);
+
+ Match(codeOwners, "/file?.txt").Should().Equal(["@question"]);
+ Match(codeOwners, "/fileX.txt").Should().BeEmpty();
+ Match(codeOwners, "/literal*.txt").Should().Equal(["@star"]);
+ Match(codeOwners, "/literal-value.txt").Should().BeEmpty();
+ Match(codeOwners, "/lettera.txt").Should().Equal(["@ordinary"]);
+ Match(codeOwners, "/bracket].txt").Should().Equal(["@closing-bracket"]);
+ Match(codeOwners, "/hyphen-.txt").Should().Equal(["@hyphen"]);
+ }
+
+ [SkippableFact]
+ public void GithubBackslashEscapesMetacharactersSpacesAndInlineCommentMarkers()
+ {
+ var content = """
+ literal\*.txt @star
+ file\?.txt @question
+ bracket\[name\].txt @bracket
+ path\ with\ spaces/ @spaces
+ middle\#hash.txt @hash
+ trailing\
+ """;
+ var codeOwners = Create(content, CodeOwners.Platform.GitHub);
+
+ Match(codeOwners, "/literal*.txt").Should().Equal(["@star"]);
+ Match(codeOwners, "/literal-value.txt").Should().BeEmpty();
+ Match(codeOwners, "/file?.txt").Should().Equal(["@question"]);
+ Match(codeOwners, "/fileX.txt").Should().BeEmpty();
+ Match(codeOwners, "/bracket[name].txt").Should().Equal(["@bracket"]);
+ Match(codeOwners, "/path with spaces/file.txt").Should().Equal(["@spaces"]);
+ Match(codeOwners, "/middle#hash.txt").Should().Equal(["@hash"]);
+ codeOwners.ParsingDiagnosticsCount.Should().Be(1, "a trailing backslash is an invalid glob");
+ }
+
+ [SkippableFact]
+ public void WhitespaceOnlyGitLabSectionHeaderStartsInvalidNamedSection()
+ {
+ var content = "[Docs] @docs\n[ ] @blank\nREADME.md\n";
+ var codeOwners = Create(content, CodeOwners.Platform.GitLab);
+
+ Match(codeOwners, "/README.md").Should().Equal(["@blank"]);
+ Match(codeOwners, "/guide.md").Should().BeEmpty();
+ codeOwners.ParsingDiagnosticsCount.Should().Be(1, "GitLab accepts the header but diagnoses its missing name");
+ }
+
+ [SkippableFact]
+ public void UnparsableGitLabSectionHeaderIsSkippedInsteadOfBecomingPattern()
+ {
+ var content = "* @global\n[Broken\n";
+ var codeOwners = Create(content, CodeOwners.Platform.GitLab);
+
+ Match(codeOwners, "/[Broken").Should().Equal(["@global"]);
+ codeOwners.ParsingDiagnosticsCount.Should().Be(1);
+ }
+
+ [SkippableFact]
+ public void GitLabMalformedSectionSuffixCannotLeakDefaultOwners()
+ {
+ var extraBracket = Create("[Docs]] @leaked\nREADME.md\n", CodeOwners.Platform.GitLab);
+ Match(extraBracket, "/README.md").Should().BeEmpty();
+ extraBracket.ParsingDiagnosticsCount.Should().Be(2, "the malformed header and ownerless entry are diagnosed independently");
+
+ var invalidApproval = Create("[Docs][x] @leaked\nREADME.md\n", CodeOwners.Platform.GitLab);
+ Match(invalidApproval, "/README.md").Should().BeEmpty();
+ invalidApproval.ParsingDiagnosticsCount.Should().Be(2);
+ }
+
+ [SkippableFact]
+ public void GitLabPermissiveSectionParsingKeepsOnlyTheRecognizedOwnerSpan()
+ {
+ var codeOwners = Create("[Docs][2]@owner\nREADME.md\n", CodeOwners.Platform.GitLab);
+
+ Match(codeOwners, "/README.md").Should().Equal(["@owner"]);
+ codeOwners.ParsingDiagnosticsCount.Should().Be(1, "the missing whitespace is diagnosed without discarding recognized defaults");
+ }
+
+ [SkippableTheory]
+ [InlineData("@@developer")]
+ [InlineData("@@developers")]
+ [InlineData("@@maintainer")]
+ [InlineData("@@maintainers")]
+ [InlineData("@@owner")]
+ [InlineData("@@OwNeRs")]
+ public void GitLabRecognizedRolesAreKeptAsOwners(string role)
+ {
+ var codeOwners = Create("*.cs " + role + "\n", CodeOwners.Platform.GitLab);
+
+ Match(codeOwners, "/file.cs").Should().Equal([role]);
+ }
+
+ [SkippableFact]
+ public void GitLabUnknownRolesAreIgnored()
+ {
+ var codeOwners = Create("*.cs @@banana @valid\n", CodeOwners.Platform.GitLab);
+
+ Match(codeOwners, "/file.cs").Should().Equal(["@valid"]);
+ codeOwners.ParsingDiagnosticsCount.Should().Be(1, "the invalid role was discarded from an otherwise valid rule");
+ }
+
+ [SkippableFact]
+ public void OwnerValidationFollowsPlatformRulesAndDoesNotApplyDefaultsToMalformedExplicitOwners()
+ {
+ var github = Create("* @global\n*.cs docs@\n*.fs @@maintainer\n", CodeOwners.Platform.GitHub);
+ Match(github, "/file.cs").Should().Equal(["@global"]);
+ Match(github, "/file.fs").Should().Equal(["@global"]);
+ github.ParsingDiagnosticsCount.Should().Be(2, "GitHub rejects each complete rule that contains an invalid owner");
+
+ var gitlab = Create("[Docs] @default malformed@\n*.md malformed@ @valid\nREADME.md malformed@\nGUIDE.md\n", CodeOwners.Platform.GitLab);
+ Match(gitlab, "/other.md").Should().Equal(["@valid"]);
+ Match(gitlab, "/README.md").Should().BeEmpty();
+ Match(gitlab, "/GUIDE.md").Should().Equal(["@default"]);
+ gitlab.ParsingDiagnosticsCount.Should().Be(3, "invalid owners in defaults and rules are all diagnosed while valid owners remain usable");
+ }
+
+ [SkippableFact]
+ public void OwnerExtractionRejectsImpossibleGithubReferencesAndCanonicalizesGitLabReferences()
+ {
+ var github = Create("* @fallback\n*.cs @!\n*.fs user@example.\n*.vb (@valid)\n*.ts @bad+owner\n*.go @org/team/nested\n", CodeOwners.Platform.GitHub);
+ Match(github, "/file.cs").Should().Equal(["@fallback"]);
+ Match(github, "/file.fs").Should().Equal(["@fallback"]);
+ Match(github, "/file.vb").Should().Equal(["@fallback"]);
+ Match(github, "/file.ts").Should().Equal(["@fallback"]);
+ Match(github, "/file.go").Should().Equal(["@fallback"]);
+ github.ParsingDiagnosticsCount.Should().Be(5);
+
+ var gitlab = Create("*.cs @! @good\n*.md (@docs)\n*.txt docs@example.\n*.go @group/nested-team\n", CodeOwners.Platform.GitLab);
+ Match(gitlab, "/file.cs").Should().Equal(["@good"]);
+ Match(gitlab, "/file.md").Should().Equal(["@docs"]);
+ Match(gitlab, "/file.txt").Should().Equal(["docs@example"]);
+ Match(gitlab, "/file.go").Should().Equal(["@group/nested-team"]);
+ gitlab.ParsingDiagnosticsCount.Should().Be(1, "only the token without an extractable reference is malformed");
+ }
+
+ [SkippableFact]
+ public void GithubAcceptsEnterpriseManagedUserNames()
+ {
+ var codeOwners = Create("*.cs @mona-cat_octo\n*.fs @octo_admin\n", CodeOwners.Platform.GitHub);
+
+ Match(codeOwners, "/file.cs").Should().Equal(["@mona-cat_octo"]);
+ Match(codeOwners, "/file.fs").Should().Equal(["@octo_admin"]);
+ codeOwners.ParsingDiagnosticsCount.Should().Be(0);
+ }
+
+ [SkippableFact]
+ public void GitLabNamespaceReferencesMayEndWithHyphens()
+ {
+ var codeOwners = Create("*.cs @team-\n*.fs (@group-/subgroup-)\n", CodeOwners.Platform.GitLab);
+
+ Match(codeOwners, "/file.cs").Should().Equal(["@team-"]);
+ Match(codeOwners, "/file.fs").Should().Equal(["@group-/subgroup-"]);
+ codeOwners.ParsingDiagnosticsCount.Should().Be(0);
+ }
+
+ [SkippableFact]
+ public void GitLabReferenceExtractionScansNamesRolesAndEmailsIndependently()
+ {
+ var content = "*.cs docs@example.com,@alice\n*.fs alice@example.com!alias\n*.vb (@@maintainer\n";
+ var codeOwners = Create(content, CodeOwners.Platform.GitLab);
+
+ Match(codeOwners, "/file.cs").Should().Equal(["@alice", "docs@example.com"]);
+ Match(codeOwners, "/file.fs").Should().Equal(["alice@example.com!alias"]);
+ Match(codeOwners, "/file.vb").Should().Equal(["@@maintainer"]);
+ codeOwners.ParsingDiagnosticsCount.Should().Be(0);
+ }
+
+ [SkippableFact]
+ public void GitLabExclusionsIgnoreOwnerTextForDiagnostics()
+ {
+ var codeOwners = Create("!*.cs definitely-not-an-owner\n", CodeOwners.Platform.GitLab);
+
+ Match(codeOwners, "/file.cs").Should().BeEmpty();
+ codeOwners.ParsingDiagnosticsCount.Should().Be(0);
+ }
+
+ [SkippableFact]
+ public void GitLabOwnerlessEntriesAreDiagnosedWithoutChangingMatchingSemantics()
+ {
+ var codeOwners = Create("*.md\n", CodeOwners.Platform.GitLab);
+
+ Match(codeOwners, "/README.md").Should().BeEmpty();
+ codeOwners.ParsingDiagnosticsCount.Should().Be(1);
+ }
+
+ [SkippableTheory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void EscapedSlashesRemainPathSeparators(bool useGitLab)
+ {
+ var platform = useGitLab ? CodeOwners.Platform.GitLab : CodeOwners.Platform.GitHub;
+ var codeOwners = Create("dir\\/file.txt @file\ndocs\\/ @docs\n", platform);
+
+ Match(codeOwners, "/dir/file.txt").Should().Equal(["@file"]);
+ Match(codeOwners, "/docs/guide.md").Should().Equal(["@docs"]);
+ if (useGitLab)
+ {
+ Match(codeOwners, "/nested/dir/file.txt").Should().Equal(["@file"]);
+ }
+ else
+ {
+ Match(codeOwners, "/nested/dir/file.txt").Should().BeEmpty("a slash roots a GitHub pattern");
+ }
+
+ codeOwners.ParsingDiagnosticsCount.Should().Be(0);
+ }
+
+ [SkippableFact]
+ public void FormerGlobstarSentinelTextIsMatchedLiterally()
+ {
+ var codeOwners = Create("* @global\n§§DOUBLESTAR§§ @literal\n", CodeOwners.Platform.GitHub);
+
+ Match(codeOwners, "/§§DOUBLESTAR§§").Should().Equal(["@literal"]);
+ Match(codeOwners, "/anything-else").Should().Equal(["@global"]);
+ }
+
+ [SkippableFact]
+ public void PathologicalPatternMatchingIsDeterministicBoundedAndThreadSafe()
+ {
+ var pathologicalPattern = string.Concat(Enumerable.Repeat("*a", 32)) + "b";
+ var codeOwners = Create("* @global\n" + pathologicalPattern + " @slow\n", CodeOwners.Platform.GitHub);
+ var nonMatchingPath = "/" + new string('a', 2_000) + "c";
+ var matchingPath = "/" + new string('a', 32) + "b";
+
+ var stopwatch = Stopwatch.StartNew();
+ for (var i = 0; i < 100; i++)
+ {
+ Match(codeOwners, nonMatchingPath).Should().Equal(["@global"]);
+ }
+
+ stopwatch.Stop();
+ stopwatch.Elapsed.Should().BeLessThan(TestTimeout, "glob matching has bounded, non-backtracking cost");
+ Match(codeOwners, matchingPath).Should().Equal(["@slow"], "a difficult non-match must not disable the rule globally");
+
+ var concurrentMatches = Enumerable.Range(0, 64)
+ .Select(_ => Task.Run(() => Match(codeOwners, nonMatchingPath)))
+ .ToArray();
+ Task.WaitAll(concurrentMatches, TestTimeout).Should().BeTrue("concurrent matching must not deadlock");
+ foreach (var concurrentMatch in concurrentMatches)
+ {
+ concurrentMatch.Result.Should().Equal(["@global"]);
+ }
+ }
+
+ [SkippableFact]
+ public void OverlongSegmentPatternIsRejectedBeforeMatching()
+ {
+ var pathologicalPattern = "*" + new string('a', 1_024) + "b";
+ var codeOwners = Create("* @global\n" + pathologicalPattern + " @slow\n", CodeOwners.Platform.GitHub);
+
+ codeOwners.ParsingDiagnosticsCount.Should().Be(1);
+ Match(codeOwners, "/" + new string('a', 2_000) + "c").Should().Equal(["@global"]);
+ }
+
+ [SkippableFact]
+ public void SegmentWildcardMatchingStopsAtWorkLimit()
+ {
+ var repeatedSuffixPattern = "*" + new string('a', 256) + "b";
+ var codeOwners = Create("* @global\n" + repeatedSuffixPattern + " @slow\n", CodeOwners.Platform.GitHub);
+
+ codeOwners.ParsingDiagnosticsCount.Should().Be(0, "the rule is valid and bounded at match time");
+ Match(codeOwners, "/" + new string('a', 2_000) + "b").Should().Equal(["@global"]);
+ }
+
+ [SkippableFact]
+ public void LargeUniqueOwnerListsHaveLinearRepeatedMatchCost()
+ {
+ const int ownerCount = 5_000;
+ var owners = string.Join(" ", Enumerable.Range(0, ownerCount).Select(i => "@owner" + i));
+ var codeOwners = Create("*.cs " + owners + "\n", CodeOwners.Platform.GitHub);
+
+ var stopwatch = Stopwatch.StartNew();
+ for (var i = 0; i < 20; i++)
+ {
+ codeOwners.Match("/file.cs").Count().Should().Be(ownerCount);
+ }
+
+ stopwatch.Stop();
+ stopwatch.Elapsed.Should().BeLessThan(TestTimeout, "owners are deduplicated once during parsing, not quadratically on every match");
+ }
+
+ [SkippableFact]
+ public void LongMalformedGitLabOwnerTokensHaveBoundedParsingCost()
+ {
+ var malformedOwner = new string('x', 4_000_000);
+ var path = WriteTemporaryCodeOwners("*.cs " + malformedOwner + "\n");
+ try
+ {
+ var stopwatch = Stopwatch.StartNew();
+ var codeOwners = new CodeOwners(path, CodeOwners.Platform.GitLab);
+ stopwatch.Stop();
+
+ stopwatch.Elapsed.Should().BeLessThan(TestTimeout, "the broad timeout guards against hangs without acting as a microbenchmark");
+ Match(codeOwners, "/file.cs").Should().BeEmpty();
+ codeOwners.ParsingDiagnosticsCount.Should().Be(1);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [SkippableFact]
+ public void LongMalformedGitLabSectionHeadersHaveBoundedParsingCost()
+ {
+ var malformedHeader = "[Docs] " + new string(' ', 1_000_000) + "!\n";
+ var path = WriteTemporaryCodeOwners(malformedHeader);
+ try
+ {
+ var stopwatch = Stopwatch.StartNew();
+ var codeOwners = new CodeOwners(path, CodeOwners.Platform.GitLab);
+ stopwatch.Stop();
+
+ stopwatch.Elapsed.Should().BeLessThan(TestTimeout, "section validation must scan malformed headers without regex backtracking");
+ codeOwners.ParsingDiagnosticsCount.Should().Be(1);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [SkippableFact]
+ public void DuplicateOwnersAreDeduplicatedOnceInStableOrder()
+ {
+ var codeOwners = Create("*.cs @first @second @first @third @second\n", CodeOwners.Platform.GitHub);
+
+ codeOwners.Match("/file.cs").Should().Equal(["@first", "@second", "@third"]);
+ }
+
+ [SkippableFact]
+ public void LargeGitLabDuplicatePatternSetsAreCompactedLinearly()
+ {
+ const int patternCount = 20_000;
+ var firstDefinitions = Enumerable.Range(0, patternCount).Select(i => $"/path/{i}.cs @old");
+ var replacements = Enumerable.Range(0, patternCount).Select(i => $"/path/{i}.cs @new");
+ var content = string.Join("\n", firstDefinitions.Concat(replacements)) + "\n";
+
+ var path = WriteTemporaryCodeOwners(content);
+ try
+ {
+ var stopwatch = Stopwatch.StartNew();
+ var codeOwners = new CodeOwners(path, CodeOwners.Platform.GitLab);
+ stopwatch.Stop();
+
+ stopwatch.Elapsed.Should().BeLessThan(TestTimeout, "duplicate replacement is a single reverse pass instead of repeated List.Remove calls");
+ Match(codeOwners, "/path/0.cs").Should().Equal(["@new"]);
+ Match(codeOwners, $"/path/{patternCount - 1}.cs").Should().Equal(["@new"]);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [SkippableFact]
+ public void GithubIgnoresCodeOwnersFilesOverThreeMegabytesOnly()
+ {
+ var belowLimit = WriteTemporaryCodeOwnersWithLength(CodeOwners.GitHubMaximumFileSizeBytes - 1);
+ var aboveLimit = WriteTemporaryCodeOwnersWithLength(CodeOwners.GitHubMaximumFileSizeBytes + 1);
+ try
+ {
+ Match(new CodeOwners(belowLimit, CodeOwners.Platform.GitHub), "/file.cs").Should().Equal(["@owner"]);
+ Match(new CodeOwners(aboveLimit, CodeOwners.Platform.GitHub), "/file.cs").Should().BeEmpty();
+ Match(new CodeOwners(aboveLimit, CodeOwners.Platform.GitLab), "/file.cs").Should().Equal(["@owner"]);
+ }
+ finally
+ {
+ File.Delete(belowLimit);
+ File.Delete(aboveLimit);
+ }
+ }
+
+ [SkippableFact]
+ public void TryLoadReturnsFalseWhenCodeOwnersDisappearsBeforeOpening()
+ {
+ var missingPath = Path.Combine(Path.GetTempPath(), "dd-codeowners-missing-" + Guid.NewGuid().ToString("N"));
+
+ CodeOwners.TryLoad(missingPath, CodeOwners.Platform.GitHub, out var codeOwners).Should().BeFalse();
+ codeOwners.Should().BeNull();
+ }
+
+ [SkippableFact]
+ public void GitLabDuplicateSectionsAreCombinedCaseInsensitively()
+ {
+ var content = "[Docs]\n*.md @old\n[DOCS]\nREADME.md @new\n";
+ var codeOwners = Create(content, CodeOwners.Platform.GitLab);
+
+ Match(codeOwners, "/README.md").Should().Equal(["@new"]);
+ Match(codeOwners, "/guide.md").Should().Equal(["@old"]);
+ }
+
+ [SkippableFact]
+ public void GitLabDuplicateSectionExclusionsAreStickyAcrossOccurrences()
+ {
+ var content = "[Ruby]\n*.rb @ruby-team\n[RUBY]\n!/config/**/*.rb\n/config/routes.rb @ops\n";
+ var codeOwners = Create(content, CodeOwners.Platform.GitLab);
+
+ Match(codeOwners, "/lib/model.rb").Should().Equal(["@ruby-team"]);
+ Match(codeOwners, "/config/routes.rb").Should().BeEmpty();
+ }
+
+ [SkippableFact]
+ public void GitLabDuplicateSectionDefaultsApplyToEntriesUnderEachHeader()
+ {
+ var content = "[Docs] @old-default\n*.md\n[DOCS] @new-default\nREADME.md\n";
+ var codeOwners = Create(content, CodeOwners.Platform.GitLab);
+
+ Match(codeOwners, "/guide.md").Should().Equal(["@old-default"]);
+ Match(codeOwners, "/README.md").Should().Equal(["@new-default"]);
+ }
+
+ [SkippableFact]
+ public void UnsupportedGithubSyntaxIsIgnored()
+ {
+ var content = "* @global\n!secret.txt @negation\nfile[0].cs @range\n\\#hash.txt @hash\n";
+ var codeOwners = Create(content, CodeOwners.Platform.GitHub);
+
+ Match(codeOwners, "/!secret.txt").Should().Equal(["@global"]);
+ Match(codeOwners, "/file[0].cs").Should().Equal(["@global"]);
+ Match(codeOwners, "/#hash.txt").Should().Equal(["@global"]);
+ }
+
+ [SkippableFact]
+ public void RealWorldHashicorpTerraformGitHubFile()
+ {
+ // Based on https://github.com/hashicorp/terraform/blob/main/CODEOWNERS
+ const string content = """
+ # The rules are evaluated in order, if a file matches multiple patterns, the last match "wins".
+ * @hashicorp/terraform-core
+
+ # Remote-state backend # Maintainer
+ /internal/backend/remote-state/azure @hashicorp/terraform-core @hashicorp/terraform-azure
+ #/internal/backend/remote-state/consul Unmaintained
+ /internal/backend/remote-state/s3 @hashicorp/terraform-core @hashicorp/terraform-aws
+
+ # Cloud backend
+ /internal/backend/remote @hashicorp/terraform-core @hashicorp/tf-core-cloud
+ /internal/cloud @hashicorp/terraform-core @hashicorp/tf-core-cloud
+
+ # Provisioners
+ builtin/provisioners/file @hashicorp/terraform-core
+ builtin/provisioners/local-exec @hashicorp/terraform-core
+
+ # Actions
+ /internal/command/jsonplan/action_invocations.go @hashicorp/team-tf-actions @hashicorp/terraform-core
+ """;
+ var codeOwners = Create(content, CodeOwners.Platform.GitHub);
+
+ Match(codeOwners, "/main.go").Should().Equal(["@hashicorp/terraform-core"]);
+ // Several teams on one line
+ Match(codeOwners, "/internal/backend/remote-state/s3/backend.go").Should().Equal(["@hashicorp/terraform-aws", "@hashicorp/terraform-core"]);
+ // Commented-out entry is skipped: consul falls back to the `*` rule
+ Match(codeOwners, "/internal/backend/remote-state/consul/backend.go").Should().Equal(["@hashicorp/terraform-core"]);
+ // Unrooted patterns match anywhere in the repository
+ Match(codeOwners, "/x/builtin/provisioners/file/resource.go").Should().Equal(["@hashicorp/terraform-core"]);
+ Match(codeOwners, "/internal/command/jsonplan/action_invocations.go").Should().Equal(["@hashicorp/team-tf-actions", "@hashicorp/terraform-core"]);
+ Match(codeOwners, "/internal/cloud/backend_run.go").Should().Equal(["@hashicorp/terraform-core", "@hashicorp/tf-core-cloud"]);
+ }
+
+ [SkippableFact]
+ public void RealWorldGitLabSectionedFile()
+ {
+ // Based on https://gitlab.com/gitlab-org/gitlab/-/blob/master/.gitlab/CODEOWNERS
+ const string content = """
+ [Maintainers] @gl-dx/maintainers @gitlab-org/maintainers/rails-backend
+ *
+
+ /* @gitlab-org/maintainers/frontend @gitlab-org/maintainers/database
+ *.rb @gitlab-org/maintainers/rails-backend
+ /app/ @gitlab-org/maintainers/rails-backend
+ /workhorse/ @gitlab-org/maintainers/gitlab-workhorse
+
+ ^[Database] @gitlab-org/maintainers/database
+ /spec/lib/gitlab/background_migration/
+
+ ^[Frontend dependency patches] @markrian @xanf @thutterer
+ /patches/
+ """;
+ var codeOwners = Create(content, CodeOwners.Platform.GitLab);
+
+ // The bare `*` entry has no owners and inherits the [Maintainers] section defaults
+ Match(codeOwners, "/random/path.txt")
+ .Should().Equal(["@gitlab-org/maintainers/rails-backend", "@gl-dx/maintainers"]);
+ // The /* root-level rule is defined after the bare `*` entry, so it overrides the defaults
+ // for top-level files only
+ Match(codeOwners, "/README.md")
+ .Should().Equal(["@gitlab-org/maintainers/database", "@gitlab-org/maintainers/frontend"]);
+ // Last matching entry within the section wins (/app/ is defined after *.rb)
+ Match(codeOwners, "/app/models/user.rb").Should().Equal(["@gitlab-org/maintainers/rails-backend"]);
+ Match(codeOwners, "/workhorse/Makefile").Should().Equal(["@gitlab-org/maintainers/gitlab-workhorse"]);
+ // Optional section entries without owners inherit that section's default owners,
+ // combined with the results from the [Maintainers] section where *.rb overrides
+ // the bare `*` defaults
+ var backgroundMigration = Match(codeOwners, "/spec/lib/gitlab/background_migration/foo_spec.rb");
+ backgroundMigration.Should().Contain("@gitlab-org/maintainers/database");
+ backgroundMigration.Should().Contain("@gitlab-org/maintainers/rails-backend");
+ backgroundMigration.Should().HaveCount(2);
+ var patchFiles = Match(codeOwners, "/patches/foo.diff");
+ patchFiles.Should().Contain("@markrian");
+ patchFiles.Should().Contain("@thutterer");
+ patchFiles.Should().HaveCount(5);
+ }
+
+ [SkippableFact]
+ public void MultipleLeadingSlashesAreNormalized()
+ {
+ // Callers prepend "/" to relative paths; a path that already contains leading slashes (or is
+ // empty) must still normalize to a single rooted form instead of failing to match.
+ var codeOwners = Create("*.md @md\n/docs/ @doctocat\n", CodeOwners.Platform.GitHub);
+ Match(codeOwners, "//docs/getting-started.md").Should().Equal(["@doctocat"]);
+ Match(codeOwners, "/docs/getting-started.md").Should().Equal(["@doctocat"]);
+ Match(codeOwners, string.Empty).Should().BeEmpty();
+ }
+
+ [SkippableFact]
+ public void NullPathReturnsNoOwners()
+ {
+ // Defensive: a null path must not throw and simply has no owners.
+ var codeOwners = Create("* @global\n", CodeOwners.Platform.GitHub);
+ Match(codeOwners, null!).Should().BeEmpty();
+ }
+
+ [SkippableFact]
+ public void QuestionMarkDoesNotMatchSlash()
+ {
+ var codeOwners = Create("a?c @segment\n", CodeOwners.Platform.GitHub);
+ Match(codeOwners, "/abc").Should().Equal(["@segment"]);
+ Match(codeOwners, "/aXc").Should().Equal(["@segment"]);
+ Match(codeOwners, "/a/c").Should().BeEmpty();
+ }
+
+ [SkippableFact]
+ public void DoubleStarIsGlobstarOnlyAsAWholeSegment()
+ {
+ var inSegment = Create("foo**bar @stars\n", CodeOwners.Platform.GitHub);
+ var slashAfterSegment = Create("/foo**/bar @component-stars\n", CodeOwners.Platform.GitHub);
+ var globstar = Create("**/index.md @index\n", CodeOwners.Platform.GitHub);
+
+ // Adjacent asterisks inside a segment are two single-level wildcards, not a globstar.
+ Match(inSegment, "/fooXbar").Should().Equal(["@stars"]);
+ Match(slashAfterSegment, "/foo/bar").Should().Equal(["@component-stars"]);
+ Match(slashAfterSegment, "/fooX/bar").Should().Equal(["@component-stars"]);
+ Match(slashAfterSegment, "/foobar").Should().BeEmpty("the slash after an in-segment double star remains required");
+ Match(slashAfterSegment, "/foo/x/bar").Should().BeEmpty("in-segment stars cannot cross a directory boundary");
+ Match(globstar, "/docs/index.md").Should().Equal(["@index"]);
+ Match(globstar, "/index.md").Should().Equal(["@index"]);
+ }
+
+ [SkippableFact]
+ public void DescendantMatchingIsStableAcrossRepeatedCalls()
+ {
+ // Direct directory matches and descendants share one deterministic glob; repeated calls
+ // must preserve both forms without recompilation or an ancestor walk.
+ var codeOwners = Create("**/logs @octocat\n", CodeOwners.Platform.GitHub);
+ for (var i = 0; i < 3; i++)
+ {
+ Match(codeOwners, "/build/logs/error.txt").Should().Equal(["@octocat"]);
+ Match(codeOwners, "/build/logs/nested/error.txt").Should().Equal(["@octocat"]);
+ Match(codeOwners, "/catalog/data.tmp").Should().BeEmpty();
+ Match(codeOwners, "/logs").Should().Equal(["@octocat"]);
+ }
+ }
+
+ private static CodeOwners Create(string content, CodeOwners.Platform platform)
+ {
+ var path = WriteTemporaryCodeOwners(content);
+
+ try
+ {
+ return new CodeOwners(path, platform);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ private static string WriteTemporaryCodeOwners(string content)
+ {
+ var path = Path.Combine(Path.GetTempPath(), "dd-codeowners-spec-" + Guid.NewGuid().ToString("N"));
+ File.WriteAllText(path, content);
+ return path;
+ }
+
+ private static string WriteTemporaryCodeOwnersWithLength(long length)
+ {
+ var path = Path.Combine(Path.GetTempPath(), "dd-codeowners-sized-" + Guid.NewGuid().ToString("N"));
+ var prefix = Encoding.ASCII.GetBytes("*.cs @owner\n#");
+ using var stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None);
+ stream.Write(prefix, 0, prefix.Length);
+ stream.SetLength(length);
+ return path;
+ }
+
+ private static string[] Match(CodeOwners codeOwners, string path)
+ => codeOwners.Match(path).OrderBy(o => o, StringComparer.Ordinal).ToArray();
+}
diff --git a/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersTests.cs b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersTests.cs
new file mode 100644
index 000000000000..02efd5d9a41e
--- /dev/null
+++ b/tracer/test/Datadog.Trace.Tests/Ci/CodeOwnersTests.cs
@@ -0,0 +1,101 @@
+//
+// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License.
+// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc.
+//
+
+using System.IO;
+using System.Linq;
+using Datadog.Trace.Ci;
+using Datadog.Trace.TestHelpers;
+using Xunit;
+
+namespace Datadog.Trace.Tests.Ci;
+
+public class CodeOwnersTests
+{
+ private readonly CodeOwners _githubCodeOwners;
+ private readonly CodeOwners _gitlabCodeOwners;
+
+ public CodeOwnersTests()
+ {
+ var ciDataFolder = Path.Combine(
+ EnvironmentTools.GetSolutionDirectory(),
+ "tracer",
+ "test",
+ "Datadog.Trace.ClrProfiler.IntegrationTests",
+ "CI",
+ "Data");
+
+ _githubCodeOwners = new CodeOwners(Path.Combine(ciDataFolder, "CODEOWNERS_GITHUB"), CodeOwners.Platform.GitHub);
+ _gitlabCodeOwners = new CodeOwners(Path.Combine(ciDataFolder, "CODEOWNERS_GITLAB"), CodeOwners.Platform.GitLab);
+ }
+
+ [SkippableTheory]
+ // Existing baseline expectations
+ [InlineData("unexistent/path/test.cs", "[\"@global-owner1\",\"@global-owner2\"]")]
+ [InlineData("apps/test.cs", "[\"@octocat\"]")]
+ [InlineData("/example/apps/test.cs", "[\"@octocat\"]")]
+ [InlineData("/docs/test.cs", "[\"@doctocat\"]")]
+ [InlineData("/examples/docs/test.cs", "[\"@global-owner1\",\"@global-owner2\"]")]
+ [InlineData("/src/vendor/match.go", "[\"docs@example.com\"]")]
+ [InlineData("/examples/docs/inside/test.cs", "[\"@global-owner1\",\"@global-owner2\"]")]
+ [InlineData("/component/path/test.js", "[\"@js-owner\"]")]
+ [InlineData("/mytextbox.txt", "[\"@octo-org/octocats\"]")]
+ [InlineData("/scripts/artifacts/value.js", "[\"@doctocat\",\"@octocat\"]")]
+ [InlineData("/apps/octo/test.cs", "[\"@octocat\"]")]
+ [InlineData("/apps/github", null)]
+ // Windows path separators
+ [InlineData(@"unexistent\path\test.cs", "[\"@global-owner1\",\"@global-owner2\"]")]
+ [InlineData(@"apps\test.cs", "[\"@octocat\"]")]
+ [InlineData(@"\docs\test.cs", "[\"@doctocat\"]")]
+ [InlineData(@"\examples\docs\test.cs", "[\"@global-owner1\",\"@global-owner2\"]")]
+ [InlineData(@"docs\getting-started.md", "[\"@doctocat\"]")] // docs/* vs /docs/ precedence
+ [InlineData(@"\scripts\artifacts\value.js", "[\"@doctocat\",\"@octocat\"]")]
+ [InlineData(@"\apps\github", null)]
+ [InlineData(@"\x\logs\error.txt", "[\"@octo-org/octocats\"]")]
+ // New GitHub quirks
+ [InlineData("/x/logs/error.txt", "[\"@octo-org/octocats\"]")] // matches the `*.txt` rule at any depth
+ // Rooted patterns match regardless of a leading slash, so the later `/docs/` rule
+ // (last match wins) takes precedence over the earlier `docs/*` rule.
+ [InlineData("docs/getting-started.md", "[\"@doctocat\"]")] // docs/* vs /docs/ precedence
+ public void CheckGithubCodeOwners(string value, string expected)
+ {
+ var match = _githubCodeOwners.Match(value);
+ var actual = match.Any() ? "[\"" + string.Join("\",\"", match.OrderBy(o => o)) + "\"]" : null;
+ Assert.Equal(expected, actual);
+ }
+
+ [SkippableTheory]
+ // Existing baseline expectations
+ [InlineData("apps/README.md", "[\"@code\",\"@database\",\"@docs\",\"@multiple\",\"@owners\"]")]
+ [InlineData("model/db", "[\"@code\",\"@database\",\"@multiple\",\"@owners\"]")]
+ [InlineData("/config/data.conf", "[\"@config-owner\"]")]
+ [InlineData("/docs/root.md", "[\"@root-docs\"]")]
+ [InlineData("/docs/sub/root.md", "[\"@all-docs\"]")]
+ [InlineData("/src/README", "[\"@group\",\"@group/with-nested/subgroup\"]")]
+ [InlineData("/src/lib/internal.h", "[\"@lib-owner\"]")]
+ [InlineData("src/ee/docs", "[\"@code\",\"@docs\",\"@multiple\",\"@owners\"]")]
+ // Windows path separators
+ [InlineData(@"apps\README.md", "[\"@code\",\"@database\",\"@docs\",\"@multiple\",\"@owners\"]")]
+ [InlineData(@"model\db", "[\"@code\",\"@database\",\"@multiple\",\"@owners\"]")]
+ [InlineData(@"\config\data.conf", "[\"@config-owner\"]")]
+ [InlineData(@"\docs\root.md", "[\"@root-docs\"]")]
+ [InlineData(@"\docs\sub\root.md", "[\"@all-docs\"]")]
+ [InlineData(@"\src\README", "[\"@group\",\"@group/with-nested/subgroup\"]")]
+ [InlineData(@"\src\lib\internal.h", "[\"@lib-owner\"]")]
+ [InlineData(@"src\ee\docs", "[\"@code\",\"@docs\",\"@multiple\",\"@owners\"]")]
+ [InlineData(@"path with spaces\example.txt", "[\"@space-owner\"]")]
+ [InlineData(@"src\app\sample.rb", "[\"@ruby-owner\"]")]
+ // New GitLab quirks present in existing fixture
+ [InlineData("#file_with_pound.rb", "[\"@owner-file-with-pound\"]")] // escaped # char
+ [InlineData("path with spaces/example.txt", "[\"@space-owner\"]")] // escaped spaces in path
+ [InlineData("src/app/sample.rb", "[\"@ruby-owner\"]")] // *.rb pattern
+ [InlineData("random/file.xyz", "[\"@code\",\"@multiple\",\"@owners\"]")] // last * rule wins
+ [InlineData("LICENSE", "[\"@legal\",\"janedoe@gitlab.com\"]")] // username + email
+ public void CheckGitlabCodeOwners(string value, string expected)
+ {
+ var match = _gitlabCodeOwners.Match(value);
+ var actual = match.Any() ? "[\"" + string.Join("\",\"", match.OrderBy(o => o)) + "\"]" : null;
+ Assert.Equal(expected, actual);
+ }
+}