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.
- changelist-0.1/LICENSE.txt +28 -0
- changelist-0.1/PKG-INFO +145 -0
- changelist-0.1/README.md +100 -0
- changelist-0.1/pyproject.toml +48 -0
- changelist-0.1/setup.cfg +4 -0
- changelist-0.1/src/changelist/__init__.py +0 -0
- changelist-0.1/src/changelist/__main__.py +527 -0
- changelist-0.1/src/changelist.egg-info/PKG-INFO +145 -0
- changelist-0.1/src/changelist.egg-info/SOURCES.txt +11 -0
- changelist-0.1/src/changelist.egg-info/dependency_links.txt +1 -0
- changelist-0.1/src/changelist.egg-info/entry_points.txt +2 -0
- changelist-0.1/src/changelist.egg-info/requires.txt +7 -0
- changelist-0.1/src/changelist.egg-info/top_level.txt +1 -0
|
@@ -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.
|
changelist-0.1/PKG-INFO
ADDED
|
@@ -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.
|
changelist-0.1/README.md
ADDED
|
@@ -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
|
+
]
|
changelist-0.1/setup.cfg
ADDED
|
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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
changelist
|