diff --git a/ci/ci/ci.py b/ci/ci/ci.py index 3a7ef1d9d0e..bb99962cd00 100644 --- a/ci/ci/ci.py +++ b/ci/ci/ci.py @@ -7,7 +7,7 @@ from collections import defaultdict from contextlib import AsyncExitStack from datetime import timezone -from typing import Any, Callable, Dict, List, NoReturn, Optional, Set, Tuple, TypedDict +from typing import Any, Callable, Dict, List, NoReturn, Optional, Set, Tuple, TypedDict, Union import aiohttp_session # type: ignore import gidgethub @@ -598,7 +598,7 @@ async def batch_callback_handler(request: web.Request): async def deploy_status(request: web.Request, _) -> web.Response: batch_client = request.app[AppKeys.BATCH_CLIENT] - async def get_failure_information(batch): + async def get_failure_information(batch: Union[Batch, MergeFailureBatch]) -> Any: if isinstance(batch, MergeFailureBatch): exc = batch.exception return traceback.format_exception(type(exc), value=exc, tb=exc.__traceback__) @@ -612,19 +612,22 @@ async def fetch_job_and_log(j): return await asyncio.gather(*[fetch_job_and_log(j) for j in jobs if j['state'] in ('Error', 'Failed')]) - wb_configs = [ - { + async def wb_config(wb: WatchedBranch) -> Dict[str, Any]: + if wb.deploy_state in ('failure', 'checkout_failure'): + assert wb.deploy_batch is not None + failure_information = await get_failure_information(wb.deploy_batch) + else: + failure_information = None + return { 'branch': wb.branch.short_str(), 'sha': wb.sha, 'deploy_batch_id': wb.deploy_batch.id if wb.deploy_batch and isinstance(wb.deploy_batch, Batch) else None, 'deploy_state': wb.deploy_state, 'repo': wb.branch.repo.short_str(), - 'failure_information': None - if wb.deploy_state == 'success' - else await get_failure_information(wb.deploy_batch), + 'failure_information': failure_information, } - for wb in watched_branches - ] + + wb_configs = [await wb_config(wb) for wb in watched_branches] return json_response(wb_configs) @@ -1045,7 +1048,8 @@ async def on_startup(app: web.Application): exit_stack = AsyncExitStack() app[AppKeys.EXIT_STACK] = exit_stack - client_session = httpx.client_session() + # 60s timeout: bulk GitHub GraphQL PR status fetches can take 10-30s. + client_session = httpx.client_session(timeout=60) exit_stack.push_async_callback(client_session.close) app[AppKeys.CLIENT_SESSION] = client_session diff --git a/ci/ci/github.py b/ci/ci/github.py index 93b8e8c1bf7..54e346ce59b 100644 --- a/ci/ci/github.py +++ b/ci/ci/github.py @@ -6,8 +6,9 @@ import os import random import secrets +import time from shlex import quote as shq -from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Union +from typing import Any, Dict, Iterable, List, Optional, Protocol, Sequence, Set, Union import aiohttp import aiohttp.client_exceptions @@ -41,6 +42,10 @@ zulip_client = zulip.Client(config_file="/zulip-config/.zuliprc") TRACKED_PRS = pc.Gauge('ci_tracked_prs', 'PRs currently being monitored by CI', ['build_state', 'review_state']) +WATCHED_BRANCH_UPDATE_LATENCY = pc.Gauge( + 'ci_watched_branch_update_latency_seconds', + 'Duration of the most recent WatchedBranch update cycle', +) MAX_CONCURRENT_PR_BATCHES = 3 @@ -270,8 +275,8 @@ def __init__( self.developers = developers def set_build_state(self, build_state): - log.info(f'{self.short_str()}: Build state changing from {self.build_state} => {build_state}') if build_state != self.build_state: + log.info(f'{self.short_str()}: build state changing: {self.build_state} => {build_state}') self.decrement_pr_metric() self.build_state = build_state self.increment_pr_metric() @@ -431,7 +436,10 @@ async def assign_gh_reviewer_if_requested(self, gh_client): assignees.add(select_random_teammate(SERVICES_TEAM).gh_username) if ASSIGN_COMPILER in self.body: assignees.add(select_random_teammate(COMPILER_TEAM).gh_username) + if not assignees: + return data = {'assignees': list(assignees)} + log.info(f'{self.short_str()}: assigning reviewers: {data}') try: await gh_client.post( f'/repos/{self.target_branch.branch.repo.short_str()}/issues/{self.number}/assignees', data=data @@ -441,72 +449,7 @@ async def assign_gh_reviewer_if_requested(self, gh_client): except aiohttp.client_exceptions.ClientResponseError: log.exception(f'{self.short_str()}: Unexpected exception in post to github: {data}') - async def _update_github(self, gh): - results = [] - cursor = None - review_decision = None - - def query(): - return f""" - query {{ - repository ( - owner: "{self.target_branch.branch.repo.owner}", - name: "{self.target_branch.branch.repo.name}" - ) {{ - pullRequest (number: {self.number}) {{ - reviewDecision - commits (last: 1) {{ - nodes {{ - commit {{ - statusCheckRollup {{ - contexts (first: 10{f', after: "{cursor}"' if cursor is not None else ''}) {{ - nodes {{ - __typename - ... on CheckRun {{ - name - conclusion - isRequired (pullRequestNumber: {self.number}) - }} - ... on StatusContext {{ - context - state - isRequired (pullRequestNumber: {self.number}) - }} - }} - pageInfo {{ - endCursor - hasNextPage - }} - }} - }} - }} - }} - }} - }} - }} - }} - """ - - def review_decision_and_commit_status(pull_request, rollup): - nonlocal review_decision - if review_decision is None: - review_decision = ( - pull_request["reviewDecision"] if pull_request["reviewDecision"] is not None else "API_NONE" - ) - if rollup is not None: - results.extend(rollup["contexts"]["nodes"]) - - while ( - rollup := ( - pull_request := (await gh.post("/graphql", data={"query": query()}))["data"]["repository"][ - "pullRequest" - ] - )["commits"]["nodes"][0]["commit"]["statusCheckRollup"] - ) is not None and rollup["contexts"]["pageInfo"]["hasNextPage"]: - cursor = rollup["contexts"]["pageInfo"]["endCursor"] - review_decision_and_commit_status(pull_request, rollup) - review_decision_and_commit_status(pull_request, rollup) - + def _apply_github_data(self, review_decision: str, check_nodes: List[Dict[str, Any]]): if review_decision == 'APPROVED': review_state = 'approved' elif review_decision == 'CHANGES_REQUESTED': @@ -528,7 +471,7 @@ def review_decision_and_commit_status(pull_request, rollup): self.target_branch.state_changed = True last_known_github_status = {} - for check in results: + for check in check_nodes: if check["isRequired"]: if (typename := check["__typename"]) == "StatusContext": last_known_github_status[check["context"]] = github_status(check["state"]) @@ -745,6 +688,100 @@ def checkout_script(self): """ +class _GitHubGraphQL(Protocol): + async def post(self, url: str, *, data: Any) -> Any: ... + + +_GITHUB_GRAPHQL_PR_CHUNK_SIZE = 100 + + +async def _fetch_pr_github_data( + gh: _GitHubGraphQL, + owner: str, + repo_name: str, + pr_numbers: List[int], +) -> Dict[int, tuple]: + """Fetch review decisions and status check rollup for all PRs, batched in chunks. + + Uses GraphQL aliases (pr_N: pullRequest(number: N) {{ ... }}) to reduce round-trips. + Chunks requests to avoid GitHub query complexity timeouts (~2s per 20-PR chunk vs ~6s for 56). + Paginates if any PR has more than 20 check contexts, which is not expected in practice. + + Returns dict mapping pr_number -> (review_decision: str, check_nodes: List[dict]). + review_decision is the raw GraphQL PullRequestReviewDecision value, or "API_NONE" when null. + """ + accumulated_nodes: Dict[int, List[Dict[str, Any]]] = {n: [] for n in pr_numbers} + review_decisions: Dict[int, Optional[str]] = {n: None for n in pr_numbers} + + chunks = [ + pr_numbers[i : i + _GITHUB_GRAPHQL_PR_CHUNK_SIZE] + for i in range(0, len(pr_numbers), _GITHUB_GRAPHQL_PR_CHUNK_SIZE) + ] + for chunk in chunks: + # remaining maps pr_number -> cursor (None = first page) + remaining: Dict[int, Optional[str]] = {n: None for n in chunk} + + while remaining: + + def build_query(remaining: Dict[int, Optional[str]]) -> str: + aliases = [] + for pr_number, cursor in remaining.items(): + cursor_arg = f', after: "{cursor}"' if cursor is not None else '' + aliases.append(f""" + pr_{pr_number}: pullRequest(number: {pr_number}) {{ + reviewDecision + commits(last: 1) {{ + nodes {{ + commit {{ + statusCheckRollup {{ + contexts(first: 20{cursor_arg}) {{ + nodes {{ + __typename + ... on CheckRun {{ + name + conclusion + isRequired(pullRequestNumber: {pr_number}) + }} + ... on StatusContext {{ + context + state + isRequired(pullRequestNumber: {pr_number}) + }} + }} + pageInfo {{ + endCursor + hasNextPage + }} + }} + }} + }} + }} + }} + }} + """) + return f'query {{ repository(owner: "{owner}", name: "{repo_name}") {{ {"".join(aliases)} }} }}' + + repo_data = (await gh.post("/graphql", data={"query": build_query(remaining)}))["data"]["repository"] + + next_remaining: Dict[int, Optional[str]] = {} + for pr_number in list(remaining.keys()): + pr_data = repo_data[f"pr_{pr_number}"] + if review_decisions[pr_number] is None: + raw = pr_data["reviewDecision"] + review_decisions[pr_number] = raw if raw is not None else "API_NONE" + rollup = pr_data["commits"]["nodes"][0]["commit"]["statusCheckRollup"] + if rollup is not None: + accumulated_nodes[pr_number].extend(rollup["contexts"]["nodes"]) + if rollup["contexts"]["pageInfo"]["hasNextPage"]: + next_remaining[pr_number] = rollup["contexts"]["pageInfo"]["endCursor"] + + if next_remaining: + log.warning(f'_fetch_pr_github_data: pagination required for PRs {list(next_remaining.keys())}') + remaining = next_remaining + + return {n: (review_decisions[n] or "API_NONE", accumulated_nodes[n]) for n in pr_numbers} + + class WatchedBranch(Code): def __init__( self, @@ -828,6 +865,7 @@ async def _update(self, db: Database, batch_client: BatchClient, gh: gh_aiohttp. log.info(f'already updating {self.short_str()}') return + t_update_start = time.monotonic() try: log.info(f'start update {self.short_str()}') self.updating = True @@ -847,7 +885,9 @@ async def _update(self, db: Database, batch_client: BatchClient, gh: gh_aiohttp. if (self.deploy_batch is None or self.deploy_state is not None) and not frozen and self.mergeable: await self.try_to_merge(gh) finally: - log.info(f'update done {self.short_str()}') + t_total = time.monotonic() - t_update_start + WATCHED_BRANCH_UPDATE_LATENCY.set(t_total) + log.info(f'update done {self.short_str()} in {t_total:.1f}s') self.updating = False async def try_to_merge(self, gh): @@ -890,8 +930,33 @@ async def _update_github(self, gh): for pr in new_prs.values(): await pr.assign_gh_reviewer_if_requested(gh) - for pr in new_prs.values(): - await pr._update_github(gh) + if new_prs: + pr_github_data = await _fetch_pr_github_data( + gh, self.branch.repo.owner, self.branch.repo.name, list(new_prs.keys()) + ) + + summary = [] + for pr_number, (review_decision, check_nodes) in pr_github_data.items(): + required_nodes = [c for c in check_nodes if c["isRequired"]] + required_statuses = [ + github_status(c["conclusion"] if c["__typename"] == "CheckRun" else c["state"]) + for c in required_nodes + ] + if any(s == GithubStatus.FAILURE for s in required_statuses): + check_decision = 'failing' + elif required_statuses and all(s == GithubStatus.SUCCESS for s in required_statuses): + check_decision = 'passing' + else: + check_decision = 'pending' + summary.append((pr_number, review_decision, len(check_nodes), len(required_nodes), check_decision)) + log.info( + f'update github {self.short_str()}: PR data fetched ' + f'(pr_number, review_decision, total_checks, required_checks, check_decision): {summary}' + ) + + for pr in new_prs.values(): + review_decision, check_nodes = pr_github_data[pr.number] + pr._apply_github_data(review_decision, check_nodes) async def _update_deploy(self, batch_client, db: Database): assert self.deployable diff --git a/ci/ci/utils.py b/ci/ci/utils.py index 730d7f15ead..48332614e8b 100644 --- a/ci/ci/utils.py +++ b/ci/ci/utils.py @@ -131,12 +131,14 @@ class GithubStatus(Enum): FAILURE = 'failure' -def github_status(state: str) -> GithubStatus: +def github_status(state: Optional[str]) -> GithubStatus: """ Converts a state for a commit status (https://docs.github.com/en/graphql/reference/enums#statusstate) or a conclusion for a check (https://docs.github.com/en/graphql/reference/enums#checkconclusionstate) from the GraphQL API to a GithubStatus. """ + if state is None: + return GithubStatus.PENDING if state in {"PENDING", "EXPECTED", "ACTION_REQUIRED", "STALE"}: return GithubStatus.PENDING if state in {"FAILURE", "ERROR", "TIMED_OUT", "CANCELLED", "STARTUP_FAILURE", "SKIPPED"}: diff --git a/ci/unit-test/conftest.py b/ci/unit-test/conftest.py new file mode 100644 index 00000000000..79f5e2ba7b2 --- /dev/null +++ b/ci/unit-test/conftest.py @@ -0,0 +1,40 @@ +"""Configure the unit-test environment before any ci.* modules are imported. + +In CI these values come from the real deployment environment (env vars + /global-config volume +mount). Locally they don't exist, so we set safe defaults and mock the config reader. +""" + +import atexit +import json +import os +import unittest.mock + +# Env vars read at module-import time by gear.profiling and ci.environment. +# setdefault leaves real CI values in place. +os.environ.setdefault('HAIL_SHA', 'test-sha') +os.environ.setdefault('HAIL_DEFAULT_NAMESPACE', 'default') +os.environ.setdefault('CLOUD', 'gcp') +os.environ.setdefault('HAIL_CI_UTILS_IMAGE', 'gcr.io/hail-vdc/ci-utils:test') +os.environ.setdefault('HAIL_BUILDKIT_IMAGE', 'gcr.io/hail-vdc/buildkit:test') +os.environ.setdefault('HAIL_CI_STORAGE_URI', 'gs://hail-ci-test/build') +os.environ.setdefault('HAIL_CI_GITHUB_CONTEXT', 'ci-test') + +if not os.path.exists('/global-config'): + # Patch gear.cloud_config.read_config_secret so that ci.environment's module-level + # get_global_config() call (and the subsequent get_gcp_config() call) succeed locally. + _fake_global_config = { + 'cloud': 'gcp', + 'docker_prefix': 'gcr.io/hail-vdc', + 'docker_root_image': 'ubuntu:22.04', + 'domain': 'hail.is', + 'kubernetes_server_url': 'https://k8s.example.com', + 'default_namespace': 'default', + # Fields required by GCPConfig.from_global_config + 'batch_gcp_regions': json.dumps(['us-central1']), + 'gcp_region': 'us-central1', + 'gcp_project': 'hail-vdc', + 'gcp_zone': 'us-central1-a', + } + _patcher = unittest.mock.patch('gear.cloud_config.read_config_secret', return_value=_fake_global_config) + _patcher.start() + atexit.register(_patcher.stop) diff --git a/ci/unit-test/pytest.ini b/ci/unit-test/pytest.ini new file mode 100644 index 00000000000..2f4c80e3075 --- /dev/null +++ b/ci/unit-test/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +asyncio_mode = auto diff --git a/ci/unit-test/test_github_unit.py b/ci/unit-test/test_github_unit.py new file mode 100644 index 00000000000..2020486cdb0 --- /dev/null +++ b/ci/unit-test/test_github_unit.py @@ -0,0 +1,277 @@ +"""Unit tests for _fetch_pr_github_data and PR._apply_github_data. + +These mock out the GitHub API client so no network access is needed. +""" + +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from ci.github import PR, _fetch_pr_github_data +from ci.utils import GithubStatus + +# ── Mock helpers ───────────────────────────────────────────────────────────── + + +class MockGH: + """Records calls and returns pre-canned responses in order.""" + + def __init__(self, responses): + self._responses = list(responses) + self.call_count = 0 + + async def post(self, url: str, *, data: Any) -> Any: # pylint: disable=unused-argument + assert self.call_count < len(self._responses), ( + f'Unexpected API call #{self.call_count + 1} (only {len(self._responses)} response(s) queued)' + ) + response = self._responses[self.call_count] + self.call_count += 1 + return response + + +def graphql_response(pr_data_by_number: dict) -> dict: + return {"data": {"repository": {f"pr_{n}": data for n, data in pr_data_by_number.items()}}} + + +def pr_response(review_decision, check_nodes, *, has_next_page=False, end_cursor=None): + """Minimal pullRequest fragment matching the shape _fetch_pr_github_data expects.""" + return { + "reviewDecision": review_decision, + "commits": { + "nodes": [ + { + "commit": { + "statusCheckRollup": { + "contexts": { + "nodes": check_nodes, + "pageInfo": {"hasNextPage": has_next_page, "endCursor": end_cursor}, + } + } + } + } + ] + }, + } + + +def pr_response_no_rollup(review_decision): + """PR with no CI runs yet — statusCheckRollup is null.""" + return { + "reviewDecision": review_decision, + "commits": {"nodes": [{"commit": {"statusCheckRollup": None}}]}, + } + + +def check_run(name, conclusion, *, is_required=True): + return {"__typename": "CheckRun", "name": name, "conclusion": conclusion, "isRequired": is_required} + + +def status_context(context, state, *, is_required=True): + return {"__typename": "StatusContext", "context": context, "state": state, "isRequired": is_required} + + +def make_pr(number=123): + """Construct a minimal PR with a MagicMock target_branch, bypassing prometheus metrics.""" + tb = MagicMock() + tb.state_changed = False + tb.batch_changed = False + with patch('ci.github.TRACKED_PRS'): + pr = PR( + number=number, + title='Test PR', + body='', + source_branch=MagicMock(), + source_sha='deadbeef', + target_branch=tb, + author='testuser', + assignees=set(), + reviewers=set(), + labels=set(), + developers=[], + ) + tb.state_changed = False # reset; constructor sets it + return pr + + +# ── _fetch_pr_github_data ───────────────────────────────────────────────────── + + +async def test_single_pr_no_pagination(): + nodes = [check_run('ci-test', 'SUCCESS'), status_context('lint', 'SUCCESS', is_required=False)] + gh = MockGH([graphql_response({123: pr_response('APPROVED', nodes)})]) + + result = await _fetch_pr_github_data(gh, 'hail-is', 'hail', [123]) + + assert gh.call_count == 1 + review_decision, check_nodes = result[123] + assert review_decision == 'APPROVED' + assert check_nodes == nodes + + +async def test_multiple_prs_in_single_request(): + nodes_a = [check_run('ci-test', 'SUCCESS')] + nodes_b = [check_run('ci-test', 'FAILURE')] + gh = MockGH([ + graphql_response({ + 1: pr_response('APPROVED', nodes_a), + 2: pr_response('REVIEW_REQUIRED', nodes_b), + }) + ]) + + result = await _fetch_pr_github_data(gh, 'hail-is', 'hail', [1, 2]) + + assert gh.call_count == 1 + assert result[1] == ('APPROVED', nodes_a) + assert result[2] == ('REVIEW_REQUIRED', nodes_b) + + +async def test_pagination_fires_multiple_requests_and_accumulates_nodes(): + page1 = [check_run('check-a', 'SUCCESS')] + page2 = [check_run('check-b', 'FAILURE')] + page3 = [check_run('check-c', 'SUCCESS')] + + gh = MockGH([ + graphql_response({42: pr_response('APPROVED', page1, has_next_page=True, end_cursor='cursor-1')}), + # reviewDecision is ignored on subsequent pages (already captured from page 1) + graphql_response({42: pr_response(None, page2, has_next_page=True, end_cursor='cursor-2')}), + graphql_response({42: pr_response(None, page3, has_next_page=False)}), + ]) + + result = await _fetch_pr_github_data(gh, 'hail-is', 'hail', [42]) + + assert gh.call_count == 3 + review_decision, check_nodes = result[42] + assert review_decision == 'APPROVED' + assert check_nodes == page1 + page2 + page3 + + +async def test_pagination_only_re_requests_prs_that_need_it(): + """PRs that finish on page 1 are dropped from subsequent requests; only lagging PRs continue.""" + page1_pr1 = [check_run('check-a', 'SUCCESS')] + page2_pr1 = [check_run('check-b', 'SUCCESS')] + page3_pr1 = [check_run('check-c', 'SUCCESS')] + nodes_pr2 = [check_run('check-a', 'FAILURE')] + + # Request 1: both PRs. PR 2 finishes; PR 1 needs 2 more pages. + # Requests 2 and 3: only PR 1 — mock would error if PR 2 appeared again. + gh = MockGH([ + graphql_response({ + 1: pr_response('APPROVED', page1_pr1, has_next_page=True, end_cursor='cur-1'), + 2: pr_response('REVIEW_REQUIRED', nodes_pr2, has_next_page=False), + }), + graphql_response({1: pr_response(None, page2_pr1, has_next_page=True, end_cursor='cur-2')}), + graphql_response({1: pr_response(None, page3_pr1, has_next_page=False)}), + ]) + + result = await _fetch_pr_github_data(gh, 'hail-is', 'hail', [1, 2]) + + assert gh.call_count == 3 + assert result[1] == ('APPROVED', page1_pr1 + page2_pr1 + page3_pr1) + assert result[2] == ('REVIEW_REQUIRED', nodes_pr2) + + +async def test_null_review_decision_becomes_api_none(): + gh = MockGH([graphql_response({5: pr_response(None, [])})]) + + result = await _fetch_pr_github_data(gh, 'hail-is', 'hail', [5]) + + assert result[5][0] == 'API_NONE' + + +async def test_null_status_rollup_returns_empty_nodes(): + gh = MockGH([graphql_response({7: pr_response_no_rollup('REVIEW_REQUIRED')})]) + + result = await _fetch_pr_github_data(gh, 'hail-is', 'hail', [7]) + + review_decision, check_nodes = result[7] + assert review_decision == 'REVIEW_REQUIRED' + assert check_nodes == [] + + +async def test_empty_pr_list_makes_no_request(): + gh = MockGH([]) + + result = await _fetch_pr_github_data(gh, 'hail-is', 'hail', []) + + assert result == {} + assert gh.call_count == 0 + + +# ── PR._apply_github_data ───────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + 'review_decision,expected_state', + [ + ('APPROVED', 'approved'), + ('CHANGES_REQUESTED', 'changes_requested'), + ('REVIEW_REQUIRED', 'pending'), + ('API_NONE', 'pending'), + ], +) +def test_review_decision_mapping(review_decision, expected_state): + pr = make_pr() + with patch('ci.github.TRACKED_PRS'): + pr._apply_github_data(review_decision, []) + assert pr.review_state == expected_state + assert pr.target_branch.state_changed is True + + +def test_review_state_unchanged_does_not_set_state_changed(): + pr = make_pr() + with patch('ci.github.TRACKED_PRS'): + pr._apply_github_data('APPROVED', []) + pr.target_branch.state_changed = False + pr._apply_github_data('APPROVED', []) + assert pr.target_branch.state_changed is False + + +def test_required_check_run_appears_in_status(): + pr = make_pr() + with patch('ci.github.TRACKED_PRS'): + pr._apply_github_data('APPROVED', [check_run('ci-test', 'SUCCESS', is_required=True)]) + assert pr.last_known_github_status == {'ci-test': GithubStatus.SUCCESS} + + +def test_required_status_context_appears_in_status(): + pr = make_pr() + with patch('ci.github.TRACKED_PRS'): + pr._apply_github_data('APPROVED', [status_context('ci-status', 'FAILURE', is_required=True)]) + assert pr.last_known_github_status == {'ci-status': GithubStatus.FAILURE} + + +def test_non_required_checks_are_ignored(): + pr = make_pr() + nodes = [ + check_run('required', 'SUCCESS', is_required=True), + check_run('optional', 'FAILURE', is_required=False), + status_context('optional-status', 'FAILURE', is_required=False), + ] + with patch('ci.github.TRACKED_PRS'): + pr._apply_github_data('APPROVED', nodes) + assert list(pr.last_known_github_status.keys()) == ['required'] + + +def test_github_status_unchanged_does_not_set_state_changed(): + pr = make_pr() + nodes = [check_run('ci-test', 'SUCCESS', is_required=True)] + with patch('ci.github.TRACKED_PRS'): + pr._apply_github_data('APPROVED', nodes) + pr.target_branch.state_changed = False + pr._apply_github_data('APPROVED', nodes) + assert pr.target_branch.state_changed is False + + +def test_check_run_failure_status(): + pr = make_pr() + with patch('ci.github.TRACKED_PRS'): + pr._apply_github_data('APPROVED', [check_run('ci-test', 'FAILURE', is_required=True)]) + assert pr.last_known_github_status['ci-test'] == GithubStatus.FAILURE + + +def test_check_run_pending_conclusion(): + pr = make_pr() + with patch('ci.github.TRACKED_PRS'): + pr._apply_github_data('APPROVED', [check_run('ci-test', 'ACTION_REQUIRED', is_required=True)]) + assert pr.last_known_github_status['ci-test'] == GithubStatus.PENDING