From 87e5a5322add4682aaeae562fdfdfa82722a0ab5 Mon Sep 17 00:00:00 2001 From: Yuzhong Zhang Date: Tue, 1 Sep 2026 22:52:49 +0000 Subject: [PATCH 1/3] Fix git-ref --since including the tagged merge PR When --since is a git tag, treat the activity window as exclusive of the tagged merge timestamp so the release PR itself is not listed again in the next changelog (Fixes #79). Calendar-date --since stays inclusive. --- github_activity/github_activity.py | 9 ++- tests/test_activity_window.py | 119 +++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 tests/test_activity_window.py diff --git a/github_activity/github_activity.py b/github_activity/github_activity.py index fa69e30..5163f8c 100644 --- a/github_activity/github_activity.py +++ b/github_activity/github_activity.py @@ -605,8 +605,13 @@ def filter_ignored(userlist): until_dt_str = data.until_dt_str # noqa: F841 # Separate into closed and opened - closed = data.query("closedAt >= @since_dt_str and closedAt <= @until_dt_str") - opened = data.query("createdAt >= @since_dt_str and createdAt <= @until_dt_str") + # Git-ref --since is exclusive of the tagged merge; a date --since is inclusive. + if data.since_is_git_ref: + closed = data.query("closedAt > @since_dt_str and closedAt <= @until_dt_str") + opened = data.query("createdAt > @since_dt_str and createdAt <= @until_dt_str") + else: + closed = data.query("closedAt >= @since_dt_str and closedAt <= @until_dt_str") + opened = data.query("createdAt >= @since_dt_str and createdAt <= @until_dt_str") # Separate into PRs and issues closed_prs = closed.query("kind == 'pr'") diff --git a/tests/test_activity_window.py b/tests/test_activity_window.py new file mode 100644 index 0000000..a30c897 --- /dev/null +++ b/tests/test_activity_window.py @@ -0,0 +1,119 @@ +"""Tests for changelog activity window filtering.""" + +import datetime + +import pandas as pd + +from github_activity.github_activity import generate_activity_md + + +def _pr_row(number, title, closed_at, labels=None, created_at=None, merge_oid=None): + return { + "id": f"PR_{number}", + "title": title, + "url": f"https://github.com/jupyterhub/action-k3s-helm/pull/{number}", + "number": number, + "state": "MERGED", + "kind": "pr", + "org": "jupyterhub", + "repo": "action-k3s-helm", + "author": "manics", + "mergedBy": "manics", + "committers": ["manics"], + "reviewers": [], + "comments": {"edges": []}, + "labels": labels or [], + "closedAt": closed_at, + "createdAt": created_at or "2022-08-20T10:00:00Z", + "mergeCommit": {"oid": merge_oid or f"sha{number}"}, + "baseRefName": "master", + } + + +def _activity_frame(rows, since_dt, until_dt, since_is_git_ref, until_is_git_ref=False): + data = pd.DataFrame(rows) + data.since_dt = since_dt + data.until_dt = until_dt + data.since_dt_str = f"{since_dt:%Y-%m-%dT%H:%M:%SZ}" + data.until_dt_str = f"{until_dt:%Y-%m-%dT%H:%M:%SZ}" + data.since_is_git_ref = since_is_git_ref + data.until_is_git_ref = until_is_git_ref + data.attrs["bot_users"] = set() + return data + + +def test_git_ref_since_excludes_pr_closed_at_tag(monkeypatch): + """A PR whose merge commit is the --since tag must not appear in the changelog. + + GitHub compare `tag...HEAD` is exclusive of the tag. Matching that window + means a PR closed at the tagged commit's timestamp belongs to the previous + release. Regression for executablebooks/github-activity#79. + """ + since_dt = datetime.datetime(2022, 8, 21, 13, 29, 31, tzinfo=datetime.timezone.utc) + until_dt = datetime.datetime(2022, 11, 15, 19, 11, 20, tzinfo=datetime.timezone.utc) + data = _activity_frame( + [ + _pr_row( + 72, + "Earlier same-day PR", + "2022-08-21T10:00:00Z", + labels=["bug"], + ), + _pr_row( + 73, + "Calico 3.24.0 (3.23 manifest URL is broken)", + "2022-08-21T13:29:31Z", + labels=["bug"], + merge_oid="d3c165a02b1c75b6e551da97970eecb401269750", + ), + _pr_row( + 74, + "Add RELEASE.md", + "2022-08-21T15:22:38Z", + ), + ], + since_dt, + until_dt, + since_is_git_ref=True, + until_is_git_ref=True, + ) + monkeypatch.setattr( + "github_activity.github_activity.get_activity", lambda *args, **kwargs: data + ) + + md = generate_activity_md( + target="jupyterhub/action-k3s-helm", + since="v3.0.5", + until="v3.0.6", + ) + + assert "[#73]" not in md + assert "[#72]" not in md + assert "[#74]" in md + + +def test_date_since_still_includes_prs_at_window_start(monkeypatch): + """A calendar-date --since remains inclusive of activity at that instant.""" + since_dt = datetime.datetime(2022, 8, 21, 0, 0, 0, tzinfo=datetime.timezone.utc) + until_dt = datetime.datetime(2022, 8, 22, 0, 0, 0, tzinfo=datetime.timezone.utc) + data = _activity_frame( + [ + _pr_row(73, "Closed at start of since date", "2022-08-21T00:00:00Z"), + _pr_row(74, "Closed later the same day", "2022-08-21T15:22:38Z"), + ], + since_dt, + until_dt, + since_is_git_ref=False, + ) + monkeypatch.setattr( + "github_activity.github_activity.get_activity", lambda *args, **kwargs: data + ) + + md = generate_activity_md( + target="jupyterhub/action-k3s-helm", + since="2022-08-21", + until="2022-08-22", + ) + + assert "[#73]" in md + assert "[#74]" in md From 56e447bb43e659718964e767ea1a2e498535befa Mon Sep 17 00:00:00 2001 From: Nabil Freij Date: Tue, 15 Sep 2026 13:51:19 -0700 Subject: [PATCH 2/3] Apply activity window filter in get_activity --- github_activity/github_activity.py | 35 ++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/github_activity/github_activity.py b/github_activity/github_activity.py index 5163f8c..87be206 100644 --- a/github_activity/github_activity.py +++ b/github_activity/github_activity.py @@ -97,6 +97,16 @@ ) +def _activity_window_masks(data, since_dt_str, until_dt_str, since_is_git_ref): + """Return closed/opened masks; a git-ref ``since`` is exclusive, a date inclusive.""" + lower = ">" if since_is_git_ref else ">=" + closed = data.eval(f"closedAt {lower} @since_dt_str and closedAt <= @until_dt_str") + opened = data.eval( + f"createdAt {lower} @since_dt_str and createdAt <= @until_dt_str" + ) + return closed, opened + + def get_activity( target, since, until=None, repo=None, kind=None, auth=None, cache=None ): @@ -226,6 +236,15 @@ def get_activity( query_data = ( pd.concat(query_data).drop_duplicates(subset=["id"]).reset_index(drop=True) ) + + # GitHub's search range is inclusive. Apply our public window semantics here + # so callers of get_activity() and generate_activity_md() see the same data. + if not query_data.empty: + closed, opened = _activity_window_masks( + query_data, since_dt_str, until_dt_str, since_is_git_ref + ) + query_data = query_data[closed | opened].reset_index(drop=True) + query_data.since_dt = since_dt query_data.until_dt = until_dt query_data.since_dt_str = since_dt_str @@ -600,18 +619,10 @@ def filter_ignored(userlist): ].index.tolist() all_contributors |= set(c for c in comment_contributors if isinstance(c, str)) - # Extract datetime strings from data attributes for pandas query - since_dt_str = data.since_dt_str # noqa: F841 - until_dt_str = data.until_dt_str # noqa: F841 - - # Separate into closed and opened - # Git-ref --since is exclusive of the tagged merge; a date --since is inclusive. - if data.since_is_git_ref: - closed = data.query("closedAt > @since_dt_str and closedAt <= @until_dt_str") - opened = data.query("createdAt > @since_dt_str and createdAt <= @until_dt_str") - else: - closed = data.query("closedAt >= @since_dt_str and closedAt <= @until_dt_str") - opened = data.query("createdAt >= @since_dt_str and createdAt <= @until_dt_str") + closed_mask, opened_mask = _activity_window_masks( + data, data.since_dt_str, data.until_dt_str, data.since_is_git_ref + ) + closed, opened = data[closed_mask], data[opened_mask] # Separate into PRs and issues closed_prs = closed.query("kind == 'pr'") From ee35a0315c7b57f94b8a5c4bacc4f9ea583f26ba Mon Sep 17 00:00:00 2001 From: Nabil Freij Date: Tue, 15 Sep 2026 13:51:34 -0700 Subject: [PATCH 3/3] Update tests and parmaterize --- tests/test_activity_window.py | 162 ++++++++++++++++++---------------- 1 file changed, 87 insertions(+), 75 deletions(-) diff --git a/tests/test_activity_window.py b/tests/test_activity_window.py index a30c897..a9954fb 100644 --- a/tests/test_activity_window.py +++ b/tests/test_activity_window.py @@ -3,11 +3,12 @@ import datetime import pandas as pd +import pytest -from github_activity.github_activity import generate_activity_md +from github_activity.github_activity import generate_activity_md, get_activity -def _pr_row(number, title, closed_at, labels=None, created_at=None, merge_oid=None): +def _pr_row(number, title, closed_at): return { "id": f"PR_{number}", "title": title, @@ -22,98 +23,109 @@ def _pr_row(number, title, closed_at, labels=None, created_at=None, merge_oid=No "committers": ["manics"], "reviewers": [], "comments": {"edges": []}, - "labels": labels or [], + "labels": [], "closedAt": closed_at, - "createdAt": created_at or "2022-08-20T10:00:00Z", - "mergeCommit": {"oid": merge_oid or f"sha{number}"}, + "createdAt": "2022-08-20T10:00:00Z", + "mergeCommit": {"oid": f"sha{number}"}, "baseRefName": "master", } -def _activity_frame(rows, since_dt, until_dt, since_is_git_ref, until_is_git_ref=False): - data = pd.DataFrame(rows) - data.since_dt = since_dt - data.until_dt = until_dt - data.since_dt_str = f"{since_dt:%Y-%m-%dT%H:%M:%SZ}" - data.until_dt_str = f"{until_dt:%Y-%m-%dT%H:%M:%SZ}" - data.since_is_git_ref = since_is_git_ref - data.until_is_git_ref = until_is_git_ref - data.attrs["bot_users"] = set() - return data +def _mock_get_activity_dependencies( + monkeypatch, + rows, + since_dt, + until_dt, + since_is_git_ref, +): + datetime_results = iter( + [ + (since_dt, since_is_git_ref), + (until_dt, False), + ] + ) + monkeypatch.setattr( + "github_activity.github_activity._get_datetime_and_type", + lambda *args, **kwargs: next(datetime_results), + ) + monkeypatch.setattr( + "github_activity.github_activity._validate_repository_exists", + lambda *args, **kwargs: None, + ) + class FakeGitHubGraphQlQuery: + def __init__(self, *args, **kwargs): + self.data = pd.DataFrame(rows) + self.data.attrs["bot_users"] = set() -def test_git_ref_since_excludes_pr_closed_at_tag(monkeypatch): - """A PR whose merge commit is the --since tag must not appear in the changelog. + request = lambda self: None # noqa: E731 - GitHub compare `tag...HEAD` is exclusive of the tag. Matching that window - means a PR closed at the tagged commit's timestamp belongs to the previous - release. Regression for executablebooks/github-activity#79. - """ - since_dt = datetime.datetime(2022, 8, 21, 13, 29, 31, tzinfo=datetime.timezone.utc) - until_dt = datetime.datetime(2022, 11, 15, 19, 11, 20, tzinfo=datetime.timezone.utc) - data = _activity_frame( - [ - _pr_row( - 72, - "Earlier same-day PR", - "2022-08-21T10:00:00Z", - labels=["bug"], - ), - _pr_row( - 73, - "Calico 3.24.0 (3.23 manifest URL is broken)", - "2022-08-21T13:29:31Z", - labels=["bug"], - merge_oid="d3c165a02b1c75b6e551da97970eecb401269750", - ), - _pr_row( - 74, - "Add RELEASE.md", - "2022-08-21T15:22:38Z", - ), - ], - since_dt, - until_dt, - since_is_git_ref=True, - until_is_git_ref=True, - ) monkeypatch.setattr( - "github_activity.github_activity.get_activity", lambda *args, **kwargs: data + "github_activity.github_activity.GitHubGraphQlQuery", + FakeGitHubGraphQlQuery, ) - md = generate_activity_md( + +@pytest.mark.parametrize("api", [get_activity, generate_activity_md]) +@pytest.mark.parametrize( + "since,since_dt,since_is_git_ref,expected", + [ + ("v3.0.5", "2022-08-21T13:29:31+00:00", True, [74]), + ("2022-08-21", "2022-08-21T00:00:00+00:00", False, [73, 74]), + ], + ids=["git-ref-exclusive", "date-inclusive"], +) +def test_activity_window(monkeypatch, api, since, since_dt, since_is_git_ref, expected): + """Both APIs exclude a git-ref boundary but include a date boundary (#79).""" + since_dt = datetime.datetime.fromisoformat(since_dt) + until_dt = datetime.datetime(2022, 11, 15, 19, 11, 20, tzinfo=datetime.timezone.utc) + rows = [ + _pr_row( + number, + title, + f"{since_dt + datetime.timedelta(hours=offset):%Y-%m-%dT%H:%M:%SZ}", + ) + for number, title, offset in [ + (72, "Closed before boundary", -1), + (73, "Closed at boundary", 0), + (74, "Closed after boundary", 1), + ] + ] + _mock_get_activity_dependencies( + monkeypatch, rows, since_dt, until_dt, since_is_git_ref + ) + + result = api( target="jupyterhub/action-k3s-helm", - since="v3.0.5", - until="v3.0.6", + since=since, + until="2022-11-15T19:11:20Z", + auth="test-token", ) - assert "[#73]" not in md - assert "[#72]" not in md - assert "[#74]" in md + if api is get_activity: + assert result["number"].tolist() == expected + else: + for number in [72, 73, 74]: + assert (f"[#{number}]" in result) == (number in expected) -def test_date_since_still_includes_prs_at_window_start(monkeypatch): - """A calendar-date --since remains inclusive of activity at that instant.""" - since_dt = datetime.datetime(2022, 8, 21, 0, 0, 0, tzinfo=datetime.timezone.utc) - until_dt = datetime.datetime(2022, 8, 22, 0, 0, 0, tzinfo=datetime.timezone.utc) - data = _activity_frame( - [ - _pr_row(73, "Closed at start of since date", "2022-08-21T00:00:00Z"), - _pr_row(74, "Closed later the same day", "2022-08-21T15:22:38Z"), - ], - since_dt, - until_dt, - since_is_git_ref=False, - ) - monkeypatch.setattr( - "github_activity.github_activity.get_activity", lambda *args, **kwargs: data +@pytest.mark.parametrize("api", [get_activity, generate_activity_md]) +def test_empty_activity(monkeypatch, api): + """Empty searches return an empty frame or the intended Markdown error.""" + since_dt = datetime.datetime(2022, 8, 21, tzinfo=datetime.timezone.utc) + until_dt = datetime.datetime(2022, 8, 22, tzinfo=datetime.timezone.utc) + _mock_get_activity_dependencies( + monkeypatch, [], since_dt, until_dt, since_is_git_ref=False ) - - md = generate_activity_md( + kwargs = dict( target="jupyterhub/action-k3s-helm", since="2022-08-21", until="2022-08-22", + auth="test-token", ) - assert "[#73]" in md - assert "[#74]" in md + if api is get_activity: + assert api(**kwargs).empty + else: + with pytest.raises(ValueError, match="No activity found"): + api(**kwargs)