Skip to content

Commit 807c391

Browse files
authored
[CODEOWNERS] Add support for sections (#14856)
* Add support for sections * Remove Debugger.Launch * Include default values for MCP scenarios * Generate: Add support for specific file paths * Update CHANGELOG * Guard against empty sections when creating/updating * Add tests for generate normalization * Additional testing for Section * Remove empty section
1 parent b6f66a6 commit 807c391

File tree

10 files changed

+201
-54
lines changed

10 files changed

+201
-54
lines changed

tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Helpers/CodeownersGenerateHelperTests.cs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ public void BuildCodeownersEntries_CreatesServiceLevelPathEntries()
161161
var data = new WorkItemDataBuilder()
162162
.AddOwner("aidev", out var ownerId)
163163
.AddLabel("AI", out var labelId)
164-
.AddPRLabelOwner(out _, repoPath: "sdk/ai", relatedTo: [ownerId, labelId])
164+
.AddPRLabelOwner(out _, repoPath: "sdk/ai/", relatedTo: [ownerId, labelId])
165165
.Build();
166166

167167
var packageLookup = new Dictionary<string, RepoPackage>(StringComparer.OrdinalIgnoreCase);
@@ -179,6 +179,31 @@ public void BuildCodeownersEntries_CreatesServiceLevelPathEntries()
179179
});
180180
}
181181

