Skip to content

Commit 8474a7a

Browse files
committed
adding functionality to bucket the audit log requests
1 parent 2ee2817 commit 8474a7a

File tree

4 files changed

+186
-34
lines changed

4 files changed

+186
-34
lines changed

nodestream_github/client/githubclient.py

Lines changed: 34 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import json
77
import logging
88
from collections.abc import AsyncGenerator
9-
from datetime import UTC, datetime
9+
from datetime import UTC, datetime, timedelta
1010
from enum import Enum
1111
from typing import Any
1212

@@ -92,28 +92,36 @@ def validate_positive_int(value: int) -> int:
9292
exception_msg = "Formatting lookback period failed"
9393
raise ValueError(exception_msg) from e
9494

95+
def generate_date_range(lookback_period: dict[str, int]) -> list[str]:
96+
"""
97+
Generate a list of date strings in YYYY-MM-DD format for
98+
the given lookback period.
99+
"""
100+
if not lookback_period:
101+
return []
102+
103+
end_date = datetime.now(tz=UTC).date()
104+
start_date = (datetime.now(tz=UTC) - relativedelta(**lookback_period)).date()
105+
106+
delta_days = (end_date - start_date).days + 1
107+
return [
108+
(start_date + timedelta(days=i)).strftime("%Y-%m-%d")
109+
for i in range(delta_days)
110+
]
95111

96112
def build_search_phrase(
97113
actions: list[str],
98114
actors: list[str],
99115
exclude_actors: list[str],
100-
lookback_period: dict[str, int],
116+
target_date: str | None = None,
101117
) -> str:
102118
# adding action-based filtering
103119
actions_phrase = ""
104120
if actions:
105121
actions_phrase = " ".join(f"action:{action}" for action in actions)
106122

107-
# adding lookback_period based filtering
108-
date_filter = ""
109-
if lookback_period:
110-
lookback_period = validate_lookback_period(lookback_period)
111-
date_filter = (
112-
f"created:>={(datetime.now(tz=UTC) - relativedelta(**lookback_period))
113-
.strftime('%Y-%m-%d')}"
114-
if lookback_period
115-
else ""
116-
)
123+
# adding date-based filtering for a specific date
124+
date_filter = f"created:{target_date}" if target_date else ""
117125

