Skip to content

Commit 2798bdb

Browse files
Fix edge cases, expand test coverage to 62
Fix conflict regex to match plurals, sort run IDs numerically. Add tests for URL variants, word boundaries, timezone offsets, parser validation.
1 parent da7c77e commit 2798bdb

2 files changed

Lines changed: 165 additions & 13 deletions

File tree

pr-status/scripts/pr_status.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ def extract_run_ids_from_checks(checks: list[dict]) -> list[str]:
133133
m = re.search(r"/actions/runs/(\d+)", link)
134134
if m:
135135
run_ids.add(m.group(1))
136-
return sorted(run_ids)
136+
return sorted(run_ids, key=int)
137137

138138

139139
def merge_state_message(state: str) -> str:
@@ -154,7 +154,7 @@ def find_error_keywords(body: str) -> list[str]:
154154
("error", r"(?i)\berror\b"),
155155
("failure", r"(?i)\bfail(?:ed|ure|ing)?\b"),
156156
("warning", r"(?i)\bwarn(?:ing)?\b"),
157-
("conflict", r"(?i)\bconflict\b"),
157+
("conflict", r"(?i)\bconflicts?\b"),
158158
("deprecated", r"(?i)\bdeprecated?\b"),
159159
("vulnerability", r"(?i)\bvulnerabilit(?:y|ies)\b"),
160160
("breaking", r"(?i)\bbreaking\b"),

pr-status/scripts/test_pr_status.py

Lines changed: 163 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import pytest
1111