182+
[Test]
183+
public void BuildCodeownersEntries_CreatesPathEntryForFile()
184+
{
185+
// Arrange: Label Owner with RepoPath but not linked to any package
186+
var data = new WorkItemDataBuilder()
187+
.AddOwner("aidev", out var ownerId)
188+
.AddLabel("AI", out var labelId)
189+
.AddPRLabelOwner(out _, repoPath: "sdk/ai/file.txt", relatedTo: [ownerId, labelId])
190+
.Build();
191+
192+
var packageLookup = new Dictionary<string, RepoPackage>(StringComparer.OrdinalIgnoreCase);
193+
194+
// Act
195+
var entries = InvokeBuildCodeownersEntries(data, packageLookup);
196+
197+
// Assert
198+
Assert.That(entries, Has.Count.EqualTo(1));
199+
Assert.Multiple(() =>
200+
{
201+
Assert.That(entries[0].PathExpression, Is.EqualTo("/sdk/ai/file.txt"));
202+
Assert.That(entries[0].SourceOwners, Does.Contain("aidev"));
203+
Assert.That(entries[0].PRLabels, Does.Contain("AI"));
204+
});
205+
}
206+
182207
[Test]
183208
public void BuildCodeownersEntries_AddsLabelOwnerMetadataToPackageEntry()
184209
{

tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Helpers/CodeownersManagementHelperTests.cs

Lines changed: 86 additions & 21 deletions
Large diffs are not rendered by default.

tools/azsdk-cli/Azure.Sdk.Tools.Cli/CHANGELOG.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
# Release History
22

3-
## 0.6.6 (Unreleased)
3+
## 0.6.6 (2026-04-01)
44

55
### Features Added
66

7-
### Breaking Changes
7+
- Added support for CODEOWNERS "Section" in Label Owners (defaults to "Client Libraries")
88

99
### Bugs Fixed
1010

1111
- Fixed sample translation to preserve source directory structure when writing translated files
1212

1313
### Other Changes
1414

15+
- CODEOWNERS generator supports file paths and doesn't assume all paths are directories
16+
1517
## 0.6.5 (2026-03-27)
1618

1719
### Features Added

tools/azsdk-cli/Azure.Sdk.Tools.Cli/Helpers/CodeownersGenerateHelper.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ private List<CodeownersEntry> BuildCodeownersEntries(
238238
var serviceLevelPathEntries = new Dictionary<string, CodeownersEntry>(StringComparer.OrdinalIgnoreCase);
239239
foreach (var lo in unlinkedLabelOwners.Where(lo => !string.IsNullOrEmpty(lo.RepoPath)))
240240
{
241-
string pathExpression = "/" + lo.RepoPath.TrimStart('/').TrimEnd('/') + "/";
241+
string pathExpression = "/" + lo.RepoPath.TrimStart('/');
242242

243243
var sourceOwners = lo.Owners
244244
.Select(o => o.GitHubAlias)

tools/azsdk-cli/Azure.Sdk.Tools.Cli/Helpers/CodeownersManagementHelper.cs

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ public async Task<CodeownersViewResponse> GetViewByLabel(string[] labels, string
8585

8686
public async Task<CodeownersViewResponse> GetViewByPath(string path, string? repo, CancellationToken ct)
8787
{
88-
var labelOwners = await QueryLabelOwnersByPath(path, repo, ct);
88+
var labelOwners = await QueryLabelOwnersByPath(path, repo, null, ct);
8989
await HydrateLabelOwners(labelOwners, ct);
9090

9191
return new CodeownersViewResponse([], labelOwners);
@@ -233,7 +233,7 @@ private async Task<List<LabelOwnerWorkItem>> FetchRelatedLabelOwners(IEnumerable
233233
return labelOwners;
234234
}
235235

236-
private async Task<List<LabelOwnerWorkItem>> QueryLabelOwnersByPath(string path, string? repo, CancellationToken ct)
236+
private async Task<List<LabelOwnerWorkItem>> QueryLabelOwnersByPath(string path, string? repo, string? section, CancellationToken ct)
237237
{
238238
var escapedPath = string.IsNullOrEmpty(path) ? string.Empty : path.Replace("'", "''");
239239
var query = $"SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = '{Constants.AZURE_SDK_DEVOPS_RELEASE_PROJECT}' AND [System.WorkItemType] = 'Label Owner' AND [Custom.RepoPath] = '{escapedPath}'";
@@ -242,6 +242,11 @@ private async Task<List<LabelOwnerWorkItem>> QueryLabelOwnersByPath(string path,
242242
var escapedRepo = repo.Replace("'", "''");
243243
query += $" AND [Custom.Repository] = '{escapedRepo}'";
244244
}
245+
if (!string.IsNullOrEmpty(section))
246+
{
247+
var escapedSection = section.Replace("'", "''");
248+
query += $" AND [Custom.Section] = '{escapedSection}'";
249+
}
245250
var rawWorkItems = await devOpsService.FetchWorkItemsPagedAsync(query, expand: WorkItemExpand.Relations, ct: ct);
246251
return rawWorkItems.Select(WorkItemMappers.MapToLabelOwnerWorkItem).ToList();
247252
}
@@ -359,6 +364,7 @@ public async Task<LabelOwnerWorkItem> FindOrCreateLabelOwnerAsync(
359364
OwnerType ownerType,
360365
string? repoPath,
361366
LabelWorkItem[] labelWorkItems,
367+
string section,
362368
CancellationToken ct
363369
) {
364370
var labelTypeString = ownerType.ToWorkItemString();
@@ -367,12 +373,14 @@ CancellationToken ct
367373
var escapedRepo = repo.Replace("'", "''");
368374
var escapedLabelType = labelTypeString.Replace("'", "''");
369375
var escapedPath = normalizedPath.Replace("'", "''");
376+
var escapedSection = section.Replace("'", "''");
370377

371378
var query = $"SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = '{Constants.AZURE_SDK_DEVOPS_RELEASE_PROJECT}'" +
372379
$" AND [System.WorkItemType] = 'Label Owner'" +
373380
$" AND [Custom.Repository] = '{escapedRepo}'" +
374381
$" AND [Custom.LabelType] = '{escapedLabelType}'" +
375-
$" AND [Custom.RepoPath] = '{escapedPath}'";
382+
$" AND [Custom.RepoPath] = '{escapedPath}'" +
383+
$" AND [Custom.Section] = '{escapedSection}'";
376384

377385
var workItems = await devOpsService.FetchWorkItemsPagedAsync(query, expand: WorkItemExpand.Relations, ct: ct);
378386
if (workItems.Count > 0)
@@ -403,7 +411,8 @@ CancellationToken ct
403411
{
404412
LabelType = labelTypeString,
405413
Repository = repo,
406-
RepoPath = normalizedPath
414+
RepoPath = normalizedPath,
415+
Section = section
407416
};
408417
var created = await devOpsService.CreateWorkItemAsync(labelOwnerWi, "Label Owner", title, ct: ct);
409418
return WorkItemMappers.MapToLabelOwnerWorkItem(created);
@@ -548,9 +557,10 @@ public async Task<CodeownersModifyResponse> AddOwnersAndLabelsToPath(
548557
string repo,
549558
string path,
550559
OwnerType ownerType,
560+
string section,
551561
CancellationToken ct
552562
) {
553-
var labelOwnerWi = await FindOrCreateLabelOwnerAsync(repo, ownerType, path, labels, ct);
563+
var labelOwnerWi = await FindOrCreateLabelOwnerAsync(repo, ownerType, path, labels, section, ct);
554564

555565
foreach (var labelWi in labels)
556566
{
@@ -651,9 +661,10 @@ public async Task<CodeownersModifyResponse> RemoveOwnersFromLabelsAndPath(
651661
string repo,
652662
string path,
653663
OwnerType ownerType,
664+
string section,
654665
CancellationToken ct
655666
) {
656-
var labelOwners = await QueryLabelOwnersByPath(path, repo, ct);
667+
var labelOwners = await QueryLabelOwnersByPath(path, repo, section, ct);
657668
if (labelOwners.Count == 0)
658669
{
659670
return new CodeownersModifyResponse { ResponseError = $"No Label Owner work item found for path '{path}'." };

tools/azsdk-cli/Azure.Sdk.Tools.Cli/Helpers/ICodeownersManagementHelper.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Task<LabelOwnerWorkItem> FindOrCreateLabelOwnerAsync(
2020
OwnerType ownerType,
2121
string? repoPath,
2222
LabelWorkItem[] labelWorkItems,
23+
string section,
2324
CancellationToken ct
2425
);
2526

@@ -29,12 +30,12 @@ CancellationToken ct
2930
// Add scenarios
3031
Task<CodeownersModifyResponse> AddOwnersToPackage(OwnerWorkItem[] owners, string packageName, string repo, CancellationToken ct);
3132
Task<CodeownersModifyResponse> AddLabelsToPackage(LabelWorkItem[] labels, string packageName, string repo, CancellationToken ct);
32-
Task<CodeownersModifyResponse> AddOwnersAndLabelsToPath(OwnerWorkItem[] owners, LabelWorkItem[] labels, string repo, string path, OwnerType ownerType, CancellationToken ct);
33+
Task<CodeownersModifyResponse> AddOwnersAndLabelsToPath(OwnerWorkItem[] owners, LabelWorkItem[] labels, string repo, string path, OwnerType ownerType, string section, CancellationToken ct);
3334

3435
// Remove scenarios
3536
Task<CodeownersModifyResponse> RemoveOwnersFromPackage(OwnerWorkItem[] owners, string packageName, string repo, CancellationToken ct);
3637
Task<CodeownersModifyResponse> RemoveLabelsFromPackage(LabelWorkItem[] labels, string packageName, string repo, CancellationToken ct);
37-
Task<CodeownersModifyResponse> RemoveOwnersFromLabelsAndPath(OwnerWorkItem[] owners, LabelWorkItem[] labels, string repo, string path, OwnerType ownerType, CancellationToken ct);
38+
Task<CodeownersModifyResponse> RemoveOwnersFromLabelsAndPath(OwnerWorkItem[] owners, LabelWorkItem[] labels, string repo, string path, OwnerType ownerType, string section, CancellationToken ct);
3839

3940
// Validation
4041
Task ThrowIfInvalidTeamAlias(string alias, CancellationToken ct);

tools/azsdk-cli/Azure.Sdk.Tools.Cli/Models/AzureDevOps/LabelOwnerWorkItem.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ public class LabelOwnerWorkItem : WorkItemBase
1919
[FieldName("Custom.RepoPath")]
2020
public string RepoPath { get; set; } = string.Empty;
2121

22+
[FieldName("Custom.Section")]
23+
public string Section { get; set; } = string.Empty;
24+
2225
/// <summary>
2326
/// IDs of related work items (populated from work item relations).
2427
/// </summary>

tools/azsdk-cli/Azure.Sdk.Tools.Cli/Models/Codeowners/WorkItemMappers.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ public static LabelOwnerWorkItem MapToLabelOwnerWorkItem(WorkItem wi)
6262
LabelType = wi.GetFieldValue("Custom.LabelType"),
6363
Repository = wi.GetFieldValue("Custom.Repository"),
6464
RepoPath = wi.GetFieldValue("Custom.RepoPath"),
65+
Section = wi.GetFieldValue("Custom.Section"),
6566
RelatedIds = ExtractRelatedIds(wi)
6667
};
6768
}

tools/azsdk-cli/Azure.Sdk.Tools.Cli/Models/Responses/Codeowners/CodeownersViewResponse.cs

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ public LabelOwnerResponse(LabelOwnerWorkItem labelOwnerWorkItem)
9191
Repo = labelOwnerWorkItem.Repository;
9292
Path = labelOwnerWorkItem.RepoPath;
9393
Type = labelOwnerWorkItem.LabelType;
94+
Section = string.IsNullOrEmpty(labelOwnerWorkItem.Section) ? null : labelOwnerWorkItem.Section;
9495
Owners = owners.Count > 0 ? owners : null;
9596
Labels = labels.Count > 0 ? labels : null;
9697
}
@@ -110,6 +111,10 @@ public LabelOwnerResponse(LabelOwnerWorkItem labelOwnerWorkItem)
110111
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
111112
public string? Type { get; set; }
112113

114+
[JsonPropertyName("section")]
115+
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
116+
public string? Section { get; set; }
117+
113118
[JsonPropertyName("owners")]
114119
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
115120
public List<OwnerResponse>? Owners { get; set; }
@@ -156,6 +161,7 @@ public CodeownersViewResponse(List<PackageWorkItem> packages, List<LabelOwnerWor
156161
{
157162
PathBasedLabelOwners = pathBased.Select(labelOwner => new LabelOwnerResponse(labelOwner))
158163
.OrderBy(lo => lo.Repo, StringComparer.OrdinalIgnoreCase)
164+
.ThenBy(lo => lo.Section, StringComparer.OrdinalIgnoreCase)
159165
.ThenBy(lo => lo.Path, StringComparer.OrdinalIgnoreCase)
160166
.ToList();
161167
}
@@ -164,6 +170,7 @@ public CodeownersViewResponse(List<PackageWorkItem> packages, List<LabelOwnerWor
164170
{
165171
PathlessLabelOwners = pathless.Select(labelOwner => new LabelOwnerResponse(labelOwner))
166172
.OrderBy(lo => lo.Repo, StringComparer.OrdinalIgnoreCase)
173+
.ThenBy(lo => lo.Section, StringComparer.OrdinalIgnoreCase)
167174
.ThenBy(lo => string.Join("|", lo.Labels ?? []), StringComparer.OrdinalIgnoreCase)
168175
.ToList();
169176
}
@@ -198,21 +205,30 @@ protected override string Format()
198205
.OrderBy(rg => rg.Key, StringComparer.OrdinalIgnoreCase))
199206
{
200207
sb.AppendLine($" Repo: {repoGroup.Key}");
201-
foreach (var pathGroup in repoGroup
202-
.GroupBy(lo => lo.Path, StringComparer.OrdinalIgnoreCase)
203-
.OrderBy(pg => pg.Key, StringComparer.OrdinalIgnoreCase))
208+
foreach (var sectionGroup in repoGroup
209+
.GroupBy(lo => lo.Section ?? "", StringComparer.OrdinalIgnoreCase)
210+
.OrderBy(sg => sg.Key, StringComparer.OrdinalIgnoreCase))
204211
{
205-
sb.AppendLine($" Path: {pathGroup.Key}");
206-
foreach (var lo in pathGroup)
212+
if (!string.IsNullOrEmpty(sectionGroup.Key))
207213
{
208-
sb.AppendLine($" Type: {lo.Type} [{lo.WorkItemId}]");
209-
if (lo.Owners?.Count > 0)
210-
{
211-
sb.AppendLine($" Owners: {FormatOwnersList(lo.Owners)}");
212-
}
213-
if (lo.Labels?.Count > 0)
214+
sb.AppendLine($" Section: {sectionGroup.Key}");
215+
}
216+
foreach (var pathGroup in sectionGroup
217+
.GroupBy(lo => lo.Path, StringComparer.OrdinalIgnoreCase)
218+
.OrderBy(pg => pg.Key, StringComparer.OrdinalIgnoreCase))
219+
{
220+
sb.AppendLine($" Path: {pathGroup.Key}");
221+
foreach (var lo in pathGroup)
214222
{
215-
sb.AppendLine($" Labels: {string.Join(", ", lo.Labels)}");
223+
sb.AppendLine($" Type: {lo.Type} [{lo.WorkItemId}]");
224+
if (lo.Owners?.Count > 0)
225+
{
226+
sb.AppendLine($" Owners: {FormatOwnersList(lo.Owners)}");
227+
}
228+
if (lo.Labels?.Count > 0)
229+
{
230+
sb.AppendLine($" Labels: {string.Join(", ", lo.Labels)}");
231+
}
216232
}
217233
}
218234
}
@@ -227,13 +243,22 @@ protected override string Format()
227243
.OrderBy(rg => rg.Key, StringComparer.OrdinalIgnoreCase))
228244
{
229245
sb.AppendLine($" Repo: {repoGroup.Key}");
230-
foreach (var lo in repoGroup)
246+
foreach (var sectionGroup in repoGroup
247+
.GroupBy(lo => lo.Section ?? "", StringComparer.OrdinalIgnoreCase)
248+
.OrderBy(sg => sg.Key, StringComparer.OrdinalIgnoreCase))
231249
{
232-
sb.AppendLine($" Labels: {string.Join(", ", lo.Labels ?? [])} [{lo.WorkItemId}]");
233-
sb.AppendLine($" Type: {lo.Type}");
234-
if (lo.Owners?.Count > 0)
250+
if (!string.IsNullOrEmpty(sectionGroup.Key))
251+
{
252+
sb.AppendLine($" Section: {sectionGroup.Key}");
253+
}
254+
foreach (var lo in sectionGroup)
235255
{
236-
sb.AppendLine($" Owners: {FormatOwnersList(lo.Owners)}");
256+
sb.AppendLine($" Labels: {string.Join(", ", lo.Labels ?? [])} [{lo.WorkItemId}]");
257+
sb.AppendLine($" Type: {lo.Type}");
258+
if (lo.Owners?.Count > 0)
259+
{
260+
sb.AppendLine($" Owners: {FormatOwnersList(lo.Owners)}");
261+
}
237262
}
238263
}
239264
}

tools/azsdk-cli/Azure.Sdk.Tools.Cli/Tools/Config/CodeownersTool.cs

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ protected override List<Command> GetCommands() =>
210210
},
211211
new(addLabelOwnerCommandName, "Add owner(s) to a label and optional path")
212212
{
213-
multipleGithubUserOption, labelsOption, pathOption, ownerTypeOption, optionalRepoOption,
213+
multipleGithubUserOption, labelsOption, pathOption, ownerTypeOption, optionalRepoOption, sectionOption,
214214
},
215215
new(removeCodeownersToPackageCommandName, "Remove source owner(s) from a package")
216216
{
@@ -222,7 +222,7 @@ protected override List<Command> GetCommands() =>
222222
},
223223
new(removeLabelOwnerCommandName, "Remove owner(s) from a label and optional path")
224224
{
225-
multipleGithubUserOption, labelsOption, pathOption, ownerTypeOption, optionalRepoOption,
225+
multipleGithubUserOption, labelsOption, pathOption, ownerTypeOption, optionalRepoOption, sectionOption,
226226
},
227227
new(exportSectionCommandName, "Export one or more named sections from a CODEOWNERS file")
228228
{
@@ -278,7 +278,8 @@ public override async Task<CommandResponse> HandleCommand(ParseResult parseResul
278278
var ownerType = parseResult.GetValue(ownerTypeOption);
279279
var path = parseResult.GetValue(pathOption);
280280
var repo = parseResult.GetValue(optionalRepoOption);
281-
return await AddLabelOwner(users!, labels!, ownerType!, path, repo, ct);
281+
var section = parseResult.GetValue(sectionOption);
282+
return await AddLabelOwner(users!, labels!, ownerType!, path, repo, section, ct);
282283
}
283284

284285
if (command == removeCodeownersToPackageCommandName)
@@ -304,7 +305,8 @@ public override async Task<CommandResponse> HandleCommand(ParseResult parseResul
304305
var ownerType = parseResult.GetValue(ownerTypeOption);
305306
var path = parseResult.GetValue(pathOption);
306307
var repo = parseResult.GetValue(optionalRepoOption);
307-
return await RemoveLabelOwner(users!, labels!, ownerType!, path, repo, ct);
308+
var section = parseResult.GetValue(sectionOption);
309+
return await RemoveLabelOwner(users!, labels!, ownerType!, path, repo, section, ct);
308310
}
309311

310312
if (command == exportSectionCommandName)
@@ -515,18 +517,24 @@ public async Task<CommandResponse> AddLabelOwner(
515517
OwnerType ownerType,
516518
string? path = null,
517519
string? repo = null,
520+
string section = "Client Libraries",
518521
CancellationToken ct = default
519522
)
520523
{
521524
try
522525
{
526+
if (string.IsNullOrEmpty(section))
527+
{
528+
throw new ArgumentException("Section name must be provided", nameof(section));
529+
}
523530
repo = await ResolveRepo(repo, ct);
524531
return await codeownersManagementHelper.AddOwnersAndLabelsToPath(
525532
await FindOrCreateOwnerWorkItems(githubUsers, ct),
526533
await FindLabels(labels, ct),
527534
repo,
528535
path,
529536
ownerType,
537+
section,
530538
ct
531539
);
532540
}
@@ -594,18 +602,24 @@ public async Task<CommandResponse> RemoveLabelOwner(
594602
OwnerType ownerType,
595603
string? path = null,
596604
string? repo = null,
605+
string section = "Client Libraries",
597606
CancellationToken ct = default
598607
)
599608
{
600609
try
601610
{
611+
if (string.IsNullOrEmpty(section))
612+
{
613+
throw new ArgumentException("Section name must be provided", nameof(section));
614+
}
602615
repo = await ResolveRepo(repo, ct);
603616
return await codeownersManagementHelper.RemoveOwnersFromLabelsAndPath(
604617
await GetOwnerWorkItems(githubUsers, ct),
605618
await FindLabels(labels, ct),
606619
repo,
607620
path,
608621
ownerType,
622+
section,
609623
ct
610624
);
611625
}

0 commit comments

Comments
 (0)