autopub 1.0.0a30__py3-none-any.whl → 1.0.0a32__py3-none-any.whl

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.
autopub/__init__.py CHANGED
@@ -40,6 +40,9 @@ class Autopub:
40
40
  RELEASE_FILE_PATH = "RELEASE.md"
41
41
  plugins: list[AutopubPlugin]
42
42
 
43
+ def __init__(self) -> None:
44
+ self.plugins = []
45
+
43
46
  @cached_property
44
47
  def config(self) -> ConfigType:
45
48
  pyproject_path = Path.cwd() / "pyproject.toml"
@@ -0,0 +1,354 @@
1
+ import json
2
+ import os
3
+ import pathlib
4
+ import textwrap
5
+ from functools import cached_property
6
+ from typing import Optional, TypedDict
7
+
8
+ from github import Github
9
+ from github.PullRequest import PullRequest
10
+ from github.Repository import Repository
11
+
12
+ from autopub.exceptions import AutopubException
13
+ from autopub.plugins import AutopubPlugin
14
+ from autopub.types import ReleaseInfo
15
+
16
+
17
+ class PRContributors(TypedDict):
18
+ pr_author: str
19
+ additional_contributors: set[str]
20
+ reviewers: set[str]
21
+
22
+
23
+ class Sponsors(TypedDict):
24
+ sponsors: set[str]
25
+ private_sponsors: int
26
+
27
+
28
+ class GithubPlugin(AutopubPlugin):
29
+ # TODO: think about the config
30
+ def __init__(self) -> None:
31
+ super().__init__()
32
+
33
+ self.github_token = os.environ.get("GITHUB_TOKEN")
34
+
35
+ if not self.github_token:
36
+ raise AutopubException("GITHUB_TOKEN environment variable is required")
37
+
38
+ self.repository_name = os.environ.get("GITHUB_REPOSITORY")
39
+ # TODO: maybe this should be in a config file?
40
+ self.discussion_category_name = os.environ.get("DISCUSSION_CATEGORY_NAME", "Announcements")
41
+
42
+ @cached_property
43
+ def _github(self) -> Github:
44
+ return Github(self.github_token)
45
+
46
+ @cached_property
47
+ def _event_data(self) -> Optional[dict]:
48
+ event_path = os.environ.get("GITHUB_EVENT_PATH")
49
+ if not event_path:
50
+ return None
51
+
52
+ with open(event_path) as f:
53
+ return json.load(f)
54
+
55
+ @cached_property
56
+ def repository(self) -> Repository:
57
+ return self._github.get_repo(self.repository_name)
58
+
59
+ @cached_property
60
+ def pull_request(self) -> Optional[PullRequest]:
61
+ pr_number = self._get_pr_number()
62
+
63
+ if pr_number is None:
64
+ return None
65
+
66
+ return self.repository.get_pull(pr_number)
67
+
68
+ def _get_pr_number(self) -> Optional[int]:
69
+ if not self._event_data:
70
+ return None
71
+
72
+ if self._event_data.get("event_name") in [
73
+ "pull_request",
74
+ "pull_request_target",
75
+ ]:
76
+ return self._event_data["pull_request"]["number"]
77
+
78
+ if self._event_data.get("pull_request"):
79
+ return self._event_data["pull_request"]["number"]
80
+
81
+ sha = self._event_data["commits"][0]["id"]
82
+
83
+ commit = self.repository.get_commit(sha)
84
+
85
+ pulls = commit.get_pulls()
86
+
87
+ try:
88
+ first_pr = pulls[0]
89
+ except IndexError:
90
+ return None
91
+
92
+ return first_pr.number
93
+
94
+ def _update_or_create_comment(
95
+ self, text: str, marker: str = "<!-- autopub-comment -->"
96
+ ) -> None:
97
+ """Update or create a comment on the current PR with the given text."""
98
+ print(f"Updating or creating comment on PR {self.pull_request} in {self.repository}")
99
+
100
+ # Look for existing autopub comment
101
+ comment_body = f"{marker}\n{text}"
102
+
103
+ # Search for existing comment
104
+ for comment in self.pull_request.get_issue_comments():
105
+ if marker in comment.body:
106
+ # Update existing comment
107
+ comment.edit(comment_body)
108
+ return
109
+
110
+ # Create new comment if none exists
111
+ self.pull_request.create_issue_comment(comment_body)
112
+
113
+ def _get_sponsors(self) -> Sponsors:
114
+ query_organisation = """
115
+ query GetSponsors($organization: String!) {
116
+ organization(login: $organization) {
117
+ sponsorshipsAsMaintainer(
118
+ first: 100
119
+ includePrivate: true
120
+ activeOnly: true
121
+ ) {
122
+ nodes {
123
+ privacyLevel
124
+ sponsorEntity {
125
+ __typename
126
+ ... on User {
127
+ login
128
+ }
129
+ ... on Organization {
130
+ login
131
+ }
132
+ }
133
+ }
134
+ }
135
+ }
136
+ }
137
+ """
138
+
139
+ query_user = """
140
+ query GetSponsors($user: String!) {
141
+ user(login: $user) {
142
+ sponsorshipsAsMaintainer(
143
+ first: 100
144
+ includePrivate: true
145
+ activeOnly: true
146
+ ) {
147
+ nodes {
148
+ privacyLevel
149
+ sponsorEntity {
150
+ __typename
151
+ ... on User {
152
+ login
153
+ }
154
+ ... on Organization {
155
+ login
156
+ }
157
+ }
158
+ }
159
+ }
160
+ }
161
+ }
162
+ """
163
+
164
+ # TODO: there might be some permission issues in some cases
165
+ if self.repository.organization:
166
+ _, response = self._github.requester.graphql_query(
167
+ query_organisation, {"organization": self.repository.organization.login}
168
+ )
169
+ else:
170
+ _, response = self._github.requester.graphql_query(
171
+ query_user, {"user": self.repository.owner.login}
172
+ )
173
+
174
+ sponsors = set()
175
+ private_sponsors = 0
176
+
177
+ for node in response["data"]["organization"]["sponsorshipsAsMaintainer"]["nodes"]:
178
+ if node["privacyLevel"] == "PUBLIC":
179
+ sponsors.add(node["sponsorEntity"]["login"])
180
+ else:
181
+ private_sponsors += 1
182
+
183
+ return Sponsors(
184
+ sponsors=sponsors,
185
+ private_sponsors=private_sponsors,
186
+ )
187
+
188
+ def _get_discussion_category_id(self) -> str:
189
+
190
+ query = """
191
+ query GetDiscussionCategoryId($owner: String!, $repositoryName: String!) {
192
+ repository(owner: $owner, name: $repositoryName) {
193
+ discussionCategories(first:100) {
194
+ nodes {
195
+ name
196
+ id
197
+ }
198
+ }
199
+ }
200
+ }
201
+ """
202
+
203
+ _, response = self._github.requester.graphql_query(
204
+ query,
205
+ {
206
+ "owner": self.repository.owner.login,
207
+ "repositoryName": self.repository.name,
208
+ },
209
+ )
210
+
211
+ for node in response["data"]["repository"]["discussionCategories"]["nodes"]:
212
+ if node["name"] == self.discussion_category_name:
213
+ return node["id"]
214
+
215
+ raise AutopubException(f"Discussion category {self.discussion_category_name} not found")
216
+
217
+ def _create_discussion(self, release_info: ReleaseInfo) -> None:
218
+ mutation = """
219
+ mutation CreateDiscussion($repositoryId: ID!, $categoryId: ID!, $body: String!, $title: String!) {
220
+ createDiscussion(input: {repositoryId: $repositoryId, categoryId: $categoryId, body: $body, title: $title}) {
221
+ discussion {
222
+ id
223
+ }
224
+ }
225
+ }
226
+ """
227
+
228
+ self._github.requester.graphql_query(
229
+ mutation,
230
+ {
231
+ # TODO: repo.node_id is not yet been published to pypi
232
+ "repositoryId": self.repository.raw_data["node_id"],
233
+ "categoryId": self._get_discussion_category_id(),
234
+ "body": self._get_release_message(release_info),
235
+ "title": f"Release {release_info.version}",
236
+ },
237
+ )
238
+
239
+ def _get_pr_contributors(self) -> PRContributors:
240
+ pr: PullRequest = self.pull_request
241
+
242
+ pr_author = pr.user.login
243
+ pr_contributors = PRContributors(
244
+ pr_author=pr_author,
245
+ additional_contributors=set(),
246
+ reviewers=set(),
247
+ )
248
+
249
+ for commit in pr.get_commits():
250
+ if commit.author.login != pr_author:
251
+ pr_contributors["additional_contributors"].add(commit.author.login)
252
+
253
+ for commit_message in commit.commit.message.split("\n"):
254
+ if commit_message.startswith("Co-authored-by:"):
255
+ author = commit_message.split(":")[1].strip()
256
+ author_login = author.split(" ")[0]
257
+
258
+ if author_login != pr_author:
259
+ pr_contributors["additional_contributors"].add(author_login)
260
+
261
+ for review in pr.get_reviews():
262
+ if review.user.login != pr_author:
263
+ pr_contributors["reviewers"].add(review.user.login)
264
+
265
+ return pr_contributors
266
+
267
+ def on_release_notes_valid(
268
+ self, release_info: ReleaseInfo
269
+ ) -> None: # pragma: no cover
270
+ assert self.pull_request is not None
271
+
272
+ contributors = self._get_pr_contributors()
273
+ sponsors = self._get_sponsors()
274
+ discussion_category_id = self._get_discussion_category_id()
275
+
276
+ message = textwrap.dedent(
277
+ f"""
278
+ ## {release_info.version}
279
+
280
+ {release_info.release_notes}
281
+
282
+ This release was contributed by {contributors} in #{self.pull_request.number}
283
+
284
+ Sponsors: {sponsors}
285
+
286
+ Discussion: {discussion_category_id}
287
+ """
288
+ )
289
+
290
+ self._update_or_create_comment(message)
291
+
292
+ def on_release_notes_invalid(
293
+ self, exception: AutopubException
294
+ ) -> None: # pragma: no cover
295
+ # TODO: better message
296
+ self._update_or_create_comment(str(exception))
297
+
298
+ def _get_release_message(self, release_info: ReleaseInfo) -> str:
299
+ assert self.pull_request is not None
300
+
301
+ contributors = self._get_pr_contributors()
302
+ sponsors = self._get_sponsors()
303
+
304
+ message = textwrap.dedent(
305
+ f"""
306
+ ## {release_info.version}
307
+
308
+ {release_info.release_notes}
309
+
310
+ This release was contributed by @{contributors['pr_author']} in #{self.pull_request.number}
311
+ """
312
+ )
313
+
314
+ if contributors["additional_contributors"]:
315
+ additional_contributors = [f"@{contributor}" for contributor in contributors["additional_contributors"]]
316
+ message += f"\n\nAdditional contributors: {', '.join(additional_contributors)}"
317
+
318
+ if contributors["reviewers"]:
319
+ reviewers = [f"@{reviewer}" for reviewer in contributors["reviewers"]]
320
+ message += f"\n\nReviewers: {', '.join(reviewers)}"
321
+
322
+ if sponsors["sponsors"]:
323
+ sponsors = [f"@{sponsor}" for sponsor in sponsors["sponsors"]]
324
+ message += f"\n\nThanks to {', '.join(sponsors)}"
325
+ if sponsors["private_sponsors"]:
326
+ message += f" and the {sponsors['private_sponsors']} private sponsor(s)"
327
+
328
+ message += " for making this release possible ✨"
329
+
330
+ return message
331
+
332
+ def _create_release(self, release_info: ReleaseInfo) -> None:
333
+ message = self._get_release_message(release_info)
334
+
335
+ release = self.repository.create_git_release(
336
+ tag=release_info.version,
337
+ name=release_info.version,
338
+ message=message,
339
+ )
340
+
341
+ for asset in pathlib.Path("dist").glob("*"):
342
+ if asset.suffix in [".tar.gz", ".whl"]:
343
+ release.upload_asset(str(asset))
344
+
345
+ def post_publish(self, release_info: ReleaseInfo) -> None:
346
+ text = f"This PR was published as {release_info.version}"
347
+ assert self.pull_request is not None
348
+
349
+ self._update_or_create_comment(
350
+ text, marker="<!-- autopub-comment-published -->"
351
+ )
352
+
353
+ self._create_release(release_info)
354
+ self._create_discussion(release_info)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: autopub
3
- Version: 1.0.0a30
3
+ Version: 1.0.0a32
4
4
  Summary: Automatic package release upon pull request merge