1212
from pr_status import (
13+
build_parser,
1314
classify_bot,
1415
extract_run_ids_from_checks,
1516
find_error_keywords,
@@ -75,6 +76,39 @@ def test_invalid_exits(self):
7576
parse_pr_identifier("not-a-pr")
7677
assert exc_info.value.code == 2
7778

79+
def test_url_with_files_suffix(self):
80+
pr, repo = parse_pr_identifier(
81+
"https://github.com/o/r/pull/123/files",
82+
)
83+
assert pr == "123"
84+
assert repo == "o/r"
85+
86+
def test_url_with_query_string(self):
87+
pr, repo = parse_pr_identifier(
88+
"https://github.com/o/r/pull/123?diff=unified",
89+
)
90+
assert pr == "123"
91+
assert repo == "o/r"
92+
93+
def test_url_with_trailing_slash(self):
94+
pr, repo = parse_pr_identifier(
95+
"https://github.com/o/r/pull/123/",
96+
)
97+
assert pr == "123"
98+
assert repo == "o/r"
99+
100+
def test_issue_url_exits(self):
101+
"""Issue URLs are not PR URLs — should reject."""
102+
with pytest.raises(SystemExit) as exc_info:
103+
parse_pr_identifier("https://github.com/o/r/issues/123")
104+
assert exc_info.value.code == 2
105+
106+
def test_whitespace_padded_number_exits(self):
107+
"""Leading/trailing whitespace on a number is not a valid identifier."""
108+
with pytest.raises(SystemExit) as exc_info:
109+
parse_pr_identifier(" 42 ")
110+
assert exc_info.value.code == 2
111+
78112

79113
# ---------------------------------------------------------------------------
80114
# classify_bot
@@ -135,23 +169,55 @@ def test_non_actions_link(self):
135169
def test_empty_list(self):
136170
assert extract_run_ids_from_checks([]) == []
137171

172+
def test_numeric_sort_not_lexicographic(self):
173+
"""Run IDs sort numerically: 2 before 10, not "10" before "2"."""
174+
checks = [
175+
{"bucket": "fail", "link": "https://github.com/o/r/actions/runs/10"},
176+
{"bucket": "fail", "link": "https://github.com/o/r/actions/runs/2"},
177+
]
178+
assert extract_run_ids_from_checks(checks) == ["2", "10"]
179+
180+
def test_ignores_pending_bucket_with_actions_link(self):
181+
"""Only 'fail' bucket checks are extracted, even if link is valid."""
182+
checks = [
183+
{"bucket": "pending", "link": "https://github.com/o/r/actions/runs/999"},
184+
]
185+
assert extract_run_ids_from_checks(checks) == []
186+
138187

139188
# ---------------------------------------------------------------------------
140189
# merge_state_message
141190
# ---------------------------------------------------------------------------
142191

143192
class TestMergeStateMessage:
144-
def test_known_states(self):
145-
assert "conflict" in merge_state_message("DIRTY").lower()
146-
assert "behind" in merge_state_message("BEHIND").lower()
147-
assert "blocked" in merge_state_message("BLOCKED").lower()
148-
assert "failing" in merge_state_message("UNSTABLE").lower()
149-
assert "computing" in merge_state_message("UNKNOWN").lower()
150-
151-
def test_unknown_state_passthrough(self):
193+
def test_dirty(self):
194+
assert merge_state_message("DIRTY") == "Branch has merge conflicts that need resolution"
195+
196+
def test_behind(self):
197+
assert merge_state_message("BEHIND") == "Branch is behind base and needs to be updated"
198+
199+
def test_blocked(self):
200+
assert merge_state_message("BLOCKED") == "Merge is blocked by branch protection rules"
201+
202+
def test_unstable(self):
203+
assert merge_state_message("UNSTABLE") == "Some required checks are failing"
204+
205+
def test_unknown(self):
206+
assert merge_state_message("UNKNOWN") == "GitHub is still computing merge status — try again shortly"
207+
208+
def test_unmapped_state_includes_state_name(self):
152209
msg = merge_state_message("SOMETHING_NEW")
153210
assert "SOMETHING_NEW" in msg
154211

212+
def test_clean_not_in_map(self):
213+
"""CLEAN is not an error state, so it falls through to default."""
214+
msg = merge_state_message("CLEAN")
215+
assert "CLEAN" in msg
216+
217+
def test_has_hooks_not_in_map(self):
218+
msg = merge_state_message("HAS_HOOKS")
219+
assert "HAS_HOOKS" in msg
220+
155221

156222
# ---------------------------------------------------------------------------
157223
# find_error_keywords
@@ -170,8 +236,10 @@ def test_finds_vulnerability(self):
170236
assert "vulnerability" in kw
171237

172238
def test_finds_conflict(self):
173-
kw = find_error_keywords("Merge conflict detected")
174-
assert any("conflict" in k for k in kw)
239+
assert "conflict" in find_error_keywords("Merge conflict detected")
240+
241+
def test_finds_conflicts_plural(self):
242+
assert "conflict" in find_error_keywords("Merge conflicts detected")
175243

176244
def test_no_keywords(self):
177245
assert find_error_keywords("All good, no problems here") == []
@@ -180,7 +248,29 @@ def test_empty_body(self):
180248
assert find_error_keywords("") == []
181249

182250
def test_case_insensitive(self):
183-
assert len(find_error_keywords("WARNING: something")) > 0
251+
assert "warning" in find_error_keywords("WARNING: something")
252+
253+
def test_multiple_keywords_in_defined_order(self):
254+
"""When multiple keywords match, they appear in definition order."""
255+
kw = find_error_keywords("Error: build failed with conflict and warning")
256+
assert kw == ["error", "failure", "warning", "conflict"]
257+
258+
def test_no_duplicates_on_repeated_matches(self):
259+
"""Each keyword appears at most once even if the word appears multiple times."""
260+
kw = find_error_keywords("error error error")
261+
assert kw.count("error") == 1
262+
263+
def test_word_boundary_no_false_positive_terror(self):
264+
"""'terror' should not trigger 'error' due to word boundary."""
265+
assert find_error_keywords("reign of terror") == []
266+
267+
def test_word_boundary_no_false_positive_warning_substring(self):
268+
"""'forewarning' should not trigger 'warning' due to word boundary."""
269+
assert find_error_keywords("a forewarning sign") == []
270+
271+
def test_deprecated_singular_and_trailing_d(self):
272+
assert "deprecated" in find_error_keywords("This API is deprecated")
273+
assert "deprecated" in find_error_keywords("deprecate this function")
184274

185275

186276
# ---------------------------------------------------------------------------
@@ -212,6 +302,68 @@ def test_invalid_dates(self):
212302

213303
def test_none_values(self):
214304
assert is_stale_approval(None, "2025-01-02T10:00:00Z") is False
305+
assert is_stale_approval("2025-01-01T10:00:00Z", None) is False
306+
assert is_stale_approval(None, None) is False
307+
308+
def test_timezone_offsets_equivalent_not_stale(self):
309+
"""Same instant expressed in different timezones should not be stale."""
310+
assert is_stale_approval(
311+
"2025-01-02T12:00:00+02:00",
312+
"2025-01-02T10:00:00Z",
313+
) is False
314+
315+
def test_timezone_offsets_stale(self):
316+
"""Earlier instant in positive offset is still stale."""
317+
assert is_stale_approval(
318+
"2025-01-01T10:00:00+02:00",
319+
"2025-01-02T10:00:00Z",
320+
) is True
321+
322+
def test_fractional_seconds(self):
323+
assert is_stale_approval(
324+
"2025-01-01T10:00:00.123Z",
325+
"2025-01-02T10:00:00.456Z",
326+
) is True
327+
328+
329+
# ---------------------------------------------------------------------------
330+
# build_parser
331+
# ---------------------------------------------------------------------------
332+
333+
class TestBuildParser:
334+
def test_requires_subcommand(self):
335+
parser = build_parser()
336+
with pytest.raises(SystemExit) as exc_info:
337+
parser.parse_args([])
338+
assert exc_info.value.code == 2
339+
340+
def test_analyze_logs_requires_run_id(self):
341+
parser = build_parser()
342+
with pytest.raises(SystemExit) as exc_info:
343+
parser.parse_args(["analyze-logs"])
344+
assert exc_info.value.code == 2
345+
346+
def test_diagnose_accepts_optional_pr(self):
347+
parser = build_parser()
348+
args = parser.parse_args(["diagnose"])
349+
assert args.command == "diagnose"
350+
assert args.pr is None
351+
352+
def test_diagnose_accepts_pr_number(self):
353+
parser = build_parser()
354+
args = parser.parse_args(["diagnose", "123"])
355+
assert args.pr == "123"
356+
357+
def test_global_repo_flag(self):
358+
parser = build_parser()
359+
args = parser.parse_args(["--repo", "org/repo", "status", "42"])
360+
assert args.repo == "org/repo"
361+
assert args.command == "status"
362+
363+
def test_global_format_flag(self):
364+
parser = build_parser()
365+
args = parser.parse_args(["--format", "json", "checks"])
366+
assert args.format == "json"
215367

216368

217369
if __name__ == "__main__":

0 commit comments

Comments
 (0)