Skip to content
Open
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
13 changes: 7 additions & 6 deletions attribution/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,13 @@ class Changelog(GeneratedFile):
{{ "-" * len(tag.name) }}

{{ tag.message if tag.message else "" }}
{% if tag.shortlog -%}
```text
$ {{ tag.shortlog_cmd }}
{{ tag.shortlog }}
```
{%- endif %}
{%- if tag.contributors %}
Contributors

{% for name in project.filter_contributors(tag.contributors) | sort -%}
- {{ name }}
{% endfor %}
{% endif -%}

{% endfor -%}

Expand Down
12 changes: 7 additions & 5 deletions attribution/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@
import logging
import shlex
import subprocess
from typing import Optional

from packaging.utils import canonicalize_name

LOG = logging.getLogger(__name__)


def sh(*cmd: str, raw: bool = False) -> str:
def sh(*cmd: str, input: Optional[str] = None, raw: bool = False) -> str:
"""Run a simple command and return mixed stdout/stderr; raise on non-zero exit"""
if len(cmd) == 1:
cmd = tuple(shlex.split(cmd[0]))
Expand All @@ -20,15 +21,16 @@ def sh(*cmd: str, raw: bool = False) -> str:
p = subprocess.run(
cmd,
check=True,
encoding="utf-8",
input=input,
text=True,
)
else:
p = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
capture_output=True,
check=True,
encoding="utf-8",
input=input,
text=True,
)
return p.stdout
except subprocess.CalledProcessError as e:
Expand Down
9 changes: 9 additions & 0 deletions attribution/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import logging
import re
import subprocess
from collections import Counter
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, Optional, Sequence
Expand Down Expand Up @@ -89,6 +90,14 @@ def log_since_tag_cmd(self, tag: Optional[Tag] = None) -> Sequence[str]:

return log_cmd

def filter_contributors(self, names: set[str]) -> set[str]:
if ignored_authors := self.config.get("ignored_authors", []):
ignore_regex = re.compile(
"(" + "|".join(re.escape(author) for author in ignored_authors) + ")"
)
names = {name for name in names if not ignore_regex.match(name)}
return names

@classmethod
def load(cls, path: Optional[Path] = None) -> "Project":
if path is None:
Expand Down
63 changes: 45 additions & 18 deletions attribution/tag.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
import logging
import re
import subprocess
from collections import Counter
from dataclasses import dataclass
from functools import cache
from io import StringIO
from typing import Any, List, Match, Optional

from .helpers import sh
Expand Down Expand Up @@ -67,28 +70,33 @@ def pgp(match: Match) -> str:

return self._message

@property
def rev_spec(self) -> str:
"""Return a git rev range in the form of <base>...<head>"""
base = sh(f"git describe --tags --abbrev=0 --always {self.name}~1").strip()

# If there is no preceding tag, the describe command above will just return
# the rev hash of the commit preceding the tag, resulting in a bad shortlog.
# We can use `git tag -l <name>` to see if it's a real tag or just a hash.
# If it's just a rev, then this is the earliest tag in that tree, and we
# should just generate a shortlog without a starting ref.
# To save shell commands, we assume that any Version-like name is a tag.
try:
Version(base)
except InvalidVersion:
base = sh(f"git tag -l {base}").strip()
if base:
spec = f"{base}...{self.name}"
else:
spec = self.name

return spec

@property
def shortlog_cmd(self) -> str:
"""Generate the shortlog command to be run."""
if self._shortlog_cmd is None:
base = sh(f"git describe --tags --abbrev=0 --always {self.name}~1").strip()

# If there is no preceding tag, the describe command above will just return
# the rev hash of the commit preceding the tag, resulting in a bad shortlog.
# We can use `git tag -l <name>` to see if it's a real tag or just a hash.
# If it's just a rev, then this is the earliest tag in that tree, and we
# should just generate a shortlog without a starting ref.
# To save shell commands, we assume that any Version-like name is a tag.
try:
Version(base)
except InvalidVersion:
base = sh(f"git tag -l {base}").strip()
if base:
spec = f"{base}...{self.name}"
else:
spec = self.name

