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 1661dda8d51..07ca20b85b9 100644 --- a/tools/code-owners-parser/Azure.Sdk.Tools.RetrieveCodeOwners/Program.cs +++ b/tools/code-owners-parser/Azure.Sdk.Tools.RetrieveCodeOwners/Program.cs @@ -37,6 +37,8 @@ public static class Program /// Defaults to ".git". /// Example usage: ".git|foo|bar" /// + /// Override for the default URI where the team/storage blob data resides. + /// File to output the owners data to, will overwrite if the file exist. /// /// On STDOUT: The JSON representation of the matched CodeownersEntry. /// "new CodeownersEntry()" if no path in the CODEOWNERS data matches. @@ -48,7 +50,9 @@ public static int Main( string codeownersFilePathOrUrl, bool excludeNonUserAliases = false, string? targetDir = null, - string ignoredPathPrefixes = DefaultIgnoredPrefixes) + string ignoredPathPrefixes = DefaultIgnoredPrefixes, + string? teamStorageURI = null, + string? ownersDataOutputFile = null) { try { @@ -71,17 +75,29 @@ public static int Main( targetDir!, codeownersFilePathOrUrl, excludeNonUserAliases, - SplitIgnoredPathPrefixes()) + SplitIgnoredPathPrefixes(), + teamStorageURI) : GetCodeownersForSimplePath( targetPath, codeownersFilePathOrUrl, - excludeNonUserAliases); + excludeNonUserAliases, + teamStorageURI); string codeownersJson = JsonSerializer.Serialize( codeownersData, new JsonSerializerOptions { WriteIndented = true }); Console.WriteLine(codeownersJson); + + // If the output data file is specified, write the json to that. + if (!string.IsNullOrEmpty(ownersDataOutputFile)) + { + // False in the ctor is to overwrite, not append + using (StreamWriter outputFile = new StreamWriter(ownersDataOutputFile, false)) + { + outputFile.WriteLine(codeownersJson); + } + } return 0; string[] SplitIgnoredPathPrefixes() @@ -101,7 +117,8 @@ private static Dictionary GetCodeownersForGlobPath( string targetDir, string codeownersFilePathOrUrl, bool excludeNonUserAliases, - string[]? ignoredPathPrefixes = null) + string[]? ignoredPathPrefixes = null, + string? teamStorageURI=null) { ignoredPathPrefixes ??= Array.Empty(); @@ -110,7 +127,8 @@ private static Dictionary GetCodeownersForGlobPath( targetPath, targetDir, codeownersFilePathOrUrl, - ignoredPathPrefixes); + ignoredPathPrefixes, + teamStorageURI); if (excludeNonUserAliases) codeownersEntries.Values.ToList().ForEach(entry => entry.ExcludeNonUserAliases()); @@ -121,12 +139,14 @@ private static Dictionary GetCodeownersForGlobPath( private static CodeownersEntry GetCodeownersForSimplePath( string targetPath, string codeownersFilePathOrUrl, - bool excludeNonUserAliases) + bool excludeNonUserAliases, + string? teamStorageURI = null) { CodeownersEntry codeownersEntry = CodeownersFile.GetMatchingCodeownersEntry( targetPath, - codeownersFilePathOrUrl); + codeownersFilePathOrUrl, + teamStorageURI); if (excludeNonUserAliases) codeownersEntry.ExcludeNonUserAliases(); diff --git a/tools/code-owners-parser/CodeOwnersParser/CodeownersEntry.cs b/tools/code-owners-parser/CodeOwnersParser/CodeownersEntry.cs index 72f1d9fbf83..782adfd7e00 100644 --- a/tools/code-owners-parser/CodeOwnersParser/CodeownersEntry.cs +++ b/tools/code-owners-parser/CodeOwnersParser/CodeownersEntry.cs @@ -1,7 +1,10 @@ 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 { @@ -45,7 +48,7 @@ public CodeownersEntry(string pathExpression, List owners) } private static string[] SplitLine(string line, char splitOn) - => line.Split(new char[] { splitOn }, StringSplitOptions.RemoveEmptyEntries); + => line.Split(new char[] { splitOn }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); public override string ToString() => $"HasWildcard:{ContainsWildcard} Expression:{PathExpression} " + @@ -85,14 +88,11 @@ private static IEnumerable ParseLabels(string line, string moniker) line = line[(colonPosition + 1)..].Trim(); foreach (string label in SplitLine(line, LabelSeparator).ToList()) { - if (!string.IsNullOrWhiteSpace(label)) - { - yield return label.Trim(); - } + yield return label; } } - public void ParseOwnersAndPath(string line) + public void ParseOwnersAndPath(string line, TeamUserHolder teamUserHolder) { if ( string.IsNullOrEmpty(line) @@ -106,10 +106,42 @@ public void ParseOwnersAndPath(string line) line = ParsePath(line); line = RemoveCommentIfAny(line); - foreach (string author in SplitLine(line, OwnerSeparator).ToList()) + // 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 { - if (!string.IsNullOrWhiteSpace(author)) - Owners.Add(author.Trim()); + Console.WriteLine($"Warning: CODEOWNERS line '{line}' does not have an owner entry."); } } diff --git a/tools/code-owners-parser/CodeOwnersParser/CodeownersFile.cs b/tools/code-owners-parser/CodeOwnersParser/CodeownersFile.cs index 83f1dc376ee..110f001751a 100644 --- a/tools/code-owners-parser/CodeOwnersParser/CodeownersFile.cs +++ b/tools/code-owners-parser/CodeOwnersParser/CodeownersFile.cs @@ -3,20 +3,24 @@ 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 codeownersFilePathOrUrl, + string? teamStorageURI = null) { string content = FileHelpers.GetFileOrUrlContents(codeownersFilePathOrUrl); - return GetCodeownersEntries(content); + return GetCodeownersEntries(content, teamStorageURI); } - public static List GetCodeownersEntries(string codeownersContent) + 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 @@ -28,7 +32,7 @@ public static List GetCodeownersEntries(string codeownersConten using StringReader sr = new StringReader(codeownersContent); while (sr.ReadLine() is { } line) { - entry = ProcessCodeownersLine(line, entry, entries); + entry = ProcessCodeownersLine(line, entry, entries, teamUserHolder); } return entries; @@ -36,9 +40,10 @@ public static List GetCodeownersEntries(string codeownersConten public static CodeownersEntry GetMatchingCodeownersEntry( string targetPath, - string codeownersFilePathOrUrl) + string codeownersFilePathOrUrl, + string? teamStorageURI = null) { - var codeownersEntries = GetCodeownersEntriesFromFileOrUrl(codeownersFilePathOrUrl); + var codeownersEntries = GetCodeownersEntriesFromFileOrUrl(codeownersFilePathOrUrl, teamStorageURI); return GetMatchingCodeownersEntry(targetPath, codeownersEntries); } @@ -46,11 +51,12 @@ public static Dictionary GetMatchingCodeownersEntries( GlobFilePath targetPath, string targetDir, string codeownersFilePathOrUrl, - string[]? ignoredPathPrefixes = null) + string[]? ignoredPathPrefixes = null, + string? teamStorageURI = null) { ignoredPathPrefixes ??= Array.Empty(); - var codeownersEntries = GetCodeownersEntriesFromFileOrUrl(codeownersFilePathOrUrl); + var codeownersEntries = GetCodeownersEntriesFromFileOrUrl(codeownersFilePathOrUrl, teamStorageURI); Dictionary codeownersEntriesByPath = targetPath .ResolveGlob(targetDir, ignoredPathPrefixes) @@ -74,7 +80,8 @@ public static CodeownersEntry GetMatchingCodeownersEntry( private static CodeownersEntry ProcessCodeownersLine( string line, CodeownersEntry entry, - List entries) + List entries, + TeamUserHolder teamUserHolder) { line = NormalizeLine(line); @@ -85,7 +92,7 @@ private static CodeownersEntry ProcessCodeownersLine( if (!IsCommentLine(line) || (IsCommentLine(line) && IsPlaceholderEntry(line))) { - entry.ParseOwnersAndPath(line); + entry.ParseOwnersAndPath(line, teamUserHolder); if (entry.IsValid) entries.Add(entry); diff --git a/tools/code-owners-parser/CodeOwnersParser/DefaultStorageConstants.cs b/tools/code-owners-parser/CodeOwnersParser/DefaultStorageConstants.cs new file mode 100644 index 00000000000..dfc25d9b957 --- /dev/null +++ b/tools/code-owners-parser/CodeOwnersParser/DefaultStorageConstants.cs @@ -0,0 +1,13 @@ +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/TeamUserHolder.cs b/tools/code-owners-parser/CodeOwnersParser/TeamUserHolder.cs new file mode 100644 index 00000000000..4a3a7c4ba60 --- /dev/null +++ b/tools/code-owners-parser/CodeOwnersParser/TeamUserHolder.cs @@ -0,0 +1,79 @@ +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(); + } + } +}