118126
# adding actor-based filtering
119127
actors_phrase = ""
@@ -412,19 +420,20 @@ async def fetch_enterprise_audit_log(
412420
https://docs.github.com/en/enterprise-cloud@latest/rest/enterprise-admin/audit-log?apiVersion=2022-11-28#get-the-audit-log-for-an-enterprise
413421
"""
414422
try:
415-
search_phrase = build_search_phrase(
416-
actions=actions,
417-
actors=actors,
418-
exclude_actors=exclude_actors,
419-
lookback_period=lookback_period,
420-
)
421-
422-
params = {"phrase": search_phrase} if search_phrase else {}
423-
424-
async for audit in self._get_paginated(
425-
f"enterprises/{enterprise_name}/audit-log", params=params
426-
):
427-
yield audit
423+
dates = generate_date_range(lookback_period) or [None]
424+
425+
for target_date in dates:
426+
search_phrase = build_search_phrase(
427+
actions=actions,
428+
actors=actors,
429+
exclude_actors=exclude_actors,
430+
target_date=target_date,
431+
)
432+
params = {"phrase": search_phrase} if search_phrase else {}
433+
async for audit in self._get_paginated(
434+
f"enterprises/{enterprise_name}/audit-log", params=params
435+
):
436+
yield audit
428437
except httpx.HTTPError as e:
429438
_fetch_problem("audit log", e)
430439

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "nodestream-plugin-github"
3-
version = "0.14.1-beta.4"
3+
version = "0.14.1-beta.6"
44
description = ""
55
authors = [
66
"Jon Bristow <[email protected]>",

tests/client/test_githubclient.py

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
import httpx
22
import pytest
3+
from freezegun import freeze_time
34
from pytest_httpx import HTTPXMock
45

5-
from nodestream_github.client.githubclient import GithubRestApiClient, RateLimitedError
6+
from nodestream_github.client.githubclient import (
7+
GithubRestApiClient,
8+
RateLimitedError,
9+
build_search_phrase,
10+
generate_date_range,
11+
validate_lookback_period,
12+
)
613
from tests.mocks.githubrest import DEFAULT_BASE_URL, DEFAULT_HOSTNAME
714

815

@@ -127,3 +134,114 @@ async def test_pagination_truncate_warning(
127134
def test_all_null_args():
128135
# noinspection PyTypeChecker
129136
assert GithubRestApiClient(auth_token=None, github_hostname=None)
137+
138+
# Test generate_date_range
139+
140+
def test_generate_date_range_empty_lookback_period():
141+
result = generate_date_range({})
142+
assert result == []
143+
144+
@freeze_time("2025-08-01")
145+
def test_generate_date_range_days_only():
146+
result = generate_date_range({"days": 3})
147+
expected = ["2025-07-29", "2025-07-30", "2025-07-31", "2025-08-01"]
148+
assert result == expected
149+
150+
@freeze_time("2025-08-01")
151+
def test_generate_date_range_zero_days():
152+
"""Test with zero days (same day only)."""
153+
result = generate_date_range({"days": 0})
154+
expected = ["2025-08-01"]
155+
assert result == expected
156+
157+
@freeze_time("2025-08-01")
158+
def test_generate_date_range_months_only():
159+
result = generate_date_range({"months": 1})
160+
# July 1 to August 1 = 32 days
161+
assert len(result) == 32
162+
assert result[0] == "2025-07-01"
163+
assert result[-1] == "2025-08-01"
164+
165+
@freeze_time("2025-08-01")
166+
def test_generate_date_range_years_only():
167+
result = generate_date_range({"years": 1})
168+
assert len(result) == 366
169+
assert result[0] == "2024-08-01"
170+
assert result[-1] == "2025-08-01"
171+
172+
@freeze_time("2025-08-01")
173+
def test_generate_date_range_combined_periods():
174+
"""Test with combined periods."""
175+
result = generate_date_range({"months": 1, "days": 5})
176+
assert result[0] == "2025-06-26"
177+
assert result[-1] == "2025-08-01"
178+
179+
@freeze_time("2025-08-01")
180+
def test_generate_date_range_complex_combination():
181+
"""Test with years, months, and days."""
182+
result = generate_date_range({"years": 1, "months": 2, "days": 10})
183+
assert result[0] == "2024-05-22"
184+
assert result[-1] == "2025-08-01"
185+
186+
187+
# Test validate_lookback_period
188+
189+
def test_validate_lookback_period_valid_input():
190+
result = validate_lookback_period({"days": 7, "months": 2, "years": 1})
191+
expected = {"days": 7, "months": 2, "years": 1}
192+
assert result == expected
193+
194+
195+
def test_validate_lookback_period_empty_dict():
196+
result = validate_lookback_period({})
197+
assert result == {}
198+
199+
200+
def test_validate_lookback_period_single_value():
201+
result = validate_lookback_period({"days": 30})
202+
assert result == {"days": 30}
203+
204+
205+
def test_validate_lookback_period_string_to_int_conversion():
206+
result = validate_lookback_period({"days": "7", "months": "2"})
207+
expected = {"days": 7, "months": 2}
208+
assert result == expected
209+
210+
def test_validate_lookback_period_zero_value():
211+
with pytest.raises(ValueError, match="Formatting lookback period failed"):
212+
validate_lookback_period({"days": 0})
213+
214+
215+
def test_validate_lookback_period_negative_value():
216+
with pytest.raises(ValueError, match="Formatting lookback period failed"):
217+
validate_lookback_period({"days": -5})
218+
219+
220+
def test_validate_lookback_period_multiple_negative_values():
221+
with pytest.raises(ValueError, match="Formatting lookback period failed"):
222+
validate_lookback_period({"days": 7, "months": -1, "years": 2})
223+
224+
225+
def test_validate_lookback_period_zero_string():
226+
with pytest.raises(ValueError, match="Formatting lookback period failed"):
227+
validate_lookback_period({"days": "0"})
228+
229+
230+
def test_validate_lookback_period_negative_string():
231+
with pytest.raises(ValueError, match="Formatting lookback period failed"):
232+
validate_lookback_period({"months": "-10"})
233+
234+
235+
def test_validate_lookback_period_invalid_string():
236+
with pytest.raises(ValueError, match="Formatting lookback period failed"):
237+
validate_lookback_period({"days": "invalid"})
238+
239+
240+
def test_validate_lookback_period_invalid_type():
241+
with pytest.raises(ValueError, match="Formatting lookback period failed"):
242+
validate_lookback_period({"days": []})
243+
244+
245+
def test_validate_lookback_period_none_value():
246+
with pytest.raises(ValueError, match="Formatting lookback period failed"):
247+
validate_lookback_period({"days": None})

tests/test_audit.py

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -137,23 +137,23 @@ async def test_get_audit_parameterized(
137137
[
138138
(
139139
{"days": 7},
140-
"action:protected_branch.create created:>=2025-07-25",
140+
"action:protected_branch.create created:2025-07-25",
141141
),
142142
(
143143
{"months": 2},
144-
"action:protected_branch.create created:>=2025-06-01",
144+
"action:protected_branch.create created:2025-06-01",
145145
),
146146
(
147147
{"years": 1},
148-
"action:protected_branch.create created:>=2024-08-01",
148+
"action:protected_branch.create created:2024-08-01",
149149
),
150150
(
151151
{"days": 15, "months": 1},
152-
"action:protected_branch.create created:>=2025-06-16",
152+
"action:protected_branch.create created:2025-06-16",
153153
),
154154
(
155155
{"days": 10, "months": 1, "years": 1},
156-
"action:protected_branch.create created:>=2024-06-21",
156+
"action:protected_branch.create created:2024-06-21",
157157
),
158158
],
159159
)
@@ -163,6 +163,8 @@ async def test_get_audit_lookback_periods(
163163
lookback_period: dict[str, int] | None,
164164
expected_path: str,
165165
):
166+
from nodestream_github.client.githubclient import generate_date_range
167+
166168
extractor = GithubAuditLogExtractor(
167169
auth_token="test-token",
168170
github_hostname=DEFAULT_HOSTNAME,
@@ -174,11 +176,34 @@ async def test_get_audit_lookback_periods(
174176
lookback_period=lookback_period,
175177
)
176178

179+
expected_dates = generate_date_range(lookback_period)
180+
181+
# Mock the first date call with the expected_path
177182
gh_rest_mock.get_enterprise_audit_logs(
178183
status_code=200,
179184
search_phrase=expected_path,
180185
json=GITHUB_AUDIT,
181186
)
187+
188+
# Mock additional dates if there are more than one
189+
test_dates = expected_dates[:3] if len(expected_dates) > 3 else expected_dates
190+
if len(test_dates) > 1:
191+
for date in test_dates[1:]:
192+
search_phrase = f"action:protected_branch.create created:{date}"
193+
gh_rest_mock.get_enterprise_audit_logs(
194+
status_code=200,
195+
search_phrase=search_phrase,
196+
json=GITHUB_AUDIT,
197+
)
182198

183-
all_records = [record async for record in extractor.extract_records()]
184-
assert all_records == GITHUB_EXPECTED_OUTPUT
199+
# replacing generate_date_range with test dates so that we don't iterate through all dates
200+
import nodestream_github.client.githubclient as client_module
201+
original_generate = client_module.generate_date_range
202+
client_module.generate_date_range = lambda x: test_dates
203+
204+
try:
205+
all_records = [record async for record in extractor.extract_records()]
206+
expected_output = GITHUB_EXPECTED_OUTPUT * len(test_dates)
207+
assert all_records == expected_output
208+
finally:
209+
client_module.generate_date_range = original_generate

0 commit comments

Comments
 (0)