Skip to content

Commit fa7eb6c

Browse files
test: add tests for stale PR state cleanup
Covers both scenarios from #398: - Scenario 1: DELETE PRs from repos removed from tracking - Scenario 2: UPDATE pr_state for PRs merged to non-acceptable branches 10 tests: 6 SQL logic tests (SQLite), 4 Python unit tests (mock DB).
1 parent 2a7c9ea commit fa7eb6c

1 file changed

Lines changed: 280 additions & 0 deletions

File tree

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
# The MIT License (MIT)
2+
# Copyright © 2025 Entrius
3+
4+
"""
5+
Tests for stale PR state cleanup (Fixes #398).
6+
7+
Scenario 1: PR records from repos removed from master_repositories should be deleted.
8+
Scenario 2: PRs skipped during evaluation (e.g., merged to non-acceptable branch)
9+
should have their pr_state updated to match GitHub.
10+
"""
11+
12+
import sqlite3
13+
from unittest.mock import MagicMock, patch
14+
15+
import pytest
16+
17+
from gittensor.classes import MinerEvaluation, PRState, PullRequest
18+
from gittensor.validator.storage.queries import CLEANUP_STALE_PULL_REQUESTS, UPDATE_SKIPPED_PR_STATE
19+
20+
21+
# ============================================================================
22+
# SQL Query Tests (using SQLite for portability)
23+
# ============================================================================
24+
25+
26+
@pytest.fixture
27+
def sqlite_db():
28+
"""Create an in-memory SQLite database with the pull_requests schema."""
29+
conn = sqlite3.connect(':memory:')
30+
conn.execute('''
31+
CREATE TABLE pull_requests (
32+
number INTEGER,
33+
repository_full_name TEXT,
34+
uid INTEGER,
35+
hotkey TEXT,
36+
github_id TEXT,
37+
pr_state TEXT,
38+
updated_at TEXT,
39+
UNIQUE(number, repository_full_name)
40+
)
41+
''')
42+
conn.commit()
43+
return conn
44+
45+
46+
def seed_pull_requests(conn, rows):
47+
"""Insert test PR rows: list of (number, repo, uid, hotkey, pr_state)."""
48+
for number, repo, uid, hotkey, state in rows:
49+
conn.execute(
50+
'INSERT INTO pull_requests (number, repository_full_name, uid, hotkey, github_id, pr_state) '
51+
'VALUES (?, ?, ?, ?, ?, ?)',
52+
(number, repo, uid, hotkey, 'gh-123', state),
53+
)
54+
conn.commit()
55+
56+
57+
def get_all_prs(conn):
58+
"""Return all PRs as list of (number, repo, pr_state) tuples."""
59+
return conn.execute(
60+
'SELECT number, repository_full_name, pr_state FROM pull_requests ORDER BY number'
61+
).fetchall()
62+
63+
64+
class TestScenario1_RepoRemovedFromTracking:
65+
"""When a repo is removed from master_repositories.json, its PR records should be deleted."""
66+
67+
def test_deletes_prs_from_removed_repo(self, sqlite_db):
68+
seed_pull_requests(sqlite_db, [
69+
(1, 'tracked/repo-a', 1, 'hk1', 'OPEN'),
70+
(2, 'tracked/repo-b', 1, 'hk1', 'MERGED'),
71+
(3, 'removed/repo-c', 1, 'hk1', 'OPEN'), # should be deleted
72+
(4, 'removed/repo-d', 1, 'hk1', 'MERGED'), # should be deleted
73+
])
74+
75+
active_repos = ('tracked/repo-a', 'tracked/repo-b')
76+
placeholders = ','.join(['?'] * len(active_repos))
77+
sqlite_db.execute(
78+
f'DELETE FROM pull_requests WHERE uid = ? AND hotkey = ? '
79+
f'AND repository_full_name NOT IN ({placeholders})',
80+
(1, 'hk1') + active_repos,
81+
)
82+
sqlite_db.commit()
83+
84+
result = get_all_prs(sqlite_db)
85+
assert len(result) == 2
86+
assert result[0] == (1, 'tracked/repo-a', 'OPEN')
87+
assert result[1] == (2, 'tracked/repo-b', 'MERGED')
88+
89+
def test_does_not_delete_other_miners_prs(self, sqlite_db):
90+
seed_pull_requests(sqlite_db, [
91+
(1, 'removed/repo', 1, 'hk1', 'OPEN'), # miner 1 — should be deleted
92+
(2, 'removed/repo', 2, 'hk2', 'OPEN'), # miner 2 — should NOT be deleted
93+
])
94+
95+
active_repos = ('tracked/repo',)
96+
placeholders = ','.join(['?'] * len(active_repos))
97+
sqlite_db.execute(
98+
f'DELETE FROM pull_requests WHERE uid = ? AND hotkey = ? '
99+
f'AND repository_full_name NOT IN ({placeholders})',
100+
(1, 'hk1') + active_repos,
101+
)
102+
sqlite_db.commit()
103+
104+
result = get_all_prs(sqlite_db)
105+
assert len(result) == 1
106+
assert result[0] == (2, 'removed/repo', 'OPEN')
107+
108+
def test_no_deletion_when_all_repos_tracked(self, sqlite_db):
109+
seed_pull_requests(sqlite_db, [
110+
(1, 'tracked/repo-a', 1, 'hk1', 'OPEN'),
111+
(2, 'tracked/repo-b', 1, 'hk1', 'MERGED'),
112+
])
113+
114+
active_repos = ('tracked/repo-a', 'tracked/repo-b')
115+
placeholders = ','.join(['?'] * len(active_repos))
116+
sqlite_db.execute(
117+
f'DELETE FROM pull_requests WHERE uid = ? AND hotkey = ? '
118+
f'AND repository_full_name NOT IN ({placeholders})',
119+
(1, 'hk1') + active_repos,
120+
)
121+
sqlite_db.commit()
122+
123+
result = get_all_prs(sqlite_db)
124+
assert len(result) == 2
125+
126+
127+
class TestScenario2_MergedToNonAcceptableBranch:
128+
"""When a PR is merged to a non-acceptable branch, pr_state should update in DB."""
129+
130+
def test_updates_open_to_merged(self, sqlite_db):
131+
seed_pull_requests(sqlite_db, [
132+
(1, 'bitcoin/bitcoin', 1, 'hk1', 'OPEN'), # stale — actually MERGED on GitHub
133+
])
134+
135+
sqlite_db.execute(
136+
'UPDATE pull_requests SET pr_state = ?, updated_at = datetime("now") '
137+
'WHERE number = ? AND repository_full_name = ? AND pr_state != ?',
138+
('MERGED', 1, 'bitcoin/bitcoin', 'MERGED'),
139+
)
140+
sqlite_db.commit()
141+
142+
result = get_all_prs(sqlite_db)
143+
assert result[0] == (1, 'bitcoin/bitcoin', 'MERGED')
144+
145+
def test_no_update_when_state_already_correct(self, sqlite_db):
146+
seed_pull_requests(sqlite_db, [
147+
(1, 'bitcoin/bitcoin', 1, 'hk1', 'MERGED'), # already correct
148+
])
149+
150+
cursor = sqlite_db.execute(
151+
'UPDATE pull_requests SET pr_state = ?, updated_at = datetime("now") '
152+
'WHERE number = ? AND repository_full_name = ? AND pr_state != ?',
153+
('MERGED', 1, 'bitcoin/bitcoin', 'MERGED'),
154+
)
155+
sqlite_db.commit()
156+
157+
assert cursor.rowcount == 0 # no rows updated
158+
159+
def test_updates_multiple_skipped_prs(self, sqlite_db):
160+
seed_pull_requests(sqlite_db, [
161+
(1, 'bitcoin/bitcoin', 1, 'hk1', 'OPEN'),
162+
(2, 'entrius/gittensor', 1, 'hk1', 'OPEN'),
163+
(3, 'entrius/gittensor', 1, 'hk1', 'MERGED'), # already correct
164+
])
165+
166+
state_updates = [
167+
(1, 'bitcoin/bitcoin', 'MERGED'),
168+
(2, 'entrius/gittensor', 'MERGED'),
169+
(3, 'entrius/gittensor', 'MERGED'), # no-op
170+
]
171+
for pr_number, repo, state in state_updates:
172+
sqlite_db.execute(
173+
'UPDATE pull_requests SET pr_state = ?, updated_at = datetime("now") '
174+
'WHERE number = ? AND repository_full_name = ? AND pr_state != ?',
175+
(state, pr_number, repo, state),
176+
)
177+
sqlite_db.commit()
178+
179+
result = get_all_prs(sqlite_db)
180+
assert result[0] == (1, 'bitcoin/bitcoin', 'MERGED')
181+
assert result[1] == (2, 'entrius/gittensor', 'MERGED')
182+
assert result[2] == (3, 'entrius/gittensor', 'MERGED')
183+
184+
185+
# ============================================================================
186+
# Integration Tests (MinerEvaluation + skipped_pr_state_updates)
187+
# ============================================================================
188+
189+
190+
class TestSkippedPrStateTracking:
191+
"""Test that skipped PRs are correctly tracked on MinerEvaluation."""
192+
193+
def test_skipped_pr_state_updates_initialized_empty(self):
194+
miner_eval = MinerEvaluation(uid=1, hotkey='hk1')
195+
assert miner_eval.skipped_pr_state_updates == []
196+
197+
def test_append_skipped_pr(self):
198+
miner_eval = MinerEvaluation(uid=1, hotkey='hk1')
199+
miner_eval.skipped_pr_state_updates.append((42, 'bitcoin/bitcoin', 'MERGED'))
200+
assert len(miner_eval.skipped_pr_state_updates) == 1
201+
assert miner_eval.skipped_pr_state_updates[0] == (42, 'bitcoin/bitcoin', 'MERGED')
202+
203+
def test_multiple_skipped_prs(self):
204+
miner_eval = MinerEvaluation(uid=1, hotkey='hk1')
205+
miner_eval.skipped_pr_state_updates.append((1, 'repo/a', 'MERGED'))
206+
miner_eval.skipped_pr_state_updates.append((2, 'repo/b', 'MERGED'))
207+
miner_eval.skipped_pr_state_updates.append((3, 'repo/c', 'CLOSED'))
208+
assert len(miner_eval.skipped_pr_state_updates) == 3
209+
210+
211+
# ============================================================================
212+
# Repository Method Tests (mocked DB)
213+
# ============================================================================
214+
215+
216+
class TestRepositoryCleanupMethods:
217+
"""Test Repository.cleanup_stale_pull_requests and update_skipped_pr_states."""
218+
219+
def test_cleanup_stale_pull_requests_builds_correct_query(self):
220+
from gittensor.validator.storage.repository import Repository
221+
222+
mock_db = MagicMock()
223+
repo = Repository(mock_db)
224+
225+
active_repos = ('bitcoin/bitcoin', 'entrius/gittensor', 'opentensor/subtensor')
226+
227+
with patch.object(repo, 'execute_command', return_value=True) as mock_exec:
228+
result = repo.cleanup_stale_pull_requests(uid=1, hotkey='hk1', active_repos=active_repos)
229+
230+
assert result is True
231+
mock_exec.assert_called_once()
232+
call_args = mock_exec.call_args
233+
query = call_args[0][0]
234+
params = call_args[0][1]
235+
236+
# Query should have 3 placeholders for repos + 2 for uid/hotkey
237+
assert 'NOT IN (%s,%s,%s)' in query
238+
assert params == (1, 'hk1', 'bitcoin/bitcoin', 'entrius/gittensor', 'opentensor/subtensor')
239+
240+
def test_cleanup_stale_pull_requests_empty_repos_returns_false(self):
241+
from gittensor.validator.storage.repository import Repository
242+
243+
mock_db = MagicMock()
244+
repo = Repository(mock_db)
245+
246+
result = repo.cleanup_stale_pull_requests(uid=1, hotkey='hk1', active_repos=())
247+
assert result is False
248+
249+
def test_update_skipped_pr_states_calls_execute_per_pr(self):
250+
from gittensor.validator.storage.repository import Repository
251+
252+
mock_db = MagicMock()
253+
repo = Repository(mock_db)
254+
255+
state_updates = [
256+
(1, 'bitcoin/bitcoin', 'MERGED'),
257+
(2, 'entrius/gittensor', 'MERGED'),
258+
]
259+
260+
with patch.object(repo, 'execute_command', return_value=True) as mock_exec:
261+
count = repo.update_skipped_pr_states(state_updates)
262+
263+
assert count == 2
264+
assert mock_exec.call_count == 2
265+
266+
# Verify params for first call: (state, number, repo, state)
267+
first_call_params = mock_exec.call_args_list[0][0][1]
268+
assert first_call_params == ('MERGED', 1, 'bitcoin/bitcoin', 'MERGED')
269+
270+
def test_update_skipped_pr_states_empty_list(self):
271+
from gittensor.validator.storage.repository import Repository
272+
273+
mock_db = MagicMock()
274+
repo = Repository(mock_db)
275+
276+
with patch.object(repo, 'execute_command') as mock_exec:
277+
count = repo.update_skipped_pr_states([])
278+
279+
assert count == 0
280+
mock_exec.assert_not_called()

0 commit comments

Comments
 (0)