5
5
  Home-page: https://github.com/autopub/autopub
6
6
  Author: Justin Mayer
@@ -15,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.13
15
15
  Provides-Extra: github
16
16
  Requires-Dist: dunamai (>=1.23.0,<2.0.0)
17
17
  Requires-Dist: pydantic (>=2.10.5,<3.0.0)
18
+ Requires-Dist: pygithub (>=2.5.0,<3.0.0)
18
19
  Requires-Dist: python-frontmatter (>=1.1.0,<2.0.0)
19
20
  Requires-Dist: rich (>=13.9.4,<14.0.0)
20
21
  Requires-Dist: tomlkit (>=0.13.2,<0.14.0)
@@ -1,18 +1,19 @@
1
- autopub/__init__.py,sha256=wbqzkmfNtka2-_iGOszJy10aFTKWvNdd_3Qe5Ykxrnw,7071
1
+ autopub/__init__.py,sha256=mJ7Ai6ygc43IuiaKbVBcLGj8oHMuZmJ3Bxq3rPgKmf8,7130
2
2
  autopub/cli/__init__.py,sha256=77rx1yYi8adLUZAW7Rs48CNVyYe4HgSMZ7pZyIxD1iQ,3894
3
3
  autopub/exceptions.py,sha256=gNUbiG3_fVmNjhk2kyueQHPSifNgQf0Bl6IDNvkVhxQ,1534
