Skip to content

Commit d9ac1e4

Browse files
authored
Add project specific configuration via local or remote pyproject.toml (#30)
* Add rudimentary format configuration via pyproject.toml The idea is to have three sources of configuration, in order of priority: - Local TOML file specified with --config option - Remote pyproject.toml at the last given revision - Default configuration in _config.py * Move default configuration into TOML file * Add missing newline to intro_template * Add documentation to configuration options * Use scientific-python/changelist as example * Document configuration options in README and tweak or extend other sections accordingly. * List features in README and also hint that changelist is not intended to replace the human part in documenting a release.
1 parent 1dfd1a8 commit d9ac1e4

6 files changed

Lines changed: 244 additions & 56 deletions

File tree

MANIFEST.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
include src/changelist/*.toml

README.md

Lines changed: 102 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,20 @@
11
# changelist
22

3-
Prepare an automatic changelog from GitHub pull requests. For example, see
3+
Changelist helps you write better release notes by automating as much of the
4+
process as possible. For example, see
45
https://github.com/scientific-python/changelist/blob/main/CHANGELOG.md.
56

7+
**Features**
8+
9+
- Compile a list of pull requests, code authors and reviewers between any two
10+
valid Git objects (refs).
11+
- Categorize pull requests into sections based on GitHub labels.
12+
- Point it at any repository on GitHub. No need to clone or checkout a
13+
repository locally, a Python package is all that's needed.
14+
15+
We recommend to treat the generated document as a first draft to build
16+
on and not as an already perfect documentation of the release.
17+
618
_This project is currently in its alpha stage and might be incomplete or change a lot!_
719

820
## Installation
@@ -11,26 +23,101 @@ _This project is currently in its alpha stage and might be incomplete or change
1123
pip install changelist
1224
```
1325

14-
## Set up your repository
26+
## Usage
27+
28+
The script requires a [GitHub personal access
29+
token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens).
30+
The token does not need any permissions, since it is used only to
31+
increase query limits.
32+
33+
```sh
34+
export GH_TOKEN='...'
35+
changelist scientific-python/changelist v0.2.0 main
36+
```
1537

16-
To categorize merged PRs in the changelist, each PR
17-
must have have one of the following labels:
38+
This will list all pull requests, authors and reviewers that touched commits
39+
between `v0.2.0` and `main` (excluding `v0.2.0`).
40+
Pull requests are sorted into section according to the configuration in
41+
`tool.changelist.label_section_map`.
42+
43+
## Configuration
44+
45+
changelist can be configured from two sources, in order of precedence:
46+
47+
- A local TOML file specified with the `--config` option
48+
- A remote `pyproject.toml` at `stop_rev`
49+
50+
If a configuration option is not specified in either file above, changelist
51+
falls back to the following configuration:
52+
53+
````toml
54+
# Default changelist configuration as supported in pyproject.toml
55+
[tool.changelist]
56+
57+
# A template string that is included as the title of the generated notes.
58+
# "{repo_name}" and "{version}", if given, are replaced by the respective
59+
# values given in the command line.
60+
title_template = "{repo_name} {version}"
61+
62+
# A template string that is included as introductory text after the title.
63+
# "{repo_name}" and "{version}", if given, are replaced by the respective
64+
# values given in the command line.
65+
intro_template = """
66+
We're happy to announce the release of {repo_name} {version}!
67+
"""
68+
69+
# A template string that is included at the end of the generated notes.
70+
# "{repo_name}" and "{version}", if given, are replaced by the respective
71+
# values given in the command line.
72+
outro_template = """
73+
_These lists are automatically generated, and may not be complete or may contain
74+
duplicates._
75+
"""
76+
77+
# Profiles that are excluded from the contributor list.
78+
ignored_user_logins = [
79+
"web-flow",
80+
]
81+
82+
# If this regex matches a pull requests description, the captured content
83+
# is included instead of the pull request title.
84+
# E.g. the default regex below is matched by
85+
#
86+
# ```release-note
87+
# An ideally expressive description of the change that is included as a single
88+
# bullet point. Newlines are removed.
89+
# ```
90+
#
91+
# If you modify this regex, make sure to match the content with a capture
92+
# group named "summary".
93+
pr_summary_regex = "^```release-note\\s*(?P<summary>[\\s\\S]*?\\w[\\s\\S]*?)\\s*^```"
94+
95+
# If any of a pull request's labels matches one of the regexes on the left side
96+
# its summary will appear in the appropriate section with the title given on
97+
# the right side. If a pull request doesn't match one of these categories it is
98+
# sorted into a section titled "Other". Pull request can appear in multiple
99+
# sections as long as their labels match.
100+
[tool.changelist.label_section_map]
101+
".*Highlight.*" = "Highlights"
102+
".*New feature.*" = "New Features"
103+
".*API.*" = "API Changes"
104+
".*Enhancement.*" = "Enhancements"
105+
".*Performance.*" = "Performance"
106+
".*Bug fix.*" = "Bug Fixes"
107+
".*Documentation.*" = "Documentation"
108+
".*Infrastructure.*" = "Infrastructure"
109+
".*Maintenance.*" = "Maintenance"
110+
````
18111

19-
- `type: Highlights`
20-
- `type: New features`
21-
- `type: Enhancements`
22-
- `type: Performance`
23-
- `type: Bug fix`
24-
- `type: API`
25-
- `type: Maintenance`
26-
- `type: Documentation`
27-
- `type: Infrastructure`
112+
## Set up your repository
28113

29-
This list will soon be configurable.
114+
To categorize merged PRs in the changelist with the default configuration, each
115+
PR must have a label that matches one of the regexes on the left side of the
116+
`label_section_map` table, e.g. `type: Highlights`.
30117

31118
### Label checking
32119

33-
To ensure that each PR has an associated `type: ` label,
120+
You may want to ensure that each PR has an associated `type: ` label,
34121
we recommend adding an action that fails CI if the label is missing.
35122

36123
To do so, place the following in `.github/workflows/label-check.yaml`:
@@ -89,15 +176,3 @@ jobs:
89176
```
90177

91178
See https://github.com/scientific-python/attach-next-milestone-action for more information.
92-
93-
## Usage
94-
95-
```sh
96-
export GH_TOKEN='...'
97-
changelist scikit-image/scikit-image v0.21.0 main
98-
```
99-
100-
The script requires a [GitHub personal access
101-
token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens).
102-
The token does not need any permissions, since it is used only to
103-
increase query limits.

src/changelist/_cli.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import argparse
22
import logging
33
import os
4+
import re
45
import sys
56
import tempfile
67
from pathlib import Path
@@ -10,6 +11,7 @@
1011
from github import Github
1112
from tqdm import tqdm
1213

14+
from ._config import add_config_defaults, local_config, remote_config
1315
from ._format import MdFormatter, RstFormatter
1416
from ._query import commits_between, contributors, pull_requests_from_commits
1517

@@ -44,7 +46,7 @@ def parse_command_line(func: Callable) -> Callable:
4446
parser.add_argument(
4547
"org_repo",
4648
help="Org and repo name of a repository on GitHub (delimited by a slash), "
47-
"e.g. 'numpy/numpy'",
49+
"e.g. 'scientific-python/changelist'",
4850
)
4951
parser.add_argument(
5052
"start_rev",
@@ -57,7 +59,7 @@ def parse_command_line(func: Callable) -> Callable:
5759
)
5860
parser.add_argument(
5961
"--version",
60-
default="0.0.0",
62+
default="x.y.z",
6163
help="Version you're about to release, used title and description of the notes",
6264
)
6365
parser.add_argument("--out", help="Write to file, prints to STDOUT otherwise")
@@ -72,6 +74,12 @@ def parse_command_line(func: Callable) -> Callable:
7274
action="store_true",
7375
help="Clear cached requests to GitHub's API before running",
7476
)
77+
parser.add_argument(
78+
"--config",
79+
dest="config_path",
80+
help="Path to local TOML configuration (falls back on remote "
81+
"pyproject.toml or default config if not given)",
82+
)
7583
parser.add_argument(
7684
"-v",
7785
"--verbose",
@@ -98,6 +106,7 @@ def main(
98106
out: str,
99107
format: str,
100108
clear_cache: bool,
109+
config_path: str,
101110
verbose: int,
102111
):
103112
"""Main function of the script.
@@ -125,6 +134,12 @@ def main(
125134
)
126135
gh = Github(gh_token)
127136

137+
if config_path is None:
138+
config = remote_config(gh, org_repo, rev=stop_rev)
139+
else:
140+
config = local_config(Path(config_path))
141+
config = add_config_defaults(config)
142+
128143
print("Fetching commits...", file=sys.stderr)
129144
commits = commits_between(gh, org_repo, start_rev, stop_rev)
130145
pull_requests = pull_requests_from_commits(
@@ -144,6 +159,12 @@ def main(
144159
authors=authors,
145160
reviewers=reviewers,
146161
version=version,
162+
title_template=config["title_template"],
163+
intro_template=config["intro_template"],
164+
outro_template=config["outro_template"],
165+
label_section_map=config["label_section_map"],
166+
pr_summary_regex=re.compile(config["pr_summary_regex"], flags=re.MULTILINE),
167+
ignored_user_logins=config["ignored_user_logins"],
147168
)
148169

149170
if out:

src/changelist/_config.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import logging
2+
from pathlib import Path
3+
4+
from github import Github, UnknownObjectException
5+
6+
try:
7+
import tomllib
8+
except ModuleNotFoundError:
9+
import tomli as tomllib
10+
11+
logger = logging.getLogger(__name__)
12+
13+
here = Path(__file__)
14+
15+
16+
DEFAULT_CONFIG_PATH = here.parent / "default_config.toml"
17+
18+
19+
def remote_config(gh: Github, org_repo: str, *, rev: str):
20+
repo = gh.get_repo(org_repo)
21+
try:
22+
file = repo.get_contents("pyproject.toml", ref=rev)
23+
logger.debug("found pyproject.toml in %s@%s", org_repo, rev)
24+
content = file.decoded_content.decode()
25+
except UnknownObjectException:
26+
content = ""
27+
config = tomllib.loads(content)
28+
config = config.get("tool", {}).get("changelist", {})
29+
return config
30+
31+
32+
def local_config(path: Path) -> dict:
33+
with path.open("rb") as fp:
34+
config = tomllib.load(fp)
35+
config = config.get("tool", {}).get("changelist", {})
36+
return config
37+
38+
39+
def add_config_defaults(
40+
config: dict, *, default_config_path: Path = DEFAULT_CONFIG_PATH
41+
) -> dict:
42+
with default_config_path.open("rb") as fp:
43+
defaults = tomllib.load(fp)
44+
defaults = defaults["tool"]["changelist"]
45+
for key, value in defaults.items():
46+
if key not in config:
47+
config[key] = value
48+
logger.debug("using default config value for %s", key)
49+
return config

src/changelist/_format.py

Lines changed: 13 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -20,31 +20,15 @@ class MdFormatter:
2020
authors: set[Union[NamedUser]]
2121
reviewers: set[NamedUser]
2222

23-
version: str = "x.y.z"
24-
title_template: str = "{repo_name} {version}"
25-
intro_template: str = """
26-
We're happy to announce the release of {repo_name} {version}!
27-
"""
28-
outro_template: str = (
29-
"_These lists are automatically generated, and may not be complete or may "
30-
"contain duplicates._\n"
31-
)
23+
version: str
24+
title_template: str
25+
intro_template: str
26+
outro_template: str
27+
3228
# Associate regexes matching PR labels to a section titles in the release notes
33-
regex_section_map: tuple[tuple[str, str], ...] = (
34-
(".*Highlight.*", "Highlights"),
35-
(".*New feature.*", "New Features"),
36-
(".*Enhancement.*", "Enhancements"),
37-
(".*Performance.*", "Performance"),
38-
(".*Bug fix.*", "Bug Fixes"),
39-
(".*API.*", "API Changes"),
40-
(".*Maintenance.*", "Maintenance"),
41-
(".*Documentation.*", "Documentation"),
42-
(".*Infrastructure.*", "Infrastructure"),
43-
)
44-
ignored_user_logins: tuple[str] = ("web-flow",)
45-
pr_summary_regex = re.compile(
46-
r"^```release-note\s*(?P<summary>[\s\S]*?\w[\s\S]*?)\s*^```", flags=re.MULTILINE
47-
)
29+
label_section_map: dict[str, str]
30+
pr_summary_regex: re.Pattern
31+
ignored_user_logins: tuple[str]
4832

4933
def __str__(self) -> str:
5034
"""Return complete release notes document as a string."""
@@ -80,10 +64,10 @@ def _prs_by_section(self) -> OrderedDict[str, set[PullRequest]]:
8064
"""
8165
label_section_map = {
8266
re.compile(pattern): section_name
83-
for pattern, section_name in self.regex_section_map
67+
for pattern, section_name in self.label_section_map.items()
8468
}
8569
prs_by_section = OrderedDict()
86-
for _, section_name in self.regex_section_map:
70+
for _, section_name in self.label_section_map.items():
8771
prs_by_section[section_name] = set()
8872
prs_by_section["Other"] = set()
8973

@@ -182,7 +166,9 @@ def _format_intro(self):
182166
yield from (f"{line}\n" for line in intro.split("\n"))
183167

184168
def _format_outro(self) -> Iterable[str]:
185-
outro = self.outro_template
169+
outro = self.outro_template.format(
170+
repo_name=self.repo_name, version=self.version
171+
)
186172
# Make sure to return exactly one line at a time
187173
yield from (f"{line}\n" for line in outro.split("\n"))
188174

0 commit comments

Comments
 (0)