Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/rampant-heavenly-honeybee.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"changeset": patch
---

fix duplicate authors in changelog
139 changes: 97 additions & 42 deletions changeset/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand All @@ -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 = [
Expand All @@ -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,
Expand All @@ -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(
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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", "")

Expand Down Expand Up @@ -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:
Expand Down
7 changes: 6 additions & 1 deletion changeset/changeset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 12 additions & 4 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,25 @@ 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", "[email protected]"],
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
(tmp_path / "README.md").write_text("# Test Project")
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
Expand All @@ -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)

Expand All @@ -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)

Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading