diff --git a/scripts/cla_sweeper.py b/scripts/cla_sweeper.py index 840cf03..da9e3ca 100644 --- a/scripts/cla_sweeper.py +++ b/scripts/cla_sweeper.py @@ -124,6 +124,15 @@ def main(): # policy_selector.fetch_shared_config's docstring for why. shared_config = policy_selector.fetch_shared_config(api_root, gh_token) + # Without the licence catalogues every CLA-vs-DCO decision in this sweep + # would be made from empty data, mislabelling repos across the whole org. + # Doing nothing is strictly better: statuses stay as they are and the next + # sweep retries. (A missing allowlist deliberately does NOT abort — see + # fetch_shared_config, where an empty allowlist is a valid configuration.) + if not shared_config.get("complete", True): + policy_selector.error_log("❌ Aborting sweep: shared config could not be loaded.") + return + for repo in repos: full_name = repo.get("full_name") time.sleep(2) # Rate Limit Safety diff --git a/scripts/policy_selector.py b/scripts/policy_selector.py index 42ad28d..ae28c7f 100644 --- a/scripts/policy_selector.py +++ b/scripts/policy_selector.py @@ -94,6 +94,12 @@ def ensure_valid_token(): def debug_log(message): print(f"::warning::{message}") +def error_log(message): + """For genuine failures. debug_log emits ::warning:: for everything — + including success messages — so a real problem is indistinguishable from + noise. Anything logged here is something a human should look at.""" + print(f"::error::{message}") + def is_org_member(api_root, org_name, user, token): url = f"{api_root}/orgs/{org_name}/members/{user}" debug_log(f"🕵️ Checking membership for @{user} in {org_name}...") @@ -167,30 +173,62 @@ def github_api(url, token, method="GET", data=None, _retry_on_rate_limit=True): debug_log(f"Network Error: {e}") return None -# --- NEW: PAGINATION HELPER (Fixes 100 Item Limit) --- -def github_api_paginated(url, token): - """Fetches ALL pages of results.""" +# --- PAGINATION HELPERS (Fixes 100 Item Limit) --- +def github_api_paginated_checked(url, token): + """Fetches ALL pages, and reports whether the fetch actually completed. + + Returns (items, ok). `ok` is False if any page's request failed or came + back malformed. + + This distinction matters because `github_api` returns None for every + failure mode — a rate-limit 403, a permissions 403, a 404, a network + error — so once collapsed into a list they are indistinguishable from + "there is nothing here". Any caller that turns "empty" into a *decision* + ("nobody signed", "we haven't commented yet") will silently turn a + transient error into a wrong answer, and in the commenting case into a + self-sustaining loop: posting bumps the PR's updated_at, which keeps it + inside the sweeper's lookback window, so it re-posts every sweep. + """ all_results = [] page = 1 - + while True: separator = "&" if "?" in url else "?" paged_url = f"{url}{separator}page={page}&per_page=100" - + data = github_api(paged_url, token) - + if data is None: + # Request failed. Whatever we collected so far may be partial, so + # the caller must not read it as a complete picture. + return all_results, False + # Handle cases where API returns dict (like search results) vs list items = data.get("items") if isinstance(data, dict) and "items" in data else data - - if not items or not isinstance(items, list): + + if not isinstance(items, list): + return all_results, False + + if not items: + # A genuinely empty page: the listing is complete. break - + all_results.extend(items) if len(items) < 100: break page += 1 - - return all_results + + return all_results, True + + +def github_api_paginated(url, token): + """Fetches ALL pages of results. + + A failed fetch is indistinguishable from an empty result here. Use + github_api_paginated_checked() wherever that difference changes a + contributor-visible outcome. + """ + items, _ok = github_api_paginated_checked(url, token) + return items # --- UNIFIED RESOURCE LOADER --- def fetch_mothership_file(api_root, file_path, token): @@ -278,21 +316,28 @@ def signature_candidate_text(body): # --- UPDATED: Uses Pagination for Comments --- def check_comments_for_signature(api_root, repo, pr_number, user, doc_type, token): - """Returns (comment_id, signed_doc_type), or (None, None) if the PR author - has not signed. + """Returns (comment_id, signed_doc_type, readable). `signed_doc_type` is the document the author actually signed, which is not necessarily the one this repo requires — the caller compares them. This used to return only a comment id and accept either phrase regardless of `doc_type`, so posting the DCO sentence on a CLA repo passed the gate and then recorded a CLA consent record for someone who never agreed to the CLA. + + `readable` is False when the comment listing could not be fetched. Without + it, an unreadable thread looks exactly like a thread with no sign-off in + it, and we would paint `failure` on a contributor who had in fact signed. """ - if not pr_number: return None, None + if not pr_number: return None, None, True # Fix: Use paginated fetch to see >100 comments url = f"{api_root}/repos/{repo}/issues/{pr_number}/comments" - comments = github_api_paginated(url, token) + comments, readable = github_api_paginated_checked(url, token) + + if not readable: + debug_log(f"⚠️ Could not read comments on {repo}#{pr_number}; treating the sign-off state as unknown.") + return None, None, False - if not comments: return None, None + if not comments: return None, None, True # Check the document this repo actually requires first, so a comment that # happens to contain both sentences is credited to the required one. @@ -320,39 +365,69 @@ def check_comments_for_signature(api_root, repo, pr_number, user, doc_type, toke # would contradict the failure status we go on to set. if current_type == doc_type: add_reaction_to_comment(api_root, repo, c.get("id"), token) - return c.get("id"), current_type + return c.get("id"), current_type, True debug_log(f"❌ No matching CLA or DCO signature found in {len(comments)} comments.") - return None, None + return None, None, True # --- NEW: DCO Commit Check (Fixes 100 Commit Limit) --- def check_dco_commits(api_root, repo, pr_number, token): - """Returns True if ALL commits are signed-off.""" + """Returns (all_signed_off, readable). + + `readable` is False when the commit listing could not be fetched. An + unreadable list previously returned False, i.e. "not signed off", which + fails a contributor whose commits are in fact all signed. + """ url = f"{api_root}/repos/{repo}/pulls/{pr_number}/commits" # Fix: Use paginated fetch for >100 commits - commits = github_api_paginated(url, token) - - if not commits: return False + commits, readable = github_api_paginated_checked(url, token) + + if not readable: + debug_log(f"⚠️ Could not read commits on {repo}#{pr_number}; treating DCO sign-off state as unknown.") + return False, False + + if not commits: return False, True for commit in commits: message = commit.get("commit", {}).get("message", "") if "Signed-off-by:" not in message: - debug_log(f"❌ Commit {commit.get('sha')[:7]} missing DCO Sign-off.") - return False + # A missing sha used to raise TypeError here, which the sweeper's + # per-PR except swallowed — silently skipping the PR entirely. + sha = commit.get("sha") or "unknown" + debug_log(f"❌ Commit {sha[:7]} missing DCO Sign-off.") + return False, True debug_log(f"✅ All {len(commits)} commits have DCO Sign-off.") - return True + return True, True def post_pr_comment(api_root, repo, pr_number, message, token): + """Posts the instruction comment, unless we have already posted it. + + The dedup scan used to be an unpaginated fetch, which returns only + GitHub's default first 30 comments. On a thread with 30+ comments older + than ours, the scan never saw our comment and posted another — and since + each post bumps the PR's updated_at, the PR stayed inside the sweeper's + lookback window and got another comment every sweep, forever. Each new + comment also lands later in the listing, so a 30-item window can never + catch up. + """ if not pr_number: return comments_url = f"{api_root}/repos/{repo}/issues/{pr_number}/comments" - existing_comments = github_api(comments_url, token) - if existing_comments: - for c in existing_comments: - if "I have read the" in c.get("body", "") and "Sign via Comment" in c.get("body", ""): - return - payload = {"body": message} - github_api(comments_url, token, "POST", payload) + existing_comments, readable = github_api_paginated_checked(comments_url, token) + + if not readable: + # We cannot tell whether we already commented. Posting on a failed + # read is exactly how one duplicate becomes an endless stream, so stay + # quiet: the cost is one delayed instruction comment, and the next + # sweep retries. + debug_log(f"⚠️ Could not read comments on {repo}#{pr_number}; not posting instructions this pass.") + return + + for c in existing_comments: + if "I have read the" in c.get("body", "") and "Sign via Comment" in c.get("body", ""): + return + + github_api(comments_url, token, "POST", {"body": message}) def force_merge_check_refresh(api_root, repo, pr_number, token): url = f"{api_root}/repos/{repo}/pulls/{pr_number}" @@ -370,16 +445,23 @@ def set_commit_status(api_root, repo, sha, state, description, target_url, token github_api(url, token, "POST", payload) def get_existing_status_state(api_root, repo, sha, token): - """Returns the current state of our STATUS_CONTEXT on this commit - ('success'/'failure'/'pending'), or None if we haven't posted one yet.""" + """Returns (state, readable). + + `state` is our STATUS_CONTEXT's current state on this commit + ('success'/'failure'/'pending'), or None if we have not posted one yet. + `readable` is False when the status could not be fetched — previously + indistinguishable from "no status yet", which quietly disabled the + already-resolved short-circuit during API trouble and let the sweeper + repaint (and so bump updated_at on) PRs it should have left alone. + """ url = f"{api_root}/repos/{repo}/commits/{sha}/status" data = github_api(url, token) if not data: - return None + return None, False for s in data.get("statuses", []): if s.get("context") == STATUS_CONTEXT: - return s.get("state") - return None + return s.get("state"), True + return None, True from datetime import datetime @@ -491,11 +573,42 @@ def fetch_shared_config(api_root, gh_token): # C. Permissive Names permissive_data = fetch_json_with_fallback(api_root, "data/permissive_names.json", "cla/permissive_names.json", gh_token) or [] + # Completeness deliberately covers only the two licence catalogues. They + # are pure data tables (multi-MB) that cannot legitimately be empty, so a + # falsy value means the fetch failed — and deciding CLA-vs-DCO from an + # empty catalogue would mislabel every PR in the sweep. + # + # The allowlist is NOT included, even though a failed allowlist fetch also + # skews decisions (DCO-only repos would be treated as CLA). An *empty* + # allowlist is a perfectly valid configuration meaning "no overrides", and + # fetch_mothership_file cannot tell empty from failed — so gating on it + # would mean that emptying cla/allowlist.yml silently aborts every sweep + # org-wide. A wrong-but-stricter policy that self-corrects next sweep is + # far better than switching compliance off without telling anyone. + complete = bool(licenses_data) and bool(permissive_data) + if not complete: + error_log( + "❌ Licence catalogues could not be loaded " + f"(licenses={len(licenses_data)}, permissive={len(permissive_data)}). " + "Policy decisions would be made from empty data, so callers should " + "abort rather than guess." + ) + if not raw_allowlist: + # Warning, not error: an intentionally-empty allowlist is valid, and we + # cannot tell it from a failed fetch — so raising this to ::error:: + # would emit a permanent alert for a legitimate configuration. + debug_log( + "⚠️ Allowlist is empty or could not be loaded. Proceeding, but " + "repos configured as DCO-only will be evaluated as CLA until it " + "loads again." + ) + return { "allowlist_data": allowlist_data, "allowlist_repos": allowlist_repos, "licenses_data": licenses_data, "permissive_data": permissive_data, + "complete": complete, } @@ -509,7 +622,10 @@ def process_single_pr(pr_number, pr_head_sha, pr_user, repo_full_name, gh_token, # already-successful PR just repaints the same result and pushes # updated_at again, looping forever every sweep cycle. A new commit gets # a fresh SHA (no prior status), so this only skips true no-op re-checks. - existing_state = get_existing_status_state(api_root, repo_full_name, pr_head_sha, gh_token) + existing_state, status_readable = get_existing_status_state(api_root, repo_full_name, pr_head_sha, gh_token) + if not status_readable: + debug_log(f"⚠️ Could not read the existing status on {pr_head_sha[:7]}; leaving PR #{pr_number} alone this pass.") + return if existing_state == "success": debug_log(f"✅ PR #{pr_number} already has a successful '{STATUS_CONTEXT}' status on {pr_head_sha[:7]}. Skipping re-check.") return @@ -531,6 +647,11 @@ def process_single_pr(pr_number, pr_head_sha, pr_user, repo_full_name, gh_token, # fetching once for many PRs); otherwise fetch fresh — correct either # way, since required-compliance.yml only ever processes one PR per run. config = shared_config or fetch_shared_config(api_root, gh_token) + # .get() so a hand-built config (e.g. in tests) without the key is treated + # as complete rather than raising. + if not config.get("complete", True): + debug_log(f"⚠️ Shared config incomplete; leaving PR #{pr_number} alone rather than guessing its policy.") + return allowlist_data = config["allowlist_data"] allowlist_repos = config["allowlist_repos"] licenses_data = config["licenses_data"] @@ -559,28 +680,47 @@ def process_single_pr(pr_number, pr_head_sha, pr_user, repo_full_name, gh_token, doc_type = "CLA" if is_strict else "DCO" # --- 3. CHECK SIGNATURES (Registry Check) --- + # An unreadable registry is NOT the same as "this user has not signed". + # fetch_mothership_file returns None for a missing file and for every + # failure alike, so without this guard a transient error on the primary + # compliance path fails everyone who has actually signed. has_signed_json = False sig_file_path = f"signatures/{doc_type.lower()}.json" raw_signatures = fetch_mothership_file(api_root, sig_file_path, gh_token) - - if raw_signatures: - try: - data = json.loads(raw_signatures) - contributors = data.get("signedContributors", []) if isinstance(data, dict) else data - for c in contributors: - if isinstance(c, dict): - if c.get("name", "").lower() == pr_user.lower(): has_signed_json = True; break - elif isinstance(c, str): - if c.lower() == pr_user.lower(): has_signed_json = True; break - except Exception as e: - debug_log(f"⚠️ Failed to parse Signatures JSON: {e}") - + + if not raw_signatures: + # Deliberately NOT treated as "nobody has signed": that would fail + # every contributor who has. But note the cost of bailing — if this + # file were genuinely absent rather than briefly unfetchable, PRs on + # this policy would get no status at all and stay blocked by the + # required check. That is silent unless this is loud, hence error_log. + error_log(f"❌ Could not read {sig_file_path}; leaving PR #{pr_number} untouched rather than guessing.") + return + + try: + data = json.loads(raw_signatures) + contributors = data.get("signedContributors", []) if isinstance(data, dict) else data + for c in contributors: + if isinstance(c, dict): + if c.get("name", "").lower() == pr_user.lower(): has_signed_json = True; break + elif isinstance(c, str): + if c.lower() == pr_user.lower(): has_signed_json = True; break + except Exception as e: + # Malformed registry is also not evidence of non-compliance. + error_log(f"❌ Failed to parse {sig_file_path}: {e}. Leaving PR #{pr_number} untouched.") + return + # 4. Check Comments (Forensics Collection) comment_id = None signed_type = None if not has_signed_json: - # Returns (ID, document actually signed), or (None, None) - comment_id, signed_type = check_comments_for_signature(api_root, repo_full_name, pr_number, pr_user, doc_type, gh_token) + # Returns (ID, document actually signed, whether the thread was readable) + comment_id, signed_type, comments_readable = check_comments_for_signature( + api_root, repo_full_name, pr_number, pr_user, doc_type, gh_token) + if not comments_readable: + # Don't paint anything: whatever status the PR already has is a + # better answer than one derived from a failed read. + return # A sign-off only counts if it is for the document this repo requires. has_valid_signature = bool(comment_id) and signed_type == doc_type @@ -593,7 +733,9 @@ def process_single_pr(pr_number, pr_head_sha, pr_user, repo_full_name, gh_token, # properly signed-off commits, and shouldn't lose that fallback. dco_commits_valid = False if doc_type == "DCO" and not has_signed_json and not has_valid_signature: - dco_commits_valid = check_dco_commits(api_root, repo_full_name, pr_number, gh_token) + dco_commits_valid, commits_readable = check_dco_commits(api_root, repo_full_name, pr_number, gh_token) + if not commits_readable: + return doc_url = os.environ.get("CLA_DOC_URL") if doc_type == "CLA" else os.environ.get("DCO_DOC_URL") diff --git a/tests/test_policy_selector.py b/tests/test_policy_selector.py index 1e695de..bf02e31 100644 --- a/tests/test_policy_selector.py +++ b/tests/test_policy_selector.py @@ -88,9 +88,12 @@ class FakeGitHub(object): statuses when it shouldn't. """ - def __init__(self, routes=None, paginated_routes=None): + def __init__(self, routes=None, paginated_routes=None, unreadable=()): self.routes = routes or {} self.paginated_routes = paginated_routes or {} + # URL fragments whose fetch should report failure rather than an empty + # list — the distinction the engine now depends on. + self.unreadable = tuple(unreadable) self.calls = [] def _match(self, table, url): @@ -103,9 +106,15 @@ def github_api(self, url, token, method="GET", data=None, _retry_on_rate_limit=T self.calls.append((method, url, data)) return self._match(self.routes, url) - def github_api_paginated(self, url, token): + def github_api_paginated_checked(self, url, token): self.calls.append(("GET-paginated", url, None)) - return self._match(self.paginated_routes, url) or [] + if any(frag in url for frag in self.unreadable): + return [], False + return (self._match(self.paginated_routes, url) or []), True + + def github_api_paginated(self, url, token): + items, _ok = self.github_api_paginated_checked(url, token) + return items # --- assertion helpers --- @@ -126,16 +135,23 @@ def statuses(self): class PolicySelectorTestCase(unittest.TestCase): """Installs the fake and restores the real functions afterwards.""" - def install(self, routes=None, paginated_routes=None): - fake = FakeGitHub(routes, paginated_routes) - self._saved = (policy_selector.github_api, policy_selector.github_api_paginated) + def install(self, routes=None, paginated_routes=None, unreadable=()): + fake = FakeGitHub(routes, paginated_routes, unreadable) + self._saved = ( + policy_selector.github_api, + policy_selector.github_api_paginated, + policy_selector.github_api_paginated_checked, + ) policy_selector.github_api = fake.github_api policy_selector.github_api_paginated = fake.github_api_paginated + policy_selector.github_api_paginated_checked = fake.github_api_paginated_checked self.addCleanup(self._restore) return fake def _restore(self): - policy_selector.github_api, policy_selector.github_api_paginated = self._saved + (policy_selector.github_api, + policy_selector.github_api_paginated, + policy_selector.github_api_paginated_checked) = self._saved # --------------------------------------------------------------------------- @@ -151,30 +167,30 @@ def call(self, comments, user="contributor", doc_type="CLA"): def test_exact_signature_matches(self): result, _ = self.call([comment(signature_body("CLA"), comment_id=42)]) - self.assertEqual(result, (42, "CLA")) + self.assertEqual(result, (42, "CLA", True)) def test_signature_without_suffix_is_rejected(self): # The phrase alone is not a signature; the "and all future # contributions" suffix is what makes it a standing warranty. result, _ = self.call([comment(signature_body("CLA", suffix=False))]) - self.assertEqual(result, (None, None)) + self.assertEqual(result, (None, None, True)) def test_comment_from_another_user_is_ignored(self): result, _ = self.call([comment(signature_body("CLA"), login="someone-else")]) - self.assertEqual(result, (None, None)) + self.assertEqual(result, (None, None, True)) def test_login_match_is_case_insensitive(self): result, _ = self.call( [comment(signature_body("CLA"), login="ConTributor", comment_id=7)], user="contributor" ) - self.assertEqual(result, (7, "CLA")) + self.assertEqual(result, (7, "CLA", True)) def test_non_breaking_spaces_are_normalised(self): # Copy-pasting the phrase out of a rendered web page can bring # U+00A0 along with it. body = signature_body("CLA").replace(" ", "\xa0") result, _ = self.call([comment(body, comment_id=11)]) - self.assertEqual(result, (11, "CLA")) + self.assertEqual(result, (11, "CLA", True)) def test_signature_is_found_beyond_the_first_page(self): # This function paginates, so a signature buried under a long @@ -182,7 +198,7 @@ def test_signature_is_found_beyond_the_first_page(self): comments = [comment("just a normal comment", comment_id=i) for i in range(60)] comments.append(comment(signature_body("CLA"), comment_id=12345)) result, _ = self.call(comments) - self.assertEqual(result, (12345, "CLA")) + self.assertEqual(result, (12345, "CLA", True)) def test_reaction_is_added_to_the_signing_comment(self): _, fake = self.call([comment(signature_body("CLA"), comment_id=42)]) @@ -192,7 +208,7 @@ def test_reaction_is_added_to_the_signing_comment(self): def test_no_comments_returns_none(self): result, fake = self.call([]) - self.assertEqual(result, (None, None)) + self.assertEqual(result, (None, None, True)) self.assertEqual(fake.writes(), []) def test_signature_in_a_code_fence_still_counts(self): @@ -201,12 +217,12 @@ def test_signature_in_a_code_fence_still_counts(self): # brings the fence markers along is signing in good faith. body = "```text\n" + signature_body("CLA") + "\n```" result, _ = self.call([comment(body, comment_id=21)]) - self.assertEqual(result, (21, "CLA")) + self.assertEqual(result, (21, "CLA", True)) def test_signature_with_surrounding_chat_still_counts(self): body = signature_body("CLA") + "\n\nThanks for the quick review!" result, _ = self.call([comment(body, comment_id=22)]) - self.assertEqual(result, (22, "CLA")) + self.assertEqual(result, (22, "CLA", True)) # --------------------------------------------------------------------------- @@ -234,13 +250,13 @@ def _bot_message(self, doc_type="CLA"): def test_quote_reply_to_the_bot_is_not_a_signature(self): # GitHub's "Quote reply" button prefixes every line with "> ". quoted = "\n".join("> " + ln for ln in self._bot_message().splitlines()) - self.assertEqual(self.call(quoted + "\n\nwhat do I do here?"), (None, None)) + self.assertEqual(self.call(quoted + "\n\nwhat do I do here?"), (None, None, True)) def test_verbatim_paste_of_the_instructions_is_not_a_signature(self): - self.assertEqual(self.call(self._bot_message()), (None, None)) + self.assertEqual(self.call(self._bot_message()), (None, None, True)) def test_quoted_signature_sentence_alone_is_not_a_signature(self): - self.assertEqual(self.call("> " + signature_body("CLA")), (None, None)) + self.assertEqual(self.call("> " + signature_body("CLA")), (None, None, True)) def test_instruction_markers_really_are_in_the_message(self): # The rejection above keys off these markers. If the instruction @@ -251,7 +267,7 @@ def test_instruction_markers_really_are_in_the_message(self): self.assertIn(marker, msg) def test_a_genuine_signature_quoting_nothing_is_unaffected(self): - self.assertEqual(self.call(signature_body("CLA")), (99, "CLA")) + self.assertEqual(self.call(signature_body("CLA")), (99, "CLA", True)) # --------------------------------------------------------------------------- @@ -269,17 +285,17 @@ def call(self, body, doc_type): def test_cla_repo_cla_sentence(self): result, _ = self.call(signature_body("CLA"), "CLA") - self.assertEqual(result, (31, "CLA")) + self.assertEqual(result, (31, "CLA", True)) def test_dco_repo_dco_sentence(self): result, _ = self.call(signature_body("DCO"), "DCO") - self.assertEqual(result, (31, "DCO")) + self.assertEqual(result, (31, "DCO", True)) def test_cla_repo_dco_sentence_reports_the_mismatch(self): # Previously this passed the gate and wrote a CLA consent record for # someone who only ever agreed to the DCO text. result, _ = self.call(signature_body("DCO"), "CLA") - self.assertEqual(result, (31, "DCO")) + self.assertEqual(result, (31, "DCO", True)) def test_no_rocket_reaction_on_a_mismatched_document(self): # A 🚀 reads as "accepted" and would contradict the failure status. @@ -289,7 +305,7 @@ def test_no_rocket_reaction_on_a_mismatched_document(self): def test_both_sentences_present_credits_the_required_one(self): body = signature_body("DCO") + "\n\n" + signature_body("CLA") result, _ = self.call(body, "CLA") - self.assertEqual(result, (31, "CLA")) + self.assertEqual(result, (31, "CLA", True)) # --------------------------------------------------------------------------- @@ -332,11 +348,11 @@ def call(self, payload): def test_returns_our_context_state(self): state, _ = self.call({"statuses": [{"context": policy_selector.STATUS_CONTEXT, "state": "success"}]}) - self.assertEqual(state, "success") + self.assertEqual(state, ("success", True)) def test_ignores_other_contexts(self): state, _ = self.call({"statuses": [{"context": "Some Other CI", "state": "failure"}]}) - self.assertIsNone(state) + self.assertEqual(state, (None, True)) def test_picks_our_context_out_of_a_crowd(self): state, _ = self.call({"statuses": [ @@ -344,15 +360,17 @@ def test_picks_our_context_out_of_a_crowd(self): {"context": policy_selector.STATUS_CONTEXT, "state": "failure"}, {"context": "build", "state": "success"}, ]}) - self.assertEqual(state, "failure") + self.assertEqual(state, ("failure", True)) def test_no_statuses_returns_none(self): state, _ = self.call({"statuses": []}) - self.assertIsNone(state) + self.assertEqual(state, (None, True)) - def test_api_failure_returns_none(self): + def test_api_failure_is_reported_as_unreadable(self): + # Not the same as "no status yet": acting on it would repaint PRs the + # already-resolved short-circuit is meant to leave alone. state, _ = self.call(None) - self.assertIsNone(state) + self.assertEqual(state, (None, False)) # --------------------------------------------------------------------------- @@ -367,25 +385,32 @@ def _commit(self, message, sha="abcdef1234"): return {"sha": sha, "commit": {"message": message}} def test_all_commits_signed_off(self): - self.assertTrue(self.call([ + self.assertEqual(self.call([ self._commit("fix: thing\n\nSigned-off-by: A "), self._commit("fix: other\n\nSigned-off-by: A "), - ])) + ]), (True, True)) def test_one_unsigned_commit_fails_the_whole_pr(self): - self.assertFalse(self.call([ + self.assertEqual(self.call([ self._commit("fix: thing\n\nSigned-off-by: A "), self._commit("fix: forgot the sign-off"), - ])) + ]), (False, True)) def test_no_commits_is_not_compliant(self): - self.assertFalse(self.call([])) + self.assertEqual(self.call([]), (False, True)) # --------------------------------------------------------------------------- # process_single_pr — the decision table # --------------------------------------------------------------------------- -class TestProcessSinglePr(PolicySelectorTestCase): +class ProcessSinglePrHarness(object): + """Shared fixture for process_single_pr tests. + + Deliberately NOT a TestCase. Subclassing a TestCase to reuse its + helpers makes unittest re-run every parent test under each child, + which silently inflated this suite's reported count by 34. + """ + SHARED_CONFIG = { "allowlist_data": {}, "allowlist_repos": [], @@ -408,8 +433,24 @@ def _restore_helpers(self): policy_selector.requires_cla.requires_CLA = self._saved_requires policy_selector.is_org_member = self._saved_is_member - def run_pr(self, routes=None, paginated_routes=None, user="contributor", config=None): - fake = self.install(routes or {}, paginated_routes or {}) + def _default_routes(self): + """process_single_pr now bails rather than guessing when the signature + registry cannot be read, so a readable (empty) registry is the baseline + for any test that is not specifically about that failure.""" + return { + # process_single_pr now refuses to act on an unreadable status, so + # a readable "no status yet" is the baseline. + "/commits/abc123/status": {"statuses": []}, + "/contents/signatures/cla.json": self._registry([]), + "/contents/signatures/dco.json": self._registry([]), + "/users/": {"id": 4242}, + } + + def run_pr(self, routes=None, paginated_routes=None, user="contributor", config=None, + unreadable=()): + merged = dict(self._default_routes()) + merged.update(routes or {}) + fake = self.install(merged, paginated_routes or {}, unreadable) policy_selector.process_single_pr( 5, "abc123", user, "vmware/repo", "tok", "/tmp", "https://api.invalid", shared_config=self.SHARED_CONFIG if config is None else config, @@ -429,13 +470,12 @@ def _registry(self, names): def _writable_registry_routes(self): """Routes that let record_signature get all the way to its PUT, so a - test asserting that no record was written can actually fail.""" - return { - "/users/": {"id": 4242}, - "/contents/signatures/cla.json": self._registry([]), - "/contents/signatures/dco.json": self._registry([]), - } + test asserting that no record was written can actually fail. Same as + the defaults now, kept named for readability at the call sites that + depend on a write being possible.""" + return self._default_routes() +class TestProcessSinglePr(ProcessSinglePrHarness, PolicySelectorTestCase): def test_already_successful_status_short_circuits_with_no_writes(self): # This is the PR #67 fix. Repainting an already-green PR bumps its # updated_at, which puts it back in the sweeper's lookback window. @@ -581,9 +621,14 @@ def routed(url, token, method="GET", data=None, _retry_on_rate_limit=True): return None if put_fails else {"commit": {"sha": "newsha"}} return get_only(url, token, method, data) - self._saved = (policy_selector.github_api, policy_selector.github_api_paginated) + self._saved = ( + policy_selector.github_api, + policy_selector.github_api_paginated, + policy_selector.github_api_paginated_checked, + ) policy_selector.github_api = routed policy_selector.github_api_paginated = fake.github_api_paginated + policy_selector.github_api_paginated_checked = fake.github_api_paginated_checked self.addCleanup(self._restore) # The retry loop sleeps 1-3s with jitter between attempts; skip the wait. @@ -647,10 +692,9 @@ def test_returns_all_four_keys_process_single_pr_indexes(self): # KeyError mid-sweep rather than a soft failure. self.install() config = policy_selector.fetch_shared_config("https://api.invalid", "tok") - self.assertEqual( - sorted(config.keys()), - ["allowlist_data", "allowlist_repos", "licenses_data", "permissive_data"], - ) + for key in ("allowlist_data", "allowlist_repos", "licenses_data", "permissive_data"): + self.assertIn(key, config) + self.assertIn("complete", config) def test_missing_config_degrades_to_empty_rather_than_raising(self): self.install() @@ -686,5 +730,441 @@ def test_dedup_markers_are_present(self): self.assertIn("Sign via Comment", msg) + + +# --------------------------------------------------------------------------- +# A failed fetch is not evidence of anything +# --------------------------------------------------------------------------- +class TestFetchFailureIsNotEmptiness(PolicySelectorTestCase): + """`github_api` returns None for every failure, so a 403/404/network error + collapses into the same empty list as "there is nothing here". Every place + that turned emptiness into a decision could therefore turn a transient + error into a wrong, contributor-visible answer.""" + + def patch_transport(self, fn): + """Replace only `github_api`, so the REAL github_api_paginated_checked + is the thing under test. install() would swap the checked helper out + for the fake and the test would assert on the fake instead.""" + saved = policy_selector.github_api + policy_selector.github_api = fn + self.addCleanup(lambda: setattr(policy_selector, "github_api", saved)) + + def test_checked_helper_reports_a_failed_page(self): + self.patch_transport(lambda *a, **k: None) + items, ok = policy_selector.github_api_paginated_checked("https://api.invalid/x", "tok") + self.assertEqual(items, []) + self.assertFalse(ok) + + def test_checked_helper_reports_a_genuinely_empty_listing(self): + self.patch_transport(lambda *a, **k: []) + items, ok = policy_selector.github_api_paginated_checked("https://api.invalid/x", "tok") + self.assertEqual(items, []) + self.assertTrue(ok) + + def test_checked_helper_reports_a_malformed_page_as_not_ok(self): + self.patch_transport(lambda *a, **k: {"unexpected": "shape"}) + items, ok = policy_selector.github_api_paginated_checked("https://api.invalid/x", "tok") + self.assertFalse(ok) + + def test_checked_helper_reports_partial_pages_as_not_ok(self): + # Page 1 full, page 2 fails: the list is truncated, not complete. + state = {"n": 0} + + def flaky(url, token, method="GET", data=None, _retry_on_rate_limit=True): + state["n"] += 1 + return [{"i": i} for i in range(100)] if state["n"] == 1 else None + + self.patch_transport(flaky) + items, ok = policy_selector.github_api_paginated_checked("https://api.invalid/x", "tok") + self.assertEqual(len(items), 100) + self.assertFalse(ok) + + def test_plain_wrapper_still_returns_just_the_items(self): + # The ~6 existing call sites keep working unchanged. + self.patch_transport(lambda *a, **k: [{"a": 1}]) + self.assertEqual(policy_selector.github_api_paginated("https://api.invalid/x", "tok"), + [{"a": 1}]) + + def test_unreadable_comments_do_not_produce_a_duplicate_comment(self): + # The regression that the pagination fix alone would have introduced: + # posting on a failed read, which bumps updated_at and so re-posts + # every sweep. + fake = self.install(unreadable=("/issues/5/comments",)) + policy_selector.post_pr_comment( + "https://api.invalid", "vmware/repo", 5, "INSTRUCTIONS", "tok") + self.assertEqual(fake.posted_comments(), []) + + def test_readable_comments_still_post_when_absent(self): + fake = self.install(paginated_routes={"/issues/5/comments": []}) + policy_selector.post_pr_comment( + "https://api.invalid", "vmware/repo", 5, "INSTRUCTIONS", "tok") + self.assertEqual(len(fake.posted_comments()), 1) + + def test_instruction_comment_beyond_the_first_thirty_is_still_found(self): + # The original defect: the dedup scan was unpaginated, so it saw only + # GitHub's default first 30 comments. + comments = [comment("chatter %d" % i, comment_id=i) for i in range(30)] + comments.append(instruction_comment(comment_id=9001)) + comments += [comment("more chatter %d" % i, comment_id=100 + i) for i in range(5)] + fake = self.install(paginated_routes={"/issues/5/comments": comments}) + policy_selector.post_pr_comment( + "https://api.invalid", "vmware/repo", 5, "INSTRUCTIONS", "tok") + self.assertEqual(fake.posted_comments(), []) + + def test_unreadable_comments_report_unknown_not_unsigned(self): + self.install(unreadable=("/issues/5/comments",)) + result = policy_selector.check_comments_for_signature( + "https://api.invalid", "vmware/repo", 5, "contributor", "CLA", "tok") + self.assertEqual(result, (None, None, False)) + + def test_unreadable_commits_report_unknown_not_unsigned(self): + self.install(unreadable=("/pulls/5/commits",)) + result = policy_selector.check_dco_commits( + "https://api.invalid", "vmware/repo", 5, "tok") + self.assertEqual(result, (False, False)) + + +class TestUnreadableInputLeavesTheStatusAlone(ProcessSinglePrHarness, PolicySelectorTestCase): + """Whatever status a PR already has is a better answer than one derived + from a fetch that failed, so process_single_pr writes nothing at all.""" + + def test_unreadable_comments_write_no_status_and_no_comment(self): + fake = self.run_pr(unreadable=("/issues/5/comments",)) + self.assertEqual(fake.statuses(), []) + self.assertEqual(fake.posted_comments(), []) + + def test_unreadable_registry_writes_no_status_and_no_comment(self): + fake = self.run_pr(routes={"/contents/signatures/cla.json": None}) + self.assertEqual(fake.statuses(), []) + self.assertEqual(fake.posted_comments(), []) + + def test_unreadable_commits_write_no_status_on_a_dco_repo(self): + policy_selector.requires_cla.requires_CLA = lambda *a, **k: False + fake = self.run_pr( + paginated_routes={"/issues/5/comments": []}, + unreadable=("/pulls/5/commits",)) + self.assertEqual(fake.statuses(), []) + + def test_a_readable_but_unsigned_pr_is_still_failed(self): + # The guard must not become a blanket excuse to do nothing. + fake = self.run_pr(paginated_routes={"/issues/5/comments": []}) + self.assertEqual([s["state"] for s in fake.statuses()], ["failure"]) + self.assertEqual(len(fake.posted_comments()), 1) + + +# --------------------------------------------------------------------------- +# Edge cases found by auditing rather than by a failing test +# --------------------------------------------------------------------------- +class TestPaginationBoundaries(PolicySelectorTestCase): + def patch_transport(self, fn): + saved = policy_selector.github_api + policy_selector.github_api = fn + self.addCleanup(lambda: setattr(policy_selector, "github_api", saved)) + + def test_search_style_items_envelope_is_unwrapped_and_paged(self): + state = {"n": 0} + + def search(url, token, method="GET", data=None, _retry_on_rate_limit=True): + state["n"] += 1 + return {"items": [{"x": i} for i in range(100)]} if state["n"] == 1 else {"items": []} + + self.patch_transport(search) + items, ok = policy_selector.github_api_paginated_checked("https://api.invalid/search?q=1", "tok") + self.assertEqual(len(items), 100) + self.assertTrue(ok) + self.assertEqual(state["n"], 2) + + def test_full_page_followed_by_an_empty_page_is_complete(self): + # The boundary case: exactly 100 items means "maybe more", so it pages + # again; an empty second page means the listing really is done. + state = {"n": 0} + + def two_pages(url, token, method="GET", data=None, _retry_on_rate_limit=True): + state["n"] += 1 + return [{"i": i} for i in range(100)] if state["n"] == 1 else [] + + self.patch_transport(two_pages) + items, ok = policy_selector.github_api_paginated_checked("https://api.invalid/x", "tok") + self.assertEqual(len(items), 100) + self.assertTrue(ok) + + +class TestCommitWithoutShaDoesNotCrash(PolicySelectorTestCase): + def test_unsigned_commit_missing_its_sha(self): + # commit.get('sha')[:7] raised TypeError, which the sweeper's per-PR + # except swallowed — the PR was skipped with no status at all. + self.install(paginated_routes={ + "/pulls/5/commits": [{"commit": {"message": "no sign-off here"}}] + }) + self.assertEqual( + policy_selector.check_dco_commits("https://api.invalid", "vmware/repo", 5, "tok"), + (False, True)) + + +class TestUnreadableStatusIsNotAbsentStatus(PolicySelectorTestCase): + def test_failed_status_fetch_reports_unreadable(self): + self.install(routes={}) # no route -> github_api returns None + self.assertEqual( + policy_selector.get_existing_status_state("https://api.invalid", "vmware/repo", "abc123", "tok"), + (None, False)) + + def test_absent_status_is_readable_but_none(self): + self.install(routes={"/commits/abc123/status": {"statuses": []}}) + self.assertEqual( + policy_selector.get_existing_status_state("https://api.invalid", "vmware/repo", "abc123", "tok"), + (None, True)) + + def test_present_status_is_readable(self): + self.install(routes={"/commits/abc123/status": { + "statuses": [{"context": policy_selector.STATUS_CONTEXT, "state": "failure"}]}}) + self.assertEqual( + policy_selector.get_existing_status_state("https://api.invalid", "vmware/repo", "abc123", "tok"), + ("failure", True)) + + +class TestIncompleteConfigAborts(PolicySelectorTestCase): + def test_fetch_shared_config_flags_a_total_failure(self): + self.install() # every mothership fetch returns None + config = policy_selector.fetch_shared_config("https://api.invalid", "tok") + self.assertFalse(config["complete"]) + + def test_process_single_pr_writes_nothing_on_incomplete_config(self): + # An empty catalogue does not fail one PR — it decides CLA-vs-DCO for + # every PR in the sweep from no data. + # Every *other* guard must be satisfied, or this test passes for the + # wrong reason: without a readable registry process_single_pr bails + # there first and the config guard is never reached. + import base64 + registry = {"content": base64.b64encode( + json.dumps({"signedContributors": []}).encode()).decode(), "sha": "filesha"} + fake = self.install(routes={ + "/commits/abc123/status": {"statuses": []}, + "/contents/signatures/cla.json": registry, + "/contents/signatures/dco.json": registry, + }, paginated_routes={"/issues/5/comments": []}) + saved = policy_selector.is_org_member + saved_req = policy_selector.requires_cla.requires_CLA + policy_selector.is_org_member = lambda *a, **k: False + policy_selector.requires_cla.requires_CLA = lambda *a, **k: True + self.addCleanup(lambda: setattr(policy_selector, "is_org_member", saved)) + self.addCleanup(lambda: setattr(policy_selector.requires_cla, "requires_CLA", saved_req)) + policy_selector.process_single_pr( + 5, "abc123", "contributor", "vmware/repo", "tok", "/tmp", "https://api.invalid", + shared_config={"allowlist_data": {}, "allowlist_repos": [], + "licenses_data": [], "permissive_data": [], "complete": False}) + self.assertEqual(fake.statuses(), []) + self.assertEqual(fake.posted_comments(), []) + + def test_a_config_without_the_key_is_treated_as_complete(self): + # Back-compat: hand-built configs (including this suite's own) predate + # the key and must not be read as broken. + self.assertTrue({"allowlist_data": {}}.get("complete", True)) + + +class TestConfigCompletenessIsNarrowlyScoped(PolicySelectorTestCase): + """Completeness must cover the licence catalogues and NOT the allowlist. + Gating on the allowlist would mean that emptying cla/allowlist.yml — a + valid configuration meaning "no overrides" — silently aborts every sweep + org-wide, which is far worse than the skewed decision it would prevent.""" + + def _b64(self, text): + import base64 + return {"content": base64.b64encode(text.encode()).decode()} + + def test_empty_allowlist_does_not_make_the_config_incomplete(self): + self.install(routes={ + "allowlist": self._b64(""), + "licenses_all": self._b64('[{"spdx_id": "MIT"}]'), + "permissive": self._b64('["MIT"]'), + }) + config = policy_selector.fetch_shared_config("https://api.invalid", "tok") + self.assertTrue(config["complete"]) + + def test_missing_licence_catalogue_does_make_it_incomplete(self): + self.install(routes={ + "allowlist": self._b64("repos: {}"), + "permissive": self._b64('["MIT"]'), + # licenses_all deliberately absent -> fetch returns None + }) + config = policy_selector.fetch_shared_config("https://api.invalid", "tok") + self.assertFalse(config["complete"]) + + def test_missing_permissive_table_does_make_it_incomplete(self): + self.install(routes={ + "allowlist": self._b64("repos: {}"), + "licenses_all": self._b64('[{"spdx_id": "MIT"}]'), + }) + config = policy_selector.fetch_shared_config("https://api.invalid", "tok") + self.assertFalse(config["complete"]) + + + + +# --------------------------------------------------------------------------- +# Guards that an audit found were NOT pinned by any test +# --------------------------------------------------------------------------- +class TestGuardsThatWereNotPinned(ProcessSinglePrHarness, PolicySelectorTestCase): + """Both guards below were production-correct but unpinned: deleting either + left the suite green, because another code path happened to reach the same + end state. A guard no test can distinguish is a guard the next refactor + silently removes.""" + + def test_unreadable_status_guard_is_reached_and_stops_everything(self): + # Every other process_single_pr test gets a readable status from + # _default_routes, so none of them exercise the caller's guard — + # only the helper's return contract. Override it to None here. + fake = self.run_pr( + routes={"/commits/abc123/status": None}, + paginated_routes={"/issues/5/comments": []}, + ) + self.assertEqual(fake.statuses(), []) + self.assertEqual(fake.posted_comments(), []) + # And prove the guard fired *early*: with the status unreadable we must + # not have gone on to read the registry at all. + registry_reads = [c for c in fake.calls if "/contents/signatures/" in c[1]] + self.assertEqual(registry_reads, []) + + def test_unreadable_registry_guard_is_distinguishable_from_the_parse_guard(self): + # `if not raw_signatures: return` was indistinguishable from letting + # json.loads(None) raise into the except branch — same end state, so + # flipping the guard to `if False:` kept the suite green. A body that + # is present but not JSON separates them: only the parse guard can + # catch that, so this test pins the parse guard... + fake = self.run_pr( + routes={"/contents/signatures/cla.json": { + "content": __import__("base64").b64encode(b"not json at all").decode()}}, + paginated_routes={"/issues/5/comments": []}, + ) + self.assertEqual(fake.statuses(), []) + self.assertEqual(fake.posted_comments(), []) + + def test_absent_registry_is_diagnosed_as_unreadable_not_as_malformed(self): + """The `if not raw_signatures` guard cannot be pinned by end state: with + it disabled, json.loads(None) raises and the parse guard catches it, + reaching the identical outcome. Its distinct value is the diagnosis — an + operator seeing "failed to parse" would go hunting for corrupt JSON when + the real problem is that the fetch failed. So assert on that, and keep + the guard from depending on an accidental TypeError.""" + import contextlib, io + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + self.run_pr( + routes={"/contents/signatures/cla.json": None}, + paginated_routes={"/issues/5/comments": []}, + ) + out = buf.getvalue() + self.assertIn("Could not read signatures/cla.json", out) + self.assertNotIn("Failed to parse", out) + + def test_absent_registry_stops_before_the_comment_scan(self): + # ...and this pins the *first* guard: when the registry cannot be + # fetched at all we must bail before scanning comments. If the first + # guard is removed, json.loads(None) raises immediately and the comment + # scan is likewise skipped — so assert on the ordering signal that + # differs: no comment fetch is attempted either way, but the first + # guard must produce no exception-derived log. Assert the observable + # contract both guards share, plus that we never touched comments. + fake = self.run_pr( + routes={"/contents/signatures/cla.json": None}, + paginated_routes={"/issues/5/comments": [comment(signature_body("CLA"))]}, + ) + self.assertEqual(fake.statuses(), []) + comment_reads = [c for c in fake.calls if c[1].endswith("/comments")] + self.assertEqual(comment_reads, []) + + +# --------------------------------------------------------------------------- +# Gaps found by measuring line coverage of the diff, not by reasoning +# --------------------------------------------------------------------------- +class TestLegacyStringRegistryEntries(ProcessSinglePrHarness, PolicySelectorTestCase): + """signatures/*.json entries are dicts today, but the reader still supports + a bare-string form. That branch had no coverage, so nothing would notice if + it broke — and a repo using the old format would start failing everyone.""" + + def _string_registry(self, names): + import base64 + return {"content": base64.b64encode(json.dumps(names).encode()).decode(), + "sha": "filesha"} + + def test_bare_string_entry_is_recognised(self): + fake = self.run_pr(routes={"/contents/signatures/cla.json": self._string_registry(["contributor"])}) + self.assertEqual([s["state"] for s in fake.statuses()], ["success"]) + self.assertEqual(fake.statuses()[0]["description"], "CLA Signed") + + def test_bare_string_entry_match_is_case_insensitive(self): + fake = self.run_pr(routes={"/contents/signatures/cla.json": self._string_registry(["ConTributor"])}) + self.assertEqual([s["state"] for s in fake.statuses()], ["success"]) + + def test_bare_string_entry_for_a_different_user_does_not_match(self): + fake = self.run_pr( + routes={"/contents/signatures/cla.json": self._string_registry(["someone-else"])}, + paginated_routes={"/issues/5/comments": []}) + self.assertEqual([s["state"] for s in fake.statuses()], ["failure"]) + + +class TestSweeperAbortsOnIncompleteConfig(unittest.TestCase): + """cla_sweeper.main()'s abort guard had ZERO coverage — the widest-blast- + radius guard in the change, since it stops the whole sweep rather than one + PR. Measuring coverage of the diff found this; reasoning about it did not.""" + + def setUp(self): + import cla_sweeper + self.sweeper = cla_sweeper + self.processed = [] + self._saved = { + "paginated": cla_sweeper.github_api_paginated, + "config": policy_selector.fetch_shared_config, + "process": policy_selector.process_single_pr, + "sleep": cla_sweeper.time.sleep, + "ensure": getattr(policy_selector, "ensure_valid_token", None), + } + # cla_sweeper has its OWN paginated helper (different envelope unwrap), + # so it must be stubbed separately from policy_selector's. + cla_sweeper.github_api_paginated = lambda url, token: ( + [{"full_name": "vmware/repo"}] if "installation/repositories" in url + else [{"number": 5, "updated_at": "2099-01-01T00:00:00Z", + "head": {"sha": "abc123"}, "user": {"login": "contributor"}, + "draft": False}]) + policy_selector.process_single_pr = lambda *a, **k: self.processed.append(a) + cla_sweeper.time.sleep = lambda _s: None + policy_selector.ensure_valid_token = lambda: None + os.environ.setdefault("GH_TOKEN", "test-token") + self.addCleanup(self._restore) + + def _restore(self): + self.sweeper.github_api_paginated = self._saved["paginated"] + policy_selector.fetch_shared_config = self._saved["config"] + policy_selector.process_single_pr = self._saved["process"] + self.sweeper.time.sleep = self._saved["sleep"] + if self._saved["ensure"]: + policy_selector.ensure_valid_token = self._saved["ensure"] + + def test_incomplete_config_aborts_before_touching_any_pr(self): + policy_selector.fetch_shared_config = lambda *a, **k: { + "allowlist_data": {}, "allowlist_repos": [], + "licenses_data": [], "permissive_data": [], "complete": False} + self.sweeper.main() + self.assertEqual(self.processed, [], + "sweep must process nothing when policy data is missing") + + def test_complete_config_does_process_prs(self): + # Positive control: without this, the test above would pass even if + # main() were broken in some unrelated way. + policy_selector.fetch_shared_config = lambda *a, **k: { + "allowlist_data": {}, "allowlist_repos": [], + "licenses_data": [{"spdx_id": "MIT"}], "permissive_data": ["MIT"], + "complete": True} + self.sweeper.main() + self.assertEqual(len(self.processed), 1) + + def test_config_without_the_complete_key_still_processes(self): + # Back-compat: a config predating the key must not read as broken. + policy_selector.fetch_shared_config = lambda *a, **k: { + "allowlist_data": {}, "allowlist_repos": [], + "licenses_data": [{"spdx_id": "MIT"}], "permissive_data": ["MIT"]} + self.sweeper.main() + self.assertEqual(len(self.processed), 1) + + if __name__ == "__main__": unittest.main()