diff --git a/tools/code-owners-parser/Azure.Sdk.Tools.CodeOwnersParser.Tests/Azure.Sdk.Tools.CodeOwnersParser.Tests.csproj b/tools/code-owners-parser/Azure.Sdk.Tools.CodeOwnersParser.Tests/Azure.Sdk.Tools.CodeOwnersParser.Tests.csproj deleted file mode 100644 index 5054e5ad285..00000000000 --- a/tools/code-owners-parser/Azure.Sdk.Tools.CodeOwnersParser.Tests/Azure.Sdk.Tools.CodeOwnersParser.Tests.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - net6.0 - enable - Nullable - false - - - - - - - - - - - - - - - diff --git a/tools/code-owners-parser/Azure.Sdk.Tools.CodeOwnersParser.Tests/CodeownersFileTests.cs b/tools/code-owners-parser/Azure.Sdk.Tools.CodeOwnersParser.Tests/CodeownersFileTests.cs deleted file mode 100644 index 559e3bfde44..00000000000 --- a/tools/code-owners-parser/Azure.Sdk.Tools.CodeOwnersParser.Tests/CodeownersFileTests.cs +++ /dev/null @@ -1,225 +0,0 @@ -using System.Collections.Generic; -using NUnit.Framework; - -namespace Azure.Sdk.Tools.CodeOwnersParser.Tests; - -/// -/// Please see the comment on CodeownersFileTests.testCases -/// -[TestFixture] -public class CodeownersFileTests -{ - /// - /// A battery of test cases specifying the behavior of logic matching target - /// path to CODEOWNERS paths. - /// - /// For further details, please see: - /// - Class comment for Azure.Sdk.Tools.CodeOwnersParser.MatchedCodeOwnerEntry - /// - Azure.Sdk.Tools.RetrieveCodeOwners.Tests.RetrieveCodeOwnersProgramTests - /// - https://github.com/Azure/azure-sdk-tools/issues/2770 - /// - https://github.com/Azure/azure-sdk-tools/issues/4859 - /// - private static readonly TestCase[] testCases = - { - // @formatter:off - // Path: Expected match: - // Codeowners , Target , | - new ( "/**" , "a" , true ), - new ( "/**" , "A" , true ), - new ( "/**" , "/a" , true ), - new ( "/**" , "a/" , true ), - new ( "/**" , "/a/" , true ), - new ( "/**" , "/a/b" , true ), - new ( "/**" , "/a/b/" , true ), - new ( "/**" , "/a/b/c" , true ), - new ( "/**" , "[" , true ), - new ( "/**" , "]" , true ), - new ( "/" , "a" , false ), - new ( "/" , "A" , false ), - new ( "/" , "/a" , false ), - new ( "/" , "a/" , false ), - new ( "/" , "/a/" , false ), - new ( "/" , "/a/b" , false ), - new ( "/a" , "a" , true ), - new ( "/a" , "A" , false ), - new ( "/a" , "/a" , true ), - new ( "/a" , "a/" , false ), - new ( "/a" , "/a/" , false ), - new ( "/a" , "/a/b" , false ), - new ( "/a" , "/a/b/" , false ), - new ( "/a" , "/a\\ b" , false ), - new ( "/a" , "/x/a/b" , false ), - new ( "a" , "a" , false ), - new ( "a" , "ab" , false ), - new ( "a" , "ab/" , false ), - new ( "a" , "/ab/" , false ), - new ( "a" , "A" , false ), - new ( "a" , "/a" , false ), - new ( "a" , "a/" , false ), - new ( "a" , "/a/" , false ), - new ( "a" , "/a/b" , false ), - new ( "a" , "/a/b/" , false ), - new ( "a" , "/x/a/b" , false ), - new ( "/a/" , "a" , false ), - new ( "/a/" , "/a" , false ), - new ( "/a/" , "a/" , true ), - new ( "/a/" , "/a/" , true ), - new ( "/a/" , "/a/b" , true ), - new ( "/a/" , "/a\\ b" , false ), - new ( "/a/" , "/a\\ b/" , false ), - new ( "/a/" , "/a/a\\ b/" , true ), - new ( "/a/" , "/a/b/" , true ), - new ( "/a/" , "/A/b/" , false ), - new ( "/a/" , "/x/a/b" , false ), - new ( "/a/b/" , "/a" , false ), - new ( "/a/b/" , "/a/" , false ), - new ( "/a/b/" , "/a/b" , false ), - new ( "/a/b/" , "/a/b/" , true ), - new ( "/a/b/" , "/a/b/c" , true ), - new ( "/a/b/" , "/a/b/c/" , true ), - new ( "/a/b/" , "/a/b/c/d" , true ), - new ( "/a/b" , "/a" , false ), - new ( "/a/b" , "/a/" , false ), - new ( "/a/b" , "/a/b" , true ), - new ( "/a/b" , "/a/b/" , false ), - new ( "/a/b" , "/a/bc" , false ), - new ( "/a/b" , "/a/bc/" , false ), - new ( "/a/b" , "/a/b/c" , false ), - new ( "/a/b" , "/a/b/c/" , false ), - new ( "/a/b" , "/a/b/c/d" , false ), - new ( "/!a" , "!a" , false ), - new ( "/!a" , "b" , false ), - new ( "/a[b" , "a[b" , false ), - new ( "/a]b" , "a]b" , false ), - new ( "/a?b" , "a?b" , false ), - new ( "/a?b" , "axb" , false ), - new ( "/a" , "*" , false ), - new ( "/*" , "*" , false ), - new ( "/*" , "a" , true ), - new ( "/*" , "a/" , false ), - new ( "/*" , "/a" , true ), - new ( "/*" , "/a/" , false ), - new ( "/*" , "a/b" , false ), - new ( "/*" , "/a/b" , false ), - new ( "/*" , "[" , true ), - new ( "/*" , "]" , true ), - new ( "/*" , "!" , true ), - new ( "/**" , "!" , true ), - new ( "/a*" , "a" , true ), - new ( "/a*" , "a/x" , true ), - new ( "/a*" , "a/x/d" , true ), - new ( "/a*" , "ab" , true ), - new ( "/a*" , "ab/x" , true ), - new ( "/a*" , "ab/x/d" , true ), - new ( "/a/**" , "a" , false ), - new ( "/*/**" , "a" , false ), - new ( "/*/**" , "a/" , false ), - new ( "/*/**" , "a/b" , false ), - new ( "/*/" , "a" , false ), - new ( "/*/" , "a/" , true ), - new ( "/*/b" , "a/b" , true ), - new ( "/**/a" , "a" , true ), - new ( "/**/a" , "x/ba" , false ), - new ( "/a/*" , "a" , false ), - new ( "/a/*" , "a/" , true ), - new ( "/a/*" , "a/b" , true ), - new ( "/a/*" , "a/b/" , false ), - new ( "/a/*" , "a/b/c" , false ), - new ( "/a/*/" , "a" , false ), - new ( "/a/*/" , "a/" , false ), - new ( "/a/*/" , "a/b" , false ), - new ( "/a/*/" , "a/b/" , true ), - new ( "/a/*/" , "a/b/c" , true ), - new ( "/a/**" , "a" , false ), - new ( "/a/**" , "a/" , false ), - new ( "/a/**" , "a/b" , false ), - new ( "/a/**" , "a/b/" , false ), - new ( "/a/**" , "a/b/c" , false ), - new ( "/a/**/" , "a" , false ), - new ( "/a/**/" , "a/" , false ), - new ( "/a/**/" , "a/b" , false ), - new ( "/a/**/" , "a/b/" , false ), - new ( "/a/**/" , "a/b/c" , false ), - new ( "/**/a/" , "a" , false ), - new ( "/**/a/" , "a/" , true ), - new ( "/**/a/" , "a/b" , true ), - new ( "/**/b/" , "a/b" , false ), - new ( "/**/b/" , "a/b/" , true ), - new ( "/**/b/" , "a/c/" , false ), - new ( "/a/*/b/" , "a/b/" , false ), - new ( "/a/*/b/" , "a/x/b/" , true ), - new ( "/a/*/b/" , "a/x/b/c" , true ), - new ( "/a/*/b/" , "a/x/c" , false ), - new ( "/a/*/b/" , "a/x/y/b" , false ), - new ( "/a**b/" , "a/x/y/b" , false ), - new ( "/a/**/b/" , "a/b" , false ), - new ( "/a/**/b/" , "a/b/" , true ), - new ( "/a/**/b/" , "a/x/b/" , true ), - new ( "/a/**/b/" , "a/x/y/b/" , true ), - new ( "/a/**/b/" , "a/x/y/c" , false ), - new ( "/a/**/b/" , "a-b/" , false ), - new ( "a/*/*" , "a/b" , false ), - new ( "/a/*/*/d" , "a/b/c/d" , true ), - new ( "/a/*/*/d" , "a/b/x/c/d" , false ), - new ( "/a/**/*/d" , "a/b/x/c/d" , true ), - new ( "*/*/b" , "a/b" , false ), - new ( "/a*/" , "abc/" , true ), - new ( "/a*/" , "ab/c/" , true ), - new ( "/*b*/" , "axbyc/" , true ), - new ( "/*c/" , "abc/" , true ), - new ( "/*c/" , "a/abc/" , false ), - new ( "/a*c/" , "axbyc/" , true ), - new ( "/a*c/" , "axb/yc/" , false ), - new ( "/**/*x*/" , "a/b/cxy/d" , true ), - new ( "/a/*.md" , "a/x.md" , true ), - new ( "/*/*/*.md" , "a/b/x.md" , true ), - new ( "/**/*.md" , "a/b.md/x.md" , true ), - new ( "**/*.md" , "a/b.md/x.md" , false ), - new ( "/*.md" , "a/md" , false ), - new ( "/a.*" , "a.b" , true ), - new ( "/a.*" , "a.b/" , true ), - new ( "/a.*" , "x/a.b/" , false ), - new ( "/a.*/" , "a.b" , false ), - new ( "/a.*/" , "a.b/" , true ), - new ( "/**/*x*/AB/*/CD" , "a/b/cxy/AB/fff/CD" , true ), - new ( "/**/*x*/AB/*/CD" , "a/b/cxy/AB/ff/ff/CD" , false ), - new ( "/**/*x*/AB/**/CD/*" , "a/b/cxy/AB/ff/ff/CD" , false ), - new ( "/**/*x*/AB/**/CD/*" , "a/b/cxy/AB/ff/ff/CD/" , true ), - new ( "/**/*x*/AB/**/CD/*" , "a/b/cxy/AB/[]/!!/CD/h" , true ), - // @formatter:on - }; - - /// - /// Exercises CodeownersFileTests.testCases - /// See comment on that member for details. - /// - [TestCaseSource(nameof(testCases))] - public void TestGetMatchingCodeownersEntry(TestCase testCase) - { - List codeownersEntries = - CodeownersFile.GetCodeownersEntries(testCase.CodeownersPath + "@owner"); - - VerifyGetMatchingCodeownersEntry(testCase, codeownersEntries, testCase.ExpectedNewMatch); - } - - private static void VerifyGetMatchingCodeownersEntry( - TestCase testCase, - List codeownersEntries, - bool expectedMatch) - { - CodeownersEntry entry = - // Act - CodeownersFile.GetMatchingCodeownersEntry(testCase.TargetPath, - codeownersEntries); - - Assert.That(entry.Owners, Has.Count.EqualTo(expectedMatch ? 1 : 0)); - } - - /// - /// Please see comment on CodeownersFileTests.testCases - /// - public record TestCase( - string CodeownersPath, - string TargetPath, - bool ExpectedNewMatch); -} diff --git a/tools/code-owners-parser/CodeOwnersParser.sln b/tools/code-owners-parser/CodeOwnersParser.sln deleted file mode 100644 index a6c98e6be24..00000000000 --- a/tools/code-owners-parser/CodeOwnersParser.sln +++ /dev/null @@ -1,31 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Azure.Sdk.Tools.CodeOwnersParser", "CodeOwnersParser\Azure.Sdk.Tools.CodeOwnersParser.csproj", "{55D665BF-A4B3-45EA-A2A0-B33AFB208766}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Azure.Sdk.Tools.CodeOwnersParser.Tests", "Azure.Sdk.Tools.CodeOwnersParser.Tests\Azure.Sdk.Tools.CodeOwnersParser.Tests.csproj", "{66C9FF6A-32DD-4C3C-ABE1-F1F58C1C8129}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {55D665BF-A4B3-45EA-A2A0-B33AFB208766}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {55D665BF-A4B3-45EA-A2A0-B33AFB208766}.Debug|Any CPU.Build.0 = Debug|Any CPU - {55D665BF-A4B3-45EA-A2A0-B33AFB208766}.Release|Any CPU.ActiveCfg = Release|Any CPU - {55D665BF-A4B3-45EA-A2A0-B33AFB208766}.Release|Any CPU.Build.0 = Release|Any CPU - {66C9FF6A-32DD-4C3C-ABE1-F1F58C1C8129}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {66C9FF6A-32DD-4C3C-ABE1-F1F58C1C8129}.Debug|Any CPU.Build.0 = Debug|Any CPU - {66C9FF6A-32DD-4C3C-ABE1-F1F58C1C8129}.Release|Any CPU.ActiveCfg = Release|Any CPU - {66C9FF6A-32DD-4C3C-ABE1-F1F58C1C8129}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {E32EEF98-9184-4346-8801-C5A8A1C7FD7D} - EndGlobalSection -EndGlobal diff --git a/tools/code-owners-parser/CodeOwnersParser.sln.DotSettings b/tools/code-owners-parser/CodeOwnersParser.sln.DotSettings deleted file mode 100644 index d042e12eb56..00000000000 --- a/tools/code-owners-parser/CodeOwnersParser.sln.DotSettings +++ /dev/null @@ -1,6 +0,0 @@ - - PR - True - True - True - True \ No newline at end of file diff --git a/tools/code-owners-parser/CodeOwnersParser/Azure.Sdk.Tools.CodeOwnersParser.csproj b/tools/code-owners-parser/CodeOwnersParser/Azure.Sdk.Tools.CodeOwnersParser.csproj deleted file mode 100644 index dc90d68b1c6..00000000000 --- a/tools/code-owners-parser/CodeOwnersParser/Azure.Sdk.Tools.CodeOwnersParser.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - - net6.0 - enable - Nullable - false - - - - - - - - diff --git a/tools/code-owners-parser/CodeOwnersParser/CodeownersEntry.cs b/tools/code-owners-parser/CodeOwnersParser/CodeownersEntry.cs deleted file mode 100644 index 782adfd7e00..00000000000 --- a/tools/code-owners-parser/CodeOwnersParser/CodeownersEntry.cs +++ /dev/null @@ -1,259 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Azure.Sdk.Tools.CodeOwnersParser -{ - /// - /// The entry for CODEOWNERS has the following structure: - /// - /// - /// # PRLabel: %Label - /// # ServiceLabel: %Label - /// path @owner @owner - /// - /// - public class CodeownersEntry - { - const char LabelSeparator = '%'; - const char OwnerSeparator = '@'; - public const string PRLabelMoniker = "PRLabel"; - public const string ServiceLabelMoniker = "ServiceLabel"; - public const string MissingFolder = "#//"; - - public string PathExpression { get; set; } = ""; - - public bool ContainsWildcard => PathExpression.Contains('*'); - - public List Owners { get; set; } = new List(); - - public List PRLabels { get; set; } = new List(); - - public List ServiceLabels { get; set; } = new List(); - - public bool IsValid => !string.IsNullOrWhiteSpace(PathExpression); - - public CodeownersEntry() - { - } - - public CodeownersEntry(string pathExpression, List owners) - { - PathExpression = pathExpression; - Owners = owners; - } - - private static string[] SplitLine(string line, char splitOn) - => line.Split(new char[] { splitOn }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - - public override string ToString() - => $"HasWildcard:{ContainsWildcard} Expression:{PathExpression} " + - $"Owners:{string.Join(",", Owners)} PRLabels:{string.Join(",", PRLabels)} " + - $"ServiceLabels:{string.Join(",", ServiceLabels)}"; - - public bool ProcessLabelsOnLine(string line) - { - if (line.Contains(PRLabelMoniker, StringComparison.OrdinalIgnoreCase)) - { - PRLabels.AddRange(ParseLabels(line, PRLabelMoniker)); - return true; - } - else if (line.Contains(ServiceLabelMoniker, StringComparison.OrdinalIgnoreCase)) - { - ServiceLabels.AddRange(ParseLabels(line, ServiceLabelMoniker)); - return true; - } - return false; - } - - private static IEnumerable ParseLabels(string line, string moniker) - { - // Parse a line that looks like # PRLabel: %Label, %Label - if (!line.Contains(moniker, StringComparison.OrdinalIgnoreCase)) - { - yield break; - } - - // If we don't have a ':', nothing to do - int colonPosition = line.IndexOf(':'); - if (colonPosition == -1) - { - yield break; - } - - line = line[(colonPosition + 1)..].Trim(); - foreach (string label in SplitLine(line, LabelSeparator).ToList()) - { - yield return label; - } - } - - public void ParseOwnersAndPath(string line, TeamUserHolder teamUserHolder) - { - if ( - string.IsNullOrEmpty(line) - || (IsComment(line) - && !line.Contains( - CodeownersEntry.MissingFolder, StringComparison.OrdinalIgnoreCase))) - { - return; - } - - line = ParsePath(line); - line = RemoveCommentIfAny(line); - - // If the line doesn't contain the OwnerSeparator AKA no owners, then the foreach loop below - // won't work. For example, the following line would end up causing "/sdk/communication" to - // be added as an owner when one is not listed - // /sdk/communication/ - if (line.Contains(OwnerSeparator)) - { - foreach (string author in SplitLine(line, OwnerSeparator).ToList()) - { - // If the author is a team, get the user list and add that to the Owners - if (!IsGitHubUserAlias(author)) - { - var teamUsers = teamUserHolder.GetUsersForTeam(author); - // If the team is found in team user data, add the list of users to - // the owners and ensure the end result is a distinct list - if (teamUsers.Count > 0) - { - // The union of the two lists will ensure the result a distinct list - Owners = Owners.Union(teamUsers).ToList(); - } - // Else, the team user data did not contain an entry or there were no user - // for the team. In that case, just add the team to the list of authors - else - { - Owners.Add(author); - } - } - // If the entry isn't a team, then just add it - else - { - Owners.Add(author); - } - } - } - else - { - Console.WriteLine($"Warning: CODEOWNERS line '{line}' does not have an owner entry."); - } - } - - private static bool IsComment(string line) - => line.StartsWith("#"); - - private static string RemoveCommentIfAny(string line) - { - // this is the case when we have something like @user #comment - - int commentIndex = line.IndexOf("#", StringComparison.OrdinalIgnoreCase); - - if (commentIndex >= 0) - line = line[..commentIndex].Trim(); - - return line; - } - - private string ParsePath(string line) - { - // Get the start of the owner in the string - int ownerStartPosition = line.IndexOf('@'); - if (ownerStartPosition == -1) - { - return line; - } - - string path = line[..ownerStartPosition].Trim(); - // the first entry is the path/regex - PathExpression = path; - - // remove the path from the string. - return line[ownerStartPosition..]; - } - - /// - /// Remove all code owners which are not github alias. - /// - public void ExcludeNonUserAliases() - => Owners.RemoveAll(r => !IsGitHubUserAlias(r)); - - /// - /// Helper method to check if it is valid github alias. - /// - /// Alias string. - /// True if it is a github alias, Otherwise false. - private static bool IsGitHubUserAlias(string alias) - { - // We used to call the github users api but we often got 403 returned - // due to rate limiting. So instead we are approximating the check - // by check for a slash in the name if there is one then we will consider - // it to be a team instead of a users. - if (alias.Contains('/')) - { - return false; - } - return true; - } - - protected bool Equals(CodeownersEntry other) - => PathExpression == other.PathExpression - && Owners.SequenceEqual(other.Owners) - && PRLabels.SequenceEqual(other.PRLabels) - && ServiceLabels.SequenceEqual(other.ServiceLabels); - - public override bool Equals(object? obj) - { - // @formatter:off - if (ReferenceEquals(null, obj)) return false; - if (ReferenceEquals(this, obj)) return true; - if (obj.GetType() != this.GetType()) return false; - return Equals((CodeownersEntry)obj); - // @formatter:on - } - - /// - /// Implementation of GetHashCode that properly hashes collections. - /// Implementation based on - /// https://stackoverflow.com/a/10567544/986533 - /// - /// This implementation is candidate to be moved to: - /// https://github.com/Azure/azure-sdk-tools/issues/5281 - /// - public override int GetHashCode() - { - int hashCode = 0; - // ReSharper disable NonReadonlyMemberInGetHashCode - hashCode = AddHashCodeForObject(hashCode, PathExpression); - hashCode = AddHashCodeForEnumerable(hashCode, Owners); - hashCode = AddHashCodeForEnumerable(hashCode, PRLabels); - hashCode = AddHashCodeForEnumerable(hashCode, ServiceLabels); - // ReSharper restore NonReadonlyMemberInGetHashCode - return hashCode; - - // ReSharper disable once VariableHidesOuterVariable - int AddHashCodeForEnumerable(int hashCode, IEnumerable enumerable) - { - foreach (var item in enumerable) - { - hashCode = AddHashCodeForObject(hashCode, item); - } - return hashCode; - } - - int AddHashCodeForObject(int hc, object item) - { - // Based on https://stackoverflow.com/a/10567544/986533 - hc ^= item.GetHashCode(); - hc = (hc << 7) | - (hc >> (32 - 7)); // rotate hashCode to the left to swipe over all bits - return hc; - } - } - } -} diff --git a/tools/code-owners-parser/CodeOwnersParser/CodeownersFile.cs b/tools/code-owners-parser/CodeOwnersParser/CodeownersFile.cs deleted file mode 100644 index 110f001751a..00000000000 --- a/tools/code-owners-parser/CodeOwnersParser/CodeownersFile.cs +++ /dev/null @@ -1,127 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Text.Json; - -namespace Azure.Sdk.Tools.CodeOwnersParser -{ - public static class CodeownersFile - { - - public static List GetCodeownersEntriesFromFileOrUrl( - string codeownersFilePathOrUrl, - string? teamStorageURI = null) - { - string content = FileHelpers.GetFileOrUrlContents(codeownersFilePathOrUrl); - return GetCodeownersEntries(content, teamStorageURI); - } - - public static List GetCodeownersEntries(string codeownersContent, string? teamStorageURI = null) - { - TeamUserHolder teamUserHolder = new TeamUserHolder(teamStorageURI); - List entries = new List(); - - // We are going to read line by line until we find a line that is not a comment - // OR that is using the placeholder entry inside the comment. - // while we are trying to find the folder entry, we parse all comment lines - // to extract the labels from it. when we find the path or placeholder, - // we add the completed entry and create a new one. - CodeownersEntry entry = new CodeownersEntry(); - using StringReader sr = new StringReader(codeownersContent); - while (sr.ReadLine() is { } line) - { - entry = ProcessCodeownersLine(line, entry, entries, teamUserHolder); - } - - return entries; - } - - public static CodeownersEntry GetMatchingCodeownersEntry( - string targetPath, - string codeownersFilePathOrUrl, - string? teamStorageURI = null) - { - var codeownersEntries = GetCodeownersEntriesFromFileOrUrl(codeownersFilePathOrUrl, teamStorageURI); - return GetMatchingCodeownersEntry(targetPath, codeownersEntries); - } - - public static Dictionary GetMatchingCodeownersEntries( - GlobFilePath targetPath, - string targetDir, - string codeownersFilePathOrUrl, - string[]? ignoredPathPrefixes = null, - string? teamStorageURI = null) - { - ignoredPathPrefixes ??= Array.Empty(); - - var codeownersEntries = GetCodeownersEntriesFromFileOrUrl(codeownersFilePathOrUrl, teamStorageURI); - - Dictionary codeownersEntriesByPath = targetPath - .ResolveGlob(targetDir, ignoredPathPrefixes) - .ToDictionary( - path => path, - path => GetMatchingCodeownersEntry( - path, - codeownersEntries)); - - return codeownersEntriesByPath; - } - - public static CodeownersEntry GetMatchingCodeownersEntry( - string targetPath, - List codeownersEntries) - { - Debug.Assert(targetPath != null); - return new MatchedCodeownersEntry(targetPath, codeownersEntries).Value; - } - - private static CodeownersEntry ProcessCodeownersLine( - string line, - CodeownersEntry entry, - List entries, - TeamUserHolder teamUserHolder) - { - line = NormalizeLine(line); - - if (string.IsNullOrWhiteSpace(line)) - { - return entry; - } - - if (!IsCommentLine(line) || (IsCommentLine(line) && IsPlaceholderEntry(line))) - { - entry.ParseOwnersAndPath(line, teamUserHolder); - - if (entry.IsValid) - entries.Add(entry); - - // An entry has ended, as we got to a path: real bath or placeholder path. - return new CodeownersEntry(); - } - - if (IsCommentLine(line)) - { - // try to process the line in case there are markers that need to be extracted - entry.ProcessLabelsOnLine(line); - return entry; - } - - throw new InvalidOperationException( - $"This case shouldn't be possible. line: '{line}'"); - } - - private static bool IsPlaceholderEntry(string line) - => line.Contains(CodeownersEntry.MissingFolder, StringComparison.OrdinalIgnoreCase); - - private static bool IsCommentLine(string line) - => line.StartsWith("#"); - - private static string NormalizeLine(string line) - => !string.IsNullOrEmpty(line) - // Remove tabs and trim extra whitespace - ? line.Replace('\t', ' ').Trim() - : line; - } -} diff --git a/tools/code-owners-parser/CodeOwnersParser/DefaultStorageConstants.cs b/tools/code-owners-parser/CodeOwnersParser/DefaultStorageConstants.cs deleted file mode 100644 index dfc25d9b957..00000000000 --- a/tools/code-owners-parser/CodeOwnersParser/DefaultStorageConstants.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Azure.Sdk.Tools.CodeOwnersParser -{ - public class DefaultStorageConstants - { - public const string DefaultStorageURI = "https://azuresdkartifacts.blob.core.windows.net/azure-sdk-write-teams/azure-sdk-write-teams-blob"; - } -} diff --git a/tools/code-owners-parser/CodeOwnersParser/FileHelpers.cs b/tools/code-owners-parser/CodeOwnersParser/FileHelpers.cs deleted file mode 100644 index 8cf238a9224..00000000000 --- a/tools/code-owners-parser/CodeOwnersParser/FileHelpers.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System; -using System.IO; -using System.Net; -using System.Net.Http; - -namespace Azure.Sdk.Tools.CodeOwnersParser -{ - public static class FileHelpers - { - public static string GetFileOrUrlContents(string fileOrUrl) - { - if (fileOrUrl.StartsWith("https")) - return GetUrlContents(fileOrUrl); - - string fullPath = Path.GetFullPath(fileOrUrl); - if (File.Exists(fullPath)) - return File.ReadAllText(fullPath); - - throw new ArgumentException( - "The path provided is neither local path nor https link. " + - $"Please check your path: '{fileOrUrl}' resolved to '{fullPath}'."); - } - - private static string GetUrlContents(string url) - { - int maxRetries = 3; - int attempts = 1; - int delayTimeInMs = 1000; - using HttpClient client = new HttpClient(); - while (attempts <= maxRetries) - { - try - { - HttpResponseMessage response = client.GetAsync(url).ConfigureAwait(false).GetAwaiter().GetResult(); - if (response.StatusCode == HttpStatusCode.OK) - { - // This writeline is probably unnecessary but good to have if there are previous attempts that failed - Console.WriteLine($"GetUrlContents for {url} attempt number {attempts} succeeded."); - return response.Content.ReadAsStringAsync().ConfigureAwait(false).GetAwaiter().GetResult(); - } - else - { - Console.WriteLine($"GetUrlContents attempt number {attempts}. Non-{HttpStatusCode.OK} status code trying to fetch {url}. Status Code = {response.StatusCode}"); - } - } - catch (HttpRequestException httpReqEx) - { - // HttpRequestException means the request failed due to an underlying issue such as network connectivity, - // DNS failure, server certificate validation or timeout. - Console.WriteLine($"GetUrlContents attempt number {attempts}. HttpRequestException trying to fetch {url}. Exception message = {httpReqEx.Message}"); - if (attempts == maxRetries) - { - // At this point the retries have been exhausted, let this rethrow - throw; - } - } - System.Threading.Thread.Sleep(delayTimeInMs); - attempts++; - } - // This will only get hit if the final retry is non-OK status code - throw new FileLoadException($"Unable to fetch {url} after {maxRetries}. See above for status codes for each attempt."); - } - } -} diff --git a/tools/code-owners-parser/CodeOwnersParser/GlobFilePath.cs b/tools/code-owners-parser/CodeOwnersParser/GlobFilePath.cs deleted file mode 100644 index 2529be24bde..00000000000 --- a/tools/code-owners-parser/CodeOwnersParser/GlobFilePath.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using Microsoft.Extensions.FileSystemGlobbing; - -namespace Azure.Sdk.Tools.CodeOwnersParser; - -public class GlobFilePath -{ - private readonly string filePath; - - public GlobFilePath(string globFilePath) - { - Debug.Assert(globFilePath.IsGlobFilePath()); - this.filePath = globFilePath; - } - - /// - /// The '*' is the only character that can denote glob pattern - /// in the used globbing library, per: - /// - https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.filesystemglobbing.matcher?view=dotnet-plat-ext-7.0#remarks - /// - https://learn.microsoft.com/en-us/dotnet/core/extensions/file-globbing#pattern-formats - /// - public static bool IsGlobFilePath(string path) - => path.Contains('*'); - - public List ResolveGlob(string directoryPath, string[]? ignoredPathPrefixes) - { - ignoredPathPrefixes ??= Array.Empty(); - - var globMatcher = new Matcher(StringComparison.Ordinal); - globMatcher.AddInclude(this.filePath); - - List matchedPaths = globMatcher.GetResultsInFullPath(directoryPath).ToList(); - - matchedPaths = matchedPaths - .Select(path => Path.GetRelativePath(directoryPath, path).Replace("\\", "/")) - .Where(path => ignoredPathPrefixes.All(prefix => !path.StartsWith(prefix))) - .ToList(); - - return matchedPaths; - } -} diff --git a/tools/code-owners-parser/CodeOwnersParser/MatchedCodeownersEntry.cs b/tools/code-owners-parser/CodeOwnersParser/MatchedCodeownersEntry.cs deleted file mode 100644 index a719576029d..00000000000 --- a/tools/code-owners-parser/CodeOwnersParser/MatchedCodeownersEntry.cs +++ /dev/null @@ -1,325 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Text.RegularExpressions; - -namespace Azure.Sdk.Tools.CodeOwnersParser -{ - /// - /// Represents a CODEOWNERS file entry that matched to targetPath from - /// the list of entries, assumed to have been parsed from CODEOWNERS file. - /// - /// To use this class, construct it, passing as input relevant paths. - /// Then, to obtain the value of the matched entry, reference "Value" member. - /// - /// This class uses a regex-based wildcard-supporting (* and **) matcher. - /// - /// This matcher reflects the matching behavior of the built-in GitHub CODEOWNERS interpreter, - /// but with additional assumptions imposed about the paths present in CODEOWNERS, as guaranteed - /// by CODEOWNERS file validation: - /// https://github.com/Azure/azure-sdk-tools/issues/4859 - /// These assumptions are checked by IsCodeownersPathValid() method. - /// If violated, given CODEOWNERS path will be always ignored by the matcher, never matching anything. - /// As a result, this matcher is effectively a subset of GitHub CODEOWNERS matcher. - /// - /// The validation spec is given in this comment: - /// https://github.com/Azure/azure-sdk-tools/issues/4859#issuecomment-1370360622 - /// See also RetrieveCodeOwnersProgramTests and CodeownersFileTests tests. - /// - /// Reference: - /// https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners#codeowners-syntax - /// https://git-scm.com/docs/gitignore#_pattern_format - /// - public class MatchedCodeownersEntry - { - /// - /// Token for temporarily substituting "**" in regex, to avoid it being escaped when - /// Regex.Escape is called. - /// - private const string DoubleStar = "_DOUBLE_STAR_"; - /// - /// Token for temporarily substituting "*" in regex, to avoid it being escaped when - /// Regex.Escape is called. - /// - private const string SingleStar = "_SINGLE_STAR_"; - - public readonly CodeownersEntry Value; - - /// - /// See comment on IsCodeownersPathValid - /// - public bool IsValid => IsCodeownersPathValid(this.Value.PathExpression); - - /// - /// See the class comment to understand this method purpose. - /// - public static bool IsCodeownersPathValid(string codeownersPathExpression) - => !IsCommentedOutPath(codeownersPathExpression) - && !ContainsUnsupportedCharacters(codeownersPathExpression) - && !ContainsUnsupportedSequences(codeownersPathExpression); - - /// - /// Any CODEOWNERS path with these characters will be skipped. - /// Note these are valid parts of file paths, but we are not supporting - /// them to simplify the matcher logic. - /// - private static readonly char[] unsupportedChars = { '[', ']', '!', '?' }; - - public MatchedCodeownersEntry(string targetPath, List codeownersEntries) - { - this.Value = GetMatchingCodeownersEntry(targetPath, codeownersEntries); - } - - /// - /// Returns a CodeownersEntry from codeownersEntries, after normalization and validation, - /// that match targetPath per algorithm described in the GitHub CODEOWNERS reference, - /// as linked to in this class comment. - /// - /// Paths that are not valid after normalization are skipped from matching. - /// - /// If there is no match, this method returns "new CodeownersEntry()". - /// - /// For definition of "normalization", see NormalizePath(). - /// For definition of "validation", see IsCodeownersPathValid(). - /// You can also refer to the validation spec linked from this class comment. - /// - private CodeownersEntry GetMatchingCodeownersEntry( - string targetPath, - List codeownersEntries) - { - if (targetPath.Contains('*')) - { - Console.Error.WriteLine( - $"Target path \"{targetPath}\" contains star ('*') which is not supported. " - + "Returning no match without checking for ownership."); - return NoMatchCodeownersEntry; - } - - // targetPath is assumed to be absolute w.r.t. repository root, hence we ensure - // it starts with "/" to denote that. - if (!targetPath.StartsWith("/")) - targetPath = "/" + targetPath; - - // Note we cannot add or trim the slash at the end of targetPath. - // Slash at the end of target path denotes it is a directory, not a file, - // so it can not match against a CODEOWNERS entry that is guaranteed to be a file, - // by the virtue of not ending with "/". - - CodeownersEntry matchedEntry = codeownersEntries - .Where(entry => IsCodeownersPathValid(entry.PathExpression)) - // Entries listed in CODEOWNERS file below take precedence, hence we read the file from the bottom up. - // By convention, entries in CODEOWNERS should be sorted top-down in the order of: - // - 'RepoPath', - // - 'ServicePath' - // - and then 'PackagePath'. - // However, due to lack of validation, as of 12/29/2022 this is not always the case. - .Reverse() - .FirstOrDefault( - entry => Matches(targetPath, entry), - // assert: none of the codeownersEntries matched targetPath - NoMatchCodeownersEntry); - - return matchedEntry; - } - - private CodeownersEntry NoMatchCodeownersEntry { get; } = new CodeownersEntry(); - - /// - /// We do not output any error message in case the path is commented out, - /// as a) such paths are expected to be processed and discarded by this logic - /// and b) outputting error messages would possibly result in output - /// truncation, truncating the resulting json, and thus making the output of the - /// calling tool malformed. - /// - private static bool IsCommentedOutPath(string codeownersPathExpression) - => codeownersPathExpression.Trim().StartsWith("#"); - - /// - /// See the comment on unsupportedChars. - /// - private static bool ContainsUnsupportedCharacters(string codeownersPath) - { - var contains = unsupportedChars.Any(codeownersPath.Contains); - if (contains) - { - Console.Error.WriteLine( - $"CODEOWNERS path \"{codeownersPath}\" contains unsupported characters: " + - string.Join(' ', unsupportedChars) + - " Because of that this path will never match."); - } - return contains; - } - - private static bool ContainsUnsupportedSequences(string codeownersPath) - { - if (codeownersPath == "/") - { - // This behavior matches GitHub CODEOWNERS interpreter behavior. - // I.e. a path of just "/" is unsupported. - Console.Error.WriteLine( - $"CODEOWNERS path \"{codeownersPath}\" will never match. " + - "Use \"/**\" instead."); - return true; - } - - // See the comment below on why we support this path. - if (codeownersPath == "/**") - return false; - - if (!codeownersPath.StartsWith("/")) - { - Console.Error.WriteLine( - $"CODEOWNERS path \"{codeownersPath}\" does not start with " + - "\"/\". Prefix it with \"/\". " + - "Until then this path will never match."); - return true; - } - - // We do not support suffix of "/**" because it is equivalent to "/". - // For example, "/foo/**" is equivalent to "/foo/" - // One exception to this rule is if the entire path is "/**": - // GitHub doesn't match "/" to anything if it is the entire path, - // and instead expects "/**". - if (codeownersPath != "/**" && codeownersPath.EndsWith("/**")) - { - Console.Error.WriteLine( - $"CODEOWNERS path \"{codeownersPath}\" ends with " + - "unsupported sequence of \"/**\". Replace it with \"/\". " + - "Until then this path will never match."); - return true; - } - - // We do not support suffix of "/**/" because it is equivalent to - // suffix "/**" which is equivalent to suffix "/". - if (codeownersPath.EndsWith("/**/")) - { - Console.Error.WriteLine( - $"CODEOWNERS path \"{codeownersPath}\" ends with " + - "unsupported sequence of \"/**/\". Replace it with \"/\". " + - "Until then this path will never match."); - return true; - } - - // We do not support inline "**", i.e. if it is not within slashes, i.e. "/**/". - // Any inline "**" like "/a**/" or "/**a/" or "/a**b/" - // would be equivalent to single star, hence we forbid double star, to avoid confusion. - if (codeownersPath.Replace("/**/", "").Contains("**")) - { - Console.Error.WriteLine( - $"CODEOWNERS path \"{codeownersPath}\" contains " + - "unsupported sequence of \"**\". Double star can be used only within slashes \"/**/\" " + - "or as a top-level catch all path of \"/**\". " + - "Currently this path will never match."); - return true; - } - - return false; - } - - /// - /// Returns true if the regex expression representing the PathExpression - /// of CODEOWNERS entry matches a prefix of targetPath. - /// - private bool Matches(string targetPath, CodeownersEntry entry) - { - string codeownersPath = entry.PathExpression; - - Regex regex = ConvertToRegex(codeownersPath); - // Is prefix match. I.e. it will return true if the regex matches - // a prefix of targetPath. - return regex.IsMatch(targetPath); - } - - private Regex ConvertToRegex(string codeownersPath) - { - Trace.Assert(IsCodeownersPathValid(codeownersPath)); - - // Special case: path "/**" matches everything. - // We do not allow "/**" in any other context except when it is the entire path. - if (codeownersPath == "/**") - return new Regex(".*"); - - string pattern = codeownersPath; - - if (codeownersPath.Contains(SingleStar) || pattern.Contains(DoubleStar)) - { - Console.Error.WriteLine( - $"CODEOWNERS path \"{codeownersPath}\" contains reserved phrases: " + - $"\"{DoubleStar}\" or \"{SingleStar}\""); - } - - // We replace "/**/", not "**", because we disallow "**" in any other context. - // Specifically: - // - because we normalize the path to start with "/", any prefix "**/" is - // effectively "/**/"; - // - any suffix "/**", for reasons explained within ContainsUnsupportedSequences(). - // - any inline "**", for reasons explained within ContainsUnsupportedSequences(). - pattern = pattern.Replace("/**/", "/" + DoubleStar + "/"); - pattern = pattern.Replace("*", SingleStar); - - pattern = Regex.Escape(pattern); - - // Denote that all paths are absolute by pre-pending "beginning of string" symbol. - pattern = "^" + pattern; - - pattern = SetPatternSuffix(pattern); - - pattern = pattern.Replace($"/{DoubleStar}/", "((/.*/)|/)"); - pattern = pattern.Replace(SingleStar, "([^/]*)"); - - return new Regex(pattern); - } - - /// - /// Sets the regex pattern suffix, which can be either "$" (end of string) - /// or nothing. - /// - /// GitHub's CODEOWNERS matching logic is a bit inconsistent when it comes to handling suffixes. - /// - /// In a nutshell: - /// - For top level dir, `/` doesn't work. One has to use `/**`. - /// - But for nested dirs, `/` works. I.e. one can write `/foo/` and it is equivalent to `/foo/**`. - /// - `*` has different interpretations. If used with preceding slash, like `/*` or `/foo/*` - /// it means "things only in this dir". But when used as a suffix, it means "anything". - /// So `/foo*` is effectively `/foo*/** OR /foo*`. Where `*` in the OR clause - /// should be interpreted as "any character except `/`". - /// - private static string SetPatternSuffix(string pattern) - { - // If a pattern ends with "/*" this means it should match only files - // in the child directory, but not all descendant directories. - // Hence we must append "$", to avoid treating the regex pattern - // as a prefix match and instead treat it as an exact match. - if (pattern.EndsWith($"/{SingleStar}")) - return pattern + "$"; - - Trace.Assert(pattern != "^/", "Path \"/\" should have been excluded by validation."); - - // If the pattern ends with "/" it means it is a path to a directory, - // like "/foo/". This means "match everything in this directory, - // at arbitrary directory nesting depth." - // - // If the pattern ends with "*" but not "/*" (as this case was handled above) - // then it is a suffix *, e.g. "/foo*". This means "match everything - // with a prefix string of "/foo". Notably, it matches not only - // everything in "/foo/" dir, but also files like "/foobar.txt" - if (pattern.EndsWith("/") || pattern.EndsWith(SingleStar)) - return pattern; - - // If the pattern doesn't end with "/" nor "*", then according to GitHub CODEOWNERS - // matcher it is a path to a file, or to a directory with exact match. - // However, in this matcher we assume stricter interpretation where - // it has to be a path to a file. - // - // As a result we append "$", to avoid treating the regex pattern - // as a prefix match and instead treat it as an exact match. - // - // If that assumption is violated, i.e. the CODEOWNERS path is actually - // a path to a directory, due to appending "$" the result will be no match, - // e.g. targetPath of "/foo/bar" is a no match against "/foo$". - // This is the desired behavior, as we don't want to match against invalid paths. - return pattern + "$"; - } - } -} diff --git a/tools/code-owners-parser/CodeOwnersParser/PathExtensions.cs b/tools/code-owners-parser/CodeOwnersParser/PathExtensions.cs deleted file mode 100644 index 4167b65d7f1..00000000000 --- a/tools/code-owners-parser/CodeOwnersParser/PathExtensions.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Azure.Sdk.Tools.CodeOwnersParser; - -public static class PathExtensions -{ - public static bool IsGlobFilePath(this string path) - => GlobFilePath.IsGlobFilePath(path); -} diff --git a/tools/code-owners-parser/CodeOwnersParser/TeamUserHolder.cs b/tools/code-owners-parser/CodeOwnersParser/TeamUserHolder.cs deleted file mode 100644 index 4a3a7c4ba60..00000000000 --- a/tools/code-owners-parser/CodeOwnersParser/TeamUserHolder.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Threading.Tasks; - -namespace Azure.Sdk.Tools.CodeOwnersParser -{ - public class TeamUserHolder - { - private string TeamUserStorageURI { get; set; } = DefaultStorageConstants.DefaultStorageURI; - private Dictionary>? _teamUserDict = null; - - public Dictionary> TeamUserDict - { - get - { - if (_teamUserDict == null) - { - _teamUserDict = GetTeamUserData(); - } - return _teamUserDict; - } - set - { - _teamUserDict = value; - } - } - - public TeamUserHolder(string? teamUserStorageURI) - { - if (!string.IsNullOrWhiteSpace(teamUserStorageURI)) - { - TeamUserStorageURI = teamUserStorageURI; - } - } - - private Dictionary> GetTeamUserData() - { - if (null == _teamUserDict) - { - string rawJson = FileHelpers.GetFileOrUrlContents(TeamUserStorageURI); - var list = JsonSerializer.Deserialize>>>(rawJson); - if (null != list) - { - return list.ToDictionary((keyItem) => keyItem.Key, (valueItem) => valueItem.Value); - } - Console.WriteLine($"Error! Unable to deserialize json team/user data from {TeamUserStorageURI}. rawJson={rawJson}"); - return new Dictionary>(); - } - return _teamUserDict; - } - - public List GetUsersForTeam(string teamName) - { - // The teamName in the codeowners file should be in the form /. - // The dictionary's team names do not contain the org so the org needs to - // be stripped off. Handle the case where the teamName passed in does and - // does not being with @org/ - string teamWithoutOrg = teamName.Trim(); - if (teamWithoutOrg.Contains('/')) - { - teamWithoutOrg = teamWithoutOrg.Split("/")[1]; - } - if (TeamUserDict != null) - { - if (TeamUserDict.ContainsKey(teamWithoutOrg)) - { - Console.WriteLine($"Found team entry for {teamWithoutOrg}"); - return TeamUserDict[teamWithoutOrg]; - } - Console.WriteLine($"Warning: TeamUserDictionary did not contain a team entry for {teamWithoutOrg}"); - } - return new List(); - } - } -} diff --git a/tools/github-event-processor/ci.yml b/tools/github-event-processor/ci.yml index dc06966fb26..f55e574bdfd 100644 --- a/tools/github-event-processor/ci.yml +++ b/tools/github-event-processor/ci.yml @@ -6,7 +6,7 @@ trigger: paths: include: - tools/github-event-processor - - tools/code-owners-parser/CodeOwnersParser + - tools/codeowners-utils/Azure.Sdk.Tools.CodeownersUtils pr: branches: @@ -18,7 +18,7 @@ pr: paths: include: - tools/github-event-processor - - tools/code-owners-parser/CodeOwnersParser + - tools/codeowners-utils/Azure.Sdk.Tools.CodeownersUtils extends: template: /eng/pipelines/templates/stages/archetype-sdk-tool-dotnet.yml