blob: e24ea7120782efb3da2abdde879513a0efeb0c3b [file]
#!/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()