diff --git a/.changeset/rampant-heavenly-honeybee.md b/.changeset/rampant-heavenly-honeybee.md new file mode 100644 index 0000000..3946f28 --- /dev/null +++ b/.changeset/rampant-heavenly-honeybee.md @@ -0,0 +1,5 @@ +--- +"changeset": patch +--- + +fix duplicate authors in changelog diff --git a/changeset/changelog.py b/changeset/changelog.py index ffb95b3..8695454 100755 --- a/changeset/changelog.py +++ b/changeset/changelog.py @@ -114,7 +114,6 @@ def get_changeset_metadata(changeset_path: Path) -> dict: commit_hash = result.stdout.strip().split("\n")[0] metadata["commit_hash"] = commit_hash - # Get the commit message to extract PR number and co-authors msg_result = subprocess.run( ["git", "log", "-1", "--format=%B", commit_hash], @@ -136,8 +135,8 @@ def get_changeset_metadata(changeset_path: Path) -> dict: # Try to get PR author using GitHub CLI if available try: # Check if we're in GitHub Actions and have a token - gh_token = ( - os.environ.get('GITHUB_TOKEN') or os.environ.get('GH_TOKEN') + gh_token = os.environ.get("GITHUB_TOKEN") or os.environ.get( + "GH_TOKEN" ) cmd = [ @@ -151,7 +150,7 @@ def get_changeset_metadata(changeset_path: Path) -> dict: env = os.environ.copy() if gh_token: - env['GH_TOKEN'] = gh_token + env["GH_TOKEN"] = gh_token gh_result = subprocess.run( cmd, @@ -170,14 +169,12 @@ def get_changeset_metadata(changeset_path: Path) -> dict: # Also try to get co-authors from PR commits try: - # Get all commits in the PR + # Get all commits in the PR with full author info cmd = [ "gh", "api", f"repos/{git_info.get('owner', '')}/" f"{git_info.get('repo', '')}/pulls/{pr_number}/commits", - "--jq", - ".[].author.login", ] commits_result = subprocess.run( @@ -188,15 +185,31 @@ def get_changeset_metadata(changeset_path: Path) -> dict: env=env, ) if commits_result.stdout.strip(): - # Get unique commit authors (excluding the PR author) - commit_authors = set( - commits_result.stdout.strip().split('\n') - ) - commit_authors.discard(metadata.get("pr_author")) - commit_authors.discard('') # Remove empty strings - if commit_authors: - metadata["co_authors"] = list(commit_authors) + import json + + commits_data = json.loads(commits_result.stdout) + + # Build a map of GitHub usernames to their info + github_users = {} + for commit in commits_data: + author = commit.get("author") + if author and author.get("login"): + username = author["login"] + pr_author = metadata.get("pr_author") + if username and username != pr_author: + commit_data = commit.get("commit", {}) + commit_author = commit_data.get("author", {}) + github_users[username] = { + "login": username, + "name": commit_author.get("name", ""), + "email": commit_author.get("email", ""), + } + + if github_users: + metadata["co_authors"] = list(github_users.keys()) metadata["co_authors_are_usernames"] = True + # Store the full user info for deduplication later + metadata["github_user_info"] = github_users except Exception: pass @@ -226,25 +239,53 @@ def get_changeset_metadata(changeset_path: Path) -> dict: metadata["pr_author"] = author_result.stdout.strip() metadata["pr_author_is_username"] = False - # Extract co-authors from commit message if we don't already have - # them from GitHub API - if "co_authors" not in metadata: - co_authors = [] - for line in commit_msg.split('\n'): - co_author_match = re.match( - r'^Co-authored-by:\s*(.+?)\s*<.*>$', line.strip() - ) - if co_author_match: - co_author_name = co_author_match.group(1).strip() - if ( - co_author_name - and co_author_name != metadata.get("pr_author") - ): - co_authors.append(co_author_name) - metadata["co_authors_are_usernames"] = False - - if co_authors: - metadata["co_authors"] = co_authors + # Extract co-authors from commit message + co_authors_from_commits = [] + for line in commit_msg.split("\n"): + co_author_match = re.match( + r"^Co-authored-by:\s*(.+?)\s*<(.+?)>$", line.strip() + ) + if co_author_match: + co_author_name = co_author_match.group(1).strip() + co_author_email = co_author_match.group(2).strip() + if co_author_name and co_author_name != metadata.get("pr_author"): + co_authors_from_commits.append( + {"name": co_author_name, "email": co_author_email} + ) + + # Deduplicate co-authors using GitHub user info + if "co_authors" in metadata and metadata.get("github_user_info"): + # We have GitHub users - check if commit co-authors match + github_users = metadata.get("github_user_info", {}) + final_co_authors = [] + + # Add all GitHub users + for username in metadata["co_authors"]: + final_co_authors.append((username, True)) + + # Check commit co-authors against GitHub users + for commit_author in co_authors_from_commits: + is_duplicate = False + for username, user_info in github_users.items(): + # Check by email (most reliable) + if commit_author["email"] == user_info.get("email", ""): + is_duplicate = True + break + # Check by name + if commit_author["name"] == user_info.get("name", ""): + is_duplicate = True + break + + if not is_duplicate: + # This is a unique co-author not in GitHub commits + final_co_authors.append((commit_author["name"], False)) + + metadata["co_authors"] = final_co_authors + elif co_authors_from_commits: + # No GitHub API data - just use commit co-authors + metadata["co_authors"] = [ + (author["name"], False) for author in co_authors_from_commits + ] except subprocess.CalledProcessError: # If git commands fail, return empty metadata @@ -273,7 +314,11 @@ def format_changelog_entry(entry: dict, config: dict, pr_metadata: dict) -> str: pr_author = pr_metadata.get("pr_author") pr_author_is_username = pr_metadata.get("pr_author_is_username", False) co_authors = pr_metadata.get("co_authors", []) - co_authors_are_usernames = pr_metadata.get("co_authors_are_usernames", False) + # Support legacy format where co_authors might be simple strings + if co_authors and isinstance(co_authors[0], str): + # Convert legacy format to new tuple format + co_authors_are_usernames = pr_metadata.get("co_authors_are_usernames", False) + co_authors = [(author, co_authors_are_usernames) for author in co_authors] commit_hash = pr_metadata.get("commit_hash", "")[:7] repo_url = pr_metadata.get("repo_url", "") @@ -301,14 +346,24 @@ def format_changelog_entry(entry: dict, config: dict, pr_metadata: dict) -> str: authors_to_thank.append(pr_author) # Add co-authors - for co_author in co_authors: - if co_author.startswith("@"): - authors_to_thank.append(co_author) - elif co_authors_are_usernames: - authors_to_thank.append(f"@{co_author}") + for co_author_entry in co_authors: + # Handle both new tuple format and legacy string format + if isinstance(co_author_entry, tuple): + co_author, is_username = co_author_entry + if co_author.startswith("@"): + authors_to_thank.append(co_author) + elif is_username: + authors_to_thank.append(f"@{co_author}") + else: + # Display name from git - don't add @ + authors_to_thank.append(co_author) else: - # Display names from git - don't add @ - authors_to_thank.append(co_author) + # Legacy format - just a string + if co_author_entry.startswith("@"): + authors_to_thank.append(co_author_entry) + else: + # Assume it's a display name without context + authors_to_thank.append(co_author_entry) if authors_to_thank: if len(authors_to_thank) == 1: diff --git a/changeset/changeset.py b/changeset/changeset.py index 6e94eb9..97fe947 100644 --- a/changeset/changeset.py +++ b/changeset/changeset.py @@ -680,7 +680,12 @@ def version(dry_run: bool, skip_changelog: bool): package_changes[package] = {"changes": [], "descriptions": []} package_changes[package]["changes"].append(change_type) package_changes[package]["descriptions"].append( - {"type": change_type, "description": desc, "changeset": filepath.name, "filepath": filepath} + { + "type": change_type, + "description": desc, + "changeset": filepath.name, + "filepath": filepath, + } ) # Show changesets diff --git a/tests/conftest.py b/tests/conftest.py index dfd3062..a459c9d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,11 +23,15 @@ def temp_repo(tmp_path: Path) -> Generator[Path]: subprocess.run(["git", "init"], cwd=tmp_path, check=True, capture_output=True) subprocess.run( ["git", "config", "user.email", "test@example.com"], - cwd=tmp_path, check=True, capture_output=True + cwd=tmp_path, + check=True, + capture_output=True, ) subprocess.run( ["git", "config", "user.name", "Test User"], - cwd=tmp_path, check=True, capture_output=True + cwd=tmp_path, + check=True, + capture_output=True, ) # Create initial commit @@ -35,7 +39,9 @@ def temp_repo(tmp_path: Path) -> Generator[Path]: subprocess.run(["git", "add", "."], cwd=tmp_path, check=True, capture_output=True) subprocess.run( ["git", "commit", "-m", "Initial commit"], - cwd=tmp_path, check=True, capture_output=True + cwd=tmp_path, + check=True, + capture_output=True, ) yield tmp_path @@ -54,6 +60,7 @@ def sample_project(temp_repo: Path) -> Path: } import toml + with open(temp_repo / "pyproject.toml", "w") as f: toml.dump(pyproject_content, f) @@ -80,6 +87,7 @@ def multi_package_project(temp_repo: Path) -> Path: } import toml + with open(pkg1_dir / "pyproject.toml", "w") as f: toml.dump(pyproject1, f) @@ -114,7 +122,7 @@ def initialized_changeset_project(sample_project: Path) -> Path: "major": {"description": "Breaking changes", "emoji": "💥"}, "minor": {"description": "New features", "emoji": "✨"}, "patch": {"description": "Bug fixes and improvements", "emoji": "🐛"}, - } + }, } with open(changeset_dir / "config.json", "w") as f: diff --git a/tests/test_changeset_cli.py b/tests/test_changeset_cli.py index ef3fc62..b3ad205 100644 --- a/tests/test_changeset_cli.py +++ b/tests/test_changeset_cli.py @@ -29,6 +29,7 @@ def test_init_detects_main_branch(self, cli_runner: CliRunner, temp_repo: Path): with cli_runner.isolated_filesystem(temp_dir=temp_repo): # Create a main branch import subprocess + subprocess.run(["git", "checkout", "-b", "main"], capture_output=True) result = cli_runner.invoke(cli, ["init"]) @@ -43,6 +44,7 @@ def test_init_handles_existing_directory( ): """Test that init handles existing .changeset directory.""" import os + os.chdir(initialized_changeset_project) result = cli_runner.invoke(cli, ["init"], input="n\n") @@ -59,6 +61,7 @@ def test_add_creates_changeset_interactive( ): """Test creating a changeset interactively.""" import os + os.chdir(initialized_changeset_project) # Create a change @@ -68,22 +71,26 @@ def test_add_creates_changeset_interactive( # Mock the interactive prompts mocker.patch( - 'changeset.changeset.questionary.checkbox', + "changeset.changeset.questionary.checkbox", + return_value=mocker.Mock( + ask=mocker.Mock(return_value=["changed_test-package"]) + ), + ) + mocker.patch( + "changeset.changeset.questionary.select", + return_value=mocker.Mock(ask=mocker.Mock(return_value="patch")), + ) + mocker.patch( + "changeset.changeset.questionary.text", return_value=mocker.Mock( - ask=mocker.Mock(return_value=['changed_test-package']) - ) + ask=mocker.Mock(return_value="Test changeset description") + ), ) - mocker.patch('changeset.changeset.questionary.select', return_value=mocker.Mock( - ask=mocker.Mock(return_value='patch') - )) - mocker.patch('changeset.changeset.questionary.text', return_value=mocker.Mock( - ask=mocker.Mock(return_value='Test changeset description') - )) mocker.patch( - 'changeset.changeset.questionary.confirm', + "changeset.changeset.questionary.confirm", return_value=mocker.Mock( ask=mocker.Mock(return_value=False) # Don't confirm major version - ) + ), ) # Run add command @@ -102,6 +109,7 @@ def test_add_with_all_flag( ): """Test add command with --all flag.""" import os + os.chdir(multi_package_project) # Initialize changesets first @@ -110,16 +118,12 @@ def test_add_with_all_flag( # Mock the interactive prompts for both packages select_mock = mocker.patch("changeset.changeset.questionary.select") # One for each package - select_mock.return_value.ask.side_effect = ['patch', 'patch'] + select_mock.return_value.ask.side_effect = ["patch", "patch"] text_mock = mocker.patch("changeset.changeset.questionary.text") - text_mock.return_value.ask.return_value = 'Test all packages' + text_mock.return_value.ask.return_value = "Test all packages" - result = cli_runner.invoke( - cli, - ["add", "--all"], - catch_exceptions=False - ) + result = cli_runner.invoke(cli, ["add", "--all"], catch_exceptions=False) # Check the command succeeded assert result.exit_code == 0, f"Command failed: {result.output}" @@ -143,6 +147,7 @@ def test_version_bumps_package_version( ): """Test that version command bumps the package version.""" import os + os.chdir(initialized_changeset_project) # Create a changeset @@ -152,9 +157,9 @@ def test_version_bumps_package_version( Added new feature """ - ( - initialized_changeset_project / ".changeset" / "test-change.md" - ).write_text(changeset_content) + (initialized_changeset_project / ".changeset" / "test-change.md").write_text( + changeset_content + ) # Run version command result = cli_runner.invoke(cli, ["version", "--skip-changelog"]) @@ -163,6 +168,7 @@ def test_version_bumps_package_version( # Check version was bumped import toml + with open(initialized_changeset_project / "pyproject.toml") as f: data = toml.load(f) @@ -178,6 +184,7 @@ def test_version_dry_run(self, cli_runner: CliRunner, sample_changeset: Path): project_dir = sample_changeset.parent.parent import os + os.chdir(project_dir) result = cli_runner.invoke(cli, ["version", "--dry-run"]) @@ -195,6 +202,7 @@ def test_version_no_changesets( ): """Test version command when no changesets exist.""" import os + os.chdir(initialized_changeset_project) result = cli_runner.invoke(cli, ["version"]) @@ -211,14 +219,17 @@ def test_check_changeset_on_main_branch( ): """Test check-changeset on main branch (should skip).""" import os + os.chdir(initialized_changeset_project) result = cli_runner.invoke(cli, ["check-changeset"]) assert result.exit_code == 0 # Accept either main or master as the default branch - assert ("Skipping changeset check for branch: main" in result.output or - "Skipping changeset check for branch: master" in result.output) + assert ( + "Skipping changeset check for branch: main" in result.output + or "Skipping changeset check for branch: master" in result.output + ) def test_check_changeset_on_feature_branch_without_changeset( self, cli_runner: CliRunner, initialized_changeset_project: Path @@ -226,6 +237,7 @@ def test_check_changeset_on_feature_branch_without_changeset( """Test check-changeset on feature branch without changeset.""" import os import subprocess + os.chdir(initialized_changeset_project) # Create feature branch @@ -244,6 +256,7 @@ def test_check_changeset_on_feature_branch_with_changeset( import os import subprocess + os.chdir(project_dir) # Create feature branch and add changeset diff --git a/tests/test_changeset_parsing.py b/tests/test_changeset_parsing.py index d14c567..9a96cca 100644 --- a/tests/test_changeset_parsing.py +++ b/tests/test_changeset_parsing.py @@ -33,10 +33,14 @@ def test_parse_valid_changeset(self, tmp_path: Path): assert len(result) == 2 assert result[0] == ( - "my-package", "minor", "This is a test changeset with multiple packages" + "my-package", + "minor", + "This is a test changeset with multiple packages", ) assert result[1] == ( - "other-package", "patch", "This is a test changeset with multiple packages" + "other-package", + "patch", + "This is a test changeset with multiple packages", ) def test_parse_changeset_invalid_format(self, tmp_path: Path): @@ -66,17 +70,20 @@ def test_parse_changeset_no_description(self, tmp_path: Path): class TestVersionBumping: """Test version bumping logic.""" - @pytest.mark.parametrize("current,bump_type,expected", [ - ("1.0.0", "major", "2.0.0"), - ("1.2.3", "major", "2.0.0"), - ("0.1.0", "major", "1.0.0"), - ("1.0.0", "minor", "1.1.0"), - ("1.2.3", "minor", "1.3.0"), - ("0.1.2", "minor", "0.2.0"), - ("1.0.0", "patch", "1.0.1"), - ("1.2.3", "patch", "1.2.4"), - ("0.0.1", "patch", "0.0.2"), - ]) + @pytest.mark.parametrize( + "current,bump_type,expected", + [ + ("1.0.0", "major", "2.0.0"), + ("1.2.3", "major", "2.0.0"), + ("0.1.0", "major", "1.0.0"), + ("1.0.0", "minor", "1.1.0"), + ("1.2.3", "minor", "1.3.0"), + ("0.1.2", "minor", "0.2.0"), + ("1.0.0", "patch", "1.0.1"), + ("1.2.3", "patch", "1.2.4"), + ("0.0.1", "patch", "0.0.2"), + ], + ) def test_bump_version(self, current: str, bump_type: str, expected: str): """Test version bumping for different bump types.""" assert bump_version(current, bump_type) == expected @@ -119,6 +126,7 @@ class TestProjectDiscovery: def test_find_single_project(self, sample_project: Path): """Test finding a single Python project.""" import os + os.chdir(sample_project) projects = find_python_projects() @@ -129,6 +137,7 @@ def test_find_single_project(self, sample_project: Path): def test_find_multiple_projects(self, multi_package_project: Path): """Test finding multiple Python projects.""" import os + os.chdir(multi_package_project) projects = find_python_projects() @@ -141,6 +150,7 @@ def test_find_multiple_projects(self, multi_package_project: Path): def test_find_projects_ignores_hidden_directories(self, temp_repo: Path): """Test that hidden directories are ignored.""" import os + os.chdir(temp_repo) # Create hidden directory with pyproject.toml @@ -148,6 +158,7 @@ def test_find_projects_ignores_hidden_directories(self, temp_repo: Path): hidden_dir.mkdir() import toml + with open(hidden_dir / "pyproject.toml", "w") as f: toml.dump({"project": {"name": "hidden-package"}}, f) @@ -167,6 +178,7 @@ class TestGetChangesets: def test_get_changesets_multiple_files(self, initialized_changeset_project: Path): """Test getting multiple changeset files.""" import os + os.chdir(initialized_changeset_project) # Create multiple changesets @@ -195,6 +207,7 @@ def test_get_changesets_multiple_files(self, initialized_changeset_project: Path def test_get_changesets_ignores_readme(self, initialized_changeset_project: Path): """Test that README.md is ignored when getting changesets.""" import os + os.chdir(initialized_changeset_project) changesets = get_changesets() diff --git a/tests/test_validate_changesets.py b/tests/test_validate_changesets.py index 63e713b..82fbdd1 100644 --- a/tests/test_validate_changesets.py +++ b/tests/test_validate_changesets.py @@ -112,7 +112,7 @@ def test_validate_cli_valid_files(self, cli_runner: CliRunner, tmp_path: Path): with cli_runner.isolated_filesystem(temp_dir=tmp_path.parent): result = cli_runner.invoke( validate_main, - [str(tmp_path / "change1.md"), str(tmp_path / "change2.md")] + [str(tmp_path / "change1.md"), str(tmp_path / "change2.md")], ) assert result.exit_code == 0