changelist 0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2023, Scientific Python
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,145 @@
1
+ Metadata-Version: 2.1
2
+ Name: changelist
3
+ Version: 0.1
4
+ License: BSD 3-Clause License
5
+
6
+ Copyright (c) 2023, Scientific Python
7
+
8
+ Redistribution and use in source and binary forms, with or without
9
+ modification, are permitted provided that the following conditions are met:
10
+
11
+ 1. Redistributions of source code must retain the above copyright notice, this
12
+ list of conditions and the following disclaimer.
13
+
14
+ 2. Redistributions in binary form must reproduce the above copyright notice,
15
+ this list of conditions and the following disclaimer in the documentation
16
+ and/or other materials provided with the distribution.
17
+
18
+ 3. Neither the name of the copyright holder nor the names of its
19
+ contributors may be used to endorse or promote products derived from
20
+ this software without specific prior written permission.
21
+
22
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
23
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
24
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
25
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
26
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
27
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
28
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
29
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
30
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
31
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
32
+
33
+ Project-URL: Source, https://github.com/scientific-python/changelist
34
+ Classifier: Development Status :: 3 - Alpha
35
+ Classifier: License :: OSI Approved :: BSD License
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: Programming Language :: Python :: 3.9
38
+ Classifier: Programming Language :: Python :: 3.10
39
+ Classifier: Programming Language :: Python :: 3.11
40
+ Classifier: Programming Language :: Python :: 3 :: Only
41
+ Requires-Python: >=3.9
42
+ Description-Content-Type: text/markdown
43
+ Provides-Extra: lint
44
+ License-File: LICENSE.txt
45
+
46
+ # changelist
47
+
48
+ Prepare an automatic changelog from GitHub pull requests.
49
+
50
+ _This project is currently in its alpha stage and might be incomplete or change a lot!_
51
+
52
+ ## Installation
53
+
54
+ ```sh
55
+ pip install changelist
56
+ ```
57
+
58
+ ## Set up your repository
59
+
60
+ To categorize merged PRs in the changelist, each PR
61
+ must have have one of the following labels:
62
+
63
+ - `type: Highlights`
64
+ - `type: New features`
65
+ - `type: Enhancements`
66
+ - `type: Performance`
67
+ - `type: Bug fix`
68
+ - `type: API`
69
+ - `type: Maintenance`
70
+ - `type: Documentation`
71
+ - `type: Infrastructure`
72
+
73
+ This list will soon be configurable.
74
+
75
+ ### Label checking
76
+
77
+ To ensure that each PR has an associated `type: ` label,
78
+ we recommend adding an action that fails CI if the label is missing.
79
+
80
+ To do so, place the following in `.github/workflows/label-check.yaml`:
81
+
82
+ ```yaml
83
+ name: Labels
84
+
85
+ on:
86
+ pull_request:
87
+ types:
88
+ - opened
89
+ - labeled
90
+ - unlabeled
91
+
92
+ env:
93
+ LABELS: ${{ join( github.event.pull_request.labels.*.name, ' ' ) }}
94
+
95
+ jobs:
96
+ check-type-label:
97
+ name: ensure type label
98
+ runs-on: ubuntu-latest
99
+ steps:
100
+ - if: "contains( env.LABELS, 'type: ' ) == false"
101
+ run: exit 1
102
+ ```
103
+
104
+ ### Milestones
105
+
106
+ Often, it is helpful to have milestones that reflect the actual PRs
107
+ merged. We therefore recommend adding an action that attached the
108
+ next open milestone to any merged PR.
109
+
110
+ To do so, place the following in `.github/workflows/milestone-merged-prs.yaml`:
111
+
112
+ ```yaml
113
+ name: Milestone
114
+
115
+ on:
116
+ pull_request_target:
117
+ types:
118
+ - closed
119
+ branches:
120
+ - "main"
121
+
122
+ jobs:
123
+ milestone_pr:
124
+ name: attach to PR
125
+ runs-on: ubuntu-latest
126
+ steps:
127
+ - uses: scientific-python/attach-next-milestone-action@f94a5235518d4d34911c41e19d780b8e79d42238
128
+ with:
129
+ token: ${{ secrets.MILESTONE_LABELER_TOKEN }}
130
+ force: true
131
+ ```
132
+
133
+ See https://github.com/scientific-python/attach-next-milestone-action for more information.
134
+
135
+ ## Usage
136
+
137
+ ```sh
138
+ export GH_TOKEN='...'
139
+ changelist scikit-image/scikit-image v0.21.0 main
140
+ ```
141
+
142
+ The script requires a [GitHub personal access
143
+ token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens).
144
+ The token does not need any permissions, since it is used only to
145
+ increase query limits.
@@ -0,0 +1,100 @@
1
+ # changelist
2
+
3
+ Prepare an automatic changelog from GitHub pull requests.
4
+
5
+ _This project is currently in its alpha stage and might be incomplete or change a lot!_
6
+
7
+ ## Installation
8
+
9
+ ```sh
10
+ pip install changelist
11
+ ```
12
+
13
+ ## Set up your repository
14
+
15
+ To categorize merged PRs in the changelist, each PR
16
+ must have have one of the following labels:
17
+
18
+ - `type: Highlights`
19
+ - `type: New features`
20
+ - `type: Enhancements`
21
+ - `type: Performance`
22
+ - `type: Bug fix`
23
+ - `type: API`
24
+ - `type: Maintenance`
25
+ - `type: Documentation`
26
+ - `type: Infrastructure`
27
+
28
+ This list will soon be configurable.
29
+
30
+ ### Label checking
31
+
32
+ To ensure that each PR has an associated `type: ` label,
33
+ we recommend adding an action that fails CI if the label is missing.
34
+
35
+ To do so, place the following in `.github/workflows/label-check.yaml`:
36
+
37
+ ```yaml
38
+ name: Labels
39
+
40
+ on:
41
+ pull_request:
42
+ types:
43
+ - opened
44
+ - labeled
45
+ - unlabeled
46
+
47
+ env:
48
+ LABELS: ${{ join( github.event.pull_request.labels.*.name, ' ' ) }}
49
+
50
+ jobs:
51
+ check-type-label:
52
+ name: ensure type label
53
+ runs-on: ubuntu-latest
54
+ steps:
55
+ - if: "contains( env.LABELS, 'type: ' ) == false"
56
+ run: exit 1
57
+ ```
58
+
59
+ ### Milestones
60
+
61
+ Often, it is helpful to have milestones that reflect the actual PRs
62
+ merged. We therefore recommend adding an action that attached the
63
+ next open milestone to any merged PR.
64
+
65
+ To do so, place the following in `.github/workflows/milestone-merged-prs.yaml`:
66
+
67
+ ```yaml
68
+ name: Milestone
69
+
70
+ on:
71
+ pull_request_target:
72
+ types:
73
+ - closed
74
+ branches:
75
+ - "main"
76
+
77
+ jobs:
78
+ milestone_pr:
79
+ name: attach to PR
80
+ runs-on: ubuntu-latest
81
+ steps:
82
+ - uses: scientific-python/attach-next-milestone-action@f94a5235518d4d34911c41e19d780b8e79d42238
83
+ with:
84
+ token: ${{ secrets.MILESTONE_LABELER_TOKEN }}
85
+ force: true
86
+ ```
87
+
88
+ See https://github.com/scientific-python/attach-next-milestone-action for more information.
89
+
90
+ ## Usage
91
+
92
+ ```sh
93
+ export GH_TOKEN='...'
94
+ changelist scikit-image/scikit-image v0.21.0 main
95
+ ```
96
+
97
+ The script requires a [GitHub personal access
98
+ token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens).
99
+ The token does not need any permissions, since it is used only to
100
+ increase query limits.
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["setuptools>=43.0.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "changelist"
7
+ version = "0.1"
8
+ requires-python = ">=3.9"
9
+ readme = "README.md"
10
+ license = {file = "LICENSE.txt"}
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "License :: OSI Approved :: BSD License",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.9",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ ]
20
+
21
+ dependencies = [
22
+ "requests",
23
+ "requests-cache",
24
+ "tqdm",
25
+ "PyGithub"
26
+ ]
27
+
28
+ [project.urls]
29
+ "Source" = "https://github.com/scientific-python/changelist"
30
+
31
+ [project.scripts]
32
+ changelist = "changelist.__main__:main"
33
+
34
+ [project.optional-dependencies]
35
+ lint = ["pre-commit == 3.3.3"]
36
+
37
+ [tool.ruff]
38
+ line-length = 88
39
+ target-version = "py39"
40
+ select = [
41
+ "C",
42
+ "E",
43
+ "F",
44
+ "W",
45
+ "B",
46
+ "I",
47
+ "UP",
48
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,527 @@
1
+ """Prepare an automatic changelog from GitHub's pull requests."""
2
+
3
+
4
+ import argparse
5
+ import json
6
+ import logging
7
+ import os
8
+ import re
9
+ import sys
10
+ import tempfile
11
+ from collections import OrderedDict
12
+ from collections.abc import Iterable
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Callable, Union
16
+
17
+ import requests
18
+ import requests_cache
19
+ from github import Github
20
+ from github.Commit import Commit
21
+ from github.NamedUser import NamedUser
22
+ from github.PullRequest import PullRequest
23
+ from tqdm import tqdm
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ here = Path(__file__).parent
28
+
29
+ REQUESTS_CACHE_PATH = Path(tempfile.gettempdir()) / "github_cache.sqlite"
30
+
31
+ GH_URL = "https://github.com"
32
+
33
+
34
+ def lazy_tqdm(*args, **kwargs):
35
+ """Defer initialization of progress bar until first item is requested.
36
+
37
+ Calling `tqdm(...)` prints the progress bar right there and then. This can scramble
38
+ output, if more than one progress bar are initialized at the same time but their
39
+ iteration is meant to be done later in successive order.
40
+ """
41
+ kwargs["file"] = kwargs.get("file", sys.stderr)
42
+ yield from tqdm(*args, **kwargs)
43
+
44
+
45
+ def commits_between(
46
+ gh: Github, org_name: str, start_rev: str, stop_rev: str
47
+ ) -> set[Commit]:
48
+ """Fetch commits between two revisions excluding the commit of `start_rev`."""
49
+ repo = gh.get_repo(org_name)
50
+ comparison = repo.compare(base=start_rev, head=stop_rev)
51
+ commits = set(comparison.commits)
52
+ assert repo.get_commit(start_rev) not in commits
53
+ assert repo.get_commit(stop_rev) in commits
54
+ return commits
55
+
56
+
57
+ def pull_requests_from_commits(commits: Iterable[Commit]) -> set[PullRequest]:
58
+ """Fetch pull requests that are associated with the given `commits`."""
59
+ all_pull_requests = set()
60
+ for commit in commits:
61
+ commit_pull_requests = list(commit.get_pulls())
62
+ if len(commit_pull_requests) != 1:
63
+ logger.info(
64
+ "%s with no or multiple PR(s): %r",
65
+ commit.html_url,
66
+ [p.html_url for p in commit_pull_requests],
67
+ )
68
+ if any(not p.merged for p in commit_pull_requests):
69
+ logger.error(
70
+ "%s with unmerged PRs: %r",
71
+ )
72
+ for pull in commit_pull_requests:
73
+ if pull in all_pull_requests:
74
+ # Expected if pull request is merged without squashing
75
+ logger.debug(
76
+ "%r associated with multiple commits",
77
+ pull.html_url,
78
+ )
79
+ all_pull_requests.update(commit_pull_requests)
80
+ return all_pull_requests
81
+
82
+
83
+ @dataclass(frozen=True, kw_only=True)
84
+ class GitHubGraphQl:
85
+ """Interface to query GitHub's GraphQL API for a particular repository."""
86
+
87
+ org_name: str
88
+ repo_name: str
89
+
90
+ URL: str = "https://api.github.com/graphql"
91
+ GRAPHQL_AUTHORS: str = """
92
+ query {{
93
+ repository (owner: "{org_name}" name: "{repo_name}") {{
94
+ object(expression: "{commit_sha}" ) {{
95
+ ... on Commit {{
96
+ commitUrl
97
+ authors(first:{page_limit}) {{
98
+ edges {{
99
+ cursor
100
+ node {{
101
+ name
102
+ email
103
+ user {{
104
+ login
105
+ databaseId
106
+ }}
107
+ }}
108
+ }}
109
+ }}
110
+ }}
111
+ }}
112
+ }}
113
+ }}
114
+ """
115
+ PAGE_LIMIT: int = 100
116
+
117
+ def _run_query(self, query: str) -> dict:
118
+ """Fetch results for a GraphQl query."""
119
+ headers = {"Authorization": f"Bearer {os.environ.get('GH_TOKEN')}"}
120
+ sanitized_query = json.dumps({"query": query.replace("\n", "")})
121
+ response = requests.post(self.URL, data=sanitized_query, headers=headers)
122
+ data = response.json()
123
+ return data
124
+
125
+ def find_authors(self, commit_sha: str) -> dict[int, str]:
126
+ """Find ID and login of (co-)author(s) for a commit.
127
+
128
+ Other than GitHub's REST API, the GraphQL API supports returning all authors,
129
+ including co-authors, of a commit.
130
+ """
131
+ query = self.GRAPHQL_AUTHORS.format(
132
+ org_name=self.org_name,
133
+ repo_name=self.repo_name,
134
+ commit_sha=commit_sha,
135
+ page_limit=self.PAGE_LIMIT,
136
+ )
137
+ data = self._run_query(query)
138
+ commit = data["data"]["repository"]["object"]
139
+ edges = commit["authors"]["edges"]
140
+ if len(edges) == self.PAGE_LIMIT:
141
+ # TODO implement pagination if this becomes an issue, e.g. see
142
+ # https://github.com/scientific-python/devstats-data/blob/e3cd826518bf590083409318b0a7518f7781084f/query.py#L92-L107
143
+ logger.warning(
144
+ "reached page limit while querying authors in %r, "
145
+ "only the first %i authors will be included",
146
+ commit["commitUrl"],
147
+ self.PAGE_LIMIT,
148
+ )
149
+
150
+ coauthors = {}
151
+ for _i, edge in enumerate(edges):
152
+ node = edge["node"]
153
+ user = node["user"]
154
+ if user is None:
155
+ logger.warning(
156
+ "could not determine GitHub user for %r in %r",
157
+ node,
158
+ commit["commitUrl"],
159
+ )
160
+ continue
161
+ coauthors[user["databaseId"]] = user["login"]
162
+
163
+ assert coauthors
164
+ return coauthors
165
+
166
+
167
+ def contributors(
168
+ gh: Github,
169
+ org_repo: str,
170
+ commits: Iterable[Commit],
171
+ pull_requests: Iterable[PullRequest],
172
+ ) -> tuple[set[NamedUser], set[NamedUser]]:
173
+ """Fetch commit authors, co-authors and reviewers.
174
+
175
+ `authors` are users which created or co-authored a commit.
176
+ `reviewers` are users, who added reviews to a merged pull request or merged a
177
+ pull request (committer of the merge commit).
178
+ """
179
+ authors = set()
180
+ reviewers = set()
181
+
182
+ org_name, repo_name = org_repo.split("/")
183
+ ql = GitHubGraphQl(org_name=org_name, repo_name=repo_name)
184
+
185
+ for commit in commits:
186
+ if commit.author:
187
+ authors.add(commit.author)
188
+ if commit.committer:
189
+ reviewers.add(commit.committer)
190
+ if "Co-authored-by:" in commit.commit.message:
191
+ # Fallback on GraphQL API to find co-authors as well
192
+ user_ids = ql.find_authors(commit.sha)
193
+ for user_id, user_login in user_ids.items():
194
+ named_user = gh.get_user_by_id(user_id)
195
+ assert named_user.login == user_login
196
+ authors.add(named_user)
197
+ else:
198
+ logger.debug("no co-authors in %r", commit.html_url)
199
+
200
+ for pull in pull_requests:
201
+ for review in pull.get_reviews():
202
+ if review.user:
203
+ reviewers.add(review.user)
204
+
205
+ return authors, reviewers
206
+
207
+
208
+ @dataclass(frozen=True, kw_only=True)
209
+ class MdFormatter:
210
+ """Format release notes in Markdown from PRs, authors and reviewers."""
211
+
212
+ repo_name: str
213
+ pull_requests: set[PullRequest]
214
+ authors: set[Union[NamedUser]]
215
+ reviewers: set[NamedUser]
216
+
217
+ version: str = "x.y.z"
218
+ title_template: str = "{repo_name} {version}"
219
+ intro_template: str = """
220
+ We're happy to announce the release of {repo_name} {version}!
221
+ """
222
+ outro_template: str = (
223
+ "_These lists are automatically generated, and may not be complete or may "
224
+ "contain duplicates._\n"
225
+ )
226
+ # Associate regexes matching PR labels to a section titles in the release notes
227
+ regex_section_map: tuple[tuple[str, str], ...] = (
228
+ (".*Highlight.*", "Highlights"),
229
+ (".*New feature.*", "New Features"),
230
+ (".*Enhancement.*", "Enhancements"),
231
+ (".*Performance.*", "Performance"),
232
+ (".*Bug fix.*", "Bug Fixes"),
233
+ (".*API.*", "API Changes"),
234
+ (".*Maintenance.*", "Maintenance"),
235
+ (".*Documentation.*", "Documentation"),
236
+ (".*Infrastructure.*", "Infrastructure"),
237
+ )
238
+ ignored_user_logins: tuple[str] = ("web-flow",)
239
+ pr_summary_regex = re.compile(
240
+ r"^```release-note\s*(?P<summary>[\s\S]*?\w[\s\S]*?)\s*^```", flags=re.MULTILINE
241
+ )
242
+
243
+ def __str__(self) -> str:
244
+ """Return complete release notes document as a string."""
245
+ return self.document
246
+
247
+ def __iter__(self) -> Iterable[str]:
248
+ """Iterate the release notes document line-wise."""
249
+ return self.iter_lines()
250
+
251
+ @property
252
+ def document(self) -> str:
253
+ """Return complete release notes document as a string."""
254
+ return "".join(self.iter_lines())
255
+
256
+ def iter_lines(self) -> Iterable[str]:
257
+ """Iterate the release notes document line-wise."""
258
+ title = self.title_template.format(
259
+ repo_name=self.repo_name, version=self.version
260
+ )
261
+ yield from self._format_section_title(title, level=1)
262
+ yield from self._format_intro()
263
+ for title, pull_requests in self._prs_by_section.items():
264
+ yield from self._format_pr_section(title, pull_requests)
265
+ yield from self._format_contributor_section(self.authors, self.reviewers)
266
+ yield from self._format_outro()
267
+
268
+ @property
269
+ def _prs_by_section(self) -> OrderedDict[str, set[PullRequest]]:
270
+ """Map pull requests to section titles.
271
+
272
+ Pull requests whose labels do not match one of the sections given in
273
+ `regex_section_map`, are sorted into a section named "Other".
274
+ """
275
+ label_section_map = {
276
+ re.compile(pattern): section_name
277
+ for pattern, section_name in self.regex_section_map
278
+ }
279
+ prs_by_section = OrderedDict()
280
+ for _, section_name in self.regex_section_map:
281
+ prs_by_section[section_name] = set()
282
+ prs_by_section["Other"] = set()
283
+
284
+ for pr in self.pull_requests:
285
+ matching_sections = [
286
+ section_name
287
+ for regex, section_name in label_section_map.items()
288
+ if any(regex.match(label.name) for label in pr.labels)
289
+ ]
290
+ for section_name in matching_sections:
291
+ prs_by_section[section_name].add(pr)
292
+ if not matching_sections:
293
+ logger.warning(
294
+ "%s without matching label, sorting into section 'Other'",
295
+ pr.html_url,
296
+ )
297
+ prs_by_section["Other"].add(pr)
298
+
299
+ return prs_by_section
300
+
301
+ def _sanitize_text(self, text: str) -> str:
302
+ text = text.strip()
303
+ text = text.replace("\r\n", " ")
304
+ text = text.replace("\n", " ")
305
+ return text
306
+
307
+ def _format_link(self, name: str, target: str) -> str:
308
+ return f"[{name}]({target})"
309
+
310
+ def _format_section_title(self, title: str, *, level: int) -> Iterable[str]:
311
+ yield f"{'#' * level} {title}\n"
312
+
313
+ def _parse_pull_request_summary(self, pr: PullRequest) -> str:
314
+ if pr.body and (match := self.pr_summary_regex.search(pr.body)):
315
+ summary = match["summary"]
316
+ else:
317
+ logger.debug("falling back to title for %s", pr.html_url)
318
+ summary = pr.title
319
+ summary = self._sanitize_text(summary)
320
+ return summary
321
+
322
+ def _format_pull_request(self, pr: PullRequest) -> Iterable[str]:
323
+ summary = self._parse_pull_request_summary(pr).rstrip(".")
324
+ yield f"- {summary}\n"
325
+ link = self._format_link(f"#{pr.number}", f"{pr.html_url}")
326
+ yield f" ({link}).\n"
327
+
328
+ def _format_pr_section(
329
+ self, title: str, pull_requests: set[PullRequest]
330
+ ) -> Iterable[str]:
331
+ """Format a section title and list its pull requests sorted by merge date."""
332
+ if pull_requests:
333
+ yield from self._format_section_title(title, level=2)
334
+ for pr in sorted(pull_requests, key=lambda pr: pr.merged_at):
335
+ yield from self._format_pull_request(pr)
336
+ yield "\n"
337
+
338
+ def _format_user_line(self, user: Union[NamedUser]) -> str:
339
+ line = f"@{user.login}"
340
+ line = self._format_link(line, user.html_url)
341
+ if user.name:
342
+ line = f"{user.name} ({line})"
343
+ return line + ",\n"
344
+
345
+ def _format_contributor_section(
346
+ self,
347
+ authors: set[Union[NamedUser]],
348
+ reviewers: set[NamedUser],
349
+ ) -> Iterable[str]:
350
+ """Format contributor section and list users sorted by login handle."""
351
+ authors = {u for u in authors if u.login not in self.ignored_user_logins}
352
+ reviewers = {u for u in reviewers if u.login not in self.ignored_user_logins}
353
+
354
+ yield from self._format_section_title("Contributors", level=2)
355
+ yield "\n"
356
+
357
+ yield f"{len(authors)} authors added to this release (alphabetically):\n"
358
+ author_lines = map(self._format_user_line, authors)
359
+ yield from sorted(author_lines, key=lambda s: s.lower())
360
+ yield "\n"
361
+
362
+ yield f"{len(reviewers)} reviewers added to this release (alphabetically):\n"
363
+ reviewers_lines = map(self._format_user_line, reviewers)
364
+ yield from sorted(reviewers_lines, key=lambda s: s.lower())
365
+ yield "\n"
366
+
367
+ def _format_intro(self):
368
+ intro = self.intro_template.format(
369
+ repo_name=self.repo_name, version=self.version
370
+ )
371
+ # Make sure to return exactly one line at a time
372
+ yield from (f"{line}\n" for line in intro.split("\n"))
373
+
374
+ def _format_outro(self) -> Iterable[str]:
375
+ outro = self.outro_template
376
+ # Make sure to return exactly one line at a time
377
+ yield from (f"{line}\n" for line in outro.split("\n"))
378
+
379
+
380
+ class RstFormatter(MdFormatter):
381
+ """Format release notes in reStructuredText from PRs, authors and reviewers."""
382
+
383
+ def _sanitize_text(self, text) -> str:
384
+ text = super()._sanitize_text(text)
385
+ text = text.replace("`", "``")
386
+ return text
387
+
388
+ def _format_link(self, name: str, target: str) -> str:
389
+ return f"`{name} <{target}>`_"
390
+
391
+ def _format_section_title(self, title: str, *, level: int) -> Iterable[str]:
392
+ yield title + "\n"
393
+ underline = {1: "=", 2: "-", 3: "~"}
394
+ yield underline[level] * len(title) + "\n"
395
+
396
+
397
+ def parse_command_line(func: Callable) -> Callable:
398
+ """Define and parse command line options.
399
+
400
+ Has no effect if any keyword argument is passed to the underlying function.
401
+ """
402
+ parser = argparse.ArgumentParser(usage=__doc__)
403
+ parser.add_argument(
404
+ "org_repo",
405
+ help="Org and repo name of a repository on GitHub (delimited by a slash), "
406
+ "e.g. 'numpy/numpy'",
407
+ )
408
+ parser.add_argument(
409
+ "start_rev",
410
+ help="The starting revision (excluded), e.g. the tag of the previous release",
411
+ )
412
+ parser.add_argument(
413
+ "stop_rev",
414
+ help="The stop revision (included), e.g. the 'main' branch or the current "
415
+ "release",
416
+ )
417
+ parser.add_argument(
418
+ "--version",
419
+ default="0.0.0",
420
+ help="Version you're about to release, used title and description of the notes",
421
+ )
422
+ parser.add_argument("--out", help="Write to file, prints to STDOUT otherwise")
423
+ parser.add_argument(
424
+ "--format",
425
+ choices=["rst", "md"],
426
+ default="md",
427
+ help="Choose format, defaults to Markdown",
428
+ )
429
+ parser.add_argument(
430
+ "--clear-cache",
431
+ action="store_true",
432
+ help="Clear cached requests to GitHub's API before running",
433
+ )
434
+ parser.add_argument(
435
+ "-v",
436
+ "--verbose",
437
+ action="count",
438
+ default=0,
439
+ help="Increase logging level",
440
+ )
441
+
442
+ def wrapped(**kwargs):
443
+ if not kwargs:
444
+ kwargs = vars(parser.parse_args())
445
+ return func(**kwargs)
446
+
447
+ return wrapped
448
+
449
+
450
+ @parse_command_line
451
+ def main(
452
+ *,
453
+ org_repo: str,
454
+ start_rev: str,
455
+ stop_rev: str,
456
+ version: str,
457
+ out: str,
458
+ format: str,
459
+ clear_cache: bool,
460
+ verbose: int,
461
+ ):
462
+ """Main function of the script.
463
+
464
+ See :func:`parse_command_line` for a description of the accepted input.
465
+ """
466
+ level = {0: logging.WARNING, 1: logging.INFO}.get(verbose, logging.DEBUG)
467
+ logger.setLevel(level)
468
+
469
+ requests_cache.install_cache(
470
+ REQUESTS_CACHE_PATH, backend="sqlite", expire_after=3600
471
+ )
472
+ print(f"Using requests cache at {REQUESTS_CACHE_PATH}", file=sys.stderr)
473
+ if clear_cache:
474
+ requests_cache.clear()
475
+ logger.info("cleared requests cache at %s", REQUESTS_CACHE_PATH)
476
+
477
+ gh_token = os.environ.get("GH_TOKEN")
478
+ if gh_token is None:
479
+ raise RuntimeError(
480
+ "You need to set the environment variable `GH_TOKEN`. "
481
+ "The token is used to avoid rate limiting, "
482
+ "and can be created at https://github.com/settings/tokens.\n\n"
483
+ "The token does not require any permissions (we only use the public API)."
484
+ )
485
+ gh = Github(gh_token)
486
+
487
+ print("Fetching commits...", file=sys.stderr)
488
+ commits = commits_between(gh, org_repo, start_rev, stop_rev)
489
+ pull_requests = pull_requests_from_commits(
490
+ lazy_tqdm(commits, desc="Fetching pull requests")
491
+ )
492
+ authors, reviewers = contributors(
493
+ gh=gh,
494
+ org_repo=org_repo,
495
+ commits=lazy_tqdm(commits, desc="Fetching authors"),
496
+ pull_requests=lazy_tqdm(pull_requests, desc="Fetching reviewers"),
497
+ )
498
+
499
+ Formatter = {"md": MdFormatter, "rst": RstFormatter}[format]
500
+ formatter = Formatter(
501
+ repo_name=org_repo.split("/")[-1],
502
+ pull_requests=pull_requests,
503
+ authors=authors,
504
+ reviewers=reviewers,
505
+ version=version,
506
+ )
507
+
508
+ if out:
509
+ out_path = Path(out)
510
+ out_path.parent.mkdir(parents=True, exist_ok=True)
511
+ with open(out, "w") as io:
512
+ io.writelines(formatter.iter_lines())
513
+ else:
514
+ print()
515
+ for line in formatter.iter_lines():
516
+ assert line.endswith("\n")
517
+ assert line.count("\n") == 1
518
+ print(line, end="", file=sys.stdout)
519
+
520
+
521
+ if __name__ == "__main__":
522
+ logging.basicConfig(
523
+ level=logging.WARNING,
524
+ format="%(levelname)s: %(filename)s::%(funcName)s: %(message)s",
525
+ stream=sys.stderr,
526
+ )
527
+ main()
@@ -0,0 +1,145 @@
1
+ Metadata-Version: 2.1
2
+ Name: changelist
3
+ Version: 0.1
4
+ License: BSD 3-Clause License
5
+
6
+ Copyright (c) 2023, Scientific Python
7
+
8
+ Redistribution and use in source and binary forms, with or without
9
+ modification, are permitted provided that the following conditions are met:
10
+
11
+ 1. Redistributions of source code must retain the above copyright notice, this
12
+ list of conditions and the following disclaimer.
13
+
14
+ 2. Redistributions in binary form must reproduce the above copyright notice,
15
+ this list of conditions and the following disclaimer in the documentation
16
+ and/or other materials provided with the distribution.
17
+
18
+ 3. Neither the name of the copyright holder nor the names of its
19
+ contributors may be used to endorse or promote products derived from
20
+ this software without specific prior written permission.
21
+
22
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
23
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
24
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
25
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
26
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
27
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
28
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
29
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
30
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
31
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
32
+
33
+ Project-URL: Source, https://github.com/scientific-python/changelist
34
+ Classifier: Development Status :: 3 - Alpha
35
+ Classifier: License :: OSI Approved :: BSD License
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: Programming Language :: Python :: 3.9
38
+ Classifier: Programming Language :: Python :: 3.10
39
+ Classifier: Programming Language :: Python :: 3.11
40
+ Classifier: Programming Language :: Python :: 3 :: Only
41
+ Requires-Python: >=3.9
42
+ Description-Content-Type: text/markdown
43
+ Provides-Extra: lint
44
+ License-File: LICENSE.txt
45
+
46
+ # changelist
47
+
48
+ Prepare an automatic changelog from GitHub pull requests.
49
+
50
+ _This project is currently in its alpha stage and might be incomplete or change a lot!_
51
+
52
+ ## Installation
53
+
54
+ ```sh
55
+ pip install changelist
56
+ ```
57
+
58
+ ## Set up your repository
59
+
60
+ To categorize merged PRs in the changelist, each PR
61
+ must have have one of the following labels:
62
+
63
+ - `type: Highlights`
64
+ - `type: New features`
65
+ - `type: Enhancements`
66
+ - `type: Performance`
67
+ - `type: Bug fix`
68
+ - `type: API`
69
+ - `type: Maintenance`
70
+ - `type: Documentation`
71
+ - `type: Infrastructure`
72
+
73
+ This list will soon be configurable.
74
+
75
+ ### Label checking
76
+
77
+ To ensure that each PR has an associated `type: ` label,
78
+ we recommend adding an action that fails CI if the label is missing.
79
+
80
+ To do so, place the following in `.github/workflows/label-check.yaml`:
81
+
82
+ ```yaml
83
+ name: Labels
84
+
85
+ on:
86
+ pull_request:
87
+ types:
88
+ - opened
89
+ - labeled
90
+ - unlabeled
91
+
92
+ env:
93
+ LABELS: ${{ join( github.event.pull_request.labels.*.name, ' ' ) }}
94
+
95
+ jobs:
96
+ check-type-label:
97
+ name: ensure type label
98
+ runs-on: ubuntu-latest
99
+ steps:
100
+ - if: "contains( env.LABELS, 'type: ' ) == false"
101
+ run: exit 1
102
+ ```
103
+
104
+ ### Milestones
105
+
106
+ Often, it is helpful to have milestones that reflect the actual PRs
107
+ merged. We therefore recommend adding an action that attached the
108
+ next open milestone to any merged PR.
109
+
110
+ To do so, place the following in `.github/workflows/milestone-merged-prs.yaml`:
111
+
112
+ ```yaml
113
+ name: Milestone
114
+
115
+ on:
116
+ pull_request_target:
117
+ types:
118
+ - closed
119
+ branches:
120
+ - "main"
121
+
122
+ jobs:
123
+ milestone_pr:
124
+ name: attach to PR
125
+ runs-on: ubuntu-latest
126
+ steps:
127
+ - uses: scientific-python/attach-next-milestone-action@f94a5235518d4d34911c41e19d780b8e79d42238
128
+ with:
129
+ token: ${{ secrets.MILESTONE_LABELER_TOKEN }}
130
+ force: true
131
+ ```
132
+
133
+ See https://github.com/scientific-python/attach-next-milestone-action for more information.
134
+
135
+ ## Usage
136
+
137
+ ```sh
138
+ export GH_TOKEN='...'
139
+ changelist scikit-image/scikit-image v0.21.0 main
140
+ ```
141
+
142
+ The script requires a [GitHub personal access
143
+ token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens).
144
+ The token does not need any permissions, since it is used only to
145
+ increase query limits.
@@ -0,0 +1,11 @@
1
+ LICENSE.txt
2
+ README.md
3
+ pyproject.toml
4
+ src/changelist/__init__.py
5
+ src/changelist/__main__.py
6
+ src/changelist.egg-info/PKG-INFO
7
+ src/changelist.egg-info/SOURCES.txt
8
+ src/changelist.egg-info/dependency_links.txt
9
+ src/changelist.egg-info/entry_points.txt
10
+ src/changelist.egg-info/requires.txt
11
+ src/changelist.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ changelist = changelist.__main__:main
@@ -0,0 +1,7 @@
1
+ requests
2
+ requests-cache
3
+ tqdm
4
+ PyGithub
5
+
6
+ [lint]
7
+ pre-commit==3.3.3
@@ -0,0 +1 @@
1
+ changelist