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 similarity index 89% rename from tools/code-owners-parser/Azure.Sdk.Tools.CodeOwnersParser.Tests/CodeOwnersFileTests.cs rename to tools/code-owners-parser/Azure.Sdk.Tools.CodeOwnersParser.Tests/CodeownersFileTests.cs index 53e83978ffc..7259bed77fa 100644 --- a/tools/code-owners-parser/Azure.Sdk.Tools.CodeOwnersParser.Tests/CodeOwnersFileTests.cs +++ b/tools/code-owners-parser/Azure.Sdk.Tools.CodeOwnersParser.Tests/CodeownersFileTests.cs @@ -4,13 +4,13 @@ namespace Azure.Sdk.Tools.CodeOwnersParser.Tests; [TestFixture] -public class CodeOwnersFileTests +public class CodeownersFileTests { /// /// A battery of test cases specifying behavior of new logic matching target /// path to CODEOWNERS entries, and comparing it to existing, legacy logic. /// - /// The logic that has changed is located in CodeOwnersFile.FindOwnersForClosestMatch. + /// The logic that has changed is located in CodeownersFile.GetMatchingCodeownersEntry. /// /// In the test case table below, any discrepancy between legacy and new /// matcher expected matches that doesn't pertain to wildcard matching denotes @@ -143,33 +143,31 @@ public class CodeOwnersFileTests }; /// - /// Exercises Azure.Sdk.Tools.CodeOwnersParser.Tests.CodeOwnersFileTests.testCases. + /// Exercises Azure.Sdk.Tools.CodeOwnersParser.Tests.CodeownersFileTests.testCases. /// See comment on that member for details. /// [TestCaseSource(nameof(testCases))] - public void TestParseAndFindOwnersForClosestMatch(TestCase testCase) + public void TestGetMatchingCodeownersEntry(TestCase testCase) { - List? codeownersEntries = - CodeOwnersFile.ParseContent(testCase.CodeownersPath + "@owner"); + List? codeownersEntries = + CodeownersFile.GetCodeownersEntries(testCase.CodeownersPath + "@owner"); - VerifyFindOwnersForClosestMatch(testCase, codeownersEntries, useNewImpl: false, testCase.ExpectedLegacyMatch); - VerifyFindOwnersForClosestMatch(testCase, codeownersEntries, useNewImpl: true, testCase.ExpectedNewMatch); + VerifyGetMatchingCodeownersEntry(testCase, codeownersEntries, useNewImpl: false, testCase.ExpectedLegacyMatch); + VerifyGetMatchingCodeownersEntry(testCase, codeownersEntries, useNewImpl: true, testCase.ExpectedNewMatch); } - private static void VerifyFindOwnersForClosestMatch( + private static void VerifyGetMatchingCodeownersEntry( TestCase testCase, - List codeownersEntries, + List codeownersEntries, bool useNewImpl, bool expectedMatch) { - CodeOwnerEntry? entryLegacy = + CodeownersEntry? entryLegacy = // Act - CodeOwnersFile.FindOwnersForClosestMatch( - codeownersEntries, - testCase.TargetPath, - useNewFindOwnersForClosestMatchImpl: useNewImpl); + CodeownersFile.GetMatchingCodeownersEntry(testCase.TargetPath, + codeownersEntries, useNewImpl: useNewImpl); - Assert.That(entryLegacy.Owners.Count, Is.EqualTo(expectedMatch ? 1 : 0)); + Assert.That(entryLegacy.Owners, Has.Count.EqualTo(expectedMatch ? 1 : 0)); } public record TestCase( diff --git a/tools/code-owners-parser/Azure.Sdk.Tools.RetrieveCodeOwners.Tests/ConsoleOutput.cs b/tools/code-owners-parser/Azure.Sdk.Tools.RetrieveCodeOwners.Tests/ConsoleOutput.cs index a4de98d7690..434129f87c4 100644 --- a/tools/code-owners-parser/Azure.Sdk.Tools.RetrieveCodeOwners.Tests/ConsoleOutput.cs +++ b/tools/code-owners-parser/Azure.Sdk.Tools.RetrieveCodeOwners.Tests/ConsoleOutput.cs @@ -38,6 +38,8 @@ public void Dispose() Console.SetOut(this.originalOutput); this.stringWriter.Dispose(); this.originalOutput.Dispose(); + // https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1816 + GC.SuppressFinalize(this); } /// diff --git a/tools/code-owners-parser/Azure.Sdk.Tools.RetrieveCodeOwners.Tests/MainTests.cs b/tools/code-owners-parser/Azure.Sdk.Tools.RetrieveCodeOwners.Tests/MainTests.cs deleted file mode 100644 index 51701637e91..00000000000 --- a/tools/code-owners-parser/Azure.Sdk.Tools.RetrieveCodeOwners.Tests/MainTests.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System.Collections.Generic; -using System.Text.Json; -using Azure.Sdk.Tools.CodeOwnersParser; -using NUnit.Framework; - -namespace Azure.Sdk.Tools.RetrieveCodeOwners.Tests -{ - /// - /// This is test of console app Azure.Sdk.Tools.RetrieveCodeOwners. - /// - [TestFixture] - public class MainTests - { - private const string CodeOwnersFilePath = "CODEOWNERS"; - - private static readonly object[] sourceLists = - { - new object[] {"sdk", false, new List { "person1", "person2" } }, - new object[] { "/sdk", false, new List { "person1", "person2" } }, - new object[] { "sdk/noPath", false, new List { "person1", "person2" } }, - new object[] { "/sdk/azconfig", false, new List { "person3", "person4" } }, - new object[] { "/sdk/azconfig/package", false, new List { "person3", "person4" } }, - new object[] { "/sdk/testUser/", true, new List { "azure-sdk" } }, - new object[] { "/sd", true, new List() } - }; - - [TestCaseSource(nameof(sourceLists))] - public void TestOnNormalOutput(string targetDirectory, bool includeUserAliasesOnly, List expectedReturn) - { - using (var consoleOutput = new ConsoleOutput()) - { - Program.Main(CodeOwnersFilePath, targetDirectory, includeUserAliasesOnly); - var output = consoleOutput.GetOutput(); - TestExpectResult(expectedReturn, output); - consoleOutput.Dispose(); - } - } - - [TestCase("PathNotExist")] - [TestCase("http://testLink")] - [TestCase("https://testLink")] - public void TestOnError(string codeOwnerPath) - { - Assert.That(Program.Main(codeOwnerPath, "sdk"), Is.EqualTo(1)); - } - - private static void TestExpectResult(List expectReturn, string output) - { - CodeOwnerEntry? codeOwnerEntry = JsonSerializer.Deserialize(output); - List actualReturn = codeOwnerEntry!.Owners; - Assert.That(actualReturn.Count, Is.EqualTo(expectReturn.Count)); - for (int i = 0; i < actualReturn.Count; i++) - { - Assert.That(actualReturn[i], Is.EqualTo(expectReturn[i])); - } - } - } -} diff --git a/tools/code-owners-parser/Azure.Sdk.Tools.RetrieveCodeOwners.Tests/ProgramTests.cs b/tools/code-owners-parser/Azure.Sdk.Tools.RetrieveCodeOwners.Tests/ProgramTests.cs new file mode 100644 index 00000000000..87fe0a32d5d --- /dev/null +++ b/tools/code-owners-parser/Azure.Sdk.Tools.RetrieveCodeOwners.Tests/ProgramTests.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using System.Text.Json; +using Azure.Sdk.Tools.CodeOwnersParser; +using NUnit.Framework; + +namespace Azure.Sdk.Tools.RetrieveCodeOwners.Tests +{ + /// + /// Test class for Azure.Sdk.Tools.RetrieveCodeOwners.Program. + /// + [TestFixture] + public class ProgramTests + { + private const string CodeownersFilePath = "CODEOWNERS"; + + private static readonly object[] sourceLists = + { + new object[] {"sdk", false, new List { "person1", "person2" } }, + new object[] { "/sdk", false, new List { "person1", "person2" } }, + new object[] { "sdk/noPath", false, new List { "person1", "person2" } }, + new object[] { "/sdk/azconfig", false, new List { "person3", "person4" } }, + new object[] { "/sdk/azconfig/package", false, new List { "person3", "person4" } }, + new object[] { "/sdk/testUser/", true, new List { "azure-sdk" } }, + new object[] { "/sd", true, new List() } + }; + + [TestCaseSource(nameof(sourceLists))] + public void TestOnNormalOutput(string targetPath, bool excludeNonUserAliases, List expectedOwners) + { + using var consoleOutput = new ConsoleOutput(); + + // Act + Program.Main(targetPath, CodeownersFilePath, excludeNonUserAliases); + + string actualOutput = consoleOutput.GetOutput(); + AssertOwners(actualOutput, expectedOwners); + } + + [TestCase("PathNotExist")] + [TestCase("http://testLink")] + [TestCase("https://testLink")] + public void TestOnError(string codeownersPath) + { + Assert.That(Program.Main("sdk", codeownersPath), Is.EqualTo(1)); + } + + private static void AssertOwners(string actualOutput, List expectedOwners) + { + CodeownersEntry? actualEntry = JsonSerializer.Deserialize(actualOutput); + List actualOwners = actualEntry!.Owners; + Assert.That(actualOwners, Has.Count.EqualTo(expectedOwners.Count)); + for (int i = 0; i < actualOwners.Count; i++) + { + Assert.That(actualOwners[i], Is.EqualTo(expectedOwners[i])); + } + } + } +} diff --git a/tools/code-owners-parser/Azure.Sdk.Tools.RetrieveCodeOwners/Program.cs b/tools/code-owners-parser/Azure.Sdk.Tools.RetrieveCodeOwners/Program.cs index efc105654b7..10f8df3486f 100644 --- a/tools/code-owners-parser/Azure.Sdk.Tools.RetrieveCodeOwners/Program.cs +++ b/tools/code-owners-parser/Azure.Sdk.Tools.RetrieveCodeOwners/Program.cs @@ -5,36 +5,47 @@ namespace Azure.Sdk.Tools.RetrieveCodeOwners { /// - /// The tool command to retrieve code owners. + /// See Program.Main comment. /// public static class Program { /// - /// Retrieves CODEOWNERS information for specific section of the repo + /// Given targetPath and CODEOWNERS file path or https url codeownersFilePathOrUrl, + /// prints out to stdout owners of the targetPath as determined by the CODEOWNERS data. /// - /// The path of CODEOWNERS file in repo - /// The directory whose information is to be retrieved - /// The option to filter out code owner team alias. - /// Exit code - + /// The path whose owners are to be determined. + /// The https url or path to the CODEOWNERS file. + /// Whether owners that aren't users should be excluded from the + /// returned owners. + /// + /// On STDOUT: The JSON representation of the matched CodeownersEntry. + /// "new CodeownersEntry()" if no path in the CODEOWNERS data matches. + ///

+ /// From the Main method: exit code. 0 if successful, 1 if error. + ///
public static int Main( - string codeOwnerFilePath, - string targetDirectory, - bool filterOutNonUserAliases = false - ) + string targetPath, + string codeownersFilePathOrUrl, + bool excludeNonUserAliases = false) { - var target = targetDirectory.Trim(); - try { - var codeOwnerEntry = CodeOwnersFile.ParseAndFindOwnersForClosestMatch(codeOwnerFilePath, target); - if (filterOutNonUserAliases) + targetPath = targetPath.Trim(); + try + { + var codeownersEntry = CodeownersFile.GetMatchingCodeownersEntry(targetPath, codeownersFilePathOrUrl); + if (excludeNonUserAliases) { - codeOwnerEntry.FilterOutNonUserAliases(); + codeownersEntry.ExcludeNonUserAliases(); } - var codeOwnerJson = JsonSerializer.Serialize(codeOwnerEntry, new JsonSerializerOptions { WriteIndented = true }); - Console.WriteLine(codeOwnerJson); + + var codeownersJson = JsonSerializer.Serialize( + codeownersEntry, + new JsonSerializerOptions { WriteIndented = true }); + + Console.WriteLine(codeownersJson); return 0; } - catch (Exception e) { + catch (Exception e) + { Console.Error.WriteLine(e.Message); return 1; } diff --git a/tools/code-owners-parser/CodeOwnersParser/CodeOwnersFile.cs b/tools/code-owners-parser/CodeOwnersParser/CodeOwnersFile.cs deleted file mode 100644 index 0e7d4285973..00000000000 --- a/tools/code-owners-parser/CodeOwnersParser/CodeOwnersFile.cs +++ /dev/null @@ -1,109 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; - -namespace Azure.Sdk.Tools.CodeOwnersParser -{ - public static class CodeOwnersFile - { - public static List ParseFile(string filePathOrUrl) - { - string content = FileHelpers.GetFileContents(filePathOrUrl); - return ParseContent(content); - } - - public static List ParseContent(string fileContent) - { - List entries = new List(); - - // An entry ends when we get to a path (a real path or a commented dummy path) - using (StringReader sr = new StringReader(fileContent)) - { - CodeOwnerEntry entry = new CodeOwnerEntry(); - - // 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. - while (sr.ReadLine() is { } line) - { - line = NormalizeLine(line); - - if (string.IsNullOrWhiteSpace(line)) - { - continue; - } - - if (!line.StartsWith("#") || line.IndexOf(CodeOwnerEntry.MissingFolder, System.StringComparison.OrdinalIgnoreCase) >= 0) - { - // If this is not a comment line OR this is a placeholder entry - - entry.ParseOwnersAndPath(line); - - // only add it if it is a valid entry - if (entry.IsValid) - { - entries.Add(entry); - } - - // create a new entry. - entry = new CodeOwnerEntry(); - } - else if (line.StartsWith("#")) - { - // try to process the line in case there are markers that need to be extracted - entry.ProcessLabelsOnLine(line); - } - - } - } - return entries; - } - - public static CodeOwnerEntry ParseAndFindOwnersForClosestMatch( - string codeOwnersFilePathOrUrl, - string targetPath, - bool useNewFindOwnersForClosestMatchImpl = false) - { - var codeOwnerEntries = ParseFile(codeOwnersFilePathOrUrl); - return FindOwnersForClosestMatch(codeOwnerEntries, targetPath, useNewFindOwnersForClosestMatchImpl); - } - - public static CodeOwnerEntry FindOwnersForClosestMatch( - List codeOwnerEntries, - string targetPath, - bool useNewFindOwnersForClosestMatchImpl = false) - { - return useNewFindOwnersForClosestMatchImpl - ? new MatchedCodeOwnerEntry(codeOwnerEntries, targetPath).Value - : FindOwnersForClosestMatchLegacyImpl(codeOwnerEntries, targetPath); - } - - private static CodeOwnerEntry FindOwnersForClosestMatchLegacyImpl(List codeOwnerEntries, - string targetPath) - { - // Normalize the start and end of the paths by trimming slash - targetPath = targetPath.Trim('/'); - - // We want to find the match closest to the bottom of the codeowners file. - // CODEOWNERS sorts the paths in order of 'RepoPath', 'ServicePath' and then 'PackagePath'. - for (int i = codeOwnerEntries.Count - 1; i >= 0; i--) - { - string codeOwnerPath = codeOwnerEntries[i].PathExpression.Trim('/'); - // Note that this only matches on paths without glob patterns which is good enough - // for our current scenarios but in the future might need to support globs - if (targetPath.StartsWith(codeOwnerPath, StringComparison.OrdinalIgnoreCase)) - { - return codeOwnerEntries[i]; - } - } - - return new CodeOwnerEntry(); - } - - 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/CodeOwnerEntry.cs b/tools/code-owners-parser/CodeOwnersParser/CodeownersEntry.cs similarity index 67% rename from tools/code-owners-parser/CodeOwnersParser/CodeOwnerEntry.cs rename to tools/code-owners-parser/CodeOwnersParser/CodeownersEntry.cs index c2fa426734a..ce5558c9882 100644 --- a/tools/code-owners-parser/CodeOwnersParser/CodeOwnerEntry.cs +++ b/tools/code-owners-parser/CodeOwnersParser/CodeownersEntry.cs @@ -6,11 +6,14 @@ namespace Azure.Sdk.Tools.CodeOwnersParser { /// /// The entry for CODEOWNERS has the following structure: - /// # PRLabel: %Label - /// # ServiceLabel: %Label - /// path @owner @owner + /// + /// + /// # PRLabel: %Label + /// # ServiceLabel: %Label + /// path @owner @owner + /// /// - public class CodeOwnerEntry + public class CodeownersEntry { const char LabelSeparator = '%'; const char OwnerSeparator = '@'; @@ -20,7 +23,7 @@ public class CodeOwnerEntry public string PathExpression { get; set; } = ""; - public bool ContainsWildcard => PathExpression.Contains("*"); + public bool ContainsWildcard => PathExpression.Contains('*'); public List Owners { get; set; } = new List(); @@ -28,32 +31,24 @@ public class CodeOwnerEntry public List ServiceLabels { get; set; } = new List(); - public bool IsValid - { - get - { - return !string.IsNullOrEmpty(PathExpression); - } - } + public bool IsValid => !string.IsNullOrWhiteSpace(PathExpression); private static string[] SplitLine(string line, char splitOn) - { - return line.Split(new char[] { splitOn }, StringSplitOptions.RemoveEmptyEntries); - } + => line.Split(new char[] { splitOn }, StringSplitOptions.RemoveEmptyEntries); public override string ToString() - { - return $"HasWildcard:{ContainsWildcard} Expression:{PathExpression} Owners:{string.Join(",", Owners)} PRLabels:{string.Join(",", PRLabels)} ServiceLabels:{string.Join(",", ServiceLabels)}"; - } + => $"HasWildcard:{ContainsWildcard} Expression:{PathExpression} " + + $"Owners:{string.Join(",", Owners)} PRLabels:{string.Join(",", PRLabels)} " + + $"ServiceLabels:{string.Join(",", ServiceLabels)}"; public bool ProcessLabelsOnLine(string line) { - if (line.IndexOf(PRLabelMoniker, StringComparison.OrdinalIgnoreCase) >= 0) + if (line.Contains(PRLabelMoniker, StringComparison.OrdinalIgnoreCase)) { PRLabels.AddRange(ParseLabels(line, PRLabelMoniker)); return true; } - else if (line.IndexOf(ServiceLabelMoniker, StringComparison.OrdinalIgnoreCase) >= 0) + else if (line.Contains(ServiceLabelMoniker, StringComparison.OrdinalIgnoreCase)) { ServiceLabels.AddRange(ParseLabels(line, ServiceLabelMoniker)); return true; @@ -61,10 +56,10 @@ public bool ProcessLabelsOnLine(string line) return false; } - private IEnumerable ParseLabels(string line, string moniker) + private static IEnumerable ParseLabels(string line, string moniker) { // Parse a line that looks like # PRLabel: %Label, %Label - if (line.IndexOf(moniker, StringComparison.OrdinalIgnoreCase) == -1) + if (!line.Contains(moniker, StringComparison.OrdinalIgnoreCase)) { yield break; } @@ -76,7 +71,7 @@ private IEnumerable ParseLabels(string line, string moniker) yield break; } - line = line.Substring(colonPosition + 1).Trim(); + line = line[(colonPosition + 1)..].Trim(); foreach (string label in SplitLine(line, LabelSeparator).ToList()) { if (!string.IsNullOrWhiteSpace(label)) @@ -88,32 +83,40 @@ private IEnumerable ParseLabels(string line, string moniker) public void ParseOwnersAndPath(string line) { - if (string.IsNullOrEmpty(line) || - (line.StartsWith("#") && !(line.IndexOf(CodeOwnerEntry.MissingFolder, StringComparison.OrdinalIgnoreCase) >= 0))) + if ( + string.IsNullOrEmpty(line) + || (IsComment(line) + && !line.Contains( + CodeownersEntry.MissingFolder, StringComparison.OrdinalIgnoreCase))) { return; } line = ParsePath(line); - - //remove any comments from the line, if any. - // this is the case when we have something like @user #comment - int commentIndex = line.IndexOf("#"); - - if (commentIndex >= 0) - { - line = line.Substring(0, commentIndex).Trim(); - } + line = RemoveCommentIfAny(line); foreach (string author in SplitLine(line, OwnerSeparator).ToList()) { if (!string.IsNullOrWhiteSpace(author)) - { Owners.Add(author.Trim()); - } } } + 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 @@ -123,22 +126,19 @@ private string ParsePath(string line) return line; } - string path = line.Substring(0, ownerStartPosition).Trim(); + string path = line[..ownerStartPosition].Trim(); // the first entry is the path/regex PathExpression = path; // remove the path from the string. - return line.Substring(ownerStartPosition); + return line[ownerStartPosition..]; } /// /// Remove all code owners which are not github alias. /// - public void FilterOutNonUserAliases() - { - Owners.RemoveAll(r => !IsGitHubUserAlias(r)); - } - + public void ExcludeNonUserAliases() + => Owners.RemoveAll(r => !IsGitHubUserAlias(r)); /// /// Helper method to check if it is valid github alias. diff --git a/tools/code-owners-parser/CodeOwnersParser/CodeownersFile.cs b/tools/code-owners-parser/CodeOwnersParser/CodeownersFile.cs new file mode 100644 index 00000000000..bb77161aaae --- /dev/null +++ b/tools/code-owners-parser/CodeOwnersParser/CodeownersFile.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; + +namespace Azure.Sdk.Tools.CodeOwnersParser +{ + public static class CodeownersFile + { + public static List GetCodeownersEntriesFromFileOrUrl(string codeownersFilePathOrUrl) + { + string content = FileHelpers.GetFileOrUrlContents(codeownersFilePathOrUrl); + return GetCodeownersEntries(content); + } + + public static List GetCodeownersEntries(string codeownersContent) + { + 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); + } + + return entries; + } + + public static CodeownersEntry GetMatchingCodeownersEntry( + string targetPath, + string codeownersFilePathOrUrl, + bool useNewImpl = false) + { + var codeownersEntries = GetCodeownersEntriesFromFileOrUrl(codeownersFilePathOrUrl); + return GetMatchingCodeownersEntry(targetPath, codeownersEntries, useNewImpl); + } + + public static CodeownersEntry GetMatchingCodeownersEntry( + string targetPath, + List codeownersEntries, + bool useNewImpl = false) + { + Debug.Assert(targetPath != null); + return useNewImpl + ? new MatchedCodeownersEntry(targetPath, codeownersEntries).Value + : GetMatchingCodeownersEntryLegacyImpl(targetPath, codeownersEntries); + } + + private static CodeownersEntry ProcessCodeownersLine( + string line, + CodeownersEntry entry, + List entries) + { + line = NormalizeLine(line); + + if (string.IsNullOrWhiteSpace(line)) + { + return entry; + } + + if (!IsCommentLine(line) || (IsCommentLine(line) && IsPlaceholderEntry(line))) + { + entry.ParseOwnersAndPath(line); + + 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; + + private static CodeownersEntry GetMatchingCodeownersEntryLegacyImpl( + string targetPath, + List codeownersEntries) + { + // Normalize the start and end of the paths by trimming slash + targetPath = targetPath.Trim('/'); + + // We want to find the match closest to the bottom of the codeowners file. + // CODEOWNERS sorts the paths in order of 'RepoPath', 'ServicePath' and then 'PackagePath'. + for (int i = codeownersEntries.Count - 1; i >= 0; i--) + { + string codeownersPath = codeownersEntries[i].PathExpression.Trim('/'); + // Note that this only matches on paths without glob patterns which is good enough + // for our current scenarios but in the future might need to support globs + if (targetPath.StartsWith(codeownersPath, StringComparison.OrdinalIgnoreCase)) + { + return codeownersEntries[i]; + } + } + + return new CodeownersEntry(); + } + } +} diff --git a/tools/code-owners-parser/CodeOwnersParser/FileHelpers.cs b/tools/code-owners-parser/CodeOwnersParser/FileHelpers.cs index 2c9b0ff58e2..91db19aa3e1 100644 --- a/tools/code-owners-parser/CodeOwnersParser/FileHelpers.cs +++ b/tools/code-owners-parser/CodeOwnersParser/FileHelpers.cs @@ -6,25 +6,26 @@ namespace Azure.Sdk.Tools.CodeOwnersParser { public static class FileHelpers { - public static string GetFileContents(string fileOrUri) + public static string GetFileOrUrlContents(string fileOrUrl) { - // try to parse it as an Uri - if (fileOrUri.StartsWith("https")) - { - using (HttpClient client = new HttpClient()) - { - HttpResponseMessage response = client.GetAsync(fileOrUri).ConfigureAwait(false).GetAwaiter().GetResult(); - return response.Content.ReadAsStringAsync().ConfigureAwait(false).GetAwaiter().GetResult(); - } - } + if (fileOrUrl.StartsWith("https")) + return GetUrlContents(fileOrUrl); - string fullPath = Path.GetFullPath(fileOrUri); + 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: {fileOrUri} resolved to {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) + { + using HttpClient client = new HttpClient(); + HttpResponseMessage response = + client.GetAsync(url).ConfigureAwait(false).GetAwaiter().GetResult(); + return response.Content.ReadAsStringAsync().ConfigureAwait(false).GetAwaiter().GetResult(); } } } diff --git a/tools/code-owners-parser/CodeOwnersParser/MatchedCodeOwnerEntry.cs b/tools/code-owners-parser/CodeOwnersParser/MatchedCodeownersEntry.cs similarity index 89% rename from tools/code-owners-parser/CodeOwnersParser/MatchedCodeOwnerEntry.cs rename to tools/code-owners-parser/CodeOwnersParser/MatchedCodeownersEntry.cs index 76f3cd297ee..d828f93ea15 100644 --- a/tools/code-owners-parser/CodeOwnersParser/MatchedCodeOwnerEntry.cs +++ b/tools/code-owners-parser/CodeOwnersParser/MatchedCodeownersEntry.cs @@ -11,7 +11,7 @@ namespace Azure.Sdk.Tools.CodeOwnersParser /// /// This is a new matcher, compared to the old one, located in: /// - /// CodeOwnersFile.FindOwnersForClosestMatchLegacyImpl() + /// CodeownersFile.GetMatchingCodeownersEntryLegacyImpl() /// /// This new matcher supports matching against wildcards, while the old one doesn't. /// This new matcher is designed to work with CODEOWNERS file validation: @@ -25,7 +25,7 @@ namespace Azure.Sdk.Tools.CodeOwnersParser /// 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 /// - internal class MatchedCodeOwnerEntry + internal class MatchedCodeownersEntry { /// /// Token for temporarily substituting "**" in regex, to avoid it being escaped when @@ -38,7 +38,7 @@ internal class MatchedCodeOwnerEntry /// private const string SingleStar = "_SINGLE_STAR_"; - public readonly CodeOwnerEntry Value; + public readonly CodeownersEntry Value; /// /// See comment on IsCodeOwnersPathValid @@ -52,30 +52,30 @@ internal class MatchedCodeOwnerEntry /// private static readonly char[] unsupportedChars = { '[', ']', '!', '?' }; - private readonly ILogger log; + private readonly ILogger log; - public MatchedCodeOwnerEntry(List entries, string targetPath) + public MatchedCodeownersEntry(string targetPath, List codeownersEntries) { this.log = CreateLog(); - this.Value = FindOwnersForClosestMatch(entries, targetPath); + this.Value = GetMatchingCodeownersEntry(targetPath, codeownersEntries); } - private ILogger CreateLog() + private ILogger CreateLog() { var loggerFactory = LoggerFactory.Create(builder => { builder.AddSimpleConsole(); }); - return loggerFactory.CreateLogger(); + return loggerFactory.CreateLogger(); } /// - /// Returns a CodeOwnerEntry from codeOwnerEntries that matches targetPath + /// Returns a CodeownersEntry from codeOwnerEntries that matches targetPath /// per algorithm described in the GitHub CODEOWNERS reference, /// as linked to in this class comment. /// - /// If there is no match, returns "new CodeOwnerEntry()". + /// If there is no match, returns "new CodeownersEntry()". /// - private CodeOwnerEntry FindOwnersForClosestMatch( - List codeownersEntries, - string targetPath) + private CodeownersEntry GetMatchingCodeownersEntry( + string targetPath, + List codeownersEntries) { // targetPath is assumed to be absolute w.r.t. repository root, hence we ensure // it starts with "/" to denote that. @@ -87,7 +87,7 @@ private CodeOwnerEntry FindOwnersForClosestMatch( // so it can not match against a CODEOWNERS entry that is guaranteed to be a file, // by the virtue of not ending with "/". - CodeOwnerEntry matchedEntry = codeownersEntries + CodeownersEntry matchedEntry = codeownersEntries .Where(entry => !ContainsUnsupportedCharacters(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: @@ -99,7 +99,7 @@ private CodeOwnerEntry FindOwnersForClosestMatch( .FirstOrDefault( entry => Matches(targetPath, entry), // assert: none of the codeownersEntries matched targetPath - new CodeOwnerEntry()); + new CodeownersEntry()); return matchedEntry; } @@ -124,7 +124,7 @@ private bool ContainsUnsupportedCharacters(string codeownersPath) /// Returns true if the regex expression representing the PathExpression /// of CODEOWNERS entry matches a prefix of targetPath. ///
- private bool Matches(string targetPath, CodeOwnerEntry entry) + private bool Matches(string targetPath, CodeownersEntry entry) { string codeownersPath = entry.PathExpression; @@ -226,7 +226,7 @@ private string NormalizePath(string codeownersPath) /// /// The entry is valid if it obeys following conditions: /// - The Value was obtained with a call to - /// Azure.Sdk.Tools.CodeOwnersParser.CodeOwnersFile.ParseContent(). + /// Azure.Sdk.Tools.CodeOwnersParser.CodeownersFile.GetCodeownersEntries(). /// - As a consequence, in the case of no match, the entry is not valid. /// - It does not contain unsupported characters (see "unsupportedChars"). /// - the Value.PathExpression starts with "/". diff --git a/tools/identity-resolution/Services/GitHubService.cs b/tools/identity-resolution/Services/GitHubService.cs index bbd40d23e3f..0e4bb73ab7d 100644 --- a/tools/identity-resolution/Services/GitHubService.cs +++ b/tools/identity-resolution/Services/GitHubService.cs @@ -16,8 +16,8 @@ public class GitHubService { private static readonly HttpClient httpClient = new HttpClient(); - private static readonly ConcurrentDictionary> - codeownersFileCache = new ConcurrentDictionary>(); + private static readonly ConcurrentDictionary> + codeownersFileCache = new ConcurrentDictionary>(); private readonly ILogger logger; @@ -35,9 +35,9 @@ public GitHubService(ILogger logger) /// /// GitHub repository URL /// Contents fo the located CODEOWNERS file - public async Task> GetCodeownersFile(Uri repoUrl) + public async Task> GetCodeownersFile(Uri repoUrl) { - List result; + List result; if (codeownersFileCache.TryGetValue(repoUrl.ToString(), out result)) { return result; @@ -53,7 +53,7 @@ public async Task> GetCodeownersFile(Uri repoUrl) ///
/// /// - private async Task> GetCodeownersFileImpl(Uri repoUrl) + private async Task> GetCodeownersFileImpl(Uri repoUrl) { // Gets the repo path from the URL var relevantPathParts = repoUrl.Segments.Skip(1).Take(2); @@ -64,7 +64,7 @@ private async Task> GetCodeownersFileImpl(Uri repoUrl) if (result.IsSuccessStatusCode) { logger.LogInformation("Retrieved CODEOWNERS file URL = {0}", codeOwnersUrl); - return CodeOwnersFile.ParseContent(await result.Content.ReadAsStringAsync()); + return CodeownersFile.GetCodeownersEntries(await result.Content.ReadAsStringAsync()); } logger.LogWarning("Could not retrieve CODEOWNERS file URL = {0} ResponseCode = {1}", codeOwnersUrl, result.StatusCode); diff --git a/tools/notification-configuration/notification-configuration.sln.DotSettings b/tools/notification-configuration/notification-configuration.sln.DotSettings index 1e20132c27c..8c1c0ec65f1 100644 --- a/tools/notification-configuration/notification-configuration.sln.DotSettings +++ b/tools/notification-configuration/notification-configuration.sln.DotSettings @@ -1,3 +1,4 @@  + AAD PR True \ No newline at end of file diff --git a/tools/notification-configuration/notification-creator/NotificationConfigurator.cs b/tools/notification-configuration/notification-creator/NotificationConfigurator.cs index 386e56a3f28..adecdc32fcb 100644 --- a/tools/notification-configuration/notification-creator/NotificationConfigurator.cs +++ b/tools/notification-configuration/notification-creator/NotificationConfigurator.cs @@ -209,8 +209,8 @@ private async Task SyncTeamWithCodeOwnerFile(BuildDefinition pipeline, WebApiTea logger.LogInformation("Searching CODEOWNERS for matching path for {0}", process.YamlFilename); - var codeOwnerEntry = CodeOwnersFile.FindOwnersForClosestMatch(codeOwnerEntries, process.YamlFilename); - codeOwnerEntry.FilterOutNonUserAliases(); + var codeOwnerEntry = CodeownersFile.GetMatchingCodeownersEntry(process.YamlFilename, codeOwnerEntries); + codeOwnerEntry.ExcludeNonUserAliases(); logger.LogInformation("Matching Contacts Path = {0}, NumContacts = {1}", process.YamlFilename, codeOwnerEntry.Owners.Count); diff --git a/tools/notification-configuration/notification-creator/YamlHelper.cs b/tools/notification-configuration/notification-creator/YamlHelper.cs index bdad08f876c..a8bf28802da 100644 --- a/tools/notification-configuration/notification-creator/YamlHelper.cs +++ b/tools/notification-configuration/notification-creator/YamlHelper.cs @@ -1,4 +1,4 @@ -using System; +using System; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; @@ -6,10 +6,10 @@ namespace Azure.Sdk.Tools.NotificationConfiguration { static class YamlHelper { - private static ISerializer serializer = + private static readonly ISerializer serializer = new SerializerBuilder().WithNamingConvention(new CamelCaseNamingConvention()).Build(); - private static IDeserializer deserializer = + private static readonly IDeserializer deserializer = new DeserializerBuilder().WithNamingConvention(new CamelCaseNamingConvention()).Build();