Coverage for extra_codeowners/codeowners.py: 90%

154 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-13 09:46 +0000

1"""Strict parsing and matching for the supported GitHub CODEOWNERS subset. 

2 

3GitHub silently skips malformed CODEOWNERS lines. Extra CODEOWNERS instead 

4reports every malformed line and fails closed, because silently changing the 

5approval boundary would be unsafe. 

6""" 

7 

8from __future__ import annotations 

9 

10import re 

11from dataclasses import dataclass, field 

12 

13from extra_codeowners.models import normalize_owner, normalize_repository_path 

14 

15MAX_CODEOWNERS_BYTES = 3 * 1024 * 1024 

16 

17 

18@dataclass(frozen=True, slots=True) 

19class CodeownersIssue: 

20 """One parse error with a stable code suitable for Check Run output.""" 

21 

22 code: str 

23 message: str 

24 line_number: int | None = None 

25 

26 def render(self) -> str: 

27 """Return a concise human-readable representation.""" 

28 

29 prefix = f"line {self.line_number}: " if self.line_number is not None else "" 

30 return f"{prefix}{self.message}" 

31 

32 

33class CodeownersParseError(ValueError): 

34 """Raised when a CODEOWNERS document cannot be evaluated safely.""" 

35 

36 def __init__(self, issues: tuple[CodeownersIssue, ...]) -> None: 

37 self.issues = issues 

38 super().__init__("; ".join(issue.render() for issue in issues)) 

39 

40 

41def validate_pattern(pattern: str) -> str: 

42 """Validate a CODEOWNERS-style pattern supported by GitHub and this engine.""" 

43 

44 value = pattern.strip() 

45 if not value: 45 ↛ 46line 45 didn't jump to line 46 because the condition on line 45 was never true

46 raise ValueError("pattern must not be empty") 

47 if value.startswith("!"): 

48 raise ValueError("negated patterns ('!') are not supported by GitHub CODEOWNERS") 

49 if "[" in value or "]" in value: 

50 raise ValueError("character ranges ('[...]') are not supported by GitHub CODEOWNERS") 

51 if "\\" in value: 51 ↛ 52line 51 didn't jump to line 52 because the condition on line 51 was never true

52 raise ValueError("backslash escapes are not supported; use a POSIX path pattern") 

53 if value.startswith("#"): 53 ↛ 54line 53 didn't jump to line 54 because the condition on line 53 was never true

54 raise ValueError("a CODEOWNERS pattern cannot begin with '#'") 

55 

56 without_anchor = value.removeprefix("/") 

57 if not without_anchor: 57 ↛ 58line 57 didn't jump to line 58 because the condition on line 57 was never true

58 raise ValueError("root-only '/' is not a file pattern") 

59 segments = without_anchor.rstrip("/").split("/") 

60 if any(segment in {"", ".", ".."} for segment in segments): 60 ↛ 61line 60 didn't jump to line 61 because the condition on line 60 was never true

61 raise ValueError("pattern must not contain empty, '.' or '..' path segments") 

62 return value 

63 

64 

65def _pattern_regex(pattern: str) -> re.Pattern[str]: 

66 value = validate_pattern(pattern) 

67 anchored = value.startswith("/") or "/" in value.rstrip("/") 

68 value = value.removeprefix("/") 

69 final_segment = value.rstrip("/").rsplit("/", maxsplit=1)[-1] 

70 literal_directory_candidate = "*" not in final_segment and "?" not in final_segment 

71 if value.endswith("/"): 

72 # A trailing slash names a directory recursively. This follows GitHub's 

73 # `/build/logs/` and `apps/` CODEOWNERS examples; `docs/*`, by contrast, 

74 # remains limited to direct children. 

75 value += "**" 

76 

77 translated: list[str] = [] 

78 index = 0 

79 while index < len(value): 

80 character = value[index] 

81 if character == "*": 

82 if index + 1 < len(value) and value[index + 1] == "*": 

83 index += 2 

84 while index < len(value) and value[index] == "*": 84 ↛ 85line 84 didn't jump to line 85 because the condition on line 84 was never true

85 index += 1 

86 if index < len(value) and value[index] == "/": 

87 translated.append("(?:.*/)?") 

88 index += 1 

89 else: 

90 translated.append(".*") 

91 continue 

92 translated.append("[^/]*") 

93 elif character == "?": 93 ↛ 94line 93 didn't jump to line 94 because the condition on line 93 was never true

94 translated.append("[^/]") 

95 else: 

96 translated.append(re.escape(character)) 

97 index += 1 

98 

99 prefix = "^" if anchored else r"^(?:.*/)?" 

100 # GitHub applies a pattern ending in a literal directory name to every file 

101 # below that directory (`/apps/github` and `**/logs` in its examples). A 

102 # wildcard terminal such as `docs/*` does not gain that recursive suffix. 

103 suffix = r"(?:/.*)?$" if literal_directory_candidate else "$" 

104 return re.compile(prefix + "".join(translated) + suffix) 

105 

106 

107def compile_pattern(pattern: str) -> re.Pattern[str]: 

108 """Validate and precompile one CODEOWNERS-style pattern.""" 

109 return _pattern_regex(pattern) 

110 

111 

112def matches_pattern(pattern: str, path: str) -> bool: 

113 """Return whether a repository-relative path matches a CODEOWNERS pattern.""" 

114 

