From 775fbe79e5c087484b173825ef0fd4d6adf1d65f Mon Sep 17 00:00:00 2001 From: Suejung Shin Date: Mon, 18 May 2026 17:20:22 -0700 Subject: [PATCH] feat: Add scm author to CommitAuthor --- src/scm/providers/github/provider.py | 28 +++++++++++++------ src/scm/providers/gitlab/provider.py | 40 +++++++++++++++++----------- src/scm/test_fixtures.py | 6 ++++- src/scm/types.py | 7 +++++ tests/unit/provider/test_github.py | 13 +++++++++ tests/unit/provider/test_gitlab.py | 7 +++++ 6 files changed, 77 insertions(+), 24 deletions(-) diff --git a/src/scm/providers/github/provider.py b/src/scm/providers/github/provider.py index 9f2ddc6..5428f49 100644 --- a/src/scm/providers/github/provider.py +++ b/src/scm/providers/github/provider.py @@ -1645,17 +1645,29 @@ def map_file_content(raw: dict[str, Any]) -> FileContent: ) -def map_commit_author(raw_author: dict[str, Any] | None) -> CommitAuthor | None: - if raw_author is None: +def _github_commit_scm_author(raw: dict[str, Any]) -> Author | None: + # Commit responses carry git metadata under commit.author and the linked + # GitHub user at the top level as author (id + login). + return map_author(raw.get("author")) + + +def map_commit_author( + git_author: dict[str, Any] | None, + *, + scm_author: Author | None = None, +) -> CommitAuthor | None: + """Map commit.author (git name/email/date) and optional linked SCM user.""" + if git_author is None: return None - raw_date = raw_author.get("date") + raw_date = git_author.get("date") date = datetime.fromisoformat(raw_date) if raw_date else None return CommitAuthor( - name=raw_author.get("name", ""), - email=raw_author.get("email", ""), + name=git_author.get("name", ""), + email=git_author.get("email", ""), date=date, + scm_author=scm_author, ) @@ -1698,7 +1710,7 @@ def map_commit(raw: dict[str, Any]) -> Commit: return Commit( id=raw["sha"], message=commit.get("message", ""), - author=map_commit_author(commit.get("author")), + author=map_commit_author(commit.get("author"), scm_author=_github_commit_scm_author(raw)), additions=stats.get("additions"), deletions=stats.get("deletions"), ) @@ -1710,7 +1722,7 @@ def map_commit_with_changes(raw: dict[str, Any]) -> CommitWithChanges: return CommitWithChanges( id=raw["sha"], message=commit.get("message", ""), - author=map_commit_author(commit.get("author")), + author=map_commit_author(commit.get("author"), scm_author=_github_commit_scm_author(raw)), files=[map_commit_file(f) for f in raw.get("files", [])], additions=stats.get("additions"), deletions=stats.get("deletions"), @@ -1781,7 +1793,7 @@ def map_pull_request_commit(raw: dict[str, Any]) -> PullRequestCommit: return PullRequestCommit( sha=raw["sha"], message=raw.get("commit", {}).get("message", ""), - author=map_commit_author(raw_author), + author=map_commit_author(raw_author, scm_author=_github_commit_scm_author(raw)), ) diff --git a/src/scm/providers/gitlab/provider.py b/src/scm/providers/gitlab/provider.py index f1e244c..f7216e6 100644 --- a/src/scm/providers/gitlab/provider.py +++ b/src/scm/providers/gitlab/provider.py @@ -1587,16 +1587,34 @@ def map_commit_comparison(raw: dict[str, Any]) -> CommitComparison: ) +def _gitlab_commit_scm_author(raw: dict[str, Any]) -> Author | None: + # Most commit endpoints only populate author_name/author_email; nested + # author (id + username) appears on some responses (e.g. MR commits). + author = raw.get("author") + if not isinstance(author, dict): + return None + author_id = author.get("id") + username = author.get("username") + if author_id is None or username is None: + return None + return map_author(author) + + +def map_gitlab_commit_author(raw: dict[str, Any], *, date_key: str) -> CommitAuthor: + return CommitAuthor( + name=raw["author_name"], + email=raw["author_email"], + date=datetime.datetime.fromisoformat(raw[date_key]), + scm_author=_gitlab_commit_scm_author(raw), + ) + + def map_commit(raw: dict[str, Any]) -> Commit: stats = raw.get("stats") or {} return Commit( id=str(raw["id"]), message=raw["message"], - author=CommitAuthor( - name=raw["author_name"], - email=raw["author_email"], - date=datetime.datetime.fromisoformat(raw["created_at"]), - ), + author=map_gitlab_commit_author(raw, date_key="created_at"), additions=stats.get("additions"), deletions=stats.get("deletions"), ) @@ -1607,11 +1625,7 @@ def map_commit_with_changes(raw: dict[str, Any]) -> CommitWithChanges: return CommitWithChanges( id=str(raw["id"]), message=raw["message"], - author=CommitAuthor( - name=raw["author_name"], - email=raw["author_email"], - date=datetime.datetime.fromisoformat(raw["created_at"]), - ), + author=map_gitlab_commit_author(raw, date_key="created_at"), files=None, additions=stats.get("additions"), deletions=stats.get("deletions"), @@ -1694,11 +1708,7 @@ def map_pull_request_commit(raw: dict[str, Any]) -> PullRequestCommit: return PullRequestCommit( sha=raw["id"], message=raw["message"], - author=CommitAuthor( - name=raw["author_name"], - email=raw["author_email"], - date=datetime.datetime.fromisoformat(raw["authored_date"]), - ), + author=map_gitlab_commit_author(raw, date_key="authored_date"), ) diff --git a/src/scm/test_fixtures.py b/src/scm/test_fixtures.py index e3a152b..22a297e 100644 --- a/src/scm/test_fixtures.py +++ b/src/scm/test_fixtures.py @@ -366,6 +366,7 @@ def make_github_pull_request_commit( author_email: str = "test@example.com", author_date: str = "2026-02-04T10:00:00Z", author_login: str | None = "testuser", + author_id: int = 1, ) -> dict[str, Any]: """Factory for GitHub pull request commit API responses.""" result: dict[str, Any] = { @@ -380,7 +381,7 @@ def make_github_pull_request_commit( }, } if author_login is not None: - result["author"] = {"login": author_login} + result["author"] = {"id": author_id, "login": author_login} else: result["author"] = None return result @@ -1114,6 +1115,7 @@ def get_commit( name="Test User", email="test@example.com", date=datetime.fromisoformat("2026-02-04T10:00:00Z"), + scm_author=None, ), files=[ CommitFile( @@ -1211,6 +1213,7 @@ def create_commit( name="Test User", email="test@example.com", date=datetime.fromisoformat("2026-02-04T10:00:00Z"), + scm_author=None, ), additions=None, deletions=None, @@ -1327,6 +1330,7 @@ def get_pull_request_commits( name="Test User", email="test@example.com", date=datetime.fromisoformat("2026-02-04T10:00:00Z"), + scm_author=Author(id="1", username="testuser"), ), ), ], diff --git a/src/scm/types.py b/src/scm/types.py index 7e1f67b..8e66196 100644 --- a/src/scm/types.py +++ b/src/scm/types.py @@ -357,9 +357,16 @@ class FileContent(TypedDict): class CommitAuthor(TypedDict): + """Git commit author metadata, optionally linked to an SCM user. + + ``name``, ``email``, and ``date`` come from the git commit object. + ``scm_author`` is the provider user tied to the commit when the API exposes one. + """ + name: str email: str date: datetime | None + scm_author: Author | None class CommitFile(TypedDict): diff --git a/tests/unit/provider/test_github.py b/tests/unit/provider/test_github.py index 12077cc..a74dd71 100644 --- a/tests/unit/provider/test_github.py +++ b/tests/unit/provider/test_github.py @@ -290,6 +290,17 @@ def expected_file_content(raw: dict[str, Any]) -> dict[str, Any]: } +def expected_commit_scm_author(raw: dict[str, Any]) -> dict[str, str] | None: + github_user = raw.get("author") + if not isinstance(github_user, dict): + return None + user_id = github_user.get("id") + login = github_user.get("login") + if user_id is None or login is None: + return None + return {"id": str(user_id), "username": login} + + def expected_commit(raw: dict[str, Any]) -> dict[str, Any]: author = raw["commit"]["author"] stats = raw.get("stats") or {} @@ -300,6 +311,7 @@ def expected_commit(raw: dict[str, Any]) -> dict[str, Any]: "name": author["name"], "email": author["email"], "date": datetime.fromisoformat(author["date"]), + "scm_author": expected_commit_scm_author(raw), }, "additions": stats.get("additions"), "deletions": stats.get("deletions"), @@ -368,6 +380,7 @@ def expected_pull_request_commit(raw: dict[str, Any]) -> dict[str, Any]: "name": author["name"], "email": author["email"], "date": datetime.fromisoformat(author["date"]), + "scm_author": expected_commit_scm_author(raw), }, } diff --git a/tests/unit/provider/test_gitlab.py b/tests/unit/provider/test_gitlab.py index e3c3a83..5c4e084 100644 --- a/tests/unit/provider/test_gitlab.py +++ b/tests/unit/provider/test_gitlab.py @@ -1603,6 +1603,7 @@ def _make_mock_response(json_data): "author": { "name": "Vincent Jacques", "email": "vincent@vincent-jacques.net", + "scm_author": None, "date": datetime.datetime( 2026, 2, @@ -1691,6 +1692,7 @@ def _make_mock_response(json_data): "author": { "name": "Vincent Jacques", "email": "vincent@vincent-jacques.net", + "scm_author": None, "date": datetime.datetime( 2026, 2, @@ -10914,6 +10916,7 @@ def _make_mock_response(json_data): "author": { "name": "Vincent Jacques", "email": "vincent@vincent-jacques.net", + "scm_author": None, "date": datetime.datetime( 2026, 2, @@ -11078,6 +11081,7 @@ def _make_mock_response(json_data): "author": { "name": "Vincent Jacques", "email": "vincent@vincent-jacques.net", + "scm_author": None, "date": datetime.datetime( 2026, 3, @@ -11154,6 +11158,7 @@ def _make_mock_response(json_data): "author": { "name": "Vincent Jacques", "email": "vincent@vincent-jacques.net", + "scm_author": None, "date": datetime.datetime( 2026, 2, @@ -11363,6 +11368,7 @@ def _make_mock_response(json_data): "author": { "name": "Vincent Jacques", "email": "vincent@vincent-jacques.net", + "scm_author": None, "date": datetime.datetime( 2026, 2, @@ -11380,6 +11386,7 @@ def _make_mock_response(json_data): "author": { "name": "Vincent Jacques", "email": "vincent@vincent-jacques.net", + "scm_author": None, "date": datetime.datetime( 2026, 3,