| #!/usr/bin/env fuchsia-vendored-python |
| # Copyright 2026 The Fuchsia Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| import inspect |
| import io |
| import json |
| import os |
| import random |
| import shutil |
| import subprocess |
| import sys |
| import unittest |
| import urllib.error |
| import urllib.parse |
| import urllib.request |
| from typing import Any |
| from unittest import mock |
| |
| import min_owners |
| |
| |
| class PluralTest(unittest.TestCase): |
| def test_plural(self) -> None: |
| self.assertEqual(min_owners.plural(0, "file"), "0 files") |
| self.assertEqual(min_owners.plural(1, "file"), "1 file") |
| self.assertEqual(min_owners.plural(2, "file"), "2 files") |
| |
| |
| class ParseNameStatusTest(unittest.TestCase): |
| def test_keeps_both_rename_paths(self) -> None: |
| out = "M\0src/a.cc\0R100\0src/old.cc\0src/new.cc\0D\0src/tab\tname.cc\0" |
| self.assertEqual( |
| min_owners.parse_name_status(out), |
| ["src/a.cc", "src/new.cc", "src/old.cc", "src/tab\tname.cc"], |
| ) |
| |
| def test_empty(self) -> None: |
| self.assertEqual(min_owners.parse_name_status(""), []) |
| |
| |
| class DiffArgsTest(unittest.TestCase): |
| def test_single_commit_uses_first_parent(self) -> None: |
| self.assertEqual(min_owners.diff_args("abc123"), ["abc123^1", "abc123"]) |
| |
| def test_ranges_use_merge_base(self) -> None: |
| cases = { |
| "main..HEAD": ["main...HEAD"], |
| "main...HEAD": ["main...HEAD"], |
| "main..": ["main...HEAD"], |
| } |
| for rev, want in cases.items(): |
| with self.subTest(rev=rev): |
| self.assertEqual(min_owners.diff_args(rev), want) |
| |
| |
| class FetchOwnersTest(unittest.TestCase): |
| def test_owners(self) -> None: |
| requested = [] |
| |
| def get(path: str) -> Any: |
| requested.append(path) |
| return { |
| "code_owners": [ |
| { |
| "account": { |
| "_account_id": 1, |
| "email": "alice@example.com", |
| }, |
| "scorings": {"DISTANCE": 1}, |
| }, |
| {"account": {"email": "bob@example.com"}}, |
| {"account": {"_account_id": 123}}, |
| ] |
| } |
| |
| account_ids: dict[str, int] = {} |
| self.assertEqual( |
| min_owners.fetch_owners( |
| get, "vendor/google", "main", "a/b c.cc", account_ids |
| ), |
| {"alice@example.com": 1, "bob@example.com": 0}, |
| ) |
| self.assertEqual(account_ids, {"alice@example.com": 1}) |
| self.assertEqual( |
| requested, |
| [ |
| "/projects/vendor%2Fgoogle/branches/main/code_owners/" |
| "a%2Fb%20c.cc?o=DETAILS&limit=1000" |
| ], |
| ) |
| |
| def test_owned_by_all_users(self) -> None: |
| self.assertIsNone( |
| min_owners.fetch_owners( |
| lambda _: {"owned_by_all_users": True}, "p", "main", "f" |
| ) |
| ) |
| |
| |
| class ParseRemoteUrlTest(unittest.TestCase): |
| def test_urls(self) -> None: |
| cases = { |
| "sso://fuchsia/fuchsia": ( |
| "https://fuchsia-review.googlesource.com", |
| "fuchsia", |
| ), |
| "https://fuchsia.googlesource.com/fuchsia": ( |
| "https://fuchsia-review.googlesource.com", |
| "fuchsia", |
| ), |
| "https://fuchsia.googlesource.com/a/infra/recipes.git": ( |
| "https://fuchsia-review.googlesource.com", |
| "infra/recipes", |
| ), |
| "sso://turquoise-internal/vendor/google": ( |
| "https://turquoise-internal-review.googlesource.com", |
| "vendor/google", |
| ), |
| "https://turquoise-internal.git.corp.google.com/vendor/google/": ( |
| "https://turquoise-internal-review.googlesource.com", |
| "vendor/google", |
| ), |
| } |
| for url, want in cases.items(): |
| with self.subTest(url=url): |
| self.assertEqual(min_owners.parse_remote_url(url), want) |
| |
| def test_unknown_url(self) -> None: |
| with self.assertRaises(ValueError): |
| min_owners.parse_remote_url("git@github.com:foo/bar.git") |
| |
| |
| class GerritGetterTest(unittest.TestCase): |
| def test_unauthorized_probe_uses_gob_curl(self) -> None: |
| def urlopen(url: str, timeout: int) -> Any: |
| raise urllib.error.HTTPError(url, 401, "Unauthorized", {}, None) # type: ignore[arg-type] |
| |
| run = mock.Mock( |
| return_value=subprocess.CompletedProcess( |
| [], 0, stdout=min_owners.XSSI_PREFIX + "{}", stderr="" |
| ) |
| ) |
| with mock.patch.object( |
| urllib.request, "urlopen", urlopen |
| ), mock.patch.object( |
| shutil, "which", return_value="/bin/gob-curl" |
| ), mock.patch.object( |
| subprocess, "run", run |
| ): |
| get = min_owners.gerrit_getter("https://host") |
| self.assertEqual(get("/foo"), {}) |
| self.assertIn("https://host/a/foo", run.call_args.args[0]) |
| |
| def test_other_probe_errors_propagate(self) -> None: |
| def urlopen(url: str, timeout: int) -> Any: |
| raise urllib.error.HTTPError(url, 500, "Oops", {}, None) # type: ignore[arg-type] |
| |
| with mock.patch.object(urllib.request, "urlopen", urlopen): |
| with self.assertRaises(urllib.error.HTTPError): |
| min_owners.gerrit_getter("https://host") |
| |
| |
| class OwnersToCoverTest(unittest.TestCase): |
| def test_filters(self) -> None: |
| file_owners = { |
| "anyone.md": None, |
| "authors.cc": {"author@example.com": 1, "bob@example.com": 1}, |
| "root_only.cc": {"root@example.com": 1}, |
| "mixed.cc": {"alice@example.com": 1, "root@example.com": 2}, |
| } |
| self.assertEqual( |
| min_owners.owners_to_cover( |
| file_owners, |
| root_owners={"root@example.com"}, |
| author="author@example.com", |
| include_root=False, |
| ), |
| { |
| # Root owners are a fallback when nobody else owns the file. |
| "root_only.cc": {"root@example.com": 1}, |
| "mixed.cc": {"alice@example.com": 1}, |
| }, |
| ) |
| |
| def test_include_root(self) -> None: |
| file_owners = { |
| "mixed.cc": {"alice@example.com": 1, "root@example.com": 2} |
| } |
| self.assertEqual( |
| min_owners.owners_to_cover( |
| file_owners, |
| root_owners={"root@example.com"}, |
| author="author@example.com", |
| include_root=True, |
| ), |
| file_owners, |
| ) |
| |
| def test_root_owner_author_needs_no_reviewers(self) -> None: |
| # Code-owners lists root owners for every file (unless an OWNERS file |
| # sets noparent), so a root owner's implicit approval covers it all. |
| file_owners = { |
| "a.cc": {"alice@example.com": 1, "root@example.com": 2}, |
| "b.cc": {"bob@example.com": 1, "root@example.com": 3}, |
| } |
| self.assertEqual( |
| min_owners.owners_to_cover( |
| file_owners, |
| root_owners={"root@example.com"}, |
| author="root@example.com", |
| include_root=False, |
| ), |
| {}, |
| ) |
| |
| def test_skips_unavailable_owners(self) -> None: |
| file_owners = { |
| "some_out.cc": {"alice@example.com": 1, "bob@example.com": 1}, |
| # An available root owner beats an unavailable regular owner. |
| "use_root.cc": {"alice@example.com": 1, "root@example.com": 2}, |
| # If everyone's out, someone still has to review. |
| "all_out.cc": {"alice@example.com": 1}, |
| } |
| self.assertEqual( |
| min_owners.owners_to_cover( |
| file_owners, |
| root_owners={"root@example.com"}, |
| author="author@example.com", |
| include_root=False, |
| unavailable={"alice@example.com"}, |
| ), |
| { |
| "some_out.cc": {"bob@example.com": 1}, |
| "use_root.cc": {"root@example.com": 2}, |
| "all_out.cc": {"alice@example.com": 1}, |
| }, |
| ) |
| |
| |
| class GreedyCoverTest(unittest.TestCase): |
| def test_prefers_owner_covering_most_files(self) -> None: |
| picks, unowned = min_owners.greedy_cover( |
| { |
| "a/1": {"alice@example.com": 1, "bob@example.com": 1}, |
| "a/2": {"alice@example.com": 1}, |
| "b/1": {"alice@example.com": 2, "carol@example.com": 1}, |
| "c/1": {"dave@example.com": 1}, |
| } |
| ) |
| self.assertEqual( |
| picks, |
| [ |
| ("alice@example.com", ["a/1", "a/2", "b/1"]), |
| ("dave@example.com", ["c/1"]), |
| ], |
| ) |
| self.assertEqual(unowned, []) |
| |
| def test_ties_go_to_closer_owner(self) -> None: |
| picks, _ = min_owners.greedy_cover( |
| { |
| "a/1": {"alice@example.com": 2, "bob@example.com": 1}, |
| "a/2": {"alice@example.com": 2, "bob@example.com": 1}, |
| } |
| ) |
| self.assertEqual(picks, [("bob@example.com", ["a/1", "a/2"])]) |
| |
| def test_unowned_files(self) -> None: |
| picks, unowned = min_owners.greedy_cover( |
| {"a": {"alice@example.com": 1}, "orphan": {}} |
| ) |
| self.assertEqual(picks, [("alice@example.com", ["a"])]) |
| self.assertEqual(unowned, ["orphan"]) |
| |
| |
| class AllMinCoversTest(unittest.TestCase): |
| def test_groups_interchangeable_owners(self) -> None: |
| covers = min_owners.all_min_covers( |
| { |
| "a/1": {"alice@example.com": 1, "bob@example.com": 2}, |
| "b/1": {"carol@example.com": 1, "dave@example.com": 1}, |
| } |
| ) |
| self.assertEqual( |
| covers, |
| [ |
| [ |
| (["alice@example.com", "bob@example.com"], ["a/1"]), |
| (["carol@example.com", "dave@example.com"], ["b/1"]), |
| ] |
| ], |
| ) |
| |
| def test_finds_covers_greedy_misses(self) -> None: |
| # Greedy grabs alice first (3 files) and then needs two more owners. |
| # bob + carol cover everything with two. |
| file_owners = { |
| "1": {"alice@example.com": 1, "bob@example.com": 1}, |
| "2": {"alice@example.com": 1, "bob@example.com": 1}, |
| "3": {"alice@example.com": 1, "carol@example.com": 1}, |
| "4": {"bob@example.com": 1}, |
| "5": {"carol@example.com": 1}, |
| } |
| picks, _ = min_owners.greedy_cover(file_owners) |
| self.assertEqual(len(picks), 3) |
| self.assertEqual( |
| min_owners.all_min_covers(file_owners), |
| [ |
| [ |
| (["bob@example.com"], ["1", "2", "4"]), |
| (["carol@example.com"], ["3", "5"]), |
| ] |
| ], |
| ) |
| |
| def test_multiple_options_closest_first(self) -> None: |
| covers = min_owners.all_min_covers( |
| { |
| "1": {"far@example.com": 5, "x@example.com": 1}, |
| "2": {"far@example.com": 5, "y@example.com": 1}, |
| "3": {"near@example.com": 1, "x@example.com": 1}, |
| "4": {"near@example.com": 1, "y@example.com": 1}, |
| } |
| ) |
| self.assertEqual( |
| covers, |
| [ |
| # Total distance 4. |
| [ |
| (["x@example.com"], ["1", "3"]), |
| (["y@example.com"], ["2", "4"]), |
| ], |
| # Total distance 12. |
| [ |
| (["far@example.com"], ["1", "2"]), |
| (["near@example.com"], ["3", "4"]), |
| ], |
| ], |
| ) |
| |
| def test_unowned_and_empty(self) -> None: |
| self.assertEqual( |
| min_owners.all_min_covers( |
| {"a": {"alice@example.com": 1}, "orphan": {}} |
| ), |
| [[(["alice@example.com"], ["a"])]], |
| ) |
| self.assertEqual(min_owners.all_min_covers({}), [[]]) |
| |
| def test_gives_up_past_budget(self) -> None: |
| file_owners = { |
| str(i): {f"o{i}@example.com": 1, f"o{i + 1}@example.com": 1} |
| for i in range(20) |
| } |
| self.assertIsNone(min_owners.all_min_covers(file_owners, max_steps=10)) |
| self.assertIsNotNone(min_owners.all_min_covers(file_owners)) |
| |
| def test_gives_up_on_deep_recursion(self) -> None: |
| # No owner covers more than one file, so the search goes one level |
| # deeper per file. |
| file_owners = { |
| str(i): {f"o{i}@example.com": 1, f"p{i}@example.com": 1} |
| for i in range(100) |
| } |
| old = sys.getrecursionlimit() |
| # Leave room for the test runner's frames but not for 100 levels. |
| sys.setrecursionlimit(len(inspect.stack(0)) + 50) |
| try: |
| self.assertIsNone(min_owners.all_min_covers(file_owners)) |
| finally: |
| sys.setrecursionlimit(old) |
| |
| |
| class PickReviewersTest(unittest.TestCase): |
| def test_closer_owner_always_wins(self) -> None: |
| file_owners = { |
| "1": {"near@example.com": 1, "far@example.com": 2}, |
| } |
| covers = min_owners.all_min_covers(file_owners) |
| assert covers is not None |
| distance = min_owners.owner_distances(file_owners) |
| for seed in range(20): |
| self.assertEqual( |
| min_owners.pick_reviewers( |
| covers, distance, random.Random(seed) |
| ), |
| [("near@example.com", ["1"])], |
| ) |
| |
| def test_prefers_closer_cover(self) -> None: |
| file_owners = { |
| "1": {"far@example.com": 5, "x@example.com": 1}, |
| "2": {"far@example.com": 5, "y@example.com": 1}, |
| "3": {"near@example.com": 1, "x@example.com": 1}, |
| "4": {"near@example.com": 1, "y@example.com": 1}, |
| } |
| covers = min_owners.all_min_covers(file_owners) |
| assert covers is not None |
| distance = min_owners.owner_distances(file_owners) |
| for seed in range(20): |
| picks = min_owners.pick_reviewers( |
| covers, distance, random.Random(seed) |
| ) |
| self.assertEqual( |
| [o for o, _ in picks], ["x@example.com", "y@example.com"] |
| ) |
| |
| def test_ties_are_random_but_seeded(self) -> None: |
| tied = [f"o{i}@example.com" for i in range(5)] |
| file_owners = {"1": {o: 1 for o in tied}} |
| covers = min_owners.all_min_covers(file_owners) |
| assert covers is not None |
| distance = min_owners.owner_distances(file_owners) |
| |
| def pick(seed: str) -> str: |
| picks = min_owners.pick_reviewers( |
| covers, distance, random.Random(seed) |
| ) |
| return picks[0][0] |
| |
| self.assertEqual(pick("a"), pick("a")) |
| self.assertEqual( |
| {pick(str(seed)) for seed in range(50)}, |
| set(tied), |
| ) |
| |
| def test_picks_among_equally_close_options(self) -> None: |
| file_owners = { |
| "1": {"a@example.com": 1, "x@example.com": 1}, |
| "2": {"a@example.com": 1, "y@example.com": 1}, |
| "3": {"b@example.com": 1, "x@example.com": 1}, |
| "4": {"b@example.com": 1, "y@example.com": 1}, |
| } |
| covers = min_owners.all_min_covers(file_owners) |
| assert covers is not None |
| self.assertEqual(len(covers), 2) |
| distance = min_owners.owner_distances(file_owners) |
| picked = { |
| tuple( |
| o |
| for o, _ in min_owners.pick_reviewers( |
| covers, distance, random.Random(seed) |
| ) |
| ) |
| for seed in range(50) |
| } |
| self.assertEqual( |
| picked, |
| { |
| ("a@example.com", "b@example.com"), |
| ("x@example.com", "y@example.com"), |
| }, |
| ) |
| |
| |
| class OutOfOfficeTest(unittest.TestCase): |
| def test_only_out_of_office_counts(self) -> None: |
| body = ")]}'\n" + json.dumps( |
| [ |
| {"account_id": 1, "status": "OUT_OF_OFFICE"}, |
| {"account_id": 2, "status": "OUTSIDE_WORKING_HOURS"}, |
| {"account_id": 3, "status": "NORMAL_AVAILABILITY"}, |
| ] |
| ) |
| requests: list[urllib.request.Request] = [] |
| |
| def urlopen(req: urllib.request.Request, timeout: int) -> Any: |
| requests.append(req) |
| return io.BytesIO(body.encode()) |
| |
| with mock.patch.object(urllib.request, "urlopen", urlopen): |
| ooo = min_owners.out_of_office( |
| "https://host", |
| {"a@x": 1, "b@x": 2, "c@x": 3}, |
| "token", |
| ) |
| self.assertEqual(ooo, {"a@x"}) |
| self.assertEqual(len(requests), 1) |
| self.assertEqual( |
| requests[0].full_url, |
| "https://host/a/plugins/availability/statuses/?id=1&id=2&id=3", |
| ) |
| self.assertEqual( |
| requests[0].get_header("Authorization"), "Bearer token" |
| ) |
| |
| def test_ignores_people_back_soon(self) -> None: |
| now = 1_000_000 |
| body = ")]}'\n" + json.dumps( |
| [ |
| { |
| "account_id": 1, |
| "status": "OUT_OF_OFFICE", |
| "return_time": {"seconds": now + 60 * 60}, |
| }, |
| { |
| "account_id": 2, |
| "status": "OUT_OF_OFFICE", |
| "return_time": {"seconds": now + 24 * 60 * 60}, |
| }, |
| ] |
| ) |
| |
| def urlopen(req: urllib.request.Request, timeout: int) -> Any: |
| return io.BytesIO(body.encode()) |
| |
| with mock.patch.object(urllib.request, "urlopen", urlopen): |
| ooo = min_owners.out_of_office( |
| "https://host", {"soon@x": 1, "later@x": 2}, "token", now=now |
| ) |
| self.assertEqual(ooo, {"later@x"}) |
| |
| def test_batches_requests(self) -> None: |
| ids = {f"o{i}@x": i for i in range(250)} |
| requests: list[str] = [] |
| |
| def urlopen(req: urllib.request.Request, timeout: int) -> Any: |
| requests.append(req.full_url) |
| query = urllib.parse.urlparse(req.full_url).query |
| statuses = [ |
| {"account_id": int(i), "status": "OUT_OF_OFFICE"} |
| for _, i in urllib.parse.parse_qsl(query) |
| ] |
| return io.BytesIO((")]}'\n" + json.dumps(statuses)).encode()) |
| |
| with mock.patch.object(urllib.request, "urlopen", urlopen): |
| ooo = min_owners.out_of_office("https://host", ids, "token") |
| self.assertEqual(len(requests), 3) |
| self.assertEqual(ooo, set(ids)) |
| |
| def test_failures_become_runtime_errors(self) -> None: |
| def timeout(req: urllib.request.Request, timeout: int) -> Any: |
| raise TimeoutError("read timed out") |
| |
| def bad_shape(req: urllib.request.Request, timeout: int) -> Any: |
| return io.BytesIO(b')]}\'\n[{"account_id": 99}]') |
| |
| for urlopen in (timeout, bad_shape): |
| with self.subTest(urlopen=urlopen.__name__): |
| with mock.patch.object(urllib.request, "urlopen", urlopen): |
| with self.assertRaises(RuntimeError): |
| min_owners.out_of_office("https://h", {"a@x": 1}, "t") |
| |
| |
| class LuciAuthTokenTest(unittest.TestCase): |
| def test_missing_binary(self) -> None: |
| with ( |
| mock.patch.object(os.path, "exists", return_value=False), |
| mock.patch.object(shutil, "which", return_value=None), |
| ): |
| with self.assertRaisesRegex(RuntimeError, "on PATH"): |
| min_owners.luci_auth_token() |
| |
| def test_not_logged_in(self) -> None: |
| proc = subprocess.CompletedProcess( |
| [], 1, stdout="", stderr="Not logged in.\n" |
| ) |
| with mock.patch.object(shutil, "which", return_value="/luci-auth"): |
| with mock.patch.object(subprocess, "run", return_value=proc): |
| with self.assertRaisesRegex(RuntimeError, "Not logged in"): |
| min_owners.luci_auth_token() |
| |
| |
| if __name__ == "__main__": |
| unittest.main() |