self._shortlog_cmd = f"git shortlog -s {spec}"
self._shortlog_cmd = f"git shortlog -s {self.rev_spec}"

return self._shortlog_cmd

Expand All @@ -105,6 +113,25 @@ def shortlog(self) -> str:

return self._shortlog

@property
def contributors(self) -> set[str]:
"""Generate a set of all unique contributors by name"""

log = sh(f"git log --format='Author: %aN <%aE>%n%b' {self.rev_spec}")
authors = re.findall(
r"^\s*(?:author|co-authored-by):\s*(.+)\s*$",
log,
re.IGNORECASE | re.MULTILINE,
)
result = sh("git check-mailmap --stdin", input="\n".join(authors))
names = {
(name or username).strip()
for name, username in re.findall(
r"^(.*)\s*<(.+)@.*>\s*$", result, re.MULTILINE
)
}
return names

@classmethod
def all_tags(cls) -> List["Tag"]:
"""Generate an ordered list of tag objects"""
Expand Down
5 changes: 5 additions & 0 deletions attribution/tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ def test_sh(self):
output = helpers.sh("echo", "foo bar")
self.assertEqual(output, "foo bar\n")

def test_cat(self):
data = "hello world\n"
output = helpers.sh("cat", input=data)
self.assertEqual(output, data)

def test_sh_split(self):
output = helpers.sh("echo foo bar")
self.assertEqual(output, "foo bar\n")
Expand Down
35 changes: 34 additions & 1 deletion attribution/tests/tag.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import subprocess
from dataclasses import replace
from unittest import TestCase
from unittest.mock import call, patch
from unittest.mock import ANY, call, patch

from ..tag import Tag
from ..types import Version
Expand Down Expand Up @@ -109,6 +109,17 @@ def test_message(self, sh_mock, log_mock):
self.assertEqual(result, "")
self.assertIsNone(tag._signature)

@patch("attribution.tag.sh")
def test_rev_spec(self, sh_mock):
sh_mock.side_effect = [
"v0.5\n",
]

tag = Tag("v1.0", Version("1.0"))
result = tag.rev_spec
sh_mock.assert_called_with("git describe --tags --abbrev=0 --always v1.0~1")
self.assertEqual(result, "v0.5...v1.0")

@patch("attribution.tag.LOG")
@patch("attribution.tag.sh")
def test_shortlog(self, sh_mock, log_mock):
Expand Down Expand Up @@ -165,6 +176,28 @@ def test_shortlog(self, sh_mock, log_mock):
log_mock.exception.assert_called_once()
self.assertEqual(result, "shortlog for v1.0")

@patch("attribution.tag.sh")
def test_contributors(self, sh_mock):
sh_mock.side_effect = [
"v0.5",
"Author: Alice <alice@place>\n\nCo-authored-by: Bob <bob@place>\nAuthor: Cara <cara@place>\n",
"Ali <ali@place>\nBob Rob <bob@place>\nCara Mae <cara@place>\n",
]

tag = Tag("v1.0", Version("1.0"))
names = tag.contributors
sh_mock.assert_has_calls(
[
call("git describe --tags --abbrev=0 --always v1.0~1"),
call(r"git log --format='Author: %aN <%aE>%n%b' v0.5...v1.0"),
call(
r"git check-mailmap --stdin",
input="Alice <alice@place>\nBob <bob@place>\nCara <cara@place>",
),
]
)
self.assertEqual(names, {"Ali", "Bob Rob", "Cara Mae"})

@patch("attribution.tag.LOG")
@patch("attribution.tag.sh")
def test_all_tags(self, sh_mock, log_mock):
Expand Down
Loading