4
4
  autopub/plugin_loader.py,sha256=i8nz0cmu5eaSRJ2Hx5KxbA-tOMR2mjcz8fdRg4zkGLE,1813
5
5
  autopub/plugins/__init__.py,sha256=57ewn1lhZYKKuVL49l3cgqAN9LQfCRzdsgSZimIAkls,2030
6
6
  autopub/plugins/bump_version.py,sha256=-tTGQR0v8K3Bto1Y8UcmkEs4WnExeF1QyHKHPUKgll8,1565
7
7
  autopub/plugins/git.py,sha256=d0SMLc6hwuk0eymj8aHyu3_cEd-7x4fhkwu35wPPV4k,1054
8
+ autopub/plugins/github.py,sha256=5VSL8L9jdc7nTGizxBe8m5FzhAgfwUUiwQYwS76hynU,11717
8
9
  autopub/plugins/pdm.py,sha256=Pczye06fKg8_HMJDkEfMXQyvao9rZ7sqzTHFd6lLEpU,532
9
10
  autopub/plugins/poetry.py,sha256=d2LvW9RI7ZB3reBOXbcp1mqWmzQ06Uyg_T-MxTvlSBg,517
10
11
  autopub/plugins/update_changelog.py,sha256=g_6flOP5wocZbjOaYSayWxobL3ld8f0wT78nFtAIkFc,1586
