1010import pytest
1111
1212from 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
143192class 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
217369if __name__ == "__main__" :
0 commit comments