Skip to content

Commit b85908e

Browse files
feat: gitlab codeowners section owner parsing (#11999)
feat: gitlab codeowners section owner parsing Co-authored-by: daniel.mohedano <daniel.mohedano@datadoghq.com>
1 parent 21f783f commit b85908e

7 files changed

Lines changed: 405 additions & 68 deletions

File tree

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

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,28 @@
55
import java.io.IOException;
66
import java.io.Reader;
77
import java.util.ArrayDeque;
8+
import java.util.ArrayList;
89
import java.util.Collection;
10+
import java.util.Collections;
911
import java.util.Deque;
12+
import java.util.LinkedHashMap;
13+
import java.util.LinkedHashSet;
14+
import java.util.List;
15+
import java.util.Locale;
16+
import java.util.Map;
17+
import java.util.Set;
1018
import javax.annotation.Nonnull;
1119
import javax.annotation.Nullable;
1220

1321
public class CodeownersImpl implements Codeowners {
1422

15-
private final Iterable<Entry> entries;
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;
1627

17-
private CodeownersImpl(Iterable<Entry> entries) {
18-
this.entries = entries;
28+
private CodeownersImpl(Collection<Deque<Entry>> sections) {
29+
this.sections = sections;
1930
}
2031

2132
/**
@@ -25,12 +36,19 @@ private CodeownersImpl(Iterable<Entry> entries) {
2536
@Override
2637
public @Nullable Collection<String> getOwners(@Nonnull String path) {
2738
char[] pathCharacters = path.toCharArray();
28-
for (Entry entry : entries) {
29-
if (entry.getMatcher().consume(pathCharacters, 0) >= 0) {
30-
return entry.getOwners();
39+
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
48+
}
3149
}
3250
}
33-
return null;
51+
return owners != null ? new ArrayList<>(owners) : null;
3452
}
3553

3654
@Override
@@ -39,20 +57,37 @@ public boolean exist() {
3957
}
4058

4159
public static Codeowners parse(Reader r) throws IOException {
42-
Deque<Entry> entries = new ArrayDeque<>();
60+
Deque<Entry> defaultSection = new ArrayDeque<>();
61+
Map<String, Deque<Entry>> namedSections = new LinkedHashMap<>();
62+
Deque<Entry> currentSection = defaultSection;
4363

4464
CharacterMatcher.Factory characterMatcherFactory = new CharacterMatcher.Factory();
4565
BufferedReader br = new BufferedReader(r);
46-
String s;
47-
while ((s = br.readLine()) != null) {
48-
EntryBuilder entryBuilder = new EntryBuilder(characterMatcherFactory, s);
49-
Entry entry = entryBuilder.parse();
66+
String line;
67+
// Entries without owners inherit the current section's default owners
68+
Collection<String> sectionDefaultOwners = Collections.emptyList();
69+
while ((line = br.readLine()) != null) {
70+
EntryBuilder entryBuilder = new EntryBuilder(characterMatcherFactory, line);
71+
72+
SectionHeader header = entryBuilder.parseSectionHeader();
73+
if (header != null) {
74+
sectionDefaultOwners = header.getDefaultOwners();
75+
String key = header.getName().trim().toLowerCase(Locale.ROOT);
76+
currentSection = namedSections.computeIfAbsent(key, k -> new ArrayDeque<>());
77+
continue;
78+
}
79+
80+
Entry entry = entryBuilder.parse(sectionDefaultOwners);
5081
if (entry != null) {
51-
// place last parsed entry in the beginning of the list, since it has the highest priority
52-
entries.offerFirst(entry);
82+
// within a section, the last entry in the file has the highest priority
83+
currentSection.offerFirst(entry);
5384
}
5485
}
5586

56-
return new CodeownersImpl(entries);
87+
// Unnamed section is evaluated first, then named sections in order of first appearance
88+
List<Deque<Entry>> sections = new ArrayList<>(namedSections.size() + 1);
89+
sections.add(defaultSection);
90+
sections.addAll(namedSections.values());
91+
return new CodeownersImpl(sections);
5792
}
5893
}

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

Lines changed: 97 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
import datadog.trace.civisibility.codeowners.matcher.RangeMatcher;
1313
import java.util.ArrayDeque;
1414
import java.util.ArrayList;
15-
import java.util.Arrays;
1615
import java.util.Collection;
16+
import java.util.Collections;
1717
import java.util.Deque;
1818
import javax.annotation.Nullable;
1919
import org.slf4j.Logger;
@@ -46,29 +46,116 @@ public EntryBuilder(CharacterMatcher.Factory characterMatcherFactory, String s)
4646
}
4747

4848
public @Nullable Entry parse() {
49+
return parse(Collections.emptyList());
50+
}
51+
52+
/**
53+
* Parses the line as a CODEOWNERS entry: a path pattern optionally followed by one or more
54+
* owners.
55+
*
56+
* @param sectionDefaultOwners default owners declared on the enclosing GitLab section header,
57+
* used when the entry declares no owners of its own. Pass an empty collection for entries
58+
* that do not belong to a section (e.g. GitHub CODEOWNERS files).
59+
* @return the parsed entry, or {@code null} for blank lines, comments, section headers, or lines
60+
* that cannot be parsed.
61+
*/
62+
public @Nullable Entry parse(Collection<String> sectionDefaultOwners) {
4963
try {
50-
// skip trailing whitespace
51-
while (offset < c.length && Character.isWhitespace(c[offset])) {
52-
offset++;
53-
}
64+
skipWhitespace();
5465

5566
if (offset == c.length // empty line
5667
|| c[offset] == '#' // comment
57-
|| c[offset] == '[' // section header
58-
|| (c[offset] == '^'
59-
&& offset + 1 < c.length
60-
&& c[offset + 1] == '[')) { // GitLab optional section header (^[...])
68+
|| isSectionHeader()) { // GitLab section header (including optional '^[')
6169
return null;
6270
}
6371

6472
Matcher matcher = parseMatcher();
6573
Collection<String> owners = parseOwners();
74+
if (owners.isEmpty()) {
75+
owners = sectionDefaultOwners;
76+
}
6677
return new Entry(matcher, owners);
6778

6879
} catch (Exception e) {
69-
log.error("error parsing CODEOWNERS pattern: {}", Arrays.toString(c), e);
80+
log.warn("Skipping malformed CODEOWNERS entry: {}", new String(c), e);
81+
return null;
82+
}
83+
}
84+
85+
/**
86+
* If the line is a GitLab section header, consumes it and returns the parsed {@link
87+
* SectionHeader} (its name and default owners). Returns {@code null} when the line is not a
88+
* section header, leaving the cursor at the start of the pattern so the line can be parsed with
89+
* {@link #parse(Collection)}.
90+
*
91+
* <p>Supports the GitLab header syntax {@code ^[Section name][N] @owner}: an optional leading
92+
* {@code ^} (optional section), a bracketed name that may contain spaces, an optional {@code [N]}
93+
* required-approvals count, and trailing default owners.
94+
*
95+
* @see <a href="https://docs.gitlab.com/user/project/codeowners/reference/">GitLab Code Owners
96+
* reference</a>
97+
*/
98+
public @Nullable SectionHeader parseSectionHeader() {
99+
skipWhitespace();
100+
if (!isSectionHeader()) {
70101
return null;
71102
}
103+
if (c[offset] == '^') {
104+
offset++; // consume the optional-section marker
105+
}
106+
offset++; // consume the opening '['
107+
int nameStart = offset;
108+
while (offset < c.length && c[offset] != ']') {
109+
offset++; // consume the section name (which may contain spaces)
110+
}
111+
String name = new String(c, nameStart, offset - nameStart);
112+
if (offset < c.length) {
113+
offset++; // consume the closing ']'
114+
}
115+
// skip the optional [N] required-approvals count that may immediately follow the name
116+
if (offset < c.length && c[offset] == '[') {
117+
while (offset < c.length && c[offset] != ']') {
118+
offset++;
119+
}
120+
if (offset < c.length) {
121+
offset++; // consume the closing ']'
122+
}
123+
}
124+
return new SectionHeader(name, parseOwners());
125+
}
126+
127+
private void skipWhitespace() {
128+
while (offset < c.length && Character.isWhitespace(c[offset])) {
129+
offset++;
130+
}
131+
}
132+
133+
/**
134+
* Determines whether the line, starting at the current cursor (with leading whitespace already
135+
* skipped), is a section header. Does not move the cursor.
136+
*
137+
* <p>A section header begins with {@code [} — or the GitLab optional-section marker {@code ^[} —
138+
* and the section name's closing {@code ]} is followed by end-of-line, whitespace, a comment
139+
* ({@code #}), or the optional {@code [N]} approvals count. Requiring that trailing context
140+
* distinguishes a section header from a path pattern that merely begins with a character class
141+
* such as {@code [a-z]*.txt}.
142+
*/
143+
private boolean isSectionHeader() {
144+
int i = offset;
145+
if (i < c.length && c[i] == '^') {
146+
i++; // optional-section marker
147+
}
148+
if (i >= c.length || c[i] != '[') {
149+
return false;
150+
}
151+
while (i < c.length && c[i] != ']') {
152+
i++; // scan to the section name's closing ']'
153+
}
154+
if (i >= c.length) {
155+
return false; // unterminated brackets: not a well-formed section header
156+
}
157+
i++; // move past ']'
158+
return i >= c.length || Character.isWhitespace(c[i]) || c[i] == '[' || c[i] == '#';
72159
}
73160

74161
private Matcher parseMatcher() {
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package datadog.trace.civisibility.codeowners;
2+
3+
import java.util.Collection;
4+
5+
/**
6+
* A parsed GitLab CODEOWNERS section header, holding the section {@code name} (used to combine
7+
* blocks that repeat a name) and the {@code defaultOwners} it declares (inherited by entries in the
8+
* section that do not declare their own).
9+
*/
10+
public class SectionHeader {
11+
12+
private final String name;
13+
private final Collection<String> defaultOwners;
14+
15+
public SectionHeader(String name, Collection<String> defaultOwners) {
16+
this.name = name;
17+
this.defaultOwners = defaultOwners;
18+
}
19+
20+
public String getName() {
21+
return name;
22+
}
23+
24+
public Collection<String> getDefaultOwners() {
25+
return defaultOwners;
26+
}
27+
}

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

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

0 commit comments

Comments
 (0)