11
12
  autopub/plugins/uv.py,sha256=goo8QxaD3FVJ1c3xOSmN1hikZTCUXN8jWNac1S5uDDY,1089
12
13
  autopub/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
14
  autopub/types.py,sha256=gY1WR93XZVFS7vf5JMSmL_h5z7zO51-rtmZ6MYsh3so,1043
14
- autopub-1.0.0a30.dist-info/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
15
- autopub-1.0.0a30.dist-info/METADATA,sha256=hf4CteUV8mLmhe5XICfFohOPpgnVvqBNWLJO5y_nqfw,951
16
- autopub-1.0.0a30.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
17
- autopub-1.0.0a30.dist-info/entry_points.txt,sha256=oeTav5NgCxif6mcZ_HeVGgGv5LzS4DwdI01nr4bO1IM,43
18
- autopub-1.0.0a30.dist-info/RECORD,,
15
+ autopub-1.0.0a32.dist-info/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
16
+ autopub-1.0.0a32.dist-info/METADATA,sha256=HfV4-XOB_ape8p7NpGCOCoe-xsq3GrSml08ZisaEcgE,992
17
+ autopub-1.0.0a32.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
18
+ autopub-1.0.0a32.dist-info/entry_points.txt,sha256=oeTav5NgCxif6mcZ_HeVGgGv5LzS4DwdI01nr4bO1IM,43
19
+ autopub-1.0.0a32.dist-info/RECORD,,