Skip to content

Commit d2e437a

Browse files
committed
Use SecretStr for sensitive fields in configuration and GitHubPoller, removing custom masking logic. Update tests accordingly.
1 parent 6518095 commit d2e437a

5 files changed

Lines changed: 38 additions & 63 deletions

File tree

foreman/__main__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ def _run_start(args: Any) -> None:
116116
memory = MemoryStore(db_path)
117117

118118
# 3. Create core components.
119-
poller = GitHubPoller(token=str(config.identity.github_token), memory=memory)
119+
poller = GitHubPoller(token=config.identity.github_token, memory=memory)
120120
dispatcher = Dispatcher(config=config, memory=memory)
121121

122122
logger.info(

foreman/config.py

Lines changed: 3 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from typing import Any, Optional
1313

1414
import yaml
15-
from pydantic import BaseModel, field_validator, model_validator
15+
from pydantic import BaseModel, SecretStr, model_validator
1616

1717
_ENV_REF_RE = re.compile(r"\$\{([^}]+)\}")
1818

@@ -67,23 +67,6 @@ def _resolve_refs_in(obj: Any) -> Any:
6767
return obj
6868

6969

70-
# ---------------------------------------------------------------------------
71-
# Secret-masking string type
72-
# ---------------------------------------------------------------------------
73-
74-
75-
class _SecretStr(str):
76-
"""A string subclass that masks its value in repr and str output."""
77-
78-
def __repr__(self) -> str:
79-
"""Return masked representation."""
80-
return "'**********'"
81-
82-
def __str__(self) -> str:
83-
"""Return masked string value."""
84-
return "**********"
85-
86-
8770
# ---------------------------------------------------------------------------
8871
# Pydantic models
8972
# ---------------------------------------------------------------------------
@@ -92,18 +75,12 @@ def __str__(self) -> str:
9275
class IdentityConfig(BaseModel):
9376
"""Bot GitHub identity configuration."""
9477

95-
github_token: str
78+
github_token: SecretStr
9679
"""GitHub Personal Access Token for the bot account."""
9780

9881
github_user: str
9982
"""GitHub username of the bot account."""
10083

101-
@field_validator("github_token", mode="after")
102-
@classmethod
103-
def mask_token(cls, v: str) -> str:
104-
"""Wrap the token in a secret-masking string."""
105-
return _SecretStr(v)
106-
10784

10885
class LLMConfig(BaseModel):
10986
"""LLM backend configuration."""
@@ -114,15 +91,9 @@ class LLMConfig(BaseModel):
11491
model: str
11592
"""Model name / identifier."""
11693

117-
api_key: Optional[str] = None
94+
api_key: Optional[SecretStr] = None
11895
"""API key; omit for local providers such as Ollama."""
11996

120-
@field_validator("api_key", mode="after")
121-
@classmethod
122-
def mask_api_key(cls, v: Optional[str]) -> Optional[str]:
123-
"""Wrap the API key in a secret-masking string if present."""
124-
return _SecretStr(v) if v is not None else None
125-
12697

12798
class PollingConfig(BaseModel):
12899
"""GitHub polling configuration."""

foreman/poller.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Asyncio polling loop for GitHub repositories.
22
3-
Polls all configured repositories concurrently, bounded by a semaphore to
3+
Polls all configured repositories concurrently, bounded by semaphore to
44
avoid GitHub API rate limits. Issues authored by repo collaborators are
55
skipped by default. Exponential backoff is applied on 403/429 responses.
66
"""
@@ -12,21 +12,23 @@
1212
from typing import TYPE_CHECKING, Any
1313

1414
import structlog
15-
from github import Github, GithubException
15+
from github import Auth, Github, GithubException
1616

1717
if TYPE_CHECKING:
1818
from collections.abc import Awaitable, Callable
1919

20+
from pydantic import SecretStr
21+
2022
from foreman.config import RepoConfig
2123
from foreman.memory import MemoryStore
2224

2325
logger = structlog.get_logger(__name__)
2426

25-
#: Default maximum number of repos polled at the same time.
2627
_DEFAULT_MAX_CONCURRENT = 5
28+
"""Default maximum number of repos polled at the same time."""
2729

28-
#: Initial backoff delay in seconds on rate-limit errors.
2930
_BACKOFF_BASE_SECONDS = 10.0
31+
"""Initial backoff delay in seconds on rate-limit errors."""
3032

3133

3234
class GitHubPoller:
@@ -42,8 +44,9 @@ class GitHubPoller:
4244
max_concurrent: Maximum number of repos polled simultaneously.
4345
"""
4446

45-
def __init__(self, token: str, memory: MemoryStore, max_concurrent: int = _DEFAULT_MAX_CONCURRENT) -> None:
46-
self._github = Github(token)
47+
def __init__(self, token: SecretStr, memory: MemoryStore, max_concurrent: int = _DEFAULT_MAX_CONCURRENT) -> None:
48+
auth = Auth.Token(token.get_secret_value())
49+
self._github = Github(auth=auth)
4750
self._memory = memory
4851
self._max_concurrent = max_concurrent
4952

tests/test_config.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ def test_returns_correct_identity(self, valid_config_file: Path) -> None:
8080
"""Loaded config contains expected identity values."""
8181
config = load_config(valid_config_file)
8282
assert config.identity.github_user == "test-bot"
83-
assert config.identity.github_token == "ghp_test_token"
83+
assert config.identity.github_token.get_secret_value() == "ghp_test_token"
8484

8585
def test_returns_correct_llm_config(self, valid_config_file: Path) -> None:
8686
"""Loaded config contains expected LLM values."""
@@ -138,8 +138,8 @@ def test_env_refs_resolved_from_environment(
138138
monkeypatch.setenv("GITHUB_TOKEN", "ghp_from_env")
139139
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-from-env")
140140
config = load_config(env_ref_config_file)
141-
assert config.identity.github_token == "ghp_from_env"
142-
assert config.llm.api_key == "sk-from-env"
141+
assert config.identity.github_token.get_secret_value() == "ghp_from_env"
142+
assert config.llm.api_key.get_secret_value() == "sk-from-env"
143143

144144
def test_missing_env_var_raises_config_error(
145145
self, env_ref_config_file: Path, monkeypatch: pytest.MonkeyPatch

tests/test_poller.py

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from unittest.mock import AsyncMock, MagicMock
88

99
import pytest
10+
from pydantic import SecretStr
1011

1112
from foreman.config import RepoConfig
1213
from foreman.memory import MemoryStore
@@ -75,7 +76,7 @@ def test_returns_events_for_open_issues(self, memory: MemoryStore, mocker) -> No
7576
mock_repo.get_issues.return_value = [make_issue(1), make_issue(2)]
7677
mock_repo.get_collaborators.return_value = []
7778

78-
poller = GitHubPoller(token="test-token", memory=memory)
79+
poller = GitHubPoller(token=SecretStr("test-token"), memory=memory)
7980
events = poller.poll_repo(RepoConfig(owner="owner", name="repo"))
8081

8182
assert len(events) == 2
@@ -88,7 +89,7 @@ def test_event_has_required_fields(self, memory: MemoryStore, mocker) -> None:
8889
mock_repo.get_issues.return_value = [make_issue(7)]
8990
mock_repo.get_collaborators.return_value = []
9091

91-
poller = GitHubPoller(token="test-token", memory=memory)
92+
poller = GitHubPoller(token=SecretStr("test-token"), memory=memory)
9293
events = poller.poll_repo(RepoConfig(owner="owner", name="repo"))
9394

9495
assert len(events) == 1
@@ -108,7 +109,7 @@ def test_skips_issues_by_collaborators(self, memory: MemoryStore, mocker) -> Non
108109
]
109110
mock_repo.get_collaborators.return_value = [make_collaborator("maintainer")]
110111

111-
poller = GitHubPoller(token="test-token", memory=memory)
112+
poller = GitHubPoller(token=SecretStr("test-token"), memory=memory)
112113
events = poller.poll_repo(RepoConfig(owner="owner", name="repo"))
113114

114115
assert len(events) == 1
@@ -125,7 +126,7 @@ def test_passes_since_to_get_issues_when_last_polled_set(self, memory: MemorySto
125126
last_polled = datetime(2024, 6, 1, tzinfo=timezone.utc)
126127
memory.set_last_polled("owner/repo", last_polled)
127128

128-
poller = GitHubPoller(token="test-token", memory=memory)
129+
poller = GitHubPoller(token=SecretStr("test-token"), memory=memory)
129130
poller.poll_repo(RepoConfig(owner="owner", name="repo"))
130131

131132
call_kwargs = mock_repo.get_issues.call_args
@@ -139,7 +140,7 @@ def test_get_issues_called_without_since_on_first_poll(self, memory: MemoryStore
139140
mock_repo.get_issues.return_value = []
140141
mock_repo.get_collaborators.return_value = []
141142

142-
poller = GitHubPoller(token="test-token", memory=memory)
143+
poller = GitHubPoller(token=SecretStr("test-token"), memory=memory)
143144
poller.poll_repo(RepoConfig(owner="owner", name="repo"))
144145

145146
call_kwargs = mock_repo.get_issues.call_args
@@ -155,7 +156,7 @@ def test_updates_last_polled_after_successful_poll(self, memory: MemoryStore, mo
155156

156157
assert memory.get_last_polled("owner/repo") is None
157158

158-
poller = GitHubPoller(token="test-token", memory=memory)
159+
poller = GitHubPoller(token=SecretStr("test-token"), memory=memory)
159160
poller.poll_repo(RepoConfig(owner="owner", name="repo"))
160161

161162
assert memory.get_last_polled("owner/repo") is not None
@@ -168,11 +169,11 @@ def test_last_polled_survives_new_instance(self, memory: MemoryStore, mocker) ->
168169
mock_repo.get_issues.return_value = []
169170
mock_repo.get_collaborators.return_value = []
170171

171-
poller1 = GitHubPoller(token="test-token", memory=memory)
172+
poller1 = GitHubPoller(token=SecretStr("test-token"), memory=memory)
172173
poller1.poll_repo(RepoConfig(owner="owner", name="repo"))
173174
ts1 = memory.get_last_polled("owner/repo")
174175

175-
poller2 = GitHubPoller(token="test-token", memory=memory)
176+
poller2 = GitHubPoller(token=SecretStr("test-token"), memory=memory)
176177
poller2.poll_repo(RepoConfig(owner="owner", name="repo"))
177178
ts2 = memory.get_last_polled("owner/repo")
178179

@@ -188,7 +189,7 @@ def test_payload_contains_issue_metadata(self, memory: MemoryStore, mocker) -> N
188189
mock_repo.get_issues.return_value = [make_issue(3, author_login="alice", title="A bug")]
189190
mock_repo.get_collaborators.return_value = []
190191

191-
poller = GitHubPoller(token="test-token", memory=memory)
192+
poller = GitHubPoller(token=SecretStr("test-token"), memory=memory)
192193
events = poller.poll_repo(RepoConfig(owner="owner", name="repo"))
193194

194195
payload = events[0]["payload"]
@@ -211,7 +212,7 @@ async def test_poll_all_calls_poll_repo_for_each_repo(self, memory: MemoryStore,
211212
mocker.patch("foreman.poller.Github")
212213
mock_poll = mocker.patch.object(GitHubPoller, "poll_repo", return_value=[])
213214

214-
poller = GitHubPoller(token="test-token", memory=memory)
215+
poller = GitHubPoller(token=SecretStr("test-token"), memory=memory)
215216
repos = [
216217
RepoConfig(owner="org", name="repo1"),
217218
RepoConfig(owner="org", name="repo2"),
@@ -235,7 +236,7 @@ def side_effect(repo_cfg: RepoConfig):
235236

236237
mocker.patch.object(GitHubPoller, "poll_repo", side_effect=side_effect)
237238

238-
poller = GitHubPoller(token="test-token", memory=memory)
239+
poller = GitHubPoller(token=SecretStr("test-token"), memory=memory)
239240
repos = [RepoConfig(owner="org", name="repo1"), RepoConfig(owner="org", name="repo2")]
240241
collected: list[dict] = []
241242

@@ -250,7 +251,7 @@ async def callback(repo_cfg: RepoConfig, event: dict) -> None:
250251
async def test_poll_all_with_empty_repo_list(self, memory: MemoryStore, mocker) -> None:
251252
"""poll_all completes without error when given an empty repo list."""
252253
mocker.patch("foreman.poller.Github")
253-
poller = GitHubPoller(token="test-token", memory=memory)
254+
poller = GitHubPoller(token=SecretStr("test-token"), memory=memory)
254255
await poller.poll_all([], AsyncMock()) # must not raise
255256

256257

@@ -281,7 +282,7 @@ def fail_then_succeed(repo_cfg: RepoConfig):
281282

282283
mocker.patch.object(GitHubPoller, "poll_repo", side_effect=fail_then_succeed)
283284

284-
poller = GitHubPoller(token="test-token", memory=memory)
285+
poller = GitHubPoller(token=SecretStr("test-token"), memory=memory)
285286
await poller.poll_all([RepoConfig(owner="owner", name="repo")], AsyncMock())
286287

287288
assert call_count == 2, "should retry once after 429"
@@ -306,7 +307,7 @@ def fail_then_succeed(repo_cfg: RepoConfig):
306307

307308
mocker.patch.object(GitHubPoller, "poll_repo", side_effect=fail_then_succeed)
308309

309-
poller = GitHubPoller(token="test-token", memory=memory)
310+
poller = GitHubPoller(token=SecretStr("test-token"), memory=memory)
310311
await poller.poll_all([RepoConfig(owner="owner", name="repo")], AsyncMock())
311312

312313
assert call_count == 2
@@ -325,7 +326,7 @@ async def test_poll_all_skips_repo_after_repeated_failures(self, memory: MemoryS
325326
side_effect=GithubException(429, {"message": "rate limited"}, {}),
326327
)
327328

328-
poller = GitHubPoller(token="test-token", memory=memory)
329+
poller = GitHubPoller(token=SecretStr("test-token"), memory=memory)
329330
# Must not raise
330331
await poller.poll_all([RepoConfig(owner="owner", name="repo")], AsyncMock())
331332

@@ -345,7 +346,7 @@ async def test_non_rate_limit_github_exception_logs_error_and_skips_repo(
345346
side_effect=GithubException(500, {"message": "server error"}, {}),
346347
)
347348

348-
poller = GitHubPoller(token="test-token", memory=memory)
349+
poller = GitHubPoller(token=SecretStr("test-token"), memory=memory)
349350
# Must not raise — error is logged and repo is skipped this cycle
350351
await poller.poll_all([RepoConfig(owner="owner", name="repo")], AsyncMock())
351352

@@ -365,7 +366,7 @@ async def test_bad_credentials_logs_critical_and_skips_repo(self, memory: Memory
365366
side_effect=GithubException(401, {"message": "Bad credentials"}, {}),
366367
)
367368

368-
poller = GitHubPoller(token="test-token", memory=memory)
369+
poller = GitHubPoller(token=SecretStr("test-token"), memory=memory)
369370
# Must not raise — critical is logged and repo is skipped
370371
await poller.poll_all([RepoConfig(owner="owner", name="repo")], AsyncMock())
371372

@@ -383,14 +384,14 @@ class TestSemaphore:
383384
def test_default_max_concurrent_is_five(self, memory: MemoryStore, mocker) -> None:
384385
"""GitHubPoller defaults to max_concurrent=5."""
385386
mocker.patch("foreman.poller.Github")
386-
poller = GitHubPoller(token="test-token", memory=memory)
387-
assert poller._max_concurrent == 5 # noqa: SLF001
387+
poller = GitHubPoller(token=SecretStr("test-token"), memory=memory)
388+
assert poller._max_concurrent == 5
388389

389390
def test_custom_max_concurrent_is_stored(self, memory: MemoryStore, mocker) -> None:
390391
"""max_concurrent passed to __init__ is stored on the instance."""
391392
mocker.patch("foreman.poller.Github")
392-
poller = GitHubPoller(token="test-token", memory=memory, max_concurrent=2)
393-
assert poller._max_concurrent == 2 # noqa: SLF001
393+
poller = GitHubPoller(token=SecretStr("test-token"), memory=memory, max_concurrent=2)
394+
assert poller._max_concurrent == 2
394395

395396

396397
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)