Skip to content

Commit c341840

Browse files
fix: gitlab codeowners exclusion handling (#12021)
fix: gitlab codeowners exclusion handling Co-authored-by: daniel.mohedano <daniel.mohedano@datadoghq.com>
1 parent 1358e8e commit c341840

7 files changed

Lines changed: 115 additions & 55 deletions

File tree

dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/codeowners/CodeownersImpl.java

Lines changed: 53 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,9 @@
2020

2121
public class CodeownersImpl implements Codeowners {
2222

23-
// Entries grouped by section in order of appearance (the unnamed section that precedes the first
24-
// header comes first). Within a section, the highest-priority (last in the file) matching entry
25-
// wins and owners from every matching section are then combined.
26-
private final Collection<Deque<Entry>> sections;
23+
private final Collection<Section> sections;
2724

28-
private CodeownersImpl(Collection<Deque<Entry>> sections) {
25+
private CodeownersImpl(Collection<Section> sections) {
2926
this.sections = sections;
3027
}
3128

@@ -37,15 +34,20 @@ private CodeownersImpl(Collection<Deque<Entry>> sections) {
3734
public @Nullable Collection<String> getOwners(@Nonnull String path) {
3835
char[] pathCharacters = path.toCharArray();
3936
Set<String> owners = null;
40-
for (Deque<Entry> section : sections) {
41-
for (Entry entry : section) {
42-
if (entry.getMatcher().consume(pathCharacters, 0) >= 0) {
43-
if (owners == null) {
44-
owners = new LinkedHashSet<>();
45-
}
46-
owners.addAll(entry.getOwners());
47-
break; // highest-priority match within a section wins
37+
for (Section section : sections) {
38+
if (section.isExcluded(pathCharacters)) {
39+
if (owners == null) {
40+
owners = new LinkedHashSet<>();
4841
}
42+
continue;
43+
}
44+
45+
Entry entry = section.findMatchingEntry(pathCharacters);
46+
if (entry != null) {
47+
if (owners == null) {
48+
owners = new LinkedHashSet<>();
49+
}
50+
owners.addAll(entry.getOwners());
4951
}
5052
}
5153
return owners != null ? new ArrayList<>(owners) : null;
@@ -57,9 +59,9 @@ public boolean exist() {
5759
}
5860

5961
public static Codeowners parse(Reader r) throws IOException {
60-
Deque<Entry> defaultSection = new ArrayDeque<>();
61-
Map<String, Deque<Entry>> namedSections = new LinkedHashMap<>();
62-
Deque<Entry> currentSection = defaultSection;
62+
Section defaultSection = new Section();
63+
Map<String, Section> namedSections = new LinkedHashMap<>();
64+
Section currentSection = defaultSection;
6365

6466
CharacterMatcher.Factory characterMatcherFactory = new CharacterMatcher.Factory();
6567
BufferedReader br = new BufferedReader(r);
@@ -73,21 +75,51 @@ public static Codeowners parse(Reader r) throws IOException {
7375
if (header != null) {
7476
sectionDefaultOwners = header.getDefaultOwners();
7577
String key = header.getName().trim().toLowerCase(Locale.ROOT);
76-
currentSection = namedSections.computeIfAbsent(key, k -> new ArrayDeque<>());
78+
currentSection = namedSections.computeIfAbsent(key, k -> new Section());
7779
continue;
7880
}
7981

8082
Entry entry = entryBuilder.parse(sectionDefaultOwners);
8183
if (entry != null) {
82-
// within a section, the last entry in the file has the highest priority
83-
currentSection.offerFirst(entry);
84+
currentSection.add(entry);
8485
}
8586
}
8687

87-
// Unnamed section is evaluated first, then named sections in order of first appearance
88-
List<Deque<Entry>> sections = new ArrayList<>(namedSections.size() + 1);
88+
List<Section> sections = new ArrayList<>(namedSections.size() + 1);
8989
sections.add(defaultSection);
9090
sections.addAll(namedSections.values());
9191
return new CodeownersImpl(sections);
9292
}
93+
94+
private static final class Section {
95+
96+
private final Deque<Entry> entries = new ArrayDeque<>();
97+
private final Collection<Entry> exclusions = new ArrayList<>();
98+
99+
private void add(Entry entry) {
100+
if (entry.isExclusion()) {
101+
exclusions.add(entry);
102+
} else {
103+
entries.offerFirst(entry);
104+
}
105+
}
106+
107+
private boolean isExcluded(char[] path) {
108+
for (Entry exclusion : exclusions) {
109+
if (exclusion.getMatcher().consume(path, 0) >= 0) {
110+
return true;
111+
}
112+
}
113+
return false;
114+
}
115+
116+
private @Nullable Entry findMatchingEntry(char[] path) {
117+
for (Entry entry : entries) {
118+
if (entry.getMatcher().consume(path, 0) >= 0) {
119+
return entry;
120+
}
121+
}
122+
return null;
123+
}
124+
}
93125
}

dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/codeowners/Entry.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ public class Entry {
77

88
private final Matcher matcher;
99
private final Collection<String> owners;
10+
private final boolean exclusion;
1011

11-
public Entry(Matcher matcher, Collection<String> owners) {
12+
public Entry(Matcher matcher, Collection<String> owners, boolean exclusion) {
1213
this.matcher = matcher;
1314
this.owners = owners;
15+
this.exclusion = exclusion;
1416
}
1517

1618
public Matcher getMatcher() {
@@ -20,4 +22,8 @@ public Matcher getMatcher() {
2022
public Collection<String> getOwners() {
2123
return owners;
2224
}
25+
26+
public boolean isExclusion() {
27+
return exclusion;
28+
}
2329
}

dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/codeowners/EntryBuilder.java

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
import datadog.trace.civisibility.codeowners.matcher.EndOfLineMatcher;
88
import datadog.trace.civisibility.codeowners.matcher.EndOfSegmentMatcher;
99
import datadog.trace.civisibility.codeowners.matcher.Matcher;
10-
import datadog.trace.civisibility.codeowners.matcher.NegatingMatcher;
1110
import datadog.trace.civisibility.codeowners.matcher.QuestionMarkMatcher;
1211
import datadog.trace.civisibility.codeowners.matcher.RangeMatcher;
1312
import java.util.ArrayDeque;
@@ -69,12 +68,17 @@ public EntryBuilder(CharacterMatcher.Factory characterMatcherFactory, String s)
6968
return null;
7069
}
7170

71+
boolean exclusion = c[offset] == '!';
72+
if (exclusion) {
73+
offset++;
74+
}
75+
7276
Matcher matcher = parseMatcher();
73-
Collection<String> owners = parseOwners();
74-
if (owners.isEmpty()) {
77+
Collection<String> owners = exclusion ? Collections.emptyList() : parseOwners();
78+
if (!exclusion && owners.isEmpty()) {
7579
owners = sectionDefaultOwners;
7680
}
77-
return new Entry(matcher, owners);
81+
return new Entry(matcher, owners, exclusion);
7882

7983
} catch (Exception e) {
8084
log.warn("Skipping malformed CODEOWNERS entry: {}", new String(c), e);
@@ -159,11 +163,6 @@ private boolean isSectionHeader() {
159163
}
160164

161165
private Matcher parseMatcher() {
162-
if (c[offset] == '!') {
163-
offset++;
164-
return new NegatingMatcher(parseMatcher());
165-
}
166-
167166
boolean patternContainsSlashes = false;
168167
if (c[offset] == '/') {
169168
// opening slash gets special treatment

dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/codeowners/matcher/NegatingMatcher.java

Lines changed: 0 additions & 20 deletions
This file was deleted.

dd-java-agent/agent-ci-visibility/src/test/java/datadog/trace/civisibility/codeowners/CodeownersTest.java

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ static Stream<Arguments> testCodeownersMatchingArguments() {
5858
// --- GitLab sections: a path's owners combine the winning match from every section,
5959
// including the unnamed section (the leading "* @global-owner") ---
6060
arguments(GITLAB_SAMPLE, "random/file.txt", singletonList("@global-owner")),
61+
// exclusions also apply to the unnamed section
62+
arguments(GITLAB_SAMPLE, "unowned.txt", emptyList()),
6163
// section default owners are inherited and combined with the global owner
6264
arguments(GITLAB_SAMPLE, "docs/guide.md", asList("@global-owner", "@docs-team")),
6365
arguments(GITLAB_SAMPLE, "README.md", asList("@global-owner", "@docs-team")),
@@ -84,7 +86,20 @@ static Stream<Arguments> testCodeownersMatchingArguments() {
8486
// entries in both blocks resolves to the last matching entry's owners, not both
8587
arguments(GITLAB_SAMPLE, "service/util.go", asList("@global-owner", "@backend-team-a")),
8688
arguments(
87-
GITLAB_SAMPLE, "service/api/handler.go", asList("@global-owner", "@backend-team-b")));
89+
GITLAB_SAMPLE, "service/api/handler.go", asList("@global-owner", "@backend-team-b")),
90+
// exclusions suppress only their section and cannot be overridden by a later entry
91+
arguments(
92+
GITLAB_SAMPLE,
93+
"generated-assets/output.txt",
94+
asList("@global-owner", "@generated-files-team")),
95+
arguments(
96+
GITLAB_SAMPLE,
97+
"generated-assets/excluded/output.txt",
98+
asList("@global-owner", "@other-generated-files-team")),
99+
arguments(
100+
GITLAB_SAMPLE,
101+
"generated-assets/excluded/special.txt",
102+
asList("@global-owner", "@other-generated-files-team")));
88103
}
89104

90105
private static Codeowners parse(String resource) throws IOException {

dd-java-agent/agent-ci-visibility/src/test/java/datadog/trace/civisibility/codeowners/EntryBuilderTest.java

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
import static java.util.Collections.emptyList;
55
import static java.util.Collections.singletonList;
66
import static org.junit.jupiter.api.Assertions.assertEquals;
7+
import static org.junit.jupiter.api.Assertions.assertFalse;
78
import static org.junit.jupiter.api.Assertions.assertNull;
9+
import static org.junit.jupiter.api.Assertions.assertTrue;
810
import static org.junit.jupiter.params.provider.Arguments.arguments;
911

1012
import datadog.trace.civisibility.codeowners.matcher.CharacterMatcher;
@@ -23,15 +25,18 @@ class EntryBuilderTest {
2325
void testEntryMatch(String pattern, List<String> owners, String path, boolean expectedResult) {
2426
CharacterMatcher.Factory matcherFactory = new CharacterMatcher.Factory();
2527
Entry entry = new EntryBuilder(matcherFactory, pattern + " " + ownersToString(owners)).parse();
26-
Entry negatedEntry =
28+
Entry exclusionEntry =
2729
new EntryBuilder(matcherFactory, "!" + pattern + " " + ownersToString(owners)).parse();
2830

2931
boolean result = entry.getMatcher().consume(path.toCharArray(), 0) >= 0;
30-
boolean negatedResult = negatedEntry.getMatcher().consume(path.toCharArray(), 0) >= 0;
32+
boolean exclusionResult = exclusionEntry.getMatcher().consume(path.toCharArray(), 0) >= 0;
3133

3234
assertEquals(owners, entry.getOwners());
35+
assertFalse(entry.isExclusion());
36+
assertEquals(emptyList(), exclusionEntry.getOwners());
37+
assertTrue(exclusionEntry.isExclusion());
3338
assertEquals(expectedResult, result);
34-
assertEquals(!expectedResult, negatedResult);
39+
assertEquals(expectedResult, exclusionResult);
3540
}
3641

3742
static Stream<Arguments> testEntryMatchArguments() {
@@ -274,4 +279,15 @@ void testEntryInheritsSectionDefaultOwners() {
274279
Entry noOwners = new EntryBuilder(matcherFactory, "generated/").parse(emptyList());
275280
assertEquals(emptyList(), noOwners.getOwners());
276281
}
282+
283+
@Test
284+
void testExclusionDoesNotInheritSectionDefaultOwners() {
285+
CharacterMatcher.Factory matcherFactory = new CharacterMatcher.Factory();
286+
287+
Entry exclusion =
288+
new EntryBuilder(matcherFactory, "!generated/").parse(singletonList("@docs-team"));
289+
290+
assertTrue(exclusion.isExclusion());
291+
assertEquals(emptyList(), exclusion.getOwners());
292+
}
277293
}

dd-java-agent/agent-ci-visibility/src/test/resources/ci/codeowners/CODEOWNERS_gitlab_sample

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
# Entries before any section behave like a plain (GitHub-style) CODEOWNERS file. They form an
55
# unnamed section whose owners are combined with those of every other matching section.
66
* @global-owner
7+
# Exclusions can also apply to the unnamed section.
8+
!unowned.txt
79

810
# A section with default owners: every entry inherits them unless it declares its own.
911
[Documentation] @docs-team
@@ -44,3 +46,13 @@ service/
4446

4547
[BACKEND] @backend-team-b
4648
service/api/
49+
50+
# An exclusion suppresses its section even if a later entry matches the same path.
51+
[Generated Files] @generated-files-team
52+
generated-assets/
53+
!generated-assets/excluded/
54+
generated-assets/excluded/special.txt @special-owner
55+
56+
# Exclusions do not suppress ownership from other sections.
57+
[Other Generated Files] @other-generated-files-team
58+
generated-assets/excluded/

0 commit comments

Comments
 (0)