115 normalized_path = normalize_repository_path(path) 

116 return _pattern_regex(pattern).fullmatch(normalized_path) is not None 

117 

118 

119@dataclass(frozen=True, slots=True) 

120class CodeownersRule: 

121 """One valid CODEOWNERS rule.""" 

122 

123 pattern: str 

124 owners: tuple[str, ...] 

125 line_number: int 

126 _regex: re.Pattern[str] = field(init=False, repr=False, compare=False) 

127 

128 def __post_init__(self) -> None: 

129 object.__setattr__(self, "_regex", _pattern_regex(self.pattern)) 

130 

131 def matches(self, path: str) -> bool: 

132 """Return whether this rule applies to ``path``.""" 

133 

134 normalized_path = normalize_repository_path(path) 

135 return self._regex.fullmatch(normalized_path) is not None 

136 

137 def matches_normalized(self, path: str) -> bool: 

138 """Match a path already validated by the containing document.""" 

139 return self._regex.fullmatch(path) is not None 

140 

141 

142@dataclass(frozen=True, slots=True) 

143class CodeownersDocument: 

144 """A validated CODEOWNERS file preserving GitHub's last-match-wins order.""" 

145 

146 rules: tuple[CodeownersRule, ...] 

147 

148 def rule_for(self, path: str) -> CodeownersRule | None: 

149 """Return the last matching rule, as GitHub does.""" 

150 

151 normalized_path = normalize_repository_path(path) 

152 matched: CodeownersRule | None = None 

153 for rule in self.rules: 

154 if rule.matches_normalized(normalized_path): 

155 matched = rule 

156 return matched 

157 

158 def owners_for(self, path: str) -> tuple[str, ...]: 

159 """Return owners for the last matching rule, or an empty tuple.""" 

160 

161 rule = self.rule_for(path) 

162 return () if rule is None else rule.owners 

163 

164 

165def _split_line(line: str) -> list[str]: 

166 """Split a CODEOWNERS line while honoring escaped whitespace in a path.""" 

167 

168 tokens: list[str] = [] 

169 current: list[str] = [] 

170 index = 0 

171 while index < len(line): 

172 character = line[index] 

173 if character == "\\" and index + 1 < len(line) and line[index + 1].isspace(): 

174 current.append(line[index + 1]) 

175 index += 2 

176 continue 

177 if character.isspace(): 

178 if current: 178 ↛ 183line 178 didn't jump to line 183 because the condition on line 178 was always true

179 tokens.append("".join(current)) 

180 current = [] 

181 else: 

182 current.append(character) 

183 index += 1 

184 if current: 184 ↛ 186line 184 didn't jump to line 186 because the condition on line 184 was always true

185 tokens.append("".join(current)) 

186 return tokens 

187 

188 

189def parse_codeowners(content: str) -> CodeownersDocument: 

190 """Parse a CODEOWNERS document or raise all detected syntax errors.""" 

191 

192 size = len(content.encode("utf-8")) 

193 if size > MAX_CODEOWNERS_BYTES: 

194 issue = CodeownersIssue( 

195 code="codeowners_too_large", 

196 message=( 

197 f"CODEOWNERS is {size} bytes; GitHub ignores files larger than " 

198 f"{MAX_CODEOWNERS_BYTES} bytes" 

199 ), 

200 ) 

201 raise CodeownersParseError((issue,)) 

202 

203 rules: list[CodeownersRule] = [] 

204 issues: list[CodeownersIssue] = [] 

205 for line_number, raw_line in enumerate(content.splitlines(), start=1): 

206 stripped = raw_line.strip() 

207 if not stripped or stripped.startswith("#"): 

208 continue 

209 

210 tokens = _split_line(stripped) 

211 comment_index = next( 

212 (index for index, token in enumerate(tokens) if token.startswith("#")), 

213 len(tokens), 

214 ) 

215 tokens = tokens[:comment_index] 

216 if not tokens: 216 ↛ 217line 216 didn't jump to line 217 because the condition on line 216 was never true

217 continue 

218 pattern, *raw_owners = tokens 

219 try: 

220 pattern = validate_pattern(pattern) 

221 except ValueError as error: 

222 issues.append( 

223 CodeownersIssue( 

224 code="invalid_pattern", 

225 message=str(error), 

226 line_number=line_number, 

227 ) 

228 ) 

229 continue 

230 

231 owners: list[str] = [] 

232 owner_error = False 

233 for raw_owner in raw_owners: 

234 try: 

235 owners.append(normalize_owner(raw_owner)) 

236 except ValueError as error: 

237 code = ( 

238 "email_owner_unsupported" 

239 if "email CODEOWNER" in str(error) 

240 else "invalid_owner" 

241 ) 

242 issues.append( 

243 CodeownersIssue(code=code, message=str(error), line_number=line_number) 

244 ) 

245 owner_error = True 

246 if owner_error: 

247 continue 

248 

249 # An empty owner list is valid and clears ownership established by an 

250 # earlier matching rule. Repeated owners do not change semantics, so 

251 # preserve order while removing duplicates. 

252 unique_owners = tuple(dict.fromkeys(owners)) 

253 rules.append(CodeownersRule(pattern=pattern, owners=unique_owners, line_number=line_number)) 

254 

255 if issues: 

256 raise CodeownersParseError(tuple(issues)) 

257 return CodeownersDocument(tuple(rules))